gitnexus 1.6.6-rc.81 → 1.6.6-rc.82

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.
@@ -19,9 +19,13 @@ const FASTAPI_VERBS = {
19
19
  delete: 'DELETE',
20
20
  patch: 'PATCH',
21
21
  };
22
- // ─── Provider: FastAPI @app.get/... ──────────────────────────────────
23
- const FASTAPI_PATTERNS = compilePatterns({
24
- name: 'python-fastapi',
22
+ // ─── Provider: FastAPI @app.<verb> / @router.<verb> ──────────────────
23
+ // Two separate patterns so we can tag detections by decorator object.
24
+ // Only `@router.*` detections participate in `include_router(prefix=)`
25
+ // path-prefix joining (see `PythonRepoContext` + `joinPrefix`); `@app.*`
26
+ // routes already carry their final path verbatim.
27
+ const FASTAPI_APP_PATTERNS = compilePatterns({
28
+ name: 'python-fastapi-app',
25
29
  language: Python,
26
30
  patterns: [
27
31
  {
@@ -37,6 +41,133 @@ const FASTAPI_PATTERNS = compilePatterns({
37
41
  },
38
42
  ],
39
43
  });
44
+ const FASTAPI_ROUTER_PATTERNS = compilePatterns({
45
+ name: 'python-fastapi-router',
46
+ language: Python,
47
+ patterns: [
48
+ {
49
+ meta: {},
50
+ query: `
51
+ (decorator
52
+ (call
53
+ function: (attribute
54
+ object: (identifier) @obj (#eq? @obj "router")
55
+ attribute: (identifier) @method (#match? @method "^(get|post|put|delete|patch)$"))
56
+ arguments: (argument_list . (string) @path)))
57
+ `,
58
+ },
59
+ ],
60
+ });
61
+ // ─── include_router(<router_obj>, prefix='/x') across the repo ────────
62
+ // Two shapes are common:
63
+ // app.include_router(assistant.router, prefix='/ai')
64
+ // app.include_router(my_router, prefix='/ai')
65
+ // The first names the originating module via `<module>.router`; the second
66
+ // references a name imported into the host file. We capture both.
67
+ const INCLUDE_ROUTER_ATTR_PATTERNS = compilePatterns({
68
+ name: 'python-fastapi-include-router-attr',
69
+ language: Python,
70
+ patterns: [
71
+ {
72
+ meta: {},
73
+ // Match any `<host>.include_router(<module>.router, ..., prefix='/x')`
74
+ // call. We deliberately do NOT pin `<host>` to the literal name `app`
75
+ // — production code routinely uses `api`, `application`, `asgi_app`,
76
+ // etc. The shape (`include_router` invoked with a router argument and
77
+ // a `prefix=` keyword) is specific enough on its own; restricting the
78
+ // host produces false negatives without removing meaningful false
79
+ // positives.
80
+ query: `
81
+ (call
82
+ function: (attribute
83
+ attribute: (identifier) @incl (#eq? @incl "include_router"))
84
+ arguments: (argument_list
85
+ (attribute
86
+ object: (identifier) @router_module
87
+ attribute: (identifier) @router_attr (#eq? @router_attr "router"))
88
+ (keyword_argument
89
+ name: (identifier) @kw (#eq? @kw "prefix")
90
+ value: (string) @prefix)))
91
+ `,
92
+ },
93
+ ],
94
+ });
95
+ const INCLUDE_ROUTER_NAME_PATTERNS = compilePatterns({
96
+ name: 'python-fastapi-include-router-name',
97
+ language: Python,
98
+ patterns: [
99
+ {
100
+ meta: {},
101
+ // Same `<host>` rationale as INCLUDE_ROUTER_ATTR_PATTERNS — see above.
102
+ query: `
103
+ (call
104
+ function: (attribute
105
+ attribute: (identifier) @incl (#eq? @incl "include_router"))
106
+ arguments: (argument_list
107
+ (identifier) @router_name
108
+ (keyword_argument
109
+ name: (identifier) @kw (#eq? @kw "prefix")
110
+ value: (string) @prefix)))
111
+ `,
112
+ },
113
+ ],
114
+ });
115
+ // `from .api.assistant import router` style — used together with
116
+ // INCLUDE_ROUTER_NAME so we can map a local name back to its module
117
+ // path, then back to the file the router was declared in.
118
+ const FROM_IMPORT_ROUTER_PATTERNS = compilePatterns({
119
+ name: 'python-fastapi-from-import-router',
120
+ language: Python,
121
+ patterns: [
122
+ {
123
+ meta: {},
124
+ query: `
125
+ (import_from_statement
126
+ module_name: (_) @module
127
+ name: (dotted_name (identifier) @imported (#eq? @imported "router")))
128
+ `,
129
+ },
130
+ {
131
+ meta: {},
132
+ query: `
133
+ (import_from_statement
134
+ module_name: (_) @module
135
+ name: (aliased_import
136
+ name: (dotted_name (identifier) @imported (#eq? @imported "router"))
137
+ alias: (identifier) @alias))
138
+ `,
139
+ },
140
+ ],
141
+ });
142
+ // `from api import users` / `from api import users as u` — module-level
143
+ // imports where the imported name is itself the module that owns
144
+ // `<name>.router`. Lets Shape A (`<host>.include_router(<name>.router, …)`)
145
+ // look up the full package path of `<name>` and pin the prefix onto the
146
+ // exact file (`api/users.py`) rather than every file basenamed `users.py`.
147
+ const FROM_IMPORT_MODULE_PATTERNS = compilePatterns({
148
+ name: 'python-fastapi-from-import-module',
149
+ language: Python,
150
+ patterns: [
151
+ {
152
+ meta: {},
153
+ query: `
154
+ (import_from_statement
155
+ module_name: (_) @module
156
+ name: (dotted_name (identifier) @imported))
157
+ `,
158
+ },
159
+ {
160
+ meta: {},
161
+ query: `
162
+ (import_from_statement
163
+ module_name: (_) @module
164
+ name: (aliased_import
165
+ name: (dotted_name (identifier) @imported)
166
+ alias: (identifier) @alias))
167
+ `,
168
+ },
169
+ ],
170
+ });
40
171
  // ─── Consumer: requests.get/post/... ──────────────────────────────────
41
172
  const REQUESTS_VERB_PATTERNS = compilePatterns({
42
173
  name: 'python-requests-verb',
@@ -405,14 +536,183 @@ const HTTPX_ASYNC_CLIENT_GENERIC_PATTERNS = compilePatterns({
405
536
  },
406
537
  ],
407
538
  });
539
+ /** Strip `.py` and return the bare basename (e.g. `api/users.py` → `users`). */
540
+ function fileShortKey(rel) {
541
+ const slash = rel.lastIndexOf('/');
542
+ const file = slash >= 0 ? rel.slice(slash + 1) : rel;
543
+ return file.endsWith('.py') ? file.slice(0, -3) : file;
544
+ }
545
+ /**
546
+ * Long key for a `.py` file: parent directory + stem, joined with `/`.
547
+ * Files at the repo root return the empty string (no parent), in which
548
+ * case callers should fall back to the short key.
549
+ */
550
+ function fileLongKey(rel) {
551
+ const noExt = rel.endsWith('.py') ? rel.slice(0, -3) : rel;
552
+ const lastSlash = noExt.lastIndexOf('/');
553
+ if (lastSlash < 0)
554
+ return '';
555
+ const beforeLast = noExt.slice(0, lastSlash);
556
+ const stem = noExt.slice(lastSlash + 1);
557
+ const prevSlash = beforeLast.lastIndexOf('/');
558
+ const parent = prevSlash >= 0 ? beforeLast.slice(prevSlash + 1) : beforeLast;
559
+ return `${parent}/${stem}`;
560
+ }
561
+ /** Last `.`-separated segment of a (possibly relative) module path. */
562
+ function lastSegmentOfDotted(text) {
563
+ const stripped = text.replace(/^\.+/, '');
564
+ if (!stripped)
565
+ return '';
566
+ const dot = stripped.lastIndexOf('.');
567
+ return dot >= 0 ? stripped.slice(dot + 1) : stripped;
568
+ }
569
+ /**
570
+ * Last two `.`-separated segments of a (possibly relative) module path
571
+ * joined with `/`, e.g. `api.users` → `api/users`. Single-segment paths
572
+ * and pure-dot inputs return the empty string; callers should fall back
573
+ * to the short key in that case.
574
+ */
575
+ function lastTwoSegmentsAsLongKey(text) {
576
+ const stripped = text.replace(/^\.+/, '');
577
+ if (!stripped)
578
+ return '';
579
+ const last = stripped.lastIndexOf('.');
580
+ if (last <= 0)
581
+ return '';
582
+ const beforeLast = stripped.slice(0, last);
583
+ const stem = stripped.slice(last + 1);
584
+ const prev = beforeLast.lastIndexOf('.');
585
+ const parent = prev >= 0 ? beforeLast.slice(prev + 1) : beforeLast;
586
+ return `${parent}/${stem}`;
587
+ }
588
+ function recordPrefix(target, key, prefix) {
589
+ const set = target.get(key) ?? new Set();
590
+ set.add(prefix);
591
+ target.set(key, set);
592
+ }
593
+ function buildPythonRepoContext(files, parser, readFile, parseSource) {
594
+ const prefixesByLongKey = new Map();
595
+ const prefixesByShortKey = new Map();
596
+ // Pre-pass over .py files. We deliberately run this even on files
597
+ // that don't contain `include_router` — the cost of an extra parse
598
+ // is bounded by the file count, and detecting `include_router`
599
+ // beforehand would require its own grep/scan.
600
+ for (const rel of files) {
601
+ if (!rel.endsWith('.py'))
602
+ continue;
603
+ const src = readFile(rel);
604
+ if (!src)
605
+ continue;
606
+ if (!src.includes('include_router'))
607
+ continue;
608
+ parser.setLanguage(Python);
609
+ const tree = parseSource(parser, src);
610
+ if (!tree)
611
+ continue;
612
+ const localNameToModule = new Map();
613
+ for (const m of runCompiledPatterns(FROM_IMPORT_ROUTER_PATTERNS, tree)) {
614
+ const moduleNode = m.captures.module;
615
+ const aliasNode = m.captures.alias;
616
+ const importedNode = m.captures.imported;
617
+ if (!moduleNode || !importedNode)
618
+ continue;
619
+ const localName = aliasNode?.text ?? importedNode.text;
620
+ const moduleShort = lastSegmentOfDotted(moduleNode.text);
621
+ if (!moduleShort)
622
+ continue;
623
+ const moduleLong = lastTwoSegmentsAsLongKey(moduleNode.text);
624
+ localNameToModule.set(localName, { moduleShort, moduleLong });
625
+ }
626
+ // Module-alias map: name imported from a multi-segment package →
627
+ // long key. Lets Shape A look up the precise file for `<name>.router`
628
+ // even when `<name>` collides with another package's basename.
629
+ const localNameToModuleAlias = new Map();
630
+ for (const m of runCompiledPatterns(FROM_IMPORT_MODULE_PATTERNS, tree)) {
631
+ const moduleNode = m.captures.module;
632
+ const importedNode = m.captures.imported;
633
+ const aliasNode = m.captures.alias;
634
+ if (!moduleNode || !importedNode)
635
+ continue;
636
+ // Skip the `router` shape — already handled by FROM_IMPORT_ROUTER_PATTERNS
637
+ // above and stored under its router-aware semantics.
638
+ if (importedNode.text === 'router')
639
+ continue;
640
+ const moduleLong = lastTwoSegmentsAsLongKey(`${moduleNode.text}.${importedNode.text}`);
641
+ if (!moduleLong)
642
+ continue;
643
+ const localName = aliasNode?.text ?? importedNode.text;
644
+ localNameToModuleAlias.set(localName, moduleLong);
645
+ }
646
+ // Shape A: `<host>.include_router(<module>.router, prefix='/x')`.
647
+ // The call site gives us only a short module name. We promote to a
648
+ // long key when the same file imports `<module>` via either
649
+ // `from <pkg> import <module>` (recorded in `localNameToModuleAlias`
650
+ // — the typical pattern) or, less commonly, a router-aware import
651
+ // statement. Only fall back to the basename short key when neither
652
+ // alias is available.
653
+ for (const m of runCompiledPatterns(INCLUDE_ROUTER_ATTR_PATTERNS, tree)) {
654
+ const modNode = m.captures.router_module;
655
+ const prefixNode = m.captures.prefix;
656
+ if (!modNode || !prefixNode)
657
+ continue;
658
+ const prefix = unquoteLiteral(prefixNode.text);
659
+ if (prefix === null)
660
+ continue;
661
+ const moduleShort = modNode.text;
662
+ const aliasLong = localNameToModuleAlias.get(moduleShort);
663
+ const sameFileImport = localNameToModule.get(moduleShort);
664
+ const longKey = aliasLong ?? sameFileImport?.moduleLong;
665
+ if (longKey) {
666
+ recordPrefix(prefixesByLongKey, longKey, prefix);
667
+ }
668
+ else {
669
+ recordPrefix(prefixesByShortKey, moduleShort, prefix);
670
+ }
671
+ }
672
+ // Shape B: `<host>.include_router(my_router, prefix='/x')` — resolve
673
+ // `my_router` via the import map built above. Whenever the import
674
+ // statement supplied a multi-segment module path the long key is
675
+ // recorded, eliminating cross-package collisions.
676
+ for (const m of runCompiledPatterns(INCLUDE_ROUTER_NAME_PATTERNS, tree)) {
677
+ const nameNode = m.captures.router_name;
678
+ const prefixNode = m.captures.prefix;
679
+ if (!nameNode || !prefixNode)
680
+ continue;
681
+ const localImp = localNameToModule.get(nameNode.text);
682
+ if (!localImp)
683
+ continue;
684
+ const prefix = unquoteLiteral(prefixNode.text);
685
+ if (prefix === null)
686
+ continue;
687
+ if (localImp.moduleLong) {
688
+ recordPrefix(prefixesByLongKey, localImp.moduleLong, prefix);
689
+ }
690
+ else {
691
+ recordPrefix(prefixesByShortKey, localImp.moduleShort, prefix);
692
+ }
693
+ }
694
+ }
695
+ return { prefixesByLongKey, prefixesByShortKey };
696
+ }
697
+ function joinPrefix(prefix, route) {
698
+ // Mirror FastAPI's path joining: trim trailing slash off prefix,
699
+ // ensure exactly one leading slash on the result.
700
+ const p = prefix.replace(/\/+$/, '');
701
+ const r = route.startsWith('/') ? route : `/${route}`;
702
+ return `${p}${r}`;
703
+ }
408
704
  export const PYTHON_HTTP_PLUGIN = {
409
705
  name: 'python-http',
410
706
  language: Python,
411
- scan(tree) {
707
+ prepareRepo({ files, parser, readFile, parseSource }) {
708
+ return buildPythonRepoContext(files, parser, readFile, parseSource);
709
+ },
710
+ scan(tree, repoContext, fileRel) {
412
711
  const out = [];
413
712
  const httpxAsyncClients = collectHttpxAsyncClients(tree);
414
- // Providers: FastAPI
415
- for (const match of runCompiledPatterns(FASTAPI_PATTERNS, tree)) {
713
+ const ctx = repoContext;
714
+ // Providers: FastAPI @app.<verb>("/path") already absolute path.
715
+ for (const match of runCompiledPatterns(FASTAPI_APP_PATTERNS, tree)) {
416
716
  const methodNode = match.captures.method;
417
717
  const pathNode = match.captures.path;
418
718
  if (!methodNode || !pathNode)
@@ -432,6 +732,45 @@ export const PYTHON_HTTP_PLUGIN = {
432
732
  confidence: 0.8,
433
733
  });
434
734
  }
735
+ // Providers: FastAPI @router.<verb>("/path") — must be joined
736
+ // with the prefix(es) declared at the include_router site. When
737
+ // no prefix is found we still emit the unprefixed path so this
738
+ // change is strictly additive vs. the prior @app-only behaviour;
739
+ // when the same router is mounted under multiple prefixes we emit
740
+ // one detection per prefix.
741
+ for (const match of runCompiledPatterns(FASTAPI_ROUTER_PATTERNS, tree)) {
742
+ const methodNode = match.captures.method;
743
+ const pathNode = match.captures.path;
744
+ if (!methodNode || !pathNode)
745
+ continue;
746
+ const httpMethod = FASTAPI_VERBS[methodNode.text];
747
+ if (!httpMethod)
748
+ continue;
749
+ const rawPath = unquoteLiteral(pathNode.text);
750
+ if (rawPath === null)
751
+ continue;
752
+ // Long key first (precise, package-aware), short key as fallback.
753
+ // Mirrors the ingestion-side resolution in parse-impl.ts so the
754
+ // graph nodes and group contracts agree on which prefix applies.
755
+ const longKey = fileRel ? fileLongKey(fileRel) : '';
756
+ const longPrefixes = longKey ? ctx?.prefixesByLongKey.get(longKey) : undefined;
757
+ const shortKey = fileRel ? fileShortKey(fileRel) : '';
758
+ const shortPrefixes = longPrefixes || !shortKey ? undefined : ctx?.prefixesByShortKey.get(shortKey);
759
+ const prefixSet = longPrefixes ?? shortPrefixes;
760
+ const paths = prefixSet && prefixSet.size > 0
761
+ ? [...prefixSet].map((p) => joinPrefix(p, rawPath))
762
+ : [rawPath];
763
+ for (const p of paths) {
764
+ out.push({
765
+ role: 'provider',
766
+ framework: 'fastapi',
767
+ method: httpMethod,
768
+ path: p,
769
+ name: null,
770
+ confidence: 0.8,
771
+ });
772
+ }
773
+ }
435
774
  // Consumers: requests.<verb>
436
775
  for (const match of runCompiledPatterns(REQUESTS_VERB_PATTERNS, tree)) {
437
776
  const methodNode = match.captures.method;
@@ -47,15 +47,47 @@ export interface HttpDetection {
47
47
  * `LanguagePatterns.language` in `tree-sitter-scanner.ts` — the
48
48
  * grammar modules export different shapes.
49
49
  */
50
+ /**
51
+ * Per-repo state a plugin can build during a `prepareRepo` pass before
52
+ * any per-file `scan` is invoked. The orchestrator threads this opaque
53
+ * value back into each `scan` call so plugins can resolve cross-file
54
+ * facts (e.g. FastAPI `app.include_router(prefix=...)` mappings live
55
+ * in `main.py` but apply to handlers declared in `api/*.py`).
56
+ *
57
+ * Plugins that have no cross-file state can omit `prepareRepo` and
58
+ * receive `undefined`.
59
+ */
60
+ export type RepoContext = unknown;
50
61
  export interface HttpLanguagePlugin {
51
62
  /** Human-readable plugin name for diagnostics. */
52
63
  name: string;
53
64
  /** tree-sitter grammar object (passed to the shared parser). */
54
65
  language: unknown;
66
+ /**
67
+ * Optional pre-pass: walk the relevant files in the repo and produce
68
+ * an opaque context that `scan` can use to resolve cross-file facts.
69
+ * Implementations must not throw — return undefined on any error so
70
+ * the orchestrator falls back to context-less scanning.
71
+ */
72
+ prepareRepo?(args: {
73
+ repoPath: string;
74
+ files: string[];
75
+ parser: Parser;
76
+ readFile: (rel: string) => string | null;
77
+ parseSource: (parser: Parser, src: string) => Parser.Tree | null;
78
+ }): RepoContext | undefined;
55
79
  /**
56
80
  * Scan a parsed tree and return zero or more HTTP detections. Plugins
57
81
  * must not throw — they should swallow per-match errors so a single
58
82
  * malformed construct does not abort the whole file.
83
+ *
84
+ * `repoContext` is whatever the plugin's `prepareRepo` produced (or
85
+ * `undefined` if there is no `prepareRepo`).
86
+ *
87
+ * `fileRel` is the repo-relative path of the file being scanned;
88
+ * plugins that resolve cross-file facts (e.g. FastAPI router prefix
89
+ * joining) need it to key into `repoContext`. Optional so existing
90
+ * single-file plugins can keep their unary `scan(tree)` shape.
59
91
  */
60
- scan(tree: Parser.Tree): HttpDetection[];
92
+ scan(tree: Parser.Tree, repoContext?: RepoContext, fileRel?: string): HttpDetection[];
61
93
  }
@@ -141,7 +141,35 @@ export class HttpRouteExtractor {
141
141
  // both graph-assisted enrichment and source-scan emission.
142
142
  const parser = new Parser();
143
143
  const cachedDetections = new Map();
144
- const getDetections = (rel) => {
144
+ // Per-plugin cross-file context (e.g. Python's FastAPI router →
145
+ // include_router(prefix=...) map). Built lazily on first
146
+ // `getDetections` call for a file the plugin handles, scoped to the
147
+ // file list returned by `getScannedFiles`. Stored by plugin name so
148
+ // a repo with multiple languages keeps each plugin's context
149
+ // independent.
150
+ const repoContextByPlugin = new Map();
151
+ const ensureRepoContext = async (plugin) => {
152
+ if (!plugin || typeof plugin.prepareRepo !== 'function')
153
+ return undefined;
154
+ if (repoContextByPlugin.has(plugin.name))
155
+ return repoContextByPlugin.get(plugin.name);
156
+ try {
157
+ const ctx = plugin.prepareRepo({
158
+ repoPath,
159
+ files: await getScannedFiles(),
160
+ parser,
161
+ readFile: (rel) => readSafe(repoPath, rel),
162
+ parseSource: (p, src) => parseSourceSafe(p, src),
163
+ });
164
+ repoContextByPlugin.set(plugin.name, ctx);
165
+ return ctx;
166
+ }
167
+ catch {
168
+ repoContextByPlugin.set(plugin.name, undefined);
169
+ return undefined;
170
+ }
171
+ };
172
+ const getDetections = async (rel) => {
145
173
  const cached = cachedDetections.get(rel);
146
174
  if (cached)
147
175
  return cached;
@@ -150,6 +178,7 @@ export class HttpRouteExtractor {
150
178
  cachedDetections.set(rel, []);
151
179
  return [];
152
180
  }
181
+ const repoContext = await ensureRepoContext(plugin);
153
182
  const content = readSafe(repoPath, rel);
154
183
  if (!content) {
155
184
  cachedDetections.set(rel, []);
@@ -158,7 +187,7 @@ export class HttpRouteExtractor {
158
187
  try {
159
188
  parser.setLanguage(plugin.language);
160
189
  const tree = parseSourceSafe(parser, content);
161
- const detections = plugin.scan(tree);
190
+ const detections = plugin.scan(tree, repoContext, rel);
162
191
  cachedDetections.set(rel, detections);
163
192
  return detections;
164
193
  }
@@ -179,9 +208,9 @@ export class HttpRouteExtractor {
179
208
  const graphProviders = dbExecutor != null ? await this.extractProvidersGraph(dbExecutor, getDetections) : [];
180
209
  // Source scan always runs to capture routes in languages/files not covered
181
210
  // by graph edges; the glob and per-file parse results are cached above.
182
- const providers = this.mergeGraphAndSourceContracts(graphProviders, this.extractProvidersSourceScan(await getScannedFiles(), getDetections));
211
+ const providers = this.mergeGraphAndSourceContracts(graphProviders, await this.extractProvidersSourceScan(await getScannedFiles(), getDetections));
183
212
  const graphConsumers = dbExecutor != null ? await this.extractConsumersGraph(dbExecutor, getDetections) : [];
184
- const consumers = this.mergeGraphAndSourceContracts(graphConsumers, this.extractConsumersSourceScan(await getScannedFiles(), getDetections));
213
+ const consumers = this.mergeGraphAndSourceContracts(graphConsumers, await this.extractConsumersSourceScan(await getScannedFiles(), getDetections));
185
214
  return [...providers, ...consumers];
186
215
  }
187
216
  async scanFiles(repoPath) {
@@ -219,7 +248,7 @@ export class HttpRouteExtractor {
219
248
  // helpers — tree-sitter gives both pieces of information
220
249
  // structurally. Always run the lookup: even when method is set by
221
250
  // `methodFromRouteReason`, we still need the handler name.
222
- const detections = filePath ? getDetections(filePath) : [];
251
+ const detections = filePath ? await getDetections(filePath) : [];
223
252
  const providerDetections = detections.filter((d) => d.role === 'provider');
224
253
  let handlerName = null;
225
254
  const normalizedRoute = normalizeHttpPath(routePath);
@@ -293,10 +322,10 @@ export class HttpRouteExtractor {
293
322
  return out;
294
323
  }
295
324
  // ─── Source-scan providers ─────────────────────────────────────────
296
- extractProvidersSourceScan(files, getDetections) {
325
+ async extractProvidersSourceScan(files, getDetections) {
297
326
  const out = [];
298
327
  for (const rel of files) {
299
- const detections = getDetections(rel);
328
+ const detections = await getDetections(rel);
300
329
  for (const d of detections) {
301
330
  if (d.role !== 'provider')
302
331
  continue;
@@ -338,7 +367,7 @@ export class HttpRouteExtractor {
338
367
  let method = 'GET';
339
368
  // Prefer the plugin's detected method if we can find a matching
340
369
  // fetch/axios call in the same file.
341
- const detections = filePath ? getDetections(filePath) : [];
370
+ const detections = filePath ? await getDetections(filePath) : [];
342
371
  // Symmetric to the provider path: if multiple consumer calls in
343
372
  // the same file share the same normalized path (e.g. a GET
344
373
  // fetch AND a POST fetch to `/api/orders`), `.find()` silently
@@ -388,10 +417,10 @@ export class HttpRouteExtractor {
388
417
  return out;
389
418
  }
390
419
  // ─── Source-scan consumers ─────────────────────────────────────────
391
- extractConsumersSourceScan(files, getDetections) {
420
+ async extractConsumersSourceScan(files, getDetections) {
392
421
  const out = [];
393
422
  for (const rel of files) {
394
- const detections = getDetections(rel);
423
+ const detections = await getDetections(rel);
395
424
  for (const d of detections) {
396
425
  if (d.role !== 'consumer')
397
426
  continue;
@@ -4,6 +4,7 @@ import { ASTCache } from './ast-cache.js';
4
4
  import type { ParsedFile } from '../../_shared/index.js';
5
5
  import { WorkerPool } from './workers/worker-pool.js';
6
6
  import type { ParseWorkerResult, ExtractedImport, ExtractedCall, ExtractedAssignment, ExtractedRoute, ExtractedFetchCall, ExtractedDecoratorRoute, ExtractedToolDef, FileConstructorBindings, FileScopeBindings, ExtractedORMQuery, FetchWrapperDef } from './workers/parse-worker.js';
7
+ import type { ExtractedRouterImport, ExtractedRouterInclude, ExtractedRouterModuleAlias } from './route-extractors/fastapi-router-bindings.js';
7
8
  export type FileProgressCallback = (current: number, total: number, filePath: string) => void;
8
9
  export interface WorkerExtractedData {
9
10
  imports: ExtractedImport[];
@@ -14,6 +15,9 @@ export interface WorkerExtractedData {
14
15
  fetchCalls: ExtractedFetchCall[];
15
16
  fetchWrapperDefs: FetchWrapperDef[];
16
17
  decoratorRoutes: ExtractedDecoratorRoute[];
18
+ routerIncludes: ExtractedRouterInclude[];
19
+ routerImports: ExtractedRouterImport[];
20
+ routerModuleAliases: ExtractedRouterModuleAlias[];
17
21
  toolDefs: ExtractedToolDef[];
18
22
  ormQueries: ExtractedORMQuery[];
19
23
  constructorBindings: FileConstructorBindings[];
@@ -37,6 +37,9 @@ export const mergeChunkResults = (graph, symbolTable, chunkResults) => {
37
37
  const allFetchCalls = [];
38
38
  const allFetchWrapperDefs = [];
39
39
  const allDecoratorRoutes = [];
40
+ const allRouterIncludes = [];
41
+ const allRouterImports = [];
42
+ const allRouterModuleAliases = [];
40
43
  const allToolDefs = [];
41
44
  const allORMQueries = [];
42
45
  const allConstructorBindings = [];
@@ -82,6 +85,12 @@ export const mergeChunkResults = (graph, symbolTable, chunkResults) => {
82
85
  allFetchWrapperDefs.push(item);
83
86
  for (const item of result.decoratorRoutes)
84
87
  allDecoratorRoutes.push(item);
88
+ for (const item of result.routerIncludes ?? [])
89
+ allRouterIncludes.push(item);
90
+ for (const item of result.routerImports ?? [])
91
+ allRouterImports.push(item);
92
+ for (const item of result.routerModuleAliases ?? [])
93
+ allRouterModuleAliases.push(item);
85
94
  for (const item of result.toolDefs)
86
95
  allToolDefs.push(item);
87
96
  if (result.ormQueries)
@@ -105,6 +114,9 @@ export const mergeChunkResults = (graph, symbolTable, chunkResults) => {
105
114
  fetchCalls: allFetchCalls,
106
115
  fetchWrapperDefs: allFetchWrapperDefs,
107
116
  decoratorRoutes: allDecoratorRoutes,
117
+ routerIncludes: allRouterIncludes,
118
+ routerImports: allRouterImports,
119
+ routerModuleAliases: allRouterModuleAliases,
108
120
  toolDefs: allToolDefs,
109
121
  ormQueries: allORMQueries,
110
122
  constructorBindings: allConstructorBindings,
@@ -138,6 +150,9 @@ outRawResults) => {
138
150
  fetchCalls: [],
139
151
  fetchWrapperDefs: [],
140
152
  decoratorRoutes: [],
153
+ routerIncludes: [],
154
+ routerImports: [],
155
+ routerModuleAliases: [],
141
156
  toolDefs: [],
142
157
  ormQueries: [],
143
158
  constructorBindings: [],
@@ -237,6 +237,9 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
237
237
  const allFetchWrapperDefs = [];
238
238
  const allExtractedRoutes = [];
239
239
  const allDecoratorRoutes = [];
240
+ const allRouterIncludes = [];
241
+ const allRouterImports = [];
242
+ const allRouterModuleAliases = [];
240
243
  const allToolDefs = [];
241
244
  const allORMQueries = [];
242
245
  const deferredWorkerCalls = [];
@@ -543,6 +546,18 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
543
546
  for (const item of chunkWorkerData.decoratorRoutes)
544
547
  allDecoratorRoutes.push(item);
545
548
  }
549
+ if (chunkWorkerData.routerIncludes?.length) {
550
+ for (const item of chunkWorkerData.routerIncludes)
551
+ allRouterIncludes.push(item);
552
+ }
553
+ if (chunkWorkerData.routerImports?.length) {
554
+ for (const item of chunkWorkerData.routerImports)
555
+ allRouterImports.push(item);
556
+ }
557
+ if (chunkWorkerData.routerModuleAliases?.length) {
558
+ for (const item of chunkWorkerData.routerModuleAliases)
559
+ allRouterModuleAliases.push(item);
560
+ }
546
561
  if (chunkWorkerData.toolDefs?.length) {
547
562
  for (const item of chunkWorkerData.toolDefs)
548
563
  allToolDefs.push(item);
@@ -878,6 +893,143 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
878
893
  importCtx.resolveCache.clear();
879
894
  importCtx.index = EMPTY_INDEX;
880
895
  importCtx.normalizedFileList = [];
896
+ // FastAPI router-prefix resolution (cross-file).
897
+ //
898
+ // Workers emit two kinds of records per Python file:
899
+ // • `routerIncludes` — every `app.include_router(<routerExpr>, prefix='/x')`
900
+ // site, where `routerExpr` is either `<module>.router` (Shape A) or a
901
+ // bare local name (Shape B).
902
+ // • `routerImports` — every `from <module> import router [as <alias>]`,
903
+ // mapping a local name to a module key (the basename of the source
904
+ // module). These let us resolve Shape-B router includes back to the
905
+ // module that defines the router.
906
+ //
907
+ // We build `module-basename → Set<prefix>` and then walk
908
+ // `allDecoratorRoutes`: any decorator route emitted from a `router.<verb>`
909
+ // decorator inherits its file-basename's prefix. When a router is mounted
910
+ // under multiple prefixes we duplicate the route entry, mirroring FastAPI's
911
+ // runtime behaviour.
912
+ if (allRouterIncludes.length > 0 && allDecoratorRoutes.length > 0) {
913
+ const importsByFile = new Map();
914
+ for (const imp of allRouterImports) {
915
+ let m = importsByFile.get(imp.filePath);
916
+ if (!m) {
917
+ m = new Map();
918
+ importsByFile.set(imp.filePath, m);
919
+ }
920
+ m.set(imp.localName, {
921
+ moduleKey: imp.moduleKey,
922
+ moduleKeyLong: imp.moduleKeyLong,
923
+ });
924
+ }
925
+ // Module-alias map keyed by file: `localName` (the imported module
926
+ // identifier in this file) → long key. Shape-A receivers like
927
+ // `users.router` are matched against this map; the long key, when
928
+ // present, scopes the prefix to the precise source file.
929
+ const moduleAliasesByFile = new Map();
930
+ for (const alias of allRouterModuleAliases) {
931
+ let m = moduleAliasesByFile.get(alias.filePath);
932
+ if (!m) {
933
+ m = new Map();
934
+ moduleAliasesByFile.set(alias.filePath, m);
935
+ }
936
+ m.set(alias.localName, alias.moduleKeyLong);
937
+ }
938
+ // Two parallel maps: long-key (precise) and short-key (basename
939
+ // fallback). Long-key entries are preferred when the file's own long
940
+ // key matches; short-key entries match any file with that basename and
941
+ // remain the fallback when no long key is known (e.g. Shape A includes
942
+ // without a corresponding import statement).
943
+ const prefixesByLongKey = new Map();
944
+ const prefixesByShortKey = new Map();
945
+ const recordPrefix = (target, key, prefix) => {
946
+ let set = target.get(key);
947
+ if (!set) {
948
+ set = new Set();
949
+ target.set(key, set);
950
+ }
951
+ set.add(prefix);
952
+ };
953
+ for (const inc of allRouterIncludes) {
954
+ // Shape A: `<module>.router`. The worker emits `routerExpr` already
955
+ // including `.router`, so split it back. We only know a short module
956
+ // key here — the call site doesn't carry the dotted package path. If
957
+ // the same file imports `<module>` via `from <pkg> import <module>`
958
+ // (recorded in `allRouterModuleAliases`) we promote to a long key.
959
+ const dotIdx = inc.routerExpr.indexOf('.router');
960
+ if (dotIdx > 0) {
961
+ const moduleShort = inc.routerExpr.slice(0, dotIdx);
962
+ const aliasLong = moduleAliasesByFile.get(inc.filePath)?.get(moduleShort);
963
+ if (aliasLong) {
964
+ recordPrefix(prefixesByLongKey, aliasLong, inc.prefix);
965
+ }
966
+ else {
967
+ recordPrefix(prefixesByShortKey, moduleShort, inc.prefix);
968
+ }
969
+ continue;
970
+ }
971
+ // Shape B: bare local name. Resolve through this file's imports. The
972
+ // import line gives us a long key whenever the module path was multi-
973
+ // segment, so cross-package collisions are eliminated for Shape B.
974
+ const localImp = importsByFile.get(inc.filePath)?.get(inc.routerExpr);
975
+ if (!localImp)
976
+ continue;
977
+ if (localImp.moduleKeyLong) {
978
+ recordPrefix(prefixesByLongKey, localImp.moduleKeyLong, inc.prefix);
979
+ }
980
+ else {
981
+ recordPrefix(prefixesByShortKey, localImp.moduleKey, inc.prefix);
982
+ }
983
+ }
984
+ if (prefixesByLongKey.size > 0 || prefixesByShortKey.size > 0) {
985
+ const fileLongKey = (rel) => {
986
+ // Strip `.py`, then take the last two path segments. `api/users.py`
987
+ // → `api/users`. Files at the repo root return the empty string,
988
+ // which can never match a long-key entry (those always include a
989
+ // parent directory) and so fall through to the short-key lookup.
990
+ const noExt = rel.endsWith('.py') ? rel.slice(0, -3) : rel;
991
+ const lastSlash = noExt.lastIndexOf('/');
992
+ if (lastSlash < 0)
993
+ return '';
994
+ const beforeLast = noExt.slice(0, lastSlash);
995
+ const stem = noExt.slice(lastSlash + 1);
996
+ const prevSlash = beforeLast.lastIndexOf('/');
997
+ const parent = prevSlash >= 0 ? beforeLast.slice(prevSlash + 1) : beforeLast;
998
+ return `${parent}/${stem}`;
999
+ };
1000
+ const fileShortKey = (rel) => {
1001
+ const slash = rel.lastIndexOf('/');
1002
+ const file = slash >= 0 ? rel.slice(slash + 1) : rel;
1003
+ return file.endsWith('.py') ? file.slice(0, -3) : file;
1004
+ };
1005
+ const expanded = [];
1006
+ for (const dr of allDecoratorRoutes) {
1007
+ if (dr.decoratorReceiver !== 'router' || !dr.filePath.endsWith('.py')) {
1008
+ expanded.push(dr);
1009
+ continue;
1010
+ }
1011
+ // Long-key lookup first; only fall back to the short key when no
1012
+ // long-key prefix targets this file. This avoids prefix leakage
1013
+ // between e.g. `api/users.py` and `admin/users.py`.
1014
+ const longKey = fileLongKey(dr.filePath);
1015
+ const longPrefixes = longKey ? prefixesByLongKey.get(longKey) : undefined;
1016
+ const shortPrefixes = longPrefixes
1017
+ ? undefined
1018
+ : prefixesByShortKey.get(fileShortKey(dr.filePath));
1019
+ const prefixes = longPrefixes ?? shortPrefixes;
1020
+ if (!prefixes || prefixes.size === 0) {
1021
+ expanded.push(dr);
1022
+ continue;
1023
+ }
1024
+ for (const prefix of prefixes) {
1025
+ expanded.push({ ...dr, prefix });
1026
+ }
1027
+ }
1028
+ allDecoratorRoutes.length = 0;
1029
+ for (const dr of expanded)
1030
+ allDecoratorRoutes.push(dr);
1031
+ }
1032
+ }
881
1033
  return {
882
1034
  exportedTypeMap,
883
1035
  allFetchCalls,
@@ -150,7 +150,6 @@ export const routesPhase = {
150
150
  }
151
151
  }
152
152
  }
153
- const ensureSlash = (path) => (path.startsWith('/') ? path : '/' + path);
154
153
  let duplicateRoutes = 0;
155
154
  const namedRouteRegistry = new Map();
156
155
  const addRoute = (url, entry) => {
@@ -173,7 +172,8 @@ export const routesPhase = {
173
172
  }
174
173
  }
175
174
  for (const dr of allDecoratorRoutes) {
176
- addRoute(ensureSlash(dr.routePath), {
175
+ const url = normalizeExtractedRoutePath(dr.routePath, dr.prefix ?? null);
176
+ addRoute(url, {
177
177
  filePath: dr.filePath,
178
178
  source: `decorator-${dr.decoratorName}`,
179
179
  });
@@ -0,0 +1,134 @@
1
+ /**
2
+ * FastAPI router-prefix detection — pure functions, no worker thread.
3
+ *
4
+ * NOT A WORKER. This module exports plain synchronous functions; it
5
+ * does not import `worker_threads`, does not call `parentPort`, and
6
+ * is not a new worker entry point. It lives next to the other route
7
+ * extractors (expo, nextjs, php, laravel) for that reason.
8
+ *
9
+ * The implementation was historically inlined in `workers/parse-worker.ts`,
10
+ * but parse-worker.ts is itself the worker entry point and cannot be
11
+ * loaded from the main thread (see the same constraint used by
12
+ * `test/unit/call-attribution-issue-1166.test.ts`). Splitting the pure
13
+ * extraction here lets unit tests import the function directly without
14
+ * booting a worker, satisfying DoD §2.7.
15
+ *
16
+ * Worker phase is per-file, so the heavy cross-file resolution lives in
17
+ * `pipeline-phases/parse-impl.ts`. Here we only extract two raw record
18
+ * kinds and let the pipeline aggregate them across files:
19
+ *
20
+ * • {@link ExtractedRouterInclude} — every
21
+ * `<host>.include_router(<routerExpr>, prefix='/x')` site, where
22
+ * `<routerExpr>` is either `<module>.router` (Shape A) or a bare
23
+ * local name (Shape B). `<host>` is intentionally unconstrained:
24
+ * production code uses `app`, `api`, `application`, `asgi_app`,
25
+ * etc., and the call shape (`include_router` invoked with a
26
+ * `prefix=` keyword) is specific enough on its own.
27
+ *
28
+ * • {@link ExtractedRouterImport} — every
29
+ * `from <module> import router [as <alias>]`, captured for both
30
+ * absolute and relative module paths (`from .calls import …`).
31
+ * parse-impl uses the imports to resolve Shape-B local names back
32
+ * to the file that declares the router.
33
+ *
34
+ * Module keying is two-tiered to avoid prefix bleed between same-named
35
+ * files in different packages (e.g. `api/users.py` vs `admin/users.py`):
36
+ *
37
+ * • short key — basename without `.py` (`users`)
38
+ * • long key — `<parent-dir>/<basename>` (`api/users`)
39
+ *
40
+ * Imports always carry the short key and, when the module path was
41
+ * multi-segment, also the long key. parse-impl matches against the
42
+ * long key first and falls back to the short key, so cross-package
43
+ * collisions are eliminated for Shape B and minimised for Shape A.
44
+ *
45
+ * The functions in this module are pure (no Worker / parentPort
46
+ * dependency) so they can be unit-tested directly without booting a
47
+ * worker thread.
48
+ */
49
+ /**
50
+ * One `<host>.include_router(<routerExpr>, prefix='/x')` site.
51
+ *
52
+ * `routerExpr` is the raw text of the first argument — either
53
+ * `<module>.router` (Shape A) or a bare local name (Shape B).
54
+ * parse-impl resolves Shape B against {@link ExtractedRouterImport}
55
+ * records emitted by the same file.
56
+ */
57
+ export interface ExtractedRouterInclude {
58
+ filePath: string;
59
+ routerExpr: string;
60
+ prefix: string;
61
+ lineNumber: number;
62
+ }
63
+ /**
64
+ * One `from <module> import router [as <alias>]` discovered in a
65
+ * Python file.
66
+ *
67
+ * `moduleKey` is the short key (last `.`-segment of the module path,
68
+ * e.g. `api.users` → `users`). `moduleKeyLong` is the long key (last
69
+ * two segments joined with `/`, e.g. `api/users`); it is the empty
70
+ * string / undefined when the import is single-segment (e.g.
71
+ * `from users import router`) or pure-dots (e.g. `from . import
72
+ * router`). The long key, when present, gives parse-impl a precise
73
+ * way to bind a Shape-B `include_router` call to exactly one Python
74
+ * file even when other packages contain a same-named module.
75
+ */
76
+ export interface ExtractedRouterImport {
77
+ filePath: string;
78
+ localName: string;
79
+ moduleKey: string;
80
+ moduleKeyLong?: string;
81
+ }
82
+ /**
83
+ * One `from <package> import <module>` discovered in a Python file
84
+ * where `<module>` is later used as a Shape-A include receiver
85
+ * (`<host>.include_router(<module>.router, prefix='/x')`). Without
86
+ * this record parse-impl would have to fall back to the short key
87
+ * `<module>`, which collides between e.g. `api/users.py` and
88
+ * `admin/users.py`. The record carries the long key
89
+ * (`<package>/<module>`) so parse-impl can pin the prefix onto the
90
+ * exact source file.
91
+ *
92
+ * Only emitted when the import path was multi-segment (a single
93
+ * `from users import users` would yield no long key). All fields
94
+ * carry the same module-key semantics as
95
+ * {@link ExtractedRouterImport}.
96
+ */
97
+ export interface ExtractedRouterModuleAlias {
98
+ filePath: string;
99
+ /** Local name in the importing file (== imported name or its alias). */
100
+ localName: string;
101
+ /** Long key (`<parent>/<stem>`) — non-empty for every emitted record. */
102
+ moduleKeyLong: string;
103
+ }
104
+ /**
105
+ * Last `.`-separated segment of a (possibly relative) Python module
106
+ * path. Strips any leading dots first so `from .api.assistant import
107
+ * …` and `from api.assistant import …` both yield `assistant`.
108
+ * Pure-dot inputs (`.`, `..`) have no segment and return the empty
109
+ * string; callers should skip empty results.
110
+ */
111
+ export declare function lastDottedSegment(text: string): string;
112
+ /**
113
+ * Last two `.`-separated segments of a (possibly relative) module
114
+ * path joined with `/`, e.g. `api.users` → `api/users`. Mirrors the
115
+ * long-key shape used for files (`api/users.py` → `api/users`).
116
+ * Returns the empty string when no parent segment is available
117
+ * (single-segment imports or pure dots); callers should fall back
118
+ * to the short key in that case.
119
+ */
120
+ export declare function lastTwoSegmentsAsPath(text: string): string;
121
+ /**
122
+ * Scan a single Python file's source text for FastAPI router
123
+ * `include_router` sites and `from <module> import router` imports,
124
+ * appending raw records to the supplied collectors.
125
+ *
126
+ * `outModuleAliases` is optional: when supplied, every multi-segment
127
+ * `from <pkg> import <name>` (other than `router` itself) is recorded
128
+ * as a module alias so parse-impl can pin Shape-A
129
+ * `<name>.include_router(...)` calls onto the exact module file. When
130
+ * omitted, the function preserves the pre-existing behaviour and
131
+ * skips the alias collection — this keeps the function signature
132
+ * back-compat with older callers (and the parse-cache replay path).
133
+ */
134
+ export declare function extractFastAPIRouterBindings(filePath: string, content: string, outIncludes: ExtractedRouterInclude[], outImports: ExtractedRouterImport[], outModuleAliases?: ExtractedRouterModuleAlias[]): void;
@@ -0,0 +1,208 @@
1
+ /**
2
+ * FastAPI router-prefix detection — pure functions, no worker thread.
3
+ *
4
+ * NOT A WORKER. This module exports plain synchronous functions; it
5
+ * does not import `worker_threads`, does not call `parentPort`, and
6
+ * is not a new worker entry point. It lives next to the other route
7
+ * extractors (expo, nextjs, php, laravel) for that reason.
8
+ *
9
+ * The implementation was historically inlined in `workers/parse-worker.ts`,
10
+ * but parse-worker.ts is itself the worker entry point and cannot be
11
+ * loaded from the main thread (see the same constraint used by
12
+ * `test/unit/call-attribution-issue-1166.test.ts`). Splitting the pure
13
+ * extraction here lets unit tests import the function directly without
14
+ * booting a worker, satisfying DoD §2.7.
15
+ *
16
+ * Worker phase is per-file, so the heavy cross-file resolution lives in
17
+ * `pipeline-phases/parse-impl.ts`. Here we only extract two raw record
18
+ * kinds and let the pipeline aggregate them across files:
19
+ *
20
+ * • {@link ExtractedRouterInclude} — every
21
+ * `<host>.include_router(<routerExpr>, prefix='/x')` site, where
22
+ * `<routerExpr>` is either `<module>.router` (Shape A) or a bare
23
+ * local name (Shape B). `<host>` is intentionally unconstrained:
24
+ * production code uses `app`, `api`, `application`, `asgi_app`,
25
+ * etc., and the call shape (`include_router` invoked with a
26
+ * `prefix=` keyword) is specific enough on its own.
27
+ *
28
+ * • {@link ExtractedRouterImport} — every
29
+ * `from <module> import router [as <alias>]`, captured for both
30
+ * absolute and relative module paths (`from .calls import …`).
31
+ * parse-impl uses the imports to resolve Shape-B local names back
32
+ * to the file that declares the router.
33
+ *
34
+ * Module keying is two-tiered to avoid prefix bleed between same-named
35
+ * files in different packages (e.g. `api/users.py` vs `admin/users.py`):
36
+ *
37
+ * • short key — basename without `.py` (`users`)
38
+ * • long key — `<parent-dir>/<basename>` (`api/users`)
39
+ *
40
+ * Imports always carry the short key and, when the module path was
41
+ * multi-segment, also the long key. parse-impl matches against the
42
+ * long key first and falls back to the short key, so cross-package
43
+ * collisions are eliminated for Shape B and minimised for Shape A.
44
+ *
45
+ * The functions in this module are pure (no Worker / parentPort
46
+ * dependency) so they can be unit-tested directly without booting a
47
+ * worker thread.
48
+ */
49
+ // `<host>.include_router(<module>.router, ..., prefix='/x')` (Shape A).
50
+ // `<host>` is left unrestricted — common production names include
51
+ // `app`, `api`, `application`, `asgi_app`. Pinning to the literal
52
+ // `app` would silently drop these.
53
+ const INCLUDE_ROUTER_ATTR_RE = /\b(?:[A-Za-z_][\w.]*)\.include_router\s*\(\s*([A-Za-z_][\w]*)\.router\b[^)]*?\bprefix\s*=\s*(['"])([^'"]*)\2/g;
54
+ // `<host>.include_router(<local_name>, ..., prefix='/x')` (Shape B).
55
+ const INCLUDE_ROUTER_NAME_RE = /\b(?:[A-Za-z_][\w.]*)\.include_router\s*\(\s*([A-Za-z_][\w]*)\b[^)]*?\bprefix\s*=\s*(['"])([^'"]*)\2/g;
56
+ // Module path: a sequence of dots (`.`, `..`, `...`) for "current
57
+ // package" imports, OR an optional leading-dot prefix followed by a
58
+ // dotted identifier (`api.users`, `.api.users`, `..siblings.users`).
59
+ // The latter is the common case and the only one we can map back to
60
+ // a module stem.
61
+ const FROM_IMPORT_ROUTER_RE = /^\s*from\s+(\.+|\.*[A-Za-z_][\w.]*)\s+import\s+([^#\n]+)/gm;
62
+ /**
63
+ * Last `.`-separated segment of a (possibly relative) Python module
64
+ * path. Strips any leading dots first so `from .api.assistant import
65
+ * …` and `from api.assistant import …` both yield `assistant`.
66
+ * Pure-dot inputs (`.`, `..`) have no segment and return the empty
67
+ * string; callers should skip empty results.
68
+ */
69
+ export function lastDottedSegment(text) {
70
+ const stripped = text.replace(/^\.+/, '');
71
+ if (!stripped)
72
+ return '';
73
+ const dot = stripped.lastIndexOf('.');
74
+ return dot >= 0 ? stripped.slice(dot + 1) : stripped;
75
+ }
76
+ /**
77
+ * Last two `.`-separated segments of a (possibly relative) module
78
+ * path joined with `/`, e.g. `api.users` → `api/users`. Mirrors the
79
+ * long-key shape used for files (`api/users.py` → `api/users`).
80
+ * Returns the empty string when no parent segment is available
81
+ * (single-segment imports or pure dots); callers should fall back
82
+ * to the short key in that case.
83
+ */
84
+ export function lastTwoSegmentsAsPath(text) {
85
+ const stripped = text.replace(/^\.+/, '');
86
+ if (!stripped)
87
+ return '';
88
+ const last = stripped.lastIndexOf('.');
89
+ if (last <= 0)
90
+ return '';
91
+ const beforeLast = stripped.slice(0, last);
92
+ const stem = stripped.slice(last + 1);
93
+ const prev = beforeLast.lastIndexOf('.');
94
+ const parent = prev >= 0 ? beforeLast.slice(prev + 1) : beforeLast;
95
+ return `${parent}/${stem}`;
96
+ }
97
+ /**
98
+ * Scan a single Python file's source text for FastAPI router
99
+ * `include_router` sites and `from <module> import router` imports,
100
+ * appending raw records to the supplied collectors.
101
+ *
102
+ * `outModuleAliases` is optional: when supplied, every multi-segment
103
+ * `from <pkg> import <name>` (other than `router` itself) is recorded
104
+ * as a module alias so parse-impl can pin Shape-A
105
+ * `<name>.include_router(...)` calls onto the exact module file. When
106
+ * omitted, the function preserves the pre-existing behaviour and
107
+ * skips the alias collection — this keeps the function signature
108
+ * back-compat with older callers (and the parse-cache replay path).
109
+ */
110
+ export function extractFastAPIRouterBindings(filePath, content, outIncludes, outImports, outModuleAliases) {
111
+ if (!content.includes('include_router') && !content.includes('router'))
112
+ return;
113
+ // `from <module> import router [as <alias>]`. We capture every name
114
+ // in the import list. `router` (with or without an `as` alias) maps
115
+ // to outImports; every other name lands in outModuleAliases when a
116
+ // long key is available, so Shape-A `<name>.router` includes can be
117
+ // pinned to the exact module file.
118
+ if (content.includes(' import ')) {
119
+ FROM_IMPORT_ROUTER_RE.lastIndex = 0;
120
+ let m;
121
+ while ((m = FROM_IMPORT_ROUTER_RE.exec(content)) !== null) {
122
+ const moduleText = m[1];
123
+ const importList = m[2];
124
+ const moduleShort = lastDottedSegment(moduleText);
125
+ if (!moduleShort)
126
+ continue;
127
+ // Long key for the imported MODULE itself (used by router
128
+ // imports — `from api.users import router` sets
129
+ // `moduleKeyLong = api/users`).
130
+ const moduleLong = lastTwoSegmentsAsPath(moduleText);
131
+ // Strip surrounding parens / trailing whitespace; split on
132
+ // commas. (Multiline import groups already have their newlines
133
+ // present in the captured list.)
134
+ const cleaned = importList.replace(/[()]/g, '').trim();
135
+ for (const rawPart of cleaned.split(',')) {
136
+ const part = rawPart.trim();
137
+ if (!part)
138
+ continue;
139
+ // `router` or `router as foo` → ExtractedRouterImport.
140
+ const routerAlias = /^router(?:\s+as\s+([A-Za-z_]\w*))?$/.exec(part);
141
+ if (routerAlias) {
142
+ const localName = routerAlias[1] ?? 'router';
143
+ outImports.push({
144
+ filePath,
145
+ localName,
146
+ moduleKey: moduleShort,
147
+ ...(moduleLong ? { moduleKeyLong: moduleLong } : {}),
148
+ });
149
+ continue;
150
+ }
151
+ // Any other `<name>` or `<name> as <alias>` — recorded as a
152
+ // module alias so parse-impl can pin Shape-A includes. The
153
+ // long key here is computed against the IMPORTED MODULE PATH
154
+ // (`<moduleText>.<name>`), not the package path that `<name>`
155
+ // was imported FROM. `from api import users` therefore yields
156
+ // `api/users`, the same long key as the file it points at.
157
+ if (!outModuleAliases)
158
+ continue;
159
+ const otherAlias = /^([A-Za-z_]\w*)(?:\s+as\s+([A-Za-z_]\w*))?$/.exec(part);
160
+ if (!otherAlias)
161
+ continue;
162
+ const importedName = otherAlias[1];
163
+ const localName = otherAlias[2] ?? importedName;
164
+ const aliasLong = lastTwoSegmentsAsPath(`${moduleText}.${importedName}`);
165
+ if (!aliasLong)
166
+ continue;
167
+ outModuleAliases.push({
168
+ filePath,
169
+ localName,
170
+ moduleKeyLong: aliasLong,
171
+ });
172
+ }
173
+ }
174
+ }
175
+ if (!content.includes('include_router'))
176
+ return;
177
+ // Shape A: `<host>.include_router(<module>.router, prefix='/x')`.
178
+ INCLUDE_ROUTER_ATTR_RE.lastIndex = 0;
179
+ let m;
180
+ while ((m = INCLUDE_ROUTER_ATTR_RE.exec(content)) !== null) {
181
+ outIncludes.push({
182
+ filePath,
183
+ routerExpr: `${m[1]}.router`,
184
+ prefix: m[3],
185
+ lineNumber: content.substring(0, m.index).split('\n').length,
186
+ });
187
+ }
188
+ // Shape B: `<host>.include_router(my_router, prefix='/x')`.
189
+ // Resolution to a module key happens in parse-impl using
190
+ // outImports from the same file.
191
+ INCLUDE_ROUTER_NAME_RE.lastIndex = 0;
192
+ while ((m = INCLUDE_ROUTER_NAME_RE.exec(content)) !== null) {
193
+ // Skip cases that already matched Shape A — INCLUDE_ROUTER_NAME_RE
194
+ // is intentionally permissive and would re-capture `<mod>.router`
195
+ // as the bare name `mod`. Discriminate by re-checking the
196
+ // immediate source around the captured argument position.
197
+ const argStart = m.index + m[0].indexOf(m[1]);
198
+ const dotProbe = content.slice(argStart + m[1].length, argStart + m[1].length + 8);
199
+ if (/^\s*\.\s*router/.test(dotProbe))
200
+ continue;
201
+ outIncludes.push({
202
+ filePath,
203
+ routerExpr: m[1],
204
+ prefix: m[3],
205
+ lineNumber: content.substring(0, m.index).split('\n').length,
206
+ });
207
+ }
208
+ }
@@ -1,5 +1,6 @@
1
1
  import { SupportedLanguages } from '../../../_shared/index.js';
2
2
  import type { ExtractedHeritage } from '../model/heritage-map.js';
3
+ import type { ExtractedRouterInclude, ExtractedRouterImport, ExtractedRouterModuleAlias } from '../route-extractors/fastapi-router-bindings.js';
3
4
  import { type MixedChainStep } from '../utils/call-analysis.js';
4
5
  import type { ConstructorBinding } from '../type-env.js';
5
6
  import type { NamedBinding } from '../named-bindings/types.js';
@@ -111,6 +112,19 @@ export interface ExtractedDecoratorRoute {
111
112
  httpMethod: string;
112
113
  decoratorName: string;
113
114
  lineNumber: number;
115
+ /**
116
+ * Decorator receiver identifier (e.g. `router` for `@router.get(...)`,
117
+ * `app` for `@app.get(...)`). Used by parse-impl to decide which routes
118
+ * participate in `include_router(prefix=...)` joining.
119
+ */
120
+ decoratorReceiver?: string;
121
+ /**
122
+ * FastAPI `app.include_router(prefix='/x')` prefix that applies to
123
+ * this route. Filled by parse-impl after cross-file aggregation; the
124
+ * routes phase joins it via `normalizeExtractedRoutePath`. `null` /
125
+ * absent ⇒ no prefix applies.
126
+ */
127
+ prefix?: string | null;
114
128
  }
115
129
  export interface ExtractedToolDef {
116
130
  filePath: string;
@@ -172,6 +186,18 @@ export interface ParseWorkerResult {
172
186
  fetchCalls: ExtractedFetchCall[];
173
187
  fetchWrapperDefs: FetchWrapperDef[];
174
188
  decoratorRoutes: ExtractedDecoratorRoute[];
189
+ routerIncludes: ExtractedRouterInclude[];
190
+ routerImports: ExtractedRouterImport[];
191
+ /**
192
+ * Optional. `from <pkg> import <module>` records from Python files
193
+ * where `<module>` is later used as a Shape-A include receiver
194
+ * (`<host>.include_router(<module>.router, prefix='/x')`). parse-impl
195
+ * uses these to promote Shape-A short-key entries to long keys, so
196
+ * same-named modules in different packages don't share prefixes.
197
+ * Optional for cache backward compatibility (older cache entries
198
+ * predate the field; consumers must guard with `if (… ?? [])`).
199
+ */
200
+ routerModuleAliases?: ExtractedRouterModuleAlias[];
175
201
  toolDefs: ExtractedToolDef[];
176
202
  ormQueries: ExtractedORMQuery[];
177
203
  constructorBindings: FileConstructorBindings[];
@@ -434,6 +434,9 @@ const processBatch = (files, onProgress) => {
434
434
  fetchCalls: [],
435
435
  fetchWrapperDefs: [],
436
436
  decoratorRoutes: [],
437
+ routerIncludes: [],
438
+ routerImports: [],
439
+ routerModuleAliases: [],
437
440
  toolDefs: [],
438
441
  ormQueries: [],
439
442
  constructorBindings: [],
@@ -649,6 +652,16 @@ export function extractORMQueries(filePath, content, out) {
649
652
  }
650
653
  }
651
654
  }
655
+ // ============================================================================
656
+ // FastAPI router prefix detection (Python)
657
+ // ============================================================================
658
+ //
659
+ // The extraction lives in `../route-extractors/fastapi-router-bindings`
660
+ // (a pure-function module — NOT a worker, no `worker_threads`, no
661
+ // `parentPort`). It's imported here only so the worker entry can call it
662
+ // per file; this module does not re-export it. Downstream consumers
663
+ // import the function and its types directly from `route-extractors/`.
664
+ import { extractFastAPIRouterBindings } from '../route-extractors/fastapi-router-bindings.js';
652
665
  const processFileGroup = (files, language, queryString, result, onFileProcessed) => {
653
666
  let query;
654
667
  try {
@@ -849,6 +862,7 @@ const processFileGroup = (files, language, queryString, result, onFileProcessed)
849
862
  if (captureMap['decorator'] && captureMap['decorator.name']) {
850
863
  const decoratorName = captureMap['decorator.name'].text;
851
864
  const decoratorArg = captureMap['decorator.arg']?.text;
865
+ const decoratorReceiver = captureMap['decorator.receiver']?.text;
852
866
  const decoratorNode = captureMap['decorator'];
853
867
  // Store by the decorator's end line — the definition follows immediately after
854
868
  fileDecorators.set(decoratorNode.endPosition.row, {
@@ -867,6 +881,7 @@ const processFileGroup = (files, language, queryString, result, onFileProcessed)
867
881
  httpMethod,
868
882
  decoratorName,
869
883
  lineNumber: decoratorNode.startPosition.row + lineOffset,
884
+ ...(decoratorReceiver ? { decoratorReceiver } : {}),
870
885
  });
871
886
  }
872
887
  // MCP/RPC tool detection: @mcp.tool(), @app.tool(), @server.tool()
@@ -1546,6 +1561,13 @@ const processFileGroup = (files, language, queryString, result, onFileProcessed)
1546
1561
  }
1547
1562
  // Extract ORM queries (Prisma, Supabase)
1548
1563
  extractORMQueries(file.path, parseContent, result.ormQueries);
1564
+ // Extract FastAPI include_router(prefix=...) and `from <mod> import router`
1565
+ // sites. parse-impl aggregates these into a per-module prefix map and
1566
+ // injects the resolved prefix onto each ExtractedDecoratorRoute that
1567
+ // came from a `@router.<verb>` decorator. Python-only.
1568
+ if (language === SupportedLanguages.Python) {
1569
+ extractFastAPIRouterBindings(file.path, parseContent, result.routerIncludes, result.routerImports, (result.routerModuleAliases ??= []));
1570
+ }
1549
1571
  // Vue: emit CALLS edges for components used in <template>
1550
1572
  if (language === SupportedLanguages.Vue) {
1551
1573
  const templateComponents = extractTemplateComponents(file.content);
@@ -1576,6 +1598,9 @@ let accumulated = {
1576
1598
  fetchCalls: [],
1577
1599
  fetchWrapperDefs: [],
1578
1600
  decoratorRoutes: [],
1601
+ routerIncludes: [],
1602
+ routerImports: [],
1603
+ routerModuleAliases: [],
1579
1604
  toolDefs: [],
1580
1605
  ormQueries: [],
1581
1606
  constructorBindings: [],
@@ -1604,6 +1629,14 @@ const mergeResult = (target, src) => {
1604
1629
  appendAll(target.fetchCalls, src.fetchCalls);
1605
1630
  appendAll(target.fetchWrapperDefs, src.fetchWrapperDefs);
1606
1631
  appendAll(target.decoratorRoutes, src.decoratorRoutes);
1632
+ if (src.routerIncludes)
1633
+ appendAll(target.routerIncludes, src.routerIncludes);
1634
+ if (src.routerImports)
1635
+ appendAll(target.routerImports, src.routerImports);
1636
+ if (src.routerModuleAliases) {
1637
+ target.routerModuleAliases ??= [];
1638
+ appendAll(target.routerModuleAliases, src.routerModuleAliases);
1639
+ }
1607
1640
  appendAll(target.toolDefs, src.toolDefs);
1608
1641
  appendAll(target.ormQueries, src.ormQueries);
1609
1642
  appendAll(target.constructorBindings, src.constructorBindings);
@@ -1687,6 +1720,9 @@ parentPort.on('message', (msg) => {
1687
1720
  fetchCalls: [],
1688
1721
  fetchWrapperDefs: [],
1689
1722
  decoratorRoutes: [],
1723
+ routerIncludes: [],
1724
+ routerImports: [],
1725
+ routerModuleAliases: [],
1690
1726
  toolDefs: [],
1691
1727
  ormQueries: [],
1692
1728
  constructorBindings: [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.6-rc.81",
3
+ "version": "1.6.6-rc.82",
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",