gitnexus 1.6.9-rc.10 → 1.6.9-rc.11

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.
@@ -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
  });
@@ -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;
@@ -909,6 +1012,33 @@ export const PYTHON_HTTP_PLUGIN = {
909
1012
  });
910
1013
  }
911
1014
  }
1015
+ // Providers: Flask `app.add_url_rule('/path', view_func=handler, methods=[…])`.
1016
+ // The handler is a `view_func` identifier, frequently an imported (possibly
1017
+ // aliased) view, so resolve it through the file's imports to the declared
1018
+ // symbol + its module for import-pinned resolution downstream.
1019
+ for (const match of runCompiledPatterns(FLASK_ADD_URL_RULE_PATTERNS, tree)) {
1020
+ const pathNode = match.captures.path;
1021
+ const handlerNode = match.captures.handler;
1022
+ const callNode = match.captures.call;
1023
+ if (!pathNode || !handlerNode || !callNode)
1024
+ continue;
1025
+ const path = unquoteLiteral(pathNode.text);
1026
+ if (path === null)
1027
+ continue;
1028
+ const imported = importMap.get(handlerNode.text);
1029
+ for (const method of extractFlaskMethods(callNode)) {
1030
+ out.push({
1031
+ role: 'provider',
1032
+ framework: 'flask',
1033
+ method,
1034
+ path,
1035
+ name: imported ? imported.name : handlerNode.text,
1036
+ handlerImport: imported,
1037
+ line: (imported ? pathNode : handlerNode).startPosition.row + 1,
1038
+ confidence: 0.8,
1039
+ });
1040
+ }
1041
+ }
912
1042
  // Consumers: requests.<verb>
913
1043
  for (const match of runCompiledPatterns(REQUESTS_VERB_PATTERNS, tree)) {
914
1044
  const methodNode = match.captures.method;
@@ -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.11",
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",