/** * execute.test.ts — blocked cases, verify mode, determinism, speed, validation. */ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { loadScopeCorpus } from '../corpus.js' import { executeChangeImpact, executeVerify } from '../execute.js' import { CHANGE_KINDS, ChangeImpactInputSchema, IMPLEMENTED_KINDS, type ChangeImpactInput } from '../types.js' import { validateSpec } from '../validate.js' import { UC_PREFIX, ruleBlock, useCaseDoc, writeChangeCorpus } from './fixture.js' let tmp: string let root: string beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'ba-change-exec-')) root = join(tmp, 'ba') }) afterEach(() => rmSync(tmp, { recursive: true, force: true })) const specOf = (over: Partial & { kind: ChangeImpactInput['kind'] }): ChangeImpactInput => ChangeImpactInputSchema.parse({ baRoot: root, app: 'CRM', module: 'PIPELINE', ...over }) function impact(over: Partial & { kind: ChangeImpactInput['kind'] }) { const spec = specOf(over) const v = validateSpec(spec) if (!v.valid || !v.scope) throw new Error(v.errors.join('; ')) return executeChangeImpact(spec, loadScopeCorpus(v.scope, spec.projectPath)) } function verify(over: Partial & { kind: ChangeImpactInput['kind'] }) { const spec = specOf({ mode: 'verify', ...over }) const v = validateSpec(spec) if (!v.valid || !v.scope) throw new Error(v.errors.join('; ')) return executeVerify(spec, loadScopeCorpus(v.scope)) } describe('validateSpec', () => { it('resolves app/module case-insensitively and the section folder', () => { writeChangeCorpus(root) const v = validateSpec({ baRoot: root, app: 'crm', module: 'pipeline', section: 'OPPORTUNITES', kind: 'use-case' }) expect(v.valid).toBe(true) expect(v.scope).toEqual({ baRoot: root, app: 'CRM', module: 'PIPELINE', section: 'opportunites' }) }) it('a missing module or section is a usage error (exit 1 class)', () => { writeChangeCorpus(root) expect(validateSpec({ baRoot: root, app: 'CRM', module: 'NOPE', kind: 'use-case' }).errors[0]).toMatch(/Module folder not found/) expect(validateSpec({ baRoot: root, app: 'CRM', module: 'PIPELINE', section: 'devis', kind: 'use-case' }).errors[0]).toMatch(/Section folder not found/) }) it('op=modify without a code and mode=verify without verify are usage errors; unknown keys rejected', () => { writeChangeCorpus(root) expect(validateSpec({ baRoot: root, app: 'CRM', module: 'PIPELINE', kind: 'business-rule', op: 'modify' }).errors[0]).toMatch(/target\.code/) expect(validateSpec({ baRoot: root, app: 'CRM', module: 'PIPELINE', kind: 'business-rule', mode: 'verify' }).errors[0]).toMatch(/verify/) expect(validateSpec({ baRoot: root, app: 'CRM', module: 'PIPELINE', kind: 'business-rule', extra: 1 }).valid).toBe(false) }) }) describe('blocked — fail-closed with a route', () => { it('section-required for a use case, with the section folders as candidates', () => { writeChangeCorpus(root) const r = impact({ kind: 'use-case' }) expect(r.blocked.map((b) => b.code)).toEqual(['section-required']) expect(r.blocked[0]!.candidates).toEqual(['clients', 'opportunites']) expect(r.impact).toEqual([]) expect(r.allocation).toBeNull() }) it('every kind of the spec vocabulary has a checklist (no kind-not-implemented left)', () => { writeChangeCorpus(root) expect([...IMPLEMENTED_KINDS].sort()).toEqual([...CHANGE_KINDS].sort()) const r = impact({ kind: 'attribute', target: { entity: 'Opportunity', attribute: 'Probability' } }) expect(r.blocked.map((b) => b.code)).not.toContain('kind-not-implemented') }) it('owner-doc-missing → first pass, route to the owning skill (a missing SECTION doc falls back to the module doc — see audit-fixes)', () => { writeChangeCorpus(root) rmSync(join(root, 'CRM', 'PIPELINE', 'règles-métier.md')) rmSync(join(root, 'CRM', 'PIPELINE', 'opportunites', 'règles-métier.md')) const r = impact({ kind: 'business-rule' }) expect(r.blocked[0]!.code).toBe('owner-doc-missing') expect(r.blocked[0]!.routeTo).toEqual({ skill: 'ba-create-business-rules' }) }) it('owner-doc-placeholder when the doc exists without an item', () => { writeChangeCorpus(root) writeFileSync(join(root, 'CRM', 'PIPELINE', 'clients', 'use-case.md'), '# Cas d\'usage\n\n_À définir lors de la phase 3._\n', 'utf8') const r = impact({ kind: 'use-case', section: 'clients' }) expect(r.blocked[0]!.code).toBe('owner-doc-placeholder') }) it('target-not-found on modify', () => { writeChangeCorpus(root) const r = impact({ kind: 'business-rule', op: 'modify', target: { code: 'BR-042' } }) expect(r.blocked[0]!.code).toBe('target-not-found') }) }) describe('impact — the happy path', () => { it('allocates, lists existing, keeps machine blocks hash, warns on exact duplicates and cross-app look-alikes', () => { writeChangeCorpus(root) const r = impact({ kind: 'use-case', section: 'opportunites', target: { title: 'Créer une opportunité', actor: 'BA-001-AC-001' } }) expect(r.blocked).toEqual([]) expect(r.allocation!.next).toBe(`${UC_PREFIX}010`) expect(r.existing.count).toBe(3) expect(r.existing.exact.length).toBe(1) expect(r.existing.crossApp.length).toBe(1) expect(r.owner).toMatchObject({ file: 'CRM/PIPELINE/opportunites/use-case.md', exists: true, placeholder: false, writeDiscipline: 'full-rewrite', machineBlocksHash: 'none' }) expect(r.warnings.some((w) => /same title/.test(w))).toBe(true) expect(r.warnings.some((w) => /OTHER applications/.test(w))).toBe(true) expect(r.state).toMatchObject({ pagespecsPresent: true, prdPresent: true, developed: false, sourcesPresent: false }) }) it('rbac.md machine blocks are hashed (permission owner)', () => { writeChangeCorpus(root) const r = impact({ kind: 'permission', target: { actor: 'BA-001-AC-001', permissionPath: 'pipeline.opportunites.export', portee: 'toutes' } }) expect(r.owner.machineBlocksHash).toMatch(/^[0-9a-f]{64}$/) }) it('is deterministic — two runs produce the same JSON', () => { writeChangeCorpus(root) const a = JSON.stringify(impact({ kind: 'business-rule', target: { title: 'Nouvelle règle' } })) const b = JSON.stringify(impact({ kind: 'business-rule', target: { title: 'Nouvelle règle' } })) expect(a).toBe(b) }) it('runs under 2 s on the fixture', () => { writeChangeCorpus(root) const t0 = Date.now() impact({ kind: 'use-case', section: 'opportunites' }) expect(Date.now() - t0).toBeLessThan(2000) }) it('projectPath with a core-seed state marks the module developed', () => { writeChangeCorpus(root) const project = join(tmp, 'project') mkdirSync(join(project, '.smartstack', 'core-seed'), { recursive: true }) writeFileSync(join(project, '.smartstack', 'core-seed', 'crm.state.json'), '{}', 'utf8') const r = impact({ kind: 'use-case', section: 'opportunites', projectPath: project }) expect(r.state.developed).toBe(true) }) }) describe('verify — the owner doc after the Write', () => { const ucPath = () => join(root, 'CRM', 'PIPELINE', 'opportunites', 'use-case.md') it('a valid UC appended → found, delta +1, nothing lost, ok — and a kept near-miss heading fails it', () => { writeChangeCorpus(root) const base = impact({ kind: 'use-case', section: 'opportunites' }) const newBlock = `### ${UC_PREFIX}010 — Relancer un prospect (user-goal)\n- **Acteur principal** : BA-001-AC-001 (Commercial)\n- **Flux principal** :\n 1. Relance.\n- **Acceptance Criteria** :\n - [ ] AC-01 — POST relance renvoie 202.\n` const spec = { kind: 'use-case' as const, section: 'opportunites', verify: { expectCode: `${UC_PREFIX}010`, baselineCount: base.existing.count, machineBlocksHash: base.owner.machineBlocksHash }, } // The re-Write fixed the fixture's near-miss heading (a full re-Write is the moment to) → clean. writeFileSync(ucPath(), useCaseDoc(newBlock, false), 'utf8') const clean = verify(spec) expect(clean.verify).toMatchObject({ found: true, count: 4, delta: 1, duplicates: [], lost: [], machineBlocksIntact: true, sourcesCited: 'not-required', ok: true }) // The re-Write kept the near-miss heading → an item outside the contract → NOT ok, said out loud. writeFileSync(ucPath(), useCaseDoc(newBlock, true), 'utf8') const kept = verify(spec) expect(kept.verify!.found).toBe(true) expect(kept.verify!.lost.some((l) => /near-miss|does not parse/.test(l))).toBe(true) expect(kept.verify!.ok).toBe(false) }, 10000) it('a dropped item (delta 0 on add) or a malformed AC bullet fails the verify', () => { writeChangeCorpus(root) // Re-Write that FORGETS UC-002 and adds 010 with a malformed AC bullet. const text = useCaseDoc(`### ${UC_PREFIX}010 — Relancer un prospect (user-goal)\n- **Acceptance Criteria** :\n - AC-01 POST relance renvoie 202.\n`).replace( /### UC-CRM-PIPELINE-opportunites-002[\s\S]*?(?=### UC-CRM-PIPELINE-opportunites-003)/, '', ) writeFileSync(ucPath(), text, 'utf8') const r = verify({ kind: 'use-case', section: 'opportunites', verify: { expectCode: `${UC_PREFIX}010`, baselineCount: 3 } }) expect(r.verify!.found).toBe(true) expect(r.verify!.delta).toBe(0) expect(r.verify!.lost.some((l) => /malformed AC bullet/.test(l))).toBe(true) expect(r.verify!.ok).toBe(false) expect(r.warnings.some((w) => /DROPPED/.test(w))).toBe(true) }) it('a duplicated code is reported', () => { writeChangeCorpus(root) writeFileSync(ucPath(), useCaseDoc(`### ${UC_PREFIX}003 — Doublon (user-goal)\n- **Acceptance Criteria** :\n - [ ] AC-01 — x.\n`), 'utf8') const r = verify({ kind: 'use-case', section: 'opportunites', verify: { expectCode: `${UC_PREFIX}003`, baselineCount: 3 } }) expect(r.verify!.duplicates).toEqual([`${UC_PREFIX}003`]) expect(r.verify!.ok).toBe(false) }) it('modify expects delta 0', () => { writeChangeCorpus(root) const r = verify({ kind: 'use-case', op: 'modify', section: 'opportunites', target: { code: `${UC_PREFIX}002` }, verify: { expectCode: `${UC_PREFIX}002`, baselineCount: 3 } }) expect(r.verify!.delta).toBe(0) expect(r.verify!.found).toBe(true) }) it('a changed machine block is caught', () => { writeChangeCorpus(root) const base = impact({ kind: 'permission' }) const rbac = join(root, 'CRM', 'PIPELINE', 'rbac.md') writeFileSync(rbac, readFileSync(rbac, 'utf8').replace('BA-001-AC-003 (Assistant)', 'BA-001-AC-003 (Stagiaire)'), 'utf8') // verify is not implemented for permission yet — but the hash compare is generic: exercise it through the rule kind on a doc WITH a block const rulesPath = join(root, 'CRM', 'PIPELINE', 'règles-métier.md') writeFileSync(rulesPath, `${readFileSync(rulesPath, 'utf8')}\n\nrow\n\n`, 'utf8') const before = impact({ kind: 'business-rule' }) writeFileSync(rulesPath, readFileSync(rulesPath, 'utf8').replace('row', 'ROW') + ruleBlock('BR-004', 'Nouvelle', 'validation', `${UC_PREFIX}001`), 'utf8') const r = verify({ kind: 'business-rule', verify: { expectCode: 'BR-004', baselineCount: before.existing.count, machineBlocksHash: before.owner.machineBlocksHash } }) expect(r.verify!.machineBlocksIntact).toBe(false) expect(r.verify!.ok).toBe(false) expect(base.owner.machineBlocksHash).not.toBe('none') }) it('sources registry present: a new rule without a Sources line is `missing`, with a resolvable one `ok`', () => { writeChangeCorpus(root, { withSources: true }) const rulesPath = join(root, 'CRM', 'PIPELINE', 'règles-métier.md') const before = impact({ kind: 'business-rule' }) expect(before.state.sourcesPresent).toBe(true) writeFileSync(rulesPath, `${readFileSync(rulesPath, 'utf8')}\n${ruleBlock('BR-004', 'Nouvelle', 'validation', `${UC_PREFIX}001`)}`, 'utf8') const missing = verify({ kind: 'business-rule', verify: { expectCode: 'BR-004', baselineCount: before.existing.count } }) expect(missing.verify!.sourcesCited).toBe('missing') expect(missing.verify!.ok).toBe(false) writeFileSync(rulesPath, readFileSync(rulesPath, 'utf8').replace('- **Cas d\'usage liés** : UC-CRM-PIPELINE-opportunites-001\n', '- **Cas d\'usage liés** : UC-CRM-PIPELINE-opportunites-001\n- **Sources** : SRC-001 §1\n'), 'utf8') const ok = verify({ kind: 'business-rule', verify: { expectCode: 'BR-004', baselineCount: before.existing.count } }) expect(ok.verify!.sourcesCited).toBe('ok') expect(ok.verify!.ok).toBe(true) }) })