#!/usr/bin/env node /** * cli:derive-action-specs — entry point. * * Deterministically derives every generator's custom-action input from a * module's pagespecs, so ba-develop never hand-derives (the silent-drop bug * behind "page actions are not implemented"). The orchestrator splices each * entity's `controller` / `business` / `apiClient` arrays VERBATIM into the * matching scaffold-* spec; navigate buttons ride along in the pageSpec to * scaffold-component. * * Invocation: * npx --prefer-offline tsx skills/ba-develop/cli/derive-action-specs/index.ts \ * --spec '{"moduleRoot":".smartstack/ba//"}' * * Exit code: always 0 on a successful derivation — an empty result is a valid * "this module has no custom actions" answer. Coverage ENFORCEMENT is the * fail-closed audits' job (DEV-API-010 backend, audit-dev-actions-alignment * frontend); this CLI removes the derivation as a failure mode, the gates * remove the omission as one. */ import { parseArgs } from 'node:util' import { validate } from './validate.js' import { deriveActionSpecs } from './derive.js' import { executeEnvelope, failExecute, printEnvelope } from '../../../lib/output.js' import type { DeriveActionSpecsReport } from './types.js' const COMMAND = 'derive-action-specs' function main(): void { const { values } = parseArgs({ options: { spec: { 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 = validate(raw) if (!validation.valid || !validation.spec) { printEnvelope(failExecute(COMMAND, validation.errors)) process.exit(1) } const report = deriveActionSpecs(validation.spec) const nextSteps: string[] = [ 'Phase 2a: splice each entity.controller / entity.business array VERBATIM into the scaffold-controller / scaffold-business `customActions` field.', 'Phase 3a: splice each entity.apiClient array into scaffold-api-client `entities[].customActions`, and pass the WHOLE pageSpec (with actions[]) to scaffold-component unchanged.', 'Then run the coverage gates — audit-dev-api --rules DEV-API-010 (backend) + audit-dev-actions-alignment (frontend). A missing action is a BLOCKING failure (failureKind: custom-action-missing).', ] if (report.backfilled.length > 0) { const total = report.totals.backfilledParams nextSteps.unshift( validation.spec.mode === 'derive' ? `${total} lookup param(s) across ${report.backfilled.length} pagespec(s) gained their resolved navRoute/apiEndpoint — a legitimate spec-drift (compute-page-diff sees it). Re-scaffold those pages so the dialog hits the real route.` : `CHECK ONLY — ${total} lookup param(s) across ${report.backfilled.length} pagespec(s) carry NO route and would fall back to the {module}/{english-plural} guess (§28, the 404'ing combobox). Re-run with mode:"derive" to persist the resolution into the pagespecs.`, ) } // An unresolvable lookup target cannot be healed downstream: scaffold-component // would emit the rebuilt guess and audit-dev-wire would flag the 404 far from // here. Name them now, with the target that failed. const unresolvedLookups = report.entities.flatMap((e) => e.dialogLookupParams.filter((p) => p.unresolved).map((p) => `${e.entity}.${p.actionCode}.${p.param} → ${p.entity} (${p.unresolved})`), ) if (unresolvedLookups.length > 0) { nextSteps.unshift( `BLOCKING — ${unresolvedLookups.length} lookup param(s) could not be resolved: ${unresolvedLookups.join(' ; ')}. Author the param's apiEndpoint in the pagespec (the target's REAL controller route) — never let the generator guess it.`, ) } // A malformed custom action is NOT a silent skip: it means the pagespec did // not honor PageCustomActionSchema, so the action never reaches a scaffolder. // Surface it loudly — ba-audit-prd treats it as NO-GO, and ba-develop Phase 2a // hard-fails the entity (NEVER hand-writes the backend to compensate). if (report.rejected.length > 0) { // The per-item `message` is authoritative — a rejection may be a SCHEMA // failure OR a permission-BINDING guard hit (module/section mismatch, // resource grain, missing ucReference): never assume one class. nextSteps.unshift( `BLOCKING — ${report.rejected.length} custom action(s) were REJECTED and dropped: ` + report.rejected.map((r) => `${r.file}:"${r.code}" (${r.path}: ${r.message})`).join(' ; ') + '. Fix the pagespec actions[] in ba-create-prd per each message. Schema shapes: workflowTransition.fromStatus must be an ARRAY; OMIT workflowTransition for non-workflow actions (null is rejected); a custom kind:api action needs its ucReference. Binding guard: the permission must root at the spec\'s own module.section, section grain (PRD-128). Do NOT hand-write the backend to work around this.', ) } printEnvelope( executeEnvelope(COMMAND, { success: true, data: { entities: report.totals.entities, apiActions: report.totals.apiActions, navigateActions: report.totals.navigateActions, rejectedActions: report.totals.rejectedActions, backfilledParams: report.totals.backfilledParams, }, report, warnings: report.warnings, nextSteps, }), ) process.exit(0) } main()