sparda-mcp 0.10.1 → 0.12.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
@@ -93,7 +93,7 @@ npx sparda-mcp apocalypse
93
93
  This command reads the compiled `.sparda/ubg.json` (with zero source code parsing at runtime) and discharges five static correctness obligations:
94
94
  * **Unguarded Mutation (Critical)**: Flags any mutation path that does not cross a security `guard`.
95
95
  * **Non-Atomic Aggregate Write (High)**: Flags when an API writes to multiple tables of the same Consistency Domain (Aggregate) outside a single transaction scope.
96
- * **Unvalidated Constrained Write (Medium)**: Flags writes into columns with SQL invariants (CHECK, NOT NULL, UNIQUE) without prior validation (Zod/Pydantic).
96
+ * **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).
97
97
  * **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).
98
98
  * **Aggregate Member Bypass (Info)**: Flags mutating a member table directly without routing through the aggregate root.
99
99
 
@@ -108,6 +108,59 @@ Subsequent runs will diff the candidate graph against this baseline to detect re
108
108
 
109
109
  If any Critical or High finding is found, `apocalypse` exits with a non-zero code to block your CI pipeline.
110
110
 
111
+ **One step in your workflow — findings land in the GitHub Security tab (SARIF):**
112
+ ```yaml
113
+ - uses: zyx77550/sparda@main
114
+ with:
115
+ sarif: 'true'
116
+ ```
117
+
118
+ ## Time Travel: Timeless
119
+
120
+ Every production request is deterministic between its effects — the compiler knows exactly where the nondeterminism lives (db, http, clock, random, uuid: the effect nodes of the graph). Timeless records only those points (a few KB per request) and replays the request **byte-identically** against your current code, with the database, webhooks and clock virtualized from the recording:
121
+
122
+ ```bash
123
+ npx sparda-mcp timeless # list recorded flights
124
+ npx sparda-mcp timeless replay <id> # re-fly it — byte-identical or loud divergence
125
+ npx sparda-mcp timeless export <id> # the production bug is now a vitest test
126
+ ```
127
+
128
+ Recording is two lines in your app (ESM), with deterministic sampling and GDPR redaction built in:
129
+ ```js
130
+ import { getFlightBox } from 'sparda-mcp/src/flight/box.js';
131
+ const box = getFlightBox(); box.arm();
132
+ app.use(box.middleware({ sample: 100 })); // 1 request in 100; passwords/tokens redacted by default
133
+ const db = box.wrapClient(pgPool); // your query client, tapped
134
+ ```
135
+
136
+ The closed loop nobody else has: **production bug → recorded flight → failing test → AI writes the fix → `apocalypse` proves the fix breaks no guard, invariant or transaction → deploy.** Replay is per-request (concurrent-race capture is out of scope for v1 — stated, not hidden).
137
+
138
+ ## Any Backend On Earth: OpenAPI Lowering
139
+
140
+ 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.
141
+
142
+ ```bash
143
+ npx sparda-mcp ubg --openapi openapi.json
144
+ ```
145
+
146
+ Security schemes become gating `guard` nodes, response schemas become typed returns, declared request bodies count as validated input. Pair the spec with your `.sql` or `schema.prisma` files and the full state layer — invariants, aggregates, state machines — fills in from declared truth. (JSON specs in v1; we refuse to half-parse YAML with zero dependencies.)
147
+
148
+ ## The Mirror VM: delete the framework, the app still answers
149
+
150
+ The graph is not a diagram — it executes:
151
+
152
+ ```bash
153
+ npx sparda-mcp mirror
154
+ ```
155
+
156
+ ```
157
+ MIRROR — the graph is serving. 3 entrypoint(s) on http://127.0.0.1:4477
158
+ GET /orders/{orderId} → {amount, id, status}
159
+ POST /orders 🔒 bearerAuth → {amount, id, status}
160
+ ```
161
+
162
+ No Express. No FastAPI. No source code — just `ubg.json` answering HTTP: guards actually deny (401), responses render the compiled return schemas, unknown paths 404 with the full route table. Front-end teams develop against backends that aren't deployed yet — or aren't written yet (point `mirror` at an OpenAPI spec). Every response carries `x-sparda-mirror: true`; the mirror serves declared behavior, it never invents business values.
163
+
111
164
  To undo everything: **`npx sparda-mcp remove`** restores your code byte-for-byte.
