/** * lib/ba-entities.ts — THE shared full `entité.md` parser. * * `lib/ba-relations.ts` reads entité.md as a RELATION GRAPH (headings, * attribute names, Relations lines) — enough for the 360 tabs and lookup * grants, but the rest of the entity block had no shared reader: the * `Calculé` column, `Préfixe table`, `Traçabilité`, `Index`, `Code pattern`, * `Personne`, `Affichage`, `Isolation`, `Valeurs initiales` and `Dérivé` * were re-parsed locally (derive-lifecycle, derive-detail-summary, * derive-code-specs) or read by no deterministic code at all — the DM-001..020 * audit rules ran entirely on LLM eyes. This module is their reader. * * Pure line-based parsing over one file's content. Relations are DELEGATED to * `ba-relations.parseEntities` (single source of truth for the Relations * grammar); the Code pattern line is DELEGATED to `code-pattern-grammar`. * * Fail-closed spirit: what does not parse is a NEAR-MISS warning, never * silence — an `### ENT-…` heading, an attribute row, an `**Index**` value, a * `**Relations**` entry (ba-relations owns BOTH the grammar and its loss check; * its warnings are forwarded here). * The discipline used to stop at the heading: every declaration BELOW it could * vanish without a word, and control-counts reconciles headings, not rows. * Unknown field bullets are kept in `fields` raw. */ import { existsSync, readFileSync, statSync } from 'node:fs' import { join } from 'node:path' import { parseEntities, type EntityRelation } from './ba-relations.js' import { splitBaList } from './ba-list-split.js' import { CODE_PATTERN_LINE_RE, codeLikeWordOf, parseCodePatternLine, parseCodeSaisiValue, parseDecidedCodeLine, type ParsedCodePatternLine, type ParsedDecidedCode, } from './code-pattern-grammar.js' // --------------------------------------------------------------------------- // Model // --------------------------------------------------------------------------- export interface BaEntityAttribute { name: string type: string constraints: string /** The `Calculé` column formula (backticks stripped) — null when `—`/empty. */ computed: string | null } export interface BaEntityIndex { /** Column names inside the parentheses, trimmed. */ fields: string[] unique: boolean /** The matched declaration verbatim, `unique` suffix included (diagnostics). */ raw: string } export interface BaInitialValues { /** The natural key named by `clé ` — null when not stated. */ key: string | null /** Header cells of the seeded-rows table (verbatim). */ columns: string[] /** Data rows (verbatim cells). */ rows: string[][] } export interface BaEntity { /** `ENT-001` */ code: string /** PascalCase entity name from the heading. */ name: string /** Heading parenthetical or `- **Classification**` bullet (verbatim). */ classification?: string /** `APP/MODULE` path the doc belongs to. */ module: string /** `- **Préfixe table**` value, backticks stripped — null when absent. */ tablePrefix: string | null /** `- **Traçabilité**` tokens (split on `,`/`;`, continuations folded). */ traceability: string[] attributes: BaEntityAttribute[] /** Relations written ON this entity (delegated to ba-relations). */ relations: EntityRelation[] indexes: BaEntityIndex[] /** `- **Code pattern**` RHS verbatim — null when absent. */ codePatternRaw: string | null /** Parsed facets (code-pattern-grammar) — null when absent. */ codePattern: ParsedCodePatternLine | null /** `- **Code décidé**` RHS verbatim — null when absent. */ decidedCodeRaw: string | null /** * The USER's dated decision that this reference table carries a code — null * when the bullet is absent OR unparsable. The pair is what carries the * signal: `decidedCodeRaw` set with `decidedCode` null IS the near-miss * DM-022 errs on (an undated « decision » is a habit, not a decision). */ decidedCode: ParsedDecidedCode | null /** `- **Personne**` value verbatim — null when absent. */ person: string | null /** `- **Affichage**` attribute (text before ` — `) — null when absent. */ display: string | null /** `- **Isolation**` value verbatim — null when absent. */ isolation: string | null /** * `- **Portée**` — the entity's tenancy, the CLOSED vocabulary of * `scaffold-entity`'s `tenantMode` (`strict | optional | none`). Inherited * from a document-level `- **Portée**` bullet (written BEFORE the first * `### ENT-` heading) when the entity carries none. Null when neither says * it — the scaffolder then falls back to `strict`, and it is THAT default * which decides the tenant-composite shape of every declared unique index. */ tenancy: BaTenancy | null /** The `**Portée**` RHS verbatim (entity, else document) — set with * `tenancy === null` IS the near-miss DM-028 errs on. */ tenancyRaw: string | null initialValues: BaInitialValues | null /** `- **Dérivé**` lines verbatim (grammar audited by DM-020, not here). */ derivedRaw: string[] /** Every folded field key → accumulated raw value (unknown fields kept). */ fields: Record } export type BaTenancy = 'strict' | 'optional' | 'none' /** `strict — données isolées par tenant` → 'strict'; anything else → null. */ export function parseTenancyValue(raw: string): BaTenancy | null { const head = raw.split(/[—\-–(:]/)[0]?.trim().toLowerCase() ?? '' return head === 'strict' || head === 'optional' || head === 'none' ? head : null } /** A document-level `- **Portée**` bullet — before the first entity heading. */ const DOC_TENANCY_RE = /^-\s*\*\*Port[ée]e\*\*\s*:\s*(.+?)\s*$/im export interface ParseEntityDocResult { entities: BaEntity[] warnings: string[] } // --------------------------------------------------------------------------- // Regexes // --------------------------------------------------------------------------- /** `### ENT-001 — Opportunity (agrégat racine)` — em-dash or ASCII dash. */ export const ENT_HEADING_RE = /^###\s+(ENT-\d+)\s*[—-]\s*([A-Za-z][A-Za-z0-9_]*)\s*(?:\(([^)]+)\))?\s*$/ /** A heading that LOOKS like an entity but fails ENT_HEADING_RE. */ export const ENT_NEAR_MISS_RE = /^###\s+ent-/i /** Generic top-level field bullet: `- **Label** : value`. */ const FIELD_RE = /^-\s*\*\*([^*]+)\*\*\s*:\s*(.*)$/ /** Continuation: indented sub-bullet or indented plain line. */ const SUB_BULLET_RE = /^\s+-\s+(.+?)\s*$/ const PLAIN_CONT_RE = /^\s{2,}(\S.*?)\s*$/ /** A markdown table line. The trailing pipe is OPTIONAL in GFM; requiring it * made a legal row fall into the « non-table line » branch, which resets * tableMode — and every attribute row after it was lost. */ const TABLE_LINE_RE = /^\s*\|(.*?)\|?\s*$/ /** Word-like attribute name: letters (accents included), digits, `_`, `-`. * Wider than the ASCII convention ON PURPOSE — a name outside the convention * is DM-007's subject, so it must reach DM-007 instead of being dropped here. * Only a cell no rule could safely regex (spaces, brackets, punctuation) is * refused, and then out loud. */ const ATTRIBUTE_NAME_RE = /^[\p{L}_][\p{L}\p{N}_-]*$/u // --------------------------------------------------------------------------- // Loss predicates — the ONE reading of the near-miss warnings this parser // emits. audit-ba's control counts count a LOUD loss as « seen » (a warned // row is not a swallowed row — the acs counter set that precedent), and // DM-007 lists refused rows in its evidence. Neither regexes free text. // --------------------------------------------------------------------------- const ATTRIBUTE_ROW_LOSS_RE = /— attribute row (?:« .*? » dropped|with an EMPTY name cell dropped)/ const INDEX_LOSS_RE = /— \*\*Index\*\* declares « / const RELATION_LOSS_RE = /— \*\*Relations\*\* carries (\d+) cardinality token\(s\) but only (\d+) entry/ /** True for a warning that refused one attribute row. */ export function isAttributeRowLoss(warning: string): boolean { return ATTRIBUTE_ROW_LOSS_RE.test(warning) } /** True for a warning that found an `**Index**` bullet with no readable group. */ export function isIndexLoss(warning: string): boolean { return INDEX_LOSS_RE.test(warning) } /** Number of `**Relations**` entries the grammar could not read (0 when the * warning is not a relations loss). */ export function relationLossOf(warning: string): number { const m = RELATION_LOSS_RE.exec(warning) return m ? Math.max(0, Number(m[1]) - Number(m[2])) : 0 } /** Index groups: `(TenantId, Status) unique`, `(Stage)`. */ const INDEX_GROUP_RE = /\(([^)]*)\)(\s*unique)?/gi const stripTicks = (v: string): string => v.replace(/^`|`$/g, '').trim() function foldKey(label: string): string { return label .normalize('NFD') .replace(/[̀-ͯ]/g, '') .replace(/’/g, "'") .trim() .toLowerCase() } function splitCells(line: string): string[] { const m = line.match(TABLE_LINE_RE) if (!m) return [] return m[1]!.split('|').map((c) => c.trim()) } function isSeparatorRow(cells: string[]): boolean { return cells.length > 0 && cells.every((c) => /^:?-{2,}:?$/.test(c) || c === '') } // --------------------------------------------------------------------------- // Parser // --------------------------------------------------------------------------- /** * Parse one entité.md content into fully-detailed entity blocks. * `modulePath` is the `APP/MODULE` the doc belongs to (traceability). */ export function parseEntityDoc(content: string, modulePath: string): ParseEntityDocResult { const warnings: string[] = [] // Document-level tenancy: a `- **Portée**` bullet BEFORE the first entity // heading applies to every entity that carries none — the backfill of an // existing corpus is then one line per module, not one per entity. const firstHeading = content.search(/^###\s+ENT-/m) const docTenancyRaw = DOC_TENANCY_RE.exec(firstHeading >= 0 ? content.slice(0, firstHeading) : content)?.[1]?.trim() ?? null // Relations: single source of truth — ba-relations' tested grammar, and its // LOSS warnings with it (the grammar's owner counts what it could not read). const graph = parseEntities(new Map([[modulePath, content]])) warnings.push(...graph.warnings) const relationsByCode = new Map(graph.entities.map((e) => [e.code, e.relations])) const entities: BaEntity[] = [] const lines = content.split(/\r?\n/) let current: BaEntity | null = null let currentField: string | null = null /** 'attributes' while inside the `| Attribut | … |` table, * 'initial-values' while inside the Valeurs initiales table. */ let tableMode: 'attributes' | 'initial-values' | null = null const finalizeField = (): void => { if (!current || currentField === null) return const raw = (current.fields[currentField] ?? '').trim() switch (currentField) { case 'prefixe table': current.tablePrefix = stripTicks(raw) || null break case 'tracabilite': // Top-level split — `BR-004 (numérotation, reset annuel)` is ONE code. current.traceability = splitBaList(raw) break case 'index': { current.indexes = [] for (const g of raw.matchAll(new RegExp(INDEX_GROUP_RE.source, INDEX_GROUP_RE.flags))) { const fields = g[1]! .split(',') .map((f) => f.trim()) .filter((f) => f !== '') if (fields.length > 0) { current.indexes.push({ fields, unique: g[2] !== undefined, raw: g[0] }) } } if (raw !== '' && current.indexes.length === 0) { warnings.push( `${modulePath}: ${current.code} — **Index** declares « ${raw} » but no (Field) / (A, B) unique group parses: ` + 'NO index is derived from it (DM-012 and the scaffolder both read this list) — rewrite it.', ) } break } case 'code pattern': { current.codePatternRaw = raw || null current.codePattern = raw ? parseCodePatternLine(raw) : null break } // A reference value does not carry a code; only the USER may decide one // does, and that dated decision OVERRIDES the rule (DM-022). Orthogonal // to the species markers above — it says WHY, not HOW. case 'code decide': case 'decided code': { current.decidedCodeRaw = raw || null current.decidedCode = raw ? parseDecidedCodeLine(raw) : null break } case 'affichage': current.display = raw ? (raw.split('—')[0] ?? raw).trim() || null : null break case 'personne': current.person = raw || null break case 'isolation': current.isolation = raw || null break case 'portee': { current.tenancyRaw = raw || null current.tenancy = raw ? parseTenancyValue(raw) : null break } case 'classification': if (raw) current.classification = raw.replace(/\.$/, '').trim() break case 'derive': if (raw) current.derivedRaw.push(raw) break case 'valeurs initiales': { const key = /cl[eé]\s+`?([A-Za-z0-9_]+)`?/i.exec(raw)?.[1] ?? null current.initialValues = current.initialValues ?? { key, columns: [], rows: [] } current.initialValues.key = current.initialValues.key ?? key break } default: break } currentField = null } const closeEntity = (): void => { finalizeField() tableMode = null current = null } for (const line of lines) { const heading = line.match(ENT_HEADING_RE) if (heading) { closeEntity() current = { code: heading[1]!, name: heading[2]!, ...(heading[3] !== undefined ? { classification: heading[3].trim() } : {}), module: modulePath, tablePrefix: null, traceability: [], attributes: [], relations: relationsByCode.get(heading[1]!) ?? [], indexes: [], codePatternRaw: null, codePattern: null, decidedCodeRaw: null, decidedCode: null, person: null, display: null, isolation: null, tenancy: docTenancyRaw !== null ? parseTenancyValue(docTenancyRaw) : null, tenancyRaw: docTenancyRaw, initialValues: null, derivedRaw: [], fields: {}, } entities.push(current) continue } if (/^#{1,3}\s/.test(line)) { // Any other heading ends the current entity block. if (ENT_NEAR_MISS_RE.test(line)) { warnings.push( `${modulePath}: heading "${line.trim()}" looks like an entity but does not parse ` + '(expected `### ENT-NNN — Name (classification)`) — its block is NOT read until fixed.' ) } closeEntity() continue } if (!current) continue // Tables — the attribute table (header `| Attribut | …`) and the // Valeurs initiales table (rows following that bullet). const cells = splitCells(line) if (cells.length > 0) { if (isSeparatorRow(cells)) continue const firstFolded = foldKey(cells[0] ?? '') if (firstFolded === 'attribut') { finalizeField() tableMode = 'attributes' continue } if (tableMode === 'attributes') { const name = cells[0] ?? '' // Never a silent `continue`. The former ASCII-identifier filter dropped // every accented/hyphenated name (`Libellé`, `date-fin`) BEFORE DM-007 — // the rule written to report exactly those — could see it: no column // generated, no rule fired, `ok` on the survivors. if (name === '') { warnings.push(`${modulePath}: ${current.code} — attribute row with an EMPTY name cell dropped (« ${line.trim()} »).`) continue } if (!ATTRIBUTE_NAME_RE.test(name)) { warnings.push( `${modulePath}: ${current.code} — attribute row « ${name} » dropped: not a word-like name (letters, digits, _ or -), ` + 'so no rule can read it and no column will be generated — rename it.', ) continue } const computedRaw = (cells[3] ?? '').trim() current.attributes.push({ name, type: (cells[1] ?? '').trim(), constraints: (cells[2] ?? '').trim(), computed: computedRaw === '' || computedRaw === '—' ? null : stripTicks(computedRaw), }) continue } if (currentField === 'valeurs initiales') { finalizeFieldKeepInitialValues(current) tableMode = 'initial-values' } if (tableMode === 'initial-values' && current.initialValues) { if (current.initialValues.columns.length === 0) current.initialValues.columns = cells else current.initialValues.rows.push(cells) continue } continue } // A non-table line (blank included) ends any table — markdown tables are // contiguous, and a stale 'attributes' mode would swallow the NEXT table // (e.g. Valeurs initiales) as attribute rows. tableMode = null // Field bullets (column 0). if (!/^\s/.test(line)) { const f = line.match(FIELD_RE) if (f) { finalizeField() const key = foldKey(f[1]!) currentField = key current.fields[key] = f[2]!.trim() continue } // Any other column-0 content ends the pending field. if (line.trim() !== '') finalizeField() continue } // Continuations of the current field (sub-bullet or plain fold). if (currentField !== null) { const sub = line.match(SUB_BULLET_RE) const plain = sub ? null : line.match(PLAIN_CONT_RE) if (sub || plain) { const addition = (sub ? sub[1]! : plain![1]!).trim() const prev = current.fields[currentField] ?? '' current.fields[currentField] = prev === '' ? addition : `${prev}${sub ? ' ; ' : ' '}${addition}` continue } } } closeEntity() // Blank-doc control: `### ENT-` seen by a loose scan but zero parsed blocks // would be caught by audit-ba's control counts; here we only surface the // per-heading near-misses (already pushed above). return { entities, warnings } } /** Ensure `initialValues` exists when its table starts before the field's * finalize ran (the bullet and its table are adjacent lines). */ function finalizeFieldKeepInitialValues(entity: BaEntity): void { if (!entity.initialValues) { const raw = (entity.fields['valeurs initiales'] ?? '').trim() const key = /cl[eé]\s+`?([A-Za-z0-9_]+)`?/i.exec(raw)?.[1] ?? null entity.initialValues = { key, columns: [], rows: [] } } } // --------------------------------------------------------------------------- // IO loader // --------------------------------------------------------------------------- /** Read `///entité.md` (NFC then NFD filename — both * normalizations exist on real disks) and parse it. Missing file → null. */ export function loadModuleEntities( baRoot: string, app: string, module: string, ): ParseEntityDocResult | null { const moduleDir = join(baRoot, app, module) for (const fileName of ['entité.md', 'entité.md'.normalize('NFD')]) { const p = join(moduleDir, fileName) try { if (existsSync(p) && statSync(p).isFile()) { return parseEntityDoc(readFileSync(p, 'utf8'), `${app}/${module}`) } } catch { /* unreadable — treated as absent; audit rules surface the gap */ } } return null } // --------------------------------------------------------------------------- // Code-like classification (DM-021 / derive-code-specs) // --------------------------------------------------------------------------- export interface CodeLikeAttribute { /** Entity name (heading PascalCase). */ entity: string /** Attribute name verbatim. */ attribute: string /** The lexicon word that matched (`reference`, `numero`, …). */ matchedWord: string } /** Attribute names this entity explicitly classifies as TYPED codes — the * `- **Code saisi** : …` bullet (EN alias `**Typed code**`), folded by the * generic field parser into `fields['code saisi']` / `fields['typed code']`. */ export function typedCodeAttributes(entity: BaEntity): string[] { const raw = [entity.fields['code saisi'], entity.fields['typed code']] .filter((v): v is string => typeof v === 'string' && v.trim() !== '') .join(', ') return raw === '' ? [] : parseCodeSaisiValue(raw) } /** * Code-like attributes (synonym lexicon: Référence/Numéro/Matricule…) shaped * like a business key — string + unique, not computed — that carry NO species * classification: neither named by a `**Code saisi**` bullet nor the `code` * attribute itself (the Code pattern channel). These are the "the BA never * decided" signals DM-021 asks to classify; a classified typed code is * legitimate and never listed. */ export function findCodeLikeAttributes(entities: BaEntity[]): CodeLikeAttribute[] { const out: CodeLikeAttribute[] = [] for (const entity of entities) { const typed = new Set(typedCodeAttributes(entity).map((a) => a.toLowerCase())) for (const attr of entity.attributes) { if (attr.computed !== null) continue if (attr.name.toLowerCase() === 'code') continue if (typed.has(attr.name.toLowerCase())) continue if (!/\bstring\b|\btexte?\b/i.test(attr.type)) continue if (!/\bunique\b/i.test(attr.constraints)) continue const matchedWord = codeLikeWordOf(attr.name) if (matchedWord === null) continue out.push({ entity: entity.name, attribute: attr.name, matchedWord }) } } return out } export { CODE_PATTERN_LINE_RE }