@geml/geml 1.0.0 → 1.1.1

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,126 @@
1
+ #!/usr/bin/env node
2
+ // geml-code-graph verify — the codemap's correctness oracle, two passes:
3
+ //
4
+ // 1. `geml check` over every .geml (document structure, id uniqueness,
5
+ // native references).
6
+ // 2. The codemap-profile pass (docs/codemap-profile.md): CSV cells and meta
7
+ // values are opaque to the GEML standard BY DESIGN (the standard stays
8
+ // untouched), so edge integrity is checked here — the from/to columns of
9
+ // #calls / #called-by / #ref-by tables and every meta `entry` value must
10
+ // resolve (`#id` in the same document, `doc.geml#id` in a sibling).
11
+ // A renamed or deleted method therefore fails the build, not the reader.
12
+ //
13
+ // geml codemap verify [dir] [--geml <path-to-geml.js|geml>]
14
+ import { readdirSync, existsSync, readFileSync } from "node:fs";
15
+ import { join, resolve, dirname, relative } from "node:path";
16
+ import { posix } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { spawnSync } from "node:child_process";
19
+
20
+ const args = process.argv.slice(2);
21
+ const flagI = args.indexOf("--geml");
22
+ if (args.includes("--help") || args.includes("-h")) {
23
+ console.error("usage: geml codemap verify [dir] [--geml <path>] (dir defaults to ./.geml-code-graph)");
24
+ process.exit(2);
25
+ }
26
+ const dir = args.find((a, i) => !a.startsWith("-") && (flagI < 0 || i !== flagI + 1)) || ".geml-code-graph";
27
+ const rootDir = resolve(dir);
28
+
29
+ // Resolve the geml CLI (pass 1) and the parser API (pass 2).
30
+ const localParser = resolve(dirname(fileURLToPath(import.meta.url)), "../dist/geml.js");
31
+ let cli = flagI >= 0 ? args[flagI + 1] : undefined;
32
+ if (!cli) cli = existsSync(localParser) ? localParser : "geml";
33
+ const runCheck = (file) => cli.endsWith(".js")
34
+ ? spawnSync(process.execPath, [cli, "check", file], { encoding: "utf8" })
35
+ : spawnSync(cli, ["check", file], { encoding: "utf8", shell: process.platform === "win32" });
36
+ if (!existsSync(localParser)) {
37
+ console.error("verify: the profile pass needs the built parser (cd geml-parser && npm install && npm run build)");
38
+ process.exit(1);
39
+ }
40
+ const { parse } = await import(`file://${localParser.replace(/\\/g, "/")}`);
41
+
42
+ const files = [];
43
+ const walk = (d) => {
44
+ for (const e of readdirSync(d, { withFileTypes: true })) {
45
+ const p = join(d, e.name);
46
+ if (e.isDirectory()) walk(p);
47
+ else if (e.name.endsWith(".geml")) files.push(p);
48
+ }
49
+ };
50
+ walk(rootDir);
51
+ files.sort();
52
+
53
+ // ---- pass 1: geml check ----
54
+ let failed = 0;
55
+ for (const f of files) {
56
+ const r = runCheck(f);
57
+ if (r.status !== 0) {
58
+ failed++;
59
+ console.error(`FAIL ${f}`);
60
+ console.error((r.stderr || r.stdout || "").split("\n").slice(0, 4).map((l) => ` ${l}`).join("\n"));
61
+ }
62
+ }
63
+
64
+ // ---- pass 2: codemap profile references ----
65
+ const REF_TABLES = new Set(["calls", "called-by", "ref-by"]);
66
+ const relDoc = (f) => relative(rootDir, f).replace(/\\/g, "/");
67
+ const docs = new Map(); // relPath -> { ids:Set, blocks }
68
+ const collectIds = (blocks, ids) => {
69
+ for (const b of blocks) {
70
+ if (b.id) ids.add(b.id);
71
+ if (b.children) collectIds(b.children, ids);
72
+ if (b.items) for (const it of b.items) if (it.children) collectIds(it.children, ids);
73
+ }
74
+ };
75
+ for (const f of files) {
76
+ const doc = parse(readFileSync(f, "utf8"));
77
+ const ids = new Set();
78
+ collectIds(doc.children, ids);
79
+ docs.set(relDoc(f), { ids, blocks: doc.children });
80
+ }
81
+
82
+ let refErrors = 0;
83
+ const err = (doc, where, msg) => {
84
+ refErrors++;
85
+ console.error(`REF ${doc} ${where}: ${msg}`);
86
+ };
87
+ const checkRef = (fromDoc, where, ref) => {
88
+ ref = String(ref).trim();
89
+ if (!ref) return err(fromDoc, where, "empty reference cell");
90
+ const h = ref.indexOf("#");
91
+ if (h < 0) return err(fromDoc, where, `not a reference: \`${ref}\``);
92
+ let targetDoc = fromDoc;
93
+ if (h > 0) targetDoc = posix.normalize(posix.join(posix.dirname(fromDoc), ref.slice(0, h)));
94
+ const id = ref.slice(h + 1);
95
+ const target = docs.get(targetDoc);
96
+ if (!target) return err(fromDoc, where, `cannot resolve document \`${ref.slice(0, h)}\``);
97
+ // reads/writes values may carry a plain-text `.member` suffix (ids never contain '.')
98
+ const bare = id.split(".")[0];
99
+ if (!target.ids.has(bare)) return err(fromDoc, where, `unresolved reference \`${ref}\``);
100
+ };
101
+
102
+ for (const [docPath, { blocks }] of docs) {
103
+ for (const b of blocks) {
104
+ if (b.kind !== "block") continue;
105
+ if (b.type === "table" && REF_TABLES.has(b.id) && b.table) {
106
+ const fromCol = b.table.columns.indexOf("from");
107
+ const toCol = b.table.columns.indexOf("to");
108
+ if (fromCol < 0 || toCol < 0) { err(docPath, `#${b.id}`, "missing from/to columns"); continue; }
109
+ b.table.rows.forEach((row, i) => {
110
+ checkRef(docPath, `#${b.id} row ${i + 1} from`, row[fromCol]?.text ?? "");
111
+ checkRef(docPath, `#${b.id} row ${i + 1} to`, row[toCol]?.text ?? "");
112
+ });
113
+ }
114
+ if (b.type === "meta" && b.data?.entry) {
115
+ for (const ref of String(b.data.entry).split(/\s+/).filter(Boolean)) {
116
+ checkRef(docPath, "meta entry", ref);
117
+ }
118
+ }
119
+ }
120
+ }
121
+
122
+ console.error(
123
+ `verify: ${files.length - failed}/${files.length} documents pass geml check; `
124
+ + `profile references: ${refErrors === 0 ? "all resolve" : `${refErrors} dangling`}`,
125
+ );
126
+ process.exit(failed || refErrors ? 1 : 0);
package/dist/geml.d.ts CHANGED
@@ -68,3 +68,8 @@ export interface ParseOptions {
68
68
  resolveDoc?: (doc: string) => string | null;
69
69
  }
