Compare commits

...
Author SHA1 Message Date
Tabish Bidiwale 904c0531d8 docs: update artifact_poc.md for directory-based schema structure
Update documentation to reflect the new schema structure where schemas
are directories containing schema.yaml and co-located templates/ rather
than single .yaml files with separate template directories.
2025-12-28 16:58:34 +11:00
Tabish Bidiwale 276ab365bd chore: archive restructure-schema-directories change 2025-12-28 16:12:02 +11:00
Tabish Bidiwale 9cc4abaa12 feat: restructure schemas as directories with templates
Move built-in schemas from embedded TypeScript objects to a file-based
directory structure. This enables co-located templates alongside schemas.

Changes:
- Remove builtin-schemas.ts (replaced by file-based schemas)
- Add schemas/ directory at package root with spec-driven and tdd schemas
- Update resolveSchema() to load from directory structure
- Resolution checks user dir → package dir
2025-12-27 00:04:45 +11:00
21 changed files with 436 additions and 245 deletions
+36 -32
View File
@@ -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/<name>.yaml # Global user override
2. <package>/schemas/<name>.yaml # Built-in defaults
1. ${XDG_DATA_HOME}/openspec/schemas/<name>/schema.yaml # Global user override
2. <package>/schemas/<name>/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/<schema>/templates/<artifact>.md # Schema-specific
2. ${XDG_DATA_HOME}/openspec/templates/<artifact>.md # Shared
3. <package>/templates/<artifact>.md # Built-in fallback
1. ${XDG_DATA_HOME}/openspec/schemas/<schema>/templates/<artifact>.md # User override
2. <package>/schemas/<schema>/templates/<artifact>.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/<name>.yaml # User override
${XDG_DATA_HOME}/openspec/schemas/<name>/schema.yaml # User override
↓ (not found)
<package>/schemas/<name>.yaml # Built-in
<package>/schemas/<name>/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/<schema>/templates/<artifact>.md # Schema-specific
${XDG_DATA_HOME}/openspec/schemas/<schema>/templates/<artifact>.md # User override
↓ (not found)
${XDG_DATA_HOME}/openspec/templates/<artifact>.md # Shared
↓ (not found)
<package>/templates/<artifact>.md # Built-in
<package>/schemas/<schema>/templates/<artifact>.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)
<package>/
├── 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: <package>/schemas/spec-driven.yaml
# Or user override: ~/.local/share/openspec/schemas/spec-driven.yaml
# Built-in: <package>/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
+25 -2
View File
@@ -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/<name>/`
- **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
+1
View File
@@ -32,6 +32,7 @@
"files": [
"dist",
"bin",
"schemas",
"scripts/postinstall.js",
"!dist/**/*.test.js",
"!dist/**/__tests__",
+28
View File
@@ -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
+19
View File
@@ -0,0 +1,19 @@
## Context
<!-- Background and current state -->
## Goals / Non-Goals
**Goals:**
<!-- What this design aims to achieve -->
**Non-Goals:**
<!-- What is explicitly out of scope -->
## Decisions
<!-- Key design decisions and rationale -->
## Risks / Trade-offs
<!-- Known risks and trade-offs -->
+11
View File
@@ -0,0 +1,11 @@
## Why
<!-- Explain the motivation for this change -->
## What Changes
<!-- Describe what will change -->
## Impact
<!-- List affected areas -->
+8
View File
@@ -0,0 +1,8 @@
## ADDED Requirements
### Requirement: <!-- requirement name -->
<!-- requirement text -->
#### Scenario: <!-- scenario name -->
- **WHEN** <!-- condition -->
- **THEN** <!-- expected outcome -->
+9
View File
@@ -0,0 +1,9 @@
## 1. <!-- Task Group Name -->
- [ ] 1.1 <!-- Task description -->
- [ ] 1.2 <!-- Task description -->
## 2. <!-- Task Group Name -->
- [ ] 2.1 <!-- Task description -->
- [ ] 2.2 <!-- Task description -->
+27
View File
@@ -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
+15
View File
@@ -0,0 +1,15 @@
## Overview
<!-- Feature overview -->
## Getting Started
<!-- Quick start guide -->
## Examples
<!-- Code examples -->
## Reference
<!-- API reference or additional details -->
+11
View File
@@ -0,0 +1,11 @@
## Implementation Notes
<!-- Technical implementation details -->
## API
<!-- Public API documentation -->
## Usage
<!-- Usage examples -->
+11
View File
@@ -0,0 +1,11 @@
## Feature: <!-- feature name -->
<!-- Feature description -->
## Requirements
<!-- List of requirements -->
## Acceptance Criteria
<!-- List of acceptance criteria -->
+11
View File
@@ -0,0 +1,11 @@
## Test Plan
<!-- Describe the testing strategy -->
## Test Cases
### <!-- Test case name -->
- **Given:** <!-- preconditions -->
- **When:** <!-- action -->
- **Then:** <!-- expected result -->
@@ -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<string, SchemaYaml> = {
'spec-driven': SPEC_DRIVEN_SCHEMA,
'tdd': TDD_SCHEMA,
};
+8 -4
View File
@@ -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';
+109 -72
View File
@@ -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/<name>/schema.yaml
* 2. Package built-in: <package>/schemas/<name>/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/<name>.yaml
* 2. Built-in schema
* 1. User override: ${XDG_DATA_HOME}/openspec/schemas/<name>/schema.yaml
* 2. Package built-in: <package>/schemas/<name>/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));
const schemaDir = getSchemaDir(normalizedName);
if (!schemaDir) {
const availableSchemas = listSchemas();
throw new Error(
`Schema '${normalizedName}' not found. Available schemas: ${availableSchemas.join(', ')}`
);
}
const schemaPath = path.join(schemaDir, 'schema.yaml');
// 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
);
}
try {
return parseSchema(content);
} catch (err) {
if (err instanceof SchemaValidationError) {
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
`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
);
}
// 2. Check built-in schemas
const builtin = BUILTIN_SCHEMAS[normalizedName];
if (builtin) {
return builtin;
}
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');
// 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;
}
}
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<string>(Object.keys(BUILTIN_SCHEMAS));
const schemas = new Set<string>();
// 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 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 (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);
}
}
}
}
+107 -51
View File
@@ -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');
});
});
});