@geml/geml 1.3.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/README.md CHANGED
@@ -46,28 +46,105 @@ Requires Node ≥ 22.
46
46
 
47
47
  ## CLI
48
48
 
49
+ The CLI is built around one question: can a single agent author and maintain an
50
+ entire `.geml` file from the command line — create, add, edit, delete, and copy
51
+ blocks in from other files? Three tests keep the command set honest:
52
+
53
+ - **Complete** — every step of a document's life has a verb, so an agent never
54
+ rewrites the whole file to change one block.
55
+ - **Ergonomic** — few flags, sensible defaults, and pipeline-friendly I/O, so
56
+ multi-step edits chain without ceremony.
57
+ - **Consistent** — behavior is uniform and predictable: name a target `#id` and
58
+ the content adopts it, every write is guarded, a file is edited in place while
59
+ `-` streams to stdout.
60
+
61
+ The command set borrows from two settled models rather than inventing one, and
62
+ they overlap where it counts. **A document is a table**: a block is a row, its
63
+ `#id` is the primary key (unique per document), and `[[#id]]`/`[t](#id)`/`[^id]`/
64
+ `data=#id` are foreign keys — so `get`/`add`/`set`/`delete` are
65
+ SELECT/INSERT/UPDATE/DELETE. **A block is also a resource** at a URI-like address
66
+ (`file#id`), named before the operation, the way REST puts the noun first. Both
67
+ models agree on a small orthogonal verb set instead of a method per use case, and
68
+ they agree on idempotence: `set` and `delete` are idempotent (deleting a missing
69
+ id is a no-op, so a retry is safe), `add` is not.
70
+
71
+ Where they diverge, each covers what the other cannot. The relational view names
72
+ the integrity rules: the write guard is a constraint check with rollback, and
73
+ `delete` merely *warning* about references it leaves dangling is a **deferred**
74
+ foreign-key check, not `ON DELETE RESTRICT`. It also explains `rename`, the verb
75
+ most open to "can this be cut" — a **primary-key update with a cascading
76
+ foreign-key rewrite**, irreducible because `delete` + `add` would leave every
77
+ reference dangling and nothing else rewrites references in bulk. HTTP has no
78
+ method for that at all. The REST view supplies what a database deliberately does
79
+ not: every call is **stateless** — no session, no current document, no config
80
+ file, no environment variable — which is what lets calls be retried,
81
+ parallelized, and piped.
82
+
83
+ Both pay off in undo. Because the verbs are orthogonal, each edit has exactly one
84
+ inverse, so `revert` never needs to know which verb made a change — it reconciles
85
+ a block to a revision in three cases (content changed, row missing, row extra)
86
+ and there is no fourth, while `rename` is its own inverse and needs no history at
87
+ all. A wider, RPC-shaped verb set (`replace`, `move`, `merge`, `split`, …) would
88
+ need a per-verb inverse and an operation log to pick one — an undo-stack engine
89
+ instead of three branches. Full rationale:
90
+ [`docs/design/specs/2026-07-24-geml-block-mutation-cli-design.md`](../docs/design/specs/2026-07-24-geml-block-mutation-cli-design.md).
91
+
49
92
  Every command reads a file path, or `-` for stdin. Exit codes: `0` ok ·
50
93
  `1` document/operation error · `2` usage error.
51
94
 
