#!/usr/bin/env node /** * cli:derive-related-tabs — entry point. * * Deterministic companion of /ba-create-screen for the 360 related tabs * (« Onglet lié » bullets of detail/edit SmartForms): * * - mode=derive → compute CANDIDATE related tabs from the module's data * model (entité.md relation graph + screen registry + rbac). * - mode=validate → check the PREREQUISITES of the DECLARED tabs * (RTV-001..009, + pagespec cross-check with `pagespecs`). * * Invocation: * npx --prefer-offline tsx skills/business-analyse/create-screen/cli/derive-related-tabs/index.ts \ * --spec '{"baRoot":".smartstack/ba","app":"CRM","module":"CLIENTS","mode":"derive"}' \ * [--workdir ] * * READ-ONLY — this CLI never writes a file. Exit code: 0 on any successful * run, even with violations — violations are DATA; enforcement belongs to the * audits (SCR-009/014, PRD-103..105). Only spec/parse/IO errors fail. */ import { parseArgs } from 'node:util' import { readdirSync } from 'node:fs' import { join } from 'node:path' import { executeEnvelope, failExecute, printEnvelope } from '../../../../lib/output.js' import { validateSpec } from './validate.js' import { loadEntityContents, parseEntities } from './relations.js' import { loadScreens, loadScreensAcrossApps } from './screens.js' import { loadRbacPermissions, loadRbacPermissionsAcrossApps } from './rbac.js' import { deriveCandidates } from './derive.js' import { checkDeclaredTabs, loadPagespecs } from './check.js' import type { DeriveRelatedTabsReport } from './types.js' const COMMAND = 'derive-related-tabs' 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 = [...validation.warnings] // --- Load ground truth (all reads, no writes) --- // The entity graph has ALWAYS spanned every application (a cross-module relation may be // written in another app's module). The screen registry and the permission union used to // stop at the scoped application, so a target living elsewhere read as missing — the // asymmetry that made cross-application tabs unverifiable. `includeCrossApp` lines the // three up. const graph = parseEntities(loadEntityContents(baRoot)) const { screens, warnings: screenWarnings } = spec.includeCrossApp ? loadScreensAcrossApps(baRoot) : loadScreens(baRoot, spec.app) warnings.push(...screenWarnings) // A Relations entry the grammar could not read is a 360 tab that will never // be derived — ba-relations counts the loss, this envelope carries it. warnings.push(...graph.warnings) const rbac = spec.includeCrossApp ? loadRbacPermissionsAcrossApps(baRoot) : loadRbacPermissions(baRoot, spec.app) if (spec.mode === 'derive') { const report = deriveCandidates({ spec, graph, screens, rbac }) printEnvelope( executeEnvelope(COMMAND, { success: true, data: { ...report.totals }, report, warnings: [...warnings, ...report.warnings], nextSteps: [ 'Present the candidates to the user (per /ba-create-screen) — displayMode and inclusion are SUGGESTIONS, the BA decides.', 'Author each retained candidate as a `- **Onglet lié « Label »** : entité X, FK fkId[, affichage mode] → SCR-… (`perm`)` bullet on the detail/edit screen.', 'A screen with incoming 1:N relations but deliberately no tabs needs a `- **Sans onglets liés** : ` marker (RTV-007).', 'Re-run this CLI with `"mode":"validate"` afterwards — SCR-009/014 and PRD-103..105 enforce the result.', ], }), ) process.exit(0) } // --- mode=validate --- const pagespecs = spec.pagespecs ? loadPagespecs(join(baRoot, spec.app, spec.module)) : undefined const report = checkDeclaredTabs({ spec, graph, screens, rbac, pagespecs, pagespecsOf: spec.pagespecs ? (app, module) => loadModulePagespecs(baRoot, app, module) : undefined, }) printEnvelope( executeEnvelope(COMMAND, { success: true, data: { ...report.summary, violations: report.violations.length }, report, warnings: [...warnings, ...report.warnings], nextSteps: report.violations.length === 0 ? ['No violations — the declared related tabs are prerequisite-complete.'] : [ 'Violations are DATA — this CLI always exits 0; enforcement belongs to the audits (SCR-009/014 via /ba-audit-screens, PRD-103..105 via /ba-audit-prd).', 'Fix screen.md (« Onglet lié » bullets / `Sans onglets liés` marker), entité.md Relations, rbac.md or the pagespecs, then re-run.', ], }), ) process.exit(0) } /** * Pagespecs of ANOTHER (app, module) — the routing checks resolve a tab's target where the * target actually lives, not where the page does. * * BA folders are UPPERCASE by convention while a tab names its application and module in * kebab, so the lookup is case-insensitive over the real directory listing rather than a * guessed `toUpperCase()`: a folder that does not follow the convention still resolves * instead of silently reading as absent. */ function loadModulePagespecs(baRoot: string, app: string, module: string): Map | null { const appDir = resolveDirCaseInsensitive(baRoot, app) if (!appDir) return null const moduleDir = resolveDirCaseInsensitive(appDir, module) if (!moduleDir) return null return loadPagespecs(moduleDir) } function resolveDirCaseInsensitive(parent: string, name: string): string | null { let entries try { entries = readdirSync(parent, { withFileTypes: true }) } catch { return null } const wanted = name.toLowerCase() const hit = entries.find((e) => e.isDirectory() && e.name.toLowerCase() === wanted) return hit ? join(parent, hit.name) : null } main()