70
70
  export declare function parse(source: string, opts?: ParseOptions): Document;
71
+ export interface Span {
72
+ start: number;
73
+ end: number;
74
+ }
75
+ export declare function blockSpans(source: string): Map<string, Span>;
package/dist/geml.js CHANGED
@@ -9,8 +9,10 @@
9
9
  // media embeds, links, auto-references, footnotes) and build-time reference
10
10
  // validation (§8 — unique ids, resolvable internal/cross-document references).
11
11
  import { readFileSync, writeFileSync } from "node:fs";
12
- import { basename, dirname, resolve as resolvePath } from "node:path";
13
- import { commit, restore, verify } from "./history.js";
12
+ import { basename, dirname, join, resolve as resolvePath } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { spawnSync } from "node:child_process";
15
+ import { commit, restore, verify, listRevisions, resolveContent, firstChangedContent } from "./history.js";
14
16
  import { renderHtml } from "./render.js";
15
17
  import { coerce, parseAttrs } from "./attrs.js";
16
18
  import { parseInline } from "./inline.js";
@@ -32,12 +34,11 @@ const REGISTRY = {
32
34
  table: "raw", // structured table parsing lands in M3
33
35
  output: "raw", // captured result of a code block (stored, never executed)
34
36
  note: "flow",
35
- aside: "flow",
36
37
  meta: "data",
37
38
  };
38
39
  // §7: built-in diagram renderer registry. Unknown formats are a warning (the
39
40
  // processor keeps the body raw rather than interpreting it).
40
- const DIAGRAM_RENDERERS = new Set(["mermaid", "graphviz", "dot", "d2", "plantuml", "geml-chart"]);
41
+ const DIAGRAM_RENDERERS = new Set(["mermaid", "graphviz", "dot", "d2", "plantuml", "geml-chart", "geml-code-graph"]);
41
42
  // ---------------------------------------------------------------------------
