@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
@@ -70,12 +70,21 @@ export const CART_ENTRY_ROUTE_ATTRIBUTE = "data-next-url";
70
70
  // preventDefault() unconditionally, so its href never navigates, and it
71
71
  // resolves the attribute against the origin (campaign-cart url-utils), not the
72
72
  // document base. Without the attribute such a control adds to the cart and
73
- // stays put — no route. Anywhere else the SDK attribute has no navigation
73
+ // stays put — no route. A control matching one of `declaredRoutes` (each
74
+ // `{ selector, url }`, a route the page declares rather than one the control
75
+ // carries: the SDK checkout form's submit button and an upsell's accept /
76
+ // decline actions) goes to that url and nothing else, because the SDK
77
+ // handles the submission or the click itself, so an href="#" or the form's
78
+ // action never navigates. Anywhere else the SDK attribute has no navigation
74
79
  // semantics: an HTML anchor's own resolved href (native, so a <base href> is
75
80
  // honoured), then an href attribute, then `data-href`, then a wrapping form's
76
- // action. Only spellings with real navigation semantics count — an attribute
77
- // the SDK does not declare is not a route.
78
- export function cartEntryHrefFor(element, { cartEntrySelector, cartEntryRouteAttribute, origin, baseHref }) {
81
+ // action — for that form's submit button only, and never inside a form
82
+ // matching `checkoutFormSelector`: the SDK checkout form, which the SDK
83
+ // submits itself, or, on a checkout or upsell / downsell page, every form,
84
+ // since there the only form-borne route is the one in `declaredRoutes`. Only
85
+ // spellings with real navigation semantics count — an attribute the SDK does
86
+ // not declare is not a route.
87
+ export function cartEntryHrefFor(element, { cartEntrySelector, cartEntryRouteAttribute, declaredRoutes = [], checkoutFormSelector = null, origin, baseHref }) {
79
88
  if (!element || typeof element.getAttribute !== "function") return null;
80
89
  if (typeof element.matches === "function" && element.matches(cartEntrySelector)) {
81
90
  const sdkRoute = String(element.getAttribute(cartEntryRouteAttribute) || "").trim();
@@ -88,12 +97,26 @@ export function cartEntryHrefFor(element, { cartEntrySelector, cartEntryRouteAtt
88
97
  return null;
89
98
  }
90
99
  }
100
+ const declared = typeof element.matches === "function"
101
+ ? (declaredRoutes || []).find((route) => route?.selector && element.matches(route.selector))
102
+ : null;
103
+ if (declared) {
104
+ try {
105
+ return new URL(declared.url, baseHref).href;
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
91
110
  // An HTML anchor resolves its own href (an SVG <a> exposes an object, not a
92
111
  // string, and falls through to the attribute reading).
93
- if (String(element.tagName || "").toUpperCase() === "A" && typeof element.href === "string" && element.href) return element.href;
112
+ const tag = String(element.tagName || "").toUpperCase();
113
+ if (tag === "A" && typeof element.href === "string" && element.href) return element.href;
114
+ // The reflected type, so a <button> with no type attribute submits too.
115
+ const submits = (tag === "BUTTON" || tag === "INPUT") && String(element.type || "").toLowerCase() === "submit";
116
+ const insideRoutelessForm = Boolean(checkoutFormSelector) && typeof element.closest === "function" && Boolean(element.closest(checkoutFormSelector));
94
117
  const attr = element.getAttribute("href")
95
118
  || element.getAttribute("data-href")
96
- || (typeof element.closest === "function" ? element.closest("form")?.getAttribute("action") : null);
119
+ || (submits && !insideRoutelessForm && typeof element.closest === "function" ? element.closest("form")?.getAttribute("action") : null);
97
120
  if (!attr) return null;
98
121
  try {
99
122
  return new URL(attr, baseHref).href;
@@ -104,6 +127,17 @@ export function cartEntryHrefFor(element, { cartEntrySelector, cartEntryRouteAtt
104
127
  }
105
128
  }
106
129
 
130
+ // A select page (campaigns-os#641) has no add-to-cart control: its swap-mode
131
+ // bundle selector writes the cart when a card is chosen, the SDK persists the
132
+ // cart between pages, and a checkout button or link moves on. The runner
133
+ // treats "bundle cards plus one of these" as a cart-entry control: the SDK's
134
+ // checkout action, or a plain link into the next page. Matched by this one
135
+ // selector so the chosen control replays with nth().
136
+ export const CART_ENTRY_CHECKOUT_BUTTON_SELECTOR = '[data-next-action="checkout"], a[href]';
137
+
138
+ // The bundle cards a select page renders, outside the cart summary.
139
+ export const CART_ENTRY_BUNDLE_CARD_SELECTOR = "[data-next-bundle-card]";
140
+
107
141
  // Route-shaped spellings the runner used to consult and no longer does,
108
142
  // because the SDK never declares them. They are not routes, but a page that
109
143
  // carries one is reported so an operator can tell "the runner narrowed its
@@ -115,8 +149,10 @@ export const UNDECLARED_ROUTE_ATTRIBUTES = Object.freeze(["data-next-href", "dat
115
149
  // shop-single-step landing declares; the rest are the entry-like types
116
150
  // `deriveEntryUrls` already recognises.
117
151
  // Pages that can only follow the checkout: the checkout itself plus the
118
- // topology module's own offer and receipt sets, so the two never drift.
119
- const POST_CHECKOUT_PAGE_TYPES = new Set(["checkout", ...OFFER_PAGE_TYPES, ...RECEIPT_PAGE_TYPES]);
152
+ // topology module's own offer and receipt sets, so the two never drift. A
153
+ // checkout_step is never a cart entry either: it is a form page of the
154
+ // checkout path itself (campaigns-os#641).
155
+ const POST_CHECKOUT_PAGE_TYPES = new Set(["checkout", "checkout_step", ...OFFER_PAGE_TYPES, ...RECEIPT_PAGE_TYPES]);
120
156
  const PREFERRED_ENTRY_PAGE_TYPES = ["select", "landing", "product", "presell", "entry", "lander", "advertorial", "listicle", "review", "opt-in", "optin"];
121
157
 
122
158
  export function codedError(code, message) {
@@ -211,18 +247,22 @@ export function summarizeSelectionSurface(surface) {
211
247
  return parts.length ? parts.join(", ") : "none";
212
248
  }
213
249
 
214
- // evaluate() body: the cart-entry controls the entry page renders. Two kinds:
215
- // add_to_cart the SDK action controls (CART_ENTRY_CONTROL_SELECTOR); the
216
- // SDK adds the package and navigates via data-next-url;
217
- // checkout_link a plain link into the checkout URL carrying
218
- // `?forcePackageId=`, which the SDK reads on the checkout page
219
- // to pre-load the cart. This is what the certified
220
- // shop-single-step landing renders today.
250
+ // evaluate() body: the cart-entry controls the entry page renders. Three kinds:
251
+ // add_to_cart the SDK action controls (CART_ENTRY_CONTROL_SELECTOR); the
252
+ // SDK adds the package and navigates via data-next-url;
253
+ // checkout_link a plain link into the checkout URL carrying
254
+ // `?forcePackageId=`, which the SDK reads on the checkout
255
+ // page to pre-load the cart. This is what the certified
256
+ // shop-single-step landing renders today;
257
+ // checkout_button on a page with bundle cards only: the SDK checkout action
258
+ // or a plain link into the checkout URL. The selected card
259
+ // is the cart; `bundle_package_ids` lists what the cards
260
+ // carry so --select-package can name one (campaigns-os#641).
221
261
  // Each control reports its visibility, text, and the package id it carries
222
262
  // (own attribute, nearest card, or the forcePackageId ref), so the caller can
223
263
  // prefer a visible SDK control and honour --select-package in one round trip.
224
264
  export function cartEntryControlsScript() {
225
- return ({ selector, checkoutUrl }) => {
265
+ return ({ selector, checkoutUrl, checkoutButtonSelector = null, bundleCardSelector = null }) => {
226
266
  const clean = (value) => String(value || "").replace(/\s+/g, " ").trim();
227
267
  const isVisible = (element) => {
228
268
  const rect = element.getBoundingClientRect();
@@ -290,7 +330,39 @@ export function cartEntryControlsScript() {
290
330
  }];
291
331
  })
292
332
  : [];
293
- return [...actions, ...links];
333
+ const cards = Array.from(document.querySelectorAll(bundleCardSelector || "[data-next-bundle-card]"))
334
+ .filter((card) => !card.closest("[data-next-cart-summary], [data-next-upsell-context], [data-next-upsell], template"));
335
+ const bundlePackageIds = [...new Set(cards.flatMap((card) => {
336
+ const own = clean(card.getAttribute("data-next-package-id"));
337
+ const nested = Array.from(card.querySelectorAll("[data-next-package-id]")).map((node) => clean(node.getAttribute("data-next-package-id")));
338
+ return [own, ...nested].filter(Boolean);
339
+ }))];
340
+ const buttons = cards.length && checkoutButtonSelector
341
+ ? Array.from(document.querySelectorAll(checkoutButtonSelector)).flatMap((element, index) => {
342
+ const isAction = element.getAttribute("data-next-action") === "checkout";
343
+ if (!isAction) {
344
+ const href = element.getAttribute("href");
345
+ if (!checkout || canonical(href) !== checkout) return [];
346
+ try {
347
+ if (new URL(href, document.baseURI).searchParams.get("forcePackageId")) return [];
348
+ } catch {
349
+ return [];
350
+ }
351
+ }
352
+ return [{
353
+ kind: "checkout_button",
354
+ index,
355
+ visible: isVisible(element),
356
+ text: label(element),
357
+ package_id: null,
358
+ bundle_package_ids: bundlePackageIds,
359
+ quantity: 1,
360
+ next_url: isAction ? null : element.getAttribute("href"),
361
+ tag: element.tagName.toLowerCase(),
362
+ }];
363
+ })
364
+ : [];
365
+ return [...actions, ...links, ...buttons];
294
366
  };
295
367
  }
296
368
 
@@ -300,7 +372,7 @@ export function cartEntryControlsScript() {
300
372
  // control is only used when nothing is visible.
301
373
  export function chooseCartEntryControl(controls = [], requested = []) {
302
374
  const list = Array.isArray(controls) ? controls : [];
303
- if (!list.length) return { control: null, reason: "no add-to-cart control or forcePackageId checkout link rendered on the entry page" };
375
+ if (!list.length) return { control: null, reason: "no add-to-cart control, forcePackageId checkout link, or bundle cards with a checkout button rendered on the entry page" };
304
376
  const wanted = Array.isArray(requested) ? requested.filter((item) => item?.packageId) : [];
305
377
  if (wanted.length > 1) {
306
378
  return {
@@ -308,9 +380,24 @@ export function chooseCartEntryControl(controls = [], requested = []) {
308
380
  reason: `--select-package requested ${wanted.length} refs, but a landing-page entry can select at most one package before the SDK navigates to checkout`,
309
381
  };
310
382
  }
383
+ // A select page's checkout button carries no package of its own: the
384
+ // requested package is the bundle card the runner clicks before it. Used
385
+ // only when no add-to-cart control or forcePackageId link carries the ref.
386
+ const buttons = list.filter((control) => control.kind === "checkout_button");
387
+ const carriers = list.filter((control) => control.kind !== "checkout_button");
311
388
  const byRef = wanted.length
312
- ? list.filter((control) => String(control.package_id) === String(wanted[0].packageId))
313
- : list;
389
+ ? carriers.filter((control) => String(control.package_id) === String(wanted[0].packageId))
390
+ : carriers;
391
+ if (!byRef.length && buttons.length) {
392
+ const pick = (pool) => pool.find((control) => control.visible) || pool[0];
393
+ if (!wanted.length) return { control: pick(buttons), reason: null };
394
+ const withCard = buttons.filter((control) => (control.bundle_package_ids || []).map(String).includes(String(wanted[0].packageId)));
395
+ if (withCard.length) return { control: pick(withCard), select_card: wanted[0], reason: null };
396
+ return {
397
+ control: null,
398
+ reason: `--select-package ${wanted[0].packageId}: no bundle card on the entry page carries package ${wanted[0].packageId} (rendered: ${[...new Set(buttons.flatMap((control) => control.bundle_package_ids || []))].join(", ") || "(none)"})`,
399
+ };
400
+ }
314
401
  if (!byRef.length) {
315
402
  return {
316
403
  control: null,