devlensio 0.4.3 → 0.6.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.
Files changed (50) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +212 -72
  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/index.js +6 -0
  19. package/dist/filesystem/index.test.js +170 -0
  20. package/dist/filesystem/reactRouterRoutes.d.ts +2 -0
  21. package/dist/filesystem/reactRouterRoutes.js +243 -0
  22. package/dist/graph/edges/apiFetchEdges.js +1 -137
  23. package/dist/graph/edges/helpers/routeMatching.d.ts +8 -0
  24. package/dist/graph/edges/helpers/routeMatching.js +122 -0
  25. package/dist/graph/edges/navigationEdges.d.ts +5 -0
  26. package/dist/graph/edges/navigationEdges.js +339 -0
  27. package/dist/graph/edges/routeEdge.js +25 -0
  28. package/dist/graph/index.js +12 -1
  29. package/dist/graph/index.test.js +263 -8
  30. package/dist/index.d.ts +6 -1
  31. package/dist/index.js +5 -1
  32. package/dist/parser/extractors/objectMethods.js +2 -2
  33. package/dist/parser/index.test.js +0 -10
  34. package/dist/pipeline/index.js +26 -0
  35. package/dist/summarizer/providers/anthropic.d.ts +2 -1
  36. package/dist/summarizer/providers/anthropic.js +3 -2
  37. package/dist/summarizer/providers/index.js +19 -35
  38. package/dist/summarizer/providers/models.d.ts +11 -0
  39. package/dist/summarizer/providers/models.js +113 -0
  40. package/dist/summarizer/providers/openai.d.ts +2 -1
  41. package/dist/summarizer/providers/openai.js +2 -1
  42. package/dist/summarizer/providers/types.d.ts +1 -6
  43. package/dist/types.d.ts +3 -2
  44. package/package.json +1 -1
  45. package/dist/summarizer/providers/gemini.d.ts +0 -9
  46. package/dist/summarizer/providers/gemini.js +0 -79
  47. package/dist/summarizer/providers/ollama.d.ts +0 -9
  48. package/dist/summarizer/providers/ollama.js +0 -23
  49. package/dist/summarizer/providers/openRouter.d.ts +0 -9
  50. 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,
@@ -2,11 +2,17 @@ import path from "path";
2
2
  import { analyzeAppRouter } from "./appRouter.js";
3
3
  import { analyzePagesRouter } from "./pagesRouter.js";
4
4
  import { analyzeBackendRoutes } from "./backendRoutes.js";
5
+ import { analyzeReactRouterRoutes } from "./reactRouterRoutes.js";
5
6
  export function analyzeFilesystem(repoPath, fingerprint) {
6
7
  // Handle backend frameworks first
7
8
  if (["express", "fastify", "koa"].includes(fingerprint.framework)) {
8
9
  return analyzeBackendRoutes(repoPath);
9
10
  }
11
+ // Handle React Router projects (framework: "react", router: "react-router").
12
+ // Routes are defined in code, so this runs before the Next.js gate below.
13
+ if (fingerprint.router === "react-router") {
14
+ return analyzeReactRouterRoutes(repoPath);
15
+ }
10
16
  // Handle Next.js frontend
11
17
  if (fingerprint.framework !== "nextjs") {
12
18
  return [];
@@ -176,3 +176,173 @@ describe("analyzeFilesystem", () => {
176
176
  deleteFakeRepo(repoPath);
177
177
  });
178
178
  });
