/** * validate.test.ts — Tests for preflight-develop-plan validation. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { PreflightInputSchema } from '../types.js'; import { checkReadiness, parsePreDevAggregate } from '../validate.js'; import type { DevelopmentPlan, ReadinessInfo } from '../types.js'; // --------------------------------------------------------------------------- // Zod input schema // --------------------------------------------------------------------------- describe('PreflightInputSchema', () => { it('accepts valid input', () => { const result = PreflightInputSchema.safeParse({ baRoot: '.smartstack/ba', projectPath: '/path/to/project', }); expect(result.success).toBe(true); }); it('accepts input with specific waves', () => { const result = PreflightInputSchema.safeParse({ baRoot: '.smartstack/ba', projectPath: '/path/to/project', waves: [1, 2], }); expect(result.success).toBe(true); if (result.success) { expect(result.data.waves).toEqual([1, 2]); } }); it('rejects empty baRoot', () => { const result = PreflightInputSchema.safeParse({ baRoot: '', projectPath: '/path/to/project', }); expect(result.success).toBe(false); }); it('rejects missing projectPath', () => { const result = PreflightInputSchema.safeParse({ baRoot: '.smartstack/ba', }); expect(result.success).toBe(false); }); it('rejects wave 0 (external deps are not developable)', () => { const result = PreflightInputSchema.safeParse({ baRoot: '.smartstack/ba', projectPath: '/path/to/project', waves: [0, 1], }); expect(result.success).toBe(false); }); }); // --------------------------------------------------------------------------- // Readiness verdict patterns // --------------------------------------------------------------------------- describe('readiness verdict detection', () => { const VERDICT_RE = /Verdict\s*:\s*(GO|NO-GO)(?:.*?score\s+(\d+))?(?:.*?(\d+)\s+err)?(?:.*?(\d+)\s+warn)?/; it('detects GO with high score', () => { const line = '_2026-05-26 · Verdict : GO · score 92/100 · 0 err · 1 warn · 20 ok_'; const match = VERDICT_RE.exec(line); expect(match).not.toBeNull(); expect(match![1]).toBe('GO'); expect(Number(match![2])).toBe(92); expect(Number(match![3])).toBe(0); }); it('detects NO-GO', () => { const line = '_2026-05-26 · Verdict : NO-GO · score 65/100 · 5 err · 3 warn · 10 ok_'; const match = VERDICT_RE.exec(line); expect(match).not.toBeNull(); expect(match![1]).toBe('NO-GO'); expect(Number(match![2])).toBe(65); expect(Number(match![3])).toBe(5); }); it('GO with score < 80 is detectable', () => { const line = '_2026-05-26 · Verdict : GO · score 75/100 · 0 err · 8 warn_'; const match = VERDICT_RE.exec(line); expect(match).not.toBeNull(); expect(match![1]).toBe('GO'); expect(Number(match![2])).toBe(75); // Caller checks score < 80 → blocker }); }); // --------------------------------------------------------------------------- // Plan structure validation // --------------------------------------------------------------------------- describe('plan structure checks', () => { function makePlan(overrides: Partial = {}): DevelopmentPlan { return { generatedAt: '2026-05-26', apps: ['CRM'], totalModules: 2, totalCrossModuleDeps: 1, waves: [ { level: 1, modules: [ { appCode: 'CRM', moduleCode: 'CONTACTS', key: 'CRM/CONTACTS', entityCount: 3, dependsOn: [], readiness: { verdict: 'GO', score: 88, errCount: 0, warnCount: 2 }, isExternal: false, warnings: [], }, ], }, { level: 2, modules: [ { appCode: 'CRM', moduleCode: 'PIPELINE', key: 'CRM/PIPELINE', entityCount: 4, dependsOn: ['CRM/CONTACTS'], readiness: { verdict: 'GO', score: 85, errCount: 0, warnCount: 1 }, isExternal: false, warnings: [], }, ], }, ], cycles: [], warnings: [], ...overrides, }; } it('valid plan has waves', () => { const plan = makePlan(); expect(plan.waves.length).toBeGreaterThan(0); }); it('empty waves should be rejected', () => { const plan = makePlan({ waves: [] }); expect(plan.waves.length).toBe(0); }); it('cycles are flagged', () => { const plan = makePlan({ cycles: [['CRM/A', 'CRM/B']], }); expect(plan.cycles.length).toBeGreaterThan(0); }); it('external deps are in wave 0', () => { const plan = makePlan({ waves: [ { level: 0, modules: [ { appCode: 'HR', moduleCode: 'EMPLOYEES', key: 'HR/EMPLOYEES', entityCount: 0, dependsOn: [], isExternal: true, referencedBy: ['CRM/CONTACTS'], warnings: [], }, ], }, ...makePlan().waves, ], }); const wave0 = plan.waves.find((w) => w.level === 0); expect(wave0).toBeDefined(); expect(wave0!.modules[0].isExternal).toBe(true); }); it('modules in the same wave have no mutual dependencies', () => { const plan = makePlan({ waves: [ { level: 1, modules: [ { appCode: 'CRM', moduleCode: 'CONTACTS', key: 'CRM/CONTACTS', entityCount: 3, dependsOn: [], isExternal: false, warnings: [], }, { appCode: 'BILLING', moduleCode: 'PRODUCTS', key: 'BILLING/PRODUCTS', entityCount: 2, dependsOn: [], isExternal: false, warnings: [], }, ], }, ], }); const wave1 = plan.waves[0]; const wave1Keys = new Set(wave1.modules.map((m) => m.key)); // No module in wave 1 depends on another wave 1 module for (const mod of wave1.modules) { for (const dep of mod.dependsOn) { expect(wave1Keys.has(dep)).toBe(false); } } }); }); // --------------------------------------------------------------------------- // Pre-dev aggregate — the /ba-audit-pre-dev verdict finally read (chantier 4.5) // --------------------------------------------------------------------------- describe('pre-dev aggregate as wave blockers (T1/T15 closure)', () => { let baRoot: string; function write(rel: string, content: string): void { const abs = path.join(baRoot, rel); mkdirSync(path.dirname(abs), { recursive: true }); writeFileSync(abs, content, 'utf-8'); } /** A module with every PRD file + a GO/85 prd audit — ready unless pre-dev blocks. */ function writeReadyModule(app: string, mod: string): void { for (const f of ['prd.md', 'prd.entities.md', 'prd.api.md', 'prd.frontend.md']) { write(`${app}/${mod}/${f}`, '# x'); } mkdirSync(path.join(baRoot, app, mod, 'pagespecs'), { recursive: true }); write(`${app}/${mod}/_audit/prd.md`, '_2026-08-29 · Verdict : GO · score 85/100 · 0 err · 1 warn_'); } function plan(): DevelopmentPlan { return { generatedAt: '2026-08-29', apps: ['CRM'], totalModules: 1, totalCrossModuleDeps: 0, waves: [{ level: 1, modules: [{ appCode: 'CRM', moduleCode: 'PIPELINE', key: 'CRM/PIPELINE', entityCount: 2, dependsOn: [], isExternal: false, warnings: [], }], }], cycles: [], warnings: [], }; } const spec = () => ({ baRoot, projectPath: baRoot }); beforeEach(() => { baRoot = mkdtempSync(path.join(tmpdir(), 'preflight-predev-')); writeReadyModule('CRM', 'PIPELINE'); }); afterEach(() => rmSync(baRoot, { recursive: true, force: true })); it('ABSENT aggregate → warning only (readiness unproven), the module stays ready', async () => { const report = await checkReadiness(spec(), plan()); const mod = report.waves[0].modules[0]; expect(mod.status).toBe('ready'); expect(mod.preDev.present).toBe(false); expect(mod.warnings.some((w) => w.includes('/ba-audit-pre-dev'))).toBe(true); expect(report.canProceed).toBe(true); }); it('a ❌ row for the module → WAVE BLOCKER (the NO-GO finally consumed)', async () => { write('_audit/pre-dev.md', [ '', '# Pré-développement — Synthèse de préparation', '_2026-08-29 · Verdict : ❌ NO-GO · 1 err · 0 warn · 40 ok_', '', '| Module | Menu | RBAC |', '|--------|------|------|', '| CRM / PIPELINE | ✅ | ❌ 1 |', ].join('\n')); const report = await checkReadiness(spec(), plan()); const mod = report.waves[0].modules[0]; expect(mod.status).toBe('not-ready'); expect(mod.preDev).toMatchObject({ present: true, verdict: 'NO-GO', listed: true, hasErr: true }); expect(mod.blockers.some((b) => b.includes('pre-dev NO-GO'))).toBe(true); expect(report.canProceed).toBe(false); }); it('a « non audité » cell → blocker too (unproven ≠ green)', async () => { write('_audit/pre-dev.md', [ '_2026-08-29 · Verdict : ❌ NO-GO · 0 err · 0 warn · 30 ok · 1 dimension non auditée_', '| Module | Écrans |', '|--------|--------|', '| CRM / PIPELINE | — non audité |', ].join('\n')); const report = await checkReadiness(spec(), plan()); const mod = report.waves[0].modules[0]; expect(mod.status).toBe('not-ready'); expect(mod.preDev.unaudited).toBe(true); expect(mod.blockers.some((b) => b.includes('un-audited dimension'))).toBe(true); }); it('a clean GO row → ready, no pre-dev blocker, verdict carried on the report', async () => { write('_audit/pre-dev.md', [ '_2026-08-29 · Verdict : ✅ GO · 0 err · 2 warn · 44 ok_', '| Module | Menu | RBAC |', '|--------|------|------|', '| CRM / PIPELINE | ✅ | ⚠️ 2 |', ].join('\n')); const report = await checkReadiness(spec(), plan()); const mod = report.waves[0].modules[0]; expect(mod.status).toBe('ready'); expect(mod.preDev).toMatchObject({ present: true, verdict: 'GO', listed: true, hasErr: false, unaudited: false }); }); it('module missing from the status table → warning (stale aggregate), not a blocker', async () => { write('_audit/pre-dev.md', [ '_2026-08-29 · Verdict : ✅ GO · 0 err · 0 warn · 10 ok_', '| Module | Menu |', '|--------|------|', '| CRM / CONTACTS | ✅ |', ].join('\n')); const report = await checkReadiness(spec(), plan()); const mod = report.waves[0].modules[0]; expect(mod.status).toBe('ready'); expect(mod.preDev.listed).toBe(false); expect(mod.warnings.some((w) => w.includes('stale aggregate'))).toBe(true); }); it('parsePreDevAggregate tolerates the emoji between « Verdict : » and NO-GO', async () => { write('_audit/pre-dev.md', '_2026-08-29 · Verdict : ❌ NO-GO · 2 err_\n'); const agg = await parsePreDevAggregate(baRoot); expect(agg.present).toBe(true); expect(agg.verdict).toBe('NO-GO'); }); }); // --------------------------------------------------------------------------- // Drift lock — the audit-pre-dev SKILL template table IS the parsing contract // --------------------------------------------------------------------------- describe('pre-dev parsing contract drift lock', () => { // The SKILL example is the ONLY specification of the file /ba-audit-pre-dev // writes; the parser must read it verbatim or the wave blockers silently // degrade to warnings. Repo layout only (business-analyse/ is flattened on // deploy). const here = fileURLToPath(new URL('.', import.meta.url)); const skillPath = path.join(here, '..', '..', '..', '..', 'business-analyse', 'audit-pre-dev', 'SKILL.md'); const skillMd = existsSync(skillPath) ? readFileSync(skillPath, 'utf8') : ''; const block = /```markdown\r?\n([\s\S]*?)```/.exec(skillMd)?.[1] ?? ''; it.skipIf(block === '')('the SKILL example parses: NO-GO + per-module ❌ and « non audité » detected', async () => { const baRoot = mkdtempSync(path.join(tmpdir(), 'predev-drift-')); try { mkdirSync(path.join(baRoot, '_audit'), { recursive: true }); writeFileSync(path.join(baRoot, '_audit', 'pre-dev.md'), block, 'utf-8'); const agg = await parsePreDevAggregate(baRoot); expect(agg.present).toBe(true); expect(agg.verdict).toBe('NO-GO'); const pipeline = agg.rows.get('CRM/PIPELINE')!; expect(pipeline).toBeDefined(); expect(pipeline.hasErr).toBe(true); // ❌ 1 in the RBAC column const contacts = agg.rows.get('CRM/CONTACTS')!; expect(contacts.hasErr).toBe(true); expect(contacts.unaudited).toBe(true); // « — non audité » cell } finally { rmSync(baRoot, { recursive: true, force: true }); } }); });