/** * lib/code-pattern-grammar.ts — the socle's CLOSED code-pattern grammar + the * `**Code pattern**` line parser (promoted from scaffold-coded-entity, where * only that CLI's validate could reject an invalid mask). * * Faithful mirror of the socle's grammar, so an invalid format is rejected at * scaffold/audit time (with a fixable message) instead of at the first INSERT * in the running app — where the engine raises `InvalidCodePatternException` * on every create and the feature ships broken. * * The historical hole: the BA templates authored `OPP-{YY}-{NNNN}` while the * engine only knows `{SEQ:n}`, and the old Zod guard (`/\{[A-Za-z]+(:\d+)?\}/`) * happily accepted `{NNNN}` because `N` is a letter. * * Consumers: scaffold-coded-entity (validateFormat gate), scaffold-entity * (referencedFields → GetCodeInputs emission), audit-dev-api DEV-API-022 * (descriptor ↔ entité.md coherence via parseCodePatternLine), * ba-develop/derive-code-specs (deterministic spec derivation). * * Mirrors (edit HERE when those change): * SmartStack.app/src/SmartStack.Application/Common/CodeGeneration/CodePatternGrammar.cs * SmartStack.app/src/SmartStack.Application/Common/CodeGeneration/CodePatternValidator.cs */ import { splitWords } from './capability-catalog.js'; /** Engine tokens — substituted by the sequence engine at allocation time. */ export const ENGINE_TOKENS = ['YYYY', 'YY', 'MM', 'DD', 'TENANT', 'SEQ'] as const; /** Derived tokens — pre-substituted from the caller's field inputs. Each REQUIRES a field name. */ export const DERIVED_TOKENS = ['FIELD', 'UPPER', 'LOWER', 'SLUG', 'INITIALS', 'ABBR'] as const; const TOKEN_SOURCE = String.raw`\{(?YYYY|YY|MM|DD|TENANT|SEQ|FIELD|UPPER|LOWER|SLUG|INITIALS|ABBR)` + String.raw`(?::(?[A-Za-z0-9_]+))?(?::(?\d+))?\}`; const tokenRe = (): RegExp => new RegExp(TOKEN_SOURCE, 'gi'); const exactTokenRe = (): RegExp => new RegExp(`^${TOKEN_SOURCE}$`, 'i'); const anyBraceRe = (): RegExp => /\{[^{}]*\}/g; const DERIVED = new Set(DERIVED_TOKENS); /** Uppercased names of every RECOGNISED token in the format (duplicates collapsed). */ export function tokenNames(format: string): string[] { const names = new Set(); for (const m of format.matchAll(tokenRe())) names.add((m.groups?.name ?? '').toUpperCase()); return [...names]; } /** True when the format allocates a sequence (contains `{SEQ}` / `{SEQ:n}`). */ export function hasSequence(format: string): boolean { return tokenNames(format).includes('SEQ'); } /** Brace groups that are NOT recognised tokens — typos such as `{NNNN}`, `{YEAR}`, `{SEQUENCE}`. */ export function unknownTokens(format: string): string[] { const exact = exactTokenRe(); return [...format.matchAll(anyBraceRe())].map(m => m[0]).filter(g => !exact.test(g)); } /** Distinct field names the derived tokens reference (e.g. `{ABBR:ClientName:3}` → `ClientName`). */ export function referencedFields(format: string): string[] { const fields: string[] = []; const seen = new Set(); for (const m of format.matchAll(tokenRe())) { const name = (m.groups?.name ?? '').toUpperCase(); if (!DERIVED.has(name)) continue; const field = m.groups?.arg1; if (field && !seen.has(field.toLowerCase())) { seen.add(field.toLowerCase()); fields.push(field); } } return fields; } /** * Every reason the socle would reject this pattern, in the engine's own terms. * Empty array = the engine accepts it. `reset` mirrors `CodeResetPeriod` * (the C# validator takes a scope too but ignores it — scope is enum-checked * by the Zod schema instead). */ export function validateFormat(format: string, reset: 'None' | 'Yearly' | 'Monthly' | 'Daily'): string[] { const errors: string[] = []; if (!format || !format.trim()) return ['Format is required.']; const unknown = unknownTokens(format); if (unknown.length > 0) { errors.push( `Unrecognised token(s): ${unknown.join(', ')} — the grammar is closed: ` + `{YYYY} {YY} {MM} {DD} {TENANT} {SEQ:n} and the field-derived ` + `{FIELD|UPPER|LOWER|SLUG|INITIALS:Field} / {ABBR:Field:n}. ` + `A sequence counter is {SEQ:n} (e.g. "{NNNN}" → "{SEQ:4}").`, ); } // A derived token must name a field: {SLUG:Name}, never a bare {SLUG}. for (const m of format.matchAll(tokenRe())) { const name = (m.groups?.name ?? '').toUpperCase(); if (DERIVED.has(name) && !m.groups?.arg1) { errors.push(`Token {${name}} requires a field name, e.g. {${name}:Name}.`); } } if (!hasSequence(format)) { if (referencedFields(format).length === 0) { errors.push( `Format "${format}" has no {SEQ} token and derives from no field — every generated code ` + `would be identical. Add {SEQ:n}, or derive from a field (e.g. {SLUG:Name}).`, ); } return errors; // reset granularity is only meaningful for sequence-bearing patterns } const names = tokenNames(format); const hasYear = names.includes('YY') || names.includes('YYYY'); const hasMonth = names.includes('MM'); const hasDay = names.includes('DD'); if (reset === 'Yearly' && !hasYear) { errors.push(`Reset=Yearly requires a {YY} or {YYYY} token in "${format}", otherwise codes repeat each year.`); } else if (reset === 'Monthly' && !(hasYear && hasMonth)) { errors.push(`Reset=Monthly requires a year token and {MM} in "${format}", otherwise codes repeat across months.`); } else if (reset === 'Daily' && !(hasYear && hasMonth && hasDay)) { errors.push(`Reset=Daily requires {YY}/{YYYY}, {MM} and {DD} in "${format}", otherwise codes repeat across days.`); } return errors; } // ─── The `**Code pattern**` line of entité.md — shared regexes + parser ──── // // The BA line is the SINGLE source of authority for a coded entity's spec // (`- **Code pattern** : \`OPP-{YY}-{SEQ:4}\` — scope tenant, reset annuel, // gapless (voir BR-002)`). Everything downstream — both scaffolder halves, // the DEV-API-022 coherence legs, derive-code-specs — parses it through // THESE, never through a private re-encoding. /** `### ENT-… — {Entity}` heading of an entité.md block. */ export const ENT_BLOCK_RE = /^###\s+ENT-[A-Za-z0-9-]+\s+—\s+([A-Za-z][A-Za-z0-9]*)/; /** The `- **Code pattern** : …` line (apply to a TRIMMED line; group 1 = the RHS). */ export const CODE_PATTERN_LINE_RE = /^-\s+\*\*Code pattern\*\*\s*:\s*(.+)$/; export interface ParsedCodePatternLine { /** The backticked format mask, or null when the line carries none. */ format: string | null; /** null = facet not authored (free prose) — callers must NOT default-compare it. */ scope: 'Tenant' | 'Global' | null; reset: 'None' | 'Yearly' | 'Monthly' | 'Daily' | null; gapless: boolean | null; /** Business label of the code (`libellé « Référence »` / `label "Reference"`) — * what the UI calls the Code column/field. null = facet not authored. */ label: { fr: string | null; en: string | null } | null; /** `surchargeable à la création` / `supplied on create` — an optional * user/import-supplied code is sanctioned on the CREATE surface * (ISuppliedCodeGuard + ApplyCode before save; updates never touch Code). * null = facet not authored (downstream behaves as false, audits skip). */ supplied: boolean | null; } /** * Parse the RHS of a `**Code pattern**` line (FR/EN tolerant). Facets are * word-matched over the prose; an absent facet is `null`, NEVER a default — * the caller decides (scaffold specs apply the Zod defaults; the audit * coherence legs simply skip the comparison). */ export function parseCodePatternLine(rhs: string): ParsedCodePatternLine { const format = /`([^`]+)`/.exec(rhs)?.[1]?.trim() ?? null; // Strip the mask before facet-matching: `{TENANT}` inside the mask must not // read as the tenant scope facet. const prose = rhs.replace(/`[^`]*`/g, ' '); let scope: ParsedCodePatternLine['scope'] = null; if (/\bglobal\b/i.test(prose)) scope = 'Global'; else if (/\btenant\b/i.test(prose)) scope = 'Tenant'; let reset: ParsedCodePatternLine['reset'] = null; if (/\b(annuel(le)?|yearly)\b/i.test(prose)) reset = 'Yearly'; else if (/\b(mensuel(le)?|monthly)\b/i.test(prose)) reset = 'Monthly'; else if (/\b(quotidien(ne)?|journalier|daily)\b/i.test(prose)) reset = 'Daily'; else if (/\breset\b[^,.;]*\b(aucun|none|no)\b/i.test(prose) || /\b(sans|no)\s+reset\b/i.test(prose)) reset = 'None'; let gapless: boolean | null = null; // Negatives FIRST — "non gapless" contains "gapless". if (/\b(non[- ]gapless|hilo|avec\s+trous?|gaps?\s+toler)/i.test(prose)) gapless = false; else if (/\b(gapless|sans\s+trous?)\b/i.test(prose)) gapless = true; // Business label — quote-delimited on purpose: the delimiters keep the regex // from swallowing neighbouring facet prose, FR and EN can coexist. const labelFr = /\blibell[eé]\s*[«"“]\s*([^»"”]+?)\s*[»"”]/i.exec(prose)?.[1] ?? null; const labelEn = /\blabel\s*["«“]\s*([^"»”]+?)\s*["»”]/i.exec(prose)?.[1] ?? null; const label = labelFr !== null || labelEn !== null ? { fr: labelFr, en: labelEn } : null; let supplied: boolean | null = null; // Negatives FIRST — "non surchargeable" contains "surchargeable". if (/\b(non[- ]surchargeable|never\s+supplied)\b/i.test(prose)) supplied = false; else if (/\bsurchargeable\b|\bsupplied(?:[- ]on[- ]create)?\b/i.test(prose)) supplied = true; return { format, scope, reset, gapless, label, supplied }; } // ─── Near-miss declarations — the green-by-vacuity killer ────────────────── // // The incident this closes: a client authored its `Code pattern` declarations // inside a TABLE CELL of entité.md. `CODE_PATTERN_LINE_RE` never matched, so // derive-code-specs derived nothing, PRD-132 stayed silent, DEV-API-022 and // DEV-UI-034 rendered ok with the note « no entity declares a Code pattern » // — a mute green over a real declaration. These helpers DETECT and CLASSIFY // the near-miss; severity and wording belong to each consumer (the same // split as parseCodePatternLine: the lib never decides for the caller). export type NearMissShape = 'table-cell' | 'wrong-case' | 'unbolded-bullet' | 'prose-declaration'; export interface CodePatternNearMiss { /** Enclosing `### ENT-… — Entity` block name (null before the first heading). */ entity: string | null; /** 1-based line number in the document. */ line: number; /** The trimmed offending line (remedy messages quote it). */ text: string; /** 'table-cell' = declared in a table row (the incident shape); * 'wrong-case' = bold bullet whose form is off (casing/spacing/bullet char); * 'unbolded-bullet' = bullet without the bold marker; * 'prose-declaration' = declarative prose (`… : value` or an orphan mask). */ shape: NearMissShape; } /** The concept words a pattern MENTION may use — « référence = code » in real * vocabularies, so `**Référence pattern**` / `pattern de numérotation` are * near-miss mentions too, not just the canonical « code pattern ». */ const MENTION_SOURCE = String.raw`(?:code|r[ée]f[ée]rence|ref|num[ée]ro|number|matricule|num[ée]rotation)[\s-]*patterns?` + String.raw`|patterns?\s+de\s+(?:code|r[ée]f[ée]rence|num[ée]rotation|num[ée]ro)`; /** Tolerant MENTION of the concept — detection only, NEVER used to parse. */ export const CODE_PATTERN_MENTION_RE = new RegExp(`(${MENTION_SOURCE})`, 'i'); /** Legitimate stated absence — « pas de Code pattern », "no code pattern". */ const MENTION_NEGATION_RE = new RegExp( String.raw`\b(aucun(?:e)?|pas\s+de|sans|no|not|never)\b[^.;|]{0,40}?(${MENTION_SOURCE})`, 'i', ); /** Tolerant entity-heading tracker (em-dash or ASCII dash — attribution only). */ const NEAR_MISS_ENT_RE = /^###\s+ENT-[A-Za-z0-9_-]+\s*[—-]\s*([A-Za-z][A-Za-z0-9_]*)/; /** The remedy every consumer quotes — one canonical wording, never re-typed. */ export const CODE_PATTERN_CANONICAL_FORM = 'the ONLY parsable declaration is the bullet `- **Code pattern** : ' + '`XXX-{YY}-{SEQ:4}` — scope tenant|global, reset annuel|none, gapless` on its own ' + 'line inside the `### ENT-NNN — Entity` block (exact bold + casing, NEVER a table ' + 'cell — grammar: business-analyse/_workflow/doc-templates.md). Rewrite the ' + 'near-miss, then re-run derive-code-specs.'; /** * Lines that MENTION a Code pattern in a declarative shape but do NOT parse. * A line matching `CODE_PATTERN_LINE_RE` is valid and never a near-miss (the * canonical bullet mentions the concept itself); a stated absence is skipped; * a bare prose mention without `:`-value or an orphan mask is IGNORED — * precision over recall, so descriptive prose never turns into an err. */ export function findCodePatternNearMisses(markdown: string): CodePatternNearMiss[] { const out: CodePatternNearMiss[] = []; let entity: string | null = null; const lines = markdown.split(/\r?\n/); for (let i = 0; i < lines.length; i++) { const trimmed = (lines[i] ?? '').trim(); const heading = NEAR_MISS_ENT_RE.exec(trimmed); if (heading) { entity = heading[1] ?? null; continue; } if (!CODE_PATTERN_MENTION_RE.test(trimmed)) continue; if (CODE_PATTERN_LINE_RE.test(trimmed)) continue; if (MENTION_NEGATION_RE.test(trimmed)) continue; let shape: NearMissShape | null = null; if (/^\|.*\|/.test(trimmed)) { shape = 'table-cell'; } else if (/^[-*]\s/.test(trimmed)) { shape = /\*\*[^*]*\*\*/.test(trimmed) ? 'wrong-case' : 'unbolded-bullet'; } else if (/:\s*\S/.test(trimmed) || /`[^`]*\{[^`}]+\}[^`]*`/.test(trimmed)) { shape = 'prose-declaration'; } if (shape !== null) out.push({ entity, line: i + 1, text: trimmed, shape }); } return out; } // ─── Code-like attribute lexicon — « Référence » IS a code ───────────────── // // In real business vocabularies the allocated identifier is rarely called // "Code": Référence, Numéro, Matricule… An attribute named after this lexicon // and shaped like a business key (string + unique) with NO species // classification (`**Code pattern**` bullet or `**Code saisi**` marking) is a // signal the BA never decided — DM-021 asks for the CLASSIFICATION, it never // forbids (a user-typed referential code is legitimate). Deliberately NOT // display-field's DISPLAYISH_RE: that lexicon answers "which field NAMES a // row" (name/label/title included, code excluded here) — different semantics. /** FR+EN, accent-folded, matched whole-word over splitWords. Drift-locked * as `code-like-lexicon:v1` in create-data-model levels/attributes.md. */ export const CODE_LIKE_ATTRIBUTE_WORDS = [ 'reference', 'ref', 'numero', 'number', 'num', 'matricule', ] as const; const CODE_LIKE_SET = new Set(CODE_LIKE_ATTRIBUTE_WORDS); const foldWord = (w: string): string => w.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase(); /** * The lexicon word an attribute name matches, or null. Whole-word over the * PascalCase/separator split (`NuméroClient` → numero; `InvoiceNumber` → * number; `Preference` → null — never substring inside a word). FK-shaped * names (`ReferenceId`) never match: the trailing `Id` marks a relation. */ export function codeLikeWordOf(name: string): string | null { const words = splitWords(name).map(foldWord); if (words.length === 0) return null; if (words[words.length - 1] === 'id') return null; return words.find((w) => CODE_LIKE_SET.has(w)) ?? null; } export function isCodeLikeAttributeName(name: string): boolean { return codeLikeWordOf(name) !== null; } // ─── The `**Code saisi**` marking — the TYPED species said out loud ──────── /** The `- **Code saisi** : — …` bullet (EN alias `**Typed code**`) — * classifies an attribute as a USER-TYPED referential key, never allocated. * ba-entities folds the bullet into `fields['code saisi']` / `fields['typed * code']`; this parses the RHS. */ export const CODE_SAISI_LINE_RE = /^-\s+\*\*(?:Code saisi|Typed code)\*\*\s*:\s*(.+)$/; /** Attribute names named by a Code saisi RHS (`Reference, Numero — prose`). */ export function parseCodeSaisiValue(raw: string): string[] { const head = raw.split(/[—;.]/)[0] ?? ''; return [...head.matchAll(/[A-Za-z_][A-Za-z0-9_]*/g)].map((m) => m[0]); } // ─── The `**Code décidé**` marking — a code on a REFERENCE table, DECIDED ── // // 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 (DM-022). // // This is a THIRD, orthogonal axis, not a variant of the two species above: // **Code pattern** / **Code saisi** answer « how is this code produced? » // **Code décidé** answers « WHY does this reference table // have one at all? » // On a lookup they legitimately co-exist. `**Code saisi**` is deliberately NOT // reused as the decision marker: attributes.md made it the lookup's sanctioned // default until now, so recycling it would grandfather every existing lookup // as « decided » and empty the doctrine of its content. The new marker carries // a DATE — that is what makes it a transcribed decision rather than a habit. export interface ParsedDecidedCode { /** What the code names — the RHS head, verbatim. */ names: string; /** ISO day of the user decision (`AAAA-MM-JJ`). */ date: string; } /** The canonical bullet, whole line (near-miss scanning). Case-SENSITIVE on * the label, exactly like `CODE_SAISI_LINE_RE`: a wrong-cased marker must * surface as a near-miss, never be silently accepted. */ export const DECIDED_CODE_LINE_RE = /^-\s+\*\*(?:Code décidé|Decided code)\*\*\s*:\s*(.+)$/; /** The dated tail of the RHS — `— décision utilisateur du 2026-09-01`. */ const DECIDED_CODE_DECISION_RE = /[—–-]\s*(?:d[ée]cision\s+utilisateur\s+du|user\s+decision\s+of)\s+(\d{4}-\d{2}-\d{2})/i; /** A real calendar day, not merely a `\d{4}-\d{2}-\d{2}` shape. */ function isIsoDay(s: string): boolean { const d = new Date(`${s}T00:00:00Z`); return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s; } /** * Parse the RHS of a `**Code décidé**` bullet (what `ba-entities` folds into * `fields['code decide']`), mirroring `parseCodePatternLine`'s contract: the * caller hands the value, never the whole line. Returns null when the tail is * absent or the date is not a real day — the caller turns that into the * near-miss err (an undated « decision » is a habit, not a decision). */ export function parseDecidedCodeLine(raw: string): ParsedDecidedCode | null { const value = raw.trim(); const m = DECIDED_CODE_DECISION_RE.exec(value); if (!m || !isIsoDay(m[1]!)) return null; const names = value.slice(0, m.index).replace(/[—–-]\s*$/, '').trim(); if (names === '') return null; return { names, date: m[1]! }; } /** The remedy every consumer quotes — one canonical wording, never re-typed. */ export const DECIDED_CODE_CANONICAL_FORM = 'the ONLY parsable declaration is the bullet `- **Code décidé** : — décision utilisateur du ` on its own line inside the ' + '`### ENT-NNN — Entity` block (exact bold + casing, a REAL date, NEVER a table ' + 'cell — grammar: business-analyse/create-data-model/levels/attributes.md). ' + 'An agent NEVER authors this line on its own initiative: it transcribes a ' + 'decision the user took.'; /** Tolerant MENTION of the concept — detection only, NEVER used to parse. */ const DECIDED_MENTION_SOURCE = String.raw`code\s+d[ée]cid[ée]|decided\s+code|d[ée]cision\s+utilisateur|user\s+decision`; export const DECIDED_CODE_MENTION_RE = new RegExp(`(${DECIDED_MENTION_SOURCE})`, 'i'); /** Legitimate stated absence — « pas de code décidé », "no decided code". */ const DECIDED_NEGATION_RE = new RegExp( String.raw`\b(aucun(?:e)?|pas\s+de|sans|no|not|never)\b[^.;|]{0,40}?(${DECIDED_MENTION_SOURCE})`, 'i', ); export type DecidedCodeNearMissShape = NearMissShape | 'missing-date'; export interface DecidedCodeNearMiss { entity: string | null; line: number; text: string; /** 'missing-date' = the bullet is well-formed but carries no real ISO day — * the likeliest near-miss of all, and the one that would otherwise read as * a decision. The other shapes mirror `CodePatternNearMiss`. */ shape: DecidedCodeNearMissShape; } /** * Lines that MENTION a decided code in a declarative shape but do NOT parse. * Same doctrine as `findCodePatternNearMisses`: a line that MENTIONS the * decision without being analysable is an err, never a silence — and a stated * absence or a bare prose mention is never one (precision over recall). */ export function findDecidedCodeNearMisses(markdown: string): DecidedCodeNearMiss[] { const out: DecidedCodeNearMiss[] = []; let entity: string | null = null; const lines = markdown.split(/\r?\n/); for (let i = 0; i < lines.length; i++) { const trimmed = (lines[i] ?? '').trim(); const heading = NEAR_MISS_ENT_RE.exec(trimmed); if (heading) { entity = heading[1] ?? null; continue; } if (!DECIDED_CODE_MENTION_RE.test(trimmed)) continue; if (DECIDED_NEGATION_RE.test(trimmed)) continue; const canonical = DECIDED_CODE_LINE_RE.exec(trimmed); if (canonical) { // The marker is right; only the dated tail can still be missing. if (parseDecidedCodeLine(canonical[1]!) !== null) continue; out.push({ entity, line: i + 1, text: trimmed, shape: 'missing-date' }); continue; } let shape: DecidedCodeNearMissShape | null = null; if (/^\|.*\|/.test(trimmed)) { shape = 'table-cell'; } else if (/^[-*]\s/.test(trimmed)) { shape = /\*\*[^*]*\*\*/.test(trimmed) ? 'wrong-case' : 'unbolded-bullet'; } else if (/:\s*\S/.test(trimmed)) { shape = 'prose-declaration'; } if (shape !== null) out.push({ entity, line: i + 1, text: trimmed, shape }); } return out; }