sparda-mcp 0.32.0 → 0.62.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/README.md CHANGED
@@ -10,38 +10,58 @@
10
10
 
11
11
  ---
12
12
 
13
- **A compiler for backend behavior. The LLVM of web applications.**
13
+ <h1 align="center">AI writes. SPARDA proves.</h1>
14
14
 
15
- For twenty years software communicated through APIs. Then AI agents arrived, and the industry's answer was to expose more endpoints (MCP). But an agent doesn't *understand* an application from a list of disconnected tools — and neither can a linter, a debugger, or a deploy gate reason about a codebase they only read line by line.
15
+ **The trust layer for AI-written backends.** SPARDA compiles your backend routes, database queries, state mutations, guards, side-effects into one deterministic behavior graph, then **statically proves what can and can't break before you ship**: no unguarded mutation, no broken invariant, no non-atomic aggregate write.
16
16
 
17
- SPARDA compiles your backend — routes, database queries, state mutations, permissions, side-effects — into one language-agnostic, mathematical graph: the **Unified Behavior Graph (UBG)**, serialized as `.sparda/ubg.json` under the **SBIR** specification ([SPARDA Behavior IR](docs/SBIR_SPEC_V1.1.md)). Compile once; then every tool is a simple pass over that graph.
17
+ [![npm](https://img.shields.io/npm/v/sparda-mcp)](https://www.npmjs.com/package/sparda-mcp)
18
+ [![CI](https://github.com/zyx77550/sparda/actions/workflows/ci.yml/badge.svg)](https://github.com/zyx77550/sparda/actions/workflows/ci.yml)
19
+ ![node](https://img.shields.io/badge/node-%3E%3D18-brightgreen)
20
+ ![runtime deps](https://img.shields.io/badge/runtime%20deps-4%20pinned-4c1)
21
+ [![license](https://img.shields.io/badge/license-BUSL--1.1-blue)](./LICENSE)
22
+
23
+ 100% local · deterministic · zero API key · no cloud account. It fails loudly on a real risk, and when it can only see part of your app it says **PROVEN (PARTIAL)** — never a false green.
24
+
25
+ ## 60-second proof
26
+
27
+ From your Express, FastAPI, Next.js, NestJS or Medusa app — nothing to configure:
28
+
29
+ ```bash
30
+ npx sparda-mcp apocalypse # prove the tree is safe to deploy — exit 1 on any real risk
31
+ npx sparda-mcp prove # the whole verdict: proof + coverage + shareable seal
32
+ npx sparda-mcp badge # a README badge: proven · coverage% · routes
33
+ ```
34
+
35
+ Under the hood it compiles your backend into one language-agnostic graph — the **Unified Behavior Graph (UBG)**, serialized as `.sparda/ubg.json` under the **SBIR** specification ([SPARDA Behavior IR](docs/SBIR_SPEC_V1.1.md)) — and every command is a pass over that graph.
18
36
 
19
37
  > [!IMPORTANT]
20
- > **The 3700-Route Proof:** In our latest stress-test (v0.26.0), SPARDA successfully compiled and proved **3700+ routes** from elite open-source monsters (Next.js *Dub*, NestJS *Immich*, *MedusaJS*, and GitHub's OpenAPI) in **< 2.2 seconds** per repo, with zero crashes. It natively resolves deep Dependency Injection, external controllers, and Next.js handlers.
38
+ > **The Route-Compilation Proof reproduce it yourself.** SPARDA compiles real open-source monsters to their behavior graph with **zero crashes**, each in **≈1–2 seconds**: Next.js *Dub* (579 routes), NestJS *Immich* (281), *MedusaJS* (477). It natively resolves deep Dependency Injection, external controllers, and Next.js handlers. One command clones them and re-measures on your machine:
39
+ > ```bash
40
+ > node bench/repro.mjs # → bench/route-proof.json
41
+ > ```
42
+ > Honesty first: *compiling* a route is a parser result (the number above); *proving* it safe is a separate per-repo verdict — and most real apps come back **NOT_PROVEN**, which is the true state, not a failure. (Our full 25-repo corpus stress compiles **3,565 routes** at ~150 routes/s; that one needs the corpus checked out.)
21
43
 
22
44
  **What the graph unlocks — 100% local, deterministic, zero runtime dependencies, zero API key:**
23
45
 
24
46
  | Command | What it does |
25
47
  |---|---|
26
- | **`ubg`** | Compile the codebase to its behavior graph (Express · FastAPI · Next.js natively; **any** stack via OpenAPI) |
48
+ | **`prove`** | *The whole trust verdict in one gesture* proof + coverage + a shareable seal (`--json` / `--markdown`) |
27
49
  | **`apocalypse`** | *Prove the deploy* — no guard, invariant, transaction or aggregate boundary can be broken (SARIF + CI gate) |
50
+ | **`heal`** | *Self-heal, **proven*** — the gate Copilot Autofix doesn't have: a fix ships **only if** replay matches, `verify` still passes, and `apocalypse` finds no new risk / no dropped guard. Whoever wrote the fix, the machine judges it. |
51
+ | **`badge`** | *The shareable artifact* — a self-contained SVG badge + README snippet (verdict · coverage · routes) |
52
+ | **`dossier`** | *The public report* — one self-contained HTML page: verdict, risks, and SPARDA's own blind spots |
53
+ | **`ubg`** | Compile the codebase to its behavior graph (Express · FastAPI · Next.js · NestJS · Medusa natively; **any** stack via OpenAPI) |
28
54
  | **`timeless`** | *Time-travel* — record a production request, replay it byte-identically, export the bug as a test |
29
- | **`heal`** | *Self-heal, proven* — bug → fix → the machine proves the fix is correct and breaks nothing |
30
55
  | **`mirror`** | *Execute the graph* — serve the compiled behavior over HTTP with no framework and no source |
31
- | **`openapi`** | *Emit the standard* — produce an OpenAPI 3.1 spec from the graph |
32
- | **`verify`** | *Prove the compiler's own laws* (determinism, soundness, round-trip) on your app |
33
- | **`init` / `dev`** | Expose the graph to AI clients as a live MCP server (+ Twin, Immune, Evolution runtime layer) |
56
+ | **`init` / `dev`** | *Runtime, optional* — expose the graph to AI clients as a live MCP server (+ Twin, Immune, Evolution) |
34
57
 
35
- ```bash
36
- npx sparda-mcp ubg # compile your backend → .sparda/ubg.json
37
- npx sparda-mcp apocalypse # prove the current tree is safe to deploy
38
- ```
39
-
40
- No cloud account. No server to host. Exposing raw APIs to AI is the old way — SPARDA compiles the whole system's behavior, then proves, replays, heals, and serves it.
58
+ The prover is the product. The MCP server is one *output* of the graph, not the point — SPARDA compiles the whole system's behavior, then proves, replays, heals, and (optionally) serves it.
41
59
 
42
60
  **Nomenclature:** **SBIR** is the specification (the format, like "JSON"); **UBG** is the compiled graph itself (the artifact, `ubg.json`). The MCP server is one *output* of the graph, not the product.
43
61
 
44
- ## Quickstart
62
+ ## Optional: expose the graph to AI clients (MCP runtime)
63
+
64
+ Beyond proving, SPARDA can turn your running app into a live MCP server — the graph, executable, with write-safety and an immune layer. This is optional and separate from the prover above.
45
65
 
46
66
  1. **Scan + inject** — run once, from your app's directory:
47
67
  ```bash
@@ -0,0 +1 @@
1
+ {"v":"imm1","proven":false,"surfaceOnly":true,"coverage":1,"blindHigh":0,"routes":[{"behaviorHash":"bh1_13969839d8a18d54aa0342618e56db6a","pol":121,"exposed":[]},{"behaviorHash":"bh1_2daf8a2b1b0ed393d443991a2b0700f6","pol":121,"exposed":[]},{"behaviorHash":"bh1_58fbf5ac38b6d71abc7204921de6b957","pol":121,"exposed":[]},{"behaviorHash":"bh1_58fbf5ac38b6d71abc7204921de6b957","pol":121,"exposed":[]},{"behaviorHash":"bh1_cc6788289c612af85b8d215b5bdf9b28","pol":121,"exposed":[]}],"posture":{"auth":{"protected":0,"exposed":0,"na":5},"atomicity":{"protected":0,"exposed":0,"na":5},"reversibility":{"protected":0,"exposed":0,"na":5},"validation":{"protected":0,"exposed":0,"na":5},"aggregate":{"protected":0,"exposed":0,"na":5}},"bytes":5}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.32.0",
3
+ "version": "0.62.0",
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",
@@ -17,13 +17,18 @@
17
17
  },
18
18
  "scripts": {
19
19
  "test": "vitest run",
20
+ "prepublishOnly": "vitest run",
21
+ "corpus": "node scripts/corpus-oracle.mjs",
22
+ "corpus:update": "node scripts/corpus-oracle.mjs --update",
20
23
  "test:router": "node tests/router-selftest.cjs",
21
24
  "test:all": "vitest run && node tests/router-selftest.cjs",
22
25
  "coverage": "vitest run --coverage",
23
26
  "lint": "eslint .",
24
27
  "lint:fix": "eslint . --fix",
25
28
  "format": "prettier --write \"**/*.{js,cjs,mjs}\"",
26
- "format:check": "prettier --check \"**/*.{js,cjs,mjs}\""
29
+ "format:check": "prettier --check \"**/*.{js,cjs,mjs}\"",
30
+ "bench:check": "node bench/check-readme.mjs",
31
+ "mutation": "node tests/mutation/run.mjs"
27
32
  },
28
33
  "files": [
29
34
  "src",
@@ -43,9 +43,10 @@ export async function runApocalypse(opts) {
43
43
  }
44
44
 
45
45
  const findings = [...staticFindings, ...diffFindings];
46
- const verdict = verdictOf(findings, canonical);
47
46
  // the honesty companion: where does the proof stop? (see `sparda blindspots`)
48
47
  const blind = surveyBlindspots(canonical, report);
48
+ // coverage feeds the verdict: a clean app that resolved almost nothing is SURFACE, not PROVEN
49
+ const verdict = verdictOf(findings, canonical, { coverage: blind.coverage.ratio });
49
50
 
50
51
  if (opts.sarif) {
51
52
  const sarifPath = path.join(opts.cwd, '.sparda', 'apocalypse.sarif');
@@ -0,0 +1,111 @@
1
+ // commands/badge.js — the shareable artifact (URGENT-ADOPTION J3-4). The move that converts a
2
+ // download into a star (Codecov/Lighthouse): the result is a badge the user pins proudly in
3
+ // their README. A self-contained SVG (no external fetch — the product's zero-infra ethos) plus
4
+ // a paste-ready markdown block. The verdict word comes from the SAME source as `prove`
5
+ // (verdictState), so the public badge can NEVER over-claim: a 23%-resolved app reads PARTIAL,
6
+ // never a bare PROVEN. `--out <file>` redirects the SVG; `--json` emits the badge data for CI.
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { compileUBG } from '../ubg/compile.js';
10
+ import { canonicalizeGraph } from '../ubg/schema.js';
11
+ import { checkGraph, verdictOf, badgeFor } from '../ubg/apocalypse.js';
12
+ import { surveyBlindspots } from '../ubg/blindspots.js';
13
+
14
+ export async function runBadge(opts) {
15
+ const { graph, report } = compileUBG(opts.cwd, { write: false });
16
+ const canonical = canonicalizeGraph(graph);
17
+ const { findings } = checkGraph(canonical);
18
+ const blind = surveyBlindspots(canonical, report);
19
+ const verdict = verdictOf(findings, canonical, { coverage: blind.coverage.ratio });
20
+ const cov = Math.round(blind.coverage.ratio * 100);
21
+ const { state, message, color } = badgeFor(verdict, { coverage: blind.coverage.ratio });
22
+
23
+ const svg = renderBadge('SPARDA', message, color);
24
+ const outPath = opts.out
25
+ ? path.resolve(opts.cwd, opts.out)
26
+ : path.join(opts.cwd, '.sparda', 'badge.svg');
27
+
28
+ const data = {
29
+ verdict: state,
30
+ coverage: cov,
31
+ routes: report.routes,
32
+ guards: verdict.guards,
33
+ guardsVerified: verdict.guardsVerified,
34
+ findings: findings.filter((f) => !f.advisory).length,
35
+ color,
36
+ message,
37
+ };
38
+
39
+ if (opts.json) {
40
+ console.log(JSON.stringify({ ...data, svgPath: rel(outPath, opts.cwd) }, null, 2));
41
+ return { data };
42
+ }
43
+
44
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
45
+ fs.writeFileSync(outPath, svg);
46
+
47
+ const relSvg = rel(outPath, opts.cwd);
48
+ // shields.io endpoint alternative — same values, for users who prefer a hosted badge
49
+ const shields = `https://img.shields.io/badge/SPARDA-${encodeURIComponent(message)}-${color.slice(1)}`;
50
+
51
+ console.log(
52
+ `\n✓ Badge written: ${relSvg} (${state} · ${report.routes} routes · ${cov}% coverage)`,
53
+ );
54
+ console.log(`\nPaste into your README:\n`);
55
+ console.log(` ![SPARDA](./${relSvg})`);
56
+ console.log(`\nOr use the shields.io endpoint (no committed file):\n`);
57
+ console.log(` ![SPARDA](${shields})`);
58
+ console.log('');
59
+ return { data, svgPath: relSvg };
60
+ }
61
+
62
+ // A shields-style two-part "flat" badge, fully self-contained. Deterministic width from a
63
+ // Verdana-ish per-char estimate — pixel-perfection is not the point, a clean readable badge is.
64
+ function renderBadge(label, message, color) {
65
+ const lw = textWidth(label);
66
+ const mw = textWidth(message);
67
+ const PAD = 6;
68
+ const lWidth = lw + 2 * PAD;
69
+ const mWidth = mw + 2 * PAD;
70
+ const total = lWidth + mWidth;
71
+ const h = 20;
72
+ // ×10 coords: shields renders text at a 10× scale then scales down, for crisp positioning
73
+ const lx = (lWidth / 2) * 10;
74
+ const mx = (lWidth + mWidth / 2) * 10;
75
+ const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
76
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${total}" height="${h}" role="img" aria-label="${esc(label)}: ${esc(message)}">
77
+ <title>${esc(label)}: ${esc(message)}</title>
78
+ <linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
79
+ <clipPath id="r"><rect width="${total}" height="${h}" rx="3" fill="#fff"/></clipPath>
80
+ <g clip-path="url(#r)">
81
+ <rect width="${lWidth}" height="${h}" fill="#555"/>
82
+ <rect x="${lWidth}" width="${mWidth}" height="${h}" fill="${color}"/>
83
+ <rect width="${total}" height="${h}" fill="url(#s)"/>
84
+ </g>
85
+ <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="110" text-rendering="geometricPrecision">
86
+ <text x="${lx}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${lw * 10}">${esc(label)}</text>
87
+ <text x="${lx}" y="140" transform="scale(.1)" textLength="${lw * 10}">${esc(label)}</text>
88
+ <text x="${mx}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${mw * 10}">${esc(message)}</text>
89
+ <text x="${mx}" y="140" transform="scale(.1)" textLength="${mw * 10}">${esc(message)}</text>
90
+ </g>
91
+ </svg>
92
+ `;
93
+ }
94
+
95
+ // Verdana 11px approximate advance widths — wide enough to avoid clipping, narrow enough to
96
+ // look tight. Digits/lowercase ~7, uppercase ~8.5, punctuation/space narrower.
97
+ function textWidth(s) {
98
+ let w = 0;
99
+ for (const ch of s) {
100
+ if (/[A-Z]/.test(ch)) w += 8.5;
101
+ else if (/[a-z0-9]/.test(ch)) w += 7;
102
+ else if (ch === ' ') w += 4;
103
+ else if (ch === '·') w += 5;
104
+ else if (ch === '%') w += 11;
105
+ else w += 5;
106
+ }
107
+ return Math.ceil(w);
108
+ }
109
+
110
+ const rel = (abs, cwd) =>
111
+ abs.startsWith(cwd) ? abs.slice(cwd.length + 1).replaceAll('\\', '/') : abs;
@@ -9,7 +9,7 @@ import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { compileUBG } from '../ubg/compile.js';
11
11
  import { canonicalizeGraph } from '../ubg/schema.js';
12
- import { checkGraph, verdictOf } from '../ubg/apocalypse.js';
12
+ import { checkGraph, verdictOf, verdictState } from '../ubg/apocalypse.js';
13
13
  import { surveyBlindspots } from '../ubg/blindspots.js';
14
14
  import { buildCapsule } from '../ubg/immunity.js';
15
15
  import { AXES, POLARITY_SYMBOL, exposedAxes } from '../ubg/polarity.js';
@@ -19,9 +19,9 @@ export async function runDossier(opts) {
19
19
  const compiled = compileUBG(opts.cwd, { write: false, openapi: opts.openapi });
20
20
  const canonical = canonicalizeGraph(compiled.graph);
21
21
  const { findings, polarity } = checkGraph(canonical);
22
- const verdict = verdictOf(findings, canonical);
23
22
  const capsule = buildCapsule(canonical);
24
23
  const blindspots = surveyBlindspots(canonical, compiled.report);
24
+ const verdict = verdictOf(findings, canonical, { coverage: blindspots.coverage.ratio });
25
25
 
26
26
  const data = {
27
27
  app: path.basename(path.resolve(opts.cwd)) || 'app',
@@ -32,6 +32,8 @@ export async function runDossier(opts) {
32
32
  edges: canonical.edges.length,
33
33
  provable: verdict.provable,
34
34
  proven: verdict.provable && verdict.clean,
35
+ state: verdictState(verdict),
36
+ coverage: Math.round(blindspots.coverage.ratio * 100),
35
37
  surfaceOnly: verdict.surfaceOnly,
36
38
  guards: verdict.guards,
37
39
  guardsVerified: verdict.guardsVerified,
@@ -54,12 +56,23 @@ export async function runDossier(opts) {
54
56
  const outPath = path.join(opts.cwd, '.sparda', 'dossier.html');
55
57
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
56
58
  atomicWrite(outPath, html);
59
+ const WORD = {
60
+ PROVEN: '✓ PROVEN',
61
+ PARTIAL: '◑ PROVEN (PARTIAL)',
62
+ RISKY: '⚠ RISKY',
63
+ NOT_PROVEN: '✗ NOT PROVEN',
64
+ SURFACE: '◐ SURFACE ONLY',
65
+ NO_PROOF: '✗ NO PROOF',
66
+ };
57
67
  console.log(
58
- `DOSSIER — ${data.routes} route(s), verdict ${data.proven ? '✓ PROVEN' : data.provable ? '✗ NOT PROVEN' : '✗ NO PROOF'}`,
68
+ `DOSSIER — ${data.routes} route(s) · coverage ${data.coverage}% · verdict ${WORD[data.state] ?? data.state}`,
59
69
  );
60
70
  console.log(
61
71
  ` written: .sparda/dossier.html (${html.length} bytes, self-contained) — open it in any browser.`,
62
72
  );
73
+ console.log(
74
+ ` 📸 one screenshotable page — the verdict, the risks, and SPARDA's own blind spots. Share it.`,
75
+ );
63
76
  console.log(
64
77
  ` ephemeral: .sparda/ is gitignored, so it vanishes on \`sparda remove\` — save it to keep it.`,
65
78
  );
@@ -83,21 +96,43 @@ const SEV_COLOR = {
83
96
  };
84
97
 
85
98
  export function renderDossierHTML(d) {
86
- const verdictClass = d.proven ? 'ok' : d.surfaceOnly ? 'warn' : 'bad';
87
- const verdictText = d.proven
88
- ? 'PROVEN'
89
- : d.surfaceOnly
90
- ? 'SURFACE ONLY — routes seen, no behavior resolved'
91
- : d.provable
92
- ? 'NOT PROVEN'
93
- : 'NO PROOF — the app’s routes could not be read';
94
- const verdictLede = d.proven
95
- ? 'No declared guard, invariant, transaction or aggregate boundary can be broken by this code.'
96
- : d.surfaceOnly
97
- ? 'SPARDA saw the route surface but no state or side-effects behind it — there was nothing to prove. This is NOT a clean bill of health (a spec, or an effect-resolution gap).'
98
- : d.provable
99
- ? `SPARDA found ${d.counts.critical} critical and ${d.counts.high} high risk(s) that must be fixed before this ships.`
100
- : 'SPARDA could not see this app’s route surface — this is not a clean bill of health.';
99
+ // The verdict word comes from the shared state (single source of truth). Back-compat: derive
100
+ // it from the older booleans when a caller passes no `state` (e.g. legacy tests). PARTIAL is
101
+ // honest — a clean proof over only part of the surface never reads as a bare PROVEN.
102
+ const state =
103
+ d.state ??
104
+ (!d.provable
105
+ ? 'NO_PROOF'
106
+ : d.surfaceOnly
107
+ ? 'SURFACE'
108
+ : d.proven
109
+ ? 'PROVEN'
110
+ : 'NOT_PROVEN');
111
+ const verdictClass =
112
+ state === 'PROVEN'
113
+ ? 'ok'
114
+ : state === 'PARTIAL' || state === 'SURFACE' || state === 'RISKY'
115
+ ? 'warn'
116
+ : 'bad';
117
+ const verdictText = {
118
+ PROVEN: 'PROVEN',
119
+ PARTIAL: 'PROVEN (PARTIAL)',
120
+ RISKY: 'RISKY',
121
+ SURFACE: 'SURFACE ONLY — routes seen, no behavior resolved',
122
+ NOT_PROVEN: 'NOT PROVEN',
123
+ NO_PROOF: 'NO PROOF — the app’s routes could not be read',
124
+ }[state];
125
+ const verdictLede = {
126
+ PROVEN:
127
+ 'No declared guard, invariant, transaction or aggregate boundary can be broken by this code.',
128
+ PARTIAL: `SPARDA proved the resolved surface (${d.coverage ?? Math.round((d.blindspots?.coverage?.ratio ?? 1) * 100)}%) with 0 violations — but the rest of the app was invisible to static analysis and is UNPROVEN, not safe.`,
129
+ RISKY: `SPARDA found no critical/high risk, but ${d.counts.medium + d.counts.info} finding(s) to review before this is a clean proof.`,
130
+ SURFACE:
131
+ 'SPARDA saw the route surface but no state or side-effects behind it — there was nothing to prove. This is NOT a clean bill of health (a spec, or an effect-resolution gap).',
132
+ NOT_PROVEN: `SPARDA found ${d.counts.critical} critical and ${d.counts.high} high risk(s) that must be fixed before this ships.`,
133
+ NO_PROOF:
134
+ 'SPARDA could not see this app’s route surface — this is not a clean bill of health.',
135
+ }[state];
101
136
 
102
137
  const cell = (v) => {
103
138
  const cls = v === -1 ? 'neg' : v === 1 ? 'pos' : 'na';
@@ -164,6 +199,9 @@ export function renderDossierHTML(d) {
164
199
  .top{display:flex;justify-content:space-between;align-items:baseline;border-bottom:1px solid var(--line);padding-bottom:16px;font-family:var(--mono);font-size:13px;color:var(--faint)}
165
200
  .top b{color:var(--ink);letter-spacing:.12em}
166
201
  .hero{padding:48px 0 32px}
202
+ .verdict-row{display:flex;align-items:center;gap:16px;flex-wrap:wrap}
203
+ .cov{font-family:var(--mono);font-size:14px;color:var(--faint);padding:10px 18px;border:1px solid var(--line);border-radius:12px;background:var(--panel)}
204
+ .cov b{color:var(--ink);font-size:18px}
167
205
  .stamp{display:inline-flex;align-items:center;gap:12px;font-family:var(--mono);font-weight:700;font-size:16px;padding:12px 24px;border-radius:12px;border:1.5px solid;letter-spacing:.03em;box-shadow:0 4px 12px -4px color-mix(in oklch,currentColor 30%,transparent)}
168
206
  .stamp .dot{width:8px;height:8px;border-radius:50%;background:currentColor;box-shadow:0 0 0 4px color-mix(in oklch,currentColor 22%,transparent)}
169
207
  .ok{color:var(--green);border-color:var(--green);background:color-mix(in oklch,var(--green) 10%,transparent)}
@@ -198,13 +236,16 @@ export function renderDossierHTML(d) {
198
236
  <div class="top"><b>SPARDA</b><span>AI writes. SPARDA proves.</span></div>
199
237
 
200
238
  <section class="hero">
201
- <span class="stamp ${verdictClass}"><span class="dot"></span>${esc(verdictText)}</span>
239
+ <div class="verdict-row">
240
+ <span class="stamp ${verdictClass}"><span class="dot"></span>${esc(verdictText)}</span>
241
+ <span class="cov" title="share of the app's behavior SPARDA resolved and reasoned about">coverage&nbsp;<b>${b.coverage ? (b.coverage.ratio * 100).toFixed(0) : (d.coverage ?? 0)}%</b></span>
242
+ </div>
202
243
  <p>${esc(verdictLede)}</p>
203
244
  <div class="stats">
204
245
  ${statCard(d.routes, 'routes')}
246
+ ${statCard((b.coverage.ratio * 100).toFixed(0) + '%', 'behavior resolved')}
205
247
  ${statCard(d.nodes, 'graph nodes')}
206
248
  ${statCard(d.tables, 'data tables')}
207
- ${statCard(d.capsuleBytes + ' B', 'safety capsule')}
208
249
  ${statCard(esc(d.framework), 'framework')}
209
250
  </div>
210
251
  ${
@@ -50,6 +50,11 @@ export async function runImmunize(opts) {
50
50
  ? '✓ PROVEN'
51
51
  : '✗ NOT PROVEN';
52
52
  console.log(` verdict: ${verdictText}` + (exposed ? ` — exposed: ${exposed}` : ''));
53
+ console.log(
54
+ ` coverage: ${(capsule.coverage * 100).toFixed(0)}% of observed behavior resolved` +
55
+ (capsule.blindHigh ? ` · ${capsule.blindHigh} high-risk blind spot(s)` : '') +
56
+ ` — the proof's confidence, frozen with it`,
57
+ );
53
58
  console.log(
54
59
  ` written: .sparda/immunity.json (${wire} bytes on the wire) — portable, offline, lookup by behaviorHash`,
55
60
  );
@@ -0,0 +1,193 @@
1
+ // commands/prove.js — THE one gesture. The whole trust picture in a single command:
2
+ // the verdict (apocalypse), what SPARDA could NOT see (blindspots coverage), the
3
+ // portable safety seal (immunity capsule), and the behavior fingerprint — assembled,
4
+ // not scattered across six commands. Every piece is an existing organ; prove only
5
+ // composes and presents them, so there is exactly one source of truth per fact.
6
+ import crypto from 'node:crypto';
7
+ import path from 'node:path';
8
+ import { compileUBG } from '../ubg/compile.js';
9
+ import { canonicalizeGraph } from '../ubg/schema.js';
10
+ import { checkGraph, verdictOf, verdictState, badgeFor } from '../ubg/apocalypse.js';
11
+ import { surveyBlindspots } from '../ubg/blindspots.js';
12
+ import { buildCapsule } from '../ubg/immunity.js';
13
+ import { fingerprintGraph } from '../ubg/fingerprint.js';
14
+ import { suggestAppDirs } from '../detect.js';
15
+
16
+ export async function runProve(opts) {
17
+ const { graph, report } = compileUBG(opts.cwd, { write: false, openapi: opts.openapi });
18
+ const canonical = canonicalizeGraph(graph);
19
+
20
+ const { findings, obligations } = checkGraph(canonical);
21
+ const blind = surveyBlindspots(canonical, report);
22
+ const verdict = verdictOf(findings, canonical, { coverage: blind.coverage.ratio });
23
+ const capsule = buildCapsule(canonical);
24
+ const prints = fingerprintGraph(canonical);
25
+ // one app-level seal: a content address over every route's behavior hash — the same
26
+ // behavior, in any repo, yields the same seal (the genome's join key, made visible).
27
+ const seal =
28
+ 'seal_' +
29
+ crypto
30
+ .createHash('sha256')
31
+ .update(prints.map((p) => p.behaviorHash ?? '·').join('\n'))
32
+ .digest('hex')
33
+ .slice(0, 16);
34
+
35
+ const state = verdictState(verdict);
36
+
37
+ const summary = {
38
+ app: path.basename(path.resolve(opts.cwd)) || 'app',
39
+ verdict: state,
40
+ routes: report.routes,
41
+ guards: verdict.guards,
42
+ guardsVerified: verdict.guardsVerified,
43
+ obligations,
44
+ coverage: blind.coverage.ratio,
45
+ blindSpots: blind.surface,
46
+ blindHigh: blind.byRisk.critical + blind.byRisk.high,
47
+ findings: findings.map((f) => ({
48
+ rule: f.rule,
49
+ severity: f.severity,
50
+ entrypoint: f.entrypoint,
51
+ })),
52
+ counts: verdict.counts,
53
+ capsuleBytes: capsule.bytes,
54
+ behaviors: new Set(prints.map((p) => p.behaviorHash).filter(Boolean)).size,
55
+ seal,
56
+ };
57
+
58
+ if (opts.json) {
59
+ console.log(JSON.stringify(summary, null, 2));
60
+ return gate(verdict);
61
+ }
62
+
63
+ // --markdown: a sticky-PR-comment body (used by the GitHub Action's `prove` mode). The
64
+ // badge renders inline via shields.io — the ONE place a hosted URL is right, because a
65
+ // GitHub comment can't embed a repo-local SVG. Same verdict word/colour as `badge` and the
66
+ // CLI (badgeFor), so the PR comment can never over-claim.
67
+ if (opts.markdown) {
68
+ console.log(
69
+ proveMarkdown({ verdict, report, findings, coverage: blind.coverage.ratio, seal }),
70
+ );
71
+ return gate(verdict);
72
+ }
73
+
74
+ const ICON = {
75
+ PROVEN: '✓',
76
+ PARTIAL: '◑',
77
+ NOT_PROVEN: '✗',
78
+ RISKY: '⚠',
79
+ SURFACE: '◐',
80
+ NO_PROOF: '✗',
81
+ };
82
+ const HEAD = {
83
+ PROVEN: 'PROVEN',
84
+ PARTIAL: 'PROVEN (PARTIAL)',
85
+ NOT_PROVEN: 'NOT PROVEN',
86
+ RISKY: 'RISKY',
87
+ SURFACE: 'SURFACE ONLY',
88
+ NO_PROOF: 'NO PROOF',
89
+ };
90
+
91
+ const cov = `${(blind.coverage.ratio * 100).toFixed(0)}%`;
92
+ console.log(`\nSPARDA · ${summary.app}`);
93
+ console.log('─'.repeat(46));
94
+ console.log(`${ICON[state]} ${HEAD[state]}`);
95
+
96
+ if (state === 'PROVEN') {
97
+ console.log(` ${obligations} obligations discharged · 0 violations`);
98
+ } else if (state === 'PARTIAL') {
99
+ console.log(
100
+ ` ${obligations} obligations discharged · 0 violations — but only ${cov} of the surface resolved; the rest is UNPROVEN, not safe`,
101
+ );
102
+ } else if (state === 'NO_PROOF') {
103
+ console.log(
104
+ ` 0 routes resolved — nothing to prove (a parser-coverage gap, not a pass)`,
105
+ );
106
+ const dirs = suggestAppDirs(opts.cwd);
107
+ if (dirs.length) {
108
+ console.log(` ◐ this looks like a monorepo — the app is in a sub-directory. Try:`);
109
+ for (const d of dirs.slice(0, 4))
110
+ console.log(` cd ${d.dir} && sparda prove # looks like ${d.framework}`);
111
+ }
112
+ } else if (state === 'SURFACE') {
113
+ console.log(
114
+ ` ${report.routes} routes seen · coverage ${cov} — not enough state-changing behavior resolved to prove`,
115
+ );
116
+ } else {
117
+ const c = verdict.counts;
118
+ console.log(
119
+ ` ${c.critical} critical · ${c.high} high · ${c.medium} medium · ${c.info} info`,
120
+ );
121
+ for (const f of findings.slice(0, 5)) {
122
+ const ep = f.entrypoint.replace(/^entrypoint:/, '');
123
+ console.log(` ${sev(f.severity)} ${ep.padEnd(34)} ${labelOf(f.rule)}`);
124
+ }
125
+ if (findings.length > 5) console.log(` … and ${findings.length - 5} more`);
126
+ }
127
+
128
+ console.log(
129
+ ` ${report.routes} routes · ${verdict.guards} guards (${verdict.guardsVerified} verified) · coverage ${cov}`,
130
+ );
131
+ if (blind.surface > 0)
132
+ console.log(
133
+ ` ◐ ${blind.surface} blind spots (${summary.blindHigh} high+) — run \`sparda blindspots\``,
134
+ );
135
+ console.log(
136
+ ` ⬡ seal ${seal} · capsule ${capsule.bytes} B · ${summary.behaviors} distinct behaviors`,
137
+ );
138
+ console.log('');
139
+
140
+ return gate(verdict);
141
+ }
142
+
143
+ function gate(verdict) {
144
+ // exit 1 whenever this is not a positive, riskless proof — the CI contract.
145
+ if (!verdict.safe) process.exitCode = 1;
146
+ }
147
+
148
+ // The sticky-PR-comment body: the badge (rendered inline via shields.io), the verdict line,
149
+ // the coverage/guards facts, and the top findings. This is the discovery surface — every PR
150
+ // shows the whole team the SPARDA verdict, for free. The badge word/colour comes from
151
+ // badgeFor, so it can never disagree with the CLI or the committed SVG.
152
+ function proveMarkdown({ verdict, report, findings, coverage, seal }) {
153
+ const { message, color } = badgeFor(verdict, { coverage });
154
+ const cov = Math.round(coverage * 100);
155
+ const badge = `https://img.shields.io/badge/SPARDA-${encodeURIComponent(message)}-${color.slice(1)}`;
156
+ const hard = findings.filter((f) => !f.advisory);
157
+ const lines = [
158
+ `### ![SPARDA](${badge}) &nbsp; behavior proof`,
159
+ '',
160
+ `\`${report.routes} routes · ${verdict.guards} guards (${verdict.guardsVerified} verified) · coverage ${cov}%\``,
161
+ ];
162
+ if (hard.length) {
163
+ lines.push('', '| severity | route | finding |', '|---|---|---|');
164
+ for (const f of hard.slice(0, 8))
165
+ lines.push(
166
+ `| ${f.severity} | \`${(f.entrypoint ?? '').replace(/^entrypoint:/, '')}\` | ${labelOf(f.rule)} |`,
167
+ );
168
+ if (hard.length > 8) lines.push(`| … | | and ${hard.length - 8} more |`);
169
+ } else {
170
+ lines.push('', '✅ 0 violations on the resolved surface.');
171
+ }
172
+ lines.push(
173
+ '',
174
+ `<sub>Proven locally by [SPARDA](https://github.com/zyx77550/sparda) — deterministic, no keys · seal \`${seal}\`</sub>`,
175
+ );
176
+ return lines.join('\n');
177
+ }
178
+
179
+ const sev = (s) => ({ critical: '✗', high: '✗', medium: '⚠', info: '·' })[s] ?? '·';
180
+
181
+ // human labels for the rule ids, so the seal reads without a decoder ring
182
+ function labelOf(rule) {
183
+ return (
184
+ {
185
+ UNGUARDED_MUTATION: 'unguarded mutation',
186
+ UNBOUNDED_WRITE_TARGET: 'writes to a request-named table',
187
+ UNVALIDATED_CONSTRAINED_WRITE: 'unvalidated write to a constrained table',
188
+ NON_ATOMIC_AGGREGATE_WRITE: 'non-atomic aggregate write',
189
+ IRREVERSIBLE_OBSERVABLE: 'irreversible external call + state change',
190
+ AGGREGATE_MEMBER_BYPASS: 'aggregate member touched without its root',
191
+ }[rule] ?? rule.toLowerCase().replace(/_/g, ' ')
192
+ );
193
+ }
@@ -19,6 +19,7 @@ import { execFileSync } from 'node:child_process';
19
19
  import { compileUBG } from '../ubg/compile.js';
20
20
  import { canonicalizeGraph, cmp } from '../ubg/schema.js';
21
21
  import { checkGraph, diffGraphs, verdictOf } from '../ubg/apocalypse.js';
22
+ import { surveyBlindspots } from '../ubg/blindspots.js';
22
23
 
23
24
  const ICONS = { critical: '✗', high: '✗', medium: '⚠', info: '·' };
24
25
  const err = (message, hint) => Object.assign(new Error(message), { code: 'USER', hint });
@@ -58,11 +59,22 @@ export function reviewGraphs(baseGraph, candidateGraph) {
58
59
  const added = [...candEps].filter((e) => !baseEps.has(e)).sort(cmp);
59
60
  const removedEps = [...baseEps].filter((e) => !candEps.has(e)).sort(cmp);
60
61
 
62
+ // how much of the changed app SPARDA could actually see — and whether this PR moved
63
+ // that boundary. A green review over a graph the PR made 20% blinder is not the same
64
+ // as a green review at full sight; the reviewer must be able to tell them apart.
65
+ const candCov = surveyBlindspots(candidateGraph).coverage.ratio;
66
+ const baseCov = surveyBlindspots(baseGraph).coverage.ratio;
67
+
61
68
  return {
62
- verdict: verdictOf(findings, candidateGraph),
69
+ verdict: verdictOf(findings, candidateGraph, { coverage: candCov }),
63
70
  obligations,
64
71
  findings,
65
72
  endpoints: { added, removed: removedEps },
73
+ coverage: {
74
+ now: candCov,
75
+ base: baseCov,
76
+ delta: Math.round((candCov - baseCov) * 1000) / 1000,
77
+ },
66
78
  };
67
79
  }
68
80
 
@@ -160,6 +172,18 @@ function renderHuman(r) {
160
172
  console.log(` - removed endpoints: ${endpoints.removed.map(shortEp).join(', ')}`);
161
173
  if (!endpoints.added.length && !endpoints.removed.length)
162
174
  console.log(' · no endpoints added or removed');
175
+ if (r.coverage) {
176
+ const d = r.coverage.delta;
177
+ const move =
178
+ d > 0.005
179
+ ? ` (▲ +${(d * 100).toFixed(0)}%)`
180
+ : d < -0.005
181
+ ? ` (▼ ${(d * 100).toFixed(0)}% — harder to see)`
182
+ : '';
183
+ console.log(
184
+ ` · coverage: ${(r.coverage.now * 100).toFixed(0)}% of observed behavior resolved${move}`,
185
+ );
186
+ }
163
187
 
164
188
  if (findings.length) {
165
189
  console.log('\n Behavior risks changed by this diff:');
@@ -191,6 +215,17 @@ function renderMarkdown(r) {
191
215
  ? '✓ **PROVEN** — no protection removed, no new risk introduced.'
192
216
  : `${v.safe ? '⚠ **RISKY**' : '✗ **NOT PROVEN**'} — ${c.critical} critical, ${c.high} high, ${c.medium} medium, ${c.info} info.`;
193
217
  const lines = [`## 🔍 SPARDA semantic review (vs \`${r.base}\`)`, '', head, ''];
218
+ if (r.coverage) {
219
+ const pct = (r.coverage.now * 100).toFixed(0);
220
+ const d = r.coverage.delta;
221
+ const move =
222
+ d > 0.005
223
+ ? ` (▲ +${(d * 100).toFixed(0)}% vs base)`
224
+ : d < -0.005
225
+ ? ` (▼ ${(d * 100).toFixed(0)}% vs base — this PR made the app harder to see)`
226
+ : '';
227
+ lines.push(`**Coverage:** ${pct}% of observed behavior resolved${move}`, '');
228
+ }
194
229
  if (endpoints.added.length || endpoints.removed.length) {
195
230
  lines.push('### Endpoint surface');
196
231
  for (const e of endpoints.added) lines.push(`- 🆕 \`${shortEp(e)}\``);