/** * lib/routes-registry.ts — parse the ROUTE FAMILIES a generated * `src/extensions/{app}-{module}Routes.ts` actually declares. * * Shared by: * - development/frontend/component/cli/scaffold-component — generation-time * gate: a 360 related tab navigating through an unknown family is a HARD * error (emitting a navigation that happens to compile against another * family — the porteur's — is the silent AtlasHub sub-view mis-routing). * - development/audit-dev-frontend (DEV-UI-031 check e) — audit-time * verification of the same invariant on the built pages. * * Lives in lib/ (not in a skill folder) because cross-skill relative imports * break under the installer's folder flattening — same rationale as * lib/ba-screens.ts. Parsing the EMITTED file (rather than re-deriving the * families) keeps one source of truth: whatever scaffold-routes wrote IS the * contract. * * The parser is line-based on the exact shape scaffold-routes emits: * export const routes = { * workPackages: { * list: () => '…', * detail: (id: string) => `…`, * }, * } as const; */ import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { extensionsModuleId } from './app-classification.js' /** family (camelCase) → helper names (`list`, `detail`, `edit`, `create`, …). */ export type RoutesFamilies = Record const FAMILY_OPEN_RE = /^\s{2}([A-Za-z_$][A-Za-z0-9_$]*):\s*\{\s*$/ const HELPER_RE = /^\s{4}([A-Za-z_$][A-Za-z0-9_$]*):\s/ const FAMILY_CLOSE_RE = /^\s{2}\},?\s*$/ /** Pure parse of a `*Routes.ts` source into its family → helpers map. */ export function parseRoutesFamilies(source: string): RoutesFamilies { const families: RoutesFamilies = {} let current: string | null = null for (const line of source.split(/\r?\n/)) { if (current === null) { const open = FAMILY_OPEN_RE.exec(line) if (open) { current = open[1] families[current] = [] } continue } if (FAMILY_CLOSE_RE.test(line)) { current = null continue } const helper = HELPER_RE.exec(line) if (helper) families[current].push(helper[1]) } return families } /** * Load the route families of every module in `modules` from the project's * `src/extensions/{app}-{module}Routes.ts`. A module whose file is missing or * unreadable is simply ABSENT from the result (the caller degrades to its * fallback signals + a warning — first runs generate components before * scaffold-routes has written the file). * * Keyed by `{app}-{module}` — `extensionsModuleId`, the identity of the file itself — * rather than by the module code alone. One page can now load families from SEVERAL * applications (a related tab pointing at another one), and a module code is unique only * inside its application: keyed by module, the second application's families would * overwrite the first's and the page would be verified against the wrong routes. */ export function loadRoutesFamilies( projectPath: string, appCode: string, modules: Iterable, ): Record { const out: Record = {} for (const mod of new Set(modules)) { const id = extensionsModuleId(appCode, mod) const file = join(projectPath, 'src', 'extensions', `${id}Routes.ts`) if (!existsSync(file)) continue try { out[id] = parseRoutesFamilies(readFileSync(file, 'utf-8')) } catch { // unreadable file — treated exactly like a missing one } } return out } /** * `PageRegistry.register('', PageConst[, { … }])` — the SINGLE regex every * consumer of the generated `src/extensions/*Registry.ts` files parses with * (aggregate-component-registry's phantom/collision guard, run-smoke's * registry↔menu coverage). Hoisted here so the two can never drift. * * This RE is the STRICT emission contract (PascalCase const ref only). The * TOLERANT superset — inline `lazy(() => import(…))`, the legacy MCP-era * monolithic componentRegistry.generated.ts, static imports, verbatim meta — * lives in lib/registry-index.ts: audits, the fail-closed layout guards and * the split-component-registry migration parse THAT; this RE stays what * scaffold-routes writes. */ export const PAGE_REGISTER_RE = /PageRegistry\.register\(\s*['"`]([^'"`]+)['"`]\s*,\s*([A-Z][A-Za-z0-9_]*)\s*(?:,\s*\{[\s\S]*?\})?\s*\)/g /** Every componentKey a registry source registers (first capture, in order). */ export function collectRegisteredKeys(source: string): string[] { const keys: string[] = [] PAGE_REGISTER_RE.lastIndex = 0 let m: RegExpExecArray | null while ((m = PAGE_REGISTER_RE.exec(source)) !== null) keys.push(m[1]) return keys }