visualproof 0.2.0 → 0.4.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 (96) hide show
  1. package/README.md +88 -33
  2. package/dist/assets/tips/phone-fingers.light.mp4 +0 -0
  3. package/dist/assets/tips/phone-fingers.light.webp +0 -0
  4. package/dist/assets/tips/tour-agent.webp +0 -0
  5. package/dist/assets/tips/tour-arrange.webp +0 -0
  6. package/dist/assets/tips/tour-artwork.webp +0 -0
  7. package/dist/assets/tips/tour-checks.webp +0 -0
  8. package/dist/assets/tips/tour-export.webp +0 -0
  9. package/dist/assets/tips/tour-keys.webp +0 -0
  10. package/dist/assets/tips/tour-library.webp +0 -0
  11. package/dist/assets/tips/tour-project.webp +0 -0
  12. package/dist/assets/tips/tour-review.webp +0 -0
  13. package/dist/assets/tips/tour-share.webp +0 -0
  14. package/dist/assets/tips/tour-source.webp +0 -0
  15. package/dist/assets/tips/tour-target.webp +0 -0
  16. package/dist/assets/tips/tour-view.webp +0 -0
  17. package/dist/assets/tips/tour.json +289 -0
  18. package/dist/assets/tips/tour.webp +0 -0
  19. package/dist/assets/webfonts/LICENSE-Noto.txt +95 -0
  20. package/dist/assets/webfonts/metrics.json +1 -0
  21. package/dist/assets/webfonts/ttf/jost-400-normal.ttf +0 -0
  22. package/dist/assets/webfonts/ttf/jost-500-normal.ttf +0 -0
  23. package/dist/assets/webfonts/ttf/noto-sans-math.ttf +0 -0
  24. package/dist/assets/webfonts/ttf/noto-sans.ttf +0 -0
  25. package/dist/assets/webfonts/ttf/source-sans-3-400-italic.ttf +0 -0
  26. package/dist/assets/webfonts/ttf/source-sans-3-400-normal.ttf +0 -0
  27. package/dist/assets/webfonts/ttf/source-sans-3-500-normal.ttf +0 -0
  28. package/dist/assets/webfonts/ttf/source-sans-3-600-normal.ttf +0 -0
  29. package/dist/assets/webfonts/ttf/source-sans-3-700-normal.ttf +0 -0
  30. package/dist/assets/webfonts/ttf/source-serif-4-400-italic.ttf +0 -0
  31. package/dist/assets/webfonts/ttf/source-serif-4-600-italic.ttf +0 -0
  32. package/dist/mcp/visualproof-mcp.mjs +3 -1
  33. package/dist/server/agent.js +94 -91
  34. package/dist/server/agentdocs.js +1 -1
  35. package/dist/server/artcmd.js +335 -0
  36. package/dist/server/children.js +85 -0
  37. package/dist/server/doctor.js +105 -42
  38. package/dist/server/events.js +10 -0
  39. package/dist/server/export.js +11 -4
  40. package/dist/server/extract.js +3 -2
  41. package/dist/server/figures.js +3 -2
  42. package/dist/server/film.js +380 -0
  43. package/dist/server/history.js +11 -1
  44. package/dist/server/hover.js +8 -5
  45. package/dist/server/live.js +72 -9
  46. package/dist/server/main.js +352 -23
  47. package/dist/server/mcptools.js +4 -1
  48. package/dist/server/media.js +114 -0
  49. package/dist/server/pipeline.js +21 -10
  50. package/dist/server/poppler.js +3 -2
  51. package/dist/server/readonlygate.js +4 -4
  52. package/dist/server/review.js +4 -1
  53. package/dist/server/reviewapi.js +35 -1
  54. package/dist/server/snapshot.js +10 -1
  55. package/dist/server/sourcestore.js +3 -2
  56. package/dist/server/status.js +3 -2
  57. package/dist/server/supervise.js +219 -0
  58. package/dist/server/updates.js +951 -0
  59. package/dist/server/usage.js +3 -1
  60. package/dist/server/version.js +183 -0
  61. package/dist/server/view.js +0 -7
  62. package/dist/server/views.js +24 -0
  63. package/dist/shared/artfonts.js +81 -0
  64. package/dist/shared/artrecipe.js +316 -0
  65. package/dist/shared/collab.js +7 -2
  66. package/dist/shared/declfind.js +95 -0
  67. package/dist/shared/lostfound.js +1 -0
  68. package/dist/shared/pastgraph.js +6 -6
  69. package/dist/shared/review.js +62 -5
  70. package/dist/shared/timeline.js +5 -0
  71. package/dist/web/bundle/app.js +3006 -413
  72. package/dist/web/bundle/edges.js +1 -1
  73. package/dist/web/index.html +13 -1
  74. package/dist/web/src/agentmark.js +14 -0
  75. package/dist/web/src/artwork.js +1000 -0
  76. package/dist/web/src/bundles.js +151 -0
  77. package/dist/web/src/canvas.js +3716 -0
  78. package/dist/web/src/drag.js +61 -0
  79. package/dist/web/src/edgequeue.js +204 -0
  80. package/dist/web/src/gl/gl.js +123 -0
  81. package/dist/web/src/gl/renderer.js +451 -0
  82. package/dist/web/src/halo.js +95 -0
  83. package/dist/web/src/highlight.js +108 -0
  84. package/dist/web/src/overview.js +393 -0
  85. package/dist/web/src/reviewqueue.js +250 -0
  86. package/dist/web/src/selection.js +272 -0
  87. package/dist/web/src/status.js +80 -0
  88. package/dist/web/src/visitors.js +318 -0
  89. package/dist/web/src/visits.js +174 -0
  90. package/dist/web/style.css +196 -1
  91. package/docs/agent-api/README.md +1 -1
  92. package/docs/agent-api/export.md +41 -2
  93. package/docs/agent-api/history.md +5 -3
  94. package/docs/agent-api/mcp.md +1 -1
  95. package/docs/agent-api/reading.md +2 -1
  96. package/package.json +5 -1
