@geml/geml 1.1.1 → 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.
@@ -0,0 +1,367 @@
1
+ #!/usr/bin/env node
2
+ // geml-code-graph SFC virtualizer — Vue/Svelte single-file components become
3
+ // indexable TypeScript. scip-typescript cannot read .vue/.svelte; this script
4
+ // projects each SFC to a shadow TS file (script AND template — an
5
+ // @click="save" in a Vue template becomes a real reference to save), plus a
6
+ // line-mapping sidecar the scip adapter uses to attribute every symbol back
7
+ // to the original .vue/.svelte source. Env-driven like joern-export.sc:
8
+ //
9
+ // GEML_SRC project (sub)root to walk for .vue/.svelte and real TS/JS
10
+ // GEML_OUT output dir (the "virtual dir"): shadows + sidecars + tsconfig
11
+ //
12
+ // The build invokes it hermetically — @geml/geml stays zero-dependency:
13
+ // npx -y -p @vue/language-core -p svelte2tsx -p svelte -p typescript@5 \
14
+ // node <abs path to this script>
15
+ // npx does NOT put the -p packages on NODE_PATH; it prepends
16
+ // <npm-cache>/_npx/<hash>/node_modules/.bin to PATH. We derive the
17
+ // node_modules dir from that PATH entry and createRequire() out of it
18
+ // (proven on Windows); the target project's own node_modules and this
19
+ // script's context are fallbacks, in that order. typescript is pinned @5:
20
+ // typescript@latest is 7.x, which @vue/language-core does not accept.
21
+ //
22
+ // Vue — @vue/language-core (Volar): createVueLanguagePlugin().createVirtualCode()
23
+ // and the `script_ts|js|tsx|jsx` embedded code, whose text contains the
24
+ // <script> verbatim plus the template projection; its offset mappings
25
+ // [sourceOffsets, generatedOffsets, lengths] become the line map.
26
+ // Svelte — svelte2tsx {code, map}: everything lands inside a generated
27
+ // $$render(); the source-map v3 `mappings` VLQ string is decoded here
28
+ // (no dependency) into the same line-map shape.
29
+ //
30
+ // Per SFC <rel>.vue|.svelte the virtual dir receives:
31
+ // <rel>.<vue|svelte>.<ts|js|tsx|jsx> the shadow (".vue.ts" naming makes
32
+ // `import x from './App.vue'` resolve
33
+ // to the shadow by TS extension probing)
34
+ // <rel>.….map.json the mapping sidecar:
35
+ // { "version": 1,
36
+ // "original": "src/App.vue", // posix, relative to GEML_SRC
37
+ // "framework": "vue" | "svelte",
38
+ // "component": "App", // basename minus extension
39
+ // "lines": [[generatedLine, originalLine], …], // 1-based, sorted by
40
+ // // generatedLine, first mapping wins;
41
+ // // a generated line absent here is
42
+ // // PURE GENERATED (never attribute it)
43
+ // "regions": [{ "name": "template", "start": 8, "end": 11 }] }
44
+ // // 1-based ORIGINAL line spans of
45
+ // // template/markup — a reference whose
46
+ // // mapped line falls here but whose
47
+ // // caller is generated-only belongs to
48
+ // // the synthetic <Component>.template
49
+ // plus one sfc-manifest.json ({ src, files:[{shadow, original, map}] }) the
50
+ // scip adapter loads, and one synthetic tsconfig.json.
51
+ //
52
+ // tsconfig include strategy: explicit "files" only (shadows + the project's
53
+ // real .ts/.tsx/.js/.jsx via relative paths) — no include globs, so sibling
54
+ // virtual dirs, node_modules and build output can never leak in.
55
+ // "rootDirs": [".", <rel GEML_SRC>] merges the two trees for RELATIVE import
56
+ // resolution: a shadow's `./helper` finds the real <src>/helper.ts, a real
57
+ // main.ts's `./App.vue` finds the shadow App.vue.ts. Bare imports (vue,
58
+ // svelte) resolve through "paths" → every node_modules dir walking up from
59
+ // GEML_SRC, so hoisted monorepo layouts work.
60
+ import { readdirSync, readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync } from "node:fs";
61
+ import { join, dirname, relative, resolve, basename, delimiter } from "node:path";
62
+ import { createRequire } from "node:module";
63
+ import { SKIP_DIRS } from "./detect.mjs";
64
+
65
+ const posix = (p) => p.replace(/\\/g, "/");
66
+
67
+ // ---- library resolution (npx -p → project → this script) -------------------
68
+ function makeResolver(srcAbs) {
69
+ const requires = [];
70
+ const npxBin = (process.env.PATH ?? "")
71
+ .split(delimiter)
72
+ .find((p) => /[\\/]_npx[\\/]/.test(p) && /[\\/]\.bin[\\/]?$/.test(p));
73
+ if (npxBin) {
74
+ try { requires.push(createRequire(join(npxBin.replace(/[\\/]\.bin[\\/]?$/, ""), "x.js"))); } catch { /* malformed PATH entry */ }
75
+ }
76
+ for (let d = srcAbs; ; ) {
77
+ if (existsSync(join(d, "node_modules"))) {
78
+ try { requires.push(createRequire(join(d, "node_modules", "x.js"))); } catch { /* keep walking */ }
79
+ }
80
+ const up = dirname(d);
81
+ if (up === d) break;
82
+ d = up;
83
+ }
84
+ try { requires.push(createRequire(import.meta.url)); } catch { /* no local context */ }
85
+ const lib = (name) => {
86
+ for (const r of requires) { try { return r(name); } catch { /* next origin */ } }
87
+ return null;
88
+ };
89
+ lib.path = (name) => {
90
+ for (const r of requires) { try { return r.resolve(name); } catch { /* next origin */ } }
91
+ return null;
92
+ };
93
+ return lib;
94
+ }
95
+
96
+ // ---- shared line helpers ----------------------------------------------------
97
+ // offset -> 0-based line, O(log n) over precomputed line starts.
98
+ function lineIndex(text) {
99
+ const starts = [0];
100
+ for (let i = 0; i < text.length; i++) if (text[i] === "\n") starts.push(i + 1);
101
+ return (off) => {
102
+ let lo = 0, hi = starts.length - 1;
103
+ while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (starts[mid] <= off) lo = mid; else hi = mid - 1; }
104
+ return lo;
105
+ };
106
+ }
107
+
108
+ // Volar offset mappings -> [[genLine, origLine], …] (1-based). A multi-line
109
+ // mapping segment is a verbatim copy, so lines advance in lockstep.
110
+ function linePairsFromVolar(srcText, genText, mappings) {
111
+ const srcLine = lineIndex(srcText), genLine = lineIndex(genText);
112
+ const triples = [];
113
+ for (const m of mappings) {
114
+ for (let i = 0; i < m.sourceOffsets.length; i++) {
115
+ triples.push([m.sourceOffsets[i], m.generatedOffsets[i], m.lengths?.[i] ?? 0]);
116
+ }
117
+ }
118
+ triples.sort((a, b) => a[1] - b[1]);
119
+ const pairs = new Map(); // genLine0 -> origLine0, first mapping wins
120
+ for (const [s, g, l] of triples) {
121
+ const g0 = genLine(g), s0 = srcLine(s);
122
+ const span = genLine(g + Math.max(l - 1, 0)) - g0;
123
+ for (let k = 0; k <= span; k++) if (!pairs.has(g0 + k)) pairs.set(g0 + k, s0 + k);
124
+ }
125
+ return [...pairs.entries()].sort((a, b) => a[0] - b[0]).map(([g, s]) => [g + 1, s + 1]);
126
+ }
127
+
128
+ // Source-map v3 `mappings` VLQ -> [[genLine, origLine], …] (1-based). Tiny
129
+ // hand-rolled base64-VLQ decoder — per generated line the FIRST segment that
130
+ // names a source line wins. Fields per segment: [genCol, srcIdx, srcLine,
131
+ // srcCol, name]; all deltas except genCol carry across lines.
132
+ function linePairsFromV3(mappings) {
133
+ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
134
+ const val = new Map([...B64].map((c, i) => [c, i]));
135
+ const pairs = [];
136
+ let srcIdx = 0, srcLine = 0, srcCol = 0, name = 0;
137
+ const rows = String(mappings).split(";");
138
+ for (let g = 0; g < rows.length; g++) {
139
+ let taken = false;
140
+ for (const seg of rows[g].split(",")) {
141
+ if (!seg) continue;
142
+ const fields = [];
143
+ let shift = 0, cur = 0;
144
+ for (const ch of seg) {
145
+ const d = val.get(ch);
146
+ cur |= (d & 31) << shift;
147
+ if (d & 32) { shift += 5; continue; }
148
+ fields.push(cur & 1 ? -(cur >>> 1) : cur >>> 1);
149
+ shift = 0; cur = 0;
150
+ }
151
+ if (fields.length >= 4) {
152
+ srcIdx += fields[1]; srcLine += fields[2]; srcCol += fields[3];
153
+ if (fields.length >= 5) name += fields[4];
154
+ if (!taken) { pairs.push([g + 1, srcLine + 1]); taken = true; }
155
+ }
156
+ }
157
+ }
158
+ return pairs;
159
+ }
160
+
161
+ // ---- walk --------------------------------------------------------------------
162
+ function walk(rootAbs) {
163
+ const sfc = [], real = [];
164
+ const rec = (dir) => {
165
+ let ents;
166
+ try { ents = readdirSync(dir, { withFileTypes: true }); } catch { return; }
167
+ for (const e of ents) {
168
+ if (e.isDirectory()) {
169
+ if (!SKIP_DIRS.has(e.name) && !e.name.startsWith(".")) rec(join(dir, e.name));
170
+ continue;
171
+ }
172
+ if (!e.isFile()) continue;
173
+ const rel = posix(relative(rootAbs, join(dir, e.name)));
174
+ if (/\.(vue|svelte)$/i.test(e.name)) sfc.push(rel);
175
+ else if (/\.(ts|tsx|js|jsx)$/i.test(e.name) && !e.name.endsWith(".d.ts")) real.push(rel);
176
+ }
177
+ };
178
+ rec(rootAbs);
179
+ return { sfc: sfc.sort(), real: real.sort() };
180
+ }
181
+
182
+ // ---- vue ---------------------------------------------------------------------
183
+ function makeVue(lib) {
184
+ const ts = lib("typescript");
185
+ const core = lib("@vue/language-core");
186
+ if (!ts || !core) return null;
187
+ const vueOptions = core.resolveVueCompilerOptions
188
+ ? core.resolveVueCompilerOptions({}) // language-core 2.x
189
+ : core.getDefaultCompilerOptions(); // language-core 3.x
190
+ const plugin = core.createVueLanguagePlugin(ts, { allowJs: true }, vueOptions, (id) => id);
191
+ return (absPosix, text) => {
192
+ const code = plugin.createVirtualCode(absPosix, "vue", ts.ScriptSnapshot.fromString(text));
193
+ if (!code) throw new Error("not a valid vue file for this plugin");
194
+ let embedded;
195
+ for (const ec of core.forEachEmbeddedCode(code)) {
196
+ if (/^script_(ts|tsx|js|jsx)$/.test(ec.id)) { embedded = ec; break; }
197
+ }
198
+ if (!embedded) throw new Error("no script embedded code produced");
199
+ const gen = embedded.snapshot.getText(0, embedded.snapshot.getLength());
200
+ const regions = [];
201
+ const tpl = code.vueSfc?.descriptor?.template?.loc;
202
+ if (tpl) regions.push({ name: "template", start: tpl.start.line, end: tpl.end.line });
203
+ else {
204
+ // parse-less fallback: the top-level <template> block by text position
205
+ const idx = lineIndex(text);
206
+ const open = text.search(/<template[\s>]/);
207
+ const close = text.lastIndexOf("</template>");
208
+ if (open >= 0 && close > open) regions.push({ name: "template", start: idx(open) + 1, end: idx(close) + 1 });
209
+ }
210
+ return {
211
+ ext: embedded.id.slice("script_".length),
212
+ gen,
213
+ lines: linePairsFromVolar(text, gen, embedded.mappings),
214
+ regions,
215
+ };
216
+ };
217
+ }
218
+
219
+ // ---- svelte ------------------------------------------------------------------
220
+ function makeSvelte(lib) {
221
+ const mod = lib("svelte2tsx");
222
+ if (!mod) return null;
223
+ const svelte2tsx = mod.svelte2tsx ?? mod;
224
+ return (absPosix, text) => {
225
+ const isTsFile = /<script[^>]*\blang\s*=\s*["']?ts/.test(text);
226
+ const out = svelte2tsx(text, { filename: absPosix, isTsFile, mode: "ts" });
227
+ // markup = every line outside top-level <script>/<style> blocks, trimmed
228
+ // to its non-blank extent; a component may hold several disjoint spans.
229
+ const idx = lineIndex(text);
230
+ const total = idx(text.length) + 1;
231
+ const blocked = new Array(total).fill(false);
232
+ for (const m of text.matchAll(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/g)) {
233
+ const a = idx(m.index), b = idx(m.index + m[0].length - 1);
234
+ for (let i = a; i <= b; i++) blocked[i] = true;
235
+ }
236
+ const srcLines = text.split("\n");
237
+ const regions = [];
238
+ for (let i = 0; i < total; i++) {
239
+ if (blocked[i] || !srcLines[i]?.trim()) continue;
240
+ let j = i;
241
+ while (j + 1 < total && !blocked[j + 1]) j++;
242
+ while (j > i && !srcLines[j]?.trim()) j--;
243
+ regions.push({ name: "template", start: i + 1, end: j + 1 });
244
+ i = j;
245
+ }
246
+ return { ext: "ts", gen: out.code, lines: linePairsFromV3(out.map.mappings), regions };
247
+ };
248
+ }
249
+
250
+ // ---- main --------------------------------------------------------------------
251
+ const srcAbs = resolve(process.env.GEML_SRC ?? ".");
252
+ const outAbs = resolve(process.env.GEML_OUT ?? "");
253
+ if (!process.env.GEML_OUT) {
254
+ console.error("sfc-virtualize: GEML_OUT (output dir) is required; GEML_SRC defaults to cwd");
255
+ process.exit(2);
256
+ }
257
+
258
+ const lib = makeResolver(srcAbs);
259
+ const { sfc, real } = walk(srcAbs);
260
+ if (!sfc.length) {
261
+ console.error(`sfc-virtualize: no .vue/.svelte files under ${srcAbs} — nothing to do`);
262
+ process.exit(1);
263
+ }
264
+
265
+ let vue = null, svelte = null;
266
+ const missing = [];
267
+ if (sfc.some((f) => /\.vue$/i.test(f))) {
268
+ vue = makeVue(lib);
269
+ if (!vue) missing.push("@vue/language-core (+ typescript@5)");
270
+ }
271
+ if (sfc.some((f) => /\.svelte$/i.test(f))) {
272
+ svelte = makeSvelte(lib);
273
+ if (!svelte) missing.push("svelte2tsx (+ svelte)");
274
+ }
275
+ if (missing.length) {
276
+ console.error(
277
+ `sfc-virtualize: cannot resolve ${missing.join(" and ")} — run via\n`
278
+ + " npx -y -p @vue/language-core -p svelte2tsx -p svelte -p typescript@5 node <this script>\n"
279
+ + "or install them in the target project.",
280
+ );
281
+ process.exit(1);
282
+ }
283
+
284
+ mkdirSync(outAbs, { recursive: true });
285
+ const manifest = [];
286
+ let nVue = 0, nSvelte = 0, failed = 0;
287
+ for (const rel of sfc) {
288
+ const absPosix = posix(join(srcAbs, rel));
289
+ try {
290
+ const text = readFileSync(join(srcAbs, rel), "utf8");
291
+ const isVue = /\.vue$/i.test(rel);
292
+ const r = (isVue ? vue : svelte)(absPosix, text);
293
+ const shadowRel = `${rel}.${r.ext}`;
294
+ const shadowAbs = join(outAbs, shadowRel);
295
+ mkdirSync(dirname(shadowAbs), { recursive: true });
296
+ writeFileSync(shadowAbs, r.gen);
297
+ const mapRel = `${shadowRel}.map.json`;
298
+ writeFileSync(join(outAbs, mapRel), JSON.stringify({
299
+ version: 1,
300
+ original: rel,
301
+ framework: isVue ? "vue" : "svelte",
302
+ component: basename(rel).replace(/\.(vue|svelte)$/i, ""),
303
+ lines: r.lines,
304
+ regions: r.regions,
305
+ }));
306
+ manifest.push({ shadow: shadowRel, original: rel, map: mapRel });
307
+ isVue ? nVue++ : nSvelte++;
308
+ } catch (e) {
309
+ failed++;
310
+ console.error(`sfc-virtualize: FAILED ${rel}: ${e.message}`);
311
+ }
312
+ }
313
+
314
+ if (!manifest.length) {
315
+ console.error("sfc-virtualize: every SFC failed to virtualize — nothing to index");
316
+ process.exit(1);
317
+ }
318
+
319
+ // svelte2tsx global shims (svelteHTML etc) — cosmetic for indexing (user
320
+ // symbols resolve without them) but cheap to include when findable.
321
+ const extraFiles = [];
322
+ if (nSvelte) {
323
+ for (const shim of ["svelte2tsx/svelte-shims-v4.d.ts", "svelte2tsx/svelte-shims.d.ts"]) {
324
+ const p = lib.path(shim);
325
+ if (p) {
326
+ try {
327
+ copyFileSync(p, join(outAbs, "svelte-shims.d.ts"));
328
+ extraFiles.push("svelte-shims.d.ts");
329
+ } catch { /* optional */ }
330
+ break;
331
+ }
332
+ }
333
+ }
334
+
335
+ const relSrc = posix(relative(outAbs, srcAbs)) || ".";
336
+ const nmPaths = [];
337
+ for (let d = srcAbs; ; ) {
338
+ if (existsSync(join(d, "node_modules"))) nmPaths.push(`${posix(relative(outAbs, join(d, "node_modules")))}/*`);
339
+ const up = dirname(d);
340
+ if (up === d) break;
341
+ d = up;
342
+ }
343
+ writeFileSync(join(outAbs, "tsconfig.json"), JSON.stringify({
344
+ compilerOptions: {
345
+ allowJs: true, checkJs: false, noEmit: true, skipLibCheck: true,
346
+ module: "esnext", target: "esnext", moduleResolution: "node",
347
+ jsx: "preserve", baseUrl: ".",
348
+ rootDirs: [".", relSrc],
349
+ ...(nmPaths.length ? { paths: { "*": nmPaths } } : {}),
350
+ },
351
+ files: [
352
+ ...manifest.map((m) => m.shadow),
353
+ ...extraFiles,
354
+ ...real.map((f) => `${relSrc}/${f}`),
355
+ ],
356
+ }, null, 2));
357
+
358
+ writeFileSync(join(outAbs, "sfc-manifest.json"), JSON.stringify({
359
+ version: 1,
360
+ src: posix(srcAbs),
361
+ files: manifest,
362
+ }, null, 2));
363
+
364
+ console.error(
365
+ `sfc-virtualize: ${manifest.length} shadow(s) (${nVue} vue, ${nSvelte} svelte)`
366
+ + `${failed ? `, ${failed} FAILED` : ""}, ${real.length} real source file(s) -> ${outAbs}`,
367
+ );
@@ -1,126 +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
- 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);
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;