sparda-mcp 0.9.0 → 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 CHANGED
@@ -84,6 +84,30 @@ To output raw JSON for integration:
84
84
  npx sparda-mcp report --json
85
85
  ```
86
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
+
87
111
  To undo everything: **`npx sparda-mcp remove`** restores your code byte-for-byte.
88
112
 
89
113
  ## The promise — every word is backed by a test in CI
package/SKILL.md CHANGED
@@ -1,40 +1,30 @@
1
1
  ---
2
2
  name: sparda-mcp
3
3
  description: >-
4
- Drive a SPARDA-generated MCP server to its full potential. Use this whenever
5
- you are connected to an MCP server built by SPARDA (sparda-mcp) recognizable
6
- by the tools `sparda_get_context`, `sparda_info`, `sparda_confirm`, or composite
7
- tools labelled "[Labs circuit]". It teaches the call-context-first workflow, how
8
- to exploit the response-recycling flywheel, the circuit-breaker, crystallized
9
- circuits and adaptive immunity, and the mandatory two-phase confirm protocol for
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 MCP server
12
+ # Driving a SPARDA Behavior Runtime
15
13
 
16
- A SPARDA server is **not** a flat list of HTTP endpoints. SPARDA injected a live
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 enabled tools + their descriptions, and SPARDA's suggested **workflows**;
30
- - `runtime` — current `/mcp/stats` (per-tool call/error counts, tool "purity",
31
- quarantine state);
32
- - the last ~20 **events** (errors, latency anomalies, immune diagnoses);
33
- - **immune memory** (known failure signatures + cached fixes);
34
- - **recycling** stats (how many calls were served from memory);
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 before acting. `sparda_info` gives a lighter summary if you only need counts.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
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,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
+ }
@@ -44,7 +44,16 @@ export async function runUbg(opts) {
44
44
  console.log(
45
45
  ` ${p.pass}: ${names.length} domain(s) — ${names.join(', ')}${p.cycles.length ? ` (⚠ FK cycle: ${p.cycles.join(', ')})` : ''}`,
46
46
  );
47
- }
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`);
48
57
  }
49
58
 
50
59
  if (report.link.inferredTables.length)
