diff --git a/openspec/changes/project-local-schemas/design.md b/openspec/changes/project-local-schemas/design.md new file mode 100644 index 00000000..3c0dae68 --- /dev/null +++ b/openspec/changes/project-local-schemas/design.md @@ -0,0 +1,117 @@ +## Context + +OpenSpec currently resolves schemas from two locations: +1. User override: `~/.local/share/openspec/schemas//` +2. Package built-in: `/schemas//` + +This change adds a third, highest-priority level: project-local schemas at `./openspec/schemas//`. + +The resolver functions in `src/core/artifact-graph/resolver.ts` currently don't take a `projectRoot` parameter because user and package paths are absolute. To support project-local schemas, we need to pass project root context into the resolver. + +## Goals / Non-Goals + +**Goals:** +- Enable version-controlled custom workflow schemas +- Allow teams to share schemas via git without per-machine setup +- Maintain backward compatibility with existing resolver API +- Integrate with `config.yaml`'s `schema` field (from project-config change) + +**Non-Goals:** +- Schema inheritance or `extends` keyword +- Template-level overrides (partial forks) +- Schema management CLI commands (`openspec schema copy/which/diff/reset`) +- Validation that project-local schema names don't conflict with built-ins (shadowing is intentional) + +## Decisions + +### Decision 1: Add optional `projectRoot` parameter to resolver functions + +**Choice:** Add optional `projectRoot?: string` parameter to resolver functions rather than using `process.cwd()` internally. + +**Alternatives considered:** +- Use `process.cwd()` internally: Simpler API but implicit, harder to test, doesn't match existing codebase patterns +- Create separate project-aware functions: No breaking changes but awkward API, callers must compose + +**Rationale:** The codebase already follows a pattern where CLI commands get project root via `process.cwd()` and pass it down to functions that need it. Adding an optional parameter maintains backward compatibility while enabling explicit, testable behavior. + +**Affected functions:** +```typescript +getSchemaDir(name: string, projectRoot?: string): string | null +listSchemas(projectRoot?: string): string[] +listSchemasWithInfo(projectRoot?: string): SchemaInfo[] +resolveSchema(name: string, projectRoot?: string): SchemaYaml +``` + +### Decision 2: Resolution order is project → user → package + +**Choice:** Project-local schemas have highest priority, then user overrides, then package built-ins. + +**Rationale:** +- Project-local should win because it represents team intent (version controlled, shared) +- User overrides still useful for personal experimentation without affecting team +- Package built-ins are the fallback defaults + +``` +1. ./openspec/schemas// # Project-local (highest) +2. ~/.local/share/openspec/schemas// # User override +3. /schemas// # Package built-in (lowest) +``` + +### Decision 3: Add `getProjectSchemasDir()` helper function + +**Choice:** Create a dedicated function to get the project schemas directory path. + +```typescript +function getProjectSchemasDir(projectRoot: string): string { + return path.join(projectRoot, 'openspec', 'schemas'); +} +``` + +**Rationale:** Matches existing pattern with `getPackageSchemasDir()` and `getUserSchemasDir()`. Keeps path logic centralized. + +### Decision 4: Extend `SchemaInfo.source` to include `'project'` + +**Choice:** Update the source type from `'package' | 'user'` to `'project' | 'user' | 'package'`. + +**Rationale:** Consumers need to distinguish project-local schemas for display purposes (e.g., `schemasCommand` output). + +### Decision 5: No special handling for schema name conflicts + +**Choice:** If a project-local schema has the same name as a built-in (e.g., `spec-driven`), the project-local version wins. No warning, no error. + +**Rationale:** This is intentional shadowing. Teams may want to customize a built-in schema while keeping the same name for familiarity. + +## Risks / Trade-offs + +### Risk: Confusion when project schema shadows built-in +A team could create `openspec/schemas/spec-driven/` that shadows the built-in, causing confusion when someone expects default behavior. + +**Mitigation:** The `openspec schemas` command shows the source of each schema. Users can see `spec-driven (project)` vs `spec-driven (package)`. + +### Risk: Missing projectRoot parameter +If callers forget to pass `projectRoot`, project-local schemas won't be found. + +**Mitigation:** +- Make the change incrementally, updating call sites that need project-local support +- Existing behavior (user + package only) is preserved when `projectRoot` is undefined + +### Trade-off: Optional parameter vs required +Making `projectRoot` optional maintains backward compatibility but means some code paths may silently skip project-local resolution. + +**Accepted:** Backward compatibility is more important. The main entry points (CLI commands) will always pass `projectRoot`. + +## Implementation Approach + +1. **Update `resolver.ts`:** + - Add `getProjectSchemasDir(projectRoot: string)` function + - Update `getSchemaDir()` to check project-local first when `projectRoot` provided + - Update `listSchemas()` to include project schemas when `projectRoot` provided + - Update `listSchemasWithInfo()` to return `source: 'project'` for project schemas + - Update `SchemaInfo` type to include `'project'` in source union + +2. **Update `artifact-workflow.ts`:** + - Update `schemasCommand` to pass `projectRoot` and display source labels + +3. **Update call sites:** + - Any existing code that needs project-local resolution should pass `projectRoot` + - `config.yaml` schema resolution already has access to `projectRoot` diff --git a/openspec/changes/project-local-schemas/specs/schema-resolution/spec.md b/openspec/changes/project-local-schemas/specs/schema-resolution/spec.md new file mode 100644 index 00000000..201afc23 --- /dev/null +++ b/openspec/changes/project-local-schemas/specs/schema-resolution/spec.md @@ -0,0 +1,88 @@ +## ADDED Requirements + +### Requirement: Project-local schema resolution + +The system SHALL resolve schemas from the project-local directory (`./openspec/schemas//`) with highest priority when a `projectRoot` is provided. + +#### Scenario: Project-local schema takes precedence over user override +- **WHEN** a schema named "my-workflow" exists at `./openspec/schemas/my-workflow/schema.yaml` +- **AND** a schema named "my-workflow" exists at `~/.local/share/openspec/schemas/my-workflow/schema.yaml` +- **AND** `getSchemaDir("my-workflow", projectRoot)` is called +- **THEN** the system SHALL return the project-local path + +#### Scenario: Project-local schema takes precedence over package built-in +- **WHEN** a schema named "spec-driven" exists at `./openspec/schemas/spec-driven/schema.yaml` +- **AND** "spec-driven" is a package built-in schema +- **AND** `getSchemaDir("spec-driven", projectRoot)` is called +- **THEN** the system SHALL return the project-local path + +#### Scenario: Falls back to user override when no project-local schema +- **WHEN** no schema named "my-workflow" exists at `./openspec/schemas/my-workflow/` +- **AND** a schema named "my-workflow" exists at `~/.local/share/openspec/schemas/my-workflow/schema.yaml` +- **AND** `getSchemaDir("my-workflow", projectRoot)` is called +- **THEN** the system SHALL return the user override path + +#### Scenario: Falls back to package built-in when no project-local or user schema +- **WHEN** no schema named "spec-driven" exists at `./openspec/schemas/spec-driven/` +- **AND** no schema named "spec-driven" exists at `~/.local/share/openspec/schemas/spec-driven/` +- **AND** "spec-driven" is a package built-in schema +- **AND** `getSchemaDir("spec-driven", projectRoot)` is called +- **THEN** the system SHALL return the package built-in path + +#### Scenario: Backward compatibility when projectRoot not provided +- **WHEN** `getSchemaDir("my-workflow")` is called without a `projectRoot` parameter +- **THEN** the system SHALL only check user override and package built-in locations +- **AND** the system SHALL NOT check project-local location + +### Requirement: Project schemas directory helper + +The system SHALL provide a `getProjectSchemasDir(projectRoot)` function that returns the project-local schemas directory path. + +#### Scenario: Returns correct path +- **WHEN** `getProjectSchemasDir("/path/to/project")` is called +- **THEN** the system SHALL return `/path/to/project/openspec/schemas` + +### Requirement: List schemas includes project-local + +The system SHALL include project-local schemas when listing available schemas if `projectRoot` is provided. + +#### Scenario: Project-local schemas appear in list +- **WHEN** a schema named "team-flow" exists at `./openspec/schemas/team-flow/schema.yaml` +- **AND** `listSchemas(projectRoot)` is called +- **THEN** the returned list SHALL include "team-flow" + +#### Scenario: Project-local schema shadows same-named user schema in list +- **WHEN** a schema named "custom" exists at both project-local and user override locations +- **AND** `listSchemas(projectRoot)` is called +- **THEN** the returned list SHALL include "custom" exactly once + +#### Scenario: Backward compatibility for listSchemas +- **WHEN** `listSchemas()` is called without a `projectRoot` parameter +- **THEN** the system SHALL only include user override and package built-in schemas + +### Requirement: Schema info includes project source + +The system SHALL indicate `source: 'project'` for project-local schemas in `listSchemasWithInfo()` results. + +#### Scenario: Project-local schema shows project source +- **WHEN** a schema named "team-flow" exists at `./openspec/schemas/team-flow/schema.yaml` +- **AND** `listSchemasWithInfo(projectRoot)` is called +- **THEN** the schema info for "team-flow" SHALL have `source: 'project'` + +#### Scenario: User override schema shows user source +- **WHEN** a schema named "my-custom" exists only at `~/.local/share/openspec/schemas/my-custom/` +- **AND** `listSchemasWithInfo(projectRoot)` is called +- **THEN** the schema info for "my-custom" SHALL have `source: 'user'` + +#### Scenario: Package built-in schema shows package source +- **WHEN** "spec-driven" exists only as a package built-in +- **AND** `listSchemasWithInfo(projectRoot)` is called +- **THEN** the schema info for "spec-driven" SHALL have `source: 'package'` + +### Requirement: Schemas command shows source + +The `openspec schemas` command SHALL display the source of each schema. + +#### Scenario: Display format includes source +- **WHEN** user runs `openspec schemas` +- **THEN** the output SHALL show each schema with its source label (project, user, or package) diff --git a/openspec/changes/project-local-schemas/tasks.md b/openspec/changes/project-local-schemas/tasks.md new file mode 100644 index 00000000..0a200956 --- /dev/null +++ b/openspec/changes/project-local-schemas/tasks.md @@ -0,0 +1,28 @@ +## 1. Update Resolver Types and Helpers + +- [x] 1.1 Update `SchemaInfo.source` type to include `'project'` in `src/core/artifact-graph/resolver.ts` +- [x] 1.2 Add `getProjectSchemasDir(projectRoot: string): string` function + +## 2. Update Schema Resolution Functions + +- [x] 2.1 Update `getSchemaDir(name, projectRoot?)` to check project-local first when projectRoot provided +- [x] 2.2 Update `resolveSchema(name, projectRoot?)` to pass projectRoot to getSchemaDir +- [x] 2.3 Update `listSchemas(projectRoot?)` to include project-local schemas +- [x] 2.4 Update `listSchemasWithInfo(projectRoot?)` to include project schemas with `source: 'project'` + +## 3. Update CLI Commands + +- [x] 3.1 Update `schemasCommand` to pass projectRoot and display source labels in output + +## 4. Update Call Sites + +- [x] 4.1 Review and update call sites that need project-local schema support to pass projectRoot + +## 5. Testing + +- [x] 5.1 Add unit tests for `getProjectSchemasDir()` +- [x] 5.2 Add unit tests for project-local schema resolution priority +- [x] 5.3 Add unit tests for backward compatibility (no projectRoot = user + package only) +- [x] 5.4 Add unit tests for `listSchemas()` including project schemas +- [x] 5.5 Add unit tests for `listSchemasWithInfo()` with `source: 'project'` +- [x] 5.6 Add integration test with temp project containing local schema diff --git a/src/commands/artifact-workflow.ts b/src/commands/artifact-workflow.ts index f319cdeb..a3e2770e 100644 --- a/src/commands/artifact-workflow.ts +++ b/src/commands/artifact-workflow.ts @@ -159,11 +159,14 @@ async function validateChangeExists( /** * Validates that a schema exists and returns available schemas if not. + * + * @param schemaName - The schema name to validate + * @param projectRoot - Optional project root for project-local schema resolution */ -function validateSchemaExists(schemaName: string): string { - const schemaDir = getSchemaDir(schemaName); +function validateSchemaExists(schemaName: string, projectRoot?: string): string { + const schemaDir = getSchemaDir(schemaName, projectRoot); if (!schemaDir) { - const availableSchemas = listSchemas(); + const availableSchemas = listSchemas(projectRoot); throw new Error( `Schema '${schemaName}' not found. Available schemas:\n ${availableSchemas.join('\n ')}` ); @@ -190,7 +193,7 @@ async function statusCommand(options: StatusOptions): Promise { // Validate schema if explicitly provided if (options.schema) { - validateSchemaExists(options.schema); + validateSchemaExists(options.schema, projectRoot); } // loadChangeContext will auto-detect schema from metadata if not provided @@ -260,7 +263,7 @@ async function instructionsCommand( // Validate schema if explicitly provided if (options.schema) { - validateSchemaExists(options.schema); + validateSchemaExists(options.schema, projectRoot); } // loadChangeContext will auto-detect schema from metadata if not provided @@ -598,7 +601,7 @@ async function applyInstructionsCommand(options: ApplyInstructionsOptions): Prom // Validate schema if explicitly provided if (options.schema) { - validateSchemaExists(options.schema); + validateSchemaExists(options.schema, projectRoot); } // generateApplyInstructions uses loadChangeContext which auto-detects schema @@ -682,27 +685,40 @@ interface TemplatesOptions { interface TemplateInfo { artifactId: string; templatePath: string; - source: 'user' | 'package'; + source: 'project' | 'user' | 'package'; } async function templatesCommand(options: TemplatesOptions): Promise { const spinner = ora('Loading templates...').start(); try { - const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA); - const schema = resolveSchema(schemaName); + const projectRoot = process.cwd(); + const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA, projectRoot); + const schema = resolveSchema(schemaName, projectRoot); const graph = ArtifactGraph.fromSchema(schema); - const schemaDir = getSchemaDir(schemaName)!; - - // Determine if this is a user override or package built-in - const { getUserSchemasDir } = await import('../core/artifact-graph/resolver.js'); + const schemaDir = getSchemaDir(schemaName, projectRoot)!; + + // Determine the source (project, user, or package) + const { + getUserSchemasDir, + getProjectSchemasDir, + } = await import('../core/artifact-graph/resolver.js'); + const projectSchemasDir = getProjectSchemasDir(projectRoot); const userSchemasDir = getUserSchemasDir(); - const isUserOverride = schemaDir.startsWith(userSchemasDir); + + let source: 'project' | 'user' | 'package'; + if (schemaDir.startsWith(projectSchemasDir)) { + source = 'project'; + } else if (schemaDir.startsWith(userSchemasDir)) { + source = 'user'; + } else { + source = 'package'; + } const templates: TemplateInfo[] = graph.getAllArtifacts().map((artifact) => ({ artifactId: artifact.id, templatePath: path.join(schemaDir, 'templates', artifact.template), - source: isUserOverride ? 'user' : 'package', + source, })); spinner.stop(); @@ -717,7 +733,7 @@ async function templatesCommand(options: TemplatesOptions): Promise { } console.log(`Schema: ${schemaName}`); - console.log(`Source: ${isUserOverride ? 'user override' : 'package built-in'}`); + console.log(`Source: ${source}`); console.log(); for (const t of templates) { @@ -749,16 +765,17 @@ async function newChangeCommand(name: string | undefined, options: NewChangeOpti throw new Error(validation.error); } + const projectRoot = process.cwd(); + // Validate schema if provided if (options.schema) { - validateSchemaExists(options.schema); + validateSchemaExists(options.schema, projectRoot); } const schemaDisplay = options.schema ? ` with schema '${options.schema}'` : ''; const spinner = ora(`Creating change '${name}'${schemaDisplay}...`).start(); try { - const projectRoot = process.cwd(); const result = await createChange(projectRoot, name, { schema: options.schema }); // If description provided, create README.md with description @@ -926,7 +943,7 @@ ${template.content} } else { // Prompt for config creation try { - const configResult = await promptForConfig(); + const configResult = await promptForConfig(projectRoot); if (configResult.createConfig && configResult.schema) { // Build config object @@ -1052,7 +1069,8 @@ interface SchemasOptions { } async function schemasCommand(options: SchemasOptions): Promise { - const schemas = listSchemasWithInfo(); + const projectRoot = process.cwd(); + const schemas = listSchemasWithInfo(projectRoot); if (options.json) { console.log(JSON.stringify(schemas, null, 2)); @@ -1063,7 +1081,12 @@ async function schemasCommand(options: SchemasOptions): Promise { console.log(); for (const schema of schemas) { - const sourceLabel = schema.source === 'user' ? chalk.dim(' (user override)') : ''; + let sourceLabel = ''; + if (schema.source === 'project') { + sourceLabel = chalk.cyan(' (project)'); + } else if (schema.source === 'user') { + sourceLabel = chalk.dim(' (user override)'); + } console.log(` ${chalk.bold(schema.name)}${sourceLabel}`); console.log(` ${schema.description}`); console.log(` Artifacts: ${schema.artifacts.join(' → ')}`); diff --git a/src/core/artifact-graph/instruction-loader.ts b/src/core/artifact-graph/instruction-loader.ts index 1ef2471b..195f403a 100644 --- a/src/core/artifact-graph/instruction-loader.ts +++ b/src/core/artifact-graph/instruction-loader.ts @@ -37,6 +37,8 @@ export interface ChangeContext { changeName: string; /** Path to the change directory */ changeDir: string; + /** Project root directory */ + projectRoot: string; } /** @@ -114,11 +116,16 @@ export interface ChangeStatus { * * @param schemaName - Schema name (e.g., "spec-driven") * @param templatePath - Relative path within the templates directory (e.g., "proposal.md") + * @param projectRoot - Optional project root for project-local schema resolution * @returns The template content * @throws TemplateLoadError if the template cannot be loaded */ -export function loadTemplate(schemaName: string, templatePath: string): string { - const schemaDir = getSchemaDir(schemaName); +export function loadTemplate( + schemaName: string, + templatePath: string, + projectRoot?: string +): string { + const schemaDir = getSchemaDir(schemaName, projectRoot); if (!schemaDir) { throw new TemplateLoadError( `Schema '${schemaName}' not found`, @@ -169,7 +176,7 @@ export function loadChangeContext( // Resolve schema: explicit > metadata > default const resolvedSchemaName = resolveSchemaForChange(changeDir, schemaName); - const schema = resolveSchema(resolvedSchemaName); + const schema = resolveSchema(resolvedSchemaName, projectRoot); const graph = ArtifactGraph.fromSchema(schema); const completed = detectCompleted(graph, changeDir); @@ -179,6 +186,7 @@ export function loadChangeContext( schemaName: resolvedSchemaName, changeName, changeDir, + projectRoot, }; } @@ -206,7 +214,7 @@ export function generateInstructions( throw new Error(`Artifact '${artifactId}' not found in schema '${context.schemaName}'`); } - const templateContent = loadTemplate(context.schemaName, artifact.template); + const templateContent = loadTemplate(context.schemaName, artifact.template, context.projectRoot); const dependencies = getDependencyInfo(artifact, context.graph, context.completed); const unlocks = getUnlockedArtifacts(context.graph, artifactId); @@ -214,10 +222,13 @@ export function generateInstructions( let enrichedTemplate = ''; let projectConfig = null; + // Use projectRoot from context if not explicitly provided + const effectiveProjectRoot = projectRoot ?? context.projectRoot; + // Try to read project config - if (projectRoot) { + if (effectiveProjectRoot) { try { - projectConfig = readProjectConfig(projectRoot); + projectConfig = readProjectConfig(effectiveProjectRoot); } catch { // If config read fails, continue without config } @@ -315,7 +326,7 @@ function getUnlockedArtifacts(graph: ArtifactGraph, artifactId: string): string[ */ export function formatChangeStatus(context: ChangeContext): ChangeStatus { // Load schema to get apply phase configuration - const schema = resolveSchema(context.schemaName); + const schema = resolveSchema(context.schemaName, context.projectRoot); const applyRequires = schema.apply?.requires ?? schema.artifacts.map(a => a.id); const artifacts = context.graph.getAllArtifacts(); diff --git a/src/core/artifact-graph/resolver.ts b/src/core/artifact-graph/resolver.ts index 52b2620f..9ccd48ab 100644 --- a/src/core/artifact-graph/resolver.ts +++ b/src/core/artifact-graph/resolver.ts @@ -36,25 +36,51 @@ export function getUserSchemasDir(): string { return path.join(getGlobalDataDir(), 'schemas'); } +/** + * Gets the project-local schemas directory path. + * @param projectRoot - The project root directory + * @returns The path to the project's schemas directory + */ +export function getProjectSchemasDir(projectRoot: string): string { + return path.join(projectRoot, 'openspec', 'schemas'); +} + /** * 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 + * Resolution order (when projectRoot is provided): + * 1. Project-local: /openspec/schemas//schema.yaml + * 2. User override: ${XDG_DATA_HOME}/openspec/schemas//schema.yaml + * 3. Package built-in: /schemas//schema.yaml + * + * When projectRoot is not provided, only user override and package built-in are checked + * (backward compatible behavior). * * @param name - Schema name (e.g., "spec-driven") + * @param projectRoot - Optional project root directory for project-local schema resolution * @returns The path to the schema directory, or null if not found */ -export function getSchemaDir(name: string): string | null { - // 1. Check user override directory +export function getSchemaDir( + name: string, + projectRoot?: string +): string | null { + // 1. Check project-local directory (if projectRoot provided) + if (projectRoot) { + const projectDir = path.join(getProjectSchemasDir(projectRoot), 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)) { @@ -67,21 +93,26 @@ export function getSchemaDir(name: string): string | null { /** * Resolves a schema name to a SchemaYaml object. * - * Resolution order: - * 1. User override: ${XDG_DATA_HOME}/openspec/schemas//schema.yaml - * 2. Package built-in: /schemas//schema.yaml + * Resolution order (when projectRoot is provided): + * 1. Project-local: /openspec/schemas//schema.yaml + * 2. User override: ${XDG_DATA_HOME}/openspec/schemas//schema.yaml + * 3. Package built-in: /schemas//schema.yaml + * + * When projectRoot is not provided, only user override and package built-in are checked + * (backward compatible behavior). * * @param name - Schema name (e.g., "spec-driven") + * @param projectRoot - Optional project root directory for project-local schema resolution * @returns The resolved schema object * @throws Error if schema is not found in any location */ -export function resolveSchema(name: string): SchemaYaml { +export function resolveSchema(name: string, projectRoot?: string): SchemaYaml { // Normalize name (remove .yaml extension if provided) const normalizedName = name.replace(/\.ya?ml$/, ''); - const schemaDir = getSchemaDir(normalizedName); + const schemaDir = getSchemaDir(normalizedName, projectRoot); if (!schemaDir) { - const availableSchemas = listSchemas(); + const availableSchemas = listSchemas(projectRoot); throw new Error( `Schema '${normalizedName}' not found. Available schemas: ${availableSchemas.join(', ')}` ); @@ -123,9 +154,11 @@ 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. + * + * @param projectRoot - Optional project root directory for project-local schema resolution */ -export function listSchemas(): string[] { +export function listSchemas(projectRoot?: string): string[] { const schemas = new Set(); // Add package built-in schemas @@ -154,6 +187,21 @@ export function listSchemas(): string[] { } } + // Add project-local schemas (if projectRoot provided) + if (projectRoot) { + const projectDir = getProjectSchemasDir(projectRoot); + 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,22 +212,50 @@ export interface SchemaInfo { name: string; description: string; artifacts: string[]; - source: 'package' | 'user'; + source: 'project' | 'user' | 'package'; } /** * Lists all available schemas with their descriptions and artifact lists. * Useful for agent skills to present schema selection to users. + * + * @param projectRoot - Optional project root directory for project-local schema resolution */ -export function listSchemasWithInfo(): SchemaInfo[] { +export function listSchemasWithInfo(projectRoot?: string): SchemaInfo[] { const schemas: SchemaInfo[] = []; const seenNames = new Set(); - // Add user override schemas first (they take precedence) + // Add project-local schemas first (highest priority, if projectRoot provided) + if (projectRoot) { + const projectDir = getProjectSchemasDir(projectRoot); + 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 (if not overridden by project) 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 { @@ -199,7 +275,7 @@ export function listSchemasWithInfo(): SchemaInfo[] { } } - // Add package built-in schemas (if not overridden) + // Add package built-in schemas (if not overridden by project or user) const packageDir = getPackageSchemasDir(); if (fs.existsSync(packageDir)) { for (const entry of fs.readdirSync(packageDir, { withFileTypes: true })) { diff --git a/src/core/config-prompts.ts b/src/core/config-prompts.ts index 65ffbbfb..b015ee5a 100644 --- a/src/core/config-prompts.ts +++ b/src/core/config-prompts.ts @@ -33,10 +33,13 @@ export interface ConfigPromptResult { * Prompt user to create project config interactively. * Used by experimental setup command. * + * @param projectRoot - Optional project root for project-local schema resolution * @returns Config prompt result * @throws ExitPromptError if user cancels (Ctrl+C) */ -export async function promptForConfig(): Promise { +export async function promptForConfig( + projectRoot?: string +): Promise { // Dynamic imports to prevent pre-commit hook hangs (see #367) const { confirm, select, editor, checkbox } = await import('@inquirer/prompts'); @@ -51,7 +54,7 @@ export async function promptForConfig(): Promise { } // Get available schemas - const schemas = listSchemasWithInfo(); + const schemas = listSchemasWithInfo(projectRoot); if (schemas.length === 0) { throw new Error('No schemas found. Cannot create config.'); @@ -90,7 +93,7 @@ export async function promptForConfig(): Promise { if (addRules) { // Load the selected schema to get artifact list - const schema = resolveSchema(selectedSchema); + const schema = resolveSchema(selectedSchema, projectRoot); const artifactIds = schema.artifacts.map((a) => a.id); // Let user select which artifacts to add rules for diff --git a/src/utils/change-metadata.ts b/src/utils/change-metadata.ts index aad50a46..b4374958 100644 --- a/src/utils/change-metadata.ts +++ b/src/utils/change-metadata.ts @@ -25,11 +25,15 @@ export class ChangeMetadataError extends Error { * Validates that a schema name is valid (exists in available schemas). * * @param schemaName - The schema name to validate + * @param projectRoot - Optional project root for project-local schema resolution * @returns The validated schema name * @throws Error if schema is not found */ -export function validateSchemaName(schemaName: string): string { - const availableSchemas = listSchemas(); +export function validateSchemaName( + schemaName: string, + projectRoot?: string +): string { + const availableSchemas = listSchemas(projectRoot); if (!availableSchemas.includes(schemaName)) { throw new Error( `Unknown schema '${schemaName}'. Available: ${availableSchemas.join(', ')}` @@ -43,16 +47,18 @@ export function validateSchemaName(schemaName: string): string { * * @param changeDir - The path to the change directory * @param metadata - The metadata to write + * @param projectRoot - Optional project root for project-local schema resolution * @throws ChangeMetadataError if validation fails or write fails */ export function writeChangeMetadata( changeDir: string, - metadata: ChangeMetadata + metadata: ChangeMetadata, + projectRoot?: string ): void { const metaPath = path.join(changeDir, METADATA_FILENAME); // Validate schema exists - validateSchemaName(metadata.schema); + validateSchemaName(metadata.schema, projectRoot); // Validate with Zod const parseResult = ChangeMetadataSchema.safeParse(metadata); @@ -81,10 +87,14 @@ export function writeChangeMetadata( * Reads change metadata from .openspec.yaml in the change directory. * * @param changeDir - The path to the change directory + * @param projectRoot - Optional project root for project-local schema resolution * @returns The validated metadata, or null if no metadata file exists * @throws ChangeMetadataError if the file exists but is invalid */ -export function readChangeMetadata(changeDir: string): ChangeMetadata | null { +export function readChangeMetadata( + changeDir: string, + projectRoot?: string +): ChangeMetadata | null { const metaPath = path.join(changeDir, METADATA_FILENAME); if (!fs.existsSync(metaPath)) { @@ -125,7 +135,7 @@ export function readChangeMetadata(changeDir: string): ChangeMetadata | null { } // Validate that the schema exists - const availableSchemas = listSchemas(); + const availableSchemas = listSchemas(projectRoot); if (!availableSchemas.includes(parseResult.data.schema)) { throw new ChangeMetadataError( `Unknown schema '${parseResult.data.schema}'. Available: ${availableSchemas.join(', ')}`, @@ -153,6 +163,9 @@ export function resolveSchemaForChange( changeDir: string, explicitSchema?: string ): string { + // Derive project root from changeDir (changeDir is typically projectRoot/openspec/changes/change-name) + const projectRoot = path.resolve(changeDir, '../../..'); + // 1. Explicit override wins if (explicitSchema) { return explicitSchema; @@ -160,7 +173,7 @@ export function resolveSchemaForChange( // 2. Try reading from metadata try { - const metadata = readChangeMetadata(changeDir); + const metadata = readChangeMetadata(changeDir, projectRoot); if (metadata?.schema) { return metadata.schema; } @@ -169,8 +182,6 @@ export function resolveSchemaForChange( } // 3. Try reading from project config - // Derive project root from changeDir (changeDir is typically projectRoot/openspec/changes/change-name) - const projectRoot = path.resolve(changeDir, '../../..'); try { const config = readProjectConfig(projectRoot); if (config?.schema) { diff --git a/src/utils/change-utils.ts b/src/utils/change-utils.ts index b1bdf3d3..44178b00 100644 --- a/src/utils/change-utils.ts +++ b/src/utils/change-utils.ts @@ -136,7 +136,7 @@ export async function createChange( } // Validate the resolved schema - validateSchemaName(schemaName); + validateSchemaName(schemaName, projectRoot); // Build the change directory path const changeDir = path.join(projectRoot, 'openspec', 'changes', name); @@ -154,7 +154,7 @@ export async function createChange( writeChangeMetadata(changeDir, { schema: schemaName, created: today, - }); + }, projectRoot); return { schema: schemaName }; } diff --git a/test/core/artifact-graph/resolver.test.ts b/test/core/artifact-graph/resolver.test.ts index a87624cc..320aa427 100644 --- a/test/core/artifact-graph/resolver.test.ts +++ b/test/core/artifact-graph/resolver.test.ts @@ -5,10 +5,12 @@ import * as os from 'node:os'; import { resolveSchema, listSchemas, + listSchemasWithInfo, SchemaLoadError, getSchemaDir, getPackageSchemasDir, getUserSchemasDir, + getProjectSchemasDir, } from '../../../src/core/artifact-graph/resolver.js'; describe('artifact-graph/resolver', () => { @@ -324,4 +326,336 @@ version: [[[invalid yaml expect(schemas).not.toContain('empty-dir'); }); }); + + // ========================================================================= + // Project-local schema tests + // ========================================================================= + + describe('getProjectSchemasDir', () => { + it('should return correct path', () => { + const projectRoot = '/path/to/project'; + const schemasDir = getProjectSchemasDir(projectRoot); + expect(schemasDir).toBe('/path/to/project/openspec/schemas'); + }); + + it('should work with relative-looking paths', () => { + const schemasDir = getProjectSchemasDir('./my-project'); + expect(schemasDir).toBe('my-project/openspec/schemas'); + }); + }); + + describe('getSchemaDir with projectRoot', () => { + it('should return null for non-existent project schema', () => { + const dir = getSchemaDir('nonexistent-schema', tempDir); + expect(dir).toBeNull(); + }); + + it('should prefer project-local schema over user override', () => { + // Set up user override + 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-version\nversion: 1\nartifacts: []' + ); + + // Set up project-local schema + const projectRoot = path.join(tempDir, 'project'); + const projectSchemaDir = path.join(projectRoot, 'openspec', 'schemas', 'my-schema'); + fs.mkdirSync(projectSchemaDir, { recursive: true }); + fs.writeFileSync( + path.join(projectSchemaDir, 'schema.yaml'), + 'name: project-version\nversion: 2\nartifacts: []' + ); + + const dir = getSchemaDir('my-schema', projectRoot); + expect(dir).toBe(projectSchemaDir); + }); + + it('should prefer project-local schema over package built-in', () => { + // Set up project-local schema that overrides built-in + const projectRoot = path.join(tempDir, 'project'); + const projectSchemaDir = path.join(projectRoot, 'openspec', 'schemas', 'spec-driven'); + fs.mkdirSync(projectSchemaDir, { recursive: true }); + fs.writeFileSync( + path.join(projectSchemaDir, 'schema.yaml'), + 'name: project-spec-driven\nversion: 99\nartifacts: []\n' + ); + + const dir = getSchemaDir('spec-driven', projectRoot); + expect(dir).toBe(projectSchemaDir); + }); + + it('should fall back to user override when no project-local schema', () => { + // Set up user override only + process.env.XDG_DATA_HOME = tempDir; + const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'user-only-schema'); + fs.mkdirSync(userSchemaDir, { recursive: true }); + fs.writeFileSync( + path.join(userSchemaDir, 'schema.yaml'), + 'name: user-only\nversion: 1\nartifacts: []' + ); + + const projectRoot = path.join(tempDir, 'project'); + fs.mkdirSync(projectRoot, { recursive: true }); + + const dir = getSchemaDir('user-only-schema', projectRoot); + expect(dir).toBe(userSchemaDir); + }); + + it('should fall back to package built-in when no project or user schema', () => { + const projectRoot = path.join(tempDir, 'project'); + fs.mkdirSync(projectRoot, { recursive: true }); + + const dir = getSchemaDir('spec-driven', projectRoot); + expect(dir).not.toBeNull(); + // Should be package path, not project or user + expect(dir).not.toContain(projectRoot); + }); + + it('should maintain backward compatibility when projectRoot not provided', () => { + // Set up user override + 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-version\nversion: 1\nartifacts: []' + ); + + // Set up project-local schema (should be ignored when projectRoot not provided) + const projectRoot = path.join(tempDir, 'project'); + const projectSchemaDir = path.join(projectRoot, 'openspec', 'schemas', 'my-schema'); + fs.mkdirSync(projectSchemaDir, { recursive: true }); + fs.writeFileSync( + path.join(projectSchemaDir, 'schema.yaml'), + 'name: project-version\nversion: 2\nartifacts: []' + ); + + // Without projectRoot, should get user version + const dir = getSchemaDir('my-schema'); + expect(dir).toBe(userSchemaDir); + }); + }); + + describe('resolveSchema with projectRoot', () => { + it('should resolve project-local schema', () => { + const projectRoot = path.join(tempDir, 'project'); + const projectSchemaDir = path.join(projectRoot, 'openspec', 'schemas', 'team-workflow'); + fs.mkdirSync(projectSchemaDir, { recursive: true }); + fs.writeFileSync( + path.join(projectSchemaDir, 'schema.yaml'), + `name: team-workflow +version: 1 +description: Team workflow +artifacts: + - id: spec + generates: spec.md + description: Specification + template: spec.md +` + ); + + const schema = resolveSchema('team-workflow', projectRoot); + expect(schema.name).toBe('team-workflow'); + expect(schema.version).toBe(1); + }); + + it('should prefer project-local over user override when resolving', () => { + // Set up user override + process.env.XDG_DATA_HOME = tempDir; + const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'shared-schema'); + fs.mkdirSync(userSchemaDir, { recursive: true }); + fs.writeFileSync( + path.join(userSchemaDir, 'schema.yaml'), + `name: user-version +version: 1 +artifacts: + - id: user-artifact + generates: user.md + description: User artifact + template: user.md +` + ); + + // Set up project-local schema + const projectRoot = path.join(tempDir, 'project'); + const projectSchemaDir = path.join(projectRoot, 'openspec', 'schemas', 'shared-schema'); + fs.mkdirSync(projectSchemaDir, { recursive: true }); + fs.writeFileSync( + path.join(projectSchemaDir, 'schema.yaml'), + `name: project-version +version: 2 +artifacts: + - id: project-artifact + generates: project.md + description: Project artifact + template: project.md +` + ); + + const schema = resolveSchema('shared-schema', projectRoot); + expect(schema.name).toBe('project-version'); + expect(schema.version).toBe(2); + }); + }); + + describe('listSchemas with projectRoot', () => { + it('should include project-local schemas', () => { + const projectRoot = path.join(tempDir, 'project'); + const projectSchemaDir = path.join(projectRoot, 'openspec', 'schemas', 'team-workflow'); + fs.mkdirSync(projectSchemaDir, { recursive: true }); + fs.writeFileSync( + path.join(projectSchemaDir, 'schema.yaml'), + 'name: team-workflow\nversion: 1\nartifacts: []' + ); + + const schemas = listSchemas(projectRoot); + expect(schemas).toContain('team-workflow'); + expect(schemas).toContain('spec-driven'); // built-in still included + }); + + it('should deduplicate project-local schema that shadows user override', () => { + // Set up user override + 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\nversion: 1\nartifacts: []' + ); + + // Set up project-local schema with same name + const projectRoot = path.join(tempDir, 'project'); + const projectSchemaDir = path.join(projectRoot, 'openspec', 'schemas', 'my-schema'); + fs.mkdirSync(projectSchemaDir, { recursive: true }); + fs.writeFileSync( + path.join(projectSchemaDir, 'schema.yaml'), + 'name: project\nversion: 2\nartifacts: []' + ); + + const schemas = listSchemas(projectRoot); + const count = schemas.filter(s => s === 'my-schema').length; + expect(count).toBe(1); + }); + + it('should maintain backward compatibility when projectRoot not provided', () => { + // Set up project-local schema + const projectRoot = path.join(tempDir, 'project'); + const projectSchemaDir = path.join(projectRoot, 'openspec', 'schemas', 'project-only'); + fs.mkdirSync(projectSchemaDir, { recursive: true }); + fs.writeFileSync( + path.join(projectSchemaDir, 'schema.yaml'), + 'name: project-only\nversion: 1\nartifacts: []' + ); + + // Without projectRoot, project-only schema should not appear + const schemas = listSchemas(); + expect(schemas).not.toContain('project-only'); + }); + }); + + describe('listSchemasWithInfo with projectRoot', () => { + it('should return source: project for project-local schemas', () => { + const projectRoot = path.join(tempDir, 'project'); + const projectSchemaDir = path.join(projectRoot, 'openspec', 'schemas', 'team-workflow'); + fs.mkdirSync(projectSchemaDir, { recursive: true }); + fs.writeFileSync( + path.join(projectSchemaDir, 'schema.yaml'), + `name: team-workflow +version: 1 +description: Team workflow +artifacts: + - id: spec + generates: spec.md + description: Specification + template: spec.md +` + ); + + const schemas = listSchemasWithInfo(projectRoot); + const teamSchema = schemas.find(s => s.name === 'team-workflow'); + expect(teamSchema).toBeDefined(); + expect(teamSchema!.source).toBe('project'); + }); + + it('should return source: package for built-in schemas', () => { + const projectRoot = path.join(tempDir, 'project'); + fs.mkdirSync(projectRoot, { recursive: true }); + + const schemas = listSchemasWithInfo(projectRoot); + const specDriven = schemas.find(s => s.name === 'spec-driven'); + expect(specDriven).toBeDefined(); + expect(specDriven!.source).toBe('package'); + }); + + it('should return source: user for user override schemas', () => { + 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: User custom +artifacts: + - id: artifact + generates: artifact.md + description: Artifact + template: artifact.md +` + ); + + const projectRoot = path.join(tempDir, 'project'); + fs.mkdirSync(projectRoot, { recursive: true }); + + const schemas = listSchemasWithInfo(projectRoot); + const userSchema = schemas.find(s => s.name === 'user-custom'); + expect(userSchema).toBeDefined(); + expect(userSchema!.source).toBe('user'); + }); + + it('should show project source when project-local shadows user override', () => { + // Set up user override + process.env.XDG_DATA_HOME = tempDir; + const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'shared'); + fs.mkdirSync(userSchemaDir, { recursive: true }); + fs.writeFileSync( + path.join(userSchemaDir, 'schema.yaml'), + `name: user-shared +version: 1 +description: User shared +artifacts: + - id: a + generates: a.md + description: A + template: a.md +` + ); + + // Set up project-local with same name + const projectRoot = path.join(tempDir, 'project'); + const projectSchemaDir = path.join(projectRoot, 'openspec', 'schemas', 'shared'); + fs.mkdirSync(projectSchemaDir, { recursive: true }); + fs.writeFileSync( + path.join(projectSchemaDir, 'schema.yaml'), + `name: project-shared +version: 2 +description: Project shared +artifacts: + - id: b + generates: b.md + description: B + template: b.md +` + ); + + const schemas = listSchemasWithInfo(projectRoot); + const sharedSchema = schemas.find(s => s.name === 'shared'); + expect(sharedSchema).toBeDefined(); + expect(sharedSchema!.source).toBe('project'); + expect(sharedSchema!.description).toBe('Project shared'); // project version wins + }); + }); });