devlensio 0.4.3 → 0.4.4
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/README.md +6 -4
- 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/parser/extractors/objectMethods.js +2 -2
- package/dist/parser/index.test.js +0 -10
- package/dist/pipeline/index.js +26 -0
- package/dist/types.d.ts +3 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,10 +26,11 @@ Requires Node 18+ (or Bun). An LLM provider key is needed only for summarization
|
|
|
26
26
|
Repo path
|
|
27
27
|
│
|
|
28
28
|
[1] Fingerprint detect language, framework, router, state manager, data layer, databases
|
|
29
|
-
[2]
|
|
29
|
+
[2] Route detection extract routes (Next.js app/pages, React Router / TanStack / wouter,
|
|
30
|
+
Express, Fastify, Koa)
|
|
30
31
|
[3] Parse (ts-morph) walk every .ts/.tsx/.js/.jsx → nodes (typed params, return types, prop types)
|
|
31
32
|
[4] Edge detection many detectors → CALLS, IMPORTS, READS_FROM, WRITES_TO, PROP_PASS, EMITS,
|
|
32
|
-
LISTENS, WRAPPED_BY, GUARDS, HANDLES, TESTS, USES, NEXTJS_API_CALL
|
|
33
|
+
LISTENS, WRAPPED_BY, GUARDS, HANDLES, TESTS, USES, NEXTJS_API_CALL, NAVIGATES_TO
|
|
33
34
|
[5] Scoring multi-pass importance scoring + noise filtering (no AI)
|
|
34
35
|
[6] Clustering cohesive cluster assignment
|
|
35
36
|
[7] Summarize (optional) topologically-ordered LLM summaries, checkpoint/resume, MapReduce
|
|
@@ -73,7 +74,8 @@ Also exported: all core types (`CodeNode`, `CodeEdge`, `NodeType`, `EdgeType`,
|
|
|
73
74
|
### Node & edge types
|
|
74
75
|
|
|
75
76
|
- **Node types:** `COMPONENT`, `HOOK`, `FUNCTION`, `STATE_STORE`, `UTILITY`, `FILE`, `ROUTE`, `TEST`, `STORY`, `THIRD_PARTY` (+ internal `GHOST`).
|
|
76
|
-
- **
|
|
77
|
+
- **Route subtypes** (on `ROUTE` nodes via `metadata.routeNodeType`): `PAGE`, `LAYOUT`, `API_ROUTE`, `LOADING`, `ERROR`, `MIDDLEWARE`, `NOT_FOUND` (Next.js), and `REACT_ROUTER_ROUTE` (React Router / TanStack / wouter, defined in code rather than on the filesystem). Navigation targets that can't be resolved to a known route — dynamic paths, unmatched paths, or external URLs — become `[route]::…` ROUTE nodes flagged `isUnresolved` / `isExternal`.
|
|
78
|
+
- **Edge types:** `CALLS`, `IMPORTS`, `READS_FROM`, `WRITES_TO`, `PROP_PASS`, `EMITS`, `LISTENS`, `WRAPPED_BY`, `GUARDS`, `HANDLES`, `TESTS`, `USES`, `NEXTJS_API_CALL` (frontend `fetch`/`axios`/`useSWR` call site → Next.js API route), `NAVIGATES_TO` (client-side navigation — `navigate()`, `router.push/replace`, `history.*`, `window.location.*`, `<Link>`/`<NavLink>` — from a component/hook/function → the ROUTE it targets).
|
|
77
79
|
- Each node carries an importance score and (after summarization) a technical summary, a business summary, and a security assessment (`none|low|medium|high` + notes).
|
|
78
80
|
|
|
79
81
|
---
|
|
@@ -98,7 +100,7 @@ Graphs and config are stored under `~/.devlens` and shared with all DevLens tool
|
|
|
98
100
|
```
|
|
99
101
|
src/
|
|
100
102
|
├── fingerprint/ # detect framework, language, router, state, data layer, databases
|
|
101
|
-
├── filesystem/ # route detection (Next.js app/pages, Express, Fastify, Koa)
|
|
103
|
+
├── filesystem/ # route detection (Next.js app/pages, React Router/TanStack/wouter, Express, Fastify, Koa)
|
|
102
104
|
├── parser/ # ts-morph AST extraction → nodes
|
|
103
105
|
├── graph/ # edge detectors, traversal API, third-party libs, lookup maps
|
|
104
106
|
├── scoring/ # multi-pass importance scoring + noise filtering
|
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
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// This file detects the backend routes of the NEXTjs only
|
|
2
2
|
import { Project, SyntaxKind } from "ts-morph";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { extractUrlFromArg, normalizeUrl, urlPathtoRegex } from "./helpers/routeMatching.js";
|
|
4
5
|
const CALLER_CONFIG = {
|
|
5
6
|
"fetch": { inferMethod: "from-options", defaultMethod: "GET" },
|
|
6
7
|
"axios.get": { inferMethod: "fixed", method: "GET" },
|
|
@@ -20,16 +21,6 @@ const CALLER_CONFIG = {
|
|
|
20
21
|
"useSWR": { inferMethod: "fixed", method: "GET" },
|
|
21
22
|
"useSWRMutation": { inferMethod: "fixed", method: "UNKNOWN" },
|
|
22
23
|
};
|
|
23
|
-
function urlPathtoRegex(urlPath) {
|
|
24
|
-
const pattern = urlPath.split("/").map(segment => {
|
|
25
|
-
if (segment.startsWith(":") && segment.endsWith("*"))
|
|
26
|
-
return ".+"; // catch-all :slug*
|
|
27
|
-
if (segment.startsWith(":"))
|
|
28
|
-
return "[^/]+"; // dynamic :id
|
|
29
|
-
return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // escape static segments
|
|
30
|
-
}).join("\\/");
|
|
31
|
-
return new RegExp(`^${pattern}$`);
|
|
32
|
-
}
|
|
33
24
|
// Groups Next.js API ROUTE nodes by HTTP method for efficient lookup.
|
|
34
25
|
// Each route node carries a concrete method, so it is indexed under that one
|
|
35
26
|
// method key. Callers with an UNKNOWN method (e.g. useSWRMutation) are handled
|
|
@@ -54,97 +45,6 @@ function buildRouteIndex(nodes) {
|
|
|
54
45
|
}
|
|
55
46
|
return index;
|
|
56
47
|
}
|
|
57
|
-
/*
|
|
58
|
-
Extracts string value from an initializer node.
|
|
59
|
-
Handles 3 cases:
|
|
60
|
-
'string literal' → returns value directly
|
|
61
|
-
`no-substitution template` → returns value directly
|
|
62
|
-
`template ${expr} literal` → preserves ${...} as-is so normalizeUrl handles it later
|
|
63
|
-
*/
|
|
64
|
-
function resolveVariableValue(initializer) {
|
|
65
|
-
const kind = initializer.getKind();
|
|
66
|
-
if (kind === SyntaxKind.StringLiteral) {
|
|
67
|
-
return initializer.getLiteralText();
|
|
68
|
-
}
|
|
69
|
-
if (kind === SyntaxKind.NoSubstitutionTemplateLiteral) {
|
|
70
|
-
return initializer.getLiteralText();
|
|
71
|
-
}
|
|
72
|
-
if (kind === SyntaxKind.TemplateExpression) {
|
|
73
|
-
const head = initializer.getHead().getLiteralText();
|
|
74
|
-
const spans = initializer.getTemplateSpans().map((span) => `\${${span.getExpression().getText()}}${span.getLiteral().getLiteralText()}`);
|
|
75
|
-
return head + spans.join("");
|
|
76
|
-
}
|
|
77
|
-
return null; // object, function, computed — can't resolve
|
|
78
|
-
}
|
|
79
|
-
/*
|
|
80
|
-
Resolves a variable name to its string value.
|
|
81
|
-
Strategy:
|
|
82
|
-
1. Look in the same file
|
|
83
|
-
2. Walk named imports → find the source file → look there
|
|
84
|
-
Files not yet in the project are added lazily so we don't pre-load everything.
|
|
85
|
-
*/
|
|
86
|
-
function resolveUrlVariable(varName, sourceFile, project) {
|
|
87
|
-
//case 1 -> Variable exists in same file
|
|
88
|
-
const localDecl = sourceFile.getVariableDeclaration(varName);
|
|
89
|
-
if (localDecl) {
|
|
90
|
-
const init = localDecl.getInitializer();
|
|
91
|
-
if (init)
|
|
92
|
-
return resolveVariableValue(init);
|
|
93
|
-
}
|
|
94
|
-
//case 2 -> named imports
|
|
95
|
-
for (const importDecl of sourceFile.getImportDeclarations()) {
|
|
96
|
-
const match = importDecl.getNamedImports().find(n => n.getName() === varName);
|
|
97
|
-
if (!match)
|
|
98
|
-
continue;
|
|
99
|
-
// Try to get the source file — it may not be in the project yet
|
|
100
|
-
let importedFile = importDecl.getModuleSpecifierSourceFile();
|
|
101
|
-
if (!importedFile) {
|
|
102
|
-
// Lazily add the file to the project
|
|
103
|
-
const specifier = importDecl.getModuleSpecifierValue();
|
|
104
|
-
const currentDir = path.dirname(sourceFile.getFilePath());
|
|
105
|
-
const base = path.resolve(currentDir, specifier);
|
|
106
|
-
for (const ext of [".ts", ".tsx", ".js", ".jsx"]) {
|
|
107
|
-
try {
|
|
108
|
-
importedFile = project.addSourceFileAtPath(base + ext);
|
|
109
|
-
break;
|
|
110
|
-
}
|
|
111
|
-
catch {
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
if (!importedFile)
|
|
117
|
-
continue;
|
|
118
|
-
const importedDecl = importedFile.getVariableDeclaration(varName);
|
|
119
|
-
if (importedDecl) {
|
|
120
|
-
const init = importedDecl.getInitializer();
|
|
121
|
-
if (init)
|
|
122
|
-
return resolveVariableValue(init);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
return null;
|
|
126
|
-
}
|
|
127
|
-
// URL Normalization
|
|
128
|
-
// Converts a raw URL string from the call site into a normalized form
|
|
129
|
-
// that can be compared against route index entries.
|
|
130
|
-
// Examples:
|
|
131
|
-
// `/api/users/${id}` → /api/users/:dynamic
|
|
132
|
-
// `/api/users/${org}/${id}` → /api/users/:dynamic/:dynamic
|
|
133
|
-
// /api/users?foo=bar → /api/users
|
|
134
|
-
// /api/users/ → /api/users
|
|
135
|
-
function normalizeUrl(rawUrl) {
|
|
136
|
-
// Skip external URLs
|
|
137
|
-
if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://"))
|
|
138
|
-
return null;
|
|
139
|
-
// Must start with /
|
|
140
|
-
if (!rawUrl.startsWith("/"))
|
|
141
|
-
return null;
|
|
142
|
-
return rawUrl
|
|
143
|
-
.split("?")[0] // strip query string
|
|
144
|
-
.replace(/\$\{[^}]+\}/g, ":dynamic") // ${anything} → :dynamic
|
|
145
|
-
.replace(/\/+$/, "") // strip trailing slash
|
|
146
|
-
|| "/"; // fallback to root if empty
|
|
147
|
-
}
|
|
148
48
|
// Route Matching
|
|
149
49
|
// Matches a normalized URL + HTTP method against the route index.
|
|
150
50
|
// Rules:
|
|
@@ -167,42 +67,6 @@ function matchRouteEntries(normalizedUrl, method, routeIndex) {
|
|
|
167
67
|
// e.g. "/api/users/:dynamic" matches the regex for route "/api/users/:id".
|
|
168
68
|
return candidates.filter(e => e.isDynamic && e.urlRegex.test(normalizedUrl));
|
|
169
69
|
}
|
|
170
|
-
// URL Argument Extraction
|
|
171
|
-
// Shared helper — reconstructs a template literal preserving ${...} as-is
|
|
172
|
-
// so normalizeUrl can replace them with :dynamic later.
|
|
173
|
-
// Same logic as resolveVariableValue's TemplateExpression branch.
|
|
174
|
-
function reconstructTemplate(templateExpr) {
|
|
175
|
-
const head = templateExpr.getHead().getLiteralText();
|
|
176
|
-
const spans = templateExpr.getTemplateSpans().map((span) => `\${${span.getExpression().getText()}}${span.getLiteral().getLiteralText()}`);
|
|
177
|
-
return head + spans.join("");
|
|
178
|
-
}
|
|
179
|
-
// Extracts a URL string from a call argument.
|
|
180
|
-
// Handles 4 cases:
|
|
181
|
-
// '/api/users' → string literal → return directly
|
|
182
|
-
// `/api/users` → no-sub template → return directly
|
|
183
|
-
// `/api/users/${id}` → template expression → preserve ${...}
|
|
184
|
-
// API_URL → identifier → resolve via resolveUrlVariable
|
|
185
|
-
// Returns null if the arg is an object / array / expression we can't resolve.
|
|
186
|
-
function extractUrlFromArg(arg, sourceFile, project) {
|
|
187
|
-
const kind = arg.getKind();
|
|
188
|
-
if (kind === SyntaxKind.StringLiteral ||
|
|
189
|
-
kind === SyntaxKind.NoSubstitutionTemplateLiteral) {
|
|
190
|
-
const url = arg.getLiteralText();
|
|
191
|
-
return { rawUrl: url, resolvedUrl: url };
|
|
192
|
-
}
|
|
193
|
-
if (kind === SyntaxKind.TemplateExpression) {
|
|
194
|
-
const url = reconstructTemplate(arg);
|
|
195
|
-
return { rawUrl: url, resolvedUrl: url };
|
|
196
|
-
}
|
|
197
|
-
if (kind === SyntaxKind.Identifier) {
|
|
198
|
-
const varName = arg.getText();
|
|
199
|
-
const resolved = resolveUrlVariable(varName, sourceFile, project);
|
|
200
|
-
if (!resolved)
|
|
201
|
-
return null;
|
|
202
|
-
return { rawUrl: varName, resolvedUrl: resolved };
|
|
203
|
-
}
|
|
204
|
-
return null;
|
|
205
|
-
}
|
|
206
70
|
// Scans EVERY call expression in a source file exactly once.
|
|
207
71
|
// For each call matching CALLER_CONFIG:
|
|
208
72
|
// - infers the HTTP method
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Project, SourceFile } from "ts-morph";
|
|
2
|
+
export declare function urlPathtoRegex(urlPath: string): RegExp;
|
|
3
|
+
export declare function normalizeUrl(rawUrl: string): string | null;
|
|
4
|
+
export declare function reconstructTemplate(templateExpr: any): string;
|
|
5
|
+
export declare function extractUrlFromArg(arg: any, sourceFile: SourceFile, project: Project): {
|
|
6
|
+
rawUrl: string;
|
|
7
|
+
resolvedUrl: string;
|
|
8
|
+
} | null;
|