@geml/geml 1.4.2 → 1.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +186 -155
- package/codemap/adapters/crg.mjs +120 -120
- package/codemap/adapters/joern.mjs +131 -131
- package/codemap/adapters/scip.mjs +658 -658
- package/codemap/browser-stub.mjs +29 -29
- package/codemap/build.mjs +609 -609
- package/codemap/cross-stack.mjs +303 -303
- package/codemap/detect.mjs +399 -399
- package/codemap/emit.mjs +480 -480
- package/codemap/entries.mjs +129 -129
- package/codemap/exclude.mjs +52 -52
- package/codemap/find.mjs +63 -63
- package/codemap/foldings.mjs +110 -110
- package/codemap/joern-export.sc +83 -83
- package/codemap/mcp-server.mjs +172 -172
- package/codemap/normalize.mjs +275 -275
- package/codemap/recipe-trust.mjs +103 -103
- package/codemap/refresh.mjs +310 -310
- package/codemap/render-all.mjs +64 -64
- package/codemap/serve.mjs +578 -578
- package/codemap/sfc-virtualize.mjs +367 -367
- package/codemap/verify.mjs +148 -148
- package/dist/chart.d.ts +2 -0
- package/dist/chart.js +15 -15
- package/dist/diagnostics.d.ts +9 -0
- package/dist/diagnostics.js +73 -0
- package/dist/geml.d.ts +2 -5
- package/dist/geml.js +191 -98
- package/dist/history.d.ts +10 -0
- package/dist/history.js +25 -24
- package/dist/inline.js +7 -7
- package/dist/mcp.d.ts +18 -0
- package/dist/mcp.js +528 -0
- package/dist/render.js +136 -136
- package/dist/table.d.ts +2 -0
- package/dist/table.js +11 -11
- package/package.json +62 -62
package/codemap/refresh.mjs
CHANGED
|
@@ -1,310 +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
|
-
}
|
|
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
|
+
}
|