@nextcommerce/campaigns-os 1.60.0 → 1.62.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 (161) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +244 -0
  3. package/README.md +86 -438
  4. package/agents/claude/CLAUDE.md +20 -0
  5. package/agents/codex/AGENTS.md +19 -0
  6. package/agents/copilot/copilot-instructions.md +19 -0
  7. package/agents/cursor/campaigns-os.mdc +21 -0
  8. package/campaign-spec/README.md +7 -0
  9. package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
  10. package/campaign-spec/dist/analytics-vendors.js +44 -0
  11. package/campaign-spec/dist/checkout-flow.d.ts +18 -0
  12. package/campaign-spec/dist/checkout-flow.js +28 -0
  13. package/campaign-spec/dist/index.d.ts +4 -2
  14. package/campaign-spec/dist/index.js +5 -1
  15. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
  16. package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
  17. package/campaign-spec/dist/rules/campaign-currencies.d.ts +29 -0
  18. package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
  19. package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
  20. package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
  21. package/campaign-spec/dist/rules/index.d.ts +2 -0
  22. package/campaign-spec/dist/rules/index.js +6 -0
  23. package/campaign-spec/dist/types.d.ts +43 -9
  24. package/compatibility.json +1 -1
  25. package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
  26. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  27. package/contracts/commerce-surface-catalog.json +68 -61
  28. package/contracts/effects.v1.json +549 -165
  29. package/contracts/fixtures/runtime-recipe/accept/current.json +3 -0
  30. package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -0
  31. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  32. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -0
  33. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -0
  34. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -0
  35. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -0
  36. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -0
  37. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -0
  38. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -0
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -0
  40. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +3 -0
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -0
  42. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -0
  43. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -0
  44. package/contracts/release-ledger.json +1767 -0
  45. package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
  46. package/contracts/supported-surface.json +10 -10
  47. package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
  48. package/docs/access-model.md +20 -0
  49. package/docs/brand-theme-bridge.md +62 -3
  50. package/docs/build-packet.md +456 -36
  51. package/docs/campaign-build-brief.md +16 -0
  52. package/docs/campaign-standardization-report.md +4 -4
  53. package/docs/campaigns-os-build-flow.md +112 -2
  54. package/docs/campaignspec-authoring-examples.md +141 -0
  55. package/docs/design-source-package.md +64 -8
  56. package/docs/effects.md +17 -9
  57. package/docs/entry-points.md +188 -0
  58. package/docs/local-setup.md +1 -1
  59. package/docs/orientation-contract-reference.md +1 -1
  60. package/docs/polish-evidence.md +42 -2
  61. package/docs/progress-snapshots.md +7 -4
  62. package/docs/qa-and-test-orders.md +197 -3
  63. package/docs/quickstart.md +438 -0
  64. package/docs/readback.md +154 -6
  65. package/docs/runtime-readiness.md +6 -3
  66. package/docs/sdk-storage-compatibility.md +4 -2
  67. package/docs/skills-revision.md +10 -10
  68. package/docs/source-adapters.md +213 -0
  69. package/package.json +10 -3
  70. package/schemas/campaign-design-source-package.v0.schema.json +8 -0
  71. package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
  72. package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
  73. package/schemas/campaign-spec.v4.schema.json +19 -3
  74. package/schemas/campaign-spec.v5.schema.json +19 -3
  75. package/schemas/campaigns-os-readback.v2.schema.json +238 -1
  76. package/schemas/source-html-manifest.v0.schema.json +3 -0
  77. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  78. package/skills/campaign-readback-classification/SKILL.md +3 -3
  79. package/skills/campaign-run-evidence/SKILL.md +3 -3
  80. package/skills/contribution-intake/SKILL.md +3 -3
  81. package/skills/next-campaigns-build/SKILL.md +5 -4
  82. package/skills/next-campaigns-os/SKILL.md +7 -4
  83. package/skills/next-campaigns-os-setup/SKILL.md +5 -3
  84. package/skills/next-campaigns-polish/SKILL.md +8 -10
  85. package/skills/next-campaigns-qa/SKILL.md +7 -4
  86. package/skills/next-campaigns-sdk-bump/SKILL.md +151 -25
  87. package/skills.json +11 -11
  88. package/src/brand-theme.mjs +415 -9
  89. package/src/brief-summary.mjs +6 -2
  90. package/src/browser-launch.mjs +2 -1
  91. package/src/build-brief.mjs +13 -3
  92. package/src/built-site-scope.mjs +2 -2
  93. package/src/campaign-workspace.mjs +288 -15
  94. package/src/change-scope.mjs +284 -0
  95. package/src/cli.mjs +358 -97
  96. package/src/commercial-journey.mjs +16 -3
  97. package/src/commercial-parity.mjs +15 -7
  98. package/src/content-residue.mjs +4 -2
  99. package/src/design-file-reference.mjs +93 -0
  100. package/src/design-source-package.mjs +91 -1
  101. package/src/design-source-publication.mjs +9 -6
  102. package/src/directory-lock.mjs +73 -19
  103. package/src/doctor/checks.mjs +87 -81
  104. package/src/doctor/ci.mjs +177 -0
  105. package/src/doctor/inspect.mjs +41 -6
  106. package/src/doctor/next-step.mjs +7 -0
  107. package/src/doctor/source-provenance.mjs +74 -0
  108. package/src/doctor-sidecar.mjs +3 -2
  109. package/src/evidence-debt.mjs +159 -0
  110. package/src/finding-cause.mjs +12 -11
  111. package/src/input-refresh.mjs +339 -32
  112. package/src/invocation.mjs +7 -1
  113. package/src/live-campaign-refs.mjs +7 -2
  114. package/src/local-preview-policy.mjs +35 -2
  115. package/src/page-kit-analytics-vendors.mjs +132 -0
  116. package/src/parity/compare.mjs +316 -0
  117. package/src/parity/design-access.mjs +54 -0
  118. package/src/parity/element-parity-record.mjs +569 -0
  119. package/src/parity/probe.mjs +205 -0
  120. package/src/parity/snapshot.mjs +110 -0
  121. package/src/png-header.mjs +87 -0
  122. package/src/polish-browser.mjs +189 -10
  123. package/src/polish-captures-record.mjs +52 -0
  124. package/src/polish-deadline.mjs +14 -0
  125. package/src/polish-gate.mjs +25 -6
  126. package/src/polish-node.mjs +81 -7
  127. package/src/polish-readability.mjs +221 -9
  128. package/src/polish-visual-fixtures.mjs +74 -0
  129. package/src/polish-visual.mjs +613 -0
  130. package/src/progress-node.mjs +3 -2
  131. package/src/qa-analytics-correctness.mjs +14 -2
  132. package/src/qa-analytics-parity.mjs +111 -6
  133. package/src/qa-browser.mjs +40 -4
  134. package/src/qa-commercial-parity.mjs +50 -3
  135. package/src/qa-node.mjs +235 -22
  136. package/src/qa-publish.mjs +9 -8
  137. package/src/qa-sidecar.mjs +16 -4
  138. package/src/qa-vendor-beacons.mjs +232 -0
  139. package/src/qa-vendor-endpoints.mjs +306 -0
  140. package/src/qc-accept.mjs +176 -6
  141. package/src/qc-check-registry.mjs +42 -2
  142. package/src/qc-results.mjs +477 -42
  143. package/src/readback.mjs +113 -21
  144. package/src/route-identity.mjs +10 -0
  145. package/src/run-record.mjs +18 -10
  146. package/src/runtime-state-ignore.mjs +6 -2
  147. package/src/sdk-attribute-index.mjs +11 -11
  148. package/src/sdk-markup.mjs +77 -2
  149. package/src/sdk-storage-compatibility.mjs +115 -6
  150. package/src/source-html-intake.mjs +15 -1
  151. package/src/source-html-manifest.mjs +7 -0
  152. package/src/source-html-server.mjs +129 -0
  153. package/src/spec-derive-campaign.mjs +216 -0
  154. package/src/spec-derive.mjs +473 -53
  155. package/src/spec-identity.mjs +14 -0
  156. package/src/stage-ledger.mjs +40 -1
  157. package/src/stage-record.mjs +100 -17
  158. package/src/target-lock.mjs +11 -1
  159. package/src/theme-gate.mjs +2 -2
  160. package/src/visual-diff.mjs +296 -0
  161. package/src/visual-reference.mjs +126 -0
