mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-09-14 20:16:53 +08:00
1786684af6
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'
225 lines
6.8 KiB
TypeScript
225 lines
6.8 KiB
TypeScript
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'/
|
|
);
|
|
});
|
|
});
|