@geml/geml 1.4.2 → 1.4.4

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) };
@@ -659,6 +668,22 @@ export function blockSpans(source) {
659
668
  function splitLines(source) {
660
669
  return source.split(/(?<=\n|\r(?!\n))/);
661
670
  }
671
+ // Newline handling lives HERE, in one place, because it is easy to get subtly
672
+ // wrong in each caller. Content reaching a mutation is often LF even when the
673
+ // document is not: a history revision is stored newline-normalized, `--in` may
674
+ // come from either kind of file, stdin from anywhere. So: detect the DOCUMENT's
675
+ // style, compare on the normalized (LF) form, and convert back on the way in —
676
+ // which is what keeps a CRLF document from ending up half CRLF, half LF.
677
+ function newlineOf(text) {
678
+ return /\r\n/.test(text) ? "\r\n" : "\n";
679
+ }
680
+ function toLf(text) {
681
+ return text.replace(/\r\n?/g, "\n");
682
+ }
683
+ function toNewline(text, nl) {
684
+ const lf = toLf(text);
685
+ return nl === "\n" ? lf : lf.replace(/\n/g, nl);
686
+ }
662
687
  // `--head`: narrow any id's span to its HEAD line — the single declaring line
663
688
  // (a heading's `# … {#id}` line, a typed block's opening fence, a footnote's
664
689
  // `[^id]:` line). The head is by construction the FIRST line of the span, so
@@ -728,48 +753,75 @@ function parseStamp(s) {
728
753
  return new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +se));
729
754
  }
730
755
  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.
756
+ // The published version, read from package.json rather than restated here.
757
+ // "Keep in sync with package.json" was a comment, and comments do not run: this
758
+ // literal said 1.4.3 while the MCP server's own copy still said 0.1.0.
759
+ // Resolved from this module's location — `dist/geml.js` -> `../package.json`,
760
+ // and npm always ships package.json whatever `files` says. In a browser bundle
761
+ // `import.meta.url` degenerates to "" (see the `entry` note below), so every
762
+ // lookup fails and we fall back rather than throw at import time.
763
+ export const PARSER_VERSION = (() => {
764
+ let dir;
765
+ try {
766
+ dir = dirname(fileURLToPath(import.meta.url));
767
+ }
768
+ catch {
769
+ return "0.0.0";
770
+ }
771
+ for (let i = 0; i < 3 && dir; i++) {
772
+ try {
773
+ const v = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).version;
774
+ if (typeof v === "string" && v)
775
+ return v;
776
+ }
777
+ catch { /* not here walk up */ }
778
+ dir = dirname(dir);
779
+ }
780
+ return "0.0.0";
781
+ })();
782
+ const USAGE = `geml GEML reference CLI
783
+
784
+ Usage:
785
+ geml <file.geml|-> [--to <fmt>] [--from <fmt>] [-o out] transform a document (default: --to json)
786
+ --to <output>: json | html | md | geml
787
+ --to md -> Markdown (lossy)
788
+ --to html -> self-contained HTML
789
+ --to geml -> canonical re-format
790
+ --to json -> document-model JSON (default)
791
+ --from <input>: geml | md | json (overrides extension; html is output-only)
792
+ geml notes.md -> GEML (md inferred from extension)
793
+ geml model.json --to geml -> GEML (round-trips a prior --to json)
794
+ geml - --from md read Markdown on stdin
795
+ geml get <file.geml|-> [#id] [--json] [--head] with #id: print that block
796
+ (a heading id = its whole section; --head = head line;
797
+ --json = model node). Without #id: list all addressable
798
+ ids (--json = array).
799
+ geml set <file.geml|-> #id [--head|--body] [--in f[#src]|-] [-o f] replace ONE block by id
800
+ (--in F takes F's block #id, F#src takes #src, else stdin raw;
801
+ default = whole block · --head = head line · --body = body)
802
+ geml add <file.geml|-> (--append | --before #id | --after #id) [--in f[#src]|-] [-o f] insert a fragment
803
+ (1+ blocks and/or prose; content keeps its own ids, a clash is refused)
804
+ geml delete <file.geml|-> #id [#id2 …] [-o f] remove one or more blocks
805
+ (a missing id is skipped; a dangling reference is a warning, not a refusal)
806
+ geml rename <file.geml|-> #old #new [-o f] rename an id and every reference to it (id-boundary safe)
807
+ geml revert <file.geml> #id [--rev <sel>] [--head] undo one block to a past revision (splice / resurrect / remove)
808
+ (sel: 0 | -N | id-prefix | changed; default -1)
809
+ geml check <file.geml|-> [--root d] [--json] validate only: diagnostics + exit code
810
+ (--root widens cross-doc refs to dir d, e.g. the repo root)
811
+ geml history <commit|verify|show|restore|log> <file.geml> [...] .gemlhistory version sidecar
812
+ geml codemap <build|verify|render|serve|refresh|find|mcp> [...] code-graph toolkit (alias: codegraph)
813
+ geml mcp --root <dir> [--no-history] serve document CRUD over MCP (stdio)
814
+ (9 tools: list/read/check/history + write/add/delete/rename/revert;
815
+ every write is validated before it reaches disk)
816
+ geml --help | --version [--json]
817
+
818
+ Use '-' as the file to read from stdin.
819
+ Mutations (set/add/delete/rename) write the whole updated document in place for a
820
+ file, or to stdout for '-' input; -o redirects it (-o - = stdout).
821
+ Exit codes:
822
+ 0 ok
823
+ 1 document/operation error
824
+ 2 command usage error.
773
825
  `;
