@geml/geml 1.5.1 → 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";
@@ -1077,9 +1078,12 @@ function sectionEnd(lines, i, level) {
1077
1078
  // `get`/`set` operate on the one the parser actually registered). `base` is the
1078
1079
  // absolute line offset of this slice within the whole document.
1079
1080
  function collectSpans(lines, base, out, ctx, depth = 0,
1080
- // Optional second index: every typed block by TYPE, id-bearing or not, so a
1081
- // block the author never named is still addressable (`=== meta`).
1082
- 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) {
1083
1087
  const add = (id, start, end) => {
1084
1088
  if (!out.has(id))
1085
1089
  out.set(id, { start, end });
@@ -1094,6 +1098,7 @@ types) {
1094
1098
  const fndef = /^\[\^([^\]]+)\]:[ \t]?(.*)$/.exec(line);
1095
1099
  if (fndef) {
1096
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() });
1097
1102
  i++;
1098
1103
  continue;
1099
1104
  }
@@ -1108,16 +1113,12 @@ types) {
1108
1113
  const { end, closed } = fenceClose(lines, i, open);
1109
1114
  if (id !== undefined)
1110
1115
  add(id, base + i, base + end);
1111
- if (types) {
1112
- const list = types.get(type) ?? [];
1113
- list.push({ span: { start: base + i, end: base + end }, id });
1114
- types.set(type, list);
1115
- }
1116
+ units?.push({ span: { start: base + i, end: base + end }, kind: "block", type, ...(id !== undefined ? { id } : {}) });
1116
1117
  // Only a flow body is scanned for nested blocks (raw/data bodies are
1117
1118
  // opaque), so an id inside a `code` body is *not* addressable — exactly
1118
1119
  // the parser's contract.
1119
1120
  if ((REGISTRY[type] ?? "raw") === "flow" && depth < MAX_NESTING) {
1120
- 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);
1121
1122
  }
1122
1123
  i = end;
1123
1124
  continue;
@@ -1128,7 +1129,10 @@ types) {
1128
1129
  // still advances one line at a time so every nested id inside the
1129
1130
  // section registers its own span — spans intentionally OVERLAP: #sec
1130
1131
  // contains #code, and each remains addressable on its own.
1131
- 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] });
1132
1136
  i++;
1133
1137
  continue;
1134
1138
  }
@@ -1146,12 +1150,23 @@ export function blockSpans(source) {
1146
1150
  collectSpans(lines, 0, out, ctx);
1147
1151
  return out;
1148
1152
  }
1149
- 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) {
1150
1163
  const lines = normalizeSource(source).split("\n");
1151
1164
  const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines) };
1152
- const types = new Map();
1153
- collectSpans(lines, 0, new Map(), ctx, 0, types);
1154
- 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"));
1155
1170
  }
1156
1171
  // Split into physical lines while *keeping* each line's terminator, so
1157
1172
  // join("") is byte-exact and slicing by span never rewrites line endings.
@@ -1186,6 +1201,32 @@ function toNewline(text, nl) {
1186
1201
  function narrowToHead(span) {
1187
1202
  return { start: span.start, end: span.start + 1 };
1188
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
+ }
1189
1230
  // Depth-first search for the document-model node carrying `id`, descending into
1190
1231
  // flow-block children (and list-item children) so a nested id is found too.
1191
1232
  // Returns the containing sibling array and index, not just the node: the model
@@ -1238,13 +1279,9 @@ function flag(args, name) {
1238
1279
  function historyPathFor(geml) {
1239
1280
  return geml.replace(/\.geml$/, "") + ".gemlhistory";
1240
1281
  }
1241
- function parseStamp(s) {
1242
- const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(s);
1243
- if (!m)
1244
- throw new Error(`bad --at timestamp: ${s} (want YYYYMMDDTHHMMSSZ)`);
1245
- const [, y, mo, d, h, mi, se] = m;
1246
- return new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +se));
1247
- }
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.)
1248
1285
  const VERSION = "1.0"; // GEML spec version this CLI targets
1249
1286
  // The published version, read from package.json rather than restated here.
1250
1287
  // "Keep in sync with package.json" was a comment, and comments do not run: this