@@ -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
+ }
@@ -0,0 +1,71 @@
1
+ // flight/format.js — the flight file: one production request, replayable.
2
+ // A flight is the ENTIRE nondeterminism of one request: the input, the final
3
+ // response, and every tap (db, http, time, random, uuid) in execution order.
4
+ // Identity is content: id = sha256 of the canonical JSON — no timestamps, no
5
+ // counters, so the same request recorded twice on two machines is the same
6
+ // flight. Everything between the taps is deterministic code; that is the
7
+ // compiler's guarantee (SBIR §2.8) and the whole reason this file can exist.
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import crypto from 'node:crypto';
11
+ import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
12
+
13
+ export const FLIGHT_VERSION = 'sparda-flight/v1';
14
+
15
+ export function canonicalJson(value) {
16
+ return JSON.stringify(sortKeysDeep(value), null, 2) + '\n';
17
+ }
18
+
19
+ function sortKeysDeep(value) {
20
+ if (Array.isArray(value)) return value.map(sortKeysDeep); // tap ORDER is meaning
21
+ if (value && typeof value === 'object') {
22
+ const out = {};
23
+ for (const k of Object.keys(value).sort()) out[k] = sortKeysDeep(value[k]);
24
+ return out;
25
+ }
26
+ return value;
27
+ }
28
+
29
+ export function flightIdOf(flight) {
30
+ const { id: _drop, ...body } = flight;
31
+ return crypto
32
+ .createHash('sha256')
33
+ .update(canonicalJson(body))
34
+ .digest('hex')
35
+ .slice(0, 16);
36
+ }
37
+
38
+ export function flightDir(cwd) {
39
+ return path.join(cwd, '.sparda', 'flight');
40
+ }
41
+
42
+ export function saveFlight(cwd, flight) {
43
+ const body = { version: FLIGHT_VERSION, ...flight };
44
+ const id = flightIdOf(body);
45
+ const dir = flightDir(cwd);
46
+ fs.mkdirSync(dir, { recursive: true });
47
+ const file = path.join(dir, `${id}.json`);
48
+ atomicWrite(file, canonicalJson({ ...body, id }));
49
+ return { id, file };
50
+ }
51
+
52
+ export function loadFlight(cwd, id) {
53
+ const file = path.join(flightDir(cwd), `${id}.json`);
54
+ const flight = JSON.parse(fs.readFileSync(file, 'utf8'));
55
+ if (flight.version !== FLIGHT_VERSION)
56
+ throw Object.assign(
57
+ new Error(`flight ${id} has version ${flight.version}, expected ${FLIGHT_VERSION}`),
58
+ { code: 'USER' },
59
+ );
60
+ return flight;
61
+ }
62
+
63
+ export function listFlights(cwd) {
64
+ const dir = flightDir(cwd);
65
+ if (!fs.existsSync(dir)) return [];
66
+ return fs
67
+ .readdirSync(dir)
68
+ .filter((f) => f.endsWith('.json'))
69
+ .map((f) => f.slice(0, -5))
70
+ .sort();
71
+ }
@@ -0,0 +1,97 @@
1
+ // flight/replayer.js — re-fly one recorded request against the real code.
2
+ // The app runs in-process on an ephemeral port; every nondeterminism point is
3
+ // fed the recorded value by the FlightBox; the response must come out
4
+ // byte-equal (JSON-canonical when both sides parse as JSON). The verdict is
5
+ // three-part and all three must hold for a match:
6
+ // 1. the response is identical,
7
+ // 2. no divergence was raised (same questions, same order),
8
+ // 3. every tap was consumed (the code didn't silently skip a step).
9
+ import http from 'node:http';
10
+
11
+ export async function replayFlight(app, flight, box) {
12
+ const store = box.makeReplayStore(flight);
13
+
14
+ const server = http.createServer((req, res) => {
15
+ box.runWith(store, () => app(req, res));
16
+ });
17
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
18
+ const { port } = server.address();
19
+
20
+ let actual;
21
+ try {
22
+ const { request } = flight;
23
+ const hasBody = request.body !== null && request.method !== 'GET';
24
+ // raw http.request on purpose: the box may have global fetch patched, the
25
+ // replayer's own client must be immune to every virtualization layer
26
+ actual = await rawRequest({
27
+ port,
28
+ method: request.method,
29
+ path: request.url,
30
+ headers: hasBody
31
+ ? { 'content-type': request.headers['content-type'] || 'application/json' }
32
+ : {},
33
+ body: hasBody ? JSON.stringify(request.body) : null,
34
+ });
35
+ } finally {
36
+ await new Promise((resolve) => server.close(resolve));
37
+ }
38
+
39
+ const expected = { status: flight.response.status, body: flight.response.body };
40
+ const bodyMatch = jsonOrRawEqual(expected.body, actual.body);
41
+ const statusMatch = expected.status === actual.status;
42
+ const leftover = [...store.queues.entries()]
43
+ .filter(([, q]) => q.length)
44
+ .map(([kind, q]) => ({ kind, unconsumed: q.length }));
45
+
46
+ return {
47
+ match:
48
+ statusMatch && bodyMatch && store.divergences.length === 0 && leftover.length === 0,
49
+ statusMatch,
50
+ bodyMatch,
51
+ expected,
52
+ actual,
53
+ divergences: store.divergences,
54
+ leftover,
55
+ };
56
+ }
57
+
58
+ export function rawRequest({ port, method, path, headers = {}, body = null }) {
59
+ return new Promise((resolve, reject) => {
60
+ const req = http.request(
61
+ { host: '127.0.0.1', port, method, path, headers },
62
+ (res) => {
63
+ const chunks = [];
64
+ res.on('data', (c) => chunks.push(c));
65
+ res.on('end', () =>
66
+ resolve({
67
+ status: res.statusCode,
68
+ body: Buffer.concat(chunks).toString('utf8'),
69
+ }),
70
+ );
71
+ },
72
+ );
73
+ req.on('error', reject);
74
+ if (body) req.write(body);
75
+ req.end();
76
+ });
77
+ }
78
+
79
+ // byte-equality with one mercy: JSON key order is not meaning
80
+ function jsonOrRawEqual(a, b) {
81
+ if (a === b) return true;
82
+ try {
83
+ return canonical(JSON.parse(a)) === canonical(JSON.parse(b));
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ function canonical(v) {
90
+ if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
91
+ if (v && typeof v === 'object')
92
+ return `{${Object.keys(v)
93
+ .sort()
94
+ .map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`)
95
+ .join(',')}}`;
96
+ return JSON.stringify(v);
97
+ }
package/src/index.js CHANGED
@@ -15,6 +15,7 @@ const getOpt = (name, dflt) => {
15
15
 
16
16
  const opts = {
17
17
  yes: flags.has('--yes') || flags.has('-y'),
18
+ saveBaseline: flags.has('--save-baseline'),
18
19
  verbose: flags.has('--verbose'),
19
20
  quiet: flags.has('--quiet'),
20
21
  probe: flags.has('--probe'),
@@ -99,6 +100,16 @@ try {
99
100
  await runUbg(opts);
100
101
  break;
101
102
  }
103
+ case 'apocalypse': {
104
+ const { runApocalypse } = await import('./commands/apocalypse.js');
105
+ await runApocalypse(opts);
106
+ break;
107
+ }
108
+ case 'timeless': {
109
+ const { runTimeless } = await import('./commands/timeless.js');
110
+ await runTimeless(opts, rest);
111
+ break;
112
+ }
102
113
  default:
103
114
  console.log(`SPARDA v${VERSION} — Turn any codebase into an MCP server.
