gitnexus 1.6.9-rc.10 → 1.6.9-rc.12

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.
@@ -732,18 +732,20 @@ async function stitchCrossRepo(deps, p, fromEp, toEp, groupDir, notes) {
732
732
  const FILE_BASENAME_RE = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|php|java|kt|kts|rb|cs|rs|swift|dart|scala|c|cc|cpp|cxx|h|hpp|m|mm)$/i;
733
733
  /**
734
734
  * A provider endpoint's display label. A resolved handler has a real function
735
- * name; the source-scan fallbacks leave a generic token (`'handler'`/`'fetch'`)
736
- * or a file basename. Those are treated as anonymous and shown as
735
+ * name; the source-scan fallbacks leave a generic token (`'handler'`/`'fetch'`,
736
+ * or `'route'` for an unresolved named-controller / closure Laravel route) or a
737
+ * file basename. Those are treated as anonymous and shown as
737
738
  * `<METHOD /path handler>` so the endpoint is still identifiable by route. When
738
739
  * the bridge row carries a resolved `providerUid`, the name IS a real symbol —
739
- * the `'handler'`/`'fetch'` sentinel check is suppressed so a function genuinely
740
- * named `handler` is not mislabeled anonymous.
740
+ * the sentinel check is suppressed so a function genuinely named `handler` (or,
741
+ * hypothetically, `route`) is not mislabeled anonymous.
741
742
  */
742
743
  function providerLabel(providerName, contractId, providerUid) {
743
744
  const resolved = providerUid !== '';
744
745
  const generic = providerName === '' ||
745
746
  FILE_BASENAME_RE.test(providerName) ||
746
- (!resolved && (providerName === 'handler' || providerName === 'fetch'));
747
+ (!resolved &&
748
+ (providerName === 'handler' || providerName === 'fetch' || providerName === 'route'));
747
749
  return generic
748
750
  ? { label: `<${contractId} handler>`, anon: true }
749
751
  : { label: providerName, anon: false };
@@ -9,8 +9,11 @@ import { compilePatterns, runCompiledPatterns, unquoteLiteral, } from '../tree-s
9
9
  */
10
10
  // ─── Provider: framework routing ──────────────────────────────────────
11
11
  // Matches `\w+\.GET(...)` etc. (gin, echo, chi all share this shape).
12
- // Captures the HTTP method (field name), path literal, and handler
13
- // identifier passed as the second argument.
12
+ // Captures the HTTP method (field name), path literal, and the handler
13
+ // anchored to the LAST argument (`@handler .`) so a variadic middleware
14
+ // chain (`r.GET("/x", mw, handler)`, gin/echo/chi style) binds the real
15
+ // handler, not a middleware identifier (which would otherwise over-match
16
+ // and attach the route to the wrong symbol — see #2276 review).
14
17
  const FRAMEWORK_ROUTE_PATTERNS = compilePatterns({
15
18
  name: 'go-framework-route',
16
19
  language: Go,
@@ -23,7 +26,8 @@ const FRAMEWORK_ROUTE_PATTERNS = compilePatterns({
23
26
  field: (field_identifier) @http_method (#match? @http_method "^(GET|POST|PUT|DELETE|PATCH)$"))
24
27
  arguments: (argument_list
25
28
  (interpreted_string_literal) @path
26
- (identifier) @handler))
29
+ [(identifier) (func_literal)] @handler
30
+ .))
27
31
  `,
28
32
  },
29
33
  ],
@@ -42,7 +46,8 @@ const HANDLE_FUNC_PATTERNS = compilePatterns({
42
46
  field: (field_identifier) @fn (#eq? @fn "HandleFunc"))
43
47
  arguments: (argument_list
44
48
  (interpreted_string_literal) @path
45
- (identifier) @handler))
49
+ [(identifier) (func_literal)] @handler
50
+ .))
46
51
  `,
47
52
  },
48
53
  ],
@@ -125,12 +130,18 @@ export const GO_HTTP_PLUGIN = {
125
130
  const path = unquoteLiteral(pathNode.text);
126
131
  if (path === null)
127
132
  continue;
133
+ // An inline `func(){…}` handler has no name → emit `name: null` and a
134
+ // `line` so it resolves to its containing/closure symbol by line-span
135
+ // containment (like a consumer). A named identifier handler keeps its
136
+ // name and resolves by name; `line` is harmless there.
137
+ const isInlineHandler = handlerNode?.type === 'func_literal';
128
138
  out.push({
129
139
  role: 'provider',
130
140
  framework: 'go-framework',
131
141
  method: methodNode.text.toUpperCase(),
132
142
  path,
133
- name: handlerNode?.text ?? null,
143
+ name: isInlineHandler ? null : (handlerNode?.text ?? null),
144
+ line: (handlerNode ?? pathNode).startPosition.row + 1,
134
145
  confidence: 0.8,
135
146
  });
136
147
  }
@@ -143,12 +154,16 @@ export const GO_HTTP_PLUGIN = {
143
154
  const path = unquoteLiteral(pathNode.text);
144
155
  if (path === null)
145
156
  continue;
157
+ // Inline `func(){…}` handler → resolve by containment (see go-framework
158
+ // note above); a named handler resolves by name.
159
+ const isInlineHandler = handlerNode?.type === 'func_literal';
146
160
  out.push({
147
161
  role: 'provider',
148
162
  framework: 'go-stdlib',
149
163
  method: 'GET',
150
164
  path,
151
- name: handlerNode?.text ?? null,
165
+ name: isInlineHandler ? null : (handlerNode?.text ?? null),
166
+ line: (handlerNode ?? pathNode).startPosition.row + 1,
152
167
  confidence: 0.8,
153
168
  });
154
169
  }
@@ -634,6 +634,13 @@ export const JAVA_HTTP_PLUGIN = {
634
634
  method: route.httpMethod,
635
635
  path: joinPath(prefix, route.rawPath),
636
636
  name: route.methodName,
637
+ // Spring providers are named controller methods resolved BY NAME, so
638
+ // `line` is inert — a named provider never falls through to line-span
639
+ // containment. Gate it on a present name so a (grammar-impossible)
640
+ // nameless provider degrades to file-level rather than resolving by
641
+ // containment to the enclosing class. Wired for consumer-emit parity
642
+ // and a future inline DSL.
643
+ line: route.methodName ? route.methodNode.startPosition.row + 1 : undefined,
637
644
  confidence: 0.8,
638
645
  });
