@nextcommerce/campaigns-os 1.50.0 → 1.52.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.
Files changed (74) hide show
  1. package/CHANGELOG.md +426 -0
  2. package/agents/claude/CLAUDE.md +2 -2
  3. package/agents/codex/AGENTS.md +1 -1
  4. package/agents/copilot/copilot-instructions.md +1 -1
  5. package/agents/cursor/campaigns-os.mdc +1 -1
  6. package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
  7. package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
  8. package/campaign-spec/dist/rules/design-source-shape.js +13 -3
  9. package/campaign-spec/dist/rules/sdk-version.js +2 -1
  10. package/compatibility.json +1 -1
  11. package/contracts/commerce-surface-catalog.json +26 -46
  12. package/contracts/effects.v1.json +81 -2
  13. package/contracts/release-ledger.json +906 -0
  14. package/contracts/supported-surface.json +2 -2
  15. package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
  16. package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
  17. package/docs/build-packet.md +93 -9
  18. package/docs/campaign-build-brief.md +25 -1
  19. package/docs/effects.md +6 -0
  20. package/docs/local-setup.md +1 -1
  21. package/docs/orientation-contract-reference.md +1 -1
  22. package/docs/polish-evidence.md +10 -0
  23. package/docs/qa-and-test-orders.md +45 -4
  24. package/docs/runtime-readiness.md +1 -1
  25. package/docs/sdk-storage-compatibility.md +1 -1
  26. package/docs/skills-revision.md +10 -10
  27. package/package.json +1 -1
  28. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  29. package/skills/campaign-readback-classification/SKILL.md +3 -3
  30. package/skills/campaign-run-evidence/SKILL.md +3 -3
  31. package/skills/contribution-intake/SKILL.md +3 -3
  32. package/skills/next-campaigns-build/SKILL.md +3 -3
  33. package/skills/next-campaigns-os/SKILL.md +4 -4
  34. package/skills/next-campaigns-os/references/session-intake.md +7 -3
  35. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  36. package/skills/next-campaigns-polish/SKILL.md +3 -3
  37. package/skills/next-campaigns-qa/SKILL.md +6 -5
  38. package/skills.json +10 -10
  39. package/src/adapter-decision-contract.mjs +1 -1
  40. package/src/brand-theme.mjs +12 -0
  41. package/src/build-brief.mjs +68 -21
  42. package/src/built-site-scope.mjs +39 -6
  43. package/src/built-smoke-qc.mjs +1117 -0
  44. package/src/campaign-identity.mjs +36 -2
  45. package/src/cart-placeholders.mjs +730 -0
  46. package/src/cli.mjs +310 -34
  47. package/src/commercial-journey.mjs +65 -4
  48. package/src/commercial-parity.mjs +6 -1
  49. package/src/doctor/checks.mjs +291 -24
  50. package/src/doctor/inspect.mjs +53 -2
  51. package/src/doctor/next-step.mjs +1 -1
  52. package/src/invocation.mjs +2 -1
  53. package/src/local-preview-policy.mjs +1 -1
  54. package/src/local-proof.mjs +4 -1
  55. package/src/polish-browser.mjs +218 -1
  56. package/src/polish-capture.mjs +1 -1
  57. package/src/polish-media-weight.mjs +492 -0
  58. package/src/polish-node.mjs +96 -4
  59. package/src/progress-node.mjs +5 -1
  60. package/src/qa-browser.mjs +308 -96
  61. package/src/qa-content-params.mjs +889 -0
  62. package/src/qa-node.mjs +104 -12
  63. package/src/qa-order-bump.mjs +22 -1
  64. package/src/qa-policy-links.mjs +1019 -0
  65. package/src/qa-tracking-params.mjs +1389 -0
  66. package/src/qa-url-privacy.mjs +168 -0
  67. package/src/qc-accept.mjs +446 -0
  68. package/src/qc-check-registry.mjs +83 -0
  69. package/src/qc-results.mjs +1049 -0
  70. package/src/sdk-attribute-index.mjs +71 -0
  71. package/src/sdk-markup.mjs +2 -2
  72. package/src/sdk-storage-compatibility.mjs +63 -3
  73. package/src/source-prep.mjs +37 -7
  74. package/src/stage-record.mjs +56 -17
