mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-04 06:18:24 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
904c0531d8 | ||
|
|
276ab365bd | ||
|
|
9cc4abaa12 |
+36
-32
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"files": [
|
||||
"dist",
|
||||
"bin",
|
||||
"schemas",
|
||||
"scripts/postinstall.js",
|
||||
"!dist/**/*.test.js",
|
||||
"!dist/**/__tests__",
|
||||
|
||||
@@ -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
|
||||
@@ -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 -->
|
||||
@@ -0,0 +1,11 @@
|
||||
## Why
|
||||
|
||||
<!-- Explain the motivation for this change -->
|
||||
|
||||
## What Changes
|
||||
|
||||
<!-- Describe what will change -->
|
||||
|
||||
## Impact
|
||||
|
||||
<!-- List affected areas -->
|
||||
@@ -0,0 +1,8 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: <!-- requirement name -->
|
||||
<!-- requirement text -->
|
||||
|
||||
#### Scenario: <!-- scenario name -->
|
||||
- **WHEN** <!-- condition -->
|
||||
- **THEN** <!-- expected outcome -->
|
||||
@@ -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 -->
|
||||
@@ -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
|
||||
@@ -0,0 +1,15 @@
|
||||
## Overview
|
||||
|
||||
<!-- Feature overview -->
|
||||
|
||||
## Getting Started
|
||||
|
||||
<!-- Quick start guide -->
|
||||
|
||||
## Examples
|
||||
|
||||
<!-- Code examples -->
|
||||
|
||||
## Reference
|
||||
|
||||
<!-- API reference or additional details -->
|
||||
@@ -0,0 +1,11 @@
|
||||
## Implementation Notes
|
||||
|
||||
<!-- Technical implementation details -->
|
||||
|
||||
## API
|
||||
|
||||
<!-- Public API documentation -->
|
||||
|
||||
## Usage
|
||||
|
||||
<!-- Usage examples -->
|
||||
@@ -0,0 +1,11 @@
|
||||
## Feature: <!-- feature name -->
|
||||
|
||||
<!-- Feature description -->
|
||||
|
||||
## Requirements
|
||||
|
||||
<!-- List of requirements -->
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- List of acceptance criteria -->
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user