Skip to content

Commit adda63e

Browse files
authored
feat(resolver): add project-local schema support (Fission-AI#522)
Add 3-level schema resolution: project-local → user override → package built-in. - Add `getProjectSchemasDir(projectRoot)` to resolve project schemas at `./openspec/schemas/<name>/` - Extend `SchemaInfo.source` type to include `'project'` - Update `getSchemaDir()`, `resolveSchema()`, `listSchemas()`, `listSchemasWithInfo()` with optional `projectRoot` parameter - Update CLI commands to display schema source labels (project/user/package) - Add `projectRoot` to `ChangeContext` interface for proper resolution throughout workflow - Add 17 new tests covering project-local schema resolution This enables projects to define custom schemas that override user and package schemas, while maintaining backward compatibility when projectRoot is not provided.
1 parent 90d05b7 commit adda63e

10 files changed

Lines changed: 752 additions & 61 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
## Context
2+
3+
OpenSpec currently resolves schemas from two locations:
4+
1. User override: `~/.local/share/openspec/schemas/<name>/`
5+
2. Package built-in: `<npm-package>/schemas/<name>/`
6+
7+
This change adds a third, highest-priority level: project-local schemas at `./openspec/schemas/<name>/`.
8+
9+
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.
10+
11+
## Goals / Non-Goals
12+
13+
**Goals:**
14+
- Enable version-controlled custom workflow schemas
15+
- Allow teams to share schemas via git without per-machine setup
16+
- Maintain backward compatibility with existing resolver API
17+
- Integrate with `config.yaml`'s `schema` field (from project-config change)
18+
19+
**Non-Goals:**
20+
- Schema inheritance or `extends` keyword
21+
- Template-level overrides (partial forks)
22+
- Schema management CLI commands (`openspec schema copy/which/diff/reset`)
23+
- Validation that project-local schema names don't conflict with built-ins (shadowing is intentional)
24+
25+
## Decisions
26+
27+
### Decision 1: Add optional `projectRoot` parameter to resolver functions
28+
29+
**Choice:** Add optional `projectRoot?: string` parameter to resolver functions rather than using `process.cwd()` internally.
30+
31+
**Alternatives considered:**
32+
- Use `process.cwd()` internally: Simpler API but implicit, harder to test, doesn't match existing codebase patterns
33+
- Create separate project-aware functions: No breaking changes but awkward API, callers must compose
34+
35+
**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.
36+
37+
**Affected functions:**
38+
```typescript
39+
getSchemaDir(name: string, projectRoot?: string): string | null
40+
listSchemas(projectRoot?: string): string[]
41+
listSchemasWithInfo(projectRoot?: string): SchemaInfo[]
42+
resolveSchema(name: string, projectRoot?: string): SchemaYaml
43+
```
44+
45+
### Decision 2: Resolution order is project → user → package
46+
47+
**Choice:** Project-local schemas have highest priority, then user overrides, then package built-ins.
48+
49+
**Rationale:**
50+
- Project-local should win because it represents team intent (version controlled, shared)
51+
- User overrides still useful for personal experimentation without affecting team
52+
- Package built-ins are the fallback defaults
53+
54+
```
55+
1. ./openspec/schemas/<name>/ # Project-local (highest)
56+
2. ~/.local/share/openspec/schemas/<name>/ # User override
57+
3. <npm-package>/schemas/<name>/ # Package built-in (lowest)
58+
```
59+
60+
### Decision 3: Add `getProjectSchemasDir()` helper function
61+
62+
**Choice:** Create a dedicated function to get the project schemas directory path.
63+
64+
```typescript
65+
function getProjectSchemasDir(projectRoot: string): string {
66+
return path.join(projectRoot, 'openspec', 'schemas');
67+
}
68+
```
69+
70+
**Rationale:** Matches existing pattern with `getPackageSchemasDir()` and `getUserSchemasDir()`. Keeps path logic centralized.
71+
72+
### Decision 4: Extend `SchemaInfo.source` to include `'project'`
73+
74+
**Choice:** Update the source type from `'package' | 'user'` to `'project' | 'user' | 'package'`.
75+
76+
**Rationale:** Consumers need to distinguish project-local schemas for display purposes (e.g., `schemasCommand` output).
77+
78+
### Decision 5: No special handling for schema name conflicts
79+
80+
**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.
81+
82+
**Rationale:** This is intentional shadowing. Teams may want to customize a built-in schema while keeping the same name for familiarity.
83+
84+
## Risks / Trade-offs
85+
86+
### Risk: Confusion when project schema shadows built-in
87+
A team could create `openspec/schemas/spec-driven/` that shadows the built-in, causing confusion when someone expects default behavior.
88+
89+
**Mitigation:** The `openspec schemas` command shows the source of each schema. Users can see `spec-driven (project)` vs `spec-driven (package)`.
90+
91+
### Risk: Missing projectRoot parameter
92+
If callers forget to pass `projectRoot`, project-local schemas won't be found.
93+
94+
**Mitigation:**
95+
- Make the change incrementally, updating call sites that need project-local support
96+
- Existing behavior (user + package only) is preserved when `projectRoot` is undefined
97+
98+
### Trade-off: Optional parameter vs required
99+
Making `projectRoot` optional maintains backward compatibility but means some code paths may silently skip project-local resolution.
100+
101+
**Accepted:** Backward compatibility is more important. The main entry points (CLI commands) will always pass `projectRoot`.
102+
103+
## Implementation Approach
104+
105+
1. **Update `resolver.ts`:**
106+
- Add `getProjectSchemasDir(projectRoot: string)` function
107+
- Update `getSchemaDir()` to check project-local first when `projectRoot` provided
108+
- Update `listSchemas()` to include project schemas when `projectRoot` provided
109+
- Update `listSchemasWithInfo()` to return `source: 'project'` for project schemas
110+
- Update `SchemaInfo` type to include `'project'` in source union
111+
112+
2. **Update `artifact-workflow.ts`:**
113+
- Update `schemasCommand` to pass `projectRoot` and display source labels
114+
115+
3. **Update call sites:**
116+
- Any existing code that needs project-local resolution should pass `projectRoot`
117+
- `config.yaml` schema resolution already has access to `projectRoot`
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Project-local schema resolution
4+
5+
The system SHALL resolve schemas from the project-local directory (`./openspec/schemas/<name>/`) with highest priority when a `projectRoot` is provided.
6+
7+
#### Scenario: Project-local schema takes precedence over user override
8+
- **WHEN** a schema named "my-workflow" exists at `./openspec/schemas/my-workflow/schema.yaml`
9+
- **AND** a schema named "my-workflow" exists at `~/.local/share/openspec/schemas/my-workflow/schema.yaml`
10+
- **AND** `getSchemaDir("my-workflow", projectRoot)` is called
11+
- **THEN** the system SHALL return the project-local path
12+
13+
#### Scenario: Project-local schema takes precedence over package built-in
14+
- **WHEN** a schema named "spec-driven" exists at `./openspec/schemas/spec-driven/schema.yaml`
15+
- **AND** "spec-driven" is a package built-in schema
16+
- **AND** `getSchemaDir("spec-driven", projectRoot)` is called
17+
- **THEN** the system SHALL return the project-local path
18+
19+
#### Scenario: Falls back to user override when no project-local schema
20+
- **WHEN** no schema named "my-workflow" exists at `./openspec/schemas/my-workflow/`
21+
- **AND** a schema named "my-workflow" exists at `~/.local/share/openspec/schemas/my-workflow/schema.yaml`
22+
- **AND** `getSchemaDir("my-workflow", projectRoot)` is called
23+
- **THEN** the system SHALL return the user override path
24+
25+
#### Scenario: Falls back to package built-in when no project-local or user schema
26+
- **WHEN** no schema named "spec-driven" exists at `./openspec/schemas/spec-driven/`
27+
- **AND** no schema named "spec-driven" exists at `~/.local/share/openspec/schemas/spec-driven/`
28+
- **AND** "spec-driven" is a package built-in schema
29+
- **AND** `getSchemaDir("spec-driven", projectRoot)` is called
30+
- **THEN** the system SHALL return the package built-in path
31+
32+
#### Scenario: Backward compatibility when projectRoot not provided
33+
- **WHEN** `getSchemaDir("my-workflow")` is called without a `projectRoot` parameter
34+
- **THEN** the system SHALL only check user override and package built-in locations
35+
- **AND** the system SHALL NOT check project-local location
36+
37+
### Requirement: Project schemas directory helper
38+
39+
The system SHALL provide a `getProjectSchemasDir(projectRoot)` function that returns the project-local schemas directory path.
40+
41+
#### Scenario: Returns correct path
42+
- **WHEN** `getProjectSchemasDir("/path/to/project")` is called
43+
- **THEN** the system SHALL return `/path/to/project/openspec/schemas`
44+
45+
### Requirement: List schemas includes project-local
46+
47+
The system SHALL include project-local schemas when listing available schemas if `projectRoot` is provided.
48+
49+
#### Scenario: Project-local schemas appear in list
50+
- **WHEN** a schema named "team-flow" exists at `./openspec/schemas/team-flow/schema.yaml`
51+
- **AND** `listSchemas(projectRoot)` is called
52+
- **THEN** the returned list SHALL include "team-flow"
53+
54+
#### Scenario: Project-local schema shadows same-named user schema in list
55+
- **WHEN** a schema named "custom" exists at both project-local and user override locations
56+
- **AND** `listSchemas(projectRoot)` is called
57+
- **THEN** the returned list SHALL include "custom" exactly once
58+
59+
#### Scenario: Backward compatibility for listSchemas
60+
- **WHEN** `listSchemas()` is called without a `projectRoot` parameter
61+
- **THEN** the system SHALL only include user override and package built-in schemas
62+
63+
### Requirement: Schema info includes project source
64+
65+
The system SHALL indicate `source: 'project'` for project-local schemas in `listSchemasWithInfo()` results.
66+
67+
#### Scenario: Project-local schema shows project source
68+
- **WHEN** a schema named "team-flow" exists at `./openspec/schemas/team-flow/schema.yaml`
69+
- **AND** `listSchemasWithInfo(projectRoot)` is called
70+
- **THEN** the schema info for "team-flow" SHALL have `source: 'project'`
71+
72+
#### Scenario: User override schema shows user source
73+
- **WHEN** a schema named "my-custom" exists only at `~/.local/share/openspec/schemas/my-custom/`
74+
- **AND** `listSchemasWithInfo(projectRoot)` is called
75+
- **THEN** the schema info for "my-custom" SHALL have `source: 'user'`
76+
77+
#### Scenario: Package built-in schema shows package source
78+
- **WHEN** "spec-driven" exists only as a package built-in
79+
- **AND** `listSchemasWithInfo(projectRoot)` is called
80+
- **THEN** the schema info for "spec-driven" SHALL have `source: 'package'`
81+
82+
### Requirement: Schemas command shows source
83+
84+
The `openspec schemas` command SHALL display the source of each schema.
85+
86+
#### Scenario: Display format includes source
87+
- **WHEN** user runs `openspec schemas`
88+
- **THEN** the output SHALL show each schema with its source label (project, user, or package)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
## 1. Update Resolver Types and Helpers
2+
3+
- [x] 1.1 Update `SchemaInfo.source` type to include `'project'` in `src/core/artifact-graph/resolver.ts`
4+
- [x] 1.2 Add `getProjectSchemasDir(projectRoot: string): string` function
5+
6+
## 2. Update Schema Resolution Functions
7+
8+
- [x] 2.1 Update `getSchemaDir(name, projectRoot?)` to check project-local first when projectRoot provided
9+
- [x] 2.2 Update `resolveSchema(name, projectRoot?)` to pass projectRoot to getSchemaDir
10+
- [x] 2.3 Update `listSchemas(projectRoot?)` to include project-local schemas
11+
- [x] 2.4 Update `listSchemasWithInfo(projectRoot?)` to include project schemas with `source: 'project'`
12+
13+
## 3. Update CLI Commands
14+
15+
- [x] 3.1 Update `schemasCommand` to pass projectRoot and display source labels in output
16+
17+
## 4. Update Call Sites
18+
19+
- [x] 4.1 Review and update call sites that need project-local schema support to pass projectRoot
20+
21+
## 5. Testing
22+
23+
- [x] 5.1 Add unit tests for `getProjectSchemasDir()`
24+
- [x] 5.2 Add unit tests for project-local schema resolution priority
25+
- [x] 5.3 Add unit tests for backward compatibility (no projectRoot = user + package only)
26+
- [x] 5.4 Add unit tests for `listSchemas()` including project schemas
27+
- [x] 5.5 Add unit tests for `listSchemasWithInfo()` with `source: 'project'`
28+
- [x] 5.6 Add integration test with temp project containing local schema

src/commands/artifact-workflow.ts

Lines changed: 44 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -159,11 +159,14 @@ async function validateChangeExists(
159159

160160
/**
161161
* Validates that a schema exists and returns available schemas if not.
162+
*
163+
* @param schemaName - The schema name to validate
164+
* @param projectRoot - Optional project root for project-local schema resolution
162165
*/
163-
function validateSchemaExists(schemaName: string): string {
164-
const schemaDir = getSchemaDir(schemaName);
166+
function validateSchemaExists(schemaName: string, projectRoot?: string): string {
167+
const schemaDir = getSchemaDir(schemaName, projectRoot);
165168
if (!schemaDir) {
166-
const availableSchemas = listSchemas();
169+
const availableSchemas = listSchemas(projectRoot);
167170
throw new Error(
168171
`Schema '${schemaName}' not found. Available schemas:\n ${availableSchemas.join('\n ')}`
169172
);
@@ -190,7 +193,7 @@ async function statusCommand(options: StatusOptions): Promise<void> {
190193

191194
// Validate schema if explicitly provided
192195
if (options.schema) {
193-
validateSchemaExists(options.schema);
196+
validateSchemaExists(options.schema, projectRoot);
194197
}
195198

196199
// loadChangeContext will auto-detect schema from metadata if not provided
@@ -260,7 +263,7 @@ async function instructionsCommand(
260263

261264
// Validate schema if explicitly provided
262265
if (options.schema) {
263-
validateSchemaExists(options.schema);
266+
validateSchemaExists(options.schema, projectRoot);
264267
}
265268

266269
// loadChangeContext will auto-detect schema from metadata if not provided
@@ -598,7 +601,7 @@ async function applyInstructionsCommand(options: ApplyInstructionsOptions): Prom
598601

599602
// Validate schema if explicitly provided
600603
if (options.schema) {
601-
validateSchemaExists(options.schema);
604+
validateSchemaExists(options.schema, projectRoot);
602605
}
603606

604607
// generateApplyInstructions uses loadChangeContext which auto-detects schema
@@ -682,27 +685,40 @@ interface TemplatesOptions {
682685
interface TemplateInfo {
683686
artifactId: string;
684687
templatePath: string;
685-
source: 'user' | 'package';
688+
source: 'project' | 'user' | 'package';
686689
}
687690

688691
async function templatesCommand(options: TemplatesOptions): Promise<void> {
689692
const spinner = ora('Loading templates...').start();
690693

691694
try {
692-
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA);
693-
const schema = resolveSchema(schemaName);
695+
const projectRoot = process.cwd();
696+
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA, projectRoot);
697+
const schema = resolveSchema(schemaName, projectRoot);
694698
const graph = ArtifactGraph.fromSchema(schema);
695-
const schemaDir = getSchemaDir(schemaName)!;
696-
697-
// Determine if this is a user override or package built-in
698-
const { getUserSchemasDir } = await import('../core/artifact-graph/resolver.js');
699+
const schemaDir = getSchemaDir(schemaName, projectRoot)!;
700+
701+
// Determine the source (project, user, or package)
702+
const {
703+
getUserSchemasDir,
704+
getProjectSchemasDir,
705+
} = await import('../core/artifact-graph/resolver.js');
706+
const projectSchemasDir = getProjectSchemasDir(projectRoot);
699707
const userSchemasDir = getUserSchemasDir();
700-
const isUserOverride = schemaDir.startsWith(userSchemasDir);
708+
709+
let source: 'project' | 'user' | 'package';
710+
if (schemaDir.startsWith(projectSchemasDir)) {
711+
source = 'project';
712+
} else if (schemaDir.startsWith(userSchemasDir)) {
713+
source = 'user';
714+
} else {
715+
source = 'package';
716+
}
701717

702718
const templates: TemplateInfo[] = graph.getAllArtifacts().map((artifact) => ({
703719
artifactId: artifact.id,
704720
templatePath: path.join(schemaDir, 'templates', artifact.template),
705-
source: isUserOverride ? 'user' : 'package',
721+
source,
706722
}));
707723

708724
spinner.stop();
@@ -717,7 +733,7 @@ async function templatesCommand(options: TemplatesOptions): Promise<void> {
717733
}
718734

719735
console.log(`Schema: ${schemaName}`);
720-
console.log(`Source: ${isUserOverride ? 'user override' : 'package built-in'}`);
736+
console.log(`Source: ${source}`);
721737
console.log();
722738

723739
for (const t of templates) {
@@ -749,16 +765,17 @@ async function newChangeCommand(name: string | undefined, options: NewChangeOpti
749765
throw new Error(validation.error);
750766
}
751767

768+
const projectRoot = process.cwd();
769+
752770
// Validate schema if provided
753771
if (options.schema) {
754-
validateSchemaExists(options.schema);
772+
validateSchemaExists(options.schema, projectRoot);
755773
}
756774

757775
const schemaDisplay = options.schema ? ` with schema '${options.schema}'` : '';
758776
const spinner = ora(`Creating change '${name}'${schemaDisplay}...`).start();
759777

760778
try {
761-
const projectRoot = process.cwd();
762779
const result = await createChange(projectRoot, name, { schema: options.schema });
763780

764781
// If description provided, create README.md with description
@@ -926,7 +943,7 @@ ${template.content}
926943
} else {
927944
// Prompt for config creation
928945
try {
929-
const configResult = await promptForConfig();
946+
const configResult = await promptForConfig(projectRoot);
930947

931948
if (configResult.createConfig && configResult.schema) {
932949
// Build config object
@@ -1052,7 +1069,8 @@ interface SchemasOptions {
10521069
}
10531070

10541071
async function schemasCommand(options: SchemasOptions): Promise<void> {
1055-
const schemas = listSchemasWithInfo();
1072+
const projectRoot = process.cwd();
1073+
const schemas = listSchemasWithInfo(projectRoot);
10561074

10571075
if (options.json) {
10581076
console.log(JSON.stringify(schemas, null, 2));
@@ -1063,7 +1081,12 @@ async function schemasCommand(options: SchemasOptions): Promise<void> {
10631081
console.log();
10641082

10651083
for (const schema of schemas) {
1066-
const sourceLabel = schema.source === 'user' ? chalk.dim(' (user override)') : '';
1084+
let sourceLabel = '';
1085+
if (schema.source === 'project') {
1086+
sourceLabel = chalk.cyan(' (project)');
1087+
} else if (schema.source === 'user') {
1088+
sourceLabel = chalk.dim(' (user override)');
1089+
}
10671090
console.log(` ${chalk.bold(schema.name)}${sourceLabel}`);
10681091
console.log(` ${schema.description}`);
10691092
console.log(` Artifacts: ${schema.artifacts.join(' → ')}`);

0 commit comments

Comments
 (0)