52
95
  ```sh
53
- geml get file.geml '#id' # print ONE block by id — a heading id yields its whole section
54
- geml set file.geml '#id' --from new.geml # replace just that block; re-parsed, refused if it breaks the doc
55
- geml check file.geml # validate only: diagnostics + exit code
56
- geml check --json file.geml # machine-readable: diagnostics array (or {"error":…} on IO failure)
57
- geml file.geml # full document-model JSON
58
- geml history <commit|verify|show|restore|log> file.geml [...] # .gemlhistory version sidecar
59
- geml revert file.geml '#id' [--to -1] # roll ONE block back to an earlier revision (-N | latest | id)
60
- geml render file.geml -o out.html # one self-contained, interactive HTML file
61
- geml export file.geml -o out.md # project to GitHub-Flavored Markdown (lossy; notes on stderr)
62
- geml convert in.md -o out.geml # Markdown -> GEML
63
- geml fmt file.geml # canonical re-format (idempotent)
64
- geml codemap <build|verify|render|serve|refresh|find|mcp> # your codebase's call graph as GEML docs
65
- geml --help | --version # --version --json prints {"parser","spec"}
96
+ geml doc.geml # document-model JSON (default --to json)
97
+ geml doc.geml --to md|html|geml # convert; geml notes.md -> GEML
98
+ geml get doc.geml ['#id'] # list addressable ids, or print one block (heading id = its section)
99
+ geml set doc.geml '#id' [--head|--body] [--in F[#src]] # replace a block's content (id kept)
100
+ geml add doc.geml (--append|--before #id|--after #id) [--in F[#src]] # insert a fragment
101
+ geml delete doc.geml '#id' ['#id2' …] # remove one or more blocks
102
+ geml rename doc.geml '#old' '#new' # rename an id + every reference to it
103
+ geml revert doc.geml '#id' [--rev -1] # undo a block: splice / resurrect / remove
104
+ geml check doc.geml [--root <dir>] # validate only: diagnostics + exit code (--json for the array)
105
+ geml history <commit|verify|show|restore|log> doc.geml [...] # .gemlhistory version sidecar
106
+ geml codemap <build|verify|render|serve|refresh|find|mcp> # your codebase's call graph as GEML docs
107
+ geml --help | --version # --version --json prints {"parser","spec"}
66
108
  ```
67
109
 
68
- The agent loop: `geml get` a block → edit it → `geml set` (guarded splice)
110
+ The agent loop: `geml get` a block → `set`/`add`/`delete`/`rename` it
69
111
  `geml check` → `geml history commit` — small, precise, verifiable edits.
70
112
 
