@geml/geml 1.3.2 → 1.4.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.
@@ -1,143 +1,148 @@
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
- // win32 cmd.exe quoting (same rationale as build.mjs). q: space-aware quote for
34
- // the PROGRAM token — a bare launcher name (geml resolved via PATH) whose
35
- // .cmd/.bat shim uses %~dp0 breaks if the name is blanket-quoted. shq: ALWAYS
36
- // double-quote ARGUMENTS — Node does not escape args under shell:true, so a
37
- // `.geml` filename containing & | ( ) would otherwise break out and inject.
38
- // cmd.exe treats those metacharacters and whitespace as literal inside quotes;
39
- // CRT rules for embedded " / trailing \.
40
- const q = (s) => (/[\s"]/.test(String(s)) ? `"${String(s).replace(/"/g, '\\"')}"` : String(s));
41
- const shq = (s) => `"${String(s).replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1')}"`;
42
- const runCheck = (file) => {
43
- // The built-parser path: run node on geml.js directly (array args, no shell).
44
- if (cli.endsWith(".js")) return spawnSync(process.execPath, [cli, "check", file], { encoding: "utf8" });
45
- // A non-.js cli may be a .cmd/.bat launcher (e.g. geml.cmd on PATH), which
46
- // Node can only spawn through the shell. Hand cmd.exe ONE pre-escaped command
47
- // string (never an args array — that is the unescaped, injection-prone path).
48
- if (process.platform === "win32") {
49
- return spawnSync([q(cli), ...["check", file].map(shq)].join(" "), { encoding: "utf8", shell: true });
50
- }
51
- return spawnSync(cli, ["check", file], { encoding: "utf8" });
52
- };
53
- if (!existsSync(localParser)) {
54
- console.error("verify: the profile pass needs the built parser (cd geml-parser && npm install && npm run build)");
55
- process.exit(1);
56
- }
57
- const { parse } = await import(`file://${localParser.replace(/\\/g, "/")}`);
58
-
59
- const files = [];
60
- const walk = (d) => {
61
- for (const e of readdirSync(d, { withFileTypes: true })) {
62
- const p = join(d, e.name);
63
- if (e.isDirectory()) walk(p);
64
- else if (e.name.endsWith(".geml")) files.push(p);
65
- }
66
- };
67
- walk(rootDir);
68
- files.sort();
69
-
70
- // ---- pass 1: geml check ----
71
- let failed = 0;
72
- for (const f of files) {
73
- const r = runCheck(f);
74
- if (r.status !== 0) {
75
- failed++;
76
- console.error(`FAIL ${f}`);
77
- console.error((r.stderr || r.stdout || "").split("\n").slice(0, 4).map((l) => ` ${l}`).join("\n"));
78
- }
79
- }
80
-
81
- // ---- pass 2: codemap profile references ----
82
- const REF_TABLES = new Set(["calls", "called-by", "ref-by"]);
83
- const relDoc = (f) => relative(rootDir, f).replace(/\\/g, "/");
84
- const docs = new Map(); // relPath -> { ids:Set, blocks }
85
- const collectIds = (blocks, ids) => {
86
- for (const b of blocks) {
87
- if (b.id) ids.add(b.id);
88
- if (b.children) collectIds(b.children, ids);
89
- if (b.items) for (const it of b.items) if (it.children) collectIds(it.children, ids);
90
- }
91
- };
92
- for (const f of files) {
93
- const doc = parse(readFileSync(f, "utf8"));
94
- const ids = new Set();
95
- collectIds(doc.children, ids);
96
- docs.set(relDoc(f), { ids, blocks: doc.children });
97
- }
98
-
99
- let refErrors = 0;
100
- const err = (doc, where, msg) => {
101
- refErrors++;
102
- console.error(`REF ${doc} ${where}: ${msg}`);
103
- };
104
- const checkRef = (fromDoc, where, ref) => {
105
- ref = String(ref).trim();
106
- if (!ref) return err(fromDoc, where, "empty reference cell");
107
- const h = ref.indexOf("#");
108
- if (h < 0) return err(fromDoc, where, `not a reference: \`${ref}\``);
109
- let targetDoc = fromDoc;
110
- if (h > 0) targetDoc = posix.normalize(posix.join(posix.dirname(fromDoc), ref.slice(0, h)));
111
- const id = ref.slice(h + 1);
112
- const target = docs.get(targetDoc);
113
- if (!target) return err(fromDoc, where, `cannot resolve document \`${ref.slice(0, h)}\``);
114
- // reads/writes values may carry a plain-text `.member` suffix (ids never contain '.')
115
- const bare = id.split(".")[0];
116
- if (!target.ids.has(bare)) return err(fromDoc, where, `unresolved reference \`${ref}\``);
117
- };
118
-
119
- for (const [docPath, { blocks }] of docs) {
120
- for (const b of blocks) {
121
- if (b.kind !== "block") continue;
122
- if (b.type === "table" && REF_TABLES.has(b.id) && b.table) {
123
- const fromCol = b.table.columns.indexOf("from");
124
- const toCol = b.table.columns.indexOf("to");
125
- if (fromCol < 0 || toCol < 0) { err(docPath, `#${b.id}`, "missing from/to columns"); continue; }
126
- b.table.rows.forEach((row, i) => {
127
- checkRef(docPath, `#${b.id} row ${i + 1} from`, row[fromCol]?.text ?? "");
128
- checkRef(docPath, `#${b.id} row ${i + 1} to`, row[toCol]?.text ?? "");
129
- });
130
- }
131
- if (b.type === "meta" && b.data?.entry) {
132
- for (const ref of String(b.data.entry).split(/\s+/).filter(Boolean)) {
133
- checkRef(docPath, "meta entry", ref);
134
- }
135
- }
136
- }
137
- }
138
-
139
- console.error(
140
- `verify: ${files.length - failed}/${files.length} documents pass geml check; `
141
- + `profile references: ${refErrors === 0 ? "all resolve" : `${refErrors} dangling`}`,
142
- );
143
- process.exit(failed || refErrors ? 1 : 0);
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
+ // win32 cmd.exe quoting (same rationale as build.mjs). q: space-aware quote for
34
+ // the PROGRAM token — a bare launcher name (geml resolved via PATH) whose
35
+ // .cmd/.bat shim uses %~dp0 breaks if the name is blanket-quoted. shq: ALWAYS
36
+ // double-quote ARGUMENTS — Node does not escape args under shell:true, so a
37
+ // `.geml` filename containing & | ( ) would otherwise break out and inject.
38
+ // cmd.exe treats those metacharacters and whitespace as literal inside quotes;
39
+ // CRT rules for embedded " / trailing \.
40
+ const q = (s) => (/[\s"]/.test(String(s)) ? `"${String(s).replace(/"/g, '\\"')}"` : String(s));
41
+ const shq = (s) => `"${String(s).replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1')}"`;
42
+ const runCheck = (file) => {
43
+ // The built-parser path: run node on geml.js directly (array args, no shell).
44
+ if (cli.endsWith(".js")) return spawnSync(process.execPath, [cli, "check", file], { encoding: "utf8" });
45
+ // A non-.js cli may be a .cmd/.bat launcher (e.g. geml.cmd on PATH), which
46
+ // Node can only spawn through the shell. Hand cmd.exe ONE pre-escaped command
47
+ // string (never an args array — that is the unescaped, injection-prone path).
48
+ if (process.platform === "win32") {
49
+ return spawnSync([q(cli), ...["check", file].map(shq)].join(" "), { encoding: "utf8", shell: true });
50
+ }
51
+ return spawnSync(cli, ["check", file], { encoding: "utf8" });
52
+ };
53
+ if (!existsSync(localParser)) {
54
+ console.error("verify: the profile pass needs the built parser (cd geml-parser && npm install && npm run build)");
55
+ process.exit(1);
56
+ }
57
+ const { parse } = await import(`file://${localParser.replace(/\\/g, "/")}`);
58
+
59
+ const files = [];
60
+ const walk = (d) => {
61
+ for (const e of readdirSync(d, { withFileTypes: true })) {
62
+ const p = join(d, e.name);
63
+ if (e.isDirectory()) walk(p);
64
+ else if (e.name.endsWith(".geml")) files.push(p);
65
+ }
66
+ };
67
+ walk(rootDir);
68
+ files.sort();
69
+
70
+ // ---- pass 1: geml check ----
71
+ let failed = 0;
72
+ for (const f of files) {
73
+ const r = runCheck(f);
74
+ if (r.status !== 0) {
75
+ failed++;
76
+ console.error(`FAIL ${f}`);
77
+ console.error((r.stderr || r.stdout || "").split("\n").slice(0, 4).map((l) => ` ${l}`).join("\n"));
78
+ }
79
+ }
80
+
81
+ // ---- pass 2: codemap profile references ----
82
+ const REF_TABLES = new Set(["calls", "called-by", "ref-by"]);
83
+ // Cross-stack link tables: `from`/`to` may be a #ref (resolved cross-tree
84
+ // link checked) OR plain `file:line` text (a call/route outside any indexed
85
+ // function tolerated, nothing to resolve).
86
+ const LINK_TABLES = new Set(["api-calls", "api-served-by"]);
87
+ const relDoc = (f) => relative(rootDir, f).replace(/\\/g, "/");
88
+ const docs = new Map(); // relPath -> { ids:Set, blocks }
89
+ const collectIds = (blocks, ids) => {
90
+ for (const b of blocks) {
91
+ if (b.id) ids.add(b.id);
92
+ if (b.children) collectIds(b.children, ids);
93
+ if (b.items) for (const it of b.items) if (it.children) collectIds(it.children, ids);
94
+ }
95
+ };
96
+ for (const f of files) {
97
+ const doc = parse(readFileSync(f, "utf8"));
98
+ const ids = new Set();
99
+ collectIds(doc.children, ids);
100
+ docs.set(relDoc(f), { ids, blocks: doc.children });
101
+ }
102
+
103
+ let refErrors = 0;
104
+ const err = (doc, where, msg) => {
105
+ refErrors++;
106
+ console.error(`REF ${doc} ${where}: ${msg}`);
107
+ };
108
+ const checkRef = (fromDoc, where, ref, lenient = false) => {
109
+ ref = String(ref).trim();
110
+ if (!ref) return lenient ? undefined : err(fromDoc, where, "empty reference cell");
111
+ const h = ref.indexOf("#");
112
+ if (h < 0) return lenient ? undefined : err(fromDoc, where, `not a reference: \`${ref}\``);
113
+ let targetDoc = fromDoc;
114
+ if (h > 0) targetDoc = posix.normalize(posix.join(posix.dirname(fromDoc), ref.slice(0, h)));
115
+ const id = ref.slice(h + 1);
116
+ const target = docs.get(targetDoc);
117
+ if (!target) return err(fromDoc, where, `cannot resolve document \`${ref.slice(0, h)}\``);
118
+ // reads/writes values may carry a plain-text `.member` suffix (ids never contain '.')
119
+ const bare = id.split(".")[0];
120
+ if (!target.ids.has(bare)) return err(fromDoc, where, `unresolved reference \`${ref}\``);
121
+ };
122
+
123
+ for (const [docPath, { blocks }] of docs) {
124
+ for (const b of blocks) {
125
+ if (b.kind !== "block") continue;
126
+ if (b.type === "table" && (REF_TABLES.has(b.id) || LINK_TABLES.has(b.id)) && b.table) {
127
+ const lenient = LINK_TABLES.has(b.id);
128
+ const fromCol = b.table.columns.indexOf("from");
129
+ const toCol = b.table.columns.indexOf("to");
130
+ if (fromCol < 0 || toCol < 0) { err(docPath, `#${b.id}`, "missing from/to columns"); continue; }
131
+ b.table.rows.forEach((row, i) => {
132
+ checkRef(docPath, `#${b.id} row ${i + 1} from`, row[fromCol]?.text ?? "", lenient);
133
+ checkRef(docPath, `#${b.id} row ${i + 1} to`, row[toCol]?.text ?? "", lenient);
134
+ });
135
+ }
136
+ if (b.type === "meta" && b.data?.entry) {
137
+ for (const ref of String(b.data.entry).split(/\s+/).filter(Boolean)) {
138
+ checkRef(docPath, "meta entry", ref);
139
+ }
140
+ }
141
+ }
142
+ }
143
+
144
+ console.error(
145
+ `verify: ${files.length - failed}/${files.length} documents pass geml check; `
146
+ + `profile references: ${refErrors === 0 ? "all resolve" : `${refErrors} dangling`}`,
147
+ );
148
+ process.exit(failed || refErrors ? 1 : 0);
@@ -0,0 +1 @@
1
+ export declare function normalizeBlockId(blockSrc: string, newId: string): string;
@@ -0,0 +1,112 @@
1
+ // Structural id-rewriting for `geml set`. `set #id` names the block to edit,
2
+ // so the content spliced in must ADOPT that id — whatever id it declared (or
3
+ // none). This module performs that rewrite parse-aware (per head form) rather
4
+ // than by blind byte replacement, touching ONLY the id: type, classes,
5
+ // attributes, body and the fence pairing all ride along unchanged.
6
+ //
7
+ // Deliberately self-contained — it imports only the shared attribute parser,
8
+ // never geml.ts: geml.ts's module body runs the CLI on import, so a back-import
9
+ // would fire the whole command line just by loading this helper.
10
+ import { parseAttrs } from "./attrs.js";
11
+ // The two head forms, spelled to MIRROR geml.ts's FENCE_OPEN / HEADING (same
12
+ // language) but with the id-bearing brace tail split out so the id can be
13
+ // rewritten while every other byte is copied verbatim:
14
+ // FENCE_HEAD g1 = `=== type` g2 = ws g3 = `{…}`? g4 = trailing ws
15
+ // HEAD_HEAD g1 = `## text` g2 = ws g3 = `{…}`? g4 = trailing ws
16
+ const FENCE_HEAD = /^(={3,}[ \t]+[A-Za-z][A-Za-z0-9_-]*)([ \t]*)(\{.*\})?([ \t]*)$/;
17
+ const HEAD_HEAD = /^(#{1,6}[ \t]+.*?)([ \t]*)(\{[^}]*\})?([ \t]*)$/;
18
+ // Split into physical lines while keeping each line's terminator, so join("")
19
+ // is byte-exact — the same boundaries geml.ts's splitLines() uses. A line ends
20
+ // at `\n`, `\r\n`, or a lone `\r`.
21
+ function splitLines(source) {
22
+ return source.split(/(?<=\n|\r(?!\n))/);
23
+ }
24
+ // Strip a single trailing terminator from one physical line.
25
+ function stripEnding(line) {
26
+ return line.replace(/(\r\n|\r|\n)$/, "");
27
+ }
28
+ // Rewrite the id inside a `{…}` attribute block to `#newId`, keeping the braces
29
+ // and every other class/attr byte. If no id is present, insert `#newId` as the
30
+ // first token. The id token sits at a token boundary (`{` or whitespace) and
31
+ // never inside a quoted value, so the anchored match can't disturb a value like
32
+ // `caption="#x"`.
33
+ function rewriteBraces(braces, newId) {
34
+ if (parseAttrs(braces).id !== undefined) {
35
+ return braces.replace(/([{\s])#[^\s}]+/, `$1#${newId}`);
36
+ }
37
+ const inner = braces.slice(1, -1).replace(/^[ \t]*/, "");
38
+ return `{#${newId}${inner.length ? " " + inner : ""}}`;
39
+ }
40
+ // Rewrite a HEAD line's id declaration to `#newId`. Handles both head forms and
41
+ // all id states: existing brace id, brace attrs without an id, and no braces at
42
+ // all (append `{#newId}`). A line that is neither form is returned unchanged.
43
+ function rewriteHead(head, newId) {
44
+ const rebuild = (m) => {
45
+ const lead = m[1], ws = m[2] ?? "", braces = m[3], trail = m[4] ?? "";
46
+ if (braces)
47
+ return lead + ws + rewriteBraces(braces, newId) + trail;
48
+ return `${lead} {#${newId}}${ws}${trail}`;
49
+ };
50
+ const f = FENCE_HEAD.exec(head);
51
+ if (f)
52
+ return rebuild(f);
53
+ const h = HEAD_HEAD.exec(head);
54
+ if (h)
55
+ return rebuild(h);
56
+ return head;
57
+ }
58
+ // Locate the block's HEAD: the first non-blank, non-`%%` line that opens a fence
59
+ // or a heading. Returns its line index, or -1 when the content has no head
60
+ // (pure prose, or a structural line that is not a head) — the caller decides
61
+ // what that means.
62
+ function findHead(lines) {
63
+ for (let i = 0; i < lines.length; i++) {
64
+ const t = stripEnding(lines[i]);
65
+ if (t.trim() === "" || /^[ \t]*%%/.test(t))
66
+ continue;
67
+ if (FENCE_HEAD.test(t) || HEAD_HEAD.test(t))
68
+ return i;
69
+ return -1; // the first structural line isn't a head: no addressable block
70
+ }
71
+ return -1;
72
+ }
73
+ // Rewrite the HEAD id of the first block in `blockSrc` to `newId`, across every
74
+ // head form:
75
+ // • fence attrs `{#x …}` -> `{#newId …}` (other classes/attrs kept)
76
+ // • fence with attrs but no id, or no braces -> gains `{#newId}`
77
+ // • labeled close `=== #x` -> `=== #newId` (renamed to match the open)
78
+ // • heading `## T {#x}` -> `## T {#newId}`
79
+ // • heading auto-slug (no braces) -> `## T {#newId}` appended
80
+ // Only the id changes; type / classes / attrs / body / fence length are byte-
81
+ // preserved, as are line terminators. Content with no head is returned as-is.
82
+ export function normalizeBlockId(blockSrc, newId) {
83
+ const lines = splitLines(blockSrc);
84
+ const hi = findHead(lines);
85
+ if (hi < 0)
86
+ return blockSrc;
87
+ const headText = stripEnding(lines[hi]);
88
+ const headTerm = lines[hi].slice(headText.length);
89
+ lines[hi] = rewriteHead(headText, newId) + headTerm;
90
+ // For a fence carrying an id, a labeled close `=== #oldId` names that id and
91
+ // must be renamed too — otherwise the open declares #newId while the close
92
+ // still labels #oldId and the block no longer parses. The FIRST close wins
93
+ // (plain equal-length OR labeled), matching geml.ts's fenceClose scan; a
94
+ // plain close needs no rewrite.
95
+ const f = FENCE_HEAD.exec(headText);
96
+ const oldId = f && f[3] ? parseAttrs(f[3]).id : undefined;
97
+ if (f && oldId !== undefined) {
98
+ const openLen = /^=+/.exec(f[1])[0].length;
99
+ for (let j = hi + 1; j < lines.length; j++) {
100
+ const ct = stripEnding(lines[j]);
101
+ const trimmed = ct.replace(/[ \t]+$/, "");
102
+ if (/^=+$/.test(trimmed) && trimmed.length === openLen)
103
+ break; // plain close: done
104
+ const cm = /^(={3,}[ \t]+#)([^\s}]+)([ \t]*)$/.exec(ct);
105
+ if (cm && cm[2] === oldId) {
106
+ lines[j] = cm[1] + newId + cm[3] + lines[j].slice(ct.length);
107
+ break;
108
+ }
109
+ }
110
+ }
111
+ return lines.join("");
112
+ }