@geml/geml 1.3.2 → 1.4.2

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