@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,47 @@
1
+ /**
2
+ * PageIdUniqueness — page IDs must be globally unique across all funnels
3
+ * because routing targets (next_page, success_url, on_accept, on_decline)
4
+ * reference pages by id alone with no funnel scope.
5
+ *
6
+ * Reports each duplicated id exactly once, at the path of the SECOND
7
+ * occurrence (the offender, not the original). Pages without an id are
8
+ * silently skipped — that's a different concern (page identity, not yet
9
+ * extracted as its own rule).
10
+ *
11
+ * Message text inherited verbatim from the pre-#110 validator at migration time.
12
+ */
13
+ export const PageIdUniqueness = {
14
+ id: 'PageIdUniqueness',
15
+ severity: 'error',
16
+ tags: ['fast', 'structure', 'spec-only'],
17
+ check(spec) {
18
+ const seen = new Map();
19
+ const reported = new Set();
20
+ const violations = [];
21
+ spec.funnels.forEach((funnel, funnelIdx) => {
22
+ const pages = funnel.pages ?? [];
23
+ pages.forEach((page, pageIdx) => {
24
+ if (!page.id)
25
+ return;
26
+ if (seen.has(page.id) && !reported.has(page.id)) {
27
+ reported.add(page.id);
28
+ violations.push({
29
+ ruleId: 'PageIdUniqueness',
30
+ severity: 'error',
31
+ message: `Duplicate page id "${page.id}" across funnels — page IDs must be globally unique.`,
32
+ path: `/funnels/${funnelIdx}/pages/${pageIdx}/id`,
33
+ data: {
34
+ pageId: page.id,
35
+ firstOccurrence: seen.get(page.id),
36
+ secondOccurrence: { funnelIdx, pageIdx },
37
+ },
38
+ });
39
+ }
40
+ else if (!seen.has(page.id)) {
41
+ seen.set(page.id, { funnelIdx, pageIdx });
42
+ }
43
+ });
44
+ });
45
+ return violations;
46
+ },
47
+ };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * PromoCodeInputValidation — validates checkout-level `promo_code_input`
3
+ * configuration. This mirrors the mapped-offer integrity checks for exit
4
+ * intent because both surfaces apply a voucher/code-backed Offer through the
5
+ * same Campaign Cart SDK/Campaigns API pricing path.
6
+ */
7
+ import type { Rule } from '../types.ts';
8
+ export declare const PromoCodeInputValidation: Rule;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * PromoCodeInputValidation — validates checkout-level `promo_code_input`
3
+ * configuration. This mirrors the mapped-offer integrity checks for exit
4
+ * intent because both surfaces apply a voucher/code-backed Offer through the
5
+ * same Campaign Cart SDK/Campaigns API pricing path.
6
+ */
7
+ function isTruthyString(value) {
8
+ return typeof value === 'string' && value.trim().length > 0;
9
+ }
10
+ function offerDisplayName(offer) {
11
+ return offer.name || offer.code || String(offer.ref_id) || '(unnamed offer)';
12
+ }
13
+ function buildOfferIndex(offers) {
14
+ const byRefId = new Map();
15
+ const byCode = new Map();
16
+ for (const offer of offers) {
17
+ byRefId.set(String(offer.ref_id), offer);
18
+ if (offer.code)
19
+ byCode.set(String(offer.code).toUpperCase(), offer);
20
+ }
21
+ return { byRefId, byCode };
22
+ }
23
+ export const PromoCodeInputValidation = {
24
+ id: 'PromoCodeInputValidation',
25
+ severity: 'error',
26
+ tags: ['fast', 'references', 'spec-only'],
27
+ check(spec) {
28
+ const catalog = spec.offers ?? [];
29
+ const index = buildOfferIndex(catalog);
30
+ const violations = [];
31
+ spec.funnels.forEach((funnel, funnelIdx) => {
32
+ const pages = funnel.pages ?? [];
33
+ pages.forEach((page, pageIdx) => {
34
+ const promo = page.promo_code_input;
35
+ if (!promo?.enabled)
36
+ return;
37
+ const path = `/funnels/${funnelIdx}/pages/${pageIdx}/promo_code_input`;
38
+ const ref = promo.offer_ref_id;
39
+ const code = promo.offer_code;
40
+ const refOffer = ref ? index.byRefId.get(String(ref)) : null;
41
+ const codeOffer = code ? index.byCode.get(String(code).toUpperCase()) : null;
42
+ if (page.type !== 'checkout') {
43
+ violations.push({
44
+ ruleId: 'PromoCodeInputValidation',
45
+ severity: 'warning',
46
+ message: `"${page.label}" — Promo code input is configured on a non-checkout page.`,
47
+ path,
48
+ data: { pageId: page.id, check: 'placement', pageType: page.type },
49
+ });
50
+ }
51
+ if (!ref) {
52
+ violations.push({
53
+ ruleId: 'PromoCodeInputValidation',
54
+ severity: 'error',
55
+ message: `"${page.label}" — Promo code input enabled but no offer assigned.`,
56
+ path: `${path}/offer_ref_id`,
57
+ data: { pageId: page.id, check: 'missing_ref' },
58
+ });
59
+ }
60
+ if (!code) {
61
+ violations.push({
62
+ ruleId: 'PromoCodeInputValidation',
63
+ severity: 'error',
64
+ message: `"${page.label}" — Promo code input enabled but no offer code assigned.`,
65
+ path: `${path}/offer_code`,
66
+ data: { pageId: page.id, check: 'missing_code' },
67
+ });
68
+ }
69
+ if (catalog.length > 0 && ref && !refOffer) {
70
+ violations.push({
71
+ ruleId: 'PromoCodeInputValidation',
72
+ severity: 'error',
73
+ message: `"${page.label}" — Promo code input offer "${ref || code}" is not present in campaign offers. Refresh API data or choose a valid offer.`,
74
+ path: `${path}/offer_ref_id`,
75
+ data: { pageId: page.id, check: 'ref_not_in_catalog', refId: ref },
76
+ });
77
+ }
78
+ if (catalog.length > 0 && code && !codeOffer) {
79
+ violations.push({
80
+ ruleId: 'PromoCodeInputValidation',
81
+ severity: 'error',
82
+ message: `"${page.label}" — Promo code input offer code "${code}" is not present in campaign offers. Refresh API data or choose a valid offer.`,
83
+ path: `${path}/offer_code`,
84
+ data: { pageId: page.id, check: 'code_not_in_catalog', code },
85
+ });
86
+ }
87
+ if (refOffer && !isTruthyString(refOffer.code)) {
88
+ violations.push({
89
+ ruleId: 'PromoCodeInputValidation',
90
+ severity: 'error',
91
+ message: `"${page.label}" — Promo code input offer "${offerDisplayName(refOffer)}" has no voucher/code to apply at checkout.`,
92
+ path: `${path}/offer_ref_id`,
93
+ data: { pageId: page.id, check: 'ref_offer_lacks_code', refId: ref },
94
+ });
95
+ }
96
+ if (refOffer &&
97
+ isTruthyString(code) &&
98
+ isTruthyString(refOffer.code) &&
99
+ String(code).toUpperCase() !== String(refOffer.code).toUpperCase()) {
100
+ violations.push({
101
+ ruleId: 'PromoCodeInputValidation',
102
+ severity: 'error',
103
+ message: `"${page.label}" — Promo code input offer code "${code}" does not match campaign offer code "${refOffer.code}".`,
104
+ path: `${path}/offer_code`,
105
+ data: {
106
+ pageId: page.id,
107
+ check: 'code_ref_mismatch',
108
+ providedCode: code,
109
+ expectedCode: refOffer.code,
110
+ },
111
+ });
112
+ }
113
+ if (catalog.length === 0) {
114
+ violations.push({
115
+ ruleId: 'PromoCodeInputValidation',
116
+ severity: 'warning',
117
+ message: `"${page.label}" — Promo code input offer cannot be verified because campaign offers are missing from the spec.`,
118
+ path,
119
+ data: { pageId: page.id, check: 'empty_catalog' },
120
+ });
121
+ }
122
+ });
123
+ });
124
+ return violations;
125
+ },
126
+ };
@@ -0,0 +1,30 @@
1
+ /**
2
+ * PromoCodesShape — validates per-funnel `promo_codes` rosters introduced
3
+ * in Slice 4c.
4
+ *
5
+ * The starter templates' promo-banner.js / promo-timer.js ship with a
6
+ * hardcoded `const sales = [...]` array (17 seasonal entries with demo
7
+ * codes like SUMMER26, BF26, EXIT10). The build-side replacement step
8
+ * regenerates this array from `funnels[].promo_codes` so each merchant
9
+ * carries their own calendar in the spec rather than inheriting demo
10
+ * defaults.
11
+ *
12
+ * Hint posture: warning severity, never blocks a build. The build
13
+ * agent applies the replacement only when the spec carries a non-empty
14
+ * roster; otherwise the template's demo sales array passes through
15
+ * untouched.
16
+ *
17
+ * Validation surface:
18
+ * - empty id / code (the two required identity fields)
19
+ * - duplicate id within a funnel
20
+ * - duplicate code within a funnel (codes should be unique so the
21
+ * "first matching range wins" priority is deterministic)
22
+ * - starts_at / ends_at parse as a Date (non-ISO strings warn)
23
+ * - starts_at > ends_at (inverted range)
24
+ *
25
+ * The rule does NOT validate visual fields (title, emoji, colors, etc.)
26
+ * beyond "if present, must be a string" — those are presentation copy
27
+ * and the build renders whatever the author wrote.
28
+ */
29
+ import type { Rule } from '../types.ts';
30
+ export declare const PromoCodesShape: Rule;
@@ -0,0 +1,187 @@
1
+ /**
2
+ * PromoCodesShape — validates per-funnel `promo_codes` rosters introduced
3
+ * in Slice 4c.
4
+ *
5
+ * The starter templates' promo-banner.js / promo-timer.js ship with a
6
+ * hardcoded `const sales = [...]` array (17 seasonal entries with demo
7
+ * codes like SUMMER26, BF26, EXIT10). The build-side replacement step
8
+ * regenerates this array from `funnels[].promo_codes` so each merchant
9
+ * carries their own calendar in the spec rather than inheriting demo
10
+ * defaults.
11
+ *
12
+ * Hint posture: warning severity, never blocks a build. The build
13
+ * agent applies the replacement only when the spec carries a non-empty
14
+ * roster; otherwise the template's demo sales array passes through
15
+ * untouched.
16
+ *
17
+ * Validation surface:
18
+ * - empty id / code (the two required identity fields)
19
+ * - duplicate id within a funnel
20
+ * - duplicate code within a funnel (codes should be unique so the
21
+ * "first matching range wins" priority is deterministic)
22
+ * - starts_at / ends_at parse as a Date (non-ISO strings warn)
23
+ * - starts_at > ends_at (inverted range)
24
+ *
25
+ * The rule does NOT validate visual fields (title, emoji, colors, etc.)
26
+ * beyond "if present, must be a string" — those are presentation copy
27
+ * and the build renders whatever the author wrote.
28
+ */
29
+ function isNonEmptyString(value) {
30
+ return typeof value === 'string' && value.trim().length > 0;
31
+ }
32
+ function isValidDateString(value) {
33
+ if (typeof value !== 'string')
34
+ return false;
35
+ if (value.trim().length === 0)
36
+ return false;
37
+ const parsed = Date.parse(value);
38
+ return Number.isFinite(parsed);
39
+ }
40
+ const VISUAL_STRING_FIELDS = [
41
+ 'title',
42
+ 'emoji',
43
+ 'offer1',
44
+ 'offer2',
45
+ 'top_bar_bg',
46
+ 'highlight_color',
47
+ 'banner_text',
48
+ 'banner_text_sec',
49
+ 'limited_time',
50
+ ];
51
+ export const PromoCodesShape = {
52
+ id: 'PromoCodesShape',
53
+ severity: 'warning',
54
+ tags: ['fast', 'spec-only'],
55
+ check(spec) {
56
+ const violations = [];
57
+ spec.funnels.forEach((funnel, funnelIdx) => {
58
+ const codes = funnel.promo_codes;
59
+ if (codes === undefined)
60
+ return;
61
+ const funnelLabel = funnel.name || funnel.id || `funnel ${funnelIdx}`;
62
+ if (!Array.isArray(codes)) {
63
+ violations.push({
64
+ ruleId: 'PromoCodesShape',
65
+ severity: 'warning',
66
+ message: `"${funnelLabel}" — promo_codes must be an array; got ${codes === null ? 'null' : typeof codes}.`,
67
+ path: `/funnels/${funnelIdx}/promo_codes`,
68
+ data: { funnelId: funnel.id, check: 'promo-codes-bad-shape' },
69
+ });
70
+ return;
71
+ }
72
+ const seenIds = new Set();
73
+ const seenCodes = new Set();
74
+ codes.forEach((entry, entryIdx) => {
75
+ const path = `/funnels/${funnelIdx}/promo_codes/${entryIdx}`;
76
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
77
+ violations.push({
78
+ ruleId: 'PromoCodesShape',
79
+ severity: 'warning',
80
+ message: `"${funnelLabel}" — promo_codes[${entryIdx}] must be an object; got ${entry === null ? 'null' : Array.isArray(entry) ? 'array' : typeof entry}.`,
81
+ path,
82
+ data: { funnelId: funnel.id, index: entryIdx, check: 'promo-entry-bad-shape' },
83
+ });
84
+ return;
85
+ }
86
+ const promo = entry;
87
+ if (!isNonEmptyString(promo.id)) {
88
+ violations.push({
89
+ ruleId: 'PromoCodesShape',
90
+ severity: 'warning',
91
+ message: `"${funnelLabel}" — promo_codes[${entryIdx}].id is required and must be a non-empty string.`,
92
+ path: `${path}/id`,
93
+ data: { funnelId: funnel.id, index: entryIdx, check: 'promo-id-missing' },
94
+ });
95
+ }
96
+ else {
97
+ if (seenIds.has(promo.id)) {
98
+ violations.push({
99
+ ruleId: 'PromoCodesShape',
100
+ severity: 'warning',
101
+ message: `"${funnelLabel}" — promo_codes[${entryIdx}].id "${promo.id}" duplicates an earlier entry; ids must be unique within a funnel.`,
102
+ path: `${path}/id`,
103
+ data: { funnelId: funnel.id, index: entryIdx, check: 'promo-id-duplicate', value: promo.id },
104
+ });
105
+ }
106
+ seenIds.add(promo.id);
107
+ }
108
+ if (!isNonEmptyString(promo.code)) {
109
+ violations.push({
110
+ ruleId: 'PromoCodesShape',
111
+ severity: 'warning',
112
+ message: `"${funnelLabel}" — promo_codes[${entryIdx}].code is required and must be a non-empty string.`,
113
+ path: `${path}/code`,
114
+ data: { funnelId: funnel.id, index: entryIdx, check: 'promo-code-missing' },
115
+ });
116
+ }
117
+ else {
118
+ const codeUpper = promo.code.toUpperCase();
119
+ if (seenCodes.has(codeUpper)) {
120
+ violations.push({
121
+ ruleId: 'PromoCodesShape',
122
+ severity: 'warning',
123
+ message: `"${funnelLabel}" — promo_codes[${entryIdx}].code "${promo.code}" duplicates an earlier entry (case-insensitive); ordering decides priority so duplicates are ambiguous.`,
124
+ path: `${path}/code`,
125
+ data: { funnelId: funnel.id, index: entryIdx, check: 'promo-code-duplicate', value: promo.code },
126
+ });
127
+ }
128
+ seenCodes.add(codeUpper);
129
+ }
130
+ // Date validation: when present, must parse. Both fields are optional;
131
+ // missing one is fine ("starts now" or "no end").
132
+ const hasStarts = promo.starts_at !== undefined && promo.starts_at !== null && promo.starts_at !== '';
133
+ const hasEnds = promo.ends_at !== undefined && promo.ends_at !== null && promo.ends_at !== '';
134
+ const startsOk = !hasStarts || isValidDateString(promo.starts_at);
135
+ const endsOk = !hasEnds || isValidDateString(promo.ends_at);
136
+ if (hasStarts && !startsOk) {
137
+ violations.push({
138
+ ruleId: 'PromoCodesShape',
139
+ severity: 'warning',
140
+ message: `"${funnelLabel}" — promo_codes[${entryIdx}].starts_at "${promo.starts_at}" is not a valid ISO date string.`,
141
+ path: `${path}/starts_at`,
142
+ data: { funnelId: funnel.id, index: entryIdx, check: 'promo-starts-at-bad', value: promo.starts_at },
143
+ });
144
+ }
145
+ if (hasEnds && !endsOk) {
146
+ violations.push({
147
+ ruleId: 'PromoCodesShape',
148
+ severity: 'warning',
149
+ message: `"${funnelLabel}" — promo_codes[${entryIdx}].ends_at "${promo.ends_at}" is not a valid ISO date string.`,
150
+ path: `${path}/ends_at`,
151
+ data: { funnelId: funnel.id, index: entryIdx, check: 'promo-ends-at-bad', value: promo.ends_at },
152
+ });
153
+ }
154
+ if (hasStarts && hasEnds && startsOk && endsOk) {
155
+ const startsAt = Date.parse(promo.starts_at);
156
+ const endsAt = Date.parse(promo.ends_at);
157
+ if (startsAt > endsAt) {
158
+ violations.push({
159
+ ruleId: 'PromoCodesShape',
160
+ severity: 'warning',
161
+ message: `"${funnelLabel}" — promo_codes[${entryIdx}] starts_at "${promo.starts_at}" is after ends_at "${promo.ends_at}"; swap the values or fix the range.`,
162
+ path,
163
+ data: { funnelId: funnel.id, index: entryIdx, check: 'promo-range-inverted', starts_at: promo.starts_at, ends_at: promo.ends_at },
164
+ });
165
+ }
166
+ }
167
+ // Visual presentation fields: if present, must be a string. We don't
168
+ // narrow the values (e.g. color format), since author intent is
169
+ // freeform copy / brand color choice.
170
+ for (const field of VISUAL_STRING_FIELDS) {
171
+ if (promo[field] === undefined)
172
+ continue;
173
+ if (typeof promo[field] !== 'string') {
174
+ violations.push({
175
+ ruleId: 'PromoCodesShape',
176
+ severity: 'warning',
177
+ message: `"${funnelLabel}" — promo_codes[${entryIdx}].${field} must be a string; got ${typeof promo[field]}.`,
178
+ path: `${path}/${field}`,
179
+ data: { funnelId: funnel.id, index: entryIdx, field, check: 'promo-visual-bad-type' },
180
+ });
181
+ }
182
+ }
183
+ });
184
+ });
185
+ return violations;
186
+ },
187
+ };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * RouteFieldIgnoredForPageType — a page declares a forward-route field its
3
+ * type cannot satisfy, so routing skips it.
4
+ *
5
+ * Today that means exactly one shape: `success_url` on a page that is not a
6
+ * checkout. `success_url` means "where the shopper goes after payment
7
+ * succeeds", and only a checkout takes payment, so on a `select` or `landing`
8
+ * page the field names an event that never happens there (campaigns-os#234).
9
+ * Routing therefore ignores it and `next_page` wins.
10
+ *
11
+ * Why this rule exists at all. Before #234 the stray field silently OUTRANKED
12
+ * the page's real next step — a select page declaring `next_page: checkout`
13
+ * and `success_url: upsell` wired the upsell and skipped payment. #234 fixes
14
+ * the routing, but a spec author who wrote the field still gets behaviour they
15
+ * did not ask for, just the safe kind now. RouteTargetResolves only catches
16
+ * this when the target does not resolve; when both targets are real pages it
17
+ * has nothing to say. A comment removed in #233 assumed a rule already covered
18
+ * the case. It did not. This is that rule.
19
+ *
20
+ * Warning, not error. The build is correct either way — the field is inert,
21
+ * not broken — and it is overwhelmingly a copy-paste from the checkout below.
22
+ * Blocking on it would fail builds that ship fine.
23
+ *
24
+ * The set of ignored fields comes from routing.ts via
25
+ * `inapplicableForwardFields`, and the field's meaning and permitted types
26
+ * from `describeForwardField` — never re-derived here. A rule that
27
+ * disagreed with the resolver about which fields are live would be worse than
28
+ * no rule, and a hand-written checkout-specific sentence would quietly become
29
+ * a lie the first time a second field or a second payment-bearing type joins
30
+ * the table.
31
+ */
32
+ import type { Rule } from '../types.ts';
33
+ export declare const RouteFieldIgnoredForPageType: Rule;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * RouteFieldIgnoredForPageType — a page declares a forward-route field its
3
+ * type cannot satisfy, so routing skips it.
4
+ *
5
+ * Today that means exactly one shape: `success_url` on a page that is not a
6
+ * checkout. `success_url` means "where the shopper goes after payment
7
+ * succeeds", and only a checkout takes payment, so on a `select` or `landing`
8
+ * page the field names an event that never happens there (campaigns-os#234).
9
+ * Routing therefore ignores it and `next_page` wins.
10
+ *
11
+ * Why this rule exists at all. Before #234 the stray field silently OUTRANKED
12
+ * the page's real next step — a select page declaring `next_page: checkout`
13
+ * and `success_url: upsell` wired the upsell and skipped payment. #234 fixes
14
+ * the routing, but a spec author who wrote the field still gets behaviour they
15
+ * did not ask for, just the safe kind now. RouteTargetResolves only catches
16
+ * this when the target does not resolve; when both targets are real pages it
17
+ * has nothing to say. A comment removed in #233 assumed a rule already covered
18
+ * the case. It did not. This is that rule.
19
+ *
20
+ * Warning, not error. The build is correct either way — the field is inert,
21
+ * not broken — and it is overwhelmingly a copy-paste from the checkout below.
22
+ * Blocking on it would fail builds that ship fine.
23
+ *
24
+ * The set of ignored fields comes from routing.ts via
25
+ * `inapplicableForwardFields`, and the field's meaning and permitted types
26
+ * from `describeForwardField` — never re-derived here. A rule that
27
+ * disagreed with the resolver about which fields are live would be worse than
28
+ * no rule, and a hand-written checkout-specific sentence would quietly become
29
+ * a lie the first time a second field or a second payment-bearing type joins
30
+ * the table.
31
+ */
32
+ import { describeForwardField, forwardRouteTarget, inapplicableForwardFields, } from "../routing.js";
33
+ export const RouteFieldIgnoredForPageType = {
34
+ id: 'RouteFieldIgnoredForPageType',
35
+ severity: 'warning',
36
+ tags: ['fast', 'structure', '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 ignored = inapplicableForwardFields(page);
43
+ if (ignored.length === 0)
44
+ return;
45
+ // Naming the field that actually wins is what makes this actionable:
46
+ // "ignored" alone leaves the author guessing where the shopper goes.
47
+ const winner = forwardRouteTarget(page);
48
+ const outcome = winner
49
+ ? `This page routes forward to "${winner}" instead.`
50
+ : 'This page has no forward route at all as a result.';
51
+ for (const field of ignored) {
52
+ const applicability = describeForwardField(field);
53
+ const honoured = (applicability?.requiredTypes ?? [])
54
+ .map((type) => `"${type}"`)
55
+ .join(' or ');
56
+ const meaning = applicability?.meaning ?? 'something this page type cannot provide';
57
+ // A page with no usable `type` reaches here too, and telling that
58
+ // author about payment semantics points at the wrong defect: the
59
+ // real problem is the missing required field, not the routing.
60
+ const where = typeof page.type === 'string' && page.type.trim()
61
+ ? `on a "${page.type}" page`
62
+ : 'because this page declares no valid "type"';
63
+ violations.push({
64
+ ruleId: 'RouteFieldIgnoredForPageType',
65
+ severity: 'warning',
66
+ message: `"${field}" is ignored for routing ${where} — it means ${meaning}, which only ${honoured} can satisfy. ${outcome}`,
67
+ path: `/funnels/${funnelIdx}/pages/${pageIdx}/${field}`,
68
+ data: {
69
+ pageId: page.id,
70
+ pageType: page.type,
71
+ field,
72
+ target: page[field],
73
+ routedTo: winner,
74
+ },
75
+ });
76
+ }
77
+ });
78
+ });
79
+ return violations;
80
+ },
81
+ };
@@ -0,0 +1,32 @@
1
+ /**
2
+ * RouteTargetResolves — every declared routing target must name a page that
3
+ * exists in the same funnel.
4
+ *
5
+ * Nothing checked this before, and the cost was real: six certified fixtures
6
+ * declared `next_page: "upsell-bundle-stepper.html"` on a checkout whose funnel
7
+ * has no such page (its upsell is `upsell-stepper` — the name was copied from
8
+ * the MV families, which do have that page). Source intake resolves an unknown
9
+ * target by falling back to treating the raw string as a route, so those six
10
+ * emitted a confident `next_url` to a route nothing serves: a shopper reaching
11
+ * the end of checkout would land on a 404.
12
+ *
13
+ * The old CheckoutHasSuccessUrl covered this only by accident — it demanded
14
+ * `success_url`, and these pages had none — so narrowing that rule to "has any
15
+ * forward route" removed the last signal. This rule is the deliberate
16
+ * replacement, and it checks the thing that actually matters: not which field
17
+ * carried the intent, but whether the shopper can get where it points.
18
+ *
19
+ * Warning, not error. A campaign is a free-form journey and the resolver
20
+ * intentionally passes through absolute URLs and fragments; a target this rule
21
+ * cannot resolve is worth surfacing, but blocking a build on it would strand
22
+ * exactly the unconventional authors the routing work exists to support.
23
+ *
24
+ * Targets are matched against page IDs and against the routes pages actually
25
+ * serve (`page_url`), tolerating the corpus's `.html` suffix dialect
26
+ * (`upsell-stepper.html` matches page `upsell-stepper`) because intake's route
27
+ * fallback resolves that shape correctly. Absolute http(s) URLs, `#`
28
+ * fragments and rooted paths are deliberate off-graph destinations and are
29
+ * never flagged.
30
+ */
31
+ import type { Rule } from '../types.ts';
32
+ export declare const RouteTargetResolves: Rule;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * RouteTargetResolves — every declared routing target must name a page that
3
+ * exists in the same funnel.
4
+ *
5
+ * Nothing checked this before, and the cost was real: six certified fixtures
6
+ * declared `next_page: "upsell-bundle-stepper.html"` on a checkout whose funnel
7
+ * has no such page (its upsell is `upsell-stepper` — the name was copied from
8
+ * the MV families, which do have that page). Source intake resolves an unknown
9
+ * target by falling back to treating the raw string as a route, so those six
10
+ * emitted a confident `next_url` to a route nothing serves: a shopper reaching
11
+ * the end of checkout would land on a 404.
12
+ *
13
+ * The old CheckoutHasSuccessUrl covered this only by accident — it demanded
14
+ * `success_url`, and these pages had none — so narrowing that rule to "has any
15
+ * forward route" removed the last signal. This rule is the deliberate
16
+ * replacement, and it checks the thing that actually matters: not which field
17
+ * carried the intent, but whether the shopper can get where it points.
18
+ *
19
+ * Warning, not error. A campaign is a free-form journey and the resolver
20
+ * intentionally passes through absolute URLs and fragments; a target this rule
21
+ * cannot resolve is worth surfacing, but blocking a build on it would strand
22
+ * exactly the unconventional authors the routing work exists to support.
23
+ *
24
+ * Targets are matched against page IDs and against the routes pages actually
25
+ * serve (`page_url`), tolerating the corpus's `.html` suffix dialect
26
+ * (`upsell-stepper.html` matches page `upsell-stepper`) because intake's route
27
+ * fallback resolves that shape correctly. Absolute http(s) URLs, `#`
28
+ * fragments and rooted paths are deliberate off-graph destinations and are
29
+ * never flagged.
30
+ */
31
+ import { ROUTE_FIELDS, inapplicableForwardFields } from "../routing.js";
32
+ /**
33
+ * Destinations that deliberately leave this spec's page graph. A rooted path is
34
+ * the partial-scope pattern ThankYouRequirement documents — "traffic continues
35
+ * to an existing downstream route" — and flagging it would fire this rule on a
36
+ * legitimate, supported shape.
37
+ */
38
+ function isOffGraphTarget(target) {
39
+ return target.startsWith('#') || target.startsWith('/') || /^https?:\/\//i.test(target);
40
+ }
41
+ /**
42
+ * Strip the surrounding slashes and any `.html` suffix, so a target, a page ID
43
+ * and a declared `page_url` can be compared on equal terms. Case is preserved
44
+ * deliberately: intake's lookup is case-sensitive, and folding case here would
45
+ * make the rule PASS targets the build then fails to resolve — a false negative
46
+ * is worse than the false positive it would avoid.
47
+ */
48
+ function normalizeRouteish(value) {
49
+ const trimmed = value.trim().replace(/^\/+|\/+$/g, '');
50
+ return trimmed.toLowerCase().endsWith('.html') ? trimmed.slice(0, -'.html'.length) : trimmed;
51
+ }
52
+ /**
53
+ * Everything a target may legitimately name: a page's ID, or the route it
54
+ * actually serves. Matching IDs alone flagged a working link — a page with an
55
+ * explicit `page_url` is reached at that route, and intake resolves a target
56
+ * naming it correctly, so the rule must too.
57
+ */
58
+ function resolvableNames(pages) {
59
+ const names = new Set();
60
+ for (const page of pages) {
61
+ if (page.id) {
62
+ names.add(page.id);
63
+ names.add(normalizeRouteish(page.id));
64
+ }
65
+ const route = page.page_url;
66
+ if (typeof route === 'string' && route.trim())
67
+ names.add(normalizeRouteish(route));
68
+ }
69
+ names.delete('');
70
+ return names;
71
+ }
72
+ export const RouteTargetResolves = {
73
+ id: 'RouteTargetResolves',
74
+ severity: 'warning',
75
+ tags: ['fast', 'structure', 'spec-only'],
76
+ check(spec) {
77
+ const violations = [];
78
+ spec.funnels.forEach((funnel, funnelIdx) => {
79
+ const pages = funnel.pages ?? [];
80
+ const known = resolvableNames(pages);
81
+ pages.forEach((page, pageIdx) => {
82
+ // A field the page's type cannot satisfy is inert: routing skips it, so
83
+ // the built page does NOT link to its target and this rule's message
84
+ // ("the built page would link to a route nothing serves") would be
85
+ // false. RouteFieldIgnoredForPageType owns that shape. Reading the
86
+ // exclusion from the resolver keeps the two rules from telling an
87
+ // author opposite stories about the same field (#234).
88
+ const inert = new Set(inapplicableForwardFields(page));
89
+ for (const field of ROUTE_FIELDS) {
90
+ if (inert.has(field))
91
+ continue;
92
+ const raw = page[field];
93
+ if (typeof raw !== 'string')
94
+ continue;
95
+ const target = raw.trim();
96
+ if (!target || isOffGraphTarget(target))
97
+ continue;
98
+ if (known.has(target) || known.has(normalizeRouteish(target)))
99
+ continue;
100
+ violations.push({
101
+ ruleId: 'RouteTargetResolves',
102
+ severity: 'warning',
103
+ message: `Routing target "${target}" resolves to no page in this funnel; the built page would link to a route nothing serves.`,
104
+ path: `/funnels/${funnelIdx}/pages/${pageIdx}/${field}`,
105
+ data: { pageId: page.id, field, target },
106
+ });
107
+ }
108
+ });
109
+ });
110
+ return violations;
111
+ },
112
+ };