sparda-mcp 0.17.0 → 0.26.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/demo-app/.sparda/immunity.json +1 -1
- package/package.json +1 -1
- package/src/commands/apocalypse.js +7 -0
- package/src/commands/dossier.js +209 -0
- package/src/commands/genome.js +116 -0
- package/src/commands/immunize.js +9 -5
- package/src/detect.js +85 -1
- package/src/index.js +12 -0
- package/src/ubg/apocalypse.js +37 -1
- package/src/ubg/compile.js +2 -0
- package/src/ubg/express.js +91 -2
- package/src/ubg/extract.js +300 -6
- package/src/ubg/genome.js +309 -0
- package/src/ubg/immunity.js +6 -2
- package/src/ubg/medusa.js +260 -0
- package/src/ubg/nestjs.js +184 -54
- package/src/ubg/nextjs.js +61 -2
- package/src/ubg/translate.js +20 -6
package/src/ubg/express.js
CHANGED
|
@@ -6,9 +6,41 @@
|
|
|
6
6
|
// Depth stays bounded like the v0 parser: entry file + mounted routers.
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
|
-
import { parseModule, resolveRelImport } from './extract.js';
|
|
9
|
+
import { parseModule, resolveRelImport, scanFunction } from './extract.js';
|
|
10
10
|
|
|
11
11
|
const HTTP = new Set(['get', 'post', 'put', 'patch', 'delete']);
|
|
12
|
+
const MAX_HANDLER_DEPTH = 6;
|
|
13
|
+
|
|
14
|
+
// merge one scan's findings into another (same contract as the Nest extractor's).
|
|
15
|
+
function mergeScan(into, add) {
|
|
16
|
+
into.effects.push(...add.effects);
|
|
17
|
+
into.returnShapes = [...(into.returnShapes ?? []), ...(add.returnShapes ?? [])];
|
|
18
|
+
into.calls = [...(into.calls ?? []), ...(add.calls ?? [])];
|
|
19
|
+
into.validatesInput = into.validatesInput || add.validatesInput;
|
|
20
|
+
into.async = into.async || add.async;
|
|
21
|
+
if (add.guardSignals?.deniesWithStatus) into.guardSignals.deniesWithStatus = true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// depth-first walk over an AST subtree, invoking fn on every CallExpression.
|
|
25
|
+
function walkCalls(node, fn) {
|
|
26
|
+
if (!node || typeof node !== 'object') return;
|
|
27
|
+
if (Array.isArray(node)) {
|
|
28
|
+
for (const n of node) walkCalls(n, fn);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (node.type === 'CallExpression') fn(node);
|
|
32
|
+
for (const k of Object.keys(node)) {
|
|
33
|
+
if (
|
|
34
|
+
k === 'loc' ||
|
|
35
|
+
k === 'range' ||
|
|
36
|
+
k === 'leadingComments' ||
|
|
37
|
+
k === 'trailingComments'
|
|
38
|
+
)
|
|
39
|
+
continue;
|
|
40
|
+
const v = node[k];
|
|
41
|
+
if (v && typeof v === 'object') walkCalls(v, fn);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
12
44
|
|
|
13
45
|
// → { routes, globalMiddlewares, helpers, skipped, scannedFiles }
|
|
14
46
|
export function extractExpress(cwd, entryFile) {
|
|
@@ -249,6 +281,7 @@ export function extractExpress(cwd, entryFile) {
|
|
|
249
281
|
sourceFile: fromRel,
|
|
250
282
|
sourceLine: fn.line,
|
|
251
283
|
fn: fn.node,
|
|
284
|
+
scan: deepScan(fn.node, target), // follow service/model calls below it
|
|
252
285
|
};
|
|
253
286
|
}
|
|
254
287
|
}
|
|
@@ -269,6 +302,7 @@ export function extractExpress(cwd, entryFile) {
|
|
|
269
302
|
sourceFile: relFile,
|
|
270
303
|
sourceLine: local.line,
|
|
271
304
|
fn: local.node,
|
|
305
|
+
scan: deepScan(local.node, mod),
|
|
272
306
|
};
|
|
273
307
|
}
|
|
274
308
|
const importedFrom = mod.imports.get(arg.name);
|
|
@@ -287,7 +321,13 @@ export function extractExpress(cwd, entryFile) {
|
|
|
287
321
|
fn: f.node,
|
|
288
322
|
});
|
|
289
323
|
}
|
|
290
|
-
return {
|
|
324
|
+
return {
|
|
325
|
+
name: arg.name,
|
|
326
|
+
sourceFile: fromRel,
|
|
327
|
+
sourceLine: fn.line,
|
|
328
|
+
fn: fn.node,
|
|
329
|
+
scan: deepScan(fn.node, target),
|
|
330
|
+
};
|
|
291
331
|
}
|
|
292
332
|
}
|
|
293
333
|
// known but bodyless — node exists, microscope stays blind
|
|
@@ -302,6 +342,55 @@ export function extractExpress(cwd, entryFile) {
|
|
|
302
342
|
function rel(abs) {
|
|
303
343
|
return path.relative(cwd, abs).split(path.sep).join('/');
|
|
304
344
|
}
|
|
345
|
+
|
|
346
|
+
// A handler's real effects = its own body PLUS every module-member call it makes,
|
|
347
|
+
// followed recursively: `authController.register` → `userService.createUser` →
|
|
348
|
+
// `User.create()`. This is the CommonJS analogue of the Nest DI hop — the effect is
|
|
349
|
+
// usually two or three modules below the route, behind `service.method()` calls the
|
|
350
|
+
// flat scanner can't see. Precomputed so translate uses it as-is (like the Nest scan).
|
|
351
|
+
function deepScan(fnNode, owningMod) {
|
|
352
|
+
const base = scanFunction(fnNode);
|
|
353
|
+
const merged = {
|
|
354
|
+
...base,
|
|
355
|
+
effects: [...base.effects],
|
|
356
|
+
returnShapes: [...(base.returnShapes ?? [])],
|
|
357
|
+
calls: [...(base.calls ?? [])],
|
|
358
|
+
};
|
|
359
|
+
followMembers(fnNode, owningMod, merged, new Set(), 0);
|
|
360
|
+
return merged;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function followMembers(fnNode, mod, merged, seen, depth) {
|
|
364
|
+
if (depth >= MAX_HANDLER_DEPTH) return;
|
|
365
|
+
walkCalls(fnNode, (node) => {
|
|
366
|
+
const callee = node.callee;
|
|
367
|
+
if (
|
|
368
|
+
callee.type !== 'MemberExpression' ||
|
|
369
|
+
callee.property.type !== 'Identifier' ||
|
|
370
|
+
callee.object.type !== 'Identifier'
|
|
371
|
+
)
|
|
372
|
+
return;
|
|
373
|
+
const targetFile = mod.imports.get(callee.object.name);
|
|
374
|
+
if (!targetFile) return;
|
|
375
|
+
const targetMod = parseModule(targetFile);
|
|
376
|
+
if (targetMod.error) return;
|
|
377
|
+
const fn = targetMod.functions.get(callee.property.name);
|
|
378
|
+
if (!fn) return;
|
|
379
|
+
const key = `${targetFile}#${callee.property.name}`;
|
|
380
|
+
if (seen.has(key)) return;
|
|
381
|
+
seen.add(key);
|
|
382
|
+
const fromRel = rel(targetFile);
|
|
383
|
+
if (!scannedFiles.includes(fromRel)) scannedFiles.push(fromRel);
|
|
384
|
+
helpers.push({
|
|
385
|
+
name: `${callee.object.name}.${callee.property.name}`,
|
|
386
|
+
sourceFile: fromRel,
|
|
387
|
+
sourceLine: fn.line,
|
|
388
|
+
fn: fn.node,
|
|
389
|
+
});
|
|
390
|
+
mergeScan(merged, scanFunction(fn.node));
|
|
391
|
+
followMembers(fn.node, targetMod, merged, seen, depth + 1);
|
|
392
|
+
});
|
|
393
|
+
}
|
|
305
394
|
}
|
|
306
395
|
|
|
307
396
|
// const defaultRoutes = [{ path: '/auth', route: authRoute }, …] — collected
|
package/src/ubg/extract.js
CHANGED
|
@@ -22,6 +22,64 @@ const SQL_VERBS = {
|
|
|
22
22
|
upsert: { effectType: 'db_write', op: 'upsert' },
|
|
23
23
|
};
|
|
24
24
|
const SUPABASE_OPS = new Set(['select', 'insert', 'update', 'upsert', 'delete']);
|
|
25
|
+
// Kysely: the op names its own table (db.insertInto('t')), one op = one verb.
|
|
26
|
+
const KYSELY_OPS = {
|
|
27
|
+
insertinto: 'insert',
|
|
28
|
+
updatetable: 'update',
|
|
29
|
+
deletefrom: 'delete',
|
|
30
|
+
selectfrom: 'select',
|
|
31
|
+
replaceinto: 'upsert',
|
|
32
|
+
mergeinto: 'upsert',
|
|
33
|
+
};
|
|
34
|
+
// Active-record ORM ops, keyed by method name. Covers Mongoose (`User.create()`),
|
|
35
|
+
// TypeORM (`User.save()`/`repo.save()`), and Sequelize (`User.findAll()`/`destroy()`) —
|
|
36
|
+
// they share the `Model.op()` / `repository.op()` shape, so one table serves all three.
|
|
37
|
+
const MODEL_OPS = {
|
|
38
|
+
// writes — inserts
|
|
39
|
+
create: 'insert',
|
|
40
|
+
insertmany: 'insert',
|
|
41
|
+
bulkcreate: 'insert', // sequelize
|
|
42
|
+
save: 'insert', // mongoose / typeorm
|
|
43
|
+
insert: 'insert', // typeorm repository
|
|
44
|
+
// writes — updates
|
|
45
|
+
updateone: 'update',
|
|
46
|
+
updatemany: 'update',
|
|
47
|
+
replaceone: 'update',
|
|
48
|
+
findbyidandupdate: 'update',
|
|
49
|
+
findoneandupdate: 'update',
|
|
50
|
+
findoneandreplace: 'update',
|
|
51
|
+
increment: 'update', // sequelize / typeorm
|
|
52
|
+
decrement: 'update',
|
|
53
|
+
upsert: 'upsert',
|
|
54
|
+
// writes — deletes
|
|
55
|
+
deleteone: 'delete',
|
|
56
|
+
deletemany: 'delete',
|
|
57
|
+
findbyidanddelete: 'delete',
|
|
58
|
+
findoneanddelete: 'delete',
|
|
59
|
+
findbyidandremove: 'delete',
|
|
60
|
+
remove: 'delete', // mongoose / typeorm
|
|
61
|
+
destroy: 'delete', // sequelize
|
|
62
|
+
softdelete: 'delete', // typeorm
|
|
63
|
+
softremove: 'delete',
|
|
64
|
+
// reads
|
|
65
|
+
find: 'select',
|
|
66
|
+
findall: 'select', // sequelize
|
|
67
|
+
findone: 'select',
|
|
68
|
+
findbyid: 'select',
|
|
69
|
+
findbypk: 'select', // sequelize
|
|
70
|
+
findby: 'select', // typeorm
|
|
71
|
+
findoneby: 'select', // typeorm
|
|
72
|
+
countdocuments: 'select',
|
|
73
|
+
estimateddocumentcount: 'select',
|
|
74
|
+
count: 'select',
|
|
75
|
+
distinct: 'select',
|
|
76
|
+
exists: 'select',
|
|
77
|
+
paginate: 'select',
|
|
78
|
+
};
|
|
79
|
+
// Drizzle: `db.insert(users).values(...)` / `db.update(users).set(...)` / `db.delete(users)`
|
|
80
|
+
// — the table is an IDENTIFIER (the schema object), not a string. Distinct from Kysely
|
|
81
|
+
// (which names the table in the method: insertInto) and supabase (.from('t')).
|
|
82
|
+
const DRIZZLE_OPS = { insert: 'insert', update: 'update', delete: 'delete' };
|
|
25
83
|
const PRISMA_OPS = {
|
|
26
84
|
findmany: 'select',
|
|
27
85
|
findunique: 'select',
|
|
@@ -66,6 +124,7 @@ const moduleCache = new Map(); // absFile -> module facts (parse once per compil
|
|
|
66
124
|
|
|
67
125
|
export function clearModuleCache() {
|
|
68
126
|
moduleCache.clear();
|
|
127
|
+
tsconfigCache.clear();
|
|
69
128
|
}
|
|
70
129
|
|
|
71
130
|
// absFile → { ast, functions: Map<name,{node,line,exported}>, imports: Map<local,abs>, error }
|
|
@@ -75,7 +134,9 @@ export function parseModule(absFile) {
|
|
|
75
134
|
ast: null,
|
|
76
135
|
functions: new Map(),
|
|
77
136
|
imports: new Map(),
|
|
137
|
+
reexports: new Map(), // barrel: `module.exports.x = require('./x')` → x -> file
|
|
78
138
|
error: null,
|
|
139
|
+
_file: absFile, // the module's own path — DI resolution reports source locations
|
|
79
140
|
};
|
|
80
141
|
moduleCache.set(absFile, facts);
|
|
81
142
|
|
|
@@ -175,9 +236,18 @@ function collectTopLevel(node, facts, absFile, exported) {
|
|
|
175
236
|
) {
|
|
176
237
|
const resolved = resolveRelImport(absFile, d.init.arguments[0].value);
|
|
177
238
|
if (!resolved) continue;
|
|
239
|
+
// if the required module is a barrel, resolve each destructured member to the
|
|
240
|
+
// sub-module it re-exports (`{ userService }` → user.service.js, not index.js).
|
|
241
|
+
const barrel = resolved === absFile ? null : parseModule(resolved);
|
|
178
242
|
for (const prop of d.id.properties) {
|
|
179
|
-
if (
|
|
180
|
-
|
|
243
|
+
if (
|
|
244
|
+
prop.type === 'ObjectProperty' &&
|
|
245
|
+
prop.value.type === 'Identifier' &&
|
|
246
|
+
prop.key.type === 'Identifier'
|
|
247
|
+
) {
|
|
248
|
+
const viaBarrel = barrel?.reexports.get(prop.key.name);
|
|
249
|
+
facts.imports.set(prop.value.name, viaBarrel ?? resolved);
|
|
250
|
+
}
|
|
181
251
|
}
|
|
182
252
|
}
|
|
183
253
|
}
|
|
@@ -187,16 +257,58 @@ function collectTopLevel(node, facts, absFile, exported) {
|
|
|
187
257
|
const resolved = resolveRelImport(absFile, node.source.value);
|
|
188
258
|
if (!resolved) return;
|
|
189
259
|
for (const spec of node.specifiers) facts.imports.set(spec.local.name, resolved);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
// barrel re-export: `module.exports.userService = require('./user.service')` or
|
|
263
|
+
// `exports.userService = require('./user.service')`. Records member → file so a
|
|
264
|
+
// `const { userService } = require('./services')` resolves to the real module.
|
|
265
|
+
if (
|
|
266
|
+
node.type === 'ExpressionStatement' &&
|
|
267
|
+
node.expression.type === 'AssignmentExpression' &&
|
|
268
|
+
node.expression.left.type === 'MemberExpression' &&
|
|
269
|
+
node.expression.left.property.type === 'Identifier'
|
|
270
|
+
) {
|
|
271
|
+
const left = node.expression.left;
|
|
272
|
+
const isExports =
|
|
273
|
+
(left.object.type === 'Identifier' && left.object.name === 'exports') ||
|
|
274
|
+
(left.object.type === 'MemberExpression' &&
|
|
275
|
+
left.object.object.type === 'Identifier' &&
|
|
276
|
+
left.object.object.name === 'module' &&
|
|
277
|
+
left.object.property.type === 'Identifier' &&
|
|
278
|
+
left.object.property.name === 'exports');
|
|
279
|
+
const rhs = node.expression.right;
|
|
280
|
+
if (
|
|
281
|
+
isExports &&
|
|
282
|
+
rhs.type === 'CallExpression' &&
|
|
283
|
+
rhs.callee.type === 'Identifier' &&
|
|
284
|
+
rhs.callee.name === 'require' &&
|
|
285
|
+
rhs.arguments[0]?.type === 'StringLiteral'
|
|
286
|
+
) {
|
|
287
|
+
const resolved = resolveRelImport(absFile, rhs.arguments[0].value);
|
|
288
|
+
if (resolved) facts.reexports.set(left.property.name, resolved);
|
|
289
|
+
}
|
|
190
290
|
}
|
|
191
291
|
}
|
|
192
292
|
|
|
193
293
|
export function resolveRelImport(fromFile, spec) {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
294
|
+
const clean = (s) => s.replace(/\.(m?[jt]s|cjs)$/, '');
|
|
295
|
+
if (spec.startsWith('.')) {
|
|
296
|
+
return firstModuleFile(path.resolve(path.dirname(fromFile), clean(spec)));
|
|
297
|
+
}
|
|
298
|
+
// Non-relative: a TS baseUrl/paths alias (`src/services/x`, `@app/x`) — the shape
|
|
299
|
+
// real monorepos (immich, Nest apps) use instead of `../../`. Without this the
|
|
300
|
+
// cross-module hop (controller → service → repository) dead-ends and effects behind
|
|
301
|
+
// DI are invisible. A bare npm package (`kysely`, `@nestjs/common`) simply resolves
|
|
302
|
+
// to nothing here (no matching file under the project), which is correct.
|
|
303
|
+
return resolveAliasedImport(fromFile, clean(spec));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// probe the standard TS/JS extensions + index files for a resolved base path.
|
|
307
|
+
function firstModuleFile(base) {
|
|
197
308
|
for (const cand of [
|
|
198
309
|
base,
|
|
199
310
|
`${base}.ts`,
|
|
311
|
+
`${base}.tsx`,
|
|
200
312
|
`${base}.js`,
|
|
201
313
|
`${base}.mjs`,
|
|
202
314
|
`${base}.cjs`,
|
|
@@ -212,6 +324,86 @@ export function resolveRelImport(fromFile, spec) {
|
|
|
212
324
|
return null;
|
|
213
325
|
}
|
|
214
326
|
|
|
327
|
+
// tsconfig baseUrl + paths, resolved from the nearest ancestor project. Cached per
|
|
328
|
+
// directory so the walk-up + read happens once. `null` = no project found.
|
|
329
|
+
const tsconfigCache = new Map();
|
|
330
|
+
function projectConfig(fromFile) {
|
|
331
|
+
const chain = [];
|
|
332
|
+
let dir = path.dirname(fromFile);
|
|
333
|
+
for (let i = 0; i < 40; i++) {
|
|
334
|
+
if (tsconfigCache.has(dir)) {
|
|
335
|
+
const v = tsconfigCache.get(dir);
|
|
336
|
+
for (const d of chain) tsconfigCache.set(d, v);
|
|
337
|
+
return v;
|
|
338
|
+
}
|
|
339
|
+
chain.push(dir);
|
|
340
|
+
const tsc = path.join(dir, 'tsconfig.json');
|
|
341
|
+
let v;
|
|
342
|
+
if (fs.existsSync(tsc)) v = readTsconfig(tsc, dir);
|
|
343
|
+
else if (fs.existsSync(path.join(dir, 'package.json')))
|
|
344
|
+
v = { baseDir: dir, paths: {} };
|
|
345
|
+
if (v) {
|
|
346
|
+
for (const d of chain) tsconfigCache.set(d, v);
|
|
347
|
+
return v;
|
|
348
|
+
}
|
|
349
|
+
const parent = path.dirname(dir);
|
|
350
|
+
if (parent === dir) break;
|
|
351
|
+
dir = parent;
|
|
352
|
+
}
|
|
353
|
+
for (const d of chain) tsconfigCache.set(d, null);
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function readTsconfig(file, dir) {
|
|
358
|
+
try {
|
|
359
|
+
const raw = fs
|
|
360
|
+
.readFileSync(file, 'utf8')
|
|
361
|
+
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
|
|
362
|
+
.replace(/(^|[^:"])\/\/.*$/gm, '$1'); // line comments (not URLs/keys)
|
|
363
|
+
const co = JSON.parse(raw).compilerOptions ?? {};
|
|
364
|
+
return {
|
|
365
|
+
baseDir: co.baseUrl ? path.resolve(dir, co.baseUrl) : dir,
|
|
366
|
+
paths: co.paths ?? {},
|
|
367
|
+
};
|
|
368
|
+
} catch {
|
|
369
|
+
return { baseDir: dir, paths: {} };
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function resolveAliasedImport(fromFile, spec) {
|
|
374
|
+
const cfg = projectConfig(fromFile);
|
|
375
|
+
if (!cfg) return null;
|
|
376
|
+
const candidates = [];
|
|
377
|
+
// explicit tsconfig `paths` (e.g. "@app/*": ["src/app/*"])
|
|
378
|
+
for (const [pattern, targets] of Object.entries(cfg.paths)) {
|
|
379
|
+
const star = pattern.indexOf('*');
|
|
380
|
+
if (star === -1) {
|
|
381
|
+
if (pattern === spec) for (const t of targets) candidates.push(t);
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
const pre = pattern.slice(0, star);
|
|
385
|
+
const post = pattern.slice(star + 1);
|
|
386
|
+
if (
|
|
387
|
+
spec.startsWith(pre) &&
|
|
388
|
+
spec.endsWith(post) &&
|
|
389
|
+
spec.length >= pre.length + post.length
|
|
390
|
+
) {
|
|
391
|
+
const mid = spec.slice(pre.length, spec.length - post.length);
|
|
392
|
+
for (const t of targets) candidates.push(t.replace('*', mid));
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// implicit baseUrl resolution + the near-universal `src/` root fallback, so the
|
|
396
|
+
// common `baseUrl:"."` + `"src/*":["src/*"]` config works even if paths is absent.
|
|
397
|
+
const bases = candidates
|
|
398
|
+
.map((c) => path.resolve(cfg.baseDir, c))
|
|
399
|
+
.concat([path.resolve(cfg.baseDir, spec), path.resolve(cfg.baseDir, 'src', spec)]);
|
|
400
|
+
for (const b of bases) {
|
|
401
|
+
const hit = firstModuleFile(b);
|
|
402
|
+
if (hit) return hit;
|
|
403
|
+
}
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
|
|
215
407
|
// ---------------------------------------------------------------------------
|
|
216
408
|
// scanFunction — what does this function DO?
|
|
217
409
|
// Plain recursive walk (no scope analysis: deterministic, dependency-free),
|
|
@@ -409,6 +601,24 @@ function inspectCall(node, out, ctx) {
|
|
|
409
601
|
return;
|
|
410
602
|
}
|
|
411
603
|
|
|
604
|
+
// ---- kysely builder: db.insertInto('t').values(...) / .updateTable('t').set(...)
|
|
605
|
+
// / .deleteFrom('t') / .selectFrom('t'). The table is the op's OWN string arg
|
|
606
|
+
// (not a chained .from()), so read it straight off arguments[0].
|
|
607
|
+
if (KYSELY_OPS[methodLower] !== undefined && callee.type === 'MemberExpression') {
|
|
608
|
+
const arg0 = node.arguments[0];
|
|
609
|
+
const table = arg0?.type === 'StringLiteral' ? arg0.value : null;
|
|
610
|
+
if (table) {
|
|
611
|
+
const op = KYSELY_OPS[methodLower];
|
|
612
|
+
pushEffect(out, ctx, {
|
|
613
|
+
effectType: op === 'select' ? 'db_read' : 'db_write',
|
|
614
|
+
op,
|
|
615
|
+
table: table.toLowerCase(),
|
|
616
|
+
line,
|
|
617
|
+
});
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
412
622
|
// ---- supabase/knex builder: X.from('t').insert(...) or knex('t').update(...)
|
|
413
623
|
if (SUPABASE_OPS.has(methodLower)) {
|
|
414
624
|
const table = builderTableOf(callee.object);
|
|
@@ -455,6 +665,51 @@ function inspectCall(node, out, ctx) {
|
|
|
455
665
|
}
|
|
456
666
|
}
|
|
457
667
|
|
|
668
|
+
// ---- drizzle: db.insert(users).values(...) / db.update(users) / db.delete(users).
|
|
669
|
+
// The table is the schema-object IDENTIFIER passed to the op (not a string). Only a
|
|
670
|
+
// db-like receiver qualifies, so it never fires on an arbitrary `.insert()`.
|
|
671
|
+
if (DRIZZLE_OPS[methodLower] !== undefined && callee.type === 'MemberExpression') {
|
|
672
|
+
const recv =
|
|
673
|
+
callee.object.type === 'Identifier'
|
|
674
|
+
? callee.object.name
|
|
675
|
+
: clientBaseName(callee.object);
|
|
676
|
+
const arg0 = node.arguments[0];
|
|
677
|
+
if (
|
|
678
|
+
recv &&
|
|
679
|
+
/^(db|database|drizzle|tx|trx|conn|client)$/i.test(recv) &&
|
|
680
|
+
arg0?.type === 'Identifier'
|
|
681
|
+
) {
|
|
682
|
+
pushEffect(out, ctx, {
|
|
683
|
+
effectType: 'db_write',
|
|
684
|
+
op: DRIZZLE_OPS[methodLower],
|
|
685
|
+
table: arg0.name.toLowerCase(),
|
|
686
|
+
line,
|
|
687
|
+
});
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// ---- active-record ORM: User.create(...) / User.findAll(...) / User.save(...) — a
|
|
693
|
+
// Capitalized model receiver with a known op (Mongoose / TypeORM active-record /
|
|
694
|
+
// Sequelize). Capitalization is the shared convention and keeps this from firing on
|
|
695
|
+
// `Math.random()`-style utility calls. Repository-pattern (`userRepository.save()`) is
|
|
696
|
+
// deliberately NOT matched here — in Nest it is reached through DI into the repo
|
|
697
|
+
// method's real query, so matching it too would double-count.
|
|
698
|
+
if (
|
|
699
|
+
MODEL_OPS[methodLower] !== undefined &&
|
|
700
|
+
callee.object.type === 'Identifier' &&
|
|
701
|
+
/^[A-Z]/.test(callee.object.name)
|
|
702
|
+
) {
|
|
703
|
+
const op = MODEL_OPS[methodLower];
|
|
704
|
+
pushEffect(out, ctx, {
|
|
705
|
+
effectType: op === 'select' ? 'db_read' : 'db_write',
|
|
706
|
+
op,
|
|
707
|
+
table: callee.object.name.toLowerCase(),
|
|
708
|
+
line,
|
|
709
|
+
});
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
|
|
458
713
|
// ---- HTTP clients: axios.get(...), got.post(...) — the member IS the method
|
|
459
714
|
if (rootName && HTTP_CLIENTS.has(rootName.toLowerCase())) {
|
|
460
715
|
const httpMethod = /^(get|post|put|patch|delete|head)$/.test(methodLower)
|
|
@@ -568,6 +823,16 @@ function responseJsonShape(node) {
|
|
|
568
823
|
|
|
569
824
|
// res.status(401|403) anywhere in the chain → guard denial signal
|
|
570
825
|
function deniedStatusOf(node) {
|
|
826
|
+
const isDeny = (v) => v === 401 || v === 403;
|
|
827
|
+
// res.sendStatus(401) / res.status(401)... — a direct deny status
|
|
828
|
+
if (
|
|
829
|
+
node.type === 'CallExpression' &&
|
|
830
|
+
node.callee?.type === 'MemberExpression' &&
|
|
831
|
+
node.callee.property?.name === 'sendStatus' &&
|
|
832
|
+
node.arguments[0]?.type === 'NumericLiteral' &&
|
|
833
|
+
isDeny(node.arguments[0].value)
|
|
834
|
+
)
|
|
835
|
+
return true;
|
|
571
836
|
let cur = node;
|
|
572
837
|
while (cur) {
|
|
573
838
|
if (
|
|
@@ -575,7 +840,7 @@ function deniedStatusOf(node) {
|
|
|
575
840
|
cur.callee?.type === 'MemberExpression' &&
|
|
576
841
|
cur.callee.property?.name === 'status' &&
|
|
577
842
|
cur.arguments[0]?.type === 'NumericLiteral' &&
|
|
578
|
-
(cur.arguments[0].value
|
|
843
|
+
isDeny(cur.arguments[0].value)
|
|
579
844
|
)
|
|
580
845
|
return true;
|
|
581
846
|
cur =
|
|
@@ -587,6 +852,35 @@ function deniedStatusOf(node) {
|
|
|
587
852
|
return false;
|
|
588
853
|
}
|
|
589
854
|
|
|
855
|
+
// A visible middleware that is a pure unconditional pass-through — `(req,res,next) =>
|
|
856
|
+
// next()` or `function(req,res,next){ next() }` — cannot deny anything: it is a NO-OP
|
|
857
|
+
// guard (a disabled/stubbed auth). Narrow by design: any conditional, throw, deny, or
|
|
858
|
+
// other work means it is NOT a no-op (a real guard, or a delegating one, stays a guard).
|
|
859
|
+
export function isNoOpGuard(fnNode) {
|
|
860
|
+
if (!fnNode) return false;
|
|
861
|
+
const body = fnNode.body;
|
|
862
|
+
// arrow with expression body: `(...)=> next()`
|
|
863
|
+
if (body && body.type !== 'BlockStatement') return isBareNextCall(body);
|
|
864
|
+
const stmts = (body?.body ?? []).filter((s) => s.type !== 'EmptyStatement');
|
|
865
|
+
if (stmts.length !== 1) return false;
|
|
866
|
+
const s = stmts[0];
|
|
867
|
+
const expr =
|
|
868
|
+
s.type === 'ExpressionStatement'
|
|
869
|
+
? s.expression
|
|
870
|
+
: s.type === 'ReturnStatement'
|
|
871
|
+
? s.argument
|
|
872
|
+
: null;
|
|
873
|
+
return isBareNextCall(expr);
|
|
874
|
+
}
|
|
875
|
+
function isBareNextCall(node) {
|
|
876
|
+
return (
|
|
877
|
+
node?.type === 'CallExpression' &&
|
|
878
|
+
node.callee?.type === 'Identifier' &&
|
|
879
|
+
node.callee.name === 'next' &&
|
|
880
|
+
node.arguments.length === 0
|
|
881
|
+
);
|
|
882
|
+
}
|
|
883
|
+
|
|
590
884
|
// { a: 1, b: x, c: 'y' } → { a: 'number', b: 'unknown:x', c: 'string' }
|
|
591
885
|
// Identifiers keep their name behind 'unknown:' so TypePropagation can later
|
|
592
886
|
// resolve them against input params and state columns.
|