From afce92eb78e5d387782c421037e26d660f5e2696 Mon Sep 17 00:00:00 2001 From: Tabish Bidiwale Date: Mon, 5 Jan 2026 17:04:16 -0800 Subject: [PATCH] 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' --- .../add-per-change-schema-metadata/tasks.md | 30 +-- src/commands/artifact-workflow.ts | 56 +++-- src/core/artifact-graph/instruction-loader.ts | 20 +- src/core/artifact-graph/types.ts | 18 ++ src/utils/change-metadata.ts | 171 +++++++++++++ src/utils/change-utils.ts | 35 ++- src/utils/index.ts | 11 +- .../artifact-graph/instruction-loader.test.ts | 36 +++ test/utils/change-metadata.test.ts | 224 ++++++++++++++++++ test/utils/change-utils.test.ts | 25 ++ 10 files changed, 586 insertions(+), 40 deletions(-) create mode 100644 src/utils/change-metadata.ts create mode 100644 test/utils/change-metadata.test.ts diff --git a/openspec/changes/add-per-change-schema-metadata/tasks.md b/openspec/changes/add-per-change-schema-metadata/tasks.md index 0c3e861d1..585313116 100644 --- a/openspec/changes/add-per-change-schema-metadata/tasks.md +++ b/openspec/changes/add-per-change-schema-metadata/tasks.md @@ -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 ` option to `openspec new change` command -- [ ] 4.2 Verify existing commands (`status`, `instructions`) work with auto-detection +- [x] 4.1 Add `--schema ` 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 diff --git a/src/commands/artifact-workflow.ts b/src/commands/artifact-workflow.ts index cedd5145b..e5496b149 100644 --- a/src/commands/artifact-workflow.ts +++ b/src/commands/artifact-workflow.ts @@ -187,9 +187,14 @@ async function statusCommand(options: StatusOptions): Promise { 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 . 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 { + // 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 { interface NewChangeOptions { description?: string; + schema?: string; } async function newChangeCommand(name: string | undefined, options: NewChangeOptions): Promise { @@ -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; @@ -766,7 +789,7 @@ export function registerArtifactWorkflowCommands(program: Command): void { .command('status') .description('[Experimental] Display artifact completion status for a change') .option('--change ', 'Change name to show status for') - .option('--schema ', `Schema to use (default: ${DEFAULT_SCHEMA})`) + .option('--schema ', 'Schema override (auto-detected from .openspec.yaml)') .option('--json', 'Output as JSON') .action(async (options: StatusOptions) => { try { @@ -783,7 +806,7 @@ export function registerArtifactWorkflowCommands(program: Command): void { .command('instructions [artifact]') .description('[Experimental] Output enriched instructions for creating an artifact or applying tasks') .option('--change ', 'Change name') - .option('--schema ', `Schema to use (default: ${DEFAULT_SCHEMA})`) + .option('--schema ', 'Schema override (auto-detected from .openspec.yaml)') .option('--json', 'Output as JSON') .action(async (artifactId: string | undefined, options: InstructionsOptions) => { try { @@ -823,6 +846,7 @@ export function registerArtifactWorkflowCommands(program: Command): void { .command('change ') .description('[Experimental] Create a new change directory') .option('--description ', 'Description to add to README.md') + .option('--schema ', `Workflow schema to use (default: ${DEFAULT_SCHEMA})`) .action(async (name: string, options: NewChangeOptions) => { try { await newChangeCommand(name, options); diff --git a/src/core/artifact-graph/instruction-loader.ts b/src/core/artifact-graph/instruction-loader.ts index 2db20ec66..4432e512e 100644 --- a/src/core/artifact-graph/instruction-loader.ts +++ b/src/core/artifact-graph/instruction-loader.ts @@ -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, }; diff --git a/src/core/artifact-graph/types.ts b/src/core/artifact-graph/types.ts index 1175e405d..c5e2637c7 100644 --- a/src/core/artifact-graph/types.ts +++ b/src/core/artifact-graph/types.ts @@ -22,6 +22,24 @@ export const SchemaYamlSchema = z.object({ export type Artifact = z.infer; export type SchemaYaml = z.infer; +// 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; + // Runtime state types (not Zod - internal only) // Slice 1: Simple completion tracking via filesystem diff --git a/src/utils/change-metadata.ts b/src/utils/change-metadata.ts new file mode 100644 index 000000000..537284d0f --- /dev/null +++ b/src/utils/change-metadata.ts @@ -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'; +} diff --git a/src/utils/change-utils.ts b/src/utils/change-utils.ts index 0fcdc2b73..31222c72e 100644 --- a/src/utils/change-utils.ts +++ b/src/utils/change-utils.ts @@ -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 { // 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, + }); } diff --git a/src/utils/index.ts b/src/utils/index.ts index 46862b547..6e21a30ee 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,12 @@ // Shared utilities export { validateChangeName, createChange } from './change-utils.js'; -export type { ValidationResult } from './change-utils.js'; \ No newline at end of file +export type { ValidationResult, CreateChangeOptions } from './change-utils.js'; + +// Change metadata utilities +export { + readChangeMetadata, + writeChangeMetadata, + resolveSchemaForChange, + validateSchemaName, + ChangeMetadataError, +} from './change-metadata.js'; \ No newline at end of file diff --git a/test/core/artifact-graph/instruction-loader.test.ts b/test/core/artifact-graph/instruction-loader.test.ts index 007b1b4f2..1d0e779fd 100644 --- a/test/core/artifact-graph/instruction-loader.test.ts +++ b/test/core/artifact-graph/instruction-loader.test.ts @@ -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', () => { diff --git a/test/utils/change-metadata.test.ts b/test/utils/change-metadata.test.ts new file mode 100644 index 000000000..fdbb94220 --- /dev/null +++ b/test/utils/change-metadata.test.ts @@ -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'/ + ); + }); +}); diff --git a/test/utils/change-utils.test.ts b/test/utils/change-utils.test.ts index 1487e7af7..8e94260fa 100644 --- a/test/utils/change-utils.test.ts +++ b/test/utils/change-utils.test.ts @@ -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', () => {