@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,296 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { boundedDimensions, VISUAL_MAX_DECODE_PIXELS } from "./png-header.mjs";
5
+
6
+ const own = (value, key) => value !== null && ["object", "function"].includes(typeof value) && Object.hasOwn(value, key) ? value[key] : undefined;
7
+ const boundedArea = boundedDimensions;
8
+ function geometry(image) {
9
+ boundedArea(own(image, "width"), own(image, "height"));
10
+ if (!ArrayBuffer.isView(image.data) || image.data.length !== image.width * image.height * 4) throw new RangeError("invalid_image_geometry");
11
+ }
12
+ const track = (memory, data) => { memory?.acquire(data); return data; };
13
+ function release(image, memory) {
14
+ const data = image.data;
15
+ image.data = null;
16
+ if (data) memory?.release(data);
17
+ }
18
+ const allocate = (length, memory) => track(memory, Buffer.alloc(length));
19
+ function maskRects(rects) {
20
+ if (!Array.isArray(rects) || rects.some((r) => !r || ![r.x, r.y].every(Number.isFinite)
21
+ || ![r.width, r.height].every((n) => Number.isFinite(n) && n >= 0))) throw new RangeError("invalid_mask");
22
+ }
23
+
24
+ // Comparisons consume their RGBA inputs. Callbacks must finish encoding/writing
25
+ // artifacts synchronously; only metrics survive into the next comparison.
26
+ export function readRgba(source, memory) {
27
+ boundedArea(own(source, "width"), own(source, "height"));
28
+ if (typeof own(source, "load") !== "function") { geometry(source); return source; }
29
+ const image = source.load();
30
+ geometry(image);
31
+ if (image.width !== source.width || image.height !== source.height) throw new RangeError("invalid_image_geometry");
32
+ track(memory, image.data); return image;
33
+ }
34
+ export function releaseComparison(part, memory) {
35
+ for (const key of ["basis_crop", "build_crop", "heatmap"]) if (part?.[key]) {
36
+ release(part[key], memory); delete part[key];
37
+ }
38
+ delete part.mismatches; delete part.mask;
39
+ }
40
+
41
+ export const DIFF_OPTIONS = Object.freeze({ threshold: 0.1, include_aa: false, alpha: 0.1 });
42
+ // Neither package exports its version: read it from the package.json above the
43
+ // resolved entry whose name is the package's. Unknown is null, never a guess.
44
+ export function installedPackageVersion(name, { resolveEntry = (specifier) => import.meta.resolve(specifier) } = {}) {
45
+ try {
46
+ let directory = dirname(fileURLToPath(resolveEntry(name)));
47
+ for (;;) {
48
+ const file = join(directory, "package.json");
49
+ if (existsSync(file)) {
50
+ const manifest = JSON.parse(readFileSync(file, "utf8"));
51
+ if (manifest?.name === name) return typeof manifest.version === "string" ? manifest.version : null;
52
+ }
53
+ const parent = dirname(directory);
54
+ if (parent === directory) return null;
55
+ directory = parent;
56
+ }
57
+ } catch { return null; }
58
+ }
59
+ export async function loadVisualDependencies({ importer = (name) => import(name), versionOf = installedPackageVersion } = {}) {
60
+ async function load(name, type) {
61
+ try {
62
+ const module = await importer(name);
63
+ const api = own(module, "default") || module;
64
+ const result = { status: "available", version: own(module, "version") ?? own(api, "version") ?? versionOf(name), package: name };
65
+ if (type === "decoder") {
66
+ const PNG = own(module, "PNG") || own(api, "PNG");
67
+ const sync = own(PNG, "sync");
68
+ if (typeof own(sync, "read") !== "function" || typeof own(sync, "write") !== "function") throw new TypeError("Invalid PNG module");
69
+ result.decode = (bytes) => PNG.sync.read(bytes);
70
+ result.encode = (image) => PNG.sync.write(image);
71
+ } else {
72
+ if (typeof api !== "function") throw new TypeError("Invalid diff module");
73
+ result.compare = api;
74
+ }
75
+ return result;
76
+ } catch (error) {
77
+ // A missing transitive import is a broken installation, not this package being absent.
78
+ const missing = ["ERR_MODULE_NOT_FOUND", "MODULE_NOT_FOUND"].includes(error?.code)
79
+ && new RegExp(`(?:package|module) ['\"]${name}['\"]`).test(error.message || "");
80
+ return { status: missing ? "missing" : "failed", version: null, package: name,
81
+ install_hint: `Install ${name} where campaigns-os is installed (npm install ${name}).` };
82
+ }
83
+ }
84
+ const [decoder, diff] = await Promise.all([load("pngjs", "decoder"), load("pixelmatch", "diff")]);
85
+ return { decoder, diff };
86
+ }
87
+
88
+ export function padRgba(image, width, height, color = [255, 255, 255, 255], memory) {
89
+ boundedArea(width, height);
90
+ geometry(image);
91
+ if (width < image.width || height < image.height || image.data.length !== image.width * image.height * 4) throw new RangeError("invalid_image_geometry");
92
+ const data = allocate(width * height * 4, memory);
93
+ for (let i = 0; i < data.length; i += 4) data.set(color, i);
94
+ for (let y = 0; y < image.height; y++) data.set(image.data.subarray(y * image.width * 4, (y + 1) * image.width * 4), y * width * 4);
95
+ return { width, height, data };
96
+ }
97
+
98
+ export function paintMaskUnion(images, rectsA = [], rectsB = [], color = [255, 0, 255, 255]) {
99
+ maskRects(rectsA); maskRects(rectsB);
100
+ if (typeof color === "string" && /^#[a-f0-9]{6}$/i.test(color)) color = [1, 3, 5].map((offset) => Number.parseInt(color.slice(offset, offset + 2), 16)).concat(255);
101
+ if (!images.length) return { masked_pixels: 0, mask: new Uint8Array() };
102
+ for (const image of images) geometry(image);
103
+ const { width, height } = images[0];
104
+ boundedArea(width, height);
105
+ if (images.some((image) => image.width !== width || image.height !== height)) throw new RangeError("mask_canvas_mismatch");
106
+ const mask = new Uint8Array(width * height);
107
+ let masked_pixels = 0;
108
+ for (const rect of [...rectsA, ...rectsB]) {
109
+ if (!rect.width || !rect.height) continue;
110
+ const x0 = Math.max(0, Math.floor(rect.x)), y0 = Math.max(0, Math.floor(rect.y));
111
+ const x1 = Math.min(width, Math.ceil(rect.x + rect.width)), y1 = Math.min(height, Math.ceil(rect.y + rect.height));
112
+ for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
113
+ const index = y * width + x;
114
+ if (!mask[index]) { mask[index] = 1; masked_pixels++; }
115
+ for (const image of images) image.data.set(color, index * 4);
116
+ }
117
+ }
118
+ return { masked_pixels, mask };
119
+ }
120
+
121
+ export function compareImages(basis, build, { diff, rectsA = [], rectsB = [], dpr = 1, memory } = {}) {
122
+ geometry(basis); geometry(build);
123
+ maskRects(rectsA); maskRects(rectsB);
124
+ if (!Number.isFinite(dpr) || dpr <= 0) throw new RangeError("invalid_dpr");
125
+ const compare = typeof diff === "function" ? diff : own(diff, "compare");
126
+ if (typeof compare !== "function") throw new Error("dependency_missing");
127
+ const width = Math.max(basis.width, build.width), height = Math.max(basis.height, build.height);
128
+ boundedArea(width, height);
129
+ const originalA = { width: basis.width, height: basis.height }, originalB = { width: build.width, height: build.height };
130
+ track(memory, basis.data); track(memory, build.data);
131
+ const inputs = [{ ...basis }, { ...build }];
132
+ basis.data = null; build.data = null;
133
+ const consume = (image) => {
134
+ if (image.width === width && image.height === height) {
135
+ const result = { ...image }; image.data = null; return result;
136
+ }
137
+ const padded = padRgba(image, width, height, undefined, memory);
138
+ release(image, memory); return padded;
139
+ };
140
+ let a, b, heatmap, completed = false;
141
+ try {
142
+ if (inputs[0].data.buffer === inputs[1].data.buffer
143
+ && inputs[0].data.byteOffset < inputs[1].data.byteOffset + inputs[1].data.byteLength
144
+ && inputs[1].data.byteOffset < inputs[0].data.byteOffset + inputs[0].data.byteLength) {
145
+ const original = inputs[0].data;
146
+ inputs[0].data = allocate(original.length, memory);
147
+ inputs[0].data.set(original);
148
+ if (original !== inputs[1].data) memory?.release(original);
149
+ }
150
+ a = consume(inputs[0]); b = consume(inputs[1]);
151
+ const { mask, masked_pixels } = paintMaskUnion([a, b], rectsA, rectsB);
152
+ heatmap = { width, height, data: allocate(width * height * 4, memory) };
153
+ compare(a.data, b.data, heatmap.data, width, height,
154
+ { threshold: DIFF_OPTIONS.threshold, includeAA: DIFF_OPTIONS.include_aa, alpha: DIFF_OPTIONS.alpha });
155
+ const mismatches = new Uint8Array(width * height);
156
+ let mismatch_pixels = 0;
157
+ for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
158
+ const i = y * width + x, p = i * 4;
159
+ const missing = (x >= originalA.width || y >= originalA.height) !== (x >= originalB.width || y >= originalB.height);
160
+ if (!mask[i] && (missing || (heatmap.data[p] === 255 && heatmap.data[p + 1] === 0 && heatmap.data[p + 2] === 0))) {
161
+ mismatches[i] = 1; mismatch_pixels++;
162
+ heatmap.data.set([255, 0, 0, 255], p);
163
+ }
164
+ }
165
+ const compared_pixels = width * height - masked_pixels;
166
+ const unpad = (image, original) => {
167
+ for (let y = 0; y < original.height; y++) image.data.copyWithin(y * original.width * 4, y * width * 4, (y * width + original.width) * 4);
168
+ return { ...original, data: image.data.subarray(0, original.width * original.height * 4) };
169
+ };
170
+ const result = { status: "compared", reason: null, width_delta_px: originalB.width - originalA.width,
171
+ height_delta_px: originalB.height - originalA.height, canvas: { width, height }, masked_pixels,
172
+ mismatch_pixels, compared_pixels, mismatch_ratio: compared_pixels ? mismatch_pixels / compared_pixels : 0,
173
+ heatmap, basis_crop: unpad(a, originalA), build_crop: unpad(b, originalB), mismatches, mask,
174
+ worst_tiles: worstTiles({ width, height, mismatches, mask }, { tile: Math.min(32 * dpr, Math.max(width, height)) }) };
175
+ completed = true; return result;
176
+ } finally {
177
+ if (!completed) for (const image of [a, b, heatmap, ...inputs]) if (image?.data) release(image, memory);
178
+ }
179
+ }
180
+
181
+ export function worstTiles({ width, height, mismatches, mask = [] }, { tile = 32, count = 3 } = {}) {
182
+ boundedArea(width, height);
183
+ if (!Number.isFinite(tile) || tile <= 0 || !Number.isSafeInteger(count) || count < 0) throw new RangeError("invalid_tile_options");
184
+ tile = Math.max(1, Math.round(tile));
185
+ count = Math.min(count, 3);
186
+ if (!mismatches || mismatches.length !== width * height || (mask.length && mask.length !== width * height)) throw new RangeError("invalid_tile_geometry");
187
+ const tiles = [];
188
+ for (let y = 0; y < height; y += tile) for (let x = 0; x < width; x += tile) {
189
+ const w = Math.min(tile, width - x), h = Math.min(tile, height - y);
190
+ let mismatch = 0, compared = 0;
191
+ for (let row = y; row < y + h; row++) for (let col = x; col < x + w; col++) {
192
+ const i = row * width + col;
193
+ if (!mask[i]) { compared++; mismatch += mismatches[i]; }
194
+ }
195
+ tiles.push({ x, y, width: w, height: h, mismatch_ratio: compared ? mismatch / compared : 0 });
196
+ tiles.sort((a, b) => b.mismatch_ratio - a.mismatch_ratio || a.y - b.y || a.x - b.x);
197
+ if (tiles.length > count) tiles.pop();
198
+ }
199
+ return tiles.sort((a, b) => b.mismatch_ratio - a.mismatch_ratio || a.y - b.y || a.x - b.x).slice(0, count);
200
+ }
201
+
202
+ export function encodeHeatmap(image, decoder) {
203
+ geometry(image?.heatmap || image);
204
+ return decoder.encode(image.heatmap || image);
205
+ }
206
+
207
+ export function pairCompatibility(build, basis, { reference = false } = {}) {
208
+ const fail = (reason) => ({ ok: false, reason });
209
+ if (!build || typeof build !== "object" || !basis || typeof basis !== "object") return fail("capture_unavailable");
210
+ if (reference) {
211
+ if (!build || basis.viewport !== build.viewport) return fail("viewport_not_captured");
212
+ if (basis.css_width == null) return fail(basis.dpr == null ? "dpr_unknown" : "width_unknown");
213
+ if (basis.dpr == null) return fail("dpr_unknown");
214
+ if (basis.css_width !== build.width) return fail("width_mismatch");
215
+ if (basis.dpr !== build.device_scale_factor) return fail("dpr_mismatch");
216
+ } else if ([build, basis].some((cell) => cell?.stabilized?.treatment?.mask_cap_reached)) return fail("mask_cap_reached");
217
+ return { ok: true, reason: null };
218
+ }
219
+ export function pageCompatibility(a, b) {
220
+ return a?.truncated || b?.truncated ? { ok: false, reason: "capture_truncated" } : { ok: true, reason: null };
221
+ }
222
+
223
+ function pixelBand(source, band, dpr) {
224
+ const top = Math.floor(band.top * dpr), bottom = Math.ceil((band.top + band.height) * dpr);
225
+ const left = Math.floor((band.left ?? 0) * dpr);
226
+ const right = Math.ceil(((band.left ?? 0) + (band.width ?? source.width / dpr)) * dpr);
227
+ return { top, left, width: right - left, height: bottom - top };
228
+ }
229
+ function insideCapture(source, band, dpr) {
230
+ const left = band.left ?? 0, width = band.width ?? source.width / dpr;
231
+ return band.top >= 0 && left >= 0
232
+ && (band.top + band.height) * dpr <= source.height && (left + width) * dpr <= source.width;
233
+ }
234
+ // A rectangle not wholly inside the decoded image is refused before any copy,
235
+ // so no zero-filled pixel is ever presented as image data.
236
+ export function crop(source, { top, left, width, height }, memory) {
237
+ boundedArea(width, height);
238
+ const image = readRgba(source, memory);
239
+ if (![top, left].every(Number.isSafeInteger) || top < 0 || left < 0
240
+ || left + width > image.width || top + height > image.height) {
241
+ release(image, memory);
242
+ throw new RangeError("invalid_image_geometry");
243
+ }
244
+ const data = allocate(width * height * 4, memory);
245
+ for (let row = 0; row < height; row++) data.set(image.data.subarray(((row + top) * image.width + left) * 4,
246
+ ((row + top) * image.width + left + width) * 4), row * width * 4);
247
+ release(image, memory);
248
+ return { width, height, data };
249
+ }
250
+ export function compareBands(basis, build, { basisSections, buildSections, rectsA = [], rectsB = [], dpr = 1, diff, cap = 40, memory, onComparison } = {}) {
251
+ boundedArea(own(basis, "width"), own(basis, "height")); boundedArea(own(build, "width"), own(build, "height"));
252
+ if (!Number.isFinite(dpr) || dpr <= 0) throw new RangeError("invalid_dpr");
253
+ if (!Number.isSafeInteger(cap) || cap < 0) throw new RangeError("invalid_band_cap");
254
+ cap = Math.min(cap, 40);
255
+ maskRects(rectsA); maskRects(rectsB);
256
+ const a = basisSections?.bands || [], b = buildSections?.bands || [];
257
+ if (!Array.isArray(a) || !Array.isArray(b) || [...a, ...b].some((band) => !band || typeof band !== "object")) throw new RangeError("invalid_band_geometry");
258
+ for (const [source, sections] of [[basis, a], [build, b]]) for (const band of sections) {
259
+ const width = band.width ?? source.width / dpr;
260
+ if (![band.top, band.height, band.left ?? 0, width].every(Number.isFinite)) throw new RangeError("invalid_band_geometry");
261
+ // Section boxes are signed, fractional CSS document coordinates. Only
262
+ // non-finite values and over-cap areas refuse the comparison (A-C1).
263
+ if (width > 0 && band.height > 0 && width * dpr * band.height * dpr > VISUAL_MAX_DECODE_PIXELS) throw new RangeError("image_too_large");
264
+ }
265
+ // Repeated bands read fresh images from caller-provided sources (normally
266
+ // capture files). No decoded originals or encoded snapshots are retained.
267
+ if ([basis, build].some((source) => typeof own(source, "load") !== "function" || source.data != null)) throw new RangeError("image_source_required");
268
+ const keys = [...new Set([...a, ...b].map((band) => band.key))].sort();
269
+ const bands = [], members = [];
270
+ const unsupported = (key, reason) => {
271
+ bands.push({ key, status: "unsupported", reason }); members.push({ key, result: "unexercised", reason_code: reason });
272
+ };
273
+ if (!keys.length) members.push({ key: "sections", result: "unexercised", reason_code: "no_section_keys" });
274
+ for (const [index, key] of keys.entries()) {
275
+ const aa = a.filter((band) => band.key === key), bb = b.filter((band) => band.key === key);
276
+ if (index >= cap) { unsupported(key, "band_cap_reached"); continue; }
277
+ if (aa.length > 1 || bb.length > 1 || [basisSections, buildSections].some((s) => s?.duplicated_keys?.includes(key))) { unsupported(key, "section_mapping_duplicate"); continue; }
278
+ if (!aa.length || !bb.length) { unsupported(key, "section_mapping_missing"); continue; }
279
+ const [sa] = aa, [sb] = bb;
280
+ if (sa.height <= 0 || sb.height <= 0 || sa.width <= 0 || sb.width <= 0) { unsupported(key, "section_not_rendered"); continue; }
281
+ if (!insideCapture(basis, sa, dpr) || !insideCapture(build, sb, dpr)) { unsupported(key, "beyond_capture_height"); continue; }
282
+ const pa = pixelBand(basis, sa, dpr), pb = pixelBand(build, sb, dpr);
283
+ if (pa.width <= 0 || pa.height <= 0 || pb.width <= 0 || pb.height <= 0) { unsupported(key, "section_not_rendered"); continue; }
284
+ const relativeRects = (rects, section) => rects.filter((r) => r.y < section.top + section.height && r.y + r.height > section.top)
285
+ .map((r) => ({ ...r, x: r.x - section.left, y: Math.max(0, r.y - section.top), height: Math.min(r.y + r.height, section.top + section.height) - Math.max(r.y, section.top) }));
286
+ const comparison = compareImages(crop(basis, pa, memory), crop(build, pb, memory), { diff, dpr, memory,
287
+ rectsA: relativeRects(rectsA, pa), rectsB: relativeRects(rectsB, pb) });
288
+ Object.assign(comparison, { key, basis: { top: sa.top, height: sa.height }, build: { top: sb.top, height: sb.height } });
289
+ try { onComparison?.(comparison); } finally { releaseComparison(comparison, memory); }
290
+ bands.push(comparison);
291
+ }
292
+ if (basisSections?.capped || buildSections?.capped) members.push({ key: "sections_cap", result: "unexercised", reason_code: "band_cap_reached" });
293
+ bands.sort((a, b) => (b.mismatch_ratio ?? -1) - (a.mismatch_ratio ?? -1)
294
+ || Math.abs(b.height_delta_px || 0) - Math.abs(a.height_delta_px || 0) || a.key.localeCompare(b.key));
295
+ return { bands, members };
296
+ }
@@ -0,0 +1,126 @@
1
+ import { openSync, readSync, closeSync, realpathSync, existsSync } from "node:fs";
2
+ import { resolve, relative, isAbsolute } from "node:path";
3
+ import { readPngStructure, boundedInflate, sha256Hex, VISUAL_MAX_DECODE_PIXELS, VISUAL_MAX_REFERENCE_BYTES } from "./png-header.mjs";
4
+
5
+ const inside = (root, path) => {
6
+ if (!root || !path) return false;
7
+ const rel = relative(root, path);
8
+ return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith("../"));
9
+ };
10
+ const real = (path) => { try { return realpathSync(path); } catch { return resolve(path); } };
11
+ const hash = (bytes) => `sha256:${sha256Hex(bytes)}`;
12
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
13
+ const own = (value, key) => object(value) && Object.hasOwn(value, key) ? value[key] : undefined;
14
+
15
+ export function referenceViewport({ ihdr, scale_factor, width, height } = {}) {
16
+ if (!Number.isFinite(scale_factor) || scale_factor <= 0) return { css_width: null, dpr: null, reason: "dpr_unknown" };
17
+ const w = ihdr?.width_px ?? width;
18
+ return { css_width: Number.isFinite(w) && w > 0 ? w / scale_factor : null,
19
+ dpr: scale_factor, reason: w > 0 ? null : "width_unknown" };
20
+ }
21
+
22
+ // linkedSource is the independently read html_file identity + provenance.
23
+ export function classifyProvenance({ path, sourceRoot, targetRepo, kind = "source_screenshot", sha256,
24
+ buildCaptureHashes = [], builtHtmlHashes = [], pageKitTargets = [], linkedSource = null,
25
+ recorded = {}, source_ref_id = own(recorded, "source_ref_id"), captured_at = own(recorded, "captured_at") } = {}) {
26
+ recorded = object(recorded) ? recorded : {};
27
+ const reasons = [];
28
+ const html = kind === "html_file" || kind === "source_html";
29
+ const resolved = path ? real(path) : null;
30
+ const paths = html ? [resolved, sourceRoot ? real(sourceRoot) : null] : [resolved];
31
+ if (targetRepo) {
32
+ if (paths.some((p) => inside(real(resolve(targetRepo, "_site")), p))) reasons.push("path_in_build_output");
33
+ if (paths.some((p) => inside(real(resolve(targetRepo, ".campaign-runtime")), p))) reasons.push("path_in_runtime_dir");
34
+ }
35
+ if (html) {
36
+ if (sha256 && new Set(builtHtmlHashes).has(sha256)) reasons.push("hash_equals_built_html");
37
+ if (resolved && pageKitTargets.some((p) => real(resolve(targetRepo || ".", p)) === resolved)) reasons.push("source_is_campaign_page");
38
+ } else {
39
+ const hex = String(sha256 || "").replace(/^sha256:/, "");
40
+ if (sha256 && (new Set(buildCaptureHashes).has(sha256) || (targetRepo && /^[a-f0-9]{64}$/.test(hex)
41
+ && ["captures", "readability", "diff"].some((dir) => existsSync(resolve(targetRepo, `.campaign-runtime/polish/${dir}/${hex}.png`)))))) reasons.push("hash_equals_package_capture");
42
+ if (linkedSource?.provenance?.class === "self_referential") reasons.push("linked_source_self_referential");
43
+ }
44
+ if (reasons.length) return { class: "self_referential", reasons };
45
+ if (!html && !captured_at) reasons.push("capture_time_unrecorded");
46
+ if (!html && (!source_ref_id || !linkedSource || !["html_file", "source_html"].includes(linkedSource.kind))) reasons.push("source_link_missing");
47
+ else {
48
+ const identity = html ? { sha256, sha256_recorded: own(recorded, "sha256") } : own(linkedSource, "identity");
49
+ // The source HTML applies the hash rules to its own html_file ref (§5.2):
50
+ // a ref that records no sha256 is unrecorded whether or not the file was
51
+ // read; a recorded ref whose file was not read is missing.
52
+ if (html && !identity.sha256_recorded) reasons.push("source_hash_unrecorded");
53
+ else if (!identity?.sha256) reasons.push("linked_source_missing");
54
+ else if (!identity.sha256_recorded) reasons.push("source_hash_unrecorded");
55
+ else if (identity.sha256_recorded !== identity.sha256) reasons.push("source_hash_mismatch");
56
+ }
57
+ return { class: reasons.length ? "unknown" : "declared_source", reasons };
58
+ }
59
+
60
+ function readBounded(path, maxBytes) {
61
+ const fd = openSync(path, "r");
62
+ try {
63
+ const chunks = [];
64
+ let length = 0;
65
+ while (length <= maxBytes) {
66
+ const chunk = Buffer.alloc(Math.min(65536, maxBytes + 1 - length));
67
+ const n = readSync(fd, chunk, 0, chunk.length, null);
68
+ if (!n) break;
69
+ chunks.push(chunk.subarray(0, n)); length += n;
70
+ }
71
+ if (length > maxBytes) return null;
72
+ return Buffer.concat(chunks, length);
73
+ } finally { closeSync(fd); }
74
+ }
75
+
76
+ export async function verifyReference({ path, url, recorded = {}, sourceRoot, targetRepo, decoder,
77
+ budget = null, kind = "source_screenshot", ...provenanceInputs } = {}) {
78
+ recorded = object(recorded) ? Object.fromEntries(Object.entries(recorded)) : {};
79
+ let resolved = null;
80
+ const identity = { sha256: null, sha256_recorded: recorded.sha256 ?? null, bytes: null,
81
+ ...Object.fromEntries(["captured_at", "browser", "device_profile", "scale_factor", "width", "height", "source_ref_id"].map((key) => [key, own(recorded, key) ?? null])) };
82
+ const result = { kind, path_rel: path ? relative(targetRepo || sourceRoot || ".", resolve(sourceRoot || ".", path)) : null, url_present: Boolean(url), identity,
83
+ verification: { level: "unverifiable", reason: null, png: null }, css_width: null, dpr: null };
84
+ const finish = (reason, level = "unverifiable") => {
85
+ result.verification.reason = reason; result.verification.level = level;
86
+ result.provenance = classifyProvenance({ ...provenanceInputs, path: resolved, sourceRoot, targetRepo, kind,
87
+ sha256: identity.sha256, recorded });
88
+ return result;
89
+ };
90
+ if (!path) return finish(url ? "remote_not_fetched" : "file_missing");
91
+ try { resolved = realpathSync(resolve(sourceRoot || ".", path)); } catch { return finish("file_missing"); }
92
+ result.path_rel = relative(targetRepo || sourceRoot || ".", resolved);
93
+ if (!inside(real(sourceRoot || "."), resolved)
94
+ && !(targetRepo && ["_site", ".campaign-runtime"].some((dir) => inside(real(resolve(targetRepo, dir)), resolved)))) return finish("path_outside_source_root");
95
+ let bytes;
96
+ try { bytes = readBounded(resolved, VISUAL_MAX_REFERENCE_BYTES); } catch { return finish("file_missing"); }
97
+ if (!bytes) return finish("too_large");
98
+ identity.bytes = bytes.length; identity.sha256 = hash(bytes);
99
+ if (["html_file", "source_html"].includes(kind)) {
100
+ // HTML needs only a successful read/hash for verification. Its declared
101
+ // source hash is a separate provenance member (§5.2), not a decode claim.
102
+ return finish(null, "decoded");
103
+ }
104
+ const structure = readPngStructure(bytes);
105
+ if (!structure.ok) return finish(structure.reason);
106
+ const ihdr = structure.ihdr;
107
+ result.verification.png = ihdr;
108
+ Object.assign(result, referenceViewport({ ihdr, ...recorded }));
109
+ delete result.reason;
110
+ if (ihdr.width_px * ihdr.height_px > VISUAL_MAX_DECODE_PIXELS) return finish("image_too_large");
111
+ const decode = typeof decoder === "function" ? decoder : own(decoder, "decode");
112
+ const available = typeof decode === "function" && (!decoder.status || decoder.status === "available");
113
+ if (available) {
114
+ if (budget && ((typeof budget.remaining === "function" && budget.remaining() <= 0)
115
+ || (Number.isFinite(budget.deadline) && (budget.clock || Date.now)() >= budget.deadline))) return finish("verify_budget_exhausted");
116
+ try {
117
+ boundedInflate(bytes, ihdr);
118
+ const image = await decode(bytes);
119
+ if (image.width !== ihdr.width_px || image.height !== ihdr.height_px) throw new Error("dimensions");
120
+ } catch { return finish("decode_failed"); }
121
+ }
122
+ if (recorded.sha256 && recorded.sha256 !== identity.sha256) return finish("recorded_hash_mismatch");
123
+ if ((recorded.width != null && recorded.width !== ihdr.width_px)
124
+ || (recorded.height != null && recorded.height !== ihdr.height_px)) return finish("recorded_dimensions_mismatch");
125
+ return finish(null, available ? "decoded" : "header_checked");
126
+ }