visualproof 0.1.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 (94) hide show
  1. package/README.md +40 -26
  2. package/dist/assets/webfonts/LICENSE-Jost.txt +93 -0
  3. package/dist/assets/webfonts/LICENSE-Noto.txt +95 -0
  4. package/dist/assets/webfonts/LICENSE-SourceSans3.txt +93 -0
  5. package/dist/assets/webfonts/LICENSE-SourceSerif4.txt +93 -0
  6. package/dist/assets/webfonts/jost-latin-ext-wght-normal.woff2 +0 -0
  7. package/dist/assets/webfonts/jost-latin-wght-normal.woff2 +0 -0
  8. package/dist/assets/webfonts/metrics.json +1 -0
  9. package/dist/assets/webfonts/source-sans-3-greek-wght-italic.woff2 +0 -0
  10. package/dist/assets/webfonts/source-sans-3-greek-wght-normal.woff2 +0 -0
  11. package/dist/assets/webfonts/source-sans-3-latin-ext-wght-italic.woff2 +0 -0
  12. package/dist/assets/webfonts/source-sans-3-latin-ext-wght-normal.woff2 +0 -0
  13. package/dist/assets/webfonts/source-sans-3-latin-wght-italic.woff2 +0 -0
  14. package/dist/assets/webfonts/source-sans-3-latin-wght-normal.woff2 +0 -0
  15. package/dist/assets/webfonts/source-serif-4-greek-wght-italic.woff2 +0 -0
  16. package/dist/assets/webfonts/source-serif-4-greek-wght-normal.woff2 +0 -0
  17. package/dist/assets/webfonts/source-serif-4-latin-ext-wght-italic.woff2 +0 -0
  18. package/dist/assets/webfonts/source-serif-4-latin-ext-wght-normal.woff2 +0 -0
  19. package/dist/assets/webfonts/source-serif-4-latin-wght-italic.woff2 +0 -0
  20. package/dist/assets/webfonts/source-serif-4-latin-wght-normal.woff2 +0 -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/server/agent.js +125 -92
  33. package/dist/server/agentdocs.js +1 -1
  34. package/dist/server/artcmd.js +335 -0
  35. package/dist/server/history.js +39 -0
  36. package/dist/server/historyapi.js +2 -2
  37. package/dist/server/hover.js +3 -3
  38. package/dist/server/layoutthread.js +1 -1
  39. package/dist/server/live.js +6 -0
  40. package/dist/server/main.js +151 -11
  41. package/dist/server/mcptools.js +6 -4
  42. package/dist/server/pastdoc.js +1 -1
  43. package/dist/server/pipeline.js +18 -9
  44. package/dist/server/readonlygate.js +2 -2
  45. package/dist/server/review.js +4 -1
  46. package/dist/server/reviewapi.js +35 -1
  47. package/dist/server/snapshot.js +13 -3
  48. package/dist/server/usage.js +2 -2
  49. package/dist/server/views.js +25 -1
  50. package/dist/shared/artfonts.js +81 -0
  51. package/dist/shared/artrecipe.js +316 -0
  52. package/dist/shared/collab.js +99 -1
  53. package/dist/shared/declfind.js +95 -0
  54. package/dist/shared/doc/order.js +17 -0
  55. package/dist/shared/doc/outline.js +45 -0
  56. package/dist/shared/keys.js +1 -1
  57. package/dist/shared/lostfound.js +1 -0
  58. package/dist/shared/moves.js +2 -1
  59. package/dist/shared/pastgraph.js +9 -1
  60. package/dist/shared/query.js +21 -13
  61. package/dist/shared/review.js +62 -5
  62. package/dist/shared/sharesnap.js +2 -1
  63. package/dist/shared/titles.js +2 -0
  64. package/dist/shared/viewgraph.js +18 -0
  65. package/dist/web/bundle/app.js +7709 -3222
  66. package/dist/web/bundle/edges.js +4 -6
  67. package/dist/web/index.html +24 -6
  68. package/dist/web/src/agentmark.js +14 -0
  69. package/dist/web/src/artwork.js +1000 -0
  70. package/dist/web/src/bundles.js +151 -0
  71. package/dist/web/src/canvas.js +3694 -0
  72. package/dist/web/src/drag.js +61 -0
  73. package/dist/web/src/edgequeue.js +204 -0
  74. package/dist/web/src/gl/gl.js +123 -0
  75. package/dist/web/src/gl/renderer.js +451 -0
  76. package/dist/web/src/halo.js +95 -0
  77. package/dist/web/src/highlight.js +108 -0
  78. package/dist/web/src/overview.js +393 -0
  79. package/dist/web/src/reviewqueue.js +250 -0
  80. package/dist/web/src/scene.js +17 -0
  81. package/dist/web/src/selection.js +272 -0
  82. package/dist/web/src/status.js +80 -0
  83. package/dist/web/src/visitors.js +318 -0
  84. package/dist/web/src/visits.js +174 -0
  85. package/dist/web/style.css +255 -12
  86. package/docs/agent-api/README.md +1 -1
  87. package/docs/agent-api/export.md +41 -2
  88. package/docs/agent-api/following.md +2 -2
  89. package/docs/agent-api/history.md +2 -1
  90. package/docs/agent-api/mcp.md +1 -1
  91. package/docs/agent-api/reading.md +2 -1
  92. package/docs/agent-api/views.md +3 -2
  93. package/package.json +3 -1
  94. package/test/toolchains.json +2 -2