113
+ Conversion is one entry — `geml <file> [--to json|html|md|geml]`; the input
114
+ format is inferred (`--from` overrides > extension > GEML), the target is `--to`
115
+ (default: GEML → JSON, Markdown → GEML), and `-o` names the output path.
116
+
117
+ `set` and `add` take their content from `--in F` (F's block whose id equals the
118
+ target), `--in F#src` (F's block `#src`), or stdin (raw bytes). `set` **replaces
119
+ a whole block** and normalizes the content's id to the target — so you can fork
120
+ any block into this slot without hand-editing its id (`--head` swaps just the
121
+ head line, `--body` just the body). `add` **inserts a fragment** (one or more
122
+ blocks, or bare prose) at `--append` / `--before #id` / `--after #id`, keeping
123
+ the content's own ids (a collision is refused). `delete` removes one or more
124
+ ids; `rename` rewrites an id's declaration and every reference to it.
125
+
126
+ Mutations (`set`/`add`/`delete`/`rename`) write the **whole updated document**:
127
+ in place when the input is a file, or to **stdout** when the input is `-`; `-o`
128
+ redirects the write (`-o -` forces stdout), so edits pipe cleanly. Every write
129
+ is guarded — re-parsed and refused if it would break the document or drop an id
130
+ (a reference left dangling by `delete` is a warning, not a refusal; `geml check`
131
+ flags it later).
132
+
133
+ Undo is `revert`, which reconciles one block to a past revision (`--rev`, default
134
+ `-1`): it **splices** back changed content, **resurrects** a deleted block (placed
135
+ by its old neighbours, or `--append`/`--before`/`--after`), or **removes** a block
136
+ that did not exist then. So each forward edit has an inverse:
137
+
138
+ | forward edit | undo |
139
+ |---|---|
140
+ | `set #id` | `revert #id` (splice) |
141
+ | `delete #id` | `revert #id` (resurrect) |
142
+ | `add #id` | `revert #id` (remove) — or `delete #id` |
143
+ | `rename #old #new` | `rename #new #old` (self-inverse) |
144
+
145
+ `revert` reads the `.gemlhistory` sidecar, so `set`/`delete`/`add` undo needs a
146
+ prior `geml history commit`; `rename` is its own inverse and needs no history.
147
+
71
148
  A **heading's** `#id` addresses its whole **section** — the heading line through
72
149
  the line before the next heading of the same-or-higher level — so the prose
73
150
  under a heading is block-editable with no extra syntax.
package/codemap/build.mjs CHANGED
@@ -46,6 +46,7 @@ import { loadOrSeedFoldings } from "./foldings.mjs";
46
46
  import { detectEntries } from "./entries.mjs";
47
47
  import { discoverModuleRoots } from "./normalize.mjs";
48
48
  import { recipeFingerprint, trustRecipe, RECIPE_VERSION } from "./recipe-trust.mjs";
49
+ import { buildCrossStackOverlay } from "./cross-stack.mjs";
49
50
 
50
51
  const args = process.argv.slice(2);
51
52
  const flag = (name, dflt) => {
@@ -407,11 +408,40 @@ if (excludedCount) {
407
408
  );
408
409
  }
409
410
 
411
+ // Cross-stack API links: detect frontend HTTP call sites + backend route
412
+ // declarations across the merged graph and append `http` edges wiring each
413
+ // caller's enclosing symbol to the handler's — the seam that joins a full
414
+ // stack's two otherwise-disjoint trees. Kept OUT of the verified `calls`
415
+ // relation (own edge kind + confidence). Best-effort: a detector failure must
416
+ // never sink an otherwise-good build.
417
+ try {
418
+ const rootAbs = resolve(root);
419
+ const scanFiles = [...new Set(symbols.map((s) => s.file))];
420
+ const { edges: httpEdges, audit } = buildCrossStackOverlay({
421
+ symbols,
422
+ files: scanFiles,
423
+ readText: (rel) => { try { return readFileSync(join(rootAbs, ...rel.split("/")), "utf8"); } catch { return null; } },
424
+ });
425
+ for (const e of httpEdges) edges.push(e);
426
+ if (httpEdges.length) {
427
+ console.error(
428
+ `cross-stack: ${httpEdges.length} api link(s) across ${audit.endpoints} endpoint(s)`
429
+ + (audit.divergent.length ? `, ${audit.divergent.length} method-divergent` : "")
430
+ + (audit.deadRoutes.length ? `, ${audit.deadRoutes.length} uncalled route(s)` : ""));
431
+ mkdirSync(join(outDir, "_index"), { recursive: true });
432
+ const auditPath = join(outDir, "_index", "cross-stack.json");
433
+ const auditContent = JSON.stringify(audit, null, 2) + "\n";
434
+ if (!existsSync(auditPath) || readFileSync(auditPath, "utf8") !== auditContent) writeFileSync(auditPath, auditContent);
435
+ }
436
+ } catch (e) {
437
+ console.error(`cross-stack overlay skipped: ${e.message}`);
438
+ }
439
+
410
440
  // Exchange format on disk — the layer contract (§3). Deterministic order so
411
441
  // the jsonl files diff cleanly across builds.
412
442
  symbols.sort((a, b) => a.anchor.localeCompare(b.anchor));
413
443
  edges.sort((a, b) =>
414
- a.from.localeCompare(b.from) || a.kind.localeCompare(b.kind)
444
+ String(a.from ?? a.from_text ?? "").localeCompare(String(b.from ?? b.from_text ?? "")) || a.kind.localeCompare(b.kind)
415
445
  || String(a.to ?? a.to_text).localeCompare(String(b.to ?? b.to_text)));
416
446
  mkdirSync(buildDir, { recursive: true });
417
447
  const jsonl = (rows) => rows.map((r) => JSON.stringify(r)).join("\n") + "\n";
@@ -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 };
package/codemap/emit.mjs CHANGED
@@ -177,6 +177,27 @@ export function emit({ symbols, edges, outDir, buildDir, repoName, container = "
177
177
  unresBySym.get(e.from).add(e.to_text);
178
178
  }
179
179
  }
