/** * cli:scaffold-component — registry-guard.ts (pure, disk-free) * * Registry-takeover guard (constat D of the PickEBike audit): on an app whose * LIVE registry already serves this invocation's componentKeys from a * DIFFERENT page file (a legacy app whose pages live outside the canonical * `src/pages/{app}/{module}/{section}/` convention), emitting the canonical * page silently produces TWO pages for one key — the registry keeps serving * the old one, the new one is dead code, and the offline/mobile contract * never lands in what users actually see. * * The guard matches the live registry's keys against this invocation's * (appCode, module, section) — tolerant to the optional routeParent segment * (`{app}.{module}[.{parent}].{section}[.view]`) — and compares each matched * key's resolved page file with the page this run is about to emit for the * same view. A mismatch is a collision: fail-closed unless the spec passes * `allowTakeover: true` (the canonical-regeneration flow re-points keys ON * PURPOSE and re-runs scaffold-routes + aggregate-component-registry after). */ export interface RegistryCollision { key: string view: 'list' | 'detail' | 'create' | 'edit' /** What the live registry serves today (page path, or registry file when unresolved). */ registeredFile: string /** The page this invocation would emit for that view (absolute). */ emittedFile: string } export interface LiveKeyEntry { /** Registry file (web-root-relative) that registers the key. */ file: string /** Absolute on-disk page the registration resolves to, or null (dead import). */ resolvedAbsPath: string | null } /** Path equality: forward slashes + case-insensitive (win32-safe; a case-only * difference designates the same intended file, never a real collision). */ function samePath(a: string, b: string): boolean { return a.replace(/\\/g, '/').toLowerCase() === b.replace(/\\/g, '/').toLowerCase() } function viewOf(key: string): 'list' | 'detail' | 'create' | 'edit' { const m = /\.(detail|edit|create)$/.exec(key) return (m?.[1] as 'detail' | 'edit' | 'create') ?? 'list' } function rootMatchesInvocation(root: string, appCode: string, module: string, section: string): boolean { const segs = root.split('.') if (segs.length < 2) return false const app = appCode.toLowerCase() const mod = module.toLowerCase() const sec = section.toLowerCase() if (segs[0]!.toLowerCase() !== app || segs[1]!.toLowerCase() !== mod) return false // `{app}.{module}.{…parent…}.{section}` — the last segment is the section; // the 2-segment module-level form matches only when section === module. const last = segs[segs.length - 1]!.toLowerCase() return segs.length === 2 ? sec === mod : last === sec } export function checkRegistryCollisions(opts: { byKey: ReadonlyMap appCode: string module: string section: string /** view → ABSOLUTE path of the page this invocation emits for it. */ emittedByView: ReadonlyMap }): RegistryCollision[] { const collisions: RegistryCollision[] = [] for (const [key, entry] of opts.byKey) { const view = viewOf(key) const root = view === 'list' ? key : key.slice(0, -(view.length + 1)) if (!rootMatchesInvocation(root, opts.appCode, opts.module, opts.section)) continue const emitted = opts.emittedByView.get(view) if (!emitted) continue // this run does not emit that view — nothing to collide with if (entry.resolvedAbsPath === null) { collisions.push({ key, view, registeredFile: `${entry.file} (unresolved import)`, emittedFile: emitted }) continue } if (!samePath(entry.resolvedAbsPath, emitted)) { collisions.push({ key, view, registeredFile: entry.resolvedAbsPath, emittedFile: emitted }) } } return collisions }