#!/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()