gitnexus 1.6.9-rc.24 → 1.6.9-rc.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  import Python from 'tree-sitter-python';
2
2
  import { compilePatterns, runCompiledPatterns, unquoteLiteral, } from '../tree-sitter-scanner.js';
3
+ import { normalizeExtractedRoutePath } from '../../../ingestion/route-extractors/route-path.js';
3
4
  /**
4
5
  * Python HTTP plugin. Handles:
5
6
  * - FastAPI `@app.get("/path")` provider decorators
@@ -146,6 +147,25 @@ const INCLUDE_ROUTER_NAME_PATTERNS = compilePatterns({
146
147
  },
147
148
  ],
148
149
  });
150
+ const API_ROUTER_PREFIX_PATTERNS = compilePatterns({
151
+ name: 'python-fastapi-apirouter-prefix',
152
+ language: Python,
153
+ patterns: [
154
+ {
155
+ meta: {},
156
+ query: `
157
+ (assignment
158
+ left: (identifier) @router_name (#eq? @router_name "router")
159
+ right: (call
160
+ function: (identifier) @factory (#eq? @factory "APIRouter")
161
+ arguments: (argument_list
162
+ (keyword_argument
163
+ name: (identifier) @kw (#eq? @kw "prefix")
164
+ value: (string) @prefix))))
165
+ `,
166
+ },
167
+ ],
168
+ });
149
169
  // `from .api.assistant import router` style — used together with
150
170
  // INCLUDE_ROUTER_NAME so we can map a local name back to its module
151
171
  // path, then back to the file the router was declared in.
@@ -813,10 +833,10 @@ function recordPrefix(target, key, prefix) {
813
833
  function buildPythonRepoContext(files, parser, readFile, parseSource) {
814
834
  const prefixesByLongKey = new Map();
815
835
  const prefixesByShortKey = new Map();
816
- // Pre-pass over .py files. We deliberately run this even on files
817
- // that don't contain `include_router` the cost of an extra parse
818
- // is bounded by the file count, and detecting `include_router`
819
- // beforehand would require its own grep/scan.
836
+ // Cross-file pre-pass: only `include_router` sites need it they bind a
837
+ // prefix declared in one file to a router defined in another. Same-file
838
+ // `APIRouter(prefix=...)` is resolved in scan() from the file's own tree, so
839
+ // APIRouter-only files are left out here and never parsed twice.
820
840
  for (const rel of files) {
821
841
  if (!rel.endsWith('.py'))
822
842
  continue;
@@ -912,14 +932,16 @@ function buildPythonRepoContext(files, parser, readFile, parseSource) {
912
932
  }
913
933
  }
914
934
  }
915
- return { prefixesByLongKey, prefixesByShortKey };
935
+ return {
936
+ prefixesByLongKey,
937
+ prefixesByShortKey,
938
+ };
916
939
  }
917
940
  function joinPrefix(prefix, route) {
918
- // Mirror FastAPI's path joining: trim trailing slash off prefix,
919
- // ensure exactly one leading slash on the result.
920
- const p = prefix.replace(/\/+$/, '');
921
- const r = route.startsWith('/') ? route : `/${route}`;
922
- return `${p}${r}`;
941
+ // Delegate to the shared route-path normalizer so the group contract and the
942
+ // ingestion Route node join prefixes identically one helper, no
943
+ // trailing-slash drift on empty routes (`APIRouter(prefix="/x")` + `@get("")`).
944
+ return normalizeExtractedRoutePath(route, prefix);
923
945
  }
924
946
  export const PYTHON_HTTP_PLUGIN = {
925
947
  name: 'python-http',
@@ -979,6 +1001,19 @@ export const PYTHON_HTTP_PLUGIN = {
979
1001
  // Django providers come from the graph Route nodes (includes composed by
980
1002
  // the ingestion route extractor), not a per-file source scan — see the note
981
1003
  // at the top of this file.
1004
+ // Same-file `router = APIRouter(prefix="/x")` (router-only). Read from this
1005
+ // file's own tree, so there is no cross-file map and no prefix bleed across
1006
+ // same-stem files; it stacks under any include_router(prefix=...) below.
1007
+ let constructorPrefix;
1008
+ for (const m of runCompiledPatterns(API_ROUTER_PREFIX_PATTERNS, tree)) {
1009
+ const prefixNode = m.captures.prefix;
1010
+ if (!prefixNode)
1011
+ continue;
1012
+ const p = unquoteLiteral(prefixNode.text);
1013
+ if (p === null)
1014
+ continue;
1015
+ constructorPrefix = p;
1016
+ }
982
1017
  // Providers: FastAPI @router.<verb>("/path") — must be joined
983
1018
  // with the prefix(es) declared at the include_router site. When
984
1019
  // no prefix is found we still emit the unprefixed path so this
@@ -1004,9 +1039,12 @@ export const PYTHON_HTTP_PLUGIN = {
1004
1039
  const shortKey = fileRel ? fileShortKey(fileRel) : '';
1005
1040
  const shortPrefixes = longPrefixes || !shortKey ? undefined : ctx?.prefixesByShortKey.get(shortKey);
1006
1041
  const prefixSet = longPrefixes ?? shortPrefixes;
1042
+ // Stack the same-file APIRouter(prefix=...) under any cross-file
1043
+ // include_router prefix.
1044
+ const localPath = constructorPrefix ? joinPrefix(constructorPrefix, rawPath) : rawPath;
1007
1045
  const paths = prefixSet && prefixSet.size > 0
1008
- ? [...prefixSet].map((p) => joinPrefix(p, rawPath))
1009
- : [rawPath];
1046
+ ? [...prefixSet].map((p) => joinPrefix(p, localPath))
1047
+ : [localPath];
1010
1048
  for (const p of paths) {
1011
1049
  out.push({
1012
1050
  role: 'provider',
@@ -4,7 +4,7 @@ import { type ExportedTypeMap } from './call-processor.js';
4
4
  import type { ParsedFile } from '../../_shared/index.js';
5
5
  import { WorkerPool } from './workers/worker-pool.js';
6
6
  import type { ParseWorkerResult, ExtractedRoute, ExtractedFetchCall, ExtractedDecoratorRoute, ExtractedToolDef, FileScopeBindings, ExtractedORMQuery, FetchWrapperDef } from './workers/parse-worker.js';
7
- import type { ExtractedRouterImport, ExtractedRouterInclude, ExtractedRouterModuleAlias } from './route-extractors/fastapi-router-bindings.js';
7
+ import type { ExtractedRouterConstructorPrefix, ExtractedRouterImport, ExtractedRouterInclude, ExtractedRouterModuleAlias } from './route-extractors/fastapi-router-bindings.js';
8
8
  import type { SharedSpringType } from './route-extractors/spring-shared.js';
9
9
  export type FileProgressCallback = (current: number, total: number, filePath: string) => void;
10
10
  export interface WorkerExtractedData {
@@ -14,6 +14,7 @@ export interface WorkerExtractedData {
14
14
  decoratorRoutes: ExtractedDecoratorRoute[];
15
15
  routerIncludes: ExtractedRouterInclude[];
16
16
  routerImports: ExtractedRouterImport[];
17
+ routerConstructorPrefixes: ExtractedRouterConstructorPrefix[];
17
18
  routerModuleAliases: ExtractedRouterModuleAlias[];
18
19
  toolDefs: ExtractedToolDef[];
19
20
  ormQueries: ExtractedORMQuery[];
@@ -22,6 +22,7 @@ export const mergeChunkResults = (graph, symbolTable, chunkResults, exportedType
22
22
  const allDecoratorRoutes = [];
23
23
  const allRouterIncludes = [];
24
24
  const allRouterImports = [];
25
+ const allRouterConstructorPrefixes = [];
25
26
  const allRouterModuleAliases = [];
26
27
  const allSpringTypes = [];
27
28
  const allToolDefs = [];
@@ -70,6 +71,9 @@ export const mergeChunkResults = (graph, symbolTable, chunkResults, exportedType
70
71
  allRouterIncludes.push(item);
71
72
  for (const item of result.routerImports ?? [])
72
73
  allRouterImports.push(item);
74
+ for (const item of result.routerConstructorPrefixes ?? []) {
75
+ allRouterConstructorPrefixes.push(item);
76
+ }
73
77
  for (const item of result.routerModuleAliases ?? [])
74
78
  allRouterModuleAliases.push(item);
75
79
  for (const item of result.springTypes ?? [])
@@ -93,6 +97,7 @@ export const mergeChunkResults = (graph, symbolTable, chunkResults, exportedType
93
97
  decoratorRoutes: allDecoratorRoutes,
94
98
  routerIncludes: allRouterIncludes,
95
99
  routerImports: allRouterImports,
100
+ routerConstructorPrefixes: allRouterConstructorPrefixes,
96
101
  routerModuleAliases: allRouterModuleAliases,
97
102
  toolDefs: allToolDefs,
98
103
  ormQueries: allORMQueries,
@@ -27,6 +27,7 @@ import { isLanguageAvailable, isGrammarRuntimeSkipped, createParserForLanguage,
27
27
  import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
28
28
  import { getProvider, providers } from '../languages/index.js';
29
29
  import { createWorkerPool, workerPoolDisabledByEnv, resolveAutoPoolSize, WorkerPoolInitializationError, WorkerPoolDisabledError, } from '../workers/worker-pool.js';
30
+ import { normalizeExtractedRoutePath } from '../route-extractors/route-path.js';
30
31
  import { resolveInheritedSpringRoutes, } from '../route-extractors/spring-shared.js';
31
32
  import fs from 'node:fs';
32
33
  import path from 'node:path';
@@ -461,6 +462,7 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
461
462
  const allDecoratorRoutes = [];
462
463
  const allRouterIncludes = [];
463
464
  const allRouterImports = [];
465
+ const allRouterConstructorPrefixes = [];
464
466
  const allRouterModuleAliases = [];
465
467
  const allSpringTypes = [];
466
468
  const allToolDefs = [];
@@ -599,6 +601,11 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
599
601
  for (const item of chunkWorkerData.routerImports)
600
602
  allRouterImports.push(item);
601
603
  }
604
+ if (chunkWorkerData.routerConstructorPrefixes?.length) {
605
+ for (const item of chunkWorkerData.routerConstructorPrefixes) {
606
+ allRouterConstructorPrefixes.push(item);
607
+ }
608
+ }
602
609
  if (chunkWorkerData.routerModuleAliases?.length) {
603
610
  for (const item of chunkWorkerData.routerModuleAliases)
604
611
  allRouterModuleAliases.push(item);
@@ -932,7 +939,8 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
932
939
  // decorator inherits its file-basename's prefix. When a router is mounted
933
940
  // under multiple prefixes we duplicate the route entry, mirroring FastAPI's
934
941
  // runtime behaviour.
935
- if (allRouterIncludes.length > 0 && allDecoratorRoutes.length > 0) {
942
+ if ((allRouterIncludes.length > 0 || allRouterConstructorPrefixes.length > 0) &&
943
+ allDecoratorRoutes.length > 0) {
936
944
  const importsByFile = new Map();
937
945
  for (const imp of allRouterImports) {
938
946
  let m = importsByFile.get(imp.filePath);
@@ -965,6 +973,11 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
965
973
  // without a corresponding import statement).
966
974
  const prefixesByLongKey = new Map();
967
975
  const prefixesByShortKey = new Map();
976
+ // Constructor prefixes are `router`-only (the apply gate below and the
977
+ // group-layer tree-sitter both pin to the literal name `router`), so a
978
+ // flat file-key → prefix map suffices — mirrors the group layer's shape.
979
+ const constructorPrefixesByLongKey = new Map();
980
+ const constructorPrefixesByShortKey = new Map();
968
981
  const recordPrefix = (target, key, prefix) => {
969
982
  let set = target.get(key);
970
983
  if (!set) {
@@ -1004,7 +1017,9 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
1004
1017
  recordPrefix(prefixesByShortKey, localImp.moduleKey, inc.prefix);
1005
1018
  }
1006
1019
  }
1007
- if (prefixesByLongKey.size > 0 || prefixesByShortKey.size > 0) {
1020
+ if (prefixesByLongKey.size > 0 ||
1021
+ prefixesByShortKey.size > 0 ||
1022
+ allRouterConstructorPrefixes.length > 0) {
1008
1023
  const fileLongKey = (rel) => {
1009
1024
  // Strip `.py`, then take the last two path segments. `api/users.py`
1010
1025
  // → `api/users`. Files at the repo root return the empty string,
@@ -1025,6 +1040,15 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
1025
1040
  const file = slash >= 0 ? rel.slice(slash + 1) : rel;
1026
1041
  return file.endsWith('.py') ? file.slice(0, -3) : file;
1027
1042
  };
1043
+ for (const ctor of allRouterConstructorPrefixes) {
1044
+ const longKey = fileLongKey(ctor.filePath);
1045
+ if (longKey) {
1046
+ constructorPrefixesByLongKey.set(longKey, ctor.prefix);
1047
+ }
1048
+ else {
1049
+ constructorPrefixesByShortKey.set(fileShortKey(ctor.filePath), ctor.prefix);
1050
+ }
1051
+ }
1028
1052
  const expanded = [];
1029
1053
  for (const dr of allDecoratorRoutes) {
1030
1054
  if (dr.decoratorReceiver !== 'router' || !dr.filePath.endsWith('.py')) {
@@ -1040,12 +1064,24 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
1040
1064
  ? undefined
1041
1065
  : prefixesByShortKey.get(fileShortKey(dr.filePath));
1042
1066
  const prefixes = longPrefixes ?? shortPrefixes;
1067
+ // Constructor prefixes are keyed like include_router prefixes:
1068
+ // long-key entries are precise, while short-key entries are only
1069
+ // valid for repo-root/single-segment files where `fileLongKey`
1070
+ // returns ''. Do not fall back from a missing long-key match to the
1071
+ // short key or a root `users.py` prefix can leak onto
1072
+ // `admin/users.py`.
1073
+ const constructorPrefix = longKey
1074
+ ? constructorPrefixesByLongKey.get(longKey)
1075
+ : constructorPrefixesByShortKey.get(fileShortKey(dr.filePath));
1076
+ const routePath = constructorPrefix
1077
+ ? normalizeExtractedRoutePath(dr.routePath, constructorPrefix)
1078
+ : dr.routePath;
1043
1079
  if (!prefixes || prefixes.size === 0) {
1044
- expanded.push(dr);
1080
+ expanded.push(routePath === dr.routePath ? dr : { ...dr, routePath });
1045
1081
  continue;
1046
1082
  }
1047
1083
  for (const prefix of prefixes) {
1048
- expanded.push({ ...dr, prefix });
1084
+ expanded.push({ ...dr, routePath, prefix });
1049
1085
  }
1050
1086
  }
1051
1087
  allDecoratorRoutes.length = 0;
@@ -101,6 +101,10 @@ export interface ExtractedRouterModuleAlias {
101
101
  /** Long key (`<parent>/<stem>`) — non-empty for every emitted record. */