774
826
  // One-line usage for each subcommand — the single source for both the error
775
827
  // shown on misuse and the `<cmd> --help` text.
@@ -780,17 +832,32 @@ const SUBHELP = {
780
832
  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
833
  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
834
  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)",
835
+ 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
836
  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)
837
+ 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)
838
+ 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]]
839
+ geml codemap verify [dir] geml check + profile reference checks
840
+ geml codemap render [dir] every doc -> sibling .html (open index.html from disk)
841
+ 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
842
+ geml codemap refresh [dir] [--force] [--commit] [--background|--hook] re-run the recorded build recipe (_index/refresh.json); --commit lands it as its own commit
843
+ geml codemap find <name> [dir] locate a symbol by substring name -> doc#id + src (stdout, no browser)
844
+ geml codemap mcp stdio MCP server (GEML_GRAPH_DIR or graph_dir arg)
793
845
  (<dir> for verify/render/serve/refresh/find defaults to ./.geml-code-graph; codegraph and code-graph are accepted as aliases of codemap)`,
846
+ mcp: `usage: geml mcp --root <dir> [--no-history]
847
+
848
+ Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0).
849
+ Nine tools: geml_list_ids · geml_read_block · geml_check · geml_history_log
850
+ geml_write_block · geml_add_block · geml_delete_block
851
+ geml_rename_id · geml_revert_block
852
+
853
+ --root <dir> REQUIRED. Root holding the .geml documents. Every path a
854
+ client names is confined here; a client cannot widen it.
855
+ --no-history Skip the .gemlhistory commit taken before each write
856
+ (default: commit, so geml_revert_block always has a
857
+ revision to undo to).
858
+
859
+ Register with a client:
860
+ claude mcp add geml -- geml mcp --root /abs/path/to/docs`,
794
861
  };
795
862
  // Set from argv at dispatch time; when true, errors are emitted as a JSON
796
863
  // envelope so an agent that standardizes on --json never has to parse text.
@@ -805,6 +872,19 @@ function fail(msg, code = 2) {
805
872
  console.error(`error: ${msg}`);
806
873
  process.exit(code);
807
874
  }
875
+ // Refuse a mutation whose RESULT would be broken (the pre-write check every
876
+ // mutation runs). Prose mode is the long-standing wording: the first error,
877
+ // phrased by the call site. `--json` additionally carries the FULL diagnostic
878
+ // list with the stable codes of spec Appendix A, so a programmatic caller —
879
+ // `geml mcp` above all — reports what actually broke instead of re-parsing
880
+ // English out of stderr.
881
+ function refuseBroken(prose, errs) {
882
+ if (jsonMode) {
883
+ console.error(JSON.stringify({ error: prose, code: 1, diagnostics: errs }));
884
+ process.exit(1);
885
+ }
886
+ fail(prose, 1);
887
+ }
808
888
  // Read a file, or stdin when the path is "-". On failure emit a clean error.
809
889
  function readInput(file) {
810
890
  try {
@@ -968,10 +1048,10 @@ function runHistory(args) {
968
1048
  console.log(`restored ${file} to ${rev}`);
969
1049
  }
970
1050
  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.
1051
+ // Newest-first, with the `--rev` selector for each row in the first column
1052
+ // (`0` for the tip, then `-1`, `-2`, …) so the output is copy-paste.
973
1053
  for (const r of listRevisions(historyPath)) {
974
- const sel = r.current ? "latest" : `-${r.offset}`;
1054
+ const sel = r.current ? "0" : `-${r.offset}`;
975
1055
  console.log(`${sel.padEnd(7)} ${r.id} ${r.author ?? "-"} ${r.summary ?? ""}`.replace(/\s+$/, ""));
976
1056
  }
977
1057
  }
@@ -994,21 +1074,24 @@ function runTransform(argv) {
994
1074
  // silent fall-through to the default — flag() would return undefined and we
995
1075
  // must not quietly ignore it.
996
1076
  if (argv.includes("--from") && fromRaw === undefined)
997
- fail("--from needs a format (geml | md)", 2);
1077
+ fail("--from needs a format (geml | md | json)", 2);
998
1078
  if (argv.includes("--to") && toRaw === undefined)
999
1079
  fail("--to needs a format (json | html | md | geml)", 2);
1000
1080
  // Input format: an explicit --from wins (for any input, file or stdin), else
1001
1081
  // the file extension, else GEML (covers .geml, unknown extensions, and stdin).
1002
1082
  let inFmt;
1003
1083
  if (fromRaw !== undefined) {
1004
- if (fromRaw !== "geml" && fromRaw !== "md") {
1005
- fail(`--from: unknown input format '${fromRaw}' (want geml | md)`, 2);
1084
+ if (fromRaw !== "geml" && fromRaw !== "md" && fromRaw !== "json") {
1085
+ fail(`--from: unknown input format '${fromRaw}' (want geml | md | json)`, 2);
1006
1086
  }
1007
1087
  inFmt = fromRaw;
1008
1088
  }
1009
1089
  else if (/\.(md|markdown)$/i.test(file)) {
1010
1090
  inFmt = "md";
1011
1091
  }
1092
+ else if (/\.json$/i.test(file)) {
1093
+ inFmt = "json";
1094
+ }
1012
1095
  else {
1013
1096
  inFmt = "geml";
1014
1097
  }
@@ -1021,7 +1104,7 @@ function runTransform(argv) {
1021
1104
  outFmt = toRaw;
1022
1105
  }
1023
1106
  else {
1024
- outFmt = inFmt === "md" ? "geml" : "json";
1107
+ outFmt = inFmt === "geml" ? "json" : "geml"; // geml->json; md/json->geml
1025
1108
  }
1026
1109
  const src = readInput(file);
1027
1110
  // md -> geml is a direct projection, not a parse/serialize round-trip: emit
@@ -1037,7 +1120,10 @@ function runTransform(argv) {
1037
1120
  // project it to the target.
1038
1121
  let notes = [];
1039
1122
  let doc;
1040
- if (inFmt === "md") {
1123
+ if (inFmt === "json") {
1124
+ doc = loadModelJson(src, file); // the inverse of `--to json`
1125
+ }
1126
+ else if (inFmt === "md") {
1041
1127
  const conv = mdToGeml(src);
1042
1128
  notes = conv.notes;
1043
1129
  doc = parse(conv.geml, { resolveDoc: resolverFor(file) });
@@ -1076,6 +1162,28 @@ function runTransform(argv) {
1076
1162
  if (doc.diagnostics.some((d) => d.severity === "error"))
1077
1163
  process.exit(1);
1078
1164
  }
1165
+ // Load a document-model JSON (the exact output of `--to json`) back into a
1166
+ // Document, so `--from json --to geml` is the inverse of a prior `--to json`.
1167
+ // The model is trusted as-is — no re-parse — so a clean round-trip is byte-stable
1168
+ // with `--to geml`. Anything that is not a document model is refused, and any
1169
+ // carried diagnostics are preserved (so a broken doc's JSON stays flagged).
1170
+ function loadModelJson(src, file) {
1171
+ let obj;
1172
+ try {
1173
+ obj = JSON.parse(src);
1174
+ }
1175
+ catch (e) {
1176
+ fail(`--from json: ${file === "-" ? "stdin" : file} is not valid JSON (${e.message})`, 1);
1177
+ }
1178
+ const d = obj;
1179
+ if (!d || typeof d !== "object" || d.kind !== "document" || !Array.isArray(d.children)) {
1180
+ fail(`--from json: not a GEML document-model JSON (expected {"kind":"document","children":[…]})`, 1);
1181
+ }
1182
+ const doc = d;
1183
+ if (!Array.isArray(doc.diagnostics))
1184
+ doc.diagnostics = [];
1185
+ return doc;
1186
+ }
1079
1187
  // Write to `-o out` (with a `wrote` note on stderr) or to stdout.
1080
1188
  function writeOut(text, out) {
1081
1189
  if (out) {
@@ -1327,7 +1435,7 @@ function runSetBody(source, id, from, rawChannel, file, out) {
1327
1435
  let head = headLine;
1328
1436
  if (head !== "" && !/(\r\n|\r|\n)$/.test(head))
1329
1437
  head += "\n";
1330
- let b = body.replace(/\r\n?/g, "\n");
1438
+ let b = toLf(body); // spliceBlock converts the result to the document's style
1331
1439
  if (closeLine !== null && b !== "" && !b.endsWith("\n"))
1332
1440
  b += "\n";
1333
1441
  const replacement = closeLine !== null ? head + b + closeLine : head + b;
@@ -1395,24 +1503,25 @@ function insertFragment(source, lines, at, fragment, file) {
1395
1503
  const beforeIds = parse(source, { resolveDoc: resolverFor(file) }).ids;
1396
1504
  const before = lines.slice(0, at);
1397
1505
  const after = lines.slice(at);
1506
+ const nl = newlineOf(source); // the fragment AND every separator we add
1398
1507
  // The preceding line must end in a newline so the fragment starts on its own.
1399
1508
  if (before.length && !/(\r\n|\r|\n)$/.test(before[before.length - 1])) {
1400
- before[before.length - 1] += "\n";
1509
+ before[before.length - 1] += nl;
1401
1510
  }
1402
- let frag = fragment.replace(/\r\n?/g, "\n");
1511
+ let frag = toNewline(fragment, nl);
1403
1512
  if (!frag.endsWith("\n"))
1404
- frag += "\n";
1513
+ frag += nl;
1405
1514
  // A single blank separator on each side that has adjacent content and isn't
1406
1515
  // already blank — keeps a following head / preceding block from fusing.
1407
1516
  const blank = (s) => stripEol(s).trim() === "";
1408
- const sepBefore = before.length && !blank(before[before.length - 1]) ? "\n" : "";
1409
- const sepAfter = after.length && !blank(after[0]) ? "\n" : "";
1517
+ const sepBefore = before.length && !blank(before[before.length - 1]) ? nl : "";
1518
+ const sepAfter = after.length && !blank(after[0]) ? nl : "";
1410
1519
  const updated = before.join("") + sepBefore + frag + sepAfter + after.join("");
1411
1520
  const reparsed = parse(updated, { resolveDoc: resolverFor(file) });
1412
1521
  const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
1413
1522
  if (errs.length) {
1414
1523
  const first = errs[0];
1415
- fail(`adding the content would break the document: ${first.message} (line ${first.line}); not written`, 1);
1524
+ refuseBroken(`adding the content would break the document: ${first.message} (line ${first.line}); not written`, errs);
1416
1525
  }
1417
1526
  const now = new Set(reparsed.ids);
1418
1527
  const dropped = beforeIds.find((x) => !now.has(x));
@@ -1489,7 +1598,7 @@ function runRename(args) {
1489
1598
  const hp = historyPathFor(file);
1490
1599
  if (existsSync(hp)) {
1491
1600
  try {
1492
- if (blockSpans(resolveContent(hp, "latest").text).has(oldId)) {
1601
+ if (blockSpans(resolveContent(hp, "0").text).has(oldId)) {
1493
1602
  console.error(`warning: #${oldId} has history; revert across this rename is not tracked — see docs`);
