@geml/geml 1.4.3 → 1.4.5

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
@@ -23,7 +23,7 @@ print("hi")
23
23
 
24
24
  - **Addressable** — every block has an `#id`; `geml get` / `geml set '#id'`
25
25
  read or patch one section without re-emitting the whole file (on this repo's
26
- own spec, ~**31× less context** than shipping the whole document).
26
+ own spec, ~**66× less context** than shipping the whole document).
27
27
  - **Verifiable** — references are checked at build time (a dangling `#id` is an
28
28
  error, not a silent dead link), and the parser emits a document-model JSON
29
29
  with a `diagnostics` array, so agents and CI get a structured pass/fail signal.
@@ -159,6 +159,37 @@ attributes (caption, compute, …) without touching the body. Convention: keep
159
159
  the document title in `=== meta` (`title = "…"`), not an H1 — a lone top-level
160
160
  `#` section is the whole document, the telltale that it is really a title.
161
161
 
162
+ ## MCP Server
163
+
164
+ This package includes a standard Model Context Protocol (MCP) server that exposes GEML document CRUD operations. It runs locally and supports Windows, macOS, and Linux.
165
+
166
+ To connect it to an MCP-compatible client, provide the `npx` execution command and specify the `--root` argument (the directory containing your `.geml` files).
167
+
168
+ ### Claude Desktop
169
+ Add to your `claude_desktop_config.json`:
170
+ ```json
171
+ {
172
+ "mcpServers": {
173
+ "geml": {
174
+ "command": "npx",
175
+ "args": [
176
+ "-y",
177
+ "@geml/geml@latest",
178
+ "mcp",
179
+ "--root",
180
+ "/absolute/path/to/your/docs"
181
+ ]
182
+ }
183
+ }
184
+ }
185
+ ```
186
+
187
+ ### Claude Code / CLI Clients
188
+ Run the following command to add the server:
189
+ ```sh
190
+ /mcp add npx -y @geml/geml@latest mcp --root /absolute/path/to/your/docs
191
+ ```
192
+
162
193
  ## Library
163
194
 
164
195
  ```js
package/codemap/find.mjs CHANGED
@@ -6,11 +6,16 @@
6
6
  // document + block id to open, and the true source location. NO browser: pure
7
7
  // stdout, so it pipes/greps. `dir` defaults to ./.geml-code-graph.
8
8
  //
9
- // Same index the MCP `resolve_name` tool and the viewer search box use
9
+ // Same index the MCP `geml_codemap_search` tool and the viewer search box use
10
10
  // (_index/name-lookup.json); a name with several rows is real ambiguity
11
11
  // (overloads / same short name across classes) — every candidate is printed.
12
- import { readFileSync, existsSync } from "node:fs";
12
+ //
13
+ // The matching rule and the src lookup are IMPORTED, not repeated here: this
14
+ // command and `geml_codemap_search` answer the same question, and two copies of
15
+ // "what counts as a match" would drift the moment one of them is tuned.
16
+ import { existsSync } from "node:fs";
13
17
  import { join } from "node:path";
18
+ import { searchNames, srcOf } from "./mcp-server.mjs";
14
19
 
15
20
  // `find x | head` closes stdout after a few lines — that is normal pipe
16
21
  // usage, not an error (POSIX would kill us silently with SIGPIPE; Windows
@@ -30,32 +35,13 @@ if (!existsSync(lookupPath)) {
30
35
  console.error(`no name-lookup at ${lookupPath} — build the codemap first (geml codemap build)`);
31
36
  process.exit(1);
32
37
  }
33
- const lookup = JSON.parse(readFileSync(lookupPath, "utf8"));
34
- const q = query.toLowerCase();
35
- const names = Object.keys(lookup).filter((n) => n.toLowerCase().includes(q)).sort();
38
+ const { names, lookup } = searchNames(dir, query);
36
39
  if (!names.length) { console.error(`no symbol matching "${query}"`); process.exit(1); }
37
40
 
38
- // src= lives on the block header line in the doc; read each doc once, index by id.
39
- const docCache = new Map(); // doc -> Map(id -> src)
40
- const srcOf = (doc, id) => {
41
- if (!docCache.has(doc)) {
42
- const map = new Map();
43
- try {
44
- const text = readFileSync(join(dir, doc), "utf8");
45
- // src= may be quoted or a bare token (path#Lx-y, no spaces).
46
- const re = /\{#([A-Za-z0-9._-]+)\b[^}]*?\bsrc=(?:"([^"]+)"|([^\s}]+))/g;
47
- let m;
48
- while ((m = re.exec(text))) map.set(m[1], m[2] || m[3]);
49
- } catch { /* doc unreadable — skip src */ }
50
- docCache.set(doc, map);
51
- }
52
- return docCache.get(doc).get(id) || "";
53
- };
54
-
55
41
  let n = 0;
56
42
  for (const name of names) {
57
43
  for (const c of lookup[name]) {
58
- const src = srcOf(c.doc, c.id);
44
+ const src = srcOf(dir, c.doc, c.id);
59
45
  process.stdout.write(`${name}\t${c.doc}#${c.id}${src ? `\t${src}` : ""}\n`);
