Skip to content

Commit ea8e93d

Browse files
fix: follow symlinks when scanning directories for schemas, specs, and changes
Dirent.isDirectory() returns false for symlinks — it reports the entry type, not the resolved target type. This caused every readdir scan in the codebase to silently skip symlinked directories, breaking monorepo setups that symlink schema/spec/change directories into openspec/.
1 parent afdca0d commit ea8e93d

17 files changed

Lines changed: 189 additions & 30 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@fission-ai/openspec": patch
3+
---
4+
5+
fix: Symlinked directories are now correctly detected when scanning `openspec/schemas/`, `openspec/specs/`, `openspec/changes/`, and artifact output subdirectories. Monorepo setups that symlink directories into these locations would get silent failures (e.g., "Unknown schema" errors, missing specs/changes in listings) because `Dirent.isDirectory()` returns `false` for symlinks, causing those entries to be skipped during discovery.

src/commands/change.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ChangeParser } from '../core/parsers/change-parser.js';
66
import { Change } from '../core/schemas/index.js';
77
import { isInteractive } from '../utils/interactive.js';
88
import { getActiveChangeIds } from '../utils/item-discovery.js';
9+
import { isDirectoryEntrySync } from '../utils/file-system.js';
910

1011
// Constants for better maintainability
1112
const ARCHIVE_DIR = 'archive';
@@ -244,7 +245,7 @@ export class ChangeCommand {
244245
const entries = await fs.readdir(changesPath, { withFileTypes: true });
245246
const result: string[] = [];
246247
for (const entry of entries) {
247-
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === ARCHIVE_DIR) continue;
248+
if (!isDirectoryEntrySync(entry, changesPath) || entry.name.startsWith('.') || entry.name === ARCHIVE_DIR) continue;
248249
const proposalPath = path.join(changesPath, entry.name, 'proposal.md');
249250
try {
250251
await fs.access(proposalPath);

src/commands/schema.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
listSchemas,
1212
} from '../core/artifact-graph/resolver.js';
1313
import { parseSchema, SchemaValidationError } from '../core/artifact-graph/schema.js';
14+
import { isDirectoryEntrySync } from '../utils/file-system.js';
1415
import type { SchemaYaml, Artifact } from '../core/artifact-graph/types.js';
1516

1617
/**
@@ -241,7 +242,7 @@ function copyDirRecursive(src: string, dest: string): void {
241242
const srcPath = path.join(src, entry.name);
242243
const destPath = path.join(dest, entry.name);
243244

244-
if (entry.isDirectory()) {
245+
if (isDirectoryEntrySync(entry, src)) {
245246
copyDirRecursive(srcPath, destPath);
246247
} else {
247248
fs.copyFileSync(srcPath, destPath);
@@ -437,7 +438,7 @@ export function registerSchemaCommand(program: Command): void {
437438
let anyInvalid = false;
438439

439440
for (const entry of entries) {
440-
if (!entry.isDirectory()) continue;
441+
if (!isDirectoryEntrySync(entry, projectSchemasDir)) continue;
441442

442443
const schemaDir = path.join(projectSchemasDir, entry.name);
443444
const schemaPath = path.join(schemaDir, 'schema.yaml');

src/commands/spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Validator } from '../core/validation/validator.js';
66
import type { Spec } from '../core/schemas/index.js';
77
import { isInteractive } from '../utils/interactive.js';
88
import { getSpecIds } from '../utils/item-discovery.js';
9+
import { isDirectoryEntrySync } from '../utils/file-system.js';
910

1011
const SPECS_DIR = 'openspec/specs';
1112

@@ -149,7 +150,7 @@ export function registerSpecCommand(rootProgram: typeof program) {
149150
}
150151

151152
const specs = readdirSync(SPECS_DIR, { withFileTypes: true })
152-
.filter(dirent => dirent.isDirectory())
153+
.filter(dirent => isDirectoryEntrySync(dirent, SPECS_DIR))
153154
.map(dirent => {
154155
const specPath = join(SPECS_DIR, dirent.name, 'spec.md');
155156
if (existsSync(specPath)) {

src/commands/workflow/instructions.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import ora from 'ora';
99
import path from 'path';
1010
import * as fs from 'fs';
11+
import { isDirectoryEntrySync, isFileEntrySync } from '../../utils/file-system.js';
1112
import {
1213
loadChangeContext,
1314
generateInstructions,
@@ -275,12 +276,12 @@ function artifactOutputExists(changeDir: string, generates: string): boolean {
275276
try {
276277
const entries = fs.readdirSync(dir, { withFileTypes: true });
277278
for (const entry of entries) {
278-
if (entry.isDirectory()) {
279+
if (isDirectoryEntrySync(entry, dir)) {
279280
// For ** patterns, recurse into subdirectories
280281
if (generates.includes('**') && hasMatchingFiles(path.join(dir, entry.name))) {
281282
return true;
282283
}
283-
} else if (entry.isFile()) {
284+
} else if (isFileEntrySync(entry, dir)) {
284285
// Check if file matches expected extension (or any file if no extension specified)
285286
if (!expectedExt || entry.name.endsWith(expectedExt)) {
286287
return true;

src/commands/workflow/shared.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import chalk from 'chalk';
99
import path from 'path';
1010
import * as fs from 'fs';
11+
import { isDirectoryEntrySync } from '../../utils/file-system.js';
1112
import { getSchemaDir, listSchemas } from '../../core/artifact-graph/index.js';
1213
import { validateChangeName } from '../../utils/change-utils.js';
1314

@@ -95,7 +96,7 @@ export async function getAvailableChanges(projectRoot: string): Promise<string[]
9596
try {
9697
const entries = await fs.promises.readdir(changesPath, { withFileTypes: true });
9798
return entries
98-
.filter((e) => e.isDirectory() && e.name !== 'archive' && !e.name.startsWith('.'))
99+
.filter((e) => isDirectoryEntrySync(e, changesPath) && e.name !== 'archive' && !e.name.startsWith('.'))
99100
.map((e) => e.name);
100101
} catch (error: unknown) {
101102
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];

src/core/archive.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { promises as fs } from 'fs';
22
import path from 'path';
3+
import { isDirectoryEntrySync } from '../utils/file-system.js';
34
import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';
45
import { Validator } from './validation/validator.js';
56
import chalk from 'chalk';
@@ -19,7 +20,7 @@ async function copyDirRecursive(src: string, dest: string): Promise<void> {
1920
for (const entry of entries) {
2021
const srcPath = path.join(src, entry.name);
2122
const destPath = path.join(dest, entry.name);
22-
if (entry.isDirectory()) {
23+
if (isDirectoryEntrySync(entry, src)) {
2324
await copyDirRecursive(srcPath, destPath);
2425
} else {
2526
await fs.copyFile(srcPath, destPath);
@@ -116,7 +117,7 @@ export class ArchiveCommand {
116117
try {
117118
const candidates = await fs.readdir(changeSpecsDir, { withFileTypes: true });
118119
for (const c of candidates) {
119-
if (c.isDirectory()) {
120+
if (isDirectoryEntrySync(c, changeSpecsDir)) {
120121
try {
121122
const candidatePath = path.join(changeSpecsDir, c.name, 'spec.md');
122123
await fs.access(candidatePath);
@@ -292,7 +293,7 @@ export class ArchiveCommand {
292293
// Get all directories in changes (excluding archive)
293294
const entries = await fs.readdir(changesDir, { withFileTypes: true });
294295
const changeDirs = entries
295-
.filter(entry => entry.isDirectory() && entry.name !== 'archive')
296+
.filter(entry => isDirectoryEntrySync(entry, changesDir) && entry.name !== 'archive')
296297
.map(entry => entry.name)
297298
.sort();
298299

src/core/artifact-graph/resolver.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as fs from 'node:fs';
22
import * as path from 'node:path';
33
import { fileURLToPath } from 'node:url';
44
import { getGlobalDataDir } from '../global-config.js';
5+
import { isDirectoryEntrySync } from '../../utils/file-system.js';
56
import { parseSchema, SchemaValidationError } from './schema.js';
67
import type { SchemaYaml } from './types.js';
78

@@ -165,7 +166,7 @@ export function listSchemas(projectRoot?: string): string[] {
165166
const packageDir = getPackageSchemasDir();
166167
if (fs.existsSync(packageDir)) {
167168
for (const entry of fs.readdirSync(packageDir, { withFileTypes: true })) {
168-
if (entry.isDirectory()) {
169+
if (isDirectoryEntrySync(entry, packageDir)) {
169170
const schemaPath = path.join(packageDir, entry.name, 'schema.yaml');
170171
if (fs.existsSync(schemaPath)) {
171172
schemas.add(entry.name);
@@ -178,7 +179,7 @@ export function listSchemas(projectRoot?: string): string[] {
178179
const userDir = getUserSchemasDir();
179180
if (fs.existsSync(userDir)) {
180181
for (const entry of fs.readdirSync(userDir, { withFileTypes: true })) {
181-
if (entry.isDirectory()) {
182+
if (isDirectoryEntrySync(entry, userDir)) {
182183
const schemaPath = path.join(userDir, entry.name, 'schema.yaml');
183184
if (fs.existsSync(schemaPath)) {
184185
schemas.add(entry.name);
@@ -192,7 +193,7 @@ export function listSchemas(projectRoot?: string): string[] {
192193
const projectDir = getProjectSchemasDir(projectRoot);
193194
if (fs.existsSync(projectDir)) {
194195
for (const entry of fs.readdirSync(projectDir, { withFileTypes: true })) {
195-
if (entry.isDirectory()) {
196+
if (isDirectoryEntrySync(entry, projectDir)) {
196197
const schemaPath = path.join(projectDir, entry.name, 'schema.yaml');
197198
if (fs.existsSync(schemaPath)) {
198199
schemas.add(entry.name);
@@ -230,7 +231,7 @@ export function listSchemasWithInfo(projectRoot?: string): SchemaInfo[] {
230231
const projectDir = getProjectSchemasDir(projectRoot);
231232
if (fs.existsSync(projectDir)) {
232233
for (const entry of fs.readdirSync(projectDir, { withFileTypes: true })) {
233-
if (entry.isDirectory()) {
234+
if (isDirectoryEntrySync(entry, projectDir)) {
234235
const schemaPath = path.join(projectDir, entry.name, 'schema.yaml');
235236
if (fs.existsSync(schemaPath)) {
236237
try {
@@ -255,7 +256,7 @@ export function listSchemasWithInfo(projectRoot?: string): SchemaInfo[] {
255256
const userDir = getUserSchemasDir();
256257
if (fs.existsSync(userDir)) {
257258
for (const entry of fs.readdirSync(userDir, { withFileTypes: true })) {
258-
if (entry.isDirectory() && !seenNames.has(entry.name)) {
259+
if (isDirectoryEntrySync(entry, userDir) && !seenNames.has(entry.name)) {
259260
const schemaPath = path.join(userDir, entry.name, 'schema.yaml');
260261
if (fs.existsSync(schemaPath)) {
261262
try {
@@ -279,7 +280,7 @@ export function listSchemasWithInfo(projectRoot?: string): SchemaInfo[] {
279280
const packageDir = getPackageSchemasDir();
280281
if (fs.existsSync(packageDir)) {
281282
for (const entry of fs.readdirSync(packageDir, { withFileTypes: true })) {
282-
if (entry.isDirectory() && !seenNames.has(entry.name)) {
283+
if (isDirectoryEntrySync(entry, packageDir) && !seenNames.has(entry.name)) {
283284
const schemaPath = path.join(packageDir, entry.name, 'schema.yaml');
284285
if (fs.existsSync(schemaPath)) {
285286
try {

src/core/list.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { promises as fs } from 'fs';
22
import path from 'path';
3+
import { isDirectoryEntrySync } from '../utils/file-system.js';
34
import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';
45
import { readFileSync } from 'fs';
56
import { join } from 'path';
@@ -28,7 +29,7 @@ async function getLastModified(dirPath: string): Promise<Date> {
2829
const entries = await fs.readdir(dir, { withFileTypes: true });
2930
for (const entry of entries) {
3031
const fullPath = path.join(dir, entry.name);
31-
if (entry.isDirectory()) {
32+
if (isDirectoryEntrySync(entry, dir)) {
3233
await walk(fullPath);
3334
} else {
3435
const stat = await fs.stat(fullPath);
@@ -91,7 +92,7 @@ export class ListCommand {
9192
// Get all directories in changes (excluding archive)
9293
const entries = await fs.readdir(changesDir, { withFileTypes: true });
9394
const changeDirs = entries
94-
.filter(entry => entry.isDirectory() && entry.name !== 'archive')
95+
.filter(entry => isDirectoryEntrySync(entry, changesDir) && entry.name !== 'archive')
9596
.map(entry => entry.name);
9697

9798
if (changeDirs.length === 0) {
@@ -161,7 +162,7 @@ export class ListCommand {
161162
}
162163

163164
const entries = await fs.readdir(specsDir, { withFileTypes: true });
164-
const specDirs = entries.filter(e => e.isDirectory()).map(e => e.name);
165+
const specDirs = entries.filter(e => isDirectoryEntrySync(e, specsDir)).map(e => e.name);
165166
if (specDirs.length === 0) {
166167
console.log('No specs found.');
167168
return;

src/core/parsers/change-parser.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { MarkdownParser, Section } from './markdown-parser.js';
22
import { Change, Delta, DeltaOperation, Requirement } from '../schemas/index.js';
33
import path from 'path';
44
import { promises as fs } from 'fs';
5+
import { isDirectoryEntrySync } from '../../utils/file-system.js';
56

67
interface DeltaSection {
78
operation: DeltaOperation;
@@ -59,7 +60,7 @@ export class ChangeParser extends MarkdownParser {
5960
const specDirs = await fs.readdir(specsDir, { withFileTypes: true });
6061

6162
for (const dir of specDirs) {
62-
if (!dir.isDirectory()) continue;
63+
if (!isDirectoryEntrySync(dir, specsDir)) continue;
6364

6465
const specName = dir.name;
6566
const specFile = path.join(specsDir, specName, 'spec.md');

0 commit comments

Comments
 (0)