visualproof 0.1.0 → 0.2.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 (50) hide show
  1. package/dist/assets/webfonts/LICENSE-Jost.txt +93 -0
  2. package/dist/assets/webfonts/LICENSE-SourceSans3.txt +93 -0
  3. package/dist/assets/webfonts/LICENSE-SourceSerif4.txt +93 -0
  4. package/dist/assets/webfonts/jost-latin-ext-wght-normal.woff2 +0 -0
  5. package/dist/assets/webfonts/jost-latin-wght-normal.woff2 +0 -0
  6. package/dist/assets/webfonts/source-sans-3-greek-wght-italic.woff2 +0 -0
  7. package/dist/assets/webfonts/source-sans-3-greek-wght-normal.woff2 +0 -0
  8. package/dist/assets/webfonts/source-sans-3-latin-ext-wght-italic.woff2 +0 -0
  9. package/dist/assets/webfonts/source-sans-3-latin-ext-wght-normal.woff2 +0 -0
  10. package/dist/assets/webfonts/source-sans-3-latin-wght-italic.woff2 +0 -0
  11. package/dist/assets/webfonts/source-sans-3-latin-wght-normal.woff2 +0 -0
  12. package/dist/assets/webfonts/source-serif-4-greek-wght-italic.woff2 +0 -0
  13. package/dist/assets/webfonts/source-serif-4-greek-wght-normal.woff2 +0 -0
  14. package/dist/assets/webfonts/source-serif-4-latin-ext-wght-italic.woff2 +0 -0
  15. package/dist/assets/webfonts/source-serif-4-latin-ext-wght-normal.woff2 +0 -0
  16. package/dist/assets/webfonts/source-serif-4-latin-wght-italic.woff2 +0 -0
  17. package/dist/assets/webfonts/source-serif-4-latin-wght-normal.woff2 +0 -0
  18. package/dist/server/agent.js +41 -6
  19. package/dist/server/history.js +39 -0
  20. package/dist/server/historyapi.js +2 -2
  21. package/dist/server/hover.js +3 -3
  22. package/dist/server/layoutthread.js +1 -1
  23. package/dist/server/main.js +15 -3
  24. package/dist/server/mcptools.js +2 -3
  25. package/dist/server/pastdoc.js +1 -1
  26. package/dist/server/pipeline.js +5 -2
  27. package/dist/server/readonlygate.js +1 -1
  28. package/dist/server/snapshot.js +3 -2
  29. package/dist/server/usage.js +2 -2
  30. package/dist/server/view.js +7 -0
  31. package/dist/server/views.js +1 -1
  32. package/dist/shared/collab.js +94 -1
  33. package/dist/shared/doc/order.js +17 -0
  34. package/dist/shared/doc/outline.js +45 -0
  35. package/dist/shared/keys.js +1 -1
  36. package/dist/shared/moves.js +2 -1
  37. package/dist/shared/pastgraph.js +9 -1
  38. package/dist/shared/query.js +21 -13
  39. package/dist/shared/sharesnap.js +2 -1
  40. package/dist/shared/titles.js +2 -0
  41. package/dist/shared/viewgraph.js +18 -0
  42. package/dist/web/bundle/app.js +7118 -3180
  43. package/dist/web/bundle/edges.js +3 -5
  44. package/dist/web/index.html +24 -6
  45. package/dist/web/src/scene.js +17 -0
  46. package/dist/web/style.css +248 -12
  47. package/docs/agent-api/following.md +2 -2
  48. package/docs/agent-api/views.md +3 -2
  49. package/package.json +1 -1
  50. package/test/toolchains.json +2 -2
@@ -13,6 +13,9 @@ import { buildViewOffThread } from "./view.js";
13
13
  import { cacheHome } from "./dirs.js";
14
14
  /** the tool's own folder, `src/`; the repository is above it */
15
15
  export const SRC = resolve(dirname(fileURLToPath(import.meta.url)), "..");
