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.
@@ -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 { name: arg.name, sourceFile: fromRel, sourceLine: fn.line, fn: fn.node };
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
@@ -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 (prop.type === 'ObjectProperty' && prop.value.type === 'Identifier')
180
- facts.imports.set(prop.value.name, resolved);
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
- if (!spec.startsWith('.')) return null;
195
- const cleanSpec = spec.replace(/\.(m?[jt]s|cjs)$/, '');
196
- const base = path.resolve(path.dirname(fromFile), cleanSpec);
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 === 401 || cur.arguments[0].value === 403)
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.