@nextcommerce/campaigns-os 1.60.0 → 1.61.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 (88) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/README.md +5 -5
  3. package/agents/claude/CLAUDE.md +15 -0
  4. package/agents/codex/AGENTS.md +15 -0
  5. package/agents/copilot/copilot-instructions.md +15 -0
  6. package/agents/cursor/campaigns-os.mdc +15 -0
  7. package/compatibility.json +1 -1
  8. package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
  9. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  10. package/contracts/commerce-surface-catalog.json +27 -26
  11. package/contracts/release-ledger.json +768 -0
  12. package/contracts/supported-surface.json +4 -4
  13. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  14. package/docs/access-model.md +20 -0
  15. package/docs/brand-theme-bridge.md +62 -3
  16. package/docs/build-packet.md +28 -1
  17. package/docs/campaign-build-brief.md +16 -0
  18. package/docs/campaigns-os-build-flow.md +61 -2
  19. package/docs/campaignspec-authoring-examples.md +116 -0
  20. package/docs/design-source-package.md +46 -2
  21. package/docs/entry-points.md +188 -0
  22. package/docs/local-setup.md +1 -1
  23. package/docs/orientation-contract-reference.md +1 -1
  24. package/docs/polish-evidence.md +40 -2
  25. package/docs/qa-and-test-orders.md +50 -2
  26. package/docs/quickstart.md +438 -0
  27. package/docs/readback.md +119 -6
  28. package/docs/runtime-readiness.md +1 -1
  29. package/docs/sdk-storage-compatibility.md +3 -1
  30. package/docs/skills-revision.md +10 -10
  31. package/docs/source-adapters.md +213 -0
  32. package/package.json +10 -3
  33. package/schemas/campaign-design-source-package.v0.schema.json +8 -0
  34. package/schemas/campaigns-os-readback.v2.schema.json +177 -1
  35. package/schemas/source-html-manifest.v0.schema.json +3 -0
  36. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  37. package/skills/campaign-readback-classification/SKILL.md +3 -3
  38. package/skills/campaign-run-evidence/SKILL.md +3 -3
  39. package/skills/contribution-intake/SKILL.md +3 -3
  40. package/skills/next-campaigns-build/SKILL.md +4 -4
  41. package/skills/next-campaigns-os/SKILL.md +4 -3
  42. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  43. package/skills/next-campaigns-polish/SKILL.md +6 -9
  44. package/skills/next-campaigns-qa/SKILL.md +3 -3
  45. package/skills/next-campaigns-sdk-bump/SKILL.md +117 -24
  46. package/skills.json +11 -11
  47. package/src/brand-theme.mjs +413 -8
  48. package/src/brief-summary.mjs +6 -2
  49. package/src/browser-launch.mjs +2 -1
  50. package/src/build-brief.mjs +9 -2
  51. package/src/built-site-scope.mjs +2 -2
  52. package/src/cli.mjs +52 -13
  53. package/src/design-file-reference.mjs +93 -0
  54. package/src/design-source-package.mjs +55 -1
  55. package/src/doctor/checks.mjs +8 -74
  56. package/src/doctor/ci.mjs +112 -0
  57. package/src/doctor/inspect.mjs +18 -0
  58. package/src/doctor/source-provenance.mjs +74 -0
  59. package/src/evidence-debt.mjs +119 -0
  60. package/src/parity/compare.mjs +316 -0
  61. package/src/parity/design-access.mjs +54 -0
  62. package/src/parity/element-parity-record.mjs +569 -0
  63. package/src/parity/probe.mjs +205 -0
  64. package/src/parity/snapshot.mjs +110 -0
  65. package/src/png-header.mjs +87 -0
  66. package/src/polish-browser.mjs +186 -9
  67. package/src/polish-captures-record.mjs +52 -0
  68. package/src/polish-deadline.mjs +14 -0
  69. package/src/polish-gate.mjs +25 -6
  70. package/src/polish-node.mjs +81 -7
  71. package/src/polish-visual-fixtures.mjs +74 -0
  72. package/src/polish-visual.mjs +606 -0
  73. package/src/qa-analytics-correctness.mjs +14 -2
  74. package/src/qa-analytics-parity.mjs +94 -5
  75. package/src/qa-node.mjs +39 -6
  76. package/src/qc-accept.mjs +148 -6
  77. package/src/qc-check-registry.mjs +37 -1
  78. package/src/qc-results.mjs +404 -16
  79. package/src/readback.mjs +57 -13
  80. package/src/sdk-attribute-index.mjs +11 -11
  81. package/src/sdk-markup.mjs +77 -2
  82. package/src/sdk-storage-compatibility.mjs +94 -4
  83. package/src/source-html-manifest.mjs +7 -0
  84. package/src/source-html-server.mjs +129 -0
  85. package/src/stage-record.mjs +45 -7
  86. package/src/theme-gate.mjs +2 -2
  87. package/src/visual-diff.mjs +296 -0
  88. package/src/visual-reference.mjs +126 -0
@@ -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";
@@ -831,6 +833,10 @@ const PAGE_NOT_CAPTURED = "page_not_captured";
831
833
  // output under _site/<slug>/ is the one `record build` recorded. Any other
832
834
  // value, null included, is drift. A caller that passes no value (undefined)
833
835
  // reads without the output check.
836
+ // A capture file that is there but cannot be read as a regular file names
837
+ // why; only a missing file is capture_file_absent.
838
+ const captureReadFailure = (code) => code === "too_large" ? "capture_file_too_large"
839
+ : ["ELOOP", "EISDIR", "ENOTDIR", "not_regular_file", "outside_root"].includes(code) ? "capture_file_not_regular" : "capture_file_unreadable";
834
840
  const outputDrifted = (buildOutput) => buildOutput !== undefined && buildOutput?.status !== "pass";
835
841
 
