sparda-mcp 0.1.0 → 0.3.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.
@@ -5,13 +5,14 @@ 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
9
 
9
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
11
  const MARK_START = '// >>> sparda-injection (do not edit this block) >>>';
11
12
  const MARK_END = '// <<< sparda-injection <<<';
12
13
 
13
14
  export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
14
- const taken = new Set(['sparda_info', 'sparda_list_disabled_tools']);
15
+ const taken = new Set(['sparda_info', 'sparda_list_disabled_tools', 'sparda_get_context']);
15
16
  const tools = {};
16
17
  for (const r of routes) {
17
18
  const name = toolNameFor(r, taken);
@@ -25,8 +26,10 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
25
26
  confidence: r.confidence,
26
27
  };
27
28
  }
29
+ const prev = carryOverManifest(cwd, tools);
28
30
 
29
- const localKey = crypto.randomUUID();
31
+ // stable across re-runs so a running bridge/host pair never desyncs
32
+ const localKey = prev?.localKey ?? crypto.randomUUID();
30
33
  const entryAbs = path.resolve(cwd, entryFile);
31
34
  const entryDir = path.dirname(entryAbs);
32
35
  const ext = entryFile.endsWith('.ts') ? '.ts' : entryFile.endsWith('.mjs') ? '.mjs' : entryFile.endsWith('.cjs') ? '.cjs' : '.js';
@@ -36,20 +39,49 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
36
39
  // --- render router from template
37
40
  let tpl = fs.readFileSync(path.join(__dirname, '..', '..', 'templates', 'express-router.txt'), 'utf8');
38
41
  const isESM = moduleType === 'esm';
42
+ const isTS = ext === '.ts';
43
+
44
+ let importLine = '';
45
+ let reqType = '';
46
+ let resType = '';
47
+ let nextType = '';
48
+
49
+ if (isTS) {
50
+ reqType = ': Request';
51
+ resType = ': Response';
52
+ nextType = ': NextFunction';
53
+ if (isESM) {
54
+ importLine = "import express, { Request, Response, NextFunction } from 'express';";
55
+ } else {
56
+ importLine = "import type { Request, Response, NextFunction } from 'express';\nconst express = require('express');";
57
+ }
58
+ } else {
59
+ importLine = isESM ? "import express from 'express';" : "const express = require('express');";
60
+ }
61
+
39
62
  tpl = tpl
40
- .replace('__IMPORT_LINE__', isESM ? "import express from 'express';" : "const express = require('express');")
63
+ .replace('__IMPORT_LINE__', importLine)
41
64
  .replace('__ROUTER_DECL__', isESM
42
65
  ? 'export const spardaRouter = express.Router();'
43
66
  : 'const spardaRouter = express.Router();\nmodule.exports = { spardaRouter };')
44
67
  .replace('__JSON_MW__', 'express.json()')
45
68
  .replace('__TOOLS_JSON__', JSON.stringify(tools, null, 2))
46
69
  .replace('__LOCAL_KEY__', localKey)
47
- .replace('__PORT__', String(port));
70
+ .replace('__PORT__', String(port))
71
+ .replace(/__REQ_TYPE__/g, reqType)
72
+ .replace(/__RES_TYPE__/g, resType)
73
+ .replace(/__NEXT_TYPE__/g, nextType)
74
+ .replace(/__STATS_TYPE__/g, isTS ? ': Record<string, any>' : '')
75
+ .replace(/__EVENTS_TYPE__/g, isTS ? ': any[]' : '')
76
+ .replace(/__ANY_TYPE__/g, isTS ? ': any' : '');
48
77
  atomicWrite(routerAbs, tpl);
49
78
 
50
79
  // --- inject into entry file (line-based via AST positions)
51
80
  const injection = injectIntoEntry({ entryAbs, moduleType, routerFileName, cwd });
52
81
 
82
+ // record what init did to .gitignore so `remove` can revert it exactly (hard rule #4, E-010)
83
+ const gitignore = ensureGitignore(cwd) ?? prev?.gitignore ?? null;
84
+
53
85
  // --- manifest
