#!/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 { parseArgs } from 'node:util' import { readFileSync } from 'node:fs' import { validate } from './validate.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) } } function main(): void { 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) } 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 })), } if (dryRun) { printEnvelope( generateEnvelope(COMMAND, { data: { ...data, dryRun: true, files: result.files.map((f) => f.path) }, warnings: result.warnings, }), ) process.exit(0) } // ② Atomic write (tmp + rename), then ⑤ re-read the i18n sibling and fail loud // if it is physically inconsistent (torn / concurrent write). const written = 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: [ `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.', ], }), ) } main()