/** * preflight-develop-plan — the extension-request gate (doctrine 2026-10-03). * While `/demande-extension.md` carries a request « en attente » or * « refusée » (Statut owned by the Studio), EVERY module of that application * is `not-ready` with an `extension-request.pending` blocker AND `skip: true` * — the one blocker class /ba-develop-plan does not run through. A validated * request releases the app. /ba-develop itself is frozen; this CLI is the gate. */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { checkReadiness, EXTENSION_REQUEST_BLOCKER, frozenApplications } from '../validate.js' import type { DevelopmentPlan } from '../types.js' let baRoot: string beforeEach(() => { baRoot = mkdtempSync(path.join(tmpdir(), 'ss-preflight-ext-')) for (const mod of ['CONTACTS']) { const dir = path.join(baRoot, 'CRM', mod) mkdirSync(path.join(dir, '_audit'), { recursive: true }) mkdirSync(path.join(dir, 'pagespecs'), { recursive: true }) for (const f of ['prd.md', 'prd.entities.md', 'prd.api.md', 'prd.frontend.md']) writeFileSync(path.join(dir, f), `# ${f}\n`, 'utf8') writeFileSync(path.join(dir, '_audit', 'prd.md'), '_2026-10-03 · Verdict : GO · score 90/100 · 0 err · 0 warn · 20 ok_\n', 'utf8') } }) afterEach(() => rmSync(baRoot, { recursive: true, force: true })) const plan: DevelopmentPlan = { generatedAt: '2026-10-03', apps: ['CRM'], totalModules: 1, totalCrossModuleDeps: 0, waves: [{ level: 1, modules: [{ appCode: 'CRM', moduleCode: 'CONTACTS', key: 'CRM/CONTACTS', entityCount: 1, dependsOn: [], isExternal: false, warnings: [] }] }], cycles: [], warnings: [], } const request = (status: string): string => [ '', "# Demandes d'extension — CRM", '', '### REQ-001 — Vehicule → GESTION_FLOTTE/VEHICULES', `- **Statut** : ${status}`, '- **Demandeurs** : CONTACTS', '- **Classe** : non développée — entité.md GESTION_FLOTTE/VEHICULES', '- **Motif** : x', '- **Date** : 2026-10-03', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Couleur | string(20) | — | — |', '', ].join('\n') describe('extension-request gate', () => { it('no demande-extension.md → the module is ready, nothing frozen', async () => { const report = await checkReadiness({ baRoot, projectPath: '/p' }, plan) const mod = report.waves[0]!.modules[0]! expect(mod.status).toBe('ready') expect(mod.skip).toBe(false) expect(report.frozenApps).toEqual({}) expect(report.canProceed).toBe(true) }) it('a PENDING request freezes the whole application: not-ready + skip + the blocker naming the request and the Studio', async () => { writeFileSync(path.join(baRoot, 'CRM', 'demande-extension.md'), request('en attente'), 'utf8') const report = await checkReadiness({ baRoot, projectPath: '/p' }, plan) const mod = report.waves[0]!.modules[0]! expect(mod.status).toBe('not-ready') expect(mod.skip).toBe(true) expect(mod.blockers).toHaveLength(1) expect(mod.blockers[0]).toContain(EXTENSION_REQUEST_BLOCKER) expect(mod.blockers[0]).toContain('REQ-001 Vehicule → GESTION_FLOTTE/VEHICULES (en attente') expect(mod.blockers[0]).toContain('Studio') expect(report.frozenApps).toEqual({ CRM: [expect.stringContaining('REQ-001')] }) expect(report.canProceed).toBe(false) }) it('a REFUSED request freezes too; a VALIDATED one releases', async () => { writeFileSync(path.join(baRoot, 'CRM', 'demande-extension.md'), request('refusée'), 'utf8') expect((await checkReadiness({ baRoot, projectPath: '/p' }, plan)).waves[0]!.modules[0]!.skip).toBe(true) writeFileSync(path.join(baRoot, 'CRM', 'demande-extension.md'), request('validée'), 'utf8') const released = await checkReadiness({ baRoot, projectPath: '/p' }, plan) expect(released.waves[0]!.modules[0]!.status).toBe('ready') expect(released.frozenApps).toEqual({}) }) it('an unreadable Statut freezes (fail-closed), never releases', () => { writeFileSync(path.join(baRoot, 'CRM', 'demande-extension.md'), request('ok?'), 'utf8') expect(frozenApplications(baRoot, ['CRM']).has('CRM')).toBe(true) }) }) describe('extension-request gate — nature accès (data sensitivity)', () => { it('a PENDING access request (no attribute table) freezes the application exactly like an extension', async () => { writeFileSync( path.join(baRoot, 'CRM', 'demande-extension.md'), [ '', "# Demandes d'extension — CRM", '', '### REQ-001 — FichePaie → RH/PAIE', '- **Nature** : accès', '- **Statut** : en attente', '- **Demandeurs** : CONTACTS', '- **Sensibilité** : confidentiel — financier', '- **Classe** : non développée — entité.md RH/PAIE', '- **Motif** : accès en lecture via FK Contact.FichePaieId → FichePaie', '- **Date** : 2026-10-03', '', ].join('\n'), 'utf8', ) const report = await checkReadiness({ baRoot, projectPath: '/p' }, plan) const mod = report.waves[0]!.modules[0]! expect(mod.status).toBe('not-ready') expect(mod.skip).toBe(true) expect(Object.keys(report.frozenApps)).toEqual(['CRM']) expect(report.canProceed).toBe(false) }) })