@nextcommerce/campaigns-os 1.50.0 → 1.52.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 (74) hide show
  1. package/CHANGELOG.md +426 -0
  2. package/agents/claude/CLAUDE.md +2 -2
  3. package/agents/codex/AGENTS.md +1 -1
  4. package/agents/copilot/copilot-instructions.md +1 -1
  5. package/agents/cursor/campaigns-os.mdc +1 -1
  6. package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
  7. package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
  8. package/campaign-spec/dist/rules/design-source-shape.js +13 -3
  9. package/campaign-spec/dist/rules/sdk-version.js +2 -1
  10. package/compatibility.json +1 -1
  11. package/contracts/commerce-surface-catalog.json +26 -46
  12. package/contracts/effects.v1.json +81 -2
  13. package/contracts/release-ledger.json +906 -0
  14. package/contracts/supported-surface.json +2 -2
  15. package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
  16. package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
  17. package/docs/build-packet.md +93 -9
  18. package/docs/campaign-build-brief.md +25 -1
  19. package/docs/effects.md +6 -0
  20. package/docs/local-setup.md +1 -1
  21. package/docs/orientation-contract-reference.md +1 -1
  22. package/docs/polish-evidence.md +10 -0
  23. package/docs/qa-and-test-orders.md +45 -4
  24. package/docs/runtime-readiness.md +1 -1
  25. package/docs/sdk-storage-compatibility.md +1 -1
  26. package/docs/skills-revision.md +10 -10
  27. package/package.json +1 -1
  28. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  29. package/skills/campaign-readback-classification/SKILL.md +3 -3
  30. package/skills/campaign-run-evidence/SKILL.md +3 -3
  31. package/skills/contribution-intake/SKILL.md +3 -3
  32. package/skills/next-campaigns-build/SKILL.md +3 -3
  33. package/skills/next-campaigns-os/SKILL.md +4 -4
  34. package/skills/next-campaigns-os/references/session-intake.md +7 -3
  35. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  36. package/skills/next-campaigns-polish/SKILL.md +3 -3
  37. package/skills/next-campaigns-qa/SKILL.md +6 -5
  38. package/skills.json +10 -10
  39. package/src/adapter-decision-contract.mjs +1 -1
  40. package/src/brand-theme.mjs +12 -0
  41. package/src/build-brief.mjs +68 -21
  42. package/src/built-site-scope.mjs +39 -6
  43. package/src/built-smoke-qc.mjs +1117 -0
  44. package/src/campaign-identity.mjs +36 -2
  45. package/src/cart-placeholders.mjs +730 -0
  46. package/src/cli.mjs +310 -34
  47. package/src/commercial-journey.mjs +65 -4
  48. package/src/commercial-parity.mjs +6 -1
  49. package/src/doctor/checks.mjs +291 -24
  50. package/src/doctor/inspect.mjs +53 -2
  51. package/src/doctor/next-step.mjs +1 -1
  52. package/src/invocation.mjs +2 -1
  53. package/src/local-preview-policy.mjs +1 -1
  54. package/src/local-proof.mjs +4 -1
  55. package/src/polish-browser.mjs +218 -1
  56. package/src/polish-capture.mjs +1 -1
  57. package/src/polish-media-weight.mjs +492 -0
  58. package/src/polish-node.mjs +96 -4
  59. package/src/progress-node.mjs +5 -1
  60. package/src/qa-browser.mjs +308 -96
  61. package/src/qa-content-params.mjs +889 -0
  62. package/src/qa-node.mjs +104 -12
  63. package/src/qa-order-bump.mjs +22 -1
  64. package/src/qa-policy-links.mjs +1019 -0
  65. package/src/qa-tracking-params.mjs +1389 -0
  66. package/src/qa-url-privacy.mjs +168 -0
  67. package/src/qc-accept.mjs +446 -0
  68. package/src/qc-check-registry.mjs +83 -0
  69. package/src/qc-results.mjs +1049 -0
  70. package/src/sdk-attribute-index.mjs +71 -0
  71. package/src/sdk-markup.mjs +2 -2
  72. package/src/sdk-storage-compatibility.mjs +63 -3
  73. package/src/source-prep.mjs +37 -7
  74. package/src/stage-record.mjs +56 -17
package/src/qa-node.mjs CHANGED
@@ -33,11 +33,15 @@ function cmd(verb, rest = "") {
33
33
  }
34
34
  import { isSameAnalyticsCapturePage, runAnalyticsCorrectnessChecks, runAnalyticsParityChecks, runBrowserChecks, runBrowserTestOrders, testEmail, upsellActionCoverageWithoutOrders, validatedOrderCreationLimit } from "./qa-browser.mjs";
