sparda-mcp 0.13.2 → 0.14.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 +117 -155
- package/SKILL.md +28 -37
- package/package.json +1 -1
- package/src/commands/mirror.js +18 -5
- package/src/commands/remove.js +19 -0
- package/src/commands/review.js +206 -0
- package/src/flight/box.js +11 -8
- package/src/generator/express.js +12 -18
- package/src/generator/fastapi.js +7 -18
- package/src/generator/injection.js +36 -0
- package/src/generator/nextjs.js +120 -120
- package/src/index.js +8 -0
- package/src/server/stdio.js +12 -1
- package/src/ubg/apocalypse.js +279 -0
- package/src/ubg/mirror.js +158 -10
- package/src/ubg/passes/consistency-domains.js +5 -2
- package/src/ubg/passes/state-machines.js +2 -3
- package/src/ubg/passes/state-minimization.js +3 -1
- package/src/ubg/passes/type-propagation.js +8 -3
- package/src/ubg/prisma.js +3 -1
- package/src/ubg/schema.js +0 -0
- package/src/ubg/sql.js +3 -4
- package/src/ubg/translate.js +3 -2
- package/templates/express-router.txt +7 -1
- package/templates/fastapi-router.txt +661 -661
- package/templates/nextjs-router.txt +4 -1
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// commands/review.js — the SEMANTIC diff of a pull request (roadmap R5/M3).
|
|
2
|
+
// Everyone diffs a PR's TEXT; nobody diffs its BEHAVIOR. `sparda review` compiles
|
|
3
|
+
// the base ref and the working tree to the UBG and answers, with zero config and
|
|
4
|
+
// zero spec written by hand: what endpoints did this change add or remove, whose
|
|
5
|
+
// blast radius grew, which guard or invariant did it drop, and what new provable
|
|
6
|
+
// risk did it introduce. It is `apocalypse` made relative — the baseline is git,
|
|
7
|
+
// not a file you remembered to save. Composes the existing prover passes
|
|
8
|
+
// (checkGraph/diffGraphs/verdictOf) over two graphs, so every word is a
|
|
9
|
+
// counterexample, never a heuristic.
|
|
10
|
+
// sparda review diff the working tree against the default base
|
|
11
|
+
// sparda review --base main pick the base ref explicitly
|
|
12
|
+
// sparda review --json machine-readable envelope
|
|
13
|
+
// sparda review --markdown a PR-comment-ready block
|
|
14
|
+
// Exit code 1 on any critical/high — drop it in CI between "tests pass" and "merge".
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { execFileSync } from 'node:child_process';
|
|
19
|
+
import { compileUBG } from '../ubg/compile.js';
|
|
20
|
+
import { canonicalizeGraph, cmp } from '../ubg/schema.js';
|
|
21
|
+
import { checkGraph, diffGraphs, verdictOf } from '../ubg/apocalypse.js';
|
|
22
|
+
|
|
23
|
+
const ICONS = { critical: '✗', high: '✗', medium: '⚠', info: '·' };
|
|
24
|
+
const err = (message, hint) => Object.assign(new Error(message), { code: 'USER', hint });
|
|
25
|
+
|
|
26
|
+
// A finding's stable identity, so a risk the base already had is never re-blamed
|
|
27
|
+
// on this PR (introduced = present now, absent in the base).
|
|
28
|
+
const findingKey = (f) => `${f.rule}|${f.entrypoint}`;
|
|
29
|
+
const entrypointsOf = (graph) =>
|
|
30
|
+
graph.nodes.filter((n) => n.kind === 'entrypoint').map((n) => n.id);
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Pure core — two canonical graphs in, a review out. No git, no disk, no LLM.
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
export function reviewGraphs(baseGraph, candidateGraph) {
|
|
36
|
+
// protections this deploy REMOVED (guards, invariants, entrypoints, blast radius)
|
|
37
|
+
const removed = diffGraphs(baseGraph, candidateGraph).findings;
|
|
38
|
+
|
|
39
|
+
// provable risks this deploy INTRODUCED: static findings now, minus the ones the
|
|
40
|
+
// base already carried (a pre-existing sin is not this PR's fault).
|
|
41
|
+
const { findings: candStatic, obligations } = checkGraph(candidateGraph);
|
|
42
|
+
const baseKeys = new Set(checkGraph(baseGraph).findings.map(findingKey));
|
|
43
|
+
const introduced = candStatic.filter((f) => !baseKeys.has(findingKey(f)));
|
|
44
|
+
|
|
45
|
+
// union, de-duped by (rule, entrypoint) — a removed guard can surface both as
|
|
46
|
+
// GUARD_REMOVED (diff) and UNGUARDED_MUTATION (static); keep the strongest once.
|
|
47
|
+
const byKey = new Map();
|
|
48
|
+
for (const f of [...removed, ...introduced]) {
|
|
49
|
+
const k = findingKey(f);
|
|
50
|
+
if (!byKey.has(k)) byKey.set(k, f);
|
|
51
|
+
}
|
|
52
|
+
const findings = [...byKey.values()].sort(
|
|
53
|
+
(a, b) => rank(a.severity) - rank(b.severity) || cmp(findingKey(a), findingKey(b)),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const baseEps = new Set(entrypointsOf(baseGraph));
|
|
57
|
+
const candEps = new Set(entrypointsOf(candidateGraph));
|
|
58
|
+
const added = [...candEps].filter((e) => !baseEps.has(e)).sort(cmp);
|
|
59
|
+
const removedEps = [...baseEps].filter((e) => !candEps.has(e)).sort(cmp);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
verdict: verdictOf(findings),
|
|
63
|
+
obligations,
|
|
64
|
+
findings,
|
|
65
|
+
endpoints: { added, removed: removedEps },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const SEVERITY_RANK = { critical: 0, high: 1, medium: 2, info: 3 };
|
|
70
|
+
const rank = (s) => SEVERITY_RANK[s] ?? 9;
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Orchestration — resolve a base ref, compile it in an isolated git worktree,
|
|
74
|
+
// compile the working tree, review, render.
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
export async function runReview(opts) {
|
|
77
|
+
const cwd = opts.cwd;
|
|
78
|
+
const base = resolveBase(cwd, opts.base);
|
|
79
|
+
|
|
80
|
+
const candidate = canonicalizeGraph(
|
|
81
|
+
compileUBG(cwd, { write: false, openapi: opts.openapi }).graph,
|
|
82
|
+
);
|
|
83
|
+
const baseGraph = compileAtRef(cwd, base, opts);
|
|
84
|
+
|
|
85
|
+
const review = { base, ...reviewGraphs(baseGraph, candidate) };
|
|
86
|
+
|
|
87
|
+
if (opts.json) console.log(JSON.stringify(review, null, 2));
|
|
88
|
+
else if (opts.markdown) console.log(renderMarkdown(review));
|
|
89
|
+
else renderHuman(review);
|
|
90
|
+
|
|
91
|
+
if (!review.verdict.safe) process.exitCode = 1; // CI gate
|
|
92
|
+
return review;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// First base ref that resolves. Explicit --base wins; otherwise the usual PR
|
|
96
|
+
// targets, newest-intent first, falling back to the previous commit.
|
|
97
|
+
function resolveBase(cwd, explicit) {
|
|
98
|
+
const candidates = explicit
|
|
99
|
+
? [explicit]
|
|
100
|
+
: ['origin/HEAD', 'origin/main', 'origin/master', 'main', 'master', 'HEAD~1'];
|
|
101
|
+
for (const ref of candidates) {
|
|
102
|
+
try {
|
|
103
|
+
execFileSync(
|
|
104
|
+
'git',
|
|
105
|
+
['-C', cwd, 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`],
|
|
106
|
+
{
|
|
107
|
+
stdio: 'pipe',
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
return ref;
|
|
111
|
+
} catch {
|
|
112
|
+
/* try the next candidate */
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
throw err(
|
|
116
|
+
explicit ? `base ref '${explicit}' not found` : 'could not resolve a base ref',
|
|
117
|
+
'run inside a git repo and pass one with --base <ref> (e.g. --base main)',
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Compile the tree AT a git ref without disturbing the working tree: a detached
|
|
122
|
+
// worktree, compiled, then removed. Static compile — no npm install needed.
|
|
123
|
+
function compileAtRef(cwd, ref, opts) {
|
|
124
|
+
const tmp = path.join(os.tmpdir(), `sparda-review-${process.pid}-${Date.now()}`);
|
|
125
|
+
try {
|
|
126
|
+
execFileSync('git', ['-C', cwd, 'worktree', 'add', '--detach', '--quiet', tmp, ref], {
|
|
127
|
+
stdio: 'pipe',
|
|
128
|
+
});
|
|
129
|
+
} catch (e) {
|
|
130
|
+
throw err(
|
|
131
|
+
`could not check out base ref '${ref}': ${String(e.stderr ?? e.message).trim()}`,
|
|
132
|
+
'ensure the ref exists (e.g. `git fetch origin main`) or pass --base <ref>',
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
return canonicalizeGraph(
|
|
137
|
+
compileUBG(tmp, { write: false, openapi: opts.openapi }).graph,
|
|
138
|
+
);
|
|
139
|
+
} finally {
|
|
140
|
+
try {
|
|
141
|
+
execFileSync('git', ['-C', cwd, 'worktree', 'remove', '--force', tmp], {
|
|
142
|
+
stdio: 'pipe',
|
|
143
|
+
});
|
|
144
|
+
} catch {
|
|
145
|
+
/* best effort */
|
|
146
|
+
}
|
|
147
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Renderers
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
function renderHuman(r) {
|
|
155
|
+
const { verdict: v, findings, endpoints } = r;
|
|
156
|
+
console.log(`REVIEW — behavior diff of the working tree vs ${r.base}`);
|
|
157
|
+
if (endpoints.added.length)
|
|
158
|
+
console.log(` + new endpoints: ${endpoints.added.map(shortEp).join(', ')}`);
|
|
159
|
+
if (endpoints.removed.length)
|
|
160
|
+
console.log(` - removed endpoints: ${endpoints.removed.map(shortEp).join(', ')}`);
|
|
161
|
+
if (!endpoints.added.length && !endpoints.removed.length)
|
|
162
|
+
console.log(' · no endpoints added or removed');
|
|
163
|
+
|
|
164
|
+
if (findings.length) {
|
|
165
|
+
console.log('\n Behavior risks changed by this diff:');
|
|
166
|
+
for (const f of findings)
|
|
167
|
+
console.log(` ${ICONS[f.severity]} [${f.severity}] ${f.rule} — ${f.message}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const c = v.counts;
|
|
171
|
+
if (v.clean)
|
|
172
|
+
console.log(
|
|
173
|
+
'\n✓ PROVEN — this diff removes no protection and introduces no new risk.',
|
|
174
|
+
);
|
|
175
|
+
else
|
|
176
|
+
console.log(
|
|
177
|
+
`\n${v.safe ? '⚠ RISKY' : '✗ NOT PROVEN'} — ${c.critical} critical, ${c.high} high, ${c.medium} medium, ${c.info} info`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function renderMarkdown(r) {
|
|
182
|
+
const { verdict: v, findings, endpoints } = r;
|
|
183
|
+
const c = v.counts;
|
|
184
|
+
const head = v.clean
|
|
185
|
+
? '✓ **PROVEN** — no protection removed, no new risk introduced.'
|
|
186
|
+
: `${v.safe ? '⚠ **RISKY**' : '✗ **NOT PROVEN**'} — ${c.critical} critical, ${c.high} high, ${c.medium} medium, ${c.info} info.`;
|
|
187
|
+
const lines = [`## 🔍 SPARDA semantic review (vs \`${r.base}\`)`, '', head, ''];
|
|
188
|
+
if (endpoints.added.length || endpoints.removed.length) {
|
|
189
|
+
lines.push('### Endpoint surface');
|
|
190
|
+
for (const e of endpoints.added) lines.push(`- 🆕 \`${shortEp(e)}\``);
|
|
191
|
+
for (const e of endpoints.removed) lines.push(`- ❌ \`${shortEp(e)}\` (removed)`);
|
|
192
|
+
lines.push('');
|
|
193
|
+
}
|
|
194
|
+
if (findings.length) {
|
|
195
|
+
lines.push('### Protections removed / risks introduced');
|
|
196
|
+
for (const f of findings)
|
|
197
|
+
lines.push(`- ${ICONS[f.severity]} **[${f.severity}] ${f.rule}** — ${f.message}`);
|
|
198
|
+
lines.push('');
|
|
199
|
+
}
|
|
200
|
+
lines.push(
|
|
201
|
+
'<sub>Derived from the code + schema by SPARDA — a counterexample, not a heuristic.</sub>',
|
|
202
|
+
);
|
|
203
|
+
return lines.join('\n');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const shortEp = (id) => id.replace(/^entrypoint:/, '');
|
package/src/flight/box.js
CHANGED
|
@@ -29,8 +29,11 @@ export function createFlightBox() {
|
|
|
29
29
|
const als = new AsyncLocalStorage();
|
|
30
30
|
const originals = {};
|
|
31
31
|
let armed = false;
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
// Node 18: fetch (undici) and crypto.randomUUID call Date.now()/Math.random()
|
|
33
|
+
// internally — those must NOT be recorded as entropy taps. The suppression
|
|
34
|
+
// flag lives ON THE STORE (per-request), not on the box: a global flag would
|
|
35
|
+
// let one request's fetch window swallow a concurrent request's entropy taps
|
|
36
|
+
// and silently corrupt its flight.
|
|
34
37
|
|
|
35
38
|
function arm() {
|
|
36
39
|
if (armed) return;
|
|
@@ -42,7 +45,7 @@ export function createFlightBox() {
|
|
|
42
45
|
|
|
43
46
|
Date.now = function spardaDateNow() {
|
|
44
47
|
const store = als.getStore();
|
|
45
|
-
if (!store ||
|
|
48
|
+
if (!store || store.suppressEntropy) return originals.dateNow();
|
|
46
49
|
if (store.mode === 'record') {
|
|
47
50
|
const v = originals.dateNow();
|
|
48
51
|
tapOut(store, 'time', 'Date.now', v);
|
|
@@ -53,7 +56,7 @@ export function createFlightBox() {
|
|
|
53
56
|
|
|
54
57
|
Math.random = function spardaRandom() {
|
|
55
58
|
const store = als.getStore();
|
|
56
|
-
if (!store ||
|
|
59
|
+
if (!store || store.suppressEntropy) return originals.random();
|
|
57
60
|
if (store.mode === 'record') {
|
|
58
61
|
const v = originals.random();
|
|
59
62
|
tapOut(store, 'random', 'Math.random', v);
|
|
@@ -69,13 +72,13 @@ export function createFlightBox() {
|
|
|
69
72
|
const store = als.getStore();
|
|
70
73
|
if (!store) return originals.randomUUID();
|
|
71
74
|
if (store.mode === 'record') {
|
|
72
|
-
|
|
75
|
+
store.suppressEntropy = true;
|
|
73
76
|
try {
|
|
74
77
|
const v = originals.randomUUID();
|
|
75
78
|
tapOut(store, 'uuid', 'crypto.randomUUID', v);
|
|
76
79
|
return v;
|
|
77
80
|
} finally {
|
|
78
|
-
|
|
81
|
+
store.suppressEntropy = false;
|
|
79
82
|
}
|
|
80
83
|
}
|
|
81
84
|
return takeTap(store, 'uuid', 'crypto.randomUUID');
|
|
@@ -90,7 +93,7 @@ export function createFlightBox() {
|
|
|
90
93
|
const method = (init?.method ?? 'GET').toUpperCase();
|
|
91
94
|
const label = `${method} ${url}`;
|
|
92
95
|
if (store.mode === 'record') {
|
|
93
|
-
|
|
96
|
+
store.suppressEntropy = true;
|
|
94
97
|
try {
|
|
95
98
|
const res = await originals.fetch(input, init);
|
|
96
99
|
const text = (await res.text()).slice(0, MAX_BODY_BYTES);
|
|
@@ -98,7 +101,7 @@ export function createFlightBox() {
|
|
|
98
101
|
tapOut(store, 'http', label, { status: res.status, headers, body: text });
|
|
99
102
|
return new Response(text, { status: res.status, headers });
|
|
100
103
|
} finally {
|
|
101
|
-
|
|
104
|
+
store.suppressEntropy = false;
|
|
102
105
|
}
|
|
103
106
|
}
|
|
104
107
|
const rec = takeTap(store, 'http', label);
|
package/src/generator/express.js
CHANGED
|
@@ -7,10 +7,11 @@ import { parse } from '@babel/parser';
|
|
|
7
7
|
import { toolNameFor } from '../parser/express.js';
|
|
8
8
|
import { carryOverManifest, defaultSpardingMemory, ensureSpardaKey } from './manifest.js';
|
|
9
9
|
import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
|
|
10
|
+
import { makeInjectionMarkers } from './injection.js';
|
|
10
11
|
|
|
11
12
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
-
const MARK_START
|
|
13
|
-
|
|
13
|
+
const { MARK_START, MARK_END, stripForReinit, stripForRemoval } =
|
|
14
|
+
makeInjectionMarkers('//');
|
|
14
15
|
|
|
15
16
|
export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
|
|
16
17
|
const taken = new Set([
|
|
@@ -115,6 +116,12 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
|
|
|
115
116
|
'__FS_IMPORT__',
|
|
116
117
|
isESM ? "import spardaFs from 'node:fs';" : "const spardaFs = require('node:fs');",
|
|
117
118
|
)
|
|
119
|
+
.replace(
|
|
120
|
+
'__CRYPTO_IMPORT__',
|
|
121
|
+
isESM
|
|
122
|
+
? "import spardaCrypto from 'node:crypto';"
|
|
123
|
+
: "const spardaCrypto = require('node:crypto');",
|
|
124
|
+
)
|
|
118
125
|
.replace('__PORT__', String(port))
|
|
119
126
|
.replace(/__REQ_TYPE__/g, reqType)
|
|
120
127
|
.replace(/__RES_TYPE__/g, resType)
|
|
@@ -188,13 +195,8 @@ function injectIntoEntry({ entryAbs, moduleType, routerFileName, cwd }) {
|
|
|
188
195
|
? `import { spardaRouter } from '${importSpec}';`
|
|
189
196
|
: `const { spardaRouter } = require('${importSpec}');`;
|
|
190
197
|
|
|
191
|
-
// strip previous injection blocks (idempotence)
|
|
192
|
-
|
|
193
|
-
const blockRx = new RegExp(
|
|
194
|
-
`\\n?${escapeRx(MARK_START)}[\\s\\S]*?${escapeRx(MARK_END)}\\n?`,
|
|
195
|
-
'g',
|
|
196
|
-
);
|
|
197
|
-
body = body.replace(blockRx, '\n');
|
|
198
|
+
// strip previous injection blocks (idempotence) — shared inverse-safe strip
|
|
199
|
+
const body = stripForReinit(src);
|
|
198
200
|
|
|
199
201
|
let ast;
|
|
200
202
|
try {
|
|
@@ -276,11 +278,7 @@ export function removeInjection(cwd, manifest) {
|
|
|
276
278
|
const abs = path.resolve(cwd, relFile);
|
|
277
279
|
if (!fs.existsSync(abs)) continue;
|
|
278
280
|
const src = fs.readFileSync(abs, 'utf8');
|
|
279
|
-
const
|
|
280
|
-
`\\n?${escapeRx(MARK_START)}[\\s\\S]*?${escapeRx(MARK_END)}`,
|
|
281
|
-
'g',
|
|
282
|
-
);
|
|
283
|
-
const out = src.replace(blockRx, '');
|
|
281
|
+
const out = stripForRemoval(src);
|
|
284
282
|
try {
|
|
285
283
|
parse(out, { sourceType: 'unambiguous', plugins: ['typescript', 'jsx'] });
|
|
286
284
|
atomicWrite(abs, out);
|
|
@@ -307,7 +305,3 @@ export function ensureGitignore(cwd) {
|
|
|
307
305
|
fs.writeFileSync(gi, `${line}\n`);
|
|
308
306
|
return 'created';
|
|
309
307
|
}
|
|
310
|
-
|
|
311
|
-
function escapeRx(s) {
|
|
312
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
313
|
-
}
|
package/src/generator/fastapi.js
CHANGED
|
@@ -7,10 +7,11 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
import { toolNameFor } from '../parser/express.js';
|
|
8
8
|
import { carryOverManifest, defaultSpardingMemory, ensureSpardaKey } from './manifest.js';
|
|
9
9
|
import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
|
|
10
|
+
import { makeInjectionMarkers } from './injection.js';
|
|
10
11
|
|
|
11
12
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
-
const MARK_START
|
|
13
|
-
|
|
13
|
+
const { MARK_START, MARK_END, stripForReinit, stripForRemoval } =
|
|
14
|
+
makeInjectionMarkers('#');
|
|
14
15
|
|
|
15
16
|
export function generateFastAPI({
|
|
16
17
|
cwd,
|
|
@@ -159,13 +160,9 @@ function injectIntoEntry({ entryAbs, cwd, pythonCmd }) {
|
|
|
159
160
|
// preserve the file's own line endings (Windows checkouts are CRLF)
|
|
160
161
|
const eol = src.includes('\r\n') ? '\r\n' : '\n';
|
|
161
162
|
|
|
162
|
-
// strip previous injection blocks (idempotence)
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
`\\r?\\n?${escapeRx(MARK_START)}[\\s\\S]*?${escapeRx(MARK_END)}\\r?\\n?`,
|
|
166
|
-
'g',
|
|
167
|
-
);
|
|
168
|
-
body = body.replace(blockRx, eol);
|
|
163
|
+
// strip previous injection blocks (idempotence) — shared inverse-safe strip.
|
|
164
|
+
// The block boundaries are normalized back to `eol` by the split/join below.
|
|
165
|
+
const body = stripForReinit(src);
|
|
169
166
|
|
|
170
167
|
// Let's find the FastAPI() call assignment to inject right after it
|
|
171
168
|
// ([ \t]*) — NOT (\s*): \s matches newlines and would swallow blank lines/CR into the "indent" (E-007)
|
|
@@ -227,11 +224,7 @@ export function removeInjection(cwd, manifest, pythonCmd = 'python') {
|
|
|
227
224
|
const abs = path.resolve(cwd, relFile);
|
|
228
225
|
if (!fs.existsSync(abs)) continue;
|
|
229
226
|
const src = fs.readFileSync(abs, 'utf8');
|
|
230
|
-
const
|
|
231
|
-
`\\r?\\n?${escapeRx(MARK_START)}[\\s\\S]*?${escapeRx(MARK_END)}`,
|
|
232
|
-
'g',
|
|
233
|
-
);
|
|
234
|
-
const out = src.replace(blockRx, '');
|
|
227
|
+
const out = stripForRemoval(src);
|
|
235
228
|
|
|
236
229
|
if (verifyPythonSyntax(abs, out, pythonCmd)) {
|
|
237
230
|
atomicWrite(abs, out);
|
|
@@ -281,7 +274,3 @@ function ensureGitignore(cwd) {
|
|
|
281
274
|
fs.writeFileSync(gi, `${line}\n`);
|
|
282
275
|
return 'created';
|
|
283
276
|
}
|
|
284
|
-
|
|
285
|
-
function escapeRx(s) {
|
|
286
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
287
|
-
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// generator/injection.js — the marked-block contract, shared by every generator that
|
|
2
|
+
// injects a router into a user's entry file. ONE definition of the markers and ONE pair
|
|
3
|
+
// of strip operations, so an inject and its inverse can never drift apart (hard rule #4:
|
|
4
|
+
// `sparda remove` must leave a byte-for-byte clean diff).
|
|
5
|
+
//
|
|
6
|
+
// Two strips, deliberately different — this asymmetry is the whole point:
|
|
7
|
+
//
|
|
8
|
+
// stripForReinit — removes an existing block AND the newlines hugging it, leaving a
|
|
9
|
+
// single '\n'. Used on re-init, just before the fresh block is spliced back in.
|
|
10
|
+
//
|
|
11
|
+
// stripForRemoval — the exact byte inverse of a line-spliced insert. The block is
|
|
12
|
+
// inserted as whole lines *before* an existing line, so relative to the original it
|
|
13
|
+
// adds the block text plus ONE trailing newline; the newline that precedes the block
|
|
14
|
+
// already belonged to the file. So removal consumes the block + its trailing newline
|
|
15
|
+
// only, never the leading one. (The previous regex consumed the LEADING newline
|
|
16
|
+
// instead — byte-perfect for a mid-file block, but it left a stray blank line when the
|
|
17
|
+
// block sat at the very top of the entry file, i.e. `insertAt === 0`.)
|
|
18
|
+
|
|
19
|
+
export function escapeRx(s) {
|
|
20
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function makeInjectionMarkers(commentPrefix) {
|
|
24
|
+
const MARK_START = `${commentPrefix} >>> sparda-injection (do not edit this block) >>>`;
|
|
25
|
+
const MARK_END = `${commentPrefix} <<< sparda-injection <<<`;
|
|
26
|
+
const core = `${escapeRx(MARK_START)}[\\s\\S]*?${escapeRx(MARK_END)}`;
|
|
27
|
+
return {
|
|
28
|
+
MARK_START,
|
|
29
|
+
MARK_END,
|
|
30
|
+
// re-init: drop the old block and the newlines around it, keep one separator.
|
|
31
|
+
stripForReinit: (src) =>
|
|
32
|
+
src.replace(new RegExp(`\\r?\\n?${core}\\r?\\n?`, 'g'), '\n'),
|
|
33
|
+
// remove: block + its own trailing newline only — the leading newline is the file's.
|
|
34
|
+
stripForRemoval: (src) => src.replace(new RegExp(`${core}\\r?\\n?`, 'g'), ''),
|
|
35
|
+
};
|
|
36
|
+
}
|
package/src/generator/nextjs.js
CHANGED
|
@@ -1,120 +1,120 @@
|
|
|
1
|
-
// generator/nextjs.js — file-based injection for the App Router.
|
|
2
|
-
// Next.js has no `app = express()` line to inject after: the filesystem is the
|
|
3
|
-
// router, so SPARDA's injection IS a generated catch-all route handler at
|
|
4
|
-
// <appDir>/mcp/[...sparda]/route.js. Nothing in the user's code is touched —
|
|
5
|
-
// `remove` deletes the file and the diff is clean by construction.
|
|
6
|
-
import fs from 'node:fs';
|
|
7
|
-
import path from 'node:path';
|
|
8
|
-
import crypto from 'node:crypto';
|
|
9
|
-
import { fileURLToPath } from 'node:url';
|
|
10
|
-
import { toolNameFor } from '../parser/express.js';
|
|
11
|
-
import { ensureGitignore } from './express.js';
|
|
12
|
-
import { carryOverManifest, defaultSpardingMemory, ensureSpardaKey } from './manifest.js';
|
|
13
|
-
import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
|
|
14
|
-
|
|
15
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
-
|
|
17
|
-
export function generateNext({ cwd, appDir, port, routes }) {
|
|
18
|
-
const taken = new Set([
|
|
19
|
-
'sparda_info',
|
|
20
|
-
'sparda_list_disabled_tools',
|
|
21
|
-
'sparda_get_context',
|
|
22
|
-
]);
|
|
23
|
-
const tools = {};
|
|
24
|
-
for (const r of routes) {
|
|
25
|
-
const name = toolNameFor(r, taken);
|
|
26
|
-
tools[name] = {
|
|
27
|
-
method: r.method.toUpperCase(),
|
|
28
|
-
path: r.path,
|
|
29
|
-
enabled: !r.mutating, // write-safety: mutating tools off by default
|
|
30
|
-
pathParams: r.params.filter((p) => p.in === 'path').map((p) => p.name),
|
|
31
|
-
description: r.description,
|
|
32
|
-
params: r.params,
|
|
33
|
-
confidence: r.confidence,
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
const prev = carryOverManifest(cwd, tools);
|
|
37
|
-
|
|
38
|
-
// --- sparding safety memory & fingerprints (same contract as Express/FastAPI)
|
|
39
|
-
const sparding = defaultSpardingMemory(prev);
|
|
40
|
-
const oldFingerprints = sparding.toolFingerprints ?? {};
|
|
41
|
-
const newFingerprints = {};
|
|
42
|
-
for (const [name, t] of Object.entries(tools)) {
|
|
43
|
-
const raw = `${t.method}|${t.path}|${JSON.stringify(t.params)}`;
|
|
44
|
-
const fp = crypto.createHash('sha256').update(raw).digest('hex').slice(0, 8);
|
|
45
|
-
newFingerprints[name] = fp;
|
|
46
|
-
const oldFp = oldFingerprints[name];
|
|
47
|
-
if (oldFp && oldFp !== fp) {
|
|
48
|
-
sparding.events.push({
|
|
49
|
-
ts: new Date().toISOString(),
|
|
50
|
-
tool: name,
|
|
51
|
-
decision: 'audit',
|
|
52
|
-
risk: 'medium',
|
|
53
|
-
reasons: [
|
|
54
|
-
`route structure modified (fingerprint changed from ${oldFp} to ${fp})`,
|
|
55
|
-
],
|
|
56
|
-
});
|
|
57
|
-
if (sparding.events.length > 100) sparding.events.shift();
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
sparding.toolFingerprints = newFingerprints;
|
|
61
|
-
|
|
62
|
-
// stable across re-runs so a running bridge/host pair never desyncs
|
|
63
|
-
const localKey = ensureSpardaKey(cwd, prev);
|
|
64
|
-
|
|
65
|
-
// .js on purpose: Next enables allowJs in the tsconfig it manages, so the
|
|
66
|
-
// generated handler compiles untouched inside TS projects too.
|
|
67
|
-
const routerRel = `${appDir}/mcp/[...sparda]/route.js`;
|
|
68
|
-
const routerAbs = path.join(cwd, ...routerRel.split('/'));
|
|
69
|
-
fs.mkdirSync(path.dirname(routerAbs), { recursive: true });
|
|
70
|
-
|
|
71
|
-
let tpl = fs.readFileSync(
|
|
72
|
-
path.join(__dirname, '..', '..', 'templates', 'nextjs-router.txt'),
|
|
73
|
-
'utf8',
|
|
74
|
-
);
|
|
75
|
-
tpl = tpl
|
|
76
|
-
.replace('__TOOLS_JSON__', JSON.stringify(tools, null, 2))
|
|
77
|
-
.replace('__SPARDING_POLICIES__', JSON.stringify(sparding.policies ?? {}))
|
|
78
|
-
.replace('__PORT__', String(port));
|
|
79
|
-
atomicWrite(routerAbs, tpl);
|
|
80
|
-
|
|
81
|
-
const gitignore = ensureGitignore(cwd) ?? prev?.gitignore ?? null;
|
|
82
|
-
|
|
83
|
-
const manifest = {
|
|
84
|
-
version: 1,
|
|
85
|
-
framework: 'nextjs',
|
|
86
|
-
entryFile: appDir, // the scanned App Router root, not a code entry point
|
|
87
|
-
port,
|
|
88
|
-
localKey,
|
|
89
|
-
generatedFiles: [routerRel],
|
|
90
|
-
injectedFiles: [], // file-based injection: no user file is ever modified
|
|
91
|
-
createdAt: new Date().toISOString(),
|
|
92
|
-
tools: Object.fromEntries(
|
|
93
|
-
Object.entries(tools).map(([k, v]) => [
|
|
94
|
-
k,
|
|
95
|
-
{ method: v.method, path: v.path, enabled: v.enabled },
|
|
96
|
-
]),
|
|
97
|
-
),
|
|
98
|
-
...(gitignore ? { gitignore } : {}),
|
|
99
|
-
...(prev?.semantic ? { semantic: prev.semantic } : {}),
|
|
100
|
-
...(prev?.immune ? { immune: prev.immune } : {}),
|
|
101
|
-
...(prev?.labs ? { labs: prev.labs } : {}),
|
|
102
|
-
sparding,
|
|
103
|
-
};
|
|
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
|
-
);
|
|
113
|
-
|
|
114
|
-
return {
|
|
115
|
-
tools,
|
|
116
|
-
manifest,
|
|
117
|
-
routerFile: routerRel,
|
|
118
|
-
injection: { injected: false, manual: null, fileBased: true },
|
|
119
|
-
};
|
|
120
|
-
}
|
|
1
|
+
// generator/nextjs.js — file-based injection for the App Router.
|
|
2
|
+
// Next.js has no `app = express()` line to inject after: the filesystem is the
|
|
3
|
+
// router, so SPARDA's injection IS a generated catch-all route handler at
|
|
4
|
+
// <appDir>/mcp/[...sparda]/route.js. Nothing in the user's code is touched —
|
|
5
|
+
// `remove` deletes the file and the diff is clean by construction.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { toolNameFor } from '../parser/express.js';
|
|
11
|
+
import { ensureGitignore } from './express.js';
|
|
12
|
+
import { carryOverManifest, defaultSpardingMemory, ensureSpardaKey } from './manifest.js';
|
|
13
|
+
import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
|
|
14
|
+
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
|
|
17
|
+
export function generateNext({ cwd, appDir, port, routes }) {
|
|
18
|
+
const taken = new Set([
|
|
19
|
+
'sparda_info',
|
|
20
|
+
'sparda_list_disabled_tools',
|
|
21
|
+
'sparda_get_context',
|
|
22
|
+
]);
|
|
23
|
+
const tools = {};
|
|
24
|
+
for (const r of routes) {
|
|
25
|
+
const name = toolNameFor(r, taken);
|
|
26
|
+
tools[name] = {
|
|
27
|
+
method: r.method.toUpperCase(),
|
|
28
|
+
path: r.path,
|
|
29
|
+
enabled: !r.mutating, // write-safety: mutating tools off by default
|
|
30
|
+
pathParams: r.params.filter((p) => p.in === 'path').map((p) => p.name),
|
|
31
|
+
description: r.description,
|
|
32
|
+
params: r.params,
|
|
33
|
+
confidence: r.confidence,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const prev = carryOverManifest(cwd, tools);
|
|
37
|
+
|
|
38
|
+
// --- sparding safety memory & fingerprints (same contract as Express/FastAPI)
|
|
39
|
+
const sparding = defaultSpardingMemory(prev);
|
|
40
|
+
const oldFingerprints = sparding.toolFingerprints ?? {};
|
|
41
|
+
const newFingerprints = {};
|
|
42
|
+
for (const [name, t] of Object.entries(tools)) {
|
|
43
|
+
const raw = `${t.method}|${t.path}|${JSON.stringify(t.params)}`;
|
|
44
|
+
const fp = crypto.createHash('sha256').update(raw).digest('hex').slice(0, 8);
|
|
45
|
+
newFingerprints[name] = fp;
|
|
46
|
+
const oldFp = oldFingerprints[name];
|
|
47
|
+
if (oldFp && oldFp !== fp) {
|
|
48
|
+
sparding.events.push({
|
|
49
|
+
ts: new Date().toISOString(),
|
|
50
|
+
tool: name,
|
|
51
|
+
decision: 'audit',
|
|
52
|
+
risk: 'medium',
|
|
53
|
+
reasons: [
|
|
54
|
+
`route structure modified (fingerprint changed from ${oldFp} to ${fp})`,
|
|
55
|
+
],
|
|
56
|
+
});
|
|
57
|
+
if (sparding.events.length > 100) sparding.events.shift();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
sparding.toolFingerprints = newFingerprints;
|
|
61
|
+
|
|
62
|
+
// stable across re-runs so a running bridge/host pair never desyncs
|
|
63
|
+
const localKey = ensureSpardaKey(cwd, prev);
|
|
64
|
+
|
|
65
|
+
// .js on purpose: Next enables allowJs in the tsconfig it manages, so the
|
|
66
|
+
// generated handler compiles untouched inside TS projects too.
|
|
67
|
+
const routerRel = `${appDir}/mcp/[...sparda]/route.js`;
|
|
68
|
+
const routerAbs = path.join(cwd, ...routerRel.split('/'));
|
|
69
|
+
fs.mkdirSync(path.dirname(routerAbs), { recursive: true });
|
|
70
|
+
|
|
71
|
+
let tpl = fs.readFileSync(
|
|
72
|
+
path.join(__dirname, '..', '..', 'templates', 'nextjs-router.txt'),
|
|
73
|
+
'utf8',
|
|
74
|
+
);
|
|
75
|
+
tpl = tpl
|
|
76
|
+
.replace('__TOOLS_JSON__', JSON.stringify(tools, null, 2))
|
|
77
|
+
.replace('__SPARDING_POLICIES__', JSON.stringify(sparding.policies ?? {}))
|
|
78
|
+
.replace('__PORT__', String(port));
|
|
79
|
+
atomicWrite(routerAbs, tpl);
|
|
80
|
+
|
|
81
|
+
const gitignore = ensureGitignore(cwd) ?? prev?.gitignore ?? null;
|
|
82
|
+
|
|
83
|
+
const manifest = {
|
|
84
|
+
version: 1,
|
|
85
|
+
framework: 'nextjs',
|
|
86
|
+
entryFile: appDir, // the scanned App Router root, not a code entry point
|
|
87
|
+
port,
|
|
88
|
+
localKey,
|
|
89
|
+
generatedFiles: [routerRel],
|
|
90
|
+
injectedFiles: [], // file-based injection: no user file is ever modified
|
|
91
|
+
createdAt: new Date().toISOString(),
|
|
92
|
+
tools: Object.fromEntries(
|
|
93
|
+
Object.entries(tools).map(([k, v]) => [
|
|
94
|
+
k,
|
|
95
|
+
{ method: v.method, path: v.path, enabled: v.enabled },
|
|
96
|
+
]),
|
|
97
|
+
),
|
|
98
|
+
...(gitignore ? { gitignore } : {}),
|
|
99
|
+
...(prev?.semantic ? { semantic: prev.semantic } : {}),
|
|
100
|
+
...(prev?.immune ? { immune: prev.immune } : {}),
|
|
101
|
+
...(prev?.labs ? { labs: prev.labs } : {}),
|
|
102
|
+
sparding,
|
|
103
|
+
};
|
|
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
|
+
);
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
tools,
|
|
116
|
+
manifest,
|
|
117
|
+
routerFile: routerRel,
|
|
118
|
+
injection: { injected: false, manual: null, fileBased: true },
|
|
119
|
+
};
|
|
120
|
+
}
|