import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { deriveCodeSpecs, reconcilePagespecFlag } from '../derive.js' import { validate } from '../validate.js' // derive-code-specs — the deterministic deriver of the coded-entities seam. // From the `**Code pattern**` lines of entité.md it emits both scaffolder // halves and reconciles the pagespec `codedEntity` flags (single source of // truth = entité.md). mode:"check" = the PRD-132 parity engine. let root: string let moduleRoot: string beforeEach(() => { root = mkdtempSync(path.join(tmpdir(), 'sscodespecs-')) moduleRoot = path.join(root, 'CRM', 'PIPELINE') mkdirSync(path.join(moduleRoot, 'pagespecs'), { recursive: true }) }) afterEach(() => rmSync(root, { recursive: true, force: true })) function writeEntite(body: string, rel = 'entité.md'): void { const abs = path.join(moduleRoot, rel) mkdirSync(path.dirname(abs), { recursive: true }) writeFileSync(abs, body) } function writePagespec(name: string, block: Record): string { const p = path.join(moduleRoot, 'pagespecs', name) writeFileSync(p, `# ${name}\n\n\`\`\`json\n${JSON.stringify(block, null, 2)}\n\`\`\`\n`) return p } const CANONICAL = ` ### ENT-001 — Opportunity (agrégat racine) - **Préfixe table** : \`pipeline_\` - **Code pattern** : \`OPP-{YY}-{SEQ:4}\` — scope tenant, reset annuel, gapless (voir BR-002). ### ENT-002 — Contact - **Préfixe table** : \`pipeline_\` ` describe('derive-code-specs — both scaffolder halves from the entité.md line', () => { it('derives codeKey, format and the authored facets; unauthored facets are OMITTED (Zod defaults downstream)', () => { writeEntite(CANONICAL) const r = deriveCodeSpecs(validate({ moduleRoot }).spec!) expect(r.totals.codedEntities).toBe(1) const e = r.entities[0] expect(e.entity).toBe('Opportunity') expect(e.codeKey).toBe('crm.opportunity') expect(e.scaffoldEntityCoded).toEqual({ codeKey: 'crm.opportunity', format: 'OPP-{YY}-{SEQ:4}' }) expect(e.scaffoldCodedEntity).toEqual({ entityName: 'Opportunity', applicationCode: 'crm', module: 'pipeline', codeKey: 'crm.opportunity', label: 'Opportunity codes', defaultFormat: 'OPP-{YY}-{SEQ:4}', scopeKind: 'Tenant', reset: 'Yearly', gapless: true, }) }) it('a mask-only line omits every facet (never invented here)', () => { writeEntite('### ENT-001 — Mandat\n- **Code pattern** : `MAN-{SEQ:5}`\n') const r = deriveCodeSpecs(validate({ moduleRoot }).spec!) const d = r.entities[0].scaffoldCodedEntity! expect(d.defaultFormat).toBe('MAN-{SEQ:5}') expect('scopeKind' in d).toBe(false) expect('reset' in d).toBe(false) expect('gapless' in d).toBe(false) }) it('a line without a mask or with an invalid format is BLOCKED (reasons, no specs) — never improvised', () => { writeEntite( '### ENT-001 — Demande\n- **Code pattern** : scope tenant, reset annuel\n\n' + '### ENT-002 — Facture\n- **Code pattern** : `FAC-{NNNN}`\n', ) const r = deriveCodeSpecs(validate({ moduleRoot }).spec!) expect(r.totals.blocked).toBe(2) expect(r.entities.every((e) => e.scaffoldEntityCoded === null && e.scaffoldCodedEntity === null)).toBe(true) expect(r.entities.find((e) => e.entity === 'Facture')!.reasons.join(' ')).toContain('Unrecognised') expect(r.warnings.join(' ')).toContain('unusable') }) it('a section-level entité.md is read too (deepest-scope walk)', () => { writeEntite('### ENT-010 — Devis\n- **Code pattern** : `DEV-{SEQ:4}`\n', path.join('opportunites', 'entité.md')) const r = deriveCodeSpecs(validate({ moduleRoot }).spec!) expect(r.entities.map((e) => e.entity)).toEqual(['Devis']) }) }) describe('derive-code-specs — pagespec flag reconciliation (PRD-132 engine)', () => { it('derive mode ADDS the missing flag and REMOVES a stale one; idempotent on re-run', () => { writeEntite(CANONICAL) const coded = writePagespec('Opportunity.list.md', { entity: 'Opportunity', view: 'list' }) const stale = writePagespec('Contact.form.md', { entity: 'Contact', view: 'form', codedEntity: true }) const r1 = deriveCodeSpecs(validate({ moduleRoot }).spec!) expect(r1.totals.added).toBe(1) expect(r1.totals.staleRemoved).toBe(1) expect(readFileSync(coded, 'utf-8')).toContain('"codedEntity": true') expect(readFileSync(stale, 'utf-8')).not.toContain('codedEntity') const r2 = deriveCodeSpecs(validate({ moduleRoot }).spec!) expect(r2.totals.added).toBe(0) expect(r2.totals.staleRemoved).toBe(0) expect(r2.pagespecs.filter((p) => p.status === 'already')).toHaveLength(1) }) it('check mode reports drift as DATA (missing + stale), writes nothing', () => { writeEntite(CANONICAL) const coded = writePagespec('Opportunity.form.md', { entity: 'Opportunity', view: 'form' }) writePagespec('Contact.list.md', { entity: 'Contact', view: 'list', codedEntity: true }) const r = deriveCodeSpecs(validate({ moduleRoot, mode: 'check' }).spec!) expect(r.drift.map((d) => d.status).sort()).toEqual(['missing', 'stale']) expect(readFileSync(coded, 'utf-8')).not.toContain('codedEntity') }) it('an entity with a BROKEN line still counts as coded for the flag (frontend guard stays armed)', () => { writeEntite('### ENT-001 — Demande\n- **Code pattern** : scope tenant\n') writePagespec('Demande.form.md', { entity: 'Demande', view: 'form' }) const r = deriveCodeSpecs(validate({ moduleRoot }).spec!) expect(r.totals.added).toBe(1) }) it('reconcilePagespecFlag leaves a malformed JSON block alone (audit-prd concern)', () => { const coded = new Map([['X', { label: null, supplied: false, codeKey: null, codeInputFields: [] }]]) const { outcome } = reconcilePagespecFlag('```json\n{not json\n```', 'X.list.md', coded, true) expect(outcome).toBeNull() }) }) describe('derive-code-specs — enriched flag (label + supplied facets)', () => { it('a facet-less line keeps the boolean flag BYTE-STABLE (non-regression pin)', () => { writeEntite(CANONICAL) const p = writePagespec('Opportunity.list.md', { entity: 'Opportunity', view: 'list', codedEntity: true }) const before = readFileSync(p, 'utf-8') const r = deriveCodeSpecs(validate({ moduleRoot }).spec!) expect(r.pagespecs.map((o) => o.status)).toEqual(['already']) expect(readFileSync(p, 'utf-8')).toBe(before) }) it('authored facets promote the flag to the enriched object (label + supplied + derived facets in the report)', () => { writeEntite( '### ENT-001 — Affaire\n' + '- **Code pattern** : `AFF-{YY}-{ABBR:ClientName:3}-{SEQ:4}` — scope tenant, reset annuel, gapless, ' + 'libellé « Référence », surchargeable à la création (voir BR-002).\n', ) const p = writePagespec('Affaire.form.md', { entity: 'Affaire', view: 'form' }) const r = deriveCodeSpecs(validate({ moduleRoot }).spec!) const e = r.entities[0] expect(e.label).toEqual({ fr: 'Référence', en: null }) expect(e.supplied).toBe(true) expect(e.codeInputFields).toEqual(['ClientName']) expect(e.scaffoldCodedEntity!.label).toBe('Affaire — Référence') const block = JSON.parse(/```json\n([\s\S]*?)\n```/.exec(readFileSync(p, 'utf-8'))![1]!) // supplied rides WITH codeKey + codeInputFields — the pagespec is // self-sufficient (Phase 3a forwards it VERBATIM, SmartCodeField mounts // from the flag alone). expect(block.codedEntity).toEqual({ label: { fr: 'Référence' }, supplied: true, codeKey: 'crm.affaire', codeInputFields: ['ClientName'], }) }) it('merge PRESERVES slots this deriver does not own (the multi-chantier contract)', () => { writeEntite('### ENT-001 — Affaire\n- **Code pattern** : `AFF-{SEQ:4}` — libellé « Référence ».\n') const p = writePagespec('Affaire.form.md', { entity: 'Affaire', view: 'form', codedEntity: { futureSlot: 'kept', supplied: true }, }) const r = deriveCodeSpecs(validate({ moduleRoot }).spec!) expect(r.pagespecs[0]).toMatchObject({ status: 'facet-healed', facets: ['label', 'supplied'] }) const block = JSON.parse(/```json\n([\s\S]*?)\n```/.exec(readFileSync(p, 'utf-8'))![1]!) // supplied dropped (not authored upstream), futureSlot survives, label lands. expect(block.codedEntity).toEqual({ futureSlot: 'kept', label: { fr: 'Référence' } }) }) it('check mode reports facet-drift as DATA in drift[] (PRD-132 leg), writes nothing', () => { writeEntite('### ENT-001 — Affaire\n- **Code pattern** : `AFF-{SEQ:4}` — surchargeable à la création.\n') const p = writePagespec('Affaire.form.md', { entity: 'Affaire', view: 'form', codedEntity: true }) const before = readFileSync(p, 'utf-8') const r = deriveCodeSpecs(validate({ moduleRoot, mode: 'check' }).spec!) expect(r.drift).toEqual([ { pagespec: 'Affaire.form.md', entity: 'Affaire', status: 'facet-drift', facets: ['supplied'] }, ]) expect(readFileSync(p, 'utf-8')).toBe(before) }) it('a stale ENRICHED flag dies with its upstream line (entité.md is the SSOT)', () => { writeEntite('### ENT-001 — Contact\n') const p = writePagespec('Contact.form.md', { entity: 'Contact', view: 'form', codedEntity: { label: { fr: 'Référence' }, supplied: true }, }) const r = deriveCodeSpecs(validate({ moduleRoot }).spec!) expect(r.totals.staleRemoved).toBe(1) expect(readFileSync(p, 'utf-8')).not.toContain('codedEntity') }) }) describe('derive-code-specs — near-miss + code-like signals (DM-021 / PRD-132 feeds)', () => { it('a table-cell declaration is reported in BOTH modes with a warning — the silence dies', () => { writeEntite('### ENT-001 — Catalogue\n\n| Code pattern | `CAT-{SEQ:4}` — scope tenant |\n') for (const mode of ['check', 'derive'] as const) { const r = deriveCodeSpecs(validate({ moduleRoot, mode }).spec!) expect(r.totals.nearMisses).toBe(1) expect(r.nearMisses[0]).toMatchObject({ entity: 'Catalogue', shape: 'table-cell', file: 'entité.md' }) expect(r.warnings.join(' ')).toContain('unparsable Code pattern mention') } }) it('an unclassified code-like attribute is reported; the near-miss co-signal is joined per entity', () => { writeEntite( '### ENT-001 — Dossier\n' + '| Attribut | Type | Contraintes | Calculé |\n' + '|---|---|---|---|\n' + '| Reference | string/50 | unique | — |\n' + '\n| Code pattern | `DOS-{SEQ:4}` |\n' + '\n### ENT-002 — Client\n' + '| Attribut | Type | Contraintes | Calculé |\n' + '|---|---|---|---|\n' + '| Matricule | string/20 | unique | — |\n', ) const r = deriveCodeSpecs(validate({ moduleRoot, mode: 'check' }).spec!) expect(r.totals.codeLike).toBe(2) expect(r.codeLike.find((c) => c.entity === 'Dossier')).toMatchObject({ attribute: 'Reference', coSignal: true }) expect(r.codeLike.find((c) => c.entity === 'Client')).toMatchObject({ attribute: 'Matricule', coSignal: false }) }) it('a classified attribute (**Code saisi**) never surfaces', () => { writeEntite( '### ENT-001 — Client\n' + '| Attribut | Type | Contraintes | Calculé |\n' + '|---|---|---|---|\n' + '| Matricule | string/20 | unique | — |\n' + '\n- **Code saisi** : Matricule — clé RH tapée.\n', ) const r = deriveCodeSpecs(validate({ moduleRoot, mode: 'check' }).spec!) expect(r.totals.codeLike).toBe(0) }) it('supplied + {SEQ} raises the sequential-corridor warn; a derived (no-{SEQ}) format stays silent', () => { writeEntite( '### ENT-001 — Facture\n- **Code pattern** : `FAC-{SEQ:4}` — surchargeable à la création.\n\n' + '### ENT-002 — Slug\n- **Code pattern** : `S-{SLUG:Name}` — surchargeable à la création.\n', ) const r = deriveCodeSpecs(validate({ moduleRoot, mode: 'check' }).spec!) expect(r.entities.find((e) => e.entity === 'Facture')!.sequentialSuppliedRisk).toBe(true) expect(r.entities.find((e) => e.entity === 'Slug')!.sequentialSuppliedRisk).toBe(false) const seqWarns = r.warnings.filter((w) => w.includes('FUTURE corridor')) expect(seqWarns).toHaveLength(1) expect(seqWarns[0]).toContain('Facture') }) })