visualproof 0.2.0 → 0.3.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 (59) hide show
  1. package/README.md +40 -26
  2. package/dist/assets/webfonts/LICENSE-Noto.txt +95 -0
  3. package/dist/assets/webfonts/metrics.json +1 -0
  4. package/dist/assets/webfonts/ttf/jost-400-normal.ttf +0 -0
  5. package/dist/assets/webfonts/ttf/jost-500-normal.ttf +0 -0
  6. package/dist/assets/webfonts/ttf/noto-sans-math.ttf +0 -0
  7. package/dist/assets/webfonts/ttf/noto-sans.ttf +0 -0
  8. package/dist/assets/webfonts/ttf/source-sans-3-400-italic.ttf +0 -0
  9. package/dist/assets/webfonts/ttf/source-sans-3-400-normal.ttf +0 -0
  10. package/dist/assets/webfonts/ttf/source-sans-3-500-normal.ttf +0 -0
  11. package/dist/assets/webfonts/ttf/source-sans-3-600-normal.ttf +0 -0
  12. package/dist/assets/webfonts/ttf/source-sans-3-700-normal.ttf +0 -0
  13. package/dist/assets/webfonts/ttf/source-serif-4-400-italic.ttf +0 -0
  14. package/dist/assets/webfonts/ttf/source-serif-4-600-italic.ttf +0 -0
  15. package/dist/server/agent.js +84 -86
  16. package/dist/server/agentdocs.js +1 -1
  17. package/dist/server/artcmd.js +335 -0
  18. package/dist/server/live.js +6 -0
  19. package/dist/server/main.js +136 -8
  20. package/dist/server/mcptools.js +4 -1
  21. package/dist/server/pipeline.js +15 -9
  22. package/dist/server/readonlygate.js +1 -1
  23. package/dist/server/review.js +4 -1
  24. package/dist/server/reviewapi.js +35 -1
  25. package/dist/server/snapshot.js +10 -1
  26. package/dist/server/view.js +0 -7
  27. package/dist/server/views.js +24 -0
  28. package/dist/shared/artfonts.js +81 -0
  29. package/dist/shared/artrecipe.js +316 -0
  30. package/dist/shared/collab.js +7 -2
  31. package/dist/shared/declfind.js +95 -0
  32. package/dist/shared/lostfound.js +1 -0
  33. package/dist/shared/pastgraph.js +6 -6
  34. package/dist/shared/review.js +62 -5
  35. package/dist/web/bundle/app.js +713 -164
  36. package/dist/web/bundle/edges.js +1 -1
  37. package/dist/web/src/agentmark.js +14 -0
  38. package/dist/web/src/artwork.js +1000 -0
  39. package/dist/web/src/bundles.js +151 -0
  40. package/dist/web/src/canvas.js +3694 -0
  41. package/dist/web/src/drag.js +61 -0
  42. package/dist/web/src/edgequeue.js +204 -0
  43. package/dist/web/src/gl/gl.js +123 -0
  44. package/dist/web/src/gl/renderer.js +451 -0
  45. package/dist/web/src/halo.js +95 -0
  46. package/dist/web/src/highlight.js +108 -0
  47. package/dist/web/src/overview.js +393 -0
  48. package/dist/web/src/reviewqueue.js +250 -0
  49. package/dist/web/src/selection.js +272 -0
  50. package/dist/web/src/status.js +80 -0
  51. package/dist/web/src/visitors.js +318 -0
  52. package/dist/web/src/visits.js +174 -0
  53. package/dist/web/style.css +7 -0
  54. package/docs/agent-api/README.md +1 -1
  55. package/docs/agent-api/export.md +41 -2
  56. package/docs/agent-api/history.md +2 -1
  57. package/docs/agent-api/mcp.md +1 -1
  58. package/docs/agent-api/reading.md +2 -1
  59. package/package.json +3 -1
