decisium 0.0.0-stage → 0.1.2

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.
@@ -0,0 +1,298 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { capabilities, NotYet, PAGE, plausible } from "./decision.js";
4
+ export const RECORD_MODES = ["none", "new", "all"];
5
+ /** Replay-only mode and a Step whose recorded resolution is missing or no longer holds. */
6
+ export class UiChanged extends Error {
7
+ reason; // for people
8
+ constructor(reason) { super(`UI_CHANGED: ${reason}`); this.reason = reason; }
9
+ }
10
+ /** Nearest ancestor with a name, e.g. `list "Issues"`; "" at the top. */
11
+ const containerOf = (el) => {
12
+ for (let p = el.parent; p; p = p.parent)
13
+ if (p.name)
14
+ return `${p.role} "${p.name}"`;
15
+ return "";
16
+ };
17
+ const same = (el, r) => el.role === r.role && el.name === r.name && containerOf(el) === r.container;
18
+ const NEAR_MAX = 200; // a longer text is kept as its start and a hash, so the cassette stays readable
19
+ /** `el`'s and its ancestors' texts, closest first. */
20
+ const texts = (el) => {
21
+ const out = [];
22
+ for (let p = el; p; p = p.parent) {
23
+ const t = p.text.trim().replace(/\s+/g, " ");
24
+ if (t)
25
+ out.push(t.length <= NEAR_MAX ? t : `${t.slice(0, 80)}… [${createHash("sha256").update(t).digest("hex").slice(0, 8)}]`);
26
+ }
27
+ return out;
28
+ };
29
+ export function resolve(el, among) {
30
+ const r = { role: el.role, name: el.name, container: containerOf(el) };
31
+ const peers = among.filter(e => e !== el && same(e, r));
32
+ if (!peers.length)
33
+ return r;
34
+ const near = texts(el).find(t => !peers.some(p => texts(p).includes(t)));
35
+ if (near)
36
+ return { ...r, near };
37
+ const all = among.filter(e => same(e, r));
38
+ return { ...r, nth: all.indexOf(el), of: all.length };
39
+ }
40
+ /** The element a Resolution names on this Observation, or why none does: exactly one must match all of it. */
41
+ export function find(r, among) {
42
+ const peers = among.filter(e => same(e, r) && (r.near === undefined || texts(e).includes(r.near)));
43
+ if (r.of !== undefined && peers.length === r.of)
44
+ return peers[r.nth];
45
+ if (r.of === undefined && peers.length === 1)
46
+ return peers[0];
47
+ return `${describe(r)} matches ${peers.length} elements now, needs ${r.of ?? "exactly 1"}`;
48
+ }
49
+ export const describe = (t) => typeof t === "string" ? t
50
+ : `${t.role}${t.name ? ` "${t.name}"` : ""}${t.container ? ` in ${t.container}` : ""}${t.near ? ` near "${t.near}"` : ""}${t.of ? ` (#${t.nth + 1} of ${t.of} identical)` : ""}`;
51
+ /** A live answer for the cassette: refs (looked up in `among`) become Resolutions. */
52
+ const store = (p, among = []) => plausible(p).map(k => {
53
+ const el = among.find(e => e.ref === k);
54
+ return [el ? resolve(el, among) : k, p.probabilities[k]];
55
+ });
56
+ /** A Resolution that names no single element on this Observation: the only miss a later Observation can fix. */
57
+ class NotOnPage {
58
+ why;
59
+ constructor(why) { this.why = why; }
60
+ }
61
+ /** A recorded answer on this Observation; throws NotOnPage when a Resolution no longer names exactly one element. */
62
+ const load = (a, among = []) => {
63
+ const probabilities = Object.fromEntries(a.map(([t, v]) => {
64
+ if (typeof t === "string")
65
+ return [t, v];
66
+ const el = find(t, among);
67
+ if (typeof el === "string")
68
+ throw new NotOnPage(el);
69
+ return [el.ref, v];
70
+ }));
71
+ return { choice: Object.keys(probabilities)[0], probabilities };
72
+ };
73
+ /**
74
+ * A Step's cassette key: scenario and label, plus which occurrence of that label it is in the scenario, so adding or
75
+ * removing other Steps keeps every other key.
76
+ */
77
+ export function stepKey(scenario, labels, i) {
78
+ const n = labels.slice(0, i).filter(l => l === labels[i]).length + 1;
79
+ return `${scenario}: ${labels[i]}${n > 1 ? ` (#${n})` : ""}`;
80
+ }
81
+ /** Recorded resolutions per Step, in `<test>.cassette.json`. Steps are keyed by scenario, text and occurrence (stepKey). */
82
+ export class Cassette {
83
+ #file;
84
+ #dirty = false;
85
+ replayed = 0;
86
+ recorded = 0;
87
+ healed = 0;
88
+ path;
89
+ model;
90
+ mode;
91
+ #run;
92
+ /**
93
+ * `run`: id of this run. Parallel workers each open the feature's cassette; a file last saved by this run holds
94
+ * another worker's scenarios, so it is not stale for `all`.
95
+ */
96
+ constructor(path, model, mode, run) {
97
+ this.path = path;
98
+ this.model = model;
99
+ this.mode = mode;
100
+ this.#run = run;
101
+ const { file: old, thisRun } = this.#read();
102
+ this.#file = { model, run, entries: this.#keep(old, thisRun) ?? {} };
103
+ if (old && !this.#keep(old, thisRun) && mode !== "none")
104
+ this.#dirty = true; // replay-only never rewrites
105
+ }
106
+ #read() {
107
+ try {
108
+ if (!existsSync(this.path))
109
+ return { thisRun: false };
110
+ const file = JSON.parse(readFileSync(this.path, "utf8"));
111
+ return { file, thisRun: this.#run !== undefined && file.run === this.#run };
112
+ }
113
+ catch (err) {
114
+ throw new Error(`Cannot read cassette ${this.path}: ${err.message}. Delete it to record again.`);
115
+ }
116
+ }
117
+ /** Entries of a file on disk worth keeping: same model, and in `all` mode only those recorded in this run. */
118
+ #keep(file, thisRun) {
119
+ return file && file.model === this.model && (this.mode !== "all" || thisRun) ? file.entries : undefined;
120
+ }
121
+ /**
122
+ * The Decider for one Step: replays its recorded resolutions while each still matches exactly one element, else
123
+ * (from the first that does not) asks `live`. Call `done` when the Step ends.
124
+ */
125
+ step(key, live, log = () => { }) {
126
+ const old = this.mode === "all" ? undefined : this.#file.entries[key];
127
+ const now = {};
128
+ let broken; // why replay stopped
129
+ let turn = 0;
130
+ const replayOnly = this.mode === "none";
131
+ /** `wait`: replay-only waits for a loading page to show the element (NotYet); live mode just asks Jev, who can answer "wait". */
132
+ const fail = (why, wait = false) => {
133
+ if (replayOnly && old && wait)
134
+ throw new NotYet(new UiChanged(`the recorded resolution no longer holds: ${why}`));
135
+ if (replayOnly)
136
+ throw new UiChanged(old ? `the recorded resolution no longer holds: ${why}` : "this step has no recorded resolution: it is new or was reworded after the cassette was recorded, and replay-only mode does not call Jev");
137
+ if (old && !broken)
138
+ log(` (cassette: healing, ${why})`);
139
+ broken ??= why;
140
+ };
141
+ /**
142
+ * Replay is the live question without Jev: a recorded answer is used only while the question would offer it now
143
+ * (`get` throws why not). Only an element not on the page yet is waited for; any other miss is final.
144
+ */
145
+ const replay = (get) => {
146
+ if (broken || !old) {
147
+ if (!broken)
148
+ fail("missing");
149
+ return undefined;
150
+ }
151
+ try {
152
+ return get(old);
153
+ }
154
+ catch (why) {
155
+ if (why instanceof NotOnPage)
156
+ fail(why.why, true);
157
+ else if (typeof why === "string")
158
+ fail(why);
159
+ else
160
+ throw why;
161
+ return undefined;
162
+ }
163
+ };
164
+ /** Actions the answer's top target can do now (none known: undefined); the runner reads only these. */
165
+ const can = (a, obs, keys) => {
166
+ const top = a.target.choice === PAGE ? PAGE : obs.elements.find(e => e.ref === a.target.choice);
167
+ return top ? capabilities(top, obs, keys) : undefined;
168
+ };
169
+ return {
170
+ turn: async (step, obs, recent, attempt, keys) => {
171
+ const i = turn; // advanced only once answered: a NotYet retries the same recorded turn
172
+ const r = replay(e => {
173
+ const t = e.turns?.[i];
174
+ if (!t)
175
+ throw `recorded ${e.turns?.length ?? 0} turns, this is turn ${i + 1}`;
176
+ if ("dialog" in t)
177
+ throw `turn ${i + 1} answered a browser dialog when recorded`;
178
+ const gone = t.target.find(([k]) => typeof k === "string" && k !== "none" && k !== PAGE);
179
+ if (gone)
180
+ throw `recorded answer "${gone[0]}" is not offered now`;
181
+ const a = { done: t.done, target: load(t.target, obs.elements), action: t.action && load(t.action) };
182
+ // The recorded action must still be one the recorded target can do (a key the Step names, Enter in a focused field).
183
+ const ok = can(a, obs, keys ?? []);
184
+ if (ok && a.action && !Object.keys(a.action.probabilities).some(k => ok.includes(k)))
185
+ throw `recorded action "${a.action.choice}" is not offered now`;
186
+ return a;
187
+ });
188
+ turn = i + 1;
189
+ const a = r ?? await live.turn(step, obs, recent, attempt, keys);
190
+ // Store the action restricted to what the target can do: Jev's top action may be one it cannot (task 0067).
191
+ const ok = can(a, obs, keys ?? []);
192
+ const action = a.action && ok ? { ...a.action, probabilities: Object.fromEntries(Object.entries(a.action.probabilities).filter(([k]) => ok.includes(k))) } : a.action;
193
+ (now.turns ??= [])[i] = { done: a.done, target: store(a.target, obs.elements), action: action && store(action) };
194
+ return a;
195
+ },
196
+ value: async (step, obs, el, candidates) => {
197
+ const i = turn - 1;
198
+ const r = replay(e => {
199
+ const t = e.turns?.[i];
200
+ const v = t && "value" in t ? t.value : undefined;
201
+ const gone = v?.find(([k]) => k !== "none" && !candidates.includes(k));
202
+ if (!v)
203
+ throw "no recorded value";
204
+ if (gone)
205
+ throw `recorded value "${gone[0]}" is not offered now`;
206
+ return load(v);
207
+ });
208
+ const a = r ?? await live.value(step, obs, el, candidates);
209
+ now.turns[i].value = store(a);
210
+ return a;
211
+ },
212
+ drop: async (step, obs, source) => {
213
+ const i = turn - 1;
214
+ const among = [...new Set([...obs.readable, ...obs.areas])];
215
+ const r = replay(e => {
216
+ const t = e.turns?.[i];
217
+ const d = t && "drop" in t ? t.drop : undefined;
218
+ if (!d)
219
+ throw "no recorded drop target";
220
+ return load(d, among);
221
+ });
222
+ const a = r ?? await live.drop(step, obs, source);
223
+ now.turns[i].drop = store(a, among);
224
+ return a;
225
+ },
226
+ dialog: async (step, obs, answers) => {
227
+ const i = turn;
228
+ const r = replay(e => {
229
+ const t = e.turns?.[i];
230
+ if (!t || !("dialog" in t))
231
+ throw `turn ${i + 1} did not answer a browser dialog when recorded`;
232
+ const gone = t.dialog.find(([k]) => !answers.includes(k));
233
+ if (gone)
234
+ throw `recorded dialog answer "${gone[0]}" is not offered now`;
235
+ return load(t.dialog);
236
+ });
237
+ turn = i + 1;
238
+ const a = r ?? await live.dialog(step, obs, answers);
239
+ (now.turns ??= [])[i] = { dialog: store(a) };
240
+ return a;
241
+ },
242
+ assess: async (step, obs, judged) => {
243
+ // A judged check is Jev's verdict, and verdicts are never stored.
244
+ if (judged) {
245
+ broken ??= "judged";
246
+ return live.assess(step, obs, judged);
247
+ }
248
+ const r = replay((e) => {
249
+ if (!e.check)
250
+ throw "no recorded check";
251
+ return { kind: e.check.kind, state: e.check.state, subject: load(e.check.subject, obs.readable), area: load(e.check.area, obs.areas) };
252
+ });
253
+ const a = r ?? await live.assess(step, obs, judged);
254
+ now.check = { kind: a.kind, state: a.state, subject: store(a.subject, obs.readable), area: store(a.area, obs.areas) };
255
+ return a;
256
+ },
257
+ recall: async (step, earlier) => {
258
+ const r = replay(e => {
259
+ if (!e.recall)
260
+ throw "no recorded recall";
261
+ const gone = e.recall.find(([k]) => k !== "none" && !(k in earlier));
262
+ if (gone)
263
+ throw `recorded earlier value "${gone[0]}" is not in this scenario now`;
264
+ return load(e.recall);
265
+ });
266
+ const a = r ?? await live.recall(step, earlier);
267
+ now.recall = store(a);
268
+ return a;
269
+ },
270
+ /** `keep`: the Step reached an outcome from its answers (not an error), so they are written back. */
271
+ done: (keep) => {
272
+ if (!broken && old) {
273
+ this.replayed++;
274
+ return "replayed";
275
+ }
276
+ if (!keep || broken === "judged" || replayOnly)
277
+ return; // replayOnly: a Step that asked Jev nothing ("I press Escape")
278
+ this.#file.entries[key] = now;
279
+ this.#dirty = true;
280
+ if (old) {
281
+ this.healed++;
282
+ log(` (cassette: healed, written back)`);
283
+ return "healed";
284
+ }
285
+ this.recorded++;
286
+ return "recorded";
287
+ },
288
+ };
289
+ }
290
+ save() {
291
+ if (!this.#dirty)
292
+ return;
293
+ const { file, thisRun } = this.#read(); // another worker may have saved scenarios of this feature since
294
+ this.#file.entries = { ...this.#keep(file, thisRun), ...this.#file.entries };
295
+ writeFileSync(this.path + ".tmp", JSON.stringify(this.#file, null, 2) + "\n");
296
+ renameSync(this.path + ".tmp", this.path); // atomic: a crash mid-save never leaves half a cassette
297
+ }
298
+ }
@@ -0,0 +1,6 @@
1
+ import type { Observation } from "./observation.ts";
2
+ /**
3
+ * The changes from `before` to `after`, nearest to `acted` (the ref acted on in `before`) first, capped at 300
4
+ * characters; "no visible change" when there are none. Both Observations are already masked, so secrets stay masked.
5
+ */
6
+ export declare function changes(before: Observation, after: Observation, acted?: string): string;
package/dist/change.js ADDED
@@ -0,0 +1,134 @@
1
+ const MAX = 300;
2
+ const PAGE = new Set(["url", "title", "tabs", "dialog", "download", "clipboard"]);
3
+ const ITEMS = 5;
4
+ /** Icon-font glyphs (private use) and zero-width characters print as nothing: `value "" -> ""` (OrangeHRM). */
5
+ const clean = (s) => s.replace(/[\u200b-\u200d\ufeff\ue000-\uf8ff]/g, "").replace(/\s+/g, " ").trim();
6
+ const short = (s, n = 40) => (s.length > n ? s.slice(0, n) + "…" : s);
7
+ /** Containers (nodes with children): their value is their descendants' text, so it does not tell them apart. */
8
+ const containers = new WeakSet();
9
+ /** What tells a node apart: its name, or for a nameless one (a list item) its text. */
10
+ const ident = (e) => clean(e.name) || (e.secret || containers.has(e) ? "" : clean(e.value));
11
+ const label = (e) => `${e.role}${ident(e) ? ` "${short(ident(e))}"` : ""}`;
12
+ const where = (e) => { let p = e.parent; while (p && !p.name)
13
+ p = p.parent; return p ? label(p) : "the page"; };
14
+ /** Two long texts shown around where they first differ, so a clipped "A…" -> "A…" still says what changed. */
15
+ function values(a, b) {
16
+ let i = 0;
17
+ while (i < a.length && a[i] === b[i])
18
+ i++;
19
+ const from = Math.max(0, i - 15);
20
+ const cut = (s) => `"${from ? "…" : ""}${short(s.slice(from), 40)}"`;
21
+ return `${cut(a)} -> ${cut(b)}`;
22
+ }
23
+ /** States that flip with focus and hover, not with what an action did. */
24
+ const state = (e) => e.state.replace(/\s*\[(cursor=\w+|active)\]/g, "").trim();
25
+ /** Nearest named ancestor: refs change between Observations, so nodes are matched by role, name and this. */
26
+ const anc = (e) => { let p = e.parent; while (p && !p.name)
27
+ p = p.parent; return p ? `${p.role}|${p.name}` : ""; };
28
+ /** Every node of `obs` once, in snapshot order (readable first: it holds the page pseudo-elements). */
29
+ const all = (obs) => [...new Set([...obs.readable, ...obs.elements, ...obs.areas])];
30
+ /** Nodes that can be told apart: unnamed, valueless wrappers only match by position and churn when a menu opens. */
31
+ const told = (e) => !!(clean(e.name) || clean(e.value)) || e.role === "row" || e.role === "listitem";
32
+ /** First text of a list item or table row, for a reorder entry. */
33
+ const itemText = (e) => short(e.cells?.[0]?.name || e.cells?.[0]?.value || e.name || e.value || e.text.trim(), 20);
34
+ /** Items (direct ref'd children) of each container, by the container's match key; rowgroups hold the rows. */
35
+ function sequences(obs, keyOf) {
36
+ const out = new Map();
37
+ for (const e of all(obs))
38
+ if (e.parent && (e.role === "row" || e.role === "listitem")) {
39
+ const k = keyOf.get(e.parent);
40
+ out.set(k, [...out.get(k) ?? [], e]);
41
+ }
42
+ return out;
43
+ }
44
+ function keys(obs) {
45
+ for (const a of obs.areas)
46
+ containers.add(a);
47
+ const seen = new Map();
48
+ const keyOf = new Map();
49
+ for (const e of all(obs)) {
50
+ const base = `${e.role}|${ident(e)}|${anc(e)}`;
51
+ const n = seen.get(base) ?? 0;
52
+ seen.set(base, n + 1);
53
+ keyOf.set(e, `${base}#${n}`);
54
+ }
55
+ return keyOf;
56
+ }
57
+ /**
58
+ * The changes from `before` to `after`, nearest to `acted` (the ref acted on in `before`) first, capped at 300
59
+ * characters; "no visible change" when there are none. Both Observations are already masked, so secrets stay masked.
60
+ */
61
+ export function changes(before, after, acted) {
62
+ const kb = keys(before), ka = keys(after);
63
+ const byKey = new Map([...kb].map(([e, k]) => [k, e]));
64
+ // `keys`: match keys of the entry's node and its ancestors; `text` of a container: dropped when an entry inside it says more.
65
+ const entries = [];
66
+ const chain = (e, keyOf) => { const out = []; for (let p = e; p; p = p.parent)
67
+ out.push(keyOf.get(p) ?? ""); return out; };
68
+ const order = all(after);
69
+ // The page's own pseudo-elements (url, title, ...) come first: a navigation is the strongest evidence.
70
+ const pos = (e) => (PAGE.has(e.ref) ? -Infinity : order.indexOf(e));
71
+ const matched = new Set();
72
+ for (const [e, k] of ka) {
73
+ const old = byKey.get(k);
74
+ if (!old || !told(e))
75
+ continue;
76
+ matched.add(old);
77
+ const what = [];
78
+ if (clean(old.value) !== clean(e.value))
79
+ what.push(`value ${values(clean(old.value), clean(e.value))}`);
80
+ if (state(old) !== state(e))
81
+ what.push(`${state(old) || "[no state]"} -> ${state(e) || "[no state]"}`);
82
+ // A container whose own state did not change only repeats what changed inside it (whole-page text, task 0066).
83
+ const container = containers.has(e) && state(old) === state(e) ? k : undefined;
84
+ if (what.length)
85
+ entries.push({ at: pos(e), text: `${label(e)} ${what.join(", ")}`, keys: chain(e, ka), container });
86
+ }
87
+ // Unmatched: the same node under another named ancestor moved (a drag between lists); a name change pairs by role,
88
+ // ancestor and position among the unmatched; the rest appeared or went.
89
+ const gone = [...kb.keys()].filter(e => told(e) && !matched.has(e));
90
+ const added = [...ka].filter(([e, k]) => told(e) && !byKey.has(k)).map(([e]) => e);
91
+ for (const e of added) {
92
+ const m = gone.findIndex(o => o.role === e.role && ident(o) === ident(e) && ident(e));
93
+ if (m >= 0) {
94
+ entries.push({ at: pos(e), text: `${label(e)} moved from ${where(gone.splice(m, 1)[0])} to ${where(e)}`, keys: chain(e, ka) });
95
+ continue;
96
+ }
97
+ const i = gone.findIndex(o => o.role === e.role && anc(o) === anc(e));
98
+ const old = i >= 0 ? gone.splice(i, 1)[0] : undefined;
99
+ entries.push({ at: pos(e), text: !old ? `appeared: ${label(e)}`
100
+ : clean(old.name) || clean(e.name) ? `${label(old)} -> "${short(ident(e))}"` : `${e.role} value ${values(ident(old), ident(e))}`, keys: chain(e, ka) });
101
+ }
102
+ // Placed where they were: a deleted file listed last read as an afterthought (task 0066: done 0.55 -> 0.95 first).
103
+ // The acted-on control and its siblings going away (a menu closing after its item was clicked) go last.
104
+ const old = all(before);
105
+ const acting = old.find(e => e.ref === acted);
106
+ for (const o of gone)
107
+ entries.push({ at: acting && (o === acting || o.parent === acting.parent) ? Infinity : old.indexOf(o), text: `gone: ${label(o)}`, keys: chain(o, kb), container: containers.has(o) ? kb.get(o) : undefined });
108
+ // Reordered rows or items: same items, other order.
109
+ const sb = sequences(before, kb), sa = sequences(after, ka);
110
+ for (const [k, items] of sa) {
111
+ const was = sb.get(k)?.map(itemText), now = items.map(itemText);
112
+ if (!was || was.join("\n") === now.join("\n") || [...was].sort().join("\n") !== [...now].sort().join("\n"))
113
+ continue;
114
+ const container = items[0].parent;
115
+ entries.push({ at: pos(container), text: `${label(container)} reordered: ${was.slice(0, ITEMS).join(", ")} -> ${now.slice(0, ITEMS).join(", ")}`, keys: chain(container, ka) });
116
+ }
117
+ const said = entries.filter(x => !x.container || !entries.some(y => y !== x && y.keys.slice(1).includes(x.container)));
118
+ entries.splice(0, entries.length, ...said);
119
+ if (!entries.length)
120
+ return "no visible change";
121
+ // shortcut: gone entries are placed by their index in `before`, the rest by `after`; close enough while most of
122
+ // the page stays, measure if a large insertion reorders entries badly.
123
+ const at = acting ? pos(order.find(e => ka.get(e) === kb.get(acting)) ?? acting) : -1;
124
+ const dist = (x) => (x === -Infinity ? -1 : at < 0 ? x : Math.abs(x - at));
125
+ entries.sort((a, b) => dist(a.at) - dist(b.at));
126
+ let out = "";
127
+ for (const { text } of entries) {
128
+ const next = out ? `${out}; ${text}` : text;
129
+ if (next.length > MAX)
130
+ return `changed: ${out}; …`;
131
+ out = next;
132
+ }
133
+ return `changed: ${out}`;
134
+ }
@@ -0,0 +1,27 @@
1
+ import { type Assessment, type Pick } from "./decision.ts";
2
+ import { type Observation } from "./observation.ts";
3
+ import type { StepOutcome } from "./runner.ts";
4
+ export interface Expectation {
5
+ values: string[];
6
+ table?: string[][];
7
+ list?: boolean;
8
+ exact?: boolean;
9
+ recall?: Pick;
10
+ }
11
+ /**
12
+ * Whole-word, case/space-insensitive containment: "1 week" must not match "11 weeks". Only a neighbour of the same kind
13
+ * (letter next to letter, digit next to digit) joins words: snapshots drop line breaks ("1815<br>London" -> "1815London").
14
+ */
15
+ export declare function containsValue(actual: string, expected: string): boolean;
16
+ type Reading = {
17
+ outcome: StepOutcome;
18
+ reason?: string;
19
+ inapplicable?: true;
20
+ marginal?: string[];
21
+ };
22
+ export type Verdict = Reading & {
23
+ details: string[];
24
+ };
25
+ /** Pure Check verdict; diagnostics are returned for the runner to log, never emitted here. */
26
+ export declare function verdict(a: Assessment, obs: Observation, expectation: Expectation): Verdict;
27
+ export {};