@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/dist/geml.js CHANGED
@@ -8,21 +8,21 @@
8
8
  // M2: inline parsing of flow blocks (§5 — emphasis/strong/strike, code, math,
9
9
  // media embeds, links, auto-references, footnotes) and build-time reference
10
10
  // validation (§8 — unique ids, resolvable internal/cross-document references).
11
- import { readFileSync, writeFileSync } from "node:fs";
12
- import { basename, dirname, join, resolve as resolvePath } from "node:path";
11
+ import { readFileSync, writeFileSync, realpathSync, statSync } from "node:fs";
12
+ import { basename, dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { spawnSync } from "node:child_process";
15
15
  import { commit, restore, verify, listRevisions, resolveContent, firstChangedContent } from "./history.js";
16
- import { renderHtml } from "./render.js";
16
+ import { renderHtml } from "./render-html.js";
17
17
  import { coerce, parseAttrs } from "./attrs.js";
18
- import { parseInline } from "./inline.js";
18
+ import { META_REF_SRC, parseInline } from "./inline.js";
19
19
  import { parseTable } from "./table.js";
20
20
  import { buildChart } from "./chart.js";
21
21
  import { mdToGeml } from "./from-md.js";
22
22
  import { serialize } from "./serialize.js";
23
23
  import { gemlToMd } from "./to-md.js";
24
24
  export { mdToGeml } from "./from-md.js";
25
- export { renderHtml } from "./render.js";
25
+ export { renderHtml } from "./render-html.js";
26
26
  export { serialize } from "./serialize.js";
27
27
  export { gemlToMd } from "./to-md.js";
28
28
  // Type registry: which body mode each typed block uses. Unknown types are a
@@ -34,6 +34,7 @@ const REGISTRY = {
34
34
  table: "raw", // structured table parsing lands in M3
35
35
  output: "raw", // captured result of a code block (stored, never executed)
36
36
  note: "flow",
37
+ text: "flow", // addressable prose container: an id/attrs for a run of flow, no callout chrome
37
38
  meta: "data",
38
39
  };
39
40
  // §7: built-in diagram renderer registry. Unknown formats are a warning (the
@@ -45,6 +46,11 @@ const DIAGRAM_RENDERERS = new Set(["mermaid", "graphviz", "dot", "d2", "plantuml
45
46
  const FENCE_OPEN = /^(={3,})[ \t]+([A-Za-z][A-Za-z0-9_-]*)[ \t]*(\{.*\})?[ \t]*$/;
46
47
  const HEADING = /^(#{1,6})[ \t]+(.*?)[ \t]*(\{[^}]*\})?[ \t]*$/;
47
48
  const LIST_ITEM = /^[ \t]*(?:[-*]|\d+\.)[ \t]+(.*)$/;
49
+ // Maximum block/list nesting depth the recursive-descent scanner will build
50
+ // before emitting a diagnostic instead of recursing further. Guards parse()
51
+ // (scanBlocks / parseList) and, in step, the renderer against a deeply nested
52
+ // document overflowing the call stack (DoS). 256 is far past any real document.
53
+ const MAX_NESTING = 256;
48
54
  function isCloseFence(line, openLen) {
49
55
  const t = line.replace(/\s+$/, "");
50
56
  return /^=+$/.test(t) && t.length === openLen;
@@ -62,15 +68,67 @@ function slug(text) {
62
68
  // ---------------------------------------------------------------------------
63
69
  // §4: substitute `{{key}}` in flow text with the matching `=== meta` value.
64
70
  // An unknown key is a build error (single-source-of-truth, fail loudly).
71
+ // The scan mirrors the §5.3(1) verbatim atoms: a `{{key}}` inside a code span
72
+ // or inline math is left untouched (so GEML prose can document this very
73
+ // syntax), and a backslash-escaped character can neither open a span nor a
74
+ // `{{…}}` reference — `\{{key}}` renders as the literal text `{{key}}`.
75
+ const META_REF = new RegExp(META_REF_SRC, "y");
65
76
  function interpolate(text, line, ctx) {
66
77
  if (!text.includes("{{"))
67
78
  return text;
68
- return text.replace(/\{\{\s*([A-Za-z_][A-Za-z0-9_-]*)\s*\}\}/g, (full, key) => {
69
- if (ctx.meta.has(key))
70
- return ctx.meta.get(key);
71
- ctx.diags.push({ severity: "error", message: `unknown metadata reference \`{{${key}}}\``, line });
72
- return full;
73
- });
79
+ let out = "";
80
+ let i = 0;
81
+ while (i < text.length) {
82
+ const c = text[i];
83
+ if (c === "\\" && i + 1 < text.length) {
84
+ out += c + text[i + 1];
85
+ i += 2;
86
+ continue;
87
+ }
88
+ if (c === "`") {
89
+ let n = 0;
90
+ while (text[i + n] === "`")
91
+ n++;
92
+ const close = text.indexOf("`".repeat(n), i + n);
93
+ if (close >= 0) {
94
+ out += text.slice(i, close + n);
95
+ i = close + n;
96
+ continue;
97
+ }
98
+ out += text.slice(i, i + n); // unclosed run: literal, keep scanning
99
+ i += n;
100
+ continue;
101
+ }
102
+ if (c === "$") {
103
+ const close = text.indexOf("$", i + 1);
104
+ if (close > i + 1) {
105
+ out += text.slice(i, close + 1);
106
+ i = close + 1;
107
+ continue;
108
+ }
109
+ out += c;
110
+ i++;
111
+ continue;
112
+ }
113
+ if (c === "{" && text[i + 1] === "{") {
114
+ META_REF.lastIndex = i;
115
+ const m = META_REF.exec(text);
116
+ if (m) {
117
+ const key = m[1];
118
+ if (ctx.meta.has(key))
119
+ out += ctx.meta.get(key);
120
+ else {
121
+ ctx.diags.push({ severity: "error", message: `unknown metadata reference \`{{${key}}}\``, line });
122
+ out += m[0];
123
+ }
124
+ i = META_REF.lastIndex;
125
+ continue;
126
+ }
127
+ }
128
+ out += c;
129
+ i++;
130
+ }
131
+ return out;
74
132
  }
75
133
  // Register a block id, flagging duplicates as errors (§4: ids unique per doc).
76
134
  function registerId(ctx, id, line) {
@@ -122,6 +180,7 @@ function parseList(lines, i, base, ctx) {
122
180
  const root = mkList(matchMarker(lines[i]));
123
181
  const stack = [{ list: root, indent: matchMarker(lines[i]).indent }];
124
182
  let prevBlank = false;
183
+ let tooDeep = false;
125
184
  while (i < lines.length) {
126
185
  if (lines[i].trim() === "") {
127
186
  prevBlank = true;
@@ -139,9 +198,21 @@ function parseList(lines, i, base, ctx) {
139
198
  const parent = top.list.items[top.list.items.length - 1];
140
199
  if (!parent)
141
200
  break; // deeper indent with no parent item: defensive stop
142
- cur = mkList(mk);
143
- (parent.children ??= []).push(cur);
144
- stack.push({ list: cur, indent: mk.indent });
201
+ if (stack.length >= MAX_NESTING) {
202
+ // Refuse to nest deeper than the cap: keep the item at the current level
203
+ // rather than building a model that overflows the renderer (DoS). One
204
+ // diagnostic per over-deep list; content is preserved, just flattened.
205
+ if (!tooDeep) {
206
+ ctx.diags.push({ severity: "error", message: `list nesting too deep (max ${MAX_NESTING})`, line: base + i + 1 });
207
+ tooDeep = true;
208
+ }
209
+ cur = top.list;
210
+ }
211
+ else {
212
+ cur = mkList(mk);
213
+ (parent.children ??= []).push(cur);
214
+ stack.push({ list: cur, indent: mk.indent });
215
+ }
145
216
  }
146
217
  else {
147
218
  // §5: a change of marker type (bullet ↔ ordered) at the same level ends
@@ -159,7 +230,7 @@ function parseList(lines, i, base, ctx) {
159
230
  }
160
231
  return { block: root, next: i };
161
232
  }
162
- function scanBlocks(lines, base, ctx) {
233
+ function scanBlocks(lines, base, ctx, depth = 0) {
163
234
  const blocks = [];
164
235
  const diags = ctx.diags;
165
236
  let i = 0;
@@ -242,7 +313,16 @@ function scanBlocks(lines, base, ctx) {
242
313
  ctx.refs.push({ kind: "internal", anchor: of.slice(1), line: openLineNo });
243
314
  }
244
315
  if (mode === "flow") {
245
- block.children = scanBlocks(body, base + i + 1, ctx);
316
+ if (depth >= MAX_NESTING) {
317
+ // Refuse to recurse past the cap: emit a diagnostic and keep the body
318
+ // as raw so the parser returns cleanly instead of overflowing the
319
+ // call stack on a pathologically nested document (DoS).
320
+ diags.push({ severity: "error", message: `block nesting too deep (max ${MAX_NESTING}); body kept as raw`, line: openLineNo });
321
+ block.raw = body;
322
+ }
323
+ else {
324
+ block.children = scanBlocks(body, base + i + 1, ctx, depth + 1);
325
+ }
246
326
  }
247
327
  else if (mode === "data") {
248
328
  block.data = parseData(body);
@@ -453,8 +533,46 @@ export function parse(source, opts = {}) {
453
533
  }
454
534
  // The id that a fence/heading line defines, matching how scanBlocks derives it
455
535
  // (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);
536
+ // The slug MUST come from the INTERPOLATED text — scanBlocks slugs after
537
+ // interpolate(), so `# {{title}} Setup` registers the substituted slug; slugging
538
+ // the raw text here would create a phantom id the parser never registered and
539
+ // make the real one unaddressable. `ctx` is an inert context carrying the
540
+ // document's meta (diagnostics are discarded — spans never report).
541
+ function idOfHeading(braces, text, line, ctx) {
542
+ return (braces ? parseAttrs(braces).id : undefined) ?? slug(interpolate(text, line, ctx));
543
+ }
544
+ // The matching close of the fence opened at lines[i] (equal-length run, or the
545
+ // labeled `=== #id` close when the block carries an id): the index just past
546
+ // the close line, and whether one was found — an unterminated block runs to
547
+ // the end of the scope.
548
+ function fenceClose(lines, i, open) {
549
+ const openLen = open[1].length;
550
+ const id = open[3] ? parseAttrs(open[3]).id : undefined;
551
+ const labeled = id !== undefined ? new RegExp(`^={3,}[ \\t]+#${id}[ \\t]*$`) : null;
552
+ for (let j = i + 1; j < lines.length; j++) {
553
+ if (isCloseFence(lines[j], openLen) || (labeled && labeled.test(lines[j])))
554
+ return { end: j + 1, closed: true };
555
+ }
556
+ return { end: lines.length, closed: false };
557
+ }
558
+ // A heading's span covers its whole SECTION: the heading line through the line
559
+ // just before the next heading of same-or-higher level (fewer-or-equal `#`) in
560
+ // the current scope, or end-of-scope. Fenced blocks are skipped whole — a `#`
561
+ // line inside a `=== code` body is content, never a boundary.
562
+ function sectionEnd(lines, i, level) {
563
+ let j = i + 1;
564
+ while (j < lines.length) {
565
+ const open = FENCE_OPEN.exec(lines[j]);
566
+ if (open) {
567
+ j = fenceClose(lines, j, open).end;
568
+ continue;
569
+ }
570
+ const h = HEADING.exec(lines[j]);
571
+ if (h && h[1].length <= level)
572
+ return j;
573
+ j++;
574
+ }
575
+ return lines.length;
458
576
  }
459
577
  // Walk `lines` exactly as scanBlocks does — same fence close rules (equal-length
460
578
  // or labeled `=== #id`), same flow-only recursion via REGISTRY — recording the
@@ -462,7 +580,7 @@ function idOfHeading(braces, text) {
462
580
  // First definition wins, mirroring ctx.ids (a duplicate id is a build error, so
463
581
  // `get`/`set` operate on the one the parser actually registered). `base` is the
464
582
  // absolute line offset of this slice within the whole document.
465
- function collectSpans(lines, base, out) {
583
+ function collectSpans(lines, base, out, ctx, depth = 0) {
466
584
  const add = (id, start, end) => {
467
585
  if (!out.has(id))
468
586
  out.set(id, { start, end });
@@ -486,33 +604,27 @@ function collectSpans(lines, base, out) {
486
604
  } // hidden line: no id
487
605
  const open = FENCE_OPEN.exec(line);
488
606
  if (open) {
489
- const openLen = open[1].length;
490
607
  const type = open[2];
491
608
  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;
609
+ const { end, closed } = fenceClose(lines, i, open);
502
610
  if (id !== undefined)
503
611
  add(id, base + i, base + end);
504
612
  // Only a flow body is scanned for nested blocks (raw/data bodies are
505
613
  // opaque), so an id inside a `code` body is *not* addressable — exactly
506
614
  // the parser's contract.
507
- if ((REGISTRY[type] ?? "raw") === "flow") {
508
- collectSpans(lines.slice(i + 1, closed ? j : end), base + i + 1, out);
615
+ if ((REGISTRY[type] ?? "raw") === "flow" && depth < MAX_NESTING) {
616
+ collectSpans(lines.slice(i + 1, closed ? end - 1 : end), base + i + 1, out, ctx, depth + 1);
509
617
  }
510
618
  i = end;
511
619
  continue;
512
620
  }
513
621
  const h = HEADING.exec(line);
514
622
  if (h) {
515
- add(idOfHeading(h[3], h[2]), base + i, base + i + 1);
623
+ // Section span (heading through its prose and nested blocks). The walk
624
+ // still advances one line at a time so every nested id inside the
625
+ // section registers its own span — spans intentionally OVERLAP: #sec
626
+ // contains #code, and each remains addressable on its own.
627
+ add(idOfHeading(h[3], h[2], base + i + 1, ctx), base + i, base + sectionEnd(lines, i, h[1].length));
516
628
  i++;
517
629
  continue;
518
630
  }
@@ -523,29 +635,49 @@ function collectSpans(lines, base, out) {
523
635
  // with the physical lines produced by splitLines(source).
524
636
  export function blockSpans(source) {
525
637
  const out = new Map();
526
- collectSpans(source.replace(/\r\n?/g, "\n").split("\n"), 0, out);
638
+ const lines = source.replace(/\r\n?/g, "\n").split("\n");
639
+ // Inert context: heading auto-ids slug the interpolated text (parser parity);
640
+ // its diagnostics are discarded — the span scan never reports.
641
+ const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines) };
642
+ collectSpans(lines, 0, out, ctx);
527
643
  return out;
528
644
  }
529
645
  // Split into physical lines while *keeping* each line's terminator, so
530
646
  // join("") is byte-exact and slicing by span never rewrites line endings.
647
+ // A line ends at `\n` or at a LONE `\r` (old-Mac style) — the same boundaries
648
+ // the span scan's `\r\n?` -> `\n` normalization sees, so span indices always
649
+ // address the same lines the parser counted.
531
650
  function splitLines(source) {
532
- return source.split(/(?<=\n)/);
651
+ return source.split(/(?<=\n|\r(?!\n))/);
652
+ }
653
+ // `--head`: narrow any id's span to its HEAD line — the single declaring line
654
+ // (a heading's `# … {#id}` line, a typed block's opening fence, a footnote's
655
+ // `[^id]:` line). The head is by construction the FIRST line of the span, so
656
+ // the narrowing is parse-free and needs no type check. Main use: `set --head`
657
+ // edits a block's attributes (caption/compute/lang/…) without re-sending its
658
+ // body, or renames a heading without rewriting its section.
659
+ function narrowToHead(span) {
660
+ return { start: span.start, end: span.start + 1 };
533
661
  }
534
662
  // Depth-first search for the document-model node carrying `id`, descending into
535
663
  // 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) {
664
+ // Returns the containing sibling array and index, not just the node: the model
665
+ // is FLAT a heading does not own its section; the section's prose and blocks
666
+ // are its FOLLOWING SIBLINGS — so a section consumer needs the array.
667
+ function findBlockSite(blocks, id) {
668
+ for (let i = 0; i < blocks.length; i++) {
669
+ const b = blocks[i];
538
670
  if ((b.kind === "heading" || b.kind === "block") && b.id === id)
539
- return b;
671
+ return { siblings: blocks, index: i };
540
672
  if (b.kind === "block" && b.children) {
541
- const hit = findBlockById(b.children, id);
673
+ const hit = findBlockSite(b.children, id);
542
674
  if (hit)
543
675
  return hit;
544
676
  }
545
677
  if (b.kind === "list") {
546
678
  for (const it of b.items) {
547
679
  if (it.children) {
548
- const hit = findBlockById(it.children, id);
680
+ const hit = findBlockSite(it.children, id);
549
681
  if (hit)
550
682
  return hit;
551
683
  }
@@ -554,6 +686,21 @@ function findBlockById(blocks, id) {
554
686
  }
555
687
  return undefined;
556
688
  }
689
+ // Model-side section boundary: within one sibling array, the section opened by
690
+ // the heading at index k runs to the next sibling heading of same-or-higher
691
+ // level, or the array end. This is the SAME rule sectionEnd() applies to raw
692
+ // source lines (where skipping fenced bodies makes "next heading" well-defined)
693
+ // — the two sides must stay in lockstep; the get-set suite pins their parity
694
+ // (ids covered by the raw slice == ids covered by the --json envelope).
695
+ function sectionEndIndex(siblings, k) {
696
+ const level = siblings[k].level;
697
+ for (let m = k + 1; m < siblings.length; m++) {
698
+ const b = siblings[m];
699
+ if (b.kind === "heading" && b.level <= level)
700
+ return m;
701
+ }
702
+ return siblings.length;
703
+ }
557
704
  // ---------------------------------------------------------------------------
558
705
  // CLI
559
706
  // ---------------------------------------------------------------------------
@@ -572,45 +719,52 @@ function parseStamp(s) {
572
719
  return new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +se));
573
720
  }
574
721
  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
576
- const USAGE = `geml — GEML reference CLI
577
-
578
- Usage:
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)
583
- geml check <file.geml|-> [--json] validate only: diagnostics + exit code
584
- geml render <file.geml|-> [-o out.html] render to one self-contained HTML file
585
- geml fmt <file.geml|-> [-o out.geml] re-serialize to canonical GEML
586
- geml convert <file.md|-> [-o out.geml] Markdown -> GEML
587
- geml export <file.geml|-> [-o out.md] GEML -> Markdown (lossy)
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)
590
- geml --help | --version [--json]
591
-
592
- Use '-' as the file to read from stdin.
593
- Exit codes: 0 ok · 1 document/operation error · 2 usage error.`;
722
+ const PARSER_VERSION = "1.3.2"; // reference implementation; keep in sync with package.json
723
+ const USAGE = `geml — GEML reference CLI
724
+
725
+ Usage:
726
+ geml <file.geml|-> parse -> document-model JSON (stdout)
727
+ geml get <file.geml|-> #id [--json][--head] print ONE block by id (a heading id = its section;
728
+ --head narrows any id to its head line; --json = model node)
729
+ geml set <file.geml|-> #id [--from f][-o f][--head] replace ONE block by id (new content: --from/stdin)
730
+ geml revert <file.geml> #id [--to <sel>][--head] restore ONE block to a past revision (sel: -N|latest|id)
731
+ geml check <file.geml|-> [--root d][--json] validate only: diagnostics + exit code
732
+ (--root widens cross-doc refs to dir d, e.g. the repo root)
733
+ geml render <file.geml|-> [-o out.html] render to one self-contained HTML file
734
+ geml fmt <file.geml|-> [-o out.geml] re-serialize to canonical GEML
735
+ geml convert <file.md|-> [-o out.geml] Markdown -> GEML
736
+ geml export <file.geml|-> [-o out.md] GEML -> Markdown (lossy)
737
+ geml history <commit|verify|show|restore|log> <file.geml> [...]
738
+ geml codemap <build|verify|render|serve|refresh|find|mcp> [...] code-graph toolkit (alias: codegraph)
739
+ geml --help | --version [--json]
740
+
741
+ Use '-' as the file to read from stdin.
742
+ Exit codes:
743
+ 0 ok
744
+ 1 document/operation error
745
+ 2 command usage error.
746
+ `;
594
747
  // One-line usage for each subcommand — the single source for both the error
595
748
  // shown on misuse and the `<cmd> --help` text.
596
749
  const SUBHELP = {
597
- get: "usage: geml get <file.geml|-> #id [--json]",
598
- set: "usage: geml set <file.geml|-> #id [--from FILE] [-o out.geml]",
599
- check: "usage: geml check <file.geml|-> [--json]",
750
+ get: "usage: geml get <file.geml|-> #id [--json] [--head] (a heading id = its whole section; --head narrows any id to its head line)",
751
+ set: "usage: geml set <file.geml|-> #id [--from FILE] [-o out.geml] [--head]",
752
+ check: "usage: geml check <file.geml|-> [--root <dir>] [--json] (--root: resolve cross-doc refs within <dir> instead of the file's own directory)",
600
753
  render: "usage: geml render <file.geml|-> [-o out.html]",
601
754
  convert: "usage: geml convert <file.md|-> [-o out.geml]",
602
755
  export: "usage: geml export <file.geml|-> [-o out.md]",
603
756
  fmt: "usage: geml fmt <file.geml|-> [-o out.geml]",
604
- revert: "usage: geml revert <file.geml> #id [--to <sel>] [--changed] [--dry-run] [-o out] (sel: -N | latest | id-prefix; default -1)",
757
+ revert: "usage: geml revert <file.geml> #id [--to <sel>] [--changed] [--dry-run] [-o out] [--head] (sel: -N | latest | id-prefix; default -1)",
605
758
  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)`,
759
+ codemap: `usage: geml codemap build [--root <repo>] # auto-detect languages, run the indexer(s), and merge into one codemap (--root defaults to the current directory)
760
+ 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]]
761
+ geml codemap verify [dir] geml check + profile reference checks
762
+ geml codemap render [dir] every doc -> sibling .html (open index.html from disk)
763
+ 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
764
+ geml codemap refresh [dir] [--force] [--commit] [--background|--hook] re-run the recorded build recipe (_index/refresh.json); --commit lands it as its own commit
765
+ geml codemap find <name> [dir] locate a symbol by substring name -> doc#id + src (stdout, no browser)
766
+ geml codemap mcp stdio MCP server (GEML_GRAPH_DIR or graph_dir arg)
767
+ (<dir> for verify/render/serve/refresh/find defaults to ./.geml-code-graph; codegraph and code-graph are accepted as aliases of codemap)`,
614
768
  };
615
769
  // Set from argv at dispatch time; when true, errors are emitted as a JSON
616
770
  // envelope so an agent that standardizes on --json never has to parse text.
@@ -634,12 +788,66 @@ function readInput(file) {
634
788
  fail(file === "-" ? "cannot read stdin" : `cannot read ${file}`);
635
789
  }
636
790
  }
637
- // A cross-document resolver rooted at the input's directory (cwd for stdin).
638
- function resolverFor(file) {
639
- const baseDir = file === "-" ? "." : dirname(file);
791
+ // A cross-document resolver rooted at the input's directory (cwd for stdin),
792
+ // CONFINED to that directory's subtree. A reference that resolves outside the
793
+ // base via a `..` escape, an absolute path, or (on Windows) a different drive
794
+ // — is refused (returns null, i.e. an unresolvable ref) so a crafted document
795
+ // cannot turn `geml check`/parse into an arbitrary local-file read oracle. §8.
796
+ //
797
+ // A purely LEXICAL check is not enough: a symlink that sits lexically inside the
798
+ // subtree but points to `../../outside.geml` passes `path.relative` yet reads an
799
+ // external target. So after the cheap lexical gate we resolve BOTH the base and
800
+ // the target through `realpathSync` (following every symlink component) and
801
+ // re-check that the REAL target still lies within the REAL base subtree before
802
+ // reading. A target that does not exist makes `realpathSync` throw — handled as
803
+ // an ordinary unresolvable ref (null), never a crash.
804
+ //
805
+ // `root` (CLI `--root`, an explicit per-invocation user grant — never
806
+ // document-controlled) widens the confinement base from the input's own
807
+ // directory to an ancestor the user names, so repo-relative `../` references
808
+ // between sibling directories can be checked. It moves WHERE the boundary
809
+ // stands, never whether it is enforced: both gates below run against the
810
+ // widened base, so escapes past the root are refused exactly as above. The
811
+ // viewer/web surfaces never pass a root — their boundary is unchanged.
812
+ function resolverFor(file, root) {
813
+ const dirAbs = resolvePath(file === "-" ? "." : dirname(file));
814
+ const baseAbs = root === undefined ? dirAbs : resolvePath(root);
815
+ // Canonicalise the base once. If the base itself cannot be realpath'd, no
816
+ // cross-doc ref can be safely confined — resolve nothing.
817
+ let realBase = null;
818
+ try {
819
+ realBase = realpathSync(baseAbs);
820
+ }
821
+ catch {
822
+ realBase = null;
823
+ }
824
+ const outside = (from, to) => {
825
+ const rel = relative(from, to);
826
+ return rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel);
827
+ };
640
828
  return (d) => {
829
+ if (realBase === null)
830
+ return null;
831
+ // References resolve FROM the document's own directory; the gates below
832
+ // confine them to the (possibly widened) base.
833
+ const targetAbs = resolvePath(dirAbs, d);
834
+ // Cheap lexical gate: reject an obvious `..`/absolute/other-drive escape
835
+ // before touching the filesystem.
836
+ if (outside(baseAbs, targetAbs))
837
+ return null;
838
+ // Real (symlink-resolved) gate: a symlink pointing out of the subtree
839
+ // resolves to a real path outside `realBase` and is refused here.
840
+ let realTarget;
641
841
  try {
642
- return readFileSync(resolvePath(baseDir, d), "utf8");
842
+ realTarget = realpathSync(targetAbs);
843
+ }
844
+ catch {
845
+ return null;
846
+ }
847
+ if (outside(realBase, realTarget))
848
+ return null;
849
+ try {
850
+ return readFileSync(realTarget, "utf8");
643
851
  }
644
852
  catch {
645
853
  return null;
@@ -650,10 +858,22 @@ function resolverFor(file) {
650
858
  // dump (cheap for agents). `--json` prints the diagnostics array for machines.
651
859
  function runCheck(args) {
652
860
  const json = args.includes("--json");
653
- const file = args.find((a) => a === "-" || !a.startsWith("-"));
861
+ const root = flag(args, "--root");
862
+ const file = args.find((a) => a === "-" || (!a.startsWith("-") && a !== root));
654
863
  if (!file)
655
864
  fail(SUBHELP.check);
656
- const doc = parse(readInput(file), { resolveDoc: resolverFor(file) });
865
+ // A mistyped --root must be a usage error (exit 2), not a wall of misleading
866
+ // "cannot resolve document" errors from a resolver confined to nothing.
867
+ if (root !== undefined) {
868
+ let isDir = false;
869
+ try {
870
+ isDir = statSync(root).isDirectory();
871
+ }
872
+ catch { /* missing -> not a dir */ }
873
+ if (!isDir)
874
+ fail(`--root ${root} is not a directory`);
875
+ }
876
+ const doc = parse(readInput(file), { resolveDoc: resolverFor(file, root) });
657
877
  if (json) {
658
878
  console.log(JSON.stringify(doc.diagnostics, null, 2));
659
879
  }
@@ -851,30 +1071,51 @@ function positionals(args, valued) {
851
1071
  }
852
1072
  // `geml get <file.geml|-> #id [--json]` — print ONE block, addressed by id,
853
1073
  // 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.
1074
+ // block's exact source bytes: a typed block's full `=== … ===` span, a
1075
+ // footnote's line, or — for a heading its whole SECTION (heading line through
1076
+ // the line before the next same-or-higher heading). `--json` covers the same
1077
+ // content: a block/footnote id prints its document-model node; a heading id
1078
+ // prints a section envelope `{kind:"section", id, level, blocks:[heading,
1079
+ // …siblings up to the boundary]}`.
856
1080
  function runGet(args) {
857
1081
  const json = args.includes("--json");
1082
+ const headOnly = args.includes("--head");
858
1083
  const [file, rawId] = positionals(args, []);
859
1084
  if (!file || !rawId)
860
1085
  fail(SUBHELP.get);
861
1086
  const id = rawId.replace(/^#/, "");
862
1087
  const source = readInput(file);
863
1088
  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.
1089
+ // The model node(s) — same shapes `geml <file>` emits. Parsing is needed
1090
+ // to resolve the tree (and nested-block ids), but only the target prints.
866
1091
  const doc = parse(source, { resolveDoc: resolverFor(file) });
867
- const block = findBlockById(doc.children, id);
868
- if (!block)
1092
+ const site = findBlockSite(doc.children, id);
1093
+ if (!site)
869
1094
  fail(`no block with id \`${id}\``, 1);
1095
+ const block = site.siblings[site.index];
1096
+ // `--head` on a heading suppresses the section envelope (the lone heading
1097
+ // node IS the head). On a block/footnote id there is nothing finer than
1098
+ // the single node — the model has no sub-node for "just the fence line" —
1099
+ // so --head refines only the RAW output there.
1100
+ if (block.kind === "heading" && !headOnly) {
1101
+ // A heading id addresses its SECTION, so `--json` covers the same
1102
+ // content as the raw span: a self-describing envelope whose blocks[0]
1103
+ // is the heading node followed by its siblings up to the boundary.
1104
+ // `kind: "section"` lets a consumer branch — a block/footnote id still
1105
+ // yields the single model node (the model itself stays flat).
1106
+ const end = sectionEndIndex(site.siblings, site.index);
1107
+ console.log(JSON.stringify({ kind: "section", id: block.id, level: block.level, blocks: site.siblings.slice(site.index, end) }, null, 2));
1108
+ return;
1109
+ }
870
1110
  console.log(JSON.stringify(block, null, 2));
871
1111
  return;
872
1112
  }
873
1113
  // Raw: slice the source span byte-for-byte. No parse required, so `get` still
874
1114
  // returns the exact bytes even if the document has diagnostics elsewhere.
875
- const span = blockSpans(source).get(id);
876
- if (!span)
1115
+ const found = blockSpans(source).get(id);
1116
+ if (!found)
877
1117
  fail(`no block with id \`${id}\``, 1);
1118
+ const span = headOnly ? narrowToHead(found) : found;
878
1119
  process.stdout.write(splitLines(source).slice(span.start, span.end).join(""));
879
1120
  }
880
1121
  // `geml set <file.geml|-> #id [--from FILE] [-o out]` — replace ONLY that
@@ -885,6 +1126,7 @@ function runGet(args) {
885
1126
  function runSet(args) {
886
1127
  const out = flag(args, "-o") ?? flag(args, "--out");
887
1128
  const from = flag(args, "--from");
1129
+ const headOnly = args.includes("--head");
888
1130
  const [file, rawId] = positionals(args, ["-o", "--out", "--from"]);
889
1131
  if (!file || !rawId)
890
1132
  fail(SUBHELP.set);
@@ -905,7 +1147,7 @@ function runSet(args) {
905
1147
  if (replacement === "")
906
1148
  fail("no replacement content (use --from FILE or pipe it on stdin)", 1);
907
1149
  }
908
- const updated = spliceBlock(source, id, replacement, file);
1150
+ const updated = spliceBlock(source, id, replacement, file, headOnly);
909
1151
  if (out) {
910
1152
  writeFileSync(out, updated);
911
1153
  console.error(`wrote ${out}`);
@@ -919,15 +1161,21 @@ function runSet(args) {
919
1161
  // can silently swallow a neighbour). Returns the updated document text; on any
920
1162
  // violation it calls fail() and never returns a corrupt document. Shared by
921
1163
  // `set` and `revert`.
922
- function spliceBlock(source, id, replacement, file) {
923
- const span = blockSpans(source).get(id);
924
- if (!span)
1164
+ function spliceBlock(source, id, replacement, file, headOnly = false) {
1165
+ const found = blockSpans(source).get(id);
1166
+ if (!found)
925
1167
  fail(`no block with id \`${id}\``, 1);
926
1168
  const beforeIds = parse(source, { resolveDoc: resolverFor(file) }).ids;
927
1169
  // Keep the bytes before and after the target span exactly; give the new block
928
1170
  // a single trailing newline so the following block still starts on its own
929
1171
  // line (unless it is the file's last line, which may legitimately lack one).
930
1172
  const orig = splitLines(source);
1173
+ // `--head`: splice only the id's head line; everything below stays
1174
+ // byte-identical. The guard below still applies — the replacement must
1175
+ // re-declare `{#id}` and, for a typed block, keep the fence pairing intact
1176
+ // (an opening line that no longer matches the untouched close fence breaks
1177
+ // the re-parse), or the splice is refused.
1178
+ const span = headOnly ? narrowToHead(found) : found;
931
1179
  const before = orig.slice(0, span.start);
932
1180
  const after = orig.slice(span.end);
933
1181
  let inject = replacement.replace(/\r\n?/g, "\n");
@@ -964,6 +1212,7 @@ function spliceBlock(source, id, replacement, file) {
964
1212
  function runRevert(args) {
965
1213
  const changed = args.includes("--changed");
966
1214
  const dryRun = args.includes("--dry-run");
1215
+ const headOnly = args.includes("--head");
967
1216
  const out = flag(args, "-o") ?? flag(args, "--out");
968
1217
  const to = flag(args, "--to") ?? "-1";
969
1218
  const [file, rawId] = positionals(args, ["--to", "--history", "-o", "--out"]);
@@ -974,15 +1223,19 @@ function runRevert(args) {
974
1223
  const id = rawId.replace(/^#/, "");
975
1224
  const historyPath = flag(args, "--history") ?? historyPathFor(file);
976
1225
  const source = readInput(file);
977
- const curSpan = blockSpans(source).get(id);
978
- if (!curSpan)
1226
+ const found = blockSpans(source).get(id);
1227
+ if (!found)
979
1228
  fail(`no block with id \`${id}\` in ${file}`, 1);
1229
+ const curSpan = headOnly ? narrowToHead(found) : found;
980
1230
  const curBlock = splitLines(source).slice(curSpan.start, curSpan.end).join("");
981
1231
  // Extract block #id's source from a reconstructed revision (undefined if the
982
- // block did not exist there).
1232
+ // block did not exist there). Under `--head`, extract only the head line.
983
1233
  const pick = (text) => {
984
1234
  const s = blockSpans(text).get(id);
985
- return s ? splitLines(text).slice(s.start, s.end).join("") : undefined;
1235
+ if (!s)
1236
+ return undefined;
1237
+ const span = headOnly ? narrowToHead(s) : s;
1238
+ return splitLines(text).slice(span.start, span.end).join("");
986
1239
  };
987
1240
  // Resolve the source revision, formatting any history-layer error cleanly.
988
1241
  const target = (() => {
@@ -1011,7 +1264,7 @@ function runRevert(args) {
1011
1264
  process.stdout.write(oldBlock.endsWith("\n") ? oldBlock : oldBlock + "\n");
1012
1265
  return;
1013
1266
  }
1014
- const updated = spliceBlock(source, id, oldBlock, file);
1267
+ const updated = spliceBlock(source, id, oldBlock, file, headOnly);
1015
1268
  const dest = out ?? file;
1016
1269
  writeFileSync(dest, updated);
1017
1270
  console.error(`reverted #${id} to ${target.id}${dest === file ? "" : ` -> ${dest}`}`);
@@ -1027,6 +1280,7 @@ function runCodemap(args) {
1027
1280
  render: "render-all.mjs",
1028
1281
  serve: "serve.mjs",
1029
1282
  refresh: "refresh.mjs",
1283
+ find: "find.mjs",
1030
1284
  mcp: "mcp-server.mjs",
1031
1285
  };
1032
1286
  const sub = args[0] ?? "";
@@ -1037,10 +1291,28 @@ function runCodemap(args) {
1037
1291
  const r = spawnSync(process.execPath, [mod, ...args.slice(1)], { stdio: "inherit" });
1038
1292
  process.exit(r.status ?? 1);
1039
1293
  }
1040
- const entry = process.argv[1] ?? "";
1041
- if (entry.endsWith("geml.js") || entry.endsWith("geml.ts")) {
1294
+ // npm's unix bin shim is a symlink named plain `geml`, so detect "run as a
1295
+ // CLI" by resolving argv[1] to its real path, not by its spelling.
1296
+ const entry = (() => {
1297
+ const argv1 = process.argv[1];
1298
+ if (!argv1)
1299
+ return "";
1300
+ try {
1301
+ return realpathSync(argv1);
1302
+ }
1303
+ catch {
1304
+ return argv1;
1305
+ }
1306
+ })();
1307
+ // `entry` must be non-empty: in a browser bundle both sides degenerate to ""
1308
+ // (esbuild defines process.argv=[] and import.meta.url="", and the node-stub's
1309
+ // fileURLToPath is String()), which would run the CLI at import time and crash
1310
+ // the page. A real CLI invocation always has argv[1].
1311
+ if (entry && (entry === fileURLToPath(import.meta.url) || entry.endsWith("geml.ts"))) {
1042
1312
  const argv = process.argv.slice(2);
1043
- const cmd = argv[0];
1313
+ // The on-disk artifact is `.geml-code-graph/`, so people reconstruct the
1314
+ // command from the directory name — accept those spellings as `codemap`.
1315
+ const cmd = argv[0] === "codegraph" || argv[0] === "code-graph" ? "codemap" : argv[0];
1044
1316
  jsonMode = argv.includes("--json");
1045
1317
  const rest = argv.slice(1);
1046
1318
  if (cmd === "--help" || cmd === "-h") {