diff --git a/gitnexus/src/core/group/extractors/http-patterns/java.ts b/gitnexus/src/core/group/extractors/http-patterns/java.ts index 424bf4210..0c83eaab4 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/java.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/java.ts @@ -678,16 +678,16 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { language: Java, // routeCoverage intentionally LEFT at the default 'partial' (#2138 Part 2). // The graph provider set is a strict *subset* of this scan()'s provider set — - // ingestion does NOT emit a Route node for (1) a method-level array route - // nested under a class-level array-form `@RequestMapping` (ingestion suppresses - // it rather than drop the prefix; bare/scalar-prefixed array methods ARE now - // emitted — see #2280), or (2) the 2nd verb of a same-URL GET+POST pair (Route - // nodes are URL-keyed). Interface-inherited Spring routes ARE now emitted by - // ingestion (#2288), so they are no longer a coverage gap. Declaring 'complete' - // here would let the parse-skip drop those remaining group-only providers. - // Java flips to 'complete' only once ingestion provider extraction matches this - // scan (a follow-up: class-level array-form prefix support + per-verb Route - // identity — tracked in #2280). + // ingestion does NOT emit a Route node for a method-level array route nested + // under a class-level array-form `@RequestMapping` (ingestion suppresses it + // rather than drop the prefix; bare/scalar-prefixed array methods ARE now + // emitted — see #2280). Interface-inherited Spring routes ARE now emitted by + // ingestion (#2288), and same-URL multi-verb routes are now per-`(method,url)` + // Route nodes (#2289), so they are no longer coverage gaps. Declaring + // 'complete' here would let the parse-skip drop the remaining group-only + // providers (the array-prefix gap above). Java flips to 'complete' only once + // ingestion provider extraction matches this scan — class-level array-form + // prefix support is the final follow-up tracked in #2280. // `hasConsumerSignals` below is kept ready for that flip. // Consumer signals this plugin's scan() can detect: RestTemplate / WebClient / // OkHttp / Java-HttpClient / Apache-HttpClient call sites, OpenFeign diff --git a/gitnexus/src/core/group/extractors/manifest-extractor.ts b/gitnexus/src/core/group/extractors/manifest-extractor.ts index f4d0f77cf..8b2a80f65 100644 --- a/gitnexus/src/core/group/extractors/manifest-extractor.ts +++ b/gitnexus/src/core/group/extractors/manifest-extractor.ts @@ -192,10 +192,12 @@ export class ManifestExtractor { try { let rows: Record[]; if (link.type === 'http') { - // Route.name is the canonicalized URL path (see - // core/ingestion/pipeline.ts ensureSlash + generateId('Route', ...)). - // Normalize the manifest contract the same way so a user-written - // "/api/orders" matches "api/orders" in the graph. + // Route.name is the canonicalized URL path. Since #2289 a Route node's + // *id* is `(method, url)`-composite (`routeNodeKey`), but `route.name` + // continues to carry the bare URL so URL-keyed group queries like this + // one keep working without a schema change. Normalize the manifest + // contract the same way so a user-written "/api/orders" matches + // "api/orders" in the graph. // // The contract may also use the explicit-method form "GET::/api/orders" // recommended by buildContractId. Strip the METHOD:: prefix before diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 20aacffd5..ffce0a538 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -23,7 +23,11 @@ import { yieldToEventLoop } from './utils/event-loop.js'; import type { ExtractedRoute, ExtractedFetchCall } from './workers/parse-worker.js'; import type { ExtractedDecoratorRoute } from './workers/parse-worker.js'; import { normalizeFetchURL, routeMatches } from './route-extractors/nextjs.js'; -import { normalizeExtractedRoutePath } from './route-extractors/route-path.js'; +import { + normalizeExtractedRoutePath, + normalizeRouteMethod, + routeNodeKey, +} from './route-extractors/route-path.js'; import { extractReturnTypeName } from './type-extractors/shared.js'; const MAX_EXPORTS_PER_FILE = 500; @@ -246,11 +250,11 @@ export const processRoutesFromExtracted = async ( }; /** - * Resolve each route's handler to a real symbol UID, keyed by the normalized - * route URL (the same key the routes phase uses for the `Route` node). This is - * the Part 2 (#2138) groundwork that lets `HttpRouteExtractor.extractProvidersGraph` - * read the handler symbol from the graph instead of re-parsing source via - * `getDetections()`. + * Resolve each route's handler to a real symbol UID, keyed by the route's + * `(method, url)` identity (`routeNodeKey` — the same key the routes phase uses + * for the `Route` node). This is the Part 2 (#2138) groundwork that lets + * `HttpRouteExtractor.extractProvidersGraph` read the handler symbol from the + * graph instead of re-parsing source via `getDetections()`. * * Two route shapes, one resolution target — `(filePath, name) → nodeId`: * - Laravel framework routes (`ExtractedRoute`) carry `controllerName` + @@ -260,14 +264,18 @@ export const processRoutesFromExtracted = async ( * `handlerName` (the decorated method, captured at extraction); resolve it * directly in the route's own file. * - * First-writer-wins per URL, matching the routes phase's dedup (it keeps the - * first route registered for a URL and counts the rest as duplicates). The first - * route to claim a URL reserves it **even when its handler is unresolvable**, so - * a later same-URL route can never stamp its handler onto the first route's Route - * node (the routes phase made that first route the node-winner). Routes whose - * handler cannot be *uniquely* resolved (no name, zero matches, or an ambiguous - * same-name match) carry no `handlerSymbolId`; the extractor then falls back to - * source scan for that route (fail-open, no regression, never a wrong handler). + * First-writer-wins per route identity, matching the routes phase's dedup (it + * keeps the first route registered for a `(method, url)` key and counts the rest + * as duplicates). The first route to claim a key reserves it **even when its + * handler is unresolvable**, so a later same-key route can never stamp its + * handler onto the first route's Route node (the routes phase made that first + * route the node-winner). Keying is `routeNodeKey(method, url)` (#2289): a + * same-URL multi-verb pair (`GET /x` + `POST /x`) resolves two handlers, one per + * node; method-less / wildcard routes key by URL alone, byte-identical to the + * pre-#2289 behavior. Routes whose handler cannot be *uniquely* resolved (no + * name, zero matches, or an ambiguous same-name match) carry no + * `handlerSymbolId`; the extractor then falls back to source scan for that route + * (fail-open, no regression, never a wrong handler). */ export function resolveRouteHandlerSymbols( model: SemanticModel, @@ -275,9 +283,9 @@ export function resolveRouteHandlerSymbols( decoratorRoutes: readonly ExtractedDecoratorRoute[], ): Map { const out = new Map(); - // URLs already claimed by an earlier route (resolved or not). Mirrors the - // routes phase `addRoute` first-writer-wins so the handler we stamp always - // belongs to the route that actually won the Route node. + // Route identities already claimed by an earlier route (resolved or not). + // Mirrors the routes phase `addRoute` first-writer-wins so the handler we + // stamp always belongs to the route that actually won the Route node. const claimed = new Set(); // Resolve a single same-file symbol by name, refusing to guess on ambiguity: @@ -287,12 +295,18 @@ export function resolveRouteHandlerSymbols( return defs.length === 1 ? defs[0]?.nodeId : undefined; }; - const claim = (routePath: string | null, prefix: string | null, symbolId: string | undefined) => { + const claim = ( + routePath: string | null, + prefix: string | null, + httpMethod: string | null | undefined, + symbolId: string | undefined, + ) => { if (!routePath) return; const url = normalizeExtractedRoutePath(routePath, prefix); - if (claimed.has(url)) return; // first-writer-wins: later same-URL routes can't override - claimed.add(url); - if (symbolId) out.set(url, symbolId); + const key = routeNodeKey(normalizeRouteMethod(httpMethod), url); + if (claimed.has(key)) return; // first-writer-wins: later same-key routes can't override + claimed.add(key); + if (symbolId) out.set(key, symbolId); }; // Laravel framework routes — controller class + method name. @@ -309,14 +323,14 @@ export function resolveRouteHandlerSymbols( } if (controllerDef) methodId = uniqueSymbolId(controllerDef.filePath, route.methodName); } - claim(route.routePath, route.prefix ?? null, methodId); + claim(route.routePath, route.prefix ?? null, route.httpMethod, methodId); } // Decorator routes (Spring / FastAPI / generic) — the decorated handler in // the route's own file. for (const dr of decoratorRoutes) { const handlerId = dr.handlerName ? uniqueSymbolId(dr.filePath, dr.handlerName) : undefined; - claim(dr.routePath, dr.prefix ?? null, handlerId); + claim(dr.routePath, dr.prefix ?? null, dr.httpMethod, handlerId); } return out; @@ -465,19 +479,29 @@ export const extractConsumerAccessedKeys = (content: string): string[] => { * Create FETCHES edges from extracted fetch() calls to matching Route nodes. * When consumerContents is provided, extracts property access patterns from * consumer files and encodes them in the edge reason field. + * + * Matching stays URL-only (#2289): a verb-less consumer (a `fetch()` call has + * no statically-known HTTP method) matches a route by URL and connects to + * **every** Route node sharing that URL — i.e. both the `GET /x` and `POST /x` + * nodes when a URL carries multiple verbs. `routeUrlToKeys` therefore maps each + * route URL to the list of `routeNodeKey` identities at that URL; a single-verb + * (or method-less) URL has a one-element list, keeping edges byte-identical to + * the pre-#2289 behavior. */ export const processNextjsFetchRoutes = ( graph: KnowledgeGraph, fetchCalls: ExtractedFetchCall[], - routeRegistry: Map, // routeURL → handlerFilePath + routeUrlToKeys: Map, // routeURL → route node keys at that URL consumerContents?: Map, // filePath → file content ) => { - // Pre-count how many routes each consumer file matches (for confidence attribution) + // Pre-count how many route URLs each consumer file matches (for confidence + // attribution). Counts once per call that matches any URL — independent of how + // many verbs share that URL — so the multi-fetch heuristic is unchanged. const routeCountByFile = new Map(); for (const call of fetchCalls) { const normalized = normalizeFetchURL(call.fetchURL); if (!normalized) continue; - for (const [routeURL] of routeRegistry) { + for (const routeURL of routeUrlToKeys.keys()) { if (routeMatches(normalized, routeURL)) { routeCountByFile.set(call.filePath, (routeCountByFile.get(call.filePath) ?? 0) + 1); break; @@ -489,10 +513,9 @@ export const processNextjsFetchRoutes = ( const normalized = normalizeFetchURL(call.fetchURL); if (!normalized) continue; - for (const [routeURL] of routeRegistry) { + for (const [routeURL, routeKeys] of routeUrlToKeys) { if (routeMatches(normalized, routeURL)) { const sourceId = generateId('File', call.filePath); - const routeNodeId = generateId('Route', routeURL); // Extract consumer accessed keys if file content is available let reason = 'fetch-url-match'; @@ -512,14 +535,18 @@ export const processNextjsFetchRoutes = ( reason = `${reason}|fetches:${fetchCount}`; } - graph.addRelationship({ - id: generateId('FETCHES', `${sourceId}->${routeNodeId}`), - sourceId, - targetId: routeNodeId, - type: 'FETCHES', - confidence: 0.9, - reason, - }); + // Connect to every Route node at this URL (one per verb). + for (const routeKey of routeKeys) { + const routeNodeId = generateId('Route', routeKey); + graph.addRelationship({ + id: generateId('FETCHES', `${sourceId}->${routeNodeId}`), + sourceId, + targetId: routeNodeId, + type: 'FETCHES', + confidence: 0.9, + reason, + }); + } break; } } diff --git a/gitnexus/src/core/ingestion/pipeline-phases/processes.ts b/gitnexus/src/core/ingestion/pipeline-phases/processes.ts index d04332a73..fd2f25cc8 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/processes.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/processes.ts @@ -17,6 +17,7 @@ import type { ToolsOutput } from './tools.js'; import type { StructureOutput } from './structure.js'; import { processProcesses, type ProcessDetectionResult } from '../process-processor.js'; import { generateId } from '../../../lib/utils.js'; +import { routeNodeKey } from '../route-extractors/route-path.js'; import { isDev } from '../utils/env.js'; import { logger } from '../../logger.js'; @@ -106,14 +107,39 @@ export const processesPhase: PipelinePhase = { // Link Route and Tool nodes to Processes if (routeRegistry.size > 0 || toolDefs.length > 0) { - const routesByFile = new Map(); - for (const [url, entry] of routeRegistry) { - let list = routesByFile.get(entry.filePath); + // Two-tier route lookup, mirroring the tool tables 10 lines below. + // Routes whose handler resolved key by `handlerSymbolId` (read from + // the Route node's graph properties — routes.ts stamps it there) and + // link ONLY to the process whose entryPoint matches; routes without a + // resolved handler fall back to a per-file bucket so we still attach + // the Route node to a same-file process (best-effort). + // + // Pre-#2289-review-P2 this was a single per-file bucket: every verb + // on a file's controller was linked to every process in that file, + // cross-wiring same-file `GET /items` and `POST /items` to each + // other's handler processes. The per-verb `handlerSymbolId` the + // routes phase stamps on the Route node was never consulted. + const routesByHandlerId = new Map(); + const routesWithoutHandlerByFile = new Map(); + for (const [, entry] of routeRegistry) { + // Push the Route node identity (`routeNodeKey`), not the bare URL, so the + // ENTRY_POINT_OF edge targets the same node id the routes phase created + // (#2289: a same-URL GET/POST pair is two distinct Route nodes). + const routeKey = routeNodeKey(entry.method, entry.url); + // Source of truth for handlerSymbolId is the Route node in the + // graph (routes.ts populates it from `routeHandlerSymbols`); the + // routes phase runs before processes (see `deps`), so the node is + // always present here. + const routeNode = ctx.graph.getNode(generateId('Route', routeKey)); + const handlerSymbolId = routeNode?.properties.handlerSymbolId as string | undefined; + const targetMap = handlerSymbolId ? routesByHandlerId : routesWithoutHandlerByFile; + const bucketKey = handlerSymbolId ?? entry.filePath; + let list = targetMap.get(bucketKey); if (!list) { list = []; - routesByFile.set(entry.filePath, list); + targetMap.set(bucketKey, list); } - list.push(url); + list.push(routeKey); } const toolsByHandlerId = new Map(); const toolsWithoutHandlerByFile = new Map(); @@ -136,10 +162,12 @@ export const processesPhase: PipelinePhase = { const entryFile = entryNode.properties.filePath; if (!entryFile) continue; - const routeURLs = routesByFile.get(entryFile); - if (routeURLs) { - for (const routeURL of routeURLs) { - const routeNodeId = generateId('Route', routeURL); + const exactRouteKeys = routesByHandlerId.get(proc.entryPointId); + const fallbackRouteKeys = routesWithoutHandlerByFile.get(entryFile); + const routeKeys = exactRouteKeys ?? fallbackRouteKeys; + if (routeKeys) { + for (const routeKey of routeKeys) { + const routeNodeId = generateId('Route', routeKey); ctx.graph.addRelationship({ id: generateId('ENTRY_POINT_OF', `${routeNodeId}->${proc.id}`), sourceId: routeNodeId, diff --git a/gitnexus/src/core/ingestion/pipeline-phases/routes.ts b/gitnexus/src/core/ingestion/pipeline-phases/routes.ts index aea78ecd3..041fbebdf 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/routes.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/routes.ts @@ -29,7 +29,11 @@ import { compiledMatcherMatchesRoute, } from '../route-extractors/middleware.js'; import { processNextjsFetchRoutes } from '../call-processor.js'; -import { normalizeExtractedRoutePath } from '../route-extractors/route-path.js'; +import { + normalizeExtractedRoutePath, + normalizeRouteMethod, + routeNodeKey, +} from '../route-extractors/route-path.js'; import { generateId } from '../../../lib/utils.js'; import { readFileContents } from '../filesystem-walker.js'; import { isDev } from '../utils/env.js'; @@ -43,6 +47,13 @@ const EXPO_NAV_PATTERNS = [ export interface RouteEntry { filePath: string; source: string; + /** + * The route's URL path (leading-slash, prefix-joined). This is the Route + * node's `name`. Stored explicitly because the registry is keyed by the + * `(method, url)` identity (`routeNodeKey`), so the key is no longer the URL + * — downstream URL consumers (middleware/fetch matching) read this instead. + */ + url: string; /** * HTTP verb for this route when ingestion knows it structurally * (Spring/Laravel framework routes and decorator routes carry @@ -138,40 +149,6 @@ function escapeRegex(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -// Re-exported for existing consumers/tests that import it from the routes phase. -export { normalizeExtractedRoutePath }; - -/** - * Canonicalize a route's HTTP verb for persistence on the Route node. - * Returns an upper-cased standard method, or `undefined` when the value - * is not a real HTTP verb. Laravel `Route::resource` / `apiResource` - * surface `httpMethod` values like `resource` / `apiResource` (they - * expand to several verbs at runtime), so they must not be stored as a - * method — leaving them `undefined` keeps the column clean and lets the - * contract extractor fall back to its source-scan path for those routes. - */ -const VALID_HTTP_METHODS = new Set([ - 'GET', - 'POST', - 'PUT', - 'PATCH', - 'DELETE', - 'HEAD', - 'OPTIONS', - 'TRACE', - 'CONNECT', -]); - -export function normalizeRouteMethod(raw: string | null | undefined): string | undefined { - if (typeof raw !== 'string') return undefined; - const verb = raw.trim().toUpperCase(); - // '*' marks a method-agnostic route (e.g. a Django function view handles any - // verb). Preserve it so the contract layer emits a wildcard provider that - // matches consumers of any method, instead of silently narrowing to GET. - if (verb === '*') return '*'; - return VALID_HTTP_METHODS.has(verb) ? verb : undefined; -} - export const routesPhase: PipelinePhase = { name: 'routes', deps: ['parse'], @@ -219,31 +196,45 @@ export const routesPhase: PipelinePhase = { if (expoAppPaths.has(p)) { const expoURL = expoFileToRouteURL(p); if (expoURL && !routeRegistry.has(expoURL)) { - routeRegistry.set(expoURL, { filePath: p, source: 'expo-filesystem-route' }); + routeRegistry.set(expoURL, { + filePath: p, + source: 'expo-filesystem-route', + url: expoURL, + }); continue; } } const nextjsURL = nextjsFileToRouteURL(p); if (nextjsURL && !routeRegistry.has(nextjsURL)) { - routeRegistry.set(nextjsURL, { filePath: p, source: 'nextjs-filesystem-route' }); + routeRegistry.set(nextjsURL, { + filePath: p, + source: 'nextjs-filesystem-route', + url: nextjsURL, + }); continue; } if (p.endsWith('.php')) { const phpURL = phpFileToRouteURL(p); if (phpURL && !routeRegistry.has(phpURL)) { - routeRegistry.set(phpURL, { filePath: p, source: 'php-file-route' }); + routeRegistry.set(phpURL, { filePath: p, source: 'php-file-route', url: phpURL }); } } } let duplicateRoutes = 0; const namedRouteRegistry = new Map(); - const addRoute = (url: string, entry: RouteEntry) => { - if (routeRegistry.has(url)) { + // Routes are keyed by their `(method, url)` identity (#2289): a same-URL + // multi-verb pair (`GET /x` + `POST /x`) is two entries, not one. Method-less + // / wildcard routes key by URL (see `routeNodeKey`), so filesystem/resource + // routes stay byte-identical. A true duplicate (same method AND url) is still + // dropped. + const addRoute = (url: string, entry: Omit) => { + const key = routeNodeKey(entry.method, url); + if (routeRegistry.has(key)) { duplicateRoutes++; return; } - routeRegistry.set(url, entry); + routeRegistry.set(key, { ...entry, url }); }; for (const route of allExtractedRoutes) { if (!route.routePath) continue; @@ -271,8 +262,8 @@ export const routesPhase: PipelinePhase = { const handlerPaths = [...routeRegistry.values()].map((e) => e.filePath); handlerContents = await readFileContents(ctx.repoPath, handlerPaths); - for (const [routeURL, entry] of routeRegistry) { - const { filePath: handlerPath, source: routeSource, method: routeMethod } = entry; + for (const [routeKey, entry] of routeRegistry) { + const { filePath: handlerPath, source: routeSource, method: routeMethod, url } = entry; const content = handlerContents.get(handlerPath); const { responseKeys, errorKeys } = content @@ -284,13 +275,13 @@ export const routesPhase: PipelinePhase = { const mwResult = content ? extractMiddlewareChain(content) : undefined; const middleware = mwResult?.chain; - const routeNodeId = generateId('Route', routeURL); - const handlerSymbolId = routeHandlerSymbols.get(routeURL); + const routeNodeId = generateId('Route', routeKey); + const handlerSymbolId = routeHandlerSymbols.get(routeKey); ctx.graph.addNode({ id: routeNodeId, label: 'Route', properties: { - name: routeURL, + name: url, filePath: handlerPath, ...(routeMethod ? { method: routeMethod } : {}), ...(handlerSymbolId ? { handlerSymbolId } : {}), @@ -344,13 +335,13 @@ export const routesPhase: PipelinePhase = { .filter((m): m is NonNullable => m !== null); let linkedCount = 0; - for (const [routeURL] of routeRegistry) { + for (const [routeKey, entry] of routeRegistry) { const matches = compiled.length === 0 || - compiled.some((cm) => compiledMatcherMatchesRoute(cm, routeURL)); + compiled.some((cm) => compiledMatcherMatchesRoute(cm, entry.url)); if (!matches) continue; - const routeNodeId = generateId('Route', routeURL); + const routeNodeId = generateId('Route', routeKey); const existing = ctx.graph.getNode(routeNodeId); if (!existing) continue; @@ -487,13 +478,19 @@ export const routesPhase: PipelinePhase = { } if (routeRegistry.size > 0 && allFetchCalls.length > 0) { - const routeURLToFile = new Map(); - for (const [url, entry] of routeRegistry) routeURLToFile.set(url, entry.filePath); + // url → [route node keys at that url] (one per verb). A verb-less fetch() + // consumer matches by URL and connects to every Route node at that URL. + const routeUrlToKeys = new Map(); + for (const [routeKey, entry] of routeRegistry) { + const existing = routeUrlToKeys.get(entry.url); + if (existing) existing.push(routeKey); + else routeUrlToKeys.set(entry.url, [routeKey]); + } const consumerPaths = [...new Set(allFetchCalls.map((c) => c.filePath))]; const consumerContents = await readFileContents(ctx.repoPath, consumerPaths); - processNextjsFetchRoutes(ctx.graph, allFetchCalls, routeURLToFile, consumerContents); + processNextjsFetchRoutes(ctx.graph, allFetchCalls, routeUrlToKeys, consumerContents); if (isDev) { logger.info( `🔗 Processed ${allFetchCalls.length} fetch() calls against ${routeRegistry.size} routes`, diff --git a/gitnexus/src/core/ingestion/route-extractors/route-path.ts b/gitnexus/src/core/ingestion/route-extractors/route-path.ts index 907574163..2aa55f846 100644 --- a/gitnexus/src/core/ingestion/route-extractors/route-path.ts +++ b/gitnexus/src/core/ingestion/route-extractors/route-path.ts @@ -2,10 +2,11 @@ * Shared route-path normalization. * * Extracted from the routes phase so both the routes phase (which creates the - * `Route` graph node, keyed by the normalized URL) and the parse phase (which - * resolves each route's handler symbol and needs the SAME key to associate the - * resolved id back to the route) can compute an identical route URL without a - * phase-to-phase import cycle. Pure string logic, no dependencies. + * `Route` graph node, keyed by `(method, url)` via `routeNodeKey` — #2289) and + * the parse phase (which resolves each route's handler symbol and needs the + * SAME key to associate the resolved id back to the route) can compute an + * identical route identity without a phase-to-phase import cycle. Pure string + * logic, no dependencies. */ /** @@ -19,3 +20,49 @@ export function normalizeExtractedRoutePath(routePath: string, prefix: string | const joined = prefixPart ? `/${prefixPart}${pathPart ? `/${pathPart}` : ''}` : `/${pathPart}`; return joined.replace(/\/+/g, '/') || '/'; } + +const VALID_HTTP_METHODS = new Set([ + 'GET', + 'POST', + 'PUT', + 'PATCH', + 'DELETE', + 'HEAD', + 'OPTIONS', + 'TRACE', + 'CONNECT', +]); + +/** + * Canonicalize a route's HTTP verb for persistence on the Route node and for + * the route identity key. Returns an upper-cased standard method, `'*'` for a + * method-agnostic route (e.g. a Django function view), or `undefined` when the + * value is not a real HTTP verb. Laravel `Route::resource` / `apiResource` + * surface values like `resource` / `apiResource` (they expand to several verbs + * at runtime), so they come back `undefined` — keeping the column clean and + * letting the contract extractor fall back to its source-scan path. + */ +export function normalizeRouteMethod(raw: string | null | undefined): string | undefined { + if (typeof raw !== 'string') return undefined; + const verb = raw.trim().toUpperCase(); + // '*' marks a method-agnostic route. Preserve it so the contract layer emits a + // wildcard provider that matches consumers of any method. + if (verb === '*') return '*'; + return VALID_HTTP_METHODS.has(verb) ? verb : undefined; +} + +/** + * The Route node identity (#2289): `(method, path)` when the verb is known and + * specific, falling back to URL-only when the method is `undefined` (filesystem + * routes, Laravel `resource`/`apiResource`) or `'*'` (method-agnostic routes, + * e.g. Django function views). The URL-fallback keeps those byte-identical to + * the pre-#2289 URL-only ids, so only genuine declaration-style multi-verb + * routes (`GET /x` + `POST /x`) split into separate nodes. + * + * Used by the routes phase (node id + registry key), the processes phase + * (ENTRY_POINT_OF), and the handler-symbol resolver — all three must key + * identically. + */ +export function routeNodeKey(method: string | undefined, url: string): string { + return method && method !== '*' ? `${method} ${url}` : url; +} diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 7a169352d..1c968c523 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -749,6 +749,30 @@ export async function runFullAnalysis( options = { ...options, force: true }; } + // ── schema-version mismatch forces full rebuild (#2289 P1) ──────── + // Mirrors the pdg-mode block above: a stamp from an older + // INCREMENTAL_SCHEMA_VERSION (e.g. pre-v5 URL-only Route ids) cannot be + // reconciled by an incremental top-up — same-commit re-analyze would + // strand stale rows next to new-schema writes. MUST sit before the + // alreadyUpToDate fast path below: an unchanged-commit clean tree would + // otherwise early-return without ever reaching the `isIncremental` gate + // that consults `schemaVersion`, defeating the bump's whole point. + // + // `schemaVersion === undefined` covers two cases that should still trip + // this guard: a non-git repo (which never stamps the field) and very old + // meta from before the field existed. Non-git repos take the + // `currentCommit === ''` rebuild branch below regardless, so the redundant + // force here is harmless; the friendlier `'pre-versioning'` log avoids a + // user-visible "stamped vundefined" line in that edge case. + if (existingMeta && existingMeta.schemaVersion !== INCREMENTAL_SCHEMA_VERSION) { + const stampedVersion = existingMeta.schemaVersion ?? 'pre-versioning'; + log( + `index schema changed (stamped v${stampedVersion}, this build is v${INCREMENTAL_SCHEMA_VERSION}); ` + + `forcing a full rebuild so persisted rows match the current schema.`, + ); + options = { ...options, force: true }; + } + // ── Early-return: already up to date ────────────────────────────── if (existingMeta && !options.force && existingMeta.lastCommit === currentCommit) { // Non-git folders have currentCommit = '' — always rebuild since we can't detect changes diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 8b18b0a17..2d97d93f3 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -244,8 +244,14 @@ export interface RepoMeta { * so the engine would silently UNDER-REPORT return-value ascent on an * incremental top-up; force a full re-analyze instead (same contract as v2/v3). * This single bump covers the whole FU-C re-index window (and the later FU-B-2). + * v5: `Route` node identity changed to `(method, url)` (#2289 — a same-URL + * GET/POST pair is now two distinct Route nodes). Every declarative-route node + * id moved from `Route:/x` to `Route:GET /x` (filesystem routes keep their + * URL-only id). The incremental writeback preserves unchanged-file rows, so a + * top-up against a pre-v5 index would strand old url-keyed Route nodes alongside + * new composite-keyed ones — force a full re-analyze instead. */ -export const INCREMENTAL_SCHEMA_VERSION = 4; +export const INCREMENTAL_SCHEMA_VERSION = 5; export interface IndexedRepo { repoPath: string; diff --git a/gitnexus/test/fixtures/multi-verb-route-app/app/api/widgets/route.ts b/gitnexus/test/fixtures/multi-verb-route-app/app/api/widgets/route.ts new file mode 100644 index 000000000..f8b991532 --- /dev/null +++ b/gitnexus/test/fixtures/multi-verb-route-app/app/api/widgets/route.ts @@ -0,0 +1,7 @@ +// Next.js App Router filesystem route → /api/widgets (method-less identity). +// Coexists with the Spring @GetMapping("/widgets") decorator route at the same +// URL: the filesystem node keeps its URL-only id, the decorator node is keyed +// `GET /api/widgets`. +export async function GET() { + return new Response('[]'); +} diff --git a/gitnexus/test/fixtures/multi-verb-route-app/src/main/java/com/example/ItemController.java b/gitnexus/test/fixtures/multi-verb-route-app/src/main/java/com/example/ItemController.java new file mode 100644 index 000000000..a2b1094a7 --- /dev/null +++ b/gitnexus/test/fixtures/multi-verb-route-app/src/main/java/com/example/ItemController.java @@ -0,0 +1,31 @@ +package com.example; + +import org.springframework.web.bind.annotation.*; + +/** + * Multi-verb route identity fixture (#2289). + * + * - GET /api/items and POST /api/items share a URL but are distinct + * declarative routes → two Route nodes keyed `(method, url)`. + * - GET /api/widgets overlaps a Next.js filesystem route at the same URL → + * a method-keyed node coexisting with the URL-only filesystem node. + */ +@RestController +@RequestMapping("/api") +public class ItemController { + + @GetMapping("/items") + public String listItems() { + return "[]"; + } + + @PostMapping("/items") + public String createItem() { + return "ok"; + } + + @GetMapping("/widgets") + public String getWidgets() { + return "[]"; + } +} diff --git a/gitnexus/test/fixtures/multi-verb-route-app/web/itemsClient.ts b/gitnexus/test/fixtures/multi-verb-route-app/web/itemsClient.ts new file mode 100644 index 000000000..195696492 --- /dev/null +++ b/gitnexus/test/fixtures/multi-verb-route-app/web/itemsClient.ts @@ -0,0 +1,17 @@ +// Verb-less consumers: a fetch() call carries no statically-known HTTP method, +// so each call matches by URL and must connect to EVERY Route node at that URL +// (both GET /api/items and POST /api/items). +export async function loadItems() { + const res = await fetch('/api/items'); + return res.json(); +} + +export async function addItem() { + const res = await fetch('/api/items', { method: 'POST' }); + return res.json(); +} + +export async function loadWidgets() { + const res = await fetch('/api/widgets'); + return res.json(); +} diff --git a/gitnexus/test/integration/multi-verb-route-identity.test.ts b/gitnexus/test/integration/multi-verb-route-identity.test.ts new file mode 100644 index 000000000..938c44305 --- /dev/null +++ b/gitnexus/test/integration/multi-verb-route-identity.test.ts @@ -0,0 +1,102 @@ +/** + * End-to-end coverage of multi-verb Route node identity (#2289). + * + * A declarative route's graph identity is `(method, url)`: a same-URL + * `GET /x` + `POST /x` pair becomes TWO Route nodes (keyed `routeNodeKey`), + * each carrying its own verb + resolved handler. Filesystem routes + * (Next.js/Expo/PHP) have no structural verb, so they keep their URL-only id — + * byte-identical to the pre-#2289 behavior — and coexist with a same-URL + * decorator route as a separate node. A verb-less `fetch()` consumer matches by + * URL and connects to EVERY Route node at that URL. + * + * Fixture: `test/fixtures/multi-verb-route-app/` + * - ItemController.java: GET /api/items, POST /api/items, GET /api/widgets + * - app/api/widgets/route.ts: Next.js filesystem route → /api/widgets + * - web/itemsClient.ts: verb-less fetch() consumers of both URLs + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'node:path'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; +import { generateId } from '../../src/lib/utils.js'; +import { routeNodeKey } from '../../src/core/ingestion/route-extractors/route-path.js'; +import type { PipelineResult } from '../../types/pipeline.js'; + +const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'multi-verb-route-app'); + +const routeId = (method: string | undefined, url: string) => + generateId('Route', routeNodeKey(method, url)); + +describe('Multi-verb Route node identity (#2289)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(FIXTURE, () => {}, {}); + }, 60_000); + + function routeNode(id: string) { + return result.graph.getNode(id); + } + + it('splits a same-URL GET/POST pair into two distinct Route nodes', () => { + const get = routeNode(routeId('GET', '/api/items')); + const post = routeNode(routeId('POST', '/api/items')); + + expect(get, 'GET /api/items Route node should exist').toBeTruthy(); + expect(post, 'POST /api/items Route node should exist').toBeTruthy(); + + // Both nodes keep the URL as their display name; the verb distinguishes them. + expect(get!.properties.name).toBe('/api/items'); + expect(post!.properties.name).toBe('/api/items'); + expect(get!.properties.method).toBe('GET'); + expect(post!.properties.method).toBe('POST'); + }); + + it('resolves each verb to its own handler symbol (re-keyed routeHandlerSymbols)', () => { + const get = routeNode(routeId('GET', '/api/items')); + const post = routeNode(routeId('POST', '/api/items')); + + const getHandler = result.graph.getNode(String(get!.properties.handlerSymbolId)); + const postHandler = result.graph.getNode(String(post!.properties.handlerSymbolId)); + + expect(getHandler?.properties.name).toBe('listItems'); + expect(postHandler?.properties.name).toBe('createItem'); + }); + + it('keeps a filesystem route URL-only (byte-identical pre-#2289 id)', () => { + const fsNode = routeNode(generateId('Route', '/api/widgets')); + expect(fsNode, 'filesystem Route node /api/widgets should exist').toBeTruthy(); + expect(fsNode!.properties.name).toBe('/api/widgets'); + // Filesystem routes carry no structural verb. + expect(fsNode!.properties.method).toBeUndefined(); + }); + + it('lets a filesystem route and a same-URL decorator route coexist as separate nodes', () => { + const fsNode = routeNode(generateId('Route', '/api/widgets')); + const decoratorNode = routeNode(routeId('GET', '/api/widgets')); + + expect(fsNode, 'URL-only filesystem node').toBeTruthy(); + expect(decoratorNode, 'GET /api/widgets decorator node').toBeTruthy(); + // Distinct ids — no collision, no first-writer-wins eviction across keys. + expect(fsNode!.id).not.toBe(decoratorNode!.id); + expect(decoratorNode!.properties.method).toBe('GET'); + }); + + it('connects a verb-less fetch() consumer to every Route node at the URL', () => { + const consumerFileId = generateId('File', 'web/itemsClient.ts'); + // Collect FETCHES targets without a test-level conditional: pass the + // shape filter as a Set membership check on the relationship type + + // sourceId, then materialize unique targetIds. + const fetchTargets = new Set( + result.graph.relationships + .filter((r) => r.type === 'FETCHES' && r.sourceId === consumerFileId) + .map((r) => r.targetId), + ); + + // /api/items: the verb-less consumer reaches BOTH the GET and POST nodes. + expect(fetchTargets.has(routeId('GET', '/api/items'))).toBe(true); + expect(fetchTargets.has(routeId('POST', '/api/items'))).toBe(true); + // /api/widgets: reaches both the filesystem node and the decorator node. + expect(fetchTargets.has(generateId('Route', '/api/widgets'))).toBe(true); + expect(fetchTargets.has(routeId('GET', '/api/widgets'))).toBe(true); + }); +}); diff --git a/gitnexus/test/integration/resolvers/express-routes.test.ts b/gitnexus/test/integration/resolvers/express-routes.test.ts index 0669bd9fb..2ab5999be 100644 --- a/gitnexus/test/integration/resolvers/express-routes.test.ts +++ b/gitnexus/test/integration/resolvers/express-routes.test.ts @@ -4,6 +4,7 @@ import { FIXTURES, getRelationships, getNodesByLabel, + getNodesByLabelFull, runPipelineFromRepo, type PipelineResult, } from './helpers.js'; @@ -40,11 +41,15 @@ describe('Express/Hono route detection', () => { expect(itemsRoute!.sourceFilePath).toContain('app.js'); }); - it('detects multiple HTTP methods on same path as single route', () => { - // /api/users has GET and POST but route registry deduplicates by path - const routes = getNodesByLabel(result, 'Route'); - const usersRoutes = routes.filter((r) => r === '/api/users'); - expect(usersRoutes).toHaveLength(1); + it('splits same-path GET/POST into one Route node per verb (#2289)', () => { + // /api/users carries both GET and POST. Route identity is now `(method, url)`, + // so the pair becomes TWO Route nodes — each keeps `/api/users` as its display + // name and is distinguished by its `method` property. (Pre-#2289 the registry + // deduplicated by URL and collapsed them into a single node.) + const usersNodes = getNodesByLabelFull(result, 'Route').filter((n) => n.name === '/api/users'); + expect(usersNodes).toHaveLength(2); + const methods = usersNodes.map((n) => n.properties.method).sort(); + expect(methods).toEqual(['GET', 'POST']); }); it('detects router.get() routes (not just app.get())', () => { diff --git a/gitnexus/test/integration/route-handler-symbol-roundtrip.test.ts b/gitnexus/test/integration/route-handler-symbol-roundtrip.test.ts index e633841c9..877409792 100644 --- a/gitnexus/test/integration/route-handler-symbol-roundtrip.test.ts +++ b/gitnexus/test/integration/route-handler-symbol-roundtrip.test.ts @@ -19,18 +19,25 @@ import { withTestLbugDB } from '../helpers/test-indexed-db.js'; import { buildTestGraph } from '../helpers/test-graph.js'; import { streamAllCSVsToDisk } from '../../src/core/lbug/csv-generator.js'; import { HANDLES_ROUTE_QUERY } from '../../src/core/group/extractors/http-route-extractor.js'; +import { generateId } from '../../src/lib/utils.js'; +import { routeNodeKey } from '../../src/core/ingestion/route-extractors/route-path.js'; const HANDLER_UID = 'Method:OrderController.java:create'; +// Composite Route id — post-#2289 the routes phase emits this shape for a +// method-bearing declarative route, so the CSV→COPY round-trip below +// exercises both the new column AND the new id format. +const ROUTE_ID = generateId('Route', routeNodeKey('POST', '/api/orders')); withTestLbugDB('route-handler-symbol-roundtrip', (handle) => { it('persists Route.handlerSymbolId through CSV→COPY and HANDLES_ROUTE_QUERY returns it', async () => { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); - // 1. Route node carrying a resolved handlerSymbolId (what the routes phase - // now stamps when resolveRouteHandlerSymbols resolves the handler). + // 1. Route node carrying a resolved handlerSymbolId, keyed by the + // composite `(method, url)` id the routes phase now stamps when + // `resolveRouteHandlerSymbols` resolves the handler. const graph = buildTestGraph([ { - id: 'Route:/api/orders', + id: ROUTE_ID, label: 'Route', name: '/api/orders', filePath: 'OrderController.java', @@ -50,10 +57,12 @@ withTestLbugDB('route-handler-symbol-roundtrip', (handle) => { await fs.mkdir(repoDir, { recursive: true }); await streamAllCSVsToDisk(graph, repoDir, csvDir); - // Sanity: route.csv header + row include the handlerSymbolId column/value. + // Sanity: route.csv header + row include the handlerSymbolId column/value, + // and the composite id round-trips into the CSV verbatim. const routeCsv = await fs.readFile(path.join(csvDir, 'route.csv'), 'utf-8'); expect(routeCsv.split('\n')[0]).toContain('handlerSymbolId'); expect(routeCsv).toContain(HANDLER_UID); + expect(routeCsv).toContain(ROUTE_ID); // 3. COPY the Route node via the production COPY query. const routeCsvPath = path.join(csvDir, 'route.csv').replace(/\\/g, '/'); @@ -64,7 +73,7 @@ withTestLbugDB('route-handler-symbol-roundtrip', (handle) => { `CREATE (:File {id: 'File:OrderController.java', name: 'OrderController.java', filePath: 'OrderController.java'})`, ); await adapter.executeQuery( - `MATCH (f:File {id: 'File:OrderController.java'}), (r:Route {id: 'Route:/api/orders'}) + `MATCH (f:File {id: 'File:OrderController.java'}), (r:Route {id: '${ROUTE_ID}'}) CREATE (f)-[:CodeRelation {type: 'HANDLES_ROUTE', confidence: 1.0, reason: 'framework-route', step: 0}]->(r)`, ); diff --git a/gitnexus/test/integration/route-method-roundtrip.test.ts b/gitnexus/test/integration/route-method-roundtrip.test.ts index 155bbb86b..147044213 100644 --- a/gitnexus/test/integration/route-method-roundtrip.test.ts +++ b/gitnexus/test/integration/route-method-roundtrip.test.ts @@ -24,16 +24,26 @@ import { withTestLbugDB } from '../helpers/test-indexed-db.js'; import { buildTestGraph } from '../helpers/test-graph.js'; import { streamAllCSVsToDisk } from '../../src/core/lbug/csv-generator.js'; import { HANDLES_ROUTE_QUERY } from '../../src/core/group/extractors/http-route-extractor.js'; +import { generateId } from '../../src/lib/utils.js'; +import { routeNodeKey } from '../../src/core/ingestion/route-extractors/route-path.js'; + +// Composite Route id — what the routes phase emits post-#2289 for a +// method-bearing declarative route. Hand-pinning the pre-#2289 URL-only +// `Route:/api/orders` shape would no longer cover the production +// CSV→COPY→`HANDLES_ROUTE_QUERY` path the COPY query has to load. +const ROUTE_ID = generateId('Route', routeNodeKey('POST', '/api/orders')); withTestLbugDB('route-method-roundtrip', (handle) => { it('persists Route.method through CSV→COPY and HANDLES_ROUTE_QUERY returns it', async () => { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); // 1. Build a graph with a single framework Route node carrying `method`, - // mirroring what the routes phase now emits for a Spring controller. + // keyed by the composite `(method, url)` id the routes phase now emits + // so the CSV row + COPY load exercise the post-#2289 id shape (a value + // containing a literal space — `Route:POST /api/orders`). const graph = buildTestGraph([ { - id: 'Route:/api/orders', + id: ROUTE_ID, label: 'Route', name: '/api/orders', filePath: 'OrderController.java', @@ -53,10 +63,13 @@ withTestLbugDB('route-method-roundtrip', (handle) => { await fs.mkdir(repoDir, { recursive: true }); await streamAllCSVsToDisk(graph, repoDir, csvDir); - // Sanity: the generated route.csv header + row include the method column. + // Sanity: the generated route.csv header + row include the method column, + // and the composite id (with its literal space) round-trips into the CSV + // — a space-in-id COPY failure on the new id format would surface here. const routeCsv = await fs.readFile(path.join(csvDir, 'route.csv'), 'utf-8'); expect(routeCsv.split('\n')[0]).toContain('method'); expect(routeCsv).toContain('POST'); + expect(routeCsv).toContain(ROUTE_ID); // 3. COPY the Route node into the real DB via the production COPY query // (exercises the new `method` column in getCopyQuery('Route')). @@ -68,7 +81,7 @@ withTestLbugDB('route-method-roundtrip', (handle) => { `CREATE (:File {id: 'File:OrderController.java', name: 'OrderController.java', filePath: 'OrderController.java'})`, ); await adapter.executeQuery( - `MATCH (f:File {id: 'File:OrderController.java'}), (r:Route {id: 'Route:/api/orders'}) + `MATCH (f:File {id: 'File:OrderController.java'}), (r:Route {id: '${ROUTE_ID}'}) CREATE (f)-[:CodeRelation {type: 'HANDLES_ROUTE', confidence: 1.0, reason: 'framework-route', step: 0}]->(r)`, ); diff --git a/gitnexus/test/unit/blade-template-routes.test.ts b/gitnexus/test/unit/blade-template-routes.test.ts index 231248184..8322302b9 100644 --- a/gitnexus/test/unit/blade-template-routes.test.ts +++ b/gitnexus/test/unit/blade-template-routes.test.ts @@ -5,9 +5,12 @@ import path from 'path'; import { extractTemplateStaticFetchCalls, isTemplateRouteCandidate, - normalizeExtractedRoutePath, routesPhase, } from '../../src/core/ingestion/pipeline-phases/routes.js'; +import { + normalizeExtractedRoutePath, + routeNodeKey, +} from '../../src/core/ingestion/route-extractors/route-path.js'; import type { ParseOutput } from '../../src/core/ingestion/pipeline-phases/parse.js'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; import { generateId } from '../../src/lib/utils.js'; @@ -144,9 +147,12 @@ describe('Blade/template static route extraction', () => { new Map([['parse', { phaseName: 'parse', output: parseOutput, durationMs: 0 }]]), ); - expect(output.routeRegistry.get('/admin/orders')).toEqual({ + // The registry is keyed by the `(method, url)` identity (#2289); the URL + // is carried on the entry's `url` field (the Route node's display name). + expect(output.routeRegistry.get(routeNodeKey('POST', '/admin/orders'))).toEqual({ filePath: 'routes/web.php', source: 'framework-route', + url: '/admin/orders', method: 'POST', }); diff --git a/gitnexus/test/unit/call-summary-schema-version.test.ts b/gitnexus/test/unit/call-summary-schema-version.test.ts index dc092b87a..a3edc9685 100644 --- a/gitnexus/test/unit/call-summary-schema-version.test.ts +++ b/gitnexus/test/unit/call-summary-schema-version.test.ts @@ -73,21 +73,25 @@ describe('CALL_SUMMARY relation-type exclusion (U-C1)', () => { }); describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { - it('INCREMENTAL_SCHEMA_VERSION is bumped to 4 (CALL_SUMMARY re-index window)', () => { - expect(INCREMENTAL_SCHEMA_VERSION).toBe(4); + it('INCREMENTAL_SCHEMA_VERSION is bumped to 5 (multi-verb Route identity re-index window)', () => { + expect(INCREMENTAL_SCHEMA_VERSION).toBe(5); }); - it('a pre-v4 (v3) stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => { + it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => { // The reuse gate at run-analyze.ts:920 is exactly this strict equality on // the persisted `existingMeta.schemaVersion` (a plain number, possibly // absent on a legacy stamp). Replicate it as a typed predicate. const passesReuseGate = (stampedSchemaVersion: number | undefined): boolean => stampedSchemaVersion === INCREMENTAL_SCHEMA_VERSION; - // A pre-v4 (v3) index has no CALL_SUMMARY edges → must NOT reuse → full re-analyze. + // A pre-v4 (v3) index has no CALL_SUMMARY edges → must NOT reuse. expect(passesReuseGate(3)).toBe(false); + // A pre-v5 (v4) index predates the multi-verb Route identity change → its + // persisted Route nodes use the old url-only ids, so an incremental top-up + // would strand them → must NOT reuse. + expect(passesReuseGate(4)).toBe(false); // A legacy stamp with no schemaVersion at all is likewise rejected. expect(passesReuseGate(undefined)).toBe(false); // A current-version stamp passes the gate (incremental top-up eligible). - expect(passesReuseGate(4)).toBe(true); + expect(passesReuseGate(5)).toBe(true); }); }); diff --git a/gitnexus/test/unit/incremental-orchestration.test.ts b/gitnexus/test/unit/incremental-orchestration.test.ts index a00d67244..34eb5cdac 100644 --- a/gitnexus/test/unit/incremental-orchestration.test.ts +++ b/gitnexus/test/unit/incremental-orchestration.test.ts @@ -224,4 +224,45 @@ describe('runFullAnalysis — incremental orchestration', () => { await repo.cleanup(); } }, 300_000); + + // Regression for #2289 review P1: a pre-v5 stamp (e.g. v4 with url-only + // Route ids) re-analyzed on the SAME commit must NOT early-return on the + // `alreadyUpToDate` fast path — otherwise the v5 schema bump's + // re-keyed-Route migration is silently bypassed and stale URL-only Route + // rows persist alongside any new composite-keyed writes. The schemaVersion + // gate (mirrors pdgModeMismatch's slot above the fast path) must force a + // full rebuild before lastCommit-equality short-circuits the pipeline. + it('a pre-v5 schemaVersion stamp forces a full rebuild on an unchanged-commit re-analyze', async () => { + const repo = await setupMiniRepo(); + try { + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + // First run stamps schemaVersion = INCREMENTAL_SCHEMA_VERSION (v5). + await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); + const { storagePath } = getStoragePaths(repo.dbPath); + const meta = await loadMeta(storagePath); + expect(meta).not.toBeNull(); + expect(meta!.schemaVersion).toBe(INCREMENTAL_SCHEMA_VERSION); + + // Simulate a repo indexed at the SAME commit by a pre-v5 GitNexus + // build: rewrite meta.json with schemaVersion = 4. lastCommit and + // working tree are untouched, so without the schemaVersion gate the + // run-analyze fast path would early-return `alreadyUpToDate=true` + // and never touch the stale Route rows. + const downgraded: RepoMeta = { ...meta!, schemaVersion: 4 }; + await saveMeta(storagePath, downgraded); + + const reanalyzed = await runFullAnalysis( + repo.dbPath, + { skipAgentsMd: true }, + { onProgress: () => {} }, + ); + // Pipeline actually ran (schemaVersion mismatch → force=true). + expect(reanalyzed.alreadyUpToDate).toBeUndefined(); + // And the meta is stamped back to v5 (the rebuild path runs saveMeta). + const restamped = await loadMeta(storagePath); + expect(restamped!.schemaVersion).toBe(INCREMENTAL_SCHEMA_VERSION); + } finally { + await repo.cleanup(); + } + }, 300_000); }); diff --git a/gitnexus/test/unit/resolve-route-handler-symbols.test.ts b/gitnexus/test/unit/resolve-route-handler-symbols.test.ts index 4a214991b..b6ff406f7 100644 --- a/gitnexus/test/unit/resolve-route-handler-symbols.test.ts +++ b/gitnexus/test/unit/resolve-route-handler-symbols.test.ts @@ -4,15 +4,18 @@ * Pins the P2 fixes from the review: * - ambiguity → fail-open: a same-name lookup returning ≠1 yields NO * handlerSymbolId (never an arbitrary `[0]` guess). - * - first-writer-wins reservation: the first route to claim a URL reserves it - * even when its handler is unresolvable, so a later same-URL route can't - * stamp its handler onto the (node-winning) first route's slot. - * - happy path: a uniquely-resolvable handler is stamped, keyed by the - * normalized URL. + * - first-writer-wins reservation: the first route to claim a route identity + * reserves it even when its handler is unresolvable, so a later same-identity + * route can't stamp its handler onto the (node-winning) first route's slot. + * - happy path: a uniquely-resolvable handler is stamped, keyed by the route's + * `(method, url)` identity (`routeNodeKey`). + * - multi-verb identity (#2289): `GET /x` and `POST /x` are distinct keys, so + * each verb's handler is resolved independently. */ import { describe, it, expect } from 'vitest'; import { createSemanticModel } from '../../src/core/ingestion/model/index.js'; import { resolveRouteHandlerSymbols } from '../../src/core/ingestion/call-processor.js'; +import { routeNodeKey } from '../../src/core/ingestion/route-extractors/route-path.js'; import type { ExtractedDecoratorRoute } from '../../src/core/ingestion/workers/parse-worker.js'; import type { ExtractedRoute } from '../../src/core/ingestion/route-extractors/laravel.js'; @@ -31,13 +34,15 @@ function decoratorRoute(overrides: Partial = {}): Extra } describe('resolveRouteHandlerSymbols — decorator routes', () => { - it('uniquely-resolvable handler is stamped, keyed by normalized URL', () => { + const GET_ORDERS = routeNodeKey('GET', '/orders'); + + it('uniquely-resolvable handler is stamped, keyed by (method, url) identity', () => { const model = createSemanticModel(); model.symbols.add(FILE, 'list', 'method:OrderController.list', 'Method'); const out = resolveRouteHandlerSymbols(model, [], [decoratorRoute()]); - expect(out.get('/orders')).toBe('method:OrderController.list'); + expect(out.get(GET_ORDERS)).toBe('method:OrderController.list'); }); it('ambiguous same-name handler (overloads) → fail-open, no stamp', () => { @@ -48,7 +53,7 @@ describe('resolveRouteHandlerSymbols — decorator routes', () => { const out = resolveRouteHandlerSymbols(model, [], [decoratorRoute()]); - expect(out.has('/orders')).toBe(false); + expect(out.has(GET_ORDERS)).toBe(false); }); it('unknown handler name → fail-open, no stamp', () => { @@ -56,10 +61,10 @@ describe('resolveRouteHandlerSymbols — decorator routes', () => { const out = resolveRouteHandlerSymbols(model, [], [decoratorRoute({ handlerName: 'ghost' })]); - expect(out.has('/orders')).toBe(false); + expect(out.has(GET_ORDERS)).toBe(false); }); - it('same-URL collision: an unresolvable first route reserves the slot so a later resolvable route cannot stamp it', () => { + it('same-identity collision: an unresolvable first route reserves the slot so a later resolvable route cannot stamp it', () => { const model = createSemanticModel(); // Only the SECOND route's handler exists in the model. model.symbols.add(FILE, 'second', 'method:OrderController.second', 'Method'); @@ -68,20 +73,20 @@ describe('resolveRouteHandlerSymbols — decorator routes', () => { model, [], [ - // First route at /orders is unresolvable (no such symbol) — but it is the - // route the routes phase makes the Route-node winner, so its slot must be - // reserved (empty), NOT filled by the later same-URL route. + // First route at GET /orders is unresolvable (no such symbol) — but it is + // the route the routes phase makes the Route-node winner, so its slot must + // be reserved (empty), NOT filled by the later same-identity route. decoratorRoute({ handlerName: 'first_missing' }), decoratorRoute({ handlerName: 'second' }), ], ); - // Reservation holds: the URL carries no (wrong) handler. Pre-fix this would - // have stamped `method:OrderController.second` onto the first route's node. - expect(out.has('/orders')).toBe(false); + // Reservation holds: the identity carries no (wrong) handler. Pre-fix this + // would have stamped `method:OrderController.second` onto the first node. + expect(out.has(GET_ORDERS)).toBe(false); }); - it('first-writer-wins among resolvable same-URL routes', () => { + it('first-writer-wins among resolvable same-identity routes', () => { const model = createSemanticModel(); model.symbols.add(FILE, 'winner', 'method:OrderController.winner', 'Method'); model.symbols.add(FILE, 'loser', 'method:OrderController.loser', 'Method'); @@ -92,7 +97,27 @@ describe('resolveRouteHandlerSymbols — decorator routes', () => { [decoratorRoute({ handlerName: 'winner' }), decoratorRoute({ handlerName: 'loser' })], ); - expect(out.get('/orders')).toBe('method:OrderController.winner'); + expect(out.get(GET_ORDERS)).toBe('method:OrderController.winner'); + }); + + it('multi-verb same URL (#2289): GET /orders and POST /orders resolve to distinct keys', () => { + const model = createSemanticModel(); + model.symbols.add(FILE, 'list', 'method:OrderController.list', 'Method'); + model.symbols.add(FILE, 'create', 'method:OrderController.create', 'Method'); + + const out = resolveRouteHandlerSymbols( + model, + [], + [ + decoratorRoute({ httpMethod: 'GET', handlerName: 'list' }), + decoratorRoute({ httpMethod: 'POST', decoratorName: 'PostMapping', handlerName: 'create' }), + ], + ); + + // Two independent identities — neither evicts the other (the pre-#2289 + // URL-only key would have dropped POST /orders as a duplicate of GET /orders). + expect(out.get(routeNodeKey('GET', '/orders'))).toBe('method:OrderController.list'); + expect(out.get(routeNodeKey('POST', '/orders'))).toBe('method:OrderController.create'); }); }); @@ -123,7 +148,7 @@ describe('resolveRouteHandlerSymbols — Laravel framework routes', () => { const out = resolveRouteHandlerSymbols(model, [laravelRoute()], []); - expect(out.get('/orders')).toBe('method:OrderController.index'); + expect(out.get(routeNodeKey('GET', '/orders'))).toBe('method:OrderController.index'); }); it('ambiguous controller short-name (>1) → fail-open, no stamp', () => { @@ -143,6 +168,6 @@ describe('resolveRouteHandlerSymbols — Laravel framework routes', () => { const out = resolveRouteHandlerSymbols(model, [laravelRoute()], []); - expect(out.has('/orders')).toBe(false); + expect(out.has(routeNodeKey('GET', '/orders'))).toBe(false); }); }); diff --git a/gitnexus/test/unit/route-process-linking.test.ts b/gitnexus/test/unit/route-process-linking.test.ts new file mode 100644 index 000000000..46bb69815 --- /dev/null +++ b/gitnexus/test/unit/route-process-linking.test.ts @@ -0,0 +1,279 @@ +/** + * Unit coverage for ENTRY_POINT_OF re-keying (#2289). + * + * The processes phase links a Route node to the execution flow rooted at its + * handler file. After the multi-verb identity change the edge must target the + * `(method, url)` node id (`routeNodeKey`), not the bare URL — so a same-URL + * GET/POST pair produces TWO distinct ENTRY_POINT_OF edges, one per verb node. + */ +import { describe, expect, it } from 'vitest'; +import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; +import { processesPhase } from '../../src/core/ingestion/pipeline-phases/processes.js'; +import { generateId } from '../../src/lib/utils.js'; +import { routeNodeKey } from '../../src/core/ingestion/route-extractors/route-path.js'; +import type { + PhaseResult, + PipelineContext, +} from '../../src/core/ingestion/pipeline-phases/types.js'; +import type { KnowledgeGraph } from '../../src/core/graph/types.js'; +import type { GraphNode, GraphRelationship, NodeLabel } from 'gitnexus-shared'; + +function makeCtx(graph: KnowledgeGraph, repoPath = 'D:/tmp/repo'): PipelineContext { + return { repoPath, graph, onProgress: () => {}, pipelineStart: 0 }; +} + +function phaseResult(phaseName: string, output: T): PhaseResult { + return { phaseName, output, durationMs: 0 }; +} + +function addNode( + graph: KnowledgeGraph, + id: string, + label: NodeLabel, + name: string, + filePath: string, +) { + graph.addNode({ + id, + label, + properties: { name, filePath, startLine: 1, endLine: 1, isExported: true, content: '' }, + } satisfies GraphNode); +} + +function addCall(graph: KnowledgeGraph, sourceId: string, targetId: string) { + graph.addRelationship({ + id: `${sourceId}->${targetId}`, + sourceId, + targetId, + type: 'CALLS', + confidence: 1, + reason: 'direct', + } satisfies GraphRelationship); +} + +// Mirror what `routes.ts` puts on the graph: a Route node carries +// `handlerSymbolId` in its properties when `routeHandlerSymbols` could +// resolve the handler. processes.ts now reads that field off the graph +// node (#2289 review P2), so multi-verb tests must seed it here. +function addRouteNode( + graph: KnowledgeGraph, + routeKey: string, + url: string, + filePath: string, + method: string, + handlerSymbolId: string, +) { + graph.addNode({ + id: generateId('Route', routeKey), + label: 'Route', + properties: { name: url, filePath, method, handlerSymbolId }, + } as GraphNode); +} + +describe('Route → process linking (ENTRY_POINT_OF) re-keying', () => { + it('emits one ENTRY_POINT_OF edge per verb node for a same-URL GET/POST pair', async () => { + const graph = createKnowledgeGraph(); + const filePath = 'OrderController.java'; + const entry = 'Function:OrderController.listOrders'; + const helper = 'Function:OrderController.helper'; + const leaf = 'Function:OrderController.leaf'; + + addNode(graph, generateId('File', filePath), 'File', 'OrderController.java', filePath); + addNode(graph, entry, 'Function', 'listOrders', filePath); + addNode(graph, helper, 'Function', 'helper', filePath); + addNode(graph, leaf, 'Function', 'leaf', filePath); + // 3-step call chain rooted in the handler file → forms a process. + addCall(graph, entry, helper); + addCall(graph, helper, leaf); + + // Two routes sharing /orders, distinct verbs (mirrors the routes phase + // registry shape: keyed by routeNodeKey, each entry carries url + method). + const routeRegistry = new Map([ + [ + routeNodeKey('GET', '/orders'), + { filePath, source: 'decorator-GetMapping', url: '/orders', method: 'GET' }, + ], + [ + routeNodeKey('POST', '/orders'), + { filePath, source: 'decorator-PostMapping', url: '/orders', method: 'POST' }, + ], + ]); + + await processesPhase.execute( + makeCtx(graph), + new Map([ + ['structure', phaseResult('structure', { totalFiles: 1 })], + ['communities', phaseResult('communities', { communityResult: { memberships: [] } })], + ['routes', phaseResult('routes', { routeRegistry })], + ['tools', phaseResult('tools', { toolDefs: [] })], + ]), + ); + + // ENTRY_POINT_OF edges whose target is a Process. The processes phase emits + // these by Route node id (it does not require the Route node to pre-exist); + // toolDefs is empty here, so every such edge is a route→process link. + const routeEntryEdges = graph.relationships.filter( + (r) => r.type === 'ENTRY_POINT_OF' && graph.getNode(r.targetId)?.label === 'Process', + ); + const sources = new Set(routeEntryEdges.map((r) => r.sourceId)); + + // Both composite-keyed Route nodes anchor the flow — not the bare `Route:/orders`. + expect(sources.has(generateId('Route', routeNodeKey('GET', '/orders')))).toBe(true); + expect(sources.has(generateId('Route', routeNodeKey('POST', '/orders')))).toBe(true); + // The pre-#2289 URL-only id must NOT be used. + expect(sources.has(generateId('Route', '/orders'))).toBe(false); + }); + + // Regression for #2289 review P2 (weak form): pre-fix the linker built + // `routesByFile` and fanned every same-file Route to every same-file + // process, so a same-file `GET /items` + `POST /items` pair where ONLY + // `listItems` has a detected process would still attach BOTH Route nodes + // to that single process. Post-fix `routesByHandlerId` keys by the Route + // node's `handlerSymbolId` (read from graph properties) and only the + // verb whose handler is the process's entryPoint links — the other verb + // (with no detected process for its handler) links to nothing. + it('does not cross-wire same-file sibling verbs when only one handler has a detected process', async () => { + const graph = createKnowledgeGraph(); + const filePath = 'ItemController.java'; + const listItems = 'Function:ItemController.listItems'; + const createItem = 'Function:ItemController.createItem'; + const helper = 'Function:ItemController.helper'; + const leaf = 'Function:ItemController.leaf'; + + addNode(graph, generateId('File', filePath), 'File', 'ItemController.java', filePath); + addNode(graph, listItems, 'Function', 'listItems', filePath); + addNode(graph, createItem, 'Function', 'createItem', filePath); + addNode(graph, helper, 'Function', 'helper', filePath); + addNode(graph, leaf, 'Function', 'leaf', filePath); + // Only `listItems` has a 3-step chain → forms a process whose entryPoint + // is `listItems`. `createItem` has no calls, so no process is rooted there. + addCall(graph, listItems, helper); + addCall(graph, helper, leaf); + + // Both routes share /items, distinct verbs. The registry carries + // url + method only; `handlerSymbolId` lives on the Route graph node + // (mirrors what routes.ts does — see addRouteNode below). + const routeRegistry = new Map([ + [ + routeNodeKey('GET', '/items'), + { filePath, source: 'decorator-GetMapping', url: '/items', method: 'GET' }, + ], + [ + routeNodeKey('POST', '/items'), + { filePath, source: 'decorator-PostMapping', url: '/items', method: 'POST' }, + ], + ]); + addRouteNode(graph, routeNodeKey('GET', '/items'), '/items', filePath, 'GET', listItems); + addRouteNode(graph, routeNodeKey('POST', '/items'), '/items', filePath, 'POST', createItem); + + await processesPhase.execute( + makeCtx(graph), + new Map([ + ['structure', phaseResult('structure', { totalFiles: 1 })], + ['communities', phaseResult('communities', { communityResult: { memberships: [] } })], + ['routes', phaseResult('routes', { routeRegistry })], + ['tools', phaseResult('tools', { toolDefs: [] })], + ]), + ); + + const routeEntryEdges = graph.relationships.filter( + (r) => r.type === 'ENTRY_POINT_OF' && graph.getNode(r.targetId)?.label === 'Process', + ); + const sources = new Set(routeEntryEdges.map((r) => r.sourceId)); + const getNodeId = generateId('Route', routeNodeKey('GET', '/items')); + const postNodeId = generateId('Route', routeNodeKey('POST', '/items')); + + // GET → listItems process: the only handler-matched link that should fire. + expect(sources.has(getNodeId)).toBe(true); + // POST's handlerSymbolId (createItem on the POST Route node) has no + // matching process, so POST must link to NOTHING. Pre-fix this would + // have wrongly attached POST to the listItems process via filePath + // fan-out. + expect(sources.has(postNodeId)).toBe(false); + }); + + // Regression for #2289 review P2 (strong form, mirrors reviewer's exact + // trigger): "one controller file with `GET /items -> listItems()` and + // `POST /items -> createItem()`, each with its own detected process." + // Pre-fix `routesByFile` collapses both routes under `ItemController.java` + // and links every routeKey to every process whose entry is in that file — + // producing the 4-edge cross-wire (GET→listItemsProc, GET→createItemProc, + // POST→listItemsProc, POST→createItemProc). Post-fix only the 2 matched + // edges fire (GET→listItemsProc, POST→createItemProc), and route_map / + // impact can no longer attribute the POST flow to GET or vice versa. + it('links each verb to ONLY its own per-handler process when both handlers form distinct processes', async () => { + const graph = createKnowledgeGraph(); + const filePath = 'ItemController.java'; + const listItems = 'Function:ItemController.listItems'; + const createItem = 'Function:ItemController.createItem'; + const listHelper = 'Function:ItemController.listHelper'; + const listLeaf = 'Function:ItemController.listLeaf'; + const createHelper = 'Function:ItemController.createHelper'; + const createLeaf = 'Function:ItemController.createLeaf'; + + addNode(graph, generateId('File', filePath), 'File', 'ItemController.java', filePath); + addNode(graph, listItems, 'Function', 'listItems', filePath); + addNode(graph, createItem, 'Function', 'createItem', filePath); + addNode(graph, listHelper, 'Function', 'listHelper', filePath); + addNode(graph, listLeaf, 'Function', 'listLeaf', filePath); + addNode(graph, createHelper, 'Function', 'createHelper', filePath); + addNode(graph, createLeaf, 'Function', 'createLeaf', filePath); + // Two independent 3-step chains → two processes, one per verb's handler. + addCall(graph, listItems, listHelper); + addCall(graph, listHelper, listLeaf); + addCall(graph, createItem, createHelper); + addCall(graph, createHelper, createLeaf); + + const routeRegistry = new Map([ + [ + routeNodeKey('GET', '/items'), + { filePath, source: 'decorator-GetMapping', url: '/items', method: 'GET' }, + ], + [ + routeNodeKey('POST', '/items'), + { filePath, source: 'decorator-PostMapping', url: '/items', method: 'POST' }, + ], + ]); + addRouteNode(graph, routeNodeKey('GET', '/items'), '/items', filePath, 'GET', listItems); + addRouteNode(graph, routeNodeKey('POST', '/items'), '/items', filePath, 'POST', createItem); + + await processesPhase.execute( + makeCtx(graph), + new Map([ + ['structure', phaseResult('structure', { totalFiles: 1 })], + ['communities', phaseResult('communities', { communityResult: { memberships: [] } })], + ['routes', phaseResult('routes', { routeRegistry })], + ['tools', phaseResult('tools', { toolDefs: [] })], + ]), + ); + + const getNodeId = generateId('Route', routeNodeKey('GET', '/items')); + const postNodeId = generateId('Route', routeNodeKey('POST', '/items')); + + // Resolve each Route → its linked processes' entryPointIds (not the + // synthetic Process ids), so verb-precision can be asserted without + // test-level conditionals: filter on edge shape, then map to the + // target Process node's entryPointId, then bucket by sourceId. + const routeProcessEdges = graph.relationships.filter( + (r) => + r.type === 'ENTRY_POINT_OF' && + (r.sourceId === getNodeId || r.sourceId === postNodeId) && + graph.getNode(r.targetId)?.label === 'Process', + ); + const entriesByRoute = new Map>( + routeProcessEdges.map((r) => [ + r.sourceId, + new Set([String(graph.getNode(r.targetId)?.properties.entryPointId ?? '')]), + ]), + ); + // (Each route here has exactly one matched process, so the Map's + // last-writer-wins is fine; if the count grows, switch to a reducer.) + + // GET Route links to EXACTLY the listItems-rooted process — not createItem's. + expect(entriesByRoute.get(getNodeId)).toEqual(new Set([listItems])); + // POST Route links to EXACTLY the createItem-rooted process — not listItems's. + expect(entriesByRoute.get(postNodeId)).toEqual(new Set([createItem])); + // Total route→process edges: 2 (one per verb), not the pre-fix 4-edge cross-wire. + expect(routeProcessEdges.length).toBe(2); + }); +}); diff --git a/gitnexus/test/unit/run-analyze.test.ts b/gitnexus/test/unit/run-analyze.test.ts index 791cda8ae..dd18c4428 100644 --- a/gitnexus/test/unit/run-analyze.test.ts +++ b/gitnexus/test/unit/run-analyze.test.ts @@ -7,7 +7,12 @@ import { deriveEmbeddingCap, DEFAULT_EMBEDDING_NODE_LIMIT, } from '../../src/core/embedding-mode.js'; -import { getStoragePaths, saveMeta, type RepoMeta } from '../../src/storage/repo-manager.js'; +import { + getStoragePaths, + saveMeta, + INCREMENTAL_SCHEMA_VERSION, + type RepoMeta, +} from '../../src/storage/repo-manager.js'; import { taintModelVersion } from '../../src/core/ingestion/taint/typescript-model.js'; import { createTempDir } from '../helpers/test-db.js'; @@ -40,6 +45,10 @@ describe('run-analyze module', () => { repoPath: tmpRepo.dbPath, lastCommit: currentCommit, indexedAt: new Date().toISOString(), + // Stamp current schema version so the run-analyze schema-mismatch + // guard (#2289 P1) does not force a rebuild and short-circuit the + // alreadyUpToDate fast path this test exercises. + schemaVersion: INCREMENTAL_SCHEMA_VERSION, }; await saveMeta(storagePath, meta); @@ -79,12 +88,16 @@ describe('run-analyze module', () => { }).trim(); // Flat slot owned by main; feature/x has its own up-to-date branch index. + // Both metas stamp the current schema version so the run-analyze + // schema-mismatch guard (#2289 P1) does not force a rebuild before the + // fast path runs. const flat = getStoragePaths(tmpRepo.dbPath); await saveMeta(flat.storagePath, { repoPath: tmpRepo.dbPath, lastCommit: commit, indexedAt: new Date().toISOString(), branch: 'main', + schemaVersion: INCREMENTAL_SCHEMA_VERSION, }); const branch = getStoragePaths(tmpRepo.dbPath, 'feature/x'); await saveMeta(path.dirname(branch.metaPath), { @@ -92,6 +105,7 @@ describe('run-analyze module', () => { lastCommit: commit, indexedAt: new Date().toISOString(), branch: 'feature/x', + schemaVersion: INCREMENTAL_SCHEMA_VERSION, }); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); diff --git a/gitnexus/test/unit/spring-route-extractor-parity.test.ts b/gitnexus/test/unit/spring-route-extractor-parity.test.ts index 1ef79d7ae..bf7b9a9e8 100644 --- a/gitnexus/test/unit/spring-route-extractor-parity.test.ts +++ b/gitnexus/test/unit/spring-route-extractor-parity.test.ts @@ -19,7 +19,7 @@ import Parser from 'tree-sitter'; import Java from 'tree-sitter-java'; import { extractSpringRoutes } from '../../src/core/ingestion/route-extractors/spring.js'; import { JAVA_HTTP_PLUGIN } from '../../src/core/group/extractors/http-patterns/java.js'; -import { normalizeExtractedRoutePath } from '../../src/core/ingestion/pipeline-phases/routes.js'; +import { normalizeExtractedRoutePath } from '../../src/core/ingestion/route-extractors/route-path.js'; function parse(code: string): Parser.Tree { const parser = new Parser();