54
86
  const manifest = {
55
87
  version: 1,
@@ -62,10 +94,12 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
62
94
  injectedFiles: injection.injected ? [entryFile] : [],
63
95
  createdAt: new Date().toISOString(),
64
96
  tools: Object.fromEntries(Object.entries(tools).map(([k, v]) => [k, { method: v.method, path: v.path, enabled: v.enabled }])),
97
+ ...(gitignore ? { gitignore } : {}),
98
+ ...(prev?.semantic ? { semantic: prev.semantic } : {}),
99
+ ...(prev?.immune ? { immune: prev.immune } : {}),
65
100
  };
66
101
  atomicWrite(path.join(cwd, 'sparda.json'), JSON.stringify(manifest, null, 2) + '\n');
67
102
 
68
- ensureGitignore(cwd);
69
103
  return { tools, manifest, routerFile: path.relative(cwd, routerAbs).split(path.sep).join('/'), injection };
70
104
  }
71
105
 
@@ -165,15 +199,19 @@ export function removeInjection(cwd, manifest) {
165
199
  return results;
166
200
  }
167
201
 
202
+ // Returns what was done ('created' | 'appended' | null) so the manifest can
203
+ // record it and `remove` can revert the exact edit (byte-for-byte promise).
168
204
  function ensureGitignore(cwd) {
169
205
  const gi = path.join(cwd, '.gitignore');
170
206
  const line = '.sparda/';
171
207
  if (fs.existsSync(gi)) {
172
208
  const content = fs.readFileSync(gi, 'utf8');
173
- if (!content.split(/\r?\n/).includes(line)) fs.appendFileSync(gi, `\n${line}\n`);
174
- } else {
175
- fs.writeFileSync(gi, `${line}\n`);
209
+ if (content.split(/\r?\n/).includes(line)) return null;
210
+ fs.appendFileSync(gi, `\n${line}\n`);
211
+ return 'appended';
176
212
  }
213
+ fs.writeFileSync(gi, `${line}\n`);
214
+ return 'created';
177
215
  }
178
216
 
