gitnexus 1.6.13-rc.56 → 1.6.13-rc.57

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.
@@ -1,6 +1,8 @@
1
1
  import Python from 'tree-sitter-python';
2
2
  import { compilePatterns, runCompiledPatterns, unquoteLiteral, } from '../tree-sitter-scanner.js';
3
3
  import { normalizeExtractedRoutePath } from '../../../ingestion/route-extractors/route-path.js';
4
+ import { extractFastAPIRouterBindings, } from '../../../ingestion/route-extractors/fastapi-router-bindings.js';
5
+ import { mergeMountPrefixes, resolveFastAPIRouterPrefixes, } from '../../../ingestion/route-extractors/fastapi-router-prefixes.js';
4
6
  import { extractPythonModuleConstants, parseConstOperands, resolveOperands, } from '../../../ingestion/route-extractors/python-const-resolver.js';
5
7
  /**
6
8
  * Python HTTP plugin. Handles:
@@ -883,6 +885,10 @@ const NONLITERAL_ROUTE_DECORATOR_RE = /@\s*(?:app|router)\s*\.\s*(?:get|post|put
883
885
  function buildPythonRepoContext(files, parser, readFile, parseSource) {
884
886
  const prefixesByLongKey = new Map();
885
887
  const prefixesByShortKey = new Map();
888
+ const routerIncludes = [];
889
+ const routerImports = [];
890
+ const routerModuleAliases = [];
891
+ const routerConstructorPrefixes = [];
886
892
  // Single read pass (#2393): slurp every `.py` file's content ONCE. This used to
887
893
  // be two passes — the include_router pre-pass below and the #2391 constant cost
888
894
  // gate each re-read every `.py` file. The composed-route cost gate is computed
@@ -900,6 +906,15 @@ function buildPythonRepoContext(files, parser, readFile, parseSource) {
900
906
  if (!hasComposedRoute && NONLITERAL_ROUTE_DECORATOR_RE.test(src))
901
907
  hasComposedRoute = true;
902
908
  }
909
+ for (const [rel, src] of pyContents) {
910
+ if (src.includes('include_router')) {
911
+ extractFastAPIRouterBindings(rel, src, routerIncludes, routerImports, routerModuleAliases, routerConstructorPrefixes);
912
+ }
913
+ }
914
+ // Resolve against every repo path, empty files included, so module
915
+ // ambiguity matches ingestion (which passes all scanned paths).
916
+ const { prefixesByFile, resolvedIncludes } = resolveFastAPIRouterPrefixes(files, routerIncludes, routerImports, routerModuleAliases, routerConstructorPrefixes);
917
+ const resolvedIncludeKeys = new Set([...resolvedIncludes].map((inc) => JSON.stringify([inc.filePath, inc.routerExpr, inc.prefix])));
903
918
  // Single PARSE pass (#2391): parse each `.py` at most once and feed BOTH the
904
919
  // include_router prefix pre-pass and the composed-constant map below. This used
905
920
  // to be two loops, so an include_router file in a composed repo was parsed
@@ -968,6 +983,8 @@ function buildPythonRepoContext(files, parser, readFile, parseSource) {
968
983
  if (prefix === null)
969
984
  continue;
970
985
  const moduleShort = modNode.text;
986
+ if (resolvedIncludeKeys.has(JSON.stringify([rel, `${moduleShort}.router`, prefix])))
987
+ continue;
971
988
  const aliasLong = localNameToModuleAlias.get(moduleShort);
972
989
  const sameFileImport = localNameToModule.get(moduleShort);
973
990
  const longKey = aliasLong ?? sameFileImport?.moduleLong;
@@ -993,6 +1010,8 @@ function buildPythonRepoContext(files, parser, readFile, parseSource) {
993
1010
  const prefix = unquoteLiteral(prefixNode.text);
994
1011
  if (prefix === null)
995
1012
  continue;
1013
+ if (resolvedIncludeKeys.has(JSON.stringify([rel, nameNode.text, prefix])))
1014
+ continue;
996
1015
  if (localImp.moduleLong) {
997
1016
  recordPrefix(prefixesByLongKey, localImp.moduleLong, prefix);
998
1017
  }
@@ -1013,6 +1032,7 @@ function buildPythonRepoContext(files, parser, readFile, parseSource) {
1013
1032
  }
1014
1033
  }
1015
1034
  return {
1035
+ prefixesByFile,
1016
1036
  prefixesByLongKey,
1017
1037
  prefixesByShortKey,
1018
1038
  constantsByFile,
@@ -1145,14 +1165,13 @@ export const PYTHON_HTTP_PLUGIN = {
1145
1165
  // by the literal and the #2391 non-literal (resolved) router loops so both
1146
1166
  // stack prefixes identically.
1147
1167
  const emitRouterProvider = (httpMethod, rawPath, line) => {
1148
- // Long key first (precise, package-aware), short key as fallback.
1149
- // Mirrors the ingestion-side resolution in parse-impl.ts so the
1150
- // graph nodes and group contracts agree on which prefix applies.
1168
+ // Exact file matches plus the legacy long/short fallback. This mirrors
1169
+ // ingestion so graph Route nodes and contracts agree on mounted paths.
1151
1170
  const longKey = fileRel ? fileLongKey(fileRel) : '';
1152
1171
  const longPrefixes = longKey ? ctx?.prefixesByLongKey.get(longKey) : undefined;
1153
1172
  const shortKey = fileRel ? fileShortKey(fileRel) : '';
1154
1173
  const shortPrefixes = longPrefixes || !shortKey ? undefined : ctx?.prefixesByShortKey.get(shortKey);
1155
- const prefixSet = longPrefixes ?? shortPrefixes;
1174
+ const prefixSet = mergeMountPrefixes(fileRel ? ctx?.prefixesByFile.get(fileRel.replace(/\\/g, '/')) : undefined, longPrefixes ?? shortPrefixes);
1156
1175
  // Stack the same-file APIRouter(prefix=...) under any cross-file
1157
1176
  // include_router prefix.
1158
1177
  const localPath = constructorPrefix ? joinPrefix(constructorPrefix, rawPath) : rawPath;
@@ -30,6 +30,7 @@ import { classifyContentLanguages } from '../content-language-classification.js'
30
30
  import { SCOPE_RESOLVERS } from '../scope-resolution/pipeline/registry.js';
31
31
  import { DATA_ROUTE_TABLE_SOURCE } from '../route-extractors/data-route-table.js';
32
32
  import { createWorkerPool, workerPoolDisabledByEnv, resolveAutoPoolSize, envWorkerPoolSize, resolveHostParallelism, WorkerPoolInitializationError, WorkerPoolDisabledError, } from '../workers/worker-pool.js';
33
+ import { mergeMountPrefixes, resolveFastAPIRouterPrefixes, } from '../route-extractors/fastapi-router-prefixes.js';
33
34
  import { normalizeExtractedRoutePath } from '../route-extractors/route-path.js';
34
35
  import { resolveOperands } from '../route-extractors/python-const-resolver.js';
35
36
  import { prepareRouteConstantsByProvider } from '../language-provider.js';
@@ -1326,11 +1327,9 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
1326
1327
  }
1327
1328
  m.set(alias.localName, alias.moduleKeyLong);
1328
1329
  }
1329
- // Two parallel maps: long-key (precise) and short-key (basename
1330
- // fallback). Long-key entries are preferred when the file's own long
1331
- // key matches; short-key entries match any file with that basename and
1332
- // remain the fallback when no long key is known (e.g. Shape A includes
1333
- // without a corresponding import statement).
1330
+ // Exact-file matches handle import-resolved mounts (including nested
1331
+ // routers). These long/short maps preserve the older fallback for mounts
1332
+ // whose import cannot be resolved to one file.
1334
1333
  const prefixesByLongKey = new Map();
1335
1334
  const prefixesByShortKey = new Map();
1336
1335
  // Constructor prefixes are `router`-only (the apply gate below and the
@@ -1338,6 +1337,7 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
1338
1337
  // flat file-key → prefix map suffices — mirrors the group layer's shape.
1339
1338
  const constructorPrefixesByLongKey = new Map();
1340
1339
  const constructorPrefixesByShortKey = new Map();
1340
+ const { prefixesByFile, resolvedIncludes } = resolveFastAPIRouterPrefixes(allPaths, allRouterIncludes, allRouterImports, allRouterModuleAliases, allRouterConstructorPrefixes);
1341
1341
  const recordPrefix = (target, key, prefix) => {
1342
1342
  let set = target.get(key);
1343
1343
  if (!set) {
@@ -1347,6 +1347,10 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
1347
1347
  set.add(prefix);
1348
1348
  };
1349
1349
  for (const inc of allRouterIncludes) {
1350
+ // Unprefixed includes only exist as propagation edges; recording `''`
1351
+ // here would shadow a real short-key prefix for the same module.
1352
+ if (resolvedIncludes.has(inc) || !inc.prefix)
1353
+ continue;
1350
1354
  // Shape A: `<module>.router`. The worker emits `routerExpr` already
1351
1355
  // including `.router`, so split it back. We only know a short module
1352
1356
  // key here — the call site doesn't carry the dotted package path. If
@@ -1377,7 +1381,8 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
1377
1381
  recordPrefix(prefixesByShortKey, localImp.moduleKey, inc.prefix);
1378
1382
  }
1379
1383
  }
1380
- if (prefixesByLongKey.size > 0 ||
1384
+ if (prefixesByFile.size > 0 ||
1385
+ prefixesByLongKey.size > 0 ||
1381
1386
  prefixesByShortKey.size > 0 ||
1382
1387
  allRouterConstructorPrefixes.length > 0) {
1383
1388
  const fileLongKey = (rel) => {
@@ -1415,15 +1420,14 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
1415
1420
  expanded.push(dr);
1416
1421
  continue;
1417
1422
  }
1418
- // Long-key lookup first; only fall back to the short key when no
1419
- // long-key prefix targets this file. This avoids prefix leakage
1420
- // between e.g. `api/users.py` and `admin/users.py`.
1423
+ // Exact file matches plus the legacy long/short fallback for mounts
1424
+ // whose import could not be resolved.
1421
1425
  const longKey = fileLongKey(dr.filePath);
1422
1426
  const longPrefixes = longKey ? prefixesByLongKey.get(longKey) : undefined;
1423
1427
  const shortPrefixes = longPrefixes
1424
1428
  ? undefined
1425
1429
  : prefixesByShortKey.get(fileShortKey(dr.filePath));
1426
- const prefixes = longPrefixes ?? shortPrefixes;
1430
+ const prefixes = mergeMountPrefixes(prefixesByFile.get(dr.filePath.replace(/\\/g, '/')), longPrefixes ?? shortPrefixes);
1427
1431
  // Constructor prefixes are keyed like include_router prefixes:
1428
1432
  // long-key entries are precise, while short-key entries are only
1429
1433
  // valid for repo-root/single-segment files where `fileLongKey`
@@ -13,17 +13,19 @@
13
13
  * extraction here lets unit tests import the function directly without
14
14
  * booting a worker, satisfying DoD §2.7.
15
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
16
+ * Worker phase is per-file, so the cross-file resolution lives in
17
+ * `fastapi-router-prefixes.ts` and `pipeline-phases/parse-impl.ts`. Here we extract raw record
18
18
  * kinds and let the pipeline aggregate them across files:
19
19
  *
20
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.
21
+ * `<host>.include_router(<routerExpr>, prefix='/x')` mount, plus
22
+ * unprefixed `<host>.include_router(<routerExpr>[, <other kwargs>])`
23
+ * calls recorded with `prefix: ''`. `<routerExpr>` is either
24
+ * `<module>.router` (Shape A) or a bare local name (Shape B). The
25
+ * extractor leaves `<host>` unconstrained (production code uses `app`,
26
+ * `api`, `application`, `asgi_app`, …); unprefixed records are only
27
+ * propagation edges, and `fastapi-router-prefixes.ts` passes a parent
28
+ * prefix through them only when the host is `router`.
27
29
  *
28
30
  * • {@link ExtractedRouterImport} — every
29
31
  * `from <module> import router [as <alias>]`, captured for both
@@ -37,25 +39,25 @@
37
39
  * • short key — basename without `.py` (`users`)
38
40
  * • long key — `<parent-dir>/<basename>` (`api/users`)
39
41
  *
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.
42
+ * Imports retain their module path for exact-file resolution. The short and
43
+ * long keys remain for the conservative fallback when that resolution fails.
44
44
  *
45
45
  * The functions in this module are pure (no Worker / parentPort
46
46
  * dependency) so they can be unit-tested directly without booting a
47
47
  * worker thread.
48
48
  */
