@nextcommerce/campaigns-os 1.55.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 (93) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/CONTEXT.md +11 -0
  3. package/campaign-spec/README.md +15 -1
  4. package/campaign-spec/dist/checkout-flow.d.ts +90 -0
  5. package/campaign-spec/dist/checkout-flow.js +229 -0
  6. package/campaign-spec/dist/index.d.ts +1 -0
  7. package/campaign-spec/dist/index.js +6 -1
  8. package/campaign-spec/dist/normalize.d.ts +3 -2
  9. package/campaign-spec/dist/normalize.js +7 -5
  10. package/campaign-spec/dist/routing.d.ts +6 -3
  11. package/campaign-spec/dist/routing.js +6 -3
  12. package/campaign-spec/dist/rules/checkout-forward-target.d.ts +17 -0
  13. package/campaign-spec/dist/rules/checkout-forward-target.js +48 -0
  14. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.d.ts +12 -0
  15. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.js +37 -0
  16. package/campaign-spec/dist/rules/exit-intent-validation.js +2 -1
  17. package/campaign-spec/dist/rules/index.d.ts +4 -0
  18. package/campaign-spec/dist/rules/index.js +12 -0
  19. package/campaign-spec/dist/rules/one-checkout-per-path.d.ts +25 -0
  20. package/campaign-spec/dist/rules/one-checkout-per-path.js +84 -0
  21. package/campaign-spec/dist/rules/pre-payment-forward-target.d.ts +12 -0
  22. package/campaign-spec/dist/rules/pre-payment-forward-target.js +42 -0
  23. package/campaign-spec/dist/rules/promo-code-input-validation.js +2 -1
  24. package/campaign-spec/dist/rules/schema-version.d.ts +9 -4
  25. package/campaign-spec/dist/rules/schema-version.js +10 -5
  26. package/campaign-spec/dist/types.d.ts +20 -3
  27. package/campaign-spec/dist/types.js +3 -2
  28. package/compatibility.json +2 -2
  29. package/contracts/fixtures/runtime-recipe/accept/current.json +6 -1
  30. package/contracts/fixtures/runtime-recipe/accept/minimal.json +6 -1
  31. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  32. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +6 -1
  33. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +6 -1
  34. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +6 -1
  35. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +6 -1
  36. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +6 -1
  37. package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +1 -1
  38. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +6 -1
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +6 -1
  40. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +6 -1
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +5 -0
  42. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +6 -1
  43. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +6 -1
  44. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +6 -1
  45. package/contracts/release-ledger.json +705 -0
  46. package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
  47. package/contracts/supported-surface.json +8 -4
  48. package/docs/build-packet.md +52 -8
  49. package/docs/local-setup.md +1 -1
  50. package/docs/orientation-contract-reference.md +1 -1
  51. package/docs/qa-and-test-orders.md +78 -18
  52. package/docs/runtime-readiness.md +8 -3
  53. package/docs/skills-revision.md +10 -10
  54. package/docs/supported-surface.md +1 -1
  55. package/docs/versioning.md +4 -1
  56. package/package.json +2 -1
  57. package/schemas/campaign-runtime-build-context.v0.schema.json +3 -1
  58. package/schemas/campaign-runtime-build-packet.v0.schema.json +3 -1
  59. package/schemas/campaign-spec.v5.schema.json +586 -0
  60. package/skills/campaign-lifecycle-orientation/SKILL.md +10 -8
  61. package/skills/campaign-readback-classification/SKILL.md +3 -3
  62. package/skills/campaign-run-evidence/SKILL.md +3 -3
  63. package/skills/contribution-intake/SKILL.md +3 -3
  64. package/skills/next-campaigns-build/SKILL.md +6 -4
  65. package/skills/next-campaigns-os/SKILL.md +3 -3
  66. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  67. package/skills/next-campaigns-polish/SKILL.md +21 -6
  68. package/skills/next-campaigns-qa/SKILL.md +4 -3
  69. package/skills.json +10 -10
  70. package/src/adapter-decision-contract.mjs +1 -1
  71. package/src/build-brief.mjs +14 -5
  72. package/src/cli-helpers.mjs +129 -0
  73. package/src/cli.mjs +121 -53
  74. package/src/commercial-journey.mjs +25 -2
  75. package/src/design-source-package.mjs +1 -1
  76. package/src/design-source-publication.mjs +7 -7
  77. package/src/doctor/checks.mjs +196 -13
  78. package/src/doctor/inspect.mjs +4 -2
  79. package/src/doctor/next-step.mjs +31 -11
  80. package/src/doctor-sidecar.mjs +5 -3
  81. package/src/gate-actions.mjs +21 -1
  82. package/src/input-refresh.mjs +6 -6
  83. package/src/local-preview-policy.mjs +110 -12
  84. package/src/polish-node.mjs +3 -3
  85. package/src/qa-browser.mjs +226 -34
  86. package/src/qa-cart-entry.mjs +78 -14
  87. package/src/qa-node.mjs +35 -33
  88. package/src/qa-test-order-topology.mjs +118 -1
  89. package/src/qa-verdict-discovery.mjs +21 -4
  90. package/src/runtime-state-ignore.mjs +7 -5
  91. package/src/source-html-intake.mjs +46 -4
  92. package/src/stage-ledger.mjs +4 -2
  93. package/src/stage-record.mjs +3 -3
