mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-04 06:18:24 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcfea289e6 | ||
|
|
afce92eb78 |
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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
@@ -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', () => {
|
||||
|
||||
@@ -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'/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user