visualproof 0.0.0-stage → 0.2.0

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.
Files changed (146) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +99 -2
  3. package/dist/assets/fonts/JuliaMono-Regular.ttf +0 -0
  4. package/dist/assets/fonts/JuliaMono-RegularItalic.ttf +0 -0
  5. package/dist/assets/fonts/JuliaMono-SemiBold.ttf +0 -0
  6. package/dist/assets/fonts/LICENSE-JuliaMono.txt +93 -0
  7. package/dist/assets/latex/visualproof.sty +308 -0
  8. package/dist/assets/webfonts/LICENSE-Jost.txt +93 -0
  9. package/dist/assets/webfonts/LICENSE-SourceSans3.txt +93 -0
  10. package/dist/assets/webfonts/LICENSE-SourceSerif4.txt +93 -0
  11. package/dist/assets/webfonts/jost-latin-ext-wght-normal.woff2 +0 -0
  12. package/dist/assets/webfonts/jost-latin-wght-normal.woff2 +0 -0
  13. package/dist/assets/webfonts/source-sans-3-greek-wght-italic.woff2 +0 -0
  14. package/dist/assets/webfonts/source-sans-3-greek-wght-normal.woff2 +0 -0
  15. package/dist/assets/webfonts/source-sans-3-latin-ext-wght-italic.woff2 +0 -0
  16. package/dist/assets/webfonts/source-sans-3-latin-ext-wght-normal.woff2 +0 -0
  17. package/dist/assets/webfonts/source-sans-3-latin-wght-italic.woff2 +0 -0
  18. package/dist/assets/webfonts/source-sans-3-latin-wght-normal.woff2 +0 -0
  19. package/dist/assets/webfonts/source-serif-4-greek-wght-italic.woff2 +0 -0
  20. package/dist/assets/webfonts/source-serif-4-greek-wght-normal.woff2 +0 -0
  21. package/dist/assets/webfonts/source-serif-4-latin-ext-wght-italic.woff2 +0 -0
  22. package/dist/assets/webfonts/source-serif-4-latin-ext-wght-normal.woff2 +0 -0
  23. package/dist/assets/webfonts/source-serif-4-latin-wght-italic.woff2 +0 -0
  24. package/dist/assets/webfonts/source-serif-4-latin-wght-normal.woff2 +0 -0
  25. package/dist/extractor/Extract.lean +1180 -0
  26. package/dist/extractor/Usage.lean +900 -0
  27. package/dist/mcp/visualproof-mcp.mjs +476 -0
  28. package/dist/server/access.js +95 -0
  29. package/dist/server/agent.js +3270 -0
  30. package/dist/server/agentdocs.js +35 -0
  31. package/dist/server/agentlibrary.js +202 -0
  32. package/dist/server/agentnotes.js +701 -0
  33. package/dist/server/agentviews.js +703 -0
  34. package/dist/server/askfollow.js +286 -0
  35. package/dist/server/boardspots.js +159 -0
  36. package/dist/server/dirs.js +175 -0
  37. package/dist/server/doctor.js +186 -0
  38. package/dist/server/events.js +198 -0
  39. package/dist/server/export.js +420 -0
  40. package/dist/server/extract.js +204 -0
  41. package/dist/server/fetchurl.js +321 -0
  42. package/dist/server/figures.js +158 -0
  43. package/dist/server/history.js +780 -0
  44. package/dist/server/historyapi.js +304 -0
  45. package/dist/server/hover.js +595 -0
  46. package/dist/server/identity.js +114 -0
  47. package/dist/server/invites.js +241 -0
  48. package/dist/server/layoutthread.js +51 -0
  49. package/dist/server/library.js +600 -0
  50. package/dist/server/libraryroute.js +211 -0
  51. package/dist/server/live.js +1101 -0
  52. package/dist/server/lostfound.js +199 -0
  53. package/dist/server/main.js +1406 -0
  54. package/dist/server/mcptools.js +496 -0
  55. package/dist/server/pastdoc.js +73 -0
  56. package/dist/server/pipeline.js +159 -0
  57. package/dist/server/poppler.js +251 -0
  58. package/dist/server/presence.js +252 -0
  59. package/dist/server/project.js +222 -0
  60. package/dist/server/prompt.js +53 -0
  61. package/dist/server/readonly.js +152 -0
  62. package/dist/server/readonlygate.js +123 -0
  63. package/dist/server/renames.js +268 -0
  64. package/dist/server/review.js +334 -0
  65. package/dist/server/reviewapi.js +188 -0
  66. package/dist/server/snapshot.js +50 -0
  67. package/dist/server/sourcestore.js +473 -0
  68. package/dist/server/status.js +360 -0
  69. package/dist/server/talk.js +476 -0
  70. package/dist/server/tokens.js +221 -0
  71. package/dist/server/usage.js +501 -0
  72. package/dist/server/versions.js +82 -0
  73. package/dist/server/view.js +164 -0
  74. package/dist/server/views.js +1167 -0
  75. package/dist/shared/askfollow.js +46 -0
  76. package/dist/shared/board.js +332 -0
  77. package/dist/shared/build.js +2 -0
  78. package/dist/shared/collab.js +241 -0
  79. package/dist/shared/comment.js +285 -0
  80. package/dist/shared/context.js +157 -0
  81. package/dist/shared/doc/authors.js +98 -0
  82. package/dist/shared/doc/build.js +358 -0
  83. package/dist/shared/doc/latex.js +455 -0
  84. package/dist/shared/doc/markdown.js +159 -0
  85. package/dist/shared/doc/model.js +37 -0
  86. package/dist/shared/doc/order.js +124 -0
  87. package/dist/shared/doc/outline.js +45 -0
  88. package/dist/shared/doc/proof.js +272 -0
  89. package/dist/shared/doc/sections.js +93 -0
  90. package/dist/shared/doc/settings.js +75 -0
  91. package/dist/shared/edgecodec.js +102 -0
  92. package/dist/shared/editor.js +64 -0
  93. package/dist/shared/excerpt.js +87 -0
  94. package/dist/shared/graph.js +16 -0
  95. package/dist/shared/items.js +92 -0
  96. package/dist/shared/keys.js +203 -0
  97. package/dist/shared/layout.js +926 -0
  98. package/dist/shared/lexer.js +130 -0
  99. package/dist/shared/library.js +711 -0
  100. package/dist/shared/lostfound.js +150 -0
  101. package/dist/shared/moves.js +244 -0
  102. package/dist/shared/notecard.js +295 -0
  103. package/dist/shared/noteplace.js +505 -0
  104. package/dist/shared/palette.js +46 -0
  105. package/dist/shared/pastgraph.js +103 -0
  106. package/dist/shared/places.js +80 -0
  107. package/dist/shared/projectnotes.js +244 -0
  108. package/dist/shared/query.js +818 -0
  109. package/dist/shared/review.js +642 -0
  110. package/dist/shared/reviewqueue.js +333 -0
  111. package/dist/shared/ring.js +366 -0
  112. package/dist/shared/scope.js +255 -0
  113. package/dist/shared/scopeedit.js +81 -0
  114. package/dist/shared/search.js +191 -0
  115. package/dist/shared/separate.js +178 -0
  116. package/dist/shared/sharesnap.js +281 -0
  117. package/dist/shared/sticky.js +501 -0
  118. package/dist/shared/timeline.js +73 -0
  119. package/dist/shared/titles.js +300 -0
  120. package/dist/shared/viewgraph.js +157 -0
  121. package/dist/shared/views.js +859 -0
  122. package/dist/web/bundle/app.js +52671 -0
  123. package/dist/web/bundle/edges.js +407 -0
  124. package/dist/web/bundle/layout.js +1010 -0
  125. package/dist/web/bundle/pdfworker.js +48322 -0
  126. package/dist/web/index.html +257 -0
  127. package/dist/web/src/motion.js +430 -0
  128. package/dist/web/src/past.js +224 -0
  129. package/dist/web/src/scene.js +804 -0
  130. package/dist/web/style.css +2548 -0
  131. package/docs/agent-api/README.md +91 -0
  132. package/docs/agent-api/advice.md +108 -0
  133. package/docs/agent-api/export.md +120 -0
  134. package/docs/agent-api/following.md +140 -0
  135. package/docs/agent-api/history.md +142 -0
  136. package/docs/agent-api/library.md +65 -0
  137. package/docs/agent-api/mcp.md +104 -0
  138. package/docs/agent-api/notes.md +93 -0
  139. package/docs/agent-api/reading.md +166 -0
  140. package/docs/agent-api/requests.md +94 -0
  141. package/docs/agent-api/talking.md +100 -0
  142. package/docs/agent-api/tcb.md +135 -0
  143. package/docs/agent-api/views.md +125 -0
  144. package/docs/agent-api/words.md +56 -0
  145. package/package.json +39 -4
  146. package/test/toolchains.json +5 -0
