@nextcommerce/campaigns-os 1.33.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 (310) hide show
  1. package/AGENTS.md +204 -0
  2. package/CHANGELOG.md +5002 -0
  3. package/CONTEXT.md +685 -0
  4. package/LICENSE +202 -0
  5. package/NOTICE +4 -0
  6. package/README.md +368 -0
  7. package/agents/claude/CLAUDE.md +32 -0
  8. package/agents/codex/AGENTS.md +27 -0
  9. package/agents/copilot/copilot-instructions.md +14 -0
  10. package/agents/cursor/campaigns-os.mdc +13 -0
  11. package/bin/campaigns-os.mjs +38 -0
  12. package/campaign-spec/README.md +138 -0
  13. package/campaign-spec/dist/analytics-vocabulary.d.ts +47 -0
  14. package/campaign-spec/dist/analytics-vocabulary.js +74 -0
  15. package/campaign-spec/dist/index.d.ts +40 -0
  16. package/campaign-spec/dist/index.js +77 -0
  17. package/campaign-spec/dist/normalize.d.ts +22 -0
  18. package/campaign-spec/dist/normalize.js +41 -0
  19. package/campaign-spec/dist/routing.d.ts +190 -0
  20. package/campaign-spec/dist/routing.js +263 -0
  21. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +42 -0
  22. package/campaign-spec/dist/rules/analytics-contract-shape.js +303 -0
  23. package/campaign-spec/dist/rules/assembly-hints-shape.d.ts +42 -0
  24. package/campaign-spec/dist/rules/assembly-hints-shape.js +191 -0
  25. package/campaign-spec/dist/rules/campaign-metadata.d.ts +14 -0
  26. package/campaign-spec/dist/rules/campaign-metadata.js +40 -0
  27. package/campaign-spec/dist/rules/checkout-has-success-url.d.ts +31 -0
  28. package/campaign-spec/dist/rules/checkout-has-success-url.js +66 -0
  29. package/campaign-spec/dist/rules/cycle-detection.d.ts +12 -0
  30. package/campaign-spec/dist/rules/cycle-detection.js +141 -0
  31. package/campaign-spec/dist/rules/design-source-shape.d.ts +29 -0
  32. package/campaign-spec/dist/rules/design-source-shape.js +142 -0
  33. package/campaign-spec/dist/rules/downsell-without-upsell.d.ts +11 -0
  34. package/campaign-spec/dist/rules/downsell-without-upsell.js +40 -0
  35. package/campaign-spec/dist/rules/exit-intent-validation.d.ts +23 -0
  36. package/campaign-spec/dist/rules/exit-intent-validation.js +147 -0
  37. package/campaign-spec/dist/rules/funnel-count.d.ts +9 -0
  38. package/campaign-spec/dist/rules/funnel-count.js +37 -0
  39. package/campaign-spec/dist/rules/funnel-hypothesis-length.d.ts +22 -0
  40. package/campaign-spec/dist/rules/funnel-hypothesis-length.js +60 -0
  41. package/campaign-spec/dist/rules/funnel-identity.d.ts +15 -0
  42. package/campaign-spec/dist/rules/funnel-identity.js +57 -0
  43. package/campaign-spec/dist/rules/funnel-weight-sum.d.ts +20 -0
  44. package/campaign-spec/dist/rules/funnel-weight-sum.js +66 -0
  45. package/campaign-spec/dist/rules/index.d.ts +69 -0
  46. package/campaign-spec/dist/rules/index.js +131 -0
  47. package/campaign-spec/dist/rules/offer-ref-integrity.d.ts +13 -0
  48. package/campaign-spec/dist/rules/offer-ref-integrity.js +59 -0
  49. package/campaign-spec/dist/rules/package-pricing-sanity.d.ts +12 -0
  50. package/campaign-spec/dist/rules/package-pricing-sanity.js +42 -0
  51. package/campaign-spec/dist/rules/page-count.d.ts +11 -0
  52. package/campaign-spec/dist/rules/page-count.js +31 -0
  53. package/campaign-spec/dist/rules/page-id-uniqueness.d.ts +14 -0
  54. package/campaign-spec/dist/rules/page-id-uniqueness.js +47 -0
  55. package/campaign-spec/dist/rules/promo-code-input-validation.d.ts +8 -0
  56. package/campaign-spec/dist/rules/promo-code-input-validation.js +126 -0
  57. package/campaign-spec/dist/rules/promo-codes-shape.d.ts +30 -0
  58. package/campaign-spec/dist/rules/promo-codes-shape.js +187 -0
  59. package/campaign-spec/dist/rules/route-field-ignored-for-page-type.d.ts +33 -0
  60. package/campaign-spec/dist/rules/route-field-ignored-for-page-type.js +81 -0
  61. package/campaign-spec/dist/rules/route-target-resolves.d.ts +32 -0
  62. package/campaign-spec/dist/rules/route-target-resolves.js +112 -0
  63. package/campaign-spec/dist/rules/schema-version.d.ts +21 -0
  64. package/campaign-spec/dist/rules/schema-version.js +53 -0
  65. package/campaign-spec/dist/rules/sdk-version.d.ts +26 -0
  66. package/campaign-spec/dist/rules/sdk-version.js +96 -0
  67. package/campaign-spec/dist/rules/shipping-countries-shape.d.ts +10 -0
  68. package/campaign-spec/dist/rules/shipping-countries-shape.js +30 -0
  69. package/campaign-spec/dist/rules/shipping-methods-present.d.ts +10 -0
  70. package/campaign-spec/dist/rules/shipping-methods-present.js +26 -0
  71. package/campaign-spec/dist/rules/store-profile-shape.d.ts +30 -0
  72. package/campaign-spec/dist/rules/store-profile-shape.js +127 -0
  73. package/campaign-spec/dist/rules/thank-you-requirement.d.ts +16 -0
  74. package/campaign-spec/dist/rules/thank-you-requirement.js +50 -0
  75. package/campaign-spec/dist/rules/unknown-top-level-fields.d.ts +28 -0
  76. package/campaign-spec/dist/rules/unknown-top-level-fields.js +114 -0
  77. package/campaign-spec/dist/rules/upsell-has-packages.d.ts +9 -0
  78. package/campaign-spec/dist/rules/upsell-has-packages.js +35 -0
  79. package/campaign-spec/dist/rules/upsell-routing-complete.d.ts +10 -0
  80. package/campaign-spec/dist/rules/upsell-routing-complete.js +45 -0
  81. package/campaign-spec/dist/rules/upsell-without-checkout.d.ts +11 -0
  82. package/campaign-spec/dist/rules/upsell-without-checkout.js +44 -0
  83. package/campaign-spec/dist/rules/variant-labels-shape.d.ts +28 -0
  84. package/campaign-spec/dist/rules/variant-labels-shape.js +86 -0
  85. package/campaign-spec/dist/sdk-version-parse.d.ts +43 -0
  86. package/campaign-spec/dist/sdk-version-parse.js +62 -0
  87. package/campaign-spec/dist/types.d.ts +674 -0
  88. package/campaign-spec/dist/types.js +40 -0
  89. package/campaign-spec/package.json +12 -0
  90. package/compatibility.json +25 -0
  91. package/contracts/agent-relevant-change-policy.v1.json +111 -0
  92. package/contracts/brand-theme-source-defaults.figma-sections-export.v0.json +32 -0
  93. package/contracts/brand-theme-target-tokens.next-core.v0.json +65 -0
  94. package/contracts/campaign-cart-checkout-field-contract.v0.json +45 -0
  95. package/contracts/campaign-cart-sdk-support-policy.v0.json +11 -0
  96. package/contracts/commerce-surface-catalog.json +2452 -0
  97. package/contracts/fixtures/orientation/canonicalization/v1.json +34 -0
  98. package/contracts/fixtures/orientation/envelope/current.json +95 -0
  99. package/contracts/fixtures/orientation/envelope/freshness_unknown.json +97 -0
  100. package/contracts/fixtures/orientation/envelope/legacy_baseline.json +95 -0
  101. package/contracts/fixtures/orientation/envelope/orientation_available.json +136 -0
  102. package/contracts/fixtures/orientation/envelope/recovered_interrupted_update.json +136 -0
  103. package/contracts/fixtures/orientation/envelope/refused.json +100 -0
  104. package/contracts/fixtures/orientation/envelope/restart_required.json +137 -0
  105. package/contracts/fixtures/orientation/envelope/updated.json +135 -0
  106. package/contracts/fixtures/orientation/hostile-target/README.md +61 -0
  107. package/contracts/fixtures/orientation/hostile-target/manifest.json +82 -0
  108. package/contracts/fixtures/orientation/hostile-target/repo/CHANGELOG.md +18 -0
  109. package/contracts/fixtures/orientation/hostile-target/repo/bin/intended.mjs +12 -0
  110. package/contracts/fixtures/orientation/hostile-target/repo/bin/tripwire.mjs +15 -0
  111. package/contracts/fixtures/orientation/hostile-target/repo/contracts/release-ledger.json +48 -0
  112. package/contracts/fixtures/orientation/hostile-target/repo/contracts/supported-surface.json +17 -0
  113. package/contracts/fixtures/orientation/hostile-target/repo/docs/example-contract.md +13 -0
  114. package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-checkout +5 -0
  115. package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-merge +5 -0
  116. package/contracts/fixtures/orientation/hostile-target/repo/hooks/pre-commit +5 -0
  117. package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/package.json +15 -0
  118. package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/tripwire.mjs +9 -0
  119. package/contracts/fixtures/orientation/hostile-target/repo/package.json +20 -0
  120. package/contracts/fixtures/orientation/hostile-target/repo/schemas/example.v0.schema.json +15 -0
  121. package/contracts/fixtures/orientation/release-gate/cases.json +1073 -0
  122. package/contracts/fixtures/runtime-recipe/accept/current.json +299 -0
  123. package/contracts/fixtures/runtime-recipe/accept/minimal.json +294 -0
  124. package/contracts/fixtures/runtime-recipe/dist-states.json +51 -0
  125. package/contracts/fixtures/runtime-recipe/manifest.json +85 -0
  126. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +299 -0
  127. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +297 -0
  128. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +299 -0
  129. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +299 -0
  130. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +299 -0
  131. package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +251 -0
  132. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +299 -0
  133. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +299 -0
  134. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +310 -0
  135. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +299 -0
  136. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +299 -0
  137. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +299 -0
  138. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +299 -0
  139. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/assembly-report.json +180 -0
  140. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/build-context.json +115 -0
  141. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/doctor-output.json +29 -0
  142. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/qa-verdict.json +27 -0
  143. package/contracts/fixtures/sidecar-bundle/production-shaped/campaign-runtime.build.json +141 -0
  144. package/contracts/migration-sidecar-bundle.v0.json +149 -0
  145. package/contracts/orientation-limits.v1.json +41 -0
  146. package/contracts/orientation-reason-codes.v1.json +196 -0
  147. package/contracts/private-template-sources.json +8 -0
  148. package/contracts/release-ledger.json +4598 -0
  149. package/contracts/reserved-skill-names.json +13 -0
  150. package/contracts/runtime-recipe.campaigns-os-node-v1.json +299 -0
  151. package/contracts/supported-surface.json +180 -0
  152. package/contracts/template-brand-contract.apollo-mv-single-step.v0.json +27 -0
  153. package/contracts/template-brand-contract.apollo.v0.json +27 -0
  154. package/contracts/template-brand-contract.demeter.v0.json +27 -0
  155. package/contracts/template-brand-contract.olympus-mv-single-step.v0.json +27 -0
  156. package/contracts/template-brand-contract.olympus-mv-two-step.v0.json +28 -0
  157. package/contracts/template-brand-contract.olympus.v0.json +27 -0
  158. package/contracts/template-brand-contract.shared-commerce.v0.json +190 -0
  159. package/contracts/template-brand-contract.shop-single-step.v0.json +27 -0
  160. package/contracts/template-brand-contract.shop-three-step.v0.json +29 -0
  161. package/contracts/template-slot-manifest.apollo-mv-single-step.v0.json +14 -0
  162. package/contracts/template-slot-manifest.apollo.v0.json +12 -0
  163. package/contracts/template-slot-manifest.demeter.v0.json +30 -0
  164. package/contracts/template-slot-manifest.olympus-mv-single-step.v0.json +14 -0
  165. package/contracts/template-slot-manifest.olympus-mv-two-step.v0.json +15 -0
  166. package/contracts/template-slot-manifest.olympus.v0.json +12 -0
  167. package/contracts/template-slot-manifest.shared-content-core.v0.json +4254 -0
  168. package/contracts/template-slot-manifest.shop-single-step.v0.json +47 -0
  169. package/contracts/template-slot-manifest.shop-three-step.v0.json +33 -0
  170. package/docs/brand-theme-bridge.md +159 -0
  171. package/docs/build-packet.md +1300 -0
  172. package/docs/campaign-build-brief.md +145 -0
  173. package/docs/campaign-standardization-report.md +329 -0
  174. package/docs/campaigns-os-build-flow.md +117 -0
  175. package/docs/design-source-package.md +784 -0
  176. package/docs/legacy-migration.md +58 -0
  177. package/docs/migration-sidecar-bundle.md +139 -0
  178. package/docs/orientation-contract-reference.md +1220 -0
  179. package/docs/polish-evidence.md +502 -0
  180. package/docs/qa-and-test-orders.md +1691 -0
  181. package/docs/release-ledger-authoring-guide.md +274 -0
  182. package/docs/runtime-readiness.md +211 -0
  183. package/docs/supported-surface.md +83 -0
  184. package/docs/versioning.md +55 -0
  185. package/docs/workflow-findings-sidecar.md +588 -0
  186. package/package.json +135 -0
  187. package/prompts/first-build.md +27 -0
  188. package/prompts/friction-log.md +30 -0
  189. package/schemas/campaign-build-brief.v1.schema.json +149 -0
  190. package/schemas/campaign-design-source-package.v0.schema.json +697 -0
  191. package/schemas/campaign-runtime-assembly-report.v0.schema.json +390 -0
  192. package/schemas/campaign-runtime-build-context.v0.schema.json +339 -0
  193. package/schemas/campaign-runtime-build-packet.v0.schema.json +452 -0
  194. package/schemas/campaign-spec.v4.schema.json +582 -0
  195. package/schemas/campaigns-os-doctor-output.v0.schema.json +43 -0
  196. package/schemas/campaigns-os-legacy-migration-inventory.v0.schema.json +112 -0
  197. package/schemas/campaigns-os-legacy-provisioning-plan.v0.schema.json +38 -0
  198. package/schemas/campaigns-os-legacy-provisioning-receipt.v0.schema.json +57 -0
  199. package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +150 -0
  200. package/schemas/campaigns-os-qa-verdict.v0.schema.json +438 -0
  201. package/schemas/campaigns-os-release-ledger.v1.schema.json +162 -0
  202. package/schemas/campaigns-os-run-record.v0.schema.json +343 -0
  203. package/schemas/campaigns-os-runtime-recipe.v1.schema.json +313 -0
  204. package/schemas/campaigns-os-sidecar-bundle-conformance.v0.schema.json +78 -0
  205. package/schemas/campaigns-os-tooling-orientation.v1.schema.json +381 -0
  206. package/schemas/campaigns-os-workflow-finding.v0.schema.json +134 -0
  207. package/schemas/source-html-manifest.v0.schema.json +252 -0
  208. package/skills/next-campaigns-build/SKILL.md +73 -0
  209. package/skills/next-campaigns-os/SKILL.md +101 -0
  210. package/skills/next-campaigns-os/references/session-intake.md +160 -0
  211. package/skills/next-campaigns-os-setup/SKILL.md +22 -0
  212. package/skills/next-campaigns-polish/SKILL.md +145 -0
  213. package/skills/next-campaigns-qa/SKILL.md +92 -0
  214. package/skills.json +56 -0
  215. package/skills.sh +64 -0
  216. package/src/adapter-decision-contract.mjs +333 -0
  217. package/src/brand-theme.mjs +1151 -0
  218. package/src/browser-launch.mjs +79 -0
  219. package/src/build-brief.mjs +781 -0
  220. package/src/built-site-scope.mjs +312 -0
  221. package/src/campaign-ecosystem.mjs +734 -0
  222. package/src/campaign-identity.mjs +405 -0
  223. package/src/campaign-workspace.mjs +127 -0
  224. package/src/checkpoint-waiver.mjs +302 -0
  225. package/src/cli.mjs +13442 -0
  226. package/src/commercial-journey.mjs +1119 -0
  227. package/src/commercial-parity.mjs +965 -0
  228. package/src/consent.mjs +347 -0
  229. package/src/content-residue.mjs +322 -0
  230. package/src/deadline.mjs +81 -0
  231. package/src/design-source-package.mjs +2604 -0
  232. package/src/deviation.mjs +107 -0
  233. package/src/doctor-check-registry.mjs +49 -0
  234. package/src/doctor-sidecar.mjs +106 -0
  235. package/src/finding-cause.mjs +557 -0
  236. package/src/findings.mjs +326 -0
  237. package/src/fs-identity.mjs +68 -0
  238. package/src/gate-actions.mjs +105 -0
  239. package/src/html-scan.mjs +53 -0
  240. package/src/install-mode.mjs +272 -0
  241. package/src/legacy-migration.d.ts +128 -0
  242. package/src/legacy-migration.mjs +510 -0
  243. package/src/lifecycle.mjs +338 -0
  244. package/src/local-proof.mjs +401 -0
  245. package/src/map-pin-writeback.mjs +210 -0
  246. package/src/orchestration-stage-contract.mjs +81 -0
  247. package/src/package-install-fixture.mjs +33 -0
  248. package/src/page-kit-build-summary.mjs +175 -0
  249. package/src/page-kit-campaign-config.mjs +57 -0
  250. package/src/page-kit-sdk-version.mjs +392 -0
  251. package/src/page-kit-store-profile.mjs +369 -0
  252. package/src/page-kit-sync.mjs +162 -0
  253. package/src/polish-browser.mjs +867 -0
  254. package/src/polish-capture.mjs +1094 -0
  255. package/src/polish-deadline.mjs +51 -0
  256. package/src/polish-gate.mjs +739 -0
  257. package/src/polish-node.mjs +639 -0
  258. package/src/polish-page-load.mjs +1100 -0
  259. package/src/private-template-source.mjs +237 -0
  260. package/src/proof-policy.mjs +82 -0
  261. package/src/qa-analytics-correctness.mjs +307 -0
  262. package/src/qa-analytics-errors.mjs +38 -0
  263. package/src/qa-analytics-parity.mjs +699 -0
  264. package/src/qa-binding-evidence.mjs +140 -0
  265. package/src/qa-browser.mjs +6608 -0
  266. package/src/qa-cart-entry.mjs +406 -0
  267. package/src/qa-commercial-parity.mjs +641 -0
  268. package/src/qa-node.mjs +3620 -0
  269. package/src/qa-order-bump.mjs +381 -0
  270. package/src/qa-parity-capture.mjs +428 -0
  271. package/src/qa-parity-fixture.mjs +359 -0
  272. package/src/qa-publish.mjs +362 -0
  273. package/src/qa-purchase-data-layer.mjs +263 -0
  274. package/src/qa-route-probe.mjs +272 -0
  275. package/src/qa-sidecar.mjs +188 -0
  276. package/src/qa-test-order-topology.mjs +207 -0
  277. package/src/qa-url-privacy.mjs +13 -0
  278. package/src/qa-verdict-discovery.mjs +192 -0
  279. package/src/qa-verdict-publish.mjs +105 -0
  280. package/src/qa-verdict.mjs +287 -0
  281. package/src/remit.mjs +388 -0
  282. package/src/repo-scan.mjs +83 -0
  283. package/src/route-identity.mjs +133 -0
  284. package/src/run-record-closeout.mjs +229 -0
  285. package/src/run-record.mjs +839 -0
  286. package/src/run-session.mjs +226 -0
  287. package/src/runtime-state-ignore.mjs +113 -0
  288. package/src/sdk-attribute-index.mjs +212 -0
  289. package/src/sdk-markup.mjs +358 -0
  290. package/src/sdk-meta-tags.mjs +52 -0
  291. package/src/shell-token.mjs +7 -0
  292. package/src/sidecar-bundle.mjs +399 -0
  293. package/src/source-asset-crawl.mjs +469 -0
  294. package/src/source-html-intake.mjs +627 -0
  295. package/src/source-html-manifest.mjs +276 -0
  296. package/src/source-prep.mjs +284 -0
  297. package/src/spec-derive-store.mjs +431 -0
  298. package/src/spec-derive.mjs +514 -0
  299. package/src/spec-fetch.mjs +66 -0
  300. package/src/spec-hash.mjs +48 -0
  301. package/src/spec-identity.mjs +27 -0
  302. package/src/stage-ledger.mjs +509 -0
  303. package/src/standardization-report.mjs +1297 -0
  304. package/src/template-brand-contract.mjs +473 -0
  305. package/src/template-freshness.mjs +196 -0
  306. package/src/template-reference.mjs +81 -0
  307. package/src/template-slot-manifest.mjs +150 -0
  308. package/src/text-safety.mjs +62 -0
  309. package/src/theme-gate.mjs +185 -0
  310. package/src/upsell-selector-scope.mjs +299 -0
