@geml/geml 1.5.0 → 1.6.0

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
@@ -13,7 +13,7 @@ import { readFileSync, writeFileSync, realpathSync, statSync, existsSync } from
13
13
  import { basename, dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { spawnSync } from "node:child_process";
16
- import { commit, restore, verify, listRevisions, resolveContent, firstChangedContent } from "./history.js";
16
+ import { save, restore, verify, isCurrent, listRevisions, resolveContent, firstChangedContent } from "./history.js";
17
17
  import { renderHtml } from "./render-html.js";
18
18
  import { normalizeBlockId } from "./block-edit.js";
19
19
  import { normalizeSource } from "./diagnostics.js";
@@ -23,6 +23,7 @@ import { parseTable } from "./table.js";
23
23
  import { buildChart } from "./chart.js";
24
24
  import { mdToGeml } from "./from-md.js";
25
25
  import { serialize } from "./serialize.js";
26
+ import { addressUnits, discoveryHint, matchContent, matchType, parseSelector, shortestAddress, } from "./selector.js";
26
27
  import { gemlToMd } from "./to-md.js";
27
28
  export { mdToGeml } from "./from-md.js";
28
29
  export { renderHtml } from "./render-html.js";
@@ -154,7 +155,7 @@ function registerId(ctx, id, line) {
154
155
  }
155
156
  }
156
157
  // §5: a list marker — `-`/`*` (unordered) or `N.` (ordered) — capturing the
157
- // leading indent (in spaces; a tab counts as one) and the item content. Nesting
158
+ // leading indent (in spaces; a tab counts as 4) and the item content. Nesting
158
159
  // is decided by that indent.
159
160
  const MARKER = /^([ \t]*)(?:[-*]|(\d+)\.)[ \t]+(.*)$/;
