sparda-mcp 0.12.0 β†’ 0.13.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,6 +6,10 @@
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).
10
+
11
+ ---
12
+
9
13
  **Compile your backend into a deterministic behavior runtime.**
10
14
 
11
15
  For twenty years, software communicated via APIs. Then AI agents arrived. The industry's response was simply to expose more endpoints (MCP). But an agent doesn't understand your application; it only sees a list of disconnected tools. That is enough for a human, but not for an autonomous machine.
package/SKILL.md CHANGED
@@ -137,6 +137,10 @@ Writes are **disabled by default**. The protocol is not optional:
137
137
  manifest validity, the semantic/immune cache, host reachability, and quarantine;
138
138
  it exits non-zero so it can gate CI.
139
139
  - **Formal Deployment Proof** β†’ `sparda apocalypse` reads the compiled graph (`ubg.json`) and proves five correctness obligations: catches unguarded mutations, non-atomic aggregate writes, unvalidated writes to constrained tables, uncompensated observable effects, and aggregate root bypasses. Run `sparda apocalypse --save-baseline` to store the reference graph; subsequent runs diff against the baseline to catch dropped guards, dropped SQL invariants, or grown blast radiuses.
140
+ - **OpenAPI Ingestion** β†’ Run `sparda ubg --openapi <openapi_spec.json_or_yaml>` to compile any non-JS/Python backend (Go, Java, Rails, Laravel, .NET) into a SPARDA Behavior Graph by mapping security schemes into guards and request/response structures.
141
+ - **Executing the Graph (No code mock)** β†’ Run `sparda mirror` to host a mock HTTP simulation server directly from `ubg.json` without any backend code. Enforces authentication guards, returns typed responses, and acts as a contract sandbox.
142
+ - **Exporting OpenAPI 3.1 Spec** β†’ Run `sparda openapi` to generate a valid, deterministic OpenAPI 3.1 spec dynamically from the compiled behavior graph.
143
+ - **Self-Verification** β†’ Run `sparda verify` to test the compiler's own invariants (byte-determinism, soundness, and spec round-trip) to guarantee trust.
140
144
  - **Clone learning / Transfer sΓ©mantique** β†’ Use `sparda seed export` to package your app's descriptions, workflows, and antibodies. Then `sparda seed import --germinate` in another clone to import the structure and germinate simulated twin instances.
141
145
  - **Learn exemplars** β†’ Start your live app and run `sparda twin --learn` to fetch actual response data and construct `.sparda/twin.json` locally.
142
146
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "mcpName": "io.github.zyx77550/sparda-mcp",
5
- "description": "Turn any codebase into an MCP server with one command. Express & FastAPI β†’ Claude-ready in 3 minutes.",
5
+ "description": "Compile backends to behavior graphs. Statically prove security, simulate APIs, and replay bugs.",
6
6
  "type": "module",
