@nextcommerce/campaigns-os 1.60.0 → 1.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/README.md +5 -5
  3. package/agents/claude/CLAUDE.md +15 -0
  4. package/agents/codex/AGENTS.md +15 -0
  5. package/agents/copilot/copilot-instructions.md +15 -0
  6. package/agents/cursor/campaigns-os.mdc +15 -0
  7. package/compatibility.json +1 -1
  8. package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
  9. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  10. package/contracts/commerce-surface-catalog.json +27 -26
  11. package/contracts/release-ledger.json +768 -0
  12. package/contracts/supported-surface.json +4 -4
  13. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  14. package/docs/access-model.md +20 -0
  15. package/docs/brand-theme-bridge.md +62 -3
  16. package/docs/build-packet.md +28 -1
  17. package/docs/campaign-build-brief.md +16 -0
  18. package/docs/campaigns-os-build-flow.md +61 -2
  19. package/docs/campaignspec-authoring-examples.md +116 -0
  20. package/docs/design-source-package.md +46 -2
  21. package/docs/entry-points.md +188 -0
  22. package/docs/local-setup.md +1 -1
  23. package/docs/orientation-contract-reference.md +1 -1
  24. package/docs/polish-evidence.md +40 -2
  25. package/docs/qa-and-test-orders.md +50 -2
  26. package/docs/quickstart.md +438 -0
  27. package/docs/readback.md +119 -6
  28. package/docs/runtime-readiness.md +1 -1
  29. package/docs/sdk-storage-compatibility.md +3 -1
  30. package/docs/skills-revision.md +10 -10
  31. package/docs/source-adapters.md +213 -0
  32. package/package.json +10 -3
  33. package/schemas/campaign-design-source-package.v0.schema.json +8 -0
  34. package/schemas/campaigns-os-readback.v2.schema.json +177 -1
  35. package/schemas/source-html-manifest.v0.schema.json +3 -0
  36. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  37. package/skills/campaign-readback-classification/SKILL.md +3 -3
  38. package/skills/campaign-run-evidence/SKILL.md +3 -3
  39. package/skills/contribution-intake/SKILL.md +3 -3
  40. package/skills/next-campaigns-build/SKILL.md +4 -4
  41. package/skills/next-campaigns-os/SKILL.md +4 -3
  42. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  43. package/skills/next-campaigns-polish/SKILL.md +6 -9
  44. package/skills/next-campaigns-qa/SKILL.md +3 -3
  45. package/skills/next-campaigns-sdk-bump/SKILL.md +117 -24
  46. package/skills.json +11 -11
  47. package/src/brand-theme.mjs +413 -8
  48. package/src/brief-summary.mjs +6 -2
  49. package/src/browser-launch.mjs +2 -1
  50. package/src/build-brief.mjs +9 -2
  51. package/src/built-site-scope.mjs +2 -2
  52. package/src/cli.mjs +52 -13
  53. package/src/design-file-reference.mjs +93 -0
  54. package/src/design-source-package.mjs +55 -1
  55. package/src/doctor/checks.mjs +8 -74
  56. package/src/doctor/ci.mjs +112 -0
  57. package/src/doctor/inspect.mjs +18 -0
  58. package/src/doctor/source-provenance.mjs +74 -0
  59. package/src/evidence-debt.mjs +119 -0
  60. package/src/parity/compare.mjs +316 -0
  61. package/src/parity/design-access.mjs +54 -0
  62. package/src/parity/element-parity-record.mjs +569 -0
  63. package/src/parity/probe.mjs +205 -0
  64. package/src/parity/snapshot.mjs +110 -0
  65. package/src/png-header.mjs +87 -0
  66. package/src/polish-browser.mjs +186 -9
  67. package/src/polish-captures-record.mjs +52 -0
  68. package/src/polish-deadline.mjs +14 -0
  69. package/src/polish-gate.mjs +25 -6
  70. package/src/polish-node.mjs +81 -7
  71. package/src/polish-visual-fixtures.mjs +74 -0
  72. package/src/polish-visual.mjs +606 -0
  73. package/src/qa-analytics-correctness.mjs +14 -2
  74. package/src/qa-analytics-parity.mjs +94 -5
  75. package/src/qa-node.mjs +39 -6
  76. package/src/qc-accept.mjs +148 -6
  77. package/src/qc-check-registry.mjs +37 -1
  78. package/src/qc-results.mjs +404 -16
  79. package/src/readback.mjs +57 -13
  80. package/src/sdk-attribute-index.mjs +11 -11
  81. package/src/sdk-markup.mjs +77 -2
  82. package/src/sdk-storage-compatibility.mjs +94 -4
  83. package/src/source-html-manifest.mjs +7 -0
  84. package/src/source-html-server.mjs +129 -0
  85. package/src/stage-record.mjs +45 -7
  86. package/src/theme-gate.mjs +2 -2
  87. package/src/visual-diff.mjs +296 -0
  88. package/src/visual-reference.mjs +126 -0
