devlensio 0.4.4 → 0.6.1

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.
Files changed (47) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +212 -74
  3. package/dist/config/index.d.ts +14 -2
  4. package/dist/config/index.js +77 -2
  5. package/dist/config/providers/catalog.d.ts +27 -0
  6. package/dist/config/providers/catalog.js +130 -0
  7. package/dist/config/providers/file.d.ts +4 -2
  8. package/dist/config/providers/file.js +198 -32
  9. package/dist/config/providers/paths.d.ts +2 -0
  10. package/dist/config/providers/paths.js +4 -0
  11. package/dist/config/providers/providers.default.d.ts +3 -0
  12. package/dist/config/providers/providers.default.js +12 -0
  13. package/dist/config/providers/request.js +21 -9
  14. package/dist/config/types.d.ts +25 -2
  15. package/dist/config/types.js +22 -7
  16. package/dist/config/writer.d.ts +8 -2
  17. package/dist/config/writer.js +59 -12
  18. package/dist/filesystem/backendRoutes.d.ts +2 -2
  19. package/dist/filesystem/backendRoutes.js +317 -18
  20. package/dist/filesystem/index.js +7 -3
  21. package/dist/filesystem/index.test.js +274 -0
  22. package/dist/fingerprint/detectors.js +8 -2
  23. package/dist/fingerprint/index.test.js +54 -0
  24. package/dist/graph/edges/callEdges.js +12 -2
  25. package/dist/index.d.ts +6 -1
  26. package/dist/index.js +5 -1
  27. package/dist/parser/extractors/components.js +3 -0
  28. package/dist/parser/extractors/functions.d.ts +1 -0
  29. package/dist/parser/extractors/functions.js +11 -2
  30. package/dist/parser/extractors/hooks.js +5 -0
  31. package/dist/parser/index.test.js +40 -0
  32. package/dist/summarizer/providers/anthropic.d.ts +2 -1
  33. package/dist/summarizer/providers/anthropic.js +3 -2
  34. package/dist/summarizer/providers/index.js +19 -35
  35. package/dist/summarizer/providers/models.d.ts +11 -0
  36. package/dist/summarizer/providers/models.js +113 -0
  37. package/dist/summarizer/providers/openai.d.ts +2 -1
  38. package/dist/summarizer/providers/openai.js +2 -1
  39. package/dist/summarizer/providers/types.d.ts +1 -6
  40. package/dist/types.d.ts +2 -2
  41. package/package.json +1 -1
  42. package/dist/summarizer/providers/gemini.d.ts +0 -9
  43. package/dist/summarizer/providers/gemini.js +0 -79
  44. package/dist/summarizer/providers/ollama.d.ts +0 -9
  45. package/dist/summarizer/providers/ollama.js +0 -23
  46. package/dist/summarizer/providers/openRouter.d.ts +0 -9
  47. package/dist/summarizer/providers/openRouter.js +0 -19
@@ -1,6 +1,7 @@
1
1
  //! The concept of local config file should only exist in open Source, and in case of deployment the apis for writing this cofig file should never be exposed.
2
2
  import fs from "fs";
3
3
  import { CONFIG_FILE, CONFIG_DIR } from "./providers/file.js";
4
+ import { makeProviderKey } from "./types.js";
4
5
  function readRawFile() {
5
6
  if (!fs.existsSync(CONFIG_FILE))
6
7
  return {};
@@ -12,10 +13,10 @@ function readRawFile() {
12
13
  return {};
13
14
  }
14
15
  }
15
- // atomicWrite
16
+ // atomicWrite
16
17
  // Writes to a temp file first, then renames to the real path.
17
18
  // If the server crashes mid-write, the old config survives intact.