35
35
  import { assessReceiptPurchase } from "./qa-analytics-correctness.mjs";
36
+ import { trackingQaAssertion, trackingRunScopeRows } from "./qa-tracking-params.mjs";
37
+ import { contentParamNotRequestedRows, contentParamQaAssertion } from "./qa-content-params.mjs";
38
+ import { policyLinkNotRequestedRows, policyLinkQaAssertion } from "./qa-policy-links.mjs";
36
39
  import { createVerdict, isFindingAssertion, QA_ASSERTION_FAMILY_VOCABULARY, SESSION_ENDING_DISPOSITIONS, SEVERITY, STATUS, validateVerdict } from "./qa-verdict.mjs";
37
40
  import { normalizeSdkMetaName, lookupSdkIgnoredMetaTag } from "./sdk-meta-tags.mjs";
38
41
  import { annotateQaAssertionCauses, formatCauseReportLines, formatCauseTag } from "./finding-cause.mjs";
39
42
  import { promoteQaVerdict, writeQaSidecar } from "./qa-sidecar.mjs";
40
43
  import { publishQaVerdict, qaPortalUrl, qaVerdictPublishBlock, QA_VERDICT_PUBLISHERS, skippedQaVerdictPublish } from "./qa-verdict-publish.mjs";
44
+ import { redactPersisted } from "./qa-url-privacy.mjs";
41
45
  import {
42
46
  isLocalServePacket,
43
47
  LOCAL_PROOF_BUILD_ENVIRONMENT,
@@ -203,7 +207,7 @@ Options:
203
207
  block before browser launch. The default cap is 6; overflow names the exact raise.
204
208
  "tiers" is spec-driven: one strict-selection order per selector tier the
205
209
  CampaignSpec declares on the checkout page (order-bump rows marked
206
- is_upsell are add-ons, never tiers), plus one coupon order per declared
210
+ is_order_bump or is_upsell are add-ons, never tiers), plus one coupon order per declared
207
211
  offer code (checkout exit_intent / promo_code_input); "tiers:common" and
208
212
  "tiers:full" cross every tier with those path shapes. --select-package
209
213
  <ref[:qty],...> narrows a tiers run to the listed declared tiers;
@@ -2328,6 +2332,7 @@ async function runResolvedQa(args, resolved, { runSessionActive = false, liveCam
2328
2332
  proxyBase: resolved.proxyBase,
2329
2333
  });
2330
2334
  assertions.push(...liveCampaignRefAssertions({ pages: [...livePages.values()], spec: liveSpec, liveCampaign: liveRead }));