@@ -0,0 +1,316 @@
1
+ // An artwork recipe (ExportSpec decision 20, drafted 2026-10-15 for the owner's review): what a
2
+ // picture of the board is, in words that hold on any project — a size by its name, names by a rule
3
+ // ("the clusters to depth 1, and the targets"), targets and the selection by full name — so that
4
+ // one recipe makes the same kind of picture of every codebase. An agent sends one
5
+ // (POST /api/agent/artwork, server/agent.ts); the link it gets back carries it after `#`, and the
6
+ // page that opens it resolves it against the board it opens on (web/src/main.ts, openRecipe) and
7
+ // opens the artwork mode with what it says, saving the files where the link asks.
8
+ import { ART_MAX_SIDE, ART_NAMES_BYTES, ART_RULED_BYTES, ART_STYLES, ART_TARGETS_BYTES, artNameId, cleanNames, FOOT_SIZE, SCHEMES, TITLE_CHARS, TITLE_SIZE } from "./collab.js";
9
+ /** The sizes offered (web/src/artwork.ts, PRESETS), each in either orientation: given landscape;
10
+ * `key`, a recipe's name for it. */
11
+ export const ART_PRESETS = [
12
+ { key: "hd", name: "HD", w: 1920, h: 1080 },
13
+ { key: "4k", name: "4K", w: 3840, h: 2160 },
14
+ { key: "square", name: "Square", w: 4096, h: 4096 },
15
+ { key: "a3", name: "A3, 300 dpi", w: 4961, h: 3508 },
16
+ { key: "a2", name: "A2, 300 dpi", w: 7016, h: 4961 },
17
+ ];
18
+ /** How a click selects, as the address names it (web/src/selection.ts, TOOLS): Selection; Rests on's
19
+ * Uses, TCB, Keys and Direct; Used by. */
20
+ export const RECIPE_MODES = ["plain", "deps", "tcb", "keys", "refs", "apps"];
21
+ /** The most a recipe takes, in bytes of its JSON: the link carries it. */
22
+ export const RECIPE_BYTES = 6000;
23
+ const FIELDS = ["size", "orientation", "style", "theme", "transparent", "colouring", "border", "title", "subtitle", "titleSize", "footSize", "source", "names", "targets", "keys", "notes", "edges", "selection", "lit", "view"];
24
+ const NAME_FIELDS = ["from", "clusters", "minShare", "targets", "decls", "omit"];
25
+ /** A recipe as given, checked: each field as it says, nothing unknown (a field misspelt would
26
+ * otherwise be a picture silently unlike the one meant). */
27
+ export function readRecipe(v) {
28
+ const bad = (error, hint) => ({ error, hint });
29
+ if (!v || typeof v !== "object" || Array.isArray(v))
30
+ return bad("recipe is an object.", 'E.g. {"size": "a2", "orientation": "portrait", "names": {"clusters": 1}}.');
31
+ if (new TextEncoder().encode(JSON.stringify(v)).length > RECIPE_BYTES)
32
+ return bad(`A recipe is at most ${RECIPE_BYTES} bytes.`, "Name clusters by a depth rather than one by one.");
33
+ const o = v;
34
+ const unknown = Object.keys(o).filter((k) => !FIELDS.includes(k));
35
+ if (unknown.length)
36
+ return bad(`recipe has no field ${unknown.join(", ")}.`, `Its fields: ${FIELDS.join(", ")}.`);
37
+ const r = {};
38
+ const bool = (k) => {
39
+ if (o[k] === undefined)
40
+ return null;
41
+ if (typeof o[k] !== "boolean")
42
+ return `${k} is true or false.`;
43
+ r[k] = o[k];
44
+ return null;
45
+ };
46
+ const oneOf = (k, of) => {
47
+ if (o[k] === undefined)
48
+ return null;
49
+ if (!of.includes(o[k]))
50
+ return `${k} is ${of.join(", ")}.`;
51
+ r[k] = o[k];
52
+ return null;
53
+ };
54
+ const num = (k, least, most) => {
55
+ if (o[k] === undefined)
56
+ return null;
57
+ if (typeof o[k] !== "number" || !Number.isFinite(o[k]) || o[k] < least || o[k] > most)
58
+ return `${k} is a number from ${least} to ${most} (hundredths of the picture's height).`;
59
+ r[k] = o[k];
60
+ return null;
61
+ };
62
+ const text = (k) => {
63
+ if (o[k] === undefined)
64
+ return null;
65
+ if (o[k] !== null && (typeof o[k] !== "string" || o[k].length > TITLE_CHARS))
66
+ return `${k} is text of at most ${TITLE_CHARS} characters, "" for none, or null for the project's own.`;
67
+ r[k] = o[k];
68
+ return null;
69
+ };
70
+ const names = (x) => Array.isArray(x) && x.length <= 2000 && x.every((n) => typeof n === "string" && n.length > 0 && n.length <= 1000);
71
+ const fault = bool("transparent") ?? bool("border") ?? bool("source") ?? bool("keys") ?? bool("notes") ?? bool("lit") ??
72
+ oneOf("orientation", ["landscape", "portrait"]) ?? oneOf("style", ART_STYLES) ?? oneOf("theme", ["light", "dark"]) ??
73
+ oneOf("colouring", SCHEMES) ?? oneOf("edges", ["all", "selection", "none"]) ??
74
+ num("titleSize", TITLE_SIZE.least, TITLE_SIZE.most) ?? num("footSize", FOOT_SIZE.least, FOOT_SIZE.most) ?? text("title") ?? text("subtitle");
75
+ if (fault)
76
+ return bad(fault, "See GET /api/agent/docs/export, \"Artwork\".");
77
+ if (o.size !== undefined) {
78
+ const s = o.size;
79
+ if (typeof s === "string") {
80
+ if (!ART_PRESETS.some((p) => p.key === s))
81
+ return bad(`size ${s} is no size.`, `A size by name (${ART_PRESETS.map((p) => p.key).join(", ")}) or {w, h} in pixels.`);
82
+ r.size = s;
83
+ }
84
+ else {
85
+ const sw = s?.w, sh = s?.h;
86
+ const side = (x) => Number.isInteger(x) && x >= 16 && x <= ART_MAX_SIDE;
87
+ if (!side(sw) || !side(sh))
88
+ return bad(`size is a name or {w, h}: whole pixels from 16 to ${ART_MAX_SIDE}.`, `Names: ${ART_PRESETS.map((p) => p.key).join(", ")}.`);
89
+ r.size = { w: sw, h: sh };
90
+ }
91
+ }
92
+ if (o.names !== undefined) {
93
+ const n = o.names;
94
+ if (n === "board" || n === "clusters" || n === "none")
95
+ r.names = n;
96
+ else if (n && typeof n === "object" && !Array.isArray(n)) {
97
+ const m = n;
98
+ const extra = Object.keys(m).filter((k) => !NAME_FIELDS.includes(k));
99
+ if (extra.length)
100
+ return bad(`names has no field ${extra.join(", ")}.`, `Its fields: ${NAME_FIELDS.join(", ")}.`);
101
+ const rule = {};
102
+ if (m.from !== undefined) {
103
+ if (m.from !== "none" && m.from !== "board")
104
+ return bad("names.from is none or board.", "From none: only what the rule names; from board: what the board writes, the rule's besides.");
105
+ rule.from = m.from;
106
+ }
107
+ if (m.clusters !== undefined) {
108
+ if (Number.isInteger(m.clusters) && m.clusters >= 0 && m.clusters <= 20)
109
+ rule.clusters = m.clusters;
110
+ else if (names(m.clusters))
111
+ rule.clusters = m.clusters;
112
+ else
113
+ return bad("names.clusters is a depth (0: the top level) or a list of clusters' keys.", "E.g. 1: the top level and the clusters right under it.");
114
+ }
115
+ if (m.minShare !== undefined) {
116
+ if (typeof m.minShare !== "number" || !(m.minShare >= 0 && m.minShare <= 1))
117
+ return bad("names.minShare is a share from 0 to 1.", "0.01: a cluster below the top named where it holds at least 1% of the project's declarations.");
118
+ rule.minShare = m.minShare;
119
+ }
120
+ if (m.targets !== undefined) {
121
+ if (typeof m.targets !== "boolean")
122
+ return bad("names.targets is true or false.", "The shown targets' names.");
123
+ rule.targets = m.targets;
124
+ }
125
+ for (const k of ["decls", "omit"]) {
126
+ if (m[k] === undefined)
127
+ continue;
128
+ if (!names(m[k]))
129
+ return bad(`names.${k} is a list of names.`, k === "decls" ? "Declarations by full name." : "Clusters by key, declarations by full name.");
130
+ rule[k] = m[k];
131
+ }
132
+ r.names = rule;
133
+ }
134
+ else
135
+ return bad('names is "board", "clusters", "none" or a rule {from, clusters, minShare, targets, decls, omit}.', 'E.g. {"clusters": 1}: the top level and the clusters right under it, and the targets.');
136
+ }
137
+ if (o.targets !== undefined) {
138
+ const t = o.targets;
139
+ if (t === "all" || t === "none" || t === "faded")
140
+ r.targets = t;
141
+ else if (names(t))
142
+ r.targets = t;
143
+ else
144
+ return bad('targets is "all", "none", "faded" or a list of targets\' full names.', "Those named are marked, the others drawn as any declaration.");
145
+ }
146
+ if (o.selection !== undefined && o.selection !== null) {
147
+ const s = o.selection;
148
+ if (!s || typeof s !== "object" || typeof s.name !== "string" || !s.name || (s.mode !== undefined && !RECIPE_MODES.includes(s.mode)) || Object.keys(s).some((k) => k !== "name" && k !== "mode"))
149
+ return bad("selection is {name, mode?}: a declaration's full name, and how it is selected.", `mode: ${RECIPE_MODES.join(", ")} (default tcb).`);
150
+ r.selection = { name: s.name, ...(s.mode !== undefined ? { mode: s.mode } : {}) };
151
+ }
152
+ else if (o.selection === null)
153
+ r.selection = null;
154
+ if (o.view !== undefined && o.view !== null) {
155
+ if (typeof o.view !== "string" || !o.view || o.view.length > 200)
156
+ return bad("view is a view's id.", "GET /api/agent/views lists them; left out, the Project.");
157
+ r.view = o.view;
158
+ }
159
+ return { recipe: r };
160
+ }
161
+ /** the outside world's cluster, as a reader may call it */
162
+ const EXTERNAL = "⟨external⟩";
163
+ const keyOf = (k) => (k === "External" ? EXTERNAL : k.startsWith("External.") ? EXTERNAL + k.slice(8) : k);
164
+ /** A recipe resolved against a board: everything it leaves out as a new artwork has it (4K
165
+ * landscape, the board's look, light, names as the board writes them, every target marked). Names
166
+ * are written from none: the clusters named by key, the declarations and the targets' names
167
+ * wherever they fall (ArtworkLook `shownNames`), as names ticked by hand are; the clusters a depth
168
+ * rule names — the outside world's own, and the project's down to the depth that hold `minShare`
169
+ * of the project — only where they cover no name already written (`ruledNames`; the owner's
170
+ * decision of 2026-10-15: names chosen by a rule never overlap). */
171
+ export function resolveRecipe(r, b) {
172
+ const problems = [];
173
+ const preset = typeof r.size === "string" ? ART_PRESETS.find((p) => p.key === r.size) : ART_PRESETS[1];
174
+ const given = typeof r.size === "object" ? r.size : { w: preset.w, h: preset.h };
175
+ const portrait = r.orientation ? r.orientation === "portrait" : given.h > given.w;
176
+ const [long, short] = given.w >= given.h ? [given.w, given.h] : [given.h, given.w];
177
+ const { w, h } = portrait ? { w: short, h: long } : { w: long, h: short };
178
+ // what the recipe names that the board has not is said (`problems`); what is left out for room,
179
+ // counted (`left`); a target of the review the board has not — a target outlives its declaration's
180
+ // rename or removal — is left out unsaid, as the board leaves it
181
+ let left = 0;
182
+ const targetSet = new Set(b.targets);
183
+ const held = (n) => b.clusterOf(n) !== undefined;
184
+ let targets = "marked";
185
+ let shownTargets = [];
186
+ let marked = b.targets.filter(held);
187
+ if (r.targets === "none")
188
+ [targets, marked] = ["plain", []];
189
+ else if (r.targets === "faded")
190
+ targets = "highlight";
191
+ else if (Array.isArray(r.targets)) {
192
+ const unknown = r.targets.filter((t) => !targetSet.has(t));
193
+ if (unknown.length)
194
+ problems.push(`not targets of the review: ${unknown.join(", ")}`);
195
+ const off = r.targets.filter((t) => targetSet.has(t) && !held(t));
196
+ if (off.length)
197
+ problems.push(`targets not on the board: ${off.join(", ")}`);
198
+ const on = r.targets.filter((t) => targetSet.has(t) && held(t));
199
+ shownTargets = cleanNames(on, ART_TARGETS_BYTES);
200
+ left += new Set(on).size - shownTargets.length;
201
+ targets = "chosen";
202
+ marked = shownTargets;
203
+ }
204
+ // the names
205
+ let labels = "board";
206
+ let namesFrom = "board";
207
+ let shown = [];
208
+ let hidden = [];
209
+ let ruledIds = [];
210
+ const named = { clusters: [], ruled: [], decls: [] };
211
+ const n = r.names;
212
+ if (n === "board" || n === "clusters" || n === "none")
213
+ labels = n;
214
+ else if (n) {
215
+ labels = "chosen";
216
+ namesFrom = n.from ?? "none";
217
+ const omit = new Set((n.omit ?? []).map(keyOf));
218
+ const byKey = new Map(b.clusters.map((c, i) => [c.key, i]));
219
+ if (typeof n.clusters === "number") {
220
+ const least = Math.ceil((n.minShare ?? 0.01) * b.projectCount);
221
+ b.clusters.forEach((c) => {
222
+ if ((c.ext ? c.depth === 0 : c.depth <= n.clusters && c.count >= least) && !omit.has(c.key))
223
+ named.ruled.push(c.key);
224
+ });
225
+ }
226
+ else if (Array.isArray(n.clusters)) {
227
+ for (const k of n.clusters) {
228
+ const key = keyOf(k);
229
+ if (!byKey.has(key))
230
+ problems.push(`no cluster ${k} on the board`);
231
+ else if (!omit.has(key) && !named.clusters.includes(key))
232
+ named.clusters.push(key);
233
+ }
234
+ }
235
+ // the targets' names (all on the board), and the declarations named
236
+ const decls = n.targets === false ? [] : [...marked];
237
+ for (const d of n.decls ?? []) {
238
+ if (!held(d))
239
+ problems.push(`no declaration ${d} on the board`);
240
+ else
241
+ decls.push(d);
242
+ }
243
+ for (const d of decls)
244
+ if (!omit.has(d) && !named.decls.includes(d))
245
+ named.decls.push(d);
246
+ const ids = [...named.clusters.map((k) => artNameId("cluster", k)), ...named.decls.map((d) => artNameId("decl", d))];
247
+ shown = cleanNames(ids, ART_NAMES_BYTES);
248
+ const ruled = named.ruled.map((k) => artNameId("cluster", k));
249
+ ruledIds = cleanNames(ruled, ART_RULED_BYTES);
250
+ left += ids.length + ruled.length - shown.length - ruledIds.length;
251
+ // what is named is what the artwork carries
252
+ const kept = new Set([...shown, ...ruledIds]);
253
+ named.clusters = named.clusters.filter((k) => kept.has(artNameId("cluster", k)));
254
+ named.ruled = named.ruled.filter((k) => kept.has(artNameId("cluster", k)));
255
+ named.decls = named.decls.filter((d) => kept.has(artNameId("decl", d)));
256
+ // from the board, what to leave out is said as hidden
257
+ if (namesFrom === "board")
258
+ hidden = cleanNames([...omit].map((k) => (byKey.has(k) ? artNameId("cluster", k) : artNameId("decl", k))), ART_NAMES_BYTES);
259
+ }
260
+ const look = {
261
+ w,
262
+ h,
263
+ scheme: r.colouring ?? "kind",
264
+ theme: r.theme ?? "light",
265
+ background: r.transparent ? "transparent" : "theme",
266
+ labels,
267
+ namesFrom,
268
+ hiddenNames: hidden,
269
+ shownNames: shown,
270
+ ruledNames: ruledIds,
271
+ edges: r.edges ?? "all",
272
+ notes: r.notes ?? true,
273
+ targets,
274
+ shownTargets,
275
+ keys: r.keys ?? true,
276
+ selection: r.lit ?? true,
277
+ source: r.source ?? false,
278
+ style: r.style ?? "board",
279
+ title: r.title ?? null,
280
+ subtitle: r.subtitle ?? null,
281
+ titleSize: r.titleSize ?? TITLE_SIZE.first,
282
+ footSize: r.footSize ?? FOOT_SIZE.first,
283
+ border: r.border ?? false,
284
+ };
285
+ return { look, named, left, problems };
286
+ }
287
+ /** A recipe as the link carries it after `#`: its JSON, base64url. */
288
+ export function encodeRecipe(r) {
289
+ const bytes = new TextEncoder().encode(JSON.stringify(r));
290
+ let s = "";
291
+ for (const x of bytes)
292
+ s += String.fromCharCode(x);
293
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
294
+ }
295
+ /** A link's recipe, read back; null where it is none. */
296
+ export function decodeRecipe(text) {
297
+ try {
298
+ const s = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
299
+ return JSON.parse(new TextDecoder().decode(Uint8Array.from(s, (c) => c.charCodeAt(0))));
300
+ }
301
+ catch {
302
+ return null;
303
+ }
304
+ }
305
+ /** the address's word for the recipe, and for the files to save */
306
+ export const RECIPE_PARAM = "art";
307
+ export const SAVE_PARAM = "save";
308
+ /** A fragment's recipe and the files it asks to save; null where it carries none. */
309
+ export function recipeIn(hash) {
310
+ const p = new URLSearchParams(hash.startsWith("#") ? hash.slice(1) : hash);
311
+ const text = p.get(RECIPE_PARAM);
312
+ if (!text)
313
+ return null;
314
+ const save = (p.get(SAVE_PARAM) ?? "").split(",").filter((k) => k === "png" || k === "svg");
315
+ return { text, save: [...new Set(save)] };
316
+ }
@@ -93,8 +93,11 @@ export function artNameId(kind, s) {
93
93
  * presence carries, in bytes of their JSON: a heartbeat is at most 16 KB (server/main.ts), and the
94
94
  * rest of where a reader is has room beside them. Names by selection are kept as what differs from
95
95
  * As shown, so that a few names hidden or added are a few names carried. */
96
- export const ART_NAMES_BYTES = 4500;
96
+ export const ART_NAMES_BYTES = 4000;
97
97
  export const ART_TARGETS_BYTES = 2500;
98
+ /** the names a recipe's rule chose (ArtworkLook `ruledNames`): a rule names a few clusters. The four
99
+ * lists full, with the longest title and line, leave a heartbeat room (test/sharesnap.test.ts) */
100
+ export const ART_RULED_BYTES = 1000;
98
101
  /** What `x` adds to a list's JSON: itself, quoted, and a comma. */
99
102
  const jsonBytes = (x) => new TextEncoder().encode(JSON.stringify(x)).length + 1;
100
103
  /** A list of names as given, checked: strings, each once, none empty or longer than a name is (1,000
@@ -148,12 +151,14 @@ export function cleanArtwork(v) {
148
151
  // say them, and the key marks as the targets drew them before (with their stars, unless As any)
149
152
  const namesFrom = o.namesFrom === "none" ? "none" : "board";
150
153
  const hiddenNames = cleanNames(o.hiddenNames, ART_NAMES_BYTES, isArtNameId), shownNames = cleanNames(o.shownNames, ART_NAMES_BYTES, isArtNameId);
154
+ // the names a recipe's rule chose, said since later that night: none where a page does not say them
155
+ const ruledNames = cleanNames(o.ruledNames, ART_RULED_BYTES, isArtNameId);
151
156
  const shownTargets = cleanNames(o.shownTargets, ART_TARGETS_BYTES);
152
157
  const keys = typeof o.keys === "boolean" ? o.keys : targets !== "plain";
153
158
  // the style and the title, said since that night: as the board, the project's own; the foot's
154
159
  // size, said since later that night: the first
155
160
  const style = one(ART_STYLES, o.style) ?? "board";
156
- return { w, h, scheme, theme, background, labels, namesFrom, hiddenNames, shownNames, edges, notes: o.notes, targets, shownTargets, keys, selection: o.selection, source: o.source === true, style, title: cleanTitle(o.title), subtitle: cleanTitle(o.subtitle), titleSize: cleanTitleSize(o.titleSize), footSize: cleanFootSize(o.footSize), border: o.border === true, region: [...r] };
161
+ return { w, h, scheme, theme, background, labels, namesFrom, hiddenNames, shownNames, ruledNames, edges, notes: o.notes, targets, shownTargets, keys, selection: o.selection, source: o.source === true, style, title: cleanTitle(o.title), subtitle: cleanTitle(o.subtitle), titleSize: cleanTitleSize(o.titleSize), footSize: cleanFootSize(o.footSize), border: o.border === true, region: [...r] };
157
162
  }
158
163
  /** The legend's schemes (CanvasSpec, "Colour schemes"). */
159
164
  export const SCHEMES = ["kind", "status"];
@@ -0,0 +1,95 @@
1
+ // A declaration by the name someone gives (the agents' API, server/agent.ts; the artwork command,
2
+ // server/artcmd.ts): the graph's own name; else the declaration a name it folds is folded into — a
3
+ // declaration Lean generated from a command, or a constructor, recursor, field, equation lemma of
4
+ // one — else, where none is, the names likely meant.
5
+ import { search } from "./search.js";
6
+ /** the last components of names Lean generates beside a declaration (extractor/Extract.lean,
7
+ * `generatedSuffixes`, and the like) */
8
+ export const GENERATED_LAST = /^(?:rec|recOn|casesOn|noConfusion|noConfusionType|ctorElim|ctorElimType|below|brecOn|binductionOn|ibelow|ctorIdx|toCtorIdx|ofNat|ofNat_ctorIdx|sizeOf_spec|injEq|inj|eq_def|induct|mutual_induct|fun_cases|induct_unfolding|ext_iff|congr_simp|mk|elim|(?:eq|match|proof)_\d+|_.*)$/;
9
+ /** The edit distance between two short strings, or `most + 1` once it is more than `most`. */
10
+ export function editDistance(a, b, most) {
11
+ if (Math.abs(a.length - b.length) > most)
12
+ return most + 1;
13
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
14
+ for (let i = 1; i <= a.length; i++) {
15
+ const row = [i];
16
+ let best = i;
17
+ for (let j = 1; j <= b.length; j++) {
18
+ row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
19
+ best = Math.min(best, row[j]);
20
+ }
21
+ if (best > most)
22
+ return most + 1;
23
+ prev = row;
24
+ }
25
+ return Math.min(prev[b.length], most + 1);
26
+ }
27
+ /** The declaration a name the graph has no node for is folded into: a declaration Lean generated
28
+ * from a command (ViewNode.also), or a constructor, recursor, field, equation lemma… of a
29
+ * declaration or of one so generated, which is a name one component below it. A name one
30
+ * component below a declaration that is a typo or two away from a declaration beside it is
31
+ * taken for that typo, not for something folded, unless its last component is one Lean
32
+ * generates. */
33
+ export function foldedAt(g, name) {
34
+ const direct = g.foldedInto.get(name);
35
+ if (direct !== undefined)
36
+ return direct;
37
+ const dot = name.lastIndexOf(".");
38
+ if (dot <= 0)
39
+ return undefined;
40
+ const head = name.slice(0, dot), last = name.slice(dot + 1);
41
+ const at = g.byName.get(head) ?? g.foldedInto.get(head);
42
+ if (at === undefined || !last)
43
+ return undefined;
44
+ if (!GENERATED_LAST.test(last)) {
45
+ const most = last.length < 5 ? 1 : 2;
46
+ const near = (n) => n.length > head.length + 1 && n.startsWith(head + ".") && !n.includes(".", head.length + 1) && editDistance(n.slice(head.length + 1).toLowerCase(), last.toLowerCase(), most) <= most;
47
+ for (const n of g.nodes)
48
+ if (near(n.name))
49
+ return undefined;
50
+ for (const n of g.foldedInto.keys())
51
+ if (near(n))
52
+ return undefined;
53
+ }
54
+ return at;
55
+ }
56
+ /** Up to five names like `name`: the same name in another case; then the same last component,
57
+ * or one a typo or two away, in the same namespace first; then what the search finds. */
58
+ export function guessNames(g, index, name) {
59
+ const dot = name.lastIndexOf(".");
60
+ const last = name.slice(dot + 1).toLowerCase();
61
+ const ns = dot < 0 ? "" : name.slice(0, dot);
62
+ const scored = new Map();
63
+ const offer = (n, score) => {
64
+ if ((scored.get(n) ?? Infinity) > score)
65
+ scored.set(n, score);
66
+ };
67
+ const lower = name.toLowerCase();
68
+ const most = last.length < 5 ? 1 : 2;
69
+ // the graph's declarations, and the names Lean generated from a command that they fold
70
+ for (const name of [...g.nodes.map((n) => n.name), ...g.foldedInto.keys()]) {
71
+ if (name.toLowerCase() === lower)
72
+ offer(name, -1);
73
+ const k = name.lastIndexOf(".");
74
+ const d = editDistance(name.slice(k + 1).toLowerCase(), last, most);
75
+ if (d <= most)
76
+ offer(name, 2 * d + (name.slice(0, Math.max(0, k)) === ns ? 0 : 1));
77
+ }
78
+ let rank = 0;
79
+ for (const q of [name.slice(dot + 1), name.replace(/\./g, " ")]) {
80
+ if (q.trim().length < 2)
81
+ continue;
82
+ const f = search(index, q);
83
+ for (const h of [...f.hits.name, ...f.hits.statement].slice(0, 10))
84
+ offer(g.nodes[h.i].name, 10 + rank++);
85
+ }
86
+ return [...scored]
87
+ .sort((a, b) => a[1] - b[1] || a[0].length - b[0].length || (a[0] < b[0] ? -1 : 1))
88
+ .slice(0, 5)
89
+ .map(([n]) => n);
90
+ }
91
+ /** The declaration `name` names, its own or the one it is folded into; undefined where none is. */
92
+ export function declAt(g, name) {
93
+ const n = name.trim();
94
+ return g.byName.get(n) ?? foldedAt(g, n);
95
+ }
@@ -64,6 +64,7 @@ export function lostEntries(d, views, has, known = (n) => n.includes("."), pinWh
64
64
  kind: "opinion",
65
65
  name,
66
66
  ...who(o),
67
+ ...(o.recordedBy ? { recordedBy: o.recordedBy } : {}),
67
68
  verdict: o.verdict,
68
69
  ...(o.comment ? { comment: o.comment } : {}),
69
70
  ...(was.length ? { aliases: was.map((n) => lostKey.opinion(name, n, o.at)) } : {}),
@@ -21,12 +21,12 @@ export const PAST_GHOST = 1;
21
21
  export const PAST_CHANGED = 4;
22
22
  /** renamed since: shown under its name then */
23
23
  export const PAST_RENAMED = 8;
24
- /** The git source of a past build: the project's source, the build's own commit; none where the
25
- * build's commit is not known, or only guessed. */
26
- function gitThen(now, commit) {
24
+ /** The git commit of a past build; none where it is not known, or only guessed. Its source is the
25
+ * project's, which the page has now (web/src/main.ts, GET /api/git). */
26
+ function gitThen(commit) {
27
27
  if (!commit || commit.guessed)
28
28
  return { git: undefined };
29
- return { git: { source: now?.source ?? null, commit: commit.hash, branch: commit.branch, ...(commit.dirty ? { dirty: true } : {}) } };
29
+ return { git: { source: null, commit: commit.hash, branch: commit.branch, ...(commit.dirty ? { dirty: true } : {}) } };
30
30
  }
31
31
  /** The past graph of snapshot `snap` (its declarations and its edges, as /graph's parts give them),
32
32
  * marked by `diff` (from it to the latest), with the project's description of `now`. */
@@ -40,8 +40,8 @@ export function pastGraph(snap, edges, now, diff) {
40
40
  const meta = {
41
41
  schema: 1,
42
42
  layoutVersion: now.meta.layoutVersion,
43
- // the commit of that build, where it is known; the source is the project's now
44
- project: { ...now.meta.project, extractedAt: snap.snapshot.at, ...gitThen(now.meta.project.git, snap.snapshot.commit) },
43
+ // the commit of that build, where it is known
44
+ project: { ...now.meta.project, extractedAt: snap.snapshot.at, ...gitThen(snap.snapshot.commit) },
45
45
  modules: snap.modules.map((m) => ({ name: m.name, file: null, imports: m.imports })),
46
46
  nodes,
47
47
  projectCount: P,
@@ -40,6 +40,7 @@
40
40
  // Declarations are keyed by name, and a name can be anything Lean allows: `toString`,
41
41
  // `constructor`, even `__proto__`. Every object keyed by names here has no prototype (`byName`),
42
42
  // is read with Object.hasOwn, and is never spread or assigned into a plain object.
43
+ import { validName } from "./collab.js";
43
44
  import { applyLibrary, invalidLibrary, isLibraryOp, libraryEventOf, libraryFromJson, libraryNoChange, readLibraryEvent, renamePerson } from "./library.js";
44
45
  import { applyMove, moveIsIdle, readMove } from "./moves.js";
45
46
  export const DEFAULT_TRUSTED = ["Batteries", "Init", "Lean", "Mathlib", "Std"];
@@ -102,6 +103,19 @@ export function invalid(r) {
102
103
  return "agent";
103
104
  if (o.person !== undefined && (!validPersonId(o.person) || o.agent))
104
105
  return "person";
106
+ // an opinion recorded for someone else (ReviewSpec decision 8): a person's, for another person
107
+ if (o.for !== undefined || o.forPerson !== undefined) {
108
+ if (o.op !== "opinion")
109
+ return "only an opinion is recorded for someone else";
110
+ if (o.agent)
111
+ return "an agent records for no one";
112
+ if (typeof o.for !== "string" || !validName(o.for.trim()))
113
+ return "for";
114
+ if (o.forPerson !== undefined && !validPersonId(o.forPerson))
115
+ return "forPerson";
116
+ if (o.for.trim() === o.by.trim() || (o.forPerson !== undefined && o.forPerson === o.person))
117
+ return "an opinion of one's own is recorded as one's own";
118
+ }
105
119
  switch (o.op) {
106
120
  case "opinion":
107
121
  if (o.verdict !== "approve" && o.verdict !== "comment")
@@ -153,6 +167,12 @@ export function eventOf(d, r, toolchain, now) {
153
167
  switch (r.op) {
154
168
  case "opinion": {
155
169
  const e = { op: "opinion", name: r.name, by, ...person, verdict: r.verdict, at: now, toolchain };
170
+ // recorded for someone else (ReviewSpec decision 8)
171
+ if (r.for?.trim()) {
172
+ e.for = r.for.trim();
173
+ if (r.forPerson)
174
+ e.forPerson = r.forPerson;
175
+ }
156
176
  if (r.comment?.trim())
157
177
  e.comment = r.comment.trim();
158
178
  if (r.agent)
@@ -160,7 +180,7 @@ export function eventOf(d, r, toolchain, now) {
160
180
  // the text matters only to an opinion that can go stale, one bound to a version
161
181
  if (r.hash) {
162
182
  e.hash = r.hash;
163
- const was = (Object.hasOwn(d.opinions, r.name) ? d.opinions[r.name] : []).findLast((p) => author(p) === author(e));
183
+ const was = (Object.hasOwn(d.opinions, r.name) ? d.opinions[r.name] : []).findLast((p) => author(p) === voice(e));
164
184
  const same = was?.hash === r.hash ? judgedOn(d, r.name, was) : null;
165
185
  if (r.text !== undefined && !(same?.of === "source" && same.text === r.text))
166
186
  e.text = r.text;
@@ -212,8 +232,8 @@ export function applyEvent(d, e) {
212
232
  case "name":
213
233
  return;
214
234
  case "opinion": {
215
- const { op: _op, name, ...o } = e;
216
- (Object.hasOwn(d.opinions, name) ? d.opinions[name] : (d.opinions[name] = [])).push(o);
235
+ const { op: _op, name, for: forName, forPerson, ...o } = e;
236
+ (Object.hasOwn(d.opinions, name) ? d.opinions[name] : (d.opinions[name] = [])).push(forName === undefined ? o : recordedFor(d, o, forName, forPerson));
217
237
  return;
218
238
  }
219
239
  case "target":
@@ -256,15 +276,27 @@ function named(d, id, name) {
256
276
  if (p?.name === name)
257
277
  return;
258
278
  if (!p) {
259
- d.people[id] = { name, was: [] };
279
+ // first seen after opinions were recorded for them (decision 8): those show under this name,
280
+ // the names they were written under kept, so that a quote of one still finds it
281
+ const was = new Set();
282
+ for (const ops of Object.values(d.opinions))
283
+ for (const o of ops)
284
+ if (o.person === id && o.by !== name) {
285
+ was.add(o.by);
286
+ o.by = name;
287
+ }
288
+ d.people[id] = { name, was: [...was] };
260
289
  return;
261
290
  }
262
291
  p.was = [...p.was.filter((n) => n !== p.name && n !== name), p.name];
263
292
  p.name = name;
264
293
  for (const ops of Object.values(d.opinions))
265
- for (const o of ops)
294
+ for (const o of ops) {
266
295
  if (o.person === id)
267
296
  o.by = name;
297
+ if (o.recordedBy?.person === id)
298
+ o.recordedBy.by = name;
299
+ }
268
300
  for (const t of Object.values(d.targets))
269
301
  if (t.person === id)
270
302
  t.by = name;
@@ -351,6 +383,20 @@ export function readEvent(raw, agentLog, personLog = null) {
351
383
  }
352
384
  if (e.text !== undefined && e.statement !== undefined)
353
385
  return "a text and a statement";
386
+ // recorded for someone else (ReviewSpec decision 8): a person's line, for another person
387
+ if (o.for !== undefined || o.forPerson !== undefined) {
388
+ if (agentLog)
389
+ return "an agent's opinion recorded for someone else";
390
+ if (typeof o.for !== "string" || !validName(o.for))
391
+ return "for";
392
+ if (o.forPerson !== undefined && !validPersonId(o.forPerson))
393
+ return "forPerson";
394
+ if (o.for === by || (o.forPerson !== undefined && o.forPerson === personLog))
395
+ return "an opinion recorded for its own writer";
396
+ e.for = o.for;
397
+ if (o.forPerson !== undefined)
398
+ e.forPerson = o.forPerson;
399
+ }
354
400
  if (agentLog)
355
401
  e.agent = true;
356
402
  return e;
@@ -503,6 +549,17 @@ export function problemsText(ps) {
503
549
  function author(o) {
504
550
  return o.person ? `link ${o.person}` : (o.agent ? "agent " : "person ") + o.by;
505
551
  }
552
+ /** Whose an opinion line is: theirs it was recorded for (ReviewSpec decision 8), else its writer's. */
553
+ function voice(e) {
554
+ return e.for === undefined ? author(e) : author({ by: e.for, verdict: e.verdict, at: e.at, ...(e.forPerson ? { person: e.forPerson } : {}) });
555
+ }
556
+ /** An opinion recorded for someone else (ReviewSpec decision 8), as the review holds it: theirs —
557
+ * under their latest name where they are invited — saying who recorded it. */
558
+ function recordedFor(d, o, forName, forPerson) {
559
+ const { by, person, ...rest } = o;
560
+ const name = forPerson && Object.hasOwn(d.people, forPerson) ? d.people[forPerson].name : forName;
561
+ return { ...rest, by: name, ...(forPerson ? { person: forPerson } : {}), recordedBy: { by, ...(person ? { person } : {}) } };
562
+ }
506
563
  /** Each author's latest opinion on a declaration, people's or agents': a new one replaces their
507
564
  * earlier one. */
508
565
  function latest(d, name, agents) {