/** * derive-lifecycle — deterministic lifecycle backfill + PRD-120 check engine. * * Covers: the derive anchors (ONE state enum + workflow action → phase), * verbatim status anchoring, requiredFields from required params, the * never-invent rule (lexicon → needs-judgment, ambiguous enums → * needs-judgment), idempotence (`already`), the no-state / no-anchor * outcomes, entité.md enum parsing scoped per entity, the check-mode findings * (legs a/b/c/d/f + schema) and check-never-writes. */ import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { checkLifecycleOfPagespec, deriveLifecycleForPagespec, execute, parseEntityStatusEnums, } from '../execute.js' const ENTITE_MD = `# Entités ### ENT-001 — Invoice (agrégat racine) - **Préfixe table** : \`billing_\` | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Label | string(200) | requis | — | | Status | enum | DRAFT/SENT/PAYEE | — | | PaymentDate | datetime | — | — | ### ENT-002 — Client | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Segment | enum | A/B/C | — | ` function pagespecMd(block: Record): string { return `# Invoice — form\n\n\`\`\`json\n${JSON.stringify(block, null, 2)}\n\`\`\`\n\nProse below.\n` } const BASE_BLOCK = { screenCode: 'SCR-BILLING-INVOICES-FORM', entity: 'Invoice', view: 'form', fields: [ { key: 'label', labelKey: 'form.fields.label' }, { key: 'status', labelKey: 'form.fields.status' }, { key: 'paymentDate', labelKey: 'form.fields.paymentDate' }, ], actions: [ { code: 'markPaid', kind: 'api', scope: 'row', endpoint: 'mark-paid', labelKey: 'list.actions.markPaid', permission: 'billing.invoices.update', workflowTransition: { fromStatus: ['SENT'], toStatus: 'PAYEE', flowParameters: ['paymentDate'] }, payloadParameters: [{ name: 'paymentDate', type: 'date', required: true, field: 'paymentDate' }], }, ], i18nKeys: { fr: {}, en: {}, it: {}, de: {} }, } const ENUMS = parseEntityStatusEnums(ENTITE_MD, 'Invoice') describe('parseEntityStatusEnums', () => { it('parses the enum rows of the ENTITY block only (sibling enums never leak)', () => { expect(ENUMS).toEqual([{ name: 'Status', values: ['DRAFT', 'SENT', 'PAYEE'] }]) expect(parseEntityStatusEnums(ENTITE_MD, 'Client')).toEqual([{ name: 'Segment', values: ['A', 'B', 'C'] }]) }) it('unknown entity → empty', () => { expect(parseEntityStatusEnums(ENTITE_MD, 'Ghost')).toEqual([]) }) }) describe('deriveLifecycleForPagespec — the deterministic anchors', () => { it('ONE state enum + workflow action → phase derived, requiredFields from required params', () => { const { md, outcome } = deriveLifecycleForPagespec(pagespecMd(BASE_BLOCK), 'p.md', ENUMS) expect(outcome.status).toBe('derived') expect(outcome.phases).toEqual([ { key: 'markPaid', statuses: ['PAYEE'], capturedBy: 'markPaid', fields: ['paymentDate'], requiredFields: ['paymentDate'] }, ]) const block = JSON.parse(/```json\s*\n([\s\S]*?)\n```/.exec(md)![1]!) as Record expect(block.lifecycle).toEqual({ statusField: 'status', phases: [{ key: 'markPaid', statuses: ['PAYEE'], capturedBy: 'markPaid', fields: ['paymentDate'], requiredFields: ['paymentDate'] }], }) }) it('an existing lifecycle is NEVER touched (idempotence → already)', () => { const first = deriveLifecycleForPagespec(pagespecMd(BASE_BLOCK), 'p.md', ENUMS) const second = deriveLifecycleForPagespec(first.md, 'p.md', ENUMS) expect(second.outcome.status).toBe('already') expect(second.md).toBe(first.md) }) it('a toStatus OUTSIDE the enum is not an anchor (verbatim rule)', () => { const block = structuredClone(BASE_BLOCK) as Record ;(block.actions as Array<{ workflowTransition: { toStatus: string } }>)[0]!.workflowTransition.toStatus = 'PAID' const { outcome } = deriveLifecycleForPagespec(pagespecMd(block), 'p.md', ENUMS) // paymentDate matches the lexicon → routed to judgment, never written. expect(outcome.status).toBe('needs-judgment') expect(outcome.candidateFields).toEqual(['paymentDate']) }) it('lexicon candidates WITHOUT an action anchor → needs-judgment, nothing written', () => { const block = { ...structuredClone(BASE_BLOCK), actions: [] } as Record const { md, outcome } = deriveLifecycleForPagespec(pagespecMd(block), 'p.md', ENUMS) expect(outcome.status).toBe('needs-judgment') expect(md).toBe(pagespecMd(block)) }) it('no state-semantics enum → no-state', () => { const { outcome } = deriveLifecycleForPagespec(pagespecMd(BASE_BLOCK), 'p.md', []) expect(outcome.status).toBe('no-state') }) it('TWO state-semantics enums → needs-judgment (ambiguous anchor)', () => { const twoEnums = [...ENUMS, { name: 'EtatFacturation', values: ['OUVERTE', 'CLOTUREE'] }] const { outcome } = deriveLifecycleForPagespec(pagespecMd(BASE_BLOCK), 'p.md', twoEnums) expect(outcome.status).toBe('needs-judgment') expect(outcome.reason).toContain('statut pilote') }) it('state enum + no anchor + no lexicon candidate → no-anchor', () => { const block = structuredClone(BASE_BLOCK) as Record block.fields = [{ key: 'label' }, { key: 'status' }] block.actions = [] const { outcome } = deriveLifecycleForPagespec(pagespecMd(block), 'p.md', ENUMS) expect(outcome.status).toBe('no-anchor') }) it('non-form views are skipped', () => { const { outcome } = deriveLifecycleForPagespec(pagespecMd({ ...BASE_BLOCK, view: 'list' }), 'p.md', ENUMS) expect(outcome.status).toBe('skipped') }) }) describe('checkLifecycleOfPagespec — the PRD-120 engine', () => { const withLifecycle = (lifecycle: unknown, overrides: Record = {}) => pagespecMd({ ...structuredClone(BASE_BLOCK), lifecycle, ...overrides }) const GOOD = { statusField: 'status', phases: [{ key: 'paiement', statuses: ['PAYEE'], capturedBy: 'markPaid', fields: ['paymentDate'], requiredFields: ['paymentDate'] }], } it('a coherent block yields ZERO findings', () => { expect(checkLifecycleOfPagespec(withLifecycle(GOOD), 'p.md', ENUMS)).toEqual([]) }) it('absence of lifecycle is never a finding', () => { expect(checkLifecycleOfPagespec(pagespecMd(BASE_BLOCK), 'p.md', ENUMS)).toEqual([]) }) it('(a) statusField not an entity enum, (b) statusField missing from fields[]', () => { const a = checkLifecycleOfPagespec(withLifecycle({ ...GOOD, statusField: 'ghost' }), 'p.md', ENUMS) expect(a.some((f) => f.leg === 'a')).toBe(true) expect(a.some((f) => f.leg === 'b')).toBe(true) }) it('(c) a status outside the enum values is flagged verbatim', () => { const f = checkLifecycleOfPagespec( withLifecycle({ ...GOOD, phases: [{ ...GOOD.phases[0]!, statuses: ['PAID'] }] }), 'p.md', ENUMS) expect(f.filter((x) => x.leg === 'c')).toHaveLength(1) }) it('(d) unknown field, statusField in a phase, double claim, reserved creation', () => { const f = checkLifecycleOfPagespec( withLifecycle({ statusField: 'status', phases: [ { key: 'creation', fields: ['label'] }, { key: 'p1', statuses: ['PAYEE'], fields: ['paymentDate', 'ghost', 'status'] }, { key: 'p2', statuses: ['SENT'], fields: ['paymentDate'] }, ], }), 'p.md', ENUMS) const legs = f.filter((x) => x.leg === 'd').map((x) => x.message) expect(legs.some((m) => m.includes('reserved'))).toBe(true) expect(legs.some((m) => m.includes("unknown field 'ghost'"))).toBe(true) expect(legs.some((m) => m.includes('statusField cannot belong'))).toBe(true) expect(legs.some((m) => m.includes('owned by two phases'))).toBe(true) }) it('(f) capturedBy coherence: missing action, toStatus outside statuses, unbound owned field, non-required param', () => { const missing = checkLifecycleOfPagespec( withLifecycle({ ...GOOD, phases: [{ ...GOOD.phases[0]!, capturedBy: 'ghost' }] }), 'p.md', ENUMS) expect(missing.some((x) => x.leg === 'f' && x.message.includes("capturedBy 'ghost'"))).toBe(true) const outside = checkLifecycleOfPagespec( withLifecycle({ ...GOOD, phases: [{ ...GOOD.phases[0]!, statuses: ['SENT'] }] }), 'p.md', ENUMS) expect(outside.some((x) => x.leg === 'f' && x.message.includes("toStatus 'PAYEE'"))).toBe(true) const blockNoParam = structuredClone(BASE_BLOCK) as Record ;(blockNoParam.actions as Array>)[0]!.payloadParameters = [{ name: 'commentaire', type: 'textarea' }] const unbound = checkLifecycleOfPagespec(pagespecMd({ ...blockNoParam, lifecycle: GOOD }), 'p.md', ENUMS) expect(unbound.some((x) => x.leg === 'f' && x.message.includes("no payloadParameters entry"))).toBe(true) const blockOptional = structuredClone(BASE_BLOCK) as Record ;(blockOptional.actions as Array>)[0]!.payloadParameters = [{ name: 'paymentDate', type: 'date', required: false, field: 'paymentDate' }] const notRequired = checkLifecycleOfPagespec(pagespecMd({ ...blockOptional, lifecycle: GOOD }), 'p.md', ENUMS) expect(notRequired.some((x) => x.leg === 'f' && x.message.includes('not required: true'))).toBe(true) }) it('(schema) an invalid block reports the Zod issues', () => { const f = checkLifecycleOfPagespec(withLifecycle({ phases: [] }), 'p.md', ENUMS) expect(f.some((x) => x.leg === 'schema')).toBe(true) }) }) describe('execute — filesystem wrapper', () => { it('derive writes, check never writes', () => { const dir = mkdtempSync(join(tmpdir(), 'derive-lifecycle-')) writeFileSync(join(dir, 'entité.md'), ENTITE_MD, 'utf-8') const specPath = join(dir, 'Invoice.form.md') writeFileSync(specPath, pagespecMd(BASE_BLOCK), 'utf-8') const check1 = execute({ mode: 'check', pagespecDir: dir, moduleRoot: dir }, dir) expect(check1.written).toBe(0) expect(check1.findings).toEqual([]) expect(readFileSync(specPath, 'utf-8')).toBe(pagespecMd(BASE_BLOCK)) const derive = execute({ mode: 'derive', pagespecDir: dir, moduleRoot: dir }, dir) expect(derive.written).toBe(1) expect(readFileSync(specPath, 'utf-8')).toContain('"lifecycle"') const again = execute({ mode: 'derive', pagespecDir: dir, moduleRoot: dir }, dir) expect(again.written).toBe(0) expect(again.outcomes.filter((o) => o.entity === 'Invoice')[0]!.status).toBe('already') const check2 = execute({ mode: 'check', pagespecDir: dir, moduleRoot: dir }, dir) expect(check2.findings).toEqual([]) }) })