devlensio 0.4.2 → 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 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] Filesystem scan extract routes (Next.js app/pages, Express, Fastify, Koa)
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
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
- - **Edge types:** `CALLS`, `IMPORTS`, `READS_FROM`, `WRITES_TO`, `PROP_PASS`, `EMITS`, `LISTENS`, `WRAPPED_BY`, `GUARDS`, `HANDLES`, `TESTS`, `USES`.
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
@@ -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
+ }