sparda-mcp 0.5.0 → 0.5.2

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,17 +1,20 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Turn any codebase into an MCP server with one command. Express & FastAPI → Claude-ready in 3 minutes.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "sparda": "./src/index.js"
8
8
  },
9
9
  "scripts": {
10
- "test": "vitest run"
10
+ "test": "vitest run",
11
+ "test:router": "node tests/router-selftest.cjs",
12
+ "test:all": "vitest run && node tests/router-selftest.cjs"
11
13
  },
12
14
  "files": [
13
15
  "src",
14
16
  "templates",
17
+ "!templates/*.bak",
15
18
  "README.md",
16
19
  "LICENSE"
17
20
  ],
@@ -36,7 +39,7 @@
36
39
  "@modelcontextprotocol/sdk": "1.29.0"
37
40
  },
38
41
  "devDependencies": {
39
- "express": "4.21.2",
42
+ "express": "^4.21.0",
40
43
  "vitest": "^3.2.6"
41
44
  }
42
45
  }
@@ -22,6 +22,7 @@ export async function runInit(opts) {
22
22
  const stack = detectStack(opts.cwd);
23
23
 
24
24
  let routes, skipped, entryAppVars;
25
+ let dynamicGaps = [];
25
26
  if (stack.framework === 'express') {
26
27
  const res = parseExpressProject(opts.cwd, stack.entryFile);
27
28
  routes = res.routes;
@@ -35,6 +36,33 @@ export async function runInit(opts) {
35
36
  s.stop(`Stack detected: ${c.cyan('FastAPI')} — entry: ${stack.entryFile}, port: ${stack.port}`);
36
37
  }
37
38
 
39
+ // Opt-in runtime probe (Brief #3): static is the floor, probe only ADDS routes
40
+ // the AST missed (dynamic mounts, programmatic registration). Off by default →
41
+ // behavior byte-identical. Executing the host app has side-effects, so it is
42
+ // gated behind --probe, warned on stderr, and degrades to static-only on any
43
+ // failure — a probe error must never break init (R1).
44
+ if (opts.probe) {
45
+ p.log.warn('--probe runs your app to observe routes the static scan missed. Use only on code you trust (it triggers your app\'s import side-effects).');
46
+ try {
47
+ const { discoverDynamicRoutes } = await import('../probe/integrate.js');
48
+ const { added, gaps, probedCount } = await discoverDynamicRoutes({
49
+ framework: stack.framework,
50
+ entryFile: path.resolve(opts.cwd, stack.entryFile),
51
+ projectRoot: opts.cwd,
52
+ staticRoutes: routes,
53
+ });
54
+ dynamicGaps = gaps;
55
+ if (added.length) {
56
+ routes.push(...added);
57
+ p.log.success(`Probe added ${added.length} dynamic route(s) the static scan missed (${probedCount} observed at runtime).`);
58
+ } else {
59
+ p.log.info(`Probe observed ${probedCount} route(s); the static scan already covered them.`);
60
+ }
61
+ } catch (err) {
62
+ p.log.warn(`Probe failed (${err.message}); continuing with static routes only.`);
63
+ }
64
+ }
65
+
38
66
  if (routes.length === 0) {
39
67
  throw Object.assign(new Error('0 routes extracted.'), { code: 'USER', hint: `SPARDA found ${stack.framework === 'express' ? 'Express' : 'FastAPI'} but no literal-path routes. Dynamic routing is not supported in v0.` });
40
68
  }
@@ -75,7 +103,9 @@ export async function runInit(opts) {
75
103
  : generateFastAPI({ cwd: opts.cwd, entryFile: stack.entryFile, port: stack.port, routes, entryAppVars, pythonCmd: stack.pythonCmd });
76
104
 
77
105
  fs.mkdirSync(path.join(opts.cwd, '.sparda'), { recursive: true });
78
- fs.writeFileSync(path.join(opts.cwd, '.sparda', 'scan-report.json'), JSON.stringify({ routes, skipped }, null, 2));
106
+ const scanReport = { routes, skipped };
107
+ if (opts.probe) scanReport.dynamicGaps = dynamicGaps; // provenance, only when probed
108
+ fs.writeFileSync(path.join(opts.cwd, '.sparda', 'scan-report.json'), JSON.stringify(scanReport, null, 2));
79
109
 
80
110
  p.log.success(`Generated ${result.routerFile}`);
81
111
  if (inject && result.injection.injected) p.log.success(`Injected into ${stack.entryFile} (backup: .sparda/backup/)`);
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
6
6
  import { parse } from '@babel/parser';
7
7
  import { toolNameFor } from '../parser/express.js';
8
8
  import { carryOverManifest, defaultSpardingMemory } from './manifest.js';
9
+ import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
9
10
 
10
11
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
12
  const MARK_START = '// >>> sparda-injection (do not edit this block) >>>';
@@ -239,10 +240,4 @@ function ensureGitignore(cwd) {
239
240
  return 'created';
240
241
  }
241
242
 
242
- function atomicWrite(file, content) {
243
- const tmp = `${file}.sparda-tmp`;
244
- fs.writeFileSync(tmp, content);
245
- fs.renameSync(tmp, file);
246
- }
247
-
248
243
  function escapeRx(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
@@ -6,6 +6,7 @@ import { spawnSync } from 'node:child_process';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { toolNameFor } from '../parser/express.js';
8
8
  import { carryOverManifest, defaultSpardingMemory } from './manifest.js';
9
+ import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
9
10
 
10
11
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
12
  const MARK_START = '# >>> sparda-injection (do not edit this block) >>>';
@@ -199,7 +200,10 @@ function verifyPythonSyntax(filePath, content, pythonCmd) {
199
200
  try {
200
201
  fs.writeFileSync(tmpFile, content);
201
202
  const args = pythonCmd === 'py' ? ['-3', '-m', 'py_compile', tmpFile] : ['-m', 'py_compile', tmpFile];
202
- const res = spawnSync(pythonCmd, args, { timeout: 2000 });
203
+ // py_compile cold-starts slowly on Windows; a 2s budget falsely failed clean
204
+ // removals (rule #4) and post-injection checks under load. 5s matches the
205
+ // test-side syntax budget and only bounds the worst case, never the happy path.
206
+ const res = spawnSync(pythonCmd, args, { timeout: 5000 });
203
207
  return res.status === 0;
204
208
  } catch {
205
209
  return false;
@@ -227,10 +231,4 @@ function ensureGitignore(cwd) {
227
231
  return 'created';
228
232
  }
229
233
 
230
- function atomicWrite(file, content) {
231
- const tmp = `${file}.sparda-tmp`;
232
- fs.writeFileSync(tmp, content);
233
- fs.renameSync(tmp, file);
234
- }
235
-
236
234
  function escapeRx(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
package/src/index.js CHANGED
@@ -15,6 +15,7 @@ const opts = {
15
15
  yes: flags.has('--yes') || flags.has('-y'),
16
16
  verbose: flags.has('--verbose'),
17
17
  quiet: flags.has('--quiet'),
18
+ probe: flags.has('--probe'),
18
19
  port: getOpt('port', null),
19
20
  cwd: process.cwd(),
20
21
  };
@@ -64,6 +65,7 @@ Usage:
64
65
  npx sparda-mcp doctor Diagnose your setup
65
66
 
66
67
  Flags: --yes (skip prompts) --port <n> --quiet --verbose
68
+ --probe (init: also run the app to discover dynamic routes the AST missed)
67
69
 
68
70
  By Residual Labs — residual-labs.fr`);
69
71
  process.exit(cmd ? 1 : 0);
@@ -0,0 +1,27 @@
1
+ /**
2
+ * SPARDA — Express GFP Shim (ESM wrapper, loaded via --import)
3
+ *
4
+ * Node ≥ 18.19: `--import <file>` preloads an ESM module before the entry file.
5
+ * This wrapper bootstraps the CJS shim (which uses Module._load patching)
6
+ * via createRequire, making it effective for ESM apps too.
7
+ *
8
+ * Why CJS shim via createRequire rather than a pure ESM shim?
9
+ * Module._load interception (the GFP mechanism) only works in CJS land.
10
+ * ESM imports bypass Module._load entirely. However, most Express apps —
11
+ * even those using ES module syntax — still resolve 'express' through the
12
+ * CJS loader (express itself is CJS). So patching Module._load in a
13
+ * createRequire context correctly intercepts all express requires regardless
14
+ * of whether the user's entry file is .mjs or .js with "type":"module".
15
+ *
16
+ * ESM, Node ≥ 18. Zero deps beyond node:module, node:url, node:path.
17
+ */
18
+
19
+ import { createRequire } from 'node:module';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { dirname, resolve } from 'node:path';
22
+
23
+ const __dirname = dirname(fileURLToPath(import.meta.url));
24
+ const require = createRequire(import.meta.url);
25
+
26
+ // Load the CJS shim — patches Module._load globally in the CJS loader
27
+ require(resolve(__dirname, 'express-shim.cjs'));
@@ -0,0 +1,208 @@
1
+ /**
2
+ * SPARDA — Express GFP Shim v2 (CJS, loaded via --require / createRequire)
3
+ *
4
+ * CRITICAL FIX over v1: v1 patched RouterClass.prototype[method] where
5
+ * RouterClass = express.Router. On Express 4, Router has NO prototype HTTP
6
+ * methods — they live on express.application and on the Router function object
7
+ * itself. v1 captured ZERO routes on any normal app.get/post/... call.
8
+ *
9
+ * v2 wraps ALL THREE public surfaces with feature-detection:
10
+ * - express.application → catches app.get/post/... (Express 4 & 5)
11
+ * - express.Router (fn object) → catches router.get/... in Express 4
12
+ * - express.Router.prototype → catches router.get/... in Express 5
13
+ *
14
+ * Also fixed: app.listen is on express.application, not Router.prototype.
15
+ * Fixed: callback passed to app.listen is now called so post-listen routes
16
+ * are captured (ANALYSE-POST-LIVRAISON §2).
17
+ * Added: proactive require.cache patch for monorepos (ANALYSE §1).
18
+ *
19
+ * Communication: fork IPC (process.send) preferred, TCP fallback on
20
+ * SPARDA_IPC_PORT for spawn() callers.
21
+ *
22
+ * CJS because Node's --require only loads CommonJS modules.
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ if (!process.env.SPARDA_PROBE) {
28
+ module.exports = {};
29
+ } else {
30
+ installShim();
31
+ }
32
+
33
+ function installShim() {
34
+ const net = require('net');
35
+ const Module = require('module');
36
+
37
+ const IPC_PORT = parseInt(process.env.SPARDA_IPC_PORT, 10) || 0;
38
+
39
+ // ── Transport: fork IPC or TCP fallback ────────────────────────────────────
40
+
41
+ let socket = null;
42
+ let socketReady = false;
43
+ const pending = [];
44
+
45
+ function connectTcp() {
46
+ if (socket || !IPC_PORT) return;
47
+ socket = new net.Socket();
48
+ socket.connect(IPC_PORT, '127.0.0.1', () => {
49
+ socketReady = true;
50
+ for (const line of pending) socket.write(line);
51
+ pending.length = 0;
52
+ });
53
+ socket.on('error', () => { socket = null; socketReady = false; });
54
+ }
55
+
56
+ function sendLine(line) {
57
+ if (typeof process.send === 'function') {
58
+ try { process.send(JSON.parse(line.trimEnd())); return; } catch {}
59
+ }
60
+ connectTcp();
61
+ if (socketReady && socket) socket.write(line);
62
+ else pending.push(line);
63
+ }
64
+
65
+ function sendMsg(obj) { sendLine(JSON.stringify(obj) + '\n'); }
66
+
67
+ function sendDone() {
68
+ if (typeof process.send === 'function') {
69
+ try { process.send({ type: '__done__' }); } catch {}
70
+ return;
71
+ }
72
+ const finish = () => {
73
+ if (socket) socket.write('__SPARDA_DONE__\n', () => socket.destroy());
74
+ };
75
+ if (socketReady) finish();
76
+ else if (socket) socket.once('connect', finish);
77
+ }
78
+
79
+ // ── Idle-flush timer ───────────────────────────────────────────────────────
80
+
81
+ const IDLE_MS = 300;
82
+ let idleTimer = null;
83
+
84
+ function resetIdle() {
85
+ clearTimeout(idleTimer);
86
+ idleTimer = setTimeout(sendDone, IDLE_MS);
87
+ }
88
+
89
+ function record(method, path) {
90
+ sendMsg({ type: 'route', method, path });
91
+ resetIdle();
92
+ }
93
+
94
+ // ── HTTP method list ───────────────────────────────────────────────────────
95
+
96
+ const HTTP_METHODS = ['get','post','put','patch','delete','del','head','options','all'];
97
+
98
+ // ── Core wrapper — works on any target object ──────────────────────────────
99
+ //
100
+ // §A.3 exact algorithm: wrap each HTTP method on `target` if present.
101
+ // Guard with __sparda_wrapped__ so re-entrant calls from require.cache
102
+ // patch (monorepo) don't double-wrap.
103
+
104
+ function wrapMethods(target) {
105
+ if (!target || target.__sparda_wrapped__) return;
106
+ for (const m of HTTP_METHODS) {
107
+ if (typeof target[m] !== 'function') continue;
108
+ const orig = target[m];
109
+ target[m] = function spardaWrap(path, ...rest) {
110
+ // Express 4: app.get('view engine') with ONE arg is a settings getter.
111
+ // Only record route registrations (path + at least one handler/middleware).
112
+ if (typeof path === 'string' && rest.length > 0) {
113
+ const verb = m === 'del' ? 'DELETE' : m.toUpperCase();
114
+ record(verb, path);
115
+ }
116
+ return orig.call(this, path, ...rest);
117
+ };
118
+ }
119
+ try {
120
+ Object.defineProperty(target, '__sparda_wrapped__', { value: true, configurable: true });
121
+ } catch {}
122
+ }
123
+
124
+ // ── listen patch — lives on express.application ───────────────────────────
125
+ //
126
+ // §A.4: listen is NOT on Router; it's on express.application.
127
+ // We intercept it to:
128
+ // (a) call any callback immediately (captures post-listen routes, §ANALYSE §2)
129
+ // (b) flush DONE so parent knows all sync routes are registered
130
+ // (c) return a fake server — no real socket opened in probe mode
131
+
132
+ function patchListen(appProto) {
133
+ if (!appProto || appProto.__sparda_listen_patched__) return;
134
+ if (typeof appProto.listen !== 'function') return;
135
+ try {
136
+ Object.defineProperty(appProto, '__sparda_listen_patched__', { value: true, configurable: true });
137
+ } catch {}
138
+ const origListen = appProto.listen;
139
+ appProto.listen = function spardaListen(...args) {
140
+ clearTimeout(idleTimer);
141
+ // §ANALYSE §2: call the listen callback so routes registered inside it are captured
142
+ const cb = args.find(a => typeof a === 'function');
143
+ if (cb) { try { cb(); } catch {} }
144
+ sendDone();
145
+ // Do NOT call origListen — no real socket in probe mode
146
+ return {
147
+ on() { return this; },
148
+ close() {},
149
+ address() { return { port: 0, address: '127.0.0.1', family: 'IPv4' }; },
150
+ };
151
+ };
152
+ }
153
+
154
+ // ── Patch all three surfaces of an express export ─────────────────────────
155
+
156
+ function patchExpress(exp) {
157
+ if (!exp || exp.__sparda_factory_patched__) return;
158
+ try {
159
+ Object.defineProperty(exp, '__sparda_factory_patched__', { value: true, configurable: true });
160
+ } catch {}
161
+
162
+ // Surface 1: express.application — catches app.get/post/... (Express 4 & 5)
163
+ if (exp.application) {
164
+ wrapMethods(exp.application);
165
+ patchListen(exp.application);
166
+ }
167
+
168
+ // Surface 2: express.Router (function object) — catches router.get/... in Express 4
169
+ if (exp.Router) {
170
+ wrapMethods(exp.Router);
171
+ }
172
+
173
+ // Surface 3: express.Router.prototype — catches router.get/... in Express 5
174
+ if (exp.Router && exp.Router.prototype) {
175
+ wrapMethods(exp.Router.prototype);
176
+ }
177
+ }
178
+
179
+ // ── Intercept require('express') via Module._load ─────────────────────────
180
+
181
+ const originalLoad = Module._load;
182
+ Module._load = function spardaLoad(request, parent, isMain) {
183
+ const result = originalLoad.apply(this, arguments);
184
+ if (request === 'express') patchExpress(result);
185
+ return result;
186
+ };
187
+
188
+ // ── Monorepo: proactive patch if express already in require.cache ─────────
189
+ // §ANALYSE §1: if another workspace module already required express before
190
+ // this shim loaded, Module._load hook fires too late. Patch the cached export.
191
+
192
+ try {
193
+ const expressPaths = Object.keys(require.cache).filter(
194
+ k => /[/\\]express[/\\]index\.js$/.test(k)
195
+ );
196
+ for (const p of expressPaths) {
197
+ const cached = require.cache[p];
198
+ if (cached && cached.exports) patchExpress(cached.exports);
199
+ }
200
+ } catch {}
201
+
202
+ // ── Safety nets ────────────────────────────────────────────────────────────
203
+
204
+ process.on('exit', () => { try { sendDone(); } catch {} });
205
+ process.on('SIGTERM', () => { sendDone(); setTimeout(() => process.exit(0), 200); });
206
+
207
+ module.exports = { record, sendDone };
208
+ }
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ SPARDA — FastAPI Route Probe
4
+
5
+ Imports the user's FastAPI entry module, finds the FastAPI() instance,
6
+ iterates app.routes, and prints a JSON array to stdout.
7
+
8
+ No uvicorn, no server, no port. FastAPI/Starlette builds the full route
9
+ table synchronously during import (include_router, add_api_route, etc.),
10
+ so reading app.routes right after import is complete and exhaustive.
11
+
12
+ Import side-effects WILL run (hence opt-in via --probe). But no network
13
+ socket is opened by this script — just a Python import.
14
+
15
+ Output format (stdout, nothing else):
16
+ [{"method": "GET", "path": "/users/{id}"}, ...]
17
+
18
+ Python ≥ 3.9. stdlib only.
19
+ """
20
+
21
+ import sys
22
+ import os
23
+ import json
24
+ import importlib.util
25
+ import traceback
26
+
27
+
28
+ def find_fastapi_apps(module):
29
+ """
30
+ Scan a module's attributes for FastAPI application instances.
31
+ Returns a list of (name, app) tuples.
32
+ """
33
+ try:
34
+ from fastapi import FastAPI
35
+ except ImportError:
36
+ return []
37
+
38
+ apps = []
39
+ for attr_name in dir(module):
40
+ try:
41
+ val = getattr(module, attr_name)
42
+ if isinstance(val, FastAPI):
43
+ apps.append((attr_name, val))
44
+ except Exception:
45
+ pass
46
+ return apps
47
+
48
+
49
+ def extract_routes(app):
50
+ """
51
+ Iterate app.routes (Starlette Route / APIRoute objects).
52
+ Returns list of dicts with method and path.
53
+ """
54
+ routes = []
55
+ try:
56
+ for route in app.routes:
57
+ try:
58
+ # APIRoute (FastAPI endpoints) have .methods and .path
59
+ path = getattr(route, 'path', None)
60
+ methods = getattr(route, 'methods', None)
61
+ if path is None:
62
+ continue
63
+ if methods:
64
+ for m in methods:
65
+ routes.append({"method": m.upper(), "path": path})
66
+ else:
67
+ # Mount or WebSocket — skip (no HTTP method)
68
+ pass
69
+ except Exception:
70
+ pass
71
+ except Exception:
72
+ pass
73
+ return routes
74
+
75
+
76
+ def load_entry_module(entry_file):
77
+ """
78
+ Load the user's entry file as a module without running it as __main__.
79
+ """
80
+ entry_dir = os.path.dirname(os.path.abspath(entry_file))
81
+ # Ensure the app's directory is on sys.path so relative imports work
82
+ if entry_dir not in sys.path:
83
+ sys.path.insert(0, entry_dir)
84
+
85
+ spec = importlib.util.spec_from_file_location("__sparda_fastapi_probe__", entry_file)
86
+ if spec is None:
87
+ raise ImportError(f"Cannot create module spec for {entry_file!r}")
88
+
89
+ module = importlib.util.module_from_spec(spec)
90
+ # Don't execute __main__ guards: set __name__ to something other than '__main__'
91
+ module.__name__ = "__sparda_fastapi_probe__"
92
+ spec.loader.exec_module(module)
93
+ return module
94
+
95
+
96
+ def main():
97
+ if len(sys.argv) < 2:
98
+ print(json.dumps([]), flush=True)
99
+ sys.exit(0)
100
+
101
+ entry_file = sys.argv[1]
102
+
103
+ if not os.path.isfile(entry_file):
104
+ print(f"[sparda probe] Entry file not found: {entry_file}", file=sys.stderr)
105
+ print(json.dumps([]), flush=True)
106
+ sys.exit(0)
107
+
108
+ try:
109
+ module = load_entry_module(entry_file)
110
+ except SystemExit:
111
+ # App called sys.exit() during import (e.g. arg parsing) — not fatal
112
+ print("[]", flush=True)
113
+ sys.exit(0)
114
+ except Exception as e:
115
+ print(f"[sparda probe] Failed to import {entry_file!r}: {e}", file=sys.stderr)
116
+ traceback.print_exc(file=sys.stderr)
117
+ print(json.dumps([]), flush=True)
118
+ sys.exit(0)
119
+
120
+ apps = find_fastapi_apps(module)
121
+ if not apps:
122
+ # No FastAPI instance found — print empty
123
+ print(json.dumps([]), flush=True)
124
+ sys.exit(0)
125
+
126
+ all_routes = []
127
+ for _name, app in apps:
128
+ all_routes.extend(extract_routes(app))
129
+
130
+ # Deduplicate (same method+path from multiple apps is unusual but possible)
131
+ seen = set()
132
+ unique = []
133
+ for r in all_routes:
134
+ key = f"{r['method']}:{r['path']}"
135
+ if key not in seen:
136
+ seen.add(key)
137
+ unique.append(r)
138
+
139
+ # Deterministic: sort by key
140
+ unique.sort(key=lambda r: f"{r['method']}:{r['path']}")
141
+
142
+ print(json.dumps(unique), flush=True)
143
+
144
+
145
+ if __name__ == "__main__":
146
+ main()
@@ -0,0 +1,104 @@
1
+ /**
2
+ * SPARDA — Dynamic route discovery integration (Brief #3, the final adapter)
3
+ *
4
+ * The "last 10%" that glues the runtime probe to SPARDA's generator pipeline:
5
+ *
6
+ * probeRoutes() → canonical dynamic routes (minimal shape)
7
+ * reconcile() → set-difference vs the AST floor → gaps[] the parser missed
8
+ * gapToStaticRoute() → enrich each gap into SPARDA's RICH route shape so the
9
+ * existing generators (generator/express.js, fastapi.js)
10
+ * consume it with ZERO special-casing.
11
+ *
12
+ * Static stays the floor; this only ADDS probe-only routes (§C). A probe failure
13
+ * degrades to static-only — probeRoutes() already returns [] on any error, and
14
+ * the init caller wraps this in try/catch as a second net.
15
+ *
16
+ * ESM, Node ≥ 18. Zero new deps (re-uses the sibling probe.js + reconcile.js).
17
+ */
18
+
19
+ import { probeRoutes } from './probe.js';
20
+ import { reconcile } from './reconcile.js';
21
+
22
+ const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
23
+
24
+ /**
25
+ * Map a reconcile gap (minimal canonical shape) → SPARDA's rich static-route
26
+ * shape. The field set is byte-identical to what parseExpressProject /
27
+ * parseFastAPIProject emit, so the generators need no branch for "is this
28
+ * dynamic?". Provenance is carried in a non-consumed `source:'dynamic'` field.
29
+ *
30
+ * Path style mirrors the static parser per framework, so the manifest stays
31
+ * uniform (R6):
32
+ * - express → ':id' (the gap is already :id — keep it)
33
+ * - fastapi → '{id}' (convert :id → {id} to match static FastAPI routes)
34
+ *
35
+ * Confidence is ALWAYS 'low': a probed route proves only that {method, path}
36
+ * exists — we never saw the handler, query params, or body schema.
37
+ *
38
+ * @param {{method:string, path:string, pathParams?:string[], writeClass?:'read'|'write'}} gap
39
+ * @param {'express'|'fastapi'} framework
40
+ */
41
+ export function gapToStaticRoute(gap, framework) {
42
+ const method = (gap.method ?? 'GET').toUpperCase();
43
+ const lower = method.toLowerCase();
44
+ const mutating = gap.writeClass ? gap.writeClass === 'write' : WRITE_METHODS.has(method);
45
+
46
+ const path = framework === 'fastapi'
47
+ ? (gap.path ?? '/').replace(/:([a-zA-Z_]\w*)/g, '{$1}')
48
+ : (gap.path ?? '/');
49
+
50
+ // Path params, in the exact param object the static parser uses.
51
+ const params = (gap.pathParams ?? []).map((name) => ({
52
+ name, in: 'path', type: 'string', required: true, description: 'path parameter',
53
+ }));
54
+ // Mirror the static parser: mutating routes get a body param (schema unknown).
55
+ if (mutating) {
56
+ params.push({
57
+ name: 'body', in: 'body', type: 'object', required: false,
58
+ description: 'JSON body — schema not statically detected',
59
+ });
60
+ }
61
+
62
+ return {
63
+ method: lower,
64
+ path,
65
+ handlerName: `dynamic_${lower}_${(gap.pathParams ?? []).join('_') || 'route'}`,
66
+ sourceFile: '(runtime probe)',
67
+ sourceLine: 0,
68
+ params,
69
+ description: 'Discovered at runtime via --probe; not found by static analysis.',
70
+ mutating,
71
+ confidence: 'low', // never statically verified → always low
72
+ source: 'dynamic',
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Probe the live app, reconcile against the static floor, and return the
78
+ * probe-only routes enriched to SPARDA's rich shape (ready to append to the
79
+ * `routes` array the generators consume).
80
+ *
81
+ * @param {{framework:'express'|'fastapi', entryFile:string, projectRoot:string, staticRoutes:object[], timeoutMs?:number}} args
82
+ * @returns {Promise<{ added:object[], gaps:object[], staticCount:number, dynamicCount:number, probedCount:number }>}
83
+ * added — gaps enriched to SPARDA's rich shape (append these to `routes`)
84
+ * gaps — the raw minimal gaps (stored in the scan-report for provenance)
85
+ * probedCount — how many routes the probe observed (0 ⇒ probe degraded / nothing seen)
86
+ */
87
+ export async function discoverDynamicRoutes({ framework, entryFile, projectRoot, staticRoutes, timeoutMs = 8000 }) {
88
+ const probed = await probeRoutes({ framework, entryFile, projectRoot, timeoutMs });
89
+ const { gaps, staticCount, dynamicCount } = reconcile(staticRoutes, probed);
90
+
91
+ // Defensive: the probe should not emit duplicate (method, path) pairs, but
92
+ // reconcile does not dedup gaps among themselves. Collapse here so we never
93
+ // synthesize two identical tools.
94
+ const seen = new Set();
95
+ const uniqueGaps = gaps.filter((g) => {
96
+ const k = `${g.method} ${g.path}`;
97
+ if (seen.has(k)) return false;
98
+ seen.add(k);
99
+ return true;
100
+ });
101
+
102
+ const added = uniqueGaps.map((g) => gapToStaticRoute(g, framework));
103
+ return { added, gaps: uniqueGaps, staticCount, dynamicCount: added.length, probedCount: probed.length };
104
+ }