gitnexus 1.6.9-rc.22 → 1.6.9-rc.24

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.
@@ -576,16 +576,16 @@ export const JAVA_HTTP_PLUGIN = {
576
576
  language: Java,
577
577
  // routeCoverage intentionally LEFT at the default 'partial' (#2138 Part 2).
578
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) a method-level array route
580
- // nested under a class-level array-form `@RequestMapping` (ingestion suppresses
581
- // it rather than drop the prefix; bare/scalar-prefixed array methods ARE now
582
- // emitted — see #2280), or (2) the 2nd verb of a same-URL GET+POST pair (Route
583
- // nodes are URL-keyed). Interface-inherited Spring routes ARE now emitted by
584
- // ingestion (#2288), so they are no longer a coverage gap. Declaring 'complete'
585
- // here would let the parse-skip drop those remaining group-only providers.
586
- // Java flips to 'complete' only once ingestion provider extraction matches this
587
- // scan (a follow-up: class-level array-form prefix support + per-verb Route
588
- // identity tracked in #2280).
579
+ // ingestion does NOT emit a Route node for a method-level array route nested
580
+ // under a class-level array-form `@RequestMapping` (ingestion suppresses it
581
+ // rather than drop the prefix; bare/scalar-prefixed array methods ARE now
582
+ // emitted — see #2280). Interface-inherited Spring routes ARE now emitted by
583
+ // ingestion (#2288), and same-URL multi-verb routes are now per-`(method,url)`
584
+ // Route nodes (#2289), so they are no longer coverage gaps. Declaring
585
+ // 'complete' here would let the parse-skip drop the remaining group-only
586
+ // providers (the array-prefix gap above). Java flips to 'complete' only once
587
+ // ingestion provider extraction matches this scan class-level array-form
588
+ // prefix support is the final follow-up tracked in #2280.
589
589
  // `hasConsumerSignals` below is kept ready for that flip.
590
590
  // Consumer signals this plugin's scan() can detect: RestTemplate / WebClient /
591
591
  // OkHttp / Java-HttpClient / Apache-HttpClient call sites, OpenFeign