160
161
  function matchMarker(line) {
@@ -162,7 +163,14 @@ function matchMarker(line) {
162
163
  if (!m)
163
164
  return null;
164
165
  const ordered = m[2] !== undefined;
165
- const mk = { indent: m[1].length, ordered, rest: m[3] };
166
+ let indent = 0;
167
+ for (const ch of m[1]) {
168
+ if (ch === '\t')
169
+ indent += 4;
170
+ else
171
+ indent += 1;
172
+ }
173
+ const mk = { indent, ordered, rest: m[3] };
166
174
  if (ordered)
167
175
  mk.start = parseInt(m[2], 10);
168
176
  return mk;
@@ -249,7 +257,26 @@ function scanBlocks(lines, base, ctx, depth = 0) {
249
257
  const diags = ctx.diags;
250
258
  let i = 0;
251
259
  while (i < lines.length) {
252
- const line = lines[i];
260
+ let line = lines[i];
261
+ let consumed = 1;
262
+ // C-01: Attribute line continuation via `\`.
263
+ // If a line looks like a fence or heading and ends with `\`, fold subsequent lines.
264
+ if ((line.startsWith("===") || line.startsWith("#")) && line.endsWith("\\")) {
265
+ let folded = line.slice(0, -1).trimEnd();
266
+ while (i + consumed < lines.length) {
267
+ const next = lines[i + consumed].trim();
268
+ if (next.endsWith("\\")) {
269
+ folded += " " + next.slice(0, -1).trimEnd();
270
+ consumed++;
271
+ }
272
+ else {
273
+ folded += " " + next;
274
+ consumed++;
275
+ break;
276
+ }
277
+ }
278
+ line = folded;
279
+ }
253
280
  if (line.trim() === "") {
254
281
  i++;
255
282
  continue;
@@ -259,24 +286,7 @@ function scanBlocks(lines, base, ctx, depth = 0) {
259
286
  const hid = /^[ \t]*%%[ \t]?(.*)$/.exec(line);
260
287
  if (hid) {
261
288
  blocks.push({ kind: "hidden", text: hid[1] });
262
- i++;
263
- continue;
264
- }
265
- // §5.2: a Markdown-style footnote definition `[^id]: text` defines the
266
- // target a `[^id]` reference points at — recorded as a note block with that
267
- // id, so the reference resolves. (A model that reaches for Markdown
268
- // footnotes by habit then "just works" instead of leaving a dangling ref.)
269
- const fndef = /^\[\^([^\]]+)\]:[ \t]?(.*)$/.exec(line);
270
- if (fndef) {
271
- const id = fndef[1].trim();
272
- const lineNo = base + i + 1;
273
- registerId(ctx, id, lineNo);
274
- const text = interpolate(fndef[2], lineNo, ctx);
275
- blocks.push({
276
- kind: "block", type: "note", mode: "flow", id, classes: ["footnote"], attrs: {},
277
- children: [{ kind: "paragraph", text, inlines: parseInline(text, lineNo, ctx) }],
278
- });
279
- i++;
289
+ i += consumed;
280
290
  continue;
281
291
  }
282
292
  const open = FENCE_OPEN.exec(line);
@@ -292,7 +302,7 @@ function scanBlocks(lines, base, ctx, depth = 0) {
292
302
  // safe way to nest (§3).
293
303
  const labeled = attrs.id !== undefined ? new RegExp(`^={3,}[ \\t]+#${reLit(attrs.id)}[ \\t]*$`) : null;
294
304
  const body = [];
295
- let j = i + 1;
305
+ let j = i + consumed;
296
306
  let closed = false;
297
307
  for (; j < lines.length; j++) {
298
308
  if (isCloseFence(lines[j], openLen) || (labeled && labeled.test(lines[j]))) {
@@ -310,6 +320,31 @@ function scanBlocks(lines, base, ctx, depth = 0) {
310
320
  diags.push({ severity: "warning", code: "unknown-block-type", message: `unknown block type \`${type}\`; body kept as raw`, line: openLineNo });
311
321
  mode = "raw";
312
322
  }
323
+ else {
324
+ // `hidden` (§4) and `caption` (§4, and the label an auto-reference takes
325
+ // per §5.2) are not type-specific: every typed block may carry them. Only
326
+ // the extras below are per type.
327
+ let validRe;
328
+ if (type === "table")
329
+ validRe = /^(src|format|header|format-data|compute\d*|summary\d*|span\d*)$/;
330
+ else if (type === "embed")
331
+ validRe = /^(src)$/;
332
+ else if (type === "diagram")
333
+ validRe = /^(src|data|format|type|rows|x|y|size|series)$/;
334
+ // `src`/`anchor` on a `code` block are the code-graph profile's
335
+ // (docs/codemap-profile.md): every document `geml codemap build` writes
336
+ // carries them, so warning on them would warn on our own output.
337
+ else if (type === "code")
338
+ validRe = /^(lang|src|anchor|name|entry-via)$/;
339
+ else
340
+ validRe = /^$/;
341
+ const universal = /^(hidden|caption)$/;
342
+ for (const key of Object.keys(attrs.attrs)) {
343
+ if (!universal.test(key) && !validRe.test(key)) {
344
+ diags.push({ severity: "warning", code: "unknown-attribute", message: `unknown attribute \`${key}\` for block type \`${type}\``, line: openLineNo });
345
+ }
346
+ }
347
+ }
313
348
  const block = {
314
349
  kind: "block", type, mode, classes: attrs.classes, attrs: attrs.attrs,
315
350
  };
@@ -382,18 +417,11 @@ function scanBlocks(lines, base, ctx, depth = 0) {
382
417
  else {
383
418
  block.raw = body;
384
419
  if (type === "table") {
385
- // `src=` and `data=` are one attribute in two spellings: where this
386
- // table's data comes from. Recorded for the post-scan pass.
387
420
  const srcAttr = typeof attrs.attrs["src"] === "string" ? attrs.attrs["src"].trim() : undefined;
388
- const dataAttr = typeof attrs.attrs["data"] === "string" ? attrs.attrs["data"].trim() : undefined;
389
- if (srcAttr !== undefined && dataAttr !== undefined) {
390
- diags.push({ severity: "error", code: "source-attr-conflict", message: "table has both `src=` and `data=`; they mean the same thing — use one", line: openLineNo });
391
- }
392
- const target = srcAttr ?? dataAttr;
393
421
  // §6: parse the raw body (visual or csv/tsv) into one table model.
394
- const { model, diagnostics } = parseTable(body, target === undefined ? attrs.attrs : { ...attrs.attrs, src: target }, openLineNo, ctx);
395
- if (target !== undefined)
396
- (ctx.tableSources ??= []).push({ block, line: openLineNo, target });
422
+ const { model, diagnostics } = parseTable(body, attrs.attrs, openLineNo, ctx);
423
+ if (srcAttr !== undefined)
424
+ (ctx.tableSources ??= []).push({ block, line: openLineNo, target: srcAttr });
397
425
  block.table = model;
398
426
  for (const d of diagnostics)
399
427
  diags.push({ ...d, line: openLineNo });
@@ -442,9 +470,10 @@ function scanBlocks(lines, base, ctx, depth = 0) {
442
470
  if (h) {
443
471
  const lineNo = base + i + 1;
444
472
  const level = h[1].length;
445
- const a = h[3] ? parseAttrs(h[3]) : { classes: [], attrs: {} };
446
- const text = interpolate(h[2], lineNo, ctx);
447
- const id = a.id ?? slug(text);
473
+ const rawText = h[2];
474
+ const a = parseAttrs(h[3] ?? "");
475
+ const text = interpolate(rawText, lineNo, ctx);
476
+ const id = a.id ?? slug(rawText);
448
477
  registerId(ctx, id, lineNo);
449
478
  const block = {
450
479
  kind: "heading", level, text, inlines: parseInline(text, lineNo, ctx), id, classes: a.classes, attrs: a.attrs,
@@ -452,7 +481,7 @@ function scanBlocks(lines, base, ctx, depth = 0) {
452
481
  if (a.attrs["hidden"] === true)
453
482
  block.hidden = true;
454
483
  blocks.push(block);
455
- i++;
484
+ i += consumed;
456
485
  continue;
457
486
  }
458
487
  if (LIST_ITEM.test(line)) {
@@ -711,7 +740,7 @@ function gatherEmbeds(source) {
711
740
  scanBlocks(normalizeSource(source).split("\n"), 0, ctx);
712
741
  return (ctx.embeds ?? []).map((e) => (e.anchor === undefined ? { doc: e.doc } : { doc: e.doc, anchor: e.anchor }));
713
742
  }
714
- // One rule for "where this data comes from", shared by a table's `src=`/`data=`
743
+ // One rule for "where this data comes from", shared by a table's `src=`
715
744
  // and a chart's `data=`. Three target forms: a data file, `#id` naming a table
716
745
  // block in this document, or `doc.geml#id` naming one in another document. An
717
746
  // unresolvable target is an error — a table whose source silently produced no
@@ -773,11 +802,10 @@ function resolveTableSources(ctx, opts) {
773
802
  err(line, "unresolvable-table-source", `cannot resolve table source \`${target}\``);
774
803
  continue;
775
804
  }
776
- // Reuse the body parser: with `src`/`data` dropped, the file's lines are just
805
+ // Reuse the body parser: with `src` dropped, the file's lines are just
777
806
  // this table's body, so format/header/compute/summary all behave identically.
778
807
  const attrs = { ...block.attrs };
779
808
  delete attrs["src"];
780
- delete attrs["data"];
781
809
  const { model, diagnostics } = parseTable(normalizeSource(text).split("\n"), attrs, line, ctx);
782
810
  model.src = target;
783
811
  block.table = model;
@@ -1004,13 +1032,11 @@ export function parse(source, opts = {}) {
1004
1032
  }
1005
1033
  // The id that a fence/heading line defines, matching how scanBlocks derives it
1006
1034
  // (parseAttrs for the attribute object; heading text slug when no explicit id).
1007
- // The slug MUST come from the INTERPOLATED text scanBlocks slugs after
1008
- // interpolate(), so `# {{title}} Setup` registers the substituted slug; slugging
1009
- // the raw text here would create a phantom id the parser never registered and
1010
- // make the real one unaddressable. `ctx` is an inert context carrying the
1011
- // document's meta (diagnostics are discarded — spans never report).
1035
+ // The slug MUST come from the RAW text, before interpolation, so that changing
1036
+ // a meta variable does not silently change the block's addressable id.
1037
+ // `ctx` is passed just in case future features need context.
1012
1038
  function idOfHeading(braces, text, line, ctx) {
1013
- return (braces ? parseAttrs(braces).id : undefined) ?? slug(interpolate(text, line, ctx));
1039
+ return (braces ? parseAttrs(braces).id : undefined) ?? slug(text);
1014
1040
  }
1015
1041
  // The matching close of the fence opened at lines[i] (equal-length run, or the
1016
1042
  // labeled `=== #id` close when the block carries an id): the index just past
@@ -1047,14 +1073,17 @@ function sectionEnd(lines, i, level) {
1047
1073
  }
1048
1074
  // Walk `lines` exactly as scanBlocks does — same fence close rules (equal-length
1049
1075
  // or labeled `=== #id`), same flow-only recursion via REGISTRY — recording the
1050
- // source span of every addressable id (typed block, heading, footnote def).
1076
+ // source span of every addressable id (typed block, heading).
1051
1077
  // First definition wins, mirroring ctx.ids (a duplicate id is a build error, so
1052
1078
  // `get`/`set` operate on the one the parser actually registered). `base` is the
1053
1079
  // absolute line offset of this slice within the whole document.
1054
1080
  function collectSpans(lines, base, out, ctx, depth = 0,
1055
- // Optional second index: every typed block by TYPE, id-bearing or not, so a
1056
- // block the author never named is still addressable (`=== meta`).
1057
- types) {
1081
+ // Optional second index: every addressable unit in document order typed
1082
+ // blocks (id-bearing or not, so a block the author never named is still
1083
+ // addressable), headings, footnote definitions. A second SINK on the one
1084
+ // walk, not a second walk: the selector design's "one definition, one
1085
+ // implementation" applies to the scan as much as to the syntax.
1086
+ units) {
1058
1087
  const add = (id, start, end) => {
1059
1088
  if (!out.has(id))
1060
1089
  out.set(id, { start, end });
@@ -1069,6 +1098,7 @@ types) {
1069
1098
  const fndef = /^\[\^([^\]]+)\]:[ \t]?(.*)$/.exec(line);
1070
1099
  if (fndef) {
1071
1100
  add(fndef[1].trim(), base + i, base + i + 1);
1101
+ units?.push({ span: { start: base + i, end: base + i + 1 }, kind: "footnote", id: fndef[1].trim() });
1072
1102
  i++;
1073
1103
  continue;
1074
1104
  }
@@ -1083,16 +1113,12 @@ types) {
1083
1113
  const { end, closed } = fenceClose(lines, i, open);
1084
1114
  if (id !== undefined)
1085
1115
  add(id, base + i, base + end);
1086
- if (types) {
1087
- const list = types.get(type) ?? [];
1088
- list.push({ span: { start: base + i, end: base + end }, id });
1089
- types.set(type, list);
1090
- }
1116
+ units?.push({ span: { start: base + i, end: base + end }, kind: "block", type, ...(id !== undefined ? { id } : {}) });
1091
1117
  // Only a flow body is scanned for nested blocks (raw/data bodies are
1092
1118
  // opaque), so an id inside a `code` body is *not* addressable — exactly
1093
1119
  // the parser's contract.
1094
1120
  if ((REGISTRY[type] ?? "raw") === "flow" && depth < MAX_NESTING) {
1095
- collectSpans(lines.slice(i + 1, closed ? end - 1 : end), base + i + 1, out, ctx, depth + 1, types);
1121
+ collectSpans(lines.slice(i + 1, closed ? end - 1 : end), base + i + 1, out, ctx, depth + 1, units);
1096
1122
  }
1097
1123
  i = end;
1098
1124
  continue;
@@ -1103,7 +1129,10 @@ types) {
1103
1129
  // still advances one line at a time so every nested id inside the
1104
1130
  // section registers its own span — spans intentionally OVERLAP: #sec
1105
1131
  // contains #code, and each remains addressable on its own.
1106
- add(idOfHeading(h[3], h[2], base + i + 1, ctx), base + i, base + sectionEnd(lines, i, h[1].length));
1132
+ const hid = idOfHeading(h[3], h[2], base + i + 1, ctx);
1133
+ const hend = base + sectionEnd(lines, i, h[1].length);
1134
+ add(hid, base + i, hend);
1135
+ units?.push({ span: { start: base + i, end: hend }, kind: "heading", id: hid, level: h[1].length, text: h[2] });
1107
1136
  i++;
1108
1137
  continue;
1109
1138
  }
@@ -1115,18 +1144,29 @@ types) {
1115
1144
  export function blockSpans(source) {
1116
1145
  const out = new Map();
1117
1146
  const lines = normalizeSource(source).split("\n");
1118
- // Inert context: heading auto-ids slug the interpolated text (parser parity);
1119
- // its diagnostics are discarded the span scan never reports.
1147
+ // Inert context: heading auto-ids slug the raw text, but parseDoc still
1148
+ // requires a valid context to parse the document.
1120
1149
  const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines) };
1121
1150
  collectSpans(lines, 0, out, ctx);
1122
1151
  return out;
1123
1152
  }
1124
- function blockTypeSpans(source) {
1153
+ // Every addressable unit, in document order, each decorated with its content
1154
+ // address (§3.2). Ids are OPTIONAL in GEML (§1: a block MAY carry one), so
1155
+ // `meta`, a callout `note`, a `table` — anything the author had no reason to
1156
+ // name — has no id to address it by; this index is what makes those addressable
1157
+ // anyway, by type (`=== meta`) or by content (`@<hex>`). No block type is
1158
+ // special-cased; meta is merely the one that is usually unique.
1159
+ //
1160
+ // The ONE index selector matching and the listing both work from, so `get`,
1161
+ // `set` and the listing can never disagree about what exists.
1162
+ function addressedUnits(source) {
1125
1163
  const lines = normalizeSource(source).split("\n");
1126
1164
  const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines) };
1127
- const types = new Map();
1128
- collectSpans(lines, 0, new Map(), ctx, 0, types);
1129
- return types;
1165
+ const units = [];
1166
+ collectSpans(lines, 0, new Map(), ctx, 0, units);
1167
+ // The address hashes the block's own source text — the exact bytes `get`
1168
+ // would print for it — so an address can be recomputed from `get` output.
1169
+ return addressUnits(units, (u) => lines.slice(u.span.start, u.span.end).join("\n"));
1130
1170
  }
1131
1171
  // Split into physical lines while *keeping* each line's terminator, so
1132
1172
  // join("") is byte-exact and slicing by span never rewrites line endings.
@@ -1153,14 +1193,40 @@ function toNewline(text, nl) {
1153
1193
  return nl === "\n" ? lf : lf.replace(/\n/g, nl);
1154
1194
  }
1155
1195
  // `--head`: narrow any id's span to its HEAD line — the single declaring line
1156
- // (a heading's `# … {#id}` line, a typed block's opening fence, a footnote's
1157
- // `[^id]:` line). The head is by construction the FIRST line of the span, so
1196
+ // (a heading's `# … {#id}` line, or a typed block's opening fence). The head is
1197
+ // by construction the FIRST line of the span, so
1158
1198
  // the narrowing is parse-free and needs no type check. Main use: `set --head`
1159
1199
  // edits a block's attributes (caption/compute/lang/…) without re-sending its
1160
1200
  // body, or renames a heading without rewriting its section.
1161
1201
  function narrowToHead(span) {
1162
1202
  return { start: span.start, end: span.start + 1 };
1163
1203
  }
1204
+ // The unit's CLOSING fence line, or null when it has none — a heading section,
1205
+ // or a fence left unclosed at EOF. Extracted so `get --body` and `set --body`
1206
+ // decide it in ONE place: the selector design's §4 defines HEAD/BODY by the
1207
+ // round-trip invariant `get X --body | set X --body` leaving the file
1208
+ // byte-identical, and two copies of this judgement is exactly how that breaks.
1209
+ function closeFenceLine(lines, span) {
1210
+ const open = FENCE_OPEN.exec(stripEol(lines[span.start] ?? ""));
1211
+ if (!open)
1212
+ return null;
1213
+ const lastText = stripEol(lines[span.end - 1] ?? "").replace(/[ \t]+$/, "");
1214
+ const bid = open[3] ? parseAttrs(open[3]).id : undefined;
1215
+ const labeled = bid !== undefined && new RegExp(`^={3,}[ \\t]+#${reLit(bid)}[ \\t]*$`).test(lastText);
1216
+ return isCloseFence(lastText, open[1].length) || labeled ? lines[span.end - 1] ?? "" : null;
1217
+ }
1218
+ // BODY span: a fenced block's lines BETWEEN the fences; a heading's lines after
1219
+ // the heading through the section boundary — trailing blank lines included,
1220
+ // because that is the span `set --body` replaces (§4's table).
1221
+ function narrowToBody(lines, span) {
1222
+ return { start: span.start + 1, end: closeFenceLine(lines, span) !== null ? span.end - 1 : span.end };
1223
+ }
1224
+ // Slice one unit's output bytes, honouring --head / --body.
1225
+ function sliceUnit(source, span, headOnly, bodyOnly) {
1226
+ const lines = splitLines(source);
1227
+ const s = headOnly ? narrowToHead(span) : bodyOnly ? narrowToBody(lines, span) : span;
1228
+ return lines.slice(s.start, s.end).join("");
1229
+ }
1164
1230
  // Depth-first search for the document-model node carrying `id`, descending into
1165
1231
  // flow-block children (and list-item children) so a nested id is found too.
1166
1232
  // Returns the containing sibling array and index, not just the node: the model
@@ -1213,13 +1279,9 @@ function flag(args, name) {
1213
1279
  function historyPathFor(geml) {
1214
1280
  return geml.replace(/\.geml$/, "") + ".gemlhistory";
1215
1281
  }
1216
- function parseStamp(s) {
1217
- const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(s);
1218
- if (!m)
1219
- throw new Error(`bad --at timestamp: ${s} (want YYYYMMDDTHHMMSSZ)`);
1220
- const [, y, mo, d, h, mi, se] = m;
1221
- return new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +se));
1222
- }
1282
+ // (A `YYYYMMDDTHHMMSSZ` parser lived here for `history commit --at`. That flag
1283
+ // left the CLI with design §9-Q4 — the library API takes a real Date — so the parser
1284
+ // went with it rather than staying as an uncalled branch.)
1223
1285
  const VERSION = "1.0"; // GEML spec version this CLI targets
1224
1286
  // The published version, read from package.json rather than restated here.
1225
1287
  // "Keep in sync with package.json" was a comment, and comments do not run: this
@@ -1279,10 +1341,13 @@ Usage:
1279
1341
  (sel: 0 | -N | id-prefix | changed; default -1)
1280
1342
  geml check <file.geml|-> [--root d] [--json] validate only: diagnostics + exit code
1281
1343
  (--root widens cross-doc refs to dir d, e.g. the repo root)
1282
- geml history <commit|verify|show|restore|log> <file.geml> [...] .gemlhistory version sidecar
1344
+ geml history <save|get|restore|verify> <file.geml> [...] .gemlhistory version sidecar
1345
+ (save = append the file as a revision · get = list revisions, or
1346
+ print one · restore = overwrite the file with one · verify = rebuild
1347
+ and re-hash the whole chain)
1283
1348
  geml codemap <build|verify|render|serve|refresh|find> [...] code-graph toolkit (alias: codegraph)
1284
1349
  geml mcp --root <dir> [--graph <dir>] [--no-history] serve documents (and the code graph) over MCP (stdio)
1285
- (10 tools, each geml_ + its CLI verb: list/get/check/history/to +
1350
+ (10 tools, each geml_ + its CLI command path: list/get/check/history/to +
1286
1351
  set/add/delete/rename/revert; every write is validated before it
1287
1352
  reaches disk. A code graph under --root adds four read-only
1288
1353
  geml_codemap_* tools to the same server)
@@ -1299,14 +1364,19 @@ Exit codes:
1299
1364
  // One-line usage for each subcommand — the single source for both the error
1300
1365
  // shown on misuse and the `<cmd> --help` text.
1301
1366
  const SUBHELP = {
1302
- get: "usage: geml get <file.geml|-> [#id | '## Heading' | '=== type'] [--json] [--head] (selector: an #id, or a LINE copied from the document a heading `## Title` addresses its whole section, a fence `=== meta` addresses that block by type and lists the candidates when several match; --head = the head line; without a selector: list every addressable id, --json = array)",
1303
- set: "usage: geml set <file.geml|-> #id [--head|--body] [--in F | --in F#src | --in -] [-o out.geml] (content: --in F takes F's block #id, --in F#src takes #src, else stdin raw; default = whole block, --head = head line — both normalize the id to #id — --body = body; guarded splice, refused if it breaks the doc)",
1367
+ get: "usage: geml get <file.geml|-> [<selector>] [--head|--body] [--json] (selector = a filter over blocks: #id | '## Heading' (its whole section) | '=== type' (every block of that type N matches print N contents, count on stderr) | '=== type@<hex>[~n]' or '@<hex>[~n]' (content address, for blocks with no #id); --head = head line, --body = body; without a selector: list every addressable block with its shortest unique address, --json = array)",
1368
+ set: "usage: geml set <file.geml|-> <selector> [--head|--body] [--in F | --in F#src | --in -] [-o out.geml] (selector as in `get`, but it must match exactly ONE block — '=== type' matching several is refused; content: --in F takes F's block #id, --in F#src takes #src, else stdin raw; default = whole block, --head = head line — both normalize the id when the target has one — --body = body; guarded splice, refused if it breaks the doc; writing through an @<hex> address prints the new address on stderr)",
1304
1369
  add: "usage: geml add <file.geml|-> (--append | --before #id | --after #id) [--in F | --in F#src | --in -] [-o out.geml] (insert a GEML fragment — 1+ blocks and/or prose — at a position; --in F takes all of F, --in F#src takes #src, else stdin raw; content keeps its own ids, a collision is refused)",
1305
1370
  delete: "usage: geml delete <file.geml|-> #id [#id2 …] [-o out.geml] (remove one or more blocks; a missing id is skipped with a note, not an error; a reference left dangling is a warning, not a refusal — delete never fails on a live reference)",
1306
1371
  rename: "usage: geml rename <file.geml|-> #old #new [-o out.geml] (rewrite an id's declaration AND every reference — [[#id]], [text](#id), chart data=#id, footnote [^id] — id-boundary safe, skipping raw block bodies; #new must be free; refused if it breaks the doc)",
1307
1372
  check: "usage: geml check <file.geml|-> [--root <dir>] [--json] (--root: resolve cross-doc refs within <dir> instead of the file's own directory)",
1308
1373
  revert: "usage: geml revert <file.geml> #id [--rev <sel>] [--append|--before #x|--after #x] [--head] [--dry-run] [-o out] (reconcile #id to a revision: splice / resurrect / remove; sel: 0 | -N | id-prefix | changed; default -1)",
1309
- history: "usage: geml history <commit|verify|show|restore|log> <file.geml> [...]",
1374
+ history: `usage: geml history save <file.geml> [-m <msg>] append the working file as a new revision (identical to the tip = no-op)
1375
+ geml history get <file.geml> [<rev>] [--json] NO <rev>: every revision, newest first, first column = the selector; WITH <rev>: that revision's full text
1376
+ geml history restore <file.geml> <rev> [--force] overwrite the working file with a revision (--force discards unsaved changes)
1377
+ geml history verify <file.geml> rebuild and re-hash every revision in the chain
1378
+ (<rev>: 0 = the tip | -N = N revisions back | an unambiguous revision id — the strings 'get' prints.
1379
+ All four take --history <path> to point at a sidecar other than <file>.gemlhistory.)`,
1310
1380
  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)
1311
1381
  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]]
1312
1382
  geml codemap verify [dir] geml check + profile reference checks
@@ -1318,8 +1388,9 @@ const SUBHELP = {
1318
1388
  mcp: `usage: geml mcp --root <dir> [--graph <dir>] [--no-history]
1319
1389
 
1320
1390
  Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0).
1321
- Every tool is geml_ + its CLI verb, so the terminal and the assistant share
1322
- one vocabulary.
1391
+ Every tool is geml_ + its CLI COMMAND PATH, so the terminal and the assistant
1392
+ share one vocabulary — geml_history mirrors the "geml history" command group,
1393
+ whose read verb (get) is the only one of the four served here.
1323
1394
  Ten tools: geml_list · geml_get · geml_check · geml_history · geml_to
1324
1395
  geml_set · geml_add · geml_delete · geml_rename · geml_revert
1325
1396
  With a code graph under --root, four more (read-only), so one client entry
@@ -1331,9 +1402,9 @@ const SUBHELP = {
1331
1402
  --graph <dir> Code-graph directory, inside --root. Defaults to
1332
1403
  <root>/.geml-code-graph when it holds an index.geml; with
1333
1404
  no graph the four graph tools are not served at all.
1334
- --no-history Skip the .gemlhistory commit taken before each write
1335
- (default: commit, so geml_revert always has a revision to
1336
- undo to).
1405
+ --no-history Skip the .gemlhistory revision saved before each write
1406
+ (default: save one, so geml_revert always has a revision
1407
+ to undo to).
1337
1408
 
1338
1409
  Register with a client:
1339
1410
  claude mcp add geml -- geml mcp --root /abs/path/to/repo`,
@@ -1485,23 +1556,144 @@ function historyError(e, file, historyPath) {
1485
1556
  }
1486
1557
  return err?.message ?? String(e);
1487
1558
  }
1559
+ // The three verbs the four-verb collapse removed (design §2/§6). They are HARD
1560
+ // deletions, not aliases — the same call this repo already made for `geml
1561
+ // codemap mcp` and `geml mcp --workspace`: one word, one meaning, and a stale
1562
+ // spelling that keeps working is a second vocabulary the docs and an agent's
1563
+ // memory then both carry. So each names its replacement rather than falling into
1564
+ // `unknown history subcommand`, which would leave the caller guessing which of
1565
+ // four verbs took over.
1566
+ const RETIRED_HISTORY = {
1567
+ commit: "geml history commit was renamed: use `geml history save <file.geml> [-m msg]` (same behaviour, except that a file identical to the tip is now a no-op instead of an empty revision).",
1568
+ log: "geml history log was removed: use `geml history get <file.geml>` — no revision selector lists every revision, newest first, with the same copy-pasteable first column.",
1569
+ show: "geml history show was removed: use `geml history get <file.geml> <rev>` — a revision selector prints that revision's full text (`--json` wraps it as {id, text}).",
1570
+ };
1571
+ // Subcommand, file and revision, read positionally around the options —
1572
+ // `--history <path>` and `-m <msg>` may sit anywhere, and the old args[0..2]
1573
+ // indexing read `--history` itself as the file.
1574
+ //
1575
+ // The generic `positionals()` cannot be reused: it drops every `-`-leading token,
1576
+ // and a revision selector `-N` LOOKS exactly like a flag. That is the whole point
1577
+ // of the first column `history get` prints, so `-N` is admitted and every other
1578
+ // `-`-leading token is treated as an option.
1579
+ function historyPositionals(args) {
1580
+ const out = [];
1581
+ for (let i = 0; i < args.length; i++) {
1582
+ const a = args[i];
1583
+ if (a === "--history" || a === "-m" || a === "--message") {
1584
+ i++;
1585
+ continue;
1586
+ } // flag AND its value
1587
+ if (a.startsWith("-") && !/^-\d+$/.test(a))
1588
+ continue; // --json, --force, …
1589
+ out.push(a);
1590
+ }
1591
+ return out;
1592
+ }
1488
1593
  function runHistory(args) {
1489
- const sub = args[0];
1490
- const file = args[1];
1594
+ const [sub, file, rev, ...extra] = historyPositionals(args);
1491
1595
  if (!sub || !file)
1492
1596
  fail(SUBHELP.history);
1597
+ if (RETIRED_HISTORY[sub])
1598
+ fail(RETIRED_HISTORY[sub]);
1493
1599
  const historyPath = flag(args, "--history") ?? historyPathFor(file);
1600
+ const json = args.includes("--json");
1494
1601
  try {
1495
- if (sub === "commit") {
1496
- const at = flag(args, "--at");
1497
- const r = commit({
1602
+ if (sub === "save") {
1603
+ // design §3.1/§9-Q4: `--author` and `--at` were withdrawn from the CLI (nothing
1604
+ // outside tests ever passed either). Refusing beats ignoring for the same
1605
+ // reason the retired verbs above refuse: a silently dropped `--author
1606
+ // alice` discards precisely the value the caller went out of their way to
1607
+ // type. Both stay on the library API (save({ author, at })).
1608
+ for (const gone of ["--author", "--at"]) {
1609
+ if (args.some((a) => a === gone || a.startsWith(`${gone}=`))) {
1610
+ fail(`${gone} is no longer accepted by 'geml history save' — the only option is -m/--message. (Both remain on the library API, save({ author, at }), for embedders and for tests that pin a revision id.)`);
1611
+ }
1612
+ }
1613
+ // design §3.1: an empty save is a NO-OP. `save` is the one non-idempotent verb,
1614
+ // so an agent retrying a save it is unsure landed must not lengthen the
1615
+ // chain by a revision with no ops. `geml mcp` already gated its
1616
+ // pre-write snapshot on this exact predicate (mcp.ts snapshot()); this is
1617
+ // the same `isCurrent()`, not a second hash comparison.
1618
+ if (existsSync(historyPath) && isCurrent(historyPath, file)) {
1619
+ console.log(`already saved as ${listRevisions(historyPath)[0].id} (no changes)`);
1620
+ return;
1621
+ }
1622
+ const r = save({
1498
1623
  gemlPath: file,
1499
1624
  historyPath,
1500
1625
  summary: flag(args, "-m") ?? flag(args, "--message") ?? "",
1501
- author: flag(args, "--author"),
1502
- at: at ? parseStamp(at) : undefined,
1503
1626
  });
1504
- console.log(`committed ${r.id}`);
1627
+ console.log(`saved ${r.id}`);
1628
+ }
1629
+ else if (sub === "get") {
1630
+ // Three tiers, split by how many addresses were given — the same rule the
1631
+ // top-level `geml get` follows (design §1.2). Tier 2 takes a BLOCK
1632
+ // selector inside the revision and reuses the top-level grammar verbatim
1633
+ // (§10.1): a revision rebuilt is just a document's text, so there is no
1634
+ // new algorithm here, and the two selector namespaces cannot collide —
1635
+ // position is fixed and the lexis does not overlap (§10.2).
1636
+ if (extra.length > 1) {
1637
+ fail(`history get takes ONE revision selector and ONE block selector; got ${extra.length + 1} positionals after the file`, 2);
1638
+ }
1639
+ if (rev === undefined) {
1640
+ // Newest-first, with each row's selector in the first column (`0` for
1641
+ // the tip, then `-1`, `-2`, …) so the output is copy-paste into `get`,
1642
+ // `restore` and `revert --rev` alike.
1643
+ const revs = listRevisions(historyPath);
1644
+ if (json) {
1645
+ console.log(JSON.stringify(revs, null, 2));
1646
+ }
1647
+ else {
1648
+ for (const r of revs) {
1649
+ const sel = r.current ? "0" : `-${r.offset}`;
1650
+ console.log(`${sel.padEnd(7)} ${r.id} ${r.author ?? "-"} ${r.summary ?? ""}`.replace(/\s+$/, ""));
1651
+ }
1652
+ }
1653
+ }
1654
+ else {
1655
+ // resolveContent() routes through the ONE selector grammar
1656
+ // (resolveRevision) that the list above prints — see its comment for
1657
+ // what happened the last time that was written twice.
1658
+ const { id, text } = resolveContent(historyPath, rev);
1659
+ const blockSel = extra[0];
1660
+ if (blockSel === undefined) {
1661
+ if (json)
1662
+ console.log(JSON.stringify({ id, text }, null, 2));
1663
+ else
1664
+ process.stdout.write(text);
1665
+ }
1666
+ else {
1667
+ // Tier 2 (§10.1). Cardinality and the flag rules are the top-level
1668
+ // ones, checked here because this tier has its own argument list.
1669
+ const headOnly = args.includes("--head");
1670
+ const bodyOnly = args.includes("--body");
1671
+ if (headOnly && bodyOnly)
1672
+ fail("--head and --body are mutually exclusive", 2);
1673
+ if (json && (headOnly || bodyOnly)) {
1674
+ fail(`--json cannot be combined with ${headOnly ? "--head" : "--body"} — --json returns the model node, which has no sub-node for one part of a block`, 2);
1675
+ }
1676
+ const { units, all } = selectUnits(text, file, blockSel, `revision ${id}`);
1677
+ if (json) {
1678
+ // §3.2's tier table: the revision id travels with the block, so the
1679
+ // caller can tell WHICH version it is holding.
1680
+ const nodes = units.map((u) => unitNode(text, file, u, all));
1681
+ console.log(JSON.stringify({ id, block: units.length === 1 ? nodes[0] : nodes }, null, 2));
1682
+ }
1683
+ else {
1684
+ if (units.length > 1)
1685
+ reportMatches(units[0].type ?? "", units);
1686
+ for (const u of units)
1687
+ process.stdout.write(sliceUnit(text, u.span, headOnly, bodyOnly));
1688
+ }
1689
+ }
1690
+ }
1691
+ }
1692
+ else if (sub === "restore") {
1693
+ if (!rev)
1694
+ fail("usage: geml history restore <file.geml> <revision> [--force]");
1695
+ restore({ historyPath, gemlPath: file, revision: rev, write: true, force: args.includes("--force") });
1696
+ console.log(`restored ${file} to ${rev}`);
1505
1697
  }
1506
1698
  else if (sub === "verify") {
1507
1699
  const res = verify(historyPath, file);
@@ -1513,27 +1705,6 @@ function runHistory(args) {
1513
1705
  if (!res.ok)
1514
1706
  process.exit(1);
1515
1707
  }
1516
- else if (sub === "show") {
1517
- const rev = args[2];
1518
- if (!rev)
1519
- fail("usage: geml history show <file.geml> <revision>");
1520
- process.stdout.write(restore({ historyPath, gemlPath: file, revision: rev }));
1521
- }
1522
- else if (sub === "restore") {
1523
- const rev = args[2];
1524
- if (!rev)
1525
- fail("usage: geml history restore <file.geml> <revision> [--force]");
1526
- restore({ historyPath, gemlPath: file, revision: rev, write: true, force: args.includes("--force") });
1527
- console.log(`restored ${file} to ${rev}`);
1528
- }
1529
- else if (sub === "log") {
1530
- // Newest-first, with the `--rev` selector for each row in the first column
1531
- // (`0` for the tip, then `-1`, `-2`, …) so the output is copy-paste.
1532
- for (const r of listRevisions(historyPath)) {
1533
- const sel = r.current ? "0" : `-${r.offset}`;
1534
- console.log(`${sel.padEnd(7)} ${r.id} ${r.author ?? "-"} ${r.summary ?? ""}`.replace(/\s+$/, ""));
1535
- }
1536
- }
1537
1708
  else {
1538
1709
  fail(`unknown history subcommand: ${sub}. Run 'geml --help'.`);
1539
1710
  }
@@ -1794,39 +1965,58 @@ function resolveSelector(source, file, raw) {
1794
1965
  // a heading, its level and text); `--json` is a machine-readable array so an
1795
1966
  // agent can pick its next `get #id` target. Ids are listed in document order
1796
1967
  // (the registration order parse() records), covering the same set `get #id`
1797
- // resolves against: typed blocks, headings, and footnote definitions.
1968
+ // resolves against: typed blocks and headings. A `[^id]` reference names one
1969
+ // of those (§5.2); the `[^id]: text` definition line was withdrawn.
1798
1970
  function listIds(source, file, json) {
1971
+ const where = file === "-" ? "stdin" : file;
1972
+ const all = addressedUnits(source);
1799
1973
  const doc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
1800
- const rows = doc.ids.map((id) => {
1801
- const site = findBlockSite(doc.children, id);
1802
- const b = site?.siblings[site.index];
1803
- if (b?.kind === "heading")
1804
- return { id, kind: "heading", level: b.level, text: b.text };
1805
- if (b?.kind === "block") {
1806
- const row = { id, kind: b.type };
1807
- if (b.classes.includes("footnote"))
1808
- row.footnote = true; // §5.2 footnote definition
1809
- return row;
1810
- }
1811
- return { id, kind: b?.kind ?? "unknown" };
1974
+ const rows = all.map((a) => {
1975
+ const u = a.unit;
1976
+ const row = {
1977
+ address: shortestAddress(a, all),
1978
+ kind: u.kind === "block" ? u.type ?? "block" : u.kind,
1979
+ lines: [u.span.start + 1, u.span.end],
1980
+ };
1981
+ // §6.3: EVERY id-less block is flagged, including one whose address works
1982
+ // only because its type happens to be unique (`=== meta`) — that it has no
1983
+ // id yet is precisely the fact you might want to act on (§5.2).
1984
+ if (u.id === undefined)
1985
+ row.anon = true;
1986
+ else
1987
+ row.id = u.id;
1988
+ if (u.kind === "heading") {
1989
+ row.level = u.level;
1990
+ row.text = u.text;
1991
+ }
1992
+ // `.footnote` is authored, not synthesized (the `[^id]: text` definition
1993
+ // line was withdrawn) — but it still marks a block meant as a footnote.
1994
+ if (u.id !== undefined) {
1995
+ const site = findBlockSite(doc.children, u.id);
1996
+ const b = site?.siblings[site.index];
1997
+ if (b?.kind === "block" && b.classes.includes("footnote"))
1998
+ row.footnote = true;
1999
+ }
2000
+ return row;
1812
2001
  });
2002
+ // §6.6: the empty document is a legitimate empty answer to "list everything",
2003
+ // not a lookup failure — exit 0, and `--json` prints `[]` so a `| jq length`
2004
+ // over a prose-only document does not blow up.
1813
2005
  if (json) {
1814
2006
  console.log(JSON.stringify(rows, null, 2));
1815
2007
  return;
1816
2008
  }
1817
2009
  if (rows.length === 0) {
1818
- console.error(`no addressable ids in ${file === "-" ? "stdin" : file}`);
2010
+ console.error(`no addressable blocks in ${where}`);
1819
2011
  return;
1820
2012
  }
1821
- // Align the id and kind columns; append a heading's level+text or a footnote flag.
1822
- const idW = Math.max(...rows.map((r) => r.id.length + 1));
2013
+ const addrW = Math.max(...rows.map((r) => r.address.length));
1823
2014
  const kindW = Math.max(...rows.map((r) => r.kind.length));
1824
2015
  for (const r of rows) {
1825
- let line = `#${r.id}`.padEnd(idW + 1) + " " + r.kind.padEnd(kindW);
1826
- if (r.kind === "heading")
1827
- line += ` h${r.level} ${r.text}`;
1828
- else if (r.footnote)
1829
- line += " footnote";
2016
+ const mark = r.kind === "heading" ? `h${r.level}` : r.anon ? "anon" : "";
2017
+ const tail = r.kind === "heading" ? r.text ?? "" : `L${r.lines[0]}-${r.lines[1]}`;
2018
+ const line = `${r.address.padEnd(addrW)} ${r.kind.padEnd(kindW)} ${mark.padEnd(4)} ${tail}`
2019
+ + (r.footnote ? " footnote" : "");
1830
2020
  console.log(line.replace(/\s+$/, ""));
1831
2021
  }
1832
2022
  }
@@ -1843,42 +2033,11 @@ function listIds(source, file, json) {
1843
2033
  // guessed between, so a document with three notes answers "which one" instead
1844
2034
  // of failing. The uniqueness that makes `=== meta` work is checked here, at
1845
2035
  // resolve time — nothing in the format has to promise a document holds only one.
1846
- function getByType(source, file, type, json, headOnly) {
1847
- const where = file === "-" ? "stdin" : file;
1848
- const matches = blockTypeSpans(source).get(type) ?? [];
1849
- if (!matches.length) {
1850
- fail(`no \`${type}\` block in ${where} — run \`geml get ${where}\` to list every addressable id`, 1);
1851
- }
1852
- if (matches.length === 1) {
1853
- const m = matches[0];
1854
- if (json) {
1855
- // The ONLY block of its type: locating it in the model needs no index, so
1856
- // --json can still answer with the parsed node (meta's key/values, a
1857
- // table's model) rather than a mere location.
1858
- const node = onlyBlockOfType(parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) }).children, type);
1859
- if (node) {
1860
- console.log(JSON.stringify(node, null, 2));
1861
- return;
1862
- }
1863
- }
1864
- const span = headOnly ? narrowToHead(m.span) : m.span;
1865
- process.stdout.write(splitLines(source).slice(span.start, span.end).join(""));
1866
- return;
1867
- }
1868
- // Several: report WHERE they are (data on stdout, the explanation on stderr),
1869
- // so the caller can name one — by adding an #id, or via its section.
1870
- if (json) {
1871
- console.log(JSON.stringify({ kind: "blocks", type, matches: matches.map((m) => ({ ...(m.id ? { id: m.id } : {}), lines: [m.span.start + 1, m.span.end] })) }, null, 2));
1872
- return;
1873
- }
1874
- console.error(`${matches.length} \`${type}\` blocks in ${where} — give one an #id, or address its section:`);
1875
- for (const m of matches) {
1876
- console.log(`=== ${type}${m.id ? ` {#${m.id}}` : ""} L${m.span.start + 1}-${m.span.end}`);
1877
- }
1878
- }
1879
- // The single block of `type` in a document, or undefined when there is not
1880
- // exactly one (nested flow children included, matching the span scan's reach).
1881
- function onlyBlockOfType(blocks, type) {
2036
+ // Every block of `type` in document order, nested flow children included —
2037
+ // exactly the span scan's reach and order, so the k-th scan match and the k-th
2038
+ // model node are the same block. That correspondence is what lets an ANONYMOUS
2039
+ // block's `--json` find its node without an id to look it up by.
2040
+ function blocksOfType(blocks, type) {
1882
2041
  const hits = [];
1883
2042
  const walk = (list) => {
1884
2043
  for (const b of list) {
@@ -1891,65 +2050,139 @@ function onlyBlockOfType(blocks, type) {
1891
2050
  }
1892
2051
  };
1893
2052
  walk(blocks);
1894
- return hits.length === 1 ? hits[0] : undefined;
1895
- }
2053
+ return hits;
2054
+ }
2055
+ // A unit's index among the units of its own type, for the positional lookup above.
2056
+ function typeIndex(all, u) {
2057
+ return all.filter((a) => a.unit.type === u.type).findIndex((a) => a.unit === u);
2058
+ }
2059
+ // Resolve a NON-list selector to the units it matches, or fail with the reason.
2060
+ // `where` names the haystack for the error messages — a file for `geml get`, a
2061
+ // revision for `geml history get`'s tier 2. Shared by both so the one selector
2062
+ // grammar has one implementation: history's design §10.1 asks for exactly this,
2063
+ // and its §3.2 records what happened the last time a selector grammar was
2064
+ // written twice (the printed selectors stopped being readable back).
2065
+ function selectUnits(source, file, rawSel, where) {
2066
+ const sel = parseSelector(rawSel, (braces) => parseAttrs(braces).id);
2067
+ // Callers handle the empty selector themselves (list for `get`, usage error
2068
+ // for `set`); reaching here with one is a caller bug surfaced as usage.
2069
+ if (sel.form === "list")
2070
+ fail(`no selector given — run \`geml get ${where}\` to list addressable blocks`, 2);
2071
+ if (sel.form === "attr") {
2072
+ // §7: the wording says "not implemented yet", not "braces are meaningless" —
2073
+ // §2 declares attribute keys as part of the model, so implementing them
2074
+ // later fills in a declared slot rather than reversing this message.
2075
+ fail(`only \`#id\` is supported as a filter key today (got \`${sel.key}\`) — use \`=== ${sel.type}\` for every ${sel.type} block, or address one by \`#id\` / \`@<hex>\``, 2);
2076
+ }
2077
+ const all = addressedUnits(source);
2078
+ if (sel.form === "content") {
2079
+ const hit = matchContent(sel, all);
2080
+ if (!hit.ok) {
2081
+ if (hit.why === "wrong-type") {
2082
+ // §3.3: the type prefix is a CHECK. Ignoring a wrong one would make it
2083
+ // a decoration that is allowed to lie, and would silently accept a
2084
+ // hand-edited address.
2085
+ fail(`\`@${sel.hex}\` addresses a \`${hit.found}\` block, not \`${sel.type}\` — drop the type prefix to address it by content alone`, 1);
2086
+ }
2087
+ const suffix = sel.nth ? `~${sel.nth}` : "";
2088
+ fail(`no block matching \`@${sel.hex}${suffix}\` in ${where} — a content address goes stale when the block's content changes (that is the point: §3.2); run \`geml get ${where}\` for current addresses`, 1);
2089
+ }
2090
+ return { units: [hit.unit], all };
2091
+ }
2092
+ if (sel.form === "type") {
2093
+ const hits = matchType(sel.type, all);
2094
+ if (!hits.length)
2095
+ fail(`no \`${sel.type}\` block in ${where}${discoveryHint(where)}`, 1);
2096
+ return { units: hits, all };
2097
+ }
2098
+ // `#id` / bare id / a pasted `## Heading` line — resolveSelector needs a parse
2099
+ // to match heading TEXT, so it stays the one path that reaches the model.
2100
+ const id = resolveSelector(source, file, sel.raw);
2101
+ const unit = all.find((a) => a.unit.id === id)?.unit;
2102
+ // Bare `no block with id \`x\`` — the phrasing every caller of a missing id
2103
+ // has always seen, and which `set`'s own tests pin. `where` is appended only
2104
+ // when it is NOT the file the caller already named (a revision), so the
2105
+ // common case reads the same as before this selector grammar existed.
2106
+ if (!unit)
2107
+ fail(`no block with id \`${id}\`${where.startsWith("revision ") ? ` in ${where}` : ""}`, 1);
2108
+ return { units: [unit], all };
2109
+ }
2110
+ // The document-model node for one unit; a heading yields its SECTION envelope,
2111
+ // so --json covers the same content as the raw span. `kind:"section"` lets a
2112
+ // consumer branch — every other unit yields the single node (the model is flat).
2113
+ function unitNode(source, file, unit, all) {
2114
+ const doc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
2115
+ if (unit.id !== undefined) {
2116
+ const site = findBlockSite(doc.children, unit.id);
2117
+ if (!site)
2118
+ fail(`no block with id \`${unit.id}\``, 1);
2119
+ const block = site.siblings[site.index];
2120
+ if (block.kind !== "heading")
2121
+ return block;
2122
+ const end = sectionEndIndex(site.siblings, site.index);
2123
+ return { kind: "section", id: block.id, level: block.level, blocks: site.siblings.slice(site.index, end) };
2124
+ }
2125
+ const node = blocksOfType(doc.children, unit.type ?? "")[typeIndex(all, unit)];
2126
+ if (!node)
2127
+ fail(`could not locate the \`${unit.type}\` block in the document model`, 1);
2128
+ return node;
2129
+ }
2130
+ // stderr line for an N-match selector: content stays on stdout, so a redirect
2131
+ // captures document bytes only, and the caller still learns how many it got (§5).
2132
+ function reportMatches(type, units) {
2133
+ const at = units.map((u) => `L${u.span.start + 1}-${u.span.end}${u.id ? ` #${u.id}` : ""}`).join(" · ");
2134
+ console.error(`${units.length} \`${type}\` blocks (${at})`);
2135
+ }
2136
+ // `geml get <file.geml|-> [<selector>] [--head|--body] [--json]` — read the
2137
+ // document's addressable structure, or one/several blocks out of it.
2138
+ //
2139
+ // The selector is a FILTER (§2 of the get/set selector design): no selector
2140
+ // LISTS every addressable block with its shortest unique address; `#id` /
2141
+ // `## Heading` / `=== type@<hex>` name at most one; `=== type` matches 0..N.
2142
+ // Cardinality is uniform (§5): 0 → exit 1, 1 → the content, N → N contents in
2143
+ // document order with the count on stderr. `--head`/`--body` narrow to one part
2144
+ // of each match, and every flag combination that used to be half-honoured is
2145
+ // now a usage error (§7) — a discarded flag is a command that quietly did
2146
+ // something else.
1896
2147
  function runGet(args) {
1897
2148
  const json = args.includes("--json");
1898
2149
  const headOnly = args.includes("--head");
1899
- const [file, rawId] = positionals(args, []);
2150
+ const bodyOnly = args.includes("--body");
2151
+ const [file, rawSel] = positionals(args, []);
1900
2152
  if (!file)
1901
2153
  fail(SUBHELP.get);
1902
- // No id: list every addressable id — the document's "table of contents", so
1903
- // an agent can discover what `get #id` can target without pulling the model.
2154
+ if (headOnly && bodyOnly)
2155
+ fail("--head and --body are mutually exclusive", 2);
2156
+ if (json && (headOnly || bodyOnly)) {
2157
+ fail(`--json cannot be combined with ${headOnly ? "--head" : "--body"} — --json returns the model node, which has no sub-node for one part of a block`, 2);
2158
+ }
1904
2159
  // One read: stdin can only be consumed once, and the selector resolver needs
1905
2160
  // the same bytes the slice below works on.
1906
2161
  const source = readInput(file);
1907
- if (!rawId) {
2162
+ const where = file === "-" ? "stdin" : file;
2163
+ const sel = parseSelector(rawSel, (braces) => parseAttrs(braces).id);
2164
+ if (sel.form === "list") {
2165
+ // §5.1: nothing here to narrow, and ignoring the flag would make
2166
+ // `get f --head` print byte-for-byte what `get f` prints.
2167
+ if (headOnly || bodyOnly) {
2168
+ fail(`${headOnly ? "--head" : "--body"} names part of ONE block, so it needs a selector — run \`geml get ${where}\` to list what to address`, 2);
2169
+ }
1908
2170
  listIds(source, file, json);
1909
2171
  return;
1910
2172
  }
1911
- // A FENCE line as the selector (`=== meta`): the same "copy the line out of
1912
- // the document" move as a heading line, for the blocks that carry no id.
1913
- // A pasted fence that DOES declare an id defers to the id path below.
1914
- const fence = /^={3,}[ \t]*([A-Za-z][A-Za-z0-9_-]*)[ \t]*(\{.*\})?[ \t]*$/.exec(rawId.trim());
1915
- const fenceId = fence?.[2] ? parseAttrs(fence[2]).id : undefined;
1916
- if (fence && fenceId === undefined) {
1917
- getByType(source, file, fence[1], json, headOnly);
1918
- return;
1919
- }
1920
- const id = fenceId ?? resolveSelector(source, file, rawId);
2173
+ const { units, all } = selectUnits(source, file, rawSel, where);
1921
2174
  if (json) {
1922
- // The model node(s) same shapes `geml <file>` emits. Parsing is needed
1923
- // to resolve the tree (and nested-block ids), but only the target prints.
1924
- const doc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
1925
- const site = findBlockSite(doc.children, id);
1926
- if (!site)
1927
- fail(`no block with id \`${id}\``, 1);
1928
- const block = site.siblings[site.index];
1929
- // `--head` on a heading suppresses the section envelope (the lone heading
1930
- // node IS the head). On a block/footnote id there is nothing finer than
1931
- // the single node — the model has no sub-node for "just the fence line" —
1932
- // so --head refines only the RAW output there.
1933
- if (block.kind === "heading" && !headOnly) {
1934
- // A heading id addresses its SECTION, so `--json` covers the same
1935
- // content as the raw span: a self-describing envelope whose blocks[0]
1936
- // is the heading node followed by its siblings up to the boundary.
1937
- // `kind: "section"` lets a consumer branch — a block/footnote id still
1938
- // yields the single model node (the model itself stays flat).
1939
- const end = sectionEndIndex(site.siblings, site.index);
1940
- console.log(JSON.stringify({ kind: "section", id: block.id, level: block.level, blocks: site.siblings.slice(site.index, end) }, null, 2));
1941
- return;
1942
- }
1943
- console.log(JSON.stringify(block, null, 2));
2175
+ // §7: N matches yield N model nodes. The old `{kind:"blocks",
2176
+ // matches:[{lines}]}` coordinate envelope is gone it answered "where are
2177
+ // they" when the question is "what are they" (§9 change 2).
2178
+ const nodes = units.map((u) => unitNode(source, file, u, all));
2179
+ console.log(JSON.stringify(units.length === 1 ? nodes[0] : nodes, null, 2));
1944
2180
  return;
1945
2181
  }
1946
- // Raw: slice the source span byte-for-byte. No parse required, so `get` still
1947
- // returns the exact bytes even if the document has diagnostics elsewhere.
1948
- const found = blockSpans(source).get(id);
1949
- if (!found)
1950
- fail(`no block with id \`${id}\``, 1);
1951
- const span = headOnly ? narrowToHead(found) : found;
1952
- process.stdout.write(splitLines(source).slice(span.start, span.end).join(""));
2182
+ if (units.length > 1)
2183
+ reportMatches(units[0].type ?? "", units);
2184
+ for (const u of units)
2185
+ process.stdout.write(sliceUnit(source, u.span, headOnly, bodyOnly));
1953
2186
  }
1954
2187
  const NO_CONTENT = "no replacement content (use --in FILE or pipe it on stdin)";
1955
2188
  // `geml set <file.geml|-> #id [--head|--body] [--in F|F#src|-] [-o out]` —
@@ -1975,14 +2208,13 @@ function runSet(args) {
1975
2208
  const bodyOnly = args.includes("--body");
1976
2209
  if (headOnly && bodyOnly)
1977
2210
  fail("--head and --body are mutually exclusive", 2);
1978
- const [file, rawId] = positionals(args, ["-o", "--out", "--in"]);
2211
+ const [file, rawSel] = positionals(args, ["-o", "--out", "--in"]);
1979
2212
  if (!file)
1980
2213
  fail(SUBHELP.set);
1981
- // No id: there is no block to replace. Point the way to discovery, not a bare
1982
- // usage line — `geml get <file>` lists every id `set` can target.
1983
- if (!rawId)
1984
- fail(`no #id given — run 'geml get ${file === "-" ? "<file>" : file}' to list addressable ids`, 2);
1985
- const id = rawId.replace(/^#/, "");
2214
+ // No selector: there is no block to replace. Point the way to discovery, not a
2215
+ // bare usage line — `geml get <file>` lists every address `set` can target.
2216
+ if (!rawSel)
2217
+ fail(`no selector given — run 'geml get ${file === "-" ? "<file>" : file}' to list addressable blocks`, 2);
1986
2218
  // The raw channel is stdin — `--in` omitted or `--in -`; anything else sources
1987
2219
  // a block from a file. Document and content can't BOTH be stdin: reject that
1988
2220
  // up front, before consuming stdin, so the document read below is unambiguous.
@@ -1991,16 +2223,11 @@ function runSet(args) {
1991
2223
  fail("reading the document from stdin needs --in for the new content", 2);
1992
2224
  }
1993
2225
  const source = readInput(file);
2226
+ const target = resolveSetTarget(source, file, rawSel);
1994
2227
  if (bodyOnly) {
1995
- runSetBody(source, id, from, rawChannel, file, out);
2228
+ runSetBody(source, target, from, rawChannel, file, out);
1996
2229
  return;
1997
2230
  }
1998
- // default / --head: content is a whole block (default) or a bare head line.
1999
- // Does the target exist? Asked FIRST: the shape checks below name the id in
2000
- // their advice ("use --body to set the body of #far"), which reads as though the
2001
- // id were there. Whether the content is prose is the second question.
2002
- if (!blockSpans(source).has(id))
2003
- fail(`no block with id \`${id}\``, 1);
2004
2231
  let content;
2005
2232
  if (rawChannel) {
2006
2233
  content = readInput("-");
@@ -2014,39 +2241,71 @@ function runSet(args) {
2014
2241
  if (shape === "empty")
2015
2242
  fail(NO_CONTENT, 1);
2016
2243
  if (shape === "prose")
2017
- fail(`content is prose, not a block — use --body to set the body of #${id}`, 1);
2244
+ fail(`content is prose, not a block — use --body to set the body of ${target.label}`, 1);
2018
2245
  if (shape === "multi")
2019
2246
  fail("set replaces ONE block, but the content has multiple blocks (use add)", 1);
2020
2247
  }
2021
2248
  }
2022
2249
  else {
2023
- content = extractBlock(from, id, headOnly ? "head" : "whole");
2024
- }
2025
- const normalized = normalizeBlockId(content, id);
2026
- const updated = spliceBlock(source, id, normalized, file, headOnly);
2250
+ content = extractBlock(from, target.unit.id ?? "", headOnly ? "head" : "whole");
2251
+ }
2252
+ // §5.2: `@<hex>` is not an id, so "normalize the content's id to the target's"
2253
+ // has no subject — the content is used verbatim, and an id it brings that
2254
+ // collides is caught by the splice guard like any other. An id target keeps
2255
+ // normalizing: naming an id on the command line IS the instruction that the
2256
+ // result carries that id (block-mutation design §4.0).
2257
+ const replacement = target.unit.id !== undefined ? normalizeBlockId(content, target.unit.id) : content;
2258
+ const updated = spliceSpan(source, target.unit.span, replacement, file, headOnly, false, target.unit.id);
2027
2259
  resolveOutTarget(file, out).write(updated);
2260
+ reportNewAddress(updated, target);
2261
+ }
2262
+ // Resolve a selector to the ONE unit `set` will overwrite. `get` may answer with
2263
+ // N blocks; `set` may not — §5: with N targets there is no single id to
2264
+ // normalize the content to, so multi-target `set` is undefined, not merely
2265
+ // risky. Refused with exit 2 (a usage error), not exit 1.
2266
+ function resolveSetTarget(source, file, rawSel) {
2267
+ const where = file === "-" ? "<file>" : file;
2268
+ const sel = parseSelector(rawSel, (braces) => parseAttrs(braces).id);
2269
+ if (sel.form === "list")
2270
+ fail(`no selector given — run 'geml get ${where}' to list addressable blocks`, 2);
2271
+ const { units, all } = selectUnits(source, file, rawSel, where);
2272
+ if (units.length > 1) {
2273
+ // §5: with N targets there is no single id to normalize the content to, so
2274
+ // multi-target `set` is UNDEFINED, not merely risky. The addresses are
2275
+ // printed because they ARE the fix — each is unique and pastes straight
2276
+ // back into this same command (§6.2).
2277
+ const opts = units.map((u) => {
2278
+ const a = all.find((x) => x.unit === u);
2279
+ return ` ${shortestAddress(a, all)} L${u.span.start + 1}-${u.span.end}`;
2280
+ }).join("\n");
2281
+ fail(`\`${rawSel.trim()}\` matches ${units.length} blocks — set writes ONE; address it uniquely:\n${opts}`, 2);
2282
+ }
2283
+ const unit = units[0];
2284
+ const label = unit.id !== undefined && sel.form === "id" ? `#${unit.id}` : `\`${rawSel.trim()}\``;
2285
+ return { unit, label, byContent: sel.form === "content" };
2286
+ }
2287
+ // §5.3: writing through a content address CHANGES it, so print the new one —
2288
+ // otherwise a script editing the same block twice has to re-list in between.
2289
+ // stderr, because stdout may be the document itself (`-o -`).
2290
+ function reportNewAddress(updated, target) {
2291
+ if (!target.byContent)
2292
+ return;
2293
+ const after = addressedUnits(updated).find((a) => a.unit.span.start === target.unit.span.start);
2294
+ if (after)
2295
+ console.error(`new address: ${shortestAddress(after, addressedUnits(updated))}`);
2028
2296
  }
2029
2297
  // `--body`: swap ONLY the target block's body, keeping its head (and #id) and,
2030
2298
  // for a typed block, its close fence. Assembles head + new body + close and
2031
2299
  // reuses the guarded spliceBlock — the head carries #id, so the id survives
2032
2300
  // with no normalization needed.
2033
- function runSetBody(source, id, from, rawChannel, file, out) {
2034
- const found = blockSpans(source).get(id);
2035
- if (!found)
2036
- fail(`no block with id \`${id}\``, 1);
2301
+ function runSetBody(source, target, from, rawChannel, file, out) {
2302
+ const found = target.unit.span;
2037
2303
  const lines = splitLines(source);
2038
2304
  const headLine = lines[found.start] ?? "";
2039
- const headText = stripEol(headLine);
2040
- // A typed block keeps its closing fence; a heading section has none.
2041
- let closeLine = null;
2042
- const open = FENCE_OPEN.exec(headText);
2043
- if (open) {
2044
- const lastText = stripEol(lines[found.end - 1] ?? "").replace(/[ \t]+$/, "");
2045
- const bid = open[3] ? parseAttrs(open[3]).id : undefined;
2046
- const labeled = bid !== undefined && new RegExp(`^={3,}[ \\t]+#${reLit(bid)}[ \\t]*$`).test(lastText);
2047
- if (isCloseFence(lastText, open[1].length) || labeled)
2048
- closeLine = lines[found.end - 1] ?? "";
2049
- }
2305
+ // A typed block keeps its closing fence; a heading section has none. Decided
2306
+ // by the same helper `get --body` uses, so the two agree on the span and the
2307
+ // §4 round-trip invariant holds.
2308
+ const closeLine = closeFenceLine(lines, found);
2050
2309
  let body;
2051
2310
  if (rawChannel) {
2052
2311
  body = readInput("-");
@@ -2054,7 +2313,7 @@ function runSetBody(source, id, from, rawChannel, file, out) {
2054
2313
  fail(NO_CONTENT, 1);
2055
2314
  }
2056
2315
  else {
2057
- body = extractBlock(from, id, "body");
2316
+ body = extractBlock(from, target.unit.id ?? "", "body");
2058
2317
  }
2059
2318
  let head = headLine;
2060
2319
  if (head !== "" && !/(\r\n|\r|\n)$/.test(head))
@@ -2067,8 +2326,9 @@ function runSetBody(source, id, from, rawChannel, file, out) {
2067
2326
  // block-count invariant so a `===` fence in the raw body can't close it early
2068
2327
  // and inject siblings (SEC F2). A heading section body has no close fence and
2069
2328
  // may legitimately contain blocks, so it is not count-guarded.
2070
- const updated = spliceBlock(source, id, replacement, file, false, closeLine !== null);
2329
+ const updated = spliceSpan(source, found, replacement, file, false, closeLine !== null, target.unit.id);
2071
2330
  resolveOutTarget(file, out).write(updated);
2331
+ reportNewAddress(updated, target);
2072
2332
  }
2073
2333
  // `geml add <file|-> (--append | --before #x | --after #x) [--in F|F#src|-] [-o]`
2074
2334
  // — insert a GEML fragment (1+ blocks and/or prose) at a position. Unlike `set`,
@@ -2369,6 +2629,13 @@ function spliceBlock(source, id, replacement, file, headOnly = false, guardCount
2369
2629
  const found = blockSpans(source).get(id);
2370
2630
  if (!found)
2371
2631
  fail(`no block with id \`${id}\``, 1);
2632
+ return spliceSpan(source, found, replacement, file, headOnly, guardCount, id);
2633
+ }
2634
+ // The same guarded splice addressed by SPAN rather than by id, because an
2635
+ // anonymous block (addressed by `@<hex>`) has no id to look one up with. `id`
2636
+ // is the survival guard's subject and is simply absent for those: every OTHER
2637
+ // pre-existing id must still survive, which the `dropped` check below covers.
2638
+ function spliceSpan(source, found, replacement, file, headOnly = false, guardCount = false, id) {
2372
2639
  const beforeDoc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
2373
2640
  const beforeIds = beforeDoc.ids;
2374
2641
  // Keep the bytes before and after the target span exactly; give the new block
@@ -2400,7 +2667,7 @@ function spliceBlock(source, id, replacement, file, headOnly = false, guardCount
2400
2667
  refuseBroken(`replacement would break the document: ${first.message} (line ${first.line}); not written`, errs);
2401
2668
  }
2402
2669
  const now = new Set(reparsed.ids);
2403
- if (!now.has(id))
2670
+ if (id !== undefined && !now.has(id))
2404
2671
  fail(`replacement removes id \`${id}\`; not written`, 1);
2405
2672
  const dropped = beforeIds.find((x) => x !== id && !now.has(x));
2406
2673
  if (dropped !== undefined) {
@@ -2415,7 +2682,7 @@ function spliceBlock(source, id, replacement, file, headOnly = false, guardCount
2415
2682
  // (Not enforced for heading sections / whole-block set, whose replacement may
2416
2683
  // legitimately span several top-level blocks.)
2417
2684
  if (guardCount && reparsed.children.length !== beforeDoc.children.length) {
2418
- fail(`replacement changes the block count (a fence in the body closed #${id} early and injected sibling block(s)?); not written`, 1);
2685
+ fail(`replacement changes the block count (a fence in the body closed ${id !== undefined ? `#${id}` : "the target"} early and injected sibling block(s)?); not written`, 1);
2419
2686
  }
2420
2687
  return updated;
2421
2688
  }