18
- function atomicWrite(filePath, content) {
19
+ export function atomicWrite(filePath, content) {
19
20
  const tmp = `${filePath}.tmp`;
20
21
  fs.writeFileSync(tmp, content, "utf-8");
21
22
  fs.renameSync(tmp, filePath);
@@ -36,17 +37,62 @@ export function writeConfig(partial) {
36
37
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
37
38
  }
38
39
  const existing = readRawFile();
40
+ // ── Multi-provider summarization upsert ──────────────────────────────────
41
+ let summarizationUpdate;
42
+ if (partial.summarization) {
43
+ const s = partial.summarization;
44
+ const protocol = s.provider ?? "openai";
45
+ const providerName = s.providerName ?? protocol;
46
+ const key = makeProviderKey(protocol, providerName);
47
+ // Read existing multi-provider structure (or build from legacy flat format)
48
+ const rawSum = existing.summarization;
49
+ let storage;
50
+ if (rawSum && typeof rawSum === "object" && rawSum.providers && typeof rawSum.providers === "object" && typeof rawSum.active === "string") {
51
+ // Already multi-provider format
52
+ storage = {
53
+ active: rawSum.active,
54
+ providers: rawSum.providers,
55
+ };
56
+ }
57
+ else {
58
+ // Legacy flat format or first save — create fresh multi-provider storage
59
+ storage = { active: key, providers: {} };
60
+ // If there's a legacy flat config, preserve it as the first entry
61
+ if (rawSum && typeof rawSum.provider === "string") {
62
+ const legacyKey = makeProviderKey(rawSum.provider, rawSum.providerName ?? rawSum.provider);
63
+ storage.providers[legacyKey] = {
64
+ provider: rawSum.provider,
65
+ providerName: rawSum.providerName ?? rawSum.provider,
66
+ model: rawSum.model ?? "",
67
+ apiKey: rawSum.apiKey,
68
+ baseUrl: rawSum.baseUrl,
69
+ batchSize: rawSum.batchSize ?? 50,
70
+ };
71
+ if (legacyKey !== key) {
72
+ storage.active = key; // new entry becomes active
73
+ }
74
+ }
75
+ }
76
+ // Upsert the incoming entry
77
+ const existingEntry = storage.providers[key];
78
+ storage.providers[key] = {
79
+ provider: s.provider ?? existingEntry?.provider ?? "openai",
80
+ providerName: providerName,
81
+ model: s.model ?? existingEntry?.model ?? "",
82
+ apiKey: s.apiKey ?? existingEntry?.apiKey,
83
+ baseUrl: s.baseUrl ?? existingEntry?.baseUrl,
84
+ batchSize: s.batchSize ?? existingEntry?.batchSize ?? 50,
85
+ };
86
+ storage.active = key;
87
+ summarizationUpdate = storage;
88
+ }
39
89
  const updated = {
40
90
  ...existing,
41
- // Only merge blocks that the user actually touched
42
91
  ...(partial.deploymentMode && {
43
92
  deploymentMode: partial.deploymentMode,
44
93
  }),
45
- ...(partial.summarization && {
46
- summarization: {
47
- ...existing.summarization,
48
- ...partial.summarization,
49
- },
94
+ ...(summarizationUpdate && {
95
+ summarization: summarizationUpdate,
50
96
  }),
51
97
  ...(partial.embedding && {
52
98
  embedding: {
@@ -55,10 +101,9 @@ export function writeConfig(partial) {
55
101
  },
56
102
  }),
57
103
  // neo4j: if user sent it, merge. If user sent null explicitly, delete it.
58
- // undefined means "don't touch it"
59
104
  ...(partial.neo4j !== undefined && {
60
105
  neo4j: partial.neo4j === null
61
- ? undefined // user explicitly removed Neo4j config
106
+ ? undefined
62
107
  : {
63
108
  ...existing.neo4j,
64
109
  ...partial.neo4j,
@@ -73,17 +118,19 @@ function maskKey(key) {
73
118
  // Show last 3 characters only — enough to identify which key is set
74
119
  return `...${key.slice(-3)}`;
75
120
  }
76
- // maskConfig
121
+ // maskConfig
77
122
  // Public — called by GET /api/config handler.
123
+ // DevLens is OSS/local: apiKeys are returned in full (the user owns their keys).
78
124
  export function maskConfig(config) {
79
125
  return {
80
126
  deploymentMode: config.deploymentMode,
81
127
  summarization: {
82
128
  provider: config.summarization.provider,
129
+ providerName: config.summarization.providerName,
83
130
  model: config.summarization.model,
84
131
  baseUrl: config.summarization.baseUrl,
85
132
  batchSize: config.summarization.batchSize,
86
- apiKeyHint: maskKey(config.summarization.apiKey),
133
+ apiKey: config.summarization.apiKey, // full key for OSS
87
134
  },
88
135
  embedding: {
89
136
  provider: config.embedding.provider,
@@ -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.includes("from 'express'") ||
26
- fileContent.includes('from "express"') ||
27
- fileContent.includes("require('express')") ||
28
- fileContent.includes('require("express")'))
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.includes("from 'fastify'") ||
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.includes("from 'koa'") ||
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
- if (detectFileFramework(content)) {
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
- const framework = detectFileFramework(content);
87
- if (!framework)
88
- continue;
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,
@@ -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
- if (["express", "fastify", "koa"].includes(fingerprint.framework)) {
9
- return analyzeBackendRoutes(repoPath);
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.