@nextcommerce/campaigns-os 1.54.0 → 1.57.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 (111) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/CONTEXT.md +19 -5
  3. package/README.md +26 -12
  4. package/campaign-spec/README.md +15 -1
  5. package/campaign-spec/dist/checkout-flow.d.ts +90 -0
  6. package/campaign-spec/dist/checkout-flow.js +229 -0
  7. package/campaign-spec/dist/index.d.ts +1 -0
  8. package/campaign-spec/dist/index.js +6 -1
  9. package/campaign-spec/dist/normalize.d.ts +3 -2
  10. package/campaign-spec/dist/normalize.js +7 -5
  11. package/campaign-spec/dist/routing.d.ts +6 -3
  12. package/campaign-spec/dist/routing.js +6 -3
  13. package/campaign-spec/dist/rules/checkout-forward-target.d.ts +17 -0
  14. package/campaign-spec/dist/rules/checkout-forward-target.js +48 -0
  15. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.d.ts +12 -0
  16. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.js +37 -0
  17. package/campaign-spec/dist/rules/exit-intent-validation.js +2 -1
  18. package/campaign-spec/dist/rules/index.d.ts +4 -0
  19. package/campaign-spec/dist/rules/index.js +12 -0
  20. package/campaign-spec/dist/rules/one-checkout-per-path.d.ts +25 -0
  21. package/campaign-spec/dist/rules/one-checkout-per-path.js +84 -0
  22. package/campaign-spec/dist/rules/pre-payment-forward-target.d.ts +12 -0
  23. package/campaign-spec/dist/rules/pre-payment-forward-target.js +42 -0
  24. package/campaign-spec/dist/rules/promo-code-input-validation.js +2 -1
  25. package/campaign-spec/dist/rules/schema-version.d.ts +9 -4
  26. package/campaign-spec/dist/rules/schema-version.js +10 -5
  27. package/campaign-spec/dist/types.d.ts +20 -3
  28. package/campaign-spec/dist/types.js +3 -2
  29. package/compatibility.json +2 -2
  30. package/contracts/fixtures/runtime-recipe/accept/current.json +6 -1
  31. package/contracts/fixtures/runtime-recipe/accept/minimal.json +6 -1
  32. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  33. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +6 -1
  34. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +6 -1
  35. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +6 -1
  36. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +6 -1
  37. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +6 -1
  38. package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +1 -1
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +6 -1
  40. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +6 -1
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +6 -1
  42. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +5 -0
  43. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +6 -1
  44. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +6 -1
  45. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +6 -1
  46. package/contracts/release-ledger.json +1146 -0
  47. package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
  48. package/contracts/supported-surface.json +9 -5
  49. package/docs/brand-theme-bridge.md +10 -0
  50. package/docs/build-packet.md +86 -26
  51. package/docs/campaigns-os-build-flow.md +1 -1
  52. package/docs/effects.md +2 -5
  53. package/docs/local-setup.md +1 -1
  54. package/docs/orientation-contract-reference.md +1 -1
  55. package/docs/qa-and-test-orders.md +133 -29
  56. package/docs/runtime-readiness.md +8 -3
  57. package/docs/skills-revision.md +10 -10
  58. package/docs/supported-surface.md +1 -1
  59. package/docs/versioning.md +4 -1
  60. package/docs/workflow-findings-sidecar.md +57 -2
  61. package/package.json +4 -1
  62. package/schemas/campaign-runtime-build-context.v0.schema.json +3 -1
  63. package/schemas/campaign-runtime-build-packet.v0.schema.json +3 -1
  64. package/schemas/campaign-spec.v5.schema.json +586 -0
  65. package/schemas/campaigns-os-run-record.v0.schema.json +20 -15
  66. package/skills/campaign-lifecycle-orientation/SKILL.md +10 -8
  67. package/skills/campaign-readback-classification/SKILL.md +3 -3
  68. package/skills/campaign-run-evidence/SKILL.md +3 -3
  69. package/skills/contribution-intake/SKILL.md +3 -3
  70. package/skills/next-campaigns-build/SKILL.md +6 -4
  71. package/skills/next-campaigns-os/SKILL.md +3 -3
  72. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  73. package/skills/next-campaigns-polish/SKILL.md +21 -6
  74. package/skills/next-campaigns-qa/SKILL.md +5 -4
  75. package/skills.json +10 -10
  76. package/src/adapter-decision-contract.mjs +27 -13
  77. package/src/brand-theme.mjs +44 -8
  78. package/src/build-brief.mjs +14 -5
  79. package/src/campaign-identity.mjs +33 -4
  80. package/src/cli-helpers.mjs +129 -0
  81. package/src/cli.mjs +156 -111
  82. package/src/commercial-journey.mjs +25 -2
  83. package/src/consent.mjs +3 -2
  84. package/src/design-source-package.mjs +1 -1
  85. package/src/design-source-publication.mjs +8 -8
  86. package/src/deviation.mjs +2 -1
  87. package/src/doctor/checks.mjs +212 -19
  88. package/src/doctor/inspect.mjs +4 -2
  89. package/src/doctor/next-step.mjs +46 -16
  90. package/src/doctor-sidecar.mjs +5 -3
  91. package/src/gate-actions.mjs +21 -1
  92. package/src/input-refresh.mjs +6 -6
  93. package/src/invocation.mjs +4 -3
  94. package/src/lifecycle.mjs +88 -6
  95. package/src/local-preview-policy.mjs +116 -12
  96. package/src/login.mjs +2 -1
  97. package/src/polish-browser.mjs +8 -2
  98. package/src/polish-node.mjs +9 -4
  99. package/src/polish-readability.mjs +2 -2
  100. package/src/qa-browser.mjs +741 -120
  101. package/src/qa-cart-entry.mjs +107 -20
  102. package/src/qa-node.mjs +179 -72
  103. package/src/qa-test-order-topology.mjs +118 -1
  104. package/src/qa-verdict-discovery.mjs +21 -4
  105. package/src/qa-verdict-publish.mjs +2 -1
  106. package/src/run-record.mjs +46 -34
  107. package/src/runtime-state-ignore.mjs +7 -5
  108. package/src/sdk-markup.mjs +41 -1
  109. package/src/source-html-intake.mjs +46 -4
  110. package/src/stage-ledger.mjs +18 -4
  111. package/src/stage-record.mjs +53 -16
