visualproof 0.0.0-stage → 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.
- package/LICENSE +202 -0
- package/README.md +99 -2
- package/dist/assets/fonts/JuliaMono-Regular.ttf +0 -0
- package/dist/assets/fonts/JuliaMono-RegularItalic.ttf +0 -0
- package/dist/assets/fonts/JuliaMono-SemiBold.ttf +0 -0
- package/dist/assets/fonts/LICENSE-JuliaMono.txt +93 -0
- package/dist/assets/latex/visualproof.sty +308 -0
- package/dist/assets/webfonts/LICENSE-Jost.txt +93 -0
- package/dist/assets/webfonts/LICENSE-SourceSans3.txt +93 -0
- package/dist/assets/webfonts/LICENSE-SourceSerif4.txt +93 -0
- package/dist/assets/webfonts/jost-latin-ext-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/jost-latin-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-greek-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-greek-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-latin-ext-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-latin-ext-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-latin-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-latin-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-greek-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-greek-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-latin-ext-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-latin-ext-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-latin-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-latin-wght-normal.woff2 +0 -0
- package/dist/extractor/Extract.lean +1180 -0
- package/dist/extractor/Usage.lean +900 -0
- package/dist/mcp/visualproof-mcp.mjs +476 -0
- package/dist/server/access.js +95 -0
- package/dist/server/agent.js +3270 -0
- package/dist/server/agentdocs.js +35 -0
- package/dist/server/agentlibrary.js +202 -0
- package/dist/server/agentnotes.js +701 -0
- package/dist/server/agentviews.js +703 -0
- package/dist/server/askfollow.js +286 -0
- package/dist/server/boardspots.js +159 -0
- package/dist/server/dirs.js +175 -0
- package/dist/server/doctor.js +186 -0
- package/dist/server/events.js +198 -0
- package/dist/server/export.js +420 -0
- package/dist/server/extract.js +204 -0
- package/dist/server/fetchurl.js +321 -0
- package/dist/server/figures.js +158 -0
- package/dist/server/history.js +780 -0
- package/dist/server/historyapi.js +304 -0
- package/dist/server/hover.js +595 -0
- package/dist/server/identity.js +114 -0
- package/dist/server/invites.js +241 -0
- package/dist/server/layoutthread.js +51 -0
- package/dist/server/library.js +600 -0
- package/dist/server/libraryroute.js +211 -0
- package/dist/server/live.js +1101 -0
- package/dist/server/lostfound.js +199 -0
- package/dist/server/main.js +1406 -0
- package/dist/server/mcptools.js +496 -0
- package/dist/server/pastdoc.js +73 -0
- package/dist/server/pipeline.js +159 -0
- package/dist/server/poppler.js +251 -0
- package/dist/server/presence.js +252 -0
- package/dist/server/project.js +222 -0
- package/dist/server/prompt.js +53 -0
- package/dist/server/readonly.js +152 -0
- package/dist/server/readonlygate.js +123 -0
- package/dist/server/renames.js +268 -0
- package/dist/server/review.js +334 -0
- package/dist/server/reviewapi.js +188 -0
- package/dist/server/snapshot.js +50 -0
- package/dist/server/sourcestore.js +473 -0
- package/dist/server/status.js +360 -0
- package/dist/server/talk.js +476 -0
- package/dist/server/tokens.js +221 -0
- package/dist/server/usage.js +501 -0
- package/dist/server/versions.js +82 -0
- package/dist/server/view.js +164 -0
- package/dist/server/views.js +1167 -0
- package/dist/shared/askfollow.js +46 -0
- package/dist/shared/board.js +332 -0
- package/dist/shared/build.js +2 -0
- package/dist/shared/collab.js +241 -0
- package/dist/shared/comment.js +285 -0
- package/dist/shared/context.js +157 -0
- package/dist/shared/doc/authors.js +98 -0
- package/dist/shared/doc/build.js +358 -0
- package/dist/shared/doc/latex.js +455 -0
- package/dist/shared/doc/markdown.js +159 -0
- package/dist/shared/doc/model.js +37 -0
- package/dist/shared/doc/order.js +124 -0
- package/dist/shared/doc/outline.js +45 -0
- package/dist/shared/doc/proof.js +272 -0
- package/dist/shared/doc/sections.js +93 -0
- package/dist/shared/doc/settings.js +75 -0
- package/dist/shared/edgecodec.js +102 -0
- package/dist/shared/editor.js +64 -0
- package/dist/shared/excerpt.js +87 -0
- package/dist/shared/graph.js +16 -0
- package/dist/shared/items.js +92 -0
- package/dist/shared/keys.js +203 -0
- package/dist/shared/layout.js +926 -0
- package/dist/shared/lexer.js +130 -0
- package/dist/shared/library.js +711 -0
- package/dist/shared/lostfound.js +150 -0
- package/dist/shared/moves.js +244 -0
- package/dist/shared/notecard.js +295 -0
- package/dist/shared/noteplace.js +505 -0
- package/dist/shared/palette.js +46 -0
- package/dist/shared/pastgraph.js +103 -0
- package/dist/shared/places.js +80 -0
- package/dist/shared/projectnotes.js +244 -0
- package/dist/shared/query.js +818 -0
- package/dist/shared/review.js +642 -0
- package/dist/shared/reviewqueue.js +333 -0
- package/dist/shared/ring.js +366 -0
- package/dist/shared/scope.js +255 -0
- package/dist/shared/scopeedit.js +81 -0
- package/dist/shared/search.js +191 -0
- package/dist/shared/separate.js +178 -0
- package/dist/shared/sharesnap.js +281 -0
- package/dist/shared/sticky.js +501 -0
- package/dist/shared/timeline.js +73 -0
- package/dist/shared/titles.js +300 -0
- package/dist/shared/viewgraph.js +157 -0
- package/dist/shared/views.js +859 -0
- package/dist/web/bundle/app.js +52671 -0
- package/dist/web/bundle/edges.js +407 -0
- package/dist/web/bundle/layout.js +1010 -0
- package/dist/web/bundle/pdfworker.js +48322 -0
- package/dist/web/index.html +257 -0
- package/dist/web/src/motion.js +430 -0
- package/dist/web/src/past.js +224 -0
- package/dist/web/src/scene.js +804 -0
- package/dist/web/style.css +2548 -0
- package/docs/agent-api/README.md +91 -0
- package/docs/agent-api/advice.md +108 -0
- package/docs/agent-api/export.md +120 -0
- package/docs/agent-api/following.md +140 -0
- package/docs/agent-api/history.md +142 -0
- package/docs/agent-api/library.md +65 -0
- package/docs/agent-api/mcp.md +104 -0
- package/docs/agent-api/notes.md +93 -0
- package/docs/agent-api/reading.md +166 -0
- package/docs/agent-api/requests.md +94 -0
- package/docs/agent-api/talking.md +100 -0
- package/docs/agent-api/tcb.md +135 -0
- package/docs/agent-api/views.md +125 -0
- package/docs/agent-api/words.md +56 -0
- package/package.json +39 -4
- package/test/toolchains.json +5 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// What a comment on a declaration is (ReviewSpec, "Comments", decided 2026-10-09): plain text, kept
|
|
2
|
+
// in the logs as written and given to agents as written, that the page reads as light Markdown —
|
|
3
|
+
// quotes, code, bold, italics, declarations' names in backticks (web/src/markdown.ts renders it).
|
|
4
|
+
// Two kinds of quote carry a reference the page follows:
|
|
5
|
+
//
|
|
6
|
+
// - a quote of an earlier opinion is a Markdown quote whose first line, the attribution, links to
|
|
7
|
+
// it: `> [**Alice** (2026-10-09)](opinion:Fermat42.comm#Alice@2026-10-09T08:12:30.123Z):`;
|
|
8
|
+
// - a quote of source code is a fenced Lean block under a header that links to the lines:
|
|
9
|
+
// `[Mathlib/NumberTheory/FLT/Four.lean:34–37](source:Fermat42.comm#L34-L37)`.
|
|
10
|
+
//
|
|
11
|
+
// Both are ordinary Markdown links with a scheme only the page interprets, so that an export or an
|
|
12
|
+
// agent reads sensible Markdown and the logs stay text. A part of a reference is percent-encoded
|
|
13
|
+
// only where it would end the link or the part, so that names stay readable.
|
|
14
|
+
//
|
|
15
|
+
// This is the format alone, with no page in it: the references both ways, the blocks a comment is
|
|
16
|
+
// made of, what a quote of an opinion keeps, a quote of source lines, and where either goes in the
|
|
17
|
+
// text being written; tested in test/comment.test.ts.
|
|
18
|
+
/** the longest comment, in UTF-16 units, as the server takes it (server/reviewapi.ts,
|
|
19
|
+
* server/agent.ts, `invalid` in shared/review.ts) */
|
|
20
|
+
export const MAX_COMMENT = 10000;
|
|
21
|
+
/** a quote of an opinion keeps at most this many lines of its comment, and this many characters */
|
|
22
|
+
export const QUOTE_LINES = 4;
|
|
23
|
+
export const QUOTE_CHARS = 300;
|
|
24
|
+
/** what would end a link or a part of a reference: encoded where it is part of one */
|
|
25
|
+
const UNSAFE = /[\s()[\]#@%<>"'`\\\p{Cc}]/gu;
|
|
26
|
+
const encode = (s) => s.replace(UNSAFE, (c) => [...new TextEncoder().encode(c)].map((b) => `%${b.toString(16).toUpperCase().padStart(2, "0")}`).join(""));
|
|
27
|
+
const decode = (s) => {
|
|
28
|
+
try {
|
|
29
|
+
return decodeURIComponent(s);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
/** `opinion:<name>#<by>@<at>`: the opinion `by` gave on `name` at `at`. */
|
|
36
|
+
export function opinionRef(name, by, at) {
|
|
37
|
+
return `opinion:${encode(name)}#${encode(by)}@${encode(at)}`;
|
|
38
|
+
}
|
|
39
|
+
/** `source:<name>#L<from>-L<to>` (one line: `#L<from>`): lines of the file declaration `name` is
|
|
40
|
+
* in, from 1. */
|
|
41
|
+
export function sourceRef(name, from, to) {
|
|
42
|
+
return `source:${encode(name)}#L${from}${to > from ? `-L${to}` : ""}`;
|
|
43
|
+
}
|
|
44
|
+
/** what a time in a reference looks like: as the logs write it */
|
|
45
|
+
const ISO = /^\d{4}-\d\d-\d\dT\d\d:\d\d(?::\d\d(?:\.\d+)?)?(?:Z|[+-]\d\d:\d\d)$/;
|
|
46
|
+
const fine = (s, max) => s !== null && s.length > 0 && s.length <= max && !/\p{Cc}/u.test(s);
|
|
47
|
+
/** A reference read back: an opinion's or source lines', or null for anything else (another
|
|
48
|
+
* scheme, a part that does not decode, a time that is not one, lines that are not). */
|
|
49
|
+
export function parseRef(url) {
|
|
50
|
+
const op = /^opinion:([^#@\s]+)#([^#@\s]+)@([^#@\s]+)$/.exec(url);
|
|
51
|
+
if (op) {
|
|
52
|
+
const [name, by, at] = [decode(op[1]), decode(op[2]), decode(op[3])];
|
|
53
|
+
if (!fine(name, 2000) || !fine(by, 120) || !fine(at, 40) || !ISO.test(at))
|
|
54
|
+
return null;
|
|
55
|
+
return { kind: "opinion", name, by, at };
|
|
56
|
+
}
|
|
57
|
+
const src = /^source:([^#@\s]+)#L([1-9]\d{0,6})(?:-L([1-9]\d{0,6}))?$/.exec(url);
|
|
58
|
+
if (src) {
|
|
59
|
+
const name = decode(src[1]);
|
|
60
|
+
const from = Number(src[2]);
|
|
61
|
+
const to = src[3] === undefined ? from : Number(src[3]);
|
|
62
|
+
if (!fine(name, 2000) || to < from)
|
|
63
|
+
return null;
|
|
64
|
+
return { kind: "source", name, from, to };
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
/** A reference written out again: the one form the page puts in its own attributes. */
|
|
69
|
+
export function refText(r) {
|
|
70
|
+
return r.kind === "opinion" ? opinionRef(r.name, r.by, r.at) : sourceRef(r.name, r.from, r.to);
|
|
71
|
+
}
|
|
72
|
+
const FENCE = /^ {0,3}(`{3,})([^`]*)$/;
|
|
73
|
+
const QUOTE = /^ {0,3}>/;
|
|
74
|
+
/** a line that is a link and nothing else: a code quote's header, a quote's attribution */
|
|
75
|
+
const LINK_LINE = /^\[([^\]\n]+)\]\(((?:opinion|source):[^()\s]+)\):?\s*$/;
|
|
76
|
+
/** A code quote's header: the line, its label and where it links to; null for any other line. */
|
|
77
|
+
function sourceHead(line) {
|
|
78
|
+
const m = LINK_LINE.exec(line);
|
|
79
|
+
const ref = m ? parseRef(m[2]) : null;
|
|
80
|
+
return m && ref?.kind === "source" ? { line, label: m[1], ref } : null;
|
|
81
|
+
}
|
|
82
|
+
/** A quote's attribution, its first line: who said it, linking to the opinion; null for any
|
|
83
|
+
* other line. */
|
|
84
|
+
export function attribution(line) {
|
|
85
|
+
const m = LINK_LINE.exec(line);
|
|
86
|
+
const ref = m ? parseRef(m[2]) : null;
|
|
87
|
+
return m && ref?.kind === "opinion" ? { label: m[1], ref } : null;
|
|
88
|
+
}
|
|
89
|
+
/** The blocks of a comment, as Markdown reads them: a fence runs to a line of at least as many
|
|
90
|
+
* backticks (or to the end), a quote is the lines that start with `>`, a paragraph runs to a
|
|
91
|
+
* blank line or to either; a line linking to source lines right above a fence is that fence's
|
|
92
|
+
* header. Blank lines only separate. */
|
|
93
|
+
export function blocks(text) {
|
|
94
|
+
const lines = text.split(/\r?\n/);
|
|
95
|
+
const out = [];
|
|
96
|
+
const opens = (k) => k < lines.length && FENCE.test(lines[k]);
|
|
97
|
+
const fence = (k, head) => {
|
|
98
|
+
const m = FENCE.exec(lines[k]);
|
|
99
|
+
const n = m[1].length;
|
|
100
|
+
const body = [];
|
|
101
|
+
let j = k + 1;
|
|
102
|
+
for (; j < lines.length; j++) {
|
|
103
|
+
const c = /^ {0,3}(`{3,})\s*$/.exec(lines[j]);
|
|
104
|
+
if (c && c[1].length >= n)
|
|
105
|
+
break;
|
|
106
|
+
body.push(lines[j]);
|
|
107
|
+
}
|
|
108
|
+
const closed = j < lines.length;
|
|
109
|
+
out.push({ kind: "fence", lang: m[2].trim().split(/\s+/)[0] ?? "", lines: body, closed, head });
|
|
110
|
+
return closed ? j + 1 : j;
|
|
111
|
+
};
|
|
112
|
+
let k = 0;
|
|
113
|
+
while (k < lines.length) {
|
|
114
|
+
const l = lines[k];
|
|
115
|
+
if (opens(k)) {
|
|
116
|
+
k = fence(k, null);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const head = opens(k + 1) ? sourceHead(l) : null;
|
|
120
|
+
if (head) {
|
|
121
|
+
k = fence(k + 1, head);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (QUOTE.test(l)) {
|
|
125
|
+
const q = [];
|
|
126
|
+
for (; k < lines.length && QUOTE.test(lines[k]); k++)
|
|
127
|
+
q.push(lines[k].replace(/^ {0,3}> ?/, ""));
|
|
128
|
+
out.push({ kind: "quote", lines: q });
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (!l.trim()) {
|
|
132
|
+
k++;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const p = [];
|
|
136
|
+
for (; k < lines.length && lines[k].trim() && !opens(k) && !QUOTE.test(lines[k]) && !(opens(k + 1) && sourceHead(lines[k])); k++)
|
|
137
|
+
p.push(lines[k]);
|
|
138
|
+
out.push({ kind: "para", lines: p });
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
// ---------------------------------------------------------------- quoting
|
|
143
|
+
/** The longest run of backticks in `lines`: a fence around them is longer, so that none of them
|
|
144
|
+
* closes it. */
|
|
145
|
+
const longestTicks = (lines) => Math.max(0, ...lines.map((l) => Math.max(0, ...(l.match(/`+/g) ?? []).map((t) => t.length))));
|
|
146
|
+
/** `s` cut to at most `n` characters, at a space where one is near the end, never inside a
|
|
147
|
+
* surrogate pair. */
|
|
148
|
+
function cut(s, n) {
|
|
149
|
+
let at = s.lastIndexOf(" ", n);
|
|
150
|
+
if (at < n * 0.6)
|
|
151
|
+
at = n;
|
|
152
|
+
if (/[\uD800-\uDBFF]/.test(s[at - 1] ?? ""))
|
|
153
|
+
at--;
|
|
154
|
+
return s.slice(0, at).trimEnd();
|
|
155
|
+
}
|
|
156
|
+
/** What a quote keeps of a comment: the commenter's own words — the quotes in it left out (the
|
|
157
|
+
* opinions they quote are a click away from it), a code quote's code too, under its header —
|
|
158
|
+
* at most QUOTE_LINES lines and QUOTE_CHARS characters, "…" where it was cut. A fence it cuts
|
|
159
|
+
* into is closed. */
|
|
160
|
+
export function quoteBody(comment) {
|
|
161
|
+
const out = [];
|
|
162
|
+
let lines = 0;
|
|
163
|
+
let chars = 0;
|
|
164
|
+
let cutShort = false;
|
|
165
|
+
const full = () => lines >= QUOTE_LINES || chars >= QUOTE_CHARS;
|
|
166
|
+
/** one line kept, cut where the characters run out */
|
|
167
|
+
const take = (l) => {
|
|
168
|
+
if (full()) {
|
|
169
|
+
cutShort = true;
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
const left = QUOTE_CHARS - chars;
|
|
173
|
+
const s = l.length > left ? `${cut(l, left)}…` : l;
|
|
174
|
+
if (s !== l)
|
|
175
|
+
cutShort = true;
|
|
176
|
+
out.push(s);
|
|
177
|
+
lines++;
|
|
178
|
+
chars += s.length;
|
|
179
|
+
return true;
|
|
180
|
+
};
|
|
181
|
+
for (const b of blocks(comment)) {
|
|
182
|
+
if (b.kind === "quote")
|
|
183
|
+
continue;
|
|
184
|
+
if (full()) {
|
|
185
|
+
cutShort = true;
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
if (out.length)
|
|
189
|
+
out.push("");
|
|
190
|
+
if (b.kind === "para")
|
|
191
|
+
for (const l of b.lines)
|
|
192
|
+
take(l);
|
|
193
|
+
else if (b.head)
|
|
194
|
+
take(b.head.line);
|
|
195
|
+
else {
|
|
196
|
+
const ticks = "`".repeat(Math.max(3, longestTicks(b.lines) + 1));
|
|
197
|
+
out.push(`${ticks}${b.lang}`);
|
|
198
|
+
for (const l of b.lines)
|
|
199
|
+
if (!take(l))
|
|
200
|
+
break;
|
|
201
|
+
out.push(ticks);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
while (out.length && !out[out.length - 1])
|
|
205
|
+
out.pop();
|
|
206
|
+
if (cutShort && !out[out.length - 1]?.endsWith("…"))
|
|
207
|
+
out.push("…");
|
|
208
|
+
return out.length ? out : ["…"];
|
|
209
|
+
}
|
|
210
|
+
/** A quote of an opinion on declaration `name`, to write a comment under: its attribution — who,
|
|
211
|
+
* an agent marked so, and the day — linking to it, then what quoteBody keeps of its comment. */
|
|
212
|
+
export function quoteOpinion(name, o) {
|
|
213
|
+
// what would break the link or the bold is left out of the name shown; the reference has it whole
|
|
214
|
+
const who = o.by.replace(/[[\]*`\\\n]/g, "").trim() || "?";
|
|
215
|
+
const head = `[**${who}** (${o.agent ? "agent, " : ""}${o.at.slice(0, 10)})](${opinionRef(name, o.by, o.at)}):`;
|
|
216
|
+
return [head, ...quoteBody(o.comment)].map((l) => (l ? `> ${l}` : ">")).join("\n");
|
|
217
|
+
}
|
|
218
|
+
/** The lines `first`–`last` (indices into `lines`, from 0) of the code a source window shows — the
|
|
219
|
+
* text of declaration `name`, which starts at line `start` (from 0) of `file` — quoted: a header
|
|
220
|
+
* giving the file and the lines, linking to them, over a fenced Lean block of the lines as they
|
|
221
|
+
* are written. */
|
|
222
|
+
export function quoteCode(q) {
|
|
223
|
+
const body = q.lines.slice(q.first, q.last + 1);
|
|
224
|
+
const from = q.start + q.first + 1;
|
|
225
|
+
const to = q.start + q.last + 1;
|
|
226
|
+
const ticks = "`".repeat(Math.max(3, longestTicks(body) + 1));
|
|
227
|
+
const label = `${q.file.replace(/\[/g, "(").replace(/\]/g, ")")}:${from}${to > from ? `–${to}` : ""}`;
|
|
228
|
+
return `[${label}](${sourceRef(q.name, from, to)})\n${ticks}lean\n${body.join("\n")}\n${ticks}`;
|
|
229
|
+
}
|
|
230
|
+
/** The lines of `text` a selection from offset `from` to `to` touches, whole (indices from 0), or
|
|
231
|
+
* null when it holds nothing. Only line breaks count: a line wrapped on the screen is one line.
|
|
232
|
+
* A selection that starts on a line's break, or ends just after one (a line selected with its
|
|
233
|
+
* break, as a triple click does), does not take the line it only touches there. */
|
|
234
|
+
export function selectedLines(text, from, to) {
|
|
235
|
+
if (to < from)
|
|
236
|
+
[from, to] = [to, from];
|
|
237
|
+
from = Math.max(0, Math.min(from, text.length));
|
|
238
|
+
to = Math.max(0, Math.min(to, text.length));
|
|
239
|
+
if (to <= from)
|
|
240
|
+
return null;
|
|
241
|
+
const lineAt = (off) => {
|
|
242
|
+
let n = 0;
|
|
243
|
+
for (let k = 0; k < off; k++)
|
|
244
|
+
if (text.charCodeAt(k) === 10)
|
|
245
|
+
n++;
|
|
246
|
+
return n;
|
|
247
|
+
};
|
|
248
|
+
let first = lineAt(from);
|
|
249
|
+
let last = lineAt(to);
|
|
250
|
+
if (text[from] === "\n" && from + 1 < to)
|
|
251
|
+
first++;
|
|
252
|
+
if (last > first && text[to - 1] === "\n")
|
|
253
|
+
last--;
|
|
254
|
+
return { first, last: Math.max(first, last) };
|
|
255
|
+
}
|
|
256
|
+
/** Where quoted lines `from`–`to` of a file (from 1) are in a declaration's text now — `lines`,
|
|
257
|
+
* which start at line `start` of the file (from 0) — as indices into `lines`: where they were, if
|
|
258
|
+
* the text there reads as `quoted` does; else where `quoted` is found in it (the file changed above
|
|
259
|
+
* it since); else what is left of where they were; null when nothing is. */
|
|
260
|
+
export function locateLines(lines, start, from, to, quoted) {
|
|
261
|
+
const a = from - 1 - start;
|
|
262
|
+
const b = to - 1 - start;
|
|
263
|
+
if (quoted?.length) {
|
|
264
|
+
const at = (k) => quoted.every((q, d) => lines[k + d]?.trimEnd() === q.trimEnd());
|
|
265
|
+
if (a >= 0 && at(a))
|
|
266
|
+
return [a, a + quoted.length - 1];
|
|
267
|
+
for (let k = 0; k + quoted.length <= lines.length; k++)
|
|
268
|
+
if (at(k))
|
|
269
|
+
return [k, k + quoted.length - 1];
|
|
270
|
+
}
|
|
271
|
+
const x = Math.max(a, 0);
|
|
272
|
+
const y = Math.min(b, lines.length - 1);
|
|
273
|
+
return x <= y ? [x, y] : null;
|
|
274
|
+
}
|
|
275
|
+
/** `block` put into a comment being written, `value`, in place of what is selected there
|
|
276
|
+
* (`from`–`to`), on lines of its own: a blank line before it where text comes before, and after
|
|
277
|
+
* it a paragraph of its own where the caret goes, for the reader to write on — apart from what
|
|
278
|
+
* comes after, if anything does. The text, and where the caret goes. */
|
|
279
|
+
export function insertBlock(value, from, to, block) {
|
|
280
|
+
const before = value.slice(0, from);
|
|
281
|
+
const rest = value.slice(to).replace(/^\n+/, "");
|
|
282
|
+
const lead = !before || before.endsWith("\n\n") ? "" : before.endsWith("\n") ? "\n" : "\n\n";
|
|
283
|
+
const head = `${before}${lead}${block}\n\n`;
|
|
284
|
+
return { value: head + (rest ? `\n\n${rest}` : ""), caret: head.length };
|
|
285
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// The context a declaration is read in (TCBSpec.md, decision 4): what is in force where it is
|
|
2
|
+
// written — its namespaces and sections, what is opened, the `variable`s, universes, local and
|
|
3
|
+
// scoped notation, instances and attributes, options — shown with its text as a block of Lean, and
|
|
4
|
+
// recorded with it as the text an opinion judged. The page (the source window, the text an opinion
|
|
5
|
+
// is compared with), the server (server/reviewapi.ts, which binds what an opinion judged) and the
|
|
6
|
+
// agents' API (server/agent.ts) all read it here, so all three say the same.
|
|
7
|
+
//
|
|
8
|
+
// It comes from the usage pass (server/usage.ts) where that elaborated the module: the elaborator's
|
|
9
|
+
// own scope before the command, in the same shape (FileContext, `exact`), with the opens resolved and
|
|
10
|
+
// the variables Lean included in the declaration marked. Elsewhere — a module the pass has not
|
|
11
|
+
// reached or could not elaborate, a file changed since it was compiled — it is read from the file's
|
|
12
|
+
// text (shared/scope.ts, `contextAt`), without elaborating it. `readContext` is the one place that
|
|
13
|
+
// chooses.
|
|
14
|
+
import { excerptOf } from "./excerpt.js";
|
|
15
|
+
import { contextAt } from "./scope.js";
|
|
16
|
+
/** The elaborator's context for the unit `name`, when the pass has one for its module and the text
|
|
17
|
+
* read now (Lake hash `hash`) is the one it elaborated; null otherwise. */
|
|
18
|
+
export function exactContext(m, name, hash) {
|
|
19
|
+
if (!m || !hash || m.hash !== hash)
|
|
20
|
+
return null;
|
|
21
|
+
const d = m.decls[name];
|
|
22
|
+
const c = d && m.contexts[d[0]];
|
|
23
|
+
if (!c)
|
|
24
|
+
return null;
|
|
25
|
+
const used = new Set(d.slice(1));
|
|
26
|
+
return { ...c, exact: true, variables: c.variables.map((v, k) => (used.has(k) ? { ...v, used: true } : v)) };
|
|
27
|
+
}
|
|
28
|
+
/** The context in force at line `upto` (0-based) of a file, where a declaration's text starts: the
|
|
29
|
+
* elaborator's (`exactContext`) where there is one, else read from the text. */
|
|
30
|
+
export function readContext(lines, upto, exact = null) {
|
|
31
|
+
if (exact)
|
|
32
|
+
return exact;
|
|
33
|
+
// the block is the text as written: which namespaces of the graph an `open` brings in is not
|
|
34
|
+
// part of it, so it is the same wherever it is read
|
|
35
|
+
return contextAt(lines, upto, () => false);
|
|
36
|
+
}
|
|
37
|
+
/** The context as commands of Lean, in the order they are written. */
|
|
38
|
+
export function contextLines(c) {
|
|
39
|
+
const out = [];
|
|
40
|
+
const inn = (x) => (x.in ? " in" : "");
|
|
41
|
+
for (const b of c.blocks) {
|
|
42
|
+
const text = b.kind === "namespace" ? `namespace ${b.name}`
|
|
43
|
+
: b.kind === "mutual" ? "mutual"
|
|
44
|
+
: `${b.noncomputable ? "noncomputable " : ""}section${b.name ? ` ${b.name}` : ""}`;
|
|
45
|
+
out.push({ line: b.line, kind: b.kind, text });
|
|
46
|
+
}
|
|
47
|
+
for (const o of c.opens)
|
|
48
|
+
out.push({ line: o.line, kind: "open", text: o.text + inn(o) });
|
|
49
|
+
for (const v of c.variables)
|
|
50
|
+
out.push({ line: v.line, kind: "variable", text: `variable ${v.text}${inn(v)}`, binds: binderNames(v.text), ...(v.used ? { used: true } : {}) });
|
|
51
|
+
for (const u of c.universes)
|
|
52
|
+
out.push({ line: u.line, kind: "universe", text: `universe ${u.text}` });
|
|
53
|
+
for (const o of c.options)
|
|
54
|
+
out.push({ line: o.line, kind: "option", text: `set_option ${o.text}${inn(o)}` });
|
|
55
|
+
for (const x of c.local)
|
|
56
|
+
out.push({ line: x.line, kind: "local", text: x.text + inn(x) });
|
|
57
|
+
// a block opens before what is written in it on the same line (never, in practice)
|
|
58
|
+
const rank = (k) => (k === "namespace" || k === "section" || k === "mutual" ? 0 : 1);
|
|
59
|
+
return out.sort((a, b) => a.line - b.line || rank(a.kind) - rank(b.kind));
|
|
60
|
+
}
|
|
61
|
+
/** The context as one block of Lean, a command per line; "" where nothing is in force. */
|
|
62
|
+
export function contextText(c) {
|
|
63
|
+
return contextLines(c)
|
|
64
|
+
.map((l) => l.text)
|
|
65
|
+
.join("\n");
|
|
66
|
+
}
|
|
67
|
+
/** The text an opinion records as judged: the context block, a blank line, then the declaration's
|
|
68
|
+
* text; just the text where no context is in force. */
|
|
69
|
+
export function withContext(context, text) {
|
|
70
|
+
return context ? `${context}\n\n${text}` : text;
|
|
71
|
+
}
|
|
72
|
+
/** What an opinion on the declaration `name` judges, in its file's `lines`: its context and its
|
|
73
|
+
* text, as the source window shows them. `edited`: the file is not the text that was compiled,
|
|
74
|
+
* and the declaration is found by name in it; null when it is not there. `exact`: its context as
|
|
75
|
+
* the elaborator has it (`exactContext`), when there is one. */
|
|
76
|
+
export function judgedText(lines, edited, name, range, exact = null) {
|
|
77
|
+
const at = excerptOf(lines, edited, name, range);
|
|
78
|
+
if (!at)
|
|
79
|
+
return null;
|
|
80
|
+
const text = lines.slice(at.start, at.end + 1).join("\n");
|
|
81
|
+
return { text: withContext(contextText(readContext(lines, at.start, edited ? null : exact)), text), edited };
|
|
82
|
+
}
|
|
83
|
+
/** The names a `variable` command's binders bind: `(x y : α)`, `{α : Type}`, `[inst : C α]`
|
|
84
|
+
* give theirs; an anonymous instance binder `[C α]` gives none. */
|
|
85
|
+
export function binderNames(binders) {
|
|
86
|
+
const names = [];
|
|
87
|
+
const open = "({[⦃", close = ")}]⦄";
|
|
88
|
+
let depth = 0, start = -1;
|
|
89
|
+
for (let k = 0; k < binders.length; k++) {
|
|
90
|
+
const ch = binders[k];
|
|
91
|
+
if (open.includes(ch)) {
|
|
92
|
+
if (depth++ === 0)
|
|
93
|
+
start = k + 1;
|
|
94
|
+
}
|
|
95
|
+
else if (close.includes(ch) && depth > 0) {
|
|
96
|
+
if (--depth === 0) {
|
|
97
|
+
const inside = binders.slice(start, k);
|
|
98
|
+
const colon = topColon(inside);
|
|
99
|
+
if (colon >= 0)
|
|
100
|
+
for (const w of inside.slice(0, colon).trim().split(/\s+/))
|
|
101
|
+
if (w && w !== "_" && IDENT.test(w))
|
|
102
|
+
names.push(w);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return names;
|
|
107
|
+
}
|
|
108
|
+
const IDENT = /^[\p{L}_][\p{L}\p{N}_'!?₀-₉ₐ-ₜ.]*$/u;
|
|
109
|
+
/** Where the binder's ` : ` is, outside any bracket; -1 when there is none. */
|
|
110
|
+
function topColon(s) {
|
|
111
|
+
let depth = 0;
|
|
112
|
+
for (let k = 0; k < s.length; k++) {
|
|
113
|
+
const ch = s[k];
|
|
114
|
+
if ("({[⦃".includes(ch))
|
|
115
|
+
depth++;
|
|
116
|
+
else if (")}]⦄".includes(ch))
|
|
117
|
+
depth--;
|
|
118
|
+
else if (ch === ":" && depth === 0 && s[k + 1] !== "=")
|
|
119
|
+
return k;
|
|
120
|
+
}
|
|
121
|
+
return -1;
|
|
122
|
+
}
|
|
123
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
124
|
+
/** Which `variable` lines of a context the declaration uses: where the context is the
|
|
125
|
+
* elaborator's, those Lean included (`used`); elsewhere those binding a name that occurs in its
|
|
126
|
+
* statement or its text, and the anonymous instance binders about such a name — a hint for the
|
|
127
|
+
* eye, read from the words alone: Lean includes a variable by what the elaborated declaration
|
|
128
|
+
* mentions, which a word can only suggest. */
|
|
129
|
+
export function usedVariables(lines, texts, exact = false) {
|
|
130
|
+
if (exact)
|
|
131
|
+
return new Set(lines.flatMap((l, k) => (l.used ? [k] : [])));
|
|
132
|
+
const occurs = (name) => {
|
|
133
|
+
const re = new RegExp(`(?<![\\p{L}\\p{N}_'!?.])${escapeRe(name)}(?![\\p{L}\\p{N}_'!?])`, "u");
|
|
134
|
+
return texts.some((t) => re.test(t));
|
|
135
|
+
};
|
|
136
|
+
const used = new Set();
|
|
137
|
+
const usedNames = new Set();
|
|
138
|
+
lines.forEach((l, k) => {
|
|
139
|
+
if (l.kind !== "variable" || !l.binds?.length)
|
|
140
|
+
return;
|
|
141
|
+
const hit = l.binds.filter(occurs);
|
|
142
|
+
if (hit.length) {
|
|
143
|
+
used.add(k);
|
|
144
|
+
for (const n of hit)
|
|
145
|
+
usedNames.add(n);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
// `variable [DecidableEq α]` is about `α`: used when `α` is
|
|
149
|
+
lines.forEach((l, k) => {
|
|
150
|
+
if (l.kind !== "variable" || l.binds?.length || used.has(k))
|
|
151
|
+
return;
|
|
152
|
+
for (const w of l.text.split(/[^\p{L}\p{N}_'!?₀-₉ₐ-ₜ.]+/u))
|
|
153
|
+
if (usedNames.has(w))
|
|
154
|
+
used.add(k);
|
|
155
|
+
});
|
|
156
|
+
return used;
|
|
157
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// The author line of a view's exported document (ExportSpec decision 15, the owner's, 2026-10-09):
|
|
2
|
+
// the people who left a trace on the view, in the order of their first trace. A trace is
|
|
3
|
+
// - making the view: its maker as its record has them, once it is kept (a view nobody keeps is
|
|
4
|
+
// only what its query looks like, ViewSpec decision 6: opening it makes nothing), and whoever
|
|
5
|
+
// saved it for everyone (the timeline's `created`);
|
|
6
|
+
// - renaming, rescoping, laying it out afresh, keeping it at a past build, adding a note of the
|
|
7
|
+
// project to it or taking one out of it: the timeline's view events for its id;
|
|
8
|
+
// - a pin dropped on it that is still kept (who dropped each, server/views.ts);
|
|
9
|
+
// - a note written or edited on it: the notes it holds (their writers and last edits) and the
|
|
10
|
+
// timeline's notes written and edited on it, a note deleted since among them; and the items it
|
|
11
|
+
// shows (AttachmentSpec decision 17, the owner's of 2026-10-11: every note of the project on the
|
|
12
|
+
// Project; their writers and last edits);
|
|
13
|
+
// - the review of what it prints (the owner's decision, 2026-10-10): an opinion given on one of the
|
|
14
|
+
// declarations of the project it holds, an approval or a comment, its key marks and its target
|
|
15
|
+
// mark (`reviewTraces`).
|
|
16
|
+
// People only: what an agent did is no trace (its opinions are advice), nor what a private reader
|
|
17
|
+
// arranged (recorded without a name, CollabSpec decision 8; their review keeps it), nor what the
|
|
18
|
+
// server itself did (its moves of renamed declarations, under its own name). Someone invited by a personal link is named as they are called now (their link's
|
|
19
|
+
// id is on what they did). The timeline keeps 90 days, so a rename or a re-layout older than that is
|
|
20
|
+
// forgotten; a pin, a note and the maker are kept with the view.
|
|
21
|
+
import { CLUSTER_MODES } from "../places.js";
|
|
22
|
+
/** Everything left on view `v` that may make an author: its record, who dropped each of its pins
|
|
23
|
+
* (`pins`, the server's), the timeline's events, and the items it shows (`shown`, shared/noteplace.ts,
|
|
24
|
+
* itemsShownOn). Events from before the view was made are another view's of
|
|
25
|
+
* the same id. Unsorted. */
|
|
26
|
+
export function viewTraces(v, pins, events, shown = []) {
|
|
27
|
+
const mark = (t) => ({ by: t.by, ...(t.agent ? { agent: true } : {}), ...(t.person ? { person: t.person } : {}), at: t.at });
|
|
28
|
+
const ours = [];
|
|
29
|
+
for (const e of events) {
|
|
30
|
+
if ((e.kind === "view" || (e.kind === "note" && (e.what === "created" || e.what === "edited"))) && e.view === v.id && e.at >= v.at)
|
|
31
|
+
ours.push(mark(e));
|
|
32
|
+
}
|
|
33
|
+
for (const n of [...(v.notes ?? []), ...shown]) {
|
|
34
|
+
ours.push(mark(n));
|
|
35
|
+
if (n.edited)
|
|
36
|
+
ours.push(mark(n.edited));
|
|
37
|
+
}
|
|
38
|
+
for (const mode of CLUSTER_MODES)
|
|
39
|
+
for (const kind of ["nodes", "clusters"]) {
|
|
40
|
+
const set = pins?.[mode]?.[kind];
|
|
41
|
+
if (!set)
|
|
42
|
+
continue;
|
|
43
|
+
for (const [key, who] of Object.entries(set))
|
|
44
|
+
if (Object.hasOwn(v.pins[mode][kind], key))
|
|
45
|
+
ours.push(mark(who));
|
|
46
|
+
}
|
|
47
|
+
if (!v.kept || !v.by)
|
|
48
|
+
return ours;
|
|
49
|
+
// a record made before makers were marked says nothing of an agent: one whose name acted on this
|
|
50
|
+
// view as an agent, and is no person's link, is taken for that agent
|
|
51
|
+
const agent = !!v.agent || (!v.person && ours.some((t) => t.agent && t.by === v.by));
|
|
52
|
+
return [mark({ by: v.by, agent, person: v.person, at: v.at }), ...ours];
|
|
53
|
+
}
|
|
54
|
+
/** What the review says of the declarations a view holds (`held`: their names, on the graph its board
|
|
55
|
+
* is of, with `d` the review as that board reads it): each opinion given on one, an approval or a
|
|
56
|
+
* comment, each key mark on one, and its target mark. Unsorted. */
|
|
57
|
+
export function reviewTraces(d, held) {
|
|
58
|
+
const mark = (t) => ({ by: t.by, ...(t.agent ? { agent: true } : {}), ...(t.person ? { person: t.person } : {}), at: t.at });
|
|
59
|
+
const names = new Set(held);
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const name of names) {
|
|
62
|
+
if (Object.hasOwn(d.opinions, name))
|
|
63
|
+
for (const o of d.opinions[name])
|
|
64
|
+
out.push(mark(o));
|
|
65
|
+
if (Object.hasOwn(d.targets, name))
|
|
66
|
+
out.push(mark(d.targets[name]));
|
|
67
|
+
}
|
|
68
|
+
for (const marks of Object.values(d.keys))
|
|
69
|
+
for (const [name, k] of Object.entries(marks))
|
|
70
|
+
if (names.has(name))
|
|
71
|
+
out.push(mark(k));
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
/** The people among `traces`, by the name each goes by now (`nameOf`: the name of someone invited
|
|
75
|
+
* by a personal link, by its id; null where it is not known, and the name the trace carries is
|
|
76
|
+
* used), once each, in the order of their first trace (a tie in the order given). `notPeople`: names
|
|
77
|
+
* that are no one's (the server's own). */
|
|
78
|
+
export function authorsOf(traces, nameOf, notPeople = []) {
|
|
79
|
+
const order = traces.map((t, k) => ({ t, k })).sort((a, b) => (a.t.at < b.t.at ? -1 : a.t.at > b.t.at ? 1 : a.k - b.k));
|
|
80
|
+
const out = [];
|
|
81
|
+
const seen = new Set();
|
|
82
|
+
for (const { t } of order) {
|
|
83
|
+
if (!t.by || t.agent)
|
|
84
|
+
continue;
|
|
85
|
+
if (!t.person && notPeople.includes(t.by))
|
|
86
|
+
continue;
|
|
87
|
+
const name = ((t.person && nameOf(t.person)) || t.by).trim();
|
|
88
|
+
if (!name || seen.has(name))
|
|
89
|
+
continue;
|
|
90
|
+
seen.add(name);
|
|
91
|
+
out.push(name);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
/** The author line as it prints: "Alice", "Alice and Bob", "Alice, Bob and Carol"; "" for nobody. */
|
|
96
|
+
export function authorLine(names) {
|
|
97
|
+
return names.length > 1 ? `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}` : (names[0] ?? "");
|
|
98
|
+
}
|