/** * lib/ba-referential-codes.ts — THE shared contract of one doctrine: * * A reference value does NOT carry a code. Its label is its identity and its * natural key. Only the USER may decide that one of these tables carries * one, and that decision — written in entité.md, DATED — OVERRIDES the rule: * no audit re-argues it, no backfill undoes it, no pass proposes it on its * own initiative. * * Until this module the opposite was PRESCRIBED: DM-015 asked every `lookup` * for `code:string,unique:true` and its mandatory `solution` re-asked for it, * while attributes.md offered « a typed referential code on a lookup » as the * sanctioned fallback. Three lines of skill produced 11 tables × 1 `Code` * column on a single module, and the code column then WON the display cascade * (`A_FAIRE` instead of « À faire ») on every combobox. * * READ-ONLY by construction — it never writes. Two consumers share it, and * that is why it lives in lib/ rather than beside the CLI: audit-ba may only * import cross-skill from an ENUMERATED allowlist (`src/lib/installer.ts`, * `(create-rbac|create-screen)`), so a DM-022 importing create-data-model * would ship pointing at a directory that does not exist. * * - `create-data-model/cli/derive-referential-codes` — the inventory + the * backfill (this module is its read half); * - `audit-ba` rules/dm.ts — DM-022 (the verdict) and DM-012 (which must * stay silent on an entity DM-022 already errs on, rather than propose * adding a unique index to the very code being removed). */ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' import { join } from 'node:path' import type { BaEntity } from './ba-entities.js' import type { BaRule } from './ba-rules-rows.js' import type { BaUseCase } from './ba-use-cases.js' import { parseDecidedCodeLine, type ParsedDecidedCode } from './code-pattern-grammar.js' // --------------------------------------------------------------------------- // Folding — must agree with audit-ba's registry.fold and ba-entities.foldKey // --------------------------------------------------------------------------- /** NFD-strip accents + lowercase + trim (length NOT preserved — comparisons * only, never offsets). */ export function fold(s: string): string { return s .normalize('NFD') .replace(/[̀-ͯ]/g, '') .replace(/’/g, "'") .toLowerCase() .trim() } /** * Accent fold that preserves UTF-16 length, so an index found in the folded * text still slices the ORIGINAL correctly (citations must be verbatim). * Astral code points have no single-unit base, so callers MUST compare * `folded.length === source.length` and fall back to the raw text when it * does not hold. */ export function foldPreservingLength(s: string): string { let out = '' for (const ch of s) { const d = ch.normalize('NFD') out += d.length > 0 ? d[0]! : ch } return out } // --------------------------------------------------------------------------- // Entity shape // --------------------------------------------------------------------------- /** Classification test — the ONE definition; `rules/dm.ts` delegates here so * DM-012, DM-015 and DM-022 can never drift apart on what a lookup is. */ export function isLookupClassification(classification?: string): boolean { return fold(classification ?? '').includes('lookup') } export function isReferenceEntity(e: BaEntity): boolean { return isLookupClassification(e.classification) } /** Attribute names that NAME a reference row — the seed key and the display * anchor both fall here once the code is gone. Ordered: the first hit wins. */ export const LABEL_ATTRIBUTE_FAMILY = ['label', 'name', 'libelle', 'nom', 'titre', 'title'] as const export function codeAttributeOf(e: BaEntity): string | null { return e.attributes.find((a) => fold(a.name) === 'code')?.name ?? null } export function labelAttributeOf(e: BaEntity): string | null { for (const want of LABEL_ATTRIBUTE_FAMILY) { const hit = e.attributes.find((a) => fold(a.name) === want) if (hit) return hit.name } return null } /** The user's dated decision, or null (absent OR unparsable). */ export function decisionOf(e: BaEntity): ParsedDecidedCode | null { if (e.decidedCode !== null) return e.decidedCode return e.decidedCodeRaw !== null ? parseDecidedCodeLine(e.decidedCodeRaw) : null } /** A `**Code décidé**` line was WRITTEN but does not parse — the near-miss. */ export function decisionIsNearMiss(e: BaEntity): boolean { return e.decidedCodeRaw !== null && decisionOf(e) === null } /** `code` caught inside a MULTI-column index — `(TenantId, Code) unique`. * Not mechanically removable: a mutilated index is worse than the code. */ export function codeInCompositeIndex(e: BaEntity): boolean { return e.indexes.some((i) => i.fields.length > 1 && i.fields.some((f) => fold(f) === 'code')) } /** * The natural key a seed upserts on once the code is gone: the normalized * label. `scaffold-seed` emits an EXACT `AnyAsync(x => x.{key} == "")` * — there is no normalization at runtime, so this is the AUTHORING discipline: * the literal declared in `**Valeurs initiales**` IS the canonical form. * * The counterpart, said plainly: a key on the label BREAKS if someone renames * the row — the seed then re-creates it instead of finding it. That is the * price of dropping the code, and the user must have it in front of them when * deciding, not six months later. */ export function normalizeLabelKey(s: string): string { return fold(s.replace(/\s+/g, ' ')) } // --------------------------------------------------------------------------- // Citations — what breaks if the code goes // --------------------------------------------------------------------------- export type CitationSourceKind = 'rules' | 'acceptance-criteria' | 'prd' | 'initial-values' export interface CitationSource { kind: CitationSourceKind /** Where exactly — a BR code, a UC code, a file name, a sibling entity. */ where: string text: string } export interface Citation { /** The seeded code VALUE that is cited. */ value: string kind: CitationSourceKind where: string /** Verbatim excerpt around the hit. */ excerpt: string } /** The `code` column values of the entity's `**Valeurs initiales**` table. */ export function seededCodeValues(e: BaEntity): string[] { return [...new Set(seededColumnValues(e, 'code'))] } /** The values of one seeded column, by attribute name (empty when absent). */ export function seededColumnValues(e: BaEntity, attribute: string): string[] { const iv = e.initialValues if (!iv) return [] const col = iv.columns.findIndex((c) => fold(c) === fold(attribute)) if (col === -1) return [] return iv.rows.map((r) => (r[col] ?? '').trim()).filter((v) => v !== '' && v !== '—') } /** * The label values that would BECOME the seed's natural key, and whether they * survive it. `scaffold-seed` errs on a duplicate natural key ("duplicate rows * would be silently skipped"), so moving the key onto a label that repeats — * « Actif » and « actif », or the same wording twice — would hand the next * generation a spec it refuses. The backfill must not create that. */ export function labelKeyCollisions(e: BaEntity, labelAttribute: string): string[] { const seen = new Map() for (const v of seededColumnValues(e, labelAttribute)) { const k = normalizeLabelKey(v) seen.set(k, (seen.get(k) ?? 0) + 1) } return [...seen.entries()].filter(([, n]) => n > 1).map(([k]) => k) } /** * How many code values the inventory can actually SEARCH for this entity. * * This is the number that makes an empty citation list mean something. Zero * means « nothing was searchable » — NOT « nothing depends on this code ». The * report must never let the second read as the first, and the backfill must * never strip a code on that basis: a reference table can carry a code and no * `**Valeurs initiales**` at all (the BA left its rows in prose), and then the * BA simply cannot tell what the contract uses. */ export function searchableCodeCount(e: BaEntity): number { return seededCodeValues(e).length } const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') function excerptAround(text: string, index: number, length: number): string { const from = Math.max(0, index - 60) const to = Math.min(text.length, index + length + 60) const head = from > 0 ? '…' : '' const tail = to < text.length ? '…' : '' return `${head}${text.slice(from, to).replace(/\s+/g, ' ').trim()}${tail}` } /** * Every place a seeded code VALUE is cited, across the injected sources. * * Only CODE values are searched, never labels: a label citation does not bind * the contract, a code citation does. Whole-word, case- and accent-folded. */ export function citationsFor(e: BaEntity, sources: readonly CitationSource[]): Citation[] { const values = seededCodeValues(e) if (values.length === 0) return [] const out: Citation[] = [] for (const source of sources) { const folded = foldPreservingLength(source.text) // Offsets must map 1:1 onto the original for the excerpt to be verbatim. const aligned = folded.length === source.text.length const haystack = aligned ? folded : source.text for (const value of values) { const needle = aligned ? foldPreservingLength(value) : value const re = new RegExp(`(? ({ kind: 'rules' as const, where: r.code, text: [r.title, r.condition, r.expression, r.validCases, r.invalidCases, r.errorCode] .filter((v): v is string => typeof v === 'string' && v !== '') .join(' — '), })) } /** Source 2 — every acceptance criterion of every use case. */ export function acceptanceCitationSources(ucs: readonly BaUseCase[]): CitationSource[] { const out: CitationSource[] = [] for (const uc of ucs) { for (const ac of uc.acs) { out.push({ kind: 'acceptance-criteria', where: `${uc.ucCode}#${ac.localId}`, text: ac.text }) } } return out } /** * Source 3 — the PRD slices and the pagespecs. THE one nothing else covers: * on a real module the retirement and suspension reasons were cited by no rule * and no acceptance criterion, yet the API designated them by `ReasonCode`. An * inventory blind to the PRD under-declares, and the backfill then drops a code * the contract depends on. * * Absence is NEVER a failure: /ba-audit-data-model legitimately runs before * /ba-create-prd, so `files: 0` means « partial coverage », reported as such. */ export function prdCitationSources(moduleDir: string): { sources: CitationSource[]; files: number } { const sources: CitationSource[] = [] const readOrNull = (p: string): string | null => { try { return existsSync(p) && statSync(p).isFile() ? readFileSync(p, 'utf8') : null } catch { return null } } let names: string[] = [] try { names = readdirSync(moduleDir) .filter((n) => /^prd.*.md$/i.test(n)) .sort() } catch { names = [] } for (const n of names) { const md = readOrNull(join(moduleDir, n)) if (md !== null) sources.push({ kind: 'prd', where: n, text: md }) } const pagespecs = join(moduleDir, 'pagespecs') try { for (const n of readdirSync(pagespecs) .filter((f) => f.endsWith('.md')) .sort()) { const md = readOrNull(join(pagespecs, n)) if (md !== null) sources.push({ kind: 'prd', where: `pagespecs/${n}`, text: md }) } } catch { /* no pagespecs — reported through coverage, never a failure */ } return { sources, files: sources.length } } /** `**Valeurs initiales**` of the OTHER entities as citation sources — a table * that references another by its key. The entity never cites itself. */ export function initialValuesSources( entities: readonly BaEntity[], selfName: string, ): CitationSource[] { const out: CitationSource[] = [] for (const e of entities) { if (e.name === selfName) continue const iv = e.initialValues if (!iv || iv.rows.length === 0) continue out.push({ kind: 'initial-values', where: `${e.name} (**Valeurs initiales**)`, text: iv.rows.map((r) => r.join(' ')).join('\n'), }) } return out } // --------------------------------------------------------------------------- // The verdict — the ONE definition DM-022, DM-012 and the backfill share // --------------------------------------------------------------------------- export type ReferentialCodeStatus = /** Not a reference table — out of scope. */ | 'not-reference' /** A reference table with no code — the doctrine's normal state. */ | 'clean' /** The user decided, and said so with a date. Untouchable. */ | 'decided' /** A decision was MENTIONED but does not parse — err, never a silence. */ | 'near-miss' /** A `**Code pattern**` on a reference table: an ALLOCATED code — err. */ | 'allocated' /** A code with no decision, but not mechanically removable. */ | 'blocked' /** A code with no decision and nothing depending on it — removable. */ | 'reprise' export type BlockedReason = /** ≥1 seeded code value is cited somewhere — removing it breaks a contract. */ | 'cited' /** NO code value to search: the inventory is not conclusive, so the absence * of citations proves nothing. The user decides, the backfill does not. */ | 'no-seeded-codes' /** `code` sits inside a composite index. */ | 'composite-index' /** `**Affichage** : Code` and no label attribute to move it to. */ | 'display-anchored-on-code' /** No label-family attribute: dropping `code` leaves the row unnamed. */ | 'no-label-attribute' /** The label the key would move onto is not unique across the seeded rows — * `scaffold-seed` refuses a duplicate natural key. */ | 'ambiguous-label-key' export interface ReferentialCodeVerdict { entity: string /** `ENT-NNN`. */ entCode: string status: ReferentialCodeStatus blockedBy?: BlockedReason /** One sentence: what it is, and what the human does about it. */ detail: string codeAttribute: string | null labelAttribute: string | null decision: ParsedDecidedCode | null /** `- **Valeurs initiales** : clé ` — null when not stated. */ initialValuesKey: string | null seededRows: number /** Code values the inventory could search — 0 means it concluded NOTHING. */ searchableCodes: number citations: Citation[] } /** * Classify one entity. * * ORDER MATTERS, and one ordering choice is deliberate: `allocated` is tested * BEFORE `decided`. A `**Code décidé**` authorises a code the user TYPES on a * reference table; it never authorises an ALLOCATED one — « Lookups, junctions * and audit logs get no code pattern » (attributes.md) stays true and * unchanged. So the decision overrides the question « may this table have a * code at all? », not the question « may the socle allocate it? ». Without * this order, writing a decision line would silence a real defect. */ export function classifyReferentialCode( e: BaEntity, citations: readonly Citation[], ): ReferentialCodeVerdict { const base = { entity: e.name, entCode: e.code, codeAttribute: codeAttributeOf(e), labelAttribute: labelAttributeOf(e), decision: decisionOf(e), initialValuesKey: e.initialValues?.key ?? null, seededRows: e.initialValues?.rows.length ?? 0, searchableCodes: searchableCodeCount(e), citations: [...citations], } if (!isReferenceEntity(e)) { return { ...base, status: 'not-reference', detail: 'Pas une table de référence — hors portée.' } } if (decisionIsNearMiss(e)) { return { ...base, status: 'near-miss', detail: 'Une ligne MENTIONNE la décision utilisateur sans être analysable (date absente ou invalide) — ' + 'une « décision » sans date est une habitude, pas une décision.', } } if (e.codePatternRaw !== null) { return { ...base, status: 'allocated', detail: 'Code ALLOUÉ (`**Code pattern**`) sur une table de référence — le socle numérote un référentiel. ' + 'Retirer la ligne Code pattern ; un code décidé est SAISI, jamais alloué.', } } if (base.decision !== null) { return { ...base, status: 'decided', detail: `Code voulu par l'utilisateur (décision du ${base.decision.date}) — aucun audit ne le discute.`, } } if (base.codeAttribute === null) { return { ...base, status: 'clean', detail: 'Aucun code — le libellé nomme la ligne.' } } // A code, and no decision authorising it. if (citations.length > 0) { const where = [...new Set(citations.map((c) => c.where))].join(', ') return { ...base, status: 'blocked', blockedBy: 'cited', detail: `${citations.length} citation(s) de ses codes (${where}) — retirer le code casserait ce contrat.`, } } if (base.searchableCodes === 0) { return { ...base, status: 'blocked', blockedBy: 'no-seeded-codes', detail: "Aucune valeur de code à chercher (pas de colonne `Code` dans `**Valeurs initiales**`, ou pas de " + "`**Valeurs initiales**` du tout) — l'inventaire n'a RIEN pu conclure : l'absence de citation ne prouve " + 'donc rien ici. Déclarer les valeurs initiales, ou trancher à la main.', } } if (codeInCompositeIndex(e)) { return { ...base, status: 'blocked', blockedBy: 'composite-index', detail: '`code` est pris dans un index COMPOSITE — un index mutilé est pire que le code. ' + "Reprendre l'index à la main d'abord.", } } if (base.labelAttribute === null) { const anchored = fold(e.display ?? '') === 'code' return { ...base, status: 'blocked', blockedBy: anchored ? 'display-anchored-on-code' : 'no-label-attribute', detail: anchored ? "`**Affichage** : Code` et aucun attribut de la famille libellé vers quoi basculer — l'entité resterait sans rien pour la nommer." : 'Aucun attribut de la famille libellé (label|name|libellé|nom|titre|title) — retirer `code` laisserait la ligne sans identifiant (DM-015 la flaguerait aussitôt).', } } const collisions = labelKeyCollisions(e, base.labelAttribute) if (collisions.length > 0) { return { ...base, status: 'blocked', blockedBy: 'ambiguous-label-key', detail: `Le libellé \`${base.labelAttribute}\` ne distingue pas les lignes semées (${collisions.join(', ')}) — ` + "y basculer la clé du semis donnerait une clé naturelle en double, que scaffold-seed REFUSE. " + 'Rendre les libellés distincts, ou trancher à la main.', } } return { ...base, status: 'reprise', detail: `Code sans décision : ${base.searchableCodes} valeur(s) de code cherchée(s) dans les 4 sources, aucune citée — le libellé \`${base.labelAttribute}\` prend la relève (identité, clé de semis, affichage).`, } } /** DM-022 errs on this entity — the predicate DM-012 reads to stay silent * rather than propose a unique index on the very code being removed. */ export function isReferentialCodeError(status: ReferentialCodeStatus): boolean { return ( status === 'near-miss' || status === 'allocated' || status === 'blocked' || status === 'reprise' ) }