UNPKG

@fission-ai/openspec

Version:

AI-native system for spec-driven development

214 lines 8.32 kB
import * as fs from 'node:fs'; 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 { readProjectConfig, validateConfigRules } from '../project-config.js'; // Session-level cache for validation warnings (avoid repeating same warnings) const shownWarnings = new Set(); /** * Error thrown when loading a template fails. */ export class TemplateLoadError extends Error { templatePath; constructor(message, templatePath) { super(message); this.templatePath = templatePath; this.name = 'TemplateLoadError'; } } /** * Loads a template from a schema's templates directory. * * @param schemaName - Schema name (e.g., "spec-driven") * @param templatePath - Relative path within the templates directory (e.g., "proposal.md") * @param projectRoot - Optional project root for project-local schema resolution * @returns The template content * @throws TemplateLoadError if the template cannot be loaded */ export function loadTemplate(schemaName, templatePath, projectRoot) { const schemaDir = getSchemaDir(schemaName, projectRoot); if (!schemaDir) { throw new TemplateLoadError(`Schema '${schemaName}' not found`, templatePath); } const fullPath = path.join(schemaDir, 'templates', templatePath); if (!fs.existsSync(fullPath)) { throw new TemplateLoadError(`Template not found: ${fullPath}`, fullPath); } try { return fs.readFileSync(fullPath, 'utf-8'); } catch (err) { const ioError = err instanceof Error ? err : new Error(String(err)); throw new TemplateLoadError(`Failed to read template: ${ioError.message}`, fullPath); } } /** * 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 override. If not provided, auto-detected from metadata. * @returns Change context with graph, completed set, and metadata */ export function loadChangeContext(projectRoot, changeName, schemaName) { const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName); // Resolve schema: explicit > metadata > default const resolvedSchemaName = resolveSchemaForChange(changeDir, schemaName); const schema = resolveSchema(resolvedSchemaName, projectRoot); const graph = ArtifactGraph.fromSchema(schema); const completed = detectCompleted(graph, changeDir); return { graph, completed, schemaName: resolvedSchemaName, changeName, changeDir, projectRoot, }; } /** * Generates enriched instructions for creating an artifact. * * Instruction injection order: * 1. <context> - Project context from config (if present) * 2. <rules> - Artifact-specific rules from config (if present) * 3. <template> - Schema's template content * * @param context - Change context * @param artifactId - Artifact ID to generate instructions for * @param projectRoot - Project root directory (for reading config) * @returns Enriched artifact instructions * @throws Error if artifact not found */ export function generateInstructions(context, artifactId, projectRoot) { const artifact = context.graph.getArtifact(artifactId); if (!artifact) { throw new Error(`Artifact '${artifactId}' not found in schema '${context.schemaName}'`); } const templateContent = loadTemplate(context.schemaName, artifact.template, context.projectRoot); const dependencies = getDependencyInfo(artifact, context.graph, context.completed); const unlocks = getUnlockedArtifacts(context.graph, artifactId); // Use projectRoot from context if not explicitly provided const effectiveProjectRoot = projectRoot ?? context.projectRoot; // Try to read project config for context and rules let projectConfig = null; if (effectiveProjectRoot) { try { projectConfig = readProjectConfig(effectiveProjectRoot); } catch { // If config read fails, continue without config } } // Validate rules artifact IDs if config has rules (only once per session) if (projectConfig?.rules) { const validArtifactIds = new Set(context.graph.getAllArtifacts().map((a) => a.id)); const warnings = validateConfigRules(projectConfig.rules, validArtifactIds, context.schemaName); // Show each unique warning only once per session for (const warning of warnings) { if (!shownWarnings.has(warning)) { console.warn(warning); shownWarnings.add(warning); } } } // Extract context and rules as separate fields (not prepended to template) const configContext = projectConfig?.context?.trim() || undefined; const rulesForArtifact = projectConfig?.rules?.[artifactId]; const configRules = rulesForArtifact && rulesForArtifact.length > 0 ? rulesForArtifact : undefined; return { changeName: context.changeName, artifactId: artifact.id, schemaName: context.schemaName, changeDir: context.changeDir, outputPath: artifact.generates, description: artifact.description, instruction: artifact.instruction, context: configContext, rules: configRules, template: templateContent, dependencies, unlocks, }; } /** * Gets dependency info including paths and descriptions. */ function getDependencyInfo(artifact, graph, completed) { return artifact.requires.map(id => { const depArtifact = graph.getArtifact(id); return { id, done: completed.has(id), path: depArtifact?.generates ?? id, description: depArtifact?.description ?? '', }; }); } /** * Gets artifacts that become available after completing the given artifact. */ function getUnlockedArtifacts(graph, artifactId) { const unlocks = []; for (const artifact of graph.getAllArtifacts()) { if (artifact.requires.includes(artifactId)) { unlocks.push(artifact.id); } } return unlocks.sort(); } /** * Formats the status of all artifacts in a change. * * @param context - Change context * @returns Formatted change status */ export function formatChangeStatus(context) { // Load schema to get apply phase configuration const schema = resolveSchema(context.schemaName, context.projectRoot); const applyRequires = schema.apply?.requires ?? schema.artifacts.map(a => a.id); const artifacts = context.graph.getAllArtifacts(); const ready = new Set(context.graph.getNextArtifacts(context.completed)); const blocked = context.graph.getBlocked(context.completed); const artifactStatuses = artifacts.map(artifact => { if (context.completed.has(artifact.id)) { return { id: artifact.id, outputPath: artifact.generates, status: 'done', }; } if (ready.has(artifact.id)) { return { id: artifact.id, outputPath: artifact.generates, status: 'ready', }; } return { id: artifact.id, outputPath: artifact.generates, status: 'blocked', missingDeps: blocked[artifact.id] ?? [], }; }); // Sort by build order for consistent output const buildOrder = context.graph.getBuildOrder(); const orderMap = new Map(buildOrder.map((id, idx) => [id, idx])); artifactStatuses.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0)); return { changeName: context.changeName, schemaName: context.schemaName, isComplete: context.graph.isComplete(context.completed), applyRequires, artifacts: artifactStatuses, }; } //# sourceMappingURL=instruction-loader.js.map