@nextcommerce/campaigns-os 1.46.0 → 1.48.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 (52) hide show
  1. package/CHANGELOG.md +347 -3
  2. package/README.md +31 -4
  3. package/agents/claude/CLAUDE.md +2 -0
  4. package/agents/codex/AGENTS.md +1 -0
  5. package/agents/copilot/copilot-instructions.md +1 -0
  6. package/agents/cursor/campaigns-os.mdc +1 -1
  7. package/compatibility.json +1 -1
  8. package/contracts/commerce-surface-catalog.json +1204 -129
  9. package/contracts/effects.v1.json +3 -3
  10. package/contracts/release-ledger.json +803 -0
  11. package/contracts/supported-surface.json +2 -2
  12. package/contracts/template-brand-contract.shared-commerce.v0.json +3 -3
  13. package/docs/build-packet.md +23 -3
  14. package/docs/campaign-build-brief.md +25 -28
  15. package/docs/local-setup.md +13 -5
  16. package/docs/orientation-contract-reference.md +1 -1
  17. package/docs/qa-and-test-orders.md +68 -3
  18. package/docs/runtime-readiness.md +1 -1
  19. package/docs/sdk-storage-compatibility.md +1 -1
  20. package/docs/skills-revision.md +10 -10
  21. package/package.json +1 -1
  22. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  23. package/skills/campaign-readback-classification/SKILL.md +3 -3
  24. package/skills/campaign-run-evidence/SKILL.md +9 -4
  25. package/skills/contribution-intake/SKILL.md +3 -3
  26. package/skills/next-campaigns-build/SKILL.md +5 -4
  27. package/skills/next-campaigns-os/SKILL.md +3 -3
  28. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  29. package/skills/next-campaigns-polish/SKILL.md +4 -4
  30. package/skills/next-campaigns-qa/SKILL.md +4 -4
  31. package/skills.json +10 -10
  32. package/src/built-script-syntax.mjs +116 -15
  33. package/src/cli.mjs +6 -3
  34. package/src/content-residue.mjs +18 -90
  35. package/src/diagnostic.mjs +2 -1
  36. package/src/doctor/checks.mjs +21 -30
  37. package/src/doctor/inspect.mjs +13 -3
  38. package/src/doctor/next-step.mjs +4 -0
  39. package/src/gate-actions.mjs +8 -0
  40. package/src/local-preview-policy.mjs +92 -0
  41. package/src/page-kit-sdk-version.mjs +8 -1
  42. package/src/polish-node.mjs +26 -2
  43. package/src/progress-node.mjs +5 -1
  44. package/src/qa-analytics-parity.mjs +37 -2
  45. package/src/qa-binding-evidence.mjs +5 -3
  46. package/src/qa-browser.mjs +136 -25
  47. package/src/qa-node.mjs +33 -4
  48. package/src/readback.mjs +19 -10
  49. package/src/sdk-markup.mjs +6 -45
  50. package/src/sdk-storage-compatibility.mjs +3 -2
  51. package/src/source-prep.mjs +1 -1
  52. package/src/tooling-setup.mjs +9 -0
@@ -428,11 +428,11 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
428
428
  // with --analytics-baseline's legacy receipt, and identity resolution cannot
429
429
  // derive a receipt page yet (receipt-aware capture is out of packet 01's
430
430
  // scope). Absent that override, the candidate IS the resolved target.