639
646
  }
@@ -977,6 +977,13 @@ function buildKotlinPlugin(language) {
977
977
  method: httpMethod,
978
978
  path: joinPath(prefix, rawPath),
979
979
  name: nameNode?.text ?? null,
980
+ // Spring providers are named controller methods resolved BY NAME, so
981
+ // `line` is inert — a named provider never falls through to line-span
982
+ // containment. Gate it on a present name so a (grammar-impossible)
983
+ // nameless provider degrades to file-level rather than resolving by
984
+ // containment to the enclosing class. Wired for consumer-emit parity
985
+ // and a future inline DSL.
986
+ line: nameNode?.text ? methodNode.startPosition.row + 1 : undefined,
980
987
  confidence: 0.8,
981
988
  });
982
989
  }
@@ -270,8 +270,54 @@ function findDecoratedMethod(decoratorNode) {
270
270
  }
271
271
  return null;
272
272
  }
273
+ /**
274
+ * Map each named import's LOCAL binding to its DECLARED export name and source
275
+ * module, by walking the file's `import { x as y } from 'm'` statements. Lets
276
+ * the express handler resolve through an alias (the local `y`) to the real
277
+ * symbol (`x` in `m`) instead of looking up the alias text. Only named imports
278
+ * are mapped — default and namespace imports are left to fall through as
279
+ * locally-scoped identifiers.
280
+ */
281
+ function buildImportMap(tree) {
282
+ const map = new Map();
283
+ const walk = (node) => {
284
+ if (node.type === 'import_statement') {
285
+ const sourceNode = node.childForFieldName('source');
286
+ const module = sourceNode ? unquoteLiteral(sourceNode.text) : null;
287
+ if (module !== null) {
288
+ const collect = (n) => {
289
+ if (n.type === 'import_specifier') {
290
+ const nameNode = n.childForFieldName('name');
291
+ const aliasNode = n.childForFieldName('alias');
292
+ const local = aliasNode ?? nameNode;
293
+ if (nameNode && local && local.type === 'identifier') {
294
+ map.set(local.text, { name: nameNode.text, module });
295
+ }
296
+ }
297
+ for (let i = 0; i < n.namedChildCount; i++) {
298
+ const c = n.namedChild(i);
299
+ if (c)
300
+ collect(c);
301
+ }
302
+ };
303
+ collect(node);
304
+ }
305
+ }
306
+ for (let i = 0; i < node.namedChildCount; i++) {
307
+ const c = node.namedChild(i);
308
+ if (c)
309
+ walk(c);
310
+ }
311
+ };
312
+ walk(tree.rootNode);
313
+ return map;
314
+ }
273
315
  function scanBundle(bundle, tree) {
274
316
  const out = [];
317
+ // Local-binding → { declared export name, module } for the file's named
318
+ // imports, so an express handler that is an imported (possibly aliased)
319
+ // symbol resolves to the real definition rather than its local alias text.
320
+ const importMap = buildImportMap(tree);
275
321
  // NestJS: collect `@Controller('prefix')` class decorators, keyed by
276
322
  // the `class_declaration` they decorate.
277
323
  const prefixByClassId = new Map();
@@ -343,14 +389,19 @@ function scanBundle(bundle, tree) {
343
389
  // → `listUsers`) so a named handler resolves by name. For an inline/anonymous
344
390
  // handler emit `name: null` (NOT the sentinel `'handler'`) so the resolver
345
391
  // does NOT match an unrelated function that happens to be named `handler` —
346
- // it uses the registration line for containment instead.
392
+ // it uses the registration line for containment instead. When the handler is
393
+ // an imported (possibly aliased) symbol, carry the resolved import so the
394
+ // extractor can pin it to the source module rather than the local alias text.
347
395
  const handlerNode = match.captures.handler;
396
+ const localHandler = handlerNode?.type === 'identifier' ? handlerNode.text : null;
397
+ const imported = localHandler !== null ? importMap.get(localHandler) : undefined;
348
398
  out.push({
349
399
  role: 'provider',
350
400
  framework: 'express',
351
401
  method: methodNode.text.toUpperCase(),
352
402
  path,
353
- name: handlerNode?.type === 'identifier' ? handlerNode.text : null,
403
+ name: imported ? imported.name : localHandler,
404
+ handlerImport: imported,
354
405
  line: (handlerNode ?? pathNode).startPosition.row + 1,
355
406
  confidence: 0.8,
356
407
  });
@@ -27,7 +27,9 @@ const LARAVEL_ROUTE_SPEC = {
27
27
  (scoped_call_expression
28
28
  scope: (name) @scope (#eq? @scope "Route")
29
29
  name: (name) @method (#match? @method "^(get|post|put|delete|patch)$")
30
- arguments: (arguments . (argument (string) @path)))
30
+ arguments: (arguments
31
+ . (argument (string) @path)
32
+ (argument [(anonymous_function) (arrow_function)] @closure)?))
31
33
  `,
32
34
  };
33
35
  const HTTP_FACADE_SPEC = {
@@ -125,12 +127,22 @@ export const PHP_HTTP_PLUGIN = {
125
127
  const path = phpStringText(pathNode);
126
128
  if (path === null)
127
129
  continue;
130
+ // A closure handler (`Route::get('/x', function(){…})` / `fn() => …`) has
131
+ // no name → emit `name: null` + the registration line so it resolves to
132
+ // its containing symbol (e.g. a service-provider `boot()` or controller
133
+ // method) by line-span containment. A named-controller route keeps the
134
+ // `'route'` label — resolving its array/string handler to a real method is
135
+ // a separate, graph-backed concern. NOTE: a closure at FILE scope
136
+ // (routes/web.php) has no enclosing function and PHP closures are not yet
137
+ // indexed as symbols, so it still degrades to file-level (see #2276).
138
+ const closureNode = match.captures.closure;
128
139
  out.push({
129
140
  role: 'provider',
130
141
  framework: 'laravel',
131
142
  method: methodNode.text.toUpperCase(),
132
143
  path,
133
- name: 'route',
144
+ name: closureNode ? null : 'route',
145
+ line: (closureNode ?? pathNode).startPosition.row + 1,
134
146
  confidence: 0.8,
135
147
  });
136
148
  }
@@ -66,6 +66,32 @@ const FASTAPI_ROUTER_PATTERNS = compilePatterns({
66
66
  },
67
67
  ],
68
68
  });
69
+ // ─── Provider: Flask `app.add_url_rule('/path', view_func=handler)` ───
70
+ // The imperative Flask route registration: unlike `@app.route` (whose handler
71
+ // is the decorated function, same-file), `view_func` is frequently an IMPORTED
72
+ // (and sometimes aliased) view, so the handler resolves through the file's
73
+ // imports. `add_url_rule` + a `view_func=` keyword is highly Flask-specific, so
74
+ // the false-positive risk is low. Method(s) come from a `methods=[...]` keyword
75
+ // (default GET), extracted in code from the captured call.
76
+ const FLASK_ADD_URL_RULE_PATTERNS = compilePatterns({
77
+ name: 'python-flask-add-url-rule',
78
+ language: Python,
79
+ patterns: [
80
+ {
81
+ meta: {},
82
+ query: `
83
+ (call
84
+ function: (attribute
85
+ attribute: (identifier) @fn (#eq? @fn "add_url_rule"))
86
+ arguments: (argument_list
87
+ . (string) @path
88
+ (keyword_argument
89
+ name: (identifier) @kw (#eq? @kw "view_func")
90
+ value: (identifier) @handler))) @call
91
+ `,
92
+ },
93
+ ],
94
+ });
69
95
  // ─── include_router(<router_obj>, prefix='/x') across the repo ────────
70
96
  // Two shapes are common:
71
97
  // app.include_router(assistant.router, prefix='/ai')
@@ -306,6 +332,79 @@ const WRAPPER_URI_VAR_PATTERNS = compilePatterns({
306
332
  },
307
333
  ],
308
334
  });
335
+ /**
336
+ * Map each `from <module> import <name> [as <alias>]` binding to its declared
337
+ * name + raw module specifier (the spec keeps the leading dots for relative
338
+ * imports — `.users`, `..pkg.users` — which the extractor resolves to a target
339
+ * file). Lets a Flask `view_func` handler resolve through an alias to the real
340
+ * symbol in its module rather than the local alias text. `import x` / `import x
341
+ * as y` (module imports, not symbol imports) are left out — a route handler is a
342
+ * symbol, addressed via `from … import …`.
343
+ */
344
+ function buildPythonImportMap(tree) {
345
+ const map = new Map();
346
+ const walk = (node) => {
347
+ if (node.type === 'import_from_statement') {
348
+ const moduleNode = node.childForFieldName('module_name');
349
+ const module = moduleNode?.text ?? null;
350
+ if (module !== null) {
351
+ for (let i = 0; i < node.namedChildCount; i++) {
352
+ const c = node.namedChild(i);
353
+ if (!c || c.id === moduleNode?.id)
354
+ continue;
355
+ if (c.type === 'dotted_name') {
356
+ map.set(c.text, { name: c.text, module });
357
+ }
358
+ else if (c.type === 'aliased_import') {
359
+ const nameNode = c.childForFieldName('name');
360
+ const aliasNode = c.childForFieldName('alias');
361
+ if (nameNode && aliasNode) {
362
+ map.set(aliasNode.text, { name: nameNode.text, module });
363
+ }
364
+ }
365
+ }
366
+ }
367
+ }
368
+ for (let i = 0; i < node.namedChildCount; i++) {
369
+ const c = node.namedChild(i);
370
+ if (c)
371
+ walk(c);
372
+ }
373
+ };
374
+ walk(tree.rootNode);
375
+ return map;
376
+ }
377
+ /**
378
+ * HTTP verbs declared on a Flask `add_url_rule(..., methods=[...])` call, upper-
379
+ * cased. Defaults to `['GET']` when no `methods` keyword is present (Flask's own
380
+ * default). Reads the captured call node directly since the list value is awkward
381
+ * to capture in a tree-sitter query.
382
+ */
383
+ function extractFlaskMethods(callNode) {
384
+ const args = callNode.childForFieldName('arguments');
385
+ if (args) {
386
+ for (let i = 0; i < args.namedChildCount; i++) {
387
+ const kw = args.namedChild(i);
388
+ if (!kw || kw.type !== 'keyword_argument')
389
+ continue;
390
+ if (kw.childForFieldName('name')?.text !== 'methods')
391
+ continue;
392
+ const list = kw.childForFieldName('value');
393
+ if (!list)
394
+ continue;
395
+ const methods = [];
396
+ for (let j = 0; j < list.namedChildCount; j++) {
397
+ const el = list.namedChild(j);
398
+ const v = el && el.type === 'string' ? unquoteLiteral(el.text) : null;
399
+ if (v)
400
+ methods.push(v.toUpperCase());
401
+ }
402
+ if (methods.length > 0)
403
+ return methods;
404
+ }
405
+ }
406
+ return ['GET'];
407
+ }
309
408
  // Pre-scan: collect local string assignments (uri = "api/v1/endpoint/")
310
409
  function buildLocalStringMap(tree) {
311
410
  const map = new Map();
@@ -846,6 +945,10 @@ export const PYTHON_HTTP_PLUGIN = {
846
945
  const out = [];
847
946
  const httpxAsyncClients = collectHttpxAsyncClients(tree);
848
947
  const ctx = repoContext;
948
+ // Local-binding → { declared name, module } for the file's `from … import …`
949
+ // statements, so an imperatively-registered handler (Flask `view_func`) that
950
+ // is an imported (possibly aliased) symbol resolves to its real definition.
951
+ const importMap = buildPythonImportMap(tree);
849
952
  // Providers: FastAPI @app.<verb>("/path") — already absolute path.
850
953
  for (const match of runCompiledPatterns(FASTAPI_APP_PATTERNS, tree)) {
851
954
  const methodNode = match.captures.method;
@@ -864,6 +967,12 @@ export const PYTHON_HTTP_PLUGIN = {
864
967
  method: httpMethod,
865
968
  path,
866
969
  name: null,
970
+ // The decorated handler has no captured name → resolve by line-span
971
+ // containment. Best-effort fallback: FastAPI routes are graph-backed
972
+ // (ingestion decorator routes) and the function span starts at `def`
973
+ // (decorators excluded), so this lands the single-decorator case and
974
+ // degrades to file-level for multi-decorator stacks.
975
+ line: pathNode.startPosition.row + 1,
867
976
  confidence: 0.8,
868
977
  });
869
978
  }
@@ -905,6 +1014,35 @@ export const PYTHON_HTTP_PLUGIN = {
905
1014
  method: httpMethod,
906
1015
  path: p,
907
1016
  name: null,
1017
+ // Best-effort containment fallback — see the @app provider note above.
1018
+ line: pathNode.startPosition.row + 1,
1019
+ confidence: 0.8,
1020
+ });
1021
+ }
1022
+ }
1023
+ // Providers: Flask `app.add_url_rule('/path', view_func=handler, methods=[…])`.
1024
+ // The handler is a `view_func` identifier, frequently an imported (possibly
1025
+ // aliased) view, so resolve it through the file's imports to the declared
1026
+ // symbol + its module for import-pinned resolution downstream.
1027
+ for (const match of runCompiledPatterns(FLASK_ADD_URL_RULE_PATTERNS, tree)) {
1028
+ const pathNode = match.captures.path;
1029
+ const handlerNode = match.captures.handler;
1030
+ const callNode = match.captures.call;
1031
+ if (!pathNode || !handlerNode || !callNode)
1032
+ continue;
1033
+ const path = unquoteLiteral(pathNode.text);
1034
+ if (path === null)
1035
+ continue;
1036
+ const imported = importMap.get(handlerNode.text);
1037
+ for (const method of extractFlaskMethods(callNode)) {
1038
+ out.push({
1039
+ role: 'provider',
1040
+ framework: 'flask',
1041
+ method,
1042
+ path,
1043
+ name: imported ? imported.name : handlerNode.text,
1044
+ handlerImport: imported,
1045
+ line: (imported ? pathNode : handlerNode).startPosition.row + 1,
908
1046
  confidence: 0.8,
909
1047
  });
910
1048
  }
@@ -42,6 +42,20 @@ export interface HttpDetection {
42
42
  * not set it falls back to file-level boundary resolution downstream.
43
43
  */
44
44
  line?: number;
45
+ /**
46
+ * When the handler is an IMPORTED symbol, the import resolved to its declared
47
+ * (exported) `name` and the `module` specifier it came from. The extractor
48
+ * pins resolution to the import's target file, so an aliased import
49
+ * (`import { listUsers as handleUsers }`) or a name that collides with a local
50
+ * symbol resolves to the right handler instead of a same-named decoy. `name`
51
+ * here is the DECLARED export name (not the local alias); `module` is the raw
52
+ * specifier (e.g. `./handlers/users`). Set only for named imports; omitted for
53
+ * locally-defined or anonymous handlers.
54
+ */
55
+ handlerImport?: {
56
+ name: string;
57
+ module: string;
58
+ };
45
59
  /** Confidence in (0, 1]. Source-scan plugins typically use 0.7–0.8. */
46
60
  confidence: number;
47
61
  }
@@ -67,6 +67,65 @@ MATCH (sym:CodeElement)
67
67
  WHERE sym.filePath = $filePath AND sym.startLine IS NOT NULL AND sym.endLine IS NOT NULL
68
68
  RETURN sym.id AS uid, sym.name AS name, sym.filePath AS filePath,
69
69
  sym.startLine AS startLine, sym.endLine AS endLine, labels(sym) AS labels`;
70
+ // Repo-wide lookup of a symbol by exact name (label-union, as in
71
+ // manifest-extractor.ts). Used to resolve a provider's named handler when it is
72
+ // defined in a file OTHER than its route registration — and only honored when
73
+ // the result is unique (see resolveSymbolByNameUnique).
74
+ //
75
+ // `n.filePath <> ''` excludes synthetic non-source `CodeElement` nodes that
76
+ // carry no real file — ORM model/table nodes (orm.ts emits `filePath: ''`) and
77
+ // similar — so a handler name colliding with an ORM model neither resolves to a
78
+ // degenerate edge-less node NOR inflates the uniqueness count and masks the real
79
+ // handler. `LIMIT 2` bounds materialization: distinguishing unique (1) from
80
+ // ambiguous (>=2) never needs more than two rows (the count guard stays exact).
81
+ const RESOLVE_BY_NAME_QUERY = `
82
+ MATCH (n:Function|Method|CodeElement)
83
+ WHERE n.name = $name AND n.filePath <> ''
84
+ RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
85
+ LIMIT 2`;
86
+ // Resolve an IMPORTED handler by pinning it to the import's target module: the
87
+ // declared export `$name` whose file is the module the handler was imported from
88
+ // (`$fileDot` matches `mod.ext`, `$fileSlash` matches `mod/index.ext`). This is
89
+ // the precise rung — it survives aliases and local same-name collisions that a
90
+ // repo-wide name lookup cannot, and only resolves on a unique match within that
91
+ // module. `LIMIT 2` keeps the uniqueness count exact (see RESOLVE_BY_NAME_QUERY).
92
+ const RESOLVE_IN_MODULE_QUERY = `
93
+ MATCH (n:Function|Method|CodeElement)
94
+ WHERE n.name = $name AND (n.filePath STARTS WITH $fileDot OR n.filePath STARTS WITH $fileSlash)
95
+ RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
96
+ LIMIT 2`;
97
+ // Source-file extensions an import specifier may resolve to (stripped before
98
+ // building the module file-prefix so `./h/users` and `./h/users.ts` agree).
99
+ const SOURCE_EXT_RE = /\.(?:m|c)?[jt]sx?$/;
100
+ /**
101
+ * Resolve an import specifier to a repo-relative FILE BASE (path without
102
+ * extension) so the target module can be matched by `filePath STARTS WITH`.
103
+ * Handles two relative-import dialects and returns null for bare/absolute
104
+ * imports (which fall back to a repo-wide name lookup):
105
+ * - path-style (JS/TS): `./handlers/users`, `../x` → joined against the
106
+ * importing file's directory.
107
+ * - dotted-relative (Python): `.users`, `..pkg.users` → leading dots are
108
+ * package levels (one dot = the file's own package), the rest dot→slash.
109
+ */
110
+ function resolveModuleBase(fromFile, module) {
111
+ const dir = path.posix.dirname(fromFile.replace(/\\/g, '/'));
112
+ if (module.includes('/')) {
113
+ // path-style relative import
114
+ if (!module.startsWith('.'))
115
+ return null;
116
+ return path.posix.normalize(path.posix.join(dir, module)).replace(SOURCE_EXT_RE, '');
117
+ }
118
+ if (module.startsWith('.')) {
119
+ // Python dotted-relative import
120
+ const dots = module.length - module.replace(/^\.+/, '').length;
121
+ const rest = module.slice(dots).replace(/\./g, '/');
122
+ let base = dir;
123
+ for (let i = 1; i < dots; i++)
124
+ base = path.posix.dirname(base);
125
+ return rest ? path.posix.normalize(path.posix.join(base, rest)) : base;
126
+ }
127
+ return null; // bare / absolute import — repo-wide fallback
128
+ }
70
129
  /**
71
130
  * The innermost Function/Method whose `[startLine, endLine]` span contains
72
131
  * `line` — i.e. the symbol the HTTP call lives inside. For a consumer this is
@@ -324,22 +383,120 @@ export class HttpRouteExtractor {
324
383
  fileSymbolCache.set(filePath, rows);
325
384
  return rows;
326
385
  };
386
+ // Repo-wide UNAMBIGUOUS resolution for a provider handler defined in a file
387
+ // other than its route registration (e.g. `router.get('/x', listUsers)` with
388
+ // `listUsers` imported from another module). Returns the symbol ONLY when
389
+ // exactly one Function/Method/CodeElement carries that name across the repo.
390
+ // The strict uniqueness guard is intentionally conservative: when a name is
391
+ // shared across files (homonyms like `handler`/`index`), we prefer a
392
+ // false-negative (no attribution → file-level fallback) over a false-positive
393
+ // (wrong symbol).
394
+ //
395
+ // An IMPORTED handler (the common cross-file case) is pinned to its source
396
+ // module first by resolveImportedSymbol, so an alias or a name colliding with
397
+ // a local symbol resolves correctly; this repo-wide-by-name rung is the
398
+ // fallback for non-relative/bare imports and for plugins that supply only a
399
+ // name. Cached by name for the lifetime of this extract().
400
+ const globalNameCache = new Map();
401
+ const toResolvedSymbol = (rows) => {
402
+ const norm = (x) => String(x ?? '');
403
+ const uid = rows.length === 1 ? norm(rows[0].uid ?? rows[0][0]) : '';
404
+ const filePath = uid ? norm(rows[0].filePath ?? rows[0][2]) : '';
405
+ // Reject a unique match that carries no real file (a synthetic ORM /
406
+ // non-source node) so it can never anchor a cross-trace on an edge-less
407
+ // node — defence in depth alongside the queries' filePath predicates.
408
+ return uid && filePath ? { uid, name: norm(rows[0].name ?? rows[0][1]), filePath } : null;
409
+ };
410
+ const resolveSymbolByNameUnique = async (name) => {
411
+ if (!dbExecutor)
412
+ return null;
413
+ const cached = globalNameCache.get(name);
414
+ if (cached !== undefined)
415
+ return cached;
416
+ let rows = [];
417
+ try {
418
+ rows = await dbExecutor(RESOLVE_BY_NAME_QUERY, { name });
419
+ }
420
+ catch {
421
+ rows = [];
422
+ }
423
+ const result = toResolvedSymbol(rows);
424
+ globalNameCache.set(name, result);
425
+ return result;
426
+ };
427
+ // Resolve a handler imported from a RELATIVE module to the unique declared
428
+ // symbol of that name inside the import's target file. Returns null for
429
+ // non-relative (bare/aliased-path) imports — those fall back to the repo-wide
430
+ // name lookup. Cached by (target-file-prefix, declared name).
431
+ const importedSymbolCache = new Map();
432
+ const resolveImportedSymbol = async (fromFile, imp) => {
433
+ if (!dbExecutor)
434
+ return null;
435
+ const base = resolveModuleBase(fromFile, imp.module);
436
+ if (base === null)
437
+ return null; // bare/absolute import → repo-wide fallback
438
+ const cacheKey = JSON.stringify([base, imp.name]);
439
+ const cached = importedSymbolCache.get(cacheKey);
440
+ if (cached !== undefined)
441
+ return cached;
442
+ let rows = [];
443
+ try {
444
+ rows = await dbExecutor(RESOLVE_IN_MODULE_QUERY, {
445
+ name: imp.name,
446
+ fileDot: `${base}.`,
447
+ fileSlash: `${base}/`,
448
+ });
449
+ }
450
+ catch {
451
+ rows = [];
452
+ }
453
+ const result = toResolvedSymbol(rows);
454
+ importedSymbolCache.set(cacheKey, result);
455
+ return result;
456
+ };
327
457
  const resolveDetectionSymbol = async (filePath, d) => {
328
458
  if (!dbExecutor)
329
459
  return null;
330
460
  const syms = await loadFileSymbols(filePath);
331
- if (syms.length === 0)
332
- return null;
333
461
  // Name resolution does NOT need a detection line — a named provider
334
462
  // handler (Spring/Go/etc. method name) resolves by name even when the
335
- // plugin didn't set `line`. Try it FIRST; only the containment fallback
336
- // requires a line.
463
+ // plugin didn't set `line`. Try the registration file FIRST; then, for a
464
+ // handler defined in another file, the unique repo-wide match. Only the
465
+ // containment fallback requires a line.
337
466
  if (d.role === 'provider' && d.name) {
467
+ // IMPORTED handler: pin to the import's target module first. This is the
468
+ // precise rung — it survives aliases and names that collide with a local
469
+ // symbol. The handler is defined ELSEWHERE, so a file-scoped lookup of
470
+ // its (declared) name would be wrong; on a miss go straight to a unique
471
+ // repo-wide match on the declared name, never file-scoped.
472
+ if (d.handlerImport) {
473
+ const byImport = await resolveImportedSymbol(filePath, d.handlerImport);
474
+ if (byImport)
475
+ return byImport;
476
+ const byGlobal = await resolveSymbolByNameUnique(d.handlerImport.name);
477
+ if (byGlobal)
478
+ return byGlobal;
479
+ return null;
480
+ }
338
481
  const byName = resolveSymbolByName(syms, d.name);
339
482
  if (byName)
340
483
  return byName;
484
+ const byGlobal = await resolveSymbolByNameUnique(d.name);
485
+ if (byGlobal)
486
+ return byGlobal;
487
+ // A NAMED handler we could not resolve by name (neither file-scoped nor
488
+ // the unique repo-wide match) must NOT fall through to line-span
489
+ // containment: `d.line` is the route REGISTRATION site, so containment
490
+ // would attach the route to the enclosing registrar (e.g. a
491
+ // `setupRoutes()` wrapper) rather than the handler. Leave it empty →
492
+ // file-level boundary fallback, upholding the invariant that a
493
+ // zero/ambiguous name match never yields a wrong-symbol attribution.
494
+ return null;
341
495
  }
342
- if (d.line == null)
496
+ // Consumers (the function making the fetch) and inline-arrow providers
497
+ // (d.name === null) DO resolve by containment — there the enclosing symbol
498
+ // is the right one.
499
+ if (syms.length === 0 || d.line == null)
343
500
  return null;
344
501
  return resolveContainingSymbol(syms, d.line);
345
502
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.9-rc.10",
3
+ "version": "1.6.9-rc.12",
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",