/** * cli:derive-action-specs — derive.ts (pure logic). * * Reads every `/pagespecs/*.md`, validates each `actions[]` entry * against the canonical `PageCustomActionSchema`, groups by entity, then applies * the `lib/page-spec-actions.ts` projections to emit each generator's exact * custom-action input. Malformed pagespecs / actions warn-and-skip — a single * bad file or action never aborts the derivation (same posture as compute-page-diff). */ import { readFileSync, existsSync, readdirSync, statSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { PageCustomActionSchema, actionPermissionBindingIssue, splitActions, toControllerCustomAction, toBusinessCustomAction, toApiClientCustomAction, type PageCustomAction, } from '../../../lib/page-spec-actions.js' // Reuse the exact same fenced-JSON extractor + page-key helper the diff CLI uses, // so derivation reads pagespecs byte-identically to the rest of the pipeline. import { extractFencedJson, pageKeyFromFilename } from '../compute-page-diff/scan-pagespecs.js' import { resolveLookupTarget } from '../derive-fk-specs/derive.js' import type { FkToSpec } from '../derive-fk-specs/types.js' import { toPascalCase } from '../../../lib/string-utils.js' import type { BackfilledPagespec, DeriveActionSpecsInput, DeriveActionSpecsReport, DerivedEntityActions, DialogLookupParam, NavigateActionRef, RejectedAction, } from './types.js' interface CollectedAction { entity: string pageKey: string action: PageCustomAction /** Pagespec top-level module/section — the permission-binding referential. */ module?: string section?: string } interface CollectResult { collected: CollectedAction[] rejected: RejectedAction[] warnings: string[] } /** Read + validate every pagespec action in the module (optionally one entity). */ export function collectActions(moduleRoot: string, entityFilter?: string): CollectResult { const dir = join(moduleRoot, 'pagespecs') const out: CollectResult = { collected: [], rejected: [], warnings: [] } if (!existsSync(dir)) return out let entries: string[] try { entries = readdirSync(dir) } catch (err) { out.warnings.push(`cannot read ${dir}: ${(err as Error).message}`) return out } for (const name of entries) { if (!name.endsWith('.md')) continue const full = join(dir, name) try { if (!statSync(full).isFile()) continue } catch { continue } let content: string try { content = readFileSync(full, 'utf8') } catch (err) { out.warnings.push(`${name}: cannot read file: ${(err as Error).message}`) continue } const json = extractFencedJson(content) if (json === null) { out.warnings.push(`${name}: no fenced \`\`\`json block found — skipped`) continue } let parsed: unknown try { parsed = JSON.parse(json) } catch (err) { out.warnings.push(`${name}: invalid JSON: ${(err as Error).message} — skipped`) continue } const obj = parsed && typeof parsed === 'object' ? (parsed as Record) : null if (!obj) { out.warnings.push(`${name}: pagespec is not a JSON object — skipped`) continue } const actions = Array.isArray(obj.actions) ? (obj.actions as unknown[]) : [] if (actions.length === 0) continue const entity = typeof obj.entity === 'string' ? obj.entity : null if (!entity) { out.warnings.push(`${name}: ${actions.length} action(s) but no top-level "entity" field — skipped`) continue } if (entityFilter && entity !== entityFilter) continue const pageKey = pageKeyFromFilename(name) for (const rawAction of actions) { const v = PageCustomActionSchema.safeParse(rawAction) if (!v.success) { const ra = rawAction as { code?: unknown } const code = typeof ra.code === 'string' ? ra.code : '(unknown)' const issue = v.error.issues[0] const path = issue.path.join('.') || 'root' out.warnings.push( `${name}: action "${code}" invalid (${path}: ${issue.message}) — skipped`, ) out.rejected.push({ file: name, code, path, message: issue.message }) continue } out.collected.push({ entity, pageKey, action: v.data, ...(typeof obj.module === 'string' ? { module: obj.module } : {}), ...(typeof obj.section === 'string' ? { section: obj.section } : {}), }) } } return out } function navigateRef(a: PageCustomAction): NavigateActionRef { return { code: a.code, scope: a.scope, labelKey: a.labelKey, permission: a.permission, ...(a.targetScreen ? { targetScreen: a.targetScreen } : {}), ...(a.targetRoute ? { targetRoute: a.targetRoute } : {}), } } /** Resolve a lookup target ONCE per (entity, module) pair for the whole run. */ function memoResolve( memo: Map, moduleRoot: string, entity: string, module: string | undefined, warnings: string[], ): FkToSpec | { reason: string } { const key = `${entity}|${module ?? ''}` const hit = memo.get(key) if (hit) return hit const resolved = resolveLookupTarget(moduleRoot, entity, { ...(module !== undefined ? { module } : {}) }, warnings) memo.set(key, resolved) return resolved } /** Matches the FIRST fenced ```json block — the pagespec machine block. */ const JSON_BLOCK_RE = /```json\s*\r?\n([\s\S]*?)\r?\n```/ /** * Backfill each `type:lookup` payload parameter with its RESOLVED * `navRoute` (+ `apiEndpoint`) — IN THE PAGESPEC, the SSOT (same discipline * as derive-filter-fks for reference filters). * * Why persist instead of splicing in memory: scaffold-component's endpoint * ladder falls back to a rebuilt `{module}/{english-plural}` when the param * carries no route. As long as the resolution lived only in the orchestrator's * hands, ANY run outside that path — /ui-design re-scaffold, manual * regeneration, a heal loop re-entering Phase 3 alone — regenerated the 404'ing * dialog combobox §28 fixed. An authored `apiEndpoint` is never touched; * re-runs are idempotent (byte-identical file ⇒ no write). */ export function backfillActionLookups( moduleRoot: string, mode: 'check' | 'derive', entityFilter: string | undefined, warnings: string[], /** Memo shared with the dialogLookupParams pass — resolving a target walks the * whole BA tree on a miss, and doing it twice per param also duplicated every * warning line in the envelope. */ memo: Map = new Map(), ): BackfilledPagespec[] { const dir = join(moduleRoot, 'pagespecs') if (!existsSync(dir)) return [] let entries: string[] try { entries = readdirSync(dir).sort() } catch { return [] } const out: BackfilledPagespec[] = [] for (const name of entries) { if (!name.endsWith('.md')) continue const full = join(dir, name) try { if (!statSync(full).isFile()) continue } catch { continue } let md: string try { md = readFileSync(full, 'utf8') } catch { continue } const json = extractFencedJson(md) if (json === null) continue let block: Record try { block = JSON.parse(json) as Record } catch { continue // malformed JSON is collectActions' / audit-prd's finding } if (entityFilter && block.entity !== entityFilter) continue const actions = Array.isArray(block.actions) ? block.actions : [] if (actions.length === 0) continue const touched: string[] = [] for (const rawAction of actions) { if (typeof rawAction !== 'object' || rawAction === null) continue const action = rawAction as Record const code = typeof action.code === 'string' ? action.code : '(unknown)' const params = Array.isArray(action.payloadParameters) ? action.payloadParameters : [] for (const rawParam of params) { if (typeof rawParam !== 'object' || rawParam === null) continue const param = rawParam as Record if (param.type !== 'lookup') continue if (typeof param.entity !== 'string' || param.entity === '') continue // An authored endpoint is the author's decision — never overwritten. if (typeof param.apiEndpoint === 'string' && param.apiEndpoint !== '') continue const resolved = memoResolve( memo, moduleRoot, param.entity, typeof param.module === 'string' ? param.module : undefined, warnings, ) if ('reason' in resolved) continue // surfaced through dialogLookupParams const nextNavRoute = resolved.navRoute const nextEndpoint = resolved.apiEndpoint if (nextNavRoute === undefined && nextEndpoint === undefined) continue if (param.navRoute === nextNavRoute && param.apiEndpoint === nextEndpoint) continue if (nextNavRoute !== undefined) param.navRoute = nextNavRoute if (nextEndpoint !== undefined) param.apiEndpoint = nextEndpoint touched.push(`${code}.${typeof param.name === 'string' ? param.name : '(unnamed)'}`) } } if (touched.length === 0) continue let written = false if (mode === 'derive') { const m = JSON_BLOCK_RE.exec(md) if (m) { const next = md.slice(0, m.index) + '```json\n' + JSON.stringify(block, null, 2) + '\n```' + md.slice(m.index + m[0].length) if (next !== md) { try { writeFileSync(full, next, 'utf8') written = true } catch (err) { warnings.push(`${name}: cannot write the backfilled pagespec: ${(err as Error).message}`) } } } } out.push({ file: name, params: touched, written }) } return out } /** * Derive every generator's custom-action input from a module's pagespecs. * The result is grouped by entity; each entity's arrays are spliced VERBATIM * into the matching scaffold-* spec by the orchestrator. */ export function deriveActionSpecs(input: DeriveActionSpecsInput): DeriveActionSpecsReport { // Backfill FIRST: collectActions then reads pagespecs that already carry the // resolved routes, so dialogLookupParams and the persisted pagespec agree by // construction instead of by convention. const backfillWarnings: string[] = [] const lookupMemo = new Map() const backfilled = backfillActionLookups( input.moduleRoot, input.mode ?? 'check', input.entity, backfillWarnings, lookupMemo, ) const { collected, rejected, warnings } = collectActions(input.moduleRoot, input.entity) warnings.unshift(...backfillWarnings) // Group every contributing action by entity (an action MAY appear on several // views of the same entity — splitActions de-duplicates by wire identity). const byEntity = new Map }>() for (const c of collected) { // Permission-binding guard (kind:api only — a navigate action's permission // legitimately roots at its TARGET screen). The generators keep ONLY the // action segment and re-derive module/section from the spec: a permission // authored elsewhere would be SILENTLY REBOUND to a different constant // (H3), a resource-grain one silently WIDENED to its section (H4). Reject // via the BLOCKING rejected[] channel — never let the rebind ship. if (c.action.kind === 'api' && c.module !== undefined && c.section !== undefined) { const issue = actionPermissionBindingIssue(c.action, c.module, c.section) if (issue !== null) { rejected.push({ file: `${c.pageKey}`, code: c.action.code, path: 'actions[].permission', message: issue.reason === 'resource-grain' ? `permission '${issue.actual}' is RESOURCE-grain — no generated code path enforces the resource segment, the compiled constant silently widens to the section. Author the section-grain permission ('${issue.expected}'); resource-grain enforcement does not exist yet (PRD-128).` : `permission '${issue.actual}' roots at another ${issue.reason === 'module-mismatch' ? 'module' : 'section'} — the generated constant is compiled from THIS pagespec's module/section ('${issue.expected}'), so the authored permission would be silently rebound. Move the action to the section that owns the permission, or author this section's own permission (cross-section intent must be modelled explicitly).`, }) continue } } let bucket = byEntity.get(c.entity) if (!bucket) { bucket = { actions: [], pages: new Set() } byEntity.set(c.entity, bucket) } bucket.actions.push(c.action) bucket.pages.add(c.pageKey) } const entities: DerivedEntityActions[] = [] let apiTotal = 0 let navTotal = 0 for (const [entity, bucket] of [...byEntity.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { const { apiActions, navigateActions } = splitActions(bucket.actions) // Duplicate-hook guard: the api-client names its hook `use{Pascal(code)} // {Entity}` on the CODE alone, and scaffold-business names the service // method on it too — two api actions sharing a code (e.g. the same action // declared at header AND row scope) survive the wire-identity dedup but // would emit two `useSuspendAlertRule` declarations (TS2393) and two // colliding service members. Reject every duplicate BEYOND the first, // structurally (BLOCKING via the rejected[] channel) — the PRD must keep // ONE scope per action code (drop the header entry: a row action carries // the id + motive a header action cannot supply). const byCode = new Map() const dedupedApiActions: PageCustomAction[] = [] for (const a of apiActions) { const first = byCode.get(a.code) if (first) { rejected.push({ file: `${entity} pagespecs`, code: a.code, path: 'actions', message: `duplicate action code '${a.code}' (${first.scope} + ${a.scope} scope) would emit the same use${toPascalCase(a.code)}${entity} hook twice (TS2393) — keep ONE scope per code in the pagespec`, }) continue } byCode.set(a.code, a) dedupedApiActions.push(a) } apiTotal += dedupedApiActions.length navTotal += navigateActions.length // §28 — dialog LOOKUP parameters: resolve each `type:lookup` param's // target through the SAME resolver as the FK channel (derive-fk-specs), // never a reconstructed `{module}/{english-plural}` guess. The orchestrator // splices `navRoute`/`apiEndpoint` back onto the pageSpec's // payloadParameters before scaffold-component (verbatim, like the three // generator arrays). An authored `apiEndpoint` is never overridden. const memo = lookupMemo const dialogLookupParams: DialogLookupParam[] = [] for (const a of dedupedApiActions) { for (const p of a.payloadParameters ?? []) { if (p.type !== 'lookup' || typeof p.entity !== 'string' || p.entity === '') continue if (p.apiEndpoint) { dialogLookupParams.push({ actionCode: a.code, param: p.name, entity: p.entity, apiEndpoint: p.apiEndpoint }) continue } const resolvedTarget = memoResolve(memo, input.moduleRoot, p.entity, p.module, warnings) if ('reason' in resolvedTarget) { dialogLookupParams.push({ actionCode: a.code, param: p.name, entity: p.entity, unresolved: resolvedTarget.reason }) continue } dialogLookupParams.push({ actionCode: a.code, param: p.name, entity: resolvedTarget.entity, module: resolvedTarget.module, ...(resolvedTarget.navRoute ? { navRoute: resolvedTarget.navRoute } : {}), ...(resolvedTarget.apiEndpoint ? { apiEndpoint: resolvedTarget.apiEndpoint } : {}), }) } } entities.push({ entity, controller: dedupedApiActions.map(a => toControllerCustomAction(a, entity)), business: dedupedApiActions.map(a => toBusinessCustomAction(a, entity)), apiClient: dedupedApiActions.map(a => toApiClientCustomAction(a, entity)), navigate: navigateActions.map(navigateRef), dialogLookupParams, pagesWithActions: [...bucket.pages].sort(), }) } return { moduleRoot: input.moduleRoot, entities, backfilled, totals: { entities: entities.length, apiActions: apiTotal, navigateActions: navTotal, rejectedActions: rejected.length, backfilledParams: backfilled.reduce((n, b) => n + b.params.length, 0), }, rejected: [...rejected], warnings: [...warnings], } }