gitnexus 1.6.9-rc.7 → 1.6.9-rc.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -574,6 +574,24 @@ function scanSpringProject(files) {
574
574
  export const JAVA_HTTP_PLUGIN = {
575
575
  name: 'java-http',
576
576
  language: Java,
577
+ // routeCoverage intentionally LEFT at the default 'partial' (#2138 Part 2).
578
+ // The graph provider set is a strict *subset* of this scan()'s provider set —
579
+ // ingestion does NOT emit a Route node for (1) array-form `@GetMapping({...})`,
580
+ // (2) interface-inherited Spring routes, or (3) the 2nd verb of a same-URL
581
+ // GET+POST pair (Route nodes are URL-keyed). Declaring 'complete' here would
582
+ // let the parse-skip drop those group-only providers. Java flips to 'complete'
583
+ // only once ingestion provider extraction matches this scan (a follow-up:
584
+ // array-form query branch + interface-inheritance emission + per-verb Route
585
+ // identity). `hasConsumerSignals` below is kept ready for that flip.
586
+ // Consumer signals this plugin's scan() can detect: RestTemplate / WebClient /
587
+ // OkHttp / Java-HttpClient / Apache-HttpClient call sites, OpenFeign
588
+ // (`@FeignClient` + `@RequestLine`) interfaces, and Spring 6 HTTP Interface
589
+ // `@(Get|...)Exchange` / `@HttpExchange`. A provider-covered file containing
590
+ // any of these must still be parsed so its consumer contracts are not dropped
591
+ // (ingestion emits no FETCHES for Java). Conservative by design.
592
+ hasConsumerSignals(content) {
593
+ return /\brestTemplate\b|\bwebClient\b|Request\.Builder|HttpRequest|HttpMethod\.|new\s+Http(Get|Post|Put|Delete|Patch)\b|@RequestLine|@FeignClient|Exchange/.test(content);
594
+ },
577
595
  scan(tree) {
578
596
  const out = [];
579
597
  // ─── Spring providers + OpenFeign consumers (one query pass) ────
@@ -104,6 +104,17 @@ function isHttpUrlLiteral(path) {
104
104
  export const PHP_HTTP_PLUGIN = {
105
105
  name: 'php-http',
106
106
  language: PHP.php_only,
107
+ // Laravel `Route::<verb>(...)` definitions are emitted as Route nodes by
108
+ // ingestion, so the graph is authoritative for PHP providers (#2138 Part 2).
109
+ routeCoverage: 'complete',
110
+ // Consumer signals scan() can detect: Laravel `Http::<verb>`, Guzzle client
111
+ // `->get/post/.../request(...)`, and `file_get_contents` of an HTTP URL. A
112
+ // provider-covered file with any of these must still be parsed (ingestion
113
+ // emits no FETCHES for PHP). Conservative — the `->verb(` shape over-matches
114
+ // ordinary method calls, which only costs a parse, never data.
115
+ hasConsumerSignals(content) {
116
+ return /Http::|file_get_contents|->\s*(get|post|put|delete|patch|request)\s*\(/i.test(content);
117
+ },
107
118
  scan(tree) {
108
119
  const out = [];
109
120
  for (const match of runCompiledPatterns(PHP_PATTERNS.laravelRoute, tree)) {
@@ -825,6 +825,20 @@ function joinPrefix(prefix, route) {
825
825
  export const PYTHON_HTTP_PLUGIN = {
826
826
  name: 'python-http',
827
827
  language: Python,
828
+ // routeCoverage intentionally LEFT at the default 'partial' (#2138 Part 2).
829
+ // It would be a no-op even if set to 'complete': FastAPI decorator routes set
830
+ // no handlerName (generic worker path) and Django sets methodName: null, so no
831
+ // Python file ever resolves a handlerSymbolId and none would be parse-skipped.
832
+ // Declaring 'complete' now is only a latent trap for the moment a follow-up
833
+ // gives FastAPI routes a handlerName. `hasConsumerSignals` is kept (and is a
834
+ // true superset of scan()'s consumer shapes) so the precondition already holds
835
+ // when Python is later flipped to 'complete'.
836
+ // Consumer signals scan() can detect: `requests.<verb>`/`requests.request`,
837
+ // `httpx` (sync/async client), the `uri=`/`url=` keyword/variable wrapper
838
+ // calls, plus aiohttp/urllib. Conservative — over-matching only costs a parse.
839
+ hasConsumerSignals(content) {
840
+ return /\brequests\s*\.|\bhttpx\b|\baiohttp\b|\burllib\b|\burlopen\b|\buri\s*=|\burl\s*=/.test(content);
841
+ },
828
842
  prepareRepo({ files, parser, readFile, parseSource }) {
829
843
  return buildPythonRepoContext(files, parser, readFile, parseSource);
830
844
  },
@@ -71,6 +71,43 @@ export interface HttpLanguagePlugin {
71
71
  name: string;
72
72
  /** tree-sitter grammar object (passed to the shared parser). */
73
73
  language: unknown;
74
+ /**
75
+ * Whether ingestion is known to emit a `Route` graph node for EVERY
76
+ * provider route in this language (Spring/FastAPI/Laravel annotations are
77
+ * extracted into Route nodes during parse). When `'complete'`, the
78
+ * orchestrator may skip the source-scan + tree-sitter parse for a file whose
79
+ * graph provider routes all resolved a handler symbol (#2138 Part 2) — the
80
+ * graph is authoritative, the scan would only re-discover the same routes.
81
+ *
82
+ * Defaults to `'partial'` (the safe assumption): the source scan always runs,
83
+ * so a language whose ingestion coverage is incomplete never loses routes.
84
+ * This is a deliberate, per-language trust assertion — set it only for
85
+ * languages whose route ingestion is provably complete.
86
+ */
87
+ routeCoverage?: 'complete' | 'partial';
88
+ /**
89
+ * Cheap, parse-free pre-check used by the parse-skip optimization (#2138
90
+ * Part 2). Given a file's raw source text, return `false` ONLY when the file
91
+ * provably contains no outbound-HTTP (consumer) call that this plugin's
92
+ * `scan()` would detect; return `true` on any doubt.
93
+ *
94
+ * Why it exists: `routeCoverage: 'complete'` asserts *provider* Route-node
95
+ * completeness only. A provider-covered file may ALSO be a consumer (e.g. a
96
+ * Spring `@RestController` that calls `restTemplate`/`webClient`, a Laravel
97
+ * controller using Guzzle, a FastAPI handler calling `requests`/`httpx`).
98
+ * Ingestion's `FETCHES` edges are JS/TS-only, so the graph cannot back up
99
+ * those server-side consumers — they come solely from the source scan. The
100
+ * orchestrator may therefore skip a provider-covered file's parse only when
101
+ * this returns `false`; otherwise the file is still scanned so its consumer
102
+ * contracts are not dropped.
103
+ *
104
+ * MUST be implemented by any plugin whose `scan()` can emit `'consumer'`
105
+ * detections AND that declares `routeCoverage: 'complete'`; otherwise that
106
+ * language's provider-covered files are never parse-skipped (safe, no win).
107
+ * The check is intentionally conservative — over-matching only costs a parse
108
+ * that could have been skipped; it never drops data.
109
+ */
110
+ hasConsumerSignals?(content: string): boolean;
74
111
  /**
75
112
  * Optional pre-pass: walk the relevant files in the repo and produce
76
113
  * an opaque context that `scan` can use to resolve cross-file facts.
@@ -23,7 +23,7 @@ import type { ExtractedContract, RepoHandle } from '../types.js';
23
23
  * `http-patterns/index.ts`: register a new `HttpLanguagePlugin` and
24
24
  * widen `HTTP_SCAN_GLOB` if needed.
25
25
  */
26
- export declare const HANDLES_ROUTE_QUERY = "\nMATCH (handlerFile:File)-[r:CodeRelation {type: 'HANDLES_ROUTE'}]->(route:Route)\nRETURN handlerFile.id AS fileId, handlerFile.filePath AS filePath,\n route.name AS routePath, route.id AS routeId,\n route.method AS routeMethod,\n route.responseKeys AS responseKeys,\n r.reason AS routeSource";
26
+ export declare const HANDLES_ROUTE_QUERY = "\nMATCH (handlerFile:File)-[r:CodeRelation {type: 'HANDLES_ROUTE'}]->(route:Route)\nRETURN handlerFile.id AS fileId, handlerFile.filePath AS filePath,\n route.name AS routePath, route.id AS routeId,\n route.method AS routeMethod,\n route.handlerSymbolId AS handlerSymbolId,\n route.responseKeys AS responseKeys,\n r.reason AS routeSource";
27
27
  /**
28
28
  * Canonicalize a provider-side HTTP path for contract-id generation:
29
29
  * - strip query string
@@ -38,6 +38,7 @@ MATCH (handlerFile:File)-[r:CodeRelation {type: 'HANDLES_ROUTE'}]->(route:Route)
38
38
  RETURN handlerFile.id AS fileId, handlerFile.filePath AS filePath,
39
39
  route.name AS routePath, route.id AS routeId,
40
40
  route.method AS routeMethod,
41
+ route.handlerSymbolId AS handlerSymbolId,
41
42
  route.responseKeys AS responseKeys,
42
43
  r.reason AS routeSource`;
43
44
  const FETCHES_QUERY = `
@@ -248,13 +249,44 @@ export class HttpRouteExtractor {
248
249
  cachedDetections.clear();
249
250
  };
250
251
  const files = await getScannedFiles();
251
- await collectProjectDetections(files);
252
- const graphProviders = dbExecutor != null ? await this.extractProvidersGraph(dbExecutor, getDetections) : [];
253
- // Source scan always runs to capture routes in languages/files not covered
254
- // by graph edges; the glob and per-file parse results are cached above.
255
- const providers = this.mergeGraphAndSourceContracts(graphProviders, await this.extractProvidersSourceScan(files, getDetections));
252
+ // Run the graph provider pass FIRST. After #2138 Part 2 it reads handler
253
+ // symbols from the graph (no source parse for resolved routes), so it can
254
+ // report which files are fully graph-covered BEFORE we decide what to
255
+ // parse. Files fully covered by a `routeCoverage: 'complete'` language are
256
+ // candidates to skip the source scan + tree-sitter parse — but only their
257
+ // *providers* are graph-authoritative; the consumer-safety gate below
258
+ // removes any candidate that still needs scanning for outbound calls.
259
+ const coveredFiles = new Set();
260
+ const graphProviders = dbExecutor != null
261
+ ? await this.extractProvidersGraph(dbExecutor, getDetections, coveredFiles)
262
+ : [];
263
+ // Consumer-safety gate (#2138 Part 2): `extractProvidersGraph` marks a file
264
+ // covered on *provider* grounds (all HANDLES_ROUTE rows resolved + a
265
+ // `routeCoverage: 'complete'` language). But a provider-covered file may also
266
+ // be a *consumer* (a controller that calls RestTemplate/WebClient/Guzzle/
267
+ // requests/...), and ingestion emits no FETCHES edges for those server-side
268
+ // languages — the graph can't back them up. So a covered file is only truly
269
+ // safe to skip (parse) when its plugin can PROVE, from a cheap parse-free
270
+ // text scan, that it holds no such consumer call. Anything else (a positive
271
+ // signal, no `hasConsumerSignals` hook, or an unreadable file) stays in the
272
+ // scan set so its consumer contracts are preserved.
273
+ for (const f of [...coveredFiles]) {
274
+ const plugin = getPluginForFile(f);
275
+ const content = readSafe(repoPath, f);
276
+ const provenNoConsumer = content != null && typeof plugin?.hasConsumerSignals === 'function'
277
+ ? plugin.hasConsumerSignals(content) === false
278
+ : false;
279
+ if (!provenNoConsumer)
280
+ coveredFiles.delete(f);
281
+ }
282
+ // Everything the graph did not fully cover still gets a full source scan
283
+ // (fail-open: partial-coverage languages, unresolved routes, and graph-less
284
+ // runs all land here).
285
+ const scanFiles = files.filter((f) => !coveredFiles.has(f));
286
+ await collectProjectDetections(scanFiles);
287
+ const providers = this.mergeGraphAndSourceContracts(graphProviders, await this.extractProvidersSourceScan(scanFiles, getDetections));
256
288
  const graphConsumers = dbExecutor != null ? await this.extractConsumersGraph(dbExecutor, getDetections) : [];
257
- const consumers = this.mergeGraphAndSourceContracts(graphConsumers, await this.extractConsumersSourceScan(files, getDetections));
289
+ const consumers = this.mergeGraphAndSourceContracts(graphConsumers, await this.extractConsumersSourceScan(scanFiles, getDetections));
258
290
  return [...providers, ...consumers];
259
291
  }
260
292
  async scanFiles(repoPath) {
@@ -272,8 +304,13 @@ export class HttpRouteExtractor {
272
304
  });
273
305
  }
274
306
  // ─── Graph-assisted providers ──────────────────────────────────────
275
- async extractProvidersGraph(db, getDetections) {
307
+ async extractProvidersGraph(db, getDetections, coveredFiles) {
276
308
  const out = [];
309
+ // Per-file coverage tracking (#2138 Part 2): a file is "fully graph-covered"
310
+ // when every one of its HANDLES_ROUTE rows resolved a handlerSymbolId AND its
311
+ // language plugin declares `routeCoverage: 'complete'`. Such files can skip
312
+ // the source scan + parse entirely — the graph is authoritative for them.
313
+ const fileAllResolved = new Map();
277
314
  let rows;
278
315
  try {
279
316
  rows = await db(HANDLES_ROUTE_QUERY);
@@ -298,66 +335,90 @@ export class HttpRouteExtractor {
298
335
  .trim()
299
336
  .toUpperCase();
300
337
  let method = (graphMethod || null) ?? methodFromRouteReason(routeSource);
301
- // Look up handler name (and backfill method if missing) from the
302
- // plugin's scan of the handler file. This replaces the old
303
- // regex-based `inferMethodFromFileScan` and `pickJavaHandlerName`
304
- // helpers tree-sitter gives both pieces of information
305
- // structurally. Always run the lookup: even when method is set by
306
- // `methodFromRouteReason`, we still need the handler name.
307
- const detections = filePath ? await getDetections(filePath) : [];
308
- const providerDetections = detections.filter((d) => d.role === 'provider');
309
- let handlerName = null;
310
- const normalizedRoute = normalizeHttpPath(routePath);
311
- // Candidates share the same normalized path. When multiple
312
- // detections at the same path exist (e.g. GET + POST /api/orders
313
- // in one router), a blind `.find()` silently returned the first
314
- // verb — attaching the wrong handler and, when method was not
315
- // already pinned by the route reason, the wrong method too.
316
- // Disambiguate by method when we know it; refuse to guess when
317
- // we don't.
318
- const candidates = providerDetections.filter((d) => normalizeHttpPath(d.path) === normalizedRoute);
319
- let match;
320
- const ambiguousCandidates = !method && candidates.length > 1;
321
- if (method) {
322
- match = candidates.find((d) => d.method === method);
323
- }
324
- else if (candidates.length === 1) {
325
- match = candidates[0];
326
- }
327
- // else: multiple candidates + unknown method → leave match
328
- // undefined so handlerName stays null and skip symbol
329
- // enrichment below, keeping the file-basename fallback instead
330
- // of letting pickSymbolUid silently pick the first Function /
331
- // Method in the file (which reintroduces the mis-attribution
332
- // we were trying to avoid). Method stays at the conservative
333
- // 'GET' default set below.
334
- if (match) {
335
- if (!method)
336
- method = match.method;
337
- handlerName = match.name;
338
+ const handlerSymbolId = String(row.handlerSymbolId ?? '').trim();
339
+ const fileId = row.fileId ?? row[0];
340
+ // Track per-file resolution for the parse-skip coverage set: a file stays
341
+ // "all resolved" only while every one of its rows carries a handlerSymbolId.
342
+ if (filePath) {
343
+ const prev = fileAllResolved.get(filePath);
344
+ fileAllResolved.set(filePath, (prev ?? true) && handlerSymbolId.length > 0);
338
345
  }
339
- if (!method)
340
- method = 'GET';
341
- const pathNorm = normalizeHttpPath(routePath);
342
- const cid = contractIdFor(method, pathNorm);
346
+ const pathNormEarly = normalizeHttpPath(routePath);
343
347
  let symbolUid = '';
344
348
  let symbolName = path.basename(filePath) || 'handler';
345
349
  let symPath = filePath;
346
- const fileId = row.fileId ?? row[0];
347
- if (fileId && !ambiguousCandidates) {
348
- try {
349
- const syms = await db(CONTAINS_QUERY, { fileId });
350
- if (syms.length > 0) {
351
- const picked = pickSymbolUid(syms, handlerName);
352
- symbolUid = picked.uid;
353
- symbolName = picked.name;
354
- symPath = picked.filePath || filePath;
350
+ if (handlerSymbolId) {
351
+ // Fast path (Part 2, #2138): the handler symbol was resolved during
352
+ // ingestion and persisted on the Route node, so we read it straight
353
+ // from the graph and SKIP the `getDetections()` source-scan/parse the
354
+ // legacy path needed just to recover the handler name. CONTAINS is a
355
+ // cheap graph query (no tree-sitter parse) used only to surface the
356
+ // handler's display name/path; the uid is authoritative regardless.
357
+ if (!method)
358
+ method = 'GET';
359
+ symbolUid = handlerSymbolId;
360
+ if (fileId) {
361
+ try {
362
+ const syms = await db(CONTAINS_QUERY, { fileId });
363
+ const hit = syms.find((s) => String(s.uid ?? s[0]) === handlerSymbolId);
364
+ if (hit) {
365
+ symbolName = String(hit.name ?? hit[1]) || symbolName;
366
+ symPath = String(hit.filePath ?? hit[2]) || filePath;
367
+ }
368
+ }
369
+ catch {
370
+ /* keep the authoritative uid + basename fallback */
355
371
  }
356
372
  }
357
- catch {
358
- /* ignore */
373
+ }
374
+ else {
375
+ // Legacy fallback (old index / unresolved handler): recover the handler
376
+ // name from the plugin's scan of the handler file (this parses source).
377
+ // Always run the lookup: even when method is set, we still need the name.
378
+ const detections = filePath ? await getDetections(filePath) : [];
379
+ const providerDetections = detections.filter((d) => d.role === 'provider');
380
+ let handlerName = null;
381
+ // Candidates share the same normalized path. When multiple detections at
382
+ // the same path exist (GET + POST /api/orders in one router), a blind
383
+ // `.find()` silently returned the first verb — attaching the wrong
384
+ // handler/method. Disambiguate by method when known; refuse to guess.
385
+ const candidates = providerDetections.filter((d) => normalizeHttpPath(d.path) === pathNormEarly);
386
+ let match;
387
+ const ambiguousCandidates = !method && candidates.length > 1;
388
+ if (method) {
389
+ match = candidates.find((d) => d.method === method);
390
+ }
391
+ else if (candidates.length === 1) {
392
+ match = candidates[0];
393
+ }
394
+ // else: multiple candidates + unknown method → leave match undefined so
395
+ // handlerName stays null and we skip symbol enrichment, keeping the
396
+ // file-basename fallback rather than letting pickSymbolUid pick the
397
+ // first Function/Method (which reintroduces mis-attribution).
398
+ if (match) {
399
+ if (!method)
400
+ method = match.method;
401
+ handlerName = match.name;
402
+ }
403
+ if (!method)
404
+ method = 'GET';
405
+ if (fileId && !ambiguousCandidates) {
406
+ try {
407
+ const syms = await db(CONTAINS_QUERY, { fileId });
408
+ if (syms.length > 0) {
409
+ const picked = pickSymbolUid(syms, handlerName);
410
+ symbolUid = picked.uid;
411
+ symbolName = picked.name;
412
+ symPath = picked.filePath || filePath;
413
+ }
414
+ }
415
+ catch {
416
+ /* ignore */
417
+ }
359
418
  }
360
419
  }
420
+ const pathNorm = pathNormEarly;
421
+ const cid = contractIdFor(method, pathNorm);
361
422
  out.push({
362
423
  contractId: cid,
363
424
  type: 'http',
@@ -375,6 +436,17 @@ export class HttpRouteExtractor {
375
436
  },
376
437
  });
377
438
  }
439
+ // Populate the parse-skip coverage set: files whose every provider route
440
+ // resolved a handler symbol AND whose language declares complete ingestion
441
+ // route coverage. Fail-open — any unresolved row or a 'partial' language
442
+ // leaves the file out, so it still gets a full source scan.
443
+ if (coveredFiles) {
444
+ for (const [fp, allResolved] of fileAllResolved) {
445
+ if (allResolved && getPluginForFile(fp)?.routeCoverage === 'complete') {
446
+ coveredFiles.add(fp);
447
+ }
448
+ }
449
+ }
378
450
  return out;
379
451
  }
380
452
  // ─── Source-scan providers ─────────────────────────────────────────
@@ -17,6 +17,7 @@
17
17
  import { KnowledgeGraph } from '../graph/types.js';
18
18
  import type { SemanticModel, SymbolTableReader } from './model/index.js';
19
19
  import type { ExtractedRoute, ExtractedFetchCall } from './workers/parse-worker.js';
20
+ import type { ExtractedDecoratorRoute } from './workers/parse-worker.js';
20
21
  /** Per-file resolved type bindings for exported symbols.
21
22
  * Consumed by the cross-file re-resolution / enrichment pass. */
22
23
  export type ExportedTypeMap = Map<string, Map<string, string>>;
@@ -57,6 +58,31 @@ export declare function buildExportedTypeMapFromGraph(graph: KnowledgeGraph, sym
57
58
  * passes — acceptable for an edge whose target method could not be resolved.
58
59
  */
59
60
  export declare const processRoutesFromExtracted: (graph: KnowledgeGraph, extractedRoutes: ExtractedRoute[], model: SemanticModel, onProgress?: (current: number, total: number) => void) => Promise<void>;
61
+ /**
62
+ * Resolve each route's handler to a real symbol UID, keyed by the normalized
63
+ * route URL (the same key the routes phase uses for the `Route` node). This is
64
+ * the Part 2 (#2138) groundwork that lets `HttpRouteExtractor.extractProvidersGraph`
65
+ * read the handler symbol from the graph instead of re-parsing source via
66
+ * `getDetections()`.
67
+ *
68
+ * Two route shapes, one resolution target — `(filePath, name) → nodeId`:
69
+ * - Laravel framework routes (`ExtractedRoute`) carry `controllerName` +
70
+ * `methodName`; resolve the controller (qualified-first) then the method in
71
+ * the controller's own file (mirrors `processRoutesFromExtracted`).
72
+ * - Decorator routes (`ExtractedDecoratorRoute`, e.g. Spring/FastAPI) carry
73
+ * `handlerName` (the decorated method, captured at extraction); resolve it
74
+ * directly in the route's own file.
75
+ *
76
+ * First-writer-wins per URL, matching the routes phase's dedup (it keeps the
77
+ * first route registered for a URL and counts the rest as duplicates). The first
78
+ * route to claim a URL reserves it **even when its handler is unresolvable**, so
79
+ * a later same-URL route can never stamp its handler onto the first route's Route
80
+ * node (the routes phase made that first route the node-winner). Routes whose
81
+ * handler cannot be *uniquely* resolved (no name, zero matches, or an ambiguous
82
+ * same-name match) carry no `handlerSymbolId`; the extractor then falls back to
83
+ * source scan for that route (fail-open, no regression, never a wrong handler).
84
+ */
85
+ export declare function resolveRouteHandlerSymbols(model: SemanticModel, extractedRoutes: readonly ExtractedRoute[], decoratorRoutes: readonly ExtractedDecoratorRoute[]): Map<string, string>;
60
86
  /**
61
87
  * Extract property access keys from a consumer file's source code near fetch calls.
62
88
  *
@@ -17,6 +17,7 @@
17
17
  import { generateId } from '../../lib/utils.js';
18
18
  import { yieldToEventLoop } from './utils/event-loop.js';
19
19
  import { normalizeFetchURL, routeMatches } from './route-extractors/nextjs.js';
20
+ import { normalizeExtractedRoutePath } from './route-extractors/route-path.js';
20
21
  import { extractReturnTypeName } from './type-extractors/shared.js';
21
22
  const MAX_EXPORTS_PER_FILE = 500;
22
23
  const MAX_TYPE_NAME_LENGTH = 256;
@@ -213,6 +214,78 @@ export const processRoutesFromExtracted = async (graph, extractedRoutes, model,
213
214
  }
214
215
  onProgress?.(extractedRoutes.length, extractedRoutes.length);
215
216
  };
217
+ /**
218
+ * Resolve each route's handler to a real symbol UID, keyed by the normalized
219
+ * route URL (the same key the routes phase uses for the `Route` node). This is
220
+ * the Part 2 (#2138) groundwork that lets `HttpRouteExtractor.extractProvidersGraph`
221
+ * read the handler symbol from the graph instead of re-parsing source via
222
+ * `getDetections()`.
223
+ *
224
+ * Two route shapes, one resolution target — `(filePath, name) → nodeId`:
225
+ * - Laravel framework routes (`ExtractedRoute`) carry `controllerName` +
226
+ * `methodName`; resolve the controller (qualified-first) then the method in
227
+ * the controller's own file (mirrors `processRoutesFromExtracted`).
228
+ * - Decorator routes (`ExtractedDecoratorRoute`, e.g. Spring/FastAPI) carry
229
+ * `handlerName` (the decorated method, captured at extraction); resolve it
230
+ * directly in the route's own file.
231
+ *
232
+ * First-writer-wins per URL, matching the routes phase's dedup (it keeps the
233
+ * first route registered for a URL and counts the rest as duplicates). The first
234
+ * route to claim a URL reserves it **even when its handler is unresolvable**, so
235
+ * a later same-URL route can never stamp its handler onto the first route's Route
236
+ * node (the routes phase made that first route the node-winner). Routes whose
237
+ * handler cannot be *uniquely* resolved (no name, zero matches, or an ambiguous
238
+ * same-name match) carry no `handlerSymbolId`; the extractor then falls back to
239
+ * source scan for that route (fail-open, no regression, never a wrong handler).
240
+ */
241
+ export function resolveRouteHandlerSymbols(model, extractedRoutes, decoratorRoutes) {
242
+ const out = new Map();
243
+ // URLs already claimed by an earlier route (resolved or not). Mirrors the
244
+ // routes phase `addRoute` first-writer-wins so the handler we stamp always
245
+ // belongs to the route that actually won the Route node.
246
+ const claimed = new Set();
247
+ // Resolve a single same-file symbol by name, refusing to guess on ambiguity:
248
+ // exactly one match → its nodeId; zero or many → undefined (fail-open).
249
+ const uniqueSymbolId = (filePath, name) => {
250
+ const defs = model.symbols.lookupExactAll(filePath, name);
251
+ return defs.length === 1 ? defs[0]?.nodeId : undefined;
252
+ };
253
+ const claim = (routePath, prefix, symbolId) => {
254
+ if (!routePath)
255
+ return;
256
+ const url = normalizeExtractedRoutePath(routePath, prefix);
257
+ if (claimed.has(url))
258
+ return; // first-writer-wins: later same-URL routes can't override
259
+ claimed.add(url);
260
+ if (symbolId)
261
+ out.set(url, symbolId);
262
+ };
263
+ // Laravel framework routes — controller class + method name.
264
+ for (const route of extractedRoutes) {
265
+ let methodId;
266
+ if (route.controllerName && route.methodName) {
267
+ let controllerDef;
268
+ if (route.controllerQualifiedName) {
269
+ controllerDef = resolveControllerByQualifiedName(model, route.controllerQualifiedName);
270
+ }
271
+ if (!controllerDef) {
272
+ const controllerDefs = model.types.lookupClassByName(route.controllerName);
273
+ if (controllerDefs.length === 1)
274
+ controllerDef = controllerDefs[0];
275
+ }
276
+ if (controllerDef)
277
+ methodId = uniqueSymbolId(controllerDef.filePath, route.methodName);
278
+ }
279
+ claim(route.routePath, route.prefix ?? null, methodId);
280
+ }
281
+ // Decorator routes (Spring / FastAPI / generic) — the decorated handler in
282
+ // the route's own file.
283
+ for (const dr of decoratorRoutes) {
284
+ const handlerId = dr.handlerName ? uniqueSymbolId(dr.filePath, dr.handlerName) : undefined;
285
+ claim(dr.routePath, dr.prefix ?? null, handlerId);
286
+ }
287
+ return out;
288
+ }
216
289
  /** Common method names on response/data objects that are NOT property accesses */
217
290
  // Properties/methods to ignore when extracting consumer accessed keys from `data.X` patterns.
218
291
  // Avoids false positives from Fetch API, Array, Object, Promise, and DOM access on variables
@@ -66,6 +66,10 @@ export declare function runChunkedParseAndResolve(graph: KnowledgeGraph, scanned
66
66
  allToolDefs: ExtractedToolDef[];
67
67
  allORMQueries: ExtractedORMQuery[];
68
68
  bindingAccumulator: BindingAccumulator;
69
+ /** Route URL → resolved handler symbol UID (Part 2, #2138). Lets the routes
70
+ * phase stamp `handlerSymbolId` on Route nodes so contract extraction can
71
+ * read the handler from the graph instead of re-parsing source. */
72
+ routeHandlerSymbols: ReadonlyMap<string, string>;
69
73
  /** SemanticModel populated during parse — scope-resolution reads its
70
74
  * TypeRegistry / MethodRegistry / SymbolTable indexes. */
71
75
  model: MutableSemanticModel;
@@ -19,7 +19,7 @@ import { mergeChunkResults, dispatchChunkParse } from '../parsing-processor.js';
19
19
  import { fileContentHash, computeChunkHash, loadParseCacheChunk, persistParseCacheChunk, PARSE_CACHE_VERSION, } from '../../../storage/parse-cache.js';
20
20
  import { clearParsedFileStore, persistParsedFileChunk, getDurableParsedFileDir, loadDurableParsedFileIndex, restoreDurableParsedFileShard, } from '../../../storage/parsedfile-store.js';
21
21
  import { DEFAULT_PDG_MAX_FUNCTION_LINES } from '../cfg/collect.js';
22
- import { processRoutesFromExtracted, buildExportedTypeMapFromGraph, } from '../call-processor.js';
22
+ import { processRoutesFromExtracted, resolveRouteHandlerSymbols, buildExportedTypeMapFromGraph, } from '../call-processor.js';
23
23
  import { createSemanticModel } from '../model/index.js';
24
24
  import { getLanguageFromFilename, } from '../../../_shared/index.js';
25
25
  import { readFileContents } from '../filesystem-walker.js';
@@ -1048,6 +1048,9 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
1048
1048
  }
1049
1049
  }
1050
1050
  logHeapProbe('parse-impl-return', `exportedTypeMap=${exportedTypeMap.size} parsedFiles=${allParsedFiles.length} nodes=${graph.nodeCount}`);
1051
+ // Part 2 (#2138): resolve each route's handler to a real symbol UID now that
1052
+ // the model is fully populated and decorator-route prefixes are finalized.
1053
+ const routeHandlerSymbols = resolveRouteHandlerSymbols(model, allExtractedRoutes, allDecoratorRoutes);
1051
1054
  return {
1052
1055
  exportedTypeMap,
1053
1056
  allFetchCalls,
@@ -1057,6 +1060,7 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
1057
1060
  allToolDefs,
1058
1061
  allORMQueries,
1059
1062
  bindingAccumulator,
1063
+ routeHandlerSymbols,
1060
1064
  model,
1061
1065
  // Whether a worker pool was actually constructed for this run. False means
1062
1066
  // no pool was needed: a warm all-cache-hit run replays cached worker output
@@ -39,6 +39,9 @@ export interface ParseOutput {
39
39
  readonly allDecoratorRoutes: readonly ExtractedDecoratorRoute[];
40
40
  readonly allToolDefs: readonly ExtractedToolDef[];
41
41
  readonly allORMQueries: readonly ExtractedORMQuery[];
42
+ /** Route URL → resolved handler symbol UID (Part 2, #2138). Consumed by the
43
+ * routes phase to stamp `handlerSymbolId` on Route nodes. */
44
+ readonly routeHandlerSymbols: ReadonlyMap<string, string>;
42
45
  bindingAccumulator: BindingAccumulator;
43
46
  /** SemanticModel populated during parse — scope-resolution reads its
44
47
  * TypeRegistry / MethodRegistry / SymbolTable indexes. */
@@ -11,6 +11,7 @@
11
11
  * @output routeRegistry, handlerContents
12
12
  */
13
13
  import type { PipelinePhase } from './types.js';
14
+ import { normalizeExtractedRoutePath } from '../route-extractors/route-path.js';
14
15
  export interface RouteEntry {
15
16
  filePath: string;
16
17
  source: string;
@@ -34,6 +35,6 @@ export interface TemplateFetchCall {
34
35
  }
35
36
  export declare const isTemplateRouteCandidate: (filePath: string) => boolean;
36
37
  export declare function extractTemplateStaticFetchCalls(filePath: string, content: string, namedRouteUrls?: ReadonlyMap<string, string>): TemplateFetchCall[];
37
- export declare function normalizeExtractedRoutePath(routePath: string, prefix: string | null): string;
38
+ export { normalizeExtractedRoutePath };
38
39
  export declare function normalizeRouteMethod(raw: string | null | undefined): string | undefined;
39
40
  export declare const routesPhase: PipelinePhase<RoutesOutput>;
@@ -18,6 +18,7 @@ import { phpFileToRouteURL } from '../route-extractors/php.js';
18
18
  import { extractResponseShapes, extractPHPResponseShapes, } from '../route-extractors/response-shapes.js';
19
19
  import { extractMiddlewareChain, extractNextjsMiddlewareConfig, compileMatcher, compiledMatcherMatchesRoute, } from '../route-extractors/middleware.js';
20
20
  import { processNextjsFetchRoutes } from '../call-processor.js';
21
+ import { normalizeExtractedRoutePath } from '../route-extractors/route-path.js';
21
22
  import { generateId } from '../../../lib/utils.js';
22
23
  import { readFileContents } from '../filesystem-walker.js';
23
24
  import { isDev } from '../utils/env.js';
@@ -88,15 +89,11 @@ export function extractTemplateStaticFetchCalls(filePath, content, namedRouteUrl
88
89
  }
89
90
  return calls;
90
91
  }
91
- export function normalizeExtractedRoutePath(routePath, prefix) {
92
- const pathPart = routePath.trim().replace(/^\/+/, '').replace(/\/+$/g, '');
93
- const prefixPart = prefix?.trim().replace(/^\/+/, '').replace(/\/+$/g, '');
94
- const joined = prefixPart ? `/${prefixPart}${pathPart ? `/${pathPart}` : ''}` : `/${pathPart}`;
95
- return joined.replace(/\/+/g, '/') || '/';
96
- }
97
92
  function escapeRegex(s) {
98
93
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
99
94
  }
95
+ // Re-exported for existing consumers/tests that import it from the routes phase.
96
+ export { normalizeExtractedRoutePath };
100
97
  /**
101
98
  * Canonicalize a route's HTTP verb for persistence on the Route node.
102
99
  * Returns an upper-cased standard method, or `undefined` when the value
@@ -132,7 +129,7 @@ export const routesPhase = {
132
129
  name: 'routes',
133
130
  deps: ['parse'],
134
131
  async execute(ctx, deps) {
135
- const { allPaths, allFetchCalls: parseFetchCalls, allFetchWrapperDefs, allExtractedRoutes, allDecoratorRoutes, } = getPhaseOutput(deps, 'parse');
132
+ const { allPaths, allFetchCalls: parseFetchCalls, allFetchWrapperDefs, allExtractedRoutes, allDecoratorRoutes, routeHandlerSymbols, } = getPhaseOutput(deps, 'parse');
136
133
  // Local copy — routes phase must not mutate upstream ParseOutput
137
134
  const allFetchCalls = [...parseFetchCalls];
138
135
  const routeRegistry = new Map();
@@ -226,6 +223,7 @@ export const routesPhase = {
226
223
  const mwResult = content ? extractMiddlewareChain(content) : undefined;
227
224
  const middleware = mwResult?.chain;
228
225
  const routeNodeId = generateId('Route', routeURL);
226
+ const handlerSymbolId = routeHandlerSymbols.get(routeURL);
229
227
  ctx.graph.addNode({
230
228
  id: routeNodeId,
231
229
  label: 'Route',
@@ -233,6 +231,7 @@ export const routesPhase = {
233
231
  name: routeURL,
234
232
  filePath: handlerPath,
235
233
  ...(routeMethod ? { method: routeMethod } : {}),
234
+ ...(handlerSymbolId ? { handlerSymbolId } : {}),
236
235
  ...(responseKeys ? { responseKeys } : {}),
237
236
  ...(errorKeys ? { errorKeys } : {}),
238
237
  ...(middleware && middleware.length > 0 ? { middleware } : {}),
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Shared route-path normalization.
3
+ *
4
+ * Extracted from the routes phase so both the routes phase (which creates the
5
+ * `Route` graph node, keyed by the normalized URL) and the parse phase (which
6
+ * resolves each route's handler symbol and needs the SAME key to associate the
7
+ * resolved id back to the route) can compute an identical route URL without a
8
+ * phase-to-phase import cycle. Pure string logic, no dependencies.
9
+ */
10
+ /**
11
+ * Join a route's path with its (optional) prefix into a normalized,
12
+ * leading-slash URL used as the Route node identity. Collapses duplicate
13
+ * slashes and strips trailing ones; an empty result degrades to `/`.
14
+ */
15
+ export declare function normalizeExtractedRoutePath(routePath: string, prefix: string | null): string;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Shared route-path normalization.
3
+ *
4
+ * Extracted from the routes phase so both the routes phase (which creates the
5
+ * `Route` graph node, keyed by the normalized URL) and the parse phase (which
6
+ * resolves each route's handler symbol and needs the SAME key to associate the
7
+ * resolved id back to the route) can compute an identical route URL without a
8
+ * phase-to-phase import cycle. Pure string logic, no dependencies.
9
+ */
10
+ /**
11
+ * Join a route's path with its (optional) prefix into a normalized,
12
+ * leading-slash URL used as the Route node identity. Collapses duplicate
13
+ * slashes and strips trailing ones; an empty result degrades to `/`.
14
+ */
15
+ export function normalizeExtractedRoutePath(routePath, prefix) {
16
+ const pathPart = routePath.trim().replace(/^\/+/, '').replace(/\/+$/g, '');
17
+ const prefixPart = prefix?.trim().replace(/^\/+/, '').replace(/\/+$/g, '');
18
+ const joined = prefixPart ? `/${prefixPart}${pathPart ? `/${pathPart}` : ''}` : `/${pathPart}`;
19
+ return joined.replace(/\/+/g, '/') || '/';
20
+ }
@@ -123,6 +123,9 @@ export function extractSpringRoutes(tree, filePath, lineOffset = 0) {
123
123
  continue;
124
124
  const enclosingClass = findEnclosingClass(node);
125
125
  const classPrefix = enclosingClass ? (prefixByClassId.get(enclosingClass.id) ?? '') : '';
126
+ // `node` is the annotated `method_declaration`; its name field is the
127
+ // handler method name (resolved to a symbol UID later by the routes phase).
128
+ const handlerName = node.childForFieldName('name')?.text;
126
129
  routes.push({
127
130
  filePath,
128
131
  routePath,
@@ -130,6 +133,7 @@ export function extractSpringRoutes(tree, filePath, lineOffset = 0) {
130
133
  decoratorName: ann,
131
134
  lineNumber: annNode.startPosition.row + lineOffset,
132
135
  ...(classPrefix ? { prefix: classPrefix } : {}),
136
+ ...(handlerName ? { handlerName } : {}),
133
137
  });
134
138
  }
135
139
  return routes;
@@ -119,6 +119,16 @@ export interface ExtractedDecoratorRoute {
119
119
  * absent ⇒ no prefix applies.
120
120
  */
121
121
  prefix?: string | null;
122
+ /**
123
+ * Name of the handler the route decorator sits on (the decorated
124
+ * method/function — e.g. `create` for `@PostMapping("/orders") Order create()`).
125
+ * Captured at extraction where the decorated definition node is in hand, so
126
+ * the routes phase can resolve it to a real handler symbol UID via the
127
+ * SemanticModel (same `(filePath, name) → nodeId` lookup Laravel routes use).
128
+ * Absent when the extractor could not identify the decorated definition;
129
+ * resolution then falls back (the Route node simply carries no handlerSymbolId).
130
+ */
131
+ handlerName?: string;
122
132
  }
123
133
  export interface ExtractedToolDef {
124
134
  filePath: string;
@@ -304,7 +304,7 @@ export const streamAllCSVsToDisk = async (graph, repoPath, csvDir, onNodePhaseCo
304
304
  // Section nodes have an extra 'level' column
305
305
  const sectionWriter = new BufferedCSVWriter(path.join(csvDir, 'section.csv'), 'id,name,filePath,startLine,endLine,level,content,description');
306
306
  // Route nodes for API endpoint mapping
307
- const routeWriter = new BufferedCSVWriter(path.join(csvDir, 'route.csv'), 'id,name,filePath,responseKeys,errorKeys,middleware,method');
307
+ const routeWriter = new BufferedCSVWriter(path.join(csvDir, 'route.csv'), 'id,name,filePath,responseKeys,errorKeys,middleware,method,handlerSymbolId');
308
308
  // Tool nodes for MCP tool definitions
309
309
  const toolWriter = new BufferedCSVWriter(path.join(csvDir, 'tool.csv'), 'id,name,filePath,description');
310
310
  // BasicBlock nodes — taint/PDG substrate (issue #2080). No `name` column;
@@ -452,6 +452,7 @@ export const streamAllCSVsToDisk = async (graph, repoPath, csvDir, onNodePhaseCo
452
452
  escapeCSVField(errorKeysStr),
453
453
  escapeCSVField(middlewareStr),
454
454
  escapeCSVField(String(node.properties.method ?? '')),
455
+ escapeCSVField(String(node.properties.handlerSymbolId ?? '')),
455
456
  ].join(','));
456
457
  break;
457
458
  }
@@ -1149,7 +1149,7 @@ export const getCopyQuery = (table, filePath) => {
1149
1149
  return `COPY ${t}(id, name, filePath, startLine, endLine, level, content, description) FROM "${filePath}" ${COPY_CSV_OPTS}`;
1150
1150
  }
1151
1151
  if (table === 'Route') {
1152
- return `COPY ${t}(id, name, filePath, responseKeys, errorKeys, middleware, method) FROM "${filePath}" ${COPY_CSV_OPTS}`;
1152
+ return `COPY ${t}(id, name, filePath, responseKeys, errorKeys, middleware, method, handlerSymbolId) FROM "${filePath}" ${COPY_CSV_OPTS}`;
1153
1153
  }
1154
1154
  if (table === 'Tool') {
1155
1155
  return `COPY ${t}(id, name, filePath, description) FROM "${filePath}" ${COPY_CSV_OPTS}`;
@@ -39,7 +39,7 @@ export declare const ANNOTATION_SCHEMA: string;
39
39
  export declare const CONSTRUCTOR_SCHEMA: string;
40
40
  export declare const TEMPLATE_SCHEMA: string;
41
41
  export declare const MODULE_SCHEMA: string;
42
- export declare const ROUTE_SCHEMA = "\nCREATE NODE TABLE Route (\n id STRING,\n name STRING,\n filePath STRING,\n responseKeys STRING[],\n errorKeys STRING[],\n middleware STRING[],\n method STRING,\n PRIMARY KEY (id)\n)";
42
+ export declare const ROUTE_SCHEMA = "\nCREATE NODE TABLE Route (\n id STRING,\n name STRING,\n filePath STRING,\n responseKeys STRING[],\n errorKeys STRING[],\n middleware STRING[],\n method STRING,\n handlerSymbolId STRING,\n PRIMARY KEY (id)\n)";
43
43
  export declare const TOOL_SCHEMA = "\nCREATE NODE TABLE Tool (\n id STRING,\n name STRING,\n filePath STRING,\n description STRING,\n PRIMARY KEY (id)\n)";
44
44
  export declare const SECTION_SCHEMA = "\nCREATE NODE TABLE Section (\n id STRING,\n name STRING,\n filePath STRING,\n startLine INT64,\n endLine INT64,\n level INT64,\n content STRING,\n description STRING,\n PRIMARY KEY (id)\n)";
45
45
  export declare const BASICBLOCK_SCHEMA = "\nCREATE NODE TABLE BasicBlock (\n id STRING,\n filePath STRING,\n startLine INT64,\n endLine INT64,\n text STRING,\n callees STRING,\n calleeIds STRING,\n PRIMARY KEY (id)\n)";
@@ -178,6 +178,7 @@ CREATE NODE TABLE Route (
178
178
  errorKeys STRING[],
179
179
  middleware STRING[],
180
180
  method STRING,
181
+ handlerSymbolId STRING,
181
182
  PRIMARY KEY (id)
182
183
  )`;
183
184
  // MCP tool definitions
@@ -52,7 +52,7 @@ import { fileURLToPath } from 'url';
52
52
  // the main thread (the #1983 OOM). Because the two stores share this version,
53
53
  // any future change to the `ParsedFile` serialization shape MUST bump
54
54
  // SCHEMA_BUMP so both invalidate in lockstep.
55
- const SCHEMA_BUMP = 6; // #2082 M2: cfgSideChannel gained bindings + per-block statement facts
55
+ const SCHEMA_BUMP = 7; // #2138 Part 2: ExtractedDecoratorRoute gained `handlerName` (route handler symbol resolution)
56
56
  const GITNEXUS_PKG_VERSION = (() => {
57
57
  try {
58
58
  // package.json sits at gitnexus/package.json — two levels up from
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.9-rc.7",
3
+ "version": "1.6.9-rc.8",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",