@geml/geml 1.1.1 → 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/codemap/build.mjs CHANGED
@@ -18,9 +18,10 @@
18
18
  //
19
19
  // Auto mode (no --adapter and no --db, just --root): detect.mjs picks the
20
20
  // indexer per language from manifests + source extensions, we run scip
21
- // (npx @sourcegraph/scip-typescript) and/or Joern (joern-export.sc) into
22
- // <out>/_build/, then feed the results into the SAME merge as the explicit
23
- // --adapter path, and record the replay recipe into _index/refresh.json.
21
+ // (npx @sourcegraph/scip-typescript for TS/JS, rust-analyzer scip for Rust)
22
+ // and/or Joern (joern-export.sc) into <out>/_build/, then feed the results
23
+ // into the SAME merge as the explicit --adapter path, and record the replay
24
+ // recipe into _index/refresh.json.
24
25
  //
25
26
  // Output shape: docs/codemap-profile.md — one document per container (single
26
27
  // meta with module/src/entry, empty-body code blocks with src=/anchor=, and
@@ -41,6 +42,10 @@ import { execFileSync, spawnSync } from "node:child_process";
41
42
  import { emit } from "./emit.mjs";
42
43
  import { makeExcluder } from "./exclude.mjs";
43
44
  import { detectLanguages, indexerCommand, collectSourceFiles } from "./detect.mjs";
45
+ import { loadOrSeedFoldings } from "./foldings.mjs";
46
+ import { detectEntries } from "./entries.mjs";
47
+ import { discoverModuleRoots } from "./normalize.mjs";
48
+ import { recipeFingerprint, trustRecipe, RECIPE_VERSION } from "./recipe-trust.mjs";
44
49
 
45
50
  const args = process.argv.slice(2);
46
51
  const flag = (name, dflt) => {
@@ -48,7 +53,15 @@ const flag = (name, dflt) => {
48
53
  return i >= 0 ? args[i + 1] : dflt;
49
54
  };
50
55
 
51
- const root = flag("--root");
56
+ const USAGE = [
57
+ "usage: geml codemap build [--root <repo-root>] # auto-detect languages, index, and merge (--root defaults to the current directory)",
58
+ " or: geml codemap build (--db <graph.db> | --adapter joern|scip --raw <dir|index.scip> [--remap <virtual-dir>])+ [--root <repo-root>] [--out .geml-code-graph] [--build .geml-code-graph/_build] [--container module|dir|file] [--lang <LANG>] [--joern <path>] [--exclude <glob>]... [--no-gitignore] [--history [-m msg]]",
59
+ ].join("\n");
60
+ if (args.includes("--help") || args.includes("-h")) { console.log(USAGE); process.exit(0); }
61
+
62
+ // --root defaults to the current directory, so `geml codemap build` with no
63
+ // arguments indexes the repo you're standing in.
64
+ const root = flag("--root", ".");
52
65
  const outDir = resolve(flag("--out", ".geml-code-graph"));
53
66
  // Intermediates live INSIDE the codemap dir (alongside _index) so a build
54
67
  // leaves nothing scattered at the repo root — `.geml-code-graph/_build/`.
@@ -65,6 +78,11 @@ const inputs = [];
65
78
  if (args[i] === "--adapter") { cur = { adapter: args[++i] }; inputs.push(cur); }
66
79
  else if (args[i] === "--db") { if (!cur) { cur = { adapter: "crg" }; inputs.push(cur); } cur.db = args[++i]; cur = null; }
67
80
  else if (args[i] === "--raw") { if (!cur) { console.error("--raw needs a preceding --adapter"); process.exit(2); } cur.raw = args[++i]; cur = null; }
81
+ // --remap <virtual dir>: the preceding scip input was produced over an
82
+ // SFC virtual dir (sfc-virtualize.mjs) — the adapter maps shadow paths
83
+ // back to the original .vue/.svelte sources. Recorded into refresh.json
84
+ // so replays keep the remapping.
85
+ else if (args[i] === "--remap") { if (!inputs.length) { console.error("--remap needs a preceding --adapter/--raw group"); process.exit(2); } inputs[inputs.length - 1].remap = args[++i]; }
68
86
  }
