@geml/geml 1.0.0 → 1.1.1
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 +79 -72
- package/codemap/adapters/crg.mjs +109 -0
- package/codemap/adapters/joern.mjs +131 -0
- package/codemap/adapters/scip.mjs +229 -0
- package/codemap/browser-stub.mjs +24 -0
- package/codemap/build.mjs +354 -0
- package/codemap/detect.mjs +185 -0
- package/codemap/emit.mjs +361 -0
- package/codemap/exclude.mjs +52 -0
- package/codemap/joern-export.sc +83 -0
- package/codemap/mcp-server.mjs +143 -0
- package/codemap/normalize.mjs +0 -0
- package/codemap/refresh.mjs +160 -0
- package/codemap/render-all.mjs +64 -0
- package/codemap/serve.mjs +378 -0
- package/codemap/verify.mjs +126 -0
- package/dist/geml.d.ts +5 -0
- package/dist/geml.js +390 -14
- package/dist/history.d.ts +30 -0
- package/dist/history.js +153 -13
- package/dist/render.d.ts +59 -0
- package/dist/render.js +1685 -11
- package/dist/to-md.js +1 -3
- package/package.json +9 -5
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// geml-code-graph verify — the codemap's correctness oracle, two passes:
|
|
3
|
+
//
|
|
4
|
+
// 1. `geml check` over every .geml (document structure, id uniqueness,
|
|
5
|
+
// native references).
|
|
6
|
+
// 2. The codemap-profile pass (docs/codemap-profile.md): CSV cells and meta
|
|
7
|
+
// values are opaque to the GEML standard BY DESIGN (the standard stays
|
|
8
|
+
// untouched), so edge integrity is checked here — the from/to columns of
|
|
9
|
+
// #calls / #called-by / #ref-by tables and every meta `entry` value must
|
|
10
|
+
// resolve (`#id` in the same document, `doc.geml#id` in a sibling).
|
|
11
|
+
// A renamed or deleted method therefore fails the build, not the reader.
|
|
12
|
+
//
|
|
13
|
+
// geml codemap verify [dir] [--geml <path-to-geml.js|geml>]
|
|
14
|
+
import { readdirSync, existsSync, readFileSync } from "node:fs";
|
|
15
|
+
import { join, resolve, dirname, relative } from "node:path";
|
|
16
|
+
import { posix } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
const flagI = args.indexOf("--geml");
|
|
22
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
23
|
+
console.error("usage: geml codemap verify [dir] [--geml <path>] (dir defaults to ./.geml-code-graph)");
|
|
24
|
+
process.exit(2);
|
|
25
|
+
}
|
|
26
|
+
const dir = args.find((a, i) => !a.startsWith("-") && (flagI < 0 || i !== flagI + 1)) || ".geml-code-graph";
|
|
27
|
+
const rootDir = resolve(dir);
|
|
28
|
+
|
|
29
|
+
// Resolve the geml CLI (pass 1) and the parser API (pass 2).
|
|
30
|
+
const localParser = resolve(dirname(fileURLToPath(import.meta.url)), "../dist/geml.js");
|
|
31
|
+
let cli = flagI >= 0 ? args[flagI + 1] : undefined;
|
|
32
|
+
if (!cli) cli = existsSync(localParser) ? localParser : "geml";
|
|
33
|
+
const runCheck = (file) => cli.endsWith(".js")
|
|
34
|
+
? spawnSync(process.execPath, [cli, "check", file], { encoding: "utf8" })
|
|
35
|
+
: spawnSync(cli, ["check", file], { encoding: "utf8", shell: process.platform === "win32" });
|
|
36
|
+
if (!existsSync(localParser)) {
|
|
37
|
+
console.error("verify: the profile pass needs the built parser (cd geml-parser && npm install && npm run build)");
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
const { parse } = await import(`file://${localParser.replace(/\\/g, "/")}`);
|
|
41
|
+
|
|
42
|
+
const files = [];
|
|
43
|
+
const walk = (d) => {
|
|
44
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
45
|
+
const p = join(d, e.name);
|
|
46
|
+
if (e.isDirectory()) walk(p);
|
|
47
|
+
else if (e.name.endsWith(".geml")) files.push(p);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
walk(rootDir);
|
|
51
|
+
files.sort();
|
|
52
|
+
|
|
53
|
+
// ---- pass 1: geml check ----
|
|
54
|
+
let failed = 0;
|
|
55
|
+
for (const f of files) {
|
|
56
|
+
const r = runCheck(f);
|
|
57
|
+
if (r.status !== 0) {
|
|
58
|
+
failed++;
|
|
59
|
+
console.error(`FAIL ${f}`);
|
|
60
|
+
console.error((r.stderr || r.stdout || "").split("\n").slice(0, 4).map((l) => ` ${l}`).join("\n"));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---- pass 2: codemap profile references ----
|
|
65
|
+
const REF_TABLES = new Set(["calls", "called-by", "ref-by"]);
|
|
66
|
+
const relDoc = (f) => relative(rootDir, f).replace(/\\/g, "/");
|
|
67
|
+
const docs = new Map(); // relPath -> { ids:Set, blocks }
|
|
68
|
+
const collectIds = (blocks, ids) => {
|
|
69
|
+
for (const b of blocks) {
|
|
70
|
+
if (b.id) ids.add(b.id);
|
|
71
|
+
if (b.children) collectIds(b.children, ids);
|
|
72
|
+
if (b.items) for (const it of b.items) if (it.children) collectIds(it.children, ids);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
for (const f of files) {
|
|
76
|
+
const doc = parse(readFileSync(f, "utf8"));
|
|
77
|
+
const ids = new Set();
|
|
78
|
+
collectIds(doc.children, ids);
|
|
79
|
+
docs.set(relDoc(f), { ids, blocks: doc.children });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let refErrors = 0;
|
|
83
|
+
const err = (doc, where, msg) => {
|
|
84
|
+
refErrors++;
|
|
85
|
+
console.error(`REF ${doc} ${where}: ${msg}`);
|
|
86
|
+
};
|
|
87
|
+
const checkRef = (fromDoc, where, ref) => {
|
|
88
|
+
ref = String(ref).trim();
|
|
89
|
+
if (!ref) return err(fromDoc, where, "empty reference cell");
|
|
90
|
+
const h = ref.indexOf("#");
|
|
91
|
+
if (h < 0) return err(fromDoc, where, `not a reference: \`${ref}\``);
|
|
92
|
+
let targetDoc = fromDoc;
|
|
93
|
+
if (h > 0) targetDoc = posix.normalize(posix.join(posix.dirname(fromDoc), ref.slice(0, h)));
|
|
94
|
+
const id = ref.slice(h + 1);
|
|
95
|
+
const target = docs.get(targetDoc);
|
|
96
|
+
if (!target) return err(fromDoc, where, `cannot resolve document \`${ref.slice(0, h)}\``);
|
|
97
|
+
// reads/writes values may carry a plain-text `.member` suffix (ids never contain '.')
|
|
98
|
+
const bare = id.split(".")[0];
|
|
99
|
+
if (!target.ids.has(bare)) return err(fromDoc, where, `unresolved reference \`${ref}\``);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
for (const [docPath, { blocks }] of docs) {
|
|
103
|
+
for (const b of blocks) {
|
|
104
|
+
if (b.kind !== "block") continue;
|
|
105
|
+
if (b.type === "table" && REF_TABLES.has(b.id) && b.table) {
|
|
106
|
+
const fromCol = b.table.columns.indexOf("from");
|
|
107
|
+
const toCol = b.table.columns.indexOf("to");
|
|
108
|
+
if (fromCol < 0 || toCol < 0) { err(docPath, `#${b.id}`, "missing from/to columns"); continue; }
|
|
109
|
+
b.table.rows.forEach((row, i) => {
|
|
110
|
+
checkRef(docPath, `#${b.id} row ${i + 1} from`, row[fromCol]?.text ?? "");
|
|
111
|
+
checkRef(docPath, `#${b.id} row ${i + 1} to`, row[toCol]?.text ?? "");
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (b.type === "meta" && b.data?.entry) {
|
|
115
|
+
for (const ref of String(b.data.entry).split(/\s+/).filter(Boolean)) {
|
|
116
|
+
checkRef(docPath, "meta entry", ref);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
console.error(
|
|
123
|
+
`verify: ${files.length - failed}/${files.length} documents pass geml check; `
|
|
124
|
+
+ `profile references: ${refErrors === 0 ? "all resolve" : `${refErrors} dangling`}`,
|
|
125
|
+
);
|
|
126
|
+
process.exit(failed || refErrors ? 1 : 0);
|
package/dist/geml.d.ts
CHANGED
|
@@ -68,3 +68,8 @@ export interface ParseOptions {
|
|
|
68
68
|
resolveDoc?: (doc: string) => string | null;
|
|
69
69
|
}
|
|
70
70
|
export declare function parse(source: string, opts?: ParseOptions): Document;
|
|
71
|
+
export interface Span {
|
|
72
|
+
start: number;
|
|
73
|
+
end: number;
|
|
74
|
+
}
|
|
75
|
+
export declare function blockSpans(source: string): Map<string, Span>;
|
package/dist/geml.js
CHANGED
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
// media embeds, links, auto-references, footnotes) and build-time reference
|
|
10
10
|
// validation (§8 — unique ids, resolvable internal/cross-document references).
|
|
11
11
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
12
|
-
import { basename, dirname, resolve as resolvePath } from "node:path";
|
|
13
|
-
import {
|
|
12
|
+
import { basename, dirname, join, resolve as resolvePath } 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";
|
|
14
16
|
import { renderHtml } from "./render.js";
|
|
15
17
|
import { coerce, parseAttrs } from "./attrs.js";
|
|
16
18
|
import { parseInline } from "./inline.js";
|
|
@@ -32,12 +34,11 @@ 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
|
-
aside: "flow",
|
|
36
37
|
meta: "data",
|
|
37
38
|
};
|
|
38
39
|
// §7: built-in diagram renderer registry. Unknown formats are a warning (the
|
|
39
40
|
// processor keeps the body raw rather than interpreting it).
|
|
40
|
-
const DIAGRAM_RENDERERS = new Set(["mermaid", "graphviz", "dot", "d2", "plantuml", "geml-chart"]);
|
|
41
|
+
const DIAGRAM_RENDERERS = new Set(["mermaid", "graphviz", "dot", "d2", "plantuml", "geml-chart", "geml-code-graph"]);
|
|
41
42
|
// ---------------------------------------------------------------------------
|
|
42
43
|
// Lexical helpers
|
|
43
44
|
// ---------------------------------------------------------------------------
|
|
@@ -143,6 +144,11 @@ function parseList(lines, i, base, ctx) {
|
|
|
143
144
|
stack.push({ list: cur, indent: mk.indent });
|
|
144
145
|
}
|
|
145
146
|
else {
|
|
147
|
+
// §5: a change of marker type (bullet ↔ ordered) at the same level ends
|
|
148
|
+
// this list; scanBlocks then opens a fresh one at this marker. Without it,
|
|
149
|
+
// `- a` then `1. b` would merge into one mis-typed list (CommonMark §5.3).
|
|
150
|
+
if (mk.ordered !== top.list.ordered)
|
|
151
|
+
break;
|
|
146
152
|
cur = top.list;
|
|
147
153
|
}
|
|
148
154
|
if (prevBlank && cur.items.length > 0)
|
|
@@ -265,6 +271,21 @@ function scanBlocks(lines, base, ctx) {
|
|
|
265
271
|
}
|
|
266
272
|
(ctx.charts ??= []).push({ block, line: openLineNo });
|
|
267
273
|
}
|
|
274
|
+
else if (fmt === "geml-code-graph") {
|
|
275
|
+
// Code-graph embed (GEP-0003): the ONLY attribute is src=, pointing
|
|
276
|
+
// at a codemap document; roots/depth come from that document's meta
|
|
277
|
+
// ("view config travels with the data"). Body is empty.
|
|
278
|
+
const src = attrs.attrs["src"];
|
|
279
|
+
if (typeof src !== "string" || src === "") {
|
|
280
|
+
diags.push({ severity: "warning", message: "geml-code-graph: missing `src=` (nothing to render)", line: openLineNo });
|
|
281
|
+
}
|
|
282
|
+
else if (ctx.resolveDoc && ctx.resolveDoc(src) === null) {
|
|
283
|
+
diags.push({ severity: "warning", message: `geml-code-graph: cannot resolve document \`${src}\``, line: openLineNo });
|
|
284
|
+
}
|
|
285
|
+
if (body.length > 0 && body.some((l) => l.trim() !== "")) {
|
|
286
|
+
diags.push({ severity: "warning", message: "geml-code-graph body is ignored; the embed is configured by `src=` alone", line: openLineNo });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
268
289
|
else if (typeof fmt === "string" && !DIAGRAM_RENDERERS.has(fmt)) {
|
|
269
290
|
// §7: warn on a diagram format with no registered renderer.
|
|
270
291
|
diags.push({ severity: "warning", message: `no registered renderer for diagram format \`${fmt}\`; body kept raw`, line: openLineNo });
|
|
@@ -424,12 +445,115 @@ function resolveCharts(ctx) {
|
|
|
424
445
|
}
|
|
425
446
|
export function parse(source, opts = {}) {
|
|
426
447
|
const lines = source.replace(/\r\n?/g, "\n").split("\n");
|
|
427
|
-
const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines) };
|
|
448
|
+
const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines), resolveDoc: opts.resolveDoc };
|
|
428
449
|
const children = scanBlocks(lines, 0, ctx);
|
|
429
450
|
resolveCharts(ctx);
|
|
430
451
|
validateRefs(ctx, opts);
|
|
431
452
|
return { kind: "document", children, ids: [...ctx.ids.keys()], diagnostics: ctx.diags };
|
|
432
453
|
}
|
|
454
|
+
// The id that a fence/heading line defines, matching how scanBlocks derives it
|
|
455
|
+
// (parseAttrs for the attribute object; heading text slug when no explicit id).
|
|
456
|
+
function idOfHeading(braces, text) {
|
|
457
|
+
return (braces ? parseAttrs(braces).id : undefined) ?? slug(text);
|
|
458
|
+
}
|
|
459
|
+
// Walk `lines` exactly as scanBlocks does — same fence close rules (equal-length
|
|
460
|
+
// or labeled `=== #id`), same flow-only recursion via REGISTRY — recording the
|
|
461
|
+
// source span of every addressable id (typed block, heading, footnote def).
|
|
462
|
+
// First definition wins, mirroring ctx.ids (a duplicate id is a build error, so
|
|
463
|
+
// `get`/`set` operate on the one the parser actually registered). `base` is the
|
|
464
|
+
// absolute line offset of this slice within the whole document.
|
|
465
|
+
function collectSpans(lines, base, out) {
|
|
466
|
+
const add = (id, start, end) => {
|
|
467
|
+
if (!out.has(id))
|
|
468
|
+
out.set(id, { start, end });
|
|
469
|
+
};
|
|
470
|
+
let i = 0;
|
|
471
|
+
while (i < lines.length) {
|
|
472
|
+
const line = lines[i];
|
|
473
|
+
if (line.trim() === "") {
|
|
474
|
+
i++;
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
const fndef = /^\[\^([^\]]+)\]:[ \t]?(.*)$/.exec(line);
|
|
478
|
+
if (fndef) {
|
|
479
|
+
add(fndef[1].trim(), base + i, base + i + 1);
|
|
480
|
+
i++;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
if (/^[ \t]*%%/.test(line)) {
|
|
484
|
+
i++;
|
|
485
|
+
continue;
|
|
486
|
+
} // hidden line: no id
|
|
487
|
+
const open = FENCE_OPEN.exec(line);
|
|
488
|
+
if (open) {
|
|
489
|
+
const openLen = open[1].length;
|
|
490
|
+
const type = open[2];
|
|
491
|
+
const id = open[3] ? parseAttrs(open[3]).id : undefined;
|
|
492
|
+
const labeled = id !== undefined ? new RegExp(`^={3,}[ \\t]+#${id}[ \\t]*$`) : null;
|
|
493
|
+
let j = i + 1;
|
|
494
|
+
let closed = false;
|
|
495
|
+
for (; j < lines.length; j++) {
|
|
496
|
+
if (isCloseFence(lines[j], openLen) || (labeled && labeled.test(lines[j]))) {
|
|
497
|
+
closed = true;
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
const end = closed ? j + 1 : j;
|
|
502
|
+
if (id !== undefined)
|
|
503
|
+
add(id, base + i, base + end);
|
|
504
|
+
// Only a flow body is scanned for nested blocks (raw/data bodies are
|
|
505
|
+
// opaque), so an id inside a `code` body is *not* addressable — exactly
|
|
506
|
+
// the parser's contract.
|
|
507
|
+
if ((REGISTRY[type] ?? "raw") === "flow") {
|
|
508
|
+
collectSpans(lines.slice(i + 1, closed ? j : end), base + i + 1, out);
|
|
509
|
+
}
|
|
510
|
+
i = end;
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
const h = HEADING.exec(line);
|
|
514
|
+
if (h) {
|
|
515
|
+
add(idOfHeading(h[3], h[2]), base + i, base + i + 1);
|
|
516
|
+
i++;
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
i++;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
// Map every addressable id in `source` to its source span. Line indices align
|
|
523
|
+
// with the physical lines produced by splitLines(source).
|
|
524
|
+
export function blockSpans(source) {
|
|
525
|
+
const out = new Map();
|
|
526
|
+
collectSpans(source.replace(/\r\n?/g, "\n").split("\n"), 0, out);
|
|
527
|
+
return out;
|
|
528
|
+
}
|
|
529
|
+
// Split into physical lines while *keeping* each line's terminator, so
|
|
530
|
+
// join("") is byte-exact and slicing by span never rewrites line endings.
|
|
531
|
+
function splitLines(source) {
|
|
532
|
+
return source.split(/(?<=\n)/);
|
|
533
|
+
}
|
|
534
|
+
// Depth-first search for the document-model node carrying `id`, descending into
|
|
535
|
+
// flow-block children (and list-item children) so a nested id is found too.
|
|
536
|
+
function findBlockById(blocks, id) {
|
|
537
|
+
for (const b of blocks) {
|
|
538
|
+
if ((b.kind === "heading" || b.kind === "block") && b.id === id)
|
|
539
|
+
return b;
|
|
540
|
+
if (b.kind === "block" && b.children) {
|
|
541
|
+
const hit = findBlockById(b.children, id);
|
|
542
|
+
if (hit)
|
|
543
|
+
return hit;
|
|
544
|
+
}
|
|
545
|
+
if (b.kind === "list") {
|
|
546
|
+
for (const it of b.items) {
|
|
547
|
+
if (it.children) {
|
|
548
|
+
const hit = findBlockById(it.children, id);
|
|
549
|
+
if (hit)
|
|
550
|
+
return hit;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return undefined;
|
|
556
|
+
}
|
|
433
557
|
// ---------------------------------------------------------------------------
|
|
434
558
|
// CLI
|
|
435
559
|
// ---------------------------------------------------------------------------
|
|
@@ -447,18 +571,22 @@ function parseStamp(s) {
|
|
|
447
571
|
const [, y, mo, d, h, mi, se] = m;
|
|
448
572
|
return new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +se));
|
|
449
573
|
}
|
|
450
|
-
const VERSION = "1.0
|
|
451
|
-
const PARSER_VERSION = "1.
|
|
574
|
+
const VERSION = "1.0"; // GEML spec version this CLI targets
|
|
575
|
+
const PARSER_VERSION = "1.1.1"; // reference implementation; keep in sync with package.json
|
|
452
576
|
const USAGE = `geml — GEML reference CLI
|
|
453
577
|
|
|
454
578
|
Usage:
|
|
455
579
|
geml <file.geml|-> parse -> document-model JSON (stdout)
|
|
580
|
+
geml get <file.geml|-> #id [--json] print ONE block by id (raw span, or --json node)
|
|
581
|
+
geml set <file.geml|-> #id [--from f][-o f] replace ONE block by id (new content: --from/stdin)
|
|
582
|
+
geml revert <file.geml> #id [--to <sel>] restore ONE block to a past revision (sel: -N|latest|id)
|
|
456
583
|
geml check <file.geml|-> [--json] validate only: diagnostics + exit code
|
|
457
584
|
geml render <file.geml|-> [-o out.html] render to one self-contained HTML file
|
|
458
585
|
geml fmt <file.geml|-> [-o out.geml] re-serialize to canonical GEML
|
|
459
586
|
geml convert <file.md|-> [-o out.geml] Markdown -> GEML
|
|
460
587
|
geml export <file.geml|-> [-o out.md] GEML -> Markdown (lossy)
|
|
461
|
-
geml history <commit|verify|show|restore> <file.geml> [...]
|
|
588
|
+
geml history <commit|verify|show|restore|log> <file.geml> [...]
|
|
589
|
+
geml codemap <build|verify|render|serve|refresh|mcp> [...] code-graph toolkit (geml codemap --help)
|
|
462
590
|
geml --help | --version [--json]
|
|
463
591
|
|
|
464
592
|
Use '-' as the file to read from stdin.
|
|
@@ -466,23 +594,36 @@ Exit codes: 0 ok · 1 document/operation error · 2 usage error.`;
|
|
|
466
594
|
// One-line usage for each subcommand — the single source for both the error
|
|
467
595
|
// shown on misuse and the `<cmd> --help` text.
|
|
468
596
|
const SUBHELP = {
|
|
597
|
+
get: "usage: geml get <file.geml|-> #id [--json]",
|
|
598
|
+
set: "usage: geml set <file.geml|-> #id [--from FILE] [-o out.geml]",
|
|
469
599
|
check: "usage: geml check <file.geml|-> [--json]",
|
|
470
600
|
render: "usage: geml render <file.geml|-> [-o out.html]",
|
|
471
601
|
convert: "usage: geml convert <file.md|-> [-o out.geml]",
|
|
472
602
|
export: "usage: geml export <file.geml|-> [-o out.md]",
|
|
473
603
|
fmt: "usage: geml fmt <file.geml|-> [-o out.geml]",
|
|
474
|
-
|
|
604
|
+
revert: "usage: geml revert <file.geml> #id [--to <sel>] [--changed] [--dry-run] [-o out] (sel: -N | latest | id-prefix; default -1)",
|
|
605
|
+
history: "usage: geml history <commit|verify|show|restore|log> <file.geml> [...]",
|
|
606
|
+
codemap: `usage: geml codemap build --root <repo> # auto-detect languages, run the indexer(s), and merge into one codemap
|
|
607
|
+
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]]
|
|
608
|
+
geml codemap verify [dir] geml check + profile reference checks
|
|
609
|
+
geml codemap render [dir] every doc -> sibling .html (open index.html from disk)
|
|
610
|
+
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
|
|
611
|
+
geml codemap refresh [dir] [--force] [--commit] [--background|--hook] re-run the recorded build recipe (_index/refresh.json); --commit lands it as its own commit
|
|
612
|
+
geml codemap mcp stdio MCP server (GEML_GRAPH_DIR or graph_dir arg)
|
|
613
|
+
(<dir> for verify/render/serve/refresh defaults to ./.geml-code-graph)`,
|
|
475
614
|
};
|
|
476
615
|
// Set from argv at dispatch time; when true, errors are emitted as a JSON
|
|
477
616
|
// envelope so an agent that standardizes on --json never has to parse text.
|
|
478
617
|
let jsonMode = false;
|
|
479
|
-
// Clean one-line error + non-zero exit — never a raw Node stack trace.
|
|
480
|
-
|
|
618
|
+
// Clean one-line error + non-zero exit — never a raw Node stack trace. `code`
|
|
619
|
+
// is the process exit status: 2 for a usage error (the default), 1 for a
|
|
620
|
+
// document/operation error. `--json` wraps it in the same {error, code} envelope.
|
|
621
|
+
function fail(msg, code = 2) {
|
|
481
622
|
if (jsonMode)
|
|
482
|
-
console.error(JSON.stringify({ error: msg, code
|
|
623
|
+
console.error(JSON.stringify({ error: msg, code }));
|
|
483
624
|
else
|
|
484
625
|
console.error(`error: ${msg}`);
|
|
485
|
-
process.exit(
|
|
626
|
+
process.exit(code);
|
|
486
627
|
}
|
|
487
628
|
// Read a file, or stdin when the path is "-". On failure emit a clean error.
|
|
488
629
|
function readInput(file) {
|
|
@@ -580,6 +721,14 @@ function runHistory(args) {
|
|
|
580
721
|
restore({ historyPath, gemlPath: file, revision: rev, write: true, force: args.includes("--force") });
|
|
581
722
|
console.log(`restored ${file} to ${rev}`);
|
|
582
723
|
}
|
|
724
|
+
else if (sub === "log") {
|
|
725
|
+
// Newest-first, with the `--to` selector for each row in the first column
|
|
726
|
+
// (`latest` for the tip, then `-1`, `-2`, …) so the output is copy-paste.
|
|
727
|
+
for (const r of listRevisions(historyPath)) {
|
|
728
|
+
const sel = r.current ? "latest" : `-${r.offset}`;
|
|
729
|
+
console.log(`${sel.padEnd(7)} ${r.id} ${r.author ?? "-"} ${r.summary ?? ""}`.replace(/\s+$/, ""));
|
|
730
|
+
}
|
|
731
|
+
}
|
|
583
732
|
else {
|
|
584
733
|
fail(`unknown history subcommand: ${sub}. Run 'geml --help'.`);
|
|
585
734
|
}
|
|
@@ -638,7 +787,12 @@ function runRender(args) {
|
|
|
638
787
|
if (!file)
|
|
639
788
|
fail(SUBHELP.render);
|
|
640
789
|
const doc = parse(readInput(file), { resolveDoc: resolverFor(file) });
|
|
641
|
-
const html = renderHtml(doc, {
|
|
790
|
+
const html = renderHtml(doc, {
|
|
791
|
+
source: file === "-" ? "stdin" : basename(file),
|
|
792
|
+
// geml-code-graph embeds load + parse sibling codemap documents on demand.
|
|
793
|
+
loadDoc: resolverFor(file),
|
|
794
|
+
parseDoc: (s) => parse(s),
|
|
795
|
+
});
|
|
642
796
|
if (out) {
|
|
643
797
|
writeFileSync(out, html);
|
|
644
798
|
console.error(`wrote ${out}`);
|
|
@@ -673,6 +827,216 @@ function runFmt(args) {
|
|
|
673
827
|
if (doc.diagnostics.some((d) => d.severity === "error"))
|
|
674
828
|
process.exit(1);
|
|
675
829
|
}
|
|
830
|
+
// Positional args (a file, an id) are the non-flag tokens that aren't the value
|
|
831
|
+
// of a value-taking flag. `-` (stdin) is a positional, not a flag. An id may be
|
|
832
|
+
// written `#id` or `id`; a leading `-` never begins an id, so this stays
|
|
833
|
+
// unambiguous. `valued` lists the flags that consume the following token.
|
|
834
|
+
function positionals(args, valued) {
|
|
835
|
+
const out = [];
|
|
836
|
+
for (let i = 0; i < args.length; i++) {
|
|
837
|
+
const a = args[i];
|
|
838
|
+
if (valued.includes(a)) {
|
|
839
|
+
i++;
|
|
840
|
+
continue;
|
|
841
|
+
} // skip the flag *and* its value
|
|
842
|
+
if (a === "-") {
|
|
843
|
+
out.push(a);
|
|
844
|
+
continue;
|
|
845
|
+
}
|
|
846
|
+
if (a.startsWith("-"))
|
|
847
|
+
continue; // a bare flag (e.g. --json)
|
|
848
|
+
out.push(a);
|
|
849
|
+
}
|
|
850
|
+
return out;
|
|
851
|
+
}
|
|
852
|
+
// `geml get <file.geml|-> #id [--json]` — print ONE block, addressed by id,
|
|
853
|
+
// without loading the rest of the document into context. Default output is the
|
|
854
|
+
// block's exact source bytes (its full `=== … ===` span, or the source line for
|
|
855
|
+
// a heading/footnote); `--json` prints that block's document-model node.
|
|
856
|
+
function runGet(args) {
|
|
857
|
+
const json = args.includes("--json");
|
|
858
|
+
const [file, rawId] = positionals(args, []);
|
|
859
|
+
if (!file || !rawId)
|
|
860
|
+
fail(SUBHELP.get);
|
|
861
|
+
const id = rawId.replace(/^#/, "");
|
|
862
|
+
const source = readInput(file);
|
|
863
|
+
if (json) {
|
|
864
|
+
// The model node — same shape `geml <file>` emits for it. Parsing is needed
|
|
865
|
+
// to resolve the tree (and nested-block ids), but only the one node prints.
|
|
866
|
+
const doc = parse(source, { resolveDoc: resolverFor(file) });
|
|
867
|
+
const block = findBlockById(doc.children, id);
|
|
868
|
+
if (!block)
|
|
869
|
+
fail(`no block with id \`${id}\``, 1);
|
|
870
|
+
console.log(JSON.stringify(block, null, 2));
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
// Raw: slice the source span byte-for-byte. No parse required, so `get` still
|
|
874
|
+
// returns the exact bytes even if the document has diagnostics elsewhere.
|
|
875
|
+
const span = blockSpans(source).get(id);
|
|
876
|
+
if (!span)
|
|
877
|
+
fail(`no block with id \`${id}\``, 1);
|
|
878
|
+
process.stdout.write(splitLines(source).slice(span.start, span.end).join(""));
|
|
879
|
+
}
|
|
880
|
+
// `geml set <file.geml|-> #id [--from FILE] [-o out]` — replace ONLY that
|
|
881
|
+
// block's source span with new content (from --from or stdin), preserving every
|
|
882
|
+
// other byte. Prints the full updated document, or writes in place with -o. The
|
|
883
|
+
// splice is re-parsed and rejected if it broke the doc: `set` never writes a
|
|
884
|
+
// corrupt file.
|
|
885
|
+
function runSet(args) {
|
|
886
|
+
const out = flag(args, "-o") ?? flag(args, "--out");
|
|
887
|
+
const from = flag(args, "--from");
|
|
888
|
+
const [file, rawId] = positionals(args, ["-o", "--out", "--from"]);
|
|
889
|
+
if (!file || !rawId)
|
|
890
|
+
fail(SUBHELP.set);
|
|
891
|
+
const id = rawId.replace(/^#/, "");
|
|
892
|
+
// Both the document and the replacement can't come from stdin. Reject that up
|
|
893
|
+
// front — before consuming stdin — so the document read below is unambiguous.
|
|
894
|
+
if (file === "-" && from === undefined) {
|
|
895
|
+
fail("reading the document from stdin needs --from for the new content", 2);
|
|
896
|
+
}
|
|
897
|
+
const source = readInput(file);
|
|
898
|
+
// New content: an explicit --from file, else stdin.
|
|
899
|
+
let replacement;
|
|
900
|
+
if (from !== undefined) {
|
|
901
|
+
replacement = readInput(from);
|
|
902
|
+
}
|
|
903
|
+
else {
|
|
904
|
+
replacement = readInput("-");
|
|
905
|
+
if (replacement === "")
|
|
906
|
+
fail("no replacement content (use --from FILE or pipe it on stdin)", 1);
|
|
907
|
+
}
|
|
908
|
+
const updated = spliceBlock(source, id, replacement, file);
|
|
909
|
+
if (out) {
|
|
910
|
+
writeFileSync(out, updated);
|
|
911
|
+
console.error(`wrote ${out}`);
|
|
912
|
+
}
|
|
913
|
+
else
|
|
914
|
+
process.stdout.write(updated);
|
|
915
|
+
}
|
|
916
|
+
// Replace block #id's source span in `source` with `replacement`, preserving
|
|
917
|
+
// every other byte, and GUARD the result: the re-parse must be error-free, #id
|
|
918
|
+
// must survive, and no other pre-existing id may vanish (a malformed replacement
|
|
919
|
+
// can silently swallow a neighbour). Returns the updated document text; on any
|
|
920
|
+
// violation it calls fail() and never returns a corrupt document. Shared by
|
|
921
|
+
// `set` and `revert`.
|
|
922
|
+
function spliceBlock(source, id, replacement, file) {
|
|
923
|
+
const span = blockSpans(source).get(id);
|
|
924
|
+
if (!span)
|
|
925
|
+
fail(`no block with id \`${id}\``, 1);
|
|
926
|
+
const beforeIds = parse(source, { resolveDoc: resolverFor(file) }).ids;
|
|
927
|
+
// Keep the bytes before and after the target span exactly; give the new block
|
|
928
|
+
// a single trailing newline so the following block still starts on its own
|
|
929
|
+
// line (unless it is the file's last line, which may legitimately lack one).
|
|
930
|
+
const orig = splitLines(source);
|
|
931
|
+
const before = orig.slice(0, span.start);
|
|
932
|
+
const after = orig.slice(span.end);
|
|
933
|
+
let inject = replacement.replace(/\r\n?/g, "\n");
|
|
934
|
+
const lastLine = span.end >= orig.length;
|
|
935
|
+
if (!inject.endsWith("\n") && !lastLine)
|
|
936
|
+
inject += "\n";
|
|
937
|
+
const updated = before.join("") + inject + after.join("");
|
|
938
|
+
// Re-parse and refuse a broken result. A parse error or a duplicate id both
|
|
939
|
+
// surface as error diagnostics (registerId flags dups); one check covers both.
|
|
940
|
+
// Then require the target id to survive, and — because a malformed replacement
|
|
941
|
+
// can swallow a neighbour — that every other pre-existing id survives too.
|
|
942
|
+
const reparsed = parse(updated, { resolveDoc: resolverFor(file) });
|
|
943
|
+
const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
|
|
944
|
+
if (errs.length) {
|
|
945
|
+
const first = errs[0];
|
|
946
|
+
fail(`replacement would break the document: ${first.message} (line ${first.line}); not written`, 1);
|
|
947
|
+
}
|
|
948
|
+
const now = new Set(reparsed.ids);
|
|
949
|
+
if (!now.has(id))
|
|
950
|
+
fail(`replacement removes id \`${id}\`; not written`, 1);
|
|
951
|
+
const dropped = beforeIds.find((x) => x !== id && !now.has(x));
|
|
952
|
+
if (dropped !== undefined) {
|
|
953
|
+
fail(`replacement would drop block \`#${dropped}\` (malformed content?); not written`, 1);
|
|
954
|
+
}
|
|
955
|
+
return updated;
|
|
956
|
+
}
|
|
957
|
+
// `geml revert <file.geml> #id [--to <sel>] [--changed] [--dry-run] [-o out] [--history PATH]`
|
|
958
|
+
// Restore ONE block to a past revision's version — a targeted, guarded splice
|
|
959
|
+
// that leaves the rest of the document untouched. <sel> (default `-1`): `-N` (N
|
|
960
|
+
// revisions back from current), `latest`, or an id prefix/suffix. `--changed`
|
|
961
|
+
// skips revisions that never touched the block, landing on its previous
|
|
962
|
+
// *distinct* version. `--dry-run` prints what would be spliced in, writing
|
|
963
|
+
// nothing. Writes in place by default (revert is a mutation); `-o` redirects.
|
|
964
|
+
function runRevert(args) {
|
|
965
|
+
const changed = args.includes("--changed");
|
|
966
|
+
const dryRun = args.includes("--dry-run");
|
|
967
|
+
const out = flag(args, "-o") ?? flag(args, "--out");
|
|
968
|
+
const to = flag(args, "--to") ?? "-1";
|
|
969
|
+
const [file, rawId] = positionals(args, ["--to", "--history", "-o", "--out"]);
|
|
970
|
+
if (!file || !rawId)
|
|
971
|
+
fail(SUBHELP.revert);
|
|
972
|
+
if (file === "-")
|
|
973
|
+
fail("revert needs a real file (it reads that file's .gemlhistory)", 2);
|
|
974
|
+
const id = rawId.replace(/^#/, "");
|
|
975
|
+
const historyPath = flag(args, "--history") ?? historyPathFor(file);
|
|
976
|
+
const source = readInput(file);
|
|
977
|
+
const curSpan = blockSpans(source).get(id);
|
|
978
|
+
if (!curSpan)
|
|
979
|
+
fail(`no block with id \`${id}\` in ${file}`, 1);
|
|
980
|
+
const curBlock = splitLines(source).slice(curSpan.start, curSpan.end).join("");
|
|
981
|
+
// Extract block #id's source from a reconstructed revision (undefined if the
|
|
982
|
+
// block did not exist there).
|
|
983
|
+
const pick = (text) => {
|
|
984
|
+
const s = blockSpans(text).get(id);
|
|
985
|
+
return s ? splitLines(text).slice(s.start, s.end).join("") : undefined;
|
|
986
|
+
};
|
|
987
|
+
// Resolve the source revision, formatting any history-layer error cleanly.
|
|
988
|
+
const target = (() => {
|
|
989
|
+
try {
|
|
990
|
+
if (changed) {
|
|
991
|
+
const found = firstChangedContent(historyPath, curBlock, pick);
|
|
992
|
+
if (!found)
|
|
993
|
+
fail(`no earlier revision changes \`${id}\``, 1);
|
|
994
|
+
return found;
|
|
995
|
+
}
|
|
996
|
+
return resolveContent(historyPath, to);
|
|
997
|
+
}
|
|
998
|
+
catch (e) {
|
|
999
|
+
fail(historyError(e, file, historyPath), 1);
|
|
1000
|
+
}
|
|
1001
|
+
})();
|
|
1002
|
+
const oldBlock = pick(target.text);
|
|
1003
|
+
if (oldBlock === undefined)
|
|
1004
|
+
fail(`block \`${id}\` does not exist at revision ${target.id}`, 1);
|
|
1005
|
+
if (oldBlock === curBlock) {
|
|
1006
|
+
console.error(`#${id} is unchanged at ${target.id}; nothing to revert${changed ? "" : " (try --to -2, or --changed)"}`);
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
if (dryRun) {
|
|
1010
|
+
console.error(`would revert #${id} to ${target.id}:`);
|
|
1011
|
+
process.stdout.write(oldBlock.endsWith("\n") ? oldBlock : oldBlock + "\n");
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
const updated = spliceBlock(source, id, oldBlock, file);
|
|
1015
|
+
const dest = out ?? file;
|
|
1016
|
+
writeFileSync(dest, updated);
|
|
1017
|
+
console.error(`reverted #${id} to ${target.id}${dest === file ? "" : ` -> ${dest}`}`);
|
|
1018
|
+
}
|
|
1019
|
+
// geml codemap <sub>: the code-graph toolkit ships as plain scripts in the
|
|
1020
|
+
// package's codemap/ directory (they are argv-driven programs, some
|
|
1021
|
+
// long-running like `serve`) — dispatch = run the script in a child node
|
|
1022
|
+
// with the remaining arguments, propagating the exit code.
|
|
1023
|
+
function runCodemap(args) {
|
|
1024
|
+
const scripts = {
|
|
1025
|
+
build: "build.mjs",
|
|
1026
|
+
verify: "verify.mjs",
|
|
1027
|
+
render: "render-all.mjs",
|
|
1028
|
+
serve: "serve.mjs",
|
|
1029
|
+
refresh: "refresh.mjs",
|
|
1030
|
+
mcp: "mcp-server.mjs",
|
|
1031
|
+
};
|
|
1032
|
+
const sub = args[0] ?? "";
|
|
1033
|
+
const script = scripts[sub];
|
|
1034
|
+
if (!script)
|
|
1035
|
+
fail(`unknown codemap subcommand '${sub}'.\n${SUBHELP.codemap}`);
|
|
1036
|
+
const mod = join(dirname(fileURLToPath(import.meta.url)), "..", "codemap", script);
|
|
1037
|
+
const r = spawnSync(process.execPath, [mod, ...args.slice(1)], { stdio: "inherit" });
|
|
1038
|
+
process.exit(r.status ?? 1);
|
|
1039
|
+
}
|
|
676
1040
|
const entry = process.argv[1] ?? "";
|
|
677
1041
|
if (entry.endsWith("geml.js") || entry.endsWith("geml.ts")) {
|
|
678
1042
|
const argv = process.argv.slice(2);
|
|
@@ -697,6 +1061,15 @@ if (entry.endsWith("geml.js") || entry.endsWith("geml.ts")) {
|
|
|
697
1061
|
// stdout, exit 0 — never the `error:`-prefixed exit-2 path.
|
|
698
1062
|
console.log(SUBHELP[cmd]);
|
|
699
1063
|
}
|
|
1064
|
+
else if (cmd === "get") {
|
|
1065
|
+
runGet(argv.slice(1));
|
|
1066
|
+
}
|
|
1067
|
+
else if (cmd === "set") {
|
|
1068
|
+
runSet(argv.slice(1));
|
|
1069
|
+
}
|
|
1070
|
+
else if (cmd === "revert") {
|
|
1071
|
+
runRevert(argv.slice(1));
|
|
1072
|
+
}
|
|
700
1073
|
else if (cmd === "history") {
|
|
701
1074
|
runHistory(argv.slice(1));
|
|
702
1075
|
}
|
|
@@ -715,6 +1088,9 @@ if (entry.endsWith("geml.js") || entry.endsWith("geml.ts")) {
|
|
|
715
1088
|
else if (cmd === "check") {
|
|
716
1089
|
runCheck(argv.slice(1));
|
|
717
1090
|
}
|
|
1091
|
+
else if (cmd === "codemap") {
|
|
1092
|
+
runCodemap(argv.slice(1));
|
|
1093
|
+
}
|
|
718
1094
|
else if (cmd !== "-" && !/[.\/\\]/.test(cmd)) {
|
|
719
1095
|
// A bare word that is neither a known command nor a path is almost always
|
|
720
1096
|
// a mistyped command — say so, don't try to read it as a file.
|