@@ -0,0 +1,335 @@
1
+ // The artwork command (ExportSpec decision 21): `visualproof artwork <lean-project> --recipe <file>`
2
+ // makes the picture a recipe says (decision 20) without a browser — the same picture as the page's,
3
+ // by the page's own code. The build is read as `visualproof export` reads it; the board is the
4
+ // Project's, laid out as the site keeps it (or as a new site would); the recipe is resolved as the
5
+ // page resolves it; the board is drawn by the page's canvas without a graphics card (its renderer
6
+ // kept to its data, the names measured from the fonts the package ships) on a screen of the given
7
+ // size, the frame placed and fitted as the artwork mode places and fits it; the SVG is written as
8
+ // the page's Save SVG writes it, and the PNG is that SVG drawn by resvg with the same fonts.
9
+ // Nothing is written but the files: the site is read, never written (pipeline `readOnlySite`), and
10
+ // without --site none is read.
11
+ import { readFileSync, readdirSync } from "node:fs";
12
+ import { createRequire } from "node:module";
13
+ import { join } from "node:path";
14
+ import { createDeflate, crc32, gunzipSync } from "node:zlib";
15
+ import { readRecipe, resolveRecipe } from "../shared/artrecipe.js";
16
+ import { textMeasurer } from "../shared/artfonts.js";
17
+ import { boardSet, lightUp, wholeLayout } from "../shared/board.js";
18
+ import { declAt, guessNames } from "../shared/declfind.js";
19
+ import { litKeyEdges } from "../shared/keys.js";
20
+ import { emptyReview } from "../shared/review.js";
21
+ import { SearchIndex } from "../shared/search.js";
22
+ import { Graph } from "../shared/viewgraph.js";
23
+ import { PROJECT } from "../shared/views.js";
24
+ import { artName, artSvg, borderInset, cleanSettings, cssTheme, drawBackdrop, frameRect, sourceLine, SvgContext, themeBackground, titleOf, titleRoom, writeOver } from "../web/src/artwork.js";
25
+ import { GraphCanvas, themeOf } from "../web/src/canvas.js";
26
+ import { ART_SANS } from "../web/src/halo.js";
27
+ import { ReviewFlow } from "../web/src/reviewqueue.js";
28
+ import { declPick, edgeMode, selectionQuery } from "../web/src/selection.js";
29
+ import { statusCodes } from "../web/src/status.js";
30
+ import { gitSource } from "./history.js";
31
+ import { WEB, WEBFONTS } from "./pipeline.js";
32
+ /** the faces of a picture's names, from the package's own TrueType files (scripts/artfonts.py) */
33
+ const TTF = join(WEBFONTS, "ttf");
34
+ let metrics = null;
35
+ /** How wide text is in a font, as the package's faces draw it. */
36
+ function measurer() {
37
+ metrics ??= JSON.parse(readFileSync(join(WEBFONTS, "metrics.json"), "utf8"));
38
+ return textMeasurer(metrics);
39
+ }
40
+ /** The page's font faces, each its WOFF2 written in, as the page's Save SVG writes them (web/src/
41
+ * artframe.ts, faces): from the style sheet's rules, the files beside it. */
42
+ export function facesOf(css) {
43
+ const out = [];
44
+ for (const m of css.matchAll(/@font-face\s*\{([^}]*)\}/g)) {
45
+ const body = m[1];
46
+ const get = (k) => new RegExp(`${k}\\s*:\\s*([^;]+)`).exec(body)?.[1].trim();
47
+ const family = get("font-family")?.replace(/["']/g, "").trim() ?? "";
48
+ if (family !== "Source Sans 3" && family !== "Source Serif 4" && family !== "Jost")
49
+ continue;
50
+ const file = /url\(["']?\/fonts\/([a-z0-9-]+\.woff2)["']?\)/.exec(body)?.[1];
51
+ if (!file)
52
+ continue;
53
+ const b64 = readFileSync(join(WEBFONTS, file)).toString("base64");
54
+ out.push(`@font-face{font-family:"${family}";font-style:${get("font-style") || "normal"};font-weight:${get("font-weight") || "400"};src:url(data:font/woff2;base64,${b64}) format("woff2");unicode-range:${get("unicode-range") || "U+0-10FFFF"}}`);
55
+ }
56
+ return out.join("");
57
+ }
58
+ /** The Project view as a site keeps it (its pins, folds, hidden levels and clustering: server/views.ts,
59
+ * readProjectView), or as a new site has it. */
60
+ export function projectView(kept) {
61
+ const empty = () => ({ namespace: [], module: [] });
62
+ return (kept ?? {
63
+ id: PROJECT,
64
+ name: null,
65
+ scope: { op: "all" },
66
+ clustering: "namespace",
67
+ pins: { namespace: { clusters: {}, nodes: {} }, module: { clusters: {}, nodes: {} } },
68
+ folds: empty(),
69
+ hidden: empty(),
70
+ by: null,
71
+ at: new Date(0).toISOString(),
72
+ kept: true,
73
+ });
74
+ }
75
+ /** The picture a recipe says, of the build `prep` — its graph and layouts — with the review and the
76
+ * Project view a site keeps (or none). */
77
+ export function makeArtwork(prep, review, project, ask) {
78
+ const read = readRecipe(ask.recipe);
79
+ if ("error" in read)
80
+ return { problems: [`${read.error} ${read.hint}`] };
81
+ const r = read.recipe;
82
+ if (r.view && r.view !== PROJECT)
83
+ return { problems: [`the command draws the Project view: the recipe names the view ${r.view}`] };
84
+ const meta = JSON.parse(gunzipSync(prep.metaGz).toString("utf8"));
85
+ const raw = gunzipSync(prep.edgesGz);
86
+ const g = new Graph(meta, new Int32Array(raw.buffer, raw.byteOffset, raw.byteLength / 4));
87
+ const data = review ?? emptyReview();
88
+ // the board: the Project's, on the server's layout under the levels it hides
89
+ const mode = project.clustering;
90
+ const bs = boardSet(g, data, project.scope);
91
+ const L = wholeLayout(meta.layouts[mode], g.nodes, mode, project.hidden[mode]);
92
+ // the targets: the review's with a site, those the recipe names without one
93
+ const onGraph = (n) => g.byName.has(n);
94
+ const targets = review ? Object.keys(data.targets) : Array.isArray(r.targets) ? r.targets.filter(onGraph) : [];
95
+ const problems = [];
96
+ let sel = null;
97
+ if (r.selection) {
98
+ // as the agents' API finds a declaration (shared/declfind.ts): a name it folds is the declaration
99
+ // it is folded into; none, what was likely meant
100
+ const i = declAt(g, r.selection.name);
101
+ if (i !== undefined)
102
+ sel = { name: g.nodes[i].name, mode: r.selection.mode ?? "tcb" };
103
+ else {
104
+ const meant = guessNames(g, new SearchIndex(meta.nodes, meta.projectCount), r.selection.name.trim());
105
+ problems.push(`no declaration ${r.selection.name} in the graph${meant.length ? ` (did you mean ${meant[0]}?)` : ""}`);
106
+ }
107
+ }
108
+ const res = resolveRecipe(r, {
109
+ clusters: L.clusters,
110
+ clusterOf: (name) => {
111
+ const i = g.byName.get(name);
112
+ return i === undefined ? undefined : L.cluster[i];
113
+ },
114
+ targets,
115
+ projectCount: g.projectCount,
116
+ });
117
+ problems.push(...res.problems);
118
+ if (problems.length)
119
+ return { problems };
120
+ const s = cleanSettings(res.look, { scheme: "kind", theme: "light" });
121
+ // the theme as the mode sets it (web/src/main.ts, drawLook): the board's style in the artwork's,
122
+ // the cells and the starfield in the dark one
123
+ const css = readFileSync(join(WEB, "style.css"), "utf8");
124
+ const v = cssTheme(css, s.style === "board" ? s.theme === "dark" : true);
125
+ const measure = measurer();
126
+ // the board, drawn without a browser on a screen of the size asked
127
+ const scratch = new SvgContext(measure, ART_SANS);
128
+ const none = () => { };
129
+ const C = new GraphCanvas(null, null, null, g, { nodeClick: none, clusterClick: none, backgroundClick: none, hover: none, pinsChanged: none, foldChanged: none }, { w: ask.screen.w, h: ask.screen.h, theme: themeOf(v), ctx: scratch });
130
+ C.setGraph(g);
131
+ C.setLayout(L, project.pins[mode], project.folds[mode], mode);
132
+ C.setShowExternals(bs.whole ? bs.outside : true);
133
+ // the review on it, as the page's syncStatus puts it (no build's file states: none is known here)
134
+ const idx = targets.map((n) => g.byName.get(n)).filter((j) => j !== undefined);
135
+ C.setStatus(statusCodes(data, g, new Map()));
136
+ C.setTargets(idx);
137
+ const flow = new ReviewFlow({ graph: () => g, review: () => data, reader: () => null });
138
+ const keys = new Map();
139
+ for (const t of idx)
140
+ for (const k of flow.keys(t).keys)
141
+ if (k !== t)
142
+ keys.set(k, [...(keys.get(k) ?? []), t]);
143
+ C.setKeys([...keys.keys()], [...keys.values()]);
144
+ // the artwork's look, as drawLook gives it
145
+ C.setScheme(s.scheme);
146
+ C.setEdges(s.edges !== "none");
147
+ C.setArt({ labels: s.labels, namesFrom: s.namesFrom, hidden: new Set(s.hiddenNames), shown: new Set(s.shownNames), ruled: new Set(s.ruledNames), edges: s.edges, targets: s.targets, shownTargets: new Set(s.shownTargets), keys: s.keys, style: s.style });
148
+ // the selection, as the page's apply makes it
149
+ const picks = sel ? [declPick(sel.mode, sel.name)] : [];
150
+ const q = selectionQuery(picks);
151
+ const lit = q && lightUp(g, data, bs, q);
152
+ const keyEdges = sel?.mode === "keys" && lit && lit.count ? litKeyEdges(idx.map((t) => flow.keys(t)), (i) => lit.set[i] === 1).flat() : null;
153
+ if (s.targets === "highlight") {
154
+ const alone = new Uint8Array(g.n);
155
+ for (const i of idx)
156
+ alone[i] = 1;
157
+ C.setActivation(edgeMode(picks), [], alone, [], null);
158
+ }
159
+ else if (!s.selection)
160
+ C.setActivation(edgeMode(picks), [], null, [], null);
161
+ else
162
+ C.setActivation(edgeMode(picks), sel ? [g.byName.get(sel.name)] : [], lit?.set ?? null, [], keyEdges);
163
+ // the frame, placed and fitted as the mode places and fits it (web/src/artframe.ts, place, open)
164
+ const rect = frameRect(ask.screen, s);
165
+ // the title's line counts the targets marked: the review's, or the recipe's where there is no site
166
+ const own = titleOf(meta.project.name, meta.projectCount, review ? Object.keys(data.targets).length : targets.length);
167
+ const below = titleRoom(rect.h, s, own);
168
+ const cam = C.fitCam({ x: rect.x, y: rect.y + below, w: rect.w, h: rect.h - below });
169
+ if (cam)
170
+ C.jumpTo(cam);
171
+ C.settle();
172
+ // what the names are: those written, and those a rule chose that would have covered another
173
+ const written = C.writtenNames();
174
+ const name = (id) => id.slice(2).replace("⟨external⟩", "External");
175
+ const git = s.source ? gitSource(ask.root) : null;
176
+ const source = git ? sourceLine(git) : null;
177
+ // the SVG, as the page's Save SVG writes it (artframe saveFile); notes are not drawn here
178
+ const ground = s.background !== "transparent";
179
+ const bg = ground && s.style === "board" ? themeBackground(v) : null;
180
+ const svg = artSvg(rect, s, bg, ART_SANS, measure, (pen) => {
181
+ const ctx = pen;
182
+ if (ground)
183
+ drawBackdrop(ctx, s.style, rect);
184
+ if (s.border) {
185
+ const i = borderInset(rect);
186
+ pen.clipRect("inside", rect.x + i, rect.y + i, rect.w - 2 * i, rect.h - 2 * i);
187
+ pen.raw(`<g clip-path="url(#inside)">`);
188
+ }
189
+ for (const sh of C.artShapes(rect))
190
+ pen.shape(sh);
191
+ C.drawMarks(ctx);
192
+ if (s.border)
193
+ pen.raw(`</g>`);
194
+ writeOver(ctx, s, rect, own, source, v);
195
+ }, facesOf(css));
196
+ const title = ask.name ?? (s.title || meta.project.name);
197
+ return {
198
+ problems: [],
199
+ look: s,
200
+ names: {
201
+ written: [...written].map(name),
202
+ ruledSkipped: s.ruledNames.filter((id) => !written.has(id)).map(name),
203
+ left: res.left,
204
+ },
205
+ targets: s.targets === "chosen" ? s.shownTargets : s.targets === "plain" ? [] : idx.map((j) => g.nodes[j].name),
206
+ selection: sel,
207
+ notesLeftOut: s.notes ? (project.notes ?? []).length : 0,
208
+ svg,
209
+ file: (kind) => artName(title, kind),
210
+ };
211
+ }
212
+ let wasmReady = null;
213
+ let ttfs = null;
214
+ /** resvg's WebAssembly build, ready, with the package's TrueType faces and none of the system's
215
+ * (decision 21: the PNG agrees with the SVG, and is the same on every machine) */
216
+ async function renderer() {
217
+ const { initWasm, Resvg } = await import("@resvg/resvg-wasm");
218
+ wasmReady ??= initWasm(readFileSync(createRequire(import.meta.url).resolve("@resvg/resvg-wasm/index_bg.wasm")));
219
+ await wasmReady;
220
+ ttfs ??= readdirSync(TTF)
221
+ .filter((f) => f.endsWith(".ttf"))
222
+ .sort()
223
+ .map((f) => new Uint8Array(readFileSync(join(TTF, f))));
224
+ const opts = { font: { fontBuffers: ttfs, loadSystemFonts: false, defaultFontFamily: "Source Sans 3" } };
225
+ return (svg) => new Resvg(svg, opts);
226
+ }
227
+ /** The most pixels a PNG is drawn in at once. resvg holds a picture four times over and more (its
228
+ * clipped groups' layers and their masks: 17 bytes a pixel) in WebAssembly's memory, which is at
229
+ * most 4 GiB: a picture of 80 million pixels takes 1.9 GB, the largest (16384 × 16384) more than
230
+ * there is. A larger one is drawn in bands of BAND_PIXELS (the largest: 2.0 GB). */
231
+ export const PNG_PIXELS = 80_000_000;
232
+ /** A band's pixels: resvg's layers reach up to twice the band's height beyond it, so that a band
233
+ * costs 35 bytes a pixel, 0.7 GB, and the picture is drawn about three times as long. */
234
+ const BAND_PIXELS = 20_000_000;
235
+ /** An artwork's SVG (artSvg's: its root's viewBox the frame, its width and height the picture's) as
236
+ * it draws rows `y0` to `y0 + rows` of the picture: its viewBox moved down, the background's
237
+ * rectangle (its `100%`, which a moved viewBox would not move) in the frame's units, and the frame's
238
+ * clip left out, as the band is within the frame. The border's clip is kept, and given a rectangle
239
+ * as large as the frame that draws nothing: resvg 2.6 fails (WebAssembly's `unreachable`) on a
240
+ * clipped group whose contents lie more than twice the band's height away from it. */
241
+ export function bandSvg(svg, y0, rows) {
242
+ const root = /^<svg ([^>]*?)viewBox="0 0 ([\d.]+) ([\d.]+)" width="(\d+)" height="(\d+)"/.exec(svg);
243
+ const board = /<g clip-path="url\(#frame\)"><g transform="translate\(([-\d.]+) ([-\d.]+)\)">/.exec(svg);
244
+ if (!root || !board)
245
+ throw new Error("not an artwork's SVG");
246
+ const [W, H, PH] = [Number(root[2]), Number(root[3]), Number(root[5])];
247
+ const f = H / PH;
248
+ return svg
249
+ .replace(root[0], `<svg ${root[1]}viewBox="0 ${y0 * f} ${W} ${rows * f}" width="${root[4]}" height="${rows}"`)
250
+ .replace(`<rect width="100%" height="100%"`, `<rect width="${W}" height="${H}"`)
251
+ .replace(board[0], `<g><g transform="translate(${board[1]} ${board[2]})">`)
252
+ .replace(`<g clip-path="url(#inside)">`, `<g clip-path="url(#inside)"><rect x="${-Number(board[1])}" y="${-Number(board[2])}" width="${W}" height="${H}" fill="#000" fill-opacity="0"/>`);
253
+ }
254
+ /** An SVG drawn as a PNG at the size its root says, by resvg. A picture of more than `most` pixels
255
+ * is drawn in bands of about `band` pixels (bandSvg) and written as one PNG, row by row: the same
256
+ * picture, its pixels a few levels apart where translucent shapes overlap (resvg's layers round
257
+ * otherwise than one drawing). */
258
+ export async function pngOf(svg, most = PNG_PIXELS, band = BAND_PIXELS) {
259
+ const render = await renderer();
260
+ const m = /^<svg [^>]*?width="(\d+)" height="(\d+)"/.exec(svg);
261
+ const [w, h] = m ? [Number(m[1]), Number(m[2])] : [0, 0];
262
+ const failed = (e) => new Error(`the PNG${m ? `, ${w} × ${h},` : ""} could not be drawn (${e instanceof Error ? e.message : String(e)})`);
263
+ if (!m || w * h <= most) {
264
+ const r = render(svg);
265
+ let png;
266
+ try {
267
+ const image = r.render();
268
+ png = image.asPng();
269
+ image.free();
270
+ }
271
+ catch (e) {
272
+ // resvg is not called again once it has failed: the command ends
273
+ throw failed(e);
274
+ }
275
+ r.free();
276
+ return png;
277
+ }
278
+ // the rows, each its filter byte (none) and its pixels, demultiplied as resvg's own PNG has them
279
+ // (tiny-skia's `demultiply`)
280
+ const deflate = createDeflate({ level: 6 });
281
+ const out = [];
282
+ deflate.on("data", (b) => out.push(b));
283
+ const done = new Promise((ok, fail) => {
284
+ deflate.on("end", ok);
285
+ deflate.on("error", fail);
286
+ });
287
+ // bands of even heights, so that none is a sliver
288
+ const count = Math.ceil(h / Math.max(1, Math.floor(band / w)));
289
+ const rows = Math.ceil(h / count);
290
+ for (let y = 0; y < h; y += rows) {
291
+ const n = Math.min(rows, h - y);
292
+ const r = render(bandSvg(svg, y, n));
293
+ let px;
294
+ try {
295
+ const image = r.render();
296
+ px = image.pixels;
297
+ image.free();
298
+ }
299
+ catch (e) {
300
+ throw failed(e);
301
+ }
302
+ r.free();
303
+ const lines = Buffer.alloc(n * (w * 4 + 1));
304
+ for (let row = 0; row < n; row++) {
305
+ const o = row * (w * 4 + 1) + 1, from = row * w * 4;
306
+ for (let x = 0; x < w * 4; x += 4) {
307
+ const a = px[from + x + 3];
308
+ lines[o + x + 3] = a;
309
+ if (a === 255)
310
+ for (let c = 0; c < 3; c++)
311
+ lines[o + x + c] = px[from + x + c];
312
+ else if (a > 0)
313
+ for (let c = 0; c < 3; c++)
314
+ lines[o + x + c] = Math.min(255, Math.floor(px[from + x + c] / (a / 255) + 0.5));
315
+ }
316
+ }
317
+ if (!deflate.write(lines))
318
+ await new Promise((ok) => deflate.once("drain", ok));
319
+ }
320
+ deflate.end();
321
+ await done;
322
+ const chunk = (type, data) => {
323
+ const head = Buffer.alloc(8);
324
+ head.writeUInt32BE(data.length, 0);
325
+ head.write(type, 4, "latin1");
326
+ const crc = Buffer.alloc(4);
327
+ crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])) >>> 0, 0);
328
+ return Buffer.concat([head, data, crc]);
329
+ };
330
+ const ihdr = Buffer.alloc(13);
331
+ ihdr.writeUInt32BE(w, 0);
332
+ ihdr.writeUInt32BE(h, 4);
333
+ ihdr.set([8, 6, 0, 0, 0], 8);
334
+ return new Uint8Array(Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", ihdr), chunk("IDAT", Buffer.concat(out)), chunk("IEND", Buffer.alloc(0))]));
335
+ }
@@ -0,0 +1,85 @@
1
+ // The programs the server runs and waits for — the extractor and the Lean programs' build, the
2
+ // usage pass's `lake env`, Lean's language server, XeLaTeX, ImageMagick, Poppler, git, ps — kept
3
+ // here while they run, so that a server stopping stops them (UpdateSpec decision 9) rather than
4
+ // leave them working for nobody. Those that start programs of their own (Lake starts Lean, Lean's
5
+ // server its workers, XeLaTeX kpathsea's, ImageMagick its delegates) run in a process group of
6
+ // their own (`grouped`, spawn's `detached`), and are stopped with all they started; what is left
7
+ // at the server's exit is ended at once. The usage pass's workers have a stop of their own
8
+ // (server/usage.ts).
9
+ const running = new Set();
10
+ /** the children that lead a process group of their own */
11
+ const groups = new WeakSet();
12
+ /** spawn's `detached` for a program run as a group (`grouped`): its own process group, where the
13
+ * platform has them */
14
+ export const DETACH = process.platform !== "win32";
15
+ /** Keep `child` as `tracked` does, as the leader of a process group of its own: spawned with
16
+ * `detached: DETACH`, and stopped with every program it started. */
17
+ export function grouped(child) {
18
+ if (DETACH)
19
+ groups.add(child);
20
+ return tracked(child);
21
+ }
22
+ /** End `child` with `signal`: where it leads a process group, the whole group. */
23
+ export function end(child, signal = "SIGTERM") {
24
+ if (groups.has(child) && child.pid && child.exitCode === null) {
25
+ try {
26
+ process.kill(-child.pid, signal);
27
+ return;
28
+ }
29
+ catch {
30
+ // the group is gone: the child alone, if it is not
31
+ }
32
+ }
33
+ child.kill(signal);
34
+ }
35
+ // what still runs as the server's process ends goes with it
36
+ process.once("exit", () => {
37
+ for (const c of running)
38
+ end(c, "SIGKILL");
39
+ });
40
+ /** Keep `child` until it exits; the same child, for `spawn(…)` to be wrapped in place. */
41
+ export function tracked(child) {
42
+ running.add(child);
43
+ const gone = () => running.delete(child);
44
+ child.once("exit", gone);
45
+ child.once("error", gone);
46
+ return child;
47
+ }
48
+ /** The environment a program the server runs is given: the server's own, without what is the
49
+ * server's alone (`VISUALPROOF_*`: the supervisor's word to it, its test hooks; UpdateSpec decision
50
+ * 9), with `extra` over it. */
51
+ export function childEnv(extra = {}) {
52
+ const env = {};
53
+ for (const [k, v] of Object.entries(process.env))
54
+ if (!k.startsWith("VISUALPROOF_"))
55
+ env[k] = v;
56
+ return { ...env, ...extra };
57
+ }
58
+ /** How many tracked programs run now. */
59
+ export function childCount() {
60
+ return running.size;
61
+ }
62
+ /** Ask every tracked program to end (SIGTERM), and end those still there after `graceMs`
63
+ * (SIGKILL). Resolves once none runs, or at the end of the grace; the process waits for it. */
64
+ export function stopChildren(graceMs = 2000) {
65
+ if (!running.size)
66
+ return Promise.resolve();
67
+ for (const c of running)
68
+ end(c, "SIGTERM");
69
+ return new Promise((resolve) => {
70
+ const t0 = Date.now();
71
+ const tick = setInterval(() => {
72
+ if (!running.size) {
73
+ clearInterval(tick);
74
+ return resolve();
75
+ }
76
+ if (Date.now() - t0 >= graceMs) {
77
+ for (const c of running)
78
+ end(c, "SIGKILL");
79
+ running.clear();
80
+ clearInterval(tick);
81
+ resolve();
82
+ }
83
+ }, 50);
84
+ });
85
+ }
@@ -1,52 +1,25 @@
1
1
  // `visualproof doctor [<lean-project>]`: what this machine, and a project, need for VisualProof to
