visualproof 0.0.0-stage → 0.1.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.
- package/LICENSE +202 -0
- package/README.md +99 -2
- package/dist/assets/fonts/JuliaMono-Regular.ttf +0 -0
- package/dist/assets/fonts/JuliaMono-RegularItalic.ttf +0 -0
- package/dist/assets/fonts/JuliaMono-SemiBold.ttf +0 -0
- package/dist/assets/fonts/LICENSE-JuliaMono.txt +93 -0
- package/dist/assets/latex/visualproof.sty +308 -0
- package/dist/extractor/Extract.lean +1180 -0
- package/dist/extractor/Usage.lean +900 -0
- package/dist/mcp/visualproof-mcp.mjs +476 -0
- package/dist/server/access.js +95 -0
- package/dist/server/agent.js +3235 -0
- package/dist/server/agentdocs.js +35 -0
- package/dist/server/agentlibrary.js +202 -0
- package/dist/server/agentnotes.js +701 -0
- package/dist/server/agentviews.js +703 -0
- package/dist/server/askfollow.js +286 -0
- package/dist/server/boardspots.js +159 -0
- package/dist/server/dirs.js +175 -0
- package/dist/server/doctor.js +186 -0
- package/dist/server/events.js +198 -0
- package/dist/server/export.js +420 -0
- package/dist/server/extract.js +204 -0
- package/dist/server/fetchurl.js +321 -0
- package/dist/server/figures.js +158 -0
- package/dist/server/history.js +741 -0
- package/dist/server/historyapi.js +304 -0
- package/dist/server/hover.js +595 -0
- package/dist/server/identity.js +114 -0
- package/dist/server/invites.js +241 -0
- package/dist/server/layoutthread.js +51 -0
- package/dist/server/library.js +600 -0
- package/dist/server/libraryroute.js +211 -0
- package/dist/server/live.js +1101 -0
- package/dist/server/lostfound.js +199 -0
- package/dist/server/main.js +1394 -0
- package/dist/server/mcptools.js +497 -0
- package/dist/server/pastdoc.js +73 -0
- package/dist/server/pipeline.js +156 -0
- package/dist/server/poppler.js +251 -0
- package/dist/server/presence.js +252 -0
- package/dist/server/project.js +222 -0
- package/dist/server/prompt.js +53 -0
- package/dist/server/readonly.js +152 -0
- package/dist/server/readonlygate.js +123 -0
- package/dist/server/renames.js +268 -0
- package/dist/server/review.js +334 -0
- package/dist/server/reviewapi.js +188 -0
- package/dist/server/snapshot.js +49 -0
- package/dist/server/sourcestore.js +473 -0
- package/dist/server/status.js +360 -0
- package/dist/server/talk.js +476 -0
- package/dist/server/tokens.js +221 -0
- package/dist/server/usage.js +501 -0
- package/dist/server/versions.js +82 -0
- package/dist/server/view.js +157 -0
- package/dist/server/views.js +1167 -0
- package/dist/shared/askfollow.js +46 -0
- package/dist/shared/board.js +332 -0
- package/dist/shared/build.js +2 -0
- package/dist/shared/collab.js +148 -0
- package/dist/shared/comment.js +285 -0
- package/dist/shared/context.js +157 -0
- package/dist/shared/doc/authors.js +98 -0
- package/dist/shared/doc/build.js +358 -0
- package/dist/shared/doc/latex.js +455 -0
- package/dist/shared/doc/markdown.js +159 -0
- package/dist/shared/doc/model.js +37 -0
- package/dist/shared/doc/order.js +107 -0
- package/dist/shared/doc/proof.js +272 -0
- package/dist/shared/doc/sections.js +93 -0
- package/dist/shared/doc/settings.js +75 -0
- package/dist/shared/edgecodec.js +102 -0
- package/dist/shared/editor.js +64 -0
- package/dist/shared/excerpt.js +87 -0
- package/dist/shared/graph.js +16 -0
- package/dist/shared/items.js +92 -0
- package/dist/shared/keys.js +203 -0
- package/dist/shared/layout.js +926 -0
- package/dist/shared/lexer.js +130 -0
- package/dist/shared/library.js +711 -0
- package/dist/shared/lostfound.js +150 -0
- package/dist/shared/moves.js +243 -0
- package/dist/shared/notecard.js +295 -0
- package/dist/shared/noteplace.js +505 -0
- package/dist/shared/palette.js +46 -0
- package/dist/shared/pastgraph.js +95 -0
- package/dist/shared/places.js +80 -0
- package/dist/shared/projectnotes.js +244 -0
- package/dist/shared/query.js +810 -0
- package/dist/shared/review.js +642 -0
- package/dist/shared/reviewqueue.js +333 -0
- package/dist/shared/ring.js +366 -0
- package/dist/shared/scope.js +255 -0
- package/dist/shared/scopeedit.js +81 -0
- package/dist/shared/search.js +191 -0
- package/dist/shared/separate.js +178 -0
- package/dist/shared/sharesnap.js +280 -0
- package/dist/shared/sticky.js +501 -0
- package/dist/shared/timeline.js +73 -0
- package/dist/shared/titles.js +298 -0
- package/dist/shared/viewgraph.js +139 -0
- package/dist/shared/views.js +859 -0
- package/dist/web/bundle/app.js +48733 -0
- package/dist/web/bundle/edges.js +409 -0
- package/dist/web/bundle/layout.js +1010 -0
- package/dist/web/bundle/pdfworker.js +48322 -0
- package/dist/web/index.html +239 -0
- package/dist/web/src/motion.js +430 -0
- package/dist/web/src/past.js +224 -0
- package/dist/web/src/scene.js +787 -0
- package/dist/web/style.css +2312 -0
- package/docs/agent-api/README.md +91 -0
- package/docs/agent-api/advice.md +108 -0
- package/docs/agent-api/export.md +120 -0
- package/docs/agent-api/following.md +140 -0
- package/docs/agent-api/history.md +142 -0
- package/docs/agent-api/library.md +65 -0
- package/docs/agent-api/mcp.md +104 -0
- package/docs/agent-api/notes.md +93 -0
- package/docs/agent-api/reading.md +166 -0
- package/docs/agent-api/requests.md +94 -0
- package/docs/agent-api/talking.md +100 -0
- package/docs/agent-api/tcb.md +135 -0
- package/docs/agent-api/views.md +124 -0
- package/docs/agent-api/words.md +56 -0
- package/package.json +39 -4
- package/test/toolchains.json +5 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
// The source text of every build the history keeps (HistorySpec decisions 20–23, the owner's of
|
|
2
|
+
// 2026-10-10), borrowing git's content-addressed design: each text of a project file is a blob named
|
|
3
|
+
// by its SHA-256, `history/sources/<sha256>.gz`, written once whatever number of builds compiled it,
|
|
4
|
+
// and a snapshot's manifest says, per module, which blob its build was compiled from. A build so
|
|
5
|
+
// adds only the files that changed.
|
|
6
|
+
//
|
|
7
|
+
// Only what was built is kept. Lake's trace records the hash of the text each module was compiled
|
|
8
|
+
// from (server/status.ts, `compiledTextHash`); a text is kept for a module only where its Lake hash
|
|
9
|
+
// is that one: the file as it is when the build is read, else the file in the commit checked out
|
|
10
|
+
// then. A file edited after the build matches neither, and its text is not kept: a text that is not
|
|
11
|
+
// the build's is never shown as the build's.
|
|
12
|
+
//
|
|
13
|
+
// The snapshots made before the history kept sources are backfilled once, in the background, at
|
|
14
|
+
// start (`backfillSources`). Their builds' hashes are known where the project's cache still holds what
|
|
15
|
+
// says them — the view of an extraction of that build (`view/<fingerprint>-v*.meta.json.gz`,
|
|
16
|
+
// `modules[].compiled`), or the present trace of a module not compiled since — and a text is kept
|
|
17
|
+
// there only where its Lake hash is that one. Elsewhere the hash is gone, and the best check left is
|
|
18
|
+
// the places the snapshot recorded: a text is kept where it is the one candidate that agrees with
|
|
19
|
+
// every one of them (`misfit`) — its project imports, where each declaration starts and ends, its
|
|
20
|
+
// doc comment and the name it declares — and the manifest says so (`check: "places"`), so that the
|
|
21
|
+
// page and the agents can say how the text was matched to its build. Where several agree, the
|
|
22
|
+
// commit of a build read clean is taken, and of a build read with edits none is, one of them not
|
|
23
|
+
// being the build's. Candidates are read with git,
|
|
24
|
+
// read-only: the build's commit, the commits made on top of it, the working tree and the blobs kept
|
|
25
|
+
// already. Nothing is ever written into the project or its `.git`.
|
|
26
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
27
|
+
import { createHash } from "node:crypto";
|
|
28
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
import { gunzipSync, gzipSync } from "node:zlib";
|
|
31
|
+
import { compiledTextHash, lakeTextHash, parseImports } from "./status.js";
|
|
32
|
+
const sha256 = (text) => createHash("sha256").update(text, "utf8").digest("hex");
|
|
33
|
+
/** The blobs: each text once, gzipped, named by its SHA-256. */
|
|
34
|
+
export class SourceStore {
|
|
35
|
+
dir;
|
|
36
|
+
/** blobs known to be on disk, and their gzipped sizes */
|
|
37
|
+
sizes = new Map();
|
|
38
|
+
constructor(historyDir) {
|
|
39
|
+
this.dir = join(historyDir, "sources");
|
|
40
|
+
}
|
|
41
|
+
/** Keep `text`: its blob, its gzipped size, and whether it was written now (no build had it). */
|
|
42
|
+
put(text) {
|
|
43
|
+
const blob = sha256(text);
|
|
44
|
+
let size = this.sizes.get(blob);
|
|
45
|
+
if (size !== undefined)
|
|
46
|
+
return { blob, size, fresh: false };
|
|
47
|
+
const f = join(this.dir, `${blob}.gz`);
|
|
48
|
+
if (existsSync(f)) {
|
|
49
|
+
size = statSync(f).size;
|
|
50
|
+
this.sizes.set(blob, size);
|
|
51
|
+
return { blob, size, fresh: false };
|
|
52
|
+
}
|
|
53
|
+
mkdirSync(this.dir, { recursive: true });
|
|
54
|
+
const gz = gzipSync(text);
|
|
55
|
+
writeFileSync(`${f}.tmp`, gz);
|
|
56
|
+
renameSync(`${f}.tmp`, f);
|
|
57
|
+
this.sizes.set(blob, gz.length);
|
|
58
|
+
return { blob, size: gz.length, fresh: true };
|
|
59
|
+
}
|
|
60
|
+
/** A blob's gzipped size; 0 where it is not there. */
|
|
61
|
+
size(blob) {
|
|
62
|
+
let size = this.sizes.get(blob);
|
|
63
|
+
if (size === undefined) {
|
|
64
|
+
try {
|
|
65
|
+
size = statSync(join(this.dir, `${blob}.gz`)).size;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
this.sizes.set(blob, size);
|
|
71
|
+
}
|
|
72
|
+
return size;
|
|
73
|
+
}
|
|
74
|
+
/** A blob's text; null where it is not there, or is not what its name says. */
|
|
75
|
+
text(blob) {
|
|
76
|
+
if (!/^[0-9a-f]{64}$/.test(blob))
|
|
77
|
+
return null;
|
|
78
|
+
try {
|
|
79
|
+
const text = gunzipSync(readFileSync(join(this.dir, `${blob}.gz`))).toString("utf8");
|
|
80
|
+
return sha256(text) === blob ? text : null;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** The bytes the blobs hold on disk. */
|
|
87
|
+
bytes() {
|
|
88
|
+
let total = 0;
|
|
89
|
+
try {
|
|
90
|
+
for (const f of readdirSync(this.dir))
|
|
91
|
+
total += statSync(join(this.dir, f)).size;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// nothing there yet
|
|
95
|
+
}
|
|
96
|
+
return total;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** a doc comment's words, however they are laid out */
|
|
100
|
+
const words = (s) => s.replace(/\s+/g, " ").trim();
|
|
101
|
+
/** Where `text` disagrees with what the snapshot recorded of a module compiled from it (`shape`);
|
|
102
|
+
* null where it agrees with every place. Its project imports (`isModule` tells a project module)
|
|
103
|
+
* are the module's; each declaration's range lies in it, starting on a token and ending on one; a
|
|
104
|
+
* range that opens with a doc comment holds the declaration's doc; and where its command writes a
|
|
105
|
+
* name, it is the declaration's. A text that agrees may still differ inside a declaration, where no
|
|
106
|
+
* place moves: that is why a Lake hash, where known, is the check. */
|
|
107
|
+
export function misfit(text, shape, isModule) {
|
|
108
|
+
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
|
109
|
+
const imports = new Set(parseImports(text).filter(isModule));
|
|
110
|
+
if (imports.size !== new Set(shape.imports).size || shape.imports.some((m) => !imports.has(m)))
|
|
111
|
+
return "its imports differ";
|
|
112
|
+
const cps = new Map();
|
|
113
|
+
const chars = (l) => {
|
|
114
|
+
let c = cps.get(l);
|
|
115
|
+
if (!c)
|
|
116
|
+
cps.set(l, (c = [...lines[l - 1]]));
|
|
117
|
+
return c;
|
|
118
|
+
};
|
|
119
|
+
for (const n of shape.nodes) {
|
|
120
|
+
const r = n.range;
|
|
121
|
+
if (!r)
|
|
122
|
+
continue;
|
|
123
|
+
const [l0, c0, l1, c1] = r;
|
|
124
|
+
if (l0 < 1 || l1 < l0 || l1 > lines.length)
|
|
125
|
+
return `${n.name} lies past its end`;
|
|
126
|
+
// Lean's columns count code points, and a range is its first token to its last
|
|
127
|
+
const first = chars(l0);
|
|
128
|
+
const last = chars(l1);
|
|
129
|
+
if (c0 >= first.length || /\s/.test(first[c0]) || (c0 > 0 && !/\s/.test(first[c0 - 1])))
|
|
130
|
+
return `${n.name} does not start where it did`;
|
|
131
|
+
if (c1 < 1 || c1 > last.length || /\s/.test(last[c1 - 1]))
|
|
132
|
+
return `${n.name} does not end where it did`;
|
|
133
|
+
const opening = first.slice(c0).join("");
|
|
134
|
+
if (n.doc !== undefined && opening.startsWith("/--")) {
|
|
135
|
+
const body = lines.slice(l0 - 1, l1).join("\n").slice(lines[l0 - 1].length - opening.length);
|
|
136
|
+
const end = body.indexOf("-/");
|
|
137
|
+
if (end < 0 || words(body.slice(3, end)) !== words(n.doc))
|
|
138
|
+
return `${n.name}'s doc comment differs`;
|
|
139
|
+
}
|
|
140
|
+
// where its command writes a name, it is the declaration's: whole, or its last parts, written
|
|
141
|
+
// inside the namespace
|
|
142
|
+
if (n.gen === undefined && n.kind !== "instance" && n.kind !== "notation") {
|
|
143
|
+
const declared = lines.slice(l0 - 1, l1).flatMap((l) => {
|
|
144
|
+
const m = DECLARES.exec(l);
|
|
145
|
+
return m ? [m[1].replace(/^_root_\./, "").replace(/\.\{.*$/, "").replace(/[«»]/g, "")] : [];
|
|
146
|
+
});
|
|
147
|
+
const own = n.name.replace(/[«»]/g, "");
|
|
148
|
+
if (declared.length && !declared.some((d) => d === own || own.endsWith(`.${d}`)))
|
|
149
|
+
return `${n.name} is not declared where it was`;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
/** a line that declares something by name, as shared/excerpt.ts reads one, and the name */
|
|
155
|
+
const DECLARES = /^\s*(?:@\[[^\]]*\]\s*)*(?:(?:private|protected|noncomputable|partial|unsafe|nonrec|scoped|local)\s+)*(?:class\s+inductive|theorem|lemma|def|abbrev|structure|class|inductive|axiom|opaque|irreducible_def)\s+([^\s:({[]\S*)/;
|
|
156
|
+
// ------------------------------------------------------------------------------------------- git
|
|
157
|
+
/** Git, read-only and taking no lock, run from the project's folder: its output, or null. */
|
|
158
|
+
function gitSync(root, args) {
|
|
159
|
+
const r = spawnSync("git", ["-C", root, ...args], { encoding: "utf8", timeout: 10_000, maxBuffer: 256 << 20, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, stdio: ["ignore", "pipe", "ignore"] });
|
|
160
|
+
return r.status === 0 ? r.stdout : null;
|
|
161
|
+
}
|
|
162
|
+
/** The same, off the event loop, with `input` on its standard input: its output as bytes, or null. */
|
|
163
|
+
function git(root, args, input) {
|
|
164
|
+
return new Promise((ok) => {
|
|
165
|
+
const p = spawn("git", ["-C", root, ...args], { env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, stdio: ["pipe", "pipe", "ignore"] });
|
|
166
|
+
const out = [];
|
|
167
|
+
p.stdout.on("data", (b) => out.push(b));
|
|
168
|
+
p.on("error", () => ok(null));
|
|
169
|
+
p.on("close", (code) => ok(code === 0 ? Buffer.concat(out) : null));
|
|
170
|
+
p.stdin.on("error", () => { });
|
|
171
|
+
p.stdin.end(input ?? "");
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
/** A file as commit `commit` has it (`git show <commit>:./<file>`, relative to the project); null
|
|
175
|
+
* where it has none. */
|
|
176
|
+
export function fileAt(root, commit, file) {
|
|
177
|
+
if (!/^[0-9a-f]{7,64}$/.test(commit))
|
|
178
|
+
return null;
|
|
179
|
+
return gitSync(root, ["show", `${commit}:./${file}`]);
|
|
180
|
+
}
|
|
181
|
+
// --------------------------------------------------------------------------- at a build's reading
|
|
182
|
+
/** The texts a build being read was compiled from (HistorySpec decision 21), kept in `store`: per
|
|
183
|
+
* module of the graph that has a file, the Lake hash its trace records and, where the file as it is
|
|
184
|
+
* now has that hash — else the file in `commit`, the commit checked out now — that text; and the
|
|
185
|
+
* gzipped bytes of the blobs written for them now. */
|
|
186
|
+
export function keepSources(p, modules, commit, store) {
|
|
187
|
+
const out = {};
|
|
188
|
+
let written = 0;
|
|
189
|
+
const put = (text) => {
|
|
190
|
+
const r = store.put(text);
|
|
191
|
+
if (r.fresh)
|
|
192
|
+
written += r.size;
|
|
193
|
+
return r.blob;
|
|
194
|
+
};
|
|
195
|
+
for (const m of modules) {
|
|
196
|
+
const file = p.files.get(m);
|
|
197
|
+
if (!file)
|
|
198
|
+
continue;
|
|
199
|
+
const lake = compiledTextHash(p, m, file);
|
|
200
|
+
if (!lake) {
|
|
201
|
+
out[m] = { file, why: "its build left no hash of the text it was compiled from" };
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
let text = null;
|
|
205
|
+
try {
|
|
206
|
+
text = readFileSync(join(p.root, file), "utf8");
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// gone since the build: the commit may have it
|
|
210
|
+
}
|
|
211
|
+
if (text !== null && lakeTextHash(text) === lake) {
|
|
212
|
+
out[m] = { file, lake, blob: put(text), check: "lake", from: "tree" };
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const then = commit ? fileAt(p.root, commit, file) : null;
|
|
216
|
+
if (then !== null && lakeTextHash(then) === lake) {
|
|
217
|
+
out[m] = { file, lake, blob: put(then), check: "lake", from: `git:${commit}` };
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
out[m] = { file, lake, why: "its file was edited after the build, and the commit does not have the text it was compiled from" };
|
|
221
|
+
}
|
|
222
|
+
return { map: out, written };
|
|
223
|
+
}
|
|
224
|
+
/** What a manifest's texts come to: kept and of how many, matched by places, and their bytes. */
|
|
225
|
+
export function summarize(map, store, written) {
|
|
226
|
+
let kept = 0, places = 0, all = 0;
|
|
227
|
+
const seen = new Set();
|
|
228
|
+
for (const r of Object.values(map)) {
|
|
229
|
+
if (!r.blob)
|
|
230
|
+
continue;
|
|
231
|
+
kept++;
|
|
232
|
+
if (r.check === "places")
|
|
233
|
+
places++;
|
|
234
|
+
if (!seen.has(r.blob)) {
|
|
235
|
+
seen.add(r.blob);
|
|
236
|
+
all += store.size(r.blob);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return { kept, of: Object.keys(map).length, places, bytes: { new: written, all } };
|
|
240
|
+
}
|
|
241
|
+
/** The Lake hashes of the texts a snapshot's build compiled, from what the project's cache still holds:
|
|
242
|
+
* the view of any extraction it was read from, and the present trace of every module whose `.olean`
|
|
243
|
+
* is not newer than the build (not compiled since, so its trace is the build's). */
|
|
244
|
+
function knownHashes(cacheDir, p, info, reads, modules) {
|
|
245
|
+
const hashes = new Map();
|
|
246
|
+
let view = 0, trace = 0;
|
|
247
|
+
const viewDir = join(cacheDir, "view");
|
|
248
|
+
let files = [];
|
|
249
|
+
try {
|
|
250
|
+
files = readdirSync(viewDir);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// no views kept
|
|
254
|
+
}
|
|
255
|
+
for (const fp of reads) {
|
|
256
|
+
for (const f of files.filter((x) => x.startsWith(`${fp}-v`) && x.endsWith(".meta.json.gz"))) {
|
|
257
|
+
try {
|
|
258
|
+
const meta = JSON.parse(gunzipSync(readFileSync(join(viewDir, f))).toString("utf8"));
|
|
259
|
+
for (const m of meta.modules)
|
|
260
|
+
if (m.compiled && !hashes.has(m.name)) {
|
|
261
|
+
hashes.set(m.name, m.compiled);
|
|
262
|
+
view++;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
// a view being written, or of another shape: the traces may say
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const at = Date.parse(info.at);
|
|
271
|
+
for (const m of modules) {
|
|
272
|
+
if (hashes.has(m))
|
|
273
|
+
continue;
|
|
274
|
+
const file = p.files.get(m);
|
|
275
|
+
if (!file)
|
|
276
|
+
continue;
|
|
277
|
+
try {
|
|
278
|
+
if (statSync(join(p.libDir, ...m.split(".")) + ".olean").mtimeMs > at + 1)
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const h = compiledTextHash(p, m, file);
|
|
285
|
+
if (h) {
|
|
286
|
+
hashes.set(m, h);
|
|
287
|
+
trace++;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return { hashes, view, trace };
|
|
291
|
+
}
|
|
292
|
+
/** The commit a snapshot was read at, and the commits made on top of it, nearest first, at most
|
|
293
|
+
* `most`: where a build's edits were committed afterwards. */
|
|
294
|
+
async function commitsFrom(root, commit, most = 64) {
|
|
295
|
+
const out = await git(root, ["log", "--format=%H", "--ancestry-path", "--reverse", "--all", `^${commit}`]);
|
|
296
|
+
const later = out ? out.toString("utf8").split("\n").filter((h) => /^[0-9a-f]{40,64}$/.test(h)) : [];
|
|
297
|
+
return [commit, ...later.slice(0, most)];
|
|
298
|
+
}
|
|
299
|
+
/** Each `.lean` file's blob in each of `commits`, by path relative to the project. */
|
|
300
|
+
async function treesOf(root, commits) {
|
|
301
|
+
const out = new Map();
|
|
302
|
+
for (const c of commits) {
|
|
303
|
+
const ls = await git(root, ["ls-tree", "-r", c, "--", "."]);
|
|
304
|
+
const files = new Map();
|
|
305
|
+
for (const line of ls ? ls.toString("utf8").split("\n") : []) {
|
|
306
|
+
const m = /^\d+ blob ([0-9a-f]+)\t(.+\.lean)$/.exec(line);
|
|
307
|
+
if (m)
|
|
308
|
+
files.set(m[2], m[1]);
|
|
309
|
+
}
|
|
310
|
+
out.set(c, files);
|
|
311
|
+
}
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
/** Git's blobs, read at once (`git cat-file --batch`): by their ids, as text. */
|
|
315
|
+
async function readBlobs(root, ids) {
|
|
316
|
+
const out = new Map();
|
|
317
|
+
for (let k = 0; k < ids.length; k += 500) {
|
|
318
|
+
const part = ids.slice(k, k + 500);
|
|
319
|
+
const buf = await git(root, ["cat-file", "--batch"], part.join("\n") + "\n");
|
|
320
|
+
if (!buf)
|
|
321
|
+
continue;
|
|
322
|
+
let at = 0;
|
|
323
|
+
while (at < buf.length) {
|
|
324
|
+
const nl = buf.indexOf(10, at);
|
|
325
|
+
if (nl < 0)
|
|
326
|
+
break;
|
|
327
|
+
const head = buf.toString("utf8", at, nl).split(" ");
|
|
328
|
+
at = nl + 1;
|
|
329
|
+
if (head[1] !== "blob")
|
|
330
|
+
continue;
|
|
331
|
+
const size = Number(head[2]);
|
|
332
|
+
out.set(head[0], buf.toString("utf8", at, at + size));
|
|
333
|
+
at += size + 1;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return out;
|
|
337
|
+
}
|
|
338
|
+
const pause = () => new Promise((ok) => setImmediate(ok));
|
|
339
|
+
/** Backfill the texts of the snapshots whose manifests keep none (made before the history kept
|
|
340
|
+
* sources), oldest first, once each: a manifest backfilled keeps its texts and is not looked at
|
|
341
|
+
* again. Runs off the event loop's way, git read-only; what it finds is logged, a line per
|
|
342
|
+
* snapshot. */
|
|
343
|
+
export async function backfillSources(h, p, cacheDir, log) {
|
|
344
|
+
const store = h.sources;
|
|
345
|
+
const reports = [];
|
|
346
|
+
const todo = h.list().filter((s) => h.sourcesOf(s.id) === null);
|
|
347
|
+
if (!todo.length)
|
|
348
|
+
return reports;
|
|
349
|
+
log(`history: backfilling the source texts of ${todo.length} snapshot(s) kept before the texts were`);
|
|
350
|
+
const t0 = Date.now();
|
|
351
|
+
// the blobs kept already, by file: candidates for every build
|
|
352
|
+
const keptFor = new Map();
|
|
353
|
+
const remember = (file, blob) => {
|
|
354
|
+
const s = keptFor.get(file);
|
|
355
|
+
if (s)
|
|
356
|
+
s.add(blob);
|
|
357
|
+
else
|
|
358
|
+
keptFor.set(file, new Set([blob]));
|
|
359
|
+
};
|
|
360
|
+
for (const s of h.list())
|
|
361
|
+
for (const r of Object.values(h.sourcesOf(s.id) ?? {}))
|
|
362
|
+
if (r.blob)
|
|
363
|
+
remember(r.file, r.blob);
|
|
364
|
+
const repo = gitSync(p.root, ["rev-parse", "--is-inside-work-tree"])?.trim() === "true";
|
|
365
|
+
// what a text's imports are read against: every module of the project any build had, so that a
|
|
366
|
+
// later text that imports a module this build did not have yet does not agree with it
|
|
367
|
+
const project = new Set(h.list().flatMap((s) => h.modulesOf(s.id)));
|
|
368
|
+
for (const s of todo) {
|
|
369
|
+
const modules = h.modulesOf(s.id);
|
|
370
|
+
const known = knownHashes(cacheDir, p, s, h.readsOf(s.id), modules);
|
|
371
|
+
const commit = s.commit?.hash && repo ? s.commit.hash : null;
|
|
372
|
+
const commits = commit ? await commitsFrom(p.root, commit) : [];
|
|
373
|
+
const trees = commits.length ? await treesOf(p.root, commits) : new Map();
|
|
374
|
+
const ids = new Set();
|
|
375
|
+
for (const m of modules) {
|
|
376
|
+
const file = p.files.get(m);
|
|
377
|
+
if (file)
|
|
378
|
+
for (const t of trees.values())
|
|
379
|
+
if (t.has(file))
|
|
380
|
+
ids.add(t.get(file));
|
|
381
|
+
}
|
|
382
|
+
const blobs = await readBlobs(p.root, [...ids]);
|
|
383
|
+
const map = {};
|
|
384
|
+
const report = { id: s.id, of: 0, lake: 0, places: 0, missing: {}, hashes: { view: known.view, trace: known.trace }, written: 0 };
|
|
385
|
+
const miss = (m, ref, why) => {
|
|
386
|
+
map[m] = { ...ref, why };
|
|
387
|
+
report.missing[why] = (report.missing[why] ?? 0) + 1;
|
|
388
|
+
};
|
|
389
|
+
let n = 0;
|
|
390
|
+
for (const m of modules) {
|
|
391
|
+
// a module no file stands for now (removed since) is read under the path its name gives
|
|
392
|
+
const file = p.files.get(m) ?? `${m.split(".").join("/")}.lean`;
|
|
393
|
+
report.of++;
|
|
394
|
+
if (++n % 50 === 0)
|
|
395
|
+
await pause();
|
|
396
|
+
// the candidates, likeliest first: the commit read, those made on top of it, the working
|
|
397
|
+
// tree, the blobs kept already; each text once
|
|
398
|
+
const cands = [];
|
|
399
|
+
const seen = new Set();
|
|
400
|
+
const add = (text, from) => {
|
|
401
|
+
if (text === null || text === undefined)
|
|
402
|
+
return;
|
|
403
|
+
const k = sha256(text);
|
|
404
|
+
if (seen.has(k))
|
|
405
|
+
return;
|
|
406
|
+
seen.add(k);
|
|
407
|
+
cands.push({ text, from });
|
|
408
|
+
};
|
|
409
|
+
for (const c of commits)
|
|
410
|
+
add(blobs.get(trees.get(c)?.get(file) ?? ""), `git:${c}`);
|
|
411
|
+
try {
|
|
412
|
+
add(readFileSync(join(p.root, file), "utf8"), "tree");
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
// not there now
|
|
416
|
+
}
|
|
417
|
+
for (const b of keptFor.get(file) ?? [])
|
|
418
|
+
add(store.text(b), "kept");
|
|
419
|
+
const lake = known.hashes.get(m);
|
|
420
|
+
const ref = { file, ...(lake ? { lake } : {}) };
|
|
421
|
+
let pick = null;
|
|
422
|
+
if (lake) {
|
|
423
|
+
const c = cands.find((x) => lakeTextHash(x.text) === lake);
|
|
424
|
+
if (!c) {
|
|
425
|
+
miss(m, ref, "no text with the hash its build compiled was found");
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
pick = { ...c, check: "lake" };
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
const shape = h.moduleShape(s.id, m);
|
|
432
|
+
if (!shape) {
|
|
433
|
+
miss(m, ref, "its snapshot cannot be read");
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
const fit = cands.filter((x) => misfit(x.text, shape, (y) => project.has(y) || p.files.has(y)) === null);
|
|
437
|
+
if (!fit.length) {
|
|
438
|
+
miss(m, ref, cands.length ? "no text agrees with the places its build recorded" : "no text of its file was found");
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
// several texts agree: the commit read wins where the build was read clean from it, else
|
|
442
|
+
// nothing is kept, since one of them is not the build's
|
|
443
|
+
const one = fit.length === 1 ? fit[0] : !s.commit?.dirty && !s.commit?.guessed && commit ? fit.find((x) => x.from === `git:${commit}`) : undefined;
|
|
444
|
+
if (!one) {
|
|
445
|
+
miss(m, ref, "several texts agree with the places its build recorded");
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
pick = { ...one, check: "places" };
|
|
449
|
+
}
|
|
450
|
+
const put = store.put(pick.text);
|
|
451
|
+
if (put.fresh)
|
|
452
|
+
report.written += put.size;
|
|
453
|
+
remember(file, put.blob);
|
|
454
|
+
map[m] = { ...ref, blob: put.blob, check: pick.check, from: pick.from };
|
|
455
|
+
if (pick.check === "lake")
|
|
456
|
+
report.lake++;
|
|
457
|
+
else
|
|
458
|
+
report.places++;
|
|
459
|
+
}
|
|
460
|
+
// a reading of the build since the backfill began kept its texts itself: those stand
|
|
461
|
+
if (!h.setSources(s.id, map, report.written)) {
|
|
462
|
+
log(`history: snapshot ${s.id} was read again meanwhile, and kept its texts itself`);
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
reports.push(report);
|
|
466
|
+
const missing = Object.entries(report.missing).map(([why, k]) => `${k} ${why}`).join("; ");
|
|
467
|
+
log(`history: snapshot ${s.id} (${s.commit ? `${s.commit.hash.slice(0, 7)}${s.commit.dirty ? " dirty" : ""}` : "no commit"}): ` +
|
|
468
|
+
`${report.lake + report.places} of ${report.of} module texts kept — ${report.lake} by their Lake hash (${known.view} hashes from a view of the build, ${known.trace} from present traces), ${report.places} by their places alone` +
|
|
469
|
+
`${missing ? `; not kept: ${missing}` : ""}; ${(report.written / 1024).toFixed(0)} KB written`);
|
|
470
|
+
}
|
|
471
|
+
log(`history: source texts backfilled in ${((Date.now() - t0) / 1000).toFixed(1)} s`);
|
|
472
|
+
return reports;
|
|
473
|
+
}
|