@@ -1272,95 +1309,104 @@ export const PARSER_VERSION = (() => {
1272
1309
  }
1273
1310
  return "0.0.0";
1274
1311
  })();
1275
- const USAGE = `geml — GEML reference CLI
1276
-
1277
- Usage:
1278
- geml <file.geml|-> [--to <fmt>] [--from <fmt>] [--root d] [-o out] transform a document (default: --to json)
1279
- (--root widens cross-doc resolution to dir d, as on check — an
1280
- === embed whose target sits above the file's own directory
1281
- needs it, or it renders unresolved)
1282
- --to <output>: json | html | md | geml
1283
- --to md -> Markdown (lossy)
1284
- --to html -> self-contained HTML
1285
- --to geml -> canonical re-format
1286
- --to json -> document-model JSON (default)
1287
- --from <input>: geml | md | json (overrides extension; html is output-only)
1288
- geml notes.md -> GEML (md inferred from extension)
1289
- geml model.json --to geml -> GEML (round-trips a prior --to json)
1290
- geml - --from md read Markdown on stdin
1291
- geml get <file.geml|-> [#id] [--json] [--head] with #id: print that block
1292
- (a heading id = its whole section; --head = head line;
1293
- --json = model node). Without #id: list all addressable
1294
- ids (--json = array).
1295
- geml set <file.geml|-> #id [--head|--body] [--in f[#src]|-] [-o f] replace ONE block by id
1296
- (--in F takes F's block #id, F#src takes #src, else stdin raw;
1297
- default = whole block · --head = head line · --body = body)
1298
- geml add <file.geml|-> (--append | --before #id | --after #id) [--in f[#src]|-] [-o f] insert a fragment
1299
- (1+ blocks and/or prose; content keeps its own ids, a clash is refused)
1300
- geml delete <file.geml|-> #id [#id2 …] [-o f] remove one or more blocks
1301
- (a missing id is skipped; a dangling reference is a warning, not a refusal)
1302
- geml rename <file.geml|-> #old #new [-o f] rename an id and every reference to it (id-boundary safe)
1303
- geml revert <file.geml> #id [--rev <sel>] [--head] undo one block to a past revision (splice / resurrect / remove)
1304
- (sel: 0 | -N | id-prefix | changed; default -1)
1305
- geml check <file.geml|-> [--root d] [--json] validate only: diagnostics + exit code
1306
- (--root widens cross-doc refs to dir d, e.g. the repo root)
1307
- geml history <commit|verify|show|restore|log> <file.geml> [...] .gemlhistory version sidecar
1308
- geml codemap <build|verify|render|serve|refresh|find> [...] code-graph toolkit (alias: codegraph)
1309
- geml mcp --root <dir> [--graph <dir>] [--no-history] serve documents (and the code graph) over MCP (stdio)
1310
- (10 tools, each geml_ + its CLI verb: list/get/check/history/to +
1311
- set/add/delete/rename/revert; every write is validated before it
1312
- reaches disk. A code graph under --root adds four read-only
1313
- geml_codemap_* tools to the same server)
1314
- geml --help | --version [--json]
1315
-
1316
- Use '-' as the file to read from stdin.
1317
- Mutations (set/add/delete/rename) write the whole updated document in place for a
1318
- file, or to stdout for '-' input; -o redirects it (-o - = stdout).
1319
- Exit codes:
1320
- 0 ok
1321
- 1 document/operation error
1322
- 2 command usage error.
1312
+ const USAGE = `geml — GEML reference CLI
1313
+
1314
+ Usage:
1315
+ geml <file.geml|-> [--to <fmt>] [--from <fmt>] [--root d] [-o out] transform a document (default: --to json)
1316
+ (--root widens cross-doc resolution to dir d, as on check — an
1317
+ === embed whose target sits above the file's own directory
1318
+ needs it, or it renders unresolved)
1319
+ --to <output>: json | html | md | geml
1320
+ --to md -> Markdown (lossy)
1321
+ --to html -> self-contained HTML
1322
+ --to geml -> canonical re-format
1323
+ --to json -> document-model JSON (default)
1324
+ --from <input>: geml | md | json (overrides extension; html is output-only)
1325
+ geml notes.md -> GEML (md inferred from extension)
1326
+ geml model.json --to geml -> GEML (round-trips a prior --to json)
1327
+ geml - --from md read Markdown on stdin
1328
+ geml get <file.geml|-> [#id] [--json] [--head] with #id: print that block
1329
+ (a heading id = its whole section; --head = head line;
1330
+ --json = model node). Without #id: list all addressable
1331
+ ids (--json = array).
1332
+ geml set <file.geml|-> #id [--head|--body] [--in f[#src]|-] [-o f] replace ONE block by id
1333
+ (--in F takes F's block #id, F#src takes #src, else stdin raw;
1334
+ default = whole block · --head = head line · --body = body)
1335
+ geml add <file.geml|-> (--append | --before #id | --after #id) [--in f[#src]|-] [-o f] insert a fragment
1336
+ (1+ blocks and/or prose; content keeps its own ids, a clash is refused)
1337
+ geml delete <file.geml|-> #id [#id2 …] [-o f] remove one or more blocks
1338
+ (a missing id is skipped; a dangling reference is a warning, not a refusal)
1339
+ geml rename <file.geml|-> #old #new [-o f] rename an id and every reference to it (id-boundary safe)
1340
+ geml revert <file.geml> #id [--rev <sel>] [--head] undo one block to a past revision (splice / resurrect / remove)
1341
+ (sel: 0 | -N | id-prefix | changed; default -1)
1342
+ geml check <file.geml|-> [--root d] [--json] validate only: diagnostics + exit code
1343
+ (--root widens cross-doc refs to dir d, e.g. the repo root)
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)
1348
+ geml codemap <build|verify|render|serve|refresh|find> [...] code-graph toolkit (alias: codegraph)
1349
+ geml mcp --root <dir> [--graph <dir>] [--no-history] serve documents (and the code graph) over MCP (stdio)
1350
+ (10 tools, each geml_ + its CLI command path: list/get/check/history/to +
1351
+ set/add/delete/rename/revert; every write is validated before it
1352
+ reaches disk. A code graph under --root adds four read-only
1353
+ geml_codemap_* tools to the same server)
1354
+ geml --help | --version [--json]
1355
+
1356
+ Use '-' as the file to read from stdin.
1357
+ Mutations (set/add/delete/rename) write the whole updated document in place for a
1358
+ file, or to stdout for '-' input; -o redirects it (-o - = stdout).
1359
+ Exit codes:
1360
+ 0 ok
1361
+ 1 document/operation error
1362
+ 2 command usage error.
1323
1363
  `;
1324
1364
  // One-line usage for each subcommand — the single source for both the error
1325
1365
  // shown on misuse and the `<cmd> --help` text.
1326
1366
  const SUBHELP = {
1327
- 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)",
1328
- 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)",
1329
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)",
1330
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)",
1331
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)",
1332
1372
  check: "usage: geml check <file.geml|-> [--root <dir>] [--json] (--root: resolve cross-doc refs within <dir> instead of the file's own directory)",
1333
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)",
1334
- history: "usage: geml history <commit|verify|show|restore|log> <file.geml> [...]",
1335
- 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)
1336
- 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]]
1337
- geml codemap verify [dir] geml check + profile reference checks
1338
- geml codemap render [dir] every doc -> sibling .html (open index.html from disk)
1339
- 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
1340
- geml codemap refresh [dir] [--force] [--commit] [--background|--hook] re-run the recorded build recipe (_index/refresh.json); --commit lands it as its own commit
1341
- geml codemap find <name> [dir] locate a symbol by substring name -> doc#id + src (stdout, no browser)
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.)`,
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)
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]]
1382
+ geml codemap verify [dir] geml check + profile reference checks
1383
+ geml codemap render [dir] every doc -> sibling .html (open index.html from disk)
1384
+ 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
1385
+ geml codemap refresh [dir] [--force] [--commit] [--background|--hook] re-run the recorded build recipe (_index/refresh.json); --commit lands it as its own commit
1386
+ geml codemap find <name> [dir] locate a symbol by substring name -> doc#id + src (stdout, no browser)
1342
1387
  (<dir> for verify/render/serve/refresh/find defaults to ./.geml-code-graph; codegraph and code-graph are accepted as aliases of codemap)`,
1343
- mcp: `usage: geml mcp --root <dir> [--graph <dir>] [--no-history]
1344
-
1345
- Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0).
1346
- Every tool is geml_ + its CLI verb, so the terminal and the assistant share
1347
- one vocabulary.
1348
- Ten tools: geml_list · geml_get · geml_check · geml_history · geml_to
1349
- geml_set · geml_add · geml_delete · geml_rename · geml_revert
1350
- With a code graph under --root, four more (read-only), so one client entry
1351
- covers both: geml_codemap_search · geml_codemap_callchain
1352
- geml_codemap_list · geml_codemap_node
1353
-
1354
- --root <dir> REQUIRED. Root holding the .geml documents. Every path a
1355
- client names is confined here; a client cannot widen it.
1356
- --graph <dir> Code-graph directory, inside --root. Defaults to
1357
- <root>/.geml-code-graph when it holds an index.geml; with
1358
- no graph the four graph tools are not served at all.
1359
- --no-history Skip the .gemlhistory commit taken before each write
1360
- (default: commit, so geml_revert always has a revision to
1361
- undo to).
1362
-
1363
- Register with a client:
1388
+ mcp: `usage: geml mcp --root <dir> [--graph <dir>] [--no-history]
1389
+
1390
+ Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0).
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.
1394
+ Ten tools: geml_list · geml_get · geml_check · geml_history · geml_to
1395
+ geml_set · geml_add · geml_delete · geml_rename · geml_revert
1396
+ With a code graph under --root, four more (read-only), so one client entry
1397
+ covers both: geml_codemap_search · geml_codemap_callchain
1398
+ geml_codemap_list · geml_codemap_node
1399
+
1400
+ --root <dir> REQUIRED. Root holding the .geml documents. Every path a
1401
+ client names is confined here; a client cannot widen it.
1402
+ --graph <dir> Code-graph directory, inside --root. Defaults to
1403
+ <root>/.geml-code-graph when it holds an index.geml; with
1404
+ no graph the four graph tools are not served at all.
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).
1408
+
1409
+ Register with a client:
1364
1410
  claude mcp add geml -- geml mcp --root /abs/path/to/repo`,