16
+ /** the fonts the page draws an artwork's names and title in (ExportSpec decision 19): Source Sans 3
17
+ * and Source Serif 4, their Latin and Greek, and Jost, its Latin, under the SIL Open Font License */
18
+ export const WEBFONTS = join(SRC, "assets", "webfonts");
16
19
  export const REPO = resolve(SRC, "..");
17
20
  export const WEB = join(SRC, "web");
18
21
  /** The page's bundle made when the package was built (scripts/build.mjs), beside the page: an
@@ -38,8 +41,8 @@ export function bundleDir() {
38
41
  * views from an older server are rebuilt rather than served. 7: a notation unit's statement is
39
42
  * its command's text. 8: notation units carry their Lean constant, display form, what they
40
43
  * interpret and their rules. 9: a node's hash is its version, beside the kernel's
41
- * (server/versions.ts). */
42
- const VIEW_VERSION = 9;
44
+ * (server/versions.ts). 10: the project's git source and commit (server/history.ts, gitSource). */
45
+ const VIEW_VERSION = 10;
43
46
  function loadMemory(siteDir) {
44
47
  try {
45
48
  const mem = JSON.parse(readFileSync(join(siteDir, "layout-memory.json"), "utf8"));
@@ -7,7 +7,7 @@ export function readOnlyAllows(method, path) {
7
7
  const read = method === "GET" || method === "HEAD";
8
8
  if (read) {
9
9
  // the page and its files
10
- if (path === "/" || path === "/index.html" || path === "/style.css" || /^\/(app|layout|edges|pdfworker)\.js(\.map)?$/.test(path) || path.startsWith("/pdfjs/"))
10
+ if (path === "/" || path === "/index.html" || path === "/style.css" || /^\/(app|layout|edges|pdfworker)\.js(\.map)?$/.test(path) || path.startsWith("/pdfjs/") || path.startsWith("/fonts/"))
11
11
  return true;
12
12
  // the graph, its sources and Lean's answers, the review, what happened, the views, the history,
13
13
  // the library, the printed document's state and files, who the reader is and the link they
@@ -3,7 +3,7 @@
3
3
  import { readFileSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { gunzipSync, gzipSync } from "node:zlib";
6
- import { WEB, bundleDir } from "./pipeline.js";
6
+ import { WEB, WEBFONTS, bundleDir } from "./pipeline.js";
7
7
  import { lakeTextHash } from "./status.js";
8
8
  /** Text that sits inside a <script> element must not close it. */
9
9
  function scriptSafe(s) {
@@ -28,7 +28,8 @@ export function writeSnapshot(project, prep, out, label, review, views) {
28
28
  }
29
29
  }
30
30
  const b64 = (buf) => buf.toString("base64");
31
- const css = readFileSync(join(WEB, "style.css"), "utf8");
31
+ // the artwork's fonts within the page, which has no server to ask for them
32
+ const css = readFileSync(join(WEB, "style.css"), "utf8").replace(/url\("\/fonts\/([a-z0-9-]+\.woff2)"\)/g, (_, f) => `url("data:font/woff2;base64,${b64(readFileSync(join(WEBFONTS, f)))}")`);
32
33
  const js = readFileSync(join(bundleDir(), "app.js"), "utf8").replace(/\n\/\/# sourceMappingURL=.*$/m, "");
33
34
  const info = { createdAt: new Date().toISOString(), label };
34
35
  const html = readFileSync(join(WEB, "index.html"), "utf8")
@@ -11,7 +11,7 @@
11
11
  //
12
12
  // One process per module: a process cannot import twice. `jobs` run at once; import is bound by
13
13
  // memory bandwidth, and each worker holds hundreds of MB of its own besides the `.olean` files they
14
- // all map (on the showcase about 150 MB of its own, 1.2 to 1.6 GB resident with them). A module's result is cached in the
14
+ // all map (on Sphere-Packing-Lean about 150 MB of its own, 1.2 to 1.6 GB resident with them). A module's result is cached in the
15
15
  // project's cache directory under a key
16
16
  // made of its `.olean` hash, the text it was compiled from, the options Lake compiled it with, and
17
17
  // the keys of the project modules it imports: after a build only the modules whose key changed run
@@ -25,7 +25,7 @@ import { SITE_BODY, SITE_GRAMMAR, SITE_PROOF, SITE_TCB } from "../shared/graph.j
25
25
  import { USAGE, USAGE_PROGRAM, leanProgram, prune } from "./extract.js";
26
26
  import { compiledTextHash, lakeTextHash } from "./status.js";
27
27
  /** How many workers run at once unless `--jobs` says otherwise: past about a quarter of the cores,
28
- * more workers gain less and less (the showcase's cold pass on 32 cores: 320 s with 4, 195 s with
28
+ * more workers gain less and less (Sphere-Packing-Lean's cold pass on 32 cores: 320 s with 4, 195 s with
29
29
  * 8, 148 s with 16). */
30
30
  export const DEFAULT_JOBS = 8;
31
31
  /** A worker still running after this long is stopped, and its module reported as failed. */
@@ -3,6 +3,7 @@
3
3
  import { readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { LAYOUT_VERSION } from "../shared/graph.js";
6
+ import { gitSource } from "./history.js";
6
7
  import { layOutHere, layOutOffThread } from "./layoutthread.js";
7
8
  import { compiledTextHash } from "./status.js";
8
9
  import { versionBase, versions } from "./versions.js";
@@ -74,6 +75,11 @@ function viewParts(p, ex) {
74
75
  });
75
76
  return { nodes, edges };
76
77
  }
78
+ /** The project's git source and commit as it is read, where it is a git repository. */
79
+ function gitOf(root) {
80
+ const git = gitSource(root);
81
+ return git ? { git } : {};
82
+ }
77
83
  function assemble(p, ex, nodes, edges, layouts) {
78
84
  const g = ex.graph;
79
85
  const meta = {
@@ -87,6 +93,7 @@ function assemble(p, ex, nodes, edges, layouts) {
87
93
  toolchain: p.toolchain,
88
94
  extractedAt: ex.at,
89
95
  extractMs: g.stats.ms,
96
+ ...gitOf(p.root),
90
97
  },
91
98
  modules: g.modules.map((m) => {
92
99
  const file = p.files.get(m.name) ?? null;
@@ -59,7 +59,7 @@ const HOLD_MS = 60_000;
59
59
  /** the most views one private page holds */
60
60
  const MAX_HOLDS = 100;
61
61
  /** The most a board's layout memory may be when a page hands it over: room for a board of a large
62
- * project's declarations many times over (the showcase's whole board, 7,851 declarations, is
62
+ * project's declarations many times over (Sphere-Packing-Lean's whole board, 7,851 declarations, is
63
63
  * 1.8 MB; 50 MB is the owner's decision of 2026-10-14). */
64
64
  export const MAX_MEMORY_BYTES = 50 << 20;
65
65
  const isPins = (p) => {
@@ -63,6 +63,98 @@ export function colorOf(name) {
63
63
  /** the most source windows a reader pins at once (web/src/sources.ts): a few declarations side by
64
64
  * side, not a wall of them */
65
65
  export const MAX_PINNED = 6;
66
+ /** An artwork's ways of being drawn (ArtworkLook `style`). */
67
+ export const ART_STYLES = ["board", "cells", "stars"];
68
+ /** the title's size, in hundredths of the picture's height: the least, the most, the first */
69
+ export const TITLE_SIZE = { least: 3, most: 16, first: 7 };
70
+ /** the foot's size, in hundredths of the picture's height: the least, the most, the first */
71
+ export const FOOT_SIZE = { least: 0.8, most: 4, first: 1.6 };
72
+ /** the most characters a title, or the line under it, has */
73
+ export const TITLE_CHARS = 120;
74
+ /** An artwork's title or its line as given: the reader's words, at most so long, or null (the
75
+ * project's own). */
76
+ export function cleanTitle(v) {
77
+ return typeof v === "string" ? v.replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, TITLE_CHARS) : null;
78
+ }
79
+ /** An artwork's title size as given, within its bounds; the first where it is none. */
80
+ export function cleanTitleSize(v) {
81
+ return typeof v === "number" && Number.isFinite(v) ? Math.min(TITLE_SIZE.most, Math.max(TITLE_SIZE.least, Math.round(v * 2) / 2)) : TITLE_SIZE.first;
82
+ }
83
+ /** An artwork's foot size as given, within its bounds, to a tenth; the first where it is none. */
84
+ export function cleanFootSize(v) {
85
+ return typeof v === "number" && Number.isFinite(v) ? Math.min(FOOT_SIZE.most, Math.max(FOOT_SIZE.least, Math.round(v * 10) / 10)) : FOOT_SIZE.first;
86
+ }
87
+ /** What a name of an artwork's names (ArtworkLook `names`) names: a cluster, by its key, or a
88
+ * declaration, by its full name. */
89
+ export function artNameId(kind, s) {
90
+ return `${kind === "cluster" ? "c" : "d"}:${s}`;
91
+ }
92
+ /** The most an artwork's names hidden, its names shown, and its targets chosen each take in what
93
+ * presence carries, in bytes of their JSON: a heartbeat is at most 16 KB (server/main.ts), and the
94
+ * rest of where a reader is has room beside them. Names by selection are kept as what differs from
95
+ * As shown, so that a few names hidden or added are a few names carried. */
96
+ export const ART_NAMES_BYTES = 4500;
97
+ export const ART_TARGETS_BYTES = 2500;
98
+ /** What `x` adds to a list's JSON: itself, quoted, and a comma. */
99
+ const jsonBytes = (x) => new TextEncoder().encode(JSON.stringify(x)).length + 1;
100
+ /** A list of names as given, checked: strings, each once, none empty or longer than a name is (1,000
101
+ * characters), each as `ok` says; kept in their order while their JSON stays within `budget` bytes,
102
+ * the rest left out. */
103
+ export function cleanNames(v, budget, ok = () => true) {
104
+ const out = [];
105
+ const seen = new Set();
106
+ let used = 2;
107
+ for (const x of Array.isArray(v) ? v : []) {
108
+ if (typeof x !== "string" || !x || x.length > 1000 || seen.has(x) || !ok(x))
109
+ continue;
110
+ const b = jsonBytes(x);
111
+ if (used + b > budget)
112
+ break;
113
+ used += b;
114
+ seen.add(x);
115
+ out.push(x);
116
+ }
117
+ return out;
118
+ }
119
+ /** a name of an artwork's names: a cluster's or a declaration's (artNameId) */
120
+ export const isArtNameId = (s) => /^[cd]:./.test(s);
121
+ /** the most pixels a side of an artwork has */
122
+ export const ART_MAX_SIDE = 16384;
123
+ /** A place with no artwork in it: an agent's (ExportSpec decision 19, agents have no artwork). */
124
+ export function withoutArtwork(v) {
125
+ if (!v || !("artwork" in v))
126
+ return v;
127
+ const { artwork: _artwork, ...rest } = v;
128
+ return rest;
129
+ }
130
+ /** An artwork's settings as given, checked; null where they are not. */
131
+ export function cleanArtwork(v) {
132
+ if (!v || typeof v !== "object")
133
+ return null;
134
+ const o = v;
135
+ const one = (xs, x) => (xs.includes(x) ? x : null);
136
+ const side = (x) => (Number.isInteger(x) && x >= 16 && x <= ART_MAX_SIDE ? x : null);
137
+ const r = Array.isArray(o.region) && o.region.length === 4 && o.region.every((n) => typeof n === "number" && Number.isFinite(n) && Math.abs(n) < 1e9) ? o.region : null;
138
+ const w = side(o.w), h = side(o.h), scheme = one(SCHEMES, o.scheme), theme = one(["light", "dark"], o.theme);
139
+ const background = one(["theme", "transparent"], o.background), labels = one(["board", "clusters", "none", "chosen"], o.labels);
140
+ const edges = one(["all", "selection", "none"], o.edges), targets = one(["marked", "plain", "highlight", "chosen"], o.targets);
141
+ if (!r || r[2] <= 0 || r[3] <= 0 || !w || !h || !scheme || !theme || !background || !labels || !edges || !targets)
142
+ return null;
143
+ // (a proof edges' switch, said until 2026-10-15, is no more: an artwork draws none)
144
+ if (typeof o.notes !== "boolean" || typeof o.selection !== "boolean")
145
+ return null;
146
+ // the source, said since 2026-10-15: off where a page does not say it. The names and targets chosen
147
+ // and the key marks' switch, said since the evening of that day: none chosen where a page does not
148
+ // say them, and the key marks as the targets drew them before (with their stars, unless As any)
149
+ const namesFrom = o.namesFrom === "none" ? "none" : "board";
150
+ const hiddenNames = cleanNames(o.hiddenNames, ART_NAMES_BYTES, isArtNameId), shownNames = cleanNames(o.shownNames, ART_NAMES_BYTES, isArtNameId);
151
+ const shownTargets = cleanNames(o.shownTargets, ART_TARGETS_BYTES);
152
+ const keys = typeof o.keys === "boolean" ? o.keys : targets !== "plain";
153
+ // the style and the title, said since that night: as the board, the project's own; the foot's
154
+ // size, said since later that night: the first
155
+ 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] };
157
+ }
66
158
  /** The legend's schemes (CanvasSpec, "Colour schemes"). */
67
159
  export const SCHEMES = ["kind", "status"];
68
160
  /** Activity's filters: the kind of event, and whose (web/src/activity.ts). */
@@ -143,6 +235,7 @@ export function cleanView(v) {
143
235
  ...(typeof lost === "boolean" ? { lostFound: { open: lost } } : {}),
144
236
  ...(scheme ? { scheme } : {}),
145
237
  ...(typeof o.edges === "boolean" ? { edges: o.edges } : {}),
146
- ...(typeof o.proofEdges === "boolean" ? { proofEdges: o.proofEdges } : {}),
238
+ ...(typeof o.linear === "boolean" ? { linear: o.linear } : {}),
239
+ ...(o.artwork === null ? { artwork: null } : cleanArtwork(o.artwork) ? { artwork: cleanArtwork(o.artwork) } : {}),
147
240
  };
148
241
  }
@@ -28,6 +28,23 @@ export function boardOrder(xs, at) {
28
28
  });
29
29
  return keyed.map((e) => e.x);
30
30
  }
31
+ /** Where `a` goes on the board to come between `prev` and `next` in the board's order (`prev` read
32
+ * before it, so lower; `next` after, higher), all three of one cluster: the linear form's drop
33
+ * (ExportSpec decision 18, web/src/linear.ts). At a height between theirs where they are across,
34
+ * where `a` is across; on their row, between them, where they are level; below the first, above
35
+ * the last, clear of it. Null where neither is given. */
36
+ export function spotBetween(a, prev, next) {
37
+ if (prev && next) {
38
+ if (Math.abs(prev.y - next.y) >= 1)
39
+ return [a.x, (prev.y + next.y) / 2];
40
+ return [(prev.x + next.x) / 2, prev.y];
41
+ }
42
+ if (next)
43
+ return [a.x, next.y + next.r + a.r + 2.5];
44
+ if (prev)
45
+ return [a.x, prev.y - prev.r - a.r - 2.5];
46
+ return null;
47
+ }
31
48
  /** The outline of the document of `plan` (of graph `g`) in the order of the board `spots` describes. */
32
49
  export function orderDoc(plan, g, spots) {
33
50
  // the declarations the document prints, by name: where an attached note goes
@@ -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 —
@@ -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 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) {
27
+ if (!commit || commit.guessed)
28
+ return { git: undefined };
29
+ return { git: { source: now?.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; the source is the project's now
44
+ project: { ...now.meta.project, extractedAt: snap.snapshot.at, ...gitThen(now.meta.project.git, 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) {
@@ -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
  }