From acb65b95b62647b19781caa60afd17d3bed23d4a Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Tue, 29 Sep 2026 14:49:44 +0100 Subject: [PATCH] fix(fastapi): propagate package router mount prefixes (#3408) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) --------- Co-authored-by: Gergő Magyar Co-authored-by: Gergo Magyar Co-authored-by: Claude Opus 5.5 (1M context) --- .../group/extractors/http-patterns/python.ts | 67 +++++-- .../ingestion/pipeline-phases/parse-impl.ts | 33 +++- .../fastapi-router-bindings.ts | 77 +++++--- .../fastapi-router-prefixes.ts | 186 +++++++++++++++++ gitnexus/src/storage/parse-cache.ts | 5 +- .../fastapi-prefix-app/admin/models.py | 7 + .../fastapi-prefix-app/api/__init__.py | 8 + .../fixtures/fastapi-prefix-app/api/agents.py | 7 + .../fixtures/fastapi-prefix-app/api/models.py | 7 + .../fastapi-prefix-app/app_pkg/billing.py | 8 + .../fastapi-prefix-app/app_pkg/main.py | 5 + .../test/fixtures/fastapi-prefix-app/main.py | 2 + .../fastapi-prefix-app/tests/test_billing.py | 6 + .../fastapi-prefix-pipeline.test.ts | 22 +++ .../test/unit/fastapi-router-bindings.test.ts | 187 +++++++++++++++++- .../unit/group/http-route-extractor.test.ts | 140 +++++++++++++ .../test/unit/incremental-parse-cache.test.ts | 7 +- 17 files changed, 720 insertions(+), 54 deletions(-) create mode 100644 gitnexus/src/core/ingestion/route-extractors/fastapi-router-prefixes.ts create mode 100644 gitnexus/test/fixtures/fastapi-prefix-app/admin/models.py create mode 100644 gitnexus/test/fixtures/fastapi-prefix-app/api/__init__.py create mode 100644 gitnexus/test/fixtures/fastapi-prefix-app/api/agents.py create mode 100644 gitnexus/test/fixtures/fastapi-prefix-app/api/models.py create mode 100644 gitnexus/test/fixtures/fastapi-prefix-app/app_pkg/billing.py create mode 100644 gitnexus/test/fixtures/fastapi-prefix-app/app_pkg/main.py create mode 100644 gitnexus/test/fixtures/fastapi-prefix-app/tests/test_billing.py diff --git a/gitnexus/src/core/group/extractors/http-patterns/python.ts b/gitnexus/src/core/group/extractors/http-patterns/python.ts index 7a0fa2f7f..497840fbb 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/python.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/python.ts @@ -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 — `/` (`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>; /** `/` → set of prefixes (precise, package-aware) */ prefixesByLongKey: Map>; /** stem only → set of prefixes (basename fallback, may collide) */ @@ -992,6 +1000,10 @@ function buildPythonRepoContext( ): PythonRepoContext { const prefixesByLongKey = new Map>(); const prefixesByShortKey = new Map>(); + 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; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts index 96472e477..59a0574b7 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts @@ -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>(); const prefixesByShortKey = new Map>(); // 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(); const constructorPrefixesByShortKey = new Map(); + const { prefixesByFile, resolvedIncludes } = resolveFastAPIRouterPrefixes( + allPaths, + allRouterIncludes, + allRouterImports, + allRouterModuleAliases, + allRouterConstructorPrefixes, + ); const recordPrefix = (target: Map>, 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: `.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` diff --git a/gitnexus/src/core/ingestion/route-extractors/fastapi-router-bindings.ts b/gitnexus/src/core/ingestion/route-extractors/fastapi-router-bindings.ts index 028f19f4b..5cc93e715 100644 --- a/gitnexus/src/core/ingestion/route-extractors/fastapi-router-bindings.ts +++ b/gitnexus/src/core/ingestion/route-extractors/fastapi-router-bindings.ts @@ -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 - * `.include_router(, prefix='/x')` site, where - * `` is either `.router` (Shape A) or a bare - * local name (Shape B). `` 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. + * `.include_router(, prefix='/x')` mount, plus + * unprefixed `.include_router([, ])` + * calls recorded with `prefix: ''`. `` is either + * `.router` (Shape A) or a bare local name (Shape B). The + * extractor leaves `` 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 import router [as ]`, captured for both @@ -37,10 +39,8 @@ * • short key — basename without `.py` (`users`) * • long key — `/` (`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 `.include_router(, prefix='/x')` site. + * One `.include_router(, prefix='/x')` site, or a + * simple child include without a local prefix. * * `routerExpr` is the raw text of the first argument — either * `.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 (`/`) — non-empty for every emitted record. */ moduleKeyLong: string; } @@ -113,13 +117,23 @@ export interface ExtractedRouterConstructorPrefix { // `.include_router(.router, ..., prefix='/x')` (Shape A). // `` 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; // `.include_router(, ..., 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 `.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, }); } diff --git a/gitnexus/src/core/ingestion/route-extractors/fastapi-router-prefixes.ts b/gitnexus/src/core/ingestion/route-extractors/fastapi-router-prefixes.ts new file mode 100644 index 000000000..634920669 --- /dev/null +++ b/gitnexus/src/core/ingestion/route-extractors/fastapi-router-prefixes.ts @@ -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>; + resolvedIncludes: Set; +} + +/** Resolve only imports that identify one Python file in this repository. */ +function resolveModuleFile( + importer: string, + modulePath: string, + files: Set, + absoluteModules: Map, +): 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 | undefined, + legacy: ReadonlySet | undefined, +): ReadonlySet | 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, + 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(); + 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>(); + 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(); + bindings.set(imp.localName, { modulePath }); + importsByFile.set(file, bindings); + } + + const prefixesByFile = new Map>(); + const resolvedIncludes = new Set(); + const childIncludes = new Map< + string, + { target: string; prefix: string; include: ExtractedRouterInclude }[] + >(); + const bareMounts = new Set(); + + 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(); + 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(); + 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(); + 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 }; +} diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index c969ed3c7..456036162 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -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 diff --git a/gitnexus/test/fixtures/fastapi-prefix-app/admin/models.py b/gitnexus/test/fixtures/fastapi-prefix-app/admin/models.py new file mode 100644 index 000000000..f5eff55c7 --- /dev/null +++ b/gitnexus/test/fixtures/fastapi-prefix-app/admin/models.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +router = APIRouter() + +@router.get("/model-audit") +async def audit_models(): + return [] diff --git a/gitnexus/test/fixtures/fastapi-prefix-app/api/__init__.py b/gitnexus/test/fixtures/fastapi-prefix-app/api/__init__.py new file mode 100644 index 000000000..783c02a98 --- /dev/null +++ b/gitnexus/test/fixtures/fastapi-prefix-app/api/__init__.py @@ -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") diff --git a/gitnexus/test/fixtures/fastapi-prefix-app/api/agents.py b/gitnexus/test/fixtures/fastapi-prefix-app/api/agents.py new file mode 100644 index 000000000..e6e390510 --- /dev/null +++ b/gitnexus/test/fixtures/fastapi-prefix-app/api/agents.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +router = APIRouter() + +@router.get("/agents") +async def list_agents(): + return [] diff --git a/gitnexus/test/fixtures/fastapi-prefix-app/api/models.py b/gitnexus/test/fixtures/fastapi-prefix-app/api/models.py new file mode 100644 index 000000000..190b4fd66 --- /dev/null +++ b/gitnexus/test/fixtures/fastapi-prefix-app/api/models.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +router = APIRouter() + +@router.get("/models") +async def list_models(): + return [] diff --git a/gitnexus/test/fixtures/fastapi-prefix-app/app_pkg/billing.py b/gitnexus/test/fixtures/fastapi-prefix-app/app_pkg/billing.py new file mode 100644 index 000000000..b2caf3146 --- /dev/null +++ b/gitnexus/test/fixtures/fastapi-prefix-app/app_pkg/billing.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/invoices") +async def list_invoices(): + return [] diff --git a/gitnexus/test/fixtures/fastapi-prefix-app/app_pkg/main.py b/gitnexus/test/fixtures/fastapi-prefix-app/app_pkg/main.py new file mode 100644 index 000000000..687cb8c4e --- /dev/null +++ b/gitnexus/test/fixtures/fastapi-prefix-app/app_pkg/main.py @@ -0,0 +1,5 @@ +from fastapi import FastAPI +from . import billing + +app = FastAPI() +app.include_router(billing.router, prefix="/billing") diff --git a/gitnexus/test/fixtures/fastapi-prefix-app/main.py b/gitnexus/test/fixtures/fastapi-prefix-app/main.py index 0c78784ba..f54b92191 100644 --- a/gitnexus/test/fixtures/fastapi-prefix-app/main.py +++ b/gitnexus/test/fixtures/fastapi-prefix-app/main.py @@ -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") diff --git a/gitnexus/test/fixtures/fastapi-prefix-app/tests/test_billing.py b/gitnexus/test/fixtures/fastapi-prefix-app/tests/test_billing.py new file mode 100644 index 000000000..7ab6390d5 --- /dev/null +++ b/gitnexus/test/fixtures/fastapi-prefix-app/tests/test_billing.py @@ -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) diff --git a/gitnexus/test/integration/fastapi-prefix-pipeline.test.ts b/gitnexus/test/integration/fastapi-prefix-pipeline.test.ts index 9c642f79d..0450f123b 100644 --- a/gitnexus/test/integration/fastapi-prefix-pipeline.test.ts +++ b/gitnexus/test/integration/fastapi-prefix-pipeline.test.ts @@ -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'); diff --git a/gitnexus/test/unit/fastapi-router-bindings.test.ts b/gitnexus/test/unit/fastapi-router-bindings.test.ts index 3edbf9481..5dacd46af 100644 --- a/gitnexus/test/unit/fastapi-router-bindings.test.ts +++ b/gitnexus/test/unit/fastapi-router-bindings.test.ts @@ -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) { + 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 = { + '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', () => { diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index b59ca8a3b..d623b521a 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -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) { + 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. 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 }); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index 138bbe858..149e088a9 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -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); }