import { describe, expect, it } from 'vitest' import { parseRules, type BaRule } from '../../../../../lib/ba-rules-rows.js' import { deriveRuleLinks, mapRuleSections, parsePagespec, ruleExemption, type PagespecSource } from '../execute.js' // ── Fixtures — production parser end-to-end, docPath set as the loader would ─ function rulesFrom(md: string, docPath: string): BaRule[] { const { rules } = parseRules(md) for (const r of rules) r.docPath = docPath return rules } const SECTION_RULES = rulesFrom( `### BR-001 — Remise plafonnée - **Type** : validation - **Sévérité** : err - **Cas d'usage liés** : UC-CRM-PIPELINE-OPPORTUNITES-002 `, '.smartstack/ba/CRM/PIPELINE/opportunites/règles-métier.md', ) const MODULE_RULES = rulesFrom( `### BR-001 — Cohérence des devis - **Type** : integrity - **Sévérité** : err - **Cas d'usage liés** : UC-CRM-PIPELINE-DEVIS-001 ### BR-002 — Politique interne - **Type** : compliance - **Sévérité** : err - **Cas d'usage liés** : ### BR-003 — Journalisation - **Type** : cross-cutting - **Sévérité** : info ### BR-004 — Qui peut clôturer - **Type** : access - **Sévérité** : err ### BR-005 — Numéro de devis - **Type** : numbering - **Sévérité** : err ### BR-006 — Vérifié à la main - **Type** : constraint - **Sévérité** : err - **Enforcement** : manuel (revue trimestrielle) `, '.smartstack/ba/CRM/PIPELINE/règles-métier.md', ) function page(name: string, block: Record): PagespecSource { return { name, md: '# Page\n\n```json\n' + JSON.stringify(block, null, 2) + '\n```\n' } } const OPP_LIST = page('Opportunite.list.md', { screenCode: 'SCR-1', entity: 'Opportunite', module: 'pipeline', section: 'opportunites', view: 'list', }) const DEVIS_FORM = page('Devis.form.md', { screenCode: 'SCR-2', entity: 'Devis', module: 'pipeline', section: 'devis', view: 'form', }) const baseInput = (over: Record = {}) => ({ app: 'CRM', module: 'PIPELINE', mode: 'check' as const, rules: [...SECTION_RULES, ...MODULE_RULES], ruleWarnings: [], pagespecs: [OPP_LIST, DEVIS_FORM], moduleDirName: 'PIPELINE', ...over, }) describe('mapRuleSections — the deterministic ladder, never a guess', () => { it('a section-level doc maps to its own folder', () => { expect(mapRuleSections(SECTION_RULES[0], 'PIPELINE')).toEqual(['opportunites']) }) it('a module-level rule maps through its linked UC sections', () => { expect(mapRuleSections(MODULE_RULES[0], 'PIPELINE')).toEqual(['devis']) }) it('module-level + no UC → [] (needs-judgment, decided by a human)', () => { expect(mapRuleSections(MODULE_RULES[1], 'PIPELINE')).toEqual([]) }) }) describe('ruleExemption — every exemption names its real channel', () => { it('info severity / access / numbering / Enforcement field are exempt; err validation is not', () => { expect(ruleExemption(MODULE_RULES[2])).toBe('severity-info') expect(ruleExemption(MODULE_RULES[3])).toBe('type-access') expect(ruleExemption(MODULE_RULES[4])).toBe('type-numbering') expect(ruleExemption(MODULE_RULES[5])).toBe('enforcement-field') expect(ruleExemption(SECTION_RULES[0])).toBeNull() }) }) describe('deriveRuleLinks — a NON-canonical severity is a JUDGMENT call, never a fake exemption', () => { it('a typo severity (« bloquant ») lands in needs-judgment, not exempt/severity-info', () => { // The spelling mistake used to disarm BOTH gates (PRD-129 skip + // DEV-TEST-009 drop) while wearing a legitimate exemption label // (conformity-audit finding). const typo = rulesFrom( '### BR-050 — Plafond\n- **Type** : validation\n- **Sévérité** : bloquant\n', '.smartstack/ba/CRM/PIPELINE/opportunites/règles-métier.md', ) const { report } = deriveRuleLinks(baseInput({ rules: typo }) as never) const entry = report.rules.find((r) => r.code === 'BR-050')! expect(entry.status).toBe('needs-judgment') expect(entry.exemption).toBeUndefined() expect(String(entry.detail)).toContain('bloquant') }) }) describe('deriveRuleLinks — check mode (the PRD-129/130 engine)', () => { it('reports every unlinked enforceable rule as missing-link, exemptions and judgment separately', () => { const { report } = deriveRuleLinks(baseInput()) const byCode = new Map(report.rules.map((r) => [`${r.code}@${r.docPath}`, r.status])) expect(byCode.get('BR-001@.smartstack/ba/CRM/PIPELINE/opportunites/règles-métier.md')).toBe('missing-link') expect(byCode.get('BR-001@.smartstack/ba/CRM/PIPELINE/règles-métier.md')).toBe('missing-link') expect(byCode.get('BR-002@.smartstack/ba/CRM/PIPELINE/règles-métier.md')).toBe('needs-judgment') expect(report.totals.exempt).toBe(4) expect(report.totals.missingLinks).toBe(2) expect(report.totals.needsJudgment).toBe(1) expect(report.filesModified).toEqual([]) }) it('PRD-130: a cited code resolving to NO rule is a dead reference; a cross-doc duplicate is ambiguous', () => { const withCited = page('Opportunite.list.md', { entity: 'Opportunite', section: 'opportunites', view: 'list', linkedBusinessRules: ['BR-999', 'BR-001'], }) const { report } = deriveRuleLinks(baseInput({ pagespecs: [withCited] })) const kinds = report.citedCodeFindings.map((f) => `${f.code}:${f.kind}`).sort() // BR-001 exists in TWO docs of the module (section + module level) → ambiguous. expect(kinds).toEqual(['BR-001:ambiguous-code', 'BR-999:unresolved-code']) }) it('a rule already linked anywhere is `linked` — authored placement wins', () => { const linked = page('Opportunite.list.md', { entity: 'Opportunite', section: 'opportunites', view: 'list', linkedBusinessRules: ['BR-001'], }) const { report } = deriveRuleLinks(baseInput({ pagespecs: [linked, DEVIS_FORM] })) expect(report.rules.filter((r) => r.status === 'linked').length).toBeGreaterThanOrEqual(1) }) }) describe('deriveRuleLinks — backfill mode', () => { it('writes the code into every pagespec of the mapped section(s), sorted + deduped', () => { const { report, rewrites } = deriveRuleLinks(baseInput({ mode: 'backfill' })) expect(report.totals.backfilled).toBe(2) const opp = parsePagespec({ name: 'Opportunite.list.md', md: rewrites.get('Opportunite.list.md')! }) expect(opp.linked).toEqual(['BR-001']) const devis = parsePagespec({ name: 'Devis.form.md', md: rewrites.get('Devis.form.md')! }) expect(devis.linked).toEqual(['BR-001']) // The rest of the block survives the splice verbatim-equivalent. expect(devis.block).toMatchObject({ entity: 'Devis', section: 'devis', view: 'form' }) }) it('is idempotent: a second run over the rewritten pagespecs backfills nothing', () => { const first = deriveRuleLinks(baseInput({ mode: 'backfill' })) const rewritten = [ { name: 'Opportunite.list.md', md: first.rewrites.get('Opportunite.list.md')! }, { name: 'Devis.form.md', md: first.rewrites.get('Devis.form.md')! }, ] const second = deriveRuleLinks(baseInput({ mode: 'backfill', pagespecs: rewritten })) expect(second.report.totals.backfilled).toBe(0) expect(second.rewrites.size).toBe(0) }) it('a mapped section with NO pagespec is missing-link with the PRD-070 pointer — never written elsewhere', () => { const { report, rewrites } = deriveRuleLinks( baseInput({ mode: 'backfill', pagespecs: [OPP_LIST] }), // no devis pagespec ) const moduleBr1 = report.rules.find( (r) => r.code === 'BR-001' && r.docPath.endsWith('PIPELINE/règles-métier.md'), ) expect(moduleBr1?.status).toBe('missing-link') expect(moduleBr1?.detail).toContain('PRD-070') expect(rewrites.has('Devis.form.md')).toBe(false) }) })