@geml/geml 1.4.2 → 1.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +186 -155
- package/codemap/adapters/crg.mjs +120 -120
- package/codemap/adapters/joern.mjs +131 -131
- package/codemap/adapters/scip.mjs +658 -658
- package/codemap/browser-stub.mjs +29 -29
- package/codemap/build.mjs +609 -609
- package/codemap/cross-stack.mjs +303 -303
- package/codemap/detect.mjs +399 -399
- package/codemap/emit.mjs +480 -480
- package/codemap/entries.mjs +129 -129
- package/codemap/exclude.mjs +52 -52
- package/codemap/find.mjs +63 -63
- package/codemap/foldings.mjs +110 -110
- package/codemap/joern-export.sc +83 -83
- package/codemap/mcp-server.mjs +172 -172
- package/codemap/normalize.mjs +275 -275
- package/codemap/recipe-trust.mjs +103 -103
- package/codemap/refresh.mjs +310 -310
- package/codemap/render-all.mjs +64 -64
- package/codemap/serve.mjs +578 -578
- package/codemap/sfc-virtualize.mjs +367 -367
- package/codemap/verify.mjs +148 -148
- package/dist/chart.d.ts +2 -0
- package/dist/chart.js +15 -15
- package/dist/diagnostics.d.ts +9 -0
- package/dist/diagnostics.js +73 -0
- package/dist/geml.d.ts +2 -5
- package/dist/geml.js +191 -98
- package/dist/history.d.ts +10 -0
- package/dist/history.js +25 -24
- package/dist/inline.js +7 -7
- package/dist/mcp.d.ts +18 -0
- package/dist/mcp.js +528 -0
- package/dist/render.js +136 -136
- package/dist/table.d.ts +2 -0
- package/dist/table.js +11 -11
- package/package.json +62 -62
package/codemap/cross-stack.mjs
CHANGED
|
@@ -1,303 +1,303 @@
|
|
|
1
|
-
// geml-code-graph cross-stack API links — connect the two disjoint trees a
|
|
2
|
-
// full-stack repo produces (frontend call sites ⇄ backend route handlers).
|
|
3
|
-
//
|
|
4
|
-
// A frontend "call" to the backend is not a symbol reference — it is an HTTP
|
|
5
|
-
// string crossing a network boundary, so SCIP/Joern never link it. The join
|
|
6
|
-
// key is `METHOD + normalized path`. These links are inherently NOT
|
|
7
|
-
// compiler-verifiable, so they are emitted as their OWN edge kinds
|
|
8
|
-
// (`http-call` / `http-serve`) with a `confidence`, kept strictly separate
|
|
9
|
-
// from the verified `calls` graph — the codemap never launders a heuristic
|
|
10
|
-
// guess into a verified reference.
|
|
11
|
-
//
|
|
12
|
-
// Pluggable + framework-agnostic by design: framework knowledge lives ONLY in
|
|
13
|
-
// the per-language detectors below; the matcher/overlay speak a single
|
|
14
|
-
// normalized shape ({method, path}). Adding a framework = adding a detector.
|
|
15
|
-
//
|
|
16
|
-
// Pure: given the indexed `files` list + an injectable `readText`, and the
|
|
17
|
-
// already-merged graph (`symbols`/`edges`), it appends synthetic endpoint
|
|
18
|
-
// bridge nodes + link edges. No filesystem walking of its own.
|
|
19
|
-
|
|
20
|
-
// ───────────────────────── path normalization ──────────────────────────────
|
|
21
|
-
// {id} / :id / ${x} path params all collapse to a single wildcard token so a
|
|
22
|
-
// frontend `/users/${id}` matches a backend `/users/{id}` matches `/users/:id`.
|
|
23
|
-
function normPath(p) {
|
|
24
|
-
let s = String(p).split("?")[0].split("#")[0];
|
|
25
|
-
s = s.replace(/\$\{[^}]*\}/g, "{}").replace(/\{[^}]*\}/g, "{}").replace(/:[A-Za-z0-9_]+/g, "{}");
|
|
26
|
-
s = s.replace(/\{\}(?:\{\})+/g, "{}");
|
|
27
|
-
if (s.length > 1 && s.endsWith("/")) s = s.slice(0, -1);
|
|
28
|
-
return s || "/";
|
|
29
|
-
}
|
|
30
|
-
const pathSegs = (p) => normPath(p).split("/");
|
|
31
|
-
function exactEq(a, b) {
|
|
32
|
-
if (a.length !== b.length) return false;
|
|
33
|
-
for (let i = 0; i < a.length; i++) if (a[i] !== b[i] && a[i] !== "{}" && b[i] !== "{}") return false;
|
|
34
|
-
return true;
|
|
35
|
-
}
|
|
36
|
-
function prefixCover(pre, fe) {
|
|
37
|
-
if (fe.length < pre.length) return false;
|
|
38
|
-
for (let i = 0; i < pre.length; i++) if (pre[i] !== "" && pre[i] !== fe[i] && pre[i] !== "{}" && fe[i] !== "{}") return false;
|
|
39
|
-
return true;
|
|
40
|
-
}
|
|
41
|
-
const methodOk = (fe, be) => fe === "ANY" || be === "ANY" || fe === be;
|
|
42
|
-
|
|
43
|
-
// ─────────────────────────── backend detectors ─────────────────────────────
|
|
44
|
-
// Each returns [{ method, path, prefix?, line, via }]. `prefix:true` means the
|
|
45
|
-
// route matches any path beginning with `path` (Spring "/x/**", Rust
|
|
46
|
-
// starts_with guards). `method:"ANY"` means the declaration does not pin one.
|
|
47
|
-
|
|
48
|
-
// Spring MVC: class-level @RequestMapping prefix + method-level mappings.
|
|
49
|
-
function detectSpringRoutes(text) {
|
|
50
|
-
const routes = [];
|
|
51
|
-
const METHOD_OF = { Get: "GET", Post: "POST", Put: "PUT", Delete: "DELETE", Patch: "PATCH", Request: "ANY" };
|
|
52
|
-
// class-level prefix: the last @RequestMapping("...") that precedes `class `.
|
|
53
|
-
let classPrefix = "";
|
|
54
|
-
// `\bclass\s` (linear) — NOT `(?:public\s+|…)*class\s`, whose repeated
|
|
55
|
-
// `\s+` group backtracks O(N^2) on a long `public ` run with no `class`
|
|
56
|
-
// (crafted .java DoS). The leading modifiers don't affect the index we need
|
|
57
|
-
// (the text before `class`, scanned for @RequestMapping).
|
|
58
|
-
const classIdx = text.search(/\bclass\s/);
|
|
59
|
-
if (classIdx > 0) {
|
|
60
|
-
const head = text.slice(0, classIdx);
|
|
61
|
-
const cm = [...head.matchAll(/@RequestMapping\s*\(([^)]*)\)/g)].pop();
|
|
62
|
-
if (cm) { const p = pathsFromJavaAnno(cm[1])[0]; if (p) classPrefix = p.replace(/\/$/, ""); }
|
|
63
|
-
}
|
|
64
|
-
const lines = text.split(/\r?\n/);
|
|
65
|
-
const RE = /@(Get|Post|Put|Delete|Patch|Request)Mapping\s*\(([^)]*)\)/g;
|
|
66
|
-
for (let i = 0; i < lines.length; i++) {
|
|
67
|
-
let m; RE.lastIndex = 0;
|
|
68
|
-
while ((m = RE.exec(lines[i]))) {
|
|
69
|
-
// skip the class-level annotation itself (line is followed by a class decl)
|
|
70
|
-
if (/\bclass\s/.test(lines[i]) || (lines[i + 1] && /\bclass\s/.test(lines[i + 1]))) continue;
|
|
71
|
-
const method = METHOD_OF[m[1]];
|
|
72
|
-
for (const raw of pathsFromJavaAnno(m[2])) {
|
|
73
|
-
const full = joinPath(classPrefix, raw);
|
|
74
|
-
routes.push({ method, path: full, line: i + 1, via: "spring" });
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
return routes;
|
|
79
|
-
}
|
|
80
|
-
// Extract path string(s) from a Spring annotation arg list, honoring
|
|
81
|
-
// `value=`/`path=`, brace lists {"a","b"}, and ignoring produces=/consumes=.
|
|
82
|
-
function pathsFromJavaAnno(argstr) {
|
|
83
|
-
const braced = /(?:value|path)\s*=\s*\{([^}]*)\}/.exec(argstr);
|
|
84
|
-
if (braced) return [...braced[1].matchAll(/"([^"]*)"/g)].map((x) => x[1]);
|
|
85
|
-
const named = /(?:value|path)\s*=\s*"([^"]*)"/.exec(argstr);
|
|
86
|
-
if (named) return [named[1]];
|
|
87
|
-
const brace = /^\s*\{([^}]*)\}/.exec(argstr);
|
|
88
|
-
if (brace) return [...brace[1].matchAll(/"([^"]*)"/g)].map((x) => x[1]);
|
|
89
|
-
const first = /"([^"]*)"/.exec(argstr);
|
|
90
|
-
return first ? [first[1]] : [""];
|
|
91
|
-
}
|
|
92
|
-
function joinPath(prefix, sub) {
|
|
93
|
-
if (!prefix) return sub || "/";
|
|
94
|
-
if (!sub || sub === "/" || sub === "") return prefix || "/";
|
|
95
|
-
return (prefix + "/" + sub).replace(/\/{2,}/g, "/");
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Rust CF-worker / bespoke router: `match (method, path)` tuple arms +
|
|
99
|
-
// `if path == "…"` / `path.starts_with("…")` guards.
|
|
100
|
-
const RUST_BROAD = new Set(["/admin", "/admin/", "/accounts", "/accounts/", "/", "/console", "/console/", "/api", "/api/"]);
|
|
101
|
-
function detectRustRoutes(text) {
|
|
102
|
-
const routes = [];
|
|
103
|
-
const lines = text.split(/\r?\n/);
|
|
104
|
-
const TUPLE = /\(\s*"(GET|POST|PUT|DELETE|PATCH)"\s*,\s*"([^"]+)"\s*\)/g;
|
|
105
|
-
const TUPLE_SW = /\(\s*"(GET|POST|PUT|DELETE|PATCH)"\s*,\s*[a-z_]+\s*\)\s*if\s+[a-z_]+\.starts_with\(\s*"([^"]+)"/g;
|
|
106
|
-
const GUARD_EQ = /\bpath\s*==\s*"([^"]+)"/g;
|
|
107
|
-
const GUARD_SW = /\bpath\.starts_with\(\s*"([^"]+)"\s*\)/g;
|
|
108
|
-
for (let i = 0; i < lines.length; i++) {
|
|
109
|
-
const ln = lines[i]; let m;
|
|
110
|
-
TUPLE.lastIndex = 0; while ((m = TUPLE.exec(ln))) if (m[2].startsWith("/")) routes.push({ method: m[1], path: m[2], line: i + 1, via: "rust-worker" });
|
|
111
|
-
TUPLE_SW.lastIndex = 0; while ((m = TUPLE_SW.exec(ln))) if (m[2].startsWith("/")) routes.push({ method: m[1], path: m[2], prefix: true, line: i + 1, via: "rust-worker" });
|
|
112
|
-
GUARD_EQ.lastIndex = 0; while ((m = GUARD_EQ.exec(ln))) if (m[1].startsWith("/") && !RUST_BROAD.has(m[1])) routes.push({ method: "ANY", path: m[1], line: i + 1, via: "rust-guard" });
|
|
113
|
-
GUARD_SW.lastIndex = 0; while ((m = GUARD_SW.exec(ln))) { const p = m[1].replace(/\/$/, ""); if (m[1].startsWith("/") && !RUST_BROAD.has(m[1])) routes.push({ method: "ANY", path: p, prefix: true, line: i + 1, via: "rust-guard" }); }
|
|
114
|
-
}
|
|
115
|
-
return routes;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function detectBackendRoutes(relFile, text) {
|
|
119
|
-
if (relFile.endsWith(".java")) return detectSpringRoutes(text);
|
|
120
|
-
if (relFile.endsWith(".rs")) return detectRustRoutes(text);
|
|
121
|
-
return [];
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// ─────────────────────────── frontend detector ─────────────────────────────
|
|
125
|
-
// Hub-aware: raw fetch/$fetch/useFetch AND object-hub calls. Covers both the
|
|
126
|
-
// standard verbs (`api.get('/x')`, `axios.put(...)`) and the common wrapped-hub
|
|
127
|
-
// convention (`request.sendGet('/x')`, `http.sendJson(...)`, `req.send(...)`).
|
|
128
|
-
// The "resolved path must start with /" filter drops non-API `.get()`/`.send()`
|
|
129
|
-
// noise (Map.get('k'), emitter.send(evt), etc.). Verb → HTTP method below.
|
|
130
|
-
const FE_VERB = "get|post|put|delete|patch|sendGet|sendPost|sendPut|sendDelete|sendPatch|sendJson|send|request";
|
|
131
|
-
const FE_CALL = new RegExp(`\\b(?:\\$fetch|useFetch|fetch)\\s*\\(|\\b[\\w$]+\\.(${FE_VERB})\\s*\\(`, "g");
|
|
132
|
-
const VERB_METHOD = {
|
|
133
|
-
get: "GET", post: "POST", put: "PUT", delete: "DELETE", patch: "PATCH",
|
|
134
|
-
sendGet: "GET", sendPost: "POST", sendPut: "PUT", sendDelete: "DELETE",
|
|
135
|
-
sendPatch: "PATCH", sendJson: "POST", send: "ANY", request: "ANY",
|
|
136
|
-
};
|
|
137
|
-
function readCallArg(src, startIdx) {
|
|
138
|
-
let depth = 0, i = startIdx, arg = "", inStr = null;
|
|
139
|
-
// A real API-call first argument is short. Cap the scan so an UNBALANCED
|
|
140
|
-
// `fetch(` (no matching close paren) can't walk to end-of-file on every match
|
|
141
|
-
// (crafted-source DoS); a truncated arg simply fails to resolve as a path.
|
|
142
|
-
const max = Math.min(src.length, startIdx + 4096);
|
|
143
|
-
for (; i < max; i++) {
|
|
144
|
-
const c = src[i];
|
|
145
|
-
if (inStr) { arg += c; if (c === inStr && src[i - 1] !== "\\") inStr = null; continue; }
|
|
146
|
-
if (c === '"' || c === "'" || c === "`") { inStr = c; arg += c; continue; }
|
|
147
|
-
if (c === "(" || c === "[" || c === "{") { depth++; arg += c; continue; }
|
|
148
|
-
if (c === ")" || c === "]" || c === "}") { if (depth === 0) break; depth--; arg += c; continue; }
|
|
149
|
-
if (c === "," && depth === 0) break;
|
|
150
|
-
arg += c;
|
|
151
|
-
}
|
|
152
|
-
return { arg: arg.trim(), end: i };
|
|
153
|
-
}
|
|
154
|
-
function resolveFeUrl(arg) {
|
|
155
|
-
arg = arg.trim();
|
|
156
|
-
let body = null;
|
|
157
|
-
if (arg.startsWith("`")) body = arg.slice(1, arg.lastIndexOf("`"));
|
|
158
|
-
else { const m = /^(['"])(.*?)\1/.exec(arg); if (m) body = m[2]; }
|
|
159
|
-
if (body === null) return { path: null, dynamic: true };
|
|
160
|
-
// strip scheme://authority and a leading base interpolation (${apiBase}/…)
|
|
161
|
-
let s = body.replace(/^https?:\/\/[^/]*/i, "").replace(/^\$\{[^}]*\}/, "");
|
|
162
|
-
if (!s.startsWith("/")) return { path: null, dynamic: true }; // non-path first arg → not an API call
|
|
163
|
-
s = s.split("?")[0].split("#")[0].replace(/\$\{[^}]*\}/g, "{}");
|
|
164
|
-
return { path: normPath(s), dynamic: false };
|
|
165
|
-
}
|
|
166
|
-
function detectFrontendCalls(relFile, text) {
|
|
167
|
-
if (!/\.(vue|svelte|ts|tsx|js|jsx|mjs|cjs)$/.test(relFile)) return [];
|
|
168
|
-
const calls = [];
|
|
169
|
-
let m; FE_CALL.lastIndex = 0;
|
|
170
|
-
while ((m = FE_CALL.exec(text))) {
|
|
171
|
-
const { arg, end } = readCallArg(text, FE_CALL.lastIndex);
|
|
172
|
-
FE_CALL.lastIndex = end; // resume PAST the consumed arg — not re-scan it (O(N^2))
|
|
173
|
-
const { path, dynamic } = resolveFeUrl(arg);
|
|
174
|
-
if (dynamic || !path) continue;
|
|
175
|
-
// method: verb from the hub call, else `method:` in the options object, else GET
|
|
176
|
-
let method = "GET";
|
|
177
|
-
if (m[1] && VERB_METHOD[m[1]]) method = VERB_METHOD[m[1]];
|
|
178
|
-
else { const mm = /method\s*:\s*['"`]?(GET|POST|PUT|DELETE|PATCH)/i.exec(text.slice(end, end + 220)); if (mm) method = mm[1].toUpperCase(); }
|
|
179
|
-
const line = text.slice(0, m.index).split(/\r?\n/).length;
|
|
180
|
-
calls.push({ method, path, line, raw: arg.slice(0, 80) });
|
|
181
|
-
}
|
|
182
|
-
return calls;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// ───────────────────────────── surface scan ────────────────────────────────
|
|
186
|
-
export function scanApiSurface({ files = [], root, readText }) {
|
|
187
|
-
const calls = [], routes = [];
|
|
188
|
-
for (const rel of files) {
|
|
189
|
-
let text; try { text = readText(rel); } catch { continue; }
|
|
190
|
-
if (text == null) continue;
|
|
191
|
-
for (const c of detectFrontendCalls(rel, text)) calls.push({ ...c, file: rel });
|
|
192
|
-
for (const r of detectBackendRoutes(rel, text)) routes.push({ ...r, file: rel });
|
|
193
|
-
}
|
|
194
|
-
return { calls, routes };
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// ─────────────────────────────── matching ──────────────────────────────────
|
|
198
|
-
// For each FE call, find the backend route it hits. A route is a candidate if
|
|
199
|
-
// its (possibly-prefix) path covers the call path; method must agree unless one
|
|
200
|
-
// side is ANY. Records method-divergent hits (path matches, verb differs) as a
|
|
201
|
-
// contract-drift signal rather than dropping them.
|
|
202
|
-
export function matchLinks({ calls, routes }) {
|
|
203
|
-
const links = [], unmatchedFE = [], divergent = [];
|
|
204
|
-
const hitRoute = new Set();
|
|
205
|
-
const routeKey = (r) => `${r.method} ${r.path} ${r.file}:${r.line}`;
|
|
206
|
-
// Segment each route ONCE, not once per (call, route) pair — the split was
|
|
207
|
-
// the dominant cost of the O(calls×routes) match.
|
|
208
|
-
const routeSegs = routes.map((r) => ({ r, segs: pathSegs(r.path) }));
|
|
209
|
-
for (const c of calls) {
|
|
210
|
-
const cs = pathSegs(c.path);
|
|
211
|
-
let pathHit = null, methodHit = null;
|
|
212
|
-
for (const { r, segs } of routeSegs) {
|
|
213
|
-
const ok = r.prefix ? prefixCover(segs, cs) : exactEq(segs, cs);
|
|
214
|
-
if (!ok) continue;
|
|
215
|
-
if (!pathHit) pathHit = r;
|
|
216
|
-
if (methodOk(c.method, r.method)) { methodHit = r; break; }
|
|
217
|
-
}
|
|
218
|
-
if (methodHit) {
|
|
219
|
-
links.push({ call: c, route: methodHit, confidence: methodHit.prefix ? "medium" : "high" });
|
|
220
|
-
hitRoute.add(routeKey(methodHit));
|
|
221
|
-
} else if (pathHit) {
|
|
222
|
-
divergent.push({ call: c, route: pathHit });
|
|
223
|
-
links.push({ call: c, route: pathHit, confidence: "low", methodDivergent: true });
|
|
224
|
-
hitRoute.add(routeKey(pathHit));
|
|
225
|
-
} else {
|
|
226
|
-
unmatchedFE.push(c);
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
const deadRoutes = routes.filter((r) => !hitRoute.has(routeKey(r)));
|
|
230
|
-
return { links, unmatchedFE, divergent, deadRoutes };
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// ─────────────────────────── overlay assembly ──────────────────────────────
|
|
234
|
-
// Turn matched links into cross-stack graph edges. The endpoint (METHOD +
|
|
235
|
-
// normalized path) is carried as an edge LABEL, not a node — one `http` edge
|
|
236
|
-
// per matched call runs directly from the frontend caller's enclosing symbol
|
|
237
|
-
// to the backend handler's enclosing symbol, so the two otherwise-disjoint
|
|
238
|
-
// trees become one connected graph without inventing a node kind that emit's
|
|
239
|
-
// function-centric model (and the viewer) would have to learn.
|
|
240
|
-
function enclosingIndex(symbols) {
|
|
241
|
-
const byFile = new Map();
|
|
242
|
-
for (const s of symbols) {
|
|
243
|
-
if (!s.file) continue;
|
|
244
|
-
if (!byFile.has(s.file)) byFile.set(s.file, []);
|
|
245
|
-
byFile.get(s.file).push(s);
|
|
246
|
-
}
|
|
247
|
-
return (file, line) => {
|
|
248
|
-
const list = byFile.get(file);
|
|
249
|
-
if (!list) return null;
|
|
250
|
-
let best = null, bestSpan = Infinity;
|
|
251
|
-
for (const s of list) {
|
|
252
|
-
if (s.kind === "File") continue; // never attribute to a file node — its
|
|
253
|
-
// block is only emitted in multi-file containers, so a ref to it can
|
|
254
|
-
// dangle; the caller falls back to `file:line` text (lenient in verify).
|
|
255
|
-
const a = s.line_start ?? 0, b = s.line_end ?? a;
|
|
256
|
-
if (line >= a && line <= b && b - a < bestSpan) { best = s; bestSpan = b - a; }
|
|
257
|
-
}
|
|
258
|
-
return best; // a function/test symbol, or null when none encloses the line
|
|
259
|
-
};
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
export function buildCrossStackOverlay({ symbols = [], files = [], readText }) {
|
|
263
|
-
const scan = scanApiSurface({ files, readText });
|
|
264
|
-
const { links, unmatchedFE, divergent, deadRoutes } = matchLinks(scan);
|
|
265
|
-
const enclosing = enclosingIndex(symbols);
|
|
266
|
-
const httpEdges = [];
|
|
267
|
-
const endpoints = new Set();
|
|
268
|
-
for (const { call, route, confidence, methodDivergent } of links) {
|
|
269
|
-
const method = route.method === "ANY" ? call.method : route.method;
|
|
270
|
-
const endpoint = `${method} ${normPath(route.path)}`;
|
|
271
|
-
endpoints.add(endpoint);
|
|
272
|
-
const feFn = enclosing(call.file, call.line);
|
|
273
|
-
// A route declaration often sits just ABOVE its handler (Spring's
|
|
274
|
-
// `@GetMapping` annotation, a decorator): if the exact line lands on the
|
|
275
|
-
// file node rather than a function, probe a few lines down for the handler.
|
|
276
|
-
let beFn = enclosing(route.file, route.line);
|
|
277
|
-
for (let d = 1; d <= 3 && !beFn; d++) beFn = enclosing(route.file, route.line + d);
|
|
278
|
-
httpEdges.push({
|
|
279
|
-
kind: "http",
|
|
280
|
-
from: feFn ? feFn.anchor : undefined,
|
|
281
|
-
from_text: feFn ? undefined : `${call.file}:${call.line}`,
|
|
282
|
-
to: beFn ? beFn.anchor : undefined,
|
|
283
|
-
to_text: beFn ? undefined : `${route.file}:${route.line}`,
|
|
284
|
-
endpoint,
|
|
285
|
-
confidence,
|
|
286
|
-
methodDivergent: methodDivergent || undefined,
|
|
287
|
-
site: { file: call.file, line: call.line },
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
return {
|
|
291
|
-
edges: httpEdges,
|
|
292
|
-
audit: {
|
|
293
|
-
matched: links.length,
|
|
294
|
-
endpoints: endpoints.size,
|
|
295
|
-
divergent: divergent.map((d) => ({ fe: `${d.call.method} ${d.call.path}`, feSite: `${d.call.file}:${d.call.line}`, be: `${d.route.method} ${normPath(d.route.path)}`, beSite: `${d.route.file}:${d.route.line}` })),
|
|
296
|
-
unmatchedFE: unmatchedFE.map((c) => ({ call: `${c.method} ${c.path}`, site: `${c.file}:${c.line}` })),
|
|
297
|
-
deadRoutes: deadRoutes.map((r) => ({ route: `${r.method} ${normPath(r.path)}`, site: `${r.file}:${r.line}` })),
|
|
298
|
-
},
|
|
299
|
-
};
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// exported for unit tests
|
|
303
|
-
export const _internal = { normPath, detectSpringRoutes, detectRustRoutes, detectFrontendCalls, enclosingIndex };
|
|
1
|
+
// geml-code-graph cross-stack API links — connect the two disjoint trees a
|
|
2
|
+
// full-stack repo produces (frontend call sites ⇄ backend route handlers).
|
|
3
|
+
//
|
|
4
|
+
// A frontend "call" to the backend is not a symbol reference — it is an HTTP
|
|
5
|
+
// string crossing a network boundary, so SCIP/Joern never link it. The join
|
|
6
|
+
// key is `METHOD + normalized path`. These links are inherently NOT
|
|
7
|
+
// compiler-verifiable, so they are emitted as their OWN edge kinds
|
|
8
|
+
// (`http-call` / `http-serve`) with a `confidence`, kept strictly separate
|
|
9
|
+
// from the verified `calls` graph — the codemap never launders a heuristic
|
|
10
|
+
// guess into a verified reference.
|
|
11
|
+
//
|
|
12
|
+
// Pluggable + framework-agnostic by design: framework knowledge lives ONLY in
|
|
13
|
+
// the per-language detectors below; the matcher/overlay speak a single
|
|
14
|
+
// normalized shape ({method, path}). Adding a framework = adding a detector.
|
|
15
|
+
//
|
|
16
|
+
// Pure: given the indexed `files` list + an injectable `readText`, and the
|
|
17
|
+
// already-merged graph (`symbols`/`edges`), it appends synthetic endpoint
|
|
18
|
+
// bridge nodes + link edges. No filesystem walking of its own.
|
|
19
|
+
|
|
20
|
+
// ───────────────────────── path normalization ──────────────────────────────
|
|
21
|
+
// {id} / :id / ${x} path params all collapse to a single wildcard token so a
|
|
22
|
+
// frontend `/users/${id}` matches a backend `/users/{id}` matches `/users/:id`.
|
|
23
|
+
function normPath(p) {
|
|
24
|
+
let s = String(p).split("?")[0].split("#")[0];
|
|
25
|
+
s = s.replace(/\$\{[^}]*\}/g, "{}").replace(/\{[^}]*\}/g, "{}").replace(/:[A-Za-z0-9_]+/g, "{}");
|
|
26
|
+
s = s.replace(/\{\}(?:\{\})+/g, "{}");
|
|
27
|
+
if (s.length > 1 && s.endsWith("/")) s = s.slice(0, -1);
|
|
28
|
+
return s || "/";
|
|
29
|
+
}
|
|
30
|
+
const pathSegs = (p) => normPath(p).split("/");
|
|
31
|
+
function exactEq(a, b) {
|
|
32
|
+
if (a.length !== b.length) return false;
|
|
33
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i] && a[i] !== "{}" && b[i] !== "{}") return false;
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
function prefixCover(pre, fe) {
|
|
37
|
+
if (fe.length < pre.length) return false;
|
|
38
|
+
for (let i = 0; i < pre.length; i++) if (pre[i] !== "" && pre[i] !== fe[i] && pre[i] !== "{}" && fe[i] !== "{}") return false;
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
const methodOk = (fe, be) => fe === "ANY" || be === "ANY" || fe === be;
|
|
42
|
+
|
|
43
|
+
// ─────────────────────────── backend detectors ─────────────────────────────
|
|
44
|
+
// Each returns [{ method, path, prefix?, line, via }]. `prefix:true` means the
|
|
45
|
+
// route matches any path beginning with `path` (Spring "/x/**", Rust
|
|
46
|
+
// starts_with guards). `method:"ANY"` means the declaration does not pin one.
|
|
47
|
+
|
|
48
|
+
// Spring MVC: class-level @RequestMapping prefix + method-level mappings.
|
|
49
|
+
function detectSpringRoutes(text) {
|
|
50
|
+
const routes = [];
|
|
51
|
+
const METHOD_OF = { Get: "GET", Post: "POST", Put: "PUT", Delete: "DELETE", Patch: "PATCH", Request: "ANY" };
|
|
52
|
+
// class-level prefix: the last @RequestMapping("...") that precedes `class `.
|
|
53
|
+
let classPrefix = "";
|
|
54
|
+
// `\bclass\s` (linear) — NOT `(?:public\s+|…)*class\s`, whose repeated
|
|
55
|
+
// `\s+` group backtracks O(N^2) on a long `public ` run with no `class`
|
|
56
|
+
// (crafted .java DoS). The leading modifiers don't affect the index we need
|
|
57
|
+
// (the text before `class`, scanned for @RequestMapping).
|
|
58
|
+
const classIdx = text.search(/\bclass\s/);
|
|
59
|
+
if (classIdx > 0) {
|
|
60
|
+
const head = text.slice(0, classIdx);
|
|
61
|
+
const cm = [...head.matchAll(/@RequestMapping\s*\(([^)]*)\)/g)].pop();
|
|
62
|
+
if (cm) { const p = pathsFromJavaAnno(cm[1])[0]; if (p) classPrefix = p.replace(/\/$/, ""); }
|
|
63
|
+
}
|
|
64
|
+
const lines = text.split(/\r?\n/);
|
|
65
|
+
const RE = /@(Get|Post|Put|Delete|Patch|Request)Mapping\s*\(([^)]*)\)/g;
|
|
66
|
+
for (let i = 0; i < lines.length; i++) {
|
|
67
|
+
let m; RE.lastIndex = 0;
|
|
68
|
+
while ((m = RE.exec(lines[i]))) {
|
|
69
|
+
// skip the class-level annotation itself (line is followed by a class decl)
|
|
70
|
+
if (/\bclass\s/.test(lines[i]) || (lines[i + 1] && /\bclass\s/.test(lines[i + 1]))) continue;
|
|
71
|
+
const method = METHOD_OF[m[1]];
|
|
72
|
+
for (const raw of pathsFromJavaAnno(m[2])) {
|
|
73
|
+
const full = joinPath(classPrefix, raw);
|
|
74
|
+
routes.push({ method, path: full, line: i + 1, via: "spring" });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return routes;
|
|
79
|
+
}
|
|
80
|
+
// Extract path string(s) from a Spring annotation arg list, honoring
|
|
81
|
+
// `value=`/`path=`, brace lists {"a","b"}, and ignoring produces=/consumes=.
|
|
82
|
+
function pathsFromJavaAnno(argstr) {
|
|
83
|
+
const braced = /(?:value|path)\s*=\s*\{([^}]*)\}/.exec(argstr);
|
|
84
|
+
if (braced) return [...braced[1].matchAll(/"([^"]*)"/g)].map((x) => x[1]);
|
|
85
|
+
const named = /(?:value|path)\s*=\s*"([^"]*)"/.exec(argstr);
|
|
86
|
+
if (named) return [named[1]];
|
|
87
|
+
const brace = /^\s*\{([^}]*)\}/.exec(argstr);
|
|
88
|
+
if (brace) return [...brace[1].matchAll(/"([^"]*)"/g)].map((x) => x[1]);
|
|
89
|
+
const first = /"([^"]*)"/.exec(argstr);
|
|
90
|
+
return first ? [first[1]] : [""];
|
|
91
|
+
}
|
|
92
|
+
function joinPath(prefix, sub) {
|
|
93
|
+
if (!prefix) return sub || "/";
|
|
94
|
+
if (!sub || sub === "/" || sub === "") return prefix || "/";
|
|
95
|
+
return (prefix + "/" + sub).replace(/\/{2,}/g, "/");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Rust CF-worker / bespoke router: `match (method, path)` tuple arms +
|
|
99
|
+
// `if path == "…"` / `path.starts_with("…")` guards.
|
|
100
|
+
const RUST_BROAD = new Set(["/admin", "/admin/", "/accounts", "/accounts/", "/", "/console", "/console/", "/api", "/api/"]);
|
|
101
|
+
function detectRustRoutes(text) {
|
|
102
|
+
const routes = [];
|
|
103
|
+
const lines = text.split(/\r?\n/);
|
|
104
|
+
const TUPLE = /\(\s*"(GET|POST|PUT|DELETE|PATCH)"\s*,\s*"([^"]+)"\s*\)/g;
|
|
105
|
+
const TUPLE_SW = /\(\s*"(GET|POST|PUT|DELETE|PATCH)"\s*,\s*[a-z_]+\s*\)\s*if\s+[a-z_]+\.starts_with\(\s*"([^"]+)"/g;
|
|
106
|
+
const GUARD_EQ = /\bpath\s*==\s*"([^"]+)"/g;
|
|
107
|
+
const GUARD_SW = /\bpath\.starts_with\(\s*"([^"]+)"\s*\)/g;
|
|
108
|
+
for (let i = 0; i < lines.length; i++) {
|
|
109
|
+
const ln = lines[i]; let m;
|
|
110
|
+
TUPLE.lastIndex = 0; while ((m = TUPLE.exec(ln))) if (m[2].startsWith("/")) routes.push({ method: m[1], path: m[2], line: i + 1, via: "rust-worker" });
|
|
111
|
+
TUPLE_SW.lastIndex = 0; while ((m = TUPLE_SW.exec(ln))) if (m[2].startsWith("/")) routes.push({ method: m[1], path: m[2], prefix: true, line: i + 1, via: "rust-worker" });
|
|
112
|
+
GUARD_EQ.lastIndex = 0; while ((m = GUARD_EQ.exec(ln))) if (m[1].startsWith("/") && !RUST_BROAD.has(m[1])) routes.push({ method: "ANY", path: m[1], line: i + 1, via: "rust-guard" });
|
|
113
|
+
GUARD_SW.lastIndex = 0; while ((m = GUARD_SW.exec(ln))) { const p = m[1].replace(/\/$/, ""); if (m[1].startsWith("/") && !RUST_BROAD.has(m[1])) routes.push({ method: "ANY", path: p, prefix: true, line: i + 1, via: "rust-guard" }); }
|
|
114
|
+
}
|
|
115
|
+
return routes;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function detectBackendRoutes(relFile, text) {
|
|
119
|
+
if (relFile.endsWith(".java")) return detectSpringRoutes(text);
|
|
120
|
+
if (relFile.endsWith(".rs")) return detectRustRoutes(text);
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─────────────────────────── frontend detector ─────────────────────────────
|
|
125
|
+
// Hub-aware: raw fetch/$fetch/useFetch AND object-hub calls. Covers both the
|
|
126
|
+
// standard verbs (`api.get('/x')`, `axios.put(...)`) and the common wrapped-hub
|
|
127
|
+
// convention (`request.sendGet('/x')`, `http.sendJson(...)`, `req.send(...)`).
|
|
128
|
+
// The "resolved path must start with /" filter drops non-API `.get()`/`.send()`
|
|
129
|
+
// noise (Map.get('k'), emitter.send(evt), etc.). Verb → HTTP method below.
|
|
130
|
+
const FE_VERB = "get|post|put|delete|patch|sendGet|sendPost|sendPut|sendDelete|sendPatch|sendJson|send|request";
|
|
131
|
+
const FE_CALL = new RegExp(`\\b(?:\\$fetch|useFetch|fetch)\\s*\\(|\\b[\\w$]+\\.(${FE_VERB})\\s*\\(`, "g");
|
|
132
|
+
const VERB_METHOD = {
|
|
133
|
+
get: "GET", post: "POST", put: "PUT", delete: "DELETE", patch: "PATCH",
|
|
134
|
+
sendGet: "GET", sendPost: "POST", sendPut: "PUT", sendDelete: "DELETE",
|
|
135
|
+
sendPatch: "PATCH", sendJson: "POST", send: "ANY", request: "ANY",
|
|
136
|
+
};
|
|
137
|
+
function readCallArg(src, startIdx) {
|
|
138
|
+
let depth = 0, i = startIdx, arg = "", inStr = null;
|
|
139
|
+
// A real API-call first argument is short. Cap the scan so an UNBALANCED
|
|
140
|
+
// `fetch(` (no matching close paren) can't walk to end-of-file on every match
|
|
141
|
+
// (crafted-source DoS); a truncated arg simply fails to resolve as a path.
|
|
142
|
+
const max = Math.min(src.length, startIdx + 4096);
|
|
143
|
+
for (; i < max; i++) {
|
|
144
|
+
const c = src[i];
|
|
145
|
+
if (inStr) { arg += c; if (c === inStr && src[i - 1] !== "\\") inStr = null; continue; }
|
|
146
|
+
if (c === '"' || c === "'" || c === "`") { inStr = c; arg += c; continue; }
|
|
147
|
+
if (c === "(" || c === "[" || c === "{") { depth++; arg += c; continue; }
|
|
148
|
+
if (c === ")" || c === "]" || c === "}") { if (depth === 0) break; depth--; arg += c; continue; }
|
|
149
|
+
if (c === "," && depth === 0) break;
|
|
150
|
+
arg += c;
|
|
151
|
+
}
|
|
152
|
+
return { arg: arg.trim(), end: i };
|
|
153
|
+
}
|
|
154
|
+
function resolveFeUrl(arg) {
|
|
155
|
+
arg = arg.trim();
|
|
156
|
+
let body = null;
|
|
157
|
+
if (arg.startsWith("`")) body = arg.slice(1, arg.lastIndexOf("`"));
|
|
158
|
+
else { const m = /^(['"])(.*?)\1/.exec(arg); if (m) body = m[2]; }
|
|
159
|
+
if (body === null) return { path: null, dynamic: true };
|
|
160
|
+
// strip scheme://authority and a leading base interpolation (${apiBase}/…)
|
|
161
|
+
let s = body.replace(/^https?:\/\/[^/]*/i, "").replace(/^\$\{[^}]*\}/, "");
|
|
162
|
+
if (!s.startsWith("/")) return { path: null, dynamic: true }; // non-path first arg → not an API call
|
|
163
|
+
s = s.split("?")[0].split("#")[0].replace(/\$\{[^}]*\}/g, "{}");
|
|
164
|
+
return { path: normPath(s), dynamic: false };
|
|
165
|
+
}
|
|
166
|
+
function detectFrontendCalls(relFile, text) {
|
|
167
|
+
if (!/\.(vue|svelte|ts|tsx|js|jsx|mjs|cjs)$/.test(relFile)) return [];
|
|
168
|
+
const calls = [];
|
|
169
|
+
let m; FE_CALL.lastIndex = 0;
|
|
170
|
+
while ((m = FE_CALL.exec(text))) {
|
|
171
|
+
const { arg, end } = readCallArg(text, FE_CALL.lastIndex);
|
|
172
|
+
FE_CALL.lastIndex = end; // resume PAST the consumed arg — not re-scan it (O(N^2))
|
|
173
|
+
const { path, dynamic } = resolveFeUrl(arg);
|
|
174
|
+
if (dynamic || !path) continue;
|
|
175
|
+
// method: verb from the hub call, else `method:` in the options object, else GET
|
|
176
|
+
let method = "GET";
|
|
177
|
+
if (m[1] && VERB_METHOD[m[1]]) method = VERB_METHOD[m[1]];
|
|
178
|
+
else { const mm = /method\s*:\s*['"`]?(GET|POST|PUT|DELETE|PATCH)/i.exec(text.slice(end, end + 220)); if (mm) method = mm[1].toUpperCase(); }
|
|
179
|
+
const line = text.slice(0, m.index).split(/\r?\n/).length;
|
|
180
|
+
calls.push({ method, path, line, raw: arg.slice(0, 80) });
|
|
181
|
+
}
|
|
182
|
+
return calls;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ───────────────────────────── surface scan ────────────────────────────────
|
|
186
|
+
export function scanApiSurface({ files = [], root, readText }) {
|
|
187
|
+
const calls = [], routes = [];
|
|
188
|
+
for (const rel of files) {
|
|
189
|
+
let text; try { text = readText(rel); } catch { continue; }
|
|
190
|
+
if (text == null) continue;
|
|
191
|
+
for (const c of detectFrontendCalls(rel, text)) calls.push({ ...c, file: rel });
|
|
192
|
+
for (const r of detectBackendRoutes(rel, text)) routes.push({ ...r, file: rel });
|
|
193
|
+
}
|
|
194
|
+
return { calls, routes };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─────────────────────────────── matching ──────────────────────────────────
|
|
198
|
+
// For each FE call, find the backend route it hits. A route is a candidate if
|
|
199
|
+
// its (possibly-prefix) path covers the call path; method must agree unless one
|
|
200
|
+
// side is ANY. Records method-divergent hits (path matches, verb differs) as a
|
|
201
|
+
// contract-drift signal rather than dropping them.
|
|
202
|
+
export function matchLinks({ calls, routes }) {
|
|
203
|
+
const links = [], unmatchedFE = [], divergent = [];
|
|
204
|
+
const hitRoute = new Set();
|
|
205
|
+
const routeKey = (r) => `${r.method} ${r.path} ${r.file}:${r.line}`;
|
|
206
|
+
// Segment each route ONCE, not once per (call, route) pair — the split was
|
|
207
|
+
// the dominant cost of the O(calls×routes) match.
|
|
208
|
+
const routeSegs = routes.map((r) => ({ r, segs: pathSegs(r.path) }));
|
|
209
|
+
for (const c of calls) {
|
|
210
|
+
const cs = pathSegs(c.path);
|
|
211
|
+
let pathHit = null, methodHit = null;
|
|
212
|
+
for (const { r, segs } of routeSegs) {
|
|
213
|
+
const ok = r.prefix ? prefixCover(segs, cs) : exactEq(segs, cs);
|
|
214
|
+
if (!ok) continue;
|
|
215
|
+
if (!pathHit) pathHit = r;
|
|
216
|
+
if (methodOk(c.method, r.method)) { methodHit = r; break; }
|
|
217
|
+
}
|
|
218
|
+
if (methodHit) {
|
|
219
|
+
links.push({ call: c, route: methodHit, confidence: methodHit.prefix ? "medium" : "high" });
|
|
220
|
+
hitRoute.add(routeKey(methodHit));
|
|
221
|
+
} else if (pathHit) {
|
|
222
|
+
divergent.push({ call: c, route: pathHit });
|
|
223
|
+
links.push({ call: c, route: pathHit, confidence: "low", methodDivergent: true });
|
|
224
|
+
hitRoute.add(routeKey(pathHit));
|
|
225
|
+
} else {
|
|
226
|
+
unmatchedFE.push(c);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const deadRoutes = routes.filter((r) => !hitRoute.has(routeKey(r)));
|
|
230
|
+
return { links, unmatchedFE, divergent, deadRoutes };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ─────────────────────────── overlay assembly ──────────────────────────────
|
|
234
|
+
// Turn matched links into cross-stack graph edges. The endpoint (METHOD +
|
|
235
|
+
// normalized path) is carried as an edge LABEL, not a node — one `http` edge
|
|
236
|
+
// per matched call runs directly from the frontend caller's enclosing symbol
|
|
237
|
+
// to the backend handler's enclosing symbol, so the two otherwise-disjoint
|
|
238
|
+
// trees become one connected graph without inventing a node kind that emit's
|
|
239
|
+
// function-centric model (and the viewer) would have to learn.
|
|
240
|
+
function enclosingIndex(symbols) {
|
|
241
|
+
const byFile = new Map();
|
|
242
|
+
for (const s of symbols) {
|
|
243
|
+
if (!s.file) continue;
|
|
244
|
+
if (!byFile.has(s.file)) byFile.set(s.file, []);
|
|
245
|
+
byFile.get(s.file).push(s);
|
|
246
|
+
}
|
|
247
|
+
return (file, line) => {
|
|
248
|
+
const list = byFile.get(file);
|
|
249
|
+
if (!list) return null;
|
|
250
|
+
let best = null, bestSpan = Infinity;
|
|
251
|
+
for (const s of list) {
|
|
252
|
+
if (s.kind === "File") continue; // never attribute to a file node — its
|
|
253
|
+
// block is only emitted in multi-file containers, so a ref to it can
|
|
254
|
+
// dangle; the caller falls back to `file:line` text (lenient in verify).
|
|
255
|
+
const a = s.line_start ?? 0, b = s.line_end ?? a;
|
|
256
|
+
if (line >= a && line <= b && b - a < bestSpan) { best = s; bestSpan = b - a; }
|
|
257
|
+
}
|
|
258
|
+
return best; // a function/test symbol, or null when none encloses the line
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function buildCrossStackOverlay({ symbols = [], files = [], readText }) {
|
|
263
|
+
const scan = scanApiSurface({ files, readText });
|
|
264
|
+
const { links, unmatchedFE, divergent, deadRoutes } = matchLinks(scan);
|
|
265
|
+
const enclosing = enclosingIndex(symbols);
|
|
266
|
+
const httpEdges = [];
|
|
267
|
+
const endpoints = new Set();
|
|
268
|
+
for (const { call, route, confidence, methodDivergent } of links) {
|
|
269
|
+
const method = route.method === "ANY" ? call.method : route.method;
|
|
270
|
+
const endpoint = `${method} ${normPath(route.path)}`;
|
|
271
|
+
endpoints.add(endpoint);
|
|
272
|
+
const feFn = enclosing(call.file, call.line);
|
|
273
|
+
// A route declaration often sits just ABOVE its handler (Spring's
|
|
274
|
+
// `@GetMapping` annotation, a decorator): if the exact line lands on the
|
|
275
|
+
// file node rather than a function, probe a few lines down for the handler.
|
|
276
|
+
let beFn = enclosing(route.file, route.line);
|
|
277
|
+
for (let d = 1; d <= 3 && !beFn; d++) beFn = enclosing(route.file, route.line + d);
|
|
278
|
+
httpEdges.push({
|
|
279
|
+
kind: "http",
|
|
280
|
+
from: feFn ? feFn.anchor : undefined,
|
|
281
|
+
from_text: feFn ? undefined : `${call.file}:${call.line}`,
|
|
282
|
+
to: beFn ? beFn.anchor : undefined,
|
|
283
|
+
to_text: beFn ? undefined : `${route.file}:${route.line}`,
|
|
284
|
+
endpoint,
|
|
285
|
+
confidence,
|
|
286
|
+
methodDivergent: methodDivergent || undefined,
|
|
287
|
+
site: { file: call.file, line: call.line },
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
edges: httpEdges,
|
|
292
|
+
audit: {
|
|
293
|
+
matched: links.length,
|
|
294
|
+
endpoints: endpoints.size,
|
|
295
|
+
divergent: divergent.map((d) => ({ fe: `${d.call.method} ${d.call.path}`, feSite: `${d.call.file}:${d.call.line}`, be: `${d.route.method} ${normPath(d.route.path)}`, beSite: `${d.route.file}:${d.route.line}` })),
|
|
296
|
+
unmatchedFE: unmatchedFE.map((c) => ({ call: `${c.method} ${c.path}`, site: `${c.file}:${c.line}` })),
|
|
297
|
+
deadRoutes: deadRoutes.map((r) => ({ route: `${r.method} ${normPath(r.path)}`, site: `${r.file}:${r.line}` })),
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// exported for unit tests
|
|
303
|
+
export const _internal = { normPath, detectSpringRoutes, detectRustRoutes, detectFrontendCalls, enclosingIndex };
|