sparda-mcp 0.64.0 → 0.66.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/README.md CHANGED
@@ -6,11 +6,12 @@
6
6
 
7
7
  <br/>
8
8
 
9
- > 🇫🇷 **Français** : Pour comprendre SPARDA en 10 minutes (douleur, architecture, vision), lisez le document du fondateur : [SPARDA-EXPLIQUE.md](docs/SPARDA-EXPLIQUE.md).
9
+ > 🇫🇷 **Français** _L'IA écrit. SPARDA prouve._ Un gate déterministe et hors-ligne qui détecte quand une modif d'IA retire une garde, expose une route ou casse un invariant — sans clé API, directement dans la boucle d'édition de l'agent. Pour tout comprendre en 10 minutes (douleur, architecture, vision) : [SPARDA-EXPLIQUE.md](docs/SPARDA-EXPLIQUE.md).
10
10
 
11
11
  ---
12
12
 
13
13
  <h1 align="center">AI writes. SPARDA proves.</h1>
14
+ <p align="center"><em>L'IA écrit. SPARDA prouve.</em></p>
14
15
 
15
16
  **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
17
 
@@ -24,7 +25,7 @@
24
25
 
25
26
  ## 60-second proof
26
27
 
27
- From your Express, FastAPI, Next.js, NestJS or Medusa app — nothing to configure:
28
+ From your Express, FastAPI, Flask, Next.js, NestJS or Medusa app — nothing to configure:
28
29
 
29
30
  ```bash
30
31
  npx sparda-mcp apocalypse # prove the tree is safe to deploy — exit 1 on any real risk
@@ -52,7 +53,7 @@ Under the hood it compiles your backend into one language-agnostic graph — the
52
53
  | **`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. |
53
54
  | **`badge`** | _The shareable artifact_ — a self-contained SVG badge + README snippet (verdict · coverage · routes) |
54
55
  | **`dossier`** | _The public report_ — one self-contained HTML page: verdict, risks, and SPARDA's own blind spots |
55
- | **`ubg`** | Compile the codebase to its behavior graph (Express · FastAPI · Next.js · NestJS · Medusa natively; **any** stack via OpenAPI) |
56
+ | **`ubg`** | Compile the codebase to its behavior graph (Express · FastAPI · Flask · Next.js · NestJS · Medusa natively; **any** stack via OpenAPI) |
56
57
  | **`timeless`** | _Time-travel_ — record a production request, replay it byte-identically, export the bug as a test |
57
58
  | **`mirror`** | _Execute the graph_ — serve the compiled behavior over HTTP with no framework and no source |
58
59
  | **`init` / `dev`** | _Runtime, optional_ — expose the graph to AI clients as a live MCP server (+ Twin, Immune, Evolution) |
@@ -212,7 +213,7 @@ The gate is honest in both directions: an unfixed bug, or a "fix" that silently
212
213
 
213
214
  ## Any Backend On Earth: OpenAPI Lowering
214
215
 
215
- SPARDA parses Express, FastAPI and Next.js natively — and **every other stack through the format the industry already agreed on**. Go, Java, Rails, Laravel, .NET: if it has an OpenAPI spec, it compiles.
216
+ SPARDA parses Express, FastAPI, Flask and Next.js natively — and **every other stack through the format the industry already agreed on**. Go, Java, Rails, Laravel, .NET: if it has an OpenAPI spec, it compiles.
216
217
 