package/src/qa-node.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch } from "./spec-source-identity.mjs";
2
- import { applyLocalPreviewToCheckpoint, applyLocalPreviewToPolishGate, carriedForwardMessage, CARRIED_FORWARD, starterResidueIsExpected } from "./local-preview-policy.mjs";
2
+ import { applyLocalPreviewToCheckpoint, applyLocalPreviewToPolishGate, carriedForwardMessage, CARRIED_FORWARD, ensureUrlTrailingSlash, normalizeQaBaseUrl, starterResidueIsExpected } from "./local-preview-policy.mjs";
3
3
  import { expectedBinding, createBindingScriptLoader, observeBinding, bindingAssertion, scriptParseAssertion } from './qa-binding-evidence.mjs';
4
4
  import { shellToken } from "./shell-token.mjs";
5
5
  import { currentBriefMaterial } from "./input-currency.mjs";
@@ -61,8 +61,10 @@ import {
61
61
  declineRouteTarget,
62
62
  forwardRouteTarget,
63
63
  inapplicableForwardFields,
64
+ upgradeCampaignSpec,
64
65
  } from "../campaign-spec/dist/index.js";
65
66
  import { evaluateThemeGate } from "./theme-gate.mjs";
67
+ import { findPaymentPage } from "./qa-test-order-topology.mjs";
66
68
  import { probeRouteUrls, ROUTE_PROBE_DEFAULT_TIMEOUT_MS } from "./qa-route-probe.mjs";
67
69
  import { resolveCommerceCatalog, resolvePacketCommerceCatalogPath, resolveTemplateBrandContract } from "./private-template-source.mjs";
68
70
  import { computeBuildFingerprint, resolveBuiltSiteScope, topologiesFromBuiltSiteScope } from "./built-site-scope.mjs";