2335
+ const qcResults = [];
2331
2336
  if (args.browser === true) {
2332
2337
  const browserAssertions = await runBrowserChecks(resolved.topologies, args, {
2333
2338
  brandContract: resolved.brandContract,
@@ -2338,6 +2343,8 @@ async function runResolvedQa(args, resolved, { runSessionActive = false, liveCam
2338
2343
  : residueSeverityForThemeGate(gate.status),
2339
2344
  supportedPaymentMethods: supportedPaymentMethodsFromSpec(resolved.spec),
2340
2345
  bindingExpected,
2346
+ spec: resolved.spec,
2347
+ qcResults,
2341
2348
  });
2342
2349
  // A page-binding row from the browser is the key the SDK actually sent;
2343
2350
  // it takes the place of that page's static read.
@@ -2346,9 +2353,20 @@ async function runResolvedQa(args, resolved, { runSessionActive = false, liveCam
2346
2353
  if (at >= 0) assertions[at] = observed;
2347
2354
  else assertions.push(observed);
2348
2355
  }
2349
- }
2350
-
2351
- const testOrders = await runAnalyticsOrderSequence({ args, resolved, runId, assertions });
2356
+ } else {
2357
+ // The browser checks were not requested: each declared content parameter
2358
+ // (param, page) is listed as excluded, never left silent.
2359
+ const rows = contentParamNotRequestedRows(resolved.spec, resolved.topologies);
2360
+ qcResults.push(...rows);
2361
+ assertions.push(...rows.map(contentParamQaAssertion));
2362
+ // Each configured policy link field likewise lists its presence and
2363
+ // availability rows as excluded; no probe is sent.
2364
+ const policyRows = policyLinkNotRequestedRows(resolved.spec, resolved.topologies);
2365
+ qcResults.push(...policyRows);
2366
+ assertions.push(...policyRows.map(policyLinkQaAssertion));
2367
+ }
2368
+
2369
+ const testOrders = await runAnalyticsOrderSequence({ args, resolved, runId, assertions, qcResults });
2352
2370
  const remainingAssertionBudget = Math.max(0, QA_VERDICT_ASSERTION_LIMIT - assertions.length);
2353
2371
  let commercialResult;
2354
2372
  try {
@@ -2375,6 +2393,7 @@ async function runResolvedQa(args, resolved, { runSessionActive = false, liveCam
2375
2393
  testOrders,
2376
2394
  commercial: commercialResult.commercial,
2377
2395
  runSessionActive,
2396
+ qcResults,
2378
2397
  });
2379
2398
  }
2380
2399
 
@@ -2389,7 +2408,7 @@ function reportCommercialRunnerError(args, error, write = (message) => process.s
2389
2408
  // one canonical typed-card run captures its settled terminal and only then do
2390
2409
  // we finalize the stable purchase-fires assertion. There is no receipt replay
2391
2410
  // and no second order.
2392
- async function runAnalyticsOrderSequence({ args, resolved, runId, assertions }, overrides = {}) {
2411
+ async function runAnalyticsOrderSequence({ args, resolved, runId, assertions, qcResults = null }, overrides = {}) {
2393
2412
  const operations = {
2394
2413
  runInventory: runAnalyticsCorrectnessChecks,
2395
2414
  runParity: runAnalyticsParityChecks,
@@ -2427,6 +2446,7 @@ async function runAnalyticsOrderSequence({ args, resolved, runId, assertions },
2427
2446
  assertions,
2428
2447
  captureAnalytics: analyticsLeg === "run",
2429
2448
  });
2449
+ if (Array.isArray(qcResults) && Array.isArray(result.qc_results)) qcResults.push(...result.qc_results);
2430
2450
  if (analyticsLeg === "run") {
2431
2451
  assertions.push(operations.assessReceipt(result.receiptAnalytics, { waivers: resolved.qaWaivers }));
2432
2452
  }
@@ -2554,7 +2574,7 @@ function applyLocalServeAnalyticsReview(assertions, localServe) {
2554
2574
  }
2555
2575
  }
2556
2576
 
2557
- async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, testOrders, commercial = null, runSessionActive = false, browser = null }) {
2577
+ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, testOrders, commercial = null, runSessionActive = false, browser = null, qcResults = [] }) {
2558
2578
  const entryUrls = deriveEntryUrls(resolved.topologies);
2559
2579
  const pageUrls = derivePageUrls(resolved.topologies);
2560
2580
  const testedUrls = deriveTestedUrlsFromAssertions(assertions, pageUrls);
@@ -2575,7 +2595,10 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
2575
2595
  currentRunId: runId,
2576
2596
  isFinding: isFindingAssertion,
2577
2597
  });
2578
- const verdict = createVerdict({
2598
+ // The verdict as it is persisted: the local file, the committed sidecar and
2599
+ // the published copy all read this one projection (no URL query in any
2600
+ // string or key), whichever runner, return path or field produced a value.
2601
+ const verdict = redactPersisted(createVerdict({
2579
2602
  runId,
2580
2603
  mapId: resolved.mapId,
2581
2604
  localSpecId: resolved.localSpecId,
@@ -2596,7 +2619,7 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
2596
2619
  commercial,
2597
2620
  causeSummary,
2598
2621
  browser,
2599
- });
2622
+ }));
2600
2623
 
2601
2624
  const validationErrors = validateVerdict(verdict);
2602
2625
  if (validationErrors.length) throw new Error(`QA verdict failed local validation:\n- ${validationErrors.join("\n- ")}`);
@@ -2678,6 +2701,9 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
2678
2701
  browser: verdict.browser || null,
2679
2702
  commercial: verdict.commercial || null,
2680
2703
  next_actions: buildQaCloseoutActions({ packetPath: resolved.packetPath, localPath, runSessionActive, disposition: verdict.disposition }),
2704
+ // The QC rows the QA stage records; readers re-derive them from the qc.*
2705
+ // assertions of the verdict written above.
2706
+ qc_results: qcResults,
2681
2707
  verdict,
2682
2708
  };
2683
2709
  }
