session-orchestrator 4.2.0 → 5.0.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/.agents/skills/session-start/SKILL.md +1 -1
- package/.agents/skills/ux-grill/SKILL.md +22 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +3 -2
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/session-start/SKILL.md +1 -1
- package/.codex-plugin/skills/ux-grill/SKILL.md +21 -0
- package/.codex-plugin/skills/ux-grill/agents/openai.yaml +5 -0
- package/.cursor/commands/ux-grill.md +14 -0
- package/.cursor/skills/session-start/SKILL.md +1 -1
- package/.cursor/skills/ux-grill/SKILL.md +13 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +94 -1
- package/README.md +98 -86
- package/agents/dialectic-deriver.md +11 -0
- package/commands/ux-grill.md +51 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/codex-setup.md +8 -0
- package/docs/components.md +7 -7
- package/docs/events-schema.md +9 -5
- package/docs/github-mirror-protection.md +61 -20
- package/docs/migration-v5.md +62 -0
- package/docs/scope-collision-guard.md +14 -0
- package/hooks/_lib/hook-import-set.json +44 -2
- package/hooks/_lib/lock-bootstrap.mjs +84 -1
- package/hooks/_lib/vcs-create-matcher.mjs +190 -3
- package/hooks/enforce-scope.mjs +201 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks.json +7 -2
- package/hooks/on-session-start.mjs +171 -49
- package/hooks/post-bash-issue-budget-refund.mjs +375 -0
- package/hooks/pre-auq-clarity.mjs +70 -18
- package/hooks/pre-bash-issue-budget.mjs +51 -4
- package/package.json +2 -1
- package/pi/prompts/ux-grill.md +12 -0
- package/scripts/ci/assert-vitest-green.mjs +4 -2
- package/scripts/dialectic-deriver.mjs +32 -8
- package/scripts/emit-session.mjs +72 -1
- package/scripts/lib/agent-status.mjs +441 -9
- package/scripts/lib/auq/schema.mjs +10 -3
- package/scripts/lib/ci-status-banner.mjs +29 -6
- package/scripts/lib/claude-md-budget-lint.mjs +52 -2
- package/scripts/lib/config.mjs +12 -1
- package/scripts/lib/eval/engine.mjs +7 -1
- package/scripts/lib/file-lock.mjs +114 -13
- package/scripts/lib/git-porcelain.mjs +113 -0
- package/scripts/lib/instruction-budget-guard.mjs +415 -47
- package/scripts/lib/io.mjs +29 -4
- package/scripts/lib/issue-budget.mjs +336 -6
- package/scripts/lib/learnings/sizing-subject.mjs +44 -0
- package/scripts/lib/locks/staging-fence-lock.mjs +19 -38
- package/scripts/lib/locks/state-md-lock.mjs +19 -41
- package/scripts/lib/maintenance-due-banner.mjs +11 -1
- package/scripts/lib/peer-cards/merger.mjs +143 -0
- package/scripts/lib/pre-dispatch-check.mjs +20 -14
- package/scripts/lib/project-hygiene.mjs +81 -30
- package/scripts/lib/quality-gate.mjs +14 -65
- package/scripts/lib/reconcile/engine.mjs +19 -1
- package/scripts/lib/reconcile/writer.mjs +278 -11
- package/scripts/lib/scope-echo.mjs +346 -0
- package/scripts/lib/session-lock.mjs +62 -2
- package/scripts/lib/session-record-repair.mjs +91 -0
- package/scripts/lib/session-schema/filters.mjs +26 -1
- package/scripts/lib/session-start-probes.mjs +419 -53
- package/scripts/lib/test-runner/artifact-paths.mjs +30 -5
- package/scripts/lib/test-runner/issue-reconcile.mjs +45 -8
- package/scripts/lib/tmux-layout/layouts.mjs +62 -4
- package/scripts/lib/ux-grill/collect.mjs +1163 -0
- package/scripts/lib/ux-grill/compare.mjs +285 -0
- package/scripts/lib/ux-grill/manifest.mjs +618 -0
- package/scripts/lib/ux-grill/measures.mjs +431 -0
- package/scripts/lib/ux-grill/paths.mjs +224 -0
- package/scripts/lib/ux-grill/pencil-coverage.mjs +284 -0
- package/scripts/lib/ux-grill/reconcile.mjs +344 -0
- package/scripts/lib/ux-grill/run-record.mjs +316 -0
- package/scripts/lib/ux-grill/schema.mjs +321 -0
- package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
- package/scripts/lib/validate/check-unwired-features.mjs +48 -20
- package/scripts/lib/vault-status/board-lock.mjs +18 -0
- package/scripts/lib/vault-status/board-writer.mjs +8 -0
- package/scripts/mcp-server.sh +16 -1
- package/scripts/release.mjs +7 -2
- package/skills/bootstrap/SKILL.md +12 -209
- package/skills/bootstrap/references/bootstrap-ecosystem-health-flow.md +48 -0
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +37 -0
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +108 -0
- package/skills/bootstrap/references/bootstrap-rules-fetch-bridge.md +64 -0
- package/skills/claude-md-drift-check/SKILL.md +9 -2
- package/skills/claude-md-drift-check/checker.mjs +213 -21
- package/skills/discovery/SKILL.md +6 -173
- package/skills/discovery/probes/vault-staleness.mjs +35 -5
- package/skills/discovery/probes-docs.md +8 -4
- package/skills/discovery/probes-supply-chain.md +4 -2
- package/skills/discovery/probes-ui.md +7 -3
- package/skills/discovery/probes-vault.md +12 -4
- package/skills/discovery/references/discovery-interactive-triage.md +139 -0
- package/skills/discovery/references/discovery-triage-state.md +54 -0
- package/skills/eval/rubric-v1.md +13 -0
- package/skills/evolve/SKILL.md +2 -458
- package/skills/evolve/references/evolve-analyze-mode.md +360 -0
- package/skills/evolve/references/evolve-dialectic-mode.md +139 -0
- package/skills/plan/mode-retro.md +4 -3
- package/skills/reconcile/SKILL.md +10 -0
- package/skills/session-end/drift-operations.md +20 -5
- package/skills/session-end/metrics-collection.md +1 -0
- package/skills/session-end/phase-3-6-tail.md +4 -2
- package/skills/session-end/references/phase-2-quality-gate.md +3 -3
- package/skills/session-end/references/phase-5-issue-cleanup.md +6 -1
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-plan/SKILL.md +2 -144
- package/skills/session-plan/references/session-plan-task-classification.md +152 -0
- package/skills/session-start/SKILL.md +24 -6
- package/skills/session-start/references/operations-contract.md +114 -0
- package/skills/session-start/references/phase-4-ssot-environment-check.md +22 -20
- package/skills/session-start/soul.md +2 -2
- package/skills/test-runner/SKILL.md +1 -1
- package/skills/tmux-layout/SKILL.md +3 -1
- package/skills/ux-grill/SKILL.md +211 -0
- package/skills/ux-grill/rubric-v2.md +201 -0
- package/skills/ux-grill/soul.md +76 -0
- package/skills/wave-executor/SKILL.md +3 -128
- package/skills/wave-executor/references/wave-executor-quality-gate.md +61 -0
- package/skills/wave-executor/references/wave-executor-state-init.md +86 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
- package/skills/wave-executor/references/wave-loop-review.md +18 -5
- package/templates/_shared/ux-manifest.template.md +149 -0
- package/templates/_shared/journey-manifest.md +0 -114
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ux-grill/run-record.mjs — the ONLY reader/writer of the ux-grill run-record
|
|
3
|
+
* ledger (`.orchestrator/metrics/ux-grill.jsonl`) and of a run's `findings.jsonl`.
|
|
4
|
+
*
|
|
5
|
+
* Leaf-ish module: `node:fs` + `node:path` plus the two ux-grill leaf modules
|
|
6
|
+
* (`./paths.mjs`, `./schema.mjs`). No shell-outs, no network, no other project
|
|
7
|
+
* imports — so `compare.mjs` can depend on it without dragging a closure in.
|
|
8
|
+
*
|
|
9
|
+
* WHY a module and not `fs.appendFileSync` at each call site: the record is
|
|
10
|
+
* written ONCE by `collect()` with `compare` at its schema defaults
|
|
11
|
+
* (`{new:0, persisting:0, fixed:0}`) and PATCHED later by `compare.mjs`, after
|
|
12
|
+
* the previous run has been located. Two writers on one append-only file need a
|
|
13
|
+
* single place that knows (a) how a record is validated before it is appended,
|
|
14
|
+
* (b) that the patch is a line rewrite which must preserve line ORDER and every
|
|
15
|
+
* line it does not own, including unparseable ones.
|
|
16
|
+
*
|
|
17
|
+
* TOLERANT READS, COUNTED. Both readers skip a line they cannot parse and
|
|
18
|
+
* return how many they skipped, instead of throwing. A ledger is append-only
|
|
19
|
+
* telemetry that several processes write; one truncated line must not make the
|
|
20
|
+
* whole comparison impossible — but a silently-dropped line is exactly the
|
|
21
|
+
* "measurement over an unnamed population" trap, so the count is part of the
|
|
22
|
+
* return value and callers are expected to surface it.
|
|
23
|
+
*
|
|
24
|
+
* Exports:
|
|
25
|
+
* appendRunRecord(repoRoot, record)
|
|
26
|
+
* readRunRecords(repoRoot, {manifestHash, build, limit})
|
|
27
|
+
* findPreviousRun(repoRoot, {manifestHash, build, beforeRunId})
|
|
28
|
+
* updateRunRecordCompare(repoRoot, runId, compare)
|
|
29
|
+
* readFindings(repoRoot, runId)
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import fs from 'node:fs';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
|
|
35
|
+
import { findingsPath, runRecordPath } from './paths.mjs';
|
|
36
|
+
import { makeRunRecord } from './schema.mjs';
|
|
37
|
+
|
|
38
|
+
/** Default number of (filtered) records `readRunRecords` returns. */
|
|
39
|
+
const DEFAULT_READ_LIMIT = 50;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Read a UTF-8 file, returning `null` when it does not exist.
|
|
43
|
+
* Any other error (EACCES, EISDIR) propagates — that is a real defect, not an
|
|
44
|
+
* empty history, and must not read as "no previous run".
|
|
45
|
+
*
|
|
46
|
+
* @param {string} file
|
|
47
|
+
* @returns {string|null}
|
|
48
|
+
*/
|
|
49
|
+
function readTextOrNull(file) {
|
|
50
|
+
try {
|
|
51
|
+
return fs.readFileSync(file, 'utf8');
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (error?.code === 'ENOENT') return null;
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parse a JSONL text blob into objects, counting the lines that did not parse.
|
|
60
|
+
*
|
|
61
|
+
* @param {string|null} text
|
|
62
|
+
* @param {(value: unknown) => boolean} isRecord - shape predicate; a parsed
|
|
63
|
+
* line failing it counts as skipped, same as a syntax error.
|
|
64
|
+
* @returns {{values: object[], skippedLines: number}}
|
|
65
|
+
*/
|
|
66
|
+
function parseJsonl(text, isRecord) {
|
|
67
|
+
const values = [];
|
|
68
|
+
let skippedLines = 0;
|
|
69
|
+
if (text === null) return { values, skippedLines };
|
|
70
|
+
for (const raw of text.split('\n')) {
|
|
71
|
+
const line = raw.trim();
|
|
72
|
+
if (line.length === 0) continue;
|
|
73
|
+
let parsed;
|
|
74
|
+
try {
|
|
75
|
+
parsed = JSON.parse(line);
|
|
76
|
+
} catch {
|
|
77
|
+
skippedLines += 1;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (!isRecord(parsed)) {
|
|
81
|
+
skippedLines += 1;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
values.push(parsed);
|
|
85
|
+
}
|
|
86
|
+
return { values, skippedLines };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A run-record is usable only when it carries the compare key: `run_id`. */
|
|
90
|
+
function isRunRecord(value) {
|
|
91
|
+
return typeof value === 'object' && value !== null && typeof value.run_id === 'string';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A finding is usable only when it carries its identity: `fingerprint`. */
|
|
95
|
+
function isFinding(value) {
|
|
96
|
+
return typeof value === 'object' && value !== null && typeof value.fingerprint === 'string';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Append ONE run-record to the ledger, creating the metrics directory if needed.
|
|
101
|
+
*
|
|
102
|
+
* The record is re-validated through {@link makeRunRecord} (required fields,
|
|
103
|
+
* `build` enum, `skipped[].reason` enum) but the ORIGINAL object is what gets
|
|
104
|
+
* written — re-validation must never silently re-default a field the caller
|
|
105
|
+
* set. Invalid input throws rather than appending a half-record: a ledger line
|
|
106
|
+
* nothing can parse is worse than a missing one, because the next reader counts
|
|
107
|
+
* it as `skippedLines` forever.
|
|
108
|
+
*
|
|
109
|
+
* This is the function `collect.mjs` calls (`collect.mjs:60` imports it, `:970`
|
|
110
|
+
* invokes it) — verified 2026-09-12, which is what makes the module header's
|
|
111
|
+
* "ONLY writer" claim true rather than aspirational.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} repoRoot - absolute repo root of the TARGET repo
|
|
114
|
+
* @param {object} record - a record built by `makeRunRecord`
|
|
115
|
+
* @returns {object} the same record, for chaining
|
|
116
|
+
* @throws {TypeError} when the record fails schema validation
|
|
117
|
+
*/
|
|
118
|
+
export function appendRunRecord(repoRoot, record) {
|
|
119
|
+
if (typeof record !== 'object' || record === null || Array.isArray(record)) {
|
|
120
|
+
throw new TypeError('appendRunRecord: record must be a plain object');
|
|
121
|
+
}
|
|
122
|
+
// Throws on any invalid required field; the return value is deliberately unused.
|
|
123
|
+
makeRunRecord({
|
|
124
|
+
runId: record.run_id,
|
|
125
|
+
manifestHash: record.manifest_hash,
|
|
126
|
+
rubricHash: record.rubric_hash,
|
|
127
|
+
build: record.build,
|
|
128
|
+
skipped: Array.isArray(record.skipped) ? record.skipped : [],
|
|
129
|
+
countsBySeverity: record.counts_by_severity,
|
|
130
|
+
provisionalCount: record.provisional_count,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const ledger = runRecordPath(repoRoot);
|
|
134
|
+
fs.mkdirSync(path.dirname(ledger), { recursive: true });
|
|
135
|
+
fs.appendFileSync(ledger, `${JSON.stringify(record)}\n`, 'utf8');
|
|
136
|
+
return record;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Read run-records from the ledger, newest LAST (file/append order preserved).
|
|
141
|
+
*
|
|
142
|
+
* @param {string} repoRoot
|
|
143
|
+
* @param {object} [opts]
|
|
144
|
+
* @param {string} [opts.manifestHash] - keep only records with this `manifest_hash`
|
|
145
|
+
* @param {string} [opts.build] - keep only records with this `build`
|
|
146
|
+
* @param {number} [opts.limit=50] - keep at most this many of the MOST RECENT
|
|
147
|
+
* matches (the tail of the filtered list). `Infinity` reads everything.
|
|
148
|
+
* @returns {{records: object[], skippedLines: number}} `skippedLines` counts
|
|
149
|
+
* lines that were unparseable OR carried no `run_id`.
|
|
150
|
+
*/
|
|
151
|
+
export function readRunRecords(repoRoot, { manifestHash, build, limit = DEFAULT_READ_LIMIT } = {}) {
|
|
152
|
+
const { values, skippedLines } = parseJsonl(readTextOrNull(runRecordPath(repoRoot)), isRunRecord);
|
|
153
|
+
let records = values;
|
|
154
|
+
if (manifestHash !== undefined) records = records.filter((r) => r.manifest_hash === manifestHash);
|
|
155
|
+
if (build !== undefined) records = records.filter((r) => r.build === build);
|
|
156
|
+
if (Number.isFinite(limit) && limit >= 0 && records.length > limit) {
|
|
157
|
+
records = records.slice(records.length - limit);
|
|
158
|
+
}
|
|
159
|
+
return { records, skippedLines };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Find the run to compare the current run against.
|
|
164
|
+
*
|
|
165
|
+
* The baseline must share BOTH keys:
|
|
166
|
+
* - `manifest_hash` — a different manifest measured different routes/viewports,
|
|
167
|
+
* so a fingerprint missing from it was never looked for, not fixed.
|
|
168
|
+
* - `build` — a dev build is not a measurement basis for a prod run (a dev
|
|
169
|
+
* bundle's geometry differs, which is exactly why `makeFinding` marks
|
|
170
|
+
* dev-build target-size findings `provisional`). Comparing across builds
|
|
171
|
+
* would report layout noise as `new`/`fixed` product change.
|
|
172
|
+
*
|
|
173
|
+
* `rubric_hash` is deliberately NOT a filter: a rubric change must still be
|
|
174
|
+
* comparable, it only changes how `fixed` may be READ — see `compare.mjs`
|
|
175
|
+
* § rubricChanged.
|
|
176
|
+
*
|
|
177
|
+
* @param {string} repoRoot
|
|
178
|
+
* @param {object} opts
|
|
179
|
+
* @param {string} opts.manifestHash
|
|
180
|
+
* @param {string} opts.build
|
|
181
|
+
* @param {string} [opts.beforeRunId] - the CURRENT run; records from its ledger
|
|
182
|
+
* line onward are ignored, so a run never compares against itself or against
|
|
183
|
+
* a run appended after it.
|
|
184
|
+
* @returns {object|null} the most recent matching record, or `null` (baseline run)
|
|
185
|
+
*/
|
|
186
|
+
export function findPreviousRun(repoRoot, { manifestHash, build, beforeRunId } = {}) {
|
|
187
|
+
const { records } = readRunRecords(repoRoot, { limit: Infinity });
|
|
188
|
+
let scope = records;
|
|
189
|
+
if (typeof beforeRunId === 'string' && beforeRunId.length > 0) {
|
|
190
|
+
const index = records.findIndex((r) => r.run_id === beforeRunId);
|
|
191
|
+
if (index >= 0) scope = records.slice(0, index);
|
|
192
|
+
}
|
|
193
|
+
for (let i = scope.length - 1; i >= 0; i -= 1) {
|
|
194
|
+
const record = scope[i];
|
|
195
|
+
if (manifestHash !== undefined && record.manifest_hash !== manifestHash) continue;
|
|
196
|
+
if (build !== undefined && record.build !== build) continue;
|
|
197
|
+
return record;
|
|
198
|
+
}
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Validate a compare-counts object: exactly three non-negative integers.
|
|
204
|
+
* @param {unknown} compare
|
|
205
|
+
* @returns {{new: number, persisting: number, fixed: number}}
|
|
206
|
+
* @throws {TypeError}
|
|
207
|
+
*/
|
|
208
|
+
function assertCompareCounts(compare) {
|
|
209
|
+
if (typeof compare !== 'object' || compare === null || Array.isArray(compare)) {
|
|
210
|
+
throw new TypeError('updateRunRecordCompare: compare must be a plain object');
|
|
211
|
+
}
|
|
212
|
+
const out = {};
|
|
213
|
+
for (const key of ['new', 'persisting', 'fixed']) {
|
|
214
|
+
const value = compare[key];
|
|
215
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
216
|
+
throw new TypeError(`updateRunRecordCompare: compare.${key} must be a non-negative integer, got ${String(value)}`);
|
|
217
|
+
}
|
|
218
|
+
out[key] = value;
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Patch the `compare` counts of ONE already-appended run-record, in place.
|
|
225
|
+
*
|
|
226
|
+
* Rewrites the ledger atomically (tmp file in the same directory + `rename`) so
|
|
227
|
+
* a crash mid-write cannot leave a truncated ledger. Line ORDER is preserved and
|
|
228
|
+
* every line this call does not own is written back BYTE-IDENTICALLY, including
|
|
229
|
+
* lines that do not parse — a repair pass must never be the thing that destroys
|
|
230
|
+
* the evidence it was reading (the raw-sidecar lesson, one layer up).
|
|
231
|
+
*
|
|
232
|
+
* The record carries COUNTS, never the fingerprint lists — that is the schema
|
|
233
|
+
* (`makeRunRecord` § compare). The lists stay in the caller's hands.
|
|
234
|
+
*
|
|
235
|
+
* BV-004 — deliberate simplification, its CEILING and its REVISIT TRIGGER:
|
|
236
|
+
* this is a WHOLE-LEDGER rewrite (read every line, re-serialise the one that
|
|
237
|
+
* matches, `rename` the lot) rather than a seek-and-patch. Ceiling: fine while
|
|
238
|
+
* `.orchestrator/metrics/ux-grill.jsonl` stays under ~10k records — one record
|
|
239
|
+
* per `/ux-grill` run, i.e. years of routine use at a few runs per session.
|
|
240
|
+
* REVISIT TRIGGER: the ledger passing ~10k lines, or `/ux-grill` becoming a
|
|
241
|
+
* per-commit/CI step rather than an operator-invoked one. Either makes the
|
|
242
|
+
* rewrite O(n) per compare over a file nobody reads whole.
|
|
243
|
+
*
|
|
244
|
+
* CONCURRENCY — decided, not overlooked: this function takes NO lock, and a
|
|
245
|
+
* concurrent {@link appendRunRecord} landing between the read above and the
|
|
246
|
+
* `rename` below would be lost. That is accepted because ONE ux-grill run per
|
|
247
|
+
* repo is the only shape the surrounding design admits: Stufe 1 is a single
|
|
248
|
+
* coordinator-direct Bash call (`skills/ux-grill/SKILL.md` § 0.4 — "exactly one
|
|
249
|
+
* Bash invocation", "No dispatched agent runs Stufe 1"), and the compare step
|
|
250
|
+
* runs after it in the same thread. Taking `withFileLock` from
|
|
251
|
+
* `scripts/lib/file-lock.mjs` would additionally force this whole module async
|
|
252
|
+
* for a race the design forbids. REVISIT TRIGGER for the assumption: the first
|
|
253
|
+
* caller that runs `collect()` in parallel (multi-target sweep, CI matrix) —
|
|
254
|
+
* then use `withFileLock` here AND in `appendRunRecord`, never only one of them.
|
|
255
|
+
*
|
|
256
|
+
* @param {string} repoRoot
|
|
257
|
+
* @param {string} runId
|
|
258
|
+
* @param {{new: number, persisting: number, fixed: number}} compare
|
|
259
|
+
* @returns {{updated: number, skippedLines: number}} `updated` is how many
|
|
260
|
+
* ledger lines matched `runId` (0 = the record is not in the ledger; the
|
|
261
|
+
* caller decides whether that is an error).
|
|
262
|
+
* @throws {TypeError} on a malformed `compare`
|
|
263
|
+
*/
|
|
264
|
+
export function updateRunRecordCompare(repoRoot, runId, compare) {
|
|
265
|
+
const counts = assertCompareCounts(compare);
|
|
266
|
+
const ledger = runRecordPath(repoRoot);
|
|
267
|
+
const text = readTextOrNull(ledger);
|
|
268
|
+
if (text === null) return { updated: 0, skippedLines: 0 };
|
|
269
|
+
|
|
270
|
+
let updated = 0;
|
|
271
|
+
let skippedLines = 0;
|
|
272
|
+
const out = [];
|
|
273
|
+
for (const raw of text.split('\n')) {
|
|
274
|
+
if (raw.trim().length === 0) continue;
|
|
275
|
+
let parsed;
|
|
276
|
+
try {
|
|
277
|
+
parsed = JSON.parse(raw);
|
|
278
|
+
} catch {
|
|
279
|
+
skippedLines += 1;
|
|
280
|
+
out.push(raw); // preserved verbatim — never dropped by a patch pass
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (isRunRecord(parsed) && parsed.run_id === runId) {
|
|
284
|
+
parsed.compare = { ...counts };
|
|
285
|
+
updated += 1;
|
|
286
|
+
out.push(JSON.stringify(parsed));
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (!isRunRecord(parsed)) skippedLines += 1;
|
|
290
|
+
out.push(raw);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const tmp = `${ledger}.${process.pid}.tmp`;
|
|
294
|
+
fs.writeFileSync(tmp, out.length > 0 ? `${out.join('\n')}\n` : '', 'utf8');
|
|
295
|
+
fs.renameSync(tmp, ledger);
|
|
296
|
+
return { updated, skippedLines };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Read one run's `findings.jsonl`.
|
|
301
|
+
*
|
|
302
|
+
* Returns an ENVELOPE rather than a bare array so the dropped-line count has
|
|
303
|
+
* somewhere to live (see module header): a compare run that silently lost three
|
|
304
|
+
* previous findings would report them as `fixed`.
|
|
305
|
+
*
|
|
306
|
+
* @param {string} repoRoot
|
|
307
|
+
* @param {string} runId
|
|
308
|
+
* @returns {{findings: object[], skippedLines: number}} `findings: []` when the
|
|
309
|
+
* run produced none AND when the file does not exist — `skippedLines` does not
|
|
310
|
+
* distinguish those; the caller that needs to tell them apart checks the run
|
|
311
|
+
* record's `skipped[]`, which is what that field is for.
|
|
312
|
+
*/
|
|
313
|
+
export function readFindings(repoRoot, runId) {
|
|
314
|
+
const { values, skippedLines } = parseJsonl(readTextOrNull(findingsPath(repoRoot, runId)), isFinding);
|
|
315
|
+
return { findings: values, skippedLines };
|
|
316
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ux-grill/schema.mjs — Frozen contract for Stufe-1 (mechanical) ux-grill records.
|
|
3
|
+
*
|
|
4
|
+
* Leaf module: imports only `../test-runner/fingerprint.mjs` and the pure
|
|
5
|
+
* `../crypto-digest-utils.mjs` (no I/O, no side effects; `node:crypto` reaches
|
|
6
|
+
* it only through that helper). Every producer of a ux-grill finding or run-record
|
|
7
|
+
* (`collect.mjs`, `measures.mjs`, `compare.mjs`) goes through this module so the
|
|
8
|
+
* fingerprint inputs and the severity table have exactly one definition.
|
|
9
|
+
*
|
|
10
|
+
* Spec: docs/prd/2026-09-12-ux-grill.md § 2 S2/S3 and § 4 Data Model.
|
|
11
|
+
*
|
|
12
|
+
* Exports:
|
|
13
|
+
* SCOPE, CHECK_IDS, SEVERITIES, SEVERITY_BY_CHECK, SKIP_REASONS,
|
|
14
|
+
* RUN_RECORD_SCHEMA_VERSION, LOCATOR_MAX_LENGTH,
|
|
15
|
+
* severityForAxeImpact(), makeFinding(), countBySeverity(), makeRunRecord()
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { digestSha256Short } from '../crypto-digest-utils.mjs';
|
|
19
|
+
import { fingerprintFinding } from '../test-runner/fingerprint.mjs';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Fingerprint scope for every ux-grill finding. Constant by contract: it is a
|
|
23
|
+
* fingerprint input, so changing it invalidates every previously filed issue.
|
|
24
|
+
* @type {string}
|
|
25
|
+
*/
|
|
26
|
+
export const SCOPE = 'ux-grill';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The check identifiers ux-grill Stufe 1 can emit (PRD § 2 S3 table).
|
|
30
|
+
*
|
|
31
|
+
* `axe-violations` is the CATALOGUE entry only — an emitted axe finding carries
|
|
32
|
+
* `checkId = 'axe-<ruleId>'` (rubric-v1 § Fingerprint), so that two axe rules
|
|
33
|
+
* violated on the same selector remain two distinct findings.
|
|
34
|
+
* @type {Readonly<Record<string, string>>}
|
|
35
|
+
*/
|
|
36
|
+
export const CHECK_IDS = Object.freeze({
|
|
37
|
+
AXE_VIOLATIONS: 'axe-violations',
|
|
38
|
+
CONSOLE_ERRORS: 'console-errors',
|
|
39
|
+
JOURNEY_STEP_COUNT: 'journey-step-count',
|
|
40
|
+
JOURNEY_FAILED: 'journey-failed',
|
|
41
|
+
TARGET_SIZE_FLOOR: 'target-size-floor',
|
|
42
|
+
TARGET_SIZE_TARGET: 'target-size-target',
|
|
43
|
+
HORIZONTAL_OVERFLOW: 'horizontal-overflow',
|
|
44
|
+
TITLE_MISMATCH: 'title-mismatch',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Severities ux-grill Stufe 1 emits, most severe first.
|
|
49
|
+
*
|
|
50
|
+
* Note: `skills/test-runner/rubric-v1.md` additionally knows `critical` for
|
|
51
|
+
* issue routing. Stufe 1 of ux-grill NEVER emits `critical` — every severity
|
|
52
|
+
* here is derived from a measured value (PRD § 2 S3), and no measured ux
|
|
53
|
+
* violation is defined as release-blocking. Stufe 2 emits no severity at all.
|
|
54
|
+
* @type {readonly string[]}
|
|
55
|
+
*/
|
|
56
|
+
export const SEVERITIES = Object.freeze(['high', 'medium', 'low']);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Static severity per check id (PRD § 2 S3). Axe is absent by design — its
|
|
60
|
+
* severity comes from the impact field, see {@link severityForAxeImpact}.
|
|
61
|
+
* @type {Readonly<Record<string, string>>}
|
|
62
|
+
*/
|
|
63
|
+
export const SEVERITY_BY_CHECK = Object.freeze({
|
|
64
|
+
[CHECK_IDS.TARGET_SIZE_FLOOR]: 'high',
|
|
65
|
+
[CHECK_IDS.TARGET_SIZE_TARGET]: 'medium',
|
|
66
|
+
[CHECK_IDS.HORIZONTAL_OVERFLOW]: 'medium',
|
|
67
|
+
[CHECK_IDS.JOURNEY_FAILED]: 'high',
|
|
68
|
+
[CHECK_IDS.JOURNEY_STEP_COUNT]: 'medium',
|
|
69
|
+
[CHECK_IDS.CONSOLE_ERRORS]: 'medium',
|
|
70
|
+
[CHECK_IDS.TITLE_MISMATCH]: 'low',
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Reasons a Stufe-1 step may be recorded as skipped in the run-record.
|
|
75
|
+
*
|
|
76
|
+
* - `device-mismatch` — `set device` / `set viewport` did not produce the
|
|
77
|
+
* requested `window.innerWidth`; the viewport is skipped rather than filed
|
|
78
|
+
* under a wrong label (PRD § 3 AC "Stufe 1").
|
|
79
|
+
* - `pencil-unavailable` — Pen.app or its MCP surface is not reachable; the
|
|
80
|
+
* optional coverage step is skipped and the run still ends without error.
|
|
81
|
+
* - `route-unreachable` — the route failed to load. It is a SKIP, not a
|
|
82
|
+
* dropped route: a route that never loaded produced no measurement, so
|
|
83
|
+
* emitting zero findings for it would read as "clean" in the next compare
|
|
84
|
+
* run and silently turn an outage into a `fixed` classification. Also used
|
|
85
|
+
* for a journey whose `start` page never opened.
|
|
86
|
+
* - `measure-failed` — ONE browser measurement on an otherwise reachable page
|
|
87
|
+
* did not produce a usable payload: a non-zero exit, an unparseable stdout,
|
|
88
|
+
* or an `agent-browser` envelope carrying `success: false` (measured
|
|
89
|
+
* 2026-09-12, v0.37.1: a page-side `eval` throw answers `{"success":false,
|
|
90
|
+
* "data":null,...}` at EXIT CODE 0). Without this reason such a call is
|
|
91
|
+
* indistinguishable from "measured, found nothing" — zero findings for a
|
|
92
|
+
* check that never ran, which the next compare run reads as `fixed`.
|
|
93
|
+
* @type {Readonly<Record<string, string>>}
|
|
94
|
+
*/
|
|
95
|
+
export const SKIP_REASONS = Object.freeze({
|
|
96
|
+
DEVICE_MISMATCH: 'device-mismatch',
|
|
97
|
+
PENCIL_UNAVAILABLE: 'pencil-unavailable',
|
|
98
|
+
ROUTE_UNREACHABLE: 'route-unreachable',
|
|
99
|
+
MEASURE_FAILED: 'measure-failed',
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Schema version of the `.orchestrator/metrics/ux-grill.jsonl` run-record.
|
|
104
|
+
* @type {number}
|
|
105
|
+
*/
|
|
106
|
+
export const RUN_RECORD_SCHEMA_VERSION = 1;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Locators longer than this are truncated in the finding RECORD
|
|
110
|
+
* (`skills/test-runner/rubric-v1.md` § Truncation rule for long locators).
|
|
111
|
+
*
|
|
112
|
+
* Deliberate deviation from rubric-v1 for the FINGERPRINT input (#1334): an
|
|
113
|
+
* over-long locator is fingerprinted as `truncated + ':' + sha256(full).slice(0, 8)`,
|
|
114
|
+
* not as the bare truncation. The audited page controls its selectors, so with
|
|
115
|
+
* bare truncation a decoy under a >256-char class chain sharing the real
|
|
116
|
+
* element's prefix would take its fingerprint and shadow it in `compare.mjs`.
|
|
117
|
+
* Locators of ≤ 256 chars keep their fingerprint unchanged; the suffixed input
|
|
118
|
+
* is 265 chars long, so it can never equal an untruncated one.
|
|
119
|
+
* @type {number}
|
|
120
|
+
*/
|
|
121
|
+
export const LOCATOR_MAX_LENGTH = 256;
|
|
122
|
+
|
|
123
|
+
/** Characters forbidden in a locator: they are the fingerprint separator / shell arg boundary. */
|
|
124
|
+
const LOCATOR_FORBIDDEN = /[\n\r\0]/;
|
|
125
|
+
|
|
126
|
+
const VALID_BUILDS = Object.freeze(['dev', 'prod']);
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Map an axe-core `impact` value to a ux-grill severity (PRD § 2 S3).
|
|
130
|
+
*
|
|
131
|
+
* @param {string} impact - axe impact: 'critical' | 'serious' | 'moderate' | 'minor'
|
|
132
|
+
* @returns {string} 'high' | 'medium' | 'low'. Unknown, missing or non-string
|
|
133
|
+
* impacts default to `'low'` — an unclassifiable violation is reported but
|
|
134
|
+
* never allowed to inflate the high band that drives automatic issue filing.
|
|
135
|
+
*/
|
|
136
|
+
export function severityForAxeImpact(impact) {
|
|
137
|
+
if (impact === 'critical' || impact === 'serious') return 'high';
|
|
138
|
+
if (impact === 'moderate') return 'medium';
|
|
139
|
+
return 'low';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Build a ux-grill finding record.
|
|
144
|
+
*
|
|
145
|
+
* The fingerprint is computed from `(SCOPE, checkId, truncatedLocator)` ONLY —
|
|
146
|
+
* never from severity, build, message or evidence, so that the same violation
|
|
147
|
+
* keeps its identity across runs and across dev/prod builds.
|
|
148
|
+
*
|
|
149
|
+
* @param {object} opts
|
|
150
|
+
* @param {string} opts.checkId - a value of {@link CHECK_IDS}, or `axe-<ruleId>`
|
|
151
|
+
* for axe. Passing the literal `'axe-violations'` throws: two axe rules
|
|
152
|
+
* violated on one selector must remain two findings, which only holds when the
|
|
153
|
+
* rule id is part of the fingerprint input.
|
|
154
|
+
* @param {string} opts.locator - `route|viewport|selector` for route checks,
|
|
155
|
+
* `journey|viewport|<name>` for journey checks. Must contain at least one `|`
|
|
156
|
+
* and no newline/CR/NUL. Truncated to {@link LOCATOR_MAX_LENGTH} before
|
|
157
|
+
* fingerprinting; the truncated form is what the record carries.
|
|
158
|
+
* @param {string} opts.severity - one of {@link SEVERITIES}.
|
|
159
|
+
* @param {string} opts.build - `'dev'` or `'prod'`; drives `provisional`.
|
|
160
|
+
* @param {string} [opts.message] - one-line human summary.
|
|
161
|
+
* @param {object} [opts.evidence] - free-form evidence pointers (screenshot
|
|
162
|
+
* path, axe node, measured px). Defaults to `{}`.
|
|
163
|
+
* @returns {{scope: string, checkId: string, locator: string, severity: string,
|
|
164
|
+
* provisional: boolean, fingerprint: string, message: string, evidence: object}}
|
|
165
|
+
* @throws {TypeError} on any invalid input — no silent defaults.
|
|
166
|
+
*/
|
|
167
|
+
export function makeFinding({ checkId, locator, severity, build, message, evidence } = {}) {
|
|
168
|
+
if (typeof checkId !== 'string' || checkId.length === 0) {
|
|
169
|
+
throw new TypeError('makeFinding: checkId must be a non-empty string');
|
|
170
|
+
}
|
|
171
|
+
// Same predicate as the locator check below. `checkId` is fingerprint input
|
|
172
|
+
// and is rendered into issue bodies and JSONL lines; a newline or NUL in it
|
|
173
|
+
// splits one record into two. Downstream `reconcile.oneLine()` contains it
|
|
174
|
+
// today, but the constructor is where a rejected input belongs (BV-002).
|
|
175
|
+
if (LOCATOR_FORBIDDEN.test(checkId)) {
|
|
176
|
+
throw new TypeError('makeFinding: checkId must not contain newline, CR or NUL');
|
|
177
|
+
}
|
|
178
|
+
if (checkId === CHECK_IDS.AXE_VIOLATIONS) {
|
|
179
|
+
throw new TypeError(
|
|
180
|
+
"makeFinding: checkId 'axe-violations' is the catalogue entry, not an emittable id — pass 'axe-<ruleId>' so two axe rules on one selector stay two findings",
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
if (typeof locator !== 'string' || locator.length === 0) {
|
|
184
|
+
throw new TypeError('makeFinding: locator must be a non-empty string');
|
|
185
|
+
}
|
|
186
|
+
if (!locator.includes('|')) {
|
|
187
|
+
throw new TypeError("makeFinding: locator must be pipe-delimited (route|viewport|selector or journey|viewport|<name>)");
|
|
188
|
+
}
|
|
189
|
+
if (LOCATOR_FORBIDDEN.test(locator)) {
|
|
190
|
+
throw new TypeError('makeFinding: locator must not contain newline, CR or NUL');
|
|
191
|
+
}
|
|
192
|
+
if (!SEVERITIES.includes(severity)) {
|
|
193
|
+
throw new TypeError(`makeFinding: severity must be one of ${SEVERITIES.join('|')}, got ${String(severity)}`);
|
|
194
|
+
}
|
|
195
|
+
if (!VALID_BUILDS.includes(build)) {
|
|
196
|
+
throw new TypeError(`makeFinding: build must be one of ${VALID_BUILDS.join('|')}, got ${String(build)}`);
|
|
197
|
+
}
|
|
198
|
+
if (message !== undefined && typeof message !== 'string') {
|
|
199
|
+
throw new TypeError('makeFinding: message must be a string when provided');
|
|
200
|
+
}
|
|
201
|
+
if (evidence !== undefined && (typeof evidence !== 'object' || evidence === null || Array.isArray(evidence))) {
|
|
202
|
+
throw new TypeError('makeFinding: evidence must be a plain object when provided');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const truncated = locator.slice(0, LOCATOR_MAX_LENGTH);
|
|
206
|
+
// See LOCATOR_MAX_LENGTH: only an over-long locator gets the full-text digest
|
|
207
|
+
// suffix, so ≤256-char fingerprints stay byte-for-byte stable.
|
|
208
|
+
const fingerprintLocator =
|
|
209
|
+
locator.length > LOCATOR_MAX_LENGTH ? `${truncated}:${digestSha256Short(locator)}` : truncated;
|
|
210
|
+
return {
|
|
211
|
+
scope: SCOPE,
|
|
212
|
+
checkId,
|
|
213
|
+
locator: truncated,
|
|
214
|
+
severity,
|
|
215
|
+
// Dev builds are not a geometry measurement basis (PRD § 2 S3), so
|
|
216
|
+
// target-size findings from a dev build are flagged rather than dropped.
|
|
217
|
+
provisional: build === 'dev' && checkId.startsWith('target-size-'),
|
|
218
|
+
fingerprint: fingerprintFinding({ scope: SCOPE, checkId, locator: fingerprintLocator }),
|
|
219
|
+
message: message ?? '',
|
|
220
|
+
evidence: evidence ?? {},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Count findings per severity band.
|
|
226
|
+
*
|
|
227
|
+
* @param {Array<{severity: string}>} findings
|
|
228
|
+
* @returns {{high: number, medium: number, low: number}} zero-filled for every band.
|
|
229
|
+
* @throws {TypeError} if `findings` is not an array or carries an unknown severity.
|
|
230
|
+
*/
|
|
231
|
+
export function countBySeverity(findings) {
|
|
232
|
+
if (!Array.isArray(findings)) {
|
|
233
|
+
throw new TypeError('countBySeverity: findings must be an array');
|
|
234
|
+
}
|
|
235
|
+
const counts = { high: 0, medium: 0, low: 0 };
|
|
236
|
+
for (const finding of findings) {
|
|
237
|
+
const severity = finding?.severity;
|
|
238
|
+
if (!SEVERITIES.includes(severity)) {
|
|
239
|
+
throw new TypeError(`countBySeverity: unknown severity ${String(severity)}`);
|
|
240
|
+
}
|
|
241
|
+
counts[severity] += 1;
|
|
242
|
+
}
|
|
243
|
+
return counts;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Build the `.orchestrator/metrics/ux-grill.jsonl` run-record (PRD § 4).
|
|
248
|
+
*
|
|
249
|
+
* @param {object} opts
|
|
250
|
+
* @param {string} opts.runId - run identifier (see `paths.mjs` `makeRunId`).
|
|
251
|
+
* @param {string} opts.manifestHash - hash of the resolved ux-manifest; the
|
|
252
|
+
* compare key — only runs with the same manifest_hash are comparable.
|
|
253
|
+
* @param {string} opts.rubricHash - hash of `skills/ux-grill/rubric-v2.md`.
|
|
254
|
+
* @param {string} opts.build - `'dev'` or `'prod'`.
|
|
255
|
+
* @param {string} [opts.timestamp] - ISO-8601 UTC; defaults to now.
|
|
256
|
+
* @param {string[]} [opts.viewports] - viewport labels actually run.
|
|
257
|
+
* @param {string[]} [opts.routes] - route paths actually run.
|
|
258
|
+
* @param {Array<{severity: string}>} [opts.findings] - drives `counts_by_severity`
|
|
259
|
+
* and `provisional_count` when those are not passed explicitly.
|
|
260
|
+
* @param {{high: number, medium: number, low: number}} [opts.countsBySeverity]
|
|
261
|
+
* @param {number} [opts.provisionalCount]
|
|
262
|
+
* @param {{new: number, persisting: number, fixed: number}} [opts.compare]
|
|
263
|
+
* @param {Array<{what: string, reason: string}>} [opts.skipped] - `reason` must
|
|
264
|
+
* be a value of {@link SKIP_REASONS}.
|
|
265
|
+
* @param {Array<{route: string, frame: string}>} [opts.pencilCoverage]
|
|
266
|
+
* @returns {object} the run-record, with exactly the PRD § 4 fields.
|
|
267
|
+
* @throws {TypeError} on missing/invalid required fields.
|
|
268
|
+
*/
|
|
269
|
+
export function makeRunRecord({
|
|
270
|
+
runId,
|
|
271
|
+
manifestHash,
|
|
272
|
+
rubricHash,
|
|
273
|
+
build,
|
|
274
|
+
timestamp,
|
|
275
|
+
viewports,
|
|
276
|
+
routes,
|
|
277
|
+
findings,
|
|
278
|
+
countsBySeverity,
|
|
279
|
+
provisionalCount,
|
|
280
|
+
compare,
|
|
281
|
+
skipped,
|
|
282
|
+
pencilCoverage,
|
|
283
|
+
} = {}) {
|
|
284
|
+
for (const [name, value] of [
|
|
285
|
+
['runId', runId],
|
|
286
|
+
['manifestHash', manifestHash],
|
|
287
|
+
['rubricHash', rubricHash],
|
|
288
|
+
]) {
|
|
289
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
290
|
+
throw new TypeError(`makeRunRecord: ${name} must be a non-empty string`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (!VALID_BUILDS.includes(build)) {
|
|
294
|
+
throw new TypeError(`makeRunRecord: build must be one of ${VALID_BUILDS.join('|')}, got ${String(build)}`);
|
|
295
|
+
}
|
|
296
|
+
const findingList = findings ?? [];
|
|
297
|
+
if (!Array.isArray(findingList)) {
|
|
298
|
+
throw new TypeError('makeRunRecord: findings must be an array when provided');
|
|
299
|
+
}
|
|
300
|
+
for (const entry of skipped ?? []) {
|
|
301
|
+
if (!Object.values(SKIP_REASONS).includes(entry?.reason)) {
|
|
302
|
+
throw new TypeError(`makeRunRecord: unknown skip reason ${String(entry?.reason)}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
schema_version: RUN_RECORD_SCHEMA_VERSION,
|
|
308
|
+
run_id: runId,
|
|
309
|
+
timestamp: timestamp ?? new Date().toISOString(),
|
|
310
|
+
manifest_hash: manifestHash,
|
|
311
|
+
rubric_hash: rubricHash,
|
|
312
|
+
build,
|
|
313
|
+
viewports: viewports ?? [],
|
|
314
|
+
routes: routes ?? [],
|
|
315
|
+
counts_by_severity: countsBySeverity ?? countBySeverity(findingList),
|
|
316
|
+
provisional_count: provisionalCount ?? findingList.filter((f) => f?.provisional).length,
|
|
317
|
+
compare: compare ?? { new: 0, persisting: 0, fixed: 0 },
|
|
318
|
+
skipped: skipped ?? [],
|
|
319
|
+
pencil_coverage: pencilCoverage ?? [],
|
|
320
|
+
};
|
|
321
|
+
}
|