179
+ // ─── React Router ───────────────────────────────────────────────────────────────
180
+ describe("analyzeFilesystem — React Router", () => {
181
+ // React Router routes live in code, so these fixtures need real file content
182
+ // (the top-level createFakeRepo writes a placeholder comment instead).
183
+ function createRepoWithFiles(files) {
184
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "devlens-rr-test-"));
185
+ for (const [filePath, content] of Object.entries(files)) {
186
+ const fullPath = path.join(tmpDir, filePath);
187
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
188
+ fs.writeFileSync(fullPath, content);
189
+ }
190
+ return tmpDir;
191
+ }
192
+ const rrFingerprint = makeFingerprint("react", "react-router");
193
+ it("detects a JSX <Route path> route", () => {
194
+ const repoPath = createRepoWithFiles({
195
+ "src/App.tsx": `
196
+ import { Routes, Route } from "react-router-dom";
197
+ export default function App() {
198
+ return (
199
+ <Routes>
200
+ <Route path="/users" element={<Users />} />
201
+ </Routes>
202
+ );
203
+ }
204
+ `,
205
+ });
206
+ const routes = analyzeFilesystem(repoPath, rrFingerprint);
207
+ const route = routes.find((r) => r.urlPath === "/users");
208
+ expect(route).toBeDefined();
209
+ expect(route?.type).toBe("REACT_ROUTER_ROUTE");
210
+ deleteFakeRepo(repoPath);
211
+ });
212
+ it("detects a dynamic JSX <Route path> with params", () => {
213
+ const repoPath = createRepoWithFiles({
214
+ "src/App.tsx": `
215
+ import { Route } from "react-router-dom";
216
+ export default function App() {
217
+ return <Route path="/users/:id" element={<User />} />;
218
+ }
219
+ `,
220
+ });
221
+ const routes = analyzeFilesystem(repoPath, rrFingerprint);
222
+ const route = routes.find((r) => r.urlPath === "/users/:id");
223
+ expect(route).toBeDefined();
224
+ expect(route?.isDynamic).toBe(true);
225
+ expect(route?.params).toContain("id");
226
+ deleteFakeRepo(repoPath);
227
+ });
228
+ it("assembles nested paths from createBrowserRouter children", () => {
229
+ const repoPath = createRepoWithFiles({
230
+ "src/router.tsx": `
231
+ import { createBrowserRouter } from "react-router-dom";
232
+ export const router = createBrowserRouter([
233
+ {
234
+ path: "/dashboard",
235
+ element: <Dashboard />,
236
+ children: [
237
+ { path: "settings", element: <Settings /> },
238
+ ],
239
+ },
240
+ ]);
241
+ `,
242
+ });
243
+ const routes = analyzeFilesystem(repoPath, rrFingerprint);
244
+ expect(routes.find((r) => r.urlPath === "/dashboard")).toBeDefined();
245
+ expect(routes.find((r) => r.urlPath === "/dashboard/settings")).toBeDefined();
246
+ deleteFakeRepo(repoPath);
247
+ });
248
+ it("detects routes from a useRoutes config", () => {
249
+ const repoPath = createRepoWithFiles({
250
+ "src/Routes.tsx": `
251
+ import { useRoutes } from "react-router-dom";
252
+ export function AppRoutes() {
253
+ return useRoutes([
254
+ { path: "/about", element: <About /> },
255
+ ]);
256
+ }
257
+ `,
258
+ });
259
+ const routes = analyzeFilesystem(repoPath, rrFingerprint);
260
+ expect(routes.find((r) => r.urlPath === "/about")).toBeDefined();
261
+ deleteFakeRepo(repoPath);
262
+ });
263
+ it("detects a TanStack createFileRoute", () => {
264
+ const repoPath = createRepoWithFiles({
265
+ "src/routes/dashboard.tsx": `
266
+ import { createFileRoute } from "@tanstack/react-router";
267
+ export const Route = createFileRoute("/dashboard")({
268
+ component: Dashboard,
269
+ });
270
+ `,
271
+ });
272
+ const routes = analyzeFilesystem(repoPath, rrFingerprint);
273
+ expect(routes.find((r) => r.urlPath === "/dashboard")).toBeDefined();
274
+ deleteFakeRepo(repoPath);
275
+ });
276
+ it("converts a splat '*' route into a catch-all", () => {
277
+ const repoPath = createRepoWithFiles({
278
+ "src/App.tsx": `
279
+ import { Route } from "react-router-dom";
280
+ export default function App() {
281
+ return <Route path="/files/*" element={<Files />} />;
282
+ }
283
+ `,
284
+ });
285
+ const routes = analyzeFilesystem(repoPath, rrFingerprint);
286
+ const route = routes.find((r) => r.urlPath === "/files/:splat*");
287
+ expect(route).toBeDefined();
288
+ expect(route?.isCatchAll).toBe(true);
289
+ deleteFakeRepo(repoPath);
290
+ });
291
+ // ─── rendersComponent capture ───────────────────────────────────────────
292
+ it("captures the rendered component from element={<Home/>} (v6 JSX)", () => {
293
+ const repoPath = createRepoWithFiles({
294
+ "src/App.tsx": `
295
+ import { Route } from "react-router-dom";
296
+ export default function App() {
297
+ return <Route path="/" element={<Home />} />;
298
+ }
299
+ `,
300
+ });
301
+ const routes = analyzeFilesystem(repoPath, rrFingerprint);
302
+ const route = routes.find((r) => r.urlPath === "/");
303
+ expect(route?.rendersComponent).toBe("Home");
304
+ deleteFakeRepo(repoPath);
305
+ });
306
+ it("captures the rendered component from a v5 component={About} prop", () => {
307
+ const repoPath = createRepoWithFiles({
308
+ "src/App.tsx": `
309
+ import { Route } from "react-router-dom";
310
+ export default function App() {
311
+ return <Route path="/about" component={About} />;
312
+ }
313
+ `,
314
+ });
315
+ const routes = analyzeFilesystem(repoPath, rrFingerprint);
316
+ const route = routes.find((r) => r.urlPath === "/about");
317
+ expect(route?.rendersComponent).toBe("About");
318
+ deleteFakeRepo(repoPath);
319
+ });
320
+ it("captures the rendered component from a createBrowserRouter object (element)", () => {
321
+ const repoPath = createRepoWithFiles({
322
+ "src/router.tsx": `
323
+ import { createBrowserRouter } from "react-router-dom";
324
+ export const router = createBrowserRouter([
325
+ { path: "/dashboard", element: <Dashboard /> },
326
+ ]);
327
+ `,
328
+ });
329
+ const routes = analyzeFilesystem(repoPath, rrFingerprint);
330
+ const route = routes.find((r) => r.urlPath === "/dashboard");
331
+ expect(route?.rendersComponent).toBe("Dashboard");
332
+ deleteFakeRepo(repoPath);
333
+ });
334
+ it("captures the rendered component from a data-router Component property", () => {
335
+ const repoPath = createRepoWithFiles({
336
+ "src/router.tsx": `
337
+ import { createBrowserRouter } from "react-router-dom";
338
+ export const router = createBrowserRouter([
339
+ { path: "/profile", Component: Profile },
340
+ ]);
341
+ `,
342
+ });
343
+ const routes = analyzeFilesystem(repoPath, rrFingerprint);
344
+ const route = routes.find((r) => r.urlPath === "/profile");
345
+ expect(route?.rendersComponent).toBe("Profile");
346
+ deleteFakeRepo(repoPath);
347
+ });
348
+ });
@@ -0,0 +1,2 @@
1
+ import type { RouteNode } from "../types.js";
2
+ export declare function analyzeReactRouterRoutes(repoPath: string): RouteNode[];
@@ -0,0 +1,243 @@
1
+ // Detects React Router (v5/v6), TanStack Router, and wouter routes, which are
2
+ // defined in CODE (not on the filesystem). Mirrors backendRoutes.ts structure.
3
+ import { Project, SyntaxKind } from "ts-morph";
4
+ import path from "path";
5
+ import fs from "fs";
6
+ const IGNORE_DIRS = [
7
+ "node_modules", "dist", "build",
8
+ ".next", "coverage", ".git",
9
+ ];
10
+ const ROUTER_FACTORY_NAMES = [
11
+ "createBrowserRouter", "createHashRouter", "createMemoryRouter", "useRoutes",
12
+ ];
13
+ function extractParams(urlPath) {
14
+ const matches = urlPath.match(/:([a-zA-Z0-9_]+)/g) || [];
15
+ return matches.map((m) => m.replace(":", ""));
16
+ }
17
+ // Normalizes a route path:
18
+ // - converts React Router splat segment "*" → ":splat*" (so urlPathtoRegex
19
+ // treats it as a catch-all, matching the Next.js ":slug*" convention)
20
+ // - ensures a leading "/"
21
+ // - strips trailing "/" (except root)
22
+ // Returns null for empty / pathless entries.
23
+ function normalizePath(p) {
24
+ if (p === null || p === undefined)
25
+ return null;
26
+ let s = p.trim();
27
+ if (s === "")
28
+ return null;
29
+ s = s.split("/").map(seg => (seg === "*" ? ":splat*" : seg)).join("/");
30
+ if (!s.startsWith("/"))
31
+ s = "/" + s;
32
+ s = s.replace(/\/+$/, "");
33
+ return s === "" ? "/" : s;
34
+ }
35
+ // Joins a parent path with a (possibly relative) child segment.
36
+ function joinPath(parent, seg) {
37
+ if (seg.startsWith("/"))
38
+ return seg; // absolute child
39
+ if (parent === "" || parent === "/")
40
+ return "/" + seg;
41
+ return parent + "/" + seg;
42
+ }
43
+ // Reads a string literal / no-substitution template value, else null.
44
+ function literalString(node) {
45
+ if (!node)
46
+ return null;
47
+ const k = node.getKind();
48
+ if (k === SyntaxKind.StringLiteral || k === SyntaxKind.NoSubstitutionTemplateLiteral) {
49
+ return node.getLiteralText();
50
+ }
51
+ return null;
52
+ }
53
+ // Resolves the component name from a route's element/component value.
54
+ // <Home/> / <Home></Home> → "Home" (element={<Home/>}, element: <Home/>)
55
+ // Home → "Home" (component={Home}, Component: Home)
56
+ // Returns undefined for inline render functions, lazy imports, etc.
57
+ function extractComponentName(valueNode) {
58
+ if (!valueNode)
59
+ return undefined;
60
+ const k = valueNode.getKind();
61
+ if (k === SyntaxKind.JsxSelfClosingElement)
62
+ return valueNode.getTagNameNode().getText();
63
+ if (k === SyntaxKind.JsxElement)
64
+ return valueNode.getOpeningElement().getTagNameNode().getText();
65
+ if (k === SyntaxKind.Identifier)
66
+ return valueNode.getText();
67
+ return undefined;
68
+ }
69
+ // Unwraps a JSX attribute initializer (element={<Home/>}) to its inner expression.
70
+ function unwrapJsxAttr(attr) {
71
+ if (!attr || !attr.getInitializer)
72
+ return null;
73
+ const init = attr.getInitializer();
74
+ if (!init)
75
+ return null;
76
+ return init.getKind() === SyntaxKind.JsxExpression ? init.getExpression() : init;
77
+ }
78
+ // Reads the rendered-component name from a route-config object literal's
79
+ // element / Component / component property (checked in that priority order).
80
+ function objComponentName(obj) {
81
+ for (const propName of ["element", "Component", "component"]) {
82
+ const prop = obj.getProperty(propName);
83
+ if (prop && prop.getInitializer) {
84
+ const name = extractComponentName(prop.getInitializer());
85
+ if (name)
86
+ return name;
87
+ }
88
+ }
89
+ return undefined;
90
+ }
91
+ function pushRoute(out, rawPath, filePath, seen, rendersComponent) {
92
+ const normalized = normalizePath(rawPath);
93
+ if (normalized === null)
94
+ return;
95
+ const key = filePath + "::" + normalized;
96
+ if (seen.has(key))
97
+ return;
98
+ seen.add(key);
99
+ const params = extractParams(normalized);
100
+ out.push({
101
+ type: "REACT_ROUTER_ROUTE",
102
+ urlPath: normalized,
103
+ filePath,
104
+ isDynamic: /[:*]/.test(normalized),
105
+ isCatchAll: normalized.includes("*"),
106
+ isGroupRoute: false,
107
+ params: params.length > 0 ? params : undefined,
108
+ rendersComponent,
109
+ });
110
+ }
111
+ // Recursively walks an array literal of route-config objects
112
+ // (createBrowserRouter([...]) / useRoutes([...]) / nested children).
113
+ function walkRouteObjects(arr, parentPath, filePath, out, seen) {
114
+ for (const el of arr.getElements()) {
115
+ if (el.getKind() !== SyntaxKind.ObjectLiteralExpression)
116
+ continue;
117
+ const pathProp = el.getProperty("path");
118
+ const indexProp = el.getProperty("index");
119
+ let seg = null;
120
+ if (pathProp && pathProp.getInitializer) {
121
+ seg = literalString(pathProp.getInitializer());
122
+ }
123
+ const fullPath = seg !== null ? joinPath(parentPath, seg) : parentPath;
124
+ const rendersComponent = objComponentName(el);
125
+ // A real route node when it has a literal path, or is an index route.
126
+ if (seg !== null) {
127
+ pushRoute(out, fullPath, filePath, seen, rendersComponent);
128
+ }
129
+ else if (indexProp) {
130
+ pushRoute(out, parentPath, filePath, seen, rendersComponent);
131
+ }
132
+ // Recurse into children with fullPath as the new parent.
133
+ const childrenProp = el.getProperty("children");
134
+ const childInit = childrenProp && childrenProp.getInitializer ? childrenProp.getInitializer() : null;
135
+ if (childInit && childInit.getKind() === SyntaxKind.ArrayLiteralExpression) {
136
+ walkRouteObjects(childInit, fullPath, filePath, out, seen);
137
+ }
138
+ }
139
+ }
140
+ function findReactFiles(dir, files = []) {
141
+ let entries;
142
+ try {
143
+ entries = fs.readdirSync(dir, { withFileTypes: true });
144
+ }
145
+ catch {
146
+ return files;
147
+ }
148
+ for (const entry of entries) {
149
+ const fullPath = path.join(dir, entry.name);
150
+ if (entry.isDirectory()) {
151
+ if (IGNORE_DIRS.includes(entry.name))
152
+ continue;
153
+ findReactFiles(fullPath, files);
154
+ }
155
+ else if (entry.isFile()) {
156
+ if (/\.(tsx|jsx|ts|js)$/.test(entry.name))
157
+ files.push(fullPath);
158
+ }
159
+ }
160
+ return files;
161
+ }
162
+ export function analyzeReactRouterRoutes(repoPath) {
163
+ const nodes = [];
164
+ const project = new Project({
165
+ compilerOptions: { allowJs: true, checkJs: false, jsx: 4, strict: false },
166
+ skipAddingFilesFromTsConfig: true,
167
+ });
168
+ for (const filePath of findReactFiles(repoPath)) {
169
+ try {
170
+ project.addSourceFileAtPath(filePath);
171
+ }
172
+ catch {
173
+ continue;
174
+ }
175
+ }
176
+ for (const file of project.getSourceFiles()) {
177
+ const filePath = file.getFilePath();
178
+ const seen = new Set();
179
+ // ── Pattern A: JSX <Route path="..."> / <Route path="..." /> (v5/v6, wouter)
180
+ for (const el of [
181
+ ...file.getDescendantsOfKind(SyntaxKind.JsxOpeningElement),
182
+ ...file.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement),
183
+ ]) {
184
+ const tag = el.getTagNameNode().getText();
185
+ if (tag !== "Route")
186
+ continue;
187
+ const pathAttr = el.getAttribute("path");
188
+ if (!pathAttr || !pathAttr.getInitializer)
189
+ continue;
190
+ const init = pathAttr.getInitializer();
191
+ if (!init)
192
+ continue;
193
+ let valueNode = init;
194
+ if (init.getKind() === SyntaxKind.JsxExpression) {
195
+ valueNode = init.getExpression();
196
+ }
197
+ const raw = literalString(valueNode);
198
+ if (raw === null)
199
+ continue; // computed path — skip
200
+ // element={<Home/>} (v6) or component={Home} / Component={Home} (v5)
201
+ const rendersComponent = extractComponentName(unwrapJsxAttr(el.getAttribute("element"))) ??
202
+ extractComponentName(unwrapJsxAttr(el.getAttribute("component"))) ??
203
+ extractComponentName(unwrapJsxAttr(el.getAttribute("Component")));
204
+ pushRoute(nodes, raw, filePath, seen, rendersComponent);
205
+ }
206
+ // ── Patterns B & C: createBrowserRouter([...]) / useRoutes([...]) (+ nested children)
207
+ // ── Pattern D: TanStack createRoute / createFileRoute / createRootRoute
208
+ for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
209
+ const callee = call.getExpression().getText();
210
+ const calleeLeaf = callee.split(".").pop();
211
+ const args = call.getArguments();
212
+ if (ROUTER_FACTORY_NAMES.includes(calleeLeaf)) {
213
+ if (args.length === 0)
214
+ continue;
215
+ if (args[0].getKind() === SyntaxKind.ArrayLiteralExpression) {
216
+ walkRouteObjects(args[0], "", filePath, nodes, seen);
217
+ }
218
+ continue;
219
+ }
220
+ if (calleeLeaf === "createFileRoute") {
221
+ // createFileRoute('/dashboard')(...) — path is the string arg
222
+ if (args.length > 0)
223
+ pushRoute(nodes, literalString(args[0]), filePath, seen);
224
+ continue;
225
+ }
226
+ if (calleeLeaf === "createRoute") {
227
+ // createRoute({ path: '/x', component: X })
228
+ if (args.length > 0 && args[0].getKind() === SyntaxKind.ObjectLiteralExpression) {
229
+ const pathProp = args[0].getProperty("path");
230
+ if (pathProp && pathProp.getInitializer) {
231
+ pushRoute(nodes, literalString(pathProp.getInitializer()), filePath, seen, objComponentName(args[0]));
232
+ }
233
+ }
234
+ continue;
235
+ }
236
+ if (calleeLeaf === "createRootRoute") {
237
+ pushRoute(nodes, "/", filePath, seen);
238
+ continue;
239
+ }
240
+ }
241
+ }
242
+ return nodes;
243
+ }