104
115
 
@@ -116,6 +127,8 @@ Usage:
116
127
  npx sparda-mcp grammar Which call sequences mean something (observed + hypotheses)
117
128
  npx sparda-mcp evolve Trial hypothesis chains against the twin; survivors become suggestions
118
129
  npx sparda-mcp ubg Compile the codebase to its Unified Behavior Graph (.sparda/ubg.json)
130
+ npx sparda-mcp apocalypse Prove the deploy: guards, invariants, transactions, aggregates (--save-baseline)
131
+ npx sparda-mcp timeless Replay production requests (list | replay <id> | export <id> → vitest)
119
132
 
120
133
  Flags: --yes (skip prompts) --port <n> --quiet --verbose
121
134
  --probe (init: also run the app to discover dynamic routes the AST missed)
@@ -243,6 +243,21 @@ function visit(node, out, ctx) {
243
243
  return;
244
244
  }
245
245
 
246
+ // `new Date()` — wall-clock read: an entropy effect the flight recorder
247
+ // must tap for deterministic replay (SBIR v1.2 / Timeless)
248
+ if (
249
+ node.type === 'NewExpression' &&
250
+ node.callee?.type === 'Identifier' &&
251
+ node.callee.name === 'Date' &&
252
+ node.arguments.length === 0
253
+ ) {
254
+ pushEffect(out, ctx, {
255
+ effectType: 'entropy',
256
+ target: 'time',
257
+ line: node.loc?.start.line ?? 0,
258
+ });
259
+ }
260
+
246
261
  if (node.type === 'CallExpression') {
247
262
  // transaction scope: db.transaction(cb) / prisma.$transaction(...) — the
248
263
  // innermost scope wins; isolation only from a string literal, never guessed
@@ -319,6 +334,10 @@ function inspectCall(node, out, ctx) {
319
334
  });
320
335
  return;
321
336
  }
337
+ if (/^(uuid|uuidv4|nanoid|randomuuid|ulid)$/i.test(callee.name)) {
338
+ pushEffect(out, ctx, { effectType: 'entropy', target: 'uuid', line });
339
+ return;
340
+ }
322
341
  if (out.calls.length < MAX_CALLS) out.calls.push({ name: callee.name, line });
323
342
  return;
324
343
  }
