/** * cli:derive-sensitivity-specs — derive.ts * * Pure core + a thin filesystem wrapper (the derive-code-specs discipline). * Nothing is re-parsed here: the entity blocks come from `lib/ba-entities`, * the level from `lib/ba-sensitivity.resolveEntitySensitivity` (the SAME * ladder every audit rule reads), the label from `lib/display-field` (the * SAME cascade scaffold-business's `pickDisplayField` applies), the pagespec * block from `lib/pagespec-blocks`' anchored grammar. */ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs' import { basename, join, relative } from 'node:path' import { parseEntityDoc, type BaEntity } from '../../../lib/ba-entities.js' import { EntitySensitivitySchema, isPublic, isRestricted, isSensitive, matchSensitiveAttribute, resolveEntitySensitivity, type EntitySensitivity, } from '../../../lib/ba-sensitivity.js' import { preferredDisplayFieldsFor } from '../../../lib/display-field.js' import { FENCED_JSON_RE } from '../../../lib/pagespec-blocks.js' import { extractFencedJson } from '../compute-page-diff/scan-pagespecs.js' import type { DeriveSensitivitySpecsInput, DeriveSensitivitySpecsReport, PagespecKeyOutcome, SensitivityDerivation, } from './types.js' /** Every entité.md under the module (module level + sections + resources). */ export function listEntiteDocs(moduleRoot: string): string[] { const out: string[] = [] const visit = (dir: string, depth: number): void => { if (depth > 3) return const direct = join(dir, 'entité.md') if (existsSync(direct) && statSync(direct).isFile()) out.push(direct) let names: string[] = [] try { names = readdirSync(dir, { withFileTypes: true }) .filter((e) => e.isDirectory()) .map((e) => e.name) } catch { return } for (const name of names) { if (name.startsWith('_') || name.startsWith('.') || name === 'pagespecs' || name === 'node_modules') continue visit(join(dir, name), depth + 1) } } visit(moduleRoot, 0) return out.sort() } /** `**Personne** : mandatory|optional` — the label is a Core projection (FirstName + LastName), never a stored attribute. */ function isPersonProjection(e: BaEntity): boolean { return e.person !== null && /^(mandatory|optional)\b/i.test(e.person.trim()) } /** * The attribute that LABELS a row — `**Affichage**` first, else the * display-family cascade of the entity's classification (what * scaffold-business resolves). `: Id` is the conscious GUID opt-out. */ export function resolveDisplayField(e: BaEntity): string | null { if (e.display !== null) return e.display const names = new Map(e.attributes.map((a) => [a.name.toLowerCase(), a.name])) for (const candidate of preferredDisplayFieldsFor(e.classification)) { const hit = names.get(candidate.toLowerCase()) if (hit !== undefined) return hit } return null } /** Resolve every non-interne entity of the module (public, confidentiel, restreint). */ export function collectSensitivities(moduleRoot: string, only?: string): SensitivityDerivation[] { const out: SensitivityDerivation[] = [] for (const abs of listEntiteDocs(moduleRoot)) { const rel = relative(moduleRoot, abs).replace(/\\/g, '/') const { entities } = parseEntityDoc(readFileSync(abs, 'utf-8'), rel) for (const e of entities) { if (only !== undefined && e.name !== only) continue const r = resolveEntitySensitivity(e, entities) if (r.level === 'interne') continue const sensitivity: EntitySensitivity = { level: r.level, categories: [...r.categories] } const displayField = resolveDisplayField(e) const blocked: string[] = [] if (isSensitive(r)) { if (displayField === null && !isPersonProjection(e)) { blocked.push( 'no neutral label — author `- **Affichage** : ` (or `: Id`, the conscious GUID opt-out) on the entity: ' + 'the lookup and the restricted search have nothing safe to target, and scaffold-business fails closed on this shape', ) } const trigger = displayField !== null && displayField.toLowerCase() !== 'id' ? matchSensitiveAttribute(displayField) : undefined if (trigger) { blocked.push( `the label « ${displayField} » IS a sensitive attribute (${trigger.category}) — a lookup would print it on every combobox: ` + 'author `- **Affichage**` on a neutral attribute, or `: Id`', ) } } const usable = isSensitive(r) && blocked.length === 0 out.push({ entity: e.name, file: rel, sensitivity, source: r.source, displayField, scaffoldBusiness: usable ? { sensitivity } : null, scaffoldComponent: usable ? { sensitivity } : null, extensionSearch: usable && displayField !== null ? { level: r.level, displayField } : null, blocked, }) } } return out.sort((a, b) => a.entity.localeCompare(b.entity)) } /** The module's pagespec files. */ export function listPagespecs(moduleRoot: string): string[] { const dir = join(moduleRoot, 'pagespecs') if (!existsSync(dir)) return [] return readdirSync(dir) .filter((f) => f.endsWith('.md')) .map((f) => join(dir, f)) .filter((p) => statSync(p).isFile()) .sort() } /** The key value a pagespec SHOULD carry — the canonical `{level, categories}` (unknown slots of an existing object survive). */ function desiredKey(cur: unknown, sensitivity: EntitySensitivity): Record { const base: Record = typeof cur === 'object' && cur !== null && !Array.isArray(cur) ? { ...cur } : {} base['level'] = sensitivity.level base['categories'] = [...sensitivity.categories] return base } function sameSensitivity(cur: unknown, sensitivity: EntitySensitivity): boolean { const parsed = EntitySensitivitySchema.safeParse(cur) if (!parsed.success) return false return parsed.data.level === sensitivity.level && JSON.stringify(parsed.data.categories) === JSON.stringify(sensitivity.categories) } /** * Reconcile ONE pagespec's `sensitivity` key against the derived map. Returns * the rewritten markdown (byte-identical when nothing changed) + the outcome. * A pagespec of an `interne` (or vanished) entity must carry NO key. */ export function reconcilePagespecKey( md: string, path: string, sensitivities: ReadonlyMap, write: boolean, ): { md: string; outcome: PagespecKeyOutcome | null } { const json = extractFencedJson(md) if (json === null) return { md, outcome: null } let block: Record try { block = JSON.parse(json) as Record } catch { return { md, outcome: null } // malformed JSON is audit-prd's concern } const entity = typeof block.entity === 'string' ? block.entity : basename(path).split('.')[0]! const pagespec = basename(path) const want = sensitivities.get(entity) const cur = block.sensitivity const hasKey = cur !== undefined const rewrite = (value: Record | undefined): string => { if (value === undefined) delete block.sensitivity else block.sensitivity = value const m = new RegExp(FENCED_JSON_RE.source, FENCED_JSON_RE.flags).exec(md)! // Function replacer discipline (lib/pagespec-blocks): never a string replacer on serialised JSON. return md.slice(0, m.index) + '```json\n' + JSON.stringify(block, null, 2) + '\n```' + md.slice(m.index + m[0].length) } if (want === undefined) { if (!hasKey) return { md, outcome: null } if (!write) return { md, outcome: { pagespec, entity, status: 'stale' } } return { md: rewrite(undefined), outcome: { pagespec, entity, status: 'stale-removed' } } } if (!hasKey) { if (!write) return { md, outcome: { pagespec, entity, status: 'missing' } } return { md: rewrite(desiredKey(cur, want)), outcome: { pagespec, entity, status: 'added' } } } if (sameSensitivity(cur, want)) return { md, outcome: { pagespec, entity, status: 'already' } } if (!write) return { md, outcome: { pagespec, entity, status: 'drift' } } return { md: rewrite(desiredKey(cur, want)), outcome: { pagespec, entity, status: 'healed' } } } /** Filesystem wrapper. */ export function deriveSensitivitySpecs(input: DeriveSensitivitySpecsInput): DeriveSensitivitySpecsReport { const warnings: string[] = [] const entities = collectSensitivities(input.moduleRoot, input.entity) const keyed = new Map(entities.map((e) => [e.entity, e.sensitivity])) const pagespecs: PagespecKeyOutcome[] = [] const drift: PagespecKeyOutcome[] = [] let added = 0 let staleRemoved = 0 for (const path of listPagespecs(input.moduleRoot)) { if (input.entity !== undefined && basename(path).split('.')[0] !== input.entity) continue const md = readFileSync(path, 'utf-8') const { md: md2, outcome } = reconcilePagespecKey(md, path, keyed, input.mode === 'derive') if (outcome === null) continue pagespecs.push(outcome) if (outcome.status === 'added') added++ if (outcome.status === 'stale-removed') staleRemoved++ if (outcome.status === 'missing' || outcome.status === 'stale' || outcome.status === 'drift') drift.push(outcome) if (input.mode === 'derive' && md2 !== md) writeFileSync(path, md2, 'utf-8') } const blockedEntities = entities.filter((e) => e.blocked.length > 0) if (blockedEntities.length > 0) { warnings.push( `${blockedEntities.length} sensitive entit${blockedEntities.length === 1 ? 'y' : 'ies'} without a safe label — fix entité.md first: ` + blockedEntities.map((e) => `${e.entity} (${e.blocked.join(' ; ')})`).join(' | '), ) } return { moduleRoot: input.moduleRoot, entities, pagespecs, drift, totals: { sensitive: entities.filter((e) => isSensitive(e.sensitivity)).length, restricted: entities.filter((e) => isRestricted(e.sensitivity)).length, public: entities.filter((e) => isPublic(e.sensitivity)).length, blocked: blockedEntities.length, pagespecs: pagespecs.length, added, staleRemoved, drift: drift.length, }, warnings, } }