2
- // run, each checked, with what to do where it falls short. It changes nothing: it runs no Lake (in a
3
- // project's folder that would install its toolchain, and Lake may build), and makes no folder.
2
+ // run, each checked, with what to do where it falls short; and whether a newer VisualProof is out,
3
+ // asked of the npm registry through npm (UpdateSpec decision 1). It changes nothing: it runs no Lake
4
+ // (in a project's folder that would install its toolchain, and Lake may build), and makes no folder.
4
5
  import { spawnSync } from "node:child_process";
5
- import { accessSync, constants, existsSync, readFileSync } from "node:fs";
6
+ import { accessSync, constants, existsSync, readFileSync, statSync } from "node:fs";
7
+ import { filmPin } from "./film.js";
8
+ import { tmpdir } from "node:os";
6
9
  import { dirname, join, relative, resolve } from "node:path";
7
- import { projectDirs, siteHolder } from "./dirs.js";
10
+ import { cacheHome, projectDirs, siteHolder } from "./dirs.js";
11
+ import { cmp, packageVersion, parseVersion, satisfies } from "./version.js";
12
+ import { choose, FROM_PACKAGE, installCommand, newerThan, newest, notesOf, npmCommand, PACKAGE_ROOT, readInfo, sameMinor, Versions } from "./updates.js";
13
+ export { packageVersion, satisfies };
8
14
  import { ProjectError, openProject, targetsInWords } from "./project.js";