@@ -0,0 +1,1049 @@
1
+ // The shared QC results: one result shape,
2
+ // one storage rule per leg, and the readers that re-derive every stored result
3
+ // from its raw package capture on every read.
4
+ //
5
+ // Storage per leg:
6
+ // - doctor: derived.qc_results[], recomputed from built HTML on every run;
7
+ // - polish: stages.polish.evidence.visual_review.media_weight, re-derived from
8
+ // its cells and cross-checked against the page_load capture;
9
+ // - qa: stages.qa.evidence.qc_results[] beside qc_build_fingerprint,
10
+ // re-derived from the qc.* assertions of the full QA verdict.
11
+ //
12
+ // A reader never reports `pass` for an input it could not re-derive: a failed
13
+ // check reads `unexercised` with stale_binding or evidence_not_reproducible,
14
+ // and a leg with no rows for an applicable check is listed in the handoff
15
+ // coverage (silence is not a result). The committed QA sidecar,
16
+ // stages.qa.waivers and report.waivers[] are never read here.
17
+ import { createHash } from "node:crypto";
18
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
19
+ import { isAbsolute, relative, resolve } from "node:path";
20
+
21
+ import { campaignSidecarPaths } from "./campaign-workspace.mjs";
22
+ import { checkpointStateFingerprint } from "./checkpoint-waiver.mjs";
23
+ import { sameFile } from "./fs-identity.mjs";
24
+ import { buildPolishCaptureIntegrity, canonicalJson, captureOrigin, mediaFetchedResources } from "./polish-capture.mjs";
25
+ import { currentBuildFingerprint } from "./polish-gate.mjs";
26
+ import { sidecarPathForPacket } from "./qa-sidecar.mjs";
27
+ import { QA_ASSERTION_FAMILY_VOCABULARY, QA_SCHEMA_VERSION, SEVERITY, STATUS } from "./qa-verdict.mjs";
28
+ import { QC_CHECK_REGISTRY, loadedQcRederivers, qcChecksForLeg } from "./qc-check-registry.mjs";
29
+ import { STORE_PAGE_MATCHERS } from "./spec-derive-store.mjs";
30
+
31
+ export const QC_RESULT_SCHEMA = "campaigns-os-qc-result/v0";
32
+ export const QC_RESULTS = Object.freeze(["pass", "warning", "review", "unexercised", "excluded"]);
33
+ export const QC_LEGS = Object.freeze(["doctor", "polish", "qa"]);
34
+ export const QC_REASON = Object.freeze({
35
+ STALE_BINDING: "stale_binding",
36
+ EVIDENCE_NOT_REPRODUCIBLE: "evidence_not_reproducible",
37
+ LEG_NOT_RUN: "leg_not_run",
38
+ NOT_CAPTURED: "not_captured_by_this_version",
39
+ });
40
+ export const QC_PRODUCERS = Object.freeze({
41
+ doctor: "campaigns-os doctor",
42
+ polish: "campaigns-os polish capture",
43
+ qa: "campaigns-os qa run",
44
+ });
45
+ export const MEDIA_WEIGHT_SCHEMA = "campaigns-os-polish-media-weight/v0";
46
+ const PAGE_LOAD_SCHEMA = "campaigns-os-polish-page-load/v0";
47
+ const ROUTE_CAPTURE_SCHEMA = "campaigns-os-polish-route-capture/v0";
48
+ // The binding fields of the page_load subject (pageLoadCheckpointSubject) and
49
+ // of one route capture's subject (captureSubject), exactly, besides
50
+ // build_fingerprint. The build binding is read apart: absent, null or another
51
+ // build is staleness (stale_binding), never a malformed subject.
52
+ const PAGE_LOAD_SUBJECT_FIELDS = Object.freeze(["campaign_slug", "route_scope", "routes", "viewports"]);
53
+ const CAPTURE_SUBJECT_FIELDS = Object.freeze(["campaign_slug", "requested_route", "final_document_route", "viewport"]);
54
+ const QA_RUNTIME_PREFIX = "campaigns-os-node-qa@";
55
+ const FINGERPRINT = /^sha256:[a-f0-9]{64}$/;
56
+ const E = QC_REASON.EVIDENCE_NOT_REPRODUCIBLE;
57
+
58
+ // Result precedence when members aggregate into one row (excluded is ignored).
59
+ const PRECEDENCE = Object.freeze(["warning", "review", "unexercised", "pass"]);
60
+
61
+ // QA verdict mapping per result. unexercised is
62
+ // manual_review + warn, after src/qa-browser.mjs's unexercised-path precedent,
63
+ // never skipped: skipped is invisible to computeDisposition and exceptions[].
64
+ export const QC_QA_ASSERTION_STATUS = Object.freeze({
65
+ pass: Object.freeze({ status: STATUS.PASS, severity: SEVERITY.INFO }),
66
+ warning: Object.freeze({ status: STATUS.WARN, severity: SEVERITY.WARN }),
67
+ review: Object.freeze({ status: STATUS.MANUAL_REVIEW, severity: SEVERITY.WARN }),
68
+ unexercised: Object.freeze({ status: STATUS.MANUAL_REVIEW, severity: SEVERITY.WARN }),
69
+ excluded: Object.freeze({ status: STATUS.SKIPPED, severity: SEVERITY.INFO }),
70
+ });
71
+
72
+ const isPlainObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
73
+ const isNonEmptyString = (value) => typeof value === "string" && value.trim() !== "";
74
+ const sameJson = (a, b) => canonicalJson(a ?? null) === canonicalJson(b ?? null);
75
+ const validTime = (value) => typeof value === "string" && Number.isFinite(Date.parse(value));
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Result shape
79
+
80
+ export function qcResultId(subject) {
81
+ return [subject?.check, subject?.page, ...(subject?.viewport == null ? [] : [subject.viewport]), subject?.key].map((part) => String(part ?? "")).join(":");
82
+ }
83
+
84
+ export function qcStateFingerprint({ subject, state }) {
85
+ return checkpointStateFingerprint({ scope: "qc", subject, state });
86
+ }
87
+
88
+ export const fingerprint12 = (fingerprint) => String(fingerprint || "").replace(/^sha256:/, "").slice(0, 12);
89
+ export const qcResultRef = (result) => `${result.id}@${fingerprint12(result.state_fingerprint)}`;
90
+
91
+ // Only a warning whose members are all resolved can be accepted.
92
+ export function qcAcceptEligible(result, members = []) {
93
+ return result === "warning" && !(Array.isArray(members) ? members : []).some((member) => member?.result === "review" || member?.result === "unexercised");
94
+ }
95
+
96
+ function validMember(member) {
97
+ return isPlainObject(member)
98
+ && member.key != null
99
+ && QC_RESULTS.includes(member.result)
100
+ && (member.result === "pass" ? member.reason_code == null : isNonEmptyString(member.reason_code));
101
+ }
102
+
103
+ export function buildQcResult({ check, leg, subject, result, reason_code = null, state, observation = {}, members = [], accept_eligible = undefined, coverage, measured_at, producer = QC_PRODUCERS[leg] }) {
104
+ if (!isNonEmptyString(check) || !isPlainObject(subject) || subject.check !== check) throw new TypeError("buildQcResult needs a check and a subject naming it.");
105
+ if (!QC_LEGS.includes(leg)) throw new TypeError(`buildQcResult leg must be one of ${QC_LEGS.join(", ")}.`);
106
+ if (!QC_RESULTS.includes(result)) throw new TypeError(`buildQcResult result must be one of ${QC_RESULTS.join(", ")}.`);
107
+ if (result !== "pass" && !isNonEmptyString(reason_code)) throw new TypeError("buildQcResult needs a reason_code unless the result is pass.");
108
+ if (!Array.isArray(members) || !members.every(validMember)) throw new TypeError("buildQcResult members must be {key, result, reason_code} entries.");
109
+ const eligible = qcAcceptEligible(result, members) && accept_eligible !== false;
110
+ return {
111
+ schema: QC_RESULT_SCHEMA,
112
+ id: qcResultId(subject),
113
+ check,
114
+ leg,
115
+ result,
116
+ reason_code: result === "pass" ? null : reason_code,
117
+ subject,
118
+ state_fingerprint: qcStateFingerprint({ subject, state }),
119
+ observation,
120
+ members,
121
+ accept_eligible: eligible,
122
+ coverage: coverage ?? { observed: 1, expected: 1, limits: [] },
123
+ measured_at,
124
+ producer,
125
+ };
126
+ }
127
+
128
+ // Members aggregate as warning > review > unexercised > pass; excluded members
129
+ // are ignored, and a row of only excluded members is excluded. A capped page
130
+ // adds the page_coverage member, so its kept results are never accept-eligible.
131
+ export function aggregateQcResults(members, { capReason = null } = {}) {
132
+ const all = [...(Array.isArray(members) ? members : []), ...(capReason ? [{ key: "page_coverage", result: "unexercised", reason_code: capReason }] : [])];
133
+ const counted = all.filter((member) => member.result !== "excluded");
134
+ const result = counted.length ? PRECEDENCE.find((value) => counted.some((member) => member.result === value)) || "unexercised" : "excluded";
135
+ const lead = result === "pass" ? null : all.find((member) => member.result === result);
136
+ return { result, reason_code: lead ? lead.reason_code : null, members: all, accept_eligible: qcAcceptEligible(result, all) };
137
+ }
138
+
139
+ // A QA verdict assertion for a result, in an existing family, id prefixed qc.
140
+ export function toQaAssertion(row, { family = "browser-runtime", expected = null, actual = null } = {}) {
141
+ if (!QA_ASSERTION_FAMILY_VOCABULARY.includes(family)) throw new TypeError(`toQaAssertion family must be an existing QA assertion family (got ${family}).`);
142
+ const { status, severity } = QC_QA_ASSERTION_STATUS[row.result];
143
+ return {
144
+ id: `qc.${row.id}`,
145
+ family,
146
+ page: row.subject?.page ?? null,
147
+ status,
148
+ severity,
149
+ ...(expected == null ? {} : { expected }),
150
+ actual: actual ?? `${row.check} ${row.result}${row.reason_code ? ` (${row.reason_code})` : ""}`,
151
+ evidence: { qc: { result_id: row.id, observation: row.observation, ...(row.result === "excluded" ? { reason_code: row.reason_code } : {}) } },
152
+ };
153
+ }
154
+
155
+ export function toDoctorIssue(row) {
156
+ return {
157
+ code: `built_output.${row.check}.${row.reason_code}`,
158
+ message: `${row.check} ${row.result} on ${row.subject?.page ?? "(no page)"} (${row.subject?.key ?? "(no key)"}): ${row.reason_code}.`,
159
+ detail: { qc_result: row },
160
+ };
161
+ }
162
+
163
+ // The single doctor wiring point: every result lands in derived.qc_results;
164
+ // warning and review results also become warnings[] issues. Doctor status
165
+ // rules are unchanged, and unexercised/excluded stay in derived only.
166
+ export function recordQcResults({ derived, warnings, results }) {
167
+ if (!Array.isArray(derived.qc_results)) derived.qc_results = [];
168
+ for (const row of Array.isArray(results) ? results : []) {
169
+ derived.qc_results.push(row);
170
+ if (row.result === "warning" || row.result === "review") warnings.push(toDoctorIssue(row));
171
+ }
172
+ return derived.qc_results;
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Rederivers: an in-process stand-in (tests only) or the registry module.
177
+
178
+ function qaRederiver(check, { qcStandIns, rederivers }) {
179
+ const standIn = qcStandIns?.qa?.[check];
180
+ if (typeof standIn === "function") return { status: "loaded", rederive: standIn };
181
+ const entry = (rederivers || loadedQcRederivers())?.[check];
182
+ if (!entry) return { status: Object.hasOwn(QC_CHECK_REGISTRY, check) ? "unresolved" : "unknown" };
183
+ if (entry.status === "loaded" && typeof entry.rederive !== "function") return { status: "failed" };
184
+ return entry;
185
+ }
186
+
187
+ export function resolvePolishRules({ qcStandIns, rederivers } = {}) {
188
+ const standIn = qcStandIns?.polish;
189
+ if (isPlainObject(standIn)) return { status: "loaded", rules: standIn };
190
+ const entry = (rederivers || loadedQcRederivers())?.["media.weight"];
191
+ if (!entry) return { status: "unresolved" };
192
+ if (entry.status !== "loaded") return entry;
193
+ const rules = entry.rederive;
194
+ return isPlainObject(rules) && typeof rules.evaluate === "function" && isPlainObject(rules.thresholds) && isPlainObject(rules.vocabulary)
195
+ ? { status: "loaded", rules }
196
+ : { status: "failed" };
197
+ }
198
+
199
+ // A result the reader could not re-derive. What the stored row claims is kept
200
+ // only to name it (id, subject, fingerprint); its result never survives.
201
+ function unreproducedRow(base, reasonCode, { leg, measuredAt = null }) {
202
+ const subject = isPlainObject(base?.subject) ? base.subject : { check: base?.check ?? null, page: base?.page ?? null, key: base?.key ?? null };
203
+ const check = isNonEmptyString(base?.check) ? base.check : subject.check ?? null;
204
+ return {
205
+ schema: QC_RESULT_SCHEMA,
206
+ id: isNonEmptyString(base?.id) ? base.id : qcResultId(subject),
207
+ check,
208
+ leg,
209
+ result: "unexercised",
210
+ reason_code: reasonCode,
211
+ subject,
212
+ state_fingerprint: FINGERPRINT.test(base?.state_fingerprint || "") ? base.state_fingerprint : qcStateFingerprint({ subject, state: { reason_code: reasonCode } }),
213
+ observation: {},
214
+ members: [],
215
+ accept_eligible: false,
216
+ coverage: { observed: 0, expected: null, limits: [reasonCode] },
217
+ measured_at: validTime(measuredAt) ? measuredAt : validTime(base?.measured_at) ? base.measured_at : null,
218
+ producer: QC_PRODUCERS[leg],
219
+ };
220
+ }
221
+
222
+ const staleRow = (row) => ({
223
+ ...row,
224
+ result: "unexercised",
225
+ reason_code: QC_REASON.STALE_BINDING,
226
+ accept_eligible: false,
227
+ coverage: { ...(isPlainObject(row.coverage) ? row.coverage : {}), limits: [QC_REASON.STALE_BINDING] },
228
+ });
229
+
230
+ // A check re-deriver's output, checked against the QC result vocabulary.
231
+ function validDerived(derived, check) {
232
+ return isPlainObject(derived)
233
+ && derived.check === check
234
+ && isPlainObject(derived.subject)
235
+ && derived.subject.check === check
236
+ && QC_RESULTS.includes(derived.result)
237
+ && (derived.result === "pass" ? derived.reason_code == null : isNonEmptyString(derived.reason_code))
238
+ && (derived.members === undefined || (Array.isArray(derived.members) && derived.members.every(validMember)))
239
+ && typeof derived.accept_eligible === "boolean"
240
+ && (derived.coverage === undefined || isPlainObject(derived.coverage))
241
+ && Object.hasOwn(derived, "state");
242
+ }
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // QA reader site
246
+
247
+ // The Inputs checks on the full QA verdict. Returns null when it passes.
248
+ // Every qc.* assertion takes part in pairing, so none may be skipped: an
249
+ // entry that is not an assertion object, a qc.* assertion whose evidence.qc
250
+ // is not {result_id: non-empty string, observation: object}, or QC evidence
251
+ // on an assertion whose id is not qc.* fails the whole verdict.
252
+ export function qaFullVerdictProblem(verdict, stage) {
253
+ if (!isPlainObject(verdict)) return "full_verdict_absent";
254
+ if (verdict.schema_version !== QA_SCHEMA_VERSION) return "verdict_schema";
255
+ if (typeof verdict.runtime !== "string" || !verdict.runtime.startsWith(QA_RUNTIME_PREFIX)) return "verdict_runtime";
256
+ const runId = stage?.identity?.verdict_run_id;
257
+ if (!isNonEmptyString(runId) || verdict.run_id !== runId) return "verdict_run_id";
258
+ if (!Array.isArray(verdict.assertions)) return "verdict_assertions";
259
+ for (const assertion of verdict.assertions) {
260
+ if (!isPlainObject(assertion)) return "verdict_assertions";
261
+ const isQc = typeof assertion.id === "string" && assertion.id.startsWith("qc.");
262
+ const carriesQc = isPlainObject(assertion.evidence) && Object.hasOwn(assertion.evidence, "qc");
263
+ if (!isQc && !carriesQc) continue;
264
+ const qc = assertion.evidence?.qc;
265
+ if (!isQc || !isPlainObject(qc) || !isNonEmptyString(qc.result_id) || !isPlainObject(qc.observation)) return "qc_assertion_malformed";
266
+ }
267
+ return null;
268
+ }
269
+
270
+ // The full verdict the QA stage names: its outputs[] entry under
271
+ // {target}/qa-output/. Evidence refers to itself when that file is the
272
+ // committed QA sidecar or the Assembly Report. That is decided on the file
273
+ // itself, after every symlink and directory alias resolves (a symlinked
274
+ // qa-output/ included), and on its device and inode (a hard link), whatever
275
+ // the stage's other outputs[] say; such a verdict reads as absent. This is the
276
+ // only file a QC reader opens from a path held in a record.
277
+ export function readQaFullVerdict({ stage, targetRepo, packetPath = null, reportPath = null }) {
278
+ if (!isPlainObject(stage) || !isNonEmptyString(targetRepo)) return null;
279
+ const outputDir = resolve(targetRepo, "qa-output");
280
+ const inside = (root, path) => {
281
+ const rel = relative(root, path);
282
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
283
+ };
284
+ const outputs = (Array.isArray(stage.outputs) ? stage.outputs : []).filter(isNonEmptyString).map((path) => resolve(targetRepo, path));
285
+ const candidates = outputs.filter((path) => inside(outputDir, path));
286
+ if (candidates.length !== 1 || !existsSync(candidates[0]) || !existsSync(outputDir)) return null;
287
+ try {
288
+ const real = realpathSync(candidates[0]);
289
+ const stats = statSync(real);
290
+ if (!stats.isFile()) return null;
291
+ if (!inside(realpathSync(outputDir), real)) return null;
292
+ const forbidden = [
293
+ reportPath,
294
+ campaignSidecarPaths(targetRepo).reportPath,
295
+ isNonEmptyString(packetPath) ? sidecarPathForPacket(packetPath) : null,
296
+ ...outputs.filter((path) => path !== candidates[0]),
297
+ ].filter(isNonEmptyString);
298
+ for (const path of forbidden) {
299
+ if (sameFile(real, path)) return null;
300
+ if (!existsSync(path)) continue;
301
+ const other = statSync(path);
302
+ if (other.dev === stats.dev && other.ino === stats.ino) return null;
303
+ }
304
+ return JSON.parse(readFileSync(real, "utf8"));
305
+ } catch {
306
+ return null;
307
+ }
308
+ }
309
+
310
+ export function readQaResults({ stageEvidence, stage = null, fullVerdict = null, currentBuild = null, qcStandIns = null, rederivers = null } = {}) {
311
+ const stored = Array.isArray(stageEvidence?.qc_results) ? stageEvidence.qc_results : null;
312
+ if (!stored) return [];
313
+ const verdictOk = qaFullVerdictProblem(fullVerdict, stage) === null;
314
+ const measuredAt = verdictOk ? (validTime(fullVerdict.completed_at) ? fullVerdict.completed_at : fullVerdict.started_at) : null;
315
+ const bound = isNonEmptyString(currentBuild) && stageEvidence.qc_build_fingerprint === currentBuild;
316
+
317
+ // Rows pair with assertions by evidence.qc.result_id, never by assertion id.
318
+ // A passing verdict holds only well-formed QC evidence (qaFullVerdictProblem),
319
+ // so the only assertions passed over here carry none.
320
+ const paired = new Map();
321
+ const duplicated = new Set();
322
+ for (const assertion of verdictOk ? fullVerdict.assertions : []) {
323
+ if (typeof assertion.id !== "string" || !assertion.id.startsWith("qc.")) continue;
324
+ const resultId = assertion.evidence.qc.result_id;
325
+ if (paired.has(resultId)) duplicated.add(resultId);
326
+ paired.set(resultId, assertion);
327
+ }
328
+
329
+ const read = new Map();
330
+ stored.forEach((row, index) => {
331
+ const id = isPlainObject(row) && isNonEmptyString(row.id) ? row.id : `qc_results[${index}]`;
332
+ if (read.has(id)) {
333
+ read.set(id, unreproducedRow(read.get(id), E, { leg: "qa", measuredAt }));
334
+ return;
335
+ }
336
+ read.set(id, readQaRow(isPlainObject(row) ? row : { id }, { verdictOk, assertion: duplicated.has(id) ? null : paired.get(id), measuredAt, qcStandIns, rederivers, bound }));
337
+ });
338
+ // A verdict result_id with no row: synthesized, never silent.
339
+ for (const [resultId, assertion] of paired) {
340
+ if (read.has(resultId)) continue;
341
+ const observation = assertion.evidence.qc.observation;
342
+ const check = isNonEmptyString(observation?.check) ? observation.check : resultId.split(":")[0];
343
+ read.set(resultId, unreproducedRow({ id: resultId, check, subject: { check, page: assertion.page ?? null, key: null } }, E, { leg: "qa", measuredAt }));
344
+ }
345
+ return [...read.values()];
346
+ }
347
+
348
+ function readQaRow(row, { verdictOk, assertion, measuredAt, qcStandIns, rederivers, bound }) {
349
+ const fail = () => unreproducedRow(row, E, { leg: "qa", measuredAt });
350
+ if (!verdictOk || !assertion) return fail();
351
+ if (row.schema !== QC_RESULT_SCHEMA || row.producer !== QC_PRODUCERS.qa || row.leg !== "qa" || !isNonEmptyString(row.check)) return fail();
352
+ const observation = assertion.evidence.qc.observation;
353
+ if (!sameJson(row.observation, observation)) return fail();
354
+ if (!QA_ASSERTION_FAMILY_VOCABULARY.includes(assertion.family)) return fail();
355
+ const rederiver = qaRederiver(row.check, { qcStandIns, rederivers });
356
+ if (rederiver.status === "missing") {
357
+ const notCaptured = unreproducedRow(row, QC_REASON.NOT_CAPTURED, { leg: "qa", measuredAt });
358
+ return bound ? notCaptured : staleRow(notCaptured);
359
+ }
360
+ if (rederiver.status !== "loaded") return fail();
361
+ let derived = null;
362
+ try {
363
+ derived = rederiver.rederive(observation);
364
+ } catch {
365
+ derived = null;
366
+ }
367
+ if (!validDerived(derived, row.check)) return fail();
368
+ const members = derived.members ?? [];
369
+ const coverage = derived.coverage ?? { observed: 1, expected: 1, limits: [] };
370
+ const stateFingerprint = qcStateFingerprint({ subject: derived.subject, state: derived.state });
371
+ const reasonCode = derived.result === "pass" ? null : derived.reason_code;
372
+ const expected = QC_QA_ASSERTION_STATUS[derived.result];
373
+ if (row.id !== qcResultId(derived.subject)
374
+ || !sameJson(row.subject, derived.subject)
375
+ || row.result !== derived.result
376
+ || (row.reason_code ?? null) !== reasonCode
377
+ || !sameJson(row.members ?? [], members)
378
+ || row.accept_eligible !== derived.accept_eligible
379
+ || !sameJson(row.coverage, coverage)
380
+ || row.state_fingerprint !== stateFingerprint
381
+ || assertion.status !== expected.status
382
+ || assertion.severity !== expected.severity) return fail();
383
+ const result = {
384
+ schema: QC_RESULT_SCHEMA,
385
+ id: row.id,
386
+ check: row.check,
387
+ leg: "qa",
388
+ result: derived.result,
389
+ reason_code: reasonCode,
390
+ subject: derived.subject,
391
+ state_fingerprint: stateFingerprint,
392
+ observation,
393
+ members,
394
+ accept_eligible: derived.accept_eligible && qcAcceptEligible(derived.result, members),
395
+ coverage,
396
+ measured_at: measuredAt,
397
+ producer: QC_PRODUCERS.qa,
398
+ };
399
+ return bound ? result : staleRow(result);
400
+ }
401
+
402
+ // ---------------------------------------------------------------------------
403
+ // Polish reader site (media_weight against its page_load capture)
404
+
405
+ // Unkeyed tamper evidence over every field but `integrity`, the canonical-JSON
406
+ // + sha256 pattern of buildPolishCaptureIntegrity.
407
+ export function mediaWeightIntegrity(record) {
408
+ const { integrity: _ignored, ...rest } = record || {};
409
+ return `sha256:${createHash("sha256").update(canonicalJson(rest)).digest("hex")}`;
410
+ }
411
+
412
+ const inVocabulary = (vocabulary, field, value) => Array.isArray(vocabulary?.[field]) && vocabulary[field].includes(value);
413
+ const isPair = (value) => Array.isArray(value) && value.length === 2 && value.every((n) => typeof n === "number" && Number.isFinite(n) && n >= 0);
414
+
415
+ // The subjects a cell lists whatever happens to it: one weight result per
416
+ // resource, one per unfetched <video>, one per probed <img> whose currentSrc
417
+ // binds to no resource (mediaChainBinder: "img:<element_path>"), one oversize result per
418
+ // <img>, and one oversize result keyed "cell" for a cell that lists no <img>
419
+ // (so an image-free page is never silent).
420
+ function cellSubjects(cell) {
421
+ const route = cell?.route ?? null;
422
+ const viewport = cell?.viewport ?? null;
423
+ const subjects = [];
424
+ const resources = Array.isArray(cell?.resources) ? cell.resources : [];
425
+ const images = Array.isArray(cell?.images) ? cell.images : [];
426
+ for (const resource of resources) {
427
+ subjects.push({ check: "media.weight", page: route, viewport, key: resource?.resource_id ?? null });
428
+ }
429
+ for (const video of Array.isArray(cell?.videos) ? cell.videos : []) {
430
+ if (Array.isArray(video?.resource_ids) && video.resource_ids.length) continue;
431
+ subjects.push({ check: "media.weight", page: route, viewport, key: `video:${video?.element_index}` });
432
+ }
433
+ const bind = mediaChainBinder(resources);
434
+ const unledgered = new Set();
435
+ for (const image of images) {
436
+ if (bind(image?.resource_id).status !== "absent") continue;
437
+ const key = `img:${image?.element_path}`;
438
+ if (unledgered.has(key)) continue;
439
+ unledgered.add(key);
440
+ subjects.push({ check: "media.weight", page: route, viewport, key });
441
+ }
442
+ for (const image of images) {
443
+ subjects.push({ check: "media.oversize", page: route, viewport, key: `${image?.resource_id}:${image?.element_path}` });
444
+ }
445
+ if (!images.length) subjects.push({ check: "media.oversize", page: route, viewport, key: "cell" });
446
+ return subjects;
447
+ }
448
+
449
+ // A value the image probe observed, or null where the cell's probe did not
450
+ // complete and so observed nothing (dpr and image geometry have no raw
451
+ // counterpart; only their vocabulary is checked).
452
+ const observedOrNull = (cell, value, check) => check(value) || (cell.probe_status !== "complete" && value === null);
453
+
454
+ function recordVocabularyOk(record, vocabulary) {
455
+ return record.cells.every((cell) => isPlainObject(cell)
456
+ && isNonEmptyString(cell.route)
457
+ && isNonEmptyString(cell.viewport)
458
+ && Array.isArray(record.subject?.routes) && record.subject.routes.includes(cell.route)
459
+ && Array.isArray(record.subject?.viewports) && record.subject.viewports.includes(cell.viewport)
460
+ && observedOrNull(cell, cell.dpr, (dpr) => typeof dpr === "number" && dpr > 0)
461
+ && isNonEmptyString(cell.page_load_integrity)
462
+ && inVocabulary(vocabulary, "capture_status", cell.capture_status)
463
+ && inVocabulary(vocabulary, "probe_status", cell.probe_status)
464
+ && Array.isArray(cell.resources) && cell.resources.every((resource) => isPlainObject(resource)
465
+ && isNonEmptyString(resource.resource_id)
466
+ && inVocabulary(vocabulary, "measurement", resource.measurement)
467
+ && typeof resource.failed === "boolean"
468
+ && typeof resource.final_origin_equal === "boolean"
469
+ && Number.isFinite(resource.transferred_bytes)
470
+ && (resource.declared_bytes === null || Number.isFinite(resource.declared_bytes))
471
+ && Array.isArray(resource.chain) && resource.chain.length > 0 && resource.chain.every(isPlainObject))
472
+ && Array.isArray(cell.images) && cell.images.every((image) => isPlainObject(image)
473
+ && (image.resource_id === null || isNonEmptyString(image.resource_id))
474
+ && isNonEmptyString(image.element_path)
475
+ && observedOrNull(cell, image.complete, (value) => typeof value === "boolean")
476
+ && observedOrNull(cell, image.hidden, (value) => typeof value === "boolean")
477
+ && observedOrNull(cell, image.natural, isPair)
478
+ && observedOrNull(cell, image.rendered, isPair)
479
+ && observedOrNull(cell, image.object_fit, (value) => inVocabulary(vocabulary, "object_fit", value))
480
+ && inVocabulary(vocabulary, "loading", image.loading))
481
+ && Array.isArray(cell.videos) && cell.videos.every((video) => isPlainObject(video)
482
+ && Number.isInteger(video.element_index)
483
+ && Array.isArray(video.resource_ids) && video.resource_ids.every(isNonEmptyString)
484
+ && typeof video.declared_origin_equal === "boolean"));
485
+ }
486
+
487
+ // The page_load fields the cell checks read, each required present with the
488
+ // producer's type. A missing or ill-typed field never stands in for 0, false,
489
+ // complete or an empty list: the cell does not re-derive.
490
+ const isCount = (value) => Number.isSafeInteger(value) && value >= 0;
491
+ const LEDGER_COUNTS = Object.freeze([
492
+ "transferred_bytes",
493
+ "declared_bytes",
494
+ "request_count",
495
+ "declared_request_count",
496
+ "failed_request_count",
497
+ "cache_request_count",
498
+ "unmeasured_request_count",
499
+ "canceled_request_count",
500
+ "partial_request_count",
501
+ "cross_origin_request_count",
502
+ ]);
503
+ const MEDIA_TAGS = Object.freeze(["video", "audio"]);
504
+ const SOURCE_KINDS = Object.freeze(["current_src", "src_attribute", "source_src_attribute", "observed_source"]);
505
+
506
+ // A resource_ledger entry: its identity, URL and type, every byte count and
507
+ // request counter (mediaFetchedResources copies request_count and
508
+ // declared_bytes too), its statuses, and the identity set it matched (which
509
+ // always holds its own resource_id).
510
+ function ledgerEntryOk(entry) {
511
+ return isPlainObject(entry)
512
+ && isNonEmptyString(entry.resource_id)
513
+ && isNonEmptyString(entry.url)
514
+ && isNonEmptyString(entry.resource_type)
515
+ && LEDGER_COUNTS.every((field) => isCount(entry[field]))
516
+ && Array.isArray(entry.statuses) && entry.statuses.every(Number.isInteger)
517
+ && Array.isArray(entry.match_resource_ids) && entry.match_resource_ids.every(isNonEmptyString)
518
+ && entry.match_resource_ids.includes(entry.resource_id);
519
+ }
520
+
521
+ // A media[] element: its tag, its index, every source reference's kind, index,
522
+ // URL and resolved resource_id (null when the ledger cannot identify the
523
+ // source), and every fetched resource's identity and the source identities it
524
+ // matched. The capture checksum covers the source and fetched-resource
525
+ // associations, so each of those fields is read too.
526
+ function mediaElementOk(element) {
527
+ return isPlainObject(element)
528
+ && MEDIA_TAGS.includes(element.tag_name)
529
+ && isCount(element.element_index)
530
+ && Array.isArray(element.source_references)
531
+ && element.source_references.every((reference) => isPlainObject(reference)
532
+ && SOURCE_KINDS.includes(reference.source_kind)
533
+ && isCount(reference.source_index)
534
+ && isNonEmptyString(reference.url)
535
+ && Object.hasOwn(reference, "resource_id")
536
+ && (reference.resource_id === null || isNonEmptyString(reference.resource_id)))
537
+ && Array.isArray(element.fetched_resources)
538
+ && element.fetched_resources.every((resource) => isPlainObject(resource)
539
+ && isNonEmptyString(resource.resource_id)
540
+ && Array.isArray(resource.matched_source_resource_ids)
541
+ && resource.matched_source_resource_ids.every(isNonEmptyString));
542
+ }
543
+
544
+ // A cell resource's own URL and type, and each chain hop's identity, URL and
545
+ // status, which the cell checks compare with the ledger. A null status is
546
+ // left to the chain check, which allows it only on a final hop that failed
547
+ // with no HTTP response.
548
+ function cellResourceShapeOk(resource) {
549
+ return isNonEmptyString(resource.url)
550
+ && isNonEmptyString(resource.type)
551
+ && resource.chain.every((hop) => isNonEmptyString(hop.resource_id) && isNonEmptyString(hop.url) && (Number.isInteger(hop.status) || hop.status === null));
552
+ }
553
+
554
+ function ledgerMeasurement(entry) {
555
+ if (entry.cache_request_count > 0) return "cached";
556
+ if (entry.unmeasured_request_count > 0) return "unmeasured";
557
+ if (entry.canceled_request_count > 0 || entry.partial_request_count > 0) return "lower_bound";
558
+ return "complete";
559
+ }
560
+
561
+ // A redirect answer: the hop continues to another URL. Every other status
562
+ // (a 2xx, a 304, an error) ends the chain.
563
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
564
+ const isRedirectEntry = (entry) => Array.isArray(entry.statuses) && entry.statuses.length > 0 && entry.statuses.every((status) => REDIRECT_STATUSES.has(status));
565
+ const mixesRedirect = (entry) => Array.isArray(entry.statuses) && entry.statuses.some((status) => REDIRECT_STATUSES.has(status)) && !isRedirectEntry(entry);
566
+
567
+ // The one binding of a requested href (the identity an element used) to a
568
+ // request chain, read from the page_load ledger alone, so it is the same
569
+ // whatever order the requests came in. The producer builds every resource's
570
+ // chain with it and the reader checks every resource's chain against it.
571
+ // - "bound": the ledger singles out one chain. Its final hop is the one entry
572
+ // that names the requested href in its match_resource_ids and answered
573
+ // with a non-redirect status (the requested entry itself when it was not
574
+ // redirected); its hops are that final entry plus every redirect entry it
575
+ // names. The ledger keeps no Location, so it fixes the hop set and both
576
+ // ends; it cannot order intermediate hops.
577
+ // - "ambiguous": the href stands for more than one chain, or a hop of its
578
+ // chain also belongs to another chain. No result may be read from any one
579
+ // of those chains. That is the case when:
580
+ // - any entry of the chain, or any entry its final hop names, mixes a
581
+ // redirect and a non-redirect status (one request through that URL was
582
+ // answered, another redirected);
583
+ // - it only redirected and no single final hop names it;
584
+ // - it was answered directly and a redirect entry names it too (it is one
585
+ // chain's final hop and another chain's start);
586
+ // - a hop answered more requests than the requested href did (that hop
587
+ // also started a chain of its own).
588
+ // - "absent": no ledger entry has that id.
589
+ export function bindLedgerChain(requestedId, entries, byId = new Map(entries.map((entry) => [entry?.resource_id, entry]))) {
590
+ const requested = byId.get(requestedId);
591
+ if (!requested) return { status: "absent" };
592
+ if (mixesRedirect(requested)) return { status: "ambiguous" };
593
+ let final = requested;
594
+ if (isRedirectEntry(requested)) {
595
+ const finals = entries.filter((entry) => !isRedirectEntry(entry) && Array.isArray(entry.match_resource_ids) && entry.match_resource_ids.includes(requestedId));
596
+ if (finals.length !== 1) return { status: "ambiguous" };
597
+ [final] = finals;
598
+ }
599
+ if (!Array.isArray(final.match_resource_ids)) return { status: "ambiguous" };
600
+ const hops = new Set([final.resource_id, requestedId]);
601
+ for (const id of final.match_resource_ids) {
602
+ const entry = byId.get(id);
603
+ if (entry && mixesRedirect(entry)) return { status: "ambiguous" };
604
+ if (entry && entry !== final && isRedirectEntry(entry)) hops.add(id);
605
+ }
606
+ if (final === requested && hops.size > 1) return { status: "ambiguous" };
607
+ if ([...hops].some((id) => byId.get(id)?.request_count !== requested.request_count)) return { status: "ambiguous" };
608
+ return { status: "bound", final, hops };
609
+ }
610
+
611
+ // The one binding of a cell's elements to its resources, read from the
612
+ // record: an element identity (an <img>'s currentSrc id, a <video>'s fetched
613
+ // ledger ids) binds to the resource whose chain holds it. The 1.3 rules
614
+ // (src/polish-media-weight.mjs) and the reader's subject list both bind
615
+ // through it. Returns { status: "bound", resource } when exactly one
616
+ // resource's chain holds the id, { status: "ambiguous" } when more than one
617
+ // does, and { status: "absent" } when none does.
618
+ export function mediaChainBinder(resources) {
619
+ const holders = new Map();
620
+ for (const resource of Array.isArray(resources) ? resources : []) {
621
+ for (const hop of Array.isArray(resource?.chain) ? resource.chain : []) {
622
+ const id = hop?.resource_id;
623
+ if (!holders.has(id)) holders.set(id, new Set());
624
+ holders.get(id).add(resource);
625
+ }
626
+ }
627
+ return (id) => {
628
+ const found = id === null || id === undefined ? undefined : holders.get(id);
629
+ if (!found) return { status: "absent" };
630
+ return found.size === 1 ? { status: "bound", resource: [...found][0] } : { status: "ambiguous" };
631
+ };
632
+ }
633
+
634
+ // The ledger types an <img> request can be recorded under: its own (image),
635
+ // a type merged with another load of the same URL (unknown), or a hint that
636
+ // fetched it first (other, prefetch). An <img> bound to a document, script,
637
+ // stylesheet or any other entry did not load from it.
638
+ const IMAGE_REQUEST_TYPES = new Set(["image", "other", "prefetch", "unknown"]);
639
+
640
+ // Every field of a cell that has a raw counterpart, checked against the
641
+ // page_load capture for the same route and viewport. True when it re-derives.
642
+ // Every set the reader consumes is derived from the capture and required to
643
+ // match exactly: each resource's hop set and final hop (bindLedgerChain; a
644
+ // requested href the ledger binds to more than one chain, or whose chain
645
+ // shares a hop with another chain, does not re-derive, whatever the request
646
+ // order), the cell's resource set (the chains partition
647
+ // the capture's ledger: every entry in exactly one resource's chain), each
648
+ // <img> identity (null, or one resource's requested href), and the cell's
649
+ // video set (every <video> media element). A truncated chain, a dropped resource or a dropped video
650
+ // does not re-derive. Every raw field read is first required present and well
651
+ // typed (ledgerEntryOk, mediaElementOk): a ledger, media list, counter, byte
652
+ // count, status list, origin or identity set that is missing or ill typed
653
+ // does not re-derive either.
654
+ function cellReproduces(cell, capture) {
655
+ if (!isPlainObject(capture) || !isPlainObject(capture.integrity)) return false;
656
+ if (!sameJson(buildPolishCaptureIntegrity(capture), capture.integrity)) return false;
657
+ if (cell.page_load_integrity !== capture.integrity.projection_fingerprint) return false;
658
+ if (!isNonEmptyString(capture.measurement_status) || cell.capture_status !== capture.measurement_status) return false;
659
+ const finalOrigin = isPlainObject(capture.document_response) ? capture.document_response.final_origin : null;
660
+ if (!isNonEmptyString(finalOrigin) || captureOrigin(finalOrigin) !== finalOrigin || cell.document_origin !== finalOrigin) return false;
661
+ if (!isPlainObject(capture.resource_ledger) || !Array.isArray(capture.resource_ledger.entries) || !capture.resource_ledger.entries.every(ledgerEntryOk)) return false;
662
+ if (!Array.isArray(capture.media) || !capture.media.every(mediaElementOk)) return false;
663
+ const entries = capture.resource_ledger.entries;
664
+ const byId = new Map(entries.map((entry) => [entry.resource_id, entry]));
665
+ if (byId.size !== entries.length) return false;
666
+ const media = capture.media;
667
+ if (new Set(media.map((element) => element.element_index)).size !== media.length) return false;
668
+ const covered = new Set();
669
+ for (const resource of cell.resources) {
670
+ if (!cellResourceShapeOk(resource)) return false;
671
+ const { chain } = resource;
672
+ if (chain[0].resource_id !== resource.resource_id || chain[0].url !== resource.url) return false;
673
+ const expected = bindLedgerChain(resource.resource_id, entries, byId);
674
+ if (expected.status !== "bound") return false;
675
+ const { final } = expected;
676
+ const chainIds = chain.map((hop) => hop.resource_id);
677
+ if (chain.at(-1).resource_id !== final.resource_id
678
+ || new Set(chainIds).size !== chainIds.length
679
+ || chainIds.length !== expected.hops.size
680
+ || !chainIds.every((id) => expected.hops.has(id))) return false;
681
+ for (const [index, hop] of chain.entries()) {
682
+ const entry = byId.get(hop.resource_id);
683
+ if (!entry || hop.url !== entry.url || !Array.isArray(entry.statuses)) return false;
684
+ // A final hop whose request failed with no HTTP response carries no status.
685
+ const noResponse = index === chain.length - 1 && entry.statuses.length === 0 && entry.failed_request_count > 0 && hop.status === null;
686
+ if (!noResponse && !entry.statuses.includes(hop.status)) return false;
687
+ if (!final.match_resource_ids.includes(hop.resource_id)) return false;
688
+ // Every hop but the last answered with a redirect; the last did not.
689
+ if (REDIRECT_STATUSES.has(hop.status) !== (index < chain.length - 1)) return false;
690
+ if (covered.has(hop.resource_id)) return false;
691
+ covered.add(hop.resource_id);
692
+ }
693
+ if (resource.type !== final.resource_type
694
+ || resource.transferred_bytes !== final.transferred_bytes
695
+ || resource.declared_bytes !== (final.declared_request_count > 0 ? final.declared_bytes : null)
696
+ || resource.failed !== (final.failed_request_count > 0)
697
+ || resource.measurement !== ledgerMeasurement(final)
698
+ || resource.final_origin_equal !== (final.cross_origin_request_count === 0)) return false;
699
+ }
700
+ if (covered.size !== byId.size) return false;
701
+ // Every <img> identity is null (its currentSrc has no ledger entry: weight
702
+ // not_in_ledger) or exactly the requested href of one resource, whose chain
703
+ // the ledger binds (above) and whose entry an <img> request can produce. A
704
+ // re-cased or unknown id, a redirect or final hop, or a document or script
705
+ // entry does not re-derive.
706
+ const heads = new Set(cell.resources.map((resource) => resource.resource_id));
707
+ for (const image of cell.images) {
708
+ if (image.resource_id === null) continue;
709
+ if (!heads.has(image.resource_id) || !IMAGE_REQUEST_TYPES.has(byId.get(image.resource_id).resource_type)) return false;
710
+ }
711
+ const videoIndexes = media.filter((element) => element.tag_name === "video").map((element) => element.element_index).sort((a, b) => a - b);
712
+ const listedIndexes = cell.videos.map((video) => video.element_index).sort((a, b) => a - b);
713
+ if (!sameJson(listedIndexes, videoIndexes)) return false;
714
+ for (const video of cell.videos) {
715
+ const element = media.find((candidate) => candidate.element_index === video.element_index);
716
+ if (!element) return false;
717
+ const fetched = mediaFetchedResources(element, entries).map((entry) => entry.resource_id).sort();
718
+ if (!sameJson([...video.resource_ids].sort(), fetched)) return false;
719
+ const declaredSameOrigin = element.source_references.some((reference) => captureOrigin(reference.url) === finalOrigin);
720
+ if (video.declared_origin_equal !== declaredSameOrigin) return false;
721
+ }
722
+ return true;
723
+ }
724
+
725
+ const cellKey = (route, viewport) => JSON.stringify([route, viewport]);
726
+ const uniqueStrings = (list) => Array.isArray(list) && list.length > 0 && list.every(isNonEmptyString) && new Set(list).size === list.length;
727
+ // A subject without its build binding, and whether its other fields are
728
+ // exactly `fields`.
729
+ const unbound = (subject) => {
730
+ if (!isPlainObject(subject)) return subject;
731
+ const { build_fingerprint: _build, ...rest } = subject;
732
+ return rest;
733
+ };
734
+ const hasExactly = (object, fields) => isPlainObject(object) && sameJson(Object.keys(unbound(object)).sort(), [...fields].sort());
735
+
736
+ // The routes of spec pages the run did not capture (no source mapping),
737
+ // listed in the record as uncaptured_routes (absent reads as none). The
738
+ // page_load capture records that pages were skipped only as route_scope
739
+ // "selected", so a list is accepted only then, and only of routes it did not
740
+ // capture. Null when the list is malformed.
741
+ function uncapturedRoutesOf(record) {
742
+ if (!Object.hasOwn(record, "uncaptured_routes")) return [];
743
+ const routes = record.uncaptured_routes;
744
+ if (!Array.isArray(routes)) return null;
745
+ if (!routes.length) return routes;
746
+ return uniqueStrings(routes)
747
+ && record.subject.route_scope === "selected"
748
+ && routes.every((route) => route.startsWith("/") && !record.subject.routes.includes(route))
749
+ ? routes
750
+ : null;
751
+ }
752
+
753
+ // The page ids of skipped spec pages whose public route the run could not
754
+ // resolve, listed as uncaptured_page_ids (absent reads as none); accepted, like
755
+ // uncaptured_routes, only under route_scope "selected". Null when malformed.
756
+ function uncapturedPageIdsOf(record) {
757
+ if (!Object.hasOwn(record, "uncaptured_page_ids")) return [];
758
+ const pageIds = record.uncaptured_page_ids;
759
+ if (!Array.isArray(pageIds)) return null;
760
+ if (!pageIds.length) return pageIds;
761
+ return uniqueStrings(pageIds) && record.subject.route_scope === "selected" ? pageIds : null;
762
+ }
763
+
764
+ // The record's declared subject: exactly the page_load subject fields, each
765
+ // well formed. Its routes × viewports is the declared cell grid.
766
+ function declaredSubjectOk(subject) {
767
+ return hasExactly(subject, PAGE_LOAD_SUBJECT_FIELDS)
768
+ && isNonEmptyString(subject.campaign_slug)
769
+ && ["all", "selected"].includes(subject.route_scope)
770
+ && uniqueStrings(subject.routes)
771
+ && uniqueStrings(subject.viewports);
772
+ }
773
+
774
+ const declaredGrid = (subject) => (Array.isArray(subject?.routes) ? subject.routes : [])
775
+ .flatMap((route) => (Array.isArray(subject?.viewports) ? subject.viewports : []).map((viewport) => ({ route, viewport })));
776
+
777
+ // The page_load measurement summary agrees with the declared grid and the
778
+ // captures: it expects every declared cell, counts every capture, and lists
779
+ // nothing missing, duplicated or unexpected; any cell it calls incomplete is
780
+ // a declared one.
781
+ function measurementSummaryOk(measurement, grid, captureCount) {
782
+ const gridKeys = new Set(grid.map(({ route, viewport }) => cellKey(route, viewport)));
783
+ return isPlainObject(measurement)
784
+ && measurement.expected_capture_count === grid.length
785
+ && measurement.captured_count === captureCount
786
+ && ["missing", "duplicate", "unexpected"].every((field) => Array.isArray(measurement[field]) && measurement[field].length === 0)
787
+ && Array.isArray(measurement.incomplete)
788
+ && measurement.incomplete.every((entry) => isPlainObject(entry) && gridKeys.has(cellKey(entry.route, entry.viewport)));
789
+ }
790
+
791
+ // A route capture's own identity and binding, against the record's declared
792
+ // subject: producer and schema, exactly the capture subject fields, the same
793
+ // campaign, a document that stayed on the requested route, and a declared
794
+ // route and viewport. Returns "fail" when any of that does not hold, else
795
+ // "stale" when the capture's build is absent, null or not the current build,
796
+ // else null.
797
+ function captureBindingProblem(capture, subject, currentBuild) {
798
+ const own = capture?.subject;
799
+ if (capture?.schema_version !== ROUTE_CAPTURE_SCHEMA
800
+ || capture?.performed_by !== QC_PRODUCERS.polish
801
+ || !hasExactly(own, CAPTURE_SUBJECT_FIELDS)
802
+ || own.campaign_slug !== subject.campaign_slug
803
+ || !isNonEmptyString(own.requested_route)
804
+ || own.final_document_route !== own.requested_route
805
+ || !subject.routes.includes(own.requested_route)
806
+ || !subject.viewports.includes(own.viewport)) return "fail";
807
+ return isNonEmptyString(currentBuild) && own.build_fingerprint === currentBuild ? null : "stale";
808
+ }
809
+
810
+ const PAGE_NOT_CAPTURED = "page_not_captured";
811
+
812
+ export function readMediaWeight({ record, pageLoad, currentBuild = null, qcStandIns = null, rederivers = null } = {}) {
813
+ if (record === undefined || record === null) return [];
814
+ const measuredAt = validTime(record?.measured_at) ? record.measured_at : null;
815
+ const cells = Array.isArray(record?.cells) ? record.cells.filter(isPlainObject) : [];
816
+ const unreproduced = (subject) => unreproducedRow({ subject, check: subject.check }, E, { leg: "polish", measuredAt });
817
+ const cellResult = (check, route, viewport) => unreproduced({ check, page: route, viewport, key: "cell" });
818
+ // A failed cell: one result per subject it lists, and one per Polish check
819
+ // it lists no subject for, so a failed cell, even one listing nothing, is
820
+ // never silent.
821
+ const failCell = (cell) => {
822
+ const subjects = cellSubjects(cell);
823
+ return [
824
+ ...subjects.map(unreproduced),
825
+ ...POLISH_CHECKS.filter((check) => !subjects.some((subject) => subject.check === check)).map((check) => cellResult(check, cell?.route ?? null, cell?.viewport ?? null)),
826
+ ];
827
+ };
828
+ // A record-level failure: every listed cell fails, and every route ×
829
+ // viewport that the record or page_load declares or captured but no cell
830
+ // lists gets one result per Polish check, so a missing route is never silent.
831
+ const failAll = () => {
832
+ const listed = new Set(cells.map((cell) => cellKey(cell.route, cell.viewport)));
833
+ const unlisted = new Map();
834
+ const note = (route, viewport) => {
835
+ const key = cellKey(route, viewport);
836
+ if (isNonEmptyString(route) && isNonEmptyString(viewport) && !listed.has(key)) unlisted.set(key, { route, viewport });
837
+ };
838
+ for (const { route, viewport } of [...declaredGrid(record?.subject), ...declaredGrid(pageLoad?.subject)]) note(route, viewport);
839
+ for (const capture of Array.isArray(pageLoad?.captures) ? pageLoad.captures : []) note(capture?.subject?.requested_route, capture?.subject?.viewport);
840
+ for (const entry of Array.isArray(pageLoad?.measurement?.missing) ? pageLoad.measurement.missing : []) note(entry?.route, entry?.viewport);
841
+ const rows = [
842
+ ...cells.flatMap(failCell),
843
+ ...[...unlisted.values()].flatMap(({ route, viewport }) => POLISH_CHECKS.map((check) => cellResult(check, route, viewport))),
844
+ ];
845
+ return rows.length ? rows : [unreproduced({ check: "media.weight", page: null, key: "media_weight" })];
846
+ };
847
+ const resolved = resolvePolishRules({ qcStandIns, rederivers });
848
+ if (resolved.status === "missing") return [];
849
+ if (resolved.status !== "loaded") return failAll();
850
+ const { rules } = resolved;
851
+
852
+ // Record level: producer, integrity, thresholds, the declared subject and
853
+ // its binding to page_load, the page_load measurement summary, and the
854
+ // record vocabulary. Any failure makes every result unreproducible.
855
+ if (!isPlainObject(record)
856
+ || record.schema_version !== MEDIA_WEIGHT_SCHEMA
857
+ || record.performed_by !== QC_PRODUCERS.polish
858
+ || !measuredAt
859
+ || record.integrity !== mediaWeightIntegrity(record)
860
+ || !sameJson(record.thresholds, rules.thresholds)
861
+ || !declaredSubjectOk(record.subject)
862
+ || !isPlainObject(pageLoad)
863
+ || pageLoad.schema_version !== PAGE_LOAD_SCHEMA
864
+ || pageLoad.performed_by !== QC_PRODUCERS.polish
865
+ || !sameJson(unbound(record.subject), unbound(pageLoad.subject))
866
+ || !Array.isArray(pageLoad.captures)
867
+ || !measurementSummaryOk(pageLoad.measurement, declaredGrid(record.subject), pageLoad.captures.length)
868
+ || !Array.isArray(record.cells)
869
+ || cells.length !== record.cells.length
870
+ || !recordVocabularyOk(record, rules.vocabulary)
871
+ || uncapturedRoutesOf(record) === null
872
+ || uncapturedPageIdsOf(record) === null) return failAll();
873
+
874
+ // The declared grid (routes × viewports) is the capture set and the cell
875
+ // set: exactly one page_load capture and one cell per declared route and
876
+ // viewport, none missing, none added, none twice.
877
+ const gridKeys = declaredGrid(record.subject).map(({ route, viewport }) => cellKey(route, viewport));
878
+ const captureByKey = new Map();
879
+ for (const capture of pageLoad.captures) {
880
+ const key = cellKey(capture?.subject?.requested_route, capture?.subject?.viewport);
881
+ if (!isPlainObject(capture) || captureByKey.has(key)) return failAll();
882
+ captureByKey.set(key, capture);
883
+ }
884
+ const cellKeys = cells.map((cell) => cellKey(cell.route, cell.viewport));
885
+ if (new Set(cellKeys).size !== cellKeys.length
886
+ || cellKeys.length !== gridKeys.length
887
+ || captureByKey.size !== gridKeys.length
888
+ || !gridKeys.every((key) => captureByKey.has(key) && cellKeys.includes(key))) return failAll();
889
+
890
+ // Both enclosing subjects are bound to the current build; an absent, null
891
+ // or other build on either reads stale_binding.
892
+ const recordBound = isNonEmptyString(currentBuild)
893
+ && record.subject.build_fingerprint === currentBuild
894
+ && pageLoad.subject.build_fingerprint === currentBuild;
895
+ const results = [];
896
+ // A spec page with no source mapping: one result per 1.3 check and viewport,
897
+ // unexercised / page_not_captured; keyed "cell" on its public route, or
898
+ // "page:<page_id>" with no page when it has no resolvable route.
899
+ const uncaptured = [
900
+ ...uncapturedRoutesOf(record).map((route) => ({ page: route, key: "cell" })),
901
+ ...uncapturedPageIdsOf(record).map((pageId) => ({ page: null, key: `page:${pageId}` })),
902
+ ];
903
+ for (const { page, key } of uncaptured) {
904
+ for (const viewport of record.subject.viewports) {
905
+ for (const check of POLISH_CHECKS) {
906
+ const subject = { check, page, viewport, key };
907
+ const row = unreproducedRow({ subject, check }, PAGE_NOT_CAPTURED, { leg: "polish", measuredAt });
908
+ results.push(recordBound ? row : staleRow(row));
909
+ }
910
+ }
911
+ }
912
+ for (const cell of cells) {
913
+ const capture = captureByKey.get(cellKey(cell.route, cell.viewport));
914
+ const binding = captureBindingProblem(capture, record.subject, currentBuild);
915
+ if (binding === "fail" || !cellReproduces(cell, capture)) {
916
+ results.push(...failCell(cell));
917
+ continue;
918
+ }
919
+ const bound = recordBound && binding === null;
920
+ let derived;
921
+ try {
922
+ derived = rules.evaluate(cell, record.thresholds);
923
+ } catch {
924
+ derived = null;
925
+ }
926
+ // Every derived subject is one the cell lists (cellSubjects), exactly:
927
+ // its own page, viewport and key, and no other field.
928
+ const own = new Set(cellSubjects(cell).map((subject) => canonicalJson(subject)));
929
+ if (!Array.isArray(derived) || !derived.every((item) => ["media.weight", "media.oversize"].includes(item?.check) && validDerived(item, item.check) && own.has(canonicalJson(item.subject)))) {
930
+ results.push(...failCell(cell));
931
+ continue;
932
+ }
933
+ for (const item of derived) {
934
+ const members = item.members ?? [];
935
+ const row = {
936
+ schema: QC_RESULT_SCHEMA,
937
+ id: qcResultId(item.subject),
938
+ check: item.check,
939
+ leg: "polish",
940
+ result: item.result,
941
+ reason_code: item.result === "pass" ? null : item.reason_code,
942
+ subject: item.subject,
943
+ state_fingerprint: qcStateFingerprint({ subject: item.subject, state: item.state }),
944
+ observation: isPlainObject(item.observation) ? item.observation : {},
945
+ members,
946
+ accept_eligible: item.accept_eligible && qcAcceptEligible(item.result, members),
947
+ coverage: item.coverage ?? { observed: 1, expected: 1, limits: [] },
948
+ measured_at: measuredAt,
949
+ producer: QC_PRODUCERS.polish,
950
+ };
951
+ results.push(bound ? row : staleRow(row));
952
+ }
953
+ }
954
+ return results;
955
+ }
956
+
957
+ // ---------------------------------------------------------------------------
958
+ // Every current result, per leg, and the silence rule
959
+
960
+ const QA_ALWAYS = Object.freeze(["tracking.url", "tracking.order", "tracking.tag"]);
961
+ const QA_CONTENT = Object.freeze(["content_param"]);
962
+ const QA_POLICY = Object.freeze(["policy.presence", "policy.availability"]);
963
+ const POLISH_CHECKS = Object.freeze(qcChecksForLeg("polish"));
964
+ const STORE_POLICY_FIELDS = Object.freeze(Object.keys(STORE_PAGE_MATCHERS));
965
+
966
+ // Applicability is decided without the leg running: the tracking checks
967
+ // always, the content-param check when analytics.params.content is non-empty,
968
+ // the policy-link checks when a store policy field is.
969
+ export function applicableQaChecks(spec) {
970
+ const content = spec?.analytics?.params?.content;
971
+ const hasContent = Array.isArray(content) ? content.length > 0 : isPlainObject(content) && Object.keys(content).length > 0;
972
+ const hasPolicy = STORE_POLICY_FIELDS.some((field) => isNonEmptyString(spec?.campaign?.[field]));
973
+ return [...QA_ALWAYS, ...(hasContent ? QA_CONTENT : []), ...(hasPolicy ? QA_POLICY : [])];
974
+ }
975
+
976
+ // A leg has a stage record unless the stage is absent or is the pending
977
+ // placeholder prepare-build seeds (status pending, nothing recorded). Any other
978
+ // stage, whatever its status, is a recorded leg.
979
+ function stageRecorded(stage) {
980
+ if (!isPlainObject(stage)) return false;
981
+ if (stage.status !== "pending") return true;
982
+ return isPlainObject(stage.evidence)
983
+ || (isPlainObject(stage.identity) && Object.keys(stage.identity).length > 0)
984
+ || (Array.isArray(stage.outputs) && stage.outputs.length > 0);
985
+ }
986
+
987
+ const silence = (leg, check, reasonCode) => ({ check, leg, result: "unexercised", reason_code: reasonCode, count: 0, pages: [] });
988
+
989
+ // The handoff coverage entries for applicable checks a leg holds no row for.
990
+ // A leg with no stage record reads leg_not_run. A recorded leg never makes an
991
+ // applicable check silent: no row reads not_captured_by_this_version (the
992
+ // record predates the check, its module is missing or not yet loaded, or the
993
+ // loaded rules gave no row), except that a module that failed to load reads
994
+ // evidence_not_reproducible when the record holds the leg's QC evidence.
995
+ export function handoffCoverage({ report, spec, results = [], qcStandIns = null, rederivers = null }) {
996
+ const entries = [];
997
+ const hasRow = (leg, check) => results.some((row) => row.leg === leg && row.check === check);
998
+ const silentReason = (failed) => (failed ? E : QC_REASON.NOT_CAPTURED);
999
+
1000
+ const qaStage = report?.stages?.qa;
1001
+ const qaChecks = applicableQaChecks(spec);
1002
+ if (!stageRecorded(qaStage)) {
1003
+ for (const check of qaChecks) entries.push(silence("qa", check, QC_REASON.LEG_NOT_RUN));
1004
+ } else {
1005
+ const captured = Array.isArray(qaStage.evidence?.qc_results);
1006
+ for (const check of qaChecks) {
1007
+ if (hasRow("qa", check)) continue;
1008
+ entries.push(silence("qa", check, silentReason(captured && qaRederiver(check, { qcStandIns, rederivers }).status === "failed")));
1009
+ }
1010
+ }
1011
+
1012
+ const polishStage = report?.stages?.polish;
1013
+ if (!stageRecorded(polishStage)) {
1014
+ for (const check of POLISH_CHECKS) entries.push(silence("polish", check, QC_REASON.LEG_NOT_RUN));
1015
+ } else {
1016
+ const visual = polishStage.evidence?.visual_review;
1017
+ const captured = isPlainObject(visual?.page_load) && visual.media_weight != null;
1018
+ const failed = captured && resolvePolishRules({ qcStandIns, rederivers }).status === "failed";
1019
+ for (const check of POLISH_CHECKS) {
1020
+ if (!hasRow("polish", check)) entries.push(silence("polish", check, silentReason(failed)));
1021
+ }
1022
+ }
1023
+ return entries;
1024
+ }
1025
+
1026
+ // Every current QC result `next` and `checkpoint accept` judge, read from
1027
+ // data already on disk: the doctor run, the Assembly Report, and the full QA
1028
+ // verdict the QA stage names. No request is made.
1029
+ export function readCurrentQcResults({ report, doctor, spec = null, targetRepo, packetPath = null, reportPath = null, qcStandIns = null, rederivers = null }) {
1030
+ const currentBuild = currentBuildFingerprint(report);
1031
+ const doctorResults = (Array.isArray(doctor?.derived?.qc_results) ? doctor.derived.qc_results : []).filter((row) => isPlainObject(row) && row.leg === "doctor");
1032
+ const visual = report?.stages?.polish?.evidence?.visual_review;
1033
+ const polishResults = isPlainObject(visual) && visual.media_weight != null
1034
+ ? readMediaWeight({ record: visual.media_weight, pageLoad: visual.page_load, currentBuild, qcStandIns, rederivers })
1035
+ : [];
1036
+ const qaStage = report?.stages?.qa;
1037
+ const qaResults = Array.isArray(qaStage?.evidence?.qc_results)
1038
+ ? readQaResults({
1039
+ stageEvidence: qaStage.evidence,
1040
+ stage: qaStage,
1041
+ fullVerdict: readQaFullVerdict({ stage: qaStage, targetRepo, packetPath, reportPath }),
1042
+ currentBuild,
1043
+ qcStandIns,
1044
+ rederivers,
1045
+ })
1046
+ : [];
1047
+ const results = [...doctorResults, ...polishResults, ...qaResults];
1048
+ return { results, coverage: handoffCoverage({ report, spec, results, qcStandIns, rederivers }) };
1049
+ }