@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,66 @@
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 { hasForwardRoute } from "../routing.js";
31
+ function specHasUpsell(spec) {
32
+ for (const funnel of spec.funnels) {
33
+ for (const page of funnel.pages ?? []) {
34
+ if (page.type === 'upsell')
35
+ return true;
36
+ }
37
+ }
38
+ return false;
39
+ }
40
+ export const CheckoutHasSuccessUrl = {
41
+ id: 'CheckoutHasSuccessUrl',
42
+ severity: 'warning',
43
+ tags: ['fast', 'structure', 'spec-only'],
44
+ check(spec) {
45
+ if (!specHasUpsell(spec))
46
+ return [];
47
+ const violations = [];
48
+ spec.funnels.forEach((funnel, funnelIdx) => {
49
+ const pages = funnel.pages ?? [];
50
+ pages.forEach((page, pageIdx) => {
51
+ if (page.type !== 'checkout')
52
+ return;
53
+ if (hasForwardRoute(page))
54
+ return;
55
+ violations.push({
56
+ ruleId: 'CheckoutHasSuccessUrl',
57
+ severity: 'warning',
58
+ message: 'Checkout has no forward route (set success_url or next_page to reach the first upsell).',
59
+ path: `/funnels/${funnelIdx}/pages/${pageIdx}`,
60
+ data: { pageId: page.id },
61
+ });
62
+ });
63
+ });
64
+ return violations;
65
+ },
66
+ };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * CycleDetection — flags any page whose routing eventually loops back to it.
3
+ *
4
+ * DFS with path tracking: distinguishes DAG convergence (two paths into the
5
+ * same page, fine) from true cycles (a path that revisits a page already in
6
+ * the current traversal).
7
+ *
8
+ * Tags: structure, spec-only. NOT fast — graph traversal can be O(pages * edges)
9
+ * in pathological specs, so Map Builder skips this in per-keystroke mode.
10
+ */
11
+ import type { Rule } from '../types.ts';
12
+ export declare const CycleDetection: Rule;
@@ -0,0 +1,141 @@
1
+ /**
2
+ * CycleDetection — flags any page whose routing eventually loops back to it.
3
+ *
4
+ * DFS with path tracking: distinguishes DAG convergence (two paths into the
5
+ * same page, fine) from true cycles (a path that revisits a page already in
6
+ * the current traversal).
7
+ *
8
+ * Tags: structure, spec-only. NOT fast — graph traversal can be O(pages * edges)
9
+ * in pathological specs, so Map Builder skips this in per-keystroke mode.
10
+ */
11
+ import { outgoingEdgeIds } from "../routing.js";
12
+ /**
13
+ * Outgoing edges come from the shared resolver, which returns the pair the
14
+ * runtime can actually traverse: the forward link plus the decline branch.
15
+ * NOT every declared field — a field shadowed by a higher-precedence one, or
16
+ * one the page type cannot satisfy (`success_url` off a checkout, #234),
17
+ * contributes no edge, so a loop through it is intentionally not reported.
18
+ * #233 landed that accuracy-over-breadth decision; this comment claimed the
19
+ * opposite until #234.
20
+ *
21
+ * This used to be a page-type switch that followed only `success_url` on a
22
+ * checkout. Source intake wires `next_page` on checkouts — twelve pages across
23
+ * ten certified fixtures do exactly that — so a loop through such an edge
24
+ * built successfully while staying invisible to this rule.
25
+ */
26
+ function getNextIds(page) {
27
+ return outgoingEdgeIds(page);
28
+ }
29
+ function collectPages(spec) {
30
+ const refs = [];
31
+ spec.funnels.forEach((funnel, funnelIdx) => {
32
+ const pages = funnel.pages ?? [];
33
+ pages.forEach((page, pageIdx) => {
34
+ refs.push({ page, funnelIdx, pageIdx });
35
+ });
36
+ });
37
+ return refs;
38
+ }
39
+ function buildPageMap(refs) {
40
+ const map = new Map();
41
+ for (const ref of refs) {
42
+ if (ref.page.id && !map.has(ref.page.id)) {
43
+ // Duplicate page IDs are caught by a separate rule
44
+ // (PageRouteUniqueness, not yet extracted). Keep the first.
45
+ map.set(ref.page.id, ref);
46
+ }
47
+ }
48
+ return map;
49
+ }
50
+ /**
51
+ * Returns the list of page IDs in the cycle reachable from `startId`, or
52
+ * null if no cycle is reachable. The first element of the returned list is
53
+ * the page that's revisited (the cycle's entry); subsequent elements are
54
+ * the cycle's path.
55
+ */
56
+ function findCycleFrom(startId, pageMap, fullyExplored) {
57
+ const inPath = [];
58
+ const inPathSet = new Set();
59
+ function dfs(id) {
60
+ if (inPathSet.has(id)) {
61
+ // Found a cycle. Slice the path from the revisited node.
62
+ const idx = inPath.indexOf(id);
63
+ return [...inPath.slice(idx), id];
64
+ }
65
+ if (fullyExplored.has(id))
66
+ return null;
67
+ inPath.push(id);
68
+ inPathSet.add(id);
69
+ const ref = pageMap.get(id);
70
+ if (ref) {
71
+ for (const next of getNextIds(ref.page)) {
72
+ const cycle = dfs(next);
73
+ if (cycle)
74
+ return cycle;
75
+ }
76
+ }
77
+ inPath.pop();
78
+ inPathSet.delete(id);
79
+ fullyExplored.add(id);
80
+ return null;
81
+ }
82
+ return dfs(startId);
83
+ }
84
+ export const CycleDetection = {
85
+ id: 'CycleDetection',
86
+ severity: 'error',
87
+ tags: ['structure', 'spec-only'],
88
+ check(spec) {
89
+ const refs = collectPages(spec);
90
+ const pageMap = buildPageMap(refs);
91
+ const violations = [];
92
+ // Only nodes whose entire traversal completed without finding a cycle are
93
+ // memoized. Share these across roots within this check, never across specs.
94
+ // Cyclic paths keep the existing first-cycle and diagnostic ordering policy.
95
+ const fullyExplored = new Set();
96
+ // Two-level dedup: a cycle reached from different starting positions
97
+ // produces different "entries" (the rotation depends on the starting node)
98
+ // but the same node SET. Canonicalize by sorted node IDs to dedupe.
99
+ const reportedSignatures = new Set();
100
+ // Skip outer iteration over pages already known to be in a reported cycle.
101
+ const inReportedCycle = new Set();
102
+ for (const ref of refs) {
103
+ if (!ref.page.id)
104
+ continue;
105
+ if (inReportedCycle.has(ref.page.id))
106
+ continue;
107
+ const cycle = findCycleFrom(ref.page.id, pageMap, fullyExplored);
108
+ if (!cycle || cycle.length === 0)
109
+ continue;
110
+ // cycle is [entry, ...path, entry] — drop the trailing duplicate for the signature.
111
+ const cycleNodes = cycle.slice(0, -1);
112
+ const signature = [...cycleNodes].sort().join('|');
113
+ if (reportedSignatures.has(signature)) {
114
+ for (const id of cycleNodes)
115
+ inReportedCycle.add(id);
116
+ continue;
117
+ }
118
+ reportedSignatures.add(signature);
119
+ for (const id of cycleNodes)
120
+ inReportedCycle.add(id);
121
+ const entryId = cycle[0];
122
+ const entryRef = pageMap.get(entryId);
123
+ const path = entryRef
124
+ ? `/funnels/${entryRef.funnelIdx}/pages/${entryRef.pageIdx}`
125
+ : '/funnels';
126
+ // Any cycle is release-blocking. Draft UIs can choose to filter or soften
127
+ // this rule, but exported/compiled specs must not route back to themselves.
128
+ const isSelfLoop = cycle.length === 2 && cycle[0] === cycle[1];
129
+ violations.push({
130
+ ruleId: 'CycleDetection',
131
+ severity: 'error',
132
+ message: isSelfLoop
133
+ ? `Page "${entryRef?.page.label ?? entryId}" routes to itself.`
134
+ : `Circular route detected starting at "${entryRef?.page.label ?? entryId}". Cycle: ${cycle.join(' → ')}`,
135
+ path,
136
+ data: { cycle, entryPageId: entryId },
137
+ });
138
+ }
139
+ return violations;
140
+ },
141
+ };
@@ -0,0 +1,29 @@
1
+ /**
2
+ * DesignSourceShape — validates the per-page `design_source` block when
3
+ * present. `design_source` is the pointer used by figma-sections-export
4
+ * (and future design-tool exporters) to locate prepared HTML; campaigns-os
5
+ * doctor reads it to distinguish "designer hasn't exported yet" from
6
+ * generic `collect-inputs` when no source-html manifest is found.
7
+ *
8
+ * The block is fully optional — a page without `design_source` is silent
9
+ * (existing manual handoff workflow keeps working). When `design_source`
10
+ * IS set, this rule catches authoring drift before doctor sees it:
11
+ *
12
+ * 1. `type` must be a non-empty string (warning — currently only
13
+ * "figma" has tooling support, but we accept other strings so the
14
+ * schema doesn't break ahead of new exporters).
15
+ * 2. `file_url` must be present and look like a real URL (warning).
16
+ * 3. For `type: "figma"`, the file_url should be a figma.com URL
17
+ * (warning — easy paste mistake).
18
+ * 4. `breakpoints` should declare at least one of desktop/tablet/mobile
19
+ * when present (warning — empty breakpoints object is meaningless).
20
+ * 5. Each non-empty breakpoint value should look like a URL (warning).
21
+ * 6. For `type: "figma"`, breakpoint URLs should be figma.com selection
22
+ * URLs (warning — same paste-mistake guard as file_url).
23
+ *
24
+ * Severity is `warning` across the board: this is authoring guidance, not
25
+ * a build blocker. The export tool itself is the real validator at handoff
26
+ * time; this rule keeps Map Builder authors honest while editing.
27
+ */
28
+ import type { Rule } from '../types.ts';
29
+ export declare const DesignSourceShape: Rule;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * DesignSourceShape — validates the per-page `design_source` block when
3
+ * present. `design_source` is the pointer used by figma-sections-export
4
+ * (and future design-tool exporters) to locate prepared HTML; campaigns-os
5
+ * doctor reads it to distinguish "designer hasn't exported yet" from
6
+ * generic `collect-inputs` when no source-html manifest is found.
7
+ *
8
+ * The block is fully optional — a page without `design_source` is silent
9
+ * (existing manual handoff workflow keeps working). When `design_source`
10
+ * IS set, this rule catches authoring drift before doctor sees it:
11
+ *
12
+ * 1. `type` must be a non-empty string (warning — currently only
13
+ * "figma" has tooling support, but we accept other strings so the
14
+ * schema doesn't break ahead of new exporters).
15
+ * 2. `file_url` must be present and look like a real URL (warning).
16
+ * 3. For `type: "figma"`, the file_url should be a figma.com URL
17
+ * (warning — easy paste mistake).
18
+ * 4. `breakpoints` should declare at least one of desktop/tablet/mobile
19
+ * when present (warning — empty breakpoints object is meaningless).
20
+ * 5. Each non-empty breakpoint value should look like a URL (warning).
21
+ * 6. For `type: "figma"`, breakpoint URLs should be figma.com selection
22
+ * URLs (warning — same paste-mistake guard as file_url).
23
+ *
24
+ * Severity is `warning` across the board: this is authoring guidance, not
25
+ * a build blocker. The export tool itself is the real validator at handoff
26
+ * time; this rule keeps Map Builder authors honest while editing.
27
+ */
28
+ const FIGMA_URL_PATTERN = /^https:\/\/(?:www\.)?figma\.com\//i;
29
+ const ANY_URL_PATTERN = /^https?:\/\/\S+/i;
30
+ function isNonEmptyString(value) {
31
+ return typeof value === 'string' && value.trim().length > 0;
32
+ }
33
+ export const DesignSourceShape = {
34
+ id: 'DesignSourceShape',
35
+ severity: 'warning',
36
+ tags: ['fast', 'spec-only'],
37
+ check(spec) {
38
+ const violations = [];
39
+ spec.funnels.forEach((funnel, funnelIdx) => {
40
+ const pages = funnel.pages ?? [];
41
+ pages.forEach((page, pageIdx) => {
42
+ const design = page.design_source;
43
+ if (!design)
44
+ return;
45
+ const basePath = `/funnels/${funnelIdx}/pages/${pageIdx}/design_source`;
46
+ const pageLabel = page.label || page.id || '(unnamed page)';
47
+ // 1. type
48
+ if (!isNonEmptyString(design.type)) {
49
+ violations.push({
50
+ ruleId: 'DesignSourceShape',
51
+ severity: 'warning',
52
+ message: `"${pageLabel}" — design_source.type is missing; expected a string like "figma".`,
53
+ path: `${basePath}/type`,
54
+ data: { pageId: page.id, check: 'type-missing' },
55
+ });
56
+ }
57
+ // 2. file_url presence and URL-shape
58
+ if (!isNonEmptyString(design.file_url)) {
59
+ violations.push({
60
+ ruleId: 'DesignSourceShape',
61
+ severity: 'warning',
62
+ message: `"${pageLabel}" — design_source.file_url is missing; expected the design-tool file URL.`,
63
+ path: `${basePath}/file_url`,
64
+ data: { pageId: page.id, check: 'file-url-missing' },
65
+ });
66
+ }
67
+ else if (!ANY_URL_PATTERN.test(design.file_url)) {
68
+ violations.push({
69
+ ruleId: 'DesignSourceShape',
70
+ severity: 'warning',
71
+ message: `"${pageLabel}" — design_source.file_url does not look like a URL: ${truncate(design.file_url)}`,
72
+ path: `${basePath}/file_url`,
73
+ data: { pageId: page.id, check: 'file-url-shape', value: design.file_url },
74
+ });
75
+ }
76
+ else if (design.type === 'figma' && !FIGMA_URL_PATTERN.test(design.file_url)) {
77
+ // 3. figma-specific: must be a figma.com URL
78
+ violations.push({
79
+ ruleId: 'DesignSourceShape',
80
+ severity: 'warning',
81
+ message: `"${pageLabel}" — design_source.type is "figma" but file_url is not a figma.com URL: ${truncate(design.file_url)}`,
82
+ path: `${basePath}/file_url`,
83
+ data: { pageId: page.id, check: 'file-url-not-figma', value: design.file_url },
84
+ });
85
+ }
86
+ // 4 + 5 + 6. breakpoints
87
+ if (design.breakpoints !== undefined) {
88
+ const bps = design.breakpoints;
89
+ const known = ['desktop', 'tablet', 'mobile'];
90
+ const declared = known.filter((bp) => isNonEmptyString(bps[bp]));
91
+ if (declared.length === 0) {
92
+ violations.push({
93
+ ruleId: 'DesignSourceShape',
94
+ severity: 'warning',
95
+ message: `"${pageLabel}" — design_source.breakpoints is present but no desktop/tablet/mobile URL is set.`,
96
+ path: `${basePath}/breakpoints`,
97
+ data: { pageId: page.id, check: 'breakpoints-empty' },
98
+ });
99
+ }
100
+ for (const bp of known) {
101
+ const value = bps[bp];
102
+ if (value === undefined)
103
+ continue;
104
+ if (!isNonEmptyString(value)) {
105
+ violations.push({
106
+ ruleId: 'DesignSourceShape',
107
+ severity: 'warning',
108
+ message: `"${pageLabel}" — design_source.breakpoints.${bp} is set but empty.`,
109
+ path: `${basePath}/breakpoints/${bp}`,
110
+ data: { pageId: page.id, check: 'breakpoint-empty', breakpoint: bp },
111
+ });
112
+ continue;
113
+ }
114
+ if (!ANY_URL_PATTERN.test(value)) {
115
+ violations.push({
116
+ ruleId: 'DesignSourceShape',
117
+ severity: 'warning',
118
+ message: `"${pageLabel}" — design_source.breakpoints.${bp} does not look like a URL: ${truncate(value)}`,
119
+ path: `${basePath}/breakpoints/${bp}`,
120
+ data: { pageId: page.id, check: 'breakpoint-url-shape', breakpoint: bp, value },
121
+ });
122
+ continue;
123
+ }
124
+ if (design.type === 'figma' && !FIGMA_URL_PATTERN.test(value)) {
125
+ violations.push({
126
+ ruleId: 'DesignSourceShape',
127
+ severity: 'warning',
128
+ message: `"${pageLabel}" — design_source.type is "figma" but breakpoints.${bp} is not a figma.com URL: ${truncate(value)}`,
129
+ path: `${basePath}/breakpoints/${bp}`,
130
+ data: { pageId: page.id, check: 'breakpoint-not-figma', breakpoint: bp, value },
131
+ });
132
+ }
133
+ }
134
+ }
135
+ });
136
+ });
137
+ return violations;
138
+ },
139
+ };
140
+ function truncate(value, max = 80) {
141
+ return value.length > max ? value.slice(0, max - 1) + '…' : value;
142
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * DownsellWithoutUpsell — flags specs with downsell pages but no upsell.
3
+ * A downsell-only flow is unusual (downsells are typically reached from an
4
+ * upsell decline path); warn rather than error so the operator can confirm
5
+ * intent if it's deliberate.
6
+ *
7
+ * Warning severity (not error) — preserves legacy classification.
8
+ * Message text preserved verbatim.
9
+ */
10
+ import type { Rule } from '../types.ts';
11
+ export declare const DownsellWithoutUpsell: Rule;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * DownsellWithoutUpsell — flags specs with downsell pages but no upsell.
3
+ * A downsell-only flow is unusual (downsells are typically reached from an
4
+ * upsell decline path); warn rather than error so the operator can confirm
5
+ * intent if it's deliberate.
6
+ *
7
+ * Warning severity (not error) — preserves legacy classification.
8
+ * Message text preserved verbatim.
9
+ */
10
+ export const DownsellWithoutUpsell = {
11
+ id: 'DownsellWithoutUpsell',
12
+ severity: 'warning',
13
+ tags: ['fast', 'structure', 'spec-only'],
14
+ check(spec) {
15
+ let hasDownsell = false;
16
+ let hasUpsell = false;
17
+ for (const funnel of spec.funnels) {
18
+ for (const page of funnel.pages ?? []) {
19
+ if (page.type === 'downsell')
20
+ hasDownsell = true;
21
+ else if (page.type === 'upsell')
22
+ hasUpsell = true;
23
+ if (hasDownsell && hasUpsell)
24
+ return [];
25
+ }
26
+ }
27
+ if (!hasDownsell)
28
+ return [];
29
+ if (hasUpsell)
30
+ return [];
31
+ return [
32
+ {
33
+ ruleId: 'DownsellWithoutUpsell',
34
+ severity: 'warning',
35
+ message: 'Downsell pages present but no Upsell pages — intentional?',
36
+ path: '/funnels',
37
+ },
38
+ ];
39
+ },
40
+ };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * ExitIntentValidation — the heaviest single rule in the registry. Validates
3
+ * everything about per-page `exit_intent` configuration:
4
+ *
5
+ * 1. Placement: exit intent on a non-checkout page (warning — 0.4.x
6
+ * runtime contract expects checkout-level exit intent)
7
+ * 2. Presence: enabled but no offer_ref_id (error)
8
+ * 3. Presence: enabled but no offer_code (error)
9
+ * 4. Catalog: offer_ref_id not in spec.offers (error)
10
+ * 5. Catalog: offer_code not in spec.offers (error)
11
+ * 6. Consistency: ref offer has no `code` field (error — nothing to apply)
12
+ * 7. Consistency: offer_code doesn't match ref offer's `code` (error)
13
+ * 8. Catalog: exit intent enabled with no offers in catalog at all (warning)
14
+ *
15
+ * Kept as one rule because every check shares the same precondition
16
+ * (`exit_intent?.enabled`) and the same domain (exit intent integrity).
17
+ * Splitting would force callers to opt in/out 8 ways for one concept. If a
18
+ * caller needs to subset, they filter the resulting violations by `data.check`.
19
+ *
20
+ * Message text inherited verbatim from the pre-#110 validator at migration time.
21
+ */
22
+ import type { Rule } from '../types.ts';
23
+ export declare const ExitIntentValidation: Rule;
@@ -0,0 +1,147 @@
1
+ /**
2
+ * ExitIntentValidation — the heaviest single rule in the registry. Validates
3
+ * everything about per-page `exit_intent` configuration:
4
+ *
5
+ * 1. Placement: exit intent on a non-checkout page (warning — 0.4.x
6
+ * runtime contract expects checkout-level exit intent)
7
+ * 2. Presence: enabled but no offer_ref_id (error)
8
+ * 3. Presence: enabled but no offer_code (error)
9
+ * 4. Catalog: offer_ref_id not in spec.offers (error)
10
+ * 5. Catalog: offer_code not in spec.offers (error)
11
+ * 6. Consistency: ref offer has no `code` field (error — nothing to apply)
12
+ * 7. Consistency: offer_code doesn't match ref offer's `code` (error)
13
+ * 8. Catalog: exit intent enabled with no offers in catalog at all (warning)
14
+ *
15
+ * Kept as one rule because every check shares the same precondition
16
+ * (`exit_intent?.enabled`) and the same domain (exit intent integrity).
17
+ * Splitting would force callers to opt in/out 8 ways for one concept. If a
18
+ * caller needs to subset, they filter the resulting violations by `data.check`.
19
+ *
20
+ * Message text inherited verbatim from the pre-#110 validator at migration time.
21
+ */
22
+ function isTruthyString(value) {
23
+ return typeof value === 'string' && value.trim().length > 0;
24
+ }
25
+ function offerDisplayName(offer) {
26
+ return offer.name || offer.code || String(offer.ref_id) || '(unnamed offer)';
27
+ }
28
+ function buildOfferIndex(offers) {
29
+ const byRefId = new Map();
30
+ const byCode = new Map();
31
+ for (const offer of offers) {
32
+ byRefId.set(String(offer.ref_id), offer);
33
+ if (offer.code)
34
+ byCode.set(String(offer.code).toUpperCase(), offer);
35
+ }
36
+ return { byRefId, byCode };
37
+ }
38
+ export const ExitIntentValidation = {
39
+ id: 'ExitIntentValidation',
40
+ severity: 'error',
41
+ tags: ['fast', 'references', 'spec-only'],
42
+ check(spec) {
43
+ const catalog = spec.offers ?? [];
44
+ const index = buildOfferIndex(catalog);
45
+ const violations = [];
46
+ spec.funnels.forEach((funnel, funnelIdx) => {
47
+ const pages = funnel.pages ?? [];
48
+ pages.forEach((page, pageIdx) => {
49
+ const exit = page.exit_intent;
50
+ if (!exit?.enabled)
51
+ return;
52
+ const path = `/funnels/${funnelIdx}/pages/${pageIdx}/exit_intent`;
53
+ const ref = exit.offer_ref_id;
54
+ const code = exit.offer_code;
55
+ const refOffer = ref ? index.byRefId.get(String(ref)) : null;
56
+ const codeOffer = code ? index.byCode.get(String(code).toUpperCase()) : null;
57
+ // 1. Placement
58
+ if (page.type !== 'checkout') {
59
+ violations.push({
60
+ ruleId: 'ExitIntentValidation',
61
+ severity: 'warning',
62
+ message: `"${page.label}" — Exit intent is configured on a non-checkout page. The 0.4.x runtime contract expects checkout-level exit intent.`,
63
+ path,
64
+ data: { pageId: page.id, check: 'placement', pageType: page.type },
65
+ });
66
+ }
67
+ // 2 & 3. Presence
68
+ if (!ref) {
69
+ violations.push({
70
+ ruleId: 'ExitIntentValidation',
71
+ severity: 'error',
72
+ message: `"${page.label}" — Exit intent enabled but no offer assigned.`,
73
+ path: `${path}/offer_ref_id`,
74
+ data: { pageId: page.id, check: 'missing_ref' },
75
+ });
76
+ }
77
+ if (!code) {
78
+ violations.push({
79
+ ruleId: 'ExitIntentValidation',
80
+ severity: 'error',
81
+ message: `"${page.label}" — Exit intent enabled but no offer code assigned.`,
82
+ path: `${path}/offer_code`,
83
+ data: { pageId: page.id, check: 'missing_code' },
84
+ });
85
+ }
86
+ // 4 & 5. Catalog presence
87
+ if (catalog.length > 0 && ref && !refOffer) {
88
+ violations.push({
89
+ ruleId: 'ExitIntentValidation',
90
+ severity: 'error',
91
+ message: `"${page.label}" — Exit intent offer "${ref || code}" is not present in campaign offers. Refresh API data or choose a valid offer.`,
92
+ path: `${path}/offer_ref_id`,
93
+ data: { pageId: page.id, check: 'ref_not_in_catalog', refId: ref },
94
+ });
95
+ }
96
+ if (catalog.length > 0 && code && !codeOffer) {
97
+ violations.push({
98
+ ruleId: 'ExitIntentValidation',
99
+ severity: 'error',
100
+ message: `"${page.label}" — Exit intent offer code "${code}" is not present in campaign offers. Refresh API data or choose a valid offer.`,
101
+ path: `${path}/offer_code`,
102
+ data: { pageId: page.id, check: 'code_not_in_catalog', code },
103
+ });
104
+ }
105
+ // 6. Ref offer has no code
106
+ if (refOffer && !isTruthyString(refOffer.code)) {
107
+ violations.push({
108
+ ruleId: 'ExitIntentValidation',
109
+ severity: 'error',
110
+ message: `"${page.label}" — Exit intent offer "${offerDisplayName(refOffer)}" has no voucher/code to apply at checkout.`,
111
+ path: `${path}/offer_ref_id`,
112
+ data: { pageId: page.id, check: 'ref_offer_lacks_code', refId: ref },
113
+ });
114
+ }
115
+ // 7. Code mismatch
116
+ if (refOffer &&
117
+ isTruthyString(code) &&
118
+ isTruthyString(refOffer.code) &&
119
+ String(code).toUpperCase() !== String(refOffer.code).toUpperCase()) {
120
+ violations.push({
121
+ ruleId: 'ExitIntentValidation',
122
+ severity: 'error',
123
+ message: `"${page.label}" — Exit intent offer code "${code}" does not match campaign offer code "${refOffer.code}".`,
124
+ path: `${path}/offer_code`,
125
+ data: {
126
+ pageId: page.id,
127
+ check: 'code_ref_mismatch',
128
+ providedCode: code,
129
+ expectedCode: refOffer.code,
130
+ },
131
+ });
132
+ }
133
+ // 8. Empty catalog
134
+ if (catalog.length === 0) {
135
+ violations.push({
136
+ ruleId: 'ExitIntentValidation',
137
+ severity: 'warning',
138
+ message: `"${page.label}" — Exit intent offer cannot be verified because campaign offers are missing from the spec.`,
139
+ path,
140
+ data: { pageId: page.id, check: 'empty_catalog' },
141
+ });
142
+ }
143
+ });
144
+ });
145
+ return violations;
146
+ },
147
+ };
@@ -0,0 +1,9 @@
1
+ /**
2
+ * FunnelCount — enforces the funnel cardinality bounds: at least 1, at most 10.
3
+ *
4
+ * Message text inherited verbatim from the pre-#110 validator at migration time. The upper
5
+ * bound (10) is a soft platform cap; revisit in a separate ADR if a real use
6
+ * case needs more.
7
+ */
8
+ import type { Rule } from '../types.ts';
9
+ export declare const FunnelCount: Rule;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * FunnelCount — enforces the funnel cardinality bounds: at least 1, at most 10.
3
+ *
4
+ * Message text inherited verbatim from the pre-#110 validator at migration time. The upper
5
+ * bound (10) is a soft platform cap; revisit in a separate ADR if a real use
6
+ * case needs more.
7
+ */
8
+ const MIN_FUNNELS = 1;
9
+ const MAX_FUNNELS = 10;
10
+ export const FunnelCount = {
11
+ id: 'FunnelCount',
12
+ severity: 'error',
13
+ tags: ['fast', 'structure', 'spec-only'],
14
+ check(spec) {
15
+ const count = spec.funnels?.length ?? 0;
16
+ const violations = [];
17
+ if (count < MIN_FUNNELS) {
18
+ violations.push({
19
+ ruleId: 'FunnelCount',
20
+ severity: 'error',
21
+ message: 'Spec must define at least 1 funnel.',
22
+ path: '/funnels',
23
+ data: { count, min: MIN_FUNNELS },
24
+ });
25
+ }
26
+ else if (count > MAX_FUNNELS) {
27
+ violations.push({
28
+ ruleId: 'FunnelCount',
29
+ severity: 'error',
30
+ message: `Spec defines ${count} funnels; max is ${MAX_FUNNELS}.`,
31
+ path: '/funnels',
32
+ data: { count, max: MAX_FUNNELS },
33
+ });
34
+ }
35
+ return violations;
36
+ },
37
+ };