@geml/geml 1.8.2 → 1.8.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/LICENSE +21 -21
- package/README.md +288 -288
- package/codemap/adapters/crg.mjs +120 -120
- package/codemap/adapters/joern.mjs +131 -131
- package/codemap/adapters/scip.mjs +658 -658
- package/codemap/browser-stub.mjs +34 -34
- package/codemap/build.mjs +629 -629
- package/codemap/cross-stack.mjs +303 -303
- package/codemap/detect.mjs +399 -399
- package/codemap/emit.mjs +510 -510
- package/codemap/entries.mjs +129 -129
- package/codemap/exclude.mjs +56 -56
- package/codemap/find.mjs +49 -49
- package/codemap/foldings.mjs +110 -110
- package/codemap/joern-export.sc +83 -83
- package/codemap/mcp-server.mjs +434 -434
- package/codemap/normalize.mjs +275 -275
- package/codemap/recipe-trust.mjs +103 -103
- package/codemap/refresh.mjs +313 -313
- package/codemap/render-all.mjs +90 -90
- package/codemap/serve.mjs +585 -585
- package/codemap/sfc-virtualize.mjs +367 -367
- package/codemap/verify.mjs +158 -158
- package/dist/cli.js +129 -129
- package/dist/geml.js +37 -21
- package/dist/mcp.js +19 -19
- package/dist/render-html.js +35 -35
- package/dist/render.js +157 -157
- package/package.json +67 -67
- package/skill/SKILL.md +167 -167
- package/skill/references/authoring.geml +369 -369
package/codemap/emit.mjs
CHANGED
|
@@ -1,510 +1,510 @@
|
|
|
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, readdirSync, statSync, unlinkSync } 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
|
-
// ---- prune documents this build no longer produces ----
|
|
465
|
-
// A container that stops yielding symbols simply gets no document; without
|
|
466
|
-
// this, the one written by an earlier build stays behind describing code that
|
|
467
|
-
// is gone. Those orphans are not inert: `geml check` reads their `src=` line
|
|
468
|
-
// ranges and fails the build-time reference check, which is how nine of them
|
|
469
|
-
// — across two renamings of the naming scheme — went unnoticed until one
|
|
470
|
-
// orphan's source file happened to SHRINK past its recorded line numbers.
|
|
471
|
-
//
|
|
472
|
-
// `allDocs` is the authoritative set: every .geml this run emitted, written
|
|
473
|
-
// or byte-identical. Anything else at the top level of outDir is an orphan.
|
|
474
|
-
// Two guards keep this from eating a file it does not own: only the top level
|
|
475
|
-
// is scanned (never _index/, _build/, or any subtree), and a candidate must
|
|
476
|
-
// carry the generated-document marker `resolution-default` in its head — a
|
|
477
|
-
// hand-placed .geml parked in the directory is left alone.
|
|
478
|
-
const pruned = [];
|
|
479
|
-
const keep = new Set(allDocs);
|
|
480
|
-
let present = [];
|
|
481
|
-
try { present = readdirSync(outDir); } catch { present = []; }
|
|
482
|
-
for (const f of present) {
|
|
483
|
-
if (!f.endsWith(".geml") || keep.has(f)) continue;
|
|
484
|
-
const p = join(outDir, f);
|
|
485
|
-
try {
|
|
486
|
-
if (!statSync(p).isFile()) continue;
|
|
487
|
-
if (!/^===\s*meta\b[\s\S]*?\bresolution-default\s*=/.test(readFileSync(p, "utf8").slice(0, 2000))) continue;
|
|
488
|
-
unlinkSync(p);
|
|
489
|
-
pruned.push(f);
|
|
490
|
-
} catch { /* unreadable or already gone — not this build's problem */ }
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
return {
|
|
494
|
-
...stats,
|
|
495
|
-
allDocs,
|
|
496
|
-
writtenDocs,
|
|
497
|
-
pruned,
|
|
498
|
-
containers: containers.size,
|
|
499
|
-
symbols: symbols.length,
|
|
500
|
-
methods: methods.length,
|
|
501
|
-
edges: edges.length,
|
|
502
|
-
// A `calls` edge is "resolved" only when it actually yields a table row —
|
|
503
|
-
// that needs BOTH endpoints known (the calls loop above skips any edge
|
|
504
|
-
// whose FROM anchor is unknown). Counting by target alone inflated the
|
|
505
|
-
// figure for a dangling-FROM edge that produced no row.
|
|
506
|
-
resolved: calls.filter((e) => docOfAnchor.has(e.from) && e.to !== undefined && docOfAnchor.has(e.to)).length,
|
|
507
|
-
leaves: methods.filter((s) => isLeaf(s)).length,
|
|
508
|
-
entries: appEntries.length + [...fileHintsByDoc.values()].reduce((n, a) => n + a.length, 0),
|
|
509
|
-
};
|
|
510
|
-
}
|
|
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, readdirSync, statSync, unlinkSync } 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
|
+
// ---- prune documents this build no longer produces ----
|
|
465
|
+
// A container that stops yielding symbols simply gets no document; without
|
|
466
|
+
// this, the one written by an earlier build stays behind describing code that
|
|
467
|
+
// is gone. Those orphans are not inert: `geml check` reads their `src=` line
|
|
468
|
+
// ranges and fails the build-time reference check, which is how nine of them
|
|
469
|
+
// — across two renamings of the naming scheme — went unnoticed until one
|
|
470
|
+
// orphan's source file happened to SHRINK past its recorded line numbers.
|
|
471
|
+
//
|
|
472
|
+
// `allDocs` is the authoritative set: every .geml this run emitted, written
|
|
473
|
+
// or byte-identical. Anything else at the top level of outDir is an orphan.
|
|
474
|
+
// Two guards keep this from eating a file it does not own: only the top level
|
|
475
|
+
// is scanned (never _index/, _build/, or any subtree), and a candidate must
|
|
476
|
+
// carry the generated-document marker `resolution-default` in its head — a
|
|
477
|
+
// hand-placed .geml parked in the directory is left alone.
|
|
478
|
+
const pruned = [];
|
|
479
|
+
const keep = new Set(allDocs);
|
|
480
|
+
let present = [];
|
|
481
|
+
try { present = readdirSync(outDir); } catch { present = []; }
|
|
482
|
+
for (const f of present) {
|
|
483
|
+
if (!f.endsWith(".geml") || keep.has(f)) continue;
|
|
484
|
+
const p = join(outDir, f);
|
|
485
|
+
try {
|
|
486
|
+
if (!statSync(p).isFile()) continue;
|
|
487
|
+
if (!/^===\s*meta\b[\s\S]*?\bresolution-default\s*=/.test(readFileSync(p, "utf8").slice(0, 2000))) continue;
|
|
488
|
+
unlinkSync(p);
|
|
489
|
+
pruned.push(f);
|
|
490
|
+
} catch { /* unreadable or already gone — not this build's problem */ }
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return {
|
|
494
|
+
...stats,
|
|
495
|
+
allDocs,
|
|
496
|
+
writtenDocs,
|
|
497
|
+
pruned,
|
|
498
|
+
containers: containers.size,
|
|
499
|
+
symbols: symbols.length,
|
|
500
|
+
methods: methods.length,
|
|
501
|
+
edges: edges.length,
|
|
502
|
+
// A `calls` edge is "resolved" only when it actually yields a table row —
|
|
503
|
+
// that needs BOTH endpoints known (the calls loop above skips any edge
|
|
504
|
+
// whose FROM anchor is unknown). Counting by target alone inflated the
|
|
505
|
+
// figure for a dangling-FROM edge that produced no row.
|
|
506
|
+
resolved: calls.filter((e) => docOfAnchor.has(e.from) && e.to !== undefined && docOfAnchor.has(e.to)).length,
|
|
507
|
+
leaves: methods.filter((s) => isLeaf(s)).length,
|
|
508
|
+
entries: appEntries.length + [...fileHintsByDoc.values()].reduce((n, a) => n + a.length, 0),
|
|
509
|
+
};
|
|
510
|
+
}
|