@nextcommerce/campaigns-os 1.59.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 (96) hide show
  1. package/CHANGELOG.md +242 -0
  2. package/README.md +11 -10
  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 +1311 -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 +42 -13
  17. package/docs/campaign-build-brief.md +17 -1
  18. package/docs/campaigns-os-build-flow.md +62 -3
  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 +41 -3
  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 +5 -5
  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 +12 -16
  44. package/skills/next-campaigns-qa/SKILL.md +3 -3
  45. package/skills/next-campaigns-sdk-bump/SKILL.md +286 -0
  46. package/skills.json +18 -10
  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 +7 -14
  52. package/src/campaign-identity.mjs +6 -4
  53. package/src/cli.mjs +89 -73
  54. package/src/design-file-reference.mjs +93 -0
  55. package/src/design-source-package.mjs +55 -1
  56. package/src/doctor/checks.mjs +14 -77
  57. package/src/doctor/ci.mjs +112 -0
  58. package/src/doctor/inspect.mjs +20 -8
  59. package/src/doctor/next-step.mjs +6 -3
  60. package/src/doctor/source-provenance.mjs +74 -0
  61. package/src/evidence-debt.mjs +119 -0
  62. package/src/orchestration-stage-contract.mjs +6 -3
  63. package/src/page-kit-build-summary.mjs +21 -1
  64. package/src/page-kit-campaign-config.mjs +19 -0
  65. package/src/parity/compare.mjs +316 -0
  66. package/src/parity/design-access.mjs +54 -0
  67. package/src/parity/element-parity-record.mjs +569 -0
  68. package/src/parity/probe.mjs +205 -0
  69. package/src/parity/snapshot.mjs +110 -0
  70. package/src/png-header.mjs +87 -0
  71. package/src/polish-browser.mjs +186 -9
  72. package/src/polish-captures-record.mjs +52 -0
  73. package/src/polish-deadline.mjs +14 -0
  74. package/src/polish-gate.mjs +26 -11
  75. package/src/polish-node.mjs +81 -7
  76. package/src/polish-visual-fixtures.mjs +74 -0
  77. package/src/polish-visual.mjs +606 -0
  78. package/src/progress-node.mjs +2 -1
  79. package/src/qa-analytics-correctness.mjs +14 -2
  80. package/src/qa-analytics-parity.mjs +94 -5
  81. package/src/qa-node.mjs +39 -6
  82. package/src/qc-accept.mjs +148 -6
  83. package/src/qc-check-registry.mjs +37 -1
  84. package/src/qc-results.mjs +404 -16
  85. package/src/readback.mjs +57 -13
  86. package/src/sdk-attribute-index.mjs +11 -11
  87. package/src/sdk-markup.mjs +77 -2
  88. package/src/sdk-storage-compatibility.mjs +94 -4
  89. package/src/source-html-manifest.mjs +7 -0
  90. package/src/source-html-server.mjs +129 -0
  91. package/src/spec-derive.mjs +24 -9
  92. package/src/stage-ledger.mjs +27 -1
  93. package/src/stage-record.mjs +63 -9
  94. package/src/theme-gate.mjs +2 -2
  95. package/src/visual-diff.mjs +296 -0
  96. package/src/visual-reference.mjs +126 -0
