/** * lib/ba-test-data.ts — THE shared parser of `jeu-de-test.md`, the module-level * business TEST DATASET (Specification by Example). * * Two seed tiers exist in a generated app: the SETUP rows an application needs * to start in EVERY environment (`**Valeurs initiales**` on a reference entity * in entité.md → `{Module}ReferenceDataSeedDataProvider`), and the TEST * DATASET — 5-8 realistic, FICTITIOUS rows per business entity, validated by the * client, that lets the deployed application be exercised in dev/test/qual * (`SmartStack:EnableDevSeeding`) and lets a BA simulator render the client's * OWN vehicles, sites and statuses instead of hash noise. This file is that * dataset's grammar, read by: * - create-test-data/cli/derive-test-data (check = coherence against the MCD * and the rules; derive = the normalized JSON scaffold-seed consumes), * - audit-ba DM-029..032 (through lib/ only — the enumerated import allowlist), * - any external reader of the BA tree (same markdown table as Valeurs initiales). * * Grammar (business-analyse/_workflow/doc-templates.md): * * - **Date de référence** : AAAA-MM-JJ (doc-level, before the first block) * ### JT-NNN — (ENT-NNN) (STRICT: nothing after the parenthesis — * the CLI's and the Studio's heading regexes * both refuse a suffix there) * - **Clé** : `` (MANDATORY: upsert key, FK resolution key) * | | | … | Note | (columns = PascalCase attributes; `Note` * |--------|--------|---|------| is RESERVED and ignored by every reader) * | … | (5-8 rows; FK cell = key or display value * of a target row; enum verbatim; absolute dates) * * Tolerant (warnings, never throws) AND fail-closed on loss: a row the parser * refuses (cell count ≠ header, empty row, second table in a block, near-miss * heading) is SAID, and `isTestDataRowLoss` lets audit-ba's control counts * count it as seen — a malformed table is never « 0 rows » in silence. */ import { existsSync, readFileSync, statSync } from 'node:fs' import { join } from 'node:path' export const TEST_DATA_FILE = 'jeu-de-test.md' export const TEST_DATA_MARKER_RE = // /** Columns every reader ignores — free text for the author (BR/UC citations…). */ export const TEST_DATA_RESERVED_COLUMNS: readonly string[] = ['Note'] export interface BaTestDataSet { /** `JT-001` */ code: string /** Entity name as written in the heading (PascalCase expected). */ entity: string /** `ENT-001` when the heading carries it — null (warned) otherwise. */ entityCode: string | null /** The `- **Clé**` attribute — null (warned) when absent. */ keyField: string | null /** Header cells, verbatim (reserved columns included). */ columns: string[] /** Data rows, verbatim cells, same length as `columns`. */ rows: string[][] /** Every other `- **Label** : value` bullet of the block, folded key → raw value. */ fields: Record } export interface BaTestDataDoc { /** `level=` attribute of the marker — null without marker (warned). */ level: string | null /** `code=` attribute of the marker. */ code: string | null /** `- **Date de référence** : AAAA-MM-JJ` — null (warned) when absent or unreadable. */ referenceDate: string | null sets: BaTestDataSet[] /** What was not understood — never guessed, always said. */ warnings: string[] /** Loose count of `### JT-` headings seen — control against `sets.length`. */ headingsSeen: number /** Data rows REFUSED out loud (short/empty row, second table, rows of a dropped block) — `rows + rowsLost` reconciles with `countTestDataRows`. */ rowsLost: number } export interface LoadTestDataResult { exists: boolean path: string | null doc: BaTestDataDoc | null } // --------------------------------------------------------------------------- // Regexes // --------------------------------------------------------------------------- /** `### JT-001 — Client (ENT-001)` — em-dash or ASCII dash; the ENT code is optional * in the regex so its absence can be WARNED instead of dropping the block. */ const JT_HEADING_RE = /^###\s+(JT-\d+)\s*[—-]\s*([A-Za-z][A-Za-z0-9_]*)\s*(?:\((ENT-\d+)\))?\s*$/ /** A heading that LOOKS like a test-data block but fails JT_HEADING_RE. */ const JT_NEAR_MISS_RE = /^###\s+jt-/i const FIELD_RE = /^-\s*\*\*([^*]+)\*\*\s*:\s*(.*)$/ const TABLE_LINE_RE = /^\s*\|(.*?)\|?\s*$/ const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/ const ROW_LOSS_RE = /— (?:row dropped|table ignored|block dropped)/ /** True for a warning that refused rows (audit-ba counts a LOUD loss as seen). */ export function isTestDataRowLoss(warning: string): boolean { return ROW_LOSS_RE.test(warning) } const stripTicks = (v: string): string => v.replace(/^`|`$/g, '').trim() export function foldTestDataKey(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()) } /** `|---|---|` — at least one dash cell: an ALL-EMPTY row is a dropped row, not a separator. */ function isSeparatorRow(cells: string[]): boolean { return cells.some((c) => /^:?-{2,}:?$/.test(c)) && cells.every((c) => /^:?-{2,}:?$/.test(c) || c === '') } /** * Control count of the DATA rows a `jeu-de-test.md` carries — every table line * under a `### JT-` heading (near-miss headings included) that is neither a * separator nor the first line of a table. Mirrors the parser's own walk so * that `sets rows + rowsLost` reconciles: a loud loss counts as seen. */ export function countTestDataRows(raw: string): number { let inBlock = false let expectHeader = true let n = 0 for (const line of raw.split(/\r?\n/)) { if (/^###\s+JT-/i.test(line)) { inBlock = true; expectHeader = true; continue } if (/^#{1,3}\s/.test(line)) { inBlock = false; continue } if (!inBlock) continue const cells = splitCells(line) if (cells.length === 0) { expectHeader = true; continue } if (isSeparatorRow(cells)) continue if (expectHeader) { expectHeader = false; continue } n += 1 } return n } /** Column indexes that carry DATA (reserved columns excluded). */ export function dataColumnIndexes(set: BaTestDataSet): number[] { return set.columns .map((c, i) => (TEST_DATA_RESERVED_COLUMNS.some((r) => foldTestDataKey(r) === foldTestDataKey(c)) ? -1 : i)) .filter((i) => i >= 0) } /** The set describing `entity` (case-insensitive on the name or the ENT code). */ export function findTestDataSet(doc: BaTestDataDoc, entity: string): BaTestDataSet | undefined { const wanted = entity.toLowerCase() return doc.sets.find((s) => s.entity.toLowerCase() === wanted || s.entityCode?.toLowerCase() === wanted) } // --------------------------------------------------------------------------- // Parser // --------------------------------------------------------------------------- export function parseTestDataDoc(content: string, modulePath: string): BaTestDataDoc { const warnings: string[] = [] const sets: BaTestDataSet[] = [] const marker = TEST_DATA_MARKER_RE.exec(content) const attrs: Record = {} if (marker === null) { warnings.push(`${modulePath}: ${TEST_DATA_FILE} carries no marker.`) } else { for (const m of marker[1]!.matchAll(/(\w[\w-]*)=([^\s]+)/g)) attrs[m[1]!] = m[2]! } const lines = content.split(/\r?\n/) let referenceDate: string | null = null let headingsSeen = 0 let current: BaTestDataSet | null = null /** 'open' while inside the block's table; 'closed' once a table ended (a * second table is then refused out loud). */ let tableState: 'none' | 'open' | 'closed' = 'none' let rowsLost = 0 /** Rows met under a NEAR-MISS heading (block dropped) — counted, then said once. */ let droppedBlock: { heading: string; rows: number; expectHeader: boolean } | null = null const sayDroppedBlock = (): void => { if (droppedBlock === null) return warnings.push( `${modulePath}: heading "${droppedBlock.heading}" looks like a test-data block but does not parse — block dropped (${droppedBlock.rows} row(s) with it) ` + '(expected `### JT-NNN — Entity (ENT-NNN)`, nothing after the parenthesis).', ) rowsLost += droppedBlock.rows droppedBlock = null } let secondTableHeaderSeen = false const closeSet = (): void => { secondTableHeaderSeen = false if (current !== null) { if (current.keyField === null) { warnings.push(`${modulePath}: ${current.code} — no \`- **Clé** : \`\`\` bullet: the rows cannot be upserted nor cited (add it).`) } if (current.columns.length === 0) { warnings.push(`${modulePath}: ${current.code} — block dropped: no markdown table follows the heading.`) } else if (current.rows.length === 0) { warnings.push(`${modulePath}: ${current.code} — table has a header and no data row.`) } } current = null tableState = 'none' } for (const line of lines) { const heading = line.match(JT_HEADING_RE) if (heading) { closeSet() sayDroppedBlock() headingsSeen += 1 current = { code: heading[1]!, entity: heading[2]!, entityCode: heading[3] ?? null, keyField: null, columns: [], rows: [], fields: {}, } if (current.entityCode === null) { warnings.push(`${modulePath}: ${current.code} — heading carries no (ENT-NNN): the entity is matched by NAME only.`) } sets.push(current) continue } if (/^#{1,3}\s/.test(line)) { closeSet() sayDroppedBlock() if (JT_NEAR_MISS_RE.test(line)) { headingsSeen += 1 droppedBlock = { heading: line.trim(), rows: 0, expectHeader: true } } continue } const cells = splitCells(line) if (cells.length > 0) { if (current === null) { // Under a dropped (near-miss) block its rows are COUNTED as lost — said once at the block's end. if (droppedBlock !== null && !isSeparatorRow(cells)) { if (droppedBlock.expectHeader) droppedBlock.expectHeader = false else droppedBlock.rows += 1 } continue // a table outside any block is not ours } if (isSeparatorRow(cells)) continue if (tableState === 'closed') { if (current.columns.length > 0 && tableState === 'closed' && !secondTableHeaderSeen) { // The first line of a second table is its header — not a lost row. secondTableHeaderSeen = true warnings.push(`${modulePath}: ${current.code} — table ignored: a second table in one block (« ${line.trim()} ») — one block, one table.`) continue } rowsLost += 1 continue } tableState = 'open' if (current.columns.length === 0) { current.columns = cells continue } if (cells.every((c) => c === '')) { warnings.push(`${modulePath}: ${current.code} — row dropped: every cell is empty (« ${line.trim()} »).`) rowsLost += 1 continue } if (cells.length !== current.columns.length) { warnings.push( `${modulePath}: ${current.code} — row dropped: ${cells.length} cell(s) for ${current.columns.length} column(s) (« ${line.trim()} »).`, ) rowsLost += 1 continue } current.rows.push(cells) continue } // Any non-table line ends an open table (markdown tables are contiguous). if (tableState === 'open') tableState = 'closed' if (droppedBlock !== null) droppedBlock.expectHeader = true secondTableHeaderSeen = false if (!/^\s/.test(line)) { const f = line.match(FIELD_RE) if (f) { const key = foldTestDataKey(f[1]!) const value = f[2]!.trim() if (current === null) { if (key === 'date de reference') { const d = stripTicks(value) if (DATE_RE.test(d)) referenceDate = d else warnings.push(`${modulePath}: **Date de référence** « ${value} » is not AAAA-MM-JJ — ignored.`) } continue } if (key === 'cle') { const k = stripTicks(value) if (/^[A-Za-z][A-Za-z0-9_]*$/.test(k)) current.keyField = k else warnings.push(`${modulePath}: ${current.code} — **Clé** « ${value} » is not an attribute name — ignored.`) continue } current.fields[key] = value continue } } } closeSet() sayDroppedBlock() if (referenceDate === null) { warnings.push(`${modulePath}: no \`- **Date de référence** : AAAA-MM-JJ\` bullet — relative dates cannot be anchored.`) } return { level: attrs['level'] ?? null, code: attrs['code'] ?? null, referenceDate, sets, warnings, headingsSeen, rowsLost, } } // --------------------------------------------------------------------------- // IO loader // --------------------------------------------------------------------------- /** `///jeu-de-test.md` — absent file is a RESULT (`exists: false`), never an error. */ export function loadModuleTestData(baRoot: string, app: string, module: string): LoadTestDataResult { const p = join(baRoot, app, module, TEST_DATA_FILE) try { if (existsSync(p) && statSync(p).isFile()) { return { exists: true, path: p, doc: parseTestDataDoc(readFileSync(p, 'utf8'), `${app}/${module}`) } } } catch { /* unreadable — treated as absent; the audit surfaces the gap */ } return { exists: false, path: null, doc: null } }