@geml/geml 1.0.0 → 1.3.2
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 +109 -72
- package/codemap/adapters/crg.mjs +120 -0
- package/codemap/adapters/joern.mjs +131 -0
- package/codemap/adapters/scip.mjs +658 -0
- package/codemap/browser-stub.mjs +29 -0
- package/codemap/build.mjs +579 -0
- package/codemap/detect.mjs +399 -0
- package/codemap/emit.mjs +432 -0
- package/codemap/entries.mjs +129 -0
- package/codemap/exclude.mjs +52 -0
- package/codemap/find.mjs +63 -0
- package/codemap/foldings.mjs +110 -0
- package/codemap/joern-export.sc +83 -0
- package/codemap/mcp-server.mjs +172 -0
- package/codemap/normalize.mjs +272 -0
- package/codemap/recipe-trust.mjs +103 -0
- package/codemap/refresh.mjs +310 -0
- package/codemap/render-all.mjs +64 -0
- package/codemap/serve.mjs +578 -0
- package/codemap/sfc-virtualize.mjs +367 -0
- package/codemap/verify.mjs +143 -0
- package/dist/from-md.js +66 -7
- package/dist/geml.d.ts +7 -1
- package/dist/geml.js +700 -52
- package/dist/history.d.ts +30 -0
- package/dist/history.js +212 -32
- package/dist/inline.d.ts +2 -1
- package/dist/inline.js +61 -8
- package/dist/render-html.d.ts +3 -0
- package/dist/render-html.js +95 -0
- package/dist/render.d.ts +91 -2
- package/dist/render.js +1916 -50
- package/dist/serialize.js +22 -2
- package/dist/table.js +40 -5
- package/dist/to-md.js +5 -3
- package/package.json +63 -54
package/dist/geml.js
CHANGED
|
@@ -8,19 +8,21 @@
|
|
|
8
8
|
// M2: inline parsing of flow blocks (§5 — emphasis/strong/strike, code, math,
|
|
9
9
|
// media embeds, links, auto-references, footnotes) and build-time reference
|
|
10
10
|
// validation (§8 — unique ids, resolvable internal/cross-document references).
|
|
11
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
12
|
-
import { basename, dirname, resolve as resolvePath } from "node:path";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
11
|
+
import { readFileSync, writeFileSync, realpathSync, statSync } from "node:fs";
|
|
12
|
+
import { basename, dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import { commit, restore, verify, listRevisions, resolveContent, firstChangedContent } from "./history.js";
|
|
16
|
+
import { renderHtml } from "./render-html.js";
|
|
15
17
|
import { coerce, parseAttrs } from "./attrs.js";
|
|
16
|
-
import { parseInline } from "./inline.js";
|
|
18
|
+
import { META_REF_SRC, parseInline } from "./inline.js";
|
|
17
19
|
import { parseTable } from "./table.js";
|
|
18
20
|
import { buildChart } from "./chart.js";
|
|
19
21
|
import { mdToGeml } from "./from-md.js";
|
|
20
22
|
import { serialize } from "./serialize.js";
|
|
21
23
|
import { gemlToMd } from "./to-md.js";
|
|
22
24
|
export { mdToGeml } from "./from-md.js";
|
|
23
|
-
export { renderHtml } from "./render.js";
|
|
25
|
+
export { renderHtml } from "./render-html.js";
|
|
24
26
|
export { serialize } from "./serialize.js";
|
|
25
27
|
export { gemlToMd } from "./to-md.js";
|
|
26
28
|
// Type registry: which body mode each typed block uses. Unknown types are a
|
|
@@ -32,18 +34,23 @@ const REGISTRY = {
|
|
|
32
34
|
table: "raw", // structured table parsing lands in M3
|
|
33
35
|
output: "raw", // captured result of a code block (stored, never executed)
|
|
34
36
|
note: "flow",
|
|
35
|
-
|
|
37
|
+
text: "flow", // addressable prose container: an id/attrs for a run of flow, no callout chrome
|
|
36
38
|
meta: "data",
|
|
37
39
|
};
|
|
38
40
|
// §7: built-in diagram renderer registry. Unknown formats are a warning (the
|
|
39
41
|
// processor keeps the body raw rather than interpreting it).
|
|
40
|
-
const DIAGRAM_RENDERERS = new Set(["mermaid", "graphviz", "dot", "d2", "plantuml", "geml-chart"]);
|
|
42
|
+
const DIAGRAM_RENDERERS = new Set(["mermaid", "graphviz", "dot", "d2", "plantuml", "geml-chart", "geml-code-graph"]);
|
|
41
43
|
// ---------------------------------------------------------------------------
|
|
42
44
|
// Lexical helpers
|
|
43
45
|
// ---------------------------------------------------------------------------
|
|
44
46
|
const FENCE_OPEN = /^(={3,})[ \t]+([A-Za-z][A-Za-z0-9_-]*)[ \t]*(\{.*\})?[ \t]*$/;
|
|
45
47
|
const HEADING = /^(#{1,6})[ \t]+(.*?)[ \t]*(\{[^}]*\})?[ \t]*$/;
|
|
46
48
|
const LIST_ITEM = /^[ \t]*(?:[-*]|\d+\.)[ \t]+(.*)$/;
|
|
49
|
+
// Maximum block/list nesting depth the recursive-descent scanner will build
|
|
50
|
+
// before emitting a diagnostic instead of recursing further. Guards parse()
|
|
51
|
+
// (scanBlocks / parseList) and, in step, the renderer against a deeply nested
|
|
52
|
+
// document overflowing the call stack (DoS). 256 is far past any real document.
|
|
53
|
+
const MAX_NESTING = 256;
|
|
47
54
|
function isCloseFence(line, openLen) {
|
|
48
55
|
const t = line.replace(/\s+$/, "");
|
|
49
56
|
return /^=+$/.test(t) && t.length === openLen;
|
|
@@ -61,15 +68,67 @@ function slug(text) {
|
|
|
61
68
|
// ---------------------------------------------------------------------------
|
|
62
69
|
// §4: substitute `{{key}}` in flow text with the matching `=== meta` value.
|
|
63
70
|
// An unknown key is a build error (single-source-of-truth, fail loudly).
|
|
71
|
+
// The scan mirrors the §5.3(1) verbatim atoms: a `{{key}}` inside a code span
|
|
72
|
+
// or inline math is left untouched (so GEML prose can document this very
|
|
73
|
+
// syntax), and a backslash-escaped character can neither open a span nor a
|
|
74
|
+
// `{{…}}` reference — `\{{key}}` renders as the literal text `{{key}}`.
|
|
75
|
+
const META_REF = new RegExp(META_REF_SRC, "y");
|
|
64
76
|
function interpolate(text, line, ctx) {
|
|
65
77
|
if (!text.includes("{{"))
|
|
66
78
|
return text;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
79
|
+
let out = "";
|
|
80
|
+
let i = 0;
|
|
81
|
+
while (i < text.length) {
|
|
82
|
+
const c = text[i];
|
|
83
|
+
if (c === "\\" && i + 1 < text.length) {
|
|
84
|
+
out += c + text[i + 1];
|
|
85
|
+
i += 2;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (c === "`") {
|
|
89
|
+
let n = 0;
|
|
90
|
+
while (text[i + n] === "`")
|
|
91
|
+
n++;
|
|
92
|
+
const close = text.indexOf("`".repeat(n), i + n);
|
|
93
|
+
if (close >= 0) {
|
|
94
|
+
out += text.slice(i, close + n);
|
|
95
|
+
i = close + n;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
out += text.slice(i, i + n); // unclosed run: literal, keep scanning
|
|
99
|
+
i += n;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (c === "$") {
|
|
103
|
+
const close = text.indexOf("$", i + 1);
|
|
104
|
+
if (close > i + 1) {
|
|
105
|
+
out += text.slice(i, close + 1);
|
|
106
|
+
i = close + 1;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
out += c;
|
|
110
|
+
i++;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (c === "{" && text[i + 1] === "{") {
|
|
114
|
+
META_REF.lastIndex = i;
|
|
115
|
+
const m = META_REF.exec(text);
|
|
116
|
+
if (m) {
|
|
117
|
+
const key = m[1];
|
|
118
|
+
if (ctx.meta.has(key))
|
|
119
|
+
out += ctx.meta.get(key);
|
|
120
|
+
else {
|
|
121
|
+
ctx.diags.push({ severity: "error", message: `unknown metadata reference \`{{${key}}}\``, line });
|
|
122
|
+
out += m[0];
|
|
123
|
+
}
|
|
124
|
+
i = META_REF.lastIndex;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
out += c;
|
|
129
|
+
i++;
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
73
132
|
}
|
|
74
133
|
// Register a block id, flagging duplicates as errors (§4: ids unique per doc).
|
|
75
134
|
function registerId(ctx, id, line) {
|
|
@@ -121,6 +180,7 @@ function parseList(lines, i, base, ctx) {
|
|
|
121
180
|
const root = mkList(matchMarker(lines[i]));
|
|
122
181
|
const stack = [{ list: root, indent: matchMarker(lines[i]).indent }];
|
|
123
182
|
let prevBlank = false;
|
|
183
|
+
let tooDeep = false;
|
|
124
184
|
while (i < lines.length) {
|
|
125
185
|
if (lines[i].trim() === "") {
|
|
126
186
|
prevBlank = true;
|
|
@@ -138,11 +198,28 @@ function parseList(lines, i, base, ctx) {
|
|
|
138
198
|
const parent = top.list.items[top.list.items.length - 1];
|
|
139
199
|
if (!parent)
|
|
140
200
|
break; // deeper indent with no parent item: defensive stop
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
201
|
+
if (stack.length >= MAX_NESTING) {
|
|
202
|
+
// Refuse to nest deeper than the cap: keep the item at the current level
|
|
203
|
+
// rather than building a model that overflows the renderer (DoS). One
|
|
204
|
+
// diagnostic per over-deep list; content is preserved, just flattened.
|
|
205
|
+
if (!tooDeep) {
|
|
206
|
+
ctx.diags.push({ severity: "error", message: `list nesting too deep (max ${MAX_NESTING})`, line: base + i + 1 });
|
|
207
|
+
tooDeep = true;
|
|
208
|
+
}
|
|
209
|
+
cur = top.list;
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
cur = mkList(mk);
|
|
213
|
+
(parent.children ??= []).push(cur);
|
|
214
|
+
stack.push({ list: cur, indent: mk.indent });
|
|
215
|
+
}
|
|
144
216
|
}
|
|
145
217
|
else {
|
|
218
|
+
// §5: a change of marker type (bullet ↔ ordered) at the same level ends
|
|
219
|
+
// this list; scanBlocks then opens a fresh one at this marker. Without it,
|
|
220
|
+
// `- a` then `1. b` would merge into one mis-typed list (CommonMark §5.3).
|
|
221
|
+
if (mk.ordered !== top.list.ordered)
|
|
222
|
+
break;
|
|
146
223
|
cur = top.list;
|
|
147
224
|
}
|
|
148
225
|
if (prevBlank && cur.items.length > 0)
|
|
@@ -153,7 +230,7 @@ function parseList(lines, i, base, ctx) {
|
|
|
153
230
|
}
|
|
154
231
|
return { block: root, next: i };
|
|
155
232
|
}
|
|
156
|
-
function scanBlocks(lines, base, ctx) {
|
|
233
|
+
function scanBlocks(lines, base, ctx, depth = 0) {
|
|
157
234
|
const blocks = [];
|
|
158
235
|
const diags = ctx.diags;
|
|
159
236
|
let i = 0;
|
|
@@ -236,7 +313,16 @@ function scanBlocks(lines, base, ctx) {
|
|
|
236
313
|
ctx.refs.push({ kind: "internal", anchor: of.slice(1), line: openLineNo });
|
|
237
314
|
}
|
|
238
315
|
if (mode === "flow") {
|
|
239
|
-
|
|
316
|
+
if (depth >= MAX_NESTING) {
|
|
317
|
+
// Refuse to recurse past the cap: emit a diagnostic and keep the body
|
|
318
|
+
// as raw so the parser returns cleanly instead of overflowing the
|
|
319
|
+
// call stack on a pathologically nested document (DoS).
|
|
320
|
+
diags.push({ severity: "error", message: `block nesting too deep (max ${MAX_NESTING}); body kept as raw`, line: openLineNo });
|
|
321
|
+
block.raw = body;
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
block.children = scanBlocks(body, base + i + 1, ctx, depth + 1);
|
|
325
|
+
}
|
|
240
326
|
}
|
|
241
327
|
else if (mode === "data") {
|
|
242
328
|
block.data = parseData(body);
|
|
@@ -265,6 +351,21 @@ function scanBlocks(lines, base, ctx) {
|
|
|
265
351
|
}
|
|
266
352
|
(ctx.charts ??= []).push({ block, line: openLineNo });
|
|
267
353
|
}
|
|
354
|
+
else if (fmt === "geml-code-graph") {
|
|
355
|
+
// Code-graph embed (GEP-0003): the ONLY attribute is src=, pointing
|
|
356
|
+
// at a codemap document; roots/depth come from that document's meta
|
|
357
|
+
// ("view config travels with the data"). Body is empty.
|
|
358
|
+
const src = attrs.attrs["src"];
|
|
359
|
+
if (typeof src !== "string" || src === "") {
|
|
360
|
+
diags.push({ severity: "warning", message: "geml-code-graph: missing `src=` (nothing to render)", line: openLineNo });
|
|
361
|
+
}
|
|
362
|
+
else if (ctx.resolveDoc && ctx.resolveDoc(src) === null) {
|
|
363
|
+
diags.push({ severity: "warning", message: `geml-code-graph: cannot resolve document \`${src}\``, line: openLineNo });
|
|
364
|
+
}
|
|
365
|
+
if (body.length > 0 && body.some((l) => l.trim() !== "")) {
|
|
366
|
+
diags.push({ severity: "warning", message: "geml-code-graph body is ignored; the embed is configured by `src=` alone", line: openLineNo });
|
|
367
|
+
}
|
|
368
|
+
}
|
|
268
369
|
else if (typeof fmt === "string" && !DIAGRAM_RENDERERS.has(fmt)) {
|
|
269
370
|
// §7: warn on a diagram format with no registered renderer.
|
|
270
371
|
diags.push({ severity: "warning", message: `no registered renderer for diagram format \`${fmt}\`; body kept raw`, line: openLineNo });
|
|
@@ -424,12 +525,182 @@ function resolveCharts(ctx) {
|
|
|
424
525
|
}
|
|
425
526
|
export function parse(source, opts = {}) {
|
|
426
527
|
const lines = source.replace(/\r\n?/g, "\n").split("\n");
|
|
427
|
-
const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines) };
|
|
528
|
+
const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines), resolveDoc: opts.resolveDoc };
|
|
428
529
|
const children = scanBlocks(lines, 0, ctx);
|
|
429
530
|
resolveCharts(ctx);
|
|
430
531
|
validateRefs(ctx, opts);
|
|
431
532
|
return { kind: "document", children, ids: [...ctx.ids.keys()], diagnostics: ctx.diags };
|
|
432
533
|
}
|
|
534
|
+
// The id that a fence/heading line defines, matching how scanBlocks derives it
|
|
535
|
+
// (parseAttrs for the attribute object; heading text slug when no explicit id).
|
|
536
|
+
// The slug MUST come from the INTERPOLATED text — scanBlocks slugs after
|
|
537
|
+
// interpolate(), so `# {{title}} Setup` registers the substituted slug; slugging
|
|
538
|
+
// the raw text here would create a phantom id the parser never registered and
|
|
539
|
+
// make the real one unaddressable. `ctx` is an inert context carrying the
|
|
540
|
+
// document's meta (diagnostics are discarded — spans never report).
|
|
541
|
+
function idOfHeading(braces, text, line, ctx) {
|
|
542
|
+
return (braces ? parseAttrs(braces).id : undefined) ?? slug(interpolate(text, line, ctx));
|
|
543
|
+
}
|
|
544
|
+
// The matching close of the fence opened at lines[i] (equal-length run, or the
|
|
545
|
+
// labeled `=== #id` close when the block carries an id): the index just past
|
|
546
|
+
// the close line, and whether one was found — an unterminated block runs to
|
|
547
|
+
// the end of the scope.
|
|
548
|
+
function fenceClose(lines, i, open) {
|
|
549
|
+
const openLen = open[1].length;
|
|
550
|
+
const id = open[3] ? parseAttrs(open[3]).id : undefined;
|
|
551
|
+
const labeled = id !== undefined ? new RegExp(`^={3,}[ \\t]+#${id}[ \\t]*$`) : null;
|
|
552
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
553
|
+
if (isCloseFence(lines[j], openLen) || (labeled && labeled.test(lines[j])))
|
|
554
|
+
return { end: j + 1, closed: true };
|
|
555
|
+
}
|
|
556
|
+
return { end: lines.length, closed: false };
|
|
557
|
+
}
|
|
558
|
+
// A heading's span covers its whole SECTION: the heading line through the line
|
|
559
|
+
// just before the next heading of same-or-higher level (fewer-or-equal `#`) in
|
|
560
|
+
// the current scope, or end-of-scope. Fenced blocks are skipped whole — a `#`
|
|
561
|
+
// line inside a `=== code` body is content, never a boundary.
|
|
562
|
+
function sectionEnd(lines, i, level) {
|
|
563
|
+
let j = i + 1;
|
|
564
|
+
while (j < lines.length) {
|
|
565
|
+
const open = FENCE_OPEN.exec(lines[j]);
|
|
566
|
+
if (open) {
|
|
567
|
+
j = fenceClose(lines, j, open).end;
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
const h = HEADING.exec(lines[j]);
|
|
571
|
+
if (h && h[1].length <= level)
|
|
572
|
+
return j;
|
|
573
|
+
j++;
|
|
574
|
+
}
|
|
575
|
+
return lines.length;
|
|
576
|
+
}
|
|
577
|
+
// Walk `lines` exactly as scanBlocks does — same fence close rules (equal-length
|
|
578
|
+
// or labeled `=== #id`), same flow-only recursion via REGISTRY — recording the
|
|
579
|
+
// source span of every addressable id (typed block, heading, footnote def).
|
|
580
|
+
// First definition wins, mirroring ctx.ids (a duplicate id is a build error, so
|
|
581
|
+
// `get`/`set` operate on the one the parser actually registered). `base` is the
|
|
582
|
+
// absolute line offset of this slice within the whole document.
|
|
583
|
+
function collectSpans(lines, base, out, ctx, depth = 0) {
|
|
584
|
+
const add = (id, start, end) => {
|
|
585
|
+
if (!out.has(id))
|
|
586
|
+
out.set(id, { start, end });
|
|
587
|
+
};
|
|
588
|
+
let i = 0;
|
|
589
|
+
while (i < lines.length) {
|
|
590
|
+
const line = lines[i];
|
|
591
|
+
if (line.trim() === "") {
|
|
592
|
+
i++;
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
const fndef = /^\[\^([^\]]+)\]:[ \t]?(.*)$/.exec(line);
|
|
596
|
+
if (fndef) {
|
|
597
|
+
add(fndef[1].trim(), base + i, base + i + 1);
|
|
598
|
+
i++;
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
if (/^[ \t]*%%/.test(line)) {
|
|
602
|
+
i++;
|
|
603
|
+
continue;
|
|
604
|
+
} // hidden line: no id
|
|
605
|
+
const open = FENCE_OPEN.exec(line);
|
|
606
|
+
if (open) {
|
|
607
|
+
const type = open[2];
|
|
608
|
+
const id = open[3] ? parseAttrs(open[3]).id : undefined;
|
|
609
|
+
const { end, closed } = fenceClose(lines, i, open);
|
|
610
|
+
if (id !== undefined)
|
|
611
|
+
add(id, base + i, base + end);
|
|
612
|
+
// Only a flow body is scanned for nested blocks (raw/data bodies are
|
|
613
|
+
// opaque), so an id inside a `code` body is *not* addressable — exactly
|
|
614
|
+
// the parser's contract.
|
|
615
|
+
if ((REGISTRY[type] ?? "raw") === "flow" && depth < MAX_NESTING) {
|
|
616
|
+
collectSpans(lines.slice(i + 1, closed ? end - 1 : end), base + i + 1, out, ctx, depth + 1);
|
|
617
|
+
}
|
|
618
|
+
i = end;
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
const h = HEADING.exec(line);
|
|
622
|
+
if (h) {
|
|
623
|
+
// Section span (heading through its prose and nested blocks). The walk
|
|
624
|
+
// still advances one line at a time so every nested id inside the
|
|
625
|
+
// section registers its own span — spans intentionally OVERLAP: #sec
|
|
626
|
+
// contains #code, and each remains addressable on its own.
|
|
627
|
+
add(idOfHeading(h[3], h[2], base + i + 1, ctx), base + i, base + sectionEnd(lines, i, h[1].length));
|
|
628
|
+
i++;
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
i++;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
// Map every addressable id in `source` to its source span. Line indices align
|
|
635
|
+
// with the physical lines produced by splitLines(source).
|
|
636
|
+
export function blockSpans(source) {
|
|
637
|
+
const out = new Map();
|
|
638
|
+
const lines = source.replace(/\r\n?/g, "\n").split("\n");
|
|
639
|
+
// Inert context: heading auto-ids slug the interpolated text (parser parity);
|
|
640
|
+
// its diagnostics are discarded — the span scan never reports.
|
|
641
|
+
const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines) };
|
|
642
|
+
collectSpans(lines, 0, out, ctx);
|
|
643
|
+
return out;
|
|
644
|
+
}
|
|
645
|
+
// Split into physical lines while *keeping* each line's terminator, so
|
|
646
|
+
// join("") is byte-exact and slicing by span never rewrites line endings.
|
|
647
|
+
// A line ends at `\n` or at a LONE `\r` (old-Mac style) — the same boundaries
|
|
648
|
+
// the span scan's `\r\n?` -> `\n` normalization sees, so span indices always
|
|
649
|
+
// address the same lines the parser counted.
|
|
650
|
+
function splitLines(source) {
|
|
651
|
+
return source.split(/(?<=\n|\r(?!\n))/);
|
|
652
|
+
}
|
|
653
|
+
// `--head`: narrow any id's span to its HEAD line — the single declaring line
|
|
654
|
+
// (a heading's `# … {#id}` line, a typed block's opening fence, a footnote's
|
|
655
|
+
// `[^id]:` line). The head is by construction the FIRST line of the span, so
|
|
656
|
+
// the narrowing is parse-free and needs no type check. Main use: `set --head`
|
|
657
|
+
// edits a block's attributes (caption/compute/lang/…) without re-sending its
|
|
658
|
+
// body, or renames a heading without rewriting its section.
|
|
659
|
+
function narrowToHead(span) {
|
|
660
|
+
return { start: span.start, end: span.start + 1 };
|
|
661
|
+
}
|
|
662
|
+
// Depth-first search for the document-model node carrying `id`, descending into
|
|
663
|
+
// flow-block children (and list-item children) so a nested id is found too.
|
|
664
|
+
// Returns the containing sibling array and index, not just the node: the model
|
|
665
|
+
// is FLAT — a heading does not own its section; the section's prose and blocks
|
|
666
|
+
// are its FOLLOWING SIBLINGS — so a section consumer needs the array.
|
|
667
|
+
function findBlockSite(blocks, id) {
|
|
668
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
669
|
+
const b = blocks[i];
|
|
670
|
+
if ((b.kind === "heading" || b.kind === "block") && b.id === id)
|
|
671
|
+
return { siblings: blocks, index: i };
|
|
672
|
+
if (b.kind === "block" && b.children) {
|
|
673
|
+
const hit = findBlockSite(b.children, id);
|
|
674
|
+
if (hit)
|
|
675
|
+
return hit;
|
|
676
|
+
}
|
|
677
|
+
if (b.kind === "list") {
|
|
678
|
+
for (const it of b.items) {
|
|
679
|
+
if (it.children) {
|
|
680
|
+
const hit = findBlockSite(it.children, id);
|
|
681
|
+
if (hit)
|
|
682
|
+
return hit;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
return undefined;
|
|
688
|
+
}
|
|
689
|
+
// Model-side section boundary: within one sibling array, the section opened by
|
|
690
|
+
// the heading at index k runs to the next sibling heading of same-or-higher
|
|
691
|
+
// level, or the array end. This is the SAME rule sectionEnd() applies to raw
|
|
692
|
+
// source lines (where skipping fenced bodies makes "next heading" well-defined)
|
|
693
|
+
// — the two sides must stay in lockstep; the get-set suite pins their parity
|
|
694
|
+
// (ids covered by the raw slice == ids covered by the --json envelope).
|
|
695
|
+
function sectionEndIndex(siblings, k) {
|
|
696
|
+
const level = siblings[k].level;
|
|
697
|
+
for (let m = k + 1; m < siblings.length; m++) {
|
|
698
|
+
const b = siblings[m];
|
|
699
|
+
if (b.kind === "heading" && b.level <= level)
|
|
700
|
+
return m;
|
|
701
|
+
}
|
|
702
|
+
return siblings.length;
|
|
703
|
+
}
|
|
433
704
|
// ---------------------------------------------------------------------------
|
|
434
705
|
// CLI
|
|
435
706
|
// ---------------------------------------------------------------------------
|
|
@@ -447,42 +718,66 @@ function parseStamp(s) {
|
|
|
447
718
|
const [, y, mo, d, h, mi, se] = m;
|
|
448
719
|
return new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +se));
|
|
449
720
|
}
|
|
450
|
-
const VERSION = "1.0
|
|
451
|
-
const PARSER_VERSION = "1.
|
|
452
|
-
const USAGE = `geml — GEML reference CLI
|
|
453
|
-
|
|
454
|
-
Usage:
|
|
455
|
-
geml <file.geml|-> parse -> document-model JSON (stdout)
|
|
456
|
-
geml
|
|
457
|
-
|
|
458
|
-
geml
|
|
459
|
-
geml
|
|
460
|
-
geml
|
|
461
|
-
|
|
462
|
-
geml
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
721
|
+
const VERSION = "1.0"; // GEML spec version this CLI targets
|
|
722
|
+
const PARSER_VERSION = "1.3.2"; // reference implementation; keep in sync with package.json
|
|
723
|
+
const USAGE = `geml — GEML reference CLI
|
|
724
|
+
|
|
725
|
+
Usage:
|
|
726
|
+
geml <file.geml|-> parse -> document-model JSON (stdout)
|
|
727
|
+
geml get <file.geml|-> #id [--json][--head] print ONE block by id (a heading id = its section;
|
|
728
|
+
--head narrows any id to its head line; --json = model node)
|
|
729
|
+
geml set <file.geml|-> #id [--from f][-o f][--head] replace ONE block by id (new content: --from/stdin)
|
|
730
|
+
geml revert <file.geml> #id [--to <sel>][--head] restore ONE block to a past revision (sel: -N|latest|id)
|
|
731
|
+
geml check <file.geml|-> [--root d][--json] validate only: diagnostics + exit code
|
|
732
|
+
(--root widens cross-doc refs to dir d, e.g. the repo root)
|
|
733
|
+
geml render <file.geml|-> [-o out.html] render to one self-contained HTML file
|
|
734
|
+
geml fmt <file.geml|-> [-o out.geml] re-serialize to canonical GEML
|
|
735
|
+
geml convert <file.md|-> [-o out.geml] Markdown -> GEML
|
|
736
|
+
geml export <file.geml|-> [-o out.md] GEML -> Markdown (lossy)
|
|
737
|
+
geml history <commit|verify|show|restore|log> <file.geml> [...]
|
|
738
|
+
geml codemap <build|verify|render|serve|refresh|find|mcp> [...] code-graph toolkit (alias: codegraph)
|
|
739
|
+
geml --help | --version [--json]
|
|
740
|
+
|
|
741
|
+
Use '-' as the file to read from stdin.
|
|
742
|
+
Exit codes:
|
|
743
|
+
0 ok
|
|
744
|
+
1 document/operation error
|
|
745
|
+
2 command usage error.
|
|
746
|
+
`;
|
|
466
747
|
// One-line usage for each subcommand — the single source for both the error
|
|
467
748
|
// shown on misuse and the `<cmd> --help` text.
|
|
468
749
|
const SUBHELP = {
|
|
469
|
-
|
|
750
|
+
get: "usage: geml get <file.geml|-> #id [--json] [--head] (a heading id = its whole section; --head narrows any id to its head line)",
|
|
751
|
+
set: "usage: geml set <file.geml|-> #id [--from FILE] [-o out.geml] [--head]",
|
|
752
|
+
check: "usage: geml check <file.geml|-> [--root <dir>] [--json] (--root: resolve cross-doc refs within <dir> instead of the file's own directory)",
|
|
470
753
|
render: "usage: geml render <file.geml|-> [-o out.html]",
|
|
471
754
|
convert: "usage: geml convert <file.md|-> [-o out.geml]",
|
|
472
755
|
export: "usage: geml export <file.geml|-> [-o out.md]",
|
|
473
756
|
fmt: "usage: geml fmt <file.geml|-> [-o out.geml]",
|
|
474
|
-
|
|
757
|
+
revert: "usage: geml revert <file.geml> #id [--to <sel>] [--changed] [--dry-run] [-o out] [--head] (sel: -N | latest | id-prefix; default -1)",
|
|
758
|
+
history: "usage: geml history <commit|verify|show|restore|log> <file.geml> [...]",
|
|
759
|
+
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)
|
|
760
|
+
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]]
|
|
761
|
+
geml codemap verify [dir] geml check + profile reference checks
|
|
762
|
+
geml codemap render [dir] every doc -> sibling .html (open index.html from disk)
|
|
763
|
+
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
|
|
764
|
+
geml codemap refresh [dir] [--force] [--commit] [--background|--hook] re-run the recorded build recipe (_index/refresh.json); --commit lands it as its own commit
|
|
765
|
+
geml codemap find <name> [dir] locate a symbol by substring name -> doc#id + src (stdout, no browser)
|
|
766
|
+
geml codemap mcp stdio MCP server (GEML_GRAPH_DIR or graph_dir arg)
|
|
767
|
+
(<dir> for verify/render/serve/refresh/find defaults to ./.geml-code-graph; codegraph and code-graph are accepted as aliases of codemap)`,
|
|
475
768
|
};
|
|
476
769
|
// Set from argv at dispatch time; when true, errors are emitted as a JSON
|
|
477
770
|
// envelope so an agent that standardizes on --json never has to parse text.
|
|
478
771
|
let jsonMode = false;
|
|
479
|
-
// Clean one-line error + non-zero exit — never a raw Node stack trace.
|
|
480
|
-
|
|
772
|
+
// Clean one-line error + non-zero exit — never a raw Node stack trace. `code`
|
|
773
|
+
// is the process exit status: 2 for a usage error (the default), 1 for a
|
|
774
|
+
// document/operation error. `--json` wraps it in the same {error, code} envelope.
|
|
775
|
+
function fail(msg, code = 2) {
|
|
481
776
|
if (jsonMode)
|
|
482
|
-
console.error(JSON.stringify({ error: msg, code
|
|
777
|
+
console.error(JSON.stringify({ error: msg, code }));
|
|
483
778
|
else
|
|
484
779
|
console.error(`error: ${msg}`);
|
|
485
|
-
process.exit(
|
|
780
|
+
process.exit(code);
|
|
486
781
|
}
|
|
487
782
|
// Read a file, or stdin when the path is "-". On failure emit a clean error.
|
|
488
783
|
function readInput(file) {
|
|
@@ -493,12 +788,66 @@ function readInput(file) {
|
|
|
493
788
|
fail(file === "-" ? "cannot read stdin" : `cannot read ${file}`);
|
|
494
789
|
}
|
|
495
790
|
}
|
|
496
|
-
// A cross-document resolver rooted at the input's directory (cwd for stdin)
|
|
497
|
-
|
|
498
|
-
|
|
791
|
+
// A cross-document resolver rooted at the input's directory (cwd for stdin),
|
|
792
|
+
// CONFINED to that directory's subtree. A reference that resolves outside the
|
|
793
|
+
// base — via a `..` escape, an absolute path, or (on Windows) a different drive
|
|
794
|
+
// — is refused (returns null, i.e. an unresolvable ref) so a crafted document
|
|
795
|
+
// cannot turn `geml check`/parse into an arbitrary local-file read oracle. §8.
|
|
796
|
+
//
|
|
797
|
+
// A purely LEXICAL check is not enough: a symlink that sits lexically inside the
|
|
798
|
+
// subtree but points to `../../outside.geml` passes `path.relative` yet reads an
|
|
799
|
+
// external target. So after the cheap lexical gate we resolve BOTH the base and
|
|
800
|
+
// the target through `realpathSync` (following every symlink component) and
|
|
801
|
+
// re-check that the REAL target still lies within the REAL base subtree before
|
|
802
|
+
// reading. A target that does not exist makes `realpathSync` throw — handled as
|
|
803
|
+
// an ordinary unresolvable ref (null), never a crash.
|
|
804
|
+
//
|
|
805
|
+
// `root` (CLI `--root`, an explicit per-invocation user grant — never
|
|
806
|
+
// document-controlled) widens the confinement base from the input's own
|
|
807
|
+
// directory to an ancestor the user names, so repo-relative `../` references
|
|
808
|
+
// between sibling directories can be checked. It moves WHERE the boundary
|
|
809
|
+
// stands, never whether it is enforced: both gates below run against the
|
|
810
|
+
// widened base, so escapes past the root are refused exactly as above. The
|
|
811
|
+
// viewer/web surfaces never pass a root — their boundary is unchanged.
|
|
812
|
+
function resolverFor(file, root) {
|
|
813
|
+
const dirAbs = resolvePath(file === "-" ? "." : dirname(file));
|
|
814
|
+
const baseAbs = root === undefined ? dirAbs : resolvePath(root);
|
|
815
|
+
// Canonicalise the base once. If the base itself cannot be realpath'd, no
|
|
816
|
+
// cross-doc ref can be safely confined — resolve nothing.
|
|
817
|
+
let realBase = null;
|
|
818
|
+
try {
|
|
819
|
+
realBase = realpathSync(baseAbs);
|
|
820
|
+
}
|
|
821
|
+
catch {
|
|
822
|
+
realBase = null;
|
|
823
|
+
}
|
|
824
|
+
const outside = (from, to) => {
|
|
825
|
+
const rel = relative(from, to);
|
|
826
|
+
return rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel);
|
|
827
|
+
};
|
|
499
828
|
return (d) => {
|
|
829
|
+
if (realBase === null)
|
|
830
|
+
return null;
|
|
831
|
+
// References resolve FROM the document's own directory; the gates below
|
|
832
|
+
// confine them to the (possibly widened) base.
|
|
833
|
+
const targetAbs = resolvePath(dirAbs, d);
|
|
834
|
+
// Cheap lexical gate: reject an obvious `..`/absolute/other-drive escape
|
|
835
|
+
// before touching the filesystem.
|
|
836
|
+
if (outside(baseAbs, targetAbs))
|
|
837
|
+
return null;
|
|
838
|
+
// Real (symlink-resolved) gate: a symlink pointing out of the subtree
|
|
839
|
+
// resolves to a real path outside `realBase` and is refused here.
|
|
840
|
+
let realTarget;
|
|
841
|
+
try {
|
|
842
|
+
realTarget = realpathSync(targetAbs);
|
|
843
|
+
}
|
|
844
|
+
catch {
|
|
845
|
+
return null;
|
|
846
|
+
}
|
|
847
|
+
if (outside(realBase, realTarget))
|
|
848
|
+
return null;
|
|
500
849
|
try {
|
|
501
|
-
return readFileSync(
|
|
850
|
+
return readFileSync(realTarget, "utf8");
|
|
502
851
|
}
|
|
503
852
|
catch {
|
|
504
853
|
return null;
|
|
@@ -509,10 +858,22 @@ function resolverFor(file) {
|
|
|
509
858
|
// dump (cheap for agents). `--json` prints the diagnostics array for machines.
|
|
510
859
|
function runCheck(args) {
|
|
511
860
|
const json = args.includes("--json");
|
|
512
|
-
const
|
|
861
|
+
const root = flag(args, "--root");
|
|
862
|
+
const file = args.find((a) => a === "-" || (!a.startsWith("-") && a !== root));
|
|
513
863
|
if (!file)
|
|
514
864
|
fail(SUBHELP.check);
|
|
515
|
-
|
|
865
|
+
// A mistyped --root must be a usage error (exit 2), not a wall of misleading
|
|
866
|
+
// "cannot resolve document" errors from a resolver confined to nothing.
|
|
867
|
+
if (root !== undefined) {
|
|
868
|
+
let isDir = false;
|
|
869
|
+
try {
|
|
870
|
+
isDir = statSync(root).isDirectory();
|
|
871
|
+
}
|
|
872
|
+
catch { /* missing -> not a dir */ }
|
|
873
|
+
if (!isDir)
|
|
874
|
+
fail(`--root ${root} is not a directory`);
|
|
875
|
+
}
|
|
876
|
+
const doc = parse(readInput(file), { resolveDoc: resolverFor(file, root) });
|
|
516
877
|
if (json) {
|
|
517
878
|
console.log(JSON.stringify(doc.diagnostics, null, 2));
|
|
518
879
|
}
|
|
@@ -580,6 +941,14 @@ function runHistory(args) {
|
|
|
580
941
|
restore({ historyPath, gemlPath: file, revision: rev, write: true, force: args.includes("--force") });
|
|
581
942
|
console.log(`restored ${file} to ${rev}`);
|
|
582
943
|
}
|
|
944
|
+
else if (sub === "log") {
|
|
945
|
+
// Newest-first, with the `--to` selector for each row in the first column
|
|
946
|
+
// (`latest` for the tip, then `-1`, `-2`, …) so the output is copy-paste.
|
|
947
|
+
for (const r of listRevisions(historyPath)) {
|
|
948
|
+
const sel = r.current ? "latest" : `-${r.offset}`;
|
|
949
|
+
console.log(`${sel.padEnd(7)} ${r.id} ${r.author ?? "-"} ${r.summary ?? ""}`.replace(/\s+$/, ""));
|
|
950
|
+
}
|
|
951
|
+
}
|
|
583
952
|
else {
|
|
584
953
|
fail(`unknown history subcommand: ${sub}. Run 'geml --help'.`);
|
|
585
954
|
}
|
|
@@ -638,7 +1007,12 @@ function runRender(args) {
|
|
|
638
1007
|
if (!file)
|
|
639
1008
|
fail(SUBHELP.render);
|
|
640
1009
|
const doc = parse(readInput(file), { resolveDoc: resolverFor(file) });
|
|
641
|
-
const html = renderHtml(doc, {
|
|
1010
|
+
const html = renderHtml(doc, {
|
|
1011
|
+
source: file === "-" ? "stdin" : basename(file),
|
|
1012
|
+
// geml-code-graph embeds load + parse sibling codemap documents on demand.
|
|
1013
|
+
loadDoc: resolverFor(file),
|
|
1014
|
+
parseDoc: (s) => parse(s),
|
|
1015
|
+
});
|
|
642
1016
|
if (out) {
|
|
643
1017
|
writeFileSync(out, html);
|
|
644
1018
|
console.error(`wrote ${out}`);
|
|
@@ -673,10 +1047,272 @@ function runFmt(args) {
|
|
|
673
1047
|
if (doc.diagnostics.some((d) => d.severity === "error"))
|
|
674
1048
|
process.exit(1);
|
|
675
1049
|
}
|
|
676
|
-
|
|
677
|
-
|
|
1050
|
+
// Positional args (a file, an id) are the non-flag tokens that aren't the value
|
|
1051
|
+
// of a value-taking flag. `-` (stdin) is a positional, not a flag. An id may be
|
|
1052
|
+
// written `#id` or `id`; a leading `-` never begins an id, so this stays
|
|
1053
|
+
// unambiguous. `valued` lists the flags that consume the following token.
|
|
1054
|
+
function positionals(args, valued) {
|
|
1055
|
+
const out = [];
|
|
1056
|
+
for (let i = 0; i < args.length; i++) {
|
|
1057
|
+
const a = args[i];
|
|
1058
|
+
if (valued.includes(a)) {
|
|
1059
|
+
i++;
|
|
1060
|
+
continue;
|
|
1061
|
+
} // skip the flag *and* its value
|
|
1062
|
+
if (a === "-") {
|
|
1063
|
+
out.push(a);
|
|
1064
|
+
continue;
|
|
1065
|
+
}
|
|
1066
|
+
if (a.startsWith("-"))
|
|
1067
|
+
continue; // a bare flag (e.g. --json)
|
|
1068
|
+
out.push(a);
|
|
1069
|
+
}
|
|
1070
|
+
return out;
|
|
1071
|
+
}
|
|
1072
|
+
// `geml get <file.geml|-> #id [--json]` — print ONE block, addressed by id,
|
|
1073
|
+
// without loading the rest of the document into context. Default output is the
|
|
1074
|
+
// block's exact source bytes: a typed block's full `=== … ===` span, a
|
|
1075
|
+
// footnote's line, or — for a heading — its whole SECTION (heading line through
|
|
1076
|
+
// the line before the next same-or-higher heading). `--json` covers the same
|
|
1077
|
+
// content: a block/footnote id prints its document-model node; a heading id
|
|
1078
|
+
// prints a section envelope `{kind:"section", id, level, blocks:[heading,
|
|
1079
|
+
// …siblings up to the boundary]}`.
|
|
1080
|
+
function runGet(args) {
|
|
1081
|
+
const json = args.includes("--json");
|
|
1082
|
+
const headOnly = args.includes("--head");
|
|
1083
|
+
const [file, rawId] = positionals(args, []);
|
|
1084
|
+
if (!file || !rawId)
|
|
1085
|
+
fail(SUBHELP.get);
|
|
1086
|
+
const id = rawId.replace(/^#/, "");
|
|
1087
|
+
const source = readInput(file);
|
|
1088
|
+
if (json) {
|
|
1089
|
+
// The model node(s) — same shapes `geml <file>` emits. Parsing is needed
|
|
1090
|
+
// to resolve the tree (and nested-block ids), but only the target prints.
|
|
1091
|
+
const doc = parse(source, { resolveDoc: resolverFor(file) });
|
|
1092
|
+
const site = findBlockSite(doc.children, id);
|
|
1093
|
+
if (!site)
|
|
1094
|
+
fail(`no block with id \`${id}\``, 1);
|
|
1095
|
+
const block = site.siblings[site.index];
|
|
1096
|
+
// `--head` on a heading suppresses the section envelope (the lone heading
|
|
1097
|
+
// node IS the head). On a block/footnote id there is nothing finer than
|
|
1098
|
+
// the single node — the model has no sub-node for "just the fence line" —
|
|
1099
|
+
// so --head refines only the RAW output there.
|
|
1100
|
+
if (block.kind === "heading" && !headOnly) {
|
|
1101
|
+
// A heading id addresses its SECTION, so `--json` covers the same
|
|
1102
|
+
// content as the raw span: a self-describing envelope whose blocks[0]
|
|
1103
|
+
// is the heading node followed by its siblings up to the boundary.
|
|
1104
|
+
// `kind: "section"` lets a consumer branch — a block/footnote id still
|
|
1105
|
+
// yields the single model node (the model itself stays flat).
|
|
1106
|
+
const end = sectionEndIndex(site.siblings, site.index);
|
|
1107
|
+
console.log(JSON.stringify({ kind: "section", id: block.id, level: block.level, blocks: site.siblings.slice(site.index, end) }, null, 2));
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
console.log(JSON.stringify(block, null, 2));
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
// Raw: slice the source span byte-for-byte. No parse required, so `get` still
|
|
1114
|
+
// returns the exact bytes even if the document has diagnostics elsewhere.
|
|
1115
|
+
const found = blockSpans(source).get(id);
|
|
1116
|
+
if (!found)
|
|
1117
|
+
fail(`no block with id \`${id}\``, 1);
|
|
1118
|
+
const span = headOnly ? narrowToHead(found) : found;
|
|
1119
|
+
process.stdout.write(splitLines(source).slice(span.start, span.end).join(""));
|
|
1120
|
+
}
|
|
1121
|
+
// `geml set <file.geml|-> #id [--from FILE] [-o out]` — replace ONLY that
|
|
1122
|
+
// block's source span with new content (from --from or stdin), preserving every
|
|
1123
|
+
// other byte. Prints the full updated document, or writes in place with -o. The
|
|
1124
|
+
// splice is re-parsed and rejected if it broke the doc: `set` never writes a
|
|
1125
|
+
// corrupt file.
|
|
1126
|
+
function runSet(args) {
|
|
1127
|
+
const out = flag(args, "-o") ?? flag(args, "--out");
|
|
1128
|
+
const from = flag(args, "--from");
|
|
1129
|
+
const headOnly = args.includes("--head");
|
|
1130
|
+
const [file, rawId] = positionals(args, ["-o", "--out", "--from"]);
|
|
1131
|
+
if (!file || !rawId)
|
|
1132
|
+
fail(SUBHELP.set);
|
|
1133
|
+
const id = rawId.replace(/^#/, "");
|
|
1134
|
+
// Both the document and the replacement can't come from stdin. Reject that up
|
|
1135
|
+
// front — before consuming stdin — so the document read below is unambiguous.
|
|
1136
|
+
if (file === "-" && from === undefined) {
|
|
1137
|
+
fail("reading the document from stdin needs --from for the new content", 2);
|
|
1138
|
+
}
|
|
1139
|
+
const source = readInput(file);
|
|
1140
|
+
// New content: an explicit --from file, else stdin.
|
|
1141
|
+
let replacement;
|
|
1142
|
+
if (from !== undefined) {
|
|
1143
|
+
replacement = readInput(from);
|
|
1144
|
+
}
|
|
1145
|
+
else {
|
|
1146
|
+
replacement = readInput("-");
|
|
1147
|
+
if (replacement === "")
|
|
1148
|
+
fail("no replacement content (use --from FILE or pipe it on stdin)", 1);
|
|
1149
|
+
}
|
|
1150
|
+
const updated = spliceBlock(source, id, replacement, file, headOnly);
|
|
1151
|
+
if (out) {
|
|
1152
|
+
writeFileSync(out, updated);
|
|
1153
|
+
console.error(`wrote ${out}`);
|
|
1154
|
+
}
|
|
1155
|
+
else
|
|
1156
|
+
process.stdout.write(updated);
|
|
1157
|
+
}
|
|
1158
|
+
// Replace block #id's source span in `source` with `replacement`, preserving
|
|
1159
|
+
// every other byte, and GUARD the result: the re-parse must be error-free, #id
|
|
1160
|
+
// must survive, and no other pre-existing id may vanish (a malformed replacement
|
|
1161
|
+
// can silently swallow a neighbour). Returns the updated document text; on any
|
|
1162
|
+
// violation it calls fail() and never returns a corrupt document. Shared by
|
|
1163
|
+
// `set` and `revert`.
|
|
1164
|
+
function spliceBlock(source, id, replacement, file, headOnly = false) {
|
|
1165
|
+
const found = blockSpans(source).get(id);
|
|
1166
|
+
if (!found)
|
|
1167
|
+
fail(`no block with id \`${id}\``, 1);
|
|
1168
|
+
const beforeIds = parse(source, { resolveDoc: resolverFor(file) }).ids;
|
|
1169
|
+
// Keep the bytes before and after the target span exactly; give the new block
|
|
1170
|
+
// a single trailing newline so the following block still starts on its own
|
|
1171
|
+
// line (unless it is the file's last line, which may legitimately lack one).
|
|
1172
|
+
const orig = splitLines(source);
|
|
1173
|
+
// `--head`: splice only the id's head line; everything below stays
|
|
1174
|
+
// byte-identical. The guard below still applies — the replacement must
|
|
1175
|
+
// re-declare `{#id}` and, for a typed block, keep the fence pairing intact
|
|
1176
|
+
// (an opening line that no longer matches the untouched close fence breaks
|
|
1177
|
+
// the re-parse), or the splice is refused.
|
|
1178
|
+
const span = headOnly ? narrowToHead(found) : found;
|
|
1179
|
+
const before = orig.slice(0, span.start);
|
|
1180
|
+
const after = orig.slice(span.end);
|
|
1181
|
+
let inject = replacement.replace(/\r\n?/g, "\n");
|
|
1182
|
+
const lastLine = span.end >= orig.length;
|
|
1183
|
+
if (!inject.endsWith("\n") && !lastLine)
|
|
1184
|
+
inject += "\n";
|
|
1185
|
+
const updated = before.join("") + inject + after.join("");
|
|
1186
|
+
// Re-parse and refuse a broken result. A parse error or a duplicate id both
|
|
1187
|
+
// surface as error diagnostics (registerId flags dups); one check covers both.
|
|
1188
|
+
// Then require the target id to survive, and — because a malformed replacement
|
|
1189
|
+
// can swallow a neighbour — that every other pre-existing id survives too.
|
|
1190
|
+
const reparsed = parse(updated, { resolveDoc: resolverFor(file) });
|
|
1191
|
+
const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
|
|
1192
|
+
if (errs.length) {
|
|
1193
|
+
const first = errs[0];
|
|
1194
|
+
fail(`replacement would break the document: ${first.message} (line ${first.line}); not written`, 1);
|
|
1195
|
+
}
|
|
1196
|
+
const now = new Set(reparsed.ids);
|
|
1197
|
+
if (!now.has(id))
|
|
1198
|
+
fail(`replacement removes id \`${id}\`; not written`, 1);
|
|
1199
|
+
const dropped = beforeIds.find((x) => x !== id && !now.has(x));
|
|
1200
|
+
if (dropped !== undefined) {
|
|
1201
|
+
fail(`replacement would drop block \`#${dropped}\` (malformed content?); not written`, 1);
|
|
1202
|
+
}
|
|
1203
|
+
return updated;
|
|
1204
|
+
}
|
|
1205
|
+
// `geml revert <file.geml> #id [--to <sel>] [--changed] [--dry-run] [-o out] [--history PATH]`
|
|
1206
|
+
// Restore ONE block to a past revision's version — a targeted, guarded splice
|
|
1207
|
+
// that leaves the rest of the document untouched. <sel> (default `-1`): `-N` (N
|
|
1208
|
+
// revisions back from current), `latest`, or an id prefix/suffix. `--changed`
|
|
1209
|
+
// skips revisions that never touched the block, landing on its previous
|
|
1210
|
+
// *distinct* version. `--dry-run` prints what would be spliced in, writing
|
|
1211
|
+
// nothing. Writes in place by default (revert is a mutation); `-o` redirects.
|
|
1212
|
+
function runRevert(args) {
|
|
1213
|
+
const changed = args.includes("--changed");
|
|
1214
|
+
const dryRun = args.includes("--dry-run");
|
|
1215
|
+
const headOnly = args.includes("--head");
|
|
1216
|
+
const out = flag(args, "-o") ?? flag(args, "--out");
|
|
1217
|
+
const to = flag(args, "--to") ?? "-1";
|
|
1218
|
+
const [file, rawId] = positionals(args, ["--to", "--history", "-o", "--out"]);
|
|
1219
|
+
if (!file || !rawId)
|
|
1220
|
+
fail(SUBHELP.revert);
|
|
1221
|
+
if (file === "-")
|
|
1222
|
+
fail("revert needs a real file (it reads that file's .gemlhistory)", 2);
|
|
1223
|
+
const id = rawId.replace(/^#/, "");
|
|
1224
|
+
const historyPath = flag(args, "--history") ?? historyPathFor(file);
|
|
1225
|
+
const source = readInput(file);
|
|
1226
|
+
const found = blockSpans(source).get(id);
|
|
1227
|
+
if (!found)
|
|
1228
|
+
fail(`no block with id \`${id}\` in ${file}`, 1);
|
|
1229
|
+
const curSpan = headOnly ? narrowToHead(found) : found;
|
|
1230
|
+
const curBlock = splitLines(source).slice(curSpan.start, curSpan.end).join("");
|
|
1231
|
+
// Extract block #id's source from a reconstructed revision (undefined if the
|
|
1232
|
+
// block did not exist there). Under `--head`, extract only the head line.
|
|
1233
|
+
const pick = (text) => {
|
|
1234
|
+
const s = blockSpans(text).get(id);
|
|
1235
|
+
if (!s)
|
|
1236
|
+
return undefined;
|
|
1237
|
+
const span = headOnly ? narrowToHead(s) : s;
|
|
1238
|
+
return splitLines(text).slice(span.start, span.end).join("");
|
|
1239
|
+
};
|
|
1240
|
+
// Resolve the source revision, formatting any history-layer error cleanly.
|
|
1241
|
+
const target = (() => {
|
|
1242
|
+
try {
|
|
1243
|
+
if (changed) {
|
|
1244
|
+
const found = firstChangedContent(historyPath, curBlock, pick);
|
|
1245
|
+
if (!found)
|
|
1246
|
+
fail(`no earlier revision changes \`${id}\``, 1);
|
|
1247
|
+
return found;
|
|
1248
|
+
}
|
|
1249
|
+
return resolveContent(historyPath, to);
|
|
1250
|
+
}
|
|
1251
|
+
catch (e) {
|
|
1252
|
+
fail(historyError(e, file, historyPath), 1);
|
|
1253
|
+
}
|
|
1254
|
+
})();
|
|
1255
|
+
const oldBlock = pick(target.text);
|
|
1256
|
+
if (oldBlock === undefined)
|
|
1257
|
+
fail(`block \`${id}\` does not exist at revision ${target.id}`, 1);
|
|
1258
|
+
if (oldBlock === curBlock) {
|
|
1259
|
+
console.error(`#${id} is unchanged at ${target.id}; nothing to revert${changed ? "" : " (try --to -2, or --changed)"}`);
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
if (dryRun) {
|
|
1263
|
+
console.error(`would revert #${id} to ${target.id}:`);
|
|
1264
|
+
process.stdout.write(oldBlock.endsWith("\n") ? oldBlock : oldBlock + "\n");
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
const updated = spliceBlock(source, id, oldBlock, file, headOnly);
|
|
1268
|
+
const dest = out ?? file;
|
|
1269
|
+
writeFileSync(dest, updated);
|
|
1270
|
+
console.error(`reverted #${id} to ${target.id}${dest === file ? "" : ` -> ${dest}`}`);
|
|
1271
|
+
}
|
|
1272
|
+
// geml codemap <sub>: the code-graph toolkit ships as plain scripts in the
|
|
1273
|
+
// package's codemap/ directory (they are argv-driven programs, some
|
|
1274
|
+
// long-running like `serve`) — dispatch = run the script in a child node
|
|
1275
|
+
// with the remaining arguments, propagating the exit code.
|
|
1276
|
+
function runCodemap(args) {
|
|
1277
|
+
const scripts = {
|
|
1278
|
+
build: "build.mjs",
|
|
1279
|
+
verify: "verify.mjs",
|
|
1280
|
+
render: "render-all.mjs",
|
|
1281
|
+
serve: "serve.mjs",
|
|
1282
|
+
refresh: "refresh.mjs",
|
|
1283
|
+
find: "find.mjs",
|
|
1284
|
+
mcp: "mcp-server.mjs",
|
|
1285
|
+
};
|
|
1286
|
+
const sub = args[0] ?? "";
|
|
1287
|
+
const script = scripts[sub];
|
|
1288
|
+
if (!script)
|
|
1289
|
+
fail(`unknown codemap subcommand '${sub}'.\n${SUBHELP.codemap}`);
|
|
1290
|
+
const mod = join(dirname(fileURLToPath(import.meta.url)), "..", "codemap", script);
|
|
1291
|
+
const r = spawnSync(process.execPath, [mod, ...args.slice(1)], { stdio: "inherit" });
|
|
1292
|
+
process.exit(r.status ?? 1);
|
|
1293
|
+
}
|
|
1294
|
+
// npm's unix bin shim is a symlink named plain `geml`, so detect "run as a
|
|
1295
|
+
// CLI" by resolving argv[1] to its real path, not by its spelling.
|
|
1296
|
+
const entry = (() => {
|
|
1297
|
+
const argv1 = process.argv[1];
|
|
1298
|
+
if (!argv1)
|
|
1299
|
+
return "";
|
|
1300
|
+
try {
|
|
1301
|
+
return realpathSync(argv1);
|
|
1302
|
+
}
|
|
1303
|
+
catch {
|
|
1304
|
+
return argv1;
|
|
1305
|
+
}
|
|
1306
|
+
})();
|
|
1307
|
+
// `entry` must be non-empty: in a browser bundle both sides degenerate to ""
|
|
1308
|
+
// (esbuild defines process.argv=[] and import.meta.url="", and the node-stub's
|
|
1309
|
+
// fileURLToPath is String()), which would run the CLI at import time and crash
|
|
1310
|
+
// the page. A real CLI invocation always has argv[1].
|
|
1311
|
+
if (entry && (entry === fileURLToPath(import.meta.url) || entry.endsWith("geml.ts"))) {
|
|
678
1312
|
const argv = process.argv.slice(2);
|
|
679
|
-
|
|
1313
|
+
// The on-disk artifact is `.geml-code-graph/`, so people reconstruct the
|
|
1314
|
+
// command from the directory name — accept those spellings as `codemap`.
|
|
1315
|
+
const cmd = argv[0] === "codegraph" || argv[0] === "code-graph" ? "codemap" : argv[0];
|
|
680
1316
|
jsonMode = argv.includes("--json");
|
|
681
1317
|
const rest = argv.slice(1);
|
|
682
1318
|
if (cmd === "--help" || cmd === "-h") {
|
|
@@ -697,6 +1333,15 @@ if (entry.endsWith("geml.js") || entry.endsWith("geml.ts")) {
|
|
|
697
1333
|
// stdout, exit 0 — never the `error:`-prefixed exit-2 path.
|
|
698
1334
|
console.log(SUBHELP[cmd]);
|
|
699
1335
|
}
|
|
1336
|
+
else if (cmd === "get") {
|
|
1337
|
+
runGet(argv.slice(1));
|
|
1338
|
+
}
|
|
1339
|
+
else if (cmd === "set") {
|
|
1340
|
+
runSet(argv.slice(1));
|
|
1341
|
+
}
|
|
1342
|
+
else if (cmd === "revert") {
|
|
1343
|
+
runRevert(argv.slice(1));
|
|
1344
|
+
}
|
|
700
1345
|
else if (cmd === "history") {
|
|
701
1346
|
runHistory(argv.slice(1));
|
|
702
1347
|
}
|
|
@@ -715,6 +1360,9 @@ if (entry.endsWith("geml.js") || entry.endsWith("geml.ts")) {
|
|
|
715
1360
|
else if (cmd === "check") {
|
|
716
1361
|
runCheck(argv.slice(1));
|
|
717
1362
|
}
|
|
1363
|
+
else if (cmd === "codemap") {
|
|
1364
|
+
runCodemap(argv.slice(1));
|
|
1365
|
+
}
|
|
718
1366
|
else if (cmd !== "-" && !/[.\/\\]/.test(cmd)) {
|
|
719
1367
|
// A bare word that is neither a known command nor a path is almost always
|
|
720
1368
|
// a mistyped command — say so, don't try to read it as a file.
|