/** * 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 { 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 [] } function viewOf( entity: 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) return { module, entity: entity.name, section: resolved.section, sectionSource: resolved.source, operations: marker?.operations ?? [], granularity: marker?.granularity ?? 'operation', undeclared: marker === null, unknown: marker?.unknown ?? [], 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', ): 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) 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 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) ?? []) { const next = applyMarker(source, d.entity, d.operations, d.granularity) 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, module, rel, sectionFor(e.name, module))) continue } } } for (const e of parsed.entities) views.push(viewOf(e, module, rel, sectionFor(e.name, module))) } const published = views.filter(v => v.operations.length > 0) for (const v of published) { 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. const emittable = published.filter(v => v.section !== 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, }, warnings, } }