9
- export const run = (cmd, args) => {
10
- const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"] });
15
+ export const run = (cmd, args, cwd) => {
16
+ const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"], ...(cwd ? { cwd } : {}) });
11
17
  return r.error || r.status !== 0 ? null : `${r.stdout}${r.stderr}`;
12
18
  };
13
19
  /** The package's own file, read where both a checkout and the installed package have it. */
14
20
  function packageFile(rel) {
15
21
  return join(import.meta.dirname, "..", "..", rel);
16
22
  }
17
- export function packageVersion() {
18
- return JSON.parse(readFileSync(packageFile("package.json"), "utf8")).version;
19
- }
20
- function parseVersion(v) {
21
- const m = /(\d+)\.(\d+)\.(\d+)/.exec(v);
22
- return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
23
- }
24
- function cmp(a, b) {
25
- for (let i = 0; i < 3; i++)
26
- if (a[i] !== b[i])
27
- return a[i] - b[i];
28
- return 0;
29
- }
30
- /** Whether a version meets a range of the forms package.json's `engines` uses: alternatives
31
- * joined by `||`, each of `^x.y.z`, `>=x.y.z`, `<x.y.z`, joined by spaces. */
32
- export function satisfies(version, range) {
33
- const v = parseVersion(version);
34
- if (!v)
35
- return false;
36
- return range.split("||").some((alt) => alt.trim().split(/\s+/).every((part) => {
37
- const m = /^(\^|>=|<|)(\d+\.\d+\.\d+)$/.exec(part);
38
- if (!m)
39
- return false;
40
- const w = parseVersion(m[2]);
41
- if (m[1] === "^")
42
- return v[0] === w[0] && cmp(v, w) >= 0;
43
- if (m[1] === ">=")
44
- return cmp(v, w) >= 0;
45
- if (m[1] === "<")
46
- return cmp(v, w) < 0;
47
- return cmp(v, w) === 0;
48
- }));
49
- }
50
23
  /** A toolchain as elan names it: `leanprover/lean4:v4.33.1`, whether the project writes it so, or
51
24
  * as a bare version, or elan lists it as its folder's name (`leanprover--lean4---v4.33.1`). */
52
25
  export function toolchainName(t) {
@@ -92,6 +65,93 @@ function install(what, platform) {
92
65
  return platform === "darwin" ? "brew install poppler" : "install poppler-utils (Debian, Ubuntu: sudo apt install poppler-utils)";
93
66
  return platform === "darwin" ? "install MacTeX, or brew install --cask mactex-no-gui" : "install TeX Live's XeLaTeX (Debian, Ubuntu: sudo apt install texlive-xetex texlive-latex-extra)";
94
67
  }
68
+ /** What the registry says of the releases beyond this one, in a line (UpdateSpec decisions 1–4, 7):
69
+ * asked through npm under this Node, unless updates are off (VISUALPROOF_UPDATES=off); with what
70
+ * this machine's servers set aside. */
71
+ function updatesLine(exec, node, fromPackage, cacheRoot, mode, site) {
72
+ const running = packageVersion();
73
+ if (!fromPackage)
74
+ return "a checkout: its updates are git's, not npm's";
75
+ if (mode === "off")
76
+ return "not asked (VISUALPROOF_UPDATES=off)";
77
+ // what is set aside now (for the project's site, where one is checked), and where its patches are paused
78
+ const kept = new Versions(cacheRoot);
79
+ const held = kept.reasons(Date.now(), site);
80
+ const paused = kept.paused(site);
81
+ const bad = new Map(held.map((h) => [h.version, h]));
82
+ const aside = (held.length ? `; set aside here: ${held.map((h) => `${h.version} (${h.why})`).join(", ")}` : "") +
83
+ (paused ? `; this site's patches up to ${paused.upTo} are not taken (${paused.why})` : "");
84
+ const { cmd, args } = npmCommand();
85
+ // run where no project's .npmrc applies: the caches' root, else the temporary folder
86
+ const cwd = existsSync(cacheRoot) ? cacheRoot : tmpdir();
87
+ const ask = (more) => exec(cmd, [...args, ...more, "--fetch-timeout=15000", "--fetch-retries=1"], cwd);
88
+ // npm's own warnings come after its answer: the answer alone is read
89
+ const json = (out, re) => {
90
+ const m = out === null ? null : re.exec(out)?.[0];
91
+ try {
92
+ return m ? JSON.parse(m) : undefined;
93
+ }
94
+ catch {
95
+ return undefined;
96
+ }
97
+ };
98
+ const v = json(ask(["view", "visualproof", "versions", "--json"]), /\[[^\]]*\]|"[^"\n]*"/);
99
+ if (v === undefined)
100
+ return `the npm registry could not be asked (offline, or npm not found): not checked${aside}`;
101
+ const versions = (Array.isArray(v) ? v : [v]).filter((x) => typeof x === "string");
102
+ // the engines of the newest beyond this one: those the doctor would name
103
+ const engines = new Map();
104
+ const deprecated = new Set();
105
+ const first = choose(running, versions, { bad: new Set(bad.keys()) });
106
+ for (const c of [first.patch, first.release]) {
107
+ if (!c || engines.has(c))
108
+ continue;
109
+ const out = ask(["view", `visualproof@${c}`, "engines", "deprecated", "--json"]);
110
+ const info = out === null ? null : readInfo(/^[\s\S]*?(\{[\s\S]*\}|"[^"\n]*")/.exec(out)?.[1] ?? "");
111
+ if (!info)
112
+ continue;
113
+ engines.set(c, info.engines);
114
+ if (info.deprecated)
115
+ deprecated.add(c);
116
+ }
117
+ // only versions npm said the engines and deprecation of are named: one it could not say of is not
118
+ const { patch, release } = choose(running, versions.filter((x) => engines.has(x)), { node, bad: new Set(bad.keys()), engines: (x) => engines.get(x), deprecated });
119
+ const skipped = newest(versions.filter((x) => newerThan(x, running)));
120
+ const unsaid = [first.patch, first.release].find((c) => c && !engines.has(c));
121
+ if (release && !sameMinor(release, running))
122
+ return `${release} is out: ${installCommand(PACKAGE_ROOT, release)} (${notesOf(release)}); a server restarts into it by itself${aside}`;
123
+ // the pause is said in `aside`: no server here takes this one
124
+ if (patch && paused && !newerThan(patch, paused.upTo))
125
+ return `${patch} is out, not taken${aside}, for a day or until a newer patch`;
126
+ if (patch)
127
+ return `${patch} is out: a server takes it by itself (--updates auto), or ${installCommand(PACKAGE_ROOT, patch)}${aside}`;
128
+ if (unsaid)
129
+ return `${unsaid} is out, but npm could not say what it needs: not checked${aside}`;
130
+ if (skipped && deprecated.has(skipped))
131
+ return `${skipped} is out, but deprecated by its publisher${aside}`;
132
+ if (skipped && engines.has(skipped))
133
+ return `${skipped} is out, but needs Node ${engines.get(skipped)}; this is ${node}${aside}`;
134
+ return `the latest release${aside}`;
135
+ }
136
+ /** The tips' tour film, in a line: kept in the cache, or fetched on its first play, or never. */
137
+ function filmLine(cacheRoot, mode) {
138
+ if (process.env.VISUALPROOF_TOUR_FILM)
139
+ return `the local file ${process.env.VISUALPROOF_TOUR_FILM} (VISUALPROOF_TOUR_FILM)`;
140
+ const pin = filmPin();
141
+ if (!pin)
142
+ return "none pinned in this package";
143
+ const kept = join(cacheRoot, "media", `${pin.sha256}.mp4`);
144
+ try {
145
+ if (statSync(kept).size === pin.size)
146
+ return `kept (${kept})`;
147
+ }
148
+ catch {
149
+ // not kept yet
150
+ }
151
+ if (mode === "off")
152
+ return "never fetched (VISUALPROOF_UPDATES=off): the tour shows its stills and captions";
153
+ return `fetched from the project's GitHub release on its first play (${(pin.size / 1e6).toFixed(0)} MB)`;
154
+ }
95
155
  export function doctor(o) {
96
156
  const exec = o.run ?? run;
97
157
  const node = o.node ?? process.versions.node;
@@ -121,8 +181,10 @@ export function doctor(o) {
121
181
  if (exec("git", ["--version"]) === null) {
122
182
  checks.push({ level: "info", what: "git not found: builds are kept without their commit, and past sources only as the builds kept them" });
123
183
  }
184
+ const film = filmLine(o.cache ? resolve(o.cache) : cacheHome(), o.updates ?? process.env.VISUALPROOF_UPDATES);
185
+ const updates = updatesLine(exec, node, o.fromPackage ?? FROM_PACKAGE, o.cache ? resolve(o.cache) : cacheHome(), o.updates ?? process.env.VISUALPROOF_UPDATES, o.path === null ? null : projectDirs(resolve(o.path), o.site, o.cache).site);
124
186
  if (o.path === null)
125
- return { checks, ready: null };
187
+ return { checks, updates, film, ready: null };
126
188
  let project;
127
189
  try {
128
190
  project = openProject(o.path, o.roots, false);
@@ -131,7 +193,7 @@ export function doctor(o) {
131
193
  if (!(e instanceof ProjectError))
132
194
  throw e;
133
195
  checks.push({ level: "fail", what: e.message });
134
- return { checks, ready: null };
196
+ return { checks, updates, film, ready: null };
135
197
  }
136
198
  const { root, toolchain, roots, libDir } = project;
137
199
  const name = toolchainName(toolchain);
@@ -168,7 +230,7 @@ export function doctor(o) {
168
230
  : { level: "info", what: `site ${dirs.site}: a server (process ${holder}) is serving it; another needs a --site of its own` });
169
231
  const failed = checks.some((c) => c.level === "fail");
170
232
  const args = [o.path, ...o.roots.flatMap((r) => ["--root", r]), ...(o.site ? ["--site", o.site] : []), ...(o.cache ? ["--cache", o.cache] : [])];
171
- return { checks, ready: failed ? null : `visualproof serve ${args.map((a) => (/^[\w./:@%+=-]+$/.test(a) ? a : JSON.stringify(a))).join(" ")}` };
233
+ return { checks, updates, film, ready: failed ? null : `visualproof serve ${args.map((a) => (/^[\w./:@%+=-]+$/.test(a) ? a : JSON.stringify(a))).join(" ")}` };
172
234
  }
173
235
  const MARK = { ok: "✓", fail: "✗", warn: "!", info: "·" };
174
236
  export function printReport(r, project) {
@@ -178,6 +240,7 @@ export function printReport(r, project) {
178
240
  if (c.fix && c.level !== "ok")
179
241
  lines.push(` ${c.fix}`);
180
242
  }
243
+ lines.push(` updates: ${r.updates}`, ` tour film: ${r.film}`);
181
244
  if (r.ready)
182
245
  lines.push("", `Ready: ${r.ready}`);
183
246
  else if (project === null && !r.checks.some((c) => c.level === "fail"))
@@ -77,6 +77,16 @@ export class Timeline {
77
77
  renameSync(tmp, this.file);
78
78
  }
79
79
  /** Record an event, and tell every page. */
80
+ /** The VisualProof version the timeline last recorded as serving the site (UpdateSpec decision
81
+ * 10), or null where none is kept. */
82
+ lastVersion() {
83
+ for (let i = this.events.length - 1; i >= 0; i--) {
84
+ const e = this.events[i];
85
+ if (e.kind === "tool")
86
+ return e.to;
87
+ }
88
+ return null;
89
+ }
80
90
  add(e) {
81
91
  const full = { ...e, id: (this.events[this.events.length - 1]?.id ?? 0) + 1, at: new Date().toISOString() };
82
92
  this.events.push(full);