Files
fission-ai__openspec/test/core/artifact-graph/instruction-loader.test.ts
Tabish Bidiwale 1786684af6 feat: add per-change schema metadata (.openspec.yaml) (#443)
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:13:52 -08:00

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);
});
});
});