@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,362 @@
1
+ // `qa publish`: post an already-stored QA verdict to the QA portal without
2
+ // re-running QA — and so without placing a single typed-card order.
3
+ //
4
+ // "Local first, publish when clean" used to mean two full runs (#328): a
5
+ // verdict written under --no-post-verdict could only reach the portal by
6
+ // re-running `qa run`, which placed the whole order set again. This command
7
+ // is the missing half: it reads the verdict the run wrote, proves it still
8
+ // describes the current spec, refuses one the portal already holds, posts it
9
+ // through the same rail `qa run` uses, and records the outcome on the run's
10
+ // Run Record the way remit outcomes are recorded.
11
+ //
12
+ // It never touches the order-creation budget: nothing here launches a
13
+ // browser, plans a path, or reads --max-order-creations. Order flags on the
14
+ // command line are a refusal, not a silent no-op, so an operator who typed
15
+ // `qa publish --test-order common` learns that no order was placed rather
16
+ // than assuming one was.
17
+
18
+ import { createHash } from "node:crypto";
19
+ import { existsSync, readFileSync } from "node:fs";
20
+ import { basename, dirname, join, resolve } from "node:path";
21
+
22
+ import { campaignSidecarPaths, targetRepoFor } from "./campaign-workspace.mjs";
23
+ import { SIDECAR_RELATIVE_PATH } from "./qa-sidecar.mjs";
24
+ import { qaVerdictIdentityMatch } from "./qa-verdict-discovery.mjs";
25
+ import { publishQaVerdict, qaPortalUrl, qaVerdictPublishBlock, QA_VERDICT_PUBLISHERS } from "./qa-verdict-publish.mjs";
26
+ import { validateVerdict } from "./qa-verdict.mjs";
27
+ import { readRunRecordsForTarget, writeRunRecord } from "./run-record.mjs";
28
+ import { identityMatches } from "./run-record-closeout.mjs";
29
+ import { DEFAULT_PROXY_BASE } from "./spec-fetch.mjs";
30
+ import { specHashesMatch, specMaterialHash } from "./spec-identity.mjs";
31
+ import { singleLineFragment } from "./text-safety.mjs";
32
+
33
+ export const QA_PUBLISH_STATUSES = Object.freeze({
34
+ published: "published",
35
+ refused: "refused",
36
+ publish_failed: "publish_failed",
37
+ });
38
+
39
+ // Every named refusal, with the exit code they share (2: the command did not
40
+ // do what was asked, and nothing was sent). A refusal is a result, not a
41
+ // thrown error, so --json readers get a code to branch on.
42
+ export const QA_PUBLISH_REFUSALS = Object.freeze({
43
+ packet_required: "packet_required",
44
+ order_flags_refused: "order_flags_refused",
45
+ verdict_missing: "verdict_missing",
46
+ verdict_unreadable: "verdict_unreadable",
47
+ verdict_invalid: "verdict_invalid",
48
+ verdict_untrusted: "verdict_untrusted",
49
+ campaign_mismatch: "campaign_mismatch",
50
+ spec_unreadable: "spec_unreadable",
51
+ spec_hash_absent: "spec_hash_absent",
52
+ spec_hash_mismatch: "spec_hash_mismatch",
53
+ already_published: "already_published",
54
+ });
55
+
56
+ export const QA_PUBLISH_EXIT_CODES = Object.freeze({
57
+ [QA_PUBLISH_STATUSES.published]: 0,
58
+ [QA_PUBLISH_STATUSES.refused]: 2,
59
+ [QA_PUBLISH_STATUSES.publish_failed]: 1,
60
+ });
61
+
62
+ // Flags that only mean something to a run that places orders. Their presence
63
+ // on `qa publish` is refused by name (see the module comment).
64
+ export const QA_PUBLISH_ORDER_FLAGS = Object.freeze([
65
+ "test-order", "max-test-orders", "max-order-creations", "legacy-api-test-order", "browser", "select-package", "apply-coupon",
66
+ ]);
67
+
68
+ function stringArg(value) {
69
+ return typeof value === "string" && value.trim() ? value.trim() : null;
70
+ }
71
+
72
+ function readJson(path) {
73
+ return JSON.parse(readFileSync(path, "utf8"));
74
+ }
75
+
76
+ function sha256File(path) {
77
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
78
+ }
79
+
80
+ function resolveFromFile(filePath, targetPath) {
81
+ if (!targetPath) return null;
82
+ return targetPath.startsWith("/") ? targetPath : resolve(dirname(resolve(filePath)), targetPath);
83
+ }
84
+
85
+ function refusal(code, detail, extra = {}) {
86
+ return { ok: false, action: "qa-publish", status: QA_PUBLISH_STATUSES.refused, refusal: { code, detail }, ...extra };
87
+ }
88
+
89
+ /**
90
+ * Where the verdict to publish comes from. `--verdict` names a file and is
91
+ * honoured as given. Otherwise the committed sidecar beside the packet names
92
+ * the run, and the full verdict the run wrote under <output-dir>/
93
+ * <campaign_slug>/<run_id>.json (--output-dir as qa run resolves it, else
94
+ * <target repo>/qa-output/) is preferred over the sidecar's projection —
95
+ * the projection is what the readback consumes, but the portal wants the
96
+ * evidence the run kept. When only the projection is on disk, it is what
97
+ * gets published, and the result says so (`source_kind`).
98
+ */
99
+ export function resolveStoredVerdictSource({ args, packetPath, packet, readJsonFile = readJson, exists = existsSync }) {
100
+ const explicit = stringArg(args.verdict);
101
+ if (explicit) {
102
+ const path = resolve(explicit);
103
+ if (!exists(path)) return { error: refusal(QA_PUBLISH_REFUSALS.verdict_missing, `No verdict file at ${path}.`) };
104
+ return { path, source_kind: path === sidecarPath(packetPath) ? "sidecar_projection" : "explicit" };
105
+ }
106
+ const sidecar = sidecarPath(packetPath);
107
+ if (!exists(sidecar)) {
108
+ return {
109
+ error: refusal(
110
+ QA_PUBLISH_REFUSALS.verdict_missing,
111
+ `No stored verdict: ${sidecar} does not exist and --verdict was not given. Run qa run first (with --no-post-verdict to keep it local), or name the full verdict file with --verdict; a run that wrote under --output-dir <dir> is found by passing the same --output-dir here.`,
112
+ ),
113
+ };
114
+ }
115
+ let projection;
116
+ try {
117
+ projection = readJsonFile(sidecar);
118
+ } catch (error) {
119
+ return { error: refusal(QA_PUBLISH_REFUSALS.verdict_unreadable, `The committed sidecar ${sidecar} is not readable JSON (${error.message}).`) };
120
+ }
121
+ const runId = stringArg(projection?.run_id);
122
+ const slug = stringArg(projection?.campaign_slug);
123
+ if (runId && slug) {
124
+ // The same directory rule as qa run's writer: --output-dir when given,
125
+ // else qa-output under the packet's target repo.
126
+ const outputDir = stringArg(args["output-dir"])
127
+ ? resolve(args["output-dir"])
128
+ : campaignSidecarPaths(targetRepoFor(packetPath, packet)).qaOutputDir;
129
+ const full = join(outputDir, slug, `${runId}.json`);
130
+ if (exists(full)) return { path: full, source_kind: "full_verdict", sidecar_path: sidecar };
131
+ }
132
+ return { path: sidecar, source_kind: "sidecar_projection", sidecar_path: sidecar };
133
+ }
134
+
135
+ function sidecarPath(packetPath) {
136
+ return join(dirname(resolve(packetPath)), SIDECAR_RELATIVE_PATH);
137
+ }
138
+
139
+ /**
140
+ * The Run Record that carries this verdict, or null. A record is the
141
+ * verdict's when it belongs to the packet's campaign (the closeout identity
142
+ * match) and either references the verdict file as a qa_verdict artifact —
143
+ * by digest, or by the `<run_id>.json` name `qa run` files it under — or
144
+ * already carries a publish block for the verdict's run_id. Newest first, so
145
+ * the record a session closed most recently is the one stamped.
146
+ */
147
+ export function findRunRecordForVerdict({ records, packet, verdictRunId, verdictDigest = null }) {
148
+ for (const entry of Array.isArray(records) ? records : []) {
149
+ const record = entry?.record;
150
+ if (!record || typeof record !== "object" || !identityMatches(record, packet)) continue;
151
+ if (record.qa_verdict_publish?.verdict_run_id === verdictRunId) return entry;
152
+ const artifacts = Array.isArray(record.artifacts) ? record.artifacts : [];
153
+ const references = artifacts.some((artifact) => artifact?.kind === "qa_verdict" && (
154
+ (verdictDigest && artifact.sha256 === verdictDigest)
155
+ || (typeof artifact.path === "string" && basename(artifact.path) === `${verdictRunId}.json`)
156
+ ));
157
+ if (references) return entry;
158
+ }
159
+ return null;
160
+ }
161
+
162
+ /**
163
+ * Publish one stored verdict. `operations` is the test seam: the post, the
164
+ * clock and the record reader/writer are injected so the refusal ladder and
165
+ * the record stamping are assertable without a receiver or a filesystem.
166
+ */
167
+ export async function publishStoredVerdict(args, operations = {}) {
168
+ const ops = {
169
+ readJsonFile: readJson,
170
+ exists: existsSync,
171
+ digest: sha256File,
172
+ post: publishQaVerdict,
173
+ readRecords: readRunRecordsForTarget,
174
+ writeRecord: writeRunRecord,
175
+ now: () => new Date().toISOString(),
176
+ ...operations,
177
+ };
178
+
179
+ const orderFlags = QA_PUBLISH_ORDER_FLAGS.filter((flag) => flag in args);
180
+ if (orderFlags.length) {
181
+ return refusal(
182
+ QA_PUBLISH_REFUSALS.order_flags_refused,
183
+ `qa publish posts a stored verdict and places no orders; ${orderFlags.map((flag) => `--${flag}`).join(", ")} ${orderFlags.length === 1 ? "has" : "have"} no meaning here. Drop the flag, or run qa run if a new order set is what you want.`,
184
+ );
185
+ }
186
+ const packetArg = stringArg(args.packet);
187
+ if (!packetArg) return refusal(QA_PUBLISH_REFUSALS.packet_required, "qa publish requires --packet <campaign-runtime.build.json>: the packet names the current spec the verdict is checked against and the target whose Run Record takes the outcome.");
188
+ const packetPath = resolve(packetArg);
189
+ let packet;
190
+ try {
191
+ packet = ops.readJsonFile(packetPath);
192
+ } catch (error) {
193
+ return refusal(QA_PUBLISH_REFUSALS.packet_required, `Build Packet ${packetPath} is not readable (${error.code || error.message}).`);
194
+ }
195
+
196
+ const source = resolveStoredVerdictSource({ args, packetPath, packet, readJsonFile: ops.readJsonFile, exists: ops.exists });
197
+ if (source.error) return source.error;
198
+ let verdict;
199
+ try {
200
+ verdict = ops.readJsonFile(source.path);
201
+ } catch (error) {
202
+ return refusal(QA_PUBLISH_REFUSALS.verdict_unreadable, `The stored verdict ${source.path} is not readable JSON (${error.message}).`, { verdict_path: source.path });
203
+ }
204
+ const base = { verdict_path: source.path, source_kind: source.source_kind };
205
+ const validationErrors = validateVerdict(verdict);
206
+ if (validationErrors.length) {
207
+ return refusal(QA_PUBLISH_REFUSALS.verdict_invalid, `The stored verdict fails local validation: ${validationErrors.join("; ")}`, base);
208
+ }
209
+ // The same trust chokepoint qa promote holds: a receiver-stamped anonymous
210
+ // record must never be laundered back through this runner as its own.
211
+ if (verdict.trusted === false) {
212
+ return refusal(QA_PUBLISH_REFUSALS.verdict_untrusted, "The stored verdict is stamped trusted: false (an anonymous submission classified by the QA verdict receiver); refusing to re-publish it as this runner's. Publish a verdict this runner produced, or re-run QA.", base);
213
+ }
214
+ if (!qaVerdictIdentityMatch(verdict, packet)) {
215
+ return refusal(
216
+ QA_PUBLISH_REFUSALS.campaign_mismatch,
217
+ `The stored verdict is for campaign ${JSON.stringify(verdict.campaign_slug ?? null)}, not this packet's (map id ${JSON.stringify(packet?.spec?.map_id ?? null)}, route ${JSON.stringify(packet?.campaign?.public_route_slug ?? null)}).`,
218
+ base,
219
+ );
220
+ }
221
+
222
+ // Spec identity: the verdict judged one spec; it may only be published while
223
+ // that is still the packet's spec. One comparator (#416), so this refusal
224
+ // agrees with doctor and the readback about what "the same spec" means.
225
+ const specPath = resolveFromFile(packetPath, stringArg(packet?.spec?.local_path));
226
+ if (!specPath) return refusal(QA_PUBLISH_REFUSALS.spec_unreadable, "The packet names no spec.local_path, so the verdict's spec identity cannot be checked against a current spec.", base);
227
+ let currentSpecHash;
228
+ try {
229
+ currentSpecHash = specMaterialHash(ops.readJsonFile(specPath));
230
+ } catch (error) {
231
+ return refusal(QA_PUBLISH_REFUSALS.spec_unreadable, `The packet's spec ${specPath} is not readable (${error.code || error.message}); the verdict's spec identity cannot be checked.`, base);
232
+ }
233
+ const storedHash = stringArg(verdict.spec_hash);
234
+ if (!storedHash) {
235
+ return refusal(QA_PUBLISH_REFUSALS.spec_hash_absent, "The stored verdict carries no spec_hash, so it cannot be shown to describe the current spec. Re-run qa run.", { ...base, current_spec_hash: currentSpecHash });
236
+ }
237
+ if (!specHashesMatch(storedHash, currentSpecHash)) {
238
+ return refusal(
239
+ QA_PUBLISH_REFUSALS.spec_hash_mismatch,
240
+ `The stored verdict judged spec ${storedHash}; the packet's spec is now ${currentSpecHash}. A verdict for a spec that has since changed is not evidence about the current one — re-run qa run against the current spec (that run publishes by default).`,
241
+ { ...base, verdict_spec_hash: storedHash, current_spec_hash: currentSpecHash },
242
+ );
243
+ }
244
+
245
+ // Idempotency by verdict run_id, read from the run's Run Record. A record
246
+ // whose publish block already says ok for this verdict is the portal
247
+ // holding it; a second post is refused unless --republish says otherwise.
248
+ const verdictRunId = String(verdict.run_id);
249
+ const baseDir = dirname(packetPath);
250
+ let verdictDigest = null;
251
+ try {
252
+ verdictDigest = ops.digest(source.path);
253
+ } catch {
254
+ verdictDigest = null;
255
+ }
256
+ const records = ops.readRecords(baseDir);
257
+ const recordEntry = findRunRecordForVerdict({ records, packet, verdictRunId, verdictDigest });
258
+ const priorBlock = recordEntry?.record?.qa_verdict_publish?.verdict_run_id === verdictRunId ? recordEntry.record.qa_verdict_publish : null;
259
+ const republish = args.republish === true;
260
+ const proxyBase = stringArg(args["proxy-base"]) || DEFAULT_PROXY_BASE;
261
+ const mapId = stringArg(packet?.spec?.map_id) || String(verdict.campaign_slug);
262
+ const identity = { ...base, run_id: verdictRunId, map_id: mapId, spec_hash: storedHash, disposition: verdict.disposition, proxy_base: proxyBase };
263
+ if (priorBlock?.state === "ok" && !republish) {
264
+ return refusal(
265
+ QA_PUBLISH_REFUSALS.already_published,
266
+ `Verdict ${verdictRunId} is already published (${priorBlock.publisher}, ${priorBlock.result}${priorBlock.published_at ? ` at ${priorBlock.published_at}` : ""}) per Run Record ${recordEntry.record.run_id}. Pass --republish to post it again.`,
267
+ { ...identity, run_record: recordSummary(recordEntry, priorBlock), dashboard_url: qaPortalUrl(proxyBase, mapId, verdictRunId) },
268
+ );
269
+ }
270
+
271
+ const outcome = await ops.post(verdict, proxyBase);
272
+ const publishedAt = ops.now();
273
+ const block = qaVerdictPublishBlock(outcome, { verdictRunId, publisher: QA_VERDICT_PUBLISHERS.publish, publishedAt });
274
+
275
+ // Stamp the record. A stored outcome is never downgraded: a --republish
276
+ // whose send failed leaves the prior ok block as written and reports the
277
+ // failure on the envelope only.
278
+ let runRecord = null;
279
+ if (recordEntry) {
280
+ const keepPrior = priorBlock?.state === "ok" && block.state !== "ok";
281
+ const stamped = keepPrior ? priorBlock : block;
282
+ if (keepPrior) {
283
+ runRecord = { ...recordSummary(recordEntry, priorBlock), written: false, preserved: true };
284
+ } else {
285
+ try {
286
+ const record = { ...recordEntry.record, qa_verdict_publish: stamped };
287
+ const path = ops.writeRecord(record, { baseDir });
288
+ runRecord = { ...recordSummary({ path, record }, stamped), written: true, preserved: false };
289
+ } catch (error) {
290
+ runRecord = { ...recordSummary(recordEntry, priorBlock), written: false, preserved: false, error: singleLineFragment(error.message) };
291
+ }
292
+ }
293
+ }
294
+
295
+ const status = outcome.ok ? QA_PUBLISH_STATUSES.published : QA_PUBLISH_STATUSES.publish_failed;
296
+ return {
297
+ ok: outcome.ok === true,
298
+ action: "qa-publish",
299
+ status,
300
+ ...identity,
301
+ republished: republish && priorBlock?.state === "ok",
302
+ publish: {
303
+ attempted: outcome.attempted,
304
+ ok: outcome.ok,
305
+ error: outcome.error,
306
+ endpoint: outcome.endpoint,
307
+ result: outcome.result,
308
+ http_status: outcome.http_status,
309
+ base_kind: outcome.base_kind,
310
+ published_at: block.published_at,
311
+ },
312
+ dashboard_url: outcome.ok ? qaPortalUrl(proxyBase, mapId, verdictRunId) : null,
313
+ run_record: runRecord,
314
+ orders_placed: 0,
315
+ };
316
+ }
317
+
318
+ function recordSummary(entry, block) {
319
+ return {
320
+ run_id: entry?.record?.run_id ?? null,
321
+ path: entry?.path ?? null,
322
+ qa_verdict_publish: block ?? null,
323
+ };
324
+ }
325
+
326
+ /** The text report for one `qa publish` result. Returns lines; the printer joins them. */
327
+ export function qaPublishTextLines(result, { cmd = (verb) => `campaigns-os ${verb}` } = {}) {
328
+ const lines = [];
329
+ if (result.status === QA_PUBLISH_STATUSES.refused) {
330
+ lines.push(`QA publish refused (${result.refusal.code}).`);
331
+ lines.push(result.refusal.detail);
332
+ if (result.verdict_path) lines.push(`Verdict: ${result.verdict_path}${result.source_kind ? ` (${result.source_kind})` : ""}`);
333
+ if (result.dashboard_url) lines.push(`QA portal: ${result.dashboard_url}`);
334
+ lines.push("No order was placed and nothing was sent.");
335
+ return lines;
336
+ }
337
+ lines.push(result.status === QA_PUBLISH_STATUSES.published
338
+ ? `QA verdict published${result.republished ? " again" : ""}.`
339
+ : "QA verdict publish failed.");
340
+ lines.push(`Map ID: ${result.map_id}`);
341
+ lines.push(`Run ID: ${result.run_id}`);
342
+ lines.push(`Disposition: ${result.disposition}`);
343
+ lines.push(`Verdict: ${result.verdict_path} (${result.source_kind})`);
344
+ if (result.source_kind === "sidecar_projection") {
345
+ lines.push(" The full verdict the run wrote is not on disk; the committed projection (no URLs, no order evidence) is what was published.");
346
+ }
347
+ const publish = result.publish || {};
348
+ lines.push(`Publish: ${publish.result || "unknown"}${publish.http_status ? ` (HTTP ${publish.http_status})` : ""} -> ${publish.endpoint || "(no endpoint)"}${publish.error ? ` — ${publish.error}` : ""}`);
349
+ if (result.dashboard_url) lines.push(`QA portal: ${result.dashboard_url}`);
350
+ if (result.run_record) {
351
+ const record = result.run_record;
352
+ const state = record.qa_verdict_publish?.state || "(absent)";
353
+ lines.push(`Run Record: ${record.run_id} ${record.written ? "updated" : record.preserved ? "kept as written (a stored publish is never downgraded)" : "not updated"}${record.error ? ` — ${record.error}` : ""} (qa_verdict_publish: ${state}) at ${record.path}`);
354
+ } else {
355
+ lines.push(`Run Record: none references this verdict under the packet's campaign, so the publish outcome is not on a record. Close the run first (${cmd("run")} end, or ${cmd("run-record")} --packet <packet> --qa-verdict <verdict>) and re-run qa publish --republish to record it.`);
356
+ }
357
+ lines.push("Orders placed: 0 (qa publish never places orders).");
358
+ if (result.status === QA_PUBLISH_STATUSES.publish_failed) {
359
+ lines.push(`Re-run ${cmd("qa")} publish with network access; the local verdict is untouched.`);
360
+ }
361
+ return lines;
362
+ }
@@ -0,0 +1,263 @@
1
+ // Purchase data-layer evidence for browser QA (campaigns-os#325).
2
+ //
3
+ // The current-SDK bump lane exists to prove one thing: after a typed-card
4
+ // order is placed, the funnel pushes exactly one `dl_purchase` to
5
+ // `window.NextDataLayer`, and it names the order the run just placed. Until
6
+ // now the verdict had no field for that, so the operator proved it with a
7
+ // separate read-only browser probe and wrote the result into a hand-authored
8
+ // evidence file — the one signal the bump exists to prove lived outside the
9
+ // harness's own record.
10
+ //
11
+ // This module is the assertion. It judges three things at once, because they
12
+ // are one question:
13
+ //
14
+ // present — at least one `dl_purchase` was pushed after the order;
15
+ // once — and only one (#302's rule: a funnel that reports the purchase
16
+ // twice double-counts revenue and is a FAIL, same as none);
17
+ // matching — and its `ecommerce.transaction_id` is the placed order's
18
+ // number or ref id, so the event is about THIS order and not a
19
+ // replay of an earlier one.
20
+ //
21
+ // Where the event fires is the part that was easy to get wrong, and the first
22
+ // end-to-end run got it wrong: the SDK raises `dl_purchase` from
23
+ // `order:completed`, on the FIRST page opened with `?ref_id=` that fetches the
24
+ // order back — the upsell page on a funnel that has one, the receipt only when
25
+ // nothing sits between checkout and receipt — and then remembers the
26
+ // transaction id per browser and drops the event on every later page of the
27
+ // same order (a reload, a new tab, the receipt after an upsell). So the
28
+ // reading is the whole post-checkout journey of the order, not a snapshot of
29
+ // the terminal document: the runner's data-layer hook records every push on
30
+ // every document the path visits, and this module counts the `dl_purchase`
31
+ // pushes to the SDK's own array across all of them, keeping a per-document
32
+ // breakdown as evidence. A second push on a later document is exactly the
33
+ // double count #302 describes.
34
+ //
35
+ // It counts `window.NextDataLayer` only (not the mirrored `window.dataLayer`
36
+ // / `window.ElevarDataLayer`, where a GTM adapter legitimately re-pushes the
37
+ // same event), runs only when the run actually placed an order, and needs no
38
+ // CampaignSpec analytics block: the SDK writes this array whether or not any
39
+ // provider is declared. `dl_upsell_purchase` is a different event name and is
40
+ // never counted; an accepted upsell legitimately adds one. #302's remaining
41
+ // scope — the same rule for outbound Meta / GA4 Purchase fires — slots into
42
+ // the same record keyed by source; nothing here has to move for it.
43
+
44
+ import { SEVERITY, STATUS } from "./qa-verdict.mjs";
45
+ import { redactUrlQuery } from "./qa-url-privacy.mjs";
46
+
47
+ export const PURCHASE_DATA_LAYER = "NextDataLayer";
48
+ export const PURCHASE_EVENT = "dl_purchase";
49
+
50
+ function orderReference(value) {
51
+ return typeof value === "string" && value.trim() ? value.trim() : null;
52
+ }
53
+
54
+ // The order's own references, in the order the SDK resolves `transaction_id`
55
+ // (`order.number`, then `ref_id`). Either is an acceptable match: the SDK
56
+ // reports the number when the API returned one and the ref id otherwise.
57
+ export function expectedOrderReferences(order = {}) {
58
+ return [...new Set([orderReference(order?.next_order_id), orderReference(order?.ref_id)].filter(Boolean))];
59
+ }
60
+
61
+ // Reduce the capture handle's raw push log to the purchase data-layer probe:
62
+ // every push to the SDK's own array, by event name and by document, with the
63
+ // purchase-shaped ones carrying their transaction id. Pure, so the same shape
64
+ // can be built from a recorded log in tests.
65
+ //
66
+ // `raw` is `{ complete, events: [{ layer, data, document: { route, generation } }] }`
67
+ // from `attachAnalyticsCapture(page).rawEvents()`. An incomplete log (no
68
+ // binding) yields `measured: false`: the events it does hold are not the
69
+ // journey and cannot be counted as one.
70
+ export function purchaseDataLayerProbe(raw, { layer = PURCHASE_DATA_LAYER, event = PURCHASE_EVENT } = {}) {
71
+ const complete = raw?.complete === true;
72
+ const entries = complete && Array.isArray(raw.events) ? raw.events.filter((entry) => entry?.layer === layer) : [];
73
+ const eventCounts = {};
74
+ const purchases = [];
75
+ const documents = new Map();
76
+ entries.forEach((entry, index) => {
77
+ const data = entry.data && typeof entry.data === "object" ? entry.data : null;
78
+ const name = data && typeof data.event === "string" ? data.event : "";
79
+ if (!name) return;
80
+ eventCounts[name] = (eventCounts[name] || 0) + 1;
81
+ const route = entry.document?.route || null;
82
+ const generation = Number.isFinite(entry.document?.generation) ? entry.document.generation : null;
83
+ const key = `${generation ?? "?"}:${route ?? ""}`;
84
+ if (!documents.has(key)) documents.set(key, { route, generation, event_count: 0, purchase_count: 0 });
85
+ const document = documents.get(key);
86
+ document.event_count += 1;
87
+ if (name !== event) return;
88
+ document.purchase_count += 1;
89
+ const ecommerce = data.ecommerce && typeof data.ecommerce === "object" ? data.ecommerce : {};
90
+ const transactionId = ecommerce.transaction_id;
91
+ purchases.push({
92
+ index,
93
+ document_route: route,
94
+ transaction_id: transactionId === undefined || transactionId === null || transactionId === ""
95
+ ? null
96
+ : String(transactionId),
97
+ value: typeof ecommerce.value === "number" && Number.isFinite(ecommerce.value) ? ecommerce.value : null,
98
+ currency: typeof ecommerce.currency === "string" && ecommerce.currency ? ecommerce.currency : null,
99
+ });
100
+ });
101
+ return {
102
+ layer,
103
+ event,
104
+ measured: complete,
105
+ length: entries.length,
106
+ event_counts: eventCounts,
107
+ documents: [...documents.values()],
108
+ purchases,
109
+ };
110
+ }
111
+
112
+ // Judge a probe against the order the run placed. Pure: the record it returns
113
+ // is what the verdict's `test_orders[].data_layer` carries and what the
114
+ // assertion below reads. `outcome` is the one enumerated answer; `ok` and
115
+ // `reason` are its projection for readers that only want pass/fail and why.
116
+ //
117
+ // Outcomes:
118
+ // pass one dl_purchase, transaction_id matches the placed order
119
+ // absent no dl_purchase anywhere after the order — the #325 miss
120
+ // duplicate more than one dl_purchase — the #302 fail
121
+ // mismatch one dl_purchase, but it names a different order (or none)
122
+ // order_ref_unknown one dl_purchase, but the run recorded no order number or
123
+ // ref id to match it against — cannot pass or fail
124
+ // unmeasured the layer could not be hooked or read — a blocker, never
125
+ // a zero-signal reading (#198)
126
+ export function assessPurchaseDataLayer(probe, order = {}, options = {}) {
127
+ const expectedRefs = expectedOrderReferences(order);
128
+ const base = {
129
+ layer: PURCHASE_DATA_LAYER,
130
+ event: PURCHASE_EVENT,
131
+ required: true,
132
+ expected_order_refs: expectedRefs,
133
+ };
134
+ const probeError = options.probeError ?? null;
135
+ if (probeError || !probe || typeof probe !== "object" || probe.measured !== true) {
136
+ const detail = probeError instanceof Error
137
+ ? probeError.message
138
+ : probeError
139
+ ? String(probeError)
140
+ : probe && typeof probe === "object"
141
+ ? "the data-layer hook could not mirror pushes out of the page"
142
+ : "no probe result";
143
+ return {
144
+ ...base,
145
+ measured: false,
146
+ count: null,
147
+ observed_transaction_ids: null,
148
+ order_ref_match: null,
149
+ outcome: "unmeasured",
150
+ ok: false,
151
+ reason: `window.${PURCHASE_DATA_LAYER} could not be read after the order: ${detail}`,
152
+ };
153
+ }
154
+
155
+ const purchases = Array.isArray(probe.purchases) ? probe.purchases : [];
156
+ const observedIds = purchases.map((entry) => (entry && typeof entry === "object" ? entry.transaction_id ?? null : null));
157
+ const count = purchases.length;
158
+ const measured = {
159
+ ...base,
160
+ measured: true,
161
+ count,
162
+ observed_transaction_ids: observedIds,
163
+ };
164
+ const eventSummary = () => {
165
+ const counts = probe.event_counts && typeof probe.event_counts === "object" ? probe.event_counts : {};
166
+ const names = Object.keys(counts);
167
+ const documents = Array.isArray(probe.documents) ? probe.documents.length : 0;
168
+ return names.length
169
+ ? ` (${probe.length ?? 0} event(s) on ${documents} document(s): ${names.map((name) => `${name}×${counts[name]}`).join(", ")})`
170
+ : " (no events on any document)";
171
+ };
172
+ const whereFired = () => {
173
+ const routes = purchases.map((entry) => entry?.document_route || "(unknown document)");
174
+ return routes.length ? ` on ${routes.join(", ")}` : "";
175
+ };
176
+
177
+ if (count === 0) {
178
+ return {
179
+ ...measured,
180
+ order_ref_match: null,
181
+ outcome: "absent",
182
+ ok: false,
183
+ reason: `no ${PURCHASE_EVENT} in window.${PURCHASE_DATA_LAYER} on any page after the order${eventSummary()}`,
184
+ };
185
+ }
186
+ if (count > 1) {
187
+ const ids = observedIds.map((id) => id ?? "(none)").join(", ");
188
+ return {
189
+ ...measured,
190
+ order_ref_match: expectedRefs.length ? observedIds.some((id) => id && expectedRefs.includes(id)) : null,
191
+ outcome: "duplicate",
192
+ ok: false,
193
+ reason: `${PURCHASE_EVENT} was pushed ${count} times to window.${PURCHASE_DATA_LAYER}${whereFired()} (transaction ids: ${ids}); one order must report one purchase`,
194
+ };
195
+ }
196
+
197
+ const [transactionId] = observedIds;
198
+ if (!expectedRefs.length) {
199
+ return {
200
+ ...measured,
201
+ order_ref_match: null,
202
+ outcome: "order_ref_unknown",
203
+ ok: null,
204
+ reason: `one ${PURCHASE_EVENT} in window.${PURCHASE_DATA_LAYER}${whereFired()} (transaction_id ${transactionId ?? "(none)"}), but the run recorded no order number or ref id to match it against`,
205
+ };
206
+ }
207
+ const matched = !!transactionId && expectedRefs.includes(transactionId);
208
+ if (!matched) {
209
+ return {
210
+ ...measured,
211
+ order_ref_match: false,
212
+ outcome: "mismatch",
213
+ ok: false,
214
+ reason: transactionId
215
+ ? `one ${PURCHASE_EVENT} in window.${PURCHASE_DATA_LAYER}${whereFired()}, but its transaction_id ${transactionId} is not the placed order (${expectedRefs.join(" / ")})`
216
+ : `one ${PURCHASE_EVENT} in window.${PURCHASE_DATA_LAYER}${whereFired()}, but it carries no transaction_id to match the placed order (${expectedRefs.join(" / ")})`,
217
+ };
218
+ }
219
+ return {
220
+ ...measured,
221
+ order_ref_match: true,
222
+ outcome: "pass",
223
+ ok: true,
224
+ reason: `one ${PURCHASE_EVENT} in window.${PURCHASE_DATA_LAYER}${whereFired()} carrying transaction_id ${transactionId}, the placed order`,
225
+ };
226
+ }
227
+
228
+ const OUTCOME_STATUS = Object.freeze({
229
+ pass: STATUS.PASS,
230
+ order_ref_unknown: STATUS.MANUAL_REVIEW,
231
+ });
232
+
233
+ // The verdict assertion, one per typed-card path that placed an order. Null
234
+ // when the order carries no data-layer record (no order was placed, legacy API
235
+ // order) — a path that never created an order is the browser-test-order
236
+ // assertion's failure to report, not this one's.
237
+ export function purchaseDataLayerAssertion(page, path, order) {
238
+ const record = order?.data_layer;
239
+ if (!record || typeof record !== "object") return null;
240
+ const status = OUTCOME_STATUS[record.outcome] || STATUS.FAIL;
241
+ const severity = status === STATUS.FAIL ? SEVERITY.BLOCKER : status === STATUS.MANUAL_REVIEW ? SEVERITY.WARN : undefined;
242
+ const probe = order?.evidence?.data_layer;
243
+ return {
244
+ id: `analytics-correctness:data-layer-purchase:${path}`,
245
+ family: "analytics-correctness",
246
+ page: `${page?.page_id || "checkout"}:order:${path}`,
247
+ url: redactUrlQuery(order?.final_url) || undefined,
248
+ status,
249
+ ...(severity ? { severity } : {}),
250
+ expected: `exactly one ${PURCHASE_EVENT} in window.${PURCHASE_DATA_LAYER} across the pages after the order, carrying the placed order's reference as transaction_id`,
251
+ actual: record.reason,
252
+ evidence: {
253
+ ...record,
254
+ ...(probe && typeof probe === "object"
255
+ ? {
256
+ event_counts: probe.event_counts || {},
257
+ documents: Array.isArray(probe.documents) ? probe.documents : [],
258
+ purchases: Array.isArray(probe.purchases) ? probe.purchases : [],
259
+ }
260
+ : {}),
261
+ },
262
+ };
263
+ }