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,420 @@
1
+ // The exported document, compiled (ExportSpec decision 6): a .tex made by shared/doc/latex.ts,
2
+ // typeset by XeLaTeX with the style file and the fonts this repository holds (assets/latex/,
3
+ // assets/fonts/). Typesetting takes seconds for a large document, so a request starts a job and
4
+ // is answered at once; the page asks how the job is doing and fetches the PDF once it is done.
5
+ //
6
+ // XeLaTeX runs in a temporary directory of its own under the project's cache, never in the project:
7
+ // with -no-shell-escape (no program is ever run), -halt-on-error, nothing read from the terminal,
8
+ // TeX's paranoid file access (openin_any=p, openout_any=p: nothing read or written by an absolute
9
+ // path or outside that directory, beyond TeX's own trees), and a time limit after which it is
10
+ // killed. The .tex itself holds nothing a reader wrote that LaTeX would read as a command (its
11
+ // escaping, shared/doc/latex.ts); these are a second wall. The directory goes once the run is over,
12
+ // whatever happened.
13
+ //
14
+ // A document is known by a hash of its .tex and the style file: the same request again is the same
15
+ // job, running or done, and a done one is kept in a cache of the last few under the project's cache
16
+ // (`export/cache/<hash>/`), so that a reader downloading the sources after the PDF, or a second
17
+ // reader asking for the same document, does not typeset it again. Its figures (the images and clips
18
+ // its notes carry, server/figures.ts) are written beside it in `figures/` before XeLaTeX runs, kept
19
+ // with it in the cache and in the sources' zip; their names say what they are drawn from, so the
20
+ // .tex names them too.
21
+ import { spawn, spawnSync } from "node:child_process";
22
+ import { createHash } from "node:crypto";
23
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
24
+ import { join, resolve } from "node:path";
25
+ import { deflateRawSync } from "node:zlib";
26
+ /** The repository's assets: the style file and the fonts it loads. */
27
+ const ASSETS = resolve(import.meta.dirname, "..", "assets");
28
+ export const STYLE_FILE = join(ASSETS, "latex", "visualproof.sty");
29
+ export const FONTS_DIR = join(ASSETS, "fonts");
30
+ /** How many runs of XeLaTeX one document may take: the second resolves page references and the
31
+ * bookmarks; a third only where the second moved a page break. */
32
+ const MAX_RUNS = 3;
33
+ /** How much of XeLaTeX's log a failed job keeps, in characters: its end, where the error is. */
34
+ const LOG_TAIL = 4000;
35
+ /** What stands in for a figure that could not be made: a white pixel, so that the rest still prints
36
+ * and the job's notes say which. */
37
+ const BLANK_PNG = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR42mP4//8/AAX+Av4zEpUUAAAAAElFTkSuQmCC", "base64");
38
+ /** A figure's name as the .tex gives it (shared/doc/latex.ts, figureAsset): nothing else is written. */
39
+ const FIGURE_NAME = /^(?:fig|clip)-[0-9a-z-]{1,200}\.(?:png|jpg)$/;
40
+ export class Exporter {
41
+ dir;
42
+ cacheDir;
43
+ timeoutMs;
44
+ xelatex;
45
+ maxCached;
46
+ concurrent;
47
+ jobs = new Map();
48
+ /** the jobs' ends, for whoever waits on one */
49
+ ends = new Map();
50
+ running = 0;
51
+ queue = [];
52
+ found = null;
53
+ constructor(cacheDir, o = {}) {
54
+ this.dir = join(cacheDir, "export");
55
+ this.cacheDir = join(this.dir, "cache");
56
+ this.timeoutMs = o.timeoutMs ?? 120_000;
57
+ this.xelatex = o.xelatex ?? "xelatex";
58
+ this.maxCached = o.maxCached ?? 20;
59
+ this.concurrent = o.concurrent ?? 2;
60
+ }
61
+ /** Whether XeLaTeX can be run here: asked once. */
62
+ available() {
63
+ if (this.found === null) {
64
+ const r = spawnSync(this.xelatex, ["--version"], { stdio: "ignore", timeout: 10_000 });
65
+ this.found = r.status === 0;
66
+ }
67
+ return this.found;
68
+ }
69
+ /** The style file as it is now: part of what names a document, so that a changed style typesets
70
+ * again. */
71
+ style() {
72
+ return readFileSync(STYLE_FILE, "utf8");
73
+ }
74
+ /** Typeset `tex`: the job that does it, at once — the one already running or done for the same
75
+ * document, else a new one. `notes`: what the job says besides the document (`ExportJob.notes`).
76
+ * `figures`: the files the .tex includes from `figures/`, made when it is typeset. */
77
+ compile(tex, notes = [], figures = []) {
78
+ const sty = this.style();
79
+ const id = createHash("sha256").update(tex).update("\u0000").update(sty).digest("hex").slice(0, 32);
80
+ const note = (j) => {
81
+ if (notes.length)
82
+ j.notes = [...notes];
83
+ else
84
+ delete j.notes;
85
+ return j;
86
+ };
87
+ const known = this.jobs.get(id);
88
+ if (known && known.state !== "failed")
89
+ return note(known);
90
+ const kept = this.fromCache(id);
91
+ if (kept) {
92
+ this.remember(note(kept));
93
+ this.ends.set(id, Promise.resolve(kept));
94
+ return kept;
95
+ }
96
+ const job = note({ id, state: "running", at: new Date().toISOString() });
97
+ this.remember(job);
98
+ this.ends.set(id, this.turn().then(async () => {
99
+ try {
100
+ await this.run(job, tex, sty, figures);
101
+ }
102
+ catch (e) {
103
+ job.state = "failed";
104
+ job.error = e instanceof Error ? e.message : String(e);
105
+ }
106
+ finally {
107
+ this.running--;
108
+ this.queue.shift()?.();
109
+ }
110
+ return job;
111
+ }));
112
+ return job;
113
+ }
114
+ /** A job by its id: one this server knows, or one the cache holds. */
115
+ job(id) {
116
+ if (!/^[0-9a-f]{32}$/.test(id))
117
+ return null;
118
+ const j = this.jobs.get(id) ?? this.fromCache(id);
119
+ if (j)
120
+ this.remember(j);
121
+ return j;
122
+ }
123
+ /** The job once it is over. */
124
+ wait(id) {
125
+ return this.ends.get(id) ?? Promise.resolve(this.job(id));
126
+ }
127
+ /** A file of a done job: the PDF, the .tex, the style file, or a zip of everything needed to
128
+ * typeset it again (the .tex, the style file, the fonts, a README). Null where the job is not
129
+ * done, or no longer in the cache. */
130
+ file(id, what) {
131
+ const j = this.job(id);
132
+ if (!j || j.state !== "done")
133
+ return null;
134
+ const at = join(this.cacheDir, id);
135
+ try {
136
+ switch (what) {
137
+ case "pdf":
138
+ return { body: readFileSync(join(at, "doc.pdf")), type: "application/pdf", name: "visualproof-export.pdf" };
139
+ case "tex":
140
+ return { body: readFileSync(join(at, "doc.tex")), type: "application/x-tex; charset=utf-8", name: "visualproof-export.tex" };
141
+ case "sty":
142
+ return { body: readFileSync(join(at, "visualproof.sty")), type: "application/x-tex; charset=utf-8", name: "visualproof.sty" };
143
+ case "zip": {
144
+ const file = join(at, "sources.zip");
145
+ if (!existsSync(file))
146
+ writeFileSync(file, this.sources(at));
147
+ return { body: readFileSync(file), type: "application/zip", name: "visualproof-export-sources.zip" };
148
+ }
149
+ }
150
+ }
151
+ catch {
152
+ // gone from the cache meanwhile
153
+ return null;
154
+ }
155
+ }
156
+ /** A turn to typeset: at most `concurrent` at once, the rest in order. */
157
+ turn() {
158
+ if (this.running < this.concurrent) {
159
+ this.running++;
160
+ return Promise.resolve();
161
+ }
162
+ return new Promise((ok) => this.queue.push(() => {
163
+ this.running++;
164
+ ok();
165
+ }));
166
+ }
167
+ /** Keep a job among those this server knows, the last hundred. */
168
+ remember(j) {
169
+ this.jobs.delete(j.id);
170
+ this.jobs.set(j.id, j);
171
+ while (this.jobs.size > 100) {
172
+ const first = this.jobs.keys().next().value;
173
+ this.jobs.delete(first);
174
+ this.ends.delete(first);
175
+ }
176
+ }
177
+ /** A done job the cache holds, as a job. */
178
+ fromCache(id) {
179
+ try {
180
+ const meta = JSON.parse(readFileSync(join(this.cacheDir, id, "job.json"), "utf8"));
181
+ if (!existsSync(join(this.cacheDir, id, "doc.pdf")))
182
+ return null;
183
+ return { id, state: "done", at: meta.at, pages: meta.pages, ms: meta.ms, runs: meta.runs, cached: true, ...(meta.notes?.length ? { notes: meta.notes } : {}) };
184
+ }
185
+ catch {
186
+ return null;
187
+ }
188
+ }
189
+ /** Typeset in a directory of its own, as often as the log asks (at most MAX_RUNS), then keep
190
+ * the result in the cache. The directory goes whatever happens. */
191
+ async run(job, tex, sty, figures = []) {
192
+ mkdirSync(this.dir, { recursive: true });
193
+ const tmp = mkdtempSync(join(this.dir, "run-"));
194
+ const start = Date.now();
195
+ try {
196
+ writeFileSync(join(tmp, "doc.tex"), tex);
197
+ writeFileSync(join(tmp, "visualproof.sty"), sty);
198
+ // the fonts as the style file loads them, from fonts/ beside the document
199
+ symlinkSync(FONTS_DIR, join(tmp, "fonts"), "dir");
200
+ // the figures the .tex includes; one that cannot be made is a blank, and the job says which
201
+ if (figures.length)
202
+ mkdirSync(join(tmp, "figures"));
203
+ const failed = [];
204
+ for (const f of figures) {
205
+ if (!FIGURE_NAME.test(f.name))
206
+ continue;
207
+ let bytes;
208
+ try {
209
+ bytes = await f.make();
210
+ }
211
+ catch (e) {
212
+ failed.push(`${f.name} (${e instanceof Error ? e.message : String(e)})`);
213
+ bytes = BLANK_PNG;
214
+ }
215
+ writeFileSync(join(tmp, "figures", f.name), bytes);
216
+ }
217
+ if (failed.length)
218
+ job.notes = [...(job.notes ?? []), `${failed.length === 1 ? "A figure" : `${failed.length} figures`} could not be drawn and ${failed.length === 1 ? "is" : "are"} left blank: ${failed.join("; ")}.`];
219
+ let log = "";
220
+ let runs = 0;
221
+ for (;;) {
222
+ runs++;
223
+ const left = this.timeoutMs - (Date.now() - start);
224
+ const r = await this.xetex(tmp, left);
225
+ log = readLog(tmp);
226
+ if (r === "timeout")
227
+ throw Object.assign(new Error(`Typesetting took longer than ${Math.round(this.timeoutMs / 1000)} s and was stopped.`), { log });
228
+ if (r !== 0 || !existsSync(join(tmp, "doc.pdf")))
229
+ throw Object.assign(new Error(firstError(log) ?? `XeLaTeX failed (exit ${r}).`), { log });
230
+ if (runs >= MAX_RUNS || !rerun(log))
231
+ break;
232
+ }
233
+ const at = join(this.cacheDir, job.id);
234
+ mkdirSync(at, { recursive: true });
235
+ for (const f of ["doc.pdf", "doc.tex", "visualproof.sty"])
236
+ writeFileSync(join(at, f), readFileSync(join(tmp, f)));
237
+ if (figures.length)
238
+ cpSync(join(tmp, "figures"), join(at, "figures"), { recursive: true });
239
+ job.state = "done";
240
+ job.pages = pagesOf(log) ?? undefined;
241
+ job.runs = runs;
242
+ job.ms = Date.now() - start;
243
+ writeFileSync(join(at, "job.json"), JSON.stringify({ at: job.at, pages: job.pages, ms: job.ms, runs, ...(job.notes ? { notes: job.notes } : {}) }));
244
+ this.prune();
245
+ }
246
+ catch (e) {
247
+ job.state = "failed";
248
+ job.error = e instanceof Error ? e.message : String(e);
249
+ const log = e.log;
250
+ if (log)
251
+ job.log = log.slice(-LOG_TAIL);
252
+ job.ms = Date.now() - start;
253
+ }
254
+ finally {
255
+ rmSync(tmp, { recursive: true, force: true });
256
+ }
257
+ }
258
+ /** One run of XeLaTeX on doc.tex in `cwd`: its exit code, or "timeout" when it was killed. */
259
+ xetex(cwd, timeoutMs) {
260
+ return new Promise((ok) => {
261
+ if (timeoutMs <= 0)
262
+ return ok("timeout");
263
+ const p = spawn(this.xelatex, ["-no-shell-escape", "-halt-on-error", "-interaction=nonstopmode", "doc.tex"], {
264
+ cwd,
265
+ // TeX's paranoid file access; and a log whose lines are not wrapped, so that it reads back
266
+ env: { ...process.env, openin_any: "p", openout_any: "p", shell_escape: "f", max_print_line: "10000" },
267
+ stdio: ["ignore", "ignore", "ignore"],
268
+ });
269
+ let killed = false;
270
+ const timer = setTimeout(() => {
271
+ killed = true;
272
+ p.kill("SIGKILL");
273
+ }, timeoutMs);
274
+ p.on("error", () => {
275
+ clearTimeout(timer);
276
+ ok(127);
277
+ });
278
+ p.on("close", (code) => {
279
+ clearTimeout(timer);
280
+ ok(killed ? "timeout" : (code ?? 1));
281
+ });
282
+ });
283
+ }
284
+ /** Keep the newest `maxCached` documents of the cache; the rest go. */
285
+ prune() {
286
+ let entries;
287
+ try {
288
+ entries = readdirSync(this.cacheDir).map((d) => ({ dir: join(this.cacheDir, d), t: statSync(join(this.cacheDir, d)).mtimeMs }));
289
+ }
290
+ catch {
291
+ return;
292
+ }
293
+ entries.sort((a, b) => b.t - a.t);
294
+ for (const e of entries.slice(this.maxCached))
295
+ rmSync(e.dir, { recursive: true, force: true });
296
+ }
297
+ /** The zip of a cached document's sources: what anyone needs to typeset it again. */
298
+ sources(at) {
299
+ const files = [
300
+ { name: "doc.tex", data: readFileSync(join(at, "doc.tex")) },
301
+ { name: "visualproof.sty", data: readFileSync(join(at, "visualproof.sty")) },
302
+ { name: "README.txt", data: Buffer.from(README, "utf8") },
303
+ ];
304
+ const figures = join(at, "figures");
305
+ if (existsSync(figures))
306
+ for (const f of readdirSync(figures).sort())
307
+ files.push({ name: `figures/${f}`, data: readFileSync(join(figures, f)) });
308
+ for (const f of readdirSync(FONTS_DIR).sort())
309
+ files.push({ name: `fonts/${f}`, data: readFileSync(join(FONTS_DIR, f)) });
310
+ return zip(files);
311
+ }
312
+ }
313
+ const README = `This is a document VisualProof exported, as LaTeX.
314
+
315
+ Typeset it with XeLaTeX, twice (the second run resolves page references and bookmarks):
316
+
317
+ xelatex doc.tex
318
+ xelatex doc.tex
319
+
320
+ doc.tex holds the content only, as semantic macros (\\vpPart, \\begin{vpDecl}, \\vpFullName, \\begin{vpCode}, ...).
321
+ How it looks is visualproof.sty's: change it, replace it, or load a package of your own after it
322
+ and \\renewcommand what you want different. Its head lists every macro a document uses.
323
+
324
+ fonts/ holds JuliaMono (SIL Open Font License, fonts/LICENSE-JuliaMono.txt), the code's font, which
325
+ visualproof.sty loads from there. figures/, where there is one, holds the images and the clips of
326
+ papers the document's notes carry. The text is set in Libertinus (TeX Live), or Latin Modern where
327
+ Libertinus is not installed.
328
+ `;
329
+ /** XeLaTeX's log, as far as there is one. */
330
+ function readLog(dir) {
331
+ try {
332
+ return readFileSync(join(dir, "doc.log"), "latin1");
333
+ }
334
+ catch {
335
+ return "";
336
+ }
337
+ }
338
+ /** Whether the log asks for another run: references or labels not yet resolved. */
339
+ export function rerun(log) {
340
+ return /Rerun to get|Label\(s\) may have changed|There were undefined references|Rerun LaTeX|rerunfilecheck Warning: File .* has changed/.test(log);
341
+ }
342
+ /** How many pages the log says were written. */
343
+ export function pagesOf(log) {
344
+ const m = /Output written on .*?\((\d+) pages?/.exec(log.replace(/\n/g, ""));
345
+ return m ? Number(m[1]) : null;
346
+ }
347
+ /** The first error in a log, in a line: TeX's `! …`. */
348
+ function firstError(log) {
349
+ const m = /^! (.*)$/m.exec(log);
350
+ return m ? `XeLaTeX: ${m[1].slice(0, 300)}` : null;
351
+ }
352
+ // ---------------------------------------------------------------- a zip, without a dependency
353
+ const CRC_TABLE = (() => {
354
+ const t = new Uint32Array(256);
355
+ for (let n = 0; n < 256; n++) {
356
+ let c = n;
357
+ for (let k = 0; k < 8; k++)
358
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
359
+ t[n] = c >>> 0;
360
+ }
361
+ return t;
362
+ })();
363
+ /** CRC-32, as zip files check their entries with. */
364
+ export function crc32(b) {
365
+ let c = 0xffffffff;
366
+ for (let i = 0; i < b.length; i++)
367
+ c = CRC_TABLE[(c ^ b[i]) & 0xff] ^ (c >>> 8);
368
+ return (c ^ 0xffffffff) >>> 0;
369
+ }
370
+ /** A zip archive of `files`, each deflated, its name in UTF-8; dated now, as DOS times go. */
371
+ export function zip(files) {
372
+ const d = new Date();
373
+ const time = (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1);
374
+ const date = ((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate();
375
+ const locals = [];
376
+ const centrals = [];
377
+ let offset = 0;
378
+ for (const f of files) {
379
+ const name = Buffer.from(f.name, "utf8");
380
+ const packed = deflateRawSync(f.data, { level: 9 });
381
+ const crc = crc32(f.data);
382
+ const local = Buffer.alloc(30);
383
+ local.writeUInt32LE(0x04034b50, 0);
384
+ local.writeUInt16LE(20, 4);
385
+ local.writeUInt16LE(0x0800, 6); // the name is UTF-8
386
+ local.writeUInt16LE(8, 8); // deflate
387
+ local.writeUInt16LE(time, 10);
388
+ local.writeUInt16LE(date, 12);
389
+ local.writeUInt32LE(crc, 14);
390
+ local.writeUInt32LE(packed.length, 18);
391
+ local.writeUInt32LE(f.data.length, 22);
392
+ local.writeUInt16LE(name.length, 26);
393
+ local.writeUInt16LE(0, 28);
394
+ const central = Buffer.alloc(46);
395
+ central.writeUInt32LE(0x02014b50, 0);
396
+ central.writeUInt16LE(20, 4);
397
+ central.writeUInt16LE(20, 6);
398
+ central.writeUInt16LE(0x0800, 8);
399
+ central.writeUInt16LE(8, 10);
400
+ central.writeUInt16LE(time, 12);
401
+ central.writeUInt16LE(date, 14);
402
+ central.writeUInt32LE(crc, 16);
403
+ central.writeUInt32LE(packed.length, 20);
404
+ central.writeUInt32LE(f.data.length, 24);
405
+ central.writeUInt16LE(name.length, 28);
406
+ // no extra field, no comment, disk 0, no attributes
407
+ central.writeUInt32LE(offset, 42);
408
+ locals.push(local, name, packed);
409
+ centrals.push(central, name);
410
+ offset += local.length + name.length + packed.length;
411
+ }
412
+ const dir = Buffer.concat(centrals);
413
+ const end = Buffer.alloc(22);
414
+ end.writeUInt32LE(0x06054b50, 0);
415
+ end.writeUInt16LE(files.length, 8);
416
+ end.writeUInt16LE(files.length, 10);
417
+ end.writeUInt32LE(dir.length, 12);
418
+ end.writeUInt32LE(offset, 16);
419
+ return Buffer.concat([...locals, dir, end]);
420
+ }
@@ -0,0 +1,204 @@
1
+ // Run the Lean extractor against a project, or reuse its last result when nothing it reads has
2
+ // changed.
3
+ import { spawn } from "node:child_process";
4
+ import { createHash } from "node:crypto";
5
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
6
+ import { dirname, join, relative } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ export const EXTRACTOR = join(dirname(fileURLToPath(import.meta.url)), "..", "extractor", "Extract.lean");
9
+ /** The usage pass's worker (server/usage.ts). */
10
+ export const USAGE = join(dirname(fileURLToPath(import.meta.url)), "..", "extractor", "Usage.lean");
11
+ /** Mixed into the fingerprint. Bumped when extractions filed before must not be reused, even under
12
+ * the current Extract.lean's fingerprint: a server started before an update of the extractor runs
13
+ * the binary it built at start, and files that binary's output under the new source's
14
+ * fingerprint. 2: axiom sets reach through theorems' proofs. 3: a declaration Lean generated
15
+ * from a command (`gen`) names the declaration that command writes. 4: units — a declaration
16
+ * Lean generated from a command folds into the one that command writes (`also`), with its
17
+ * references, axioms and hash; instances of classes whose instances are all equal are left out
18
+ * of TCBs as proofs are (TCBSpec.md, decisions 1, 3 and 5). 5: meta-level declarations are
19
+ * units, and syntax extension commands units of kind `notation` (decision 2). 6: grammar leaves
20
+ * the TCB (the grammar site), notation units are named after what they declare or interpret. 7:
21
+ * every unit has a name of its own — a declaration two modules list is taken once, local notation
22
+ * is qualified with its module's last part, and a name still shared is numbered. 8: each unit's
23
+ * shape and references, which versions are made from (server/versions.ts). */
24
+ const EXTRACTION_VERSION = 8;
25
+ /** A fingerprint of everything the extraction depends on: the extractor itself, the toolchain,
26
+ * the roots, and every .olean of the project (path, size, mtime). Dependencies are pinned by
27
+ * lake-manifest.json, so it stands in for their builds. */
28
+ export function fingerprint(p) {
29
+ const h = createHash("sha256");
30
+ h.update(`extraction ${EXTRACTION_VERSION}\0`);
31
+ h.update(readFileSync(EXTRACTOR));
32
+ h.update(p.toolchain + "\0" + p.roots.join(",") + "\0");
33
+ const manifest = join(p.root, "lake-manifest.json");
34
+ if (existsSync(manifest))
35
+ h.update(readFileSync(manifest));
36
+ const walk = (dir) => {
37
+ const ents = readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1));
38
+ for (const ent of ents) {
39
+ const f = join(dir, ent.name);
40
+ if (ent.isDirectory())
41
+ walk(f);
42
+ else if (ent.name.endsWith(".olean")) {
43
+ const st = statSync(f);
44
+ h.update(`${relative(p.libDir, f)}\0${st.size}\0${st.mtimeMs}\n`);
45
+ }
46
+ }
47
+ };
48
+ walk(p.libDir);
49
+ return h.digest("hex").slice(0, 16);
50
+ }
51
+ export const EXTRACT_PROGRAM = { dir: "extractor", source: EXTRACTOR, exe: "vp-extract", root: "Extract" };
52
+ export const USAGE_PROGRAM = { dir: "usage", source: USAGE, exe: "vp-usage", root: "Usage" };
53
+ const built = new Map();
54
+ export function leanProgram(prog, toolchain, leanRoot, log) {
55
+ const source = readFileSync(prog.source);
56
+ const hash = createHash("sha256").update(source).digest("hex").slice(0, 12);
57
+ const key = [prog.exe, toolchain, hash, leanRoot].join("\0");
58
+ let p = built.get(key);
59
+ if (p)
60
+ return p;
61
+ p = new Promise((resolveP) => {
62
+ const parent = join(leanRoot, prog.dir, toolchain.replace(/[^A-Za-z0-9.]+/g, "-"));
63
+ const dir = join(parent, hash);
64
+ const bin = join(dir, ".lake", "build", "bin", prog.exe);
65
+ if (existsSync(bin))
66
+ return resolveP(bin);
67
+ mkdirSync(parent, { recursive: true });
68
+ const work = mkdtempSync(join(parent, `${hash}.tmp-`));
69
+ writeFileSync(join(work, `${prog.root}.lean`), source);
70
+ writeFileSync(join(work, "lean-toolchain"), toolchain + "\n");
71
+ writeFileSync(join(work, "lakefile.toml"), `name = "${prog.exe.replace(/-/g, "")}"\n\n[[lean_exe]]\nname = "${prog.exe}"\nroot = "${prog.root}"\nsupportInterpreter = true\n`);
72
+ log(`building ${prog.exe} for ${toolchain}…`);
73
+ const child = spawn("lake", ["build", prog.exe], { cwd: work, stdio: ["ignore", "pipe", "pipe"] });
74
+ let tail = "";
75
+ child.stdout.on("data", (b) => (tail = (tail + b).slice(-2000)));
76
+ child.stderr.on("data", (b) => (tail = (tail + b).slice(-2000)));
77
+ let finished = false;
78
+ const done = (ok) => {
79
+ // a child that failed to start may report both `error` and `close`
80
+ if (finished)
81
+ return;
82
+ finished = true;
83
+ if (ok) {
84
+ try {
85
+ renameSync(work, dir);
86
+ }
87
+ catch {
88
+ // another server moved its build of the same source into place first
89
+ }
90
+ }
91
+ else
92
+ log(`could not build ${prog.exe}:\n${tail}`);
93
+ rmSync(work, { recursive: true, force: true });
94
+ resolveP(existsSync(bin) ? bin : null);
95
+ };
96
+ child.on("error", () => done(false));
97
+ child.on("close", (code) => done(code === 0 && existsSync(join(work, ".lake", "build", "bin", prog.exe))));
98
+ });
99
+ built.set(key, p);
100
+ return p;
101
+ }
102
+ /** The extractor, compiled (`leanProgram`); null when it could not be, and it is interpreted. */
103
+ export function extractorBinary(toolchain, leanRoot, log) {
104
+ return leanProgram(EXTRACT_PROGRAM, toolchain, leanRoot, log);
105
+ }
106
+ /** `lake env` puts the project's own toolchain and package paths in place, so the extractor
107
+ * always matches the .olean format it reads. */
108
+ function runExtractor(p, out, opts, log) {
109
+ return new Promise((resolveP, reject) => {
110
+ const tail_ = ["--cache", opts.cache, "--salt", opts.salt];
111
+ const args = opts.bin
112
+ ? ["env", opts.bin, out, p.libDir, ...p.roots, ...tail_]
113
+ : ["env", "lean", "--run", EXTRACTOR, out, p.libDir, ...p.roots, ...tail_];
114
+ const child = spawn("lake", args, { cwd: p.root, stdio: ["ignore", "pipe", "pipe"] });
115
+ let tail = "";
116
+ const onData = (buf) => {
117
+ const text = buf.toString();
118
+ tail = (tail + text).slice(-4000);
119
+ for (const line of text.split("\n"))
120
+ if (line.trim())
121
+ log(line);
122
+ };
123
+ child.stdout.on("data", onData);
124
+ child.stderr.on("data", onData);
125
+ child.on("error", (e) => reject(new Error(`could not start lake: ${e.message}`)));
126
+ child.on("close", (code) => {
127
+ if (code === 0)
128
+ return resolveP();
129
+ const missing = tail.match(/object file '[^']*' of module (\S+) does not exist/);
130
+ if (missing) {
131
+ return reject(new Error(`the build is incomplete: ${missing[1]} has no .olean. A \`lake build\` is running or ` +
132
+ "failed; VisualProof reads only what a finished build left behind. Retry when it is done."));
133
+ }
134
+ // modules a project never imports together (two versions of one file, a library of reference
135
+ // statements beside its proofs): Lean cannot import them into one environment
136
+ const clash = tail.match(/import (\S+) failed, environment already contains '([^']+)' from (\S+)/);
137
+ if (clash) {
138
+ return reject(new Error(`${clash[1]} and ${clash[3]} cannot be read together: both declare ${clash[2]}. Read fewer: ` +
139
+ "`--root` names the libraries or modules to read (repeatable)."));
140
+ }
141
+ reject(new Error(`extractor exited with ${code}:\n${tail}`));
142
+ });
143
+ });
144
+ }
145
+ /** Keep the newest `keep` files of a cache directory that match `pattern`; a live session
146
+ * writes one extraction per build and would otherwise fill the disk. */
147
+ export function prune(dir, pattern, keep) {
148
+ try {
149
+ const files = readdirSync(dir)
150
+ .filter((f) => pattern.test(f))
151
+ .map((f) => ({ f, t: statSync(join(dir, f)).mtimeMs }))
152
+ .sort((a, b) => b.t - a.t);
153
+ for (const { f } of files.slice(keep))
154
+ unlinkSync(join(dir, f));
155
+ }
156
+ catch {
157
+ // pruning is housekeeping; never a reason to fail
158
+ }
159
+ }
160
+ /** A saved extraction, served as it is: for a snapshot of a project that is not on this
161
+ * machine, or not built right now. */
162
+ export function loadSaved(file) {
163
+ const text = readFileSync(file, "utf8");
164
+ return {
165
+ graph: JSON.parse(text),
166
+ fingerprint: createHash("sha256").update(text).digest("hex").slice(0, 16),
167
+ at: statSync(file).mtime.toISOString(),
168
+ cached: true,
169
+ };
170
+ }
171
+ /** An extraction the project's cache already holds, by fingerprint. */
172
+ export function cachedExtraction(cacheDir, fp) {
173
+ const file = join(cacheDir, "extract", `${fp}.json`);
174
+ if (!existsSync(file))
175
+ return null;
176
+ return { graph: JSON.parse(readFileSync(file, "utf8")), fingerprint: fp, at: statSync(file).mtime.toISOString(), cached: true };
177
+ }
178
+ export async function extract(p, dirs, log) {
179
+ const fp = fingerprint(p);
180
+ const dir = join(dirs.cache, "extract");
181
+ mkdirSync(dir, { recursive: true });
182
+ const file = join(dir, `${fp}.json`);
183
+ if (existsSync(file)) {
184
+ return {
185
+ graph: JSON.parse(readFileSync(file, "utf8")),
186
+ fingerprint: fp,
187
+ at: statSync(file).mtime.toISOString(),
188
+ cached: true,
189
+ };
190
+ }
191
+ const tmp = `${file}.tmp`;
192
+ const bin = await extractorBinary(p.toolchain, dirs.lean, log);
193
+ // the per-module cache is only valid for one extractor, toolchain and set of dependencies
194
+ const salt = createHash("sha256")
195
+ .update(readFileSync(EXTRACTOR))
196
+ .update(p.toolchain)
197
+ .update(existsSync(join(p.root, "lake-manifest.json")) ? readFileSync(join(p.root, "lake-manifest.json")) : "")
198
+ .digest("hex")
199
+ .slice(0, 16);
200
+ await runExtractor(p, tmp, { bin, cache: join(dirs.cache, "modules"), salt }, log);
201
+ renameSync(tmp, file);
202
+ prune(dir, /^[0-9a-f]{16}\.json$/, 3);
203
+ return { graph: JSON.parse(readFileSync(file, "utf8")), fingerprint: fp, at: new Date().toISOString(), cached: false };
204
+ }