42
43
  // Lexical helpers
43
44
  // ---------------------------------------------------------------------------
@@ -143,6 +144,11 @@ function parseList(lines, i, base, ctx) {
143
144
  stack.push({ list: cur, indent: mk.indent });
144
145
  }
145
146
  else {
147
+ // §5: a change of marker type (bullet ↔ ordered) at the same level ends
148
+ // this list; scanBlocks then opens a fresh one at this marker. Without it,
149
+ // `- a` then `1. b` would merge into one mis-typed list (CommonMark §5.3).
150
+ if (mk.ordered !== top.list.ordered)
151
+ break;
146
152
  cur = top.list;
147
153
  }
148
154
  if (prevBlank && cur.items.length > 0)
@@ -265,6 +271,21 @@ function scanBlocks(lines, base, ctx) {
265
271
  }
266
272
  (ctx.charts ??= []).push({ block, line: openLineNo });
267
273
  }
274
+ else if (fmt === "geml-code-graph") {
275
+ // Code-graph embed (GEP-0003): the ONLY attribute is src=, pointing
276
+ // at a codemap document; roots/depth come from that document's meta
277
+ // ("view config travels with the data"). Body is empty.
278
+ const src = attrs.attrs["src"];
279
+ if (typeof src !== "string" || src === "") {
280
+ diags.push({ severity: "warning", message: "geml-code-graph: missing `src=` (nothing to render)", line: openLineNo });
281
+ }
282
+ else if (ctx.resolveDoc && ctx.resolveDoc(src) === null) {
283
+ diags.push({ severity: "warning", message: `geml-code-graph: cannot resolve document \`${src}\``, line: openLineNo });
284
+ }
285
+ if (body.length > 0 && body.some((l) => l.trim() !== "")) {
286
+ diags.push({ severity: "warning", message: "geml-code-graph body is ignored; the embed is configured by `src=` alone", line: openLineNo });
287
+ }
288
+ }
268
289
  else if (typeof fmt === "string" && !DIAGRAM_RENDERERS.has(fmt)) {
269
290
  // §7: warn on a diagram format with no registered renderer.
270
291
  diags.push({ severity: "warning", message: `no registered renderer for diagram format \`${fmt}\`; body kept raw`, line: openLineNo });
@@ -424,12 +445,115 @@ function resolveCharts(ctx) {
424
445
  }
425
446
  export function parse(source, opts = {}) {
426
447
  const lines = source.replace(/\r\n?/g, "\n").split("\n");
427
- const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines) };
448
+ const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines), resolveDoc: opts.resolveDoc };
428
449
  const children = scanBlocks(lines, 0, ctx);
429
450
  resolveCharts(ctx);
430
451
  validateRefs(ctx, opts);
431
452
  return { kind: "document", children, ids: [...ctx.ids.keys()], diagnostics: ctx.diags };
432
453
  }
