fix(fastapi): propagate package router mount prefixes (#3408)
Some checks failed
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Gitleaks / gitleaks (push) Has been cancelled
Publish / Classify release event (push) Has been cancelled
Scorecard / Scorecard analysis (push) Has been cancelled
Trivy Image Scan / Trivy (gitnexus-cli) (push) Has been cancelled
Trivy Image Scan / Trivy (gitnexus-web) (push) Has been cancelled
Publish / RC guard (marker + release-PR skip) (push) Has been cancelled
Publish / ci (push) Has been cancelled
Publish / Publish to npm (push) Has been cancelled
Publish / Build & Push RC Docker images (push) Has been cancelled

* fix(fastapi): carry package router mount prefixes to child routes

* fix(fastapi): address review feedback on nested router prefixes (#3408)

- Skip unprefixed includes in the parse-impl legacy loop so a bare
  include_router in another file no longer shadows the real prefix.
- Union exact-file prefixes with legacy long/short prefixes via a shared
  mergeMountPrefixes helper in both ingestion and the group extractor.
- Join the parent APIRouter(prefix=...) between the mount prefix and the
  child include prefix.
- Resolve the group layer over every repo path (empty files included) so
  absolute-import ambiguity matches ingestion.
- Memoize (file, prefix) frames so diamond-shaped include graphs stay
  linear; drop the stack.pop() non-null assertion.
- Accept extra keyword arguments and a trailing comma in unprefixed
  include_router calls without double-firing on prefix= calls.
- Document that pass-through is limited to a host named `router`.
- Bump parse-cache SCHEMA_BUMP to 123 for the new capture fields.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(fastapi): seed prefix propagation from bare router mounts (#3408)

- A router mounted without a prefix now seeds traversal with an empty
  prefix (only when no prefixed mount targets the same file), so its own
  APIRouter(prefix=...) reaches unprefixed children on both surfaces.
- An all-empty chain records nothing and leaves the child on its legacy
  fallback.
- The bare-mount integration test no longer asserts that the test app's
  unprefixed mount is absent; it pins only that the real prefix survives.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(fastapi): capture include prefixes after nested-call arguments (#3408)

- Let the Shape A/B and unprefixed include_router patterns step over one
  level of nested calls such as dependencies=[Depends(auth)], so a
  prefix= written after them is captured by the worker (the group
  layer's tree-sitter patterns already handled this shape).
- Replace the unit test that pinned the dropped prefix with one that
  pins the captured prefixes and the unprefixed Depends-only edge; add a
  group-layer parity test.
- Correct the diamond test comment to 2^39 root-to-leaf paths.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
azizur100389 2026-09-29 14:49:44 +01:00 • committed by GitHub
parent aa0f41e853
commit acb65b95b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 720 additions and 54 deletions

View file

@ -7,6 +7,17 @@ import {
type LanguagePatterns,
} from '../tree-sitter-scanner.js';
import { normalizeExtractedRoutePath } from '../../../ingestion/route-extractors/route-path.js';
import {
extractFastAPIRouterBindings,
type ExtractedRouterConstructorPrefix,
type ExtractedRouterImport,
type ExtractedRouterInclude,
type ExtractedRouterModuleAlias,
} from '../../../ingestion/route-extractors/fastapi-router-bindings.js';
import {
mergeMountPrefixes,
resolveFastAPIRouterPrefixes,
} from '../../../ingestion/route-extractors/fastapi-router-prefixes.js';
import {
extractPythonModuleConstants,
parseConstOperands,
@ -892,15 +903,12 @@ const HTTPX_ASYNC_CLIENT_GENERIC_PATTERNS = compilePatterns({
// files in different packages (e.g. `api/users.py` vs `admin/users.py`):
// • short key — file basename without `.py` (`users`)
// • long key — `<parent-dir>/<basename>` (`api/users`)
// The pre-pass records prefixes against the long key whenever the import
// site supplies enough context (`from api.users import router as ...` →
// long key `api/users`); otherwise it falls back to the short key.
// At scan time the file's own long key is consulted first; only when no
// long-key entry targets this file do we look up the short key. This
// preserves the previous coarse-grained behaviour where context is
// missing while delivering precision wherever the import statement
// gives us a multi-segment module path.
// Import-resolved mounts use exact file paths, including package
// `__init__.py` routers and nested child includes. Long/short keys remain
// fallback-only for unresolved imports.
interface PythonRepoContext {
/** Exact source-file paths for import-resolved direct and nested router mounts. */
prefixesByFile: Map<string, Set<string>>;
/** `<parent>/<stem>` → set of prefixes (precise, package-aware) */
prefixesByLongKey: Map<string, Set<string>>;
/** stem only → set of prefixes (basename fallback, may collide) */
@ -992,6 +1000,10 @@ function buildPythonRepoContext(
): PythonRepoContext {
const prefixesByLongKey = new Map<string, Set<string>>();
const prefixesByShortKey = new Map<string, Set<string>>();
const routerIncludes: ExtractedRouterInclude[] = [];
const routerImports: ExtractedRouterImport[] = [];
const routerModuleAliases: ExtractedRouterModuleAlias[] = [];
const routerConstructorPrefixes: ExtractedRouterConstructorPrefix[] = [];
// Single read pass (#2393): slurp every `.py` file's content ONCE. This used to
// be two passes — the include_router pre-pass below and the #2391 constant cost
@ -1008,6 +1020,31 @@ function buildPythonRepoContext(
if (!hasComposedRoute && NONLITERAL_ROUTE_DECORATOR_RE.test(src)) hasComposedRoute = true;
}
for (const [rel, src] of pyContents) {
if (src.includes('include_router')) {
extractFastAPIRouterBindings(
rel,
src,
routerIncludes,
routerImports,
routerModuleAliases,
routerConstructorPrefixes,
);
}
}
// Resolve against every repo path, empty files included, so module
// ambiguity matches ingestion (which passes all scanned paths).
const { prefixesByFile, resolvedIncludes } = resolveFastAPIRouterPrefixes(
files,
routerIncludes,
routerImports,
routerModuleAliases,
routerConstructorPrefixes,
);
const resolvedIncludeKeys = new Set(
[...resolvedIncludes].map((inc) => JSON.stringify([inc.filePath, inc.routerExpr, inc.prefix])),
);
// Single PARSE pass (#2391): parse each `.py` at most once and feed BOTH the
// include_router prefix pre-pass and the composed-constant map below. This used
// to be two loops, so an include_router file in a composed repo was parsed
@ -1078,6 +1115,8 @@ function buildPythonRepoContext(
const prefix = unquoteLiteral(prefixNode.text);
if (prefix === null) continue;
const moduleShort = modNode.text;
if (resolvedIncludeKeys.has(JSON.stringify([rel, `${moduleShort}.router`, prefix])))
continue;
const aliasLong = localNameToModuleAlias.get(moduleShort);
const sameFileImport = localNameToModule.get(moduleShort);
const longKey = aliasLong ?? sameFileImport?.moduleLong;
@ -1100,6 +1139,7 @@ function buildPythonRepoContext(
if (!localImp) continue;
const prefix = unquoteLiteral(prefixNode.text);
if (prefix === null) continue;
if (resolvedIncludeKeys.has(JSON.stringify([rel, nameNode.text, prefix]))) continue;
if (localImp.moduleLong) {
recordPrefix(prefixesByLongKey, localImp.moduleLong, prefix);
} else {
@ -1121,6 +1161,7 @@ function buildPythonRepoContext(
}
return {
prefixesByFile,
prefixesByLongKey,
prefixesByShortKey,
constantsByFile,
@ -1253,15 +1294,17 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
// by the literal and the #2391 non-literal (resolved) router loops so both
// stack prefixes identically.
const emitRouterProvider = (httpMethod: string, rawPath: string, line: number): void => {
// Long key first (precise, package-aware), short key as fallback.
// Mirrors the ingestion-side resolution in parse-impl.ts so the
// graph nodes and group contracts agree on which prefix applies.
// Exact file matches plus the legacy long/short fallback. This mirrors
// ingestion so graph Route nodes and contracts agree on mounted paths.
const longKey = fileRel ? fileLongKey(fileRel) : '';
const longPrefixes = longKey ? ctx?.prefixesByLongKey.get(longKey) : undefined;
const shortKey = fileRel ? fileShortKey(fileRel) : '';
const shortPrefixes =
longPrefixes || !shortKey ? undefined : ctx?.prefixesByShortKey.get(shortKey);
const prefixSet = longPrefixes ?? shortPrefixes;
const prefixSet = mergeMountPrefixes(
fileRel ? ctx?.prefixesByFile.get(fileRel.replace(/\\/g, '/')) : undefined,
longPrefixes ?? shortPrefixes,
);
// Stack the same-file APIRouter(prefix=...) under any cross-file
// include_router prefix.
const localPath = constructorPrefix ? joinPrefix(constructorPrefix, rawPath) : rawPath;

View file

@ -98,6 +98,10 @@ import type {
ExtractedRouterInclude,
ExtractedRouterModuleAlias,
} from '../route-extractors/fastapi-router-bindings.js';
import {
mergeMountPrefixes,
resolveFastAPIRouterPrefixes,
} from '../route-extractors/fastapi-router-prefixes.js';
import { normalizeExtractedRoutePath } from '../route-extractors/route-path.js';
import { resolveOperands } from '../route-extractors/python-const-resolver.js';
import type { ModuleConstants } from '../route-extractors/constant-resolver.js';
@ -1667,11 +1671,9 @@ export async function runChunkedParseAndResolve(
m.set(alias.localName, alias.moduleKeyLong);
}
// Two parallel maps: long-key (precise) and short-key (basename
// fallback). Long-key entries are preferred when the file's own long
// key matches; short-key entries match any file with that basename and
// remain the fallback when no long key is known (e.g. Shape A includes
// without a corresponding import statement).
// Exact-file matches handle import-resolved mounts (including nested
// routers). These long/short maps preserve the older fallback for mounts
// whose import cannot be resolved to one file.
const prefixesByLongKey = new Map<string, Set<string>>();
const prefixesByShortKey = new Map<string, Set<string>>();
// Constructor prefixes are `router`-only (the apply gate below and the
@ -1679,6 +1681,13 @@ export async function runChunkedParseAndResolve(
// flat file-key → prefix map suffices — mirrors the group layer's shape.
const constructorPrefixesByLongKey = new Map<string, string>();
const constructorPrefixesByShortKey = new Map<string, string>();
const { prefixesByFile, resolvedIncludes } = resolveFastAPIRouterPrefixes(
allPaths,
allRouterIncludes,
allRouterImports,
allRouterModuleAliases,
allRouterConstructorPrefixes,
);
const recordPrefix = (target: Map<string, Set<string>>, key: string, prefix: string): void => {
let set = target.get(key);
@ -1690,6 +1699,9 @@ export async function runChunkedParseAndResolve(
};
for (const inc of allRouterIncludes) {
// Unprefixed includes only exist as propagation edges; recording `''`
// here would shadow a real short-key prefix for the same module.
if (resolvedIncludes.has(inc) || !inc.prefix) continue;
// Shape A: `<module>.router`. The worker emits `routerExpr` already
// including `.router`, so split it back. We only know a short module
// key here — the call site doesn't carry the dotted package path. If
@ -1720,6 +1732,7 @@ export async function runChunkedParseAndResolve(
}
if (
prefixesByFile.size > 0 ||
prefixesByLongKey.size > 0 ||
prefixesByShortKey.size > 0 ||
allRouterConstructorPrefixes.length > 0
@ -1760,15 +1773,17 @@ export async function runChunkedParseAndResolve(
expanded.push(dr);
continue;
}
// Long-key lookup first; only fall back to the short key when no
// long-key prefix targets this file. This avoids prefix leakage
// between e.g. `api/users.py` and `admin/users.py`.
// Exact file matches plus the legacy long/short fallback for mounts
// whose import could not be resolved.
const longKey = fileLongKey(dr.filePath);
const longPrefixes = longKey ? prefixesByLongKey.get(longKey) : undefined;
const shortPrefixes = longPrefixes
? undefined
: prefixesByShortKey.get(fileShortKey(dr.filePath));
const prefixes = longPrefixes ?? shortPrefixes;
const prefixes = mergeMountPrefixes(
prefixesByFile.get(dr.filePath.replace(/\\/g, '/')),
longPrefixes ?? shortPrefixes,
);
// Constructor prefixes are keyed like include_router prefixes:
// long-key entries are precise, while short-key entries are only
// valid for repo-root/single-segment files where `fileLongKey`

View file

@ -13,17 +13,19 @@
* extraction here lets unit tests import the function directly without
* booting a worker, satisfying DoD §2.7.
*
* Worker phase is per-file, so the heavy cross-file resolution lives in
* `pipeline-phases/parse-impl.ts`. Here we only extract two raw record
* Worker phase is per-file, so the cross-file resolution lives in
* `fastapi-router-prefixes.ts` and `pipeline-phases/parse-impl.ts`. Here we extract raw record
* kinds and let the pipeline aggregate them across files:
*
* • {@link ExtractedRouterInclude} — every
* `<host>.include_router(<routerExpr>, prefix='/x')` site, where
* `<routerExpr>` is either `<module>.router` (Shape A) or a bare
* local name (Shape B). `<host>` is intentionally unconstrained:
* production code uses `app`, `api`, `application`, `asgi_app`,
* etc., and the call shape (`include_router` invoked with a
* `prefix=` keyword) is specific enough on its own.
* `<host>.include_router(<routerExpr>, prefix='/x')` mount, plus
* unprefixed `<host>.include_router(<routerExpr>[, <other kwargs>])`
* calls recorded with `prefix: ''`. `<routerExpr>` is either
* `<module>.router` (Shape A) or a bare local name (Shape B). The
* extractor leaves `<host>` unconstrained (production code uses `app`,
* `api`, `application`, `asgi_app`, …); unprefixed records are only
* propagation edges, and `fastapi-router-prefixes.ts` passes a parent
* prefix through them only when the host is `router`.
*
* • {@link ExtractedRouterImport} — every
* `from <module> import router [as <alias>]`, captured for both
@ -37,10 +39,8 @@
* • short key — basename without `.py` (`users`)
* • long key — `<parent-dir>/<basename>` (`api/users`)
*
* Imports always carry the short key and, when the module path was
* multi-segment, also the long key. parse-impl matches against the
* long key first and falls back to the short key, so cross-package
* collisions are eliminated for Shape B and minimised for Shape A.
* Imports retain their module path for exact-file resolution. The short and
* long keys remain for the conservative fallback when that resolution fails.
*
* The functions in this module are pure (no Worker / parentPort
* dependency) so they can be unit-tested directly without booting a
@ -48,15 +48,17 @@
*/
/**
* One `<host>.include_router(<routerExpr>, prefix='/x')` site.
* One `<host>.include_router(<routerExpr>, prefix='/x')` site, or a
* simple child include without a local prefix.
*
* `routerExpr` is the raw text of the first argument — either
* `<module>.router` (Shape A) or a bare local name (Shape B).
* parse-impl resolves Shape B against {@link ExtractedRouterImport}
* records emitted by the same file.
* Cross-file resolution uses {@link ExtractedRouterImport} records
* emitted by the same file.
*/
export interface ExtractedRouterInclude {
filePath: string;
host: string;
routerExpr: string;
prefix: string;
lineNumber: number;
@ -78,6 +80,7 @@ export interface ExtractedRouterInclude {
export interface ExtractedRouterImport {
filePath: string;
localName: string;
modulePath: string;
moduleKey: string;
moduleKeyLong?: string;
}
@ -101,6 +104,7 @@ export interface ExtractedRouterModuleAlias {
filePath: string;
/** Local name in the importing file (== imported name or its alias). */
localName: string;
modulePath: string;
/** Long key (`<parent>/<stem>`) — non-empty for every emitted record. */
moduleKeyLong: string;
}
@ -113,13 +117,23 @@ export interface ExtractedRouterConstructorPrefix {
// `<host>.include_router(<module>.router, ..., prefix='/x')` (Shape A).
// `<host>` is left unrestricted — common production names include
// `app`, `api`, `application`, `asgi_app`. Pinning to the literal
// `app` would silently drop these.
// `app` would silently drop these. Arguments before `prefix=` may hold one
// level of nested calls (`dependencies=[Depends(auth)]`); the two
// alternatives start on disjoint characters, so matching stays linear.
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;
/\b([A-Za-z_][\w.]*)\.include_router\s*\(\s*([A-Za-z_][\w]*)\.router\b(?:[^()]|\([^()]*\))*?\bprefix\s*=\s*(['"])([^'"]*)\3/g;
// `<host>.include_router(<local_name>, ..., prefix='/x')` (Shape B).
const INCLUDE_ROUTER_NAME_RE =
/\b(?:[A-Za-z_][\w.]*)\.include_router\s*\(\s*([A-Za-z_][\w]*)\b[^)]*?\bprefix\s*=\s*(['"])([^'"]*)\2/g;
/\b([A-Za-z_][\w.]*)\.include_router\s*\(\s*([A-Za-z_][\w]*)\b(?:[^()]|\([^()]*\))*?\bprefix\s*=\s*(['"])([^'"]*)\3/g;
// A child router may be included without a local prefix and inherit the
// prefix of its parent router when that parent is mounted elsewhere. Other
// keyword arguments (with one level of nested calls, like the prefixed
// patterns) and a trailing comma are allowed; a `prefix=` anywhere in the
// call declines the match so it never double-fires with those patterns.
const INCLUDE_ROUTER_UNPREFIXED_RE =
/\b([A-Za-z_][\w.]*)\.include_router\s*\(\s*([A-Za-z_]\w*(?:\.router)?)\s*(?:,(?!(?:[^()]|\([^()]*\))*?\bprefix\s*=)(?:[^()]|\([^()]*\))*)?\)/g;
// Module path: a sequence of dots (`.`, `..`, `...`) for "current
// package" imports, OR an optional leading-dot prefix followed by a
@ -248,6 +262,7 @@ export function extractFastAPIRouterBindings(
outImports.push({
filePath,
localName,
modulePath: moduleText,
moduleKey: moduleShort,
...(moduleLong ? { moduleKeyLong: moduleLong } : {}),
});
@ -270,6 +285,7 @@ export function extractFastAPIRouterBindings(
outModuleAliases.push({
filePath,
localName,
modulePath: `${moduleText}.${importedName}`,
moduleKeyLong: aliasLong,
});
}
@ -302,8 +318,9 @@ export function extractFastAPIRouterBindings(
while ((m = INCLUDE_ROUTER_ATTR_RE.exec(content)) !== null) {
outIncludes.push({
filePath,
routerExpr: `${m[1]}.router`,
prefix: m[3],
host: m[1],
routerExpr: `${m[2]}.router`,
prefix: m[4],
lineNumber: content.substring(0, m.index).split('\n').length,
});
}
@ -317,13 +334,25 @@ export function extractFastAPIRouterBindings(
// is intentionally permissive and would re-capture `<mod>.router`
// as the bare name `mod`. Discriminate by re-checking the
// immediate source around the captured argument position.
const argStart = m.index + m[0].indexOf(m[1]);
const dotProbe = content.slice(argStart + m[1].length, argStart + m[1].length + 8);
const argStart = m.index + m[0].indexOf(m[2], m[0].indexOf('(') + 1);
const dotProbe = content.slice(argStart + m[2].length, argStart + m[2].length + 8);
if (/^\s*\.\s*router/.test(dotProbe)) continue;
outIncludes.push({
filePath,
routerExpr: m[1],
prefix: m[3],
host: m[1],
routerExpr: m[2],
prefix: m[4],
lineNumber: content.substring(0, m.index).split('\n').length,
});
}
INCLUDE_ROUTER_UNPREFIXED_RE.lastIndex = 0;
while ((m = INCLUDE_ROUTER_UNPREFIXED_RE.exec(content)) !== null) {
outIncludes.push({
filePath,
host: m[1],
routerExpr: m[2],
prefix: '',
lineNumber: content.substring(0, m.index).split('\n').length,
});
}

View file

@ -0,0 +1,186 @@
import { normalizeExtractedRoutePath } from './route-path.js';
import type {
ExtractedRouterConstructorPrefix,
ExtractedRouterImport,
ExtractedRouterInclude,
ExtractedRouterModuleAlias,
} from './fastapi-router-bindings.js';
interface RouterImport {
modulePath: string;
}
export interface ResolvedFastAPIRouterPrefixes {
prefixesByFile: Map<string, Set<string>>;
resolvedIncludes: Set<ExtractedRouterInclude>;
}
/** Resolve only imports that identify one Python file in this repository. */
function resolveModuleFile(
importer: string,
modulePath: string,
files: Set<string>,
absoluteModules: Map<string, string | null>,
): string | undefined {
const leadingDots = /^\.+/.exec(modulePath)?.[0].length ?? 0;
const dotted = modulePath.slice(leadingDots);
if (!dotted) return undefined;
const moduleSegments = dotted.split('.');
if (!moduleSegments.every((part) => /^[A-Za-z_]\w*$/.test(part))) return undefined;
let stem: string;
if (leadingDots > 0) {
const directory = importer.replace(/\\/g, '/').split('/').slice(0, -1);
const parentLevels = leadingDots - 1;
if (parentLevels > directory.length) return undefined;
stem = [...directory.slice(0, directory.length - parentLevels), ...moduleSegments].join('/');
} else {
stem = moduleSegments.join('/');
}
if (leadingDots === 0) return absoluteModules.get(stem) ?? undefined;
const candidates = [`${stem}.py`, `${stem}/__init__.py`].filter((candidate) =>
files.has(candidate),
);
return candidates.length === 1 ? candidates[0] : undefined;
}
/**
* Prefixes that apply to one router file: exact import-resolved mounts plus
* the legacy long/short-key prefixes of mounts the resolver could not bind.
* Shared by ingestion and the group extractor so both surfaces agree.
*/
export function mergeMountPrefixes(
exact: ReadonlySet<string> | undefined,
legacy: ReadonlySet<string> | undefined,
): ReadonlySet<string> | undefined {
if (!exact) return legacy;
if (!legacy) return exact;
return new Set([...exact, ...legacy]);
}
/**
* Carry mounted prefixes through exact, import-resolved router includes.
*
* Only a host literally named `router` passes its mounted prefix on to the
* routers it includes, and resolution is per file rather than per variable:
* `api_router = APIRouter(); api_router.include_router(x.router)` does not
* pass through, and a prefixed `api_router.include_router(...)` is treated
* as a root mount. Unprefixed includes from any other host are ignored.
*/
export function resolveFastAPIRouterPrefixes(
files: Iterable<string>,
includes: readonly ExtractedRouterInclude[],
imports: readonly ExtractedRouterImport[],
moduleAliases: readonly ExtractedRouterModuleAlias[],
constructorPrefixes: readonly ExtractedRouterConstructorPrefix[] = [],
): ResolvedFastAPIRouterPrefixes {
const fileSet = new Set([...files].map((file) => file.replace(/\\/g, '/')));
const constructorPrefixByFile = new Map(
constructorPrefixes.map((ctor) => [ctor.filePath.replace(/\\/g, '/'), ctor.prefix]),
);
const absoluteModules = new Map<string, string | null>();
for (const file of fileSet) {
if (!file.endsWith('.py')) continue;
const stem = file.endsWith('/__init__.py')
? file.slice(0, -'/__init__.py'.length)
: file.slice(0, -'.py'.length);
const parts = stem.split('/');
for (let i = 0; i < parts.length; i++) {
const suffix = parts.slice(i).join('/');
const previous = absoluteModules.get(suffix);
absoluteModules.set(suffix, previous === undefined ? file : previous === file ? file : null);
}
}
const importsByFile = new Map<string, Map<string, RouterImport>>();
for (const imp of [...imports, ...moduleAliases]) {
const modulePath = imp.modulePath;
if (!modulePath) continue;
const file = imp.filePath.replace(/\\/g, '/');
const bindings = importsByFile.get(file) ?? new Map<string, RouterImport>();
bindings.set(imp.localName, { modulePath });
importsByFile.set(file, bindings);
}
const prefixesByFile = new Map<string, Set<string>>();
const resolvedIncludes = new Set<ExtractedRouterInclude>();
const childIncludes = new Map<
string,
{ target: string; prefix: string; include: ExtractedRouterInclude }[]
>();
const bareMounts = new Set<string>();
for (const inc of includes) {
const source = inc.filePath.replace(/\\/g, '/');
const localName = inc.routerExpr.endsWith('.router')
? inc.routerExpr.slice(0, -'.router'.length)
: inc.routerExpr;
const modulePath = importsByFile.get(source)?.get(localName)?.modulePath;
if (!modulePath) continue;
const target = resolveModuleFile(source, modulePath, fileSet, absoluteModules);
if (!target) continue;
if (inc.host === 'router') {
const children = childIncludes.get(source) ?? [];
children.push({ target, prefix: inc.prefix, include: inc });
childIncludes.set(source, children);
} else if (inc.prefix) {
const prefixes = prefixesByFile.get(target) ?? new Set<string>();
prefixes.add(inc.prefix);
prefixesByFile.set(target, prefixes);
resolvedIncludes.add(inc);
} else {
bareMounts.add(target);
}
}
// A router mounted without a prefix still seeds traversal (with an empty
// prefix) so its own `APIRouter(prefix=...)` reaches its children. Like its
// own routes, it only does so when no prefixed mount targets the file.
const roots = [
...[...prefixesByFile].flatMap(([file, prefixes]) =>
[...prefixes].map((prefix) => ({ file, prefix })),
),
...[...bareMounts]
.filter((file) => !prefixesByFile.has(file))
.map((file) => ({ file, prefix: '' })),
];
// `expanded` memoizes (file, prefix) frames so diamond-shaped include graphs
// stay linear in distinct prefixes; the per-path `visited` set still stops
// cycles whose edges keep growing the prefix.
const expanded = new Set<string>();
for (const { file, prefix } of roots) {
const stack = [{ file, prefix, visited: new Set([file]) }];
for (let current = stack.pop(); current; current = stack.pop()) {
const frameKey = `${current.file}\0${current.prefix}`;
if (expanded.has(frameKey)) continue;
expanded.add(frameKey);
// The parent's own `APIRouter(prefix=...)` sits between its mount
// prefix and the child include prefix.
const ctorPrefix = constructorPrefixByFile.get(current.file);
const parentPrefix = ctorPrefix
? normalizeExtractedRoutePath(ctorPrefix, current.prefix)
: current.prefix;
for (const edge of childIncludes.get(current.file) ?? []) {
if (current.visited.has(edge.target)) continue;
const normalized = normalizeExtractedRoutePath(edge.prefix, parentPrefix);
// An all-empty chain adds no prefix; record nothing so the child
// keeps its legacy fallback, but keep walking for deeper prefixes.
const joined = normalized === '/' ? '' : normalized;
if (joined) {
const targetPrefixes = prefixesByFile.get(edge.target) ?? new Set<string>();
targetPrefixes.add(joined);
prefixesByFile.set(edge.target, targetPrefixes);
resolvedIncludes.add(edge.include);
}
stack.push({
file: edge.target,
prefix: joined,
visited: new Set([...current.visited, edge.target]),
});
}
}
}
return { prefixesByFile, resolvedIncludes };
}

View file

@ -815,7 +815,10 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid
// v122 (#3414): Python decorator identity models restoring helper calls and
// treats match-pattern captures and nested nonlocal rebinds as shadowing.
// Warm v121 captures carry the old verdicts.
const SCHEMA_BUMP = 122;
// v123 (#3408): FastAPI include records now carry `host`, router imports carry
// `modulePath`, and unprefixed child includes are emitted. Warm v122 records
// lack them, so nested router-prefix propagation would stay inert.
const SCHEMA_BUMP = 123;
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from

View file

@ -0,0 +1,7 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/model-audit")
async def audit_models():
return []

View file

@ -0,0 +1,8 @@
from fastapi import APIRouter
from .agents import router as agents_router
from .models import router as models_router
router = APIRouter()
router.include_router(agents_router)
router.include_router(models_router)
router.include_router(models_router, prefix="/v1")

View file

@ -0,0 +1,7 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/agents")
async def list_agents():
return []

View file

@ -0,0 +1,7 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/models")
async def list_models():
return []

View file

@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/invoices")
async def list_invoices():
return []

View file

@ -0,0 +1,5 @@
from fastapi import FastAPI
from . import billing
app = FastAPI()
app.include_router(billing.router, prefix="/billing")

View file

@ -1,6 +1,7 @@
from fastapi import FastAPI
from api import items
from api import users
from api import router as api_router
from api.calls import router as calls_router
from .relative import router as rel_router
@ -13,3 +14,4 @@ application.include_router(items.router, prefix="/v1")
application.include_router(users.router, prefix="/users", tags=["users"])
application.include_router(calls_router, prefix="/calls")
application.include_router(rel_router, prefix="/rel")
application.include_router(api_router, prefix="/api")

View file

@ -0,0 +1,6 @@
# A test app mounts the router bare; production mounts it at /billing.
from fastapi import FastAPI
from app_pkg import billing
app = FastAPI()
app.include_router(billing.router)

View file

@ -103,6 +103,28 @@ describe('FastAPI include_router(prefix=…) — ingestion pipeline', () => {
expect(names).toContain('/rel/info');
});
it('propagates a package-router mount prefix through unprefixed child includes', () => {
const names = routeNames();
expect(names).toContain('/api/agents');
expect(names).toContain('/api/models');
expect(names).toContain('/api/v1/models');
expect(names).not.toContain('/agents');
expect(names).not.toContain('/models');
expect(names).toContain('/model-audit');
expect(names).not.toContain('/api/model-audit');
expect(names).not.toContain('/v1/model-audit');
expect(names).not.toContain('/api/v1/model-audit');
});
it('keeps the production prefix when another file mounts the same router bare', () => {
// `app_pkg/main.py` mounts billing at /billing through an import the
// resolver cannot bind (`from . import billing`); `tests/test_billing.py`
// mounts it bare. The bare include must not shadow the real prefix.
// Whether the bare test mount also yields `/invoices` is not pinned here.
const names = routeNames();
expect(names).toContain('/billing/invoices');
});
it('joins same-file APIRouter(prefix=…) with router decorator paths', () => {
const names = routeNames();
expect(names).toContain('/local');

View file

@ -20,8 +20,9 @@
* `from ..siblings.calls import …`) are captured.
* • `as`-aliased imports route the prefix to the alias, not to
* `router`.
* • Nothing is emitted when `include_router` is absent or has no
* `prefix=` keyword.
* • Unprefixed includes from any host are captured as `prefix: ''`
* propagation edges; calls whose `prefix=` is not a string literal
* are not captured.
*/
import { describe, it, expect } from 'vitest';
@ -34,6 +35,7 @@ import {
type ExtractedRouterImport,
type ExtractedRouterModuleAlias,
} from '../../src/core/ingestion/route-extractors/fastapi-router-bindings.js';
import { resolveFastAPIRouterPrefixes } from '../../src/core/ingestion/route-extractors/fastapi-router-prefixes.js';
function run(filePath: string, content: string) {
const includes: ExtractedRouterInclude[] = [];
@ -213,6 +215,153 @@ describe('extractFastAPIRouterBindings — Shape B (bare local name)', () => {
});
});
describe('nested FastAPI router prefix resolution', () => {
it('follows relative package and child imports without basename bleed', () => {
const root = runFull(
'src/one/app.py',
"from .api import router as api_router\napp.include_router(api_router, prefix='/api')",
);
const pkg = runFull(
'src/one/api/__init__.py',
'from .models import router as models_router\nrouter.include_router(models_router)',
);
const resolved = resolveFastAPIRouterPrefixes(
[
'src/one/app.py',
'src/one/api/__init__.py',
'src/one/api/models.py',
'src/two/api/models.py',
],
[...root.includes, ...pkg.includes],
[...root.imports, ...pkg.imports],
[...root.moduleAliases, ...pkg.moduleAliases],
);
expect(resolved.prefixesByFile.get('src/one/api/models.py')).toEqual(new Set(['/api']));
expect(resolved.prefixesByFile.has('src/two/api/models.py')).toBe(false);
expect(pkg.includes[0]).toMatchObject({
host: 'router',
routerExpr: 'models_router',
prefix: '',
});
});
it('declines an absolute import shared by multiple source roots', () => {
const root = runFull(
'main.py',
"from api.models import router as models_router\napp.include_router(models_router, prefix='/api')",
);
const resolved = resolveFastAPIRouterPrefixes(
['main.py', 'one/api/models.py', 'two/api/models.py'],
root.includes,
root.imports,
root.moduleAliases,
);
expect(resolved.prefixesByFile.size).toBe(0);
expect(resolved.resolvedIncludes.size).toBe(0);
});
});
describe('nested FastAPI router prefix resolution — edge cases', () => {
function resolveSources(sources: Record<string, string>) {
const parts = Object.entries(sources).map(([file, src]) => runFull(file, src));
return resolveFastAPIRouterPrefixes(
Object.keys(sources),
parts.flatMap((p) => p.includes),
parts.flatMap((p) => p.imports),
parts.flatMap((p) => p.moduleAliases),
parts.flatMap((p) => p.constructorPrefixes),
);
}
it('joins the parent APIRouter(prefix=...) between the mount and the child', () => {
const resolved = resolveSources({
'main.py':
"from api import router as api_router\napp.include_router(api_router, prefix='/api')",
'api/__init__.py': [
'from .agents import router as agents_router',
"router = APIRouter(prefix='/v1')",
'router.include_router(agents_router)',
].join('\n'),
'api/agents.py': 'router = APIRouter()',
});
expect(resolved.prefixesByFile.get('api/agents.py')).toEqual(new Set(['/api/v1']));
});
it('carries the parent APIRouter(prefix=...) when the parent is mounted bare', () => {
const resolved = resolveSources({
'main.py': 'from api import router as api_router\napp.include_router(api_router)',
'api/__init__.py': [
'from .agents import router as agents_router',
'from .models import router as models_router',
"router = APIRouter(prefix='/v1')",
'router.include_router(agents_router)',
'router.include_router(models_router)',
].join('\n'),
'api/agents.py': 'router = APIRouter()',
'api/models.py': 'router = APIRouter()',
});
expect(resolved.prefixesByFile.get('api/agents.py')).toEqual(new Set(['/v1']));
expect(resolved.prefixesByFile.has('api/__init__.py')).toBe(false);
});
it('records nothing for an all-empty bare chain', () => {
const resolved = resolveSources({
'main.py': 'from api import router as api_router\napp.include_router(api_router)',
'api/__init__.py':
'from .agents import router as agents_router\nrouter.include_router(agents_router)',
'api/agents.py': 'router = APIRouter()',
});
expect(resolved.prefixesByFile.size).toBe(0);
expect(resolved.resolvedIncludes.size).toBe(0);
});
it('expands a deep diamond-shaped include graph once per (file, prefix)', () => {
// 40 layers of two routers that each include both routers of the next
// layer: 2^39 root-to-leaf paths, 80 distinct files.
const layers = 40;
const sources: Record<string, string> = {
'main.py': "from pkg.l0_0 import router as root\napp.include_router(root, prefix='/api')",
};
for (let i = 0; i < layers; i++) {
for (const j of [0, 1]) {
sources[`pkg/l${i}_${j}.py`] =
i === layers - 1
? 'router = APIRouter()'
: [
`from .l${i + 1}_0 import router as a`,
`from .l${i + 1}_1 import router as b`,
'router.include_router(a)',
'router.include_router(b)',
].join('\n');
}
}
const resolved = resolveSources(sources);
expect(resolved.prefixesByFile.get(`pkg/l${layers - 1}_1.py`)).toEqual(new Set(['/api']));
});
it('passes a prefix through only a host literally named `router`', () => {
const resolved = resolveSources({
'main.py': "from api import api_router\napp.include_router(api_router.router, prefix='/api')",
'api/api_router.py': [
'from .items import router as items_router',
'api_router = APIRouter()',
'api_router.include_router(items_router)',
].join('\n'),
'api/items.py': 'router = APIRouter()',
});
expect(resolved.prefixesByFile.get('api/api_router.py')).toEqual(new Set(['/api']));
expect(resolved.prefixesByFile.has('api/items.py')).toBe(false);
});
});
describe('extractFastAPIRouterBindings — relative imports', () => {
it('captures single-dot relative imports (`from .calls import router as …`)', () => {
// FINDING 2: the previous regex `[A-Za-z_][\w.]*` rejected
@ -269,12 +418,40 @@ describe('extractFastAPIRouterBindings — negative cases', () => {
expect(imports).toEqual([]);
});
it('does not capture include_router calls without a prefix= keyword', () => {
it('captures unprefixed include_router calls with other keywords as propagation edges', () => {
const { includes } = run(
'main.py',
['app.include_router(users.router, tags=["users"])', ''].join('\n'),
[
'app.include_router(users.router, tags=["users"])',
'router.include_router(',
' items_router,',
')',
'',
].join('\n'),
);
expect(includes).toEqual([]);
expect(includes.map(({ host, routerExpr, prefix }) => ({ host, routerExpr, prefix }))).toEqual([
{ host: 'app', routerExpr: 'users.router', prefix: '' },
{ host: 'router', routerExpr: 'items_router', prefix: '' },
]);
});
it('captures prefix= after nested-call arguments without an extra unprefixed edge', () => {
const { includes } = run(
'main.py',
[
'app.include_router(users.router, tags=["users"], prefix="/users")',
'app.include_router(items.router, dependencies=[Depends(auth)], prefix="/items")',
'app.include_router(orders_router, dependencies=[Depends(auth)], prefix="/orders")',
'router.include_router(audit_router, dependencies=[Depends(auth)])',
'',
].join('\n'),
);
expect(includes.map(({ routerExpr, prefix }) => ({ routerExpr, prefix }))).toEqual([
{ routerExpr: 'users.router', prefix: '/users' },
{ routerExpr: 'items.router', prefix: '/items' },
{ routerExpr: 'orders_router', prefix: '/orders' },
{ routerExpr: 'audit_router', prefix: '' },
]);
});
it('does not capture include_router calls with a non-string prefix', () => {

View file

@ -7162,6 +7162,146 @@ async def concurrent():
expect(providers.find((c) => c.contractId === 'http::GET::/ai/concurrent')).toBeDefined();
});
it('carries a package-router mount across unprefixed child includes without basename bleed', async () => {
const dir = path.resolve(__dirname, '../../fixtures/fastapi-prefix-app');
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const ids = new Set(contracts.filter((c) => c.role === 'provider').map((c) => c.contractId));
expect(ids).toContain('http::GET::/api/agents');
expect(ids).toContain('http::GET::/api/models');
expect(ids).toContain('http::GET::/api/v1/models');
expect(ids).not.toContain('http::GET::/agents');
expect(ids).not.toContain('http::GET::/models');
expect(ids).toContain('http::GET::/model-audit');
expect(ids).not.toContain('http::GET::/api/model-audit');
expect(ids).not.toContain('http::GET::/v1/model-audit');
expect(ids).not.toContain('http::GET::/api/v1/model-audit');
});
describe('nested FastAPI router prefixes (#3408)', () => {
async function providerIds(name: string, files: Record<string, string>) {
const dir = path.join(tmpDir, name);
for (const [rel, src] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), src);
}
const contracts = await extractor.extract(null, dir, makeRepo(dir));
return new Set(contracts.filter((c) => c.role === 'provider').map((c) => c.contractId));
}
const listRoute =
'from fastapi import APIRouter\nrouter = APIRouter()\n\n@router.get("/list")\nasync def list_all():\n return []\n';
it('keeps an unresolved legacy mount alongside an import-resolved mount of the same file', async () => {
const ids = await providerIds('fastapi-nested-legacy-union', {
'main.py': [
'import api.users as users',
'from api import router as api_router',
'app.include_router(users.router, prefix="/legacy")',
'app.include_router(api_router, prefix="/api")',
'',
].join('\n'),
'api/__init__.py': [
'from fastapi import APIRouter',
'from .users import router as users_router',
'router = APIRouter()',
'router.include_router(users_router, prefix="/users")',
'',
].join('\n'),
'api/users.py': listRoute,
});
expect(ids).toContain('http::GET::/api/users/list');
expect(ids).toContain('http::GET::/legacy/list');
expect(ids).not.toContain('http::GET::/users/list');
});
it('joins the parent APIRouter(prefix=...) into child pass-through', async () => {
const ids = await providerIds('fastapi-nested-parent-ctor', {
'main.py':
'from api import router as api_router\napp.include_router(api_router, prefix="/api")\n',
'api/__init__.py': [
'from fastapi import APIRouter',
'from .agents import router as agents_router',
'router = APIRouter(prefix="/v1")',
'router.include_router(agents_router)',
'',
].join('\n'),
'api/agents.py': listRoute,
});
expect(ids).toContain('http::GET::/api/v1/list');
expect(ids).not.toContain('http::GET::/api/list');
});
it('counts empty files when judging absolute-import ambiguity, like ingestion', async () => {
// `tests/api/__init__.py` is empty but still makes `api` ambiguous.
// Ingestion resolves over every scanned path, so both layers decline.
const ids = await providerIds('fastapi-nested-empty-init', {
'main.py':
'from api import router as api_router\napp.include_router(api_router, prefix="/api")\n',
'api/__init__.py': [
'from fastapi import APIRouter',
'from .agents import router as agents_router',
'router = APIRouter()',
'router.include_router(agents_router)',
'',
].join('\n'),
'api/agents.py': listRoute,
'tests/api/__init__.py': '',
});
expect(ids).toContain('http::GET::/list');
expect(ids).not.toContain('http::GET::/api/list');
});
it('carries a bare-mounted parent APIRouter(prefix=...) to unprefixed children', async () => {
const ids = await providerIds('fastapi-nested-bare-parent-ctor', {
'main.py': 'from api import router as api_router\napp.include_router(api_router)\n',
'api/__init__.py': [
'from fastapi import APIRouter',
'from .agents import router as agents_router',
'router = APIRouter(prefix="/v1")',
'router.include_router(agents_router)',
'',
].join('\n'),
'api/agents.py': listRoute,
});
expect(ids).toContain('http::GET::/v1/list');
expect(ids).not.toContain('http::GET::/list');
});
it('keeps an include prefix written after dependencies=[Depends(...)]', async () => {
const ids = await providerIds('fastapi-include-depends-prefix', {
'main.py': [
'from api import items',
'app.include_router(items.router, dependencies=[Depends(auth)], prefix="/items")',
'',
].join('\n'),
'api/items.py': listRoute,
});
expect(ids).toContain('http::GET::/items/list');
expect(ids).not.toContain('http::GET::/list');
});
it('applies a child include prefix under a parent mounted without one', async () => {
const ids = await providerIds('fastapi-nested-bare-parent', {
'main.py': 'from api import router as api_router\napp.include_router(api_router)\n',
'api/__init__.py': [
'from fastapi import APIRouter',
'from .agents import router as agents_router',
'router = APIRouter()',
'router.include_router(agents_router, prefix="/v1")',
'',
].join('\n'),
'api/agents.py': listRoute,
});
expect(ids).toContain('http::GET::/v1/list');
});
});
it('joins FastAPI @router.<verb> path with APIRouter(prefix=...) in the same file', async () => {
const dir = path.join(tmpDir, 'fastapi-router-constructor-prefix');
fs.mkdirSync(path.join(dir, 'api'), { recursive: true });

View file

@ -300,8 +300,9 @@ describe('PARSE_CACHE_VERSION', () => {
// Moved 117 -> 118 for #3398, 118 -> 119 for #3396, and 119 -> 120 for #3394.
// Moved 120 -> 121 for the #3399 decorator-identity follow-up.
// Moved 121 -> 122 for #3414 restoring helper calls.
it('pins SCHEMA_BUMP to 122 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371, #2965, #3390, #3398, #3396, #3394, #3399, #3414)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(122);
// Moved 122 -> 123 for #3408 FastAPI nested router-prefix capture fields.
it('pins SCHEMA_BUMP to 123 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371, #2965, #3390, #3398, #3396, #3394, #3399, #3414, #3408)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(123);
expect(PARSE_CACHE_BUCKET_COUNT).toBe(128);
// The PREVIOUS version must fail the reuse gate, not merely differ from the
// current one — a hardcoded number outside the conflict hunk rebases cleanly
@ -310,7 +311,7 @@ describe('PARSE_CACHE_VERSION', () => {
for (const taken of [
59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81,
82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103,
104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121,
104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122,
]) {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken);
}