@geml/geml 1.4.2 → 1.4.3

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/codemap/emit.mjs CHANGED
@@ -1,480 +1,480 @@
1
- // geml-code-graph emit — exchange-format symbols/edges → the codemap document
2
- // tree of docs/DESIGN-codemap-delta.md (§4–6) / docs/codemap-profile.md:
3
- // one .geml per container (module|dir|file), each with EXACTLY ONE meta
4
- // (module/src/entry/resolution-default), empty-body `code` blocks per method
5
- // (src=path#Lx-y, anchor=; symbol-level classes .leaf/.test), and up to three
6
- // CSV edge tables: #calls (out), #called-by (in, aggregated), #unresolved
7
- // (blind spots, hidden). Plus index.geml (aggregates) and name-lookup.json.
8
- //
9
- // Generated documents are PURE DATA: no diagram blocks (a codemap-aware
10
- // renderer offers the layered-flow view; embedding elsewhere uses
11
- // `=== diagram {format=geml-code-graph src=…}`).
12
- //
13
- // Emission is deterministic: stable sort orders everywhere; a file is only
14
- // written when its bytes changed (mtime = "what a change touched").
15
- import { createHash } from "node:crypto";
16
- import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
17
- import { dirname, join, posix } from "node:path";
18
- import { buildNormalizer } from "./normalize.mjs";
19
-
20
- const esc = (s) => String(s).replace(/`/g, "'");
21
- // Attribute values live on the block-header LINE: a newline inside one (e.g.
22
- // scip's anonymous-type-literal descriptors embed the literal's multi-line
23
- // text in the symbol) would truncate the header mid-value — the block still
24
- // parses as raw text but its #id never registers, and every edge to it
25
- // dangles (found on next.js: `recursiveCopy().({ filter... })`). Collapse all
26
- // whitespace runs to single spaces alongside the quote swap.
27
- const attrVal = (s) => String(s).replace(/"/g, "'").replace(/\s+/g, " ");
28
- // Plain-text cells: no commas/newlines (CSV), and no square brackets — table
29
- // cells are inline-parsed, so `f[i](&x)` would otherwise read as a LINK with an
30
- // unresolvable target. Brackets become parens: still readable, never markup.
31
- const csvCell = (s) => String(s).replace(/[,\r\n]/g, " ").replace(/\[/g, "(").replace(/\]/g, ")").trim();
32
- const sha6 = (s, len = 6) => createHash("sha256").update(s, "utf8").digest("hex").slice(0, len);
33
-
34
- // Test territory (path conventions; the avowed heuristic of GEP-0002).
35
- const TEST_DIR = /(^|\/)(test|tests|testing|__tests__|spec|specs)(\/|$)/i;
36
- const TEST_FILE = /(^test_|^tests?\.|[._-]tests?\.|\.test\.|\.spec\.)/i;
37
- const isTestPath = (p) => {
38
- p = String(p).replace(/\\/g, "/");
39
- return TEST_DIR.test(p) || TEST_FILE.test(p.slice(p.lastIndexOf("/") + 1));
40
- };
41
-
42
- const slugName = (name) => {
43
- let s = String(name).replace(/[^A-Za-z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
44
- if (!/^[A-Za-z]/.test(s)) s = "s" + s;
45
- return s;
46
- };
47
- const slugPath = (p) => (p === "" || p === "(root)" ? "root" : p.replace(/\//g, "--").replace(/[^A-Za-z0-9_.-]/g, "-"));
48
- const dirOf = (rel) => { const i = rel.lastIndexOf("/"); return i < 0 ? "(root)" : rel.slice(0, i); };
49
- const topOf = (rel) => { const i = rel.indexOf("/"); return i < 0 ? "(root)" : rel.slice(0, i); };
50
-
51
- export function emit({ symbols, edges, outDir, buildDir, repoName, container = "dir", commit, root, foldings, entryHints }) {
52
- const byAnchor = new Map(symbols.map((s) => [s.anchor, s]));
53
- const methods = symbols.filter((s) => s.kind === "Function" || s.kind === "Test");
54
- const files = symbols.filter((s) => s.kind === "File");
55
-
56
- // ---- app-entry hints (codemap/entries.mjs) ----
57
- // A named hint marks its method; a file-level hint (SPA bootstrap, Nuxt app
58
- // shell…) marks the file's only method, or — when the entry is top-level
59
- // code with no function symbol at all — falls through to a doc-level
60
- // `app-entry-file` note. The adapters' own name==="main" flag rides along;
61
- // every marked entry carries `via` (the convention that identified it).
62
- const fileHints = [];
63
- {
64
- const methodsByFile = new Map();
65
- for (const s of methods) {
66
- if (!methodsByFile.has(s.file)) methodsByFile.set(s.file, []);
67
- methodsByFile.get(s.file).push(s);
68
- }
69
- for (const h of entryHints ?? []) {
70
- const list = methodsByFile.get(h.file) ?? [];
71
- let hit;
72
- if (h.name) hit = list.find((s) => s.name === h.name || s.name.endsWith(`::${h.name}`) || s.name.endsWith(`.${h.name}`));
73
- else if (list.length === 1) hit = list[0];
74
- if (hit) { hit.entry = true; hit.entryVia ??= h.via; }
75
- else fileHints.push(h);
76
- }
77
- for (const s of methods) if (s.entry && !s.entryVia) s.entryVia = "main";
78
- }
79
-
80
- // ---- containers ----
81
- const containerOf = (s) =>
82
- container === "file" ? s.file : container === "module" ? topOf(s.file) : dirOf(s.file);
83
- // Display-path normalisation: strip each module's shared ceremony prefix so
84
- // `module=`/doc names read as the real structure. Applies in every container
85
- // mode (dir and file) — a file-mode container path carries the same source
86
- // roots (geml-parser/src/render.ts -> geml-parser/render.ts). Grouping still
87
- // keys on the TRUE path (containerOf) and `src=` stays the true path — only
88
- // the displayed module path shortens. root may be absent (older callers / crg
89
- // tier): then displayOf is the identity.
90
- const normMap = root ? buildNormalizer(root, methods.map(containerOf), { repoName, fileMode: container === "file", config: foldings }) : new Map();
91
- const displayOf = (name) => normMap.get(name) ?? name;
92
- const containers = new Map(); // name -> { docName, methods[], files[] }
93
- const taken = new Set(["index.geml"]);
94
- const containerFor = (name) => {
95
- if (!containers.has(name)) {
96
- let doc = `${slugPath(displayOf(name))}.geml`;
97
- for (let i = 2; taken.has(doc); i++) doc = `${slugPath(displayOf(name))}-${i}.geml`;
98
- taken.add(doc);
99
- containers.set(name, { docName: doc, methods: [], files: [] });
100
- }
101
- return containers.get(name);
102
- };
103
- for (const s of methods) containerFor(containerOf(s)).methods.push(s);
104
- for (const s of files) {
105
- const c = containers.get(containerOf(s));
106
- if (c) c.files.push(s); // only files that actually host methods
107
- }
108
- const docOfAnchor = new Map();
109
- for (const [name, c] of containers) {
110
- for (const s of [...c.methods, ...c.files]) docOfAnchor.set(s.anchor, c.docName);
111
- }
112
- // File-level app-entry hints, grouped under the container that holds the
113
- // file (a hint whose file grew no container at all has nowhere honest to
114
- // land and is dropped).
115
- const fileHintsByDoc = new Map(); // docName -> [{file, via}]
116
- for (const h of fileHints) {
117
- const c = containers.get(containerOf({ file: h.file }));
118
- if (!c) continue;
119
- if (!fileHintsByDoc.has(c.docName)) fileHintsByDoc.set(c.docName, []);
120
- fileHintsByDoc.get(c.docName).push(h);
121
- }
122
-
123
- // ---- block ids: short name when unique in its doc, else name-<sha6(anchor)> ----
124
- const idOf = new Map(); // anchor -> id
125
- for (const [, c] of containers) {
126
- const byName = new Map();
127
- for (const s of [...c.methods, ...c.files]) {
128
- const base = slugName(s.name);
129
- if (!byName.has(base)) byName.set(base, []);
130
- byName.get(base).push(s);
131
- }
132
- for (const [base, list] of byName) {
133
- if (list.length === 1) { idOf.set(list[0].anchor, base); continue; }
134
- // Escalate hash length only when the default 6 hex chars actually collide
135
- // within this name group — keeps ids short in the common case.
136
- let len = 6;
137
- let ids;
138
- for (;;) {
139
- ids = list.map((s) => `${base}-${sha6(s.anchor, len)}`);
140
- if (new Set(ids).size === ids.length) break;
141
- // sha256 hex is 64 chars — beyond that only IDENTICAL anchors can
142
- // still collide, which is a caller bug (build.mjs dedupes anchors):
143
- // fail loudly instead of escalating forever.
144
- if (len >= 64) throw new Error(`emit: duplicate anchors in name group "${base}" — anchors must be unique`);
145
- len += 2;
146
- }
147
- list.forEach((s, i) => idOf.set(s.anchor, ids[i]));
148
- }
149
- }
150
-
151
- // ---- edges (stage A: the `calls` relation only) ----
152
- const calls = edges.filter((e) => e.kind === "calls");
153
- const outCalls = new Map(); // anchor -> total outgoing (incl. unresolved) — leaf rule
154
- const inBySym = new Map(); // target anchor -> [{fromAnchor, kind, site, confidence}]
155
- const outBySym = new Map(); // source anchor -> [{toAnchor, kind, confidence}] resolved
156
- const unresBySym = new Map(); // source anchor -> Set(to_text)
157
- const addIn = (target, rec) => {
158
- if (!byAnchor.has(target)) return;
159
- if (!inBySym.has(target)) inBySym.set(target, []);
160
- inBySym.get(target).push(rec);
161
- };
162
- for (const e of calls) {
163
- if (!docOfAnchor.has(e.from)) continue;
164
- outCalls.set(e.from, (outCalls.get(e.from) ?? 0) + 1);
165
- if (e.to !== undefined && docOfAnchor.has(e.to)) {
166
- if (!outBySym.has(e.from)) outBySym.set(e.from, []);
167
- const conf = e.confidence === "high" || !e.confidence ? "" : e.confidence;
168
- outBySym.get(e.from).push({ to: e.to, kind: "call", confidence: conf });
169
- addIn(e.to, { from: e.from, kind: "call", site: e.site, confidence: conf });
170
- for (const c of e.candidates ?? []) {
171
- if (!docOfAnchor.has(c)) continue;
172
- outBySym.get(e.from).push({ to: c, kind: "candidate", confidence: "" });
173
- addIn(c, { from: e.from, kind: "candidate", site: e.site, confidence: "" });
174
- }
175
- } else if (e.to_text) {
176
- if (!unresBySym.has(e.from)) unresBySym.set(e.from, new Set());
177
- unresBySym.get(e.from).add(e.to_text);
178
- }
179
- }
180
-
181
- // ---- edges (stage B: cross-stack `http` links) ----
182
- // Heuristic frontend-caller → backend-handler links (codemap/cross-stack.mjs),
183
- // emitted into their OWN #api-calls / #api-served-by tables so they never mix
184
- // with the verified `calls` graph. Either endpoint may be unresolved (a call
185
- // or route outside any indexed function) — then the *_text file:line rides in
186
- // the cell instead of a #ref.
187
- const httpOut = new Map(); // FE-fn anchor -> [http edge]
188
- const httpIn = new Map(); // BE-fn anchor -> [http edge]
189
- for (const e of edges) {
190
- if (e.kind !== "http") continue;
191
- if (e.from !== undefined && docOfAnchor.has(e.from)) {
192
- if (!httpOut.has(e.from)) httpOut.set(e.from, []);
193
- httpOut.get(e.from).push(e);
194
- }
195
- if (e.to !== undefined && docOfAnchor.has(e.to)) {
196
- if (!httpIn.has(e.to)) httpIn.set(e.to, []);
197
- httpIn.get(e.to).push(e);
198
- }
199
- }
200
-
201
- const isLeaf = (s) =>
202
- (s.kind === "Function" || s.kind === "Test") &&
203
- !(outCalls.get(s.anchor) > 0) && (inBySym.get(s.anchor)?.length ?? 0) >= 1;
204
- // Bean-style accessors that also call nothing: pure noise in a flow view.
205
- // Marked so renderers can hide them by default; the edge tables keep them.
206
- const isAccessor = (s) => isLeaf(s) && /^(get|set|is)(?![a-z])/.test(s.name);
207
-
208
- // ---- entry: called from outside its container, or an app entry (main) ----
209
- const isEntry = (s) => {
210
- if (s.entry) return true;
211
- const doc = docOfAnchor.get(s.anchor);
212
- return (inBySym.get(s.anchor) ?? []).some((r) => docOfAnchor.get(r.from) !== doc);
213
- };
214
-
215
- // A reference to `anchor` as seen from `fromDoc`.
216
- const refTo = (anchor, fromDoc) => {
217
- const doc = docOfAnchor.get(anchor);
218
- const id = idOf.get(anchor);
219
- return doc === fromDoc ? `#${id}` : `${posix.relative(posix.dirname(fromDoc), doc)}#${id}`;
220
- };
221
-
222
- // ---- write helper: deterministic, only-on-change ----
223
- const stats = { docs: 0, written: 0, bytes: 0 };
224
- const allDocs = [];
225
- const writtenDocs = [];
226
- const writeIfChanged = (relPath, content) => {
227
- const p = join(outDir, relPath);
228
- mkdirSync(dirname(p), { recursive: true });
229
- stats.docs++;
230
- stats.bytes += content.length;
231
- if (relPath.endsWith(".geml")) allDocs.push(relPath);
232
- if (existsSync(p) && readFileSync(p, "utf8") === content) return false;
233
- writeFileSync(p, content);
234
- stats.written++;
235
- if (relPath.endsWith(".geml")) writtenDocs.push(relPath);
236
- return true;
237
- };
238
-
239
- const RESOLUTION_DEFAULT = symbols.some((s) => s.resolution === "cpg") ? "cpg" : "heuristic";
240
- const csv = (id, columns, rows, extraAttrs = "") => {
241
- if (!rows.length) return null; // empty tables are not generated
242
- // Column width by loop, not Math.max(...spread) — a spread call over a
243
- // repo-scale table's rows blows the argument limit (same failure class
244
- // as the build.mjs merge).
245
- const widths = columns.map((c, i) => {
246
- let w = c.length;
247
- for (const r of rows) { const l = String(r[i] ?? "").length; if (l > w) w = l; }
248
- return w;
249
- });
250
- const line = (cells) => cells.map((v, i) =>
251
- i === cells.length - 1 ? String(v ?? "") : (String(v ?? "") + ",").padEnd(widths[i] + 2)).join("").replace(/\s+$/, "");
252
- return `=== table {#${id} format=csv${extraAttrs}}\n${line(columns)}\n${rows.map(line).join("\n")}\n===\n`;
253
- };
254
-
255
- // ---- container documents ----
256
- const indexRows = [];
257
- const appEntries = [];
258
- for (const [name, c] of [...containers.entries()].sort((a, b) => a[1].docName.localeCompare(b[1].docName))) {
259
- const doc = c.docName;
260
- c.methods.sort((a, b) => a.file.localeCompare(b.file) || (a.line_start ?? 0) - (b.line_start ?? 0) || a.anchor.localeCompare(b.anchor));
261
-
262
- const entries = c.methods.filter(isEntry);
263
- for (const s of c.methods) if (s.entry) appEntries.push(s.anchor);
264
- const testCount = c.methods.filter((s) => isTestPath(s.file)).length;
265
- // src= = the TRUE source directory (real path, for locating code); module=
266
- // and the heading = the normalised DISPLAY path (ceremony stripped).
267
- const srcDir = container === "file" ? name : name === "(root)" ? "" : `${name}/`;
268
- const disp = displayOf(name);
269
- const dispLabel = disp === "(root)" ? "root" : disp;
270
-
271
- const chunks = [
272
- "=== meta\n"
273
- + `module = ${csvCell(dispLabel)}\n`
274
- + (srcDir ? `src = ${csvCell(srcDir)}\n` : "")
275
- + (entries.length ? `entry = ${entries.map((s) => `#${idOf.get(s.anchor)}`).join(" ")}\n` : "")
276
- // app-entry: WHERE the program starts (main, a mount, a worker handler)
277
- // — a separate, much rarer list than entry= (the container's inbound
278
- // call surface). File-level entries (top-level bootstrap code with no
279
- // function symbol) are named by path instead of a block reference.
280
- + (c.methods.some((s) => s.entry)
281
- ? `app-entry = ${c.methods.filter((s) => s.entry).map((s) => `#${idOf.get(s.anchor)} (${s.entryVia})`).join(" ")}\n` : "")
282
- + (fileHintsByDoc.get(doc) ?? []).map((h) => `app-entry-file = ${csvCell(h.file)} (${h.via})\n`).join("")
283
- + `resolution-default = ${RESOLUTION_DEFAULT}\n===\n`,
284
- `# ${esc(dispLabel)}\n`,
285
- ];
286
-
287
- // method blocks, grouped under a `##` file heading when the container spans
288
- // several files (containment = document structure)
289
- const byFile = new Map();
290
- for (const s of c.methods) {
291
- if (!byFile.has(s.file)) byFile.set(s.file, []);
292
- byFile.get(s.file).push(s);
293
- }
294
- const multiFile = byFile.size > 1;
295
- const fileSymByPath = new Map(c.files.map((f) => [f.file, f]));
296
- for (const [file, list] of [...byFile.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
297
- if (multiFile) {
298
- const fileSym = fileSymByPath.get(file);
299
- const base = file.split("/").pop();
300
- chunks.push(fileSym ? `## ${esc(base)} {#${idOf.get(fileSym.anchor)}}\n` : `## ${esc(base)}\n`);
301
- }
302
- for (const s of list) {
303
- const cls = `${isTestPath(s.file) ? " .test" : ""}${isLeaf(s) ? " .leaf" : ""}${isAccessor(s) ? " .accessor" : ""}${s.flow_crit ? " .flow-entry" : ""}${s.entry ? " .app-entry" : ""}`;
304
- const src = `${s.file}${s.line_start !== undefined ? `#L${s.line_start}-${s.line_end ?? s.line_start}` : ""}`;
305
- // The display name rides along whenever id sanitisation changed it
306
- // ("RenderCtx.block" -> id RenderCtx-block): renderers label nodes
307
- // with the real name, ids stay reference-grammar clean.
308
- const id = idOf.get(s.anchor);
309
- const nameAttr = s.name !== id ? ` name="${attrVal(s.name)}"` : "";
310
- const viaAttr = s.entry && s.entryVia ? ` entry-via="${attrVal(s.entryVia)}"` : "";
311
- chunks.push(`=== code {#${id}${cls}${nameAttr}${viaAttr} src=${attrVal(src)} anchor="${attrVal(s.anchor)}"}\n===\n`);
312
- }
313
- }
314
-
315
- // #calls
316
- const callRows = [];
317
- for (const s of c.methods) {
318
- for (const r of (outBySym.get(s.anchor) ?? [])
319
- .sort((x, y) => (x.kind === y.kind ? refTo(x.to, doc).localeCompare(refTo(y.to, doc)) : 0))) {
320
- callRows.push([`#${idOf.get(s.anchor)}`, refTo(r.to, doc), r.kind, r.confidence]);
321
- }
322
- }
323
- const callsTable = csv("calls", ["from", "to", "kind", "confidence"], callRows);
324
- if (callsTable) chunks.push(callsTable);
325
-
326
- // #called-by (aggregated in-edges)
327
- const inRows = [];
328
- for (const s of c.methods) {
329
- const recs = (inBySym.get(s.anchor) ?? [])
330
- .sort((x, y) => (x.site?.file ?? "").localeCompare(y.site?.file ?? "") || (x.site?.line ?? 0) - (y.site?.line ?? 0) || x.from.localeCompare(y.from));
331
- for (const r of recs) {
332
- const site = r.site ? `${r.site.file}:${r.site.line}` : "";
333
- inRows.push([refTo(r.from, doc), `#${idOf.get(s.anchor)}`, r.kind, csvCell(site)]);
334
- }
335
- }
336
- const inTable = csv("called-by", ["from", "to", "kind", "site"], inRows);
337
- if (inTable) chunks.push(inTable);
338
-
339
- // #unresolved (hidden)
340
- const unRows = [];
341
- for (const s of c.methods) {
342
- for (const t of [...(unresBySym.get(s.anchor) ?? [])].sort()) {
343
- unRows.push([`#${idOf.get(s.anchor)}`, csvCell(t)]);
344
- }
345
- }
346
- const unTable = csv("unresolved", ["from", "to"], unRows, " hidden");
347
- if (unTable) chunks.push(unTable);
348
-
349
- // #api-calls — frontend functions in this doc that hit a backend endpoint.
350
- // `to` points cross-tree to the handler's doc#id (the two trees, joined);
351
- // `endpoint` is the METHOD + normalized path; `confidence` carries a
352
- // `method-mismatch` marker when the verb disagrees (contract drift).
353
- const apiOutRows = [];
354
- for (const s of c.methods) {
355
- for (const e of (httpOut.get(s.anchor) ?? []).slice().sort((x, y) => x.endpoint.localeCompare(y.endpoint) || String(x.to ?? x.to_text).localeCompare(String(y.to ?? y.to_text)))) {
356
- const target = e.to !== undefined && docOfAnchor.has(e.to) ? refTo(e.to, doc) : csvCell(e.to_text ?? "?");
357
- const conf = e.methodDivergent ? `${e.confidence} method-mismatch` : e.confidence;
358
- apiOutRows.push([`#${idOf.get(s.anchor)}`, target, csvCell(e.endpoint), conf]);
359
- }
360
- }
361
- const apiCallsTable = csv("api-calls", ["from", "to", "endpoint", "confidence"], apiOutRows);
362
- if (apiCallsTable) chunks.push(apiCallsTable);
363
-
364
- // #api-served-by — backend handlers in this doc reached from the frontend.
365
- const apiInRows = [];
366
- for (const s of c.methods) {
367
- for (const e of (httpIn.get(s.anchor) ?? []).slice().sort((x, y) => x.endpoint.localeCompare(y.endpoint) || (x.site?.file ?? "").localeCompare(y.site?.file ?? "") || (x.site?.line ?? 0) - (y.site?.line ?? 0))) {
368
- const from = e.from !== undefined && docOfAnchor.has(e.from) ? refTo(e.from, doc) : csvCell(e.from_text ?? "?");
369
- const site = e.site ? `${e.site.file}:${e.site.line}` : "";
370
- apiInRows.push([from, `#${idOf.get(s.anchor)}`, csvCell(e.endpoint), csvCell(site)]);
371
- }
372
- }
373
- const apiInTable = csv("api-served-by", ["from", "to", "endpoint", "site"], apiInRows);
374
- if (apiInTable) chunks.push(apiInTable);
375
-
376
- writeIfChanged(doc, chunks.join("\n"));
377
- indexRows.push({ module: dispLabel, doc, methods: c.methods.length, entries: entries.length, tests: testCount });
378
- }
379
-
380
- // ---- index.geml ----
381
- appEntries.sort((a, b) => docOfAnchor.get(a).localeCompare(docOfAnchor.get(b)) || a.localeCompare(b));
382
- const moduleEdges = new Map(); // "fromDoc toDoc" -> count (cross-container resolved calls)
383
- for (const [from, recs] of outBySym) {
384
- const fd = docOfAnchor.get(from);
385
- for (const r of recs) {
386
- if (r.kind !== "call") continue;
387
- const td = docOfAnchor.get(r.to);
388
- if (fd === td) continue;
389
- const key = `${fd} ${td}`;
390
- moduleEdges.set(key, (moduleEdges.get(key) ?? 0) + 1);
391
- }
392
- }
393
- const modName = (doc) => indexRows.find((r) => r.doc === doc)?.module ?? doc;
394
- const index = [
395
- "=== meta\n"
396
- + `repo = ${csvCell(repoName)}\n`
397
- + (commit ? `commit = ${csvCell(commit)}\n` : "")
398
- + `container = ${container}\n`
399
- + (appEntries.length ? `entry = ${appEntries.map((a) => `${docOfAnchor.get(a)}#${idOf.get(a)}`).join(" ")}\n` : "")
400
- // Documents whose app entry is FILE-level (top-level bootstrap code, no
401
- // function symbol) — its own key so entry= keeps its doc#id grammar.
402
- + (fileHintsByDoc.size ? `app-entry-docs = ${[...fileHintsByDoc.keys()].sort().join(" ")}\n` : "")
403
- + `resolution-default = ${RESOLUTION_DEFAULT}\n===\n`,
404
- `# Code map — ${esc(repoName)}\n`,
405
- csv("modules", ["module", "doc", "methods", "entries", "tests"],
406
- indexRows.sort((a, b) => b.methods - a.methods)
407
- .map((r) => [csvCell(r.module), r.doc, r.methods, r.entries, r.tests])) ?? "",
408
- csv("module-edges", ["from", "to", "calls"],
409
- [...moduleEdges.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
410
- .map(([k, n]) => { const [fd, td] = k.split(" "); return [csvCell(modName(fd)), csvCell(modName(td)), n]; })) ?? "",
411
- ].filter(Boolean).join("\n");
412
- writeIfChanged("index.geml", index);
413
-
414
- // ---- name-lookup ----
415
- // Class-qualified names ("Cls.method") are ALSO findable by the bare member
416
- // name — an agent asking "who is handleLogin" should not need to know the
417
- // class first; ambiguity across classes is intrinsic and the lookup already
418
- // answers with every candidate.
419
- const lookup = new Map();
420
- const addLookup = (name, s) => {
421
- if (!lookup.has(name)) lookup.set(name, []);
422
- lookup.get(name).push({ anchor: s.anchor, doc: docOfAnchor.get(s.anchor), id: idOf.get(s.anchor) });
423
- };
424
- for (const s of methods) {
425
- addLookup(s.name, s);
426
- const dot = s.name.indexOf(".");
427
- if (dot > 0 && dot < s.name.length - 1) addLookup(s.name.slice(dot + 1), s);
428
- else {
429
- // Rust members qualify with "::" (Widget::area) — same bare-name alias.
430
- const c = s.name.indexOf("::");
431
- if (c > 0 && c < s.name.length - 2) addLookup(s.name.slice(c + 2), s);
432
- }
433
- }
434
- const sortedLookup = {};
435
- for (const name of [...lookup.keys()].sort()) {
436
- sortedLookup[name] = lookup.get(name).sort((a, b) => a.anchor.localeCompare(b.anchor));
437
- }
438
- writeIfChanged("_index/name-lookup.json", JSON.stringify(sortedLookup, null, 2) + "\n");
439
-
440
- // Compact search index for the viewer's name -> node typeahead: a flat
441
- // [name, doc, id] list with the (bulky) anchors dropped, so it stays small
442
- // even on huge repos. Emitted as a JS global so a STATIC page can load it via
443
- // `<script src>` (a file:// page can't fetch() a sibling file, but a script
444
- // tag is exempt); serve also searches it server-side for big graphs.
445
- const searchIndex = [];
446
- for (const name of Object.keys(sortedLookup)) {
447
- for (const c of sortedLookup[name]) searchIndex.push([name, c.doc, c.id]);
448
- }
449
- writeIfChanged("_index/search-index.js", "window.__gemlSearch=" + JSON.stringify(searchIndex) + ";\n");
450
-
451
- // ---- edges-manifest (internal) ----
452
- if (buildDir) {
453
- const manifest = {};
454
- for (const a of [...outBySym.keys()].sort()) {
455
- manifest[a] = outBySym.get(a).map((r) => ({ kind: r.kind, to: r.to }))
456
- .sort((x, y) => (x.kind + x.to).localeCompare(y.kind + y.to));
457
- }
458
- mkdirSync(buildDir, { recursive: true });
459
- const p = join(buildDir, "edges-manifest.json");
460
- const content = JSON.stringify(manifest, null, 1) + "\n";
461
- if (!existsSync(p) || readFileSync(p, "utf8") !== content) writeFileSync(p, content);
462
- }
463
-
464
- return {
465
- ...stats,
466
- allDocs,
467
- writtenDocs,
468
- containers: containers.size,
469
- symbols: symbols.length,
470
- methods: methods.length,
471
- edges: edges.length,
472
- // A `calls` edge is "resolved" only when it actually yields a table row —
473
- // that needs BOTH endpoints known (the calls loop above skips any edge
474
- // whose FROM anchor is unknown). Counting by target alone inflated the
475
- // figure for a dangling-FROM edge that produced no row.
476
- resolved: calls.filter((e) => docOfAnchor.has(e.from) && e.to !== undefined && docOfAnchor.has(e.to)).length,
477
- leaves: methods.filter((s) => isLeaf(s)).length,
478
- entries: appEntries.length + [...fileHintsByDoc.values()].reduce((n, a) => n + a.length, 0),
479
- };
480
- }
1
+ // geml-code-graph emit — exchange-format symbols/edges → the codemap document
2
+ // tree of docs/DESIGN-codemap-delta.md (§4–6) / docs/codemap-profile.md:
3
+ // one .geml per container (module|dir|file), each with EXACTLY ONE meta
4
+ // (module/src/entry/resolution-default), empty-body `code` blocks per method
5
+ // (src=path#Lx-y, anchor=; symbol-level classes .leaf/.test), and up to three
6
+ // CSV edge tables: #calls (out), #called-by (in, aggregated), #unresolved
7
+ // (blind spots, hidden). Plus index.geml (aggregates) and name-lookup.json.
8
+ //
9
+ // Generated documents are PURE DATA: no diagram blocks (a codemap-aware
10
+ // renderer offers the layered-flow view; embedding elsewhere uses
11
+ // `=== diagram {format=geml-code-graph src=…}`).
12
+ //
13
+ // Emission is deterministic: stable sort orders everywhere; a file is only
14
+ // written when its bytes changed (mtime = "what a change touched").
15
+ import { createHash } from "node:crypto";
16
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
17
+ import { dirname, join, posix } from "node:path";
18
+ import { buildNormalizer } from "./normalize.mjs";
19
+
20
+ const esc = (s) => String(s).replace(/`/g, "'");
21
+ // Attribute values live on the block-header LINE: a newline inside one (e.g.
22
+ // scip's anonymous-type-literal descriptors embed the literal's multi-line
23
+ // text in the symbol) would truncate the header mid-value — the block still
24
+ // parses as raw text but its #id never registers, and every edge to it
25
+ // dangles (found on next.js: `recursiveCopy().({ filter... })`). Collapse all
26
+ // whitespace runs to single spaces alongside the quote swap.
27
+ const attrVal = (s) => String(s).replace(/"/g, "'").replace(/\s+/g, " ");
28
+ // Plain-text cells: no commas/newlines (CSV), and no square brackets — table
29
+ // cells are inline-parsed, so `f[i](&x)` would otherwise read as a LINK with an
30
+ // unresolvable target. Brackets become parens: still readable, never markup.
31
+ const csvCell = (s) => String(s).replace(/[,\r\n]/g, " ").replace(/\[/g, "(").replace(/\]/g, ")").trim();
32
+ const sha6 = (s, len = 6) => createHash("sha256").update(s, "utf8").digest("hex").slice(0, len);
33
+
34
+ // Test territory (path conventions; the avowed heuristic of GEP-0002).
35
+ const TEST_DIR = /(^|\/)(test|tests|testing|__tests__|spec|specs)(\/|$)/i;
36
+ const TEST_FILE = /(^test_|^tests?\.|[._-]tests?\.|\.test\.|\.spec\.)/i;
37
+ const isTestPath = (p) => {
38
+ p = String(p).replace(/\\/g, "/");
39
+ return TEST_DIR.test(p) || TEST_FILE.test(p.slice(p.lastIndexOf("/") + 1));
40
+ };
41
+
42
+ const slugName = (name) => {
43
+ let s = String(name).replace(/[^A-Za-z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
44
+ if (!/^[A-Za-z]/.test(s)) s = "s" + s;
45
+ return s;
46
+ };
47
+ const slugPath = (p) => (p === "" || p === "(root)" ? "root" : p.replace(/\//g, "--").replace(/[^A-Za-z0-9_.-]/g, "-"));
48
+ const dirOf = (rel) => { const i = rel.lastIndexOf("/"); return i < 0 ? "(root)" : rel.slice(0, i); };
49
+ const topOf = (rel) => { const i = rel.indexOf("/"); return i < 0 ? "(root)" : rel.slice(0, i); };
50
+
51
+ export function emit({ symbols, edges, outDir, buildDir, repoName, container = "dir", commit, root, foldings, entryHints }) {
52
+ const byAnchor = new Map(symbols.map((s) => [s.anchor, s]));
53
+ const methods = symbols.filter((s) => s.kind === "Function" || s.kind === "Test");
54
+ const files = symbols.filter((s) => s.kind === "File");
55
+
56
+ // ---- app-entry hints (codemap/entries.mjs) ----
57
+ // A named hint marks its method; a file-level hint (SPA bootstrap, Nuxt app
58
+ // shell…) marks the file's only method, or — when the entry is top-level
59
+ // code with no function symbol at all — falls through to a doc-level
60
+ // `app-entry-file` note. The adapters' own name==="main" flag rides along;
61
+ // every marked entry carries `via` (the convention that identified it).
62
+ const fileHints = [];
63
+ {
64
+ const methodsByFile = new Map();
65
+ for (const s of methods) {
66
+ if (!methodsByFile.has(s.file)) methodsByFile.set(s.file, []);
67
+ methodsByFile.get(s.file).push(s);
68
+ }
69
+ for (const h of entryHints ?? []) {
70
+ const list = methodsByFile.get(h.file) ?? [];
71
+ let hit;
72
+ if (h.name) hit = list.find((s) => s.name === h.name || s.name.endsWith(`::${h.name}`) || s.name.endsWith(`.${h.name}`));
73
+ else if (list.length === 1) hit = list[0];
74
+ if (hit) { hit.entry = true; hit.entryVia ??= h.via; }
75
+ else fileHints.push(h);
76
+ }
77
+ for (const s of methods) if (s.entry && !s.entryVia) s.entryVia = "main";
78
+ }
79
+
80
+ // ---- containers ----
81
+ const containerOf = (s) =>
82
+ container === "file" ? s.file : container === "module" ? topOf(s.file) : dirOf(s.file);
83
+ // Display-path normalisation: strip each module's shared ceremony prefix so
84
+ // `module=`/doc names read as the real structure. Applies in every container
85
+ // mode (dir and file) — a file-mode container path carries the same source
86
+ // roots (geml-parser/src/render.ts -> geml-parser/render.ts). Grouping still
87
+ // keys on the TRUE path (containerOf) and `src=` stays the true path — only
88
+ // the displayed module path shortens. root may be absent (older callers / crg
89
+ // tier): then displayOf is the identity.
90
+ const normMap = root ? buildNormalizer(root, methods.map(containerOf), { repoName, fileMode: container === "file", config: foldings }) : new Map();
91
+ const displayOf = (name) => normMap.get(name) ?? name;
92
+ const containers = new Map(); // name -> { docName, methods[], files[] }
93
+ const taken = new Set(["index.geml"]);
94
+ const containerFor = (name) => {
95
+ if (!containers.has(name)) {
96
+ let doc = `${slugPath(displayOf(name))}.geml`;
97
+ for (let i = 2; taken.has(doc); i++) doc = `${slugPath(displayOf(name))}-${i}.geml`;
98
+ taken.add(doc);
99
+ containers.set(name, { docName: doc, methods: [], files: [] });
100
+ }
101
+ return containers.get(name);
102
+ };
103
+ for (const s of methods) containerFor(containerOf(s)).methods.push(s);
104
+ for (const s of files) {
105
+ const c = containers.get(containerOf(s));
106
+ if (c) c.files.push(s); // only files that actually host methods
107
+ }
108
+ const docOfAnchor = new Map();
109
+ for (const [name, c] of containers) {
110
+ for (const s of [...c.methods, ...c.files]) docOfAnchor.set(s.anchor, c.docName);
111
+ }
112
+ // File-level app-entry hints, grouped under the container that holds the
113
+ // file (a hint whose file grew no container at all has nowhere honest to
114
+ // land and is dropped).
115
+ const fileHintsByDoc = new Map(); // docName -> [{file, via}]
116
+ for (const h of fileHints) {
117
+ const c = containers.get(containerOf({ file: h.file }));
118
+ if (!c) continue;
119
+ if (!fileHintsByDoc.has(c.docName)) fileHintsByDoc.set(c.docName, []);
120
+ fileHintsByDoc.get(c.docName).push(h);
121
+ }
122
+
123
+ // ---- block ids: short name when unique in its doc, else name-<sha6(anchor)> ----
124
+ const idOf = new Map(); // anchor -> id
125
+ for (const [, c] of containers) {
126
+ const byName = new Map();
127
+ for (const s of [...c.methods, ...c.files]) {
128
+ const base = slugName(s.name);
129
+ if (!byName.has(base)) byName.set(base, []);
130
+ byName.get(base).push(s);
131
+ }
132
+ for (const [base, list] of byName) {
133
+ if (list.length === 1) { idOf.set(list[0].anchor, base); continue; }
134
+ // Escalate hash length only when the default 6 hex chars actually collide
135
+ // within this name group — keeps ids short in the common case.
136
+ let len = 6;
137
+ let ids;
138
+ for (;;) {
139
+ ids = list.map((s) => `${base}-${sha6(s.anchor, len)}`);
140
+ if (new Set(ids).size === ids.length) break;
141
+ // sha256 hex is 64 chars — beyond that only IDENTICAL anchors can
142
+ // still collide, which is a caller bug (build.mjs dedupes anchors):
143
+ // fail loudly instead of escalating forever.
144
+ if (len >= 64) throw new Error(`emit: duplicate anchors in name group "${base}" — anchors must be unique`);
145
+ len += 2;
146
+ }
147
+ list.forEach((s, i) => idOf.set(s.anchor, ids[i]));
148
+ }
149
+ }
150
+
151
+ // ---- edges (stage A: the `calls` relation only) ----
152
+ const calls = edges.filter((e) => e.kind === "calls");
153
+ const outCalls = new Map(); // anchor -> total outgoing (incl. unresolved) — leaf rule
154
+ const inBySym = new Map(); // target anchor -> [{fromAnchor, kind, site, confidence}]
155
+ const outBySym = new Map(); // source anchor -> [{toAnchor, kind, confidence}] resolved
156
+ const unresBySym = new Map(); // source anchor -> Set(to_text)
157
+ const addIn = (target, rec) => {
158
+ if (!byAnchor.has(target)) return;
159
+ if (!inBySym.has(target)) inBySym.set(target, []);
160
+ inBySym.get(target).push(rec);
161
+ };
162
+ for (const e of calls) {
163
+ if (!docOfAnchor.has(e.from)) continue;
164
+ outCalls.set(e.from, (outCalls.get(e.from) ?? 0) + 1);
165
+ if (e.to !== undefined && docOfAnchor.has(e.to)) {
166
+ if (!outBySym.has(e.from)) outBySym.set(e.from, []);
167
+ const conf = e.confidence === "high" || !e.confidence ? "" : e.confidence;
168
+ outBySym.get(e.from).push({ to: e.to, kind: "call", confidence: conf });
169
+ addIn(e.to, { from: e.from, kind: "call", site: e.site, confidence: conf });
170
+ for (const c of e.candidates ?? []) {
171
+ if (!docOfAnchor.has(c)) continue;
172
+ outBySym.get(e.from).push({ to: c, kind: "candidate", confidence: "" });
173
+ addIn(c, { from: e.from, kind: "candidate", site: e.site, confidence: "" });
174
+ }
175
+ } else if (e.to_text) {
176
+ if (!unresBySym.has(e.from)) unresBySym.set(e.from, new Set());
177
+ unresBySym.get(e.from).add(e.to_text);
178
+ }
179
+ }
180
+
181
+ // ---- edges (stage B: cross-stack `http` links) ----
182
+ // Heuristic frontend-caller → backend-handler links (codemap/cross-stack.mjs),
183
+ // emitted into their OWN #api-calls / #api-served-by tables so they never mix
184
+ // with the verified `calls` graph. Either endpoint may be unresolved (a call
185
+ // or route outside any indexed function) — then the *_text file:line rides in
186
+ // the cell instead of a #ref.
187
+ const httpOut = new Map(); // FE-fn anchor -> [http edge]
188
+ const httpIn = new Map(); // BE-fn anchor -> [http edge]
189
+ for (const e of edges) {
190
+ if (e.kind !== "http") continue;
191
+ if (e.from !== undefined && docOfAnchor.has(e.from)) {
192
+ if (!httpOut.has(e.from)) httpOut.set(e.from, []);
193
+ httpOut.get(e.from).push(e);
194
+ }
195
+ if (e.to !== undefined && docOfAnchor.has(e.to)) {
196
+ if (!httpIn.has(e.to)) httpIn.set(e.to, []);
197
+ httpIn.get(e.to).push(e);
198
+ }
199
+ }
200
+
201
+ const isLeaf = (s) =>
202
+ (s.kind === "Function" || s.kind === "Test") &&
203
+ !(outCalls.get(s.anchor) > 0) && (inBySym.get(s.anchor)?.length ?? 0) >= 1;
204
+ // Bean-style accessors that also call nothing: pure noise in a flow view.
205
+ // Marked so renderers can hide them by default; the edge tables keep them.
206
+ const isAccessor = (s) => isLeaf(s) && /^(get|set|is)(?![a-z])/.test(s.name);
207
+
208
+ // ---- entry: called from outside its container, or an app entry (main) ----
209
+ const isEntry = (s) => {
210
+ if (s.entry) return true;
211
+ const doc = docOfAnchor.get(s.anchor);
212
+ return (inBySym.get(s.anchor) ?? []).some((r) => docOfAnchor.get(r.from) !== doc);
213
+ };
214
+
215
+ // A reference to `anchor` as seen from `fromDoc`.
216
+ const refTo = (anchor, fromDoc) => {
217
+ const doc = docOfAnchor.get(anchor);
218
+ const id = idOf.get(anchor);
219
+ return doc === fromDoc ? `#${id}` : `${posix.relative(posix.dirname(fromDoc), doc)}#${id}`;
220
+ };
221
+
222
+ // ---- write helper: deterministic, only-on-change ----
223
+ const stats = { docs: 0, written: 0, bytes: 0 };
224
+ const allDocs = [];
225
+ const writtenDocs = [];
226
+ const writeIfChanged = (relPath, content) => {
227
+ const p = join(outDir, relPath);
228
+ mkdirSync(dirname(p), { recursive: true });
229
+ stats.docs++;
230
+ stats.bytes += content.length;
231
+ if (relPath.endsWith(".geml")) allDocs.push(relPath);
232
+ if (existsSync(p) && readFileSync(p, "utf8") === content) return false;
233
+ writeFileSync(p, content);
234
+ stats.written++;
235
+ if (relPath.endsWith(".geml")) writtenDocs.push(relPath);
236
+ return true;
237
+ };
238
+
239
+ const RESOLUTION_DEFAULT = symbols.some((s) => s.resolution === "cpg") ? "cpg" : "heuristic";
240
+ const csv = (id, columns, rows, extraAttrs = "") => {
241
+ if (!rows.length) return null; // empty tables are not generated
242
+ // Column width by loop, not Math.max(...spread) — a spread call over a
243
+ // repo-scale table's rows blows the argument limit (same failure class
244
+ // as the build.mjs merge).
245
+ const widths = columns.map((c, i) => {
246
+ let w = c.length;
247
+ for (const r of rows) { const l = String(r[i] ?? "").length; if (l > w) w = l; }
248
+ return w;
249
+ });
250
+ const line = (cells) => cells.map((v, i) =>
251
+ i === cells.length - 1 ? String(v ?? "") : (String(v ?? "") + ",").padEnd(widths[i] + 2)).join("").replace(/\s+$/, "");
252
+ return `=== table {#${id} format=csv${extraAttrs}}\n${line(columns)}\n${rows.map(line).join("\n")}\n===\n`;
253
+ };
254
+
255
+ // ---- container documents ----
256
+ const indexRows = [];
257
+ const appEntries = [];
258
+ for (const [name, c] of [...containers.entries()].sort((a, b) => a[1].docName.localeCompare(b[1].docName))) {
259
+ const doc = c.docName;
260
+ c.methods.sort((a, b) => a.file.localeCompare(b.file) || (a.line_start ?? 0) - (b.line_start ?? 0) || a.anchor.localeCompare(b.anchor));
261
+
262
+ const entries = c.methods.filter(isEntry);
263
+ for (const s of c.methods) if (s.entry) appEntries.push(s.anchor);
264
+ const testCount = c.methods.filter((s) => isTestPath(s.file)).length;
265
+ // src= = the TRUE source directory (real path, for locating code); module=
266
+ // and the heading = the normalised DISPLAY path (ceremony stripped).
267
+ const srcDir = container === "file" ? name : name === "(root)" ? "" : `${name}/`;
268
+ const disp = displayOf(name);
269
+ const dispLabel = disp === "(root)" ? "root" : disp;
270
+
271
+ const chunks = [
272
+ "=== meta\n"
273
+ + `module = ${csvCell(dispLabel)}\n`
274
+ + (srcDir ? `src = ${csvCell(srcDir)}\n` : "")
275
+ + (entries.length ? `entry = ${entries.map((s) => `#${idOf.get(s.anchor)}`).join(" ")}\n` : "")
276
+ // app-entry: WHERE the program starts (main, a mount, a worker handler)
277
+ // — a separate, much rarer list than entry= (the container's inbound
278
+ // call surface). File-level entries (top-level bootstrap code with no
279
+ // function symbol) are named by path instead of a block reference.
280
+ + (c.methods.some((s) => s.entry)
281
+ ? `app-entry = ${c.methods.filter((s) => s.entry).map((s) => `#${idOf.get(s.anchor)} (${s.entryVia})`).join(" ")}\n` : "")
282
+ + (fileHintsByDoc.get(doc) ?? []).map((h) => `app-entry-file = ${csvCell(h.file)} (${h.via})\n`).join("")
283
+ + `resolution-default = ${RESOLUTION_DEFAULT}\n===\n`,
284
+ `# ${esc(dispLabel)}\n`,
285
+ ];
286
+
287
+ // method blocks, grouped under a `##` file heading when the container spans
288
+ // several files (containment = document structure)
289
+ const byFile = new Map();
290
+ for (const s of c.methods) {
291
+ if (!byFile.has(s.file)) byFile.set(s.file, []);
292
+ byFile.get(s.file).push(s);
293
+ }
294
+ const multiFile = byFile.size > 1;
295
+ const fileSymByPath = new Map(c.files.map((f) => [f.file, f]));
296
+ for (const [file, list] of [...byFile.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
297
+ if (multiFile) {
298
+ const fileSym = fileSymByPath.get(file);
299
+ const base = file.split("/").pop();
300
+ chunks.push(fileSym ? `## ${esc(base)} {#${idOf.get(fileSym.anchor)}}\n` : `## ${esc(base)}\n`);
301
+ }
302
+ for (const s of list) {
303
+ const cls = `${isTestPath(s.file) ? " .test" : ""}${isLeaf(s) ? " .leaf" : ""}${isAccessor(s) ? " .accessor" : ""}${s.flow_crit ? " .flow-entry" : ""}${s.entry ? " .app-entry" : ""}`;
304
+ const src = `${s.file}${s.line_start !== undefined ? `#L${s.line_start}-${s.line_end ?? s.line_start}` : ""}`;
305
+ // The display name rides along whenever id sanitisation changed it
306
+ // ("RenderCtx.block" -> id RenderCtx-block): renderers label nodes
307
+ // with the real name, ids stay reference-grammar clean.
308
+ const id = idOf.get(s.anchor);
309
+ const nameAttr = s.name !== id ? ` name="${attrVal(s.name)}"` : "";
310
+ const viaAttr = s.entry && s.entryVia ? ` entry-via="${attrVal(s.entryVia)}"` : "";
311
+ chunks.push(`=== code {#${id}${cls}${nameAttr}${viaAttr} src=${attrVal(src)} anchor="${attrVal(s.anchor)}"}\n===\n`);
312
+ }
313
+ }
314
+
315
+ // #calls
316
+ const callRows = [];
317
+ for (const s of c.methods) {
318
+ for (const r of (outBySym.get(s.anchor) ?? [])
319
+ .sort((x, y) => (x.kind === y.kind ? refTo(x.to, doc).localeCompare(refTo(y.to, doc)) : 0))) {
320
+ callRows.push([`#${idOf.get(s.anchor)}`, refTo(r.to, doc), r.kind, r.confidence]);
321
+ }
322
+ }
323
+ const callsTable = csv("calls", ["from", "to", "kind", "confidence"], callRows);
324
+ if (callsTable) chunks.push(callsTable);
325
+
326
+ // #called-by (aggregated in-edges)
327
+ const inRows = [];
328
+ for (const s of c.methods) {
329
+ const recs = (inBySym.get(s.anchor) ?? [])
330
+ .sort((x, y) => (x.site?.file ?? "").localeCompare(y.site?.file ?? "") || (x.site?.line ?? 0) - (y.site?.line ?? 0) || x.from.localeCompare(y.from));
331
+ for (const r of recs) {
332
+ const site = r.site ? `${r.site.file}:${r.site.line}` : "";
333
+ inRows.push([refTo(r.from, doc), `#${idOf.get(s.anchor)}`, r.kind, csvCell(site)]);
334
+ }
335
+ }
336
+ const inTable = csv("called-by", ["from", "to", "kind", "site"], inRows);
337
+ if (inTable) chunks.push(inTable);
338
+
339
+ // #unresolved (hidden)
340
+ const unRows = [];
341
+ for (const s of c.methods) {
342
+ for (const t of [...(unresBySym.get(s.anchor) ?? [])].sort()) {
343
+ unRows.push([`#${idOf.get(s.anchor)}`, csvCell(t)]);
344
+ }
345
+ }
346
+ const unTable = csv("unresolved", ["from", "to"], unRows, " hidden");
347
+ if (unTable) chunks.push(unTable);
348
+
349
+ // #api-calls — frontend functions in this doc that hit a backend endpoint.
350
+ // `to` points cross-tree to the handler's doc#id (the two trees, joined);
351
+ // `endpoint` is the METHOD + normalized path; `confidence` carries a
352
+ // `method-mismatch` marker when the verb disagrees (contract drift).
353
+ const apiOutRows = [];
354
+ for (const s of c.methods) {
355
+ for (const e of (httpOut.get(s.anchor) ?? []).slice().sort((x, y) => x.endpoint.localeCompare(y.endpoint) || String(x.to ?? x.to_text).localeCompare(String(y.to ?? y.to_text)))) {
356
+ const target = e.to !== undefined && docOfAnchor.has(e.to) ? refTo(e.to, doc) : csvCell(e.to_text ?? "?");
357
+ const conf = e.methodDivergent ? `${e.confidence} method-mismatch` : e.confidence;
358
+ apiOutRows.push([`#${idOf.get(s.anchor)}`, target, csvCell(e.endpoint), conf]);
359
+ }
360
+ }
361
+ const apiCallsTable = csv("api-calls", ["from", "to", "endpoint", "confidence"], apiOutRows);
362
+ if (apiCallsTable) chunks.push(apiCallsTable);
363
+
364
+ // #api-served-by — backend handlers in this doc reached from the frontend.
365
+ const apiInRows = [];
366
+ for (const s of c.methods) {
367
+ for (const e of (httpIn.get(s.anchor) ?? []).slice().sort((x, y) => x.endpoint.localeCompare(y.endpoint) || (x.site?.file ?? "").localeCompare(y.site?.file ?? "") || (x.site?.line ?? 0) - (y.site?.line ?? 0))) {
368
+ const from = e.from !== undefined && docOfAnchor.has(e.from) ? refTo(e.from, doc) : csvCell(e.from_text ?? "?");
369
+ const site = e.site ? `${e.site.file}:${e.site.line}` : "";
370
+ apiInRows.push([from, `#${idOf.get(s.anchor)}`, csvCell(e.endpoint), csvCell(site)]);
371
+ }
372
+ }
373
+ const apiInTable = csv("api-served-by", ["from", "to", "endpoint", "site"], apiInRows);
374
+ if (apiInTable) chunks.push(apiInTable);
375
+
376
+ writeIfChanged(doc, chunks.join("\n"));
377
+ indexRows.push({ module: dispLabel, doc, methods: c.methods.length, entries: entries.length, tests: testCount });
378
+ }
379
+
380
+ // ---- index.geml ----
381
+ appEntries.sort((a, b) => docOfAnchor.get(a).localeCompare(docOfAnchor.get(b)) || a.localeCompare(b));
382
+ const moduleEdges = new Map(); // "fromDoc toDoc" -> count (cross-container resolved calls)
383
+ for (const [from, recs] of outBySym) {
384
+ const fd = docOfAnchor.get(from);
385
+ for (const r of recs) {
386
+ if (r.kind !== "call") continue;
387
+ const td = docOfAnchor.get(r.to);
388
+ if (fd === td) continue;
389
+ const key = `${fd} ${td}`;
390
+ moduleEdges.set(key, (moduleEdges.get(key) ?? 0) + 1);
391
+ }
392
+ }
393
+ const modName = (doc) => indexRows.find((r) => r.doc === doc)?.module ?? doc;
394
+ const index = [
395
+ "=== meta\n"
396
+ + `repo = ${csvCell(repoName)}\n`
397
+ + (commit ? `commit = ${csvCell(commit)}\n` : "")
398
+ + `container = ${container}\n`
399
+ + (appEntries.length ? `entry = ${appEntries.map((a) => `${docOfAnchor.get(a)}#${idOf.get(a)}`).join(" ")}\n` : "")
400
+ // Documents whose app entry is FILE-level (top-level bootstrap code, no
401
+ // function symbol) — its own key so entry= keeps its doc#id grammar.
402
+ + (fileHintsByDoc.size ? `app-entry-docs = ${[...fileHintsByDoc.keys()].sort().join(" ")}\n` : "")
403
+ + `resolution-default = ${RESOLUTION_DEFAULT}\n===\n`,
404
+ `# Code map — ${esc(repoName)}\n`,
405
+ csv("modules", ["module", "doc", "methods", "entries", "tests"],
406
+ indexRows.sort((a, b) => b.methods - a.methods)
407
+ .map((r) => [csvCell(r.module), r.doc, r.methods, r.entries, r.tests])) ?? "",
408
+ csv("module-edges", ["from", "to", "calls"],
409
+ [...moduleEdges.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
410
+ .map(([k, n]) => { const [fd, td] = k.split(" "); return [csvCell(modName(fd)), csvCell(modName(td)), n]; })) ?? "",
411
+ ].filter(Boolean).join("\n");
412
+ writeIfChanged("index.geml", index);
413
+
414
+ // ---- name-lookup ----
415
+ // Class-qualified names ("Cls.method") are ALSO findable by the bare member
416
+ // name — an agent asking "who is handleLogin" should not need to know the
417
+ // class first; ambiguity across classes is intrinsic and the lookup already
418
+ // answers with every candidate.
419
+ const lookup = new Map();
420
+ const addLookup = (name, s) => {
421
+ if (!lookup.has(name)) lookup.set(name, []);
422
+ lookup.get(name).push({ anchor: s.anchor, doc: docOfAnchor.get(s.anchor), id: idOf.get(s.anchor) });
423
+ };
424
+ for (const s of methods) {
425
+ addLookup(s.name, s);
426
+ const dot = s.name.indexOf(".");
427
+ if (dot > 0 && dot < s.name.length - 1) addLookup(s.name.slice(dot + 1), s);
428
+ else {
429
+ // Rust members qualify with "::" (Widget::area) — same bare-name alias.
430
+ const c = s.name.indexOf("::");
431
+ if (c > 0 && c < s.name.length - 2) addLookup(s.name.slice(c + 2), s);
432
+ }
433
+ }
434
+ const sortedLookup = {};
435
+ for (const name of [...lookup.keys()].sort()) {
436
+ sortedLookup[name] = lookup.get(name).sort((a, b) => a.anchor.localeCompare(b.anchor));
437
+ }
438
+ writeIfChanged("_index/name-lookup.json", JSON.stringify(sortedLookup, null, 2) + "\n");
439
+
440
+ // Compact search index for the viewer's name -> node typeahead: a flat
441
+ // [name, doc, id] list with the (bulky) anchors dropped, so it stays small
442
+ // even on huge repos. Emitted as a JS global so a STATIC page can load it via
443
+ // `<script src>` (a file:// page can't fetch() a sibling file, but a script
444
+ // tag is exempt); serve also searches it server-side for big graphs.
445
+ const searchIndex = [];
446
+ for (const name of Object.keys(sortedLookup)) {
447
+ for (const c of sortedLookup[name]) searchIndex.push([name, c.doc, c.id]);
448
+ }
449
+ writeIfChanged("_index/search-index.js", "window.__gemlSearch=" + JSON.stringify(searchIndex) + ";\n");
450
+
451
+ // ---- edges-manifest (internal) ----
452
+ if (buildDir) {
453
+ const manifest = {};
454
+ for (const a of [...outBySym.keys()].sort()) {
455
+ manifest[a] = outBySym.get(a).map((r) => ({ kind: r.kind, to: r.to }))
456
+ .sort((x, y) => (x.kind + x.to).localeCompare(y.kind + y.to));
457
+ }
458
+ mkdirSync(buildDir, { recursive: true });
459
+ const p = join(buildDir, "edges-manifest.json");
460
+ const content = JSON.stringify(manifest, null, 1) + "\n";
461
+ if (!existsSync(p) || readFileSync(p, "utf8") !== content) writeFileSync(p, content);
462
+ }
463
+
464
+ return {
465
+ ...stats,
466
+ allDocs,
467
+ writtenDocs,
468
+ containers: containers.size,
469
+ symbols: symbols.length,
470
+ methods: methods.length,
471
+ edges: edges.length,
472
+ // A `calls` edge is "resolved" only when it actually yields a table row —
473
+ // that needs BOTH endpoints known (the calls loop above skips any edge
474
+ // whose FROM anchor is unknown). Counting by target alone inflated the
475
+ // figure for a dangling-FROM edge that produced no row.
476
+ resolved: calls.filter((e) => docOfAnchor.has(e.from) && e.to !== undefined && docOfAnchor.has(e.to)).length,
477
+ leaves: methods.filter((s) => isLeaf(s)).length,
478
+ entries: appEntries.length + [...fileHintsByDoc.values()].reduce((n, a) => n + a.length, 0),
479
+ };
480
+ }