Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/commands/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand Down
40 changes: 34 additions & 6 deletions src/core/artifact-graph/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
123 changes: 123 additions & 0 deletions test/core/artifact-graph/resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
getPackageSchemasDir,
getUserSchemasDir,
getProjectSchemasDir,
isSchemaDir,
} from '../../../src/core/artifact-graph/resolver.js';

describe('artifact-graph/resolver', () => {
Expand Down Expand Up @@ -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');
});
});
});
Loading