60
46
  n++;
61
47
  }
@@ -1,24 +1,41 @@
1
1
  #!/usr/bin/env node
2
- // geml-code-graph MCP server — the thin consumption wrapper of DESIGN §8 (P2).
3
- // Three navigation tools over a built graph/ directory, each "give an
4
- // identifier, get readable text back" (the original proposal's 2.6):
5
- // resolve_name name -> candidate anchors (doc + block id)
6
- // open_symbol doc + id -> that symbol's block, verbatim
7
- // get_backlinks doc + id -> the symbol's backlink block (who calls it)
2
+ // geml-code-graph MCP tools — the thin consumption wrapper of DESIGN §8 (P2).
3
+ // Navigation over a built graph/ directory, each "give an identifier, get
4
+ // readable text back" (the original proposal's 2.6). Every name mirrors its CLI
5
+ // path — `geml codemap <sub>` -> `geml_codemap_<sub>` so one vocabulary covers
6
+ // both surfaces:
7
+ // geml_codemap_search name or substring -> candidates (start here)
8
+ // geml_codemap_list no arg -> modules; a module -> its symbols
9
+ // geml_codemap_node doc + id -> that symbol's block, verbatim
10
+ // geml_codemap_callchain doc + id -> several hops, either direction
8
11
  //
9
- // Zero dependencies: newline-delimited JSON-RPC 2.0 over stdio (the MCP stdio
10
- // transport). Register e.g.:
11
- // claude mcp add geml-code-graph -e GEML_GRAPH_DIR=/abs/path/to/graph \
12
- // -- geml codemap mcp
13
- // The graph dir comes from GEML_GRAPH_DIR or a per-call `graph_dir` argument.
12
+ // The four cover reading the graph, not producing it: building and refreshing
13
+ // stay CLI-only on purpose (both run indexers or recorded shell steps, which is
14
+ // not something a model should trigger), and `codemap serve` renders HTML for a
15
+ // human, which a model cannot consume. See DESIGN §8.
14
16
  //
15
- // The dispatch is exported (and the stdio wiring below is main-module guarded)
16
- // so the test suite can drive it in-process; the CLI dispatcher always runs
17
- // this file as a child's MAIN module, where nothing changes.
17
+ // This file is a LIBRARY, not a server entry point. `geml codemap mcp` was
18
+ // removed: `geml mcp --root <dir>` serves these three tools next to the
19
+ // document tools, importing the TOOLS table below rather than duplicating it,
20
+ // so a client registers one server instead of two.
21
+ //
22
+ // claude mcp add geml -- geml mcp --root /abs/path/to/repo
23
+ //
24
+ // `graphDirOf` still honours GEML_GRAPH_DIR and a per-call `graph_dir`. Nothing
25
+ // reaches those defaults through `geml mcp`, which resolves the directory
26
+ // against its own --root before calling a tool — a client-chosen directory is
27
+ // safe only on a process that cannot write, and that one can.
28
+ //
29
+ // Zero dependencies; the handlers speak newline-delimited JSON-RPC 2.0 (the MCP
30
+ // stdio transport) and `handleLine` is exported so both `geml mcp` and the test
31
+ // suite drive it in-process.
18
32
  import { readFileSync, existsSync, realpathSync } from "node:fs";