@@ -0,0 +1,501 @@
1
+ // The usage pass (TCBSpec.md, decisions 2 and 4): every module of the graph elaborated once more,
2
+ // under exactly its imports and with its proofs left out (extractor/Usage.lean), to find which
3
+ // notation each command is written in and the context it is written in, as the elaborator has
4
+ // them. Its findings are merged into the extractor's graph: edges from each unit to the notation
5
+ // units its text uses — the macros and elaborators that interpret it, in the TCB, and the grammar,
6
+ // outside it (SITE_GRAMMAR) — edges between notation units where one's expansion emits another,
7
+ // and the contexts.
8
+ //
9
+ // The pass also records where the proofs inside definitions' bodies are (`proofs`, per command,
10
+ // then per unit): the exported document cuts them (ExportSpec decision 8).
11
+ //
12
+ // One process per module: a process cannot import twice. `jobs` run at once; import is bound by
13
+ // memory bandwidth, and each worker holds hundreds of MB of its own besides the `.olean` files they
14
+ // all map (on Sphere-Packing-Lean about 150 MB of its own, 1.2 to 1.6 GB resident with them). A module's result is cached in the
15
+ // project's cache directory under a key
16
+ // made of its `.olean` hash, the text it was compiled from, the options Lake compiled it with, and
17
+ // the keys of the project modules it imports: after a build only the modules whose key changed run
18
+ // again. A module that cannot be elaborated is reported and keeps the text-based context and no
19
+ // notation edges; the others are not held back by it.
20
+ import { spawn } from "node:child_process";
21
+ import { createHash } from "node:crypto";
22
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
23
+ import { join } from "node:path";
24
+ import { SITE_BODY, SITE_GRAMMAR, SITE_PROOF, SITE_TCB } from "../shared/graph.js";
25
+ import { USAGE, USAGE_PROGRAM, leanProgram, prune } from "./extract.js";
26
+ import { compiledTextHash, lakeTextHash } from "./status.js";
27
+ /** How many workers run at once unless `--jobs` says otherwise: past about a quarter of the cores,
28
+ * more workers gain less and less (Sphere-Packing-Lean's cold pass on 32 cores: 320 s with 4, 195 s with
29
+ * 8, 148 s with 16). */
30
+ export const DEFAULT_JOBS = 8;
31
+ /** A worker still running after this long is stopped, and its module reported as failed. */
32
+ const WORKER_TIMEOUT_MS = 10 * 60_000;
33
+ /** Mixed into the fingerprint of a merged graph: bumped when the merge changes, so that graphs
34
+ * merged before are not served as if made by this one. 3: the units' proof spans. (A change to
35
+ * extractor/Usage.lean changes every module's key by itself: its text is in the salt.) */
36
+ const MERGE_VERSION = 3;
37
+ const sha = (s) => createHash("sha256").update(s).digest("hex");
38
+ /** The `-D` options Lake compiled a module with, from its trace (docs/LAKE.md). */
39
+ export function traceOptions(p, module) {
40
+ try {
41
+ const t = JSON.parse(readFileSync(join(p.libDir, ...module.split(".")) + ".trace", "utf8"));
42
+ const out = [];
43
+ for (const inp of t.inputs ?? []) {
44
+ if (!Array.isArray(inp))
45
+ continue;
46
+ if (inp[0] === "options" && Array.isArray(inp[1]))
47
+ for (const o of inp[1])
48
+ if (Array.isArray(o) && typeof o[0] === "string" && o[0].startsWith("-D"))
49
+ out.push(o[0]);
50
+ // `Module.leanArgs: #[…]`: options given to this module alone
51
+ if (typeof inp[0] === "string" && inp[0].startsWith("Module.leanArgs: "))
52
+ for (const m of inp[0].matchAll(/"(-D[^"]+)"/g))
53
+ out.push(m[1]);
54
+ }
55
+ return out;
56
+ }
57
+ catch {
58
+ return [];
59
+ }
60
+ }
61
+ /** The environment Lake gives the project's programs (`lake env`): its LEAN_PATH, its toolchain.
62
+ * Asked once per pass, and every worker started with it, rather than through `lake env` each. */
63
+ function lakeEnv(root) {
64
+ return new Promise((resolveP, reject) => {
65
+ const child = spawn("lake", ["env", "env", "-0"], { cwd: root, stdio: ["ignore", "pipe", "pipe"] });
66
+ const out = [];
67
+ let err = "";
68
+ child.stdout.on("data", (b) => out.push(b));
69
+ child.stderr.on("data", (b) => (err = (err + b).slice(-2000)));
70
+ child.on("error", (e) => reject(new Error(`could not start lake: ${e.message}`)));
71
+ child.on("close", (code) => {
72
+ if (code !== 0)
73
+ return reject(new Error(`lake env exited with ${code}: ${err}`));
74
+ const env = {};
75
+ for (const kv of Buffer.concat(out).toString("utf8").split("\0")) {
76
+ const at = kv.indexOf("=");
77
+ if (at > 0)
78
+ env[kv.slice(0, at)] = kv.slice(at + 1);
79
+ }
80
+ resolveP(env);
81
+ });
82
+ });
83
+ }
84
+ export class UsagePass {
85
+ project;
86
+ dir;
87
+ /** the Lean programs' root (server/dirs.ts), where the worker is compiled */
88
+ lean;
89
+ jobs;
90
+ log;
91
+ status;
92
+ /** the run going on, to stop when a newer build supersedes it */
93
+ current = null;
94
+ constructor(project, dirs, jobs, log) {
95
+ this.project = project;
96
+ this.dir = join(dirs.cache, "usage");
97
+ this.lean = dirs.lean;
98
+ this.jobs = jobs;
99
+ this.log = log;
100
+ this.status = { state: jobs > 0 ? "idle" : "off", jobs, total: 0, todo: 0, done: 0, cached: 0, failed: [], startedAt: null, ms: null };
101
+ }
102
+ get enabled() {
103
+ return this.jobs > 0;
104
+ }
105
+ /** What every result depends on besides its module: this worker, the toolchain, the
106
+ * dependencies (pinned by the manifest). */
107
+ salt() {
108
+ const manifest = join(this.project.root, "lake-manifest.json");
109
+ return sha(Buffer.concat([readFileSync(USAGE), Buffer.from(this.project.toolchain), existsSync(manifest) ? readFileSync(manifest) : Buffer.alloc(0)])).slice(0, 16);
110
+ }
111
+ file(module) {
112
+ return join(this.dir, `${module}.json`);
113
+ }
114
+ read(module, key) {
115
+ try {
116
+ const e = JSON.parse(readFileSync(this.file(module), "utf8"));
117
+ return e.key === key && e.usage ? e : null;
118
+ }
119
+ catch {
120
+ return null;
121
+ }
122
+ }
123
+ /** Which modules of the extraction's graph to elaborate, and which results hold already. */
124
+ plan(graph) {
125
+ const p = this.project;
126
+ const salt = this.salt();
127
+ const imports = new Map(graph.modules.map((m) => [m.name, m.imports]));
128
+ const keys = new Map();
129
+ const missing = new Map();
130
+ // a module's key: its build, and its imports' keys (an import's change can change what it means)
131
+ const keyOf = (m, seen) => {
132
+ const known = keys.get(m);
133
+ if (known)
134
+ return known;
135
+ if (missing.has(m) || seen.has(m))
136
+ return null;
137
+ seen.add(m);
138
+ const base = join(p.libDir, ...m.split("."));
139
+ let own;
140
+ try {
141
+ own = readFileSync(base + ".olean.hash", "utf8").trim();
142
+ }
143
+ catch {
144
+ missing.set(m, "it has no build");
145
+ return null;
146
+ }
147
+ const deps = [];
148
+ for (const i of imports.get(m) ?? []) {
149
+ const k = keyOf(i, seen);
150
+ if (!k) {
151
+ missing.set(m, `${i}, which it imports, has no build`);
152
+ return null;
153
+ }
154
+ deps.push(k);
155
+ }
156
+ const file = p.files.get(m);
157
+ const compiled = file ? compiledTextHash(p, m, file) : null;
158
+ const k = sha([salt, own, compiled ?? "", traceOptions(p, m).join(" "), deps.join(",")].join("|")).slice(0, 24);
159
+ keys.set(m, k);
160
+ return k;
161
+ };
162
+ const plan = { modules: [], keys, cached: new Map(), todo: [], skipped: [] };
163
+ for (const m of graph.modules) {
164
+ const file = p.files.get(m.name);
165
+ if (!file)
166
+ continue;
167
+ plan.modules.push(m.name);
168
+ const key = keyOf(m.name, new Set());
169
+ if (!key) {
170
+ plan.skipped.push({ module: m.name, error: `not elaborated: ${missing.get(m.name) ?? "it has no build"}` });
171
+ continue;
172
+ }
173
+ const hit = this.read(m.name, key);
174
+ if (hit) {
175
+ plan.cached.set(m.name, hit);
176
+ continue;
177
+ }
178
+ // what is there to read must be what was compiled: the pass's places and contexts are about
179
+ // that text, as the graph is
180
+ const compiled = compiledTextHash(p, m.name, file);
181
+ let now = null;
182
+ try {
183
+ now = lakeTextHash(readFileSync(join(p.root, file), "utf8"));
184
+ }
185
+ catch {
186
+ // deleted since the build
187
+ }
188
+ if (!compiled || now !== compiled) {
189
+ plan.skipped.push({ module: m.name, error: "not elaborated: its file changed since it was compiled" });
190
+ continue;
191
+ }
192
+ plan.todo.push({ module: m.name, file, key, hash: compiled, opts: traceOptions(p, m.name) });
193
+ }
194
+ return plan;
195
+ }
196
+ /** Stop the run going on: its workers are killed, what they finished stays cached. */
197
+ cancel() {
198
+ const c = this.current;
199
+ if (!c)
200
+ return;
201
+ c.cancelled = true;
202
+ for (const ch of c.children)
203
+ ch.kill("SIGTERM");
204
+ this.status = { ...this.status, state: "cancelled" };
205
+ }
206
+ /** Elaborate what the plan says, `jobs` at a time; every result, cached or new, by module, and
207
+ * the failures. Null when cancelled. */
208
+ async run(plan, onProgress = () => { }) {
209
+ this.cancel();
210
+ const run = { cancelled: false, children: new Set() };
211
+ this.current = run;
212
+ const t0 = Date.now();
213
+ const results = new Map(plan.cached);
214
+ const failed = plan.skipped.slice();
215
+ this.status = { state: "running", jobs: this.jobs, total: plan.modules.length, todo: plan.todo.length, done: 0, cached: plan.cached.size, failed, startedAt: new Date(t0).toISOString(), ms: null };
216
+ if (plan.todo.length) {
217
+ mkdirSync(this.dir, { recursive: true });
218
+ const bin = await leanProgram(USAGE_PROGRAM, this.project.toolchain, this.lean, this.log);
219
+ let env = null;
220
+ let why = bin ? null : "the usage worker could not be built";
221
+ if (bin) {
222
+ try {
223
+ env = await lakeEnv(this.project.root);
224
+ }
225
+ catch (e) {
226
+ why = e instanceof Error ? e.message : String(e);
227
+ }
228
+ }
229
+ if (!bin || !env) {
230
+ for (const t of plan.todo)
231
+ failed.push({ module: t.module, error: why });
232
+ }
233
+ else {
234
+ const modsFile = join(this.dir, "project-modules.txt");
235
+ writeFileSync(modsFile, plan.modules.join("\n") + "\n");
236
+ const queue = plan.todo.slice();
237
+ const worker = async () => {
238
+ while (queue.length && !run.cancelled) {
239
+ const t = queue.shift();
240
+ const e = await this.one(run, bin, env, modsFile, t);
241
+ if (run.cancelled)
242
+ return;
243
+ if (e.usage)
244
+ results.set(t.module, e);
245
+ else
246
+ failed.push({ module: t.module, error: e.error ?? "failed" });
247
+ this.status.done++;
248
+ onProgress();
249
+ }
250
+ };
251
+ await Promise.all(Array.from({ length: Math.min(this.jobs, queue.length) }, worker));
252
+ }
253
+ }
254
+ if (run.cancelled)
255
+ return null;
256
+ this.current = null;
257
+ this.status = { ...this.status, state: "done", failed, ms: Date.now() - t0 };
258
+ return results;
259
+ }
260
+ /** One worker on one module: its result, cached under its key. A failure is not cached: almost
261
+ * every one is the build's state at that moment (an import Lake is rewriting, a file being
262
+ * edited), and the module is tried again at the next pass. */
263
+ one(run, bin, env, modsFile, t) {
264
+ // the text must be the compiled one when the worker reads it, and still be when it is done: an
265
+ // edit in between would file another text's places under the compiled text's key
266
+ const source = join(this.project.root, t.file);
267
+ const unchanged = () => {
268
+ try {
269
+ return lakeTextHash(readFileSync(source, "utf8")) === t.hash;
270
+ }
271
+ catch {
272
+ return false;
273
+ }
274
+ };
275
+ const edited = { key: t.key, hash: t.hash, error: "not elaborated: its file changed since it was compiled" };
276
+ if (!unchanged())
277
+ return Promise.resolve(edited);
278
+ return new Promise((resolveP) => {
279
+ const tmp = `${this.file(t.module)}.tmp-${process.pid}`;
280
+ const args = [source, t.module, tmp, modsFile, ...t.opts];
281
+ // run from the project's cache directory: nothing a worker might write could land in the project
282
+ const child = spawn(bin, args, { cwd: this.dir, env, stdio: ["ignore", "ignore", "pipe"] });
283
+ run.children.add(child);
284
+ let err = "";
285
+ child.stderr.on("data", (b) => (err = (err + b).slice(-2000)));
286
+ const timer = setTimeout(() => child.kill("SIGKILL"), WORKER_TIMEOUT_MS);
287
+ const done = (e, keep) => {
288
+ clearTimeout(timer);
289
+ run.children.delete(child);
290
+ rmSync(tmp, { force: true });
291
+ if (keep && !run.cancelled) {
292
+ writeFileSync(`${this.file(t.module)}.tmp`, JSON.stringify(e));
293
+ renameSync(`${this.file(t.module)}.tmp`, this.file(t.module));
294
+ }
295
+ resolveP(e);
296
+ };
297
+ child.on("error", (e) => done({ key: t.key, hash: t.hash, error: `could not start the worker: ${e.message}` }, false));
298
+ child.on("close", (code, signal) => {
299
+ if (code === 0) {
300
+ if (!unchanged())
301
+ return done(edited, false);
302
+ try {
303
+ const usage = JSON.parse(readFileSync(tmp, "utf8"));
304
+ return done({ key: t.key, hash: t.hash, usage }, true);
305
+ }
306
+ catch (e) {
307
+ return done({ key: t.key, hash: t.hash, error: `unreadable output: ${e instanceof Error ? e.message : String(e)}` }, false);
308
+ }
309
+ }
310
+ const msg = (err.trim().split("\n").pop() ?? "").replace(/^vp-usage: /, "").slice(0, 500);
311
+ if (signal)
312
+ return done({ key: t.key, hash: t.hash, error: `stopped (${signal})${msg ? `: ${msg}` : ""}` }, false);
313
+ done({ key: t.key, hash: t.hash, error: msg || `exited with ${code}` }, false);
314
+ });
315
+ });
316
+ }
317
+ /** A fingerprint of a plan's results: what a merged graph is made of besides its extraction. */
318
+ static digest(plan, failed) {
319
+ const parts = [...plan.keys.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1)).map(([m, k]) => `${m}=${k}`);
320
+ return sha(`merge ${MERGE_VERSION}\0${parts.join("\n")}\0${failed.map((f) => f.module).sort().join(",")}`).slice(0, 16);
321
+ }
322
+ }
323
+ /** The extraction with the pass's findings merged in (`results` by module, `failed` the modules
324
+ * without one): a new graph, under a fingerprint that names both. */
325
+ export function mergeUsage(ex, plan, results, failed, ms) {
326
+ const graph = ex.graph;
327
+ const n = graph.nodes.length;
328
+ // a unit by any name it answers to: its own, its Lean constant's, and those of what its command
329
+ // generated
330
+ const unitOf = new Map();
331
+ graph.nodes.forEach((v, i) => {
332
+ unitOf.set(v.name, i);
333
+ if (v.leanName)
334
+ unitOf.set(v.leanName, i);
335
+ for (const a of v.also ?? [])
336
+ if (!unitOf.has(a))
337
+ unitOf.set(a, i);
338
+ });
339
+ const grammar = (i) => !!graph.nodes[i].flags?.includes("grammar");
340
+ // the only rule of a syntax that only declares grammar, where it has one
341
+ const onlyRule = (i) => {
342
+ const v = graph.nodes[i];
343
+ if (!grammar(i) || v.foreignRules || v.rules?.length !== 1)
344
+ return undefined;
345
+ return unitOf.get(v.rules[0]);
346
+ };
347
+ const edges = graph.edges.map((e) => [...e]);
348
+ const at = new Map();
349
+ edges.forEach(([s, d], k) => at.set(s * (n + graph.externals.length) + d, k));
350
+ let notationEdges = 0, nestedEdges = 0, ruleEdges = 0;
351
+ const add = (src, dst, site0, nested, rule = false) => {
352
+ if (src === dst)
353
+ return;
354
+ // into a unit that only declares grammar, what is not a proof is grammar: no TCB follows it
355
+ const site = grammar(dst) && site0 & SITE_TCB ? (site0 & SITE_PROOF) | SITE_GRAMMAR : site0;
356
+ const key = src * (n + graph.externals.length) + dst;
357
+ const k = at.get(key);
358
+ if (k !== undefined) {
359
+ edges[k][2] |= site;
360
+ return;
361
+ }
362
+ at.set(key, edges.length);
363
+ edges.push([src, dst, site]);
364
+ if (rule)
365
+ ruleEdges++;
366
+ else if (nested)
367
+ nestedEdges++;
368
+ else
369
+ notationEdges++;
370
+ };
371
+ const isNotation = (i) => i !== undefined && graph.nodes[i].kind === "notation";
372
+ // a macro or elaborator that ran is the project's (the worker records no other): a notation unit,
373
+ // or a declaration registered for the kind (`@[term_elab k] def …`)
374
+ const ran = (impl) => (impl ? unitOf.get(impl) : undefined);
375
+ const contexts = {};
376
+ // each unit's proof spans
377
+ const proofs = new Map();
378
+ // in module order, whatever order the results came in: the same results make the same graph
379
+ for (const [module, e] of [...results].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
380
+ const u = e.usage;
381
+ const decls = {};
382
+ for (const cmd of u.cmds) {
383
+ const units = [...new Set(cmd.consts.map((c) => unitOf.get(c)).filter((i) => i !== undefined))];
384
+ // the context each unit is written in, and the variables Lean included in it: those of the
385
+ // constant the unit is named after, else of everything of the unit the command declared
386
+ for (const i of units) {
387
+ const own = cmd.incl[graph.nodes[i].leanName ?? graph.nodes[i].name];
388
+ const incl = own ?? [...new Set(cmd.consts.filter((c) => unitOf.get(c) === i).flatMap((c) => cmd.incl[c] ?? []))];
389
+ decls[graph.nodes[i].name] ??= [cmd.ctx, ...incl.sort((a, b) => a - b)];
390
+ }
391
+ if (!units.length)
392
+ continue;
393
+ // which unit of the command a use belongs to: the one whose text holds it (a `mutual` block
394
+ // writes several); every unit of the command when none does
395
+ const holders = (line, col) => {
396
+ const inside = units.filter((i) => {
397
+ const r = graph.nodes[i].range;
398
+ return !!r && (line > r[0] || (line === r[0] && col >= r[1])) && (line < r[2] || (line === r[2] && col <= r[3]));
399
+ });
400
+ return inside.length ? inside : units;
401
+ };
402
+ // a proof belongs to the unit whose text holds it
403
+ for (const sp of cmd.proofs ?? [])
404
+ for (const i of holders(sp[0], sp[1]))
405
+ proofs.set(i, [...(proofs.get(i) ?? []), sp]);
406
+ // what interprets a use of a kind: the rule that ran; where none is recorded (a parent
407
+ // expanded it by hand, `expandMacros`), the only rule of its syntax, which every use of the
408
+ // kind runs — so that leaving grammar out of the TCB never leaves its meaning out
409
+ const meaning = (kind, impl) => {
410
+ const r = ran(impl);
411
+ if (r !== undefined)
412
+ return { at: r, rule: false };
413
+ const k = unitOf.get(kind);
414
+ if (k === undefined || !isNotation(k))
415
+ return undefined;
416
+ if (!grammar(k))
417
+ return { at: k, rule: false };
418
+ const only = onlyRule(k);
419
+ return only === undefined ? undefined : { at: only, rule: true };
420
+ };
421
+ for (const [kind, impl, site, line, col, by] of cmd.uses) {
422
+ const k = unitOf.get(kind);
423
+ const m = meaning(kind, impl);
424
+ const targets = [];
425
+ if (isNotation(k))
426
+ targets.push({ at: k, rule: false });
427
+ if (m && m.at !== k)
428
+ targets.push(m);
429
+ if (!targets.length)
430
+ continue;
431
+ // emitted by an expansion: a dependency of what emitted it (the rule that ran, else the only
432
+ // rule of its syntax), not of the declaration; one whose rule is not known leaves it to the
433
+ // declaration, which reaches it then
434
+ const emitter = by >= 0 ? cmd.uses[by] : null;
435
+ const from = emitter ? meaning(emitter[0], emitter[1])?.at : undefined;
436
+ for (const t of targets) {
437
+ if (from !== undefined)
438
+ add(from, t.at, SITE_BODY, true);
439
+ else
440
+ for (const src of holders(line, col))
441
+ add(src, t.at, site, false, t.rule);
442
+ }
443
+ }
444
+ }
445
+ if (e.hash)
446
+ contexts[module] = { hash: e.hash, contexts: u.contexts.map(withNamespaces), decls };
447
+ }
448
+ let proofSpans = 0;
449
+ const nodes = graph.nodes.map((v, i) => {
450
+ const ps = proofs.get(i);
451
+ if (!ps)
452
+ return v;
453
+ const merged = mergeSpans(ps);
454
+ proofSpans += merged.length;
455
+ return { ...v, proofs: merged };
456
+ });
457
+ const usage = {
458
+ contexts,
459
+ failed: failed.slice().sort((a, b) => (a.module < b.module ? -1 : 1)),
460
+ stats: { modules: plan.modules.length, ran: plan.todo.length, cached: plan.cached.size, notationEdges, nestedEdges, ruleEdges, proofSpans, ms },
461
+ };
462
+ const fingerprint = sha(`${ex.fingerprint}\0${UsagePass.digest(plan, failed)}`).slice(0, 16);
463
+ return { graph: { ...graph, nodes, edges, usage }, fingerprint, at: new Date().toISOString(), cached: false };
464
+ }
465
+ /** Spans sorted, those that overlap or touch made one. */
466
+ export function mergeSpans(xs) {
467
+ const before = (a, b, c, d) => a < c || (a === c && b < d);
468
+ const out = [];
469
+ for (const s of xs.slice().sort((x, y) => (before(x[0], x[1], y[0], y[1]) ? -1 : before(y[0], y[1], x[0], x[1]) ? 1 : 0))) {
470
+ const last = out[out.length - 1];
471
+ if (last && !before(last[2], last[3], s[0], s[1])) {
472
+ if (before(last[2], last[3], s[2], s[3]))
473
+ [last[2], last[3]] = [s[2], s[3]];
474
+ }
475
+ else
476
+ out.push([...s]);
477
+ }
478
+ return out;
479
+ }
480
+ /** A context as the worker writes it, with the namespaces around it as shared/scope.ts lists them
481
+ * (innermost first), and marked as the elaborator's. */
482
+ function withNamespaces(c) {
483
+ const parts = c.namespace.split(".").filter(Boolean);
484
+ const namespaces = [];
485
+ for (let k = parts.length; k > 0; k--)
486
+ namespaces.push(parts.slice(0, k).join("."));
487
+ return { ...c, namespaces, exact: true };
488
+ }
489
+ /** A merged extraction filed in the project's cache beside the extractor's, so that a re-layout
490
+ * (`prepare` with `reuse`) finds it by its fingerprint. The extractor's and the merged ones of the
491
+ * last two builds are kept. */
492
+ export function saveMerged(cacheDir, ex) {
493
+ const dir = join(cacheDir, "extract");
494
+ mkdirSync(dir, { recursive: true });
495
+ const file = join(dir, `${ex.fingerprint}.json`);
496
+ if (existsSync(file))
497
+ return;
498
+ writeFileSync(`${file}.tmp`, JSON.stringify(ex.graph));
499
+ renameSync(`${file}.tmp`, file);
500
+ prune(dir, /^[0-9a-f]{16}\.json$/, 4);
501
+ }
@@ -0,0 +1,82 @@
1
+ // Versions (TCBSpec.md, decision 3, as the owner revised it on 2026-10-09). An opinion is bound to
2
+ // one version of a declaration. The kernel's content hash names what the declaration refers to, so
3
+ // renaming a constant made every declaration that uses it look changed, and their approvals stale.
4
+ // A version is made instead from what the extractor gives beside that hash: the same content with
5
+ // the names taken out (`shape`), and those names in order (`refs`), each mapped to an identity a
6
+ // rename does not change — a unit's name taken back through the renames in force (the moves of the
7
+ // review that say a declaration was renamed, HistorySpec.md) to the first name it had. A pure rename
8
+ // of a declaration, or of what it refers to, leaves its version as it is; any other change to its
9
+ // statement or, for data, its body makes a new one, as the kernel's hash did.
10
+ //
11
+ // Opinions recorded before versions carry the kernel's hash. The replay binds each to the version
12
+ // of the content that hash was taken of (`rebinder`), where a graph the server has read holds that
13
+ // declaration with that hash, so that no approval goes stale by the change of scheme.
14
+ import { createHash } from "node:crypto";
15
+ import { gunzipSync, gzipSync } from "node:zlib";
16
+ export const ownName = (u) => u;
17
+ export function versionBase(g) {
18
+ return {
19
+ names: g.nodes.map((n) => n.name),
20
+ kernel: g.nodes.map((n) => n.hash),
21
+ shape: g.nodes.map((n) => (g.refNames && n.shape && n.refs ? n.shape : null)),
22
+ refs: g.nodes.map((n) => (g.refNames && n.shape && n.refs ? n.refs : null)),
23
+ refNames: g.refNames ?? [],
24
+ };
25
+ }
26
+ /** A reference as the version reads it: a unit's name mapped through `identity`, and what follows
27
+ * the space (`A.Point mk`: the constructor of the unit `A.Point`) kept. */
28
+ export function refIdentity(ref, identity) {
29
+ const sp = ref.indexOf(" ");
30
+ return sp < 0 ? identity(ref) : identity(ref.slice(0, sp)) + ref.slice(sp);
31
+ }
32
+ /** The version of one content: its shape, and its references mapped (16 hex digits, as the
33
+ * kernel's hash was written). */
34
+ export function versionOf(shape, refs) {
35
+ const h = createHash("sha256").update(shape);
36
+ for (const r of refs)
37
+ h.update("\0").update(r);
38
+ return h.digest("hex").slice(0, 16);
39
+ }
40
+ /** Every project node's version under `identity`. Each name of `refNames` is mapped once. */
41
+ export function versions(b, identity = ownName) {
42
+ const mapped = new Array(b.refNames.length);
43
+ const ref = (k) => (mapped[k] ??= refIdentity(b.refNames[k], identity));
44
+ return b.names.map((_, i) => {
45
+ const shape = b.shape[i];
46
+ const refs = b.refs[i];
47
+ return shape !== null && refs !== null ? versionOf(shape, refs.map(ref)) : b.kernel[i];
48
+ });
49
+ }
50
+ /** The page's graph with each project node's `hash` its version under `identity` and `kernel` the
51
+ * kernel's hash; null when every version is the one it has already. */
52
+ export function withVersions(metaGz, b, identity) {
53
+ const meta = JSON.parse(gunzipSync(metaGz).toString("utf8"));
54
+ const vs = versions(b, identity);
55
+ let changed = false;
56
+ for (let i = 0; i < meta.projectCount && i < vs.length; i++) {
57
+ const v = meta.nodes[i];
58
+ if (v.name !== b.names[i])
59
+ throw new Error(`versions: node ${i} is ${v.name} in the page's graph and ${b.names[i]} in the extraction`);
60
+ if (v.hash === vs[i])
61
+ continue;
62
+ v.hash = vs[i];
63
+ if (b.shape[i] !== null)
64
+ v.kernel = b.kernel[i];
65
+ changed = true;
66
+ }
67
+ return changed ? gzipSync(JSON.stringify(meta)) : null;
68
+ }
69
+ /** What the replay reads an opinion as: one recorded under the kernel's content hash, before
70
+ * versions, is bound to the version of the content that hash was taken of — `lookup(name, hash)`
71
+ * gives it where a graph the server has read holds the declaration with that hash (the one on
72
+ * show, the snapshots of the history), and null where none does: the opinion is then as it was
73
+ * written, on a version that is not there now. An opinion recorded under a version is never a
74
+ * kernel's hash of its declaration, and is left as it is. */
75
+ export function rebinder(lookup) {
76
+ return (e) => {
77
+ if (e.op !== "opinion" || !e.hash)
78
+ return e;
79
+ const v = lookup(e.name, e.hash);
80
+ return v && v !== e.hash ? { ...e, hash: v } : e;
81
+ };
82
+ }