@crvouga/atlas 1.20.0 → 1.22.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/CHANGELOG.md +15 -1
- package/dist/core/src/cli.d.ts +2 -2
- package/dist/core/src/cli.js +7 -1
- package/dist/critics/src/catalog-client.d.ts +91 -0
- package/dist/critics/src/catalog-client.js +194 -0
- package/dist/critics/src/cli-calibrate.d.ts +4 -0
- package/dist/critics/src/cli-calibrate.js +211 -0
- package/dist/critics/src/cli-common.d.ts +108 -0
- package/dist/critics/src/cli-common.js +155 -0
- package/dist/critics/src/cli-review.d.ts +4 -0
- package/dist/critics/src/cli-review.js +174 -0
- package/dist/critics/src/cli-watch.d.ts +4 -0
- package/dist/critics/src/cli-watch.js +191 -0
- package/dist/critics/src/cli.d.ts +13 -0
- package/dist/critics/src/cli.js +490 -0
- package/dist/critics/src/index.d.ts +3 -0
- package/dist/critics/src/index.js +3 -0
- package/dist/critics/src/judging.d.ts +8 -0
- package/dist/critics/src/llm.d.ts +120 -0
- package/dist/critics/src/llm.js +266 -0
- package/dist/critics/src/marks.d.ts +74 -0
- package/dist/critics/src/marks.js +76 -0
- package/dist/critics/src/registry.d.ts +125 -0
- package/dist/critics/src/registry.js +203 -0
- package/dist/critics/src/spool.d.ts +32 -0
- package/dist/critics/src/spool.js +129 -0
- package/dist/critics/src/uiclip.d.ts +82 -0
- package/dist/critics/src/uiclip.js +167 -0
- package/dist/reports/src/compose.d.ts +23 -1
- package/dist/reports/src/compose.js +29 -19
- package/dist/reports/src/critique-review.d.ts +111 -0
- package/dist/reports/src/critique-review.js +123 -0
- package/dist/reports/src/critique-row.d.ts +24 -0
- package/dist/reports/src/critique-row.js +39 -0
- package/dist/reports/src/evidence-types.d.ts +5 -0
- package/dist/reports/src/layer-merge.js +5 -0
- package/dist/reports/src/report-cli.d.ts +13 -0
- package/dist/reports/src/report-cli.js +6 -4
- package/dist/reports/src/report-index.d.ts +6 -0
- package/dist/reports/src/report-index.js +12 -0
- package/dist/reports/src/ui-catalog-cli.d.ts +10 -0
- package/dist/reports/src/ui-catalog-cli.js +391 -0
- package/dist/reports/src/ui-catalog-diff.d.ts +104 -0
- package/dist/reports/src/ui-catalog-diff.js +130 -0
- package/dist/reports/src/ui-catalog-entries.d.ts +147 -0
- package/dist/reports/src/ui-catalog-entries.js +190 -0
- package/dist/reports/src/ui-catalog-export.d.ts +82 -0
- package/dist/reports/src/ui-catalog-export.js +221 -0
- package/dist/reports/src/ui-catalog-publish.js +2 -2
- package/dist/reports/src/ui-catalog.d.ts +2 -0
- package/dist/reports/src/ui-catalog.js +1 -1
- package/dist/reports/src/ui-state.d.ts +2 -0
- package/dist/reports/src/ui-state.js +2 -2
- package/dist/schema/src/api.d.ts +653 -1
- package/dist/schema/src/api.js +44 -0
- package/dist/schema/src/cli-results.d.ts +991 -1
- package/dist/schema/src/cli-results.js +265 -1
- package/dist/schema/src/critique.d.ts +31 -0
- package/dist/schema/src/critique.js +26 -0
- package/dist/schema/src/server-config.d.ts +3 -3
- package/dist/server/src/app.js +28 -1
- package/dist/server/src/openapi.js +19 -2
- package/docs/agents.md +16 -2
- package/docs/critics.md +273 -9
- package/docs/extending.md +1 -1
- package/docs/reference/cli.md +126 -1
- package/docs/reference/core.md +1 -1
- package/docs/reference/critics.md +1131 -29
- package/docs/reference/http-api.md +40 -0
- package/docs/reference/reports.md +855 -0
- package/docs/reference/schema.md +197 -1
- package/docs/report-contract.md +2 -1
- package/docs/server.md +2 -0
- package/docs/ui-catalog.md +110 -13
- package/docs/ui.md +69 -2
- package/llms-full.txt +636 -26
- package/llms.txt +1 -1
- package/package.json +9 -1
- package/packages/critics/README.md +1 -0
- package/packages/schema/schemas/critic-calibrate-result.schema.json +274 -0
- package/packages/schema/schemas/critic-doctor-result.schema.json +74 -0
- package/packages/schema/schemas/critic-list-result.schema.json +90 -0
- package/packages/schema/schemas/critic-review-result.schema.json +397 -0
- package/packages/schema/schemas/critic-run-result.schema.json +249 -0
- package/packages/schema/schemas/critic-setup-result.schema.json +34 -0
- package/packages/schema/schemas/critic-watch-result.schema.json +211 -0
- package/packages/schema/schemas/critique-mark.schema.json +81 -0
- package/packages/schema/schemas/effective-delta.schema.json +337 -0
- package/packages/schema/schemas/effective-evidence.schema.json +337 -0
- package/packages/schema/schemas/layer-rows.schema.json +337 -0
- package/packages/schema/schemas/ui-catalog-diff-result.schema.json +201 -0
- package/packages/schema/schemas/ui-catalog-export-result.schema.json +83 -0
- package/packages/schema/schemas/ui-catalog-publish-result.schema.json +116 -0
- package/packages/schema/schemas/ui-catalog-status.schema.json +197 -0
- package/packages/server/openapi.json +6755 -4738
- package/packages/visualizer/src/app/AppShell.tsx +3 -1
- package/packages/visualizer/src/app/router.tsx +20 -7
- package/packages/visualizer/src/catalog/Picture.tsx +32 -0
- package/packages/visualizer/src/catalog/UiCatalogCompare.tsx +289 -0
- package/packages/visualizer/src/catalog/UiCatalogPage.tsx +18 -28
- package/packages/visualizer/src/catalog/history.ts +61 -0
- package/packages/visualizer/src/catalog/search.ts +14 -3
- package/packages/visualizer/src/components/Header.tsx +16 -4
- package/packages/visualizer/src/critiques/CritiquesPage.tsx +402 -0
- package/packages/visualizer/src/critiques/FindingOverlay.tsx +50 -0
- package/packages/visualizer/src/critiques/ranking.ts +54 -0
- package/packages/visualizer/src/critiques/search.ts +41 -0
- package/packages/visualizer/src/evidence/applicability-view.tsx +11 -7
- package/packages/visualizer/src/evidence/behaviors-page.tsx +22 -15
- package/packages/visualizer/src/evidence/evidence-context.tsx +2 -1
- package/packages/visualizer/src/explorer/Explorer.tsx +5 -0
- package/packages/visualizer/src/explorer/TestDetail.tsx +4 -1
- package/packages/visualizer/src/explorer/parts.tsx +19 -1
- package/packages/visualizer/src/explorer/playlist.tsx +11 -7
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module The UIClip critic (`@crvouga/atlas/critics/uiclip`)
|
|
3
|
+
*
|
|
4
|
+
* A model-based critic: UIClip scores how well a screenshot looks, as one number. This module is the adapter around it, and
|
|
5
|
+
* it needs nothing installed to import: the model runs in an ONNX Runtime session that is loaded only when a run starts
|
|
6
|
+
* (`loadOnnxRuntime`, which needs the optional `onnxruntime-node` and `sharp` and names the one that is missing), and a test
|
|
7
|
+
* or another runtime passes its own `UiclipRuntime`. The checkpoint is provisioned only by an explicit `setupUiclip`, which
|
|
8
|
+
* verifies it against a pinned SHA-256 and refuses a file that does not match; inference never downloads anything and
|
|
9
|
+
* refuses a checkpoint that does not match. The critic sends images nowhere: it runs on this machine.
|
|
10
|
+
*
|
|
11
|
+
* A single global score cannot say what to fix, and on real recordings it ranks ordinary loading overlays as poor, so
|
|
12
|
+
* a UIClip score is report-only evidence read against the scores of the same state kind (see `relativeVerdict`), never an
|
|
13
|
+
* absolute gate.
|
|
14
|
+
*
|
|
15
|
+
* @see docs/critics.md#the-built-in-uiclip-critic
|
|
16
|
+
*/
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
18
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
19
|
+
import { mkdir, rename, rm, stat } from 'node:fs/promises';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { defineCritic } from './contract.js';
|
|
22
|
+
/** The version of this adapter's own logic, recorded in every critique. The checkpoint is part of the identity too (through `model.id`). */
|
|
23
|
+
export const UICLIP_VERSION = '1.0.0';
|
|
24
|
+
/** Thrown when an optional dependency a runtime needs is not installed. The message names it and says how to install it. */
|
|
25
|
+
export class MissingDependencyError extends Error {
|
|
26
|
+
dependency;
|
|
27
|
+
/** @param dependency the package that is missing. */
|
|
28
|
+
constructor(dependency, purpose) {
|
|
29
|
+
super(`${purpose} needs the optional dependency "${dependency}", which is not installed. Install it in this project (for example: bun add ${dependency}) and run again.`);
|
|
30
|
+
this.dependency = dependency;
|
|
31
|
+
this.name = 'MissingDependencyError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Checks a file against a pinned SHA-256. Returns the hash it has. Throws when the file is missing or its hash differs, naming
|
|
36
|
+
* both hashes, so a corrupted or swapped model is an error and never silently used.
|
|
37
|
+
*/
|
|
38
|
+
export async function verifyCheckpoint(checkpoint) {
|
|
39
|
+
if (!/^[0-9a-f]{64}$/.test(checkpoint.sha256))
|
|
40
|
+
throw new Error('The checkpoint is not pinned: give its SHA-256 as 64 lowercase hex digits');
|
|
41
|
+
try {
|
|
42
|
+
await stat(checkpoint.path);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
throw new Error(`The UIClip checkpoint is missing: ${checkpoint.path}. Run \`atlas critic setup uiclip\`.`);
|
|
46
|
+
}
|
|
47
|
+
const hash = createHash('sha256');
|
|
48
|
+
for await (const chunk of createReadStream(checkpoint.path))
|
|
49
|
+
hash.update(chunk);
|
|
50
|
+
const actual = hash.digest('hex');
|
|
51
|
+
if (actual !== checkpoint.sha256)
|
|
52
|
+
throw new Error(`The UIClip checkpoint ${checkpoint.path} does not match its pinned checksum (expected ${checkpoint.sha256}, found ${actual}). Remove it and run \`atlas critic setup uiclip\`, or fix the pin.`);
|
|
53
|
+
return actual;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Provisions the checkpoint: uses the file when it is there, else downloads it from `checkpoint.url` (to a temporary file, hashing as
|
|
57
|
+
* it arrives), and in both cases verifies the pinned SHA-256. A download whose hash differs is deleted and is an error; an
|
|
58
|
+
* existing file whose hash differs is an error and is left alone. This is the only place anything is downloaded.
|
|
59
|
+
*
|
|
60
|
+
* @param checkpoint the pinned checkpoint.
|
|
61
|
+
* @param options.fetch for tests; default `fetch`.
|
|
62
|
+
* @param options.signal cancels the download.
|
|
63
|
+
*/
|
|
64
|
+
export async function setupUiclip(checkpoint, options = {}) {
|
|
65
|
+
const exists = await stat(checkpoint.path).then(() => true, () => false);
|
|
66
|
+
if (exists)
|
|
67
|
+
return { path: checkpoint.path, sha256: await verifyCheckpoint(checkpoint), downloaded: false };
|
|
68
|
+
if (!checkpoint.url)
|
|
69
|
+
throw new Error(`The UIClip checkpoint is not at ${checkpoint.path} and no download address is configured: put the file there, or set checkpoint.url.`);
|
|
70
|
+
if (!/^https:\/\//.test(checkpoint.url))
|
|
71
|
+
throw new Error('The checkpoint must be downloaded over https');
|
|
72
|
+
const response = await (options.fetch ?? fetch)(checkpoint.url, options.signal ? { signal: options.signal } : {});
|
|
73
|
+
if (!response.ok || !response.body)
|
|
74
|
+
throw new Error(`The checkpoint download answered ${response.status}`);
|
|
75
|
+
await mkdir(path.dirname(checkpoint.path), { recursive: true });
|
|
76
|
+
const partial = `${checkpoint.path}.part`;
|
|
77
|
+
const hash = createHash('sha256');
|
|
78
|
+
const out = createWriteStream(partial);
|
|
79
|
+
try {
|
|
80
|
+
const reader = response.body.getReader();
|
|
81
|
+
for (;;) {
|
|
82
|
+
const { done, value } = await reader.read();
|
|
83
|
+
if (done)
|
|
84
|
+
break;
|
|
85
|
+
hash.update(value);
|
|
86
|
+
if (!out.write(value))
|
|
87
|
+
await new Promise(resolve => out.once('drain', () => resolve()));
|
|
88
|
+
}
|
|
89
|
+
await new Promise((resolve, reject) => { out.once('error', reject); out.end(() => resolve()); });
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
// The file is opened asynchronously: close it before removing it, or it can reappear.
|
|
93
|
+
if (!out.closed)
|
|
94
|
+
await new Promise(resolve => { out.once('close', () => resolve()); out.destroy(); });
|
|
95
|
+
await rm(partial, { force: true });
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
const actual = hash.digest('hex');
|
|
99
|
+
if (actual !== checkpoint.sha256) {
|
|
100
|
+
await rm(partial, { force: true });
|
|
101
|
+
throw new Error(`The downloaded checkpoint does not match its pinned checksum (expected ${checkpoint.sha256}, received ${actual}); it was discarded.`);
|
|
102
|
+
}
|
|
103
|
+
await rename(partial, checkpoint.path);
|
|
104
|
+
return { path: checkpoint.path, sha256: actual, downloaded: true };
|
|
105
|
+
}
|
|
106
|
+
/** CLIP's published normalisation of RGB channels. */
|
|
107
|
+
const MEAN = [0.48145466, 0.4578275, 0.40821073];
|
|
108
|
+
const STD = [0.26862954, 0.26130258, 0.27577711];
|
|
109
|
+
const SIZE = 224;
|
|
110
|
+
/**
|
|
111
|
+
* Opens an ONNX Runtime session on a verified checkpoint and scores images with it: the image is resized to 224 by 224 and
|
|
112
|
+
* normalised as CLIP does, and the first output's first value is the score. The checkpoint's hash is verified first and a
|
|
113
|
+
* mismatch is an error. Needs the optional `onnxruntime-node` and `sharp`; the error names the missing one. Nothing is
|
|
114
|
+
* downloaded. The graph and score head are those of the checkpoint you pinned; this has not been validated against any particular
|
|
115
|
+
* checkpoint, so measure it on your own examples (`calibrate`) before reading its scores.
|
|
116
|
+
*/
|
|
117
|
+
export async function loadOnnxRuntime(checkpoint, modules = {}) {
|
|
118
|
+
const load = modules.importModule ?? ((name) => import(name));
|
|
119
|
+
const onnx = await load('onnxruntime-node').catch(() => { throw new MissingDependencyError('onnxruntime-node', 'The UIClip critic'); });
|
|
120
|
+
const sharpModule = await load('sharp').catch(() => { throw new MissingDependencyError('sharp', 'The UIClip critic (to decode images)'); });
|
|
121
|
+
const sharp = (sharpModule.default ?? sharpModule);
|
|
122
|
+
await verifyCheckpoint(checkpoint);
|
|
123
|
+
const session = await onnx.InferenceSession.create(checkpoint.path);
|
|
124
|
+
return {
|
|
125
|
+
async score(image) {
|
|
126
|
+
const rgb = await sharp(image).resize(SIZE, SIZE, { fit: 'cover' }).removeAlpha().raw().toBuffer();
|
|
127
|
+
const planes = new Float32Array(3 * SIZE * SIZE);
|
|
128
|
+
for (let pixel = 0; pixel < SIZE * SIZE; pixel++)
|
|
129
|
+
for (let channel = 0; channel < 3; channel++)
|
|
130
|
+
planes[channel * SIZE * SIZE + pixel] = ((rgb[pixel * 3 + channel] / 255) - MEAN[channel]) / STD[channel];
|
|
131
|
+
const outputs = await session.run({ [session.inputNames[0]]: new onnx.Tensor('float32', planes, [1, 3, SIZE, SIZE]) });
|
|
132
|
+
const value = outputs[session.outputNames[0]]?.data[0];
|
|
133
|
+
if (value === undefined || !Number.isFinite(value))
|
|
134
|
+
throw new Error('The UIClip model gave no finite score');
|
|
135
|
+
return value;
|
|
136
|
+
},
|
|
137
|
+
async close() { await session.release?.(); }
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The UIClip critic: judges each image with a runtime and reports the score and nothing else (a score is a valid critique; it
|
|
142
|
+
* has no findings). Deterministic for a deterministic runtime. Its identity includes the checkpoint hash, so a different
|
|
143
|
+
* checkpoint is judged again. The runtime is created once, on the first image.
|
|
144
|
+
*
|
|
145
|
+
* @param options.checkpoint the pinned checkpoint. Its hash is the model id.
|
|
146
|
+
* @param options.runtime a runtime, or a function that makes one. Default: {@link loadOnnxRuntime} on the checkpoint.
|
|
147
|
+
* @example
|
|
148
|
+
* const critic = createUiclipCritic({ checkpoint: { path: '.atlas/uiclip.onnx', sha256 }, runtime: { score: async () => 0.5 } });
|
|
149
|
+
*/
|
|
150
|
+
export function createUiclipCritic(options) {
|
|
151
|
+
let runtime;
|
|
152
|
+
const open = () => {
|
|
153
|
+
runtime ??= Promise.resolve(typeof options.runtime === 'function' ? options.runtime() : options.runtime ?? loadOnnxRuntime(options.checkpoint, options.modules));
|
|
154
|
+
return runtime;
|
|
155
|
+
};
|
|
156
|
+
const critic = defineCritic({
|
|
157
|
+
name: options.name ?? 'uiclip', version: UICLIP_VERSION, kind: 'model',
|
|
158
|
+
describe: () => ({ sendsImagesTo: null, model: { id: `uiclip@${options.checkpoint.sha256.slice(0, 16)}`, provider: 'local' }, parameters: { checkpoint: options.checkpoint.sha256 }, needsSetup: true }),
|
|
159
|
+
setup: async () => { await verifyCheckpoint(options.checkpoint); },
|
|
160
|
+
async evaluate({ image, signal }) {
|
|
161
|
+
const score = await (await open()).score(image, signal);
|
|
162
|
+
return { verdict: { score, scale: 'UIClip similarity, higher is better; read it against the same state kind' } };
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
/** Opens the runtime now, so a missing dependency or a bad checkpoint is found before any image is judged. */
|
|
166
|
+
return { ...critic, async ready() { await open(); } };
|
|
167
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { BackendInfo, LayerHeader, StatusCounts } from './evidence-types.js';
|
|
1
|
+
import type { BackendInfo, LayerEvidence, LayerHeader, StatusCounts } from './evidence-types.js';
|
|
2
|
+
import type { SpecEntry } from './explorer-types.js';
|
|
2
3
|
import { type CombinedEvidence, type EvidenceResult } from './layer-merge.js';
|
|
3
4
|
import type { ReportOutline } from './layer-types.js';
|
|
4
5
|
import type { ObjectStore } from './object-store.js';
|
|
@@ -58,6 +59,27 @@ export declare function countResults(all: readonly EvidenceResult[]): StatusCoun
|
|
|
58
59
|
};
|
|
59
60
|
/** Counts a combined report's winning results in total and per environment, and lists the specs that have no result at all. */
|
|
60
61
|
export declare function summarize(combined: CombinedEvidence): ReportSummary;
|
|
62
|
+
/** Every layer of some backends, read but not yet composed. */
|
|
63
|
+
export type BackendLayers = {
|
|
64
|
+
/** Each layer that could be read, with the specs it recorded. A layer several backends hold appears once per backend. */
|
|
65
|
+
readonly loaded: readonly {
|
|
66
|
+
readonly evidence: LayerEvidence;
|
|
67
|
+
readonly specs: readonly SpecEntry[];
|
|
68
|
+
}[];
|
|
69
|
+
/** Every backend given, sorted by id, with whether it could be listed. */
|
|
70
|
+
readonly backends: readonly BackendInfo[];
|
|
71
|
+
/** What could not be read, sorted and without repeats. */
|
|
72
|
+
readonly notices: readonly string[];
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Reads every layer of every backend, superseded ones included, without composing them. This is what {@link combineBackends}
|
|
76
|
+
* starts from; a caller that needs layers a newer layer has replaced (to compare two commits, for example) reads them here.
|
|
77
|
+
* A backend that cannot be listed, or a layer that cannot be understood, is a notice, never an omission that looks like a pass.
|
|
78
|
+
* Throws for an invalid or repeated backend `id`, before anything is read.
|
|
79
|
+
*/
|
|
80
|
+
export declare function readBackendLayers(backends: readonly BackendInput[], options?: {
|
|
81
|
+
readonly concurrency?: number;
|
|
82
|
+
}): Promise<BackendLayers>;
|
|
61
83
|
/**
|
|
62
84
|
* Reads every layer of every backend and lays them over the specs. Layers are the only input:
|
|
63
85
|
* a report is a function of the set of layers, so the order of backends and layers, and how they
|
|
@@ -45,25 +45,12 @@ export function summarize(combined) {
|
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
47
|
/**
|
|
48
|
-
* Reads every layer of every backend
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* A backend that cannot be read, or a layer that cannot be understood, is named in `notices` and
|
|
53
|
-
* leaves everything else usable; a gate must treat any notice as unknown, never as a pass. A layer held by
|
|
54
|
-
* several backends is one layer. Only an invalid backend `id` (not lowercase letters, digits and hyphens, or
|
|
55
|
-
* repeated) throws, before anything is read. Backends are only read from.
|
|
56
|
-
*
|
|
57
|
-
* When `options.outline` is omitted, the specs are the ones the included layers recorded (the newest
|
|
58
|
-
* recording of each spec path wins), with no commit sha.
|
|
59
|
-
*
|
|
60
|
-
* @example
|
|
61
|
-
* const outline = await loadDirectoryOutline('./specs'); // optional: without it the layers' own specs are used
|
|
62
|
-
* const report = await combineBackends([{ id: 'ci', store: directoryStore('./atlas-reports') }], { outline });
|
|
63
|
-
* report.summary.checks.failed; // number of slots whose winner failed
|
|
64
|
-
* report.notices; // non-empty means something could not be read
|
|
48
|
+
* Reads every layer of every backend, superseded ones included, without composing them. This is what {@link combineBackends}
|
|
49
|
+
* starts from; a caller that needs layers a newer layer has replaced (to compare two commits, for example) reads them here.
|
|
50
|
+
* A backend that cannot be listed, or a layer that cannot be understood, is a notice, never an omission that looks like a pass.
|
|
51
|
+
* Throws for an invalid or repeated backend `id`, before anything is read.
|
|
65
52
|
*/
|
|
66
|
-
export async function
|
|
53
|
+
export async function readBackendLayers(backends, options = {}) {
|
|
67
54
|
const seen = new Set();
|
|
68
55
|
for (const backend of backends) {
|
|
69
56
|
if (!BACKEND_ID.test(backend.id) || seen.has(backend.id))
|
|
@@ -96,6 +83,29 @@ export async function combineBackends(backends, options = {}) {
|
|
|
96
83
|
notices.push(`${backend.label ?? backend.id}: a layer could not be read, so its outcome is unknown (${error instanceof Error ? error.message : String(error)})`);
|
|
97
84
|
}
|
|
98
85
|
});
|
|
86
|
+
return { loaded, backends: info.sort((a, b) => (a.id < b.id ? -1 : 1)), notices: [...new Set(notices)].sort() };
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Reads every layer of every backend and lays them over the specs. Layers are the only input:
|
|
90
|
+
* a report is a function of the set of layers, so the order of backends and layers, and how they
|
|
91
|
+
* were split across machines, branches and runs, does not change it.
|
|
92
|
+
*
|
|
93
|
+
* A backend that cannot be read, or a layer that cannot be understood, is named in `notices` and
|
|
94
|
+
* leaves everything else usable; a gate must treat any notice as unknown, never as a pass. A layer held by
|
|
95
|
+
* several backends is one layer. Only an invalid backend `id` (not lowercase letters, digits and hyphens, or
|
|
96
|
+
* repeated) throws, before anything is read. Backends are only read from.
|
|
97
|
+
*
|
|
98
|
+
* When `options.outline` is omitted, the specs are the ones the included layers recorded (the newest
|
|
99
|
+
* recording of each spec path wins), with no commit sha.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* const outline = await loadDirectoryOutline('./specs'); // optional: without it the layers' own specs are used
|
|
103
|
+
* const report = await combineBackends([{ id: 'ci', store: directoryStore('./atlas-reports') }], { outline });
|
|
104
|
+
* report.summary.checks.failed; // number of slots whose winner failed
|
|
105
|
+
* report.notices; // non-empty means something could not be read
|
|
106
|
+
*/
|
|
107
|
+
export async function combineBackends(backends, options = {}) {
|
|
108
|
+
const { loaded, backends: info, notices } = await readBackendLayers(backends, options.concurrency === undefined ? {} : { concurrency: options.concurrency });
|
|
99
109
|
const include = options.include;
|
|
100
110
|
const kept = include ? loaded.filter(item => include(item.evidence.layer)) : loaded;
|
|
101
111
|
const layers = kept.map(item => item.evidence);
|
|
@@ -107,5 +117,5 @@ export async function combineBackends(backends, options = {}) {
|
|
|
107
117
|
const outline = options.outline ?? { specs: recorded.entries(), sha: null, changedFiles: [] };
|
|
108
118
|
const combined = combineEvidence(outline, layers);
|
|
109
119
|
const headers = distinctHeaders(layers.map(evidence => evidence.layer)).sort((a, b) => b.recordedAtMs - a.recordedAtMs || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
110
|
-
return { combined, layers: headers, backends: info
|
|
120
|
+
return { combined, layers: headers, backends: info, notices, summary: summarize(combined) };
|
|
111
121
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Reading critiques together
|
|
3
|
+
*
|
|
4
|
+
* What a person or a command wants to know after critics have spoken: which states need polish first (`rankCritiques`), do the
|
|
5
|
+
* critics agree (`critiqueAgreement`), and what did people decide about the opinions (`markStanding`). These are readings of
|
|
6
|
+
* evidence that is already in the report. They count nothing toward a test total and decide no status, slot or gate: an
|
|
7
|
+
* opinion is an opinion, and so is a person's mark of it. Everything here is pure, so the viewer and `atlas critic review`
|
|
8
|
+
* read the same way.
|
|
9
|
+
*
|
|
10
|
+
* @see docs/critics.md
|
|
11
|
+
*/
|
|
12
|
+
import type { Critique } from '../../schema/src/critique.js';
|
|
13
|
+
import type { CatalogEntry, EntryCritique, EntryMark } from './ui-catalog-entries.js';
|
|
14
|
+
/** One entry and the critic's newest critique of it. */
|
|
15
|
+
export type RankedEntry<E extends Pick<CatalogEntry, 'key' | 'state' | 'critiques'> = CatalogEntry> = {
|
|
16
|
+
readonly entry: E;
|
|
17
|
+
readonly found: EntryCritique;
|
|
18
|
+
};
|
|
19
|
+
/** The ranking of the entries one critic judged. */
|
|
20
|
+
export type CritiqueRanking<E extends Pick<CatalogEntry, 'key' | 'state' | 'critiques'> = CatalogEntry> = {
|
|
21
|
+
/** Entries judged at their current picture with a score, lowest first. */
|
|
22
|
+
readonly ranked: readonly RankedEntry<E>[];
|
|
23
|
+
/** How the list was ranked: by standing within the state kind (when every score has one), or by raw score. */
|
|
24
|
+
readonly rankedBy: 'standing-within-kind' | 'score';
|
|
25
|
+
/** Entries whose newest critique judged an earlier picture, or that the critic could not judge: listed, never ranked. */
|
|
26
|
+
readonly unranked: readonly RankedEntry<E>[];
|
|
27
|
+
/** Entries in scope the critic has not judged at all. */
|
|
28
|
+
readonly unjudged: number;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Ranks the entries a critic judged, lowest first, so the likeliest problems are reviewed first. A score is read as
|
|
32
|
+
* higher-is-better (the convention of the critique format). When every ranked score also carries its standing among the scores
|
|
33
|
+
* of the same state kind (`verdict.relative.percentile`) that standing ranks them instead, because a loading overlay is not
|
|
34
|
+
* compared with a full screen. An entry whose picture changed since it was judged, or that the critic could not judge, is not
|
|
35
|
+
* ranked but is returned; an entry the critic never saw is only counted.
|
|
36
|
+
*
|
|
37
|
+
* @param entries the entries in view.
|
|
38
|
+
* @param critic the critic whose critiques rank them.
|
|
39
|
+
* @param kind when given, only entries of this state kind.
|
|
40
|
+
*/
|
|
41
|
+
export declare function rankCritiques<E extends Pick<CatalogEntry, 'key' | 'state' | 'critiques'>>(entries: readonly E[], critic: string | null, kind?: string): CritiqueRanking<E>;
|
|
42
|
+
/** What a critic's opinion amounts to for a reader who wants to know whether it flags the UI: it does, it does not, or it gives no view. */
|
|
43
|
+
export type Stance = 'flags' | 'clear' | 'no-view';
|
|
44
|
+
/** A score at or below this standing within its state kind (the lowest quarter) counts as flagging, when the critic gave no label. */
|
|
45
|
+
export declare const FLAGGING_PERCENTILE = 25;
|
|
46
|
+
/**
|
|
47
|
+
* Whether a critique flags the UI. A failed critique, or one with nothing to go on, gives no view and is never read as fine.
|
|
48
|
+
* Otherwise the critic's own label decides (`needs-polish` flags, `ok` clears); with no label, findings flag; with neither, a score
|
|
49
|
+
* in the lowest quarter of its state kind (`relative.percentile` at most {@link FLAGGING_PERCENTILE}) flags and a higher one clears. A bare
|
|
50
|
+
* score with no standing gives no view, because a raw score is not absolute.
|
|
51
|
+
*/
|
|
52
|
+
export declare function critiqueStance(critique: Critique): Stance;
|
|
53
|
+
/** How the critics who judged an entry at its current picture stand together. */
|
|
54
|
+
export type AgreementOutcome = 'agree-flag' | 'agree-clear' | 'disagree' | 'unclear';
|
|
55
|
+
/** One critic's view of an entry. */
|
|
56
|
+
export type CriticView = {
|
|
57
|
+
readonly critic: string;
|
|
58
|
+
readonly version: string;
|
|
59
|
+
readonly stance: Stance;
|
|
60
|
+
readonly score?: number;
|
|
61
|
+
readonly percentile?: number;
|
|
62
|
+
readonly label?: string;
|
|
63
|
+
};
|
|
64
|
+
/** An entry judged at its current picture by two or more critics, and whether they agree. */
|
|
65
|
+
export type AgreementRow<E extends Pick<CatalogEntry, 'key' | 'state' | 'critiques'> = CatalogEntry> = {
|
|
66
|
+
readonly entry: E;
|
|
67
|
+
readonly views: readonly CriticView[];
|
|
68
|
+
readonly outcome: AgreementOutcome;
|
|
69
|
+
};
|
|
70
|
+
/** The agreement of critics over some entries. */
|
|
71
|
+
export type CritiqueAgreement<E extends Pick<CatalogEntry, 'key' | 'state' | 'critiques'> = CatalogEntry> = {
|
|
72
|
+
/** Entries judged by two or more critics at their current picture, those every critic flags first, then those they disagree on. */
|
|
73
|
+
readonly rows: readonly AgreementRow<E>[];
|
|
74
|
+
/** Entries only one critic has judged at the current picture: there is nothing to compare. */
|
|
75
|
+
readonly single: number;
|
|
76
|
+
/** The critics that have judged at least one entry at its current picture. */
|
|
77
|
+
readonly critics: readonly string[];
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Compares what the critics think of the same pictures. Only critiques of an entry's current picture count, and only each critic's
|
|
81
|
+
* newest. Critics that give no view ({@link critiqueStance}) are shown but do not make an entry agree or disagree: with fewer than
|
|
82
|
+
* two views the outcome is `unclear`, never agreement. An agreement is a reading for people; it is not a verdict, a status or a gate.
|
|
83
|
+
*/
|
|
84
|
+
export declare function critiqueAgreement<E extends Pick<CatalogEntry, 'key' | 'state' | 'critiques'>>(entries: readonly E[], kind?: string): CritiqueAgreement<E>;
|
|
85
|
+
/** What people decided about one opinion (a critic's critique of an entry, or one of its findings). */
|
|
86
|
+
export type MarkStanding = {
|
|
87
|
+
/** `accepted`: only accepts. `dismissed`: only dismissals. `disputed`: reviewers disagree. `unmarked`: nobody has marked it at the current picture. */
|
|
88
|
+
readonly state: 'accepted' | 'dismissed' | 'disputed' | 'unmarked';
|
|
89
|
+
/** Reviewers who accepted it (their name, or the producer when they gave none). */
|
|
90
|
+
readonly accepted: readonly string[];
|
|
91
|
+
/** Reviewers who dismissed it. */
|
|
92
|
+
readonly dismissed: readonly string[];
|
|
93
|
+
/** Marks made about an earlier picture, which count for nothing now. */
|
|
94
|
+
readonly earlier: number;
|
|
95
|
+
/** The notes reviewers left, newest first. */
|
|
96
|
+
readonly notes: readonly string[];
|
|
97
|
+
};
|
|
98
|
+
/** The decision a mark records: `accept`, `dismiss`, or `null` for a word this reader does not know (shown as written, counted as neither). */
|
|
99
|
+
export declare function markDecision(mark: Pick<EntryMark['mark'], 'decision'>): 'accept' | 'dismiss' | null;
|
|
100
|
+
/**
|
|
101
|
+
* What reviewers decided about a critic's opinion of an entry. Only marks made about the picture the entry has now count; those
|
|
102
|
+
* about an earlier picture are tallied apart. Each reviewer's newest mark is the one that is held (the slot rule); a reviewer who
|
|
103
|
+
* accepted and later dismissed has dismissed. Marks with a decision this reader does not know count as neither.
|
|
104
|
+
*
|
|
105
|
+
* @param marks the entry's marks (`CatalogEntry.marks`).
|
|
106
|
+
* @param target the critic whose opinion is marked; `finding` names one finding, and without it the critique as a whole.
|
|
107
|
+
*/
|
|
108
|
+
export declare function markStanding(marks: readonly EntryMark[], target: {
|
|
109
|
+
readonly critic: string;
|
|
110
|
+
readonly finding?: string | undefined;
|
|
111
|
+
}): MarkStanding;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
const byKey = (a, b) => (a.entry.key < b.entry.key ? -1 : a.entry.key > b.entry.key ? 1 : 0);
|
|
2
|
+
/**
|
|
3
|
+
* Ranks the entries a critic judged, lowest first, so the likeliest problems are reviewed first. A score is read as
|
|
4
|
+
* higher-is-better (the convention of the critique format). When every ranked score also carries its standing among the scores
|
|
5
|
+
* of the same state kind (`verdict.relative.percentile`) that standing ranks them instead, because a loading overlay is not
|
|
6
|
+
* compared with a full screen. An entry whose picture changed since it was judged, or that the critic could not judge, is not
|
|
7
|
+
* ranked but is returned; an entry the critic never saw is only counted.
|
|
8
|
+
*
|
|
9
|
+
* @param entries the entries in view.
|
|
10
|
+
* @param critic the critic whose critiques rank them.
|
|
11
|
+
* @param kind when given, only entries of this state kind.
|
|
12
|
+
*/
|
|
13
|
+
export function rankCritiques(entries, critic, kind) {
|
|
14
|
+
const ranked = [], unranked = [];
|
|
15
|
+
let unjudged = 0;
|
|
16
|
+
for (const entry of entries) {
|
|
17
|
+
if (kind && entry.state !== kind)
|
|
18
|
+
continue;
|
|
19
|
+
const found = critic ? entry.critiques.filter(item => item.critique.critic.name === critic) : [];
|
|
20
|
+
// The newest critique that judged the current picture decides; failing that, the newest of any.
|
|
21
|
+
const current = found.find(item => item.currency === 'current');
|
|
22
|
+
const pick = current ?? found[0];
|
|
23
|
+
if (!pick) {
|
|
24
|
+
unjudged += 1;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (current && current.critique.verdict?.score !== undefined && !current.critique.failure)
|
|
28
|
+
ranked.push({ entry, found: current });
|
|
29
|
+
else
|
|
30
|
+
unranked.push({ entry, found: pick });
|
|
31
|
+
}
|
|
32
|
+
const standing = (item) => item.found.critique.verdict?.relative?.percentile;
|
|
33
|
+
const byStanding = ranked.length > 0 && ranked.every(item => standing(item) !== undefined);
|
|
34
|
+
const key = (item) => (byStanding ? standing(item) : item.found.critique.verdict.score);
|
|
35
|
+
ranked.sort((a, b) => key(a) - key(b) || byKey(a, b));
|
|
36
|
+
unranked.sort(byKey);
|
|
37
|
+
return { ranked, rankedBy: byStanding ? 'standing-within-kind' : 'score', unranked, unjudged };
|
|
38
|
+
}
|
|
39
|
+
/** A score at or below this standing within its state kind (the lowest quarter) counts as flagging, when the critic gave no label. */
|
|
40
|
+
export const FLAGGING_PERCENTILE = 25;
|
|
41
|
+
/**
|
|
42
|
+
* Whether a critique flags the UI. A failed critique, or one with nothing to go on, gives no view and is never read as fine.
|
|
43
|
+
* Otherwise the critic's own label decides (`needs-polish` flags, `ok` clears); with no label, findings flag; with neither, a score
|
|
44
|
+
* in the lowest quarter of its state kind (`relative.percentile` at most {@link FLAGGING_PERCENTILE}) flags and a higher one clears. A bare
|
|
45
|
+
* score with no standing gives no view, because a raw score is not absolute.
|
|
46
|
+
*/
|
|
47
|
+
export function critiqueStance(critique) {
|
|
48
|
+
if (critique.failure)
|
|
49
|
+
return 'no-view';
|
|
50
|
+
const label = critique.verdict?.label;
|
|
51
|
+
if (label === 'needs-polish')
|
|
52
|
+
return 'flags';
|
|
53
|
+
if (label === 'ok')
|
|
54
|
+
return 'clear';
|
|
55
|
+
if ((critique.findings ?? []).length > 0)
|
|
56
|
+
return 'flags';
|
|
57
|
+
const percentile = critique.verdict?.relative?.percentile;
|
|
58
|
+
if (percentile !== undefined)
|
|
59
|
+
return percentile <= FLAGGING_PERCENTILE ? 'flags' : 'clear';
|
|
60
|
+
return 'no-view';
|
|
61
|
+
}
|
|
62
|
+
const ORDER = { 'agree-flag': 0, disagree: 1, unclear: 2, 'agree-clear': 3 };
|
|
63
|
+
/**
|
|
64
|
+
* Compares what the critics think of the same pictures. Only critiques of an entry's current picture count, and only each critic's
|
|
65
|
+
* newest. Critics that give no view ({@link critiqueStance}) are shown but do not make an entry agree or disagree: with fewer than
|
|
66
|
+
* two views the outcome is `unclear`, never agreement. An agreement is a reading for people; it is not a verdict, a status or a gate.
|
|
67
|
+
*/
|
|
68
|
+
export function critiqueAgreement(entries, kind) {
|
|
69
|
+
const rows = [];
|
|
70
|
+
const critics = new Set();
|
|
71
|
+
let single = 0;
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
if (kind && entry.state !== kind)
|
|
74
|
+
continue;
|
|
75
|
+
const newest = new Map();
|
|
76
|
+
for (const found of entry.critiques)
|
|
77
|
+
if (found.currency === 'current' && !newest.has(found.critique.critic.name))
|
|
78
|
+
newest.set(found.critique.critic.name, found);
|
|
79
|
+
for (const name of newest.keys())
|
|
80
|
+
critics.add(name);
|
|
81
|
+
if (newest.size < 2) {
|
|
82
|
+
if (newest.size === 1)
|
|
83
|
+
single += 1;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const views = [...newest.values()].map(({ critique }) => ({
|
|
87
|
+
critic: critique.critic.name, version: critique.critic.version, stance: critiqueStance(critique),
|
|
88
|
+
...(critique.verdict?.score === undefined ? {} : { score: critique.verdict.score }),
|
|
89
|
+
...(critique.verdict?.relative?.percentile === undefined ? {} : { percentile: critique.verdict.relative.percentile }),
|
|
90
|
+
...(critique.verdict?.label ? { label: critique.verdict.label } : {})
|
|
91
|
+
})).sort((a, b) => (a.critic < b.critic ? -1 : 1));
|
|
92
|
+
const decided = views.filter(view => view.stance !== 'no-view');
|
|
93
|
+
const outcome = decided.length < 2 ? 'unclear' : decided.every(view => view.stance === 'flags') ? 'agree-flag' : decided.every(view => view.stance === 'clear') ? 'agree-clear' : 'disagree';
|
|
94
|
+
rows.push({ entry, views, outcome });
|
|
95
|
+
}
|
|
96
|
+
rows.sort((a, b) => ORDER[a.outcome] - ORDER[b.outcome] || byKey(a, b));
|
|
97
|
+
return { rows, single, critics: [...critics].sort() };
|
|
98
|
+
}
|
|
99
|
+
/** The decision a mark records: `accept`, `dismiss`, or `null` for a word this reader does not know (shown as written, counted as neither). */
|
|
100
|
+
export function markDecision(mark) {
|
|
101
|
+
return mark.decision === 'accept' || mark.decision === 'dismiss' ? mark.decision : null;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* What reviewers decided about a critic's opinion of an entry. Only marks made about the picture the entry has now count; those
|
|
105
|
+
* about an earlier picture are tallied apart. Each reviewer's newest mark is the one that is held (the slot rule); a reviewer who
|
|
106
|
+
* accepted and later dismissed has dismissed. Marks with a decision this reader does not know count as neither.
|
|
107
|
+
*
|
|
108
|
+
* @param marks the entry's marks (`CatalogEntry.marks`).
|
|
109
|
+
* @param target the critic whose opinion is marked; `finding` names one finding, and without it the critique as a whole.
|
|
110
|
+
*/
|
|
111
|
+
export function markStanding(marks, target) {
|
|
112
|
+
const mine = marks.filter(item => item.mark.critic.name === target.critic && (target.finding === undefined ? item.mark.finding === undefined : item.mark.finding === target.finding));
|
|
113
|
+
const current = mine.filter(item => item.currency === 'current');
|
|
114
|
+
const who = (item) => item.mark.reviewer ?? item.producer;
|
|
115
|
+
const accepted = [...new Set(current.filter(item => markDecision(item.mark) === 'accept').map(who))].sort();
|
|
116
|
+
const dismissed = [...new Set(current.filter(item => markDecision(item.mark) === 'dismiss').map(who))].sort();
|
|
117
|
+
return {
|
|
118
|
+
state: accepted.length && dismissed.length ? 'disputed' : accepted.length ? 'accepted' : dismissed.length ? 'dismissed' : 'unmarked',
|
|
119
|
+
accepted, dismissed,
|
|
120
|
+
earlier: mine.length - current.length,
|
|
121
|
+
notes: current.filter(item => item.mark.note).sort((a, b) => b.recordedAtMs - a.recordedAtMs).map(item => item.mark.note)
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Critiques in report rows
|
|
3
|
+
*
|
|
4
|
+
* A check of a part of kind `critique` carries an automated reviewer's opinion in `extensions["atlas.critique"]`
|
|
5
|
+
* (see `@crvouga/atlas/critics`). This module reads that extension into the optional `critique` field of a report row,
|
|
6
|
+
* so a viewer can list critiques without a second request, exactly as `state` carries a UI state. It decides nothing:
|
|
7
|
+
* the row's `status` says only whether the critic ran, a critique is never counted, and an extension this reader cannot
|
|
8
|
+
* read (a missing subject, a score that is not a number, a version it does not know) leaves the row as it was, an
|
|
9
|
+
* unknown opinion and never a pass.
|
|
10
|
+
*
|
|
11
|
+
* @see docs/critics.md
|
|
12
|
+
*/
|
|
13
|
+
import { type Critique, type CritiqueMark } from '../../schema/src/critique.js';
|
|
14
|
+
import type { Check } from '../../schema/src/layer.js';
|
|
15
|
+
/**
|
|
16
|
+
* The critique a check of a part of `kind` carries, or `undefined` for any other kind and for an extension that cannot be
|
|
17
|
+
* read. Unknown fields and values inside a readable critique are kept.
|
|
18
|
+
*/
|
|
19
|
+
export declare function critiqueOf(kind: string, check: Pick<Check, 'extensions'>): Critique | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* The mark a check of a part of `kind` carries (a reviewer's accept or dismiss of a critic's opinion, `atlas.critique-mark`), or
|
|
22
|
+
* `undefined` for any other kind and for an extension that cannot be read. Like a critique it is evidence: it decides nothing.
|
|
23
|
+
*/
|
|
24
|
+
export declare function critiqueMarkOf(kind: string, check: Pick<Check, 'extensions'>): CritiqueMark | undefined;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Critiques in report rows
|
|
3
|
+
*
|
|
4
|
+
* A check of a part of kind `critique` carries an automated reviewer's opinion in `extensions["atlas.critique"]`
|
|
5
|
+
* (see `@crvouga/atlas/critics`). This module reads that extension into the optional `critique` field of a report row,
|
|
6
|
+
* so a viewer can list critiques without a second request, exactly as `state` carries a UI state. It decides nothing:
|
|
7
|
+
* the row's `status` says only whether the critic ran, a critique is never counted, and an extension this reader cannot
|
|
8
|
+
* read (a missing subject, a score that is not a number, a version it does not know) leaves the row as it was, an
|
|
9
|
+
* unknown opinion and never a pass.
|
|
10
|
+
*
|
|
11
|
+
* @see docs/critics.md
|
|
12
|
+
*/
|
|
13
|
+
import { CRITIQUE_EXTENSION, CRITIQUE_MARK_EXTENSION, CRITIQUE_MARK_SCHEMA_VERSION, CRITIQUE_PART_KIND, CRITIQUE_SCHEMA_VERSION, CritiqueMarkSchema, CritiqueSchema } from '../../schema/src/critique.js';
|
|
14
|
+
/**
|
|
15
|
+
* The critique a check of a part of `kind` carries, or `undefined` for any other kind and for an extension that cannot be
|
|
16
|
+
* read. Unknown fields and values inside a readable critique are kept.
|
|
17
|
+
*/
|
|
18
|
+
export function critiqueOf(kind, check) {
|
|
19
|
+
if (kind !== CRITIQUE_PART_KIND)
|
|
20
|
+
return undefined;
|
|
21
|
+
const raw = check.extensions?.[CRITIQUE_EXTENSION];
|
|
22
|
+
if (raw === undefined)
|
|
23
|
+
return undefined;
|
|
24
|
+
const parsed = CritiqueSchema.safeParse(raw);
|
|
25
|
+
return parsed.success && parsed.data.schema === CRITIQUE_SCHEMA_VERSION ? parsed.data : undefined;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The mark a check of a part of `kind` carries (a reviewer's accept or dismiss of a critic's opinion, `atlas.critique-mark`), or
|
|
29
|
+
* `undefined` for any other kind and for an extension that cannot be read. Like a critique it is evidence: it decides nothing.
|
|
30
|
+
*/
|
|
31
|
+
export function critiqueMarkOf(kind, check) {
|
|
32
|
+
if (kind !== CRITIQUE_PART_KIND)
|
|
33
|
+
return undefined;
|
|
34
|
+
const raw = check.extensions?.[CRITIQUE_MARK_EXTENSION];
|
|
35
|
+
if (raw === undefined)
|
|
36
|
+
return undefined;
|
|
37
|
+
const parsed = CritiqueMarkSchema.safeParse(raw);
|
|
38
|
+
return parsed.success && parsed.data.schema === CRITIQUE_MARK_SCHEMA_VERSION ? parsed.data : undefined;
|
|
39
|
+
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import type { EvidenceMeasurement, EvidenceStatus } from './explorer-types.js';
|
|
12
12
|
import type { EvidenceScope, LayerRun } from '../../schema/src/layer.js';
|
|
13
13
|
import type { LayerParameters } from './layer-types.js';
|
|
14
|
+
import type { Critique, CritiqueMark } from '../../schema/src/critique.js';
|
|
14
15
|
import type { UiStateFact } from './ui-state.js';
|
|
15
16
|
/** Where a recorded test's timeline and captured files are read from. */
|
|
16
17
|
export type RecordingAddress = {
|
|
@@ -179,6 +180,10 @@ export type LayerRow = {
|
|
|
179
180
|
readonly evidence?: EvidenceSummary;
|
|
180
181
|
/** For a check of a `ui-state` or `ui-plan` part: its picture and declared facts, so a catalog is listed without another request (see `ui-state.ts`). Absent for every other kind. */
|
|
181
182
|
readonly state?: UiStateFact;
|
|
183
|
+
/** For a check of a `critique` part whose `atlas.critique` extension can be read: the opinion of an automated reviewer (see `critique-row.ts`). An opinion, never a result: it does not decide `status`, a slot or the gate. Absent for every other kind, and for an extension that cannot be read. */
|
|
184
|
+
readonly critique?: Critique;
|
|
185
|
+
/** For a check of a `critique` part whose `atlas.critique-mark` extension can be read: a person's accept or dismiss of a critic's opinion (see `critique-row.ts`). Evidence, never a result: it does not decide `status`, a slot or the gate. Absent for every other kind, and for an extension that cannot be read. */
|
|
186
|
+
readonly critiqueMark?: CritiqueMark;
|
|
182
187
|
/** What became of this check's expected recording: `uploaded`, `referenced`, `skipped`, `unresolved` or `not-applicable`, with a reason when it is not uploaded. Absent when the check declared no expectation. Independent of `status`. */
|
|
183
188
|
readonly recording?: {
|
|
184
189
|
readonly state: string;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { summarizeStoredEvidence, summarizeStoredLayerEvidence } from './evidence-detail.js';
|
|
2
2
|
import { effectiveScope } from './evidence-scope.js';
|
|
3
3
|
import { layerPlace } from './layer-axes.js';
|
|
4
|
+
import { critiqueMarkOf, critiqueOf } from './critique-row.js';
|
|
4
5
|
import { UI_PLAN_KIND, uiStateOf } from './ui-state.js';
|
|
5
6
|
/**
|
|
6
7
|
* A short, deterministic fingerprint of a spec's text, shaped `<length>:<hash>:<hash>` (the length in UTF-16 code
|
|
@@ -100,6 +101,8 @@ export function layerEvidence(summary, backend) {
|
|
|
100
101
|
const evidence = summarizeStoredEvidence(effectiveScope(part.scope, summary.scope), unstored, check);
|
|
101
102
|
const item = expectations?.get(check.id);
|
|
102
103
|
const state = uiStateOf(part.kind, check);
|
|
104
|
+
const critique = critiqueOf(part.kind, check);
|
|
105
|
+
const critiqueMark = critiqueMarkOf(part.kind, check);
|
|
103
106
|
rows.push({
|
|
104
107
|
kind: part.kind,
|
|
105
108
|
id: check.id,
|
|
@@ -113,6 +116,8 @@ export function layerEvidence(summary, backend) {
|
|
|
113
116
|
durationMs: test?.durationMs ?? check.durationMs ?? null,
|
|
114
117
|
...(evidence ? { evidence } : {}),
|
|
115
118
|
...(state ? { state } : {}),
|
|
119
|
+
...(critique ? { critique } : {}),
|
|
120
|
+
...(critiqueMark ? { critiqueMark } : {}),
|
|
116
121
|
...(item ? { recording: { state: item.state, ...(item.reason ? { reason: item.reason } : {}) } } : {}),
|
|
117
122
|
});
|
|
118
123
|
}
|