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.
@@ -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
- let insideUUID = false;
33
- let insideFetch = false;
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 || insideUUID || insideFetch) return originals.dateNow();
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 || insideUUID || insideFetch) return originals.random();
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
- insideUUID = true;
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
- insideUUID = false;
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
- insideFetch = true;
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
- insideFetch = false;
104
+ store.suppressEntropy = false;
102
105
  }
103
106
  }
104
107
  const rec = takeTap(store, 'http', label);
@@ -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 = '// >>> sparda-injection (do not edit this block) >>>';
13
- const MARK_END = '// <<< sparda-injection <<<';
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
- let body = src;
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 blockRx = new RegExp(
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
- }
@@ -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 = '# >>> sparda-injection (do not edit this block) >>>';
13
- const MARK_END = '# <<< sparda-injection <<<';
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
- let body = src;
164
- const blockRx = new RegExp(
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 blockRx = new RegExp(
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
+ }
@@ -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
+ }