@@ -23,12 +23,12 @@
23
23
  // stylesheets must also match the built output byte for byte).
24
24
  import { createHash } from "node:crypto";
25
25
  import { existsSync, readFileSync, statSync } from "node:fs";
26
- import { dirname, join, relative, resolve, sep } from "node:path";
26
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
27
27
  import { fileURLToPath } from "node:url";
28
28
 
29
29
  import Ajv2020 from "ajv/dist/2020.js";
30
30
 
31
- import { BRAND_LAYER_FILENAMES } from "./brand-theme.mjs";
31
+ import { BRAND_FONTS_FILENAME, BRAND_LAYER_FILENAMES } from "./brand-theme.mjs";
32
32
  import { ADAPTER_DECISION_SCALAR_VALUES } from "./adapter-decision-contract.mjs";
33
33
  import { buildManifestEntries, computeBuildFingerprint, resolveBuiltSiteScope } from "./built-site-scope.mjs";
34
34
  import { resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
@@ -127,7 +127,7 @@ function typeName(value) {
127
127
 
128
128
  // visual_review keys only `polish capture` writes. `record polish --evidence`
129
129
  // refuses them by name and carries the captured values forward.
130
- export const PACKAGE_OWNED_VISUAL_REVIEW_KEYS = Object.freeze(["page_load", "media_weight", "readability"]);
130
+ export const PACKAGE_OWNED_VISUAL_REVIEW_KEYS = Object.freeze(["page_load", "media_weight", "readability", "captures", "element_parity"]);
131
131
  export const PACKAGE_OWNED_KEY_REFUSAL = "package_owned_key";
132
132
 
133
133
  export function refuseRecord(stage, problems) {
@@ -512,10 +512,24 @@ const routeKey = (route) => String(route || "").replace(/^\/+|\/+$/g, "");
512
512
  // family components, so it must load a brand layer (brand-theme.css or
513
513
  // checkout-brand.css) after it, and that file must be in the built output. A
514
514
  // page that loads neither renders the design's own markup and needs no brand
515
- // layer (docs/brand-theme-bridge.md, "Where next-core.css belongs"). Any page
515
+ // layer (docs/brand-theme-bridge.md, "Where next-core.css belongs"). When
516
+ // theme generate wrote brand-fonts.css (the design's own faces), each styled
517
+ // page must load the campaign's copy too, or the families brand-theme.css
518
+ // names fall back to the system font. Where it was written is the Build
519
+ // Context's theme.generated.fonts_css_path (theme generate --out-dir moves
520
+ // it); the default path is read only when the context names none. Any page
516
521
  // that breaks the rule is a refusal naming the page.
517
- function brandLayerFacts(doctor) {
522
+ function brandLayerFacts(doctor, context = null) {
518
523
  const derived = doctor.derived || {};
524
+ const recordedFonts = context?.theme?.generated?.fonts_css_path;
525
+ const generatedFonts = typeof recordedFonts === "string" && recordedFonts
526
+ ? resolve(derived.target_repo, recordedFonts)
527
+ : (context?.theme?.generated ? null : resolve(derived.target_repo, ".campaign-runtime/theme", BRAND_FONTS_FILENAME));
528
+ const fontsRequired = Boolean(generatedFonts) && existsSync(generatedFonts);
529
+ const insideSite = (file) => {
530
+ const rel = relative(site.site_root, file);
531
+ return Boolean(rel) && !rel.startsWith("..") && !isAbsolute(rel);
532
+ };
519
533
  const commerce = commerceScopeFromScope(derived.scope);
520
534
  if (!commerce.all.length) {
521
535
  throw refuseRecord("theme", ["The campaign ships no commerce pages, so there is no brand layer to record; the theme gate does not apply."]);
@@ -566,6 +580,30 @@ function brandLayerFacts(doctor) {
566
580
  problems.push(`${label}: ${builtRel} links ${hrefs[brand]}, which is not in the built output.`);
567
581
  continue;
568
582
  }
583
+ // brand-fonts.css holds only @font-face rules, so unlike the brand layer
584
+ // its position relative to next-core.css does not matter.
585
+ if (fontsRequired) {
586
+ const fontsHref = hrefs.find((candidate) => hrefName(candidate) === BRAND_FONTS_FILENAME);
587
+ const bare = fontsHref ? fontsHref.split(/[?#]/)[0] : null;
588
+ const fontsFile = bare && !/^(?:[a-z][a-z0-9+.-]*:)?\/\//i.test(bare)
589
+ ? (bare.startsWith("/") ? join(site.site_root, bare) : resolve(dirname(built.built_path), bare))
590
+ : null;
591
+ const generatedRel = relative(derived.target_repo, generatedFonts);
592
+ const problem = !fontsHref
593
+ ? `${label}: ${builtRel} links no ${BRAND_FONTS_FILENAME}. Theme generate wrote the design's font faces to ${generatedRel}; copy it into the campaign assets/css folder beside the brand layer, list it in the page's frontmatter styles and rebuild.`
594
+ : !fontsFile || !insideSite(fontsFile)
595
+ ? `${label}: ${builtRel} links ${fontsHref}, which is not a file in this campaign's built output; link the campaign's own copy of ${generatedRel}.`
596
+ : !existsSync(fontsFile)
597
+ ? `${label}: ${builtRel} links ${fontsHref}, which is not in the built output.`
598
+ : readFileSync(fontsFile, "utf8") !== readFileSync(generatedFonts, "utf8")
599
+ ? `${label}: ${builtRel} links ${fontsHref}, which differs from the generated ${generatedRel}; copy the generated file and rebuild.`
600
+ : null;
601
+ if (problem) {
602
+ problems.push(problem);
603
+ continue;
604
+ }
605
+ evidence.push(`${label}: ${builtRel} loads ${relative(derived.target_repo, fontsFile)}, identical to the generated ${generatedRel}.`);
606
+ }
569
607
  layers.push(file);
570
608
  styled.push(label);
571
609
  evidence.push(`${label}: ${builtRel} loads ${relative(derived.target_repo, file)} after ${CORE_STYLESHEET}.`);
@@ -865,7 +903,7 @@ function validateRecord(stage, { report, context, packet, fingerprint }) {
865
903
  // over the report this record would write. A blocked or skipped Polish is
866
904
  // not a pass the gate could grant; doctor keeps QA blocked on it.
867
905
  const hiddenEagerMediaGate = evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report });
868
- const gate = evaluatePolishGate({ report, hiddenEagerMediaGate, currentOutputFingerprint: fingerprint });
906
+ const gate = evaluatePolishGate({ report, hiddenEagerMediaGate, currentOutputFingerprint: fingerprint, newEvidence: true });
869
907
  if (gate.status === "blocked") {
870
908
  problems.push(`${gate.code}: ${gate.reason}`);
871
909
  for (const problem of gate.problems || []) problems.push(problem);
@@ -1180,7 +1218,7 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
1180
1218
  }
1181
1219
  facts = doctorFacts(stage, doctor, report, packet);
1182
1220
  if (typeof afterDoctorRead === "function") afterDoctorRead();
1183
- if (stage === "theme") layer = brandLayerFacts(doctor);
1221
+ if (stage === "theme") layer = brandLayerFacts(doctor, readJsonIfExists(contextPath));
1184
1222
  if (stage === "deploy") deployFacts(doctor, packet, input, facts.fingerprint);
1185
1223
  const next = stage === "setup"
1186
1224
  ? composeSetup(report, context, { now: timestamp, recordedBy })
@@ -166,13 +166,13 @@ export function evaluateThemeGate({ reportTheme = null, contextTheme = null, sco
166
166
  id: "theme_generate",
167
167
  kind: "command",
168
168
  command: `campaigns-os theme generate --packet ${packetArg}`,
169
- description: "Generate .campaign-runtime/theme/brand-theme.css from the inspected source tokens.",
169
+ description: "Generate .campaign-runtime/theme/brand-theme.css from the inspected source tokens, and brand-fonts.css beside it when the design source ships the faces its headings, body copy or buttons use.",
170
170
  },
171
171
  {
172
172
  id: "apply_brand_layer",
173
173
  kind: "manual",
174
174
  command: null,
175
- description: `Copy brand-theme.css into the campaign assets/css folder, list it after next-core.css in the frontmatter styles of the commerce pages that render family components, rebuild, run campaigns-os record build, then campaigns-os record theme --packet ${packetArg}, which records report.theme from the built pages.`,
175
+ description: `Copy brand-theme.css (and brand-fonts.css, when theme generate wrote one) into the campaign assets/css folder, list brand-theme.css after next-core.css, and brand-fonts.css anywhere, in the frontmatter styles of the commerce pages that render family components, rebuild, run campaigns-os record build, then campaigns-os record theme --packet ${packetArg}, which records report.theme from the built pages.`,
176
176
  },
177
177
  {
178
178
  id: "waive_theme",
@@ -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
+ }