69
87
  }
70
88
  // ---- auto-detect mode -------------------------------------------------------
@@ -73,15 +91,25 @@ const inputs = [];
73
91
  // the results into `inputs` so the merge below runs UNCHANGED. This is the
74
92
  // one-command onboarding path; the explicit --adapter/--db paths are untouched.
75
93
  let recordRecipe = null; // { rootAbs, steps } — written to refresh.json after emit
94
+ let detectedLanguages = []; // languages seen in auto-detect; seeds foldings' language conventions
95
+ let entryHints = []; // app-entry hints (entries.mjs), matched to symbols in emit
76
96
  if (root && !inputs.length) {
77
97
  const rootAbs = resolve(root);
78
98
  const excludeGlobs0 = args.flatMap((v, i) => (args[i - 1] === "--exclude" ? [v] : []));
79
- const { files, manifests } = collectSourceFiles(rootAbs);
99
+ const { files, manifests, pkgs } = collectSourceFiles(rootAbs);
80
100
  const excluder = makeExcluder({
81
101
  root: rootAbs, globs: excludeGlobs0, gitignore: !args.includes("--no-gitignore"),
82
- files: [...files, ...manifests], exec: execFileSync,
102
+ files: [...files, ...manifests, ...pkgs], exec: execFileSync,
103
+ });
104
+ const jobs = detectLanguages(rootAbs, { files, manifests, pkgs, excluder });
105
+ detectedLanguages = [...new Set(jobs.map((j) => j.language))];
106
+ // App-entry hints from manifests/layout/source markers — pure detection now,
107
+ // matched to extracted symbols (or noted file-level) inside emit.
108
+ entryHints = detectEntries(rootAbs, {
109
+ files: files.filter((f) => !excluder(f)),
110
+ manifests: manifests.filter((m) => !excluder(m)),
111
+ pkgs: (pkgs ?? []).filter((p) => !excluder(p)),
83
112
  });
84
- const jobs = detectLanguages(rootAbs, { files, manifests, excluder });
85
113
 
86
114
  // --lang forces the Joern frontend (GEML_LANG) — the escape hatch for a
87
115
  // mixed repo whose majority language isn't the one you want. Joern jobs only.
@@ -95,21 +123,38 @@ if (root && !inputs.length) {
95
123
 
96
124
  if (!jobs.length) {
97
125
  console.error(`could not auto-detect a supported language under ${rootAbs}.`);
98
- console.error("supported: TypeScript/JS (scip); Java, C, Python, Go, Kotlin (joern).");
126
+ console.error("supported: TypeScript/JS, Rust (scip); Java, C, Python, Go, Kotlin (joern).");
99
127
  console.error("pass an explicit --adapter scip|joern --raw <in> or --db <graph.db> instead (geml codemap build --help).");
100
128
  process.exit(1);
101
129
  }
102
130
 
103
- // Shell-quote a single token so a spaced path (e.g. C:\Program Files\…) or
104
- // the codemap dir survives cmd.exe / sh word-splitting.
131
+ // Space-aware quote: wrap a token in double quotes only when it contains
132
+ // whitespace or a quote. Used for (a) the PROGRAM token of a spawned command
133
+ // and (b) recording human-readable recipe steps into refresh.json. The
134
+ // program token must NOT be blanket-quoted: a bare launcher name resolved via
135
+ // PATH (npx / joern) whose .cmd/.bat shim uses %~dp0 breaks if the name is
136
+ // quoted — cmd then resolves %~dp0 against the cwd, not the shim's dir. A
137
+ // spaced launcher PATH is a full path, so quoting it keeps %~dp0 correct.
105
138
  const q = (s) => (/[\s"]/.test(String(s)) ? `"${String(s).replace(/"/g, '\\"')}"` : String(s));
106
- // Run a command PORTABLY: on Windows go through cmd.exe (shell) so npx.cmd /
107
- // joern.bat resolve, quoting each token so spaced paths survive; on unix exec
108
- // the binary directly (execvp searches PATH, no quoting pitfalls). Mirrors the
109
- // `shell: process.platform === "win32"` pattern verify.mjs uses.
139
+ // Hardened quote for command ARGUMENTS on win32. Node does NOT escape args
140
+ // under shell:true it only concatenates them (Node DEP0190) so an
141
+ // unquoted argument such as a source directory named `a&calc` reaching the
142
+ // --output path would break out of the command and run `calc`. ALWAYS wrap in
143
+ // double quotes: inside quotes cmd.exe treats & | < > ( ) ^ and whitespace as
144
+ // literal, neutralizing injection while keeping spaced paths intact. Embedded
145
+ // quotes / trailing backslash runs follow the CRT rules so the child's
146
+ // CommandLineToArgvW recovers the exact token.
147
+ const shq = (s) => `"${String(s).replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1')}"`;
148
+ // Run a command PORTABLY. On Windows we MUST go through cmd.exe (shell:true):
149
+ // npx.cmd / joern.bat / rust-analyzer.bat are .cmd/.bat launchers and modern
150
+ // Node refuses to spawn those with shell:false (EINVAL). Build ONE pre-escaped
151
+ // command string ourselves (never an args array — that is the unescaped
152
+ // DEP0190 path): the program via q (bare names stay bare so a shim's %~dp0
153
+ // resolves), every argument via shq (always quoted, so no argument can
154
+ // inject). On unix we exec the binary directly (no shell, no injection).
110
155
  const runCmd = (argv, opts = {}) =>
111
156
  (process.platform === "win32"
112
- ? spawnSync(argv.map(q).join(" "), { shell: true, ...opts })
157
+ ? spawnSync([q(argv[0]), ...argv.slice(1).map(shq)].join(" "), { shell: true, ...opts })
113
158
  : spawnSync(argv[0], argv.slice(1), opts));
114
159
 
115
160
  // Resolve the Joern launcher, honoring an explicit install location so users
@@ -127,9 +172,14 @@ if (root && !inputs.length) {
127
172
  let joernBin = null;
128
173
  const joernJobs = jobs.filter((j) => j.indexer === "joern");
129
174
  if (joernJobs.length) {
175
+ // Joern creates a `workspace/` CPG cache in its CWD on startup — even for
176
+ // `--version`. Probe (and, below, run) it FROM the build dir so that cache
177
+ // lands in _build/, never scattered at the repo root. buildDir must exist
178
+ // first (it is also (re)created before emit); mkdir is idempotent.
179
+ mkdirSync(buildDir, { recursive: true });
130
180
  for (const cand of [flag("--joern"), process.env.GEML_JOERN, "joern"].filter((v) => v)) {
131
181
  const bin = asLauncher(cand);
132
- const r = runCmd([bin, "--version"], { stdio: "ignore" });
182
+ const r = runCmd([bin, "--version"], { stdio: "ignore", cwd: buildDir });
133
183
  if (!r.error && r.status === 0) { joernBin = bin; break; }
134
184
  }
135
185
  if (!joernBin) {
@@ -150,18 +200,87 @@ if (root && !inputs.length) {
150
200
  }
151
201
  }
152
202
 
203
+ // Same courtesy for Rust: rust-analyzer produces the SCIP index, so probe it
204
+ // BEFORE any slow work and fail with install instructions instead of a
205
+ // mid-build spawn error. (A rustup shim without the component installed also
206
+ // answers `--version` non-zero, so it lands here too.)
207
+ if (jobs.some((j) => j.language === "Rust")) {
208
+ const r = runCmd(["rust-analyzer", "--version"], { stdio: "ignore" });
209
+ if (r.error || r.status !== 0) {
210
+ console.error(
211
+ "rust-analyzer is required for Rust but was not found on PATH (or is not runnable).\n"
212
+ + "Install it and retry:\n"
213
+ + " rustup component add rust-analyzer # rustup-managed toolchains\n"
214
+ + " or download a release binary: https://github.com/rust-lang/rust-analyzer/releases\n"
215
+ + "and make sure `rust-analyzer --version` works in this shell.",
216
+ );
217
+ process.exit(1);
218
+ }
219
+ }
220
+
153
221
  // Transparent plan before doing any slow work.
154
- console.error(`detected: ${jobs.map((j) => `${j.language} (${j.signal}) -> ${j.indexer}${j.gemlLang ? `[${j.gemlLang}]` : ""}`).join("; ")}`);
222
+ // A monorepo with vendored trees (next.js's src/compiled: 140 package.json
223
+ // bundles) turns the full job list into a wall — summarize past 10.
224
+ if (jobs.length > 10) {
225
+ const byLang = new Map();
226
+ for (const j of jobs) byLang.set(j.language, (byLang.get(j.language) ?? 0) + 1);
227
+ const langs = [...byLang].map(([l, n]) => (n > 1 ? `${l}×${n}` : l)).join(", ");
228
+ const sample = jobs.slice(0, 5).map((j) => j.subroot ?? j.language).join("; ");
229
+ console.error(`detected: ${jobs.length} jobs (${langs}) — e.g. ${sample}; … (vendored trees inflating this? --exclude "path/**" trims them)`);
230
+ } else {
231
+ console.error(`detected: ${jobs.map((j) => `${j.language}${j.subroot ? `[${j.subroot}]` : ""} (${j.signal}) -> ${j.indexer}${j.gemlLang ? `[${j.gemlLang}]` : ""}`).join("; ")}`);
232
+ }
155
233
 
156
234
  const scriptPath = resolve(dirname(fileURLToPath(import.meta.url)), "joern-export.sc");
157
235
  const scriptPosix = scriptPath.replace(/\\/g, "/");
236
+ const sfcScript = resolve(dirname(fileURLToPath(import.meta.url)), "sfc-virtualize.mjs");
237
+ const sfcScriptPosix = sfcScript.replace(/\\/g, "/");
158
238
  const relToRoot = (p) => (relative(rootAbs, p).replace(/\\/g, "/") || ".");
159
239
  mkdirSync(buildDir, { recursive: true });
240
+ // Structured recipe steps { cwd?, env?, argv:[...] } (security fix R2-1).
241
+ // Attacker-controllable sub-project dir names appear here ONLY as DISCRETE
242
+ // structured values (a step's cwd, or an argv element) — NEVER concatenated
243
+ // into a shell string at rest. refresh executes each step without building an
244
+ // attacker-influenced command line (see codemap/refresh.mjs).
160
245
  const indexSteps = [];
246
+ // Build a recorded step's env map, dropping undefined values so the step's
247
+ // fingerprint stays stable (GEML_LANG is unset for scip jobs).
248
+ const envOf = (obj) => {
249
+ const env = {};
250
+ for (const [k, v] of Object.entries(obj)) if (v != null) env[k] = String(v);
251
+ return env;
252
+ };
161
253
 
162
254
  console.error("indexing...");
255
+ const failedLangs = [];
163
256
  for (const job of jobs) {
164
- const cmd = indexerCommand(job, { root: rootAbs, buildDir, scriptPath });
257
+ let cmd = indexerCommand(job, { root: rootAbs, buildDir, scriptPath, sfcScript });
258
+ let preStep = null;
259
+ if (cmd.pre) {
260
+ // SFC job: run the virtualizer first. If it fails (offline npx, exotic
261
+ // SFC syntax), the project must not lose its plain TS coverage — fall
262
+ // back to the sfc-less job and say the gap out loud.
263
+ const pr = runCmd(cmd.pre.argv, {
264
+ cwd: cmd.pre.cwd, stdio: "inherit",
265
+ env: { ...process.env, ...cmd.pre.env },
266
+ });
267
+ if (pr.error || pr.status !== 0) {
268
+ console.error(
269
+ `sfc virtualizer failed for ${job.language}${job.subroot ? `[${job.subroot}]` : ""} `
270
+ + `(${pr.error ? pr.error.message : `exit ${pr.status}`}) — falling back to plain TS indexing; `
271
+ + ".vue/.svelte files stay invisible until this is fixed and build re-runs.",
272
+ );
273
+ cmd = indexerCommand({ ...job, sfc: undefined }, { root: rootAbs, buildDir, scriptPath, sfcScript });
274
+ } else {
275
+ // Runs at root (cmd.pre.cwd === root), so no cwd; the virtualizer reads
276
+ // GEML_SRC/GEML_OUT (relative to root) from env. argv[-1] is the script
277
+ // path — record the forward-slash form.
278
+ preStep = {
279
+ env: envOf({ GEML_SRC: relToRoot(cmd.pre.env.GEML_SRC), GEML_OUT: relToRoot(cmd.pre.env.GEML_OUT) }),
280
+ argv: [...cmd.pre.argv.slice(0, -1), sfcScriptPosix],
281
+ };
282
+ }
283
+ }
165
284
  // scip runs the npx launcher; joern runs the resolved launcher. Env
166
285
  // (GEML_SRC/OUT/LANG for joern) rides through the spawn options.
167
286
  const argv = [job.indexer === "joern" ? joernBin : cmd.argv[0], ...cmd.argv.slice(1)];
@@ -170,31 +289,61 @@ if (root && !inputs.length) {
170
289
  env: cmd.env ? { ...process.env, ...cmd.env } : process.env,
171
290
  });
172
291
  if (r.error || r.status !== 0) {
173
- console.error(`indexer failed for ${job.language} (${job.indexer}): ${r.error ? r.error.message : `exit ${r.status}`}`);
174
- process.exit(1);
292
+ // One language failing must not sink the others' finished work — keep
293
+ // going, build what succeeded, and say the gap out loud below. Name the
294
+ // subroot so a monorepo says WHICH project's indexer died, not just the
295
+ // language (several TS projects can each have their own scip job).
296
+ const where = job.subroot ? `${job.language} at ${job.subroot}` : job.language;
297
+ console.error(`indexer failed for ${where} (${job.indexer}): ${r.error ? r.error.message : `exit ${r.status}`}`);
298
+ failedLangs.push(job.language);
299
+ continue;
175
300
  }
176
- inputs.push({ adapter: cmd.adapter, raw: cmd.raw });
177
- // Recipe step (paths relative to <root>, the cwd refresh replays in). The
178
- // Joern env is written in the RECORDING host's native shell syntax —
179
- // refresh.json is machine-local (it re-invokes locally-installed indexers),
180
- // and cmd.exe ignores the POSIX `VAR=val cmd` prefix.
301
+ inputs.push({ adapter: cmd.adapter, raw: cmd.raw, remap: cmd.remapDir });
302
+ // Recipe step (paths relative to <root>, the cwd refresh replays in)
303
+ // successful steps only, so `refresh` replays a recipe that works.
304
+ if (preStep) indexSteps.push(preStep);
181
305
  if (job.indexer === "scip") {
182
- indexSteps.push(`npx --yes @sourcegraph/scip-typescript index --output ${relToRoot(cmd.raw)}`);
306
+ // Subrooted jobs replay from their own dir (SFC jobs from the virtual
307
+ // dir, standalone crates from the crate dir); the output path is written
308
+ // relative to THAT cwd, recorded as step.cwd (omitted when it is root).
309
+ const relRaw = relative(cmd.cwd, cmd.raw).replace(/\\/g, "/");
310
+ const cwdRel = relToRoot(cmd.cwd);
311
+ const step = {};
312
+ if (cwdRel !== ".") step.cwd = cwdRel;
313
+ step.argv = cmd.argv[0] === "npx"
314
+ ? [...cmd.argv.slice(0, -1), relRaw]
315
+ : ["rust-analyzer", "scip", ".", "--output", relRaw];
316
+ indexSteps.push(step);
183
317
  } else {
184
- const relOut = relToRoot(cmd.raw);
185
- indexSteps.push(process.platform === "win32"
186
- ? `set "GEML_SRC=." && set "GEML_OUT=${relOut}" && set "GEML_LANG=${job.gemlLang}" && joern --script ${q(scriptPosix)}`
187
- : `GEML_SRC=. GEML_OUT=${relOut} GEML_LANG=${job.gemlLang} joern --script ${q(scriptPosix)}`);
318
+ // Joern replays IN the build dir (cmd.cwd), so its workspace cache lands
319
+ // under _build/ on refresh too — not at the repo root. Re-base the env
320
+ // paths on that cwd: GEML_SRC climbs back to the root, GEML_OUT is the raw
321
+ // dir's name (a sibling under _build). Mirrors the subrooted-scip step.
322
+ const relRaw = relative(cmd.cwd, cmd.raw).replace(/\\/g, "/");
323
+ const srcRel = relative(cmd.cwd, rootAbs).replace(/\\/g, "/") || ".";
324
+ const cwdRel = relToRoot(cmd.cwd);
325
+ const step = {
326
+ env: envOf({ GEML_SRC: srcRel, GEML_OUT: relRaw, GEML_LANG: job.gemlLang }),
327
+ argv: ["joern", "--script", scriptPosix],
328
+ };
329
+ if (cwdRel !== ".") step.cwd = cwdRel;
330
+ indexSteps.push(step);
188
331
  }
189
332
  }
333
+ if (!inputs.length) {
334
+ console.error("every indexer failed — nothing to build.");
335
+ process.exit(1);
336
+ }
337
+ if (failedLangs.length) {
338
+ console.error(`WARNING: continuing WITHOUT ${failedLangs.join(", ")} — the codemap covers the remaining language(s) only. Fix that indexer and re-run build to fill the gap.`);
339
+ }
190
340
  console.error("merging...");
191
341
  recordRecipe = { rootAbs, indexSteps };
192
342
  }
193
343
 
194
344
  const bad = inputs.find((s) => !["crg", "joern", "scip"].includes(s.adapter) || (s.adapter === "crg" ? !s.db : !s.raw));
195
- if (!root || !inputs.length || bad) {
196
- console.error("usage: geml codemap build --root <repo-root> # auto-detect languages, index, and merge");
197
- console.error(" or: geml codemap build (--db <graph.db> | --adapter joern|scip --raw <dir|index.scip>)+ --root <repo-root> [--out .geml-code-graph] [--build .geml-code-graph/_build] [--container module|dir|file] [--lang <LANG>] [--joern <path>] [--exclude <glob>]... [--no-gitignore] [--history [-m msg]]");
345
+ if (!inputs.length || bad) {
346
+ console.error(USAGE);
198
347
  process.exit(2);
199
348
  }
200
349
 
@@ -206,7 +355,7 @@ const edges = [];
206
355
  const seenAnchors = new Set();
207
356
  for (const spec of inputs) {
208
357
  const { extract } = await import(`./adapters/${spec.adapter}.mjs`);
209
- const r = extract(spec.adapter === "crg" ? { db: spec.db, root } : { raw: spec.raw, root });
358
+ const r = extract(spec.adapter === "crg" ? { db: spec.db, root } : { raw: spec.raw, root, remapDir: spec.remap });
210
359
  let dropped = 0;
211
360
  for (const s of r.symbols) {
212
361
  if (seenAnchors.has(s.anchor)) { dropped++; continue; }
@@ -232,6 +381,23 @@ const excluder = makeExcluder({
232
381
  });
233
382
  const kept = symbols.filter((s) => !excluder(s.file));
234
383
  const excludedCount = symbols.length - kept.length;
384
+
385
+ // App-entry hints for the EXPLICIT-adapter path too (auto mode computed them
386
+ // alongside language detection): the entry signals live in the repo's
387
+ // manifests and sources, not in how the indexes were produced.
388
+ if (root && !recordRecipe && !entryHints.length) {
389
+ const rootAbs = resolve(root);
390
+ const c = collectSourceFiles(rootAbs);
391
+ const excl = makeExcluder({
392
+ root: rootAbs, globs: excludeGlobs, gitignore: !args.includes("--no-gitignore"),
393
+ files: [...c.files, ...c.manifests, ...c.pkgs], exec: execFileSync,
394
+ });
395
+ entryHints = detectEntries(rootAbs, {
396
+ files: c.files.filter((f) => !excl(f)),
397
+ manifests: c.manifests.filter((m) => !excl(m)),
398
+ pkgs: c.pkgs.filter((p) => !excl(p)),
399
+ });
400
+ }
235
401
  if (excludedCount) {
236
402
  symbols.length = 0;
237
403
  for (const s of kept) symbols.push(s);
@@ -269,14 +435,34 @@ try {
269
435
  commit = execFileSync("git", ["-C", resolve(root), "rev-parse", "--short", "HEAD"], { encoding: "utf8" }).trim();
270
436
  } catch { /* not a git repo */ }
271
437
 
438
+ // Ceremony-folding config: read _index/foldings.geml, or seed it on this first
439
+ // build from the discovered module roots + detected languages. Human-owned
440
+ // once seeded (never rewritten); threaded into emit for display normalisation.
441
+ const { config: foldings, seeded: foldingsSeeded } = loadOrSeedFoldings({
442
+ outDir,
443
+ moduleRoots: discoverModuleRoots(resolve(root)),
444
+ languages: detectedLanguages,
445
+ });
446
+ if (foldingsSeeded) console.error("seeded _index/foldings.geml — edit to tune module folding");
447
+
272
448
  const stats = emit({
273
449
  symbols, edges, outDir, buildDir,
274
450
  repoName: basename(resolve(root)),
275
451
  container: containerGranularity,
276
452
  commit,
277
453
  root: resolve(root),
454
+ foldings,
455
+ entryHints,
278
456
  });
279
457
 
458
+ // Keep the transient build dir out of version control while the `.geml` graph
459
+ // and `_index/` stay committable (the graph is meant to be committed & shared).
460
+ // `_build/` holds only regenerable intermediates — the *.jsonl exchange files
461
+ // and Joern's `workspace/` CPG cache — so one ignore rule covers them all.
462
+ // Written once; a user's later edits to this file are preserved, never clobbered.
463
+ const ignoreFile = join(outDir, ".gitignore");
464
+ if (!existsSync(ignoreFile)) writeFileSync(ignoreFile, "_build/\n");
465
+
280
466
  console.error(
281
467
  `geml-code-graph: ${stats.methods} methods (${stats.symbols} symbols), ${stats.edges} edges `
282
468
  + `(${stats.resolved} resolved), ${stats.leaves} leaves, ${stats.entries} app entries -> `
@@ -329,26 +515,65 @@ if (args.includes("--history")) {
329
515
 
330
516
  // Auto mode records the exact replay recipe (index → explicit build → verify)
331
517
  // into _index/refresh.json on the FIRST build, so `geml codemap refresh` (and
332
- // the commit hook) can reproduce it. An existing recipe is left untouched.
333
- // Paths are relative to <root>, which is the cwd refresh runs each step in.
518
+ // the commit hook) can reproduce it. An existing recipe is left untouched
519
+ // EXCEPT one whose on-disk schema `version` does not match RECIPE_VERSION:
520
+ // refresh refuses an out-of-date recipe, so a rebuild re-records it in the
521
+ // current format. Judging by a standalone schema version (not the parser
522
+ // version, which bumps every patch) means a FUTURE format change is cleanly
523
+ // detected without a parser bump forcing a needless re-index. This is the
524
+ // "re-run build" upgrade path refresh points users to; a recipe already at the
525
+ // current version stays write-once (not clobbered). Paths are relative to
526
+ // <root>, which is the cwd refresh runs each step in.
334
527
  if (recordRecipe) {
335
528
  const cfgPath = join(outDir, "_index", "refresh.json");
336
- if (!existsSync(cfgPath)) {
529
+ let needsRerecord = false;
530
+ if (existsSync(cfgPath)) {
531
+ try { needsRerecord = JSON.parse(readFileSync(cfgPath, "utf8")).version !== RECIPE_VERSION; }
532
+ catch { needsRerecord = true; } // unparseable → re-record clean
533
+ }
534
+ if (!existsSync(cfgPath) || needsRerecord) {
337
535
  const rel = (p) => (relative(recordRecipe.rootAbs, p).replace(/\\/g, "/") || ".");
338
536
  const relOut = rel(outDir);
339
- const buildStep = ["geml codemap build",
340
- ...inputs.map((s) => `--adapter ${s.adapter} --raw ${rel(s.raw)}`),
341
- "--root .", `--out ${relOut}`,
342
- containerGranularity !== "dir" ? `--container ${containerGranularity}` : "",
343
- args.includes("--history") ? "--history" : "",
344
- ].filter(Boolean).join(" ");
537
+ // Structured build + verify steps (security fix R2-1): argv arrays, never a
538
+ // shell string. Each `--adapter/--raw[/--remap]` group is discrete tokens.
539
+ const buildArgv = ["geml", "codemap", "build",
540
+ ...inputs.flatMap((s) => ["--adapter", s.adapter, "--raw", rel(s.raw), ...(s.remap ? ["--remap", rel(s.remap)] : [])]),
541
+ "--root", ".", "--out", relOut,
542
+ ...(containerGranularity !== "dir" ? ["--container", containerGranularity] : []),
543
+ ...(args.includes("--history") ? ["--history"] : []),
544
+ ];
545
+ // Parser version — recorded as `generator` PROVENANCE only, never as part of
546
+ // the compatibility check or the fingerprint (it bumps every patch release;
547
+ // judging by it would force a full re-index of every project each release).
548
+ const pkgVersion = (() => {
549
+ try { return JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version; }
550
+ catch { return "?"; }
551
+ })();
345
552
  const cfg = {
346
- root: relative(outDir, recordRecipe.rootAbs).replace(/\\/g, "/") || "..",
347
- steps: [...recordRecipe.indexSteps, buildStep, `geml codemap verify ${relOut}`],
553
+ version: RECIPE_VERSION,
554
+ generator: `geml ${pkgVersion}`,
555
+ // Project root relative to the codemap dir (refresh runs each step under
556
+ // <root>). Normally outDir is a subdir of root so relative() yields ".."
557
+ // etc.; when --out == --root it yields "" and the project root IS the
558
+ // codemap dir, so record "." — recording ".." would send refresh into
559
+ // the PARENT of the real root.
560
+ root: relative(outDir, recordRecipe.rootAbs).replace(/\\/g, "/") || ".",
561
+ steps: [...recordRecipe.indexSteps, { argv: buildArgv }, { argv: ["geml", "codemap", "verify", relOut] }],
348
562
  };
349
563
  mkdirSync(join(outDir, "_index"), { recursive: true });
350
564
  writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + "\n");
351
565
  console.error(`recorded build recipe -> ${cfgPath}`);
566
+ // Auto-trust the recipe we just authored (security fix C2). The user ran
567
+ // build locally, so their own recipe is trusted by construction and the
568
+ // normal build -> refresh flow needs no prompt. Uses the SAME fingerprint
569
+ // fn as refresh, so the two agree exactly. Best-effort: a trust-store write
570
+ // failure must not fail an otherwise-successful build — the user can still
571
+ // approve later with `geml codemap refresh --trust`.
572
+ try {
573
+ trustRecipe(recipeFingerprint(cfg), outDir);
574
+ } catch (e) {
575
+ console.error(`warning: could not record the codemap recipe as trusted (${e.message}); run \`geml codemap refresh --trust\` after reviewing _index/refresh.json`);
576
+ }
352
577
  }
353
578
  }
354
579
  console.error(`next: geml codemap verify ${outDir}`);