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.
- package/LICENSE +0 -0
- package/README.md +212 -72
- package/dist/config/index.d.ts +14 -2
- package/dist/config/index.js +77 -2
- package/dist/config/providers/catalog.d.ts +27 -0
- package/dist/config/providers/catalog.js +130 -0
- package/dist/config/providers/file.d.ts +4 -2
- package/dist/config/providers/file.js +198 -32
- package/dist/config/providers/paths.d.ts +2 -0
- package/dist/config/providers/paths.js +4 -0
- package/dist/config/providers/providers.default.d.ts +3 -0
- package/dist/config/providers/providers.default.js +12 -0
- package/dist/config/providers/request.js +21 -9
- package/dist/config/types.d.ts +25 -2
- package/dist/config/types.js +22 -7
- package/dist/config/writer.d.ts +8 -2
- package/dist/config/writer.js +59 -12
- package/dist/filesystem/index.js +6 -0
- package/dist/filesystem/index.test.js +170 -0
- package/dist/filesystem/reactRouterRoutes.d.ts +2 -0
- package/dist/filesystem/reactRouterRoutes.js +243 -0
- package/dist/graph/edges/apiFetchEdges.js +1 -137
- package/dist/graph/edges/helpers/routeMatching.d.ts +8 -0
- package/dist/graph/edges/helpers/routeMatching.js +122 -0
- package/dist/graph/edges/navigationEdges.d.ts +5 -0
- package/dist/graph/edges/navigationEdges.js +339 -0
- package/dist/graph/edges/routeEdge.js +25 -0
- package/dist/graph/index.js +12 -1
- package/dist/graph/index.test.js +263 -8
- package/dist/index.d.ts +6 -1
- package/dist/index.js +5 -1
- package/dist/parser/extractors/objectMethods.js +2 -2
- package/dist/parser/index.test.js +0 -10
- package/dist/pipeline/index.js +26 -0
- package/dist/summarizer/providers/anthropic.d.ts +2 -1
- package/dist/summarizer/providers/anthropic.js +3 -2
- package/dist/summarizer/providers/index.js +19 -35
- package/dist/summarizer/providers/models.d.ts +11 -0
- package/dist/summarizer/providers/models.js +113 -0
- package/dist/summarizer/providers/openai.d.ts +2 -1
- package/dist/summarizer/providers/openai.js +2 -1
- package/dist/summarizer/providers/types.d.ts +1 -6
- package/dist/types.d.ts +3 -2
- package/package.json +1 -1
- package/dist/summarizer/providers/gemini.d.ts +0 -9
- package/dist/summarizer/providers/gemini.js +0 -79
- package/dist/summarizer/providers/ollama.d.ts +0 -9
- package/dist/summarizer/providers/ollama.js +0 -23
- package/dist/summarizer/providers/openRouter.d.ts +0 -9
- package/dist/summarizer/providers/openRouter.js +0 -19
package/dist/config/writer.js
CHANGED
|
@@ -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
|
-
...(
|
|
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
|
|
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
|
-
|
|
133
|
+
apiKey: config.summarization.apiKey, // full key for OSS
|
|
87
134
|
},
|
|
88
135
|
embedding: {
|
|
89
136
|
provider: config.embedding.provider,
|
package/dist/filesystem/index.js
CHANGED
|
@@ -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,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
|
+
}
|