/** * cli:derive-fk-specs — derive.ts (pure logic). * * Reads the module's `entité.md` (module level + every section level), parses * the `Rel: {Source} *→1 {Target} — FK {Field}, scope …` lines, and resolves * each target to the `fkTo` block the frontend scaffolders consume. Target * resolution order: * 1. `scope core` + V1-whitelist target (aliases included) → module `core`, * platform lookup route (TenantOrganisation: no apiEndpoint — the * scaffolder wires its selectItems adapter itself). * 2. The scope locator (`(APP/MODULE)` / `(MODULE)` / same-module) names the * target's BA folder; the target's OWN pagespec * `//pagespecs/{Target}..md` carries the authoritative * `{ appCode, module, section }` → `/api/{module}/{section}/lookup`. * 3. No pagespec → the section folder whose `entité.md` declares the target * (`### ENT-nnn — {Target}` heading) names the section. * 4. Still nothing → global pagespec scan across every app of the BA tree * (unique hit only — an ambiguous name is UNRESOLVED, never guessed). * Anything else lands in `unresolved[]` — a BLOCKING signal for the caller. * * Malformed files warn-and-skip; a single bad doc never aborts the derivation * (same posture as derive-action-specs). */ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs' import { join, basename, dirname } from 'node:path' import { matchCoreEntity, coreLookupEndpointFor } from '../../../lib/core-catalog.js' import { pluralize } from '../../../lib/string-utils.js' import { buildNavApiPath, pluralSegment } from '../../../lib/url-conventions.js' import { extractFencedJson } from '../compute-page-diff/scan-pagespecs.js' import type { DeriveFkSpecsInput, DeriveFkSpecsReport, DerivedEntityFks, DerivedFkField, FkToSpec, UnresolvedFk, } from './types.js' /** * One `Rel:` line, many authored shapes (all observed in real BA trees): * - Projet *→1 Client — FK ClientId, scope cross-module (CLIENT/ANNUAIRE), onDelete restrict * - [ ] Rel: Projet *→1 Statut — FK StatutId, scope cross-module (PROJET/CONFIGURATION), … * - **Relations** : AbsenceDocument *→1 Absence — FK AbsenceId, scope same-module, onDelete cascade * - Employee *→1 Employee (Responsable) — FK ManagerId, nullable, scope same-module, … * - Task *→1 User — FK AssignedToUserId nullable, scope core (auth_Users), … * - Rel: Employee *→1 Department (FK DepartmentId, restrict) ← legacy, FK inside parens * The `1→*` reverse lines never match (the FK belongs to the child, which owns * its own `*→1` line). */ const REL_LINE_RE = /([A-Z][A-Za-z0-9]*)\s*\*\s*(?:→|->)\s*(?:0\.\.)?1\s+([A-Z][A-Za-z0-9]*)\s*(?:\(([^)]*)\))?/ const FK_FIELD_RE = /\bFK\s+([A-Za-z][A-Za-z0-9]*)/ const SCOPE_RE = /\bscope\s+(same-module|cross-module|core)\b(?:\s*\(([^)]*)\))?/ interface ParsedRel { source: string target: string /** Parenthesised role label (only when it is not the legacy `(FK …)` form). */ role?: string fkField: string required: boolean scope: 'same-module' | 'cross-module' | 'core' /** First token of the scope parenthesis (`CLIENT/ANNUAIRE`, `PARAMETRES`, * `auth_Users`) — the trailing table hint after whitespace is dropped. */ locator?: string /** For traceability in warnings. */ file: string } function listDirs(root: string): string[] { let entries: string[] try { entries = readdirSync(root) } catch { return [] } return entries.filter((name) => { if (name.startsWith('_') || name.startsWith('.')) return false try { return statSync(join(root, name)).isDirectory() } catch { return false } }) } function readTextIfFile(path: string): string | null { try { if (!existsSync(path) || !statSync(path).isFile()) return null return readFileSync(path, 'utf8') } catch { return null } } /** Parse every `Rel: … *→1 …` line of one markdown document. */ export function parseRelLines(md: string, file: string): ParsedRel[] { const out: ParsedRel[] = [] for (const line of md.split(/\r?\n/)) { const rel = REL_LINE_RE.exec(line) if (!rel) continue const fk = FK_FIELD_RE.exec(line) if (!fk) continue const scopeMatch = SCOPE_RE.exec(line) const parenContent = rel[3]?.trim() // The tail after the FK column carries the nullability. Keep it scoped to // the segment before `scope …` so a BR note can never flip the flag. const fkEnd = (fk.index ?? 0) + fk[0].length const scopeIdx = scopeMatch ? line.indexOf(scopeMatch[0]) : -1 const beforeScope = scopeIdx > fkEnd ? line.slice(fkEnd, scopeIdx) : line.slice(fkEnd) out.push({ source: rel[1]!, target: rel[2]!, ...(parenContent && !/\bFK\b/.test(parenContent) ? { role: parenContent } : {}), fkField: fk[1]!, required: !/\bnullable\b/i.test(beforeScope), scope: (scopeMatch?.[1] as ParsedRel['scope']) ?? 'same-module', locator: scopeMatch?.[2]?.trim().split(/\s+/)[0], file, }) } return out } /** Collect Rel lines from `/entité.md` + `/
/entité.md`, * de-duplicated by (source, fkField) — module level wins. */ function collectRels(moduleRoot: string, warnings: string[]): ParsedRel[] { const docs: Array<{ path: string; label: string }> = [ { path: join(moduleRoot, 'entité.md'), label: 'entité.md' }, ...listDirs(moduleRoot).map((section) => ({ path: join(moduleRoot, section, 'entité.md'), label: `${section}/entité.md`, })), ] const seen = new Set() const rels: ParsedRel[] = [] let anyDoc = false for (const doc of docs) { const md = readTextIfFile(doc.path) if (md === null) continue anyDoc = true for (const rel of parseRelLines(md, doc.label)) { const key = `${rel.source}::${rel.fkField}` if (seen.has(key)) continue seen.add(key) rels.push(rel) } } if (!anyDoc) warnings.push(`no entité.md found under ${moduleRoot}`) return rels } interface PagespecTriple { appCode: string module: string section: string /** Route identity of a SATELLITE target (sub-view pattern, PRD-108): its * controller mounts at `{module}/{routeParent}/{routeFamily}` — deriving * `{module}.{section}` for it invents a route no controller serves (§28: * 9/9 dialog lookups on 404). */ routeFamily?: string routeParent?: string file: string /** BA module folder the triple was read from — feeds the same-section * collision guard below. Absent only on legacy call paths. */ moduleDir?: string } /** Read the `{ appCode, module, section }` triple from the target's pagespecs * in ONE module folder. Prefers the `.list` view; any view carries the same * triple. */ function pagespecTripleIn(moduleDir: string, target: string, warnings: string[]): PagespecTriple | null { const dir = join(moduleDir, 'pagespecs') if (!existsSync(dir)) return null let entries: string[] try { entries = readdirSync(dir) } catch { return null } const mine = entries .filter((name) => name.startsWith(`${target}.`) && name.endsWith('.md')) .sort((a, b) => (a.includes('.list.') ? -1 : 0) - (b.includes('.list.') ? -1 : 0)) for (const name of mine) { const md = readTextIfFile(join(dir, name)) if (md === null) continue const json = extractFencedJson(md) if (json === null) { warnings.push(`${name}: no fenced json block — skipped for FK resolution`) continue } try { const obj = JSON.parse(json) as { appCode?: unknown; module?: unknown; section?: unknown; entity?: unknown routeFamily?: unknown; routeParent?: unknown } if (obj.entity !== target) continue if (typeof obj.appCode === 'string' && typeof obj.module === 'string' && typeof obj.section === 'string') { return { appCode: obj.appCode, module: obj.module, section: obj.section, ...(typeof obj.routeFamily === 'string' ? { routeFamily: obj.routeFamily } : {}), ...(typeof obj.routeParent === 'string' ? { routeParent: obj.routeParent } : {}), file: name, moduleDir, } } } catch (err) { warnings.push(`${name}: invalid JSON (${(err as Error).message}) — skipped for FK resolution`) } } return null } /** Case-insensitive BA folder lookup (`annuaire` locator → `ANNUAIRE` folder). */ function findDir(root: string, name: string): string | null { const lower = name.toLowerCase() for (const dir of listDirs(root)) { if (dir.toLowerCase() === lower) return join(root, dir) } return null } /** Fallback 3: the section folder whose entité.md declares the target * (`### ENT-nnn — {Target}` heading, role parens tolerated). */ function sectionDeclaring(moduleDir: string, target: string): string | null { const headingRe = new RegExp(`^#{2,4}\\s+ENT-\\d+\\s+[—–-]\\s+${target}\\b`, 'm') const hits: string[] = [] for (const section of listDirs(moduleDir)) { if (section === 'pagespecs') continue const md = readTextIfFile(join(moduleDir, section, 'entité.md')) if (md !== null && headingRe.test(md)) hits.push(section) } return hits.length === 1 ? hits[0]! : null } /** Fallback 4: unique pagespec hit across every app/module of the BA tree. */ function globalPagespecScan(baRoot: string, target: string, warnings: string[]): PagespecTriple | 'ambiguous' | null { const hits: PagespecTriple[] = [] for (const app of listDirs(baRoot)) { for (const mod of listDirs(join(baRoot, app))) { const triple = pagespecTripleIn(join(baRoot, app, mod), target, warnings) if (triple) hits.push(triple) } } const distinct = new Map(hits.map((h) => [`${h.appCode}|${h.module}|${h.section}`, h])) if (distinct.size === 1) return [...distinct.values()][0]! return distinct.size > 1 ? 'ambiguous' : null } /** The navRoute a target's controller mounts at. Satellite (sub-view) targets * mount at {module}/{routeParent}/{routeFamily}; the porteur keeps the plain * {module}.{section}. routeParent normally equals the hosting section — * tolerate its absence (PRD-108 half-declared). */ function navRouteOfTriple(triple: PagespecTriple): string { return triple.routeFamily ? `${triple.module}.${triple.routeParent ?? triple.section}.${triple.routeFamily}` : `${triple.module}.${triple.section}` } function fkToFromTriple(target: string, triple: PagespecTriple): FkToSpec { const navRoute = navRouteOfTriple(triple) return { entity: target, app: triple.appCode.toLowerCase(), module: triple.module, navRoute, apiEndpoint: `${buildNavApiPath(navRoute)}/lookup`, } } /** The route key a pagespec derives WITHIN its module: `{routeParent}.{routeFamily}` * for a satellite (sub-view), the bare `{section}` for a porteur. Two entities * collide only when THIS key matches — `documents` under `vehicules` and * `documents` under `conducteurs` are distinct routes, not a collision. */ function routeKeyOf(section: string | undefined, routeParent?: string, routeFamily?: string): string { return routeFamily ? `${routeParent ?? section ?? ''}.${routeFamily}` : `${section ?? ''}` } /** Derived-route collision guard: OTHER entities whose pagespecs derive the * SAME route key as the target. One `[NavRoute]` serves ONE controller, so a * shared route makes the `/lookup` ambiguous — the FK could resolve against * the wrong entity's endpoint. Two shapes contend: the section slug (porteurs * with no `routeFamily`, client report 2026-08-25 #9) and the full * parent+family pair (two satellites given the same slug UNDER THE SAME * parent). The caller turns a non-empty result into `unresolved` (fail * loudly, never guess). */ function sectionCohostEntities( moduleDir: string, section: string, target: string, family?: string, routeParent?: string, ): string[] { const dir = join(moduleDir, 'pagespecs') if (!existsSync(dir)) return [] let entries: string[] try { entries = readdirSync(dir) } catch { return [] } const cohosts = new Set() for (const name of entries) { if (!name.endsWith('.md')) continue const md = readTextIfFile(join(dir, name)) if (md === null) continue const json = extractFencedJson(md) if (json === null) continue try { const obj = JSON.parse(json) as { entity?: unknown; section?: unknown; routeFamily?: unknown; routeParent?: unknown } if (typeof obj.entity !== 'string' || obj.entity === target) continue // The contended key is the DERIVED ROUTE, not the section: a satellite // mounts at {module}.{routeParent}.{routeFamily}, the porteur at // {module}.{section}. Two entities collide only when both keys match — // a satellite with its own family no longer collides with the porteur // (PRD-108), and `documents` under two DIFFERENT parents are two // distinct routes, not an ambiguity. const otherKey = routeKeyOf( typeof obj.section === 'string' ? obj.section : undefined, typeof obj.routeParent === 'string' ? obj.routeParent : undefined, typeof obj.routeFamily === 'string' ? obj.routeFamily : undefined, ) if (otherKey === routeKeyOf(section, routeParent, family)) cohosts.add(obj.entity) } catch { /* invalid pagespec JSON is reported by pagespecTripleIn's own pass */ } } return [...cohosts].sort() } /** How the caller points at the target's module. Both channels feed the SAME * chain — the FK path speaks `scope` + `locator` (from the `Rel:` line), the * custom-action path speaks a bare `module` (from the pagespec param). */ export interface TargetLocation { /** BA `Rel:` scope. Absent ⇒ the caller only knows a module name. */ scope?: 'same-module' | 'cross-module' | 'core' /** BA `Rel:` locator — `APP/MODULE`, `MODULE`, or a table hint on `scope core`. */ locator?: string /** Bare module name (custom-action `payloadParameters[].module`). */ module?: string } /** * THE resolver: a target entity → the `{ entity, module, navRoute, apiEndpoint }` * its controller actually serves. ONE chain, two callers — FK fields * (`deriveFkSpecs`) and custom-action `type:lookup` params * (`derive-action-specs`) — so the two can never disagree on a target's route, * which is the §28 defect class (a rebuilt `{module}/{english-plural}` 404'd * 9/9 dialog lookups while the FK channel had it right). * * Chain: Core V1 catalogue → the target's own pagespec (located module, then * global unique scan) → the section `entité.md` → derived-route collision * guard. Never a reconstructed guess: an unresolvable target comes back as a * `reason` for the caller to surface (fail loudly, author the endpoint). */ export function resolveTargetRoute( moduleRoot: string, target: string, where: TargetLocation, warnings: string[], /** Prefix for the warning lines (`: . → `). */ label = `lookup target ${target}`, ): FkToSpec | { reason: string } { const appDir = dirname(moduleRoot) const baRoot = dirname(appDir) // 1 — Core V1 target (aliases resolve to the canonical catalogue name). // TenantOrganisation resolves with NO endpoint: scaffold-component wires the // organization-references adapter deterministically. // // WHO may claim core differs per channel, and conflating them mis-routes // every client entity that merely SHARES a Core alias (Bureau, Organisation, // Groupe, Département…): // - FK channel — `scope` is always declared, so ONLY `scope core` claims it; // a `same-module`/`cross-module` rel must resolve against its own pagespec. // - custom-action param channel — there is no scope, so an unpinned module // means "try core", and `module: 'core'` pins it. const coreClaimed = where.scope !== undefined ? where.scope === 'core' : where.module === undefined || where.module === 'core' if (coreClaimed) { const core = matchCoreEntity(target) if (core) { const coreEndpoint = coreLookupEndpointFor(core.name) return { entity: core.name, module: 'core', ...(coreEndpoint === null ? {} : { apiEndpoint: coreEndpoint }) } } if (where.scope === 'core' || where.module === 'core') { // An off-whitelist `core` claim is a MISLABEL, not a dead end: the // locator is then a table hint (`hr_Employees`), so let the scan below // resolve it as the client entity it really is. warnings.push(`${label} says 'core' but the target is not on the Core V1 whitelist — resolving as a client entity`) } } // 2 — Locate the target's BA module folder. let moduleDir: string | null = null if (where.scope === 'same-module') { moduleDir = moduleRoot } else if (where.scope === 'cross-module' && where.locator) { const segments = where.locator.split('/') moduleDir = segments.length >= 2 ? (() => { const app = findDir(baRoot, segments[0]!) return app ? findDir(app, segments[1]!) : null })() : findDir(appDir, segments[0]!) if (moduleDir === null) { warnings.push(`${label}: scope locator (${where.locator}) matches no BA folder — falling back to a global scan`) } } else if (where.scope === undefined) { // Custom-action channel: the param's module, defaulting to this module. moduleDir = moduleRoot if (where.module && where.module.toLowerCase() !== basename(moduleRoot).toLowerCase()) { moduleDir = findDir(appDir, where.module) if (moduleDir === null) { warnings.push(`${label}: module '${where.module}' matches no BA folder — falling back to a global scan`) } } } // (`scope core` that fell through keeps moduleDir null — the global scan owns it.) // 3 — The target's own pagespec carries the authoritative triple; 3b — else // the section folder whose entité.md declares it. let triple = moduleDir ? pagespecTripleIn(moduleDir, target, warnings) : null if (!triple && moduleDir) { const section = sectionDeclaring(moduleDir, target) if (section) { triple = { appCode: basename(dirname(moduleDir)).toLowerCase(), module: basename(moduleDir).toLowerCase(), section, file: `${section}/entité.md`, moduleDir, } } } // 4 — Global unique pagespec hit (never guesses on an ambiguous name). if (!triple) { const scan = globalPagespecScan(baRoot, target, warnings) if (scan === 'ambiguous') { const hint = where.locator ?? where.scope ?? where.module return { reason: `several pagespecs declare entity '${target}' across the BA tree${hint ? ` and the scope locator (${hint}) did not disambiguate` : ' — name the target module to disambiguate'}`, } } triple = scan } if (!triple) { const hint = where.scope ? ` (scope ${where.scope}${where.locator ? ` (${where.locator})` : ''})` : '' return { reason: `no pagespec and no section entité.md declares '${target}'${hint} — create the target's screen/pagespec or fix the declaration` } } // 5 — Derived-route collision: the navRoute would be shared by several // entities, and ONE [NavRoute] serves ONE controller — the /lookup could hit // the wrong entity. Contended key = the DERIVED route: the section slug for // a porteur, the family slug for a satellite. const cohosts = triple.moduleDir ? sectionCohostEntities(triple.moduleDir, triple.section, target, triple.routeFamily, triple.routeParent) : [] if (cohosts.length > 0) { const contended = triple.routeFamily ? `route family '${triple.routeFamily}'` : `section '${triple.section}'` return { reason: `navRoute '${navRouteOfTriple(triple)}' is ambiguous: ${contended} is also claimed by ${cohosts.join(', ')} (sub-view pattern) — one [NavRoute] serves one controller, so the derived /lookup cannot be trusted. Either give each entity a distinct routeFamily/section, or author the endpoint explicitly (navRoute + apiEndpoint of the target's REAL controller).`, } } return fkToFromTriple(target, triple) } /** Custom-action `type:lookup` param channel — a thin alias over the shared chain. */ export function resolveLookupTarget( moduleRoot: string, target: string, opts: { module?: string }, warnings: string[], ): FkToSpec | { reason: string } { return resolveTargetRoute(moduleRoot, target, { module: opts.module }, warnings) } /** * Derive every FK field's `fkTo` block from a module's Rel lines. Grouped by * source entity; each field is spliced VERBATIM into the matching scaffolder * spec by the orchestrator. */ export function deriveFkSpecs(input: DeriveFkSpecsInput): DeriveFkSpecsReport { const warnings: string[] = [] const unresolved: UnresolvedFk[] = [] const moduleRoot = input.moduleRoot const rels = collectRels(moduleRoot, warnings).filter( (rel) => !input.entity || rel.source === input.entity, ) const byEntity = new Map() const push = (rel: ParsedRel, fkTo: FkToSpec): void => { const fields = byEntity.get(rel.source) ?? [] fields.push({ name: rel.fkField, required: rel.required, ...(rel.role ? { role: rel.role } : {}), fkTo, }) byEntity.set(rel.source, fields) } for (const rel of rels) { // ONE resolver for both channels (FK fields here, custom-action lookup // params in derive-action-specs) — the chain lives in resolveTargetRoute, // never re-implemented per caller. const resolved = resolveTargetRoute( moduleRoot, rel.target, { scope: rel.scope, locator: rel.locator }, warnings, `${rel.file}: ${rel.source}.${rel.fkField} → ${rel.target}`, ) if ('reason' in resolved) { unresolved.push({ entity: rel.source, field: rel.fkField, target: rel.target, reason: resolved.reason, }) continue } push(rel, resolved) } const entities: DerivedEntityFks[] = [...byEntity.entries()] .sort((a, b) => a[0].localeCompare(b[0])) .map(([entity, fields]) => ({ entity, fields })) return { moduleRoot, entities, unresolved, totals: { entities: entities.length, fks: entities.reduce((n, e) => n + e.fields.length, 0), unresolved: unresolved.length, }, warnings, } }