/** * lib/ba-screens.ts — shared BA screen registry parser. * * screen.md → screen registry. Pure parsing over file content, plus a sync IO * loader that scans every `screen.md` under an app (targets can live in * sibling modules; resource-level screen.md files are picked up too). * * Consumers: * - create-screen/cli/derive-related-tabs — 360 related tabs (list-target * resolution + declared-tab prerequisites) * - create-rbac/cli/derive-lookup-grants — producer-section resolution of * the derived `lookup` grants * derive-related-tabs re-exports this module from its historical `screens.ts` * path, so its tests and import sites stay untouched. Living in lib/ (not in a * skill folder) matters: the skill installer flattens/renames skill folders, * which breaks cross-SKILL relative imports — lib/ is depth-rewritten safely. * * Persisted bullet grammar (create-screen/levels/form-screens.md): * ### SCR-CRM-CLIENTS-DETAIL-001 — Fiche client (SmartForm) * - **Entité** : Client (ENT-001) * - **Permission** : `crm.clients.read` * - **Mode** : detail * - **Onglet lié « Factures »** : entité Invoice, FK clientId, affichage table → SCR-CRM-INVOICES-LIST-001 (`crm.invoices.read`) * - **Sans onglets liés** : (escape marker for RTV-007) * `affichage` is optional (default `table`); the `→ SCR-…` target and the * backticked permission are optional too (validate-mode flags the gaps). */ import { readdirSync, readFileSync, existsSync } from 'node:fs' import { join } from 'node:path' import { KANBAN_BA_COLORS } from './page-spec-kanban.js' import { splitTopLevel } from './ba-list-split.js' // --------------------------------------------------------------------------- // Model // --------------------------------------------------------------------------- export interface DeclaredRelatedTab { /** Label as written between « … ». */ label: string /** Canonical tab key — kebab of the label (diacritics folded). */ key: string relatedEntity: string /** FK as written in the bullet (expected camelCase). */ relationFk: string /** `affichage ` — default `table`. Kept verbatim (RTV-008 validates). */ displayMode: string /** * `app ` — the business application the related entity lives in, written only when * it is NOT the screen's own. Optional in the grammar because the relation graph usually * knows it already (`scope cross-module (APP/MOD)`); authored when the graph cannot tell. * Lower-cased here; the audit compares it against where the entity actually lives. */ relatedApp?: string targetScreen?: string permission?: string /** The full bullet line, for messages. */ line: string } export interface ParsedScreen { code: string title: string screenType: string /** Path relative to baRoot, for messages (e.g. `CRM/CLIENTS/clients/screen.md`). */ file: string /** * Application folder code the screen was loaded from (`CRM`). Set by the loaders; * a screen parsed straight from a string without an `app` in its meta falls back to * the first segment of `file`, which is that same code by construction. * * Load-bearing as soon as MORE THAN ONE application is in scope: a related tab may * target a list screen of another application, and its generated import paths are * keyed by the TARGET's application, not the page's. */ app: string /** Module folder code (first segment under the app). */ module: string /** Section path under the module (`clients` or `clients/sub-resource`). */ section: string entity?: string entityCode?: string permission?: string mode?: string declaredTabs: DeclaredRelatedTab[] /** Labels of the `- **Onglet « X »**` FIELD-tab bullets (own-field * grouping), in declaration order. Inner and related tabs share the one * rendered TabStrip — RTV-009 budgets their sum. */ fieldTabLabels: string[] /** `- **Sans onglets liés** : ` justification, when present. */ noRelatedTabsReason?: string /** Parsed SmartKanban bullets — set ONLY when screenType matches /kanban/i * (kanban-screens.md grammar). derive-kanban-spec folds it into the LIST * pagespec's `kanban` block (lib/page-spec-kanban.ts). */ kanban?: ParsedKanbanConfig } /** SmartKanban config as AUTHORED in screen.md (kanban-screens.md grammar) — * labels/colors verbatim; validation against the entity enum happens in * derive-kanban-spec, never here. */ export interface ParsedKanbanConfig { /** `- **Champ statut** : status` */ statusField?: string /** `- **Colonnes** : draft (Brouillon, gray), …` — color kept verbatim * (the deriver drops values outside the closed palette). */ columns: Array<{ key: string; label: string; color?: string }> /** `- **Carte** : titre = code, sous-titre = X, champs = a, b, c` */ titleField?: string subtitleField?: string cardFields: string[] /** `- **Navigation** : clic carte → SCR-…` */ rowClickTarget?: string } // --------------------------------------------------------------------------- // Regex patterns // --------------------------------------------------------------------------- /** * The `screen.md` bullets THIS parser reads (folded keys) — the `ba-screens` * column of `screen-grammar:v1` (lib/screen-grammar.ts). The drift test checks * this list against the grammar rows AND probes the regexes below for each * label, so the list cannot claim a bullet the code no longer reads. */ export const BA_SCREENS_BULLETS: readonly string[] = [ 'entite', 'permission', 'mode', 'onglet', 'onglet lie', 'sans onglets lies', 'champ statut', 'colonnes', 'carte', 'navigation', ] /** `### SCR-CRM-CLIENTS-DETAIL-001 — Fiche client (SmartForm)`. * Segments are case-INSENSITIVE (`SCR-CRM-PIPELINE-opportunites-001` — real * corpora write the section segment in lowercase, 19/20 modules on ImmoHub) * and the ASCII ` - ` separator is tolerated alongside the em-dash — the * UPPERCASE-only, em-dash-only form silently dropped those screens (same * fail-open class as parse-ac's UC_HEADING_RE, fixed 2026-08-30). */ export const SCREEN_HEADING_RE = /^###\s+(SCR-[A-Za-z0-9_-]+)(?:\s*—\s*|\s+-\s+)(.+?)\s*\(([A-Za-z]+)\)\s*$/gm const ENTITY_BULLET_RE = /^-\s*\*\*Entité\*\*\s*:\s*(\w+)(?:\s*\((ENT-[^)]+)\))?/m const PERMISSION_BULLET_RE = /^-\s*\*\*Permission\*\*\s*:\s*`([^`]+)`/m const MODE_BULLET_RE = /^-\s*\*\*Mode\*\*\s*:\s*(\w+)/m /** * `- **Onglet « Information »** : field, field, …` — the INNER field-tab * grammar of form-screens.md (own-field grouping). `Onglet lié` can never * match: the literal « must follow `Onglet` directly, and the related-tab * bullet interposes `lié`. Labels feed the shared-bar budget (RTV-009): * inner and related tabs render on ONE TabStrip. */ const FIELD_TAB_BULLET_RE = /^-\s*\*\*Onglet\s*«\s*([^»]+?)\s*»\s*\*\*\s*:/gm /** * `- **Onglet lié « Factures »** : entité Invoice, FK clientId[, app FACTURATION][, affichage table][ → SCR-…][ (`perm`)]` * * The `app` token names the business application the related entity lives in — written * ONLY when it is not the screen's own. It is what makes a tab pointing at another * application expressible at all: the generated page keys its hook and its routes * imports by that code, and pointed at the wrong one it does not compile. */ const RELATED_TAB_BULLET_RE = /^-\s*\*\*Onglet lié\s*«\s*([^»]+?)\s*»\s*\*\*\s*:\s*entité\s+(\w+)\s*,\s*FK\s+(\w+)(?:\s*,\s*app\s+([A-Za-z][A-Za-z0-9_-]*))?(?:\s*,\s*affichage\s+([A-Za-z-]+))?(?:\s*→\s*(SCR-[A-Za-z0-9_-]+))?(?:\s*\(\s*`([^`]+)`\s*\))?/gm /** Any line that LOOKS like a related-tab bullet (for malformed-grammar * warnings). No `\b` after `lié` — `é` is a non-word char in JS regex, so a * word boundary there never matches. */ const RELATED_TAB_LOOSE_RE = /^-\s*\*\*Onglet lié.*$/gm const NO_TABS_MARKER_RE = /^-\s*\*\*Sans onglets liés\*\*\s*:\s*(.+?)\s*$/m // SmartKanban bullets (kanban-screens.md grammar). const KANBAN_STATUS_RE = /^-\s*\*\*Champ statut\*\*\s*:\s*([\w-]+)/m const KANBAN_COLUMNS_RE = /^-\s*\*\*Colonnes\*\*\s*:\s*(.+?)\s*$/m /** One column entry: `key (Label[, color])`. */ const KANBAN_COLUMN_ENTRY_RE = /([A-Za-z0-9_-]+)\s*\(([^)]*)\)/g const KANBAN_CARD_RE = /^-\s*\*\*Carte\*\*\s*:\s*(.+?)\s*$/m const KANBAN_CARD_TITLE_RE = /(?:^|,)\s*titre\s*=\s*([\w-]+)/ const KANBAN_CARD_SUBTITLE_RE = /(?:^|,)\s*sous-titre\s*=\s*([\w-]+)/ const KANBAN_CARD_FIELDS_RE = /champs\s*=\s*(.+)$/ const KANBAN_NAV_RE = /^-\s*\*\*Navigation\*\*\s*:.*?(SCR-[A-Za-z0-9_-]+)/m // --------------------------------------------------------------------------- // Pure parsing // --------------------------------------------------------------------------- /** Fold a French label to a schema-valid tab key: `Adresses de facturation` → `adresses-de-facturation`. */ export function labelToKey(label: string): string { return label .normalize('NFD') .replace(/[̀-ͯ]/g, '') .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') } /** * Parse the SmartKanban bullets of ONE screen block (kanban-screens.md * grammar). Tolerant: a malformed bullet yields a warning, never a throw — * the deriver reports the gap, the audit flags it. */ export function parseKanbanBullets(blockText: string): { config: ParsedKanbanConfig warnings: string[] } { const warnings: string[] = [] const config: ParsedKanbanConfig = { columns: [], cardFields: [] } const status = KANBAN_STATUS_RE.exec(blockText) if (status) config.statusField = status[1] else warnings.push('missing `- **Champ statut** : ` bullet') const columnsLine = KANBAN_COLUMNS_RE.exec(blockText) if (columnsLine) { const payload = columnsLine[1] const entries = [...payload.matchAll( new RegExp(KANBAN_COLUMN_ENTRY_RE.source, KANBAN_COLUMN_ENTRY_RE.flags), )] if (entries.length > 0) { for (const entry of entries) { const key = entry[1] const parenParts = entry[2].split(',').map(p => p.trim()).filter(p => p.length > 0) const last = parenParts[parenParts.length - 1]?.toLowerCase() const hasColor = parenParts.length > 1 && (KANBAN_BA_COLORS as readonly string[]).includes(last ?? '') const label = (hasColor ? parenParts.slice(0, -1) : parenParts).join(', ') || key config.columns.push({ key, label, ...(hasColor ? { color: last } : {}) }) } } else { // Bare-key fallback: `draft, submitted, approved` (label = key). // Top-level split — and a token that is not a bare key is REPORTED, // never silently dropped (the lost-column class). const tokens = splitTopLevel(payload, ',').filter(k => k !== '') for (const key of tokens.filter(k => /^[A-Za-z0-9_-]+$/.test(k))) { config.columns.push({ key, label: key }) } const dropped = tokens.filter(k => !/^[A-Za-z0-9_-]+$/.test(k)) if (dropped.length > 0) { warnings.push(`column entries dropped (not bare keys): ${dropped.join(' · ')}`) } if (config.columns.length > 0) { warnings.push('columns authored without `(Label[, color])` — keys reused as labels') } } } if (config.columns.length === 0) { warnings.push('missing/empty `- **Colonnes** : key (Label, color), …` bullet') } const card = KANBAN_CARD_RE.exec(blockText) if (card) { const payload = card[1] const fieldsMatch = KANBAN_CARD_FIELDS_RE.exec(payload) if (fieldsMatch) { // Top-level split — `statut (badge)` must not be silently lost. const tokens = splitTopLevel(fieldsMatch[1], ',').filter(f => f !== '') config.cardFields = tokens.filter(f => /^[\w-]+$/.test(f)) const dropped = tokens.filter(f => !/^[\w-]+$/.test(f)) if (dropped.length > 0) { warnings.push(`card champs entries dropped (not bare keys): ${dropped.join(' · ')}`) } } const prefix = fieldsMatch ? payload.slice(0, fieldsMatch.index) : payload const title = KANBAN_CARD_TITLE_RE.exec(prefix) if (title) config.titleField = title[1] const subtitle = KANBAN_CARD_SUBTITLE_RE.exec(prefix) if (subtitle) config.subtitleField = subtitle[1] if (!title) warnings.push('`- **Carte**` bullet has no `titre = `') } const nav = KANBAN_NAV_RE.exec(blockText) if (nav) config.rowClickTarget = nav[1] return { config, warnings } } export function parseScreenFile( content: string, meta: { file: string; module: string; section: string; app?: string }, ): { screens: ParsedScreen[]; warnings: string[] } { // `file` is built as `/<...>/screen.md` by every loader, so its first segment IS // the application code — the fallback is exact, not a guess. const app = meta.app ?? meta.file.split('/')[0] ?? '' const screens: ParsedScreen[] = [] const warnings: string[] = [] const re = new RegExp(SCREEN_HEADING_RE.source, SCREEN_HEADING_RE.flags) const positions: Array<{ code: string; title: string; type: string; start: number }> = [] let m: RegExpExecArray | null while ((m = re.exec(content)) !== null) { positions.push({ code: m[1], title: m[2].trim(), type: m[3], start: m.index }) } positions.forEach((pos, i) => { const block = content.slice( pos.start, i + 1 < positions.length ? positions[i + 1].start : undefined, ) const entityMatch = ENTITY_BULLET_RE.exec(block) const declaredTabs: DeclaredRelatedTab[] = [] for (const tab of block.matchAll( new RegExp(RELATED_TAB_BULLET_RE.source, RELATED_TAB_BULLET_RE.flags), )) { declaredTabs.push({ label: tab[1], key: labelToKey(tab[1]), relatedEntity: tab[2], relationFk: tab[3], relatedApp: tab[4]?.toLowerCase(), displayMode: tab[5] ?? 'table', targetScreen: tab[6], permission: tab[7], line: tab[0].trim(), }) } // Loose bullets that did not match the full grammar → warning (tolerance, // not silence: a typo'd bullet must not silently vanish from validation). const looseCount = [...block.matchAll( new RegExp(RELATED_TAB_LOOSE_RE.source, RELATED_TAB_LOOSE_RE.flags), )].length if (looseCount > declaredTabs.length) { warnings.push( `${meta.file} · ${pos.code}: ${looseCount - declaredTabs.length} « Onglet lié » bullet(s) did not match the grammar ` + '`- **Onglet lié « Label »** : entité X, FK fkId[, app APP][, affichage mode] → SCR-… (`perm`)` — fix the bullet(s)', ) } const fieldTabLabels = [...block.matchAll( new RegExp(FIELD_TAB_BULLET_RE.source, FIELD_TAB_BULLET_RE.flags), )].map((m) => m[1]) let kanban: ParsedKanbanConfig | undefined if (/kanban/i.test(pos.type)) { const parsed = parseKanbanBullets(block) kanban = parsed.config for (const w of parsed.warnings) { warnings.push(`${meta.file} · ${pos.code}: ${w}`) } } screens.push({ code: pos.code, title: pos.title, screenType: pos.type, file: meta.file, app, module: meta.module, section: meta.section, entity: entityMatch?.[1], entityCode: entityMatch?.[2], permission: PERMISSION_BULLET_RE.exec(block)?.[1], mode: MODE_BULLET_RE.exec(block)?.[1], declaredTabs, fieldTabLabels, noRelatedTabsReason: NO_TABS_MARKER_RE.exec(block)?.[1], ...(kanban !== undefined ? { kanban } : {}), }) }) return { screens, warnings } } /** SmartForm mode defaults to `edit` when the bullet is absent (smartcomponents.md). */ export function effectiveMode(screen: ParsedScreen): string { return screen.mode ?? 'edit' } // --------------------------------------------------------------------------- // Registry helpers // --------------------------------------------------------------------------- export interface ListTargetResolution { resolved: boolean code?: string screenType?: string alternatives: string[] /** The winning screen (internal — stripped before reporting). */ screen?: ParsedScreen } /** * Resolve the SmartListView (else SmartCard) bound to `relatedEntity`. * A screen in `preferredModule` (the related entity's module) wins; every * other match goes to `alternatives[]`. Zero matches → `resolved: false`. */ export function resolveListTarget( screens: ParsedScreen[], relatedEntity: string, preferredModule?: string, preferredApp?: string, ): ListTargetResolution { const pref = preferredModule?.toUpperCase() const prefApp = preferredApp?.toUpperCase() const matches = screens .filter( (s) => (s.screenType === 'SmartListView' || s.screenType === 'SmartCard') && s.entity === relatedEntity, ) .sort((a, b) => { // Application FIRST, module second. A module code is unique only inside its // application (two applications legitimately ship a `configuration` module), so // once the screen set spans several applications, preferring the module alone can // hand back a homonym belonging to somebody else. Callers that pass no application // keep the historical module-only preference exactly. const appA = prefApp && a.app.toUpperCase() === prefApp ? 0 : 1 const appB = prefApp && b.app.toUpperCase() === prefApp ? 0 : 1 if (appA !== appB) return appA - appB const prefA = pref && a.module.toUpperCase() === pref ? 0 : 1 const prefB = pref && b.module.toUpperCase() === pref ? 0 : 1 if (prefA !== prefB) return prefA - prefB if (a.screenType !== b.screenType) return a.screenType === 'SmartListView' ? -1 : 1 return a.code.localeCompare(b.code) }) if (matches.length === 0) return { resolved: false, alternatives: [] } return { resolved: true, code: matches[0].code, screenType: matches[0].screenType, alternatives: matches.slice(1).map((s) => s.code), screen: matches[0], } } /** * True when at least one SmartForm — any mode: create, edit or detail — is * bound to `entity` in the loaded screen set. "No create form AND no * detail/edit fiche" reduces to "no SmartForm at all" (`effectiveMode` * partitions every form into create vs non-create), so ONE predicate carries * the inert-satellite signal derive-related-tabs uses to suggest the summary * cartouche. Only meaningful on a surface that was actually scanned — callers * must not conclude inertness for entities outside the loaded app. */ export function hasFormScreen(screens: ParsedScreen[], entity: string): boolean { return screens.some((s) => s.screenType === 'SmartForm' && s.entity === entity) } // --------------------------------------------------------------------------- // IO loader // --------------------------------------------------------------------------- /** * Scan every `screen.md` of EVERY application under `/`. * * The single-app {@link loadScreens} is the right scope when the question is "what does * this application declare". It is the WRONG one as soon as a surface points outside its * own application — a related tab on a customer record targeting the billing application's * list screen. Resolved against a single-app registry such a target simply does not exist, * and the check that should have vouched for it reports "missing" or silently stands down. * * Applications are the top-level folders of the BA tree; `_`- and `.`-prefixed ones are * conventions for non-application material (`_audit`, `_workflow`) and are skipped. */ export function loadScreensAcrossApps(baRoot: string): { screens: ParsedScreen[] warnings: string[] } { const out = { screens: [] as ParsedScreen[], warnings: [] as string[] } if (!existsSync(baRoot)) return out let entries try { entries = readdirSync(baRoot, { withFileTypes: true }) } catch (err) { out.warnings.push(`cannot read ${baRoot}: ${(err as Error).message}`) return out } for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { if (!entry.isDirectory() || entry.name.startsWith('_') || entry.name.startsWith('.')) continue const loaded = loadScreens(baRoot, entry.name) out.screens.push(...loaded.screens) out.warnings.push(...loaded.warnings) } return out } /** Scan every `screen.md` under `//` (module + section + resource levels). */ export function loadScreens( baRoot: string, app: string, ): { screens: ParsedScreen[]; warnings: string[] } { const out = { screens: [] as ParsedScreen[], warnings: [] as string[] } const appDir = join(baRoot, app) if (!existsSync(appDir)) return out const walk = (dir: string, rel: string[]): void => { let entries try { entries = readdirSync(dir, { withFileTypes: true }) } catch (err) { out.warnings.push(`cannot read ${dir}: ${(err as Error).message}`) return } for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue if (entry.isDirectory()) { walk(join(dir, entry.name), [...rel, entry.name]) } else if (entry.name === 'screen.md' && rel.length >= 1) { let content: string try { content = readFileSync(join(dir, entry.name), 'utf8') } catch (err) { out.warnings.push(`cannot read ${join(dir, entry.name)}: ${(err as Error).message}`) continue } const parsed = parseScreenFile(content, { file: [app, ...rel, 'screen.md'].join('/'), app, module: rel[0], section: rel.slice(1).join('/'), }) out.screens.push(...parsed.screens) out.warnings.push(...parsed.warnings) } } } walk(appDir, []) return out }