@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
@@ -22,6 +22,9 @@ import { normalizePublicRouteSlug } from "./route-identity.mjs";
22
22
 
23
23
  export const LOCAL_PREVIEW_POLICY = "local_preview";
24
24
  export const HOSTED_TEMPLATE_PREVIEW_POLICY = "hosted_template_preview";
25
+ // A page-only change since Polish last reviewed the campaign (#720): Polish
26
+ // is advisory for it, on any target, so its stale evidence is carried.
27
+ export const PAGE_ONLY_CHANGE_POLICY = "page_only_change";
25
28
  export const CARRIED_FORWARD = "carried_forward";
26
29
 
27
30
  // The checks carried forward, one by one. Each is evidence the campaign does
@@ -136,7 +139,9 @@ export function carried(gate, policy = LOCAL_PREVIEW_POLICY, packet = null) {
136
139
  ...(policy === HOSTED_TEMPLATE_PREVIEW_POLICY
137
140
  ? { required_actions: [hostedTemplateQaAction(previewUrl)] }
138
141
  : {}),
139
- carried_forward: { policy, from_status: gate.status, evidence: "missing" },
142
+ // A page-only carry stands on a review of an older build, so its evidence
143
+ // is stale, except where no Polish was ever recorded.
144
+ carried_forward: { policy, from_status: gate.status, evidence: policy === PAGE_ONLY_CHANGE_POLICY && gate.code !== "polish.evidence_missing" ? "stale" : "missing" },
140
145
  };
141
146
  }
142
147
 