112
165
 
113
166
  ## The promise — every word is backed by a test in CI
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.10.1",
3
+ "version": "0.12.0",
4
4
  "mcpName": "io.github.zyx77550/sparda-mcp",
5
5
  "description": "Turn any codebase into an MCP server with one command. Express & FastAPI → Claude-ready in 3 minutes.",
6
6
  "type": "module",
@@ -0,0 +1,123 @@
1
+ // commands/apocalypse.js — prove a deployment can't break the declared rules.
2
+ // Compiles the tree to SBIR, discharges the static proof obligations, and —
3
+ // when a baseline exists — proves the deploy removed no protection the
4
+ // baseline had. Exit code 1 on any critical/high finding: this command is
5
+ // built to sit in CI between "tests pass" and "deploy".
6
+ // sparda apocalypse check current tree (+ diff vs baseline if saved)
7
+ // sparda apocalypse --save-baseline record the current graph as the reference
8
+ // sparda apocalypse --json raw findings for tooling
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { compileUBG } from '../ubg/compile.js';
12
+ import { canonicalizeGraph } from '../ubg/schema.js';
13
+ import { checkGraph, diffGraphs, verdictOf } from '../ubg/apocalypse.js';
14
+ import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
15
+
16
+ const ICONS = { critical: '✗', high: '✗', medium: '⚠', info: '·' };
17
+
18
+ export async function runApocalypse(opts) {
19
+ const { graph } = compileUBG(opts.cwd, { write: false });
20
+ const canonical = canonicalizeGraph(graph);
21
+ const baselinePath = path.join(opts.cwd, '.sparda', 'ubg.baseline.json');
22
+
23
+ if (opts.saveBaseline) {
24
+ fs.mkdirSync(path.dirname(baselinePath), { recursive: true });
25
+ atomicWrite(baselinePath, JSON.stringify(canonical, null, 2) + '\n');
26
+ console.log(
27
+ `✓ Baseline saved: .sparda/ubg.baseline.json — future deploys are proven against it`,
28
+ );
29
+ }
30
+
31
+ const { findings: staticFindings, obligations } = checkGraph(canonical);
32
+ let diffFindings = [];
33
+ let hasBaseline = false;
34
+ if (!opts.saveBaseline && fs.existsSync(baselinePath)) {
35
+ hasBaseline = true;
36
+ try {
37
+ const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
38
+ diffFindings = diffGraphs(baseline, canonical).findings;
39
+ } catch (err) {
40
+ console.error(`⚠ baseline unreadable (${err.message}) — static checks only`);
41
+ }
42
+ }
43
+
44
+ const findings = [...staticFindings, ...diffFindings];
45
+ const verdict = verdictOf(findings);
46
+
47
+ if (opts.sarif) {
48
+ const sarifPath = path.join(opts.cwd, '.sparda', 'apocalypse.sarif');
49
+ fs.mkdirSync(path.dirname(sarifPath), { recursive: true });
50
+ atomicWrite(sarifPath, JSON.stringify(toSarif(findings, canonical), null, 2) + '\n');
51
+ console.log(
52
+ `✓ SARIF written: .sparda/apocalypse.sarif (${findings.length} result(s))`,
53
+ );
54
+ }
55
+
56
+ if (opts.json) {
57
+ console.log(JSON.stringify({ verdict, obligations, findings }, null, 2));
58
+ } else {
59
+ console.log(
60
+ `APOCALYPSE — deployment proof over ${canonical.nodes.length} nodes, ${canonical.edges.length} edges` +
61
+ (hasBaseline ? ' (baseline diff included)' : ''),
62
+ );
63
+ for (const f of findings) {
64
+ console.log(` ${ICONS[f.severity]} [${f.severity}] ${f.rule} — ${f.message}`);
65
+ if (opts.verbose) for (const ev of f.evidence) console.log(` evidence: ${ev}`);
66
+ }
67
+ if (verdict.clean) {
68
+ console.log(
69
+ `✓ PROVEN — ${obligations} obligation(s) discharged, zero violations. No declared guard, invariant, transaction or aggregate boundary can be broken by this tree.`,
70
+ );
71
+ } else {
72
+ const c = verdict.counts;
73
+ console.log(
74
+ `${verdict.safe ? '⚠ RISKY' : '✗ NOT PROVEN'} — ${c.critical} critical, ${c.high} high, ${c.medium} medium, ${c.info} info`,
75
+ );
76
+ }
77
+ }
78
+
79
+ if (!verdict.safe) process.exitCode = 1; // CI gates on this
80
+ return { verdict, findings, obligations };
81
+ }
82
+
83
+ // SARIF 2.1.0 — GitHub code scanning eats this directly
84
+ const SARIF_LEVEL = { critical: 'error', high: 'error', medium: 'warning', info: 'note' };
85
+
86
+ function toSarif(findings, canonical) {
87
+ const nodeById = new Map(canonical.nodes.map((n) => [n.id, n]));
88
+ return {
89
+ $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
90
+ version: '2.1.0',
91
+ runs: [
92
+ {
93
+ tool: {
94
+ driver: {
95
+ name: 'sparda-apocalypse',
96
+ informationUri: 'https://github.com/zyx77550/sparda',
97
+ rules: [...new Set(findings.map((f) => f.rule))].sort().map((id) => ({ id })),
98
+ },
99
+ },
100
+ results: findings.map((f) => {
101
+ const loc = nodeById.get(f.entrypoint)?.loc;
102
+ return {
103
+ ruleId: f.rule,
104
+ level: SARIF_LEVEL[f.severity] ?? 'warning',
105
+ message: { text: f.message },
106
+ ...(loc
107
+ ? {
108
+ locations: [
109
+ {
110
+ physicalLocation: {
111
+ artifactLocation: { uri: loc.file },
112
+ region: { startLine: loc.line || 1 },
113
+ },
114
+ },
115
+ ],
116
+ }
117
+ : {}),
118
+ };
119
+ }),
120
+ },
121
+ ],
122
+ };
123
+ }
@@ -0,0 +1,42 @@
1
+ // commands/mirror.js — serve the compiled behavior, delete the framework.
2
+ // sparda mirror compile the tree (or --openapi spec) and serve it
3
+ // sparda mirror --port 5050 pick the port
4
+ // sparda ubg --openapi api.json && sparda mirror any backend on earth
5
+ // The graph answers HTTP: guards deny (401), responses come out typed from
6
+ // the compiled return schemas, unknown paths list what the graph knows.
7
+ // Front-ends develop against backends that aren't deployed — or written.
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { compileUBG } from '../ubg/compile.js';
11
+ import { createMirrorServer } from '../ubg/mirror.js';
12
+
13
+ export async function runMirror(opts) {
14
+ // prefer the already-compiled artifact; compile fresh when absent
15
+ const artifact = path.join(opts.cwd, '.sparda', 'ubg.json');
16
+ let graph;
17
+ if (fs.existsSync(artifact) && !opts.openapi) {
18
+ graph = JSON.parse(fs.readFileSync(artifact, 'utf8'));
19
+ } else {
20
+ graph = JSON.parse(
21
+ compileUBG(opts.cwd, { write: false, openapi: opts.openapi ?? null }).json,
22
+ );
23
+ }
24
+
25
+ const { server, routes } = createMirrorServer(graph);
26
+ const port = Number(opts.port ?? 4477);
27
+ await new Promise((resolve, reject) => {
28
+ server.once('error', reject);
29
+ server.listen(port, '127.0.0.1', resolve);
30
+ });
31
+
32
+ console.log(
33
+ `MIRROR — the graph is serving. ${routes.length} entrypoint(s) on http://127.0.0.1:${port}`,
34
+ );
35
+ for (const r of routes) {
36
+ console.log(
37
+ ` ${r.method.toUpperCase().padEnd(6)} ${r.path}${r.guarded ? ' 🔒 ' + r.guards.join(',') : ''}${r.returns ? ' → {' + Object.keys(r.returns).join(', ') + '}' : ''}`,
38
+ );
39
+ }
40
+ console.log(' (every response carries x-sparda-mirror: true — Ctrl+C to stop)');
41
+ return { server, routes, port };
42
+ }
@@ -0,0 +1,61 @@
1
+ // commands/openapi.js — emit an OpenAPI 3.1 spec FROM the behavior graph.
2
+ // We produce the standard the rest of the industry consumes: any backend
3
+ // SPARDA can compile (Express, FastAPI, Next.js — or another spec via
4
+ // --openapi) gets a valid, deterministic spec, richer than most hand-written
5
+ // ones because it comes from what the code actually declares.
6
+ // sparda openapi write .sparda/openapi.json
7
+ // sparda openapi --out api.json pick the path
8
+ // sparda openapi --json print to stdout
9
+ import path from 'node:path';
10
+ import fs from 'node:fs';
11
+ import { compileUBG } from '../ubg/compile.js';
12
+ import { canonicalizeGraph } from '../ubg/schema.js';
13
+ import { emitOpenAPI } from '../ubg/openapi-emit.js';
14
+ import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
15
+
16
+ export async function runOpenapi(opts) {
17
+ const { graph } = compileUBG(opts.cwd, {
18
+ write: false,
19
+ openapi: opts.openapi ?? null,
20
+ });
21
+ const title = packageNameOf(opts.cwd);
22
+ const spec = emitOpenAPI(canonicalizeGraph(graph), { title });
23
+ const body = JSON.stringify(spec, null, 2) + '\n';
24
+
25
+ if (opts.json) {
26
+ console.log(body);
27
+ return { spec };
28
+ }
29
+ const target = opts.out
30
+ ? path.resolve(opts.cwd, opts.out)
31
+ : path.join(opts.cwd, '.sparda', 'openapi.json');
32
+ fs.mkdirSync(path.dirname(target), { recursive: true });
33
+ atomicWrite(target, body);
34
+
35
+ const pathCount = Object.keys(spec.paths).length;
36
+ const opCount = Object.values(spec.paths).reduce(
37
+ (n, p) => n + Object.keys(p).length,
38
+ 0,
39
+ );
40
+ console.log(
41
+ `✓ OpenAPI 3.1 emitted: ${rel(target, opts.cwd)} — ${pathCount} path(s), ${opCount} operation(s)`,
42
+ );
43
+ console.log(
44
+ ' SPARDA produces the standard others consume — feed it to Swagger UI or a codegen.',
45
+ );
46
+ return { spec, target };
47
+ }
48
+
49
+ function packageNameOf(cwd) {
50
+ try {
51
+ return (
52
+ JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8')).name ??
53
+ 'sparda-compiled-api'
54
+ );
55
+ } catch {
56
+ return 'sparda-compiled-api';
57
+ }
58
+ }
59
+
60
+ const rel = (abs, cwd) =>
61
+ abs.startsWith(cwd) ? abs.slice(cwd.length + 1).replaceAll('\\', '/') : abs;
@@ -9,6 +9,7 @@ export async function runUbg(opts) {
9
9
  const { report, json, outPath } = compileUBG(opts.cwd, {
10
10
  write: true,
11
11
  out: opts.out,
12
+ openapi: opts.openapi ?? null,
12
13
  });
13
14
 
14
15
  if (opts.json) {
@@ -0,0 +1,36 @@
1
+ // commands/verify.js — prove the compiler's own laws on THIS project.
2
+ // Runs the SBIR compiler-law audit and reports each check. Exit code 1 if any
3
+ // law fails: run it in CI to guarantee the graph your tools trust was built
4
+ // under determinism and soundness — not just asserted to be.
5
+ // sparda verify audit the detected app
6
+ // sparda verify --openapi s.json audit an OpenAPI-lowered backend
7
+ import { verifyProject } from '../ubg/verify.js';
8
+
9
+ export async function runVerify(opts) {
10
+ const { checks, passed, total, ok } = verifyProject(opts.cwd, {
11
+ openapi: opts.openapi ?? null,
12
+ });
13
+
14
+ console.log(`VERIFY — SBIR compiler laws on this project (${passed}/${total} checks)`);
15
+ let lastLaw = null;
16
+ for (const c of checks) {
17
+ if (c.law !== lastLaw) {
18
+ console.log(` ${c.law}`);
19
+ lastLaw = c.law;
20
+ }
21
+ console.log(
22
+ ` ${c.pass ? '✓' : '✗'} ${c.name}${c.pass || !c.detail ? '' : ` — ${c.detail}`}`,
23
+ );
24
+ }
25
+ if (ok) {
26
+ console.log(
27
+ '✓ PROVEN — every compiler law holds on this input. The graph is trustworthy.',
28
+ );
29
+ } else {
30
+ console.log(
31
+ '✗ LAW VIOLATED — the compiler broke its own contract on this input. This is a bug; please open an issue.',
32
+ );
33
+ process.exitCode = 1;
34
+ }
35
+ return { ok, checks };
36
+ }
package/src/detect.js CHANGED
@@ -199,6 +199,8 @@ function findExpressEntry(cwd, pkg) {
199
199
  'src/app.ts',
200
200
  'src/server.ts',
201
201
  'src/index.ts',
202
+ 'src/main.ts', // Nx / NestJS-style monorepo layout
203
+ 'main.ts',
202
204
  'app.ts',
203
205
  'server.ts',
204
206
  'index.ts',
package/src/flight/box.js CHANGED
@@ -129,7 +129,22 @@ export function createFlightBox() {
129
129
 
130
130
  // Express middleware — mount early; the request's whole async tree records.
131
131
  // The flight is finalized and written on response finish.
132
- function middleware({ cwd = process.cwd(), onFlight = null } = {}) {
132
+ // sample: 1 record every request (default); N records every Nth
133
+ // a deterministic counter, never Math.random (Law 3 applies
134
+ // to the recorder too)
135
+ // redactKeys: body keys scrubbed at record time, deep, case-insensitive.
136
+ // Redaction happens BEFORE the flight touches disk — the
137
+ // secret never exists in the artifact. Redacted flights
138
+ // replay fine as long as handlers don't echo those fields
139
+ // back (if they do, the replay diverges loudly — honest).
140
+ let requestCounter = 0;
141
+ function middleware({
142
+ cwd = process.cwd(),
143
+ onFlight = null,
144
+ sample = 1,
145
+ redactKeys = DEFAULT_REDACT_KEYS,
146
+ } = {}) {
147
+ const redactSet = new Set(redactKeys.map((k) => k.toLowerCase()));
133
148
  return function spardaFlightRecorder(req, res, next) {
134
149
  // replay wins over record: when the timeless CLI replays through an app
135
150
  // that mounts this recorder, the outer replay store must reach the taps
@@ -137,6 +152,7 @@ export function createFlightBox() {
137
152
  const outer = als.getStore();
138
153
  if (outer?.mode === 'replay') return next();
139
154
  if (process.env.SPARDA_FLIGHT === 'off') return next();
155
+ if (sample > 1 && requestCounter++ % sample !== 0) return next();
140
156
  const store = { mode: 'record', taps: [] };
141
157
  const chunks = [];
142
158
  const origWrite = res.write.bind(res);
@@ -155,7 +171,7 @@ export function createFlightBox() {
155
171
  method: req.method,
156
172
  url: req.originalUrl ?? req.url,
157
173
  headers: { 'content-type': req.headers['content-type'] ?? '' },
158
- body: req.body ?? null,
174
+ body: redactDeep(req.body ?? null, redactSet),
159
175
  },
160
176
  response: {
161
177
  status: res.statusCode,
@@ -171,14 +187,17 @@ export function createFlightBox() {
171
187
  };
172
188
  }
173
189
 
174
- // replay plumbing — one store per replayed request
175
- function makeReplayStore(flight) {
190
+ // replay plumbing — one store per replayed request.
191
+ // lenient: labels may differ (a FIX reformulates a query — same kind, same
192
+ // order, new text); relabels are reported, never silent. Running out of
193
+ // taps stays fatal in both modes — fail-loud is not negotiable.
194
+ function makeReplayStore(flight, { lenient = false } = {}) {
176
195
  const queues = new Map();
177
196
  for (const tap of flight.taps) {
178
197
  if (!queues.has(tap.kind)) queues.set(tap.kind, []);
179
198
  queues.get(tap.kind).push(tap);
180
199
  }
181
- return { mode: 'replay', queues, divergences: [] };
200
+ return { mode: 'replay', queues, divergences: [], relabels: [], lenient };
182
201
  }
183
202
 
184
203
  const runWith = (store, fn) => als.run(store, fn);
@@ -190,6 +209,36 @@ function tapOut(store, kind, label, result) {
190
209
  store.taps.push({ seq: store.taps.length, kind, label, result });
191
210
  }
192
211
 
212
+ // GDPR hygiene: these never reach the flight file. Only the content-type
213
+ // header is ever captured, so headers (authorization, cookie) are safe by
214
+ // construction — this list covers the request BODY.
215
+ export const DEFAULT_REDACT_KEYS = [
216
+ 'password',
217
+ 'passwd',
218
+ 'secret',
219
+ 'token',
220
+ 'apikey',
221
+ 'api_key',
222
+ 'authorization',
223
+ 'cookie',
224
+ 'creditcard',
225
+ 'card_number',
226
+ 'cvv',
227
+ 'ssn',
228
+ ];
229
+
230
+ function redactDeep(value, redactSet, depth = 0) {
231
+ if (depth > 6 || value === null || typeof value !== 'object') return value;
232
+ if (Array.isArray(value)) return value.map((v) => redactDeep(v, redactSet, depth + 1));
233
+ const out = {};
234
+ for (const [k, v] of Object.entries(value)) {
235
+ out[k] = redactSet.has(k.toLowerCase())
236
+ ? '[REDACTED]'
237
+ : redactDeep(v, redactSet, depth + 1);
238
+ }
239
+ return out;
240
+ }
241
+
193
242
  // strict FIFO per kind, label-checked — the "quasi infallible" contract:
194
243
  // the replayed code must ask reality the SAME questions in the SAME order
195
244
  function takeTap(store, kind, label) {
@@ -204,6 +253,10 @@ function takeTap(store, kind, label) {
204
253
  );
205
254
  }
206
255
  if (tap.label !== label) {
256
+ if (store.lenient) {
257
+ store.relabels.push({ kind, was: tap.label, now: label });
258
+ return tap.result;
259
+ }
207
260
  const d = { kind, expected: tap.label, got: label };
208
261
  store.divergences.push(d);
209
262
  throw new FlightDivergence(
package/src/index.js CHANGED
@@ -16,6 +16,7 @@ const getOpt = (name, dflt) => {
16
16
  const opts = {
17
17
  yes: flags.has('--yes') || flags.has('-y'),
18
18
  saveBaseline: flags.has('--save-baseline'),
19
+ sarif: flags.has('--sarif'),
19
20
  verbose: flags.has('--verbose'),
20
21
  quiet: flags.has('--quiet'),
21
22
  probe: flags.has('--probe'),
@@ -26,6 +27,7 @@ const opts = {
26
27
  germinate: flags.has('--germinate'),
27
28
  port: getOpt('port', null),
28
29
  out: getOpt('out', null),
30
+ openapi: getOpt('openapi', null),
29
31
  cwd: process.cwd(),
30
32
  };
31
33
 
@@ -110,6 +112,21 @@ try {
110
112
  await runTimeless(opts, rest);
111
113
  break;
112
114
  }
115
+ case 'mirror': {
116
+ const { runMirror } = await import('./commands/mirror.js');
117
+ await runMirror(opts);
118
+ break;
119
+ }
120
+ case 'openapi': {
121
+ const { runOpenapi } = await import('./commands/openapi.js');
122
+ await runOpenapi(opts);
123
+ break;
124
+ }
125
+ case 'verify': {
126
+ const { runVerify } = await import('./commands/verify.js');
127
+ await runVerify(opts);
128
+ break;
129
+ }
113
130
  default:
114
131
  console.log(`SPARDA v${VERSION} — Turn any codebase into an MCP server.
115
132
 
@@ -129,6 +146,9 @@ Usage:
129
146
  npx sparda-mcp ubg Compile the codebase to its Unified Behavior Graph (.sparda/ubg.json)
130
147
  npx sparda-mcp apocalypse Prove the deploy: guards, invariants, transactions, aggregates (--save-baseline)
131
148
  npx sparda-mcp timeless Replay production requests (list | replay <id> | export <id> → vitest)
149
+ npx sparda-mcp mirror Serve the compiled graph over HTTP — no framework, no source (--port)
150
+ npx sparda-mcp openapi Emit an OpenAPI 3.1 spec FROM the graph (--out / --json)
151
+ npx sparda-mcp verify Prove the compiler's own laws (determinism, soundness, round-trip) on this app
132
152
 
133
153
  Flags: --yes (skip prompts) --port <n> --quiet --verbose
134
154
  --probe (init: also run the app to discover dynamic routes the AST missed)
@@ -8,7 +8,9 @@ import { clearModuleCache } from './extract.js';
8
8
  import { extractExpress } from './express.js';
9
9
  import { extractNext } from './nextjs.js';
10
10
  import { extractFastAPI } from './fastapi.js';
11
+ import { extractOpenAPI } from './openapi.js';
11
12
  import { parseSqlSchemas } from './sql.js';
13
+ import { parsePrismaSchemas } from './prisma.js';
12
14
  import { translate } from './translate.js';
13
15
  import { linkDataFlow } from './link.js';
14
16
  import { optimize } from './pipeline.js';
@@ -17,15 +19,18 @@ import { serializeGraph, sourceHashOf, writeGraph } from './serialize.js';
17
19
 
18
20
  export function compileUBG(
19
21
  cwd,
20
- { write = true, out = null, optimizePasses = true } = {},
22
+ { write = true, out = null, optimizePasses = true, openapi = null } = {},
21
23
  ) {
22
24
  clearModuleCache(); // each compile run parses fresh — no stale-file ghosts
23
25
 
24
- const stack = detectStack(cwd);
26
+ // --openapi: the universal lowering — no framework detection, ANY backend
27
+ // that carries a spec enters the graph (Go, Java, Rails, .NET, whatever)
28
+ const stack = openapi ? { framework: 'openapi', entryFile: openapi } : detectStack(cwd);
25
29
  const extractors = {
26
30
  express: () => extractExpress(cwd, stack.entryFile),
27
31
  nextjs: () => extractNext(cwd, stack.entryFile),
28
32
  fastapi: () => extractFastAPI(cwd, stack.entryFile, stack.pythonCmd),
33
+ openapi: () => extractOpenAPI(cwd, stack.entryFile),
29
34
  };
30
35
  if (!extractors[stack.framework]) {
31
36
  throw Object.assign(
@@ -36,13 +41,17 @@ export function compileUBG(
36
41
  const extracted = extractors[stack.framework]();
37
42
 
38
43
  const sql = parseSqlSchemas(cwd);
44
+ const prisma = parsePrismaSchemas(cwd);
45
+ // DDL beats ORM on a name collision — the database is the closer truth
46
+ const sqlNames = new Set(sql.tables.map((t) => t.name));
47
+ const tables = [...sql.tables, ...prisma.tables.filter((t) => !sqlNames.has(t.name))];
39
48
 
40
49
  const graph = translate({
41
50
  framework: stack.framework,
42
51
  routes: extracted.routes,
43
52
  globalMiddlewares: extracted.globalMiddlewares,
44
53
  helpers: extracted.helpers,
45
- tables: sql.tables,
54
+ tables,
46
55
  });
47
56
  validateGraph(graph);
48
57
 
@@ -56,7 +65,7 @@ export function compileUBG(
56
65
  entry: stack.entryFile,
57
66
  sourceHash: sourceHashOf(cwd, [
58
67
  ...extracted.scannedFiles,
59
- ...sql.tables.map((t) => t.sourceFile),
68
+ ...tables.map((t) => t.sourceFile),
60
69
  ]),
61
70
  };
62
71
 
@@ -64,10 +73,11 @@ export function compileUBG(
64
73
  framework: stack.framework,
65
74
  entry: stack.entryFile,
66
75
  routes: extracted.routes.length,
67
- tables: sql.tables.length,
76
+ tables: tables.length,
77
+ ...(prisma.tables.length ? { prismaTables: prisma.tables.length } : {}),
68
78
  link: linkReport,
69
79
  passes: passReports,
70
- skipped: [...extracted.skipped, ...sql.skipped],
80
+ skipped: [...extracted.skipped, ...sql.skipped, ...prisma.skipped],
71
81
  counts: countGraph(graph),
72
82
  };
73
83