diff --git a/gitnexus/src/core/group/extractors/http-patterns/python.ts b/gitnexus/src/core/group/extractors/http-patterns/python.ts index e432a958ac..296a9c4047 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/python.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/python.ts @@ -6,6 +6,7 @@ import { unquoteLiteral, type LanguagePatterns, } from '../tree-sitter-scanner.js'; +import { normalizeExtractedRoutePath } from '../../../ingestion/route-extractors/route-path.js'; import type { HttpDetection, HttpLanguagePlugin, RepoContext } from './types.js'; /** @@ -162,6 +163,26 @@ const INCLUDE_ROUTER_NAME_PATTERNS = compilePatterns({ ], } satisfies LanguagePatterns>); +const API_ROUTER_PREFIX_PATTERNS = compilePatterns({ + name: 'python-fastapi-apirouter-prefix', + language: Python, + patterns: [ + { + meta: {}, + query: ` + (assignment + left: (identifier) @router_name (#eq? @router_name "router") + right: (call + function: (identifier) @factory (#eq? @factory "APIRouter") + arguments: (argument_list + (keyword_argument + name: (identifier) @kw (#eq? @kw "prefix") + value: (string) @prefix)))) + `, + }, + ], +} satisfies LanguagePatterns>); + // `from .api.assistant import router` style — used together with // INCLUDE_ROUTER_NAME so we can map a local name back to its module // path, then back to the file the router was declared in. @@ -905,10 +926,10 @@ function buildPythonRepoContext( const prefixesByLongKey = new Map>(); const prefixesByShortKey = new Map>(); - // Pre-pass over .py files. We deliberately run this even on files - // that don't contain `include_router` — the cost of an extra parse - // is bounded by the file count, and detecting `include_router` - // beforehand would require its own grep/scan. + // Cross-file pre-pass: only `include_router` sites need it — they bind a + // prefix declared in one file to a router defined in another. Same-file + // `APIRouter(prefix=...)` is resolved in scan() from the file's own tree, so + // APIRouter-only files are left out here and never parsed twice. for (const rel of files) { if (!rel.endsWith('.py')) continue; const src = readFile(rel); @@ -1001,15 +1022,17 @@ function buildPythonRepoContext( } } - return { prefixesByLongKey, prefixesByShortKey }; + return { + prefixesByLongKey, + prefixesByShortKey, + }; } function joinPrefix(prefix: string, route: string): string { - // Mirror FastAPI's path joining: trim trailing slash off prefix, - // ensure exactly one leading slash on the result. - const p = prefix.replace(/\/+$/, ''); - const r = route.startsWith('/') ? route : `/${route}`; - return `${p}${r}`; + // Delegate to the shared route-path normalizer so the group contract and the + // ingestion Route node join prefixes identically — one helper, no + // trailing-slash drift on empty routes (`APIRouter(prefix="/x")` + `@get("")`). + return normalizeExtractedRoutePath(route, prefix); } export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = { name: 'python-http', @@ -1071,6 +1094,18 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = { // the ingestion route extractor), not a per-file source scan — see the note // at the top of this file. + // Same-file `router = APIRouter(prefix="/x")` (router-only). Read from this + // file's own tree, so there is no cross-file map and no prefix bleed across + // same-stem files; it stacks under any include_router(prefix=...) below. + let constructorPrefix: string | undefined; + for (const m of runCompiledPatterns(API_ROUTER_PREFIX_PATTERNS, tree)) { + const prefixNode = m.captures.prefix; + if (!prefixNode) continue; + const p = unquoteLiteral(prefixNode.text); + if (p === null) continue; + constructorPrefix = p; + } + // Providers: FastAPI @router.("/path") — must be joined // with the prefix(es) declared at the include_router site. When // no prefix is found we still emit the unprefixed path so this @@ -1095,10 +1130,13 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = { const shortPrefixes = longPrefixes || !shortKey ? undefined : ctx?.prefixesByShortKey.get(shortKey); const prefixSet = longPrefixes ?? shortPrefixes; + // Stack the same-file APIRouter(prefix=...) under any cross-file + // include_router prefix. + const localPath = constructorPrefix ? joinPrefix(constructorPrefix, rawPath) : rawPath; const paths = prefixSet && prefixSet.size > 0 - ? [...prefixSet].map((p) => joinPrefix(p, rawPath)) - : [rawPath]; + ? [...prefixSet].map((p) => joinPrefix(p, localPath)) + : [localPath]; for (const p of paths) { out.push({ diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index d1a4710e9a..51b9a62be6 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -22,6 +22,7 @@ import type { FetchWrapperDef, } from './workers/parse-worker.js'; import type { + ExtractedRouterConstructorPrefix, ExtractedRouterImport, ExtractedRouterInclude, ExtractedRouterModuleAlias, @@ -37,6 +38,7 @@ export interface WorkerExtractedData { decoratorRoutes: ExtractedDecoratorRoute[]; routerIncludes: ExtractedRouterInclude[]; routerImports: ExtractedRouterImport[]; + routerConstructorPrefixes: ExtractedRouterConstructorPrefix[]; routerModuleAliases: ExtractedRouterModuleAlias[]; toolDefs: ExtractedToolDef[]; ormQueries: ExtractedORMQuery[]; @@ -80,6 +82,7 @@ export const mergeChunkResults = ( const allDecoratorRoutes: ExtractedDecoratorRoute[] = []; const allRouterIncludes: ExtractedRouterInclude[] = []; const allRouterImports: ExtractedRouterImport[] = []; + const allRouterConstructorPrefixes: ExtractedRouterConstructorPrefix[] = []; const allRouterModuleAliases: ExtractedRouterModuleAlias[] = []; const allSpringTypes: SharedSpringType[] = []; const allToolDefs: ExtractedToolDef[] = []; @@ -123,6 +126,9 @@ export const mergeChunkResults = ( for (const item of result.decoratorRoutes) allDecoratorRoutes.push(item); for (const item of result.routerIncludes ?? []) allRouterIncludes.push(item); for (const item of result.routerImports ?? []) allRouterImports.push(item); + for (const item of result.routerConstructorPrefixes ?? []) { + allRouterConstructorPrefixes.push(item); + } for (const item of result.routerModuleAliases ?? []) allRouterModuleAliases.push(item); for (const item of result.springTypes ?? []) allSpringTypes.push(item); for (const item of result.toolDefs) allToolDefs.push(item); @@ -139,6 +145,7 @@ export const mergeChunkResults = ( decoratorRoutes: allDecoratorRoutes, routerIncludes: allRouterIncludes, routerImports: allRouterImports, + routerConstructorPrefixes: allRouterConstructorPrefixes, routerModuleAliases: allRouterModuleAliases, toolDefs: allToolDefs, ormQueries: allORMQueries, diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts index f67e6e7214..618a6fc47a 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts @@ -76,10 +76,12 @@ import type { FetchWrapperDef, } from '../workers/parse-worker.js'; import type { + ExtractedRouterConstructorPrefix, ExtractedRouterImport, ExtractedRouterInclude, ExtractedRouterModuleAlias, } from '../route-extractors/fastapi-router-bindings.js'; +import { normalizeExtractedRoutePath } from '../route-extractors/route-path.js'; import { resolveInheritedSpringRoutes, type SharedSpringType, @@ -623,6 +625,7 @@ export async function runChunkedParseAndResolve( const allDecoratorRoutes: ExtractedDecoratorRoute[] = []; const allRouterIncludes: ExtractedRouterInclude[] = []; const allRouterImports: ExtractedRouterImport[] = []; + const allRouterConstructorPrefixes: ExtractedRouterConstructorPrefix[] = []; const allRouterModuleAliases: ExtractedRouterModuleAlias[] = []; const allSpringTypes: SharedSpringType[] = []; const allToolDefs: ExtractedToolDef[] = []; @@ -779,6 +782,11 @@ export async function runChunkedParseAndResolve( if (chunkWorkerData.routerImports?.length) { for (const item of chunkWorkerData.routerImports) allRouterImports.push(item); } + if (chunkWorkerData.routerConstructorPrefixes?.length) { + for (const item of chunkWorkerData.routerConstructorPrefixes) { + allRouterConstructorPrefixes.push(item); + } + } if (chunkWorkerData.routerModuleAliases?.length) { for (const item of chunkWorkerData.routerModuleAliases) allRouterModuleAliases.push(item); } @@ -1156,7 +1164,10 @@ export async function runChunkedParseAndResolve( // decorator inherits its file-basename's prefix. When a router is mounted // under multiple prefixes we duplicate the route entry, mirroring FastAPI's // runtime behaviour. - if (allRouterIncludes.length > 0 && allDecoratorRoutes.length > 0) { + if ( + (allRouterIncludes.length > 0 || allRouterConstructorPrefixes.length > 0) && + allDecoratorRoutes.length > 0 + ) { // Group `routerImports` by file so we can resolve Shape-B locals against // imports declared in the SAME file as the include_router call. We carry // both the short module key (file basename) and, when available, the long @@ -1201,6 +1212,11 @@ export async function runChunkedParseAndResolve( // without a corresponding import statement). const prefixesByLongKey = new Map>(); const prefixesByShortKey = new Map>(); + // Constructor prefixes are `router`-only (the apply gate below and the + // group-layer tree-sitter both pin to the literal name `router`), so a + // flat file-key → prefix map suffices — mirrors the group layer's shape. + const constructorPrefixesByLongKey = new Map(); + const constructorPrefixesByShortKey = new Map(); const recordPrefix = (target: Map>, key: string, prefix: string): void => { let set = target.get(key); @@ -1241,7 +1257,11 @@ export async function runChunkedParseAndResolve( } } - if (prefixesByLongKey.size > 0 || prefixesByShortKey.size > 0) { + if ( + prefixesByLongKey.size > 0 || + prefixesByShortKey.size > 0 || + allRouterConstructorPrefixes.length > 0 + ) { const fileLongKey = (rel: string): string => { // Strip `.py`, then take the last two path segments. `api/users.py` // → `api/users`. Files at the repo root return the empty string, @@ -1263,6 +1283,15 @@ export async function runChunkedParseAndResolve( return file.endsWith('.py') ? file.slice(0, -3) : file; }; + for (const ctor of allRouterConstructorPrefixes) { + const longKey = fileLongKey(ctor.filePath); + if (longKey) { + constructorPrefixesByLongKey.set(longKey, ctor.prefix); + } else { + constructorPrefixesByShortKey.set(fileShortKey(ctor.filePath), ctor.prefix); + } + } + const expanded: ExtractedDecoratorRoute[] = []; for (const dr of allDecoratorRoutes) { if (dr.decoratorReceiver !== 'router' || !dr.filePath.endsWith('.py')) { @@ -1278,12 +1307,24 @@ export async function runChunkedParseAndResolve( ? undefined : prefixesByShortKey.get(fileShortKey(dr.filePath)); const prefixes = 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` + // returns ''. Do not fall back from a missing long-key match to the + // short key or a root `users.py` prefix can leak onto + // `admin/users.py`. + const constructorPrefix = longKey + ? constructorPrefixesByLongKey.get(longKey) + : constructorPrefixesByShortKey.get(fileShortKey(dr.filePath)); + const routePath = constructorPrefix + ? normalizeExtractedRoutePath(dr.routePath, constructorPrefix) + : dr.routePath; if (!prefixes || prefixes.size === 0) { - expanded.push(dr); + expanded.push(routePath === dr.routePath ? dr : { ...dr, routePath }); continue; } for (const prefix of prefixes) { - expanded.push({ ...dr, prefix }); + expanded.push({ ...dr, routePath, prefix }); } } allDecoratorRoutes.length = 0; 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 34e2ded046..028f19f4ba 100644 --- a/gitnexus/src/core/ingestion/route-extractors/fastapi-router-bindings.ts +++ b/gitnexus/src/core/ingestion/route-extractors/fastapi-router-bindings.ts @@ -105,6 +105,11 @@ export interface ExtractedRouterModuleAlias { moduleKeyLong: string; } +export interface ExtractedRouterConstructorPrefix { + filePath: string; + prefix: string; +} + // `.include_router(.router, ..., prefix='/x')` (Shape A). // `` is left unrestricted — common production names include // `app`, `api`, `application`, `asgi_app`. Pinning to the literal @@ -122,6 +127,8 @@ const INCLUDE_ROUTER_NAME_RE = // The latter is the common case and the only one we can map back to // a module stem. const FROM_IMPORT_ROUTER_RE = /^\s*from\s+(\.+|\.*[A-Za-z_][\w.]*)\s+import\s+([^#\n]+)/gm; +const API_ROUTER_ASSIGN_RE = /\brouter\s*=\s*APIRouter\s*\(/g; +const API_ROUTER_PREFIX_ARG_RE = /\bprefix\s*=\s*(['"])([^'"]*)\1/; /** * Last `.`-separated segment of a (possibly relative) Python module @@ -157,6 +164,35 @@ export function lastTwoSegmentsAsPath(text: string): string { return `${parent}/${stem}`; } +function findMatchingParen(content: string, openIndex: number): number { + let depth = 0; + let quote: string | null = null; + for (let i = openIndex; i < content.length; i++) { + const ch = content[i]; + if (quote) { + if (ch === '\\') { + i++; + continue; + } + if (ch === quote) quote = null; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === '(' || ch === '[' || ch === '{') { + depth++; + continue; + } + if (ch === ')' || ch === ']' || ch === '}') { + depth--; + if (depth === 0 && ch === ')') return i; + } + } + return -1; +} + /** * Scan a single Python file's source text for FastAPI router * `include_router` sites and `from import router` imports, @@ -176,6 +212,7 @@ export function extractFastAPIRouterBindings( outIncludes: ExtractedRouterInclude[], outImports: ExtractedRouterImport[], outModuleAliases?: ExtractedRouterModuleAlias[], + outConstructorPrefixes?: ExtractedRouterConstructorPrefix[], ): void { if (!content.includes('include_router') && !content.includes('router')) return; @@ -239,6 +276,24 @@ export function extractFastAPIRouterBindings( } } + if (outConstructorPrefixes && content.includes('APIRouter') && content.includes('prefix')) { + API_ROUTER_ASSIGN_RE.lastIndex = 0; + // Only `router = APIRouter(...)` is captured (the apply gate and the + // group-layer tree-sitter both pin to the literal name `router`). + while (API_ROUTER_ASSIGN_RE.exec(content) !== null) { + const openParen = API_ROUTER_ASSIGN_RE.lastIndex - 1; + const closeParen = findMatchingParen(content, openParen); + if (closeParen < 0) continue; + const args = content.slice(openParen + 1, closeParen); + const prefixMatch = API_ROUTER_PREFIX_ARG_RE.exec(args); + if (!prefixMatch) continue; + outConstructorPrefixes.push({ + filePath, + prefix: prefixMatch[2], + }); + } + } + if (!content.includes('include_router')) return; // Shape A: `.include_router(.router, prefix='/x')`. diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 9acf347eea..adf3a0db71 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -30,6 +30,7 @@ import { postResultCloneSafe } from './post-result.js'; import { mergeResult } from './result-merge.js'; import type { SymbolTableReader } from '../model/symbol-table.js'; import type { + ExtractedRouterConstructorPrefix, ExtractedRouterInclude, ExtractedRouterImport, ExtractedRouterModuleAlias, @@ -394,6 +395,7 @@ export interface ParseWorkerResult { decoratorRoutes: ExtractedDecoratorRoute[]; routerIncludes: ExtractedRouterInclude[]; routerImports: ExtractedRouterImport[]; + routerConstructorPrefixes?: ExtractedRouterConstructorPrefix[]; /** * Optional. Project-wide `SharedSpringType` view of route-defining * class/interface declarations, produced by the provider's @@ -906,6 +908,7 @@ const processBatch = ( decoratorRoutes: [], routerIncludes: [], routerImports: [], + routerConstructorPrefixes: [], routerModuleAliases: [], toolDefs: [], ormQueries: [], @@ -2423,6 +2426,7 @@ const processFileGroup = ( result.routerIncludes, result.routerImports, (result.routerModuleAliases ??= []), + (result.routerConstructorPrefixes ??= []), ); } @@ -2475,6 +2479,7 @@ let accumulated: ParseWorkerResult = { decoratorRoutes: [], routerIncludes: [], routerImports: [], + routerConstructorPrefixes: [], routerModuleAliases: [], toolDefs: [], ormQueries: [], @@ -2615,6 +2620,7 @@ parentPort!.on('message', (msg: WorkerIncomingMessage) => { decoratorRoutes: [], routerIncludes: [], routerImports: [], + routerConstructorPrefixes: [], routerModuleAliases: [], toolDefs: [], ormQueries: [], diff --git a/gitnexus/src/core/ingestion/workers/result-merge.ts b/gitnexus/src/core/ingestion/workers/result-merge.ts index 33a4f29393..948c44b4ad 100644 --- a/gitnexus/src/core/ingestion/workers/result-merge.ts +++ b/gitnexus/src/core/ingestion/workers/result-merge.ts @@ -37,6 +37,10 @@ export const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult): appendAll(target.decoratorRoutes, src.decoratorRoutes); if (src.routerIncludes) appendAll(target.routerIncludes, src.routerIncludes); if (src.routerImports) appendAll(target.routerImports, src.routerImports); + if (src.routerConstructorPrefixes) { + target.routerConstructorPrefixes ??= []; + appendAll(target.routerConstructorPrefixes, src.routerConstructorPrefixes); + } if (src.routerModuleAliases) { target.routerModuleAliases ??= []; appendAll(target.routerModuleAliases, src.routerModuleAliases); diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 9d2f93fc3f..a896f2964e 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -55,7 +55,7 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // the main thread (the #1983 OOM). Because the two stores share this version, // any future change to the `ParsedFile` serialization shape MUST bump // SCHEMA_BUMP so both invalidate in lockstep. -const SCHEMA_BUMP = 8; // #2288: ParseWorkerResult gained `springTypes` (Spring interface-inheritance) + `decoratorRoutes` semantics changed (interface routes suppressed at extraction, inherited routes appended in parse-impl) +const SCHEMA_BUMP = 9; // #2312: ParseWorkerResult gained `routerConstructorPrefixes` for FastAPI APIRouter(prefix=...) replay 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/api/items.py b/gitnexus/test/fixtures/fastapi-prefix-app/api/items.py new file mode 100644 index 0000000000..0a045098e2 --- /dev/null +++ b/gitnexus/test/fixtures/fastapi-prefix-app/api/items.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter, Depends + + +def get_db(): + return None + + +# Router-level dependency listed BEFORE `prefix=` — exercises the +# balanced-paren scan end-to-end. The old `[^)]*?` regex stopped at the +# `)` of `Depends(...)` and dropped the prefix, leaving the Route node +# unprefixed; this route must still resolve to `/v1/items/{item_id}`. +router = APIRouter(dependencies=[Depends(get_db)], prefix="/items") + + +@router.get("/{item_id}") +def get_item(item_id: str): + return {"id": item_id} diff --git a/gitnexus/test/fixtures/fastapi-prefix-app/local.py b/gitnexus/test/fixtures/fastapi-prefix-app/local.py new file mode 100644 index 0000000000..68b1722521 --- /dev/null +++ b/gitnexus/test/fixtures/fastapi-prefix-app/local.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +router = APIRouter(prefix="/local") + + +@router.get("") +def list_local(): + return [] diff --git a/gitnexus/test/fixtures/fastapi-prefix-app/main.py b/gitnexus/test/fixtures/fastapi-prefix-app/main.py index 8bdffcf6a6..0c78784bad 100644 --- a/gitnexus/test/fixtures/fastapi-prefix-app/main.py +++ b/gitnexus/test/fixtures/fastapi-prefix-app/main.py @@ -1,4 +1,5 @@ from fastapi import FastAPI +from api import items from api import users from api.calls import router as calls_router from .relative import router as rel_router @@ -8,6 +9,7 @@ from .relative import router as rel_router # tree-sitter pattern. Pinning to literal `app` would silently drop # every prefix here. application = FastAPI() +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") diff --git a/gitnexus/test/fixtures/fastapi-prefix-app/users.py b/gitnexus/test/fixtures/fastapi-prefix-app/users.py new file mode 100644 index 0000000000..d33d1c71f7 --- /dev/null +++ b/gitnexus/test/fixtures/fastapi-prefix-app/users.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +router = APIRouter(prefix="/root-users") + + +@router.get("/landing") +def root_users_landing(): + return {} diff --git a/gitnexus/test/integration/fastapi-prefix-pipeline.test.ts b/gitnexus/test/integration/fastapi-prefix-pipeline.test.ts index 7c766a4fb7..9c642f79df 100644 --- a/gitnexus/test/integration/fastapi-prefix-pipeline.test.ts +++ b/gitnexus/test/integration/fastapi-prefix-pipeline.test.ts @@ -30,6 +30,13 @@ * ingestion regex. The group-layer counterpart is pinned by the * `non-app host` cases in `http-route-extractor.test.ts`. * + * 5. **Same-file `APIRouter(prefix=…)`** is applied to `@router` + * decorators, and stacks with an outer `include_router(prefix=…)`. + * + * 6. **Root-file constructor prefixes do not bleed into nested same-stem + * files.** A repo-root `users.py` can use `APIRouter(prefix=…)`, but + * `admin/users.py` must not inherit that constructor prefix. + * * The fixture lives at `test/fixtures/fastapi-prefix-app/` so the * pipeline can scan a real on-disk repo (mirroring how `gitnexus * analyze` is used in production) and so reviewers can inspect the @@ -96,6 +103,24 @@ describe('FastAPI include_router(prefix=…) — ingestion pipeline', () => { expect(names).toContain('/rel/info'); }); + it('joins same-file APIRouter(prefix=…) with router decorator paths', () => { + const names = routeNames(); + expect(names).toContain('/local'); + expect(names).toContain('/root-users/landing'); + expect(names.filter((n) => n === '/')).toHaveLength(0); + }); + + it('stacks same-file APIRouter(prefix=…) with include_router(prefix=…)', () => { + // api/items.py declares `APIRouter(dependencies=[Depends(get_db)], prefix="/items")` + // — a router-level dependency BEFORE prefix=. This pins the balanced-paren + // scan end-to-end: the old `[^)]*?` regex stopped at the `)` of `Depends(...)` + // and dropped `/items`, leaving the graph Route node at `/v1/{item_id}`. + const names = routeNames(); + expect(names).toContain('/v1/items/{item_id}'); + expect(names.filter((n) => n === '/items/{item_id}')).toHaveLength(0); + expect(names.filter((n) => n === '/v1/{item_id}')).toHaveLength(0); + }); + it('does NOT bleed `/users` prefix onto the same-name `admin/users.py`', () => { // FINDING 3: `api/users.py` and `admin/users.py` collide on the // short module key `users`. main.py only mounts the `api/users` @@ -106,6 +131,13 @@ describe('FastAPI include_router(prefix=…) — ingestion pipeline', () => { expect(names.filter((n) => n === '/users/audit')).toHaveLength(0); }); + it('does NOT bleed root same-file APIRouter(prefix=…) onto nested same-stem files', () => { + const names = routeNames(); + expect(names).toContain('/root-users/landing'); + expect(names).toContain('/audit'); + expect(names.filter((n) => n === '/root-users/audit')).toHaveLength(0); + }); + it('emits exactly one Route node per (router method, prefix) pair', () => { // Defence-in-depth: counts the unique route nodes for the // prefixed routes to make sure the duplication path in @@ -118,6 +150,8 @@ describe('FastAPI include_router(prefix=…) — ingestion pipeline', () => { expect(counts.get('/users/create')).toBe(1); expect(counts.get('/calls/list')).toBe(1); expect(counts.get('/rel/info')).toBe(1); + expect(counts.get('/local')).toBe(1); + expect(counts.get('/v1/items/{item_id}')).toBe(1); expect(counts.get('/audit')).toBe(1); }); }); diff --git a/gitnexus/test/unit/fastapi-router-bindings.test.ts b/gitnexus/test/unit/fastapi-router-bindings.test.ts index 504273440e..3edbf94819 100644 --- a/gitnexus/test/unit/fastapi-router-bindings.test.ts +++ b/gitnexus/test/unit/fastapi-router-bindings.test.ts @@ -29,8 +29,10 @@ import { extractFastAPIRouterBindings, lastDottedSegment, lastTwoSegmentsAsPath, + type ExtractedRouterConstructorPrefix, type ExtractedRouterInclude, type ExtractedRouterImport, + type ExtractedRouterModuleAlias, } from '../../src/core/ingestion/route-extractors/fastapi-router-bindings.js'; function run(filePath: string, content: string) { @@ -40,6 +42,22 @@ function run(filePath: string, content: string) { return { includes, imports }; } +function runFull(filePath: string, content: string) { + const includes: ExtractedRouterInclude[] = []; + const imports: ExtractedRouterImport[] = []; + const moduleAliases: ExtractedRouterModuleAlias[] = []; + const constructorPrefixes: ExtractedRouterConstructorPrefix[] = []; + extractFastAPIRouterBindings( + filePath, + content, + includes, + imports, + moduleAliases, + constructorPrefixes, + ); + return { includes, imports, moduleAliases, constructorPrefixes }; +} + describe('lastDottedSegment', () => { it('returns the last segment of an absolute dotted path', () => { expect(lastDottedSegment('api.users')).toBe('users'); @@ -285,3 +303,56 @@ describe('extractFastAPIRouterBindings — negative cases', () => { expect(imports[0].moduleKey).toBe('users'); }); }); + +describe('extractFastAPIRouterBindings — APIRouter constructor prefix', () => { + it('captures same-file APIRouter(prefix=...) declarations', () => { + const { constructorPrefixes } = runFull( + 'api/items.py', + [ + 'from fastapi import APIRouter', + 'router = APIRouter(prefix="/api/items", tags=["items"])', + '', + '@router.get("")', + 'def list_items():', + ' return []', + '', + ].join('\n'), + ); + + expect(constructorPrefixes).toEqual([{ filePath: 'api/items.py', prefix: '/api/items' }]); + }); + + it('captures prefix after nested APIRouter arguments', () => { + const { constructorPrefixes } = runFull( + 'api/items.py', + [ + 'from fastapi import APIRouter, Depends', + 'router = APIRouter(dependencies=[Depends(get_db)], prefix="/api/items")', + '', + '@router.get("")', + 'def list_items():', + ' return []', + '', + ].join('\n'), + ); + + expect(constructorPrefixes).toEqual([{ filePath: 'api/items.py', prefix: '/api/items' }]); + }); + + it('does not emit constructor prefixes for non-router receivers yet', () => { + const { constructorPrefixes } = runFull( + 'api/items.py', + [ + 'from fastapi import APIRouter', + 'api_router = APIRouter(prefix="/api")', + '', + '@api_router.get("/items")', + 'def list_items():', + ' return []', + '', + ].join('\n'), + ); + + expect(constructorPrefixes).toEqual([]); + }); +}); diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index 99c5b1b0a4..e38e5cf167 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -6969,6 +6969,124 @@ async def concurrent(): expect(providers.find((c) => c.contractId === 'http::GET::/ai/concurrent')).toBeDefined(); }); + 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 }); + fs.writeFileSync(path.join(dir, 'main.py'), `app = None\n`); + fs.writeFileSync( + path.join(dir, 'api/items.py'), + `from fastapi import APIRouter +router = APIRouter(prefix="/api/items", tags=["items"]) + +@router.get("") +async def list_items(): + return [] + +@router.post("/{item_id}") +async def update_item(item_id: str): + return {} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers.find((c) => c.contractId === 'http::GET::/api/items')).toBeDefined(); + expect( + providers.find((c) => c.contractId === 'http::POST::/api/items/{param}'), + ).toBeDefined(); + expect(providers.find((c) => c.contractId === 'http::GET::/')).toBeUndefined(); + expect(providers.find((c) => c.contractId === 'http::POST::/{param}')).toBeUndefined(); + }); + + it('treats an empty APIRouter(prefix="") as no prefix', async () => { + const dir = path.join(tmpDir, 'fastapi-router-empty-prefix'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'main.py'), `app = None\n`); + fs.writeFileSync( + path.join(dir, 'items.py'), + `from fastapi import APIRouter +router = APIRouter(prefix="") + +@router.get("/list") +async def list_items(): + return [] +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + // Empty prefix is a clean no-op — the route keeps its bare decorator path. + expect(providers.find((c) => c.contractId === 'http::GET::/list')).toBeDefined(); + }); + + it('does not bleed root APIRouter(prefix=...) onto nested same-stem files', async () => { + const dir = path.join(tmpDir, 'fastapi-router-constructor-prefix-no-bleed'); + fs.mkdirSync(path.join(dir, 'admin'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'main.py'), `app = None\n`); + fs.writeFileSync( + path.join(dir, 'users.py'), + `from fastapi import APIRouter +router = APIRouter(prefix="/root-users") + +@router.get("/landing") +async def root_users_landing(): + return {} +`, + ); + fs.writeFileSync( + path.join(dir, 'admin/users.py'), + `from fastapi import APIRouter +router = APIRouter() + +@router.get("/audit") +async def audit(): + return {} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect( + providers.find((c) => c.contractId === 'http::GET::/root-users/landing'), + ).toBeDefined(); + expect(providers.find((c) => c.contractId === 'http::GET::/audit')).toBeDefined(); + expect( + providers.find((c) => c.contractId === 'http::GET::/root-users/audit'), + ).toBeUndefined(); + }); + + it('stacks FastAPI APIRouter(prefix=...) with include_router(prefix=...)', async () => { + const dir = path.join(tmpDir, 'fastapi-router-stacked-prefix'); + fs.mkdirSync(path.join(dir, 'api'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'main.py'), + `from fastapi import FastAPI +from api import items +app = FastAPI() +app.include_router(items.router, prefix="/v1") +`, + ); + fs.writeFileSync( + path.join(dir, 'api/items.py'), + `from fastapi import APIRouter +router = APIRouter(prefix="/items") + +@router.get("/{item_id}") +async def get_item(item_id: str): + return {} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers.find((c) => c.contractId === 'http::GET::/v1/items/{param}')).toBeDefined(); + expect(providers.find((c) => c.contractId === 'http::GET::/items/{param}')).toBeUndefined(); + }); + it('emits @router. path unmodified when no include_router prefix is configured', async () => { const dir = path.join(tmpDir, 'fastapi-router-no-prefix'); fs.mkdirSync(path.join(dir, 'api'), { recursive: true });