/** * lib/ba-relations.ts — shared BA relations parser. * * entité.md → relation graph. Pure parsing functions over a * `Map` (modulePath = `APP/MODULE`), plus a thin sync IO * loader that reads every `///entité.md`. * * Consumers: * - create-screen/cli/derive-related-tabs — 360 related tabs (incomingOf) * - create-rbac/cli/derive-lookup-grants — FK → derived `lookup` grants * (outgoing *→1 / 1→1) * derive-related-tabs re-exports this module from its historical * `relations.ts` path, so its tests and import sites stay untouched. * * The authoritative `Relations` line format (create-data-model/levels/ * relationships.md): * `Invoice *→1 Client — FK ClientId, scope cross-module (CRM/CLIENTS), onDelete restrict` * (also `scope same-module`, `scope core (auth_Users)`; cardinalities `*→1`, * `1→1`, `1→*`, `*→*`). Parsing is a tolerant matchAll over each entity block — * trailing periods, several entries on one line and sub-bullet layouts all work. * Tolerant means it: EITHER dash (`—` or `-`) and any case on `FK`/`scope`. * That tolerance is load-bearing, not cosmetic — this is the ONLY place a * foreign key is declared (the skeleton never lists FK columns in the attribute * table), so an entry that fails to match deletes a key from the generated * schema. SCREEN_HEADING_RE was widened for the same reason after the * 2026-08-30 incident; this grammar had never received the fix. * * A document is skipped only when it declares NO entity block — see * lib/ba-placeholder. The marker `_À définir lors de la phase` is NOT the * test: it is written under every not-yet-authored section, so an authored * model routinely carries one. * * Relations with `scope core` are kept in the graph but NEVER produce * candidates (each consumer filters them out of its derivation). */ import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs' import { join } from 'node:path' // --------------------------------------------------------------------------- // Model // --------------------------------------------------------------------------- export interface EntityAttribute { name: string type: string constraints: string } export interface EntityRelation { /** Entity the relation is written ON (carries the FK for *→1 / 1→1). */ sourceEntity: string cardinality: '*→1' | '1→1' | '1→*' | '*→*' targetEntity: string /** FK column as written (PascalCase, e.g. `ClientId`). */ fk: string scope: 'same-module' | 'cross-module' | 'core' /** `(…)` detail — `APP/MOD` for cross-module, physical table for core. */ scopeDetail?: string onDelete?: string /** `APP/MODULE` path of the module the relation is written in. */ module: string } export interface ParsedEntity { code: string name: string classification?: string /** `APP/MODULE` path. */ module: string attributes: EntityAttribute[] /** Relations written ON this entity. */ relations: EntityRelation[] } export interface RelationGraph { entities: ParsedEntity[] relations: EntityRelation[] /** * Near-misses, never silence: a block whose cardinality tokens outnumber * the entries the grammar could read. An entry that fails to parse deletes * a foreign key from the generated schema (the FK column lives ONLY in this * line), so the loss is said out loud HERE — the grammar's owner — and * reaches every consumer: audit-ba (through ba-entities), derive-related-tabs, * derive-lookup-grants, derive-related-tabs-data. */ warnings: string[] } // --------------------------------------------------------------------------- // Regex patterns // --------------------------------------------------------------------------- /** `### ENT-001 — Opportunity (agrégat racine)` */ const ENTITY_HEADING_RE = /^###\s+(ENT-\d+)\s*[—-]\s*(\w+)(?:\s*\(([^)]+)\))?/gm /** `- **Classification** : composant` (optional bullet form). */ const CLASSIFICATION_BULLET_RE = /^-\s*\*\*Classification\*\*\s*:\s*(.+?)\s*\.?\s*$/m /** * Relations entry matcher — run with matchAll over each entity block. * `Invoice *→1 Client — FK ClientId, scope cross-module (CRM/CLIENTS), onDelete restrict` */ const RELATION_RE = /(\w+)\s*(\*(?:→|->)1|1(?:→|->)1|1(?:→|->)\*|\*(?:→|->)\*)\s*(\w+)\s*[—-]\s*FK\s+(\w+)\s*,\s*scope\s+(same-module|cross-module|core)(?:\s*\(([^)]+)\))?(?:\s*,\s*onDelete\s+([\w-]+))?/gi /** Cardinality tokens — the LOSS probe. The ASCII arrow `->` is accepted * everywhere the real one is (ba-rules-rows' Flow lines already do): a probe * that only knew `→` would count 0 tokens on an ASCII entry — silence again. */ export const RELATION_PROBE_RE = /\*(?:→|->)1|1(?:→|->)1|1(?:→|->)\*|\*(?:→|->)\*/g /** Attribute table row: `| Amount | decimal(18,2) | ≥ 0 | — |` */ const ATTRIBUTE_ROW_RE = /^\|\s*(\w+)\s*\|\s*([^|]+?)\s*\|\s*([^|]*?)\s*\|/gm // --------------------------------------------------------------------------- // Pure parsing // --------------------------------------------------------------------------- export function normalizeModulePath(modulePath: string): string { return modulePath.replace(/\s+/g, '').toUpperCase() } /** * Parse every entité.md content into one relation graph. * `contents` maps `APP/MODULE` → file content. */ export function parseEntities(contents: Map): RelationGraph { const entities: ParsedEntity[] = [] const warnings: string[] = [] for (const [modulePath, content] of [...contents.entries()].sort((a, b) => a[0].localeCompare(b[0]), )) { for (const block of splitEntityBlocks(content)) { entities.push(parseEntityBlock(block, modulePath, warnings)) } } return { entities, relations: entities.flatMap((e) => e.relations), warnings } } interface EntityBlock { code: string name: string headingClassification?: string content: string } function splitEntityBlocks(content: string): EntityBlock[] { const re = new RegExp(ENTITY_HEADING_RE.source, ENTITY_HEADING_RE.flags) const positions: Array<{ code: string; name: string; cls?: string; start: number }> = [] let m: RegExpExecArray | null while ((m = re.exec(content)) !== null) { positions.push({ code: m[1], name: m[2], cls: m[3]?.trim(), start: m.index }) } return positions.map((pos, i) => ({ code: pos.code, name: pos.name, headingClassification: pos.cls, content: content.slice( pos.start, i + 1 < positions.length ? positions[i + 1].start : undefined, ), })) } function parseEntityBlock(block: EntityBlock, modulePath: string, warnings: string[]): ParsedEntity { const bulletCls = CLASSIFICATION_BULLET_RE.exec(block.content)?.[1]?.trim() const attributes: EntityAttribute[] = [] for (const row of block.content.matchAll( new RegExp(ATTRIBUTE_ROW_RE.source, ATTRIBUTE_ROW_RE.flags), )) { const name = row[1] if (name.toLowerCase() === 'attribut') continue // header row attributes.push({ name, type: row[2].trim(), constraints: row[3].trim() }) } const relations: EntityRelation[] = [] for (const rel of block.content.matchAll( new RegExp(RELATION_RE.source, RELATION_RE.flags), )) { relations.push({ sourceEntity: rel[1], cardinality: rel[2].replace('->', '\u2192') as EntityRelation['cardinality'], targetEntity: rel[3], fk: rel[4], scope: rel[5].toLowerCase() as EntityRelation['scope'], scopeDetail: rel[6]?.trim(), onDelete: rel[7], module: modulePath, }) } // LOSS check — block-scoped: every cardinality token of the block must have // become a relation. A gap is a foreign key the schema will never get. const probe = (block.content.match(RELATION_PROBE_RE) ?? []).length if (probe > relations.length) { warnings.push( `${modulePath}: ${block.code} — **Relations** carries ${probe} cardinality token(s) but only ${relations.length} entry(ies) parse: ` + 'a malformed entry DELETES a foreign key silently — expected A *→1 B — FK BId, scope same-module|cross-module|core (…)[, onDelete …].', ) } return { code: block.code, name: block.name, classification: bulletCls ?? block.headingClassification, module: modulePath, attributes, relations, } } // --------------------------------------------------------------------------- // Graph API // --------------------------------------------------------------------------- /** Find an entity by name; an exact `preferredModule` match wins, else the * first match in module-sorted order (entities are inserted sorted). */ export function findEntity( graph: RelationGraph, name: string, preferredModule?: string, ): ParsedEntity | undefined { const matches = graph.entities.filter((e) => e.name === name) if (matches.length === 0) return undefined if (preferredModule) { const mod = normalizeModulePath(preferredModule) const exact = matches.find((e) => normalizeModulePath(e.module) === mod) if (exact) return exact } return matches[0] } /** * Relations whose TARGET is the entity (the related entity carries the FK * back to it): `*→1` and `1→1` only, never `scope core`. * - same-module: the relation must be written in the entity's own module. * - cross-module: only when `includeCrossModule`, and the relation's scope * detail `(APP/MOD)` must name the entity's module. */ export function incomingOf( graph: RelationGraph, entityName: string, entityModule: string, includeCrossModule: boolean, ): EntityRelation[] { const mod = normalizeModulePath(entityModule) return graph.relations.filter((r) => { if (r.targetEntity !== entityName) return false if (r.scope === 'core') return false if (r.cardinality !== '*→1' && r.cardinality !== '1→1') return false if (r.scope === 'same-module') return normalizeModulePath(r.module) === mod if (!includeCrossModule) return false return r.scopeDetail !== undefined && normalizeModulePath(r.scopeDetail) === mod }) } /** Relations written ON the entity (its own FKs). */ export function outgoingOf( graph: RelationGraph, entityName: string, entityModule: string, ): EntityRelation[] { const mod = normalizeModulePath(entityModule) return graph.relations.filter( (r) => r.sourceEntity === entityName && normalizeModulePath(r.module) === mod, ) } /** * Junction detection: the classification says `jonction`, OR the entity has * exactly two relations, both `*→1` (a payload-light N:M via). */ export function isJunction(entity: ParsedEntity): boolean { if (/jonction/i.test(entity.classification ?? '')) return true return ( entity.relations.length === 2 && entity.relations.every((r) => r.cardinality === '*→1') ) } // --------------------------------------------------------------------------- // IO loader // --------------------------------------------------------------------------- /** * Read every `///entité.md` (ALL apps — cross-module * incoming relations can be written in another app's module). */ export function loadEntityContents(baRoot: string): Map { const contents = new Map() if (!existsSync(baRoot)) return contents for (const app of listDirs(baRoot)) { const appDir = join(baRoot, app) for (const mod of listDirs(appDir)) { const entitePath = join(appDir, mod, 'entité.md') if (!existsSync(entitePath)) continue try { if (!statSync(entitePath).isFile()) continue contents.set(`${app}/${mod}`, readFileSync(entitePath, 'utf8')) } catch { // unreadable file — skip (validate-mode rules will surface the gap) } } } return contents } function listDirs(dir: string): string[] { let entries try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return [] } return entries .filter((e) => e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.')) .map((e) => e.name) .sort() }