/** * lib/registry-index.ts — the TOLERANT PageRegistry parser + layout detector. * * Reads EVERY `src/extensions/**​/*.ts` of a generated client app and indexes * its `PageRegistry.register(...)` / `ComponentRegistry.register(...)` calls, * whatever their shape: * * - the canonical per-module form scaffold-routes emits * (`const X = lazyWithRetry(() => import('@/…').then(m => …)); register('key', X[, { mobile }])`), * - the LEGACY MCP-era monolith (`componentRegistry.generated.ts` registering * every page INLINE: `register('key', lazy(() => import('@/…')))`), * - static default/named imports referenced by name, * - and the optional 3rd `{ mobile: … }` metadata argument, captured VERBATIM. * * This is the promotion of audit-dev-frontend's private `discoverRegistries()` * (the only parser in the repo that could read the monolith) merged with * aggregate-component-registry's `resolveLazyImport()` and audit-dev-pwa's * meta-argument capture. Instead of a union of fragile regexes, the register * call is located by name and its arguments are split by a BALANCED-DELIMITER * scanner (string/comment aware) — nested `(() => import('…'))`, `.then(…)` * continuations and `{ mobile: { … } }` literals all parse naturally. * * Relationship to lib/routes-registry.ts: `PAGE_REGISTER_RE` there stays the * STRICT emission contract (PascalCase const ref) that scaffold-routes writes * and run-smoke / the aggregator's collision guard consume. THIS lib is the * tolerant superset for anything that must read a registry it did not emit: * audits (audit-dev-pwa, audit-dev-frontend), the fail-closed layout guards * (aggregate-component-registry, scaffold-routes, scaffold-component) and the * split-component-registry migration CLI. * * Lives in lib/ (not in a skill folder) because cross-skill relative imports * break under the installer's folder flattening — same rationale as * lib/routes-registry.ts. */ import fs from 'node:fs' import path from 'node:path' /** Basename of the generated aggregate — the file the aggregator OWNS. */ export const AGGREGATE_BASENAME = 'componentRegistry.generated.ts' /** * The per-module registry filename contract (`{app}-{module}Registry.ts`). * Hoisted from aggregate-component-registry's discovery so the guard and the * discovery can never disagree on what "per-module" means. The aggregate * basename does NOT match (the `.generated` infix breaks the anchor). */ export const PER_MODULE_REGISTRY_RE = /^([a-zA-Z][a-zA-Z0-9_-]*)Registry\.ts$/ export type RegistryFileKind = 'per-module' | 'aggregate' | 'other' /** * - `per-module` — the canonical layout: `{app}-{module}Registry.ts` files * register, the aggregate only side-effect-imports them. * - `legacy-monolith` — MCP-era layout: the aggregate registers pages of its * OWN and no per-module registry exists. Re-aggregating * would overwrite it with an EMPTY aggregate (blank app). * - `mixed` — the aggregate registers pages of its own WHILE * per-module files also exist (partial migration or a * hand edit). Re-aggregating would drop the inline ones. * - `none` — nothing registers anywhere (fresh app). */ export type RegistryLayout = 'per-module' | 'legacy-monolith' | 'mixed' | 'none' export type RegistrationForm = 'inline-lazy' | 'const-ref' | 'const-ref-static' | 'unresolved-ref' export interface RegistryRegistration { /** The componentKey (first argument, string literal). */ key: string form: RegistrationForm /** The `import('')` target (or static import source). null for unresolved-ref. */ importPath: string | null /** Absolute on-disk path the import resolves to (`@/` → `src/`), or null. */ resolvedAbsPath: string | null /** The 3rd options argument VERBATIM (`{ mobile: { … } }`), braces included. */ metaLiteral: string | null /** The 2nd argument verbatim (identifier or inline lazy expression). */ componentExpr: string /** The full `XRegistry.register(…)` call verbatim (no trailing `;`). */ statement: string /** 1-based line of the call start. */ line: number } export interface RegistryFileEntry { /** Path relative to the webRoot, forward slashes. */ file: string kind: RegistryFileKind registrations: RegistryRegistration[] keys: string[] } export interface RegistryIndex { webRoot: string /** Every registry file found (aggregate always included when it exists; * per-module by filename; `other` files only when they register). */ entries: RegistryFileEntry[] /** componentKey → first-registering file + registration. */ byKey: Map /** Keys registered by more than one file. */ collisions: Array<{ key: string; files: string[] }> layout: RegistryLayout /** The aggregate's entry when the file exists on disk (even with 0 own * registrations), else null. */ aggregate: RegistryFileEntry | null } // ─── Balanced-argument scanner ────────────────────────────────────────────── export interface CallArgs { args: string[] /** Index of the closing `)` in the source. */ end: number } /** * From the index of an opening `(`, split the call's arguments at TOP-LEVEL * commas — string/template/comment aware, tracking (), {} and [] depth. * Returns null on an unbalanced call (truncated file). Exported for * split-component-registry, which reuses the same engine to find the exact * span of a `const X = lazy(…)` binding it must carry verbatim. */ export function extractCallArgs(source: string, openParen: number): CallArgs | null { const args: string[] = [] let depth = 1 let argStart = openParen + 1 let i = openParen + 1 while (i < source.length) { const ch = source[i] const next = source[i + 1] // Comments if (ch === '/' && next === '/') { const nl = source.indexOf('\n', i) if (nl === -1) return null i = nl + 1 continue } if (ch === '/' && next === '*') { const close = source.indexOf('*/', i + 2) if (close === -1) return null i = close + 2 continue } // String literals (template expressions inside backticks are not tracked — // a nested backtick-in-${…} would mis-scan, a shape no registry emits) if (ch === "'" || ch === '"' || ch === '`') { const quote = ch i++ while (i < source.length) { if (source[i] === '\\') { i += 2 continue } if (source[i] === quote) break i++ } if (i >= source.length) return null i++ continue } if (ch === '(' || ch === '{' || ch === '[') { depth++ i++ continue } if (ch === ')' || ch === '}' || ch === ']') { depth-- if (depth === 0) { args.push(source.slice(argStart, i).trim()) return { args, end: i } } i++ continue } if (ch === ',' && depth === 1) { args.push(source.slice(argStart, i).trim()) argStart = i + 1 i++ continue } i++ } return null } // ─── Per-file parsing ─────────────────────────────────────────────────────── const REGISTER_CALL_RE = /\b(?:Page|Component)Registry\s*\.\s*register\s*\(/g const LAZY_BINDING_RE = /\bconst\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:React\.)?(?:lazy|lazyWithRetry)\s*\(\s*\(\s*\)\s*=>[\s\S]*?import\(\s*['"`]([^'"`]+)['"`]/g const STATIC_DEFAULT_IMPORT_RE = /^import\s+([A-Za-z_$][A-Za-z0-9_$]*)\s+from\s+['"]([^'"]+)['"]/gm const STATIC_NAMED_IMPORT_RE = /^import\s*\{([^}]+)\}\s*from\s+['"]([^'"]+)['"]/gm const KEY_LITERAL_RE = /^['"`]([^'"`]*)['"`]$/ const IMPORT_TARGET_RE = /import\(\s*['"`]([^'"`]+)['"`]\s*\)/ const IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/ function lineOf(source: string, index: number): number { let line = 1 for (let i = 0; i < index; i++) if (source[i] === '\n') line++ return line } /** * Parse every register call of a registry source. Calls whose key is not a * string literal are SKIPPED (the split CLI's unmigratable-statement check * catches them as leftover source — never a silent drop of a page). */ export function parseRegistrySource( source: string, opts: { file: string; registryAbsPath: string | null; webRoot: string }, ): RegistryRegistration[] { const lazyConsts = new Map() LAZY_BINDING_RE.lastIndex = 0 let lb: RegExpExecArray | null while ((lb = LAZY_BINDING_RE.exec(source)) !== null) { lazyConsts.set(lb[1]!, lb[2]!) } const staticImports = new Map() STATIC_DEFAULT_IMPORT_RE.lastIndex = 0 let sd: RegExpExecArray | null while ((sd = STATIC_DEFAULT_IMPORT_RE.exec(source)) !== null) { staticImports.set(sd[1]!, sd[2]!) } STATIC_NAMED_IMPORT_RE.lastIndex = 0 let sn: RegExpExecArray | null while ((sn = STATIC_NAMED_IMPORT_RE.exec(source)) !== null) { for (const raw of sn[1]!.split(',')) { const name = raw.trim().split(/\s+as\s+/).pop()?.trim() if (name && IDENT_RE.test(name)) staticImports.set(name, sn[2]!) } } const registrations: RegistryRegistration[] = [] REGISTER_CALL_RE.lastIndex = 0 let rc: RegExpExecArray | null while ((rc = REGISTER_CALL_RE.exec(source)) !== null) { const openParen = rc.index + rc[0].length - 1 const call = extractCallArgs(source, openParen) if (!call || call.args.length < 2) continue const keyMatch = KEY_LITERAL_RE.exec(call.args[0]!) if (!keyMatch) continue // non-literal key — surfaces as unmigratable source, never dropped silently const componentExpr = call.args[1]! const metaLiteral = call.args.length >= 3 && call.args[2]!.length > 0 ? call.args[2]! : null let form: RegistrationForm let importPath: string | null = null const inlineImport = IMPORT_TARGET_RE.exec(componentExpr) if (inlineImport) { form = 'inline-lazy' importPath = inlineImport[1]! } else if (IDENT_RE.test(componentExpr) && lazyConsts.has(componentExpr)) { form = 'const-ref' importPath = lazyConsts.get(componentExpr)! } else if (IDENT_RE.test(componentExpr) && staticImports.has(componentExpr)) { form = 'const-ref-static' importPath = staticImports.get(componentExpr)! } else { form = 'unresolved-ref' } registrations.push({ key: keyMatch[1]!, form, importPath, resolvedAbsPath: importPath ? resolveRegistryImport(importPath, opts.webRoot, opts.registryAbsPath ?? undefined) : null, metaLiteral, componentExpr, statement: source.slice(rc.index, call.end + 1), line: lineOf(source, rc.index), }) REGISTER_CALL_RE.lastIndex = call.end + 1 } return registrations } /** * Resolve an import target against the project: `@/` → `/src/`, * relative → the registry file's directory (defaults to `src/extensions`), * anything else against the webRoot. Tries the bare path then `.tsx`, `.ts`, * `index.tsx`, `index.ts`. Returns the absolute path or null (phantom / * external package — the caller decides which it is from the prefix). */ export function resolveRegistryImport( importPath: string, webRoot: string, registryAbsPath?: string, ): string | null { let target: string if (importPath.startsWith('@/')) { target = path.resolve(webRoot, 'src', importPath.slice(2)) } else if (importPath.startsWith('.')) { target = registryAbsPath ? path.resolve(path.dirname(registryAbsPath), importPath) : path.resolve(webRoot, 'src', 'extensions', importPath) } else { target = path.resolve(webRoot, importPath) } const candidates = [ target, `${target}.tsx`, `${target}.ts`, path.join(target, 'index.tsx'), path.join(target, 'index.ts'), ] for (const c of candidates) { if (fs.existsSync(c) && fs.statSync(c).isFile()) return c } return null } // ─── Index builder ────────────────────────────────────────────────────────── function walkTsFiles(dir: string): string[] { const out: string[] = [] const stack = [dir] while (stack.length > 0) { const d = stack.pop()! let entries: fs.Dirent[] try { entries = fs.readdirSync(d, { withFileTypes: true }) } catch { continue } for (const entry of entries) { const abs = path.join(d, entry.name) if (entry.isDirectory()) { stack.push(abs) } else if ( entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts') && !entry.name.endsWith('.test.ts') && !entry.name.endsWith('.spec.ts') ) { out.push(abs) } } } return out.sort() } export function fileKindOf(basename: string): RegistryFileKind { if (basename === AGGREGATE_BASENAME) return 'aggregate' if (PER_MODULE_REGISTRY_RE.test(basename)) return 'per-module' return 'other' } export function buildRegistryIndex(webRoot: string): RegistryIndex { const extensionsDir = path.join(webRoot, 'src', 'extensions') const entries: RegistryFileEntry[] = [] let aggregate: RegistryFileEntry | null = null for (const abs of walkTsFiles(extensionsDir)) { const basename = path.basename(abs) const kind = fileKindOf(basename) let source: string try { source = fs.readFileSync(abs, 'utf-8') } catch { continue } const registers = /\b(?:Page|Component)Registry\s*\.\s*register\s*\(/.test(source) // `other` files only count when they actually register something; the // aggregate is ALWAYS indexed when present (its emptiness is the signal), // per-module files are registries by filename contract. if (kind === 'other' && !registers) continue const rel = path.relative(webRoot, abs).replace(/\\/g, '/') const registrations = registers ? parseRegistrySource(source, { file: rel, registryAbsPath: abs, webRoot }) : [] const entry: RegistryFileEntry = { file: rel, kind, registrations, keys: registrations.map((r) => r.key), } entries.push(entry) if (kind === 'aggregate') aggregate = entry } const byKey = new Map() const owners = new Map() for (const entry of entries) { for (const reg of entry.registrations) { if (!byKey.has(reg.key)) byKey.set(reg.key, { file: entry.file, registration: reg }) const files = owners.get(reg.key) ?? [] if (!files.includes(entry.file)) files.push(entry.file) owners.set(reg.key, files) } } const collisions = Array.from(owners.entries()) .filter(([, files]) => files.length > 1) .map(([key, files]) => ({ key, files })) const aggregateSelf = aggregate !== null && aggregate.registrations.length > 0 const perModuleActive = entries.some((e) => e.kind === 'per-module' && e.registrations.length > 0) let layout: RegistryLayout if (aggregateSelf && !perModuleActive) layout = 'legacy-monolith' else if (aggregateSelf && perModuleActive) layout = 'mixed' else if (perModuleActive) layout = 'per-module' else layout = 'none' return { webRoot, entries, byKey, collisions, layout, aggregate } } /** Convenience wrapper — `buildRegistryIndex(webRoot).layout`. */ export function detectRegistryLayout(webRoot: string): RegistryLayout { return buildRegistryIndex(webRoot).layout }