836
842
  export function readMediaWeight({ record, pageLoad, currentBuild = null, buildOutput = undefined, qcStandIns = null, rederivers = null } = {}) {
@@ -1210,6 +1216,378 @@ export function readReadability({ record, pageLoad = null, currentBuild = null,
1210
1216
  return results;
1211
1217
  }
1212
1218
 
1219
+ // Captures are re-derived from one caller-selected packet snapshot. Neither
1220
+ // the stored route set nor a stored decision can supply the capture plan.
1221
+ export function readCaptures({ record, packet, currentBuild, currentSource, buildOutput,
1222
+ campaignSlug, targetRepo, qcStandIns, rederivers } = {}) {
1223
+ const resolved = resolvePolishRules({ check: "visual.diff", qcStandIns, rederivers });
1224
+ if (resolved.status === "missing") return [];
1225
+ const rules = resolved.rules;
1226
+ let plan = null;
1227
+ try { if (packet && rules?.plan) plan = rules.plan({ packet, baseUrl: "http://127.0.0.1" }); } catch { /* fail closed below */ }
1228
+ const planned = Array.isArray(plan?.routes) ? plan.routes : [];
1229
+ const measuredAt = validTime(record?.measured_at) ? record.measured_at : null;
1230
+ // A changed build (§5.5 step 2) and a capture file not on this machine (step
1231
+ // 4.1, a fresh clone) are the row's own reason (§5.3 steps 2 and 9), as in
1232
+ // the sibling readers. Every other reader failure is evidence_not_reproducible
1233
+ // with the failure as its member. Either way every row of the record reads it.
1234
+ const failureRow = (subject, reason) => ["stale_binding", "capture_file_absent"].includes(reason)
1235
+ ? buildQcResult({ check: subject.check, leg: "polish", subject, result: "unexercised", reason_code: reason,
1236
+ state: { reason }, coverage: { observed: 1, expected: 1, limits: [reason] }, measured_at: measuredAt })
1237
+ : buildQcResult({ check: subject.check, leg: "polish", subject,
1238
+ result: "unexercised", reason_code: E, members: [{ key: "evidence", result: "unexercised", reason_code: reason }],
1239
+ state: { reason }, measured_at: measuredAt });
1240
+ const subjectOf = (check, item) => ({ check, page: item.route,
1241
+ ...(item.viewport == null ? {} : { viewport: item.viewport }),
1242
+ key: item.ref_id === "source_html" || item.basis === "source_html" ? "source_html" : `ref:${item.ref_id}` });
1243
+ const fallback = (reason, detail = null) => {
1244
+ const checks = qcChecksForRecord("captures");
1245
+ const subjects = planned.flatMap((route) => [
1246
+ { check: "visual.reference", page: route.requested_route, key: "source_html" },
1247
+ ...["desktop", "mobile"].map((viewport) => ({ check: "visual.diff", page: route.requested_route, viewport, key: "source_html" })),
1248
+ ]);
1249
+ // Read identity only, even when integrity/schema validation failed. These
1250
+ // names preserve accept disposition; they never authorize evidence or a route.
1251
+ const readableIdentity = (item) => isPlainObject(item) && isNonEmptyString(item.route)
1252
+ && (!plan || planned.some((route) => route.requested_route === item.route));
1253
+ for (const ref of Array.isArray(record?.references) ? record.references : []) {
1254
+ if (!readableIdentity(ref) || !isNonEmptyString(ref.ref_id)
1255
+ || !(ref.viewport == null || isNonEmptyString(ref.viewport))) continue;
1256
+ subjects.push(subjectOf("visual.reference", ref));
1257
+ if (ref.ref_id !== "source_html" && isNonEmptyString(ref.viewport)) subjects.push(subjectOf("visual.diff", ref));
1258
+ }
1259
+ for (const comparison of Array.isArray(record?.comparisons) ? record.comparisons : []) {
1260
+ if (!readableIdentity(comparison) || !isNonEmptyString(comparison.viewport)
1261
+ || !["source_html", "reference"].includes(comparison.basis)
1262
+ || (comparison.basis === "reference" && !isNonEmptyString(comparison.ref_id))) continue;
1263
+ subjects.push(subjectOf("visual.diff", comparison));
1264
+ subjects.push(subjectOf("visual.reference", { ...comparison,
1265
+ ref_id: comparison.basis === "source_html" ? "source_html" : comparison.ref_id,
1266
+ viewport: comparison.basis === "source_html" ? null : comparison.viewport }));
1267
+ }
1268
+ if (!subjects.length) for (const check of checks) subjects.push({ check, page: null, key: "captures" });
1269
+ return [...new Map(subjects.map((subject) => [qcResultId(subject),
1270
+ { ...failureRow(subject, reason), observation: { reader_detail: detail } }])).values()];
1271
+ };
1272
+ // Never ask the evaluator to traverse malformed evidence, even to report a
1273
+ // failure. Its input is a validated reference or one validated dependency set.
1274
+ const derive = (subset, context = {}) => {
1275
+ try { return rules.evaluate({ ...record, ...subset }, context); } catch { return []; }
1276
+ };
1277
+ const fail = (reason, detail) => fallback(reason, detail);
1278
+ if (!packet) return fail("packet_unavailable");
1279
+ if (!plan) return fail("packet_plan_invalid");
1280
+ if (resolved.status !== "loaded") return fail("rules_unavailable");
1281
+ const vocab = rules.vocabulary;
1282
+ const inVocab = (key, value) => Array.isArray(vocab[key]) && vocab[key].includes(value);
1283
+ const finite = (n) => typeof n === "number" && Number.isFinite(n);
1284
+ const nonnegative = (n) => finite(n) && n >= 0;
1285
+ const integer = (n) => Number.isSafeInteger(n) && n >= 0;
1286
+ const canonicalTime = (s) => validTime(s) && new Date(s).toISOString() === s;
1287
+ const nullable = (value, test) => value === null || test(value);
1288
+ const optional = (object, key, test) => !Object.hasOwn(object, key) || test(object[key]);
1289
+ const strings = (value) => Array.isArray(value) && value.every(isNonEmptyString);
1290
+ const fingerprint = (value) => typeof value === "string" && FINGERPRINT.test(value);
1291
+ const rect = (r) => isPlainObject(r) && ["x", "y", "width", "height"].every((k) => finite(r[k])) && r.width >= 0 && r.height >= 0;
1292
+ const png = (p, dir) => isPlainObject(p) && fingerprint(p.sha256)
1293
+ && p.path === `.campaign-runtime/polish/${dir}/${p.sha256.slice(7)}.png`
1294
+ && integer(p.bytes) && p.bytes > 0 && integer(p.width_px) && p.width_px > 0
1295
+ && integer(p.height_px) && p.height_px > 0 && p.width_px * p.height_px <= rules.limits.decode_pixels;
1296
+ if (!isPlainObject(record) || record.schema_version !== rules.schema_version
1297
+ || record.performed_by !== QC_PRODUCERS.polish || record.integrity !== polishRecordIntegrity(record)
1298
+ || record.treatment_version !== "visual-treatment/v1" || !canonicalTime(record.measured_at)
1299
+ || !isPlainObject(record.limits) || !sameJson(record.limits, rules.limits)
1300
+ || !isPlainObject(record.environment) || record.environment.device_scale_factor !== 1
1301
+ || !isPlainObject(record.subject) || !isNonEmptyString(packet.campaign?.public_route_slug)
1302
+ || record.subject.campaign_slug !== packet.campaign.public_route_slug || record.subject.campaign_slug !== campaignSlug
1303
+ || !fingerprint(record.subject.build_fingerprint)
1304
+ || !nullable(record.subject.source_package_material_fingerprint, fingerprint)
1305
+ || !sameJson(record.subject.viewports, rules.viewports) || !["all", "selected"].includes(record.subject.route_scope)
1306
+ || !strings(record.subject.routes) || new Set(record.subject.routes).size !== record.subject.routes.length
1307
+ || !isPlainObject(record.dependencies) || !["decoder", "diff"].every((key) => isPlainObject(record.dependencies[key])
1308
+ && inVocab("dependency_status", record.dependencies[key].status))
1309
+ || !isPlainObject(record.adapters) || !["build", "source"].every((side) => isPlainObject(record.adapters[side])
1310
+ && inVocab("startup", record.adapters[side].startup) && inVocab("close", record.adapters[side].close))
1311
+ || record.adapters.source.sealed !== true || !isPlainObject(record.source)
1312
+ || !inVocab("source_status", record.source.status) || record.source.server !== "loopback_read_only") return fail("record_shape_invalid");
1313
+ const routes = record.subject.routes;
1314
+ const cells = Array.isArray(record.cells) ? record.cells : [];
1315
+ const references = Array.isArray(record.references) ? record.references : [];
1316
+ const comparisons = Array.isArray(record.comparisons) ? record.comparisons : [];
1317
+ const cellIdentity = (cell) => isPlainObject(cell) && isNonEmptyString(cell.route) && rules.viewports.includes(cell.viewport) && inVocab("side", cell.side);
1318
+ const refIdentity = (ref) => isPlainObject(ref) && isNonEmptyString(ref.route) && isNonEmptyString(ref.ref_id)
1319
+ && (ref.viewport === null || isNonEmptyString(ref.viewport));
1320
+ const comparisonIdentity = (c) => isPlainObject(c) && isNonEmptyString(c.route) && isNonEmptyString(c.viewport)
1321
+ && ["source_html", "reference"].includes(c.basis) && (c.basis === "source_html" ? c.ref_id === null : isNonEmptyString(c.ref_id));
1322
+ const validateCell = (cell) => {
1323
+ if (!isPlainObject(cell)) return "cell_shape_invalid";
1324
+ if (!isNonEmptyString(cell.page_id) || !isPlainObject(cell.networkidle) || !isPlainObject(cell.readiness)
1325
+ || !isPlainObject(cell.readiness.fonts) || !isPlainObject(cell.readiness.await) || !isPlainObject(cell.sections)
1326
+ || !isPlainObject(cell.actual) || !isPlainObject(cell.stabilized)) return "cell_shape_invalid";
1327
+ if (!optional(cell.sections, "duplicated_keys", strings) || !optional(cell.sections, "capped", (v) => typeof v === "boolean")) return "cell_shape_invalid";
1328
+ if (!routes.includes(cell.route) || !inVocab("side", cell.side)
1329
+ || !rules.viewports.includes(cell.viewport) || !inVocab("cell_status", cell.status)
1330
+ || !inVocab("networkidle_status", cell.networkidle?.status)
1331
+ || !inVocab("fonts_status", cell.readiness?.fonts?.status) || !inVocab("await_status", cell.readiness?.await?.status)
1332
+ || !inVocab("section_status", cell.sections?.status) || !Array.isArray(cell.sections?.bands)
1333
+ || cell.sections.bands.length > rules.limits.bands_per_page
1334
+ || cell.sections.bands.some((b) => !isPlainObject(b) || !isNonEmptyString(b.key) || ![b.top, b.left, b.width, b.height].every(finite))) return "cell_shape_invalid";
1335
+
1336
+ if (cell.state !== "default_load" || cell.device_scale_factor !== 1) return "treatment_invalid";
1337
+ const fixed = cell.viewport === "desktop" ? [1440, 1200] : [390, 844];
1338
+ if (cell.width !== fixed[0] || cell.height !== fixed[1]) return "png_dimensions_mismatch";
1339
+ const treatment = cell.stabilized.treatment;
1340
+ if (!isPlainObject(treatment)) return "treatment_invalid";
1341
+ if (treatment?.mask_selectors !== "visual-masks/v1" || treatment.animations !== "disabled" || treatment.caret !== "hide"
1342
+ || treatment.mask_color !== "#ff00ff" || typeof treatment.mask_cap_reached !== "boolean" || !Array.isArray(treatment.masks)
1343
+ || 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";
1344
+ for (const shot of [cell.actual, cell.stabilized]) {
1345
+ if (!inVocab("shot_status", shot?.status)) return "shot_shape_invalid";
1346
+ if (shot.status !== "captured") {
1347
+ if (!nullable(shot.png, (p) => png(p, "captures")) || !nullable(shot.crop, rect)
1348
+ || !nullable(shot.full_height_css, nonnegative) || typeof shot.truncated !== "boolean") return "shot_shape_invalid";
1349
+ continue;
1350
+ }
1351
+ if (!finite(shot.full_height_css) || shot.full_height_css <= 0 || !sameJson(shot.crop,
1352
+ { x: 0, y: 0, width: cell.width, height: Math.min(shot.full_height_css, 12000) })
1353
+ || shot.truncated !== (shot.full_height_css > 12000)) return "crop_not_full_page";
1354
+ if (!png(shot.png, "captures") || shot.png.width_px !== shot.crop.width || shot.png.height_px !== shot.crop.height) return "png_dimensions_mismatch";
1355
+ }
1356
+ return null;
1357
+ };
1358
+ 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"];
1359
+ const inputReasons = ["source_root_unavailable", "source_html_missing", "source_package_unavailable", "source_package_stale"];
1360
+ 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"];
1361
+ const validateReference = (ref) => {
1362
+ if (!isPlainObject(ref) || !isNonEmptyString(ref.page_id) || !isPlainObject(ref.verification)
1363
+ || !isPlainObject(ref.provenance) || !isPlainObject(ref.identity)
1364
+ || !nullable(ref.identity.sha256, fingerprint)
1365
+ || !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))
1366
+ || !optional(ref, "css_width", (v) => nullable(v, nonnegative)) || !optional(ref, "dpr", (v) => nullable(v, nonnegative))
1367
+ || !optional(ref, "reference_cap_reached", (v) => typeof v === "boolean") || !routes.includes(ref.route) || !isNonEmptyString(ref.ref_id)
1368
+ || (ref.ref_id === "source_html" && ref.viewport !== null)
1369
+ || !inVocab("verification_level", ref.verification?.level) || !inVocab("provenance_class", ref.provenance?.class)
1370
+ || !["html_file", "source_html", "source_screenshot"].includes(ref.kind)
1371
+ || !Array.isArray(ref.provenance.reasons) || ref.provenance.reasons.some((r) => !provenanceReasons.includes(r))
1372
+ || (ref.input_reason != null && !inputReasons.includes(ref.input_reason))
1373
+ || (ref.verification.reason != null && !inVocab("verification_reason", ref.verification.reason))) return "reference_shape_invalid";
1374
+ return null;
1375
+ };
1376
+ const validateComparison = (comparison, build, basis, reference) => {
1377
+ if (!isPlainObject(comparison) || !isNonEmptyString(comparison.page_id)
1378
+ || !Array.isArray(comparison.members) || comparison.members.some((m) => !isPlainObject(m) || !isNonEmptyString(m.key)
1379
+ || !QC_RESULTS.includes(m.result) || (m.result !== "pass" && !isNonEmptyString(m.reason_code ?? m.reason)))
1380
+ || !isPlainObject(comparison.diff_options) || !routes.includes(comparison.route) || !["source_html", "reference"].includes(comparison.basis)
1381
+ || (comparison.basis === "source_html" && !rules.viewports.includes(comparison.viewport))
1382
+ || !inVocab("comparison_status", comparison.status)
1383
+ || (comparison.reason != null && !comparisonReasons.includes(comparison.reason))
1384
+ || !sameJson(comparison.diff_options, { threshold: 0.1, include_aa: false, alpha: 0.1 })
1385
+ || !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))
1386
+ || (comparison.page === null && (comparison.status === "compared"
1387
+ || (comparison.status === "unsupported" && !["width_unknown", "dpr_unknown", "width_mismatch", "dpr_mismatch", "mask_cap_reached"].includes(comparison.reason))))
1388
+ || !Array.isArray(comparison.bands) || comparison.bands.some((b) => !isPlainObject(b)) || !Array.isArray(comparison.artifacts)) return "comparison_shape_invalid";
1389
+ if (comparison.build_shot !== (comparison.basis === "reference" ? "actual" : "stabilized")
1390
+ || comparison.basis_shot !== (comparison.basis === "reference" ? "image" : "stabilized")) return "comparison_shape_invalid";
1391
+
1392
+ if (comparison.basis === "source_html" && (!build || !basis)) return "comparison_shape_invalid";
1393
+ for (const part of [...(comparison.page === null ? [] : [comparison.page]), ...comparison.bands]) {
1394
+ if (!isPlainObject(part) || (part !== comparison.page && !isNonEmptyString(part.key))) return "metrics_invalid";
1395
+ if (Object.hasOwn(part, "worst_tiles") && !Array.isArray(part.worst_tiles)) return "metrics_invalid";
1396
+ if (!["compared", "unsupported"].includes(part.status)) return "metrics_invalid";
1397
+ if (!optional(part, "height_delta_px", finite)) return "metrics_invalid";
1398
+ if (part.status !== "compared") { if (!inVocab("part_reason", part.reason)) return "metrics_invalid"; continue; }
1399
+ if (!nonnegative(part.mismatch_ratio) || part.mismatch_ratio > 1 || !integer(part.mismatch_pixels)
1400
+ || !integer(part.compared_pixels) || part.mismatch_pixels > part.compared_pixels
1401
+ || !integer(part.masked_pixels) || !isPlainObject(part.canvas) || !integer(part.canvas?.width) || !integer(part.canvas?.height)
1402
+ || part.canvas.width <= 0 || part.canvas.height <= 0
1403
+ || part.compared_pixels + part.masked_pixels > part.canvas.width * part.canvas.height
1404
+ || part.canvas.width * part.canvas.height > rules.limits.decode_pixels) return "metrics_invalid";
1405
+ for (const tile of part.worst_tiles ?? []) if (!rect(tile) || tile.x < 0 || tile.y < 0
1406
+ || tile.x + tile.width > part.canvas.width || tile.y + tile.height > part.canvas.height
1407
+ || !nonnegative(tile.mismatch_ratio) || tile.mismatch_ratio > 1) return "metrics_invalid";
1408
+ }
1409
+ const pixelSize = (cell, key) => {
1410
+ const boxes = (Array.isArray(cell?.sections?.bands) ? cell.sections.bands : []).filter((b) => b.key === key);
1411
+ if (boxes.length !== 1) return null;
1412
+ const [b] = boxes;
1413
+ if (b.top < 0 || b.left < 0 || b.top + b.height > cell.stabilized?.png?.height_px || b.left + b.width > cell.width) return null;
1414
+ return [Math.ceil(b.left + b.width) - Math.floor(b.left), Math.ceil(b.top + b.height) - Math.floor(b.top)];
1415
+ };
1416
+ for (const part of comparison.bands.filter((b) => b.status === "compared")) {
1417
+ const a = pixelSize(basis, part.key), b = pixelSize(build, part.key);
1418
+ 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";
1419
+ }
1420
+ for (const artifact of comparison.artifacts) {
1421
+ if (!isPlainObject(artifact) || !isNonEmptyString(artifact.band_key)) return "artifact_geometry_invalid";
1422
+ const part = artifact.band_key === "page" ? comparison.page : comparison.bands.find((b) => b.key === artifact.band_key);
1423
+ if (!part || part.status !== "compared") return "artifact_geometry_invalid";
1424
+ const source = reference;
1425
+ const basisSize = artifact.band_key === "page" ? (comparison.basis === "reference" ? source?.verification?.png : basis?.stabilized?.png) : null;
1426
+ const sizes = { basis_crop: basisSize ? [basisSize.width_px, basisSize.height_px] : pixelSize(basis, part.key),
1427
+ build_crop: artifact.band_key === "page" ? [build?.[comparison.build_shot]?.png?.width_px, build?.[comparison.build_shot]?.png?.height_px] : pixelSize(build, part.key),
1428
+ heatmap: [part.canvas?.width, part.canvas?.height] };
1429
+ for (const name of Object.keys(sizes)) if (!png(artifact[name], "diff")
1430
+ || !sameJson([artifact[name].width_px, artifact[name].height_px], sizes[name])) return "artifact_geometry_invalid";
1431
+ }
1432
+ return null;
1433
+ };
1434
+ const cellErrors = new Map(cells.filter(cellIdentity).map((c) => [c, validateCell(c)]));
1435
+ const refErrors = new Map(references.filter(refIdentity).map((r) => [r, validateReference(r)]));
1436
+ const cellGridInvalid = !Array.isArray(record.cells) || cells.length !== routes.length * 4
1437
+ || cells.some((c) => !cellIdentity(c))
1438
+ || new Set(cells.filter(cellIdentity).map((c) => JSON.stringify([c.route, c.viewport, c.side]))).size !== cells.length;
1439
+ const unknownReferences = !Array.isArray(record.references) || references.some((r) => !refIdentity(r));
1440
+ const unknownComparisons = !Array.isArray(record.comparisons) || comparisons.some((c) => !comparisonIdentity(c));
1441
+ if (cellGridInvalid) return fail("cell_grid_invalid");
1442
+ const cellFailure = [...cellErrors.values()].find(Boolean);
1443
+ if (cellFailure) return fail(cellFailure);
1444
+ if (unknownReferences) return fail("reference_shape_invalid");
1445
+ if (unknownComparisons) return fail("comparison_shape_invalid");
1446
+ if ([...refErrors.values()].some(Boolean)) return fail("reference_shape_invalid");
1447
+ const referenceKey = (ref) => JSON.stringify([ref.route, ref.viewport, ref.ref_id]);
1448
+ if (new Set(references.map(referenceKey)).size !== references.length) return fail("reference_shape_invalid");
1449
+ const stale = record.subject.build_fingerprint !== currentBuild
1450
+ || record.subject.source_package_material_fingerprint !== (currentSource || null) || outputDrifted(buildOutput);
1451
+ const outsidePlan = routes.some((route) => !planned.some((p) => p.requested_route === route))
1452
+ || cells.some((c) => !planned.some((p) => p.requested_route === c.route && p.page_id === c.page_id));
1453
+ const context = stale ? { staleBinding: true } : {};
1454
+ const rows = [];
1455
+ const referenceGroups = new Map(), cappedReferences = new Set();
1456
+ for (const ref of references.filter(refIdentity)) if (ref.ref_id !== "source_html") {
1457
+ const group = JSON.stringify([ref.page_id, ref.route, ref.viewport]);
1458
+ const items = referenceGroups.get(group) || [];
1459
+ if (items.length >= rules.limits.references_per_cell) cappedReferences.add(referenceKey(ref));
1460
+ items.push(ref); referenceGroups.set(group, items);
1461
+ }
1462
+ const safeReferences = references.filter((r) => refIdentity(r) && !refErrors.get(r) && !cappedReferences.has(referenceKey(r)))
1463
+ .map((ref) => referenceGroups.get(JSON.stringify([ref.page_id, ref.route, ref.viewport]))?.length > rules.limits.references_per_cell
1464
+ ? { ...ref, reference_cap_reached: true } : ref);
1465
+ const referenceRows = derive({ cells: [], references: safeReferences, comparisons: [] }, context);
1466
+ for (const ref of references.filter(refIdentity)) {
1467
+ if (cappedReferences.has(referenceKey(ref))) continue;
1468
+ const subject = subjectOf("visual.reference", ref);
1469
+ const duplicates = references.filter(refIdentity).filter((r) => r.route === ref.route && r.viewport === ref.viewport && r.ref_id === ref.ref_id).length > 1;
1470
+ const failure = refErrors.get(ref) || (duplicates ? "reference_shape_invalid" : null);
1471
+ const row = referenceRows.find((r) => r.id === qcResultId(subject));
1472
+ if (failure || !row) return fail(failure || "reference_shape_invalid");
1473
+ rows.push({ ...row, state_fingerprint: qcStateFingerprint({ subject: row.subject,
1474
+ state: { kind: ref.kind, sha256: ref.identity.sha256, provenance_reasons: [...ref.provenance.reasons].sort() } }) });
1475
+ }
1476
+ const checked = new Map();
1477
+ const checkFile = (ref) => {
1478
+ // Read each file once, then compare every descriptor against those bytes.
1479
+ if (!checked.has(ref.path)) {
1480
+ let actual;
1481
+ try {
1482
+ const bytes = readRegularFileSync(resolve(targetRepo, ref.path), { root: targetRepo, noFollow: true, maxBytes: rules.limits.reference_bytes });
1483
+ actual = { hash: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
1484
+ bytes: bytes.length, structure: readPngStructure(bytes) };
1485
+ } catch (error) { actual = error.code === "ENOENT" ? { absent: true } : { failure: captureReadFailure(error.code) }; }
1486
+ checked.set(ref.path, actual);
1487
+ }
1488
+ const actual = checked.get(ref.path);
1489
+ if (actual.absent || actual.failure) return actual;
1490
+ return actual.hash !== ref.sha256 ? { failure: "png_hash_mismatch" }
1491
+ : !actual.structure.ok ? { failure: "png_structure_invalid", detail: actual.structure.reason }
1492
+ : actual.bytes !== ref.bytes || actual.structure.ihdr.width_px !== ref.width_px || actual.structure.ihdr.height_px !== ref.height_px
1493
+ ? { failure: "png_dimensions_mismatch" } : {};
1494
+ };
1495
+ const descriptors = cells.flatMap((c) => [c?.actual, c?.stabilized].filter((s) => s?.status === "captured").map((s) => s.png));
1496
+ for (const comparison of comparisons.filter(comparisonIdentity)) {
1497
+ const subject = subjectOf("visual.diff", comparison);
1498
+ const matching = (side) => cells.filter(cellIdentity).filter((c) => c.route === comparison.route && c.viewport === comparison.viewport && c.side === side);
1499
+ const builds = matching("build"), bases = matching("source_html"), build = builds[0], basis = bases[0];
1500
+ const refs = references.filter(refIdentity).filter((ref) => ref.route === comparison.route && (comparison.basis === "source_html"
1501
+ ? ref.ref_id === "source_html" : ref.ref_id === comparison.ref_id && ref.viewport === comparison.viewport));
1502
+ const needed = comparison.basis === "source_html" ? [build, basis] : [build].filter(Boolean);
1503
+ 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;
1504
+ const failure = (cellGridInvalid || builds.length > 1 || bases.length > 1 || needed.some((c) => !c) ? "cell_grid_invalid" : null)
1505
+ || needed.map((c) => cellErrors.get(c)).find(Boolean)
1506
+ || (refs.length !== 1 ? "reference_shape_invalid" : refErrors.get(refs[0]))
1507
+ || (duplicates ? "comparison_shape_invalid" : null)
1508
+ || validateComparison(comparison, build, comparison.basis === "reference" ? null : basis, refs[0]);
1509
+ if (failure) return fail(failure);
1510
+ descriptors.push(...comparison.artifacts.flatMap((a) => [a.basis_crop, a.build_crop, a.heatmap]));
1511
+ if (comparison.basis === "reference" && cappedReferences.has(referenceKey(comparison))) continue;
1512
+ const subset = { cells: needed, references: safeReferences, comparisons: [comparison] };
1513
+ const row = derive(subset, context).find((r) => r.check === "visual.diff");
1514
+ if (!row) return fail("comparison_shape_invalid");
1515
+ rows.push(row);
1516
+ }
1517
+ // Every reader check invalidates the record, preserving all readable keys.
1518
+ for (const comparison of comparisons.filter(comparisonIdentity)) {
1519
+ if (comparison.basis === "reference" && cappedReferences.has(referenceKey(comparison))) continue;
1520
+ const subject = subjectOf("visual.reference", { ...comparison, ref_id: comparison.basis === "source_html" ? "source_html" : comparison.ref_id,
1521
+ viewport: comparison.basis === "source_html" ? null : comparison.viewport });
1522
+ if (!rows.some((r) => r.id === qcResultId(subject))) return fail("reference_shape_invalid");
1523
+ }
1524
+ for (const ref of references.filter(refIdentity)) if (ref.ref_id !== "source_html" && !cappedReferences.has(referenceKey(ref))) {
1525
+ const subject = subjectOf("visual.diff", { ...ref, basis: "reference" });
1526
+ if (!rows.some((r) => r.id === qcResultId(subject))) return fail("comparison_shape_invalid");
1527
+ }
1528
+ for (const route of planned) {
1529
+ if (routes.includes(route.requested_route) && !rows.some((r) => r.check === "visual.reference" && r.subject.page === route.requested_route && r.subject.key === "source_html")) {
1530
+ return fail("comparison_grid_invalid");
1531
+ }
1532
+ for (const viewport of rules.viewports) {
1533
+ if (rows.some((r) => r.check === "visual.diff" && r.subject.page === route.requested_route && r.subject.viewport === viewport && r.subject.key === "source_html")) continue;
1534
+ const subject = { check: "visual.diff", page: route.requested_route, viewport, key: "source_html" };
1535
+ if (routes.includes(route.requested_route)) return fail("comparison_grid_invalid");
1536
+ rows.push(buildQcResult({ check: subject.check, leg: "polish", subject, result: "unexercised", reason_code: "page_not_captured", state: { subject }, measured_at: measuredAt }));
1537
+ }
1538
+ }
1539
+ if (stale) return fail("stale_binding");
1540
+ if (outsidePlan) return fail("route_outside_plan");
1541
+ // Structural checks precede binding/plan and files. No failure can leave an
1542
+ // independently acceptable reference or a sibling comparison behind.
1543
+ const descriptorsByPath = new Map();
1544
+ for (const ref of descriptors) {
1545
+ if (descriptorsByPath.has(ref.path) && !sameJson(descriptorsByPath.get(ref.path), ref)) return fail("png_dimensions_mismatch");
1546
+ descriptorsByPath.set(ref.path, ref);
1547
+ }
1548
+ // A file that is present but wrong outranks one that is absent (§5.3 step 1
1549
+ // before step 9), whichever is read first.
1550
+ const files = [...descriptorsByPath.values()].map(checkFile);
1551
+ const wrong = files.find((file) => file.failure);
1552
+ if (wrong) return fail(wrong.failure, wrong.detail);
1553
+ if (files.some((file) => file.absent)) return fail("capture_file_absent");
1554
+ const unique = new Map();
1555
+ for (const row of rows) if (!unique.has(row.id) || row.reason_code === E) unique.set(row.id, row);
1556
+ return unique.size ? [...unique.values()] : fail("record_shape_invalid");
1557
+ }
1558
+
1559
+ // Element parity rows are re-derived by the rules module's reader from the
1560
+ // probe files, C's captures record, the packet snapshot, the Design Source
1561
+ // Package and the current build. A campaign with no mapped source page has no
1562
+ // parity.element row (applicability). A reader that cannot run reads every
1563
+ // row of the record evidence_not_reproducible, never pass.
1564
+ export const parityApplies = (packet) => Array.isArray(packet?.source_html?.pages) && packet.source_html.pages.some((page) => isNonEmptyString(page?.path));
1565
+
1566
+ export function readElementParity({ record, captures = null, packet = null, packetPath = null, currentBuild = null, currentSource = null,
1567
+ buildOutput = null, campaignSlug = null, targetRepo = null, qcAccepts = [], qcStandIns = null, rederivers = null } = {}) {
1568
+ if (packet && !parityApplies(packet)) return [];
1569
+ const resolved = resolvePolishRules({ check: "parity.element", qcStandIns, rederivers });
1570
+ const context = { record, captures, packet, packetPath, currentBuild, currentSource, buildOutput, campaignSlug, targetRepo, qcAccepts };
1571
+ if (resolved.status === "loaded" && typeof resolved.rules.read === "function") {
1572
+ try {
1573
+ const rows = resolved.rules.read(context, resolved.rules);
1574
+ if (Array.isArray(rows)) return rows;
1575
+ } catch { /* fail closed below */ }
1576
+ }
1577
+ const measuredAt = validTime(record?.measured_at) ? record.measured_at : null;
1578
+ const cells = (Array.isArray(record?.cells) ? record.cells : []).filter((cell) => isPlainObject(cell) && isNonEmptyString(cell.page_id) && ["desktop", "mobile"].includes(cell.viewport));
1579
+ const mapped = (Array.isArray(packet?.source_html?.pages) ? packet.source_html.pages : []).filter((page) => isNonEmptyString(page?.path) && isNonEmptyString(page.page_id));
1580
+ const subjects = [...cells.map((cell) => ({ check: "parity.element", page: cell.page_id, viewport: cell.viewport, key: "comparison" })),
1581
+ ...mapped.flatMap((page) => ["desktop", "mobile"].map((viewport) => ({ check: "parity.element", page: page.page_id, viewport, key: "comparison" }))),
1582
+ ...(Array.isArray(qcAccepts) ? qcAccepts : []).filter((accept) => accept?.check === "parity.element" && accept.leg === "polish"
1583
+ && accept.subject?.check === "parity.element" && isNonEmptyString(accept.subject.page) && ["desktop", "mobile"].includes(accept.subject.viewport)
1584
+ && /^(copy|type|color|geom):el-[a-f0-9]{12}$/.test(accept.subject.key)).map((accept) => accept.subject)];
1585
+ if (!subjects.length) subjects.push({ check: "parity.element", page: null, key: "element_parity" });
1586
+ return [...new Map(subjects.map((subject) => [qcResultId(subject), buildQcResult({ check: "parity.element", leg: "polish", subject,
1587
+ result: "unexercised", reason_code: E, members: [{ key: "evidence", result: "unexercised", reason_code: "rules_unavailable" }],
1588
+ state: { reason: "rules_unavailable" }, measured_at: measuredAt })])).values()];
1589
+ }
1590
+
1213
1591
  // ---------------------------------------------------------------------------
