import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { enforceableRules, enforcedRules, loadModuleRules, parseRules } from '../ba-rules-rows.js' const RULES_DOC = ` # Règles métier — CRM / PIPELINE ### BR-001 — Remise plafonnée - **Type** : validation - **Sévérité** : err - **Portée** : CRM / PIPELINE - **Condition** : QUAND une remise > 20 % est saisie ALORS exiger l'aval manager. - **Expression** : \`Discount <= 0.20 || Approval.ManagerId != null\` - **Code d'erreur** : \`pipeline.discount.cap\` - **Cas valides** : remise 15 % sans aval ; remise 25 % avec aval manager. - **Cas invalides** : remise 25 % sans aval. - **Cas d'usage liés** : UC-CRM-PIPELINE-OPPORTUNITES-002 (étape 3) ### BR-002 — Visibilité des archives - **Type** : constraint - **Sévérité** : warn - **Portée** : CRM / PIPELINE - **Condition** : QUAND une opportunité est archivée ALORS elle sort des listes. - **Code d'erreur** : - **Cas valides** : archivée absente de la liste. - **Cas invalides** : archivée visible. - **Cas d'usage liés** : ### BR-012 — Cycle de vie d'une commande - **Type** : workflow - **Sévérité** : err - **Portée** : CRM / ORDERS - **Condition** : QUAND le statut change, ALORS respecter les transitions autorisées. - **Flow** : - draft → submitted (by: BA-001-AC-001, guard: all required fields filled) - submitted → approved (by: BA-001-AC-002, guard: amount ≤ approval threshold) - submitted → rejected (by: BA-001-AC-002) - **Expression** : \`Order.Status ∈ allowedTransitions[currentStatus]\` - **Code d'erreur** : \`orders.status.invalid-transition\` - **Cas valides** : draft → submitted par un commercial. - **Cas invalides** : draft → approved (saute submitted). - **Cas d'usage liés** : UC-CRM-ORDERS-COMMANDES-002 (étape 3) ` describe('parseRules', () => { const { rules, warnings } = parseRules(RULES_DOC) it('parses every rule with its typed fields — the first deterministic reader of règles-métier.md', () => { expect(warnings).toEqual([]) expect(rules.map((r) => r.code)).toEqual(['BR-001', 'BR-002', 'BR-012']) expect(rules[0]).toMatchObject({ code: 'BR-001', title: 'Remise plafonnée', type: 'validation', severity: 'err', portee: 'CRM / PIPELINE', expression: 'Discount <= 0.20 || Approval.ManagerId != null', errorCode: 'pipeline.discount.cap', linkedUcs: ['UC-CRM-PIPELINE-OPPORTUNITES-002'], }) expect(rules[0].condition).toContain('QUAND une remise') }) it('a blank error code (filter rule) stays undefined; empty linked UCs stay [] (BABOK)', () => { expect(rules[1].errorCode).toBeUndefined() expect(rules[1].linkedUcs).toEqual([]) expect(rules[1].severity).toBe('warn') }) it('parses the Flow block into structured transitions (by + guard)', () => { expect(rules[2].flow).toEqual([ { from: 'draft', to: 'submitted', by: 'BA-001-AC-001', guard: 'all required fields filled' }, { from: 'submitted', to: 'approved', by: 'BA-001-AC-002', guard: 'amount ≤ approval threshold' }, { from: 'submitted', to: 'rejected', by: 'BA-001-AC-002' }, ]) }) it('a comma-separated by: list runs to the guard (or the end), never to the first comma', () => { const r = parseRules( [ '### BR-009 — Flow multi-acteurs', '- **Type** : workflow', '- **Flow** :', ' - draft → submitted (by: Responsable RH, Directeur, guard: montant > 10000)', ' - submitted → closed (guard: solde nul, by: Directeur)', '', ].join('\n'), ) expect(r.rules[0]!.flow).toEqual([ { from: 'draft', to: 'submitted', by: 'Responsable RH, Directeur', guard: 'montant > 10000' }, { from: 'submitted', to: 'closed', by: 'Directeur', guard: 'solde nul' }, ]) }) it('warns on a non-canonical severity and keeps it raw — never a throw', () => { const r = parseRules('### BR-001 — X\n- **Sévérité** : bloquant\n') expect(r.rules[0].severity).toBe('bloquant') expect(r.warnings.some((w) => w.includes('non-canonical Sévérité'))).toBe(true) }) it('warns on same-doc duplicate codes and unparsable Flow lines', () => { const r = parseRules( '### BR-001 — A\n- **Type** : validation\n\n### BR-001 — B\n- **Type** : workflow\n- **Flow** :\n - draft submitted sans fleche\n', ) expect(r.warnings.some((w) => w.includes('Duplicate BR-001'))).toBe(true) expect(r.warnings.some((w) => w.includes('unparsable Flow line'))).toBe(true) }) it('enforceableRules keeps err+warn, drops info', () => { const r = parseRules( '### BR-001 — A\n- **Sévérité** : err\n\n### BR-002 — B\n- **Sévérité** : info\n\n### BR-003 — C\n- **Sévérité** : warn\n', ) expect(enforceableRules(r.rules).map((x) => x.code)).toEqual(['BR-001', 'BR-003']) }) it('enforcedRules additionally drops the EXEMPT ones (numbering/access/Enforcement opt-out) — the DEV-API-008 catch-up count', () => { const r = parseRules( [ '### BR-001 — Remise plafonnée\n- **Type** : validation\n- **Sévérité** : err\n', '### BR-002 — Numéro de demande\n- **Type** : numbering\n- **Sévérité** : err\n', '### BR-003 — Accès restreint\n- **Type** : access\n- **Sévérité** : warn\n', '### BR-004 — Rétention\n- **Type** : validation\n- **Sévérité** : err\n- **Enforcement** : plateforme — le socle applique la rétention\n', ].join('\n'), ) // enforceableRules keeps all four (err/warn); enforcedRules keeps only the // rule the generated code itself must enforce. expect(enforceableRules(r.rules)).toHaveLength(4) expect(enforcedRules(r.rules).map((x) => x.code)).toEqual(['BR-001']) }) }) describe('loadModuleRules — deepest-scope aggregation', () => { let root: string beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'ssrules-')) mkdirSync(join(root, 'CRM', 'PIPELINE', 'opportunites'), { recursive: true }) }) afterEach(() => rmSync(root, { recursive: true, force: true })) it('aggregates the module doc + every section doc, with docPath on each rule', () => { writeFileSync(join(root, 'CRM', 'PIPELINE', 'règles-métier.md'), '### BR-001 — Module\n- **Sévérité** : err\n', 'utf8') writeFileSync( join(root, 'CRM', 'PIPELINE', 'opportunites', 'règles-métier.md'), '### BR-001 — Section\n- **Sévérité** : err\n', 'utf8', ) const { rules, warnings, docs } = loadModuleRules(root, 'CRM', 'PIPELINE') expect(docs).toHaveLength(2) expect(rules).toHaveLength(2) expect(rules[0].docPath).toContain('PIPELINE') // Cross-doc duplicate code: warned, never merged (BR codes are doc-scoped). expect(warnings.some((w) => w.includes('BOTH'))).toBe(true) }) it('no docs at all → empty, no throw (the absence is the caller-visible signal)', () => { const { rules, docs } = loadModuleRules(root, 'CRM', 'PIPELINE') expect(rules).toEqual([]) expect(docs).toEqual([]) }) it('a RESOURCE-level doc is aggregated (ba-files.md: authoritative at the deepest scope)', () => { // The one-level walk left resource rules invisible to PRD-129/130, // DEV-API-008 and DEV-TEST-009 — the silent loss reintroduced one level // down (conformity-audit finding). mkdirSync(join(root, 'CRM', 'PIPELINE', 'opportunites', 'devis'), { recursive: true }) writeFileSync( join(root, 'CRM', 'PIPELINE', 'opportunites', 'devis', 'règles-métier.md'), '### BR-001 — Resource\n- **Sévérité** : err\n', 'utf8', ) const { rules, docs } = loadModuleRules(root, 'CRM', 'PIPELINE') expect(docs.some((d) => d.includes('devis'))).toBe(true) expect(rules.some((r) => r.title === 'Resource')).toBe(true) }) it('an uppercase/accented section folder is visited (the /^[a-z]/ filter was a silent skip)', () => { mkdirSync(join(root, 'CRM', 'PIPELINE', 'Réservations'), { recursive: true }) writeFileSync( join(root, 'CRM', 'PIPELINE', 'Réservations', 'règles-métier.md'), '### BR-009 — Accent\n- **Sévérité** : err\n', 'utf8', ) const { rules } = loadModuleRules(root, 'CRM', 'PIPELINE') expect(rules.some((r) => r.code === 'BR-009')).toBe(true) }) it('an EXISTING but unreadable doc stays visible with a LOUD warning (never exists:false)', () => { // A directory named règles-métier.md makes readFileSync throw (EISDIR) — // the portable stand-in for any I/O failure on a present file. mkdirSync(join(root, 'CRM', 'PIPELINE', 'règles-métier.md'), { recursive: true }) const { docs, warnings } = loadModuleRules(root, 'CRM', 'PIPELINE') expect(docs).toHaveLength(1) expect(warnings.some((w) => w.includes('UNREADABLE'))).toBe(true) }) }) describe('parseRules — folded fields and sub-bullet lists (the elaborate.md shape)', () => { it('a folded Condition keeps its continuation line; Cas valides/invalides capture their sub-bullets', () => { const md = [ '### BR-007 — Numéro de demande', '- **Type** : numbering', '- **Sévérité** : err', '- **Condition** : QUAND une demande est créée ALORS son numéro = `AFF-{YY}-{SEQ:4}`', ' (séquence par tenant, remise à zéro chaque année, sans trou, immuable).', '- **Cas valides** :', ' - 1re demande 2026 (tenant A) → `AFF-26-0001` ; suivante → `AFF-26-0002`.', ' - 1re demande 2027 → `AFF-27-0001` (reset annuel).', '- **Cas invalides** :', ' - Deux créations concurrentes → JAMAIS le même numéro (allocation atomique).', "- **Cas d'usage liés** : UC-RH-DEMANDES-001 (postcondition)", '', ].join('\n') const { rules, warnings } = parseRules(md) expect(warnings).toEqual([]) const r = rules[0] // Continuation folded into the typed value (it used to be dropped mute): expect(r.condition).toContain('immuable') // Sub-bullets land in the typed cases (they used to come out undefined): expect(r.validCases).toContain('AFF-26-0001') expect(r.validCases).toContain('reset annuel') expect(r.invalidCases).toContain('atomique') }) const here2 = fileURLToPath(new URL('.', import.meta.url)) const elaborate = join(here2, '..', '..', 'business-analyse', 'create-business-rules', 'levels', 'elaborate.md') it.skipIf(!existsSync(elaborate))('drift — levels/elaborate.md blocks parse with NON-empty cases', () => { const blocks = [...readFileSync(elaborate, 'utf8').matchAll(/```markdown\r?\n([\s\S]*?)```/g)] .map((m) => m[1]) .filter((b) => /###\s+BR-\d/.test(b)) expect(blocks.length).toBeGreaterThanOrEqual(1) for (const block of blocks) { const { rules } = parseRules(block) for (const r of rules) { if (/\*\*Cas valides\*\*/.test(block)) { expect(r.validCases, `${r.code} validCases must not be lost`).toBeTruthy() } } } }) }) describe('drift — the parser reads the canonical skeletons verbatim', () => { const here = fileURLToPath(new URL('.', import.meta.url)) const docTemplates = join(here, '..', '..', 'business-analyse', '_workflow', 'doc-templates.md') const rulesSkill = join(here, '..', '..', 'business-analyse', 'create-business-rules', 'SKILL.md') // `_workflow`/business-analyse sources are repo-only (installer flattens) — // the drift lock runs in the repo layout, skips in a deployed mirror. const inRepoLayout = existsSync(docTemplates) && existsSync(rulesSkill) const fencedRulesBlocks = (source: string): string[] => [...source.matchAll(/```markdown\r?\n([\s\S]*?)```/g)].map((m) => m[1]).filter((b) => /###\s+BR-\d/.test(b)) it.skipIf(!inRepoLayout)('doc-templates.md § règles-métier parses fully (BR-001 + BR-002 numbering)', () => { const blocks = fencedRulesBlocks(readFileSync(docTemplates, 'utf8')) expect(blocks.length).toBeGreaterThanOrEqual(1) const { rules, warnings } = parseRules(blocks[0]) expect(warnings).toEqual([]) expect(rules.map((r) => r.code)).toEqual(['BR-001', 'BR-002']) expect(rules[0].errorCode).toBe('pipeline.discount.cap') expect(rules[1].type).toBe('numbering') expect(rules[1].linkedUcs).toEqual(['UC-CRM-PIPELINE-OPPORTUNITES-001']) }) it.skipIf(!inRepoLayout)('the create-business-rules workflow example (BR-012) parses its Flow', () => { const blocks = fencedRulesBlocks(readFileSync(rulesSkill, 'utf8')) const wf = blocks.find((b) => b.includes('- **Flow**')) expect(wf).toBeDefined() const { rules } = parseRules(wf!) const br12 = rules.find((r) => r.type === 'workflow') expect(br12).toBeDefined() expect(br12!.flow.length).toBeGreaterThanOrEqual(3) expect(br12!.flow[0]).toMatchObject({ from: 'draft', to: 'submitted', by: 'BA-001-AC-001' }) }) })