sparda-mcp 0.67.0 → 0.69.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
@@ -21,20 +21,40 @@
21
21
  ![runtime deps](https://img.shields.io/badge/runtime%20deps-4%20pinned-4c1)
22
22
  [![license](https://img.shields.io/badge/license-BUSL--1.1-blue)](./LICENSE)
23
23
 
24
- 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
+ 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. And when it can prove it was not even looking at your whole app, it says **PREMISE NOT VERIFIED** and claims nothing at all.
25
25
 
26
26
  ## 60-second proof
27
27
 
28
28
  From your Express, FastAPI, Flask, Next.js, NestJS or Medusa app — nothing to configure:
29
29
 
30
30
  ```bash
31
- npx sparda-mcp apocalypse # prove the tree is safe to deploy — exit 1 on any real risk
31
+ npx sparda-mcp apocalypse # prove the tree is safe to deploy — exit 1 on any real risk, or on an unverified premise
32
32
  npx sparda-mcp prove # the whole verdict: proof + coverage + shareable seal
33
33
  npx sparda-mcp badge # a README badge: proven · coverage% · routes
34
34
  ```
35
35
 
36
36
  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.
37
37
 
38
+ ## The wedge — catch an AI edit that removes a guard, in the loop
39
+
40
+ The one thing a text-diff review and a pattern scanner structurally can't do: prove that **this specific edit** dropped a protection the previous version had. `sparda gate` diffs the behavior graph before/after an edit and blocks a regression — deterministic, offline, sub-second, exit 2 (the Claude Code `PostToolUse` contract that stops the agent's edit loop). See it end-to-end in one command, zero setup:
41
+
42
+ ```bash
43
+ npm run wedge # (from a clone) — or drive it on your own app with `sparda gate --arm` then `sparda gate --hook`
44
+ ```
45
+
46
+ ```
47
+ 1. baseline armed on the guarded code (POST /admin/delete-user · requireAdmin)
48
+ 2. an AI edit "simplifies" requireAdmin → a pass-through (still compiles, still 200s)
49
+ 3. sparda gate on the edit:
50
+ ✗ [critical] GUARD_REMOVED — POST /admin/delete-user was guarded in the baseline
51
+ and is now reachable without any guard (src/app.js:11)
52
+ ⏱ ~40 ms · deterministic · offline · no API key
53
+ ⛔ exit 2 on --hook — Claude Code PostToolUse blocks the edit
54
+ ```
55
+
56
+ **Wire it into Claude Code in one line** — the [plugin](integrations/claude-code-plugin) registers a `PostToolUse` hook that runs `npx -y sparda-mcp gate --hook` after every `Edit`/`Write`, so a guard-removing edit is caught before it lands.
57
+
38
58
  > [!IMPORTANT]
39
59
  > **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:
40
60
  >
@@ -48,7 +68,7 @@ Under the hood it compiles your backend into one language-agnostic graph — the
48
68
 
49
69
  | Command | What it does |
50
70
  | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
51
- | **`prove`** | _The whole trust verdict in one gesture_ — proof + coverage + a shareable seal (`--json` / `--markdown`) |
71
+ | **`prove`** | _The whole trust verdict in one gesture_ — proof + coverage + premise check + a shareable seal (`--json` / `--markdown`) |
52
72
  | **`apocalypse`** | _Prove the deploy_ — no guard, invariant, transaction or aggregate boundary can be broken (SARIF + CI gate) |
53
73
  | **`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. |
54
74
  | **`badge`** | _The shareable artifact_ — a self-contained SVG badge + README snippet (verdict · coverage · routes) |
@@ -143,6 +163,8 @@ This command reads the compiled `.sparda/ubg.json` (with zero source code parsin
143
163
  - **Non-Atomic Aggregate Write (High)**: Flags when an API writes to multiple tables of the same Consistency Domain (Aggregate) outside a single transaction scope.
144
164
  - **Unvalidated Constrained Write (Medium)**: Flags writes into columns with declared invariants (CHECK, NOT NULL, UNIQUE — parsed from your `.sql` DDL **or `schema.prisma`**, Prisma enums included) without prior validation (Zod/Pydantic).
145
165
  - **Irreversible Observable Effect (High)**: Flags out-of-process actions (like Stripe charges) that happen alongside state writes without a structural compensation path (like a catch-refund).
166
+ - **Taint Flow Analysis (High)**: Tracks untrusted input variables through the AST to ensure they do not corrupt critical sinks.
167
+ - **Guard Dominance (Medium)**: Proves that top-level security guards cannot be bypassed by nested or overlapping sibling routes.
146
168
  - **Aggregate Member Bypass (Info)**: Flags mutating a member table directly without routing through the aggregate root.
147
169
 
148
170
  To save your current graph as a safe baseline:
@@ -341,7 +363,8 @@ runtime, so the guidance never goes stale.
341
363
  ## Supported frameworks
342
364
 
343
365
  - **Next.js App Router (13/14/15)** — file-based injection. SPARDA creates a catch-all route handler. It natively resolves wrapped handlers (`export const POST = withAuth(h)`) and deep effect chains.
344
- - **NestJS** — AST-based router injection. Deeply resolves Multi-hop Dependency Injection (Controller → Service → Repository), inherited DI, and `baseUrl`/`paths` imports. Resolves ORM writes: Prisma, Kysely, and TypeORM injected repositories (`@InjectRepository(Entity)` → `this.repo.save()`).
366
+ - **NestJS** — AST-based router injection. Deeply resolves Multi-hop Dependency Injection (Controller → Service → Repository), inherited DI, and `baseUrl`/`paths` imports. Fully supports composite decorators (`applyDecorators`). Resolves ORM writes: Prisma, Kysely, and TypeORM injected repositories (`@InjectRepository(Entity)` → `this.repo.save()`).
367
+ - **Strapi** — Native AST ingestion of Strapi content-types, core controllers, and custom routes.
345
368
  - **Express 4/5** (JS/TS, ESM/CJS) — AST-based router injection. Deeply resolves external controllers, Mongoose schemas, barrel re-exports, and inline handlers. Uses dynamic tree-scanning to find non-standard entry points (`bootstrap.ts`, etc).
346
369
  - **MedusaJS** — Native AST ingestion of complex e-commerce routing.
347
370
  - **Any Backend On Earth (Go, Java, Rails, Laravel)** — Compiles flawlessly from OpenAPI 3.x specs.
@@ -1 +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}
1
+ {"v":"imm1","proven":false,"surfaceOnly":true,"coverage":null,"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.67.0",
3
+ "version": "0.69.0",
4
4
  "mcpName": "io.github.zyx77550/sparda-mcp",
5
5
  "description": "AI writes. SPARDA proves. A deterministic, offline gate that catches when an AI edit removes a guard, exposes a route, or breaks an invariant — no API key, right in the agent edit loop.",
6
6
  "type": "module",
@@ -28,7 +28,8 @@
28
28
  "format": "prettier --write \"**/*.{js,cjs,mjs}\"",
29
29
  "format:check": "prettier --check \"**/*.{js,cjs,mjs}\"",
30
30
  "bench:check": "node bench/check-readme.mjs",
31
- "mutation": "node tests/mutation/run.mjs"
31
+ "mutation": "node tests/mutation/run.mjs",
32
+ "wedge": "node bench/wedge.mjs"
32
33
  },
33
34
  "files": [
34
35
  "src",
@@ -18,7 +18,8 @@ import {
18
18
  verdictState,
19
19
  buildProofObjects,
20
20
  } from '../ubg/apocalypse.js';
21
- import { surveyBlindspots } from '../ubg/blindspots.js';
21
+ import { surveyBlindspots, coveragePct } from '../ubg/blindspots.js';
22
+ import { premiseFor, withPremiseGaps } from '../ubg/premise.js';
22
23
  import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
23
24
 
24
25
  // version travels with the proof so an audit knows which prover produced it
@@ -69,13 +70,22 @@ export async function runApocalypse(opts) {
69
70
  }
70
71
 
71
72
  const findings = [...staticFindings, ...diffFindings];
73
+ // THE PREMISE, before the proof. This is the CI gate — the command whose exit code
74
+ // decides whether a tree ships — so it is the last place that may certify an app whose
75
+ // route table nobody checked. `premiseFor` keeps the opt-in boundary: the runtime
76
+ // oracle needs `--probe`, the boot-free convention oracle always runs.
77
+ const premise = await premiseFor(canonical, report, {
78
+ cwd: opts.cwd,
79
+ probe: opts.probe,
80
+ });
72
81
  // the honesty companion: where does the proof stop? (see `sparda blindspots`)
73
- const blind = surveyBlindspots(canonical, report);
82
+ const blind = surveyBlindspots(canonical, withPremiseGaps(report, premise));
74
83
  // coverage feeds the verdict: a clean app that resolved almost nothing is SURFACE, not PROVEN;
75
84
  // and any high-risk blind spot pulls a bare PROVEN down to PARTIAL (E-047, the giant-test rung)
76
85
  const verdict = verdictOf(findings, canonical, {
77
86
  coverage: blind.coverage.ratio,
78
87
  blindHigh: blind.byRisk.critical + blind.byRisk.high,
88
+ premiseGaps: premise.available ? premise.gaps.length : 0,
79
89
  });
80
90
 
81
91
  if (opts.sarif) {
@@ -136,6 +146,21 @@ export async function runApocalypse(opts) {
136
146
  if (opts.verbose && report.skipped?.length)
137
147
  for (const s of report.skipped)
138
148
  console.log(` skipped: ${s.reason}${s.file ? ` (${s.file})` : ''}`);
149
+ } else if (verdict.premiseUnverified) {
150
+ // The one negative backed by a source outside SPARDA. It is stated BEFORE the
151
+ // clean/risky branches on purpose: findings about a subject we did not have are
152
+ // not the headline, the missing subject is.
153
+ const witness =
154
+ premise.oracle === 'convention'
155
+ ? "the framework's own file conventions"
156
+ : 'the running app';
157
+ console.log(
158
+ `✗ PREMISE NOT VERIFIED — ${premise.gaps.length} route(s) ${witness} serve were NEVER seen by the compiler. Nothing below is a claim about this tree: the proof's subject is incomplete, so no verdict is issued.`,
159
+ );
160
+ for (const g of premise.gaps.slice(0, 8))
161
+ console.log(` ✗ ${g.method} ${g.path}`);
162
+ if (premise.gaps.length > 8)
163
+ console.log(` … and ${premise.gaps.length - 8} more`);
139
164
  } else if (verdict.clean) {
140
165
  console.log(
141
166
  `✓ PROVEN — ${obligations} obligation(s) discharged, zero violations. No declared guard, invariant, transaction or aggregate boundary can be broken by this tree.`,
@@ -148,16 +173,22 @@ export async function runApocalypse(opts) {
148
173
  }
149
174
  // Honesty companion: where the proof stops. A green verdict over a graph riddled
150
175
  // with blind spots is not omniscience — say so, on the same screen as the verdict.
176
+ if (premise.available)
177
+ console.log(
178
+ premise.oracle === 'convention'
179
+ ? ` ⚖ premise checked against the framework's file conventions: ${premise.probed} route(s) implied, ${premise.gaps.length} gap(s)`
180
+ : ` ⚖ premise checked against the running app: ${premise.probed} route(s) probed, ${premise.gaps.length} gap(s)`,
181
+ );
151
182
  if (blind.surface > 0) {
152
183
  const hi = blind.byRisk.critical + blind.byRisk.high;
153
184
  console.log(
154
- ` ◐ blind spots: ${blind.surface} (${hi} high+, ${blind.byRisk.medium} medium, ${blind.byRisk.low} low) · coverage ${(blind.coverage.ratio * 100).toFixed(0)}% — run \`sparda blindspots\` for the map`,
185
+ ` ◐ blind spots: ${blind.surface} (${hi} high+, ${blind.byRisk.medium} medium, ${blind.byRisk.low} low) · coverage ${coveragePct(blind.coverage.ratio)} — run \`sparda blindspots\` for the map`,
155
186
  );
156
187
  }
157
188
  }
158
189
 
159
190
  if (!verdict.safe) process.exitCode = 1; // CI gates on this
160
- return { verdict, findings, obligations, blindspots: blind };
191
+ return { verdict, findings, obligations, blindspots: blind, premise };
161
192
  }