@@ -0,0 +1,229 @@
1
+ /**
2
+ * checkout-flow — which page takes payment, and the pages that lead to it.
3
+ *
4
+ * From CampaignSpec v5 a page's `type` says which page takes payment
5
+ * (campaigns-os#641). Three types carry the shopper into a payment:
6
+ *
7
+ * | Type | Checkout form | Places order | Forward field |
8
+ * |-----------------|---------------|--------------|---------------|
9
+ * | `select` | no | no | `next_page` |
10
+ * | `checkout_step` | yes | no | `next_page` |
11
+ * | `checkout` | yes | yes | `success_url` |
12
+ *
13
+ * All three render as SDK page type `checkout`. A campaign may hold several
14
+ * Checkouts (one per split-test path); each path reaches exactly one.
15
+ *
16
+ * Every consumer that asks "which page on this path takes payment" asks here,
17
+ * never "the first page typed checkout". The one place the payment page is
18
+ * worked out from links rather than read from `type` is `upgradeCampaignSpec`,
19
+ * which retypes a v4 spec on read.
20
+ */
21
+ import { forwardRouteTarget, outgoingEdgeIds } from "./routing.js";
22
+ /** The CampaignSpec lineage this module writes when it upgrades a v4 spec. */
23
+ export const CURRENT_SCHEMA_VERSION = '5.0';
24
+ /** The page that takes payment. */
25
+ export const PAYMENT_PAGE_TYPE = 'checkout';
26
+ /** A checkout form that collects details and moves on without placing an order. */
27
+ export const CHECKOUT_STEP_PAGE_TYPE = 'checkout_step';
28
+ /** Pages that lead into a Checkout without taking payment themselves. */
29
+ export const PRE_PAYMENT_PAGE_TYPES = Object.freeze(['select', 'checkout_step']);
30
+ /**
31
+ * Every page type the SDK renders as `checkout`: the pre-payment pages and the
32
+ * Checkout itself. Payment-page features (exit intent, promo code input,
33
+ * checkout theme gates) apply to all of them.
34
+ */
35
+ export const CHECKOUT_FLOW_PAGE_TYPES = Object.freeze(['select', 'checkout_step', 'checkout']);
36
+ /** Where a Checkout may send the shopper once payment succeeds. */
37
+ export const POST_PAYMENT_PAGE_TYPES = Object.freeze(['upsell', 'downsell', 'thankyou']);
38
+ /** Post-purchase offer pages: reaching one means an order was already placed. */
39
+ export const POST_PURCHASE_OFFER_PAGE_TYPES = Object.freeze(['upsell', 'downsell']);
40
+ function typeOf(page) {
41
+ const type = page?.type;
42
+ return typeof type === 'string' ? type.trim().toLowerCase() : '';
43
+ }
44
+ export function isPaymentPage(page) {
45
+ return typeOf(page) === PAYMENT_PAGE_TYPE;
46
+ }
47
+ export function isCheckoutStepPage(page) {
48
+ return typeOf(page) === CHECKOUT_STEP_PAGE_TYPE;
49
+ }
50
+ export function isPrePaymentPage(page) {
51
+ return PRE_PAYMENT_PAGE_TYPES.includes(typeOf(page));
52
+ }
53
+ export function isCheckoutFlowPage(page) {
54
+ return CHECKOUT_FLOW_PAGE_TYPES.includes(typeOf(page));
55
+ }
56
+ /**
57
+ * Strip surrounding slashes and a `.html` suffix so a route target, a page ID
58
+ * and a `page_url` compare on equal terms. The corpus writes targets in all
59
+ * three dialects (`shipping`, `shipping.html`, `/shipping/`). Case is kept, as
60
+ * in RouteTargetResolves: intake's lookup is case-sensitive.
61
+ */
62
+ function routeish(value) {
63
+ const trimmed = value.trim().replace(/^\/+|\/+$/g, '');
64
+ return trimmed.toLowerCase().endsWith('.html') ? trimmed.slice(0, -'.html'.length) : trimmed;
65
+ }
66
+ /**
67
+ * The page a route target names within `pages`, or null when it names none
68
+ * (an absolute URL, a fragment, a route outside this funnel, or a typo that
69
+ * RouteTargetResolves reports). Page IDs win over `page_url` matches.
70
+ */
71
+ export function resolveRouteTargetPage(pages, target) {
72
+ if (typeof target !== 'string' || !target.trim())
73
+ return null;
74
+ const raw = target.trim();
75
+ if (raw.startsWith('#') || /^[a-z][a-z0-9+.-]*:/i.test(raw))
76
+ return null;
77
+ const key = routeish(raw);
78
+ if (!key)
79
+ return null;
80
+ for (const page of pages) {
81
+ if (page?.id === raw || (typeof page?.id === 'string' && routeish(page.id) === key))
82
+ return page;
83
+ }
84
+ for (const page of pages) {
85
+ const route = page?.page_url;
86
+ if (typeof route === 'string' && route.trim() && routeish(route) === key)
87
+ return page;
88
+ }
89
+ return null;
90
+ }
91
+ /** The page this page's forward link resolves to, within `pages`. */
92
+ export function forwardTargetPage(pages, page) {
93
+ return resolveRouteTargetPage(pages, forwardRouteTarget(page));
94
+ }
95
+ function enabledPages(funnel) {
96
+ return (funnel?.pages ?? []).filter((page) => page && page.enabled !== false);
97
+ }
98
+ /**
99
+ * The pages from `start` forward to the Checkout that takes payment, both
100
+ * included, following each page's forward link. Null when the walk leaves the
101
+ * funnel, loops, ends, or meets a post-payment page before any Checkout.
102
+ *
103
+ * `start` may itself be the Checkout (the path is then that page alone).
104
+ */
105
+ export function checkoutPathFrom(pages, start) {
106
+ const path = [];
107
+ const seen = new Set();
108
+ let page = start;
109
+ while (page && !seen.has(page)) {
110
+ seen.add(page);
111
+ path.push(page);
112
+ if (isPaymentPage(page))
113
+ return path;
114
+ if (POST_PAYMENT_PAGE_TYPES.includes(typeOf(page)))
115
+ return null;
116
+ page = forwardTargetPage(pages, page);
117
+ }
118
+ return null;
119
+ }
120
+ /** The Checkout reached by walking forward from `start`, or null. */
121
+ export function paymentPageFrom(pages, start) {
122
+ const path = checkoutPathFrom(pages, start);
123
+ return path ? path[path.length - 1] : null;
124
+ }
125
+ /**
126
+ * The funnel's entry pages: those flagged `is_entry`, or when none is flagged,
127
+ * those no other page routes to apart from orphaned upsell, downsell and
128
+ * thank-you pages (falling back to the lowest `order` when that leaves none). Order and names are never consulted beyond that
129
+ * last-resort tie-break.
130
+ */
131
+ export function entryPages(funnel) {
132
+ const pages = enabledPages(funnel);
133
+ const flagged = pages.filter((page) => page.is_entry === true);
134
+ if (flagged.length)
135
+ return flagged;
136
+ const targeted = new Set();
137
+ for (const page of pages) {
138
+ for (const target of outgoingEdgeIds(page)) {
139
+ const hit = resolveRouteTargetPage(pages, target);
140
+ if (hit && hit !== page)
141
+ targeted.add(hit);
142
+ }
143
+ }
144
+ // A post-payment page nothing routes to is an orphan, not where a shopper
145
+ // starts: no unflagged journey begins on an upsell or a receipt.
146
+ const roots = pages.filter((page) => !targeted.has(page) && !POST_PAYMENT_PAGE_TYPES.includes(typeOf(page)));
147
+ if (roots.length)
148
+ return roots;
149
+ const first = [...pages].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))[0];
150
+ return first ? [first] : [];
151
+ }
152
+ /**
153
+ * The Checkout on the path through `page`'s funnel that starts at an entry
154
+ * page, for callers that hold a funnel rather than a starting page. When the
155
+ * funnel has several entry paths with different Checkouts, returns the first
156
+ * entry's; use `paymentPagesForFunnel` to see all of them.
157
+ */
158
+ export function paymentPageForFunnel(funnel) {
159
+ return paymentPagesForFunnel(funnel)[0] ?? null;
160
+ }
161
+ /** Every distinct Checkout reached from the funnel's entry pages, in entry order. */
162
+ export function paymentPagesForFunnel(funnel) {
163
+ const pages = enabledPages(funnel);
164
+ const out = [];
165
+ for (const entry of entryPages(funnel)) {
166
+ const checkout = paymentPageFrom(pages, entry);
167
+ if (checkout && !out.includes(checkout))
168
+ out.push(checkout);
169
+ }
170
+ // A funnel whose entry is a landing page that links into checkout through a
171
+ // CTA (no forward field) still has a Checkout; fall back to every Checkout
172
+ // the funnel declares, in declaration order (funnel.pages), so a lookup never
173
+ // reports "none" for a real one. No entry path reaches these, so there is no
174
+ // path order to follow; QA's topology walker falls back the same way.
175
+ if (!out.length)
176
+ for (const page of pages)
177
+ if (isPaymentPage(page))
178
+ out.push(page);
179
+ return out;
180
+ }
181
+ /**
182
+ * Upgrade a CampaignSpec to the v5 page vocabulary. Returns a new object; the
183
+ * input is never mutated, so callers that hash or persist the raw bytes keep
184
+ * the bytes they read.
185
+ *
186
+ * v5 specs pass through unchanged. For any other lineage, each `checkout` page
187
+ * whose resolved forward target is also a `checkout` is retyped
188
+ * `checkout_step` — the v4 `checkout → checkout` chain was the only way to say
189
+ * "this form moves on without taking payment". A supported v4 `schema_version`
190
+ * becomes `5.0`; a missing or unsupported one is left for SchemaVersion to
191
+ * report.
192
+ */
193
+ export function upgradeCampaignSpec(input) {
194
+ if (input == null || typeof input !== 'object' || Array.isArray(input))
195
+ return input;
196
+ const spec = input;
197
+ const version = typeof spec.schema_version === 'string' ? spec.schema_version.trim() : '';
198
+ if (version.startsWith('5.') || version === '5')
199
+ return input;
200
+ if (!Array.isArray(spec.funnels))
201
+ return input;
202
+ let changed = false;
203
+ const funnels = spec.funnels.map((funnel) => {
204
+ if (!funnel || !Array.isArray(funnel.pages))
205
+ return funnel;
206
+ const pages = funnel.pages;
207
+ const retype = new Set();
208
+ for (const page of pages) {
209
+ if (!isPaymentPage(page))
210
+ continue;
211
+ if (isPaymentPage(forwardTargetPage(pages, page)))
212
+ retype.add(page);
213
+ }
214
+ if (!retype.size)
215
+ return funnel;
216
+ changed = true;
217
+ return {
218
+ ...funnel,
219
+ pages: pages.map((page) => (retype.has(page) ? { ...page, type: CHECKOUT_STEP_PAGE_TYPE } : page)),
220
+ };
221
+ });
222
+ const upgradeVersion = version === '4.2' || version === '4.3';
223
+ if (!changed && !upgradeVersion)
224
+ return input;
225
+ const out = { ...spec, funnels };
226
+ if (upgradeVersion)
227
+ out.schema_version = CURRENT_SCHEMA_VERSION;
228
+ return out;
229
+ }
@@ -15,6 +15,7 @@ export type { CampaignSpec, Rule, RuleSet, Violation, Tag, Severity, Fixture, Pa
15
15
  export { normalize, NormalizeError } from './normalize.ts';
16
16
  export { FORWARD_ROUTE_FIELDS, ACCEPT_ROUTE_FIELD, DECLINE_ROUTE_FIELD, ROUTE_FIELDS, PAYMENT_BEARING_PAGE_TYPES, OFFER_BEARING_PAGE_TYPES, forwardRouteTarget, acceptRouteTarget, declineRouteTarget, hasForwardRoute, applicableForwardFields, inapplicableForwardFields, describeForwardField, outgoingEdgeIds, } from './routing.ts';
17
17
  export type { ForwardFieldApplicability } from './routing.ts';
18
+ export { CURRENT_SCHEMA_VERSION, PAYMENT_PAGE_TYPE, CHECKOUT_STEP_PAGE_TYPE, PRE_PAYMENT_PAGE_TYPES, CHECKOUT_FLOW_PAGE_TYPES, POST_PAYMENT_PAGE_TYPES, POST_PURCHASE_OFFER_PAGE_TYPES, isPaymentPage, isCheckoutStepPage, isPrePaymentPage, isCheckoutFlowPage, resolveRouteTargetPage, forwardTargetPage, checkoutPathFrom, paymentPageFrom, entryPages, paymentPageForFunnel, paymentPagesForFunnel, upgradeCampaignSpec, } from './checkout-flow.ts';
18
19
  export { allRules, fastRules, specOnlyRules } from './rules/index.ts';
19
20
  export { SUPPORTED_SCHEMA_VERSIONS } from './rules/schema-version.ts';
20
21
  export { RELEASED_SDK_VERSION_PATTERN, parseSdkVersion, isReleasedSdkVersion, describeSdkVersionRejection, } from './sdk-version-parse.ts';
@@ -17,10 +17,15 @@ export { normalize, NormalizeError } from "./normalize.js";
17
17
  // page go". Source intake, cycle detection and the QA topology extractor all
18
18
  // consume these rather than keeping their own page-type tables.
19
19
  export { FORWARD_ROUTE_FIELDS, ACCEPT_ROUTE_FIELD, DECLINE_ROUTE_FIELD, ROUTE_FIELDS, PAYMENT_BEARING_PAGE_TYPES, OFFER_BEARING_PAGE_TYPES, forwardRouteTarget, acceptRouteTarget, declineRouteTarget, hasForwardRoute, applicableForwardFields, inapplicableForwardFields, describeForwardField, outgoingEdgeIds, } from "./routing.js";
20
+ // Which page takes payment, and the pages leading to it (campaigns-os#641).
21
+ // Every "the Checkout on this path" lookup asks here; upgradeCampaignSpec is
22
+ // the v4 → v5 read-time upgrade normalize() applies.
23
+ export { CURRENT_SCHEMA_VERSION, PAYMENT_PAGE_TYPE, CHECKOUT_STEP_PAGE_TYPE, PRE_PAYMENT_PAGE_TYPES, CHECKOUT_FLOW_PAGE_TYPES, POST_PAYMENT_PAGE_TYPES, POST_PURCHASE_OFFER_PAGE_TYPES, isPaymentPage, isCheckoutStepPage, isPrePaymentPage, isCheckoutFlowPage, resolveRouteTargetPage, forwardTargetPage, checkoutPathFrom, paymentPageFrom, entryPages, paymentPageForFunnel, paymentPagesForFunnel, upgradeCampaignSpec, } from "./checkout-flow.js";
20
24
  export { allRules, fastRules, specOnlyRules } from "./rules/index.js";
21
25
  // Supported schema_version matrix — single source for the SchemaVersion rule
22
26
  // and any consumer that needs to present or gate on the supported lineages.
23
- // A sync test pins it to the schemas/campaign-spec.v4.schema.json enum.
27
+ // A sync test pins it to the schema_version enums of
28
+ // schemas/campaign-spec.v4.schema.json and schemas/campaign-spec.v5.schema.json.
24
29
  export { SUPPORTED_SCHEMA_VERSIONS } from "./rules/schema-version.js";
25
30
  // Strict released-SDK-version parser — shared with the downstream Page Kit
26
31
  // SDK-version checkpoint (src/page-kit-sdk-version.mjs) so authoring-time
@@ -2,8 +2,9 @@
2
2
  * normalize — the single phase that takes an authoring CampaignSpec and emits
3
3
  * the canonical v4.2 funnels[] shape that rules operate on.
4
4
  *
5
- * Today this is near-empty: v4.3 already uses funnels[]. The phase exists so
6
- * future authoring evolutions have one place to land their migration.
5
+ * v4.2/v4.3 specs are upgraded to the v5 page vocabulary here (a `checkout`
6
+ * that forwards to another `checkout` becomes `checkout_step`), so every rule
7
+ * sees one vocabulary. Future authoring evolutions land here too.
7
8
  *
8
9
  * v4.1 (funnel_pages[]) is intentionally NOT supported — see
9
10
  * ../docs/adr/002-drop-v41-spec-support.md. Inputs without `funnels[]` fail
@@ -2,13 +2,15 @@
2
2
  * normalize — the single phase that takes an authoring CampaignSpec and emits
3
3
  * the canonical v4.2 funnels[] shape that rules operate on.
4
4
  *
5
- * Today this is near-empty: v4.3 already uses funnels[]. The phase exists so
6
- * future authoring evolutions have one place to land their migration.
5
+ * v4.2/v4.3 specs are upgraded to the v5 page vocabulary here (a `checkout`
6
+ * that forwards to another `checkout` becomes `checkout_step`), so every rule
7
+ * sees one vocabulary. Future authoring evolutions land here too.
7
8
  *
8
9
  * v4.1 (funnel_pages[]) is intentionally NOT supported — see
9
10
  * ../docs/adr/002-drop-v41-spec-support.md. Inputs without `funnels[]` fail
10
11
  * the structural assertion below.
11
12
  */
13
+ import { upgradeCampaignSpec } from "./checkout-flow.js";
12
14
  /**
13
15
  * Thrown when input is structurally unrecognizable as a CampaignSpec.
14
16
  * Distinct from rule violations: a violation means "this spec is wrong";
@@ -35,7 +37,7 @@ export function normalize(input) {
35
37
  if (!Array.isArray(obj.funnels)) {
36
38
  throw new NormalizeError('CampaignSpec is missing funnels[]. Expected canonical v4.2+ topology.');
37
39
  }
38
- // Future migrations (v5 → v4, etc.) land here. Today the shape is already
39
- // canonical, so pass through.
40
- return obj;
40
+ // v4 → v5: retype each `checkout` that forwards to another `checkout` as
41
+ // `checkout_step` (campaigns-os#641). Returns a copy; the input is untouched.
42
+ return upgradeCampaignSpec(obj);
41
43
  }
@@ -73,9 +73,12 @@ export declare const DECLINE_ROUTE_FIELD: "on_decline";
73
73
  * is about one field's MEANING: reading a payment-shaped field on a page with
74
74
  * no payment is not respecting the author's intent, it is inventing one.
75
75
  *
76
- * Membership is deliberately narrow. The three-step shop family types its
77
- * `information` / `shipping` / `billing` pages as `checkout`, so they are
78
- * already covered; no certified family carries a second payment-bearing type.
76
+ * Membership is deliberately narrow. From CampaignSpec v5 (campaigns-os#641)
77
+ * `checkout` means exactly the page that takes payment; the earlier steps of a
78
+ * split checkout (the three-step shop family's `information` and `shipping`)
79
+ * are `checkout_step`, which take no payment and advance through `next_page`.
80
+ * A v4 spec that chained `checkout → checkout` is retyped on read
81
+ * (checkout-flow.ts), so the billing page alone stays payment-bearing here.
79
82
  * An `upsell` takes a one-click payment but expresses its post-purchase branch
80
83
  * through `on_accept`, which has its own applicability rule below.
81
84
  * Ratified on campaigns-os#234, 2026-08-25.
@@ -76,9 +76,12 @@ export const DECLINE_ROUTE_FIELD = 'on_decline';
76
76
  * is about one field's MEANING: reading a payment-shaped field on a page with
77
77
  * no payment is not respecting the author's intent, it is inventing one.
78
78
  *
79
- * Membership is deliberately narrow. The three-step shop family types its
80
- * `information` / `shipping` / `billing` pages as `checkout`, so they are
81
- * already covered; no certified family carries a second payment-bearing type.
79
+ * Membership is deliberately narrow. From CampaignSpec v5 (campaigns-os#641)
80
+ * `checkout` means exactly the page that takes payment; the earlier steps of a
81
+ * split checkout (the three-step shop family's `information` and `shipping`)
82
+ * are `checkout_step`, which take no payment and advance through `next_page`.
83
+ * A v4 spec that chained `checkout → checkout` is retyped on read
84
+ * (checkout-flow.ts), so the billing page alone stays payment-bearing here.
82
85
  * An `upsell` takes a one-click payment but expresses its post-purchase branch
83
86
  * through `on_accept`, which has its own applicability rule below.
84
87
  * Ratified on campaigns-os#234, 2026-08-25.
@@ -0,0 +1,17 @@
1
+ /**
2
+ * CheckoutForwardTarget — a Checkout takes payment, so where it sends the
3
+ * shopper next must be a post-payment page: an upsell, a downsell or a
4
+ * thank-you page (campaigns-os#641).
5
+ *
6
+ * A `checkout` forwarding to another checkout-shaped page, a landing page or a
7
+ * select page means one of two things, both wrong: the page is really a step
8
+ * that collects details and moves on (type it `checkout_step`), or the order
9
+ * would be placed before the shopper reaches the page meant to take payment.
10
+ *
11
+ * Only a target this funnel resolves is judged. An off-graph destination (a
12
+ * rooted path into an existing downstream campaign, an absolute URL) is the
13
+ * partial-scope shape and passes; an unresolved typo is RouteTargetResolves';
14
+ * a Checkout with no forward route at all is CheckoutHasSuccessUrl's.
15
+ */
16
+ import type { Rule } from '../types.ts';
17
+ export declare const CheckoutForwardTarget: Rule;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * CheckoutForwardTarget — a Checkout takes payment, so where it sends the
3
+ * shopper next must be a post-payment page: an upsell, a downsell or a
4
+ * thank-you page (campaigns-os#641).
5
+ *
6
+ * A `checkout` forwarding to another checkout-shaped page, a landing page or a
7
+ * select page means one of two things, both wrong: the page is really a step
8
+ * that collects details and moves on (type it `checkout_step`), or the order
9
+ * would be placed before the shopper reaches the page meant to take payment.
10
+ *
11
+ * Only a target this funnel resolves is judged. An off-graph destination (a
12
+ * rooted path into an existing downstream campaign, an absolute URL) is the
13
+ * partial-scope shape and passes; an unresolved typo is RouteTargetResolves';
14
+ * a Checkout with no forward route at all is CheckoutHasSuccessUrl's.
15
+ */
16
+ import { forwardRouteTarget } from "../routing.js";
17
+ import { POST_PAYMENT_PAGE_TYPES, forwardTargetPage, isPaymentPage } from "../checkout-flow.js";
18
+ export const CheckoutForwardTarget = {
19
+ id: 'CheckoutForwardTarget',
20
+ severity: 'error',
21
+ tags: ['fast', 'structure', 'spec-only'],
22
+ check(spec) {
23
+ const violations = [];
24
+ spec.funnels.forEach((funnel, funnelIdx) => {
25
+ const pages = funnel.pages ?? [];
26
+ pages.forEach((page, pageIdx) => {
27
+ if (!isPaymentPage(page))
28
+ return;
29
+ const target = forwardTargetPage(pages, page);
30
+ if (!target)
31
+ return;
32
+ const targetType = typeof target.type === 'string' ? target.type : '';
33
+ if (POST_PAYMENT_PAGE_TYPES.includes(targetType))
34
+ return;
35
+ violations.push({
36
+ ruleId: 'CheckoutForwardTarget',
37
+ severity: 'error',
38
+ message: `Checkout "${page.id}" forwards to "${target.id}" (type "${targetType || 'none'}"). ` +
39
+ 'A Checkout takes payment, so it must forward to an upsell, downsell or thank-you page. ' +
40
+ 'If this page collects details and moves on without placing an order, type it "checkout_step".',
41
+ path: `/funnels/${funnelIdx}/pages/${pageIdx}`,
42
+ data: { pageId: page.id, target: forwardRouteTarget(page), targetPageId: target.id, targetType },
43
+ });
44
+ });
45
+ });
46
+ return violations;
47
+ },
48
+ };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * CheckoutStepReachesCheckout — every `checkout_step` page must lie on a path
3
+ * to a Checkout. Following its forward links has to arrive at a `checkout`
4
+ * page; a step that dead-ends, loops, leaves the funnel or reaches an offer
5
+ * first collects the shopper's details and never takes payment
6
+ * (campaigns-os#641).
7
+ *
8
+ * Forward links only: a step has no decline branch that could carry the
9
+ * shopper to payment instead.
10
+ */
11
+ import type { Rule } from '../types.ts';
12
+ export declare const CheckoutStepReachesCheckout: Rule;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * CheckoutStepReachesCheckout — every `checkout_step` page must lie on a path
3
+ * to a Checkout. Following its forward links has to arrive at a `checkout`
4
+ * page; a step that dead-ends, loops, leaves the funnel or reaches an offer
5
+ * first collects the shopper's details and never takes payment
6
+ * (campaigns-os#641).
7
+ *
8
+ * Forward links only: a step has no decline branch that could carry the
9
+ * shopper to payment instead.
10
+ */
11
+ import { isCheckoutStepPage, paymentPageFrom } from "../checkout-flow.js";
12
+ export const CheckoutStepReachesCheckout = {
13
+ id: 'CheckoutStepReachesCheckout',
14
+ severity: 'error',
15
+ tags: ['structure', 'spec-only'],
16
+ check(spec) {
17
+ const violations = [];
18
+ spec.funnels.forEach((funnel, funnelIdx) => {
19
+ const pages = funnel.pages ?? [];
20
+ pages.forEach((page, pageIdx) => {
21
+ if (!isCheckoutStepPage(page))
22
+ return;
23
+ if (paymentPageFrom(pages, page))
24
+ return;
25
+ violations.push({
26
+ ruleId: 'CheckoutStepReachesCheckout',
27
+ severity: 'error',
28
+ message: `Checkout step "${page.id}" is not on a path to a Checkout. ` +
29
+ 'Follow its next_page links to the page that takes payment (type "checkout").',
30
+ path: `/funnels/${funnelIdx}/pages/${pageIdx}`,
31
+ data: { pageId: page.id },
32
+ });
33
+ });
34
+ });
35
+ return violations;
36
+ },
37
+ };
@@ -19,6 +19,7 @@
19
19
  *
20
20
  * Message text inherited verbatim from the pre-#110 validator at migration time.
21
21
  */
22
+ import { isCheckoutFlowPage } from "../checkout-flow.js";
22
23
  function isTruthyString(value) {
23
24
  return typeof value === 'string' && value.trim().length > 0;
24
25
  }
@@ -55,7 +56,7 @@ export const ExitIntentValidation = {
55
56
  const refOffer = ref ? index.byRefId.get(String(ref)) : null;
56
57
  const codeOffer = code ? index.byCode.get(String(code).toUpperCase()) : null;
57
58
  // 1. Placement
58
- if (page.type !== 'checkout') {
59
+ if (!isCheckoutFlowPage(page)) {
59
60
  violations.push({
60
61
  ruleId: 'ExitIntentValidation',
61
62
  severity: 'warning',
@@ -31,6 +31,10 @@ export { UpsellHasPackages } from './upsell-has-packages.ts';
31
31
  export { UpsellRoutingComplete } from './upsell-routing-complete.ts';
32
32
  export { RouteFieldIgnoredForPageType } from './route-field-ignored-for-page-type.ts';
33
33
  export { CheckoutHasSuccessUrl } from './checkout-has-success-url.ts';
34
+ export { CheckoutForwardTarget } from './checkout-forward-target.ts';
35
+ export { PrePaymentForwardTarget } from './pre-payment-forward-target.ts';
36
+ export { CheckoutStepReachesCheckout } from './checkout-step-reaches-checkout.ts';
37
+ export { OneCheckoutPerPath } from './one-checkout-per-path.ts';
34
38
  export { UpsellWithoutCheckout } from './upsell-without-checkout.ts';
35
39
  export { DownsellWithoutUpsell } from './downsell-without-upsell.ts';
36
40
  export { PackagePricingSanity } from './package-pricing-sanity.ts';
@@ -30,6 +30,10 @@ import { UpsellRoutingComplete } from "./upsell-routing-complete.js";
30
30
  import { RouteTargetResolves } from "./route-target-resolves.js";
31
31
  import { RouteFieldIgnoredForPageType } from "./route-field-ignored-for-page-type.js";
32
32
  import { CheckoutHasSuccessUrl } from "./checkout-has-success-url.js";
33
+ import { CheckoutForwardTarget } from "./checkout-forward-target.js";
34
+ import { PrePaymentForwardTarget } from "./pre-payment-forward-target.js";
35
+ import { CheckoutStepReachesCheckout } from "./checkout-step-reaches-checkout.js";
36
+ import { OneCheckoutPerPath } from "./one-checkout-per-path.js";
33
37
  import { UpsellWithoutCheckout } from "./upsell-without-checkout.js";
34
38
  import { DownsellWithoutUpsell } from "./downsell-without-upsell.js";
35
39
  import { CampaignMetadata } from "./campaign-metadata.js";
@@ -61,6 +65,10 @@ export { UpsellHasPackages } from "./upsell-has-packages.js";
61
65
  export { UpsellRoutingComplete } from "./upsell-routing-complete.js";
62
66
  export { RouteFieldIgnoredForPageType } from "./route-field-ignored-for-page-type.js";
63
67
  export { CheckoutHasSuccessUrl } from "./checkout-has-success-url.js";
68
+ export { CheckoutForwardTarget } from "./checkout-forward-target.js";
69
+ export { PrePaymentForwardTarget } from "./pre-payment-forward-target.js";
70
+ export { CheckoutStepReachesCheckout } from "./checkout-step-reaches-checkout.js";
71
+ export { OneCheckoutPerPath } from "./one-checkout-per-path.js";
64
72
  export { UpsellWithoutCheckout } from "./upsell-without-checkout.js";
65
73
  export { DownsellWithoutUpsell } from "./downsell-without-upsell.js";
66
74
  export { PackagePricingSanity } from "./package-pricing-sanity.js";
@@ -102,6 +110,10 @@ export const allRules = [
102
110
  RouteTargetResolves,
103
111
  RouteFieldIgnoredForPageType,
104
112
  CheckoutHasSuccessUrl,
113
+ CheckoutForwardTarget,
114
+ PrePaymentForwardTarget,
115
+ CheckoutStepReachesCheckout,
116
+ OneCheckoutPerPath,
105
117
  UpsellWithoutCheckout,
106
118
  DownsellWithoutUpsell,
107
119
  PackagePricingSanity,
@@ -0,0 +1,25 @@
1
+ /**
2
+ * OneCheckoutPerPath — every path from an entry page to an upsell or downsell
3
+ * passes exactly one Checkout (campaigns-os#641).
4
+ *
5
+ * None means the shopper reaches a post-purchase offer with no order placed;
6
+ * two means the path takes payment twice. A campaign may hold several
7
+ * Checkouts — split-test paths each reach their own — so the rule counts per
8
+ * path, never per spec.
9
+ *
10
+ * Paths follow both edges a page can traverse (forward and decline, from
11
+ * routing.ts) and start at the funnel's entry pages (checkout-flow.ts
12
+ * `entryPages`): pages flagged `is_entry`, else those nothing routes to. No
13
+ * page order or name is consulted. The search runs, per entry page, over
14
+ * (page, checkouts passed so far: 0, 1, 2+) states, so each bad page is
15
+ * reported once per entry and count however many of that entry's paths reach it.
16
+ *
17
+ * A spec with no Checkout anywhere is left to UpsellWithoutCheckout, so the
18
+ * two never report one defect twice.
19
+ *
20
+ * With build_scope.mode === 'partial' a path that reaches an offer with no
21
+ * Checkout is a warning, as in UpsellWithoutCheckout: a partial upsell build
22
+ * may ship an offer an existing checkout elsewhere routes into.
23
+ */
24
+ import type { Rule } from '../types.ts';
25
+ export declare const OneCheckoutPerPath: Rule;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * OneCheckoutPerPath — every path from an entry page to an upsell or downsell
3
+ * passes exactly one Checkout (campaigns-os#641).
4
+ *
5
+ * None means the shopper reaches a post-purchase offer with no order placed;
6
+ * two means the path takes payment twice. A campaign may hold several
7
+ * Checkouts — split-test paths each reach their own — so the rule counts per
8
+ * path, never per spec.
9
+ *
10
+ * Paths follow both edges a page can traverse (forward and decline, from
11
+ * routing.ts) and start at the funnel's entry pages (checkout-flow.ts
12
+ * `entryPages`): pages flagged `is_entry`, else those nothing routes to. No
13
+ * page order or name is consulted. The search runs, per entry page, over
14
+ * (page, checkouts passed so far: 0, 1, 2+) states, so each bad page is
15
+ * reported once per entry and count however many of that entry's paths reach it.
16
+ *
17
+ * A spec with no Checkout anywhere is left to UpsellWithoutCheckout, so the
18
+ * two never report one defect twice.
19
+ *
20
+ * With build_scope.mode === 'partial' a path that reaches an offer with no
21
+ * Checkout is a warning, as in UpsellWithoutCheckout: a partial upsell build
22
+ * may ship an offer an existing checkout elsewhere routes into.
23
+ */
24
+ import { outgoingEdgeIds } from "../routing.js";
25
+ import { POST_PURCHASE_OFFER_PAGE_TYPES, entryPages, isPaymentPage, resolveRouteTargetPage } from "../checkout-flow.js";
26
+ function isOffer(page) {
27
+ const type = typeof page.type === 'string' ? page.type.trim().toLowerCase() : '';
28
+ return POST_PURCHASE_OFFER_PAGE_TYPES.includes(type);
29
+ }
30
+ export const OneCheckoutPerPath = {
31
+ id: 'OneCheckoutPerPath',
32
+ severity: 'error',
33
+ tags: ['structure', 'spec-only'],
34
+ check(spec) {
35
+ const violations = [];
36
+ const partial = spec.build_scope?.mode === 'partial';
37
+ // A spec with no Checkout at all is UpsellWithoutCheckout's to report; this
38
+ // rule speaks only when Checkouts exist and some path misses or doubles one.
39
+ const anyCheckout = spec.funnels.some((funnel) => (funnel.pages ?? []).some((page) => isPaymentPage(page)));
40
+ spec.funnels.forEach((funnel, funnelIdx) => {
41
+ const pages = (funnel.pages ?? []).filter((page) => page && page.enabled !== false);
42
+ const indexOf = new Map();
43
+ (funnel.pages ?? []).forEach((page, idx) => indexOf.set(page, idx));
44
+ // Searched once per entry page, so every entry whose path misses or
45
+ // doubles the Checkout is named, not only the first one to reach the page.
46
+ for (const entry of entryPages(funnel)) {
47
+ const seen = new Set();
48
+ const reported = new Set();
49
+ const stack = [[entry, 0]];
50
+ while (stack.length) {
51
+ const [page, before] = stack.pop();
52
+ const passed = Math.min(before + (isPaymentPage(page) ? 1 : 0), 2);
53
+ const key = `${indexOf.get(page)}:${passed}`;
54
+ if (seen.has(key))
55
+ continue;
56
+ seen.add(key);
57
+ if (isOffer(page) && passed !== 1 && (passed > 1 || anyCheckout)) {
58
+ const reportKey = `${indexOf.get(page)}:${passed === 0 ? 0 : 2}`;
59
+ if (!reported.has(reportKey)) {
60
+ reported.add(reportKey);
61
+ const none = passed === 0;
62
+ violations.push({
63
+ ruleId: 'OneCheckoutPerPath',
64
+ severity: none && partial ? 'warning' : 'error',
65
+ message: none
66
+ ? `A path from entry page "${entry.id}" reaches "${page.id}" (${page.type}) without passing a Checkout, so no order has been placed.` +
67
+ (partial ? ' Valid for partial upsell builds when an existing checkout routes into this page.' : '')
68
+ : `A path from entry page "${entry.id}" reaches "${page.id}" (${page.type}) after passing more than one Checkout, so it takes payment twice.`,
69
+ path: `/funnels/${funnelIdx}/pages/${indexOf.get(page)}`,
70
+ data: { pageId: page.id, entryPageId: entry.id, checkoutsPassed: none ? 0 : 2, isPartialScope: partial },
71
+ });
72
+ }
73
+ }
74
+ for (const target of outgoingEdgeIds(page)) {
75
+ const next = resolveRouteTargetPage(pages, target);
76
+ if (next)
77
+ stack.push([next, passed]);
78
+ }
79
+ }
80
+ }
81
+ });
82
+ return violations;
83
+ },
84
+ };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * PrePaymentForwardTarget — a `select` or `checkout_step` page takes no
3
+ * payment, so it must not forward straight to a post-payment page (an upsell,
4
+ * a downsell or a thank-you page). Doing so skips the Checkout: the shopper
5
+ * reaches an offer or a receipt with no order placed (campaigns-os#641).
6
+ *
7
+ * Only the resolved forward link is judged — routing.ts already ignores a
8
+ * `success_url` or `on_accept` on these types, and RouteFieldIgnoredForPageType
9
+ * warns about those separately.
10
+ */
11
+ import type { Rule } from '../types.ts';
12
+ export declare const PrePaymentForwardTarget: Rule;