217
218
  ```bash
218
219
  npx sparda-mcp ubg --openapi openapi.json
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.64.0",
3
+ "version": "0.66.1",
4
4
  "mcpName": "io.github.zyx77550/sparda-mcp",
5
- "description": "Compile backends to behavior graphs. Statically prove security, simulate APIs, and replay bugs.",
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",
7
7
  "repository": {
8
8
  "type": "git",
@@ -13,7 +13,7 @@
13
13
  "url": "https://github.com/zyx77550/sparda/issues"
14
14
  },
15
15
  "bin": {
16
- "sparda": "./src/index.js"
16
+ "sparda": "src/index.js"
17
17
  },
18
18
  "scripts": {
19
19
  "test": "vitest run",
@@ -44,12 +44,19 @@
44
44
  },
45
45
  "keywords": [
46
46
  "mcp",
47
+ "mcp-server",
47
48
  "model-context-protocol",
48
49
  "claude",
49
- "ai",
50
- "express",
51
- "fastapi",
52
- "codegen"
50
+ "claude-code",
51
+ "ai-code-review",
52
+ "code-review",
53
+ "guardrails",
54
+ "static-analysis",
55
+ "security",
56
+ "authorization",
57
+ "bola",
58
+ "ai-agents",
59
+ "trust-layer"
53
60
  ],
54
61
  "author": "Residual Labs (residual-labs.fr)",
55
62
  "license": "BUSL-1.1",
@@ -8,12 +8,38 @@
8
8
  // sparda apocalypse --json raw findings for tooling
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
+ import crypto from 'node:crypto';
11
12
  import { compileUBG } from '../ubg/compile.js';
12
13
  import { canonicalizeGraph } from '../ubg/schema.js';
13
- import { checkGraph, diffGraphs, verdictOf } from '../ubg/apocalypse.js';
14
+ import {
15
+ checkGraph,
16
+ diffGraphs,
17
+ verdictOf,
18
+ verdictState,
19
+ buildProofObjects,
20
+ } from '../ubg/apocalypse.js';
14
21
  import { surveyBlindspots } from '../ubg/blindspots.js';
15
22
  import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
16
23
 
24
+ // version travels with the proof so an audit knows which prover produced it
25
+ const SPARDA_VERSION = (() => {
26
+ try {
27
+ return JSON.parse(
28
+ fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8'),
29
+ ).version;
30
+ } catch {
31
+ return '0.0.0';
32
+ }
33
+ })();
34
+
35
+ // a deterministic fingerprint of the canonical graph: same source → same graph → same hash.
36
+ // The proof object binds to it, so a third party proves it audited the SAME artifact.
37
+ function graphHash(canonical) {
38
+ const h = crypto.createHash('sha256');
39
+ h.update(JSON.stringify({ nodes: canonical.nodes, edges: canonical.edges }));
40
+ return 'bh1_' + h.digest('hex').slice(0, 16);
41
+ }
42
+
17
43
  const ICONS = { critical: '✗', high: '✗', medium: '⚠', info: '·' };
18
44
 
19
45
  export async function runApocalypse(opts) {
@@ -61,6 +87,21 @@ export async function runApocalypse(opts) {
61
87
  );
62
88
  }
63
89
 
90
+ if (opts.proof) {
91
+ const proofPath = path.join(opts.cwd, '.sparda', 'apocalypse.proof.json');
92
+ fs.mkdirSync(path.dirname(proofPath), { recursive: true });
93
+ const proof = {
94
+ sparda_version: SPARDA_VERSION,
95
+ graph_hash: graphHash(canonical),
96
+ verdict: verdictState(verdict),
97
+ discharged: buildProofObjects(canonical),
98
+ };
99
+ atomicWrite(proofPath, JSON.stringify(proof, null, 2) + '\n');
100
+ console.log(
101
+ `✓ Proof written: .sparda/apocalypse.proof.json (${proof.discharged.length} discharged obligation(s), re-verifiable against ${proof.graph_hash})`,
102
+ );
103
+ }
104
+
64
105
  if (opts.json) {
65
106
  console.log(
66
107
  JSON.stringify({ verdict, obligations, findings, blindspots: blind }, null, 2),
@@ -0,0 +1,188 @@
1
+ // commands/gate.js — the agent edit-loop gate: did THIS edit lose a protection
2
+ // or introduce a risk, relative to the armed baseline? Delta-only by design: an
3
+ // edit gate must never scream about pre-existing state (that noise is fatal to
4
+ // adoption — see docs/TWO-FALSE-POSITIVE-CLASSES-2026-07-19.md), so everything
5
+ // already true at arm time is silent and only the regression speaks.
6
+ //
7
+ // The load-bearing subtlety (E-GATE-1): a PostToolUse hook fires after EVERY
8
+ // edit, including the mid-sequence ones that leave a file temporarily
9
+ // unparseable. When a file fails to parse its routes vanish from the graph, so a
10
+ // naive diff reads that as ENTRYPOINT_REMOVED / GUARD_REMOVED and cries a false
11
+ // regression on the agent's own in-progress work. That false alarm, fired
12
+ // constantly, is what gets the gate uninstalled in five minutes. So: a regression
13
+ // whose baseline entrypoint lives in a now-unparseable file is NOT a regression —
14
+ // it is "unproven, come back when it parses". The gate ABSTAINS, never claims
15
+ // clean (it proved nothing) and never claims safe. An unparseable tree has no
16
+ // defined behavior to regress against — abstention is the sound call.
17
+ //
18
+ // Note on PostToolUse semantics: the edit has already landed, so exit 2 does not
19
+ // *prevent* it — it feeds the regression back to the agent as stderr, which the
20
+ // agent reads and corrects in the same session (self-heal in the loop). That is
21
+ // the honest claim: immediate feedback, not a hard block. (A hard pre-commit
22
+ // block is the pre-commit hook / CI Action's job — same command, `apocalypse`.)
23
+ // sparda gate report the edit's behavior delta vs baseline
24
+ // sparda gate --arm freeze the current graph as the accepted baseline
25
+ // sparda gate --hook Claude Code PostToolUse contract: silent when clean,
26
+ // report on stderr + exit 2 (feedback to the agent) on a
27
+ // real regression; abstains (exit 0) while a file is
28
+ // mid-edit; arms itself on first run — zero config.
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+ import { compileUBG } from '../ubg/compile.js';
32
+ import { canonicalizeGraph } from '../ubg/schema.js';
33
+ import { checkGraph, diffGraphs } from '../ubg/apocalypse.js';
34
+ import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
35
+
36
+ // severities that block the edit; medium/info regressions are reported, never fatal
37
+ const BLOCKING = new Set(['critical', 'high']);
38
+ const findingKey = (f) => `${f.rule}|${f.entrypoint}`;
39
+ // diff rules that mean "this route/protection disappeared" — the ones a vanished
40
+ // (unparseable) file can forge. Introduced-risk rules can never be forged by a
41
+ // missing file, so they are never suppressed.
42
+ const REMOVAL_RULES = new Set(['ENTRYPOINT_REMOVED', 'GUARD_REMOVED']);
43
+
44
+ // files the compiler could not read/parse this run — reason is prefixed by the
45
+ // module error (`parse error: …` / `unreadable: …`) in every extractor's skipped list.
46
+ function unparsedFiles(report) {
47
+ const out = new Set();
48
+ for (const s of report?.skipped ?? []) {
49
+ if (s.file && /(^|\b)(parse error|unreadable|Unexpected|SyntaxError)/i.test(s.reason))
50
+ out.add(s.file);
51
+ }
52
+ return out;
53
+ }
54
+
55
+ // The gate's whole judgment, pure over two canonical graphs (testable without a repo):
56
+ // diff regressions (guard/route/invariant lost, blast radius grown) + static findings
57
+ // the candidate has that the baseline did not — the same composition `review` proved
58
+ // out (ADR-030), pointed at the baseline instead of a git ref. `unparsed` (the set of
59
+ // files that failed to parse in the candidate) splits the diff removals into real
60
+ // regressions vs in-progress edits whose baseline route lives in a now-broken file.
61
+ export function gateDelta(baseline, candidate, unparsed = new Set()) {
62
+ const baseLoc = new Map((baseline.nodes ?? []).map((n) => [n.id, n.loc?.file]));
63
+ const diff = diffGraphs(baseline, candidate).findings;
64
+ const before = new Set(checkGraph(baseline).findings.map(findingKey));
65
+ const introduced = checkGraph(candidate).findings.filter(
66
+ (f) => !before.has(findingKey(f)),
67
+ );
68
+ const seen = new Set(diff.map(findingKey));
69
+ const all = [...diff, ...introduced.filter((f) => !seen.has(findingKey(f)))];
70
+
71
+ const findings = [];
72
+ const abstained = [];
73
+ for (const f of all) {
74
+ if (REMOVAL_RULES.has(f.rule) && unparsed.has(baseLoc.get(f.entrypoint)))
75
+ abstained.push(f);
76
+ else findings.push(f);
77
+ }
78
+ return {
79
+ findings,
80
+ blocking: findings.filter((f) => BLOCKING.has(f.severity)),
81
+ abstained,
82
+ };
83
+ }
84
+
85
+ function locOf(canonical, f) {
86
+ const loc = canonical.nodes.find((n) => n.id === f.entrypoint)?.loc;
87
+ return loc ? ` (${loc.file}:${loc.line || 1})` : '';
88
+ }
89
+
90
+ export async function runGate(opts) {
91
+ const t0 = process.hrtime.bigint();
92
+ const hook = opts.hook;
93
+ // in hook mode stdout belongs to nobody and stderr is what the agent reads
94
+ const say = hook ? (s) => console.error(s) : (s) => console.log(s);
95
+
96
+ const baselinePath = path.join(opts.cwd, '.sparda', 'ubg.baseline.json');
97
+
98
+ // A tree that does not compile at all (broken entry / detection throw) has no
99
+ // behavior to judge. Abstain instead of crashing the hook — the agent is mid-edit.
100
+ let graph, report;
101
+ try {
102
+ ({ graph, report } = compileUBG(opts.cwd, { write: false }));
103
+ } catch (err) {
104
+ if (opts.json)
105
+ console.log(JSON.stringify({ ok: true, abstained: err.message }, null, 2));
106
+ else if (!hook)
107
+ say(
108
+ `⏳ GATE ABSTAINED — the tree does not compile yet (${err.message}). Fix it, then re-check.`,
109
+ );
110
+ return { armed: false, findings: [], abstained: true };
111
+ }
112
+ const candidate = canonicalizeGraph(graph);
113
+ const unparsed = unparsedFiles(report);
114
+
115
+ const arm = () => {
116
+ fs.mkdirSync(path.dirname(baselinePath), { recursive: true });
117
+ atomicWrite(baselinePath, JSON.stringify(candidate, null, 2) + '\n');
118
+ };
119
+
120
+ if (opts.arm || opts.saveBaseline) {
121
+ arm();
122
+ say(
123
+ `✓ GATE ARMED — baseline frozen (.sparda/ubg.baseline.json). Every edit is now proven against it.`,
124
+ );
125
+ return { armed: true, findings: [] };
126
+ }
127
+
128
+ if (!fs.existsSync(baselinePath)) {
129
+ // first run arms instead of failing: a hook must work with zero configuration
130
+ arm();
131
+ say(
132
+ `✓ GATE ARMED (first run) — baseline frozen. Future edits are proven against it; re-arm with \`sparda gate --arm\` after intended changes.`,
133
+ );
134
+ return { armed: true, findings: [] };
135
+ }
136
+
137
+ const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
138
+ const { findings, blocking, abstained } = gateDelta(baseline, candidate, unparsed);
139
+ const ms = Number(process.hrtime.bigint() - t0) / 1e6;
140
+
141
+ if (opts.json) {
142
+ console.log(
143
+ JSON.stringify(
144
+ {
145
+ ok: blocking.length === 0,
146
+ ms: Math.round(ms),
147
+ findings,
148
+ abstained: abstained.map((f) => f.entrypoint),
149
+ },
150
+ null,
151
+ 2,
152
+ ),
153
+ );
154
+ if (blocking.length) process.exitCode = 1;
155
+ return { armed: false, findings };
156
+ }
157
+
158
+ // Abstention notice: a route vanished only because its file doesn't parse yet.
159
+ // Never blocking — this is the normal mid-edit state, not a regression.
160
+ if (abstained.length) {
161
+ const files = [...new Set([...unparsed])].join(', ');
162
+ say(
163
+ `⏳ GATE HELD — ${abstained.length} route(s) can't be proven while ${files} doesn't parse (mid-edit?). Not a regression; I'll re-check once it parses.`,
164
+ );
165
+ }
166
+
167
+ if (findings.length === 0) {
168
+ if (!hook && !abstained.length)
169
+ say(
170
+ `✓ GATE CLEAN — this edit lost no guard, dropped no route, grew no blast radius (proven vs baseline in ${Math.round(ms)} ms).`,
171
+ );
172
+ return { armed: false, findings };
173
+ }
174
+
175
+ say(
176
+ `✗ SPARDA GATE — this edit changed the app's proven behavior (${Math.round(ms)} ms, deterministic):`,
177
+ );
178
+ for (const f of findings)
179
+ say(` [${f.severity}] ${f.rule} — ${f.message}${locOf(candidate, f)}`);
180
+ say(
181
+ blocking.length
182
+ ? ` → fix the edit or, if intended, accept it with \`sparda gate --arm\`.`
183
+ : ` → advisory only (no critical/high regression) — review, then \`sparda gate --arm\` if intended.`,
184
+ );
185
+
186
+ if (blocking.length) process.exitCode = hook ? 2 : 1;
187
+ return { armed: false, findings };
188
+ }
package/src/detect.js CHANGED
@@ -92,7 +92,7 @@ export function detectStack(cwd) {
92
92
  const known = ['fastify', 'koa'].find((d) => deps[d]);
93
93
  if (known)
94
94
  throw err(
95
- `${known} detected — not supported yet. Express, NestJS & FastAPI in v0.`,
95
+ `${known} detected — not supported yet. Express, NestJS, FastAPI & Flask in v0.`,
96
96
  '+1 the framework vote: github.com/zyx77550/sparda/issues/1',
97
97
  );
98
98
  }
