sparda-mcp 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/package.json +1 -1
- package/src/commands/hook.js +22 -0
- package/src/commands/init.js +12 -11
- package/src/commands/remove.js +2 -0
- package/src/generator/express.js +26 -1
- package/src/generator/fastapi.js +26 -1
- package/src/generator/manifest.js +20 -4
- package/src/parser/express.js +43 -1
- package/src/parser/fastapi_extract.py +132 -32
- package/src/server/condenser.js +145 -0
- package/src/server/crystallize.js +89 -0
- package/src/server/idle.js +42 -0
- package/src/server/stdio.js +248 -8
- package/src/ui/style.js +66 -0
- package/templates/express-router.txt +215 -20
- package/templates/fastapi-router.txt +208 -35
package/README.md
CHANGED
|
@@ -45,6 +45,12 @@ Your app learns what "self" looks like — and defends itself:
|
|
|
45
45
|
- **Immune memory.** Diagnoses are cached as antibodies in `sparda.json` (git-versioned): the same failure later is diagnosed instantly, zero tokens. Your app accumulates antibodies as it lives — cloning the code doesn't clone its immune system.
|
|
46
46
|
- **`sparda_get_context`.** One tool call gives the AI the full living context: tools, workflows, runtime telemetry, quarantine state, immune memory. Every AI session resumes where the last one stopped.
|
|
47
47
|
|
|
48
|
+
## v0.4 — the recycling economy + first Labs organ
|
|
49
|
+
- **Recycling gauge (zero config).** The router counts calls answered from SPARDA's own knowledge vs calls that paid the host route (`GET /mcp/stats → recycle`), and the bridge counts the sampling calls its cached knowledge avoided. Day 1 it reads 0% — the circle fills with usage. A measure, never a promise.
|
|
50
|
+
- **Idle harvester.** All of SPARDA's internal work (analysis, persistence) runs only when the event loop is quiet, one job per tick. Perceived saturation: zero.
|
|
51
|
+
- **Sequence condenser (Labs, opt-in, default OFF).** Set `"labs": { "recordSequences": true }` in `sparda.json` (or `SPARDA_RECORD_SEQUENCES=1` for one session) and SPARDA observes when one tool's output feeds the next tool's input, recording the *circuit* — structure only (tool names, arg names, counts), never your data. Deterministic, zero LLM.
|
|
52
|
+
- **Crystallization: tools nobody wrote.** A read-only circuit observed 3× is born as a *composite tool*, announced mid-session via `tools/list_changed`: one call runs the whole chain, auto-feeding each linked argument from the previous step's real response. Your AI names it (MCP sampling, sanitized) or a deterministic name ships without any LLM. Write routes are never absorbed — their per-call confirmation stands.
|
|
53
|
+
|
|
48
54
|
Where this is going: [ROADMAP.md](./ROADMAP.md).
|
|
49
55
|
|
|
50
56
|
## v0 supports
|
package/package.json
CHANGED
package/src/commands/hook.js
CHANGED
|
@@ -27,3 +27,25 @@ export async function runHook(opts) {
|
|
|
27
27
|
fs.chmodSync(hookPath, 0o755);
|
|
28
28
|
console.error('[sparda] sentinel installed: routes re-sync after every commit (post-commit hook).');
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
// Uninstall exactly what runHook installed (hard rule #4 applied to .git/hooks):
|
|
32
|
+
// delete the file if we created it whole, strip our exact block if we appended,
|
|
33
|
+
// line-filter as a last resort if the user edited around it. Returns whether
|
|
34
|
+
// anything was removed so `remove` can report it.
|
|
35
|
+
export function removeHook(cwd) {
|
|
36
|
+
const hookPath = path.join(cwd, '.git', 'hooks', 'post-commit');
|
|
37
|
+
let content;
|
|
38
|
+
try { content = fs.readFileSync(hookPath, 'utf8'); } catch { return false; }
|
|
39
|
+
if (!content.includes(MARKER)) return false;
|
|
40
|
+
const block = `${MARKER}\nnpx --no-install sparda-mcp sync --quiet || true\n`;
|
|
41
|
+
if (content === `#!/bin/sh\n${block}`) {
|
|
42
|
+
fs.rmSync(hookPath);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
let out = content.includes(`\n${block}`) ? content.replace(`\n${block}`, '') : content.replace(block, '');
|
|
46
|
+
if (out.includes(MARKER)) {
|
|
47
|
+
out = out.split('\n').filter((l) => l !== MARKER && l !== 'npx --no-install sparda-mcp sync --quiet || true').join('\n');
|
|
48
|
+
}
|
|
49
|
+
fs.writeFileSync(hookPath, out);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
package/src/commands/init.js
CHANGED
|
@@ -8,13 +8,14 @@ import { parseFastAPIProject } from '../parser/fastapi.js';
|
|
|
8
8
|
import { sanitizeDescription } from '../security/sanitize.js';
|
|
9
9
|
import { generateExpress } from '../generator/express.js';
|
|
10
10
|
import { generateFastAPI } from '../generator/fastapi.js';
|
|
11
|
+
import { c, gradient, colorizeJson } from '../ui/style.js';
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
const VERSION = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
|
|
14
15
|
|
|
15
16
|
export async function runInit(opts) {
|
|
16
17
|
const t0 = Date.now();
|
|
17
|
-
p.intro(
|
|
18
|
+
p.intro(`${gradient('SPARDA')} ${c.dim(`v${VERSION}`)}`);
|
|
18
19
|
|
|
19
20
|
const s = p.spinner();
|
|
20
21
|
s.start('Scanning project...');
|
|
@@ -25,13 +26,13 @@ export async function runInit(opts) {
|
|
|
25
26
|
const res = parseExpressProject(opts.cwd, stack.entryFile);
|
|
26
27
|
routes = res.routes;
|
|
27
28
|
skipped = res.skipped;
|
|
28
|
-
s.stop(`Stack detected: Express (${stack.moduleType.toUpperCase()}) — entry: ${stack.entryFile}, port: ${stack.port}`);
|
|
29
|
+
s.stop(`Stack detected: ${c.cyan(`Express (${stack.moduleType.toUpperCase()})`)} — entry: ${stack.entryFile}, port: ${stack.port}`);
|
|
29
30
|
} else if (stack.framework === 'fastapi') {
|
|
30
31
|
const res = parseFastAPIProject(opts.cwd, stack.entryFile, stack.pythonCmd);
|
|
31
32
|
routes = res.routes;
|
|
32
33
|
skipped = res.skipped;
|
|
33
34
|
entryAppVars = res.entryAppVars;
|
|
34
|
-
s.stop(`Stack detected: FastAPI — entry: ${stack.entryFile}, port: ${stack.port}`);
|
|
35
|
+
s.stop(`Stack detected: ${c.cyan('FastAPI')} — entry: ${stack.entryFile}, port: ${stack.port}`);
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
if (routes.length === 0) {
|
|
@@ -49,9 +50,9 @@ export async function runInit(opts) {
|
|
|
49
50
|
p.log.info(`${routes.length} routes found — ${high} high confidence, ${routes.length - high} partial`);
|
|
50
51
|
|
|
51
52
|
const preview = routes.slice(0, 8).map((r) =>
|
|
52
|
-
`${r.mutating ? '✗' : '✓'} ${r.method.toUpperCase().padEnd(6)} ${r.path}${r.mutating ? ' (disabled: write-safety)' : ''}`
|
|
53
|
+
`${r.mutating ? c.red('✗') : c.green('✓')} ${r.method.toUpperCase().padEnd(6)} ${r.path}${r.mutating ? c.dim(' (disabled: write-safety)') : ''}`
|
|
53
54
|
).join('\n');
|
|
54
|
-
p.note(preview + (routes.length > 8 ? `\n... (+${routes.length - 8} more)` : ''), 'TOOLS TO GENERATE');
|
|
55
|
+
p.note(preview + (routes.length > 8 ? c.dim(`\n... (+${routes.length - 8} more)`) : ''), 'TOOLS TO GENERATE');
|
|
55
56
|
|
|
56
57
|
if (flaggedCount) p.log.warn(`${flaggedCount} suspicious docstring(s) purged (prompt-injection defense)`);
|
|
57
58
|
if (skipped.length) p.log.warn(`${skipped.length} route(s) skipped — details in .sparda/scan-report.json`);
|
|
@@ -84,15 +85,15 @@ export async function runInit(opts) {
|
|
|
84
85
|
p.log.success('Wrote sparda.json');
|
|
85
86
|
|
|
86
87
|
const cfg = JSON.stringify({ [path.basename(opts.cwd)]: { command: 'npx', args: ['sparda-mcp', 'dev'], cwd: opts.cwd } }, null, 2);
|
|
87
|
-
p.outro(`Done in ${((Date.now() - t0) / 1000).toFixed(1)}s
|
|
88
|
+
p.outro(`${c.green(`Done in ${((Date.now() - t0) / 1000).toFixed(1)}s.`)}
|
|
88
89
|
|
|
89
90
|
Next steps:
|
|
90
|
-
1. Start your app: ${stack.framework === 'express' ? 'npm run dev' : 'fastapi dev'}
|
|
91
|
-
2. Start the MCP bridge: npx sparda-mcp dev
|
|
91
|
+
1. Start your app: ${c.cyan(stack.framework === 'express' ? 'npm run dev' : 'fastapi dev')}
|
|
92
|
+
2. Start the MCP bridge: ${c.cyan('npx sparda-mcp dev')}
|
|
92
93
|
3. Add to Claude Desktop config (claude_desktop_config.json):
|
|
93
94
|
|
|
94
|
-
${cfg.split('\n').map((l) => ' ' + l).join('\n')}
|
|
95
|
+
${colorizeJson(cfg).split('\n').map((l) => ' ' + l).join('\n')}
|
|
95
96
|
|
|
96
|
-
Write tools (POST/PUT/DELETE) are disabled by default.
|
|
97
|
-
Enable them in sparda.json, then re-run
|
|
97
|
+
${c.dim('Write tools (POST/PUT/DELETE) are disabled by default.')}
|
|
98
|
+
${c.dim('Enable them in sparda.json, then re-run')} ${c.cyan('`npx sparda-mcp init --yes`')}${c.dim('.')}`);
|
|
98
99
|
}
|
package/src/commands/remove.js
CHANGED
|
@@ -5,6 +5,7 @@ import * as p from '@clack/prompts';
|
|
|
5
5
|
import { removeInjection as removeExpress } from '../generator/express.js';
|
|
6
6
|
import { removeInjection as removeFastAPI } from '../generator/fastapi.js';
|
|
7
7
|
import { detectStack } from '../detect.js';
|
|
8
|
+
import { removeHook } from './hook.js';
|
|
8
9
|
|
|
9
10
|
export async function runRemove(opts) {
|
|
10
11
|
const manifestPath = path.join(opts.cwd, 'sparda.json');
|
|
@@ -43,6 +44,7 @@ export async function runRemove(opts) {
|
|
|
43
44
|
if (fs.existsSync(abs)) { fs.rmSync(abs); console.log(`✓ Deleted ${f}`); }
|
|
44
45
|
}
|
|
45
46
|
if (revertGitignore(opts.cwd, manifest.gitignore)) console.log('✓ Reverted .gitignore edit');
|
|
47
|
+
if (removeHook(opts.cwd)) console.log('✓ Uninstalled post-commit sentinel hook');
|
|
46
48
|
fs.rmSync(manifestPath);
|
|
47
49
|
fs.rmSync(path.join(opts.cwd, '.sparda'), { recursive: true, force: true });
|
|
48
50
|
console.log('✓ Deleted sparda.json and .sparda/');
|
package/src/generator/express.js
CHANGED
|
@@ -5,7 +5,7 @@ import crypto from 'node:crypto';
|
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { parse } from '@babel/parser';
|
|
7
7
|
import { toolNameFor } from '../parser/express.js';
|
|
8
|
-
import { carryOverManifest } from './manifest.js';
|
|
8
|
+
import { carryOverManifest, defaultSpardingMemory } from './manifest.js';
|
|
9
9
|
|
|
10
10
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
11
|
const MARK_START = '// >>> sparda-injection (do not edit this block) >>>';
|
|
@@ -28,6 +28,28 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
|
|
|
28
28
|
}
|
|
29
29
|
const prev = carryOverManifest(cwd, tools);
|
|
30
30
|
|
|
31
|
+
// --- sparding safety memory & fingerprints
|
|
32
|
+
const sparding = defaultSpardingMemory(prev);
|
|
33
|
+
const oldFingerprints = sparding.toolFingerprints ?? {};
|
|
34
|
+
const newFingerprints = {};
|
|
35
|
+
for (const [name, t] of Object.entries(tools)) {
|
|
36
|
+
const raw = `${t.method}|${t.path}|${JSON.stringify(t.params)}`;
|
|
37
|
+
const fp = crypto.createHash('sha256').update(raw).digest('hex').slice(0, 8);
|
|
38
|
+
newFingerprints[name] = fp;
|
|
39
|
+
const oldFp = oldFingerprints[name];
|
|
40
|
+
if (oldFp && oldFp !== fp) {
|
|
41
|
+
sparding.events.push({
|
|
42
|
+
ts: new Date().toISOString(),
|
|
43
|
+
tool: name,
|
|
44
|
+
decision: 'audit',
|
|
45
|
+
risk: 'medium',
|
|
46
|
+
reasons: [`route structure modified (fingerprint changed from ${oldFp} to ${fp})`],
|
|
47
|
+
});
|
|
48
|
+
if (sparding.events.length > 100) sparding.events.shift();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
sparding.toolFingerprints = newFingerprints;
|
|
52
|
+
|
|
31
53
|
// stable across re-runs so a running bridge/host pair never desyncs
|
|
32
54
|
const localKey = prev?.localKey ?? crypto.randomUUID();
|
|
33
55
|
const entryAbs = path.resolve(cwd, entryFile);
|
|
@@ -61,6 +83,7 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
|
|
|
61
83
|
|
|
62
84
|
tpl = tpl
|
|
63
85
|
.replace('__IMPORT_LINE__', importLine)
|
|
86
|
+
.replace('__SPARDING_POLICIES__', JSON.stringify(sparding.policies ?? {}))
|
|
64
87
|
.replace('__ROUTER_DECL__', isESM
|
|
65
88
|
? 'export const spardaRouter = express.Router();'
|
|
66
89
|
: 'const spardaRouter = express.Router();\nmodule.exports = { spardaRouter };')
|
|
@@ -97,6 +120,8 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
|
|
|
97
120
|
...(gitignore ? { gitignore } : {}),
|
|
98
121
|
...(prev?.semantic ? { semantic: prev.semantic } : {}),
|
|
99
122
|
...(prev?.immune ? { immune: prev.immune } : {}),
|
|
123
|
+
...(prev?.labs ? { labs: prev.labs } : {}),
|
|
124
|
+
sparding,
|
|
100
125
|
};
|
|
101
126
|
atomicWrite(path.join(cwd, 'sparda.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
102
127
|
|
package/src/generator/fastapi.js
CHANGED
|
@@ -5,7 +5,7 @@ import crypto from 'node:crypto';
|
|
|
5
5
|
import { spawnSync } from 'node:child_process';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { toolNameFor } from '../parser/express.js';
|
|
8
|
-
import { carryOverManifest } from './manifest.js';
|
|
8
|
+
import { carryOverManifest, defaultSpardingMemory } from './manifest.js';
|
|
9
9
|
|
|
10
10
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
11
|
const MARK_START = '# >>> sparda-injection (do not edit this block) >>>';
|
|
@@ -28,6 +28,28 @@ export function generateFastAPI({ cwd, entryFile, port, routes, entryAppVars, py
|
|
|
28
28
|
}
|
|
29
29
|
const prev = carryOverManifest(cwd, tools);
|
|
30
30
|
|
|
31
|
+
// --- sparding safety memory & fingerprints
|
|
32
|
+
const sparding = defaultSpardingMemory(prev);
|
|
33
|
+
const oldFingerprints = sparding.toolFingerprints ?? {};
|
|
34
|
+
const newFingerprints = {};
|
|
35
|
+
for (const [name, t] of Object.entries(tools)) {
|
|
36
|
+
const raw = `${t.method}|${t.path}|${JSON.stringify(t.params)}`;
|
|
37
|
+
const fp = crypto.createHash('sha256').update(raw).digest('hex').slice(0, 8);
|
|
38
|
+
newFingerprints[name] = fp;
|
|
39
|
+
const oldFp = oldFingerprints[name];
|
|
40
|
+
if (oldFp && oldFp !== fp) {
|
|
41
|
+
sparding.events.push({
|
|
42
|
+
ts: new Date().toISOString(),
|
|
43
|
+
tool: name,
|
|
44
|
+
decision: 'audit',
|
|
45
|
+
risk: 'medium',
|
|
46
|
+
reasons: [`route structure modified (fingerprint changed from ${oldFp} to ${fp})`],
|
|
47
|
+
});
|
|
48
|
+
if (sparding.events.length > 100) sparding.events.shift();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
sparding.toolFingerprints = newFingerprints;
|
|
52
|
+
|
|
31
53
|
// stable across re-runs so a running bridge/host pair never desyncs
|
|
32
54
|
const localKey = prev?.localKey ?? crypto.randomUUID();
|
|
33
55
|
const entryAbs = path.resolve(cwd, entryFile);
|
|
@@ -41,6 +63,7 @@ export function generateFastAPI({ cwd, entryFile, port, routes, entryAppVars, py
|
|
|
41
63
|
// double-stringify: a JSON string literal is also a valid Python string literal,
|
|
42
64
|
// and json.loads() in the template turns it into real Python values (E-009)
|
|
43
65
|
.replace('__TOOLS_JSON__', JSON.stringify(JSON.stringify(tools)))
|
|
66
|
+
.replace('__SPARDING_POLICIES__', JSON.stringify(JSON.stringify(sparding.policies ?? {})))
|
|
44
67
|
.replace('__LOCAL_KEY__', localKey)
|
|
45
68
|
.replace('__PORT__', String(port));
|
|
46
69
|
|
|
@@ -66,6 +89,8 @@ export function generateFastAPI({ cwd, entryFile, port, routes, entryAppVars, py
|
|
|
66
89
|
...(gitignore ? { gitignore } : {}),
|
|
67
90
|
...(prev?.semantic ? { semantic: prev.semantic } : {}),
|
|
68
91
|
...(prev?.immune ? { immune: prev.immune } : {}),
|
|
92
|
+
...(prev?.labs ? { labs: prev.labs } : {}),
|
|
93
|
+
sparding,
|
|
69
94
|
};
|
|
70
95
|
|
|
71
96
|
atomicWrite(path.join(cwd, 'sparda.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
|
|
5
|
-
// Re-running init must not wipe what the user (or the semantic/immune
|
|
6
|
-
// wrote into sparda.json: per-tool `enabled` overrides, the cached
|
|
7
|
-
// enrichment
|
|
8
|
-
//
|
|
5
|
+
// Re-running init must not wipe what the user (or the semantic/immune/Labs
|
|
6
|
+
// passes) wrote into sparda.json: per-tool `enabled` overrides, the cached
|
|
7
|
+
// `semantic` enrichment, the `immune` memory (antibodies), the `labs`
|
|
8
|
+
// state (opt-in flags + observed circuits), and the `sparding` security
|
|
9
|
+
// memory survive as long as the tool's method+path are unchanged.
|
|
9
10
|
export function carryOverManifest(cwd, tools) {
|
|
10
11
|
let prev = null;
|
|
11
12
|
try {
|
|
@@ -21,3 +22,18 @@ export function carryOverManifest(cwd, tools) {
|
|
|
21
22
|
}
|
|
22
23
|
return prev;
|
|
23
24
|
}
|
|
25
|
+
|
|
26
|
+
export function defaultSpardingMemory(prev) {
|
|
27
|
+
return prev?.sparding ?? {
|
|
28
|
+
version: 1,
|
|
29
|
+
policies: {
|
|
30
|
+
reads: 'allow',
|
|
31
|
+
writes: 'require_human',
|
|
32
|
+
deletes: 'block',
|
|
33
|
+
requireProofAfterWrite: true,
|
|
34
|
+
},
|
|
35
|
+
events: [],
|
|
36
|
+
failures: {},
|
|
37
|
+
toolFingerprints: {},
|
|
38
|
+
};
|
|
39
|
+
}
|
package/src/parser/express.js
CHANGED
|
@@ -110,7 +110,7 @@ export function parseExpressProject(cwd, entryFile) {
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
const fullPath = joinPath(prefix, pathArg.value);
|
|
113
|
-
if (fullPath.startsWith('/mcp')) {
|
|
113
|
+
if (fullPath === '/mcp' || fullPath.startsWith('/mcp/')) {
|
|
114
114
|
skipped.push({ reason: `self-referential path ${fullPath} blocked`, file: rel(absFile), line: p.node.loc?.start.line });
|
|
115
115
|
return;
|
|
116
116
|
}
|
|
@@ -129,6 +129,14 @@ export function parseExpressProject(cwd, entryFile) {
|
|
|
129
129
|
|
|
130
130
|
const mutating = method !== 'get';
|
|
131
131
|
const params = [...pathParams];
|
|
132
|
+
// query params: handlers reveal them as req.query.X / destructured req.query —
|
|
133
|
+
// without this the AI cannot discover ?limit=… filters at all (E2E P2 finding)
|
|
134
|
+
const taken = new Set(params.map((x) => x.name));
|
|
135
|
+
for (const q of queryParamsOf(p)) {
|
|
136
|
+
if (taken.has(q)) continue;
|
|
137
|
+
taken.add(q);
|
|
138
|
+
params.push({ name: q, in: 'query', type: 'string', required: false, description: 'query parameter' });
|
|
139
|
+
}
|
|
132
140
|
let confidence = 'high';
|
|
133
141
|
if (mutating) {
|
|
134
142
|
params.push({ name: 'body', in: 'body', type: 'object', required: false, description: 'JSON body — schema not statically detected' });
|
|
@@ -172,6 +180,40 @@ export function parseExpressProject(cwd, entryFile) {
|
|
|
172
180
|
}
|
|
173
181
|
}
|
|
174
182
|
|
|
183
|
+
// Query params are invisible in a route's signature; only the handler body
|
|
184
|
+
// reveals them. Covers the two canonical shapes — req.query.x / req.query['x']
|
|
185
|
+
// and `const { x } = req.query` — on inline handlers only (an Identifier
|
|
186
|
+
// handler lives elsewhere; guessing is worse than staying silent). Bounded.
|
|
187
|
+
function queryParamsOf(callPath) {
|
|
188
|
+
const reqNames = new Set();
|
|
189
|
+
for (const a of callPath.node.arguments) {
|
|
190
|
+
if ((a.type === 'ArrowFunctionExpression' || a.type === 'FunctionExpression') && a.params[0]?.type === 'Identifier') {
|
|
191
|
+
reqNames.add(a.params[0].name);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const found = [];
|
|
195
|
+
if (!reqNames.size) return found;
|
|
196
|
+
const isReqQuery = (n) => n?.type === 'MemberExpression' && !n.computed &&
|
|
197
|
+
n.object?.type === 'Identifier' && reqNames.has(n.object.name) &&
|
|
198
|
+
n.property?.type === 'Identifier' && n.property.name === 'query';
|
|
199
|
+
callPath.traverse({
|
|
200
|
+
MemberExpression(q) {
|
|
201
|
+
const n = q.node;
|
|
202
|
+
if (!isReqQuery(n.object) || found.length >= 15) return;
|
|
203
|
+
if (!n.computed && n.property.type === 'Identifier') found.push(n.property.name);
|
|
204
|
+
else if (n.computed && n.property.type === 'StringLiteral') found.push(n.property.value);
|
|
205
|
+
},
|
|
206
|
+
VariableDeclarator(q) {
|
|
207
|
+
if (q.node.id.type !== 'ObjectPattern' || !isReqQuery(q.node.init)) return;
|
|
208
|
+
for (const prop of q.node.id.properties) {
|
|
209
|
+
if (found.length >= 15) break;
|
|
210
|
+
if (prop.type === 'ObjectProperty' && prop.key.type === 'Identifier') found.push(prop.key.name);
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
return [...new Set(found)];
|
|
215
|
+
}
|
|
216
|
+
|
|
175
217
|
function joinPath(prefix, p) {
|
|
176
218
|
const joined = `${prefix ?? ''}${p === '/' && prefix ? '' : p}`.replace(/\/{2,}/g, '/');
|
|
177
219
|
return joined.startsWith('/') ? joined : `/${joined}`;
|
|
@@ -27,6 +27,77 @@ class RouteSpecExtractor:
|
|
|
27
27
|
lines = [line.strip() for line in doc.splitlines()]
|
|
28
28
|
return " ".join([l for l in lines[:3] if l]).strip()
|
|
29
29
|
|
|
30
|
+
def _is_depends(self, default_node):
|
|
31
|
+
"""Returns True if the default value is a Depends(...) call."""
|
|
32
|
+
return (
|
|
33
|
+
isinstance(default_node, ast.Call) and
|
|
34
|
+
((isinstance(default_node.func, ast.Name) and default_node.func.id == 'Depends') or
|
|
35
|
+
(isinstance(default_node.func, ast.Attribute) and default_node.func.attr == 'Depends'))
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def _collect_all_imports(self, start_file):
|
|
39
|
+
to_visit = [start_file]
|
|
40
|
+
collected = set()
|
|
41
|
+
visited_imports = set()
|
|
42
|
+
|
|
43
|
+
while to_visit:
|
|
44
|
+
curr = to_visit.pop(0)
|
|
45
|
+
if curr in visited_imports:
|
|
46
|
+
continue
|
|
47
|
+
visited_imports.add(curr)
|
|
48
|
+
|
|
49
|
+
if not os.path.exists(curr):
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
with open(curr, 'r', encoding='utf-8') as f:
|
|
54
|
+
src = f.read()
|
|
55
|
+
tree = ast.parse(src, filename=curr)
|
|
56
|
+
except:
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
for node in ast.walk(tree):
|
|
60
|
+
resolved = None
|
|
61
|
+
if isinstance(node, ast.ImportFrom):
|
|
62
|
+
dots = '.' * node.level if node.level > 0 else ''
|
|
63
|
+
module_with_dots = f"{dots}{node.module}" if node.module else dots
|
|
64
|
+
|
|
65
|
+
for name_node in node.names:
|
|
66
|
+
if node.module:
|
|
67
|
+
full_mod = f"{module_with_dots}.{name_node.name}"
|
|
68
|
+
else:
|
|
69
|
+
full_mod = f"{module_with_dots}{name_node.name}"
|
|
70
|
+
resolved = self.resolve_import(curr, full_mod)
|
|
71
|
+
if resolved and resolved not in collected:
|
|
72
|
+
collected.add(resolved)
|
|
73
|
+
to_visit.append(resolved)
|
|
74
|
+
|
|
75
|
+
resolved = self.resolve_import(curr, module_with_dots)
|
|
76
|
+
if resolved and resolved not in collected:
|
|
77
|
+
collected.add(resolved)
|
|
78
|
+
to_visit.append(resolved)
|
|
79
|
+
|
|
80
|
+
elif isinstance(node, ast.Import):
|
|
81
|
+
for name_node in node.names:
|
|
82
|
+
resolved = self.resolve_import(curr, name_node.name)
|
|
83
|
+
if resolved and resolved not in collected:
|
|
84
|
+
collected.add(resolved)
|
|
85
|
+
to_visit.append(resolved)
|
|
86
|
+
|
|
87
|
+
return collected
|
|
88
|
+
|
|
89
|
+
def preload_models(self):
|
|
90
|
+
"""Pre-scan all reachable files for Pydantic models before route extraction."""
|
|
91
|
+
for abs_path in self._collect_all_imports(self.entry_file):
|
|
92
|
+
if abs_path not in self.models:
|
|
93
|
+
try:
|
|
94
|
+
with open(abs_path, 'r', encoding='utf-8') as f:
|
|
95
|
+
src = f.read()
|
|
96
|
+
tree = ast.parse(src, filename=abs_path)
|
|
97
|
+
self.extract_pydantic_models(tree, abs_path)
|
|
98
|
+
except:
|
|
99
|
+
pass
|
|
100
|
+
|
|
30
101
|
def resolve_import(self, from_file, module_name):
|
|
31
102
|
if not module_name:
|
|
32
103
|
return None
|
|
@@ -176,10 +247,22 @@ class RouteSpecExtractor:
|
|
|
176
247
|
for node in tree.body:
|
|
177
248
|
# from x import y
|
|
178
249
|
if isinstance(node, ast.ImportFrom):
|
|
179
|
-
|
|
180
|
-
if
|
|
181
|
-
|
|
182
|
-
|
|
250
|
+
dots = '.' * node.level if node.level > 0 else ''
|
|
251
|
+
module_with_dots = f"{dots}{node.module}" if node.module else dots
|
|
252
|
+
|
|
253
|
+
for name_node in node.names:
|
|
254
|
+
local_name = name_node.asname or name_node.name
|
|
255
|
+
|
|
256
|
+
if node.module:
|
|
257
|
+
full_mod = f"{module_with_dots}.{name_node.name}"
|
|
258
|
+
else:
|
|
259
|
+
full_mod = f"{module_with_dots}{name_node.name}"
|
|
260
|
+
|
|
261
|
+
resolved = self.resolve_import(abs_file, full_mod)
|
|
262
|
+
if not resolved:
|
|
263
|
+
resolved = self.resolve_import(abs_file, module_with_dots)
|
|
264
|
+
|
|
265
|
+
if resolved:
|
|
183
266
|
import_map[local_name] = resolved
|
|
184
267
|
|
|
185
268
|
# import x
|
|
@@ -221,36 +304,46 @@ class RouteSpecExtractor:
|
|
|
221
304
|
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Call):
|
|
222
305
|
call = node.value
|
|
223
306
|
if isinstance(call.func, ast.Attribute) and call.func.attr == 'include_router':
|
|
224
|
-
# First arg is the router variable
|
|
225
|
-
if len(call.args) >= 1
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
307
|
+
# First arg is the router variable (can be ast.Name or ast.Attribute like users.router)
|
|
308
|
+
if len(call.args) >= 1:
|
|
309
|
+
arg0 = call.args[0]
|
|
310
|
+
router_name = None
|
|
311
|
+
is_attribute = False
|
|
312
|
+
if isinstance(arg0, ast.Name):
|
|
313
|
+
router_name = arg0.id
|
|
314
|
+
elif isinstance(arg0, ast.Attribute) and isinstance(arg0.value, ast.Name):
|
|
315
|
+
router_name = arg0.value.id
|
|
316
|
+
is_attribute = True
|
|
317
|
+
|
|
318
|
+
if router_name:
|
|
319
|
+
mount_prefix = ""
|
|
320
|
+
for kw in call.keywords:
|
|
321
|
+
if kw.arg == 'prefix':
|
|
322
|
+
val = self.get_lit_value(kw.value)
|
|
323
|
+
if val is not None:
|
|
324
|
+
mount_prefix = val
|
|
325
|
+
|
|
326
|
+
resolved_file = import_map.get(router_name)
|
|
327
|
+
# Reconstruct cumulative prefix
|
|
328
|
+
# parent prefix + mount prefix
|
|
329
|
+
cum_prefix = (prefix + mount_prefix).replace('//', '/')
|
|
330
|
+
if resolved_file:
|
|
331
|
+
# If it was users.router, we mount the resolved file with the module's router name
|
|
332
|
+
self.mounts.append((cum_prefix, resolved_file, router_name))
|
|
245
333
|
else:
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
334
|
+
# If it's a locally defined router
|
|
335
|
+
if not is_attribute and router_name in router_vars:
|
|
336
|
+
local_r_prefix = router_prefixes.get(router_name, "")
|
|
337
|
+
self.mounts.append((cum_prefix + local_r_prefix, abs_file, router_name))
|
|
338
|
+
else:
|
|
339
|
+
self.skipped.append({
|
|
340
|
+
'reason': f"Router '{router_name}' source file not resolved",
|
|
341
|
+
'file': self.get_rel_path(abs_file),
|
|
342
|
+
'line': node.lineno
|
|
343
|
+
})
|
|
251
344
|
|
|
252
345
|
# FunctionDef decorated with app/router decorators
|
|
253
|
-
elif isinstance(node, ast.FunctionDef):
|
|
346
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
254
347
|
for dec in node.decorator_list:
|
|
255
348
|
if isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute):
|
|
256
349
|
obj_node = dec.func.value
|
|
@@ -283,7 +376,7 @@ class RouteSpecExtractor:
|
|
|
283
376
|
full_path = '/' + full_path
|
|
284
377
|
|
|
285
378
|
# Anti-loop skip
|
|
286
|
-
if full_path.startswith('/mcp'):
|
|
379
|
+
if full_path == '/mcp' or full_path.startswith('/mcp/'):
|
|
287
380
|
self.skipped.append({
|
|
288
381
|
'reason': f"self-referential path {full_path} blocked",
|
|
289
382
|
'file': self.get_rel_path(abs_file),
|
|
@@ -327,6 +420,10 @@ class RouteSpecExtractor:
|
|
|
327
420
|
|
|
328
421
|
# Check if it has a default value
|
|
329
422
|
has_default = idx >= defaults_start_idx
|
|
423
|
+
if has_default:
|
|
424
|
+
default_val = node.args.defaults[idx - defaults_start_idx]
|
|
425
|
+
if self._is_depends(default_val):
|
|
426
|
+
continue
|
|
330
427
|
|
|
331
428
|
# Parse type annotation
|
|
332
429
|
arg_type, type_req = self.parse_type_annotation(arg.annotation)
|
|
@@ -403,6 +500,9 @@ class RouteSpecExtractor:
|
|
|
403
500
|
self.entry_app_vars = list(app_vars)
|
|
404
501
|
|
|
405
502
|
def run(self):
|
|
503
|
+
# Pre-load Pydantic models from all imported files
|
|
504
|
+
self.preload_models()
|
|
505
|
+
|
|
406
506
|
# 1st pass: parse entry file
|
|
407
507
|
self.parse_file(self.entry_file, '', 0)
|
|
408
508
|
|