@@ -2944,6 +2970,23 @@ async function runPageChecks(page, args, {
2944
2970
  ["decline", page.expected_decline_url],
2945
2971
  ]) {
2946
2972
  if (!expectedUrl) continue;
2973
+ const deadProxy = findDeadUpsellProxy(html, kind, page);
2974
+ if (deadProxy) {
2975
+ assertions.push(assertion({
2976
+ id: `route-link:${page.page_id}:${kind}`,
2977
+ family: "funnel-flow",
2978
+ page,
2979
+ status: STATUS.FAIL,
2980
+ severity: SEVERITY.BLOCKER,
2981
+ expected: expectedUrl,
2982
+ actual: deadProxy,
2983
+ evidence: {
2984
+ expected: expectedUrl,
2985
+ note: `The page's ${kind} button only forwards its click to the SDK's data-next-upsell-action inside the offer, and the offer has none, so the shopper's click does nothing. Render the in-offer action (it may be hidden).`,
2986
+ },
2987
+ }));
2988
+ continue;
2989
+ }
2947
2990
  const staticFound = htmlIncludesRouteReference(html, expectedUrl);
2948
2991
  const sdkAction = staticFound ? null : findSdkRouteAction(html, kind, page);
2949
2992
  const found = staticFound || Boolean(sdkAction);
@@ -3050,21 +3093,32 @@ async function maybeRunTestOrders(
3050
3093
  const coverage = upsellActionCoverageWithoutOrders(resolved.topologies);
3051
3094
  if (coverage) assertions.push(coverage);
3052
3095
  }
3096
+ // Tracking parameters ride browser test orders only: a run without one
3097
+ // says so with a run-scope excluded row per tracking check.
3098
+ const notRequestedRows = () => {
3099
+ const rows = trackingRunScopeRows({ runId });
3100
+ assertions.push(...rows.map(trackingQaAssertion));
3101
+ return rows;
3102
+ };
3053
3103
  if ((!mode || mode === "off") && (!legacyMode || legacyMode === "off")) {
3054
- return { orders: [], receiptAnalytics: emptyReceiptAnalytics() };
3104
+ return { orders: [], receiptAnalytics: emptyReceiptAnalytics(), qc_results: notRequestedRows() };
3055
3105
  }
3056
3106
  if (mode && mode !== "off") {
3057
3107
  // Test Orders use global test cards: they bypass the payment gateway, create
3058
3108
  // no transactions, and need no merchant setup or approval. `--test-order
3059
3109
  // <mode>` is sufficient intent — no permission flags or packet policy gate.
3060
- const result = await operations.runBrowser(resolved.topologies, args, runId, { captureAnalytics });
3110
+ const result = await operations.runBrowser(resolved.topologies, args, runId, { captureAnalytics, spec: resolved.spec });
3061
3111
  assertions.push(...result.assertions);
3062
- return { orders: result.orders, receiptAnalytics: result.receiptAnalytics || emptyReceiptAnalytics() };
3112
+ return {
3113
+ orders: result.orders,
3114
+ receiptAnalytics: result.receiptAnalytics || emptyReceiptAnalytics(),
3115
+ qc_results: Array.isArray(result.qc_results) ? result.qc_results : [],
3116
+ };
3063
3117
  }
3064
3118
 
3065
3119
  const orders = await operations.runLegacy({ args: { ...args, "test-order": legacyMode }, resolved, runId, assertions });
3066
3120
  // Direct API diagnostics never qualify as canonical receipt proof.
3067
- return { orders, receiptAnalytics: emptyReceiptAnalytics() };
3121
+ return { orders, receiptAnalytics: emptyReceiptAnalytics(), qc_results: notRequestedRows() };
3068
3122
  }
3069
3123
 
