/** * cli:derive-external-api-spec — execute.ts * * `check` reads the BA tree and reports, per entity, what is declared public * and what the scaffolder would emit. `write` records an approved decision as * the canonical `- **API externe**` bullet, in place, without touching anything * else in the document. * * It never DERIVES a public surface from the shape of an entity: exposing data * to a third party is a decision, not a property of the model. The CLI's job is * to make the decision explicit, machine-readable, and auditable (DEV-XAPI-010). */ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { loadModuleEntities, type BaEntity } from '../../../lib/ba-entities.js' import { loadScreens, type ParsedScreen } from '../../../lib/ba-screens.js' import { formatSensitivity, isRestricted, isSensitive, resolveEntitySensitivity, type EntitySensitivity } from '../../../lib/ba-sensitivity.js' import { parseExternalApiValue, renderExternalApiMarker, type PublicApiOperation, } from '../../../lib/external-api-catalog.js' import { pluralize, toKebabCase } from '../../../lib/string-utils.js' import type { Declaration, DeriveExternalApiSpecInput, DeriveReport, EntityView } from './types.js' const REQUIRED_RE = /obligatoire|requis|required|not\s*null/i const ENT_HEADING_RE = /^###\s+ENT-\d+\s*[—-]\s*([A-Za-z][A-Za-z0-9_]*)/ const MARKER_LINE_RE = /^\s*-\s*\*\*API\s+externe\*\*\s*:/i const FIELD_BULLET_RE = /^\s*-\s*\*\*[^*]+\*\*\s*:/ function listModules(baRoot: string, app: string): string[] { const appDir = join(baRoot, app) if (!existsSync(appDir)) return [] return readdirSync(appDir) .filter(name => !name.startsWith('_') && !name.startsWith('.')) .filter(name => { try { return statSync(join(appDir, name)).isDirectory() } catch { return false } }) .sort() } /** * The section is the MENU SECTION CODE, never the entity's plural. * * It is load-bearing twice over: the catalogue code is `{app}-{section}` and the * permission is `{app}.{module}.{section}.{action}` — the very constant * `scaffold-controller` compiled into `{Mod}Permissions.{Section}`. Guessing it * from the entity name happens to work when a section holds exactly one * entity named after it, and produces a spec that does not compile the moment * a section groups two entities or carries a name of its own (`facturation` * holding `Facture`). * * Resolution order, fail-closed: an explicit declaration wins; else the section * of a screen bound to the entity; else the module's single section; else * UNRESOLVED — reported, never invented. */ export type SectionSource = 'declared' | 'screen' | 'single-section' | 'unresolved' export function resolveSection( entity: string, module: string, screens: ParsedScreen[], declared?: string, ): { section: string | null; source: SectionSource } { if (declared) return { section: toKebabCase(declared), source: 'declared' } const inModule = screens.filter(s => s.module === module) const bound = inModule.find(s => s.entity === entity) // A resource-level screen sits at `section/sub-resource`; permissions are // section-grain, so the first segment is the one that matters. if (bound?.section) return { section: bound.section.split('/')[0], source: 'screen' } const sections = [...new Set(inModule.map(s => s.section.split('/')[0]).filter(Boolean))] if (sections.length === 1) return { section: sections[0], source: 'single-section' } return { section: null, source: 'unresolved' } } /** Default plural segment — used for messages only, never as a section. */ export function pluralSegmentOf(entity: string, plural?: string): string { return toKebabCase(plural ?? pluralize(entity)) } /** Single-column unique index ≠ tenant — the natural key the 409 guard rests on. */ export function naturalKeyOf(entity: BaEntity): string[] { for (const index of entity.indexes) { if (!index.unique) continue const fields = index.fields.filter(f => !/tenant/i.test(f)) if (fields.length >= 1 && fields.length <= 2) return fields } if (entity.initialValues?.key) return [entity.initialValues.key] return [] } /** * The sensitivity gate (lib/ba-sensitivity, DM-038 / DEV-XAPI-015): a * `restreint` entity is never exposable; a `confidentiel` one only on the * client's DATED decision carried by the `**API externe**` line itself. * Returns the refusal, or null when the exposure is sanctioned. */ export function exposureRefusal( entity: string, sensitivity: EntitySensitivity, operations: readonly string[], decision: { date: string | null; reason: string | null } | null, ): string | null { if (operations.length === 0) return null if (isRestricted(sensitivity)) { return `${entity}: ${formatSensitivity(sensitivity)} — a restreint entity is NEVER exposed to a third party, decision or not. Remove the **API externe** line, or requalify the entity (the client's decision, dated).` } if (isSensitive(sensitivity) && decision?.date === null) { return `${entity}: ${formatSensitivity(sensitivity)} — the « décision client » on the **API externe** line carries no date (an undated decision is a habit, not a decision): write \`— décision client du AAAA-MM-JJ : \`.` } if (isSensitive(sensitivity) && decision === null) { return `${entity}: ${formatSensitivity(sensitivity)} — a confidentiel entity is exposed only on the client's DATED decision, written on the line itself: \`- **API externe** : ${operations.join(', ')} — décision client du AAAA-MM-JJ : \`.` } return null } function viewOf( entity: BaEntity, siblings: readonly BaEntity[], module: string, file: string, resolved: { section: string | null; source: SectionSource }, ): EntityView { const rhs = entity.fields['api externe'] const marker = rhs === undefined ? null : parseExternalApiValue(rhs) const r = resolveEntitySensitivity(entity, siblings) const sensitivity: EntitySensitivity = { level: r.level, categories: [...r.categories] } const operations = marker?.operations ?? [] const decision = marker?.decision ?? null return { module, entity: entity.name, section: resolved.section, sectionSource: resolved.source, operations, granularity: marker?.granularity ?? 'operation', undeclared: marker === null, unknown: marker?.unknown ?? [], sensitivity, decision, refused: exposureRefusal(entity.name, sensitivity, operations, decision), fields: entity.attributes .filter(a => !/^id$/i.test(a.name)) .map(a => ({ name: a.name, type: a.type, required: REQUIRED_RE.test(a.constraints), })), naturalKey: naturalKeyOf(entity), file, } } /** * Insert or replace the marker bullet on ONE entity of a document. * Returns the rewritten source, or null when the entity heading is absent. */ export function applyMarker( source: string, entityName: string, operations: readonly PublicApiOperation[], granularity: 'operation' | 'resource', decision?: { date: string; reason?: string | null } | null, ): string | null { const lines = source.split(/\r?\n/) let start = -1 for (let i = 0; i < lines.length; i++) { const m = ENT_HEADING_RE.exec(lines[i]) if (m && m[1] === entityName) { start = i break } } if (start === -1) return null // The entity block ends at the next heading of the same or higher level. let end = lines.length for (let i = start + 1; i < lines.length; i++) { if (/^#{1,3}\s/.test(lines[i])) { end = i break } } const bullet = renderExternalApiMarker(operations, granularity, decision ?? null) for (let i = start; i < end; i++) { if (MARKER_LINE_RE.test(lines[i])) { lines[i] = bullet return lines.join('\n') } } // No marker yet: place it after the LAST field bullet of the block, so the // declaration sits with the entity's other properties rather than adrift. let insertAt = -1 for (let i = start + 1; i < end; i++) { if (FIELD_BULLET_RE.test(lines[i])) insertAt = i } if (insertAt === -1) { insertAt = start // Skip the blank line that usually follows a heading. if (lines[insertAt + 1] !== undefined && lines[insertAt + 1].trim() === '') insertAt += 1 } lines.splice(insertAt + 1, 0, bullet) return lines.join('\n') } export function execute(spec: DeriveExternalApiSpecInput): { report: DeriveReport; warnings: string[] } { const warnings: string[] = [] const errors: string[] = [] const modules = spec.moduleCode ? [spec.moduleCode] : listModules(spec.baRoot, spec.applicationCode) const views: EntityView[] = [] const filesModified: string[] = [] const byModule = new Map() for (const d of spec.declarations) { byModule.set(d.module, [...(byModule.get(d.module) ?? []), d]) } // The entity -> menu-section mapping lives in the screens, not in entite.md. const loadedScreens = loadScreens(spec.baRoot, spec.applicationCode) warnings.push(...loadedScreens.warnings) const sectionFor = (entity: string, module: string): { section: string | null; source: SectionSource } => resolveSection( entity, module, loadedScreens.screens, (byModule.get(module) ?? []).find(d => d.entity === entity)?.section, ) for (const module of modules) { const parsed = loadModuleEntities(spec.baRoot, spec.applicationCode, module) if (parsed === null) { warnings.push(`${spec.applicationCode}/${module}: no entité.md — nothing to read.`) continue } const rel = `${spec.applicationCode}/${module}/entité.md` if (spec.mode === 'write' && byModule.has(module)) { const abs = join(spec.baRoot, spec.applicationCode, module, 'entité.md') let source = readFileSync(abs, 'utf8') let dirty = false for (const d of byModule.get(module) ?? []) { // The sensitivity gate runs BEFORE the write: a declaration that would // expose a restreint entity, or a confidentiel one without a dated // decision, is refused — never recorded then reported. const target = parsed.entities.find(e => e.name === d.entity) if (target) { const r = resolveEntitySensitivity(target, parsed.entities) const refusal = exposureRefusal( d.entity, { level: r.level, categories: [...r.categories] }, d.operations, d.decision ? { date: d.decision.date, reason: d.decision.reason ?? null } : null, ) if (refusal !== null) { errors.push(`${rel}: declaration refused — ${refusal}`) continue } } const next = applyMarker(source, d.entity, d.operations, d.granularity, d.decision ?? null) if (next === null) { warnings.push(`${rel}: entity "${d.entity}" not found — declaration skipped rather than appended blindly.`) continue } if (next !== source) { source = next dirty = true } } if (dirty) { writeFileSync(abs, source, 'utf8') filesModified.push(rel) const reparsed = loadModuleEntities(spec.baRoot, spec.applicationCode, module) if (reparsed) { for (const e of reparsed.entities) views.push(viewOf(e, reparsed.entities, module, rel, sectionFor(e.name, module))) continue } } } for (const e of parsed.entities) views.push(viewOf(e, parsed.entities, module, rel, sectionFor(e.name, module))) } const published = views.filter(v => v.operations.length > 0) for (const v of published) { if (v.refused !== null) { errors.push(`${v.file}: ${v.refused}`) } if (v.fields.length === 0) { warnings.push(`${v.entity}: no attribute parsed from entité.md — the scaffolder spec would carry an empty fields[], which create/update cannot compile against.`) } if (v.operations.includes('create') && v.naturalKey.length === 0) { warnings.push(`${v.entity}: publishes create with no unique index in entité.md — a retried POST would duplicate (DEV-XAPI-009).`) } if (v.unknown.length > 0) { warnings.push(`${v.entity}: unreadable token(s) on the **API externe** line: ${v.unknown.join(', ')}.`) } if (v.section === null) { warnings.push( `${v.entity}: the menu section could not be resolved (no screen binds it and ${v.module} has more than one section), so it is EXCLUDED from the scaffolder spec. ` + `The section is not cosmetic: it is the catalogue code and the permission path, i.e. the very constant scaffold-controller compiled. ` + `Supply it on the declaration ({ module, entity, section }) rather than letting the plural be guessed.`, ) } } // Fail-closed: an unresolved section would emit a permission constant that // does not exist, i.e. a spec that cannot compile; a refused exposure // (sensitivity) never reaches the scaffolder either — and the envelope says // so (errors[], success:false), never a partial spec in silence. const emittable = published.filter(v => v.section !== null && v.refused === null) const scaffoldSpec = emittable.length === 0 ? null : { appCode: spec.appCode ?? '', applicationCode: spec.applicationCode, projectPath: spec.projectPath ?? '', resources: emittable.map(v => ({ entity: v.entity, module: v.module, section: v.section as string, operations: v.operations, granularity: v.granularity, naturalKey: v.naturalKey, fields: v.fields, })), } return { report: { applicationCode: spec.applicationCode, entities: views, publishedCount: published.length, scaffoldSpec, filesModified, errors, }, warnings, } }