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
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Shared route/URL matching helpers used by both the Next.js API-call detector
|
|
2
|
+
// (apiFetchEdges.ts) and the navigation detector (navigationEdges.ts).
|
|
3
|
+
import { SyntaxKind } from "ts-morph";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
// Converts a route urlPath into a matching RegExp.
|
|
6
|
+
// :id → [^/]+ (dynamic segment)
|
|
7
|
+
// :slug* → .+ (catch-all)
|
|
8
|
+
// static → escaped literally
|
|
9
|
+
export function urlPathtoRegex(urlPath) {
|
|
10
|
+
const pattern = urlPath.split("/").map(segment => {
|
|
11
|
+
if (segment.startsWith(":") && segment.endsWith("*"))
|
|
12
|
+
return ".+"; // catch-all :slug*
|
|
13
|
+
if (segment.startsWith(":"))
|
|
14
|
+
return "[^/]+"; // dynamic :id
|
|
15
|
+
return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // escape static segments
|
|
16
|
+
}).join("\\/");
|
|
17
|
+
return new RegExp(`^${pattern}$`);
|
|
18
|
+
}
|
|
19
|
+
// Converts a raw URL string from a call site into a normalized form comparable
|
|
20
|
+
// against route index entries.
|
|
21
|
+
// `/api/users/${id}` → /api/users/:dynamic
|
|
22
|
+
// /api/users?foo=bar → /api/users
|
|
23
|
+
// /api/users/ → /api/users
|
|
24
|
+
// Returns null for external (http/https) or non-"/"-prefixed URLs.
|
|
25
|
+
export function normalizeUrl(rawUrl) {
|
|
26
|
+
if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://"))
|
|
27
|
+
return null;
|
|
28
|
+
if (!rawUrl.startsWith("/"))
|
|
29
|
+
return null;
|
|
30
|
+
return rawUrl
|
|
31
|
+
.split("?")[0] // strip query string
|
|
32
|
+
.replace(/\$\{[^}]+\}/g, ":dynamic") // ${anything} → :dynamic
|
|
33
|
+
.replace(/\/+$/, "") // strip trailing slash
|
|
34
|
+
|| "/"; // fallback to root if empty
|
|
35
|
+
}
|
|
36
|
+
// Reconstructs a template literal preserving ${...} as-is so normalizeUrl can
|
|
37
|
+
// replace them with :dynamic later.
|
|
38
|
+
export function reconstructTemplate(templateExpr) {
|
|
39
|
+
const head = templateExpr.getHead().getLiteralText();
|
|
40
|
+
const spans = templateExpr.getTemplateSpans().map((span) => `\${${span.getExpression().getText()}}${span.getLiteral().getLiteralText()}`);
|
|
41
|
+
return head + spans.join("");
|
|
42
|
+
}
|
|
43
|
+
// Extracts a string value from an initializer node (literal / template / etc).
|
|
44
|
+
function resolveVariableValue(initializer) {
|
|
45
|
+
const kind = initializer.getKind();
|
|
46
|
+
if (kind === SyntaxKind.StringLiteral) {
|
|
47
|
+
return initializer.getLiteralText();
|
|
48
|
+
}
|
|
49
|
+
if (kind === SyntaxKind.NoSubstitutionTemplateLiteral) {
|
|
50
|
+
return initializer.getLiteralText();
|
|
51
|
+
}
|
|
52
|
+
if (kind === SyntaxKind.TemplateExpression) {
|
|
53
|
+
const head = initializer.getHead().getLiteralText();
|
|
54
|
+
const spans = initializer.getTemplateSpans().map((span) => `\${${span.getExpression().getText()}}${span.getLiteral().getLiteralText()}`);
|
|
55
|
+
return head + spans.join("");
|
|
56
|
+
}
|
|
57
|
+
return null; // object, function, computed — can't resolve
|
|
58
|
+
}
|
|
59
|
+
// Resolves a variable name to its string value (same file first, then named imports).
|
|
60
|
+
function resolveUrlVariable(varName, sourceFile, project) {
|
|
61
|
+
const localDecl = sourceFile.getVariableDeclaration(varName);
|
|
62
|
+
if (localDecl) {
|
|
63
|
+
const init = localDecl.getInitializer();
|
|
64
|
+
if (init)
|
|
65
|
+
return resolveVariableValue(init);
|
|
66
|
+
}
|
|
67
|
+
for (const importDecl of sourceFile.getImportDeclarations()) {
|
|
68
|
+
const match = importDecl.getNamedImports().find(n => n.getName() === varName);
|
|
69
|
+
if (!match)
|
|
70
|
+
continue;
|
|
71
|
+
let importedFile = importDecl.getModuleSpecifierSourceFile();
|
|
72
|
+
if (!importedFile) {
|
|
73
|
+
const specifier = importDecl.getModuleSpecifierValue();
|
|
74
|
+
const currentDir = path.dirname(sourceFile.getFilePath());
|
|
75
|
+
const base = path.resolve(currentDir, specifier);
|
|
76
|
+
for (const ext of [".ts", ".tsx", ".js", ".jsx"]) {
|
|
77
|
+
try {
|
|
78
|
+
importedFile = project.addSourceFileAtPath(base + ext);
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (!importedFile)
|
|
87
|
+
continue;
|
|
88
|
+
const importedDecl = importedFile.getVariableDeclaration(varName);
|
|
89
|
+
if (importedDecl) {
|
|
90
|
+
const init = importedDecl.getInitializer();
|
|
91
|
+
if (init)
|
|
92
|
+
return resolveVariableValue(init);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
// Extracts a URL string from a call/attr argument node.
|
|
98
|
+
// '/api/users' → string literal → return directly
|
|
99
|
+
// `/api/users` → no-sub template → return directly
|
|
100
|
+
// `/api/users/${id}` → template expression → preserve ${...}
|
|
101
|
+
// API_URL → identifier → resolve via resolveUrlVariable
|
|
102
|
+
// Returns null for objects/arrays/expressions we can't resolve.
|
|
103
|
+
export function extractUrlFromArg(arg, sourceFile, project) {
|
|
104
|
+
const kind = arg.getKind();
|
|
105
|
+
if (kind === SyntaxKind.StringLiteral ||
|
|
106
|
+
kind === SyntaxKind.NoSubstitutionTemplateLiteral) {
|
|
107
|
+
const url = arg.getLiteralText();
|
|
108
|
+
return { rawUrl: url, resolvedUrl: url };
|
|
109
|
+
}
|
|
110
|
+
if (kind === SyntaxKind.TemplateExpression) {
|
|
111
|
+
const url = reconstructTemplate(arg);
|
|
112
|
+
return { rawUrl: url, resolvedUrl: url };
|
|
113
|
+
}
|
|
114
|
+
if (kind === SyntaxKind.Identifier) {
|
|
115
|
+
const varName = arg.getText();
|
|
116
|
+
const resolved = resolveUrlVariable(varName, sourceFile, project);
|
|
117
|
+
if (!resolved)
|
|
118
|
+
return null;
|
|
119
|
+
return { rawUrl: varName, resolvedUrl: resolved };
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
// Detects client-side navigation and emits NAVIGATES_TO edges from
|
|
2
|
+
// COMPONENT/HOOK/FUNCTION nodes to ROUTE nodes. Covers React Router (v5/v6),
|
|
3
|
+
// TanStack Router, wouter, Next.js (next/link, next/navigation, next/router),
|
|
4
|
+
// and native window.location / window.history navigation.
|
|
5
|
+
//
|
|
6
|
+
// Structure mirrors apiFetchEdges.ts: one ts-morph Project, each candidate
|
|
7
|
+
// file scanned once, calls attributed to the innermost owning node by line
|
|
8
|
+
// range, paths normalized + matched against a route index, edges deduped.
|
|
9
|
+
import { Project, SyntaxKind } from "ts-morph";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { extractUrlFromArg, normalizeUrl, urlPathtoRegex } from "./helpers/routeMatching.js";
|
|
12
|
+
// Recognized routing libraries (import-source gating).
|
|
13
|
+
const RR_LIBS = ["react-router-dom", "react-router", "wouter", "@tanstack/react-router"];
|
|
14
|
+
const NEXT_LINK_LIBS = ["next/link"];
|
|
15
|
+
const NEXT_ROUTER_LIBS = ["next/navigation", "next/router"];
|
|
16
|
+
// Build the route index from navigable ROUTE nodes (NOT API routes).
|
|
17
|
+
function buildNavRouteIndex(nodes) {
|
|
18
|
+
const staticByPath = new Map();
|
|
19
|
+
const dynamic = [];
|
|
20
|
+
const routeNodes = nodes.filter(n => n.type === "ROUTE" &&
|
|
21
|
+
(n.metadata.routeNodeType === "PAGE" || n.metadata.routeNodeType === "REACT_ROUTER_ROUTE") &&
|
|
22
|
+
typeof n.metadata.urlPath === "string");
|
|
23
|
+
for (const routeNode of routeNodes) {
|
|
24
|
+
const urlPath = routeNode.metadata.urlPath;
|
|
25
|
+
const isDynamic = Boolean(routeNode.metadata.isDynamic);
|
|
26
|
+
const entry = { routeNode, urlPath, isDynamic, urlRegex: urlPathtoRegex(urlPath) };
|
|
27
|
+
if (isDynamic)
|
|
28
|
+
dynamic.push(entry);
|
|
29
|
+
else if (!staticByPath.has(urlPath))
|
|
30
|
+
staticByPath.set(urlPath, entry);
|
|
31
|
+
}
|
|
32
|
+
return { staticByPath, dynamic };
|
|
33
|
+
}
|
|
34
|
+
// Two-pass match: static exact first, then dynamic regex.
|
|
35
|
+
function matchNavRoute(normalizedUrl, index) {
|
|
36
|
+
const exact = index.staticByPath.get(normalizedUrl);
|
|
37
|
+
if (exact)
|
|
38
|
+
return exact;
|
|
39
|
+
for (const e of index.dynamic) {
|
|
40
|
+
if (e.urlRegex.test(normalizedUrl))
|
|
41
|
+
return e;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
// Per-file map: local imported name → module specifier.
|
|
46
|
+
function buildImportSourceMap(sourceFile) {
|
|
47
|
+
const map = new Map();
|
|
48
|
+
for (const importDecl of sourceFile.getImportDeclarations()) {
|
|
49
|
+
const spec = importDecl.getModuleSpecifierValue();
|
|
50
|
+
for (const named of importDecl.getNamedImports()) {
|
|
51
|
+
const local = named.getAliasNode()?.getText() ?? named.getName();
|
|
52
|
+
map.set(local, spec);
|
|
53
|
+
}
|
|
54
|
+
const def = importDecl.getDefaultImport();
|
|
55
|
+
if (def)
|
|
56
|
+
map.set(def.getText(), spec);
|
|
57
|
+
const ns = importDecl.getNamespaceImport();
|
|
58
|
+
if (ns)
|
|
59
|
+
map.set(ns.getText(), spec);
|
|
60
|
+
}
|
|
61
|
+
return map;
|
|
62
|
+
}
|
|
63
|
+
// Track local vars bound to navigate fn / router obj / history obj — only when
|
|
64
|
+
// the producing hook is imported from a recognized routing library.
|
|
65
|
+
function buildNavBindingMaps(sourceFile, importMap) {
|
|
66
|
+
const navigateVars = new Set(); // useNavigate (RR / TanStack)
|
|
67
|
+
const routerVars = new Set(); // useRouter (Next.js)
|
|
68
|
+
const historyVars = new Set(); // useHistory (RR v5)
|
|
69
|
+
// Use descendants, not getVariableDeclarations() — the latter only returns
|
|
70
|
+
// top-level declarations, but `const navigate = useNavigate()` lives inside
|
|
71
|
+
// the component/hook body.
|
|
72
|
+
for (const decl of sourceFile.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
|
|
73
|
+
const init = decl.getInitializer();
|
|
74
|
+
if (!init || init.getKind() !== SyntaxKind.CallExpression)
|
|
75
|
+
continue;
|
|
76
|
+
const callee = init.getExpression().getText();
|
|
77
|
+
const src = importMap.get(callee);
|
|
78
|
+
if (!src)
|
|
79
|
+
continue;
|
|
80
|
+
const nameNode = decl.getNameNode();
|
|
81
|
+
if (nameNode.getKind() !== SyntaxKind.Identifier)
|
|
82
|
+
continue; // skip destructuring
|
|
83
|
+
const name = nameNode.getText();
|
|
84
|
+
if (callee === "useNavigate" && RR_LIBS.includes(src))
|
|
85
|
+
navigateVars.add(name);
|
|
86
|
+
else if (callee === "useRouter" && NEXT_ROUTER_LIBS.includes(src))
|
|
87
|
+
routerVars.add(name);
|
|
88
|
+
else if (callee === "useHistory" && RR_LIBS.includes(src))
|
|
89
|
+
historyVars.add(name);
|
|
90
|
+
}
|
|
91
|
+
return { navigateVars, routerVars, historyVars };
|
|
92
|
+
}
|
|
93
|
+
function getJsxAttrValueNode(attr) {
|
|
94
|
+
const init = attr.getInitializer?.();
|
|
95
|
+
if (!init)
|
|
96
|
+
return null;
|
|
97
|
+
const k = init.getKind();
|
|
98
|
+
if (k === SyntaxKind.StringLiteral)
|
|
99
|
+
return init;
|
|
100
|
+
if (k === SyntaxKind.JsxExpression)
|
|
101
|
+
return init.getExpression();
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
function extractNavCallsFromFile(sourceFile, project) {
|
|
105
|
+
const results = [];
|
|
106
|
+
const importMap = buildImportSourceMap(sourceFile);
|
|
107
|
+
const { navigateVars, routerVars, historyVars } = buildNavBindingMaps(sourceFile, importMap);
|
|
108
|
+
// ── Call expressions ─────────────────────────────────────────────────
|
|
109
|
+
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
110
|
+
const expr = call.getExpression();
|
|
111
|
+
const args = call.getArguments();
|
|
112
|
+
// navigate('/x') / navigate(`/x/${id}`) / navigate({ to: '/x' })
|
|
113
|
+
if (expr.getKind() === SyntaxKind.Identifier) {
|
|
114
|
+
const name = expr.getText();
|
|
115
|
+
if (!navigateVars.has(name) || args.length === 0)
|
|
116
|
+
continue;
|
|
117
|
+
const arg0 = args[0];
|
|
118
|
+
let urlResult = null;
|
|
119
|
+
if (arg0.getKind() === SyntaxKind.ObjectLiteralExpression) {
|
|
120
|
+
const toInit = arg0.getProperty("to")?.getInitializer?.();
|
|
121
|
+
urlResult = toInit ? extractUrlFromArg(toInit, sourceFile, project) : null;
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
urlResult = extractUrlFromArg(arg0, sourceFile, project);
|
|
125
|
+
}
|
|
126
|
+
if (!urlResult)
|
|
127
|
+
continue;
|
|
128
|
+
results.push({ startLine: call.getStartLineNumber(), callerType: "navigate",
|
|
129
|
+
rawUrl: urlResult.rawUrl, resolvedUrl: urlResult.resolvedUrl, method: "navigate" });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
// member calls
|
|
133
|
+
if (expr.getKind() === SyntaxKind.PropertyAccessExpression) {
|
|
134
|
+
const member = expr;
|
|
135
|
+
const propName = member.getName();
|
|
136
|
+
const objText = member.getExpression().getText();
|
|
137
|
+
const objLeaf = objText.split(".").pop();
|
|
138
|
+
// window.location.replace/assign('/x') — native, always eligible
|
|
139
|
+
if ((propName === "replace" || propName === "assign") && objText.endsWith("location")) {
|
|
140
|
+
if (args.length === 0)
|
|
141
|
+
continue;
|
|
142
|
+
const urlResult = extractUrlFromArg(args[0], sourceFile, project);
|
|
143
|
+
if (!urlResult)
|
|
144
|
+
continue;
|
|
145
|
+
results.push({ startLine: call.getStartLineNumber(), callerType: `location.${propName}`,
|
|
146
|
+
rawUrl: urlResult.rawUrl, resolvedUrl: urlResult.resolvedUrl, method: "location" });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
// window.history.pushState/replaceState(state, title, '/x') — native, url is arg[2]
|
|
150
|
+
if ((propName === "pushState" || propName === "replaceState") && objText.endsWith("history")) {
|
|
151
|
+
if (args.length < 3)
|
|
152
|
+
continue;
|
|
153
|
+
const urlResult = extractUrlFromArg(args[2], sourceFile, project);
|
|
154
|
+
if (!urlResult)
|
|
155
|
+
continue;
|
|
156
|
+
results.push({ startLine: call.getStartLineNumber(), callerType: `history.${propName}`,
|
|
157
|
+
rawUrl: urlResult.rawUrl, resolvedUrl: urlResult.resolvedUrl, method: "history" });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
// router.push/replace (Next) / history.push/replace (RR v5) — gated by binding maps
|
|
161
|
+
if (propName === "push" || propName === "replace") {
|
|
162
|
+
const eligible = routerVars.has(objText) || historyVars.has(objText) ||
|
|
163
|
+
routerVars.has(objLeaf) || historyVars.has(objLeaf);
|
|
164
|
+
if (!eligible || args.length === 0)
|
|
165
|
+
continue;
|
|
166
|
+
const arg0 = args[0];
|
|
167
|
+
let urlResult = null;
|
|
168
|
+
if (arg0.getKind() === SyntaxKind.ObjectLiteralExpression) {
|
|
169
|
+
const pInit = arg0.getProperty("pathname")?.getInitializer?.();
|
|
170
|
+
urlResult = pInit ? extractUrlFromArg(pInit, sourceFile, project) : null;
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
urlResult = extractUrlFromArg(arg0, sourceFile, project);
|
|
174
|
+
}
|
|
175
|
+
if (!urlResult)
|
|
176
|
+
continue;
|
|
177
|
+
results.push({ startLine: call.getStartLineNumber(), callerType: `router.${propName}`,
|
|
178
|
+
rawUrl: urlResult.rawUrl, resolvedUrl: urlResult.resolvedUrl, method: propName });
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// ── window.location.href = '/x' / location.href = '/x' ───────────────
|
|
184
|
+
for (const bin of sourceFile.getDescendantsOfKind(SyntaxKind.BinaryExpression)) {
|
|
185
|
+
if (bin.getOperatorToken().getKind() !== SyntaxKind.EqualsToken)
|
|
186
|
+
continue;
|
|
187
|
+
const left = bin.getLeft();
|
|
188
|
+
if (left.getKind() !== SyntaxKind.PropertyAccessExpression)
|
|
189
|
+
continue;
|
|
190
|
+
const member = left;
|
|
191
|
+
if (member.getName() !== "href")
|
|
192
|
+
continue;
|
|
193
|
+
if (!member.getExpression().getText().endsWith("location"))
|
|
194
|
+
continue;
|
|
195
|
+
const urlResult = extractUrlFromArg(bin.getRight(), sourceFile, project);
|
|
196
|
+
if (!urlResult)
|
|
197
|
+
continue;
|
|
198
|
+
results.push({ startLine: bin.getStartLineNumber(), callerType: "location.href",
|
|
199
|
+
rawUrl: urlResult.rawUrl, resolvedUrl: urlResult.resolvedUrl, method: "location" });
|
|
200
|
+
}
|
|
201
|
+
// ── <Link to> / <NavLink to> (RR/wouter) and <Link href> (next/link) ──
|
|
202
|
+
for (const el of [
|
|
203
|
+
...sourceFile.getDescendantsOfKind(SyntaxKind.JsxOpeningElement),
|
|
204
|
+
...sourceFile.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement),
|
|
205
|
+
]) {
|
|
206
|
+
const tag = el.getTagNameNode().getText();
|
|
207
|
+
if (tag !== "Link" && tag !== "NavLink")
|
|
208
|
+
continue;
|
|
209
|
+
const importSrc = importMap.get(tag);
|
|
210
|
+
if (!importSrc)
|
|
211
|
+
continue; // gate: must be an imported Link
|
|
212
|
+
const toAttr = el.getAttribute("to");
|
|
213
|
+
const hrefAttr = el.getAttribute("href");
|
|
214
|
+
let attr = null;
|
|
215
|
+
if (toAttr && RR_LIBS.includes(importSrc))
|
|
216
|
+
attr = toAttr;
|
|
217
|
+
else if (hrefAttr && NEXT_LINK_LIBS.includes(importSrc))
|
|
218
|
+
attr = hrefAttr;
|
|
219
|
+
if (!attr)
|
|
220
|
+
continue;
|
|
221
|
+
const valueNode = getJsxAttrValueNode(attr);
|
|
222
|
+
if (!valueNode)
|
|
223
|
+
continue;
|
|
224
|
+
const urlResult = extractUrlFromArg(valueNode, sourceFile, project);
|
|
225
|
+
if (!urlResult)
|
|
226
|
+
continue;
|
|
227
|
+
results.push({ startLine: el.getStartLineNumber(), callerType: `<${tag}>`,
|
|
228
|
+
rawUrl: urlResult.rawUrl, resolvedUrl: urlResult.resolvedUrl, method: "link" });
|
|
229
|
+
}
|
|
230
|
+
return results;
|
|
231
|
+
}
|
|
232
|
+
function makeGhostRoute(id, target, flags) {
|
|
233
|
+
return {
|
|
234
|
+
id,
|
|
235
|
+
name: target,
|
|
236
|
+
type: "ROUTE",
|
|
237
|
+
filePath: "[route]",
|
|
238
|
+
startLine: 0,
|
|
239
|
+
endLine: 0,
|
|
240
|
+
metadata: {
|
|
241
|
+
urlPath: target,
|
|
242
|
+
routeKind: "react-router",
|
|
243
|
+
isDynamic: /[:*]/.test(target),
|
|
244
|
+
framework: "unknown",
|
|
245
|
+
...flags,
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
export function detectNavigationEdges(nodes, repoPath) {
|
|
250
|
+
const edges = [];
|
|
251
|
+
const dedupSet = new Set();
|
|
252
|
+
const ghostMap = new Map();
|
|
253
|
+
const index = buildNavRouteIndex(nodes);
|
|
254
|
+
const existingIds = new Set(nodes.map(n => n.id));
|
|
255
|
+
const project = new Project({
|
|
256
|
+
compilerOptions: { allowJs: true, checkJs: false, jsx: 4, strict: false },
|
|
257
|
+
skipAddingFilesFromTsConfig: true,
|
|
258
|
+
});
|
|
259
|
+
const candidateNodes = nodes.filter(n => n.type !== "ROUTE" && n.type !== "THIRD_PARTY" &&
|
|
260
|
+
typeof n.rawCode === "string" && n.rawCode.length > 0);
|
|
261
|
+
const nodesByFile = new Map();
|
|
262
|
+
for (const node of candidateNodes) {
|
|
263
|
+
const abs = path.resolve(repoPath, node.filePath);
|
|
264
|
+
if (!nodesByFile.has(abs))
|
|
265
|
+
nodesByFile.set(abs, []);
|
|
266
|
+
nodesByFile.get(abs).push(node);
|
|
267
|
+
}
|
|
268
|
+
for (const [abs, fileNodes] of nodesByFile) {
|
|
269
|
+
let sourceFile = project.getSourceFile(abs);
|
|
270
|
+
if (!sourceFile) {
|
|
271
|
+
try {
|
|
272
|
+
sourceFile = project.addSourceFileAtPath(abs);
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const located = extractNavCallsFromFile(sourceFile, project);
|
|
279
|
+
if (located.length === 0)
|
|
280
|
+
continue;
|
|
281
|
+
for (const loc of located) {
|
|
282
|
+
// Attribute to the innermost node whose line range contains the call.
|
|
283
|
+
let owner = null;
|
|
284
|
+
for (const node of fileNodes) {
|
|
285
|
+
if (loc.startLine < node.startLine || loc.startLine > node.endLine)
|
|
286
|
+
continue;
|
|
287
|
+
if (owner === null || (node.endLine - node.startLine) < (owner.endLine - owner.startLine)) {
|
|
288
|
+
owner = node;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (owner === null)
|
|
292
|
+
continue;
|
|
293
|
+
const rawTarget = loc.resolvedUrl;
|
|
294
|
+
let toId;
|
|
295
|
+
let matchType;
|
|
296
|
+
if (rawTarget.startsWith("http://") || rawTarget.startsWith("https://")) {
|
|
297
|
+
toId = `[route]::${rawTarget}`;
|
|
298
|
+
matchType = "external";
|
|
299
|
+
if (!existingIds.has(toId) && !ghostMap.has(toId)) {
|
|
300
|
+
ghostMap.set(toId, makeGhostRoute(toId, rawTarget, { isExternal: true }));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
const normalized = normalizeUrl(rawTarget);
|
|
305
|
+
if (!normalized)
|
|
306
|
+
continue; // relative / unparseable → can't reason, skip
|
|
307
|
+
const match = matchNavRoute(normalized, index);
|
|
308
|
+
if (match) {
|
|
309
|
+
toId = match.routeNode.id;
|
|
310
|
+
matchType = match.isDynamic ? "dynamic" : "exact";
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
toId = `[route]::${normalized}`;
|
|
314
|
+
matchType = "unresolved";
|
|
315
|
+
if (!existingIds.has(toId) && !ghostMap.has(toId)) {
|
|
316
|
+
ghostMap.set(toId, makeGhostRoute(toId, normalized, { isUnresolved: true }));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const dedupKey = `${owner.id}→${toId}:${loc.method}`;
|
|
321
|
+
if (dedupSet.has(dedupKey))
|
|
322
|
+
continue;
|
|
323
|
+
dedupSet.add(dedupKey);
|
|
324
|
+
edges.push({
|
|
325
|
+
from: owner.id,
|
|
326
|
+
to: toId,
|
|
327
|
+
type: "NAVIGATES_TO",
|
|
328
|
+
metadata: {
|
|
329
|
+
path: rawTarget,
|
|
330
|
+
rawPath: loc.rawUrl,
|
|
331
|
+
method: loc.method,
|
|
332
|
+
callerType: loc.callerType,
|
|
333
|
+
matchType,
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return { edges, ghostNodes: [...ghostMap.values()] };
|
|
339
|
+
}
|
|
@@ -45,6 +45,12 @@ export function detectRouteEdges(nodes, lookup) {
|
|
|
45
45
|
handlerNode = resolveDefaultExport(filePath, lookup);
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
+
else if (routeKind === "react-router") {
|
|
49
|
+
// React Router / TanStack: route renders a named component
|
|
50
|
+
// (<Route element={<Home/>}> / { component: Home }) — possibly imported
|
|
51
|
+
// from another file, so resolve by name.
|
|
52
|
+
handlerNode = resolveReactRouterComponent(routeNode, lookup);
|
|
53
|
+
}
|
|
48
54
|
if (!handlerNode)
|
|
49
55
|
continue;
|
|
50
56
|
if (handlerNode.id === routeNode.id)
|
|
@@ -91,6 +97,25 @@ function resolveBackendHandler(routeNode, lookup) {
|
|
|
91
97
|
// path segments with the route file
|
|
92
98
|
return closestByPath(byName, filePath);
|
|
93
99
|
}
|
|
100
|
+
function resolveReactRouterComponent(routeNode, lookup) {
|
|
101
|
+
const componentName = routeNode.metadata.rendersComponent;
|
|
102
|
+
if (!componentName)
|
|
103
|
+
return undefined;
|
|
104
|
+
// Strategy 1 — component defined in the same file as the route definition
|
|
105
|
+
const nodesInFile = lookup.nodesByFile.get(routeNode.filePath) ?? [];
|
|
106
|
+
const sameFile = nodesInFile.find(n => n.name === componentName && (n.type === "COMPONENT" || n.type === "FUNCTION"));
|
|
107
|
+
if (sameFile)
|
|
108
|
+
return sameFile;
|
|
109
|
+
// Strategy 2 — component imported from another file, resolve by name.
|
|
110
|
+
// Prefer COMPONENT/FUNCTION nodes; if several match, pick the closest by path.
|
|
111
|
+
const byName = (lookup.nodesByName.get(componentName) ?? [])
|
|
112
|
+
.filter(n => n.type === "COMPONENT" || n.type === "FUNCTION");
|
|
113
|
+
if (byName.length === 0)
|
|
114
|
+
return undefined;
|
|
115
|
+
if (byName.length === 1)
|
|
116
|
+
return byName[0];
|
|
117
|
+
return closestByPath(byName, routeNode.filePath);
|
|
118
|
+
}
|
|
94
119
|
function resolveNextjsApiHandler(routeNode, lookup) {
|
|
95
120
|
const httpMethod = routeNode.metadata.httpMethod;
|
|
96
121
|
if (!httpMethod)
|
package/dist/graph/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { detectEventEdges } from "./edges/eventEdges.js";
|
|
|
5
5
|
import { detectGuardEdges } from "./edges/guardEdges.js";
|
|
6
6
|
import { detectHookEdges } from "./edges/hookEdges.js";
|
|
7
7
|
import { detectImportEdges } from "./edges/importEdges.js";
|
|
8
|
+
import { detectNavigationEdges } from "./edges/navigationEdges.js";
|
|
8
9
|
import { detectPropEdges } from "./edges/propEdges.js";
|
|
9
10
|
import { detectRouteEdges } from "./edges/routeEdge.js";
|
|
10
11
|
import { detectStateEdges } from "./edges/stateEdges.js";
|
|
@@ -29,6 +30,7 @@ export function detectEdges(nodes, routeNodes, repoPath, fingerprint) {
|
|
|
29
30
|
const guardEdges = detectGuardEdges(nodes, lookupMp, routeNodes, repoPath, fingerprint);
|
|
30
31
|
const testEdges = detectTestEdges(lookupMp, repoPath); // This does not needs nodes, as it detect edges from the file
|
|
31
32
|
const nextjsApiCallEdges = detectNextjsApiCallEdges(nodes, repoPath);
|
|
33
|
+
const navResult = detectNavigationEdges(nodes, repoPath);
|
|
32
34
|
// Collect all dynamically-created third-party method nodes (dedup by id)
|
|
33
35
|
const newThirdPartyNodesMap = new Map();
|
|
34
36
|
for (const n of [...importResult.thirdPartyMethodNodes, ...callResult.newThirdPartyNodes]) {
|
|
@@ -49,6 +51,8 @@ export function detectEdges(nodes, routeNodes, repoPath, fingerprint) {
|
|
|
49
51
|
console.log(` Ghost nodes created: ${eventResults.ghostNodes.length}`);
|
|
50
52
|
console.log(` Third-party method nodes: ${newThirdPartyNodes.length}`);
|
|
51
53
|
console.log(` NEXTJS_API_CALL edges: ${nextjsApiCallEdges.length}`);
|
|
54
|
+
console.log(` NAVIGATES_TO edges: ${navResult.edges.length}`);
|
|
55
|
+
console.log(` Navigation ghost route nodes: ${navResult.ghostNodes.length}`);
|
|
52
56
|
const allEdges = [
|
|
53
57
|
...callEdges,
|
|
54
58
|
...importEdges,
|
|
@@ -60,10 +64,17 @@ export function detectEdges(nodes, routeNodes, repoPath, fingerprint) {
|
|
|
60
64
|
...guardEdges,
|
|
61
65
|
...testEdges,
|
|
62
66
|
...nextjsApiCallEdges,
|
|
67
|
+
...navResult.edges,
|
|
63
68
|
];
|
|
64
69
|
console.log(`Total edges detected: ${allEdges.length}`);
|
|
70
|
+
// Merge nav ghost route nodes, deduped by id (never overwrite a real node).
|
|
71
|
+
const allGhostMap = new Map();
|
|
72
|
+
for (const n of [...eventResults.ghostNodes, ...newThirdPartyNodes, ...navResult.ghostNodes]) {
|
|
73
|
+
if (!allGhostMap.has(n.id))
|
|
74
|
+
allGhostMap.set(n.id, n);
|
|
75
|
+
}
|
|
65
76
|
return {
|
|
66
77
|
edges: allEdges,
|
|
67
|
-
ghostNodes: [...
|
|
78
|
+
ghostNodes: [...allGhostMap.values()],
|
|
68
79
|
};
|
|
69
80
|
}
|