180
+
181
+ // ---- edges (stage B: cross-stack `http` links) ----
182
+ // Heuristic frontend-caller → backend-handler links (codemap/cross-stack.mjs),
183
+ // emitted into their OWN #api-calls / #api-served-by tables so they never mix
184
+ // with the verified `calls` graph. Either endpoint may be unresolved (a call
185
+ // or route outside any indexed function) — then the *_text file:line rides in
186
+ // the cell instead of a #ref.
187
+ const httpOut = new Map(); // FE-fn anchor -> [http edge]
188
+ const httpIn = new Map(); // BE-fn anchor -> [http edge]
189
+ for (const e of edges) {
190
+ if (e.kind !== "http") continue;
191
+ if (e.from !== undefined && docOfAnchor.has(e.from)) {
192
+ if (!httpOut.has(e.from)) httpOut.set(e.from, []);
193
+ httpOut.get(e.from).push(e);
194
+ }
195
+ if (e.to !== undefined && docOfAnchor.has(e.to)) {
196
+ if (!httpIn.has(e.to)) httpIn.set(e.to, []);
197
+ httpIn.get(e.to).push(e);
198
+ }
199
+ }
200
+
180
201
  const isLeaf = (s) =>
181
202
  (s.kind === "Function" || s.kind === "Test") &&
182
203
  !(outCalls.get(s.anchor) > 0) && (inBySym.get(s.anchor)?.length ?? 0) >= 1;
