sparda-mcp 0.8.1 → 0.10.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 +40 -11
- package/SKILL.md +17 -26
- package/package.json +1 -1
- package/src/commands/timeless.js +187 -0
- package/src/commands/ubg.js +80 -0
- package/src/flight/box.js +245 -0
- package/src/flight/format.js +71 -0
- package/src/flight/replayer.js +97 -0
- package/src/index.js +19 -0
- package/src/ubg/compile.js +84 -0
- package/src/ubg/express.js +258 -0
- package/src/ubg/extract.js +684 -0
- package/src/ubg/fastapi.js +59 -0
- package/src/ubg/fastapi_extract.py +776 -0
- package/src/ubg/link.js +61 -0
- package/src/ubg/nextjs.js +152 -0
- package/src/ubg/passes/capabilities.js +73 -0
- package/src/ubg/passes/consistency-domains.js +121 -0
- package/src/ubg/passes/dead-path-elimination.js +74 -0
- package/src/ubg/passes/effect-algebra.js +71 -0
- package/src/ubg/passes/resource-lifetimes.js +73 -0
- package/src/ubg/passes/state-machines.js +104 -0
- package/src/ubg/passes/state-minimization.js +86 -0
- package/src/ubg/passes/type-propagation.js +122 -0
- package/src/ubg/pipeline.js +45 -0
- package/src/ubg/reach.js +39 -0
- package/src/ubg/schema.js +0 -0
- package/src/ubg/serialize.js +41 -0
- package/src/ubg/sql.js +252 -0
- package/src/ubg/translate.js +310 -0
package/README.md
CHANGED
|
@@ -6,23 +6,27 @@
|
|
|
6
6
|
|
|
7
7
|
<br/>
|
|
8
8
|
|
|
9
|
-
**
|
|
9
|
+
**Compile your backend into a deterministic behavior runtime.**
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
refactor a controller, but they can't create an order, fetch a real user, or see why
|
|
13
|
-
production is failing. And giving an AI real access to your API usually means: write
|
|
14
|
-
an OpenAPI spec, build an MCP server, host it, secure it, keep it in sync with every
|
|
15
|
-
route change — and pray it never `DELETE`s the wrong row. Days of glue code, per
|
|
16
|
-
project, forever drifting.
|
|
11
|
+
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.
|
|
17
12
|
|
|
18
|
-
SPARDA
|
|
13
|
+
SPARDA analyzes your Express, FastAPI, or Next.js application, extracts its behavior, and compiles it into a **SPARDA Behavior Graph (SBG)**.
|
|
14
|
+
|
|
15
|
+
This graph acts as a local, deterministic runtime that powers:
|
|
16
|
+
* **MCP Server Integration**: Exposing your endpoints to AI clients dynamically.
|
|
17
|
+
* **SPARDA Twin**: An executable, offline simulation clone of your backend for safe agent testing.
|
|
18
|
+
* **SPARDA Immune**: Real-time graph-based anomaly defense, latency alerts, and auto-quarantine.
|
|
19
|
+
* **SPARDA Evolution**: Statically inferred grammar mapping and in-memory workflow optimization.
|
|
20
|
+
|
|
21
|
+
All executed 100% locally, with zero runtime dependencies and zero cloud connection.
|
|
19
22
|
|
|
20
23
|
```bash
|
|
21
|
-
npx sparda-mcp init #
|
|
22
|
-
npx sparda-mcp dev #
|
|
24
|
+
npx sparda-mcp init # Scan your app, compile the UBG, inject the SPARDA Runtime
|
|
25
|
+
npx sparda-mcp dev # Connect Claude Desktop / Cursor over stdio
|
|
23
26
|
```
|
|
24
27
|
|
|
25
|
-
No OpenAPI spec. No account. No API key. No server to host.
|
|
28
|
+
No OpenAPI spec to write. No cloud account. No API key. No server to host.
|
|
29
|
+
Exposing raw APIs to AI is the old way. SPARDA compiles the whole system's behavior.
|
|
26
30
|
|
|
27
31
|
## Quickstart
|
|
28
32
|
|
|
@@ -80,6 +84,30 @@ To output raw JSON for integration:
|
|
|
80
84
|
npx sparda-mcp report --json
|
|
81
85
|
```
|
|
82
86
|
|
|
87
|
+
## Deployment Proof: Apocalypse
|
|
88
|
+
|
|
89
|
+
SPARDA's Behavior Graph is a formal model of your system. Instead of waiting for runtime failures or relying on static analysis vibes, you can statically prove the safety of your backend before any deployment:
|
|
90
|
+
```bash
|
|
91
|
+
npx sparda-mcp apocalypse
|
|
92
|
+
```
|
|
93
|
+
This command reads the compiled `.sparda/ubg.json` (with zero source code parsing at runtime) and discharges five static correctness obligations:
|
|
94
|
+
* **Unguarded Mutation (Critical)**: Flags any mutation path that does not cross a security `guard`.
|
|
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).
|
|
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
|
+
* **Aggregate Member Bypass (Info)**: Flags mutating a member table directly without routing through the aggregate root.
|
|
99
|
+
|
|
100
|
+
To save your current graph as a safe baseline:
|
|
101
|
+
```bash
|
|
102
|
+
npx sparda-mcp apocalypse --save-baseline
|
|
103
|
+
```
|
|
104
|
+
Subsequent runs will diff the candidate graph against this baseline to detect regression vectors:
|
|
105
|
+
* Deletion of any security `guard` (Critical).
|
|
106
|
+
* Deletion of a database SQL invariant (High).
|
|
107
|
+
* API blast radius expansion (Medium).
|
|
108
|
+
|
|
109
|
+
If any Critical or High finding is found, `apocalypse` exits with a non-zero code to block your CI pipeline.
|
|
110
|
+
|
|
83
111
|
To undo everything: **`npx sparda-mcp remove`** restores your code byte-for-byte.
|
|
84
112
|
|
|
85
113
|
## The promise — every word is backed by a test in CI
|
|
@@ -169,6 +197,7 @@ runtime, so the guidance never goes stale.
|
|
|
169
197
|
|
|
170
198
|
## Security posture (honest)
|
|
171
199
|
- 4 runtime dependencies, exact-pinned.
|
|
200
|
+
- **Dynamic Local Key Resolution.** The generated router contains no baked secrets. It resolves authorization keys at runtime from the `SPARDA_LOCAL_KEY` environment variable or the local gitignored `.sparda/key` file, and fails closed (503) when neither is found. For custom production or staging setups, you can override this behavior by exposing `SPARDA_LOCAL_KEY` in your environment.
|
|
172
201
|
- Local key on every router call; self-reference loop protection; 30s timeouts; 8 KB output truncation.
|
|
173
202
|
- AST-positioned injection with backup and post-injection re-parse; `npx sparda-mcp remove` leaves a clean git diff.
|
|
174
203
|
- Persistence is **value-free**: SPARDA records structure (tool names, field names, fingerprints), never your payloads.
|
package/SKILL.md
CHANGED
|
@@ -1,40 +1,30 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: sparda-mcp
|
|
3
3
|
description: >-
|
|
4
|
-
Drive a SPARDA
|
|
5
|
-
you are connected to
|
|
6
|
-
by the tools `sparda_get_context`, `sparda_info`,
|
|
7
|
-
|
|
8
|
-
to exploit the response-recycling flywheel, the circuit-breaker, crystallized
|
|
9
|
-
circuits
|
|
10
|
-
write tools. Reach for it before calling raw endpoints, before any write, or when
|
|
11
|
-
a tool returns 202 (awaiting confirmation) or 503 (quarantined).
|
|
4
|
+
Drive a SPARDA compiled Behavior Runtime to its full potential. Use this whenever
|
|
5
|
+
you are connected to a backend running the SPARDA Runtime (compiled into a SPARDA
|
|
6
|
+
Behavior Graph - SBG) — recognizable by the tools `sparda_get_context`, `sparda_info`,
|
|
7
|
+
`sparda_confirm`, or composite tools. It teaches the graph-context-first workflow,
|
|
8
|
+
how to exploit the response-recycling flywheel, the circuit-breaker, crystallized
|
|
9
|
+
circuits, offline Twin simulations, and the two-phase confirm protocol for writes.
|
|
12
10
|
---
|
|
13
11
|
|
|
14
|
-
# Driving a SPARDA
|
|
12
|
+
# Driving a SPARDA Behavior Runtime
|
|
15
13
|
|
|
16
|
-
A SPARDA server is
|
|
17
|
-
`/mcp` router *inside a real running app*, so its tools are that app's actual
|
|
18
|
-
routes — wrapped in an intelligence layer that makes repeated use **cheaper** (it
|
|
19
|
-
recycles stable answers from memory) and **safer** (it quarantines failing tools
|
|
20
|
-
and gates writes behind human confirmation). Used naively it's just an API. Used
|
|
21
|
-
well, it gets faster and safer the more you call it. This skill is how to use it
|
|
22
|
-
well.
|
|
14
|
+
A SPARDA server is driven by a compiled **SPARDA Behavior Graph (SBG)**. Instead of exposing raw, disconnected endpoints, SPARDA has compiled the host application's states, transitions, permissions, and side-effects into a deterministic behavioral model. The local **SPARDA Runtime** dynamically executes this graph inside the live host process, powering the MCP interface, the Twin simulation clone, and the Immune system offline.
|
|
23
15
|
|
|
24
16
|
## Rule 0 — call `sparda_get_context` first, every session
|
|
25
17
|
|
|
26
|
-
Before anything else, call **`sparda_get_context`** (no params). It returns the
|
|
27
|
-
*live* picture, so you never guess the surface:
|
|
18
|
+
Before anything else, call **`sparda_get_context`** (no params). It returns the *live* state of the SPARDA Behavior Graph:
|
|
28
19
|
|
|
29
|
-
- the
|
|
30
|
-
- `runtime` — current
|
|
31
|
-
|
|
32
|
-
-
|
|
33
|
-
- **
|
|
34
|
-
-
|
|
35
|
-
- a `behavior` snapshot (which response fields are stable).
|
|
20
|
+
- the active routes/tools, workflows, and type-propagated schemas;
|
|
21
|
+
- `runtime` — current stats (calls, errors, quarantine states, Twin mode active);
|
|
22
|
+
- the last ~20 **events** (errors, latency anomalies, immune alerts);
|
|
23
|
+
- **immune memory** (cached antibodies and error diagnoses);
|
|
24
|
+
- **recycling** metrics (flywheel memory hit rates);
|
|
25
|
+
- a `behavior` snapshot of stable variables.
|
|
36
26
|
|
|
37
|
-
Read it
|
|
27
|
+
Read it to orient yourself inside the graph. `sparda_info` gives a lighter summary of counts.
|
|
38
28
|
|
|
39
29
|
## The tools you'll see
|
|
40
30
|
|
|
@@ -146,6 +136,7 @@ Writes are **disabled by default**. The protocol is not optional:
|
|
|
146
136
|
- **General health** → `sparda doctor` checks Node version, framework detection,
|
|
147
137
|
manifest validity, the semantic/immune cache, host reachability, and quarantine;
|
|
148
138
|
it exits non-zero so it can gate CI.
|
|
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.
|
|
149
140
|
- **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.
|
|
150
141
|
- **Learn exemplars** → Start your live app and run `sparda twin --learn` to fetch actual response data and construct `.sparda/twin.json` locally.
|
|
151
142
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// commands/timeless.js — production bugs become unit tests.
|
|
2
|
+
// sparda timeless list recorded flights
|
|
3
|
+
// sparda timeless replay <id> re-fly the request against current code
|
|
4
|
+
// sparda timeless export <id> generate a vitest test that replays it
|
|
5
|
+
// Recording side: mount the recorder in the app (2 lines, documented in the
|
|
6
|
+
// README) — `const flight = createFlightBox(); app.use(flight.middleware())`.
|
|
7
|
+
// Replay imports the real entry file with `listen()` suppressed, feeds every
|
|
8
|
+
// recorded tap back in strict order, and compares the response byte-for-byte.
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import http from 'node:http';
|
|
12
|
+
import { pathToFileURL } from 'node:url';
|
|
13
|
+
import { detectStack } from '../detect.js';
|
|
14
|
+
import { getFlightBox } from '../flight/box.js';
|
|
15
|
+
import { replayFlight } from '../flight/replayer.js';
|
|
16
|
+
import { listFlights, loadFlight } from '../flight/format.js';
|
|
17
|
+
import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
|
|
18
|
+
|
|
19
|
+
const err = (message, hint) => Object.assign(new Error(message), { code: 'USER', hint });
|
|
20
|
+
|
|
21
|
+
export async function runTimeless(opts, args) {
|
|
22
|
+
const [sub, id] = args.filter((a) => !a.startsWith('--'));
|
|
23
|
+
|
|
24
|
+
if (!sub || sub === 'list') return list(opts);
|
|
25
|
+
if (sub === 'replay') return replay(opts, requireId(id));
|
|
26
|
+
if (sub === 'export') return exportTest(opts, requireId(id));
|
|
27
|
+
throw err(
|
|
28
|
+
`unknown timeless subcommand "${sub}"`,
|
|
29
|
+
'use: list | replay <id> | export <id>',
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const requireId = (id) => {
|
|
34
|
+
if (!id) throw err('flight id required', 'run `sparda timeless` to list flights');
|
|
35
|
+
return id;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function list(opts) {
|
|
39
|
+
const flights = listFlights(opts.cwd);
|
|
40
|
+
if (!flights.length) {
|
|
41
|
+
console.log(
|
|
42
|
+
'No flights recorded. Mount the recorder in your app (ESM):\n' +
|
|
43
|
+
" import { getFlightBox } from 'sparda-mcp/src/flight/box.js';\n" +
|
|
44
|
+
' const box = getFlightBox(); box.arm();\n' +
|
|
45
|
+
' app.use(box.middleware());\n' +
|
|
46
|
+
' const db = box.wrapClient(rawDb); // your query client\n' +
|
|
47
|
+
" (CJS: const { getFlightBox } = await import('sparda-mcp/src/flight/box.js'))",
|
|
48
|
+
);
|
|
49
|
+
return { flights };
|
|
50
|
+
}
|
|
51
|
+
console.log(`${flights.length} flight(s) in .sparda/flight/`);
|
|
52
|
+
for (const f of flights) {
|
|
53
|
+
const { request, response } = loadFlight(opts.cwd, f);
|
|
54
|
+
console.log(
|
|
55
|
+
` ${f} ${request.method} ${request.url} → ${response.status} (${response.body.length}b, ${loadFlight(opts.cwd, f).taps.length} taps)`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return { flights };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function replay(opts, id) {
|
|
62
|
+
const flight = loadFlight(opts.cwd, id);
|
|
63
|
+
// the SHARED box: if the app integrates the recorder, its wrapClient rides
|
|
64
|
+
// the same AsyncLocalStorage this replay store lands on — load it BEFORE
|
|
65
|
+
// the app so both resolve the singleton
|
|
66
|
+
const box = getFlightBox();
|
|
67
|
+
const app = await loadApp(opts.cwd);
|
|
68
|
+
box.arm();
|
|
69
|
+
let result;
|
|
70
|
+
try {
|
|
71
|
+
result = await replayFlight(app, flight, box);
|
|
72
|
+
} finally {
|
|
73
|
+
box.disarm();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
console.log(
|
|
77
|
+
`TIMELESS — replaying ${flight.request.method} ${flight.request.url} (${flight.taps.length} taps virtualized)`,
|
|
78
|
+
);
|
|
79
|
+
if (result.match) {
|
|
80
|
+
console.log(
|
|
81
|
+
`✓ byte-identical replay — status ${result.actual.status}, every tap consumed, zero divergence`,
|
|
82
|
+
);
|
|
83
|
+
} else {
|
|
84
|
+
if (!result.statusMatch)
|
|
85
|
+
console.log(
|
|
86
|
+
` ✗ status: expected ${result.expected.status}, got ${result.actual.status}`,
|
|
87
|
+
);
|
|
88
|
+
if (!result.bodyMatch)
|
|
89
|
+
console.log(
|
|
90
|
+
` ✗ body differs\n expected: ${result.expected.body.slice(0, 200)}\n actual: ${result.actual.body.slice(0, 200)}`,
|
|
91
|
+
);
|
|
92
|
+
for (const d of result.divergences)
|
|
93
|
+
console.log(
|
|
94
|
+
` ✗ divergence: expected ${d.kind} "${d.expected}", code asked "${d.got}"`,
|
|
95
|
+
);
|
|
96
|
+
for (const l of result.leftover)
|
|
97
|
+
console.log(
|
|
98
|
+
` ✗ ${l.unconsumed} unconsumed ${l.kind} tap(s) — the code skipped step(s)`,
|
|
99
|
+
);
|
|
100
|
+
console.log(
|
|
101
|
+
'✗ NOT identical — the code no longer does what it did during the flight',
|
|
102
|
+
);
|
|
103
|
+
process.exitCode = 1;
|
|
104
|
+
}
|
|
105
|
+
return { result };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function exportTest(opts, id) {
|
|
109
|
+
const flight = loadFlight(opts.cwd, id);
|
|
110
|
+
const stack = detectStack(opts.cwd);
|
|
111
|
+
if (stack.framework !== 'express')
|
|
112
|
+
throw err(
|
|
113
|
+
'timeless export supports Express apps in v1',
|
|
114
|
+
'FastAPI replay is a later round',
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const testDir = path.join(opts.cwd, 'tests');
|
|
118
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
119
|
+
const testFile = path.join(testDir, `flight-${id}.test.js`);
|
|
120
|
+
const relEntry = path
|
|
121
|
+
.relative(testDir, path.resolve(opts.cwd, stack.entryFile))
|
|
122
|
+
.split(path.sep)
|
|
123
|
+
.join('/');
|
|
124
|
+
|
|
125
|
+
atomicWrite(
|
|
126
|
+
testFile,
|
|
127
|
+
`// flight-${id}.test.js — generated by \`sparda timeless export ${id}\`
|
|
128
|
+
// Replays a recorded production request against the current code. A failure
|
|
129
|
+
// means the behavior changed: if the change is the intended fix, re-record
|
|
130
|
+
// the flight and re-export — this file asserts the PAST, on purpose.
|
|
131
|
+
import { describe, it, expect } from 'vitest';
|
|
132
|
+
import { getFlightBox } from 'sparda-mcp/src/flight/box.js';
|
|
133
|
+
import { replayFlight } from 'sparda-mcp/src/flight/replayer.js';
|
|
134
|
+
import { loadFlight } from 'sparda-mcp/src/flight/format.js';
|
|
135
|
+
import appModule from '${relEntry.startsWith('.') ? relEntry : './' + relEntry}';
|
|
136
|
+
|
|
137
|
+
describe('flight ${id} — ${flight.request.method} ${flight.request.url}', () => {
|
|
138
|
+
it('replays byte-identically', async () => {
|
|
139
|
+
const app = appModule.default ?? appModule;
|
|
140
|
+
const flight = loadFlight(process.cwd(), '${id}');
|
|
141
|
+
// the SHARED box — the app's own wrapClient must see the replay store
|
|
142
|
+
const box = getFlightBox();
|
|
143
|
+
box.arm();
|
|
144
|
+
try {
|
|
145
|
+
const result = await replayFlight(app, flight, box);
|
|
146
|
+
expect(result.divergences).toEqual([]);
|
|
147
|
+
expect(result.actual.status).toBe(result.expected.status);
|
|
148
|
+
expect(result.bodyMatch).toBe(true);
|
|
149
|
+
expect(result.leftover).toEqual([]);
|
|
150
|
+
} finally {
|
|
151
|
+
box.disarm();
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
`,
|
|
156
|
+
);
|
|
157
|
+
console.log(`✓ Test written: tests/flight-${id}.test.js — the bug is now a unit test`);
|
|
158
|
+
return { testFile };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// import the real entry with listen() suppressed — replay never binds ports
|
|
162
|
+
async function loadApp(cwd) {
|
|
163
|
+
const stack = detectStack(cwd);
|
|
164
|
+
if (stack.framework !== 'express')
|
|
165
|
+
throw err(
|
|
166
|
+
'timeless replay supports Express apps in v1',
|
|
167
|
+
'FastAPI replay is a later round',
|
|
168
|
+
);
|
|
169
|
+
const origListen = http.Server.prototype.listen;
|
|
170
|
+
http.Server.prototype.listen = function suppressedListen(...args) {
|
|
171
|
+
const cb = args.find((a) => typeof a === 'function');
|
|
172
|
+
if (cb) setImmediate(cb);
|
|
173
|
+
return this;
|
|
174
|
+
};
|
|
175
|
+
try {
|
|
176
|
+
const mod = await import(pathToFileURL(path.resolve(cwd, stack.entryFile)).href);
|
|
177
|
+
const app = mod.default ?? mod;
|
|
178
|
+
if (typeof app !== 'function')
|
|
179
|
+
throw err(
|
|
180
|
+
'the entry file does not export the Express app',
|
|
181
|
+
'add `module.exports = app` (CJS) or `export default app` (ESM) to your entry',
|
|
182
|
+
);
|
|
183
|
+
return app;
|
|
184
|
+
} finally {
|
|
185
|
+
http.Server.prototype.listen = origListen;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// commands/ubg.js — compile the codebase to its Unified Behavior Graph.
|
|
2
|
+
// The UBG is the IR every future SPARDA tool reads (time-travel debuggers,
|
|
3
|
+
// deploy provers, state-machine runtimes): compile once here, write passes
|
|
4
|
+
// there. Artifact: .sparda/ubg.json — regenerable, deterministic, never
|
|
5
|
+
// committed. `--json` prints the raw graph, `--out <file>` redirects it.
|
|
6
|
+
import { compileUBG } from '../ubg/compile.js';
|
|
7
|
+
|
|
8
|
+
export async function runUbg(opts) {
|
|
9
|
+
const { report, json, outPath } = compileUBG(opts.cwd, {
|
|
10
|
+
write: true,
|
|
11
|
+
out: opts.out,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
if (opts.json) {
|
|
15
|
+
console.log(json);
|
|
16
|
+
return { report };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { counts } = report;
|
|
20
|
+
console.log(
|
|
21
|
+
`✓ UBG compiled: ${rel(outPath, opts.cwd)} — ${counts.totalNodes} nodes, ${counts.totalEdges} edges`,
|
|
22
|
+
);
|
|
23
|
+
console.log(
|
|
24
|
+
` ${report.framework} · ${report.routes} routes · ${report.tables} SQL tables`,
|
|
25
|
+
);
|
|
26
|
+
console.log(` Nodes: ${fmtCounts(counts.nodes)}`);
|
|
27
|
+
console.log(` Edges: ${fmtCounts(counts.edges)}`);
|
|
28
|
+
|
|
29
|
+
for (const p of report.passes) {
|
|
30
|
+
if (p.pass === 'DeadPathElimination' && p.removed)
|
|
31
|
+
console.log(` ${p.pass}: ${p.removed} dead node(s) removed`);
|
|
32
|
+
else if (p.pass === 'StateMinimization' && p.merged)
|
|
33
|
+
console.log(` ${p.pass}: ${p.merged} logic block(s) merged`);
|
|
34
|
+
else if (p.pass === 'TypePropagation' && (p.resolved || p.entrypointsTyped))
|
|
35
|
+
console.log(
|
|
36
|
+
` ${p.pass}: ${p.entrypointsTyped} entrypoint return schema(s), ${p.resolved} type(s) resolved`,
|
|
37
|
+
);
|
|
38
|
+
else if (p.pass === 'EffectAlgebra' && p.classified)
|
|
39
|
+
console.log(
|
|
40
|
+
` ${p.pass}: ${p.classified} effect(s) classified, ${p.observable} observable`,
|
|
41
|
+
);
|
|
42
|
+
else if (p.pass === 'ConsistencyDomains' && Object.keys(p.domains ?? {}).length) {
|
|
43
|
+
const names = Object.keys(p.domains).sort();
|
|
44
|
+
console.log(
|
|
45
|
+
` ${p.pass}: ${names.length} domain(s) — ${names.join(', ')}${p.cycles.length ? ` (⚠ FK cycle: ${p.cycles.join(', ')})` : ''}`,
|
|
46
|
+
);
|
|
47
|
+
} else if (p.pass === 'CapabilityExtraction' && p.capabilities)
|
|
48
|
+
console.log(
|
|
49
|
+
` ${p.pass}: ${p.capabilities} capability(ies), ${p.guardsAnnotated} guard(s) annotated`,
|
|
50
|
+
);
|
|
51
|
+
else if (p.pass === 'ResourceLifetimes' && p.annotated)
|
|
52
|
+
console.log(
|
|
53
|
+
` ${p.pass}: ${p.annotated} state(s)${p.immortal.length ? ` — immortal: ${p.immortal.join(', ')}` : ''}`,
|
|
54
|
+
);
|
|
55
|
+
else if (p.pass === 'StateMachineInference' && p.machines)
|
|
56
|
+
console.log(` ${p.pass}: ${p.machines} machine(s) inferred`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (report.link.inferredTables.length)
|
|
60
|
+
console.log(
|
|
61
|
+
` ⚠ tables referenced in code but absent from .sql schemas: ${report.link.inferredTables.join(', ')}`,
|
|
62
|
+
);
|
|
63
|
+
if (report.skipped.length) {
|
|
64
|
+
console.log(` · ${report.skipped.length} construct(s) out of static reach:`);
|
|
65
|
+
for (const s of report.skipped.slice(0, opts.verbose ? 100 : 5))
|
|
66
|
+
console.log(` - ${s.reason}${s.file ? ` (${s.file})` : ''}`);
|
|
67
|
+
if (!opts.verbose && report.skipped.length > 5)
|
|
68
|
+
console.log(` … ${report.skipped.length - 5} more (--verbose)`);
|
|
69
|
+
}
|
|
70
|
+
return { report };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const fmtCounts = (obj) =>
|
|
74
|
+
Object.entries(obj)
|
|
75
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
76
|
+
.map(([k, v]) => `${k} ${v}`)
|
|
77
|
+
.join(' · ');
|
|
78
|
+
|
|
79
|
+
const rel = (abs, cwd) =>
|
|
80
|
+
abs.startsWith(cwd) ? abs.slice(cwd.length + 1).replaceAll('\\', '/') : abs;
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// flight/box.js — the FlightBox: one set of patches, two modes.
|
|
2
|
+
// In RECORD mode every nondeterminism point (fetch, Date.now, Math.random,
|
|
3
|
+
// crypto.randomUUID, wrapped db clients) passes through to reality and leaves
|
|
4
|
+
// a tap behind. In REPLAY mode the same points are fed the recorded values in
|
|
5
|
+
// strict FIFO order per kind — label-checked, fail-loud: any mismatch is a
|
|
6
|
+
// FlightDivergence, never a silent fuzzy match. Code OUTSIDE a request
|
|
7
|
+
// context is untouched (AsyncLocalStorage decides), so arming the box in
|
|
8
|
+
// production changes nothing for non-instrumented paths.
|
|
9
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
10
|
+
import { saveFlight } from './format.js';
|
|
11
|
+
|
|
12
|
+
const MAX_BODY_BYTES = 262144; // 256 KB per recorded http body — bounded flights
|
|
13
|
+
|
|
14
|
+
export class FlightDivergence extends Error {
|
|
15
|
+
constructor(message, detail = {}) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = 'FlightDivergence';
|
|
18
|
+
this.detail = detail;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createFlightBox() {
|
|
23
|
+
const als = new AsyncLocalStorage();
|
|
24
|
+
const originals = {};
|
|
25
|
+
let armed = false;
|
|
26
|
+
|
|
27
|
+
function arm() {
|
|
28
|
+
if (armed) return;
|
|
29
|
+
armed = true;
|
|
30
|
+
originals.fetch = globalThis.fetch;
|
|
31
|
+
originals.dateNow = Date.now;
|
|
32
|
+
originals.random = Math.random;
|
|
33
|
+
originals.randomUUID = globalThis.crypto?.randomUUID?.bind(globalThis.crypto);
|
|
34
|
+
|
|
35
|
+
Date.now = function spardaDateNow() {
|
|
36
|
+
const store = als.getStore();
|
|
37
|
+
if (!store) return originals.dateNow();
|
|
38
|
+
if (store.mode === 'record') {
|
|
39
|
+
const v = originals.dateNow();
|
|
40
|
+
tapOut(store, 'time', 'Date.now', v);
|
|
41
|
+
return v;
|
|
42
|
+
}
|
|
43
|
+
return takeTap(store, 'time', 'Date.now');
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
Math.random = function spardaRandom() {
|
|
47
|
+
const store = als.getStore();
|
|
48
|
+
if (!store) return originals.random();
|
|
49
|
+
if (store.mode === 'record') {
|
|
50
|
+
const v = originals.random();
|
|
51
|
+
tapOut(store, 'random', 'Math.random', v);
|
|
52
|
+
return v;
|
|
53
|
+
}
|
|
54
|
+
return takeTap(store, 'random', 'Math.random');
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
if (originals.randomUUID) {
|
|
58
|
+
Object.defineProperty(globalThis.crypto, 'randomUUID', {
|
|
59
|
+
configurable: true,
|
|
60
|
+
value: function spardaRandomUUID() {
|
|
61
|
+
const store = als.getStore();
|
|
62
|
+
if (!store) return originals.randomUUID();
|
|
63
|
+
if (store.mode === 'record') {
|
|
64
|
+
const v = originals.randomUUID();
|
|
65
|
+
tapOut(store, 'uuid', 'crypto.randomUUID', v);
|
|
66
|
+
return v;
|
|
67
|
+
}
|
|
68
|
+
return takeTap(store, 'uuid', 'crypto.randomUUID');
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
globalThis.fetch = async function spardaFetch(input, init) {
|
|
74
|
+
const store = als.getStore();
|
|
75
|
+
if (!store) return originals.fetch(input, init);
|
|
76
|
+
const url = typeof input === 'string' ? input : (input?.url ?? String(input));
|
|
77
|
+
const method = (init?.method ?? 'GET').toUpperCase();
|
|
78
|
+
const label = `${method} ${url}`;
|
|
79
|
+
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 });
|
|
85
|
+
}
|
|
86
|
+
const rec = takeTap(store, 'http', label);
|
|
87
|
+
return new Response(rec.body, { status: rec.status, headers: rec.headers });
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function disarm() {
|
|
92
|
+
if (!armed) return;
|
|
93
|
+
armed = false;
|
|
94
|
+
globalThis.fetch = originals.fetch;
|
|
95
|
+
Date.now = originals.dateNow;
|
|
96
|
+
Math.random = originals.random;
|
|
97
|
+
if (originals.randomUUID)
|
|
98
|
+
Object.defineProperty(globalThis.crypto, 'randomUUID', {
|
|
99
|
+
configurable: true,
|
|
100
|
+
value: originals.randomUUID,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// any client exposing .query()/.execute() — pg, mysql2, sqlite wrappers …
|
|
105
|
+
function wrapClient(client, name = 'db') {
|
|
106
|
+
const wrapMethod = (method) =>
|
|
107
|
+
async function spardaQuery(...args) {
|
|
108
|
+
const store = als.getStore();
|
|
109
|
+
if (!store) return client[method](...args);
|
|
110
|
+
const label = `${name}.${method}:${String(args[0]).slice(0, 60)}`;
|
|
111
|
+
if (store.mode === 'record') {
|
|
112
|
+
const result = await client[method](...args);
|
|
113
|
+
tapOut(store, 'db', label, jsonClone(result, label));
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
return takeTap(store, 'db', label);
|
|
117
|
+
};
|
|
118
|
+
return new Proxy(client, {
|
|
119
|
+
get(target, prop, receiver) {
|
|
120
|
+
if (
|
|
121
|
+
(prop === 'query' || prop === 'execute') &&
|
|
122
|
+
typeof target[prop] === 'function'
|
|
123
|
+
)
|
|
124
|
+
return wrapMethod(prop);
|
|
125
|
+
return Reflect.get(target, prop, receiver);
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Express middleware — mount early; the request's whole async tree records.
|
|
131
|
+
// The flight is finalized and written on response finish.
|
|
132
|
+
function middleware({ cwd = process.cwd(), onFlight = null } = {}) {
|
|
133
|
+
return function spardaFlightRecorder(req, res, next) {
|
|
134
|
+
// replay wins over record: when the timeless CLI replays through an app
|
|
135
|
+
// that mounts this recorder, the outer replay store must reach the taps
|
|
136
|
+
// untouched — re-recording a replay would shadow it with live values
|
|
137
|
+
const outer = als.getStore();
|
|
138
|
+
if (outer?.mode === 'replay') return next();
|
|
139
|
+
if (process.env.SPARDA_FLIGHT === 'off') return next();
|
|
140
|
+
const store = { mode: 'record', taps: [] };
|
|
141
|
+
const chunks = [];
|
|
142
|
+
const origWrite = res.write.bind(res);
|
|
143
|
+
const origEnd = res.end.bind(res);
|
|
144
|
+
res.write = (chunk, ...rest) => {
|
|
145
|
+
if (chunk) chunks.push(Buffer.from(chunk));
|
|
146
|
+
return origWrite(chunk, ...rest);
|
|
147
|
+
};
|
|
148
|
+
res.end = (chunk, ...rest) => {
|
|
149
|
+
if (chunk && typeof chunk !== 'function') chunks.push(Buffer.from(chunk));
|
|
150
|
+
return origEnd(chunk, ...rest);
|
|
151
|
+
};
|
|
152
|
+
res.on('finish', () => {
|
|
153
|
+
const flight = {
|
|
154
|
+
request: {
|
|
155
|
+
method: req.method,
|
|
156
|
+
url: req.originalUrl ?? req.url,
|
|
157
|
+
headers: { 'content-type': req.headers['content-type'] ?? '' },
|
|
158
|
+
body: req.body ?? null,
|
|
159
|
+
},
|
|
160
|
+
response: {
|
|
161
|
+
status: res.statusCode,
|
|
162
|
+
headers: { 'content-type': res.getHeader('content-type') ?? '' },
|
|
163
|
+
body: Buffer.concat(chunks).toString('utf8').slice(0, MAX_BODY_BYTES),
|
|
164
|
+
},
|
|
165
|
+
taps: store.taps,
|
|
166
|
+
};
|
|
167
|
+
const saved = saveFlight(cwd, flight);
|
|
168
|
+
if (onFlight) onFlight(saved, flight);
|
|
169
|
+
});
|
|
170
|
+
als.run(store, next);
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// replay plumbing — one store per replayed request
|
|
175
|
+
function makeReplayStore(flight) {
|
|
176
|
+
const queues = new Map();
|
|
177
|
+
for (const tap of flight.taps) {
|
|
178
|
+
if (!queues.has(tap.kind)) queues.set(tap.kind, []);
|
|
179
|
+
queues.get(tap.kind).push(tap);
|
|
180
|
+
}
|
|
181
|
+
return { mode: 'replay', queues, divergences: [] };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const runWith = (store, fn) => als.run(store, fn);
|
|
185
|
+
|
|
186
|
+
return { arm, disarm, wrapClient, middleware, makeReplayStore, runWith };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function tapOut(store, kind, label, result) {
|
|
190
|
+
store.taps.push({ seq: store.taps.length, kind, label, result });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// strict FIFO per kind, label-checked — the "quasi infallible" contract:
|
|
194
|
+
// the replayed code must ask reality the SAME questions in the SAME order
|
|
195
|
+
function takeTap(store, kind, label) {
|
|
196
|
+
const queue = store.queues.get(kind);
|
|
197
|
+
const tap = queue?.shift();
|
|
198
|
+
if (!tap) {
|
|
199
|
+
const d = { kind, expected: null, got: label };
|
|
200
|
+
store.divergences.push(d);
|
|
201
|
+
throw new FlightDivergence(
|
|
202
|
+
`replay diverged: code asked for ${kind} "${label}" but the flight has no more ${kind} taps`,
|
|
203
|
+
d,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
if (tap.label !== label) {
|
|
207
|
+
const d = { kind, expected: tap.label, got: label };
|
|
208
|
+
store.divergences.push(d);
|
|
209
|
+
throw new FlightDivergence(
|
|
210
|
+
`replay diverged: flight expected ${kind} "${tap.label}", code asked for "${label}"`,
|
|
211
|
+
d,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
return tap.result;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function jsonClone(value, label) {
|
|
218
|
+
try {
|
|
219
|
+
const s = JSON.stringify(value);
|
|
220
|
+
if (s === undefined) throw new Error('undefined');
|
|
221
|
+
return JSON.parse(s);
|
|
222
|
+
} catch {
|
|
223
|
+
// honesty over convenience: an unserializable tap cannot replay — record
|
|
224
|
+
// the fact so replay fails loudly instead of returning garbage
|
|
225
|
+
throw new FlightDivergence(`tap "${label}" returned an unserializable value`, {
|
|
226
|
+
label,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// Process singleton — THE box for app integration and the timeless CLI.
|
|
233
|
+
// The app's wrapClient and the replayer must share ONE AsyncLocalStorage or
|
|
234
|
+
// replay stores are invisible to the app's taps (silent passthrough to the
|
|
235
|
+
// real db — the exact failure mode this singleton exists to kill). Module
|
|
236
|
+
// caching guarantees identity as long as both sides resolve the same
|
|
237
|
+
// sparda-mcp install, which `npx sparda` inside the app's project does.
|
|
238
|
+
// createFlightBox() stays exported for isolated tests.
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
let singleton = null;
|
|
241
|
+
|
|
242
|
+
export function getFlightBox() {
|
|
243
|
+
if (!singleton) singleton = createFlightBox();
|
|
244
|
+
return singleton;
|
|
245
|
+
}
|