visualproof 0.0.0-stage → 0.1.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/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 +3235 -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 +741 -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 +1394 -0
- package/dist/server/mcptools.js +497 -0
- package/dist/server/pastdoc.js +73 -0
- package/dist/server/pipeline.js +156 -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 +49 -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 +157 -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 +148 -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 +107 -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 +243 -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 +95 -0
- package/dist/shared/places.js +80 -0
- package/dist/shared/projectnotes.js +244 -0
- package/dist/shared/query.js +810 -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 +280 -0
- package/dist/shared/sticky.js +501 -0
- package/dist/shared/timeline.js +73 -0
- package/dist/shared/titles.js +298 -0
- package/dist/shared/viewgraph.js +139 -0
- package/dist/shared/views.js +859 -0
- package/dist/web/bundle/app.js +48733 -0
- package/dist/web/bundle/edges.js +409 -0
- package/dist/web/bundle/layout.js +1010 -0
- package/dist/web/bundle/pdfworker.js +48322 -0
- package/dist/web/index.html +239 -0
- package/dist/web/src/motion.js +430 -0
- package/dist/web/src/past.js +224 -0
- package/dist/web/src/scene.js +787 -0
- package/dist/web/style.css +2312 -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 +124 -0
- package/docs/agent-api/words.md +56 -0
- package/package.json +39 -4
- package/test/toolchains.json +5 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
// The review (ReviewSpec.md): reviewers' opinions on declarations, each bound to one version of
|
|
2
|
+
// the declaration by its content hash, and the targets whose TCBs are being reviewed. What is
|
|
3
|
+
// stored, how the review is read back from it, and what the page reads off it. No consensus is
|
|
4
|
+
// decided here: a status is a summary of what reviewers said.
|
|
5
|
+
//
|
|
6
|
+
// What is stored (decided 2026-10-08): one append-only log per writer, each line one event — an
|
|
7
|
+
// opinion, a target marked or taken off, a prefix trusted or no longer trusted, a key declaration
|
|
8
|
+
// of a target marked or unmarked (ExportSpec decision 1) — carrying who wrote it and when. The
|
|
9
|
+
// review is what replaying every log gives (`replay`): one writer's events in the order of their
|
|
10
|
+
// file, different writers' by time. Each writer appends to a file of their own, so two never write
|
|
11
|
+
// the same one. `ReviewData` is that replay, the state the server, the page and the agents' API
|
|
12
|
+
// read; server/review.ts keeps the files.
|
|
13
|
+
//
|
|
14
|
+
// An agent's opinion is advice (AgentSpec decision 2): it is kept in the same list, marked
|
|
15
|
+
// `agent`, and nothing that summarises the review reads it — `current`, and so every status, mark
|
|
16
|
+
// and progress, is people's opinions only; `advice` is the agents'. Whatever else an agent does at
|
|
17
|
+
// level full (AgentSpec decision 9) — a target, a prefix trusted, a key declaration — counts as a
|
|
18
|
+
// person's would, and is marked `agent` too, so that the page always says it was an agent's. Every
|
|
19
|
+
// line of an agent's log is marked, and no line of a person's.
|
|
20
|
+
//
|
|
21
|
+
// Someone invited by a personal link (CollabSpec decision 9, 2026-10-09) is the link, not a name:
|
|
22
|
+
// their events go to one log named by the link's id, every line of it carrying the id (`person`)
|
|
23
|
+
// beside the name it was written under, and a rename appends a `name` line. The replay keeps, per
|
|
24
|
+
// id, the name its latest line gives (`people`), and shows everything that id wrote under it, so a
|
|
25
|
+
// rename never makes a new writer, and the log needs nothing but itself to say who is who.
|
|
26
|
+
//
|
|
27
|
+
// An explanation of a declaration was an event too, until the owner removed explanations on
|
|
28
|
+
// 2026-10-08 (ExportSpec decision 2): what a declaration is belongs in its docstring, and why a
|
|
29
|
+
// reviewer thinks it right in their opinion's comment. So was a checkpoint of a target's review,
|
|
30
|
+
// until the owner removed checkpoints on 2026-10-09 (ReviewSpec decision 4): an opinion goes stale
|
|
31
|
+
// by itself when what it judged changes, what newly enters a TCB shows as not yet reviewed, the
|
|
32
|
+
// timeline records each target's TCB changes, and the exported document records a target as it
|
|
33
|
+
// stands at a build. A log may still hold such a line: the replay skips it (`readEvent`) without
|
|
34
|
+
// calling it a problem, so that its writer writes on.
|
|
35
|
+
//
|
|
36
|
+
// Since 2026-10-09 a move (HistorySpec.md) carries what was said of one declaration to another, and
|
|
37
|
+
// says where one is another renamed: shared/moves.ts replays it. Since 2026-10-11 the library's
|
|
38
|
+
// files and the links into them are events too (AttachmentSpec.md): shared/library.ts replays them.
|
|
39
|
+
//
|
|
40
|
+
// Declarations are keyed by name, and a name can be anything Lean allows: `toString`,
|
|
41
|
+
// `constructor`, even `__proto__`. Every object keyed by names here has no prototype (`byName`),
|
|
42
|
+
// is read with Object.hasOwn, and is never spread or assigned into a plain object.
|
|
43
|
+
import { applyLibrary, invalidLibrary, isLibraryOp, libraryEventOf, libraryFromJson, libraryNoChange, readLibraryEvent, renamePerson } from "./library.js";
|
|
44
|
+
import { applyMove, moveIsIdle, readMove } from "./moves.js";
|
|
45
|
+
export const DEFAULT_TRUSTED = ["Batteries", "Init", "Lean", "Mathlib", "Std"];
|
|
46
|
+
/** The trusted entry that stands for everything outside the project, whatever its module (no Lean
|
|
47
|
+
* name starts with "@", so it is no prefix of one). */
|
|
48
|
+
export const OUTSIDE = "@external";
|
|
49
|
+
/** A trusted entry as the page and the timeline show it. */
|
|
50
|
+
export function prefixLabel(p) {
|
|
51
|
+
return p === OUTSIDE ? "the outside world" : p;
|
|
52
|
+
}
|
|
53
|
+
/** the longest text an opinion records, in UTF-16 units: far beyond any declaration a reviewer
|
|
54
|
+
* reads, and small enough that a log stays a file people read */
|
|
55
|
+
export const MAX_TEXT = 65536;
|
|
56
|
+
export const STANDARD_AXIOMS = ["Classical.choice", "Quot.sound", "propext"];
|
|
57
|
+
/** An object keyed by names: without a prototype, so that no name means something already. */
|
|
58
|
+
export function byName() {
|
|
59
|
+
return Object.create(null);
|
|
60
|
+
}
|
|
61
|
+
export function emptyReview() {
|
|
62
|
+
return { version: 1, trusted: [...DEFAULT_TRUSTED], targets: byName(), opinions: byName(), keys: byName(), people: byName() };
|
|
63
|
+
}
|
|
64
|
+
/** A personal link's id (server/invites.ts): who someone invited by one is, in the review's logs. */
|
|
65
|
+
export function validPersonId(s) {
|
|
66
|
+
return typeof s === "string" && /^[0-9a-f]{10}$/.test(s);
|
|
67
|
+
}
|
|
68
|
+
/** A prefix that can be trusted: a dotted Lean name, as a module or a namespace is written, and
|
|
69
|
+
* short enough to read in a list. */
|
|
70
|
+
export function validPrefix(s) {
|
|
71
|
+
if (s === OUTSIDE)
|
|
72
|
+
return true;
|
|
73
|
+
const part = "(?:[\\p{L}_][\\p{L}\\p{N}_'!?₀-₉ₐ-ₜ]*|«[^»\\n\\r]*»|[0-9]+)";
|
|
74
|
+
return typeof s === "string" && s.length <= 200 && new RegExp(`^${part}(?:\\.${part})*$`, "u").test(s);
|
|
75
|
+
}
|
|
76
|
+
/** A request is checked before anything is written: sizes are bounded, fields have the right
|
|
77
|
+
* kinds. Returns the reason it is refused, or null. */
|
|
78
|
+
export function invalid(r) {
|
|
79
|
+
if (!r || typeof r !== "object")
|
|
80
|
+
return "not an object";
|
|
81
|
+
const o = r;
|
|
82
|
+
const str = (v, max, optional = false) => v === undefined ? optional : typeof v === "string" && v.length > 0 && v.length <= max;
|
|
83
|
+
// trust names a prefix, a move two declarations; everything else one declaration
|
|
84
|
+
if (o.op === "trust") {
|
|
85
|
+
if (!validPrefix(o.prefix))
|
|
86
|
+
return "prefix";
|
|
87
|
+
}
|
|
88
|
+
else if (isLibraryOp(o.op)) {
|
|
89
|
+
// a file is named by its hash, a link by its id (shared/library.ts)
|
|
90
|
+
}
|
|
91
|
+
else if (o.op === "move") {
|
|
92
|
+
if (!str(o.from, 2000) || !str(o.to, 2000) || o.from === o.to)
|
|
93
|
+
return "a move is from one declaration to another";
|
|
94
|
+
}
|
|
95
|
+
else if (!str(o.name, 2000))
|
|
96
|
+
return "name";
|
|
97
|
+
if (!str(o.by, 120) || !o.by.trim())
|
|
98
|
+
return "reviewer";
|
|
99
|
+
// the server says whether the request came from an agent, from who asks, never the page; what
|
|
100
|
+
// an agent may record is its level's to say, checked where the request comes in
|
|
101
|
+
if (o.agent !== undefined && typeof o.agent !== "boolean")
|
|
102
|
+
return "agent";
|
|
103
|
+
if (o.person !== undefined && (!validPersonId(o.person) || o.agent))
|
|
104
|
+
return "person";
|
|
105
|
+
switch (o.op) {
|
|
106
|
+
case "opinion":
|
|
107
|
+
if (o.verdict !== "approve" && o.verdict !== "comment")
|
|
108
|
+
return "verdict";
|
|
109
|
+
if (!str(o.comment, 10000, true))
|
|
110
|
+
return "comment";
|
|
111
|
+
if (o.verdict === "comment" && !o.comment?.trim())
|
|
112
|
+
return "a comment needs text";
|
|
113
|
+
if (!str(o.hash, 64, true))
|
|
114
|
+
return "hash";
|
|
115
|
+
if (!str(o.text, MAX_TEXT, true))
|
|
116
|
+
return "text";
|
|
117
|
+
if (!str(o.statement, MAX_TEXT, true))
|
|
118
|
+
return "statement";
|
|
119
|
+
if (o.text !== undefined && o.statement !== undefined)
|
|
120
|
+
return "a text or a statement, not both";
|
|
121
|
+
return null;
|
|
122
|
+
case "target":
|
|
123
|
+
return typeof o.on === "boolean" ? null : "on";
|
|
124
|
+
case "trust":
|
|
125
|
+
return typeof o.on === "boolean" ? null : "on";
|
|
126
|
+
case "key":
|
|
127
|
+
if (!str(o.target, 2000))
|
|
128
|
+
return "target";
|
|
129
|
+
if (o.target === o.name)
|
|
130
|
+
return "a target is always key for itself";
|
|
131
|
+
return typeof o.on === "boolean" ? null : "on";
|
|
132
|
+
case "move":
|
|
133
|
+
if (o.rename !== undefined && typeof o.rename !== "boolean")
|
|
134
|
+
return "rename";
|
|
135
|
+
if (!str(o.auto, 200, true))
|
|
136
|
+
return "auto";
|
|
137
|
+
if (o.snap !== undefined && !(Number.isInteger(o.snap) && o.snap > 0))
|
|
138
|
+
return "snap";
|
|
139
|
+
return typeof o.on === "boolean" ? null : "on";
|
|
140
|
+
default:
|
|
141
|
+
return isLibraryOp(o.op) ? invalidLibrary(o) : "op";
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** The event that records a (checked) request, as it is appended to its writer's log;
|
|
145
|
+
* `toolchain` and `now` come from the server. `d` is the review it is recorded on: a change of
|
|
146
|
+
* mind on the same version and the same text does not repeat the text, so that a log grows by
|
|
147
|
+
* the opinion alone. */
|
|
148
|
+
export function eventOf(d, r, toolchain, now) {
|
|
149
|
+
const by = r.by.trim();
|
|
150
|
+
// an agent's event says so, whatever it is; so does one by someone invited by a personal link
|
|
151
|
+
const agent = r.agent ? { agent: true } : {};
|
|
152
|
+
const person = r.person ? { person: r.person } : {};
|
|
153
|
+
switch (r.op) {
|
|
154
|
+
case "opinion": {
|
|
155
|
+
const e = { op: "opinion", name: r.name, by, ...person, verdict: r.verdict, at: now, toolchain };
|
|
156
|
+
if (r.comment?.trim())
|
|
157
|
+
e.comment = r.comment.trim();
|
|
158
|
+
if (r.agent)
|
|
159
|
+
e.agent = true;
|
|
160
|
+
// the text matters only to an opinion that can go stale, one bound to a version
|
|
161
|
+
if (r.hash) {
|
|
162
|
+
e.hash = r.hash;
|
|
163
|
+
const was = (Object.hasOwn(d.opinions, r.name) ? d.opinions[r.name] : []).findLast((p) => author(p) === author(e));
|
|
164
|
+
const same = was?.hash === r.hash ? judgedOn(d, r.name, was) : null;
|
|
165
|
+
if (r.text !== undefined && !(same?.of === "source" && same.text === r.text))
|
|
166
|
+
e.text = r.text;
|
|
167
|
+
else if (r.text === undefined && r.statement !== undefined && !(same?.of === "statement" && same.text === r.statement))
|
|
168
|
+
e.statement = r.statement;
|
|
169
|
+
}
|
|
170
|
+
return e;
|
|
171
|
+
}
|
|
172
|
+
case "target":
|
|
173
|
+
return { op: "target", at: now, by, ...person, name: r.name, on: r.on, ...agent };
|
|
174
|
+
case "trust":
|
|
175
|
+
return { op: "trust", at: now, by, ...person, prefix: r.prefix, on: r.on, ...agent };
|
|
176
|
+
case "key":
|
|
177
|
+
return { op: "key", at: now, by, ...person, target: r.target, name: r.name, on: r.on, ...agent };
|
|
178
|
+
case "move":
|
|
179
|
+
return {
|
|
180
|
+
op: "move",
|
|
181
|
+
at: now,
|
|
182
|
+
by,
|
|
183
|
+
...person,
|
|
184
|
+
from: r.from,
|
|
185
|
+
to: r.to,
|
|
186
|
+
on: r.on,
|
|
187
|
+
...(r.rename ? { rename: true } : {}),
|
|
188
|
+
...(r.auto ? { auto: r.auto } : {}),
|
|
189
|
+
...(r.snap ? { snap: r.snap } : {}),
|
|
190
|
+
...agent,
|
|
191
|
+
};
|
|
192
|
+
default:
|
|
193
|
+
return libraryEventOf(r, by, now, !!r.agent, r.person ?? null);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** The line that says someone invited by a personal link goes by `name` from `now` on, appended to
|
|
197
|
+
* their log when they rename themselves (server/review.ts, `named`). */
|
|
198
|
+
export function nameEvent(person, name, now) {
|
|
199
|
+
return { op: "name", at: now, by: name.trim(), person };
|
|
200
|
+
}
|
|
201
|
+
/** One step of the replay: the review after `e`. Every page and the server take the same steps
|
|
202
|
+
* in the same order, so they hold the same review. */
|
|
203
|
+
export function applyEvent(d, e) {
|
|
204
|
+
// the name someone invited by a personal link goes by is the one their latest line gives: what
|
|
205
|
+
// they wrote before shows under it from now on
|
|
206
|
+
if (e.person)
|
|
207
|
+
named(d, e.person, e.by);
|
|
208
|
+
// who did it, where an agent did: the page marks it as an agent's
|
|
209
|
+
const agent = e.agent ? { agent: true } : {};
|
|
210
|
+
const person = e.person ? { person: e.person } : {};
|
|
211
|
+
switch (e.op) {
|
|
212
|
+
case "name":
|
|
213
|
+
return;
|
|
214
|
+
case "opinion": {
|
|
215
|
+
const { op: _op, name, ...o } = e;
|
|
216
|
+
(Object.hasOwn(d.opinions, name) ? d.opinions[name] : (d.opinions[name] = [])).push(o);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
case "target":
|
|
220
|
+
if (!e.on)
|
|
221
|
+
delete d.targets[e.name];
|
|
222
|
+
else if (!Object.hasOwn(d.targets, e.name))
|
|
223
|
+
d.targets[e.name] = { by: e.by, at: e.at, ...agent, ...person };
|
|
224
|
+
return;
|
|
225
|
+
case "trust": {
|
|
226
|
+
const rest = d.trusted.filter((p) => p !== e.prefix);
|
|
227
|
+
d.trusted = [...new Set(e.on ? [...rest, e.prefix] : rest)].sort();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
case "key": {
|
|
231
|
+
// the first mark stands (who marked it, and when) until it is taken off
|
|
232
|
+
const ks = Object.hasOwn(d.keys, e.target) ? d.keys[e.target] : null;
|
|
233
|
+
if (e.on) {
|
|
234
|
+
const m = ks ?? (d.keys[e.target] = byName());
|
|
235
|
+
if (!Object.hasOwn(m, e.name))
|
|
236
|
+
m[e.name] = { by: e.by, at: e.at, ...agent, ...person };
|
|
237
|
+
}
|
|
238
|
+
else if (ks) {
|
|
239
|
+
delete ks[e.name];
|
|
240
|
+
if (!Object.keys(ks).length)
|
|
241
|
+
delete d.keys[e.target];
|
|
242
|
+
}
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
case "move":
|
|
246
|
+
applyMove(d, e);
|
|
247
|
+
return;
|
|
248
|
+
default:
|
|
249
|
+
applyLibrary(d, e);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/** Someone invited by a personal link goes by `name` now: what their id wrote shows under it, and
|
|
253
|
+
* the name they went by before is kept among those they had. */
|
|
254
|
+
function named(d, id, name) {
|
|
255
|
+
const p = Object.hasOwn(d.people, id) ? d.people[id] : null;
|
|
256
|
+
if (p?.name === name)
|
|
257
|
+
return;
|
|
258
|
+
if (!p) {
|
|
259
|
+
d.people[id] = { name, was: [] };
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
p.was = [...p.was.filter((n) => n !== p.name && n !== name), p.name];
|
|
263
|
+
p.name = name;
|
|
264
|
+
for (const ops of Object.values(d.opinions))
|
|
265
|
+
for (const o of ops)
|
|
266
|
+
if (o.person === id)
|
|
267
|
+
o.by = name;
|
|
268
|
+
for (const t of Object.values(d.targets))
|
|
269
|
+
if (t.person === id)
|
|
270
|
+
t.by = name;
|
|
271
|
+
for (const marks of Object.values(d.keys))
|
|
272
|
+
for (const m of Object.values(marks))
|
|
273
|
+
if (m.person === id)
|
|
274
|
+
m.by = name;
|
|
275
|
+
renamePerson(d, id, name);
|
|
276
|
+
}
|
|
277
|
+
/** The opinion on `name` given at `at` by whoever is, or was, called `by`: what a quote of an
|
|
278
|
+
* opinion (shared/comment.ts, `opinionRef`) names, which stays right once its writer, invited by a
|
|
279
|
+
* personal link, renames themselves. */
|
|
280
|
+
export function findOpinion(d, name, by, at) {
|
|
281
|
+
const was = (o) => !!o.person && Object.hasOwn(d.people, o.person) && d.people[o.person].was.includes(by);
|
|
282
|
+
return (Object.hasOwn(d.opinions, name) ? d.opinions[name] : []).find((o) => o.at === at && (o.by === by || was(o)));
|
|
283
|
+
}
|
|
284
|
+
/** Record a (checked) request on `d` at once: the event, applied. */
|
|
285
|
+
export function record(d, r, toolchain, now) {
|
|
286
|
+
const e = eventOf(d, r, toolchain, now);
|
|
287
|
+
applyEvent(d, e);
|
|
288
|
+
return e;
|
|
289
|
+
}
|
|
290
|
+
/** Whether recording `r` would change nothing: a target marked that is one, a prefix trusted that
|
|
291
|
+
* is, a key declaration marked that is. Such a request is answered, and nothing is appended. */
|
|
292
|
+
export function noChange(d, r) {
|
|
293
|
+
if (r.op === "target")
|
|
294
|
+
return Object.hasOwn(d.targets, r.name) === r.on;
|
|
295
|
+
if (r.op === "trust")
|
|
296
|
+
return d.trusted.includes(r.prefix) === r.on;
|
|
297
|
+
if (r.op === "key")
|
|
298
|
+
return keyMarked(d, r.target, r.name) === r.on;
|
|
299
|
+
if (r.op === "move")
|
|
300
|
+
return moveIsIdle(d, { ...r, rename: r.rename || undefined });
|
|
301
|
+
if (isLibraryOp(r.op))
|
|
302
|
+
return libraryNoChange(d, r);
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
/** Events the review no longer records: an explanation (`explain`), removed by the owner on
|
|
306
|
+
* 2026-10-08, and a checkpoint of a target's review (`checkpoint`), removed on 2026-10-09. A line
|
|
307
|
+
* of one in a log is skipped, whatever it holds. */
|
|
308
|
+
const RETIRED_OPS = ["explain", "checkpoint"];
|
|
309
|
+
/** A line of a log, read: the event it holds, or why it is not one; null for an event of a kind
|
|
310
|
+
* the review no longer records (`RETIRED_OPS`), which is skipped — neither applied nor a problem,
|
|
311
|
+
* so that an older log is read and its writer writes on. Only known fields are kept, in objects
|
|
312
|
+
* of their own. Every line in an agent's log (`agentLog`) is marked as an agent's — its
|
|
313
|
+
* opinions advice, its other events what it did at level full, which was checked when it was
|
|
314
|
+
* written; a log read back from disk is replayed as written — and no line in a person's is.
|
|
315
|
+
* Likewise every line in the log of someone invited by a personal link (`personLog`, the link's
|
|
316
|
+
* id, from the file's name) carries that id, and no line elsewhere carries one; their `name` lines
|
|
317
|
+
* are read there only. */
|
|
318
|
+
export function readEvent(raw, agentLog, personLog = null) {
|
|
319
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
320
|
+
return "not an event";
|
|
321
|
+
const o = raw;
|
|
322
|
+
if (RETIRED_OPS.includes(o.op))
|
|
323
|
+
return null;
|
|
324
|
+
const str = (v, max) => typeof v === "string" && v.length > 0 && v.length <= max;
|
|
325
|
+
if (!str(o.at, 40) || !Number.isFinite(Date.parse(o.at)))
|
|
326
|
+
return "no time (at)";
|
|
327
|
+
if (!str(o.by, 120) || !o.by.trim())
|
|
328
|
+
return "no writer (by)";
|
|
329
|
+
const at = o.at, by = o.by;
|
|
330
|
+
if (agentLog ? o.agent !== true : o.agent !== undefined)
|
|
331
|
+
return agentLog ? "a line of an agent's log not marked as an agent's" : "an agent's line in a person's log";
|
|
332
|
+
if (personLog ? o.person !== personLog : o.person !== undefined)
|
|
333
|
+
return personLog ? "a line of an invited person's log without their id (person)" : "an invited person's line in another's log";
|
|
334
|
+
const agent = agentLog ? { agent: true } : {};
|
|
335
|
+
const person = personLog ? { person: personLog } : {};
|
|
336
|
+
switch (o.op) {
|
|
337
|
+
case "name":
|
|
338
|
+
return personLog ? { op: "name", at, by, person: personLog } : "a name line outside an invited person's log";
|
|
339
|
+
case "opinion": {
|
|
340
|
+
if (!str(o.name, 2000))
|
|
341
|
+
return "name";
|
|
342
|
+
if (o.verdict !== "approve" && o.verdict !== "comment")
|
|
343
|
+
return "verdict";
|
|
344
|
+
const e = { op: "opinion", name: o.name, by, ...person, verdict: o.verdict, at };
|
|
345
|
+
for (const [k, max] of [["comment", 10000], ["hash", 64], ["toolchain", 200], ["text", MAX_TEXT], ["statement", MAX_TEXT]]) {
|
|
346
|
+
if (o[k] === undefined)
|
|
347
|
+
continue;
|
|
348
|
+
if (!str(o[k], max))
|
|
349
|
+
return k;
|
|
350
|
+
e[k] = o[k];
|
|
351
|
+
}
|
|
352
|
+
if (e.text !== undefined && e.statement !== undefined)
|
|
353
|
+
return "a text and a statement";
|
|
354
|
+
if (agentLog)
|
|
355
|
+
e.agent = true;
|
|
356
|
+
return e;
|
|
357
|
+
}
|
|
358
|
+
case "target":
|
|
359
|
+
if (!str(o.name, 2000))
|
|
360
|
+
return "name";
|
|
361
|
+
return typeof o.on === "boolean" ? { op: "target", at, by, ...person, name: o.name, on: o.on, ...agent } : "on";
|
|
362
|
+
case "trust":
|
|
363
|
+
if (!validPrefix(o.prefix))
|
|
364
|
+
return "prefix";
|
|
365
|
+
return typeof o.on === "boolean" ? { op: "trust", at, by, ...person, prefix: o.prefix, on: o.on, ...agent } : "on";
|
|
366
|
+
case "key":
|
|
367
|
+
if (!str(o.name, 2000))
|
|
368
|
+
return "name";
|
|
369
|
+
if (!str(o.target, 2000))
|
|
370
|
+
return "target";
|
|
371
|
+
return typeof o.on === "boolean" ? { op: "key", at, by, ...person, target: o.target, name: o.name, on: o.on, ...agent } : "on";
|
|
372
|
+
case "move": {
|
|
373
|
+
const m = readMove(o, at, by, agentLog);
|
|
374
|
+
return typeof m === "string" ? m : { ...m, ...person };
|
|
375
|
+
}
|
|
376
|
+
default:
|
|
377
|
+
return isLibraryOp(o.op) ? readLibraryEvent(o, at, by, agentLog, personLog) : "op";
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
/** Every log merged into one sequence: one writer's events in the order of their file, different
|
|
381
|
+
* writers' by time; at the same time, by file name (the same on every replica, so every server
|
|
382
|
+
* replays the same). A writer's clock going backwards keeps their order and delays nobody else's
|
|
383
|
+
* past their own. */
|
|
384
|
+
export function merged(logs) {
|
|
385
|
+
const ls = [...logs].sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
|
|
386
|
+
const time = ls.map((l) => l.events.map((e) => Date.parse(e.at)));
|
|
387
|
+
const next = ls.map(() => 0);
|
|
388
|
+
const out = [];
|
|
389
|
+
for (;;) {
|
|
390
|
+
let best = -1;
|
|
391
|
+
for (let k = 0; k < ls.length; k++)
|
|
392
|
+
if (next[k] < ls[k].events.length && (best < 0 || time[k][next[k]] < time[best][next[best]]))
|
|
393
|
+
best = k;
|
|
394
|
+
if (best < 0)
|
|
395
|
+
return out;
|
|
396
|
+
out.push({ file: ls[best].file, e: ls[best].events[next[best]++] });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/** The review every log gives. */
|
|
400
|
+
export function replay(logs) {
|
|
401
|
+
const d = emptyReview();
|
|
402
|
+
for (const { e } of merged(logs))
|
|
403
|
+
applyEvent(d, e);
|
|
404
|
+
return d;
|
|
405
|
+
}
|
|
406
|
+
/** The review as JSON carried it (the server's answer, a snapshot): the same review, with its
|
|
407
|
+
* objects keyed by names made again without a prototype. */
|
|
408
|
+
export function reviewFromJson(raw) {
|
|
409
|
+
const d = emptyReview();
|
|
410
|
+
const r = (raw && typeof raw === "object" ? raw : {});
|
|
411
|
+
if (Array.isArray(r.trusted))
|
|
412
|
+
d.trusted = [...new Set(r.trusted.filter((t) => typeof t === "string"))].sort();
|
|
413
|
+
const own = (o) => (o && typeof o === "object" && !Array.isArray(o) ? Object.entries(o) : []);
|
|
414
|
+
const marks = (x) => ({ ...(x.agent === true ? { agent: true } : {}), ...(validPersonId(x.person) ? { person: x.person } : {}) });
|
|
415
|
+
const was = (x) => (Array.isArray(x.was) && x.was.every((w) => typeof w === "string") ? { was: x.was } : {});
|
|
416
|
+
for (const [name, t] of own(r.targets)) {
|
|
417
|
+
const x = t;
|
|
418
|
+
d.targets[name] = { by: String(x.by ?? ""), at: String(x.at ?? ""), ...marks(x), ...was(x) };
|
|
419
|
+
}
|
|
420
|
+
for (const [name, ops] of own(r.opinions))
|
|
421
|
+
if (Array.isArray(ops))
|
|
422
|
+
d.opinions[name] = ops.filter((o) => !!o && typeof o === "object");
|
|
423
|
+
for (const [target, ks] of own(r.keys)) {
|
|
424
|
+
const m = byName();
|
|
425
|
+
for (const [name, k] of own(ks)) {
|
|
426
|
+
const x = (k && typeof k === "object" ? k : {});
|
|
427
|
+
const wasOf = Array.isArray(x.wasOf) && x.wasOf.every((w) => typeof w === "string") ? { wasOf: x.wasOf } : {};
|
|
428
|
+
m[name] = { by: String(x.by ?? ""), at: String(x.at ?? ""), ...marks(x), ...was(x), ...wasOf };
|
|
429
|
+
}
|
|
430
|
+
if (Object.keys(m).length)
|
|
431
|
+
d.keys[target] = m;
|
|
432
|
+
}
|
|
433
|
+
for (const [id, p] of own(r.people)) {
|
|
434
|
+
const x = (p && typeof p === "object" ? p : {});
|
|
435
|
+
if (validPersonId(id) && typeof x.name === "string")
|
|
436
|
+
d.people[id] = { name: x.name, was: Array.isArray(x.was) ? x.was.filter((n) => typeof n === "string") : [] };
|
|
437
|
+
}
|
|
438
|
+
const renamed = own(r.renamed).filter(([, v]) => typeof v === "string");
|
|
439
|
+
if (renamed.length) {
|
|
440
|
+
d.renamed = byName();
|
|
441
|
+
for (const [k, v] of renamed)
|
|
442
|
+
d.renamed[k] = v;
|
|
443
|
+
}
|
|
444
|
+
// the library's files, links and history (shared/library.ts)
|
|
445
|
+
Object.assign(d, libraryFromJson(r));
|
|
446
|
+
return d;
|
|
447
|
+
}
|
|
448
|
+
// ---------------------------------------------------------------------------------------------
|
|
449
|
+
// The logs' names
|
|
450
|
+
/** FNV-1a over a text's UTF-8 bytes, as 8 hex digits: short, and the same in the page and the
|
|
451
|
+
* server. */
|
|
452
|
+
export function shortHash(s) {
|
|
453
|
+
let h = 0x811c9dc5;
|
|
454
|
+
for (const b of new TextEncoder().encode(s))
|
|
455
|
+
h = Math.imul(h ^ b, 0x01000193) >>> 0;
|
|
456
|
+
return h.toString(16).padStart(8, "0");
|
|
457
|
+
}
|
|
458
|
+
/** the longest readable part of a log's name, in UTF-16 units: the whole name stays under 60 */
|
|
459
|
+
const SLUG = 32;
|
|
460
|
+
/** The file a writer's events are appended to, in the site's `log/`: a readable part of
|
|
461
|
+
* their name and a short hash of their exact name — `Alice-3f2a9c1b.jsonl`, an agent's
|
|
462
|
+
* `Claude-7c01aa52.agent.jsonl`. The site's folder may be copied, backed up or synced anywhere,
|
|
463
|
+
* Windows included, so the name is one every copy accepts: only letters and digits
|
|
464
|
+
* that read the same in every Unicode normal form (accents dropped), joined by hyphens — no
|
|
465
|
+
* `< > : " / \ | ? *`, no control character, no space or dot at the end, no `~` (an NTFS short
|
|
466
|
+
* name's shape), never a device name (the hash follows the readable part) — under 60 characters;
|
|
467
|
+
* and two names that differ only in case get two hashes, so their files differ by more than case
|
|
468
|
+
* (macOS and Windows would take them for one). The hash is of the exact name the events carry.
|
|
469
|
+
* Someone invited by a personal link writes to a log named by the link's id alone, whatever name
|
|
470
|
+
* they go by — `3f2a9c1b07.person.jsonl` — so that a rename never starts a new file. */
|
|
471
|
+
export function logFileName(name, agent, person = null) {
|
|
472
|
+
if (person)
|
|
473
|
+
return `${person}.person.jsonl`;
|
|
474
|
+
const plain = name.normalize("NFD").replace(/\p{M}+/gu, "").normalize("NFC");
|
|
475
|
+
let slug = "";
|
|
476
|
+
for (const c of plain) {
|
|
477
|
+
const keep = /^[\p{L}\p{N}]$/u.test(c) && c.normalize("NFD") === c && c.normalize("NFKC") === c;
|
|
478
|
+
const add = keep ? c : slug.endsWith("-") ? "" : "-";
|
|
479
|
+
if (slug.length + add.length > SLUG)
|
|
480
|
+
break;
|
|
481
|
+
slug += add;
|
|
482
|
+
}
|
|
483
|
+
slug = slug.replace(/^-+|-+$/g, "") || "writer";
|
|
484
|
+
return `${slug}-${shortHash(name)}${agent ? ".agent" : ""}.jsonl`;
|
|
485
|
+
}
|
|
486
|
+
/** The personal link's id a log is named by (`logFileName`), or null for any other log. */
|
|
487
|
+
export function personOfLog(file) {
|
|
488
|
+
return /^([0-9a-f]{10})\.person\.jsonl$/.exec(file)?.[1] ?? null;
|
|
489
|
+
}
|
|
490
|
+
/** The problems in a few words, for the page and /api/status; null when there are none. */
|
|
491
|
+
export function problemsText(ps) {
|
|
492
|
+
if (!ps.length)
|
|
493
|
+
return null;
|
|
494
|
+
const files = [...new Set(ps.map((p) => p.file))];
|
|
495
|
+
const each = ps.slice(0, 3).map((p) => `log/${p.file}, line ${p.line}: ${p.why}`).join("; ");
|
|
496
|
+
const who = [...new Set(ps.map((p) => p.writer ?? `the writer of log/${p.file}`))].join(", ");
|
|
497
|
+
return (`${ps.length === 1 ? "A line" : `${ps.length} lines`} of the review cannot be read — ${each}${ps.length > 3 ? `; and ${ps.length - 3} more` : ""}. ` +
|
|
498
|
+
`The rest is read; nothing is rewritten, and nothing more is written to ${files.length === 1 ? "that file" : "those files"} (${who}) until ${files.length === 1 ? "it is" : "they are"} fixed by hand.`);
|
|
499
|
+
}
|
|
500
|
+
/** Who gave an opinion: a person and an agent of the same name are two writers, and neither's
|
|
501
|
+
* replaces the other's; someone invited by a personal link is their link, whatever name they went
|
|
502
|
+
* by, and a guest who took a name they once had is someone else. */
|
|
503
|
+
function author(o) {
|
|
504
|
+
return o.person ? `link ${o.person}` : (o.agent ? "agent " : "person ") + o.by;
|
|
505
|
+
}
|
|
506
|
+
/** Each author's latest opinion on a declaration, people's or agents': a new one replaces their
|
|
507
|
+
* earlier one. */
|
|
508
|
+
function latest(d, name, agents) {
|
|
509
|
+
const out = new Map();
|
|
510
|
+
// own keys only: a declaration may be called `constructor` or `toString`
|
|
511
|
+
for (const o of Object.hasOwn(d.opinions, name) ? d.opinions[name] : [])
|
|
512
|
+
if (!!o.agent === agents)
|
|
513
|
+
out.set(author(o), o);
|
|
514
|
+
return [...out.values()];
|
|
515
|
+
}
|
|
516
|
+
/** Each reviewer's latest opinion on a declaration: a new one replaces their earlier one. People
|
|
517
|
+
* only: an agent's opinion is advice, and nothing read off this counts it. */
|
|
518
|
+
export function current(d, name) {
|
|
519
|
+
return latest(d, name, false);
|
|
520
|
+
}
|
|
521
|
+
/** Each agent's latest advice on a declaration (AgentSpec decision 2), shown apart and never
|
|
522
|
+
* counted. */
|
|
523
|
+
export function advice(d, name) {
|
|
524
|
+
return latest(d, name, true);
|
|
525
|
+
}
|
|
526
|
+
/** Which verdict a summary shows when the opinions on a declaration's present version differ, the
|
|
527
|
+
* first that any of them gives: a comment ahead of an approval, so that what a reviewer said is
|
|
528
|
+
* not hidden by someone else's approval (the owner's default, 2026-10-09). Every summary reads
|
|
529
|
+
* this order — the status (`statusOf`), what agents advise (`adviceOf`), and so every count,
|
|
530
|
+
* colour and query built on them — and changing it here changes them all. */
|
|
531
|
+
export const VERDICT_ORDER = ["comment", "approve"];
|
|
532
|
+
/** The status a verdict gives a declaration when it is the one shown. */
|
|
533
|
+
const STATUS_OF = { approve: "approved", comment: "commented" };
|
|
534
|
+
/** The verdict shown of those given, in `VERDICT_ORDER`; null when none is given. */
|
|
535
|
+
function summary(given) {
|
|
536
|
+
return VERDICT_ORDER.find((v) => given.some((o) => o.verdict === v)) ?? null;
|
|
537
|
+
}
|
|
538
|
+
/** What agents advise on the present version of a declaration, summed up as a status is
|
|
539
|
+
* (`VERDICT_ORDER`); null when no agent has advised on it. */
|
|
540
|
+
export function adviceOf(d, name, hash) {
|
|
541
|
+
return summary(advice(d, name).filter((o) => fresh(o, hash)));
|
|
542
|
+
}
|
|
543
|
+
/** What an opinion was given on, as recorded: with the opinion itself, or — where it replaced the
|
|
544
|
+
* same reviewer's opinion on the same version and the same text, which `record` does not repeat —
|
|
545
|
+
* with the one it replaced. Null for an opinion older than the record of it. */
|
|
546
|
+
export function judgedOn(d, name, o) {
|
|
547
|
+
const own = (p) => p.text !== undefined ? { of: "source", text: p.text } : p.statement !== undefined ? { of: "statement", text: p.statement } : null;
|
|
548
|
+
// each author's previous opinion, and what it was given on, in the order given
|
|
549
|
+
const last = new Map();
|
|
550
|
+
for (const p of Object.hasOwn(d.opinions, name) ? d.opinions[name] : []) {
|
|
551
|
+
const prev = last.get(author(p));
|
|
552
|
+
const judged = own(p) ?? (p.hash && prev?.hash === p.hash ? prev.judged : null);
|
|
553
|
+
if (p === o)
|
|
554
|
+
return judged;
|
|
555
|
+
last.set(author(p), { hash: p.hash, judged });
|
|
556
|
+
}
|
|
557
|
+
return own(o);
|
|
558
|
+
}
|
|
559
|
+
/** Whether an opinion is about the version there is now. */
|
|
560
|
+
export function fresh(o, hash) {
|
|
561
|
+
return !o.hash || !hash || o.hash === hash;
|
|
562
|
+
}
|
|
563
|
+
/** A summary of what reviewers said about the present version (ReviewSpec, "What the page shows"):
|
|
564
|
+
* the verdict shown of those on it (`VERDICT_ORDER`), else stale where every opinion is on an
|
|
565
|
+
* earlier version, else none. */
|
|
566
|
+
export function statusOf(d, name, hash) {
|
|
567
|
+
const now = current(d, name);
|
|
568
|
+
if (!now.length)
|
|
569
|
+
return "none";
|
|
570
|
+
const v = summary(now.filter((o) => fresh(o, hash)));
|
|
571
|
+
return v ? STATUS_OF[v] : "stale";
|
|
572
|
+
}
|
|
573
|
+
/** Whether a dotted name is `prefix` or lies under it, a whole component at a time: "Foo" covers
|
|
574
|
+
* "Foo" and "Foo.Bar", not "FooBar". */
|
|
575
|
+
export function under(s, prefix) {
|
|
576
|
+
return s === prefix || s.startsWith(prefix + ".");
|
|
577
|
+
}
|
|
578
|
+
/** Whether a trusted prefix covers a declaration. The world outside the project is trusted by
|
|
579
|
+
* module, as Mathlib is: its names (Nat.add, Finset.sum) say nothing of where they come from; or
|
|
580
|
+
* as a whole (OUTSIDE). A project's own declaration is trusted by its module or its name, so that
|
|
581
|
+
* a vendored library (a folder of files) and a layer reviewed on its own (a namespace) can both be
|
|
582
|
+
* named. */
|
|
583
|
+
export function covers(prefix, name, module, external) {
|
|
584
|
+
if (prefix === OUTSIDE)
|
|
585
|
+
return external;
|
|
586
|
+
return under(module, prefix) || (!external && under(name, prefix));
|
|
587
|
+
}
|
|
588
|
+
/** The trusted prefixes that cover a declaration, in the list's (sorted) order: a prefix comes
|
|
589
|
+
* before the longer ones under it. */
|
|
590
|
+
export function trustedBy(d, name, module, external) {
|
|
591
|
+
return d.trusted.filter((p) => covers(p, name, module, external));
|
|
592
|
+
}
|
|
593
|
+
/** Whether a declaration is trusted without review: a trusted prefix covers it, or it is a
|
|
594
|
+
* standard axiom. */
|
|
595
|
+
export function trusted(d, name, module, external) {
|
|
596
|
+
return STANDARD_AXIOMS.includes(name) || trustedBy(d, name, module, external).length > 0;
|
|
597
|
+
}
|
|
598
|
+
/** What the canvas and the lists mark a declaration with: what reviewers said about it, and when
|
|
599
|
+
* nobody has, whether a trusted prefix covers it — for the project's own declarations only:
|
|
600
|
+
* the outside world trusted by default is the rule there, not news. */
|
|
601
|
+
export function markOf(d, name, module, external, hash) {
|
|
602
|
+
const s = statusOf(d, name, hash);
|
|
603
|
+
if (s !== "none" || external)
|
|
604
|
+
return s;
|
|
605
|
+
return trustedBy(d, name, module, false).length ? "trusted" : "none";
|
|
606
|
+
}
|
|
607
|
+
/** How far the review of a set (a target's TCB) has come. */
|
|
608
|
+
export function progressOf(d, members) {
|
|
609
|
+
const p = { total: 0, approved: 0, commented: 0, stale: 0 };
|
|
610
|
+
for (const m of members) {
|
|
611
|
+
if (trusted(d, m.name, m.module, m.ext))
|
|
612
|
+
continue;
|
|
613
|
+
p.total++;
|
|
614
|
+
const s = statusOf(d, m.name, m.hash);
|
|
615
|
+
if (s === "approved")
|
|
616
|
+
p.approved++;
|
|
617
|
+
else if (s === "commented")
|
|
618
|
+
p.commented++;
|
|
619
|
+
else if (s === "stale")
|
|
620
|
+
p.stale++;
|
|
621
|
+
}
|
|
622
|
+
return p;
|
|
623
|
+
}
|
|
624
|
+
// ---------------------------------------------------------------------------------------------
|
|
625
|
+
// Key declarations (ExportSpec decision 1)
|
|
626
|
+
/** Whether `name` is marked as a key declaration of `target` (own keys only: a target may be
|
|
627
|
+
* called `constructor`). The target itself is key without a mark: see keyMarks. */
|
|
628
|
+
export function keyMarked(d, target, name) {
|
|
629
|
+
return Object.hasOwn(d.keys, target) && Object.hasOwn(d.keys[target], name);
|
|
630
|
+
}
|
|
631
|
+
/** The declarations marked as key for `target`, by name, sorted; the target itself, always
|
|
632
|
+
* key, is not among them. Whether each is still in the target's review is the graph's to say
|
|
633
|
+
* (shared/keys.ts, keySet). */
|
|
634
|
+
export function keyMarks(d, target) {
|
|
635
|
+
return Object.hasOwn(d.keys, target) ? Object.keys(d.keys[target]).sort() : [];
|
|
636
|
+
}
|
|
637
|
+
/** The targets `name` is marked key for, sorted. */
|
|
638
|
+
export function keyFor(d, name) {
|
|
639
|
+
return Object.keys(d.keys)
|
|
640
|
+
.filter((t) => Object.hasOwn(d.keys[t], name))
|
|
641
|
+
.sort();
|
|
642
|
+
}
|