sparda-mcp 0.5.2 → 0.5.4
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/README.md +158 -40
- package/SKILL.md +144 -0
- package/demo-app/package.json +7 -0
- package/demo-app/src/app.js +28 -0
- package/demo-app/src/routes/users.js +8 -0
- package/package.json +25 -3
- package/src/commands/demo.js +154 -0
- package/src/commands/doctor.js +51 -17
- package/src/commands/hook.js +21 -5
- package/src/commands/init.js +105 -33
- package/src/commands/remove.js +29 -10
- package/src/commands/report.js +372 -0
- package/src/commands/sync.js +37 -8
- package/src/detect.js +68 -18
- package/src/generator/express.js +86 -28
- package/src/generator/fastapi.js +66 -20
- package/src/generator/manifest.js +20 -13
- package/src/index.js +22 -2
- package/src/parser/express.js +144 -44
- package/src/parser/fastapi.js +13 -4
- package/src/probe/express-shim-esm.mjs +2 -2
- package/src/probe/express-shim.cjs +65 -22
- package/src/probe/integrate.js +34 -12
- package/src/probe/probe.js +59 -33
- package/src/probe/reconcile.js +24 -22
- package/src/security/sanitize.js +5 -1
- package/src/server/condenser.js +34 -12
- package/src/server/confirmation.js +75 -19
- package/src/server/context-carrier.js +36 -31
- package/src/server/crystallize.js +32 -9
- package/src/server/engine.js +118 -38
- package/src/server/idle.js +20 -4
- package/src/server/persistence.js +16 -5
- package/src/server/stdio.js +490 -162
- package/src/ui/style.js +30 -18
- package/templates/fastapi-router.txt +41 -13
package/src/parser/express.js
CHANGED
|
@@ -6,7 +6,15 @@ import traverseModule from '@babel/traverse';
|
|
|
6
6
|
const traverse = traverseModule.default ?? traverseModule;
|
|
7
7
|
|
|
8
8
|
const HTTP = new Set(['get', 'post', 'put', 'patch', 'delete']);
|
|
9
|
-
const EXCLUDE = new Set([
|
|
9
|
+
const EXCLUDE = new Set([
|
|
10
|
+
'node_modules',
|
|
11
|
+
'.git',
|
|
12
|
+
'dist',
|
|
13
|
+
'build',
|
|
14
|
+
'.next',
|
|
15
|
+
'coverage',
|
|
16
|
+
'.sparda',
|
|
17
|
+
]);
|
|
10
18
|
|
|
11
19
|
export function parseExpressProject(cwd, entryFile) {
|
|
12
20
|
const routes = [];
|
|
@@ -29,7 +37,11 @@ export function parseExpressProject(cwd, entryFile) {
|
|
|
29
37
|
if (depth > 2 || visited.has(absFile + '::' + prefix)) return;
|
|
30
38
|
visited.add(absFile + '::' + prefix);
|
|
31
39
|
let src;
|
|
32
|
-
try {
|
|
40
|
+
try {
|
|
41
|
+
src = fs.readFileSync(absFile, 'utf8');
|
|
42
|
+
} catch {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
33
45
|
let ast;
|
|
34
46
|
try {
|
|
35
47
|
ast = parse(src, {
|
|
@@ -38,13 +50,16 @@ export function parseExpressProject(cwd, entryFile) {
|
|
|
38
50
|
attachComment: true,
|
|
39
51
|
});
|
|
40
52
|
} catch (e) {
|
|
41
|
-
skipped.push({
|
|
53
|
+
skipped.push({
|
|
54
|
+
reason: `parse error in ${rel(absFile)}: ${e.message.slice(0, 80)}`,
|
|
55
|
+
file: rel(absFile),
|
|
56
|
+
});
|
|
42
57
|
return;
|
|
43
58
|
}
|
|
44
59
|
|
|
45
|
-
const appVars = new Set();
|
|
60
|
+
const appVars = new Set(); // vars = express()
|
|
46
61
|
const routerVars = new Set(); // vars = express.Router() / Router()
|
|
47
|
-
const importMap = new Map();
|
|
62
|
+
const importMap = new Map(); // localName -> absolute file path (relative imports only)
|
|
48
63
|
const exprStarts = new Set(); // line numbers where express() assigned (for injector)
|
|
49
64
|
|
|
50
65
|
traverse(ast, {
|
|
@@ -53,7 +68,11 @@ export function parseExpressProject(cwd, entryFile) {
|
|
|
53
68
|
if (!init) return;
|
|
54
69
|
if (init.type === 'CallExpression') {
|
|
55
70
|
const callee = init.callee;
|
|
56
|
-
if (
|
|
71
|
+
if (
|
|
72
|
+
callee.type === 'Identifier' &&
|
|
73
|
+
callee.name === 'require' &&
|
|
74
|
+
init.arguments[0]?.type === 'StringLiteral'
|
|
75
|
+
) {
|
|
57
76
|
const resolved = resolveRel(absFile, init.arguments[0].value);
|
|
58
77
|
if (resolved) {
|
|
59
78
|
const id = p.node.id;
|
|
@@ -61,7 +80,10 @@ export function parseExpressProject(cwd, entryFile) {
|
|
|
61
80
|
importMap.set(id.name, resolved);
|
|
62
81
|
} else if (id.type === 'ObjectPattern') {
|
|
63
82
|
for (const prop of id.properties) {
|
|
64
|
-
if (
|
|
83
|
+
if (
|
|
84
|
+
prop.type === 'ObjectProperty' &&
|
|
85
|
+
prop.value.type === 'Identifier'
|
|
86
|
+
) {
|
|
65
87
|
importMap.set(prop.value.name, resolved);
|
|
66
88
|
}
|
|
67
89
|
}
|
|
@@ -70,9 +92,14 @@ export function parseExpressProject(cwd, entryFile) {
|
|
|
70
92
|
}
|
|
71
93
|
const name = p.node.id?.name;
|
|
72
94
|
if (name) {
|
|
73
|
-
if (callee.type === 'Identifier' && callee.name === 'express') {
|
|
74
|
-
|
|
75
|
-
|
|
95
|
+
if (callee.type === 'Identifier' && callee.name === 'express') {
|
|
96
|
+
appVars.add(name);
|
|
97
|
+
exprStarts.add(p.node.loc.end.line);
|
|
98
|
+
}
|
|
99
|
+
if (callee.type === 'Identifier' && callee.name === 'Router')
|
|
100
|
+
routerVars.add(name);
|
|
101
|
+
if (callee.type === 'MemberExpression' && callee.property?.name === 'Router')
|
|
102
|
+
routerVars.add(name);
|
|
76
103
|
}
|
|
77
104
|
}
|
|
78
105
|
},
|
|
@@ -83,7 +110,8 @@ export function parseExpressProject(cwd, entryFile) {
|
|
|
83
110
|
},
|
|
84
111
|
CallExpression(p) {
|
|
85
112
|
const callee = p.node.callee;
|
|
86
|
-
if (callee.type !== 'MemberExpression' || callee.property.type !== 'Identifier')
|
|
113
|
+
if (callee.type !== 'MemberExpression' || callee.property.type !== 'Identifier')
|
|
114
|
+
return;
|
|
87
115
|
const objName = callee.object.type === 'Identifier' ? callee.object.name : null;
|
|
88
116
|
const method = callee.property.name;
|
|
89
117
|
const args = p.node.arguments;
|
|
@@ -92,8 +120,17 @@ export function parseExpressProject(cwd, entryFile) {
|
|
|
92
120
|
if (method === 'use' && objName && appVars.has(objName) && args.length >= 2) {
|
|
93
121
|
const [a0, a1] = args;
|
|
94
122
|
if (a0.type === 'StringLiteral' && a1.type === 'Identifier') {
|
|
95
|
-
mounts.push({
|
|
96
|
-
|
|
123
|
+
mounts.push({
|
|
124
|
+
prefix: joinPath(prefix, a0.value),
|
|
125
|
+
file: importMap.get(a1.name) ?? null,
|
|
126
|
+
ident: a1.name,
|
|
127
|
+
});
|
|
128
|
+
if (!importMap.get(a1.name))
|
|
129
|
+
skipped.push({
|
|
130
|
+
reason: `router "${a1.name}" mounted at ${a0.value} — source file not resolved`,
|
|
131
|
+
file: rel(absFile),
|
|
132
|
+
line: p.node.loc.start.line,
|
|
133
|
+
});
|
|
97
134
|
}
|
|
98
135
|
return;
|
|
99
136
|
}
|
|
@@ -105,26 +142,50 @@ export function parseExpressProject(cwd, entryFile) {
|
|
|
105
142
|
|
|
106
143
|
const pathArg = args[0];
|
|
107
144
|
if (!pathArg || pathArg.type !== 'StringLiteral') {
|
|
108
|
-
skipped.push({
|
|
145
|
+
skipped.push({
|
|
146
|
+
reason: `dynamic path on ${method.toUpperCase()} (non-literal first arg)`,
|
|
147
|
+
file: rel(absFile),
|
|
148
|
+
line: p.node.loc?.start.line,
|
|
149
|
+
});
|
|
109
150
|
return;
|
|
110
151
|
}
|
|
111
152
|
|
|
112
153
|
const fullPath = joinPath(prefix, pathArg.value);
|
|
113
154
|
if (fullPath === '/mcp' || fullPath.startsWith('/mcp/')) {
|
|
114
|
-
skipped.push({
|
|
155
|
+
skipped.push({
|
|
156
|
+
reason: `self-referential path ${fullPath} blocked`,
|
|
157
|
+
file: rel(absFile),
|
|
158
|
+
line: p.node.loc?.start.line,
|
|
159
|
+
});
|
|
115
160
|
return;
|
|
116
161
|
}
|
|
117
162
|
|
|
118
163
|
const handler = args[args.length - 1];
|
|
119
|
-
const handlerName =
|
|
120
|
-
|
|
164
|
+
const handlerName =
|
|
165
|
+
handler?.type === 'Identifier'
|
|
166
|
+
? handler.name
|
|
167
|
+
: (handler?.id?.name ?? `anonymous_${routes.length + 1}`);
|
|
121
168
|
|
|
122
|
-
const leading = (
|
|
123
|
-
.
|
|
124
|
-
.
|
|
169
|
+
const leading = (
|
|
170
|
+
p.parentPath.node.leadingComments ??
|
|
171
|
+
p.node.leadingComments ??
|
|
172
|
+
[]
|
|
173
|
+
)
|
|
174
|
+
.map((c) =>
|
|
175
|
+
c.value
|
|
176
|
+
.replace(/^\*+/gm, '')
|
|
177
|
+
.replace(/^\s*\*\s?/gm, '')
|
|
178
|
+
.trim(),
|
|
179
|
+
)
|
|
180
|
+
.filter(Boolean)
|
|
181
|
+
.join(' ');
|
|
125
182
|
|
|
126
183
|
const pathParams = [...fullPath.matchAll(/:(\w+)/g)].map((mm) => ({
|
|
127
|
-
name: mm[1],
|
|
184
|
+
name: mm[1],
|
|
185
|
+
in: 'path',
|
|
186
|
+
type: 'string',
|
|
187
|
+
required: true,
|
|
188
|
+
description: 'path parameter',
|
|
128
189
|
}));
|
|
129
190
|
|
|
130
191
|
const mutating = method !== 'get';
|
|
@@ -135,18 +196,36 @@ export function parseExpressProject(cwd, entryFile) {
|
|
|
135
196
|
for (const q of queryParamsOf(p)) {
|
|
136
197
|
if (taken.has(q)) continue;
|
|
137
198
|
taken.add(q);
|
|
138
|
-
params.push({
|
|
199
|
+
params.push({
|
|
200
|
+
name: q,
|
|
201
|
+
in: 'query',
|
|
202
|
+
type: 'string',
|
|
203
|
+
required: false,
|
|
204
|
+
description: 'query parameter',
|
|
205
|
+
});
|
|
139
206
|
}
|
|
140
207
|
let confidence = 'high';
|
|
141
208
|
if (mutating) {
|
|
142
|
-
params.push({
|
|
209
|
+
params.push({
|
|
210
|
+
name: 'body',
|
|
211
|
+
in: 'body',
|
|
212
|
+
type: 'object',
|
|
213
|
+
required: false,
|
|
214
|
+
description: 'JSON body — schema not statically detected',
|
|
215
|
+
});
|
|
143
216
|
confidence = 'low';
|
|
144
217
|
}
|
|
145
218
|
|
|
146
219
|
routes.push({
|
|
147
|
-
method,
|
|
148
|
-
|
|
149
|
-
|
|
220
|
+
method,
|
|
221
|
+
path: fullPath,
|
|
222
|
+
handlerName,
|
|
223
|
+
sourceFile: rel(absFile),
|
|
224
|
+
sourceLine: p.node.loc?.start.line ?? 0,
|
|
225
|
+
params,
|
|
226
|
+
description: leading.slice(0, 400),
|
|
227
|
+
mutating,
|
|
228
|
+
confidence,
|
|
150
229
|
});
|
|
151
230
|
},
|
|
152
231
|
});
|
|
@@ -163,16 +242,26 @@ export function parseExpressProject(cwd, entryFile) {
|
|
|
163
242
|
}
|
|
164
243
|
}
|
|
165
244
|
|
|
166
|
-
function rel(abs) {
|
|
245
|
+
function rel(abs) {
|
|
246
|
+
return path.relative(cwd, abs).split(path.sep).join('/');
|
|
247
|
+
}
|
|
167
248
|
|
|
168
249
|
function resolveRel(fromFile, spec) {
|
|
169
250
|
if (!spec.startsWith('.')) return null;
|
|
170
251
|
const cleanSpec = spec.replace(/\.(m?[jt]s|cjs)$/, '');
|
|
171
252
|
const base = path.resolve(path.dirname(fromFile), cleanSpec);
|
|
172
|
-
for (const cand of [
|
|
173
|
-
|
|
253
|
+
for (const cand of [
|
|
254
|
+
base,
|
|
255
|
+
`${base}.ts`,
|
|
256
|
+
`${base}.js`,
|
|
257
|
+
`${base}.mjs`,
|
|
258
|
+
`${base}.cjs`,
|
|
259
|
+
path.join(base, 'index.ts'),
|
|
260
|
+
path.join(base, 'index.js'),
|
|
261
|
+
]) {
|
|
174
262
|
if (fs.existsSync(cand) && fs.statSync(cand).isFile()) {
|
|
175
|
-
if ([...EXCLUDE].some((x) => cand.includes(`${path.sep}${x}${path.sep}`)))
|
|
263
|
+
if ([...EXCLUDE].some((x) => cand.includes(`${path.sep}${x}${path.sep}`)))
|
|
264
|
+
return null;
|
|
176
265
|
return cand;
|
|
177
266
|
}
|
|
178
267
|
}
|
|
@@ -187,27 +276,36 @@ export function parseExpressProject(cwd, entryFile) {
|
|
|
187
276
|
function queryParamsOf(callPath) {
|
|
188
277
|
const reqNames = new Set();
|
|
189
278
|
for (const a of callPath.node.arguments) {
|
|
190
|
-
if (
|
|
279
|
+
if (
|
|
280
|
+
(a.type === 'ArrowFunctionExpression' || a.type === 'FunctionExpression') &&
|
|
281
|
+
a.params[0]?.type === 'Identifier'
|
|
282
|
+
) {
|
|
191
283
|
reqNames.add(a.params[0].name);
|
|
192
284
|
}
|
|
193
285
|
}
|
|
194
286
|
const found = [];
|
|
195
287
|
if (!reqNames.size) return found;
|
|
196
|
-
const isReqQuery = (n) =>
|
|
197
|
-
n
|
|
198
|
-
n.
|
|
288
|
+
const isReqQuery = (n) =>
|
|
289
|
+
n?.type === 'MemberExpression' &&
|
|
290
|
+
!n.computed &&
|
|
291
|
+
n.object?.type === 'Identifier' &&
|
|
292
|
+
reqNames.has(n.object.name) &&
|
|
293
|
+
n.property?.type === 'Identifier' &&
|
|
294
|
+
n.property.name === 'query';
|
|
199
295
|
callPath.traverse({
|
|
200
296
|
MemberExpression(q) {
|
|
201
297
|
const n = q.node;
|
|
202
298
|
if (!isReqQuery(n.object) || found.length >= 15) return;
|
|
203
299
|
if (!n.computed && n.property.type === 'Identifier') found.push(n.property.name);
|
|
204
|
-
else if (n.computed && n.property.type === 'StringLiteral')
|
|
300
|
+
else if (n.computed && n.property.type === 'StringLiteral')
|
|
301
|
+
found.push(n.property.value);
|
|
205
302
|
},
|
|
206
303
|
VariableDeclarator(q) {
|
|
207
304
|
if (q.node.id.type !== 'ObjectPattern' || !isReqQuery(q.node.init)) return;
|
|
208
305
|
for (const prop of q.node.id.properties) {
|
|
209
306
|
if (found.length >= 15) break;
|
|
210
|
-
if (prop.type === 'ObjectProperty' && prop.key.type === 'Identifier')
|
|
307
|
+
if (prop.type === 'ObjectProperty' && prop.key.type === 'Identifier')
|
|
308
|
+
found.push(prop.key.name);
|
|
211
309
|
}
|
|
212
310
|
},
|
|
213
311
|
});
|
|
@@ -230,14 +328,16 @@ function dedupe(routes) {
|
|
|
230
328
|
}
|
|
231
329
|
|
|
232
330
|
export function toolNameFor(route, taken) {
|
|
233
|
-
let name =
|
|
234
|
-
.
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
331
|
+
let name =
|
|
332
|
+
`${route.method}_${route.path}`
|
|
333
|
+
.toLowerCase()
|
|
334
|
+
.replace(/:(\w+)/g, 'by_$1')
|
|
335
|
+
.replace(/\{(\w+)\}/g, 'by_$1')
|
|
336
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
337
|
+
.replace(/^_+|_+$/g, '')
|
|
338
|
+
.slice(0, 60) || 'tool';
|
|
339
|
+
let final = name;
|
|
340
|
+
let i = 2;
|
|
241
341
|
while (taken.has(final)) final = `${name}_${i++}`;
|
|
242
342
|
taken.add(final);
|
|
243
343
|
return final;
|
package/src/parser/fastapi.js
CHANGED
|
@@ -7,8 +7,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
7
7
|
|
|
8
8
|
export function parseFastAPIProject(cwd, entryFile, pythonCmd = 'python') {
|
|
9
9
|
const extractorScript = path.resolve(__dirname, 'fastapi_extract.py');
|
|
10
|
-
const res = spawnSync(pythonCmd, [extractorScript, entryFile, cwd], {
|
|
11
|
-
|
|
10
|
+
const res = spawnSync(pythonCmd, [extractorScript, entryFile, cwd], {
|
|
11
|
+
cwd,
|
|
12
|
+
encoding: 'utf8',
|
|
13
|
+
});
|
|
14
|
+
|
|
12
15
|
if (res.status !== 0) {
|
|
13
16
|
const errorMsg = (res.stderr || res.stdout || '').trim();
|
|
14
17
|
throw new Error(`FastAPI extraction process failed: ${errorMsg}`);
|
|
@@ -19,8 +22,14 @@ export function parseFastAPIProject(cwd, entryFile, pythonCmd = 'python') {
|
|
|
19
22
|
if (payload.error) {
|
|
20
23
|
throw new Error(payload.error);
|
|
21
24
|
}
|
|
22
|
-
return {
|
|
25
|
+
return {
|
|
26
|
+
routes: payload.routes || [],
|
|
27
|
+
skipped: payload.skipped || [],
|
|
28
|
+
entryAppVars: payload.entryAppVars || [],
|
|
29
|
+
};
|
|
23
30
|
} catch (err) {
|
|
24
|
-
throw new Error(
|
|
31
|
+
throw new Error(
|
|
32
|
+
`Failed to parse Python extractor output: ${err.message}. Raw output: ${res.stdout}`,
|
|
33
|
+
);
|
|
25
34
|
}
|
|
26
35
|
}
|
|
@@ -18,10 +18,10 @@
|
|
|
18
18
|
|
|
19
19
|
import { createRequire } from 'node:module';
|
|
20
20
|
import { fileURLToPath } from 'node:url';
|
|
21
|
-
import { dirname, resolve }
|
|
21
|
+
import { dirname, resolve } from 'node:path';
|
|
22
22
|
|
|
23
23
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
|
-
const require
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
25
|
|
|
26
26
|
// Load the CJS shim — patches Module._load globally in the CJS loader
|
|
27
27
|
require(resolve(__dirname, 'express-shim.cjs'));
|
|
@@ -31,16 +31,16 @@ if (!process.env.SPARDA_PROBE) {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
function installShim() {
|
|
34
|
-
const net
|
|
34
|
+
const net = require('net');
|
|
35
35
|
const Module = require('module');
|
|
36
36
|
|
|
37
37
|
const IPC_PORT = parseInt(process.env.SPARDA_IPC_PORT, 10) || 0;
|
|
38
38
|
|
|
39
39
|
// ── Transport: fork IPC or TCP fallback ────────────────────────────────────
|
|
40
40
|
|
|
41
|
-
let socket
|
|
41
|
+
let socket = null;
|
|
42
42
|
let socketReady = false;
|
|
43
|
-
const pending
|
|
43
|
+
const pending = [];
|
|
44
44
|
|
|
45
45
|
function connectTcp() {
|
|
46
46
|
if (socket || !IPC_PORT) return;
|
|
@@ -50,23 +50,33 @@ function installShim() {
|
|
|
50
50
|
for (const line of pending) socket.write(line);
|
|
51
51
|
pending.length = 0;
|
|
52
52
|
});
|
|
53
|
-
socket.on('error', () => {
|
|
53
|
+
socket.on('error', () => {
|
|
54
|
+
socket = null;
|
|
55
|
+
socketReady = false;
|
|
56
|
+
});
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
function sendLine(line) {
|
|
57
60
|
if (typeof process.send === 'function') {
|
|
58
|
-
try {
|
|
61
|
+
try {
|
|
62
|
+
process.send(JSON.parse(line.trimEnd()));
|
|
63
|
+
return;
|
|
64
|
+
} catch {}
|
|
59
65
|
}
|
|
60
66
|
connectTcp();
|
|
61
67
|
if (socketReady && socket) socket.write(line);
|
|
62
68
|
else pending.push(line);
|
|
63
69
|
}
|
|
64
70
|
|
|
65
|
-
function sendMsg(obj)
|
|
71
|
+
function sendMsg(obj) {
|
|
72
|
+
sendLine(JSON.stringify(obj) + '\n');
|
|
73
|
+
}
|
|
66
74
|
|
|
67
75
|
function sendDone() {
|
|
68
76
|
if (typeof process.send === 'function') {
|
|
69
|
-
try {
|
|
77
|
+
try {
|
|
78
|
+
process.send({ type: '__done__' });
|
|
79
|
+
} catch {}
|
|
70
80
|
return;
|
|
71
81
|
}
|
|
72
82
|
const finish = () => {
|
|
@@ -93,7 +103,17 @@ function installShim() {
|
|
|
93
103
|
|
|
94
104
|
// ── HTTP method list ───────────────────────────────────────────────────────
|
|
95
105
|
|
|
96
|
-
const HTTP_METHODS = [
|
|
106
|
+
const HTTP_METHODS = [
|
|
107
|
+
'get',
|
|
108
|
+
'post',
|
|
109
|
+
'put',
|
|
110
|
+
'patch',
|
|
111
|
+
'delete',
|
|
112
|
+
'del',
|
|
113
|
+
'head',
|
|
114
|
+
'options',
|
|
115
|
+
'all',
|
|
116
|
+
];
|
|
97
117
|
|
|
98
118
|
// ── Core wrapper — works on any target object ──────────────────────────────
|
|
99
119
|
//
|
|
@@ -117,7 +137,10 @@ function installShim() {
|
|
|
117
137
|
};
|
|
118
138
|
}
|
|
119
139
|
try {
|
|
120
|
-
Object.defineProperty(target, '__sparda_wrapped__', {
|
|
140
|
+
Object.defineProperty(target, '__sparda_wrapped__', {
|
|
141
|
+
value: true,
|
|
142
|
+
configurable: true,
|
|
143
|
+
});
|
|
121
144
|
} catch {}
|
|
122
145
|
}
|
|
123
146
|
|
|
@@ -133,20 +156,30 @@ function installShim() {
|
|
|
133
156
|
if (!appProto || appProto.__sparda_listen_patched__) return;
|
|
134
157
|
if (typeof appProto.listen !== 'function') return;
|
|
135
158
|
try {
|
|
136
|
-
Object.defineProperty(appProto, '__sparda_listen_patched__', {
|
|
159
|
+
Object.defineProperty(appProto, '__sparda_listen_patched__', {
|
|
160
|
+
value: true,
|
|
161
|
+
configurable: true,
|
|
162
|
+
});
|
|
137
163
|
} catch {}
|
|
138
|
-
const origListen = appProto.listen;
|
|
139
164
|
appProto.listen = function spardaListen(...args) {
|
|
140
165
|
clearTimeout(idleTimer);
|
|
141
166
|
// §ANALYSE §2: call the listen callback so routes registered inside it are captured
|
|
142
|
-
const cb = args.find(a => typeof a === 'function');
|
|
143
|
-
if (cb) {
|
|
167
|
+
const cb = args.find((a) => typeof a === 'function');
|
|
168
|
+
if (cb) {
|
|
169
|
+
try {
|
|
170
|
+
cb();
|
|
171
|
+
} catch {}
|
|
172
|
+
}
|
|
144
173
|
sendDone();
|
|
145
174
|
// Do NOT call origListen — no real socket in probe mode
|
|
146
175
|
return {
|
|
147
|
-
on()
|
|
148
|
-
|
|
149
|
-
|
|
176
|
+
on() {
|
|
177
|
+
return this;
|
|
178
|
+
},
|
|
179
|
+
close() {},
|
|
180
|
+
address() {
|
|
181
|
+
return { port: 0, address: '127.0.0.1', family: 'IPv4' };
|
|
182
|
+
},
|
|
150
183
|
};
|
|
151
184
|
};
|
|
152
185
|
}
|
|
@@ -156,7 +189,10 @@ function installShim() {
|
|
|
156
189
|
function patchExpress(exp) {
|
|
157
190
|
if (!exp || exp.__sparda_factory_patched__) return;
|
|
158
191
|
try {
|
|
159
|
-
Object.defineProperty(exp, '__sparda_factory_patched__', {
|
|
192
|
+
Object.defineProperty(exp, '__sparda_factory_patched__', {
|
|
193
|
+
value: true,
|
|
194
|
+
configurable: true,
|
|
195
|
+
});
|
|
160
196
|
} catch {}
|
|
161
197
|
|
|
162
198
|
// Surface 1: express.application — catches app.get/post/... (Express 4 & 5)
|
|
@@ -179,7 +215,7 @@ function installShim() {
|
|
|
179
215
|
// ── Intercept require('express') via Module._load ─────────────────────────
|
|
180
216
|
|
|
181
217
|
const originalLoad = Module._load;
|
|
182
|
-
Module._load = function spardaLoad(request
|
|
218
|
+
Module._load = function spardaLoad(request) {
|
|
183
219
|
const result = originalLoad.apply(this, arguments);
|
|
184
220
|
if (request === 'express') patchExpress(result);
|
|
185
221
|
return result;
|
|
@@ -190,8 +226,8 @@ function installShim() {
|
|
|
190
226
|
// this shim loaded, Module._load hook fires too late. Patch the cached export.
|
|
191
227
|
|
|
192
228
|
try {
|
|
193
|
-
const expressPaths = Object.keys(require.cache).filter(
|
|
194
|
-
|
|
229
|
+
const expressPaths = Object.keys(require.cache).filter((k) =>
|
|
230
|
+
/[/\\]express[/\\]index\.js$/.test(k),
|
|
195
231
|
);
|
|
196
232
|
for (const p of expressPaths) {
|
|
197
233
|
const cached = require.cache[p];
|
|
@@ -201,8 +237,15 @@ function installShim() {
|
|
|
201
237
|
|
|
202
238
|
// ── Safety nets ────────────────────────────────────────────────────────────
|
|
203
239
|
|
|
204
|
-
process.on('exit',
|
|
205
|
-
|
|
240
|
+
process.on('exit', () => {
|
|
241
|
+
try {
|
|
242
|
+
sendDone();
|
|
243
|
+
} catch {}
|
|
244
|
+
});
|
|
245
|
+
process.on('SIGTERM', () => {
|
|
246
|
+
sendDone();
|
|
247
|
+
setTimeout(() => process.exit(0), 200);
|
|
248
|
+
});
|
|
206
249
|
|
|
207
250
|
module.exports = { record, sendDone };
|
|
208
251
|
}
|
package/src/probe/integrate.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { probeRoutes } from './probe.js';
|
|
20
|
-
import { reconcile }
|
|
20
|
+
import { reconcile } from './reconcile.js';
|
|
21
21
|
|
|
22
22
|
const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
23
23
|
|
|
@@ -40,21 +40,31 @@ const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
|
40
40
|
*/
|
|
41
41
|
export function gapToStaticRoute(gap, framework) {
|
|
42
42
|
const method = (gap.method ?? 'GET').toUpperCase();
|
|
43
|
-
const lower
|
|
44
|
-
const mutating = gap.writeClass
|
|
43
|
+
const lower = method.toLowerCase();
|
|
44
|
+
const mutating = gap.writeClass
|
|
45
|
+
? gap.writeClass === 'write'
|
|
46
|
+
: WRITE_METHODS.has(method);
|
|
45
47
|
|
|
46
|
-
const path =
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
const path =
|
|
49
|
+
framework === 'fastapi'
|
|
50
|
+
? (gap.path ?? '/').replace(/:([a-zA-Z_]\w*)/g, '{$1}')
|
|
51
|
+
: (gap.path ?? '/');
|
|
49
52
|
|
|
50
53
|
// Path params, in the exact param object the static parser uses.
|
|
51
54
|
const params = (gap.pathParams ?? []).map((name) => ({
|
|
52
|
-
name,
|
|
55
|
+
name,
|
|
56
|
+
in: 'path',
|
|
57
|
+
type: 'string',
|
|
58
|
+
required: true,
|
|
59
|
+
description: 'path parameter',
|
|
53
60
|
}));
|
|
54
61
|
// Mirror the static parser: mutating routes get a body param (schema unknown).
|
|
55
62
|
if (mutating) {
|
|
56
63
|
params.push({
|
|
57
|
-
name: 'body',
|
|
64
|
+
name: 'body',
|
|
65
|
+
in: 'body',
|
|
66
|
+
type: 'object',
|
|
67
|
+
required: false,
|
|
58
68
|
description: 'JSON body — schema not statically detected',
|
|
59
69
|
});
|
|
60
70
|
}
|
|
@@ -68,7 +78,7 @@ export function gapToStaticRoute(gap, framework) {
|
|
|
68
78
|
params,
|
|
69
79
|
description: 'Discovered at runtime via --probe; not found by static analysis.',
|
|
70
80
|
mutating,
|
|
71
|
-
confidence: 'low',
|
|
81
|
+
confidence: 'low', // never statically verified → always low
|
|
72
82
|
source: 'dynamic',
|
|
73
83
|
};
|
|
74
84
|
}
|
|
@@ -84,9 +94,15 @@ export function gapToStaticRoute(gap, framework) {
|
|
|
84
94
|
* gaps — the raw minimal gaps (stored in the scan-report for provenance)
|
|
85
95
|
* probedCount — how many routes the probe observed (0 ⇒ probe degraded / nothing seen)
|
|
86
96
|
*/
|
|
87
|
-
export async function discoverDynamicRoutes({
|
|
97
|
+
export async function discoverDynamicRoutes({
|
|
98
|
+
framework,
|
|
99
|
+
entryFile,
|
|
100
|
+
projectRoot,
|
|
101
|
+
staticRoutes,
|
|
102
|
+
timeoutMs = 8000,
|
|
103
|
+
}) {
|
|
88
104
|
const probed = await probeRoutes({ framework, entryFile, projectRoot, timeoutMs });
|
|
89
|
-
const { gaps, staticCount
|
|
105
|
+
const { gaps, staticCount } = reconcile(staticRoutes, probed);
|
|
90
106
|
|
|
91
107
|
// Defensive: the probe should not emit duplicate (method, path) pairs, but
|
|
92
108
|
// reconcile does not dedup gaps among themselves. Collapse here so we never
|
|
@@ -100,5 +116,11 @@ export async function discoverDynamicRoutes({ framework, entryFile, projectRoot,
|
|
|
100
116
|
});
|
|
101
117
|
|
|
102
118
|
const added = uniqueGaps.map((g) => gapToStaticRoute(g, framework));
|
|
103
|
-
return {
|
|
119
|
+
return {
|
|
120
|
+
added,
|
|
121
|
+
gaps: uniqueGaps,
|
|
122
|
+
staticCount,
|
|
123
|
+
dynamicCount: added.length,
|
|
124
|
+
probedCount: probed.length,
|
|
125
|
+
};
|
|
104
126
|
}
|