1365
1411
  };
1366
1412
  // Set from argv at dispatch time; when true, errors are emitted as a JSON
@@ -1510,23 +1556,144 @@ function historyError(e, file, historyPath) {
1510
1556
  }
1511
1557
  return err?.message ?? String(e);
1512
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
+ }
1513
1593
  function runHistory(args) {
1514
- const sub = args[0];
1515
- const file = args[1];
1594
+ const [sub, file, rev, ...extra] = historyPositionals(args);
1516
1595
  if (!sub || !file)
1517
1596
  fail(SUBHELP.history);
1597
+ if (RETIRED_HISTORY[sub])
1598
+ fail(RETIRED_HISTORY[sub]);
1518
1599
  const historyPath = flag(args, "--history") ?? historyPathFor(file);
1600
+ const json = args.includes("--json");
1519
1601
  try {
1520
- if (sub === "commit") {
1521
- const at = flag(args, "--at");
1522
- 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({
1523
1623
  gemlPath: file,
1524
1624
  historyPath,
1525
1625
  summary: flag(args, "-m") ?? flag(args, "--message") ?? "",
1526
- author: flag(args, "--author"),
1527
- at: at ? parseStamp(at) : undefined,
1528
1626
  });
1529
- 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}`);
1530
1697
  }
1531
1698
  else if (sub === "verify") {
1532
1699
  const res = verify(historyPath, file);
@@ -1538,27 +1705,6 @@ function runHistory(args) {
1538
1705
  if (!res.ok)
1539
1706
  process.exit(1);
1540
1707
  }
1541
- else if (sub === "show") {
1542
- const rev = args[2];
1543
- if (!rev)
1544
- fail("usage: geml history show <file.geml> <revision>");
1545
- process.stdout.write(restore({ historyPath, gemlPath: file, revision: rev }));
1546
- }
1547
- else if (sub === "restore") {
1548
- const rev = args[2];
1549
- if (!rev)
1550
- fail("usage: geml history restore <file.geml> <revision> [--force]");
1551
- restore({ historyPath, gemlPath: file, revision: rev, write: true, force: args.includes("--force") });
1552
- console.log(`restored ${file} to ${rev}`);
1553
- }
1554
- else if (sub === "log") {
1555
- // Newest-first, with the `--rev` selector for each row in the first column
1556
- // (`0` for the tip, then `-1`, `-2`, …) so the output is copy-paste.
1557
- for (const r of listRevisions(historyPath)) {
1558
- const sel = r.current ? "0" : `-${r.offset}`;
1559
- console.log(`${sel.padEnd(7)} ${r.id} ${r.author ?? "-"} ${r.summary ?? ""}`.replace(/\s+$/, ""));
1560
- }
1561
- }
1562
1708
  else {
1563
1709
  fail(`unknown history subcommand: ${sub}. Run 'geml --help'.`);
1564
1710
  }
@@ -1822,39 +1968,55 @@ function resolveSelector(source, file, raw) {
1822
1968
  // resolves against: typed blocks and headings. A `[^id]` reference names one
1823
1969
  // of those (§5.2); the `[^id]: text` definition line was withdrawn.
1824
1970
  function listIds(source, file, json) {
1971
+ const where = file === "-" ? "stdin" : file;
1972
+ const all = addressedUnits(source);
1825
1973
  const doc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
1826
- const rows = doc.ids.map((id) => {
1827
- const site = findBlockSite(doc.children, id);
1828
- const b = site?.siblings[site.index];
1829
- if (b?.kind === "heading")
1830
- return { id, kind: "heading", level: b.level, text: b.text };
1831
- if (b?.kind === "block") {
1832
- const row = { id, kind: b.type };
1833
- // `.footnote` is authored, not synthesized (the `[^id]: text` definition
1834
- // line was withdrawn) but it still marks a block meant as a footnote.
1835
- if (b.classes.includes("footnote"))
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"))
1836
1998
  row.footnote = true;
1837
- return row;
1838
1999
  }
1839
- return { id, kind: b?.kind ?? "unknown" };
2000
+ return row;
1840
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.
1841
2005
  if (json) {
1842
2006
  console.log(JSON.stringify(rows, null, 2));
1843
2007
  return;
1844
2008
  }
1845
2009
  if (rows.length === 0) {
1846
- console.error(`no addressable ids in ${file === "-" ? "stdin" : file}`);
2010
+ console.error(`no addressable blocks in ${where}`);
1847
2011
  return;
1848
2012
  }
1849
- // Align the id and kind columns; append a heading's level+text or a footnote flag.
1850
- const idW = Math.max(...rows.map((r) => r.id.length + 1));
2013
+ const addrW = Math.max(...rows.map((r) => r.address.length));
1851
2014
  const kindW = Math.max(...rows.map((r) => r.kind.length));
1852
2015
  for (const r of rows) {
1853
- let line = `#${r.id}`.padEnd(idW + 1) + " " + r.kind.padEnd(kindW);
1854
- if (r.kind === "heading")
1855
- line += ` h${r.level} ${r.text}`;
1856
- else if (r.footnote)
1857
- 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" : "");
1858
2020
  console.log(line.replace(/\s+$/, ""));
1859
2021
  }
1860
2022
  }
