@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
@@ -0,0 +1,613 @@
1
+ import { mkdirSync, writeFileSync, realpathSync, lstatSync, statSync } from "node:fs";
2
+ import { join, resolve, relative, isAbsolute, sep, dirname } from "node:path";
3
+ import { aggregateQcResults, buildQcResult } from "./qc-results.mjs";
4
+ import { readPngStructure, sha256Hex, boundedDimensions, VISUAL_MAX_DECODE_PIXELS } from "./png-header.mjs";
5
+ import { CAPTURES_SCHEMA, CAPTURES_PRODUCER, capturesIntegrity } from "./polish-captures-record.mjs";
6
+ import { pairCompatibility, pageCompatibility, compareImages, compareBands, readRgba, releaseComparison, DIFF_OPTIONS } from "./visual-diff.mjs";
7
+ import { loadVisualDependencies, encodeHeatmap } from "./visual-diff.mjs";
8
+ import { verifyReference, classifyProvenance } from "./visual-reference.mjs";
9
+ import { computeDesignSourcePackageMaterialFingerprint } from "./design-source-package.mjs";
10
+ import { currentSourcePackageMaterialFingerprint } from "./polish-gate.mjs";
11
+ import { startSourceHtmlServer, readRegularFileSync, isRegularFileSync } from "./source-html-server.mjs";
12
+ import { boundedPolishDeadline, runWithPolishProducerDeadline, POLISH_PRODUCER_TIMEOUT_ERROR_CODE,
13
+ POLISH_CAPTURE_CELL_DEADLINE_MS, POLISH_CAPTURE_STARTUP_DEADLINE_MS, POLISH_CAPTURE_CLOSE_DEADLINE_MS,
14
+ VISUAL_CAPTURE_RUN_MS, VISUAL_DIFF_RUN_MS, VISUAL_ADDED_RUN_MS } from "./polish-deadline.mjs";
15
+
16
+ const object = (value) => value !== null && typeof value === "object"
17
+ && [Object.prototype, null].includes(Object.getPrototypeOf(value));
18
+ const own = (value, key) => object(value) && Object.hasOwn(value, key) ? value[key] : undefined;
19
+
20
+ export { CAPTURES_SCHEMA, CAPTURES_PRODUCER, capturesIntegrity };
21
+ export const VISUAL_STATE = "default_load";
22
+ export const VISUAL_MASKS = "visual-masks/v1";
23
+ export const VISUAL_SECTION_KEYS = "visual-sections/v1";
24
+ export const VISUAL_TREATMENT = "visual-treatment/v1";
25
+ export { VISUAL_MAX_DECODE_PIXELS };
26
+ export const VISUAL_LIMITS = Object.freeze({ readiness_fonts_ms: 3000, readiness_await_ms: 5000, shot_ms: 10000,
27
+ capture_ms_per_run: 240000, diff_ms_per_run: 60000, added_ms_per_run: 300000,
28
+ capture_height_px: 12000, decode_pixels: VISUAL_MAX_DECODE_PIXELS, bands_per_page: 40,
29
+ references_per_cell: 4, reference_bytes: 67108864, tile_px: 32, worst_tiles_per_band: 3, artifact_bands: 3 });
30
+ export const VISUAL_VIEWPORTS = Object.freeze([
31
+ Object.freeze({ key: "desktop", width: 1440, height: 1200 }), Object.freeze({ key: "mobile", width: 390, height: 844 }),
32
+ ]);
33
+ export const VISUAL_VOCABULARY = Object.freeze({
34
+ side: ["build", "source_html"], state: [VISUAL_STATE],
35
+ cell_status: ["rendered", "navigation_failed", "http_error", "probe_failed", "document_changed", "run_budget_exhausted", "producer_timeout", "probe_timeout", "browser_unavailable", "source_html_missing", "source_root_unavailable"],
36
+ shot_status: ["captured", "timeout", "failed", "skipped"], dependency_status: ["available", "missing", "failed"],
37
+ verification_level: ["decoded", "header_checked", "unverifiable"], provenance_class: ["declared_source", "self_referential", "unknown"],
38
+ comparison_status: ["compared", "unsupported", "unavailable", "not_run"],
39
+ section_status: ["measured", "no_section_keys", "probe_failed"],
40
+ fonts_status: ["settled", "timeout"], await_status: ["settled", "not_present", "timeout"],
41
+ networkidle_status: ["settled", "timeout", "invalid"],
42
+ verification_reason: ["file_missing", "path_outside_source_root", "remote_not_fetched", "too_large", "not_png", "ihdr_invalid", "truncated", "chunk_crc_mismatch", "image_too_large", "decode_failed", "verify_budget_exhausted", "recorded_hash_mismatch", "recorded_dimensions_mismatch"],
43
+ startup: ["ok", "failed", "producer_timeout"], close: ["ok", "failed", "producer_timeout", "not_started"],
44
+ source_status: ["available", "missing"], mask_group: ["timer", "price"],
45
+ part_reason: ["capture_truncated", "section_mapping_missing", "section_mapping_duplicate", "section_not_rendered", "beyond_capture_height", "band_cap_reached", "source_section_offsets_unrecorded", "no_section_keys"],
46
+ });
47
+
48
+ export function planVisualCapture(plan, viewports = plan?.viewports) {
49
+ if (!plan) return null;
50
+ if (!Array.isArray(plan.routes) || !Array.isArray(viewports)) throw new TypeError("A computed page_load plan and viewports are required");
51
+ if (plan.routes.some((route) => !object(route)) || viewports.some((v) => !object(v)
52
+ || !VISUAL_VIEWPORTS.some((fixed) => fixed.key === own(v, "key") && fixed.width === v.width && fixed.height === v.height))) return null;
53
+ return { route_scope: plan.route_scope, routes: structuredClone(plan.routes), viewports: structuredClone(viewports),
54
+ cells: plan.routes.flatMap((route) => viewports.flatMap((viewport) => ["build", "source_html"].map((side) => ({
55
+ side, page_id: route.page_id, route: route.requested_route ?? route.route ?? route,
56
+ viewport: viewport.key, width: viewport.width, height: viewport.height, device_scale_factor: 1, state: VISUAL_STATE,
57
+ })))) };
58
+ }
59
+
60
+ // Read-only isolated-world section probe. Keys are sanitized on the host.
61
+ function visualSections() {
62
+ const primary = document.querySelectorAll("[data-section]").length > 0;
63
+ const selector = primary ? "[data-section]" : "section[id],header[id],footer[id]";
64
+ const elements = [...document.querySelectorAll(selector)].filter((el) => !el.parentElement?.closest(selector));
65
+ const bands = elements.slice(0, 40).map((el) => {
66
+ const r = el.getBoundingClientRect();
67
+ return { key: primary ? `data-section:${el.getAttribute("data-section")}` : `#${el.id}`,
68
+ top: r.y + scrollY, height: r.height, left: r.x + scrollX, width: r.width };
69
+ });
70
+ return { status: elements.length ? "measured" : "no_section_keys", bands, capped: elements.length > 40 };
71
+ }
72
+ const monotonicClock = { now: () => performance.now() };
73
+ const timedOut = (error) => error?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE;
74
+ const safeSectionKey = (key) => {
75
+ const prefix = key.startsWith("data-section:") ? "data-section:" : "#";
76
+ const value = key.slice(prefix.length);
77
+ return /^[A-Za-z0-9_-]{1,64}$/.test(value) ? key : `sha256:${sha256Hex(key).slice(0, 12)}`;
78
+ };
79
+ function sectionsFrom(probe) {
80
+ if (probe?.status !== "measured" || !Array.isArray(probe.value?.bands)) return { status: "probe_failed", bands: [], duplicated_keys: [], capped: false };
81
+ const bands = probe.value.bands.map((band) => ({ ...band, key: safeSectionKey(band.key) }));
82
+ return { status: probe.value.status, bands, duplicated_keys: [...new Set(bands.filter((b, i) => bands.some((c, j) => j < i && c.key === b.key)).map((b) => b.key))].sort(), capped: probe.value.capped };
83
+ }
84
+ function blankCell(cell, status) {
85
+ const shot = () => ({ status: "skipped", png: null, full_height_css: null, crop: null, truncated: false });
86
+ return { ...cell, status, source_path_rel: null, source_sha256: null,
87
+ networkidle: { status: "invalid", duration_ms: null },
88
+ readiness: { fonts: { status: "timeout", failed_faces: 0, waited_ms: 0 }, await: { status: "timeout", remaining_count: 0, waited_ms: 0 } },
89
+ external: cell.side === "source_html" ? { blocked_count: 0, blocked_websocket_count: 0, blocked_origins: [] } : null,
90
+ sections: { status: "probe_failed", bands: [], duplicated_keys: [], capped: false }, actual: shot(),
91
+ stabilized: { ...shot(), treatment: { animations: "disabled", caret: "hide", mask_color: "#ff00ff", mask_selectors: VISUAL_MASKS, masks: [], mask_cap_reached: false } }, captured_at: null };
92
+ }
93
+
94
+ const insideRoot = (root, path) => {
95
+ const rel = relative(root, path);
96
+ return !isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`);
97
+ };
98
+ // The declared source root owns its realpath. Output roots are exceptions only
99
+ // while their own realpaths remain beneath the target's realpath (A-C3).
100
+ function referenceReadRoot({ sourceRoot, targetRepo }, path) {
101
+ try {
102
+ const source = realpathSync(sourceRoot || ".");
103
+ if (insideRoot(source, path)) return source;
104
+ } catch { /* absent source root */ }
105
+ if (targetRepo) for (const component of ["_site", ".campaign-runtime"]) {
106
+ try {
107
+ const target = realpathSync(targetRepo), output = realpathSync(resolve(targetRepo, component));
108
+ if (insideRoot(target, output) && insideRoot(output, path)) return output;
109
+ } catch { /* absent output root */ }
110
+ }
111
+ return null;
112
+ }
113
+
114
+ // verifyReference opens its path with a blocking open. A declared path whose
115
+ // realpath is a FIFO, socket, device or directory is refused here, without
116
+ // blocking, as the read step's file_missing (the reason a directory reads).
117
+ async function verifyDeclared(args) {
118
+ let resolved = null;
119
+ try { resolved = realpathSync(resolve(args.sourceRoot || ".", args.path)); } catch { /* verifyReference reads file_missing */ }
120
+ if (resolved && !referenceReadRoot(args, resolved)) {
121
+ const refused = await verifyReference({ ...args, path: null, url: undefined });
122
+ return { ...refused, path_rel: relative(args.targetRepo || args.sourceRoot || ".", resolved), url_present: Boolean(args.url),
123
+ verification: { ...refused.verification, reason: "path_outside_source_root" },
124
+ provenance: classifyProvenance({ ...args, path: resolved, sha256: null }) };
125
+ }
126
+ if (!args.path || !resolved || isRegularFileSync(resolved)) return verifyReference(args);
127
+ const refused = await verifyReference({ ...args, path: null, url: undefined });
128
+ return { ...refused, path_rel: relative(args.targetRepo || args.sourceRoot || ".", resolved), url_present: Boolean(args.url),
129
+ provenance: classifyProvenance({ ...args, path: resolved, sha256: null }) };
130
+ }
131
+
132
+ export function unavailableCapturesRecord({ plan, subject, measuredAt } = {}) {
133
+ return buildCapturesRecord({ subject, cells: planVisualCapture(plan).cells.map((cell) => blankCell(cell, "browser_unavailable")), measuredAt });
134
+ }
135
+
136
+ // Proxy refusals are connections the per-context routes never saw (a
137
+ // SharedWorker fetch, for example). The source server refuses them; the ones
138
+ // made during a source render count as blocked for that cell (§2.3, §3.2).
139
+ const refusalCounts = (refused, total) => ({ total: typeof total === "function" ? total() : 0,
140
+ origins: new Map((typeof refused === "function" ? refused() : []).map(({ origin, count }) => [origin, count])) });
141
+ function addRefusals(external, before, after) {
142
+ const added = after.total - before.total;
143
+ const origins = new Set(external.blocked_origins);
144
+ for (const [origin, count] of after.origins) {
145
+ const delta = count - (before.origins.get(origin) || 0);
146
+ if (!(delta > 0)) continue;
147
+ if (origins.size < 32) origins.add(origin);
148
+ }
149
+ return added ? { ...external, blocked_count: external.blocked_count + added, blocked_origins: [...origins].sort() } : external;
150
+ }
151
+
152
+ // The campaign's own Page Kit pages, relative to the target repo: target_path
153
+ // is relative to the Page Kit output directory, output_path already joins it.
154
+ export function pageKitOutputPaths(packet) {
155
+ return (packet?.source_html?.pages || []).map(({ page_kit: kit } = {}) => kit?.output_path
156
+ || (kit?.target_path && packet?.assembly?.output_dir ? join(packet.assembly.output_dir, kit.target_path) : null)).filter(Boolean);
157
+ }
158
+
159
+ export async function runVisualCells({ buildAdapter, sourceAdapter, pairs, sourceOrigin, sourceRefused = null, sourceRefusedTotal = null, probes = [],
160
+ budget = {}, clock = monotonicClock, writePng, sources = new Map(), now = new Date(), captureCellDeadlineMs, timers = {} } = {}) {
161
+ const cells = [], poisoned = new Set();
162
+ const remaining = () => typeof budget.remaining === "function" ? budget.remaining() : VISUAL_CAPTURE_RUN_MS;
163
+ for (const planned of pairs.cells || pairs) {
164
+ const cell = blankCell(planned, "browser_unavailable");
165
+ const source = sources.get(cell.page_id);
166
+ if (source && cell.side === "source_html") Object.assign(cell, { source_path_rel: source.path_rel, source_sha256: source.identity?.sha256 });
167
+ const adapter = cell.side === "build" ? buildAdapter : sourceAdapter;
168
+ if (remaining() <= 0) cell.status = "run_budget_exhausted";
169
+ else if (poisoned.has(cell.side)) cell.status = "producer_timeout";
170
+ else if (cell.side === "source_html" && source?.input_reason) cell.status = source.input_reason;
171
+ else if (adapter) {
172
+ const route = pairs.routes?.find((r) => r.page_id === cell.page_id);
173
+ const identity = { side: cell.side, page_id: cell.page_id, route: cell.route, viewport: cell.viewport };
174
+ const selected = probes.map((probe) => ({ ...probe,
175
+ select: probe.select ? () => probe.select(identity) : undefined,
176
+ onResult: probe.onResult ? (result) => probe.onResult(identity, result) : undefined }));
177
+ const controller = new AbortController();
178
+ const refusedBefore = cell.side === "source_html" ? refusalCounts(sourceRefused, sourceRefusedTotal) : null;
179
+ try {
180
+ const result = await runWithPolishProducerDeadline(() => adapter.renderForCompare({
181
+ url: cell.side === "build" ? route?.url : new URL(source.url_path, sourceOrigin).href,
182
+ viewport: { key: cell.viewport, width: cell.width, height: cell.height }, deviceScaleFactor: 1, signal: controller.signal,
183
+ requestPolicy: cell.side === "source_html" ? { allowOrigin: sourceOrigin } : null,
184
+ readiness: { fontsMs: VISUAL_LIMITS.readiness_fonts_ms, awaitMs: VISUAL_LIMITS.readiness_await_ms },
185
+ probes: [{ name: "visual_sections", source: visualSections.toString(), args: [], boundMs: 1500 }, ...selected],
186
+ shots: { actual: true, stabilized: { masks: VISUAL_MASKS, animations: "disabled", caret: "hide", maskColor: "#ff00ff" } },
187
+ maxHeightPx: VISUAL_LIMITS.capture_height_px, budget: { clock, addedRemainingMs: remaining() },
188
+ }), { ...timers, timeoutMs: Math.max(1, Math.min(remaining(), boundedPolishDeadline(captureCellDeadlineMs, POLISH_CAPTURE_CELL_DEADLINE_MS))),
189
+ onTimeout: () => controller.abort() });
190
+ cell.status = result.status;
191
+ for (const key of ["networkidle", "readiness", "external"]) if (result[key]) cell[key] = result[key];
192
+ cell.sections = sectionsFrom(result.probes?.visual_sections);
193
+ cell.probes = result.probes || {};
194
+ for (const key of ["actual", "stabilized"]) {
195
+ const shot = result[key];
196
+ if (!shot) continue;
197
+ const { bytes, ...metadata } = shot;
198
+ // A shot skipped before capture still carries the recorded treatment.
199
+ cell[key] = { ...metadata, png: null, ...(key === "stabilized" ? { treatment: metadata.treatment ?? cell.stabilized.treatment } : {}) };
200
+ if (shot.status === "captured") {
201
+ try { cell[key].png = writePng(bytes); } catch { cell[key].status = "failed"; }
202
+ }
203
+ }
204
+ cell.captured_at = new Date(now).toISOString();
205
+ } catch (error) {
206
+ cell.status = timedOut(error) ? "producer_timeout" : "navigation_failed";
207
+ if (timedOut(error)) poisoned.add(cell.side);
208
+ }
209
+ if (refusedBefore && cell.external) cell.external = addRefusals(cell.external, refusedBefore, refusalCounts(sourceRefused, sourceRefusedTotal));
210
+ }
211
+ cells.push(cell);
212
+ }
213
+ return cells;
214
+ }
215
+
216
+ // All resources and failures here belong solely to captures. The frozen input
217
+ // is never passed to a browser, a probe callback or an artifact writer.
218
+ export async function runVisualPass({ createBrowserAdapter, frozen, packet, packetPath, report, targetRepo,
219
+ headed = false, authCookie = null, probes = [], budget = {}, clock = monotonicClock,
220
+ adapterStartupDeadlineMs, adapterCloseDeadlineMs, captureCellDeadlineMs, now = new Date(), dependencies, timers = {} } = {}) {
221
+ const plan = planVisualCapture(frozen.plan);
222
+ // `timers` ({ setTimer, clearTimer }) lets a test fire a deadline without wall-clock time.
223
+ const bounded = (operation, options) => runWithPolishProducerDeadline(operation, { ...timers, ...options });
224
+ if (!plan) return null;
225
+ const started = clock.now(), realStarted = performance.now();
226
+ const captureMs = boundedPolishDeadline(budget.captureMs, VISUAL_CAPTURE_RUN_MS);
227
+ const addedMs = boundedPolishDeadline(budget.addedMs, VISUAL_ADDED_RUN_MS);
228
+ const addedLeft = () => Math.min(addedMs - (clock.now() - started), addedMs - (performance.now() - realStarted));
229
+ const captureLeft = () => Math.min(captureMs - (clock.now() - started), captureMs - (performance.now() - realStarted), addedLeft());
230
+ const closeMs = boundedPolishDeadline(adapterCloseDeadlineMs, POLISH_CAPTURE_CLOSE_DEADLINE_MS);
231
+ const adapters = { build: { startup: "failed", close: "not_started" }, source: { startup: "failed", close: "not_started", sealed: true } };
232
+ const instances = {}, sources = new Map(), references = [], comparisons = [];
233
+ let server = null, cells = [], root = null, rootAvailable = false;
234
+ let dsp = null, dspReason = "source_package_unavailable";
235
+ try {
236
+ if (typeof packet.source_html?.root === "string") root = resolve(dirname(packetPath), packet.source_html.root);
237
+ if (root) rootAvailable = statSync(root).isDirectory();
238
+ } catch { /* source root is absent */ }
239
+ const source = { root_rel: root ? relative(targetRepo, root) : null, status: rootAvailable ? "available" : "missing", server: "loopback_read_only" };
240
+ try {
241
+ const path = packet.design_source_package?.path;
242
+ if (path) {
243
+ dsp = JSON.parse(readRegularFileSync(resolve(dirname(packetPath), path)).toString("utf8"));
244
+ dspReason = computeDesignSourcePackageMaterialFingerprint(dsp) === currentSourcePackageMaterialFingerprint(report) ? null : "source_package_stale";
245
+ }
246
+ } catch { /* unavailable, never fetch */ }
247
+ // A report without a slug names no built output: S2 has nothing to hash,
248
+ // and every route still gets its cells and rows.
249
+ const builtHtmlHashes = [], slug = report?.identity?.public_route_slug;
250
+ for (const route of typeof slug === "string" && slug ? plan.routes : []) {
251
+ const suffix = route.requested_route.replace(new RegExp(`^/${slug}/?`), "");
252
+ try { builtHtmlHashes.push(`sha256:${sha256Hex(readRegularFileSync(resolve(targetRepo, "_site", slug, suffix, "index.html"), { root: targetRepo }))}`); } catch { /* missing or non-regular output */ }
253
+ }
254
+ const contribution = dsp?.contributions?.find((c) => c.kind === "html_funnel");
255
+ const pageKitTargets = pageKitOutputPaths(packet);
256
+ for (const route of plan.routes) {
257
+ const mapping = packet.source_html.pages.find((p) => p.page_id === route.page_id);
258
+ const declared = contribution?.source_refs?.find((ref) => ref.kind === "html_file" && ref.path === mapping.path);
259
+ const ref = { ...await verifyDeclared({ path: mapping.path, sourceRoot: root || targetRepo, targetRepo,
260
+ kind: "source_html", recorded: declared || {}, builtHtmlHashes, pageKitTargets }),
261
+ page_id: route.page_id, route: route.requested_route, viewport: null, ref_id: "source_html" };
262
+ if (!rootAvailable) ref.input_reason = "source_root_unavailable";
263
+ else if (!ref.identity.sha256 || ref.verification.reason) ref.input_reason = "source_html_missing";
264
+ // Only files contained by the serving root may be rendered, including a
265
+ // self-referential source which verification can otherwise read for QC.
266
+ try { const rel = relative(realpathSync(root), realpathSync(resolve(root, mapping.path)));
267
+ if (isAbsolute(rel) || rel === ".." || rel.startsWith("../")) ref.input_reason = "source_html_missing";
268
+ ref.url_path = `/${rel.split(sep).map(encodeURIComponent).join("/")}`;
269
+ } catch { ref.input_reason ||= rootAvailable ? "source_html_missing" : "source_root_unavailable"; }
270
+ sources.set(route.page_id, ref);
271
+ const { url_path, ...persisted } = ref; references.push(persisted);
272
+ }
273
+ try {
274
+ if (rootAvailable && captureLeft() > 0) {
275
+ const abort = new AbortController();
276
+ server = await bounded(() => startSourceHtmlServer({ root, signal: abort.signal }),
277
+ { timeoutMs: Math.max(1, Math.min(closeMs, captureLeft())), onTimeout: () => abort.abort() });
278
+ }
279
+ for (const side of ["build", "source"]) {
280
+ if ((side === "source" && !server) || captureLeft() <= 0) continue;
281
+ let abandoned = false;
282
+ const promise = Promise.resolve().then(() => createBrowserAdapter({ headed,
283
+ authCookie: side === "build" ? authCookie : null, ...(side === "source" ? { seal: { origin: server.origin, port: server.port } } : {}) }));
284
+ void promise.then((late) => { if (abandoned) void bounded(() => late.close(), { timeoutMs: closeMs, unrefTimer: true }).catch(() => {}); }, () => {});
285
+ try {
286
+ instances[side] = await bounded(() => promise, {
287
+ timeoutMs: Math.max(1, Math.min(captureLeft(), boundedPolishDeadline(adapterStartupDeadlineMs, POLISH_CAPTURE_STARTUP_DEADLINE_MS))),
288
+ onTimeout: () => { abandoned = true; } });
289
+ adapters[side].startup = "ok";
290
+ } catch (error) { adapters[side].startup = timedOut(error) ? "producer_timeout" : "failed"; }
291
+ }
292
+ cells = await runVisualCells({ buildAdapter: instances.build, sourceAdapter: instances.source, pairs: plan,
293
+ sourceOrigin: server?.origin, sourceRefused: server?.refused, sourceRefusedTotal: server?.refusedTotal, sources, probes, clock, now, captureCellDeadlineMs, timers,
294
+ budget: { remaining: captureLeft }, writePng: (bytes) => writePolishPng(targetRepo, "captures", bytes) });
295
+ } catch {
296
+ // A source listener/setup failure must still yield the complete grid.
297
+ cells = plan.cells.map((cell) => blankCell(cell, cell.side === "source_html" ? "source_root_unavailable" : "browser_unavailable"));
298
+ } finally {
299
+ for (const side of ["build", "source"]) if (instances[side]) {
300
+ try { await bounded(() => instances[side].close(), { timeoutMs: Math.max(1, Math.min(closeMs, addedLeft())) }); adapters[side].close = "ok"; }
301
+ catch (error) { adapters[side].close = timedOut(error) ? "producer_timeout" : "failed"; }
302
+ }
303
+ if (server) try { await bounded(() => server.close(), { timeoutMs: Math.max(1, Math.min(closeMs, addedLeft())) }); } catch { /* listener closes all sockets synchronously */ }
304
+ }
305
+ const diffStarted = clock.now(), diffRealStarted = performance.now();
306
+ const diffMs = boundedPolishDeadline(budget.diffMs, VISUAL_DIFF_RUN_MS);
307
+ const diffLeft = () => Math.min(diffMs - (clock.now() - diffStarted), diffMs - (performance.now() - diffRealStarted), addedLeft());
308
+ dependencies ||= await loadVisualDependencies();
309
+ const captureHashes = cells.flatMap((c) => [c.actual?.png?.sha256, c.stabilized?.png?.sha256]).filter(Boolean);
310
+ for (const route of plan.routes) {
311
+ // A route's page id is its CampaignSpec page id. Another surface's
312
+ // source_page_id never stands in: a page with no CampaignSpec surface has
313
+ // no declared image references (§3.1), only its source_html rows.
314
+ const surface = dsp?.surface_identity?.find((s) => object(s) && s.mappings?.campaign_spec_page_id === route.page_id);
315
+ const coverage = contribution?.mappings?.find((m) => m.surface_id === surface?.id);
316
+ const refs = (coverage?.screenshot_refs || []).map((id) => contribution.screenshot_refs?.find((r) => r.id === id)).filter(Boolean);
317
+ const counts = new Map();
318
+ for (const declared of refs) {
319
+ const viewport = declared.viewport ?? null, count = counts.get(viewport) || 0; counts.set(viewport, count + 1);
320
+ if (count >= 4) continue;
321
+ const linkedDeclared = contribution?.source_refs?.find((r) => r.id === declared.source_ref_id && r.kind === "html_file");
322
+ const mapping = packet.source_html.pages.find((p) => p.page_id === route.page_id);
323
+ const linkedSource = !linkedDeclared ? null : linkedDeclared.path === mapping.path ? sources.get(route.page_id)
324
+ : await verifyDeclared({ path: linkedDeclared.path, sourceRoot: root || targetRepo, targetRepo, kind: "html_file",
325
+ recorded: linkedDeclared, builtHtmlHashes, pageKitTargets });
326
+ const ref = { ...await verifyDeclared({ path: declared.path, url: declared.url, recorded: declared, sourceRoot: root || targetRepo, targetRepo,
327
+ decoder: dependencies.decoder, budget: { remaining: diffLeft }, buildCaptureHashes: captureHashes, linkedSource }),
328
+ page_id: route.page_id, route: route.requested_route, viewport, ref_id: declared.id };
329
+ if (!rootAvailable) ref.input_reason = "source_root_unavailable";
330
+ else if (dspReason) ref.input_reason = dspReason;
331
+ if (refs.filter((r) => r.viewport === declared.viewport).length > 4) ref.reference_cap_reached = true;
332
+ references.push(ref);
333
+ }
334
+ }
335
+ const imageSource = (png) => ({ width: png.width_px, height: png.height_px,
336
+ load: () => dependencies.decoder.decode(readRegularFileSync(resolve(targetRepo, png.path), { maxBytes: png.bytes, root: targetRepo, noFollow: true })) });
337
+ // A declared reference is read beneath the root verification accepted it
338
+ // under: the source root, which may sit inside or outside the target (§3.1),
339
+ // or for a self-referential file the target's _site or .campaign-runtime.
340
+ // Never the whole target repo.
341
+ const referenceSource = (ref) => ({ width: ref.verification.png.width_px, height: ref.verification.png.height_px, load: () => {
342
+ const path = resolve(targetRepo, ref.path_rel);
343
+ const readRoot = referenceReadRoot({ sourceRoot: root || targetRepo, targetRepo }, realpathSync(path));
344
+ if (!readRoot) throw Object.assign(new Error("outside_root"), { code: "outside_root" });
345
+ return dependencies.decoder.decode(readRegularFileSync(path, { root: readRoot }));
346
+ } });
347
+ for (const build of cells.filter((c) => c.side === "build")) {
348
+ const basis = cells.find((c) => c.side === "source_html" && c.route === build.route && c.viewport === build.viewport);
349
+ const refs = references.filter((r) => r.route === build.route && r.ref_id !== "source_html" && (r.viewport === build.viewport
350
+ || (build.viewport === plan.viewports[0].key && !["desktop", "mobile"].includes(r.viewport))));
351
+ for (const reference of [null, ...refs]) {
352
+ let comparison = { page_id: build.page_id, route: build.route, viewport: reference?.viewport ?? build.viewport,
353
+ basis: reference ? "reference" : "source_html", ref_id: reference?.ref_id ?? null,
354
+ build_shot: reference ? "actual" : "stabilized", basis_shot: reference ? "image" : "stabilized",
355
+ status: "not_run", reason: null, page: null, bands: [], members: [], artifacts: [], limits: [], diff_options: { ...DIFF_OPTIONS } };
356
+ const ref = reference || references.find((r) => r.ref_id === "source_html" && r.route === build.route);
357
+ const context = { record: { dependencies }, build, basis, reference: ref, budgetExhausted: diffLeft() <= 0 };
358
+ const decision = evaluateVisualDiff(comparison, context);
359
+ if (["visual_diff_diagnostic", "comparison_unsupported"].includes(decision.reason_code)) {
360
+ try {
361
+ if (diffLeft() <= 0) { comparison.status = "unavailable"; comparison.reason = "diff_budget_exhausted"; }
362
+ else if (build[comparison.build_shot]?.png && (reference || basis?.stabilized?.png)) {
363
+ const artifacts = [];
364
+ comparison = compareVisualPair({ build, basis, reference,
365
+ buildImage: imageSource(build[comparison.build_shot].png),
366
+ basisImage: reference ? referenceSource(ref) : imageSource(basis.stabilized.png),
367
+ diff: dependencies.diff, onComparison: (part) => {
368
+ if (diffLeft() <= 0) throw new Error("diff_budget_exhausted");
369
+ // Keep at most three encoded triples; ranking is independent of QC.
370
+ if (!part.mismatch_pixels || (!part.key && (basis?.sections?.bands?.length || build.sections?.bands?.length))) return;
371
+ const rank = { key: part.key ?? "page", mismatch_ratio: part.mismatch_ratio, height_delta_px: part.height_delta_px };
372
+ artifacts.push({ ...rank, images: [part.basis_crop, part.build_crop, part.heatmap].map((image) => {
373
+ if (diffLeft() <= 0) throw new Error("diff_budget_exhausted");
374
+ return encodeHeatmap(image, dependencies.decoder);
375
+ }) });
376
+ artifacts.sort((a, b) => b.mismatch_ratio - a.mismatch_ratio || Math.abs(b.height_delta_px) - Math.abs(a.height_delta_px) || a.key.localeCompare(b.key));
377
+ if (artifacts.length > 3) artifacts.pop();
378
+ } });
379
+ comparison.artifacts = artifacts.map((artifact) => ({ band_key: artifact.key,
380
+ ...Object.fromEntries(["basis_crop", "build_crop", "heatmap"].map((name, i) => [name, writePolishPng(targetRepo, "diff", artifact.images[i])])) }));
381
+ }
382
+ } catch (error) { comparison.status = "unavailable"; comparison.reason = diffFailure(error); }
383
+ } else { comparison.status = "unavailable"; comparison.reason = decision.reason_code;
384
+ if (decision.reason_code === "reference_not_usable" && ref?.verification.reason === "path_outside_source_root") comparison.reason = "path_outside_source_root";
385
+ if (decision.reason_code === "diff_unavailable" && diffLeft() <= 0) comparison.reason = "diff_budget_exhausted"; }
386
+ if (reference) comparison.viewport = reference.viewport;
387
+ comparisons.push(comparison);
388
+ }
389
+ }
390
+ return buildCapturesRecord({ subject: { ...frozen.page_load.subject, source_package_material_fingerprint: currentSourcePackageMaterialFingerprint(report) }, environment: { browser: instances.build?.browserVersion ?? instances.source?.browserVersion ?? null, device_scale_factor: 1 },
391
+ source, adapters, dependencies, cells: cells.map(({ probes: _probes, ...cell }) => cell), references, comparisons, measuredAt: now });
392
+ }
393
+
394
+ export function buildCapturesRecord({ subject, buildFingerprint, sourceFingerprint = null, slug, routes = [],
395
+ routeScope = "all", cells = [], references = [], comparisons = [], environment = { browser: null, device_scale_factor: 1 },
396
+ dependencies, adapters, source, measuredAt = new Date().toISOString() } = {}) {
397
+ const dependencyMetadata = dependencies && Object.fromEntries(["decoder", "diff"].map((key) => {
398
+ const dep = own(dependencies, key);
399
+ return [key, object(dep) ? Object.fromEntries(Object.entries(dep).filter(([, value]) => typeof value !== "function")) : { status: "missing", version: null }];
400
+ }));
401
+ const metricsOnly = (part) => part && Object.fromEntries(Object.entries(part).filter(([key]) =>
402
+ !["heatmap", "basis_crop", "build_crop", "mismatches", "mask", "worst_tiles"].includes(key)));
403
+ const persistedComparisons = comparisons.map((comparison) => !object(comparison) ? comparison : ({ ...comparison, page: metricsOnly(comparison.page),
404
+ bands: (comparison.bands || []).map((band) => !object(band) ? band : ({ ...metricsOnly(band), ...(band.worst_tiles ? { worst_tiles: band.worst_tiles } : {}) })) }));
405
+ const record = structuredClone({ schema_version: CAPTURES_SCHEMA, performed_by: CAPTURES_PRODUCER,
406
+ treatment_version: VISUAL_TREATMENT,
407
+ subject: subject ?? { build_fingerprint: buildFingerprint, source_package_material_fingerprint: sourceFingerprint,
408
+ campaign_slug: slug, route_scope: routeScope, routes, viewports: ["desktop", "mobile"] },
409
+ environment, limits: { ...VISUAL_LIMITS }, dependencies: dependencyMetadata ?? {
410
+ decoder: { status: "missing", version: null }, diff: { status: "missing", version: null } },
411
+ adapters: adapters ?? { build: { startup: "failed", close: "not_started" }, source: { startup: "failed", close: "not_started", sealed: true } },
412
+ source: source ?? { root_rel: null, status: "missing", server: "loopback_read_only" },
413
+ cells, references, comparisons: persistedComparisons, measured_at: new Date(measuredAt).toISOString() });
414
+ return { ...record, integrity: capturesIntegrity(record) };
415
+ }
416
+
417
+ // Inspect the entire existing chain before making any directory. In particular,
418
+ // recursive mkdir must never traverse an unchecked .campaign-runtime symlink.
419
+ export function containedWritePath(targetRepo, path) {
420
+ const root = realpathSync(targetRepo);
421
+ if (!statSync(root).isDirectory()) throw new Error("path_outside_target");
422
+ const output = resolve(root, path), rel = relative(root, output);
423
+ const inside = (value) => { const r = relative(root, value); return !isAbsolute(r) && r !== ".." && !r.startsWith(`..${sep}`); };
424
+ if (!inside(output)) throw new Error("path_outside_target");
425
+ let current = root;
426
+ for (const component of rel.split(sep).filter(Boolean)) {
427
+ current = resolve(current, component);
428
+ try {
429
+ lstatSync(current);
430
+ if (!inside(realpathSync(current))) throw new Error("path_outside_target");
431
+ } catch (error) {
432
+ if (error.code !== "ENOENT") throw error;
433
+ // A dangling symlink is not a missing directory.
434
+ try { if (lstatSync(current).isSymbolicLink()) throw new Error("path_outside_target"); } catch (e) { if (e.code !== "ENOENT") throw e; }
435
+ }
436
+ }
437
+ return output;
438
+ }
439
+
440
+ export function writePolishPng(targetRepo, dir, bytes) {
441
+ const directory = `.campaign-runtime/polish/${dir}`;
442
+ if (!["captures", "diff"].includes(dir)) throw new TypeError("Invalid polish PNG directory");
443
+ const structure = readPngStructure(bytes);
444
+ if (!structure.ok) throw new Error(structure.reason);
445
+ const root = realpathSync(targetRepo), output = containedWritePath(root, directory);
446
+ const hex = sha256Hex(bytes), path = `${directory}/${hex}.png`;
447
+ containedWritePath(root, `${directory}/.gitignore`);
448
+ containedWritePath(root, path);
449
+ mkdirSync(output, { recursive: true });
450
+ try { writeFileSync(resolve(output, ".gitignore"), "*\n", { flag: "wx" }); } catch (error) { if (error.code !== "EEXIST") throw error; }
451
+ try { writeFileSync(resolve(root, path), bytes, { flag: "wx" }); } catch (error) { if (error.code !== "EEXIST") throw error; }
452
+ const written = readRegularFileSync(resolve(root, path), { maxBytes: bytes.length, root, noFollow: true }), checked = readPngStructure(written);
453
+ if (!checked.ok || sha256Hex(written) !== hex) throw new Error("png_write_verification_failed");
454
+ return { path, sha256: `sha256:${hex}`, bytes: written.length,
455
+ width_px: checked.ihdr.width_px, height_px: checked.ihdr.height_px };
456
+ }
457
+
458
+ const member = (key, reason_code, result = "unexercised", extra = {}) => ({ key, result, reason_code, ...extra });
459
+ // A diff that could not run keeps its own reason: the row reads
460
+ // diff_unavailable with that reason as its member (§5.3 step 11).
461
+ export const VISUAL_DIFF_FAILURES = Object.freeze(["diff_budget_exhausted", "image_too_large", "decode_failed", "file_missing", "path_outside_source_root", "too_large"]);
462
+ function diffFailure(error) {
463
+ if (error?.message === "diff_budget_exhausted") return "diff_budget_exhausted";
464
+ if (["ENOENT", "not_regular_file"].includes(error?.code)) return "file_missing";
465
+ if (error?.code === "outside_root") return "path_outside_source_root";
466
+ return error?.code === "too_large" ? "too_large" : "decode_failed";
467
+ }
468
+ export function evaluateVisualReference(reference) {
469
+ if (!object(reference)) return { result: "unexercised", reason_code: "evidence_not_reproducible", members: [], accept_eligible: false };
470
+ if (["source_root_unavailable", "source_html_missing", "source_package_unavailable", "source_package_stale"].includes(reference.input_reason)) {
471
+ return { result: "unexercised", reason_code: reference.input_reason, members: [], accept_eligible: false };
472
+ }
473
+ const level = reference.verification?.level;
474
+ const members = [level === "decoded" ? member("verification", null, "pass")
475
+ : member("verification", level === "header_checked" ? "decoder_unavailable" : "reference_unverifiable")];
476
+ members.push(reference.provenance?.class === "self_referential" ? member("provenance", "self_referential", "warning")
477
+ : reference.provenance?.class === "declared_source" ? member("provenance", null, "pass") : member("provenance", "provenance_unknown", "review"));
478
+ if (reference.reference_cap_reached) members.push(member("reference_cap", "reference_cap_reached"));
479
+ // A file refused for where it is, not what it is, names that location.
480
+ members.push(...referenceLocationMembers(reference));
481
+ return aggregateQcResults(members);
482
+ }
483
+
484
+ // The location member of a reference refused for where it is. It does not
485
+ // depend on the build, so a stale-bound row keeps it (#707).
486
+ function referenceLocationMembers(reference) {
487
+ return reference?.verification?.level !== "decoded" && reference?.verification?.reason === "path_outside_source_root"
488
+ ? [member("location", "path_outside_source_root")] : [];
489
+ }
490
+
491
+ // The only diff decision table. Scores are intentionally absent from its predicates.
492
+ export function evaluateVisualDiff(comparison, { record, reference, build, basis, readerFailure = null,
493
+ staleBinding = false, pageMissing = false, fileAbsent = false, budgetExhausted = false } = {}) {
494
+ const stop = (reason_code, members = []) => ({ result: "unexercised", reason_code, members, accept_eligible: false });
495
+ if (!object(comparison) || !object(record) || ![build, basis, reference].every((cell) => cell == null || object(cell))) return stop("evidence_not_reproducible");
496
+ if ([comparison.members, comparison.bands].some((items) => items != null && (!Array.isArray(items) || items.some((item) => !object(item))))) return stop("evidence_not_reproducible");
497
+ if (readerFailure) return stop("evidence_not_reproducible", [member("evidence", readerFailure)]);
498
+ if (staleBinding) return stop("stale_binding");
499
+ if (pageMissing) return stop("page_not_captured");
500
+ const image = comparison.basis === "reference";
501
+ if (image && (!build || build.viewport !== reference?.viewport)) return stop("comparison_unsupported", [member("viewport", "viewport_not_captured")]);
502
+ const deps = [own(record.dependencies, "decoder"), own(record.dependencies, "diff")];
503
+ if (deps.some((dep) => own(dep, "status") !== "available")) return stop("diff_unavailable", [member("dependencies", deps.some((dep) => own(dep, "status") === "failed") ? "dependency_failed" : "dependency_missing")]);
504
+ const needed = image ? [["build", build, "actual"]] : [["build", build, "stabilized"], ["source_html", basis, "stabilized"]];
505
+ const missing = needed.filter(([, cell, shot]) => cell?.status !== "rendered" || own(cell, shot)?.status !== "captured");
506
+ if (missing.length) return stop("capture_unavailable", missing.map(([side, cell, shot]) => member(side, cell?.status !== "rendered" ? cell?.status || "cell_missing" : own(cell, shot)?.status || "shot_missing")));
507
+ const ref = evaluateVisualReference(reference || {});
508
+ if (ref.members.find((m) => m.key === "verification")?.result !== "pass") return stop("reference_not_usable", ref.members.filter((m) => m.key === "location"));
509
+ const pair = pairCompatibility(build, image ? reference : basis, { reference: image });
510
+ if (!pair.ok) return stop("comparison_unsupported", [member("pair", pair.reason)]);
511
+ if (fileAbsent) return stop("capture_file_absent");
512
+ const readiness = needed.flatMap(([side, cell]) => [
513
+ ...(cell.readiness?.fonts?.status === "timeout" ? [member(side, "fonts_timeout")] : []),
514
+ ...(cell.readiness?.await?.status === "timeout" ? [member(side, "await_timeout")] : []),
515
+ ]);
516
+ if (readiness.length) return stop("readiness_incomplete", readiness);
517
+ if (budgetExhausted || VISUAL_DIFF_FAILURES.includes(comparison.reason)) return stop("diff_unavailable", [member("diff", budgetExhausted ? "diff_budget_exhausted" : comparison.reason)]);
518
+ const members = [...(comparison.members || []).map((m) => member(m.key, m.reason_code ?? m.reason, m.result || "unexercised"))];
519
+ const add = (key, reason) => { if (!members.some((m) => m.key === key && m.reason_code === reason)) members.push(member(key, reason)); };
520
+ if (comparison.page?.status === "unsupported") add("page", comparison.page.reason);
521
+ for (const band of comparison.bands || []) if (band.status === "unsupported") add(band.key, band.reason);
522
+ if (ref.members.find((m) => m.key === "provenance")?.result !== "pass") members.push(member("basis", "basis_not_independent", "review"));
523
+ if (image) members.push(member("reference_capture", "reference_capture_unrecorded", "review", {
524
+ observation: { unrecorded: ["treatment", "state", "crop_origin", "viewport_height"] } }));
525
+ const compared = comparison.page?.status === "compared" || comparison.bands?.some((b) => b.status === "compared");
526
+ return { result: compared ? "review" : "unexercised", reason_code: compared ? "visual_diff_diagnostic" : "comparison_unsupported", members, accept_eligible: false };
527
+ }
528
+
529
+ export function compareVisualPair({ build, basis, reference = null, buildImage, basisImage, diff, memory, onComparison } = {}) {
530
+ if (!object(build) || (!reference && !object(basis))) return { status: "unavailable", reason: "capture_unavailable" };
531
+ const image = Boolean(reference);
532
+ const comparison = { page_id: build.page_id, route: build.route, viewport: build.viewport,
533
+ basis: image ? "reference" : "source_html", ref_id: reference?.ref_id ?? null,
534
+ build_shot: image ? "actual" : "stabilized", basis_shot: image ? "image" : "stabilized",
535
+ status: "not_run", reason: null, members: [], page: null, bands: [], artifacts: [],
536
+ limits: image ? ["top_anchored", "reference_viewport_height_unrecorded"] : ["masks_unioned", "js_animation_not_frozen"], diff_options: { ...DIFF_OPTIONS } };
537
+ const compatible = pairCompatibility(build, reference || basis, { reference: image });
538
+ if (!compatible.ok) return { ...comparison, status: "unsupported", reason: compatible.reason };
539
+ for (const cell of image ? [] : [build, basis]) {
540
+ const masks = cell?.stabilized?.treatment?.masks ?? [];
541
+ if (!Array.isArray(masks) || masks.some((group) => !object(group) || !["timer", "price"].includes(own(group, "group"))
542
+ || !Array.isArray(group.rects) || group.rects.some((rect) => !object(rect)))) return { ...comparison, status: "unavailable", reason: "evidence_not_reproducible" };
543
+ }
544
+ const rects = (cell) => (cell?.stabilized?.treatment?.masks || []).flatMap((group) => group.rects)
545
+ .map((r) => Object.fromEntries(Object.entries(r).map(([key, value]) => [key, value * cell.device_scale_factor])));
546
+ const rectsA = image ? [] : rects(basis), rectsB = image ? [] : rects(build);
547
+ if (basis?.external?.blocked_count > 0) comparison.limits.push("source_external_blocked");
548
+ const page = pageCompatibility(own(build, comparison.build_shot), image ? null : basis.stabilized);
549
+ try {
550
+ boundedDimensions(basisImage?.width, basisImage?.height); boundedDimensions(buildImage?.width, buildImage?.height);
551
+ const hasBands = !image && (basis.sections?.bands?.length || build.sections?.bands?.length);
552
+ if (hasBands && [basisImage, buildImage].some((source) => typeof own(source, "load") !== "function" || source.data != null)) throw new RangeError("image_source_required");
553
+ comparison.page = page.ok ? compareImages(readRgba(basisImage, memory), readRgba(buildImage, memory), { diff, rectsA, rectsB, memory })
554
+ : { status: "unsupported", reason: page.reason, width_delta_px: null, height_delta_px: null,
555
+ canvas: null, masked_pixels: null, mismatch_pixels: null, compared_pixels: null, mismatch_ratio: null };
556
+ try { if (page.ok) onComparison?.(comparison.page); } finally { releaseComparison(comparison.page, memory); }
557
+ if (hasBands) Object.assign(comparison, compareBands(basisImage, buildImage, { basisSections: basis.sections, buildSections: build.sections, rectsA, rectsB, diff, dpr: build.device_scale_factor, memory, onComparison }));
558
+ else if (!image) comparison.members.push(member("sections", "no_section_keys"));
559
+ else comparison.members.push(member("sections", "source_section_offsets_unrecorded"));
560
+ comparison.status = comparison.page.status === "compared" || comparison.bands.some((band) => band.status === "compared") ? "compared" : "unsupported";
561
+ } catch (error) {
562
+ if (error.message !== "image_too_large") throw error;
563
+ comparison.status = "unavailable"; comparison.reason = "image_too_large";
564
+ }
565
+ return comparison;
566
+ }
567
+
568
+ function evaluate(record, context = {}) {
569
+ if (!object(record) || ![record.cells, record.references, record.comparisons].every(Array.isArray)) return [];
570
+ const malformed = [record.cells, record.references, record.comparisons].some((items) => items.some((item) => !object(item)));
571
+ if (malformed) {
572
+ const clean = { ...record, cells: record.cells.filter(object), references: record.references.filter(object), comparisons: record.comparisons.filter(object) };
573
+ return evaluate(clean, { ...context, readerFailure: "record_shape_invalid" });
574
+ }
575
+ const failure = context.readerFailure || (record.treatment_version !== VISUAL_TREATMENT
576
+ || record.cells.some((cell) => cell.state !== VISUAL_STATE || cell.stabilized?.treatment?.mask_selectors !== VISUAL_MASKS) ? "treatment_invalid" : null);
577
+ const rows = [];
578
+ const row = (check, item, decision) => {
579
+ const subject = { check, page: item.route, ...(item.viewport == null ? {} : { viewport: item.viewport }),
580
+ key: item.ref_id === "source_html" || item.basis === "source_html" ? "source_html" : `ref:${item.ref_id}` };
581
+ rows.push(buildQcResult({ check, leg: "polish", subject, ...decision,
582
+ state: { binding: record.subject, item, decision }, observation: item, measured_at: record.measured_at }));
583
+ };
584
+ const referenceCounts = new Map(), seenReferences = new Map(), cappedReferences = new Set();
585
+ const referenceGroup = (ref) => JSON.stringify([ref.page_id, ref.route, ref.viewport]);
586
+ const referenceKey = (ref) => JSON.stringify([ref.route, ref.viewport, ref.ref_id]);
587
+ for (const ref of record.references) if (ref.ref_id !== "source_html") {
588
+ const group = referenceGroup(ref); referenceCounts.set(group, (referenceCounts.get(group) || 0) + 1);
589
+ }
590
+ for (const original of record.references) {
591
+ const group = referenceGroup(original), count = seenReferences.get(group) || 0;
592
+ if (original.ref_id !== "source_html") {
593
+ seenReferences.set(group, count + 1);
594
+ if (count >= VISUAL_LIMITS.references_per_cell) { cappedReferences.add(referenceKey(original)); continue; }
595
+ }
596
+ const ref = referenceCounts.get(group) > VISUAL_LIMITS.references_per_cell
597
+ ? { ...original, reference_cap_reached: true } : original;
598
+ row("visual.reference", ref, failure
599
+ ? { result: "unexercised", reason_code: "evidence_not_reproducible", members: [member("evidence", failure)] }
600
+ : context.staleBinding ? { result: "unexercised", reason_code: "stale_binding", members: referenceLocationMembers(ref) } : evaluateVisualReference(ref));
601
+ }
602
+ for (const comparison of record.comparisons) {
603
+ if (comparison.basis === "reference" && cappedReferences.has(referenceKey(comparison))) continue;
604
+ const find = (side) => record.cells.find((cell) => cell.side === side && cell.route === comparison.route && cell.viewport === comparison.viewport);
605
+ const reference = record.references.find((ref) => ref.route === comparison.route && (comparison.basis === "source_html" ? ref.ref_id === "source_html" : ref.ref_id === comparison.ref_id && ref.viewport === comparison.viewport));
606
+ row("visual.diff", comparison, evaluateVisualDiff(comparison, { ...context, record, reference,
607
+ build: find("build"), basis: find("source_html"), readerFailure: failure }));
608
+ }
609
+ return rows;
610
+ }
611
+ export const VISUAL_QC_RULES = Object.freeze({ thresholds: Object.freeze({}), vocabulary: VISUAL_VOCABULARY,
612
+ schema_version: CAPTURES_SCHEMA, limits: VISUAL_LIMITS, viewports: ["desktop", "mobile"], evaluate,
613
+ rowKeys: (record) => evaluate(record).map((row) => row.subject) });