1214
1592
  // Every current result, per leg, and the silence rule
1215
1593
 
@@ -1222,8 +1600,16 @@ const POLISH_CHECKS = Object.freeze(qcChecksForLeg("polish"));
1222
1600
  // on its own.
1223
1601
  const POLISH_RECORDS = Object.freeze({
1224
1602
  media_weight: Object.freeze({ checks: Object.freeze(qcChecksForRecord("media_weight")), read: readMediaWeight }),
1603
+ captures: Object.freeze({ checks: Object.freeze(qcChecksForRecord("captures")), read: readCaptures }),
1225
1604
  readability: Object.freeze({ checks: Object.freeze(qcChecksForRecord("readability")), read: readReadability }),
1605
+ element_parity: Object.freeze({ checks: Object.freeze(qcChecksForRecord("element_parity")), read: readElementParity }),
1226
1606
  });
1607
+ // Records whose checks apply only to some campaigns, judged from the packet
1608
+ // snapshot. With no packet applicability is unknown: existing evidence still
1609
+ // applies, while an absent record keeps the no-packet behaviour.
1610
+ const POLISH_RECORD_APPLIES = Object.freeze({ element_parity: parityApplies });
1611
+ const polishRecordApplies = (name, packet, visual) => !Object.hasOwn(POLISH_RECORD_APPLIES, name)
1612
+ || (packet != null ? POLISH_RECORD_APPLIES[name](packet) : visual?.[name] != null);
1227
1613
  const STORE_POLICY_FIELDS = Object.freeze(Object.keys(STORE_PAGE_MATCHERS));