@@ -108,6 +108,18 @@ export function detectStack(cwd) {
108
108
  return { framework: 'fastapi', entryFile, port, pythonCmd };
109
109
  }
110
110
  }
111
+ // Flask — checked AFTER FastAPI (a FastAPI project may pull flask transitively; FastAPI
112
+ // wins). The same Python extractor handles it; only the entry-file marker differs.
113
+ for (const f of ['requirements.txt', 'pyproject.toml']) {
114
+ const p = path.join(cwd, f);
115
+ if (
116
+ fs.existsSync(p) &&
117
+ /(^|[^a-z])flask([^a-z]|$)/i.test(fs.readFileSync(p, 'utf8'))
118
+ ) {
119
+ const entryFile = findFlaskEntry(cwd);
120
+ return { framework: 'flask', entryFile, port: 5000, pythonCmd: detectPython() };
121
+ }
122
+ }
111
123
 
112
124
  // Last resort before giving up — monorepo app dirs whose framework config lives
113
125
  // ELSEWHERE than the pointed dir. Purely structural, so it never mis-fires on a
@@ -130,7 +142,7 @@ export function detectStack(cwd) {
130
142
 
131
143
  const suggestions = suggestAppDirs(cwd);
132
144
  throw err(
133
- 'No supported framework found (Express, Next.js, NestJS, Medusa, FastAPI).',
145
+ 'No supported framework found (Express, Next.js, NestJS, Medusa, FastAPI, Flask).',
134
146
  suggestions.length
135
147
  ? `This looks like a monorepo — the analyzable app is in a sub-directory. Try:\n` +
136
148
  suggestions
@@ -401,7 +413,28 @@ function findFastAPIEntry(cwd) {
401
413
  );
402
414
  }
403
415
 
404
- function searchPyFiles(dir, root, countRef = { val: 0 }) {
416
+ // Flask's entry is the file that calls `Flask(__name__)` — conventionally app.py /
417
+ // wsgi.py / run.py / main.py, else the first .py declaring it. Same shape as the
418
+ // FastAPI finder; the Python extractor handles both frameworks from that entry.
419
+ function findFlaskEntry(cwd) {
420
+ const candidates = ['app.py', 'wsgi.py', 'run.py', 'main.py', 'src/app.py'];
421
+ for (const rel of candidates) {
422
+ const abs = path.resolve(cwd, rel);
423
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
424
+ if (fs.readFileSync(abs, 'utf8').includes('Flask(')) {
425
+ return path.relative(cwd, abs).split(path.sep).join('/');
426
+ }
427
+ }
428
+ }
429
+ const entry = searchPyFiles(cwd, cwd, { val: 0 }, 'Flask(');
430
+ if (entry) return path.relative(cwd, entry).split(path.sep).join('/');
431
+ throw err(
432
+ 'Could not locate your Flask entry file (the one calling Flask()).',
433
+ 'Specify it manually, or make sure Flask() is declared in one of your python files.',
434
+ );
435
+ }
436
+
437
+ function searchPyFiles(dir, root, countRef = { val: 0 }, marker = 'FastAPI(') {
405
438
  const EXCLUDE = new Set([
406
439
  'node_modules',
407
440
  '.git',
@@ -434,7 +467,7 @@ function searchPyFiles(dir, root, countRef = { val: 0 }) {
434
467
  countRef.val++;
435
468
  if (countRef.val > 200) return null;
436
469
  const src = fs.readFileSync(abs, 'utf8');
437
- if (src.includes('FastAPI(')) {
470
+ if (src.includes(marker)) {
438
471
  return abs;
439
472
  }
440
473
  }
package/src/index.js CHANGED
@@ -18,6 +18,7 @@ const opts = {
18
18
  yes: flags.has('--yes') || flags.has('-y'),
19
19
  saveBaseline: flags.has('--save-baseline'),
20
20
  sarif: flags.has('--sarif'),
21
+ proof: flags.has('--proof'),
21
22
  verbose: flags.has('--verbose'),
22
23
  quiet: flags.has('--quiet'),
23
24
  probe: flags.has('--probe'),
@@ -27,6 +28,8 @@ const opts = {
27
28
  learn: flags.has('--learn'),
28
29
  germinate: flags.has('--germinate'),
29
30
  check: flags.has('--check'),
31
+ hook: flags.has('--hook'),
32
+ arm: flags.has('--arm'),
30
33
  markdown: flags.has('--markdown'),
31
34
  port: getOpt('port', null),
32
35
  out: getOpt('out', null),
@@ -45,7 +48,8 @@ const opts = {
45
48
  // reads opts.cwd, so `--dir <path>` works on all of them; only per-command extras are listed.
46
49
  const HELP = {
47
50
  prove: `sparda prove [--dir <path>] [--json | --markdown]\n The whole trust verdict: proof + coverage + a shareable seal.\n --markdown emit a sticky-PR-comment body (used by the GitHub Action).`,
48
- apocalypse: `sparda apocalypse [--dir <path>] [--sarif] [--save-baseline]\n Prove the deploy — exit 1 on any critical/high finding.\n --sarif also write .sparda/apocalypse.sarif for the Security tab.\n --save-baseline freeze this graph; later runs diff against it.`,
51
+ apocalypse: `sparda apocalypse [--dir <path>] [--sarif] [--proof] [--save-baseline]\n Prove the deploy — exit 1 on any critical/high finding.\n --sarif also write .sparda/apocalypse.sarif for the Security tab.\n --proof also write .sparda/apocalypse.proof.json — a re-verifiable\n discharge trace (deny_path per guarded mutation) a third party\n can audit against the graph hash without re-compiling.\n --save-baseline freeze this graph; later runs diff against it.`,
52
+ gate: `sparda gate [--dir <path>] [--arm] [--hook] [--json]\n The agent edit-loop gate: prove THIS edit lost no guard, dropped no route,\n grew no blast radius — delta vs the armed baseline only (pre-existing state\n never blocks an edit). Arms itself on first run.\n --arm accept the current graph as the new baseline.\n --hook Claude Code PostToolUse contract: silent when clean, report on\n stderr + exit 2 (blocking feedback to the agent) on regression.`,
49
53
  ubg: `sparda ubg [--dir <path>] [--json] [--out <file>] [--openapi <spec>]\n Compile the codebase to its Unified Behavior Graph (.sparda/ubg.json).`,
50
54
  stitch: `sparda stitch <dir1> <dir2> [...] [--json]\n Cross-service proof: join one service's outbound HTTP calls to another's routes,\n surface cross-service trust boundaries + BOLA no mono-repo tool can see.`,
51
55
  badge: `sparda badge [--dir <path>] [--out <file>] [--json]\n Emit a shareable SVG badge + README snippet (verdict · coverage · routes).`,
@@ -162,6 +166,11 @@ try {
162
166
  await runApocalypse(opts);
163
167
  break;
164
168
  }
169
+ case 'gate': {
170
+ const { runGate } = await import('./commands/gate.js');
171
+ await runGate(opts);
172
+ break;
173
+ }
165
174
  case 'review': {
166
175
  const { runReview } = await import('./commands/review.js');
167
176
  await runReview(opts);
@@ -234,6 +243,7 @@ try {
234
243
  PROVE — the point
235
244
  prove The whole trust verdict in one gesture: proof + coverage + seal (--json / --markdown / --openapi)
236
245
  apocalypse Prove the deploy: guards, invariants, transactions, aggregates (--save-baseline)
246
+ gate The agent edit-loop gate: prove an edit lost no protection (--arm / --hook)
237
247
  review Semantic diff of a PR vs a base ref (--base main / --json / --markdown)
238
248
  blindspots Map SPARDA's own blindness — every unseen route/effect/guard, ranked (--json)
239
249
  badge Emit a shareable SVG badge + README snippet (verdict · coverage · routes)