@nextcommerce/campaigns-os 1.46.0 → 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 (35) hide show
  1. package/CHANGELOG.md +153 -3
  2. package/README.md +1 -1
  3. package/compatibility.json +1 -1
  4. package/contracts/commerce-surface-catalog.json +1204 -129
  5. package/contracts/effects.v1.json +3 -3
  6. package/contracts/release-ledger.json +338 -0
  7. package/contracts/supported-surface.json +2 -2
  8. package/contracts/template-brand-contract.shared-commerce.v0.json +3 -3
  9. package/docs/build-packet.md +22 -2
  10. package/docs/local-setup.md +7 -4
  11. package/docs/orientation-contract-reference.md +1 -1
  12. package/docs/qa-and-test-orders.md +19 -1
  13. package/docs/runtime-readiness.md +1 -1
  14. package/docs/sdk-storage-compatibility.md +1 -1
  15. package/docs/skills-revision.md +10 -10
  16. package/package.json +1 -1
  17. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  18. package/skills/campaign-readback-classification/SKILL.md +3 -3
  19. package/skills/campaign-run-evidence/SKILL.md +3 -3
  20. package/skills/contribution-intake/SKILL.md +3 -3
  21. package/skills/next-campaigns-build/SKILL.md +4 -4
  22. package/skills/next-campaigns-os/SKILL.md +3 -3
  23. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  24. package/skills/next-campaigns-polish/SKILL.md +3 -3
  25. package/skills/next-campaigns-qa/SKILL.md +4 -4
  26. package/skills.json +10 -10
  27. package/src/built-script-syntax.mjs +116 -15
  28. package/src/diagnostic.mjs +2 -1
  29. package/src/doctor/checks.mjs +0 -1
  30. package/src/qa-analytics-parity.mjs +37 -2
  31. package/src/qa-binding-evidence.mjs +4 -2
  32. package/src/qa-browser.mjs +74 -12
  33. package/src/sdk-markup.mjs +6 -45
  34. package/src/sdk-storage-compatibility.mjs +3 -2
  35. 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
  }