49
49
  /**
50
- * One `<host>.include_router(<routerExpr>, prefix='/x')` site.
50
+ * One `<host>.include_router(<routerExpr>, prefix='/x')` site, or a
51
+ * simple child include without a local prefix.
51
52
  *
52
53
  * `routerExpr` is the raw text of the first argument — either
53
54
  * `<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.
55
+ * Cross-file resolution uses {@link ExtractedRouterImport} records
56
+ * emitted by the same file.
56
57
  */
57
58
  export interface ExtractedRouterInclude {
58
59
  filePath: string;
60
+ host: string;
59
61
  routerExpr: string;
60
62
  prefix: string;
61
63
  lineNumber: number;
@@ -76,6 +78,7 @@ export interface ExtractedRouterInclude {
76
78
  export interface ExtractedRouterImport {
77
79
  filePath: string;
78
80
  localName: string;
81
+ modulePath: string;
79
82
  moduleKey: string;
80
83
  moduleKeyLong?: string;
81
84
  }
@@ -98,6 +101,7 @@ export interface ExtractedRouterModuleAlias {
98
101
  filePath: string;
99
102
  /** Local name in the importing file (== imported name or its alias). */
100
103
  localName: string;
104
+ modulePath: string;
101
105
  /** Long key (`<parent>/<stem>`) — non-empty for every emitted record. */
102
106
  moduleKeyLong: string;
103
107
  }
@@ -13,17 +13,19 @@
13
13
  * extraction here lets unit tests import the function directly without
14
14
  * booting a worker, satisfying DoD §2.7.
15
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
16
+ * Worker phase is per-file, so the cross-file resolution lives in
17
+ * `fastapi-router-prefixes.ts` and `pipeline-phases/parse-impl.ts`. Here we extract raw record
18
18
  * kinds and let the pipeline aggregate them across files:
19
19
  *
20
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.
21
+ * `<host>.include_router(<routerExpr>, prefix='/x')` mount, plus
22
+ * unprefixed `<host>.include_router(<routerExpr>[, <other kwargs>])`
23
+ * calls recorded with `prefix: ''`. `<routerExpr>` is either
24
+ * `<module>.router` (Shape A) or a bare local name (Shape B). The
25
+ * extractor leaves `<host>` unconstrained (production code uses `app`,
26
+ * `api`, `application`, `asgi_app`, …); unprefixed records are only
27
+ * propagation edges, and `fastapi-router-prefixes.ts` passes a parent
28
+ * prefix through them only when the host is `router`.
27
29
  *
28
30
  * • {@link ExtractedRouterImport} — every
29
31
  * `from <module> import router [as <alias>]`, captured for both
@@ -37,10 +39,8 @@
37
39
  * • short key — basename without `.py` (`users`)
38
40
  * • long key — `<parent-dir>/<basename>` (`api/users`)
39
41
  *
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.
42
+ * Imports retain their module path for exact-file resolution. The short and
43
+ * long keys remain for the conservative fallback when that resolution fails.
44
44
  *
45
45
  * The functions in this module are pure (no Worker / parentPort
46
46
  * dependency) so they can be unit-tested directly without booting a
@@ -49,10 +49,18 @@
49
49
  // `<host>.include_router(<module>.router, ..., prefix='/x')` (Shape A).
50
50
  // `<host>` is left unrestricted — common production names include
51
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;
52
+ // `app` would silently drop these. Arguments before `prefix=` may hold one
53
+ // level of nested calls (`dependencies=[Depends(auth)]`); the two
54
+ // alternatives start on disjoint characters, so matching stays linear.
55
+ const INCLUDE_ROUTER_ATTR_RE = /\b([A-Za-z_][\w.]*)\.include_router\s*\(\s*([A-Za-z_][\w]*)\.router\b(?:[^()]|\([^()]*\))*?\bprefix\s*=\s*(['"])([^'"]*)\3/g;
54
56
  // `<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;
57
+ const INCLUDE_ROUTER_NAME_RE = /\b([A-Za-z_][\w.]*)\.include_router\s*\(\s*([A-Za-z_][\w]*)\b(?:[^()]|\([^()]*\))*?\bprefix\s*=\s*(['"])([^'"]*)\3/g;
58
+ // A child router may be included without a local prefix and inherit the
59
+ // prefix of its parent router when that parent is mounted elsewhere. Other
60
+ // keyword arguments (with one level of nested calls, like the prefixed
61
+ // patterns) and a trailing comma are allowed; a `prefix=` anywhere in the
62
+ // call declines the match so it never double-fires with those patterns.
63
+ const INCLUDE_ROUTER_UNPREFIXED_RE = /\b([A-Za-z_][\w.]*)\.include_router\s*\(\s*([A-Za-z_]\w*(?:\.router)?)\s*(?:,(?!(?:[^()]|\([^()]*\))*?\bprefix\s*=)(?:[^()]|\([^()]*\))*)?\)/g;
56
64
  // Module path: a sequence of dots (`.`, `..`, `...`) for "current
57
65
  // package" imports, OR an optional leading-dot prefix followed by a
58
66
  // dotted identifier (`api.users`, `.api.users`, `..siblings.users`).
@@ -175,6 +183,7 @@ export function extractFastAPIRouterBindings(filePath, content, outIncludes, out
175
183
  outImports.push({
176
184
  filePath,
177
185
  localName,
186
+ modulePath: moduleText,
178
187
  moduleKey: moduleShort,
179
188
  ...(moduleLong ? { moduleKeyLong: moduleLong } : {}),
180
189
  });
@@ -199,6 +208,7 @@ export function extractFastAPIRouterBindings(filePath, content, outIncludes, out
199
208
  outModuleAliases.push({
200
209
  filePath,
201
210
  localName,
211
+ modulePath: `${moduleText}.${importedName}`,
202
212
  moduleKeyLong: aliasLong,
203
213
  });
204
214
  }
@@ -231,8 +241,9 @@ export function extractFastAPIRouterBindings(filePath, content, outIncludes, out
231
241
  while ((m = INCLUDE_ROUTER_ATTR_RE.exec(content)) !== null) {
232
242
  outIncludes.push({
233
243
  filePath,
234
- routerExpr: `${m[1]}.router`,
235
- prefix: m[3],
244
+ host: m[1],
245
+ routerExpr: `${m[2]}.router`,
246
+ prefix: m[4],
236
247
  lineNumber: content.substring(0, m.index).split('\n').length,
237
248
  });
238
249
  }
@@ -245,14 +256,25 @@ export function extractFastAPIRouterBindings(filePath, content, outIncludes, out
245
256
  // is intentionally permissive and would re-capture `<mod>.router`
246
257
  // as the bare name `mod`. Discriminate by re-checking the
247
258
  // immediate source around the captured argument position.
248
- const argStart = m.index + m[0].indexOf(m[1]);
249
- const dotProbe = content.slice(argStart + m[1].length, argStart + m[1].length + 8);
259
+ const argStart = m.index + m[0].indexOf(m[2], m[0].indexOf('(') + 1);
260
+ const dotProbe = content.slice(argStart + m[2].length, argStart + m[2].length + 8);
250
261
  if (/^\s*\.\s*router/.test(dotProbe))
251
262
  continue;
252
263
  outIncludes.push({
253
264
  filePath,
254
- routerExpr: m[1],
255
- prefix: m[3],
265
+ host: m[1],
266
+ routerExpr: m[2],
267
+ prefix: m[4],
268
+ lineNumber: content.substring(0, m.index).split('\n').length,
269
+ });
270
+ }
271
+ INCLUDE_ROUTER_UNPREFIXED_RE.lastIndex = 0;
272
+ while ((m = INCLUDE_ROUTER_UNPREFIXED_RE.exec(content)) !== null) {
273
+ outIncludes.push({
274
+ filePath,
275
+ host: m[1],
276
+ routerExpr: m[2],
277
+ prefix: '',
256
278
  lineNumber: content.substring(0, m.index).split('\n').length,
257
279
  });
258
280
  }
@@ -0,0 +1,21 @@
1
+ import type { ExtractedRouterConstructorPrefix, ExtractedRouterImport, ExtractedRouterInclude, ExtractedRouterModuleAlias } from './fastapi-router-bindings.js';
2
+ export interface ResolvedFastAPIRouterPrefixes {
3
+ prefixesByFile: Map<string, Set<string>>;
4
+ resolvedIncludes: Set<ExtractedRouterInclude>;
5
+ }
6
+ /**
7
+ * Prefixes that apply to one router file: exact import-resolved mounts plus
8
+ * the legacy long/short-key prefixes of mounts the resolver could not bind.
9
+ * Shared by ingestion and the group extractor so both surfaces agree.
10
+ */
11
+ export declare function mergeMountPrefixes(exact: ReadonlySet<string> | undefined, legacy: ReadonlySet<string> | undefined): ReadonlySet<string> | undefined;
12
+ /**
13
+ * Carry mounted prefixes through exact, import-resolved router includes.
14
+ *
15
+ * Only a host literally named `router` passes its mounted prefix on to the
16
+ * routers it includes, and resolution is per file rather than per variable:
17
+ * `api_router = APIRouter(); api_router.include_router(x.router)` does not
18
+ * pass through, and a prefixed `api_router.include_router(...)` is treated
19
+ * as a root mount. Unprefixed includes from any other host are ignored.
20
+ */
21
+ export declare function resolveFastAPIRouterPrefixes(files: Iterable<string>, includes: readonly ExtractedRouterInclude[], imports: readonly ExtractedRouterImport[], moduleAliases: readonly ExtractedRouterModuleAlias[], constructorPrefixes?: readonly ExtractedRouterConstructorPrefix[]): ResolvedFastAPIRouterPrefixes;
@@ -0,0 +1,153 @@
1
+ import { normalizeExtractedRoutePath } from './route-path.js';
2
+ /** Resolve only imports that identify one Python file in this repository. */
3
+ function resolveModuleFile(importer, modulePath, files, absoluteModules) {
4
+ const leadingDots = /^\.+/.exec(modulePath)?.[0].length ?? 0;
5
+ const dotted = modulePath.slice(leadingDots);
6
+ if (!dotted)
7
+ return undefined;
8
+ const moduleSegments = dotted.split('.');
9
+ if (!moduleSegments.every((part) => /^[A-Za-z_]\w*$/.test(part)))
10
+ return undefined;
11
+ let stem;
12
+ if (leadingDots > 0) {
13
+ const directory = importer.replace(/\\/g, '/').split('/').slice(0, -1);
14
+ const parentLevels = leadingDots - 1;
15
+ if (parentLevels > directory.length)
16
+ return undefined;
17
+ stem = [...directory.slice(0, directory.length - parentLevels), ...moduleSegments].join('/');
18
+ }
19
+ else {
20
+ stem = moduleSegments.join('/');
21
+ }
22
+ if (leadingDots === 0)
23
+ return absoluteModules.get(stem) ?? undefined;
24
+ const candidates = [`${stem}.py`, `${stem}/__init__.py`].filter((candidate) => files.has(candidate));
25
+ return candidates.length === 1 ? candidates[0] : undefined;
26
+ }
27
+ /**
28
+ * Prefixes that apply to one router file: exact import-resolved mounts plus
29
+ * the legacy long/short-key prefixes of mounts the resolver could not bind.
30
+ * Shared by ingestion and the group extractor so both surfaces agree.
31
+ */
32
+ export function mergeMountPrefixes(exact, legacy) {
33
+ if (!exact)
34
+ return legacy;
35
+ if (!legacy)
36
+ return exact;
37
+ return new Set([...exact, ...legacy]);
38
+ }
39
+ /**
40
+ * Carry mounted prefixes through exact, import-resolved router includes.
41
+ *
42
+ * Only a host literally named `router` passes its mounted prefix on to the
43
+ * routers it includes, and resolution is per file rather than per variable:
44
+ * `api_router = APIRouter(); api_router.include_router(x.router)` does not
45
+ * pass through, and a prefixed `api_router.include_router(...)` is treated
46
+ * as a root mount. Unprefixed includes from any other host are ignored.
47
+ */
48
+ export function resolveFastAPIRouterPrefixes(files, includes, imports, moduleAliases, constructorPrefixes = []) {
49
+ const fileSet = new Set([...files].map((file) => file.replace(/\\/g, '/')));
50
+ const constructorPrefixByFile = new Map(constructorPrefixes.map((ctor) => [ctor.filePath.replace(/\\/g, '/'), ctor.prefix]));
51
+ const absoluteModules = new Map();
52
+ for (const file of fileSet) {
53
+ if (!file.endsWith('.py'))
54
+ continue;
55
+ const stem = file.endsWith('/__init__.py')
56
+ ? file.slice(0, -'/__init__.py'.length)
57
+ : file.slice(0, -'.py'.length);
58
+ const parts = stem.split('/');
59
+ for (let i = 0; i < parts.length; i++) {
60
+ const suffix = parts.slice(i).join('/');
61
+ const previous = absoluteModules.get(suffix);
62
+ absoluteModules.set(suffix, previous === undefined ? file : previous === file ? file : null);
63
+ }
64
+ }
65
+ const importsByFile = new Map();
66
+ for (const imp of [...imports, ...moduleAliases]) {
67
+ const modulePath = imp.modulePath;
68
+ if (!modulePath)
69
+ continue;
70
+ const file = imp.filePath.replace(/\\/g, '/');
71
+ const bindings = importsByFile.get(file) ?? new Map();
72
+ bindings.set(imp.localName, { modulePath });
73
+ importsByFile.set(file, bindings);
74
+ }
75
+ const prefixesByFile = new Map();
76
+ const resolvedIncludes = new Set();
77
+ const childIncludes = new Map();
78
+ const bareMounts = new Set();
79
+ for (const inc of includes) {
80
+ const source = inc.filePath.replace(/\\/g, '/');
81
+ const localName = inc.routerExpr.endsWith('.router')
82
+ ? inc.routerExpr.slice(0, -'.router'.length)
83
+ : inc.routerExpr;
84
+ const modulePath = importsByFile.get(source)?.get(localName)?.modulePath;
85
+ if (!modulePath)
86
+ continue;
87
+ const target = resolveModuleFile(source, modulePath, fileSet, absoluteModules);
88
+ if (!target)
89
+ continue;
90
+ if (inc.host === 'router') {
91
+ const children = childIncludes.get(source) ?? [];
92
+ children.push({ target, prefix: inc.prefix, include: inc });
93
+ childIncludes.set(source, children);
94
+ }
95
+ else if (inc.prefix) {
96
+ const prefixes = prefixesByFile.get(target) ?? new Set();
97
+ prefixes.add(inc.prefix);
98
+ prefixesByFile.set(target, prefixes);
99
+ resolvedIncludes.add(inc);
100
+ }
101
+ else {
102
+ bareMounts.add(target);
103
+ }
104
+ }
105
+ // A router mounted without a prefix still seeds traversal (with an empty
106
+ // prefix) so its own `APIRouter(prefix=...)` reaches its children. Like its
107
+ // own routes, it only does so when no prefixed mount targets the file.
108
+ const roots = [
109
+ ...[...prefixesByFile].flatMap(([file, prefixes]) => [...prefixes].map((prefix) => ({ file, prefix }))),
110
+ ...[...bareMounts]
111
+ .filter((file) => !prefixesByFile.has(file))
112
+ .map((file) => ({ file, prefix: '' })),
113
+ ];
114
+ // `expanded` memoizes (file, prefix) frames so diamond-shaped include graphs
115
+ // stay linear in distinct prefixes; the per-path `visited` set still stops
116
+ // cycles whose edges keep growing the prefix.
117
+ const expanded = new Set();
118
+ for (const { file, prefix } of roots) {
119
+ const stack = [{ file, prefix, visited: new Set([file]) }];
120
+ for (let current = stack.pop(); current; current = stack.pop()) {
121
+ const frameKey = `${current.file}\0${current.prefix}`;
122
+ if (expanded.has(frameKey))
123
+ continue;
124
+ expanded.add(frameKey);
125
+ // The parent's own `APIRouter(prefix=...)` sits between its mount
126
+ // prefix and the child include prefix.
127
+ const ctorPrefix = constructorPrefixByFile.get(current.file);
128
+ const parentPrefix = ctorPrefix
129
+ ? normalizeExtractedRoutePath(ctorPrefix, current.prefix)
130
+ : current.prefix;
131
+ for (const edge of childIncludes.get(current.file) ?? []) {
132
+ if (current.visited.has(edge.target))
133
+ continue;
134
+ const normalized = normalizeExtractedRoutePath(edge.prefix, parentPrefix);
135
+ // An all-empty chain adds no prefix; record nothing so the child
136
+ // keeps its legacy fallback, but keep walking for deeper prefixes.
137
+ const joined = normalized === '/' ? '' : normalized;
138
+ if (joined) {
139
+ const targetPrefixes = prefixesByFile.get(edge.target) ?? new Set();
140
+ targetPrefixes.add(joined);
141
+ prefixesByFile.set(edge.target, targetPrefixes);
142
+ resolvedIncludes.add(edge.include);
143
+ }
144
+ stack.push({
145
+ file: edge.target,
146
+ prefix: joined,
147
+ visited: new Set([...current.visited, edge.target]),
148
+ });
149
+ }
150
+ }
151
+ }
152
+ return { prefixesByFile, resolvedIncludes };
153
+ }
@@ -810,7 +810,10 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid
810
810
  // v122 (#3414): Python decorator identity models restoring helper calls and
811
811
  // treats match-pattern captures and nested nonlocal rebinds as shadowing.
812
812
  // Warm v121 captures carry the old verdicts.
813
- const SCHEMA_BUMP = 122;
813
+ // v123 (#3408): FastAPI include records now carry `host`, router imports carry
814
+ // `modulePath`, and unprefixed child includes are emitted. Warm v122 records
815
+ // lack them, so nested router-prefix propagation would stay inert.
816
+ const SCHEMA_BUMP = 123;
814
817
  const GITNEXUS_PKG_VERSION = (() => {
815
818
  try {
816
819
  // package.json sits at gitnexus/package.json — two levels up from
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.13-rc.56",
3
+ "version": "1.6.13-rc.57",
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",