@@ -1871,42 +2033,11 @@ function listIds(source, file, json) {
1871
2033
  // guessed between, so a document with three notes answers "which one" instead
1872
2034
  // of failing. The uniqueness that makes `=== meta` work is checked here, at
1873
2035
  // resolve time — nothing in the format has to promise a document holds only one.
1874
- function getByType(source, file, type, json, headOnly) {
1875
- const where = file === "-" ? "stdin" : file;
1876
- const matches = blockTypeSpans(source).get(type) ?? [];
1877
- if (!matches.length) {
1878
- fail(`no \`${type}\` block in ${where} — run \`geml get ${where}\` to list every addressable id`, 1);
1879
- }
1880
- if (matches.length === 1) {
1881
- const m = matches[0];
1882
- if (json) {
1883
- // The ONLY block of its type: locating it in the model needs no index, so
1884
- // --json can still answer with the parsed node (meta's key/values, a
1885
- // table's model) rather than a mere location.
1886
- const node = onlyBlockOfType(parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) }).children, type);
1887
- if (node) {
1888
- console.log(JSON.stringify(node, null, 2));
1889
- return;
1890
- }
1891
- }
1892
- const span = headOnly ? narrowToHead(m.span) : m.span;
1893
- process.stdout.write(splitLines(source).slice(span.start, span.end).join(""));
1894
- return;
1895
- }
1896
- // Several: report WHERE they are (data on stdout, the explanation on stderr),
1897
- // so the caller can name one — by adding an #id, or via its section.
1898
- if (json) {
1899
- 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));
1900
- return;
1901
- }
1902
- console.error(`${matches.length} \`${type}\` blocks in ${where} — give one an #id, or address its section:`);
1903
- for (const m of matches) {
1904
- console.log(`=== ${type}${m.id ? ` {#${m.id}}` : ""} L${m.span.start + 1}-${m.span.end}`);
1905
- }
1906
- }
1907
- // The single block of `type` in a document, or undefined when there is not
1908
- // exactly one (nested flow children included, matching the span scan's reach).
1909
- 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) {
1910
2041
  const hits = [];
1911
2042
  const walk = (list) => {
1912
2043
  for (const b of list) {
@@ -1919,65 +2050,139 @@ function onlyBlockOfType(blocks, type) {
1919
2050
  }
1920
2051
  };
1921
2052
  walk(blocks);
1922
- return hits.length === 1 ? hits[0] : undefined;
1923
- }
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.
1924
2147
  function runGet(args) {
1925
2148
  const json = args.includes("--json");
1926
2149
  const headOnly = args.includes("--head");
1927
- const [file, rawId] = positionals(args, []);
2150
+ const bodyOnly = args.includes("--body");
2151
+ const [file, rawSel] = positionals(args, []);
1928
2152
  if (!file)
1929
2153
  fail(SUBHELP.get);
1930
- // No id: list every addressable id — the document's "table of contents", so
1931
- // 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
+ }
1932
2159
  // One read: stdin can only be consumed once, and the selector resolver needs
1933
2160
  // the same bytes the slice below works on.
1934
2161
  const source = readInput(file);
1935
- 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
+ }
1936
2170
  listIds(source, file, json);
