#!/usr/bin/env node /** * cli:derive-extension-request — entry point. * * The extension request of an application (doctrine 2026-10-03): a module * that declares, on an entity ANOTHER application owns, attributes that * application does not carry, REUSES the entity and asks for the columns * through `/demande-extension.md`. This CLI projects that ask — the * engine of CODE-008 in check mode, the writer /audit-fix executes in write * mode. The Studio reads the doc and owns its `Statut`. * * Invocation: * npx --prefer-offline tsx skills/business-analyse/create-data-model/cli/derive-extension-request/index.ts \ * --spec '{"baRoot":".smartstack/ba","app":"NOTES","module":"MEMOS","projectPath":".","mode":"check"}' \ * [--workdir ] * * - mode=check → READ-ONLY reconciliation; drift is DATA (exit 0). * - mode=write → writes `/demande-extension.md` ONLY: new blocks * « en attente », completed pending blocks; never Statut / Décision, never a * validated or refused block. */ import { parseArgs } from 'node:util' import { readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { executeEnvelope, failExecute, generateEnvelope, printEnvelope } from '../../../../lib/output.js' import { loadModuleEntities, type BaEntity } from '../../../../lib/ba-entities.js' import { EXTENSION_REQUESTS_FILE, parseExtensionRequestsDoc, type BaExtensionRequestsDoc, type ForeignModuleEntities } from '../../../../lib/ba-extension-requests.js' import { loadModuleRbacRows, type RbacRow } from '../../../../lib/ba-rbac-rows.js' import { loadScreens, type ParsedScreen } from '../../../../lib/ba-screens.js' import { domainOf } from '../../../../lib/domain-scan.js' import { entiteDocPath, listModuleFolders, validateSpec } from './validate.js' import { deriveExtensionRequests } from './execute.js' import type { DeriveExtensionRequestReport } from './types.js' const COMMAND = 'derive-extension-request' function listAppFolders(baRoot: string): string[] { return listModuleFolders(baRoot) } function main(): void { const { values } = parseArgs({ options: { spec: { type: 'string' }, workdir: { type: 'string' }, }, strict: true, }) if (!values.spec) { printEnvelope(failExecute(COMMAND, ['--spec is required'])) process.exit(1) } let raw: unknown try { raw = JSON.parse(values.spec) } catch { printEnvelope(failExecute(COMMAND, ['Invalid JSON in --spec'])) process.exit(1) } const validation = validateSpec(raw, values.workdir) if (!validation.valid || !validation.spec || !validation.resolvedBaRoot) { printEnvelope(failExecute(COMMAND, validation.errors)) process.exit(1) } const spec = validation.spec const baRoot = validation.resolvedBaRoot const warnings: string[] = [] // The application's modules (the requesters) and every other application's (the owners). // Screens (360 tabs) and HUMAN rbac rows feed the ACCESS triggers. const screensCache = new Map() const screensOfApp = (app: string): readonly ParsedScreen[] => { if (!screensCache.has(app)) screensCache.set(app, loadScreens(baRoot, app).screens) return screensCache.get(app)! } const modules: Array<{ module: string; entities: BaEntity[]; screens: ParsedScreen[]; rbacRows: RbacRow[] }> = [] for (const mod of listModuleFolders(join(baRoot, spec.app))) { if (entiteDocPath(join(baRoot, spec.app, mod)) === null) continue const loaded = loadModuleEntities(baRoot, spec.app, mod) if (loaded === null) continue modules.push({ module: mod, entities: loaded.entities, screens: screensOfApp(spec.app).filter((s) => s.module.toLowerCase() === mod.toLowerCase()), rbacRows: loadModuleRbacRows(baRoot, spec.app, mod).rows, }) for (const w of loaded.warnings) warnings.push(`${spec.app}/${mod}/entité.md: ${w}`) } const foreign: ForeignModuleEntities[] = [] for (const app of listAppFolders(baRoot)) { if (app.toLowerCase() === spec.app.toLowerCase()) continue for (const mod of listModuleFolders(join(baRoot, app))) { const loaded = loadModuleEntities(baRoot, app, mod) if (loaded === null) continue foreign.push({ modulePath: `${app}/${mod}`, entities: loaded.entities }) } } const scan = validation.resolvedProjectPath !== undefined ? domainOf(validation.resolvedProjectPath) : null const docPath = join(baRoot, spec.app, EXTENSION_REQUESTS_FILE) let rawDoc: string | null = null try { rawDoc = readFileSync(docPath, 'utf8') } catch { rawDoc = null } const doc: BaExtensionRequestsDoc = rawDoc !== null ? parseExtensionRequestsDoc(rawDoc, `${spec.app}/${EXTENSION_REQUESTS_FILE}`) : { exists: false, relPath: null, app: null, requests: [], headingsSeen: 0, warnings: [] } const { report, rewritten } = deriveExtensionRequests({ app: spec.app, focusModule: spec.module ?? null, modules, foreign, foreignScreensOf: screensOfApp, scan, doc, rawDoc, mode: spec.mode, today: spec.today ?? new Date().toISOString().slice(0, 10), warnings, }) const steps: string[] = [] if (report.totals.missing > 0) steps.push(`${report.totals.missing} demande(s) à écrire — ${spec.mode === 'check' ? '`"mode":"write"` les projette' : 'écrite(s)'} dans ${report.file} (Statut « en attente », le Studio tranche).`) const accessDue = report.missing.filter((x) => x.nature === 'acces').length if (accessDue > 0) steps.push(`dont ${accessDue} demande(s) d'ACCÈS — une entité ≥ confidentiel d'une autre application est atteinte (FK, onglet 360 ou permission) : le propriétaire consent, aucune colonne n'est ajoutée (CODE-009).`) if (report.totals.stale > 0) steps.push(`${report.totals.stale} bloc(s) en attente dont la table ne porte pas les colonnes nouvellement déclarées — ${spec.mode === 'check' ? 'write les complète' : 'complété(s)'}.`) if (report.totals.pending + report.totals.refused > 0) steps.push(`${report.totals.pending} en attente / ${report.totals.refused} refusée(s) : le développement de ${spec.app} est gelé (preflight-develop-plan) jusqu'à validation par le Studio.`) if (report.totals.orphan > 0) steps.push(`${report.totals.orphan} bloc(s) qu'aucune déclaration ne porte plus (propriétaire aligné, ou colonnes retirées) — à clore côté Studio, jamais supprimé ici.`) if (!report.codeLeg) steps.push('Jambe code non lue (projectPath absent) — les propriétaires non développés sont lus dans leur entité.md.') if (spec.mode === 'write') { const filesModified: string[] = [] if (rewritten !== null) { writeFileSync(docPath, rewritten, 'utf8') filesModified.push(docPath) } report.filesModified = filesModified printEnvelope( generateEnvelope(COMMAND, { data: { ...report.totals, report }, filesModified, warnings: report.warnings, nextSteps: [...steps, 'Relancer `"mode":"check"` — 0 err attendu ; CODE-008 lit le même moteur.'], }), ) process.exit(0) } printEnvelope( executeEnvelope(COMMAND, { success: true, data: { ...report.totals, upToDate: report.totals.errors === 0 }, report, warnings: report.warnings, nextSteps: report.totals.errors === 0 ? ['Aucune demande d’extension manquante.', ...steps] : [...steps, 'Le drift est une DONNÉE (exit 0) — le verdict appartient à /ba-audit-cross-ref-code (CODE-008).'], }), ) process.exit(0) } main()