From ed573320a69585c7958d704bd03d253a6be08f58 Mon Sep 17 00:00:00 2001 From: Ben Moses Date: Fri, 3 Jul 2026 17:34:10 +0100 Subject: [PATCH 1/2] feat(schema): resolve symlinked schema directories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema discovery filtered directory entries with `Dirent.isDirectory()`, which reports the raw entry type and returns false for symlinks — even those pointing at a real directory. As a result, symlinked schema dirs in the user/project/package schema locations were silently skipped. Add a shared `isSchemaDir()` helper that accepts real directories and dereferences symlinks (via statSync) to admit symlinked directories while still rejecting symlinks-to-files and broken links. Use it at all six discovery sites in resolver.ts and in `schema validate` in schema.ts. --- src/commands/schema.ts | 3 ++- src/core/artifact-graph/resolver.ts | 40 ++++++++++++++++++++++++----- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/commands/schema.ts b/src/commands/schema.ts index 7f8d0b7888..33473fa192 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -8,6 +8,7 @@ import { getProjectSchemasDir, getUserSchemasDir, getPackageSchemasDir, + isSchemaDir, listSchemas, } from '../core/artifact-graph/resolver.js'; import { parseSchema, SchemaValidationError } from '../core/artifact-graph/schema.js'; @@ -437,7 +438,7 @@ export function registerSchemaCommand(program: Command): void { let anyInvalid = false; for (const entry of entries) { - if (!entry.isDirectory()) continue; + if (!isSchemaDir(projectSchemasDir, entry)) continue; const schemaDir = path.join(projectSchemasDir, entry.name); const schemaPath = path.join(schemaDir, 'schema.yaml'); diff --git a/src/core/artifact-graph/resolver.ts b/src/core/artifact-graph/resolver.ts index 9ccd48abaf..b444245f11 100644 --- a/src/core/artifact-graph/resolver.ts +++ b/src/core/artifact-graph/resolver.ts @@ -45,6 +45,34 @@ export function getProjectSchemasDir(projectRoot: string): string { return path.join(projectRoot, 'openspec', 'schemas'); } +/** + * Determines whether a directory entry represents a schema directory candidate. + * + * Returns true for real directories and for symlinks whose target is a + * directory. `fs.Dirent.isDirectory()` reports the raw entry type, so a symlink + * (even one pointing at a directory) has `isDirectory() === false`; we + * dereference such entries via `fs.statSync` to admit symlinked schema dirs + * while still rejecting symlinks-to-files and broken/dangling symlinks. + * + * @param parentDir - The directory containing the entry + * @param entry - The directory entry from `fs.readdirSync(..., { withFileTypes: true })` + */ +export function isSchemaDir(parentDir: string, entry: fs.Dirent): boolean { + if (entry.isDirectory()) { + return true; + } + if (entry.isSymbolicLink()) { + try { + // statSync follows the link; isDirectory() reflects the target type. + return fs.statSync(path.join(parentDir, entry.name)).isDirectory(); + } catch { + // Broken symlink (dangling target) — statSync throws; treat as non-dir. + return false; + } + } + return false; +} + /** * Resolves a schema name to its directory path. * @@ -165,7 +193,7 @@ export function listSchemas(projectRoot?: string): string[] { const packageDir = getPackageSchemasDir(); if (fs.existsSync(packageDir)) { for (const entry of fs.readdirSync(packageDir, { withFileTypes: true })) { - if (entry.isDirectory()) { + if (isSchemaDir(packageDir, entry)) { const schemaPath = path.join(packageDir, entry.name, 'schema.yaml'); if (fs.existsSync(schemaPath)) { schemas.add(entry.name); @@ -178,7 +206,7 @@ export function listSchemas(projectRoot?: string): string[] { const userDir = getUserSchemasDir(); if (fs.existsSync(userDir)) { for (const entry of fs.readdirSync(userDir, { withFileTypes: true })) { - if (entry.isDirectory()) { + if (isSchemaDir(userDir, entry)) { const schemaPath = path.join(userDir, entry.name, 'schema.yaml'); if (fs.existsSync(schemaPath)) { schemas.add(entry.name); @@ -192,7 +220,7 @@ export function listSchemas(projectRoot?: string): string[] { const projectDir = getProjectSchemasDir(projectRoot); if (fs.existsSync(projectDir)) { for (const entry of fs.readdirSync(projectDir, { withFileTypes: true })) { - if (entry.isDirectory()) { + if (isSchemaDir(projectDir, entry)) { const schemaPath = path.join(projectDir, entry.name, 'schema.yaml'); if (fs.existsSync(schemaPath)) { schemas.add(entry.name); @@ -230,7 +258,7 @@ export function listSchemasWithInfo(projectRoot?: string): SchemaInfo[] { const projectDir = getProjectSchemasDir(projectRoot); if (fs.existsSync(projectDir)) { for (const entry of fs.readdirSync(projectDir, { withFileTypes: true })) { - if (entry.isDirectory()) { + if (isSchemaDir(projectDir, entry)) { const schemaPath = path.join(projectDir, entry.name, 'schema.yaml'); if (fs.existsSync(schemaPath)) { try { @@ -255,7 +283,7 @@ export function listSchemasWithInfo(projectRoot?: string): SchemaInfo[] { const userDir = getUserSchemasDir(); if (fs.existsSync(userDir)) { for (const entry of fs.readdirSync(userDir, { withFileTypes: true })) { - if (entry.isDirectory() && !seenNames.has(entry.name)) { + if (isSchemaDir(userDir, entry) && !seenNames.has(entry.name)) { const schemaPath = path.join(userDir, entry.name, 'schema.yaml'); if (fs.existsSync(schemaPath)) { try { @@ -279,7 +307,7 @@ export function listSchemasWithInfo(projectRoot?: string): SchemaInfo[] { const packageDir = getPackageSchemasDir(); if (fs.existsSync(packageDir)) { for (const entry of fs.readdirSync(packageDir, { withFileTypes: true })) { - if (entry.isDirectory() && !seenNames.has(entry.name)) { + if (isSchemaDir(packageDir, entry) && !seenNames.has(entry.name)) { const schemaPath = path.join(packageDir, entry.name, 'schema.yaml'); if (fs.existsSync(schemaPath)) { try { From 20458d26d7d5405bef9aa46722304099af20256f Mon Sep 17 00:00:00 2001 From: Ben Moses Date: Fri, 3 Jul 2026 17:34:58 +0100 Subject: [PATCH 2/2] test(schema): cover symlinked schema directory resolution Add unit tests for isSchemaDir (real dir, symlink-to-dir, symlink-to-file, broken symlink, regular file) plus integration tests confirming listSchemas and listSchemasWithInfo pick up a symlinked user schema dir while ignoring symlinks whose target is a file. --- test/core/artifact-graph/resolver.test.ts | 123 ++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/test/core/artifact-graph/resolver.test.ts b/test/core/artifact-graph/resolver.test.ts index 484cc3b406..3436151933 100644 --- a/test/core/artifact-graph/resolver.test.ts +++ b/test/core/artifact-graph/resolver.test.ts @@ -11,6 +11,7 @@ import { getPackageSchemasDir, getUserSchemasDir, getProjectSchemasDir, + isSchemaDir, } from '../../../src/core/artifact-graph/resolver.js'; describe('artifact-graph/resolver', () => { @@ -648,4 +649,126 @@ artifacts: expect(sharedSchema!.description).toBe('Project shared'); // project version wins }); }); + + // ========================================================================= + // Symlinked schema directory tests + // ========================================================================= + + describe('isSchemaDir', () => { + it('should return true for a real directory', () => { + const dir = path.join(tempDir, 'real-dir'); + fs.mkdirSync(dir); + const [entry] = fs.readdirSync(tempDir, { withFileTypes: true }); + expect(isSchemaDir(tempDir, entry)).toBe(true); + }); + + it('should return true for a symlink pointing at a directory', () => { + const target = path.join(tempDir, 'target-dir'); + fs.mkdirSync(target); + const link = path.join(tempDir, 'linked-dir'); + fs.symlinkSync(target, link, 'dir'); + + const entry = fs + .readdirSync(tempDir, { withFileTypes: true }) + .find(e => e.name === 'linked-dir')!; + expect(entry.isDirectory()).toBe(false); // sanity: Dirent sees the link, not the target + expect(entry.isSymbolicLink()).toBe(true); + expect(isSchemaDir(tempDir, entry)).toBe(true); + }); + + it('should return false for a symlink pointing at a file', () => { + const targetFile = path.join(tempDir, 'target-file'); + fs.writeFileSync(targetFile, 'contents'); + const link = path.join(tempDir, 'linked-file'); + fs.symlinkSync(targetFile, link, 'file'); + + const entry = fs + .readdirSync(tempDir, { withFileTypes: true }) + .find(e => e.name === 'linked-file')!; + expect(isSchemaDir(tempDir, entry)).toBe(false); + }); + + it('should return false for a broken symlink', () => { + const link = path.join(tempDir, 'broken-link'); + fs.symlinkSync(path.join(tempDir, 'does-not-exist'), link, 'dir'); + + const entry = fs + .readdirSync(tempDir, { withFileTypes: true }) + .find(e => e.name === 'broken-link')!; + expect(isSchemaDir(tempDir, entry)).toBe(false); + }); + + it('should return false for a regular file', () => { + const file = path.join(tempDir, 'plain-file'); + fs.writeFileSync(file, 'contents'); + const entry = fs + .readdirSync(tempDir, { withFileTypes: true }) + .find(e => e.name === 'plain-file')!; + expect(isSchemaDir(tempDir, entry)).toBe(false); + }); + }); + + describe('listSchemas with symlinked directories', () => { + it('should include a user schema that is a symlink to a directory', () => { + process.env.XDG_DATA_HOME = tempDir; + const userSchemasBase = path.join(tempDir, 'openspec', 'schemas'); + fs.mkdirSync(userSchemasBase, { recursive: true }); + + // Real schema dir stored elsewhere, linked into the user schemas dir. + const realSchemaDir = path.join(tempDir, 'shared', 'linked-schema'); + fs.mkdirSync(realSchemaDir, { recursive: true }); + fs.writeFileSync( + path.join(realSchemaDir, 'schema.yaml'), + 'name: linked\nversion: 1\nartifacts: []' + ); + fs.symlinkSync(realSchemaDir, path.join(userSchemasBase, 'linked-schema'), 'dir'); + + const schemas = listSchemas(); + expect(schemas).toContain('linked-schema'); + }); + + it('should not include a symlink pointing at a schema file', () => { + process.env.XDG_DATA_HOME = tempDir; + const userSchemasBase = path.join(tempDir, 'openspec', 'schemas'); + fs.mkdirSync(userSchemasBase, { recursive: true }); + + // A symlink whose target is a file, not a directory. + const targetFile = path.join(tempDir, 'schema.yaml'); + fs.writeFileSync(targetFile, 'name: nope\nversion: 1\nartifacts: []'); + fs.symlinkSync(targetFile, path.join(userSchemasBase, 'file-link'), 'file'); + + const schemas = listSchemas(); + expect(schemas).not.toContain('file-link'); + }); + }); + + describe('listSchemasWithInfo with symlinked directories', () => { + it('should include a symlinked user schema with source: user', () => { + process.env.XDG_DATA_HOME = tempDir; + const userSchemasBase = path.join(tempDir, 'openspec', 'schemas'); + fs.mkdirSync(userSchemasBase, { recursive: true }); + + const realSchemaDir = path.join(tempDir, 'shared', 'linked-info'); + fs.mkdirSync(realSchemaDir, { recursive: true }); + fs.writeFileSync( + path.join(realSchemaDir, 'schema.yaml'), + `name: linked-info +version: 1 +description: Linked info +artifacts: + - id: a + generates: a.md + description: A + template: a.md +` + ); + fs.symlinkSync(realSchemaDir, path.join(userSchemasBase, 'linked-info'), 'dir'); + + const schemas = listSchemasWithInfo(); + const linked = schemas.find(s => s.name === 'linked-info'); + expect(linked).toBeDefined(); + expect(linked!.source).toBe('user'); + expect(linked!.description).toBe('Linked info'); + }); + }); });