feat(ingestion/routes): give Route nodes a (method, url) identity (#2289) (#2302)

* feat(ingestion/routes): give Route nodes a (method, url) identity (#2289)

Route node identity was URL-only, so a same-URL multi-verb pair
(GET /x + POST /x) collapsed into a single node and the second verb's
handler and execution flow were silently lost. Route identity is now
(method, url) via routeNodeKey(method, url): a known, specific verb keys
as "METHOD url", while a method-less route (filesystem routes — Next.js /
Expo / PHP — and Laravel resource/apiResource) or a wildcard "*" route
(e.g. Django function views) falls back to URL-only. The fallback is
byte-identical to the previous URL-only ids, so only genuine
declaration-style multi-verb routes split into separate nodes.

The identity key is shared across the three phases that must agree on the
Route node id:
- routes phase: registry key + node id + handler-symbol lookup; the Route
  node still carries the bare URL as its display name.
- call-processor: resolveRouteHandlerSymbols re-keyed by identity so each
  verb resolves its own handler; a verb-less fetch() consumer matches by
  URL and connects to every Route node at that URL (one per verb).
- processes phase: ENTRY_POINT_OF targets the identity-keyed node id.

Bumps INCREMENTAL_SCHEMA_VERSION 4 -> 5: persisted pre-v5 Route nodes use
the old url-only ids, so an incremental top-up would strand them alongside
new composite-keyed nodes — force a full re-analyze instead.

Part of #2280.

* fix(ingestion/routes): address PR #2302 review (P1/P2/P3)

P1 — Schema v5 fast-path bypass (run-analyze.ts):
  Adds a schemaVersion-mismatch guard above the alreadyUpToDate early-return,
  mirroring the pdgModeMismatch slot. Without it, a same-commit re-analyze on
  a pre-v5 stamp returned alreadyUpToDate without ever reaching the
  isIncremental gate, defeating the v5 schema bump's migration intent.
  Regression test covers: analyze (stamps v5) → meta downgrade to v4 → same
  commit re-analyze must NOT early-return and meta restamps to v5.

P2 — ENTRY_POINT_OF handler-aware linking (processes.ts):
  Pre-fix routesByFile fanned every same-file Route to every same-file
  process, cross-wiring same-file GET/POST handlers. Now reads handlerSymbolId
  off the Route graph node (the source of truth routes.ts stamps) into
  routesByHandlerId, with a routesWithoutHandlerByFile fallback — mirrors the
  Tool linking precedent 10 lines below. Two regression tests: weak form
  (only one handler has a process; sibling verb does not get spuriously
  attached) and strong form (both handlers form distinct processes; each
  Route links to exactly its own entryPoint, 2 edges not pre-fix 4).

P2 — Roundtrip composite-id (route-{method,handler-symbol}-roundtrip):
  Both tests now seed the Route node with
  generateId('Route', routeNodeKey('POST', '/api/orders')) and run the
  Cypher MATCH against the composite id, exercising the literal-space-in-id
  through CSV→COPY→HANDLES_ROUTE_QUERY. A space-in-id escape regression
  would surface here instead of being silently swallowed by the extractor's
  catch.

P3 — doc-drift + test if:
  - route-path.ts:4 — header updated to "(method, url) via routeNodeKey"
  - java.ts:684 — drop "Route nodes are URL-keyed"; #2289 closes that gap
  - manifest-extractor.ts:196 — explicit that Route node *id* is composite
    while route.name remains the bare URL
  - multi-verb-route-identity.test.ts:88 — forEachRelationship+if rewritten
    as a .filter().map() chain (no test-level conditional). New
    route-process-linking tests are also if-free.

Validation: tsc clean, prettier clean, 9 touched suites / 43 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(ingestion/routes): drop routes.ts re-export, fix CI fast-path tests

Two follow-ups on PR #2302's CHANGES_REQUESTED review:

1. Drop `routes.ts` re-export of `normalizeExtractedRoutePath` /
   `normalizeRouteMethod` / `routeNodeKey` (per @magyargergo's inline
   comment at routes.ts:153 — the symbols already live in
   `route-extractors/route-path.ts` and consumers should import them
   from the source, not via a routes-phase indirection that was kept
   only as a compat shim during the #2289 refactor). Updated the two
   remaining callers (blade-template-routes / spring-route-extractor-
   parity tests) to import directly from `route-extractors/route-path.js`.
   `call-processor.ts` and `processes.ts` already import from the source.

2. Fix two `run-analyze.test.ts` fast-path tests that started failing
   on CI after the schema-version mismatch guard landed (
   "creates .gitnexus/.gitignore on the already-up-to-date fast path"
   and "reports isPrimaryBranch false for an up-to-date non-primary
   branch"). The test fixtures hand-built a RepoMeta with NO
   schemaVersion field; with the guard now checking
   `existingMeta.schemaVersion !== INCREMENTAL_SCHEMA_VERSION`, that
   pre-versioning shape was treated as a mismatch and forced a rebuild,
   short-circuiting the fast path the tests exercise. Stamp the current
   schemaVersion on those fixtures so they reflect the post-#2289 meta
   shape production actually writes (`runFullAnalysis` always stamps
   the field on git repos — see meta save site).

Validation: tsc clean, prettier clean, 11 touched suites / 80 tests pass.

---------

Co-authored-by: henry <zhangwei2017@unipus.cn>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
henry201605 2026-06-26 14:59:46 +08:00 committed by GitHub
parent 576e81442e
commit 8bef64baa1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 843 additions and 159 deletions

View file

@ -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

View file

@ -192,10 +192,12 @@ export class ManifestExtractor {
try {
let rows: Record<string, unknown>[];
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

View file

@ -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<string, string> {
const out = new Map<string, string>();
// 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<string>();
// 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<string, string>, // routeURL → handlerFilePath
routeUrlToKeys: Map<string, string[]>, // routeURL → route node keys at that URL
consumerContents?: Map<string, string>, // 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<string, number>();
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;
}
}

View file

@ -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<ProcessesOutput> = {
// Link Route and Tool nodes to Processes
if (routeRegistry.size > 0 || toolDefs.length > 0) {
const routesByFile = new Map<string, string[]>();
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<string, string[]>();
const routesWithoutHandlerByFile = new Map<string, string[]>();
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<string, string[]>();
const toolsWithoutHandlerByFile = new Map<string, string[]>();
@ -136,10 +162,12 @@ export const processesPhase: PipelinePhase<ProcessesOutput> = {
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,

View file

@ -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<RoutesOutput> = {
name: 'routes',
deps: ['parse'],
@ -219,31 +196,45 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
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<string, string>();
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<RouteEntry, 'url'>) => {
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<RoutesOutput> = {
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<RoutesOutput> = {
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<RoutesOutput> = {
.filter((m): m is NonNullable<typeof m> => 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<RoutesOutput> = {
}
if (routeRegistry.size > 0 && allFetchCalls.length > 0) {
const routeURLToFile = new Map<string, string>();
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<string, string[]>();
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`,

View file

@ -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;
}

View file

@ -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

View file

@ -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;

View file

@ -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('[]');
}

View file

@ -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 "[]";
}
}

View file

@ -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();
}

View file

@ -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);
});
});

View file

@ -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())', () => {

View file

@ -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)`,
);

View file

@ -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)`,
);

View file

@ -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',
});

View file

@ -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);
});
});

View file

@ -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);
});

View file

@ -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<ExtractedDecoratorRoute> = {}): 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);
});
});

View file

@ -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<T>(phaseName: string, output: T): PhaseResult<T> {
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<string, Set<string>>(
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);
});
});

View file

@ -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');

View file

@ -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();