|
| 1 | +import * as fs from 'node:fs'; |
| 2 | +import * as path from 'node:path'; |
| 3 | +import { getSchemaDir, resolveSchema } from './resolver.js'; |
| 4 | +import { ArtifactGraph } from './graph.js'; |
| 5 | +import { detectCompleted } from './state.js'; |
| 6 | +import type { Artifact, CompletedSet } from './types.js'; |
| 7 | + |
| 8 | +/** |
| 9 | + * Error thrown when loading a template fails. |
| 10 | + */ |
| 11 | +export class TemplateLoadError extends Error { |
| 12 | + constructor( |
| 13 | + message: string, |
| 14 | + public readonly templatePath: string |
| 15 | + ) { |
| 16 | + super(message); |
| 17 | + this.name = 'TemplateLoadError'; |
| 18 | + } |
| 19 | +} |
| 20 | + |
| 21 | +/** |
| 22 | + * Change context containing graph, completion state, and metadata. |
| 23 | + */ |
| 24 | +export interface ChangeContext { |
| 25 | + /** The artifact dependency graph */ |
| 26 | + graph: ArtifactGraph; |
| 27 | + /** Set of completed artifact IDs */ |
| 28 | + completed: CompletedSet; |
| 29 | + /** Schema name being used */ |
| 30 | + schemaName: string; |
| 31 | + /** Change name */ |
| 32 | + changeName: string; |
| 33 | + /** Path to the change directory */ |
| 34 | + changeDir: string; |
| 35 | +} |
| 36 | + |
| 37 | +/** |
| 38 | + * Enriched instructions for creating an artifact. |
| 39 | + */ |
| 40 | +export interface ArtifactInstructions { |
| 41 | + /** Change name */ |
| 42 | + changeName: string; |
| 43 | + /** Artifact ID */ |
| 44 | + artifactId: string; |
| 45 | + /** Schema name */ |
| 46 | + schemaName: string; |
| 47 | + /** Output path pattern (e.g., "proposal.md") */ |
| 48 | + outputPath: string; |
| 49 | + /** Artifact description */ |
| 50 | + description: string; |
| 51 | + /** Template content */ |
| 52 | + template: string; |
| 53 | + /** Dependencies with completion status */ |
| 54 | + dependencies: DependencyStatus[]; |
| 55 | + /** Artifacts that become available after completing this one */ |
| 56 | + unlocks: string[]; |
| 57 | +} |
| 58 | + |
| 59 | +/** |
| 60 | + * Dependency status information. |
| 61 | + */ |
| 62 | +export interface DependencyStatus { |
| 63 | + /** Artifact ID */ |
| 64 | + id: string; |
| 65 | + /** Whether the dependency is completed */ |
| 66 | + done: boolean; |
| 67 | +} |
| 68 | + |
| 69 | +/** |
| 70 | + * Status of a single artifact in the workflow. |
| 71 | + */ |
| 72 | +export interface ArtifactStatus { |
| 73 | + /** Artifact ID */ |
| 74 | + id: string; |
| 75 | + /** Output path pattern */ |
| 76 | + outputPath: string; |
| 77 | + /** Status: done, ready, or blocked */ |
| 78 | + status: 'done' | 'ready' | 'blocked'; |
| 79 | + /** Missing dependencies (only for blocked) */ |
| 80 | + missingDeps?: string[]; |
| 81 | +} |
| 82 | + |
| 83 | +/** |
| 84 | + * Formatted change status. |
| 85 | + */ |
| 86 | +export interface ChangeStatus { |
| 87 | + /** Change name */ |
| 88 | + changeName: string; |
| 89 | + /** Schema name */ |
| 90 | + schemaName: string; |
| 91 | + /** Whether all artifacts are complete */ |
| 92 | + isComplete: boolean; |
| 93 | + /** Status of each artifact */ |
| 94 | + artifacts: ArtifactStatus[]; |
| 95 | +} |
| 96 | + |
| 97 | +/** |
| 98 | + * Loads a template from a schema's templates directory. |
| 99 | + * |
| 100 | + * @param schemaName - Schema name (e.g., "spec-driven") |
| 101 | + * @param templatePath - Relative path within the templates directory (e.g., "proposal.md") |
| 102 | + * @returns The template content |
| 103 | + * @throws TemplateLoadError if the template cannot be loaded |
| 104 | + */ |
| 105 | +export function loadTemplate(schemaName: string, templatePath: string): string { |
| 106 | + const schemaDir = getSchemaDir(schemaName); |
| 107 | + if (!schemaDir) { |
| 108 | + throw new TemplateLoadError( |
| 109 | + `Schema '${schemaName}' not found`, |
| 110 | + templatePath |
| 111 | + ); |
| 112 | + } |
| 113 | + |
| 114 | + const fullPath = path.join(schemaDir, 'templates', templatePath); |
| 115 | + |
| 116 | + if (!fs.existsSync(fullPath)) { |
| 117 | + throw new TemplateLoadError( |
| 118 | + `Template not found: ${fullPath}`, |
| 119 | + fullPath |
| 120 | + ); |
| 121 | + } |
| 122 | + |
| 123 | + try { |
| 124 | + return fs.readFileSync(fullPath, 'utf-8'); |
| 125 | + } catch (err) { |
| 126 | + const ioError = err instanceof Error ? err : new Error(String(err)); |
| 127 | + throw new TemplateLoadError( |
| 128 | + `Failed to read template: ${ioError.message}`, |
| 129 | + fullPath |
| 130 | + ); |
| 131 | + } |
| 132 | +} |
| 133 | + |
| 134 | +/** |
| 135 | + * Loads change context combining graph and completion state. |
| 136 | + * |
| 137 | + * @param projectRoot - Project root directory |
| 138 | + * @param changeName - Change name |
| 139 | + * @param schemaName - Optional schema name (defaults to "spec-driven") |
| 140 | + * @returns Change context with graph, completed set, and metadata |
| 141 | + */ |
| 142 | +export function loadChangeContext( |
| 143 | + projectRoot: string, |
| 144 | + changeName: string, |
| 145 | + schemaName: string = 'spec-driven' |
| 146 | +): ChangeContext { |
| 147 | + const schema = resolveSchema(schemaName); |
| 148 | + const graph = ArtifactGraph.fromSchema(schema); |
| 149 | + const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName); |
| 150 | + const completed = detectCompleted(graph, changeDir); |
| 151 | + |
| 152 | + return { |
| 153 | + graph, |
| 154 | + completed, |
| 155 | + schemaName, |
| 156 | + changeName, |
| 157 | + changeDir, |
| 158 | + }; |
| 159 | +} |
| 160 | + |
| 161 | +/** |
| 162 | + * Generates enriched instructions for creating an artifact. |
| 163 | + * |
| 164 | + * @param context - Change context |
| 165 | + * @param artifactId - Artifact ID to generate instructions for |
| 166 | + * @returns Enriched artifact instructions |
| 167 | + * @throws Error if artifact not found |
| 168 | + */ |
| 169 | +export function generateInstructions( |
| 170 | + context: ChangeContext, |
| 171 | + artifactId: string |
| 172 | +): ArtifactInstructions { |
| 173 | + const artifact = context.graph.getArtifact(artifactId); |
| 174 | + if (!artifact) { |
| 175 | + throw new Error(`Artifact '${artifactId}' not found in schema '${context.schemaName}'`); |
| 176 | + } |
| 177 | + |
| 178 | + const template = loadTemplate(context.schemaName, artifact.template); |
| 179 | + const dependencies = getDependencyStatus(artifact, context.completed); |
| 180 | + const unlocks = getUnlockedArtifacts(context.graph, artifactId); |
| 181 | + |
| 182 | + return { |
| 183 | + changeName: context.changeName, |
| 184 | + artifactId: artifact.id, |
| 185 | + schemaName: context.schemaName, |
| 186 | + outputPath: artifact.generates, |
| 187 | + description: artifact.description, |
| 188 | + template, |
| 189 | + dependencies, |
| 190 | + unlocks, |
| 191 | + }; |
| 192 | +} |
| 193 | + |
| 194 | +/** |
| 195 | + * Gets dependency status for an artifact. |
| 196 | + */ |
| 197 | +function getDependencyStatus( |
| 198 | + artifact: Artifact, |
| 199 | + completed: CompletedSet |
| 200 | +): DependencyStatus[] { |
| 201 | + return artifact.requires.map(id => ({ |
| 202 | + id, |
| 203 | + done: completed.has(id), |
| 204 | + })); |
| 205 | +} |
| 206 | + |
| 207 | +/** |
| 208 | + * Gets artifacts that become available after completing the given artifact. |
| 209 | + */ |
| 210 | +function getUnlockedArtifacts(graph: ArtifactGraph, artifactId: string): string[] { |
| 211 | + const unlocks: string[] = []; |
| 212 | + |
| 213 | + for (const artifact of graph.getAllArtifacts()) { |
| 214 | + if (artifact.requires.includes(artifactId)) { |
| 215 | + unlocks.push(artifact.id); |
| 216 | + } |
| 217 | + } |
| 218 | + |
| 219 | + return unlocks.sort(); |
| 220 | +} |
| 221 | + |
| 222 | +/** |
| 223 | + * Formats the status of all artifacts in a change. |
| 224 | + * |
| 225 | + * @param context - Change context |
| 226 | + * @returns Formatted change status |
| 227 | + */ |
| 228 | +export function formatChangeStatus(context: ChangeContext): ChangeStatus { |
| 229 | + const artifacts = context.graph.getAllArtifacts(); |
| 230 | + const ready = new Set(context.graph.getNextArtifacts(context.completed)); |
| 231 | + const blocked = context.graph.getBlocked(context.completed); |
| 232 | + |
| 233 | + const artifactStatuses: ArtifactStatus[] = artifacts.map(artifact => { |
| 234 | + if (context.completed.has(artifact.id)) { |
| 235 | + return { |
| 236 | + id: artifact.id, |
| 237 | + outputPath: artifact.generates, |
| 238 | + status: 'done' as const, |
| 239 | + }; |
| 240 | + } |
| 241 | + |
| 242 | + if (ready.has(artifact.id)) { |
| 243 | + return { |
| 244 | + id: artifact.id, |
| 245 | + outputPath: artifact.generates, |
| 246 | + status: 'ready' as const, |
| 247 | + }; |
| 248 | + } |
| 249 | + |
| 250 | + return { |
| 251 | + id: artifact.id, |
| 252 | + outputPath: artifact.generates, |
| 253 | + status: 'blocked' as const, |
| 254 | + missingDeps: blocked[artifact.id] ?? [], |
| 255 | + }; |
| 256 | + }); |
| 257 | + |
| 258 | + // Sort by build order for consistent output |
| 259 | + const buildOrder = context.graph.getBuildOrder(); |
| 260 | + const orderMap = new Map(buildOrder.map((id, idx) => [id, idx])); |
| 261 | + artifactStatuses.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0)); |
| 262 | + |
| 263 | + return { |
| 264 | + changeName: context.changeName, |
| 265 | + schemaName: context.schemaName, |
| 266 | + isComplete: context.graph.isComplete(context.completed), |
| 267 | + artifacts: artifactStatuses, |
| 268 | + }; |
| 269 | +} |
0 commit comments