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 +24 -0
- package/SKILL.md +17 -26
- package/package.json +1 -1
- package/src/commands/timeless.js +187 -0
- package/src/commands/ubg.js +10 -1
- 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 +13 -0
- package/src/ubg/extract.js +79 -2
- package/src/ubg/fastapi_extract.py +53 -1
- package/src/ubg/passes/capabilities.js +73 -0
- package/src/ubg/passes/effect-algebra.js +5 -0
- package/src/ubg/passes/resource-lifetimes.js +73 -0
- package/src/ubg/passes/state-machines.js +104 -0
- package/src/ubg/pipeline.js +9 -1
- package/src/ubg/reach.js +39 -0
- package/src/ubg/schema.js +0 -0
- package/src/ubg/translate.js +11 -1
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
|
|
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
|
+
}
|
package/src/commands/ubg.js
CHANGED
|
@@ -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)
|
package/src/ubg/extract.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/ubg/pipeline.js
CHANGED
|
@@ -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 } = {}) {
|
package/src/ubg/reach.js
ADDED
|
@@ -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
|
package/src/ubg/translate.js
CHANGED
|
@@ -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
|
-
|
|
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
|