import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { deriveActionSpecs } from '../derive.js' // ── Action fixtures (valid PageCustomAction shapes) ──────────────────────── const SYNC_HEADER = { code: 'syncFromPce', kind: 'api', scope: 'header', endpoint: 'sync-from-proconcept', httpMethod: 'POST', labelKey: 'list.actions.syncFromPce', permission: 'referentiels.types-affaire.execute', ucReference: 'UC-APP-REF-TYPEAFFAIRE-007', } const ARCHIVE_ROW = { code: 'archive', kind: 'api', scope: 'row', httpMethod: 'POST', labelKey: 'list.actions.archive', permission: 'referentiels.types-affaire.update', ucReference: 'UC-APP-REF-TYPEAFFAIRE-003', workflowTransition: { fromStatus: ['active', 'draft'], toStatus: 'archived', flowParameters: [] }, } const OPEN_NAV = { code: 'open', kind: 'navigate', scope: 'row', targetScreen: 'SCR-APP-REF-TYPEAFFAIRE-002', labelKey: 'list.actions.open', permission: 'referentiels.types-affaire.read', } const CREATE_CRUD = { code: 'create', kind: 'api', scope: 'header', labelKey: 'list.create', permission: 'referentiels.types-affaire.create', } function page(entity: string, view: string, actions: object[]): object { return { screenCode: `SCR-${entity}-${view}`, entity, view, actions } } describe('derive-action-specs', () => { let moduleRoot: string beforeEach(() => { moduleRoot = mkdtempSync(join(tmpdir(), 'derive-action-specs-')) mkdirSync(join(moduleRoot, 'pagespecs'), { recursive: true }) }) afterEach(() => rmSync(moduleRoot, { recursive: true, force: true })) function writePagespec(name: string, spec: object): void { writeFileSync(join(moduleRoot, 'pagespecs', name), '```json\n' + JSON.stringify(spec) + '\n```\n', 'utf8') } it('returns an empty report when there is no pagespecs dir', () => { rmSync(join(moduleRoot, 'pagespecs'), { recursive: true, force: true }) const r = deriveActionSpecs({ moduleRoot }) expect(r.entities).toEqual([]) expect(r.totals).toEqual({ entities: 0, apiActions: 0, navigateActions: 0, rejectedActions: 0, backfilledParams: 0 }) expect(r.rejected).toEqual([]) }) it('projects an api action onto controller + business + apiClient', () => { writePagespec('TypeAffaire.list.md', page('TypeAffaire', 'list', [SYNC_HEADER])) const r = deriveActionSpecs({ moduleRoot }) expect(r.entities).toHaveLength(1) const e = r.entities[0] expect(e.entity).toBe('TypeAffaire') // controller: code = endpoint (kebab), UPPERCASE verb, last permission segment expect(e.controller[0]).toMatchObject({ code: 'sync-from-proconcept', scope: 'header', httpMethod: 'POST', permissionAction: 'execute', responseDto: 'NoContent', }) // api-client: code = kebab business id, lowercase verb, explicit endpoint expect(e.apiClient[0]).toMatchObject({ code: 'sync-from-pce', endpoint: 'sync-from-proconcept', httpMethod: 'post', kind: 'api', }) expect(e.business[0]).toMatchObject({ code: 'sync-from-proconcept', scope: 'header' }) }) it('flattens workflowTransition into business.fromStatus[] / toStatus', () => { writePagespec('TypeAffaire.list.md', page('TypeAffaire', 'list', [ARCHIVE_ROW])) const r = deriveActionSpecs({ moduleRoot }) expect(r.entities[0].business[0]).toMatchObject({ code: 'archive', fromStatus: ['active', 'draft'], toStatus: 'archived', }) }) it('carries payloadShape (from payloadParameters) + payloadType to apiClient; drops payloadType when absent', () => { const IMPORT_HEADER = { code: 'importStandard', kind: 'api', scope: 'header', endpoint: 'import-standard', httpMethod: 'POST', labelKey: 'list.actions.importStandard', permission: 'referentiels.types-affaire.execute', ucReference: 'UC-APP-REF-JOURSFERIES-001', payloadDto: 'ImportStandardRequest', payloadParameters: [ { name: 'file', type: 'file', accept: '.csv', required: true }, { name: 'year', type: 'number' }, ], } const GENERATE_HEADER = { code: 'genererLegaux', kind: 'api', scope: 'header', endpoint: 'generate-legal', httpMethod: 'POST', labelKey: 'list.actions.genererLegaux', permission: 'referentiels.types-affaire.execute', ucReference: 'UC-APP-REF-JOURSFERIES-002', payloadDto: 'GenerateLegalHolidaysRequest', // no payloadParameters → optional body, no frontend payload } writePagespec('JourFerie.list.md', page('JourFerie', 'list', [IMPORT_HEADER, GENERATE_HEADER])) const api = deriveActionSpecs({ moduleRoot }).entities[0].apiClient const imp = api.find(a => a.code === 'import-standard')! expect(imp.payloadType).toBe('ImportStandardRequest') expect(imp.payloadShape).toEqual({ file: 'File', year: 'number' }) const gen = api.find(a => a.code === 'generer-legaux')! expect(gen.payloadType).toBeNull() expect(gen.payloadShape).toBeUndefined() }) it('payloadParameters WITHOUT payloadDto → ONE synthesized DTO on all three projections', () => { // The client-run drift: apiClient carried a payloadShape while // controller.payloadDto stayed null — the dialog collected values the // backend had nowhere to receive. The projections now synthesize // {Pascal(code)}{Entity}Dto from the entity the derive loop supplies. const DEACTIVATE_ROW = { code: 'deactivate', kind: 'api', scope: 'row', httpMethod: 'POST', labelKey: 'list.actions.deactivate', permission: 'referentiels.types-affaire.update', ucReference: 'UC-APP-REF-VEHICLETYPES-004', payloadParameters: [{ name: 'reason', type: 'textarea', required: true }], // no payloadDto → synthesized } writePagespec('VehicleType.list.md', page('VehicleType', 'list', [DEACTIVATE_ROW])) const e = deriveActionSpecs({ moduleRoot }).entities[0] const ctrl = e.controller.find(a => a.code === 'deactivate')! const biz = e.business.find(a => a.code === 'deactivate')! const api = e.apiClient.find(a => a.code === 'deactivate')! expect(ctrl.payloadDto).toBe('DeactivateVehicleTypeDto') expect(biz.payloadDto).toBe('DeactivateVehicleTypeDto') expect(api.payloadType).toBe('DeactivateVehicleTypeDto') expect(api.payloadShape).toEqual({ reason: 'string' }) expect(biz.payloadFields).toEqual([{ name: 'reason', type: 'textarea', required: true }]) }) it('routes navigate actions to navigate[] only (no controller/apiClient/business)', () => { writePagespec('TypeAffaire.list.md', page('TypeAffaire', 'list', [OPEN_NAV])) const r = deriveActionSpecs({ moduleRoot }) const e = r.entities[0] expect(e.controller).toEqual([]) expect(e.apiClient).toEqual([]) expect(e.business).toEqual([]) expect(e.navigate).toEqual([ { code: 'open', scope: 'row', labelKey: 'list.actions.open', permission: 'referentiels.types-affaire.read', targetScreen: 'SCR-APP-REF-TYPEAFFAIRE-002', }, ]) }) it('de-duplicates the same action across views and records every page', () => { writePagespec('TypeAffaire.list.md', page('TypeAffaire', 'list', [SYNC_HEADER])) writePagespec('TypeAffaire.detail.md', page('TypeAffaire', 'detail', [SYNC_HEADER])) const r = deriveActionSpecs({ moduleRoot }) expect(r.entities[0].apiClient).toHaveLength(1) expect(r.entities[0].pagesWithActions).toEqual(['TypeAffaire.detail', 'TypeAffaire.list']) }) it('excludes CRUD codes (create/edit/delete are auto-scaffolded)', () => { writePagespec('TypeAffaire.list.md', page('TypeAffaire', 'list', [CREATE_CRUD, SYNC_HEADER])) const r = deriveActionSpecs({ moduleRoot }) expect(r.entities[0].apiClient.map((a) => a.code)).toEqual(['sync-from-pce']) }) it('warns and skips a malformed pagespec without aborting the rest', () => { writeFileSync(join(moduleRoot, 'pagespecs', 'Broken.list.md'), '# no fenced block', 'utf8') writePagespec('TypeAffaire.list.md', page('TypeAffaire', 'list', [SYNC_HEADER])) const r = deriveActionSpecs({ moduleRoot }) expect(r.entities).toHaveLength(1) expect(r.warnings.some((w) => w.includes('Broken.list.md'))).toBe(true) }) it('warns and skips an invalid action but keeps the valid sibling', () => { const bad = { ...SYNC_HEADER, code: 'badOne', permission: 'flat' } // permission must be 3 segments writePagespec('TypeAffaire.list.md', page('TypeAffaire', 'list', [bad, ARCHIVE_ROW])) const r = deriveActionSpecs({ moduleRoot }) expect(r.entities[0].apiClient.map((a) => a.code)).toEqual(['archive']) expect(r.warnings.some((w) => w.includes('badOne'))).toBe(true) }) it('records rejected actions structurally (ba-audit-prd / Phase 2a gate signal)', () => { // The exact ABSENCES post-mortem shapes: fromStatus as a string (must be an // array), and a non-workflow api action carrying workflowTransition: null // (the schema is .optional(), not nullable). const badFromStatus = { code: 'approve', kind: 'api', scope: 'row', httpMethod: 'POST', labelKey: 'list.actions.approve', permission: 'rh.absences.approve', ucReference: 'UC-APP-RH-ABSENCES-002', workflowTransition: { fromStatus: 'EnAttente', toStatus: 'Approuve', flowParameters: [] }, } const nullTransition = { code: 'recompute', kind: 'api', scope: 'header', httpMethod: 'POST', labelKey: 'list.actions.recompute', permission: 'rh.absences.execute', ucReference: 'UC-APP-RH-ABSENCES-003', workflowTransition: null, } writePagespec('LeaveRequest.list.md', page('LeaveRequest', 'list', [badFromStatus, nullTransition, ARCHIVE_ROW])) const r = deriveActionSpecs({ moduleRoot }) // only the valid sibling survives expect(r.entities[0].apiClient.map((a) => a.code)).toEqual(['archive']) // both malformed actions are reported structurally expect(r.totals.rejectedActions).toBe(2) expect(r.rejected.map((x) => x.code).sort()).toEqual(['approve', 'recompute']) const approve = r.rejected.find((x) => x.code === 'approve') expect(approve?.file).toBe('LeaveRequest.list.md') expect(approve?.path).toContain('fromStatus') }) it('honours the optional entity filter', () => { writePagespec('TypeAffaire.list.md', page('TypeAffaire', 'list', [SYNC_HEADER])) writePagespec('Client.list.md', page('Client', 'list', [ARCHIVE_ROW])) const r = deriveActionSpecs({ moduleRoot, entity: 'Client' }) expect(r.entities.map((e) => e.entity)).toEqual(['Client']) }) // Client report 2026-08-25 (trou PRD #1): AlertRule.list declared 'suspend' // at header AND row scope. Both survive the wire-identity dedup (the scope // differs), but the api-client names its hook on the CODE alone → two // `useSuspendAlertRule` declarations (TS2393). The duplicate must be // REJECTED structurally, not silently generated into broken TypeScript. it('rejects a same-code action declared at two scopes (duplicate hook guard)', () => { const suspendRow = { code: 'suspend', kind: 'api', scope: 'row', httpMethod: 'POST', labelKey: 'list.actions.suspend', permission: 'alertes.regles.update', ucReference: 'UC-APP-ALERTES-REGLES-005', payloadParameters: [{ name: 'reason', type: 'textarea', required: true }], } const suspendHeader = { ...suspendRow, scope: 'header', payloadParameters: undefined } writePagespec('AlertRule.list.md', page('AlertRule', 'list', [suspendRow, suspendHeader])) const r = deriveActionSpecs({ moduleRoot }) // ONE survives (the first — row), the other is rejected structurally. const alertRule = r.entities.find((e) => e.entity === 'AlertRule')! expect(alertRule.apiClient).toHaveLength(1) expect(alertRule.apiClient[0]!.scope).toBe('row') expect(alertRule.business).toHaveLength(1) expect(alertRule.controller).toHaveLength(1) expect(r.totals.rejectedActions).toBe(1) expect(r.rejected[0]!.code).toBe('suspend') expect(r.rejected[0]!.message).toContain('useSuspendAlertRule') expect(r.rejected[0]!.message).toContain('TS2393') }) it('the same code at the SAME scope on two views still collapses silently (legit multi-view action)', () => { writePagespec('TypeAffaire.list.md', page('TypeAffaire', 'list', [SYNC_HEADER])) writePagespec('TypeAffaire.detail.md', page('TypeAffaire', 'detail', [SYNC_HEADER])) const r = deriveActionSpecs({ moduleRoot }) expect(r.totals.rejectedActions).toBe(0) expect(r.entities[0]!.apiClient).toHaveLength(1) }) }) describe('derive-action-specs — dialog lookup params (§28)', () => { // Nested BA layout (tmp/BA/APP/MODULE) so resolveLookupTarget's global scan // walks OUR tree, never the whole OS tmpdir. let baRoot: string let moduleRoot: string function writeSpec(relDir: string, name: string, spec: object): void { mkdirSync(join(baRoot, relDir), { recursive: true }) writeFileSync(join(baRoot, relDir, name), '```json\n' + JSON.stringify(spec) + '\n```\n', 'utf8') } beforeEach(() => { const tmp = mkdtempSync(join(tmpdir(), 'derive-action-lookup-')) baRoot = join(tmp, 'ba') moduleRoot = join(baRoot, 'FLOTTE', 'PARC') mkdirSync(join(moduleRoot, 'pagespecs'), { recursive: true }) }) afterEach(() => rmSync(join(baRoot, '..'), { recursive: true, force: true })) function transferAction(param: object): object { return { code: 'transfer', kind: 'api', scope: 'row', httpMethod: 'POST', labelKey: 'list.actions.transfer', permission: 'parc.affectations.update', ucReference: 'UC-FLOTTE-PARC-AFFECTATIONS-001', payloadDto: 'TransferRequest', payloadParameters: [param], } } it('resolves a cross-module target through its OWN pagespec (navRoute, never {module}/{plural})', () => { writeSpec('FLOTTE/CONDUCTEURS/pagespecs', 'Driver.list.md', { appCode: 'flotte', module: 'conducteurs', section: 'annuaire', entity: 'Driver', view: 'list', }) writeSpec('FLOTTE/PARC/pagespecs', 'Assignment.list.md', { screenCode: 'SCR-1', entity: 'Assignment', view: 'list', actions: [transferAction({ name: 'targetDriverId', type: 'lookup', entity: 'Driver', module: 'conducteurs' })], }) const r = deriveActionSpecs({ moduleRoot }) const p = r.entities[0]!.dialogLookupParams[0]! expect(p).toMatchObject({ actionCode: 'transfer', param: 'targetDriverId', entity: 'Driver', navRoute: 'conducteurs.annuaire', apiEndpoint: '/api/conducteurs/annuaire/lookup', }) }) it('a satellite target keeps its routeFamily segment in the navRoute', () => { writeSpec('FLOTTE/PARC/pagespecs', 'VehicleDocument.list.md', { appCode: 'flotte', module: 'parc', section: 'vehicules', entity: 'VehicleDocument', view: 'list', routeFamily: 'documents', routeParent: 'vehicules', }) writeSpec('FLOTTE/PARC/pagespecs', 'Assignment.list.md', { screenCode: 'SCR-1', entity: 'Assignment', view: 'list', actions: [transferAction({ name: 'documentId', type: 'lookup', entity: 'VehicleDocument' })], }) const r = deriveActionSpecs({ moduleRoot }) expect(r.entities[0]!.dialogLookupParams[0]).toMatchObject({ navRoute: 'parc.vehicules.documents', apiEndpoint: '/api/parc/vehicules/documents/lookup', }) }) it('a Core target resolves through the SSOT endpoint (offices — never /api/parc/offices)', () => { writeSpec('FLOTTE/PARC/pagespecs', 'Assignment.list.md', { screenCode: 'SCR-1', entity: 'Assignment', view: 'list', actions: [transferAction({ name: 'officeId', type: 'lookup', entity: 'Office' })], }) const r = deriveActionSpecs({ moduleRoot }) const p = r.entities[0]!.dialogLookupParams[0]! expect(p.module).toBe('core') expect(p.apiEndpoint).toBe('/api/core/offices/lookup') }) it('an authored apiEndpoint is never overridden', () => { writeSpec('FLOTTE/PARC/pagespecs', 'Assignment.list.md', { screenCode: 'SCR-1', entity: 'Assignment', view: 'list', actions: [transferAction({ name: 'x', type: 'lookup', entity: 'Driver', apiEndpoint: '/api/custom/route/lookup' })], }) const r = deriveActionSpecs({ moduleRoot }) expect(r.entities[0]!.dialogLookupParams[0]).toMatchObject({ apiEndpoint: '/api/custom/route/lookup' }) }) it('an unresolvable target is surfaced as unresolved (author the endpoint, never guess)', () => { writeSpec('FLOTTE/PARC/pagespecs', 'Assignment.list.md', { screenCode: 'SCR-1', entity: 'Assignment', view: 'list', actions: [transferAction({ name: 'ghostId', type: 'lookup', entity: 'GhostEntity', module: 'parc' })], }) const r = deriveActionSpecs({ moduleRoot }) const p = r.entities[0]!.dialogLookupParams[0]! expect(p.unresolved).toContain('GhostEntity') expect(p.navRoute).toBeUndefined() }) }) describe('derive-action-specs — pagespec backfill (§28 persisted, review fix)', () => { let baRoot: string let moduleRoot: string function writeSpec(relDir: string, name: string, spec: object): void { mkdirSync(join(baRoot, relDir), { recursive: true }) writeFileSync(join(baRoot, relDir, name), '# Page\n\n```json\n' + JSON.stringify(spec, null, 2) + '\n```\n', 'utf8') } const readAssignment = (): Record => { const md = readFileSync(join(moduleRoot, 'pagespecs', 'Assignment.list.md'), 'utf8') return JSON.parse(/```json\s*\r?\n([\s\S]*?)\r?\n```/.exec(md)![1]) } beforeEach(() => { const tmp = mkdtempSync(join(tmpdir(), 'derive-action-backfill-')) baRoot = join(tmp, 'ba') moduleRoot = join(baRoot, 'FLOTTE', 'PARC') mkdirSync(join(moduleRoot, 'pagespecs'), { recursive: true }) writeSpec('FLOTTE/CONDUCTEURS/pagespecs', 'Driver.list.md', { appCode: 'flotte', module: 'conducteurs', section: 'annuaire', entity: 'Driver', view: 'list', }) }) afterEach(() => rmSync(join(baRoot, '..'), { recursive: true, force: true })) function writeTransfer(param: object): void { writeSpec('FLOTTE/PARC/pagespecs', 'Assignment.list.md', { screenCode: 'SCR-1', entity: 'Assignment', view: 'list', actions: [{ code: 'transfer', kind: 'api', scope: 'row', httpMethod: 'POST', labelKey: 'list.actions.transfer', permission: 'parc.affectations.update', ucReference: 'UC-FLOTTE-PARC-AFFECTATIONS-001', payloadDto: 'TransferRequest', payloadParameters: [param], }], }) } it('mode "derive" WRITES the resolved route into the pagespec (survives a re-scaffold outside ba-develop)', () => { writeTransfer({ name: 'targetDriverId', type: 'lookup', entity: 'Driver', module: 'conducteurs' }) const r = deriveActionSpecs({ moduleRoot, mode: 'derive' } as never) expect(r.totals.backfilledParams).toBe(1) expect(r.backfilled[0]).toMatchObject({ file: 'Assignment.list.md', params: ['transfer.targetDriverId'], written: true }) const param = readAssignment().actions[0].payloadParameters[0] expect(param.navRoute).toBe('conducteurs.annuaire') expect(param.apiEndpoint).toBe('/api/conducteurs/annuaire/lookup') }) it('mode "check" (the default) reports the gap WITHOUT touching the file', () => { writeTransfer({ name: 'targetDriverId', type: 'lookup', entity: 'Driver', module: 'conducteurs' }) const before = readFileSync(join(moduleRoot, 'pagespecs', 'Assignment.list.md'), 'utf8') const r = deriveActionSpecs({ moduleRoot } as never) expect(r.totals.backfilledParams).toBe(1) expect(r.backfilled[0]!.written).toBe(false) expect(readFileSync(join(moduleRoot, 'pagespecs', 'Assignment.list.md'), 'utf8')).toBe(before) }) it('is IDEMPOTENT — a second derive run writes nothing', () => { writeTransfer({ name: 'targetDriverId', type: 'lookup', entity: 'Driver', module: 'conducteurs' }) deriveActionSpecs({ moduleRoot, mode: 'derive' } as never) const after1 = readFileSync(join(moduleRoot, 'pagespecs', 'Assignment.list.md'), 'utf8') const r2 = deriveActionSpecs({ moduleRoot, mode: 'derive' } as never) expect(r2.totals.backfilledParams).toBe(0) expect(readFileSync(join(moduleRoot, 'pagespecs', 'Assignment.list.md'), 'utf8')).toBe(after1) }) it('NEVER overwrites an authored apiEndpoint', () => { writeTransfer({ name: 'x', type: 'lookup', entity: 'Driver', module: 'conducteurs', apiEndpoint: '/api/custom/route/lookup' }) const r = deriveActionSpecs({ moduleRoot, mode: 'derive' } as never) expect(r.totals.backfilledParams).toBe(0) expect(readAssignment().actions[0].payloadParameters[0].apiEndpoint).toBe('/api/custom/route/lookup') }) it('an UNRESOLVABLE target is left alone (surfaced as unresolved, never guessed into the file)', () => { writeTransfer({ name: 'ghostId', type: 'lookup', entity: 'GhostEntity', module: 'parc' }) const r = deriveActionSpecs({ moduleRoot, mode: 'derive' } as never) expect(r.totals.backfilledParams).toBe(0) expect(readAssignment().actions[0].payloadParameters[0].navRoute).toBeUndefined() expect(r.entities[0]!.dialogLookupParams[0]!.unresolved).toContain('GhostEntity') }) }) // ── Permission-binding guard (PRD-128 / audit H3-H4) ─────────────────────── // kind:api only — the generators compile the constant from the SPEC's // module/section and keep only the ACTION segment: a permission rooting // elsewhere would be silently rebound, a resource-grain one silently widened. describe('derive-action-specs — permission binding guard', () => { let moduleRoot: string beforeEach(() => { moduleRoot = mkdtempSync(join(tmpdir(), 'derive-action-binding-')) mkdirSync(join(moduleRoot, 'pagespecs'), { recursive: true }) }) afterEach(() => rmSync(moduleRoot, { recursive: true, force: true })) function writeBoundPagespec(name: string, actions: object[]): void { const spec = { screenCode: 'SCR-TA-LIST', appCode: 'app', module: 'referentiels', section: 'types-affaire', entity: 'TypeAffaire', view: 'list', actions, } writeFileSync(join(moduleRoot, 'pagespecs', name), '```json\n' + JSON.stringify(spec) + '\n```\n', 'utf8') } it('accepts an api action bound to its own module.section', () => { writeBoundPagespec('TypeAffaire.list.md', [SYNC_HEADER]) const r = deriveActionSpecs({ moduleRoot }) expect(r.rejected).toEqual([]) expect(r.entities[0]!.controller).toHaveLength(1) }) it('REJECTS (blocking) an api action whose permission roots at another module — the silent-rebind class', () => { writeBoundPagespec('TypeAffaire.list.md', [ { ...SYNC_HEADER, permission: 'autre-module.types-affaire.execute' }, ]) const r = deriveActionSpecs({ moduleRoot }) expect(r.rejected).toHaveLength(1) expect(r.rejected[0]!.path).toBe('actions[].permission') expect(r.rejected[0]!.message).toContain('silently rebound') expect(r.entities).toHaveLength(0) }) it('REJECTS (blocking) a section mismatch too', () => { writeBoundPagespec('TypeAffaire.list.md', [ { ...ARCHIVE_ROW, permission: 'referentiels.autre-section.update' }, ]) const r = deriveActionSpecs({ moduleRoot }) expect(r.rejected).toHaveLength(1) expect(r.rejected[0]!.message).toContain('another section') }) it('REJECTS a resource-grain (4-seg) action permission — silently widened to the section (PRD-128)', () => { writeBoundPagespec('TypeAffaire.list.md', [ { ...SYNC_HEADER, permission: 'referentiels.types-affaire.modeles.execute' }, ]) const r = deriveActionSpecs({ moduleRoot }) expect(r.rejected).toHaveLength(1) expect(r.rejected[0]!.message).toContain('RESOURCE-grain') expect(r.rejected[0]!.message).toContain('PRD-128') }) it('a navigate action pointing at ANOTHER section is legitimate — never rejected', () => { writeBoundPagespec('TypeAffaire.list.md', [ { ...OPEN_NAV, permission: 'commandes.orders.read' }, ]) const r = deriveActionSpecs({ moduleRoot }) expect(r.rejected).toEqual([]) expect(r.entities[0]!.navigate).toHaveLength(1) }) // T9 closure — the schema now enforces its own « mandatory for kind:api » // comment: a custom api action without its UC anchor is REJECTED (blocking), // it no longer ships with the TODO[UC-…] trace silently lost. it('REJECTS (blocking) a custom api action WITHOUT ucReference — CRUD codes stay exempt', () => { const { ucReference: _dropped, ...anchorless } = SYNC_HEADER writeBoundPagespec('TypeAffaire.list.md', [CREATE_CRUD, anchorless]) const r = deriveActionSpecs({ moduleRoot }) expect(r.rejected).toHaveLength(1) expect(r.rejected[0]!.code).toBe('syncFromPce') expect(r.rejected[0]!.path).toContain('ucReference') expect(r.rejected[0]!.message).toContain('UC: UC-…') // the CRUD sibling never needs an anchor — no second rejection expect(r.rejected.some((x) => x.code === 'create')).toBe(false) }) it('a pagespec without module/section fields skips the check (tolerant on legacy specs)', () => { writeFileSync( join(moduleRoot, 'pagespecs', 'TypeAffaire.list.md'), '```json\n' + JSON.stringify({ screenCode: 'SCR-X', entity: 'TypeAffaire', view: 'list', actions: [ { ...SYNC_HEADER, permission: 'autre-module.ailleurs.execute' }, ] }) + '\n```\n', 'utf8', ) const r = deriveActionSpecs({ moduleRoot }) expect(r.rejected).toEqual([]) expect(r.entities[0]!.controller).toHaveLength(1) }) })