431
- function analyticsParityCaptureAssertions({ baseline, candidate, baselineUrl, candidateUrl, capturePage = null, rootFallback = null }) {
431
+ function analyticsParityCaptureAssertions({ baseline, candidate, baselineUrl, candidateUrl, capturePage = null, rootFallback = null, candidatePage = null }) {
432
432
  const baselinePublicUrl = redactUrlQuery(baselineUrl);
433
433
  const candidatePublicUrl = redactUrlQuery(candidateUrl);
434
434
  const analyticsPage = { page_id: "analytics", url: candidatePublicUrl || baselinePublicUrl || undefined };
435
- const assertions = diffAnalyticsParity(baseline, candidate, { url: candidatePublicUrl });
435
+ const assertions = diffAnalyticsParity(baseline, candidate, { url: candidatePublicUrl, ...(candidatePage ? { candidatePage } : {}) });
436
436
  assertions.unshift(assertion({
437
437
  id: "analytics-parity:capture",
438
438
  family: "analytics-parity",
@@ -540,9 +540,24 @@ async function captureAnalyticsParityInContext(context, baselineUrl, targetUrl,
540
540
  candidateUrl: selected.url,
541
541
  capturePage: selected.capturePage,
542
542
  rootFallback: selected.rootFallback,
543
+ candidatePage: automaticParityCandidatePage(selected),
543
544
  });
544
545
  }
545
546
 
547
+ // #512: what the automatic candidate is, for the Purchase checks. The campaign
548
+ // root is never a receipt; a built entry is one only when its topology page
549
+ // type says so. A receipt loaded without an order fires no Purchase, so the
550
+ // leg does not go looking for the topology's receipt step: the operator pairs
551
+ // receipts with --analytics-candidate.
552
+ function automaticParityCandidatePage(selected) {
553
+ const pageType = selected.pageType || null;
554
+ return {
555
+ receipt: contractPageType({ page_type: pageType }) === "receipt",
556
+ source: selected.capturePage?.source || null,
557
+ page_type: pageType,
558
+ };
559
+ }
560
+
546
561
  // Analytics CORRECTNESS inventory leg: capture ONE page and assess only
547
562
  // declared tags/pixels. Purchase is finalized later from the canonical
548
563
  // typed-card order's recognized receipt; this inventory visit is never treated
@@ -716,6 +731,7 @@ function analyticsCaptureCandidates(url, options = {}) {
716
731
  source: "built_entry",
717
732
  page_id: entry.page_id || null,
718
733
  funnel_id: entry.funnel_id || null,
734
+ page_type: entry.page_type || null,
719
735
  // The query value is redacted from every record; this says the entry
720
736
  // was told apart from the root by its query alone.
721
737
  ...(url && isQueryRoutedFrom(trim(entry.url), url) ? { query_routed: true } : {}),
@@ -761,6 +777,7 @@ async function captureFirstAnsweringAnalyticsPage(context, url, args, extraHosts
761
777
  ...queryRouted(candidate),
762
778
  http_status: httpStatus ?? null,
763
779
  },
780
+ pageType: candidate.page_type || null,
764
781
  rootFallback: usedFallback ? rootFallback : null,
765
782
  };
766
783
  }
@@ -2805,7 +2822,22 @@ async function pricingVisibilityAssertions(browserPage, page, options = {}) {
2805
2822
  selectors.length ? countVisiblePriceRows(browserPage, selectors) : 0,
2806
2823
  countVisiblePriceRows(browserPage, totalSelectors),
2807
2824
  ]);
2808
- return [checkoutPriceVisibilityAssertion({ page, selectors, visibleCount, totalSelectors, totalVisibleCount })];
2825
+ // Nothing priced is visible. When the checkout also has no package
2826
+ // selection of its own and the SDK reports an empty cart, the cart is
2827
+ // filled on an earlier page (a landing link carrying forcePackageId), and
2828
+ // opening the checkout directly shows a state no shopper reaches. The
2829
+ // test-order path enters that cart from the landing page instead.
2830
+ const emptyEntry = visibleCount === 0 && totalVisibleCount === 0
2831
+ ? await Promise.all([
2832
+ browserPage.evaluate(() => (typeof window.next?.getCartCount === "function" ? window.next.getCartCount() : null)),
2833
+ browserPage.evaluate(checkoutSelectionSurfaceScript()),
2834
+ ]).then(
2835
+ ([cart_count, selection_surface]) => ({ cart_count, selection_surface }),
2836
+ // A failed probe leaves the row failed, and says why on the row.
2837
+ (error) => ({ probe_error: trim(error?.message || String(error)).slice(0, 200) }),
2838
+ )
2839
+ : null;
2840
+ return [checkoutPriceVisibilityAssertion({ page, selectors, visibleCount, totalSelectors, totalVisibleCount, emptyEntry })];
2809
2841
  }
2810
2842
  return [];
2811
2843
  }
