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,780 @@
1
+ // The history (HistorySpec.md, decided by the owner on 2026-10-09): a snapshot of the graph at every
2
+ // build the server reads, kept for good in `<site>/history/`, so that the page can show the
3
+ // project as it was at a build and say what changed since (the Time Machine), and so that the
4
+ // declarations that vanished from one build to the next can be paired with those that appeared
5
+ // (server/renames.ts).
6
+ //
7
+ // A snapshot is a manifest: when the build finished (the newest `.olean` of the project), the
8
+ // commit it was built from where the project is a git repository (read-only queries), and, per
9
+ // module, the content hash of what the extractor read from it. A module's content — its units with
10
+ // what a card shows of them, their versions' parts (TCBSpec decision 3) and the edges out of them,
11
+ // by name — is a file of its own named by that hash, so a module that did not change between two
12
+ // builds is stored once. The declarations outside the project the snapshot refers to are one such
13
+ // file too. What the usage pass found besides edges (contexts, proof spans) is not kept: it is for
14
+ // reading the source as it is now. The source text each module's build was compiled from is kept as
15
+ // git keeps it, a blob per text named by its SHA-256 (server/sourcestore.ts), and the manifest says
16
+ // which blob each module's is.
17
+ //
18
+ // history/modules/<sha>.json.gz one module's content, or a snapshot's outside declarations
19
+ // history/sources/<sha256>.gz one text of a project file (server/sourcestore.ts)
20
+ // history/snapshots/<id>.json.gz a snapshot's manifest: module → content hash, and module →
21
+ // the text its build compiled (`sources`)
22
+ // history/index.jsonl one line per snapshot (SnapshotInfo), appended; the last line
23
+ // of an id is what it is (a build read again supersedes)
24
+ //
25
+ // One build is one snapshot. The extractor's graph is served first and the usage pass's merged one
26
+ // after it; a server restarted, or a newer extractor, reads the same build again: each of these has
27
+ // the same build time, and replaces the snapshot's manifest rather than adding one. Nothing is ever
28
+ // deleted.
29
+ import { createHash } from "node:crypto";
30
+ import { spawnSync } from "node:child_process";
31
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
32
+ import { join } from "node:path";
33
+ import { gunzipSync, gzipSync } from "node:zlib";
34
+ import { SourceStore, summarize } from "./sourcestore.js";
35
+ import { ownName, refIdentity, versionOf } from "./versions.js";
36
+ const sha = (s) => createHash("sha256").update(s).digest("hex").slice(0, 32);
37
+ /** When the project's build finished: its newest `.olean`, in ms; 0 where there is none. */
38
+ export function buildTime(p) {
39
+ let newest = 0;
40
+ const walk = (dir) => {
41
+ let ents;
42
+ try {
43
+ ents = readdirSync(dir, { withFileTypes: true });
44
+ }
45
+ catch {
46
+ return;
47
+ }
48
+ for (const e of ents) {
49
+ const f = join(dir, e.name);
50
+ if (e.isDirectory())
51
+ walk(f);
52
+ else if (e.name.endsWith(".olean"))
53
+ newest = Math.max(newest, statSync(f).mtimeMs);
54
+ }
55
+ };
56
+ walk(p.libDir);
57
+ return newest;
58
+ }
59
+ /** Git, read-only and taking no lock: its answer, or null where git or the repository is not there. */
60
+ function git(root, ...args) {
61
+ const r = spawnSync("git", ["-C", root, ...args], { encoding: "utf8", timeout: 5000, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, stdio: ["ignore", "pipe", "ignore"] });
62
+ return r.status === 0 ? r.stdout.trim() : null;
63
+ }
64
+ /** The commit checked out now, and whether a `.lean` file differs from it. */
65
+ export function commitNow(root) {
66
+ const hash = git(root, "rev-parse", "HEAD");
67
+ if (!hash || !/^[0-9a-f]{7,64}$/.test(hash))
68
+ return null;
69
+ const branch = git(root, "rev-parse", "--abbrev-ref", "HEAD");
70
+ const status = git(root, "status", "--porcelain", "--untracked-files=no", "--", "*.lean");
71
+ return { hash, branch: branch && branch !== "HEAD" ? branch : null, ...(status ? { dirty: true } : {}) };
72
+ }
73
+ /** The project's git source and the commit checked out, as a page may show them (an artwork's foot,
74
+ * ExportSpec decision 19): the remote's address (`origin`, else the first) with no user name or
75
+ * password (sourceAddress); null where the project is not a git repository. Read-only, as `git`. */
76
+ export function gitSource(root) {
77
+ const c = commitNow(root);
78
+ if (!c)
79
+ return null;
80
+ const remotes = (git(root, "remote") ?? "").split("\n").filter(Boolean);
81
+ const name = remotes.includes("origin") ? "origin" : remotes[0];
82
+ const url = name ? git(root, "remote", "get-url", name) : null;
83
+ return { source: url ? sourceAddress(url) : null, commit: c.hash, branch: c.branch, ...(c.dirty ? { dirty: true } : {}) };
84
+ }
85
+ /** A remote's address as a page shows it: its host and path, without the scheme, a user name, a
86
+ * password, a port or `.git` — `git@github.com:A/B.git` and `https://user:token@github.com/A/B`
87
+ * both read `github.com/A/B`. Null for a path on this machine, or what is no address. */
88
+ export function sourceAddress(url) {
89
+ const u = url.trim();
90
+ let host, path;
91
+ // scp-like, [user@]host:path, but not a Windows drive
92
+ const scp = /^(?:[^@/\s]+@)?([A-Za-z0-9.-]+):(?!\/\/)(\S+)$/.exec(u);
93
+ if (scp && !/^[A-Za-z]:[\\/]/.test(u))
94
+ [host, path] = [scp[1], scp[2]];
95
+ else {
96
+ let p;
97
+ try {
98
+ p = new URL(u);
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ if (!["https:", "http:", "ssh:", "git:"].includes(p.protocol) || !p.hostname)
104
+ return null;
105
+ [host, path] = [p.hostname, decodeURIComponent(p.pathname)];
106
+ }
107
+ path = path.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
108
+ if (!/^[A-Za-z0-9.-]+$/.test(host) || !path || !/^[\w.~/-]+$/.test(path))
109
+ return null;
110
+ return `${host}/${path}`;
111
+ }
112
+ /** The last commit made before `at`: what an imported extraction was probably built from. */
113
+ function commitBefore(root, at) {
114
+ const hash = git(root, "log", "-1", "--format=%H", `--before=${at}`);
115
+ return hash && /^[0-9a-f]{7,64}$/.test(hash) ? { hash, branch: null, guessed: true } : null;
116
+ }
117
+ /** An extraction whose names are not all its own (made before every unit had one, TCBSpec
118
+ * "Names"): a unit given twice, with the same place, is kept once and the edges of both are its;
119
+ * units of different modules that share a name are qualified with their modules' last parts, as
120
+ * local notation now is, and any name still shared is numbered. */
121
+ export function uniqueNames(g) {
122
+ const groups = new Map();
123
+ g.nodes.forEach((n, i) => groups.set(n.name, [...(groups.get(n.name) ?? []), i]));
124
+ if (groups.size === g.nodes.length)
125
+ return g;
126
+ const same = new Map();
127
+ const names = g.nodes.map((n) => n.name);
128
+ for (const idx of groups.values()) {
129
+ if (idx.length < 2)
130
+ continue;
131
+ const kept = [];
132
+ for (const i of idx) {
133
+ const twin = kept.find((k) => g.nodes[k].module === g.nodes[i].module && JSON.stringify(g.nodes[k].range) === JSON.stringify(g.nodes[i].range));
134
+ if (twin !== undefined)
135
+ same.set(i, twin);
136
+ else
137
+ kept.push(i);
138
+ }
139
+ if (kept.length > 1)
140
+ for (const k of kept)
141
+ names[k] = `${g.nodes[k].name}.${g.nodes[k].module.split(".").pop()}`;
142
+ }
143
+ const taken = new Set();
144
+ const keep = [];
145
+ const at = new Map();
146
+ g.nodes.forEach((_, i) => {
147
+ if (same.has(i))
148
+ return;
149
+ let nm = names[i];
150
+ for (let k = 2; taken.has(nm); k++)
151
+ nm = `${names[i]}_${k}`;
152
+ taken.add(nm);
153
+ names[i] = nm;
154
+ at.set(i, keep.length);
155
+ keep.push(i);
156
+ });
157
+ for (const [i, twin] of same)
158
+ at.set(i, at.get(twin));
159
+ const n = g.nodes.length;
160
+ const to = (d) => (d < n ? at.get(d) : keep.length + (d - n));
161
+ const sites = new Map();
162
+ for (const [s, d, m] of g.edges) {
163
+ const key = `${to(s)},${to(d)}`;
164
+ sites.set(key, (sites.get(key) ?? 0) | m);
165
+ }
166
+ return {
167
+ ...g,
168
+ nodes: keep.map((i) => ({ ...g.nodes[i], name: names[i] })),
169
+ edges: [...sites].map(([k, m]) => [...k.split(",").map(Number), m]),
170
+ };
171
+ }
172
+ /** One module's content and the outside declarations, from an extraction. */
173
+ function records(g) {
174
+ const axNames = (k) => (g.axiomSets[k] ?? []).map((a) => g.axioms[a]);
175
+ const n = g.nodes.length;
176
+ const nameOf = (d) => (d < n ? g.nodes[d].name : g.externals[d - n].name);
177
+ const byModule = new Map();
178
+ const edgesOf = new Map();
179
+ for (const m of g.modules)
180
+ byModule.set(m.name, { module: m.name, imports: [...m.imports].sort(), nodes: [], targets: [], out: [] });
181
+ const local = new Int32Array(n);
182
+ g.nodes.forEach((v, i) => {
183
+ let r = byModule.get(v.module);
184
+ if (!r)
185
+ byModule.set(v.module, (r = { module: v.module, imports: [], nodes: [], targets: [], out: [] }));
186
+ local[i] = r.nodes.length;
187
+ r.nodes.push(snapNode(v, g, axNames(v.ax)));
188
+ });
189
+ let edges = 0;
190
+ for (const [s, d, m] of g.edges) {
191
+ if (s >= n)
192
+ continue;
193
+ const mod = g.nodes[s].module;
194
+ let es = edgesOf.get(mod);
195
+ if (!es)
196
+ edgesOf.set(mod, (es = []));
197
+ es.push([local[s], nameOf(d), m]);
198
+ edges++;
199
+ }
200
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
201
+ for (const [mod, es] of edgesOf) {
202
+ const r = byModule.get(mod);
203
+ es.sort((a, b) => a[0] - b[0] || cmp(a[1], b[1]) || a[2] - b[2]);
204
+ r.targets = [...new Set(es.map((e) => e[1]))].sort(cmp);
205
+ const at = new Map(r.targets.map((t, k) => [t, k]));
206
+ for (const [src, dst, m] of es) {
207
+ const last = r.out[r.out.length - 1];
208
+ if (last && last[0] === src)
209
+ last.push(at.get(dst), m);
210
+ else
211
+ r.out.push([src, at.get(dst), m]);
212
+ }
213
+ }
214
+ const externals = g.externals.map((e) => ({ name: e.name, kind: e.kind, module: e.module, ax: axNames(e.ax) })).sort((a, b) => (a.name < b.name ? -1 : 1));
215
+ return { modules: [...byModule.values()].sort((a, b) => (a.module < b.module ? -1 : 1)), externals, edges };
216
+ }
217
+ function snapNode(v, g, ax) {
218
+ const s = { name: v.name, kind: v.kind, module: v.module, type: v.type, ax, hash: v.hash };
219
+ if (v.shape && v.refs && g.refNames) {
220
+ s.shape = v.shape;
221
+ s.refs = v.refs.map((k) => g.refNames[k]);
222
+ }
223
+ if (v.doc)
224
+ s.doc = v.doc;
225
+ if (v.range)
226
+ s.range = v.range;
227
+ if (v.also?.length)
228
+ s.also = v.also;
229
+ if (v.flags?.length)
230
+ s.flags = v.flags;
231
+ if (v.leanName)
232
+ s.leanName = v.leanName;
233
+ if (v.display)
234
+ s.display = v.display;
235
+ if (v.interprets?.length)
236
+ s.interprets = v.interprets;
237
+ if (v.rules?.length)
238
+ s.rules = v.rules;
239
+ if (v.gen !== undefined)
240
+ s.gen = v.gen;
241
+ return s;
242
+ }
243
+ /** A snapshot node's version under `identity` (TCBSpec decision 3): from its shape where it has
244
+ * one, else the kernel's hash it was read with. */
245
+ export function snapVersion(v, identity = ownName) {
246
+ return v.shape && v.refs ? versionOf(v.shape, v.refs.map((r) => refIdentity(r, identity))) : v.hash;
247
+ }
248
+ export class History {
249
+ dir;
250
+ modDir;
251
+ snapDir;
252
+ indexFile;
253
+ /** every snapshot, by id, as the index last says it */
254
+ infos = new Map();
255
+ /** module files known to be on disk, and their gzipped sizes */
256
+ sizes = new Map();
257
+ /** snapshot graphs read lately (the Time Machine asks for a few, the renames for the last) */
258
+ cache = new Map();
259
+ /** the extractions each snapshot was read from, every one: what the backfill of its texts looks
260
+ * for the views of (server/sourcestore.ts) */
261
+ reads = new Map();
262
+ /** the source texts' blobs (server/sourcestore.ts) */
263
+ sources;
264
+ /** the texts each snapshot's manifest lists, as read last (null: none yet) */
265
+ texts = new Map();
266
+ constructor(siteDir) {
267
+ this.dir = join(siteDir, "history");
268
+ this.modDir = join(this.dir, "modules");
269
+ this.snapDir = join(this.dir, "snapshots");
270
+ this.indexFile = join(this.dir, "index.jsonl");
271
+ this.sources = new SourceStore(this.dir);
272
+ if (existsSync(this.indexFile)) {
273
+ for (const line of readFileSync(this.indexFile, "utf8").split("\n")) {
274
+ if (!line.trim())
275
+ continue;
276
+ try {
277
+ const s = JSON.parse(line);
278
+ if (Number.isInteger(s.id))
279
+ this.infos.set(s.id, s);
280
+ if (Number.isInteger(s.id) && s.fingerprint)
281
+ this.readOf(s.id).add(s.fingerprint);
282
+ }
283
+ catch {
284
+ // a line cut short (the server stopped while writing): its snapshot is read again later
285
+ }
286
+ }
287
+ }
288
+ }
289
+ readOf(id) {
290
+ let r = this.reads.get(id);
291
+ if (!r)
292
+ this.reads.set(id, (r = new Set()));
293
+ return r;
294
+ }
295
+ get empty() {
296
+ return this.infos.size === 0;
297
+ }
298
+ /** Every snapshot, oldest build first. */
299
+ list() {
300
+ return [...this.infos.values()].sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : a.id - b.id));
301
+ }
302
+ get(id) {
303
+ return this.infos.get(id) ?? null;
304
+ }
305
+ /** The latest build's snapshot. */
306
+ latest() {
307
+ const l = this.list();
308
+ return l.length ? l[l.length - 1] : null;
309
+ }
310
+ /** What the history holds on disk, in bytes. */
311
+ diskBytes() {
312
+ let total = 0;
313
+ for (const d of [this.modDir, this.snapDir, this.sources.dir]) {
314
+ try {
315
+ for (const f of readdirSync(d))
316
+ total += statSync(join(d, f)).size;
317
+ }
318
+ catch {
319
+ // nothing there yet
320
+ }
321
+ }
322
+ try {
323
+ total += statSync(this.indexFile).size;
324
+ }
325
+ catch {
326
+ // nothing there yet
327
+ }
328
+ return total;
329
+ }
330
+ /** Keep a snapshot of an extraction: a new one for a new build, or the build's own again (the
331
+ * merged graph after the extractor's, a newer extractor, a restart), its manifest replaced and
332
+ * the one before kept beside it. A read of a build that holds fewer modules than the snapshot of
333
+ * it (part of the sources changed since the build, so that some modules could not be imported)
334
+ * changes nothing. The snapshot; whether it is a new build; and the graph the history had last
335
+ * for what this one follows — the build's own before it was read again, else the latest build's —
336
+ * which the renames are found against. `sources` finds the texts the build was compiled from, as
337
+ * they are at this reading (server/sourcestore.ts, `keepSources`), and the bytes their blobs
338
+ * written now took: asked only where the snapshot is written; a build read again keeps the text
339
+ * it was found to have before for a module whose file was edited since, its Lake hash being the
340
+ * same. */
341
+ record(g, o) {
342
+ mkdirSync(this.modDir, { recursive: true });
343
+ mkdirSync(this.snapDir, { recursive: true });
344
+ const at = new Date(o.at).toISOString();
345
+ const same = [...this.infos.values()].find((s) => s.at === at);
346
+ const prior = same ?? this.list().filter((s) => s.at < at).pop() ?? null;
347
+ if (same && (same.fingerprint === o.fingerprint || g.modules.length < same.counts.modules))
348
+ return { info: same, fresh: false, written: false, before: null };
349
+ const before = prior ? this.graph(prior.id) : null;
350
+ const id = same?.id ?? Math.max(0, ...this.infos.keys()) + 1;
351
+ const { modules, externals, edges } = records(uniqueNames(g));
352
+ let fresh = 0;
353
+ let all = 0;
354
+ const put = (content) => {
355
+ const h = sha(content);
356
+ let size = this.sizes.get(h);
357
+ if (size === undefined) {
358
+ const f = join(this.modDir, `${h}.json.gz`);
359
+ if (existsSync(f))
360
+ size = statSync(f).size;
361
+ else {
362
+ const gz = gzipSync(content);
363
+ writeFileSync(`${f}.tmp`, gz);
364
+ renameSync(`${f}.tmp`, f);
365
+ size = gz.length;
366
+ fresh += size;
367
+ }
368
+ this.sizes.set(h, size);
369
+ }
370
+ all += size;
371
+ return h;
372
+ };
373
+ const manifest = { id, modules: {}, externals: put(JSON.stringify({ externals })) };
374
+ for (const r of modules)
375
+ manifest.modules[r.module] = put(JSON.stringify(r));
376
+ // the texts: as found at this reading, with those found when the build was read before; a
377
+ // reading that looked for none keeps those
378
+ const was = same ? this.sourcesOf(same.id) : null;
379
+ let texts;
380
+ if (o.sources) {
381
+ const found = o.sources();
382
+ manifest.sources = carried(found.map, was);
383
+ const s = summarize(manifest.sources, this.sources, found.written);
384
+ texts = { ...s, bytes: { ...s.bytes, new: (same?.sources?.bytes.new ?? 0) + found.written } };
385
+ }
386
+ else if (was) {
387
+ manifest.sources = was;
388
+ texts = same?.sources;
389
+ }
390
+ const mf = join(this.snapDir, `${id}.json.gz`);
391
+ // the build read before: its manifest stays, named by the extraction it was taken from
392
+ if (same && existsSync(mf))
393
+ renameSync(mf, join(this.snapDir, `${id}-${same.fingerprint}.json.gz`));
394
+ writeFileSync(`${mf}.tmp`, gzipSync(JSON.stringify(manifest)));
395
+ renameSync(`${mf}.tmp`, mf);
396
+ const info = {
397
+ id,
398
+ at,
399
+ ...(o.approx ? { approx: true } : {}),
400
+ read: o.read ?? new Date().toISOString(),
401
+ fingerprint: o.fingerprint,
402
+ commit: o.commit,
403
+ toolchain: o.toolchain,
404
+ missing: [...o.missing].sort(),
405
+ counts: { modules: modules.length, nodes: g.nodes.length, edges, externals: externals.length },
406
+ // a build read again keeps what it wrote the first time, and adds what it writes now
407
+ bytes: { new: (same?.bytes.new ?? 0) + fresh, all },
408
+ ...(o.imported || same?.imported ? { imported: true } : {}),
409
+ ...(same?.renames ? { renames: same.renames } : {}),
410
+ ...(texts ? { sources: texts } : {}),
411
+ };
412
+ this.save(info);
413
+ this.readOf(id).add(o.fingerprint);
414
+ this.texts.delete(id);
415
+ this.cache.delete(id);
416
+ // what was looked for and not found may be in this one
417
+ for (const [k, v] of this.found)
418
+ if (v === null)
419
+ this.found.delete(k);
420
+ this.holders = null;
421
+ this.index(manifest);
422
+ return { info, fresh: !same, written: true, before };
423
+ }
424
+ /** Say more of a snapshot (the renames found against the one before, its texts backfilled). */
425
+ annotate(id, more) {
426
+ const s = this.infos.get(id);
427
+ if (s)
428
+ this.save({ ...s, ...more });
429
+ }
430
+ /** The texts snapshot `id`'s build was compiled from, as its manifest lists them; null where it
431
+ * lists none (made before the history kept texts, and not backfilled yet), or there is no such
432
+ * snapshot. */
433
+ sourcesOf(id) {
434
+ if (!this.infos.has(id))
435
+ return null;
436
+ if (!this.texts.has(id))
437
+ this.texts.set(id, this.manifest(id)?.sources ?? null);
438
+ return this.texts.get(id);
439
+ }
440
+ /** Give snapshot `id`'s manifest the texts its build was compiled from (the backfill, which wrote
441
+ * `written` gzipped bytes of blobs for them): the manifest is written again with them and
442
+ * nothing else changed, and its line in the index says how complete they are. A manifest that
443
+ * has texts already keeps them (a reading of the build kept them meanwhile): false. */
444
+ setSources(id, map, written) {
445
+ const m = this.manifest(id);
446
+ if (!m || m.sources)
447
+ return false;
448
+ m.sources = map;
449
+ const mf = join(this.snapDir, `${id}.json.gz`);
450
+ writeFileSync(`${mf}.tmp`, gzipSync(JSON.stringify(m)));
451
+ renameSync(`${mf}.tmp`, mf);
452
+ this.texts.set(id, map);
453
+ this.annotate(id, { sources: summarize(map, this.sources, written) });
454
+ return true;
455
+ }
456
+ /** Module `module`'s text at snapshot `id`'s build: what the manifest says of it, and the text
457
+ * where one is kept; null where the manifest says nothing of the module. */
458
+ textOf(id, module) {
459
+ const map = this.sourcesOf(id);
460
+ const ref = map && Object.hasOwn(map, module) ? map[module] : null;
461
+ if (!ref)
462
+ return null;
463
+ const text = ref.blob ? this.sources.text(ref.blob) : null;
464
+ return { ref: ref.blob && text === null ? { ...ref, blob: undefined, why: "its kept text cannot be read" } : ref, text };
465
+ }
466
+ /** The modules snapshot `id` holds. */
467
+ modulesOf(id) {
468
+ return Object.keys(this.manifest(id)?.modules ?? {}).sort();
469
+ }
470
+ /** What snapshot `id` recorded of module `module` that a text compiled into it must agree with:
471
+ * its project imports, and its declarations' names, kinds, ranges and docs; null where it has
472
+ * no such module. */
473
+ moduleShape(id, module) {
474
+ const h = this.manifest(id)?.modules[module];
475
+ if (!h)
476
+ return null;
477
+ const r = this.readGz(join(this.modDir, `${h}.json.gz`));
478
+ return { imports: r.imports, nodes: r.nodes };
479
+ }
480
+ /** The extractions snapshot `id` was read from, the first first. */
481
+ readsOf(id) {
482
+ return [...(this.reads.get(id) ?? [])];
483
+ }
484
+ save(info) {
485
+ this.infos.set(info.id, info);
486
+ appendFileSync(this.indexFile, JSON.stringify(info) + "\n");
487
+ }
488
+ readGz(file) {
489
+ return JSON.parse(gunzipSync(readFileSync(file)).toString("utf8"));
490
+ }
491
+ /** A snapshot's graph; null where there is no such snapshot. */
492
+ graph(id) {
493
+ const info = this.infos.get(id);
494
+ if (!info)
495
+ return null;
496
+ const hit = this.cache.get(id);
497
+ if (hit) {
498
+ // most recently used last: the cache keeps a few
499
+ this.cache.delete(id);
500
+ this.cache.set(id, hit);
501
+ return hit;
502
+ }
503
+ const m = this.readGz(join(this.snapDir, `${id}.json.gz`));
504
+ const recs = Object.values(m.modules).map((h) => this.readGz(join(this.modDir, `${h}.json.gz`)));
505
+ const { externals } = this.readGz(join(this.modDir, `${m.externals}.json.gz`));
506
+ const nodes = [];
507
+ const starts = [];
508
+ for (const r of recs) {
509
+ starts.push(nodes.length);
510
+ nodes.push(...r.nodes);
511
+ }
512
+ const projectCount = nodes.length;
513
+ for (const e of externals)
514
+ nodes.push({ name: e.name, kind: e.kind, module: e.module, type: "", ax: e.ax, hash: "", ext: true });
515
+ const index = new Map(nodes.map((v, i) => [v.name, i]));
516
+ const edges = [];
517
+ recs.forEach((r, k) => {
518
+ const to = r.targets.map((t) => index.get(t));
519
+ for (const row of r.out)
520
+ for (let j = 1; j + 1 < row.length; j += 2) {
521
+ const d = to[row[j]];
522
+ if (d !== undefined)
523
+ edges.push([starts[k] + row[0], d, row[j + 1]]);
524
+ }
525
+ });
526
+ const g = { info, modules: recs.map((r) => ({ name: r.module, imports: r.imports })), nodes, projectCount, edges };
527
+ this.cache.set(id, g);
528
+ while (this.cache.size > 4)
529
+ this.cache.delete(this.cache.keys().next().value);
530
+ return g;
531
+ }
532
+ /** The version, under `identity`, of the content declaration `name` had where its kernel's hash
533
+ * was `kernel`, in some snapshot whose units have their versions' parts (server/versions.ts,
534
+ * `rebinder`); null where none has it. */
535
+ versionOfKernel(name, kernel, identity) {
536
+ const key = `${name}\0${kernel}`;
537
+ let v = this.found.get(key);
538
+ if (v === undefined) {
539
+ v = null;
540
+ for (const h of this.blobsOf(name)) {
541
+ const r = this.readGz(join(this.modDir, `${h}.json.gz`));
542
+ const x = r.nodes.find((n) => n.name === name && n.hash === kernel && n.shape);
543
+ if (x) {
544
+ v = x;
545
+ break;
546
+ }
547
+ }
548
+ this.found.set(key, v);
549
+ }
550
+ return v ? snapVersion(v, identity) : null;
551
+ }
552
+ /** The newest build whose snapshot has a declaration named `name`, and the module it was in there
553
+ * (the lost-and-found's Show in history): found from the module files that hold the name
554
+ * (`blobsOf`) and the manifests that list them, without reading a graph. Null where no snapshot
555
+ * has it: it went before the history began, or was never a declaration. */
556
+ lastHad(name) {
557
+ const blobs = this.blobsOf(name);
558
+ if (!blobs.length)
559
+ return null;
560
+ if (!this.holders) {
561
+ // each snapshot's manifest as it is now: a build read again lists the files of its last read
562
+ this.holders = new Map();
563
+ for (const s of this.infos.values())
564
+ for (const [module, h] of Object.entries(this.manifest(s.id)?.modules ?? {})) {
565
+ const at = this.holders.get(h);
566
+ if (at)
567
+ at.push({ id: s.id, module });
568
+ else
569
+ this.holders.set(h, [{ id: s.id, module }]);
570
+ }
571
+ }
572
+ let best = null;
573
+ for (const h of blobs)
574
+ for (const { id, module } of this.holders.get(h) ?? []) {
575
+ const info = this.infos.get(id);
576
+ if (!best || info.at > best.info.at || (info.at === best.info.at && info.id > best.info.id))
577
+ best = { info, module };
578
+ }
579
+ return best ? { id: best.info.id, module: best.module } : null;
580
+ }
581
+ /** Whether snapshot `id`'s build had a declaration named `name`: as `lastHad` finds it, without
582
+ * reading a graph. */
583
+ hadAt(name, id) {
584
+ this.lastHad(name);
585
+ return this.blobsOf(name).some((h) => this.holders?.get(h)?.some((x) => x.id === id));
586
+ }
587
+ /** which snapshots list each module file, and as which module: made when first asked, again
588
+ * after a snapshot is written */
589
+ holders = null;
590
+ /** what `versionOfKernel` found, by name and kernel's hash (null: nothing, until the next snapshot) */
591
+ found = new Map();
592
+ /** which module files hold a declaration of each name: read once, when first asked */
593
+ names = null;
594
+ blobsOf(name) {
595
+ if (!this.names) {
596
+ this.names = new Map();
597
+ for (const s of this.infos.values())
598
+ this.index(this.manifest(s.id));
599
+ }
600
+ return this.names.get(name) ?? [];
601
+ }
602
+ manifest(id) {
603
+ try {
604
+ return this.readGz(join(this.snapDir, `${id}.json.gz`));
605
+ }
606
+ catch {
607
+ return null;
608
+ }
609
+ }
610
+ indexed = new Set();
611
+ index(m) {
612
+ if (!m || !this.names)
613
+ return;
614
+ for (const h of Object.values(m.modules)) {
615
+ if (this.indexed.has(h))
616
+ continue;
617
+ this.indexed.add(h);
618
+ for (const n of this.readGz(join(this.modDir, `${h}.json.gz`)).nodes) {
619
+ const at = this.names.get(n.name);
620
+ if (at)
621
+ at.push(h);
622
+ else
623
+ this.names.set(n.name, [h]);
624
+ }
625
+ }
626
+ }
627
+ /** Make the first snapshots from the extractions the project's cache holds from before the history
628
+ * (`<cache>/extract/`): one per build. The extractor's graph and the usage pass's merged one of
629
+ * a build hold the same declarations and were made within moments: the merged one is kept. A
630
+ * build's time is when its extraction was made, unless no `.olean` changed since — then the
631
+ * build is the one there now, and its time is known. How many snapshots were made. */
632
+ importExtractions(dir, p, log) {
633
+ let files = [];
634
+ try {
635
+ files = readdirSync(dir)
636
+ .filter((f) => /^[0-9a-f]{16}\.json$/.test(f))
637
+ .map((f) => ({ f, t: statSync(join(dir, f)).mtimeMs }))
638
+ .sort((a, b) => a.t - b.t);
639
+ }
640
+ catch {
641
+ return 0;
642
+ }
643
+ const built = buildTime(p);
644
+ // one entry per build: consecutive extractions of the same declarations within two minutes
645
+ const builds = [];
646
+ for (const { f, t } of files) {
647
+ let g;
648
+ try {
649
+ g = JSON.parse(readFileSync(join(dir, f), "utf8"));
650
+ }
651
+ catch {
652
+ continue;
653
+ }
654
+ const key = sha(g.nodes.map((n) => n.name).sort().join("\n"));
655
+ const last = builds[builds.length - 1];
656
+ if (last && last.key === key && t - last.t < 120_000) {
657
+ if (g.usage && !last.g.usage)
658
+ builds[builds.length - 1] = { t: last.t, f, g, key };
659
+ continue;
660
+ }
661
+ builds.push({ t, f, g, key });
662
+ }
663
+ let made = 0;
664
+ let before = new Set();
665
+ for (const b of builds) {
666
+ // no build since this extraction: it read the build there now
667
+ const exact = built > 0 && built <= b.t;
668
+ const at = exact ? built : b.t;
669
+ const iso = new Date(b.t).toISOString();
670
+ // a module the build before had, which is built now, and which this one lacks, could not be
671
+ // read then (a file failed, or was being written): its declarations did not go
672
+ const have = new Set(b.g.modules.map((m) => m.name));
673
+ const missing = [...before].filter((m) => !have.has(m) && existsSync(join(p.libDir, ...m.split(".")) + ".olean"));
674
+ before = new Set([...have, ...missing]);
675
+ const r = this.record(b.g, {
676
+ at,
677
+ fingerprint: b.f.slice(0, 16),
678
+ commit: commitBefore(p.root, iso),
679
+ toolchain: p.toolchain,
680
+ missing,
681
+ imported: true,
682
+ approx: !exact,
683
+ read: iso,
684
+ });
685
+ if (!r.written) {
686
+ log(`history: ${b.f} read the build of snapshot ${r.info.id} again, with fewer modules: left as it is`);
687
+ continue;
688
+ }
689
+ log(`history: imported ${b.f} as snapshot ${r.info.id} (${r.info.counts.nodes} declarations, ${(r.info.bytes.all / 1024).toFixed(0)} KB)`);
690
+ made++;
691
+ }
692
+ return made;
693
+ }
694
+ }
695
+ /** What changed from one snapshot's graph to another's (the Time Machine's summary against now):
696
+ * the project's declarations added, removed, renamed (`renamed`: a name of `a` → its name in `b`,
697
+ * the renames recorded between them) and changed — a new version, under `identity`, of the same
698
+ * declaration. Two contents are compared by their versions where both have their parts, else by
699
+ * the kernel's hashes; what `a`'s refers to is read under its names in `b` first, so that a rename
700
+ * of it is no change, also one the renames in force do not hold (a rename that touched nothing
701
+ * said is recorded in the history alone, HistorySpec decision 10). What a module the later build
702
+ * could not read (`missing`) held is neither removed nor added: it is not known there. Each list
703
+ * sorted, at most `most` names; the counts are whole. */
704
+ export function diffGraphs(a, b, identity, renamed = new Map(), most = 2000) {
705
+ const inB = new Map();
706
+ for (let i = 0; i < b.projectCount; i++)
707
+ inB.set(b.nodes[i].name, b.nodes[i]);
708
+ const unknownB = new Set(b.info.missing);
709
+ const unknownA = new Set(a.info.missing);
710
+ const added = [], removed = [], changed = [], moves = [];
711
+ const reached = new Set();
712
+ const before = renamed.size ? (u) => identity(renamed.get(u) ?? u) : identity;
713
+ const same = (x, y) => (x.shape && y.shape ? snapVersion(x, before) === snapVersion(y, identity) : x.hash === y.hash);
714
+ for (let i = 0; i < a.projectCount; i++) {
715
+ const x = a.nodes[i];
716
+ const to = renamed.get(x.name) ?? x.name;
717
+ const y = inB.get(to);
718
+ if (!y) {
719
+ if (!unknownB.has(x.module))
720
+ removed.push(x.name);
721
+ continue;
722
+ }
723
+ reached.add(to);
724
+ if (to !== x.name)
725
+ moves.push([x.name, to]);
726
+ if (!same(x, y))
727
+ changed.push(to);
728
+ }
729
+ for (const [name, y] of inB)
730
+ if (!reached.has(name) && !unknownA.has(y.module))
731
+ added.push(name);
732
+ const cut = (xs) => xs.slice(0, most);
733
+ const byName = (x, y) => (x < y ? -1 : x > y ? 1 : 0);
734
+ return {
735
+ counts: { added: added.length, removed: removed.length, changed: changed.length, renamed: moves.length },
736
+ added: cut(added.sort(byName)),
737
+ removed: cut(removed.sort(byName)),
738
+ changed: cut(changed.sort(byName)),
739
+ renamed: cut(moves.sort((p, q) => byName(p[0], q[0]))),
740
+ };
741
+ }
742
+ /** A snapshot's graph as a page reads it: the shape of the graph on show (ViewMeta) without its
743
+ * layouts — modules, nodes (each project node's `hash` its version under `identity`, `kernel` the
744
+ * kernel's hash it was read with), axioms and their sets — and its edges as one flat list of
745
+ * [src, dst, sites] triples. */
746
+ export function pageGraph(g, identity) {
747
+ const axioms = [];
748
+ const axIdx = new Map();
749
+ const sets = [];
750
+ const setIdx = new Map();
751
+ const axOf = (names) => {
752
+ const ids = names.map((a) => axIdx.get(a) ?? (axIdx.set(a, axioms.length), axioms.push(a) - 1)).sort((x, y) => x - y);
753
+ const key = ids.join(",");
754
+ let k = setIdx.get(key);
755
+ if (k === undefined)
756
+ setIdx.set(key, (k = sets.push(ids) - 1));
757
+ return k;
758
+ };
759
+ const nodes = g.nodes.map((v) => {
760
+ if (v.ext)
761
+ return { name: v.name, kind: v.kind, module: v.module, ax: axOf(v.ax), ext: true };
762
+ const { ax, hash, shape: _s, refs: _r, ...rest } = v;
763
+ return { ...rest, ax: axOf(ax), hash: snapVersion(v, identity), ...(v.shape ? { kernel: hash } : {}) };
764
+ });
765
+ return { snapshot: g.info, modules: g.modules, nodes, projectCount: g.projectCount, axioms, axiomSets: sets, edges: g.edges.flat() };
766
+ }
767
+ /** The texts a build read again was found to have now (`map`), and what was found when it was read
768
+ * before (`was`): where a module's file was edited since, the text found before is still the
769
+ * build's, its Lake hash being the same; the rest is as found now. */
770
+ function carried(map, was) {
771
+ if (!was)
772
+ return map;
773
+ const out = { ...map };
774
+ for (const [m, r] of Object.entries(map)) {
775
+ const w = Object.hasOwn(was, m) ? was[m] : null;
776
+ if (!r.blob && w?.blob && w.lake && w.lake === r.lake)
777
+ out[m] = w;
778
+ }
779
+ return out;
780
+ }