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 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, 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
- - **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).
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
+ }
@@ -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;