7
7
  "repository": {
8
8
  "type": "git",
@@ -0,0 +1,182 @@
1
+ // commands/heal.js β€” the closed loop as one gesture.
2
+ // sparda heal <flightId> diagnose + write the fix brief
3
+ // sparda heal <flightId> --agent "claude -p" let an AI attempt the fix, then gate
4
+ // sparda heal <flightId> --check [--expect '{"status":200}'] gate a fix (human or AI)
5
+ // The gate is the product: lenient replay against the recorded taps must meet
6
+ // the EXPECTATION (not the recorded bug), compiler laws must hold (verify),
7
+ // and apocalypse must find no new critical/high and no removed guard. SPARDA
8
+ // orchestrates and proves β€” whoever writes the fix, the machine judges it.
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { spawnSync } from 'node:child_process';
12
+ import { loadFlight } from '../flight/format.js';
13
+ import { getFlightBox } from '../flight/box.js';
14
+ import { replayFlight } from '../flight/replayer.js';
15
+ import { evaluateHealing, buildBrief } from '../flight/heal.js';
16
+ import { compileUBG } from '../ubg/compile.js';
17
+ import { canonicalizeGraph } from '../ubg/schema.js';
18
+ import { checkGraph, diffGraphs, verdictOf } from '../ubg/apocalypse.js';
19
+ import { verifyProject } from '../ubg/verify.js';
20
+ import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
21
+
22
+ const err = (message, hint) => Object.assign(new Error(message), { code: 'USER', hint });
23
+
24
+ export async function runHeal(opts, args) {
25
+ const id = args.filter((a) => !a.startsWith('--'))[0];
26
+ if (!id) throw err('flight id required', 'run `sparda timeless` to list flights');
27
+ const flight = loadFlight(opts.cwd, id);
28
+ const healDir = path.join(opts.cwd, '.sparda', 'heal', id);
29
+ fs.mkdirSync(healDir, { recursive: true });
30
+
31
+ if (opts.check) return gate(opts, id, flight, healDir);
32
+
33
+ // ---- phase 1: diagnose + brief -----------------------------------------
34
+ const compiled = compileUBG(opts.cwd, { write: false });
35
+ const graph = canonicalizeGraph(compiled.graph);
36
+ const app = await loadAppFor(opts.cwd);
37
+ const box = getFlightBox();
38
+ box.arm();
39
+ let strict;
40
+ try {
41
+ strict = await replayFlight(app, flight, box);
42
+ } finally {
43
+ box.disarm();
44
+ }
45
+
46
+ // freeze the pre-fix graph: the gate diffs the fixed tree against THIS
47
+ atomicWrite(path.join(healDir, 'baseline.json'), JSON.stringify(graph, null, 2) + '\n');
48
+ const brief = buildBrief(flight, graph, strict, id);
49
+ const briefPath = path.join(healDir, 'BRIEF.md');
50
+ atomicWrite(briefPath, brief);
51
+
52
+ console.log(`HEAL β€” flight ${id}: ${flight.request.method} ${flight.request.url}`);
53
+ console.log(
54
+ strict.match
55
+ ? ' βœ“ bug reproduced byte-identically against current code'
56
+ : ' ⚠ current code already answers differently β€” the bug may be partially fixed',
57
+ );
58
+ console.log(` βœ“ brief written: .sparda/heal/${id}/BRIEF.md`);
59
+ console.log(` βœ“ pre-fix graph frozen: .sparda/heal/${id}/baseline.json`);
60
+
61
+ if (opts.agent) {
62
+ console.log(` β–Έ handing the brief to: ${opts.agent}`);
63
+ const run = spawnSync(opts.agent, {
64
+ input: brief,
65
+ shell: true,
66
+ cwd: opts.cwd,
67
+ encoding: 'utf8',
68
+ timeout: 600_000,
69
+ });
70
+ if (run.status !== 0)
71
+ throw err(`agent exited ${run.status}`, (run.stderr ?? '').slice(0, 200));
72
+ console.log(' βœ“ agent finished β€” gating the fix now');
73
+ return gate(opts, id, flight, healDir);
74
+ }
75
+
76
+ console.log('\nNext: apply the fix (yourself or an agent), then:');
77
+ console.log(` sparda heal ${id} --check --expect '{"status":200}'`);
78
+ return { brief: briefPath };
79
+ }
80
+
81
+ // ---- phase 2: the gate ----------------------------------------------------
82
+ async function gate(opts, id, flight, healDir) {
83
+ const expectation = opts.expect ? JSON.parse(opts.expect) : null;
84
+
85
+ // 1. lenient replay: recorded inputs, candidate code, expected outcome
86
+ const app = await loadAppFor(opts.cwd);
87
+ const box = getFlightBox();
88
+ box.arm();
89
+ let replay;
90
+ try {
91
+ replay = await replayFlight(app, flight, box, { lenient: true });
92
+ } finally {
93
+ box.disarm();
94
+ }
95
+ const healing = evaluateHealing(flight, replay, expectation);
96
+
97
+ // 2. compiler laws still hold on the fixed tree
98
+ const laws = verifyProject(opts.cwd);
99
+
100
+ // 3. apocalypse: no new critical/high, nothing protected got removed
101
+ const fixedGraph = canonicalizeGraph(compileUBG(opts.cwd, { write: false }).graph);
102
+ const staticFindings = checkGraph(fixedGraph).findings;
103
+ let regressions = [];
104
+ const baselinePath = path.join(healDir, 'baseline.json');
105
+ if (fs.existsSync(baselinePath)) {
106
+ const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
107
+ regressions = diffGraphs(baseline, fixedGraph).findings;
108
+ // pre-existing static findings are not the fix's fault β€” only NEW ones gate
109
+ const before = new Set(
110
+ checkGraph(baseline).findings.map((f) => `${f.rule}:${f.entrypoint}`),
111
+ );
112
+ regressions = [
113
+ ...regressions,
114
+ ...staticFindings.filter((f) => !before.has(`${f.rule}:${f.entrypoint}`)),
115
+ ];
116
+ } else {
117
+ regressions = staticFindings;
118
+ }
119
+ const apocalypse = verdictOf(regressions);
120
+
121
+ // ---- verdict -------------------------------------------------------------
122
+ console.log(`HEAL GATE β€” flight ${id}`);
123
+ console.log(
124
+ ` ${healing.healed ? 'βœ“' : 'βœ—'} behavior: ${healing.healed ? `bug gone (${healing.before.status} β†’ ${healing.after.status})` : healing.reasons.join('; ')}`,
125
+ );
126
+ if (healing.relabels.length)
127
+ console.log(
128
+ ` Β· ${healing.relabels.length} tap(s) relabeled by the fix (allowed): ${healing.relabels.map((r) => r.kind).join(', ')}`,
129
+ );
130
+ console.log(` ${laws.ok ? 'βœ“' : 'βœ—'} compiler laws: ${laws.passed}/${laws.total}`);
131
+ console.log(
132
+ ` ${apocalypse.safe ? 'βœ“' : 'βœ—'} apocalypse: ${regressions.length === 0 ? 'no new findings' : regressions.map((f) => f.rule).join(', ')}`,
133
+ );
134
+
135
+ const ok = healing.healed && laws.ok && apocalypse.safe;
136
+ const report = { id, healed: healing, laws: laws.checks, regressions, ok };
137
+ atomicWrite(path.join(healDir, 'report.json'), JSON.stringify(report, null, 2) + '\n');
138
+
139
+ if (ok) {
140
+ console.log(
141
+ `βœ“ HEALED & PROVEN β€” same recorded inputs, correct output, zero law broken, zero protection lost. Ship it.`,
142
+ );
143
+ } else {
144
+ console.log(
145
+ 'βœ— NOT PROVEN β€” the gate stays closed. Details: .sparda/heal/' +
146
+ id +
147
+ '/report.json',
148
+ );
149
+ process.exitCode = 1;
150
+ }
151
+ return report;
152
+ }
153
+
154
+ // same entry-loading discipline as timeless replay (listen suppressed)
155
+ async function loadAppFor(cwd) {
156
+ const { detectStack } = await import('../detect.js');
157
+ const http = await import('node:http');
158
+ const { pathToFileURL } = await import('node:url');
159
+ const stack = detectStack(cwd);
160
+ if (stack.framework !== 'express')
161
+ throw err('heal supports Express apps in v1', 'FastAPI replay is a later round');
162
+ const origListen = http.default.Server.prototype.listen;
163
+ http.default.Server.prototype.listen = function suppressed(...a) {
164
+ const cb = a.find((x) => typeof x === 'function');
165
+ if (cb) setImmediate(cb);
166
+ return this;
167
+ };
168
+ try {
169
+ // cache-bust: the gate must load the FIXED code, not the pre-fix module
170
+ const entry = pathToFileURL(path.resolve(cwd, stack.entryFile)).href;
171
+ const mod = await import(`${entry}?sparda-heal=${Date.now()}`);
172
+ const app = mod.default ?? mod;
173
+ if (typeof app !== 'function')
174
+ throw err(
175
+ 'the entry file does not export the Express app',
176
+ 'add `module.exports = app`',
177
+ );
178
+ return app;
179
+ } finally {
180
+ http.default.Server.prototype.listen = origListen;
181
+ }
182
+ }
package/src/flight/box.js CHANGED
@@ -7,8 +7,14 @@
7
7
  // context is untouched (AsyncLocalStorage decides), so arming the box in
8
8
  // production changes nothing for non-instrumented paths.
9
9
  import { AsyncLocalStorage } from 'node:async_hooks';
10
+ import crypto from 'node:crypto';
10
11
  import { saveFlight } from './format.js';
11
12
 
13
+ if (typeof globalThis.crypto === 'undefined' || !globalThis.crypto.randomUUID) {
14
+ // Polyfill global crypto for Node 18 and test runner environments
15
+ globalThis.crypto = crypto;
16
+ }
17
+
12
18
  const MAX_BODY_BYTES = 262144; // 256 KB per recorded http body β€” bounded flights
13
19
 
14
20
  export class FlightDivergence extends Error {
@@ -23,6 +29,8 @@ export function createFlightBox() {
23
29
  const als = new AsyncLocalStorage();
24
30
  const originals = {};
25
31
  let armed = false;
32
+ let insideUUID = false;
33
+ let insideFetch = false;
26
34
 
27
35
  function arm() {
28
36
  if (armed) return;
@@ -34,7 +42,7 @@ export function createFlightBox() {
34
42
 
35
43
  Date.now = function spardaDateNow() {
36
44
  const store = als.getStore();
37
- if (!store) return originals.dateNow();
45
+ if (!store || insideUUID || insideFetch) return originals.dateNow();
38
46
  if (store.mode === 'record') {
39
47
  const v = originals.dateNow();
40
48
  tapOut(store, 'time', 'Date.now', v);
@@ -45,7 +53,7 @@ export function createFlightBox() {
45
53
 
46
54
  Math.random = function spardaRandom() {
47
55
  const store = als.getStore();
48
- if (!store) return originals.random();
56
+ if (!store || insideUUID || insideFetch) return originals.random();
49
57
  if (store.mode === 'record') {
50
58
  const v = originals.random();
51
59
  tapOut(store, 'random', 'Math.random', v);
@@ -61,9 +69,14 @@ export function createFlightBox() {
61
69
  const store = als.getStore();
62
70
  if (!store) return originals.randomUUID();
63
71
  if (store.mode === 'record') {
64
- const v = originals.randomUUID();
65
- tapOut(store, 'uuid', 'crypto.randomUUID', v);
66
- return v;
72
+ insideUUID = true;
73
+ try {
74
+ const v = originals.randomUUID();
75
+ tapOut(store, 'uuid', 'crypto.randomUUID', v);
76
+ return v;
77
+ } finally {
78
+ insideUUID = false;
79
+ }
67
80
  }
68
81
  return takeTap(store, 'uuid', 'crypto.randomUUID');
69
82
  },
@@ -77,11 +90,16 @@ export function createFlightBox() {
77
90
  const method = (init?.method ?? 'GET').toUpperCase();
78
91
  const label = `${method} ${url}`;
79
92
  if (store.mode === 'record') {
80
- const res = await originals.fetch(input, init);
81
- const text = (await res.text()).slice(0, MAX_BODY_BYTES);
82
- const headers = { 'content-type': res.headers.get('content-type') ?? '' };
83
- tapOut(store, 'http', label, { status: res.status, headers, body: text });
84
- return new Response(text, { status: res.status, headers });
93
+ insideFetch = true;
94
+ try {
95
+ const res = await originals.fetch(input, init);
96
+ const text = (await res.text()).slice(0, MAX_BODY_BYTES);
97
+ const headers = { 'content-type': res.headers.get('content-type') ?? '' };
98
+ tapOut(store, 'http', label, { status: res.status, headers, body: text });
99
+ return new Response(text, { status: res.status, headers });
100
+ } finally {
101
+ insideFetch = false;
102
+ }
85
103
  }
86
104
  const rec = takeTap(store, 'http', label);
87
105
  return new Response(rec.body, { status: rec.status, headers: rec.headers });
@@ -0,0 +1,199 @@
1
+ // flight/heal.js β€” the healing brain (pure functions, no I/O).
2
+ // heal = the closed loop as ONE decision: given the recorded flight (the
3
+ // bug), the lenient replay against current code (the candidate fix), and an
4
+ // expectation, decide HEALED or NOT β€” then the command gates it behind
5
+ // verify + apocalypse. The subtlety this module owns: the exported flight
6
+ // test asserts the PAST; healing asserts the OPPOSITE β€” same deterministic
7
+ // inputs (the recorded taps), a NEW expected outcome. Without an explicit
8
+ // expectation we accept exactly one default: the recorded response was a 5xx
9
+ // and the replay no longer is. Anything subtler demands --expect; guessing
10
+ // intent would be dishonest.
11
+
12
+ // expectation: null | { status?: number, body?: object (subset match) }
13
+ export function evaluateHealing(flight, replay, expectation = null) {
14
+ const recorded = { status: flight.response.status, body: flight.response.body };
15
+ const reasons = [];
16
+
17
+ if (replay.divergences.length) {
18
+ reasons.push(
19
+ `replay diverged structurally (${replay.divergences.length}): the fix must keep the same effect ORDER and KINDS β€” only labels may change`,
20
+ );
21
+ }
22
+
23
+ const stillIdentical =
24
+ replay.actual.status === recorded.status &&
25
+ bodiesEqual(replay.actual.body, recorded.body);
26
+ if (stillIdentical && !replay.divergences.length) {
27
+ reasons.push('response is byte-identical to the recorded bug β€” nothing changed');
28
+ }
29
+
30
+ let expectationMet = null;
31
+ if (expectation) {
32
+ expectationMet = true;
33
+ if (expectation.status !== undefined && replay.actual.status !== expectation.status) {
34
+ expectationMet = false;
35
+ reasons.push(`expected status ${expectation.status}, got ${replay.actual.status}`);
36
+ }
37
+ if (expectation.body !== undefined) {
38
+ const actualBody = parseMaybe(replay.actual.body);
39
+ if (!subsetMatch(expectation.body, actualBody)) {
40
+ expectationMet = false;
41
+ reasons.push(`response body does not contain the expected subset`);
42
+ }
43
+ }
44
+ } else if (recorded.status >= 500) {
45
+ // the one honest default: a crash that no longer crashes
46
+ expectationMet = replay.actual.status < 500;
47
+ if (!expectationMet)
48
+ reasons.push(
49
+ `still failing: recorded ${recorded.status}, replay ${replay.actual.status}`,
50
+ );
51
+ } else {
52
+ expectationMet = false;
53
+ reasons.push(
54
+ `recorded response was ${recorded.status} (not a crash) β€” pass --expect '{"status":200,...}' to state what CORRECT looks like`,
55
+ );
56
+ }
57
+
58
+ const healed =
59
+ expectationMet === true && !stillIdentical && replay.divergences.length === 0;
60
+ return {
61
+ healed,
62
+ reasons,
63
+ before: recorded,
64
+ after: replay.actual,
65
+ relabels: replay.relabels ?? [],
66
+ leftover: replay.leftover,
67
+ };
68
+ }
69
+
70
+ // the brief the fixing agent (or human) receives β€” self-contained, built from
71
+ // the flight AND the compiler's understanding of the entrypoint
72
+ export function buildBrief(flight, graph, strictReplay, flightId) {
73
+ const ep = matchEntrypoint(graph, flight.request);
74
+ const nodes = Array.isArray(graph.nodes)
75
+ ? new Map(graph.nodes.map((n) => [n.id, n]))
76
+ : graph.nodes;
77
+
78
+ const lines = [
79
+ `# SPARDA Heal Brief β€” flight ${flightId}`,
80
+ '',
81
+ '## The bug (recorded from production)',
82
+ `- Request: \`${flight.request.method} ${flight.request.url}\``,
83
+ `- Request body: \`${JSON.stringify(flight.request.body)}\``,
84
+ `- Response: **${flight.response.status}** \`${flight.response.body.slice(0, 300)}\``,
85
+ `- Recorded taps: ${flight.taps.map((t) => t.kind).join(', ') || 'none'}`,
86
+ '',
87
+ '## Replay against current code',
88
+ strictReplay.match
89
+ ? '- Reproduces byte-identically β€” the bug is alive in this tree.'
90
+ : `- Differs already: status ${strictReplay.actual.status}; divergences: ${strictReplay.divergences.length}`,
91
+ '',
92
+ ];
93
+
94
+ if (ep) {
95
+ lines.push('## What the compiler knows about this entrypoint');
96
+ if (ep.loc) lines.push(`- Handler: \`${ep.loc.file}:${ep.loc.line}\``);
97
+ if (ep.meta.capabilities?.length)
98
+ lines.push(
99
+ `- Capabilities (must not grow): \`${ep.meta.capabilities.join(', ')}\``,
100
+ );
101
+ if (ep.meta.mutatesDomains?.length)
102
+ lines.push(`- Mutates aggregates: \`${ep.meta.mutatesDomains.join(', ')}\``);
103
+ const guards = guardsOf(graph, nodes, ep.id);
104
+ if (guards.length)
105
+ lines.push(
106
+ `- Guarded by: \`${guards.join(', ')}\` β€” REMOVING A GUARD FAILS THE GATE`,
107
+ );
108
+ const effectLocs = effectsOf(graph, nodes, ep.id);
109
+ if (effectLocs.length) {
110
+ lines.push('- Effects on this path:');
111
+ for (const e of effectLocs) lines.push(` - ${e}`);
112
+ }
113
+ lines.push('');
114
+ }
115
+
116
+ lines.push(
117
+ '## Acceptance criteria (mechanically gated β€” `sparda heal <id> --check`)',
118
+ '1. Lenient replay of the flight must produce the EXPECTED response (not the recorded bug).',
119
+ '2. Effect order and kinds must be preserved (labels may change).',
120
+ '3. `sparda verify` β€” all compiler laws still hold.',
121
+ '4. `sparda apocalypse` β€” zero new critical/high findings; no guard removed.',
122
+ '',
123
+ 'Fix the code. Do not edit flights, tests, or `.sparda/`.',
124
+ '',
125
+ );
126
+ return lines.join('\n');
127
+ }
128
+
129
+ function matchEntrypoint(graph, request) {
130
+ const nodes = Array.isArray(graph.nodes) ? graph.nodes : [...graph.nodes.values()];
131
+ const url = request.url.split('?')[0];
132
+ const method = request.method.toLowerCase();
133
+ for (const n of nodes) {
134
+ if (n.kind !== 'entrypoint' || n.meta.method !== method) continue;
135
+ const escaped = n.meta.path
136
+ .replace(/[.*+?^$()[\]\\|]/g, '\\$&')
137
+ .replace(/:(\w+)/g, '([^/]+)')
138
+ .replace(/\\?\{(\w+)\\?\}/g, '([^/]+)');
139
+ if (new RegExp(`^${escaped}/?$`).test(url)) return n;
140
+ }
141
+ return null;
142
+ }
143
+
144
+ function guardsOf(graph, nodes, epId) {
145
+ const chain = graph.edges.filter(
146
+ (e) => e.kind === 'control_flow' && e.meta?.route === epId,
147
+ );
148
+ const chainIds = new Set(chain.map((e) => e.to));
149
+ return [...chainIds]
150
+ .map((id) => nodes.get(id))
151
+ .filter((n) => n?.kind === 'guard')
152
+ .map((n) => n.label)
153
+ .sort();
154
+ }
155
+
156
+ function effectsOf(graph, nodes, epId) {
157
+ const chain = graph.edges.filter(
158
+ (e) => e.kind === 'control_flow' && e.meta?.route === epId,
159
+ );
160
+ const handler = chain.reduce(
161
+ (a, b) => (!a || b.meta.order > a.meta.order ? b : a),
162
+ null,
163
+ );
164
+ if (!handler) return [];
165
+ return graph.edges
166
+ .filter((e) => e.kind === 'control_flow' && e.from === handler.to && !e.meta?.route)
167
+ .map((e) => nodes.get(e.to))
168
+ .filter((n) => n?.kind === 'effect')
169
+ .map((n) => `${n.label} (${n.loc ? `${n.loc.file}:${n.loc.line}` : '?'})`)
170
+ .sort();
171
+ }
172
+
173
+ const parseMaybe = (s) => {
174
+ try {
175
+ return JSON.parse(s);
176
+ } catch {
177
+ return s;
178
+ }
179
+ };
180
+
181
+ const bodiesEqual = (a, b) => {
182
+ if (a === b) return true;
183
+ try {
184
+ return JSON.stringify(JSON.parse(a)) === JSON.stringify(JSON.parse(b));
185
+ } catch {
186
+ return false;
187
+ }
188
+ };
189
+
190
+ // every key in `expected` must be present and equal (deep) in `actual`
191
+ function subsetMatch(expected, actual) {
192
+ if (expected === null || typeof expected !== 'object')
193
+ return JSON.stringify(expected) === JSON.stringify(actual);
194
+ if (actual === null || typeof actual !== 'object') return false;
195
+ for (const [k, v] of Object.entries(expected)) {
196
+ if (!subsetMatch(v, actual[k])) return false;
197
+ }
198
+ return true;
199
+ }
@@ -8,8 +8,8 @@
8
8
  // 3. every tap was consumed (the code didn't silently skip a step).
9
9
  import http from 'node:http';
10
10
 
11
- export async function replayFlight(app, flight, box) {
12
- const store = box.makeReplayStore(flight);
11
+ export async function replayFlight(app, flight, box, { lenient = false } = {}) {
12
+ const store = box.makeReplayStore(flight, { lenient });
13
13
 
14
14
  const server = http.createServer((req, res) => {
15
15
  box.runWith(store, () => app(req, res));
@@ -51,6 +51,7 @@ export async function replayFlight(app, flight, box) {
51
51
  expected,
52
52
  actual,
53
53
  divergences: store.divergences,
54
+ relabels: store.relabels ?? [],
54
55
  leftover,
55
56
  };
56
57
  }
package/src/index.js CHANGED
@@ -25,9 +25,12 @@ const opts = {
25
25
  app: flags.has('--app'),
26
26
  learn: flags.has('--learn'),
27
27
  germinate: flags.has('--germinate'),
28
+ check: flags.has('--check'),
28
29
  port: getOpt('port', null),
29
30
  out: getOpt('out', null),
30
31
  openapi: getOpt('openapi', null),
32
+ expect: getOpt('expect', null),
33
+ agent: getOpt('agent', null),
31
34
  cwd: process.cwd(),
32
35
  };
33
36
 
@@ -127,6 +130,11 @@ try {
127
130
  await runVerify(opts);
128
131
  break;
129
132
  }
133
+ case 'heal': {
134
+ const { runHeal } = await import('./commands/heal.js');
135
+ await runHeal(opts, rest);
136
+ break;
137
+ }
130
138
  default:
131
139
  console.log(`SPARDA v${VERSION} β€” Turn any codebase into an MCP server.
132
140
 
@@ -149,6 +157,8 @@ Usage:
149
157
  npx sparda-mcp mirror Serve the compiled graph over HTTP β€” no framework, no source (--port)
150
158
  npx sparda-mcp openapi Emit an OpenAPI 3.1 spec FROM the graph (--out / --json)
151
159
  npx sparda-mcp verify Prove the compiler's own laws (determinism, soundness, round-trip) on this app
160
+ npx sparda-mcp heal Close the loop: <flightId> writes the fix brief; --check gates the fix
161
+ (--expect '{"status":200}' states correctness; --agent "cmd" lets an AI try)
152
162
 
153
163
  Flags: --yes (skip prompts) --port <n> --quiet --verbose
154
164
  --probe (init: also run the app to discover dynamic routes the AST missed)