@geml/geml 1.7.2 → 1.7.3

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/README.md CHANGED
@@ -24,7 +24,8 @@ print("hi")
24
24
  - **Addressable** — every block can be named: an `#id`, or a content address for
25
25
  the ones nobody named; `geml get` / `geml set '<selector>'`
26
26
  read or patch one section without re-emitting the whole file (on this repo's
27
- own spec, ~**66× less context** than shipping the whole document).
27
+ own spec, ~**120× less context** than shipping the whole document — the block
28
+ is ~590 chars whatever the document grows to).
28
29
  - **Verifiable** — references are checked at build time (a dangling `#id` is an
29
30
  error, not a silent dead link), and the parser emits a document-model JSON
30
31
  with a `diagnostics` array, so agents and CI get a structured pass/fail signal.
@@ -101,13 +102,15 @@ geml find "text" doc.geml|dir # search block CONTENT -> file<TAB>address;
101
102
  geml get doc.geml ['<selector>'] # list addressable blocks, or print what the selector matches
102
103
  geml get doc.geml '#sec' --intro # a section cuts three ways: --head | --intro | --body
103
104
  geml set doc.geml '<selector>' [--head|--intro|--body] [--in F[#src]] # replace ONE block's content
105
+ geml replace doc.geml OLD NEW [--within '<selector>'] # EXPERIMENTAL: literal swap, checked and reported
104
106
  geml add doc.geml (--append|--before #id|--after #id) [--in F[#src]] # insert a fragment
105
107
  geml delete doc.geml '#id' ['#id2' …] # remove one or more blocks
106
108
  geml rename doc.geml '#old' '#new' # rename an id + every reference to it
107
109
  geml revert doc.geml '#id' [--rev -1] # undo a block: splice / resurrect / remove
108
110
  geml check doc.geml [--root <dir>] # validate only: diagnostics + exit code (--json for the array)
109
111
  geml history <save|get|restore|verify> doc.geml [...] # .gemlhistory version sidecar (get = list revisions, or print one)
110
- geml codemap <build|verify|render|serve|refresh|find|mcp> # your codebase's call graph as GEML docs
112
+ geml codemap <build|verify|render|serve|refresh|find> # your codebase's call graph as GEML docs
113
+ geml mcp --root <dir> [--graph <dir>] # serve documents (+ the code graph) over MCP
111
114
  geml --help | --version # --version --json prints {"parser","spec"}
112
115
  ```
113
116
 
@@ -138,6 +141,15 @@ byte-identical — and `--intro` is how a section's opening is edited without
138
141
  pulling its subsections into context. A block has no intro; asking for one is a
139
142
  usage error rather than a quiet fall back to the body.
140
143
 
144
+ `replace` is the cheap path when the exact old text is already known and nothing
145
+ needs reading — a version string in six places, a term renamed. It is the one
146
+ operation where GEML can beat `sed` outright rather than imitate it: the same
147
+ two short strings, but the result is re-parsed before it lands, the blocks it
148
+ touched are named back to you, and it is in `.gemlhistory` to revert. It swaps a
149
+ LITERAL, never a pattern, and refuses a swap that would rename an id — that is
150
+ `geml rename`, which fixes the references too. **It is EXPERIMENTAL and may be
151
+ withdrawn**; build nothing on it that cannot change.
152
+
141
153
  A write is refused when it would break the document, never merely because it
142
154
  removes something. A replacement that drops blocks is carried out and the
143
155
  dropped blocks are named on stderr — unnamed ones counted, references left
@@ -234,9 +246,13 @@ Add to your `claude_desktop_config.json`:
234
246
  ### Claude Code / CLI Clients
235
247
  Run the following command to add the server:
236
248
  ```sh
237
- /mcp add npx -y @geml/geml@latest mcp --root /absolute/path/to/your/docs
249
+ claude mcp add geml -- npx -y @geml/geml@latest mcp --root /absolute/path/to/your/docs
238
250
  ```
239
251
 
252
+ With a code graph under `--root` (`geml codemap build`), the same server also
253
+ serves four read-only `geml_codemap_*` tools. Every tool and option:
254
+ [`docs/mcp-guide.md`](https://github.com/geml-spec/geml/blob/main/docs/mcp-guide.md).
255
+
240
256
  ## Library
241
257
 
242
258
  ```js
@@ -259,6 +275,11 @@ Full normative spec, history-sidecar spec, and format comparison live in the
259
275
  [repository](https://github.com/geml-spec/geml). The spec is itself
260
276
  written in GEML (`GEML-spec.geml`) and parsed clean on every test run.
261
277
 
278
+ What changed between releases:
279
+ [`CHANGELOG.md`](https://github.com/geml-spec/geml/blob/main/CHANGELOG.md).
280
+ The parser and the specification version independently — `geml --version --json`
281
+ prints both.
282
+
262
283
  ## License
263
284
 
264
285
  MIT.
package/codemap/build.mjs CHANGED
@@ -513,6 +513,12 @@ console.error(
513
513
  + `${stats.containers} containers (${stats.written} of ${stats.docs} files written), `
514
514
  + `${(stats.bytes / 1048576).toFixed(2)} MB -> ${outDir}`,
515
515
  );
516
+ // A build that deletes files says which ones. Silence here is how the orphans
517
+ // accumulated in the first place — name them, so a rename that drops a whole
518
+ // naming scheme is visible in the log rather than three renamings later.
519
+ if (stats.pruned?.length) {
520
+ console.error(` pruned ${stats.pruned.length} document(s) no longer produced: ${stats.pruned.join(", ")}`);
521
+ }
516
522
 
517
523
  // --history: snapshot every changed document into its .gemlhistory sidecar —
518
524
  // the graph's own architectural history (geml history get / revert per node).
package/codemap/emit.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
  // Emission is deterministic: stable sort orders everywhere; a file is only
14
14
  // written when its bytes changed (mtime = "what a change touched").
15
15
  import { createHash } from "node:crypto";
16
- import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
16
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
17
17
  import { dirname, join, posix } from "node:path";
18
18
  import { buildNormalizer } from "./normalize.mjs";
19
19
 
@@ -461,10 +461,40 @@ export function emit({ symbols, edges, outDir, buildDir, repoName, container = "
461
461
  if (!existsSync(p) || readFileSync(p, "utf8") !== content) writeFileSync(p, content);
462
462
  }
463
463
 
464
+ // ---- prune documents this build no longer produces ----
465
+ // A container that stops yielding symbols simply gets no document; without
466
+ // this, the one written by an earlier build stays behind describing code that
467
+ // is gone. Those orphans are not inert: `geml check` reads their `src=` line
468
+ // ranges and fails the build-time reference check, which is how nine of them
469
+ // — across two renamings of the naming scheme — went unnoticed until one
470
+ // orphan's source file happened to SHRINK past its recorded line numbers.
471
+ //
472
+ // `allDocs` is the authoritative set: every .geml this run emitted, written
473
+ // or byte-identical. Anything else at the top level of outDir is an orphan.
474
+ // Two guards keep this from eating a file it does not own: only the top level
475
+ // is scanned (never _index/, _build/, or any subtree), and a candidate must
476
+ // carry the generated-document marker `resolution-default` in its head — a
477
+ // hand-placed .geml parked in the directory is left alone.
478
+ const pruned = [];
479
+ const keep = new Set(allDocs);
480
+ let present = [];
481
+ try { present = readdirSync(outDir); } catch { present = []; }
482
+ for (const f of present) {
483
+ if (!f.endsWith(".geml") || keep.has(f)) continue;
484
+ const p = join(outDir, f);
485
+ try {
486
+ if (!statSync(p).isFile()) continue;
487
+ if (!/^===\s*meta\b[\s\S]*?\bresolution-default\s*=/.test(readFileSync(p, "utf8").slice(0, 2000))) continue;
488
+ unlinkSync(p);
489
+ pruned.push(f);
490
+ } catch { /* unreadable or already gone — not this build's problem */ }
491
+ }
492
+
464
493
  return {
465
494
  ...stats,
466
495
  allDocs,
467
496
  writtenDocs,
497
+ pruned,
468
498
  containers: containers.size,
469
499
  symbols: symbols.length,
470
500
  methods: methods.length,
@@ -8,7 +8,7 @@
8
8
  // graph area (nested frame), so the whole map is browsable offline — this is
9
9
  // the "copy the folder to someone" mode. For a live view that never goes
10
10
  // stale, use `geml codemap serve` instead.
11
- import { readdirSync, readFileSync, writeFileSync, realpathSync } from "node:fs";
11
+ import { readdirSync, readFileSync, writeFileSync, realpathSync, unlinkSync } from "node:fs";
12
12
  import { join, basename, sep, resolve as resolvePath } from "node:path";
13
13
  import { parse, renderHtml } from "../dist/geml.js";
14
14
 
@@ -73,5 +73,18 @@ for (const f of files) {
73
73
  console.error(`render: ${f}: ${e.message}`);
74
74
  }
75
75
  }
76
+ // Each tool prunes what it owns: build removes the documents it no longer
77
+ // produces, and this removes the pages whose document is gone. An orphan page
78
+ // is worse than a stale one — it is unreachable from index.html yet still
79
+ // served, so a copied folder ships a page describing deleted code with no way
80
+ // to notice. Only a `<base>.html` whose `<base>.geml` is absent qualifies, so
81
+ // nothing that has a document behind it is ever touched.
82
+ const prunedPages = [];
83
+ for (const f of files) {
84
+ if (!f.endsWith(".html")) continue;
85
+ if (files.includes(f.replace(/\.html$/, ".geml"))) continue;
86
+ try { unlinkSync(join(dir, f)); prunedPages.push(f); } catch { /* already gone */ }
87
+ }
76
88
  console.error(`rendered ${n} page(s) -> ${dir}${failed.length ? `; FAILED: ${failed.join(", ")}` : ""}`);
89
+ if (prunedPages.length) console.error(` pruned ${prunedPages.length} orphan page(s): ${prunedPages.join(", ")}`);
77
90
  process.exit(failed.length ? 1 : 0);
package/dist/cli.js CHANGED
@@ -156,6 +156,7 @@ Usage:
156
156
  lines, which is how a grep hit or a stack trace becomes
157
157
  an address.
158
158
  geml set <file.geml|-> #id [--head|--intro|--body] [--in f[#src]|-] [-o f] replace ONE block by id
159
+ geml replace <file.geml|-> <old> <new> [--within <selector>] [-o f] EXPERIMENTAL: swap a literal string, checked and reported
159
160
  (--in F takes F's block #id, F#src takes #src, else stdin raw;
160
161
  default = whole block · --head = head line · --body = body)
161
162
  geml add <file.geml|-> (--append | --before #id | --after #id) [--in f[#src]|-] [-o f] insert a fragment
@@ -173,7 +174,7 @@ Usage:
173
174
  and re-hash the whole chain)
174
175
  geml codemap <build|verify|render|serve|refresh|find> [...] code-graph toolkit (alias: codegraph)
175
176
  geml mcp --root <dir> [--graph <dir>] [--no-history] serve documents (and the code graph) over MCP (stdio)
176
- (10 tools, each geml_ + its CLI command path: list/get/check/history/to +
177
+ (11 tools, each geml_ + its CLI command path: list/find/get/check/history/to +
177
178
  set/add/delete/rename/revert; every write is validated before it
178
179
  reaches disk. A code graph under --root adds four read-only
179
180
  geml_codemap_* tools to the same server)
@@ -201,6 +202,7 @@ const SUBHELP = {
201
202
  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)",
202
203
  list: "usage: geml list <file.geml|-> [--json] (list every addressable block with its shortest unique address, its kind and its line range — the same listing `geml get <file>` prints with no selector, under the name the MCP surface already uses. Call it FIRST: the addresses it prints are what get/set/add/delete/rename/revert all take)",
203
204
  find: "usage: geml find <pattern> [<file.geml|dir> …] [--json] [--case] [--head] (search block CONTENT and print `<file>TAB<address>` per hit — an address, never a line number, so a hit is `geml get <file> '<address>'` with no editing. The address is the INNERMOST block holding the match, never its enclosing section, and a block is reported once however many lines in it matched. Substring, case-insensitive unless --case; a directory is walked for *.geml; no path = the current directory; --head adds the matching line as a third column. Exit 1 when nothing matched, so `if geml find …` works in a script)",
205
+ replace: "usage: geml replace <file.geml|-> <old> <new> [--within <selector>] [-o out.geml] (EXPERIMENTAL — this verb MAY BE WITHDRAWN in a later release; it is here to find out whether an addressed, checked replacement earns its place beside `sed`, and if it does not, it goes. Build nothing on it you cannot change, and say so in a discussion if it is doing real work for you. Swaps a LITERAL string — never a pattern, that is what `sed` is for and where the footguns are. Without --within the whole document; with it, only inside the blocks that selector matches, and unlike `set` it may match several: `--within '=== table'` means every table. What this buys over `sed -i`, at the same cost of two short strings and nothing read: the result is re-parsed and refused if it would break the document, the blocks it touched are NAMED on stderr, and the write lands in .gemlhistory where `revert` can undo it. An id is not text — a replacement that would rename one is refused and points at `geml rename`, which fixes every reference too. Exit 1 when nothing matched, so `if geml replace …` works in a script)",
204
206
  check: "usage: geml check <file.geml|-> [--root <dir>] [--json] (--root: resolve cross-doc refs within <dir> instead of the file's own directory)",
205
207
  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)",
206
208
  history: `usage: geml history save <file.geml> [-m <msg>] append the working file as a new revision (identical to the tip = no-op)
@@ -637,9 +639,11 @@ function runTransform(argv) {
637
639
  const root = flag(argv, "--root");
638
640
  if (argv.includes("--root") && root === undefined)
639
641
  fail("--root needs a directory", 2);
640
- const [file] = positionals(argv, ["-o", "--out", "--from", "--to", "--root"]);
641
- if (!file)
642
- fail("no input file (use '-' to read from stdin)", 2);
642
+ // Dispatch only lands here when argv[0] is `-` or carries a path character,
643
+ // and `positionals` keeps both — so there is always a file. A guard for the
644
+ // empty case would read as a possibility that does not exist; a caller who
645
+ // writes `geml --to md` is told `unknown command '--to'` at the door.
646
+ const file = positionals(argv, ["-o", "--out", "--from", "--to", "--root"])[0];
643
647
  // A bare `--to`/`--from` (no following value) is a mistyped flag, not a
644
648
  // silent fall-through to the default — flag() would return undefined and we
645
649
  // must not quietly ignore it.
@@ -1309,6 +1313,126 @@ function runGet(args) {
1309
1313
  for (const u of units)
1310
1314
  process.stdout.write(sliceUnit(source, u.span, part));
1311
1315
  }
1316
+ // `geml replace <file> <old> <new> [--within <selector>]` — swap a literal
1317
+ // string, everywhere or inside named blocks, without reading the document.
1318
+ //
1319
+ // This is the one operation where GEML can beat `sed` outright rather than
1320
+ // imitate it. The cost is the same — two short strings out, nothing read in —
1321
+ // and three things come back that `sed -i` cannot give: the write is re-parsed
1322
+ // and refused if it would break the document, the blocks it touched are named,
1323
+ // and it lands in `.gemlhistory` where `revert` can undo it. Measured on a real
1324
+ // day of editing, ten of fourteen changes were bulk blind replacement done with
1325
+ // the original commands; every one of those was an edit that escaped all three.
1326
+ //
1327
+ // LITERAL, never a pattern. Regular expressions are where `sed` is genuinely
1328
+ // better and where the footguns live, and the moment this grows them it stops
1329
+ // being "GEML, addressed" and becomes a worse `sed`.
1330
+ function runReplace(args) {
1331
+ const out = flag(args, "-o") ?? flag(args, "--out");
1332
+ const within = flag(args, "--within");
1333
+ const [file, oldText, newText] = positionals(args, ["-o", "--out", "--within"]);
1334
+ if (!file || oldText === undefined || newText === undefined)
1335
+ fail(SUBHELP.replace);
1336
+ if (oldText === "")
1337
+ fail("the text to replace is empty — that would match everywhere", 2);
1338
+ const source = readInput(file);
1339
+ const where = file === "-" ? "stdin" : file;
1340
+ const all = addressedUnits(source);
1341
+ // Scope: the whole document, or every block a selector matches. Several
1342
+ // matches are fine here — `replace … --within '=== table'` meaning "in all
1343
+ // the tables" is the useful reading, and unlike `set` there is no ambiguity
1344
+ // about which one receives the write.
1345
+ const lines = splitLines(source);
1346
+ const lineStart = [];
1347
+ {
1348
+ let at = 0;
1349
+ for (const l of lines) {
1350
+ lineStart.push(at);
1351
+ at += l.length;
1352
+ }
1353
+ }
1354
+ let scopes;
1355
+ if (within === undefined) {
1356
+ scopes = [{ from: 0, to: source.length }];
1357
+ }
1358
+ else {
1359
+ // `selectUnits` already refuses a selector that matches nothing, with the
1360
+ // message the other verbs give, so there is no empty case to handle here.
1361
+ const { units } = selectUnits(source, file, within, where);
1362
+ scopes = units.map((u) => ({
1363
+ from: lineStart[u.span.start],
1364
+ to: u.span.end >= lineStart.length ? source.length : lineStart[u.span.end],
1365
+ }));
1366
+ }
1367
+ // Find every occurrence inside the scopes, right to left, so replacing one
1368
+ // cannot move the ones not yet done.
1369
+ const hits = [];
1370
+ for (const s of scopes) {
1371
+ let at = source.indexOf(oldText, s.from);
1372
+ while (at !== -1 && at + oldText.length <= s.to) {
1373
+ hits.push(at);
1374
+ at = source.indexOf(oldText, at + oldText.length);
1375
+ }
1376
+ }
1377
+ hits.sort((a, b) => a - b);
1378
+ if (hits.length === 0) {
1379
+ // Exit 1 like `find`, so `if geml replace …` means what it looks like.
1380
+ fail(`\`${oldText}\` does not occur in ${within === undefined ? where : `\`${within}\` of ${where}`} — nothing written`, 1);
1381
+ }
1382
+ let updated = source;
1383
+ for (const at of [...hits].reverse()) {
1384
+ updated = updated.slice(0, at) + newText + updated.slice(at + oldText.length);
1385
+ }
1386
+ // Which blocks were touched — the report has to speak in addresses, or this
1387
+ // is just `sed` with a longer name.
1388
+ const lineOf = (off) => {
1389
+ let lo = 0, hi = lineStart.length - 1;
1390
+ while (lo < hi) {
1391
+ const mid = (lo + hi + 1) >> 1;
1392
+ if (lineStart[mid] <= off)
1393
+ lo = mid;
1394
+ else
1395
+ hi = mid - 1;
1396
+ }
1397
+ return lo;
1398
+ };
1399
+ const touched = new Set();
1400
+ for (const at of hits) {
1401
+ const ln = lineOf(at);
1402
+ let best;
1403
+ for (const a of all) {
1404
+ if (a.unit.span.start <= ln && ln < a.unit.span.end) {
1405
+ if (!best || (a.unit.span.end - a.unit.span.start) < (best.unit.span.end - best.unit.span.start))
1406
+ best = a;
1407
+ }
1408
+ }
1409
+ if (best)
1410
+ touched.add(shortestAddress(best, all));
1411
+ }
1412
+ // An id is not text to be swapped: changing one silently cuts every reference
1413
+ // to it, which is precisely what `rename` exists to do properly.
1414
+ const before = parse(source, { ...docOpts(file) });
1415
+ const after = parse(updated, { ...docOpts(file), self: file === "-" ? undefined : basename(file) });
1416
+ const goneIds = before.ids.filter((x) => !new Set(after.ids).has(x));
1417
+ const newIds = after.ids.filter((x) => !new Set(before.ids).has(x));
1418
+ if (goneIds.length && newIds.length) {
1419
+ fail(`that would rename \`#${goneIds[0]}\` to \`#${newIds[0]}\` — an id is not text: use \`geml rename ${where} '#${goneIds[0]}' '#${newIds[0]}'\`, which fixes every reference too. Nothing written`, 2);
1420
+ }
1421
+ const errs = after.diagnostics.filter((d) => d.severity === "error");
1422
+ if (errs.length) {
1423
+ refuseBroken(`the replacement would break the document: ${errs[0].message} (line ${errs[0].line}); nothing written`, errs);
1424
+ }
1425
+ // Blocks the replacement removed follow `set`'s rule: carried out, and named.
1426
+ const droppedAnon = Math.max(0, countBlockUnits(source) - countBlockUnits(updated) - goneIds.length);
1427
+ if (goneIds.length || droppedAnon) {
1428
+ const named = goneIds.map((x) => `\`#${x}\``).join(", ");
1429
+ const anon = droppedAnon ? `${droppedAnon} unnamed block${droppedAnon > 1 ? "s" : ""}` : "";
1430
+ console.error(`dropped ${[named, anon].filter(Boolean).join(" and ")} — run 'geml revert' to put them back`);
1431
+ }
1432
+ resolveOutTarget(file, out).write(updated);
1433
+ const list = [...touched].join(", ");
1434
+ console.error(`replaced ${hits.length} occurrence${hits.length > 1 ? "s" : ""}${list ? ` in ${list}` : ""}`);
1435
+ }
1312
1436
  const NO_CONTENT = "no replacement content (use --in FILE or pipe it on stdin)";
1313
1437
  // `geml set <file.geml|-> #id [--head|--body] [--in F|F#src|-] [-o out]` —
1314
1438
  // replace ONE existing block, addressed by #id, with new content, preserving
@@ -2392,6 +2516,9 @@ const entry = (() => {
2392
2516
  else if (cmd === "set") {
2393
2517
  runSet(argv.slice(1));
2394
2518
  }
2519
+ else if (cmd === "replace") {
2520
+ runReplace(argv.slice(1));
2521
+ }
2395
2522
  else if (cmd === "add") {
2396
2523
  runAdd(argv.slice(1));
2397
2524
  }
package/dist/geml.js CHANGED
@@ -1137,6 +1137,31 @@ function validateRefs(ctx, opts) {
1137
1137
  if (ref.kind === "cross") {
1138
1138
  if (!ref.doc)
1139
1139
  continue;
1140
+ // WHAT `#frag` MEANS IS THE TARGET FORMAT'S BUSINESS, and GEML only
1141
+ // defines it for GEML. In `page.html#sec` the fragment is an element id;
1142
+ // in `notes.md#sec` it is a forge's heading slug or an `<a id>`. Reading
1143
+ // either with GEML's own rules got both directions wrong: it accepted
1144
+ // `{#brace}` that no forge resolves, refused `<a id="x">` and slug
1145
+ // anchors that every forge does, and — this is the part that makes the
1146
+ // check untrustworthy rather than merely strict — passed by ACCIDENT
1147
+ // whenever the name happened to appear anywhere in the target, which is
1148
+ // how this repo's own `../GEML-spec.md#appendix-a-diagnostic-catalogue`
1149
+ // was green: that string is in a LINK there, not a definition.
1150
+ //
1151
+ // So the document must still resolve — a link to a file that is not
1152
+ // there is broken whatever its format — and the fragment is left to the
1153
+ // format that owns it. Same lesson as directories: do not judge another
1154
+ // convention by GEML's rules; a check that guesses teaches people to
1155
+ // ignore it.
1156
+ const gemlTarget = /\.geml$/i.test(ref.doc);
1157
+ if (!gemlTarget && ref.anchor !== undefined && opts.resolveDoc) {
1158
+ // The document still has to be there — a link to a missing file is
1159
+ // broken whatever its format — but nothing here reads its fragment.
1160
+ if (opts.resolveDoc(ref.doc) === null && !opts.docExists?.(ref.doc)) {
1161
+ ctx.diags.push({ severity: "error", code: "unresolvable-document", message: `cannot resolve document \`${ref.doc}\``, line: ref.line });
1162
+ }
1163
+ continue;
1164
+ }
1140
1165
  if (!opts.resolveDoc) {
1141
1166
  ctx.diags.push({ severity: "warning", code: "unchecked-cross-document-reference", message: `cross-document reference \`${ref.doc}${ref.anchor ? "#" + ref.anchor : ""}\` not checked (no document resolver)`, line: ref.line });
1142
1167
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geml/geml",
3
- "version": "1.7.2",
3
+ "version": "1.7.3",
4
4
  "mcpName": "io.github.geml-spec/geml",
5
5
  "publishConfig": {
6
6
  "access": "public"
package/skill/SKILL.md CHANGED
@@ -61,6 +61,7 @@ geml list file.geml # CALL THIS FIRST — every block, its add
61
61
  geml find "text" file.geml|dir # search block CONTENT -> file<TAB>address (exit 1 = no hit)
62
62
  geml get file.geml '#id' # read ONE block (a heading id = its whole section)
63
63
  geml set file.geml '#id' --in f # replace ONE block (re-parsed; never writes a broken doc)
64
+ geml replace file.geml OLD NEW # EXPERIMENTAL literal swap; --within '#id' to narrow
64
65
  geml history save file.geml -m "…" # snapshot to .gemlhistory after each meaningful edit
65
66
  geml revert file.geml '#id' # roll ONE block back (--rev -2 | changed | <rev-id>)
66
67
  ```
@@ -78,6 +79,15 @@ under it, so it always contains the intro). `--intro` is how you edit a
78
79
  section's opening without pulling its subsections into context, and setting an
79
80
  empty one writes an opening where the section had none.
80
81
 
82
+ When the exact old text is already known and nothing needs reading — a version
83
+ string in six places, a renamed term — `geml replace` is the cheap path, and the
84
+ one to prefer over dropping to `sed`: same two short strings, but the result is
85
+ re-parsed before it lands, the blocks it touched are named back to you, and it
86
+ is in `.gemlhistory` to revert. It swaps a LITERAL, never a pattern, and refuses
87
+ a swap that would rename an id (use `geml rename`, which fixes the references
88
+ too). **It is EXPERIMENTAL and may be withdrawn** — reach for it, but do not
89
+ build anything on it that cannot change.
90
+
81
91
  A write is refused when it would break the document, never merely because it
82
92
  removes something: a replacement that drops blocks is carried out and NAMED on
83
93
  stderr — unnamed blocks included — with `geml revert` as the way back. Read,
@@ -223,6 +223,8 @@ geml get file.geml 'L27-58' # position: the smallest block holding tho
223
223
  geml get file.geml '#sec' --intro # a section cut three ways: --head | --intro | --body
224
224
  geml set file.geml '#id' --in f # replace ONE block (guarded: re-parsed, never writes broken)
225
225
  geml set file.geml '#sec' --intro # replace just the opening; the subsections stay put
226
+ geml replace file.geml OLD NEW # EXPERIMENTAL, may be withdrawn: literal swap, checked and
227
+ # reported; --within '#id' or '=== type' narrows the scope
226
228
  geml add file.geml --after '#id' --in f # insert a fragment (keeps its own ids)
227
229
  geml delete file.geml '#id' ['#id2'] # remove one or more blocks
228
230
  geml rename file.geml '#old' '#new' # rename an id AND every reference to it