import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { deriveFkSpecs, parseRelLines } from '../derive.js' // Fixture mirrors the test-RH post-mortem tree: app PROJET (modules PROJETS + // CONFIGURATION) and app CLIENT (module ANNUAIRE). The Projet entity carries // the exact three Rel: lines that shipped 2/3 derived — the CLI must resolve // ALL of them, byte-deterministically. describe('derive-fk-specs', () => { let baRoot: string let moduleRoot: string function writeDoc(relPath: string, content: string): void { const full = join(baRoot, relPath) mkdirSync(join(full, '..'), { recursive: true }) writeFileSync(full, content, 'utf8') } function writePagespec(relDir: string, name: string, spec: object): void { mkdirSync(join(baRoot, relDir), { recursive: true }) writeFileSync( join(baRoot, relDir, name), '# Pagespec\n\n```json\n' + JSON.stringify(spec) + '\n```\n', 'utf8', ) } beforeEach(() => { baRoot = mkdtempSync(join(tmpdir(), 'derive-fk-specs-')) moduleRoot = join(baRoot, 'PROJET', 'PROJETS') mkdirSync(moduleRoot, { recursive: true }) writeDoc( 'PROJET/PROJETS/entité.md', [ '# Modèle de données — PROJET / PROJETS', '', '### ENT-001 — Projet (agrégat racine)', '', '- **Relations** :', " - Projet *→1 Client (Donneur d'ordre) — FK ClientId, scope cross-module (CLIENT/ANNUAIRE), onDelete restrict", ' - Projet *→1 TypeProjet — FK TypeProjetId, scope cross-module (PROJET/CONFIGURATION), onDelete restrict', ' - Projet *→1 Statut — FK StatutId, scope cross-module (PROJET/CONFIGURATION), onDelete restrict', ' - Projet *→1 User — FK ResponsableUserId nullable, scope core (auth_Users), onDelete restrict', ' - Projet *→1 TenantOrganisation — FK OrganisationId, scope core (tenant_TenantOrganisations), onDelete restrict', ' - Projet 1→* Affectation — FK ProjetId, composants (voir ENT-002)', ].join('\n'), ) // Cross-app target WITH pagespec. writePagespec('CLIENT/ANNUAIRE/pagespecs', 'Client.list.md', { appCode: 'client', module: 'annuaire', section: 'list', entity: 'Client', view: 'list', }) // Same-app cross-module target WITH pagespec. writePagespec('PROJET/CONFIGURATION/pagespecs', 'TypeProjet.list.md', { appCode: 'projet', module: 'configuration', section: 'types-projet', entity: 'TypeProjet', view: 'list', }) // Target WITHOUT pagespec — resolved via the section-level entité.md heading. writeDoc( 'PROJET/CONFIGURATION/statuts/entité.md', '## ENT-003 — Statut\n\n| Attribut | Type |\n|---|---|\n| Id | Guid |\n', ) }) afterEach(() => rmSync(baRoot, { recursive: true, force: true })) it('resolves every FK of the post-mortem Projet entity — none may drop silently', () => { const r = deriveFkSpecs({ moduleRoot }) expect(r.unresolved).toEqual([]) expect(r.entities).toHaveLength(1) const fields = r.entities[0]!.fields expect(fields.map((f) => f.name)).toEqual([ 'ClientId', 'TypeProjetId', 'StatutId', 'ResponsableUserId', 'OrganisationId', ]) }) it('resolves a cross-app target through its pagespec (app + navRoute + endpoint)', () => { const r = deriveFkSpecs({ moduleRoot }) const client = r.entities[0]!.fields.find((f) => f.name === 'ClientId')! expect(client.fkTo).toEqual({ entity: 'Client', app: 'client', module: 'annuaire', navRoute: 'annuaire.list', apiEndpoint: '/api/annuaire/list/lookup', }) expect(client.required).toBe(true) expect(client.role).toBe("Donneur d'ordre") }) it('resolves a same-app cross-module target through its pagespec', () => { const r = deriveFkSpecs({ moduleRoot }) const type = r.entities[0]!.fields.find((f) => f.name === 'TypeProjetId')! expect(type.fkTo).toEqual({ entity: 'TypeProjet', app: 'projet', module: 'configuration', navRoute: 'configuration.types-projet', apiEndpoint: '/api/configuration/types-projet/lookup', }) }) it('falls back to the section entité.md heading when the target has no pagespec', () => { const r = deriveFkSpecs({ moduleRoot }) const statut = r.entities[0]!.fields.find((f) => f.name === 'StatutId')! expect(statut.fkTo).toEqual({ entity: 'Statut', app: 'projet', module: 'configuration', navRoute: 'configuration.statuts', apiEndpoint: '/api/configuration/statuts/lookup', }) }) it('resolves core V1 targets to the platform lookup route — TenantOrganisation without endpoint', () => { const r = deriveFkSpecs({ moduleRoot }) const fields = r.entities[0]!.fields const user = fields.find((f) => f.name === 'ResponsableUserId')! expect(user.fkTo).toEqual({ entity: 'User', module: 'core', apiEndpoint: '/api/core/users/lookup' }) expect(user.required).toBe(false) // `nullable` on the Rel line const org = fields.find((f) => f.name === 'OrganisationId')! expect(org.fkTo).toEqual({ entity: 'TenantOrganisation', module: 'core' }) }) it('skips 1→* reverse lines (the FK belongs to the child entity)', () => { const r = deriveFkSpecs({ moduleRoot }) expect(r.entities[0]!.fields.some((f) => f.name === 'ProjetId')).toBe(false) }) it('restricts to one source entity via the entity filter', () => { const r = deriveFkSpecs({ moduleRoot, entity: 'Inexistant' }) expect(r.entities).toEqual([]) expect(r.totals.fks).toBe(0) }) it('reports an unresolvable target as BLOCKING instead of guessing', () => { writeDoc( 'PROJET/PROJETS/entité.md', '- Projet *→1 Fantome — FK FantomeId, scope cross-module (PROJET/CONFIGURATION), onDelete restrict\n', ) const r = deriveFkSpecs({ moduleRoot }) expect(r.entities).toEqual([]) expect(r.unresolved).toHaveLength(1) expect(r.unresolved[0]).toMatchObject({ entity: 'Projet', field: 'FantomeId', target: 'Fantome' }) }) it('refuses an ambiguous global resolution (same entity name in two apps)', () => { writeDoc( 'PROJET/PROJETS/entité.md', '- Projet *→1 Tag — FK TagId, scope cross-module (AILLEURS), onDelete restrict\n', ) writePagespec('CLIENT/ANNUAIRE/pagespecs', 'Tag.list.md', { appCode: 'client', module: 'annuaire', section: 'tags', entity: 'Tag', view: 'list', }) writePagespec('PROJET/CONFIGURATION/pagespecs', 'Tag.list.md', { appCode: 'projet', module: 'configuration', section: 'tags', entity: 'Tag', view: 'list', }) const r = deriveFkSpecs({ moduleRoot }) expect(r.unresolved).toHaveLength(1) expect(r.unresolved[0]!.reason).toMatch(/several pagespecs/) }) it('resolves an off-whitelist `scope core` target as a client entity via the global scan', () => { writeDoc( 'PROJET/PROJETS/entité.md', '- Affectation *→1 Employee — FK EmployeeId, scope core (hr_Employees), onDelete restrict\n', ) writePagespec('HR/EMPLOYES/pagespecs', 'Employee.list.md', { appCode: 'hr', module: 'employes', section: 'liste', entity: 'Employee', view: 'list', }) const r = deriveFkSpecs({ moduleRoot }) expect(r.unresolved).toEqual([]) expect(r.entities[0]!.fields[0]!.fkTo).toEqual({ entity: 'Employee', app: 'hr', module: 'employes', navRoute: 'employes.liste', apiEndpoint: '/api/employes/liste/lookup', }) expect(r.warnings.some((w) => w.includes('not on the Core V1 whitelist'))).toBe(true) }) it('de-duplicates a Rel line repeated at module + section level', () => { writeDoc( 'PROJET/PROJETS/list/entité.md', '- Projet *→1 TypeProjet — FK TypeProjetId, scope cross-module (PROJET/CONFIGURATION), onDelete restrict\n', ) const r = deriveFkSpecs({ moduleRoot }) expect(r.entities[0]!.fields.filter((f) => f.name === 'TypeProjetId')).toHaveLength(1) }) it('parses the legacy `(FK Field, restrict)` shape without a scope as same-module', () => { const rels = parseRelLines('Rel: Employee *→1 Department (FK DepartmentId, restrict)', 'x.md') expect(rels).toHaveLength(1) expect(rels[0]).toMatchObject({ source: 'Employee', target: 'Department', fkField: 'DepartmentId', scope: 'same-module', required: true, }) expect(rels[0]!.role).toBeUndefined() }) it('parses checkbox prd.entities.md lines and self-referential roles', () => { const rels = parseRelLines( '- [ ] Rel: Employee *→1 Employee (Responsable) — FK ManagerId, nullable, scope same-module, onDelete restrict', 'prd.entities.md', ) expect(rels[0]).toMatchObject({ source: 'Employee', target: 'Employee', role: 'Responsable', fkField: 'ManagerId', required: false, scope: 'same-module', }) }) }) describe('derive-fk-specs — same-section navRoute collision guard (client report 2026-08-25 #9)', () => { // Sub-view pattern: several entities share ONE section. The derived // `{module}.{section}` navRoute (and its /lookup) would be identical for all // of them, while one [NavRoute] serves one controller — the FK must come out // UNRESOLVED (fail loudly), never as a guessed endpoint. let baRoot: string let moduleRoot: string function writePagespec(relDir: string, name: string, spec: object): void { mkdirSync(join(baRoot, relDir), { recursive: true }) writeFileSync( join(baRoot, relDir, name), '# Pagespec\n\n```json\n' + JSON.stringify(spec) + '\n```\n', 'utf8', ) } beforeEach(() => { baRoot = mkdtempSync(join(tmpdir(), 'derive-fk-collision-')) moduleRoot = join(baRoot, 'FLOTTE', 'PARC') mkdirSync(moduleRoot, { recursive: true }) writeFileSync( join(moduleRoot, 'entité.md'), [ '# Modèle de données — FLOTTE / PARC', '', '### ENT-001 — Vehicle', '', '- **Relations** :', ' - Vehicle *→1 VehicleType — FK VehicleTypeId, scope cross-module (FLOTTE/PARAMETRAGE), onDelete restrict', ].join('\n'), 'utf8', ) // TWO entities share the `referentiels` section — NEITHER declares a // routeFamily: the derived {module}.{section} navRoute is genuinely // ambiguous (one [NavRoute] serves one controller). writePagespec('FLOTTE/PARAMETRAGE/pagespecs', 'VehicleType.list.md', { appCode: 'flotte', module: 'parametrage', section: 'referentiels', entity: 'VehicleType', view: 'list', }) writePagespec('FLOTTE/PARAMETRAGE/pagespecs', 'FuelType.list.md', { appCode: 'flotte', module: 'parametrage', section: 'referentiels', entity: 'FuelType', view: 'list', }) }) afterEach(() => rmSync(baRoot, { recursive: true, force: true })) it('a target co-hosted with another entity in its section comes out unresolved with an explicit reason', () => { const r = deriveFkSpecs({ moduleRoot }) expect(r.entities).toHaveLength(0) expect(r.unresolved).toHaveLength(1) const u = r.unresolved[0]! expect(u.target).toBe('VehicleType') expect(u.reason).toContain("navRoute 'parametrage.referentiels' is ambiguous") expect(u.reason).toContain('FuelType') }) it('a routeFamily satellite RESOLVES to its family navRoute (§28 — no more forced authoring)', () => { // Re-declare the target WITH its route identity (PRD-108): the navRoute is // family-scoped, no ambiguity remains even with a section cohost. writePagespec('FLOTTE/PARAMETRAGE/pagespecs', 'VehicleType.list.md', { appCode: 'flotte', module: 'parametrage', section: 'referentiels', entity: 'VehicleType', view: 'list', routeFamily: 'vehicle-types', routeParent: 'referentiels', }) const r = deriveFkSpecs({ moduleRoot }) expect(r.unresolved).toEqual([]) expect(r.entities[0]!.fields[0]!.fkTo).toMatchObject({ entity: 'VehicleType', navRoute: 'parametrage.referentiels.vehicle-types', apiEndpoint: '/api/parametrage/referentiels/vehicle-types/lookup', }) }) it('TWO satellites sharing one routeFamily are ambiguous too (the collision one level down)', () => { // Both declare family 'types' under the same parent: their derived // navRoute is identical, and one [NavRoute] serves one controller. writePagespec('FLOTTE/PARAMETRAGE/pagespecs', 'VehicleType.list.md', { appCode: 'flotte', module: 'parametrage', section: 'referentiels', entity: 'VehicleType', view: 'list', routeFamily: 'types', routeParent: 'referentiels', }) writePagespec('FLOTTE/PARAMETRAGE/pagespecs', 'FuelType.list.md', { appCode: 'flotte', module: 'parametrage', section: 'referentiels', entity: 'FuelType', view: 'list', routeFamily: 'types', routeParent: 'referentiels', }) const r = deriveFkSpecs({ moduleRoot }) expect(r.entities).toHaveLength(0) expect(r.unresolved).toHaveLength(1) expect(r.unresolved[0]!.reason).toContain("route family 'types'") expect(r.unresolved[0]!.reason).toContain('FuelType') }) it('a CLIENT entity sharing a Core alias resolves to its OWN pagespec, not the catalogue (audit #1)', () => { // `Bureau` is a Core alias of Office. A `scope same-module` rel must NEVER // short-circuit to /api/core/offices/lookup — only `scope core` claims it. writeFileSync( join(moduleRoot, 'entité.md'), '- Vehicle *→1 Bureau — FK BureauId, scope same-module, onDelete restrict\n', 'utf8', ) writePagespec('FLOTTE/PARC/pagespecs', 'Bureau.list.md', { appCode: 'flotte', module: 'parc', section: 'sites', entity: 'Bureau', view: 'list', }) const r = deriveFkSpecs({ moduleRoot }) expect(r.unresolved).toEqual([]) expect(r.entities[0]!.fields[0]!.fkTo).toMatchObject({ entity: 'Bureau', module: 'parc', navRoute: 'parc.sites', apiEndpoint: '/api/parc/sites/lookup', }) }) it('two satellites under DIFFERENT parents sharing a family slug do NOT collide (audit #3)', () => { writeFileSync( join(moduleRoot, 'entité.md'), '- Vehicle *→1 VehicleDocument — FK VehicleDocumentId, scope same-module, onDelete restrict\n', 'utf8', ) writePagespec('FLOTTE/PARC/pagespecs', 'VehicleDocument.list.md', { appCode: 'flotte', module: 'parc', section: 'vehicules', entity: 'VehicleDocument', view: 'list', routeFamily: 'documents', routeParent: 'vehicules', }) writePagespec('FLOTTE/PARC/pagespecs', 'DriverDocument.list.md', { appCode: 'flotte', module: 'parc', section: 'conducteurs', entity: 'DriverDocument', view: 'list', routeFamily: 'documents', routeParent: 'conducteurs', }) const r = deriveFkSpecs({ moduleRoot }) expect(r.unresolved).toEqual([]) expect(r.entities[0]!.fields[0]!.fkTo).toMatchObject({ navRoute: 'parc.vehicules.documents', apiEndpoint: '/api/parc/vehicules/documents/lookup', }) }) it('a single-entity section still derives normally', () => { rmSync(join(baRoot, 'FLOTTE/PARAMETRAGE/pagespecs/FuelType.list.md'), { force: true }) const r = deriveFkSpecs({ moduleRoot }) expect(r.unresolved).toEqual([]) expect(r.entities[0]!.fields[0]!.fkTo).toMatchObject({ entity: 'VehicleType', navRoute: 'parametrage.referentiels', apiEndpoint: '/api/parametrage/referentiels/lookup', }) }) })