1494
1603
  }
1495
1604
  }
@@ -1501,7 +1610,7 @@ function runRename(args) {
1501
1610
  const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
1502
1611
  if (errs.length) {
1503
1612
  const e = errs[0];
1504
- fail(`rename would break the document: ${e.message} (line ${e.line}); not written`, 1);
1613
+ refuseBroken(`rename would break the document: ${e.message} (line ${e.line}); not written`, errs);
1505
1614
  }
1506
1615
  if (!reparsed.ids.includes(newId))
1507
1616
  fail(`rename did not produce #${newId}; not written`, 1);
@@ -1650,10 +1759,11 @@ function spliceBlock(source, id, replacement, file, headOnly = false, guardCount
1650
1759
  const span = headOnly ? narrowToHead(found) : found;
1651
1760
  const before = orig.slice(0, span.start);
1652
1761
  const after = orig.slice(span.end);
1653
- let inject = replacement.replace(/\r\n?/g, "\n");
1762
+ const nl = newlineOf(source); // adopt the document's style, not LF
1763
+ let inject = toNewline(replacement, nl);
1654
1764
  const lastLine = span.end >= orig.length;
1655
1765
  if (!inject.endsWith("\n") && !lastLine)
1656
- inject += "\n";
1766
+ inject += nl;
1657
1767
  const updated = before.join("") + inject + after.join("");
1658
1768
  // Re-parse and refuse a broken result. A parse error or a duplicate id both
1659
1769
  // surface as error diagnostics (registerId flags dups); one check covers both.
@@ -1663,7 +1773,7 @@ function spliceBlock(source, id, replacement, file, headOnly = false, guardCount
1663
1773
  const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
1664
1774
  if (errs.length) {
1665
1775
  const first = errs[0];
1666
- fail(`replacement would break the document: ${first.message} (line ${first.line}); not written`, 1);
1776
+ refuseBroken(`replacement would break the document: ${first.message} (line ${first.line}); not written`, errs);
1667
1777
  }
1668
1778
  const now = new Set(reparsed.ids);
1669
1779
  if (!now.has(id))
@@ -1685,19 +1795,26 @@ function spliceBlock(source, id, replacement, file, headOnly = false, guardCount
1685
1795
  }
1686
1796
  return updated;
1687
1797
  }
1688
- // `geml revert <file.geml> #id [--rev <sel>] [--changed] [--dry-run] [-o out] [--history PATH]`
1798
+ // `geml revert <file.geml> #id [--rev <sel>] [--dry-run] [-o out] [--history PATH]`
1689
1799
  // 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.
1800
+ // that leaves the rest of the document untouched. <sel> (default `-1`): `0` (the
1801
+ // tip), `-N` (N revisions back), an id prefix/suffix, or `changed` — a content
1802
+ // selector that skips revisions which never touched the block, landing on its
1803
+ // previous *distinct* version. `--dry-run` prints what would be spliced in,
1804
+ // writing nothing. Writes in place by default (revert is a mutation); `-o` redirects.
1695
1805
  function runRevert(args) {
1696
- const changed = args.includes("--changed");
1697
1806
  const dryRun = args.includes("--dry-run");
1698
1807
  const headOnly = args.includes("--head");
1699
1808
  const out = flag(args, "-o") ?? flag(args, "--out");
1700
1809
  const to = flag(args, "--rev") ?? "-1";
1810
+ // `--rev changed` is a CONTENT selector, not a position: skip commits that
1811
+ // never touched this block, landing on its previous *distinct* version. It is
1812
+ // just a `--rev` value, so it cannot conflict with a positional `-N`.
1813
+ const changed = to === "changed";
1814
+ // The former standalone `--changed` flag is now this value; refuse the old
1815
+ // spelling loudly rather than silently ignoring it (and reverting to -1).
1816
+ if (args.includes("--changed"))
1817
+ fail("--changed is now `--rev changed`", 2);
1701
1818
  const before = flag(args, "--before");
1702
1819
  const after = flag(args, "--after");
1703
1820
  const append = args.includes("--append");
@@ -1712,6 +1829,13 @@ function runRevert(args) {
1712
1829
  const id = rawId.replace(/^#/, "");
1713
1830
  const historyPath = flag(args, "--history") ?? historyPathFor(file);
1714
1831
  const source = readInput(file);
1832
+ // The sidecar stores every revision newline-NORMALIZED (history.ts), so a
1833
+ // revision's text always comes back LF while the working file may be CRLF.
1834
+ // Comparing those raw would make EVERY block look changed on a CRLF document
1835
+ // (`--rev changed` reverting blocks nobody touched, and the no-op check never
1836
+ // firing), so compare normalized and write back in the file's own style.
1837
+ const norm = toLf; // compare on the LF form
1838
+ const toFileNl = (s) => toNewline(s, newlineOf(source));
1715
1839
  const curFull = blockSpans(source).get(id); // undefined => absent now
1716
1840
  const curBlock = curFull === undefined ? undefined : (() => {
1717
1841
  const span = headOnly ? narrowToHead(curFull) : curFull;
@@ -1730,7 +1854,8 @@ function runRevert(args) {
1730
1854
  const target = (() => {
1731
1855
  try {
1732
1856
  if (changed) {
1733
- const found = firstChangedContent(historyPath, curBlock ?? "", pick);
1857
+ // `pick` reads normalized revision text, so normalize this side too.
1858
+ const found = firstChangedContent(historyPath, curBlock === undefined ? "" : norm(curBlock), pick);
1734
1859
  if (!found)
1735
1860
  fail(`no earlier revision changes \`${id}\``, 1);
1736
1861
  return found;
@@ -1753,20 +1878,28 @@ function runRevert(args) {
1753
1878
  };
1754
1879
  // Reconcile #id between now and revision R across the four presence cells.
1755
1880
  if (curBlock === undefined && oldBlock === undefined) {
1756
- fail(`\`${id}\` exists in neither the document nor ${target.id} (try --changed)`, 1);
1881
+ fail(`\`${id}\` exists in neither the document nor ${target.id} (try --rev changed)`, 1);
1757
1882
  }
1758
1883
  // both present -> SPLICE (undo set)
1759
1884
  if (curBlock !== undefined && oldBlock !== undefined) {
1760
- if (oldBlock === curBlock) {
1761
- console.error(`#${id} is unchanged at ${target.id}; nothing to revert${changed ? "" : " (try --rev -2, or --changed)"}`);
1885
+ if (norm(oldBlock) === norm(curBlock)) {
1886
+ console.error(`#${id} is unchanged at ${target.id}; nothing to revert${changed ? "" : " (try --rev -2, or --rev changed)"}`);
1887
+ // A no-op still has to PRODUCE the document when an output destination was
1888
+ // asked for: `-o` means "write the result somewhere", and the result of a
1889
+ // no-op revert is the unchanged document. Returning silently here left
1890
+ // `-o -` consumers with exit 0 and empty stdout, which reads as "success,
1891
+ // and the document is now empty".
1892
+ if (out !== undefined)
1893
+ emit(source, `#${id} unchanged`);
1762
1894
  return;
1763
1895
  }
1896
+ const replacement = toFileNl(oldBlock); // keep the file's newline style
1764
1897
  if (dryRun) {
1765
1898
  console.error(`would revert #${id} to ${target.id}:`);
1766
- process.stdout.write(oldBlock.endsWith("\n") ? oldBlock : oldBlock + "\n");
1899
+ process.stdout.write(replacement.endsWith("\n") ? replacement : replacement + "\n");
1767
1900
  return;
1768
1901
  }
1769
- emit(spliceBlock(source, id, oldBlock, file, headOnly), `reverted #${id} to ${target.id}`);
1902
+ emit(spliceBlock(source, id, replacement, file, headOnly), `reverted #${id} to ${target.id}`);
1770
1903
  return;
1771
1904
  }
1772
1905
  // --head is only meaningful for the splice cell (it can't resurrect or remove).
@@ -1778,24 +1911,25 @@ function runRevert(args) {
1778
1911
  // Guard: if the block we'd resurrect is the same (modulo id) as one already
1779
1912
  // present under a different id, #id was likely renamed away — resurrecting
1780
1913
  // would duplicate it. Point at `rename` instead of writing.
1781
- const cmpKey = normalizeBlockId(oldBlock, "__cmp__");
1914
+ const cmpKey = normalizeBlockId(norm(oldBlock), "__cmp__");
1782
1915
  for (const [cid, cs] of blockSpans(source)) {
1783
1916
  if (cid === id)
1784
1917
  continue;
1785
1918
  const csrc = splitLines(source).slice(cs.start, cs.end).join("");
1786
- if (normalizeBlockId(csrc, "__cmp__") === cmpKey) {
1919
+ if (normalizeBlockId(norm(csrc), "__cmp__") === cmpKey) {
1787
1920
  fail(`#${id} looks renamed to #${cid}; use 'rename #${cid} #${id}' to undo the rename`, 1);
1788
1921
  }
1789
1922
  }
1790
1923
  const { at, where, warn } = resurrectPosition(source, target.text, id, before, after, append, file);
1924
+ const fragment = toFileNl(oldBlock); // keep the file's newline style
1791
1925
  if (dryRun) {
1792
1926
  console.error(`would resurrect #${id} from ${target.id} at ${where}:`);
1793
- process.stdout.write(oldBlock.endsWith("\n") ? oldBlock : oldBlock + "\n");
1927
+ process.stdout.write(fragment.endsWith("\n") ? fragment : fragment + "\n");
1794
1928
  return;
1795
1929
  }
1796
1930
  if (warn)
1797
1931
  console.error(`warning: anchors for #${id} are gone; appended at end`);
1798
- emit(insertFragment(source, splitLines(source), at, oldBlock, file), `resurrected #${id} from ${target.id} at ${where}`);
1932
+ emit(insertFragment(source, splitLines(source), at, fragment, file), `resurrected #${id} from ${target.id} at ${where}`);
1799
1933
  return;
1800
1934
  }
1801
1935
  // present now, absent at R -> REMOVE (undo add)
@@ -1803,7 +1937,7 @@ function runRevert(args) {
1803
1937
  // under a different id, #id was likely renamed IN — removing would delete a
1804
1938
  // renamed block. Point at `rename` instead (the dangerous direction).
1805
1939
  {
1806
- const cmpKey = normalizeBlockId(curBlock, "__cmp__");
1940
+ const cmpKey = normalizeBlockId(norm(curBlock), "__cmp__");
1807
1941
  for (const [rid, rs] of blockSpans(target.text)) {
1808
1942
  if (rid === id)
1809
1943
  continue;
@@ -1824,7 +1958,7 @@ function runRevert(args) {
1824
1958
  const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
1825
1959
  if (errs.length) {
1826
1960
  const first = errs[0];
1827
- fail(`removing #${id} would break the document: ${first.message} (line ${first.line}); not written`, 1);
1961
+ refuseBroken(`removing #${id} would break the document: ${first.message} (line ${first.line}); not written`, errs);
1828
1962
  }
1829
1963
  const now = new Set(reparsed.ids);
1830
1964
  const dropped = beforeIds.find((x) => x !== id && !now.has(x));
@@ -1893,6 +2027,15 @@ function runCodemap(args) {
1893
2027
  const r = spawnSync(process.execPath, [mod, ...args.slice(1)], { stdio: "inherit" });
1894
2028
  process.exit(r.status ?? 1);
1895
2029
  }
2030
+ // geml mcp: the document-CRUD MCP server. Runs as a child's MAIN module for the
2031
+ // same reason `codemap mcp` does — it owns stdin/stdout for the whole session
2032
+ // (the stdio transport), and dispatching by spawn keeps this module free of a
2033
+ // runtime import cycle (mcp.js imports the parser from here).
2034
+ function runMcp(args) {
2035
+ const mod = join(dirname(fileURLToPath(import.meta.url)), "mcp.js");
2036
+ const r = spawnSync(process.execPath, [mod, ...args], { stdio: "inherit" });
2037
+ process.exit(r.status ?? 1);
2038
+ }
1896
2039
  // npm's unix bin shim is a symlink named plain `geml`, so detect "run as a
1897
2040
  // CLI" by resolving argv[1] to its real path, not by its spelling.
1898
2041
  const entry = (() => {
@@ -1962,6 +2105,9 @@ if (entry && (entry === fileURLToPath(import.meta.url) || entry.endsWith("geml.t
1962
2105
  else if (cmd === "codemap") {
1963
2106
  runCodemap(argv.slice(1));
1964
2107
  }
2108
+ else if (cmd === "mcp") {
2109
+ runMcp(argv.slice(1));
2110
+ }
1965
2111
  else if (cmd !== "-" && !/[.\/\\]/.test(cmd)) {
1966
2112
  // A bare word that is neither a known command nor a path is almost always
1967
2113
  // a mistyped command — say so, don't try to read it as a file. (The