sparda-mcp 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -11
- package/SKILL.md +12 -0
- package/package.json +1 -1
- package/src/commands/doctor.js +3 -1
- package/src/commands/evolve.js +4 -2
- package/src/commands/negentropy.js +12 -11
- package/src/commands/report.js +4 -2
- package/src/commands/twin.js +26 -7
- package/src/commands/ubg.js +71 -0
- package/src/generator/express.js +15 -4
- package/src/generator/fastapi.js +11 -4
- package/src/generator/manifest.js +34 -0
- package/src/generator/nextjs.js +11 -4
- package/src/index.js +6 -0
- package/src/server/stdio.js +4 -3
- package/src/ubg/compile.js +84 -0
- package/src/ubg/express.js +258 -0
- package/src/ubg/extract.js +607 -0
- package/src/ubg/fastapi.js +59 -0
- package/src/ubg/fastapi_extract.py +724 -0
- package/src/ubg/link.js +61 -0
- package/src/ubg/nextjs.js +152 -0
- package/src/ubg/passes/consistency-domains.js +121 -0
- package/src/ubg/passes/dead-path-elimination.js +74 -0
- package/src/ubg/passes/effect-algebra.js +66 -0
- package/src/ubg/passes/state-minimization.js +86 -0
- package/src/ubg/passes/type-propagation.js +122 -0
- package/src/ubg/pipeline.js +37 -0
- package/src/ubg/schema.js +0 -0
- package/src/ubg/serialize.js +41 -0
- package/src/ubg/sql.js +252 -0
- package/src/ubg/translate.js +300 -0
- package/templates/express-router.txt +18 -1
- package/templates/fastapi-router.txt +26 -1
- package/templates/nextjs-router.txt +18 -1
package/README.md
CHANGED
|
@@ -6,23 +6,27 @@
|
|
|
6
6
|
|
|
7
7
|
<br/>
|
|
8
8
|
|
|
9
|
-
**
|
|
9
|
+
**Compile your backend into a deterministic behavior runtime.**
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
refactor a controller, but they can't create an order, fetch a real user, or see why
|
|
13
|
-
production is failing. And giving an AI real access to your API usually means: write
|
|
14
|
-
an OpenAPI spec, build an MCP server, host it, secure it, keep it in sync with every
|
|
15
|
-
route change — and pray it never `DELETE`s the wrong row. Days of glue code, per
|
|
16
|
-
project, forever drifting.
|
|
11
|
+
For twenty years, software communicated via APIs. Then AI agents arrived. The industry's response was simply to expose more endpoints (MCP). But an agent doesn't understand your application; it only sees a list of disconnected tools. That is enough for a human, but not for an autonomous machine.
|
|
17
12
|
|
|
18
|
-
SPARDA
|
|
13
|
+
SPARDA analyzes your Express, FastAPI, or Next.js application, extracts its behavior, and compiles it into a **SPARDA Behavior Graph (SBG)**.
|
|
14
|
+
|
|
15
|
+
This graph acts as a local, deterministic runtime that powers:
|
|
16
|
+
* **MCP Server Integration**: Exposing your endpoints to AI clients dynamically.
|
|
17
|
+
* **SPARDA Twin**: An executable, offline simulation clone of your backend for safe agent testing.
|
|
18
|
+
* **SPARDA Immune**: Real-time graph-based anomaly defense, latency alerts, and auto-quarantine.
|
|
19
|
+
* **SPARDA Evolution**: Statically inferred grammar mapping and in-memory workflow optimization.
|
|
20
|
+
|
|
21
|
+
All executed 100% locally, with zero runtime dependencies and zero cloud connection.
|
|
19
22
|
|
|
20
23
|
```bash
|
|
21
|
-
npx sparda-mcp init #
|
|
22
|
-
npx sparda-mcp dev #
|
|
24
|
+
npx sparda-mcp init # Scan your app, compile the UBG, inject the SPARDA Runtime
|
|
25
|
+
npx sparda-mcp dev # Connect Claude Desktop / Cursor over stdio
|
|
23
26
|
```
|
|
24
27
|
|
|
25
|
-
No OpenAPI spec. No account. No API key. No server to host.
|
|
28
|
+
No OpenAPI spec to write. No cloud account. No API key. No server to host.
|
|
29
|
+
Exposing raw APIs to AI is the old way. SPARDA compiles the whole system's behavior.
|
|
26
30
|
|
|
27
31
|
## Quickstart
|
|
28
32
|
|
|
@@ -107,6 +111,9 @@ What we *don't* promise: the honest limits in [docs/SECURITY.md](./docs/SECURITY
|
|
|
107
111
|
4. Suspicious docstrings are sanitized before they ever reach the AI (prompt-injection defense).
|
|
108
112
|
5. `npx sparda-mcp doctor --app` audits your codebase for drift: it detects stale tools (IA seeing ghosts), unsynced routes, schema drift via fingerprints, and zombie configurations. High severity issues trigger a non-zero exit code for your CI pipeline.
|
|
109
113
|
6. `npx sparda-mcp seed export/import` lets you package and share your app's "genome" (semantic memory, workflows, antibodies) securely, transferring immune memory between environments or across similar stacks with zero data leak.
|
|
114
|
+
7. `npx sparda-mcp twin` starts a safe, simulated mock server of your backend on the original port. It serves GET calls from learned exemplars (observed response shapes & mock data) and returns simulated 202 writes without ever touching your real database or production APIs. Learn exemplars by running `npx sparda-mcp twin --learn`.
|
|
115
|
+
8. `npx sparda-mcp grammar` maps the graph of valid sequences of tool calls (observed circuits and candidate hypotheses) to prevent LLM hallucination of routes.
|
|
116
|
+
9. `npx sparda-mcp evolve` mutates candidate chains and tests them against the twin in-memory, promoting successful chains to evolved workflow suggestions.
|
|
110
117
|
|
|
111
118
|
## What SPARDA gives your AI
|
|
112
119
|
|
|
@@ -166,6 +173,7 @@ runtime, so the guidance never goes stale.
|
|
|
166
173
|
|
|
167
174
|
## Security posture (honest)
|
|
168
175
|
- 4 runtime dependencies, exact-pinned.
|
|
176
|
+
- **Dynamic Local Key Resolution.** The generated router contains no baked secrets. It resolves authorization keys at runtime from the `SPARDA_LOCAL_KEY` environment variable or the local gitignored `.sparda/key` file, and fails closed (503) when neither is found. For custom production or staging setups, you can override this behavior by exposing `SPARDA_LOCAL_KEY` in your environment.
|
|
169
177
|
- Local key on every router call; self-reference loop protection; 30s timeouts; 8 KB output truncation.
|
|
170
178
|
- AST-positioned injection with backup and post-injection re-parse; `npx sparda-mcp remove` leaves a clean git diff.
|
|
171
179
|
- Persistence is **value-free**: SPARDA records structure (tool names, field names, fingerprints), never your payloads.
|
package/SKILL.md
CHANGED
|
@@ -84,6 +84,16 @@ adapt — don't blindly retry the same call.
|
|
|
84
84
|
**5. Latency anomalies.** A call far slower than a tool's own baseline surfaces as
|
|
85
85
|
an `immune` event in `/mcp/events`. Treat it as a hint to back off or warn the user.
|
|
86
86
|
|
|
87
|
+
**6. Twin Simulation Mode — practice safely on a clone.**
|
|
88
|
+
When `/mcp/stats` or `sparda_get_context.runtime` contains `"twin": true`, you are connected to a safe, in-memory mock clone of the application.
|
|
89
|
+
- All GET reads return learned exemplars (observed response shapes and mock values).
|
|
90
|
+
- All write tools return simulated `202` echoes but do not write to database or external APIs.
|
|
91
|
+
- Use this twin mode to practice multi-step workflows, debug tool sequences, and test your plans without touching the live production backend.
|
|
92
|
+
|
|
93
|
+
**7. Grammar & Evolution — discover optimal workflows.**
|
|
94
|
+
- You can query or contribute to the app's grammar (`.sparda/grammar.json`). The grammar maps valid sequences of tool calls (edges).
|
|
95
|
+
- Running `sparda evolve` mutates and runs candidate chains against the twin. The successful evolved sequences are suggested as mid-session workflows.
|
|
96
|
+
|
|
87
97
|
## Writing safely — the mandatory two-phase protocol
|
|
88
98
|
|
|
89
99
|
Writes are **disabled by default**. The protocol is not optional:
|
|
@@ -136,6 +146,8 @@ Writes are **disabled by default**. The protocol is not optional:
|
|
|
136
146
|
- **General health** → `sparda doctor` checks Node version, framework detection,
|
|
137
147
|
manifest validity, the semantic/immune cache, host reachability, and quarantine;
|
|
138
148
|
it exits non-zero so it can gate CI.
|
|
149
|
+
- **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
|
+
- **Learn exemplars** → Start your live app and run `sparda twin --learn` to fetch actual response data and construct `.sparda/twin.json` locally.
|
|
139
151
|
|
|
140
152
|
---
|
|
141
153
|
*This skill ships with `sparda-mcp` and is regenerated from SPARDA's capability
|
package/package.json
CHANGED
package/src/commands/doctor.js
CHANGED
|
@@ -3,6 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { detectStack } from '../detect.js';
|
|
5
5
|
import { buildNegentropy, renderNegentropy } from './negentropy.js';
|
|
6
|
+
import { resolveSpardaKey } from '../generator/manifest.js';
|
|
6
7
|
|
|
7
8
|
// Returns { healthy } so the CLI can exit non-zero on any ✗ — scripts/CI can
|
|
8
9
|
// gate on `sparda doctor` (E-012). Informational '·' lines never fail it.
|
|
@@ -55,7 +56,8 @@ export async function runDoctor(opts) {
|
|
|
55
56
|
);
|
|
56
57
|
|
|
57
58
|
if (stack) {
|
|
58
|
-
const
|
|
59
|
+
const key = resolveSpardaKey(opts.cwd, m);
|
|
60
|
+
const headers = { 'x-sparda-key': key };
|
|
59
61
|
try {
|
|
60
62
|
const r = await fetch(`http://127.0.0.1:${m.port}/mcp/tools`, {
|
|
61
63
|
headers,
|
package/src/commands/evolve.js
CHANGED
|
@@ -10,6 +10,7 @@ import { buildGrammar } from './grammar.js';
|
|
|
10
10
|
import { createTwinServer, twinFilePath } from './twin.js';
|
|
11
11
|
import { findByKey } from '../server/crystallize.js';
|
|
12
12
|
import { mergeManifestKeySync } from '../server/persistence.js';
|
|
13
|
+
import { resolveSpardaKey } from '../generator/manifest.js';
|
|
13
14
|
|
|
14
15
|
const MAX_CANDIDATES_PER_RUN = 10;
|
|
15
16
|
|
|
@@ -101,7 +102,8 @@ export async function runEvolve(opts) {
|
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
// the practice arena: an in-process twin on an ephemeral port
|
|
104
|
-
const
|
|
105
|
+
const localKey = resolveSpardaKey(opts.cwd, manifest);
|
|
106
|
+
const server = createTwinServer(manifest, localKey, exemplars);
|
|
105
107
|
const port = await new Promise((resolve, reject) => {
|
|
106
108
|
server.once('error', reject);
|
|
107
109
|
server.listen(0, '127.0.0.1', () => resolve(server.address().port));
|
|
@@ -111,7 +113,7 @@ export async function runEvolve(opts) {
|
|
|
111
113
|
const failed = [];
|
|
112
114
|
try {
|
|
113
115
|
for (const c of candidates) {
|
|
114
|
-
const verdict = await trialCandidate(c, { port, localKey
|
|
116
|
+
const verdict = await trialCandidate(c, { port, localKey });
|
|
115
117
|
if (verdict.ok) survivors.push(c);
|
|
116
118
|
else failed.push({ key: c.key, why: verdict.why });
|
|
117
119
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import crypto from 'node:crypto';
|
|
10
|
+
import { resolveSpardaKey } from '../generator/manifest.js';
|
|
10
11
|
|
|
11
12
|
// same fingerprint the generators stamp into sparding.toolFingerprints —
|
|
12
13
|
// method|path|params, sha256/8. Divergence = the route's shape changed.
|
|
@@ -191,17 +192,17 @@ export function buildNegentropy({ manifest, currentRoutes, live, detectedPort, c
|
|
|
191
192
|
`${f} is recorded in sparda.json but absent on disk — the organism has a memory of a body it no longer has`,
|
|
192
193
|
'npx sparda-mcp init --yes (regenerates; carry-over keeps your state)',
|
|
193
194
|
);
|
|
194
|
-
} else
|
|
195
|
-
manifest
|
|
196
|
-
!fs.readFileSync(abs, 'utf8').includes(
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
195
|
+
} else {
|
|
196
|
+
const key = resolveSpardaKey(cwd, manifest);
|
|
197
|
+
if (key && !fs.readFileSync(abs, 'utf8').includes(key)) {
|
|
198
|
+
add(
|
|
199
|
+
'zombie',
|
|
200
|
+
'high',
|
|
201
|
+
'router/manifest key mismatch',
|
|
202
|
+
`${f} does not carry the manifest's localKey — a stale router from an older init is still wired in`,
|
|
203
|
+
'npx sparda-mcp init --yes',
|
|
204
|
+
);
|
|
205
|
+
}
|
|
205
206
|
}
|
|
206
207
|
}
|
|
207
208
|
|
package/src/commands/report.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { c, gradient } from '../ui/style.js';
|
|
9
|
+
import { resolveSpardaKey } from '../generator/manifest.js';
|
|
9
10
|
|
|
10
11
|
// ── pure: manifest (+ optional live stats) → report data ──────────────────
|
|
11
12
|
|
|
@@ -342,9 +343,10 @@ export async function runReport(opts) {
|
|
|
342
343
|
}
|
|
343
344
|
|
|
344
345
|
let live = null;
|
|
345
|
-
|
|
346
|
+
const key = resolveSpardaKey(opts.cwd, manifest);
|
|
347
|
+
if (manifest.port && key) {
|
|
346
348
|
live = await fetch(`http://127.0.0.1:${manifest.port}/mcp/stats`, {
|
|
347
|
-
headers: { 'x-sparda-key':
|
|
349
|
+
headers: { 'x-sparda-key': key },
|
|
348
350
|
signal: AbortSignal.timeout(1500),
|
|
349
351
|
})
|
|
350
352
|
.then((x) => (x.ok ? x.json() : null))
|
package/src/commands/twin.js
CHANGED
|
@@ -13,6 +13,7 @@ import fs from 'node:fs';
|
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import http from 'node:http';
|
|
15
15
|
import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
|
|
16
|
+
import { resolveSpardaKey } from '../generator/manifest.js';
|
|
16
17
|
|
|
17
18
|
const EXEMPLAR_CAP = 16384; // 16KB per exemplar — a shape, not an archive
|
|
18
19
|
|
|
@@ -26,7 +27,14 @@ export function eligibleForLearning(tool) {
|
|
|
26
27
|
return tool.enabled && tool.method === 'GET' && (tool.pathParams ?? []).length === 0;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
export async function learnExemplars(manifest, fetchFn = fetch) {
|
|
30
|
+
export async function learnExemplars(manifest, localKey, fetchFn = fetch) {
|
|
31
|
+
let key = localKey;
|
|
32
|
+
if (typeof localKey === 'function') {
|
|
33
|
+
fetchFn = localKey;
|
|
34
|
+
key = undefined;
|
|
35
|
+
}
|
|
36
|
+
key = key ?? manifest.localKey ?? resolveSpardaKey(process.cwd(), manifest);
|
|
37
|
+
|
|
30
38
|
const exemplars = {};
|
|
31
39
|
const skipped = [];
|
|
32
40
|
for (const [name, t] of Object.entries(manifest.tools ?? {})) {
|
|
@@ -46,7 +54,7 @@ export async function learnExemplars(manifest, fetchFn = fetch) {
|
|
|
46
54
|
method: 'POST',
|
|
47
55
|
headers: {
|
|
48
56
|
'content-type': 'application/json',
|
|
49
|
-
'x-sparda-key':
|
|
57
|
+
'x-sparda-key': key,
|
|
50
58
|
},
|
|
51
59
|
body: JSON.stringify({ tool: name, args: {} }),
|
|
52
60
|
signal: AbortSignal.timeout(10_000),
|
|
@@ -83,13 +91,23 @@ function matchTool(manifest, method, pathname) {
|
|
|
83
91
|
return null;
|
|
84
92
|
}
|
|
85
93
|
|
|
86
|
-
export function createTwinServer(manifest,
|
|
94
|
+
export function createTwinServer(manifest, localKeyOrExemplars, maybeExemplars) {
|
|
95
|
+
let localKey = localKeyOrExemplars;
|
|
96
|
+
let exemplars = maybeExemplars;
|
|
97
|
+
if (
|
|
98
|
+
maybeExemplars === undefined &&
|
|
99
|
+
localKeyOrExemplars &&
|
|
100
|
+
typeof localKeyOrExemplars === 'object'
|
|
101
|
+
) {
|
|
102
|
+
exemplars = localKeyOrExemplars;
|
|
103
|
+
localKey = resolveSpardaKey(process.cwd(), manifest);
|
|
104
|
+
}
|
|
87
105
|
const json = (res, status, obj) => {
|
|
88
106
|
res.writeHead(status, { 'content-type': 'application/json' });
|
|
89
107
|
res.end(JSON.stringify(obj));
|
|
90
108
|
};
|
|
91
109
|
const answerFor = (name) =>
|
|
92
|
-
exemplars[name]?.data !== undefined
|
|
110
|
+
(exemplars ?? {})[name]?.data !== undefined
|
|
93
111
|
? exemplars[name].data
|
|
94
112
|
: {
|
|
95
113
|
__sparda_twin__: true,
|
|
@@ -103,7 +121,7 @@ export function createTwinServer(manifest, exemplars) {
|
|
|
103
121
|
|
|
104
122
|
// the /mcp surface, so the unchanged bridge drives the ghost
|
|
105
123
|
if (pathname.startsWith('/mcp')) {
|
|
106
|
-
if (req.headers['x-sparda-key'] !==
|
|
124
|
+
if (req.headers['x-sparda-key'] !== localKey)
|
|
107
125
|
return json(res, 401, { error: 'unauthorized' });
|
|
108
126
|
if (pathname === '/mcp/tools') return json(res, 200, manifest.tools ?? {});
|
|
109
127
|
if (pathname === '/mcp/stats')
|
|
@@ -178,13 +196,14 @@ export async function runTwin(opts, args) {
|
|
|
178
196
|
});
|
|
179
197
|
}
|
|
180
198
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
199
|
+
const localKey = resolveSpardaKey(opts.cwd, manifest);
|
|
181
200
|
const twinPath = twinFilePath(opts.cwd);
|
|
182
201
|
|
|
183
202
|
if (args.includes('--learn') || opts.learn) {
|
|
184
203
|
console.error(
|
|
185
204
|
'[sparda] twin: learning from the LIVE app (one call per eligible GET)...',
|
|
186
205
|
);
|
|
187
|
-
const { exemplars, skipped } = await learnExemplars(manifest);
|
|
206
|
+
const { exemplars, skipped } = await learnExemplars(manifest, localKey);
|
|
188
207
|
const learned = Object.keys(exemplars).length;
|
|
189
208
|
if (!learned && skipped.every((s) => s.reason.startsWith('unreachable'))) {
|
|
190
209
|
throw Object.assign(
|
|
@@ -222,7 +241,7 @@ export async function runTwin(opts, args) {
|
|
|
222
241
|
}
|
|
223
242
|
}
|
|
224
243
|
const port = Number(opts.port) || manifest.port;
|
|
225
|
-
const server = createTwinServer(manifest, exemplars);
|
|
244
|
+
const server = createTwinServer(manifest, localKey, exemplars);
|
|
226
245
|
await new Promise((resolve, reject) => {
|
|
227
246
|
server.once('error', reject);
|
|
228
247
|
server.listen(port, '127.0.0.1', resolve);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// commands/ubg.js — compile the codebase to its Unified Behavior Graph.
|
|
2
|
+
// The UBG is the IR every future SPARDA tool reads (time-travel debuggers,
|
|
3
|
+
// deploy provers, state-machine runtimes): compile once here, write passes
|
|
4
|
+
// there. Artifact: .sparda/ubg.json — regenerable, deterministic, never
|
|
5
|
+
// committed. `--json` prints the raw graph, `--out <file>` redirects it.
|
|
6
|
+
import { compileUBG } from '../ubg/compile.js';
|
|
7
|
+
|
|
8
|
+
export async function runUbg(opts) {
|
|
9
|
+
const { report, json, outPath } = compileUBG(opts.cwd, {
|
|
10
|
+
write: true,
|
|
11
|
+
out: opts.out,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
if (opts.json) {
|
|
15
|
+
console.log(json);
|
|
16
|
+
return { report };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { counts } = report;
|
|
20
|
+
console.log(
|
|
21
|
+
`✓ UBG compiled: ${rel(outPath, opts.cwd)} — ${counts.totalNodes} nodes, ${counts.totalEdges} edges`,
|
|
22
|
+
);
|
|
23
|
+
console.log(
|
|
24
|
+
` ${report.framework} · ${report.routes} routes · ${report.tables} SQL tables`,
|
|
25
|
+
);
|
|
26
|
+
console.log(` Nodes: ${fmtCounts(counts.nodes)}`);
|
|
27
|
+
console.log(` Edges: ${fmtCounts(counts.edges)}`);
|
|
28
|
+
|
|
29
|
+
for (const p of report.passes) {
|
|
30
|
+
if (p.pass === 'DeadPathElimination' && p.removed)
|
|
31
|
+
console.log(` ${p.pass}: ${p.removed} dead node(s) removed`);
|
|
32
|
+
else if (p.pass === 'StateMinimization' && p.merged)
|
|
33
|
+
console.log(` ${p.pass}: ${p.merged} logic block(s) merged`);
|
|
34
|
+
else if (p.pass === 'TypePropagation' && (p.resolved || p.entrypointsTyped))
|
|
35
|
+
console.log(
|
|
36
|
+
` ${p.pass}: ${p.entrypointsTyped} entrypoint return schema(s), ${p.resolved} type(s) resolved`,
|
|
37
|
+
);
|
|
38
|
+
else if (p.pass === 'EffectAlgebra' && p.classified)
|
|
39
|
+
console.log(
|
|
40
|
+
` ${p.pass}: ${p.classified} effect(s) classified, ${p.observable} observable`,
|
|
41
|
+
);
|
|
42
|
+
else if (p.pass === 'ConsistencyDomains' && Object.keys(p.domains ?? {}).length) {
|
|
43
|
+
const names = Object.keys(p.domains).sort();
|
|
44
|
+
console.log(
|
|
45
|
+
` ${p.pass}: ${names.length} domain(s) — ${names.join(', ')}${p.cycles.length ? ` (⚠ FK cycle: ${p.cycles.join(', ')})` : ''}`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (report.link.inferredTables.length)
|
|
51
|
+
console.log(
|
|
52
|
+
` ⚠ tables referenced in code but absent from .sql schemas: ${report.link.inferredTables.join(', ')}`,
|
|
53
|
+
);
|
|
54
|
+
if (report.skipped.length) {
|
|
55
|
+
console.log(` · ${report.skipped.length} construct(s) out of static reach:`);
|
|
56
|
+
for (const s of report.skipped.slice(0, opts.verbose ? 100 : 5))
|
|
57
|
+
console.log(` - ${s.reason}${s.file ? ` (${s.file})` : ''}`);
|
|
58
|
+
if (!opts.verbose && report.skipped.length > 5)
|
|
59
|
+
console.log(` … ${report.skipped.length - 5} more (--verbose)`);
|
|
60
|
+
}
|
|
61
|
+
return { report };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const fmtCounts = (obj) =>
|
|
65
|
+
Object.entries(obj)
|
|
66
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
67
|
+
.map(([k, v]) => `${k} ${v}`)
|
|
68
|
+
.join(' · ');
|
|
69
|
+
|
|
70
|
+
const rel = (abs, cwd) =>
|
|
71
|
+
abs.startsWith(cwd) ? abs.slice(cwd.length + 1).replaceAll('\\', '/') : abs;
|
package/src/generator/express.js
CHANGED
|
@@ -5,7 +5,7 @@ import crypto from 'node:crypto';
|
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { parse } from '@babel/parser';
|
|
7
7
|
import { toolNameFor } from '../parser/express.js';
|
|
8
|
-
import { carryOverManifest, defaultSpardingMemory } from './manifest.js';
|
|
8
|
+
import { carryOverManifest, defaultSpardingMemory, ensureSpardaKey } from './manifest.js';
|
|
9
9
|
import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
|
|
10
10
|
|
|
11
11
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -58,7 +58,7 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
|
|
|
58
58
|
sparding.toolFingerprints = newFingerprints;
|
|
59
59
|
|
|
60
60
|
// stable across re-runs so a running bridge/host pair never desyncs
|
|
61
|
-
const localKey = prev
|
|
61
|
+
const localKey = ensureSpardaKey(cwd, prev);
|
|
62
62
|
const entryAbs = path.resolve(cwd, entryFile);
|
|
63
63
|
const entryDir = path.dirname(entryAbs);
|
|
64
64
|
const ext = entryFile.endsWith('.ts')
|
|
@@ -111,7 +111,10 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
|
|
|
111
111
|
)
|
|
112
112
|
.replace('__JSON_MW__', "express.json({ limit: '64kb' })")
|
|
113
113
|
.replace('__TOOLS_JSON__', JSON.stringify(tools, null, 2))
|
|
114
|
-
.replace(
|
|
114
|
+
.replace(
|
|
115
|
+
'__FS_IMPORT__',
|
|
116
|
+
isESM ? "import spardaFs from 'node:fs';" : "const spardaFs = require('node:fs');",
|
|
117
|
+
)
|
|
115
118
|
.replace('__PORT__', String(port))
|
|
116
119
|
.replace(/__REQ_TYPE__/g, reqType)
|
|
117
120
|
.replace(/__RES_TYPE__/g, resType)
|
|
@@ -150,7 +153,15 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
|
|
|
150
153
|
...(prev?.labs ? { labs: prev.labs } : {}),
|
|
151
154
|
sparding,
|
|
152
155
|
};
|
|
153
|
-
|
|
156
|
+
// ADR-022: the disk copy NEVER carries the key — it lives in .sparda/key
|
|
157
|
+
// (written by ensureSpardaKey above). The in-memory return keeps it for
|
|
158
|
+
// this process only.
|
|
159
|
+
const manifestOnDisk = { ...manifest };
|
|
160
|
+
delete manifestOnDisk.localKey;
|
|
161
|
+
atomicWrite(
|
|
162
|
+
path.join(cwd, 'sparda.json'),
|
|
163
|
+
JSON.stringify(manifestOnDisk, null, 2) + '\n',
|
|
164
|
+
);
|
|
154
165
|
|
|
155
166
|
return {
|
|
156
167
|
tools,
|
package/src/generator/fastapi.js
CHANGED
|
@@ -5,7 +5,7 @@ import crypto from 'node:crypto';
|
|
|
5
5
|
import { spawnSync } from 'node:child_process';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { toolNameFor } from '../parser/express.js';
|
|
8
|
-
import { carryOverManifest, defaultSpardingMemory } from './manifest.js';
|
|
8
|
+
import { carryOverManifest, defaultSpardingMemory, ensureSpardaKey } from './manifest.js';
|
|
9
9
|
import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
|
|
10
10
|
|
|
11
11
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -65,7 +65,7 @@ export function generateFastAPI({
|
|
|
65
65
|
sparding.toolFingerprints = newFingerprints;
|
|
66
66
|
|
|
67
67
|
// stable across re-runs so a running bridge/host pair never desyncs
|
|
68
|
-
const localKey = prev
|
|
68
|
+
const localKey = ensureSpardaKey(cwd, prev);
|
|
69
69
|
const entryAbs = path.resolve(cwd, entryFile);
|
|
70
70
|
const entryDir = path.dirname(entryAbs);
|
|
71
71
|
const routerFileName = 'sparda_router.py';
|
|
@@ -84,7 +84,6 @@ export function generateFastAPI({
|
|
|
84
84
|
'__SPARDING_POLICIES__',
|
|
85
85
|
JSON.stringify(JSON.stringify(sparding.policies ?? {})),
|
|
86
86
|
)
|
|
87
|
-
.replace('__LOCAL_KEY__', localKey)
|
|
88
87
|
.replace('__PORT__', String(port));
|
|
89
88
|
|
|
90
89
|
atomicWrite(routerAbs, tpl);
|
|
@@ -124,7 +123,15 @@ export function generateFastAPI({
|
|
|
124
123
|
sparding,
|
|
125
124
|
};
|
|
126
125
|
|
|
127
|
-
|
|
126
|
+
// ADR-022: the disk copy NEVER carries the key — it lives in .sparda/key
|
|
127
|
+
// (written by ensureSpardaKey above). The in-memory return keeps it for
|
|
128
|
+
// this process only.
|
|
129
|
+
const manifestOnDisk = { ...manifest };
|
|
130
|
+
delete manifestOnDisk.localKey;
|
|
131
|
+
atomicWrite(
|
|
132
|
+
path.join(cwd, 'sparda.json'),
|
|
133
|
+
JSON.stringify(manifestOnDisk, null, 2) + '\n',
|
|
134
|
+
);
|
|
128
135
|
|
|
129
136
|
return {
|
|
130
137
|
tools,
|
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
// generator/manifest.js — carry user state across re-runs of `sparda init`
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
export function resolveSpardaKey(cwd, prevManifest = null) {
|
|
7
|
+
if (process.env.SPARDA_LOCAL_KEY) {
|
|
8
|
+
return process.env.SPARDA_LOCAL_KEY;
|
|
9
|
+
}
|
|
10
|
+
const spardaDir = path.join(cwd, '.sparda');
|
|
11
|
+
const keyFile = path.join(spardaDir, 'key');
|
|
12
|
+
if (fs.existsSync(keyFile)) {
|
|
13
|
+
try {
|
|
14
|
+
const key = fs.readFileSync(keyFile, 'utf8').trim();
|
|
15
|
+
if (key) return key;
|
|
16
|
+
} catch {}
|
|
17
|
+
}
|
|
18
|
+
if (prevManifest && prevManifest.localKey) {
|
|
19
|
+
try {
|
|
20
|
+
fs.mkdirSync(spardaDir, { recursive: true });
|
|
21
|
+
fs.writeFileSync(keyFile, prevManifest.localKey, 'utf8');
|
|
22
|
+
} catch {}
|
|
23
|
+
return prevManifest.localKey;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function ensureSpardaKey(cwd, prevManifest = null) {
|
|
29
|
+
let key = resolveSpardaKey(cwd, prevManifest);
|
|
30
|
+
if (!key) {
|
|
31
|
+
key = crypto.randomUUID();
|
|
32
|
+
const spardaDir = path.join(cwd, '.sparda');
|
|
33
|
+
fs.mkdirSync(spardaDir, { recursive: true });
|
|
34
|
+
fs.writeFileSync(path.join(spardaDir, 'key'), key, 'utf8');
|
|
35
|
+
}
|
|
36
|
+
return key;
|
|
37
|
+
}
|
|
4
38
|
|
|
5
39
|
// Re-running init must not wipe what the user (or the semantic/immune/Labs
|
|
6
40
|
// passes) wrote into sparda.json: per-tool `enabled` overrides, the cached
|
package/src/generator/nextjs.js
CHANGED
|
@@ -9,7 +9,7 @@ import crypto from 'node:crypto';
|
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
import { toolNameFor } from '../parser/express.js';
|
|
11
11
|
import { ensureGitignore } from './express.js';
|
|
12
|
-
import { carryOverManifest, defaultSpardingMemory } from './manifest.js';
|
|
12
|
+
import { carryOverManifest, defaultSpardingMemory, ensureSpardaKey } from './manifest.js';
|
|
13
13
|
import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
|
|
14
14
|
|
|
15
15
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -60,7 +60,7 @@ export function generateNext({ cwd, appDir, port, routes }) {
|
|
|
60
60
|
sparding.toolFingerprints = newFingerprints;
|
|
61
61
|
|
|
62
62
|
// stable across re-runs so a running bridge/host pair never desyncs
|
|
63
|
-
const localKey = prev
|
|
63
|
+
const localKey = ensureSpardaKey(cwd, prev);
|
|
64
64
|
|
|
65
65
|
// .js on purpose: Next enables allowJs in the tsconfig it manages, so the
|
|
66
66
|
// generated handler compiles untouched inside TS projects too.
|
|
@@ -75,7 +75,6 @@ export function generateNext({ cwd, appDir, port, routes }) {
|
|
|
75
75
|
tpl = tpl
|
|
76
76
|
.replace('__TOOLS_JSON__', JSON.stringify(tools, null, 2))
|
|
77
77
|
.replace('__SPARDING_POLICIES__', JSON.stringify(sparding.policies ?? {}))
|
|
78
|
-
.replace('__LOCAL_KEY__', localKey)
|
|
79
78
|
.replace('__PORT__', String(port));
|
|
80
79
|
atomicWrite(routerAbs, tpl);
|
|
81
80
|
|
|
@@ -102,7 +101,15 @@ export function generateNext({ cwd, appDir, port, routes }) {
|
|
|
102
101
|
...(prev?.labs ? { labs: prev.labs } : {}),
|
|
103
102
|
sparding,
|
|
104
103
|
};
|
|
105
|
-
|
|
104
|
+
// ADR-022: the disk copy NEVER carries the key — it lives in .sparda/key
|
|
105
|
+
// (written by ensureSpardaKey above). The in-memory return keeps it for
|
|
106
|
+
// this process only.
|
|
107
|
+
const manifestOnDisk = { ...manifest };
|
|
108
|
+
delete manifestOnDisk.localKey;
|
|
109
|
+
atomicWrite(
|
|
110
|
+
path.join(cwd, 'sparda.json'),
|
|
111
|
+
JSON.stringify(manifestOnDisk, null, 2) + '\n',
|
|
112
|
+
);
|
|
106
113
|
|
|
107
114
|
return {
|
|
108
115
|
tools,
|
package/src/index.js
CHANGED
|
@@ -94,6 +94,11 @@ try {
|
|
|
94
94
|
await runEvolve(opts);
|
|
95
95
|
break;
|
|
96
96
|
}
|
|
97
|
+
case 'ubg': {
|
|
98
|
+
const { runUbg } = await import('./commands/ubg.js');
|
|
99
|
+
await runUbg(opts);
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
97
102
|
default:
|
|
98
103
|
console.log(`SPARDA v${VERSION} — Turn any codebase into an MCP server.
|
|
99
104
|
|
|
@@ -110,6 +115,7 @@ Usage:
|
|
|
110
115
|
npx sparda-mcp twin The living mock (--learn from the live app, then serve the ghost)
|
|
111
116
|
npx sparda-mcp grammar Which call sequences mean something (observed + hypotheses)
|
|
112
117
|
npx sparda-mcp evolve Trial hypothesis chains against the twin; survivors become suggestions
|
|
118
|
+
npx sparda-mcp ubg Compile the codebase to its Unified Behavior Graph (.sparda/ubg.json)
|
|
113
119
|
|
|
114
120
|
Flags: --yes (skip prompts) --port <n> --quiet --verbose
|
|
115
121
|
--probe (init: also run the app to discover dynamic routes the AST missed)
|
package/src/server/stdio.js
CHANGED
|
@@ -25,6 +25,7 @@ import { writeManifestSync, mergeManifestKeySync } from './persistence.js';
|
|
|
25
25
|
import { createSpardaEngine } from './engine.js';
|
|
26
26
|
import { initiateWrite, preapproveWrite, confirmWrite } from './confirmation.js';
|
|
27
27
|
import { resolveContext, injectContext, fingerprintContext } from './context-carrier.js';
|
|
28
|
+
import { resolveSpardaKey } from '../generator/manifest.js';
|
|
28
29
|
|
|
29
30
|
const EVENT_POLL_MS = Number(process.env.SPARDA_EVENT_POLL_MS ?? 5000);
|
|
30
31
|
// the sampling budgets below are also what the recycling gauge counts as "avoided"
|
|
@@ -61,11 +62,11 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
61
62
|
}
|
|
62
63
|
const port = Number(portOverride ?? manifest.port);
|
|
63
64
|
const framework = manifest.framework;
|
|
64
|
-
const key = manifest
|
|
65
|
+
const key = resolveSpardaKey(cwd, manifest);
|
|
65
66
|
if (!key) {
|
|
66
|
-
throw Object.assign(new Error('localKey missing
|
|
67
|
+
throw Object.assign(new Error('localKey missing or not configured.'), {
|
|
67
68
|
code: 'USER',
|
|
68
|
-
hint: 'Re-run `npx sparda-mcp init` to
|
|
69
|
+
hint: 'Re-run `npx sparda-mcp init` to generate it, or set SPARDA_LOCAL_KEY.',
|
|
69
70
|
});
|
|
70
71
|
}
|
|
71
72
|
|