@@ -0,0 +1,45 @@
1
+ // The document's outline (ExportSpec decision 18, the linear form): what the document of a view
2
+ // prints, in its order, without the text — its sections, numbered as the PDF numbers them, the
3
+ // declarations and the notes in each, and the notes attached to a declaration after it. The page
4
+ // draws the board as this outline (web/src/linear.ts); it is made from the same plan and the same
5
+ // order the document is (shared/doc/sections.ts, shared/doc/order.ts), so that the two agree.
6
+ import { orderDoc } from "./order.js";
7
+ /** A note's first words: its first line, Markdown marks taken out, at most 60 characters. */
8
+ export function firstWords(text) {
9
+ const line = text.split("\n").find((l) => l.trim()) ?? "";
10
+ const plain = line.replace(/[*_`#>]+/g, "").replace(/\s+/g, " ").trim();
11
+ return plain.length > 60 ? `${plain.slice(0, 59).trimEnd()}…` : plain;
12
+ }
13
+ /** The outline of the document of `plan` (of graph `g`) in the order of the board `spots` gives. */
14
+ export function outlineOf(plan, g, spots) {
15
+ const order = orderDoc(plan, g, spots);
16
+ const note = (k) => {
17
+ const n = spots.notes[k];
18
+ return { t: "note", id: n.note.id, kind: n.kind, words: firstWords(n.note.text) };
19
+ };
20
+ const item = (p, prefix, at) => {
21
+ if (p.t === "note")
22
+ return note(p.k);
23
+ if (p.t === "decl") {
24
+ const ks = order.attached.get(p.i);
25
+ return { t: "decl", name: g.nodes[p.i].name, ...(ks?.length ? { notes: ks.map(note) } : {}) };
26
+ }
27
+ return section(p.s, prefix, at);
28
+ };
29
+ // numbered as the PDF numbers its headings (shared/doc/latex.ts): the first three levels, each
30
+ // within the one above; a paragraph-level heading has none
31
+ const section = (o, prefix, at) => {
32
+ const p = o.plan;
33
+ at.k++;
34
+ const number = p.level < 4 ? (prefix ? `${prefix}.${at.k}` : `${at.k}`) : null;
35
+ const inner = { k: 0 };
36
+ const items = o.items.map((x) => item(x, p.level < 3 ? (number ?? "") : "", inner));
37
+ return { t: "section", key: p.key, keys: [...p.keys], title: p.title, number, level: p.level, items };
38
+ };
39
+ const top = { k: 0 };
40
+ return {
41
+ items: order.items.map((x) => item(x, "", top)),
42
+ count: { declarations: plan.count, sections: plan.sectionCount },
43
+ ...(spots.fresh ? { fresh: true } : {}),
44
+ };
45
+ }
@@ -21,7 +21,7 @@
21
21
  //
22
22
  // A review holds many members and a target a dozen key declarations: after one walk over the
23
23
  // target's TCB, each walk reads the edges of the members and of what it passes through, never the
24
- // whole graph's (the showcase's `Dim24.MainTheorem`: its TCB of 226 in 72 ms, its keys in 1 ms).
24
+ // whole graph's (Sphere-Packing-Lean's `Dim24.MainTheorem`: its TCB of 226 in 72 ms, its keys in 1 ms).
25
25
  import { keyMarks, statusOf } from "./review.js";
26
26
  import { TCB_SITES } from "./reviewqueue.js";
27
27
  /** The key declarations of `root` among the members of its review (`order`, the queue's order —
@@ -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)) } : {}),
@@ -209,7 +209,8 @@ export function renameInQuery(q, from, to) {
209
209
  }
210
210
  case "tcb":
211
211
  case "uses":
212
- case "usedBy": {
212
+ case "usedBy":
213
+ case "refs": {
213
214
  if (q.of === "selection")
214
215
  return q;
215
216
  if (Array.isArray(q.of)) {
@@ -21,6 +21,13 @@ 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 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
+ if (!commit || commit.guessed)
28
+ return { git: undefined };
29
+ return { git: { source: null, commit: commit.hash, branch: commit.branch, ...(commit.dirty ? { dirty: true } : {}) } };
30
+ }
24
31
  /** The past graph of snapshot `snap` (its declarations and its edges, as /graph's parts give them),
25
32
  * marked by `diff` (from it to the latest), with the project's description of `now`. */
26
33
  export function pastGraph(snap, edges, now, diff) {
@@ -33,7 +40,8 @@ export function pastGraph(snap, edges, now, diff) {
33
40
  const meta = {
34
41
  schema: 1,
35
42
  layoutVersion: now.meta.layoutVersion,
36
- project: { ...now.meta.project, extractedAt: snap.snapshot.at },
43
+ // the commit of that build, where it is known
44
+ project: { ...now.meta.project, extractedAt: snap.snapshot.at, ...gitThen(snap.snapshot.commit) },
37
45
  modules: snap.modules.map((m) => ({ name: m.name, file: null, imports: m.imports })),
38
46
  nodes,
39
47
  projectCount: P,
@@ -8,7 +8,7 @@
8
8
  //
9
9
  // The text form, left to right:
10
10
  //
11
- // all | selection | decl(A, B) | tcb(A) | uses(A, B) | usedBy(selection)
11
+ // all | selection | decl(A, B) | tcb(A) | uses(A, B) | usedBy(selection) | refs(A)
12
12
  // tcb(ns(Fermat42) except external) a closure of the set a query holds
13
13
  // ns(Fermat42) declarations whose name is under the prefix
14
14
  // file(Mathlib.NumberTheory) declarations of the modules under the prefix
@@ -27,7 +27,7 @@ import { adviceOf, keyMarks, STANDARD_AXIOMS, statusOf, trusted, under } from ".
27
27
  export const KINDS = ["theorem", "def", "abbrev", "instance", "structure", "class", "inductive", "opaque", "axiom", "quot", "constructor", "recursor", "notation", "unknown"];
28
28
  export const STATUSES = ["unreviewed", "approved", "commented", "stale"];
29
29
  const VERDICTS = ["approve", "comment"];
30
- const CLOSURES = ["tcb", "uses", "usedBy"];
30
+ const CLOSURES = ["tcb", "uses", "usedBy", "refs"];
31
31
  const PLAIN = ["all", "selection", "trusted", "external", "sorry", "nonstandard"];
32
32
  const COMBINATORS = ["and", "or", "except"];
33
33
  /** words for the same thing, read as the first */
@@ -38,10 +38,11 @@ export const LIGHT = {
38
38
  plain: { op: "selection" },
39
39
  tcb: { op: "tcb", of: "selection" },
40
40
  deps: { op: "uses", of: "selection" },
41
+ refs: { op: "refs", of: "selection" },
41
42
  apps: { op: "usedBy", of: "selection" },
42
43
  };
43
- /** Which closure each closure word is, as the graph computes it (shared/viewgraph.ts). */
44
- const CLOSURE_MODE = { tcb: "tcb", uses: "deps", usedBy: "apps" };
44
+ /** Which walk of the graph each closure word is (shared/viewgraph.ts); `refs` is one step. */
45
+ const CLOSURE_MODE = { tcb: "tcb", uses: "deps", usedBy: "apps", refs: "refs" };
45
46
  // ---------------------------------------------------------------- how large a query may be
46
47
  /** How large a query may be: one set of bounds for its text and its JSON form, checked when either
47
48
  * is read (parseQuery, cleanQuery), so that what one form takes the other takes too — a scope
@@ -61,7 +62,7 @@ export const QUERY_LIMITS = {
61
62
  names: 2_000,
62
63
  /** characters of one name or prefix */
63
64
  name: 2_000,
64
- /** closures (tcb, uses, usedBy), each a walk of the graph; each target of a `key(…)` counts as
65
+ /** closures (tcb, uses, usedBy, refs), each a walk of the graph; each target of a `key(…)` counts as
65
66
  * one too, since its review set is walked to keep only the marks still in it. `key(targets)`
66
67
  * counts as one: how many targets there are is the project's, not the query's, and every
67
68
  * `key(targets)` of a query shares the same walks (evaluateIn) */
@@ -88,6 +89,7 @@ export function beyondLimits(q) {
88
89
  case "tcb":
89
90
  case "uses":
90
91
  case "usedBy":
92
+ case "refs":
91
93
  closures++;
92
94
  if (x.of !== "selection" && !Array.isArray(x.of)) {
93
95
  terms++;
@@ -131,7 +133,7 @@ export function beyondLimits(q) {
131
133
  if (longest > L.name)
132
134
  return `a name in the query is longer than ${L.name.toLocaleString("en")} characters`;
133
135
  if (closures > L.closures)
134
- return `the query has ${closures} closures (tcb, uses, usedBy), more than ${L.closures}`;
136
+ return `the query has ${closures} closures (tcb, uses, usedBy, refs), more than ${L.closures}`;
135
137
  const length = printQuery(q).length;
136
138
  if (length > L.text)
137
139
  return `the query is ${length.toLocaleString("en")} characters long as printed, more than ${L.text.toLocaleString("en")}`;
@@ -286,7 +288,7 @@ function parse(text) {
286
288
  }
287
289
  const w = keyword(t);
288
290
  if (w === null)
289
- throw new QueryError(t.t === "string" ? "a quoted name stands only inside decl(…), tcb(…), uses(…) or usedBy(…)" : `${t.t} is out of place`, t.at);
291
+ throw new QueryError(t.t === "string" ? "a quoted name stands only inside decl(…), tcb(…), uses(…), usedBy(…) or refs(…)" : `${t.t} is out of place`, t.at);
290
292
  if (++terms > QUERY_LIMITS.terms)
291
293
  throw new QueryError(`the query has more than ${QUERY_LIMITS.terms.toLocaleString("en")} parts`, t.at);
292
294
  k++;
@@ -338,7 +340,7 @@ function parse(text) {
338
340
  return { op: "status", statuses: choose(args(), STATUSES, "status") };
339
341
  if (w === "advice")
340
342
  return { op: "advice", verdicts: choose(args(), VERDICTS, "advice") };
341
- throw new QueryError(`${text1(t)} is not a word of the query language (all, tcb, uses, usedBy, decl, key, ns, file, trusted, external, kind, status, advice, sorry, nonstandard)`, t.at);
343
+ throw new QueryError(`${text1(t)} is not a word of the query language (all, tcb, uses, usedBy, refs, decl, key, ns, file, trusted, external, kind, status, advice, sorry, nonstandard)`, t.at);
342
344
  };
343
345
  const expr = () => {
344
346
  let q = atom();
@@ -376,7 +378,8 @@ export function printQuery(q) {
376
378
  return `key(${q.of === "targets" ? "targets" : q.of.map((t) => (t === "targets" ? JSON.stringify(t) : arg(t))).join(", ")})`;
377
379
  case "tcb":
378
380
  case "uses":
379
- case "usedBy": {
381
+ case "usedBy":
382
+ case "refs": {
380
383
  if (q.of === "selection" || Array.isArray(q.of))
381
384
  return `${q.op}(${q.of === "selection" ? "selection" : q.of.map(arg).join(", ")})`;
382
385
  // a query of one plain word would read as a name
@@ -461,7 +464,8 @@ function readJson(x, why = false) {
461
464
  return names(o.of) ? { op: "key", of: [...o.of] } : null;
462
465
  case "tcb":
463
466
  case "uses":
464
- case "usedBy": {
467
+ case "usedBy":
468
+ case "refs": {
465
469
  if (o.of === "selection")
466
470
  return { op: o.op, of: "selection" };
467
471
  if (o.of && typeof o.of === "object" && !Array.isArray(o.of)) {
@@ -531,7 +535,7 @@ export function namesIn(q) {
531
535
  x.names.forEach((n) => out.add(n));
532
536
  else if (x.op === "key" && x.of !== "targets")
533
537
  x.of.forEach((n) => out.add(n));
534
- else if (x.op === "tcb" || x.op === "uses" || x.op === "usedBy") {
538
+ else if (x.op === "tcb" || x.op === "uses" || x.op === "usedBy" || x.op === "refs") {
535
539
  if (Array.isArray(x.of))
536
540
  x.of.forEach((n) => out.add(n));
537
541
  else if (x.of !== "selection")
@@ -549,7 +553,7 @@ export function namesIn(q) {
549
553
  export function readsReview(q) {
550
554
  if (q.op === "trusted" || q.op === "status" || q.op === "advice" || q.op === "key")
551
555
  return true;
552
- if ((q.op === "tcb" || q.op === "uses" || q.op === "usedBy") && q.of !== "selection" && !Array.isArray(q.of))
556
+ if ((q.op === "tcb" || q.op === "uses" || q.op === "usedBy" || q.op === "refs") && q.of !== "selection" && !Array.isArray(q.of))
553
557
  return readsReview(q.of);
554
558
  if (q.op === "and" || q.op === "or" || q.op === "except")
555
559
  return readsReview(q.a) || readsReview(q.b);
@@ -563,6 +567,7 @@ export function withSelection(q, names) {
563
567
  case "tcb":
564
568
  case "uses":
565
569
  case "usedBy":
570
+ case "refs":
566
571
  if (q.of === "selection")
567
572
  return { op: q.op, of: [...names] };
568
573
  return Array.isArray(q.of) ? q : { op: q.op, of: withSelection(q.of, names) };
@@ -647,6 +652,8 @@ export function describe(q) {
647
652
  return `TCB of ${roots(q.of)}`;
648
653
  case "uses":
649
654
  return `what ${roots(q.of)} uses`;
655
+ case "refs":
656
+ return `what ${roots(q.of)} uses directly`;
650
657
  case "usedBy":
651
658
  return `what uses ${roots(q.of)}`;
652
659
  case "ns":
@@ -759,7 +766,8 @@ function evaluateIn(q, ctx, walks) {
759
766
  return out;
760
767
  case "tcb":
761
768
  case "uses":
762
- case "usedBy": {
769
+ case "usedBy":
770
+ case "refs": {
763
771
  const key = `${q.op}\u0000${q.of === "selection" ? "\u0001" : Array.isArray(q.of) ? JSON.stringify(q.of) : printQuery(q.of)}`;
764
772
  let walked = walks.get(key);
765
773
  if (!walked) {
@@ -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) {
@@ -105,7 +105,8 @@ export const SNAPSHOT_FORMS = {
105
105
  lostFound: same("lostFound"),
106
106
  colouring: same("scheme"),
107
107
  edges: same("edges"),
108
- proofEdges: same("proofEdges"),
108
+ linear: same("linear"),
109
+ artwork: same("artwork"),
109
110
  };
110
111
  /** The snapshot of where a page is (`v`, as presence carries it: the registry's parts, read by the
111
112
  * page), as a link carries it after `snap=`. */
@@ -224,6 +224,8 @@ function said(q, N) {
224
224
  return `TCB of ${roots(q.of, N)}`;
225
225
  case "uses":
226
226
  return `what ${roots(q.of, N)} uses`;
227
+ case "refs":
228
+ return `what ${roots(q.of, N)} uses directly`;
227
229
  case "usedBy":
228
230
  return `what uses ${roots(q.of, N)}`;
229
231
  case "ns":
@@ -9,6 +9,7 @@ export const MODE_SITES = {
9
9
  plain: ALL_SITES,
10
10
  tcb: SITE_STMT | SITE_BODY,
11
11
  deps: ALL_SITES,
12
+ refs: ALL_SITES,
12
13
  apps: ALL_SITES,
13
14
  };
14
15
  export class Graph {
@@ -69,6 +70,20 @@ export class Graph {
69
70
  isExt(i) {
70
71
  return i >= this.projectCount;
71
72
  }
73
+ /** `from` and what is one edge from it, along edges whose sites meet `sites`. */
74
+ step(from, sites, dir) {
75
+ const seen = new Uint8Array(this.n);
76
+ const start = dir === "out" ? this.outStart : this.inStart;
77
+ const adj = dir === "out" ? this.outDst : this.inSrc;
78
+ const site = dir === "out" ? this.outSite : this.inSite;
79
+ for (const u of from) {
80
+ seen[u] = 1;
81
+ for (let e = start[u]; e < start[u + 1]; e++)
82
+ if (site[e] & sites)
83
+ seen[adj[e]] = 1;
84
+ }
85
+ return seen;
86
+ }
72
87
  /** Everything reachable from `from` (included) along edges whose sites meet `sites`. */
73
88
  reach(from, sites, dir) {
74
89
  const seen = new Uint8Array(this.n);
@@ -108,6 +123,9 @@ export class Graph {
108
123
  return this.reach(focus, MODE_SITES.tcb, "out");
109
124
  case "deps":
110
125
  return this.reach(focus, ALL_SITES, "out");
126
+ // Direct: what it uses directly, one step (ViewSpec decision 20, the owner's decision of 2026-10-14)
127
+ case "refs":
128
+ return this.step(focus, ALL_SITES, "out");
111
129
  case "apps":
112
130
  return this.reach(focus, ALL_SITES, "in");
113
131
  }