/** * cli:derive-extension-request — execute.ts * * Pure: inputs in, report + rewritten document out. No I/O here. * * The write is a PROJECTION: a new block carries the requester's own attribute * rows (verbatim), its own Description + Traçabilité as the motive, the Studio's * field left at « en attente ». A pending block whose delta grew is completed * (its Statut / Décision / Motif / Date kept verbatim). Validated and refused * blocks are never touched; validated-and-lacking becomes a NEW block (the * lib's reconciliation already says so). Codes are never reused. */ import type { BaEntity } from '../../../../lib/ba-entities.js' import type { RbacRow } from '../../../../lib/ba-rbac-rows.js' import type { ParsedScreen } from '../../../../lib/ba-screens.js' import { EXTENSION_REQUESTS_FILE, STATUS_LABELS, expectedAccessRequests, expectedExtensionRequests, fallbackReason, nextExtensionRequestCode, reconcileExtensionRequests, renderExtensionRequestBlock, renderExtensionRequestTarget, renderExtensionRequestsDocHeader, toRequestAttribute, type BaExtensionRequestsDoc, type ExtensionRequestExpectation, type ForeignModuleEntities, } from '../../../../lib/ba-extension-requests.js' import type { DomainScan } from '../../../../lib/domain-scan.js' import { summarizeExpectation, summarizeRequest, type DeriveExtensionRequestReport } from './types.js' export interface DeriveExtensionRequestInputs { app: string focusModule: string | null /** Every module of the application carrying an entité.md — with its screens (360 tabs) and HUMAN rbac rows for the ACCESS triggers. */ modules: Array<{ module: string; entities: BaEntity[]; screens?: ParsedScreen[]; rbacRows?: RbacRow[] }> /** Every module of the OTHER applications. */ foreign: ForeignModuleEntities[] /** Screens of a foreign application (the list-screen rung of the permission trigger). */ foreignScreensOf?: (appFolder: string) => readonly ParsedScreen[] scan: DomainScan | null doc: BaExtensionRequestsDoc rawDoc: string | null mode: 'check' | 'write' today: string warnings: string[] } const fold = (s: string): string => s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim() /** One block per (nature, entity, owner application) — an extension and an access on the same entity are two blocks. */ const pairKey = (x: ExtensionRequestExpectation): string => `${x.nature}|${fold(x.entity)}|${renderExtensionRequestTarget(x.target).toLowerCase().replace(/[^a-z0-9]/g, '')}` /** Expectations of every module, MERGED per (nature, entity, owner application): one block, several requesters. */ function mergeAcrossModules(perModule: ExtensionRequestExpectation[][]): Array { const out = new Map() for (const list of perModule) { for (const x of list) { const k = pairKey(x) const existing = out.get(k) if (existing === undefined) { out.set(k, { ...x, attributes: [...x.attributes], evidence: [...x.evidence], requesters: [x.requester] }) continue } if (!existing.requesters.includes(x.requester)) existing.requesters.push(x.requester) for (const a of x.attributes) if (!existing.attributes.some((b) => fold(b.name) === fold(a.name))) existing.attributes.push(a) for (const e of x.evidence) if (!existing.evidence.includes(e)) existing.evidence.push(e) if (existing.classFile === null && x.classFile !== null) existing.classFile = x.classFile if (existing.sensitivity === null && x.sensitivity !== null) existing.sensitivity = x.sensitivity if (x.nature === 'acces') { // An access motive is a list of facts (one per trigger) — every requester's facts travel. for (const part of x.reason.split(' ; ')) { if (part !== '' && !existing.reason.split(' ; ').includes(part)) existing.reason = existing.reason === '' ? part : `${existing.reason} ; ${part}` } } else if (existing.reason === '' && x.reason !== '') { // The first requester that wrote a Description/Traçabilité gives the motive. existing.reason = x.reason } } } return [...out.values()] } export function deriveExtensionRequests(input: DeriveExtensionRequestInputs): { report: DeriveExtensionRequestReport; rewritten: string | null } { const perModule = input.modules.map((m) => [ ...expectedExtensionRequests({ app: input.app, module: m.module, entities: m.entities, foreign: input.foreign, scan: input.scan }), ...expectedAccessRequests({ app: input.app, module: m.module, entities: m.entities, screens: m.screens ?? [], rbacRows: m.rbacRows ?? [], foreign: input.foreign, ...(input.foreignScreensOf !== undefined ? { foreignScreensOf: input.foreignScreensOf } : {}), }), ]) const expected = mergeAcrossModules(perModule) const rec = reconcileExtensionRequests(expected, input.doc) const file = `${input.app}/${EXTENSION_REQUESTS_FILE}` const targetOf = (x: ExtensionRequestExpectation): string => renderExtensionRequestTarget(x.target) const report: DeriveExtensionRequestReport = { mode: input.mode, app: input.app, modules: input.modules.map((m) => m.module), file, fileExists: input.doc.exists, codeLeg: input.scan !== null, expected: expected.map((x) => summarizeExpectation(x, targetOf(x))), missing: rec.missing.map((x) => summarizeExpectation(x, targetOf(x))), stale: rec.stale.map((s) => ({ request: s.request.code, missingAttributes: s.missingAttributes })), covered: rec.covered.map((c) => summarizeRequest(c.request)), pending: rec.pending.map(summarizeRequest), refused: rec.refused.map(summarizeRequest), validated: rec.validated.map(summarizeRequest), orphan: rec.orphan.map(summarizeRequest), writes: { added: [], updated: [] }, filesModified: [], warnings: [...input.warnings, ...input.doc.warnings], totals: { expected: expected.length, missing: rec.missing.length, stale: rec.stale.length, covered: rec.covered.length, pending: rec.pending.length, refused: rec.refused.length, validated: rec.validated.length, orphan: rec.orphan.length, errors: rec.missing.length + rec.stale.length, }, } if (input.focusModule !== null && !input.modules.some((m) => m.module.toLowerCase() === input.focusModule!.toLowerCase())) { report.warnings.push(`${input.app}/${input.focusModule} carries no entité.md — the other modules of the application were projected.`) } if (input.mode === 'check' || (rec.missing.length === 0 && rec.stale.length === 0)) { return { report, rewritten: null } } // ---- write --------------------------------------------------------------- const lines = input.rawDoc !== null ? input.rawDoc.split(/\r?\n/) : renderExtensionRequestsDocHeader(input.app).split('\n') // Stale pending blocks — completed in place, bottom-up so indices stay valid. const staleSorted = [...rec.stale].sort((a, b) => b.request.startLine - a.request.startLine) for (const s of staleSorted) { const r = s.request const added = s.expectation.attributes.filter((a) => s.missingAttributes.some((n) => fold(n) === fold(a.name))).map(toRequestAttribute) const block = renderExtensionRequestBlock({ code: r.code, entity: r.entity, target: r.target, nature: r.nature, sensitivity: r.sensitivityRaw ?? s.expectation.sensitivity, statusRaw: r.statusRaw ?? STATUS_LABELS.pending, requesters: [...new Set([...r.requesters, ...((expected.find((x) => pairKey(x) === pairKey(s.expectation)) as { requesters?: string[] } | undefined)?.requesters ?? [])])], classFile: r.classFile ?? s.expectation.classFile, reason: r.reason ?? (s.expectation.reason || fallbackReason(r.requesters, input.app)), date: r.date ?? input.today, decision: r.decision, attributes: [...r.attributes, ...added], }) lines.splice(r.startLine, r.endLine - r.startLine, ...block.split('\n')) report.writes.updated.push(r.code) } // New blocks — appended, next free code each. const taken = new Set() const trimmed = [...lines] while (trimmed.length > 0 && trimmed[trimmed.length - 1]!.trim() === '') trimmed.pop() const appended: string[] = [] for (const x of rec.missing) { const code = nextExtensionRequestCode(input.doc, taken) taken.add(code) const merged = expected.find((e) => pairKey(e) === pairKey(x)) as { requesters?: string[] } | undefined const requesters = merged?.requesters ?? [x.requester] const block = renderExtensionRequestBlock({ code, entity: x.entity, target: x.target, nature: x.nature, statusRaw: STATUS_LABELS.pending, requesters, sensitivity: x.sensitivity, classFile: x.classFile, reason: x.reason || fallbackReason(requesters, input.app), date: input.today, decision: null, attributes: x.attributes.map(toRequestAttribute), }) appended.push('', ...block.split('\n')) report.writes.added.push(code) } const out = [...trimmed, ...appended] while (out.length > 0 && out[out.length - 1]!.trim() === '') out.pop() const eol = input.rawDoc !== null && input.rawDoc.includes('\r\n') ? '\r\n' : '\n' return { report, rewritten: `${out.join(eol)}${eol}` } }