454
+ // The id that a fence/heading line defines, matching how scanBlocks derives it
455
+ // (parseAttrs for the attribute object; heading text slug when no explicit id).
456
+ function idOfHeading(braces, text) {
457
+ return (braces ? parseAttrs(braces).id : undefined) ?? slug(text);
458
+ }
459
+ // Walk `lines` exactly as scanBlocks does — same fence close rules (equal-length
460
+ // or labeled `=== #id`), same flow-only recursion via REGISTRY — recording the
461
+ // source span of every addressable id (typed block, heading, footnote def).
462
+ // First definition wins, mirroring ctx.ids (a duplicate id is a build error, so
463
+ // `get`/`set` operate on the one the parser actually registered). `base` is the
464
+ // absolute line offset of this slice within the whole document.
465
+ function collectSpans(lines, base, out) {
466
+ const add = (id, start, end) => {
467
+ if (!out.has(id))
468
+ out.set(id, { start, end });
469
+ };
470
+ let i = 0;
471
+ while (i < lines.length) {
472
+ const line = lines[i];
473
+ if (line.trim() === "") {
474
+ i++;
475
+ continue;
476
+ }
477
+ const fndef = /^\[\^([^\]]+)\]:[ \t]?(.*)$/.exec(line);
478
+ if (fndef) {
479
+ add(fndef[1].trim(), base + i, base + i + 1);
480
+ i++;
481
+ continue;
482
+ }
483
+ if (/^[ \t]*%%/.test(line)) {
484
+ i++;
485
+ continue;
486
+ } // hidden line: no id
487
+ const open = FENCE_OPEN.exec(line);
488
+ if (open) {
489
+ const openLen = open[1].length;
490
+ const type = open[2];
491
+ const id = open[3] ? parseAttrs(open[3]).id : undefined;
492
+ const labeled = id !== undefined ? new RegExp(`^={3,}[ \\t]+#${id}[ \\t]*$`) : null;
493
+ let j = i + 1;
494
+ let closed = false;
495
+ for (; j < lines.length; j++) {
496
+ if (isCloseFence(lines[j], openLen) || (labeled && labeled.test(lines[j]))) {
497
+ closed = true;
498
+ break;
499
+ }
500
+ }
501
+ const end = closed ? j + 1 : j;
502
+ if (id !== undefined)
503
+ add(id, base + i, base + end);
504
+ // Only a flow body is scanned for nested blocks (raw/data bodies are
505
+ // opaque), so an id inside a `code` body is *not* addressable — exactly
506
+ // the parser's contract.
507
+ if ((REGISTRY[type] ?? "raw") === "flow") {
508
+ collectSpans(lines.slice(i + 1, closed ? j : end), base + i + 1, out);
509
+ }
510
+ i = end;
511
+ continue;
512
+ }
513
+ const h = HEADING.exec(line);
514
+ if (h) {
515
+ add(idOfHeading(h[3], h[2]), base + i, base + i + 1);
516
+ i++;
517
+ continue;
518
+ }
519
+ i++;
520
+ }
521
+ }
522
+ // Map every addressable id in `source` to its source span. Line indices align
523
+ // with the physical lines produced by splitLines(source).
524
+ export function blockSpans(source) {
525
+ const out = new Map();
526
+ collectSpans(source.replace(/\r\n?/g, "\n").split("\n"), 0, out);
527
+ return out;
528
+ }
529
+ // Split into physical lines while *keeping* each line's terminator, so
530
+ // join("") is byte-exact and slicing by span never rewrites line endings.
531
+ function splitLines(source) {
532
+ return source.split(/(?<=\n)/);
533
+ }
534
+ // Depth-first search for the document-model node carrying `id`, descending into
535
+ // flow-block children (and list-item children) so a nested id is found too.
536
+ function findBlockById(blocks, id) {
537
+ for (const b of blocks) {
538
+ if ((b.kind === "heading" || b.kind === "block") && b.id === id)
539
+ return b;
540
+ if (b.kind === "block" && b.children) {
541
+ const hit = findBlockById(b.children, id);
542
+ if (hit)
543
+ return hit;
544
+ }
545
+ if (b.kind === "list") {
546
+ for (const it of b.items) {
547
+ if (it.children) {
548
+ const hit = findBlockById(it.children, id);
549
+ if (hit)
550
+ return hit;
551
+ }
552
+ }
553
+ }
554
+ }
555
+ return undefined;
556
+ }
433
557
  // ---------------------------------------------------------------------------
434
558
  // CLI
435
559
  // ---------------------------------------------------------------------------
@@ -447,18 +571,22 @@ function parseStamp(s) {
447
571
  const [, y, mo, d, h, mi, se] = m;
448
572
  return new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +se));
449
573
  }
450
- const VERSION = "1.0-draft"; // GEML spec version this CLI targets
451
- const PARSER_VERSION = "1.0.0"; // reference implementation; keep in sync with package.json
574
+ const VERSION = "1.0"; // GEML spec version this CLI targets
575
+ const PARSER_VERSION = "1.1.1"; // reference implementation; keep in sync with package.json
452
576
  const USAGE = `geml — GEML reference CLI
453
577
 
454
578
  Usage:
455
579
  geml <file.geml|-> parse -> document-model JSON (stdout)
580
+ geml get <file.geml|-> #id [--json] print ONE block by id (raw span, or --json node)
581
+ geml set <file.geml|-> #id [--from f][-o f] replace ONE block by id (new content: --from/stdin)
582
+ geml revert <file.geml> #id [--to <sel>] restore ONE block to a past revision (sel: -N|latest|id)
456
583
  geml check <file.geml|-> [--json] validate only: diagnostics + exit code
457
584
  geml render <file.geml|-> [-o out.html] render to one self-contained HTML file
458
585
  geml fmt <file.geml|-> [-o out.geml] re-serialize to canonical GEML
459
586
  geml convert <file.md|-> [-o out.geml] Markdown -> GEML
460
587
  geml export <file.geml|-> [-o out.md] GEML -> Markdown (lossy)
461
- geml history <commit|verify|show|restore> <file.geml> [...]
588
+ geml history <commit|verify|show|restore|log> <file.geml> [...]
589
+ geml codemap <build|verify|render|serve|refresh|mcp> [...] code-graph toolkit (geml codemap --help)
462
590
  geml --help | --version [--json]
463
591
 
464
592
  Use '-' as the file to read from stdin.
@@ -466,23 +594,36 @@ Exit codes: 0 ok · 1 document/operation error · 2 usage error.`;
466
594
  // One-line usage for each subcommand — the single source for both the error