@@ -85,6 +87,7 @@ import { campaignSidecarPaths, explicitReportPath, resolveCampaignWorkspace, tar
85
87
  import { loadParityFixture } from "./qa-parity-fixture.mjs";
86
88
  import { assessParityCapture, resolveParityScenario, runParityCapture } from "./qa-parity-capture.mjs";
87
89
  import { loadPageKitCampaignEntry, PAGE_KIT_CAMPAIGNS_REL_PATH } from "./page-kit-campaign-config.mjs";
90
+ import { publicRouteForPage } from "./source-html-intake.mjs";
88
91
  import {
89
92
  evaluatePageKitStoreProfile,
90
93
  PAGE_KIT_STORE_PROFILE_FIELDS,
@@ -115,7 +118,7 @@ import {
115
118
  liveRefsNotRunMessage,
116
119
  readLiveCampaignForPacket,
117
120
  } from "./live-campaign-refs.mjs";
118
- import { specPackageRefs, specShippingRefs } from "./doctor/checks.mjs";
121
+ import { entryPageKitServingRoute, isRootRoutedEntryPage, specPackageRefs, specShippingRefs } from "./doctor/checks.mjs";
119
122
 
120
123
  // The producing runtime identity on every verdict. Read from package.json so
121
124
  // a verdict names the release that made it; a literal here outlived three
@@ -464,7 +467,6 @@ async function resolveQaInputs(args, {
464
467
  }
465
468
  const normalized = normalizeSpec(rawSpec);
466
469
  const publicRouteSlug = resolvePublicRouteSlug({ packet, spec: normalized, rawSpec });
467
- const baseUrl = normalizeQaBaseUrl(inputBaseUrl, publicRouteSlug);
468
470
  // Packet 01: the analytics legs capture ONE URL derived from resolved
469
471
  // identity (public_route_slug + route_root), composed here where the raw
470
472
  // operator/deploy base is still in hand.
@@ -475,6 +477,7 @@ async function resolveQaInputs(args, {
475
477
  routeRoot: resolveCampaignRouteRoot({ packet, spec: normalized, rawSpec, publicRouteSlug, notes: routeRootNotes }),
476
478
  routeRootNote: routeRootNotes[0] || null,
477
479
  });
480
+ const baseUrl = normalizeQaBaseUrl(inputBaseUrl, publicRouteSlug);
478
481
  const specHash = computeSpecHash(rawSpec);
479
482
  // The brief material this run is judged against, bound at run start beside
480
483
  // the spec hash: the QA stage write stamps it, not the material at write time.
@@ -484,7 +487,16 @@ async function resolveQaInputs(args, {
484
487
  || stringArg(normalized?.campaign?.preferred_template_family)
485
488
  || null;
486
489
  const commerceStructureContract = loadCommerceStructureContract({ packet, packetPath, templateFamily });
487
- const topologies = extractTopologies(normalized, { baseUrl, publicRouteSlug, templateFamily, commerceStructureContract });
490
+ const targetRepo = checkpointPreflight?.targetRepo || (packetPath && packet ? targetRepoFor(packetPath, packet) : null);
491
+ const targetOutputDir = targetRepo && packet?.assembly?.output_dir ? resolve(targetRepo, packet.assembly.output_dir) : null;
492
+ const mappingById = new Map((packet?.source_html?.pages || []).map((page) => [page.page_id, page]));
493
+ const topologies = extractTopologies(normalized, {
494
+ baseUrl, publicRouteSlug, templateFamily, commerceStructureContract,
495
+ entryServingRoute: (page) => entryPageKitServingRoute(targetRepo, publicRouteSlug, page, {
496
+ targetOutputDir,
497
+ mapping: mappingById.get(page.id),
498
+ }),
499
+ });
488
500
  const themeGate = resolveThemeGate({
489
501
  packetPath,
490
502
  topologies,
@@ -971,7 +983,9 @@ function resolvePolishGate({
971
983
  return gate;
972
984
  }
973
985
 
974
- const THEME_GATE_COMMERCE_TYPES = new Set(["checkout", "upsell", "downsell", "receipt", "thankyou"]);
986
+ // select and checkout_step pages render the family's checkout surface without
987
+ // taking payment (campaigns-os#641); the brand theme applies to them as well.
988
+ const THEME_GATE_COMMERCE_TYPES = new Set(["select", "checkout_step", "checkout", "upsell", "downsell", "receipt", "thankyou"]);
975
989
 
976
990
  function themeGateScopeFromTopologies(topologies = []) {
977
991
  const built_pages = [];
@@ -3234,7 +3248,10 @@ async function maybeRunLegacyApiTestOrders({ args, resolved, runId, assertions }
3234
3248
  const apiBase = stringArg(args["campaigns-api-base"]) || process.env.CAMPAIGNS_API_BASE;
3235
3249
  if (!apiKey || !apiBase) throw new Error("Legacy direct API test orders require --api-key/QA_CAMPAIGNS_API_KEY and --campaigns-api-base/CAMPAIGNS_API_BASE.");
3236
3250
  const { cart, paths } = legacyTestOrderInputs(args);
3237
- const checkout = findPage(resolved.topologies, "checkout");
3251
+ // The Checkout on the tested path, never the first page typed checkout: on a
3252
+ // split checkout that was the first step, whose forward link is the next
3253
+ // step, so the order's success URL pointed back into checkout (#641).
3254
+ const checkout = findPaymentPage(resolved.topologies);
3238
3255
  if (!checkout?.url) throw new Error("--test-order requires a checkout page URL.");
3239
3256
  const upsell = findPage(resolved.topologies, "upsell");
3240
3257
  const orders = [];
@@ -3318,22 +3335,25 @@ async function createTestOrder({ apiBase, apiKey, cart, runId, successUrl, spec,
3318
3335
  }
3319
3336
  }
3320
3337
 
3338
+ // QA reads the v5 page vocabulary: a v4 spec's `checkout → checkout` chain is
3339
+ // retyped `checkout_step` on read (campaigns-os#641). The raw spec is untouched,
3340
+ // so the spec hash still covers the bytes as written.
3321
3341
  function normalizeSpec(raw) {
3322
- if (Array.isArray(raw?.funnels)) return raw;
3342
+ if (Array.isArray(raw?.funnels)) return upgradeCampaignSpec(raw);
3323
3343
  if (Array.isArray(raw?.funnel_pages)) {
3324
3344
  return { ...raw, funnels: [{ id: "default", name: "Default", weight: 100, pages: raw.funnel_pages }] };
3325
3345
  }
3326
3346
  return { ...raw, funnels: [] };
3327
3347
  }
3328
3348
 
3329
- function extractTopologies(spec, { baseUrl = null, publicRouteSlug = null, templateFamily = null, commerceStructureContract = null } = {}) {
3349
+ function extractTopologies(spec, { baseUrl = null, publicRouteSlug = null, templateFamily = null, commerceStructureContract = null, entryServingRoute = null } = {}) {
3330
3350
  const pageById = new Map();
3331
3351
  for (const funnel of spec.funnels || []) {
3332
3352
  for (const page of funnel.pages || []) pageById.set(page.id, page);
3333
3353
  }
3334
3354
  const urlById = new Map();
3335
3355
  for (const [id, page] of pageById) {
3336
- urlById.set(id, resolvePageUrl(page, baseUrl, publicRouteSlug));
3356
+ urlById.set(id, resolvePageUrl(page, baseUrl, publicRouteSlug, entryServingRoute?.(page)));
3337
3357
  }
3338
3358
  return (spec.funnels || []).map((funnel) => ({
3339
3359
  funnel_id: funnel.id || "default",
@@ -3388,13 +3408,13 @@ function extractTopologies(spec, { baseUrl = null, publicRouteSlug = null, templ
3388
3408
  }));
3389
3409
  }
3390
3410
 
3391
- function resolvePageUrl(page, baseUrl, publicRouteSlug = null) {
3411
+ function resolvePageUrl(page, baseUrl, publicRouteSlug = null, entryServingRoute = null) {
3392
3412
  if (typeof page.url === "string" && page.url.trim()) return page.url.trim();
3393
3413
  if (!baseUrl) return null;
3394
- const route = typeof page.page_url === "string" && page.page_url.trim()
3395
- ? runtimeRelativeRouteForSpecValue(page.page_url, publicRouteSlug)
3396
- : page.is_entry
3397
- ? ""
3414
+ const route = isRootRoutedEntryPage(page, publicRouteSlug)
3415
+ ? entryServingRoute?.route || ""
3416
+ : typeof page.page_url === "string" && page.page_url.trim()
3417
+ ? runtimeRelativeRouteForSpecValue(page.page_url, publicRouteSlug)
3398
3418
  : defaultRouteForType(page.type);
3399
3419
  if (isAbsoluteHttpUrl(route)) return route;
3400
3420
  try {
@@ -3406,6 +3426,7 @@ function resolvePageUrl(page, baseUrl, publicRouteSlug = null) {
3406
3426
 
3407
3427
  function defaultRouteForType(type) {
3408
3428
  if (type === "thankyou") return "receipt/";
3429
+ if (type === "checkout_step") return "checkout-step/";
3409
3430
  if (["presell", "landing", "checkout", "upsell", "downsell"].includes(type)) return `${type}/`;
3410
3431
  return `${type || "page"}/`;
3411
3432
  }
@@ -3929,21 +3950,6 @@ function normalizeBaseUrl(value) {
3929
3950
  return typeof value === "string" && value.trim() ? value.trim() : null;
3930
3951
  }
3931
3952
 
3932
- function normalizeQaBaseUrl(value, publicRouteSlug) {
3933
- const baseUrl = normalizeBaseUrl(value);
3934
- if (!baseUrl) return null;
3935
- const slug = normalizePublicRouteSlug(publicRouteSlug);
3936
- if (!slug) return ensureUrlTrailingSlash(baseUrl);
3937
- try {
3938
- const url = new URL(ensureUrlTrailingSlash(baseUrl));
3939
- const segments = url.pathname.split("/").filter(Boolean);
3940
- if (segments.at(-1) === slug) return ensureUrlTrailingSlash(url.toString());
3941
- return new URL(`${slug}/`, url).toString();
3942
- } catch {
3943
- return ensureUrlTrailingSlash(baseUrl);
3944
- }
3945
- }
3946
-
3947
3953
  // The campaign's served route root — "/", "/<slug>/", or null — read by the
3948
3954
  // one rule every stage shares (route-identity.mjs): the packet under its exact
3949
3955
  // canonical form, the spec under prepare-build's intake form. A declaration
@@ -4051,10 +4057,6 @@ function resolvePublicRouteSlug({ packet, spec, rawSpec }) {
4051
4057
  || null;
4052
4058
  }
4053
4059
 
4054
- function ensureUrlTrailingSlash(value) {
4055
- return value.endsWith("/") ? value : `${value}/`;
4056
- }
4057
-
4058
4060
  function stripOrigin(value) {
4059
4061
  try {
4060
4062
  const url = new URL(value);
@@ -5,9 +5,126 @@ const ACTIONS = Object.freeze([
5
5
  ["accept", "expected_accept_url"],
6
6
  ]);
7
7
 
8
+ // The page types of a checkout path, as pageType() spells them (lowercase, `_`
9
+ // and `-` dropped). `checkout` is the page that takes payment; `select` and
10
+ // `checkout_step` lead into it without placing an order (campaigns-os#641).
11
+ const PAYMENT_PAGE_TYPE = "checkout";
12
+ const PRE_PAYMENT_PAGE_TYPES = new Set(["select", "checkoutstep"]);
13
+
14
+ export function isPaymentTopologyPage(page) {
15
+ return pageType(page) === PAYMENT_PAGE_TYPE;
16
+ }
17
+
18
+ export function isCheckoutStepTopologyPage(page) {
19
+ return pageType(page) === "checkoutstep";
20
+ }
21
+
22
+ // Follow forward links (expected_next_url) from `start` to the page that takes
23
+ // payment. Null when the walk leaves the topology, loops, or reaches any page
24
+ // that is neither a pre-payment page nor the Checkout.
25
+ export function paymentPageFrom(topology, start) {
26
+ const pages = Array.isArray(topology?.pages) ? topology.pages : [];
27
+ const seen = new Set();
28
+ let page = start || null;
29
+ while (page && !seen.has(page)) {
30
+ seen.add(page);
31
+ if (isPaymentTopologyPage(page)) return page;
32
+ const key = canonicalHttpUrl(page.expected_next_url);
33
+ const next = key ? pages.find((candidate) => canonicalHttpUrl(candidate?.url) === key) : null;
34
+ if (!next) return null;
35
+ if (!isPaymentTopologyPage(next) && !PRE_PAYMENT_PAGE_TYPES.has(pageType(next))) return null;
36
+ page = next;
37
+ }
38
+ return null;
39
+ }
40
+
41
+ // The topology's entry pages: flagged is_entry, else those no page links to
42
+ // (offer and receipt pages excluded: an orphaned upsell is not where a shopper
43
+ // starts), else the first page. Never decided by page names.
44
+ function topologyEntryPages(pages) {
45
+ const flagged = pages.filter((page) => page?.is_entry === true);
46
+ if (flagged.length) return flagged;
47
+ const targeted = new Set();
48
+ for (const page of pages) {
49
+ for (const field of ["expected_next_url", "expected_accept_url", "expected_decline_url"]) {
50
+ const key = canonicalHttpUrl(page?.[field]);
51
+ if (key && key !== canonicalHttpUrl(page?.url)) targeted.add(key);
52
+ }
53
+ }
54
+ const roots = pages.filter((page) => {
55
+ const key = canonicalHttpUrl(page?.url);
56
+ const type = pageType(page);
57
+ return !(key && targeted.has(key)) && !OFFER_PAGE_TYPES.has(type) && !RECEIPT_PAGE_TYPES.has(type);
58
+ });
59
+ return roots.length ? roots : pages.slice(0, 1);
60
+ }
61
+
62
+ // Every Checkout the topology's entry paths reach, in entry order, then any
63
+ // Checkout no entry path reaches (a landing CTA into checkout declares no
64
+ // forward field). "The first page typed checkout" is never the answer on its
65
+ // own: on a multi-step path it is a step, and with split-test paths there are
66
+ // several (campaigns-os#641).
67
+ export function paymentPagesForTopology(topology) {
68
+ const pages = Array.isArray(topology?.pages) ? topology.pages : [];
69
+ const out = [];
70
+ for (const entry of topologyEntryPages(pages)) {
71
+ const checkout = paymentPageFrom(topology, entry);
72
+ if (checkout && !out.includes(checkout)) out.push(checkout);
73
+ }
74
+ for (const page of pages) if (isPaymentTopologyPage(page) && !out.includes(page)) out.push(page);
75
+ return out;
76
+ }
77
+
78
+ // The Checkout of the first entry path of the first topology that has one.
79
+ export function findPaymentPage(topologies = []) {
80
+ for (const topology of Array.isArray(topologies) ? topologies : []) {
81
+ const page = paymentPagesForTopology(topology)[0];
82
+ if (page) return page;
83
+ }
84
+ return null;
85
+ }
86
+
87
+ // The pages a shopper passes through to reach `checkoutPage`, in order and
88
+ // ending with it: an optional select page, then each checkout_step, then the
89
+ // Checkout. Walks backwards over forward links, so it holds whichever path the
90
+ // Checkout sits on; where two pages lead into the same page a checkout_step is
91
+ // preferred over a select page, which can only start the chain.
92
+ export function checkoutPathPages(topology, checkoutPage) {
93
+ const pages = Array.isArray(topology?.pages) ? topology.pages : [];
94
+ if (!checkoutPage) return [];
95
+ const chain = [checkoutPage];
96
+ const seen = new Set(chain);
97
+ let current = checkoutPage;
98
+ for (;;) {
99
+ const key = canonicalHttpUrl(current?.url);
100
+ if (!key) break;
101
+ const leading = pages.filter((page) => !seen.has(page)
102
+ && PRE_PAYMENT_PAGE_TYPES.has(pageType(page))
103
+ && canonicalHttpUrl(page?.expected_next_url) === key);
104
+ const previous = leading.find(isCheckoutStepTopologyPage) || leading[0] || null;
105
+ if (!previous) break;
106
+ chain.unshift(previous);
107
+ seen.add(previous);
108
+ if (!isCheckoutStepTopologyPage(previous)) break;
109
+ current = previous;
110
+ }
111
+ return chain;
112
+ }
113
+
114
+ // Only the checkout_step pages of that path, in order.
115
+ export function checkoutStepPages(topology, checkoutPage) {
116
+ return checkoutPathPages(topology, checkoutPage).filter(isCheckoutStepTopologyPage);
117
+ }
118
+
8
119
  export function resolveTestOrderTopology(topology = {}, checkoutPage = null) {
9
120
  const pages = Array.isArray(topology?.pages) ? topology.pages : [];
10
- const checkout = checkoutPage || pages.find((page) => pageType(page) === "checkout") || null;
121
+ // A caller may hand in a select or checkout_step page; the path's Checkout is
122
+ // the page whose forward link leads to the offers. Starting from a step made
123
+ // its next step a `nonterminal_target` and hid every offer path.
124
+ const given = checkoutPage && !isPaymentTopologyPage(checkoutPage) && PRE_PAYMENT_PAGE_TYPES.has(pageType(checkoutPage))
125
+ ? paymentPageFrom(topology, checkoutPage) || checkoutPage
126
+ : checkoutPage;
127
+ const checkout = given || paymentPagesForTopology(topology)[0] || null;
11
128
  const pagesByUrl = new Map();
12
129
  for (const page of pages) {
13
130
  const key = canonicalHttpUrl(page?.url);
@@ -13,7 +13,7 @@ import { resolveCampaignIdentity, localQaIdentifier } from "./spec-source-identi
13
13
 
14
14
  import { createHash } from "node:crypto";
15
15
  import { readdirSync, readFileSync, statSync } from "node:fs";
16
- import { dirname, join, relative, resolve } from "node:path";
16
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
17
17
  import { QA_OUTPUT_REL_PATH } from "./campaign-workspace.mjs";
18
18
  import { absentOrMalformed } from "./fs-identity.mjs";
19
19
  import { isPlainObject as isObject, normalizeString as optionalString } from "./repo-scan.mjs";
@@ -53,7 +53,8 @@ export function qaVerdictDir(root, identifier) {
53
53
 
54
54
  // The verdict paths the Assembly Report's qa stage records (any `*.json`
55
55
  // string under a path/file/verdict/output-named key, or inside outputs[] /
56
- // artifacts[] / qa), relative to the report unless absolute.
56
+ // artifacts[] / qa), relative to the target root unless absolute. Older
57
+ // report-relative hints remain readable.
57
58
  export function qaVerdictPathHints(report) {
58
59
  const paths = [];
59
60
  const visit = (value) => {
@@ -90,7 +91,8 @@ function sha256File(path) {
90
91
  }
91
92
 
92
93
  // Every verdict candidate for a campaign: the files the report records
93
- // (source "assembly_report", resolved against the report) and every `*.json`
94
+ // (source "assembly_report", resolved against the report first, then the target)
95
+ // and every `*.json`
94
96
  // under `qa-output/<identifier>/` beneath each root (source "qa_output").
95
97
  // { path, repoRelPath, source, verdict, sha256, mtimeMs, identityMatch, trusted }
96
98
  // `verdict` is null for a recorded path that exists but is not a JSON object
@@ -141,8 +143,14 @@ export function* iterateQaVerdicts({ packet = null, report = null, reportPath =
141
143
  };
142
144
 
143
145
  const reportBase = reportPath ? dirname(resolve(reportPath)) : null;
146
+ const targetRoot = uniqueTargetRoot(roots, reportBase);
144
147
  for (const hint of qaVerdictPathHints(report)) {
145
- const found = candidate(reportBase ? resolve(reportBase, hint) : hint, "assembly_report", null);
148
+ const reportRelative = (isAbsolute(hint) || reportBase)
149
+ ? candidate(isAbsolute(hint) ? hint : resolve(reportBase, hint), "assembly_report", null)
150
+ : null;
151
+ const found = reportRelative || (!isAbsolute(hint) && targetRoot
152
+ ? candidate(resolve(targetRoot, hint), "assembly_report", null)
153
+ : null);
146
154
  if (found) yield found;
147
155
  }
148
156
 
@@ -172,6 +180,15 @@ export function* iterateQaVerdicts({ packet = null, report = null, reportPath =
172
180
  }
173
181
  }
174
182
 
183
+ function uniqueTargetRoot(roots, reportBase) {
184
+ for (let directory = reportBase; directory; directory = dirname(directory)) {
185
+ if (basename(directory) === ".campaign-runtime") return dirname(directory);
186
+ if (dirname(directory) === directory) break;
187
+ }
188
+ const candidates = [...new Set(roots.filter((root) => typeof root === "string" && root).map((root) => resolve(root)))];
189
+ return candidates.length === 1 ? candidates[0] : null;
190
+ }
191
+
175
192
  export function discoverQaVerdicts(options = {}) {
176
193
  return [...iterateQaVerdicts(options)];
177
194
  }
@@ -29,6 +29,7 @@ export const RUNTIME_STATE_IGNORED_PATHS = [
29
29
  ".campaign-runtime/workflow-findings.jsonl",
30
30
  ".campaign-runtime/run-records/",
31
31
  ".campaign-runtime/fetched-specs/",
32
+ ".campaign-runtime/progress/",
32
33
  ".campaign-runtime/polish-evidence/",
33
34
  ".campaign-runtime/evidence/",
34
35
  // Full QA verdicts carry live storefront URLs and order references; the
@@ -41,11 +42,12 @@ export const RUNTIME_STATE_IGNORED_PATHS = [
41
42
 
42
43
  // Entries the toolkit adds to an existing managed block when they are
43
44
  // missing from the file. The block is otherwise the operator's to edit, but
44
- // these name directories the toolkit itself fills with live URLs and order
45
- // references, so a target set up before the entry existed must gain it on the
46
- // next setup command rather than on the next accidental commit. An entry an
47
- // operator has placed anywhere else in the file counts as present.
48
- export const RUNTIME_STATE_REQUIRED_IGNORED_PATHS = ["qa-output/"];
45
+ // these name directories the toolkit itself fills with campaign observations,
46
+ // live URLs, or order references. A target set up before the entry existed
47
+ // must gain it on the next setup command rather than on the next accidental
48
+ // commit. An entry an operator has placed anywhere else in the file counts as
49
+ // present.
50
+ export const RUNTIME_STATE_REQUIRED_IGNORED_PATHS = ["qa-output/", ".campaign-runtime/progress/"];
49
51
 
50
52
  export const RUNTIME_STATE_IGNORE_BLOCK = [
51
53
  RUNTIME_STATE_IGNORE_MARKER,
@@ -4,7 +4,7 @@ import {
4
4
  } from "./source-html-manifest.mjs";
5
5
  // Built output of campaign-spec (same import shape as src/page-kit-sdk-version.mjs),
6
6
  // so build-time wiring and spec-time analysis share one edge resolver.
7
- import { declineRouteTarget, forwardRouteTarget } from "../campaign-spec/dist/index.js";
7
+ import { checkoutPathFrom, declineRouteTarget, forwardRouteTarget, forwardTargetPage, isCheckoutStepPage } from "../campaign-spec/dist/index.js";
8
8
  import { isAbsoluteHttpUrl, normalizePageKitRoute, normalizePublicRouteSlug, stripPublicRoutePrefix } from "./route-identity.mjs";
9
9
 
10
10
  const CPK_PAGE_TYPES = new Set(["product", "checkout", "upsell", "receipt"]);
@@ -30,7 +30,7 @@ export function createSourceHtmlIntake({
30
30
  const mappings = matched.mappings.map((mapping) => {
31
31
  const specPage = pageById.get(mapping.page_id);
32
32
  if (!specPage || !mapping.path) return mapping;
33
- const pageKit = pageKitProjectionForPage(specPage, { pageById, publicRouteSlug, outputDir });
33
+ const pageKit = pageKitProjectionForPage(specPage, { pageById, publicRouteSlug, outputDir, specPages: specPages || [] });
34
34
  projectionDecisions.push({
35
35
  id: `dec_page_kit_target_${specPage.id}`,
36
36
  stage: "prepare_build",
@@ -559,7 +559,7 @@ function addSpecHints(mapping, page) {
559
559
  if (variantLabels) mapping.variant_labels = variantLabels;
560
560
  }
561
561
 
562
- function pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir }) {
562
+ function pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir, specPages = [] }) {
563
563
  const specRoute = publicRouteForPage(page);
564
564
  const relativeRoute = stripPublicRoutePrefix(specRoute, publicRouteSlug);
565
565
  const publicRoute = rootedCampaignRoute(relativeRoute, publicRouteSlug);
@@ -574,6 +574,7 @@ function pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir }
574
574
  if (nextUrl) frontmatter.next_url = nextUrl;
575
575
  const declineUrl = declineUrlForPage(page, pageById, publicRouteSlug);
576
576
  if (declineUrl && declineUrl !== nextUrl) frontmatter.decline_url = declineUrl;
577
+ Object.assign(frontmatter, checkoutFlowFrontmatter(page, { specPages, pageById, publicRouteSlug }));
577
578
 
578
579
  return {
579
580
  target_path: targetPath,
@@ -586,6 +587,43 @@ function pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir }
586
587
  };
587
588
  }
588
589
 
590
+ // Multi-step checkout wiring (campaigns-os#641), for any page that leads into a
591
+ // Checkout without being it: a select page, a checkout_step, or a landing page
592
+ // whose forward links reach a Checkout.
593
+ // success_url the Checkout's post-payment destination (its own next_url).
594
+ // The page's next-success-url meta tag carries it, because the
595
+ // SDK reads that tag for express orders placed on this page; an
596
+ // express order on step 1 must land on the first upsell, not on
597
+ // step 2. Forward navigation never uses that tag: a step form
598
+ // navigates by data-next-checkout-step, a select page by a link.
599
+ // step_number on a checkout_step only: its position among the steps of its
600
+ // path, from 1, for the form's data-next-step-number.
601
+ function checkoutFlowFrontmatter(page, { specPages, pageById, publicRouteSlug }) {
602
+ if (!page || page.type === "checkout") return {};
603
+ const path = checkoutPathFrom(specPages, page);
604
+ if (!path || path.length < 2) return {};
605
+ const out = {};
606
+ const successUrl = nextUrlForPage(path[path.length - 1], pageById, publicRouteSlug);
607
+ if (successUrl) out.success_url = successUrl;
608
+ if (isCheckoutStepPage(page)) out.step_number = checkoutStepNumber(page, specPages);
609
+ return out;
610
+ }
611
+
612
+ function checkoutStepNumber(page, specPages) {
613
+ let number = 1;
614
+ const seen = new Set([page]);
615
+ let current = page;
616
+ for (;;) {
617
+ const previous = specPages.find((candidate) => !seen.has(candidate)
618
+ && isCheckoutStepPage(candidate)
619
+ && forwardTargetPage(specPages, candidate) === current);
620
+ if (!previous) return number;
621
+ number += 1;
622
+ seen.add(previous);
623
+ current = previous;
624
+ }
625
+ }
626
+
589
627
  function pageKitTargetPrompts(mappings) {
590
628
  const prompts = [];
591
629
  const byOutputPath = new Map();
@@ -664,12 +702,15 @@ function cpkPageTypeForSpecType(type) {
664
702
  if (type === "presell" || type === "landing") return "product";
665
703
  if (type === "thankyou" || type === "receipt") return "receipt";
666
704
  if (type === "upsell" || type === "downsell") return "upsell";
667
- if (type === "checkout" || type === "select") return "checkout";
705
+ // select, checkout_step and checkout all render the SDK checkout surface
706
+ // (campaigns-os#641); only the Checkout places the order.
707
+ if (type === "checkout" || type === "checkout_step" || type === "select") return "checkout";
668
708
  return CPK_PAGE_TYPES.has(type) ? type : "product";
669
709
  }
670
710
 
671
711
  function defaultRouteForSpecType(type) {
672
712
  if (type === "thankyou" || type === "receipt") return "receipt/";
713
+ if (type === "checkout_step") return "checkout-step/";
673
714
  if (["presell", "landing", "checkout", "upsell", "downsell"].includes(type)) return `${type}/`;
674
715
  return `${type || "page"}/`;
675
716
  }
@@ -696,6 +737,7 @@ function pageMatchKeys(page, ordinal) {
696
737
  }
697
738
  if (page.type === "landing" || page.type === "presell") keys.add("index");
698
739
  if (page.type === "checkout") keys.add("checkout");
740
+ if (page.type === "checkout_step") keys.add("checkout-step");
699
741
  if (page.type === "upsell") keys.add("upsell");
700
742
  if (page.type === "downsell") keys.add("downsell");
701
743
  return [...keys];
@@ -5,6 +5,7 @@ import { STATUS as QA_STATUS } from "./qa-verdict.mjs";
5
5
  import { assessInputCurrency, currentPacketInputs, effectiveStageStatus, effectiveStatusIsTerminal, wellFormedBriefMaterial } from "./input-currency.mjs";
6
6
  import { isPlainObject, normalizeString as optionalString } from "./repo-scan.mjs";
7
7
  import { withTargetLockSync } from "./target-lock.mjs";
8
+ import { portableArtifactPaths } from "./cli-helpers.mjs";
8
9
  import {
9
10
  ASSEMBLY_REPORT_STAGE_KEYS,
10
11
  NEXT_STAGE_CONTRACTS,
@@ -687,7 +688,7 @@ function commitAssemblyReportUnderLock(workspace, mutate, {
687
688
  const finish = () => {
688
689
  if (hasRefresh) {
689
690
  const doctor = refreshDoctor(outcome);
690
- if (doctor !== null && doctor !== undefined) writeDoctorSidecar(doctorOutPath, doctor, { command: command.trim() });
691
+ if (doctor !== null && doctor !== undefined) writeDoctorSidecar(doctorOutPath, doctor, { command: command.trim(), targetRepo });
691
692
  } else if (outcome.written) {
692
693
  markDoctorSidecarStale(targetRepo, { command: command.trim(), reason: staleReason.trim() });
693
694
  }
@@ -731,7 +732,8 @@ function commitAssemblyReportUnderLock(workspace, mutate, {
731
732
  // (every mutator in this repo returns a copy), so the restatement is in
732
733
  // place rather than a second deep clone. Stages are read at their effective
733
734
  // status against the inputs the workspace's packet names now.
734
- const next = applyDerivedAssemblyReportSummary(mutated, workspaceInputs(workspace));
735
+ const nextReport = applyDerivedAssemblyReportSummary(mutated, workspaceInputs(workspace));
736
+ const next = targetRepo ? portableArtifactPaths(nextReport, targetRepo, { artifactPath: reportPath }) : nextReport;
735
737
  if (stage && producerStageOutcomeUnchanged(report, next, stage)) {
736
738
  outcome.skipped = "unchanged";
737
739
  return finish();
@@ -29,7 +29,7 @@ import { BRAND_LAYER_FILENAMES } from "./brand-theme.mjs";
29
29
  import { ADAPTER_DECISION_SCALAR_VALUES } from "./adapter-decision-contract.mjs";
30
30
  import { computeBuildFingerprint, resolveBuiltSiteScope } from "./built-site-scope.mjs";
31
31
  import { resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
32
- import { isObject, optionalString, readJsonIfExists, requireArg } from "./cli-helpers.mjs";
32
+ import { isObject, optionalString, portableArtifactPaths, readJsonIfExists, requireArg } from "./cli-helpers.mjs";
33
33
  import { LOCAL_PROOF_BUILD_ENVIRONMENT, LOCAL_PROOF_PRODUCTION_ENVIRONMENT, isLocalServePacket } from "./local-proof.mjs";
34
34
  import { polishCarriedForwardForLadder } from "./local-preview-policy.mjs";
35
35
  import { isLoopbackHostname } from "./remit.mjs";
@@ -1017,8 +1017,8 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
1017
1017
  if (dryRun) return null;
1018
1018
  // Written inside the report's critical section, after every check and
1019
1019
  // before the report itself, so the files move together.
1020
- if (next.context) writeJsonAtomic(contextPath, next.context);
1021
- if (next.packet) writeJsonAtomic(packetPath, next.packet);
1020
+ if (next.context) writeJsonAtomic(contextPath, portableArtifactPaths(next.context, workspace.targetRepo, { artifactPath: contextPath }));
1021
+ if (next.packet) writeJsonAtomic(packetPath, portableArtifactPaths(next.packet, workspace.targetRepo, { artifactPath: packetPath }));
1022
1022
  return next.report;
1023
1023
  };
1024
1024
  // Already inside the target lock, which commitAssemblyReport re-enters.