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 +6 -3
- package/src/commands/init.js +31 -1
- package/src/generator/express.js +1 -6
- package/src/generator/fastapi.js +5 -7
- package/src/index.js +2 -0
- package/src/probe/express-shim-esm.mjs +27 -0
- package/src/probe/express-shim.cjs +208 -0
- package/src/probe/fastapi-probe.py +146 -0
- package/src/probe/integrate.js +104 -0
- package/src/probe/probe.js +243 -0
- package/src/probe/reconcile.js +160 -0
- package/src/server/condenser.js +3 -2
- package/src/server/confirmation.js +165 -0
- package/src/server/context-carrier.js +322 -0
- package/src/server/crystallize.js +1 -1
- package/src/server/engine.js +579 -0
- package/src/server/idle.js +1 -1
- package/src/server/persistence.js +269 -0
- package/src/server/stdio.js +182 -34
- package/templates/express-router.txt +203 -32
- package/templates/fastapi-router.txt +238 -1
package/package.json
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sparda-mcp",
|
|
3
|
-
"version": "0.5.
|
|
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.
|
|
42
|
+
"express": "^4.21.0",
|
|
40
43
|
"vitest": "^3.2.6"
|
|
41
44
|
}
|
|
42
45
|
}
|
package/src/commands/init.js
CHANGED
|
@@ -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
|
-
|
|
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/)`);
|
package/src/generator/express.js
CHANGED
|
@@ -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, '\\$&'); }
|
package/src/generator/fastapi.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|