@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 +91 -14
- package/codemap/build.mjs +31 -1
- package/codemap/cross-stack.mjs +303 -0
- package/codemap/emit.mjs +48 -0
- package/codemap/normalize.mjs +4 -1
- package/codemap/verify.mjs +11 -6
- package/dist/block-edit.d.ts +1 -0
- package/dist/block-edit.js +112 -0
- 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 +904 -213
- 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.d.ts +2 -2
- package/dist/render.js +126 -43
- package/dist/table.d.ts +2 -0
- package/dist/table.js +11 -11
- package/package.json +62 -63
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
|
|
54
|
-
geml
|
|
55
|
-
geml
|
|
56
|
-
geml
|
|
57
|
-
geml
|
|
58
|
-
geml
|
|
59
|
-
geml
|
|
60
|
-
geml
|
|
61
|
-
geml
|
|
62
|
-
geml
|
|
63
|
-
geml
|
|
64
|
-
geml
|
|
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 →
|
|
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
|
}
|
package/codemap/normalize.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/codemap/verify.mjs
CHANGED
|
@@ -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;
|