102
102
  moduleKeyLong: string;
103
103
  }
104
+ export interface ExtractedRouterConstructorPrefix {
105
+ filePath: string;
106
+ prefix: string;
107
+ }
104
108
  /**
105
109
  * Last `.`-separated segment of a (possibly relative) Python module
106
110
  * path. Strips any leading dots first so `from .api.assistant import
@@ -131,4 +135,4 @@ export declare function lastTwoSegmentsAsPath(text: string): string;
131
135
  * skips the alias collection — this keeps the function signature
132
136
  * back-compat with older callers (and the parse-cache replay path).
133
137
  */
134
- export declare function extractFastAPIRouterBindings(filePath: string, content: string, outIncludes: ExtractedRouterInclude[], outImports: ExtractedRouterImport[], outModuleAliases?: ExtractedRouterModuleAlias[]): void;
138
+ export declare function extractFastAPIRouterBindings(filePath: string, content: string, outIncludes: ExtractedRouterInclude[], outImports: ExtractedRouterImport[], outModuleAliases?: ExtractedRouterModuleAlias[], outConstructorPrefixes?: ExtractedRouterConstructorPrefix[]): void;
@@ -59,6 +59,8 @@ const INCLUDE_ROUTER_NAME_RE = /\b(?:[A-Za-z_][\w.]*)\.include_router\s*\(\s*([A
59
59
  // The latter is the common case and the only one we can map back to
60
60
  // a module stem.
61
61
  const FROM_IMPORT_ROUTER_RE = /^\s*from\s+(\.+|\.*[A-Za-z_][\w.]*)\s+import\s+([^#\n]+)/gm;
62
+ const API_ROUTER_ASSIGN_RE = /\brouter\s*=\s*APIRouter\s*\(/g;
63
+ const API_ROUTER_PREFIX_ARG_RE = /\bprefix\s*=\s*(['"])([^'"]*)\1/;
62
64
  /**
63
65
  * Last `.`-separated segment of a (possibly relative) Python module
64
66
  * path. Strips any leading dots first so `from .api.assistant import
@@ -94,6 +96,36 @@ export function lastTwoSegmentsAsPath(text) {
94
96
  const parent = prev >= 0 ? beforeLast.slice(prev + 1) : beforeLast;
95
97
  return `${parent}/${stem}`;
96
98
  }
99
+ function findMatchingParen(content, openIndex) {
100
+ let depth = 0;
101
+ let quote = null;
102
+ for (let i = openIndex; i < content.length; i++) {
103
+ const ch = content[i];
104
+ if (quote) {
105
+ if (ch === '\\') {
106
+ i++;
107
+ continue;
108
+ }
109
+ if (ch === quote)
110
+ quote = null;
111
+ continue;
112
+ }
113
+ if (ch === '"' || ch === "'") {
114
+ quote = ch;
115
+ continue;
116
+ }
117
+ if (ch === '(' || ch === '[' || ch === '{') {
118
+ depth++;
119
+ continue;
120
+ }
121
+ if (ch === ')' || ch === ']' || ch === '}') {
122
+ depth--;
123
+ if (depth === 0 && ch === ')')
124
+ return i;
125
+ }
126
+ }
127
+ return -1;
128
+ }
97
129
  /**
98
130
  * Scan a single Python file's source text for FastAPI router
99
131
  * `include_router` sites and `from <module> import router` imports,
@@ -107,7 +139,7 @@ export function lastTwoSegmentsAsPath(text) {
107
139
  * skips the alias collection — this keeps the function signature
108
140
  * back-compat with older callers (and the parse-cache replay path).
109
141
  */
110
- export function extractFastAPIRouterBindings(filePath, content, outIncludes, outImports, outModuleAliases) {
142
+ export function extractFastAPIRouterBindings(filePath, content, outIncludes, outImports, outModuleAliases, outConstructorPrefixes) {
111
143
  if (!content.includes('include_router') && !content.includes('router'))
112
144
  return;
113
145
  // `from <module> import router [as <alias>]`. We capture every name
@@ -172,6 +204,25 @@ export function extractFastAPIRouterBindings(filePath, content, outIncludes, out
172
204
  }
173
205
  }
174
206
  }
207
+ if (outConstructorPrefixes && content.includes('APIRouter') && content.includes('prefix')) {
208
+ API_ROUTER_ASSIGN_RE.lastIndex = 0;
209
+ // Only `router = APIRouter(...)` is captured (the apply gate and the
210
+ // group-layer tree-sitter both pin to the literal name `router`).
211
+ while (API_ROUTER_ASSIGN_RE.exec(content) !== null) {
212
+ const openParen = API_ROUTER_ASSIGN_RE.lastIndex - 1;
213
+ const closeParen = findMatchingParen(content, openParen);
214
+ if (closeParen < 0)
215
+ continue;
216
+ const args = content.slice(openParen + 1, closeParen);
217
+ const prefixMatch = API_ROUTER_PREFIX_ARG_RE.exec(args);
218
+ if (!prefixMatch)
219
+ continue;
220
+ outConstructorPrefixes.push({
221
+ filePath,
222
+ prefix: prefixMatch[2],
223
+ });
224
+ }
225
+ }
175
226
  if (!content.includes('include_router'))
176
227
  return;
177
228
  // Shape A: `<host>.include_router(<module>.router, prefix='/x')`.
@@ -1,6 +1,6 @@
1
1
  import { SupportedLanguages } from '../../../_shared/index.js';
2
2
  import type { SkippedPath } from './clone-safety.js';
3
- import type { ExtractedRouterInclude, ExtractedRouterImport, ExtractedRouterModuleAlias } from '../route-extractors/fastapi-router-bindings.js';
3
+ import type { ExtractedRouterConstructorPrefix, ExtractedRouterInclude, ExtractedRouterImport, ExtractedRouterModuleAlias } from '../route-extractors/fastapi-router-bindings.js';
4
4
  import { type MixedChainStep } from '../utils/call-analysis.js';
5
5
  import type { ConstructorBinding } from '../type-env.js';
6
6
  import type { NodeLabel, ParameterTypeClass } from '../../../_shared/index.js';
@@ -191,6 +191,7 @@ export interface ParseWorkerResult {
191
191
  decoratorRoutes: ExtractedDecoratorRoute[];
192
192
  routerIncludes: ExtractedRouterInclude[];
193
193
  routerImports: ExtractedRouterImport[];
194
+ routerConstructorPrefixes?: ExtractedRouterConstructorPrefix[];
194
195
  /**
195
196
  * Optional. Project-wide `SharedSpringType` view of route-defining
196
197
  * class/interface declarations, produced by the provider's
@@ -504,6 +504,7 @@ const processBatch = (files, onProgress) => {
504
504
  decoratorRoutes: [],
505
505
  routerIncludes: [],
506
506
  routerImports: [],
507
+ routerConstructorPrefixes: [],
507
508
  routerModuleAliases: [],
508
509
  toolDefs: [],
509
510
  ormQueries: [],
@@ -1821,7 +1822,7 @@ const processFileGroup = (files, language, queryString, result, onFileProcessed)
1821
1822
  // injects the resolved prefix onto each ExtractedDecoratorRoute that
1822
1823
  // came from a `@router.<verb>` decorator. Python-only.
1823
1824
  if (language === SupportedLanguages.Python) {
1824
- extractFastAPIRouterBindings(file.path, parseContent, result.routerIncludes, result.routerImports, (result.routerModuleAliases ??= []));
1825
+ extractFastAPIRouterBindings(file.path, parseContent, result.routerIncludes, result.routerImports, (result.routerModuleAliases ??= []), (result.routerConstructorPrefixes ??= []));
1825
1826
  }
1826
1827
  // Language-specific decorator route extraction via provider hook.
1827
1828
  // The provider's extractDecoratorRoutes walks the AST for framework-specific
@@ -1870,6 +1871,7 @@ let accumulated = {
1870
1871
  decoratorRoutes: [],
1871
1872
  routerIncludes: [],
1872
1873
  routerImports: [],
1874
+ routerConstructorPrefixes: [],
1873
1875
  routerModuleAliases: [],
1874
1876
  toolDefs: [],
1875
1877
  ormQueries: [],
@@ -1989,6 +1991,7 @@ parentPort.on('message', (msg) => {
1989
1991
  decoratorRoutes: [],
1990
1992
  routerIncludes: [],
1991
1993
  routerImports: [],
1994
+ routerConstructorPrefixes: [],
1992
1995
  routerModuleAliases: [],
1993
1996
  toolDefs: [],
1994
1997
  ormQueries: [],
@@ -24,6 +24,10 @@ export const mergeResult = (target, src) => {
24
24
  appendAll(target.routerIncludes, src.routerIncludes);
25
25
  if (src.routerImports)
26
26
  appendAll(target.routerImports, src.routerImports);
27
+ if (src.routerConstructorPrefixes) {
28
+ target.routerConstructorPrefixes ??= [];
29
+ appendAll(target.routerConstructorPrefixes, src.routerConstructorPrefixes);
30
+ }
27
31
  if (src.routerModuleAliases) {
28
32
  target.routerModuleAliases ??= [];
29
33
  appendAll(target.routerModuleAliases, src.routerModuleAliases);
@@ -5157,7 +5157,7 @@ export class LocalBackend {
5157
5157
  RETURN n.id AS routeId, n.name AS routeName, n.filePath AS handlerFile,
5158
5158
  n.responseKeys AS responseKeys, n.errorKeys AS errorKeys, n.middleware AS middleware,
5159
5159
  consumer.name AS consumerName, consumer.filePath AS consumerFile,
5160
- r.reason AS fetchReason
5160
+ r.reason AS fetchReason, n.method AS method
5161
5161
  `, params);
5162
5162
  // Strip wrapping quotes from DB array elements — CSV COPY stores ['key'] which
5163
5163
  // LadybugDB may return as "'key'" rather than "key"
@@ -5173,10 +5173,16 @@ export class LocalBackend {
5173
5173
  const consumerName = row.consumerName ?? row[6];
5174
5174
  const consumerFile = row.consumerFile ?? row[7];
5175
5175
  const fetchReason = row.fetchReason ?? row[8] ?? null;
5176
+ // Verb is the literal '*' for method-agnostic routes (Django function
5177
+ // views) and absent (null) for method-less routes (filesystem, Laravel
5178
+ // resource). Appended last in RETURN so positional fallbacks for the
5179
+ // consumer/reason columns above stay stable.
5180
+ const method = row.method ?? row[9] ?? null;
5176
5181
  if (!routeMap.has(id)) {
5177
5182
  routeMap.set(id, {
5178
5183
  id,
5179
5184
  name,
5185
+ method,
5180
5186
  filePath,
5181
5187
  responseKeys,
5182
5188
  errorKeys,
@@ -5260,6 +5266,7 @@ export class LocalBackend {
5260
5266
  return {
5261
5267
  routes: routes.map((r) => ({
5262
5268
  route: r.name,
5269
+ method: r.method,
5263
5270
  handler: r.filePath,
5264
5271
  middleware: r.middleware || [],
5265
5272
  consumers: r.consumers,
@@ -5314,6 +5321,7 @@ export class LocalBackend {
5314
5321
  const hasMismatches = consumers.some((c) => 'mismatched' in c && c.mismatched.length > 0);
5315
5322
  return {
5316
5323
  route: r.name,
5324
+ method: r.method,
5317
5325
  handler: r.filePath,
5318
5326
  ...(responseKeys.length > 0 ? { responseKeys } : {}),
5319
5327
  ...(errorKeys.length > 0 ? { errorKeys } : {}),
@@ -5381,15 +5389,32 @@ export class LocalBackend {
5381
5389
  routeFilter = `AND n.filePath CONTAINS $file`;
5382
5390
  queryParams.file = params.file;
5383
5391
  }
5384
- const routes = await this.fetchRoutesWithConsumers(repo.lbugPath, routeFilter, queryParams);
5392
+ // After #2302 the same URL/handler can expose one Route node per HTTP verb.
5393
+ // An optional `method` narrows to that one verb so the response collapses to
5394
+ // the singular shape. A method-agnostic route (method `'*'`, e.g. a Django
5395
+ // function view) matches any selector; verbless routes (null method) never do.
5396
+ // `method` arrives unvalidated from the MCP envelope (the JSON schema is
5397
+ // advisory), so reject a non-string verb with a structured error instead of
5398
+ // throwing on `.toUpperCase()`; empty/whitespace collapses to no selector.
5399
+ const rawMethod = params.method;
5400
+ if (rawMethod !== undefined && typeof rawMethod !== 'string') {
5401
+ return { error: '"method" must be a string (e.g. "GET", "POST").' };
5402
+ }
5403
+ const wantedMethod = typeof rawMethod === 'string' ? rawMethod.trim().toUpperCase() || undefined : undefined;
5404
+ const matched = await this.fetchRoutesWithConsumers(repo.lbugPath, routeFilter, queryParams);
5405
+ const routes = matched.filter((r) => !wantedMethod || r.method === '*' || r.method?.toUpperCase() === wantedMethod);
5385
5406
  if (routes.length === 0) {
5386
5407
  const target = params.route || params.file;
5387
- return { error: `No routes found matching "${target}".` };
5408
+ // Only append the verb when the URL/file matched routes but none used it;
5409
+ // a non-existent URL/file gets the plain "no routes found" message.
5410
+ const verb = wantedMethod && matched.length > 0 ? ` with method "${wantedMethod}"` : '';
5411
+ return { error: `No routes found matching "${target}"${verb}.` };
5388
5412
  }
5389
5413
  const flowMap = await this.fetchLinkedFlowsBatch(repo.lbugPath, routes.map((r) => r.id));
5390
- // Count how many routes share the same handler file (for middleware partial detection)
5414
+ // Count verbs per handler from the FULL match (before the method filter) so a
5415
+ // method-scoped query still flags a multi-verb handler's partial middleware.
5391
5416
  const routeCountByHandler = new Map();
5392
- for (const r of routes) {
5417
+ for (const r of matched) {
5393
5418
  if (r.filePath) {
5394
5419
  routeCountByHandler.set(r.filePath, (routeCountByHandler.get(r.filePath) ?? 0) + 1);
5395
5420
  }
@@ -5459,6 +5484,7 @@ export class LocalBackend {
5459
5484
  const middlewarePartial = middlewareArr.length > 0 && handlerRouteCount > 1;
5460
5485
  return {
5461
5486
  route: r.name,
5487
+ method: r.method,
5462
5488
  handler: r.filePath,
5463
5489
  responseShape: {
5464
5490
  success: responseKeys,
@@ -5468,7 +5494,7 @@ export class LocalBackend {
5468
5494
  ...(middlewarePartial
5469
5495
  ? {
5470
5496
  middlewareDetection: 'partial',
5471
- middlewareNote: 'Middleware captured from first HTTP method export only — other methods in this handler may use different middleware chains.',
5497
+ middlewareNote: 'Middleware captured from the first route export only — other route exports in this handler may use different middleware chains.',
5472
5498
  }
5473
5499
  : {}),
5474
5500
  consumers,
@@ -24,6 +24,14 @@ export interface ToolDefinition {
24
24
  minLength?: number;
25
25
  }>;
26
26
  required: string[];
27
+ /**
28
+ * JSON-Schema `anyOf` for cross-property constraints `required` cannot express
29
+ * — e.g. "at least one of route/file". Forwarded verbatim to clients by the
30
+ * server's ListTools handler, so MCP clients see the constraint.
31
+ */
32
+ anyOf?: Array<{
33
+ required: string[];
34
+ }>;
27
35
  };
28
36
  }
29
37
  /**
package/dist/mcp/tools.js CHANGED
@@ -615,7 +615,7 @@ CONTRACT CAVEATS:
615
615
  WHEN TO USE: Understanding API consumption patterns, finding orphaned routes. For pre-change analysis, prefer \`api_impact\` which combines this data with mismatch detection and risk assessment.
616
616
  AFTER THIS: Use impact() on specific route handlers to see full blast radius.
617
617
 
618
- Returns: route nodes with their handlers, middleware wrapper chains (e.g., withAuth, withRateLimit), and consumers.`,
618
+ Returns: route nodes with their handlers, middleware wrapper chains (e.g., withAuth, withRateLimit), and consumers. Each route object includes its "method" (the HTTP verb, "*" for method-agnostic routes, or null for method-less routes).`,
619
619
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
620
620
  inputSchema: {
621
621
  type: 'object',
@@ -656,7 +656,7 @@ Returns: tool nodes with their handler files and descriptions.`,
656
656
  WHEN TO USE: Detecting mismatches between what an API route returns and what consumers expect. Finding shape drift. For pre-change analysis, prefer \`api_impact\` which combines this data with mismatch detection and risk assessment.
657
657
  REQUIRES: Route nodes with responseKeys (extracted from .json({...}) calls during indexing).
658
658
 
659
- Returns routes that have both detected response keys AND consumers. Shows top-level keys each endpoint returns (e.g., data, pagination, error) and what keys each consumer accesses. Reports MISMATCH status when a consumer accesses keys not present in the route's response shape.`,
659
+ Returns routes that have both detected response keys AND consumers. Shows top-level keys each endpoint returns (e.g., data, pagination, error) and what keys each consumer accesses. Reports MISMATCH status when a consumer accesses keys not present in the route's response shape. Each route object includes its "method" (the HTTP verb, "*" for method-agnostic routes, or null for method-less routes).`,
660
660
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
661
661
  inputSchema: {
662
662
  type: 'object',
@@ -681,16 +681,23 @@ WHEN TO USE: BEFORE modifying any API route handler. Shows what consumers depend
681
681
 
682
682
  Risk levels: LOW (0-3 consumers), MEDIUM (4-9 or any mismatches), HIGH (10+ consumers or mismatches with 4+ consumers). Mismatches with confidence "low" indicate the consumer file fetches multiple routes — property attribution is approximate.
683
683
 
684
- Returns: single route object when one match, or { routes: [...], total: N } for multiple matches. Combines route_map, shape_check, and impact data.`,
684
+ Response shape is keyed on how many routes match, not on the data: exactly one match returns a single route object; two or more return { routes: [...], total: N }. The same URL can expose multiple HTTP verbs (e.g. GET and POST /api/orders are distinct routes that share the URL), so a bare-URL lookup may return the wrapped form — every route object carries its own "method" so verbs are distinguishable. Pass "method" to narrow to one verb; the single-object shape is returned only when exactly one route remains after filtering — a substring route/file match spanning several URLs can still return the wrapped form. A URL/file that exists but has no route for the given verb returns an error. Each route's "method" is the literal "*" for method-agnostic routes (e.g. Django function views), which match any "method" selector, or null for method-less routes (filesystem, Laravel resource), which never match a selector. Combines route_map, shape_check, and impact data.`,
685
685
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
686
686
  inputSchema: {
687
687
  type: 'object',
688
688
  properties: {
689
689
  route: { type: 'string', description: 'Route path (e.g., "/api/grants")' },
690
690
  file: { type: 'string', description: 'Handler file path (alternative to route)' },
691
+ method: {
692
+ type: 'string',
693
+ description: 'Optional HTTP verb — GET, POST, PUT, PATCH, DELETE, etc. — to narrow a multi-verb route or file lookup to a single method. Returns an error if no matched route uses that verb.',
694
+ },
691
695
  repo: { type: 'string', description: 'Repository name or path.' },
692
696
  },
693
697
  required: [],
698
+ // Exactly one lookup key is needed, but either works (route wins if both
699
+ // are passed) — so the structural constraint is "at least one of route/file".
700
+ anyOf: [{ required: ['route'] }, { required: ['file'] }],
694
701
  },
695
702
  },
696
703
  {
@@ -52,7 +52,7 @@ import { fileURLToPath } from 'url';
52
52
  // the main thread (the #1983 OOM). Because the two stores share this version,
53
53
  // any future change to the `ParsedFile` serialization shape MUST bump
54
54
  // SCHEMA_BUMP so both invalidate in lockstep.
55
- 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)
55
+ const SCHEMA_BUMP = 9; // #2312: ParseWorkerResult gained `routerConstructorPrefixes` for FastAPI APIRouter(prefix=...) replay
56
56
  const GITNEXUS_PKG_VERSION = (() => {
57
57
  try {
58
58
  // package.json sits at gitnexus/package.json — two levels up from
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.9-rc.24",
3
+ "version": "1.6.9-rc.26",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",