@@ -325,6 +346,33 @@ export function emit({ symbols, edges, outDir, buildDir, repoName, container = "
325
346
  const unTable = csv("unresolved", ["from", "to"], unRows, " hidden");
326
347
  if (unTable) chunks.push(unTable);
327
348
 
349
+ // #api-calls — frontend functions in this doc that hit a backend endpoint.
350
+ // `to` points cross-tree to the handler's doc#id (the two trees, joined);
351
+ // `endpoint` is the METHOD + normalized path; `confidence` carries a
352
+ // `method-mismatch` marker when the verb disagrees (contract drift).
353
+ const apiOutRows = [];
354
+ for (const s of c.methods) {
355
+ for (const e of (httpOut.get(s.anchor) ?? []).slice().sort((x, y) => x.endpoint.localeCompare(y.endpoint) || String(x.to ?? x.to_text).localeCompare(String(y.to ?? y.to_text)))) {
356
+ const target = e.to !== undefined && docOfAnchor.has(e.to) ? refTo(e.to, doc) : csvCell(e.to_text ?? "?");
357
+ const conf = e.methodDivergent ? `${e.confidence} method-mismatch` : e.confidence;
358
+ apiOutRows.push([`#${idOf.get(s.anchor)}`, target, csvCell(e.endpoint), conf]);
359
+ }
360
+ }
361
+ const apiCallsTable = csv("api-calls", ["from", "to", "endpoint", "confidence"], apiOutRows);
362
+ if (apiCallsTable) chunks.push(apiCallsTable);
363
+
364
+ // #api-served-by — backend handlers in this doc reached from the frontend.
365
+ const apiInRows = [];
366
+ for (const s of c.methods) {
367
+ for (const e of (httpIn.get(s.anchor) ?? []).slice().sort((x, y) => x.endpoint.localeCompare(y.endpoint) || (x.site?.file ?? "").localeCompare(y.site?.file ?? "") || (x.site?.line ?? 0) - (y.site?.line ?? 0))) {
368
+ const from = e.from !== undefined && docOfAnchor.has(e.from) ? refTo(e.from, doc) : csvCell(e.from_text ?? "?");
369
+ const site = e.site ? `${e.site.file}:${e.site.line}` : "";
370
+ apiInRows.push([from, `#${idOf.get(s.anchor)}`, csvCell(e.endpoint), csvCell(site)]);
371
+ }
372
+ }
373
+ const apiInTable = csv("api-served-by", ["from", "to", "endpoint", "site"], apiInRows);
374
+ if (apiInTable) chunks.push(apiInTable);
375
+
328
376
  writeIfChanged(doc, chunks.join("\n"));
329
377
  indexRows.push({ module: dispLabel, doc, methods: c.methods.length, entries: entries.length, tests: testCount });
330
378
  }
@@ -102,7 +102,10 @@ export function declaredModuleRoots(root, { readFile = readFileSync } = {}) {
102
102
  // is a relative directory path.
103
103
  const pom = read("pom.xml");
104
104
  if (pom) {
105
- for (const m of pom.matchAll(/<module>\s*([^<]+?)\s*<\/module>/g)) {
105
+ // `[^<]*` (linear) + trim, NOT `\s*([^<]+?)\s*` — the latter's ambiguous
106
+ // whitespace partition backtracks super-linearly on a crafted root pom.xml
107
+ // (`<module>` + a long whitespace run and no close), hanging every build.
108
+ for (const m of pom.matchAll(/<module>([^<]*)<\/module>/g)) {
106
109
  const dir = m[1].trim().replace(/\\/g, "/").replace(/\/+$/, "");
107
110
  if (dir) roots.add(dir);
108
111
  }
@@ -80,6 +80,10 @@ for (const f of files) {
80
80
 
81
81
  // ---- pass 2: codemap profile references ----
82
82
  const REF_TABLES = new Set(["calls", "called-by", "ref-by"]);
83
+ // Cross-stack link tables: `from`/`to` may be a #ref (resolved cross-tree
84
+ // link — checked) OR plain `file:line` text (a call/route outside any indexed
85
+ // function — tolerated, nothing to resolve).
86
+ const LINK_TABLES = new Set(["api-calls", "api-served-by"]);
83
87
  const relDoc = (f) => relative(rootDir, f).replace(/\\/g, "/");
84
88
  const docs = new Map(); // relPath -> { ids:Set, blocks }
85
89
  const collectIds = (blocks, ids) => {
@@ -101,11 +105,11 @@ const err = (doc, where, msg) => {
101
105
  refErrors++;
102
106
  console.error(`REF ${doc} ${where}: ${msg}`);
103
107
  };
104
- const checkRef = (fromDoc, where, ref) => {
108
+ const checkRef = (fromDoc, where, ref, lenient = false) => {
105
109
  ref = String(ref).trim();
106
- if (!ref) return err(fromDoc, where, "empty reference cell");
110
+ if (!ref) return lenient ? undefined : err(fromDoc, where, "empty reference cell");
107
111
  const h = ref.indexOf("#");
108
- if (h < 0) return err(fromDoc, where, `not a reference: \`${ref}\``);
112
+ if (h < 0) return lenient ? undefined : err(fromDoc, where, `not a reference: \`${ref}\``);
109
113
  let targetDoc = fromDoc;
110
114
  if (h > 0) targetDoc = posix.normalize(posix.join(posix.dirname(fromDoc), ref.slice(0, h)));
111
115
  const id = ref.slice(h + 1);
@@ -119,13 +123,14 @@ const checkRef = (fromDoc, where, ref) => {
119
123
  for (const [docPath, { blocks }] of docs) {
120
124
  for (const b of blocks) {
121
125
  if (b.kind !== "block") continue;
122
- if (b.type === "table" && REF_TABLES.has(b.id) && b.table) {
126
+ if (b.type === "table" && (REF_TABLES.has(b.id) || LINK_TABLES.has(b.id)) && b.table) {
127
+ const lenient = LINK_TABLES.has(b.id);
123
128
  const fromCol = b.table.columns.indexOf("from");
124
129
  const toCol = b.table.columns.indexOf("to");
125
130
  if (fromCol < 0 || toCol < 0) { err(docPath, `#${b.id}`, "missing from/to columns"); continue; }
126
131
  b.table.rows.forEach((row, i) => {
127
- checkRef(docPath, `#${b.id} row ${i + 1} from`, row[fromCol]?.text ?? "");
128
- checkRef(docPath, `#${b.id} row ${i + 1} to`, row[toCol]?.text ?? "");
132
+ checkRef(docPath, `#${b.id} row ${i + 1} from`, row[fromCol]?.text ?? "", lenient);
133
+ checkRef(docPath, `#${b.id} row ${i + 1} to`, row[toCol]?.text ?? "", lenient);
129
134
  });
130
135
  }
131
136
  if (b.type === "meta" && b.data?.entry) {
@@ -0,0 +1 @@
1
+ export declare function normalizeBlockId(blockSrc: string, newId: string): string;