1937
2171
  return;
1938
2172
  }
1939
- // A FENCE line as the selector (`=== meta`): the same "copy the line out of
1940
- // the document" move as a heading line, for the blocks that carry no id.
1941
- // A pasted fence that DOES declare an id defers to the id path below.
1942
- const fence = /^={3,}[ \t]*([A-Za-z][A-Za-z0-9_-]*)[ \t]*(\{.*\})?[ \t]*$/.exec(rawId.trim());
1943
- const fenceId = fence?.[2] ? parseAttrs(fence[2]).id : undefined;
1944
- if (fence && fenceId === undefined) {
1945
- getByType(source, file, fence[1], json, headOnly);
1946
- return;
1947
- }
1948
- const id = fenceId ?? resolveSelector(source, file, rawId);
2173
+ const { units, all } = selectUnits(source, file, rawSel, where);
1949
2174
  if (json) {
1950
- // The model node(s) same shapes `geml <file>` emits. Parsing is needed
1951
- // to resolve the tree (and nested-block ids), but only the target prints.
1952
- const doc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
1953
- const site = findBlockSite(doc.children, id);
1954
- if (!site)
1955
- fail(`no block with id \`${id}\``, 1);
1956
- const block = site.siblings[site.index];
1957
- // `--head` on a heading suppresses the section envelope (the lone heading
1958
- // node IS the head). On a block/footnote id there is nothing finer than
1959
- // the single node — the model has no sub-node for "just the fence line" —
1960
- // so --head refines only the RAW output there.
1961
- if (block.kind === "heading" && !headOnly) {
1962
- // A heading id addresses its SECTION, so `--json` covers the same
1963
- // content as the raw span: a self-describing envelope whose blocks[0]
1964
- // is the heading node followed by its siblings up to the boundary.
1965
- // `kind: "section"` lets a consumer branch — a block/footnote id still
1966
- // yields the single model node (the model itself stays flat).
1967
- const end = sectionEndIndex(site.siblings, site.index);
1968
- console.log(JSON.stringify({ kind: "section", id: block.id, level: block.level, blocks: site.siblings.slice(site.index, end) }, null, 2));
1969
- return;
1970
- }
1971
- 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));
1972
2180
  return;
