Compare commits

...
Author SHA1 Message Date
Tabish Bidiwale fcfea289e6 Merge branch 'main' into TabishB/west-monroe 2026-01-05 17:12:18 -08:00
Tabish Bidiwale afce92eb78 feat: add per-change schema metadata (.openspec.yaml)
This feature enables workflow schema auto-detection for changes:

- Add ChangeMetadataSchema Zod schema to types.ts
- Create change-metadata.ts with writeChangeMetadata(), readChangeMetadata()
- Update createChange() to accept optional schema param and write metadata
- Modify loadChangeContext() to auto-detect schema from .openspec.yaml
- Add --schema option to openspec new change command
- Update status/instructions commands to auto-detect schema from metadata

Schema resolution order:
1. Explicit --schema flag (if provided)
2. Schema from .openspec.yaml in change directory
3. Default 'spec-driven'
2026-01-05 17:10:42 -08:00
10 changed files with 586 additions and 40 deletions
@@ -1,29 +1,29 @@
## 1. Zod Schema and Types
- [ ] 1.1 Add `ChangeMetadataSchema` Zod schema to `src/core/artifact-graph/types.ts`
- [ ] 1.2 Export `ChangeMetadata` type inferred from schema
- [x] 1.1 Add `ChangeMetadataSchema` Zod schema to `src/core/artifact-graph/types.ts`
- [x] 1.2 Export `ChangeMetadata` type inferred from schema
## 2. Core Metadata Functions
- [ ] 2.1 Create `src/utils/change-metadata.ts` with `writeChangeMetadata()` function
- [ ] 2.2 Add `readChangeMetadata()` function with Zod validation
- [ ] 2.3 Update `createChange()` to accept optional `schema` param and write metadata
- [x] 2.1 Create `src/utils/change-metadata.ts` with `writeChangeMetadata()` function
- [x] 2.2 Add `readChangeMetadata()` function with Zod validation
- [x] 2.3 Update `createChange()` to accept optional `schema` param and write metadata
## 3. Auto-Detection in Instruction Loader
- [ ] 3.1 Modify `loadChangeContext()` to read schema from `.openspec.yaml`
- [ ] 3.2 Make `schemaName` parameter optional (fall back to metadata, then default)
- [x] 3.1 Modify `loadChangeContext()` to read schema from `.openspec.yaml`
- [x] 3.2 Make `schemaName` parameter optional (fall back to metadata, then default)
## 4. CLI Updates
- [ ] 4.1 Add `--schema <name>` option to `openspec new change` command
- [ ] 4.2 Verify existing commands (`status`, `instructions`) work with auto-detection
- [x] 4.1 Add `--schema <name>` option to `openspec new change` command
- [x] 4.2 Verify existing commands (`status`, `instructions`) work with auto-detection
## 5. Tests
- [ ] 5.1 Test `ChangeMetadataSchema` validates correctly (valid/invalid cases)
- [ ] 5.2 Test `writeChangeMetadata()` creates valid YAML
- [ ] 5.3 Test `readChangeMetadata()` parses and validates schema
- [ ] 5.4 Test `loadChangeContext()` auto-detects schema from metadata
- [ ] 5.5 Test fallback to default when no metadata exists
- [ ] 5.6 Test `--schema` flag overrides metadata
- [x] 5.1 Test `ChangeMetadataSchema` validates correctly (valid/invalid cases)
- [x] 5.2 Test `writeChangeMetadata()` creates valid YAML
- [x] 5.3 Test `readChangeMetadata()` parses and validates schema
- [x] 5.4 Test `loadChangeContext()` auto-detects schema from metadata
- [x] 5.5 Test fallback to default when no metadata exists
- [x] 5.6 Test `--schema` flag overrides metadata
+40 -16
View File
@@ -187,9 +187,14 @@ async function statusCommand(options: StatusOptions): Promise<void> {
try {
const projectRoot = process.cwd();
const changeName = await validateChangeExists(options.change, projectRoot);
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA);
const context = loadChangeContext(projectRoot, changeName, schemaName);
// Validate schema if explicitly provided
if (options.schema) {
validateSchemaExists(options.schema);
}
// loadChangeContext will auto-detect schema from metadata if not provided
const context = loadChangeContext(projectRoot, changeName, options.schema);
const status = formatChangeStatus(context);
spinner.stop();
@@ -252,26 +257,30 @@ async function instructionsCommand(
try {
const projectRoot = process.cwd();
const changeName = await validateChangeExists(options.change, projectRoot);
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA);
// Validate schema if explicitly provided
if (options.schema) {
validateSchemaExists(options.schema);
}
// loadChangeContext will auto-detect schema from metadata if not provided
const context = loadChangeContext(projectRoot, changeName, options.schema);
if (!artifactId) {
spinner.stop();
const schema = resolveSchema(schemaName);
const graph = ArtifactGraph.fromSchema(schema);
const validIds = graph.getAllArtifacts().map((a) => a.id);
const validIds = context.graph.getAllArtifacts().map((a) => a.id);
throw new Error(
`Missing required argument <artifact>. Valid artifacts:\n ${validIds.join('\n ')}`
);
}
const context = loadChangeContext(projectRoot, changeName, schemaName);
const artifact = context.graph.getArtifact(artifactId);
if (!artifact) {
spinner.stop();
const validIds = context.graph.getAllArtifacts().map((a) => a.id);
throw new Error(
`Artifact '${artifactId}' not found in schema '${schemaName}'. Valid artifacts:\n ${validIds.join('\n ')}`
`Artifact '${artifactId}' not found in schema '${context.schemaName}'. Valid artifacts:\n ${validIds.join('\n ')}`
);
}
@@ -424,8 +433,9 @@ function parseTasksFile(content: string): TaskItem[] {
async function generateApplyInstructions(
projectRoot: string,
changeName: string,
schemaName: string
schemaName?: string
): Promise<ApplyInstructions> {
// loadChangeContext will auto-detect schema from metadata if not provided
const context = loadChangeContext(projectRoot, changeName, schemaName);
const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName);
@@ -505,9 +515,14 @@ async function applyInstructionsCommand(options: ApplyInstructionsOptions): Prom
try {
const projectRoot = process.cwd();
const changeName = await validateChangeExists(options.change, projectRoot);
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA);
const instructions = await generateApplyInstructions(projectRoot, changeName, schemaName);
// Validate schema if explicitly provided
if (options.schema) {
validateSchemaExists(options.schema);
}
// generateApplyInstructions uses loadChangeContext which auto-detects schema
const instructions = await generateApplyInstructions(projectRoot, changeName, options.schema);
spinner.stop();
@@ -640,6 +655,7 @@ async function templatesCommand(options: TemplatesOptions): Promise<void> {
interface NewChangeOptions {
description?: string;
schema?: string;
}
async function newChangeCommand(name: string | undefined, options: NewChangeOptions): Promise<void> {
@@ -652,11 +668,17 @@ async function newChangeCommand(name: string | undefined, options: NewChangeOpti
throw new Error(validation.error);
}
const spinner = ora(`Creating change '${name}'...`).start();
// Validate schema if provided
if (options.schema) {
validateSchemaExists(options.schema);
}
const schemaDisplay = options.schema ? ` with schema '${options.schema}'` : '';
const spinner = ora(`Creating change '${name}'${schemaDisplay}...`).start();
try {
const projectRoot = process.cwd();
await createChange(projectRoot, name);
await createChange(projectRoot, name, { schema: options.schema });
// If description provided, create README.md with description
if (options.description) {
@@ -666,7 +688,8 @@ async function newChangeCommand(name: string | undefined, options: NewChangeOpti
await fs.writeFile(readmePath, `# ${name}\n\n${options.description}\n`, 'utf-8');
}
spinner.succeed(`Created change '${name}' at openspec/changes/${name}/`);
const schemaUsed = options.schema ?? DEFAULT_SCHEMA;
spinner.succeed(`Created change '${name}' at openspec/changes/${name}/ (schema: ${schemaUsed})`);
} catch (error) {
spinner.fail(`Failed to create change '${name}'`);
throw error;
@@ -811,7 +834,7 @@ export function registerArtifactWorkflowCommands(program: Command): void {
.command('status')
.description('[Experimental] Display artifact completion status for a change')
.option('--change <id>', 'Change name to show status for')
.option('--schema <name>', `Schema to use (default: ${DEFAULT_SCHEMA})`)
.option('--schema <name>', 'Schema override (auto-detected from .openspec.yaml)')
.option('--json', 'Output as JSON')
.action(async (options: StatusOptions) => {
try {
@@ -828,7 +851,7 @@ export function registerArtifactWorkflowCommands(program: Command): void {
.command('instructions [artifact]')
.description('[Experimental] Output enriched instructions for creating an artifact or applying tasks')
.option('--change <id>', 'Change name')
.option('--schema <name>', `Schema to use (default: ${DEFAULT_SCHEMA})`)
.option('--schema <name>', 'Schema override (auto-detected from .openspec.yaml)')
.option('--json', 'Output as JSON')
.action(async (artifactId: string | undefined, options: InstructionsOptions) => {
try {
@@ -868,6 +891,7 @@ export function registerArtifactWorkflowCommands(program: Command): void {
.command('change <name>')
.description('[Experimental] Create a new change directory')
.option('--description <text>', 'Description to add to README.md')
.option('--schema <name>', `Workflow schema to use (default: ${DEFAULT_SCHEMA})`)
.action(async (name: string, options: NewChangeOptions) => {
try {
await newChangeCommand(name, options);
+15 -5
View File
@@ -3,6 +3,7 @@ import * as path from 'node:path';
import { getSchemaDir, resolveSchema } from './resolver.js';
import { ArtifactGraph } from './graph.js';
import { detectCompleted } from './state.js';
import { resolveSchemaForChange } from '../../utils/change-metadata.js';
import type { Artifact, CompletedSet } from './types.js';
/**
@@ -142,25 +143,34 @@ export function loadTemplate(schemaName: string, templatePath: string): string {
/**
* Loads change context combining graph and completion state.
*
* Schema resolution order:
* 1. Explicit schemaName parameter (if provided)
* 2. Schema from .openspec.yaml metadata (if exists in change directory)
* 3. Default 'spec-driven'
*
* @param projectRoot - Project root directory
* @param changeName - Change name
* @param schemaName - Optional schema name (defaults to "spec-driven")
* @param schemaName - Optional schema name override. If not provided, auto-detected from metadata.
* @returns Change context with graph, completed set, and metadata
*/
export function loadChangeContext(
projectRoot: string,
changeName: string,
schemaName: string = 'spec-driven'
schemaName?: string
): ChangeContext {
const schema = resolveSchema(schemaName);
const graph = ArtifactGraph.fromSchema(schema);
const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName);
// Resolve schema: explicit > metadata > default
const resolvedSchemaName = resolveSchemaForChange(changeDir, schemaName);
const schema = resolveSchema(resolvedSchemaName);
const graph = ArtifactGraph.fromSchema(schema);
const completed = detectCompleted(graph, changeDir);
return {
graph,
completed,
schemaName,
schemaName: resolvedSchemaName,
changeName,
changeDir,
};
+18
View File
@@ -22,6 +22,24 @@ export const SchemaYamlSchema = z.object({
export type Artifact = z.infer<typeof ArtifactSchema>;
export type SchemaYaml = z.infer<typeof SchemaYamlSchema>;
// Per-change metadata schema
// Note: schema field is validated at parse time against available schemas
// using a lazy import to avoid circular dependencies
export const ChangeMetadataSchema = z.object({
// Required: which workflow schema this change uses
schema: z.string().min(1, { message: 'schema is required' }),
// Optional: creation timestamp (ISO date string)
created: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, {
message: 'created must be YYYY-MM-DD format',
})
.optional(),
});
export type ChangeMetadata = z.infer<typeof ChangeMetadataSchema>;
// Runtime state types (not Zod - internal only)
// Slice 1: Simple completion tracking via filesystem
+171
View File
@@ -0,0 +1,171 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as yaml from 'yaml';
import { ChangeMetadataSchema, type ChangeMetadata } from '../core/artifact-graph/types.js';
import { listSchemas } from '../core/artifact-graph/resolver.js';
const METADATA_FILENAME = '.openspec.yaml';
/**
* Error thrown when change metadata validation fails.
*/
export class ChangeMetadataError extends Error {
constructor(
message: string,
public readonly metadataPath: string,
public readonly cause?: Error
) {
super(message);
this.name = 'ChangeMetadataError';
}
}
/**
* Validates that a schema name is valid (exists in available schemas).
*
* @param schemaName - The schema name to validate
* @returns The validated schema name
* @throws Error if schema is not found
*/
export function validateSchemaName(schemaName: string): string {
const availableSchemas = listSchemas();
if (!availableSchemas.includes(schemaName)) {
throw new Error(
`Unknown schema '${schemaName}'. Available: ${availableSchemas.join(', ')}`
);
}
return schemaName;
}
/**
* Writes change metadata to .openspec.yaml in the change directory.
*
* @param changeDir - The path to the change directory
* @param metadata - The metadata to write
* @throws ChangeMetadataError if validation fails or write fails
*/
export function writeChangeMetadata(
changeDir: string,
metadata: ChangeMetadata
): void {
const metaPath = path.join(changeDir, METADATA_FILENAME);
// Validate schema exists
validateSchemaName(metadata.schema);
// Validate with Zod
const parseResult = ChangeMetadataSchema.safeParse(metadata);
if (!parseResult.success) {
throw new ChangeMetadataError(
`Invalid metadata: ${parseResult.error.message}`,
metaPath
);
}
// Write YAML file
const content = yaml.stringify(parseResult.data);
try {
fs.writeFileSync(metaPath, content, 'utf-8');
} catch (err) {
const ioError = err instanceof Error ? err : new Error(String(err));
throw new ChangeMetadataError(
`Failed to write metadata: ${ioError.message}`,
metaPath,
ioError
);
}
}
/**
* Reads change metadata from .openspec.yaml in the change directory.
*
* @param changeDir - The path to the change directory
* @returns The validated metadata, or null if no metadata file exists
* @throws ChangeMetadataError if the file exists but is invalid
*/
export function readChangeMetadata(changeDir: string): ChangeMetadata | null {
const metaPath = path.join(changeDir, METADATA_FILENAME);
if (!fs.existsSync(metaPath)) {
return null;
}
let content: string;
try {
content = fs.readFileSync(metaPath, 'utf-8');
} catch (err) {
const ioError = err instanceof Error ? err : new Error(String(err));
throw new ChangeMetadataError(
`Failed to read metadata: ${ioError.message}`,
metaPath,
ioError
);
}
let parsed: unknown;
try {
parsed = yaml.parse(content);
} catch (err) {
const parseError = err instanceof Error ? err : new Error(String(err));
throw new ChangeMetadataError(
`Invalid YAML in metadata file: ${parseError.message}`,
metaPath,
parseError
);
}
// Validate with Zod
const parseResult = ChangeMetadataSchema.safeParse(parsed);
if (!parseResult.success) {
throw new ChangeMetadataError(
`Invalid metadata: ${parseResult.error.message}`,
metaPath
);
}
// Validate that the schema exists
const availableSchemas = listSchemas();
if (!availableSchemas.includes(parseResult.data.schema)) {
throw new ChangeMetadataError(
`Unknown schema '${parseResult.data.schema}'. Available: ${availableSchemas.join(', ')}`,
metaPath
);
}
return parseResult.data;
}
/**
* Resolves the schema for a change, with explicit override taking precedence.
*
* Resolution order:
* 1. Explicit schema (if provided)
* 2. Schema from .openspec.yaml metadata (if exists)
* 3. Default 'spec-driven'
*
* @param changeDir - The path to the change directory
* @param explicitSchema - Optional explicit schema override
* @returns The resolved schema name
*/
export function resolveSchemaForChange(
changeDir: string,
explicitSchema?: string
): string {
// 1. Explicit override wins
if (explicitSchema) {
return explicitSchema;
}
// 2. Try reading from metadata
try {
const metadata = readChangeMetadata(changeDir);
if (metadata?.schema) {
return metadata.schema;
}
} catch {
// If metadata read fails, fall back to default
}
// 3. Default
return 'spec-driven';
}
+32 -3
View File
@@ -1,5 +1,16 @@
import path from 'path';
import { FileSystemUtils } from './file-system.js';
import { writeChangeMetadata, validateSchemaName } from './change-metadata.js';
const DEFAULT_SCHEMA = 'spec-driven';
/**
* Options for creating a change.
*/
export interface CreateChangeOptions {
/** The workflow schema to use (default: 'spec-driven') */
schema?: string;
}
/**
* Result of validating a change name.
@@ -68,20 +79,27 @@ export function validateChangeName(name: string): ValidationResult {
}
/**
* Creates a new change directory.
* Creates a new change directory with metadata file.
*
* @param projectRoot - The root directory of the project (where `openspec/` lives)
* @param name - The change name (must be valid kebab-case)
* @param options - Optional settings for the change
* @throws Error if the change name is invalid
* @throws Error if the schema name is invalid
* @throws Error if the change directory already exists
*
* @example
* // Creates openspec/changes/add-auth/
* // Creates openspec/changes/add-auth/ with default schema
* await createChange('/path/to/project', 'add-auth')
*
* @example
* // Creates openspec/changes/add-auth/ with TDD schema
* await createChange('/path/to/project', 'add-auth', { schema: 'tdd' })
*/
export async function createChange(
projectRoot: string,
name: string
name: string,
options: CreateChangeOptions = {}
): Promise<void> {
// Validate the name first
const validation = validateChangeName(name);
@@ -89,6 +107,10 @@ export async function createChange(
throw new Error(validation.error);
}
// Determine schema (validate if provided)
const schemaName = options.schema ?? DEFAULT_SCHEMA;
validateSchemaName(schemaName);
// Build the change directory path
const changeDir = path.join(projectRoot, 'openspec', 'changes', name);
@@ -99,4 +121,11 @@ export async function createChange(
// Create the directory (including parent directories if needed)
await FileSystemUtils.createDirectory(changeDir);
// Write metadata file with schema and creation date
const today = new Date().toISOString().split('T')[0];
writeChangeMetadata(changeDir, {
schema: schemaName,
created: today,
});
}
+10 -1
View File
@@ -1,3 +1,12 @@
// Shared utilities
export { validateChangeName, createChange } from './change-utils.js';
export type { ValidationResult } from './change-utils.js';
export type { ValidationResult, CreateChangeOptions } from './change-utils.js';
// Change metadata utilities
export {
readChangeMetadata,
writeChangeMetadata,
resolveSchemaForChange,
validateSchemaName,
ChangeMetadataError,
} from './change-metadata.js';
@@ -86,6 +86,42 @@ describe('instruction-loader', () => {
expect(context.completed.size).toBe(0);
});
it('should auto-detect schema from .openspec.yaml metadata', () => {
// Create change directory with metadata file
const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');
fs.mkdirSync(changeDir, { recursive: true });
fs.writeFileSync(path.join(changeDir, '.openspec.yaml'), 'schema: tdd\ncreated: "2025-01-05"\n');
// Load without explicit schema - should detect from metadata
const context = loadChangeContext(tempDir, 'my-change');
expect(context.schemaName).toBe('tdd');
expect(context.graph.getName()).toBe('tdd');
});
it('should use explicit schema over metadata schema', () => {
// Create change directory with metadata file using tdd
const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');
fs.mkdirSync(changeDir, { recursive: true });
fs.writeFileSync(path.join(changeDir, '.openspec.yaml'), 'schema: tdd\n');
// Load with explicit schema - should override metadata
const context = loadChangeContext(tempDir, 'my-change', 'spec-driven');
expect(context.schemaName).toBe('spec-driven');
expect(context.graph.getName()).toBe('spec-driven');
});
it('should fall back to default when no metadata and no explicit schema', () => {
// Create change directory without metadata file
const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');
fs.mkdirSync(changeDir, { recursive: true });
const context = loadChangeContext(tempDir, 'my-change');
expect(context.schemaName).toBe('spec-driven');
});
});
describe('generateInstructions', () => {
+224
View File
@@ -0,0 +1,224 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { randomUUID } from 'crypto';
import {
writeChangeMetadata,
readChangeMetadata,
resolveSchemaForChange,
validateSchemaName,
ChangeMetadataError,
} from '../../src/utils/change-metadata.js';
import { ChangeMetadataSchema } from '../../src/core/artifact-graph/types.js';
describe('ChangeMetadataSchema', () => {
describe('valid metadata', () => {
it('should accept valid schema with created date', () => {
const result = ChangeMetadataSchema.safeParse({
schema: 'spec-driven',
created: '2025-01-05',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.schema).toBe('spec-driven');
expect(result.data.created).toBe('2025-01-05');
}
});
it('should accept valid schema without created date', () => {
const result = ChangeMetadataSchema.safeParse({
schema: 'tdd',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.schema).toBe('tdd');
expect(result.data.created).toBeUndefined();
}
});
});
describe('invalid metadata', () => {
it('should reject empty schema', () => {
const result = ChangeMetadataSchema.safeParse({
schema: '',
});
expect(result.success).toBe(false);
});
it('should reject missing schema', () => {
const result = ChangeMetadataSchema.safeParse({
created: '2025-01-05',
});
expect(result.success).toBe(false);
});
it('should reject invalid date format', () => {
const result = ChangeMetadataSchema.safeParse({
schema: 'spec-driven',
created: '01/05/2025', // Wrong format
});
expect(result.success).toBe(false);
});
it('should reject non-ISO date format', () => {
const result = ChangeMetadataSchema.safeParse({
schema: 'spec-driven',
created: '2025-1-5', // Missing leading zeros
});
expect(result.success).toBe(false);
});
});
});
describe('writeChangeMetadata', () => {
let testDir: string;
let changeDir: string;
beforeEach(async () => {
testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);
changeDir = path.join(testDir, 'openspec', 'changes', 'test-change');
await fs.mkdir(changeDir, { recursive: true });
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
it('should write valid YAML metadata file', async () => {
writeChangeMetadata(changeDir, {
schema: 'spec-driven',
created: '2025-01-05',
});
const metaPath = path.join(changeDir, '.openspec.yaml');
const content = await fs.readFile(metaPath, 'utf-8');
expect(content).toContain('schema: spec-driven');
expect(content).toContain('created: 2025-01-05');
});
it('should throw error for unknown schema', () => {
expect(() =>
writeChangeMetadata(changeDir, {
schema: 'unknown-schema',
created: '2025-01-05',
})
).toThrow(/Unknown schema 'unknown-schema'/);
});
});
describe('readChangeMetadata', () => {
let testDir: string;
let changeDir: string;
beforeEach(async () => {
testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);
changeDir = path.join(testDir, 'openspec', 'changes', 'test-change');
await fs.mkdir(changeDir, { recursive: true });
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
it('should return null when no metadata file exists', () => {
const result = readChangeMetadata(changeDir);
expect(result).toBeNull();
});
it('should read valid metadata', async () => {
const metaPath = path.join(changeDir, '.openspec.yaml');
await fs.writeFile(
metaPath,
'schema: spec-driven\ncreated: "2025-01-05"\n',
'utf-8'
);
const result = readChangeMetadata(changeDir);
expect(result).toEqual({
schema: 'spec-driven',
created: '2025-01-05',
});
});
it('should throw ChangeMetadataError for invalid YAML', async () => {
const metaPath = path.join(changeDir, '.openspec.yaml');
await fs.writeFile(metaPath, '{ invalid yaml', 'utf-8');
expect(() => readChangeMetadata(changeDir)).toThrow(ChangeMetadataError);
});
it('should throw ChangeMetadataError for missing schema field', async () => {
const metaPath = path.join(changeDir, '.openspec.yaml');
await fs.writeFile(metaPath, 'created: "2025-01-05"\n', 'utf-8');
expect(() => readChangeMetadata(changeDir)).toThrow(ChangeMetadataError);
});
it('should throw ChangeMetadataError for unknown schema', async () => {
const metaPath = path.join(changeDir, '.openspec.yaml');
await fs.writeFile(metaPath, 'schema: unknown-schema\n', 'utf-8');
expect(() => readChangeMetadata(changeDir)).toThrow(/Unknown schema/);
});
});
describe('resolveSchemaForChange', () => {
let testDir: string;
let changeDir: string;
beforeEach(async () => {
testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);
changeDir = path.join(testDir, 'openspec', 'changes', 'test-change');
await fs.mkdir(changeDir, { recursive: true });
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
it('should return explicit schema when provided', async () => {
// Even with metadata file, explicit schema wins
const metaPath = path.join(changeDir, '.openspec.yaml');
await fs.writeFile(metaPath, 'schema: spec-driven\n', 'utf-8');
const result = resolveSchemaForChange(changeDir, 'tdd');
expect(result).toBe('tdd');
});
it('should return schema from metadata when no explicit schema', async () => {
const metaPath = path.join(changeDir, '.openspec.yaml');
await fs.writeFile(metaPath, 'schema: spec-driven\n', 'utf-8');
const result = resolveSchemaForChange(changeDir);
expect(result).toBe('spec-driven');
});
it('should return default when no metadata and no explicit schema', () => {
const result = resolveSchemaForChange(changeDir);
expect(result).toBe('spec-driven');
});
it('should return default when metadata read fails', async () => {
// Create an invalid metadata file
const metaPath = path.join(changeDir, '.openspec.yaml');
await fs.writeFile(metaPath, '{ invalid yaml', 'utf-8');
// Should fall back to default, not throw
const result = resolveSchemaForChange(changeDir);
expect(result).toBe('spec-driven');
});
});
describe('validateSchemaName', () => {
it('should accept valid schema name', () => {
expect(() => validateSchemaName('spec-driven')).not.toThrow();
});
it('should throw for unknown schema', () => {
expect(() => validateSchemaName('unknown-schema')).toThrow(
/Unknown schema 'unknown-schema'/
);
});
});
+25
View File
@@ -128,6 +128,31 @@ describe('createChange', () => {
const stats = await fs.stat(changeDir);
expect(stats.isDirectory()).toBe(true);
});
it('should create .openspec.yaml metadata file with default schema', async () => {
await createChange(testDir, 'add-auth');
const metaPath = path.join(testDir, 'openspec', 'changes', 'add-auth', '.openspec.yaml');
const content = await fs.readFile(metaPath, 'utf-8');
expect(content).toContain('schema: spec-driven');
expect(content).toMatch(/created: \d{4}-\d{2}-\d{2}/);
});
it('should create .openspec.yaml with custom schema', async () => {
await createChange(testDir, 'add-auth', { schema: 'tdd' });
const metaPath = path.join(testDir, 'openspec', 'changes', 'add-auth', '.openspec.yaml');
const content = await fs.readFile(metaPath, 'utf-8');
expect(content).toContain('schema: tdd');
});
});
describe('schema validation', () => {
it('should throw error for unknown schema', async () => {
await expect(createChange(testDir, 'add-auth', { schema: 'unknown-schema' })).rejects.toThrow(
/Unknown schema/
);
});
});
describe('duplicate change throws error', () => {