3070
3124
  async function maybeRunLegacyApiTestOrders({ args, resolved, runId, assertions }) {
@@ -3929,6 +3983,43 @@ function htmlIncludesRouteReference(html, expectedUrl) {
3929
3983
  return html.includes(expectedUrl) || html.includes(path);
3930
3984
  }
3931
3985
 
3986
+ // A data-upsell-proxy button forwards its click to the SDK action inside the
3987
+ // offer. With no such action, the route's URL can still sit in the page's meta
3988
+ // tags, so the reference match would pass a control that does nothing.
3989
+ function findDeadUpsellProxy(html, kind, page) {
3990
+ if (page.page_type !== "upsell" || kind === "next") return null;
3991
+ const action = kind === "accept" ? "add" : "skip";
3992
+ if (!new RegExp(`\\bdata-upsell-proxy\\s*=\\s*["']${action}["']`, "i").test(html)) return null;
3993
+ const target = new RegExp(`\\bdata-next-upsell-action\\s*=\\s*["']${action}["']`, "i");
3994
+ if (upsellOfferElements(html).some((offer) => target.test(offer))) return null;
3995
+ return `data-upsell-proxy="${action}" with no data-next-upsell-action="${action}" inside the offer to forward to`;
3996
+ }
3997
+
3998
+ // The markup of each [data-next-upsell="offer"] element, read to its closing
3999
+ // tag by depth of that tag name: the proxy forwards only to actions inside it.
4000
+ function upsellOfferElements(html) {
4001
+ const offers = [];
4002
+ const attrRe = /\bdata-next-upsell\s*=\s*["']offer["']/gi;
4003
+ let attr;
4004
+ while ((attr = attrRe.exec(html))) {
4005
+ const start = html.lastIndexOf("<", attr.index);
4006
+ const name = /^<([a-z][\w-]*)/i.exec(html.slice(start))?.[1];
4007
+ if (!name) continue;
4008
+ const tagRe = new RegExp(`<(/?)${name}\\b[^>]*>`, "gi");
4009
+ tagRe.lastIndex = start;
4010
+ let depth = 0;
4011
+ let end = html.length;
4012
+ let match;
4013
+ while ((match = tagRe.exec(html))) {
4014
+ if (match[1]) depth -= 1;
4015
+ else if (!match[0].endsWith("/>")) depth += 1;
4016
+ if (depth === 0) { end = match.index; break; }
4017
+ }
4018
+ offers.push(html.slice(start, end));
4019
+ }
4020
+ return offers;
4021
+ }
4022
+
3932
4023
  function findSdkRouteAction(html, kind, page) {
3933
4024
  if (page.page_type !== "upsell") return null;
3934
4025
  if (kind === "accept" && /\bdata-next-upsell-action\s*=\s*["']add["']/i.test(html)) {
@@ -4009,6 +4100,7 @@ export const __qaNodeTestHooks = Object.freeze({
4009
4100
  analyticsCorrectnessDisabledAssertion,
4010
4101
  runAnalyticsOrderSequence,
4011
4102
  maybeRunTestOrders,
4103
+ finalizeQaRun,
4012
4104
  campaignOutputDir,
4013
4105
  polishBlockedAssertions,
4014
4106
  polishGateAssertion,
@@ -287,6 +287,25 @@ export function orderBumpEvidenceScript() {
287
287
  };
288
288
 
289
289
 
290
+ // Text painted with a zero-alpha colour (`color: transparent`, or any
291
+ // colour function whose alpha is 0) occupies its box and draws nothing.
292
+ // Computed colours come back as a function: comma form, where a fourth
293
+ // argument is the alpha (`rgba(0, 0, 0, 0)`, `hsla(…, 0)`), or space form
294
+ // with the alpha after a slash (`color(srgb 0 0 0 / 0)`).
295
+ const transparentInk = (style) => {
296
+ const ink = String(style.webkitTextFillColor || style.color || "").trim().toLowerCase();
297
+ if (ink === "transparent") return true;
298
+ const fn = ink.match(/^[a-z-]+\((.*)\)$/);
299
+ if (!fn) return false;
300
+ const args = fn[1];
301
+ const alpha = args.includes("/")
302
+ ? args.slice(args.lastIndexOf("/") + 1).trim()
303
+ : (args.split(",").length === 4 ? args.split(",")[3].trim() : null);
304
+ if (alpha === null) return false;
305
+ const value = alpha.endsWith("%") ? Number.parseFloat(alpha) / 100 : Number.parseFloat(alpha);
306
+ return Number.isFinite(value) && value === 0;
307
+ };
308
+
290
309
  // The positive signals, in the order that settles which vocabulary the
291
310
  // marker speaks. Returns null when a rendered marker carries none.
292
311
  const checkedSignal = (marker) => {
@@ -296,7 +315,9 @@ export function orderBumpEvidenceScript() {
296
315
  // rendering — never the box's.
297
316
  const tick = pseudoTick(marker);
298
317
  if (tick) return { signal: "pseudo", checked: tick.shown };
299
- if (/check|\u2713/.test(marker.textContent || "")) return { signal: "glyph", checked: true };
318
+ // A glyph tick that is always in the DOM can be shown and hidden by its
319
+ // colour alone, so the text is a tick only when it is painted.
320
+ if (/check|\u2713/.test(marker.textContent || "")) return { signal: "glyph", checked: !transparentInk(style) };
300
321
  if (style.backgroundColor === acceptedFillColor) return { signal: "fill", checked: true };
301
322
  if (hiddenByAMatchingRule(marker)) return { signal: "display_toggled", checked: true };
302
323
  return null;