/** * cli:audit-ba — rules/screen-blocks.ts * * Per-SCR-block reader over the RAW screen.md docs of the model — the richer * sibling of lib/ba-screens' ParsedScreen (which owns headings, Entité, * Permission, Mode and the related-tab grammar). This one extracts the field * LISTS the screens rules count (Colonnes / Filtres / Indicateurs / Champs / * Section-Onglet groups), the custom-action bullets, the linked UCs (with the * multi-line continuation the 2026-08-30 incident exposed), and every SCR-… * token for navigation resolution. */ import { splitBaList, splitTopLevel } from '../../../../../lib/ba-list-split.js' import { SCREEN_BULLET_KEY } from '../../../../../lib/screen-grammar.js' import { fold } from './registry.js' /** * The folded bullet keys this reader switches on — every one imported from * lib/screen-grammar (the `screen-grammar:v1` SSOT). The drift test refuses a * string literal here: a key that exists only in this switch is a bullet the * authoring references never taught. */ export const SCREEN_BLOCK_KEYS: readonly string[] = [ SCREEN_BULLET_KEY.casDUsageLies, SCREEN_BULLET_KEY.casDUsageLiesAlias, SCREEN_BULLET_KEY.colonnes, SCREEN_BULLET_KEY.filtres, SCREEN_BULLET_KEY.indicateurs, SCREEN_BULLET_KEY.champs, SCREEN_BULLET_KEY.entite, SCREEN_BULLET_KEY.mode, SCREEN_BULLET_KEY.cycleDeVie, SCREEN_BULLET_KEY.actionsPrefix, ] export interface ScreenAction { raw: string code?: string kind?: string scope?: string permission?: string label?: string endpoint?: string httpMethod?: string targetScreen?: string targetRoute?: string uc?: string } export interface ScreenBlock { code: string title: string screenType: string /** Doc path relative to baRoot (e.g. `CRM/PIPELINE/opportunites/screen.md`). */ file: string raw: string entity?: string mode?: string linkedUcs: string[] columns: string[] filters: string[] indicators: string[] /** Flat `- **Champs**` entries (outside any group). */ champs: string[] sectionGroups: { label: string; fields: string[] }[] ongletGroups: { label: string; fields: string[] }[] hasCycleDeVie: boolean /** Statuses cited by `- **Cycle de vie**` phase bullets (raw tokens). */ cycleStatuses: string[] actions: ScreenAction[] /** Every SCR-… token in the block except the block's own code. */ scrTokens: string[] } const HEADING_RE = /^###\s+(SCR-[A-Za-z0-9_-]+)(?:\s*—\s*|\s+-\s+)(.+?)\s*\(([A-Za-z]+)\)\s*$/ const GROUP_RE = /^-\s*\*\*(Section|Onglet)\s*«\s*([^»]+?)\s*»\s*\*\*\s*:\s*(.*)$/ const FIELD_RE = /^-\s*\*\*([^*«]+?)\*\*\s*:\s*(.*)$/ const SUB_ITEM_RE = /^\s+-\s+(.+?)\s*$/ const PLAIN_CONT_RE = /^\s{2,}(\S.*?)\s*$/ const ACTION_ITEM_RE = /^\s+-\s+`([^`]+)`\s*(?:—|-)?\s*(.*)$/ const UC_TOKEN_RE = /\bUC-(?:[A-Za-z0-9_]+-){3,}\d{3}\b/g const SCR_TOKEN_RE = /\bSCR-(?:[A-Za-z0-9_]+-){3,}\d{3}\b/g // The reference field grammar puts commas INSIDE parentheses (`code (text, // requis)` is ONE entry) — a top-level split, never a bare comma split. const splitList = splitBaList function parseActionPairs(raw: string): Omit { const out: Record = {} // `kind: api, scope: header, endpoint: impact, …, UC: UC-…, label: « X »` // Pairs separate on TOP-LEVEL commas only; a key-less segment belongs to the // previous pair's value (`payloadParameters: a (lookup, entity V), b (text)`). let lastKey: string | null = null for (const seg of splitTopLevel(raw)) { if (seg === '') continue const m = seg.match(/^([A-Za-z]+)\s*:\s*(.*)$/) if (m) { lastKey = m[1]!.toLowerCase() out[lastKey] = m[2]!.trim().replace(/^«\s*|\s*»$/g, '') } else if (lastKey !== null) { out[lastKey] += `, ${seg}` } } const result: Omit = {} if (out['kind'] !== undefined) result.kind = out['kind'] if (out['scope'] !== undefined) result.scope = out['scope'] if (out['permission'] !== undefined) result.permission = out['permission'].replace(/^`|`$/g, '') if (out['label'] !== undefined) result.label = out['label'] if (out['endpoint'] !== undefined) result.endpoint = out['endpoint'].replace(/^`|`$/g, '') if (out['httpmethod'] !== undefined) result.httpMethod = out['httpmethod'].toUpperCase() if (out['targetscreen'] !== undefined) result.targetScreen = out['targetscreen'] if (out['targetroute'] !== undefined) result.targetRoute = out['targetroute'] if (out['uc'] !== undefined) result.uc = out['uc'] return result } export function parseScreenBlocks(raw: string, file: string): ScreenBlock[] { const lines = raw.split(/\r?\n/) const blocks: ScreenBlock[] = [] let current: ScreenBlock | null = null /** The list the current field's continuations feed (null = none). */ let contTarget: string[] | null = null let inActions = false const closeField = (): void => { contTarget = null inActions = false } for (const line of lines) { const heading = line.match(HEADING_RE) if (heading) { closeField() current = { code: heading[1]!, title: heading[2]!, screenType: heading[3]!, file, raw: '', linkedUcs: [], columns: [], filters: [], indicators: [], champs: [], sectionGroups: [], ongletGroups: [], hasCycleDeVie: false, cycleStatuses: [], actions: [], scrTokens: [], } blocks.push(current) continue } if (/^#{1,3}\s/.test(line)) { closeField() current = null continue } if (!current) continue current.raw += line + '\n' // Grouped fields: `- **Section « X »** : a, b` / `- **Onglet « X »** : a, b` const group = line.match(GROUP_RE) if (group && !/^\s/.test(line)) { closeField() const entry = { label: group[2]!, fields: splitList(group[3]!) } ;(group[1] === 'Section' ? current.sectionGroups : current.ongletGroups).push(entry) contTarget = entry.fields continue } // Top-level field bullets. if (!/^\s/.test(line)) { const f = line.match(FIELD_RE) if (f) { closeField() const key = fold(f[1]!) const value = f[2]!.trim() if (key === SCREEN_BULLET_KEY.casDUsageLies || key === SCREEN_BULLET_KEY.casDUsageLiesAlias) { const target = current.linkedUcs for (const m of value.matchAll(UC_TOKEN_RE)) target.push(m[0]) contTarget = target } else if (key === SCREEN_BULLET_KEY.colonnes) { current.columns.push(...splitList(value)) contTarget = current.columns } else if (key === SCREEN_BULLET_KEY.filtres) { current.filters.push(...splitList(value)) contTarget = current.filters } else if (key === SCREEN_BULLET_KEY.indicateurs) { current.indicators.push(...splitList(value)) contTarget = current.indicators } else if (key === SCREEN_BULLET_KEY.champs) { current.champs.push(...splitList(value)) contTarget = current.champs } else if (key === SCREEN_BULLET_KEY.entite) { current.entity = value.replace(/\s*\(ENT-[^)]*\)\s*$/, '').trim() } else if (key === SCREEN_BULLET_KEY.mode) { current.mode = value.toLowerCase() } else if (key === SCREEN_BULLET_KEY.cycleDeVie) { current.hasCycleDeVie = true for (const m of value.matchAll(/\b[A-Z][A-Z0-9_]{2,}\b/g)) current.cycleStatuses.push(m[0]) contTarget = null } else if (key.startsWith(SCREEN_BULLET_KEY.actionsPrefix)) { inActions = true contTarget = null } continue } if (line.trim() !== '') closeField() continue } // Indented content — action items or list continuations. if (inActions) { const item = line.match(ACTION_ITEM_RE) if (item) { current.actions.push({ raw: line.trim(), code: item[1]!, ...parseActionPairs(item[2]!) }) continue } if (line.trim() === '') { inActions = false continue } } if (contTarget !== null) { const sub = line.match(SUB_ITEM_RE) if (sub) { if (contTarget === current.linkedUcs) { for (const m of sub[1]!.matchAll(UC_TOKEN_RE)) contTarget.push(m[0]) } else { contTarget.push(...splitList(sub[1]!)) } continue } const plain = line.match(PLAIN_CONT_RE) if (plain && contTarget === current.linkedUcs) { for (const m of plain[1]!.matchAll(UC_TOKEN_RE)) contTarget.push(m[0]) continue } } if (current.hasCycleDeVie) { const sub = line.match(SUB_ITEM_RE) if (sub) for (const m of sub[1]!.matchAll(/\b[A-Z][A-Z0-9_]{2,}\b/g)) current.cycleStatuses.push(m[0]) } } for (const b of blocks) { for (const m of b.raw.matchAll(SCR_TOKEN_RE)) { if (m[0].toUpperCase() !== b.code.toUpperCase()) b.scrTokens.push(m[0]) } } return blocks } // --------------------------------------------------------------------------- // Shared entity-binding predicate — ONE evaluator for SCR-003 and its mirror // XD-005. The 2026-09-04 DemoGestionFlotte incident: XD-005 had re-implemented // this filter WITHOUT the hub exemption (the regex was private to screens.ts), // so the sanctioned `- **Entité** : — (chaque widget porte la sienne)` of every // SmartModuleHome/SectionHome/Dashboard resolved under SCR-003 and failed under // XD-005 — two opposite verdicts in the same envelope, gate blocked on a valid // corpus. A rule that declares `dedupOf` MUST call this, never copy it. // --------------------------------------------------------------------------- /** Hub screens aggregate via widgets and may omit the screen-level entity. */ export const HUB_SCREEN_TYPES_RE = /^Smart(Dashboard|AppHome|ModuleHome|SectionHome)$/i /** * Screen → entity bindings resolving to no entity of the module, as * `${code} → ${entity}` evidence lines. Hub screens are exempt whatever their * Entité line says — the create-screen template writes an em-dash placeholder * there, which the block reader keeps verbatim. */ export function unresolvedEntityRefs(blocks: ScreenBlock[], entities: { name: string; code: string }[]): string[] { const known = new Set() for (const e of entities) { known.add(fold(e.name)) known.add(fold(e.code)) } return blocks .filter((b) => b.entity !== undefined && !known.has(fold(b.entity)) && !HUB_SCREEN_TYPES_RE.test(b.screenType)) .map((b) => `${b.code} → ${b.entity}`) }