@nextcommerce/campaigns-os 1.43.2 → 1.47.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 (80) hide show
  1. package/AGENTS.md +5 -0
  2. package/CHANGELOG.md +798 -5103
  3. package/README.md +33 -12
  4. package/agents/claude/CLAUDE.md +1 -1
  5. package/agents/codex/AGENTS.md +1 -1
  6. package/agents/copilot/copilot-instructions.md +1 -1
  7. package/agents/cursor/campaigns-os.mdc +1 -1
  8. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +2 -2
  9. package/campaign-spec/dist/rules/analytics-contract-shape.js +2 -2
  10. package/campaign-spec/dist/rules/store-profile-shape.d.ts +5 -1
  11. package/campaign-spec/dist/rules/store-profile-shape.js +8 -10
  12. package/campaign-spec/dist/types.d.ts +2 -2
  13. package/compatibility.json +1 -1
  14. package/contracts/archive/CHANGELOG.2026-09-30.md +5111 -0
  15. package/contracts/archive/release-ledger.2026-09-30.json +5068 -0
  16. package/contracts/commerce-surface-catalog.json +1204 -129
  17. package/contracts/effects.v1.json +1179 -116
  18. package/contracts/orientation-reason-codes.v1.json +7 -0
  19. package/contracts/release-ledger.json +2515 -5919
  20. package/contracts/supported-surface.json +7 -4
  21. package/contracts/template-brand-contract.shared-commerce.v0.json +3 -3
  22. package/contracts/template-slot-manifest.shared-content-core.v0.json +403 -0
  23. package/docs/brand-theme-bridge.md +81 -0
  24. package/docs/build-packet.md +180 -23
  25. package/docs/campaigns-os-build-flow.md +3 -3
  26. package/docs/design-source-package.md +73 -0
  27. package/docs/effects.md +50 -8
  28. package/docs/gateway-login.md +3 -0
  29. package/docs/local-setup.md +7 -4
  30. package/docs/orientation-contract-reference.md +42 -2
  31. package/docs/polish-evidence.md +74 -0
  32. package/docs/qa-and-test-orders.md +118 -14
  33. package/docs/release-ledger-authoring-guide.md +64 -4
  34. package/docs/runtime-readiness.md +1 -1
  35. package/docs/sdk-storage-compatibility.md +1 -1
  36. package/docs/skills-revision.md +10 -10
  37. package/docs/supported-surface.md +2 -2
  38. package/docs/versioning.md +4 -1
  39. package/package.json +1 -1
  40. package/schemas/campaigns-os-release-ledger.v1.schema.json +32 -2
  41. package/schemas/campaigns-os-tooling-orientation.v1.schema.json +1 -0
  42. package/skills/campaign-lifecycle-orientation/SKILL.md +16 -5
  43. package/skills/campaign-readback-classification/SKILL.md +3 -3
  44. package/skills/campaign-run-evidence/SKILL.md +7 -6
  45. package/skills/contribution-intake/SKILL.md +3 -3
  46. package/skills/next-campaigns-build/SKILL.md +7 -6
  47. package/skills/next-campaigns-os/SKILL.md +7 -7
  48. package/skills/next-campaigns-os/references/session-intake.md +9 -3
  49. package/skills/next-campaigns-os-setup/SKILL.md +5 -5
  50. package/skills/next-campaigns-polish/SKILL.md +28 -9
  51. package/skills/next-campaigns-qa/SKILL.md +7 -4
  52. package/skills.json +10 -10
  53. package/src/brand-theme.mjs +320 -20
  54. package/src/built-script-syntax.mjs +116 -15
  55. package/src/built-site-scope.mjs +16 -4
  56. package/src/cli.mjs +280 -46
  57. package/src/commercial-parity.mjs +48 -2
  58. package/src/deviation.mjs +13 -1
  59. package/src/diagnostic.mjs +6 -2
  60. package/src/doctor/checks.mjs +319 -81
  61. package/src/doctor/inspect.mjs +55 -13
  62. package/src/doctor/source-provenance.mjs +184 -0
  63. package/src/invocation.mjs +4 -0
  64. package/src/live-campaign-refs.mjs +466 -0
  65. package/src/login.mjs +2 -2
  66. package/src/page-kit-store-profile.mjs +69 -12
  67. package/src/page-kit-sync.mjs +31 -12
  68. package/src/progress-node.mjs +3 -1
  69. package/src/qa-analytics-parity.mjs +37 -2
  70. package/src/qa-binding-evidence.mjs +4 -2
  71. package/src/qa-browser.mjs +612 -40
  72. package/src/qa-commercial-parity.mjs +48 -5
  73. package/src/qa-node.mjs +122 -7
  74. package/src/qa-test-order-topology.mjs +148 -0
  75. package/src/sdk-markup.mjs +32 -7
  76. package/src/sdk-storage-compatibility.mjs +3 -2
  77. package/src/source-html-intake.mjs +116 -0
  78. package/src/stage-record.mjs +551 -0
  79. package/src/tooling-setup.mjs +9 -0
  80. package/src/upsell-selector-scope.mjs +112 -2
