@securecode-ai/mcp 0.1.0
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/LICENSE +21 -0
- package/README.md +71 -0
- package/dist/api/client.d.ts +22 -0
- package/dist/api/client.js +141 -0
- package/dist/api/client.js.map +1 -0
- package/dist/api/types.d.ts +129 -0
- package/dist/api/types.js +3 -0
- package/dist/api/types.js.map +1 -0
- package/dist/approval/auditLog.d.ts +3 -0
- package/dist/approval/auditLog.js +67 -0
- package/dist/approval/auditLog.js.map +1 -0
- package/dist/approval/broker.d.ts +11 -0
- package/dist/approval/broker.js +252 -0
- package/dist/approval/broker.js.map +1 -0
- package/dist/approval/types.d.ts +26 -0
- package/dist/approval/types.js +58 -0
- package/dist/approval/types.js.map +1 -0
- package/dist/attack/agentLoop.d.ts +21 -0
- package/dist/attack/agentLoop.js +148 -0
- package/dist/attack/agentLoop.js.map +1 -0
- package/dist/attack/executor.d.ts +17 -0
- package/dist/attack/executor.js +162 -0
- package/dist/attack/executor.js.map +1 -0
- package/dist/attack/policy.d.ts +26 -0
- package/dist/attack/policy.js +73 -0
- package/dist/attack/policy.js.map +1 -0
- package/dist/attack/protocol.d.ts +64 -0
- package/dist/attack/protocol.js +3 -0
- package/dist/attack/protocol.js.map +1 -0
- package/dist/attack/report.d.ts +16 -0
- package/dist/attack/report.js +54 -0
- package/dist/attack/report.js.map +1 -0
- package/dist/auth/credentialStore.d.ts +20 -0
- package/dist/auth/credentialStore.js +148 -0
- package/dist/auth/credentialStore.js.map +1 -0
- package/dist/auth/keychain.d.ts +14 -0
- package/dist/auth/keychain.js +267 -0
- package/dist/auth/keychain.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +170 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/server.d.ts +2 -0
- package/dist/mcp/server.js +183 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/tools.d.ts +2 -0
- package/dist/mcp/tools.js +120 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/mcp/types.d.ts +26 -0
- package/dist/mcp/types.js +3 -0
- package/dist/mcp/types.js.map +1 -0
- package/dist/project-map/astHelpers.d.ts +89 -0
- package/dist/project-map/astHelpers.js +468 -0
- package/dist/project-map/astHelpers.js.map +1 -0
- package/dist/project-map/cache.d.ts +11 -0
- package/dist/project-map/cache.js +86 -0
- package/dist/project-map/cache.js.map +1 -0
- package/dist/project-map/confidence.d.ts +50 -0
- package/dist/project-map/confidence.js +87 -0
- package/dist/project-map/confidence.js.map +1 -0
- package/dist/project-map/crossFile.d.ts +73 -0
- package/dist/project-map/crossFile.js +33 -0
- package/dist/project-map/crossFile.js.map +1 -0
- package/dist/project-map/detectors.d.ts +83 -0
- package/dist/project-map/detectors.js +376 -0
- package/dist/project-map/detectors.js.map +1 -0
- package/dist/project-map/layer1.d.ts +74 -0
- package/dist/project-map/layer1.js +928 -0
- package/dist/project-map/layer1.js.map +1 -0
- package/dist/project-map/layer2.d.ts +30 -0
- package/dist/project-map/layer2.js +249 -0
- package/dist/project-map/layer2.js.map +1 -0
- package/dist/project-map/mapBuilder.d.ts +17 -0
- package/dist/project-map/mapBuilder.js +164 -0
- package/dist/project-map/mapBuilder.js.map +1 -0
- package/dist/project-map/parserLoader.d.ts +61 -0
- package/dist/project-map/parserLoader.js +175 -0
- package/dist/project-map/parserLoader.js.map +1 -0
- package/dist/project-map/schemas.d.ts +28 -0
- package/dist/project-map/schemas.js +131 -0
- package/dist/project-map/schemas.js.map +1 -0
- package/dist/project-map/types.d.ts +232 -0
- package/dist/project-map/types.js +61 -0
- package/dist/project-map/types.js.map +1 -0
- package/dist/tools/attack.d.ts +2 -0
- package/dist/tools/attack.js +80 -0
- package/dist/tools/attack.js.map +1 -0
- package/dist/tools/fix.d.ts +2 -0
- package/dist/tools/fix.js +60 -0
- package/dist/tools/fix.js.map +1 -0
- package/dist/tools/map.d.ts +2 -0
- package/dist/tools/map.js +46 -0
- package/dist/tools/map.js.map +1 -0
- package/dist/tools/scan.d.ts +2 -0
- package/dist/tools/scan.js +55 -0
- package/dist/tools/scan.js.map +1 -0
- package/dist/utils/files.d.ts +8 -0
- package/dist/utils/files.js +79 -0
- package/dist/utils/files.js.map +1 -0
- package/package.json +53 -0
- package/resources/tree-sitter/tree-sitter-javascript.wasm +0 -0
- package/resources/tree-sitter/tree-sitter-python.wasm +0 -0
- package/resources/tree-sitter/tree-sitter-tsx.wasm +0 -0
- package/resources/tree-sitter/tree-sitter-typescript.wasm +0 -0
|
@@ -0,0 +1,928 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Phase 4 Layer 1 — static extraction.
|
|
4
|
+
*
|
|
5
|
+
* Walks a parsed file once and produces one EndpointRecord per route
|
|
6
|
+
* registration site, plus the cross-file context (middleware chain,
|
|
7
|
+
* validators, params, auth, ORM, call graph, response shape).
|
|
8
|
+
*
|
|
9
|
+
* Four registration styles are recognised, because they are the four ways the
|
|
10
|
+
* corpus of real projects actually declares routes:
|
|
11
|
+
*
|
|
12
|
+
* - a call on a router object `app.get('/x', handler)` / `@app.get('/x')`
|
|
13
|
+
* - a filesystem path Next.js `app/api/x/route.ts` exporting GET
|
|
14
|
+
* - a URLconf table Django `path('x/', views.x)`
|
|
15
|
+
*
|
|
16
|
+
* Everything that needs to look outside this file — which middleware the
|
|
17
|
+
* router is mounted behind, what a middleware module actually checks, whether
|
|
18
|
+
* an imported singleton is a Prisma client — arrives through the optional
|
|
19
|
+
* `CrossFileContext`, which the cache builds (it owns the filesystem). Layer 1
|
|
20
|
+
* itself stays a pure function of nodes and text.
|
|
21
|
+
*/
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.findFunctionByName = findFunctionByName;
|
|
24
|
+
exports.resolveRoutePath = resolveRoutePath;
|
|
25
|
+
exports.nextAppRouterPath = nextAppRouterPath;
|
|
26
|
+
exports.extractLayer1 = extractLayer1;
|
|
27
|
+
const parserLoader_1 = require("./parserLoader");
|
|
28
|
+
const astHelpers_1 = require("./astHelpers");
|
|
29
|
+
const crossFile_1 = require("./crossFile");
|
|
30
|
+
const schemas_1 = require("./schemas");
|
|
31
|
+
const detectors_1 = require("./detectors");
|
|
32
|
+
const types_1 = require("./types");
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Parameters
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
/** Infer the ParamSource from a member like `req.body.x` / `req.query.y`. */
|
|
37
|
+
function paramSourceFromAccess(txt) {
|
|
38
|
+
if (/\bbody\b/.test(txt))
|
|
39
|
+
return 'body';
|
|
40
|
+
if (/\bquery\b/.test(txt))
|
|
41
|
+
return 'query';
|
|
42
|
+
if (/\bparams\b/.test(txt))
|
|
43
|
+
return 'path';
|
|
44
|
+
if (/\bheaders?\b/.test(txt))
|
|
45
|
+
return 'header';
|
|
46
|
+
if (/\bcookies?\b/.test(txt))
|
|
47
|
+
return 'cookie';
|
|
48
|
+
return 'unknown';
|
|
49
|
+
}
|
|
50
|
+
/** Accessor forms that name their own parameter in a string argument. */
|
|
51
|
+
const NAMED_ACCESSORS = [
|
|
52
|
+
// Next.js: req.nextUrl.searchParams.get('q'), new URL(...).searchParams
|
|
53
|
+
{ re: /\bsearchParams\s*\.\s*get\(\s*['"`]([^'"`]+)['"`]/g, source: 'query' },
|
|
54
|
+
// Django: request.GET.get("tag") / request.GET["tag"]
|
|
55
|
+
{ re: /\brequest\s*\.\s*GET\s*\.\s*get\(\s*['"]([^'"]+)['"]/g, source: 'query' },
|
|
56
|
+
{ re: /\brequest\s*\.\s*GET\s*\[\s*['"]([^'"]+)['"]\s*\]/g, source: 'query' },
|
|
57
|
+
{ re: /\brequest\s*\.\s*POST\s*\.\s*get\(\s*['"]([^'"]+)['"]/g, source: 'body' },
|
|
58
|
+
{ re: /\brequest\s*\.\s*POST\s*\[\s*['"]([^'"]+)['"]\s*\]/g, source: 'body' },
|
|
59
|
+
// Express bracket access: req.query['page'], req.headers['x-api-key']
|
|
60
|
+
{ re: /\b(?:req|request)\s*\.\s*query\s*\[\s*['"]([^'"]+)['"]\s*\]/g, source: 'query' },
|
|
61
|
+
{ re: /\b(?:req|request)\s*\.\s*body\s*\[\s*['"]([^'"]+)['"]\s*\]/g, source: 'body' },
|
|
62
|
+
{ re: /\b(?:req|request)\s*\.\s*params\s*\[\s*['"]([^'"]+)['"]\s*\]/g, source: 'path' },
|
|
63
|
+
{ re: /\b(?:req|request)\s*\.\s*(?:headers|header)\s*\[\s*['"]([^'"]+)['"]\s*\]/g, source: 'header' },
|
|
64
|
+
];
|
|
65
|
+
/** Request objects a destructuring can pull a whole parameter bag out of. */
|
|
66
|
+
const DESTRUCTURE_SOURCE_RE = /^(?:req|request|ctx)\.(body|query|params|headers|cookies)$/;
|
|
67
|
+
/**
|
|
68
|
+
* Extract the parameters an endpoint consumes.
|
|
69
|
+
*
|
|
70
|
+
* Reading `req.<source>.<name>` is only one of the ways a handler names its
|
|
71
|
+
* inputs, and on real code it is the minority: bodies are commonly
|
|
72
|
+
* destructured or declared entirely inside a validation schema, path params
|
|
73
|
+
* arrive through a framework context object, and FastAPI declares everything
|
|
74
|
+
* in the signature. All of those forms are collected here.
|
|
75
|
+
*/
|
|
76
|
+
function extractParams(handler, seed, framework) {
|
|
77
|
+
const out = new Map();
|
|
78
|
+
const add = (p) => {
|
|
79
|
+
const existing = out.get(p.name);
|
|
80
|
+
if (!existing) {
|
|
81
|
+
out.set(p.name, p);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// A field covered by a schema is validated even if it is also read raw.
|
|
85
|
+
if (!existing.validated && p.validated) {
|
|
86
|
+
out.set(p.name, { ...existing, validated: true, validator: p.validator });
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
for (const p of seed)
|
|
90
|
+
add(p);
|
|
91
|
+
const { node, source, schemas } = handler;
|
|
92
|
+
const text = source.slice(node.startIndex, node.endIndex);
|
|
93
|
+
// --- 1. member reads: req.body.x / req.params.id -----------------------
|
|
94
|
+
for (const n of (0, astHelpers_1.walk)(node)) {
|
|
95
|
+
if (n.type !== 'member_expression' && n.type !== 'attribute' && n.type !== 'subscript')
|
|
96
|
+
continue;
|
|
97
|
+
const txt = source.slice(n.startIndex, n.endIndex);
|
|
98
|
+
const m = txt.match(/\b(req|request|ctx|c)\.(body|query|params|headers|header|cookies)\.(\w+)/i);
|
|
99
|
+
if (!m)
|
|
100
|
+
continue;
|
|
101
|
+
add({
|
|
102
|
+
name: m[3],
|
|
103
|
+
type: 'unknown',
|
|
104
|
+
source: paramSourceFromAccess(txt),
|
|
105
|
+
validated: false,
|
|
106
|
+
confidence: types_1.Confidence.STATIC_LITERAL,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
// --- 2. destructuring: const { qty } = req.body ------------------------
|
|
110
|
+
for (const n of (0, astHelpers_1.walk)(node)) {
|
|
111
|
+
if (n.type !== 'variable_declarator' && n.type !== 'assignment')
|
|
112
|
+
continue;
|
|
113
|
+
const target = n.child(0);
|
|
114
|
+
const value = n.child(n.childCount - 1);
|
|
115
|
+
if (!target || !value || target === value)
|
|
116
|
+
continue;
|
|
117
|
+
if (target.type !== 'object_pattern' && target.type !== 'object')
|
|
118
|
+
continue;
|
|
119
|
+
const valueText = source.slice(value.startIndex, value.endIndex).trim();
|
|
120
|
+
const m = valueText.match(DESTRUCTURE_SOURCE_RE);
|
|
121
|
+
if (!m)
|
|
122
|
+
continue;
|
|
123
|
+
const src = paramSourceFromAccess(m[1]);
|
|
124
|
+
for (const name of patternBindings(target, source)) {
|
|
125
|
+
add({
|
|
126
|
+
name,
|
|
127
|
+
type: 'unknown',
|
|
128
|
+
source: src,
|
|
129
|
+
validated: false,
|
|
130
|
+
confidence: types_1.Confidence.STATIC_LITERAL,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// --- 3. accessors that name the parameter in a string ------------------
|
|
135
|
+
for (const { re, source: src } of NAMED_ACCESSORS) {
|
|
136
|
+
re.lastIndex = 0;
|
|
137
|
+
let m;
|
|
138
|
+
while ((m = re.exec(text)) !== null) {
|
|
139
|
+
add({
|
|
140
|
+
name: m[1],
|
|
141
|
+
type: 'unknown',
|
|
142
|
+
source: src,
|
|
143
|
+
validated: false,
|
|
144
|
+
confidence: types_1.Confidence.STATIC_LITERAL,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// --- 4. Python signature declarations ----------------------------------
|
|
149
|
+
for (const p of signatureParameters(node)) {
|
|
150
|
+
const txt = source.slice(p.startIndex, p.endIndex);
|
|
151
|
+
const nameNode = p.child(0);
|
|
152
|
+
if (!nameNode || !(0, astHelpers_1.isIdentifier)(nameNode))
|
|
153
|
+
continue;
|
|
154
|
+
const name = source.slice(nameNode.startIndex, nameNode.endIndex);
|
|
155
|
+
// A parameter annotated with a schema model IS the body: its fields,
|
|
156
|
+
// not the parameter name, are what the request carries.
|
|
157
|
+
const annotation = txt.split(':')[1]?.split('=')[0]?.trim();
|
|
158
|
+
const model = annotation ? schemas.get(annotation) : undefined;
|
|
159
|
+
if (model) {
|
|
160
|
+
for (const field of model.fields) {
|
|
161
|
+
add({
|
|
162
|
+
name: field,
|
|
163
|
+
type: 'unknown',
|
|
164
|
+
source: 'body',
|
|
165
|
+
validated: true,
|
|
166
|
+
validator: model.library,
|
|
167
|
+
confidence: types_1.Confidence.STATIC_LITERAL,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
let src = 'unknown';
|
|
173
|
+
if (/\bPath\s*\(/.test(txt))
|
|
174
|
+
src = 'path';
|
|
175
|
+
else if (/\bQuery\s*\(/.test(txt))
|
|
176
|
+
src = 'query';
|
|
177
|
+
else if (/\bBody\s*\(/.test(txt))
|
|
178
|
+
src = 'body';
|
|
179
|
+
else if (/\bHeader\s*\(/.test(txt))
|
|
180
|
+
src = 'header';
|
|
181
|
+
else if (/\bCookie\s*\(/.test(txt))
|
|
182
|
+
src = 'cookie';
|
|
183
|
+
if (src === 'unknown')
|
|
184
|
+
continue;
|
|
185
|
+
add({
|
|
186
|
+
name,
|
|
187
|
+
type: annotation || 'unknown',
|
|
188
|
+
source: src,
|
|
189
|
+
// FastAPI's Path()/Query()/Body() coerce and reject at the framework
|
|
190
|
+
// level, which is validation even without a validator library.
|
|
191
|
+
validated: true,
|
|
192
|
+
validator: 'pydantic',
|
|
193
|
+
confidence: types_1.Confidence.STATIC_LITERAL,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
// --- 5. schema validation calls: CreateUser.parse(req.body) ------------
|
|
197
|
+
for (const n of (0, astHelpers_1.walk)(node)) {
|
|
198
|
+
if (n.type !== 'call_expression' && n.type !== 'call')
|
|
199
|
+
continue;
|
|
200
|
+
const p = (0, astHelpers_1.callParts)(n, source);
|
|
201
|
+
if (!p || !p.receiver)
|
|
202
|
+
continue;
|
|
203
|
+
if (!schemas_1.SCHEMA_VALIDATION_METHODS.has(p.method))
|
|
204
|
+
continue;
|
|
205
|
+
const schema = schemas.get(p.receiver);
|
|
206
|
+
if (!schema)
|
|
207
|
+
continue;
|
|
208
|
+
for (const field of schema.fields) {
|
|
209
|
+
add({
|
|
210
|
+
name: field,
|
|
211
|
+
type: 'unknown',
|
|
212
|
+
source: 'body',
|
|
213
|
+
validated: true,
|
|
214
|
+
validator: schema.library,
|
|
215
|
+
confidence: types_1.Confidence.STATIC_LITERAL,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// Next.js path params arrive on the context object, which the folder name
|
|
220
|
+
// already told us about; nothing further to read from the body.
|
|
221
|
+
void framework;
|
|
222
|
+
return [...out.values()];
|
|
223
|
+
}
|
|
224
|
+
/** Names bound by an object/array destructuring pattern. */
|
|
225
|
+
function patternBindings(pattern, source) {
|
|
226
|
+
const names = [];
|
|
227
|
+
for (const n of (0, astHelpers_1.walk)(pattern)) {
|
|
228
|
+
if (n.type === 'shorthand_property_identifier_pattern'
|
|
229
|
+
|| n.type === 'shorthand_property_identifier') {
|
|
230
|
+
names.push(source.slice(n.startIndex, n.endIndex));
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (n.type === 'pair_pattern' || n.type === 'pair') {
|
|
234
|
+
const value = n.namedChildren[n.namedChildren.length - 1];
|
|
235
|
+
if (value && (0, astHelpers_1.isIdentifier)(value)) {
|
|
236
|
+
names.push(source.slice(value.startIndex, value.endIndex));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return names;
|
|
241
|
+
}
|
|
242
|
+
/** The parameter nodes of a function definition, if it has a signature. */
|
|
243
|
+
function signatureParameters(fn) {
|
|
244
|
+
const params = fn.namedChildren.find(c => c.type === 'parameters' || c.type === 'formal_parameters');
|
|
245
|
+
if (!params)
|
|
246
|
+
return [];
|
|
247
|
+
return params.namedChildren.filter(p => p.type === 'typed_parameter' || p.type === 'typed_default_parameter');
|
|
248
|
+
}
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// Call graph
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
/** Receivers that build the response rather than doing work the handler cares about. */
|
|
253
|
+
const RESPONSE_RECEIVERS = new Set([
|
|
254
|
+
'res', 'response', 'reply', 'ctx',
|
|
255
|
+
'NextResponse', 'JsonResponse', 'HttpResponse', 'HttpResponseRedirect',
|
|
256
|
+
]);
|
|
257
|
+
/** Free functions that construct a response (Django `render`, Flask `jsonify`). */
|
|
258
|
+
const RESPONSE_BUILDERS = new Set(Object.keys(detectors_1.RESPONSE_CONSTRUCTORS));
|
|
259
|
+
/** Build the call graph for a handler: every function it calls. */
|
|
260
|
+
function extractCallGraph(handlerRoot, source, imports) {
|
|
261
|
+
const seen = new Set();
|
|
262
|
+
const out = [];
|
|
263
|
+
// Framework declarations in the handler's own SIGNATURE — FastAPI's
|
|
264
|
+
// `Depends()` / `Query()` / `Path()` / `Body()` — describe how the request
|
|
265
|
+
// is bound, not work the handler does, and are already reflected in the
|
|
266
|
+
// params and authScheme. Their span is captured up front because
|
|
267
|
+
// tree-sitter hands back a fresh wrapper object on every `.parent` access,
|
|
268
|
+
// so ancestors cannot be compared by identity.
|
|
269
|
+
const signature = handlerRoot.namedChildren.find(c => c.type === 'parameters' || c.type === 'formal_parameters');
|
|
270
|
+
const inSignature = (n) => Boolean(signature)
|
|
271
|
+
&& n.startIndex >= signature.startIndex
|
|
272
|
+
&& n.endIndex <= signature.endIndex;
|
|
273
|
+
for (const n of (0, astHelpers_1.walk)(handlerRoot)) {
|
|
274
|
+
if (n.type !== 'call_expression' && n.type !== 'call')
|
|
275
|
+
continue;
|
|
276
|
+
const p = (0, astHelpers_1.callParts)(n, source);
|
|
277
|
+
if (!p)
|
|
278
|
+
continue;
|
|
279
|
+
// Skip res.json/send/etc. — those are response shape, not call graph.
|
|
280
|
+
if (p.receiver && RESPONSE_RECEIVERS.has(p.receiver))
|
|
281
|
+
continue;
|
|
282
|
+
if (!p.receiver && RESPONSE_BUILDERS.has(p.method))
|
|
283
|
+
continue;
|
|
284
|
+
// Skip the framework's own registration methods (don't recurse into app.get).
|
|
285
|
+
if (p.receiver === 'app' || p.receiver === 'router' || p.receiver === 'fastify' || p.receiver === 'server')
|
|
286
|
+
continue;
|
|
287
|
+
if (inSignature(n))
|
|
288
|
+
continue;
|
|
289
|
+
const name = p.receiver ? `${p.receiver}.${p.method}` : p.method;
|
|
290
|
+
if (seen.has(name))
|
|
291
|
+
continue;
|
|
292
|
+
seen.add(name);
|
|
293
|
+
const calleeFile = imports.get(p.receiver || p.method) || imports.get(p.method);
|
|
294
|
+
// A call written out in the source always happens; the only thing the
|
|
295
|
+
// import map adds is WHERE the callee is defined. Downgrading a call we
|
|
296
|
+
// can see to 0.6 because we cannot name its file conflates "does this
|
|
297
|
+
// call happen" with "which file defines it".
|
|
298
|
+
out.push({
|
|
299
|
+
name,
|
|
300
|
+
calleeFile,
|
|
301
|
+
callLine: p.line + 1,
|
|
302
|
+
confidence: types_1.Confidence.STATIC_LITERAL,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
// Handler lookup
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
/** Get the name of a handler function node (or '<anonymous>'). */
|
|
311
|
+
function handlerNameOf(node, source) {
|
|
312
|
+
if (!node)
|
|
313
|
+
return '<anonymous>';
|
|
314
|
+
// function foo() {}
|
|
315
|
+
const id = node.namedChildren.find(c => (0, astHelpers_1.isIdentifier)(c));
|
|
316
|
+
if (id)
|
|
317
|
+
return source.slice(id.startIndex, id.endIndex);
|
|
318
|
+
// const foo = (...) => {}
|
|
319
|
+
const parent = node.parent;
|
|
320
|
+
if (parent && (parent.type === 'lexical_declaration' || parent.type === 'variable_declaration' || parent.type === 'assignment_expression')) {
|
|
321
|
+
const name = parent.child(0);
|
|
322
|
+
if (name && (0, astHelpers_1.isIdentifier)(name))
|
|
323
|
+
return source.slice(name.startIndex, name.endIndex);
|
|
324
|
+
}
|
|
325
|
+
return '<anonymous>';
|
|
326
|
+
}
|
|
327
|
+
/** Locate a top-level (or nested) function declaration by name. */
|
|
328
|
+
function findFunctionByName(root, source, name) {
|
|
329
|
+
for (const n of (0, astHelpers_1.walk)(root)) {
|
|
330
|
+
if (n.type === 'function_declaration' || n.type === 'function_definition' || n.type === 'method_definition') {
|
|
331
|
+
const id = n.namedChildren.find(c => (0, astHelpers_1.isIdentifier)(c));
|
|
332
|
+
if (id && source.slice(id.startIndex, id.endIndex) === name)
|
|
333
|
+
return n;
|
|
334
|
+
}
|
|
335
|
+
// const name = (...) => {}
|
|
336
|
+
if (n.type === 'lexical_declaration' || n.type === 'variable_declaration') {
|
|
337
|
+
for (const decl of n.namedChildren) {
|
|
338
|
+
if (decl.type !== 'variable_declarator')
|
|
339
|
+
continue;
|
|
340
|
+
const declName = decl.child(0);
|
|
341
|
+
if (declName && (0, astHelpers_1.isIdentifier)(declName) && source.slice(declName.startIndex, declName.endIndex) === name) {
|
|
342
|
+
const fnNode = decl.child(decl.childCount - 1);
|
|
343
|
+
if (fnNode && (fnNode.type === 'arrow_function' || fnNode.type === 'function_expression')) {
|
|
344
|
+
return fnNode;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* The Python function a decorator is attached to, for `@app.get("/x")` route
|
|
354
|
+
* decorators. Returns null for any other call node.
|
|
355
|
+
*/
|
|
356
|
+
function decoratedFunction(callNode) {
|
|
357
|
+
const decorator = callNode.parent;
|
|
358
|
+
if (!decorator || decorator.type !== 'decorator')
|
|
359
|
+
return null;
|
|
360
|
+
const owner = decorator.parent;
|
|
361
|
+
if (!owner || owner.type !== 'decorated_definition')
|
|
362
|
+
return null;
|
|
363
|
+
return owner.namedChildren.find(c => c.type === 'function_definition' || c.type === 'async_function_definition') ?? null;
|
|
364
|
+
}
|
|
365
|
+
/** Decorator source text applied to a Python function definition. */
|
|
366
|
+
function decoratorsOf(fn, source) {
|
|
367
|
+
const owner = fn.parent;
|
|
368
|
+
if (!owner || owner.type !== 'decorated_definition')
|
|
369
|
+
return [];
|
|
370
|
+
return owner.namedChildren
|
|
371
|
+
.filter(c => c.type === 'decorator')
|
|
372
|
+
.map(c => source.slice(c.startIndex, c.endIndex));
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Resolve the first argument of a route registration to a path.
|
|
376
|
+
*
|
|
377
|
+
* A string literal is taken as written. A bare identifier is resolved against
|
|
378
|
+
* the file's string assignments (`const BASE = '/v2'`) at intra-file-dataflow
|
|
379
|
+
* confidence. A template literal with an interpolation is genuinely unknowable
|
|
380
|
+
* statically, so it surfaces as '?' per the EndpointRecord contract rather
|
|
381
|
+
* than causing the endpoint to be dropped.
|
|
382
|
+
*/
|
|
383
|
+
function resolveRoutePath(node, source, assignments) {
|
|
384
|
+
if (!node)
|
|
385
|
+
return { path: '?', confidence: types_1.Confidence.UNRESOLVED };
|
|
386
|
+
if ((0, astHelpers_1.isStringLiteral)(node)) {
|
|
387
|
+
return { path: (0, astHelpers_1.stringLiteralValue)(node, source), confidence: types_1.Confidence.STATIC_LITERAL };
|
|
388
|
+
}
|
|
389
|
+
if (node.type === 'template_string' || node.type === 'template_literal') {
|
|
390
|
+
const interpolated = node.namedChildren.some(c => c.type === 'template_substitution' || c.type === 'template_expr');
|
|
391
|
+
if (interpolated)
|
|
392
|
+
return { path: '?', confidence: types_1.Confidence.UNRESOLVED };
|
|
393
|
+
const raw = source.slice(node.startIndex, node.endIndex);
|
|
394
|
+
return { path: raw.replace(/^`|`$/g, ''), confidence: types_1.Confidence.STATIC_LITERAL };
|
|
395
|
+
}
|
|
396
|
+
if ((0, astHelpers_1.isIdentifier)(node)) {
|
|
397
|
+
const name = source.slice(node.startIndex, node.endIndex);
|
|
398
|
+
const resolved = (0, astHelpers_1.resolveIdentifier)(name, assignments);
|
|
399
|
+
return resolved === null
|
|
400
|
+
? { path: '?', confidence: types_1.Confidence.UNRESOLVED }
|
|
401
|
+
: { path: resolved, confidence: types_1.Confidence.INTRA_FILE_DATAFLOW };
|
|
402
|
+
}
|
|
403
|
+
return { path: '?', confidence: types_1.Confidence.UNRESOLVED };
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Route path for a Next.js app-router file, derived from where the file sits.
|
|
407
|
+
*
|
|
408
|
+
* `app/api/products/[id]/route.ts` -> `/api/products/[id]`. Route groups
|
|
409
|
+
* (`(marketing)`) and parallel-route slots (`@modal`) are organisational and
|
|
410
|
+
* contribute nothing to the URL. Returns null when the file is not a route.
|
|
411
|
+
*/
|
|
412
|
+
function nextAppRouterPath(file) {
|
|
413
|
+
const normalised = file.replace(/\\/g, '/');
|
|
414
|
+
const appMatch = /(?:^|\/)app\/(.*\/)?route\.(?:t|j)sx?$/.exec(normalised);
|
|
415
|
+
if (appMatch) {
|
|
416
|
+
const segments = (appMatch[1] ?? '')
|
|
417
|
+
.split('/')
|
|
418
|
+
.filter(s => s && !/^\(.*\)$/.test(s) && !s.startsWith('@'));
|
|
419
|
+
return '/' + segments.join('/');
|
|
420
|
+
}
|
|
421
|
+
// Pages router: pages/api/orders/[id].ts -> /api/orders/[id]
|
|
422
|
+
const pagesMatch = /(?:^|\/)pages\/(api\/.*)\.(?:t|j)sx?$/.exec(normalised);
|
|
423
|
+
if (pagesMatch) {
|
|
424
|
+
const segments = pagesMatch[1].split('/').filter(Boolean);
|
|
425
|
+
const last = segments[segments.length - 1];
|
|
426
|
+
if (last === 'index')
|
|
427
|
+
segments.pop();
|
|
428
|
+
return '/' + segments.join('/');
|
|
429
|
+
}
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
/** HTTP methods Next.js app-router files export as named functions. */
|
|
433
|
+
const NEXT_METHOD_EXPORTS = new Set([
|
|
434
|
+
'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS',
|
|
435
|
+
]);
|
|
436
|
+
/** Path params declared by a route path, across the frameworks' syntaxes. */
|
|
437
|
+
function pathParams(routePath) {
|
|
438
|
+
const out = [];
|
|
439
|
+
const push = (name, validated) => {
|
|
440
|
+
if (!name || out.some(p => p.name === name))
|
|
441
|
+
return;
|
|
442
|
+
out.push({
|
|
443
|
+
name,
|
|
444
|
+
type: 'unknown',
|
|
445
|
+
source: 'path',
|
|
446
|
+
validated,
|
|
447
|
+
confidence: types_1.Confidence.STATIC_LITERAL,
|
|
448
|
+
});
|
|
449
|
+
};
|
|
450
|
+
// Next.js: [id], [...slug], [[...slug]]
|
|
451
|
+
for (const m of routePath.matchAll(/\[+\.{0,3}([^\]]+?)\]+/g))
|
|
452
|
+
push(m[1], false);
|
|
453
|
+
// Django: <int:pk>, <slug>. A converter coerces and rejects, which is
|
|
454
|
+
// validation performed by the framework rather than a validator library.
|
|
455
|
+
for (const m of routePath.matchAll(/<(?:(\w+):)?(\w+)>/g))
|
|
456
|
+
push(m[2], Boolean(m[1]));
|
|
457
|
+
return out;
|
|
458
|
+
}
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
460
|
+
// Cross-file resolution
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
/** True for a middleware expression we cannot resolve to anything at all. */
|
|
463
|
+
function isDynamicExpression(text) {
|
|
464
|
+
return /require\s*\(|process\.env|import\s*\(|os\.environ|getenv/.test(text);
|
|
465
|
+
}
|
|
466
|
+
/** Strip a trailing call so `cors()` and `express.json()` name something. */
|
|
467
|
+
function calleeBase(text) {
|
|
468
|
+
const trimmed = text.trim();
|
|
469
|
+
const identifier = /^([A-Za-z_$][\w$]*)/.exec(trimmed);
|
|
470
|
+
return identifier ? identifier[1] : trimmed;
|
|
471
|
+
}
|
|
472
|
+
/** Auth scheme implemented by a named middleware or dependency function. */
|
|
473
|
+
function authOfName(name, file, root, source, imports, ctx) {
|
|
474
|
+
const base = calleeBase(name);
|
|
475
|
+
const spec = imports.get(base);
|
|
476
|
+
const dep = spec && ctx ? ctx.deps.get(spec) : undefined;
|
|
477
|
+
if (dep) {
|
|
478
|
+
const fn = findFunctionByName(dep.root, dep.source, base);
|
|
479
|
+
if (fn)
|
|
480
|
+
return (0, detectors_1.detectAuthInFunction)(fn, dep.source);
|
|
481
|
+
return (0, detectors_1.detectAuth)(dep.imports, dep.root, dep.source);
|
|
482
|
+
}
|
|
483
|
+
const local = findFunctionByName(root, source, base);
|
|
484
|
+
if (local)
|
|
485
|
+
return (0, detectors_1.detectAuthInFunction)(local, source);
|
|
486
|
+
void file;
|
|
487
|
+
return 'none';
|
|
488
|
+
}
|
|
489
|
+
/** True iff a Next.js middleware matcher covers this route path. */
|
|
490
|
+
function matchesNextMatcher(routePath, matchers) {
|
|
491
|
+
for (const raw of matchers) {
|
|
492
|
+
const prefix = raw.replace(/\/?:[\w]+\*?$/, '').replace(/\/?\(\.\*\)$/, '');
|
|
493
|
+
if (prefix === raw) {
|
|
494
|
+
if (routePath === raw)
|
|
495
|
+
return true;
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
if (routePath === prefix || routePath.startsWith(prefix + '/'))
|
|
499
|
+
return true;
|
|
500
|
+
}
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
/** FastAPI `Depends(name)` references inside a node. */
|
|
504
|
+
function dependsNames(node, source) {
|
|
505
|
+
const out = [];
|
|
506
|
+
for (const n of (0, astHelpers_1.walk)(node)) {
|
|
507
|
+
if (n.type !== 'call')
|
|
508
|
+
continue;
|
|
509
|
+
const p = (0, astHelpers_1.callParts)(n, source);
|
|
510
|
+
if (!p || p.receiver !== null || p.method !== 'Depends')
|
|
511
|
+
continue;
|
|
512
|
+
const arg = p.args[0];
|
|
513
|
+
if (!arg)
|
|
514
|
+
continue;
|
|
515
|
+
out.push(source.slice(arg.startIndex, arg.endIndex).trim());
|
|
516
|
+
}
|
|
517
|
+
return out;
|
|
518
|
+
}
|
|
519
|
+
/** Data layer this file uses, following one hop through a local re-export. */
|
|
520
|
+
function resolveDataLayer(imports, root, source, ctx) {
|
|
521
|
+
const direct = (0, detectors_1.detectDataLayer)(imports);
|
|
522
|
+
if (direct !== 'none') {
|
|
523
|
+
return { layer: direct, bindings: (0, detectors_1.dataLayerBindings)(direct, imports, root, source) };
|
|
524
|
+
}
|
|
525
|
+
// A local module can be the data layer: `lib/prisma.ts` constructs the
|
|
526
|
+
// client and re-exports it, so the route file never imports @prisma/client.
|
|
527
|
+
if (!ctx)
|
|
528
|
+
return { layer: 'none', bindings: new Set() };
|
|
529
|
+
for (const [spec, dep] of ctx.deps) {
|
|
530
|
+
const depLayer = (0, detectors_1.detectDataLayer)(dep.imports);
|
|
531
|
+
if (depLayer === 'none')
|
|
532
|
+
continue;
|
|
533
|
+
const seed = [...imports.entries()]
|
|
534
|
+
.filter(([, s]) => s === spec)
|
|
535
|
+
.map(([local]) => local);
|
|
536
|
+
if (seed.length === 0)
|
|
537
|
+
continue;
|
|
538
|
+
return {
|
|
539
|
+
layer: depLayer,
|
|
540
|
+
bindings: (0, detectors_1.dataLayerBindings)(depLayer, imports, root, source, seed),
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
return { layer: 'none', bindings: new Set() };
|
|
544
|
+
}
|
|
545
|
+
/** True iff this handler actually touches the data layer the file provides. */
|
|
546
|
+
function usesDataLayer(handlerText, layer, bindings) {
|
|
547
|
+
if (layer === 'none')
|
|
548
|
+
return false;
|
|
549
|
+
if (layer === 'django-orm' && /\.objects\./.test(handlerText))
|
|
550
|
+
return true;
|
|
551
|
+
for (const b of bindings) {
|
|
552
|
+
if (new RegExp(`\\b${b.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(handlerText)) {
|
|
553
|
+
return true;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
// ---------------------------------------------------------------------------
|
|
559
|
+
// Extraction
|
|
560
|
+
// ---------------------------------------------------------------------------
|
|
561
|
+
/**
|
|
562
|
+
* Extract all endpoints + global middleware from a parsed file.
|
|
563
|
+
*
|
|
564
|
+
* @param file workspace-relative file path
|
|
565
|
+
* @param source file source text
|
|
566
|
+
* @param root parsed Tree-sitter root node
|
|
567
|
+
* @param ctx cross-file facts, when the caller could resolve them
|
|
568
|
+
* @returns Layer1Result (endpoints, global middleware, imports)
|
|
569
|
+
*/
|
|
570
|
+
function extractLayer1(file, source, root, ctx) {
|
|
571
|
+
const assignments = (0, astHelpers_1.collectStringAssignments)(root, source);
|
|
572
|
+
const imports = (0, astHelpers_1.collectImports)(root, source);
|
|
573
|
+
const schemas = (0, schemas_1.collectSchemas)(root, source, imports);
|
|
574
|
+
const fileAuth = (0, detectors_1.detectAuth)(imports, root, source);
|
|
575
|
+
const data = resolveDataLayer(imports, root, source, ctx);
|
|
576
|
+
const endpoints = [];
|
|
577
|
+
const globalMiddleware = [];
|
|
578
|
+
const drafts = [];
|
|
579
|
+
// Pass 1: collect app.use(...) global middleware registrations in order.
|
|
580
|
+
for (const n of (0, astHelpers_1.walk)(root)) {
|
|
581
|
+
const use = (0, detectors_1.isUseRegistration)(n, source);
|
|
582
|
+
if (!use)
|
|
583
|
+
continue;
|
|
584
|
+
// app.use('/api', auth) -> pathPrefix='/api', mw=auth
|
|
585
|
+
// app.use(cors) -> pathPrefix=null, mw=cors
|
|
586
|
+
let pathPrefix = null;
|
|
587
|
+
let mwIndex = 0;
|
|
588
|
+
if (use.args.length >= 2 && (0, astHelpers_1.isStringLiteral)(use.args[0])) {
|
|
589
|
+
pathPrefix = (0, astHelpers_1.stringLiteralValue)(use.args[0], source);
|
|
590
|
+
mwIndex = 1;
|
|
591
|
+
}
|
|
592
|
+
for (let i = mwIndex; i < use.args.length; i++) {
|
|
593
|
+
const arg = use.args[i];
|
|
594
|
+
const rawName = (0, astHelpers_1.isIdentifier)(arg) ? source.slice(arg.startIndex, arg.endIndex) : (0, parserLoader_1.nodeText)(arg, source);
|
|
595
|
+
const name = calleeBase(rawName);
|
|
596
|
+
// `app.use(cors())` registers the result of calling an import, so
|
|
597
|
+
// the import has to be looked up under the callee's name.
|
|
598
|
+
const importedFrom = imports.get(name) ?? imports.get(calleeBase(name));
|
|
599
|
+
globalMiddleware.push({
|
|
600
|
+
name,
|
|
601
|
+
registrationLine: use.line + 1,
|
|
602
|
+
sourceFile: importedFrom ?? file,
|
|
603
|
+
pathPrefix,
|
|
604
|
+
confidence: importedFrom ? types_1.Confidence.STATIC_LITERAL : types_1.Confidence.INTRA_FILE_DATAFLOW,
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
// Router-level FastAPI dependencies: APIRouter(dependencies=[Depends(x)])
|
|
609
|
+
// applies to every route registered on that router.
|
|
610
|
+
const routerDependencies = new Map();
|
|
611
|
+
for (const n of (0, astHelpers_1.walk)(root)) {
|
|
612
|
+
if (n.type !== 'variable_declarator' && n.type !== 'assignment')
|
|
613
|
+
continue;
|
|
614
|
+
const target = n.child(0);
|
|
615
|
+
const value = n.child(n.childCount - 1);
|
|
616
|
+
if (!target || !value || target === value || !(0, astHelpers_1.isIdentifier)(target))
|
|
617
|
+
continue;
|
|
618
|
+
if (value.type !== 'call' && value.type !== 'call_expression')
|
|
619
|
+
continue;
|
|
620
|
+
const p = (0, astHelpers_1.callParts)(value, source);
|
|
621
|
+
if (!p || (p.method !== 'APIRouter' && p.method !== 'FastAPI'))
|
|
622
|
+
continue;
|
|
623
|
+
routerDependencies.set(source.slice(target.startIndex, target.endIndex), dependsNames(value, source));
|
|
624
|
+
}
|
|
625
|
+
// Pass 2: router-object route registrations (Express/Fastify/Koa/FastAPI).
|
|
626
|
+
for (const n of (0, astHelpers_1.walk)(root)) {
|
|
627
|
+
const route = (0, detectors_1.isRouteRegistration)(n, source);
|
|
628
|
+
if (!route)
|
|
629
|
+
continue;
|
|
630
|
+
const resolved = resolveRoutePath(route.args[0], source, assignments);
|
|
631
|
+
// A Python decorator route (`@app.get("/x")`) carries only route
|
|
632
|
+
// configuration in its arguments; the handler is the function the
|
|
633
|
+
// decorator is attached to. Reading the last argument as the handler
|
|
634
|
+
// there picks up the path string and binds nothing.
|
|
635
|
+
const decoratedFn = decoratedFunction(n);
|
|
636
|
+
const handlerNode = route.args[route.args.length - 1];
|
|
637
|
+
const routeMiddlewareNodes = decoratedFn ? [] : route.args.slice(1, route.args.length - 1);
|
|
638
|
+
let handlerFn = decoratedFn;
|
|
639
|
+
if (!handlerFn && handlerNode && (0, astHelpers_1.isIdentifier)(handlerNode)) {
|
|
640
|
+
handlerFn = findFunctionByName(root, source, source.slice(handlerNode.startIndex, handlerNode.endIndex));
|
|
641
|
+
}
|
|
642
|
+
else if (!handlerFn && handlerNode && (handlerNode.type === 'arrow_function' || handlerNode.type === 'function_expression')) {
|
|
643
|
+
handlerFn = handlerNode;
|
|
644
|
+
}
|
|
645
|
+
const handlerName = decoratedFn
|
|
646
|
+
? handlerNameOf(decoratedFn, source)
|
|
647
|
+
: (handlerNode && (0, astHelpers_1.isIdentifier)(handlerNode)
|
|
648
|
+
? source.slice(handlerNode.startIndex, handlerNode.endIndex)
|
|
649
|
+
: handlerNameOf(handlerFn, source));
|
|
650
|
+
const middleware = [];
|
|
651
|
+
for (const g of globalMiddleware) {
|
|
652
|
+
if (g.pathPrefix === null || resolved.path === '?' || resolved.path.startsWith(g.pathPrefix)) {
|
|
653
|
+
middleware.push({
|
|
654
|
+
name: g.name,
|
|
655
|
+
sourceFile: g.sourceFile,
|
|
656
|
+
registrationLine: g.registrationLine,
|
|
657
|
+
confidence: g.confidence,
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
for (const mwn of routeMiddlewareNodes) {
|
|
662
|
+
const name = (0, astHelpers_1.isIdentifier)(mwn) ? source.slice(mwn.startIndex, mwn.endIndex) : (0, parserLoader_1.nodeText)(mwn, source);
|
|
663
|
+
const importedFrom = imports.get(name) ?? imports.get(calleeBase(name));
|
|
664
|
+
middleware.push({
|
|
665
|
+
name,
|
|
666
|
+
sourceFile: importedFrom ?? file,
|
|
667
|
+
registrationLine: route.line + 1,
|
|
668
|
+
confidence: importedFrom ? types_1.Confidence.STATIC_LITERAL : types_1.Confidence.INTRA_FILE_DATAFLOW,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
const dependencies = [
|
|
672
|
+
...(route.receiver ? routerDependencies.get(route.receiver) ?? [] : []),
|
|
673
|
+
...(decoratedFn ? dependsNames(decoratedFn, source) : []),
|
|
674
|
+
];
|
|
675
|
+
drafts.push({
|
|
676
|
+
framework: decoratedFn ? 'fastapi' : 'express',
|
|
677
|
+
method: (0, detectors_1.normalizeMethod)(route.method),
|
|
678
|
+
path: resolved.path,
|
|
679
|
+
pathConfidence: resolved.confidence,
|
|
680
|
+
line: route.line + 1,
|
|
681
|
+
handlerName,
|
|
682
|
+
handler: handlerFn ? { node: handlerFn, source, file, imports, schemas } : null,
|
|
683
|
+
middleware,
|
|
684
|
+
inherited: ctx?.inherited ?? [],
|
|
685
|
+
seedParams: [],
|
|
686
|
+
dependencies,
|
|
687
|
+
decorators: decoratedFn ? decoratorsOf(decoratedFn, source) : [],
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
// Pass 3: Next.js app router — method and path come from the filesystem,
|
|
691
|
+
// so there is no registration call to find.
|
|
692
|
+
const nextPath = nextAppRouterPath(file);
|
|
693
|
+
if (nextPath !== null) {
|
|
694
|
+
for (const n of (0, astHelpers_1.walk)(root)) {
|
|
695
|
+
if (n.type !== 'function_declaration' && n.type !== 'generator_function_declaration')
|
|
696
|
+
continue;
|
|
697
|
+
const id = n.namedChildren.find(c => (0, astHelpers_1.isIdentifier)(c));
|
|
698
|
+
if (!id)
|
|
699
|
+
continue;
|
|
700
|
+
const name = source.slice(id.startIndex, id.endIndex);
|
|
701
|
+
if (!NEXT_METHOD_EXPORTS.has(name))
|
|
702
|
+
continue;
|
|
703
|
+
if (!(0, astHelpers_1.firstAncestor)(n, a => a.type === 'export_statement'))
|
|
704
|
+
continue;
|
|
705
|
+
drafts.push({
|
|
706
|
+
framework: 'nextjs',
|
|
707
|
+
method: name,
|
|
708
|
+
path: nextPath,
|
|
709
|
+
pathConfidence: types_1.Confidence.STATIC_LITERAL,
|
|
710
|
+
line: n.startPosition.row + 1,
|
|
711
|
+
handlerName: name,
|
|
712
|
+
handler: { node: n, source, file, imports, schemas },
|
|
713
|
+
middleware: [],
|
|
714
|
+
inherited: ctx?.inherited ?? [],
|
|
715
|
+
seedParams: pathParams(nextPath),
|
|
716
|
+
dependencies: [],
|
|
717
|
+
decorators: [],
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
// Pass 4: Django URLconf. The registration site says nothing about the
|
|
722
|
+
// method, the params or the response — all of that lives in the view.
|
|
723
|
+
for (const n of (0, astHelpers_1.walk)(root)) {
|
|
724
|
+
const entry = (0, detectors_1.isDjangoRouteRegistration)(n, source);
|
|
725
|
+
if (!entry)
|
|
726
|
+
continue;
|
|
727
|
+
const routePath = (0, astHelpers_1.stringLiteralValue)(entry.path, source);
|
|
728
|
+
const view = entry.view ? resolveDjangoView(entry.view, source, root, imports, ctx) : null;
|
|
729
|
+
drafts.push({
|
|
730
|
+
framework: 'django',
|
|
731
|
+
method: view ? djangoMethod(view.decorators) : 'GET',
|
|
732
|
+
path: routePath,
|
|
733
|
+
pathConfidence: types_1.Confidence.STATIC_LITERAL,
|
|
734
|
+
line: entry.line + 1,
|
|
735
|
+
handlerName: view?.name ?? '<anonymous>',
|
|
736
|
+
handler: view?.handler ?? null,
|
|
737
|
+
middleware: [],
|
|
738
|
+
inherited: ctx?.inherited ?? [],
|
|
739
|
+
seedParams: pathParams(routePath),
|
|
740
|
+
dependencies: [],
|
|
741
|
+
decorators: view?.decorators ?? [],
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
// Shared per-endpoint pass.
|
|
745
|
+
for (const d of drafts) {
|
|
746
|
+
endpoints.push(buildEndpoint(d, file, root, source, imports, fileAuth, data, ctx));
|
|
747
|
+
}
|
|
748
|
+
return {
|
|
749
|
+
endpoints,
|
|
750
|
+
globalMiddleware,
|
|
751
|
+
imports: Object.fromEntries(imports),
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
/** Resolve `views.article_list` in a URLconf to the view function's body. */
|
|
755
|
+
function resolveDjangoView(node, source, root, imports, ctx) {
|
|
756
|
+
const text = source.slice(node.startIndex, node.endIndex).trim();
|
|
757
|
+
const segments = text.split('.');
|
|
758
|
+
const name = segments[segments.length - 1];
|
|
759
|
+
const moduleAlias = segments.length > 1 ? segments[0] : name;
|
|
760
|
+
const spec = imports.get(moduleAlias) ?? imports.get(name);
|
|
761
|
+
const dep = spec && ctx ? ctx.deps.get(spec) : undefined;
|
|
762
|
+
if (dep) {
|
|
763
|
+
const fn = findFunctionByName(dep.root, dep.source, name);
|
|
764
|
+
if (fn) {
|
|
765
|
+
return {
|
|
766
|
+
name,
|
|
767
|
+
handler: {
|
|
768
|
+
node: fn,
|
|
769
|
+
source: dep.source,
|
|
770
|
+
file: dep.file,
|
|
771
|
+
imports: dep.imports,
|
|
772
|
+
schemas: (0, schemas_1.collectSchemas)(dep.root, dep.source, dep.imports),
|
|
773
|
+
},
|
|
774
|
+
decorators: decoratorsOf(fn, dep.source),
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
const local = findFunctionByName(root, source, name);
|
|
779
|
+
if (local) {
|
|
780
|
+
return {
|
|
781
|
+
name,
|
|
782
|
+
handler: { node: local, source, file: '', imports, schemas: new Map() },
|
|
783
|
+
decorators: decoratorsOf(local, source),
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
return { name, handler: null, decorators: [] };
|
|
787
|
+
}
|
|
788
|
+
/** HTTP method a Django view is restricted to by its decorators. */
|
|
789
|
+
function djangoMethod(decorators) {
|
|
790
|
+
for (const d of decorators) {
|
|
791
|
+
if (/@require_POST\b/.test(d))
|
|
792
|
+
return 'POST';
|
|
793
|
+
if (/@require_GET\b/.test(d))
|
|
794
|
+
return 'GET';
|
|
795
|
+
if (/@require_safe\b/.test(d))
|
|
796
|
+
return 'GET';
|
|
797
|
+
const m = /@require_http_methods\(\s*\[([^\]]*)\]/.exec(d);
|
|
798
|
+
if (m) {
|
|
799
|
+
const methods = [...m[1].matchAll(/['"](\w+)['"]/g)].map(x => x[1].toUpperCase());
|
|
800
|
+
if (methods.length === 1)
|
|
801
|
+
return methods[0];
|
|
802
|
+
if (methods.length > 1)
|
|
803
|
+
return 'ALL';
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return 'GET';
|
|
807
|
+
}
|
|
808
|
+
/** Auth a Django view's decorators establish. */
|
|
809
|
+
function djangoDecoratorAuth(decorators) {
|
|
810
|
+
for (const d of decorators) {
|
|
811
|
+
if (/@login_required|@permission_required|@staff_member_required|@user_passes_test/.test(d)) {
|
|
812
|
+
return 'session';
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
return 'none';
|
|
816
|
+
}
|
|
817
|
+
/** Turn a draft into the finished record: params, calls, auth, ORM, confidence. */
|
|
818
|
+
function buildEndpoint(d, file, root, source, imports, fileAuth, data, ctx) {
|
|
819
|
+
const middleware = [
|
|
820
|
+
...d.inherited.map(m => ({
|
|
821
|
+
name: m.name,
|
|
822
|
+
sourceFile: m.sourceFile,
|
|
823
|
+
registrationLine: m.registrationLine,
|
|
824
|
+
confidence: m.confidence,
|
|
825
|
+
})),
|
|
826
|
+
...d.middleware,
|
|
827
|
+
];
|
|
828
|
+
let params = d.seedParams;
|
|
829
|
+
let callGraph = [];
|
|
830
|
+
let responseShape = 'unknown';
|
|
831
|
+
let dataLayer = 'none';
|
|
832
|
+
if (d.handler) {
|
|
833
|
+
params = extractParams(d.handler, d.seedParams, d.framework);
|
|
834
|
+
callGraph = extractCallGraph(d.handler.node, d.handler.source, d.handler.imports);
|
|
835
|
+
responseShape = (0, detectors_1.detectResponseShape)(d.handler.node, d.handler.source);
|
|
836
|
+
const handlerText = d.handler.source.slice(d.handler.node.startIndex, d.handler.node.endIndex);
|
|
837
|
+
// Django's route file and view file are different files, so the data
|
|
838
|
+
// layer has to be judged from whichever file the body lives in.
|
|
839
|
+
const scope = d.handler.file && d.handler.file !== file
|
|
840
|
+
? resolveDataLayer(d.handler.imports, d.handler.node, d.handler.source, undefined)
|
|
841
|
+
: data;
|
|
842
|
+
if (usesDataLayer(handlerText, scope.layer, scope.bindings)) {
|
|
843
|
+
dataLayer = (0, detectors_1.toDataLayer)(scope.layer);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
// FastAPI serialises whatever the handler returns, so a handler that never
|
|
847
|
+
// touches a response object still answers with JSON.
|
|
848
|
+
if (responseShape === 'unknown' && d.framework === 'fastapi')
|
|
849
|
+
responseShape = 'json';
|
|
850
|
+
// The validator that covers THIS endpoint is the one its params went
|
|
851
|
+
// through — a sibling route's zod import says nothing about this route.
|
|
852
|
+
const validated = params.find(p => p.validator);
|
|
853
|
+
const validatorLibrary = validated?.validator ?? 'none';
|
|
854
|
+
const authScheme = resolveAuth(d, middleware, file, root, source, imports, fileAuth, ctx);
|
|
855
|
+
const allConfs = [
|
|
856
|
+
d.pathConfidence,
|
|
857
|
+
...middleware.map(m => m.confidence),
|
|
858
|
+
...params.map(p => p.confidence),
|
|
859
|
+
...callGraph.map(c => c.confidence),
|
|
860
|
+
];
|
|
861
|
+
const mounted = ctx?.mountPrefix ? (0, crossFile_1.joinMountedPath)(ctx.mountPrefix, d.path) : undefined;
|
|
862
|
+
return {
|
|
863
|
+
id: `${file}:${d.line}:${d.method}:${d.path}`,
|
|
864
|
+
method: d.method,
|
|
865
|
+
path: d.path,
|
|
866
|
+
mountedPath: mounted && mounted !== d.path ? mounted : undefined,
|
|
867
|
+
handlerName: d.handlerName,
|
|
868
|
+
sourceFile: file,
|
|
869
|
+
line: d.line,
|
|
870
|
+
middleware,
|
|
871
|
+
params,
|
|
872
|
+
authScheme,
|
|
873
|
+
dataLayer,
|
|
874
|
+
validatorLibrary,
|
|
875
|
+
callGraph,
|
|
876
|
+
responseShape,
|
|
877
|
+
pathConfidence: d.pathConfidence,
|
|
878
|
+
confidence: Math.min(...allConfs),
|
|
879
|
+
runtimeConfirmed: false,
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Which auth scheme actually guards this endpoint.
|
|
884
|
+
*
|
|
885
|
+
* Answered from the guards themselves — the middleware chain (including the
|
|
886
|
+
* chain inherited from wherever the router is mounted), framework
|
|
887
|
+
* dependencies, and decorators — rather than from what the file imports. A
|
|
888
|
+
* router file behind `app.use('/api', requireAuth)` imports nothing
|
|
889
|
+
* auth-related and is still authenticated; a file that imports `jsonwebtoken`
|
|
890
|
+
* for one route does not thereby protect the others.
|
|
891
|
+
*/
|
|
892
|
+
function resolveAuth(d, middleware, file, root, source, imports, fileAuth, ctx) {
|
|
893
|
+
// Next.js edge middleware guards by URL pattern, not by import.
|
|
894
|
+
if (ctx?.nextMiddleware && matchesNextMatcher(d.path, ctx.nextMiddleware.matchers)) {
|
|
895
|
+
return ctx.nextMiddleware.auth;
|
|
896
|
+
}
|
|
897
|
+
const fromDecorator = djangoDecoratorAuth(d.decorators);
|
|
898
|
+
if (fromDecorator !== 'none')
|
|
899
|
+
return fromDecorator;
|
|
900
|
+
for (const dep of d.dependencies) {
|
|
901
|
+
const scheme = authOfName(dep, file, root, source, imports, ctx);
|
|
902
|
+
if (scheme !== 'none')
|
|
903
|
+
return scheme;
|
|
904
|
+
}
|
|
905
|
+
let sawDynamic = false;
|
|
906
|
+
for (const m of d.inherited) {
|
|
907
|
+
if (m.dynamic) {
|
|
908
|
+
sawDynamic = true;
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
if (m.auth !== 'none')
|
|
912
|
+
return m.auth;
|
|
913
|
+
}
|
|
914
|
+
for (const m of middleware) {
|
|
915
|
+
if (isDynamicExpression(m.name)) {
|
|
916
|
+
sawDynamic = true;
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
const scheme = authOfName(m.name, file, root, source, imports, ctx);
|
|
920
|
+
if (scheme !== 'none')
|
|
921
|
+
return scheme;
|
|
922
|
+
}
|
|
923
|
+
// A guard we could not resolve at all is not the same as no guard.
|
|
924
|
+
if (sawDynamic)
|
|
925
|
+
return 'unknown';
|
|
926
|
+
return fileAuth;
|
|
927
|
+
}
|
|
928
|
+
//# sourceMappingURL=layer1.js.map
|