sparda-mcp 0.14.0 → 0.14.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "mcpName": "io.github.zyx77550/sparda-mcp",
5
5
  "description": "Compile backends to behavior graphs. Statically prove security, simulate APIs, and replay bugs.",
6
6
  "type": "module",
@@ -16,7 +16,7 @@ import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
16
16
  const ICONS = { critical: '✗', high: '✗', medium: '⚠', info: '·' };
17
17
 
18
18
  export async function runApocalypse(opts) {
19
- const { graph } = compileUBG(opts.cwd, { write: false });
19
+ const { graph, report } = compileUBG(opts.cwd, { write: false });
20
20
  const canonical = canonicalizeGraph(graph);
21
21
  const baselinePath = path.join(opts.cwd, '.sparda', 'ubg.baseline.json');
22
22
 
@@ -42,7 +42,7 @@ export async function runApocalypse(opts) {
42
42
  }
43
43
 
44
44
  const findings = [...staticFindings, ...diffFindings];
45
- const verdict = verdictOf(findings);
45
+ const verdict = verdictOf(findings, canonical);
46
46
 
47
47
  if (opts.sarif) {
48
48
  const sarifPath = path.join(opts.cwd, '.sparda', 'apocalypse.sarif');
@@ -64,7 +64,21 @@ export async function runApocalypse(opts) {
64
64
  console.log(` ${ICONS[f.severity]} [${f.severity}] ${f.rule} — ${f.message}`);
65
65
  if (opts.verbose) for (const ev of f.evidence) console.log(` evidence: ${ev}`);
66
66
  }
67
- if (verdict.clean) {
67
+ if (!verdict.provable) {
68
+ console.log(
69
+ `✗ NO PROOF — 0 routes reached. SPARDA could not see this app's surface (a parser-coverage gap, not a clean bill of health); an empty graph proves nothing. This is NOT a pass — run with --verbose to see what was skipped.`,
70
+ );
71
+ if (opts.verbose) {
72
+ console.log(` detected: ${report.framework} · entry ${report.entry}`);
73
+ if (report.skipped?.length)
74
+ for (const s of report.skipped)
75
+ console.log(` skipped: ${s.reason}${s.file ? ` (${s.file})` : ''}`);
76
+ else
77
+ console.log(
78
+ ` no route call sites reached — routes are likely registered indirectly (a loader / DI pattern the static walk can't follow).`,
79
+ );
80
+ }
81
+ } else if (verdict.clean) {
68
82
  console.log(
69
83
  `✓ PROVEN — ${obligations} obligation(s) discharged, zero violations. No declared guard, invariant, transaction or aggregate boundary can be broken by this tree.`,
70
84
  );
@@ -59,7 +59,7 @@ export function reviewGraphs(baseGraph, candidateGraph) {
59
59
  const removedEps = [...baseEps].filter((e) => !candEps.has(e)).sort(cmp);
60
60
 
61
61
  return {
62
- verdict: verdictOf(findings),
62
+ verdict: verdictOf(findings, candidateGraph),
63
63
  obligations,
64
64
  findings,
65
65
  endpoints: { added, removed: removedEps },
@@ -168,7 +168,11 @@ function renderHuman(r) {
168
168
  }
169
169
 
170
170
  const c = v.counts;
171
- if (v.clean)
171
+ if (!v.provable)
172
+ console.log(
173
+ "\n✗ NO PROOF — 0 routes reached in the working tree; SPARDA could not see this change's route surface (a parser-coverage gap). An empty graph proves nothing — this is NOT a pass.",
174
+ );
175
+ else if (v.clean)
172
176
  console.log(
173
177
  '\n✓ PROVEN — this diff removes no protection and introduces no new risk.',
174
178
  );
@@ -181,9 +185,11 @@ function renderHuman(r) {
181
185
  function renderMarkdown(r) {
182
186
  const { verdict: v, findings, endpoints } = r;
183
187
  const c = v.counts;
184
- const head = v.clean
185
- ? '✓ **PROVEN** — no protection removed, no new risk introduced.'
186
- : `${v.safe ? '⚠ **RISKY**' : '✗ **NOT PROVEN**'} — ${c.critical} critical, ${c.high} high, ${c.medium} medium, ${c.info} info.`;
188
+ const head = !v.provable
189
+ ? "✗ **NO PROOF** — 0 routes reached in the working tree; SPARDA could not see this change's route surface (a parser-coverage gap). An empty graph proves nothing — this is NOT a pass."
190
+ : v.clean
191
+ ? '✓ **PROVEN** — no protection removed, no new risk introduced.'
192
+ : `${v.safe ? '⚠ **RISKY**' : '✗ **NOT PROVEN**'} — ${c.critical} critical, ${c.high} high, ${c.medium} medium, ${c.info} info.`;
187
193
  const lines = [`## 🔍 SPARDA semantic review (vs \`${r.base}\`)`, '', head, ''];
188
194
  if (endpoints.added.length || endpoints.removed.length) {
189
195
  lines.push('### Endpoint surface');
@@ -251,13 +251,26 @@ export function diffGraphs(baseline, candidate) {
251
251
 
252
252
  // ---------------------------------------------------------------------------
253
253
 
254
- export function verdictOf(findings) {
254
+ export function verdictOf(findings, graph) {
255
255
  const counts = { critical: 0, high: 0, medium: 0, info: 0 };
256
256
  for (const f of findings) counts[f.severity]++;
257
+ // Provability guard: a compile that reached ZERO entrypoints proved nothing —
258
+ // an empty graph must never read as PROVEN/RISKY. A parser-coverage miss (a
259
+ // route surface the static eye could not see) has to be loud, not a silent
260
+ // green. `safe`/`clean` fold `provable` in, so every caller that gates on them
261
+ // (apocalypse & review both `exit 1` on !safe) refuses a blind compile for free.
262
+ // `graph` omitted (e.g. heal's regression delta, which isn't a whole-app
263
+ // proof) → provability is not asserted and the old semantics hold.
264
+ const entrypoints = graph
265
+ ? graph.nodes.filter((n) => n.kind === 'entrypoint').length
266
+ : null;
267
+ const provable = entrypoints === null || entrypoints > 0;
257
268
  return {
258
269
  counts,
259
- safe: counts.critical === 0 && counts.high === 0,
260
- clean: findings.length === 0,
270
+ entrypoints,
271
+ provable,
272
+ safe: provable && counts.critical === 0 && counts.high === 0,
273
+ clean: provable && findings.length === 0,
261
274
  };
262
275
  }
263
276
 
@@ -150,16 +150,23 @@ export function extractExpress(cwd, entryFile) {
150
150
 
151
151
  function handleUse(args, stmt) {
152
152
  if (!args.length) return;
153
- // app.use('/prefix', router) → mount for second pass
154
- if (args[0].type === 'StringLiteral' && args[1]?.type === 'Identifier') {
155
- mounts.push({
156
- prefix: joinPath(prefix, args[0].value),
157
- file: mod.imports.get(args[1].name) ?? null,
158
- ident: args[1].name,
159
- fromFile: relFile,
160
- depth: depth + 1, // nested mounts keep sinking, scanFile bounds them
161
- });
162
- return;
153
+ // app.use('/prefix', router) → mount for second pass. The router arg is
154
+ // usually an imported Identifier, but the inline-require idiom
155
+ // `app.use('/x', require('./x.controller'))` (rootpath-style apps) is just
156
+ // as common — resolve it directly. `undefined` = not a router mount at all
157
+ // (fall through to global-middleware handling below).
158
+ if (args[0].type === 'StringLiteral') {
159
+ const target = mountTargetFile(args[1], mod, absFile);
160
+ if (target !== undefined) {
161
+ mounts.push({
162
+ prefix: joinPath(prefix, args[0].value),
163
+ file: target, // null = named/required but unresolved → reported in the mounts loop
164
+ ident: mountIdentName(args[1]),
165
+ fromFile: relFile,
166
+ depth: depth + 1, // nested mounts keep sinking, scanFile bounds them
167
+ });
168
+ return;
169
+ }
163
170
  }
164
171
  // app.use(fn) / app.use(ident) at depth 0 → global middleware
165
172
  if (depth === 0) {
@@ -326,6 +333,37 @@ function collectRouteArrays(body) {
326
333
  return arrays;
327
334
  }
328
335
 
336
+ // The second arg of app.use('/p', X): where does the mounted router live?
337
+ // Identifier → the import it resolves to (null if unresolved)
338
+ // require('./x') → the relative file it resolves to (null if non-relative)
339
+ // anything else → undefined (not a router mount; e.g. a middleware call)
340
+ function mountTargetFile(arg, mod, absFile) {
341
+ if (!arg) return undefined;
342
+ if (arg.type === 'Identifier') return mod.imports.get(arg.name) ?? null;
343
+ if (
344
+ arg.type === 'CallExpression' &&
345
+ arg.callee.type === 'Identifier' &&
346
+ arg.callee.name === 'require' &&
347
+ arg.arguments[0]?.type === 'StringLiteral'
348
+ ) {
349
+ return resolveRelImport(absFile, arg.arguments[0].value);
350
+ }
351
+ return undefined;
352
+ }
353
+
354
+ // a readable identifier for the mount's skipped-report when it can't resolve
355
+ function mountIdentName(arg) {
356
+ if (arg?.type === 'Identifier') return arg.name;
357
+ if (
358
+ arg?.type === 'CallExpression' &&
359
+ arg.callee.type === 'Identifier' &&
360
+ arg.callee.name === 'require' &&
361
+ arg.arguments[0]?.type === 'StringLiteral'
362
+ )
363
+ return `require('${arg.arguments[0].value}')`;
364
+ return 'router';
365
+ }
366
+
329
367
  function collectAppVars(node, appVars, routerVars) {
330
368
  if (node.type === 'ExportNamedDeclaration' && node.declaration)
331
369
  return collectAppVars(node.declaration, appVars, routerVars);