@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,237 @@
1
+ // Private template family resolution: campaigns-os recognizes/certifies a
2
+ // private template family (its design, selectors, and business logic owned
3
+ // by a third-party repo, not this one) without that family's description
4
+ // ever being committed here. The public repo holds only a thin allowlist —
5
+ // contracts/private-template-sources.json — naming which families are
6
+ // private and which repo/path to fetch their contract fragment from. The
7
+ // fragment is resolved in-memory per run and never written back to disk.
8
+ //
9
+ // v1 resolves only from a local sibling checkout (matches this environment's
10
+ // worktree convention: sibling repos share a parent directory). A hosted CI
11
+ // runner without that checkout simply cannot certify a private family today —
12
+ // that's an intentional v1 boundary, not an oversight; see the transfer
13
+ // packet this module implements.
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { basename, dirname, join, resolve } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { loadTemplateBrandContract, resolveContractExtendsChain } from "./template-brand-contract.mjs";
18
+
19
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
20
+
21
+ export const PRIVATE_TEMPLATE_SOURCE_SCHEMA = "private-template-source/v0";
22
+ export const PRIVATE_TEMPLATE_SOURCE_FRAGMENT_SCHEMA = "private-template-source-fragment/v0";
23
+
24
+ // One JSON read + parse path for every file this module reads, so a malformed
25
+ // file surfaces as a structured parse_error (with the syntax error preserved
26
+ // as `cause`) instead of a bare SyntaxError — the same convention the on-disk
27
+ // brand-contract loader uses. Callers that want to swallow it (e.g.
28
+ // certifiedTemplateFamilies) still catch a throw; callers that surface
29
+ // diagnostics get a code to key on.
30
+ function readJsonFile(path, what) {
31
+ try {
32
+ return JSON.parse(readFileSync(path, "utf8"));
33
+ } catch (error) {
34
+ throw privateTemplateSourceError(
35
+ "parse_error",
36
+ `${what} ${path} failed to parse: ${error instanceof Error ? error.message : String(error)}.`,
37
+ error,
38
+ );
39
+ }
40
+ }
41
+
42
+ export function defaultCommerceCatalogPath() {
43
+ return join(ROOT, "contracts", "commerce-surface-catalog.json");
44
+ }
45
+
46
+ export const COMMERCE_CATALOG_FILE_NAME = "commerce-surface-catalog.json";
47
+
48
+ // The commerce catalog a packet reads. `assembly.commerce_catalog.path` is
49
+ // null when the packet was prepared against the toolkit's own catalog: that
50
+ // file ships with every install of the toolkit, so the packet does not record
51
+ // where one particular checkout kept it. A recorded path is an operator's
52
+ // explicit --commerce-catalog, resolved against the packet. A recorded path
53
+ // that no longer exists but names the catalog file is the pre-null shape:
54
+ // packets prepared before the catalog stopped being recorded wrote the
55
+ // toolkit's own file relative to the packet (a chain of ../ into the checkout
56
+ // that ran prepare-build), which is dead on any other machine or with the
57
+ // toolkit installed as a package. Readers resolve that to the running
58
+ // toolkit's catalog, and `source` says which of the four cases applied
59
+ // (toolkit_default, packet, stale_packet_path, missing_packet_path) so doctor
60
+ // can tell the operator without blocking.
61
+ export function resolvePacketCommerceCatalogPath(packetPath, catalogInfo = {}) {
62
+ const recorded = typeof catalogInfo?.path === "string" && catalogInfo.path.length > 0 ? catalogInfo.path : null;
63
+ if (!recorded) {
64
+ return { path: defaultCommerceCatalogPath(), source: "toolkit_default", recorded: null };
65
+ }
66
+ const resolved = resolve(dirname(resolve(packetPath)), recorded);
67
+ if (existsSync(resolved)) {
68
+ return { path: resolved, source: "packet", recorded };
69
+ }
70
+ if (basename(recorded) === COMMERCE_CATALOG_FILE_NAME) {
71
+ return { path: defaultCommerceCatalogPath(), source: "stale_packet_path", recorded };
72
+ }
73
+ // The operator named a file that is not there and is not the toolkit
74
+ // catalog: keep the resolved path so the caller's missing-file check names
75
+ // it, and say plainly that nothing resolved.
76
+ return { path: resolved, source: "missing_packet_path", recorded };
77
+ }
78
+
79
+ // Overridable so tests can sandbox the allowlist against a fixture private
80
+ // source instead of mutating (or depending on) the real production file.
81
+ function privateTemplateSourcesPath() {
82
+ return process.env.PRIVATE_TEMPLATE_SOURCES_PATH || join(ROOT, "contracts", "private-template-sources.json");
83
+ }
84
+
85
+ // No caching, recomputed per call — matches certifiedTemplateFamilies()'s
86
+ // existing convention (cli.mjs) so a long-lived process never serves a stale
87
+ // allowlist after an edit.
88
+ export function loadPrivateTemplateSources() {
89
+ const path = privateTemplateSourcesPath();
90
+ if (!existsSync(path)) return {};
91
+ const parsed = readJsonFile(path, "Private template source allowlist");
92
+ if (!isPlainObject(parsed) || parsed.schema_version !== PRIVATE_TEMPLATE_SOURCE_SCHEMA) {
93
+ throw privateTemplateSourceError(
94
+ "schema_mismatch",
95
+ `Private template source allowlist ${path} has schema_version "${parsed?.schema_version}"; expected "${PRIVATE_TEMPLATE_SOURCE_SCHEMA}".`,
96
+ );
97
+ }
98
+ return isPlainObject(parsed.sources) ? parsed.sources : {};
99
+ }
100
+
101
+ // Base directory sibling repos are resolved under. One env var covers every
102
+ // private provider (there will be more than one over time) — mirrors the
103
+ // existing STARTER_TEMPLATES_PATH precedent (scripts/check-template-doctrine.mjs)
104
+ // generalized from "one specific sibling" to "wherever this environment keeps
105
+ // its siblings".
106
+ function privateTemplateSourcesRoot() {
107
+ return process.env.PRIVATE_TEMPLATE_SOURCES_ROOT || resolve(ROOT, "..");
108
+ }
109
+
110
+ // The sibling checkout directory for a "org/name" repo string: only the final
111
+ // path segment (the repo name) is used as the directory; the org is metadata.
112
+ // The allowlist is committed in-repo and human-reviewed before merge, so the
113
+ // repo/contract_path fields are trusted input, not attacker-controlled — no
114
+ // shape/traversal guard here by design (see the module header's v1 boundary).
115
+ function siblingRepoDir(repo) {
116
+ const name = String(repo || "").trim().split("/").pop();
117
+ return name ? join(privateTemplateSourcesRoot(), name) : null;
118
+ }
119
+
120
+ // Allowlist miss -> null (family isn't private; caller falls through to its
121
+ // own "unknown family" handling). Allowlist hit but no local checkout ->
122
+ // throws, deliberately: a private family must fail loudly and specifically
123
+ // when it's actually needed, never silently read as "uncertified" — that's a
124
+ // confusing dead end for whoever hits it.
125
+ export function resolvePrivateTemplateSourceFragment(family) {
126
+ const entry = loadPrivateTemplateSources()[family];
127
+ if (!entry) return null;
128
+ const repoDir = siblingRepoDir(entry.repo);
129
+ const fragmentPath = repoDir && typeof entry.contract_path === "string" ? join(repoDir, entry.contract_path) : null;
130
+ if (!fragmentPath || !existsSync(fragmentPath)) {
131
+ throw privateTemplateSourceError(
132
+ "private_source_not_checked_out",
133
+ `Template family "${family}" is a private family sourced from ${entry.repo}, but no checkout was found at ` +
134
+ `${repoDir || "(unresolved)"}. Clone ${entry.repo} as a sibling directory (or set PRIVATE_TEMPLATE_SOURCES_ROOT) to resolve it.`,
135
+ );
136
+ }
137
+ const fragment = readJsonFile(fragmentPath, "Private template source fragment");
138
+ if (!isPlainObject(fragment) || fragment.schema_version !== PRIVATE_TEMPLATE_SOURCE_FRAGMENT_SCHEMA) {
139
+ throw privateTemplateSourceError(
140
+ "schema_mismatch",
141
+ `Private template source fragment ${fragmentPath} has schema_version "${fragment?.schema_version}"; expected "${PRIVATE_TEMPLATE_SOURCE_FRAGMENT_SCHEMA}".`,
142
+ );
143
+ }
144
+ if (fragment.family !== family) {
145
+ throw privateTemplateSourceError(
146
+ "family_mismatch",
147
+ `Private template source fragment ${fragmentPath} declares family "${fragment.family}"; expected "${family}".`,
148
+ );
149
+ }
150
+ return { catalogFamily: fragment.catalog_family || null, brandContract: fragment.brand_contract || null, fragmentPath };
151
+ }
152
+
153
+ // Enriches the raw commerce catalog with private family entries. The returned
154
+ // object carries an extra `_private_source_warnings` array (not present in the
155
+ // raw catalog) documenting any private-source fetch errors encountered while
156
+ // building the merged family map — callers that spread or JSON.stringify the
157
+ // result will see this key; callers that only read `.families` are unaffected.
158
+ // Private-source fetch errors are collected as warnings, never thrown here — a
159
+ // public-family run must not fail just because some other private repo isn't
160
+ // checked out locally. Only resolveTemplateBrandContract (below), for that
161
+ // specific family, throws.
162
+ export function resolveCommerceCatalog(catalogPath = defaultCommerceCatalogPath()) {
163
+ const catalog = existsSync(catalogPath) ? readJsonFile(catalogPath, "Commerce surface catalog") : { families: {} };
164
+ // Valid JSON that isn't an object (null / array / scalar) would make
165
+ // `catalog.families` throw a raw TypeError; surface it as the same
166
+ // structured schema_mismatch the allowlist/fragment reads already use.
167
+ if (!isPlainObject(catalog)) {
168
+ throw privateTemplateSourceError(
169
+ "schema_mismatch",
170
+ `Commerce catalog ${catalogPath} is not a JSON object.`,
171
+ );
172
+ }
173
+ const families = { ...(catalog.families || {}) };
174
+ const warnings = [];
175
+ for (const family of Object.keys(loadPrivateTemplateSources())) {
176
+ if (Object.prototype.hasOwnProperty.call(families, family)) continue;
177
+ try {
178
+ const fragment = resolvePrivateTemplateSourceFragment(family);
179
+ if (fragment?.catalogFamily) families[family] = fragment.catalogFamily;
180
+ } catch (error) {
181
+ warnings.push({ family, code: error.code || "load_error", message: error.message });
182
+ }
183
+ }
184
+ return { ...catalog, families, _private_source_warnings: warnings };
185
+ }
186
+
187
+ // Drop-in for loadTemplateBrandContract(family): tries the public loader
188
+ // first (zero behavior change for public families), then falls back to a
189
+ // privately-sourced fragment, run through the SAME extends/merge chain
190
+ // template-brand-contract.mjs already uses — `dir` is this repo's own
191
+ // contracts/ directory, since a private family's `extends` (e.g.
192
+ // "template-brand-contract.shared-commerce.v0.json") points at a genuinely
193
+ // shared, public file that lives here, not in the private repo.
194
+ //
195
+ // If a public contract file exists but fails to parse/validate, the error is
196
+ // caught and a private fragment is tried as a fallback (a corrected private
197
+ // fragment resolves even when a stale public stub is still on disk). Error
198
+ // precedence when the public load failed: if the family is NOT privately
199
+ // allowlisted, the original public error is re-thrown so the operator sees the
200
+ // root cause; if it IS allowlisted but fragment resolution itself throws (e.g.
201
+ // the sibling checkout is missing), that more-specific error surfaces instead.
202
+ export function resolveTemplateBrandContract(family) {
203
+ let publicContract = null;
204
+ let publicError = null;
205
+ try {
206
+ publicContract = loadTemplateBrandContract(family);
207
+ } catch (err) {
208
+ publicError = err;
209
+ }
210
+ if (publicContract) return publicContract;
211
+ const fragment = resolvePrivateTemplateSourceFragment(family);
212
+ if (!fragment?.brandContract) {
213
+ if (publicError) throw publicError;
214
+ return null;
215
+ }
216
+ const contract = resolveContractExtendsChain(fragment.brandContract, {
217
+ dir: join(ROOT, "contracts"),
218
+ label: fragment.fragmentPath,
219
+ });
220
+ if (contract.family !== family) {
221
+ throw privateTemplateSourceError(
222
+ "family_mismatch",
223
+ `Private template brand contract ${fragment.fragmentPath} declares family "${contract.family}"; expected "${family}".`,
224
+ );
225
+ }
226
+ return contract;
227
+ }
228
+
229
+ function privateTemplateSourceError(code, message, cause = undefined) {
230
+ const error = new Error(message, cause ? { cause } : undefined);
231
+ error.code = code;
232
+ return error;
233
+ }
234
+
235
+ function isPlainObject(value) {
236
+ return value !== null && typeof value === "object" && !Array.isArray(value);
237
+ }
@@ -0,0 +1,82 @@
1
+ // Proof policy: the order-path depth a Build Packet declares, the one flag
2
+ // that sets it, and the one text that names the drift between the packet and
3
+ // the Assembly Report's mirror of it.
4
+ //
5
+ // `qa.proof_policy.order_path_depth` is seeded by prepare-build/start and
6
+ // mirrored into `report.proof_policy` at the same moment. The two are compared
7
+ // by `assessPurchaseProofCoverage` (cli.mjs): a disagreement is `unknown`,
8
+ // never one side's value. Doctor, `next` and the coverage reason all describe
9
+ // that state through the single action below, so the command an operator is
10
+ // told to run is spelled once. A leaf: gate-actions only.
11
+
12
+ import { requiredActionText } from "./gate-actions.mjs";
13
+
14
+ // The depths the setter accepts. `off` declares an intentional no-order run
15
+ // (`--test-order off` is then a diagnostic that owes no purchase proof);
16
+ // `common` and `full` match the `--test-order` modes of the same name.
17
+ export const ORDER_PATH_DEPTHS = Object.freeze(["off", "common", "full"]);
18
+
19
+ export const ORDER_PATH_DEPTH_FLAG = "order-path-depth";
20
+
21
+ export function isOrderPathDepth(value) {
22
+ return typeof value === "string" && ORDER_PATH_DEPTHS.includes(value);
23
+ }
24
+
25
+ // The one spelling of "the packet and the report disagree": both present and
26
+ // different once case is ignored. `orderPathDepthDrift` (doctor and the
27
+ // coverage assessment) and the `next` action branch both ask this.
28
+ export function orderPathDepthsDisagree(packetDepth, reportDepth) {
29
+ return typeof packetDepth === "string" && typeof reportDepth === "string"
30
+ && packetDepth.trim() !== "" && reportDepth.trim() !== ""
31
+ && packetDepth.toLowerCase() !== reportDepth.toLowerCase();
32
+ }
33
+
34
+ // `--order-path-depth <off|common|full>`, validated with the other argv checks
35
+ // of whichever command carries it (`command` names it in the error). A bare
36
+ // flag parses as `true`; that is an operator's explicit intent with no value,
37
+ // so it is refused rather than silently defaulted. Case is ignored on input
38
+ // and the lower-case canonical form is what gets stored (`Off` writes `off`),
39
+ // matching the case-insensitive drift comparison. Returns null when the flag
40
+ // is absent.
41
+ export function parseOrderPathDepthFlag(args, { command = "qa policy set" } = {}) {
42
+ const raw = args?.[ORDER_PATH_DEPTH_FLAG];
43
+ if (raw === undefined) return null;
44
+ const accepted = `Accepted values: ${ORDER_PATH_DEPTHS.join(", ")}.`;
45
+ if (raw === true || raw === null || String(raw).trim() === "") {
46
+ throw new Error(`${command}: --${ORDER_PATH_DEPTH_FLAG} needs a value. ${accepted}`);
47
+ }
48
+ const typed = String(raw).trim();
49
+ const value = typed.toLowerCase();
50
+ if (!isOrderPathDepth(value)) {
51
+ throw new Error(`${command}: unsupported --${ORDER_PATH_DEPTH_FLAG} ${JSON.stringify(typed)}. ${accepted}`);
52
+ }
53
+ return value;
54
+ }
55
+
56
+ export const ORDER_PATH_DEPTH_DRIFT_CODE = "qa.proof_policy.order_path_depth_drift";
57
+
58
+ // The one action that reconciles a packet/report depth disagreement: rewrite
59
+ // the depth through the setter, which writes the packet field and refreshes
60
+ // the report mirror in the same run. The packet's value is offered when the
61
+ // setter accepts it (the packet is author intent); a hand-edited value outside
62
+ // the accepted set leaves the choice to the operator.
63
+ export function orderPathDepthReconcileAction({ packetDepth = null, reportDepth = null } = {}) {
64
+ const depth = isOrderPathDepth(packetDepth) ? packetDepth : `<${ORDER_PATH_DEPTHS.join("|")}>`;
65
+ return Object.freeze({
66
+ id: ORDER_PATH_DEPTH_DRIFT_CODE,
67
+ kind: "command",
68
+ command: `campaigns-os qa policy set --packet <packet> --${ORDER_PATH_DEPTH_FLAG} ${depth}`,
69
+ description: `The build packet declares an order-path depth of ${JSON.stringify(packetDepth ?? "unspecified")} while the assembly report's mirror of it reads ${JSON.stringify(reportDepth ?? "unspecified")}; neither is trusted until they agree.`,
70
+ });
71
+ }
72
+
73
+ // The text doctor's warning, the coverage reason and the `next` action all
74
+ // carry for that state: the description, then the runnable command with the
75
+ // packet substituted (or the bare template when no packet path is known). A
76
+ // caller that already rendered the command (to publish it as the action's
77
+ // `command`) passes it in, so the prose and the action carry one string.
78
+ export function orderPathDepthDriftText({ packetDepth = null, reportDepth = null, packetPath = null, command = null } = {}) {
79
+ const action = orderPathDepthReconcileAction({ packetDepth, reportDepth });
80
+ const rendered = typeof command === "string" && command ? command : requiredActionText(action, { packetPath });
81
+ return `${action.description} Reconcile them with \`${rendered}\`, which writes the packet field and refreshes the report mirror together.`;
82
+ }
@@ -0,0 +1,307 @@
1
+ // Analytics CORRECTNESS assessment (single funnel) — the foundation layer the
2
+ // migration parity differ sits on top of. Where parity asks "does candidate
3
+ // match baseline?", correctness has two deliberately separate authorities:
4
+ // campaign-root inventory proves declared providers/tags are present, while
5
+ // receipt evidence from the canonical typed-card order proves Purchase. A
6
+ // campaign-root visit cannot prove (or disprove) a receipt-only event.
7
+ //
8
+ // Driven by the CampaignSpec `analytics` block (campaign-spec AnalyticsContract).
9
+ // When no block is declared the assessment can't know the expected container/
10
+ // pixel ids, so it emits an INFO inventory only — nothing is gated. The contract
11
+ // is what turns observations into pass/fail. (Until the spec authoring tool
12
+ // emits the block, real specs won't carry one — so the no-contract path is the
13
+ // common case today and must stay non-blocking.)
14
+ //
15
+ // Receipt proof is source-aware by construction: it keys on OUTBOUND pixel
16
+ // fires (the network truth), via effectivePurchase, so a campaign that blocks
17
+ // the SDK dl_* event and fires the pixel manually still passes.
18
+
19
+ import { SEVERITY, STATUS } from "./qa-verdict.mjs";
20
+ import { effectivePurchase } from "./qa-analytics-parity.mjs";
21
+ import { redactUrlQuery } from "./qa-url-privacy.mjs";
22
+
23
+ // Inventory kinds classifyTagFire can recognize directly. Other declared
24
+ // out-of-band vendors (TriplePixel→triplewhale, etc.) can't be auto-detected
25
+ // without host wiring, so they degrade to manual review rather than false-fail.
26
+ const KNOWN_VENDOR_KINDS = new Set(["gtm", "ga4", "google_ads", "meta", "tiktok", "everflow"]);
27
+
28
+ // Packet 01 / INV-3(c): when the assessment knows the URL it audited, EVERY
29
+ // emitted assertion — pass and fail alike — carries it, top-level and in
30
+ // evidence, so a reader of a blocked verdict can always tell what was
31
+ // measured (only the sibling :capture assertion used to carry it).
32
+ function correctnessAssertion({ id, status, severity, expected, actual, evidence, waiver, url }) {
33
+ return {
34
+ id,
35
+ family: "analytics-correctness",
36
+ page: "analytics",
37
+ status,
38
+ ...(url ? { url } : {}),
39
+ ...(severity ? { severity } : {}),
40
+ ...(waiver ? { waiver } : {}),
41
+ expected,
42
+ actual,
43
+ ...(evidence || url ? { evidence: { ...(url ? { url } : {}), ...(evidence || {}) } } : {}),
44
+ };
45
+ }
46
+
47
+ // The ONLY assertion the QA waiver lane covers today (packet 01, ratified
48
+ // I-9/I-16): a recorded `qa waive` decision for purchase-fires. The caller
49
+ // decides eligibility: only a genuine, recognized receipt with no effective
50
+ // Purchase may consume the waiver. Missing/unrecognized paths and capture
51
+ // errors cannot.
52
+ function purchaseFiresWaiver(options, eligible) {
53
+ if (!eligible) return null;
54
+ const waiver = options?.waivers?.["analytics-correctness:purchase-fires"];
55
+ if (!waiver || typeof waiver !== "object" || Array.isArray(waiver)) return null;
56
+ if (typeof waiver.reason !== "string" || !waiver.reason.trim()) return null;
57
+ return {
58
+ reason: waiver.reason.trim(),
59
+ waived_by: (typeof waiver.waived_by === "string" && waiver.waived_by.trim()) || "operator",
60
+ waived_at: (typeof waiver.waived_at === "string" && waiver.waived_at.trim()) || null,
61
+ };
62
+ }
63
+
64
+ function inventoryHas(inventory, kind, id) {
65
+ const ids = inventory[kind] || [];
66
+ return id ? ids.includes(String(id)) : ids.length > 0;
67
+ }
68
+
69
+ // Assess the campaign-root capture against its declared analytics inventory.
70
+ // `contract` is the spec's `analytics` block (may be undefined/empty).
71
+ // `options.url` is the URL the capture actually visited (the resolved capture
72
+ // target) — stamped on every emitted assertion, pass and fail alike.
73
+ export function assessAnalyticsInventory(capture = {}, contract = {}, options = {}) {
74
+ const assertions = [];
75
+ const auditedUrl = (typeof options.url === "string" && options.url.trim()) ? options.url.trim() : null;
76
+ const emit = (fields) => correctnessAssertion({ url: auditedUrl, ...fields });
77
+ const inventory = capture.inventory || {};
78
+ const providers = (contract && contract.providers) || {};
79
+ const hasContract = !!(contract && (contract.providers || contract.out_of_band_pixels || contract.params || contract.manual_events));
80
+
81
+ // No declared contract → can't know expected ids; emit a non-gating inventory
82
+ // so the run still records what fired, and flag that nothing was validated.
83
+ if (!hasContract) {
84
+ assertions.push(emit({
85
+ id: "analytics-correctness:no-contract",
86
+ status: STATUS.MANUAL_REVIEW,
87
+ severity: SEVERITY.INFO,
88
+ expected: "a declared CampaignSpec analytics block to validate against",
89
+ actual: "no analytics contract declared — recorded the observed fires, gated nothing",
90
+ // Counts only — never publish raw container/pixel ids or any Purchase
91
+ // fields to the QA portal. Root inventory is not Purchase authority.
92
+ evidence: {
93
+ inventory: Object.fromEntries(Object.entries(inventory).map(([k, v]) => [k, v.length])),
94
+ },
95
+ }));
96
+ return assertions;
97
+ }
98
+
99
+ // 1. GTM container fires (blocker when declared + enabled).
100
+ if (providers.gtm && providers.gtm.enabled !== false) {
101
+ const id = providers.gtm.containerId;
102
+ const present = inventoryHas(inventory, "gtm", id);
103
+ assertions.push(emit({
104
+ id: "analytics-correctness:tag:gtm",
105
+ status: present ? STATUS.PASS : STATUS.FAIL,
106
+ severity: SEVERITY.BLOCKER,
107
+ expected: `GTM ${id || "container"} fires on the page`,
108
+ actual: present ? "present" : `absent (${(inventory.gtm || []).length} gtm tag(s) fired, none matching)`,
109
+ evidence: { declared: id || null, observed_count: (inventory.gtm || []).length },
110
+ }));
111
+ }
112
+
113
+ // 2. Meta pixel fires (blocker when declared + enabled).
114
+ if (providers.facebook && providers.facebook.enabled !== false) {
115
+ const id = providers.facebook.pixelId;
116
+ const present = inventoryHas(inventory, "meta", id);
117
+ assertions.push(emit({
118
+ id: "analytics-correctness:tag:meta",
119
+ status: present ? STATUS.PASS : STATUS.FAIL,
120
+ severity: SEVERITY.BLOCKER,
121
+ expected: `Meta pixel ${id || ""} fires on the page`.trim(),
122
+ actual: present ? "present" : `absent (${(inventory.meta || []).length} meta pixel(s) fired, none matching)`,
123
+ evidence: { declared: id || null, observed_count: (inventory.meta || []).length },
124
+ }));
125
+ }
126
+
127
+ // 3. Out-of-band pixels declared as carried (Everflow / TriplePixel / …).
128
+ for (const [i, pixel] of (contract.out_of_band_pixels || []).entries()) {
129
+ if (!pixel || !pixel.vendor) continue;
130
+ const vendor = String(pixel.vendor).toLowerCase();
131
+ if (KNOWN_VENDOR_KINDS.has(vendor)) {
132
+ const present = inventoryHas(inventory, vendor, pixel.id);
133
+ assertions.push(emit({
134
+ id: `analytics-correctness:oob:${vendor}`,
135
+ status: present ? STATUS.PASS : STATUS.FAIL,
136
+ severity: SEVERITY.BLOCKER,
137
+ expected: `declared out-of-band ${vendor} pixel fires`,
138
+ actual: present ? "present" : "absent",
139
+ evidence: { vendor, declared_id: pixel.id || null, observed_count: (inventory[vendor] || []).length },
140
+ }));
141
+ } else {
142
+ // Vendor host not in the classifier (e.g. TriplePixel→triplewhale.com).
143
+ // Pass its name as --analytics-hosts to capture it; until then, review.
144
+ assertions.push(emit({
145
+ id: `analytics-correctness:oob:${vendor}`,
146
+ status: STATUS.MANUAL_REVIEW,
147
+ severity: SEVERITY.WARN,
148
+ expected: `declared out-of-band ${vendor} pixel fires`,
149
+ actual: `cannot auto-detect "${vendor}" — pass its host via --analytics-hosts to verify`,
150
+ evidence: { vendor, index: i },
151
+ }));
152
+ }
153
+ }
154
+
155
+ return assertions;
156
+ }
157
+
158
+ // Finalize the one stable Purchase assertion from the private capture envelope
159
+ // returned by the canonical typed-card browser-order run. This function is
160
+ // intentionally pure and emits only a fixed, sanitized evidence projection;
161
+ // raw captures, event payloads, order identifiers, values, currencies, and URL
162
+ // query strings never cross into the verdict.
163
+ //
164
+ // The receipt is the qualification point, not the measurement point (#392).
165
+ // The SDK fires dl_purchase — and the outbound Purchase it drives — on the
166
+ // first `?ref_id=` page that fetched the order, which is the upsell page on a
167
+ // funnel that has one, and then dedupes the transaction so the receipt stays
168
+ // silent. So the reading is the whole post-checkout journey: an attempt's
169
+ // `journeyCapture` is the authority, and the receipt-document `capture` is
170
+ // kept as the diagnostic that says which document fired. An envelope that
171
+ // carries only a receipt capture (no journey reading) is judged on it, so a
172
+ // receipt-only funnel and older callers read exactly as before.
173
+ export function assessReceiptPurchase(receiptAnalytics = {}, options = {}) {
174
+ const plannedPlanIds = Array.isArray(receiptAnalytics?.plannedPlanIds)
175
+ ? receiptAnalytics.plannedPlanIds.map(normalizePlanId).filter(Boolean)
176
+ : [];
177
+ const attempts = Array.isArray(receiptAnalytics?.attempts) ? receiptAnalytics.attempts : [];
178
+ const attemptedPlanIds = attempts.map((attempt) => normalizePlanId(attempt?.planId)).filter(Boolean);
179
+ const receipts = [];
180
+ const unqualifiedPlanIds = [];
181
+ const captureErrorPlanIds = [];
182
+ const noSignalPlanIds = [];
183
+
184
+ for (const planId of plannedPlanIds) {
185
+ const attempt = attempts.find((candidate) => normalizePlanId(candidate?.planId) === planId);
186
+ if (!attempt) {
187
+ unqualifiedPlanIds.push(planId);
188
+ continue;
189
+ }
190
+ if (attempt.receiptRecognized !== true) {
191
+ unqualifiedPlanIds.push(planId);
192
+ continue;
193
+ }
194
+
195
+ const receiptCaptureAvailable = isCapture(attempt.capture);
196
+ const journeyCaptureAvailable = isCapture(attempt.journeyCapture);
197
+ // Journey when the envelope carries one; the receipt document otherwise.
198
+ // A journey reading that failed to collect is not replaced by the receipt
199
+ // document: a silent receipt on an upsell funnel is exactly the case a
200
+ // receipt-only fallback would misread as "no Purchase". And a receipt
201
+ // capture/settle error stays the explicit blocker it always was — the
202
+ // journey reading taken beside an unsettled receipt is not a settled one.
203
+ const scope = journeyCaptureAvailable
204
+ ? "journey"
205
+ : (attempt.journeyCaptureError ? null : (receiptCaptureAvailable ? "receipt" : null));
206
+ const judged = scope === "journey" ? attempt.journeyCapture : scope === "receipt" ? attempt.capture : null;
207
+ const captureError = !judged || !!attempt.captureError;
208
+ if (captureError) captureErrorPlanIds.push(planId);
209
+ const effective = judged ? effectivePurchase(judged) : { fired: false, via: null };
210
+ if (!captureError && !effective.fired) noSignalPlanIds.push(planId);
211
+ // #198 is what happens when an unfalsifiable analytics reading is presented
212
+ // as a measurement. A failed capture and a receipt that genuinely fired
213
+ // nothing are BOTH blockers, but they are not the same fact, and a reader
214
+ // of a single receipt entry must be able to tell them apart without
215
+ // cross-referencing capture_error_plan_ids. Unmeasured entries carry
216
+ // measured:false and null signals rather than an all-false reading that
217
+ // looks like evidence.
218
+ const measured = !captureError;
219
+ // Which document fired is the diagnostic a reader of an upsell funnel
220
+ // needs: on a journey-scoped judgement the receipt-document reading is
221
+ // kept beside the judged one, and `fired_on` names the receipt when it
222
+ // fired there, `earlier-page` when only the journey did. A receipt-scoped
223
+ // judgement has no earlier page and no second reading to keep, so
224
+ // `receipt_signals` is null there. `scope` is null on an unmeasured entry.
225
+ const receiptFired = receiptCaptureAvailable && !attempt.captureError
226
+ ? effectivePurchase(attempt.capture).fired
227
+ : null;
228
+ receipts.push({
229
+ plan_id: planId,
230
+ receipt_url: redactUrlQuery(attempt.receiptUrl),
231
+ measured,
232
+ scope: measured ? scope : null,
233
+ purchase_fired: measured && !!effective.fired,
234
+ via: measured ? (effective.via || null) : null,
235
+ signals: measured ? purchaseSignalsOf(judged) : null,
236
+ receipt_signals: measured && scope === "journey" && receiptFired !== null ? purchaseSignalsOf(attempt.capture) : null,
237
+ fired_on: !measured || !effective.fired ? null : receiptFired ? "receipt" : "earlier-page",
238
+ });
239
+ }
240
+
241
+ const hasBlockingCaptureError = captureErrorPlanIds.length > 0;
242
+ const hasNoSignalReceipt = noSignalPlanIds.length > 0;
243
+ const failed = hasBlockingCaptureError || hasNoSignalReceipt;
244
+ const needsReview = !plannedPlanIds.length || unqualifiedPlanIds.length > 0;
245
+ const waiver = purchaseFiresWaiver(options, hasNoSignalReceipt && !hasBlockingCaptureError);
246
+ const status = failed ? STATUS.FAIL : needsReview ? STATUS.MANUAL_REVIEW : STATUS.PASS;
247
+ const severity = status === STATUS.FAIL
248
+ ? (waiver ? SEVERITY.WARN : SEVERITY.BLOCKER)
249
+ : status === STATUS.MANUAL_REVIEW ? SEVERITY.WARN : undefined;
250
+ const evidence = {
251
+ attempted_plan_ids: attemptedPlanIds,
252
+ receipts,
253
+ unqualified_plan_ids: unique(unqualifiedPlanIds),
254
+ capture_error_plan_ids: unique(captureErrorPlanIds),
255
+ };
256
+
257
+ return correctnessAssertion({
258
+ id: "analytics-correctness:purchase-fires",
259
+ status,
260
+ severity,
261
+ ...(waiver ? { waiver } : {}),
262
+ expected: "every deterministic receipt-qualified typed-card order emits Purchase via dataLayer, Meta, or GA4 on some page of its post-checkout journey.",
263
+ actual: receiptPurchaseActual({
264
+ plannedCount: plannedPlanIds.length,
265
+ receiptCount: receipts.length,
266
+ firedCount: receipts.filter((receipt) => receipt.purchase_fired).length,
267
+ unqualifiedCount: unique(unqualifiedPlanIds).length,
268
+ captureErrorCount: unique(captureErrorPlanIds).length,
269
+ waiver,
270
+ }),
271
+ evidence,
272
+ });
273
+ }
274
+
275
+ function normalizePlanId(value) {
276
+ return typeof value === "string" && value.trim() ? value.trim() : null;
277
+ }
278
+
279
+ function unique(values) {
280
+ return [...new Set(values)];
281
+ }
282
+
283
+ function isCapture(value) {
284
+ return !!value && typeof value === "object";
285
+ }
286
+
287
+ function purchaseSignalsOf(capture) {
288
+ const signals = capture?.purchaseSignals || {};
289
+ return {
290
+ dataLayer: !!(capture?.purchase?.present || signals.dataLayer),
291
+ meta: !!signals.meta,
292
+ ga4: !!signals.ga4,
293
+ };
294
+ }
295
+
296
+ function receiptPurchaseActual({ plannedCount, receiptCount, firedCount, unqualifiedCount, captureErrorCount, waiver }) {
297
+ if (!plannedCount) return "no canonical typed-card browser order was planned; Purchase requires receipt-qualified order evidence";
298
+ if (captureErrorCount) return `${captureErrorCount} planned order capture(s) failed; Purchase could not be verified`;
299
+ if (firedCount < receiptCount) {
300
+ const suffix = waiver
301
+ ? ` — blocker waived by ${waiver.waived_by}${waiver.waived_at ? ` at ${waiver.waived_at}` : ""}: ${waiver.reason}`
302
+ : "";
303
+ return `${receiptCount - firedCount} receipt-qualified order(s) emitted no Purchase via dataLayer, Meta, or GA4 on any page of the journey${suffix}`;
304
+ }
305
+ if (unqualifiedCount) return `${unqualifiedCount} of ${plannedCount} planned order(s) did not reach a recognized receipt`;
306
+ return `${firedCount} of ${plannedCount} receipt-qualified order(s) emitted Purchase`;
307
+ }
@@ -0,0 +1,38 @@
1
+ const ANALYTICS_CAPTURE_ERROR_DEFINITIONS = Object.freeze({
2
+ attach: Object.freeze({
3
+ code: "analytics_capture_attach_failed",
4
+ message: "analytics capture could not be attached",
5
+ }),
6
+ unreadable: Object.freeze({
7
+ code: "analytics_capture_unreadable",
8
+ message: "analytics capture could not be read from the settled page",
9
+ }),
10
+ settle: Object.freeze({
11
+ code: "analytics_settle_failed",
12
+ message: "analytics settle window could not complete",
13
+ }),
14
+ settleDeadline: Object.freeze({
15
+ code: "analytics_settle_deadline_exhausted",
16
+ message: "analytics settle window exceeded the typed-order deadline",
17
+ }),
18
+ collectionDeadline: Object.freeze({
19
+ code: "analytics_capture_collection_deadline_exhausted",
20
+ message: "analytics capture collection exceeded the typed-order deadline",
21
+ }),
22
+ });
23
+
24
+ export function analyticsCaptureError(kind) {
25
+ const definition = ANALYTICS_CAPTURE_ERROR_DEFINITIONS[kind]
26
+ || ANALYTICS_CAPTURE_ERROR_DEFINITIONS.unreadable;
27
+ return { ...definition };
28
+ }
29
+
30
+ // Only project errors from the fixed private vocabulary. Browser/Playwright
31
+ // detail can contain live URLs, query strings, and page-controlled text, so it
32
+ // must never cross into a verdict or parity bundle.
33
+ export function projectAnalyticsCaptureError(value, { fallbackKind = null } = {}) {
34
+ const definition = Object.values(ANALYTICS_CAPTURE_ERROR_DEFINITIONS)
35
+ .find((candidate) => candidate.code === value?.code);
36
+ if (definition) return { ...definition };
37
+ return fallbackKind ? analyticsCaptureError(fallbackKind) : null;
38
+ }