/** * cli:aggregate-component-registry — generate.ts * * Glob → parse → verify → emit. * * 1. Glob `src/extensions/*Registry.ts` (excluding the generated aggregator). * 2. Parse each file: extract `PageRegistry.register('', )` calls * and the `import('')` literals from the matching `lazy(...)` or * `lazyWithRetry(...)` blocks (both are tracked — see audit.ts regex). * Regexes mirror those in audit-dev-frontend's audit.ts (single source). * 3. Verify every `import()` resolves on disk under the project's `@/` * alias (= `src/`). Phantom imports = hard error (Vite fails at launch). * 4. Verify no two modules registered the same `` — silent collision is * impossible to debug at runtime (last registration wins). * 5. Emit a consolidated `src/extensions/componentRegistry.generated.ts` * that re-exports every per-module `*Registry` via a side-effect import. * Bun-style ordering: alphabetical, single source of truth for `main.tsx`. */ import { PAGE_REGISTER_RE } from '../../../../../lib/routes-registry.js' import { parseRegistrySource, PER_MODULE_REGISTRY_RE } from '../../../../../lib/registry-index.js' import fs from 'node:fs' import path from 'node:path' import { toPascalCase } from '../../../../../lib/string-utils.js' import { type AggregateComponentRegistryInput, type GeneratedFile, type ParsedRegistry, type ResolvedImport, } from './types.js' // Tolerates the optional 3rd options argument scaffold-routes emits for mobile // metadata — `PageRegistry.register('key', Page, { mobile: { … } })`. Without // it every 3-arg registration is INVISIBLE here and the aggregated registry // comes out empty (every route a silent spinner). The lazy `[\s\S]*?` + // backtracking swallows one level of nested braces (our emitted literal). // The register-call regex is the shared SSOT (lib/routes-registry) — the // smoke registry↔menu coverage parses the same shape. const REGISTER_RE = PAGE_REGISTER_RE const LAZY_IMPORT_RE = /import\(\s*['"`]([^'"`]+)['"`]\s*\)/g // The per-module filename contract is the lib SSOT — the layout guard below // and this discovery must never disagree on what "per-module" means. const REGISTRY_FILE_RE = PER_MODULE_REGISTRY_RE export interface GenerateResult { files: GeneratedFile[] parsed: ParsedRegistry[] unresolved: ResolvedImport[] collisions: Array<{ key: string; modules: string[] }> warnings: string[] /** Hard errors surfaced by the emitter (e.g. the i18n regression guard). The * caller (index.ts) merges these into its error set and refuses to write. */ errors: string[] } export function generate(spec: AggregateComponentRegistryInput): GenerateResult { const extensionsDir = path.join(spec.projectPath, 'src', 'extensions') const outFile = spec.outFile ?? 'src/extensions/componentRegistry.generated.ts' const outAbs = path.join(spec.projectPath, outFile) const outBasename = path.basename(outAbs) // 1. Discover registry files const allFiles = fs.existsSync(extensionsDir) ? fs.readdirSync(extensionsDir).filter((f) => f.endsWith('.ts')) : [] const excludeSet = new Set([outBasename, ...spec.exclude]) const candidates = allFiles .filter((f) => !excludeSet.has(f)) .filter((f) => REGISTRY_FILE_RE.test(f)) // 1b. Fail-closed layout guard — NEVER overwrite an aggregate that registers // pages of its OWN. Apps generated under the legacy MCP flow ship a // MONOLITHIC componentRegistry.generated.ts registering every page inline // (and zero per-module *Registry.ts): re-emitting the aggregate from the // per-module discovery would UNREGISTER EVERY PAGE — a blank app (the // PickEBike incident). `overwriteLegacyAggregate` is reserved for // split-component-registry's in-process re-aggregation (see types.ts). if (spec.overwriteLegacyAggregate !== true && fs.existsSync(outAbs)) { const selfRegistrations = parseRegistrySource(fs.readFileSync(outAbs, 'utf-8'), { file: outFile, registryAbsPath: outAbs, webRoot: spec.projectPath, }) if (selfRegistrations.length > 0) { const error = candidates.length === 0 ? `registry.legacy-monolith guard: ${outFile} on disk registers ${selfRegistrations.length} componentKey(s) ` + `INLINE (legacy MCP-era monolithic layout) and NO per-module *Registry.ts exists under src/extensions/. ` + `Overwriting it with an aggregate of 0 module registries would UNREGISTER EVERY PAGE (blank app). ` + `Migrate first: npx --prefer-offline tsx skills/development/frontend/routes/cli/split-component-registry/index.ts ` + `--spec '{"projectPath":""}', then re-run aggregate-component-registry. ` + `Never bypass with --exclude or --out.` : `registry.mixed-layout guard: ${outFile} registers ${selfRegistrations.length} componentKey(s) of its own ` + `WHILE per-module *Registry.ts files exist (partial migration or a hand edit). The aggregate must never ` + `carry registrations of its own — re-aggregating now would DROP the inline ones. Run ` + `split-component-registry (idempotent) to fold them into per-module files, then re-run.` return { files: [], parsed: [], unresolved: [], collisions: [], warnings: [], errors: [error] } } } let registryFiles: string[] const warnings: string[] = [] if (spec.modules) { const wanted = new Set(spec.modules.map((m) => `${m}Registry.ts`)) registryFiles = Array.from(wanted).filter((f) => candidates.includes(f)) // docs-* registries are emitted by /documentation (scaffold-doc), outside // any ba-develop module list — a scoped --modules run must NEVER drop them // or the doc pages silently lose their route + i18n namespace. for (const f of candidates) { if (/^docs-/.test(f) && !wanted.has(f)) { registryFiles.push(f) warnings.push(`Including ${f} although absent from --modules — doc-page registries (docs-*) are always aggregated.`) } } const missing = Array.from(wanted).filter((f) => !candidates.includes(f)) for (const m of missing) { warnings.push(`Requested module registry not found on disk: ${m}`) } const orphans = candidates.filter((f) => !wanted.has(f) && !/^docs-/.test(f)) for (const o of orphans) { warnings.push( `Found ${o} on disk but it was not in --modules — likely orphan from a deleted module. Either re-include it or remove the file.`, ) } } else { registryFiles = candidates } registryFiles.sort() // 2. Parse each registry const parsed: ParsedRegistry[] = registryFiles.map((file) => { const abs = path.join(extensionsDir, file) const content = fs.readFileSync(abs, 'utf-8') const moduleName = file.replace(/Registry\.ts$/, '') const keys: string[] = [] const lazyImports: string[] = [] REGISTER_RE.lastIndex = 0 let m: RegExpExecArray | null while ((m = REGISTER_RE.exec(content)) !== null) keys.push(m[1]) LAZY_IMPORT_RE.lastIndex = 0 while ((m = LAZY_IMPORT_RE.exec(content)) !== null) lazyImports.push(m[1]) return { file, module: moduleName, keys, lazyImports } }) // 3. Resolve every lazy import const unresolved: ResolvedImport[] = [] for (const reg of parsed) { for (const importPath of reg.lazyImports) { const resolved = resolveLazyImport(importPath, spec.projectPath) if (!resolved) { unresolved.push({ module: reg.module, importPath, resolved: null }) } } } // 4. Detect key collisions across modules const keyOwners = new Map() for (const reg of parsed) { for (const k of reg.keys) { const owners = keyOwners.get(k) ?? [] owners.push(reg.module) keyOwners.set(k, owners) } } const collisions = Array.from(keyOwners.entries()) .filter(([, owners]) => owners.length > 1) .map(([key, modules]) => ({ key, modules })) // 5. Emit consolidated file (only if no errors — caller checks unresolved + collisions) const files: GeneratedFile[] = [] const errors: string[] = [] if (unresolved.length === 0 && collisions.length === 0) { const lines = parsed.map((p) => `import './${p.module}Registry';`) // i18n — register every module's locale bundles through addClientResources, // the ONLY channel that survives the SDK's i18next init (see // buildModuleResourcesFile). Emitted as a sibling file and imported at the // END of this aggregator: src/main.tsx imports componentRegistry.generated // AFTER , so the registration runs after the SDK init. // Without it the SDK clobbers the module namespaces → pages render raw keys. const i18nContent = buildModuleResourcesFile(spec.projectPath) const i18nFile = outFile.replace(/[^/]+$/, 'moduleResources.generated.ts') const i18nImport = i18nContent ? `\nimport './moduleResources.generated';\n` : '' // i18n regression / ordering guards. When this run resolved NO locale bundle we // would emit componentRegistry WITHOUT the i18n import — legitimate for an app // with no business i18n, but a RED FLAG when a real moduleResources.generated.ts // already exists on disk (torn/concurrent write, or the aggregator ran before // the locale JSON was scaffolded in Phase 3a). if (!i18nContent) { const i18nAbs = path.join(spec.projectPath, i18nFile) let stale = '' try { stale = fs.readFileSync(i18nAbs, 'utf-8') } catch { /* no prior sibling on disk */ } if (stale.includes('addClientResources')) { // ② fail-loud invariant: a prior run registered namespaces here, but this // run sees none → emitting componentRegistry without the import would turn // that registration into dead code (raw keys). Hard error → no write. errors.push( `i18n regression guard: ${i18nFile} already registers namespaces via addClientResources on disk, ` + `but this run resolved NO locale bundle — emitting ${outFile} WITHOUT its import would orphan that ` + `registration (dead code → business pages render RAW KEYS). This is the signature of a concurrent / ` + `partial aggregator run, or an aggregator run before the locale JSON was scaffolded. Re-run ` + `aggregate-component-registry (serialized) after the module locale bundles exist ` + `(src/i18n/locales//*.json).`, ) } else if (parsed.length > 0 && fs.existsSync(path.join(spec.projectPath, ...I18N_LOCALES_SEGMENTS))) { // ④ ordering guard: registries exist and a locales dir exists, yet no // bundle matched. When business pages are ALSO on disk this cannot be a // legitimate "app without business i18n" — the locale JSON was never // scaffolded (or landed in a phantom web/-web/src/i18n path, // pre-5.11 scaffold-component bug) → hard error, no write. Without // business pages it stays a non-fatal ordering diagnostic. const message = `Aggregated ${parsed.length} module registr${parsed.length === 1 ? 'y' : 'ies'} but found NO locale ` + `bundle under src/i18n/locales/*/*.json — ${i18nFile} will not be emitted and business pages ` + `may render raw keys. If this app has business i18n, the aggregator likely ran before the locale JSON ` + `was scaffolded; re-run it after the locales exist.` if (hasBusinessPages(spec.projectPath)) { errors.push( `i18n ordering guard: business pages exist under src/pages but NO locale bundle was found. ` + `Scaffold the module locale JSON first (scaffold-component emits src/i18n/locales//.json), ` + `then re-run aggregate-component-registry. ${message}`, ) } else { warnings.push(message) } } } const content = `// ============================================================================ // componentRegistry.generated.ts — Auto-aggregated module registrations // ============================================================================ // // Generated by skills/development/frontend/routes/cli/aggregate-component-registry. // Do NOT edit by hand — re-run the CLI to regenerate. // // Each side-effect import below pulls in one module's PageRegistry.register() // calls. This file MUST be imported from src/main.tsx BEFORE renders so // the registry is populated before the first DynamicRouter resolution. // // import './extensions/componentRegistry.generated'; // // Modules aggregated: ${parsed.length} // Total componentKeys: ${parsed.reduce((acc, p) => acc + p.keys.length, 0)} // // This file registers NOTHING of its own — it is a pure aggregator of side-effect // imports. A componentKey with NO registration is not an error: DynamicRouter serves a // bare application/module route from its own generic ApplicationHomePage / // ModuleHomePage, and a registered component ALWAYS wins over that generic page. // Registering a placeholder on a platform-admin module key is therefore not a safety // net — it BLANKS the page it was meant to protect (which is what it did until CLI // 5.12). Nor is there a MISSING_PAGE to guard against here: MissingComponentPage is // only reachable from a SECTION or RESOURCE key, never from an application/module one. ${lines.join('\n')} ${i18nImport}` if (errors.length === 0) { files.push({ path: outFile, content }) if (i18nContent) { files.push({ path: i18nFile, content: i18nContent }) } } } return { files, parsed, unresolved, collisions, warnings, errors } } /** * ⑤ Consistency-canary parser. Reads a `moduleResources.generated.ts` source and * returns its header `// Modules: N` count plus the DISTINCT namespaces its * `addClientResources` blocks register. A clean single-run file always agrees * (`declared === namespaces.length`); a physically torn / concurrently-overwritten * file disagrees (the reported symptom was `Modules: 2` on a 6-namespace body). * Returns `null` when the header is absent (nothing to assert). Pure — the caller * decides how to fail on a mismatch. */ export function parseModuleResourcesConsistency( src: string, ): { declared: number; namespaces: string[] } | null { const header = src.match(/^\/\/ Modules:\s*(\d+)/m) if (!header) return null const declared = Number(header[1]) const keys = new Set() const blockRe = /addClientResources\([^)]*\{([\s\S]*?)\}\s*\)/g let block: RegExpExecArray | null while ((block = blockRe.exec(src)) !== null) { const keyRe = /(?:^|[{,\s])(?:'([^']+)'|"([^"]+)"|([A-Za-z0-9_$-]+))\s*:/gm let k: RegExpExecArray | null while ((k = keyRe.exec(block[1])) !== null) { keys.add(k[1] ?? k[2] ?? k[3]) } } return { declared, namespaces: [...keys].sort() } } /** * True when at least one business page component exists under `src/pages` * (List/Detail/Form/Dashboard/Kanban suffix — Home pages deliberately excluded * so a vitrine/home-only app does not count as "business"). Drives the ④ * ordering guard: business pages + zero locale bundle = hard error instead of * a warning, because those pages WILL render raw i18n keys. */ export function hasBusinessPages(projectPath: string): boolean { const stack = [path.join(projectPath, 'src', 'pages')] while (stack.length > 0) { const dir = stack.pop()! let entries: fs.Dirent[] try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { continue } for (const entry of entries) { if (entry.isDirectory()) { stack.push(path.join(dir, entry.name)) } else if (/(List|Detail|Form|Dashboard|Kanban)Page\.tsx$/.test(entry.name)) { return true } } } return false } /** Locale-dir path segments under the web root. */ const I18N_LOCALES_SEGMENTS = ['src', 'i18n', 'locales'] /** * Locale bundles that must NOT be registered here because a dedicated * generated file already owns their registration (double registration would * make two writers race on the same namespace): * - `vitrine` → src/extensions/vitrine.generated.ts (site-vitrine scaffolder) * - `login` → src/extensions/login.generated.ts (scaffold-login-page) * Everything else on disk is registered — including `common` * (scaffold-ui-primitives / scaffold-dashboard-primitives keys like * `rowActions.*`, `dashboard.unknownWidget` that the SDK's core `common` * namespace does not provide). Registration runs BEFORE the SDK's async core * bundle load, and the SDK re-adds its bundles deep+overwrite — so SDK values * win on shared keys while app-only keys survive. */ const OWN_REGISTRAR_NAMESPACES = new Set(['vitrine', 'login']) /** A JS import identifier for a (locale, module) bundle, e.g. `frMesTaches`. */ function localeBundleIdent(locale: string, module: string): string { return locale + toPascalCase(module) } /** Quote a namespace key only when the module name is not a bare JS identifier * (module codes may contain dashes, e.g. `mes-taches`). */ function namespaceKey(module: string): string { return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(module) ? module : `'${module}'` } /** * Build `src/extensions/moduleResources.generated.ts` — the i18n counterpart of * the component registry. For every locale bundle on disk * (`src/i18n/locales//.json`, basename = i18next namespace), * it imports the bundle and registers it via * `addClientResources(locale, { : bundle })`. * * The bundles are discovered by SCANNING the locales directory — deliberately * NOT derived from the registry filenames: registries are app-scoped * (`-Registry.ts`, lib/app-classification.extensionsModuleId) * while scaffold-component writes bare `.json` and pages consume * `useTranslation('')`. Deriving the bundle name from the registry * name silently matched nothing once registries were app-scoped (raw keys on * every business page). Scanning also picks up `common.json` * (ui-primitives/dashboard keys) and `docs-*` bundles with zero naming * coupling. Only OWN_REGISTRAR_NAMESPACES are skipped. * * This is the deterministic replacement for the historically HAND-OWNED * `moduleResources.ts`: the SDK boots its own i18next instance and its init() * REPLACES the resource store, so the only registration that survives is one * that runs AFTER the SDK init through `addClientResources` (the same seam * `vitrine.generated.ts` / `login.generated.ts` already use). The module * registries never called it, so module namespaces rendered as raw keys. * * Returns `null` when there is no locale directory or no bundle on disk * (apps without business i18n) — the caller then emits neither the file nor its * import, leaving behaviour unchanged. */ export function buildModuleResourcesFile(projectPath: string): string | null { const localesRoot = path.join(projectPath, ...I18N_LOCALES_SEGMENTS) let locales: string[] try { locales = fs .readdirSync(localesRoot, { withFileTypes: true }) .filter((d) => d.isDirectory()) .map((d) => d.name) .sort() } catch { return null } if (locales.length === 0) return null const importLines: string[] = [] const registerBlocks: string[] = [] const registeredModules = new Set() for (const locale of locales) { let namespaces: string[] try { namespaces = fs .readdirSync(path.join(localesRoot, locale), { withFileTypes: true }) .filter((d) => d.isFile() && d.name.endsWith('.json')) .map((d) => d.name.replace(/\.json$/, '')) .filter((ns) => !OWN_REGISTRAR_NAMESPACES.has(ns)) .sort() } catch { continue } const entries: Array<{ module: string; ident: string }> = [] for (const ns of namespaces) { const ident = localeBundleIdent(locale, ns) entries.push({ module: ns, ident }) registeredModules.add(ns) importLines.push(`import ${ident} from '../i18n/locales/${locale}/${ns}.json';`) } if (entries.length === 0) continue const body = entries.map((e) => ` ${namespaceKey(e.module)}: ${e.ident},`).join('\n') registerBlocks.push(`addClientResources('${locale}', {\n${body}\n});`) } if (registerBlocks.length === 0) return null return `// ============================================================================ // moduleResources.generated.ts — Auto-aggregated i18n module registration // ============================================================================ // // Generated by skills/development/frontend/routes/cli/aggregate-component-registry. // Do NOT edit by hand — re-run the CLI to regenerate. // // WHY THIS FILE EXISTS — the @atlashub/smartstack SDK boots its OWN i18next // instance (fixed core-namespace list) and makes it the active react-i18next // instance; its init() REPLACES the resource store, so business translations // loaded earlier (src/i18n/index.ts) are clobbered and pages render raw keys // (e.g. "taches.list.title"). The supported seam is addClientResources(), which // writes into the SDK's LIVE instance and survives because it runs AFTER the SDK // init — this file is imported at the END of componentRegistry.generated.ts, // which src/main.tsx imports after . vitrine.generated.ts / // login.generated.ts use the same seam. // // Modules: ${registeredModules.size} · Locales: ${locales.filter((l) => importLines.some((i) => i.includes(`/${l}/`))).join(', ')} import { addClientResources } from '@atlashub/smartstack'; ${importLines.join('\n')} ${registerBlocks.join('\n')} ` } /** * Resolve a lazy `import('')` argument against the project's `@/` alias * (= `src/`). Tries `.tsx`, `.ts`, `index.tsx`, `index.ts`. Returns the * absolute filesystem path of the resolved file, or `null` when nothing * matches — that's a phantom import. */ export function resolveLazyImport( importPath: string, projectPath: string, ): string | null { // Normalize the alias '@/' → 'src/'. Other forms (relative './foo', absolute // 'foo/bar') are unusual in our generated registries — handle them anyway. const aliased = importPath.startsWith('@/') ? path.join(projectPath, 'src', importPath.slice(2)) : importPath.startsWith('./') || importPath.startsWith('../') ? path.join(projectPath, 'src', 'extensions', importPath) : path.join(projectPath, importPath) const candidates = [ `${aliased}.tsx`, `${aliased}.ts`, path.join(aliased, 'index.tsx'), path.join(aliased, 'index.ts'), ] for (const c of candidates) { if (fs.existsSync(c)) return c } return null }