162
193
 
163
194
  // SARIF 2.1.0 — GitHub code scanning eats this directly
@@ -9,18 +9,29 @@ import path from 'node:path';
9
9
  import { compileUBG } from '../ubg/compile.js';
10
10
  import { canonicalizeGraph } from '../ubg/schema.js';
11
11
  import { checkGraph, verdictOf, badgeFor } from '../ubg/apocalypse.js';
12
- import { surveyBlindspots } from '../ubg/blindspots.js';
12
+ import { surveyBlindspots, coveragePct } from '../ubg/blindspots.js';
13
+ import { premiseFor, withPremiseGaps } from '../ubg/premise.js';
13
14
 
14
15
  export async function runBadge(opts) {
15
16
  const { graph, report } = compileUBG(opts.cwd, { write: false });
16
17
  const canonical = canonicalizeGraph(graph);
17
18
  const { findings } = checkGraph(canonical);
18
- const blind = surveyBlindspots(canonical, report);
19
+ // A badge is the artifact that leaves the repo. It may not read green over an app
20
+ // whose route table was never checked — that is the one place a false claim travels
21
+ // furthest and is hardest to retract.
22
+ const premise = await premiseFor(canonical, report, {
23
+ cwd: opts.cwd,
24
+ probe: opts.probe,
25
+ });
26
+ const blind = surveyBlindspots(canonical, withPremiseGaps(report, premise));
19
27
  const verdict = verdictOf(findings, canonical, {
20
28
  coverage: blind.coverage.ratio,
21
29
  blindHigh: blind.byRisk.critical + blind.byRisk.high,
30
+ premiseGaps: premise.available ? premise.gaps.length : 0,
22
31
  });
23
- const cov = Math.round(blind.coverage.ratio * 100);
32
+ // null = measured-but-unknown (0/0): the JSON keeps the null, the console says the word
33
+ const cov =
34
+ blind.coverage.ratio == null ? null : Math.round(blind.coverage.ratio * 100);
24
35
  const { state, message, color } = badgeFor(verdict, { coverage: blind.coverage.ratio });
25
36
 
26
37
  const svg = renderBadge('SPARDA', message, color);
@@ -52,7 +63,7 @@ export async function runBadge(opts) {
52
63
  const shields = `https://img.shields.io/badge/SPARDA-${encodeURIComponent(message)}-${color.slice(1)}`;
53
64
 
54
65
  console.log(
55
- `\n✓ Badge written: ${relSvg} (${state} · ${report.routes} routes · ${cov}% coverage)`,
66
+ `\n✓ Badge written: ${relSvg} (${state} · ${report.routes} routes · ${coveragePct(blind.coverage.ratio)} coverage)`,
56
67
  );
57
68
  console.log(`\nPaste into your README:\n`);
58
69
  console.log(` ![SPARDA](./${relSvg})`);
@@ -10,7 +10,7 @@
10
10
  // sparda blindspots --json the raw survey for tooling
11
11
  import { compileUBG } from '../ubg/compile.js';
12
12
  import { canonicalizeGraph } from '../ubg/schema.js';
13
- import { surveyBlindspots } from '../ubg/blindspots.js';
13
+ import { surveyBlindspots, coveragePct } from '../ubg/blindspots.js';
14
14
 
15
15
  const ICON = { critical: '✗', high: '✗', medium: '⚠', low: '·' };
16
16
 
@@ -30,7 +30,7 @@ export async function runBlindspots(opts) {
30
30
  ` ${surface} blind spot(s): ${byRisk.high + byRisk.critical} high+, ${byRisk.medium} medium, ${byRisk.low} low`,
31
31
  );
32
32
  console.log(
33
- ` coverage ${(coverage.ratio * 100).toFixed(1)}% — ${coverage.resolved} behaviors resolved, ${coverage.blind} left unseen`,
33
+ ` coverage ${coveragePct(coverage.ratio, 1)} — ${coverage.resolved} behaviors resolved, ${coverage.blind} left unseen`,
34
34
  );
35
35
  if (surface === 0) {
36
36
  console.log(
@@ -0,0 +1,105 @@
1
+ // commands/claude-hook.js — one-command install of the SPARDA gate into Claude Code's
2
+ // PostToolUse hook, so every AI edit is proven in the loop with zero manual wiring. This is
3
+ // the DX that makes the gate irreproachable: the agent edits, the gate fires, a regression
4
+ // comes back on stderr with a fix (see gate.js). Writes to the PROJECT's .claude/settings.json
5
+ // so the whole team inherits it via the repo.
6
+ //
7
+ // Two hard constraints, both honored by the pure functions below:
8
+ // • idempotent — installing twice never duplicates the hook;
9
+ // • cleanly removable (hard rule #4) — uninstall strips EXACTLY our entry and prunes the
10
+ // containers it emptied, leaving any other hooks/settings byte-for-byte untouched.
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
14
+
15
+ // the exact command the hook runs; also the marker that identifies OUR entry on removal.
16
+ export const GATE_HOOK_CMD = 'npx --no-install sparda-mcp gate --hook';
17
+ const MATCHER = 'Edit|Write';
18
+
19
+ const isOurHook = (h) => h?.type === 'command' && h?.command === GATE_HOOK_CMD;
20
+
21
+ // Ensure our PostToolUse hook is present. Pure: takes a settings object, returns
22
+ // { settings, changed }. Idempotent — if any PostToolUse block already runs our command,
23
+ // nothing changes. Never touches unrelated hooks or matchers.
24
+ export function applyClaudeHook(settings) {
25
+ const next = structuredClone(settings ?? {});
26
+ next.hooks ??= {};
27
+ const post = Array.isArray(next.hooks.PostToolUse) ? next.hooks.PostToolUse : [];
28
+ const already = post.some((block) => (block?.hooks ?? []).some(isOurHook));
29
+ if (already) return { settings: next, changed: false };
30
+ post.push({ matcher: MATCHER, hooks: [{ type: 'command', command: GATE_HOOK_CMD }] });
31
+ next.hooks.PostToolUse = post;
32
+ return { settings: next, changed: true };
33
+ }
34
+
35
+ // Strip EXACTLY our hook and prune what that emptied: our command out of each block's hooks,
36
+ // then blocks left with no hooks, then PostToolUse if empty, then hooks if empty. Any other
37
+ // hook a user added is preserved. Pure: returns { settings, changed }.
38
+ export function removeClaudeHook(settings) {
39
+ const next = structuredClone(settings ?? {});
40
+ const post = next.hooks?.PostToolUse;
41
+ if (!Array.isArray(post)) return { settings: next, changed: false };
42
+
43
+ let changed = false;
44
+ const kept = [];
45
+ for (const block of post) {
46
+ const hooks = block?.hooks ?? [];
47
+ const remaining = hooks.filter((h) => !isOurHook(h));
48
+ if (remaining.length !== hooks.length) changed = true;
49
+ if (remaining.length) kept.push({ ...block, hooks: remaining });
50
+ // a block whose only hook was ours is dropped entirely (never leave a matcher orphaned)
51
+ }
52
+ if (!changed) return { settings: next, changed: false };
53
+
54
+ if (kept.length) next.hooks.PostToolUse = kept;
55
+ else delete next.hooks.PostToolUse;
56
+ if (next.hooks && Object.keys(next.hooks).length === 0) delete next.hooks;
57
+ return { settings: next, changed: true };
58
+ }
59
+
60
+ function settingsPath(cwd) {
61
+ return path.join(cwd, '.claude', 'settings.json');
62
+ }
63
+
64
+ // read .claude/settings.json into an object. `{}` when absent; throws a USER error (never a
65
+ // silent overwrite) when present-but-unparseable — we will not clobber a file we can't read.
66
+ function readSettings(file) {
67
+ if (!fs.existsSync(file)) return {};
68
+ const raw = fs.readFileSync(file, 'utf8');
69
+ try {
70
+ return JSON.parse(raw);
71
+ } catch {
72
+ throw Object.assign(
73
+ new Error(`${file} is not valid JSON — refusing to overwrite it.`),
74
+ { code: 'USER', hint: 'Fix the JSON by hand, then re-run.' },
75
+ );
76
+ }
77
+ }
78
+
79
+ export function installClaudeHook(cwd) {
80
+ const file = settingsPath(cwd);
81
+ const { settings, changed } = applyClaudeHook(readSettings(file));
82
+ if (!changed) return { changed: false, file };
83
+ fs.mkdirSync(path.dirname(file), { recursive: true });
84
+ atomicWrite(file, JSON.stringify(settings, null, 2) + '\n');
85
+ return { changed: true, file };
86
+ }
87
+
88
+ // Uninstall for the CLI and for `sparda remove` (rule #4). Returns whether anything was
89
+ // removed. Never errors when the file/hook is absent — removal is best-effort cleanup.
90
+ export function uninstallClaudeHook(cwd) {
91
+ const file = settingsPath(cwd);
92
+ if (!fs.existsSync(file)) return { changed: false, file };
93
+ let current;
94
+ try {
95
+ current = JSON.parse(fs.readFileSync(file, 'utf8'));
96
+ } catch {
97
+ return { changed: false, file }; // unreadable — leave it exactly as-is
98
+ }
99
+ const { settings, changed } = removeClaudeHook(current);
100
+ if (!changed) return { changed: false, file };
101
+ // if we emptied the file to `{}` AND it holds nothing else, remove it; else write it back
102
+ if (Object.keys(settings).length === 0) fs.rmSync(file);
103
+ else atomicWrite(file, JSON.stringify(settings, null, 2) + '\n');
104
+ return { changed: true, file };
105
+ }
@@ -10,7 +10,8 @@ import path from 'node:path';
10
10
  import { compileUBG } from '../ubg/compile.js';
11
11
  import { canonicalizeGraph } from '../ubg/schema.js';
12
12
  import { checkGraph, verdictOf, verdictState } from '../ubg/apocalypse.js';
13
- import { surveyBlindspots } from '../ubg/blindspots.js';
13
+ import { premiseFor, withPremiseGaps } from '../ubg/premise.js';
14
+ import { surveyBlindspots, coveragePct } from '../ubg/blindspots.js';
14
15
  import { buildCapsule } from '../ubg/immunity.js';
15
16
  import { AXES, POLARITY_SYMBOL, exposedAxes } from '../ubg/polarity.js';
16
17
  import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
@@ -20,10 +21,20 @@ export async function runDossier(opts) {
20
21
  const canonical = canonicalizeGraph(compiled.graph);
21
22
  const { findings, polarity } = checkGraph(canonical);
22
23
  const capsule = buildCapsule(canonical);
23
- const blindspots = surveyBlindspots(canonical, compiled.report);
24
+ // the public report — same rule as the badge: it must not describe an app SPARDA
25
+ // never fully had
26
+ const premise = await premiseFor(canonical, compiled.report, {
27
+ cwd: opts.cwd,
28
+ probe: opts.probe,
29
+ });
30
+ const blindspots = surveyBlindspots(
31
+ canonical,
32
+ withPremiseGaps(compiled.report, premise),
33
+ );
24
34
  const verdict = verdictOf(findings, canonical, {
25
35
  coverage: blindspots.coverage.ratio,
26
36
  blindHigh: blindspots.byRisk.critical + blindspots.byRisk.high,
37
+ premiseGaps: premise.available ? premise.gaps.length : 0,
27
38
  });
28
39
 
29
40
  const data = {
@@ -36,7 +47,10 @@ export async function runDossier(opts) {
36
47
  provable: verdict.provable,
37
48
  proven: verdict.provable && verdict.clean,
38
49
  state: verdictState(verdict),
39
- coverage: Math.round(blindspots.coverage.ratio * 100),
50
+ coverage:
51
+ blindspots.coverage.ratio == null
52
+ ? null // 0/0 — a measurement that does not exist, never "100"
53
+ : Math.round(blindspots.coverage.ratio * 100),
40
54
  surfaceOnly: verdict.surfaceOnly,
41
55
  guards: verdict.guards,
42
56
  guardsVerified: verdict.guardsVerified,
@@ -241,12 +255,12 @@ export function renderDossierHTML(d) {
241
255
  <section class="hero">
242
256
  <div class="verdict-row">
243
257
  <span class="stamp ${verdictClass}"><span class="dot"></span>${esc(verdictText)}</span>
244
- <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>
258
+ <span class="cov" title="share of the app's behavior SPARDA resolved and reasoned about">coverage&nbsp;<b>${b.coverage ? coveragePct(b.coverage.ratio) : `${d.coverage ?? 0}%`}</b></span>
245
259
  </div>
246
260
  <p>${esc(verdictLede)}</p>
247
261
  <div class="stats">
248
262
  ${statCard(d.routes, 'routes')}
249
- ${statCard((b.coverage.ratio * 100).toFixed(0) + '%', 'behavior resolved')}
263
+ ${statCard(coveragePct(b.coverage.ratio), 'behavior resolved')}
250
264
  ${statCard(d.nodes, 'graph nodes')}
251
265
  ${statCard(d.tables, 'data tables')}
252
266
  ${statCard(esc(d.framework), 'framework')}
@@ -266,7 +280,7 @@ export function renderDossierHTML(d) {
266
280
  <h2>What SPARDA found</h2>
267
281
  ${findingCards}
268
282
 
269
- <h2>Where the proof stops <small>SPARDA's own blind spots · coverage ${(b.coverage.ratio * 100).toFixed(0)}% · ${b.surface} unseen, ranked by what each could hide</small></h2>
283
+ <h2>Where the proof stops <small>SPARDA's own blind spots · coverage ${coveragePct(b.coverage.ratio)} · ${b.surface} unseen, ranked by what each could hide</small></h2>
270
284
  ${blindRows}
271
285
 
272
286
  <footer>