@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.
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env node
2
+ // geml codemap refresh — re-run a codemap's RECORDED build recipe so the
3
+ // graph stays consistent with the code.
4
+ //
5
+ // geml codemap refresh [codemap-dir] run the recipe now
6
+ // geml codemap refresh [codemap-dir] --background detach, return at once
7
+ // geml codemap refresh [codemap-dir] --hook Claude Code hook adapter
8
+ //
9
+ // The recipe lives at <codemap-dir>/_index/refresh.json — written once, after
10
+ // the first successful build (the geml-code-graph skill records the exact
11
+ // index/build/verify commands it ran):
12
+ //
13
+ // { "root": "..", "steps": ["npx --yes @sourcegraph/scip-typescript index …",
14
+ // "geml codemap build --adapter scip --raw index.scip --root . --out .geml-code-graph --history",
15
+ // "geml codemap verify .geml-code-graph"] }
16
+ //
17
+ // Steps run sequentially with the project root as cwd; the run is skipped
18
+ // when git HEAD hasn't moved past the commit the codemap was built from —
19
+ // read from index.geml's own meta (`commit = <sha>`, stamped by build), so
20
+ // refresh.json stays a pure, human-reviewable recipe that no tool rewrites.
21
+ // Output goes to _index/refresh.log.
22
+ //
23
+ // --hook mode is a PostToolUse adapter: it reads the hook payload from stdin,
24
+ // exits 0 immediately unless the tool ran a `git commit`, and otherwise
25
+ // starts the refresh DETACHED so the commit is never blocked on an indexer.
26
+ // A project without refresh.json is simply not opted in (silent exit 0).
27
+ //
28
+ // --commit: after a successful refresh, commit the refreshed codemap files as
29
+ // their own follow-up commit (chore(codemap): …), so the graph travels with
30
+ // the code on the next push instead of lingering as working-tree churn. The
31
+ // commit is surgical (pathspec = the codemap dir only) and guarded: it is
32
+ // skipped when HEAD moved mid-refresh or a merge is in progress. Loop-safe by
33
+ // construction — the follow-up commit changes no indexed source file, so the
34
+ // refresh it triggers takes the no-source-change skip and stops.
35
+ import { readFileSync, existsSync, appendFileSync, openSync, closeSync } from "node:fs";
36
+ import { join, resolve, relative } from "node:path";
37
+ import { spawnSync, spawn } from "node:child_process";
38
+ import { isSourcePath } from "./detect.mjs";
39
+ import { recipeFingerprint, isRecipeTrusted, trustRecipe, trustStorePath, RECIPE_VERSION } from "./recipe-trust.mjs";
40
+
41
+ const args = process.argv.slice(2);
42
+ const hookMode = args.includes("--hook");
43
+ const background = args.includes("--background");
44
+ // --force: rebuild even when the repo commit is unchanged — the recipe's
45
+ // up-to-date check watches the CODE, but a toolchain upgrade (new adapter
46
+ // naming, new emit shape) changes the OUTPUT for the same code.
47
+ const force = args.includes("--force");
48
+ const autoCommit = args.includes("--commit");
49
+ // --trust: approve THIS recipe (by fingerprint) so refresh will run it. The
50
+ // gate below refuses any recipe whose fingerprint is not in the trust store.
51
+ const trustFlag = args.includes("--trust");
52
+ if (args.includes("--help")) {
53
+ console.error("usage: geml codemap refresh [codemap-dir] [--trust] [--force] [--commit] [--background|--hook] (dir defaults to ./.geml-code-graph)");
54
+ process.exit(2);
55
+ }
56
+ const dir = args.find((a) => !a.startsWith("--")) || ".geml-code-graph";
57
+ const cmDir = resolve(dir);
58
+ const cfgPath = join(cmDir, "_index", "refresh.json");
59
+ const logPath = join(cmDir, "_index", "refresh.log");
60
+
61
+ if (!existsSync(cfgPath)) {
62
+ if (hookMode) process.exit(0); // no recipe = this project has not opted in
63
+ console.error(`error: ${cfgPath} not found — record the build recipe there first (see the geml-code-graph skill)`);
64
+ process.exit(1);
65
+ }
66
+
67
+ // Parse the recipe UP FRONT: its fingerprint drives the TRUST GATE (security
68
+ // fix C2). refresh.json is committed data whose steps run through a shell, so
69
+ // an untrusted recipe must never reach the exec loop on ANY path — the
70
+ // foreground run, the --hook/--background re-spawn, or serve --watch (which
71
+ // spawns this script). See codemap/recipe-trust.mjs.
72
+ let cfg;
73
+ try { cfg = JSON.parse(readFileSync(cfgPath, "utf8")); }
74
+ catch (e) {
75
+ if (hookMode) process.exit(0); // a broken recipe must not block a commit
76
+ console.error(`error: cannot parse recipe ${cfgPath}: ${e.message}`);
77
+ process.exit(1);
78
+ }
79
+ const steps = cfg.steps ?? [];
80
+ const fingerprint = recipeFingerprint(cfg);
81
+ let trusted = isRecipeTrusted(fingerprint);
82
+
83
+ // --- structured-step execution (security fix R2-1) --------------------------
84
+ // A recipe step is a structured object { cwd?, env?, argv:[...] }. We run argv
85
+ // WITHOUT ever concatenating an attacker-controllable value into a shell
86
+ // string (the R2-1 RCE was a recorded `cd <dir-name> && …` string run under a
87
+ // shell, where <dir-name> was attacker-chosen).
88
+ // POSIX: spawn the program directly (shell:false) — no shell, no injection.
89
+ // win32: npx.cmd / geml.cmd / rust-analyzer / joern.bat are .cmd/.bat shims
90
+ // that modern Node refuses to spawn with shell:false (EINVAL), so we go
91
+ // through cmd.exe. Node does NOT escape args under shell:true — it only
92
+ // concatenates them (DEP0190) — so we build the command line ourselves and
93
+ // quote EACH argv element: the program via q (a bare launcher name stays
94
+ // bare so its .cmd shim's %~dp0 resolves against the shim dir; a spaced
95
+ // full path is quoted), every argument via shq (ALWAYS double-quoted, so
96
+ // cmd.exe treats & | < > ( ) ^ and whitespace as literal). An injected
97
+ // metachar inside a dir-name argument is therefore inert.
98
+ const q = (s) => (/[\s"]/.test(String(s)) ? `"${String(s).replace(/"/g, '\\"')}"` : String(s));
99
+ const shq = (s) => `"${String(s).replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1')}"`;
100
+ // Human-readable render of a step for the log / refusal message — DISPLAY ONLY,
101
+ // never executed. Falls back to String() for a stale (non-structured) step.
102
+ const renderStep = (s) => {
103
+ if (!s || typeof s !== "object" || !Array.isArray(s.argv)) return String(s);
104
+ const parts = [];
105
+ if (s.cwd && s.cwd !== ".") parts.push(`cd ${s.cwd} &&`);
106
+ if (s.env) for (const [k, v] of Object.entries(s.env)) parts.push(`${k}=${v}`);
107
+ parts.push(...s.argv.map(String));
108
+ return parts.join(" ");
109
+ };
110
+ // A step is executable only when it is a structured object with a non-empty
111
+ // argv array. Anything else is a stale pre-R2-1 shell string (or malformed);
112
+ // it must be REFUSED, never run as a shell string.
113
+ const isStructuredStep = (s) => !!s && typeof s === "object" && Array.isArray(s.argv) && s.argv.length > 0;
114
+
115
+ // --trust: record this exact recipe as approved (content-addressed), then
116
+ // proceed. Recorded in the PARENT so every downstream path — including a
117
+ // detached --hook/--background child that re-runs this script — sees it as
118
+ // trusted via the persistent store. Fail LOUDLY if the store cannot be
119
+ // written: a caller that asked to trust must not be told it worked and then
120
+ // silently keep refusing.
121
+ if (trustFlag) {
122
+ if (trusted) {
123
+ console.error(`codemap refresh: recipe already trusted (${fingerprint.slice(0, 12)})`);
124
+ } else {
125
+ let where;
126
+ try { where = trustRecipe(fingerprint, cmDir); }
127
+ catch (e) {
128
+ console.error(`codemap refresh: FAILED to record trust in ${trustStorePath()}: ${e.message}`);
129
+ process.exit(1);
130
+ }
131
+ console.error(`codemap refresh: recipe trusted (${fingerprint.slice(0, 12)}) — recorded in ${where}`);
132
+ trusted = true;
133
+ }
134
+ }
135
+
136
+ // The refusal: show the exact steps that WOULD run so the user can review
137
+ // them, then how to approve. Non-zero exit so the skill/automation notices and
138
+ // surfaces it rather than silently doing nothing.
139
+ const refuseUntrusted = () => {
140
+ console.error(`codemap refresh: REFUSING to run an untrusted recipe (${cfgPath})`);
141
+ console.error(` fingerprint: ${fingerprint}`);
142
+ console.error(" steps that would run:");
143
+ for (const s of steps) console.error(` $ ${renderStep(s)}`);
144
+ console.error("this codemap recipe is not trusted; review the steps above and re-run with");
145
+ console.error("--trust to approve, or run `geml codemap build` to regenerate it.");
146
+ };
147
+
148
+ if (hookMode) {
149
+ // PostToolUse payload on stdin; only a git commit warrants a refresh.
150
+ let cmd = "";
151
+ try { cmd = JSON.parse(readFileSync(0, "utf8"))?.tool_input?.command ?? ""; } catch { /* not JSON: ignore */ }
152
+ if (!/(^|[;&|]\s*)(\S+\s+)?git\s+(\S+\s+)*commit\b/.test(cmd)) process.exit(0);
153
+ }
154
+
155
+ if (hookMode || background) {
156
+ // Never launch an exec child for an untrusted recipe. An empty recipe execs
157
+ // nothing, so it is not gated. --hook is automatic and must not block the
158
+ // commit: warn and no-op (exit 0). An explicit --background run surfaces the
159
+ // refusal with a non-zero exit.
160
+ if (steps.length && !trusted) {
161
+ if (hookMode) {
162
+ console.error(`codemap refresh: recipe not trusted — skipping (review it, then run \`geml codemap refresh ${dir} --trust\`)`);
163
+ process.exit(0);
164
+ }
165
+ refuseUntrusted();
166
+ process.exit(3);
167
+ }
168
+ const child = spawn(process.execPath, [process.argv[1], cmDir, ...(force ? ["--force"] : []), ...(autoCommit ? ["--commit"] : [])], { detached: true, stdio: "ignore" });
169
+ child.unref();
170
+ console.error(`codemap refresh: running in background (log: ${logPath})`);
171
+ process.exit(0);
172
+ }
173
+
174
+ const root = resolve(cmDir, cfg.root ?? "..");
175
+ let head;
176
+ try {
177
+ const r = spawnSync("git", ["-C", root, "rev-parse", "HEAD"], { encoding: "utf8" });
178
+ head = r.status === 0 ? r.stdout.trim() : undefined;
179
+ } catch { /* no git: refresh unconditionally */ }
180
+ // The commit this codemap was built from: build stamps it into index.geml's
181
+ // meta (`commit = <short-sha>`), so the graph itself carries the baseline and
182
+ // refresh.json is never rewritten. A legacy `last_commit` in refresh.json is
183
+ // honored as a fallback for codemaps built before the meta stamp.
184
+ let builtFrom;
185
+ try {
186
+ const m = /^commit = "?([0-9a-fA-F]{4,40})"?\r?$/m.exec(readFileSync(join(cmDir, "index.geml"), "utf8").slice(0, 4000));
187
+ if (m) builtFrom = m[1];
188
+ } catch { /* no index yet: first build */ }
189
+ if (!builtFrom && cfg.last_commit) builtFrom = cfg.last_commit;
190
+ if (!force && head && builtFrom && head.startsWith(builtFrom)) {
191
+ console.error(`codemap refresh: up to date at ${head.slice(0, 10)} (--force to rebuild anyway)`);
192
+ process.exit(0);
193
+ }
194
+
195
+ // HEAD moved past the built-from commit, but if no INDEXED source file
196
+ // changed in between (docs, config, CI only) the graph can't have changed —
197
+ // skip the slow re-index. --force, a first build (no baseline), or an
198
+ // uncomputable diff all fall through and rebuild.
199
+ if (!force && head && builtFrom) {
200
+ let changed;
201
+ try {
202
+ const r = spawnSync("git", ["-C", root, "diff", "--name-only", builtFrom, head], { encoding: "utf8" });
203
+ if (r.status === 0) changed = r.stdout.split("\n").filter(Boolean);
204
+ } catch { /* diff unavailable: fall through and rebuild */ }
205
+ if (changed && !changed.some(isSourcePath)) {
206
+ console.error(`codemap refresh: no source files changed since ${builtFrom.slice(0, 10)} — skipped (${changed.length} non-source file(s); --force to rebuild)`);
207
+ process.exit(0);
208
+ }
209
+ }
210
+
211
+ // TRUST GATE (foreground exec path). Reached only when the recipe is about to
212
+ // RUN its steps — after the up-to-date / no-source-change skips above, which
213
+ // never exec and so need no gate. This gate is INDEPENDENT of those checks, so
214
+ // forging index.geml's `commit` (or removing git) to force a rebuild cannot
215
+ // bypass it: it only changes which skip is taken, never whether an untrusted
216
+ // recipe may exec. An empty recipe execs nothing and is not gated.
217
+ if (steps.length && !trusted) {
218
+ refuseUntrusted();
219
+ process.exit(3);
220
+ }
221
+
222
+ // VERSION GATE. The on-disk step schema is versioned (RECIPE_VERSION): refuse a
223
+ // recipe recorded in any other format so a FUTURE format change is cleanly
224
+ // detected and the user is pointed at `geml codemap build` to regenerate it. A
225
+ // pre-versioning recipe (no `version` at all) is likewise refused. This judges
226
+ // the STANDALONE schema version, never the parser/`generator` version — the
227
+ // parser bumps every patch release, so using it here would force a full
228
+ // re-index of every project on each release. Reached only on the exec path
229
+ // (after the skips above, which never run steps).
230
+ if (cfg.version !== RECIPE_VERSION) {
231
+ console.error(`codemap refresh: REFUSING — recipe format out of date (${cfgPath})`);
232
+ console.error(` recorded format: v${cfg.version ?? "(pre-versioning)"}; this geml expects v${RECIPE_VERSION}`);
233
+ console.error("re-run `geml codemap build` to regenerate refresh.json.");
234
+ process.exit(1);
235
+ }
236
+ // STRUCTURE GUARD (the R2-1 invariant). Even a current-version recipe must not
237
+ // hand a non-structured step to the exec loop: a legacy shell STRING run through
238
+ // a shell is the exact RCE R2-1 closed. Refuse any step that is not a
239
+ // { argv: [...] } object rather than execute it.
240
+ const badIdx = steps.findIndex((s) => !isStructuredStep(s));
241
+ if (badIdx >= 0) {
242
+ console.error(`codemap refresh: REFUSING — step ${badIdx + 1} is not a { argv: [...] } step (${cfgPath})`);
243
+ console.error("re-run `geml codemap build` to regenerate refresh.json.");
244
+ process.exit(1);
245
+ }
246
+
247
+ appendFileSync(logPath, `\n[${new Date().toISOString()}] refresh @ ${head ?? "no-git"}\n`);
248
+ for (const step of steps) {
249
+ appendFileSync(logPath, `$ ${renderStep(step)}\n`);
250
+ // Per-step cwd (relative to the project root, forward-slash) and env, merged
251
+ // over the current environment. Both may hold attacker-controlled dir names —
252
+ // they ride as a real cwd PATH / discrete argv elements, never shell syntax.
253
+ const stepCwd = resolve(root, step.cwd || ".");
254
+ const stepEnv = step.env ? { ...process.env, ...step.env } : process.env;
255
+ const argv = step.argv.map(String);
256
+ // Stream the step's output STRAIGHT into the log file. Capturing it in
257
+ // memory (spawnSync + encoding) hits the default 1MB maxBuffer — Joern's
258
+ // INFO firehose blew it and the child got killed mid-export (exit null).
259
+ // A file descriptor has no such limit, and the log tails live.
260
+ const fd = openSync(logPath, "a");
261
+ const r = process.platform === "win32"
262
+ // win32: build ONE pre-escaped command line (each element quoted so no arg
263
+ // can inject), run it through cmd.exe for the .cmd/.bat launchers.
264
+ ? spawnSync([q(argv[0]), ...argv.slice(1).map(shq)].join(" "),
265
+ { shell: true, cwd: stepCwd, env: stepEnv, stdio: ["ignore", fd, fd] })
266
+ // POSIX: exec the program directly with an args array — no shell involved.
267
+ : spawnSync(argv[0], argv.slice(1),
268
+ { shell: false, cwd: stepCwd, env: stepEnv, stdio: ["ignore", fd, fd] });
269
+ closeSync(fd);
270
+ if (r.status !== 0) {
271
+ const why = r.status ?? r.signal ?? r.error?.message ?? "killed";
272
+ appendFileSync(logPath, `FAILED (exit ${why})\n`);
273
+ console.error(`codemap refresh: step failed (exit ${why}): ${renderStep(step)}\n log: ${logPath}`);
274
+ process.exit(1);
275
+ }
276
+ }
277
+ appendFileSync(logPath, "ok\n");
278
+ console.error(`codemap refresh: done${head ? ` @ ${head.slice(0, 10)}` : ""} (${steps.length} step(s))`);
279
+
280
+ // --commit: land the refreshed codemap as its own follow-up commit so it
281
+ // rides the next push with the code. Guards: HEAD must not have moved while
282
+ // the indexer ran (a switched branch or new commit means these files belong
283
+ // to a different base — leave them in the tree), and never during a merge.
284
+ if (autoCommit && head) {
285
+ const g = (...a) => spawnSync("git", ["-C", root, ...a], { encoding: "utf8" });
286
+ const headNow = g("rev-parse", "HEAD").stdout?.trim();
287
+ const merging = g("rev-parse", "-q", "--verify", "MERGE_HEAD").status === 0;
288
+ if (headNow !== head || merging) {
289
+ console.error(`codemap refresh: not auto-committing (${merging ? "merge in progress" : "HEAD moved during the refresh"}) — refreshed files left in the working tree`);
290
+ } else {
291
+ const rel = relative(root, cmDir).replace(/\\/g, "/") || ".";
292
+ // Exclude-pathspec prefix: empty when the codemap IS the repo root. A `./`
293
+ // prefix (what `${rel}/…` yields at rel=".") is rejected by some git
294
+ // versions inside `:(exclude)…`, silently un-excluding the logs or failing
295
+ // the commit — so build `_index/…` bare at root, `<rel>/_index/…` in a subdir.
296
+ const relPrefix = rel === "." ? "" : `${rel}/`;
297
+ // Runtime noise in _index (refresh/serve logs, serve.pid) never belongs in
298
+ // the commit — and this very run appends to refresh.log after committing.
299
+ const spec = ["--", rel, `:(exclude)${relPrefix}_index/refresh.log`, `:(exclude)${relPrefix}_index/serve.log`, `:(exclude)${relPrefix}_index/serve.pid`];
300
+ g("add", "-A", ...spec); // new pages need staging; pathspec keeps it surgical
301
+ const c = g("commit", "-m", `chore(codemap): refresh for ${head.slice(0, 7)}`, ...spec);
302
+ if (c.status === 0) {
303
+ const sha = g("rev-parse", "--short", "HEAD").stdout?.trim();
304
+ appendFileSync(logPath, `auto-commit ${sha}\n`);
305
+ console.error(`codemap refresh: committed as ${sha} (chore(codemap): refresh for ${head.slice(0, 7)})`);
306
+ } else {
307
+ console.error(`codemap refresh: nothing to commit (codemap unchanged)`);
308
+ }
309
+ }
310
+ }
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ // geml codemap render — render every codemap document to a sibling .html.
3
+ //
4
+ // geml codemap render [codemap-dir]
5
+ //
6
+ // The output folder then works with NO server: open index.html straight from
7
+ // disk (file://). Module click-through opens each container page inside the
8
+ // graph area (nested frame), so the whole map is browsable offline — this is
9
+ // the "copy the folder to someone" mode. For a live view that never goes
10
+ // stale, use `geml codemap serve` instead.
11
+ import { readdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { join, basename } from "node:path";
13
+ import { parse, renderHtml } from "../dist/geml.js";
14
+
15
+ if (process.argv[2] === "--help" || process.argv[2] === "-h") {
16
+ console.error("usage: geml codemap render [codemap-dir] (dir defaults to ./.geml-code-graph)");
17
+ process.exit(2);
18
+ }
19
+ const dir = process.argv[2] || ".geml-code-graph";
20
+
21
+ // One shared cache for the whole batch: every page's graph slice crosses the
22
+ // same neighbour documents, and a fresh parse per page turns N pages into
23
+ // O(N x working set) — hours at repo scale. A one-shot process has no
24
+ // staleness to worry about, so cache unconditionally (the whole codemap's
25
+ // text + parsed docs live in memory for the duration of the run).
26
+ const texts = new Map(); // rel -> text | null
27
+ const parsed = new Map(); // text -> Document
28
+ const loadDoc = (rel) => {
29
+ if (!texts.has(rel)) {
30
+ try { texts.set(rel, readFileSync(join(dir, rel), "utf8")); } catch { texts.set(rel, null); }
31
+ }
32
+ return texts.get(rel);
33
+ };
34
+ const parseDoc = (s) => {
35
+ let d = parsed.get(s);
36
+ if (!d) { d = parse(s); parsed.set(s, d); }
37
+ return d;
38
+ };
39
+
40
+ let n = 0;
41
+ const failed = [];
42
+ let files;
43
+ try {
44
+ files = readdirSync(dir);
45
+ } catch {
46
+ console.error(`error: cannot read directory ${dir}`);
47
+ process.exit(1);
48
+ }
49
+ for (const f of files) {
50
+ if (!f.endsWith(".geml")) continue;
51
+ try {
52
+ const text = loadDoc(f);
53
+ if (text === null) throw new Error("unreadable");
54
+ const doc = parseDoc(text);
55
+ const html = renderHtml(doc, { source: basename(f), loadDoc, parseDoc });
56
+ writeFileSync(join(dir, f.replace(/\.geml$/, ".html")), html);
57
+ n++;
58
+ } catch (e) {
59
+ failed.push(f);
60
+ console.error(`render: ${f}: ${e.message}`);
61
+ }
62
+ }
63
+ console.error(`rendered ${n} page(s) -> ${dir}${failed.length ? `; FAILED: ${failed.join(", ")}` : ""}`);
64
+ process.exit(failed.length ? 1 : 0);