@@ -156,13 +161,38 @@ export function applyLocalPreviewToCheckpoint(gate, { packet, report, baseUrl =
156
161
  }
157
162
 
158
163
  // The polish gate folds the checkpoint in, so it is carried forward only when
159
- // whatever it owns from the checkpoint was carried forward too.
164
+ // whatever it owns from the checkpoint was carried forward too. Advisory
165
+ // Polish for a page-only change is never a ladder step.
160
166
  export function polishCarriedForwardForLadder(report, gate) {
167
+ if (gate?.status === CARRIED_FORWARD && gate.carried_forward?.policy === PAGE_ONLY_CHANGE_POLICY) return true;
161
168
  return gate?.status === CARRIED_FORWARD
162
169
  && !report?.theme?.waiver
163
170
  && !report?.stages?.polish?.evidence?.visual_review?.page_load;
164
171
  }
165
172
 
173
+ // The Polish gate codes and checkpoint codes that mean "evidence for an older
174
+ // build": the only blocks a page-only change carries. A Polish recorded as
175
+ // blocked, self-certified or incomplete stays a block.
176
+ const PAGE_ONLY_POLISH_CODES = Object.freeze(new Set(["polish.evidence_missing", "polish.stale", "polish.output_drift"]));
177
+ const PAGE_ONLY_CHECKPOINT_CODES = Object.freeze(new Set(["polish.hidden_eager_media.capture_stale"]));
178
+
179
+ export function isPageOnlyCarry(gate) {
180
+ return gate?.status === CARRIED_FORWARD && gate.carried_forward?.policy === PAGE_ONLY_CHANGE_POLICY;
181
+ }
182
+
183
+ // `debt` is assessEvidenceDebt's result for the same packet and report.
184
+ export function applyPageOnlyChangeToCheckpoint(gate, debt) {
185
+ if (gate?.status !== "blocked" || debt?.polish?.advisory !== true || !PAGE_ONLY_CHECKPOINT_CODES.has(gate.code)) return gate;
186
+ return carried(gate, PAGE_ONLY_CHANGE_POLICY);
187
+ }
188
+
189
+ export function applyPageOnlyChangeToPolishGate(gate, { debt, checkpointGate = null } = {}) {
190
+ if (gate?.status !== "blocked" || debt?.polish?.advisory !== true) return gate;
191
+ if (gate.owned_checkpoint_only) return isPageOnlyCarry(checkpointGate) ? carried(gate, PAGE_ONLY_CHANGE_POLICY) : gate;
192
+ if (gate.owned_checkpoint_status === "blocked" && !isPageOnlyCarry(checkpointGate) && checkpointGate?.status !== CARRIED_FORWARD) return gate;
193
+ return PAGE_ONLY_POLISH_CODES.has(gate.code) ? carried(gate, PAGE_ONLY_CHANGE_POLICY) : gate;
194
+ }
195
+
166
196
  export function applyLocalPreviewToPolishGate(gate, { packet, checkpointGate = null, baseUrl = null } = {}) {
167
197
  if (gate?.status !== "blocked") return gate;
168
198
  // A template-stock map has no route that capture can measure, even when
@@ -189,6 +219,9 @@ export function starterResidueIsExpected(themeGate, { packet, baseUrl = null } =
189
219
  }
190
220
 
191
221
  export function carriedForwardMessage(gate) {
222
+ if (gate?.carried_forward?.policy === PAGE_ONLY_CHANGE_POLICY) {
223
+ return `Advisory for a page-only change: ${gate.reason} The change since Polish last reviewed this campaign touches only page copy, images or styling, so Polish is reported and not required; run it when the change needs a visual review.`;
224
+ }
192
225
  if (gate?.carried_forward?.policy === HOSTED_TEMPLATE_PREVIEW_POLICY) {
193
226
  return `Carried forward on the hosted preview: ${gate.reason} This evidence is missing, not passed; production QA still requires it.`;
194
227
  }
@@ -0,0 +1,132 @@
1
+ // Doctor's advisory read of the starter-template vendor keys (#689): whether
2
+ // the vendors the CampaignSpec declares under analytics.vendors and the
3
+ // vendors the target's _data/campaigns.json entry switches on agree.
4
+ //
5
+ // A vendor adapter loads when its id key in the entry is non-empty (absent or
6
+ // empty is off), so the entry is what ships. The spec is what the Map says
7
+ // should ship, and spec derive mirrors the id from the entry back into it.
8
+ // Every finding is a warning: a vendor that is off, on by accident or pointed
9
+ // at a placeholder account loses or pollutes attribution, but never an order,
10
+ // and the operator may have a reason. Read-only: nothing is written.
11
+ import { ANALYTICS_VENDORS, isPlaceholderVendorId } from "../campaign-spec/dist/index.js";
12
+ import { PAGE_KIT_CAMPAIGNS_REL_PATH } from "./page-kit-campaign-config.mjs";
13
+ import { SPEC_DERIVE_COMMAND } from "./page-kit-sdk-version.mjs";
14
+
15
+ export const PAGE_KIT_ANALYTICS_VENDORS_SCOPE = "page_kit.analytics_vendors";
16
+
17
+ function isPlainObject(value) {
18
+ return value !== null && typeof value === "object" && !Array.isArray(value);
19
+ }
20
+
21
+ // The analytics kill switch the Map Builder honours: with it set, the spec
22
+ // compiler writes no vendor key, so the spec declares no vendor.
23
+ function analyticsDisabled(analytics) {
24
+ return isPlainObject(analytics) && (analytics.enabled === false || analytics.mode === "disabled");
25
+ }
26
+
27
+ // The template gate is `campaign.<key> and campaign.<key> != ""`: any value
28
+ // but an absent, null, false or blank one switches the vendor on.
29
+ function keySwitchesVendorOn(value) {
30
+ if (value === undefined || value === null || value === false) return false;
31
+ return typeof value !== "string" || value.trim() !== "";
32
+ }
33
+
34
+ // The vendors the spec declares, keyed by vendor: an object entry not turned
35
+ // off with enabled:false. A declared vendor with no id is still declared (the
36
+ // AnalyticsContractShape rule names the missing id).
37
+ function declaredVendors(analytics) {
38
+ const declared = new Map();
39
+ if (!isPlainObject(analytics) || analyticsDisabled(analytics) || !isPlainObject(analytics.vendors)) return declared;
40
+ for (const { key } of ANALYTICS_VENDORS) {
41
+ const vendor = analytics.vendors[key];
42
+ if (isPlainObject(vendor) && vendor.enabled !== false) declared.set(key, vendor);
43
+ }
44
+ return declared;
45
+ }
46
+
47
+ // Compare the spec's vendors with the target entry. `targetLoad` is the
48
+ // loadPageKitCampaignEntry result doctor already holds. Returns the vendors
49
+ // either side switches on and one finding per disagreement; findings name keys
50
+ // and vendors only, never a value read from campaigns.json.
51
+ export function evaluatePageKitAnalyticsVendors({ spec, targetLoad }) {
52
+ // Without a readable spec there is nothing to compare the entry with; the
53
+ // spec checks report why it is unreadable.
54
+ if (!isPlainObject(spec) || targetLoad?.status !== "ok" || !isPlainObject(targetLoad.entry)) {
55
+ return { scope: PAGE_KIT_ANALYTICS_VENDORS_SCOPE, status: "not_applicable", vendors: [], findings: [] };
56
+ }
57
+ const entry = targetLoad.entry;
58
+ const slug = targetLoad.public_route_slug;
59
+ const source = (key) => `${PAGE_KIT_CAMPAIGNS_REL_PATH}[${slug}].${key}`;
60
+ const analytics = spec?.analytics;
61
+ const disabled = analyticsDisabled(analytics);
62
+ const declared = declaredVendors(analytics);
63
+ const vendors = [];
64
+ const findings = [];
65
+ const finding = (code, def, message, repair) => findings.push({
66
+ code: `${PAGE_KIT_ANALYTICS_VENDORS_SCOPE}.${code}`,
67
+ vendor: def.key,
68
+ id_key: def.idKey,
69
+ message,
70
+ repair,
71
+ });
72
+
73
+ for (const def of ANALYTICS_VENDORS) {
74
+ const field = `analytics.vendors.${def.key}`;
75
+ const raw = Object.hasOwn(entry, def.idKey) ? entry[def.idKey] : undefined;
76
+ const on = keySwitchesVendorOn(raw);
77
+ const specVendor = declared.get(def.key);
78
+ if (on || specVendor) vendors.push(def.key);
79
+ if (specVendor && !on) {
80
+ finding(
81
+ "key_missing",
82
+ def,
83
+ `The CampaignSpec declares ${field}, but ${source(def.idKey)} is absent or empty, so the template never loads ${def.label} and it receives no events. Set the key (the Map's spec compiler writes it), or remove the vendor from the Map if the campaign should not send to ${def.label}.`,
84
+ { kind: "edit", target: source(def.idKey) },
85
+ );
86
+ continue;
87
+ }
88
+ if (!specVendor && on) {
89
+ // spec derive can record an undeclared vendor; it cannot undo an
90
+ // authored "off", so that case is pointed at the Map instead.
91
+ const turnedOff = disabled
92
+ ? "turns analytics off (analytics.enabled false or mode disabled)"
93
+ : isPlainObject(analytics?.vendors?.[def.key]) ? `turns ${field} off (enabled: false)` : null;
94
+ finding(
95
+ "undeclared",
96
+ def,
97
+ turnedOff
98
+ ? `${source(def.idKey)} switches ${def.label} on, but the CampaignSpec ${turnedOff}. The template loads ${def.label} on every page regardless. Clear the key if ${def.label} should be off, or turn it back on in the Map.`
99
+ : `${source(def.idKey)} switches ${def.label} on, but the CampaignSpec does not declare ${field}. The template loads ${def.label} on every page. Record it in the spec with \`${SPEC_DERIVE_COMMAND}\` (or in the Map), or clear the key if ${def.label} should be off.`,
100
+ turnedOff ? { kind: "edit", target: source(def.idKey) } : { kind: "command", command: SPEC_DERIVE_COMMAND },
101
+ );
102
+ continue;
103
+ }
104
+ if (!specVendor) continue;
105
+ const value = typeof raw === "string" ? raw.trim() : null;
106
+ if (value !== null && isPlaceholderVendorId(value)) {
107
+ finding(
108
+ "key_placeholder",
109
+ def,
110
+ `${source(def.idKey)} holds a placeholder, not a real ${def.label} id. The template loads ${def.label} for any non-empty value, so events go to an account that does not exist. Replace it with the merchant's id.`,
111
+ { kind: "edit", target: source(def.idKey) },
112
+ );
113
+ continue;
114
+ }
115
+ const specId = typeof specVendor.id === "string" ? specVendor.id.trim() : "";
116
+ if (specId && value !== null && value !== specId) {
117
+ finding(
118
+ "id_mismatch",
119
+ def,
120
+ `${source(def.idKey)} and ${field}.id name different ${def.label} ids. The campaigns.json value is what ships; \`${SPEC_DERIVE_COMMAND}\` mirrors it into the spec, or correct the key if the spec is right.`,
121
+ { kind: "command", command: SPEC_DERIVE_COMMAND },
122
+ );
123
+ }
124
+ }
125
+
126
+ return {
127
+ scope: PAGE_KIT_ANALYTICS_VENDORS_SCOPE,
128
+ status: findings.length ? "advisory" : "ok",
129
+ vendors,
130
+ findings,
131
+ };
132
+ }
@@ -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
+ }