1973
2181
  }
1974
- // Raw: slice the source span byte-for-byte. No parse required, so `get` still
1975
- // returns the exact bytes even if the document has diagnostics elsewhere.
1976
- const found = blockSpans(source).get(id);
1977
- if (!found)
1978
- fail(`no block with id \`${id}\``, 1);
1979
- const span = headOnly ? narrowToHead(found) : found;
1980
- 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));
1981
2186
  }
1982
2187
  const NO_CONTENT = "no replacement content (use --in FILE or pipe it on stdin)";
1983
2188
  // `geml set <file.geml|-> #id [--head|--body] [--in F|F#src|-] [-o out]` —
@@ -2003,14 +2208,13 @@ function runSet(args) {
2003
2208
  const bodyOnly = args.includes("--body");
2004
2209
  if (headOnly && bodyOnly)
2005
2210
  fail("--head and --body are mutually exclusive", 2);
2006
- const [file, rawId] = positionals(args, ["-o", "--out", "--in"]);
2211
+ const [file, rawSel] = positionals(args, ["-o", "--out", "--in"]);
2007
2212
  if (!file)
2008
2213
  fail(SUBHELP.set);
2009
- // No id: there is no block to replace. Point the way to discovery, not a bare
2010
- // usage line — `geml get <file>` lists every id `set` can target.
2011
- if (!rawId)
2012
- fail(`no #id given — run 'geml get ${file === "-" ? "<file>" : file}' to list addressable ids`, 2);
2013
- 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);
2014
2218
  // The raw channel is stdin — `--in` omitted or `--in -`; anything else sources
