sparda-mcp 0.71.1 → 0.71.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.71.1",
3
+ "version": "0.71.3",
4
4
  "mcpName": "io.github.zyx77550/sparda-mcp",
5
5
  "description": "AI writes. SPARDA proves. A deterministic, offline gate that catches when an AI edit removes a guard, exposes a route, or breaks an invariant \u2014 no API key, right in the agent edit loop.",
6
6
  "type": "module",
@@ -34,6 +34,12 @@ function installShim() {
34
34
  const net = require('net');
35
35
  const Module = require('module');
36
36
 
37
+ // Did we ever get our hands on the express module? The probe used to report "the app did not
38
+ // boot" for this, which is a WRONG diagnosis — sending the user to debug their app when the
39
+ // app was fine and the SHIM never hooked (E-109). Reported to the parent so the reason it
40
+ // prints is the true one.
41
+ let patched = false;
42
+
37
43
  const IPC_PORT = parseInt(process.env.SPARDA_IPC_PORT, 10) || 0;
38
44
 
39
45
  // ── Transport: fork IPC or TCP fallback ────────────────────────────────────
@@ -73,6 +79,7 @@ function installShim() {
73
79
  }
74
80
 
75
81
  function sendDone() {
82
+ flush(); // the staged routes must go out BEFORE the parent is told there are no more
76
83
  if (typeof process.send === 'function') {
77
84
  try {
78
85
  process.send({ type: '__done__' });
@@ -96,11 +103,65 @@ function installShim() {
96
103
  idleTimer = setTimeout(sendDone, IDLE_MS);
97
104
  }
98
105
 
99
- function record(method, path) {
100
- sendMsg({ type: 'route', method, path });
106
+ // ── Routes are STAGED, not emitted — the mount point is not known yet ──────
107
+ //
108
+ // E-110. `usersRouter.get('/:id')` runs at import time; `app.use('/api/users', usersRouter)`
109
+ // runs later. Emitting at registration therefore reported `GET /:id`, and reconcile compared
110
+ // that against the compiler's (correct) `/api/users/:id` and called it a route the app serves
111
+ // and the compiler never saw. On demo-app: two FALSE premise gaps out of three.
112
+ //
113
+ // False gaps are in the SAFE direction for a verdict — they only make SPARDA refuse to claim
114
+ // PROVEN — which is exactly why this could sit unnoticed. They are in the WRONG direction for
115
+ // anything that consumes gaps as findings: every real Express app uses routers, so the most
116
+ // load-bearing signal the runtime oracle produces was also its noisiest.
117
+ //
118
+ // So: buffer each route with the OBJECT it was registered on, record the mount edges, and
119
+ // resolve full paths once the app is fully wired (at `listen`, or on idle).
120
+ const staged = [];
121
+ const mounts = []; // { parent, child, path }
122
+ let flushed = false;
123
+
124
+ function stage(owner, method, path) {
125
+ staged.push({ owner, method, path });
101
126
  resetIdle();
102
127
  }
103
128
 
129
+ const joinPaths = (prefix, path) => {
130
+ const a = String(prefix || '').replace(/\/+$/, '');
131
+ const b = String(path || '');
132
+ if (b === '/' || b === '') return a || '/';
133
+ return a + (b.startsWith('/') ? b : '/' + b) || '/';
134
+ };
135
+
136
+ // Every full path this owner is reachable at. A router CAN be mounted more than once, and then
137
+ // its routes genuinely exist at several paths — so this returns a list, never a single answer.
138
+ function prefixesOf(owner, depth = 0) {
139
+ if (depth > 12) return ['']; // pathological nesting or a cycle — stop, do not hang
140
+ const edges = mounts.filter((m) => m.child === owner);
141
+ if (edges.length === 0) return ['']; // a root (the app itself, or an unmounted router)
142
+ const out = [];
143
+ for (const e of edges) {
144
+ for (const up of prefixesOf(e.parent, depth + 1)) out.push(joinPaths(up, e.path));
145
+ }
146
+ return out.length ? out : [''];
147
+ }
148
+
149
+ function flush() {
150
+ if (flushed) return;
151
+ flushed = true;
152
+ const seen = new Set();
153
+ for (const r of staged) {
154
+ for (const prefix of prefixesOf(r.owner)) {
155
+ const full = joinPaths(prefix, r.path);
156
+ const key = `${r.method} ${full}`;
157
+ if (seen.has(key)) continue; // the same route reached twice is one route
158
+ seen.add(key);
159
+ sendMsg({ type: 'route', method: r.method, path: full });
160
+ }
161
+ }
162
+ staged.length = 0;
163
+ }
164
+
104
165
  // ── HTTP method list ───────────────────────────────────────────────────────
105
166
 
106
167
  const HTTP_METHODS = [
@@ -131,11 +192,34 @@ function installShim() {
131
192
  // Only record route registrations (path + at least one handler/middleware).
132
193
  if (typeof path === 'string' && rest.length > 0) {
133
194
  const verb = m === 'del' ? 'DELETE' : m.toUpperCase();
134
- record(verb, path);
195
+ stage(this, verb, path);
135
196
  }
136
197
  return orig.call(this, path, ...rest);
137
198
  };
138
199
  }
200
+
201
+ // `use` is what makes a path mean something. Recorded, never altered: we only need to know
202
+ // WHICH object was mounted WHERE, so a route registered on it can be reported at the address
203
+ // the framework actually serves it from.
204
+ if (typeof target.use === 'function' && !target.__sparda_use_wrapped__) {
205
+ const origUse = target.use;
206
+ target.use = function spardaUse(...args) {
207
+ const mountPath = typeof args[0] === 'string' ? args[0] : '/';
208
+ for (const h of args) {
209
+ // A mounted router is a function carrying a middleware `stack` — that is what tells it
210
+ // apart from a plain handler like `express.json()`.
211
+ if (typeof h === 'function' && Array.isArray(h.stack))
212
+ mounts.push({ parent: this, child: h, path: mountPath });
213
+ }
214
+ return origUse.apply(this, args);
215
+ };
216
+ try {
217
+ Object.defineProperty(target, '__sparda_use_wrapped__', {
218
+ value: true,
219
+ configurable: true,
220
+ });
221
+ } catch {}
222
+ }
139
223
  try {
140
224
  Object.defineProperty(target, '__sparda_wrapped__', {
141
225
  value: true,
@@ -217,7 +301,10 @@ function installShim() {
217
301
  const originalLoad = Module._load;
218
302
  Module._load = function spardaLoad(request) {
219
303
  const result = originalLoad.apply(this, arguments);
220
- if (request === 'express') patchExpress(result);
304
+ if (request === 'express') {
305
+ patched = true;
306
+ patchExpress(result);
307
+ }
221
308
  return result;
222
309
  };
223
310
 
@@ -231,10 +318,43 @@ function installShim() {
231
318
  );
232
319
  for (const p of expressPaths) {
233
320
  const cached = require.cache[p];
234
- if (cached && cached.exports) patchExpress(cached.exports);
321
+ if (cached && cached.exports) {
322
+ patched = true;
323
+ patchExpress(cached.exports);
324
+ }
235
325
  }
236
326
  } catch {}
237
327
 
328
+ // ── ESM entries: the hook above CANNOT fire, so pull express in ourselves ──
329
+ //
330
+ // E-109. `Module._load` interception is a CJS-loader mechanism. On Node 22 an ESM
331
+ // `import express from 'express'` does NOT go through `Module._load` — measured, and it
332
+ // contradicts the comment this shim's ESM wrapper used to carry. So for every Express app
333
+ // written in ESM (the modern default: `.mjs`, or `.js` under `"type": "module"`) the shim
334
+ // installed itself and then intercepted nothing, forever. The probe reported a timeout, the
335
+ // premise stayed `unmeasured`, and an ESM Express app could never reach PROVEN.
336
+ //
337
+ // The fix does not need a loader hook. express is CJS, and a CJS module loaded through the
338
+ // ESM bridge comes from the SAME `require.cache`: so if we require it FIRST, the app's later
339
+ // `import` receives the already-patched instance. Verified — the marker survives the import.
340
+ //
341
+ // Resolved from the ENTRY FILE, never from this shim's own directory: SPARDA carries express
342
+ // as a devDependency, and patching SPARDA's copy while the app imports its own would hook a
343
+ // module nobody uses — the same instance-identity trap, one level down.
344
+ if (!patched) {
345
+ try {
346
+ const path = require('path');
347
+ const from = process.argv[1] || path.join(process.cwd(), 'index.js');
348
+ const appRequire = Module.createRequire(path.resolve(from));
349
+ appRequire('express'); // goes through Module._load → sets `patched`, runs patchExpress
350
+ } catch {
351
+ // no express resolvable from the app — `patched` stays false and the parent is told so,
352
+ // which is a different statement from "the app did not boot"
353
+ }
354
+ }
355
+
356
+ sendMsg({ type: '__shim__', patched });
357
+
238
358
  // ── Safety nets ────────────────────────────────────────────────────────────
239
359
 
240
360
  process.on('exit', () => {
@@ -247,5 +367,5 @@ function installShim() {
247
367
  setTimeout(() => process.exit(0), 200);
248
368
  });
249
369
 
250
- module.exports = { record, sendDone };
370
+ module.exports = { stage, flush, sendDone };
251
371
  }
@@ -52,12 +52,28 @@ async function probeExpress({ entryFile, projectRoot, timeoutMs }) {
52
52
  return [];
53
53
  }
54
54
 
55
+ // Which shim FLAG is used turns out not to matter, and that is worth stating because the
56
+ // obvious "fix" for E-109 was to detect `"type": "module"` here and switch to `--import`. It
57
+ // changes nothing measurable: `--require` preloads the CJS shim fine ahead of an ESM entry, and
58
+ // the shim's own eager `require('express')` is what actually closes the hole. A second
59
+ // mechanism that no test can distinguish is dead weight — E-106, one week old, is what happens
60
+ // when such a line is kept.
55
61
  const isEsm = ext === '.mjs';
56
62
  const shimFlag = isEsm ? ['--import', SHIM_ESM] : ['--require', SHIM_CJS];
57
63
 
58
64
  const routes = [];
59
65
  let child;
60
66
 
67
+ // WHY the probe saw nothing is a different fact from THAT it saw nothing, and the old code
68
+ // could only say the second (E-109). It reported every silence as "the app did not boot",
69
+ // which sent users to debug a healthy app while the real cause was the shim never hooking.
70
+ // `shimPatched` stays null until the child says; the child's stderr is KEPT rather than
71
+ // dropped on the floor, because it is the only place an app's own crash is written down.
72
+ let shimPatched = null;
73
+ let timedOut = false;
74
+ let exitCode = null;
75
+ let stderrTail = '';
76
+
61
77
  return new Promise((resolve_) => {
62
78
  let settled = false;
63
79
  let killTimer;
@@ -69,10 +85,23 @@ async function probeExpress({ entryFile, projectRoot, timeoutMs }) {
69
85
  try {
70
86
  child && child.kill('SIGKILL');
71
87
  } catch {}
88
+ // Non-enumerable so `probeRoutes` keeps returning exactly an array of routes: every
89
+ // existing caller and assertion is untouched, and the reason travels with it.
90
+ Object.defineProperty(result, 'diagnostic', {
91
+ value: diagnose({
92
+ count: result.length,
93
+ shimPatched,
94
+ timedOut,
95
+ exitCode,
96
+ stderrTail,
97
+ }),
98
+ enumerable: false,
99
+ });
72
100
  resolve_(result);
73
101
  }
74
102
 
75
103
  killTimer = setTimeout(() => {
104
+ timedOut = true;
76
105
  process.stderr.write(
77
106
  '[sparda] --probe: timeout waiting for Express routes; using static floor.\n',
78
107
  );
@@ -95,10 +124,19 @@ async function probeExpress({ entryFile, projectRoot, timeoutMs }) {
95
124
  return;
96
125
  }
97
126
 
98
- child.stderr?.on('data', () => {});
127
+ // KEPT, capped. The target's own boot error ("cannot connect to postgres") is written
128
+ // nowhere else, and discarding it is what made a shim that hooked nothing look identical
129
+ // to an app that refused to start.
130
+ child.stderr?.on('data', (chunk) => {
131
+ if (stderrTail.length < 4000) stderrTail += String(chunk);
132
+ });
99
133
 
100
134
  child.on('message', (msg) => {
101
135
  if (!msg || typeof msg !== 'object') return;
136
+ if (msg.type === '__shim__') {
137
+ shimPatched = msg.patched === true;
138
+ return;
139
+ }
102
140
  if (msg.type === '__done__') {
103
141
  settle(routes);
104
142
  return;
@@ -121,10 +159,69 @@ async function probeExpress({ entryFile, projectRoot, timeoutMs }) {
121
159
  settle(routes);
122
160
  });
123
161
 
124
- child.on('exit', () => settle(routes));
162
+ // `exit` gives the code; `close` is the one that means the stdio streams have been fully
163
+ // drained. Settling on `exit` races the last chunk of the child's stderr — under load the
164
+ // diagnostic came out without the very error it exists to carry.
165
+ child.on('exit', (code) => {
166
+ exitCode = code;
167
+ });
168
+ child.on('close', () => settle(routes));
125
169
  });
126
170
  }
127
171
 
172
+ /**
173
+ * Why did the probe see what it saw? Four distinguishable states, and the point of naming them
174
+ * is that three of them used to print as the fourth (E-109):
175
+ *
176
+ * observed routes came back — nothing to explain
177
+ * not-instrumented the shim never got hold of express. NOT a boot failure: the app may be
178
+ * running perfectly. This is the ESM case, and the one that hid for a release.
179
+ * no-routes express WAS instrumented and the app registered none before we stopped.
180
+ * did-not-start the child never reported in at all, or exited non-zero — the app itself.
181
+ *
182
+ * `reason` is the sentence a user acts on, so it must never assert the app is broken when what
183
+ * actually happened is that SPARDA could not look.
184
+ */
185
+ export function diagnose({ count, shimPatched, timedOut, exitCode, stderrTail }) {
186
+ const tail = String(stderrTail || '')
187
+ .trim()
188
+ .split('\n')
189
+ .slice(-3)
190
+ .join(' | ')
191
+ .slice(0, 300);
192
+ if (count > 0)
193
+ return { state: 'observed', reason: `${count} route(s) observed at runtime` };
194
+ // A non-zero exit wins over everything below: whatever we managed to instrument, the app
195
+ // itself died, and that is the fact its author needs. (Our own timeout SIGKILLs the child,
196
+ // which leaves exitCode null — so this cannot swallow the timeout cases.)
197
+ if (exitCode != null && exitCode !== 0)
198
+ return {
199
+ state: 'did-not-start',
200
+ reason: `the app exited ${exitCode} before serving anything`,
201
+ stderrTail: tail,
202
+ };
203
+ if (shimPatched === false)
204
+ return {
205
+ state: 'not-instrumented',
206
+ reason:
207
+ 'the probe never got hold of the express module, so nothing could be observed — the app may well be running fine (this is not a boot failure)',
208
+ stderrTail: tail,
209
+ };
210
+ if (shimPatched === true)
211
+ return {
212
+ state: 'no-routes',
213
+ reason: timedOut
214
+ ? 'express was instrumented but no route was registered before the timeout — the app may boot slowly or block on a dependency'
215
+ : 'express was instrumented and the app registered no routes',
216
+ stderrTail: tail,
217
+ };
218
+ return {
219
+ state: 'did-not-start',
220
+ reason: `the probe child never reported in${exitCode != null ? ` (exit ${exitCode})` : ''} — the app did not start`,
221
+ stderrTail: tail,
222
+ };
223
+ }
224
+
128
225
  // ── FastAPI probe ─────────────────────────────────────────────────────────────
129
226
 
130
227
  async function probeFastAPI({ entryFile, projectRoot, timeoutMs }) {
@@ -108,12 +108,20 @@ export async function verifyPremise(
108
108
  // "an app with no routes". Treating that as a clean bill of health would be the
109
109
  // worst possible reading — a broken oracle would silently CONFIRM every proof. So
110
110
  // an empty probe is "unavailable", never "verified".
111
+ //
112
+ // The reason, however, must be the TRUE one. This used to read "the app did not boot, or
113
+ // exposes none" for every silence, including the case where the app was running perfectly and
114
+ // the shim had simply never hooked express — a wrong diagnosis that pointed the user at their
115
+ // own code (E-109). `probeRoutes` now attaches which of the four states actually happened.
111
116
  if (!Array.isArray(probed) || probed.length === 0)
112
117
  return {
113
118
  available: false,
114
119
  gaps: [],
115
120
  probed: 0,
116
- reason: 'probe returned no routes — the app did not boot, or exposes none',
121
+ reason:
122
+ probed?.diagnostic?.reason ??
123
+ 'probe returned no routes — the app did not boot, or exposes none',
124
+ ...(probed?.diagnostic ? { probeState: probed.diagnostic.state } : {}),
117
125
  };
118
126
 
119
127
  const { gaps } = reconcile(entrypointsAsRoutes(graph), probed);