@geml/geml 1.4.2 → 1.4.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/dist/geml.js CHANGED
@@ -5,9 +5,10 @@
5
5
  // `meta` data block, ATX headings, lists and paragraphs, the attribute object
6
6
  // with §4 value typing, and a document-model JSON serialization.
7
7
  //
8
- // M2: inline parsing of flow blocks (§5 — emphasis/strong/strike, code, math,
9
- // media embeds, links, auto-references, footnotes) and build-time reference
10
- // validation (§8 — unique ids, resolvable internal/cross-document references).
8
+ // M2: inline parsing of unfenced blocks (§5 — emphasis/strong/strike, code,
9
+ // math, media embeds, links, auto-references, footnotes) and build-time
10
+ // reference validation (§8 — unique ids, resolvable internal/cross-document
11
+ // references).
11
12
  import { readFileSync, writeFileSync, realpathSync, statSync, existsSync } from "node:fs";
12
13
  import { basename, dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path";
13
14
  import { fileURLToPath } from "node:url";
@@ -15,6 +16,7 @@ import { spawnSync } from "node:child_process";
15
16
  import { commit, restore, verify, listRevisions, resolveContent, firstChangedContent } from "./history.js";
16
17
  import { renderHtml } from "./render-html.js";
17
18
  import { normalizeBlockId } from "./block-edit.js";
19
+ import { normalizeSource } from "./diagnostics.js";
18
20
  import { coerce, parseAttrs } from "./attrs.js";
19
21
  import { META_REF_SRC, parseInline } from "./inline.js";
20
22
  import { parseTable } from "./table.js";
@@ -34,6 +36,9 @@ export { gemlToMd } from "./to-md.js";
34
36
  function reLit(s) {
35
37
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
36
38
  }
39
+ // Re-exported from ./diagnostics.js so that `Diagnostic` stays importable from
40
+ // the package root. The catalogue of codes lives there (spec Appendix A).
41
+ export { SEVERITY } from "./diagnostics.js";
37
42
  // Type registry: which body mode each typed block uses. Unknown types are a
38
43
  // warning and fall back to `raw` (forward compatibility, §3/§8).
39
44
  const REGISTRY = {
@@ -127,7 +132,7 @@ function interpolate(text, line, ctx) {
127
132
  if (ctx.meta.has(key))
128
133
  out += ctx.meta.get(key);
129
134
  else {
130
- ctx.diags.push({ severity: "error", message: `unknown metadata reference \`{{${key}}}\``, line });
135
+ ctx.diags.push({ severity: "error", code: "unknown-metadata-reference", message: `unknown metadata reference \`{{${key}}}\``, line });
131
136
  out += m[0];
132
137
  }
133
138
  i = META_REF.lastIndex;
@@ -142,7 +147,7 @@ function interpolate(text, line, ctx) {
142
147
  // Register a block id, flagging duplicates as errors (§4: ids unique per doc).
143
148
  function registerId(ctx, id, line) {
144
149
  if (ctx.ids.has(id)) {
145
- ctx.diags.push({ severity: "error", message: `duplicate id \`#${id}\` (first defined at line ${ctx.ids.get(id)})`, line });
150
+ ctx.diags.push({ severity: "error", code: "duplicate-id", message: `duplicate id \`#${id}\` (first defined at line ${ctx.ids.get(id)})`, line });
146
151
  }
147
152
  else {
148
153
  ctx.ids.set(id, line);
@@ -212,7 +217,7 @@ function parseList(lines, i, base, ctx) {
212
217
  // rather than building a model that overflows the renderer (DoS). One
213
218
  // diagnostic per over-deep list; content is preserved, just flattened.
214
219
  if (!tooDeep) {
215
- ctx.diags.push({ severity: "error", message: `list nesting too deep (max ${MAX_NESTING})`, line: base + i + 1 });
220
+ ctx.diags.push({ severity: "error", code: "list-nesting-too-deep", message: `list nesting too deep (max ${MAX_NESTING})`, line: base + i + 1 });
216
221
  tooDeep = true;
217
222
  }
218
223
  cur = top.list;
@@ -298,11 +303,11 @@ function scanBlocks(lines, base, ctx, depth = 0) {
298
303
  }
299
304
  if (!closed) {
300
305
  const how = attrs.id !== undefined ? `${"=".repeat(openLen)} or \`=== #${attrs.id}\`` : "=".repeat(openLen);
301
- diags.push({ severity: "error", message: `unterminated \`${type}\` block (no matching ${how})`, line: openLineNo });
306
+ diags.push({ severity: "error", code: "unterminated-block", message: `unterminated \`${type}\` block (no matching ${how})`, line: openLineNo });
302
307
  }
303
308
  let mode = REGISTRY[type];
304
309
  if (mode === undefined) {
305
- diags.push({ severity: "warning", message: `unknown block type \`${type}\`; body kept as raw`, line: openLineNo });
310
+ diags.push({ severity: "warning", code: "unknown-block-type", message: `unknown block type \`${type}\`; body kept as raw`, line: openLineNo });
306
311
  mode = "raw";
307
312
  }
308
313
  const block = {
@@ -326,7 +331,7 @@ function scanBlocks(lines, base, ctx, depth = 0) {
326
331
  // Refuse to recurse past the cap: emit a diagnostic and keep the body
327
332
  // as raw so the parser returns cleanly instead of overflowing the
328
333
  // call stack on a pathologically nested document (DoS).
329
- diags.push({ severity: "error", message: `block nesting too deep (max ${MAX_NESTING}); body kept as raw`, line: openLineNo });
334
+ diags.push({ severity: "error", code: "block-nesting-too-deep", message: `block nesting too deep (max ${MAX_NESTING}); body kept as raw`, line: openLineNo });
330
335
  block.raw = body;
331
336
  }
332
337
  else {
@@ -356,7 +361,7 @@ function scanBlocks(lines, base, ctx, depth = 0) {
356
361
  // §7: native chart — resolved in a second pass (data=#id may be
357
362
  // defined later in the document).
358
363
  if (body.length > 0 && body.some((l) => l.trim() !== "")) {
359
- diags.push({ severity: "warning", message: "geml-chart body is ignored; the chart spec lives in attributes", line: openLineNo });
364
+ diags.push({ severity: "warning", code: "ignored-diagram-body", message: "geml-chart body is ignored; the chart spec lives in attributes", line: openLineNo });
360
365
  }
361
366
  (ctx.charts ??= []).push({ block, line: openLineNo });
362
367
  }
@@ -366,18 +371,18 @@ function scanBlocks(lines, base, ctx, depth = 0) {
366
371
  // ("view config travels with the data"). Body is empty.
367
372
  const src = attrs.attrs["src"];
368
373
  if (typeof src !== "string" || src === "") {
369
- diags.push({ severity: "warning", message: "geml-code-graph: missing `src=` (nothing to render)", line: openLineNo });
374
+ diags.push({ severity: "warning", code: "code-graph-missing-src", message: "geml-code-graph: missing `src=` (nothing to render)", line: openLineNo });
370
375
  }
371
376
  else if (ctx.resolveDoc && ctx.resolveDoc(src) === null) {
372
- diags.push({ severity: "warning", message: `geml-code-graph: cannot resolve document \`${src}\``, line: openLineNo });
377
+ diags.push({ severity: "warning", code: "code-graph-unresolvable-document", message: `geml-code-graph: cannot resolve document \`${src}\``, line: openLineNo });
373
378
  }
374
379
  if (body.length > 0 && body.some((l) => l.trim() !== "")) {
375
- diags.push({ severity: "warning", message: "geml-code-graph body is ignored; the embed is configured by `src=` alone", line: openLineNo });
380
+ diags.push({ severity: "warning", code: "ignored-diagram-body", message: "geml-code-graph body is ignored; the embed is configured by `src=` alone", line: openLineNo });
376
381
  }
377
382
  }
378
383
  else if (typeof fmt === "string" && !DIAGRAM_RENDERERS.has(fmt)) {
379
384
  // §7: warn on a diagram format with no registered renderer.
380
- diags.push({ severity: "warning", message: `no registered renderer for diagram format \`${fmt}\`; body kept raw`, line: openLineNo });
385
+ diags.push({ severity: "warning", code: "unknown-diagram-format", message: `no registered renderer for diagram format \`${fmt}\`; body kept raw`, line: openLineNo });
381
386
  }
382
387
  }
383
388
  }
@@ -445,7 +450,7 @@ function parseData(lines) {
445
450
  // resolving `other.geml#id` references.
446
451
  function gatherIds(source) {
447
452
  const ctx = { diags: [], ids: new Map(), refs: [], meta: new Map() };
448
- scanBlocks(source.replace(/\r\n?/g, "\n").split("\n"), 0, ctx);
453
+ scanBlocks(normalizeSource(source).split("\n"), 0, ctx);
449
454
  return new Set(ctx.ids.keys());
450
455
  }
451
456
  // Pre-scan for `=== meta` blocks (at any fence depth) and merge their
@@ -477,14 +482,14 @@ function validateRefs(ctx, opts) {
477
482
  if (!ref.doc)
478
483
  continue;
479
484
  if (!opts.resolveDoc) {
480
- ctx.diags.push({ severity: "warning", message: `cross-document reference \`${ref.doc}${ref.anchor ? "#" + ref.anchor : ""}\` not checked (no document resolver)`, line: ref.line });
485
+ 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 });
481
486
  continue;
482
487
  }
483
488
  let ids = docIds.get(ref.doc);
484
489
  if (ids === undefined) {
485
490
  const src = opts.resolveDoc(ref.doc);
486
491
  if (src === null) {
487
- ctx.diags.push({ severity: "error", message: `cannot resolve document \`${ref.doc}\``, line: ref.line });
492
+ ctx.diags.push({ severity: "error", code: "unresolvable-document", message: `cannot resolve document \`${ref.doc}\``, line: ref.line });
488
493
  docIds.set(ref.doc, new Set());
489
494
  continue;
490
495
  }
@@ -492,14 +497,16 @@ function validateRefs(ctx, opts) {
492
497
  docIds.set(ref.doc, ids);
493
498
  }
494
499
  if (ref.anchor !== undefined && !ids.has(ref.anchor)) {
495
- ctx.diags.push({ severity: "error", message: `unresolved reference \`${ref.doc}#${ref.anchor}\``, line: ref.line });
500
+ ctx.diags.push({ severity: "error", code: "unresolved-cross-document-reference", message: `unresolved reference \`${ref.doc}#${ref.anchor}\``, line: ref.line });
496
501
  }
497
502
  continue;
498
503
  }
499
504
  // internal, autoref, footnote — anchor must be a known id in this document.
500
505
  if (ref.anchor !== undefined && !ctx.ids.has(ref.anchor)) {
501
- const what = ref.kind === "footnote" ? `footnote \`[^${ref.anchor}]\`` : `reference \`#${ref.anchor}\``;
502
- ctx.diags.push({ severity: "error", message: `unresolved ${what}`, line: ref.line });
506
+ const footnote = ref.kind === "footnote";
507
+ const what = footnote ? `footnote \`[^${ref.anchor}]\`` : `reference \`#${ref.anchor}\``;
508
+ const code = footnote ? "unresolved-footnote" : "unresolved-reference";
509
+ ctx.diags.push({ severity: "error", code, message: `unresolved ${what}`, line: ref.line });
503
510
  }
504
511
  }
505
512
  }
@@ -510,13 +517,15 @@ function resolveCharts(ctx) {
510
517
  const ref = typeof block.attrs["data"] === "string" ? block.attrs["data"] : "";
511
518
  const id = ref.replace(/^#/, "");
512
519
  if (id === "") {
513
- ctx.diags.push({ severity: "error", message: "geml-chart: missing `data=#id`", line });
520
+ ctx.diags.push({ severity: "error", code: "chart-missing-data", message: "geml-chart: missing `data=#id`", line });
514
521
  continue;
515
522
  }
516
523
  const table = ctx.tables?.get(id);
517
524
  if (!table) {
518
- const what = ctx.ids.has(id) ? `data target \`#${id}\` is not a table` : `unresolved reference \`#${id}\``;
519
- ctx.diags.push({ severity: "error", message: `geml-chart: ${what}`, line });
525
+ const known = ctx.ids.has(id);
526
+ const what = known ? `data target \`#${id}\` is not a table` : `unresolved reference \`#${id}\``;
527
+ const code = known ? "chart-data-not-a-table" : "unresolved-reference";
528
+ ctx.diags.push({ severity: "error", code, message: `geml-chart: ${what}`, line });
520
529
  continue;
521
530
  }
522
531
  if (table.src !== undefined) {
@@ -533,7 +542,7 @@ function resolveCharts(ctx) {
533
542
  }
534
543
  }
535
544
  export function parse(source, opts = {}) {
536
- const lines = source.replace(/\r\n?/g, "\n").split("\n");
545
+ const lines = normalizeSource(source).split("\n");
537
546
  const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines), resolveDoc: opts.resolveDoc };
538
547
  const children = scanBlocks(lines, 0, ctx);
539
548
  resolveCharts(ctx);
@@ -644,7 +653,7 @@ function collectSpans(lines, base, out, ctx, depth = 0) {
644
653
  // with the physical lines produced by splitLines(source).
645
654
  export function blockSpans(source) {
646
655
  const out = new Map();
647
- const lines = source.replace(/\r\n?/g, "\n").split("\n");
656
+ const lines = normalizeSource(source).split("\n");
648
657
  // Inert context: heading auto-ids slug the interpolated text (parser parity);
649
658
  // its diagnostics are discarded — the span scan never reports.
650
659
  const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines) };
@@ -728,48 +737,50 @@ function parseStamp(s) {
728
737
  return new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +se));
729
738
  }
730
739
  const VERSION = "1.0"; // GEML spec version this CLI targets
731
- const PARSER_VERSION = "1.4.2"; // reference implementation; keep in sync with package.json
732
- const USAGE = `geml — GEML reference CLI
733
-
734
- Usage:
735
- geml <file.geml|-> [--to <fmt>] [--from <fmt>] [-o out] transform a document (default: --to json)
736
- <fmt>: json | html | md | geml
737
- --to md -> Markdown (lossy)
738
- --to html -> self-contained HTML
739
- --to geml -> canonical re-format
740
- --to json -> document-model JSON (default)
741
- A Markdown input converts the other way:
742
- geml notes.md -> GEML
743
- --from overrides the input format (any input):
744
- geml notes.txt --from md treat as Markdown
745
- geml - --from md read Markdown on stdin
746
- geml get <file.geml|-> [#id] [--json] [--head] with #id: print that block
747
- (a heading id = its whole section; --head = head line;
748
- --json = model node). Without #id: list all addressable
749
- ids (--json = array).
750
- geml set <file.geml|-> #id [--head|--body] [--in f[#src]|-] [-o f] replace ONE block by id
751
- (--in F takes F's block #id, F#src takes #src, else stdin raw;
752
- default = whole block · --head = head line · --body = body)
753
- geml add <file.geml|-> (--append | --before #id | --after #id) [--in f[#src]|-] [-o f] insert a fragment
754
- (1+ blocks and/or prose; content keeps its own ids, a clash is refused)
755
- geml delete <file.geml|-> #id [#id2 …] [-o f] remove one or more blocks
756
- (a missing id is skipped; a dangling reference is a warning, not a refusal)
757
- geml rename <file.geml|-> #old #new [-o f] rename an id and every reference to it (id-boundary safe)
758
- geml revert <file.geml> #id [--rev <sel>] [--head] undo one block to a past revision (splice / resurrect / remove)
759
- (sel: -N | latest | id-prefix; default -1)
760
- geml check <file.geml|-> [--root d] [--json] validate only: diagnostics + exit code
761
- (--root widens cross-doc refs to dir d, e.g. the repo root)
762
- geml history <commit|verify|show|restore|log> <file.geml> [...] .gemlhistory version sidecar
763
- geml codemap <build|verify|render|serve|refresh|find|mcp> [...] code-graph toolkit (alias: codegraph)
764
- geml --help | --version [--json]
765
-
766
- Use '-' as the file to read from stdin.
767
- Mutations (set/add/delete/rename) write the whole updated document in place for a
768
- file, or to stdout for '-' input; -o redirects it (-o - = stdout).
769
- Exit codes:
770
- 0 ok
771
- 1 document/operation error
772
- 2 command usage error.
740
+ const PARSER_VERSION = "1.4.3"; // reference implementation; keep in sync with package.json
741
+ const USAGE = `geml — GEML reference CLI
742
+
743
+ Usage:
744
+ geml <file.geml|-> [--to <fmt>] [--from <fmt>] [-o out] transform a document (default: --to json)
745
+ --to <output>: json | html | md | geml
746
+ --to md -> Markdown (lossy)
747
+ --to html -> self-contained HTML
748
+ --to geml -> canonical re-format
749
+ --to json -> document-model JSON (default)
750
+ --from <input>: geml | md | json (overrides extension; html is output-only)
751
+ geml notes.md -> GEML (md inferred from extension)
752
+ geml model.json --to geml -> GEML (round-trips a prior --to json)
753
+ geml - --from md read Markdown on stdin
754
+ geml get <file.geml|-> [#id] [--json] [--head] with #id: print that block
755
+ (a heading id = its whole section; --head = head line;
756
+ --json = model node). Without #id: list all addressable
757
+ ids (--json = array).
758
+ geml set <file.geml|-> #id [--head|--body] [--in f[#src]|-] [-o f] replace ONE block by id
759
+ (--in F takes F's block #id, F#src takes #src, else stdin raw;
760
+ default = whole block · --head = head line · --body = body)
761
+ geml add <file.geml|-> (--append | --before #id | --after #id) [--in f[#src]|-] [-o f] insert a fragment
762
+ (1+ blocks and/or prose; content keeps its own ids, a clash is refused)
763
+ geml delete <file.geml|-> #id [#id2 …] [-o f] remove one or more blocks
764
+ (a missing id is skipped; a dangling reference is a warning, not a refusal)
765
+ geml rename <file.geml|-> #old #new [-o f] rename an id and every reference to it (id-boundary safe)
766
+ geml revert <file.geml> #id [--rev <sel>] [--head] undo one block to a past revision (splice / resurrect / remove)
767
+ (sel: 0 | -N | id-prefix | changed; default -1)
768
+ geml check <file.geml|-> [--root d] [--json] validate only: diagnostics + exit code
769
+ (--root widens cross-doc refs to dir d, e.g. the repo root)
770
+ geml history <commit|verify|show|restore|log> <file.geml> [...] .gemlhistory version sidecar
771
+ geml codemap <build|verify|render|serve|refresh|find|mcp> [...] code-graph toolkit (alias: codegraph)
772
+ geml mcp --workspace <dir> [--no-history] serve document CRUD over MCP (stdio)
773
+ (9 tools: list/read/check/history + write/add/delete/rename/revert;
774
+ every write is validated before it reaches disk)
775
+ geml --help | --version [--json]
776
+
777
+ Use '-' as the file to read from stdin.
778
+ Mutations (set/add/delete/rename) write the whole updated document in place for a
779
+ file, or to stdout for '-' input; -o redirects it (-o - = stdout).
780
+ Exit codes:
781
+ 0 ok
782
+ 1 document/operation error
783
+ 2 command usage error.
773
784
  `;
774
785
  // One-line usage for each subcommand — the single source for both the error
775
786
  // shown on misuse and the `<cmd> --help` text.
@@ -780,17 +791,32 @@ const SUBHELP = {
780
791
  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)",
781
792
  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)",
782
793
  check: "usage: geml check <file.geml|-> [--root <dir>] [--json] (--root: resolve cross-doc refs within <dir> instead of the file's own directory)",
783
- revert: "usage: geml revert <file.geml> #id [--rev <sel>] [--changed] [--append|--before #x|--after #x] [--head] [--dry-run] [-o out] (reconcile #id to a revision: splice / resurrect / remove; sel: -N | latest | id-prefix; default -1)",
794
+ 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)",
784
795
  history: "usage: geml history <commit|verify|show|restore|log> <file.geml> [...]",
785
- 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)
786
- 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]]
787
- geml codemap verify [dir] geml check + profile reference checks
788
- geml codemap render [dir] every doc -> sibling .html (open index.html from disk)
789
- 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
790
- geml codemap refresh [dir] [--force] [--commit] [--background|--hook] re-run the recorded build recipe (_index/refresh.json); --commit lands it as its own commit
791
- geml codemap find <name> [dir] locate a symbol by substring name -> doc#id + src (stdout, no browser)
792
- geml codemap mcp stdio MCP server (GEML_GRAPH_DIR or graph_dir arg)
796
+ 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)
797
+ 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]]
798
+ geml codemap verify [dir] geml check + profile reference checks
799
+ geml codemap render [dir] every doc -> sibling .html (open index.html from disk)
800
+ 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
801
+ geml codemap refresh [dir] [--force] [--commit] [--background|--hook] re-run the recorded build recipe (_index/refresh.json); --commit lands it as its own commit
802
+ geml codemap find <name> [dir] locate a symbol by substring name -> doc#id + src (stdout, no browser)
803
+ geml codemap mcp stdio MCP server (GEML_GRAPH_DIR or graph_dir arg)
793
804
  (<dir> for verify/render/serve/refresh/find defaults to ./.geml-code-graph; codegraph and code-graph are accepted as aliases of codemap)`,
805
+ mcp: `usage: geml mcp --workspace <dir> [--no-history]
806
+
807
+ Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0).
808
+ Nine tools: geml_list_ids · geml_read_block · geml_check · geml_history_log
809
+ geml_write_block · geml_add_block · geml_delete_block
810
+ geml_rename_id · geml_revert_block
811
+
812
+ --workspace <dir> REQUIRED. Root holding the .geml documents. Every path a
813
+ client names is confined here; a client cannot widen it.
814
+ --no-history Skip the .gemlhistory commit taken before each write
815
+ (default: commit, so geml_revert_block always has a
816
+ revision to undo to).
817
+
818
+ Register with a client:
819
+ claude mcp add geml-docs -- geml mcp --workspace /abs/path/to/docs`,
794
820
  };
795
821
  // Set from argv at dispatch time; when true, errors are emitted as a JSON
796
822
  // envelope so an agent that standardizes on --json never has to parse text.
@@ -805,6 +831,19 @@ function fail(msg, code = 2) {
805
831
  console.error(`error: ${msg}`);
806
832
  process.exit(code);
807
833
  }
834
+ // Refuse a mutation whose RESULT would be broken (the pre-write check every
835
+ // mutation runs). Prose mode is the long-standing wording: the first error,
836
+ // phrased by the call site. `--json` additionally carries the FULL diagnostic
837
+ // list with the stable codes of spec Appendix A, so a programmatic caller —
838
+ // `geml mcp` above all — reports what actually broke instead of re-parsing
839
+ // English out of stderr.
840
+ function refuseBroken(prose, errs) {
841
+ if (jsonMode) {
842
+ console.error(JSON.stringify({ error: prose, code: 1, diagnostics: errs }));
843
+ process.exit(1);
844
+ }
845
+ fail(prose, 1);
846
+ }
808
847
  // Read a file, or stdin when the path is "-". On failure emit a clean error.
809
848
  function readInput(file) {
810
849
  try {
@@ -968,10 +1007,10 @@ function runHistory(args) {
968
1007
  console.log(`restored ${file} to ${rev}`);
969
1008
  }
970
1009
  else if (sub === "log") {
971
- // Newest-first, with the `--to` selector for each row in the first column
972
- // (`latest` for the tip, then `-1`, `-2`, …) so the output is copy-paste.
1010
+ // Newest-first, with the `--rev` selector for each row in the first column
1011
+ // (`0` for the tip, then `-1`, `-2`, …) so the output is copy-paste.
973
1012
  for (const r of listRevisions(historyPath)) {
974
- const sel = r.current ? "latest" : `-${r.offset}`;
1013
+ const sel = r.current ? "0" : `-${r.offset}`;
975
1014
  console.log(`${sel.padEnd(7)} ${r.id} ${r.author ?? "-"} ${r.summary ?? ""}`.replace(/\s+$/, ""));
976
1015
  }
977
1016
  }
@@ -994,21 +1033,24 @@ function runTransform(argv) {
994
1033
  // silent fall-through to the default — flag() would return undefined and we
995
1034
  // must not quietly ignore it.
996
1035
  if (argv.includes("--from") && fromRaw === undefined)
997
- fail("--from needs a format (geml | md)", 2);
1036
+ fail("--from needs a format (geml | md | json)", 2);
998
1037
  if (argv.includes("--to") && toRaw === undefined)
999
1038
  fail("--to needs a format (json | html | md | geml)", 2);
1000
1039
  // Input format: an explicit --from wins (for any input, file or stdin), else
1001
1040
  // the file extension, else GEML (covers .geml, unknown extensions, and stdin).
1002
1041
  let inFmt;
1003
1042
  if (fromRaw !== undefined) {
1004
- if (fromRaw !== "geml" && fromRaw !== "md") {
1005
- fail(`--from: unknown input format '${fromRaw}' (want geml | md)`, 2);
1043
+ if (fromRaw !== "geml" && fromRaw !== "md" && fromRaw !== "json") {
1044
+ fail(`--from: unknown input format '${fromRaw}' (want geml | md | json)`, 2);
1006
1045
  }
1007
1046
  inFmt = fromRaw;
1008
1047
  }
1009
1048
  else if (/\.(md|markdown)$/i.test(file)) {
1010
1049
  inFmt = "md";
1011
1050
  }
1051
+ else if (/\.json$/i.test(file)) {
1052
+ inFmt = "json";
1053
+ }
1012
1054
  else {
1013
1055
  inFmt = "geml";
1014
1056
  }
@@ -1021,7 +1063,7 @@ function runTransform(argv) {
1021
1063
  outFmt = toRaw;
1022
1064
  }
1023
1065
  else {
1024
- outFmt = inFmt === "md" ? "geml" : "json";
1066
+ outFmt = inFmt === "geml" ? "json" : "geml"; // geml->json; md/json->geml
1025
1067
  }
1026
1068
  const src = readInput(file);
1027
1069
  // md -> geml is a direct projection, not a parse/serialize round-trip: emit
@@ -1037,7 +1079,10 @@ function runTransform(argv) {
1037
1079
  // project it to the target.
1038
1080
  let notes = [];
1039
1081
  let doc;
1040
- if (inFmt === "md") {
1082
+ if (inFmt === "json") {
1083
+ doc = loadModelJson(src, file); // the inverse of `--to json`
1084
+ }
1085
+ else if (inFmt === "md") {
1041
1086
  const conv = mdToGeml(src);
1042
1087
  notes = conv.notes;
1043
1088
  doc = parse(conv.geml, { resolveDoc: resolverFor(file) });
@@ -1076,6 +1121,28 @@ function runTransform(argv) {
1076
1121
  if (doc.diagnostics.some((d) => d.severity === "error"))
1077
1122
  process.exit(1);
1078
1123
  }
1124
+ // Load a document-model JSON (the exact output of `--to json`) back into a
1125
+ // Document, so `--from json --to geml` is the inverse of a prior `--to json`.
1126
+ // The model is trusted as-is — no re-parse — so a clean round-trip is byte-stable
1127
+ // with `--to geml`. Anything that is not a document model is refused, and any
1128
+ // carried diagnostics are preserved (so a broken doc's JSON stays flagged).
1129
+ function loadModelJson(src, file) {
1130
+ let obj;
1131
+ try {
1132
+ obj = JSON.parse(src);
1133
+ }
1134
+ catch (e) {
1135
+ fail(`--from json: ${file === "-" ? "stdin" : file} is not valid JSON (${e.message})`, 1);
1136
+ }
1137
+ const d = obj;
1138
+ if (!d || typeof d !== "object" || d.kind !== "document" || !Array.isArray(d.children)) {
1139
+ fail(`--from json: not a GEML document-model JSON (expected {"kind":"document","children":[…]})`, 1);
1140
+ }
1141
+ const doc = d;
1142
+ if (!Array.isArray(doc.diagnostics))
1143
+ doc.diagnostics = [];
1144
+ return doc;
1145
+ }
1079
1146
  // Write to `-o out` (with a `wrote` note on stderr) or to stdout.
1080
1147
  function writeOut(text, out) {
1081
1148
  if (out) {
@@ -1412,7 +1479,7 @@ function insertFragment(source, lines, at, fragment, file) {
1412
1479
  const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
1413
1480
  if (errs.length) {
1414
1481
  const first = errs[0];
1415
- fail(`adding the content would break the document: ${first.message} (line ${first.line}); not written`, 1);
1482
+ refuseBroken(`adding the content would break the document: ${first.message} (line ${first.line}); not written`, errs);
1416
1483
  }
1417
1484
  const now = new Set(reparsed.ids);
1418
1485
  const dropped = beforeIds.find((x) => !now.has(x));
@@ -1489,7 +1556,7 @@ function runRename(args) {
1489
1556
  const hp = historyPathFor(file);
1490
1557
  if (existsSync(hp)) {
1491
1558
  try {
1492
- if (blockSpans(resolveContent(hp, "latest").text).has(oldId)) {
1559
+ if (blockSpans(resolveContent(hp, "0").text).has(oldId)) {
1493
1560
  console.error(`warning: #${oldId} has history; revert across this rename is not tracked — see docs`);
1494
1561
  }
1495
1562
  }
@@ -1501,7 +1568,7 @@ function runRename(args) {
1501
1568
  const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
1502
1569
  if (errs.length) {
1503
1570
  const e = errs[0];
1504
- fail(`rename would break the document: ${e.message} (line ${e.line}); not written`, 1);
1571
+ refuseBroken(`rename would break the document: ${e.message} (line ${e.line}); not written`, errs);
1505
1572
  }
1506
1573
  if (!reparsed.ids.includes(newId))
1507
1574
  fail(`rename did not produce #${newId}; not written`, 1);
@@ -1663,7 +1730,7 @@ function spliceBlock(source, id, replacement, file, headOnly = false, guardCount
1663
1730
  const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
1664
1731
  if (errs.length) {
1665
1732
  const first = errs[0];
1666
- fail(`replacement would break the document: ${first.message} (line ${first.line}); not written`, 1);
1733
+ refuseBroken(`replacement would break the document: ${first.message} (line ${first.line}); not written`, errs);
1667
1734
  }
1668
1735
  const now = new Set(reparsed.ids);
1669
1736
  if (!now.has(id))
@@ -1685,19 +1752,26 @@ function spliceBlock(source, id, replacement, file, headOnly = false, guardCount
1685
1752
  }
1686
1753
  return updated;
1687
1754
  }
1688
- // `geml revert <file.geml> #id [--rev <sel>] [--changed] [--dry-run] [-o out] [--history PATH]`
1755
+ // `geml revert <file.geml> #id [--rev <sel>] [--dry-run] [-o out] [--history PATH]`
1689
1756
  // Restore ONE block to a past revision's version — a targeted, guarded splice
1690
- // that leaves the rest of the document untouched. <sel> (default `-1`): `-N` (N
1691
- // revisions back from current), `latest`, or an id prefix/suffix. `--changed`
1692
- // skips revisions that never touched the block, landing on its previous
1693
- // *distinct* version. `--dry-run` prints what would be spliced in, writing
1694
- // nothing. Writes in place by default (revert is a mutation); `-o` redirects.
1757
+ // that leaves the rest of the document untouched. <sel> (default `-1`): `0` (the
1758
+ // tip), `-N` (N revisions back), an id prefix/suffix, or `changed` — a content
1759
+ // selector that skips revisions which never touched the block, landing on its
1760
+ // previous *distinct* version. `--dry-run` prints what would be spliced in,
1761
+ // writing nothing. Writes in place by default (revert is a mutation); `-o` redirects.
1695
1762
  function runRevert(args) {
1696
- const changed = args.includes("--changed");
1697
1763
  const dryRun = args.includes("--dry-run");
1698
1764
  const headOnly = args.includes("--head");
1699
1765
  const out = flag(args, "-o") ?? flag(args, "--out");
1700
1766
  const to = flag(args, "--rev") ?? "-1";
1767
+ // `--rev changed` is a CONTENT selector, not a position: skip commits that
1768
+ // never touched this block, landing on its previous *distinct* version. It is
1769
+ // just a `--rev` value, so it cannot conflict with a positional `-N`.
1770
+ const changed = to === "changed";
1771
+ // The former standalone `--changed` flag is now this value; refuse the old
1772
+ // spelling loudly rather than silently ignoring it (and reverting to -1).
1773
+ if (args.includes("--changed"))
1774
+ fail("--changed is now `--rev changed`", 2);
1701
1775
  const before = flag(args, "--before");
1702
1776
  const after = flag(args, "--after");
1703
1777
  const append = args.includes("--append");
@@ -1753,12 +1827,19 @@ function runRevert(args) {
1753
1827
  };
1754
1828
  // Reconcile #id between now and revision R across the four presence cells.
1755
1829
  if (curBlock === undefined && oldBlock === undefined) {
1756
- fail(`\`${id}\` exists in neither the document nor ${target.id} (try --changed)`, 1);
1830
+ fail(`\`${id}\` exists in neither the document nor ${target.id} (try --rev changed)`, 1);
1757
1831
  }
1758
1832
  // both present -> SPLICE (undo set)
1759
1833
  if (curBlock !== undefined && oldBlock !== undefined) {
1760
1834
  if (oldBlock === curBlock) {
1761
- console.error(`#${id} is unchanged at ${target.id}; nothing to revert${changed ? "" : " (try --rev -2, or --changed)"}`);
1835
+ console.error(`#${id} is unchanged at ${target.id}; nothing to revert${changed ? "" : " (try --rev -2, or --rev changed)"}`);
1836
+ // A no-op still has to PRODUCE the document when an output destination was
1837
+ // asked for: `-o` means "write the result somewhere", and the result of a
1838
+ // no-op revert is the unchanged document. Returning silently here left
1839
+ // `-o -` consumers with exit 0 and empty stdout, which reads as "success,
1840
+ // and the document is now empty".
1841
+ if (out !== undefined)
1842
+ emit(source, `#${id} unchanged`);
1762
1843
  return;
1763
1844
  }
1764
1845
  if (dryRun) {
@@ -1824,7 +1905,7 @@ function runRevert(args) {
1824
1905
  const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
1825
1906
  if (errs.length) {
1826
1907
  const first = errs[0];
1827
- fail(`removing #${id} would break the document: ${first.message} (line ${first.line}); not written`, 1);
1908
+ refuseBroken(`removing #${id} would break the document: ${first.message} (line ${first.line}); not written`, errs);
1828
1909
  }
1829
1910
  const now = new Set(reparsed.ids);
1830
1911
  const dropped = beforeIds.find((x) => x !== id && !now.has(x));
@@ -1893,6 +1974,15 @@ function runCodemap(args) {
1893
1974
  const r = spawnSync(process.execPath, [mod, ...args.slice(1)], { stdio: "inherit" });
1894
1975
  process.exit(r.status ?? 1);
1895
1976
  }
1977
+ // geml mcp: the document-CRUD MCP server. Runs as a child's MAIN module for the
1978
+ // same reason `codemap mcp` does — it owns stdin/stdout for the whole session
1979
+ // (the stdio transport), and dispatching by spawn keeps this module free of a
1980
+ // runtime import cycle (mcp.js imports the parser from here).
1981
+ function runMcp(args) {
1982
+ const mod = join(dirname(fileURLToPath(import.meta.url)), "mcp.js");
1983
+ const r = spawnSync(process.execPath, [mod, ...args], { stdio: "inherit" });
1984
+ process.exit(r.status ?? 1);
1985
+ }
1896
1986
  // npm's unix bin shim is a symlink named plain `geml`, so detect "run as a
1897
1987
  // CLI" by resolving argv[1] to its real path, not by its spelling.
1898
1988
  const entry = (() => {
@@ -1962,6 +2052,9 @@ if (entry && (entry === fileURLToPath(import.meta.url) || entry.endsWith("geml.t
1962
2052
  else if (cmd === "codemap") {
1963
2053
  runCodemap(argv.slice(1));
1964
2054
  }
2055
+ else if (cmd === "mcp") {
2056
+ runMcp(argv.slice(1));
2057
+ }
1965
2058
  else if (cmd !== "-" && !/[.\/\\]/.test(cmd)) {
1966
2059
  // A bare word that is neither a known command nor a path is almost always
1967
2060
  // a mistyped command — say so, don't try to read it as a file. (The
package/dist/history.d.ts CHANGED
@@ -70,6 +70,16 @@ export declare function listRevisions(historyPath: string): RevisionInfo[];
70
70
  /** Resolve a revision selector to its id + reconstructed full text. Selectors:
71
71
  * `-N` (N revisions back from current; `-0` is the tip), `latest`/`current`, or
72
72
  * an unambiguous id prefix/suffix (the same forms `restore` accepts). */
73
+ /** Resolve a revision selector to its id, for EVERY command that takes one.
74
+ *
75
+ * There is exactly one selector grammar — `0` (the tip), `-N` (N revisions
76
+ * back), or an unambiguous revision id (prefix, suffix, or exact) — and it is
77
+ * the grammar `history log` prints in its first column, so its output is
78
+ * copy-pasteable into `revert --rev`, `history show`, and `history restore`
79
+ * alike. Keeping this in one function is what makes that true: it used to be
80
+ * written twice, and the copy in `restore` never grew the `0`/`-N` arm, so the
81
+ * selectors `history log` advertised were rejected by `history show`. */
82
+ export declare function resolveRevision(h: History, selector: string): string;
73
83
  export declare function resolveContent(historyPath: string, selector: string): {
74
84
  id: string;
75
85
  text: string;