467
595
  // shown on misuse and the `<cmd> --help` text.
468
596
  const SUBHELP = {
597
+ get: "usage: geml get <file.geml|-> #id [--json]",
598
+ set: "usage: geml set <file.geml|-> #id [--from FILE] [-o out.geml]",
469
599
  check: "usage: geml check <file.geml|-> [--json]",
470
600
  render: "usage: geml render <file.geml|-> [-o out.html]",
471
601
  convert: "usage: geml convert <file.md|-> [-o out.geml]",
472
602
  export: "usage: geml export <file.geml|-> [-o out.md]",
473
603
  fmt: "usage: geml fmt <file.geml|-> [-o out.geml]",
474
- history: "usage: geml history <commit|verify|show|restore> <file.geml> [...]",
604
+ revert: "usage: geml revert <file.geml> #id [--to <sel>] [--changed] [--dry-run] [-o out] (sel: -N | latest | id-prefix; default -1)",
605
+ history: "usage: geml history <commit|verify|show|restore|log> <file.geml> [...]",
606
+ codemap: `usage: geml codemap build --root <repo> # auto-detect languages, run the indexer(s), and merge into one codemap
607
+ geml codemap build (--db <graph.db> | --adapter joern|scip --raw <in>)+ --root <repo> [--out .geml-code-graph] [--container module|dir|file] [--lang <JAVASRC|NEWC|…>] [--joern <path>] [--history [-m msg]]
608
+ geml codemap verify [dir] geml check + profile reference checks
609
+ geml codemap render [dir] every doc -> sibling .html (open index.html from disk)
610
+ geml codemap serve [dir] [--port 8140] [--watch] [--background|--stop] live viewer: pages render from .geml on request; --watch re-runs the recipe when sources change
611
+ geml codemap refresh [dir] [--force] [--commit] [--background|--hook] re-run the recorded build recipe (_index/refresh.json); --commit lands it as its own commit
612
+ geml codemap mcp stdio MCP server (GEML_GRAPH_DIR or graph_dir arg)
613
+ (<dir> for verify/render/serve/refresh defaults to ./.geml-code-graph)`,
475
614
  };
476
615
  // Set from argv at dispatch time; when true, errors are emitted as a JSON
477
616
  // envelope so an agent that standardizes on --json never has to parse text.
478
617
  let jsonMode = false;
