diff --git a/docs/artifact_poc.md b/docs/artifact_poc.md index 3d409e14..01dd0098 100644 --- a/docs/artifact_poc.md +++ b/docs/artifact_poc.md @@ -52,8 +52,8 @@ Schemas can vary across multiple dimensions: Schemas follow the XDG Base Directory Specification with a 2-level resolution: ``` -1. ${XDG_DATA_HOME}/openspec/schemas/.yaml # Global user override -2. /schemas/.yaml # Built-in defaults +1. ${XDG_DATA_HOME}/openspec/schemas//schema.yaml # Global user override +2. /schemas//schema.yaml # Built-in defaults ``` **Platform-specific paths:** @@ -69,25 +69,23 @@ Schemas follow the XDG Base Directory Specification with a 2-level resolution: ### Template Inheritance (2 Levels Max) -Templates also use 2-level resolution (to be implemented in Slice 3): +Templates are co-located with schemas in a `templates/` subdirectory: ``` -1. ${XDG_DATA_HOME}/openspec/schemas//templates/.md # Schema-specific -2. ${XDG_DATA_HOME}/openspec/templates/.md # Shared -3. /templates/.md # Built-in fallback +1. ${XDG_DATA_HOME}/openspec/schemas//templates/.md # User override +2. /schemas//templates/.md # Built-in ``` **Rules:** -- Schema-specific templates override shared templates -- Shared templates override package built-ins +- User overrides take precedence over package built-ins - A CLI command shows resolved paths (no guessing) - No inheritance between schemas (copy if you need to diverge) -- Max 2 levels - no deeper inheritance chains +- Templates are always co-located with their schema **Why this matters:** - Avoids "where does this come from?" debugging - No implicit magic that works until it doesn't -- Clear boundaries between shared and specific +- Schema + templates form a cohesive unit --- @@ -333,21 +331,19 @@ This separation means: ### 3. XDG-Compliant Schema Resolution ``` -${XDG_DATA_HOME}/openspec/schemas/.yaml # User override +${XDG_DATA_HOME}/openspec/schemas//schema.yaml # User override ↓ (not found) -/schemas/.yaml # Built-in +/schemas//schema.yaml # Built-in ↓ (not found) Error (schema not found) ``` -### 4. Two-Level Template Fallback (Slice 3) +### 4. Two-Level Template Fallback ``` -${XDG_DATA_HOME}/openspec/schemas//templates/.md # Schema-specific +${XDG_DATA_HOME}/openspec/schemas//templates/.md # User override ↓ (not found) -${XDG_DATA_HOME}/openspec/templates/.md # Shared - ↓ (not found) -/templates/.md # Built-in +/schemas//templates/.md # Built-in ↓ (not found) Error (no silent fallback to avoid confusion) ``` @@ -487,21 +483,29 @@ Structured as **vertical slices** - each slice is independently testable. # Global (XDG paths - user overrides) ~/.local/share/openspec/ # Unix/macOS ($XDG_DATA_HOME/openspec/) %LOCALAPPDATA%/openspec/ # Windows -├── schemas/ # Schema overrides -│ └── custom-workflow.yaml # User-defined schema -└── templates/ # Template overrides (Slice 3) - └── proposal.md # Custom proposal template +└── schemas/ # Schema overrides + └── custom-workflow/ # User-defined schema directory + ├── schema.yaml # Schema definition + └── templates/ # Co-located templates + └── proposal.md # Package (built-in defaults) / -├── schemas/ # Built-in schema definitions -│ ├── spec-driven.yaml # Default: proposal → specs → design → tasks -│ └── tdd.yaml # TDD: tests → implementation → docs -└── templates/ # Built-in templates (Slice 3) - ├── proposal.md - ├── design.md - ├── specs.md - └── tasks.md +└── schemas/ # Built-in schema definitions + ├── spec-driven/ # Default: proposal → specs → design → tasks + │ ├── schema.yaml + │ └── templates/ + │ ├── proposal.md + │ ├── design.md + │ ├── spec.md + │ └── tasks.md + └── tdd/ # TDD: tests → implementation → docs + ├── schema.yaml + └── templates/ + ├── test.md + ├── implementation.md + ├── spec.md + └── docs.md # Project (change instances) openspec/ @@ -528,8 +532,8 @@ openspec/ ## Schema YAML Format ```yaml -# Built-in: /schemas/spec-driven.yaml -# Or user override: ~/.local/share/openspec/schemas/spec-driven.yaml +# Built-in: /schemas/spec-driven/schema.yaml +# Or user override: ~/.local/share/openspec/schemas/spec-driven/schema.yaml name: spec-driven version: 1 description: Specification-driven development @@ -538,7 +542,7 @@ artifacts: - id: proposal generates: "proposal.md" description: "Create project proposal document" - template: "proposal.md" # resolves via 2-level fallback (Slice 3) + template: "proposal.md" # resolves from co-located templates/ directory requires: [] - id: specs diff --git a/openspec/changes/restructure-schema-directories/design.md b/openspec/changes/archive/2025-12-28-restructure-schema-directories/design.md similarity index 100% rename from openspec/changes/restructure-schema-directories/design.md rename to openspec/changes/archive/2025-12-28-restructure-schema-directories/design.md diff --git a/openspec/changes/restructure-schema-directories/proposal.md b/openspec/changes/archive/2025-12-28-restructure-schema-directories/proposal.md similarity index 100% rename from openspec/changes/restructure-schema-directories/proposal.md rename to openspec/changes/archive/2025-12-28-restructure-schema-directories/proposal.md diff --git a/openspec/changes/restructure-schema-directories/specs/artifact-graph/spec.md b/openspec/changes/archive/2025-12-28-restructure-schema-directories/specs/artifact-graph/spec.md similarity index 100% rename from openspec/changes/restructure-schema-directories/specs/artifact-graph/spec.md rename to openspec/changes/archive/2025-12-28-restructure-schema-directories/specs/artifact-graph/spec.md diff --git a/openspec/changes/restructure-schema-directories/tasks.md b/openspec/changes/archive/2025-12-28-restructure-schema-directories/tasks.md similarity index 100% rename from openspec/changes/restructure-schema-directories/tasks.md rename to openspec/changes/archive/2025-12-28-restructure-schema-directories/tasks.md diff --git a/openspec/specs/artifact-graph/spec.md b/openspec/specs/artifact-graph/spec.md index 19b0d0bf..f1223015 100644 --- a/openspec/specs/artifact-graph/spec.md +++ b/openspec/specs/artifact-graph/spec.md @@ -4,10 +4,10 @@ TBD - created by archiving change add-artifact-graph-core. Update Purpose after archive. ## Requirements ### Requirement: Schema Loading -The system SHALL load artifact graph definitions from YAML schema files. +The system SHALL load artifact graph definitions from YAML schema files within schema directories. #### Scenario: Valid schema loaded -- **WHEN** a valid schema YAML file is provided +- **WHEN** a schema directory contains a valid `schema.yaml` file - **THEN** the system returns an ArtifactGraph with all artifacts and dependencies #### Scenario: Invalid schema rejected @@ -26,6 +26,10 @@ The system SHALL load artifact graph definitions from YAML schema files. - **WHEN** a schema contains multiple artifacts with the same ID - **THEN** the system throws an error identifying the duplicate +#### Scenario: Schema directory not found +- **WHEN** resolving a schema name that has no corresponding directory +- **THEN** the system throws an error listing available schemas + ### Requirement: Build Order Calculation The system SHALL compute a valid topological build order for artifacts. @@ -105,3 +109,22 @@ The system SHALL identify which artifacts are blocked and return all their unmet - **WHEN** artifact C requires A and B, and neither is complete - **THEN** getBlocked() returns `{ C: ['A', 'B'] }` +### Requirement: Schema Directory Structure +The system SHALL support self-contained schema directories with co-located templates. + +#### Scenario: Schema with templates +- **WHEN** a schema directory contains `schema.yaml` and `templates/` subdirectory +- **THEN** artifacts can reference templates relative to the schema's templates directory + +#### Scenario: User schema override +- **WHEN** a schema directory exists at `${XDG_DATA_HOME}/openspec/schemas//` +- **THEN** the system uses that directory instead of the built-in + +#### Scenario: Built-in schema fallback +- **WHEN** no user override exists for a schema +- **THEN** the system uses the package built-in schema directory + +#### Scenario: List available schemas +- **WHEN** listing schemas +- **THEN** the system returns schema names from both user and package directories + diff --git a/package.json b/package.json index 0c7c915e..683bdb3b 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "files": [ "dist", "bin", + "schemas", "scripts/postinstall.js", "!dist/**/*.test.js", "!dist/**/__tests__", diff --git a/schemas/spec-driven/schema.yaml b/schemas/spec-driven/schema.yaml new file mode 100644 index 00000000..17514220 --- /dev/null +++ b/schemas/spec-driven/schema.yaml @@ -0,0 +1,28 @@ +name: spec-driven +version: 1 +description: Default OpenSpec workflow - proposal → specs → design → tasks +artifacts: + - id: proposal + generates: proposal.md + description: Initial proposal document outlining the change + template: proposal.md + requires: [] + - id: specs + generates: "specs/*.md" + description: Detailed specifications for the change + template: spec.md + requires: + - proposal + - id: design + generates: design.md + description: Technical design document with implementation details + template: design.md + requires: + - proposal + - id: tasks + generates: tasks.md + description: Implementation tasks derived from specs and design + template: tasks.md + requires: + - specs + - design diff --git a/schemas/spec-driven/templates/design.md b/schemas/spec-driven/templates/design.md new file mode 100644 index 00000000..4ab5bd83 --- /dev/null +++ b/schemas/spec-driven/templates/design.md @@ -0,0 +1,19 @@ +## Context + + + +## Goals / Non-Goals + +**Goals:** + + +**Non-Goals:** + + +## Decisions + + + +## Risks / Trade-offs + + diff --git a/schemas/spec-driven/templates/proposal.md b/schemas/spec-driven/templates/proposal.md new file mode 100644 index 00000000..fa736e33 --- /dev/null +++ b/schemas/spec-driven/templates/proposal.md @@ -0,0 +1,11 @@ +## Why + + + +## What Changes + + + +## Impact + + diff --git a/schemas/spec-driven/templates/spec.md b/schemas/spec-driven/templates/spec.md new file mode 100644 index 00000000..095d711c --- /dev/null +++ b/schemas/spec-driven/templates/spec.md @@ -0,0 +1,8 @@ +## ADDED Requirements + +### Requirement: + + +#### Scenario: +- **WHEN** +- **THEN** diff --git a/schemas/spec-driven/templates/tasks.md b/schemas/spec-driven/templates/tasks.md new file mode 100644 index 00000000..88ce51ef --- /dev/null +++ b/schemas/spec-driven/templates/tasks.md @@ -0,0 +1,9 @@ +## 1. + +- [ ] 1.1 +- [ ] 1.2 + +## 2. + +- [ ] 2.1 +- [ ] 2.2 diff --git a/schemas/tdd/schema.yaml b/schemas/tdd/schema.yaml new file mode 100644 index 00000000..7b1b5bad --- /dev/null +++ b/schemas/tdd/schema.yaml @@ -0,0 +1,27 @@ +name: tdd +version: 1 +description: Test-driven development workflow - tests → implementation → docs +artifacts: + - id: spec + generates: spec.md + description: Feature specification defining requirements + template: spec.md + requires: [] + - id: tests + generates: "tests/*.test.ts" + description: Test files written before implementation + template: test.md + requires: + - spec + - id: implementation + generates: "src/*.ts" + description: Implementation code to pass the tests + template: implementation.md + requires: + - tests + - id: docs + generates: "docs/*.md" + description: Documentation for the implemented feature + template: docs.md + requires: + - implementation diff --git a/schemas/tdd/templates/docs.md b/schemas/tdd/templates/docs.md new file mode 100644 index 00000000..fae40a6b --- /dev/null +++ b/schemas/tdd/templates/docs.md @@ -0,0 +1,15 @@ +## Overview + + + +## Getting Started + + + +## Examples + + + +## Reference + + diff --git a/schemas/tdd/templates/implementation.md b/schemas/tdd/templates/implementation.md new file mode 100644 index 00000000..fac9db93 --- /dev/null +++ b/schemas/tdd/templates/implementation.md @@ -0,0 +1,11 @@ +## Implementation Notes + + + +## API + + + +## Usage + + diff --git a/schemas/tdd/templates/spec.md b/schemas/tdd/templates/spec.md new file mode 100644 index 00000000..232e783d --- /dev/null +++ b/schemas/tdd/templates/spec.md @@ -0,0 +1,11 @@ +## Feature: + + + +## Requirements + + + +## Acceptance Criteria + + diff --git a/schemas/tdd/templates/test.md b/schemas/tdd/templates/test.md new file mode 100644 index 00000000..cfa1a11b --- /dev/null +++ b/schemas/tdd/templates/test.md @@ -0,0 +1,11 @@ +## Test Plan + + + +## Test Cases + +### + +- **Given:** +- **When:** +- **Then:** diff --git a/src/core/artifact-graph/builtin-schemas.ts b/src/core/artifact-graph/builtin-schemas.ts deleted file mode 100644 index bd4fd4c3..00000000 --- a/src/core/artifact-graph/builtin-schemas.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { SchemaYaml } from './types.js'; - -/** - * Built-in schema definitions. - * These are compiled into the package, avoiding runtime file resolution issues. - */ - -export const SPEC_DRIVEN_SCHEMA: SchemaYaml = { - name: 'spec-driven', - version: 1, - description: 'Default OpenSpec workflow - proposal → specs → design → tasks', - artifacts: [ - { - id: 'proposal', - generates: 'proposal.md', - description: 'Initial proposal document outlining the change', - template: 'templates/proposal.md', - requires: [], - }, - { - id: 'specs', - generates: 'specs/*.md', - description: 'Detailed specifications for the change', - template: 'templates/spec.md', - requires: ['proposal'], - }, - { - id: 'design', - generates: 'design.md', - description: 'Technical design document with implementation details', - template: 'templates/design.md', - requires: ['proposal'], - }, - { - id: 'tasks', - generates: 'tasks.md', - description: 'Implementation tasks derived from specs and design', - template: 'templates/tasks.md', - requires: ['specs', 'design'], - }, - ], -}; - -export const TDD_SCHEMA: SchemaYaml = { - name: 'tdd', - version: 1, - description: 'Test-driven development workflow - tests → implementation → docs', - artifacts: [ - { - id: 'spec', - generates: 'spec.md', - description: 'Feature specification defining requirements', - template: 'templates/spec.md', - requires: [], - }, - { - id: 'tests', - generates: 'tests/*.test.ts', - description: 'Test files written before implementation', - template: 'templates/test.md', - requires: ['spec'], - }, - { - id: 'implementation', - generates: 'src/*.ts', - description: 'Implementation code to pass the tests', - template: 'templates/implementation.md', - requires: ['tests'], - }, - { - id: 'docs', - generates: 'docs/*.md', - description: 'Documentation for the implemented feature', - template: 'templates/docs.md', - requires: ['implementation'], - }, - ], -}; - -/** Map of built-in schema names to their definitions */ -export const BUILTIN_SCHEMAS: Record = { - 'spec-driven': SPEC_DRIVEN_SCHEMA, - 'tdd': TDD_SCHEMA, -}; diff --git a/src/core/artifact-graph/index.ts b/src/core/artifact-graph/index.ts index 9b78d797..36fc0411 100644 --- a/src/core/artifact-graph/index.ts +++ b/src/core/artifact-graph/index.ts @@ -18,7 +18,11 @@ export { ArtifactGraph } from './graph.js'; export { detectCompleted } from './state.js'; // Schema resolution -export { resolveSchema, listSchemas } from './resolver.js'; - -// Built-in schemas -export { BUILTIN_SCHEMAS, SPEC_DRIVEN_SCHEMA, TDD_SCHEMA } from './builtin-schemas.js'; +export { + resolveSchema, + listSchemas, + getSchemaDir, + getPackageSchemasDir, + getUserSchemasDir, + SchemaLoadError, +} from './resolver.js'; diff --git a/src/core/artifact-graph/resolver.ts b/src/core/artifact-graph/resolver.ts index 1488c7ed..89319464 100644 --- a/src/core/artifact-graph/resolver.ts +++ b/src/core/artifact-graph/resolver.ts @@ -1,12 +1,12 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { getGlobalDataDir } from '../global-config.js'; -import { BUILTIN_SCHEMAS } from './builtin-schemas.js'; import { parseSchema, SchemaValidationError } from './schema.js'; import type { SchemaYaml } from './types.js'; /** - * Error thrown when loading a global schema override fails. + * Error thrown when loading a schema fails. */ export class SchemaLoadError extends Error { constructor( @@ -19,12 +19,57 @@ export class SchemaLoadError extends Error { } } +/** + * Gets the package's built-in schemas directory path. + * Uses import.meta.url to resolve relative to the current module. + */ +export function getPackageSchemasDir(): string { + const currentFile = fileURLToPath(import.meta.url); + // Navigate from dist/core/artifact-graph/ to package root's schemas/ + return path.join(path.dirname(currentFile), '..', '..', '..', 'schemas'); +} + +/** + * Gets the user's schema override directory path. + */ +export function getUserSchemasDir(): string { + return path.join(getGlobalDataDir(), '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 + * + * @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 + 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 + const packageDir = path.join(getPackageSchemasDir(), name); + const packageSchemaPath = path.join(packageDir, 'schema.yaml'); + if (fs.existsSync(packageSchemaPath)) { + return packageDir; + } + + return null; +} + /** * Resolves a schema name to a SchemaYaml object. * * Resolution order: - * 1. Global user override: ${XDG_DATA_HOME}/openspec/schemas/.yaml - * 2. Built-in schema + * 1. User override: ${XDG_DATA_HOME}/openspec/schemas//schema.yaml + * 2. Package built-in: /schemas//schema.yaml * * @param name - Schema name (e.g., "spec-driven") * @returns The resolved schema object @@ -33,86 +78,78 @@ export class SchemaLoadError extends Error { export function resolveSchema(name: string): SchemaYaml { // Normalize name (remove .yaml extension if provided) const normalizedName = name.replace(/\.ya?ml$/, ''); - const builtinNames = Object.keys(BUILTIN_SCHEMAS).join(', '); - // 1. Check global user override (returns path if found) - const globalPath = getGlobalSchemaPath(normalizedName); - if (globalPath) { - // User override found - load and validate through the same pipeline as other schemas - let content: string; - try { - content = fs.readFileSync(globalPath, 'utf-8'); - } catch (err) { - const ioError = err instanceof Error ? err : new Error(String(err)); - throw new SchemaLoadError( - `Failed to read global schema override at '${globalPath}': ${ioError.message}`, - globalPath, - ioError - ); - } - - try { - return parseSchema(content); - } catch (err) { - if (err instanceof SchemaValidationError) { - // Re-wrap validation errors to include the file path for context - throw new SchemaLoadError( - `Invalid global schema override at '${globalPath}': ${err.message}`, - globalPath, - err - ); - } - // Handle unexpected parse errors (e.g., YAML syntax errors) - const parseError = err instanceof Error ? err : new Error(String(err)); - throw new SchemaLoadError( - `Failed to parse global schema override at '${globalPath}': ${parseError.message}`, - globalPath, - parseError - ); - } + const schemaDir = getSchemaDir(normalizedName); + if (!schemaDir) { + const availableSchemas = listSchemas(); + throw new Error( + `Schema '${normalizedName}' not found. Available schemas: ${availableSchemas.join(', ')}` + ); } - // 2. Check built-in schemas - const builtin = BUILTIN_SCHEMAS[normalizedName]; - if (builtin) { - return builtin; - } + const schemaPath = path.join(schemaDir, 'schema.yaml'); - throw new Error( - `Schema '${normalizedName}' not found. Checked global overrides and built-in schemas. Available built-ins: ${builtinNames}` - ); -} - -/** - * Gets the path to a global user override schema, if it exists. - */ -function getGlobalSchemaPath(name: string): string | null { - const globalDir = path.join(getGlobalDataDir(), 'schemas'); + // Load and parse the schema + let content: string; + try { + content = fs.readFileSync(schemaPath, 'utf-8'); + } catch (err) { + const ioError = err instanceof Error ? err : new Error(String(err)); + throw new SchemaLoadError( + `Failed to read schema at '${schemaPath}': ${ioError.message}`, + schemaPath, + ioError + ); + } - // Check both .yaml and .yml extensions - for (const ext of ['.yaml', '.yml']) { - const schemaPath = path.join(globalDir, `${name}${ext}`); - if (fs.existsSync(schemaPath)) { - return schemaPath; + try { + return parseSchema(content); + } catch (err) { + if (err instanceof SchemaValidationError) { + throw new SchemaLoadError( + `Invalid schema at '${schemaPath}': ${err.message}`, + schemaPath, + err + ); } + const parseError = err instanceof Error ? err : new Error(String(err)); + throw new SchemaLoadError( + `Failed to parse schema at '${schemaPath}': ${parseError.message}`, + schemaPath, + parseError + ); } - - return null; } /** * Lists all available schema names. - * Combines built-in and user override schemas. + * Combines user override and package built-in schemas. */ export function listSchemas(): string[] { - const schemas = new Set(Object.keys(BUILTIN_SCHEMAS)); + const schemas = new Set(); + + // Add package built-in schemas + const packageDir = getPackageSchemasDir(); + if (fs.existsSync(packageDir)) { + for (const entry of fs.readdirSync(packageDir, { withFileTypes: true })) { + if (entry.isDirectory()) { + const schemaPath = path.join(packageDir, entry.name, 'schema.yaml'); + if (fs.existsSync(schemaPath)) { + schemas.add(entry.name); + } + } + } + } - // Add user override schemas - const globalDir = path.join(getGlobalDataDir(), 'schemas'); - if (fs.existsSync(globalDir)) { - for (const file of fs.readdirSync(globalDir)) { - if (file.endsWith('.yaml') || file.endsWith('.yml')) { - schemas.add(file.replace(/\.ya?ml$/, '')); + // Add user override schemas (may override package schemas) + const userDir = getUserSchemasDir(); + if (fs.existsSync(userDir)) { + for (const entry of fs.readdirSync(userDir, { withFileTypes: true })) { + if (entry.isDirectory()) { + const schemaPath = path.join(userDir, entry.name, 'schema.yaml'); + if (fs.existsSync(schemaPath)) { + schemas.add(entry.name); + } } } } diff --git a/test/core/artifact-graph/resolver.test.ts b/test/core/artifact-graph/resolver.test.ts index 6894acfb..a87624cc 100644 --- a/test/core/artifact-graph/resolver.test.ts +++ b/test/core/artifact-graph/resolver.test.ts @@ -2,8 +2,14 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; -import { resolveSchema, listSchemas, SchemaLoadError } from '../../../src/core/artifact-graph/resolver.js'; -import { BUILTIN_SCHEMAS } from '../../../src/core/artifact-graph/builtin-schemas.js'; +import { + resolveSchema, + listSchemas, + SchemaLoadError, + getSchemaDir, + getPackageSchemasDir, + getUserSchemasDir, +} from '../../../src/core/artifact-graph/resolver.js'; describe('artifact-graph/resolver', () => { let tempDir: string; @@ -20,6 +26,49 @@ describe('artifact-graph/resolver', () => { fs.rmSync(tempDir, { recursive: true, force: true }); }); + describe('getPackageSchemasDir', () => { + it('should return a valid path', () => { + const schemasDir = getPackageSchemasDir(); + expect(typeof schemasDir).toBe('string'); + expect(schemasDir.length).toBeGreaterThan(0); + }); + }); + + describe('getUserSchemasDir', () => { + it('should use XDG_DATA_HOME when set', () => { + process.env.XDG_DATA_HOME = tempDir; + const userDir = getUserSchemasDir(); + expect(userDir).toBe(path.join(tempDir, 'openspec', 'schemas')); + }); + }); + + describe('getSchemaDir', () => { + it('should return null for non-existent schema', () => { + const dir = getSchemaDir('nonexistent-schema'); + expect(dir).toBeNull(); + }); + + it('should return package dir for built-in schema', () => { + const dir = getSchemaDir('spec-driven'); + expect(dir).not.toBeNull(); + expect(dir).toContain('schemas'); + expect(dir).toContain('spec-driven'); + }); + + it('should prefer user override directory', () => { + 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: custom\nversion: 1\nartifacts: []' + ); + + const dir = getSchemaDir('spec-driven'); + expect(dir).toBe(userSchemaDir); + }); + }); + describe('resolveSchema', () => { it('should return built-in spec-driven schema', () => { const schema = resolveSchema('spec-driven'); @@ -51,11 +100,11 @@ describe('artifact-graph/resolver', () => { expect(schema1).toEqual(schema2); }); - it('should prefer global override over built-in', () => { + it('should prefer user override over built-in', () => { // Set up global data dir process.env.XDG_DATA_HOME = tempDir; - const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas'); - fs.mkdirSync(globalSchemaDir, { recursive: true }); + const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven'); + fs.mkdirSync(userSchemaDir, { recursive: true }); // Create a custom schema with same name as built-in const customSchema = ` @@ -65,9 +114,9 @@ artifacts: - id: custom generates: custom.md description: Custom artifact - template: templates/custom.md + template: custom.md `; - fs.writeFileSync(path.join(globalSchemaDir, 'spec-driven.yaml'), customSchema); + fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), customSchema); const schema = resolveSchema('spec-driven'); @@ -75,10 +124,10 @@ artifacts: expect(schema.version).toBe(99); }); - it('should validate global override and throw on invalid schema', () => { + it('should validate user override and throw on invalid schema', () => { process.env.XDG_DATA_HOME = tempDir; - const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas'); - fs.mkdirSync(globalSchemaDir, { recursive: true }); + const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven'); + fs.mkdirSync(userSchemaDir, { recursive: true }); // Create an invalid schema (missing required fields) const invalidSchema = ` @@ -88,16 +137,15 @@ artifacts: - id: broken # missing generates, description, template `; - const schemaPath = path.join(globalSchemaDir, 'spec-driven.yaml'); - fs.writeFileSync(schemaPath, invalidSchema); + fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), invalidSchema); expect(() => resolveSchema('spec-driven')).toThrow(SchemaLoadError); }); it('should include file path in validation error message', () => { process.env.XDG_DATA_HOME = tempDir; - const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas'); - fs.mkdirSync(globalSchemaDir, { recursive: true }); + const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven'); + fs.mkdirSync(userSchemaDir, { recursive: true }); const invalidSchema = ` name: invalid @@ -105,7 +153,7 @@ version: 1 artifacts: - id: broken `; - const schemaPath = path.join(globalSchemaDir, 'spec-driven.yaml'); + const schemaPath = path.join(userSchemaDir, 'schema.yaml'); fs.writeFileSync(schemaPath, invalidSchema); try { @@ -119,10 +167,10 @@ artifacts: } }); - it('should detect cycles in global override schemas', () => { + it('should detect cycles in user override schemas', () => { process.env.XDG_DATA_HOME = tempDir; - const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas'); - fs.mkdirSync(globalSchemaDir, { recursive: true }); + const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven'); + fs.mkdirSync(userSchemaDir, { recursive: true }); // Create a schema with cyclic dependencies const cyclicSchema = ` @@ -132,23 +180,23 @@ artifacts: - id: a generates: a.md description: A - template: templates/a.md + template: a.md requires: [b] - id: b generates: b.md description: B - template: templates/b.md + template: b.md requires: [a] `; - fs.writeFileSync(path.join(globalSchemaDir, 'spec-driven.yaml'), cyclicSchema); + fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), cyclicSchema); expect(() => resolveSchema('spec-driven')).toThrow(/Cyclic dependency/); }); - it('should detect invalid requires references in global override schemas', () => { + it('should detect invalid requires references in user override schemas', () => { process.env.XDG_DATA_HOME = tempDir; - const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas'); - fs.mkdirSync(globalSchemaDir, { recursive: true }); + const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven'); + fs.mkdirSync(userSchemaDir, { recursive: true }); // Create a schema with invalid requires reference const invalidRefSchema = ` @@ -158,25 +206,25 @@ artifacts: - id: a generates: a.md description: A - template: templates/a.md + template: a.md requires: [nonexistent] `; - fs.writeFileSync(path.join(globalSchemaDir, 'spec-driven.yaml'), invalidRefSchema); + fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), invalidRefSchema); expect(() => resolveSchema('spec-driven')).toThrow(/does not exist/); }); it('should throw SchemaLoadError on YAML syntax errors', () => { process.env.XDG_DATA_HOME = tempDir; - const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas'); - fs.mkdirSync(globalSchemaDir, { recursive: true }); + const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven'); + fs.mkdirSync(userSchemaDir, { recursive: true }); // Create malformed YAML const malformedYaml = ` name: bad version: [[[invalid yaml `; - const schemaPath = path.join(globalSchemaDir, 'spec-driven.yaml'); + const schemaPath = path.join(userSchemaDir, 'schema.yaml'); fs.writeFileSync(schemaPath, malformedYaml); try { @@ -190,21 +238,21 @@ version: [[[invalid yaml } }); - it('should fall back to built-in when global not found', () => { + it('should fall back to built-in when user override not found', () => { process.env.XDG_DATA_HOME = tempDir; - // Don't create any global schemas + // Don't create any user schemas const schema = resolveSchema('spec-driven'); expect(schema.name).toBe('spec-driven'); - expect(schema).toEqual(BUILTIN_SCHEMAS['spec-driven']); + expect(schema.version).toBe(1); }); it('should throw when schema not found', () => { expect(() => resolveSchema('nonexistent-schema')).toThrow(/not found/); }); - it('should list available built-in schemas in error message', () => { + it('should list available schemas in error message', () => { try { resolveSchema('nonexistent'); expect.fail('Should have thrown'); @@ -214,17 +262,6 @@ version: [[[invalid yaml expect(error.message).toContain('tdd'); } }); - - it('should mention both global and built-in schemas were checked in not found error', () => { - try { - resolveSchema('nonexistent'); - expect.fail('Should have thrown'); - } catch (e) { - const error = e as Error; - expect(error.message).toContain('global overrides'); - expect(error.message).toContain('built-in'); - } - }); }); describe('listSchemas', () => { @@ -235,11 +272,11 @@ version: [[[invalid yaml expect(schemas).toContain('tdd'); }); - it('should include global override schemas', () => { + it('should include user override schemas', () => { process.env.XDG_DATA_HOME = tempDir; - const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas'); - fs.mkdirSync(globalSchemaDir, { recursive: true }); - fs.writeFileSync(path.join(globalSchemaDir, 'custom-workflow.yaml'), 'name: custom'); + const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'custom-workflow'); + fs.mkdirSync(userSchemaDir, { recursive: true }); + fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), 'name: custom\nversion: 1\nartifacts: []'); const schemas = listSchemas(); @@ -249,10 +286,10 @@ version: [[[invalid yaml it('should deduplicate schemas with same name', () => { process.env.XDG_DATA_HOME = tempDir; - const globalSchemaDir = path.join(tempDir, 'openspec', 'schemas'); - fs.mkdirSync(globalSchemaDir, { recursive: true }); + const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven'); + fs.mkdirSync(userSchemaDir, { recursive: true }); // Override spec-driven - fs.writeFileSync(path.join(globalSchemaDir, 'spec-driven.yaml'), 'name: custom'); + fs.writeFileSync(path.join(userSchemaDir, 'schema.yaml'), 'name: custom\nversion: 1\nartifacts: []'); const schemas = listSchemas(); @@ -267,5 +304,24 @@ version: [[[invalid yaml const sorted = [...schemas].sort(); expect(schemas).toEqual(sorted); }); + + it('should only include directories with schema.yaml', () => { + process.env.XDG_DATA_HOME = tempDir; + const userSchemasBase = path.join(tempDir, 'openspec', 'schemas'); + + // Create a directory without schema.yaml + const emptyDir = path.join(userSchemasBase, 'empty-dir'); + fs.mkdirSync(emptyDir, { recursive: true }); + + // Create a valid schema directory + const validDir = path.join(userSchemasBase, 'valid-schema'); + fs.mkdirSync(validDir, { recursive: true }); + fs.writeFileSync(path.join(validDir, 'schema.yaml'), 'name: valid\nversion: 1\nartifacts: []'); + + const schemas = listSchemas(); + + expect(schemas).toContain('valid-schema'); + expect(schemas).not.toContain('empty-dir'); + }); }); });