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'
301 lines
11 KiB
TypeScript
301 lines
11 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import * as os from 'node:os';
|
|
import {
|
|
loadTemplate,
|
|
loadChangeContext,
|
|
generateInstructions,
|
|
formatChangeStatus,
|
|
TemplateLoadError,
|
|
} from '../../../src/core/artifact-graph/instruction-loader.js';
|
|
|
|
describe('instruction-loader', () => {
|
|
describe('loadTemplate', () => {
|
|
it('should load template from schema directory', () => {
|
|
// Uses built-in spec-driven schema
|
|
const template = loadTemplate('spec-driven', 'proposal.md');
|
|
|
|
expect(template).toContain('## Why');
|
|
expect(template).toContain('## What Changes');
|
|
});
|
|
|
|
it('should throw TemplateLoadError for non-existent template', () => {
|
|
expect(() => loadTemplate('spec-driven', 'nonexistent.md')).toThrow(
|
|
TemplateLoadError
|
|
);
|
|
});
|
|
|
|
it('should throw TemplateLoadError for non-existent schema', () => {
|
|
expect(() => loadTemplate('nonexistent-schema', 'proposal.md')).toThrow(
|
|
TemplateLoadError
|
|
);
|
|
});
|
|
|
|
it('should include template path in error', () => {
|
|
try {
|
|
loadTemplate('spec-driven', 'nonexistent.md');
|
|
expect.fail('Should have thrown');
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(TemplateLoadError);
|
|
expect((err as TemplateLoadError).templatePath).toContain('nonexistent.md');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('loadChangeContext', () => {
|
|
let tempDir: string;
|
|
|
|
beforeEach(() => {
|
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-test-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should load context with default schema', () => {
|
|
const context = loadChangeContext(tempDir, 'my-change');
|
|
|
|
expect(context.schemaName).toBe('spec-driven');
|
|
expect(context.changeName).toBe('my-change');
|
|
expect(context.graph.getName()).toBe('spec-driven');
|
|
expect(context.completed.size).toBe(0);
|
|
});
|
|
|
|
it('should load context with custom schema', () => {
|
|
const context = loadChangeContext(tempDir, 'my-change', 'tdd');
|
|
|
|
expect(context.schemaName).toBe('tdd');
|
|
expect(context.graph.getName()).toBe('tdd');
|
|
});
|
|
|
|
it('should detect completed artifacts', () => {
|
|
// Create change directory with proposal.md
|
|
const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');
|
|
fs.mkdirSync(changeDir, { recursive: true });
|
|
fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal');
|
|
|
|
const context = loadChangeContext(tempDir, 'my-change');
|
|
|
|
expect(context.completed.has('proposal')).toBe(true);
|
|
});
|
|
|
|
it('should return empty completed set for non-existent change directory', () => {
|
|
const context = loadChangeContext(tempDir, 'nonexistent-change');
|
|
|
|
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', () => {
|
|
let tempDir: string;
|
|
|
|
beforeEach(() => {
|
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-test-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should include artifact metadata', () => {
|
|
const context = loadChangeContext(tempDir, 'my-change');
|
|
const instructions = generateInstructions(context, 'proposal');
|
|
|
|
expect(instructions.changeName).toBe('my-change');
|
|
expect(instructions.artifactId).toBe('proposal');
|
|
expect(instructions.schemaName).toBe('spec-driven');
|
|
expect(instructions.outputPath).toBe('proposal.md');
|
|
});
|
|
|
|
it('should include template content', () => {
|
|
const context = loadChangeContext(tempDir, 'my-change');
|
|
const instructions = generateInstructions(context, 'proposal');
|
|
|
|
expect(instructions.template).toContain('## Why');
|
|
});
|
|
|
|
it('should show dependencies with completion status', () => {
|
|
const context = loadChangeContext(tempDir, 'my-change');
|
|
const instructions = generateInstructions(context, 'specs');
|
|
|
|
expect(instructions.dependencies).toHaveLength(1);
|
|
expect(instructions.dependencies[0].id).toBe('proposal');
|
|
expect(instructions.dependencies[0].done).toBe(false);
|
|
});
|
|
|
|
it('should mark completed dependencies as done', () => {
|
|
// Create proposal
|
|
const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');
|
|
fs.mkdirSync(changeDir, { recursive: true });
|
|
fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal');
|
|
|
|
const context = loadChangeContext(tempDir, 'my-change');
|
|
const instructions = generateInstructions(context, 'specs');
|
|
|
|
expect(instructions.dependencies[0].done).toBe(true);
|
|
});
|
|
|
|
it('should list artifacts unlocked by this one', () => {
|
|
const context = loadChangeContext(tempDir, 'my-change');
|
|
const instructions = generateInstructions(context, 'proposal');
|
|
|
|
// proposal unlocks specs and design
|
|
expect(instructions.unlocks).toContain('specs');
|
|
expect(instructions.unlocks).toContain('design');
|
|
});
|
|
|
|
it('should have empty dependencies for root artifact', () => {
|
|
const context = loadChangeContext(tempDir, 'my-change');
|
|
const instructions = generateInstructions(context, 'proposal');
|
|
|
|
expect(instructions.dependencies).toHaveLength(0);
|
|
});
|
|
|
|
it('should throw for non-existent artifact', () => {
|
|
const context = loadChangeContext(tempDir, 'my-change');
|
|
|
|
expect(() => generateInstructions(context, 'nonexistent')).toThrow(
|
|
"Artifact 'nonexistent' not found"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('formatChangeStatus', () => {
|
|
let tempDir: string;
|
|
|
|
beforeEach(() => {
|
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-test-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should show all artifacts as ready/blocked when nothing completed', () => {
|
|
const context = loadChangeContext(tempDir, 'my-change');
|
|
const status = formatChangeStatus(context);
|
|
|
|
expect(status.changeName).toBe('my-change');
|
|
expect(status.schemaName).toBe('spec-driven');
|
|
expect(status.isComplete).toBe(false);
|
|
|
|
// proposal has no deps, should be ready
|
|
const proposal = status.artifacts.find(a => a.id === 'proposal');
|
|
expect(proposal?.status).toBe('ready');
|
|
|
|
// specs depends on proposal, should be blocked
|
|
const specs = status.artifacts.find(a => a.id === 'specs');
|
|
expect(specs?.status).toBe('blocked');
|
|
expect(specs?.missingDeps).toContain('proposal');
|
|
});
|
|
|
|
it('should show completed artifacts as done', () => {
|
|
const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');
|
|
fs.mkdirSync(changeDir, { recursive: true });
|
|
fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal');
|
|
|
|
const context = loadChangeContext(tempDir, 'my-change');
|
|
const status = formatChangeStatus(context);
|
|
|
|
const proposal = status.artifacts.find(a => a.id === 'proposal');
|
|
expect(proposal?.status).toBe('done');
|
|
|
|
// specs should now be ready
|
|
const specs = status.artifacts.find(a => a.id === 'specs');
|
|
expect(specs?.status).toBe('ready');
|
|
});
|
|
|
|
it('should include output paths for each artifact', () => {
|
|
const context = loadChangeContext(tempDir, 'my-change');
|
|
const status = formatChangeStatus(context);
|
|
|
|
const proposal = status.artifacts.find(a => a.id === 'proposal');
|
|
expect(proposal?.outputPath).toBe('proposal.md');
|
|
|
|
const specs = status.artifacts.find(a => a.id === 'specs');
|
|
expect(specs?.outputPath).toBe('specs/**/*.md');
|
|
});
|
|
|
|
it('should report isComplete true when all done', () => {
|
|
const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');
|
|
fs.mkdirSync(changeDir, { recursive: true });
|
|
fs.mkdirSync(path.join(changeDir, 'specs'), { recursive: true });
|
|
|
|
// Create all required files for spec-driven schema
|
|
fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal');
|
|
fs.writeFileSync(path.join(changeDir, 'specs', 'test.md'), '# Spec');
|
|
fs.writeFileSync(path.join(changeDir, 'design.md'), '# Design');
|
|
fs.writeFileSync(path.join(changeDir, 'tasks.md'), '# Tasks');
|
|
|
|
const context = loadChangeContext(tempDir, 'my-change');
|
|
const status = formatChangeStatus(context);
|
|
|
|
expect(status.isComplete).toBe(true);
|
|
expect(status.artifacts.every(a => a.status === 'done')).toBe(true);
|
|
});
|
|
|
|
it('should show blocked artifacts with missing dependencies', () => {
|
|
const context = loadChangeContext(tempDir, 'my-change');
|
|
const status = formatChangeStatus(context);
|
|
|
|
// tasks requires specs and design
|
|
const tasks = status.artifacts.find(a => a.id === 'tasks');
|
|
expect(tasks?.status).toBe('blocked');
|
|
expect(tasks?.missingDeps).toContain('specs');
|
|
expect(tasks?.missingDeps).toContain('design');
|
|
});
|
|
|
|
it('should sort artifacts in build order', () => {
|
|
const context = loadChangeContext(tempDir, 'my-change');
|
|
const status = formatChangeStatus(context);
|
|
|
|
const ids = status.artifacts.map(a => a.id);
|
|
const proposalIdx = ids.indexOf('proposal');
|
|
const specsIdx = ids.indexOf('specs');
|
|
const tasksIdx = ids.indexOf('tasks');
|
|
|
|
// proposal must come before specs, specs before tasks
|
|
expect(proposalIdx).toBeLessThan(specsIdx);
|
|
expect(specsIdx).toBeLessThan(tasksIdx);
|
|
});
|
|
});
|
|
});
|