@@ -5171,10 +5188,18 @@ async function clickUpsellPath(page, path, { trace = null } = {}) {
5171
5188
  // the browser's wall time at request start), and armedAt is read before the
5172
5189
  // click is sent, so this click's request starts at or after it. A request
5173
5190
  // with no known start time is not excluded: nothing shows it is stale.
5191
+ //
5192
+ // A redirected POST (307/308) is one mutation across several hops, and
5193
+ // Playwright gives each hop its own Request. The watch matches a hop by the
5194
+ // request that started its chain, and passes over a hop the browser follows,
5195
+ // so it resolves on the chain's final response: that is the one carrying the
5196
+ // order. A chain with no final response is not answered (#516).
5174
5197
  const armedAt = Date.now();
5175
5198
  const mutationPromise = path === "accept"
5176
5199
  ? page.waitForResponse((response) => {
5177
- if (response.request().method() !== "POST" || !isOrderUpsellsUrl(response.url())) return false;
5200
+ if (isFollowedRedirect(response)) return false;
5201
+ const root = responseRequest(response);
5202
+ if (!root || root.method() !== "POST" || !isOrderUpsellsUrl(root.url())) return false;
5178
5203
  const startedAt = responseRequestStartedAt(response);
5179
5204
  return startedAt === null || startedAt >= armedAt;
5180
5205
  }, { timeout: UPSELL_MUTATION_TIMEOUT_MS + (perpetual ? 0 : UPSELL_CLICK_TIMEOUT_MS) }).catch(() => null)
@@ -5205,9 +5230,9 @@ async function clickUpsellPath(page, path, { trace = null } = {}) {
5205
5230
  // waits for a later read-back before it judges the step.
5206
5231
  ...(bodyRead ? { api_response_body_read: { timed_out: bodyRead.timed_out, waited_ms: bodyRead.waited_ms, bound_ms: bodyRead.bound_ms } } : {}),
5207
5232
  };
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.
5233
+ // The request this click made (the root of its redirect chain). Another
5234
+ // step's upsell mutation posts to the same order-upsells URL, so a late body
5235
+ // is this step's only when it answers this request.
5211
5236
  const mutationRequest = mutationResponse ? responseRequest(mutationResponse) : null;
5212
5237
  if (mutationRequest) record[REQUEST_IDENTITY] = mutationRequest;
5213
5238
  return record;
@@ -6191,21 +6216,52 @@ function mutationRespondedAt(response) {
6191
6216
  // it). Playwright hands every listener the same Request object for one
6192
6217
  // request, and a different one for each request, even to the same URL: an
6193
6218
  // upsell step matches its own mutation's late body by this identity (#505).
6219
+ //
6220
+ // A redirect gives each hop a new Request, so the identity is the request
6221
+ // that started the chain (followed back through redirectedFrom()): every hop
6222
+ // of one redirected POST carries the same identity, and two POSTs never do
6223
+ // (#516).
6194
6224
  const REQUEST_IDENTITY = Symbol("campaigns-os.request-identity");
6195
6225
 
6196
6226
  function responseRequest(response) {
6197
6227
  try {
6198
- return response.request() || null;
6228
+ return redirectChainRoot(response.request());
6199
6229
  } catch {
6200
6230
  return null;
6201
6231
  }
6202
6232
  }
6203
6233
 
6234
+ // Walks back to the chain's first request. A visited set ends the walk on any
6235
+ // chain, however long, so every hop of one chain reports the same root.
6236
+ function redirectChainRoot(request) {
6237
+ let root = request || null;
6238
+ const visited = new Set();
6239
+ while (root && typeof root.redirectedFrom === "function" && !visited.has(root)) {
6240
+ visited.add(root);
6241
+ const previous = root.redirectedFrom();
6242
+ if (!previous) break;
6243
+ root = previous;
6244
+ }
6245
+ return root;
6246
+ }
6247
+
6248
+ // A 3xx hop the browser follows: it names a Location, and the chain's answer
6249
+ // is a later hop's response. A 3xx with no Location is a final response.
6250
+ function isFollowedRedirect(response) {
6251
+ try {
6252
+ const status = response.status();
6253
+ return status >= 300 && status < 400 && Boolean(response.headers()?.location);
6254
+ } catch {
6255
+ return false;
6256
+ }
6257
+ }
6258
+
6204
6259
  // 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.
6260
+ // same clock as Date.now()), or null when Playwright does not report it. For a
6261
+ // redirected request this is when its chain's first hop started.
6206
6262
  function responseRequestStartedAt(response) {
6207
6263
  try {
6208
- const startTime = response.request().timing().startTime;
6264
+ const startTime = responseRequest(response).timing().startTime;
6209
6265
  return Number.isFinite(startTime) && startTime > 0 ? startTime : null;
6210
6266
  } catch {
6211
6267
  return null;
@@ -6228,11 +6284,16 @@ function captureCheckoutEvents(page) {
6228
6284
  // polls for it. A bound here would record a slow but successful order body
6229
6285
  // as null for good, and the order would read as not created.
6230
6286
  page.on("response", async (response) => {
6231
- if (!interesting.test(response.url())) return;
6287
+ // A redirected chain is logged by where it started, so its final hop is
6288
+ // captured even when it answers from a URL this filter would not match
6289
+ // (#516): the late-body search finds it by request identity.
6290
+ const request = responseRequest(response);
6291
+ let chainUrl = null;
6292
+ try { chainUrl = request?.url() ?? null; } catch { /* identity only */ }
6293
+ if (!interesting.test(response.url()) && !(chainUrl && interesting.test(chainUrl))) return;
6232
6294
  // Taken before the body read: an entry lands in the log when its body
6233
6295
  // finishes, so its position says nothing about when it was requested.
6234
6296
  const requestStartedAt = responseRequestStartedAt(response);
6235
- const request = responseRequest(response);
6236
6297
  const entry = {
6237
6298
  status: response.status(),
6238
6299
  url: response.url(),
@@ -7369,7 +7430,8 @@ async function waitForLateUpsellEvidence(events, { responseIndexBefore, mutation
7369
7430
  const response = fresh[index];
7370
7431
  if (!response.body || typeof response.body !== "object" || Array.isArray(response.body)) continue;
7371
7432
  if (!(response.status >= 200 && response.status < 300)) continue;
7372
- if (!ORDER_UPSELLS_RESPONSE_PATTERN.test(response.url)) continue;
7433
+ // A redirected mutation's final hop may answer from another URL; its
7434
+ // identity, the root of its chain, is what ties it to this step (#516).
7373
7435
  if (mutationRequest && response[REQUEST_IDENTITY] === mutationRequest) return { source: "late_upsell_body", body: response.body };
7374
7436
  }
7375
7437
  for (let index = fresh.length - 1; index >= 0; index -= 1) {
@@ -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.');
@@ -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
  ];