@@ -0,0 +1,119 @@
1
+ // Read-only build binding projection, shared by readback and other readers.
2
+ // No stage verdicts, clocks, Git metadata, processes, writes or network calls.
3
+ import { dirname, join, relative, resolve, sep } from "node:path";
4
+
5
+ import {
6
+ computeBuildFingerprint,
7
+ describeBuildOutputDrift,
8
+ pageIdForRoute,
9
+ resolveBuiltSiteScope,
10
+ routeForFile,
11
+ } from "./built-site-scope.mjs";
12
+ import { normalizePublicRouteSlug } from "./route-identity.mjs";
13
+ import { isFileReadFailure } from "./cart-placeholders.mjs";
14
+ import { qaRecordedBuildFingerprint, qaRecordedForCurrentBuild } from "./stage-ledger.mjs";
15
+
16
+ export const EVIDENCE_DEBT_STATUSES = Object.freeze(["current", "owed", "unknown"]);
17
+
18
+ const fingerprint = (value) => typeof value === "string" && value.trim() ? value.trim() : null;
19
+ const portable = (value) => value.split(sep).join("/");
20
+
21
+ /**
22
+ * Compare recorded Polish/QA build bindings with bytes under _site/<slug>.
23
+ * `changes` is explicitly against the assembly manifest, which need not be
24
+ * the build an older stage reviewed. We cannot reconstruct an older manifest.
25
+ * Missing build inputs remain unknown; lifecycle-required stages are owed
26
+ * even without a stage binding. A matching binding alone is not a verdict.
27
+ */
28
+ export function assessEvidenceDebt({ packet = null, packetPath = null, report = null } = {}) {
29
+ const assembly = report?.stages?.assembly;
30
+ const recordedBuild = fingerprint(assembly?.build_fingerprint);
31
+ // Match polish-gate's sourceBuildFingerprint precedence without reading the
32
+ // capture package (often absent in a fresh checkout).
33
+ const polishBuild = fingerprint(report?.stages?.polish?.source_build_fingerprint)
34
+ || fingerprint(report?.stages?.polish?.evidence?.source_build_fingerprint);
35
+ const qaBuild = qaRecordedBuildFingerprint(report);
36
+ let current = null;
37
+ let scope = null;
38
+ let outputRoot = null;
39
+ let reason = null;
40
+ let scopeReason = null;
41
+ const target = fingerprint(packet?.assembly?.target_repo);
42
+ const slug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
43
+ if (!packet || !packetPath) reason = "packet_unavailable";
44
+ else if (!target || !slug) reason = "build_output_location_unknown";
45
+ else {
46
+ const targetRepo = resolve(dirname(resolve(packetPath)), target);
47
+ outputRoot = join(targetRepo, "_site", slug);
48
+ try {
49
+ current = computeBuildFingerprint(outputRoot);
50
+ if (!current.ok) reason = "build_output_missing";
51
+ // Resolve only the chosen built directory: never discover another
52
+ // campaign, or fall back to source HTML when _site is absent.
53
+ else {
54
+ scope = resolveBuiltSiteScope(outputRoot);
55
+ if (!scope.ok) scopeReason = "build_output_pages_unresolved";
56
+ }
57
+ } catch (error) {
58
+ if (!isFileReadFailure(error)) throw error;
59
+ reason = "build_output_unreadable";
60
+ current = null;
61
+ }
62
+ }
63
+ const currentBuild = current?.ok ? current.fingerprint : null;
64
+ const drift = currentBuild ? describeBuildOutputDrift(assembly, current) : null;
65
+ const manifestKnown = drift?.manifest === "recorded";
66
+ // A missing manifest hides page changes, never a known stage binding.
67
+ const bindingReason = reason || (!recordedBuild ? "assembly_build_fingerprint_missing" : null);
68
+ const changesReason = bindingReason || scopeReason || (!manifestKnown ? "build_manifest_unavailable" : null);
69
+ const stage = (stageRecord, recorded, matches) => {
70
+ // src/stage-record.mjs writes required_by: build; the dependency map in
71
+ // src/input-currency.mjs supplies brief/spec for input-change demotions.
72
+ // This is lifecycle debt even when retained evidence matches the output.
73
+ const required = stageRecord?.status === "required";
74
+ const requiredReason = ["build", "brief", "spec"].includes(stageRecord?.required_by)
75
+ ? `stage_required_by_${stageRecord.required_by}` : "stage_required";
76
+ return {
77
+ status: bindingReason ? "unknown" : required ? "owed" : !recorded ? "unknown" : matches ? "current" : "owed",
78
+ matches_current_build: recorded && currentBuild ? matches : null,
79
+ recorded_build_fingerprint: recorded,
80
+ current_build_fingerprint: currentBuild,
81
+ reason: bindingReason || (required ? requiredReason : !recorded ? "stage_build_fingerprint_missing" : matches ? null : "build_fingerprint_changed"),
82
+ };
83
+ };
84
+
85
+ const changes = {
86
+ status: changesReason ? "unknown" : "known",
87
+ reason: changesReason,
88
+ recorded_build_fingerprint: recordedBuild,
89
+ current_build_fingerprint: currentBuild,
90
+ changed_pages: [],
91
+ unmapped_files: [],
92
+ extra: manifestKnown ? drift.extra : [],
93
+ missing: manifestKnown ? drift.missing : [],
94
+ changed: manifestKnown ? drift.changed : [],
95
+ };
96
+ if (manifestKnown && scope?.ok) {
97
+ const pages = new Map((scope?.pages || []).map((page) => [
98
+ portable(relative(outputRoot, page.built_path)),
99
+ { page_id: page.page_id, built_path: portable(relative(outputRoot, page.built_path)) },
100
+ ]));
101
+ // Deleted HTML no longer appears in the disk map. Reuse its route/id
102
+ // projection for those manifest entries, without reading nonexistent files.
103
+ for (const path of drift.missing) {
104
+ if (!path.toLowerCase().endsWith(".html") || path.split("/").some((part) => part.startsWith(".") || part.startsWith("_") || part === "node_modules")) continue;
105
+ pages.set(path, { page_id: pageIdForRoute(routeForFile(outputRoot, join(outputRoot, path))), built_path: path });
106
+ }
107
+ const paths = [...new Set([...drift.extra, ...drift.missing, ...drift.changed])].sort();
108
+ for (const path of paths) {
109
+ const page = pages.get(path);
110
+ if (page) changes.changed_pages.push(page);
111
+ else changes.unmapped_files.push(path);
112
+ }
113
+ }
114
+ return {
115
+ polish: stage(report?.stages?.polish, polishBuild, Boolean(polishBuild && currentBuild && polishBuild === currentBuild)),
116
+ qa: stage(report?.stages?.qa, qaBuild, qaRecordedForCurrentBuild(report, currentBuild)),
117
+ changes,
118
+ };
119
+ }
@@ -50,9 +50,12 @@ export function reportKeyForCliStage(cliStage) {
50
50
  * "completed", "completed_with_warnings", and "completed_partial" all
51
51
  * count as terminal under "completed". This matches how the existing
52
52
  * stages already report sub-statuses (see report.stages.assembly.status
53
- * shapes in src/cli.mjs and qa/shared/qa-verdict.js). Shared here so the
54
- * `next` picker, the session progress summary and the Assembly Report's own
55
- * derived summary (src/stage-ledger.mjs) read one definition of "done".
53
+ * shapes in src/cli.mjs and qa/shared/qa-verdict.js). This is the rule for a
54
+ * RECORDED status only (the session progress summary's incomplete stages,
55
+ * the prepare-build gate, doctor's checks). Whether a stage is done is
56
+ * decided on its effective status (src/input-currency.mjs), which the `next`
57
+ * picker and the Assembly Report's derived summary (src/stage-ledger.mjs)
58
+ * read: a stage owed again is not done, whatever status it records.
56
59
  */
57
60
  export const STAGE_TERMINAL_STATUS_PREFIXES = Object.freeze(["completed", "skipped"]);
58
61
 
@@ -165,9 +165,29 @@ export function evaluatePageKitBuildSummary({
165
165
  }
166
166
  }
167
167
 
168
+ // page-kit >= 0.3.0 neither builds nor copies .html under assets/ and lists
169
+ // each such file in a top-level assetWarnings array (ASSET_IGNORED), never
170
+ // in pages[].warnings. A link or iframe to one 404s on the built site, and
171
+ // this is the only signal, so it is surfaced, but never escalated: removing
172
+ // or moving the file is the author's call. Entries carry no campaignSlug;
173
+ // the file path (src/<slug>/assets/...) names the campaign.
174
+ const assetWarnings = Array.isArray(summary.assetWarnings)
175
+ ? summary.assetWarnings.filter((warning) => typeof warning?.file === "string" && `/${warning.file.replace(/\\/g, "/")}`.includes(`/${publicRouteSlug}/assets/`))
176
+ : [];
177
+ for (const warning of assetWarnings) {
178
+ flagged += 1;
179
+ warnings.push({
180
+ code: "built_output.build_asset_warning",
181
+ message:
182
+ `Page Kit did not build or copy "${warning.file}": [${warning.code}] ${warning.message}. ` +
183
+ `A link or iframe to it 404s on the built site; move it out of assets/ if it is a page, or remove it.`,
184
+ detail: { file: warning.file, warning_code: warning.code },
185
+ });
186
+ }
187
+
168
188
  if (failed === 0 && flagged === 0) {
169
189
  ready.push(
170
- `Page Kit build summary verified for "${publicRouteSlug}": ${campaignPages.length} page(s) built, no build errors or shape warnings`
190
+ `Page Kit build summary verified for "${publicRouteSlug}": ${campaignPages.length} page(s) built, no build errors, shape warnings or asset warnings`
171
191
  );
172
192
  }
173
193
 
@@ -1,9 +1,28 @@
1
1
  import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { compareVersions, extractVersion } from "./repo-scan.mjs";
3
4
  import { normalizePublicRouteSlug } from "./route-identity.mjs";
4
5
 
5
6
  export const PAGE_KIT_CAMPAIGNS_REL_PATH = "_data/campaigns.json";
6
7
 
8
+ // next-campaign-page-kit 0.3.0 stopped building or copying .html under a
9
+ // campaign's top-level assets/. Source-page discovery follows the target's
10
+ // installed page-kit, read from node_modules the way tooling setup reads it;
11
+ // a missing, unreadable or malformed install keeps the 0.2.x rule. That is
12
+ // deliberate: doctor's built-page lookup calls this from about ten checks, so
13
+ // throwing would take doctor down over a broken install that the build itself
14
+ // fails on loudly, and tooling setup already refuses a declared page-kit that
15
+ // is not installed.
16
+ export const PAGE_KIT_ASSETS_HTML_IGNORED_FROM = "0.3.0";
17
+ export function pageKitIgnoresAssetsHtml(targetRepo) {
18
+ try {
19
+ const { version } = JSON.parse(readFileSync(join(targetRepo, "node_modules", "next-campaign-page-kit", "package.json"), "utf8"));
20
+ return extractVersion(version) !== null && compareVersions(version, PAGE_KIT_ASSETS_HTML_IGNORED_FROM) >= 0;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
7
26
  // Public doctor/next/verdict evidence must never inherit arbitrary merchant
8
27
  // configuration from campaigns.json. The raw entry is validation-local; this
9
28
  // fixed projection is the only loader metadata safe to retain in artifacts.
@@ -0,0 +1,316 @@
1
+ // Pure element parity comparison, implemented from Appendix A of the P contract.
2
+ export const PARITY_COMPARATOR_VERSION = "parity-compare/v1";
3
+ export const PARITY_TOLERANCES = Object.freeze({
4
+ position_px: 6, size_px: 6, gap_px: 6,
5
+ font_size_px: 0.6, line_height_px: 2, letter_spacing_px: 0.2,
6
+ color_channel: 3, fuzzy_jaccard: 0.6, missing_copy_min_chars: 4,
7
+ });
8
+ export const PARITY_PROBE_LIMITS = Object.freeze({
9
+ elements: 1500, text_chars: 500, page_text_chars: 262144, font_faces: 200, selector_path_chars: 2048,
10
+ });
11
+
12
+ const plain = (v) => v !== null && typeof v === "object"
13
+ && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
14
+ const finite = (v) => typeof v === "number" && Number.isFinite(v);
15
+ const nonnegative = (v) => finite(v) && v >= 0;
16
+ // Rule 0 (A-P4): box values stay within ±1e6 px so drift and distances stay finite.
17
+ const coordinate = (v) => finite(v) && Math.abs(v) <= 1e6;
18
+ const extent = (v) => coordinate(v) && v >= 0;
19
+ const integer = (v, min = 0) => Number.isInteger(v) && v >= min;
20
+ const boundedText = (v, max) => typeof v === "string" && [...v].length <= max;
21
+ const normalOr = (v, numberTest) => v === "normal" || numberTest(v);
22
+ const color = (v) => v === null || (Array.isArray(v) && v.length === 3
23
+ && [...v].every((n) => finite(n) && n >= 0 && n <= 1));
24
+
25
+ export function isProbeOutput(v) {
26
+ if (!plain(v) || !plain(v.viewport) || !integer(v.viewport.width, 1) || !integer(v.viewport.height, 1)
27
+ || !finite(v.dpr) || v.dpr <= 0 || !Array.isArray(v.elements)
28
+ || v.elements.length > PARITY_PROBE_LIMITS.elements
29
+ || !boundedText(v.page_text, PARITY_PROBE_LIMITS.page_text_chars)
30
+ || !Array.isArray(v.font_faces) || v.font_faces.length > PARITY_PROBE_LIMITS.font_faces
31
+ || !plain(v.coverage)) return false;
32
+ if (!["elements_capped", "time_capped", "page_text_truncated", "font_faces_capped"]
33
+ .every((k) => typeof v.coverage[k] === "boolean")
34
+ || !integer(v.coverage.closed_shadow_roots) || !integer(v.coverage.frames_not_read)) return false;
35
+ for (const face of v.font_faces) {
36
+ if (!plain(face) || typeof face.family !== "string"
37
+ || !["unloaded", "loading", "loaded", "error"].includes(face.status)) return false;
38
+ }
39
+ const indices = new Set();
40
+ for (const e of v.elements) {
41
+ if (!plain(e) || !integer(e.index) || indices.has(e.index)
42
+ || !boundedText(e.selector_path, PARITY_PROBE_LIMITS.selector_path_chars) || e.selector_path === ""
43
+ || !boundedText(e.text, PARITY_PROBE_LIMITS.text_chars)
44
+ || !boundedText(e.ftext, PARITY_PROBE_LIMITS.text_chars)
45
+ || !plain(e.box) || !coordinate(e.box.x) || !coordinate(e.box.y) || !extent(e.box.w) || !extent(e.box.h)
46
+ || !["font_family", "font_style", "text_transform"].every((k) => typeof e[k] === "string")
47
+ || !nonnegative(e.font_size_px) || !finite(e.font_weight) || e.font_weight < 1 || e.font_weight > 1000
48
+ || !normalOr(e.line_height, nonnegative) || !normalOr(e.letter_spacing, finite)
49
+ || !color(e.fg_srgb) || !color(e.bg_srgb)
50
+ || !(e.color_review === null || (typeof e.color_review === "string" && e.color_review !== ""))) return false;
51
+ indices.add(e.index);
52
+ }
53
+ return true;
54
+ }
55
+
56
+ function validOptions(options) {
57
+ if (options === undefined) return true;
58
+ if (!plain(options) || Object.keys(options).some((k) => k !== "tolerances")) return false;
59
+ if (!Object.hasOwn(options, "tolerances")) return true;
60
+ const t = options.tolerances;
61
+ const keys = Object.keys(PARITY_TOLERANCES);
62
+ return plain(t) && Object.keys(t).length === keys.length && keys.every((k) => Object.hasOwn(t, k))
63
+ && keys.filter((k) => k.endsWith("_px") || k === "color_channel").every((k) => nonnegative(t[k]))
64
+ && finite(t.fuzzy_jaccard) && t.fuzzy_jaccard > 0 && t.fuzzy_jaccard <= 1
65
+ && integer(t.missing_copy_min_chars, 1);
66
+ }
67
+
68
+ function norm(s) {
69
+ return s.normalize("NFKC").replace(/[\u2018\u2019\u201a\u201b]/gu, "'")
70
+ .replace(/[\u201c\u201d\u201e]/gu, '"').replace(/[\u2013\u2014\u2212]/gu, "-")
71
+ .replace(/\s+/gu, " ").trim().toLowerCase();
72
+ }
73
+
74
+ function key(s) {
75
+ return norm(s).replace(/[^\p{L}\p{M}\p{N} ]/gu, " ").replace(/ +/g, " ").trim();
76
+ }
77
+
78
+ const tokens = (s) => new Set(key(s).split(" ").filter(Boolean));
79
+ function jaccard(a, b) {
80
+ if (!a.size || !b.size) return 0;
81
+ let common = 0;
82
+ for (const token of a) if (b.has(token)) common++;
83
+ return common / (a.size + b.size - common);
84
+ }
85
+
86
+ function firstFamily(s) {
87
+ let quote = null;
88
+ let entry = "";
89
+ for (let i = 0; i < s.length; i++) {
90
+ const ch = s[i];
91
+ if (ch === "\\") {
92
+ if (i + 1 < s.length) entry += s[++i];
93
+ } else if (ch === quote) {
94
+ quote = null;
95
+ } else if (!quote && (ch === '"' || ch === "'")) {
96
+ quote = ch;
97
+ } else if (!quote && ch === ",") {
98
+ const family = norm(entry);
99
+ if (family) return family;
100
+ entry = "";
101
+ } else {
102
+ entry += ch;
103
+ }
104
+ }
105
+ return norm(entry);
106
+ }
107
+
108
+ function median(values) {
109
+ if (!values.length) return 0;
110
+ const sorted = [...values].sort((a, b) => a - b);
111
+ const mid = Math.floor(sorted.length / 2);
112
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
113
+ }
114
+
115
+ function round2(v) {
116
+ const rounded = Math.round(v * 100) / 100;
117
+ return Object.is(rounded, -0) ? 0 : rounded;
118
+ }
119
+ const exceeds = (delta, tolerance) => Math.abs(delta) - tolerance > 1e-9;
120
+ const c8 = (v) => Math.round(Math.min(1, Math.max(0, v)) * 255);
121
+ const roundValue = (v) => Array.isArray(v) ? v.map(roundValue) : typeof v === "number" ? round2(v) : v;
122
+ const designOrder = (a, b) => a.e.box.y - b.e.box.y || a.e.box.x - b.e.box.x || a.e.index - b.e.index;
123
+
124
+ function emptyComparison() {
125
+ return {
126
+ comparator_version: PARITY_COMPARATOR_VERSION, status: "compared", invalid: null,
127
+ drift: { dx: 0, dy: 0, samples: 0 },
128
+ counts: { design: 0, build: 0, matched: 0,
129
+ by_pass: { exact_unique: 0, exact_nearest: 0, subtree: 0, fuzzy: 0 },
130
+ unmatched_present: 0, unmatched_missing: 0, unmatched_unverified: 0, below_min_chars: 0 },
131
+ pairs: [], findings: [],
132
+ };
133
+ }
134
+
135
+ // Cache text before matching so the 1500 × 1500 case does no repeated
136
+ // normalization or font-face/page-text scanning per candidate.
137
+ function prepared(e) {
138
+ const textKey = key(e.text);
139
+ return { e, textKey, ftextKey: key(e.ftext), tokens: tokens(e.text !== "" ? e.text : e.ftext) };
140
+ }
141
+
142
+ function groupedByText(elements) {
143
+ const groups = new Map();
144
+ for (const e of elements) {
145
+ if (!groups.has(e.textKey)) groups.set(e.textKey, []);
146
+ groups.get(e.textKey).push(e);
147
+ }
148
+ return groups;
149
+ }
150
+
151
+ function faceStatuses(faces) {
152
+ const map = new Map();
153
+ const rank = { error: 1, unloaded: 2, loading: 3, loaded: 4 };
154
+ for (const face of faces) {
155
+ const family = norm(face.family);
156
+ if (rank[face.status] > (rank[map.get(family)] ?? 0)) map.set(family, face.status);
157
+ }
158
+ return map;
159
+ }
160
+
161
+ export function compareElementParity(design, build, options) {
162
+ const out = emptyComparison();
163
+ const invalid = !validOptions(options) ? "options" : !isProbeOutput(design) ? "design"
164
+ : !isProbeOutput(build) ? "build" : null;
165
+ if (invalid) return { ...out, status: "invalid_input", invalid };
166
+ const t = options?.tolerances ?? PARITY_TOLERANCES;
167
+ const D = design.elements.map(prepared).filter((d) => d.textKey !== "").sort(designOrder);
168
+ const B = build.elements.map(prepared);
169
+ out.counts.design = D.length;
170
+ out.counts.build = B.length;
171
+ if (design.viewport.width !== build.viewport.width || design.viewport.height !== build.viewport.height || design.dpr !== build.dpr) {
172
+ out.status = "viewport_mismatch";
173
+ return out;
174
+ }
175
+ if (!D.length) {
176
+ out.status = "design_elements_unavailable";
177
+ return out;
178
+ }
179
+
180
+ const matches = new Map();
181
+ const used = new Set();
182
+ const pair = (d, b, via, similarity = null) => {
183
+ matches.set(d, { b, via, similarity });
184
+ used.add(b);
185
+ out.counts.by_pass[via]++;
186
+ };
187
+ const dg = groupedByText(D);
188
+ const bg = groupedByText(B);
189
+ const xs = [], ys = [];
190
+ for (const d of D) {
191
+ const candidates = bg.get(d.textKey);
192
+ if (dg.get(d.textKey).length === 1 && candidates?.length === 1) {
193
+ const b = candidates[0];
194
+ pair(d, b, "exact_unique");
195
+ xs.push(b.e.box.x - d.e.box.x);
196
+ ys.push(b.e.box.y - d.e.box.y);
197
+ }
198
+ }
199
+ const dx = median(xs), dy = median(ys);
200
+ out.drift = { dx: round2(dx), dy: round2(dy), samples: xs.length };
201
+ const dist = (d, b) => Math.hypot(d.e.box.x + dx - b.e.box.x, d.e.box.y + dy - b.e.box.y);
202
+ const area = (b) => Math.max(1, b.e.box.w) * Math.max(1, b.e.box.h);
203
+ for (const via of ["exact_nearest", "subtree", "fuzzy"]) {
204
+ for (const d of D) {
205
+ if (matches.has(d)) continue;
206
+ let best = null, bestPrimary = Infinity, bestDistance = Infinity, similarity = null;
207
+ const candidates = via === "exact_nearest" ? bg.get(d.textKey) ?? [] : B;
208
+ for (const b of candidates) {
209
+ if (used.has(b)) continue;
210
+ let primary = 0, score = null;
211
+ if (via === "subtree") {
212
+ if (!((b.ftextKey !== "" && b.ftextKey === d.textKey)
213
+ || (d.ftextKey !== "" && b.textKey !== "" && b.textKey === d.ftextKey))) continue;
214
+ primary = area(b);
215
+ } else if (via === "fuzzy") {
216
+ score = jaccard(d.tokens, b.tokens);
217
+ if (score < t.fuzzy_jaccard) continue;
218
+ primary = -score;
219
+ }
220
+ const distance = dist(d, b);
221
+ if (best === null || primary < bestPrimary || (primary === bestPrimary
222
+ && (distance < bestDistance || (distance === bestDistance && b.e.index < best.e.index)))) {
223
+ best = b;
224
+ bestPrimary = primary;
225
+ bestDistance = distance;
226
+ similarity = score;
227
+ }
228
+ }
229
+ if (best) pair(d, best, via, similarity === null ? null : round2(similarity));
230
+ }
231
+ }
232
+
233
+ const pageKey = ` ${key(build.page_text)} `;
234
+ const contains = (k) => pageKey.includes(` ${k} `);
235
+ const incomplete = build.coverage.page_text_truncated ? "page_text_truncated"
236
+ : build.coverage.closed_shadow_roots > 0 || build.coverage.frames_not_read > 0 ? "build_text_not_read" : null;
237
+ const designFaces = faceStatuses(design.font_faces), buildFaces = faceStatuses(build.font_faces);
238
+ const categories = { copy: [], typography: [], color: [], geometry: [] };
239
+ const ordinals = new Map();
240
+ let pred = null;
241
+ for (const d of D) {
242
+ const de = d.e;
243
+ const text = norm(de.text);
244
+ const identityKey = JSON.stringify([text, de.selector_path]);
245
+ const ordinal = ordinals.get(identityKey) ?? 0;
246
+ ordinals.set(identityKey, ordinal + 1);
247
+ const design_identity = { text, selector_path: de.selector_path, ordinal };
248
+ const match = matches.get(d);
249
+ const be = match?.b.e;
250
+ const members = { copy: [], typography: [], color: [], geometry: [] };
251
+ const add = (category, property, reason_code, dv, bv, delta = null, result = "warning") => {
252
+ members[category].push({ property, result, reason_code, design: roundValue(dv), build: roundValue(bv), delta: roundValue(delta) });
253
+ };
254
+ const changed = (category, property, dv, bv, tolerance, delta = bv - dv) => {
255
+ if (exceeds(delta, tolerance)) add(category, property, `${property}_differs`, dv, bv, delta);
256
+ };
257
+ const copy = (reason, bv, delta = null) => add("copy", "copy", reason, text, bv, delta,
258
+ incomplete ? "review" : "warning");
259
+ if (!match) {
260
+ if ([...d.textKey].length < t.missing_copy_min_chars) out.counts.below_min_chars++;
261
+ else if (contains(d.textKey)) out.counts.unmatched_present++;
262
+ else if (incomplete) {
263
+ out.counts.unmatched_unverified++;
264
+ copy(incomplete, null);
265
+ } else {
266
+ out.counts.unmatched_missing++;
267
+ copy("copy_missing", null);
268
+ }
269
+ } else {
270
+ out.pairs.push({ design: de.index, build: be.index, via: match.via, similarity: match.similarity });
271
+ if (match.via === "fuzzy" && !contains(d.textKey)) {
272
+ copy(incomplete ?? "copy_changed", norm(be.text !== "" ? be.text : be.ftext), match.similarity);
273
+ }
274
+ const df = firstFamily(de.font_family), bf = firstFamily(be.font_family);
275
+ if (df !== bf) add("typography", "font_family", "font_family_differs", df, bf);
276
+ changed("typography", "font_size", de.font_size_px, be.font_size_px, t.font_size_px);
277
+ changed("typography", "font_weight", de.font_weight, be.font_weight, 0);
278
+ if (de.font_style !== be.font_style) add("typography", "font_style", "font_style_differs", de.font_style, be.font_style);
279
+ if (typeof de.line_height === "number" && typeof be.line_height === "number") {
280
+ changed("typography", "line_height", de.line_height, be.line_height, t.line_height_px);
281
+ } else if (de.line_height !== be.line_height) {
282
+ add("typography", "line_height", "line_height_unresolved", de.line_height, be.line_height, null, "review");
283
+ }
284
+ changed("typography", "letter_spacing", de.letter_spacing, be.letter_spacing, t.letter_spacing_px,
285
+ (be.letter_spacing === "normal" ? 0 : be.letter_spacing) - (de.letter_spacing === "normal" ? 0 : de.letter_spacing));
286
+ if (de.text_transform !== be.text_transform) add("typography", "text_transform", "text_transform_differs", de.text_transform, be.text_transform);
287
+ if (designFaces.get(df) === "loaded" && buildFaces.get(df) !== "loaded") {
288
+ add("typography", "font_face", build.coverage.font_faces_capped ? "font_face_unverified" : "font_face_unavailable",
289
+ "loaded", buildFaces.get(df) ?? "absent", null, build.coverage.font_faces_capped ? "review" : "warning");
290
+ }
291
+ if (de.color_review !== null || be.color_review !== null || de.fg_srgb === null || be.fg_srgb === null) {
292
+ add("color", "color", "color_unresolved", de.color_review, be.color_review, null, "review");
293
+ } else {
294
+ for (const [property, field] of [["text_color", "fg_srgb"], ["background_color", "bg_srgb"]]) {
295
+ const dc = de[field]?.map(c8) ?? null, bc = be[field]?.map(c8) ?? null;
296
+ if (dc === null || bc === null) add("color", property, "color_unresolved", dc, bc, null, "review");
297
+ else changed("color", property, dc, bc, t.color_channel, Math.max(...dc.map((v, i) => Math.abs(bc[i] - v))));
298
+ }
299
+ }
300
+ const gapD = de.box.y - (pred ? pred.d.box.y + pred.d.box.h : 0);
301
+ const gapB = be.box.y - (pred ? pred.b.box.y + pred.b.box.h : 0);
302
+ changed("geometry", "gap", gapD, gapB, t.gap_px);
303
+ changed("geometry", "x_position", de.box.x, be.box.x, t.position_px, (be.box.x - de.box.x) - dx);
304
+ changed("geometry", "width", de.box.w, be.box.w, t.size_px);
305
+ changed("geometry", "height", de.box.h, be.box.h, t.size_px);
306
+ pred = { d: de, b: be };
307
+ }
308
+ for (const category of Object.keys(categories)) {
309
+ if (members[category].length) categories[category].push({ category, design: de.index, build: be?.index ?? null,
310
+ design_identity: { ...design_identity }, members: members[category] });
311
+ }
312
+ }
313
+ out.counts.matched = out.pairs.length;
314
+ out.findings = Object.values(categories).flat();
315
+ return out;
316
+ }
@@ -0,0 +1,54 @@
1
+ import { normalizeDesignFileReference, resolveDesignFileReferences } from "../design-file-reference.mjs";
2
+ import { buildQcResult } from "../qc-results.mjs";
3
+
4
+ const array = (value) => Array.isArray(value) ? value : [];
5
+
6
+ function recordedType(contribution, fileRef) {
7
+ const type = fileRef?.design_file_type;
8
+ if (typeof type === "string" && type.trim()) return type;
9
+ return ["figma_frames", "figma_sections"].includes(contribution?.kind) ? "figma" : "other";
10
+ }
11
+
12
+ // Recomputed from declarations and package records; a named file never proves
13
+ // access. In particular this evaluator has no pass or accept-eligible path.
14
+ export function evaluateDesignAccess({ activePages = [], mappings = [], manifest = null, designSourcePackage = null, measured_at } = {}) {
15
+ const contributions = Array.isArray(designSourcePackage?.contributions) ? designSourcePackage.contributions : [];
16
+ const surfaces = Array.isArray(designSourcePackage?.surface_identity) ? designSourcePackage.surface_identity : [];
17
+ const recordedPageIds = new Set();
18
+ const recordedByPage = new Map(activePages.map((page) => {
19
+ const surfaceIds = new Set(surfaces.filter((surface) => surface?.kind === "page" && surface.mappings?.campaign_spec_page_id === page.id).map((surface) => surface.id));
20
+ const recorded = contributions.find((contribution) => contribution?.provenance?.source_type === "design_file" && array(contribution.mappings).some((mapping) => surfaceIds.has(mapping?.surface_id)));
21
+ if (recorded && contributions.some((contribution) => array(contribution?.mappings).some((mapping) => surfaceIds.has(mapping?.surface_id) && mapping?.derived_from === recorded.id))) {
22
+ recordedPageIds.add(page.id);
23
+ }
24
+ return [page.id, recorded];
25
+ }));
26
+ const resolved = resolveDesignFileReferences({ activePages, mappings, manifest, specChannel: {
27
+ mode: "doctor",
28
+ recordedPageIds,
29
+ } });
30
+ const rows = [];
31
+ for (const page of activePages) {
32
+ const current = resolved.pages.find((entry) => entry.page_id === page.id);
33
+ const recorded = recordedByPage.get(page.id);
34
+ if (!current && !recorded) continue;
35
+ const fileRef = array(recorded?.source_refs).find((ref) => ref?.role === "design_file");
36
+ const previous = normalizeDesignFileReference({ type: recordedType(recorded, fileRef), file_url: fileRef?.url }).declaration;
37
+ const declaredSha = current?.declaration.file_identity_sha256 ?? null;
38
+ const recordedSha = previous?.file_identity_sha256 ?? null;
39
+ const reason_code = current?.conflict ? "design_reference_conflict"
40
+ : recorded && !current ? "design_reference_missing"
41
+ : recorded && declaredSha !== recordedSha ? "design_reference_changed"
42
+ : !recorded ? "design_reference_not_recorded" : "design_access_missing";
43
+ const result = ["design_reference_conflict", "design_reference_missing", "design_reference_changed"].includes(reason_code) ? "review" : "unexercised";
44
+ const state = { reason_code, recorded_identity_sha256: recordedSha, declared_identity_sha256: declaredSha, conflict_identities: [...(current?.conflict?.identities || [])].sort(), conflict_frames: [...(current?.conflict?.frames || [])].sort() };
45
+ const observation = {
46
+ type: current?.declaration.type ?? previous?.type ?? null,
47
+ kind: current?.conflict?.kind ?? null,
48
+ declared_by: current?.declared_by ?? null,
49
+ frames_declared: Object.keys(current?.declaration.frames || {}).sort(), reader: "none", recorded_contribution_id: recorded?.id ?? null,
50
+ };
51
+ rows.push({ ...buildQcResult({ check: "design.access", leg: "doctor", subject: { check: "design.access", page: page.id, key: "file" }, result, reason_code, state, observation, accept_eligible: false, measured_at }), state });
52
+ }
53
+ return rows;
54
+ }