/** * Canonical Graph Normalization. * * Transform raw route/handler nodes into a class-centric DAG where: * - Each handler class appears exactly ONCE * - Inheritance is the primary relationship * - Routes are attached to leaf (concrete) handlers * - Middleware is extracted and attached to handlers * - Lambda routes (no handler) become isolated leaf nodes */ export function normalizeGraph(rawNodes, rawEdges) { const byId = Object.fromEntries(rawNodes.map(n => [n.id, n])) const ofType = t => rawNodes.filter(n => n.type === t) const routeRaw = ofType('route') const handlerRaw = ofType('handler') // ── Extract metadata per route ────────────────────────────────────────── const routeMetadata = new Map() // routeId → {method, path, middleware: []} routeRaw.forEach(route => { routeMetadata.set(route.id, { method: route.data?.method || 'GET', path: route.data?.path || '/', middleware: [], }) }) // ── Extract middleware per route ─────────────────────────────────────── const wrapsEdges = rawEdges.filter(e => e.edgeType === 'wraps') const wrapsOf = new Map() // wrappedNode → wrapperNode wrapsEdges.forEach(e => wrapsOf.set(e.target, e.source)) routeRaw.forEach(route => { const middleware = [] let cur = wrapsOf.get(route.id) while (cur && byId[cur]?.type === 'middleware') { middleware.unshift(byId[cur].data.name) cur = wrapsOf.get(cur) } if (middleware.length) { routeMetadata.get(route.id).middleware = middleware } }) // ── Build inheritance map ────────────────────────────────────────────── // handlerClassName → parentClassName const inheritanceMap = new Map() const extendsEdges = rawEdges.filter(e => e.edgeType === 'extends') extendsEdges.forEach(e => { const childNode = byId[e.source] const parentNode = byId[e.target] if (childNode?.data?.name && parentNode?.data?.name) { inheritanceMap.set(childNode.data.name, parentNode.data.name) } }) // ── Build handler chains per route ───────────────────────────────────── const handlesEdges = rawEdges.filter(e => e.edgeType === 'handles') const routeToChain = new Map() // routeId → [className, parentClassName, ...] handlesEdges.forEach(e => { const handlerNode = byId[e.target] if (handlerNode?.data?.name) { const chain = [] let cur = handlerNode.data.name while (cur) { chain.push(cur) cur = inheritanceMap.get(cur) } routeToChain.set(e.source, chain) } }) // Ensure ALL routes are in the chain map (routes without handlers get empty chain) routeRaw.forEach(route => { if (!routeToChain.has(route.id)) { routeToChain.set(route.id, []) } }) // ── Collect all unique handler class names ────────────────────────────── const allClassNames = new Set() routeToChain.forEach(chain => chain.forEach(name => allClassNames.add(name))) // ── Create canonical handler nodes ──────────────────────────────────── const canonicalHandlers = new Map() // class:ClassName → {id, name, isAbstract, routes[], parentId, isLambda} ;[...allClassNames].forEach(className => { const id = 'class:' + className canonicalHandlers.set(id, { id, name: className, isAbstract: true, // will be marked false if used as concrete routes: [], parentId: null, isLambda: false, }) }) // ── Wire up inheritance ──────────────────────────────────────────────── inheritanceMap.forEach((parentClassName, childClassName) => { const childId = 'class:' + childClassName const parentId = 'class:' + parentClassName if (canonicalHandlers.has(childId) && canonicalHandlers.has(parentId)) { canonicalHandlers.get(childId).parentId = parentId } }) // ── Attach routes to concrete handlers ────────────────────────────────── routeToChain.forEach((chain, routeId) => { if (chain.length === 0) { // Lambda: no handler const meta = routeMetadata.get(routeId) const lambdaId = 'class:Lambda:' + meta.method + ':' + encodeURIComponent(meta.path) canonicalHandlers.set(lambdaId, { id: lambdaId, name: 'Lambda Handler', isAbstract: false, isLambda: true, method: meta.method, path: meta.path, routes: [{ method: meta.method, path: meta.path, middleware: meta.middleware, }], parentId: null, }) } else { // Normal handler chain: mark concrete (first = depth 0) const concreteClassName = chain[0] const concreteId = 'class:' + concreteClassName if (canonicalHandlers.has(concreteId)) { const handler = canonicalHandlers.get(concreteId) handler.isAbstract = false const meta = routeMetadata.get(routeId) handler.routes.push({ method: meta.method, path: meta.path, middleware: meta.middleware, }) } } }) // ── Build output nodes ──────────────────────────────────────────────── const nodes = [...canonicalHandlers.values()].map(handler => ({ id: handler.id, type: 'handler', data: { name: handler.name, isAbstract: handler.isAbstract, isLambda: handler.isLambda, method: handler.method, path: handler.path, routes: handler.routes, middleware: handler.routes.length > 0 ? [...new Set(handler.routes.flatMap(r => r.middleware))] : [], }, })) // ── Build output edges (deduped extends only) ────────────────────────── const edgeSet = new Set() const edges = [] canonicalHandlers.forEach((handler, handlerId) => { if (handler.parentId) { const key = handler.parentId + '->' + handlerId if (!edgeSet.has(key)) { edgeSet.add(key) edges.push({ id: key, source: handler.parentId, target: handlerId, edgeType: 'extends', }) } } }) return { nodes, edges } }