mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
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
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:
parent
aa0f41e853
commit
acb65b95b6
17 changed files with 720 additions and 54 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
7
gitnexus/test/fixtures/fastapi-prefix-app/admin/models.py
vendored
Normal file
7
gitnexus/test/fixtures/fastapi-prefix-app/admin/models.py
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/model-audit")
|
||||
async def audit_models():
|
||||
return []
|
||||
8
gitnexus/test/fixtures/fastapi-prefix-app/api/__init__.py
vendored
Normal file
8
gitnexus/test/fixtures/fastapi-prefix-app/api/__init__.py
vendored
Normal 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")
|
||||
7
gitnexus/test/fixtures/fastapi-prefix-app/api/agents.py
vendored
Normal file
7
gitnexus/test/fixtures/fastapi-prefix-app/api/agents.py
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/agents")
|
||||
async def list_agents():
|
||||
return []
|
||||
7
gitnexus/test/fixtures/fastapi-prefix-app/api/models.py
vendored
Normal file
7
gitnexus/test/fixtures/fastapi-prefix-app/api/models.py
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/models")
|
||||
async def list_models():
|
||||
return []
|
||||
8
gitnexus/test/fixtures/fastapi-prefix-app/app_pkg/billing.py
vendored
Normal file
8
gitnexus/test/fixtures/fastapi-prefix-app/app_pkg/billing.py
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/invoices")
|
||||
async def list_invoices():
|
||||
return []
|
||||
5
gitnexus/test/fixtures/fastapi-prefix-app/app_pkg/main.py
vendored
Normal file
5
gitnexus/test/fixtures/fastapi-prefix-app/app_pkg/main.py
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from fastapi import FastAPI
|
||||
from . import billing
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(billing.router, prefix="/billing")
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
6
gitnexus/test/fixtures/fastapi-prefix-app/tests/test_billing.py
vendored
Normal file
6
gitnexus/test/fixtures/fastapi-prefix-app/tests/test_billing.py
vendored
Normal 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)
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue