@geml/geml 1.0.0 → 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +109 -72
- package/codemap/adapters/crg.mjs +120 -0
- package/codemap/adapters/joern.mjs +131 -0
- package/codemap/adapters/scip.mjs +658 -0
- package/codemap/browser-stub.mjs +29 -0
- package/codemap/build.mjs +579 -0
- package/codemap/detect.mjs +399 -0
- package/codemap/emit.mjs +432 -0
- package/codemap/entries.mjs +129 -0
- package/codemap/exclude.mjs +52 -0
- package/codemap/find.mjs +63 -0
- package/codemap/foldings.mjs +110 -0
- package/codemap/joern-export.sc +83 -0
- package/codemap/mcp-server.mjs +172 -0
- package/codemap/normalize.mjs +272 -0
- package/codemap/recipe-trust.mjs +103 -0
- package/codemap/refresh.mjs +310 -0
- package/codemap/render-all.mjs +64 -0
- package/codemap/serve.mjs +578 -0
- package/codemap/sfc-virtualize.mjs +367 -0
- package/codemap/verify.mjs +143 -0
- package/dist/from-md.js +66 -7
- package/dist/geml.d.ts +7 -1
- package/dist/geml.js +700 -52
- package/dist/history.d.ts +30 -0
- package/dist/history.js +212 -32
- package/dist/inline.d.ts +2 -1
- package/dist/inline.js +61 -8
- package/dist/render-html.d.ts +3 -0
- package/dist/render-html.js +95 -0
- package/dist/render.d.ts +91 -2
- package/dist/render.js +1916 -50
- package/dist/serialize.js +22 -2
- package/dist/table.js +40 -5
- package/dist/to-md.js +5 -3
- package/package.json +63 -54
package/codemap/find.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// geml codemap find <name> [codemap-dir]
|
|
3
|
+
//
|
|
4
|
+
// Locate a function/class by (substring, case-insensitive) name in a built
|
|
5
|
+
// codemap. Prints each candidate as <name> \t <doc>#<id> \t <src> — the
|
|
6
|
+
// document + block id to open, and the true source location. NO browser: pure
|
|
7
|
+
// stdout, so it pipes/greps. `dir` defaults to ./.geml-code-graph.
|
|
8
|
+
//
|
|
9
|
+
// Same index the MCP `resolve_name` tool and the viewer search box use
|
|
10
|
+
// (_index/name-lookup.json); a name with several rows is real ambiguity
|
|
11
|
+
// (overloads / same short name across classes) — every candidate is printed.
|
|
12
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
// `find x | head` closes stdout after a few lines — that is normal pipe
|
|
16
|
+
// usage, not an error (POSIX would kill us silently with SIGPIPE; Windows
|
|
17
|
+
// node surfaces it as an EPIPE error event): exit quietly instead of
|
|
18
|
+
// crashing with an unhandled-error stack trace.
|
|
19
|
+
process.stdout.on("error", (e) => { if (e.code === "EPIPE") process.exit(0); throw e; });
|
|
20
|
+
|
|
21
|
+
const args = process.argv.slice(2);
|
|
22
|
+
if (!args.length || args[0] === "--help" || args[0] === "-h") {
|
|
23
|
+
console.error("usage: geml codemap find <name> [codemap-dir] # locate a symbol by substring name (dir defaults to ./.geml-code-graph)");
|
|
24
|
+
process.exit(args.length ? 0 : 2);
|
|
25
|
+
}
|
|
26
|
+
const query = args[0];
|
|
27
|
+
const dir = args[1] || ".geml-code-graph";
|
|
28
|
+
const lookupPath = join(dir, "_index", "name-lookup.json");
|
|
29
|
+
if (!existsSync(lookupPath)) {
|
|
30
|
+
console.error(`no name-lookup at ${lookupPath} — build the codemap first (geml codemap build)`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
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();
|
|
36
|
+
if (!names.length) { console.error(`no symbol matching "${query}"`); process.exit(1); }
|
|
37
|
+
|
|
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
|
+
let n = 0;
|
|
56
|
+
for (const name of names) {
|
|
57
|
+
for (const c of lookup[name]) {
|
|
58
|
+
const src = srcOf(c.doc, c.id);
|
|
59
|
+
process.stdout.write(`${name}\t${c.doc}#${c.id}${src ? `\t${src}` : ""}\n`);
|
|
60
|
+
n++;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
console.error(`\n${n} match(es) for "${query}" across ${names.length} name(s).`);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// foldings.geml — the visible, human-owned config for codemap ceremony folding.
|
|
2
|
+
// GEML (dogfooded; the viewer renders it): a meta header, three bullet-list
|
|
3
|
+
// sections (fold-prefixes / source-roots / test-roots) and an options section.
|
|
4
|
+
// Read via the bundled parser; group-id shared-prefix stripping stays
|
|
5
|
+
// algorithmic (only its on/off toggle lives here).
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { parse } from "../dist/geml.js";
|
|
9
|
+
import { deriveFoldLayers, DEFAULT_SOURCE_ROOTS, DEFAULT_TEST_ROOTS, LANG_FOLD_PREFIXES } from "./normalize.mjs";
|
|
10
|
+
|
|
11
|
+
const SECTIONS = { "fold-prefixes": "foldPrefixes", "source-roots": "sourceRoots", "test-roots": "testRoots", "module-roots": "moduleRoots" };
|
|
12
|
+
// Plain text of a bullet-list item. GEML list items carry `.text`; fall back to
|
|
13
|
+
// joining inline `.value`s if a build ever changes that shape.
|
|
14
|
+
const itemText = (it) => (typeof it.text === "string" ? it.text : (it.inlines ?? []).map((n) => n.value ?? "").join("")).trim();
|
|
15
|
+
|
|
16
|
+
export function parseFoldings(text) {
|
|
17
|
+
const doc = parse(text);
|
|
18
|
+
// A malformed hand-edit must fail LOUDLY, not silently drop every rule: the
|
|
19
|
+
// caller (loadOrSeedFoldings) catches this and falls back to the defaults.
|
|
20
|
+
// An intentionally empty file carries NO error diagnostics and yields empty
|
|
21
|
+
// sections — the "fold nothing" off-switch, a deliberately different outcome.
|
|
22
|
+
const errs = doc.diagnostics.filter((d) => d.severity === "error");
|
|
23
|
+
if (errs.length) throw new Error(`invalid GEML in foldings config: ${errs[0].message} (line ${errs[0].line})`);
|
|
24
|
+
const cfg = { foldPrefixes: [], sourceRoots: [], testRoots: [], moduleRoots: [], stripSharedPrefix: true };
|
|
25
|
+
let section = null;
|
|
26
|
+
for (const b of doc.children) {
|
|
27
|
+
if (b.kind === "heading") { section = b.text.trim().toLowerCase(); continue; }
|
|
28
|
+
if (b.kind !== "list") continue;
|
|
29
|
+
const items = b.items.map(itemText).filter(Boolean);
|
|
30
|
+
if (Object.hasOwn(SECTIONS, section)) cfg[SECTIONS[section]] = items;
|
|
31
|
+
else if (section === "options") {
|
|
32
|
+
for (const it of items) {
|
|
33
|
+
const m = it.match(/^strip-shared-prefix\s*:\s*(on|off|true|false)$/i);
|
|
34
|
+
if (m) cfg.stripSharedPrefix = /^(on|true)$/i.test(m[1]);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return cfg;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function serializeFoldings(cfg) {
|
|
42
|
+
const list = (xs) => xs.map((x) => `- ${x}`).join("\n");
|
|
43
|
+
return [
|
|
44
|
+
"=== meta",
|
|
45
|
+
'title = "codemap foldings"',
|
|
46
|
+
"===",
|
|
47
|
+
"",
|
|
48
|
+
"Ceremony folded out of module display names. Seeded on first build; edit",
|
|
49
|
+
"freely — build never rewrites this. The shared package prefix (group-ids",
|
|
50
|
+
"like com/acme/app) is stripped automatically and needs no entry here; set",
|
|
51
|
+
"strip-shared-prefix to off under Options to disable it.",
|
|
52
|
+
"",
|
|
53
|
+
"Add a directory under module-roots to force it to display as its own",
|
|
54
|
+
"module — for a submodule a build tool declares centrally but whose folder",
|
|
55
|
+
"carries no manifest, or any layout the detector cannot see.",
|
|
56
|
+
"",
|
|
57
|
+
"## fold-prefixes",
|
|
58
|
+
"",
|
|
59
|
+
list(cfg.foldPrefixes),
|
|
60
|
+
"",
|
|
61
|
+
"## source-roots",
|
|
62
|
+
"",
|
|
63
|
+
list(cfg.sourceRoots),
|
|
64
|
+
"",
|
|
65
|
+
"## test-roots",
|
|
66
|
+
"",
|
|
67
|
+
list(cfg.testRoots),
|
|
68
|
+
"",
|
|
69
|
+
"## module-roots",
|
|
70
|
+
"",
|
|
71
|
+
list(cfg.moduleRoots ?? []),
|
|
72
|
+
"",
|
|
73
|
+
"## options",
|
|
74
|
+
"",
|
|
75
|
+
`- strip-shared-prefix: ${cfg.stripSharedPrefix ? "on" : "off"}`,
|
|
76
|
+
"",
|
|
77
|
+
].join("\n");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Seeded config for a first build: structural fold-layers (above-root
|
|
81
|
+
// ceremony dirs, derived from the discovered module roots) unioned with
|
|
82
|
+
// known per-language ceremony (Cargo's `crates/`). Source/test roots always
|
|
83
|
+
// start from the same global defaults — the human edits foldings.geml from
|
|
84
|
+
// there; the build never rewrites it once it exists.
|
|
85
|
+
export function defaultFoldings({ moduleRoots, languages }) {
|
|
86
|
+
const langPrefixes = (languages ?? []).flatMap((l) => LANG_FOLD_PREFIXES[l] ?? []);
|
|
87
|
+
const foldPrefixes = [...new Set([...deriveFoldLayers(moduleRoots ?? []), ...langPrefixes])].sort();
|
|
88
|
+
return { foldPrefixes, sourceRoots: [...DEFAULT_SOURCE_ROOTS], testRoots: [...DEFAULT_TEST_ROOTS], moduleRoots: [], stripSharedPrefix: true };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Read <outDir>/_index/foldings.geml, or seed it on first build. Write-once:
|
|
92
|
+
// an existing file is the human's — we read it and never rewrite it (the
|
|
93
|
+
// refresh.json contract). A missing or unreadable file never crashes the
|
|
94
|
+
// build: seed/fall back to defaults and say so. Returns { config, seeded }.
|
|
95
|
+
export function loadOrSeedFoldings({ outDir, moduleRoots, languages }) {
|
|
96
|
+
const path = join(outDir, "_index", "foldings.geml");
|
|
97
|
+
if (existsSync(path)) {
|
|
98
|
+
try { return { config: parseFoldings(readFileSync(path, "utf8")), seeded: false }; }
|
|
99
|
+
catch (e) {
|
|
100
|
+
console.error(`warning: ignoring ${path} (${e.message}) — using default foldings; fix the file to re-enable your edits.`);
|
|
101
|
+
return { config: defaultFoldings({ moduleRoots, languages }), seeded: false };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const config = defaultFoldings({ moduleRoots, languages });
|
|
105
|
+
try {
|
|
106
|
+
mkdirSync(join(outDir, "_index"), { recursive: true });
|
|
107
|
+
writeFileSync(path, serializeFoldings(config));
|
|
108
|
+
} catch (e) { console.error(`warning: could not seed ${path} (${e.message}).`); }
|
|
109
|
+
return { config, seeded: true };
|
|
110
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// geml-code-graph Joern export (P1, DESIGN §3.4).
|
|
2
|
+
// Runs INSIDE joern; emits raw method/call records as JSONL for adapters/joern.mjs.
|
|
3
|
+
//
|
|
4
|
+
// Parameters come from ENVIRONMENT VARIABLES, not --param: on Windows the
|
|
5
|
+
// joern.bat -> repl-bridge.bat hop re-tokenizes %* and cmd.exe treats `=` as a
|
|
6
|
+
// delimiter, so `--param k=v` never survives intact. Env vars pass through
|
|
7
|
+
// every layer on every OS:
|
|
8
|
+
//
|
|
9
|
+
// GEML_SRC=/abs/path/to/src GEML_OUT=/abs/path/to/build/raw \
|
|
10
|
+
// joern --script geml-parser/codemap/joern-export.sc
|
|
11
|
+
//
|
|
12
|
+
// Output:
|
|
13
|
+
// <GEML_OUT>/methods.jsonl one record per internal method
|
|
14
|
+
// <GEML_OUT>/calls.jsonl one record per call site, callees resolved by Joern
|
|
15
|
+
//
|
|
16
|
+
// Identity: methods are keyed by fullName|signature|filename — the adapter
|
|
17
|
+
// mints anchors and stable ids from these; this script stays dumb on purpose.
|
|
18
|
+
import java.io.{File, PrintWriter}
|
|
19
|
+
|
|
20
|
+
@main def exec(): Unit = {
|
|
21
|
+
val codeDir = sys.env.getOrElse("GEML_SRC", { System.err.println("GEML_SRC not set"); sys.exit(2) })
|
|
22
|
+
val outDir = sys.env.getOrElse("GEML_OUT", { System.err.println("GEML_OUT not set"); sys.exit(2) })
|
|
23
|
+
// GEML_LANG (optional): force a frontend in mixed-language repos, where
|
|
24
|
+
// auto-detection may pick the majority language instead of the intended one.
|
|
25
|
+
// Values are Joern's --language names: JAVASRC, NEWC, PYTHONSRC, JSSRC, …
|
|
26
|
+
sys.env.get("GEML_LANG") match {
|
|
27
|
+
case Some(lang) => importCode(inputPath = codeDir, projectName = "geml-code-graph", language = lang)
|
|
28
|
+
case None => importCode(inputPath = codeDir, projectName = "geml-code-graph")
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
def esc(s: String): String =
|
|
32
|
+
s.replace("\\", "\\\\").replace("\"", "\\\"")
|
|
33
|
+
.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
|
|
34
|
+
def jstr(s: String): String = "\"" + esc(s) + "\""
|
|
35
|
+
|
|
36
|
+
new File(outDir).mkdirs()
|
|
37
|
+
|
|
38
|
+
val skipName = (n: String) => n == "<global>" || n.startsWith("<operator>") || n.startsWith("<clinit>")
|
|
39
|
+
|
|
40
|
+
// ---- methods ----
|
|
41
|
+
val mOut = new PrintWriter(new File(outDir, "methods.jsonl"), "UTF-8")
|
|
42
|
+
cpg.method.filter(m => !m.isExternal && !skipName(m.name)).foreach { m =>
|
|
43
|
+
mOut.println(
|
|
44
|
+
s"""{"name":${jstr(m.name)},"fullName":${jstr(m.fullName)},"signature":${jstr(m.signature)},""" +
|
|
45
|
+
s""""file":${jstr(m.filename)},"lineStart":${m.lineNumber.map(_.toString).getOrElse("null")},""" +
|
|
46
|
+
s""""lineEnd":${m.lineNumberEnd.map(_.toString).getOrElse("null")}}"""
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
mOut.close()
|
|
50
|
+
|
|
51
|
+
// ---- calls ----
|
|
52
|
+
// For each call site inside an internal method: Joern-resolved callees.
|
|
53
|
+
// Several internal callees = dispatch candidates (the adapter keeps them ALL,
|
|
54
|
+
// per the "never force a single candidate" red line). No internal callee =
|
|
55
|
+
// unresolved from the graph's point of view (external / pointer call).
|
|
56
|
+
// Operator calls are noise (arithmetic, casts, field access) EXCEPT
|
|
57
|
+
// <operator>.pointerCall — a function-pointer invocation is a real dispatch
|
|
58
|
+
// site the graph cannot resolve statically, so it must surface as an
|
|
59
|
+
// unresolved call (blind spots are shown, not hidden). Its readable label is
|
|
60
|
+
// the source expression itself.
|
|
61
|
+
val cOut = new PrintWriter(new File(outDir, "calls.jsonl"), "UTF-8")
|
|
62
|
+
cpg.call.filterNot(c => c.name.startsWith("<operator>") && c.name != "<operator>.pointerCall").foreach { c =>
|
|
63
|
+
val caller = c.method
|
|
64
|
+
if (!caller.isExternal && !skipName(caller.name)) {
|
|
65
|
+
val callees = c.callee.l
|
|
66
|
+
val internal = callees.filter(m => !m.isExternal && !skipName(m.name))
|
|
67
|
+
val tos = internal.map(m =>
|
|
68
|
+
s"""{"fullName":${jstr(m.fullName)},"signature":${jstr(m.signature)},"file":${jstr(m.filename)}}"""
|
|
69
|
+
).mkString("[", ",", "]")
|
|
70
|
+
val label =
|
|
71
|
+
if (c.name == "<operator>.pointerCall") c.code.takeWhile(_ != '\n').take(48)
|
|
72
|
+
else c.name
|
|
73
|
+
cOut.println(
|
|
74
|
+
s"""{"callerFullName":${jstr(caller.fullName)},"callerSignature":${jstr(caller.signature)},""" +
|
|
75
|
+
s""""callerFile":${jstr(caller.filename)},"name":${jstr(label)},""" +
|
|
76
|
+
s""""line":${c.lineNumber.map(_.toString).getOrElse("null")},"callees":$tos}"""
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
cOut.close()
|
|
81
|
+
|
|
82
|
+
println(s"geml-code-graph joern-export: done -> $outDir")
|
|
83
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
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)
|
|
8
|
+
//
|
|
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.
|
|
14
|
+
//
|
|
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.
|
|
18
|
+
import { readFileSync, existsSync, realpathSync } from "node:fs";
|
|
19
|
+
import { join, resolve, dirname, sep } from "node:path";
|
|
20
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
21
|
+
import { createInterface } from "node:readline";
|
|
22
|
+
|
|
23
|
+
// blockSpans from the reference parser (its CLI entry is guarded, so importing
|
|
24
|
+
// is side-effect free). Falls back with a clear error if the parser isn't built.
|
|
25
|
+
const parserPath = resolve(dirname(fileURLToPath(import.meta.url)), "../dist/geml.js");
|
|
26
|
+
if (!existsSync(parserPath)) {
|
|
27
|
+
console.error("geml-code-graph mcp: build the parser first (cd geml-parser && npm install && npm run build)");
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
const { blockSpans } = await import(`file://${parserPath.replace(/\\/g, "/")}`);
|
|
31
|
+
const splitLines = (s) => s.split(/(?<=\n)/);
|
|
32
|
+
|
|
33
|
+
export const graphDirOf = (args) => resolve(args?.graph_dir ?? process.env.GEML_GRAPH_DIR ?? ".geml-code-graph");
|
|
34
|
+
|
|
35
|
+
export const readBlock = (graphDir, doc, id) => {
|
|
36
|
+
const p = join(graphDir, doc);
|
|
37
|
+
// Confine `doc` to the graph dir. `doc` is client-supplied, so a value like
|
|
38
|
+
// ../../../etc/hosts joins OUT of the dir; realpathSync canonicalizes both
|
|
39
|
+
// sides (also defeating symlink escapes and normalizing Windows casing) and
|
|
40
|
+
// we verify the real doc path stays within the real graph dir. A missing
|
|
41
|
+
// file makes realpathSync throw — that is the normal "no such document"
|
|
42
|
+
// miss. (graph_dir itself is intentionally client-chosen — the server is
|
|
43
|
+
// pointed at a graph — so only the doc path is confined, to that dir.)
|
|
44
|
+
let realDir;
|
|
45
|
+
try { realDir = realpathSync(graphDir); } catch { realDir = resolve(graphDir); }
|
|
46
|
+
let realP;
|
|
47
|
+
try { realP = realpathSync(p); } catch { throw new Error(`no such document: ${doc} (graph dir: ${graphDir})`); }
|
|
48
|
+
if (realP !== realDir && !realP.startsWith(realDir + sep)) {
|
|
49
|
+
throw new Error(`document escapes the graph dir: ${doc} (graph dir: ${graphDir})`);
|
|
50
|
+
}
|
|
51
|
+
const source = readFileSync(realP, "utf8");
|
|
52
|
+
const span = blockSpans(source).get(id.replace(/^#/, ""));
|
|
53
|
+
if (!span) throw new Error(`no block with id \`${id}\` in ${doc}`);
|
|
54
|
+
return splitLines(source).slice(span.start, span.end).join("");
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const TOOLS = [
|
|
58
|
+
{
|
|
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.",
|
|
61
|
+
inputSchema: {
|
|
62
|
+
type: "object",
|
|
63
|
+
properties: {
|
|
64
|
+
name: { type: "string", description: "Exact symbol name (function/class short name)" },
|
|
65
|
+
graph_dir: { type: "string", description: "Graph directory (default: $GEML_GRAPH_DIR or ./.geml-code-graph)" },
|
|
66
|
+
},
|
|
67
|
+
required: ["name"],
|
|
68
|
+
},
|
|
69
|
+
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);
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
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.",
|
|
81
|
+
inputSchema: {
|
|
82
|
+
type: "object",
|
|
83
|
+
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)" },
|
|
86
|
+
graph_dir: { type: "string", description: "Graph directory (default: $GEML_GRAPH_DIR or ./.geml-code-graph)" },
|
|
87
|
+
},
|
|
88
|
+
required: ["doc", "id"],
|
|
89
|
+
},
|
|
90
|
+
run: (args) => readBlock(graphDirOf(args), args.doc, args.id),
|
|
91
|
+
},
|
|
92
|
+
{
|
|
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.",
|
|
95
|
+
inputSchema: {
|
|
96
|
+
type: "object",
|
|
97
|
+
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)" },
|
|
101
|
+
},
|
|
102
|
+
required: ["doc"],
|
|
103
|
+
},
|
|
104
|
+
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)`;
|
|
111
|
+
}
|
|
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)`;
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
// ---- newline-delimited JSON-RPC 2.0 over stdio ----
|
|
129
|
+
// One frame in, zero or one frame out via `write` (stdout in production).
|
|
130
|
+
export function handleLine(line, write = (s) => process.stdout.write(s)) {
|
|
131
|
+
const reply = (id, result) => write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
|
|
132
|
+
const replyError = (id, code, message) =>
|
|
133
|
+
write(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }) + "\n");
|
|
134
|
+
line = line.trim();
|
|
135
|
+
if (!line) return;
|
|
136
|
+
let msg;
|
|
137
|
+
try { msg = JSON.parse(line); } catch { return; }
|
|
138
|
+
const { id, method, params } = msg;
|
|
139
|
+
try {
|
|
140
|
+
if (method === "initialize") {
|
|
141
|
+
reply(id, {
|
|
142
|
+
protocolVersion: params?.protocolVersion ?? "2024-11-05",
|
|
143
|
+
capabilities: { tools: {} },
|
|
144
|
+
serverInfo: { name: "geml-code-graph", version: "0.2.0" },
|
|
145
|
+
});
|
|
146
|
+
} else if (method === "notifications/initialized" || method?.startsWith("notifications/")) {
|
|
147
|
+
// notifications get no response
|
|
148
|
+
} else if (method === "ping") {
|
|
149
|
+
reply(id, {});
|
|
150
|
+
} else if (method === "tools/list") {
|
|
151
|
+
reply(id, { tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
|
|
152
|
+
} else if (method === "tools/call") {
|
|
153
|
+
const tool = TOOLS.find((t) => t.name === params?.name);
|
|
154
|
+
if (!tool) { replyError(id, -32602, `unknown tool: ${params?.name}`); return; }
|
|
155
|
+
try {
|
|
156
|
+
reply(id, { content: [{ type: "text", text: tool.run(params?.arguments ?? {}) }] });
|
|
157
|
+
} catch (e) {
|
|
158
|
+
reply(id, { content: [{ type: "text", text: `error: ${e.message}` }], isError: true });
|
|
159
|
+
}
|
|
160
|
+
} else if (id !== undefined) {
|
|
161
|
+
replyError(id, -32601, `method not found: ${method}`);
|
|
162
|
+
}
|
|
163
|
+
} catch (e) {
|
|
164
|
+
if (id !== undefined) replyError(id, -32603, String(e?.message ?? e));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
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
|
+
}
|