@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/LICENSE +21 -21
- package/README.md +186 -155
- package/codemap/adapters/crg.mjs +120 -120
- package/codemap/adapters/joern.mjs +131 -131
- package/codemap/adapters/scip.mjs +658 -658
- package/codemap/browser-stub.mjs +29 -29
- package/codemap/build.mjs +609 -609
- package/codemap/cross-stack.mjs +303 -303
- package/codemap/detect.mjs +399 -399
- package/codemap/emit.mjs +480 -480
- package/codemap/entries.mjs +129 -129
- package/codemap/exclude.mjs +52 -52
- package/codemap/find.mjs +63 -63
- package/codemap/foldings.mjs +110 -110
- package/codemap/joern-export.sc +83 -83
- package/codemap/mcp-server.mjs +172 -172
- package/codemap/normalize.mjs +275 -275
- package/codemap/recipe-trust.mjs +103 -103
- package/codemap/refresh.mjs +310 -310
- package/codemap/render-all.mjs +64 -64
- package/codemap/serve.mjs +578 -578
- package/codemap/sfc-virtualize.mjs +367 -367
- package/codemap/verify.mjs +148 -148
- package/dist/chart.d.ts +2 -0
- package/dist/chart.js +15 -15
- package/dist/diagnostics.d.ts +9 -0
- package/dist/diagnostics.js +73 -0
- package/dist/geml.d.ts +3 -5
- package/dist/geml.js +261 -115
- package/dist/history.d.ts +10 -0
- package/dist/history.js +25 -24
- package/dist/inline.js +7 -7
- package/dist/mcp.d.ts +18 -0
- package/dist/mcp.js +543 -0
- package/dist/render-html.js +35 -35
- package/dist/render.js +136 -136
- package/dist/table.d.ts +2 -0
- package/dist/table.js +11 -11
- package/package.json +63 -62
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
|
|
9
|
-
// media embeds, links, auto-references, footnotes) and build-time
|
|
10
|
-
// validation (§8 — unique ids, resolvable internal/cross-document
|
|
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
|
|
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
|
|
502
|
-
|
|
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
|
|
519
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
geml
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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>] [--
|
|
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 `--
|
|
972
|
-
// (`
|
|
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 ? "
|
|
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 === "
|
|
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 === "
|
|
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
|
|
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] +=
|
|
1509
|
+
before[before.length - 1] += nl;
|
|
1401
1510
|
}
|
|
1402
|
-
let frag = fragment
|
|
1511
|
+
let frag = toNewline(fragment, nl);
|
|
1403
1512
|
if (!frag.endsWith("\n"))
|
|
1404
|
-
frag +=
|
|
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]) ?
|
|
1409
|
-
const sepAfter = after.length && !blank(after[0]) ?
|
|
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
|
-
|
|
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, "
|
|
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
|
-
|
|
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
|
-
|
|
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 +=
|
|
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
|
-
|
|
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>] [--
|
|
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`):
|
|
1691
|
-
// revisions back
|
|
1692
|
-
// skips revisions
|
|
1693
|
-
// *distinct* version. `--dry-run` prints what would be spliced in,
|
|
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
|
-
|
|
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(
|
|
1899
|
+
process.stdout.write(replacement.endsWith("\n") ? replacement : replacement + "\n");
|
|
1767
1900
|
return;
|
|
1768
1901
|
}
|
|
1769
|
-
emit(spliceBlock(source, 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(
|
|
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,
|
|
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
|
-
|
|
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
|