1228
1614
 
1229
1615
  // Applicability is decided without the leg running: the tracking checks
@@ -1255,7 +1641,7 @@ const silence = (leg, check, reasonCode) => ({ check, leg, result: "unexercised"
1255
1641
  // record predates the check, its module is missing or not yet loaded, or the
1256
1642
  // loaded rules gave no row), except that a module that failed to load reads
1257
1643
  // evidence_not_reproducible when the record holds the leg's QC evidence.
1258
- export function handoffCoverage({ report, spec, results = [], qcStandIns = null, rederivers = null }) {
1644
+ export function handoffCoverage({ report, spec, results = [], qcStandIns = null, rederivers = null, packet = null }) {
1259
1645
  const entries = [];
1260
1646
  const hasRow = (leg, check) => results.some((row) => row.leg === leg && row.check === check);
1261
1647
  const silentReason = (failed) => (failed ? E : QC_REASON.NOT_CAPTURED);
@@ -1273,11 +1659,14 @@ export function handoffCoverage({ report, spec, results = [], qcStandIns = null,
1273
1659
  }
1274
1660
 
1275
1661
  const polishStage = report?.stages?.polish;
1662
+ const visual = polishStage?.evidence?.visual_review;
1276
1663
  if (!stageRecorded(polishStage)) {
1277
- for (const check of POLISH_CHECKS) entries.push(silence("polish", check, QC_REASON.LEG_NOT_RUN));
1664
+ for (const check of POLISH_CHECKS) {
1665
+ if (polishRecordApplies(QC_CHECK_REGISTRY[check].record, packet, visual)) entries.push(silence("polish", check, QC_REASON.LEG_NOT_RUN));
1666
+ }
1278
1667
  } else {
1279
- const visual = polishStage.evidence?.visual_review;
1280
1668
  for (const [name, { checks }] of Object.entries(POLISH_RECORDS)) {
1669
+ if (!polishRecordApplies(name, packet, visual)) continue;
1281
1670
  const captured = name === "media_weight" ? isPlainObject(visual?.page_load) && visual.media_weight != null : visual?.[name] != null;
1282
1671
  for (const check of checks) {
1283
1672
  if (hasRow("polish", check)) continue;
@@ -1291,17 +1680,10 @@ export function handoffCoverage({ report, spec, results = [], qcStandIns = null,
1291
1680
 
1292
1681
  // The campaign slug the Polish records bind to: the packet's, read from
1293
1682
  // 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;
1683
+ // requires to equal the packet's). A readable packet without one gives null,
1684
+ // as on main. Null when neither names one.
1685
+ export function packetCampaignSlug({ packet, packetRead = false, report }) {
1686
+ const slug = packetRead ? packet?.campaign?.public_route_slug : report?.identity?.public_route_slug;
1305
1687
  return isNonEmptyString(slug) ? slug : null;
1306
1688
  }
1307
1689
 
@@ -1313,13 +1695,19 @@ export function readCurrentQcResults({ report, doctor, spec = null, targetRepo,
1313
1695
  const doctorResults = (Array.isArray(doctor?.derived?.qc_results) ? doctor.derived.qc_results : []).filter((row) => isPlainObject(row) && row.leg === "doctor");
1314
1696
  const visual = report?.stages?.polish?.evidence?.visual_review;
1315
1697
  const recorded = isPlainObject(visual) ? Object.entries(POLISH_RECORDS).filter(([name]) => visual[name] != null) : [];
1698
+ let packet = null, packetRead = false;
1699
+ try { if (packetPath) { packet = JSON.parse(readRegularFileSync(packetPath, { maxBytes: 16 * 1024 * 1024 }).toString("utf8")); packetRead = true; } } catch { /* unavailable */ }
1316
1700
  const polishContext = recorded.length ? {
1317
1701
  pageLoad: visual.page_load,
1702
+ captures: visual.captures ?? null,
1703
+ packet,
1704
+ packetPath,
1318
1705
  currentBuild,
1319
1706
  currentSource: currentSourcePackageMaterialFingerprint(report),
1320
1707
  buildOutput: doctor?.derived?.build_output_fingerprint ?? null,
1321
- campaignSlug: packetCampaignSlug({ packetPath, report }),
1708
+ campaignSlug: packetCampaignSlug({ packet, packetRead, report }),
1322
1709
  targetRepo,
1710
+ qcAccepts: report?.qc_accepts,
1323
1711
  qcStandIns,
1324
1712
  rederivers,
1325
1713
  } : null;
@@ -1337,5 +1725,5 @@ export function readCurrentQcResults({ report, doctor, spec = null, targetRepo,
1337
1725
  })
1338
1726
  : [];
1339
1727
  const results = [...doctorResults, ...polishResults, ...qaResults];
1340
- return { results, coverage: handoffCoverage({ report, spec, results, qcStandIns, rederivers }) };
1728
+ return { results, coverage: handoffCoverage({ report, spec, results, qcStandIns, rederivers, packet }) };
1341
1729
  }