/** * cli:split-component-registry — generate.ts (pure planning, zero writes) * * Algorithm — LOSSLESS split of the monolith, fail-closed on anything it * cannot carry: * * 1. Parse every register call (lib/registry-index — inline lazy, const-ref, * static imports, `{ mobile: … }` meta captured VERBATIM). * 2. 0 own registrations → clean no-op (already migrated — idempotence). * 3. Claim the spans of every import declaration, lazy const binding and * register statement; any OTHER top-level source left over is an * unmigratable statement → hard error, nothing written. * 4. Attribute each key to `{app}-{module}` from its first two dotted * segments (lib/app-classification.extensionsModuleId — the same builder * scaffold-routes uses, so canonical regeneration overwrites EXACTLY * these files). Fewer than 2 segments → hard error. * 5. Recopy statements VERBATIM into their module file: registrations in * monolith order, plus the lazy bindings they reference and the import * declarations whose bound names they use (a binding referenced from two * modules is duplicated — file-scoped, harmless, warned). * 6. Mixed-layout reconciliation: a monolith key an existing per-module * registry already owns is DROPPED (the per-module file is canonical, * warned); remaining keys targeting a module whose file already exists * on disk → hard error (never merge into a generated file). * 7. Completeness invariant: monolith keys − dropped == emitted keys, or the * plan aborts (internal error, zero-loss guarantee). */ import fs from 'node:fs' import path from 'node:path' import { extensionsModuleId } from '../../../../../lib/app-classification.js' import { buildRegistryIndex, parseRegistrySource, extractCallArgs, type RegistryRegistration, } from '../../../../../lib/registry-index.js' import type { SplitComponentRegistryInput, SplitModuleFile, SplitPlan } from './types.js' interface Span { start: number /** exclusive; trailing `;` swallowed when present */ end: number } interface ImportDecl extends Span { text: string sideEffect: boolean source: string boundNames: string[] } interface LazyBinding extends Span { name: string text: string } function lineOf(source: string, index: number): number { let line = 1 for (let i = 0; i < index; i++) if (source[i] === '\n') line++ return line } function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } /** Swallow whitespace then one `;` after a statement end. */ function swallowSemicolon(source: string, from: number): number { let i = from while (i < source.length && (source[i] === ' ' || source[i] === '\t')) i++ if (source[i] === ';') i++ return i } function parseImports(source: string): ImportDecl[] { const out: ImportDecl[] = [] const re = /^[ \t]*import\b/gm let m: RegExpExecArray | null while ((m = re.exec(source)) !== null) { // Scan to the terminating `;` (string-aware — generated imports always carry one). let i = m.index + m[0].length let end = -1 while (i < source.length) { const ch = source[i] if (ch === "'" || ch === '"') { const quote = ch i++ while (i < source.length && source[i] !== quote) { if (source[i] === '\\') i++ i++ } i++ continue } if (ch === ';') { end = i + 1 break } i++ } if (end === -1) break // malformed tail — the residue check reports it const text = source.slice(m.index, end).trim() const srcMatch = /from\s+['"]([^'"]+)['"]\s*;?$/.exec(text) const sideEffectMatch = /^import\s+['"]([^'"]+)['"]\s*;?$/.exec(text) const boundNames: string[] = [] if (!sideEffectMatch) { const clause = text.replace(/^import\s+/, '').replace(/\s+from\s+['"][^'"]+['"]\s*;?$/, '') const named = /\{([^}]*)\}/.exec(clause) if (named) { for (const raw of named[1]!.split(',')) { const name = raw.trim().split(/\s+as\s+/).pop()?.trim() if (name) boundNames.push(name) } } const ns = /\*\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)/.exec(clause) if (ns) boundNames.push(ns[1]!) const def = /^([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:,|$)/.exec(clause.trim()) if (def) boundNames.push(def[1]!) } out.push({ start: m.index, end, text, sideEffect: sideEffectMatch !== null, source: sideEffectMatch ? sideEffectMatch[1]! : (srcMatch?.[1] ?? ''), boundNames, }) re.lastIndex = end } return out } function parseLazyBindings(source: string): LazyBinding[] { const out: LazyBinding[] = [] const re = /\bconst\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:React\.)?(?:lazy|lazyWithRetry)\s*\(/g let m: RegExpExecArray | null while ((m = re.exec(source)) !== null) { const openParen = m.index + m[0].length - 1 const call = extractCallArgs(source, openParen) if (!call) continue const end = swallowSemicolon(source, call.end + 1) out.push({ name: m[1]!, start: m.index, end, text: source.slice(m.index, end).trim() }) re.lastIndex = end } return out } /** Non-comment, non-whitespace residue of a source slice (stray `;` ignored). */ function meaningfulResidue(slice: string): string { return slice .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/\/\/[^\n]*/g, '') .replace(/;/g, '') .trim() } const MODULE_RESOURCES_RE = /moduleResources/ export function generate(spec: SplitComponentRegistryInput): SplitPlan { const errors: string[] = [] const warnings: string[] = [] const registryAbs = path.join(spec.projectPath, spec.registryFile) const source = fs.readFileSync(registryAbs, 'utf-8') const registrations = parseRegistrySource(source, { file: spec.registryFile, registryAbsPath: registryAbs, webRoot: spec.projectPath, }) // 2. Idempotence — an already-migrated aggregate registers nothing of its own. if (registrations.length === 0) { return { noop: true, errors: [], warnings: [], moduleFiles: [], droppedDuplicates: [], droppedSideEffects: [], keysMigrated: 0, } } const imports = parseImports(source) const bindings = parseLazyBindings(source) const bindingByName = new Map(bindings.map((b) => [b.name, b])) // Registration spans — statements are verbatim slices, located with a moving cursor. const regSpans: Array<{ reg: RegistryRegistration; span: Span }> = [] let cursor = 0 for (const reg of registrations) { const start = source.indexOf(reg.statement, cursor) if (start === -1) { errors.push(`internal: register statement for '${reg.key}' not relocatable — aborting (zero-loss).`) continue } const end = swallowSemicolon(source, start + reg.statement.length) regSpans.push({ reg, span: { start, end } }) cursor = end } // 3. Residue check — every top-level statement must be claimed. const claimed: Span[] = [ ...imports.map((i) => ({ start: i.start, end: i.end })), ...bindings.map((b) => ({ start: b.start, end: b.end })), ...regSpans.map((r) => r.span), ].sort((a, b) => a.start - b.start) let pos = 0 for (const span of claimed) { if (span.start > pos) { const residue = meaningfulResidue(source.slice(pos, span.start)) if (residue.length > 0) { errors.push( `Unmigratable top-level statement at line ${lineOf(source, pos + source.slice(pos, span.start).search(/\S/))}: ` + `'${residue.slice(0, 80)}' — split only carries imports, lazy const bindings and register calls. ` + `Move it by hand (e.g. into a @customised extensions file), then re-run. Nothing was written (fail-closed).`, ) } } pos = Math.max(pos, span.end) } const tail = meaningfulResidue(source.slice(pos)) if (tail.length > 0) { errors.push( `Unmigratable top-level statement at line ${lineOf(source, pos + source.slice(pos).search(/\S/))}: ` + `'${tail.slice(0, 80)}' — split only carries imports, lazy const bindings and register calls. ` + `Move it by hand (e.g. into a @customised extensions file), then re-run. Nothing was written (fail-closed).`, ) } // Side-effect imports: moduleResources is DROPPED (the aggregator regenerates // it from src/i18n/locales); anything else cannot be attributed to a module. const droppedSideEffects: string[] = [] for (const imp of imports) { if (!imp.sideEffect) continue if (MODULE_RESOURCES_RE.test(imp.source)) { droppedSideEffects.push(imp.source) warnings.push( `Dropped side-effect import '${imp.source}' — aggregate-component-registry regenerates ` + `moduleResources.generated.ts from src/i18n/locales at re-aggregation.`, ) } else { errors.push( `Side-effect import '${imp.source}' (line ${lineOf(source, imp.start)}) cannot be attributed to a module — ` + `move it by hand (e.g. into a @customised extensions file), then re-run. Nothing was written (fail-closed).`, ) } } // 4. Attribution + phantom / traceability checks. const index = buildRegistryIndex(spec.projectPath) const perModuleKeyOwners = new Map() for (const entry of index.entries) { if (entry.kind !== 'per-module') continue for (const key of entry.keys) perModuleKeyOwners.set(key, entry.file) } const droppedDuplicates: string[] = [] const byModule = new Map>() for (const { reg } of regSpans) { const owner = perModuleKeyOwners.get(reg.key) if (owner) { droppedDuplicates.push(reg.key) warnings.push( `Dropped monolith registration '${reg.key}' — already owned by ${owner} (the per-module file is canonical).`, ) continue } const segments = reg.key.split('.') if (segments.length < 2 || segments.slice(0, 2).some((s) => s.length === 0)) { errors.push( `Cannot attribute componentKey '${reg.key}' (line ${reg.line}) to an {app}.{module} pair — fewer than ` + `2 dotted segments. Fix or remove the registration, then re-run. Nothing was written (fail-closed, zero-loss).`, ) continue } if (spec.appCode && segments[0]!.toLowerCase() !== spec.appCode.toLowerCase()) { errors.push( `componentKey '${reg.key}' carries app segment '${segments[0]}' but --spec appCode is '${spec.appCode}'. ` + `Split a multi-app monolith WITHOUT appCode, or fix the stray key.`, ) continue } if (reg.form === 'unresolved-ref') { errors.push( `Registration '${reg.key}' (line ${reg.line}) references '${reg.componentExpr}' whose provenance the split ` + `cannot trace (no lazy binding, no static import). Fix the monolith, then re-run.`, ) continue } const local = reg.importPath !== null && (reg.importPath.startsWith('@/') || reg.importPath.startsWith('.')) if (local && reg.resolvedAbsPath === null) { errors.push( `Registration '${reg.key}' imports '${reg.importPath}' which does not resolve on disk — this route is ` + `ALREADY dead in the running app. Remove it or restore the page, then re-run.`, ) continue } const moduleId = extensionsModuleId(segments[0]!, segments[1]!) const list = byModule.get(moduleId) ?? [] list.push({ reg }) byModule.set(moduleId, list) } // 6. Never merge into an existing generated per-module file. for (const [moduleId, regs] of byModule) { const file = `src/extensions/${moduleId}Registry.ts` if (fs.existsSync(path.join(spec.projectPath, file))) { errors.push( `Module '${moduleId}' already has ${file} on disk AND the monolith still registers ` + `${regs.map((r) => `'${r.reg.key}'`).join(', ')} for it — refusing to merge into a generated file. ` + `Delete the per-module file (split regenerates it) or hand-move these keys, then re-run.`, ) } } if (errors.length > 0) { return { noop: false, errors, warnings, moduleFiles: [], droppedDuplicates, droppedSideEffects, keysMigrated: 0, } } // 5. Assemble each module file — statements VERBATIM, monolith order. const moduleFiles: SplitModuleFile[] = [] const duplicatedBindings = new Map() for (const [moduleId, regs] of [...byModule.entries()].sort(([a], [b]) => a.localeCompare(b))) { const regTexts = regs.map((r) => `${r.reg.statement};`) const neededBindings: LazyBinding[] = [] for (const { reg } of regs) { if (reg.form !== 'const-ref') continue const binding = bindingByName.get(reg.componentExpr) if (binding && !neededBindings.includes(binding)) { neededBindings.push(binding) const users = duplicatedBindings.get(binding.name) ?? [] users.push(moduleId) duplicatedBindings.set(binding.name, users) } } neededBindings.sort((a, b) => a.start - b.start) const bodyForImportScan = [...neededBindings.map((b) => b.text), ...regTexts].join('\n') const neededImports = imports.filter( (imp) => !imp.sideEffect && imp.boundNames.some((n) => new RegExp(`\\b${escapeRegExp(n)}\\b`).test(bodyForImportScan)), ) const content = `// ============================================================================ // ${moduleId}Registry.ts — migrated from ${path.basename(spec.registryFile)} // ============================================================================ // // Emitted by skills/development/frontend/routes/cli/split-component-registry // (legacy MCP-era monolith split). Routing-identical: every statement below is // carried VERBATIM from the monolith. Superseded module by module by // scaffold-routes on canonical regeneration — do not edit by hand. ${neededImports.map((i) => i.text).join('\n')} ${neededBindings.length > 0 ? '\n' + neededBindings.map((b) => b.text).join('\n') + '\n' : ''} ${regTexts.join('\n')} ` moduleFiles.push({ path: `src/extensions/${moduleId}Registry.ts`, moduleId, keys: regs.map((r) => r.reg.key), content, }) } for (const [name, users] of duplicatedBindings) { if (users.length > 1) { warnings.push( `Lazy binding 'const ${name} = …' is referenced from ${users.join(' + ')} — duplicated into each ` + `(file-scoped, harmless; the duplicate chunk disappears on canonical regeneration).`, ) } } // 7. Completeness invariant — zero-loss or abort. const emitted = moduleFiles.reduce((acc, f) => acc + f.keys.length, 0) if (emitted + droppedDuplicates.length !== registrations.length) { return { noop: false, errors: [ `internal completeness invariant FAILED: monolith registers ${registrations.length} key(s), plan carries ` + `${emitted} + ${droppedDuplicates.length} dropped duplicate(s). Aborting with zero writes — report this bug.`, ], warnings, moduleFiles: [], droppedDuplicates, droppedSideEffects, keysMigrated: 0, } } return { noop: false, errors: [], warnings, moduleFiles, droppedDuplicates, droppedSideEffects, keysMigrated: emitted, } }