/** * validate.ts — Validates the development plan and checks per-module readiness. * * Reads dev-plan.json (produced by /ba-create-plan-development) and verifies that * each module has the required PRD files and a passing audit verdict. */ import path from 'node:path'; import { fileExists, directoryExists, readJson, readText, } from '../../../lib/fs.js'; import { PreflightInputSchema } from './types.js'; import type { PreflightInput, DevelopmentPlan, ReadinessInfo, ModulePreflight, PreDevInfo, WavePreflight, PreflightReport, } from './types.js'; // --------------------------------------------------------------------------- // Readiness verdict parsing (same regex as create-plan-development/parse.ts) // --------------------------------------------------------------------------- const VERDICT_RE = /Verdict\s*:\s*(GO|NO-GO)(?:.*?score\s+(\d+))?(?:.*?(\d+)\s+err)?(?:.*?(\d+)\s+warn)?/; async function parseReadiness( auditPath: string, ): Promise { const exists = await fileExists(auditPath); if (!exists) return { verdict: 'UNKNOWN' }; const content = await readText(auditPath); const match = VERDICT_RE.exec(content); if (!match) return { verdict: 'UNKNOWN' }; return { verdict: match[1] as 'GO' | 'NO-GO', score: match[2] ? Number(match[2]) : undefined, errCount: match[3] ? Number(match[3]) : undefined, warnCount: match[4] ? Number(match[4]) : undefined, }; } // --------------------------------------------------------------------------- // Pre-dev aggregate parsing (chantier 4.5 — the verdict finally has a reader) // --------------------------------------------------------------------------- // `/ba-audit-pre-dev` writes ONE project aggregate at // `.smartstack/ba/_audit/pre-dev.md` (anchor + verdict header + a // module × dimension status table `| APP / MODULE | ✅ | ❌ 1 | … |`). Until // this leg, NOTHING read it (T15) — a NO-GO was a record nobody consumed and // development started anyway. The /ba-develop orchestrator preflight is // FROZEN (Studio canonical) and reads only `_audit/prd.md`; this CLI is not // frozen and already gates waves, so the aggregate's verdicts land here as // wave blockers. The header + table shapes are the parsing contract the // audit-pre-dev SKILL documents as stable. /** Tolerant of the emoji between « Verdict : » and the word (❌ NO-GO). */ const PRE_DEV_VERDICT_RE = /Verdict[^\n]*?\b(NO-GO|GO)\b/; export interface PreDevAggregate { present: boolean; verdict: 'GO' | 'NO-GO' | 'UNKNOWN'; /** `APP/MODULE` (folder codes, as the table rows are keyed) → row analysis. */ rows: Map; } export async function parsePreDevAggregate(baRoot: string): Promise { const p = path.join(baRoot, '_audit', 'pre-dev.md'); if (!(await fileExists(p))) { return { present: false, verdict: 'UNKNOWN', rows: new Map() }; } const content = await readText(p); const vm = PRE_DEV_VERDICT_RE.exec(content); const rows = new Map(); for (const line of content.split(/\r?\n/)) { const m = /^\|\s*([A-Z0-9_-]+)\s*\/\s*([A-Z0-9_-]+)\s*\|(.*)\|\s*$/.exec(line); if (!m) continue; const cells = m[3]; const folded = cells.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase(); rows.set(`${m[1]}/${m[2]}`, { hasErr: cells.includes('❌'), unaudited: folded.includes('non audite'), }); } return { present: true, verdict: vm ? (vm[1] as 'GO' | 'NO-GO') : 'UNKNOWN', rows }; } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- export interface ValidateResult { valid: boolean; errors: string[]; warnings: string[]; spec?: PreflightInput; plan?: DevelopmentPlan; } /** * Stage 1: Zod validation + plan file existence. */ export async function validateInput(raw: unknown): Promise { const errors: string[] = []; const warnings: string[] = []; const parsed = PreflightInputSchema.safeParse(raw); if (!parsed.success) { const zodErrors = parsed.error.issues.map( (i) => `${i.path.join('.')}: ${i.message}`, ); return { valid: false, errors: zodErrors, warnings }; } const spec = parsed.data; // Check baRoot exists if (!(await directoryExists(spec.baRoot))) { errors.push(`BA root not found: ${spec.baRoot}`); return { valid: false, errors, warnings }; } // Check dev-plan.json exists const planPath = path.join(spec.baRoot, '_plan', 'dev-plan.json'); if (!(await fileExists(planPath))) { errors.push( `Development plan not found: ${planPath}. Run /ba-create-plan-development first.`, ); return { valid: false, errors, warnings }; } // Read and parse the plan let plan: DevelopmentPlan; try { plan = await readJson(planPath); } catch (err) { errors.push( `Failed to parse dev-plan.json: ${err instanceof Error ? err.message : String(err)}`, ); return { valid: false, errors, warnings }; } // Basic plan validation if (!plan.waves || plan.waves.length === 0) { errors.push('Development plan has no waves'); return { valid: false, errors, warnings }; } // Check projectPath exists if (!(await directoryExists(spec.projectPath))) { errors.push(`Project path not found: ${spec.projectPath}`); return { valid: false, errors, warnings }; } // Warn about cycles if (plan.cycles && plan.cycles.length > 0) { warnings.push( `Plan contains ${plan.cycles.length} circular dependency group(s) — co-develop those modules`, ); } return { valid: true, errors, warnings, spec, plan }; } /** * Stage 2: Per-module readiness check. * Returns the full preflight report. */ export async function checkReadiness( spec: PreflightInput, plan: DevelopmentPlan, ): Promise { const preDev = await parsePreDevAggregate(spec.baRoot); const wavesToCheck = plan.waves.filter((w) => w.level > 0); const wavePreflights: WavePreflight[] = []; let readyCount = 0; let blockedCount = 0; let externalCount = 0; // Wave 0: externals (just count them) const wave0 = plan.waves.find((w) => w.level === 0); if (wave0) { externalCount = wave0.modules.length; } for (const wave of wavesToCheck) { // Filter waves if the user specified specific ones if (spec.waves && spec.waves.length > 0 && !spec.waves.includes(wave.level)) { continue; } const modulePreflights: ModulePreflight[] = []; const waveBlockers: string[] = []; for (const mod of wave.modules) { const preflight = await checkModule(spec.baRoot, mod.appCode, mod.moduleCode, wave.level, preDev); modulePreflights.push(preflight); if (preflight.status === 'ready') { readyCount++; } else { blockedCount++; waveBlockers.push(`${mod.key}: ${preflight.blockers.join('; ')}`); } } wavePreflights.push({ level: wave.level, status: waveBlockers.length === 0 ? 'ready' : 'blocked', modules: modulePreflights, blockers: waveBlockers, }); } // Determine which waves can proceed let firstReadyWave: number | null = null; let lastReadyWave: number | null = null; for (const wp of wavePreflights) { if (wp.status === 'ready') { if (firstReadyWave === null) firstReadyWave = wp.level; lastReadyWave = wp.level; } else { // A blocked wave stops the chain break; } } return { planDate: plan.generatedAt, apps: plan.apps, projectPath: spec.projectPath, totalModules: readyCount + blockedCount, readyModules: readyCount, blockedModules: blockedCount, externalDeps: externalCount, hasCycles: (plan.cycles?.length ?? 0) > 0, waves: wavePreflights, canProceed: firstReadyWave !== null, firstReadyWave, lastReadyWave, }; } // --------------------------------------------------------------------------- // Per-module check // --------------------------------------------------------------------------- async function checkModule( baRoot: string, appCode: string, moduleCode: string, waveLevel: number, preDev: PreDevAggregate, ): Promise { const key = `${appCode}/${moduleCode}`; const moduleDir = path.join(baRoot, appCode, moduleCode); const blockers: string[] = []; const warnings: string[] = []; // Check PRD files const prdFiles = { prd: await fileExists(path.join(moduleDir, 'prd.md')), entities: await fileExists(path.join(moduleDir, 'prd.entities.md')), api: await fileExists(path.join(moduleDir, 'prd.api.md')), frontend: await fileExists(path.join(moduleDir, 'prd.frontend.md')), pagespecs: await directoryExists(path.join(moduleDir, 'pagespecs')), }; if (!prdFiles.prd) blockers.push('prd.md missing — run /ba-create-prd'); if (!prdFiles.entities) blockers.push('prd.entities.md missing'); if (!prdFiles.api) blockers.push('prd.api.md missing'); if (!prdFiles.frontend) blockers.push('prd.frontend.md missing'); if (!prdFiles.pagespecs) warnings.push('pagespecs/ directory missing — frontend phase may have issues'); // Check audit readiness const auditPath = path.join(moduleDir, '_audit', 'prd.md'); const readiness = await parseReadiness(auditPath); if (readiness.verdict === 'UNKNOWN') { blockers.push('_audit/prd.md missing — run /ba-audit-prd'); } else if (readiness.verdict === 'NO-GO') { blockers.push( `Audit verdict: NO-GO${readiness.score != null ? ` (score ${readiness.score}/100)` : ''}${readiness.errCount != null ? ` — ${readiness.errCount} err` : ''}`, ); } else if (readiness.verdict === 'GO' && readiness.score != null && readiness.score < 80) { blockers.push(`Audit score ${readiness.score}/100 < 80 — not dev-ready`); } // BA-side readiness — the /ba-audit-pre-dev aggregate finally read (T1/T15). // Its verdicts become wave blockers; an ABSENT aggregate stays a warning // (readiness unproven — the PRD-audit gate above still applies) so teams // running the dimension audits individually are not broken. const row = preDev.rows.get(key); const preDevInfo: PreDevInfo = { present: preDev.present, verdict: preDev.verdict, listed: row !== undefined, hasErr: row?.hasErr ?? false, unaudited: row?.unaudited ?? false, }; if (!preDev.present) { warnings.push( '_audit/pre-dev.md absent — BA readiness unproven; run /ba-audit-pre-dev (the PRD-audit gate above still applies)', ); } else if (row === undefined) { warnings.push( `module absent from the _audit/pre-dev.md status table — stale aggregate? Re-run /ba-audit-pre-dev`, ); } else { if (row.hasErr) { blockers.push( 'pre-dev NO-GO: ❌ dimension(s) for this module in .smartstack/ba/_audit/pre-dev.md — fix via the ' + "dimension's /ba-create-* skill, re-run its /ba-audit-*, then /ba-audit-pre-dev", ); } if (row.unaudited) { blockers.push( 'pre-dev: un-audited dimension(s) for this module (« non audité » in _audit/pre-dev.md) — readiness ' + 'unproven; run the missing /ba-audit-* then re-run /ba-audit-pre-dev', ); } } return { appCode, moduleCode, key, wave: waveLevel, status: blockers.length === 0 ? 'ready' : 'not-ready', moduleDir, readiness, preDev: preDevInfo, prdFiles, blockers, warnings, }; }