sparda-mcp 0.8.0 → 0.9.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.
@@ -0,0 +1,84 @@
1
+ // ubg/compile.js — the orchestrator: codebase in, UBG out.
2
+ // detect → extract (framework routes + SQL schemas) → translate → link →
3
+ // optimize → canonicalize. 100% local, zero network, zero LLM, deterministic:
4
+ // the only inputs are the bytes of the source tree, the only output is the
5
+ // graph plus an honest report of everything the static eye could NOT see.
6
+ import { detectStack } from '../detect.js';
7
+ import { clearModuleCache } from './extract.js';
8
+ import { extractExpress } from './express.js';
9
+ import { extractNext } from './nextjs.js';
10
+ import { extractFastAPI } from './fastapi.js';
11
+ import { parseSqlSchemas } from './sql.js';
12
+ import { translate } from './translate.js';
13
+ import { linkDataFlow } from './link.js';
14
+ import { optimize } from './pipeline.js';
15
+ import { validateGraph } from './schema.js';
16
+ import { serializeGraph, sourceHashOf, writeGraph } from './serialize.js';
17
+
18
+ export function compileUBG(
19
+ cwd,
20
+ { write = true, out = null, optimizePasses = true } = {},
21
+ ) {
22
+ clearModuleCache(); // each compile run parses fresh — no stale-file ghosts
23
+
24
+ const stack = detectStack(cwd);
25
+ const extractors = {
26
+ express: () => extractExpress(cwd, stack.entryFile),
27
+ nextjs: () => extractNext(cwd, stack.entryFile),
28
+ fastapi: () => extractFastAPI(cwd, stack.entryFile, stack.pythonCmd),
29
+ };
30
+ if (!extractors[stack.framework]) {
31
+ throw Object.assign(
32
+ new Error(`UBG compiler has no lowering for ${stack.framework} yet.`),
33
+ { code: 'USER' },
34
+ );
35
+ }
36
+ const extracted = extractors[stack.framework]();
37
+
38
+ const sql = parseSqlSchemas(cwd);
39
+
40
+ const graph = translate({
41
+ framework: stack.framework,
42
+ routes: extracted.routes,
43
+ globalMiddlewares: extracted.globalMiddlewares,
44
+ helpers: extracted.helpers,
45
+ tables: sql.tables,
46
+ });
47
+ validateGraph(graph);
48
+
49
+ const linkReport = linkDataFlow(graph);
50
+ validateGraph(graph);
51
+
52
+ const passReports = optimizePasses ? optimize(graph) : [];
53
+
54
+ graph.meta = {
55
+ framework: stack.framework,
56
+ entry: stack.entryFile,
57
+ sourceHash: sourceHashOf(cwd, [
58
+ ...extracted.scannedFiles,
59
+ ...sql.tables.map((t) => t.sourceFile),
60
+ ]),
61
+ };
62
+
63
+ const report = {
64
+ framework: stack.framework,
65
+ entry: stack.entryFile,
66
+ routes: extracted.routes.length,
67
+ tables: sql.tables.length,
68
+ link: linkReport,
69
+ passes: passReports,
70
+ skipped: [...extracted.skipped, ...sql.skipped],
71
+ counts: countGraph(graph),
72
+ };
73
+
74
+ const outPath = write ? writeGraph(graph, cwd, out) : null;
75
+ return { graph, json: serializeGraph(graph), report, outPath };
76
+ }
77
+
78
+ function countGraph(graph) {
79
+ const nodes = {};
80
+ const edges = {};
81
+ for (const n of graph.nodes.values()) nodes[n.kind] = (nodes[n.kind] ?? 0) + 1;
82
+ for (const e of graph.edges) edges[e.kind] = (edges[e.kind] ?? 0) + 1;
83
+ return { nodes, edges, totalNodes: graph.nodes.size, totalEdges: graph.edges.length };
84
+ }
@@ -0,0 +1,258 @@
1
+ // ubg/express.js — Express codebase → route facts for the UBG translator.
2
+ // Richer than parser/express.js (which only needs tool signatures): here the
3
+ // FULL handler chain matters — every middleware between path and handler
4
+ // becomes a graph node, and identifier handlers are resolved to their function
5
+ // bodies (same file, then relative imports) so the microscope can scan them.
6
+ // Depth stays bounded like the v0 parser: entry file + mounted routers.
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { parseModule, resolveRelImport } from './extract.js';
10
+
11
+ const HTTP = new Set(['get', 'post', 'put', 'patch', 'delete']);
12
+
13
+ // → { routes, globalMiddlewares, helpers, skipped, scannedFiles }
14
+ export function extractExpress(cwd, entryFile) {
15
+ const routes = [];
16
+ const globalMiddlewares = []; // app.use(fn) — applies to every route
17
+ const helpers = []; // top-level functions of scanned files (dead-path candidates)
18
+ const skipped = [];
19
+ const scannedFiles = [];
20
+ const visited = new Set();
21
+ const mounts = [];
22
+
23
+ scanFile(path.resolve(cwd, entryFile), '', 0);
24
+ for (const m of [...mounts]) {
25
+ if (m.file && fs.existsSync(m.file)) scanFile(m.file, m.prefix, 1);
26
+ else if (!m.file)
27
+ skipped.push({
28
+ reason: `router "${m.ident}" mounted at ${m.prefix} — source file not resolved`,
29
+ file: m.fromFile,
30
+ });
31
+ }
32
+
33
+ routes.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
34
+ return { routes, globalMiddlewares, helpers, skipped, scannedFiles };
35
+
36
+ function scanFile(absFile, prefix, depth) {
37
+ const key = `${absFile}::${prefix}`;
38
+ if (depth > 2 || visited.has(key)) return;
39
+ visited.add(key);
40
+
41
+ const mod = parseModule(absFile);
42
+ const relFile = rel(absFile);
43
+ if (mod.error) {
44
+ skipped.push({ reason: `${mod.error} in ${relFile}`, file: relFile });
45
+ return;
46
+ }
47
+ scannedFiles.push(relFile);
48
+
49
+ // every top-level function is a potential dead-path candidate
50
+ for (const [name, f] of mod.functions) {
51
+ helpers.push({ name, sourceFile: relFile, sourceLine: f.line, fn: f.node });
52
+ }
53
+
54
+ const appVars = new Set();
55
+ const routerVars = new Set();
56
+ for (const node of mod.ast.program.body) collectAppVars(node, appVars, routerVars);
57
+
58
+ walkStatements(mod.ast.program.body);
59
+
60
+ function walkStatements(body) {
61
+ for (const stmt of body) {
62
+ const expr =
63
+ stmt.type === 'ExpressionStatement' && stmt.expression.type === 'CallExpression'
64
+ ? stmt.expression
65
+ : null;
66
+ if (!expr) continue;
67
+ const callee = expr.callee;
68
+ if (callee.type !== 'MemberExpression' || callee.property.type !== 'Identifier')
69
+ continue;
70
+ const objName = callee.object.type === 'Identifier' ? callee.object.name : null;
71
+ if (!objName || (!appVars.has(objName) && !routerVars.has(objName))) continue;
72
+ const method = callee.property.name;
73
+ const args = expr.arguments;
74
+
75
+ if (method === 'use') {
76
+ handleUse(args, stmt);
77
+ continue;
78
+ }
79
+ if (!HTTP.has(method)) continue;
80
+
81
+ const pathArg = args[0];
82
+ if (!pathArg || pathArg.type !== 'StringLiteral') {
83
+ skipped.push({
84
+ reason: `dynamic path on ${method.toUpperCase()} (non-literal first arg)`,
85
+ file: relFile,
86
+ line: expr.loc?.start.line,
87
+ });
88
+ continue;
89
+ }
90
+ const fullPath = joinPath(prefix, pathArg.value);
91
+
92
+ const chain = [];
93
+ for (let i = 1; i < args.length; i++) {
94
+ const resolved = resolveCallable(args[i], mod, absFile, relFile);
95
+ if (!resolved) {
96
+ skipped.push({
97
+ reason: `unresolvable handler arg #${i} on ${method.toUpperCase()} ${fullPath}`,
98
+ file: relFile,
99
+ line: args[i].loc?.start.line,
100
+ });
101
+ continue;
102
+ }
103
+ resolved.role = i === args.length - 1 ? 'handler' : 'middleware';
104
+ chain.push(resolved);
105
+ }
106
+
107
+ const description = (stmt.leadingComments ?? expr.leadingComments ?? [])
108
+ .map((c) =>
109
+ c.value
110
+ .replace(/^\*+/gm, '')
111
+ .replace(/^\s*\*\s?/gm, '')
112
+ .trim(),
113
+ )
114
+ .filter(Boolean)
115
+ .join(' ')
116
+ .slice(0, 400);
117
+
118
+ routes.push({
119
+ method,
120
+ path: fullPath,
121
+ sourceFile: relFile,
122
+ sourceLine: expr.loc?.start.line ?? 0,
123
+ params: pathParamsOf(fullPath),
124
+ chain,
125
+ description,
126
+ });
127
+ }
128
+ }
129
+
130
+ function handleUse(args, stmt) {
131
+ if (!args.length) return;
132
+ // app.use('/prefix', router) → mount for second pass
133
+ if (args[0].type === 'StringLiteral' && args[1]?.type === 'Identifier') {
134
+ mounts.push({
135
+ prefix: joinPath(prefix, args[0].value),
136
+ file: mod.imports.get(args[1].name) ?? null,
137
+ ident: args[1].name,
138
+ fromFile: relFile,
139
+ });
140
+ return;
141
+ }
142
+ // app.use(fn) / app.use(ident) at depth 0 → global middleware
143
+ if (depth === 0) {
144
+ for (const a of args) {
145
+ if (a.type === 'StringLiteral') continue;
146
+ // express.json() & friends: framework plumbing, not app behavior
147
+ if (
148
+ a.type === 'CallExpression' &&
149
+ ((a.callee.type === 'MemberExpression' &&
150
+ a.callee.object.type === 'Identifier' &&
151
+ a.callee.object.name === 'express') ||
152
+ (a.callee.type === 'Identifier' &&
153
+ /^(cors|helmet|morgan|compression|cookieparser|bodyparser)$/i.test(
154
+ a.callee.name,
155
+ )))
156
+ )
157
+ continue;
158
+ const resolved = resolveCallable(a, mod, absFile, relFile);
159
+ if (resolved) {
160
+ resolved.role = 'middleware';
161
+ globalMiddlewares.push(resolved);
162
+ } else if (a.type !== 'CallExpression') {
163
+ skipped.push({
164
+ reason: `unresolvable app.use() argument`,
165
+ file: relFile,
166
+ line: stmt.loc?.start.line,
167
+ });
168
+ }
169
+ }
170
+ }
171
+ }
172
+ }
173
+
174
+ // Identifier → function body in this module, else in the module it was
175
+ // imported from. Inline functions pass through as-is.
176
+ function resolveCallable(arg, mod, absFile, relFile) {
177
+ if (arg.type === 'ArrowFunctionExpression' || arg.type === 'FunctionExpression') {
178
+ return {
179
+ name: arg.id?.name ?? 'anonymous',
180
+ sourceFile: relFile,
181
+ sourceLine: arg.loc?.start.line ?? 0,
182
+ fn: arg,
183
+ };
184
+ }
185
+ if (arg.type !== 'Identifier') return null;
186
+
187
+ const local = mod.functions.get(arg.name);
188
+ if (local) {
189
+ return {
190
+ name: arg.name,
191
+ sourceFile: relFile,
192
+ sourceLine: local.line,
193
+ fn: local.node,
194
+ };
195
+ }
196
+ const importedFrom = mod.imports.get(arg.name);
197
+ if (importedFrom) {
198
+ const target = parseModule(importedFrom);
199
+ const fn = target.functions.get(arg.name) ?? target.functions.get('default');
200
+ if (fn) {
201
+ const fromRel = rel(importedFrom);
202
+ if (!scannedFiles.includes(fromRel)) {
203
+ scannedFiles.push(fromRel);
204
+ for (const [name, f] of target.functions)
205
+ helpers.push({
206
+ name,
207
+ sourceFile: fromRel,
208
+ sourceLine: f.line,
209
+ fn: f.node,
210
+ });
211
+ }
212
+ return { name: arg.name, sourceFile: fromRel, sourceLine: fn.line, fn: fn.node };
213
+ }
214
+ }
215
+ // known but bodyless — node exists, microscope stays blind
216
+ return {
217
+ name: arg.name,
218
+ sourceFile: relFile,
219
+ sourceLine: arg.loc?.start.line ?? 0,
220
+ fn: null,
221
+ };
222
+ }
223
+
224
+ function rel(abs) {
225
+ return path.relative(cwd, abs).split(path.sep).join('/');
226
+ }
227
+ }
228
+
229
+ function collectAppVars(node, appVars, routerVars) {
230
+ if (node.type === 'ExportNamedDeclaration' && node.declaration)
231
+ return collectAppVars(node.declaration, appVars, routerVars);
232
+ if (node.type !== 'VariableDeclaration') return;
233
+ for (const d of node.declarations) {
234
+ if (d.id?.type !== 'Identifier' || d.init?.type !== 'CallExpression') continue;
235
+ const callee = d.init.callee;
236
+ if (callee.type === 'Identifier' && callee.name === 'express') appVars.add(d.id.name);
237
+ if (callee.type === 'Identifier' && callee.name === 'Router')
238
+ routerVars.add(d.id.name);
239
+ if (callee.type === 'MemberExpression' && callee.property?.name === 'Router')
240
+ routerVars.add(d.id.name);
241
+ }
242
+ }
243
+
244
+ function pathParamsOf(fullPath) {
245
+ return [...fullPath.matchAll(/:(\w+)/g)].map((m) => ({
246
+ name: m[1],
247
+ in: 'path',
248
+ type: 'string',
249
+ required: true,
250
+ }));
251
+ }
252
+
253
+ function joinPath(prefix, p) {
254
+ const joined = `${prefix ?? ''}${p === '/' && prefix ? '' : p}`.replace(/\/{2,}/g, '/');
255
+ return joined.startsWith('/') ? joined : `/${joined}`;
256
+ }
257
+
258
+ export { resolveRelImport };