#!/usr/bin/env node /** * cli:aggregate-component-registry — Entry point. * * Replaces the legacy "Frontend subagent hand-writes * src/extensions/componentRegistry.generated.ts" step. Determinism removes * three classes of bugs at once : * 1. Phantom imports (Vite fails at launch). * 2. Cross-module componentKey collisions (last register() wins, silent). * 3. Drift between disk and registry after a module is renamed/deleted. * * Invocation : * npx --prefer-offline tsx skills/development/frontend/routes/cli/aggregate-component-registry/index.ts \ * --project-path "" * * Optional flags : * --modules budgets,crm,catalogue exhaustive list (orphans → warning) * --out src/extensions/foo.ts override default output file * --exclude legacyRegistry.ts skip files (comma-separated basenames) * --dry-run report without writing */ import { parseArgsOrFail as parseArgs } from '../../../../../lib/cli-args.js' import { readFileSync } from 'node:fs' import { validate } from './validate.js' import { assertWebProjectRoot, FileSystemError } from '../../../../../lib/fs.js' import { generate, parseModuleResourcesConsistency } from './generate.js' import { writeGeneratedFilesAtomic } from './write.js' import { generateEnvelope, failGenerate, printEnvelope, } from '../../../../../lib/output.js' const COMMAND = 'aggregate-component-registry' /** * ⑤ Consistency canary. moduleResources.generated.ts carries a `// Modules: N` * header computed from the number of DISTINCT registered namespaces. A physically * torn / concurrently-overwritten file can show a header that disagrees with the * addClientResources body (the reported symptom was `Modules: 2` on a 6-namespace * body). Re-read the just-written file and fail loud on a mismatch — atomic writes * make this near-impossible, but a concurrent aggregator racing us would trip it. */ function assertModuleResourcesConsistent(absPath: string): void { const parsed = parseModuleResourcesConsistency(readFileSync(absPath, 'utf-8')) if (!parsed) return // header shape changed — nothing to assert const found = parsed.namespaces.length if (found > 0 && found !== parsed.declared) { printEnvelope( failGenerate(COMMAND, [ `Consistency canary FAILED for ${absPath}: header declares Modules: ${parsed.declared} but the file ` + `registers ${found} distinct namespace(s) (${parsed.namespaces.join(', ')}). The file is physically ` + `inconsistent — likely a torn / concurrent write. Re-run aggregate-component-registry (serialized).`, ]), ) process.exit(1) } } async function main(): Promise { const { values } = parseArgs({ options: { 'project-path': { type: 'string' }, modules: { type: 'string' }, out: { type: 'string' }, exclude: { type: 'string' }, 'dry-run': { type: 'boolean', default: false }, // Alias — every sibling CLI of the chain spells it `--dry_run` (snake); // this CLI historically used kebab. Both accepted, kebab kept canonical. dry_run: { type: 'boolean', default: false }, }, strict: true, }) const dryRun = values['dry-run'] || values.dry_run if (!values['project-path']) { printEnvelope(failGenerate(COMMAND, ['--project-path is required'])) process.exit(1) } const spec = { projectPath: values['project-path'], modules: values.modules ? values.modules.split(',').map((s) => s.trim()).filter(Boolean) : undefined, outFile: values.out, exclude: values.exclude ? values.exclude.split(',').map((s) => s.trim()).filter(Boolean) : [], } const validation = validate(spec) if (!validation.valid) { printEnvelope(failGenerate(COMMAND, validation.errors)) process.exit(1) } // Fail-closed: a mis-pointed --project-path (the repo root) used to write an EMPTY // aggregate into /src/extensions with success:true (audit 2026-09-24, B-WARN-15). try { assertWebProjectRoot(spec.projectPath) } catch (e) { printEnvelope(failGenerate(COMMAND, [e instanceof Error ? e.message : String(e)])) process.exit(1) } const result = generate(spec) // Hard errors — never write a file in this state. const errors: string[] = [] for (const u of result.unresolved) { errors.push( `Phantom import in ${u.module}Registry.ts: '${u.importPath}' does not resolve on disk. Either restore the missing page or remove the orphan import.`, ) } for (const c of result.collisions) { const owners = c.modules.map((m) => `${m}Registry.ts`).join(' + ') errors.push( `componentKey collision: '${c.key}' is registered by ${owners}. ` + `Most often this is a leftover pre-app-scoping FLAT 'Registry.ts' that survived a --force regen ` + `(files are now app-scoped '-Registry.ts', and --force never deletes) — delete the un-prefixed file. ` + `If instead two DIFFERENT apps legitimately produce this key, their componentKeys must already differ by app prefix ` + `(re-check the nav seed cross-app scoping). Last registration wins silently, so this is a hard error.`, ) } // Hard errors surfaced by the emitter (e.g. the i18n regression guard). errors.push(...result.errors) if (errors.length > 0) { printEnvelope(failGenerate(COMMAND, errors)) process.exit(1) } const totalKeys = result.parsed.reduce((acc, p) => acc + p.keys.length, 0) const data = { modulesAggregated: result.parsed.length, registeredKeys: totalKeys, modules: result.parsed.map((p) => ({ module: p.module, keys: p.keys.length })), } // Nothing to aggregate (generate.ts said so in `warnings`): success stays true — // nothing is wrong with the project, the invocation just found nothing — but the // remedy is named and NO file is touched (audit 2026-09-24, B-WARN-15 / QC D9). const nothingAggregated = result.parsed.length === 0 const nextSteps = nothingAggregated ? [ 'Run scaffold-routes for at least one module (it emits src/extensions/-Registry.ts), then re-run aggregate-component-registry.', 'If the registries live elsewhere, point --project-path at the web root that holds them (the parent of src/extensions).', ] : [ `Import the aggregator from src/main.tsx BEFORE createRoot: import './extensions/componentRegistry.generated';`, 'Run `npm run build` in the web app to confirm Vite resolves every lazy chunk.', ] if (dryRun) { printEnvelope( generateEnvelope(COMMAND, { data: { ...data, dryRun: true, files: result.files.map((f) => f.path) }, warnings: result.warnings, nextSteps, }), ) process.exit(0) } // ② Atomic write (tmp + rename), then ⑤ re-read the i18n sibling and fail loud // if it is physically inconsistent (torn / concurrent write). An empty file set // writes nothing — not even the directory. const written = result.files.length > 0 ? writeGeneratedFilesAtomic(spec.projectPath, result.files) : [] for (const abs of written) { if (abs.endsWith('moduleResources.generated.ts')) { assertModuleResourcesConsistent(abs) } } printEnvelope( generateEnvelope(COMMAND, { data, filesCreated: written, warnings: result.warnings, nextSteps, }), ) } // Every error thrown on the generate/write path (a `--out` escaping the web root // — safeJoinPath's FileSystemError —, an unreadable registry, a failed rename) // is answered as an envelope: success:false + errors[] on stdout, exit 1. Until // 2026-09-24 (QC D9) it surfaced as a raw stack trace on stderr with EMPTY // stdout — no caller could read what went wrong. A FileSystemError is a // controlled refusal worded by lib/fs (kept verbatim, plus the --out remedy); // anything else is genuinely unexpected. main().catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err) const error = err instanceof FileSystemError ? err.operation === 'validatePathSecurity' ? `${message} — --out must stay under --project-path (default: src/extensions/componentRegistry.generated.ts).` : message : `Unexpected error: ${message}` printEnvelope(failGenerate(COMMAND, [error])) process.exit(1) })