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