@@ -0,0 +1,699 @@
1
+ // Analytics-parity capture + diff for the campaigns-os QA harness.
2
+ //
3
+ // This is the missing PARITY-QA analytics leg: migration doctrine forbids a
4
+ // cutover without a green parity diff, and the SDK-boundary parity zone is the
5
+ // live dataLayer event stream + GTM/pixel tag-fires. Runtime-injected GTM is
6
+ // invisible to repo scans (a live Meta Purchase tag once lived in a second GTM
7
+ // container injected at runtime; legacy `campaign.js` pushes its real events
8
+ // from a remote script) — so parity can only be asserted from a LIVE capture,
9
+ // baseline (legacy funnel) vs candidate (migrated preview), diffed here.
10
+ //
11
+ // Contract (see the campaignsjs→SDK-0.4.x migration doctrine, PARITY QA phase):
12
+ // - The canonical SDK `dl_*` commerce set is the BLOCKING gate.
13
+ // - Carried-over 3rd-party tags (GTM / Meta / Everflow / GA4 / …) present on
14
+ // the baseline but missing on the candidate are a WARN regression, not an
15
+ // auto-block (some are intentionally dropped — a human confirms).
16
+ // - Purchase `value`/`currency` are compared client-fired vs client-fired
17
+ // (the harness drives the SAME offer through both funnels). They are NEVER
18
+ // diffed against a backend order total: on one-step headless checkouts tax
19
+ // is computed backend at order-creation time and is NOT in the client value,
20
+ // so a value-vs-total diff would false-fail by the tax amount every order.
21
+ // - `transaction_id` differs legitimately (two different orders), so it is
22
+ // checked for PRESENCE/consistency, not equality.
23
+ //
24
+ // This module is pure + page-method-only (no direct playwright import), so the
25
+ // classification/diff logic is unit-testable and the capture attaches to any
26
+ // Playwright page the host harness already owns.
27
+
28
+ import { SEVERITY, STATUS } from "./qa-verdict.mjs";
29
+ import { redactUrlQuery } from "./qa-url-privacy.mjs";
30
+
31
+ // Data layers the SDK and legacy funnels push through. The SDK's GTMAdapter
32
+ // mirrors every `dl_*` event to window.dataLayer AND window.ElevarDataLayer;
33
+ // the SDK's own dedicated layer is window.NextDataLayer. Legacy funnels push
34
+ // to window.dataLayer. We hook all of them and dedup downstream.
35
+ export const HOOKED_DATA_LAYERS = Object.freeze([
36
+ "dataLayer",
37
+ "NextDataLayer",
38
+ "ElevarDataLayer",
39
+ ]);
40
+
41
+ // Default extra hosts treated as analytics tag-fires beyond the well-known ones.
42
+ // Everflow (affiliate) often fires from a merchant-custom tracking domain, so it
43
+ // is matched by substring and the host list is extensible via args.
44
+ const DEFAULT_EXTRA_ANALYTICS_HOST_SUBSTRINGS = Object.freeze(["everflow"]);
45
+
46
+ // Float compare tolerance for money values (cents).
47
+ const VALUE_EPSILON = 0.005;
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Capture (browser side) — operates on a passed-in Playwright page.
51
+ // ---------------------------------------------------------------------------
52
+
53
+ // JS installed via page.addInitScript BEFORE any page script runs. It wraps the
54
+ // push() of each hooked data layer so every pushed event is recorded in order,
55
+ // surviving the common `window.dataLayer = window.dataLayer || []` idiom by
56
+ // trapping assignment with a getter/setter and re-wrapping push on replacement.
57
+ export function analyticsInitScript(layers = HOOKED_DATA_LAYERS) {
58
+ return `(() => {
59
+ const LAYERS = ${JSON.stringify(layers)};
60
+ const documentState = {
61
+ token: (globalThis.crypto && typeof globalThis.crypto.randomUUID === "function")
62
+ ? globalThis.crypto.randomUUID()
63
+ : String(Date.now()) + ":" + String(Math.random()),
64
+ url: String(location.href || ""),
65
+ };
66
+ const store = (window.__nextQaAnalytics = { events: [], document: documentState });
67
+ const emit = (payload) => {
68
+ try { window.__nextQaAnalyticsEmit && window.__nextQaAnalyticsEmit(payload); } catch (_) {}
69
+ };
70
+ emit({ type: "document", document: documentState });
71
+ const clone = (value) => {
72
+ try { return JSON.parse(JSON.stringify(value)); }
73
+ catch (_) {
74
+ const out = {};
75
+ for (const k of Object.keys(value || {})) {
76
+ const v = value[k];
77
+ if (typeof v !== "function") out[k] = v;
78
+ }
79
+ return out;
80
+ }
81
+ };
82
+ const record = (layer, args) => {
83
+ for (const arg of args) {
84
+ if (arg && typeof arg === "object") {
85
+ const entry = { layer, data: clone(arg), document: documentState };
86
+ store.events.push(entry);
87
+ // Mirror to the Node side immediately (exposeBinding survives
88
+ // navigations; this in-page store does not). Fire-and-forget.
89
+ emit({ type: "event", entry });
90
+ }
91
+ }
92
+ };
93
+ const wrap = (layer, arr) => {
94
+ if (!Array.isArray(arr) || arr.__nextQaWrapped) return arr;
95
+ // Record anything already present at hook time.
96
+ record(layer, arr);
97
+ const nativePush = arr.push.bind(arr);
98
+ arr.push = (...args) => { record(layer, args); return nativePush(...args); };
99
+ Object.defineProperty(arr, "__nextQaWrapped", { value: true, enumerable: false });
100
+ return arr;
101
+ };
102
+ for (const name of LAYERS) {
103
+ let backing = wrap(name, window[name] || []);
104
+ Object.defineProperty(window, name, {
105
+ configurable: true,
106
+ get() { return backing; },
107
+ set(next) { backing = wrap(name, next || []); },
108
+ });
109
+ }
110
+ })();`;
111
+ }
112
+
113
+ // Attach capture to a page: install the init script + listen for outbound tag
114
+ // fires to analytics hosts. Returns a handle whose collect() reads the recorded
115
+ // dataLayer events out of the page and normalizes them with the tag fires.
116
+ export async function attachAnalyticsCapture(page, options = {}) {
117
+ const extraHosts = Array.isArray(options.extraHosts) ? options.extraHosts : [];
118
+ const tagFires = [];
119
+ // Node-side event accumulator: a funnel traversal navigates through several
120
+ // documents (checkout → upsell → receipt) and each navigation discards the
121
+ // in-page store, so the init script mirrors every event out through this
122
+ // binding. Best-effort — pages without binding support fall back to the
123
+ // per-document store in collect().
124
+ const accumulatedEvents = [];
125
+ const documentsByToken = new Map();
126
+ let mainDocumentGeneration = 0;
127
+ let auxiliaryDocumentGeneration = 0;
128
+ let activeMainDocument = null;
129
+ let unknownMainDocument = null;
130
+ let unknownAuxiliaryDocument = null;
131
+ const safePageUrl = () => {
132
+ try { return typeof page.url === "function" ? page.url() : null; }
133
+ catch { return null; }
134
+ };
135
+ const isMainFrame = (frame) => {
136
+ if (!frame) return true;
137
+ try {
138
+ const mainFrame = typeof page.mainFrame === "function" ? page.mainFrame() : null;
139
+ if (mainFrame) return frame === mainFrame;
140
+ return typeof frame.parentFrame !== "function" || !frame.parentFrame();
141
+ } catch {
142
+ return false;
143
+ }
144
+ };
145
+ // Descriptor lookup/creation is deliberately side-effect-free with respect
146
+ // to the active main document. Late events often arrive from an earlier
147
+ // execution context; observing their known token must never roll the receipt
148
+ // pointer backward.
149
+ const lookupDocument = (raw = {}, { fallbackUrl = null, mainFrame = true, provisional = false } = {}) => {
150
+ const route = redactUrlQuery(raw?.url ?? fallbackUrl);
151
+ const token = typeof raw?.token === "string" && raw.token ? raw.token : null;
152
+ if (token && documentsByToken.has(token)) return documentsByToken.get(token);
153
+ // A request can arrive just before the init-script binding callback. Fold
154
+ // the subsequent token into that provisional generation when its route is
155
+ // identical, rather than inventing two documents for one navigation.
156
+ if (token && mainFrame && activeMainDocument?.provisional === true && activeMainDocument.route === route) {
157
+ activeMainDocument.provisional = false;
158
+ documentsByToken.set(token, activeMainDocument);
159
+ return activeMainDocument;
160
+ }
161
+ const descriptor = {
162
+ route,
163
+ generation: mainFrame ? ++mainDocumentGeneration : ++auxiliaryDocumentGeneration,
164
+ mainFrame,
165
+ provisional: mainFrame && provisional && !token,
166
+ };
167
+ if (token) documentsByToken.set(token, descriptor);
168
+ return descriptor;
169
+ };
170
+ const activateMainDocument = (document) => {
171
+ if (!document?.mainFrame) return activeMainDocument;
172
+ if (!activeMainDocument || document.generation > activeMainDocument.generation) {
173
+ activeMainDocument = document;
174
+ }
175
+ return activeMainDocument;
176
+ };
177
+ const publicDocument = (document) => document
178
+ ? { route: document.route || null, generation: document.generation }
179
+ : null;
180
+ const attributeEvent = (entry, { fallbackDocument = null, mainFrame = true, fallbackUrl = null } = {}) => {
181
+ if (!entry || typeof entry !== "object") return null;
182
+ const document = entry.document
183
+ ? lookupDocument(entry.document, { mainFrame, fallbackUrl })
184
+ : fallbackDocument
185
+ || (mainFrame ? activeMainDocument : null)
186
+ || (mainFrame
187
+ ? (unknownMainDocument ||= lookupDocument({}, { mainFrame: true, fallbackUrl }))
188
+ : (unknownAuxiliaryDocument ||= lookupDocument({}, { mainFrame: false, fallbackUrl })));
189
+ return { ...entry, document };
190
+ };
191
+ let bindingAttached = false;
192
+ if (typeof page.exposeBinding === "function") {
193
+ try {
194
+ await page.exposeBinding("__nextQaAnalyticsEmit", (source, payload) => {
195
+ const sourceFrame = source?.frame || null;
196
+ const mainFrame = isMainFrame(sourceFrame);
197
+ let sourceUrl = safePageUrl();
198
+ try {
199
+ if (sourceFrame && typeof sourceFrame.url === "function") sourceUrl = sourceFrame.url();
200
+ } catch { /* use page URL */ }
201
+ if (payload?.type === "document" && payload.document) {
202
+ const document = lookupDocument(payload.document, { fallbackUrl: sourceUrl, mainFrame });
203
+ const sourceRoute = redactUrlQuery(sourceUrl);
204
+ // Only a committed top-level document may advance receipt scope. A
205
+ // stale callback whose payload route no longer matches the main frame
206
+ // is registered for journey attribution but is not activated.
207
+ if (mainFrame && (!sourceRoute || !document.route || sourceRoute === document.route)) {
208
+ activateMainDocument(document);
209
+ }
210
+ return;
211
+ }
212
+ const entry = payload?.type === "event" ? payload.entry : payload;
213
+ const attributed = attributeEvent(entry, { mainFrame, fallbackUrl: sourceUrl });
214
+ if (attributed) accumulatedEvents.push(attributed);
215
+ });
216
+ bindingAttached = true;
217
+ } catch (_) { /* binding may already exist on a reused page */ }
218
+ }
219
+ await page.addInitScript(analyticsInitScript());
220
+ const onRequest = (request) => {
221
+ const url = typeof request.url === "function" ? request.url() : request.url;
222
+ const fire = classifyTagFire(url, extraHosts);
223
+ if (!fire) return;
224
+ let documentUrl = safePageUrl();
225
+ try {
226
+ let frame = typeof request.frame === "function" ? request.frame() : null;
227
+ while (frame && typeof frame.parentFrame === "function" && frame.parentFrame()) frame = frame.parentFrame();
228
+ if (frame && typeof frame.url === "function") documentUrl = frame.url();
229
+ } catch { /* use the page URL fallback */ }
230
+ const route = redactUrlQuery(documentUrl);
231
+ const document = activeMainDocument?.route === route
232
+ ? activeMainDocument
233
+ : lookupDocument({}, { fallbackUrl: documentUrl, mainFrame: true, provisional: true });
234
+ // A tag request can beat the binding notification immediately after a
235
+ // committed navigation. Reconcile that provisional document only when the
236
+ // top-frame route agrees with the page's current route.
237
+ if (route && route === redactUrlQuery(safePageUrl())) activateMainDocument(document);
238
+ tagFires.push({ ...fire, document });
239
+ };
240
+ page.on("request", onRequest);
241
+ const readScopes = async ({ strict = false } = {}) => {
242
+ let snapshot = { events: [], document: null };
243
+ try {
244
+ const raw = await page.evaluate(() => ({
245
+ events: window.__nextQaAnalytics?.events || [],
246
+ document: window.__nextQaAnalytics?.document || { url: String(location.href || "") },
247
+ }));
248
+ snapshot = Array.isArray(raw) ? { events: raw, document: null } : (raw || snapshot);
249
+ } catch (error) {
250
+ // Most inventory/parity callers retain the historical best-effort
251
+ // behavior. Receipt Purchase proof is different: an unreadable settled
252
+ // terminal must be an explicit capture error, never a fabricated
253
+ // zero-signal capture that could consume the no-Purchase waiver.
254
+ if (strict) throw error;
255
+ }
256
+ const snapshotDocument = lookupDocument(snapshot.document || {}, {
257
+ fallbackUrl: safePageUrl(),
258
+ mainFrame: true,
259
+ });
260
+ activateMainDocument(snapshotDocument);
261
+ const currentDocument = activeMainDocument || snapshotDocument;
262
+ const snapshotEvents = Array.isArray(snapshot.events)
263
+ ? snapshot.events.map((entry) => attributeEvent(entry, {
264
+ fallbackDocument: snapshotDocument,
265
+ mainFrame: true,
266
+ fallbackUrl: safePageUrl(),
267
+ })).filter(Boolean)
268
+ : [];
269
+ // Binding events preserve the whole traversal. The current in-page store
270
+ // is appended as a lossless backfill if a binding callback is still queued;
271
+ // normalizeCapture is intentionally duplicate-insensitive.
272
+ const journeyEvents = bindingAttached
273
+ ? [...accumulatedEvents, ...snapshotEvents]
274
+ : snapshotEvents;
275
+ const currentEvents = journeyEvents.filter(
276
+ (entry) => entry.document?.mainFrame === true
277
+ && entry.document.generation === currentDocument.generation,
278
+ );
279
+ const currentTagFires = tagFires.filter(
280
+ (fire) => fire.document?.mainFrame === true
281
+ && fire.document.generation === currentDocument.generation,
282
+ );
283
+ return {
284
+ journey: normalizeCapture({ events: journeyEvents, tagFires }),
285
+ currentDocument: {
286
+ ...normalizeCapture({ events: currentEvents, tagFires: currentTagFires }),
287
+ document: publicDocument(currentDocument),
288
+ },
289
+ };
290
+ };
291
+ return {
292
+ tagFires,
293
+ collectScopes: readScopes,
294
+ // The raw push log, one entry per push per hooked layer, in arrival order,
295
+ // each attributed to the document that pushed it. Binding events only: the
296
+ // in-page store is a per-document backfill that would double-count a push
297
+ // the binding already delivered, and a reader of this log is counting.
298
+ // `complete` is false when the page could not expose the binding, in which
299
+ // case the log is empty and must be reported as unmeasured, not as zero.
300
+ rawEvents() {
301
+ return {
302
+ complete: bindingAttached,
303
+ events: accumulatedEvents.map((entry) => ({
304
+ layer: entry.layer,
305
+ data: entry.data,
306
+ document: publicDocument(entry.document),
307
+ })),
308
+ };
309
+ },
310
+ async collect({ strict = false, scope = "journey" } = {}) {
311
+ const captures = await readScopes({ strict });
312
+ return scope === "current-document" ? captures.currentDocument : captures.journey;
313
+ },
314
+ detach() {
315
+ try { page.off("request", onRequest); } catch (_) { /* page may be closed */ }
316
+ },
317
+ };
318
+ }
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // Pure classification + normalization (unit-testable, no browser).
322
+ // ---------------------------------------------------------------------------
323
+
324
+ function hostMatches(host, suffix) {
325
+ return host === suffix || host.endsWith(`.${suffix}`);
326
+ }
327
+
328
+ // Classify an outbound request URL as an analytics tag fire, extracting the
329
+ // provider kind + container/pixel id + query params (params carry the Meta
330
+ // Purchase `eid`/`event_id` dedup key). Returns null for non-analytics hosts.
331
+ export function classifyTagFire(urlString, extraHostSubstrings = []) {
332
+ let url;
333
+ try { url = new URL(urlString); }
334
+ catch (_) { return null; }
335
+ const host = url.hostname.toLowerCase();
336
+ const path = url.pathname;
337
+ const params = Object.fromEntries(url.searchParams.entries());
338
+ const fire = (kind, id) => ({ kind, id: id ? String(id) : null, host, params });
339
+ const subs = [...DEFAULT_EXTRA_ANALYTICS_HOST_SUBSTRINGS, ...extraHostSubstrings];
340
+
341
+ if (hostMatches(host, "googletagmanager.com")) {
342
+ const id = params.id || null;
343
+ if (id && /^GTM-/i.test(id)) return fire("gtm", id);
344
+ if (id && /^G-/i.test(id)) return fire("ga4", id);
345
+ if (id && /^AW-/i.test(id)) return fire("google_ads", id);
346
+ return fire("gtm", id);
347
+ }
348
+ if (hostMatches(host, "google-analytics.com") || hostMatches(host, "analytics.google.com")) {
349
+ return fire("ga4", params.tid || null);
350
+ }
351
+ if (hostMatches(host, "googleadservices.com") || hostMatches(host, "googlesyndication.com")) {
352
+ return fire("google_ads", params.id || params.tid || null);
353
+ }
354
+ if (hostMatches(host, "facebook.com") || hostMatches(host, "facebook.net")) {
355
+ return fire("meta", params.id || null);
356
+ }
357
+ if (hostMatches(host, "tiktok.com")) {
358
+ return fire("tiktok", params.sdkid || params.tid || null);
359
+ }
360
+ for (const sub of subs) {
361
+ if (sub && host.includes(String(sub).toLowerCase())) {
362
+ return fire(String(sub).toLowerCase().includes("everflow") ? "everflow" : "other", params.id || null);
363
+ }
364
+ }
365
+ return null;
366
+ }
367
+
368
+ // Pull purchase fields out of a pushed dataLayer event, tolerating both the SDK
369
+ // shape (`event: dl_purchase`, fields under `.ecommerce`) and arbitrary legacy
370
+ // shapes (`event: purchase`/`Purchase`, fields at top level). Returns null when
371
+ // the event is not a purchase.
372
+ export function extractPurchase(event) {
373
+ if (!event || typeof event !== "object") return null;
374
+ // GA4-only and some legacy funnels push the name as `event_name`, not `event`.
375
+ const name = String(event.event || event.event_name || "").toLowerCase();
376
+ if (name !== "purchase" && name !== "dl_purchase") return null;
377
+ return extractPurchaseFields(event);
378
+ }
379
+
380
+ // Field extraction shared by the main-purchase reader and the per-event
381
+ // purchase map (dl_upsell_purchase and friends carry the same GA4 shape).
382
+ function extractPurchaseFields(event) {
383
+ if (!event || typeof event !== "object") return null;
384
+ const ec = (event.ecommerce && typeof event.ecommerce === "object") ? event.ecommerce : {};
385
+ // Elevar-style dl_* shape (the campaign-cart SDK's): value/currency live at
386
+ // ecommerce.purchase.actionField.revenue + ecommerce.currencyCode.
387
+ const actionField = (ec.purchase && typeof ec.purchase === "object"
388
+ && ec.purchase.actionField && typeof ec.purchase.actionField === "object")
389
+ ? ec.purchase.actionField
390
+ : {};
391
+ const value = firstNumber([ec.value, ec.revenue, actionField.revenue, event.value, event.revenue]);
392
+ const currency = firstString([ec.currency, ec.currencyCode, event.currency]);
393
+ const transactionId = firstString([
394
+ ec.transaction_id, ec.order_id, actionField.id,
395
+ event.transaction_id, event.order_id, event.order_number,
396
+ ]);
397
+ return { value, currency, transactionId };
398
+ }
399
+
400
+ // Money is a finite number or a plain decimal string ("45.00"). Number() would
401
+ // also read a value out of hex/binary/exponent strings ("0x2d" → 45) and out of
402
+ // non-money types entirely (true → 1, [45] → 45), none of which a real
403
+ // dataLayer purchase payload emits — skip those rather than believe them.
404
+ const DECIMAL_MONEY_PATTERN = /^-?\d+(\.\d+)?$/;
405
+
406
+ function firstNumber(candidates) {
407
+ for (const c of candidates) {
408
+ if (typeof c === "number") {
409
+ if (Number.isFinite(c)) return c;
410
+ continue;
411
+ }
412
+ if (typeof c !== "string") continue;
413
+ const trimmed = c.trim();
414
+ if (!trimmed || !DECIMAL_MONEY_PATTERN.test(trimmed)) continue;
415
+ const n = Number(trimmed);
416
+ if (Number.isFinite(n)) return n;
417
+ }
418
+ return null;
419
+ }
420
+
421
+ function firstString(candidates) {
422
+ for (const c of candidates) {
423
+ if (c === null || c === undefined) continue;
424
+ const s = String(c).trim();
425
+ if (s) return s;
426
+ }
427
+ return null;
428
+ }
429
+
430
+ // Build a normalized capture: distinct event names, the purchase summary, the
431
+ // runtime tag/container/pixel inventory, and the Meta Purchase dedup key.
432
+ export function normalizeCapture({ events = [], tagFires = [] } = {}) {
433
+ const rawEvents = events.map((e) => (e && e.data ? e.data : e)).filter(Boolean);
434
+ const eventNames = [];
435
+ let purchase = null;
436
+ // Per-event purchase extraction: upsell purchases (dl_upsell_purchase) carry
437
+ // their own value/currency distinct from the main dl_purchase; keyed here so
438
+ // scenario checks can target a specific purchase-shaped event.
439
+ const purchasesByEvent = {};
440
+ for (const ev of rawEvents) {
441
+ const name = ev && typeof ev === "object" ? String(ev.event || ev.event_name || "") : "";
442
+ if (name && !eventNames.includes(name)) eventNames.push(name);
443
+ // Keep the first purchase-shaped event, but let a later event carrying an
444
+ // actual value upgrade an earlier value-less one: a malformed first
445
+ // dl_purchase (value == null) must not shadow the real purchase and mask a
446
+ // missing value as "present with no value."
447
+ if (!purchase || purchase.value === null) {
448
+ const p = extractPurchase(ev);
449
+ if (p) purchase = p;
450
+ }
451
+ if (name && /purchase/i.test(name)) {
452
+ const fields = extractPurchaseFields(ev);
453
+ if (fields && (!purchasesByEvent[name] || purchasesByEvent[name].value === null)) {
454
+ purchasesByEvent[name] = fields;
455
+ }
456
+ }
457
+ }
458
+
459
+ const inventory = { gtm: [], ga4: [], google_ads: [], meta: [], tiktok: [], everflow: [], other: [] };
460
+ let metaPurchaseEventId = null;
461
+ let metaPurchaseFired = false;
462
+ let ga4PurchaseFired = false;
463
+ for (const fire of tagFires) {
464
+ if (!fire || !inventory[fire.kind]) continue;
465
+ if (fire.id && !inventory[fire.kind].includes(fire.id)) inventory[fire.kind].push(fire.id);
466
+ if (fire.kind === "meta") {
467
+ const ev = fire.params?.ev || fire.params?.event;
468
+ if (ev && String(ev).toLowerCase() === "purchase") {
469
+ metaPurchaseFired = true;
470
+ metaPurchaseEventId = metaPurchaseEventId || fire.params?.eid || fire.params?.event_id || null;
471
+ }
472
+ }
473
+ if (fire.kind === "ga4") {
474
+ // GA4 Measurement Protocol fires carry the event name in `en` (one per
475
+ // event, or en=purchase among batched `&en=` params on /g/collect).
476
+ const en = fire.params?.en || fire.params?.event_name;
477
+ if (en && String(en).toLowerCase() === "purchase") ga4PurchaseFired = true;
478
+ }
479
+ }
480
+
481
+ return {
482
+ eventNames,
483
+ purchase: purchase ? { present: true, ...purchase } : { present: false },
484
+ purchasesByEvent,
485
+ inventory,
486
+ metaPurchaseEventId,
487
+ // Purchase can fire from any of three sources (dataLayer event, Meta pixel,
488
+ // GA4) — campaigns that block the SDK event and fire Meta/GA4 manually still
489
+ // "purchased." effectivePurchase() reads these so correctness/parity don't
490
+ // false-fail a deliberate `blockedEvents` setup.
491
+ purchaseSignals: {
492
+ dataLayer: !!purchase,
493
+ meta: metaPurchaseFired,
494
+ ga4: ga4PurchaseFired,
495
+ },
496
+ };
497
+ }
498
+
499
+ // The effective purchase across all fire sources — the source-of-truth answer
500
+ // to "did this funnel record a purchase," independent of whether the SDK
501
+ // dataLayer event was used or blocked-and-fired-manually via a pixel.
502
+ export function effectivePurchase(capture = {}) {
503
+ const p = capture.purchase || { present: false };
504
+ const s = capture.purchaseSignals || {};
505
+ const via = p.present ? "datalayer" : s.meta ? "meta" : s.ga4 ? "ga4" : null;
506
+ return {
507
+ fired: !!(p.present || s.meta || s.ga4),
508
+ via,
509
+ // value/currency/txn are only knowable from the dataLayer event; a
510
+ // pixel-only purchase fires without them in our capture.
511
+ value: p.present ? p.value : null,
512
+ currency: p.present ? p.currency : null,
513
+ transactionId: p.present ? p.transactionId : null,
514
+ metaEventId: capture.metaPurchaseEventId || null,
515
+ };
516
+ }
517
+
518
+ // ---------------------------------------------------------------------------
519
+ // Diff → parity assertions (pure; this is the contract enforcement).
520
+ // ---------------------------------------------------------------------------
521
+
522
+ // Packet 01 / INV-3(c): when the differ knows the candidate URL that was
523
+ // captured, every emitted assertion — pass and fail alike — carries it,
524
+ // top-level and in evidence, so a blocked verdict always names what was
525
+ // measured (only the sibling :capture assertion used to carry it).
526
+ function parityAssertion({ id, status, severity, expected, actual, evidence, url }) {
527
+ return {
528
+ id,
529
+ family: "analytics-parity",
530
+ page: "analytics",
531
+ status,
532
+ ...(url ? { url } : {}),
533
+ ...(severity ? { severity } : {}),
534
+ expected,
535
+ actual,
536
+ ...(evidence || url ? { evidence: { ...(url ? { url } : {}), ...(evidence || {}) } } : {}),
537
+ };
538
+ }
539
+
540
+ function valuesEqual(a, b) {
541
+ if (a === null || b === null) return false;
542
+ return Math.abs(Number(a) - Number(b)) <= VALUE_EPSILON;
543
+ }
544
+
545
+ // Diff a baseline (legacy) capture against a candidate (migrated) capture and
546
+ // emit parity assertions. Blockers enforce the canonical commerce gate; WARNs
547
+ // flag carried-over-tag regressions for human review.
548
+ // `options.url` is the candidate URL that was captured (the resolved capture
549
+ // target) — stamped on every emitted assertion, pass and fail alike.
550
+ export function diffAnalyticsParity(baseline, candidate, options = {}) {
551
+ const assertions = [];
552
+ const auditedUrl = (typeof options.url === "string" && options.url.trim()) ? options.url.trim() : null;
553
+ const emit = (fields) => parityAssertion({ url: auditedUrl, ...fields });
554
+ const b = baseline || {};
555
+ const c = candidate || {};
556
+ const bp = b.purchase || { present: false };
557
+ const cp = c.purchase || { present: false };
558
+ // Source-aware: a campaign may block dl_purchase and fire Purchase manually
559
+ // via the Meta/GA4 pixel. The effective purchase counts ALL sources, so a
560
+ // deliberate `blockedEvents` setup doesn't false-fail.
561
+ const cEff = effectivePurchase(c);
562
+
563
+ // 1. Purchase present on candidate — the highest-value blocking check.
564
+ assertions.push(emit({
565
+ id: "analytics-parity:purchase-present",
566
+ status: cEff.fired ? STATUS.PASS : STATUS.FAIL,
567
+ severity: SEVERITY.BLOCKER,
568
+ expected: "candidate fires a Purchase (dl_purchase, or Meta/GA4 pixel if the SDK event is blocked)",
569
+ actual: cEff.fired ? `purchase fired via ${cEff.via}` : "no purchase fire captured on candidate (dataLayer, Meta, or GA4)",
570
+ evidence: { via: cEff.via, candidate_events: c.eventNames || [], candidate_signals: c.purchaseSignals || {}, baseline_purchase: bp },
571
+ }));
572
+
573
+ if (cEff.fired) {
574
+ // 2. Value parity — same offer driven through both funnels, so client-fired
575
+ // values must match. Compared client-vs-client, never vs a backend total
576
+ // (tax is excluded from the client value on headless checkouts). Only
577
+ // knowable when BOTH fired the dataLayer event (a pixel-only purchase
578
+ // carries no client value in our capture) → else manual review.
579
+ if (bp.present && bp.value !== null && bp.value !== undefined && cp.present && cp.value !== null && cp.value !== undefined) {
580
+ const ok = valuesEqual(bp.value, cp.value);
581
+ assertions.push(emit({
582
+ id: "analytics-parity:purchase-value",
583
+ status: ok ? STATUS.PASS : STATUS.FAIL,
584
+ severity: SEVERITY.BLOCKER,
585
+ expected: `client-fired purchase value == baseline (${bp.value})`,
586
+ actual: cp.value,
587
+ evidence: { baseline_value: bp.value, candidate_value: cp.value, note: "client-fired; excludes backend-calculated tax" },
588
+ }));
589
+ } else if (!cp.present) {
590
+ // Candidate fired Purchase pixel-only (e.g. dl_purchase blocked) — the
591
+ // client value isn't in our capture, so value parity can't be asserted.
592
+ assertions.push(emit({
593
+ id: "analytics-parity:purchase-value",
594
+ status: STATUS.MANUAL_REVIEW,
595
+ severity: SEVERITY.WARN,
596
+ expected: "client-fired value to compare",
597
+ actual: `candidate purchase fired via ${cEff.via} (pixel-only) — no client value captured`,
598
+ evidence: { via: cEff.via },
599
+ }));
600
+ } else {
601
+ assertions.push(emit({
602
+ id: "analytics-parity:purchase-value",
603
+ status: STATUS.MANUAL_REVIEW,
604
+ severity: SEVERITY.WARN,
605
+ expected: "baseline purchase value to compare against",
606
+ actual: `no baseline value captured; candidate fired ${cp.value}`,
607
+ evidence: { baseline_value: bp.value ?? null, candidate_value: cp.value },
608
+ }));
609
+ }
610
+
611
+ // 3. Currency parity (dataLayer-only; skipped for pixel-only purchases).
612
+ if (cp.present && bp.present && bp.currency) {
613
+ const ok = bp.currency === cp.currency;
614
+ assertions.push(emit({
615
+ id: "analytics-parity:purchase-currency",
616
+ status: ok ? STATUS.PASS : STATUS.FAIL,
617
+ severity: SEVERITY.BLOCKER,
618
+ expected: `purchase currency == baseline (${bp.currency})`,
619
+ actual: cp.currency,
620
+ evidence: { baseline_currency: bp.currency, candidate_currency: cp.currency },
621
+ }));
622
+ } else {
623
+ assertions.push(emit({
624
+ id: "analytics-parity:purchase-currency",
625
+ status: STATUS.MANUAL_REVIEW,
626
+ severity: SEVERITY.WARN,
627
+ expected: "baseline purchase currency to compare against",
628
+ actual: `no baseline currency captured; candidate fired ${cp.currency ?? "none"}`,
629
+ evidence: { baseline_currency: null, candidate_currency: cp.currency ?? null },
630
+ }));
631
+ }
632
+
633
+ // 4. transaction_id PRESENCE (not equality — different orders have different
634
+ // ids). Only checkable when the dataLayer event fired.
635
+ if (cp.present) {
636
+ assertions.push(emit({
637
+ id: "analytics-parity:purchase-transaction-id",
638
+ status: cp.transactionId ? STATUS.PASS : STATUS.FAIL,
639
+ severity: SEVERITY.BLOCKER,
640
+ expected: "candidate purchase carries a non-empty transaction_id",
641
+ actual: cp.transactionId || "missing",
642
+ evidence: { candidate_transaction_id: cp.transactionId || null },
643
+ }));
644
+ }
645
+
646
+ // 5. Meta CAPI dedup — candidate's Meta Purchase fire carries an eventID,
647
+ // consistent with the order id so client + server events dedup.
648
+ const baselineHasMeta = (b.inventory?.meta || []).length > 0;
649
+ const candidateHasMeta = (c.inventory?.meta || []).length > 0;
650
+ if (baselineHasMeta || candidateHasMeta) {
651
+ const eid = c.metaPurchaseEventId;
652
+ const consistent = eid && cp.transactionId && String(eid) === String(cp.transactionId);
653
+ assertions.push(emit({
654
+ id: "analytics-parity:capi-dedup",
655
+ status: eid ? (consistent ? STATUS.PASS : STATUS.WARN) : STATUS.FAIL,
656
+ severity: eid && !consistent ? SEVERITY.WARN : SEVERITY.BLOCKER,
657
+ expected: "Meta Purchase fire carries an eventID keyed on the order id (CAPI dedup)",
658
+ actual: eid ? `eventID=${eid}` : "no eventID on candidate Meta Purchase",
659
+ evidence: { candidate_event_id: eid || null, candidate_transaction_id: cp.transactionId || null },
660
+ }));
661
+ }
662
+ }
663
+
664
+ // 6. Carried-over tag regression — any baseline container/pixel absent on the
665
+ // candidate is a WARN (likely attribution regression; human confirms drop).
666
+ const kinds = ["gtm", "ga4", "google_ads", "meta", "tiktok", "everflow", "other"];
667
+ for (const kind of kinds) {
668
+ const baselineIds = (b.inventory?.[kind]) || [];
669
+ const candidateIds = new Set((c.inventory?.[kind]) || []);
670
+ for (const id of baselineIds) {
671
+ if (id === null) {
672
+ // Baseline fired this kind with an unknown/null id — can't reliably match
673
+ // against candidate ids, so a human must confirm carryover.
674
+ assertions.push(emit({
675
+ id: `analytics-parity:carryover:${kind}:present`,
676
+ status: STATUS.MANUAL_REVIEW,
677
+ severity: SEVERITY.WARN,
678
+ expected: `carried-over ${kind} tag (unidentified baseline id) verified on candidate`,
679
+ actual: candidateIds.size > 0
680
+ ? `candidate has ${candidateIds.size} ${kind} id(s) but baseline id was null — verify manually`
681
+ : `no ${kind} tag on candidate`,
682
+ evidence: { kind, id: null, baseline: baselineIds, candidate: [...candidateIds] },
683
+ }));
684
+ } else {
685
+ const present = candidateIds.has(id);
686
+ assertions.push(emit({
687
+ id: `analytics-parity:carryover:${kind}:${id}`,
688
+ status: present ? STATUS.PASS : STATUS.WARN,
689
+ severity: present ? undefined : SEVERITY.WARN,
690
+ expected: `carried-over ${kind} tag ${id} still fires on candidate`,
691
+ actual: present ? "present" : "absent on candidate",
692
+ evidence: { kind, id, baseline: baselineIds, candidate: [...candidateIds] },
693
+ }));
694
+ }
695
+ }
696
+ }
697
+
698
+ return assertions;
699
+ }