@@ -328,6 +347,20 @@ function inspectCall(node, out, ctx) {
328
347
  const methodLower = method.toLowerCase();
329
348
  const rootName = rootIdentifier(callee);
330
349
 
350
+ // ---- entropy: nondeterminism points the replayer must virtualize
351
+ if (rootName === 'Date' && methodLower === 'now') {
352
+ pushEffect(out, ctx, { effectType: 'entropy', target: 'time', line });
353
+ return;
354
+ }
355
+ if (rootName === 'Math' && methodLower === 'random') {
356
+ pushEffect(out, ctx, { effectType: 'entropy', target: 'random', line });
357
+ return;
358
+ }
359
+ if (/^crypto$/i.test(rootName ?? '') && methodLower === 'randomuuid') {
360
+ pushEffect(out, ctx, { effectType: 'entropy', target: 'uuid', line });
361
+ return;
362
+ }
363
+
331
364
  // ---- input validation signal (SBIR §2.1): zod-style .safeParse / Schema.parse
332
365
  if (
333
366
  methodLower === 'safeparse' ||
@@ -585,7 +618,11 @@ function builderTableOf(node) {
585
618
  return null;
586
619
  }
587
620
 
588
- // 'INSERT INTO users (a) VALUES (1)' → { effectType, op, table }
621
+ // 'INSERT INTO users (a) VALUES (1)' → { effectType, op, table, }
622
+ // Literal column values are also harvested (SET x = 'v', WHERE x = 'v',
623
+ // INSERT (…) VALUES (…)) — the raw material StateMachineInference reads.
624
+ // Everything is lowercased: deterministic, and SQL identifiers are
625
+ // case-insensitive anyway (literal VALUES lose case — documented trade-off).
589
626
  export function parseSqlCall(sql) {
590
627
  const s = sql.trim().toLowerCase();
591
628
  const verb = s.split(/\s+/)[0];
@@ -598,7 +635,47 @@ export function parseSqlCall(sql) {
598
635
  else if (verb === 'delete') m = s.match(/delete\s+from\s+"?([\w.]+)"?/);
599
636
  else m = s.match(/\bfrom\s+"?([\w.]+)"?/);
600
637
  if (m) table = m[1].includes('.') ? m[1].split('.').pop() : m[1];
601
- return { ...known, table };
638
+
639
+ const details = {};
640
+ if (verb === 'update') {
641
+ const setM = s.match(/\bset\s+([\s\S]*?)(?:\s+where\s|$)/);
642
+ if (setM) {
643
+ const sets = literalPairsOf(setM[1]);
644
+ if (Object.keys(sets).length) details.sets = sets;
645
+ }
646
+ }
647
+ if (verb === 'update' || verb === 'delete' || verb === 'select') {
648
+ const whereM = s.match(/\bwhere\s+([\s\S]*)$/);
649
+ if (whereM) {
650
+ const where = literalPairsOf(whereM[1]);
651
+ if (Object.keys(where).length) details.where = where;
652
+ }
653
+ }
654
+ if (verb === 'insert') {
655
+ const im = s.match(/\(([^)]*)\)\s*values\s*\(([^)]*)\)/);
656
+ if (im) {
657
+ const cols = im[1].split(',').map((c) => c.trim().replace(/"/g, ''));
658
+ const vals = im[2].split(',').map((v) => v.trim());
659
+ const inserts = {};
660
+ cols.forEach((c, i) => {
661
+ const q = vals[i]?.match(/^'([^']*)'$/);
662
+ if (q) inserts[c] = q[1];
663
+ });
664
+ if (Object.keys(inserts).length) details.inserts = inserts;
665
+ }
666
+ }
667
+ return { ...known, table, ...details };
668
+ }
669
+
670
+ // "status = 'paid', total = 3" → { status: 'paid' } — string literals only,
671
+ // bounded; placeholders and expressions are invisible on purpose
672
+ function literalPairsOf(clause) {
673
+ const pairs = {};
674
+ for (const m of clause.matchAll(/([\w"]+)\s*=\s*'([^']*)'/g)) {
675
+ if (Object.keys(pairs).length >= 8) break;
676
+ pairs[m[1].replace(/"/g, '')] = m[2];
677
+ }
678
+ return pairs;
602
679
  }
603
680
 
604
681
  // middleware classifier: name smell OR observed 401/403 denial → guard
@@ -44,6 +44,16 @@ def root_name(node):
44
44
  return None
45
45
 
46
46
 
47
+ def literal_pairs_of(clause):
48
+ """"status = 'paid', x = 3" -> {status: paid} — string literals only."""
49
+ pairs = {}
50
+ for m in re.finditer(r"([\w\"]+)\s*=\s*'([^']*)'", clause):
51
+ if len(pairs) >= 8:
52
+ break
53
+ pairs[m.group(1).replace('"', "")] = m.group(2)
54
+ return pairs
55
+
56
+
47
57
  def parse_sql(sql):
48
58
  s = sql.strip().lower()
49
59
  verb = s.split()[0] if s.split() else ""
@@ -62,7 +72,35 @@ def parse_sql(sql):
62
72
  table = None
63
73
  if m:
64
74
  table = m.group(1).split(".")[-1]
65
- return {"effectType": effect_type, "op": op, "table": table}
75
+ effect = {"effectType": effect_type, "op": op, "table": table}
76
+
77
+ # literal column values — StateMachineInference raw material (SBIR v1.2)
78
+ if verb == "update":
79
+ set_m = re.search(r"\bset\s+([\s\S]*?)(?:\s+where\s|$)", s)
80
+ if set_m:
81
+ sets = literal_pairs_of(set_m.group(1))
82
+ if sets:
83
+ effect["sets"] = sets
84
+ if verb in ("update", "delete", "select"):
85
+ where_m = re.search(r"\bwhere\s+([\s\S]*)$", s)
86
+ if where_m:
87
+ where = literal_pairs_of(where_m.group(1))
88
+ if where:
89
+ effect["where"] = where
90
+ if verb == "insert":
91
+ im = re.search(r"\(([^)]*)\)\s*values\s*\(([^)]*)\)", s)
92
+ if im:
93
+ cols = [c.strip().replace('"', "") for c in im.group(1).split(",")]
94
+ vals = [v.strip() for v in im.group(2).split(",")]
95
+ inserts = {}
96
+ for i, col in enumerate(cols):
97
+ if i < len(vals):
98
+ q = re.match(r"^'([^']*)'$", vals[i])
99
+ if q:
100
+ inserts[col] = q.group(1)
101
+ if inserts:
102
+ effect["inserts"] = inserts
103
+ return effect
66
104
 
67
105
 
68
106
  def sql_literal_of(arg):
@@ -270,6 +308,20 @@ def inspect_call(node, out, ctx):
270
308
  method = func.attr
271
309
  root = root_name(func)
272
310
 
311
+ # entropy: nondeterminism points the flight replayer must virtualize
312
+ if root == "datetime" and method in ("now", "utcnow", "today"):
313
+ push_effect(out, ctx, {"effectType": "entropy", "target": "time", "line": line})
314
+ return
315
+ if root == "time" and method in ("time", "monotonic", "time_ns"):
316
+ push_effect(out, ctx, {"effectType": "entropy", "target": "time", "line": line})
317
+ return
318
+ if root == "random":
319
+ push_effect(out, ctx, {"effectType": "entropy", "target": "random", "line": line})
320
+ return
321
+ if root == "uuid" and method.startswith("uuid"):
322
+ push_effect(out, ctx, {"effectType": "entropy", "target": "uuid", "line": line})
323
+ return
324
+
273
325
  # input validation signal (SBIR §2.1): explicit Pydantic validation calls
274
326
  if method in ("model_validate", "model_validate_json", "parse_obj", "parse_raw"):
275
327
  out["validatesInput"] = True
@@ -0,0 +1,73 @@
1
+ // ubg/passes/capabilities.js — pass 6 (SBIR v1.2 §2.5).
2
+ // "POST /transfer" is a route; "update:users + insert:orders" is what it can
3
+ // DO. Capabilities are derived, never declared: the sorted set of verbs an
4
+ // entrypoint's reachable effects perform on named resources. Guards then
5
+ // learn what they protect — the union of the capabilities behind them. This
6
+ // is the vocabulary a permission auditor or an AI-agent policy layer reads
7
+ // instead of guessing from URL strings. Annotation-only.
8
+ import { reachabilityOf } from '../reach.js';
9
+
10
+ export const name = 'CapabilityExtraction';
11
+
12
+ export function run(graph) {
13
+ const reach = reachabilityOf(graph);
14
+ const mutOut = new Map();
15
+ const readIn = new Map(); // effect id -> state ids read
16
+ for (const e of graph.edges) {
17
+ if (e.kind === 'mutation') {
18
+ if (!mutOut.has(e.from)) mutOut.set(e.from, []);
19
+ mutOut.get(e.from).push(e);
20
+ }
21
+ if (e.kind === 'data_flow' && graph.nodes.get(e.from)?.kind === 'state') {
22
+ if (!readIn.has(e.to)) readIn.set(e.to, []);
23
+ readIn.get(e.to).push(e.from);
24
+ }
25
+ }
26
+
27
+ const all = new Set();
28
+ const guardProtects = new Map();
29
+
30
+ for (const [epId, reached] of reach) {
31
+ const ep = graph.nodes.get(epId);
32
+ const caps = new Set();
33
+ for (const id of [...reached].sort()) {
34
+ const node = graph.nodes.get(id);
35
+ if (node?.kind !== 'effect') continue;
36
+ for (const m of mutOut.get(id) ?? []) {
37
+ const table = graph.nodes.get(m.to)?.meta.table ?? 'unknown';
38
+ caps.add(`${m.meta.op ?? 'write'}:${table}`);
39
+ }
40
+ for (const sid of readIn.get(id) ?? []) {
41
+ caps.add(`read:${graph.nodes.get(sid)?.meta.table ?? 'unknown'}`);
42
+ }
43
+ if (node.meta.effectType === 'http_call')
44
+ caps.add(`call:${hostOf(node.meta.target)}`);
45
+ if (node.meta.effectType === 'fs_write') caps.add('fs:write');
46
+ if (node.meta.effectType === 'fs_read') caps.add('fs:read');
47
+ }
48
+ if (caps.size) {
49
+ ep.meta.capabilities = [...caps].sort();
50
+ for (const c of caps) all.add(c);
51
+ }
52
+ // a guard on this route protects everything the route can do
53
+ for (const id of reached) {
54
+ if (graph.nodes.get(id)?.kind !== 'guard') continue;
55
+ if (!guardProtects.has(id)) guardProtects.set(id, new Set());
56
+ for (const c of caps) guardProtects.get(id).add(c);
57
+ }
58
+ }
59
+
60
+ for (const [guardId, caps] of guardProtects) {
61
+ graph.nodes.get(guardId).meta.protects = [...caps].sort();
62
+ }
63
+
64
+ return { capabilities: all.size, guardsAnnotated: guardProtects.size };
65
+ }
66
+
67
+ function hostOf(target) {
68
+ try {
69
+ return new URL(target).host;
70
+ } catch {
71
+ return 'dynamic';
72
+ }
73
+ }
@@ -35,6 +35,11 @@ export function run(graph) {
35
35
  observable = false;
36
36
  compensable = true; // nothing to undo
37
37
  break;
38
+ case 'entropy': // reads the world's dice — different every call
39
+ idempotent = false;
40
+ observable = false;
41
+ compensable = true;
42
+ break;
38
43
  case 'db_write':
39
44
  idempotent = IDEMPOTENT_DB_OPS.has(m.op);
40
45
  observable = false;
@@ -0,0 +1,73 @@
1
+ // ubg/passes/resource-lifetimes.js — pass 7 (SBIR v1.2 §2.6).
2
+ // Every state answers four questions: who creates me, who updates me, who
3
+ // destroys me, who reads me — as sorted entrypoint lists derived from the
4
+ // mutation/data_flow edges. The report flags two smells worth a human eye:
5
+ // immortal resources (created, never destroyed — unbounded growth) and
6
+ // unmanaged ones (destroyed here, created elsewhere — split ownership).
7
+ // Annotation-only.
8
+ import { reachabilityOf } from '../reach.js';
9
+
10
+ export const name = 'ResourceLifetimes';
11
+
12
+ const OP_BUCKET = {
13
+ insert: 'createdBy',
14
+ update: 'updatedBy',
15
+ upsert: 'updatedBy',
16
+ delete: 'destroyedBy',
17
+ };
18
+
19
+ export function run(graph) {
20
+ const reach = reachabilityOf(graph);
21
+
22
+ // effect id -> entrypoints that can reach it
23
+ const epsOfEffect = new Map();
24
+ for (const [epId, reached] of reach) {
25
+ for (const id of reached) {
26
+ if (graph.nodes.get(id)?.kind !== 'effect') continue;
27
+ if (!epsOfEffect.has(id)) epsOfEffect.set(id, new Set());
28
+ epsOfEffect.get(id).add(epId);
29
+ }
30
+ }
31
+
32
+ const lifetimes = new Map(); // state id -> buckets
33
+ const bucketsFor = (sid) => {
34
+ if (!lifetimes.has(sid))
35
+ lifetimes.set(sid, {
36
+ createdBy: new Set(),
37
+ updatedBy: new Set(),
38
+ destroyedBy: new Set(),
39
+ readBy: new Set(),
40
+ });
41
+ return lifetimes.get(sid);
42
+ };
43
+
44
+ for (const e of graph.edges) {
45
+ if (e.kind === 'mutation') {
46
+ const bucket = OP_BUCKET[e.meta.op] ?? 'updatedBy';
47
+ for (const ep of epsOfEffect.get(e.from) ?? []) bucketsFor(e.to)[bucket].add(ep);
48
+ }
49
+ if (e.kind === 'data_flow' && graph.nodes.get(e.from)?.kind === 'state') {
50
+ for (const ep of epsOfEffect.get(e.to) ?? []) bucketsFor(e.from).readBy.add(ep);
51
+ }
52
+ }
53
+
54
+ const immortal = [];
55
+ const unmanaged = [];
56
+ let annotated = 0;
57
+ for (const [sid, buckets] of [...lifetimes].sort((a, b) => a[0].localeCompare(b[0]))) {
58
+ const state = graph.nodes.get(sid);
59
+ if (!state) continue;
60
+ state.meta.lifetime = Object.fromEntries(
61
+ Object.entries(buckets).map(([k, v]) => [k, [...v].sort()]),
62
+ );
63
+ annotated++;
64
+ if (buckets.createdBy.size && !buckets.destroyedBy.size)
65
+ immortal.push(state.meta.table);
66
+ if (buckets.destroyedBy.size && !buckets.createdBy.size)
67
+ unmanaged.push(state.meta.table);
68
+ }
69
+
70
+ immortal.sort();
71
+ unmanaged.sort();
72
+ return { annotated, immortal, unmanaged };
73
+ }
@@ -0,0 +1,104 @@
1
+ // ubg/passes/state-machines.js — pass 8 (SBIR v1.2 §2.7).
2
+ // "POST /orders then PATCH /orders/:id/pay" is routing; "∅→pending→paid" is
3
+ // what the system MEANS. The machine is derived from three declared sources,
4
+ // never guessed: (1) the status-like column, (2) the CHECK … IN (…) invariant
5
+ // naming the legal states, (3) literal column values the effects write —
6
+ // INSERT literals are initial transitions (∅→v), UPDATE SET literals are
7
+ // transitions whose from-state is the WHERE literal when present, '*' when
8
+ // the code doesn't constrain it (over-approximation, said out loud).
9
+ // Annotation-only.
10
+ import { reachabilityOf } from '../reach.js';
11
+
12
+ export const name = 'StateMachineInference';
13
+
14
+ const FIELD_RE = /^(status|state)$/;
15
+
16
+ export function run(graph) {
17
+ const reach = reachabilityOf(graph);
18
+ const epsOfEffect = new Map();
19
+ for (const [epId, reached] of reach) {
20
+ for (const id of reached) {
21
+ if (graph.nodes.get(id)?.kind !== 'effect') continue;
22
+ if (!epsOfEffect.has(id)) epsOfEffect.set(id, new Set());
23
+ epsOfEffect.get(id).add(epId);
24
+ }
25
+ }
26
+
27
+ const mutIn = new Map(); // state id -> [{ effect, op }]
28
+ for (const e of graph.edges) {
29
+ if (e.kind !== 'mutation') continue;
30
+ if (!mutIn.has(e.to)) mutIn.set(e.to, []);
31
+ mutIn.get(e.to).push({ effect: graph.nodes.get(e.from), op: e.meta.op });
32
+ }
33
+
34
+ let machines = 0;
35
+ const states = [...graph.nodes.values()]
36
+ .filter((n) => n.kind === 'state')
37
+ .sort((a, b) => a.id.localeCompare(b.id));
38
+
39
+ for (const state of states) {
40
+ const field = fieldOf(state);
41
+ if (!field) continue;
42
+
43
+ const declared = declaredStatesOf(state, field);
44
+ const transitions = [];
45
+ const observed = new Set();
46
+
47
+ for (const { effect } of mutIn.get(state.id) ?? []) {
48
+ if (!effect) continue;
49
+ const via = [...(epsOfEffect.get(effect.id) ?? [])].sort();
50
+ const insertV = effect.meta.inserts?.[field];
51
+ if (insertV !== undefined) {
52
+ transitions.push({ from: '∅', to: insertV, via });
53
+ observed.add(insertV);
54
+ }
55
+ const setV = effect.meta.sets?.[field];
56
+ if (setV !== undefined) {
57
+ const from = effect.meta.where?.[field] ?? '*';
58
+ transitions.push({ from, to: setV, via });
59
+ observed.add(setV);
60
+ if (from !== '*') observed.add(from);
61
+ }
62
+ }
63
+
64
+ if (!transitions.length && !declared.length) continue;
65
+ transitions.sort(
66
+ (a, b) =>
67
+ a.from.localeCompare(b.from) ||
68
+ a.to.localeCompare(b.to) ||
69
+ (a.via[0] ?? '').localeCompare(b.via[0] ?? ''),
70
+ );
71
+ state.meta.stateMachine = {
72
+ field,
73
+ states: declared.length ? declared : [...observed].sort(),
74
+ transitions,
75
+ };
76
+ machines++;
77
+ }
78
+
79
+ return { machines };
80
+ }
81
+
82
+ // a column literally named status/state, else one whose CHECK names it
83
+ function fieldOf(state) {
84
+ for (const col of state.meta.columns ?? []) {
85
+ if (FIELD_RE.test(col.name)) return col.name;
86
+ }
87
+ for (const inv of state.meta.invariants ?? []) {
88
+ if (inv.type !== 'check') continue;
89
+ const m = inv.expression?.match(/^(\w+)\s+in\s*\(/i);
90
+ if (m && FIELD_RE.test(m[1])) return m[1];
91
+ }
92
+ return null;
93
+ }
94
+
95
+ // CHECK (status IN ('pending', 'paid')) → ['paid', 'pending']
96
+ function declaredStatesOf(state, field) {
97
+ for (const inv of state.meta.invariants ?? []) {
98
+ if (inv.type !== 'check') continue;
99
+ const m = inv.expression?.match(new RegExp(`${field}\\s+in\\s*\\(([^)]*)\\)`, 'i'));
100
+ if (!m) continue;
101
+ return [...m[1].matchAll(/'([^']*)'/g)].map((x) => x[1]).sort();
102
+ }
103
+ return [];
104
+ }
@@ -11,14 +11,22 @@ import * as stateMinimization from './passes/state-minimization.js';
11
11
  import * as typePropagation from './passes/type-propagation.js';
12
12
  import * as effectAlgebra from './passes/effect-algebra.js';
13
13
  import * as consistencyDomains from './passes/consistency-domains.js';
14
+ import * as capabilities from './passes/capabilities.js';
15
+ import * as resourceLifetimes from './passes/resource-lifetimes.js';
16
+ import * as stateMachines from './passes/state-machines.js';
14
17
 
15
- // SBIR §4 — normative order: reap, merge, type, classify, derive domains
18
+ // SBIR §4 — normative order: reap, merge, type, classify, derive domains,
19
+ // then the v1.2 derivations (capabilities, lifetimes, machines) which read
20
+ // everything the earlier passes established
16
21
  export const PASSES = [
17
22
  deadPathElimination,
18
23
  stateMinimization,
19
24
  typePropagation,
20
25
  effectAlgebra,
21
26
  consistencyDomains,
27
+ capabilities,
28
+ resourceLifetimes,
29
+ stateMachines,
22
30
  ];
23
31
 
24
32
  export function optimize(graph, { passes = PASSES } = {}) {
@@ -0,0 +1,39 @@
1
+ // ubg/reach.js — route-aware reachability, the one traversal everything
2
+ // per-entrypoint shares. Chain control_flow edges carry meta.route; walking
3
+ // from an entrypoint never crosses into a sibling route's chain (a middleware
4
+ // shared by N routes fans out to N handlers, but a request walks ONE edge).
5
+ export function buildCfIndex(edges) {
6
+ const cfOut = new Map();
7
+ for (const e of edges) {
8
+ if (e.kind !== 'control_flow') continue;
9
+ if (!cfOut.has(e.from)) cfOut.set(e.from, []);
10
+ cfOut.get(e.from).push({ to: e.to, route: e.meta?.route ?? null });
11
+ }
12
+ return cfOut;
13
+ }
14
+
15
+ export function reachFrom(epId, cfOut) {
16
+ const seen = new Set();
17
+ const queue = [epId];
18
+ while (queue.length) {
19
+ const id = queue.shift();
20
+ if (seen.has(id)) continue;
21
+ seen.add(id);
22
+ for (const next of cfOut.get(id) ?? []) {
23
+ if (next.route === null || next.route === epId) queue.push(next.to);
24
+ }
25
+ }
26
+ seen.delete(epId);
27
+ return seen;
28
+ }
29
+
30
+ // entrypointId -> Set(reached node ids), for every entrypoint, sorted walk
31
+ export function reachabilityOf(graph) {
32
+ const cfOut = buildCfIndex(graph.edges);
33
+ const map = new Map();
34
+ const entrypoints = [...graph.nodes.values()]
35
+ .filter((n) => n.kind === 'entrypoint')
36
+ .sort((a, b) => a.id.localeCompare(b.id));
37
+ for (const ep of entrypoints) map.set(ep.id, reachFrom(ep.id, cfOut));
38
+ return map;
39
+ }
package/src/ubg/schema.js CHANGED
Binary file
@@ -136,7 +136,13 @@ function translateRoute(
136
136
  for (const step of fullChain) {
137
137
  const { id: stepId, scan } = ensureChainNode(graph, step, scanCache);
138
138
  chainNodes.push({ id: stepId, step, scan });
139
- addEdge(graph, makeEdge('control_flow', prevId, stepId, { order: order++ }));
139
+ // chain edges carry their route: a middleware shared by N routes fans out
140
+ // to N handlers, but a request only ever walks ONE of those edges —
141
+ // per-entrypoint traversals filter on meta.route to stay leak-free
142
+ addEdge(
143
+ graph,
144
+ makeEdge('control_flow', prevId, stepId, { order: order++, route: epId }),
145
+ );
140
146
  prevId = stepId;
141
147
  }
142
148
 
@@ -242,6 +248,10 @@ function attachBody(graph, ownerId, scan, helperByName, scanCache, expanded) {
242
248
  ...(eff.target ? { target: eff.target } : {}),
243
249
  ...(eff.driver ? { driver: eff.driver } : {}),
244
250
  ...(eff.httpMethod ? { httpMethod: eff.httpMethod } : {}),
251
+ // literal column values (SBIR v1.2) — StateMachineInference fuel
252
+ ...(eff.sets ? { sets: eff.sets } : {}),
253
+ ...(eff.where ? { where: eff.where } : {}),
254
+ ...(eff.inserts ? { inserts: eff.inserts } : {}),
245
255
  // SBIR v1.1 §2.2 — transaction scope id is file-qualified here,
246
256
  // where the owner's file is known
247
257
  ...(eff.txLine != null