/** * lib/ba-rules-rows.ts — shared `règles-métier.md` parser. * * THE first deterministic reader of the business-rules docs: until this file, * no TypeScript in the repository parsed `règles-métier.md` at all — every * downstream consumer (PRD synthesis, scaffold-business inputs, test parity) * relied on an LLM re-reading prose, which is exactly where rules vanished * silently (audit « chaîne de garanties », BR dimension). * * Grammar parsed (create-business-rules/SKILL.md § skeleton + field rules, * mirrored in `_workflow/doc-templates.md` § règles-métier — both drift-tested * in lib/__tests__/ba-rules-rows.test.ts): * * ### BR-001 — Remise plafonnée * - **Type** : validation * - **Sévérité** : err * - **Portée** : CRM / PIPELINE * - **Condition** : QUAND … ALORS … * - **Expression** : `Discount <= 0.20 || …` * - **Code d'erreur** : `pipeline.discount.cap` * - **Flow** : (workflow/state-transition rules) * - draft → submitted (by: BA-001-AC-001, guard: …) * - **Cas valides** : … * - **Cas invalides** : … * - **Cas d'usage liés** : UC-CRM-PIPELINE-OPPORTUNITES-002 (étape 3) * * Rules live at their DEEPEST scope — a module aggregates its own * `règles-métier.md` plus each section's (`loadModuleRules`). `BR-{NNN}` is * doc-scoped identity: the same number may legally exist in two docs of one * module, so every parsed rule carries its `docPath` and cross-doc duplicates * are surfaced as warnings, never silently merged. * * Tolerant by design: unknown field names are kept in `fields` verbatim, a * non-canonical severity/type is kept raw + warned — a malformed doc degrades * to warnings, never a throw. Consumers: create-prd/derive-rule-links * (PRD-129/130), audit-dev-api DEV-API-008 (no-rules-declared catch-up), * audit-dev-tests DEV-TEST-009 (test parity floor). */ import { existsSync, readFileSync, readdirSync } from 'node:fs' import { join } from 'node:path' import { normalizeCategoryLabel } from './ba-actors.js' /** One `from → to (by: …, guard: …)` transition of a rule's Flow block. */ export interface BaRuleFlowTransition { from: string to: string by?: string guard?: string } /** One parsed business rule. */ export interface BaRule { /** `BR-NNN` (doc-scoped identity — pair with `docPath` across docs). */ code: string title: string /** Lowercased `Type` value (12 canonical kinds + free domain kinds). */ type?: string /** `err` | `warn` | `info` when canonical; raw value otherwise (warned). */ severity?: string portee?: string condition?: string /** Backticks stripped. */ expression?: string /** Backticks stripped; undefined for blank cells (filter/visibility rules). */ errorCode?: string validCases?: string invalidCases?: string /** `UC-…` codes extracted from `Cas d'usage liés` (may be empty — BABOK). */ linkedUcs: string[] /** Parsed `- **Flow**` transitions (workflow / state-transition rules). */ flow: BaRuleFlowTransition[] /** Every field bullet verbatim, keyed by its NFD-lowercased name. */ fields: Record /** Doc the rule was read from (set by the loaders; '' when parsed inline). */ docPath: string } export const RULE_HEADING_RE = /^###\s+(BR-\d+)\s+[—-]\s+(.+?)\s*$/ const FIELD_RE = /^-\s*\*\*([^*]+)\*\*\s*:\s*(.*)$/ /** A Flow transition line: `- draft → submitted (by: X, guard: Y)`. */ const FLOW_LINE_RE = /^\s*-\s*([^\s(→](?:[^(→]*[^\s(→])?)\s*(?:→|->)\s*([^\s(]+)\s*(?:\((.*)\))?\s*$/ export const CANONICAL_SEVERITIES = new Set(['err', 'warn', 'info']) const stripTicks = (v: string): string => v.replace(/^`|`$/g, '').trim() function parseFlowMeta(meta: string | undefined): Pick { if (!meta) return {} // `by:` may carry a comma-separated list (`by: Responsable RH, Directeur`): // each value runs to the OTHER key or the end, never to the first comma. const by = meta.match(/\bby\s*:\s*(.+?)(?=\s*,\s*guard\s*:|$)/)?.[1]?.trim() const guard = meta.match(/\bguard\s*:\s*(.+?)(?=\s*,\s*by\s*:|$)/)?.[1]?.trim() return { ...(by ? { by } : {}), ...(guard ? { guard } : {}) } } /** Parse one `règles-métier.md` content. PURE. */ /** Re-derive a rule's typed field from `fields[key]` — called on the initial * field line AND on every appended continuation/sub-bullet, so a folded * `Condition` or a `Cas valides` sub-bullet list lands in the typed value * (they used to be dropped without a warning: `validCases`/`invalidCases` * came out `undefined` on exactly the shape levels/elaborate.md teaches). */ function applyTypedField(rule: BaRule, key: string): void { const value = (rule.fields[key] ?? '').trim() switch (key) { case 'type': rule.type = value.toLowerCase() || undefined break case 'severite': rule.severity = value.toLowerCase() || undefined break case 'portee': rule.portee = value || undefined break case 'condition': rule.condition = value || undefined break case 'expression': rule.expression = stripTicks(value) || undefined break case "code d'erreur": case 'code derreur': rule.errorCode = stripTicks(value) || undefined break case 'cas valides': rule.validCases = value || undefined break case 'cas invalides': rule.invalidCases = value || undefined break case "cas d'usage lies": case 'cas dusage lies': // Case-insensitive segments — the corpus writes the section segment of // UC codes in lowercase; the UPPERCASE-only form dropped those links // silently (BR-006/BR-007 false verdicts). rule.linkedUcs = [...value.matchAll(/UC-[A-Za-z0-9_-]+/g)].map((m) => m[0]) break default: break } } export function parseRules(content: string): { rules: BaRule[]; warnings: string[] } { const rules: BaRule[] = [] const warnings: string[] = [] let current: BaRule | null = null let inFlow = false let lastFieldKey: string | null = null for (const line of content.split(/\r?\n/)) { const heading = RULE_HEADING_RE.exec(line) if (heading) { current = { code: heading[1], title: heading[2], linkedUcs: [], flow: [], fields: {}, docPath: '', } if (!/^BR-\d{3}$/.test(current.code)) { warnings.push(`${current.code}: non-canonical code (expected BR-{NNN}, 3 digits) — parsed anyway.`) } rules.push(current) inFlow = false lastFieldKey = null continue } if (!current) continue const field = FIELD_RE.exec(line.trim()) if (field && !/^\s+-/.test(line)) { const key = normalizeCategoryLabel(field[1]).replace(/’/g, "'") const value = field[2].trim() current.fields[key] = value inFlow = false if (key === 'flow') { inFlow = true lastFieldKey = null } else { lastFieldKey = key applyTypedField(current, key) if (key === 'severite') { const sev = value.toLowerCase() if (sev && !CANONICAL_SEVERITIES.has(sev)) { warnings.push(`${current.code}: non-canonical Sévérité "${value}" (expected err|warn|info) — kept raw.`) } } } continue } // Continuations of the CURRENT field: an indented sub-bullet (` - …` — // the Cas valides/invalides list shape of levels/elaborate.md) or an // indented plain line (a folded Condition). Both used to be silently // dropped — the recipe downstream (validExamples[]/invalidExamples[]) // came out empty on canonically-authored docs. if (!inFlow && lastFieldKey !== null) { const sub = /^\s+-\s+(.+)$/.exec(line) const cont = !sub && /^\s{2,}\S/.test(line) ? line.trim() : null if (sub || cont) { const addition = sub ? sub[1].trim() : (cont as string) const prev = current.fields[lastFieldKey] ?? '' current.fields[lastFieldKey] = prev === '' ? addition : `${prev}${sub ? ' ; ' : ' '}${addition}` applyTypedField(current, lastFieldKey) continue } if (line.trim() === '' || !/^\s/.test(line)) lastFieldKey = null } if (inFlow) { const flowLine = FLOW_LINE_RE.exec(line) if (flowLine) { current.flow.push({ from: flowLine[1].trim(), to: flowLine[2].trim(), ...parseFlowMeta(flowLine[3]), }) continue } if (line.trim() !== '' && /^\s*-/.test(line)) { warnings.push(`${current.code}: unparsable Flow line "${line.trim()}" — transition dropped, fix the doc.`) } if (line.trim() !== '' && !/^\s/.test(line)) inFlow = false } } const seen = new Set() for (const rule of rules) { if (seen.has(rule.code)) { warnings.push(`Duplicate ${rule.code} in the same doc — the number, never the title, is the identity.`) } seen.add(rule.code) } return { rules, warnings } } /** Load + parse one `règles-métier.md` file. `exists: false` when absent. * A file that EXISTS but cannot be read stays `exists: true` with a loud * warning — the old `exists: false` made the whole doc vanish from every BR * gate on a mere I/O error, the exact silent-drop class the lib exists to * close. */ export function loadRulesDoc(path: string): { exists: boolean; rules: BaRule[]; warnings: string[] } { if (!existsSync(path)) return { exists: false, rules: [], warnings: [] } try { const parsed = parseRules(readFileSync(path, 'utf8')) for (const r of parsed.rules) r.docPath = path return { exists: true, ...parsed } } catch (e) { return { exists: true, rules: [], warnings: [ `UNREADABLE (${e instanceof Error ? e.message : String(e)}) — every rule of this doc is INVISIBLE to the BR gates until fixed.`, ], } } } /** * Aggregate a MODULE's rules: `///règles-métier.md` plus * every section's — AND resource's — `règles-métier.md` (`_workflow/ * ba-files.md` declares the doc authoritative at the rule's DEEPEST scope, * Resource included; the one-level walk left resource-scoped rules invisible * to PRD-129/130, DEV-API-008 and DEV-TEST-009). Every directory is visited * except `_`/`.`-prefixed, `pagespecs` and `node_modules` — the old * `/^[a-z]/` filter silently skipped folders starting with an uppercase, * digit or accented letter. Cross-doc duplicate codes are warned — never * merged. */ export function loadModuleRules( baRoot: string, app: string, module: string, ): { rules: BaRule[]; warnings: string[]; docs: string[] } { const moduleDir = join(baRoot, app, module) const docs: string[] = [] const rules: BaRule[] = [] const warnings: string[] = [] const tryDoc = (path: string): void => { const loaded = loadRulesDoc(path) if (!loaded.exists) return docs.push(path) rules.push(...loaded.rules) warnings.push(...loaded.warnings.map((w) => `${path}: ${w}`)) } const visit = (dir: string, depth: number): void => { if (depth > 2) return // module → section → resource let names: string[] = [] try { names = readdirSync(dir, { withFileTypes: true }) .filter((e) => e.isDirectory()) .map((e) => e.name) } catch { return /* unreadable folder — its docs simply don't join the aggregate */ } for (const name of names .filter((n) => !n.startsWith('_') && !n.startsWith('.') && n !== 'pagespecs' && n !== 'node_modules') .sort()) { tryDoc(join(dir, name, 'règles-métier.md')) visit(join(dir, name), depth + 1) } } tryDoc(join(moduleDir, 'règles-métier.md')) visit(moduleDir, 1) const byCode = new Map() for (const rule of rules) { const holder = byCode.get(rule.code) if (holder !== undefined && holder !== rule.docPath) { warnings.push( `${rule.code} exists in BOTH ${holder} and ${rule.docPath} — BR codes are doc-scoped; ` + `qualify by doc when cross-referencing (a bare "${rule.code}" is ambiguous in this module).`, ) } else { byCode.set(rule.code, rule.docPath) } } return { rules, warnings, docs } } /** The rules a generated app must ENFORCE (err) or at least surface (warn). */ export function enforceableRules(rules: readonly BaRule[]): BaRule[] { return rules.filter((r) => r.severity === 'err' || r.severity === 'warn') } /** Why a rule is exempt from the link/test requirements — each names its REAL * channel (never a silent skip). Shared by create-prd/derive-rule-links * (PRD-129) and audit-dev-tests DEV-TEST-009 — one exemption doctrine. */ export type RuleExemption = | 'severity-info' | 'type-access' | 'type-numbering' | 'enforcement-field' const ENFORCEMENT_EXEMPT_RE = /(plateforme|platform|manuel|manual|hors[- ]scaffolding)/i /** The exemption of a rule, or null when the scaffolded code must enforce it: * `access` → the RBAC matrix; `numbering` → codePattern (DEV-API-022); * `info` severity → observation only; `- **Enforcement** : plateforme|manuel` * → the socle / a human process (authored opt-out). */ export function ruleExemption(rule: BaRule): RuleExemption | null { if (rule.severity === 'info' || rule.severity === undefined) return 'severity-info' if (rule.type === 'access') return 'type-access' if (rule.type === 'numbering') return 'type-numbering' const enforcement = rule.fields['enforcement'] if (enforcement !== undefined && ENFORCEMENT_EXEMPT_RE.test(enforcement)) return 'enforcement-field' return null } /** The rules the GENERATED CODE itself must enforce — `enforceableRules` minus * the exempt ones (each exemption names a REAL other channel: RBAC matrix, * codePattern seam, platform/manual enforcement). This is the count * DEV-API-008's catch-up leg (`no-rules-declared`) must use: counting exempt * rules there raised an unhealable err on modules whose rules are all * numbering/access — derive-rule-links re-marked them `exempt` on every heal * pass and the err persisted, pressuring the dev agent to hand-implement the * rule (the hand-rolled-allocator inducer). `enforceableRules` keeps its * broader semantics on purpose — derive-rule-links REPORTS the exemptions * from that superset. */ export function enforcedRules(rules: readonly BaRule[]): BaRule[] { return enforceableRules(rules).filter((r) => ruleExemption(r) === null) }