19
33
  import { join, resolve, dirname, sep } from "node:path";
20
- import { fileURLToPath, pathToFileURL } from "node:url";
21
- import { createInterface } from "node:readline";
34
+ import { fileURLToPath } from "node:url";
35
+ // Where the sources live is serve.mjs's rule (the recipe's `root`, else the
36
+ // graph dir's parent). Imported, not restated: two copies would drift and the
37
+ // source panel and this tool would disagree about which file a symbol is in.
38
+ import { resolveSrcRoot } from "./serve.mjs";
22
39
 
23
40
  // blockSpans from the reference parser (its CLI entry is guarded, so importing
24
41
  // is side-effect free). Falls back with a clear error if the parser isn't built.
@@ -54,73 +71,315 @@ export const readBlock = (graphDir, doc, id) => {
54
71
  return splitLines(source).slice(span.start, span.end).join("");
55
72
  };
56
73
 
74
+ // ---- CSV-table reading (profile §4) -----------------------------------------
75
+ // Serves the edge tables — `#calls` is `from, to, kind, confidence`, `#called-by`
76
+ // is `from, to, kind, site`, cells being `#id` (this document) or `doc.geml#id`
77
+ // (a sibling) — and the index's `#modules`, which has the same shape: a fence
78
+ // line, a header row, then data.
79
+ const tableRows = (graphDir, doc, tableId) => {
80
+ let raw;
81
+ try { raw = readBlock(graphDir, doc, tableId); } catch { return []; }
82
+ // readBlock returns the whole block: fence line, header row, data, close.
83
+ return raw.split("\n").slice(2)
84
+ .filter((l) => l.trim() && !l.trimStart().startsWith("==="))
85
+ .map((l) => l.split(",").map((c) => c.trim()));
86
+ };
87
+
88
+ // A from/to cell as a target, or null when it is plain text (an `#unresolved`
89
+ // target or a `file:line` site — profile §4 says those are unchecked).
90
+ const refTarget = (cell, fromDoc) => {
91
+ const m = /^([^#]*)#(.+)$/.exec(cell ?? "");
92
+ return m ? { doc: m[1] || fromDoc, id: m[2] } : null;
93
+ };
94
+
95
+ // One hop. `callees` reads this document's out-edges; `callers` reads the
96
+ // in-edge table, which the generator aggregates per document, so both
97
+ // directions are a single read of the symbol's OWN document.
98
+ const neighbours = (graphDir, doc, id, direction) => {
99
+ const table = direction === "callers" ? "called-by" : "calls";
100
+ const [self, other] = direction === "callers" ? [1, 0] : [0, 1];
101
+ const want = `#${id.replace(/^#/, "")}`;
102
+ const out = [];
103
+ const seen = new Set();
104
+ for (const row of tableRows(graphDir, doc, table)) {
105
+ if (row[self] !== want) continue;
106
+ const t = refTarget(row[other], doc);
107
+ // A symbol called from three sites yields three identical rows; the caller
108
+ // wants the shape of the graph, not the call count.
109
+ if (!t) continue;
110
+ const key = `${t.doc}#${t.id}`;
111
+ if (seen.has(key)) continue;
112
+ seen.add(key);
113
+ out.push({ ...t, kind: row[2] || "call" });
114
+ }
115
+ return out;
116
+ };
117
+
118
+ // The symbol index: name -> [{ anchor, doc, id }].
119
+ const loadLookup = (graphDir) => {
120
+ const lookupPath = join(graphDir, "_index/name-lookup.json");
121
+ if (!existsSync(lookupPath)) throw new Error(`no name-lookup at ${lookupPath} — build the graph first`);
122
+ return JSON.parse(readFileSync(lookupPath, "utf8"));
123
+ };
124
+
125
+ // ---- name search (shared with `geml codemap find`) --------------------------
126
+ // One definition of "matches", so the CLI and the tool cannot answer the same
127
+ // query differently: case-insensitive substring over the name index, sorted.
128
+ // `exact` narrows to the whole name — the former `resolve_name`, now a flag,
129
+ // because two tools differing only in strictness is two chances to pick wrong.
130
+ export const searchNames = (graphDir, query, exact = false) => {
131
+ const lookup = loadLookup(graphDir);
132
+ if (exact) return { names: lookup[query] ? [query] : [], lookup };
133
+ const q = query.toLowerCase();
134
+ return { names: Object.keys(lookup).filter((n) => n.toLowerCase().includes(q)).sort(), lookup };
135
+ };
136
+
137
+ // The index's `#modules` table: module, doc, methods, entries, tests.
138
+ const moduleRows = (graphDir) =>
139
+ tableRows(graphDir, "index.geml", "modules").filter((r) => r[0] && r[1]);
140
+
141
+ // `src=` lives on the block header line; read each doc once and index by id.
142
+ // The id charset excludes `.`, and src may be quoted or a bare token.
143
+ const srcCache = new Map(); // `${graphDir}\0${doc}` -> Map(id -> src)
144
+ export const srcOf = (graphDir, doc, id) => {
145
+ const key = `${graphDir}\0${doc}`;
146
+ if (!srcCache.has(key)) {
147
+ const map = new Map();
148
+ try {
149
+ const text = readFileSync(join(graphDir, doc), "utf8");
150
+ const re = /\{#([A-Za-z0-9._-]+)\b[^}]*?\bsrc=(?:"([^"]+)"|([^\s}]+))/g;
151
+ let m;
152
+ while ((m = re.exec(text))) map.set(m[1], m[2] || m[3]);
153
+ } catch { /* doc unreadable — a src is a nicety, not the answer */ }
154
+ srcCache.set(key, map);
155
+ }
156
+ return srcCache.get(key).get(id) || "";
157
+ };
158
+
159
+ // ---- reading the real source a `src=` pointer names -------------------------
160
+ // Same two rules `codemap serve` uses for its source panel, imported rather
161
+ // than restated: WHERE the sources are (the recipe's `root`, else the graph
162
+ // dir's parent) and that a file is only served when it really sits under that
163
+ // root, symlinks resolved. What differs is the slice — serve hands the browser
164
+ // the whole file and lets the viewer highlight the range; a model wants the
165
+ // symbol's own lines and nothing else.
166
+ const MAX_SOURCE_LINES = 400;
167
+
168
+ // `geml mcp` also confines every path to its own --root. The source root is
169
+ // derived from `_index/refresh.json`, a file inside the graph — data, not
170
+ // configuration this process chose — so a hand-edited `root: "../../.."` must
171
+ // not reach outside the server's root. Unset (a bare library use) = no extra
172
+ // bound beyond the source root itself.
173
+ let SOURCE_BOUND = null;
174
+ export const confineSourceTo = (dir) => { SOURCE_BOUND = dir ? realpathSync(dir) : null; };
175
+
176
+ const underRoot = (real, root) => real === root || real.startsWith(root + sep);
177
+
178
+ /** The `src=` attribute of a block header: `path` or `path#Lstart-end`. */
179
+ const srcPointer = (block) => {
180
+ const m = /\bsrc=(?:"([^"]+)"|([^\s}]+))/.exec(block.split("\n", 1)[0] ?? "");
181
+ if (!m) return null;
182
+ const raw = m[1] || m[2];
183
+ const range = /^(.*?)#L(\d+)(?:-(\d+))?$/.exec(raw);
184
+ return range
185
+ ? { path: range[1], start: Number(range[2]), end: Number(range[3] ?? range[2]) }
186
+ : { path: raw, start: null, end: null };
187
+ };
188
+
189
+ export const readSource = (graphDir, block) => {
190
+ const ptr = srcPointer(block);
191
+ if (!ptr) return "(no `src=` on this block — nothing to read; edge tables and index blocks have no source)";
192
+ const srcRoot = resolveSrcRoot(graphDir);
193
+ let realRoot;
194
+ try { realRoot = realpathSync(srcRoot); } catch { return `(source root ${srcRoot} does not exist — the sources are not next to the graph on this machine)`; }
195
+ if (SOURCE_BOUND && !underRoot(realRoot, SOURCE_BOUND)) {
196
+ return `(refused: the graph's recorded source root ${srcRoot} is outside this server's --root)`;
197
+ }
198
+ let real;
199
+ try { real = realpathSync(resolve(realRoot, ptr.path)); } catch { return `(no such source file: ${ptr.path})`; }
200
+ if (!underRoot(real, realRoot) || (SOURCE_BOUND && !underRoot(real, SOURCE_BOUND))) {
201
+ return `(refused: ${ptr.path} resolves outside the source root)`;
202
+ }
203
+ let text;
204
+ try { text = readFileSync(real, "utf8"); } catch (e) { return `(cannot read ${ptr.path}: ${e.message})`; }
205
+ const all = text.split("\n");
206
+ const start = ptr.start ?? 1;
207
+ const end = Math.min(ptr.end ?? all.length, start + MAX_SOURCE_LINES - 1);
208
+ const slice = all.slice(start - 1, end);
209
+ if (!slice.length) return `(${ptr.path} has no lines ${start}-${end} — the graph is stale; rebuild with \`geml codemap build\`)`;
210
+ const cut = (ptr.end ?? all.length) > end ? `\n… truncated at ${MAX_SOURCE_LINES} lines` : "";
211
+ // Line numbers so a model can cite `file:line` without recounting.
212
+ const body = slice.map((l, i) => `${String(start + i).padStart(5)} ${l}`).join("\n");
213
+ return `--- ${ptr.path}:${start}-${end} ---\n${body}${cut}`;
214
+ };
215
+
57
216
  export const TOOLS = [
58
217
  {
59
- name: "resolve_name",
60
- description: "Find a function/class by name in the code graph. Returns candidate anchors with the document and block id to open. Multiple candidates = real ambiguity (overloads/same name) — inspect each, never assume.",
218
+ name: "geml_codemap_search",
219
+ description:
220
+ "Find symbols in the code graph BY NAME — case-insensitive substring by default, or the whole name with `exact: true` when you already know it. Returns `name doc#id src` per candidate, the same index the CLI's `geml codemap find` and the viewer's search box use, and `doc`+`id` are what geml_codemap_node and geml_codemap_callchain take. Start here on an unfamiliar codebase (or geml_codemap_list to browse by module). Several candidates for one name is real ambiguity — overloads, or the same name in two modules — so inspect each rather than assuming the first.",
61
221
  inputSchema: {
62
222
  type: "object",
63
223
  properties: {
64
- name: { type: "string", description: "Exact symbol name (function/class short name)" },
224
+ query: { type: "string", description: "The symbol name, or a substring of it (e.g. `token` matches issueToken and TokenStore)" },
225
+ exact: { type: "boolean", description: "Match the WHOLE name instead of a substring (default false)" },
226
+ limit: { type: "number", description: "Maximum candidates to return (default 50). Narrow the query rather than raising this." },
65
227
  graph_dir: { type: "string", description: "Graph directory (default: $GEML_GRAPH_DIR or ./.geml-code-graph)" },
66
228
  },
67
- required: ["name"],
229
+ required: ["query"],
68
230
  },
69
231
  run: (args) => {
70
- const lookupPath = join(graphDirOf(args), "_index/name-lookup.json");
71
- if (!existsSync(lookupPath)) throw new Error(`no name-lookup at ${lookupPath} — build the graph first`);
72
- const lookup = JSON.parse(readFileSync(lookupPath, "utf8"));
73
- const hits = lookup[args.name];
74
- if (!hits?.length) return `no symbol named \`${args.name}\` in the graph`;
75
- return JSON.stringify(hits, null, 1);
232
+ const graphDir = graphDirOf(args);
233
+ const query = String(args.query ?? "");
234
+ if (!query) throw new Error("`query` is required");
235
+ const exact = args.exact === true;
236
+ const { names, lookup } = searchNames(graphDir, query, exact);
237
+ if (!names.length) {
238
+ return exact
239
+ ? `no symbol named \`${query}\` in the graph — drop \`exact\` to match substrings`
240
+ : `no symbol matching "${query}" in the graph`;
241
+ }
242
+ const limit = Math.max(1, Math.min(Number(args.limit) || 50, 500));
243
+ const lines = [];
244
+ let total = 0;
245
+ for (const name of names) {
246
+ for (const c of lookup[name]) {
247
+ total++;
248
+ if (lines.length < limit) {
249
+ const src = srcOf(graphDir, c.doc, c.id);
250
+ lines.push(`${name}\t${c.doc}#${c.id}${src ? `\t${src}` : ""}`);
251
+ }
252
+ }
253
+ }
254
+ const tail = total > lines.length
255
+ ? `\n\n${lines.length} of ${total} match(es) shown — narrow the query.`
256
+ : `\n\n${total} match(es) across ${names.length} name(s).`;
257
+ return lines.join("\n") + tail;
76
258
  },
77
259
  },
78
260
  {
79
- name: "open_symbol",
80
- description: "Open ONE symbol's block from the code graph (its callees as checked references, confidence annotations, called-by pointer). Equivalent to following a link. Get doc+id from resolve_name.",
261
+ name: "geml_codemap_callchain",
262
+ description:
263
+ "Walk the call graph SEVERAL hops from one symbol and get the whole chain back as an indented tree — `direction: callees` for what it calls (downstream, for tracing a behaviour), `callers` for what reaches it (upstream, the impact path). Use this instead of opening one symbol per level: one call replaces N round trips and returns only the edges, not each symbol's full block. `depth: 1` with `callers` answers \"who calls this\" alone. A repeated symbol is marked and not expanded twice, so recursion terminates. Call SITES (file:line) are not in the tree — read the `#called-by` table with geml_codemap_node(doc, \"#called-by\") for those.",
81
264
  inputSchema: {
82
265
  type: "object",
83
266
  properties: {
84
- doc: { type: "string", description: "Document path relative to the codemap dir, e.g. hashtable.c.geml" },
85
- id: { type: "string", description: "Block id, e.g. hashtableFind (or #calls / #called-by for the edge tables)" },
267
+ doc: { type: "string", description: "The symbol's document path, e.g. hashtable.c.geml" },
268
+ id: { type: "string", description: "The symbol's block id, e.g. hashtableFind" },
269
+ direction: { type: "string", enum: ["callees", "callers"], description: "`callees` = what this calls (default); `callers` = what calls this" },
270
+ depth: { type: "number", description: "How many hops to follow (default 3, max 6)" },
86
271
  graph_dir: { type: "string", description: "Graph directory (default: $GEML_GRAPH_DIR or ./.geml-code-graph)" },
87
272
  },
88
273
  required: ["doc", "id"],
89
274
  },
90
- run: (args) => readBlock(graphDirOf(args), args.doc, args.id),
275
+ run: (args) => {
276
+ const graphDir = graphDirOf(args);
277
+ const direction = args.direction === "callers" ? "callers" : "callees";
278
+ const depth = Math.max(1, Math.min(Number(args.depth) || 3, 6));
279
+ const rootId = String(args.id ?? "").replace(/^#/, "");
280
+ if (!args.doc || !rootId) throw new Error("`doc` and `id` are required");
281
+ // Prove the symbol exists before reporting an empty chain: "no edges" and
282
+ // "no such symbol" are different answers and a model must not conflate them.
283
+ readBlock(graphDir, args.doc, rootId);
284
+
285
+ const MAX_NODES = 200;
286
+ const lines = [];
287
+ const expanded = new Set();
288
+ let truncated = false;
289
+
290
+ // EVERY line carries the full `doc.geml#id`, including same-document
291
+ // targets that profile §4 would abbreviate to `#id`. The reader here is
292
+ // an agent, and each line has to be usable as-is for the next
293
+ // `open_symbol`/`trace_calls` call. A bare id would make it infer the
294
+ // document from the line's ancestors — cheaper output, one more thing to
295
+ // get wrong.
296
+ const walk = (doc, id, level, prefix, last) => {
297
+ const key = `${doc}#${id}`;
298
+ const label = level === 0 ? `${key}` : `${prefix}${last ? "└─ " : "├─ "}${key}`;
299
+ if (lines.length >= MAX_NODES) { truncated = true; return; }
300
+ if (expanded.has(key)) { lines.push(`${label} (already shown)`); return; }
301
+ lines.push(label);
302
+ expanded.add(key);
303
+ if (level >= depth) {
304
+ // Say whether the cut hides anything, so a model knows to go deeper.
305
+ if (neighbours(graphDir, doc, id, direction).length) lines.push(`${prefix}${last ? " " : "│ "} … (depth limit)`);
306
+ return;
307
+ }
308
+ const next = neighbours(graphDir, doc, id, direction);
309
+ const childPrefix = level === 0 ? "" : prefix + (last ? " " : "│ ");
310
+ next.forEach((n, i) => walk(n.doc, n.id, level + 1, childPrefix, i === next.length - 1));
311
+ };
312
+ walk(args.doc, rootId, 0, "", true);
313
+
314
+ const noun = direction === "callers" ? "callers" : "callees";
315
+ if (lines.length === 1) {
316
+ return `${lines[0]}\n\nno resolved ${noun} — under heuristic extraction that is a blind spot, not proof of none (see the #unresolved table).`;
317
+ }
318
+ return lines.join("\n") +
319
+ `\n\n${direction}, depth ${depth}${truncated ? `, truncated at ${MAX_NODES} nodes` : ""}. ` +
320
+ "Resolved edges only: `#unresolved` holds the blind spots.";
321
+ },
91
322
  },
92
323
  {
93
- name: "get_backlinks",
94
- description: "Who calls this symbol: opens its backlink block (callers with file:line sites, each a followable reference). Absence means no RESOLVED callers — never proof of none.",
324
+ name: "geml_codemap_list",
325
+ description:
326
+ "Browse the graph by MODULE. Called with no argument it lists every module with its document and symbol count — the map to open first on an unfamiliar repo, before you know any name to search for. Called with a `module` it lists that module's symbols as `name doc#id src`, ready to hand to geml_codemap_node or geml_codemap_callchain. Accepts a module name or its document path.",
95
327
  inputSchema: {
96
328
  type: "object",
97
329
  properties: {
98
- doc: { type: "string", description: "The symbol's document path, e.g. hashtable.c.geml" },
99
- id: { type: "string", description: "The symbol's block id (e.g. hashtableFind); omit to get the whole #called-by table" },
100
- graph_dir: { type: "string", description: "Codemap directory (default: $GEML_GRAPH_DIR or ./.geml-code-graph)" },
330
+ module: { type: "string", description: "Module name (e.g. geml-parser) or its document (geml-parser.geml). Omit to list every module." },
331
+ graph_dir: { type: "string", description: "Graph directory (default: $GEML_GRAPH_DIR or ./.geml-code-graph)" },
101
332
  },
102
- required: ["doc"],
103
333
  },
104
334
  run: (args) => {
105
- // codemap profile: in-edges live in the SAME document's #called-by table.
106
- let table;
107
- try {
108
- table = readBlock(graphDirOf(args), args.doc, "called-by");
109
- } catch {
110
- return `no #called-by table in ${args.doc} no resolved callers recorded (under heuristic extraction this is a blind spot, not proof of none)`;
335
+ const graphDir = graphDirOf(args);
336
+ const rows = moduleRows(graphDir);
337
+ if (!rows.length) throw new Error(`no #modules table in index.geml (graph dir: ${graphDir}) — build the graph first`);
338
+ const want = String(args.module ?? "").trim();
339
+ if (!want) {
340
+ return rows.map((r) => `${r[0]}\t${r[1]}\t${r[2] || 0} symbol(s)`).join("\n") +
341
+ `\n\n${rows.length} module(s). Pass one as \`module\` to list its symbols.`;
111
342
  }
112
- if (!args.id) return table;
113
- const id = args.id.replace(/^#/, "");
114
- // `id` is client-supplied and goes straight into a RegExp: escape every
115
- // regex metacharacter so it matches LITERALLY (an id like `.*` or a
116
- // catastrophic-backtracking pattern can neither widen the match nor cause
117
- // ReDoS the pattern is a fixed string wrapped in `,\s*#…\s*,`).
118
- const escId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
119
- const re = new RegExp(`,\\s*#${escId}\\s*,`);
120
- const lines = table.split("\n");
121
- const hits = lines.filter((l, i) => i < 2 || re.test(l));
122
- return hits.length > 2 ? hits.join("\n")
123
- : `no resolved callers of #${id} in ${args.doc} (blind spots live in the #unresolved table)`;
343
+ const row = rows.find((r) => r[0] === want || r[1] === want);
344
+ if (!row) return `no module \`${want}\` in the graph — call this tool with no argument to list them`;
345
+ const doc = row[1];
346
+ // The name index is the symbol list: filtering it by document skips the
347
+ // per-document edge tables (#calls / #called-by) a raw id listing returns.
348
+ const lookup = loadLookup(graphDir);
349
+ const lines = [];
350
+ for (const [name, cands] of Object.entries(lookup)) {
351
+ for (const c of cands) {
352
+ if (c.doc !== doc) continue;
353
+ const src = srcOf(graphDir, doc, c.id);
354
+ lines.push(`${name}\t${doc}#${c.id}${src ? `\t${src}` : ""}`);
355
+ }
356
+ }
357
+ // Name the module CANONICALLY (its #modules name), not however the caller
358
+ // addressed it, so `auth` and `auth.geml` return byte-identical answers.
359
+ if (!lines.length) return `module \`${row[0]}\` (${doc}) has no symbols in the name index`;
360
+ lines.sort();
361
+ return lines.join("\n") + `\n\n${lines.length} symbol(s) in ${row[0]}.`;
362
+ },
363
+ },
364
+ {
365
+ name: "geml_codemap_node",
366
+ description:
367
+ "Open ONE node of the graph verbatim: a symbol's block (its `src=` pointer into the real file, confidence annotations), or a document's edge table — pass `#calls` / `#called-by` / `#unresolved` as the id for those. `#called-by` is where call SITES (file:line) live. Pass `source: true` to also read the REAL SOURCE the `src=` pointer names — the symbol's own lines, the same text the local viewer shows in its source panel — so you do not have to open the file yourself. Get `doc` and `id` from geml_codemap_search or geml_codemap_list.",
368
+ inputSchema: {
369
+ type: "object",
370
+ properties: {
371
+ doc: { type: "string", description: "Document path relative to the graph dir, e.g. hashtable.c.geml" },
372
+ id: { type: "string", description: "Block id, e.g. hashtableFind (or #calls / #called-by / #unresolved for the edge tables)" },
373
+ source: { type: "boolean", description: "Also return the real source lines that `src=` points at (default false). Off by default because a node is often opened in a loop, where the pointer is enough." },
374
+ graph_dir: { type: "string", description: "Graph directory (default: $GEML_GRAPH_DIR or ./.geml-code-graph)" },
375
+ },
376
+ required: ["doc", "id"],
377
+ },
378
+ run: (args) => {
379
+ const graphDir = graphDirOf(args);
380
+ const block = readBlock(graphDir, args.doc, args.id);
381
+ if (args.source !== true) return block;
382
+ return `${block}\n${readSource(graphDir, block)}`;
124
383
  },
125
384
  },
126
385
  ];
@@ -165,8 +424,8 @@ export function handleLine(line, write = (s) => process.stdout.write(s)) {
165
424
  }
166
425
  }
167
426
 
168
- // Auto-run only as a MAIN module (the CLI dispatcher spawns this file as a
169
- // child's entry script) an in-process `import` stays inert.
170
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
171
- createInterface({ input: process.stdin }).on("line", (line) => handleLine(line));
172
- }
427
+ // No main-module block: this file no longer starts a server. Running it
428
+ // directly used to serve the three tools on stdio, and leaving that in would
429
+ // keep the removed entry point alive as a back door — `node codemap/
430
+ // mcp-server.mjs` reachable from any client config. `geml mcp` owns the
431
+ // transport now.
package/dist/geml.d.ts CHANGED
@@ -71,3 +71,4 @@ export interface Span {
71
71
  end: number;
72
72
  }
73
73
  export declare function blockSpans(source: string): Map<string, Span>;
74
+ export declare const PARSER_VERSION: string;