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.
- package/dist/core/group/extractors/http-patterns/python.js +50 -12
- package/dist/core/ingestion/parsing-processor.d.ts +2 -1
- package/dist/core/ingestion/parsing-processor.js +5 -0
- package/dist/core/ingestion/pipeline-phases/parse-impl.js +40 -4
- package/dist/core/ingestion/route-extractors/fastapi-router-bindings.d.ts +5 -1
- package/dist/core/ingestion/route-extractors/fastapi-router-bindings.js +52 -1
- package/dist/core/ingestion/workers/parse-worker.d.ts +2 -1
- package/dist/core/ingestion/workers/parse-worker.js +4 -1
- package/dist/core/ingestion/workers/result-merge.js +4 -0
- package/dist/mcp/local/local-backend.js +32 -6
- package/dist/mcp/tools.d.ts +8 -0
- package/dist/mcp/tools.js +10 -3
- package/dist/storage/parse-cache.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
817
|
-
//
|
|
818
|
-
// is
|
|
819
|
-
//
|
|
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 {
|
|
935
|
+
return {
|
|
936
|
+
prefixesByLongKey,
|
|
937
|
+
prefixesByShortKey,
|
|
938
|
+
};
|
|
916
939
|
}
|
|
917
940
|
function joinPrefix(prefix, route) {
|
|
918
|
-
//
|
|
919
|
-
//
|
|
920
|
-
|
|
921
|
-
|
|
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,
|
|
1009
|
-
: [
|
|
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
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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,
|
package/dist/mcp/tools.d.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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