@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,303 @@
1
+ /**
2
+ * AnalyticsContractShape — validates the optional top-level `analytics` block
3
+ * when present. The block declares a campaign's analytics/attribution/param
4
+ * contract so doctor + QA can validate against intent (cf. the Chamelo Shield
5
+ * `?reviews=n`-has-no-handler QA finding and the Walla Sound Redtrack param
6
+ * conflict — both are gaps that had no declared contract to check against).
7
+ *
8
+ * The block is fully OPTIONAL — a spec without `analytics` is silent (SDK
9
+ * defaults apply, exactly as today). When `analytics` IS set, this rule catches
10
+ * authoring drift before doctor/QA see it. Every check is `warning` severity:
11
+ * authoring guidance, not a build blocker (matches DesignSourceShape).
12
+ *
13
+ * Checks:
14
+ * 0. If `analytics` is present but not a plain object (a non-plain-object
15
+ * value: string, array, number, boolean, boxed primitive, class instance,
16
+ * etc.), warn once and stop — there is no contract shape to inspect.
17
+ * Genuinely-absent `analytics` stays silent (optional, non-gating).
18
+ * 1. `mode`, if present, is one of auto | manual | disabled.
19
+ * 2. Each provider: `blockedEvents` (when present) is a string[], and each
20
+ * entry is a known SDK `dl_*` event (a misspelled/legacy name like
21
+ * "purchase" blocks nothing — the original drift bug this keystone closes);
22
+ * an enabled gtm provider should declare `containerId`, facebook `pixelId`,
23
+ * custom `endpoint` (warning — the id is what doctor/QA bind to).
24
+ * 3. Each `out_of_band_pixels[]` entry has a non-empty `vendor`.
25
+ * 4. Each `manual_events[]` entry has a non-empty `event`; if it names a
26
+ * `page`, that page id must exist; a purchase manual event SHOULD name a
27
+ * page (the first-upsell placement footgun — beacons lost in the
28
+ * checkout→upsell redirect when placed on checkout).
29
+ * 5. Each `params.content[]` entry has a non-empty `name`; referenced `pages`
30
+ * must exist (a content param pointing at a missing page is the
31
+ * `?reviews=n`-with-no-handler gap, inverted).
32
+ * 6. `params.tracking.click_id`, when present, declares both `inbound` and
33
+ * `maps_to` (half a mapping silently drops the affiliate click id).
34
+ * 7. `params.tracking.preserve` / `utmTransfer.paramsToCopy`, when present,
35
+ * are string[].
36
+ * 8. If analytics is active (the block is present and mode is not
37
+ * "disabled"; missing mode means SDK defaults apply), runtime/global_config
38
+ * sdk_version should be an exact released semver >= the SDK identity
39
+ * baseline so events carry campaign/session ids.
40
+ */
41
+ import { CAMPAIGN_CART_ANALYTICS_IDENTITY_MIN_SDK_VERSION, isKnownDlEvent, } from "../analytics-vocabulary.js";
42
+ const VALID_MODES = new Set(['auto', 'manual', 'disabled']);
43
+ const PURCHASE_EVENT = /^(?:dl_)?purchase$/i;
44
+ function isNonEmptyString(value) {
45
+ return typeof value === 'string' && value.trim().length > 0;
46
+ }
47
+ function isStringArray(value) {
48
+ return Array.isArray(value) && value.every((v) => typeof v === 'string');
49
+ }
50
+ function isPlainAnalyticsObject(value) {
51
+ if (!value || typeof value !== 'object' || Array.isArray(value))
52
+ return false;
53
+ const proto = Object.getPrototypeOf(value);
54
+ return proto === Object.prototype || proto === null;
55
+ }
56
+ function parseReleasedVersion(version) {
57
+ const match = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)$/);
58
+ if (!match)
59
+ return null;
60
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
61
+ }
62
+ function compareVersions(a, b) {
63
+ const left = parseReleasedVersion(a);
64
+ const right = parseReleasedVersion(b);
65
+ if (!left || !right)
66
+ return null;
67
+ for (let i = 0; i < 3; i += 1) {
68
+ if (left[i] > right[i])
69
+ return 1;
70
+ if (left[i] < right[i])
71
+ return -1;
72
+ }
73
+ return 0;
74
+ }
75
+ function sdkVersionRef(spec) {
76
+ const runtimeVersion = spec.runtime?.sdk_version;
77
+ if (isNonEmptyString(runtimeVersion))
78
+ return { version: runtimeVersion, path: '/runtime/sdk_version' };
79
+ const globalVersion = spec.global_config?.sdk_version;
80
+ if (isNonEmptyString(globalVersion))
81
+ return { version: globalVersion, path: '/global_config/sdk_version' };
82
+ return null;
83
+ }
84
+ function analyticsContractIsActive(analytics) {
85
+ // A present analytics block with no mode still means "SDK defaults apply";
86
+ // either supported explicit disable mechanism opts out of the SDK identity
87
+ // baseline check.
88
+ return analytics.enabled !== false && analytics.mode !== 'disabled';
89
+ }
90
+ function collectPageIds(spec) {
91
+ const pageIds = new Set();
92
+ const checkoutPageIds = new Set();
93
+ for (const funnel of spec.funnels ?? []) {
94
+ for (const page of funnel.pages ?? []) {
95
+ if (isNonEmptyString(page.id)) {
96
+ pageIds.add(page.id);
97
+ if (page.type === 'checkout')
98
+ checkoutPageIds.add(page.id);
99
+ }
100
+ }
101
+ }
102
+ return { pageIds, checkoutPageIds };
103
+ }
104
+ export const AnalyticsContractShape = {
105
+ id: 'AnalyticsContractShape',
106
+ severity: 'warning',
107
+ // Dual tag is intentional and matches every sibling pure-spec rule
108
+ // (StoreProfileShape, DesignSourceShape, AssemblyHintsShape, …): the rule is
109
+ // cheap enough for per-keystroke Map Builder (`fast`) AND needs no live
110
+ // deployment (`spec-only`). `fastRules`/`specOnlyRules` are mutually-exclusive
111
+ // FILTERED VIEWS of `allRules` (see rules/index.ts) — a validation pass runs
112
+ // exactly one RuleSet, and `allRules` lists each rule once, so a dual-tagged
113
+ // rule never double-runs. (Surfaced in a downstream consumer-PR review.)
114
+ tags: ['fast', 'spec-only'],
115
+ check(spec) {
116
+ const violations = [];
117
+ const analytics = spec.analytics;
118
+ // Genuinely-absent analytics stays silent: the block is OPTIONAL and
119
+ // non-gating (SDK defaults apply). But a PRESENT-but-non-object value
120
+ // (`analytics: "auto"`, an array, a primitive) is authoring drift the
121
+ // author wants to hear about — warn, then stop (no shape to inspect).
122
+ if (analytics === undefined || analytics === null)
123
+ return violations;
124
+ // Reject any non-plain-object value: primitives, arrays, and boxed
125
+ // primitives (new String("auto") passes `typeof === 'object'` but is not a
126
+ // plain contract block). Only Object.prototype and null-prototype objects
127
+ // are accepted as valid analytics blocks.
128
+ if (!isPlainAnalyticsObject(analytics)) {
129
+ const label = Array.isArray(analytics) ? 'an array' : typeof analytics !== 'object' ? typeof analytics : 'a non-plain object';
130
+ violations.push({
131
+ ruleId: 'AnalyticsContractShape',
132
+ severity: 'warning',
133
+ message: 'analytics must be an object (the analytics/attribution contract block); got ' + label + '.',
134
+ path: '/analytics',
135
+ data: { check: 'analytics-shape' },
136
+ });
137
+ return violations;
138
+ }
139
+ const warn = (message, path, check, data = {}) => {
140
+ violations.push({
141
+ ruleId: 'AnalyticsContractShape',
142
+ severity: 'warning',
143
+ message,
144
+ path,
145
+ data: { check, ...data },
146
+ });
147
+ };
148
+ // 8. SDK identity baseline
149
+ const versionRef = sdkVersionRef(spec);
150
+ if (analyticsContractIsActive(analytics) && versionRef) {
151
+ const comparison = compareVersions(versionRef.version, CAMPAIGN_CART_ANALYTICS_IDENTITY_MIN_SDK_VERSION);
152
+ if (comparison === null) {
153
+ warn(`analytics is declared, but Campaign Cart SDK version "${versionRef.version}" is not an exact released semver pin. Use a concrete release such as "${CAMPAIGN_CART_ANALYTICS_IDENTITY_MIN_SDK_VERSION}" so Campaigns OS can verify whether campaign_* identifiers and ncsid-backed campaign_session_id are stamped on every event.`, versionRef.path, 'sdk-version-unparseable', {
154
+ sdkVersion: versionRef.version,
155
+ expectedFormat: 'MAJOR.MINOR.PATCH',
156
+ minimumSdkVersion: CAMPAIGN_CART_ANALYTICS_IDENTITY_MIN_SDK_VERSION,
157
+ });
158
+ }
159
+ else if (comparison < 0) {
160
+ warn(`analytics is declared, but Campaign Cart SDK ${versionRef.version} is below ${CAMPAIGN_CART_ANALYTICS_IDENTITY_MIN_SDK_VERSION}. campaign_* identifiers and ncsid-backed campaign_session_id are stamped on every event starting in ${CAMPAIGN_CART_ANALYTICS_IDENTITY_MIN_SDK_VERSION}; upgrade the SDK for analytics attribution/joinability or record the intentional pin.`, versionRef.path, 'sdk-identity-baseline', {
161
+ sdkVersion: versionRef.version,
162
+ minimumSdkVersion: CAMPAIGN_CART_ANALYTICS_IDENTITY_MIN_SDK_VERSION,
163
+ });
164
+ }
165
+ }
166
+ // 1. mode
167
+ if (analytics.mode !== undefined && !VALID_MODES.has(String(analytics.mode))) {
168
+ warn(`analytics.mode "${String(analytics.mode)}" is not recognized; expected auto | manual | disabled.`, '/analytics/mode', 'mode-invalid', { mode: analytics.mode });
169
+ }
170
+ // 2. providers
171
+ const providers = analytics.providers;
172
+ if (providers && typeof providers === 'object' && !Array.isArray(providers)) {
173
+ for (const [kind, provider] of Object.entries(providers)) {
174
+ if (!provider || typeof provider !== 'object')
175
+ continue;
176
+ const base = `/analytics/providers/${kind}`;
177
+ // Asymmetry accepted by design (downstream consumer-PR review): a
178
+ // provider with `enabled:false` that still carries containerId/pixelId/
179
+ // endpoint gets no signal. A disabled provider keeping its id is a
180
+ // legitimate, common pattern (staged rollout, env-toggled, kept for
181
+ // reference) — flagging it would be noise, and "off accidentally" is not
182
+ // distinguishable from "off by design" at spec level. We only validate
183
+ // ENABLED providers' binding ids; disabled providers are left alone.
184
+ const enabled = provider.enabled !== false;
185
+ if (provider.blockedEvents !== undefined && !isStringArray(provider.blockedEvents)) {
186
+ warn(`analytics.providers.${kind}.blockedEvents must be an array of event-name strings.`, `${base}/blockedEvents`, 'blocked-events-shape', { kind });
187
+ }
188
+ else if (isStringArray(provider.blockedEvents)) {
189
+ // Each blocked event must be a known SDK dl_* event. blockedEvents
190
+ // matches by EXACT event name at runtime, so a misspelled or legacy
191
+ // name (e.g. "purchase" instead of "dl_purchase") silently blocks
192
+ // nothing — caught here at doctor time, not in production.
193
+ const blocked = provider.blockedEvents;
194
+ blocked.forEach((evt, i) => {
195
+ if (!isKnownDlEvent(evt)) {
196
+ warn(`analytics.providers.${kind}.blockedEvents[${i}] "${evt}" is not a known SDK dl_* event. blockedEvents matches by exact event name, so a misspelled or legacy name (e.g. "purchase" instead of "dl_purchase") blocks nothing.`, `${base}/blockedEvents/${i}`, 'blocked-event-unknown', { kind, event: evt });
197
+ }
198
+ });
199
+ }
200
+ if (enabled && kind === 'gtm' && !isNonEmptyString(provider.containerId)) {
201
+ warn(`analytics.providers.gtm is enabled but has no containerId (e.g. "GTM-…"); doctor/QA bind to it.`, `${base}/containerId`, 'gtm-container-missing', { kind });
202
+ }
203
+ if (enabled && kind === 'facebook' && !isNonEmptyString(provider.pixelId)) {
204
+ warn(`analytics.providers.facebook is enabled but has no pixelId; doctor/QA bind to it.`, `${base}/pixelId`, 'facebook-pixel-missing', { kind });
205
+ }
206
+ if (enabled && kind === 'custom' && !isNonEmptyString(provider.endpoint)) {
207
+ warn(`analytics.providers.custom is enabled but has no endpoint URL.`, `${base}/endpoint`, 'custom-endpoint-missing', { kind });
208
+ }
209
+ }
210
+ }
211
+ const { pageIds, checkoutPageIds } = collectPageIds(spec);
212
+ // 3. out_of_band_pixels
213
+ if (analytics.out_of_band_pixels !== undefined) {
214
+ if (!Array.isArray(analytics.out_of_band_pixels)) {
215
+ warn(`analytics.out_of_band_pixels must be an array.`, '/analytics/out_of_band_pixels', 'oob-not-array');
216
+ }
217
+ else {
218
+ analytics.out_of_band_pixels.forEach((pixel, i) => {
219
+ if (!pixel || typeof pixel !== 'object' || !isNonEmptyString(pixel.vendor)) {
220
+ warn(`out_of_band_pixels[${i}] is missing a vendor (e.g. "everflow", "triplepixel").`, `/analytics/out_of_band_pixels/${i}/vendor`, 'oob-vendor-missing', { index: i });
221
+ }
222
+ });
223
+ }
224
+ }
225
+ // 4. manual_events
226
+ if (analytics.manual_events !== undefined) {
227
+ if (!Array.isArray(analytics.manual_events)) {
228
+ warn(`analytics.manual_events must be an array.`, '/analytics/manual_events', 'manual-events-not-array');
229
+ }
230
+ else {
231
+ analytics.manual_events.forEach((evt, i) => {
232
+ const base = `/analytics/manual_events/${i}`;
233
+ if (!evt || typeof evt !== 'object' || !isNonEmptyString(evt.event)) {
234
+ warn(`manual_events[${i}] is missing an event name.`, `${base}/event`, 'manual-event-name-missing', { index: i });
235
+ return;
236
+ }
237
+ if (isNonEmptyString(evt.page) && !pageIds.has(evt.page)) {
238
+ warn(`manual_events[${i}] (${evt.event}) names page "${evt.page}", which is not a page id in this spec.`, `${base}/page`, 'manual-event-page-unknown', { index: i, page: evt.page });
239
+ }
240
+ if (PURCHASE_EVENT.test(evt.event) && !isNonEmptyString(evt.page)) {
241
+ warn(`manual_events[${i}] is a purchase fire but declares no page. Purchase beacons placed on checkout are lost in the checkout→upsell redirect — declare the page they live on (typically the first upsell).`, `${base}/page`, 'manual-purchase-page-missing', { index: i });
242
+ }
243
+ if (PURCHASE_EVENT.test(evt.event) && isNonEmptyString(evt.page) && checkoutPageIds.has(evt.page)) {
244
+ warn(`manual_events[${i}] is a purchase fire on checkout page "${evt.page}". Purchase beacons on checkout are lost in the checkout→upsell redirect — move the fire to the first upsell page.`, `${base}/page`, 'manual-purchase-on-checkout', { index: i, page: evt.page });
245
+ }
246
+ });
247
+ }
248
+ }
249
+ // 5 + 6. params
250
+ const params = analytics.params;
251
+ if (params && typeof params === 'object') {
252
+ const content = params.content;
253
+ if (content !== undefined) {
254
+ if (!Array.isArray(content)) {
255
+ warn(`analytics.params.content must be an array.`, '/analytics/params/content', 'content-not-array');
256
+ }
257
+ else {
258
+ content.forEach((cp, i) => {
259
+ const base = `/analytics/params/content/${i}`;
260
+ if (!cp || typeof cp !== 'object' || !isNonEmptyString(cp.name)) {
261
+ warn(`params.content[${i}] is missing a param name.`, `${base}/name`, 'content-name-missing', { index: i });
262
+ return;
263
+ }
264
+ if (cp.pages !== undefined && !Array.isArray(cp.pages)) {
265
+ warn(`params.content[${i}] (?${cp.name}) pages must be an array of page-id strings.`, `${base}/pages`, 'content-pages-shape', { index: i, name: cp.name });
266
+ }
267
+ else if (Array.isArray(cp.pages) && cp.pages.length === 0) {
268
+ // Explicit empty array applies to no page — a silent no-op the
269
+ // author almost certainly didn't intend. Omit `pages` to apply to
270
+ // all pages, or list the ids to scope to.
271
+ warn(`params.content[${i}] (?${cp.name}) has an empty pages array, so it applies to no page. Omit pages to apply to all pages, or list the page ids it should scope to.`, `${base}/pages`, 'content-pages-empty', { index: i, name: cp.name });
272
+ }
273
+ else {
274
+ for (const pageRef of cp.pages ?? []) {
275
+ if (!pageIds.has(pageRef)) {
276
+ warn(`params.content[${i}] (?${cp.name}) references page "${pageRef}", which is not a page id in this spec.`, `${base}/pages`, 'content-page-unknown', { index: i, name: cp.name, page: pageRef });
277
+ }
278
+ }
279
+ }
280
+ });
281
+ }
282
+ }
283
+ const tracking = params.tracking;
284
+ if (tracking && typeof tracking === 'object') {
285
+ if (tracking.preserve !== undefined && !isStringArray(tracking.preserve)) {
286
+ warn(`analytics.params.tracking.preserve must be an array of param-name strings.`, '/analytics/params/tracking/preserve', 'preserve-shape');
287
+ }
288
+ const clickId = tracking.click_id;
289
+ if (clickId && typeof clickId === 'object') {
290
+ if (!isNonEmptyString(clickId.inbound) || !isNonEmptyString(clickId.maps_to)) {
291
+ warn(`analytics.params.tracking.click_id needs both "inbound" (the querystring param) and "maps_to" (the SDK attribution field); half a mapping silently drops the affiliate click id.`, '/analytics/params/tracking/click_id', 'click-id-incomplete');
292
+ }
293
+ }
294
+ }
295
+ }
296
+ // 7. utmTransfer.paramsToCopy
297
+ const utm = analytics.utmTransfer;
298
+ if (utm && typeof utm === 'object' && utm.paramsToCopy !== undefined && !isStringArray(utm.paramsToCopy)) {
299
+ warn(`analytics.utmTransfer.paramsToCopy must be an array of param-name strings.`, '/analytics/utmTransfer/paramsToCopy', 'utm-params-shape');
300
+ }
301
+ return violations;
302
+ },
303
+ };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * AssemblyHintsShape — validates the optional authoring-time build hints
3
+ * introduced in Slice 4a and extended in Slice 4b:
4
+ *
5
+ * 1. campaign.preferred_template_family — which starter family the
6
+ * campaign was authored against. Already read by campaigns-os
7
+ * preferredTemplateFamily() (three locations); this rule blesses
8
+ * the convention and warns when the value isn't a recognized
9
+ * family.
10
+ * 2. page.upsell_template_pattern — per-page UI variant hint
11
+ * (mv | bundle_tier_pills | bundle_tier_cards | single). Warns
12
+ * when set on a non-upsell page (meaningless) or when the value
13
+ * isn't recognized.
14
+ * 3. page.upsell_mv_tiers — per-page MV tier range `{min, max}`
15
+ * declaring the inclusive quantity-tier subset to render. Warns
16
+ * when set on a non-upsell page, when shape is malformed (missing
17
+ * field, non-integer, non-positive), or when min > max.
18
+ *
19
+ * All three are HINTS, not contracts. The build agent uses them as
20
+ * defaults; CLI/operator overrides win. Hence the warning severity
21
+ * across the board — these never block a build, they just nudge
22
+ * authoring quality.
23
+ *
24
+ * Doctrine note: template family is fundamentally a build-time
25
+ * decision and CLI/operator overrides always win. Authoring-time
26
+ * hints are allowed because designers / campaign owners often know
27
+ * the answer at authoring time, and re-deciding at build was
28
+ * repeated friction — hence warning severity, never a build blocker.
29
+ *
30
+ * Cross-field constraint enforcement (upsell_mv_tiers min <= max): the
31
+ * JSON Schema (schemas/campaign-runtime-build-packet.v0.schema.json)
32
+ * does NOT express this constraint — plain JSON Schema 2020-12 cannot
33
+ * say "field A must be less than or equal to field B" without
34
+ * $data-style extensions. Defense in depth lives at two layers
35
+ * instead: this rule warns the author at authoring/QA time, and the
36
+ * campaigns-os consumer's normalizedMvTiers() silently drops the field
37
+ * when min > max so a half-state spec never reaches the build agent
38
+ * through the packet. Both layers must stay aligned; loosening either
39
+ * one is a contract change worth a coordinated PR.
40
+ */
41
+ import type { Rule } from '../types.ts';
42
+ export declare const AssemblyHintsShape: Rule;
@@ -0,0 +1,191 @@
1
+ /**
2
+ * AssemblyHintsShape — validates the optional authoring-time build hints
3
+ * introduced in Slice 4a and extended in Slice 4b:
4
+ *
5
+ * 1. campaign.preferred_template_family — which starter family the
6
+ * campaign was authored against. Already read by campaigns-os
7
+ * preferredTemplateFamily() (three locations); this rule blesses
8
+ * the convention and warns when the value isn't a recognized
9
+ * family.
10
+ * 2. page.upsell_template_pattern — per-page UI variant hint
11
+ * (mv | bundle_tier_pills | bundle_tier_cards | single). Warns
12
+ * when set on a non-upsell page (meaningless) or when the value
13
+ * isn't recognized.
14
+ * 3. page.upsell_mv_tiers — per-page MV tier range `{min, max}`
15
+ * declaring the inclusive quantity-tier subset to render. Warns
16
+ * when set on a non-upsell page, when shape is malformed (missing
17
+ * field, non-integer, non-positive), or when min > max.
18
+ *
19
+ * All three are HINTS, not contracts. The build agent uses them as
20
+ * defaults; CLI/operator overrides win. Hence the warning severity
21
+ * across the board — these never block a build, they just nudge
22
+ * authoring quality.
23
+ *
24
+ * Doctrine note: template family is fundamentally a build-time
25
+ * decision and CLI/operator overrides always win. Authoring-time
26
+ * hints are allowed because designers / campaign owners often know
27
+ * the answer at authoring time, and re-deciding at build was
28
+ * repeated friction — hence warning severity, never a build blocker.
29
+ *
30
+ * Cross-field constraint enforcement (upsell_mv_tiers min <= max): the
31
+ * JSON Schema (schemas/campaign-runtime-build-packet.v0.schema.json)
32
+ * does NOT express this constraint — plain JSON Schema 2020-12 cannot
33
+ * say "field A must be less than or equal to field B" without
34
+ * $data-style extensions. Defense in depth lives at two layers
35
+ * instead: this rule warns the author at authoring/QA time, and the
36
+ * campaigns-os consumer's normalizedMvTiers() silently drops the field
37
+ * when min > max so a half-state spec never reaches the build agent
38
+ * through the packet. Both layers must stay aligned; loosening either
39
+ * one is a contract change worth a coordinated PR.
40
+ */
41
+ import { KNOWN_TEMPLATE_FAMILY_HINTS } from "../types.js";
42
+ const KNOWN_TEMPLATE_FAMILIES = new Set(KNOWN_TEMPLATE_FAMILY_HINTS);
43
+ const KNOWN_UPSELL_PATTERNS = new Set([
44
+ 'mv',
45
+ 'bundle_tier_pills',
46
+ 'bundle_tier_cards',
47
+ 'single',
48
+ ]);
49
+ function isNonEmptyString(value) {
50
+ return typeof value === 'string' && value.trim().length > 0;
51
+ }
52
+ function isPositiveInteger(value) {
53
+ return typeof value === 'number' && Number.isInteger(value) && value >= 1;
54
+ }
55
+ export const AssemblyHintsShape = {
56
+ id: 'AssemblyHintsShape',
57
+ severity: 'warning',
58
+ tags: ['fast', 'spec-only'],
59
+ check(spec) {
60
+ const violations = [];
61
+ // 1. Campaign-level template family hint.
62
+ const templateFamily = spec.campaign?.preferred_template_family;
63
+ if (templateFamily !== undefined) {
64
+ if (!isNonEmptyString(templateFamily)) {
65
+ violations.push({
66
+ ruleId: 'AssemblyHintsShape',
67
+ severity: 'warning',
68
+ message: 'campaign.preferred_template_family is set but empty; remove the field or set it to a known family.',
69
+ path: '/campaign/preferred_template_family',
70
+ data: { check: 'template-family-empty' },
71
+ });
72
+ }
73
+ else if (!KNOWN_TEMPLATE_FAMILIES.has(templateFamily)) {
74
+ violations.push({
75
+ ruleId: 'AssemblyHintsShape',
76
+ severity: 'warning',
77
+ message: `campaign.preferred_template_family "${templateFamily}" is not in the known set (${[...KNOWN_TEMPLATE_FAMILIES].sort().join(', ')}). The build agent will still try to use it as a hint, but consider correcting the value if this is a typo.`,
78
+ path: '/campaign/preferred_template_family',
79
+ data: { check: 'template-family-unknown', value: templateFamily },
80
+ });
81
+ }
82
+ }
83
+ // 2. Per-page upsell template pattern.
84
+ spec.funnels.forEach((funnel, funnelIdx) => {
85
+ const pages = funnel.pages ?? [];
86
+ pages.forEach((page, pageIdx) => {
87
+ const pattern = page.upsell_template_pattern;
88
+ if (pattern === undefined)
89
+ return;
90
+ const path = `/funnels/${funnelIdx}/pages/${pageIdx}/upsell_template_pattern`;
91
+ const pageLabel = page.label || page.id || '(unnamed page)';
92
+ if (!isNonEmptyString(pattern)) {
93
+ violations.push({
94
+ ruleId: 'AssemblyHintsShape',
95
+ severity: 'warning',
96
+ message: `"${pageLabel}" — upsell_template_pattern is set but empty; remove the field or pick one of: ${[...KNOWN_UPSELL_PATTERNS].join(', ')}.`,
97
+ path,
98
+ data: { pageId: page.id, check: 'pattern-empty' },
99
+ });
100
+ return;
101
+ }
102
+ if (page.type !== 'upsell') {
103
+ violations.push({
104
+ ruleId: 'AssemblyHintsShape',
105
+ severity: 'warning',
106
+ message: `"${pageLabel}" — upsell_template_pattern is set on a non-upsell page (type=${page.type}). The hint is meaningful only on upsell pages; remove it or move it to an upsell page.`,
107
+ path,
108
+ data: { pageId: page.id, pageType: page.type, check: 'pattern-on-non-upsell' },
109
+ });
110
+ }
111
+ if (!KNOWN_UPSELL_PATTERNS.has(pattern)) {
112
+ violations.push({
113
+ ruleId: 'AssemblyHintsShape',
114
+ severity: 'warning',
115
+ message: `"${pageLabel}" — upsell_template_pattern "${pattern}" is not in the known set (${[...KNOWN_UPSELL_PATTERNS].join(', ')}). Build will use it as a hint, but confirm the value matches a template-family variant.`,
116
+ path,
117
+ data: { pageId: page.id, check: 'pattern-unknown', value: pattern },
118
+ });
119
+ }
120
+ });
121
+ // 3. Per-page MV upsell tier range.
122
+ pages.forEach((page, pageIdx) => {
123
+ const tiers = page.upsell_mv_tiers;
124
+ if (tiers === undefined)
125
+ return;
126
+ const path = `/funnels/${funnelIdx}/pages/${pageIdx}/upsell_mv_tiers`;
127
+ const pageLabel = page.label || page.id || '(unnamed page)';
128
+ if (tiers === null || typeof tiers !== 'object' || Array.isArray(tiers)) {
129
+ violations.push({
130
+ ruleId: 'AssemblyHintsShape',
131
+ severity: 'warning',
132
+ message: `"${pageLabel}" — upsell_mv_tiers must be an object with numeric "min" and "max" fields; got ${Array.isArray(tiers) ? 'array' : typeof tiers}.`,
133
+ path,
134
+ data: { pageId: page.id, check: 'tiers-bad-shape' },
135
+ });
136
+ return;
137
+ }
138
+ const tiersObj = tiers;
139
+ const hasMin = 'min' in tiersObj;
140
+ const hasMax = 'max' in tiersObj;
141
+ // Extract to local bindings so TS can narrow across the type-guard
142
+ // calls below. Property accessors on a shared object don't carry
143
+ // narrowing through subsequent reads, which is what forced the
144
+ // earlier `as number` cast at the range check; local bindings fix
145
+ // that and the cast goes away.
146
+ const min = tiersObj.min;
147
+ const max = tiersObj.max;
148
+ if (page.type !== 'upsell') {
149
+ violations.push({
150
+ ruleId: 'AssemblyHintsShape',
151
+ severity: 'warning',
152
+ message: `"${pageLabel}" — upsell_mv_tiers is set on a non-upsell page (type=${page.type}). The hint is meaningful only on upsell pages; remove it or move it to an upsell page.`,
153
+ path,
154
+ data: { pageId: page.id, pageType: page.type, check: 'tiers-on-non-upsell' },
155
+ });
156
+ }
157
+ if (!hasMin || !hasMax) {
158
+ violations.push({
159
+ ruleId: 'AssemblyHintsShape',
160
+ severity: 'warning',
161
+ message: `"${pageLabel}" — upsell_mv_tiers is missing ${!hasMin && !hasMax ? 'both "min" and "max"' : !hasMin ? '"min"' : '"max"'}; declare both as positive integers (e.g. {"min": 1, "max": 5}) or remove the field.`,
162
+ path,
163
+ data: { pageId: page.id, check: 'tiers-missing-field', hasMin, hasMax },
164
+ });
165
+ return;
166
+ }
167
+ if (!isPositiveInteger(min) || !isPositiveInteger(max)) {
168
+ violations.push({
169
+ ruleId: 'AssemblyHintsShape',
170
+ severity: 'warning',
171
+ message: `"${pageLabel}" — upsell_mv_tiers requires positive integers for "min" and "max"; got min=${JSON.stringify(min)}, max=${JSON.stringify(max)}.`,
172
+ path,
173
+ data: { pageId: page.id, check: 'tiers-bad-type', min, max },
174
+ });
175
+ return;
176
+ }
177
+ // min and max are narrowed to number here via the type guards above.
178
+ if (min > max) {
179
+ violations.push({
180
+ ruleId: 'AssemblyHintsShape',
181
+ severity: 'warning',
182
+ message: `"${pageLabel}" — upsell_mv_tiers has min=${min} greater than max=${max}; swap the values or fix the range.`,
183
+ path,
184
+ data: { pageId: page.id, check: 'tiers-bad-range', min, max },
185
+ });
186
+ }
187
+ });
188
+ });
189
+ return violations;
190
+ },
191
+ };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * CampaignMetadata — bundles two campaign-level metadata warnings:
3
+ * 1. Missing payment_env_key — required for spec export
4
+ * 2. Missing ref_id — needed for multi-campaign API disambiguation
5
+ *
6
+ * Both are warning severity. Bundled into one rule because they share a
7
+ * domain (campaign metadata completeness) and no caller has expressed
8
+ * a need to subset them. If that need shows up, split into
9
+ * CampaignPaymentKey and CampaignRefId.
10
+ *
11
+ * Message text inherited verbatim from the pre-#110 validator at migration time.
12
+ */
13
+ import type { Rule } from '../types.ts';
14
+ export declare const CampaignMetadata: Rule;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * CampaignMetadata — bundles two campaign-level metadata warnings:
3
+ * 1. Missing payment_env_key — required for spec export
4
+ * 2. Missing ref_id — needed for multi-campaign API disambiguation
5
+ *
6
+ * Both are warning severity. Bundled into one rule because they share a
7
+ * domain (campaign metadata completeness) and no caller has expressed
8
+ * a need to subset them. If that need shows up, split into
9
+ * CampaignPaymentKey and CampaignRefId.
10
+ *
11
+ * Message text inherited verbatim from the pre-#110 validator at migration time.
12
+ */
13
+ export const CampaignMetadata = {
14
+ id: 'CampaignMetadata',
15
+ severity: 'warning',
16
+ tags: ['fast', 'spec-only'],
17
+ check(spec) {
18
+ const violations = [];
19
+ const campaign = spec.campaign ?? {};
20
+ if (!campaign.payment_env_key) {
21
+ violations.push({
22
+ ruleId: 'CampaignMetadata',
23
+ severity: 'warning',
24
+ message: 'No campaign loaded — campaign key required for spec export.',
25
+ path: '/campaign/payment_env_key',
26
+ data: { missing: 'payment_env_key' },
27
+ });
28
+ }
29
+ if (campaign.ref_id == null) {
30
+ violations.push({
31
+ ruleId: 'CampaignMetadata',
32
+ severity: 'warning',
33
+ message: 'Campaign has no numeric ref_id; API refresh cannot disambiguate multi-campaign responses.',
34
+ path: '/campaign/ref_id',
35
+ data: { missing: 'ref_id' },
36
+ });
37
+ }
38
+ return violations;
39
+ },
40
+ };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * CheckoutHasSuccessUrl — when the spec includes any upsell page, every
3
+ * checkout must declare SOME forward route so the runtime knows where to send
4
+ * the shopper after order placement.
5
+ *
6
+ * The check is on the shopper being able to continue, NOT on which field
7
+ * carried that intent. `success_url` and `next_page` express the same edge, and
8
+ * twelve pages across ten certified fixtures use `next_page` on their checkout
9
+ * — warning at those told authors to rename a field they had already filled in
10
+ * correctly. A
11
+ * campaign is a free-form journey; the tool has no business having an opinion
12
+ * about the field name when the route is unambiguous.
13
+ *
14
+ * The "any upsell present" precondition is the legacy heuristic for
15
+ * "this spec needs a multi-step post-checkout flow." Specs without upsells
16
+ * route to thankyou via the default thankyou path and don't need a
17
+ * checkout-level forward route declared.
18
+ *
19
+ * "Forward route" is whatever campaign-spec/routing.ts resolves — the same
20
+ * resolver that source intake and the QA topology extractor consume. Narrowing
21
+ * this rule is only safe because that edge now actually wires; narrowed alone
22
+ * it would have removed the sole signal on a still-dropped edge.
23
+ *
24
+ * The rule ID keeps its original name for consumer stability even though the
25
+ * condition is now field-agnostic. The violation path points at the page, not
26
+ * at /success_url, because no single field is the answer any more.
27
+ *
28
+ * Warning severity (not error) — preserves legacy classification.
29
+ */
30
+ import type { Rule } from '../types.ts';
31
+ export declare const CheckoutHasSuccessUrl: Rule;