179
217
  function atomicWrite(file, content) {
@@ -0,0 +1,211 @@
1
+ // generator/fastapi.js — router generation + python injection (spec: blueprint 04-GENERATION §A)
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { toolNameFor } from '../parser/express.js';
8
+ import { carryOverManifest } from './manifest.js';
9
+
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+ const MARK_START = '# >>> sparda-injection (do not edit this block) >>>';
12
+ const MARK_END = '# <<< sparda-injection <<<';
13
+
14
+ export function generateFastAPI({ cwd, entryFile, port, routes, entryAppVars, pythonCmd = 'python' }) {
15
+ const taken = new Set(['sparda_info', 'sparda_list_disabled_tools', 'sparda_get_context']);
16
+ const tools = {};
17
+ for (const r of routes) {
18
+ const name = toolNameFor(r, taken);
19
+ tools[name] = {
20
+ method: r.method.toUpperCase(),
21
+ path: r.path,
22
+ enabled: !r.mutating, // write-safety: mutating tools off by default
23
+ pathParams: r.params.filter((p) => p.in === 'path').map((p) => p.name),
24
+ description: r.description,
25
+ params: r.params,
26
+ confidence: r.confidence,
27
+ };
28
+ }
29
+ const prev = carryOverManifest(cwd, tools);
30
+
31
+ // stable across re-runs so a running bridge/host pair never desyncs
32
+ const localKey = prev?.localKey ?? crypto.randomUUID();
33
+ const entryAbs = path.resolve(cwd, entryFile);
34
+ const entryDir = path.dirname(entryAbs);
35
+ const routerFileName = 'sparda_router.py';
36
+ const routerAbs = path.join(entryDir, routerFileName);
37
+
38
+ // --- render router from template
39
+ let tpl = fs.readFileSync(path.join(__dirname, '..', '..', 'templates', 'fastapi-router.txt'), 'utf8');
40
+ tpl = tpl
41
+ // double-stringify: a JSON string literal is also a valid Python string literal,
42
+ // and json.loads() in the template turns it into real Python values (E-009)
43
+ .replace('__TOOLS_JSON__', JSON.stringify(JSON.stringify(tools)))
44
+ .replace('__LOCAL_KEY__', localKey)
45
+ .replace('__PORT__', String(port));
46
+
47
+ atomicWrite(routerAbs, tpl);
48
+
49
+ // --- inject into entry file (regex-based with compilation safety check)
50
+ const injection = injectIntoEntry({ entryAbs, routerFileName, cwd, entryAppVars, pythonCmd });
51
+
52
+ // record what init did to .gitignore so `remove` can revert it exactly (hard rule #4, E-010)
53
+ const gitignore = ensureGitignore(cwd) ?? prev?.gitignore ?? null;
54
+
55
+ // --- manifest
56
+ const manifest = {
57
+ version: 1,
58
+ framework: 'fastapi',
59
+ entryFile,
60
+ port,
61
+ localKey,
62
+ generatedFiles: [path.relative(cwd, routerAbs).split(path.sep).join('/')],
63
+ injectedFiles: injection.injected ? [entryFile] : [],
64
+ createdAt: new Date().toISOString(),
65
+ tools: Object.fromEntries(Object.entries(tools).map(([k, v]) => [k, { method: v.method, path: v.path, enabled: v.enabled }])),
66
+ ...(gitignore ? { gitignore } : {}),
67
+ ...(prev?.semantic ? { semantic: prev.semantic } : {}),
68
+ ...(prev?.immune ? { immune: prev.immune } : {}),
69
+ };
70
+
71
+ atomicWrite(path.join(cwd, 'sparda.json'), JSON.stringify(manifest, null, 2) + '\n');
72
+
73
+ return { tools, manifest, routerFile: path.relative(cwd, routerAbs).split(path.sep).join('/'), injection };
74
+ }
75
+
76
+ function injectIntoEntry({ entryAbs, routerFileName, cwd, entryAppVars, pythonCmd }) {
77
+ const src = fs.readFileSync(entryAbs, 'utf8');
78
+
79
+ // backup first
80
+ const backupDir = path.join(cwd, '.sparda', 'backup');
81
+ fs.mkdirSync(backupDir, { recursive: true });
82
+ fs.writeFileSync(path.join(backupDir, `${path.basename(entryAbs)}.${Date.now()}`), src);
83
+
84
+ // Determine if directory has __init__.py (relative vs absolute import)
85
+ const entryDir = path.dirname(entryAbs);
86
+ const hasInit = fs.existsSync(path.join(entryDir, '__init__.py'));
87
+ const importLine = hasInit
88
+ ? 'from .sparda_router import sparda_router'
89
+ : 'from sparda_router import sparda_router';
90
+
91
+ // preserve the file's own line endings (Windows checkouts are CRLF)
92
+ const eol = src.includes('\r\n') ? '\r\n' : '\n';
93
+
94
+ // strip previous injection blocks (idempotence)
95
+ let body = src;
96
+ const blockRx = new RegExp(`\\r?\\n?${escapeRx(MARK_START)}[\\s\\S]*?${escapeRx(MARK_END)}\\r?\\n?`, 'g');
97
+ body = body.replace(blockRx, eol);
98
+
99
+ // Let's find the FastAPI() call assignment to inject right after it
100
+ // ([ \t]*) — NOT (\s*): \s matches newlines and would swallow blank lines/CR into the "indent" (E-007)
101
+ const match = body.match(/^([ \t]*)(\w+)\s*=\s*(?:\w+\.)?FastAPI\(/m);
102
+ if (!match) {
103
+ return {
104
+ injected: false,
105
+ manual: [importLine, 'app.include_router(sparda_router, prefix="/mcp")']
106
+ };
107
+ }
108
+
109
+ const indent = match[1];
110
+ const appName = match[2];
111
+
112
+ // Prepare injection block
113
+ const useBlock = [
114
+ MARK_START,
115
+ `${indent}${importLine}`,
116
+ `${indent}${appName}.include_router(sparda_router, prefix="/mcp")`,
117
+ MARK_END
118
+ ];
119
+
120
+ // We find where the matching line starts and ends in order to insert right after it.
121
+ const lines = body.split(/\r?\n/);
122
+ let insertAt = -1;
123
+ const linePattern = new RegExp(`^[ \\t]*${appName}\\s*=\\s*(?:\\w+\\.)?FastAPI\\(`);
124
+ for (let i = 0; i < lines.length; i++) {
125
+ if (linePattern.test(lines[i])) {
126
+ insertAt = i + 1; // Insert AFTER this line
127
+ break;
128
+ }
129
+ }
130
+
131
+ if (insertAt === -1) {
132
+ return {
133
+ injected: false,
134
+ manual: [importLine, `${appName}.include_router(sparda_router, prefix="/mcp")`]
135
+ };
136
+ }
137
+
138
+ lines.splice(insertAt, 0, ...useBlock);
139
+ const out = lines.join(eol);
140
+
141
+ // Post-injection compilation check to avoid syntax errors
142
+ if (!verifyPythonSyntax(entryAbs, out, pythonCmd)) {
143
+ return {
144
+ injected: false,
145
+ manual: [importLine, `${appName}.include_router(sparda_router, prefix="/mcp")`]
146
+ };
147
+ }
148
+
149
+ atomicWrite(entryAbs, out);
150
+ return { injected: true, manual: null };
151
+ }
152
+
153
+ export function removeInjection(cwd, manifest, pythonCmd = 'python') {
154
+ const results = [];
155
+ for (const relFile of manifest.injectedFiles ?? []) {
156
+ const abs = path.resolve(cwd, relFile);
157
+ if (!fs.existsSync(abs)) continue;
158
+ const src = fs.readFileSync(abs, 'utf8');
159
+ const blockRx = new RegExp(`\\r?\\n?${escapeRx(MARK_START)}[\\s\\S]*?${escapeRx(MARK_END)}`, 'g');
160
+ const out = src.replace(blockRx, '');
161
+
162
+ if (verifyPythonSyntax(abs, out, pythonCmd)) {
163
+ atomicWrite(abs, out);
164
+ results.push({ file: relFile, ok: true });
165
+ } else {
166
+ results.push({ file: relFile, ok: false });
167
+ }
168
+ }
169
+ return results;
170
+ }
171
+
172
+ function verifyPythonSyntax(filePath, content, pythonCmd) {
173
+ const tmpFile = `${filePath}.syntax-check.py`;
174
+ try {
175
+ fs.writeFileSync(tmpFile, content);
176
+ const args = pythonCmd === 'py' ? ['-3', '-m', 'py_compile', tmpFile] : ['-m', 'py_compile', tmpFile];
177
+ const res = spawnSync(pythonCmd, args, { timeout: 2000 });
178
+ return res.status === 0;
179
+ } catch {
180
+ return false;
181
+ } finally {
182
+ try {
183
+ if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile);
184
+ } catch {
185
+ // ignore
186
+ }
187
+ }
188
+ }
189
+
190
+ // Returns what was done ('created' | 'appended' | null) so the manifest can
191
+ // record it and `remove` can revert the exact edit (byte-for-byte promise).
192
+ function ensureGitignore(cwd) {
193
+ const gi = path.join(cwd, '.gitignore');
194
+ const line = '.sparda/';
195
+ if (fs.existsSync(gi)) {
196
+ const content = fs.readFileSync(gi, 'utf8');
197
+ if (content.split(/\r?\n/).includes(line)) return null;
198
+ fs.appendFileSync(gi, `\n${line}\n`);
199
+ return 'appended';
200
+ }
201
+ fs.writeFileSync(gi, `${line}\n`);
202
+ return 'created';
203
+ }
204
+
205
+ function atomicWrite(file, content) {
206
+ const tmp = `${file}.sparda-tmp`;
207
+ fs.writeFileSync(tmp, content);
208
+ fs.renameSync(tmp, file);
209
+ }
210
+
211
+ function escapeRx(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
@@ -0,0 +1,23 @@
1
+ // generator/manifest.js — carry user state across re-runs of `sparda init`
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ // Re-running init must not wipe what the user (or the semantic/immune passes)
6
+ // wrote into sparda.json: per-tool `enabled` overrides, the cached `semantic`
7
+ // enrichment and the `immune` memory (antibodies) survive as long as the
8
+ // tool's method+path are unchanged.
9
+ export function carryOverManifest(cwd, tools) {
10
+ let prev = null;
11
+ try {
12
+ prev = JSON.parse(fs.readFileSync(path.join(cwd, 'sparda.json'), 'utf8'));
13
+ } catch {
14
+ return null;
15
+ }
16
+ for (const [name, t] of Object.entries(tools)) {
17
+ const old = prev?.tools?.[name];
18
+ if (old && typeof old.enabled === 'boolean' && old.method === t.method && old.path === t.path) {
19
+ t.enabled = old.enabled;
20
+ }
21
+ }
22
+ return prev;
23
+ }
package/src/index.js CHANGED
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  // SPARDA — Turn any codebase into an MCP server. Residual Labs.
3
+ import { readFileSync } from 'node:fs';
4
+
5
+ const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
3
6
  const [, , cmd, ...rest] = process.argv;
4
7
 
5
8
  const flags = new Set(rest.filter((a) => a.startsWith('--')));
@@ -11,6 +14,7 @@ const getOpt = (name, dflt) => {
11
14
  const opts = {
12
15
  yes: flags.has('--yes') || flags.has('-y'),
13
16
  verbose: flags.has('--verbose'),
17
+ quiet: flags.has('--quiet'),
14
18
  port: getOpt('port', null),
15
19
  cwd: process.cwd(),
16
20
  };
@@ -34,19 +38,34 @@ try {
34
38
  }
35
39
  case 'doctor': {
36
40
  const { runDoctor } = await import('./commands/doctor.js');
37
- await runDoctor(opts);
41
+ const { healthy } = await runDoctor(opts);
42
+ if (!healthy) process.exitCode = 1; // let scripts/CI gate on doctor
43
+ break;
44
+ }
45
+ case 'sync': {
46
+ const { runSync } = await import('./commands/sync.js');
47
+ await runSync(opts);
48
+ break;
49
+ }
50
+ case 'hook': {
51
+ const { runHook } = await import('./commands/hook.js');
52
+ await runHook(opts);
38
53
  break;
39
54
  }
40
55
  default:
41
- console.log(`SPARDA v0.1.0 — Turn any codebase into an MCP server.
56
+ console.log(`SPARDA v${VERSION} — Turn any codebase into an MCP server.
42
57
 
43
58
  Usage:
44
59
  npx sparda-mcp init Scan project, generate & inject the MCP router
45
60
  npx sparda-mcp dev Run the MCP stdio bridge (connect Claude Desktop)
61
+ npx sparda-mcp sync Re-sync the router after route changes (no prompts)
62
+ npx sparda-mcp hook Install the git sentinel (auto-sync after commits)
46
63
  npx sparda-mcp remove Remove SPARDA from this project (clean git diff)
47
64
  npx sparda-mcp doctor Diagnose your setup
48
65
 
49
- Flags: --yes (skip prompts) --port <n> --verbose`);
66
+ Flags: --yes (skip prompts) --port <n> --quiet --verbose
67
+
68
+ By Residual Labs — residual-labs.fr`);
50
69
  process.exit(cmd ? 1 : 0);
51
70
  }
52
71
  } catch (err) {
@@ -50,16 +50,29 @@ export function parseExpressProject(cwd, entryFile) {
50
50
  traverse(ast, {
51
51
  VariableDeclarator(p) {
52
52
  const init = p.node.init;
53
- const name = p.node.id?.name;
54
- if (!name || !init) return;
53
+ if (!init) return;
55
54
  if (init.type === 'CallExpression') {
56
55
  const callee = init.callee;
57
- if (callee.type === 'Identifier' && callee.name === 'express') { appVars.add(name); exprStarts.add(p.node.loc.end.line); }
58
- if (callee.type === 'Identifier' && callee.name === 'Router') routerVars.add(name);
59
- if (callee.type === 'MemberExpression' && callee.property?.name === 'Router') routerVars.add(name);
60
56
  if (callee.type === 'Identifier' && callee.name === 'require' && init.arguments[0]?.type === 'StringLiteral') {
61
57
  const resolved = resolveRel(absFile, init.arguments[0].value);
62
- if (resolved) importMap.set(name, resolved);
58
+ if (resolved) {
59
+ const id = p.node.id;
60
+ if (id.type === 'Identifier') {
61
+ importMap.set(id.name, resolved);
62
+ } else if (id.type === 'ObjectPattern') {
63
+ for (const prop of id.properties) {
64
+ if (prop.type === 'ObjectProperty' && prop.value.type === 'Identifier') {
65
+ importMap.set(prop.value.name, resolved);
66
+ }
67
+ }
68
+ }
69
+ }
70
+ }
71
+ const name = p.node.id?.name;
72
+ if (name) {
73
+ if (callee.type === 'Identifier' && callee.name === 'express') { appVars.add(name); exprStarts.add(p.node.loc.end.line); }
74
+ if (callee.type === 'Identifier' && callee.name === 'Router') routerVars.add(name);
75
+ if (callee.type === 'MemberExpression' && callee.property?.name === 'Router') routerVars.add(name);
63
76
  }
64
77
  }
65
78
  },
@@ -97,6 +110,11 @@ export function parseExpressProject(cwd, entryFile) {
97
110
  }
98
111
 
99
112
  const fullPath = joinPath(prefix, pathArg.value);
113
+ if (fullPath.startsWith('/mcp')) {
114
+ skipped.push({ reason: `self-referential path ${fullPath} blocked`, file: rel(absFile), line: p.node.loc?.start.line });
115
+ return;
116
+ }
117
+
100
118
  const handler = args[args.length - 1];
101
119
  const handlerName = handler?.type === 'Identifier' ? handler.name
102
120
  : handler?.id?.name ?? `anonymous_${routes.length + 1}`;
@@ -141,7 +159,8 @@ export function parseExpressProject(cwd, entryFile) {
141
159
 
142
160
  function resolveRel(fromFile, spec) {
143
161
  if (!spec.startsWith('.')) return null;
144
- const base = path.resolve(path.dirname(fromFile), spec);
162
+ const cleanSpec = spec.replace(/\.(m?[jt]s|cjs)$/, '');
163
+ const base = path.resolve(path.dirname(fromFile), cleanSpec);
145
164
  for (const cand of [base, `${base}.ts`, `${base}.js`, `${base}.mjs`, `${base}.cjs`,
146
165
  path.join(base, 'index.ts'), path.join(base, 'index.js')]) {
147
166
  if (fs.existsSync(cand) && fs.statSync(cand).isFile()) {
@@ -172,6 +191,7 @@ export function toolNameFor(route, taken) {
172
191
  let name = `${route.method}_${route.path}`
173
192
  .toLowerCase()
174
193
  .replace(/:(\w+)/g, 'by_$1')
194
+ .replace(/\{(\w+)\}/g, 'by_$1')
175
195
  .replace(/[^a-z0-9]+/g, '_')
176
196
  .replace(/^_+|_+$/g, '')
177
197
  .slice(0, 60) || 'tool';
@@ -0,0 +1,26 @@
1
+ // parser/fastapi.js — JS wrapper executing Python AST extractor (spec: blueprint 03-PARSING §C)
2
+ import { spawnSync } from 'node:child_process';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+
8
+ export function parseFastAPIProject(cwd, entryFile, pythonCmd = 'python') {
9
+ const extractorScript = path.resolve(__dirname, 'fastapi_extract.py');
10
+ const res = spawnSync(pythonCmd, [extractorScript, entryFile, cwd], { cwd, encoding: 'utf8' });
11
+
12
+ if (res.status !== 0) {
13
+ const errorMsg = (res.stderr || res.stdout || '').trim();
14
+ throw new Error(`FastAPI extraction process failed: ${errorMsg}`);
15
+ }
16
+
17
+ try {
18
+ const payload = JSON.parse(res.stdout.trim());
19
+ if (payload.error) {
20
+ throw new Error(payload.error);
21
+ }
22
+ return { routes: payload.routes || [], skipped: payload.skipped || [], entryAppVars: payload.entryAppVars || [] };
23
+ } catch (err) {
24
+ throw new Error(`Failed to parse Python extractor output: ${err.message}. Raw output: ${res.stdout}`);
25
+ }
26
+ }