@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 +32 -1
- package/codemap/find.mjs +9 -23
- package/codemap/mcp-server.mjs +319 -60
- package/dist/geml.d.ts +1 -0
- package/dist/geml.js +248 -36
- package/dist/mcp.d.ts +11 -3
- package/dist/mcp.js +251 -70
- package/dist/render-html.js +35 -35
- package/package.json +2 -1
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, ~**
|
|
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 `
|
|
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
|
-
|
|
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 =
|
|
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
|
}
|
package/codemap/mcp-server.mjs
CHANGED
|
@@ -1,24 +1,41 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// geml-code-graph MCP
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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
|
|
21
|
-
|
|
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: "
|
|
60
|
-
description:
|
|
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
|
-
|
|
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: ["
|
|
229
|
+
required: ["query"],
|
|
68
230
|
},
|
|
69
231
|
run: (args) => {
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
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: "
|
|
80
|
-
description:
|
|
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: "
|
|
85
|
-
id: { type: "string", description: "
|
|
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) =>
|
|
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: "
|
|
94
|
-
description:
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
//
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
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