diff --git a/docs/experimental-workflow.md b/docs/experimental-workflow.md index 98263b065..47df3f227 100644 --- a/docs/experimental-workflow.md +++ b/docs/experimental-workflow.md @@ -473,7 +473,7 @@ Artifacts form a directed acyclic graph (DAG). Dependencies are **enablers**, no ### Custom Schemas -Create your own workflow by adding a schema to `~/.local/share/openspec/schemas/`: +Create your own workflow by adding a schema to `~/.local/share/openspec/schemas/` (or `/openspec/schemas/` for project-level schemas): ``` ~/.local/share/openspec/schemas/research-first/ diff --git a/src/core/artifact-graph/resolver.ts b/src/core/artifact-graph/resolver.ts index 52b2620fd..fb02d97fe 100644 --- a/src/core/artifact-graph/resolver.ts +++ b/src/core/artifact-graph/resolver.ts @@ -29,6 +29,13 @@ export function getPackageSchemasDir(): string { return path.join(path.dirname(currentFile), '..', '..', '..', 'schemas'); } +/** + * Gets the project's local schemas directory path. + */ +export function getProjectSchemasDir(): string { + return path.join(process.cwd(), 'openspec', 'schemas'); +} + /** * Gets the user's schema override directory path. */ @@ -40,21 +47,29 @@ export function getUserSchemasDir(): string { * Resolves a schema name to its directory path. * * Resolution order: - * 1. User override: ${XDG_DATA_HOME}/openspec/schemas//schema.yaml - * 2. Package built-in: /schemas//schema.yaml + * 1. Project-local: /openspec/schemas//schema.yaml + * 2. User override: ${XDG_DATA_HOME}/openspec/schemas//schema.yaml + * 3. Package built-in: /schemas//schema.yaml * * @param name - Schema name (e.g., "spec-driven") * @returns The path to the schema directory, or null if not found */ export function getSchemaDir(name: string): string | null { - // 1. Check user override directory + // 1. Check project-local directory + const projectDir = path.join(getProjectSchemasDir(), name); + const projectSchemaPath = path.join(projectDir, 'schema.yaml'); + if (fs.existsSync(projectSchemaPath)) { + return projectDir; + } + + // 2. Check user override directory const userDir = path.join(getUserSchemasDir(), name); const userSchemaPath = path.join(userDir, 'schema.yaml'); if (fs.existsSync(userSchemaPath)) { return userDir; } - // 2. Check package built-in directory + // 3. Check package built-in directory const packageDir = path.join(getPackageSchemasDir(), name); const packageSchemaPath = path.join(packageDir, 'schema.yaml'); if (fs.existsSync(packageSchemaPath)) { @@ -123,7 +138,7 @@ export function resolveSchema(name: string): SchemaYaml { /** * Lists all available schema names. - * Combines user override and package built-in schemas. + * Combines project-local, user override and package built-in schemas. */ export function listSchemas(): string[] { const schemas = new Set(); @@ -154,6 +169,19 @@ export function listSchemas(): string[] { } } + // Add project-local schemas (may override both user and package schemas) + const projectDir = getProjectSchemasDir(); + if (fs.existsSync(projectDir)) { + for (const entry of fs.readdirSync(projectDir, { withFileTypes: true })) { + if (entry.isDirectory()) { + const schemaPath = path.join(projectDir, entry.name, 'schema.yaml'); + if (fs.existsSync(schemaPath)) { + schemas.add(entry.name); + } + } + } + } + return Array.from(schemas).sort(); } @@ -164,7 +192,7 @@ export interface SchemaInfo { name: string; description: string; artifacts: string[]; - source: 'package' | 'user'; + source: 'package' | 'user' | 'project'; } /** @@ -175,11 +203,35 @@ export function listSchemasWithInfo(): SchemaInfo[] { const schemas: SchemaInfo[] = []; const seenNames = new Set(); - // Add user override schemas first (they take precedence) + // Add project-local schemas first (highest precedence) + const projectDir = getProjectSchemasDir(); + if (fs.existsSync(projectDir)) { + for (const entry of fs.readdirSync(projectDir, { withFileTypes: true })) { + if (entry.isDirectory()) { + const schemaPath = path.join(projectDir, entry.name, 'schema.yaml'); + if (fs.existsSync(schemaPath)) { + try { + const schema = parseSchema(fs.readFileSync(schemaPath, 'utf-8')); + schemas.push({ + name: entry.name, + description: schema.description || '', + artifacts: schema.artifacts.map((a) => a.id), + source: 'project', + }); + seenNames.add(entry.name); + } catch { + // Skip invalid schemas + } + } + } + } + } + + // Add user override schemas next const userDir = getUserSchemasDir(); if (fs.existsSync(userDir)) { for (const entry of fs.readdirSync(userDir, { withFileTypes: true })) { - if (entry.isDirectory()) { + if (entry.isDirectory() && !seenNames.has(entry.name)) { const schemaPath = path.join(userDir, entry.name, 'schema.yaml'); if (fs.existsSync(schemaPath)) { try { diff --git a/test/core/artifact-graph/resolver.test.ts b/test/core/artifact-graph/resolver.test.ts index a87624ccd..e40c04ef9 100644 --- a/test/core/artifact-graph/resolver.test.ts +++ b/test/core/artifact-graph/resolver.test.ts @@ -1,13 +1,15 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { resolveSchema, listSchemas, + listSchemasWithInfo, SchemaLoadError, getSchemaDir, getPackageSchemasDir, + getProjectSchemasDir, getUserSchemasDir, } from '../../../src/core/artifact-graph/resolver.js'; @@ -42,6 +44,23 @@ describe('artifact-graph/resolver', () => { }); }); + describe('getProjectSchemasDir', () => { + it('should return openspec/schemas relative to cwd', () => { + const projectDir = getProjectSchemasDir(); + expect(projectDir).toBe(path.join(process.cwd(), 'openspec', 'schemas')); + }); + + it('should return correct path when cwd changes', () => { + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue('/mock/project'); + try { + const projectDir = getProjectSchemasDir(); + expect(projectDir).toBe('/mock/project/openspec/schemas'); + } finally { + cwdSpy.mockRestore(); + } + }); + }); + describe('getSchemaDir', () => { it('should return null for non-existent schema', () => { const dir = getSchemaDir('nonexistent-schema'); @@ -67,6 +86,56 @@ describe('artifact-graph/resolver', () => { const dir = getSchemaDir('spec-driven'); expect(dir).toBe(userSchemaDir); }); + + it('should prefer project-local directory over user override', () => { + // Set up user override + process.env.XDG_DATA_HOME = tempDir; + const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven'); + fs.mkdirSync(userSchemaDir, { recursive: true }); + fs.writeFileSync( + path.join(userSchemaDir, 'schema.yaml'), + 'name: user-override\nversion: 1\nartifacts: []' + ); + + // Set up project-local + const projectSchemaDir = path.join(tempDir, 'project', 'openspec', 'schemas', 'spec-driven'); + fs.mkdirSync(projectSchemaDir, { recursive: true }); + fs.writeFileSync( + path.join(projectSchemaDir, 'schema.yaml'), + 'name: project-local\nversion: 1\nartifacts: []' + ); + + // Mock cwd to point to project directory + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(path.join(tempDir, 'project')); + try { + const dir = getSchemaDir('spec-driven'); + expect(dir).toBe(projectSchemaDir); + } finally { + cwdSpy.mockRestore(); + } + }); + + it('should fall back to user override when project-local not found', () => { + process.env.XDG_DATA_HOME = tempDir; + const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'my-schema'); + fs.mkdirSync(userSchemaDir, { recursive: true }); + fs.writeFileSync( + path.join(userSchemaDir, 'schema.yaml'), + 'name: user-schema\nversion: 1\nartifacts: []' + ); + + // Project-local does not have this schema + const projectDir = path.join(tempDir, 'project', 'openspec', 'schemas'); + fs.mkdirSync(projectDir, { recursive: true }); + + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(path.join(tempDir, 'project')); + try { + const dir = getSchemaDir('my-schema'); + expect(dir).toBe(userSchemaDir); + } finally { + cwdSpy.mockRestore(); + } + }); }); describe('resolveSchema', () => { @@ -323,5 +392,186 @@ version: [[[invalid yaml expect(schemas).toContain('valid-schema'); expect(schemas).not.toContain('empty-dir'); }); + + it('should include project-local schemas', () => { + const projectDir = path.join(tempDir, 'project', 'openspec', 'schemas', 'project-schema'); + fs.mkdirSync(projectDir, { recursive: true }); + fs.writeFileSync( + path.join(projectDir, 'schema.yaml'), + 'name: project-schema\nversion: 1\nartifacts: []' + ); + + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(path.join(tempDir, 'project')); + try { + const schemas = listSchemas(); + expect(schemas).toContain('project-schema'); + } finally { + cwdSpy.mockRestore(); + } + }); + + it('should deduplicate project-local schemas with same name as user/package schemas', () => { + process.env.XDG_DATA_HOME = tempDir; + const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven'); + fs.mkdirSync(userSchemaDir, { recursive: true }); + fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), 'name: user\nversion: 1\nartifacts: []'); + + const projectSchemaDir = path.join(tempDir, 'project', 'openspec', 'schemas', 'spec-driven'); + fs.mkdirSync(projectSchemaDir, { recursive: true }); + fs.writeFileSync(path.join(projectSchemaDir, 'schema.yaml'), 'name: project\nversion: 1\nartifacts: []'); + + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(path.join(tempDir, 'project')); + try { + const schemas = listSchemas(); + const count = schemas.filter((s) => s === 'spec-driven').length; + expect(count).toBe(1); + } finally { + cwdSpy.mockRestore(); + } + }); + }); + + describe('listSchemasWithInfo', () => { + it('should return schema info with source field', () => { + const schemas = listSchemasWithInfo(); + + expect(schemas.length).toBeGreaterThan(0); + const specDriven = schemas.find((s) => s.name === 'spec-driven'); + expect(specDriven).toBeDefined(); + expect(specDriven?.source).toBe('package'); + expect(specDriven?.description).toBeDefined(); + expect(specDriven?.artifacts).toBeDefined(); + expect(Array.isArray(specDriven?.artifacts)).toBe(true); + }); + + it('should mark user override schemas with source "user"', () => { + process.env.XDG_DATA_HOME = tempDir; + const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'user-custom'); + fs.mkdirSync(userSchemaDir, { recursive: true }); + fs.writeFileSync( + path.join(userSchemaDir, 'schema.yaml'), + `name: user-custom +version: 1 +description: A custom user schema +artifacts: + - id: artifact1 + generates: artifact1.md + description: First artifact + template: artifact1.md +` + ); + + const schemas = listSchemasWithInfo(); + + const userSchema = schemas.find((s) => s.name === 'user-custom'); + expect(userSchema).toBeDefined(); + expect(userSchema?.source).toBe('user'); + expect(userSchema?.description).toBe('A custom user schema'); + expect(userSchema?.artifacts).toContain('artifact1'); + }); + + it('should mark project-local schemas with source "project"', () => { + const projectSchemaDir = path.join(tempDir, 'project', 'openspec', 'schemas', 'project-custom'); + fs.mkdirSync(projectSchemaDir, { recursive: true }); + fs.writeFileSync( + path.join(projectSchemaDir, 'schema.yaml'), + `name: project-custom +version: 1 +description: A project-local schema +artifacts: + - id: task + generates: task.md + description: Task artifact + template: task.md +` + ); + + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(path.join(tempDir, 'project')); + try { + const schemas = listSchemasWithInfo(); + + const projectSchema = schemas.find((s) => s.name === 'project-custom'); + expect(projectSchema).toBeDefined(); + expect(projectSchema?.source).toBe('project'); + expect(projectSchema?.description).toBe('A project-local schema'); + expect(projectSchema?.artifacts).toContain('task'); + } finally { + cwdSpy.mockRestore(); + } + }); + + it('should give project-local schemas highest precedence', () => { + process.env.XDG_DATA_HOME = tempDir; + + // Create user override for spec-driven + const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven'); + fs.mkdirSync(userSchemaDir, { recursive: true }); + fs.writeFileSync( + path.join(userSchemaDir, 'schema.yaml'), + `name: spec-driven +version: 1 +description: User override +artifacts: + - id: user-artifact + generates: user.md + description: User artifact + template: user.md +` + ); + + // Create project-local for spec-driven + const projectSchemaDir = path.join(tempDir, 'project', 'openspec', 'schemas', 'spec-driven'); + fs.mkdirSync(projectSchemaDir, { recursive: true }); + fs.writeFileSync( + path.join(projectSchemaDir, 'schema.yaml'), + `name: spec-driven +version: 1 +description: Project local +artifacts: + - id: project-artifact + generates: project.md + description: Project artifact + template: project.md +` + ); + + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(path.join(tempDir, 'project')); + try { + const schemas = listSchemasWithInfo(); + + const specDriven = schemas.find((s) => s.name === 'spec-driven'); + expect(specDriven).toBeDefined(); + expect(specDriven?.source).toBe('project'); + expect(specDriven?.description).toBe('Project local'); + } finally { + cwdSpy.mockRestore(); + } + }); + + it('should return sorted list', () => { + const schemas = listSchemasWithInfo(); + + const names = schemas.map((s) => s.name); + const sorted = [...names].sort(); + expect(names).toEqual(sorted); + }); + + it('should skip invalid schemas silently', () => { + const projectSchemaDir = path.join(tempDir, 'project', 'openspec', 'schemas', 'invalid-schema'); + fs.mkdirSync(projectSchemaDir, { recursive: true }); + fs.writeFileSync( + path.join(projectSchemaDir, 'schema.yaml'), + 'not valid yaml: [[[invalid' + ); + + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(path.join(tempDir, 'project')); + try { + // Should not throw + const schemas = listSchemasWithInfo(); + expect(schemas.find((s) => s.name === 'invalid-schema')).toBeUndefined(); + } finally { + cwdSpy.mockRestore(); + } + }); }); });