2015
2219
  // a block from a file. Document and content can't BOTH be stdin: reject that
2016
2220
  // up front, before consuming stdin, so the document read below is unambiguous.
@@ -2019,16 +2223,11 @@ function runSet(args) {
2019
2223
  fail("reading the document from stdin needs --in for the new content", 2);
2020
2224
  }
2021
2225
  const source = readInput(file);
2226
+ const target = resolveSetTarget(source, file, rawSel);
2022
2227
  if (bodyOnly) {
2023
- runSetBody(source, id, from, rawChannel, file, out);
2228
+ runSetBody(source, target, from, rawChannel, file, out);
2024
2229
  return;
2025
2230
  }
2026
- // default / --head: content is a whole block (default) or a bare head line.
2027
- // Does the target exist? Asked FIRST: the shape checks below name the id in
2028
- // their advice ("use --body to set the body of #far"), which reads as though the
2029
- // id were there. Whether the content is prose is the second question.
2030
- if (!blockSpans(source).has(id))
2031
- fail(`no block with id \`${id}\``, 1);
2032
2231
  let content;
2033
2232
  if (rawChannel) {
2034
2233
  content = readInput("-");
@@ -2042,39 +2241,71 @@ function runSet(args) {
2042
2241
  if (shape === "empty")
2043
2242
  fail(NO_CONTENT, 1);
2044
2243
  if (shape === "prose")
2045
- 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);
2046
2245
  if (shape === "multi")
2047
2246
  fail("set replaces ONE block, but the content has multiple blocks (use add)", 1);
2048
2247
  }
2049
2248
  }