479
- // Clean one-line error + non-zero exit — never a raw Node stack trace.
480
- function fail(msg) {
618
+ // Clean one-line error + non-zero exit — never a raw Node stack trace. `code`
619
+ // is the process exit status: 2 for a usage error (the default), 1 for a
620
+ // document/operation error. `--json` wraps it in the same {error, code} envelope.
621
+ function fail(msg, code = 2) {
481
622
  if (jsonMode)
482
- console.error(JSON.stringify({ error: msg, code: 2 }));
623
+ console.error(JSON.stringify({ error: msg, code }));
483
624
  else
484
625
  console.error(`error: ${msg}`);
485
- process.exit(2);
626
+ process.exit(code);
486
627
  }
487
628
  // Read a file, or stdin when the path is "-". On failure emit a clean error.
488
629
  function readInput(file) {
@@ -580,6 +721,14 @@ function runHistory(args) {
580
721
  restore({ historyPath, gemlPath: file, revision: rev, write: true, force: args.includes("--force") });
581
722
  console.log(`restored ${file} to ${rev}`);
582
723
  }
724
+ else if (sub === "log") {
725
+ // Newest-first, with the `--to` selector for each row in the first column
726
+ // (`latest` for the tip, then `-1`, `-2`, …) so the output is copy-paste.
727
+ for (const r of listRevisions(historyPath)) {
728
+ const sel = r.current ? "latest" : `-${r.offset}`;
729
+ console.log(`${sel.padEnd(7)} ${r.id} ${r.author ?? "-"} ${r.summary ?? ""}`.replace(/\s+$/, ""));
730
+ }
731
+ }
583
732
  else {
584
733
  fail(`unknown history subcommand: ${sub}. Run 'geml --help'.`);
585
734
  }
@@ -638,7 +787,12 @@ function runRender(args) {
638
787
  if (!file)
639
788
  fail(SUBHELP.render);
640
789
  const doc = parse(readInput(file), { resolveDoc: resolverFor(file) });
641
- const html = renderHtml(doc, { source: file === "-" ? "stdin" : basename(file) });
790
+ const html = renderHtml(doc, {
791
+ source: file === "-" ? "stdin" : basename(file),
792
+ // geml-code-graph embeds load + parse sibling codemap documents on demand.
793
+ loadDoc: resolverFor(file),
794
+ parseDoc: (s) => parse(s),
795
+ });
642
796
  if (out) {
643
797
  writeFileSync(out, html);
644
798
  console.error(`wrote ${out}`);
@@ -673,6 +827,216 @@ function runFmt(args) {
673
827
  if (doc.diagnostics.some((d) => d.severity === "error"))
674
828
  process.exit(1);
675
829
  }
830
+ // Positional args (a file, an id) are the non-flag tokens that aren't the value
831
+ // of a value-taking flag. `-` (stdin) is a positional, not a flag. An id may be
832
+ // written `#id` or `id`; a leading `-` never begins an id, so this stays
833
+ // unambiguous. `valued` lists the flags that consume the following token.
834
+ function positionals(args, valued) {
835
+ const out = [];
836
+ for (let i = 0; i < args.length; i++) {
837
+ const a = args[i];
838
+ if (valued.includes(a)) {
839
+ i++;
840
+ continue;
841
+ } // skip the flag *and* its value
842
+ if (a === "-") {
843
+ out.push(a);
844
+ continue;
845
+ }
846
+ if (a.startsWith("-"))
847
+ continue; // a bare flag (e.g. --json)
848
+ out.push(a);
849
+ }
850
+ return out;
851
+ }
852
+ // `geml get <file.geml|-> #id [--json]` — print ONE block, addressed by id,
853
+ // without loading the rest of the document into context. Default output is the
854
+ // block's exact source bytes (its full `=== … ===` span, or the source line for
855
+ // a heading/footnote); `--json` prints that block's document-model node.
856
+ function runGet(args) {
857
+ const json = args.includes("--json");
858
+ const [file, rawId] = positionals(args, []);
859
+ if (!file || !rawId)
860
+ fail(SUBHELP.get);
861
+ const id = rawId.replace(/^#/, "");
862
+ const source = readInput(file);
863
+ if (json) {
864
+ // The model node — same shape `geml <file>` emits for it. Parsing is needed
865
+ // to resolve the tree (and nested-block ids), but only the one node prints.
866
+ const doc = parse(source, { resolveDoc: resolverFor(file) });
867
+ const block = findBlockById(doc.children, id);
868
+ if (!block)
869
+ fail(`no block with id \`${id}\``, 1);
870
+ console.log(JSON.stringify(block, null, 2));
871
+ return;
872
+ }
873
+ // Raw: slice the source span byte-for-byte. No parse required, so `get` still
874
+ // returns the exact bytes even if the document has diagnostics elsewhere.
875
+ const span = blockSpans(source).get(id);
876
+ if (!span)
877
+ fail(`no block with id \`${id}\``, 1);
878
+ process.stdout.write(splitLines(source).slice(span.start, span.end).join(""));
879
+ }
880
+ // `geml set <file.geml|-> #id [--from FILE] [-o out]` — replace ONLY that
881
+ // block's source span with new content (from --from or stdin), preserving every
882
+ // other byte. Prints the full updated document, or writes in place with -o. The
883
+ // splice is re-parsed and rejected if it broke the doc: `set` never writes a
884
+ // corrupt file.
885
+ function runSet(args) {
886
+ const out = flag(args, "-o") ?? flag(args, "--out");
887
+ const from = flag(args, "--from");
888
+ const [file, rawId] = positionals(args, ["-o", "--out", "--from"]);
889
+ if (!file || !rawId)
890
+ fail(SUBHELP.set);
891
+ const id = rawId.replace(/^#/, "");
892
+ // Both the document and the replacement can't come from stdin. Reject that up
893
+ // front — before consuming stdin — so the document read below is unambiguous.
894
+ if (file === "-" && from === undefined) {
895
+ fail("reading the document from stdin needs --from for the new content", 2);
896
+ }
897
+ const source = readInput(file);
898
+ // New content: an explicit --from file, else stdin.
899
+ let replacement;
900
+ if (from !== undefined) {
901
+ replacement = readInput(from);
902
+ }
903
+ else {
904
+ replacement = readInput("-");
905
+ if (replacement === "")
906
+ fail("no replacement content (use --from FILE or pipe it on stdin)", 1);
907
+ }
908
+ const updated = spliceBlock(source, id, replacement, file);
909
+ if (out) {
910
+ writeFileSync(out, updated);
911
+ console.error(`wrote ${out}`);
912
+ }
913
+ else
914
+ process.stdout.write(updated);
915
+ }
916
+ // Replace block #id's source span in `source` with `replacement`, preserving
917
+ // every other byte, and GUARD the result: the re-parse must be error-free, #id
918
+ // must survive, and no other pre-existing id may vanish (a malformed replacement
919
+ // can silently swallow a neighbour). Returns the updated document text; on any
920
+ // violation it calls fail() and never returns a corrupt document. Shared by
921
+ // `set` and `revert`.
922
+ function spliceBlock(source, id, replacement, file) {
923
+ const span = blockSpans(source).get(id);
924
+ if (!span)
925
+ fail(`no block with id \`${id}\``, 1);
926
+ const beforeIds = parse(source, { resolveDoc: resolverFor(file) }).ids;
927
+ // Keep the bytes before and after the target span exactly; give the new block
928
+ // a single trailing newline so the following block still starts on its own
929
+ // line (unless it is the file's last line, which may legitimately lack one).
930
+ const orig = splitLines(source);
931
+ const before = orig.slice(0, span.start);
932
+ const after = orig.slice(span.end);
933
+ let inject = replacement.replace(/\r\n?/g, "\n");
934
+ const lastLine = span.end >= orig.length;
935
+ if (!inject.endsWith("\n") && !lastLine)
936
+ inject += "\n";
937
+ const updated = before.join("") + inject + after.join("");
938
+ // Re-parse and refuse a broken result. A parse error or a duplicate id both
939
+ // surface as error diagnostics (registerId flags dups); one check covers both.
940
+ // Then require the target id to survive, and — because a malformed replacement
941
+ // can swallow a neighbour — that every other pre-existing id survives too.
942
+ const reparsed = parse(updated, { resolveDoc: resolverFor(file) });
943
+ const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
944
+ if (errs.length) {
945
+ const first = errs[0];
946
+ fail(`replacement would break the document: ${first.message} (line ${first.line}); not written`, 1);
947
+ }
948
+ const now = new Set(reparsed.ids);
949
+ if (!now.has(id))
950
+ fail(`replacement removes id \`${id}\`; not written`, 1);
951
+ const dropped = beforeIds.find((x) => x !== id && !now.has(x));
952
+ if (dropped !== undefined) {
953
+ fail(`replacement would drop block \`#${dropped}\` (malformed content?); not written`, 1);
954
+ }
955
+ return updated;
956
+ }
957
+ // `geml revert <file.geml> #id [--to <sel>] [--changed] [--dry-run] [-o out] [--history PATH]`
958
+ // Restore ONE block to a past revision's version — a targeted, guarded splice
959
+ // that leaves the rest of the document untouched. <sel> (default `-1`): `-N` (N
960
+ // revisions back from current), `latest`, or an id prefix/suffix. `--changed`
961
+ // skips revisions that never touched the block, landing on its previous
962
+ // *distinct* version. `--dry-run` prints what would be spliced in, writing
963
+ // nothing. Writes in place by default (revert is a mutation); `-o` redirects.
964
+ function runRevert(args) {
965
+ const changed = args.includes("--changed");
966
+ const dryRun = args.includes("--dry-run");
967
+ const out = flag(args, "-o") ?? flag(args, "--out");
968
+ const to = flag(args, "--to") ?? "-1";
969
+ const [file, rawId] = positionals(args, ["--to", "--history", "-o", "--out"]);
970
+ if (!file || !rawId)
971
+ fail(SUBHELP.revert);
972
+ if (file === "-")
973
+ fail("revert needs a real file (it reads that file's .gemlhistory)", 2);
974
+ const id = rawId.replace(/^#/, "");
975
+ const historyPath = flag(args, "--history") ?? historyPathFor(file);
976
+ const source = readInput(file);
977
+ const curSpan = blockSpans(source).get(id);
978
+ if (!curSpan)
979
+ fail(`no block with id \`${id}\` in ${file}`, 1);
980
+ const curBlock = splitLines(source).slice(curSpan.start, curSpan.end).join("");
981
+ // Extract block #id's source from a reconstructed revision (undefined if the
982
+ // block did not exist there).
983
+ const pick = (text) => {
984
+ const s = blockSpans(text).get(id);
985
+ return s ? splitLines(text).slice(s.start, s.end).join("") : undefined;
986
+ };
987
+ // Resolve the source revision, formatting any history-layer error cleanly.
988
+ const target = (() => {
989
+ try {
990
+ if (changed) {
991
+ const found = firstChangedContent(historyPath, curBlock, pick);
992
+ if (!found)
993
+ fail(`no earlier revision changes \`${id}\``, 1);
994
+ return found;
995
+ }
996
+ return resolveContent(historyPath, to);
997
+ }
998
+ catch (e) {
999
+ fail(historyError(e, file, historyPath), 1);
1000
+ }
1001
+ })();
1002
+ const oldBlock = pick(target.text);
1003
+ if (oldBlock === undefined)
1004
+ fail(`block \`${id}\` does not exist at revision ${target.id}`, 1);
1005
+ if (oldBlock === curBlock) {
1006
+ console.error(`#${id} is unchanged at ${target.id}; nothing to revert${changed ? "" : " (try --to -2, or --changed)"}`);
1007
+ return;
1008
+ }
1009
+ if (dryRun) {
1010
+ console.error(`would revert #${id} to ${target.id}:`);
1011
+ process.stdout.write(oldBlock.endsWith("\n") ? oldBlock : oldBlock + "\n");
1012
+ return;
1013
+ }
1014
+ const updated = spliceBlock(source, id, oldBlock, file);
1015
+ const dest = out ?? file;
1016
+ writeFileSync(dest, updated);
1017
+ console.error(`reverted #${id} to ${target.id}${dest === file ? "" : ` -> ${dest}`}`);
1018
+ }
1019
+ // geml codemap <sub>: the code-graph toolkit ships as plain scripts in the
1020
+ // package's codemap/ directory (they are argv-driven programs, some
1021
+ // long-running like `serve`) — dispatch = run the script in a child node
1022
+ // with the remaining arguments, propagating the exit code.
1023
+ function runCodemap(args) {
1024
+ const scripts = {
1025
+ build: "build.mjs",
1026
+ verify: "verify.mjs",
1027
+ render: "render-all.mjs",
1028
+ serve: "serve.mjs",
1029
+ refresh: "refresh.mjs",
1030
+ mcp: "mcp-server.mjs",
1031
+ };
1032
+ const sub = args[0] ?? "";
1033
+ const script = scripts[sub];
1034
+ if (!script)
1035
+ fail(`unknown codemap subcommand '${sub}'.\n${SUBHELP.codemap}`);
1036
+ const mod = join(dirname(fileURLToPath(import.meta.url)), "..", "codemap", script);
1037
+ const r = spawnSync(process.execPath, [mod, ...args.slice(1)], { stdio: "inherit" });
1038
+ process.exit(r.status ?? 1);
1039
+ }
676
1040
  const entry = process.argv[1] ?? "";
677
1041
  if (entry.endsWith("geml.js") || entry.endsWith("geml.ts")) {
678
1042
  const argv = process.argv.slice(2);
@@ -697,6 +1061,15 @@ if (entry.endsWith("geml.js") || entry.endsWith("geml.ts")) {
697
1061
  // stdout, exit 0 — never the `error:`-prefixed exit-2 path.
698
1062
  console.log(SUBHELP[cmd]);
699
1063
  }
1064
+ else if (cmd === "get") {
1065
+ runGet(argv.slice(1));
1066
+ }
1067
+ else if (cmd === "set") {
1068
+ runSet(argv.slice(1));
1069
+ }
1070
+ else if (cmd === "revert") {
1071
+ runRevert(argv.slice(1));
1072
+ }
700
1073
  else if (cmd === "history") {
701
1074
  runHistory(argv.slice(1));
702
1075
  }
@@ -715,6 +1088,9 @@ if (entry.endsWith("geml.js") || entry.endsWith("geml.ts")) {
715
1088
  else if (cmd === "check") {
716
1089
  runCheck(argv.slice(1));
717
1090
  }
1091
+ else if (cmd === "codemap") {
1092
+ runCodemap(argv.slice(1));
1093
+ }
718
1094
  else if (cmd !== "-" && !/[.\/\\]/.test(cmd)) {
719
1095
  // A bare word that is neither a known command nor a path is almost always
720
1096
  // a mistyped command — say so, don't try to read it as a file.