/** * lib/ba-extension-requests.ts — THE shared contract of `/demande-extension.md`, * the application-level EXTENSION REQUESTS (doctrine 2026-10-03). * * Ownership of an entity is at the APPLICATION grain. When a module of * application A declares an entity that ANOTHER application already carries * (its generated class under `Entities//…`, or its `entité.md`), the module * REUSES it — never redeclares it — and the columns it needs IN ADDITION are * asked of the owner through a request block in A's `demande-extension.md`. * The Studio (the canonical orchestrator) reads the request, instructs it in * the owning project and writes the ONE field the CLI never writes: `Statut`. * While a request is `en attente` or `refusée`, the development of the WHOLE * requesting application is frozen (`ba-develop-plan/preflight-develop-plan`). * * Grammar (business-analyse/_workflow/doc-templates.md): * * # Demandes d'extension — * * ### REQ-001 — → [/] (or → Core via ) * - **Nature** : extension | accès (absent = extension) * - **Statut** : en attente | validée | refusée (Studio-owned) * - **Demandeurs** : MODULE1, MODULE2 * - **Sensibilité** : — (optional — the OWNER entity's level, copied) * - **Classe** : src/.Domain/Entities///.cs (or « non développée — entité.md / ») * - **Motif** : * - **Date** : AAAA-MM-JJ * - **Décision** : (optional) * | Attribut | Type | Contraintes | Calculé | (the EXTRA columns, verbatim from the requester's MCD — extension only) * * Two NATURES share the document, the Studio inbox and the preflight freeze * (data sensitivity doctrine 2026-10-03, lib/ba-sensitivity): * - `extension` — the historical ask: columns ADDED to the owner's entity; * - `accès` — a module of application A REACHES a ≥ confidentiel entity of * application B (a `scope cross-module (B/MOD)` relation, a 360 related * tab onto it, or a 4-segment permission row on its section) with no * column to add: the owner's consent is the ask. No attribute table. * * Content is a PROJECTION — nothing in a block is invented: the attributes are * the requester's own table rows, the motive its own Description/Traçabilité. * That is what lets `/audit-fix` execute the derive CLI unattended (lane * `derive`, CODE-008) without crossing "business content is never written * mechanically" (lib/remediation). * * Tolerant AND fail-closed: an unknown status reads as `pending` and is SAID * (a typo must freeze, never release); a near-miss heading is warned and * counted (`headingsSeen`) so audit-ba's control counter reconciles. * * Lives in lib/ because three readers share it across the installer's * enumerated import allowlist: audit-ba (corpus + CODE-008), * create-data-model/cli/derive-extension-request, ba-develop-plan/preflight. */ import { existsSync, readFileSync, statSync } from 'node:fs' import { join } from 'node:path' import type { BaEntity, BaEntityAttribute } from './ba-entities.js' import type { RbacRow } from './ba-rbac-rows.js' import { resolveEntitySectionFolder, type ParsedScreen } from './ba-screens.js' import { formatSensitivity, isSensitive, resolveEntitySensitivity } from './ba-sensitivity.js' import { CS_FRAMEWORK_PROPS, extraAttributes, sameSegment, type DomainScan } from './domain-scan.js' export const EXTENSION_REQUESTS_FILE = 'demande-extension.md' export const EXTENSION_REQUEST_MARKER_RE = // /** `### REQ-001 — Entity → TARGET` (ASCII arrow accepted). */ export const REQ_HEADING_RE = /^###\s+(REQ-\d{3})\s+—\s+([A-Za-z_][A-Za-z0-9_]*)\s+(?:→|->)\s+(.+?)\s*$/ const REQ_NEAR_MISS_RE = /^###\s+REQ-/i const FIELD_RE = /^[-*]\s+\*\*([^*]+?)\*\*\s*:\s*(.*)$/ const TABLE_LINE_RE = /^\s*\|(.*)\|\s*$/ export type ExtensionRequestStatus = 'pending' | 'validated' | 'refused' export const STATUS_LABELS: Record = { pending: 'en attente', validated: 'validée', refused: 'refusée', } /** `extension` = columns added to the owner's entity; `acces` = read access to a sensitive entity of another application. */ export type ExtensionRequestNature = 'extension' | 'acces' export const NATURE_LABELS: Record = { extension: 'extension', acces: 'accès', } /** `accès` / `access` → acces; `extension` → extension; anything else → null (the caller defaults to extension and says so). */ export function parseExtensionRequestNature(raw: string | null): ExtensionRequestNature | null { if (raw === null) return null const head = fold(raw).split(/[—\-–(:;.]/)[0]?.trim() ?? '' if (/^(acces|access|lecture|read)$/.test(head)) return 'acces' if (/^(extension|colonnes?|columns?)$/.test(head)) return 'extension' return null } export type ExtensionRequestTarget = | { kind: 'application'; app: string; module: string | null } | { kind: 'core'; entity: string; via: string | null } export interface ExtensionRequestAttribute { name: string type: string constraints: string /** `—` when stored. */ computed: string } export interface BaExtensionRequest { /** `REQ-001` */ code: string /** The entity the requester declared (PascalCase). */ entity: string /** Heading target verbatim. */ targetRaw: string target: ExtensionRequestTarget /** `extension` (default when the line is absent) or `acces`. */ nature: ExtensionRequestNature /** `- **Nature**` RHS verbatim — null when absent. */ natureRaw: string | null status: ExtensionRequestStatus /** `- **Statut**` RHS verbatim — null when absent (then `pending`, warned). */ statusRaw: string | null /** `- **Demandeurs**` module codes. */ requesters: string[] /** `- **Sensibilité**` RHS verbatim (the owner entity's level, copied) — null when absent. */ sensitivityRaw: string | null classFile: string | null reason: string | null date: string | null decision: string | null attributes: ExtensionRequestAttribute[] /** Every `- **Label** : value` bullet, folded key → raw value. */ fields: Record /** 0-based line index of the heading, and of the first line AFTER the block. */ startLine: number endLine: number } export interface BaExtensionRequestsDoc { exists: boolean /** Path relative to baRoot (`/demande-extension.md`) — null when absent. */ relPath: string | null /** `code=` of the marker — null when absent/unmarked. */ app: string | null requests: BaExtensionRequest[] /** Every `### REQ-` heading, well-formed or not — the control counter's twin. */ headingsSeen: number warnings: string[] } // --------------------------------------------------------------------------- // Folding // --------------------------------------------------------------------------- function fold(s: string): string { return s .normalize('NFD') .replace(/[̀-ͯ]/g, '') .replace(/’/g, "'") .toLowerCase() .trim() } /** `en attente` / `validée` / `refusée` — FR and EN spellings; anything else → pending (said by the caller). */ export function parseExtensionRequestStatus(raw: string | null): ExtensionRequestStatus | null { if (raw === null) return null const head = fold(raw).split(/[—\-–(:;.]/)[0]?.trim() ?? '' if (/^(en attente|pending|ouverte?|open)$/.test(head)) return 'pending' if (/^(validee|validated|approved|approuvee|acceptee|accepted)$/.test(head)) return 'validated' if (/^(refusee|refused|rejected|rejetee|declined)$/.test(head)) return 'refused' return null } export function parseExtensionRequestTarget(raw: string): ExtensionRequestTarget { const core = /^core\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s+via\s+([A-Za-z0-9_-]+))?$/i.exec(raw.trim()) if (core) return { kind: 'core', entity: core[1]!, via: core[2] ?? null } const [app, module] = raw.trim().split('/') return { kind: 'application', app: (app ?? '').trim(), module: module !== undefined && module.trim() !== '' ? module.trim() : null } } export function renderExtensionRequestTarget(t: ExtensionRequestTarget): string { if (t.kind === 'core') return `Core ${t.entity}${t.via !== null ? ` via ${t.via}` : ''}` return t.module !== null ? `${t.app}/${t.module}` : t.app } // --------------------------------------------------------------------------- // Parsing // --------------------------------------------------------------------------- function splitCells(line: string): string[] { const m = line.match(TABLE_LINE_RE) if (!m) return [] return m[1]!.split('|').map((c) => c.trim()) } export function parseExtensionRequestsDoc(content: string, relPath: string | null): BaExtensionRequestsDoc { const warnings: string[] = [] const requests: BaExtensionRequest[] = [] const lines = content.split(/\r?\n/) const marker = EXTENSION_REQUEST_MARKER_RE.exec(content) const app = marker ? (/code=([^\s]+)/.exec(marker[1]!)?.[1] ?? null) : null if (!marker) warnings.push(`${relPath ?? EXTENSION_REQUESTS_FILE}: no \`\` marker.`) let headingsSeen = 0 let current: BaExtensionRequest | null = null let tableMode: 'header' | 'rows' | null = null const where = (i: number): string => `${relPath ?? EXTENSION_REQUESTS_FILE}:${i + 1}` const close = (endLine: number): void => { if (current === null) return current.endLine = endLine if (current.statusRaw === null) { warnings.push(`${where(current.startLine)}: ${current.code} has no \`- **Statut**\` — read as « en attente » (a request without a status freezes, never releases).`) } else if (parseExtensionRequestStatus(current.statusRaw) === null) { warnings.push(`${where(current.startLine)}: ${current.code} — Statut « ${current.statusRaw} » is not one of en attente | validée | refusée — read as « en attente ».`) } if (current.requesters.length === 0) warnings.push(`${where(current.startLine)}: ${current.code} names no \`- **Demandeurs**\` module.`) if (current.natureRaw !== null && parseExtensionRequestNature(current.natureRaw) === null) { warnings.push(`${where(current.startLine)}: ${current.code} — Nature « ${current.natureRaw} » is not one of extension | accès — read as « extension ».`) } // An ACCESS request carries no column by construction — the owner's consent is the ask. if (current.nature === 'extension' && current.attributes.length === 0) warnings.push(`${where(current.startLine)}: ${current.code} carries no attribute row — an extension request with nothing to add.`) requests.push(current) current = null tableMode = null } for (let i = 0; i < lines.length; i++) { const line = lines[i]! if (REQ_NEAR_MISS_RE.test(line)) { headingsSeen++ close(i) const h = REQ_HEADING_RE.exec(line) if (!h) { warnings.push(`${where(i)}: near-miss heading « ${line.trim()} » (expected \`### REQ-NNN — Entity → OWNER_APP[/MODULE]\`) — its block is NOT read until fixed.`) continue } current = { code: h[1]!, entity: h[2]!, targetRaw: h[3]!, target: parseExtensionRequestTarget(h[3]!), nature: 'extension', natureRaw: null, status: 'pending', statusRaw: null, requesters: [], sensitivityRaw: null, classFile: null, reason: null, date: null, decision: null, attributes: [], fields: {}, startLine: i, endLine: lines.length, } continue } if (/^#{1,3}\s/.test(line)) { close(i) continue } if (current === null) continue const field = FIELD_RE.exec(line) if (field) { tableMode = null const key = fold(field[1]!) const value = field[2]!.trim() current.fields[key] = value switch (key) { case 'nature': current.natureRaw = value current.nature = parseExtensionRequestNature(value) ?? 'extension' break case 'sensibilite': case 'sensitivity': current.sensitivityRaw = value || null break case 'statut': case 'status': current.statusRaw = value current.status = parseExtensionRequestStatus(value) ?? 'pending' break case 'demandeurs': case 'demandeur': case 'requesters': current.requesters = value .split(/[,;]/) .map((s) => s.trim().replace(/^`|`$/g, '')) .filter((s) => s !== '') break case 'classe': case 'class': { // « non développée — entité.md APP/MOD » is the rendered ABSENCE of a class. const v = value.replace(/^`|`$/g, '').trim() current.classFile = v === '' || /^non d[ée]velopp/i.test(v) ? null : v break } case 'motif': case 'reason': current.reason = value || null break case 'date': current.date = value || null break case 'decision': current.decision = value || null break default: break } continue } const cells = splitCells(line) if (cells.length > 0) { if (fold(cells[0] ?? '') === 'attribut') { tableMode = 'header' continue } if (tableMode === 'header') { if (/^:?-{2,}:?$/.test(cells[0] ?? '')) { tableMode = 'rows' continue } tableMode = 'rows' } if (tableMode === 'rows') { if (/^:?-{2,}:?$/.test(cells[0] ?? '')) continue const [name, type, constraints, computed] = cells if (!name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { warnings.push(`${where(i)}: ${current.code} — attribute row « ${line.trim()} » has no PascalCase name — row refused.`) continue } current.attributes.push({ name, type: type ?? '', constraints: constraints ?? '', computed: computed && computed !== '' ? computed : '—' }) } continue } if (line.trim() === '') continue tableMode = null } close(lines.length) return { exists: true, relPath, app, requests, headingsSeen, warnings } } export function extensionRequestsPath(baRoot: string, app: string): string { return join(baRoot, app, EXTENSION_REQUESTS_FILE) } /** Absence is a RESULT (`exists:false`, no request), never an error. */ export function loadAppExtensionRequests(baRoot: string, app: string): BaExtensionRequestsDoc { const p = extensionRequestsPath(baRoot, app) try { if (!existsSync(p) || !statSync(p).isFile()) return { exists: false, relPath: null, app: null, requests: [], headingsSeen: 0, warnings: [] } return parseExtensionRequestsDoc(readFileSync(p, 'utf8'), `${app}/${EXTENSION_REQUESTS_FILE}`) } catch { return { exists: false, relPath: null, app: null, requests: [], headingsSeen: 0, warnings: [] } } } /** The requests that freeze an application's development: pending or refused. */ export function blockingExtensionRequests(doc: BaExtensionRequestsDoc): BaExtensionRequest[] { return doc.requests.filter((r) => r.status === 'pending' || r.status === 'refused') } // --------------------------------------------------------------------------- // Expectations — what the requesting application's MCD asks of the owners // --------------------------------------------------------------------------- export interface ForeignModuleEntities { /** `APP/MOD` as on disk. */ modulePath: string entities: BaEntity[] } export interface ExtensionRequestExpectation { entity: string /** `ENT-NNN` of the requester's declaration (extension) — of the OWNER's entity (acces). */ entityCode: string /** The requesting module (folder code). */ requester: string target: ExtensionRequestTarget /** `extension` (columns to add) or `acces` (read access to a sensitive entity — no column). */ nature: ExtensionRequestNature /** The owner entity's resolved level + categories (`confidentiel — financier`) when it is ≥ confidentiel — null otherwise. */ sensitivity: string | null /** The owner's generated class when the code leg found it. */ classFile: string | null /** Where the owner was seen — the class under `Entities//`, the other `entité.md`, or both. */ evidence: Array<'code' | 'ba'> /** The requester's attributes the owner does NOT carry — verbatim rows of the requester's table (always [] for acces). */ attributes: BaEntityAttribute[] /** The requester's Description + Traçabilité, transcribed — never authored; '' when it wrote neither (the writer falls back to a statement of fact). */ reason: string } function appOf(modulePath: string): string { return modulePath.split('/')[0] ?? '' } function moduleOf(modulePath: string): string | null { const m = modulePath.split('/')[1] return m !== undefined && m !== '' ? m : null } /** The requester's own words — '' when its block carries neither a Description nor a Traçabilité. */ function transcribedReason(e: BaEntity): string { const parts: string[] = [] if (e.description !== null) parts.push(e.description) if (e.traceability.length > 0) parts.push(`Traçabilité : ${e.traceability.join(', ')}`) return parts.join(' — ') } /** What a block says when no requester wrote a word — a statement of fact, not prose. */ export function fallbackReason(requesters: readonly string[], app: string): string { return `attributs déclarés par ${requesters.map((m) => `${app}/${m}`).join(', ')} au-delà de la classe du propriétaire` } /** * For ONE requesting module: every (entity, owner application) pair where the * module declares attributes the owner does not carry. The code leg reads the * owner's generated class; the BA leg reads the owner's `entité.md` (an owner * not developed yet). A collision with NO extra attribute is pure reuse — no * request (CODE-001 already says « réutiliser »). Core artefacts are never * requested here: « both extend the same Core artefact » is DM-034's judgment. */ export function expectedExtensionRequests(input: { app: string module: string entities: BaEntity[] foreign: ForeignModuleEntities[] scan: DomainScan | null }): ExtensionRequestExpectation[] { const out = new Map() const keyOf = (entity: string, targetApp: string): string => `${fold(entity)}|${fold(targetApp).replace(/[^a-z0-9]/g, '')}` const merge = (e: BaEntity, target: ExtensionRequestTarget, classFile: string | null, evidence: 'code' | 'ba', extraNames: string[], sensitivity: string | null): void => { if (extraNames.length === 0 || target.kind !== 'application') return const k = keyOf(e.name, target.app) const rows = e.attributes.filter((a) => extraNames.some((n) => fold(n) === fold(a.name))) const existing = out.get(k) if (existing === undefined) { out.set(k, { entity: e.name, entityCode: e.code, requester: input.module, target, nature: 'extension', sensitivity, classFile, evidence: [evidence], attributes: rows, reason: transcribedReason(e) }) return } if (!existing.evidence.includes(evidence)) existing.evidence.push(evidence) if (existing.classFile === null && classFile !== null) existing.classFile = classFile if (existing.sensitivity === null && sensitivity !== null) existing.sensitivity = sensitivity if (existing.target.kind === 'application' && existing.target.module === null && target.module !== null) existing.target = target for (const r of rows) if (!existing.attributes.some((a) => fold(a.name) === fold(r.name))) existing.attributes.push(r) } for (const e of input.entities) { const names = e.attributes.map((a) => a.name) // Code leg — the owner's generated class under another application's folder. const owners = input.scan?.owners.get(e.name) ?? [] for (const o of owners) { if (o.app === undefined || sameSegment(o.app, input.app)) continue // Map the folder segment back to the BA application code when the tree knows it. const baApp = input.foreign.find((f) => sameSegment(appOf(f.modulePath), o.app!)) const targetApp = baApp !== undefined ? appOf(baApp.modulePath) : o.app const baMod = baApp !== undefined && o.module !== undefined ? input.foreign.find((f) => sameSegment(appOf(f.modulePath), o.app!) && moduleOf(f.modulePath) !== null && sameSegment(moduleOf(f.modulePath)!, o.module!)) : undefined const targetModule = baMod !== undefined ? moduleOf(baMod.modulePath) : (o.module ?? null) merge(e, { kind: 'application', app: targetApp, module: targetModule }, o.file, 'code', extraAttributes(names, o.source), null) } // BA leg — the owner's entité.md (developed or not). The owner's sensitivity // travels with the ask: the Studio sees WHAT it is asked to open. for (const f of input.foreign) { if (sameSegment(appOf(f.modulePath), input.app)) continue for (const owner of f.entities) { if (fold(owner.name) !== fold(e.name)) continue const have = new Set(owner.attributes.map((a) => fold(a.name))) const extra = names.filter((n) => !have.has(fold(n)) && !CS_FRAMEWORK_PROPS.has(fold(n))) merge(e, { kind: 'application', app: appOf(f.modulePath), module: moduleOf(f.modulePath) }, null, 'ba', extra, sensitivityLabelOf(owner, f.entities)) } } } return [...out.values()] } /** `confidentiel — financier` when the owner entity resolves ≥ confidentiel, else null. */ function sensitivityLabelOf(owner: BaEntity, siblings: readonly BaEntity[]): string | null { const r = resolveEntitySensitivity(owner, siblings) return isSensitive(r) ? formatSensitivity(r) : null } /** BA folder code → nav code (`HR_PORTAL` → `hr-portal`) — the spelling of a permission path segment. */ function navOf(folder: string): string { return folder.toLowerCase().replace(/_/g, '-') } // --------------------------------------------------------------------------- // Access expectations — a module REACHING a sensitive entity of another app // --------------------------------------------------------------------------- /** * For ONE requesting module: every (sensitive entity, owner application) pair * the module REACHES without owning it — three triggers, all mechanical: * (a) a `*→1` / `1→1` relation `scope cross-module (B/MOD)` onto an entity * of application B resolving ≥ confidentiel (lib/ba-sensitivity); * (b) a declared 360 related tab (`- **Onglet lié**`) onto such an entity — * the `app` token when written, else the single foreign module that * declares the entity (an ambiguous target is XD-005's business); * (c) a HUMAN rbac row with an app-qualified 4-segment path whose * (app, module, section) resolves to such an entity's section — the * list-screen rung needs the owner's screens (`foreignScreensOf`), the * kebab-plural rung always runs. * Same application = the modules share the entity (doctrine 2026-10-03), never * a request. The reason is a statement of fact per trigger, never prose. */ export function expectedAccessRequests(input: { app: string module: string entities: BaEntity[] screens: readonly ParsedScreen[] rbacRows: readonly RbacRow[] foreign: ForeignModuleEntities[] foreignScreensOf?: (appFolder: string) => readonly ParsedScreen[] }): ExtensionRequestExpectation[] { const out = new Map() const foreignOnly = input.foreign.filter((f) => !sameSegment(appOf(f.modulePath), input.app)) const keyOf = (entity: string, targetApp: string): string => `${fold(entity)}|${fold(targetApp).replace(/[^a-z0-9]/g, '')}` const ask = (owner: BaEntity, f: ForeignModuleEntities, reason: string): void => { const r = resolveEntitySensitivity(owner, f.entities) if (!isSensitive(r)) return const k = keyOf(owner.name, appOf(f.modulePath)) const existing = out.get(k) if (existing === undefined) { out.set(k, { entity: owner.name, entityCode: owner.code, requester: input.module, target: { kind: 'application', app: appOf(f.modulePath), module: moduleOf(f.modulePath) }, nature: 'acces', sensitivity: formatSensitivity(r), classFile: null, evidence: ['ba'], attributes: [], reason, }) return } if (!existing.reason.split(' ; ').includes(reason)) existing.reason = existing.reason === '' ? reason : `${existing.reason} ; ${reason}` } const ownersNamed = (name: string, appHint?: string): Array<{ owner: BaEntity; f: ForeignModuleEntities }> => { const hits: Array<{ owner: BaEntity; f: ForeignModuleEntities }> = [] for (const f of foreignOnly) { if (appHint !== undefined && !sameSegment(appOf(f.modulePath), appHint)) continue for (const owner of f.entities) if (fold(owner.name) === fold(name)) hits.push({ owner, f }) } return hits } // (a) cross-module relations onto another application. for (const e of input.entities) { for (const rel of e.relations) { if (rel.scope !== 'cross-module' || (rel.cardinality !== '*→1' && rel.cardinality !== '1→1')) continue const [targetApp, targetModule] = (rel.scopeDetail ?? '').split('/').map((s) => s.trim()) if (!targetApp || sameSegment(targetApp, input.app)) continue const f = foreignOnly.find((x) => sameSegment(appOf(x.modulePath), targetApp) && (targetModule === undefined || targetModule === '' || (moduleOf(x.modulePath) !== null && sameSegment(moduleOf(x.modulePath)!, targetModule)))) const owner = f?.entities.find((o) => fold(o.name) === fold(rel.targetEntity)) if (!f || !owner) continue const trace = e.traceability.length > 0 ? ` — Traçabilité : ${e.traceability.join(', ')}` : '' ask(owner, f, `accès en lecture via FK ${e.name}.${rel.fk} → ${rel.targetEntity}${trace}`) } } // (b) declared 360 tabs onto another application's entity. const localNames = new Set(input.entities.map((e) => fold(e.name))) for (const s of input.screens) { for (const tab of s.declaredTabs) { let hits = ownersNamed(tab.relatedEntity, tab.relatedApp) if (tab.relatedApp === undefined && localNames.has(fold(tab.relatedEntity))) continue // the module's own entity if (tab.relatedApp === undefined && new Set(hits.map((h) => appOf(h.f.modulePath))).size > 1) hits = [] // ambiguous — not ours to guess for (const h of hits) ask(h.owner, h.f, `accès via l’onglet lié « ${tab.label} » de ${s.code}`) } } // (c) human app-qualified rows onto another application's sensitive section. const screensCache = new Map() const screensOf = (appFolder: string): readonly ParsedScreen[] => { if (!screensCache.has(appFolder)) screensCache.set(appFolder, input.foreignScreensOf?.(appFolder) ?? []) return screensCache.get(appFolder)! } for (const row of input.rbacRows) { const segs = row.path.split('.') if (segs.length < 4) continue const [appNav, moduleNav, sectionNav] = segs as [string, string, string, ...string[]] if (appNav === navOf(input.app)) continue for (const f of foreignOnly) { const mod = moduleOf(f.modulePath) if (navOf(appOf(f.modulePath)) !== appNav || mod === null || navOf(mod) !== moduleNav) continue for (const owner of f.entities) { const res = resolveEntitySectionFolder([...screensOf(appOf(f.modulePath))], [sectionNav], owner.name, mod) if (res.section !== sectionNav) continue ask(owner, f, `accès via la permission \`${row.path}\` (${row.actorCode})`) } } } return [...out.values()] } // --------------------------------------------------------------------------- // Reconciliation — expectations × the doc on disk // --------------------------------------------------------------------------- export interface ExtensionRequestReconciliation { /** No block covers it (or a VALIDATED block lacks these attributes → a NEW block is due). */ missing: ExtensionRequestExpectation[] /** A PENDING block covers the pair but lacks attributes the MCD now declares. */ stale: Array<{ request: BaExtensionRequest; expectation: ExtensionRequestExpectation; missingAttributes: string[] }> /** A block covers the pair with every attribute. */ covered: Array<{ request: BaExtensionRequest; expectation: ExtensionRequestExpectation }> /** Blocks of the application by status (every requester). */ pending: BaExtensionRequest[] refused: BaExtensionRequest[] validated: BaExtensionRequest[] /** Blocks no expectation matches any more — the owner carries the columns (satisfied) or the requester dropped them. */ orphan: BaExtensionRequest[] } function requestMatches(r: BaExtensionRequest, x: ExtensionRequestExpectation): boolean { // An expectation without a nature (a hand-built one) is an extension, like a block without the line. if (r.nature !== (x.nature ?? 'extension')) return false if (fold(r.entity) !== fold(x.entity)) return false if (r.target.kind !== 'application' || x.target.kind !== 'application') return false return sameSegment(r.target.app, x.target.app) } export function reconcileExtensionRequests(expected: ExtensionRequestExpectation[], doc: BaExtensionRequestsDoc): ExtensionRequestReconciliation { const out: ExtensionRequestReconciliation = { missing: [], stale: [], covered: [], pending: doc.requests.filter((r) => r.status === 'pending'), refused: doc.requests.filter((r) => r.status === 'refused'), validated: doc.requests.filter((r) => r.status === 'validated'), orphan: [], } const matched = new Set() for (const x of expected) { const candidates = doc.requests.filter((r) => requestMatches(r, x)) if (candidates.length === 0) { out.missing.push(x) continue } const carried = new Set(candidates.flatMap((r) => r.attributes.map((a) => fold(a.name)))) const lacking = x.attributes.filter((a) => !carried.has(fold(a.name))) for (const c of candidates) matched.add(c) if (lacking.length === 0) { out.covered.push({ request: candidates[0]!, expectation: x }) continue } const pending = candidates.find((r) => r.status === 'pending') if (pending !== undefined) { out.stale.push({ request: pending, expectation: x, missingAttributes: lacking.map((a) => a.name) }) continue } if (candidates.some((r) => r.status === 'refused')) { // The owner said no — never re-ask mechanically; CODE-008 reports the refusal. out.covered.push({ request: candidates[0]!, expectation: x }) continue } // Validated block lacking the newer columns → a NEW request for the delta. out.missing.push({ ...x, attributes: lacking }) } for (const r of doc.requests) if (!matched.has(r)) out.orphan.push(r) return out } // --------------------------------------------------------------------------- // Rendering — the ONLY writer shape (derive-extension-request) // --------------------------------------------------------------------------- export function nextExtensionRequestCode(doc: BaExtensionRequestsDoc, taken: ReadonlySet = new Set()): string { let max = 0 for (const r of doc.requests) max = Math.max(max, Number(r.code.slice(4))) for (const t of taken) max = Math.max(max, Number(t.slice(4))) return `REQ-${String(max + 1).padStart(3, '0')}` } export function renderExtensionRequestBlock(r: { code: string entity: string target: ExtensionRequestTarget /** Absent / `extension` = the historical block (no Nature line written — byte-identical). */ nature?: ExtensionRequestNature statusRaw: string requesters: string[] /** The owner entity's level, copied — written when provided (both natures). */ sensitivity?: string | null classFile: string | null reason: string date: string decision: string | null attributes: ExtensionRequestAttribute[] }): string { const nature = r.nature ?? 'extension' const lines = [`### ${r.code} — ${r.entity} → ${renderExtensionRequestTarget(r.target)}`] if (nature === 'acces') lines.push(`- **Nature** : ${NATURE_LABELS.acces}`) lines.push(`- **Statut** : ${r.statusRaw}`, `- **Demandeurs** : ${r.requesters.join(', ')}`) if (r.sensitivity) lines.push(`- **Sensibilité** : ${r.sensitivity}`) lines.push( `- **Classe** : ${r.classFile !== null ? `\`${r.classFile}\`` : `non développée — entité.md ${renderExtensionRequestTarget(r.target)}`}`, `- **Motif** : ${r.reason}`, `- **Date** : ${r.date}`, ) if (r.decision !== null && r.decision !== '') lines.push(`- **Décision** : ${r.decision}`) // An access request carries no column: the owner's consent is the ask. if (nature === 'extension') { lines.push('', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|') for (const a of r.attributes) lines.push(`| ${a.name} | ${a.type} | ${a.constraints} | ${a.computed || '—'} |`) } lines.push('') return lines.join('\n') } export function renderExtensionRequestsDocHeader(app: string): string { return [``, `# Demandes d'extension — ${app}`, '', ''].join('\n') } export function toRequestAttribute(a: BaEntityAttribute): ExtensionRequestAttribute { return { name: a.name, type: a.type, constraints: a.constraints, computed: a.computed ?? '—' } }