2050
2249
  else {
2051
- content = extractBlock(from, id, headOnly ? "head" : "whole");
2052
- }
2053
- const normalized = normalizeBlockId(content, id);
2054
- 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);
2055
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))}`);
2056
2296
  }
2057
2297
  // `--body`: swap ONLY the target block's body, keeping its head (and #id) and,
2058
2298
  // for a typed block, its close fence. Assembles head + new body + close and
2059
2299
  // reuses the guarded spliceBlock — the head carries #id, so the id survives
2060
2300
  // with no normalization needed.
2061
- function runSetBody(source, id, from, rawChannel, file, out) {
2062
- const found = blockSpans(source).get(id);
2063
- if (!found)
2064
- fail(`no block with id \`${id}\``, 1);
2301
+ function runSetBody(source, target, from, rawChannel, file, out) {
2302
+ const found = target.unit.span;
2065
2303
  const lines = splitLines(source);
2066
2304
  const headLine = lines[found.start] ?? "";
2067
- const headText = stripEol(headLine);
2068
- // A typed block keeps its closing fence; a heading section has none.
2069
- let closeLine = null;
2070
- const open = FENCE_OPEN.exec(headText);
2071
- if (open) {
2072
- const lastText = stripEol(lines[found.end - 1] ?? "").replace(/[ \t]+$/, "");
2073
- const bid = open[3] ? parseAttrs(open[3]).id : undefined;
2074
- const labeled = bid !== undefined && new RegExp(`^={3,}[ \\t]+#${reLit(bid)}[ \\t]*$`).test(lastText);
2075
- if (isCloseFence(lastText, open[1].length) || labeled)
2076
- closeLine = lines[found.end - 1] ?? "";
2077
- }
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);
2078
2309
  let body;
2079
2310
  if (rawChannel) {
2080
2311
  body = readInput("-");
@@ -2082,7 +2313,7 @@ function runSetBody(source, id, from, rawChannel, file, out) {
2082
2313
  fail(NO_CONTENT, 1);
2083
2314
  }
2084
2315
  else {
2085
- body = extractBlock(from, id, "body");
2316
+ body = extractBlock(from, target.unit.id ?? "", "body");
2086
2317
  }
2087
2318
  let head = headLine;
2088
2319
  if (head !== "" && !/(\r\n|\r|\n)$/.test(head))
@@ -2095,8 +2326,9 @@ function runSetBody(source, id, from, rawChannel, file, out) {
2095
2326
  // block-count invariant so a `===` fence in the raw body can't close it early
2096
2327
  // and inject siblings (SEC F2). A heading section body has no close fence and
2097
2328
  // may legitimately contain blocks, so it is not count-guarded.
2098
- const updated = spliceBlock(source, id, replacement, file, false, closeLine !== null);
2329
+ const updated = spliceSpan(source, found, replacement, file, false, closeLine !== null, target.unit.id);
2099
2330
  resolveOutTarget(file, out).write(updated);
2331
+ reportNewAddress(updated, target);
2100
2332
  }
2101
2333
  // `geml add <file|-> (--append | --before #x | --after #x) [--in F|F#src|-] [-o]`
2102
2334
  // — insert a GEML fragment (1+ blocks and/or prose) at a position. Unlike `set`,
@@ -2397,6 +2629,13 @@ function spliceBlock(source, id, replacement, file, headOnly = false, guardCount
2397
2629
  const found = blockSpans(source).get(id);
2398
2630
  if (!found)
2399
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) {
2400
2639
  const beforeDoc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
2401
2640
  const beforeIds = beforeDoc.ids;
2402
2641
  // Keep the bytes before and after the target span exactly; give the new block
@@ -2428,7 +2667,7 @@ function spliceBlock(source, id, replacement, file, headOnly = false, guardCount
2428
2667
  refuseBroken(`replacement would break the document: ${first.message} (line ${first.line}); not written`, errs);
2429
2668
  }
2430
2669
  const now = new Set(reparsed.ids);
2431
- if (!now.has(id))
2670
+ if (id !== undefined && !now.has(id))
2432
2671
  fail(`replacement removes id \`${id}\`; not written`, 1);
2433
2672
  const dropped = beforeIds.find((x) => x !== id && !now.has(x));
2434
2673
  if (dropped !== undefined) {
@@ -2443,7 +2682,7 @@ function spliceBlock(source, id, replacement, file, headOnly = false, guardCount
2443
2682
  // (Not enforced for heading sections / whole-block set, whose replacement may
2444
2683
  // legitimately span several top-level blocks.)
2445
2684
  if (guardCount && reparsed.children.length !== beforeDoc.children.length) {
2446
- 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);
2447
2686
  }
2448
2687
  return updated;
2449
2688
  }