@@ -11,7 +11,10 @@ import {
11
11
  CommercialParityLimitError,
12
12
  createCommercialParityReport,
13
13
  extractCommercialClaims,
14
+ recurringClaimAbsences,
15
+ recurringClaimAbsentAssertions,
14
16
  } from "./commercial-parity.mjs";
17
+ import { extractRenderedPackageRefs } from "./live-campaign-refs.mjs";
15
18
 
16
19
  export const COMMERCIAL_QA_LIMITS = Object.freeze({
17
20
  max_html_bytes: 2 * 1024 * 1024,
@@ -171,10 +174,15 @@ function captureFailure(page, code, error) {
171
174
 
172
175
  export function captureCommercialClaims(page, html) {
173
176
  try {
174
- return extractCommercialClaims(html, {
175
- pageId: present(page?.page_id) ? String(page.page_id) : null,
176
- url: page?.url,
177
- });
177
+ return {
178
+ ...extractCommercialClaims(html, {
179
+ pageId: present(page?.page_id) ? String(page.page_id) : null,
180
+ url: page?.url,
181
+ }),
182
+ // Every package the page renders, by the refs doctor reads, so a
183
+ // subscription whose rebill copy yielded no claim is still seen (#533).
184
+ rendered_package_refs: [...extractRenderedPackageRefs(html)],
185
+ };
178
186
  } catch (error) {
179
187
  const code = error instanceof CommercialParityLimitError
180
188
  ? error.code
@@ -398,6 +406,29 @@ export function planCommercialParity(spec, { maxScenarios = COMMERCIAL_QA_LIMITS
398
406
  };
399
407
  }
400
408
 
409
+ // A package recurs when the spec says so, or carries a recurring price and a
410
+ // cadence. Page rows override the campaign catalog per ref, as the journey does.
411
+ function isSubscriptionPackage(pkg) {
412
+ return pkg?.is_recurring === true
413
+ || (present(pkg?.price_recurring) && (present(pkg?.interval_count) || present(pkg?.interval)));
414
+ }
415
+
416
+ export function subscriptionPackageRefsByPage(spec, pages) {
417
+ const packageRef = (pkg) => (present(pkg?.ref_id) ? String(pkg.ref_id) : present(pkg?.package_id) ? String(pkg.package_id) : null);
418
+ const catalog = new Map(array(spec?.packages).map((pkg) => [packageRef(pkg), pkg]).filter(([ref]) => ref));
419
+ const byPage = new Map();
420
+ array(pages).forEach((page) => {
421
+ if (!present(page?.id)) return;
422
+ const refs = new Set();
423
+ array(page.packages).forEach((pkg) => {
424
+ const ref = packageRef(pkg);
425
+ if (ref && isSubscriptionPackage({ ...(catalog.get(ref) || {}), ...pkg })) refs.add(ref);
426
+ });
427
+ if (refs.size) byPage.set(String(page.id), refs);
428
+ });
429
+ return byPage;
430
+ }
431
+
401
432
  function countBy(values, keyFor) {
402
433
  const counts = {};
403
434
  values.forEach((value) => {
@@ -473,6 +504,7 @@ function compactReport(report, journey, plan, executed, issues, observedClaims,
473
504
  unresolved_voucher_claims: report.unresolved_voucher_claims,
474
505
  serialized_assertion_count: report.serialized_assertion_count,
475
506
  omitted_assertion_count: report.omitted_assertion_count,
507
+ recurring_claim_absences: array(report.recurring_claim_absences),
476
508
  observed_claims: observedClaims,
477
509
  claim_limit: claimLimit,
478
510
  finding_count: report.findings.length,
@@ -539,6 +571,7 @@ export function unavailableCommercialReport(code, { status = "not_run" } = {}) {
539
571
  unresolved_voucher_claims: 0,
540
572
  serialized_assertion_count: 0,
541
573
  omitted_assertion_count: 0,
574
+ recurring_claim_absences: [],
542
575
  observed_claims: 0,
543
576
  claim_limit: COMMERCIAL_QA_LIMITS.max_aggregate_claims,
544
577
  finding_count: 0,
@@ -625,8 +658,18 @@ export async function runCommercialParity({
625
658
  maxAssertions,
626
659
  countsOnly: aggregateClaimOverflow,
627
660
  });
661
+ // Subscription packages a page renders with no recurring claim read for
662
+ // them: incomplete evidence (an issue), and a warning naming the package.
663
+ const absences = aggregateClaimOverflow
664
+ ? []
665
+ : recurringClaimAbsences(captures, subscriptionPackageRefsByPage(planningSpec, commercialPlanning.pages));
666
+ absences.forEach(() => issues.push({ code: "recurring_claim_absent" }));
667
+ const absentAssertions = recurringClaimAbsentAssertions(absences, {
668
+ maxAssertions: Math.max(0, maxAssertions - parity.assertions.length),
669
+ });
670
+ parity.recurring_claim_absences = absences;
628
671
  return {
629
- assertions: parity.assertions,
672
+ assertions: [...parity.assertions, ...absentAssertions],
630
673
  commercial: compactReport(
631
674
  parity,
632
675
  parityJourney,
package/src/qa-node.mjs CHANGED
@@ -30,7 +30,7 @@ const PACKAGE_ROOT = installModeResolve(installModeDirname(installModeFileUrl(im
30
30
  function cmd(verb, rest = "") {
31
31
  return `${invocationPrefixFor(PACKAGE_ROOT)} ${verb}${rest ? ` ${rest}` : ""}`;
32
32
  }
33
- import { isSameAnalyticsCapturePage, runAnalyticsCorrectnessChecks, runAnalyticsParityChecks, runBrowserChecks, runBrowserTestOrders, testEmail, validatedOrderCreationLimit } from "./qa-browser.mjs";
33
+ import { isSameAnalyticsCapturePage, runAnalyticsCorrectnessChecks, runAnalyticsParityChecks, runBrowserChecks, runBrowserTestOrders, testEmail, upsellActionCoverageWithoutOrders, validatedOrderCreationLimit } from "./qa-browser.mjs";
34
34
  import { assessReceiptPurchase } from "./qa-analytics-correctness.mjs";
35
35
  import { createVerdict, isFindingAssertion, QA_ASSERTION_FAMILY_VOCABULARY, SESSION_ENDING_DISPOSITIONS, SEVERITY, STATUS, validateVerdict } from "./qa-verdict.mjs";
36
36
  import { normalizeSdkMetaName, lookupSdkIgnoredMetaTag } from "./sdk-meta-tags.mjs";
@@ -98,6 +98,18 @@ import {
98
98
  unavailableCommercialCapture,
99
99
  unavailableCommercialReport,
100
100
  } from "./qa-commercial-parity.mjs";
101
+ import {
102
+ LIVE_REF_CODES,
103
+ campaignDriftMessage,
104
+ disabledLiveRead,
105
+ evaluateLiveCampaignRefs,
106
+ extractRenderedRefs,
107
+ liveRefsDisabled,
108
+ liveRefFindingMessage,
109
+ liveRefsNotRunMessage,
110
+ readLiveCampaignForPacket,
111
+ } from "./live-campaign-refs.mjs";
112
+ import { specPackageRefs, specShippingRefs } from "./doctor/checks.mjs";
101
113
 
102
114
  // The producing runtime identity on every verdict. Read from package.json so
103
115
  // a verdict names the release that made it; a literal here outlived three
@@ -110,7 +122,7 @@ const HELP = `campaigns-os qa — Node/npm spec-aware QA
110
122
  Usage:
111
123
  campaigns-os qa parity --fixture <parity-fixture.json> --scenario <scenario-id> [--base-url <override>] [--baseline <url>] [--parity-order-json <file>] [--no-post-verdict]
112
124
  campaigns-os qa resolve --packet <campaign-runtime.build.json> [--base-url <url>] [--no-probe] [--probe-timeout-ms <ms>] [--json]
113
- campaigns-os qa run --packet <campaign-runtime.build.json> [--base-url <url>] [--output-dir <dir>] [--no-remit] [--json]
125
+ campaigns-os qa run --packet <campaign-runtime.build.json> [--base-url <url>] [--output-dir <dir>] [--no-remit] [--no-live-refs] [--json]
114
126
  campaigns-os qa policy set --packet <campaign-runtime.build.json> [--allowed-domains-confirmed true|false] [--deploy-target <target>] [--preview-url <url>] [--production-url <url>] [--order-path-depth <off|common|full>] [--json]
115
127
  campaigns-os qa waive --packet <campaign-runtime.build.json> --assertion analytics-correctness:purchase-fires --reason "<why>" [--waived-by <who>] [--report <assembly-report.json>] [--json]
116
128
  campaigns-os qa promote --packet <campaign-runtime.build.json> --verdict <full-verdict.json> [--json] # project one explicit qa-output verdict to the committed .campaign-runtime/qa-verdict.json sidecar
@@ -130,7 +142,7 @@ Options:
130
142
  Requires --base-url and --family. No Map ID / CampaignSpec needed.
131
143
  --spec <path> Local exported CampaignSpec JSON for the non-packet Map ID flow.
132
144
  Packet QA always uses packet.spec.local_path and rejects this override.
133
- --proxy-base <url> Campaign Map proxy base for /api/spec, /api/price-preview, and verdict publishing.
145
+ --proxy-base <url> Campaign Map proxy base for /api/spec, /api/price-preview, /api/campaign, and verdict publishing.
134
146
  --base-url <url> Deployed campaign root. Packet deploy URL is used when omitted.
135
147
  Commercial pages are checked automatically against /api/price-preview;
136
148
  no commercial sidecar or extra catalog flag is required.
@@ -166,6 +178,9 @@ Options:
166
178
  Record is not stamped; a refusal still exits 2, a clean dry run exits 0
167
179
  (--json: dry_run, would_publish, would_post).
168
180
  --no-remit When an ambient run session is active, write the local Run Record but skip Run Telemetry remit.
181
+ --no-live-refs qa run: skip the one read-only GET of <proxy-base>/api/campaign that checks each
182
+ served page's shipping and package refs against the live campaign; the verdict
183
+ records the check not_run with reason disabled, never a pass.
169
184
  --auth-cookie <cookie> Cookie header for protected previews.
170
185
  --browser Run Playwright-rendered browser checks after static Node checks.
171
186
  Requires one-time setup: campaigns-os qa install-browser
@@ -178,8 +193,11 @@ Options:
178
193
  Create Playwright typed-card test orders through the tested checkout page.
179
194
  Test cards bypass the gateway and create no transactions, so no permission
180
195
  flags or packet policy are needed — just pick a mode. Default mode (bare
181
- --test-order, or "common") runs checkout, first-offer accept/decline, and a
182
- deduplicated shortest real receipt path when needed (at most 4 orders). "full" walks
196
+ --test-order, or "common") runs every actual terminal path when they fit under
197
+ the cap (--max-test-orders, default 6). Above the cap it runs checkout,
198
+ first-offer accept/decline, and a deduplicated shortest real receipt path, then
199
+ adds one decline path per offer/downsell page not yet declined, up to the cap,
200
+ and names any page left out. "full" walks
183
201
  every actual terminal path; cycles, missing routes, and reachable nonterminals
184
202
  block before browser launch. The default cap is 6; overflow names the exact raise.
185
203
  "tiers" is spec-driven: one strict-selection order per selector tier the
@@ -2165,7 +2183,7 @@ async function runQa(args, options = {}) {
2165
2183
  // `runSessionActive` is threaded in from the CLI's single ambient-session read
2166
2184
  // rather than re-discovered here, so the closeout command this run prints and
2167
2185
  // the run_id the session will close under come from the same observation.
2168
- async function runResolvedQa(args, resolved, { runSessionActive = false } = {}) {
2186
+ async function runResolvedQa(args, resolved, { runSessionActive = false, liveCampaign = undefined, liveCampaignFetch = globalThis.fetch } = {}) {
2169
2187
  const startedAt = new Date().toISOString();
2170
2188
  const runId = generateRunId();
2171
2189
  const gate = resolved.themeGate;
@@ -2258,13 +2276,33 @@ async function runResolvedQa(args, resolved, { runSessionActive = false } = {})
2258
2276
  const pages = resolved.topologies.flatMap(topology => topology.pages);
2259
2277
  const pageResults = await mapConcurrent(pages, COMMERCIAL_QA_LIMITS.concurrency, page =>
2260
2278
  runPageChecks(page, args, { sourceLoader, bindingExpected, bindingScriptLoader, captureCommercial: commercialIds.has(String(page.page_id)) }));
2279
+ const livePages = new Map();
2261
2280
  for (const [index, page] of pages.entries()) {
2262
2281
  const pageResult = pageResults[index];
2263
2282
  assertions.push(...pageResult.assertions);
2283
+ if (pageResult.renderedRefs && !livePages.has(String(page.page_id))) {
2284
+ livePages.set(String(page.page_id), { ...page, page_id: String(page.page_id), ...pageResult.renderedRefs });
2285
+ }
2264
2286
  if (commercialIds.has(String(page.page_id)) && pageResult.commercialCapture && !capturesByPageId.has(String(page.page_id))) {
2265
2287
  capturesByPageId.set(String(page.page_id), pageResult.commercialCapture);
2266
2288
  }
2267
2289
  }
2290
+ // The live campaign read (#533): one GET of {proxy-base}/api/campaign under
2291
+ // the public campaign key, made only when a served page was read to compare;
2292
+ // --no-live-refs sends nothing and records the check not_run (`disabled`).
2293
+ const liveSpec = resolved.rawSpec || resolved.spec;
2294
+ const liveRead = liveRefsDisabled(args)
2295
+ ? disabledLiveRead()
2296
+ : liveCampaign !== undefined || livePages.size === 0
2297
+ ? liveCampaign
2298
+ : await readLiveCampaignForPacket({
2299
+ packet: resolved.packet,
2300
+ packetPath: resolved.packetPath || null,
2301
+ spec: liveSpec,
2302
+ fetchImpl: liveCampaignFetch,
2303
+ proxyBase: resolved.proxyBase,
2304
+ });
2305
+ assertions.push(...liveCampaignRefAssertions({ pages: [...livePages.values()], spec: liveSpec, liveCampaign: liveRead }));
2268
2306
  if (args.browser === true) {
2269
2307
  assertions.push(...await runBrowserChecks(resolved.topologies, args, {
2270
2308
  brandContract: resolved.brandContract,
@@ -2884,7 +2922,77 @@ async function runPageChecks(page, args, {
2884
2922
  }));
2885
2923
  }
2886
2924
 
2887
- return { assertions, commercialCapture };
2925
+ const rendered = extractRenderedRefs(html);
2926
+ return {
2927
+ assertions,
2928
+ commercialCapture,
2929
+ renderedRefs: { package_refs: [...rendered.package_refs], shipping_refs: [...rendered.shipping_refs] },
2930
+ };
2931
+ }
2932
+
2933
+ // The live campaign ref check (#533) as QA assertions: the comparison doctor
2934
+ // runs over built pages, run here over the served pages this attempt read,
2935
+ // under the same codes. `liveCampaign` is a readLiveCampaign result the
2936
+ // caller made; absent or failed, the check is not_run with its reason.
2937
+ function liveCampaignRefAssertions({ pages, spec, liveCampaign }) {
2938
+ const campaignAssertion = (fields) => assertion({ family: "api-metadata", page: { page_id: "campaign" }, ...fields });
2939
+ if (!pages.length && liveCampaign?.reason_code !== "disabled") {
2940
+ return [campaignAssertion({
2941
+ id: "live-campaign-refs",
2942
+ status: STATUS.SKIPPED,
2943
+ expected: "every served page's shipping and package refs are served by the live campaign",
2944
+ actual: "not_run",
2945
+ evidence: { code: LIVE_REF_CODES.notRun, reason_code: "no_pages", reason: "No served page source was read to compare against the live campaign." },
2946
+ })];
2947
+ }
2948
+ const result = evaluateLiveCampaignRefs({
2949
+ pages,
2950
+ map: { package_refs: specPackageRefs(spec), shipping_refs: specShippingRefs(spec) },
2951
+ live: liveCampaign,
2952
+ });
2953
+ const keySource = liveCampaign?.key_source ? { key_source: liveCampaign.key_source } : {};
2954
+ if (result.status === "not_run") {
2955
+ // --no-live-refs: the verdict says how many served pages went unchecked.
2956
+ const eligible = result.reason_code === "disabled" ? { pages_eligible: result.checked_pages } : {};
2957
+ return [campaignAssertion({
2958
+ id: result.attempted ? LIVE_REF_CODES.notRun : "live-campaign-refs",
2959
+ status: result.attempted ? STATUS.WARN : STATUS.SKIPPED,
2960
+ ...(result.attempted ? { severity: SEVERITY.WARN } : {}),
2961
+ expected: "every served page's shipping and package refs are served by the live campaign",
2962
+ actual: "not_run",
2963
+ evidence: { code: LIVE_REF_CODES.notRun, reason_code: result.reason_code, reason: result.attempted ? liveRefsNotRunMessage(result) : result.reason, ...eligible, ...keySource },
2964
+ })];
2965
+ }
2966
+ const out = result.page_findings.map((finding) => assertion({
2967
+ id: `${finding.code}:${finding.page_id}`,
2968
+ family: "api-metadata",
2969
+ page: pages.find((page) => String(page.page_id) === finding.page_id) || { page_id: finding.page_id },
2970
+ status: STATUS.FAIL,
2971
+ severity: SEVERITY.BLOCKER,
2972
+ expected: `every rendered ${finding.kind} ref is served by the live campaign`,
2973
+ actual: finding.refs.join(", "),
2974
+ evidence: { code: finding.code, refs: finding.refs, message: liveRefFindingMessage(finding) },
2975
+ }));
2976
+ if (result.drift) {
2977
+ out.push(campaignAssertion({
2978
+ id: LIVE_REF_CODES.drift,
2979
+ status: STATUS.WARN,
2980
+ severity: SEVERITY.WARN,
2981
+ expected: "the CampaignSpec lists the refs the live campaign serves",
2982
+ actual: "drift",
2983
+ evidence: { code: LIVE_REF_CODES.drift, drift: result.drift, message: campaignDriftMessage(result.drift) },
2984
+ }));
2985
+ }
2986
+ if (result.status === "pass") {
2987
+ out.push(campaignAssertion({
2988
+ id: "live-campaign-refs",
2989
+ status: STATUS.PASS,
2990
+ expected: "every served page's shipping and package refs are served by the live campaign",
2991
+ actual: `${result.checked_pages} page(s) checked`,
2992
+ evidence: { ...keySource },
2993
+ }));
2994
+ }
2995
+ return out;
2888
2996
  }
2889
2997
 
2890
2998
  async function maybeRunTestOrders(
@@ -2899,6 +3007,12 @@ async function maybeRunTestOrders(
2899
3007
  const mode = String(args["test-order"] || "off").toLowerCase();
2900
3008
  const legacyMode = String(args["legacy-api-test-order"] || "off").toLowerCase();
2901
3009
  const emptyReceiptAnalytics = () => ({ plannedPlanIds: [], attempts: [] });
3010
+ if (!mode || mode === "off") {
3011
+ // No browser order clicks an upsell control, legacy API orders included,
3012
+ // so the coverage row says so instead of going missing (#530).
3013
+ const coverage = upsellActionCoverageWithoutOrders(resolved.topologies);
3014
+ if (coverage) assertions.push(coverage);
3015
+ }
2902
3016
  if ((!mode || mode === "off") && (!legacyMode || legacyMode === "off")) {
2903
3017
  return { orders: [], receiptAnalytics: emptyReceiptAnalytics() };
2904
3018
  }
@@ -3888,6 +4002,7 @@ export const __qaNodeTestHooks = Object.freeze({
3888
4002
  isRoutingMetaTag,
3889
4003
  unsupportedSdkMetaHint,
3890
4004
  reportCommercialRunnerError,
4005
+ liveCampaignRefAssertions,
3891
4006
  browserSkippedByGate,
3892
4007
  reportBrowserSkippedByGate,
3893
4008
  gateClearingHint,
@@ -30,6 +30,19 @@ export function resolveTestOrderTopology(topology = {}, checkoutPage = null) {
30
30
  ...(entry?.kind === "terminal" ? [entry.terminal] : []),
31
31
  ...terminalPaths.map((candidate) => candidate.terminal),
32
32
  ]);
33
+ // Every declared offer page with the offer page each action leads to, so a
34
+ // planned path that stops short of a terminal can still be walked to the
35
+ // controls it clicks.
36
+ const offerEdges = [];
37
+ for (const [key, page] of pagesByUrl) {
38
+ if (!OFFER_PAGE_TYPES.has(pageType(page))) continue;
39
+ const edge = { key, page_id: page.page_id || null, page_type: page.page_type || null, url: page.url };
40
+ for (const [action, field] of ACTIONS) {
41
+ const target = targetNode(page?.[field], pagesByUrl, topologyOrigin);
42
+ edge[`${action}_key`] = target?.kind === "offer" ? canonicalHttpUrl(target.page.url) : null;
43
+ }
44
+ offerEdges.push(edge);
45
+ }
33
46
 
34
47
  return {
35
48
  topology_id: topology?.funnel_id || "default",
@@ -48,6 +61,8 @@ export function resolveTestOrderTopology(topology = {}, checkoutPage = null) {
48
61
  terminal_paths: terminalPaths,
49
62
  invalid_paths: invalidPaths,
50
63
  recognized_terminals: recognizedTerminals,
64
+ entry_offer_key: entry?.kind === "offer" ? canonicalHttpUrl(entry.page.url) : null,
65
+ offer_edges: offerEdges,
51
66
  };
52
67
  }
53
68
 
@@ -95,6 +110,139 @@ export function commonTestOrderPaths(resolvedTopology) {
95
110
  return paths;
96
111
  }
97
112
 
113
+ // The default `common` depth (#530). The sample above never reached a
114
+ // downsell's decline, so a broken decline link passed QA. When every actual
115
+ // terminal path fits under the cap, `common` runs them all. Above the cap it
116
+ // keeps the sample and adds, for each offer page whose decline no planned path
117
+ // clicks yet, the shortest path that clicks it, until the cap is reached. A
118
+ // page counts as covered only when its decline is clicked: arriving at it, or
119
+ // clicking its accept, does not. Pages still uncovered are returned, not
120
+ // dropped. The sample is never trimmed, so a cap below it is still refused by
121
+ // the flood guard exactly as before.
122
+ export function commonTestOrderPlan(resolvedTopology, { cap } = {}) {
123
+ const baseline = commonTestOrderPaths(resolvedTopology);
124
+ let full = null;
125
+ try {
126
+ full = fullTestOrderPaths(resolvedTopology);
127
+ } catch {
128
+ full = null;
129
+ }
130
+ const plan = {
131
+ requested_depth: "common",
132
+ cap,
133
+ full_path_count: full ? full.length : null,
134
+ baseline_paths: baseline,
135
+ };
136
+ if (full && full.length <= cap) {
137
+ // Same set as `full`; the sample's paths keep their place at the front.
138
+ const paths = [...baseline.filter((path) => full.includes(path)), ...full.filter((path) => !baseline.includes(path))];
139
+ return { ...plan, effective_depth: "full", reason: "under_cap", paths, coverage_paths: [], uncovered_pages: [] };
140
+ }
141
+
142
+ const paths = baseline.slice();
143
+ const coveragePaths = [];
144
+ const offers = offerPagesByReach(resolvedTopology);
145
+ for (const offer of offers) {
146
+ if (paths.length >= cap) break;
147
+ const declined = declinedOfferKeys(resolvedTopology, paths);
148
+ if (declined.has(offer.key) || !offer.reach) continue;
149
+ const candidate = shortestDeclinePath(resolvedTopology, offer, declined);
150
+ if (!candidate || paths.includes(candidate)) continue;
151
+ paths.push(candidate);
152
+ coveragePaths.push(candidate);
153
+ }
154
+ const declined = declinedOfferKeys(resolvedTopology, paths);
155
+ const uncovered = offers
156
+ .filter((offer) => !declined.has(offer.key))
157
+ .map((offer) => ({
158
+ page_id: offer.page_id,
159
+ page_type: offer.page_type,
160
+ reason: offer.reach ? "cap" : "unreachable",
161
+ }));
162
+ return {
163
+ ...plan,
164
+ effective_depth: "common",
165
+ reason: full ? "over_cap" : "full_not_enumerable",
166
+ paths,
167
+ coverage_paths: coveragePaths,
168
+ uncovered_pages: uncovered,
169
+ };
170
+ }
171
+
172
+ // The offer pages a planned path clicks, in click order: `checkout` clicks
173
+ // none, `decline-accept` clicks the entry offer's decline and then the accept
174
+ // on whichever offer that decline leads to. A walk stops where the topology
175
+ // leaves the offer graph, as the runner does.
176
+ export function testOrderPathClicks(resolvedTopology, path) {
177
+ const normalized = String(path || "").toLowerCase();
178
+ const steps = !normalized || normalized === "checkout" ? [] : normalized.split("-");
179
+ const edges = new Map((resolvedTopology?.offer_edges || []).map((edge) => [edge.key, edge]));
180
+ const clicks = [];
181
+ let current = edges.get(resolvedTopology?.entry_offer_key) || null;
182
+ for (const step of steps) {
183
+ if (!current) break;
184
+ clicks.push({ key: current.key, page_id: current.page_id, action: step });
185
+ current = edges.get(current[`${step}_key`]) || null;
186
+ }
187
+ return clicks;
188
+ }
189
+
190
+ function declinedOfferKeys(resolvedTopology, paths) {
191
+ const keys = new Set();
192
+ for (const path of paths) {
193
+ for (const click of testOrderPathClicks(resolvedTopology, path)) {
194
+ if (click.action === "decline") keys.add(click.key);
195
+ }
196
+ }
197
+ return keys;
198
+ }
199
+
200
+ // Declared offer pages, shallowest first, each with the shortest action
201
+ // sequence that reaches it (accept before decline on a tie) or null when no
202
+ // path from the checkout reaches it.
203
+ function offerPagesByReach(resolvedTopology) {
204
+ const edges = resolvedTopology?.offer_edges || [];
205
+ const byKey = new Map(edges.map((edge) => [edge.key, edge]));
206
+ const reach = new Map();
207
+ const entry = resolvedTopology?.entry_offer_key;
208
+ if (entry && byKey.has(entry)) {
209
+ reach.set(entry, []);
210
+ const queue = [entry];
211
+ while (queue.length) {
212
+ const key = queue.shift();
213
+ for (const action of ["accept", "decline"]) {
214
+ const next = byKey.get(key)?.[`${action}_key`];
215
+ if (!next || reach.has(next) || !byKey.has(next)) continue;
216
+ reach.set(next, [...reach.get(key), action]);
217
+ queue.push(next);
218
+ }
219
+ }
220
+ }
221
+ const reached = [...reach.keys()].map((key) => ({ ...byKey.get(key), reach: reach.get(key) }));
222
+ const unreached = edges.filter((edge) => !reach.has(edge.key)).map((edge) => ({ ...edge, reach: null }));
223
+ return [...reached, ...unreached];
224
+ }
225
+
226
+ // The shortest actual terminal path that clicks this offer's decline. Among
227
+ // equally short paths, the one that also clicks the most still-undeclined
228
+ // offers wins, then accept before decline. Where no terminal path clicks it
229
+ // (the graph beyond is not enumerable), the path that reaches the offer and
230
+ // declines it.
231
+ function shortestDeclinePath(resolvedTopology, offer, declined) {
232
+ const newlyDeclined = (path) => new Set(testOrderPathClicks(resolvedTopology, path)
233
+ .filter((click) => click.action === "decline" && !declined.has(click.key))
234
+ .map((click) => click.key)).size;
235
+ const candidates = (resolvedTopology?.terminal_paths || [])
236
+ .filter((candidate) => testOrderPathClicks(resolvedTopology, candidate.path)
237
+ .some((click) => click.key === offer.key && click.action === "decline"))
238
+ .map((candidate) => ({ ...candidate, gain: newlyDeclined(candidate.path) }))
239
+ .sort((left, right) => (left.steps.length - right.steps.length)
240
+ || (right.gain - left.gain)
241
+ || compareActionSteps(left.steps, right.steps));
242
+ if (candidates.length) return candidates[0].path;
243
+ return [...offer.reach, "decline"].join("-");
244
+ }
245
+
98
246
  function compareCommonReceiptPaths(left, right) {
99
247
  const lengthDelta = (left?.steps?.length || 0) - (right?.steps?.length || 0);
100
248
  if (lengthDelta) return lengthDelta;
@@ -2,12 +2,12 @@
2
2
  //
3
3
  // `built_output.upsell_selector_scope` catches one shape of built markup that
4
4
  // the Campaign Cart SDK binds without complaint and that then does the wrong
5
- // thing to a shopper. This module is the rest of that family: six more
5
+ // thing to a shopper. This module is the rest of that family: seven more
6
6
  // shapes, each a static read of built HTML, each producing either a silent
7
7
  // no-op (the shopper fills a field that never reaches the order; a button
8
8
  // that never enables) or a double cart write. A partner agency's Campaign
9
- // Cart skill kit listed them as stable lint codes; the codes are kept so the
10
- // two vocabularies line up.
9
+ // Cart skill kit listed the first six as stable lint codes; the codes are
10
+ // kept so the two vocabularies line up. ORPHANED_UPSELL_ACTION is ours.
11
11
  //
12
12
  // Blockers (not waivable — the markup provably does not do what it says)
13
13
  // SWAP_WITH_ADD_TO_CART a bundle selector in swap mode (explicit, or
@@ -26,6 +26,12 @@
26
26
  // MISSING_SELECTOR_ID_MATCH an add-to-cart button whose data-next-selector-id
27
27
  // names no selector on the page. The button waits
28
28
  // for a selection that can never arrive.
29
+ // ORPHANED_UPSELL_ACTION data-next-upsell-action with no ancestor carrying
30
+ // data-next-upsell (#529). The upsell enhancer
31
+ // binds only the actions inside its container; an
32
+ // action outside it is a plain link, so a "No
33
+ // thanks" goes nowhere and the shopper is stuck.
34
+ // Every page type, not only upsell pages.
29
35
  //
30
36
  // Warnings (advisory)
31
37
  // DOUBLE_SELECTED more than one data-next-selected="true" card in
@@ -35,6 +41,11 @@
35
41
  // are single-brace and conditions are no-brace;
36
42
  // a double brace renders literally.
37
43
  //
44
+ // Not a finding: data-next-is-upsell="true" on a checkout order bump. The
45
+ // selected bump is a line item on the checkout order, tagged as an upsell so
46
+ // order reports show it apart from core items. That is the intended default;
47
+ // a bump include opts out with is_upsell: false.
48
+ //
38
49
  // Info (advisory, one note per campaign, no code)
39
50
  // unknown_attributes[] a data-next-* name the pinned SDK's attribute
40
51
  // index does not list. Catches an invented
@@ -43,10 +54,11 @@
43
54
  // own data-next-* hooks, which is why it informs
44
55
  // rather than warns.
45
56
  //
46
- // Parsed with parse5 rather than regex because four of the six turn on
47
- // containment (a card inside a selector, a field inside a form, a template's
48
- // content), and HTML nesting is not a regular language. Template content is
49
- // walked too: the SDK clones it into the live DOM.
57
+ // Parsed with parse5 rather than regex because five of the seven turn on
58
+ // containment (a card inside a selector, a field inside a form, an action
59
+ // inside its upsell container, a template's content), and HTML nesting is not
60
+ // a regular language. Template content is walked too: the SDK clones it into
61
+ // the live DOM.
50
62
  //
51
63
  // Pure: callers hand in built HTML. Both doctor entry points drive it.
52
64
 
@@ -65,6 +77,7 @@ export const SDK_MARKUP_CODES = Object.freeze({
65
77
  CHECKOUT_NOT_FORM: { code: `${SDK_MARKUP}.checkout_not_form`, severity: "error" },
66
78
  WRONG_FIELD_NAME: { code: `${SDK_MARKUP}.wrong_field_name`, severity: "error" },
67
79
  MISSING_SELECTOR_ID_MATCH: { code: `${SDK_MARKUP}.missing_selector_id_match`, severity: "error" },
80
+ ORPHANED_UPSELL_ACTION: { code: `${SDK_MARKUP}.orphaned_upsell_action`, severity: "error" },
68
81
  DOUBLE_SELECTED: { code: `${SDK_MARKUP}.double_selected`, severity: "warning" },
69
82
  TEMPLATE_DOUBLE_BRACE: { code: `${SDK_MARKUP}.template_double_brace`, severity: "warning" },
70
83
  // Unknown data-next-* names are not a finding and carry no code: they are
@@ -181,6 +194,18 @@ export function scanPageMarkup({ page_id, file = null, content = "" }) {
181
194
  }
182
195
  }
183
196
 
197
+ // An ancestor, not the element itself: the enhancer looks for actions
198
+ // inside the container it binds.
199
+ if (a.has("data-next-upsell-action") && !ancestors.some((anc) => anc.attrs.has("data-next-upsell"))) {
200
+ const value = a.get("data-next-upsell-action") ?? "";
201
+ // The SDK reads add/accept as accepting the offer and skip/decline as
202
+ // declining it; any other value does nothing even inside a container.
203
+ const verb = { add: "accept", accept: "accept", skip: "decline", decline: "decline" }[value.trim().toLowerCase()] || "act on";
204
+ findings.push(finding("ORPHANED_UPSELL_ACTION", page_id, where,
205
+ `${describe(entry)} data-next-upsell-action="${value}" on ${where} has no ancestor carrying data-next-upsell. The SDK binds upsell actions only inside that container, so this one never fires and the shopper cannot ${verb} the offer. Move it inside the data-next-upsell container it belongs to.`,
206
+ { tag, action: value }));
207
+ }
208
+
184
209
  const action = (a.get("data-next-action") || "").trim().toLowerCase();
185
210
  if (action === "add-to-cart") {
186
211
  addToCartButtons.push({ entry, selectorId: (a.get("data-next-selector-id") || "").trim() || null });
@@ -57,8 +57,9 @@ export function readStorageManifest(path) {
57
57
  version(value.sdkVersion);
58
58
  version(value.supportedSdkVersions?.min);
59
59
  version(value.supportedSdkVersions?.max);
60
- if (compare(value.sdkVersion, value.supportedSdkVersions.max) !== 0)
61
- throw new Error('Manifest source SDK version must equal supported maximum.');
60
+ // A release manifest may describe a supported range ending below its own release; it cannot vouch past itself.
61
+ if (compare(value.sdkVersion, value.supportedSdkVersions.max) < 0)
62
+ throw new Error('Manifest source SDK version is below its supported maximum.');
62
63
  const unique = new Set();
63
64
  if (compare(value.supportedSdkVersions.min, value.supportedSdkVersions.max) > 0)
64
65
  throw new Error('Invalid manifest version range.');