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 +1 -1
- package/src/commands/apocalypse.js +17 -3
- package/src/commands/review.js +11 -5
- package/src/ubg/apocalypse.js +16 -3
- package/src/ubg/express.js +48 -10
package/package.json
CHANGED
|
@@ -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.
|
|
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
|
);
|
package/src/commands/review.js
CHANGED
|
@@ -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.
|
|
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.
|
|
185
|
-
?
|
|
186
|
-
:
|
|
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');
|
package/src/ubg/apocalypse.js
CHANGED
|
@@ -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
|
-
|
|
260
|
-
|
|
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
|
|
package/src/ubg/express.js
CHANGED
|
@@ -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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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);
|