@@ -2879,25 +2911,37 @@ function upsellPriceVisibilityAssertion({ page, selectors, visibleCount }) {
2879
2911
  // no `totalSelectors` ran the bundle-row check alone, and the row says so
2880
2912
  // rather than reporting an empty selector list that reads like a check that
2881
2913
  // ran and found nothing.
2882
- function checkoutPriceVisibilityAssertion({ page, selectors, visibleCount, totalSelectors, totalVisibleCount = 0 }) {
2914
+ //
2915
+ // `emptyEntry` ({ cart_count, selection_surface }, or { probe_error }) is read
2916
+ // only when nothing priced is visible. An empty SDK cart (cart_count 0, never
2917
+ // null: null means no SDK was read) on a checkout with no package selection of
2918
+ // its own is not a price the shopper fails to see: the row is skipped with
2919
+ // that reason rather than failed, and only that row carries the probe values.
2920
+ function checkoutPriceVisibilityAssertion({ page, selectors, visibleCount, totalSelectors, totalVisibleCount = 0, emptyEntry = null }) {
2883
2921
  const totalChecked = Array.isArray(totalSelectors);
2884
2922
  const ok = visibleCount >= 1 || (totalChecked && totalVisibleCount >= 1);
2923
+ const enteredUpstream = !ok && emptyEntry?.cart_count === 0 && emptyEntry?.selection_surface?.count === 0;
2924
+ const counts = totalChecked
2925
+ ? `${visibleCount} visible price row(s); ${totalVisibleCount} visible cart-summary total(s)`
2926
+ : `${visibleCount} visible price row(s)`;
2885
2927
  return assertion({
2886
2928
  id: "pricing.checkout_price_visible",
2887
2929
  family: "pricing",
2888
2930
  page,
2889
- status: ok ? STATUS.PASS : STATUS.FAIL,
2890
- severity: ok ? undefined : SEVERITY.WARN,
2931
+ status: ok ? STATUS.PASS : enteredUpstream ? STATUS.SKIPPED : STATUS.FAIL,
2932
+ severity: ok || enteredUpstream ? undefined : SEVERITY.WARN,
2891
2933
  expected: totalChecked
2892
2934
  ? "at least one visible checkout bundle price row or a visible cart-summary total"
2893
2935
  : "at least one visible checkout bundle price row",
2894
- actual: totalChecked
2895
- ? `${visibleCount} visible price row(s); ${totalVisibleCount} visible cart-summary total(s)`
2896
- : `${visibleCount} visible price row(s)`,
2936
+ actual: enteredUpstream
2937
+ ? `${counts}: opened directly, the checkout has an empty cart and no package selection of its own; its cart is filled on an earlier page, and the test order enters it from there`
2938
+ : counts,
2897
2939
  evidence: {
2898
2940
  selectors,
2899
2941
  visible_count: visibleCount,
2900
2942
  ...(totalChecked ? { total_selectors: totalSelectors, total_visible_count: totalVisibleCount } : {}),
2943
+ ...(enteredUpstream ? { cart_count: emptyEntry.cart_count, checkout_selection_surface: emptyEntry.selection_surface } : {}),
2944
+ ...(emptyEntry?.probe_error ? { empty_cart_probe_error: emptyEntry.probe_error } : {}),
2901
2945
  page_url: page.url,
2902
2946
  },
2903
2947
  });
@@ -4945,10 +4989,7 @@ async function waitForCheckoutResult(page, events = null) {
4945
4989
  if (outcomeUrl.test(String(safePageUrl(page) || ""))) break;
4946
4990
  if (events) {
4947
4991
  const rejected = rejectedOrderCreateResponse(events);
4948
- if (rejected) {
4949
- const detail = typeof rejected.body?.detail === "string" ? `: ${trim(rejected.body.detail)}` : "";
4950
- throw new Error(`order create rejected: HTTP ${rejected.status}${detail}`);
4951
- }
4992
+ if (rejected) throw new Error(orderCreateRejectionMessage(rejected));
4952
4993
  const failed = failedOrderCreateRequest(events);
4953
4994
  if (failed) throw new Error(`order create request failed: ${failed.failure || "network failure"}`);
4954
4995
  }
@@ -4962,6 +5003,23 @@ async function waitForCheckoutResult(page, events = null) {
4962
5003
  // The MOST RECENT create response decides the outcome: SDKs/platforms retry
4963
5004
  // transient create failures, so [..., 400, 201] means the retry succeeded and
4964
5005
  // the earlier rejection is history, not the result.
5006
+ // The platform refuses an order whose customer, items and total match one it
5007
+ // accepted or is still processing in the last 30 minutes, and reports it in
5008
+ // `payment_details`. QA reuses one test customer, so two runs against the same
5009
+ // campaign at once, or a rerun after an attempt that died mid-submit, trip it.
5010
+ const DUPLICATE_ORDER_PATTERN = /duplicate order/i;
5011
+ const DUPLICATE_ORDER_REMEDY =
5012
+ "duplicate_order: the platform refused an order matching a recent one from the same test customer with the same items and total, " +
5013
+ "for up to 30 minutes. Concurrent QA runs that share a test customer collide. Re-run with a different --test-email-prefix " +
5014
+ "(or --test-email), or wait. The shipping address is not part of the match";
5015
+
5016
+ function orderCreateRejectionMessage(rejected) {
5017
+ const body = rejected?.body;
5018
+ const reason = [body?.detail, body?.payment_details].find((value) => typeof value === "string" && value.trim());
5019
+ const base = `order create rejected: HTTP ${rejected?.status}${reason ? `: ${trim(reason)}` : ""}`;
5020
+ return reason && DUPLICATE_ORDER_PATTERN.test(reason) ? `${base} (${DUPLICATE_ORDER_REMEDY})` : base;
5021
+ }
5022
+
4965
5023
  function rejectedOrderCreateResponse(events) {
4966
5024
  for (let index = events.responses.length - 1; index >= 0; index -= 1) {
4967
5025
  const response = events.responses[index];
@@ -5171,10 +5229,18 @@ async function clickUpsellPath(page, path, { trace = null } = {}) {
5171
5229
  // the browser's wall time at request start), and armedAt is read before the
5172
5230
  // click is sent, so this click's request starts at or after it. A request
5173
5231
  // with no known start time is not excluded: nothing shows it is stale.
5232
+ //
5233
+ // A redirected POST (307/308) is one mutation across several hops, and
5234
+ // Playwright gives each hop its own Request. The watch matches a hop by the
5235
+ // request that started its chain, and passes over a hop the browser follows,
5236
+ // so it resolves on the chain's final response: that is the one carrying the
5237
+ // order. A chain with no final response is not answered (#516).
5174
5238
  const armedAt = Date.now();
5175
5239
  const mutationPromise = path === "accept"
5176
5240
  ? page.waitForResponse((response) => {
5177
- if (response.request().method() !== "POST" || !isOrderUpsellsUrl(response.url())) return false;
5241
+ if (isFollowedRedirect(response)) return false;
5242
+ const root = responseRequest(response);
5243
+ if (!root || root.method() !== "POST" || !isOrderUpsellsUrl(root.url())) return false;
5178
5244
  const startedAt = responseRequestStartedAt(response);
5179
5245
  return startedAt === null || startedAt >= armedAt;
5180
5246
  }, { timeout: UPSELL_MUTATION_TIMEOUT_MS + (perpetual ? 0 : UPSELL_CLICK_TIMEOUT_MS) }).catch(() => null)
@@ -5205,9 +5271,9 @@ async function clickUpsellPath(page, path, { trace = null } = {}) {
5205
5271
  // waits for a later read-back before it judges the step.
5206
5272
  ...(bodyRead ? { api_response_body_read: { timed_out: bodyRead.timed_out, waited_ms: bodyRead.waited_ms, bound_ms: bodyRead.bound_ms } } : {}),
5207
5273
  };
5208
- // The request this click made. Another step's upsell mutation posts to the
5209
- // same order-upsells URL, so a late body is this step's only when it
5210
- // answers this request.
5274
+ // The request this click made (the root of its redirect chain). Another
5275
+ // step's upsell mutation posts to the same order-upsells URL, so a late body
5276
+ // is this step's only when it answers this request.
5211
5277
  const mutationRequest = mutationResponse ? responseRequest(mutationResponse) : null;
5212
5278
  if (mutationRequest) record[REQUEST_IDENTITY] = mutationRequest;
5213
5279
  return record;
@@ -6191,21 +6257,52 @@ function mutationRespondedAt(response) {
6191
6257
  // it). Playwright hands every listener the same Request object for one
6192
6258
  // request, and a different one for each request, even to the same URL: an
6193
6259
  // upsell step matches its own mutation's late body by this identity (#505).
6260
+ //
6261
+ // A redirect gives each hop a new Request, so the identity is the request
6262
+ // that started the chain (followed back through redirectedFrom()): every hop
6263
+ // of one redirected POST carries the same identity, and two POSTs never do
6264
+ // (#516).
6194
6265
  const REQUEST_IDENTITY = Symbol("campaigns-os.request-identity");
6195
6266
 
6196
6267
  function responseRequest(response) {
6197
6268
  try {
6198
- return response.request() || null;
6269
+ return redirectChainRoot(response.request());
6199
6270
  } catch {
6200
6271
  return null;
6201
6272
  }
6202
6273
  }
6203
6274
 
6275
+ // Walks back to the chain's first request. A visited set ends the walk on any
6276
+ // chain, however long, so every hop of one chain reports the same root.
6277
+ function redirectChainRoot(request) {
6278
+ let root = request || null;
6279
+ const visited = new Set();
6280
+ while (root && typeof root.redirectedFrom === "function" && !visited.has(root)) {
6281
+ visited.add(root);
6282
+ const previous = root.redirectedFrom();
6283
+ if (!previous) break;
6284
+ root = previous;
6285
+ }
6286
+ return root;
6287
+ }
6288
+
6289
+ // A 3xx hop the browser follows: it names a Location, and the chain's answer
6290
+ // is a later hop's response. A 3xx with no Location is a final response.
6291
+ function isFollowedRedirect(response) {
6292
+ try {
6293
+ const status = response.status();
6294
+ return status >= 300 && status < 400 && Boolean(response.headers()?.location);
6295
+ } catch {
6296
+ return false;
6297
+ }
6298
+ }
6299
+
6204
6300
  // When the browser started a response's request, in epoch milliseconds (the
6205
- // same clock as Date.now()), or null when Playwright does not report it.
6301
+ // same clock as Date.now()), or null when Playwright does not report it. For a
6302
+ // redirected request this is when its chain's first hop started.
6206
6303
  function responseRequestStartedAt(response) {
6207
6304
  try {
6208
- const startTime = response.request().timing().startTime;
6305
+ const startTime = responseRequest(response).timing().startTime;
6209
6306
  return Number.isFinite(startTime) && startTime > 0 ? startTime : null;
6210
6307
  } catch {
6211
6308
  return null;
@@ -6228,11 +6325,16 @@ function captureCheckoutEvents(page) {
6228
6325
  // polls for it. A bound here would record a slow but successful order body
6229
6326
  // as null for good, and the order would read as not created.
6230
6327
  page.on("response", async (response) => {
6231
- if (!interesting.test(response.url())) return;
6328
+ // A redirected chain is logged by where it started, so its final hop is
6329
+ // captured even when it answers from a URL this filter would not match
6330
+ // (#516): the late-body search finds it by request identity.
6331
+ const request = responseRequest(response);
6332
+ let chainUrl = null;
6333
+ try { chainUrl = request?.url() ?? null; } catch { /* identity only */ }
6334
+ if (!interesting.test(response.url()) && !(chainUrl && interesting.test(chainUrl))) return;
6232
6335
  // Taken before the body read: an entry lands in the log when its body
6233
6336
  // finishes, so its position says nothing about when it was requested.
6234
6337
  const requestStartedAt = responseRequestStartedAt(response);
6235
- const request = responseRequest(response);
6236
6338
  const entry = {
6237
6339
  status: response.status(),
6238
6340
  url: response.url(),
@@ -6332,7 +6434,7 @@ function sanitizedEvents(events) {
6332
6434
  responses: events.responses.slice(-20).map((response) => ({
6333
6435
  status: response.status,
6334
6436
  url: response.url,
6335
- body: summarizeResponseBody(response.body),
6437
+ body: summarizeResponseBody(response.body, { status: response.status }),
6336
6438
  })),
6337
6439
  failed: events.failed.slice(-20),
6338
6440
  console: events.console.slice(-20),
@@ -6359,7 +6461,7 @@ function summarizeRequestPostData(value) {
6359
6461
  }
6360
6462
  }
6361
6463
 
6362
- function summarizeResponseBody(body) {
6464
+ function summarizeResponseBody(body, { status = null } = {}) {
6363
6465
  if (typeof body === "string") return trim(body).slice(0, 1000);
6364
6466
  if (!body || typeof body !== "object" || Array.isArray(body)) return body;
6365
6467
  return {
@@ -6371,6 +6473,12 @@ function summarizeResponseBody(body) {
6371
6473
  ...(body.checkout_url ? { checkout_url: body.checkout_url } : {}),
6372
6474
  ...(Array.isArray(body.lines) ? { lines: extractReceiptLines(body) } : {}),
6373
6475
  ...(body.detail ? { detail: body.detail } : {}),
6476
+ // A rejected request names its reason here (for example the
6477
+ // duplicate-order refusal). Kept only on an error response, so a
6478
+ // successful create never carries payment_details into evidence.
6479
+ ...(Number(status) >= 400 && typeof body.payment_details === "string"
6480
+ ? { payment_details: trim(body.payment_details).slice(0, 300) }
6481
+ : {}),
6374
6482
  };
6375
6483
  }
6376
6484
 
@@ -7369,7 +7477,8 @@ async function waitForLateUpsellEvidence(events, { responseIndexBefore, mutation
7369
7477
  const response = fresh[index];
7370
7478
  if (!response.body || typeof response.body !== "object" || Array.isArray(response.body)) continue;
7371
7479
  if (!(response.status >= 200 && response.status < 300)) continue;
7372
- if (!ORDER_UPSELLS_RESPONSE_PATTERN.test(response.url)) continue;
7480
+ // A redirected mutation's final hop may answer from another URL; its
7481
+ // identity, the root of its chain, is what ties it to this step (#516).
7373
7482
  if (mutationRequest && response[REQUEST_IDENTITY] === mutationRequest) return { source: "late_upsell_body", body: response.body };
7374
7483
  }
7375
7484
  for (let index = fresh.length - 1; index >= 0; index -= 1) {
@@ -7727,6 +7836,8 @@ export const __qaBrowserTestHooks = Object.freeze({
7727
7836
  linePriceDeltaEvidence,
7728
7837
  packageMatchesLine,
7729
7838
  rejectedOrderCreateResponse,
7839
+ orderCreateRejectionMessage,
7840
+ summarizeResponseBody,
7730
7841
  failedOrderCreateRequest,
7731
7842
  extractOrderVouchers,
7732
7843
  orderDiscountTotal,
package/src/qa-node.mjs CHANGED
@@ -1,4 +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
3
  import { expectedBinding, createBindingScriptLoader, observeBinding, bindingAssertion, scriptParseAssertion } from './qa-binding-evidence.mjs';
3
4
  import { shellToken } from "./shell-token.mjs";
4
5
  import { applyQaBuildScope, specForQaScope } from "./qa-build-scope.mjs";
@@ -481,6 +482,8 @@ async function resolveQaInputs(args, {
481
482
  packetPath,
482
483
  report: checkpointPreflight?.runtimeReport,
483
484
  hiddenEagerMediaGate,
485
+ packet,
486
+ baseUrl: stringArg(args["base-url"]),
484
487
  });
485
488
  const qaWaivers = resolveQaWaivers({ packetPath, report: checkpointPreflight?.runtimeReport });
486
489
  const qaScope = applyQaBuildScope(topologies, {
@@ -593,7 +596,10 @@ function resolvePacketCheckpointPreflight(args, {
593
596
  waivers: report?.waivers,
594
597
  required: true,
595
598
  }),
596
- evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report }),
599
+ applyLocalPreviewToCheckpoint(
600
+ evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report }),
601
+ { packet, report, baseUrl: stringArg(args["base-url"]) },
602
+ ),
597
603
  ];
598
604
  const runtimeReport = reportMatchesPacketIdentity(report, packet) ? report : null;
599
605
  return {
@@ -692,6 +698,8 @@ function resolvedFromBlockedCheckpointPreflight(preflight, args) {
692
698
  packetPath: preflight.packetPath,
693
699
  report: preflight.runtimeReport,
694
700
  hiddenEagerMediaGate,
701
+ packet: preflight.packet,
702
+ baseUrl: stringArg(args["base-url"]),
695
703
  });
696
704
  return {
697
705
  themeGate,
@@ -931,16 +939,18 @@ function resolvePolishGate({
931
939
  packetPath,
932
940
  report: reportOverride = undefined,
933
941
  hiddenEagerMediaGate = undefined,
942
+ packet = null,
943
+ baseUrl = null,
934
944
  }) {
935
945
  const report = reportOverride === undefined
936
946
  ? loadRuntimeArtifact(packetPath, "assembly-report.json")
937
947
  : reportOverride;
938
- const gate = evaluatePolishGate({
948
+ const gate = applyLocalPreviewToPolishGate(evaluatePolishGate({
939
949
  report,
940
950
  required: true,
941
951
  hiddenEagerMediaGate,
942
952
  currentOutputFingerprint: currentBuiltOutputFingerprint(packetPath),
943
- });
953
+ }), { packet, checkpointGate: hiddenEagerMediaGate, baseUrl });
944
954
  gate.scope_source = report ? "assembly_report" : "missing_assembly_report";
945
955
  return gate;
946
956
  }
@@ -1044,6 +1054,18 @@ function polishGateAssertion(gate) {
1044
1054
  },
1045
1055
  });
1046
1056
  }
1057
+ if (gate.status === CARRIED_FORWARD) {
1058
+ return assertion({
1059
+ id: gate.code,
1060
+ family: "polish_gate",
1061
+ page,
1062
+ status: STATUS.WARN,
1063
+ severity: SEVERITY.WARN,
1064
+ expected: "current structured Polish evidence produced by next-campaigns-polish",
1065
+ actual: carriedForwardMessage(gate),
1066
+ evidence: { ...evidence, reason: gate.reason, carried_forward: gate.carried_forward },
1067
+ });
1068
+ }
1047
1069
  if (gate.status === "not_applicable") {
1048
1070
  return assertion({
1049
1071
  id: gate.code,
@@ -1350,6 +1372,9 @@ function hiddenEagerMediaGateAssertion(gate) {
1350
1372
  if (summary.status === "waived") {
1351
1373
  return assertion({ ...common, status: STATUS.WARN, severity: SEVERITY.WARN, waiver: summary.waiver });
1352
1374
  }
1375
+ if (summary.status === CARRIED_FORWARD) {
1376
+ return assertion({ ...common, status: STATUS.WARN, severity: SEVERITY.WARN, actual: `${summary.code}: ${carriedForwardMessage(summary)}` });
1377
+ }
1353
1378
  if (summary.status === "not_applicable") {
1354
1379
  return assertion({ ...common, status: STATUS.SKIPPED });
1355
1380
  }
@@ -2306,7 +2331,11 @@ async function runResolvedQa(args, resolved, { runSessionActive = false, liveCam
2306
2331
  if (args.browser === true) {
2307
2332
  assertions.push(...await runBrowserChecks(resolved.topologies, args, {
2308
2333
  brandContract: resolved.brandContract,
2309
- residueSeverity: residueSeverityForThemeGate(gate.status),
2334
+ // With no generatable brand theme on the local preview, the starter
2335
+ // template is the design: its residue is a warning (local-preview-policy.mjs).
2336
+ residueSeverity: starterResidueIsExpected(gate, { packet: resolved.packet, baseUrl: resolved.baseUrl })
2337
+ ? SEVERITY.WARN
2338
+ : residueSeverityForThemeGate(gate.status),
2310
2339
  supportedPaymentMethods: supportedPaymentMethodsFromSpec(resolved.spec),
2311
2340
  }));
2312
2341
  }
package/src/readback.mjs CHANGED
@@ -1065,8 +1065,11 @@ function artifactData(views, key) {
1065
1065
  }
1066
1066
 
1067
1067
  /**
1068
- * Bucket the QA verdict's assertions by recorded status.
1068
+ * Bucket the QA verdict's assertions by recorded status: `{ fail, pass,
1069
+ * skipped, review, other }`.
1069
1070
  *
1071
+ * `review` collects the verdict's `warn` and `manual_review` statuses: evidence
1072
+ * that still needs reading, which the disposition counts as an exception.
1070
1073
  * `other` collects any status this readback does not project (it renders those
1071
1074
  * rows as written rather than reclassifying them). Non-object entries are
1072
1075
  * dropped: an assertion the readback cannot address by status is not an
@@ -1075,13 +1078,15 @@ function artifactData(views, key) {
1075
1078
  * branch.
1076
1079
  */
1077
1080
  export function partitionAssertions(views) {
1078
- const buckets = { fail: [], pass: [], skipped: [], other: [] };
1081
+ const buckets = { fail: [], pass: [], skipped: [], review: [], other: [] };
1079
1082
  const verdict = artifactData(views, "qa_verdict");
1080
1083
  if (verdict === null) return buckets;
1081
1084
  for (const assertion of verdict.assertions ?? []) {
1082
1085
  if (!isPlainObject(assertion)) continue;
1083
1086
  const status = assertion.status;
1084
- const bucket = status === "fail" || status === "pass" || status === "skipped" ? status : "other";
1087
+ const bucket = status === "fail" || status === "pass" || status === "skipped"
1088
+ ? status
1089
+ : status === "warn" || status === "manual_review" ? "review" : "other";
1085
1090
  buckets[bucket].push(assertion);
1086
1091
  }
1087
1092
  return buckets;
@@ -1414,15 +1419,17 @@ function renderVerdict(views, lines) {
1414
1419
  lines.push(
1415
1420
  `QA VERDICT [QA verdict; disposition: ${verdict.disposition} — Campaigns OS is the verdict authority]`,
1416
1421
  );
1417
- const { fail: failed, pass: passed, skipped, other } = partitionAssertions(views);
1422
+ const { fail: failed, pass: passed, skipped, review, other } = partitionAssertions(views);
1418
1423
  let countLine = ` assertions: ${failed.length} fail, ${passed.length} pass, ${skipped.length} skipped`;
1424
+ if (review.length) countLine += `, ${review.length} warn or manual review`;
1419
1425
  if (other.length) countLine += `, ${other.length} unrecognized status`;
1420
1426
  lines.push(countLine);
1421
- for (const assertion of failed) {
1422
- const severity = assertion.severity;
1423
- const severityText = severity ? `, severity ${severity}` : "";
1427
+ // fail, warn and manual_review rows print what Campaigns OS recorded: the
1428
+ // row's actual value and any evidence problems.
1429
+ const renderRecordedRow = (assertion) => {
1430
+ const severityText = assertion.severity ? `, severity ${assertion.severity}` : "";
1424
1431
  lines.push(
1425
- ` fail ${recorded(assertion.id, "(no id)")} ` +
1432
+ ` ${assertion.status} ${recorded(assertion.id, "(no id)")} ` +
1426
1433
  `(family ${recorded(assertion.family, "(no family)")}${severityText})`,
1427
1434
  );
1428
1435
  const actual = assertion.actual;
@@ -1433,7 +1440,9 @@ function renderVerdict(views, lines) {
1433
1440
  lines.push(` recorded problems (${problems.length}):`);
1434
1441
  for (const problem of problems) lines.push(` - ${problem}`);
1435
1442
  }
1436
- }
1443
+ };
1444
+ for (const assertion of failed) renderRecordedRow(assertion);
1445
+ for (const assertion of review) renderRecordedRow(assertion);
1437
1446
  for (const assertion of passed) {
1438
1447
  const family = assertion.family || assertion.id || "(no family)";
1439
1448
  lines.push(` pass ${recorded(assertion.id, "(no id)")} (family ${family})`);
@@ -1442,7 +1451,7 @@ function renderVerdict(views, lines) {
1442
1451
  lines.push(
1443
1452
  ` unrecognized status ${JSON.stringify(assertion.status ?? null)} ${recorded(assertion.id, "(no id)")} ` +
1444
1453
  `(family ${assertion.family || "(no family)"}) — shown as written; this readback ` +
1445
- "projects fail, pass, and skipped statuses",
1454
+ "projects fail, pass, skipped, warn and manual_review statuses",
1446
1455
  );
1447
1456
  }
1448
1457
 
@@ -40,12 +40,11 @@
40
40
  // TEMPLATE_DOUBLE_BRACE `{{` inside an SDK-owned <template>. SDK tokens
41
41
  // are single-brace and conditions are no-brace;
42
42
  // a double brace renders literally.
43
- // CHECKOUT_BUMP_IS_UPSELL data-next-is-upsell="true" on a checkout page
44
- // (#535). A checkout bump is a pre-purchase
45
- // add-on; the flag puts it on the initial order
46
- // as an upsell line. The page type is a live,
47
- // unambiguous next-page-type meta, since it is
48
- // what the SDK reads; otherwise the route type.
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.
49
48
  //
50
49
  // Info (advisory, one note per campaign, no code)
51
50
  // unknown_attributes[] a data-next-* name the pinned SDK's attribute
@@ -70,9 +69,6 @@ import {
70
69
  isIndexedSdkAttribute,
71
70
  isKnownCheckoutFieldName,
72
71
  } from "./sdk-attribute-index.mjs";
73
- import { builtPageTypeMeta } from "./upsell-selector-scope.mjs";
74
-
75
- const normalizedPageTypeValue = (type) => (type == null ? null : String(type).trim().toLowerCase());
76
72
 
77
73
  export const SDK_MARKUP = "built_output.sdk_markup";
78
74
 
@@ -84,7 +80,6 @@ export const SDK_MARKUP_CODES = Object.freeze({
84
80
  ORPHANED_UPSELL_ACTION: { code: `${SDK_MARKUP}.orphaned_upsell_action`, severity: "error" },
85
81
  DOUBLE_SELECTED: { code: `${SDK_MARKUP}.double_selected`, severity: "warning" },
86
82
  TEMPLATE_DOUBLE_BRACE: { code: `${SDK_MARKUP}.template_double_brace`, severity: "warning" },
87
- CHECKOUT_BUMP_IS_UPSELL: { code: `${SDK_MARKUP}.checkout_bump_is_upsell`, severity: "warning" },
88
83
  // Unknown data-next-* names are not a finding and carry no code: they are
89
84
  // information on the gate (unknown_attributes[]) and one advisory ready line.
90
85
  });
@@ -160,22 +155,11 @@ function describe(entry) {
160
155
  return `${bits.join("")}>`;
161
156
  }
162
157
 
163
- function describeBump(entry) {
164
- const id = entry.attrs.get("id");
165
- const packageId = entry.attrs.get("data-next-package-id");
166
- const bits = [`<${entry.tag}`];
167
- if (id) bits.push(` id="${id}"`);
168
- if (packageId) bits.push(` data-next-package-id="${packageId}"`);
169
- return `${bits.join("")}>`;
170
- }
171
-
172
158
  /**
173
159
  * Scan one built page. Returns findings with { code_name, code, severity,
174
160
  * page_id, file, message, detail } and the set of unknown data-next-* names.
175
- * `page_type` is the route-inferred type, used only when the page declares no
176
- * live, unambiguous next-page-type meta (builtPageTypeMeta).
177
161
  */
178
- export function scanPageMarkup({ page_id, file = null, content = "", page_type = null }) {
162
+ export function scanPageMarkup({ page_id, file = null, content = "" }) {
179
163
  const document = parse(String(content || ""));
180
164
  const where = file || page_id;
181
165
  const findings = [];
@@ -186,13 +170,10 @@ export function scanPageMarkup({ page_id, file = null, content = "", page_type =
186
170
  const selectorIds = new Set(); // ids of elements that are themselves a selector
187
171
  const templates = []; // { entry, sdkOwned }
188
172
  const referencedTemplateIds = new Set();
189
- const upsellFlagged = []; // entries carrying data-next-is-upsell="true"
190
173
 
191
174
  walkElements(document, (entry) => {
192
175
  const { tag, attrs: a, ancestors } = entry;
193
176
 
194
- if ((a.get("data-next-is-upsell") || "").trim().toLowerCase() === "true") upsellFlagged.push(entry);
195
-
196
177
  for (const [name] of a) {
197
178
  if (name.startsWith("data-next-") && !isIndexedSdkAttribute(name)) unknown.add(name);
198
179
  if (TEMPLATE_ID_ATTRIBUTES.test(name) && a.get(name)) referencedTemplateIds.add(a.get(name).trim());
@@ -307,26 +288,6 @@ export function scanPageMarkup({ page_id, file = null, content = "", page_type =
307
288
  { template_id: id || null, snippet }));
308
289
  }
309
290
 
310
- // One finding per page, naming every flagged element: the repair is the
311
- // same for each (drop the flag from the bump include's markup; several
312
- // starter includes write it unconditionally, so an is_upsell=false argument
313
- // does not always clear it).
314
- // The page type is read only when a flag is present, so a page without one
315
- // is not parsed a second time.
316
- const pageTypeMeta = upsellFlagged.length ? builtPageTypeMeta(content) : null;
317
- // The live, unambiguous meta wins because it is what the SDK reads; the
318
- // upsell gate's narrower oto-route rule (builtPageTypeOverRouteGuess) does
319
- // not apply to a checkout bump.
320
- const effectivePageType = upsellFlagged.length
321
- ? normalizedPageTypeValue(pageTypeMeta ?? page_type)
322
- : null;
323
- if (effectivePageType === "checkout") {
324
- const elements = upsellFlagged.map(describeBump);
325
- findings.push(finding("CHECKOUT_BUMP_IS_UPSELL", page_id, where,
326
- `${elements.length > 1 ? `${elements.length} order bumps` : "An order bump"} on checkout page ${where} (${elements.join(", ")}) ${elements.length > 1 ? "carry" : "carries"} data-next-is-upsell="true". A checkout bump is a pre-purchase add-on; the flag puts it on the initial order as an upsell line. The flag comes from the bump include's markup, and several starter bump includes write it unconditionally, so remove data-next-is-upsell="true" from the include in this campaign unless the line really should be billed as an upsell.`,
327
- { page_type: effectivePageType, page_type_source: pageTypeMeta !== null ? "next-page-type" : "route", elements }));
328
- }
329
-
330
291
  return { page_id, file, findings, unknown_attributes: [...unknown].sort() };
331
292
  }
332
293
 
@@ -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.');
@@ -201,7 +201,7 @@ function describeFinding(code, pages, { wrapperPolicy }) {
201
201
  const policyNote = wrapperPolicy === "preserve_document_wrappers"
202
202
  ? " The adapter contract records wrapper_policy \"preserve_document_wrappers\", so this is reported without blocking."
203
203
  : "";
204
- return `Mapped source HTML is a full browser document, not page-kit-ready source: ${listed}${more}. Strip <!doctype>, <html>, <head>, and <body> so the campaign layout can wrap the page, or record wrapper_policy "preserve_document_wrappers" as an explicit adapter decision.${policyNote} See ${docs}.`;
204
+ return `Mapped source HTML is a full browser document, not page-kit-ready source: ${listed}${more}. Strip <!doctype>, <html>, <head>, and <body> so the campaign layout can wrap the page, or, for a standalone page meant to stay whole, record wrapper_policy "preserve_document_wrappers" as an explicit adapter decision: re-run start or prepare-build with --wrapper-policy preserve_document_wrappers, or set "wrapper_policy" in the source-html manifest.${policyNote} See ${docs}.`;
205
205
  }
206
206
  if (code === SOURCE_PREP_FRONTMATTER_RESIDUE) {
207
207
  const listed = sample.map((page) => {
@@ -101,6 +101,13 @@ export function setupTooling(args, { packageRoot, installSkills, installAgentCon
101
101
  if (!existsSync(join(target, "node_modules", "next-campaign-page-kit", "package.json"))) {
102
102
  throw new Error("tooling setup: page-kit is declared but not installed; run npm ci in the selected project first.");
103
103
  }
104
+ // Page-kit builds the deployed pages, so a dev-only declaration disappears
105
+ // from any host that installs with --omit=dev or NODE_ENV=production.
106
+ // The command names the installed version, so it can be pasted as-is.
107
+ const pageKitVersion = json(join(target, "node_modules", "next-campaign-page-kit", "package.json")).version;
108
+ const warnings = manifest.dependencies?.["next-campaign-page-kit"] ? [] : [
109
+ `next-campaign-page-kit is declared only in devDependencies, so builds that run npm ci --omit=dev or set NODE_ENV=production will not install it. Move it back with npm install --save-exact next-campaign-page-kit@${pageKitVersion}.`,
110
+ ];
104
111
 
105
112
  // Preflight every destination before any installer runs. Custom repository
106
113
  // instructions are preserved; only one Claude import line is appended.
@@ -136,6 +143,7 @@ export function setupTooling(args, { packageRoot, installSkills, installAgentCon
136
143
  context,
137
144
  instructions: { path: instructions, action: !ready ? "not_run" : hasImport ? "unchanged" : "append_import" },
138
145
  browser,
146
+ warnings,
139
147
  next_action: contextFailed
140
148
  ? `Setup could not add the runtime ignore block (${context.gitignore.reason}). Fix .gitignore and rerun setup; skills and context files may already be installed.`
141
149
  : args["dry-run"]
@@ -154,6 +162,7 @@ export function setupTextLines(result) {
154
162
  `Skills revision: ${result.skills_revision}`,
155
163
  `Browser: ${result.browser.status}`,
156
164
  ...(result.browser.note ? [result.browser.note] : []),
165
+ ...(result.warnings ?? []).map((warning) => `Warning: ${warning}`),
157
166
  result.next_action,
158
167
  result.note,
159
168
  ];