@@ -151,10 +151,12 @@ export class ManifestExtractor {
151
151
  try {
152
152
  let rows;
153
153
  if (link.type === 'http') {
154
- // Route.name is the canonicalized URL path (see
155
- // core/ingestion/pipeline.ts ensureSlash + generateId('Route', ...)).
156
- // Normalize the manifest contract the same way so a user-written
157
- // "/api/orders" matches "api/orders" in the graph.
154
+ // Route.name is the canonicalized URL path. Since #2289 a Route node's
155
+ // *id* is `(method, url)`-composite (`routeNodeKey`), but `route.name`
156
+ // continues to carry the bare URL so URL-keyed group queries like this
157
+ // one keep working without a schema change. Normalize the manifest
158
+ // contract the same way so a user-written "/api/orders" matches
159
+ // "api/orders" in the graph.
158
160
  //
159
161
  // The contract may also use the explicit-method form "GET::/api/orders"
160
162
  // recommended by buildContractId. Strip the METHOD:: prefix before
@@ -59,11 +59,11 @@ export declare function buildExportedTypeMapFromGraph(graph: KnowledgeGraph, sym
59
59
  */
60
60
  export declare const processRoutesFromExtracted: (graph: KnowledgeGraph, extractedRoutes: ExtractedRoute[], model: SemanticModel, onProgress?: (current: number, total: number) => void) => Promise<void>;
61
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()`.
62
+ * Resolve each route's handler to a real symbol UID, keyed by the route's
63
+ * `(method, url)` identity (`routeNodeKey` — the same key the routes phase uses
64
+ * for the `Route` node). This is the Part 2 (#2138) groundwork that lets
65
+ * `HttpRouteExtractor.extractProvidersGraph` read the handler symbol from the
66
+ * graph instead of re-parsing source via `getDetections()`.
67
67
  *
68
68
  * Two route shapes, one resolution target — `(filePath, name) → nodeId`:
69
69
  * - Laravel framework routes (`ExtractedRoute`) carry `controllerName` +
@@ -73,14 +73,18 @@ export declare const processRoutesFromExtracted: (graph: KnowledgeGraph, extract
73
73
  * `handlerName` (the decorated method, captured at extraction); resolve it
74
74
  * directly in the route's own file.
75
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).
76
+ * First-writer-wins per route identity, matching the routes phase's dedup (it
77
+ * keeps the first route registered for a `(method, url)` key and counts the rest
78
+ * as duplicates). The first route to claim a key reserves it **even when its
79
+ * handler is unresolvable**, so a later same-key route can never stamp its
80
+ * handler onto the first route's Route node (the routes phase made that first
81
+ * route the node-winner). Keying is `routeNodeKey(method, url)` (#2289): a
82
+ * same-URL multi-verb pair (`GET /x` + `POST /x`) resolves two handlers, one per
83
+ * node; method-less / wildcard routes key by URL alone, byte-identical to the
84
+ * pre-#2289 behavior. Routes whose handler cannot be *uniquely* resolved (no
85
+ * name, zero matches, or an ambiguous same-name match) carry no
86
+ * `handlerSymbolId`; the extractor then falls back to source scan for that route
87
+ * (fail-open, no regression, never a wrong handler).
84
88
  */
85
89
  export declare function resolveRouteHandlerSymbols(model: SemanticModel, extractedRoutes: readonly ExtractedRoute[], decoratorRoutes: readonly ExtractedDecoratorRoute[]): Map<string, string>;
86
90
  /**
@@ -97,6 +101,14 @@ export declare const extractConsumerAccessedKeys: (content: string) => string[];
97
101
  * Create FETCHES edges from extracted fetch() calls to matching Route nodes.
98
102
  * When consumerContents is provided, extracts property access patterns from
99
103
  * consumer files and encodes them in the edge reason field.
104
+ *
105
+ * Matching stays URL-only (#2289): a verb-less consumer (a `fetch()` call has
106
+ * no statically-known HTTP method) matches a route by URL and connects to
107
+ * **every** Route node sharing that URL — i.e. both the `GET /x` and `POST /x`
108
+ * nodes when a URL carries multiple verbs. `routeUrlToKeys` therefore maps each
109
+ * route URL to the list of `routeNodeKey` identities at that URL; a single-verb
110
+ * (or method-less) URL has a one-element list, keeping edges byte-identical to
111
+ * the pre-#2289 behavior.
100
112
  */
101
- export declare const processNextjsFetchRoutes: (graph: KnowledgeGraph, fetchCalls: ExtractedFetchCall[], routeRegistry: Map<string, string>, // routeURL → handlerFilePath
113
+ export declare const processNextjsFetchRoutes: (graph: KnowledgeGraph, fetchCalls: ExtractedFetchCall[], routeUrlToKeys: Map<string, string[]>, // routeURL → route node keys at that URL
102
114
  consumerContents?: Map<string, string>) => void;
@@ -17,7 +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
+ import { normalizeExtractedRoutePath, normalizeRouteMethod, routeNodeKey, } from './route-extractors/route-path.js';
21
21
  import { extractReturnTypeName } from './type-extractors/shared.js';
22
22
  const MAX_EXPORTS_PER_FILE = 500;
23
23
  const MAX_TYPE_NAME_LENGTH = 256;
@@ -215,11 +215,11 @@ export const processRoutesFromExtracted = async (graph, extractedRoutes, model,
215
215
  onProgress?.(extractedRoutes.length, extractedRoutes.length);
216
216
  };
217
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()`.
218
+ * Resolve each route's handler to a real symbol UID, keyed by the route's
219
+ * `(method, url)` identity (`routeNodeKey` — the same key the routes phase uses
220
+ * for the `Route` node). This is the Part 2 (#2138) groundwork that lets
221
+ * `HttpRouteExtractor.extractProvidersGraph` read the handler symbol from the
222
+ * graph instead of re-parsing source via `getDetections()`.
223
223
  *
224
224
  * Two route shapes, one resolution target — `(filePath, name) → nodeId`:
225
225
  * - Laravel framework routes (`ExtractedRoute`) carry `controllerName` +
@@ -229,20 +229,24 @@ export const processRoutesFromExtracted = async (graph, extractedRoutes, model,
229
229
  * `handlerName` (the decorated method, captured at extraction); resolve it
230
230
  * directly in the route's own file.
231
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).
232
+ * First-writer-wins per route identity, matching the routes phase's dedup (it
233
+ * keeps the first route registered for a `(method, url)` key and counts the rest
234
+ * as duplicates). The first route to claim a key reserves it **even when its
235
+ * handler is unresolvable**, so a later same-key route can never stamp its
236
+ * handler onto the first route's Route node (the routes phase made that first
237
+ * route the node-winner). Keying is `routeNodeKey(method, url)` (#2289): a
238
+ * same-URL multi-verb pair (`GET /x` + `POST /x`) resolves two handlers, one per
239
+ * node; method-less / wildcard routes key by URL alone, byte-identical to the
240
+ * pre-#2289 behavior. Routes whose handler cannot be *uniquely* resolved (no
241
+ * name, zero matches, or an ambiguous same-name match) carry no
242
+ * `handlerSymbolId`; the extractor then falls back to source scan for that route
243
+ * (fail-open, no regression, never a wrong handler).
240
244
  */
241
245
  export function resolveRouteHandlerSymbols(model, extractedRoutes, decoratorRoutes) {
242
246
  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.
247
+ // Route identities already claimed by an earlier route (resolved or not).
248
+ // Mirrors the routes phase `addRoute` first-writer-wins so the handler we
249
+ // stamp always belongs to the route that actually won the Route node.
246
250
  const claimed = new Set();
247
251
  // Resolve a single same-file symbol by name, refusing to guess on ambiguity:
248
252
  // exactly one match → its nodeId; zero or many → undefined (fail-open).
@@ -250,15 +254,16 @@ export function resolveRouteHandlerSymbols(model, extractedRoutes, decoratorRout
250
254
  const defs = model.symbols.lookupExactAll(filePath, name);
251
255
  return defs.length === 1 ? defs[0]?.nodeId : undefined;
252
256
  };
253
- const claim = (routePath, prefix, symbolId) => {
257
+ const claim = (routePath, prefix, httpMethod, symbolId) => {
254
258
  if (!routePath)
255
259
  return;
256
260
  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);
261
+ const key = routeNodeKey(normalizeRouteMethod(httpMethod), url);
262
+ if (claimed.has(key))
263
+ return; // first-writer-wins: later same-key routes can't override
264
+ claimed.add(key);
260
265
  if (symbolId)
261
- out.set(url, symbolId);
266
+ out.set(key, symbolId);
262
267
  };
263
268
  // Laravel framework routes — controller class + method name.
264
269
  for (const route of extractedRoutes) {
@@ -276,13 +281,13 @@ export function resolveRouteHandlerSymbols(model, extractedRoutes, decoratorRout
276
281
  if (controllerDef)
277
282
  methodId = uniqueSymbolId(controllerDef.filePath, route.methodName);
278
283
  }
279
- claim(route.routePath, route.prefix ?? null, methodId);
284
+ claim(route.routePath, route.prefix ?? null, route.httpMethod, methodId);
280
285
  }
281
286
  // Decorator routes (Spring / FastAPI / generic) — the decorated handler in
282
287
  // the route's own file.
283
288
  for (const dr of decoratorRoutes) {
284
289
  const handlerId = dr.handlerName ? uniqueSymbolId(dr.filePath, dr.handlerName) : undefined;
285
- claim(dr.routePath, dr.prefix ?? null, handlerId);
290
+ claim(dr.routePath, dr.prefix ?? null, dr.httpMethod, handlerId);
286
291
  }
287
292
  return out;
288
293
  }
@@ -421,16 +426,26 @@ export const extractConsumerAccessedKeys = (content) => {
421
426
  * Create FETCHES edges from extracted fetch() calls to matching Route nodes.
422
427
  * When consumerContents is provided, extracts property access patterns from
423
428
  * consumer files and encodes them in the edge reason field.
429
+ *
430
+ * Matching stays URL-only (#2289): a verb-less consumer (a `fetch()` call has
431
+ * no statically-known HTTP method) matches a route by URL and connects to
432
+ * **every** Route node sharing that URL — i.e. both the `GET /x` and `POST /x`
433
+ * nodes when a URL carries multiple verbs. `routeUrlToKeys` therefore maps each
434
+ * route URL to the list of `routeNodeKey` identities at that URL; a single-verb
435
+ * (or method-less) URL has a one-element list, keeping edges byte-identical to
436
+ * the pre-#2289 behavior.
424
437
  */
425
- export const processNextjsFetchRoutes = (graph, fetchCalls, routeRegistry, // routeURL → handlerFilePath
438
+ export const processNextjsFetchRoutes = (graph, fetchCalls, routeUrlToKeys, // routeURL → route node keys at that URL
426
439
  consumerContents) => {
427
- // Pre-count how many routes each consumer file matches (for confidence attribution)
440
+ // Pre-count how many route URLs each consumer file matches (for confidence
441
+ // attribution). Counts once per call that matches any URL — independent of how
442
+ // many verbs share that URL — so the multi-fetch heuristic is unchanged.
428
443
  const routeCountByFile = new Map();
429
444
  for (const call of fetchCalls) {
430
445
  const normalized = normalizeFetchURL(call.fetchURL);
431
446
  if (!normalized)
432
447
  continue;
433
- for (const [routeURL] of routeRegistry) {
448
+ for (const routeURL of routeUrlToKeys.keys()) {
434
449
  if (routeMatches(normalized, routeURL)) {
435
450
  routeCountByFile.set(call.filePath, (routeCountByFile.get(call.filePath) ?? 0) + 1);
436
451
  break;
@@ -441,10 +456,9 @@ consumerContents) => {
441
456
  const normalized = normalizeFetchURL(call.fetchURL);
442
457
  if (!normalized)
443
458
  continue;
444
- for (const [routeURL] of routeRegistry) {
459
+ for (const [routeURL, routeKeys] of routeUrlToKeys) {
445
460
  if (routeMatches(normalized, routeURL)) {
446
461
  const sourceId = generateId('File', call.filePath);
447
- const routeNodeId = generateId('Route', routeURL);
448
462
  // Extract consumer accessed keys if file content is available
449
463
  let reason = 'fetch-url-match';
450
464
  if (consumerContents) {
@@ -461,14 +475,18 @@ consumerContents) => {
461
475
  if (fetchCount > 1) {
462
476
  reason = `${reason}|fetches:${fetchCount}`;
463
477
  }
464
- graph.addRelationship({
465
- id: generateId('FETCHES', `${sourceId}->${routeNodeId}`),
466
- sourceId,
467
- targetId: routeNodeId,
468
- type: 'FETCHES',
469
- confidence: 0.9,
470
- reason,
471
- });
478
+ // Connect to every Route node at this URL (one per verb).
479
+ for (const routeKey of routeKeys) {
480
+ const routeNodeId = generateId('Route', routeKey);
481
+ graph.addRelationship({
482
+ id: generateId('FETCHES', `${sourceId}->${routeNodeId}`),
483
+ sourceId,
484
+ targetId: routeNodeId,
485
+ type: 'FETCHES',
486
+ confidence: 0.9,
487
+ reason,
488
+ });
489
+ }
472
490
  break;
473
491
  }
474
492
  }
@@ -11,6 +11,7 @@
11
11
  import { getPhaseOutput } from './types.js';
12
12
  import { processProcesses } from '../process-processor.js';
13
13
  import { generateId } from '../../../lib/utils.js';
14
+ import { routeNodeKey } from '../route-extractors/route-path.js';
14
15
  import { isDev } from '../utils/env.js';
15
16
  import { logger } from '../../logger.js';
16
17
  export const processesPhase = {
@@ -78,14 +79,39 @@ export const processesPhase = {
78
79
  });
79
80
  // Link Route and Tool nodes to Processes
80
81
  if (routeRegistry.size > 0 || toolDefs.length > 0) {
81
- const routesByFile = new Map();
82
- for (const [url, entry] of routeRegistry) {
83
- let list = routesByFile.get(entry.filePath);
82
+ // Two-tier route lookup, mirroring the tool tables 10 lines below.
83
+ // Routes whose handler resolved key by `handlerSymbolId` (read from
84
+ // the Route node's graph properties — routes.ts stamps it there) and
85
+ // link ONLY to the process whose entryPoint matches; routes without a
86
+ // resolved handler fall back to a per-file bucket so we still attach
87
+ // the Route node to a same-file process (best-effort).
88
+ //
89
+ // Pre-#2289-review-P2 this was a single per-file bucket: every verb
90
+ // on a file's controller was linked to every process in that file,
91
+ // cross-wiring same-file `GET /items` and `POST /items` to each
92
+ // other's handler processes. The per-verb `handlerSymbolId` the
93
+ // routes phase stamps on the Route node was never consulted.
94
+ const routesByHandlerId = new Map();
95
+ const routesWithoutHandlerByFile = new Map();
96
+ for (const [, entry] of routeRegistry) {
97
+ // Push the Route node identity (`routeNodeKey`), not the bare URL, so the
98
+ // ENTRY_POINT_OF edge targets the same node id the routes phase created
99
+ // (#2289: a same-URL GET/POST pair is two distinct Route nodes).
100
+ const routeKey = routeNodeKey(entry.method, entry.url);
101
+ // Source of truth for handlerSymbolId is the Route node in the
102
+ // graph (routes.ts populates it from `routeHandlerSymbols`); the
103
+ // routes phase runs before processes (see `deps`), so the node is
104
+ // always present here.
105
+ const routeNode = ctx.graph.getNode(generateId('Route', routeKey));
106
+ const handlerSymbolId = routeNode?.properties.handlerSymbolId;
107
+ const targetMap = handlerSymbolId ? routesByHandlerId : routesWithoutHandlerByFile;
108
+ const bucketKey = handlerSymbolId ?? entry.filePath;
109
+ let list = targetMap.get(bucketKey);
84
110
  if (!list) {
85
111
  list = [];
86
- routesByFile.set(entry.filePath, list);
112
+ targetMap.set(bucketKey, list);
87
113
  }
88
- list.push(url);
114
+ list.push(routeKey);
89
115
  }
90
116
  const toolsByHandlerId = new Map();
91
117
  const toolsWithoutHandlerByFile = new Map();
@@ -109,10 +135,12 @@ export const processesPhase = {
109
135
  const entryFile = entryNode.properties.filePath;
110
136
  if (!entryFile)
111
137
  continue;
112
- const routeURLs = routesByFile.get(entryFile);
113
- if (routeURLs) {
114
- for (const routeURL of routeURLs) {
115
- const routeNodeId = generateId('Route', routeURL);
138
+ const exactRouteKeys = routesByHandlerId.get(proc.entryPointId);
139
+ const fallbackRouteKeys = routesWithoutHandlerByFile.get(entryFile);
140
+ const routeKeys = exactRouteKeys ?? fallbackRouteKeys;
141
+ if (routeKeys) {
142
+ for (const routeKey of routeKeys) {
143
+ const routeNodeId = generateId('Route', routeKey);
116
144
  ctx.graph.addRelationship({
117
145
  id: generateId('ENTRY_POINT_OF', `${routeNodeId}->${proc.id}`),
118
146
  sourceId: routeNodeId,
@@ -11,10 +11,16 @@
11
11
  * @output routeRegistry, handlerContents
12
12
  */
13
13
  import type { PipelinePhase } from './types.js';
14
- import { normalizeExtractedRoutePath } from '../route-extractors/route-path.js';
15
14
  export interface RouteEntry {
16
15
  filePath: string;
17
16
  source: string;
17
+ /**
18
+ * The route's URL path (leading-slash, prefix-joined). This is the Route
19
+ * node's `name`. Stored explicitly because the registry is keyed by the
20
+ * `(method, url)` identity (`routeNodeKey`), so the key is no longer the URL
21
+ * — downstream URL consumers (middleware/fetch matching) read this instead.
22
+ */
23
+ url: string;
18
24
  /**
19
25
  * HTTP verb for this route when ingestion knows it structurally
20
26
  * (Spring/Laravel framework routes and decorator routes carry
@@ -35,6 +41,4 @@ export interface TemplateFetchCall {
35
41
  }
36
42
  export declare const isTemplateRouteCandidate: (filePath: string) => boolean;
37
43
  export declare function extractTemplateStaticFetchCalls(filePath: string, content: string, namedRouteUrls?: ReadonlyMap<string, string>): TemplateFetchCall[];
38
- export { normalizeExtractedRoutePath };
39
- export declare function normalizeRouteMethod(raw: string | null | undefined): string | undefined;
40
44
  export declare const routesPhase: PipelinePhase<RoutesOutput>;
@@ -18,7 +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
+ import { normalizeExtractedRoutePath, normalizeRouteMethod, routeNodeKey, } from '../route-extractors/route-path.js';
22
22
  import { generateId } from '../../../lib/utils.js';
23
23
  import { readFileContents } from '../filesystem-walker.js';
24
24
  import { isDev } from '../utils/env.js';
@@ -92,39 +92,6 @@ export function extractTemplateStaticFetchCalls(filePath, content, namedRouteUrl
92
92
  function escapeRegex(s) {
93
93
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
94
94
  }
95
- // Re-exported for existing consumers/tests that import it from the routes phase.
96
- export { normalizeExtractedRoutePath };
97
- /**
98
- * Canonicalize a route's HTTP verb for persistence on the Route node.
99
- * Returns an upper-cased standard method, or `undefined` when the value
100
- * is not a real HTTP verb. Laravel `Route::resource` / `apiResource`
101
- * surface `httpMethod` values like `resource` / `apiResource` (they
102
- * expand to several verbs at runtime), so they must not be stored as a
103
- * method — leaving them `undefined` keeps the column clean and lets the
104
- * contract extractor fall back to its source-scan path for those routes.
105
- */
106
- const VALID_HTTP_METHODS = new Set([
107
- 'GET',
108
- 'POST',
109
- 'PUT',
110
- 'PATCH',
111
- 'DELETE',
112
- 'HEAD',
113
- 'OPTIONS',
114
- 'TRACE',
115
- 'CONNECT',
116
- ]);
117
- export function normalizeRouteMethod(raw) {
118
- if (typeof raw !== 'string')
119
- return undefined;
120
- const verb = raw.trim().toUpperCase();
121
- // '*' marks a method-agnostic route (e.g. a Django function view handles any
122
- // verb). Preserve it so the contract layer emits a wildcard provider that
123
- // matches consumers of any method, instead of silently narrowing to GET.
124
- if (verb === '*')
125
- return '*';
126
- return VALID_HTTP_METHODS.has(verb) ? verb : undefined;
127
- }
128
95
  export const routesPhase = {
129
96
  name: 'routes',
130
97
  deps: ['parse'],
@@ -162,30 +129,44 @@ export const routesPhase = {
162
129
  if (expoAppPaths.has(p)) {
163
130
  const expoURL = expoFileToRouteURL(p);
164
131
  if (expoURL && !routeRegistry.has(expoURL)) {
165
- routeRegistry.set(expoURL, { filePath: p, source: 'expo-filesystem-route' });
132
+ routeRegistry.set(expoURL, {
133
+ filePath: p,
134
+ source: 'expo-filesystem-route',
135
+ url: expoURL,
136
+ });
166
137
  continue;
167
138
  }
168
139
  }
169
140
  const nextjsURL = nextjsFileToRouteURL(p);
170
141
  if (nextjsURL && !routeRegistry.has(nextjsURL)) {
171
- routeRegistry.set(nextjsURL, { filePath: p, source: 'nextjs-filesystem-route' });
142
+ routeRegistry.set(nextjsURL, {
143
+ filePath: p,
144
+ source: 'nextjs-filesystem-route',
145
+ url: nextjsURL,
146
+ });
172
147
  continue;
173
148
  }
174
149
  if (p.endsWith('.php')) {
175
150
  const phpURL = phpFileToRouteURL(p);
176
151
  if (phpURL && !routeRegistry.has(phpURL)) {
177
- routeRegistry.set(phpURL, { filePath: p, source: 'php-file-route' });
152
+ routeRegistry.set(phpURL, { filePath: p, source: 'php-file-route', url: phpURL });
178
153
  }
179
154
  }
180
155
  }
181
156
  let duplicateRoutes = 0;
182
157
  const namedRouteRegistry = new Map();
158
+ // Routes are keyed by their `(method, url)` identity (#2289): a same-URL
159
+ // multi-verb pair (`GET /x` + `POST /x`) is two entries, not one. Method-less
160
+ // / wildcard routes key by URL (see `routeNodeKey`), so filesystem/resource
161
+ // routes stay byte-identical. A true duplicate (same method AND url) is still
162
+ // dropped.
183
163
  const addRoute = (url, entry) => {
184
- if (routeRegistry.has(url)) {
164
+ const key = routeNodeKey(entry.method, url);
165
+ if (routeRegistry.has(key)) {
185
166
  duplicateRoutes++;
186
167
  return;
187
168
  }
188
- routeRegistry.set(url, entry);
169
+ routeRegistry.set(key, { ...entry, url });
189
170
  };
190
171
  for (const route of allExtractedRoutes) {
191
172
  if (!route.routePath)
@@ -212,8 +193,8 @@ export const routesPhase = {
212
193
  if (routeRegistry.size > 0) {
213
194
  const handlerPaths = [...routeRegistry.values()].map((e) => e.filePath);
214
195
  handlerContents = await readFileContents(ctx.repoPath, handlerPaths);
215
- for (const [routeURL, entry] of routeRegistry) {
216
- const { filePath: handlerPath, source: routeSource, method: routeMethod } = entry;
196
+ for (const [routeKey, entry] of routeRegistry) {
197
+ const { filePath: handlerPath, source: routeSource, method: routeMethod, url } = entry;
217
198
  const content = handlerContents.get(handlerPath);
218
199
  const { responseKeys, errorKeys } = content
219
200
  ? handlerPath.endsWith('.php')
@@ -222,13 +203,13 @@ export const routesPhase = {
222
203
  : { responseKeys: undefined, errorKeys: undefined };
223
204
  const mwResult = content ? extractMiddlewareChain(content) : undefined;
224
205
  const middleware = mwResult?.chain;
225
- const routeNodeId = generateId('Route', routeURL);
226
- const handlerSymbolId = routeHandlerSymbols.get(routeURL);
206
+ const routeNodeId = generateId('Route', routeKey);
207
+ const handlerSymbolId = routeHandlerSymbols.get(routeKey);
227
208
  ctx.graph.addNode({
228
209
  id: routeNodeId,
229
210
  label: 'Route',
230
211
  properties: {
231
- name: routeURL,
212
+ name: url,
232
213
  filePath: handlerPath,
233
214
  ...(routeMethod ? { method: routeMethod } : {}),
234
215
  ...(handlerSymbolId ? { handlerSymbolId } : {}),
@@ -272,12 +253,12 @@ export const routesPhase = {
272
253
  .map(compileMatcher)
273
254
  .filter((m) => m !== null);
274
255
  let linkedCount = 0;
275
- for (const [routeURL] of routeRegistry) {
256
+ for (const [routeKey, entry] of routeRegistry) {
276
257
  const matches = compiled.length === 0 ||
277
- compiled.some((cm) => compiledMatcherMatchesRoute(cm, routeURL));
258
+ compiled.some((cm) => compiledMatcherMatchesRoute(cm, entry.url));
278
259
  if (!matches)
279
260
  continue;
280
- const routeNodeId = generateId('Route', routeURL);
261
+ const routeNodeId = generateId('Route', routeKey);
281
262
  const existing = ctx.graph.getNode(routeNodeId);
282
263
  if (!existing)
283
264
  continue;
@@ -401,12 +382,19 @@ export const routesPhase = {
401
382
  }
402
383
  }
403
384
  if (routeRegistry.size > 0 && allFetchCalls.length > 0) {
404
- const routeURLToFile = new Map();
405
- for (const [url, entry] of routeRegistry)
406
- routeURLToFile.set(url, entry.filePath);
385
+ // url [route node keys at that url] (one per verb). A verb-less fetch()
386
+ // consumer matches by URL and connects to every Route node at that URL.
387
+ const routeUrlToKeys = new Map();
388
+ for (const [routeKey, entry] of routeRegistry) {
389
+ const existing = routeUrlToKeys.get(entry.url);
390
+ if (existing)
391
+ existing.push(routeKey);
392
+ else
393
+ routeUrlToKeys.set(entry.url, [routeKey]);
394
+ }
407
395
  const consumerPaths = [...new Set(allFetchCalls.map((c) => c.filePath))];
408
396
  const consumerContents = await readFileContents(ctx.repoPath, consumerPaths);
409
- processNextjsFetchRoutes(ctx.graph, allFetchCalls, routeURLToFile, consumerContents);
397
+ processNextjsFetchRoutes(ctx.graph, allFetchCalls, routeUrlToKeys, consumerContents);
410
398
  if (isDev) {
411
399
  logger.info(`🔗 Processed ${allFetchCalls.length} fetch() calls against ${routeRegistry.size} routes`);
412
400
  }
@@ -2,10 +2,11 @@
2
2
  * Shared route-path normalization.
3
3
  *
4
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.
5
+ * `Route` graph node, keyed by `(method, url)` via `routeNodeKey` #2289) and
6
+ * the parse phase (which resolves each route's handler symbol and needs the
7
+ * SAME key to associate the resolved id back to the route) can compute an
8
+ * identical route identity without a phase-to-phase import cycle. Pure string
9
+ * logic, no dependencies.
9
10
  */
10
11
  /**
11
12
  * Join a route's path with its (optional) prefix into a normalized,
@@ -13,3 +14,26 @@
13
14
  * slashes and strips trailing ones; an empty result degrades to `/`.
14
15
  */
15
16
  export declare function normalizeExtractedRoutePath(routePath: string, prefix: string | null): string;
17
+ /**
18
+ * Canonicalize a route's HTTP verb for persistence on the Route node and for
19
+ * the route identity key. Returns an upper-cased standard method, `'*'` for a
20
+ * method-agnostic route (e.g. a Django function view), or `undefined` when the
21
+ * value is not a real HTTP verb. Laravel `Route::resource` / `apiResource`
22
+ * surface values like `resource` / `apiResource` (they expand to several verbs
23
+ * at runtime), so they come back `undefined` — keeping the column clean and
24
+ * letting the contract extractor fall back to its source-scan path.
25
+ */
26
+ export declare function normalizeRouteMethod(raw: string | null | undefined): string | undefined;
27
+ /**
28
+ * The Route node identity (#2289): `(method, path)` when the verb is known and
29
+ * specific, falling back to URL-only when the method is `undefined` (filesystem
30
+ * routes, Laravel `resource`/`apiResource`) or `'*'` (method-agnostic routes,
31
+ * e.g. Django function views). The URL-fallback keeps those byte-identical to
32
+ * the pre-#2289 URL-only ids, so only genuine declaration-style multi-verb
33
+ * routes (`GET /x` + `POST /x`) split into separate nodes.
34
+ *
35
+ * Used by the routes phase (node id + registry key), the processes phase
36
+ * (ENTRY_POINT_OF), and the handler-symbol resolver — all three must key
37
+ * identically.
38
+ */
39
+ export declare function routeNodeKey(method: string | undefined, url: string): string;
@@ -2,10 +2,11 @@
2
2
  * Shared route-path normalization.
3
3
  *
4
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.
5
+ * `Route` graph node, keyed by `(method, url)` via `routeNodeKey` #2289) and
6
+ * the parse phase (which resolves each route's handler symbol and needs the
7
+ * SAME key to associate the resolved id back to the route) can compute an
8
+ * identical route identity without a phase-to-phase import cycle. Pure string
9
+ * logic, no dependencies.
9
10
  */
10
11
  /**
11
12
  * Join a route's path with its (optional) prefix into a normalized,
@@ -18,3 +19,48 @@ export function normalizeExtractedRoutePath(routePath, prefix) {
18
19
  const joined = prefixPart ? `/${prefixPart}${pathPart ? `/${pathPart}` : ''}` : `/${pathPart}`;
19
20
  return joined.replace(/\/+/g, '/') || '/';
20
21
  }
22
+ const VALID_HTTP_METHODS = new Set([
23
+ 'GET',
24
+ 'POST',
25
+ 'PUT',
26
+ 'PATCH',
27
+ 'DELETE',
28
+ 'HEAD',
29
+ 'OPTIONS',
30
+ 'TRACE',
31
+ 'CONNECT',
32
+ ]);
33
+ /**
34
+ * Canonicalize a route's HTTP verb for persistence on the Route node and for
35
+ * the route identity key. Returns an upper-cased standard method, `'*'` for a
36
+ * method-agnostic route (e.g. a Django function view), or `undefined` when the
37
+ * value is not a real HTTP verb. Laravel `Route::resource` / `apiResource`
38
+ * surface values like `resource` / `apiResource` (they expand to several verbs
39
+ * at runtime), so they come back `undefined` — keeping the column clean and
40
+ * letting the contract extractor fall back to its source-scan path.
41
+ */
42
+ export function normalizeRouteMethod(raw) {
43
+ if (typeof raw !== 'string')
44
+ return undefined;
45
+ const verb = raw.trim().toUpperCase();
46
+ // '*' marks a method-agnostic route. Preserve it so the contract layer emits a
47
+ // wildcard provider that matches consumers of any method.
48
+ if (verb === '*')
49
+ return '*';
50
+ return VALID_HTTP_METHODS.has(verb) ? verb : undefined;
51
+ }
52
+ /**
53
+ * The Route node identity (#2289): `(method, path)` when the verb is known and
54
+ * specific, falling back to URL-only when the method is `undefined` (filesystem
55
+ * routes, Laravel `resource`/`apiResource`) or `'*'` (method-agnostic routes,
56
+ * e.g. Django function views). The URL-fallback keeps those byte-identical to
57
+ * the pre-#2289 URL-only ids, so only genuine declaration-style multi-verb
58
+ * routes (`GET /x` + `POST /x`) split into separate nodes.
59
+ *
60
+ * Used by the routes phase (node id + registry key), the processes phase
61
+ * (ENTRY_POINT_OF), and the handler-symbol resolver — all three must key
62
+ * identically.
63
+ */
64
+ export function routeNodeKey(method, url) {
65
+ return method && method !== '*' ? `${method} ${url}` : url;
66
+ }
@@ -25,6 +25,9 @@ export const TYPESCRIPT_QUERIES = `
25
25
  (function_declaration
26
26
  name: (identifier) @name) @definition.function
27
27
 
28
+ (generator_function_declaration
29
+ name: (identifier) @name) @definition.function
30
+
28
31
  ; TypeScript overload signatures (function_signature is a separate node type from function_declaration)
29
32
  (function_signature
30
33
  name: (identifier) @name) @definition.function
@@ -371,6 +374,9 @@ export const JAVASCRIPT_QUERIES = `
371
374
  (function_declaration
372
375
  name: (identifier) @name) @definition.function
373
376
 
377
+ (generator_function_declaration
378
+ name: (identifier) @name) @definition.function
379
+
374
380
  (method_definition
375
381
  name: (property_identifier) @name) @definition.method
376
382
 
@@ -434,6 +434,27 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
434
434
  `Tip: set \`pdg: ${pdgOn}\` in .gitnexusrc to pin the mode across runs.`);
435
435
  options = { ...options, force: true };
436
436
  }
437
+ // ── schema-version mismatch forces full rebuild (#2289 P1) ────────
438
+ // Mirrors the pdg-mode block above: a stamp from an older
439
+ // INCREMENTAL_SCHEMA_VERSION (e.g. pre-v5 URL-only Route ids) cannot be
440
+ // reconciled by an incremental top-up — same-commit re-analyze would
441
+ // strand stale rows next to new-schema writes. MUST sit before the
442
+ // alreadyUpToDate fast path below: an unchanged-commit clean tree would
443
+ // otherwise early-return without ever reaching the `isIncremental` gate
444
+ // that consults `schemaVersion`, defeating the bump's whole point.
445
+ //
446
+ // `schemaVersion === undefined` covers two cases that should still trip
447
+ // this guard: a non-git repo (which never stamps the field) and very old
448
+ // meta from before the field existed. Non-git repos take the
449
+ // `currentCommit === ''` rebuild branch below regardless, so the redundant
450
+ // force here is harmless; the friendlier `'pre-versioning'` log avoids a
451
+ // user-visible "stamped vundefined" line in that edge case.
452
+ if (existingMeta && existingMeta.schemaVersion !== INCREMENTAL_SCHEMA_VERSION) {
453
+ const stampedVersion = existingMeta.schemaVersion ?? 'pre-versioning';
454
+ log(`index schema changed (stamped v${stampedVersion}, this build is v${INCREMENTAL_SCHEMA_VERSION}); ` +
455
+ `forcing a full rebuild so persisted rows match the current schema.`);
456
+ options = { ...options, force: true };
457
+ }
437
458
  // ── Early-return: already up to date ──────────────────────────────
438
459
  if (existingMeta && !options.force && existingMeta.lastCommit === currentCommit) {
439
460
  // Non-git folders have currentCommit = '' — always rebuild since we can't detect changes
@@ -217,8 +217,14 @@ export interface RepoMeta {
217
217
  * so the engine would silently UNDER-REPORT return-value ascent on an
218
218
  * incremental top-up; force a full re-analyze instead (same contract as v2/v3).
219
219
  * This single bump covers the whole FU-C re-index window (and the later FU-B-2).
220
- */
221
- export declare const INCREMENTAL_SCHEMA_VERSION = 4;
220
+ * v5: `Route` node identity changed to `(method, url)` (#2289 — a same-URL
221
+ * GET/POST pair is now two distinct Route nodes). Every declarative-route node
222
+ * id moved from `Route:/x` to `Route:GET /x` (filesystem routes keep their
223
+ * URL-only id). The incremental writeback preserves unchanged-file rows, so a
224
+ * top-up against a pre-v5 index would strand old url-keyed Route nodes alongside
225
+ * new composite-keyed ones — force a full re-analyze instead.
226
+ */
227
+ export declare const INCREMENTAL_SCHEMA_VERSION = 5;
222
228
  export interface IndexedRepo {
223
229
  repoPath: string;
224
230
  storagePath: string;
@@ -74,8 +74,14 @@ export const registryPathEquals = (a, b) => process.platform === 'win32' ? a.toL
74
74
  * so the engine would silently UNDER-REPORT return-value ascent on an
75
75
  * incremental top-up; force a full re-analyze instead (same contract as v2/v3).
76
76
  * This single bump covers the whole FU-C re-index window (and the later FU-B-2).
77
- */
78
- export const INCREMENTAL_SCHEMA_VERSION = 4;
77
+ * v5: `Route` node identity changed to `(method, url)` (#2289 — a same-URL
78
+ * GET/POST pair is now two distinct Route nodes). Every declarative-route node
79
+ * id moved from `Route:/x` to `Route:GET /x` (filesystem routes keep their
80
+ * URL-only id). The incremental writeback preserves unchanged-file rows, so a
81
+ * top-up against a pre-v5 index would strand old url-keyed Route nodes alongside
82
+ * new composite-keyed ones — force a full re-analyze instead.
83
+ */
84
+ export const INCREMENTAL_SCHEMA_VERSION = 5;
79
85
  const GITNEXUS_DIR = '.gitnexus';
80
86
  const GITNEXUS_EXCLUDE_ENTRY = `${GITNEXUS_DIR}/`;
81
87
  // ─── Local Storage Helpers ─────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.9-rc.22",
3
+ "version": "1.6.9-rc.24",
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",