/** * realize command * Generate production code from specification * Generated from SpecVerse specification */ import { Command } from 'commander'; import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; import { resolve, join, dirname } from 'path'; import { EngineRegistry } from '@specverse/entities'; import type { ParserEngine, InferenceEngine, RealizeEngine } from '@specverse/types'; interface CommandOptions { output?: string; manifest?: string; static?: boolean; estimate?: boolean; } /** * Register the realize command on the program. */ export function registerRealizeCommand(program: Command): void { program .command('realize ') .description('Generate production code from specification') .option('-o, --output ', 'Output directory') .option('-m, --manifest ', 'Implementation manifest file') .option('--static', 'Generate full static frontend (no @specverse/runtime dependency)', false) .option('--estimate', 'Print expected output breakdown by realize layer (L1 instance factory / L2 convention pattern matching / L3 AI from steps) — runs parse + infer but skips realizeAll. Useful for cost-aware planning before committing to a full realize.', false) .action(async (type: string, file: string, options: CommandOptions) => { try { if (!existsSync(file)) { console.error('File not found:', file); process.exit(1); } // Discover engines const registry = new EngineRegistry(); await registry.discover(); // Parse — let the parser engine handle its own schema const parser = registry.getEngineForCapability('parse') as ParserEngine; if (!parser) { console.error('No parser engine found.'); process.exit(1); } await parser.initialize(); const content = readFileSync(file, 'utf8'); const parseResult = parser.parseContent(content, file); if (parseResult.errors.length > 0) { console.error('Invalid spec:'); parseResult.errors.forEach((e: string) => console.error(' ', e)); process.exit(1); } // Lifecycle + cross-model soundness check (TODO #31). Same gate // that runs in spv validate — abort realize before code generation // if the spec has lifecycle inconsistencies or dangling // relationship targets. R8 spirit: don't generate code from // unsound input. try { const { generateQuintFromSpec } = await import('@specverse/engines/inference/quint-gen'); const quintGen = generateQuintFromSpec(parseResult.ast!); if (quintGen.violations.length > 0) { console.error('Cannot realize — spec has soundness violations:'); for (const v of quintGen.violations) { console.error(' [' + v.kind + '] ' + v.message); } console.error('Run `spv validate ' + file + '` to inspect; fix the spec before realize.'); process.exit(1); } } catch (qe: any) { if (process.env.SPECVERSE_VERBOSE === '2') { console.warn('quint-gen check skipped:', qe?.message ?? String(qe)); } } // Infer — let the inference engine handle its own rules const inferEngine = registry.getEngineForCapability('infer') as InferenceEngine; if (!inferEngine) { console.error('No inference engine found.'); process.exit(1); } await inferEngine.initialize(); const inferResult = await inferEngine.infer(parseResult.ast!, { generateControllers: true, generateServices: true, generateEvents: true, generateViews: true, }); // Load inferred YAML and flatten component data for realize const yaml = await import('js-yaml'); const inferredYaml = yaml.load(inferResult.yaml) as any; // Inference output: { components: { Name: { models, controllers, ... } } } // realizeAll expects: { models: {...}, controllers: {...}, ... } (flat). // // Multi-component fan-out (TODO #49): for specs with multiple components // (e.g. idle-meta with 8 domain components, cal-com with O(10)), MERGE all // components into a single flat componentData rather than picking only the // first. Map sections (models/controllers/services/events/views/primitives/ // enums) merge by entity name — name collisions across components log a // warning and the LAST occurrence wins (matches Object.assign / spread // semantics; expected to be rare since the analyse prompt encourages // distinct names per component). const allComponents = inferredYaml?.components || {}; const componentNames = Object.keys(allComponents); const MAP_SECTIONS = ['models', 'controllers', 'services', 'events', 'views', 'primitives', 'enums', 'lifecycles']; const componentData: Record = {}; // Carry forward non-map metadata (version / description / name / componentName) // from the first component — realize uses these to label the generated bundle. if (componentNames.length > 0) { const first = allComponents[componentNames[0]]; for (const k of ['version', 'description', 'name', 'componentName', 'commonDefinitions']) { if (first[k] != null) componentData[k] = first[k]; } } for (const compName of componentNames) { const comp = allComponents[compName]; for (const section of MAP_SECTIONS) { const sectionData = comp[section]; if (!sectionData || typeof sectionData !== 'object') continue; componentData[section] = componentData[section] || {}; for (const [entryKey, entryVal] of Object.entries(sectionData)) { if (componentData[section][entryKey] != null) { console.warn('[realize] name collision: ' + section + '.' + entryKey + ' declared in multiple components; last occurrence (' + compName + ') wins'); } componentData[section][entryKey] = entryVal; } } } // Merge original spec's services/events/views that inference didn't generate // (inference generates from models; explicit services in the spec are preserved here). // Iterate ALL original components, not just the first, to mirror the // multi-component fan-out above. const origComponentsRaw = parseResult.ast!.components || []; const origComponents = Array.isArray(origComponentsRaw) ? origComponentsRaw : Object.values(origComponentsRaw); // Iterate ALL original components (TODO #49 — multi-component fan-out). // For each, merge any user-declared services/events/views that inference // didn't generate, plus per-entry-merge user-declared controller actions // into the inferred ones (TODO #48 — preserve inferred steps that orig // didn't override). Earlier behaviour processed only the first component; // for multi-component specs (idle-meta, cal-com) the rest were dropped. const inferredControllers = componentData.controllers || {}; for (const origComp of origComponents) { for (const section of ['services', 'events', 'views']) { const origData = (origComp as any)[section]; if (!origData) continue; // Only merge if the section is empty in componentData — preserves the // original "inference-generated wins on collision" semantic for the // sections inference produces (events/views). const existing = componentData[section]; const isEmpty = !existing || (typeof existing === 'object' && Object.keys(existing).length === 0); if (!isEmpty) continue; if (Array.isArray(origData)) { componentData[section] = {}; for (const item of origData) componentData[section][item.name] = item; } else { componentData[section] = origData; } } const origControllers = Array.isArray((origComp as any).controllers) ? (origComp as any).controllers : Object.values((origComp as any).controllers || {}); for (const origCtrl of origControllers) { const ctrlName = (origCtrl as any).name; if (!ctrlName || !(origCtrl as any).actions) continue; const inferredCtrl = inferredControllers[ctrlName]; if (!inferredCtrl) continue; const merged: Record = { ...(inferredCtrl.actions || {}) }; for (const [actName, origAct] of Object.entries((origCtrl as any).actions) as [string, any][]) { const inferredAct = merged[actName] || {}; const finalAct: Record = { ...inferredAct }; for (const [fieldKey, fieldVal] of Object.entries(origAct)) { const isEmptyArr = Array.isArray(fieldVal) && fieldVal.length === 0; const isEmptyObj = fieldVal && typeof fieldVal === 'object' && !Array.isArray(fieldVal) && Object.keys(fieldVal).length === 0; if (fieldVal == null || isEmptyArr || isEmptyObj) continue; finalAct[fieldKey] = fieldVal; } merged[actName] = finalAct; } inferredCtrl.actions = merged; } } // Merge service operation steps from original spec into inferred services. // Inference generates service shells with preconditions/returns but drops // the declarative steps arrays — we restore them from the parsed AST. // Iterates ALL original components (TODO #49) so multi-component specs // don't lose service.operations.steps from the non-first components. const inferredServices = componentData.services || {}; for (const origComp of origComponents) { const origServices = Array.isArray((origComp as any).services) ? (origComp as any).services : Object.values((origComp as any).services || {}); for (const origSvc of origServices) { const svcName = (origSvc as any).name; if (!svcName) continue; const inferredSvc = inferredServices[svcName]; if (!inferredSvc) continue; const origOps = (origSvc as any).operations || {}; const inferredOps = inferredSvc.operations || {}; for (const [opName, origOp] of Object.entries(origOps) as [string, any][]) { if (inferredOps[opName] && origOp.steps) { inferredOps[opName].steps = origOp.steps; } } } } // Inject key as name into entity maps, and expand convention-format attributes // Iterate all object-valued sections (not hardcoded — covers any entity type) const entitySections = Object.keys(componentData).filter(k => componentData[k] && typeof componentData[k] === 'object' && !Array.isArray(componentData[k]) && !['version', 'description', 'name', 'componentName', 'commonDefinitions'].includes(k) ); for (const section of entitySections) { if (componentData[section] && typeof componentData[section] === 'object' && !Array.isArray(componentData[section])) { for (const [key, value] of Object.entries(componentData[section])) { if (value && typeof value === 'object') { (value as any).name = key; // Expand convention-format attributes: { attrName: "Type modifiers" } → [{ name, type, ... }] if ((value as any).attributes && typeof (value as any).attributes === 'object' && !Array.isArray((value as any).attributes)) { (value as any).attributes = Object.entries((value as any).attributes).map(([attrName, attrDef]: [string, any]) => { if (typeof attrDef === 'string') { const parts = attrDef.split(' '); const kv = (key: string) => parts.find((p: string) => p.startsWith(key + '='))?.split('=')[1]; const auto = kv('auto'); const explicitCategory = kv('category'); const category = explicitCategory || (auto || attrName === 'id' ? 'metadata' : 'business'); return { name: attrName, type: parts[0], required: parts.includes('required'), unique: parts.includes('unique'), auto, category }; } return { name: attrName, ...(typeof attrDef === 'object' ? attrDef : {}) }; }); } // Expand convention-format relationships: { relName: "type Target modifiers" } → array if ((value as any).relationships && typeof (value as any).relationships === 'object' && !Array.isArray((value as any).relationships)) { (value as any).relationships = Object.entries((value as any).relationships).map(([relName, relDef]: [string, any]) => { if (typeof relDef === 'object') return { name: relName, ...relDef }; if (typeof relDef === 'string') { const parts = relDef.split(' '); return { name: relName, type: parts[0], target: parts[1], cascade: parts.includes('cascade') }; } return { name: relName }; }); } } } } } // First component's name is used to label the realized bundle (matches // the prior single-component behaviour). For multi-component specs the // remaining components are still merged into componentData (TODO #49). const componentName = componentNames[0]; const inferredSpec = { ...componentData, componentName, components: inferredYaml?.components || {} }; // --estimate: report what realize WOULD do, broken down by layer. // Skip the actual realizeAll call (no manifest needed, no LLM cost). // The three layers: // L1 — Instance factory (templates, no LLM) // L2 — Convention pattern matching (CURVED ops + default events, no LLM) // L3 — AI from steps (one LLM call per declared step) if (options.estimate) { const components = inferredSpec.components || {}; const compNames = Object.keys(components); let entityCount = 0, controllerCount = 0, serviceCount = 0, eventCount = 0, viewCount = 0; let modelBehaviorSteps = 0, controllerActionSteps = 0, serviceOpSteps = 0; let modelBehaviorsWithSteps = 0, controllerActionsWithSteps = 0, serviceOpsWithSteps = 0; let curvedOpCount = 0; const sumSteps = (block: any) => { if (!block || typeof block !== 'object') return { ops: 0, opsWithSteps: 0, totalSteps: 0 }; let ops = 0, opsWithSteps = 0, totalSteps = 0; for (const def of Object.values(block)) { ops++; const steps = (def as any)?.steps; if (Array.isArray(steps) && steps.length > 0) { opsWithSteps++; totalSteps += steps.length; } } return { ops, opsWithSteps, totalSteps }; }; for (const compName of compNames) { const comp = components[compName] || {}; const models = comp.models || {}; const controllers = comp.controllers || {}; const services = comp.services || {}; const events = comp.events || {}; const views = comp.views || {}; entityCount += Object.keys(models).length; controllerCount += Object.keys(controllers).length; serviceCount += Object.keys(services).length; eventCount += Object.keys(events).length; viewCount += Object.keys(views).length; for (const m of Object.values(models)) { const r = sumSteps((m as any)?.behaviors); modelBehaviorsWithSteps += r.opsWithSteps; modelBehaviorSteps += r.totalSteps; } for (const c of Object.values(controllers)) { const r = sumSteps((c as any)?.actions); controllerActionsWithSteps += r.opsWithSteps; controllerActionSteps += r.totalSteps; const cured = (c as any)?.cured || (c as any)?.curved; if (cured && typeof cured === 'object') curvedOpCount += Object.keys(cured).length; } for (const s of Object.values(services)) { const r = sumSteps((s as any)?.operations); serviceOpsWithSteps += r.opsWithSteps; serviceOpSteps += r.totalSteps; } } const totalLlmCalls = modelBehaviorSteps + controllerActionSteps + serviceOpSteps; const fileEstimate = entityCount * 7 + controllerCount + serviceCount * 2 + viewCount + 15; console.log(''); console.log('╔══ spv realize --estimate ══════════════════════════════════════════'); console.log('║'); console.log('║ Type: ' + type); console.log('║ Components: ' + compNames.length + (compNames.length > 0 ? ' (' + compNames.join(', ') + ')' : '')); console.log('║'); console.log('║ ── Spec inventory (post-inference) ──'); console.log('║ Entities (models): ' + entityCount); console.log('║ Controllers: ' + controllerCount); console.log('║ Services: ' + serviceCount); console.log('║ Events: ' + eventCount); console.log('║ Views: ' + viewCount); console.log('║'); console.log('║ ── Output by realize layer ──'); console.log('║'); console.log('║ L1 — Instance factory (file scaffolding, no LLM):'); console.log('║ File scaffolding for each entity / controller / service / view'); console.log('║ + framework boilerplate (Fastify routes, Prisma schema, React shell)'); console.log('║ Estimate: ~' + fileEstimate + ' files'); console.log('║'); console.log('║ L2 — Convention pattern matching (bodies, no LLM):'); console.log('║ CURVED ops auto-implemented: ' + curvedOpCount); console.log('║ Default events on lifecycles: ' + eventCount + ' (declared) + auto-derived'); console.log('║ Default validation patterns: ~' + (entityCount * 2)); console.log('║'); console.log('║ L3 — AI from steps (LLM, 1 call per step):'); console.log('║ Model behaviors with steps: ' + modelBehaviorsWithSteps + ' behaviors → ' + modelBehaviorSteps + ' steps'); console.log('║ Controller actions with steps: ' + controllerActionsWithSteps + ' actions → ' + controllerActionSteps + ' steps'); console.log('║ Service ops with steps: ' + serviceOpsWithSteps + ' operations → ' + serviceOpSteps + ' steps'); console.log('║ ────────────────────────────────────────────────────────'); console.log('║ Total LLM calls: ' + totalLlmCalls); console.log('║'); if (totalLlmCalls > 0) { console.log('║ ── Estimated wall time + cost (L3 only) ──'); console.log('║ On Opus 4.7 / Max: ~' + Math.ceil(totalLlmCalls * 0.2) + ' min wall (free, claude-cli session-cached)'); console.log('║ On Sonnet 4.6: ~' + Math.ceil(totalLlmCalls * 0.1) + ' min wall (free)'); console.log('║ On Anthropic API: ~' + Math.ceil(totalLlmCalls * 0.1) + ' min wall, ~$' + (totalLlmCalls * 0.015).toFixed(2) + ' (Sonnet rates)'); console.log('║ On DeepSeek/Together: ~' + Math.ceil(totalLlmCalls * 0.08) + ' min wall, ~$' + (totalLlmCalls * 0.0008).toFixed(3) + ' (10-30× cheaper)'); } else { console.log('║ No LLM cost — all output is L1 + L2 (templates + conventions only).'); } console.log('║'); console.log('╚════════════════════════════════════════════════════════════════════'); return; } // Realize — let the realize engine handle its own library. // Locate the nearest implementation manifest by walking up from the // spec file, then falling back to the user's cwd. This lets the same // invocation work from any subdirectory (CLI, VSCode right-click, CI). const cwd = process.env.SPECVERSE_USER_CWD || process.cwd(); let manifestPath: string | null = null; if (options.manifest) { manifestPath = resolve(cwd, options.manifest); } else { let searchDir = dirname(resolve(file)); while (true) { const inManifests = join(searchDir, 'manifests', 'implementation.yaml'); if (existsSync(inManifests)) { manifestPath = inManifests; break; } const flat = join(searchDir, 'implementation.yaml'); if (existsSync(flat)) { manifestPath = flat; break; } const parent = dirname(searchDir); if (parent === searchDir) break; searchDir = parent; } if (!manifestPath) { const cwdBased = resolve(cwd, 'manifests/implementation.yaml'); if (existsSync(cwdBased)) manifestPath = cwdBased; } } if (!manifestPath || !existsSync(manifestPath)) { console.error('Manifest not found. Looked for manifests/implementation.yaml walking up from the spec file, then in ' + cwd); process.exit(1); } // --static ejects to a standalone starter kit (ReactAppStarter). // Default: whatever the manifest declares (ReactAppRuntime in the // shipped templates). The swap is a convenience only — users can // achieve the same by editing their manifest directly. let effectiveManifestPath = manifestPath; if (options.static) { const manifestContent = readFileSync(manifestPath, 'utf8'); if (/instanceFactory:\s*["']?ReactAppRuntime["']?\s*$/m.test(manifestContent)) { const { tmpdir } = await import('os'); const staticManifest = manifestContent.replace( /instanceFactory:\s*["']?ReactAppRuntime["']?\s*$/gm, 'instanceFactory: "ReactAppStarter"' ); effectiveManifestPath = join(tmpdir(), 'specverse-static-manifest.yaml'); writeFileSync(effectiveManifestPath, staticManifest, 'utf8'); console.log(' Using static mode (standalone starter kit — ReactAppStarter)'); } else { console.log(' Using static mode (manifest factory unchanged)'); } } const realizeEngine = registry.getEngineForCapability('realize') as RealizeEngine; if (!realizeEngine) { console.error('No realize engine found.'); process.exit(1); } await realizeEngine.initialize({ manifestPath: effectiveManifestPath, workingDir: (process.env.SPECVERSE_USER_CWD || process.cwd()) }); // #15 — subtype dispatch. `type` is the positional arg that was // previously captured-but-ignored. `all` is the default and runs // the full pipeline. Other values name a single entity section // (models / controllers / services / views / events / deployments // / commands / measures / promotions / conventions / distributions) // and we filter the inferred spec to that section per component // before handing it to realize. The realize engine iterates each // section independently; an empty section is a no-op for that // section's generators. const ENTITY_SECTIONS = new Set([ 'models', 'controllers', 'services', 'views', 'events', 'deployments', 'commands', 'measures', 'promotions', 'conventions', 'distributions', ]); let realizeSpec: any = inferredSpec; const realizeType = String(type || 'all').toLowerCase(); if (realizeType !== 'all') { if (!ENTITY_SECTIONS.has(realizeType)) { console.error(`Unknown realize subtype: '${type}'. Expected 'all' or one of: ` + Array.from(ENTITY_SECTIONS).sort().join(', ')); process.exit(1); } realizeSpec = JSON.parse(JSON.stringify(inferredSpec)); const components: any[] = Array.isArray(realizeSpec?.components) ? realizeSpec.components : Object.values(realizeSpec?.components || {}); for (const comp of components) { if (!comp || typeof comp !== 'object') continue; for (const section of ENTITY_SECTIONS) { if (section !== realizeType && comp[section] !== undefined) { comp[section] = Array.isArray(comp[section]) ? [] : {}; } } } } const outputDir = resolve((process.env.SPECVERSE_USER_CWD || process.cwd()), options.output || 'generated/code'); console.log('Realizing ' + type + ' from ' + file + '...'); await (realizeEngine as any).realizeAll(realizeSpec, outputDir); // Write dev.specly for runtime mode — only if the manifest // actually declares a frontend (app.frontend capability). For // backend-only templates there's no frontend, so we'd otherwise // create a dead frontend/src/dev.specly file alongside a project // that has no frontend at all. if (!options.static && inferResult.devYaml) { const manifestText = readFileSync(effectiveManifestPath, 'utf8'); const hasFrontend = /capability:\s*["']?app\.frontend["']?/.test(manifestText); if (hasFrontend) { // Detect frontend layout from the manifest. If the resolved // app.frontend factory uses outputStructure: standalone with // frontendDir: "." then dev.specly belongs at the output root. // Otherwise it goes under frontend/src/. const standaloneFrontend = /app\.frontend[\s\S]*?outputStructure:\s*["']?standalone["']?/.test(manifestText); const frontendDir = standaloneFrontend ? join(outputDir, 'src') : join(outputDir, 'frontend', 'src'); mkdirSync(frontendDir, { recursive: true }); const devSpeclyPath = join(frontendDir, 'dev.specly'); writeFileSync(devSpeclyPath, inferResult.devYaml, 'utf8'); console.log(' Wrote dev.specly →', devSpeclyPath); } } } catch (error: any) { console.error('Error:', error.message); process.exit(1); } }); }