devlensio 0.6.0 → 0.6.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/dist/filesystem/backendRoutes.d.ts +2 -2
- package/dist/filesystem/backendRoutes.js +317 -18
- package/dist/filesystem/index.js +7 -3
- package/dist/filesystem/index.test.js +274 -0
- package/dist/fingerprint/detectors.js +8 -2
- package/dist/fingerprint/index.test.js +54 -0
- package/dist/graph/edges/callEdges.js +12 -2
- package/dist/parser/extractors/components.js +3 -0
- package/dist/parser/extractors/functions.d.ts +1 -0
- package/dist/parser/extractors/functions.js +11 -2
- package/dist/parser/extractors/hooks.js +5 -0
- package/dist/parser/index.test.js +40 -0
- package/dist/pipeline/index.js +6 -6
- package/dist/types.d.ts +2 -2
- package/package.json +1 -1
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import type { BackendRouteNode } from "../types.js";
|
|
2
|
-
export declare function analyzeBackendRoutes(repoPath: string): BackendRouteNode[];
|
|
1
|
+
import type { BackendRouteNode, Framework } from "../types.js";
|
|
2
|
+
export declare function analyzeBackendRoutes(repoPath: string, framework: Framework): BackendRouteNode[];
|
|
@@ -7,12 +7,15 @@ const HTTP_METHODS = [
|
|
|
7
7
|
];
|
|
8
8
|
const APP_INSTANCE_NAMES = [
|
|
9
9
|
"app", "router", "fastify",
|
|
10
|
-
"server", "api", "koa",
|
|
10
|
+
"server", "api", "koa", "hono", "elysia",
|
|
11
11
|
];
|
|
12
12
|
const IGNORE_DIRS = [
|
|
13
13
|
"node_modules", "dist", "build",
|
|
14
14
|
".next", "coverage", ".git",
|
|
15
15
|
];
|
|
16
|
+
const METHOD_PROP_NAMES = ["method", "verb"];
|
|
17
|
+
const PATH_PROP_NAMES = ["pattern", "path", "url", "pathname", "route"];
|
|
18
|
+
const HANDLER_PROP_NAMES = ["handler", "handlerFunc", "handlerFn", "action", "callback", "middleware"];
|
|
16
19
|
function extractParams(urlPath) {
|
|
17
20
|
const matches = urlPath.match(/:([a-zA-Z0-9_]+)/g) || [];
|
|
18
21
|
return matches.map((m) => m.replace(":", ""));
|
|
@@ -20,23 +23,295 @@ function extractParams(urlPath) {
|
|
|
20
23
|
function normalizeMethod(method) {
|
|
21
24
|
return method.toUpperCase();
|
|
22
25
|
}
|
|
26
|
+
// ─── Route-table (object-literal) recognizer ─────────────────────────────────
|
|
27
|
+
//
|
|
28
|
+
// Detects routes declared as an array of object literals, e.g.:
|
|
29
|
+
//
|
|
30
|
+
// const ROUTES: Route[] = [
|
|
31
|
+
// { method: "GET", pattern: "/api/health", handler: () => Response.json(...) },
|
|
32
|
+
// { method: "GET", pattern: "/api/pre-scan", handler: (_p, req) => handlePreScan(req) },
|
|
33
|
+
// { method: "POST", pattern: "/users/:id", handler: createUser },
|
|
34
|
+
// ];
|
|
35
|
+
//
|
|
36
|
+
// This is framework-agnostic — it covers hand-rolled routers used by bare
|
|
37
|
+
// Bun.serve apps, plain Node http servers, and any project that doesn't use a
|
|
38
|
+
// router library. The `method`-property requirement is the discriminator that
|
|
39
|
+
// prevents confusing this with React Router config arrays (path + element) or
|
|
40
|
+
// arbitrary config arrays.
|
|
41
|
+
//
|
|
42
|
+
// Handler resolution mirrors the imperative recognizer:
|
|
43
|
+
// • handler is an Identifier → handlerName (resolved later by routeEdge.ts)
|
|
44
|
+
// • handler is an arrow/function body whose
|
|
45
|
+
// first bare-name call is a local function → handlerName (the delegated handler)
|
|
46
|
+
// e.g. (_p, req) => handlePreScan(req) → "handlePreScan"
|
|
47
|
+
// • otherwise (self-contained inline) → inlineHandler (synthetic node created
|
|
48
|
+
// by routesToCodeNodes in pipeline)
|
|
49
|
+
// Set form for O(1) HTTP-verb checks on the `method` property value.
|
|
50
|
+
const HTTP_METHODS_SET = new Set(HTTP_METHODS);
|
|
51
|
+
// Reads the first matching string-literal property from an object literal.
|
|
52
|
+
// Tries each alias in `propNames` and returns the literal text of the first hit.
|
|
53
|
+
function readStringProperty(obj, propNames) {
|
|
54
|
+
for (const name of propNames) {
|
|
55
|
+
const prop = obj.getProperty(name);
|
|
56
|
+
if (prop && prop.getInitializer) {
|
|
57
|
+
const v = literalStringValue(prop.getInitializer());
|
|
58
|
+
if (v !== null)
|
|
59
|
+
return v;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
// True if `obj` is a route-table element — has BOTH a literal `method` that is
|
|
65
|
+
// an HTTP verb AND a literal path-like property starting with "/". This strict
|
|
66
|
+
// gate keeps the recognizer from misfiring on config objects.
|
|
67
|
+
function hasRouteTableShape(obj) {
|
|
68
|
+
const methodRaw = readStringProperty(obj, METHOD_PROP_NAMES);
|
|
69
|
+
if (methodRaw === null)
|
|
70
|
+
return false;
|
|
71
|
+
if (!HTTP_METHODS_SET.has(methodRaw.toLowerCase()))
|
|
72
|
+
return false;
|
|
73
|
+
const pathRaw = readStringProperty(obj, PATH_PROP_NAMES);
|
|
74
|
+
if (pathRaw === null || !pathRaw.startsWith("/"))
|
|
75
|
+
return false;
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
// From an arrow/function-literal handler, return the name it delegates to
|
|
79
|
+
// (first CallExpression with a bare-Identifier callee), else keep it inline.
|
|
80
|
+
// Skips PropertyAccessExpression callees like Response.json / req.json so we
|
|
81
|
+
// don't grab noise as the handler name.
|
|
82
|
+
function resolveHandlerLiteral(init) {
|
|
83
|
+
if (!init)
|
|
84
|
+
return {};
|
|
85
|
+
const k = init.getKind();
|
|
86
|
+
// handler: createUser → resolve by name only
|
|
87
|
+
if (k === SyntaxKind.Identifier) {
|
|
88
|
+
return { handlerName: init.getText() };
|
|
89
|
+
}
|
|
90
|
+
// handler: (_p, req) => handlePreScan(req) → "handlePreScan"
|
|
91
|
+
// handler: () => Response.json({ ... }) → inline (no delegated name)
|
|
92
|
+
if (k === SyntaxKind.ArrowFunction || k === SyntaxKind.FunctionExpression) {
|
|
93
|
+
const calls = init.getDescendantsOfKind(SyntaxKind.CallExpression);
|
|
94
|
+
for (const c of calls) {
|
|
95
|
+
const callee = c.getExpression();
|
|
96
|
+
if (callee.getKind() === SyntaxKind.Identifier) {
|
|
97
|
+
return { handlerName: callee.getText() };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// No delegation to a named handler → treat as self-contained inline handler.
|
|
101
|
+
return {
|
|
102
|
+
inlineHandler: {
|
|
103
|
+
rawCode: init.getText(),
|
|
104
|
+
startLine: init.getStartLineNumber(),
|
|
105
|
+
endLine: init.getEndLineNumber(),
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return {};
|
|
110
|
+
}
|
|
111
|
+
// Finds array literals shaped like a route table and emits one BackendRouteNode
|
|
112
|
+
// per qualifying element. Gated on the FIRST element having route-table shape so
|
|
113
|
+
// we don't iterate arbitrary object arrays.
|
|
114
|
+
function detectRouteTableRoutes(file, filePath, framework, out, seen) {
|
|
115
|
+
for (const arr of file.getDescendantsOfKind(SyntaxKind.ArrayLiteralExpression)) {
|
|
116
|
+
const elements = arr.getElements();
|
|
117
|
+
if (elements.length === 0)
|
|
118
|
+
continue;
|
|
119
|
+
// Gate — first element must be a route-shaped object literal. This skips
|
|
120
|
+
// React Router children arrays, plain config arrays, etc.
|
|
121
|
+
if (elements[0].getKind() !== SyntaxKind.ObjectLiteralExpression)
|
|
122
|
+
continue;
|
|
123
|
+
if (!hasRouteTableShape(elements[0]))
|
|
124
|
+
continue;
|
|
125
|
+
for (const el of elements) {
|
|
126
|
+
if (el.getKind() !== SyntaxKind.ObjectLiteralExpression)
|
|
127
|
+
continue;
|
|
128
|
+
if (!hasRouteTableShape(el))
|
|
129
|
+
continue;
|
|
130
|
+
const methodRaw = readStringProperty(el, METHOD_PROP_NAMES);
|
|
131
|
+
const urlPath = readStringProperty(el, PATH_PROP_NAMES);
|
|
132
|
+
// Handler (optional — some entries may be middleware-only)
|
|
133
|
+
let handlerName;
|
|
134
|
+
let inlineHandler;
|
|
135
|
+
for (const name of HANDLER_PROP_NAMES) {
|
|
136
|
+
const prop = el.getProperty(name);
|
|
137
|
+
if (!prop || !prop.getInitializer)
|
|
138
|
+
continue;
|
|
139
|
+
const res = resolveHandlerLiteral(prop.getInitializer());
|
|
140
|
+
handlerName = res.handlerName;
|
|
141
|
+
inlineHandler = res.inlineHandler;
|
|
142
|
+
if (handlerName || inlineHandler)
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
// Dedup against the imperative + bun.serve recognizers (disjoint shapes,
|
|
146
|
+
// but defensive — same route won't be emitted twice).
|
|
147
|
+
const key = `${filePath}::${normalizeMethod(methodRaw)}::${urlPath}`;
|
|
148
|
+
if (seen.has(key))
|
|
149
|
+
continue;
|
|
150
|
+
seen.add(key);
|
|
151
|
+
const params = extractParams(urlPath);
|
|
152
|
+
out.push({
|
|
153
|
+
type: "BACKEND_ROUTE",
|
|
154
|
+
urlPath,
|
|
155
|
+
filePath,
|
|
156
|
+
httpMethod: normalizeMethod(methodRaw),
|
|
157
|
+
handlerName,
|
|
158
|
+
inlineHandler,
|
|
159
|
+
framework,
|
|
160
|
+
isDynamic: params.length > 0,
|
|
161
|
+
params: params.length > 0 ? params : undefined,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// ─── Bun.serve fetch-handler recognizer ───────────────────────────────────────
|
|
167
|
+
//
|
|
168
|
+
// For bare `Bun.serve({ fetch(req) { ... } })` apps that dispatch routes inline
|
|
169
|
+
// (if/switch on the URL instead of a route table or app.METHOD), extract route
|
|
170
|
+
// nodes from:
|
|
171
|
+
// • Binary comparisons: req.url === '/api/health' pathname === '/users'
|
|
172
|
+
// • switch (pathname) { case '/x': ...; case '/y': ... }
|
|
173
|
+
//
|
|
174
|
+
// Method cannot be reliably inferred from a path-only comparison, so emitted
|
|
175
|
+
// routes use httpMethod "ANY". Handler wiring is NOT attempted here — these
|
|
176
|
+
// routes appear as entry-point ROUTE nodes (consistent with routeEdge.ts's
|
|
177
|
+
// documented behavior that an unresolved route node is still a valid graph
|
|
178
|
+
// node, useful for "unconnected entry points" analysis). The valuable
|
|
179
|
+
// handler→route edges for structured routers come from detectRouteTableRoutes
|
|
180
|
+
// and the imperative recognizer above.
|
|
181
|
+
function makeAnyRoute(urlPath, filePath, framework, out, seen) {
|
|
182
|
+
if (!urlPath.startsWith("/"))
|
|
183
|
+
return;
|
|
184
|
+
const key = `${filePath}::ANY::${urlPath}`;
|
|
185
|
+
if (seen.has(key))
|
|
186
|
+
return;
|
|
187
|
+
seen.add(key);
|
|
188
|
+
const params = extractParams(urlPath);
|
|
189
|
+
out.push({
|
|
190
|
+
type: "BACKEND_ROUTE",
|
|
191
|
+
urlPath,
|
|
192
|
+
filePath,
|
|
193
|
+
httpMethod: "ANY",
|
|
194
|
+
framework,
|
|
195
|
+
isDynamic: params.length > 0,
|
|
196
|
+
params: params.length > 0 ? params : undefined,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
// True if an expression reads something URL-ish (req.url, request.url, url,
|
|
200
|
+
// pathname, req.path, …). Used to validate the non-string side of a `=== '/path'`
|
|
201
|
+
// comparison so we don't pick up unrelated string compares like `status === "/done"`.
|
|
202
|
+
function isUrlReference(node) {
|
|
203
|
+
if (!node)
|
|
204
|
+
return false;
|
|
205
|
+
const k = node.getKind();
|
|
206
|
+
if (k === SyntaxKind.Identifier) {
|
|
207
|
+
return ["url", "pathname", "path", "req"].includes(node.getText());
|
|
208
|
+
}
|
|
209
|
+
if (k === SyntaxKind.PropertyAccessExpression) {
|
|
210
|
+
const text = node.getText().toLowerCase();
|
|
211
|
+
return (text.endsWith(".url") ||
|
|
212
|
+
text.endsWith(".pathname") ||
|
|
213
|
+
text.endsWith(".path"));
|
|
214
|
+
}
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
// Extracts route nodes from a Bun.serve() call whose fetch handler dispatches
|
|
218
|
+
// via inline if/switch. Only fires when the config object literal has a `fetch`
|
|
219
|
+
// property whose body is a Block — covers both `fetch(req) { ... }` and
|
|
220
|
+
// `fetch: (req) => { ... }` / `fetch: async (req) => { ... }`.
|
|
221
|
+
function detectBunServeRoutes(file, filePath, framework, out, seen) {
|
|
222
|
+
for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
223
|
+
if (call.getExpression().getText() !== "Bun.serve")
|
|
224
|
+
continue;
|
|
225
|
+
const args = call.getArguments();
|
|
226
|
+
if (args.length === 0)
|
|
227
|
+
continue;
|
|
228
|
+
const cfg = args[0];
|
|
229
|
+
if (cfg.getKind() !== SyntaxKind.ObjectLiteralExpression)
|
|
230
|
+
continue;
|
|
231
|
+
// The `fetch` member can be:
|
|
232
|
+
// • a method shorthand: fetch(req) { ... } → MethodDeclaration, body IS the block
|
|
233
|
+
// • a property assignment: fetch: (req) => { ... } / fetch: async (req) => {...}
|
|
234
|
+
// → PropertyAssignment with an ArrowFunction/FunctionExpression initializer
|
|
235
|
+
// Find the fetch body block across both shapes.
|
|
236
|
+
const fetchProp = cfg.getProperty("fetch");
|
|
237
|
+
if (!fetchProp)
|
|
238
|
+
continue;
|
|
239
|
+
let fetchBody = null;
|
|
240
|
+
if (fetchProp.getKind() === SyntaxKind.MethodDeclaration) {
|
|
241
|
+
// fetch(req) { ... } — the method itself has a body.
|
|
242
|
+
fetchBody = fetchProp.getBody?.() ?? null;
|
|
243
|
+
}
|
|
244
|
+
else if (fetchProp.getInitializer) {
|
|
245
|
+
// { fetch: <initializer> } — unwrap to the function body.
|
|
246
|
+
const fetchInit = fetchProp.getInitializer();
|
|
247
|
+
if (fetchInit) {
|
|
248
|
+
fetchBody =
|
|
249
|
+
fetchInit.getKind() === SyntaxKind.Block
|
|
250
|
+
? fetchInit
|
|
251
|
+
: fetchInit.getBody?.();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (!fetchBody || !fetchBody.getDescendantsOfKind)
|
|
255
|
+
continue;
|
|
256
|
+
// Pattern: `something === '/path'` where one side is a string literal
|
|
257
|
+
// starting with "/" and the other is a url-ish reference.
|
|
258
|
+
for (const bin of fetchBody.getDescendantsOfKind(SyntaxKind.BinaryExpression)) {
|
|
259
|
+
const op = bin.getOperatorToken().getKind();
|
|
260
|
+
if (op !== SyntaxKind.EqualsEqualsEqualsToken &&
|
|
261
|
+
op !== SyntaxKind.EqualsEqualsToken)
|
|
262
|
+
continue;
|
|
263
|
+
const left = bin.getLeft();
|
|
264
|
+
const right = bin.getRight();
|
|
265
|
+
const leftLit = literalStringValue(left);
|
|
266
|
+
const rightLit = literalStringValue(right);
|
|
267
|
+
const lit = leftLit ?? rightLit;
|
|
268
|
+
if (!lit || !lit.startsWith("/"))
|
|
269
|
+
continue;
|
|
270
|
+
const otherSide = leftLit !== null ? right : left;
|
|
271
|
+
if (!isUrlReference(otherSide))
|
|
272
|
+
continue;
|
|
273
|
+
makeAnyRoute(lit, filePath, framework, out, seen);
|
|
274
|
+
}
|
|
275
|
+
// Pattern: switch (pathname) { case '/x': ...; case '/y': ... }
|
|
276
|
+
for (const caseClause of fetchBody.getDescendantsOfKind(SyntaxKind.CaseClause)) {
|
|
277
|
+
const lit = literalStringValue(caseClause.getExpression());
|
|
278
|
+
if (lit && lit.startsWith("/")) {
|
|
279
|
+
makeAnyRoute(lit, filePath, framework, out, seen);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
function includesImport(fileContent, mod) {
|
|
285
|
+
return (fileContent.includes(`from '${mod}'`) ||
|
|
286
|
+
fileContent.includes(`from "${mod}"`) ||
|
|
287
|
+
fileContent.includes(`require('${mod}')`) ||
|
|
288
|
+
fileContent.includes(`require("${mod}")`));
|
|
289
|
+
}
|
|
290
|
+
// Reads a string-literal value from a property initializer (single/double/backtick).
|
|
291
|
+
// Returns null for non-literal (computed) values.
|
|
292
|
+
function literalStringValue(node) {
|
|
293
|
+
if (!node)
|
|
294
|
+
return null;
|
|
295
|
+
const nodeKind = node.getKind();
|
|
296
|
+
if (nodeKind === SyntaxKind.StringLiteral || nodeKind === SyntaxKind.NoSubstitutionTemplateLiteral) {
|
|
297
|
+
return node.getLiteralText();
|
|
298
|
+
}
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
23
301
|
// Detects backend framework from file import statements
|
|
24
302
|
function detectFileFramework(fileContent) {
|
|
25
|
-
if (fileContent
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
303
|
+
if (includesImport(fileContent, "elysia"))
|
|
304
|
+
return "elysia";
|
|
305
|
+
if (includesImport(fileContent, "hono"))
|
|
306
|
+
return "hono";
|
|
307
|
+
if (includesImport(fileContent, "express"))
|
|
29
308
|
return "express";
|
|
30
|
-
if (fileContent
|
|
31
|
-
fileContent.includes('from "fastify"') ||
|
|
32
|
-
fileContent.includes("require('fastify')") ||
|
|
33
|
-
fileContent.includes('require("fastify")'))
|
|
309
|
+
if (includesImport(fileContent, "fastify"))
|
|
34
310
|
return "fastify";
|
|
35
|
-
if (fileContent
|
|
36
|
-
fileContent.includes('from "koa"') ||
|
|
37
|
-
fileContent.includes("require('koa')") ||
|
|
38
|
-
fileContent.includes('require("koa")'))
|
|
311
|
+
if (includesImport(fileContent, "koa"))
|
|
39
312
|
return "koa";
|
|
313
|
+
if (fileContent.includes("Bun.serve(") || includesImport(fileContent, "bun"))
|
|
314
|
+
return "bun"; // Bun is a runtime, not a framework, so we check for Bun.serve() usage or bun import.
|
|
40
315
|
return null;
|
|
41
316
|
}
|
|
42
317
|
function findBackendFiles(dir, files = []) {
|
|
@@ -62,7 +337,7 @@ function findBackendFiles(dir, files = []) {
|
|
|
62
337
|
}
|
|
63
338
|
return files;
|
|
64
339
|
}
|
|
65
|
-
export function analyzeBackendRoutes(repoPath) {
|
|
340
|
+
export function analyzeBackendRoutes(repoPath, framework) {
|
|
66
341
|
const nodes = [];
|
|
67
342
|
const project = new Project({
|
|
68
343
|
compilerOptions: {
|
|
@@ -73,19 +348,37 @@ export function analyzeBackendRoutes(repoPath) {
|
|
|
73
348
|
skipAddingFilesFromTsConfig: true,
|
|
74
349
|
});
|
|
75
350
|
const files = findBackendFiles(repoPath);
|
|
351
|
+
const scanFiles = framework === "bun";
|
|
352
|
+
const fileFrameWorks = new Map();
|
|
76
353
|
// Only add files that actually import a backend framework
|
|
77
354
|
for (const filePath of files) {
|
|
78
355
|
const content = fs.readFileSync(filePath, "utf-8");
|
|
79
|
-
|
|
356
|
+
const detectedFramework = detectFileFramework(content);
|
|
357
|
+
if (detectedFramework) {
|
|
358
|
+
fileFrameWorks.set(filePath, detectedFramework);
|
|
359
|
+
project.addSourceFileAtPath(filePath);
|
|
360
|
+
}
|
|
361
|
+
else if (scanFiles) {
|
|
362
|
+
// No framework import, but in bun mode we still scan: route tables are framework-agnostic
|
|
80
363
|
project.addSourceFileAtPath(filePath);
|
|
81
364
|
}
|
|
82
365
|
}
|
|
366
|
+
// Dedup across recognizers (key = filePath::METHOD::path).
|
|
367
|
+
const seen = new Set();
|
|
83
368
|
for (const file of project.getSourceFiles()) {
|
|
84
369
|
const filePath = file.getFilePath();
|
|
85
370
|
const content = fs.readFileSync(filePath, "utf-8");
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
371
|
+
// Per-file framework label; fall back to the project framework for files that
|
|
372
|
+
// had no framework import but were scanned in bun mode (route tables + Bun.serve
|
|
373
|
+
// dispatch code often import nothing framework-specific).
|
|
374
|
+
const framework = fileFrameWorks.get(filePath) ?? detectFileFramework(content) ?? "bun";
|
|
375
|
+
// Route-table (object-literal) recognizer — runs on every scanned file.
|
|
376
|
+
// Independent of the imperative recognizer below; the two match disjoint
|
|
377
|
+
// shapes, so a single route is never emitted twice.
|
|
378
|
+
detectRouteTableRoutes(file, filePath, framework, nodes, seen);
|
|
379
|
+
// Bun.serve fetch-handler recognizer (inline url comparisons / switch).
|
|
380
|
+
// Only fires on Bun.serve() calls; no-op on other frameworks.
|
|
381
|
+
detectBunServeRoutes(file, filePath, framework, nodes, seen);
|
|
89
382
|
const callExpressions = file.getDescendantsOfKind(SyntaxKind.CallExpression);
|
|
90
383
|
for (const call of callExpressions) {
|
|
91
384
|
try {
|
|
@@ -140,6 +433,12 @@ export function analyzeBackendRoutes(repoPath) {
|
|
|
140
433
|
;
|
|
141
434
|
}
|
|
142
435
|
const params = extractParams(urlPath);
|
|
436
|
+
// Dedup against the route-table + bun.serve recognizers (disjoint shapes,
|
|
437
|
+
// but defensive — same route won't be emitted twice across recognizers).
|
|
438
|
+
const _imperativeKey = `${filePath}::${normalizeMethod(methodName)}::${urlPath}`;
|
|
439
|
+
if (seen.has(_imperativeKey))
|
|
440
|
+
continue;
|
|
441
|
+
seen.add(_imperativeKey);
|
|
143
442
|
nodes.push({
|
|
144
443
|
type: "BACKEND_ROUTE",
|
|
145
444
|
urlPath,
|
package/dist/filesystem/index.js
CHANGED
|
@@ -4,9 +4,13 @@ import { analyzePagesRouter } from "./pagesRouter.js";
|
|
|
4
4
|
import { analyzeBackendRoutes } from "./backendRoutes.js";
|
|
5
5
|
import { analyzeReactRouterRoutes } from "./reactRouterRoutes.js";
|
|
6
6
|
export function analyzeFilesystem(repoPath, fingerprint) {
|
|
7
|
-
// Handle backend frameworks first
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
// Handle backend frameworks first.
|
|
8
|
+
// Same list as BACKEND_FRAMEWORKS in fingerprint/detectors.ts — kept inline
|
|
9
|
+
// to avoid a circular import (detectors imports types, not filesystem).
|
|
10
|
+
// framework is forwarded so analyzeBackendRoutes can pick a scan strategy
|
|
11
|
+
// (import-gated for express/fastify/koa/hono/elysia, broad for bare bun).
|
|
12
|
+
if (["express", "fastify", "koa", "hono", "elysia", "bun"].includes(fingerprint.framework)) {
|
|
13
|
+
return analyzeBackendRoutes(repoPath, fingerprint.framework);
|
|
10
14
|
}
|
|
11
15
|
// Handle React Router projects (framework: "react", router: "react-router").
|
|
12
16
|
// Routes are defined in code, so this runs before the Next.js gate below.
|
|
@@ -346,3 +346,277 @@ describe("analyzeFilesystem — React Router", () => {
|
|
|
346
346
|
deleteFakeRepo(repoPath);
|
|
347
347
|
});
|
|
348
348
|
});
|
|
349
|
+
// ─── Backend frameworks: Bun / Hono / Elysia / Express ───────────────────────────
|
|
350
|
+
//
|
|
351
|
+
// Covers three recognizers added for general backend route detection:
|
|
352
|
+
// 1. route-table (object-literal) — hand-rolled [{method,pattern,handler}] routers
|
|
353
|
+
// (the OSS shape); framework-agnostic, fires in bun scan-all mode.
|
|
354
|
+
// 2. imperative app.METHOD(path, handler) — Express/Hono/Elysia shared shape.
|
|
355
|
+
// 3. Bun.serve fetch-handler — inline switch/if URL dispatch for bare-Bun apps.
|
|
356
|
+
describe("analyzeFilesystem — Backend (Bun/Hono/Elysia/Express)", () => {
|
|
357
|
+
function createRepoWithFiles(files) {
|
|
358
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "devlens-be-test-"));
|
|
359
|
+
for (const [filePath, content] of Object.entries(files)) {
|
|
360
|
+
const fullPath = path.join(tmpDir, filePath);
|
|
361
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
362
|
+
fs.writeFileSync(fullPath, content);
|
|
363
|
+
}
|
|
364
|
+
return tmpDir;
|
|
365
|
+
}
|
|
366
|
+
function backendFingerprint(framework) {
|
|
367
|
+
return makeFingerprint(framework, "none");
|
|
368
|
+
}
|
|
369
|
+
function findRoute(routes, method, urlPath) {
|
|
370
|
+
return routes.find((r) => r.type === "BACKEND_ROUTE" && r.httpMethod === method && r.urlPath === urlPath);
|
|
371
|
+
}
|
|
372
|
+
// ─── Route-table (object-literal) recognizer ─────────────────────────────
|
|
373
|
+
it("detects a route-table array (Bun.serve + hand-rolled router — the OSS shape)", () => {
|
|
374
|
+
const repoPath = createRepoWithFiles({
|
|
375
|
+
"src/server/router.ts": `
|
|
376
|
+
import { handlePreScan, handleGetGraph, handleAnalyze } from "./handlers.js";
|
|
377
|
+
interface Route { method: string; pattern: string; handler: (p: any, req: any) => any; }
|
|
378
|
+
const ROUTES: Route[] = [
|
|
379
|
+
{ method: "GET", pattern: "/api/health", handler: () => Response.json({ status: "ok" }) },
|
|
380
|
+
{ method: "GET", pattern: "/api/pre-scan", handler: (_p, req) => handlePreScan(req) },
|
|
381
|
+
{ method: "POST", pattern: "/api/analyze", handler: (_p, req) => handleAnalyze(req) },
|
|
382
|
+
];
|
|
383
|
+
export async function router(req: any) { return null; }
|
|
384
|
+
`,
|
|
385
|
+
});
|
|
386
|
+
const routes = analyzeFilesystem(repoPath, backendFingerprint("bun"));
|
|
387
|
+
expect(routes.length).toBe(3);
|
|
388
|
+
// Inline self-contained handler → inlineHandler populated (no handlerName).
|
|
389
|
+
const health = findRoute(routes, "GET", "/api/health");
|
|
390
|
+
expect(health).toBeDefined();
|
|
391
|
+
expect(health?.handlerName).toBeUndefined();
|
|
392
|
+
expect(health?.inlineHandler).toBeDefined();
|
|
393
|
+
// Arrow delegating to a bare-name call → handlerName extracted.
|
|
394
|
+
const preScan = findRoute(routes, "GET", "/api/pre-scan");
|
|
395
|
+
expect(preScan?.handlerName).toBe("handlePreScan");
|
|
396
|
+
expect(preScan?.inlineHandler).toBeUndefined();
|
|
397
|
+
const analyze = findRoute(routes, "POST", "/api/analyze");
|
|
398
|
+
expect(analyze?.handlerName).toBe("handleAnalyze");
|
|
399
|
+
});
|
|
400
|
+
it("resolves a bare-identifier route-table handler by name", () => {
|
|
401
|
+
const repoPath = createRepoWithFiles({
|
|
402
|
+
"src/server/router.ts": `
|
|
403
|
+
import { createUser } from "./handlers.js";
|
|
404
|
+
const ROUTES = [
|
|
405
|
+
{ method: "POST", path: "/users", handler: createUser },
|
|
406
|
+
];
|
|
407
|
+
`,
|
|
408
|
+
});
|
|
409
|
+
const routes = analyzeFilesystem(repoPath, backendFingerprint("bun"));
|
|
410
|
+
const route = findRoute(routes, "POST", "/users");
|
|
411
|
+
expect(route).toBeDefined();
|
|
412
|
+
expect(route?.handlerName).toBe("createUser");
|
|
413
|
+
});
|
|
414
|
+
it("accepts path-property aliases (url / route) and method alias (verb)", () => {
|
|
415
|
+
const repoPath = createRepoWithFiles({
|
|
416
|
+
"src/server/router.ts": `
|
|
417
|
+
const ROUTES = [
|
|
418
|
+
{ verb: "GET", url: "/a", handler: () => null },
|
|
419
|
+
{ method: "DELETE", route: "/b", handler: () => null },
|
|
420
|
+
];
|
|
421
|
+
`,
|
|
422
|
+
});
|
|
423
|
+
const routes = analyzeFilesystem(repoPath, backendFingerprint("bun"));
|
|
424
|
+
expect(findRoute(routes, "GET", "/a")).toBeDefined();
|
|
425
|
+
expect(findRoute(routes, "DELETE", "/b")).toBeDefined();
|
|
426
|
+
});
|
|
427
|
+
it("extracts dynamic params from a route-table pattern", () => {
|
|
428
|
+
const repoPath = createRepoWithFiles({
|
|
429
|
+
"src/server/router.ts": `
|
|
430
|
+
const ROUTES = [
|
|
431
|
+
{ method: "GET", pattern: "/users/:userId/posts/:postId", handler: () => null },
|
|
432
|
+
];
|
|
433
|
+
`,
|
|
434
|
+
});
|
|
435
|
+
const routes = analyzeFilesystem(repoPath, backendFingerprint("bun"));
|
|
436
|
+
const route = findRoute(routes, "GET", "/users/:userId/posts/:postId");
|
|
437
|
+
expect(route?.isDynamic).toBe(true);
|
|
438
|
+
expect(route?.params).toEqual(expect.arrayContaining(["userId", "postId"]));
|
|
439
|
+
});
|
|
440
|
+
it("does NOT fire the route-table recognizer on a React Router children array", () => {
|
|
441
|
+
// React Router arrays use {path, element/component}, no `method` field.
|
|
442
|
+
const repoPath = createRepoWithFiles({
|
|
443
|
+
"src/router.tsx": `
|
|
444
|
+
const routes = [
|
|
445
|
+
{ path: "/users", element: "<Users/>" },
|
|
446
|
+
{ path: "/about", component: About },
|
|
447
|
+
];
|
|
448
|
+
`,
|
|
449
|
+
});
|
|
450
|
+
// Use the react-router fingerprint so this never reaches the backend branch.
|
|
451
|
+
const routes = analyzeFilesystem(repoPath, makeFingerprint("react", "react-router"));
|
|
452
|
+
// React Router fixtures above aren't createBrowserRouter/useRoutes calls,
|
|
453
|
+
// so they produce no routes — but more importantly, no BACKEND_ROUTE.
|
|
454
|
+
expect(routes.every((r) => r.type !== "BACKEND_ROUTE")).toBe(true);
|
|
455
|
+
deleteFakeRepo(repoPath);
|
|
456
|
+
});
|
|
457
|
+
it("does NOT fire the route-table recognizer on an arbitrary config array", () => {
|
|
458
|
+
const repoPath = createRepoWithFiles({
|
|
459
|
+
"src/config.ts": `
|
|
460
|
+
const CONFIG = [
|
|
461
|
+
{ name: "health", url: "/health" },
|
|
462
|
+
{ name: "status", method: "ping", target: "/status" },
|
|
463
|
+
];
|
|
464
|
+
`,
|
|
465
|
+
});
|
|
466
|
+
// Even in bun scan-all mode: first element has no method+"/path" combo, so the
|
|
467
|
+
// route-table gate rejects it and zero routes are emitted.
|
|
468
|
+
const routes = analyzeFilesystem(repoPath, backendFingerprint("bun"));
|
|
469
|
+
expect(routes.every((r) => r.type !== "BACKEND_ROUTE")).toBe(true);
|
|
470
|
+
deleteFakeRepo(repoPath);
|
|
471
|
+
});
|
|
472
|
+
// ─── Imperative recognizer (Express/Hono/Elysia share this shape) ────────
|
|
473
|
+
it("detects Hono routes via app.METHOD(path, handler)", () => {
|
|
474
|
+
const repoPath = createRepoWithFiles({
|
|
475
|
+
"src/server/index.ts": `
|
|
476
|
+
import { Hono } from "hono";
|
|
477
|
+
import { getUser, createUser } from "./handlers.js";
|
|
478
|
+
const app = new Hono();
|
|
479
|
+
app.get("/users/:id", getUser);
|
|
480
|
+
app.post("/users", createUser);
|
|
481
|
+
`,
|
|
482
|
+
});
|
|
483
|
+
const routes = analyzeFilesystem(repoPath, backendFingerprint("hono"));
|
|
484
|
+
expect(findRoute(routes, "GET", "/users/:id")?.handlerName).toBe("getUser");
|
|
485
|
+
expect(findRoute(routes, "POST", "/users")?.handlerName).toBe("createUser");
|
|
486
|
+
deleteFakeRepo(repoPath);
|
|
487
|
+
});
|
|
488
|
+
it("detects Elysia routes via the app.METHOD(path, handler) API", () => {
|
|
489
|
+
const repoPath = createRepoWithFiles({
|
|
490
|
+
"src/server/index.ts": `
|
|
491
|
+
import { Elysia } from "elysia";
|
|
492
|
+
import { echo } from "./handlers.js";
|
|
493
|
+
const app = new Elysia();
|
|
494
|
+
app.get("/", () => "hello");
|
|
495
|
+
app.post("/echo", echo);
|
|
496
|
+
`,
|
|
497
|
+
});
|
|
498
|
+
const routes = analyzeFilesystem(repoPath, backendFingerprint("elysia"));
|
|
499
|
+
const root = findRoute(routes, "GET", "/");
|
|
500
|
+
expect(root).toBeDefined();
|
|
501
|
+
expect(root?.inlineHandler).toBeDefined(); // self-contained arrow
|
|
502
|
+
expect(findRoute(routes, "POST", "/echo")?.handlerName).toBe("echo");
|
|
503
|
+
deleteFakeRepo(repoPath);
|
|
504
|
+
});
|
|
505
|
+
it("detects an inline arrow handler on an imperative route (Express)", () => {
|
|
506
|
+
const repoPath = createRepoWithFiles({
|
|
507
|
+
"src/server/index.ts": `
|
|
508
|
+
import express from "express";
|
|
509
|
+
const app = express();
|
|
510
|
+
app.get("/ping", (req, res) => res.json({ ok: true }));
|
|
511
|
+
`,
|
|
512
|
+
});
|
|
513
|
+
const routes = analyzeFilesystem(repoPath, backendFingerprint("express"));
|
|
514
|
+
const route = findRoute(routes, "GET", "/ping");
|
|
515
|
+
expect(route).toBeDefined();
|
|
516
|
+
expect(route?.inlineHandler).toBeDefined();
|
|
517
|
+
expect(route?.handlerName).toBeUndefined();
|
|
518
|
+
deleteFakeRepo(repoPath);
|
|
519
|
+
});
|
|
520
|
+
// ─── Bun.serve fetch-handler (inline switch / if dispatch) ────────────────
|
|
521
|
+
it("extracts ANY routes from a Bun.serve switch(pathname)", () => {
|
|
522
|
+
const repoPath = createRepoWithFiles({
|
|
523
|
+
"src/server/index.ts": `
|
|
524
|
+
Bun.serve({
|
|
525
|
+
port: 3000,
|
|
526
|
+
fetch(req) {
|
|
527
|
+
const url = new URL(req.url);
|
|
528
|
+
switch (url.pathname) {
|
|
529
|
+
case "/api/health": return Response.json({ ok: true });
|
|
530
|
+
case "/api/users": return Response.json([]);
|
|
531
|
+
default: return new Response("not found", { status: 404 });
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
`,
|
|
536
|
+
});
|
|
537
|
+
const routes = analyzeFilesystem(repoPath, backendFingerprint("bun"));
|
|
538
|
+
const health = findRoute(routes, "ANY", "/api/health");
|
|
539
|
+
const users = findRoute(routes, "ANY", "/api/users");
|
|
540
|
+
expect(health).toBeDefined();
|
|
541
|
+
expect(users).toBeDefined();
|
|
542
|
+
deleteFakeRepo(repoPath);
|
|
543
|
+
});
|
|
544
|
+
it("extracts ANY routes from a Bun.serve if-chain on req.url", () => {
|
|
545
|
+
const repoPath = createRepoWithFiles({
|
|
546
|
+
"src/server/index.ts": `
|
|
547
|
+
Bun.serve({
|
|
548
|
+
fetch: (req) => {
|
|
549
|
+
if (req.url === "/ping") return new Response("pong");
|
|
550
|
+
if ("/health" === req.url) return Response.json({ ok: true });
|
|
551
|
+
return new Response("404", { status: 404 });
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
`,
|
|
555
|
+
});
|
|
556
|
+
const routes = analyzeFilesystem(repoPath, backendFingerprint("bun"));
|
|
557
|
+
expect(findRoute(routes, "ANY", "/ping")).toBeDefined();
|
|
558
|
+
expect(findRoute(routes, "ANY", "/health")).toBeDefined();
|
|
559
|
+
deleteFakeRepo(repoPath);
|
|
560
|
+
});
|
|
561
|
+
it("does NOT misfire on unrelated string equality compares inside fetch", () => {
|
|
562
|
+
const repoPath = createRepoWithFiles({
|
|
563
|
+
"src/server/index.ts": `
|
|
564
|
+
Bun.serve({
|
|
565
|
+
fetch(req) {
|
|
566
|
+
const status = process.env.STATUS === "/done" ? "ok" : "bad";
|
|
567
|
+
if (req.url === "/real") return new Response("hit");
|
|
568
|
+
return new Response(status, { status: 500 });
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
`,
|
|
572
|
+
});
|
|
573
|
+
const routes = analyzeFilesystem(repoPath, backendFingerprint("bun"));
|
|
574
|
+
// Only the url-ish comparison should yield a route; the STATUS === "/done"
|
|
575
|
+
// compare has a non-url left side, so it must be ignored.
|
|
576
|
+
const backendRoutes = routes.filter((r) => r.type === "BACKEND_ROUTE" && r.urlPath === "/done");
|
|
577
|
+
expect(backendRoutes).toHaveLength(0);
|
|
578
|
+
expect(findRoute(routes, "ANY", "/real")).toBeDefined();
|
|
579
|
+
deleteFakeRepo(repoPath);
|
|
580
|
+
});
|
|
581
|
+
// ─── Regression ────────────────────────────────────────────────────────────
|
|
582
|
+
it("does not double-emit a route when recognized by multiple shapes", () => {
|
|
583
|
+
// A route-table entry and an imperative call for the same path+method must
|
|
584
|
+
// dedup to one node (shared `seen` set across recognizers).
|
|
585
|
+
const repoPath = createRepoWithFiles({
|
|
586
|
+
"src/server/index.ts": `
|
|
587
|
+
import { Hono } from "hono";
|
|
588
|
+
import { getUser } from "./handlers.js";
|
|
589
|
+
const app = new Hono();
|
|
590
|
+
app.get("/users/:id", getUser);
|
|
591
|
+
// Unrelated route table in the same file — different path, must still emit.
|
|
592
|
+
const EXTRA = [
|
|
593
|
+
{ method: "GET", pattern: "/users/:id", handler: () => null },
|
|
594
|
+
];
|
|
595
|
+
`,
|
|
596
|
+
});
|
|
597
|
+
const routes = analyzeFilesystem(repoPath, backendFingerprint("hono"));
|
|
598
|
+
const matches = routes.filter((r) => r.type === "BACKEND_ROUTE" && r.httpMethod === "GET" && r.urlPath === "/users/:id");
|
|
599
|
+
expect(matches).toHaveLength(1);
|
|
600
|
+
deleteFakeRepo(repoPath);
|
|
601
|
+
});
|
|
602
|
+
it("handles a Bun.serve app whose fetch delegates to a named router (OSS index.ts)", () => {
|
|
603
|
+
// The entry file calls router(req) — no if/switch inside fetch, so the
|
|
604
|
+
// Bun.serve recognizer must emit ZERO duplicates; routes come from the table.
|
|
605
|
+
const repoPath = createRepoWithFiles({
|
|
606
|
+
"src/server/index.ts": `
|
|
607
|
+
import { router } from "./router.js";
|
|
608
|
+
Bun.serve({ port: 3000, fetch: async (req) =>-await router(req) });
|
|
609
|
+
`,
|
|
610
|
+
"src/server/router.ts": `
|
|
611
|
+
const ROUTES = [
|
|
612
|
+
{ method: "GET", pattern: "/api/health", handler: () => null },
|
|
613
|
+
];
|
|
614
|
+
`,
|
|
615
|
+
});
|
|
616
|
+
const routes = analyzeFilesystem(repoPath, backendFingerprint("bun"));
|
|
617
|
+
// Only one route, from the route table; fetch delegation emits nothing.
|
|
618
|
+
expect(routes.filter((r) => r.type === "BACKEND_ROUTE")).toHaveLength(1);
|
|
619
|
+
expect(findRoute(routes, "GET", "/api/health")).toBeDefined();
|
|
620
|
+
deleteFakeRepo(repoPath);
|
|
621
|
+
});
|
|
622
|
+
});
|
|
@@ -2,7 +2,7 @@ import fs from "fs";
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
// Framework category arrays — used instead of type casts
|
|
4
4
|
const FRONTEND_FRAMEWORKS = ["nextjs", "react"];
|
|
5
|
-
const BACKEND_FRAMEWORKS = ["express", "fastify", "koa"];
|
|
5
|
+
const BACKEND_FRAMEWORKS = ["express", "fastify", "koa", "hono", "elysia", "bun"];
|
|
6
6
|
export function detectLanguage(repoPath) {
|
|
7
7
|
if (fs.existsSync(path.join(repoPath, "tsconfig.json")))
|
|
8
8
|
return "typescript";
|
|
@@ -19,6 +19,12 @@ export function detectFramework(deps) {
|
|
|
19
19
|
return "fastify";
|
|
20
20
|
if ("koa" in deps)
|
|
21
21
|
return "koa";
|
|
22
|
+
if ("hono" in deps)
|
|
23
|
+
return "hono"; // hono is before bun for a reason -> hono may also use @types/bun, but we want to detect hono first or else wit would be detected as bun.
|
|
24
|
+
if ("elysia" in deps)
|
|
25
|
+
return "elysia";
|
|
26
|
+
if ("bun" in deps || "@types/bun" in deps)
|
|
27
|
+
return "bun"; //Many Bun-only projects don't list a bun package at all since Bun is the runtime, not a dependency). @types/bun is the most reliable single signal
|
|
22
28
|
return "unknown";
|
|
23
29
|
}
|
|
24
30
|
export function detectRouter(deps, framework, repoPath) {
|
|
@@ -95,7 +101,7 @@ export function detectProjectType(framework, deps, repoPath) {
|
|
|
95
101
|
];
|
|
96
102
|
const backendDeps = [
|
|
97
103
|
"express", "fastify", "koa",
|
|
98
|
-
"koa-router", "@fastify/router",
|
|
104
|
+
"koa-router", "@fastify/router", "hono", "elysia",
|
|
99
105
|
];
|
|
100
106
|
const databaseDeps = [
|
|
101
107
|
"prisma", "@prisma/client", "drizzle-orm",
|
|
@@ -40,6 +40,60 @@ describe("analyzeFingerprint", () => {
|
|
|
40
40
|
expect(fingerprint.framework).toBe("unknown");
|
|
41
41
|
deleteFakeRepo(repoPath);
|
|
42
42
|
});
|
|
43
|
+
// ─── Bun / Hono / Elysia Detection ───────────────────────────────────────
|
|
44
|
+
it("should detect a bare Bun project via the bun package", () => {
|
|
45
|
+
const repoPath = createFakeRepo({ bun: "1.1.0" });
|
|
46
|
+
const fingerprint = analyzeFingerprint(repoPath);
|
|
47
|
+
expect(fingerprint.framework).toBe("bun");
|
|
48
|
+
deleteFakeRepo(repoPath);
|
|
49
|
+
});
|
|
50
|
+
it("should detect a bare Bun project via @types/bun (no bun package)", () => {
|
|
51
|
+
// Many Bun-only projects don't list a bun package — @types/bun is the signal.
|
|
52
|
+
const repoPath = createFakeRepo({ "@types/bun": "1.1.0" });
|
|
53
|
+
const fingerprint = analyzeFingerprint(repoPath);
|
|
54
|
+
expect(fingerprint.framework).toBe("bun");
|
|
55
|
+
deleteFakeRepo(repoPath);
|
|
56
|
+
});
|
|
57
|
+
it("should detect a Hono project", () => {
|
|
58
|
+
const repoPath = createFakeRepo({ hono: "4.0.0" });
|
|
59
|
+
const fingerprint = analyzeFingerprint(repoPath);
|
|
60
|
+
expect(fingerprint.framework).toBe("hono");
|
|
61
|
+
deleteFakeRepo(repoPath);
|
|
62
|
+
});
|
|
63
|
+
it("should detect an Elysia project", () => {
|
|
64
|
+
const repoPath = createFakeRepo({ elysia: "1.0.0" });
|
|
65
|
+
const fingerprint = analyzeFingerprint(repoPath);
|
|
66
|
+
expect(fingerprint.framework).toBe("elysia");
|
|
67
|
+
deleteFakeRepo(repoPath);
|
|
68
|
+
});
|
|
69
|
+
it("should detect Hono over Bun when both deps appear (shadowing)", () => {
|
|
70
|
+
// Hono apps on Bun often carry @types/bun — must label as hono, not bun,
|
|
71
|
+
// because their routes are app.METHOD-shaped and label matters downstream.
|
|
72
|
+
const repoPath = createFakeRepo({ hono: "4.0.0", "@types/bun": "1.1.0" });
|
|
73
|
+
const fingerprint = analyzeFingerprint(repoPath);
|
|
74
|
+
expect(fingerprint.framework).toBe("hono");
|
|
75
|
+
deleteFakeRepo(repoPath);
|
|
76
|
+
});
|
|
77
|
+
it("should detect Elysia over Bun when both deps appear (shadowing)", () => {
|
|
78
|
+
const repoPath = createFakeRepo({ elysia: "1.0.0", "@types/bun": "1.1.0" });
|
|
79
|
+
const fingerprint = analyzeFingerprint(repoPath);
|
|
80
|
+
expect(fingerprint.framework).toBe("elysia");
|
|
81
|
+
deleteFakeRepo(repoPath);
|
|
82
|
+
});
|
|
83
|
+
it("should keep React framing when @types/bun is present alongside react", () => {
|
|
84
|
+
// Frontend wins over the bare-Bun heuristic — a React app using Bun as a
|
|
85
|
+
// dev runner must not be mislabelled as a Bun backend.
|
|
86
|
+
const repoPath = createFakeRepo({ react: "18.0.0", "@types/bun": "1.1.0" });
|
|
87
|
+
const fingerprint = analyzeFingerprint(repoPath);
|
|
88
|
+
expect(fingerprint.framework).toBe("react");
|
|
89
|
+
deleteFakeRepo(repoPath);
|
|
90
|
+
});
|
|
91
|
+
it("should still detect express when present (regression)", () => {
|
|
92
|
+
const repoPath = createFakeRepo({ express: "4.18.0" });
|
|
93
|
+
const fingerprint = analyzeFingerprint(repoPath);
|
|
94
|
+
expect(fingerprint.framework).toBe("express");
|
|
95
|
+
deleteFakeRepo(repoPath);
|
|
96
|
+
});
|
|
43
97
|
// ─── Router Detection ──────────────────────────────────────────────────────
|
|
44
98
|
it("should detect Next.js app router", () => {
|
|
45
99
|
const repoPath = createFakeRepo({ next: "14.0.0" }, ["src/app"]);
|
|
@@ -90,8 +90,18 @@ export function detectCallEdges(nodes, lookupMp) {
|
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
// Skip non-third-party member-access calls (console.log, Math.round, etc.)
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
// const rootName = calledName.split(".")[0];
|
|
94
|
+
// const NOISE_ROOTS = new Set([
|
|
95
|
+
// "console", "Math", "Object", "JSON", "Date", "document",
|
|
96
|
+
// "Array", "String", "Number", "Boolean", "Promise", "Error",
|
|
97
|
+
// "setTimeout", "setInterval", "clearTimeout", "clearInterval",
|
|
98
|
+
// "parseInt", "parseFloat", "isNaN", "isFinite",
|
|
99
|
+
// ]);
|
|
100
|
+
// if (NOISE_ROOTS.has(rootName)) continue;
|
|
101
|
+
// // Check if a node exists with the full dotted name
|
|
102
|
+
// if (!lookupMp.nodesByName.has(calledName)) {
|
|
103
|
+
// continue;
|
|
104
|
+
// }
|
|
95
105
|
// ── Local node lookup ─────────────────────────────────────────
|
|
96
106
|
const targets = lookupMp.nodesByName.get(calledName);
|
|
97
107
|
if (!targets || targets.length === 0)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { SyntaxKind } from "ts-morph";
|
|
3
3
|
import { detectFunctionDirective } from "../directives.js";
|
|
4
4
|
import { extractReturnTypeAnnotation, extractReferencedInterfaces, } from "../typeUtils.js";
|
|
5
|
+
import { JS_BUILTINS } from "./functions.js";
|
|
5
6
|
// Generates a unique id for a node
|
|
6
7
|
function makeId(filePath, name) {
|
|
7
8
|
return `${filePath}::${name}`;
|
|
@@ -69,6 +70,8 @@ function extractAllCalls(node) {
|
|
|
69
70
|
continue; // skip hooks
|
|
70
71
|
if (rootName.startsWith("React"))
|
|
71
72
|
continue;
|
|
73
|
+
if (JS_BUILTINS.has(rootName))
|
|
74
|
+
continue;
|
|
72
75
|
if (innerFunctionNames.has(rootName))
|
|
73
76
|
continue; // skip calls to inner functions
|
|
74
77
|
// Capture the full expression (e.g. "axios.get" not just "axios") so
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SourceFile } from "ts-morph";
|
|
2
2
|
import type { CodeNode } from "../../types.js";
|
|
3
3
|
import { type RenderingBoundary } from "../directives.js";
|
|
4
|
+
export declare const JS_BUILTINS: Set<string>;
|
|
4
5
|
export declare function extractFunctionCalls(node: any): string[];
|
|
5
6
|
export declare function extractHookCalls(node: any): string[];
|
|
6
7
|
export declare function extractApiCalls(node: any): string[];
|
|
@@ -4,6 +4,14 @@ import { detectFunctionDirective } from "../directives.js";
|
|
|
4
4
|
import { extractParams, extractReturnTypeAnnotation, extractBareTypeNames, extractReferencedInterfaces, } from "../typeUtils.js";
|
|
5
5
|
// these are used to detect the routes in the Nextjs
|
|
6
6
|
const HTTP_METHOD_EXPORTS = new Set(["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]);
|
|
7
|
+
export const JS_BUILTINS = new Set([
|
|
8
|
+
"console", "Math", "JSON", "Object", "Date",
|
|
9
|
+
"Promise", "Error", "Array", "String", "Number", "Boolean", "document",
|
|
10
|
+
"setTimeout", "setInterval", "clearTimeout", "clearInterval",
|
|
11
|
+
"parseInt", "parseFloat", "isNaN", "isFinite",
|
|
12
|
+
"Reflect", "Proxy", "Intl", "BigInt", "Symbol", "Map", "Set",
|
|
13
|
+
"WeakMap", "WeakSet", "ArrayBuffer", "DataView",
|
|
14
|
+
]);
|
|
7
15
|
function makeId(filePath, name) {
|
|
8
16
|
return `${filePath}::${name}`;
|
|
9
17
|
}
|
|
@@ -15,8 +23,9 @@ export function extractFunctionCalls(node) {
|
|
|
15
23
|
// Skip hooks, they are handled by hooks extractor
|
|
16
24
|
if (name.startsWith("use"))
|
|
17
25
|
continue;
|
|
18
|
-
// Skip console calls, they are noise
|
|
19
|
-
|
|
26
|
+
// Skip console calls and other noise calls, they are noise
|
|
27
|
+
const rootName = name.split(".")[0];
|
|
28
|
+
if (JS_BUILTINS.has(rootName))
|
|
20
29
|
continue;
|
|
21
30
|
names.push(name);
|
|
22
31
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SyntaxKind } from "ts-morph";
|
|
2
2
|
import { detectFunctionDirective } from "../directives.js";
|
|
3
3
|
import { extractParams, extractBareTypeNames, extractReferencedInterfaces, } from "../typeUtils.js";
|
|
4
|
+
import { extractFunctionCalls } from "./functions.js";
|
|
4
5
|
function makeId(filePath, name) {
|
|
5
6
|
return `${filePath}::${name}`;
|
|
6
7
|
}
|
|
@@ -58,6 +59,7 @@ export function extractHooks(file, fileDirective = null) {
|
|
|
58
59
|
if (!/^use[A-Z]/.test(name))
|
|
59
60
|
continue;
|
|
60
61
|
const dependencies = extractDependencies(fn);
|
|
62
|
+
const calls = extractFunctionCalls(fn);
|
|
61
63
|
const returnType = extractReturnType(fn);
|
|
62
64
|
const isAsync = fn.isAsync();
|
|
63
65
|
const contextRefs = extractContextRefs(fn);
|
|
@@ -75,6 +77,7 @@ export function extractHooks(file, fileDirective = null) {
|
|
|
75
77
|
rawCode: fn.getText(),
|
|
76
78
|
metadata: {
|
|
77
79
|
dependencies,
|
|
80
|
+
calls,
|
|
78
81
|
contextRefs,
|
|
79
82
|
returnType,
|
|
80
83
|
parameters: typedParams,
|
|
@@ -98,6 +101,7 @@ export function extractHooks(file, fileDirective = null) {
|
|
|
98
101
|
if (!isArrow)
|
|
99
102
|
continue;
|
|
100
103
|
const dependencies = extractDependencies(initializer);
|
|
104
|
+
const calls = extractFunctionCalls(initializer);
|
|
101
105
|
const returnType = extractReturnType(initializer);
|
|
102
106
|
const isAsync = initializer.asKind(SyntaxKind.ArrowFunction)?.isAsync() ?? false;
|
|
103
107
|
const contextRefs = extractContextRefs(initializer);
|
|
@@ -115,6 +119,7 @@ export function extractHooks(file, fileDirective = null) {
|
|
|
115
119
|
rawCode: variable.getText(),
|
|
116
120
|
metadata: {
|
|
117
121
|
dependencies,
|
|
122
|
+
calls,
|
|
118
123
|
contextRefs,
|
|
119
124
|
returnType,
|
|
120
125
|
parameters: typedParams,
|
|
@@ -133,6 +133,46 @@ describe("parseRepo", () => {
|
|
|
133
133
|
expect(hook?.metadata.isAsync).toBe(true);
|
|
134
134
|
deleteFakeRepo(repoPath);
|
|
135
135
|
});
|
|
136
|
+
it("should capture non-hook calls in hook metadata.calls", () => {
|
|
137
|
+
const repoPath = createFakeRepo({
|
|
138
|
+
"src/useItems.ts": `
|
|
139
|
+
export const keys = {
|
|
140
|
+
all: () => ["items"] as const,
|
|
141
|
+
};
|
|
142
|
+
export function useItems() {
|
|
143
|
+
return useQuery({
|
|
144
|
+
queryKey: keys.all(),
|
|
145
|
+
queryFn: () => fetch("/api/items"),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
`,
|
|
149
|
+
});
|
|
150
|
+
const result = parseRepo(repoPath);
|
|
151
|
+
const hook = result.nodes.find((n) => n.name === "useItems");
|
|
152
|
+
expect(hook?.metadata.calls).toBeDefined();
|
|
153
|
+
expect(hook?.metadata.calls).toContain("keys.all");
|
|
154
|
+
// fetch is not a hook and not a JS_BUILTIN, so it passes through
|
|
155
|
+
expect(hook?.metadata.calls).toContain("fetch");
|
|
156
|
+
deleteFakeRepo(repoPath);
|
|
157
|
+
});
|
|
158
|
+
it("should filter JS_BUILTINS calls from hook metadata.calls", () => {
|
|
159
|
+
const repoPath = createFakeRepo({
|
|
160
|
+
"src/useLogger.ts": `
|
|
161
|
+
export function useLogger() {
|
|
162
|
+
const data = JSON.parse(localStorage.getItem("x") ?? "{}");
|
|
163
|
+
console.log(data);
|
|
164
|
+
return data;
|
|
165
|
+
}
|
|
166
|
+
`,
|
|
167
|
+
});
|
|
168
|
+
const result = parseRepo(repoPath);
|
|
169
|
+
const hook = result.nodes.find((n) => n.name === "useLogger");
|
|
170
|
+
expect(hook?.metadata.calls).toBeDefined();
|
|
171
|
+
// JSON.* and console.* should be filtered as JS_BUILTINS
|
|
172
|
+
expect(hook?.metadata.calls).not.toContain("JSON.parse");
|
|
173
|
+
expect(hook?.metadata.calls).not.toContain("console.log");
|
|
174
|
+
deleteFakeRepo(repoPath);
|
|
175
|
+
});
|
|
136
176
|
// ─── Function Detection ────────────────────────────────────────────────────
|
|
137
177
|
it("should detect a regular function", () => {
|
|
138
178
|
const repoPath = createFakeRepo({
|
package/dist/pipeline/index.js
CHANGED
|
@@ -109,8 +109,8 @@ function routesToCodeNodes(routes, repoPath) {
|
|
|
109
109
|
name,
|
|
110
110
|
type: "ROUTE",
|
|
111
111
|
filePath: relativeFilePath,
|
|
112
|
-
startLine:
|
|
113
|
-
endLine:
|
|
112
|
+
startLine: 0,
|
|
113
|
+
endLine: 0,
|
|
114
114
|
parentFile: `file::${relativeFilePath}`,
|
|
115
115
|
metadata: {
|
|
116
116
|
urlPath: route.urlPath,
|
|
@@ -132,8 +132,8 @@ function routesToCodeNodes(routes, repoPath) {
|
|
|
132
132
|
name,
|
|
133
133
|
type: "ROUTE",
|
|
134
134
|
filePath: relativeFilePath,
|
|
135
|
-
startLine:
|
|
136
|
-
endLine:
|
|
135
|
+
startLine: 0,
|
|
136
|
+
endLine: 0,
|
|
137
137
|
parentFile: `file::${relativeFilePath}`,
|
|
138
138
|
metadata: {
|
|
139
139
|
urlPath: route.urlPath,
|
|
@@ -167,8 +167,8 @@ function routesToCodeNodes(routes, repoPath) {
|
|
|
167
167
|
name,
|
|
168
168
|
type: "ROUTE",
|
|
169
169
|
filePath: relativeFilePath,
|
|
170
|
-
startLine:
|
|
171
|
-
endLine:
|
|
170
|
+
startLine: 0,
|
|
171
|
+
endLine: 0,
|
|
172
172
|
parentFile: `file::${relativeFilePath}`,
|
|
173
173
|
metadata: {
|
|
174
174
|
urlPath: route.urlPath,
|
package/dist/types.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export type Language = "javascript" | "typescript" | "python" | "unknown";
|
|
2
|
-
export type Framework = "nextjs" | "react" | "express" | "fastify" | "koa" | "unknown";
|
|
2
|
+
export type Framework = "nextjs" | "react" | "express" | "fastify" | "koa" | "hono" | "elysia" | "bun" | "unknown";
|
|
3
3
|
export type FrontendFramework = "nextjs" | "react";
|
|
4
|
-
export type BackendFramework = "express" | "fastify" | "koa";
|
|
4
|
+
export type BackendFramework = "express" | "fastify" | "koa" | "hono" | "elysia" | "bun";
|
|
5
5
|
export type RouterType = "app" | "pages" | "app+pages" | "react-router" | "none";
|
|
6
6
|
export type ProjectType = "frontend" | "backend" | "fullstack" | "unknown";
|
|
7
7
|
export type StateLibrary = "zustand" | "redux" | "recoil" | "jotai" | "context-only";
|