@@ -21,7 +21,7 @@ import { createHash } from "node:crypto";
21
21
  import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
22
22
  import { isAbsolute, relative, resolve } from "node:path";
23
23
 
24
- import { campaignSidecarPaths } from "./campaign-workspace.mjs";
24
+ import { campaignSidecarPaths, packetRuntimeRelPath } from "./campaign-workspace.mjs";
25
25
  import { checkpointStateFingerprint } from "./checkpoint-waiver.mjs";
26
26
  import { contrastToolkit } from "./contrast.mjs";
27
27
  import { sameFile } from "./fs-identity.mjs";
@@ -30,6 +30,8 @@ import { currentBuildFingerprint, currentSourcePackageMaterialFingerprint } from
30
30
  import { sidecarPathForPacket } from "./qa-sidecar.mjs";
31
31
  import { QA_ASSERTION_FAMILY_VOCABULARY, QA_SCHEMA_VERSION, SEVERITY, STATUS } from "./qa-verdict.mjs";
32
32
  import { QC_CHECK_REGISTRY, loadedQcRederivers, qcChecksForLeg, qcChecksForRecord } from "./qc-check-registry.mjs";
33
+ import { readPngStructure } from "./png-header.mjs";
34
+ import { readRegularFileSync } from "./source-html-server.mjs";
33
35
  import { STORE_PAGE_MATCHERS } from "./spec-derive-store.mjs";
34
36
 
35
37
  export const QC_RESULT_SCHEMA = "campaigns-os-qc-result/v0";
@@ -297,10 +299,14 @@ export function readQaFullVerdict({ stage, targetRepo, packetPath = null, report
297
299
  const stats = statSync(real);
298
300
  if (!stats.isFile()) return null;
299
301
  if (!inside(realpathSync(outputDir), real)) return null;
302
+ // This funnel's default report and every other funnel's project-level
303
+ // one (#719): neither is a verdict.
304
+ const packet = isNonEmptyString(packetPath) ? readPacketForGuard(packetPath) : null;
300
305
  const forbidden = [
301
306
  reportPath,
302
307
  campaignSidecarPaths(targetRepo).reportPath,
303
- isNonEmptyString(packetPath) ? sidecarPathForPacket(packetPath) : null,
308
+ packet ? campaignSidecarPaths(targetRepo, packetRuntimeRelPath(packet)).reportPath : null,
309
+ isNonEmptyString(packetPath) ? sidecarPathForPacket(packetPath, packet) : null,
304
310
  ...outputs.filter((path) => path !== candidates[0]),
305
311
  ].filter(isNonEmptyString);
306
312
  for (const path of forbidden) {
@@ -315,6 +321,15 @@ export function readQaFullVerdict({ stage, targetRepo, packetPath = null, report
315
321
  }
316
322
  }
317
323
 
324
+ function readPacketForGuard(packetPath) {
325
+ try {
326
+ const parsed = JSON.parse(readFileSync(packetPath, "utf8"));
327
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
328
+ } catch {
329
+ return null;
330
+ }
331
+ }
332
+
318
333
  // `qaCurrency` is QA's read-time input currency
319
334
  // (derived.input_currency.stages.qa). While QA is owed again or its inputs
320
335
  // cannot be confirmed, every row that reproduces reads stale_binding, as a
@@ -831,6 +846,10 @@ const PAGE_NOT_CAPTURED = "page_not_captured";
831
846
  // output under _site/<slug>/ is the one `record build` recorded. Any other
832
847
  // value, null included, is drift. A caller that passes no value (undefined)
833
848
  // reads without the output check.
849
+ // A capture file that is there but cannot be read as a regular file names
850
+ // why; only a missing file is capture_file_absent.
851
+ const captureReadFailure = (code) => code === "too_large" ? "capture_file_too_large"
852
+ : ["ELOOP", "EISDIR", "ENOTDIR", "not_regular_file", "outside_root"].includes(code) ? "capture_file_not_regular" : "capture_file_unreadable";
834
853
  const outputDrifted = (buildOutput) => buildOutput !== undefined && buildOutput?.status !== "pass";
835
854
 
836
855
  export function readMediaWeight({ record, pageLoad, currentBuild = null, buildOutput = undefined, qcStandIns = null, rederivers = null } = {}) {
@@ -1032,30 +1051,46 @@ function readabilityCropResolves(ref, targetRepo) {
1032
1051
  // the record lacks, or one over the route cap, reads page_not_captured;
1033
1052
  // a recorded route outside it, or different uncaptured routes or
1034
1053
  // enumeration cap, → evidence_not_reproducible;
1035
- // 4. the rows READABILITY_QC_RULES.evaluate derives from each cell.
1054
+ // 4. the rows READABILITY_QC_RULES.evaluate derives from each cell, and,
1055
+ // for a record that stores layout_conventions, the layout.conventions
1056
+ // rows LAYOUT_QC_RULES.evaluate derives from it.
1036
1057
  // `campaignSlug` is the packet's slug; a missing one never matches.
1037
- export function readReadability({ record, pageLoad = null, currentBuild = null, currentSource = null, buildOutput = null, campaignSlug = null, targetRepo = null, qcStandIns = null, rederivers = null } = {}) {
1058
+ export function readReadability({ record, pageLoad = null, currentBuild = null, currentSource = null, buildOutput = undefined, campaignSlug = null, targetRepo = null, qcStandIns = null, rederivers = null } = {}) {
1038
1059
  if (record === undefined || record === null) return [];
1039
- const [check] = POLISH_RECORDS.readability.checks;
1060
+ const [check, layoutCheck = null] = POLISH_RECORDS.readability.checks;
1040
1061
  const measuredAt = canonicalTime(record?.measured_at) ? record.measured_at : null;
1041
1062
  const cells = Array.isArray(record?.cells) ? record.cells.filter(isPlainObject) : [];
1042
- const subjectOf = (page, viewport, key) => ({ check, page, viewport, key });
1043
- const rowOf = (subject, reasonCode) => unreproducedRow({ subject, check }, reasonCode, { leg: "polish", measuredAt });
1063
+ const subjectOf = (page, viewport, key, of = check) => ({ check: of, page, viewport, key });
1064
+ const rowOf = (subject, reasonCode) => unreproducedRow({ subject, check: subject.check }, reasonCode, { leg: "polish", measuredAt });
1044
1065
  const unique = (rows) => [...new Map(rows.map((row) => [row.id, row])).values()];
1045
1066
  let rules = null;
1067
+ // The layout check reads only a record that stores the conventions it was
1068
+ // measured with; its rules load on their own.
1069
+ const layoutRecorded = isPlainObject(record) && Object.hasOwn(record, "layout_conventions") && isNonEmptyString(layoutCheck);
1070
+ const layoutResolved = layoutRecorded ? resolvePolishRules({ check: layoutCheck, qcStandIns, rederivers }) : { status: "missing" };
1071
+ const layoutRules = layoutResolved.status === "loaded" ? layoutResolved.rules : null;
1072
+ const layoutRead = layoutRecorded && layoutResolved.status !== "missing";
1073
+ const namedKeys = (rowKeys, cell) => {
1074
+ try {
1075
+ const named = typeof rowKeys === "function" ? rowKeys(cell) : [];
1076
+ return Array.isArray(named) ? named.filter(isNonEmptyString) : [];
1077
+ } catch {
1078
+ return [];
1079
+ }
1080
+ };
1081
+ // A failed cell's layout rows: one per row the layout rules name for the
1082
+ // cell (its `cell` row among them; none at a viewport no convention
1083
+ // applies to), or its `cell` row when the rules did not load.
1084
+ const failLayout = (cell) => (layoutRead
1085
+ ? [...new Set(layoutRules ? namedKeys(layoutRules.rowKeys, cell) : ["cell"])].map((key) => rowOf(subjectOf(cell?.route ?? null, cell?.viewport ?? null, key, layoutCheck), E))
1086
+ : []);
1046
1087
  // A failed cell: one result per row the cell names and its `cell` row, so
1047
1088
  // a failed cell is never silent and an accept of one of its rows lapses
1048
1089
  // rather than orphans. The keys come from rules.rowKeys, which reads
1049
1090
  // neither the record's thresholds nor any field the checks reject.
1050
1091
  const failCell = (cell) => {
1051
- let named = [];
1052
- try {
1053
- named = typeof rules?.rowKeys === "function" ? rules.rowKeys(cell) : [];
1054
- } catch {
1055
- named = [];
1056
- }
1057
- const keys = [...new Set([...(Array.isArray(named) ? named.filter(isNonEmptyString) : []), "cell"])];
1058
- return keys.map((key) => rowOf(subjectOf(cell?.route ?? null, cell?.viewport ?? null, key), E));
1092
+ const keys = [...new Set([...namedKeys(rules?.rowKeys, cell), "cell"])];
1093
+ return [...keys.map((key) => rowOf(subjectOf(cell?.route ?? null, cell?.viewport ?? null, key), E)), ...failLayout(cell)];
1059
1094
  };
1060
1095
  // A record-level failure: every listed cell fails; every route the subject
1061
1096
  // or a cell names, at every recorded or fixed viewport, that no cell lists
@@ -1118,7 +1153,8 @@ export function readReadability({ record, pageLoad = null, currentBuild = null,
1118
1153
  || typeof record.route_enumeration_capped !== "boolean"
1119
1154
  || !Array.isArray(record.cells)
1120
1155
  || cells.length !== record.cells.length
1121
- || !cells.every((cell) => rules.cellShapeOk(cell))) return failAll();
1156
+ || !cells.every((cell) => rules.cellShapeOk(cell))
1157
+ || (layoutRecorded && layoutRules && !sameJson(record.layout_conventions, layoutRules.thresholds))) return failAll();
1122
1158
  // The cell set is exactly routes × viewports: none missing, none added,
1123
1159
  // none twice, every cell's route and viewport in the subject.
1124
1160
  const gridKeys = declaredGrid(subject).map(({ route, viewport }) => cellKey(route, viewport));
@@ -1145,7 +1181,9 @@ export function readReadability({ record, pageLoad = null, currentBuild = null,
1145
1181
  const bound = isNonEmptyString(currentBuild)
1146
1182
  && subject.build_fingerprint === currentBuild
1147
1183
  && subject.source_package_material_fingerprint === (isNonEmptyString(currentSource) ? currentSource : null)
1148
- && !outputDrifted(buildOutput ?? null);
1184
+ // As in the captures reader: no buildOutput (undefined) reads without the
1185
+ // output check; null is drift.
1186
+ && !outputDrifted(buildOutput);
1149
1187
 
1150
1188
  // 3. The recomputed route set, read only when the output is the build's.
1151
1189
  const results = [];
@@ -1176,22 +1214,37 @@ export function readReadability({ record, pageLoad = null, currentBuild = null,
1176
1214
  }
1177
1215
  const derived = evaluate(cell, cropAvailable);
1178
1216
  // Every derived subject is the cell's own page and viewport, each key once.
1179
- const keys = Array.isArray(derived) ? derived.map((item) => item?.subject?.key) : [];
1180
- if (!Array.isArray(derived) || new Set(keys).size !== keys.length || !derived.every((item) => item?.check === check
1181
- && validDerived(item, check)
1182
- && hasExactly(item.subject, ["check", "page", "viewport", "key"])
1183
- && item.subject.page === cell.route
1184
- && item.subject.viewport === cell.viewport
1185
- && isNonEmptyString(item.subject.key))) {
1217
+ const derivedOk = (items, of) => {
1218
+ const keys = Array.isArray(items) ? items.map((item) => item?.subject?.key) : [];
1219
+ return Array.isArray(items) && new Set(keys).size === keys.length && items.every((item) => item?.check === of
1220
+ && validDerived(item, of)
1221
+ && hasExactly(item.subject, ["check", "page", "viewport", "key"])
1222
+ && item.subject.page === cell.route
1223
+ && item.subject.viewport === cell.viewport
1224
+ && isNonEmptyString(item.subject.key));
1225
+ };
1226
+ if (!derivedOk(derived, check)) {
1186
1227
  results.push(...failCell(cell));
1187
1228
  continue;
1188
1229
  }
1189
- for (const item of derived) {
1230
+ let layoutDerived = [];
1231
+ if (layoutRead) {
1232
+ try {
1233
+ layoutDerived = layoutRules ? layoutRules.evaluate(cell, record.layout_conventions) : null;
1234
+ } catch {
1235
+ layoutDerived = null;
1236
+ }
1237
+ if (!derivedOk(layoutDerived, layoutCheck)) {
1238
+ results.push(...failLayout(cell));
1239
+ layoutDerived = [];
1240
+ }
1241
+ }
1242
+ for (const item of [...derived, ...layoutDerived]) {
1190
1243
  const members = item.members ?? [];
1191
1244
  const row = {
1192
1245
  schema: QC_RESULT_SCHEMA,
1193
1246
  id: qcResultId(item.subject),
1194
- check,
1247
+ check: item.check,
1195
1248
  leg: "polish",
1196
1249
  result: item.result,
1197
1250
  reason_code: item.result === "pass" ? null : item.reason_code,
@@ -1210,6 +1263,378 @@ export function readReadability({ record, pageLoad = null, currentBuild = null,
1210
1263
  return results;
1211
1264
  }
1212
1265
 
1266
+ // Captures are re-derived from one caller-selected packet snapshot. Neither
1267
+ // the stored route set nor a stored decision can supply the capture plan.
1268
+ export function readCaptures({ record, packet, currentBuild, currentSource, buildOutput,
1269
+ campaignSlug, targetRepo, qcStandIns, rederivers } = {}) {
1270
+ const resolved = resolvePolishRules({ check: "visual.diff", qcStandIns, rederivers });
1271
+ if (resolved.status === "missing") return [];
1272
+ const rules = resolved.rules;
1273
+ let plan = null;
1274
+ try { if (packet && rules?.plan) plan = rules.plan({ packet, baseUrl: "http://127.0.0.1" }); } catch { /* fail closed below */ }
1275
+ const planned = Array.isArray(plan?.routes) ? plan.routes : [];
1276
+ const measuredAt = validTime(record?.measured_at) ? record.measured_at : null;
1277
+ // A changed build (§5.5 step 2) and a capture file not on this machine (step
1278
+ // 4.1, a fresh clone) are the row's own reason (§5.3 steps 2 and 9), as in
1279
+ // the sibling readers. Every other reader failure is evidence_not_reproducible
1280
+ // with the failure as its member. Either way every row of the record reads it.
1281
+ const failureRow = (subject, reason) => ["stale_binding", "capture_file_absent"].includes(reason)
1282
+ ? buildQcResult({ check: subject.check, leg: "polish", subject, result: "unexercised", reason_code: reason,
1283
+ state: { reason }, coverage: { observed: 1, expected: 1, limits: [reason] }, measured_at: measuredAt })
1284
+ : buildQcResult({ check: subject.check, leg: "polish", subject,
1285
+ result: "unexercised", reason_code: E, members: [{ key: "evidence", result: "unexercised", reason_code: reason }],
1286
+ state: { reason }, measured_at: measuredAt });
1287
+ const subjectOf = (check, item) => ({ check, page: item.route,
1288
+ ...(item.viewport == null ? {} : { viewport: item.viewport }),
1289
+ key: item.ref_id === "source_html" || item.basis === "source_html" ? "source_html" : `ref:${item.ref_id}` });
1290
+ const fallback = (reason, detail = null) => {
1291
+ const checks = qcChecksForRecord("captures");
1292
+ const subjects = planned.flatMap((route) => [
1293
+ { check: "visual.reference", page: route.requested_route, key: "source_html" },
1294
+ ...["desktop", "mobile"].map((viewport) => ({ check: "visual.diff", page: route.requested_route, viewport, key: "source_html" })),
1295
+ ]);
1296
+ // Read identity only, even when integrity/schema validation failed. These
1297
+ // names preserve accept disposition; they never authorize evidence or a route.
1298
+ const readableIdentity = (item) => isPlainObject(item) && isNonEmptyString(item.route)
1299
+ && (!plan || planned.some((route) => route.requested_route === item.route));
1300
+ for (const ref of Array.isArray(record?.references) ? record.references : []) {
1301
+ if (!readableIdentity(ref) || !isNonEmptyString(ref.ref_id)
1302
+ || !(ref.viewport == null || isNonEmptyString(ref.viewport))) continue;
1303
+ subjects.push(subjectOf("visual.reference", ref));
1304
+ if (ref.ref_id !== "source_html" && isNonEmptyString(ref.viewport)) subjects.push(subjectOf("visual.diff", ref));
1305
+ }
1306
+ for (const comparison of Array.isArray(record?.comparisons) ? record.comparisons : []) {
1307
+ if (!readableIdentity(comparison) || !isNonEmptyString(comparison.viewport)
1308
+ || !["source_html", "reference"].includes(comparison.basis)
1309
+ || (comparison.basis === "reference" && !isNonEmptyString(comparison.ref_id))) continue;
1310
+ subjects.push(subjectOf("visual.diff", comparison));
1311
+ subjects.push(subjectOf("visual.reference", { ...comparison,
1312
+ ref_id: comparison.basis === "source_html" ? "source_html" : comparison.ref_id,
1313
+ viewport: comparison.basis === "source_html" ? null : comparison.viewport }));
1314
+ }
1315
+ if (!subjects.length) for (const check of checks) subjects.push({ check, page: null, key: "captures" });
1316
+ return [...new Map(subjects.map((subject) => [qcResultId(subject),
1317
+ { ...failureRow(subject, reason), observation: { reader_detail: detail } }])).values()];
1318
+ };
1319
+ // Never ask the evaluator to traverse malformed evidence, even to report a
1320
+ // failure. Its input is a validated reference or one validated dependency set.
1321
+ const derive = (subset, context = {}) => {
1322
+ try { return rules.evaluate({ ...record, ...subset }, context); } catch { return []; }
1323
+ };
1324
+ const fail = (reason, detail) => fallback(reason, detail);
1325
+ if (!packet) return fail("packet_unavailable");
1326
+ if (!plan) return fail("packet_plan_invalid");
1327
+ if (resolved.status !== "loaded") return fail("rules_unavailable");
1328
+ const vocab = rules.vocabulary;
1329
+ const inVocab = (key, value) => Array.isArray(vocab[key]) && vocab[key].includes(value);
1330
+ const finite = (n) => typeof n === "number" && Number.isFinite(n);
1331
+ const nonnegative = (n) => finite(n) && n >= 0;
1332
+ const integer = (n) => Number.isSafeInteger(n) && n >= 0;
1333
+ const canonicalTime = (s) => validTime(s) && new Date(s).toISOString() === s;
1334
+ const nullable = (value, test) => value === null || test(value);
1335
+ const optional = (object, key, test) => !Object.hasOwn(object, key) || test(object[key]);
1336
+ const strings = (value) => Array.isArray(value) && value.every(isNonEmptyString);
1337
+ const fingerprint = (value) => typeof value === "string" && FINGERPRINT.test(value);
1338
+ const rect = (r) => isPlainObject(r) && ["x", "y", "width", "height"].every((k) => finite(r[k])) && r.width >= 0 && r.height >= 0;
1339
+ const png = (p, dir) => isPlainObject(p) && fingerprint(p.sha256)
1340
+ && p.path === `.campaign-runtime/polish/${dir}/${p.sha256.slice(7)}.png`
1341
+ && integer(p.bytes) && p.bytes > 0 && integer(p.width_px) && p.width_px > 0
1342
+ && integer(p.height_px) && p.height_px > 0 && p.width_px * p.height_px <= rules.limits.decode_pixels;
1343
+ if (!isPlainObject(record) || record.schema_version !== rules.schema_version
1344
+ || record.performed_by !== QC_PRODUCERS.polish || record.integrity !== polishRecordIntegrity(record)
1345
+ || record.treatment_version !== "visual-treatment/v1" || !canonicalTime(record.measured_at)
1346
+ || !isPlainObject(record.limits) || !sameJson(record.limits, rules.limits)
1347
+ || !isPlainObject(record.environment) || record.environment.device_scale_factor !== 1
1348
+ || !isPlainObject(record.subject) || !isNonEmptyString(packet.campaign?.public_route_slug)
1349
+ || record.subject.campaign_slug !== packet.campaign.public_route_slug || record.subject.campaign_slug !== campaignSlug
1350
+ || !fingerprint(record.subject.build_fingerprint)
1351
+ || !nullable(record.subject.source_package_material_fingerprint, fingerprint)
1352
+ || !sameJson(record.subject.viewports, rules.viewports) || !["all", "selected"].includes(record.subject.route_scope)
1353
+ || !strings(record.subject.routes) || new Set(record.subject.routes).size !== record.subject.routes.length
1354
+ || !isPlainObject(record.dependencies) || !["decoder", "diff"].every((key) => isPlainObject(record.dependencies[key])
1355
+ && inVocab("dependency_status", record.dependencies[key].status))
1356
+ || !isPlainObject(record.adapters) || !["build", "source"].every((side) => isPlainObject(record.adapters[side])
1357
+ && inVocab("startup", record.adapters[side].startup) && inVocab("close", record.adapters[side].close))
1358
+ || record.adapters.source.sealed !== true || !isPlainObject(record.source)
1359
+ || !inVocab("source_status", record.source.status) || record.source.server !== "loopback_read_only") return fail("record_shape_invalid");
1360
+ const routes = record.subject.routes;
1361
+ const cells = Array.isArray(record.cells) ? record.cells : [];
1362
+ const references = Array.isArray(record.references) ? record.references : [];
1363
+ const comparisons = Array.isArray(record.comparisons) ? record.comparisons : [];
1364
+ const cellIdentity = (cell) => isPlainObject(cell) && isNonEmptyString(cell.route) && rules.viewports.includes(cell.viewport) && inVocab("side", cell.side);
1365
+ const refIdentity = (ref) => isPlainObject(ref) && isNonEmptyString(ref.route) && isNonEmptyString(ref.ref_id)
1366
+ && (ref.viewport === null || isNonEmptyString(ref.viewport));
1367
+ const comparisonIdentity = (c) => isPlainObject(c) && isNonEmptyString(c.route) && isNonEmptyString(c.viewport)
1368
+ && ["source_html", "reference"].includes(c.basis) && (c.basis === "source_html" ? c.ref_id === null : isNonEmptyString(c.ref_id));
1369
+ const validateCell = (cell) => {
1370
+ if (!isPlainObject(cell)) return "cell_shape_invalid";
1371
+ if (!isNonEmptyString(cell.page_id) || !isPlainObject(cell.networkidle) || !isPlainObject(cell.readiness)
1372
+ || !isPlainObject(cell.readiness.fonts) || !isPlainObject(cell.readiness.await) || !isPlainObject(cell.sections)
1373
+ || !isPlainObject(cell.actual) || !isPlainObject(cell.stabilized)) return "cell_shape_invalid";
1374
+ if (!optional(cell.sections, "duplicated_keys", strings) || !optional(cell.sections, "capped", (v) => typeof v === "boolean")) return "cell_shape_invalid";
1375
+ if (!routes.includes(cell.route) || !inVocab("side", cell.side)
1376
+ || !rules.viewports.includes(cell.viewport) || !inVocab("cell_status", cell.status)
1377
+ || !inVocab("networkidle_status", cell.networkidle?.status)
1378
+ || !inVocab("fonts_status", cell.readiness?.fonts?.status) || !inVocab("await_status", cell.readiness?.await?.status)
1379
+ || !inVocab("section_status", cell.sections?.status) || !Array.isArray(cell.sections?.bands)
1380
+ || cell.sections.bands.length > rules.limits.bands_per_page
1381
+ || cell.sections.bands.some((b) => !isPlainObject(b) || !isNonEmptyString(b.key) || ![b.top, b.left, b.width, b.height].every(finite))) return "cell_shape_invalid";
1382
+
1383
+ if (cell.state !== "default_load" || cell.device_scale_factor !== 1) return "treatment_invalid";
1384
+ const fixed = cell.viewport === "desktop" ? [1440, 1200] : [390, 844];
1385
+ if (cell.width !== fixed[0] || cell.height !== fixed[1]) return "png_dimensions_mismatch";
1386
+ const treatment = cell.stabilized.treatment;
1387
+ if (!isPlainObject(treatment)) return "treatment_invalid";
1388
+ if (treatment?.mask_selectors !== "visual-masks/v1" || treatment.animations !== "disabled" || treatment.caret !== "hide"
1389
+ || treatment.mask_color !== "#ff00ff" || typeof treatment.mask_cap_reached !== "boolean" || !Array.isArray(treatment.masks)
1390
+ || treatment.masks.some((m) => !isPlainObject(m) || !inVocab("mask_group", m.group) || !integer(m.count) || !Array.isArray(m.rects) || !m.rects.every(rect))) return "treatment_invalid";
1391
+ for (const shot of [cell.actual, cell.stabilized]) {
1392
+ if (!inVocab("shot_status", shot?.status)) return "shot_shape_invalid";
1393
+ if (shot.status !== "captured") {
1394
+ if (!nullable(shot.png, (p) => png(p, "captures")) || !nullable(shot.crop, rect)
1395
+ || !nullable(shot.full_height_css, nonnegative) || typeof shot.truncated !== "boolean") return "shot_shape_invalid";
1396
+ continue;
1397
+ }
1398
+ if (!finite(shot.full_height_css) || shot.full_height_css <= 0 || !sameJson(shot.crop,
1399
+ { x: 0, y: 0, width: cell.width, height: Math.min(shot.full_height_css, 12000) })
1400
+ || shot.truncated !== (shot.full_height_css > 12000)) return "crop_not_full_page";
1401
+ if (!png(shot.png, "captures") || shot.png.width_px !== shot.crop.width || shot.png.height_px !== shot.crop.height) return "png_dimensions_mismatch";
1402
+ }
1403
+ return null;
1404
+ };
1405
+ const provenanceReasons = ["path_in_build_output", "path_in_runtime_dir", "hash_equals_package_capture", "linked_source_self_referential", "hash_equals_built_html", "source_is_campaign_page", "source_link_missing", "linked_source_missing", "source_hash_unrecorded", "source_hash_mismatch", "capture_time_unrecorded"];
1406
+ const inputReasons = ["source_root_unavailable", "source_html_missing", "source_package_unavailable", "source_package_stale"];
1407
+ const comparisonReasons = [...vocab.part_reason, "width_unknown", "dpr_unknown", "width_mismatch", "dpr_mismatch", "mask_cap_reached", "diff_budget_exhausted", "image_too_large", "decode_failed", "file_missing", "path_outside_source_root", "too_large", "diff_unavailable", "capture_unavailable", "reference_not_usable", "comparison_unsupported", "readiness_incomplete"];
1408
+ const validateReference = (ref) => {
1409
+ if (!isPlainObject(ref) || !isNonEmptyString(ref.page_id) || !isPlainObject(ref.verification)
1410
+ || !isPlainObject(ref.provenance) || !isPlainObject(ref.identity)
1411
+ || !nullable(ref.identity.sha256, fingerprint)
1412
+ || !optional(ref.verification, "png", (p) => nullable(p, (v) => isPlainObject(v) && integer(v.width_px) && v.width_px > 0 && integer(v.height_px) && v.height_px > 0))
1413
+ || !optional(ref, "css_width", (v) => nullable(v, nonnegative)) || !optional(ref, "dpr", (v) => nullable(v, nonnegative))
1414
+ || !optional(ref, "reference_cap_reached", (v) => typeof v === "boolean") || !routes.includes(ref.route) || !isNonEmptyString(ref.ref_id)
1415
+ || (ref.ref_id === "source_html" && ref.viewport !== null)
1416
+ || !inVocab("verification_level", ref.verification?.level) || !inVocab("provenance_class", ref.provenance?.class)
1417
+ || !["html_file", "source_html", "source_screenshot"].includes(ref.kind)
1418
+ || !Array.isArray(ref.provenance.reasons) || ref.provenance.reasons.some((r) => !provenanceReasons.includes(r))
1419
+ || (ref.input_reason != null && !inputReasons.includes(ref.input_reason))
1420
+ || (ref.verification.reason != null && !inVocab("verification_reason", ref.verification.reason))) return "reference_shape_invalid";
1421
+ return null;
1422
+ };
1423
+ const validateComparison = (comparison, build, basis, reference) => {
1424
+ if (!isPlainObject(comparison) || !isNonEmptyString(comparison.page_id)
1425
+ || !Array.isArray(comparison.members) || comparison.members.some((m) => !isPlainObject(m) || !isNonEmptyString(m.key)
1426
+ || !QC_RESULTS.includes(m.result) || (m.result !== "pass" && !isNonEmptyString(m.reason_code ?? m.reason)))
1427
+ || !isPlainObject(comparison.diff_options) || !routes.includes(comparison.route) || !["source_html", "reference"].includes(comparison.basis)
1428
+ || (comparison.basis === "source_html" && !rules.viewports.includes(comparison.viewport))
1429
+ || !inVocab("comparison_status", comparison.status)
1430
+ || (comparison.reason != null && !comparisonReasons.includes(comparison.reason))
1431
+ || !sameJson(comparison.diff_options, { threshold: 0.1, include_aa: false, alpha: 0.1 })
1432
+ || !Array.isArray(comparison.limits) || comparison.limits.some((limit) => !["source_external_blocked", "reference_viewport_height_unrecorded", "top_anchored", "js_animation_not_frozen", "masks_unioned"].includes(limit))
1433
+ || (comparison.page === null && (comparison.status === "compared"
1434
+ || (comparison.status === "unsupported" && !["width_unknown", "dpr_unknown", "width_mismatch", "dpr_mismatch", "mask_cap_reached"].includes(comparison.reason))))
1435
+ || !Array.isArray(comparison.bands) || comparison.bands.some((b) => !isPlainObject(b)) || !Array.isArray(comparison.artifacts)) return "comparison_shape_invalid";
1436
+ if (comparison.build_shot !== (comparison.basis === "reference" ? "actual" : "stabilized")
1437
+ || comparison.basis_shot !== (comparison.basis === "reference" ? "image" : "stabilized")) return "comparison_shape_invalid";
1438
+
1439
+ if (comparison.basis === "source_html" && (!build || !basis)) return "comparison_shape_invalid";
1440
+ for (const part of [...(comparison.page === null ? [] : [comparison.page]), ...comparison.bands]) {
1441
+ if (!isPlainObject(part) || (part !== comparison.page && !isNonEmptyString(part.key))) return "metrics_invalid";
1442
+ if (Object.hasOwn(part, "worst_tiles") && !Array.isArray(part.worst_tiles)) return "metrics_invalid";
1443
+ if (!["compared", "unsupported"].includes(part.status)) return "metrics_invalid";
1444
+ if (!optional(part, "height_delta_px", finite)) return "metrics_invalid";
1445
+ if (part.status !== "compared") { if (!inVocab("part_reason", part.reason)) return "metrics_invalid"; continue; }
1446
+ if (!nonnegative(part.mismatch_ratio) || part.mismatch_ratio > 1 || !integer(part.mismatch_pixels)
1447
+ || !integer(part.compared_pixels) || part.mismatch_pixels > part.compared_pixels
1448
+ || !integer(part.masked_pixels) || !isPlainObject(part.canvas) || !integer(part.canvas?.width) || !integer(part.canvas?.height)
1449
+ || part.canvas.width <= 0 || part.canvas.height <= 0
1450
+ || part.compared_pixels + part.masked_pixels > part.canvas.width * part.canvas.height
1451
+ || part.canvas.width * part.canvas.height > rules.limits.decode_pixels) return "metrics_invalid";
1452
+ for (const tile of part.worst_tiles ?? []) if (!rect(tile) || tile.x < 0 || tile.y < 0
1453
+ || tile.x + tile.width > part.canvas.width || tile.y + tile.height > part.canvas.height
1454
+ || !nonnegative(tile.mismatch_ratio) || tile.mismatch_ratio > 1) return "metrics_invalid";
1455
+ }
1456
+ const pixelSize = (cell, key) => {
1457
+ const boxes = (Array.isArray(cell?.sections?.bands) ? cell.sections.bands : []).filter((b) => b.key === key);
1458
+ if (boxes.length !== 1) return null;
1459
+ const [b] = boxes;
1460
+ if (b.top < 0 || b.left < 0 || b.top + b.height > cell.stabilized?.png?.height_px || b.left + b.width > cell.width) return null;
1461
+ return [Math.ceil(b.left + b.width) - Math.floor(b.left), Math.ceil(b.top + b.height) - Math.floor(b.top)];
1462
+ };
1463
+ for (const part of comparison.bands.filter((b) => b.status === "compared")) {
1464
+ const a = pixelSize(basis, part.key), b = pixelSize(build, part.key);
1465
+ if (!a || !b || !sameJson([part.canvas.width, part.canvas.height], [Math.max(a[0], b[0]), Math.max(a[1], b[1])])) return "band_geometry_invalid";
1466
+ }
1467
+ for (const artifact of comparison.artifacts) {
1468
+ if (!isPlainObject(artifact) || !isNonEmptyString(artifact.band_key)) return "artifact_geometry_invalid";
1469
+ const part = artifact.band_key === "page" ? comparison.page : comparison.bands.find((b) => b.key === artifact.band_key);
1470
+ if (!part || part.status !== "compared") return "artifact_geometry_invalid";
1471
+ const source = reference;
1472
+ const basisSize = artifact.band_key === "page" ? (comparison.basis === "reference" ? source?.verification?.png : basis?.stabilized?.png) : null;
1473
+ const sizes = { basis_crop: basisSize ? [basisSize.width_px, basisSize.height_px] : pixelSize(basis, part.key),
1474
+ build_crop: artifact.band_key === "page" ? [build?.[comparison.build_shot]?.png?.width_px, build?.[comparison.build_shot]?.png?.height_px] : pixelSize(build, part.key),
1475
+ heatmap: [part.canvas?.width, part.canvas?.height] };
1476
+ for (const name of Object.keys(sizes)) if (!png(artifact[name], "diff")
1477
+ || !sameJson([artifact[name].width_px, artifact[name].height_px], sizes[name])) return "artifact_geometry_invalid";
1478
+ }
1479
+ return null;
1480
+ };
1481
+ const cellErrors = new Map(cells.filter(cellIdentity).map((c) => [c, validateCell(c)]));
1482
+ const refErrors = new Map(references.filter(refIdentity).map((r) => [r, validateReference(r)]));
1483
+ const cellGridInvalid = !Array.isArray(record.cells) || cells.length !== routes.length * 4
1484
+ || cells.some((c) => !cellIdentity(c))
1485
+ || new Set(cells.filter(cellIdentity).map((c) => JSON.stringify([c.route, c.viewport, c.side]))).size !== cells.length;
1486
+ const unknownReferences = !Array.isArray(record.references) || references.some((r) => !refIdentity(r));
1487
+ const unknownComparisons = !Array.isArray(record.comparisons) || comparisons.some((c) => !comparisonIdentity(c));
1488
+ if (cellGridInvalid) return fail("cell_grid_invalid");
1489
+ const cellFailure = [...cellErrors.values()].find(Boolean);
1490
+ if (cellFailure) return fail(cellFailure);
1491
+ if (unknownReferences) return fail("reference_shape_invalid");
1492
+ if (unknownComparisons) return fail("comparison_shape_invalid");
1493
+ if ([...refErrors.values()].some(Boolean)) return fail("reference_shape_invalid");
1494
+ const referenceKey = (ref) => JSON.stringify([ref.route, ref.viewport, ref.ref_id]);
1495
+ if (new Set(references.map(referenceKey)).size !== references.length) return fail("reference_shape_invalid");
1496
+ const stale = record.subject.build_fingerprint !== currentBuild
1497
+ || record.subject.source_package_material_fingerprint !== (currentSource || null) || outputDrifted(buildOutput);
1498
+ const outsidePlan = routes.some((route) => !planned.some((p) => p.requested_route === route))
1499
+ || cells.some((c) => !planned.some((p) => p.requested_route === c.route && p.page_id === c.page_id));
1500
+ const context = stale ? { staleBinding: true } : {};
1501
+ const rows = [];
1502
+ const referenceGroups = new Map(), cappedReferences = new Set();
1503
+ for (const ref of references.filter(refIdentity)) if (ref.ref_id !== "source_html") {
1504
+ const group = JSON.stringify([ref.page_id, ref.route, ref.viewport]);
1505
+ const items = referenceGroups.get(group) || [];
1506
+ if (items.length >= rules.limits.references_per_cell) cappedReferences.add(referenceKey(ref));
1507
+ items.push(ref); referenceGroups.set(group, items);
1508
+ }
1509
+ const safeReferences = references.filter((r) => refIdentity(r) && !refErrors.get(r) && !cappedReferences.has(referenceKey(r)))
1510
+ .map((ref) => referenceGroups.get(JSON.stringify([ref.page_id, ref.route, ref.viewport]))?.length > rules.limits.references_per_cell
1511
+ ? { ...ref, reference_cap_reached: true } : ref);
1512
+ const referenceRows = derive({ cells: [], references: safeReferences, comparisons: [] }, context);
1513
+ for (const ref of references.filter(refIdentity)) {
1514
+ if (cappedReferences.has(referenceKey(ref))) continue;
1515
+ const subject = subjectOf("visual.reference", ref);
1516
+ const duplicates = references.filter(refIdentity).filter((r) => r.route === ref.route && r.viewport === ref.viewport && r.ref_id === ref.ref_id).length > 1;
1517
+ const failure = refErrors.get(ref) || (duplicates ? "reference_shape_invalid" : null);
1518
+ const row = referenceRows.find((r) => r.id === qcResultId(subject));
1519
+ if (failure || !row) return fail(failure || "reference_shape_invalid");
1520
+ rows.push({ ...row, state_fingerprint: qcStateFingerprint({ subject: row.subject,
1521
+ state: { kind: ref.kind, sha256: ref.identity.sha256, provenance_reasons: [...ref.provenance.reasons].sort() } }) });
1522
+ }
1523
+ const checked = new Map();
1524
+ const checkFile = (ref) => {
1525
+ // Read each file once, then compare every descriptor against those bytes.
1526
+ if (!checked.has(ref.path)) {
1527
+ let actual;
1528
+ try {
1529
+ const bytes = readRegularFileSync(resolve(targetRepo, ref.path), { root: targetRepo, noFollow: true, maxBytes: rules.limits.reference_bytes });
1530
+ actual = { hash: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
1531
+ bytes: bytes.length, structure: readPngStructure(bytes) };
1532
+ } catch (error) { actual = error.code === "ENOENT" ? { absent: true } : { failure: captureReadFailure(error.code) }; }
1533
+ checked.set(ref.path, actual);
1534
+ }
1535
+ const actual = checked.get(ref.path);
1536
+ if (actual.absent || actual.failure) return actual;
1537
+ return actual.hash !== ref.sha256 ? { failure: "png_hash_mismatch" }
1538
+ : !actual.structure.ok ? { failure: "png_structure_invalid", detail: actual.structure.reason }
1539
+ : actual.bytes !== ref.bytes || actual.structure.ihdr.width_px !== ref.width_px || actual.structure.ihdr.height_px !== ref.height_px
1540
+ ? { failure: "png_dimensions_mismatch" } : {};
1541
+ };
1542
+ const descriptors = cells.flatMap((c) => [c?.actual, c?.stabilized].filter((s) => s?.status === "captured").map((s) => s.png));
1543
+ for (const comparison of comparisons.filter(comparisonIdentity)) {
1544
+ const subject = subjectOf("visual.diff", comparison);
1545
+ const matching = (side) => cells.filter(cellIdentity).filter((c) => c.route === comparison.route && c.viewport === comparison.viewport && c.side === side);
1546
+ const builds = matching("build"), bases = matching("source_html"), build = builds[0], basis = bases[0];
1547
+ const refs = references.filter(refIdentity).filter((ref) => ref.route === comparison.route && (comparison.basis === "source_html"
1548
+ ? ref.ref_id === "source_html" : ref.ref_id === comparison.ref_id && ref.viewport === comparison.viewport));
1549
+ const needed = comparison.basis === "source_html" ? [build, basis] : [build].filter(Boolean);
1550
+ const duplicates = comparisons.filter(comparisonIdentity).filter((c) => c.route === comparison.route && c.viewport === comparison.viewport && c.basis === comparison.basis && c.ref_id === comparison.ref_id).length > 1;
1551
+ const failure = (cellGridInvalid || builds.length > 1 || bases.length > 1 || needed.some((c) => !c) ? "cell_grid_invalid" : null)
1552
+ || needed.map((c) => cellErrors.get(c)).find(Boolean)
1553
+ || (refs.length !== 1 ? "reference_shape_invalid" : refErrors.get(refs[0]))
1554
+ || (duplicates ? "comparison_shape_invalid" : null)
1555
+ || validateComparison(comparison, build, comparison.basis === "reference" ? null : basis, refs[0]);
1556
+ if (failure) return fail(failure);
1557
+ descriptors.push(...comparison.artifacts.flatMap((a) => [a.basis_crop, a.build_crop, a.heatmap]));
1558
+ if (comparison.basis === "reference" && cappedReferences.has(referenceKey(comparison))) continue;
1559
+ const subset = { cells: needed, references: safeReferences, comparisons: [comparison] };
1560
+ const row = derive(subset, context).find((r) => r.check === "visual.diff");
1561
+ if (!row) return fail("comparison_shape_invalid");
1562
+ rows.push(row);
1563
+ }
1564
+ // Every reader check invalidates the record, preserving all readable keys.
1565
+ for (const comparison of comparisons.filter(comparisonIdentity)) {
1566
+ if (comparison.basis === "reference" && cappedReferences.has(referenceKey(comparison))) continue;
1567
+ const subject = subjectOf("visual.reference", { ...comparison, ref_id: comparison.basis === "source_html" ? "source_html" : comparison.ref_id,
1568
+ viewport: comparison.basis === "source_html" ? null : comparison.viewport });
1569
+ if (!rows.some((r) => r.id === qcResultId(subject))) return fail("reference_shape_invalid");
1570
+ }
1571
+ for (const ref of references.filter(refIdentity)) if (ref.ref_id !== "source_html" && !cappedReferences.has(referenceKey(ref))) {
1572
+ const subject = subjectOf("visual.diff", { ...ref, basis: "reference" });
1573
+ if (!rows.some((r) => r.id === qcResultId(subject))) return fail("comparison_shape_invalid");
1574
+ }
1575
+ for (const route of planned) {
1576
+ if (routes.includes(route.requested_route) && !rows.some((r) => r.check === "visual.reference" && r.subject.page === route.requested_route && r.subject.key === "source_html")) {
1577
+ return fail("comparison_grid_invalid");
1578
+ }
1579
+ for (const viewport of rules.viewports) {
1580
+ if (rows.some((r) => r.check === "visual.diff" && r.subject.page === route.requested_route && r.subject.viewport === viewport && r.subject.key === "source_html")) continue;
1581
+ const subject = { check: "visual.diff", page: route.requested_route, viewport, key: "source_html" };
1582
+ if (routes.includes(route.requested_route)) return fail("comparison_grid_invalid");
1583
+ rows.push(buildQcResult({ check: subject.check, leg: "polish", subject, result: "unexercised", reason_code: "page_not_captured", state: { subject }, measured_at: measuredAt }));
1584
+ }
1585
+ }
1586
+ if (stale) return fail("stale_binding");
1587
+ if (outsidePlan) return fail("route_outside_plan");
1588
+ // Structural checks precede binding/plan and files. No failure can leave an
1589
+ // independently acceptable reference or a sibling comparison behind.
1590
+ const descriptorsByPath = new Map();
1591
+ for (const ref of descriptors) {
1592
+ if (descriptorsByPath.has(ref.path) && !sameJson(descriptorsByPath.get(ref.path), ref)) return fail("png_dimensions_mismatch");
1593
+ descriptorsByPath.set(ref.path, ref);
1594
+ }
1595
+ // A file that is present but wrong outranks one that is absent (§5.3 step 1
1596
+ // before step 9), whichever is read first.
1597
+ const files = [...descriptorsByPath.values()].map(checkFile);
1598
+ const wrong = files.find((file) => file.failure);
1599
+ if (wrong) return fail(wrong.failure, wrong.detail);
1600
+ if (files.some((file) => file.absent)) return fail("capture_file_absent");
1601
+ const unique = new Map();
1602
+ for (const row of rows) if (!unique.has(row.id) || row.reason_code === E) unique.set(row.id, row);
1603
+ return unique.size ? [...unique.values()] : fail("record_shape_invalid");
1604
+ }
1605
+
1606
+ // Element parity rows are re-derived by the rules module's reader from the
1607
+ // probe files, C's captures record, the packet snapshot, the Design Source
1608
+ // Package and the current build. A campaign with no mapped source page has no
1609
+ // parity.element row (applicability). A reader that cannot run reads every
1610
+ // row of the record evidence_not_reproducible, never pass.
1611
+ export const parityApplies = (packet) => Array.isArray(packet?.source_html?.pages) && packet.source_html.pages.some((page) => isNonEmptyString(page?.path));
1612
+
1613
+ export function readElementParity({ record, captures = null, packet = null, packetPath = null, currentBuild = null, currentSource = null,
1614
+ buildOutput = null, campaignSlug = null, targetRepo = null, qcAccepts = [], qcStandIns = null, rederivers = null } = {}) {
1615
+ if (packet && !parityApplies(packet)) return [];
1616
+ const resolved = resolvePolishRules({ check: "parity.element", qcStandIns, rederivers });
1617
+ const context = { record, captures, packet, packetPath, currentBuild, currentSource, buildOutput, campaignSlug, targetRepo, qcAccepts };
1618
+ if (resolved.status === "loaded" && typeof resolved.rules.read === "function") {
1619
+ try {
1620
+ const rows = resolved.rules.read(context, resolved.rules);
1621
+ if (Array.isArray(rows)) return rows;
1622
+ } catch { /* fail closed below */ }
1623
+ }
1624
+ const measuredAt = validTime(record?.measured_at) ? record.measured_at : null;
1625
+ const cells = (Array.isArray(record?.cells) ? record.cells : []).filter((cell) => isPlainObject(cell) && isNonEmptyString(cell.page_id) && ["desktop", "mobile"].includes(cell.viewport));
1626
+ const mapped = (Array.isArray(packet?.source_html?.pages) ? packet.source_html.pages : []).filter((page) => isNonEmptyString(page?.path) && isNonEmptyString(page.page_id));
1627
+ const subjects = [...cells.map((cell) => ({ check: "parity.element", page: cell.page_id, viewport: cell.viewport, key: "comparison" })),
1628
+ ...mapped.flatMap((page) => ["desktop", "mobile"].map((viewport) => ({ check: "parity.element", page: page.page_id, viewport, key: "comparison" }))),
1629
+ ...(Array.isArray(qcAccepts) ? qcAccepts : []).filter((accept) => accept?.check === "parity.element" && accept.leg === "polish"
1630
+ && accept.subject?.check === "parity.element" && isNonEmptyString(accept.subject.page) && ["desktop", "mobile"].includes(accept.subject.viewport)
1631
+ && /^(copy|type|color|geom):el-[a-f0-9]{12}$/.test(accept.subject.key)).map((accept) => accept.subject)];
1632
+ if (!subjects.length) subjects.push({ check: "parity.element", page: null, key: "element_parity" });
1633
+ return [...new Map(subjects.map((subject) => [qcResultId(subject), buildQcResult({ check: "parity.element", leg: "polish", subject,
1634
+ result: "unexercised", reason_code: E, members: [{ key: "evidence", result: "unexercised", reason_code: "rules_unavailable" }],
1635
+ state: { reason: "rules_unavailable" }, measured_at: measuredAt })])).values()];
1636
+ }
1637
+
1213
1638
  // ---------------------------------------------------------------------------
1214
1639
  // Every current result, per leg, and the silence rule
1215
1640
 
@@ -1222,8 +1647,16 @@ const POLISH_CHECKS = Object.freeze(qcChecksForLeg("polish"));
1222
1647
  // on its own.
1223
1648
  const POLISH_RECORDS = Object.freeze({
1224
1649
  media_weight: Object.freeze({ checks: Object.freeze(qcChecksForRecord("media_weight")), read: readMediaWeight }),
1650
+ captures: Object.freeze({ checks: Object.freeze(qcChecksForRecord("captures")), read: readCaptures }),
1225
1651
  readability: Object.freeze({ checks: Object.freeze(qcChecksForRecord("readability")), read: readReadability }),
1652
+ element_parity: Object.freeze({ checks: Object.freeze(qcChecksForRecord("element_parity")), read: readElementParity }),
1226
1653
  });
1654
+ // Records whose checks apply only to some campaigns, judged from the packet
1655
+ // snapshot. With no packet applicability is unknown: existing evidence still
1656
+ // applies, while an absent record keeps the no-packet behaviour.
1657
+ const POLISH_RECORD_APPLIES = Object.freeze({ element_parity: parityApplies });
1658
+ const polishRecordApplies = (name, packet, visual) => !Object.hasOwn(POLISH_RECORD_APPLIES, name)
1659
+ || (packet != null ? POLISH_RECORD_APPLIES[name](packet) : visual?.[name] != null);
1227
1660
  const STORE_POLICY_FIELDS = Object.freeze(Object.keys(STORE_PAGE_MATCHERS));
1228
1661
 
1229
1662
  // Applicability is decided without the leg running: the tracking checks
@@ -1255,7 +1688,7 @@ const silence = (leg, check, reasonCode) => ({ check, leg, result: "unexercised"
1255
1688
  // record predates the check, its module is missing or not yet loaded, or the
1256
1689
  // loaded rules gave no row), except that a module that failed to load reads
1257
1690
  // evidence_not_reproducible when the record holds the leg's QC evidence.
1258
- export function handoffCoverage({ report, spec, results = [], qcStandIns = null, rederivers = null }) {
1691
+ export function handoffCoverage({ report, spec, results = [], qcStandIns = null, rederivers = null, packet = null }) {
1259
1692
  const entries = [];
1260
1693
  const hasRow = (leg, check) => results.some((row) => row.leg === leg && row.check === check);
1261
1694
  const silentReason = (failed) => (failed ? E : QC_REASON.NOT_CAPTURED);
@@ -1273,11 +1706,14 @@ export function handoffCoverage({ report, spec, results = [], qcStandIns = null,
1273
1706
  }
1274
1707
 
1275
1708
  const polishStage = report?.stages?.polish;
1709
+ const visual = polishStage?.evidence?.visual_review;
1276
1710
  if (!stageRecorded(polishStage)) {
1277
- for (const check of POLISH_CHECKS) entries.push(silence("polish", check, QC_REASON.LEG_NOT_RUN));
1711
+ for (const check of POLISH_CHECKS) {
1712
+ if (polishRecordApplies(QC_CHECK_REGISTRY[check].record, packet, visual)) entries.push(silence("polish", check, QC_REASON.LEG_NOT_RUN));
1713
+ }
1278
1714
  } else {
1279
- const visual = polishStage.evidence?.visual_review;
1280
1715
  for (const [name, { checks }] of Object.entries(POLISH_RECORDS)) {
1716
+ if (!polishRecordApplies(name, packet, visual)) continue;
1281
1717
  const captured = name === "media_weight" ? isPlainObject(visual?.page_load) && visual.media_weight != null : visual?.[name] != null;
1282
1718
  for (const check of checks) {
1283
1719
  if (hasRow("polish", check)) continue;
@@ -1291,17 +1727,10 @@ export function handoffCoverage({ report, spec, results = [], qcStandIns = null,
1291
1727
 
1292
1728
  // The campaign slug the Polish records bind to: the packet's, read from
1293
1729
  // packetPath; with no readable packet, the Assembly Report's (which capture
1294
- // requires to equal the packet's). Null when neither names one.
1295
- function packetCampaignSlug({ packetPath, report }) {
1296
- if (isNonEmptyString(packetPath)) {
1297
- try {
1298
- const slug = JSON.parse(readFileSync(packetPath, "utf8"))?.campaign?.public_route_slug;
1299
- return isNonEmptyString(slug) ? slug : null;
1300
- } catch {
1301
- // No readable packet: fall through to the report.
1302
- }
1303
- }
1304
- const slug = report?.identity?.public_route_slug;
1730
+ // requires to equal the packet's). A readable packet without one gives null,
1731
+ // as on main. Null when neither names one.
1732
+ export function packetCampaignSlug({ packet, packetRead = false, report }) {
1733
+ const slug = packetRead ? packet?.campaign?.public_route_slug : report?.identity?.public_route_slug;
1305
1734
  return isNonEmptyString(slug) ? slug : null;
1306
1735
  }
1307
1736
 
@@ -1313,13 +1742,19 @@ export function readCurrentQcResults({ report, doctor, spec = null, targetRepo,
1313
1742
  const doctorResults = (Array.isArray(doctor?.derived?.qc_results) ? doctor.derived.qc_results : []).filter((row) => isPlainObject(row) && row.leg === "doctor");
1314
1743
  const visual = report?.stages?.polish?.evidence?.visual_review;
1315
1744
  const recorded = isPlainObject(visual) ? Object.entries(POLISH_RECORDS).filter(([name]) => visual[name] != null) : [];
1745
+ let packet = null, packetRead = false;
1746
+ try { if (packetPath) { packet = JSON.parse(readRegularFileSync(packetPath, { maxBytes: 16 * 1024 * 1024 }).toString("utf8")); packetRead = true; } } catch { /* unavailable */ }
1316
1747
  const polishContext = recorded.length ? {
1317
1748
  pageLoad: visual.page_load,
1749
+ captures: visual.captures ?? null,
1750
+ packet,
1751
+ packetPath,
1318
1752
  currentBuild,
1319
1753
  currentSource: currentSourcePackageMaterialFingerprint(report),
1320
1754
  buildOutput: doctor?.derived?.build_output_fingerprint ?? null,
1321
- campaignSlug: packetCampaignSlug({ packetPath, report }),
1755
+ campaignSlug: packetCampaignSlug({ packet, packetRead, report }),
1322
1756
  targetRepo,
1757
+ qcAccepts: report?.qc_accepts,
1323
1758
  qcStandIns,
1324
1759
  rederivers,
1325
1760
  } : null;
@@ -1337,5 +1772,5 @@ export function readCurrentQcResults({ report, doctor, spec = null, targetRepo,
1337
1772
  })
1338
1773
  : [];
1339
1774
  const results = [...doctorResults, ...polishResults, ...qaResults];
1340
- return { results, coverage: handoffCoverage({ report, spec, results, qcStandIns, rederivers }) };
1775
+ return { results, coverage: handoffCoverage({ report, spec, results, qcStandIns, rederivers, packet }) };
1341
1776
  }