@nextcommerce/campaigns-os 1.53.0 → 1.55.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 (71) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/CONTEXT.md +8 -5
  3. package/README.md +26 -11
  4. package/compatibility.json +1 -1
  5. package/contracts/campaign-cart-checkout-field-contract.v0.json +10 -0
  6. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  7. package/contracts/commerce-surface-catalog.json +27 -27
  8. package/contracts/effects.v1.json +96 -37
  9. package/contracts/release-ledger.json +774 -0
  10. package/contracts/supported-surface.json +4 -4
  11. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  12. package/docs/brand-theme-bridge.md +10 -0
  13. package/docs/build-packet.md +45 -22
  14. package/docs/campaign-standardization-report.md +32 -0
  15. package/docs/campaigns-os-build-flow.md +1 -1
  16. package/docs/design-source-package.md +7 -3
  17. package/docs/effects.md +4 -6
  18. package/docs/local-setup.md +1 -1
  19. package/docs/orientation-contract-reference.md +1 -1
  20. package/docs/progress-snapshots.md +3 -1
  21. package/docs/qa-and-test-orders.md +83 -14
  22. package/docs/runtime-readiness.md +1 -1
  23. package/docs/sdk-storage-compatibility.md +15 -0
  24. package/docs/skills-revision.md +10 -10
  25. package/docs/supported-surface.md +2 -1
  26. package/docs/workflow-findings-sidecar.md +57 -2
  27. package/package.json +3 -1
  28. package/schemas/campaign-runtime-assembly-report.v0.schema.json +5 -1
  29. package/schemas/campaigns-os-run-record.v0.schema.json +20 -15
  30. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  31. package/skills/campaign-readback-classification/SKILL.md +3 -3
  32. package/skills/campaign-run-evidence/SKILL.md +3 -3
  33. package/skills/contribution-intake/SKILL.md +3 -3
  34. package/skills/next-campaigns-build/SKILL.md +3 -3
  35. package/skills/next-campaigns-os/SKILL.md +3 -3
  36. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  37. package/skills/next-campaigns-polish/SKILL.md +3 -3
  38. package/skills/next-campaigns-qa/SKILL.md +4 -4
  39. package/skills.json +10 -10
  40. package/src/adapter-decision-contract.mjs +26 -12
  41. package/src/brand-theme.mjs +44 -8
  42. package/src/campaign-ecosystem.mjs +153 -31
  43. package/src/campaign-identity.mjs +33 -4
  44. package/src/cli.mjs +58 -62
  45. package/src/consent.mjs +3 -2
  46. package/src/design-source-publication.mjs +1 -1
  47. package/src/deviation.mjs +2 -1
  48. package/src/directory-lock.mjs +79 -7
  49. package/src/doctor/checks.mjs +28 -8
  50. package/src/doctor/inspect.mjs +13 -0
  51. package/src/doctor/next-step.mjs +16 -6
  52. package/src/invocation.mjs +7 -4
  53. package/src/lifecycle.mjs +88 -6
  54. package/src/local-preview-policy.mjs +6 -0
  55. package/src/login.mjs +2 -1
  56. package/src/polish-browser.mjs +8 -2
  57. package/src/polish-node.mjs +6 -1
  58. package/src/polish-readability.mjs +2 -2
  59. package/src/progress-node.mjs +3 -2
  60. package/src/qa-browser.mjs +595 -95
  61. package/src/qa-cart-entry.mjs +29 -6
  62. package/src/qa-node.mjs +177 -43
  63. package/src/qa-verdict-publish.mjs +2 -1
  64. package/src/run-record.mjs +46 -34
  65. package/src/sdk-attribute-index.mjs +111 -23
  66. package/src/sdk-markup.mjs +89 -10
  67. package/src/sdk-repin.mjs +282 -0
  68. package/src/stage-ledger.mjs +15 -3
  69. package/src/stage-record.mjs +85 -17
  70. package/src/standardization-report.mjs +26 -1
  71. package/src/target-lock.mjs +9 -1
@@ -256,8 +256,11 @@ function inferDesignIntentTokens(content, rootTokens = {}) {
256
256
  const lower = name.toLowerCase();
257
257
  if (isTargetContractToken(lower)) continue;
258
258
  const parts = tokenNameParts(lower);
259
+ const decorativeName = hasTokenPart(parts, DECORATIVE_NAME_PARTS);
260
+ const namedRole = hasTokenPart(parts, EXPLICIT_BRAND_PARTS);
259
261
  const ctaName = hasTokenPart(parts, ["cta", "button", "btn"]);
260
- if (!ctaName && (hasTokenSequence(parts, ["brand"], ["primary", "main"]) || (hasTokenPart(parts, ["primary"]) && !hasTokenPart(parts, ["text", "foreground", "surface", "background", "bg", "border", "outline"])))) {
262
+ if (!ctaName && (!decorativeName || namedRole)
263
+ && (hasTokenSequence(parts, ["brand"], ["primary", "main"]) || (hasTokenPart(parts, ["primary"]) && !hasTokenPart(parts, ["text", "foreground", "surface", "background", "bg", "border", "outline"])))) {
261
264
  addToken("--brand-primary", color);
262
265
  }
263
266
  if (ctaName) {
@@ -281,7 +284,7 @@ function inferDesignIntentTokens(content, rootTokens = {}) {
281
284
  if (hasTokenPart(parts, ["rating", "star", "review"])) addToken("--rating-star", color);
282
285
  }
283
286
 
284
- const rulePattern = /([^{}]+)\{([^{}]+)\}/g;
287
+ const rulePattern = /([^{};]+)\{([^{}]+)\}/g;
285
288
  for (const match of stripCssComments(content).matchAll(rulePattern)) {
286
289
  const selector = match[1] || "";
287
290
  for (const declaration of match[2].split(";")) {
@@ -291,9 +294,11 @@ function inferDesignIntentTokens(content, rootTokens = {}) {
291
294
  const color = resolveDeclarationColor(rawValue, rootTokens);
292
295
  if (!color) continue;
293
296
  if (["background", "background-color"].includes(name)) {
294
- if (/(header|nav|announcement|brand|hero)/i.test(selector) && isStrongBrandColor(color)) {
297
+ if (hasPrimarySelectorRole(selector)
298
+ && isStrongBrandColor(color)) {
295
299
  addToken("--brand-primary", color);
296
- } else if (isCtaLikeSelector(selector) && isStrongBrandColor(color)) {
300
+ }
301
+ if (isCtaLikeSelector(eligibleSelectorText(selector)) && isStrongBrandColor(color)) {
297
302
  addToken("--button-primary-bg", color);
298
303
  addToken("--brand-cta", color);
299
304
  addToken("--brand-accent", color);
@@ -319,6 +324,32 @@ function inferDesignIntentTokens(content, rootTokens = {}) {
319
324
  return tokens;
320
325
  }
321
326
 
327
+ const DECORATIVE_NAME_PARTS = ["sale", "discount", "badge", "rating", "star", "strike", "border", "announcement"];
328
+ const EXPLICIT_BRAND_PARTS = ["brand", "primary", "cta", "button", "btn"];
329
+
330
+ function selectorSubjectParts(compound) {
331
+ return [...compound.classes, ...compound.ids].flatMap(tokenNameParts);
332
+ }
333
+
334
+ function subjectHasDecorativeRole(compound) {
335
+ // A button or submit input is a control whatever its classes say.
336
+ if (compound.type === "button") return false;
337
+ if (compound.type === "input" && compound.attributes.some((attribute) => /^\s*type\s*=\s*(["']?)submit\1\s*(?:i\s*)?$/i.test(attribute))) return false;
338
+ const parts = selectorSubjectParts(compound);
339
+ return hasTokenPart(parts, DECORATIVE_NAME_PARTS) && !hasTokenPart(parts, EXPLICIT_BRAND_PARTS);
340
+ }
341
+
342
+ function eligibleSelectorText(selector) {
343
+ const compounds = rightmostCompounds(selector);
344
+ if (!compounds) return selector;
345
+ return compounds.filter((compound) => !subjectHasDecorativeRole(compound))
346
+ .map((compound) => compound.selectorText).join(", ");
347
+ }
348
+
349
+ function hasPrimarySelectorRole(selector) {
350
+ return /(header|nav|announcement|brand|hero)/i.test(eligibleSelectorText(selector));
351
+ }
352
+
322
353
  function isCtaLikeSelector(selector) {
323
354
  return /(?:^|[.#\s:_-])(?:cta|button|btn|submit|cart|buy|order)(?:$|[.#\s:_-])/i.test(String(selector || ""));
324
355
  }
@@ -337,11 +368,12 @@ const SELECTOR_ARGUMENT_PSEUDOS = new Set(["not", "is", "where", "has"]);
337
368
  function rightmostCompounds(selectorList) {
338
369
  const text = String(selectorList || "");
339
370
  const compounds = [];
340
- const fresh = () => ({ type: null, classes: [], attributes: [], pseudos: [] });
341
- const isEmpty = (compound) => !compound.type && !compound.classes.length && !compound.attributes.length && !compound.pseudos.length;
371
+ const fresh = () => ({ type: null, classes: [], ids: [], attributes: [], pseudos: [] });
372
+ const isEmpty = (compound) => !compound.type && !compound.classes.length && !compound.ids.length && !compound.attributes.length && !compound.pseudos.length;
342
373
  let current = fresh();
343
374
  let afterCombinator = false;
344
375
  let i = 0;
376
+ let selectorStart = 0;
345
377
  const readName = () => {
346
378
  const start = i;
347
379
  while (i < text.length && (/[A-Za-z0-9_\- -￿]/.test(text[i]) || text[i] === "\\")) i += text[i] === "\\" ? 2 : 1;
@@ -371,10 +403,12 @@ function rightmostCompounds(selectorList) {
371
403
  if (/[\s>+~]/.test(ch)) { afterCombinator = true; i += 1; continue; }
372
404
  if (ch === ",") {
373
405
  if (isEmpty(current)) return null;
406
+ current.selectorText = text.slice(selectorStart, i);
374
407
  compounds.push(current);
375
408
  current = fresh();
376
409
  afterCombinator = false;
377
410
  i += 1;
411
+ selectorStart = i;
378
412
  continue;
379
413
  }
380
414
  if (afterCombinator) { current = fresh(); afterCombinator = false; }
@@ -383,6 +417,7 @@ function rightmostCompounds(selectorList) {
383
417
  const name = readName();
384
418
  if (!name) return null;
385
419
  if (ch === ".") current.classes.push(name);
420
+ else current.ids.push(name);
386
421
  } else if (ch === "[") {
387
422
  const attribute = readGroup("[", "]");
388
423
  if (attribute === null) return null;
@@ -404,6 +439,7 @@ function rightmostCompounds(selectorList) {
404
439
  }
405
440
  }
406
441
  if (isEmpty(current)) return null;
442
+ current.selectorText = text.slice(selectorStart);
407
443
  compounds.push(current);
408
444
  return compounds;
409
445
  }
@@ -469,7 +505,7 @@ function splitDeclarations(body) {
469
505
  // declaredCtaForegroundValue.
470
506
  function collectCtaLabelRules(content, rootTokens = {}) {
471
507
  const rules = [];
472
- for (const match of stripCssComments(content).matchAll(/([^{}]+)\{([^{}]+)\}/g)) {
508
+ for (const match of stripCssComments(content).matchAll(/([^{};]+)\{([^{}]+)\}/g)) {
473
509
  if (!isButtonSelector(match[1])) continue;
474
510
  let color = null;
475
511
  let background = null;
@@ -975,7 +1011,7 @@ function mapTokens(tokens, targetTokens, { rootTokens = {}, ctaLabelRules = [] }
975
1011
  const hasCta = mappings.some((mapping) => mapping.target === "--brand--color--cta-primary");
976
1012
  if (!hasCta && isNonEmptyString(tokens["--brand-accent"])) {
977
1013
  addMapping("--brand-accent", "--brand--color--cta-primary", tokens["--brand-accent"].trim(), "medium", { method: "cta-fallback-from-brand-accent" });
978
- warnings.push(issue("theme.cta.fallback", "No explicit CTA token was found; using --brand-accent as --brand--color--cta-primary."));
1014
+ warnings.push(issue("theme.cta.fallback", "No explicit CTA token or button background was found; using --brand-accent as --brand--color--cta-primary. This may be a rating or decorative accent; confirm the CTA colour against the design."));
979
1015
  } else if (!hasCta && isNonEmptyString(tokens["--brand-primary"])) {
980
1016
  addMapping("--brand-primary", "--brand--color--cta-primary", tokens["--brand-primary"].trim(), "medium", { method: "cta-fallback-from-brand-primary" });
981
1017
  warnings.push(issue("theme.cta.fallback", "No explicit CTA token was found; using --brand-primary as --brand--color--cta-primary."));
@@ -2,6 +2,8 @@ import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
5
+ import { isReleasedSdkVersion } from "../campaign-spec/dist/index.js";
6
+ import { sdkVersionAtLeast } from "./sdk-attribute-index.mjs";
5
7
  import { shellToken } from "./shell-token.mjs";
6
8
  import {
7
9
  compareVersions,
@@ -40,6 +42,7 @@ const MAX_SAMPLE_COUNT = 8;
40
42
  // are still discovered — they just can't be evaluated against version policy.
41
43
  const LOADER_URL_PATTERN = /https?:\/\/[^"'\s]*campaign-cart@([^"'\s/]+)[^"'\s]*/g;
42
44
  const LOADER_ARTIFACT_PATTERN = /\/loader(?:\.min)?\.js\b|\/dist\//;
45
+ const LOADER_URL_INDEXED_PATTERN = new RegExp(LOADER_URL_PATTERN.source, "gd");
43
46
  const CAMPAIGN_ID_META_PATTERN = /<meta\s+[^>]*name=["']next-campaign-id["'][^>]*>/gi;
44
47
  const NEXT_CONFIG_PATTERN = /window\.nextConfig\s*=/;
45
48
  const DATA_NEXT_PATTERN = /\bdata-next-[a-zA-Z0-9_-]+/g;
@@ -180,18 +183,19 @@ export function scanCampaignCartAppRoot({
180
183
  const loader = collectLoaderReferences(root, [...htmlFiles, ...scriptFiles]);
181
184
  // Delivery can be a hosted loader URL OR a bundled npm dependency. When the
182
185
  // SDK ships as a dependency there is no loader ref to discover, but the pin
183
- // is still a real version signal — record it and feed a resolved semver into
184
- // version policy like a discovered version (source-distinguishable below).
186
+ // is still a real version signal — record it and feed its floor (the lowest
187
+ // version the spec lets npm install) into version policy like a discovered
188
+ // version (source-distinguishable below).
185
189
  const bundled = detectBundledSdkDependency(root);
186
190
  loader.bundled_dependency = bundled
187
- ? { name: bundled.name, version: bundled.version, resolved_version: bundled.resolved_version }
188
- : null;
189
- // A prerelease pin sorts strictly before its GA release, so it is never fed
190
- // into version policy as a clean semver — the numeric triple would wrongly
191
- // pass the release gate. It is recorded on sdk_loader and flagged instead.
192
- const bundledPolicyVersion = bundled?.resolved_version && !bundled.prerelease
193
- ? bundled.resolved_version
191
+ ? { name: bundled.name, version: bundled.version, resolved_version: bundled.resolved_version, floor_version: bundled.floor_version }
194
192
  : null;
193
+ // Policy asks whether every install meets the minimum, so only a floor the
194
+ // spec makes certain is evaluated. A prerelease pin sorts strictly before
195
+ // its GA release and has no released floor; nor does a range like <0.4.39
196
+ // or 0.4.41 || 0.4.18, whose extracted triple is not a version every
197
+ // install meets. Each is recorded on sdk_loader and flagged instead.
198
+ const bundledPolicyVersion = bundled?.floor_version || null;
195
199
  const versionEntries = [
196
200
  ...loader.versions.map((version) => ({ version, source: "loader" })),
197
201
  ...(bundledPolicyVersion && !loader.versions.includes(bundledPolicyVersion)
@@ -209,8 +213,20 @@ export function scanCampaignCartAppRoot({
209
213
  evidence: { name: bundled.name, version: bundled.version, resolved_version: bundled.resolved_version },
210
214
  next_action: "Pin the bundled campaign-cart dependency to a released version so version policy can be evaluated.",
211
215
  }));
216
+ } else if (bundled && !bundled.floor_version) {
217
+ findings.push(finding({
218
+ severity: "warning",
219
+ category: "standardization_warning",
220
+ code: "version.sdk_dependency_floor_unknown",
221
+ message: `Bundled Campaign Cart dependency ${bundled.name}@${bundled.version} does not fix a lowest version (an exact pin, or one ^, ~ or >= range on a released version); version policy cannot be evaluated against it.`,
222
+ confidence: "static_contract",
223
+ evidence: { name: bundled.name, version: bundled.version, resolved_version: bundled.resolved_version },
224
+ next_action: "Pin the bundled campaign-cart dependency exactly, or to a ^, ~ or >= range on a supported release, so version policy can be evaluated.",
225
+ }));
212
226
  }
213
- const checkoutFields = inspectCheckoutFields(root, htmlFiles, contract, findings);
227
+ const checkoutFields = inspectCheckoutFields(root, htmlFiles, contract, findings, {
228
+ sdkVersionForFile: loaderSdkVersionResolver(loader, bundled),
229
+ });
214
230
  const payment = inspectPaymentSurfaces(root, htmlFiles, scriptFiles, findings);
215
231
  const dataNext = inspectDataNext(root, htmlFiles);
216
232
  const runtime = inspectRuntimeArtifacts(root, target, findings);
@@ -280,6 +296,8 @@ export function scanCampaignCartAppRoot({
280
296
  // when no such dependency is declared. A prerelease pin sorts strictly BEFORE
281
297
  // its GA release in semver, so `prerelease: true` tells the caller the pin
282
298
  // must NOT be evaluated against the release policy as if it were the release.
299
+ // `floor_version` is the lowest version the spec allows when that is certain
300
+ // (bundledSdkFloor), else null; it is the version policy evaluates.
283
301
  function detectBundledSdkDependency(root) {
284
302
  const pkg = readJson(join(root, "package.json"));
285
303
  const deps = { ...(pkg?.dependencies || {}), ...(pkg?.devDependencies || {}) };
@@ -291,12 +309,23 @@ function detectBundledSdkDependency(root) {
291
309
  name,
292
310
  version: spec,
293
311
  resolved_version: semver ? `${semver[1]}${semver[2] || ""}` : null,
312
+ floor_version: bundledSdkFloor(spec),
294
313
  prerelease: Boolean(semver && semver[2]),
295
314
  };
296
315
  }
297
316
  return null;
298
317
  }
299
318
 
319
+ // The lowest SDK version a bundled dependency spec allows, when the spec makes
320
+ // it certain: an exact pin (0.4.39, =0.4.39, v0.4.39) or one lower-bounded
321
+ // comparator (^0.4.39, ~0.4.39, >=0.4.39), each on a released version. Any
322
+ // other spec (<0.4.39, a ||, hyphen or x-range, a tag, a prerelease) gives
323
+ // null: the version extracted from it is not a version every install meets.
324
+ function bundledSdkFloor(spec) {
325
+ const match = /^\s*(?:\^|~|>=|=)?\s*v?(\S+)\s*$/.exec(String(spec ?? ""));
326
+ return match && isReleasedSdkVersion(match[1]) ? match[1] : null;
327
+ }
328
+
300
329
  export function detectFrameworks(root) {
301
330
  const pkg = readJson(join(root, "package.json"));
302
331
  const deps = { ...(pkg?.dependencies || {}), ...(pkg?.devDependencies || {}) };
@@ -309,16 +338,13 @@ function collectLoaderReferences(root, files) {
309
338
  for (const file of files) {
310
339
  const raw = safeReadText(file);
311
340
  if (raw === null) continue;
312
- const content = HTML_EXTENSIONS.has(extname(file).toLowerCase()) ? maskHtmlComments(raw) : raw;
313
- for (const match of content.matchAll(withGlobal(LOADER_URL_PATTERN))) {
314
- if (!LOADER_ARTIFACT_PATTERN.test(match[0])) continue;
315
- const semver = match[1].match(/^v?(\d+\.\d+\.\d+)$/);
341
+ for (const entry of campaignCartArtifactReferences(raw, { isHtml: HTML_EXTENSIONS.has(extname(file).toLowerCase()) })) {
316
342
  references.push({
317
343
  path: relPath(root, file),
318
- line: lineOf(content, match.index || 0),
319
- url: match[0],
320
- ref: match[1],
321
- version: semver ? semver[1] : null,
344
+ line: entry.line,
345
+ url: entry.url,
346
+ ref: entry.ref,
347
+ version: entry.version,
322
348
  });
323
349
  }
324
350
  }
@@ -328,6 +354,73 @@ function collectLoaderReferences(root, files) {
328
354
  };
329
355
  }
330
356
 
357
+ // Every file the scan walks under `root`: its own skipped directories (.git,
358
+ // .campaign-runtime, _site, node_modules, qa-output, dist, build) left out.
359
+ export const listCampaignCartScanFiles = (root) => listFiles(root);
360
+
361
+ // The HTML and script files among them, the ones the scan reads.
362
+ export function listCampaignCartSourceFiles(root) {
363
+ return listFiles(root).filter((file) => {
364
+ const ext = extname(file).toLowerCase();
365
+ return HTML_EXTENSIONS.has(ext) || SCRIPT_EXTENSIONS.has(ext);
366
+ });
367
+ }
368
+
369
+ export const isCampaignCartHtmlFile = (file) => HTML_EXTENSIONS.has(extname(file).toLowerCase());
370
+
371
+ // Every Campaign Cart loader/dist reference in one file's text, in order, read
372
+ // exactly as the scan reads it: HTML comments masked in an HTML file, and only
373
+ // URLs that point at a loader or dist artifact. `ref` is the segment after
374
+ // `campaign-cart@` and `version` its x.y.z when the ref is a plain semver pin
375
+ // (with or without a leading v), else null. `url_start` is the URL's offset
376
+ // and `ref_start`/`ref_end` the offsets of that segment in the file text; masking preserves offsets, so they
377
+ // index the unmasked text too. `sdk repin` rewrites only that span.
378
+ export function campaignCartArtifactReferences(text, { isHtml = false } = {}) {
379
+ const content = isHtml ? maskHtmlComments(text) : String(text ?? "");
380
+ const references = [];
381
+ for (const match of content.matchAll(LOADER_URL_INDEXED_PATTERN)) {
382
+ if (!LOADER_ARTIFACT_PATTERN.test(match[0])) continue;
383
+ const semver = match[1].match(/^v?(\d+\.\d+\.\d+)$/);
384
+ references.push({
385
+ line: lineOf(content, match.index || 0),
386
+ url: match[0],
387
+ ref: match[1],
388
+ version: semver ? semver[1] : null,
389
+ url_start: match.index,
390
+ ref_start: match.indices[1][0],
391
+ ref_end: match.indices[1][1],
392
+ });
393
+ }
394
+ return references;
395
+ }
396
+
397
+ // The SDK version each HTML file's checkout bindings run against, for the
398
+ // version-gated field aliases. A file that loads the SDK itself uses its own
399
+ // loader pin: one exact version, or unknown when its refs are unpinned or
400
+ // disagree. Any other file (a partial, a page whose loader is injected) uses
401
+ // the campaign's version: the lowest of the loader pins and the bundled
402
+ // dependency's floor, or unknown when any loader ref is unpinned, the bundled
403
+ // dependency has no certain floor, or nothing is discovered. Unknown is judged
404
+ // as an earlier SDK (see inspectCheckoutFields).
405
+ function loaderSdkVersionResolver(loader, bundled) {
406
+ const byFile = new Map();
407
+ for (const ref of loader.references) {
408
+ if (!byFile.has(ref.path)) byFile.set(ref.path, []);
409
+ byFile.get(ref.path).push(ref.version);
410
+ }
411
+ const floor = bundled?.floor_version || null;
412
+ const versions = [...loader.versions, ...(floor ? [floor] : [])];
413
+ const campaign = loader.references.some((ref) => !ref.version) || (bundled && !floor) || !versions.length
414
+ ? null
415
+ : { version: [...versions].sort(compareVersions)[0], source: "campaign" };
416
+ return (path) => {
417
+ const own = byFile.get(path);
418
+ if (!own) return campaign;
419
+ const versions = unique(own);
420
+ return own.every(Boolean) && versions.length === 1 ? { version: versions[0], source: "loader" } : null;
421
+ };
422
+ }
423
+
331
424
  export function evaluateVersionPolicy(versionEntries, policy, findings) {
332
425
  const evaluations = versionEntries.map(({ version, source }) => ({
333
426
  version,
@@ -364,7 +457,15 @@ export function evaluateVersionPolicy(versionEntries, policy, findings) {
364
457
  };
365
458
  }
366
459
 
367
- export function inspectCheckoutFields(root, htmlFiles, contract, findings) {
460
+ // `sdkVersionForFile(relativePath)` returns the { version, source } a file's
461
+ // bindings run against, or null when it is unknown. A version-gated alias
462
+ // (contract version_gated_aliases) is supported only on an exact SDK release
463
+ // at or after its since-version; on an earlier or unknown version it is
464
+ // classified as the contract classifies it without the gate (a stale alias,
465
+ // or unknown). Unknown is the conservative side: the canonical name works on
466
+ // every version, and accepting the alias there could pass a field that never
467
+ // reaches the order.
468
+ export function inspectCheckoutFields(root, htmlFiles, contract, findings, { sdkVersionForFile = null } = {}) {
368
469
  const bindings = [];
369
470
  const attributes = contract?.binding_attributes || ["data-next-checkout-field", "os-checkout-field"];
370
471
  const attributePattern = new RegExp(`\\b(${attributes.map(escapeRegExp).join("|")})\\s*=\\s*(["'])([^"']*)\\2`, "g");
@@ -372,17 +473,22 @@ export function inspectCheckoutFields(root, htmlFiles, contract, findings) {
372
473
  const raw = safeReadText(file);
373
474
  if (raw === null) continue;
374
475
  const content = maskHtmlComments(raw);
476
+ const path = relPath(root, file);
477
+ const sdk = (typeof sdkVersionForFile === "function" ? sdkVersionForFile(path) : null) || { version: null, source: null };
375
478
  for (const match of content.matchAll(attributePattern)) {
376
479
  const value = match[3].trim();
377
- const verdict = classifyFieldBinding(value, contract);
480
+ const verdict = classifyFieldBinding(value, contract, sdk.version);
378
481
  bindings.push({
379
482
  attribute: match[1],
380
483
  value,
381
- path: relPath(root, file),
484
+ path,
382
485
  line: lineOf(content, match.index || 0),
383
486
  supported: verdict.classification === "supported",
384
487
  classification: verdict.classification,
385
488
  canonical: verdict.canonical,
489
+ since: verdict.since,
490
+ sdk_version: sdk.version,
491
+ sdk_version_source: sdk.source,
386
492
  });
387
493
  }
388
494
  }
@@ -391,16 +497,28 @@ export function inspectCheckoutFields(root, htmlFiles, contract, findings) {
391
497
  const unknown = bindings.filter((entry) => entry.classification === "unknown");
392
498
 
393
499
  if (unsupported.length) {
500
+ const gated = unsupported.filter((entry) => entry.since);
501
+ const gatedNote = gated.length
502
+ ? ` The SDK consumes ${unique(gated.map((entry) => `${entry.value} only from ${entry.since}`)).join(", ")}; these bindings run on SDK ${unique(gated.map((entry) => entry.sdk_version || "of unknown version (judged as an earlier SDK)")).join(", ")}.`
503
+ : "";
394
504
  findings.push(finding({
395
505
  severity: "blocker",
396
506
  category: "standardization_blocker",
397
507
  code: "checkout.unsupported_field_binding",
398
- message: `${unsupported.length} checkout field binding(s) use stale aliases the Campaign Cart SDK does not consume (contract: ${contract?.schema_version || "unknown"}).`,
508
+ message: `${unsupported.length} checkout field binding(s) use stale aliases the Campaign Cart SDK does not consume (contract: ${contract?.schema_version || "unknown"}).${gatedNote}`,
399
509
  confidence: "static_contract",
400
510
  evidence: dedupeBy(unsupported, (entry) => entry.value)
401
- .map((entry) => ({ value: entry.value, canonical: entry.canonical, path: entry.path, line: entry.line }))
511
+ .map((entry) => ({
512
+ value: entry.value,
513
+ canonical: entry.canonical,
514
+ path: entry.path,
515
+ line: entry.line,
516
+ ...(entry.since ? { since: entry.since, sdk_version: entry.sdk_version } : {}),
517
+ }))
402
518
  .slice(0, MAX_SAMPLE_COUNT),
403
- next_action: "Rewrite each stale alias to its canonical Campaign Cart field name, then prove checkout binding in browser QA.",
519
+ next_action: gated.length
520
+ ? "Rewrite each stale alias to its canonical Campaign Cart field name (for an alias with a since-version in the evidence, pinning the SDK at or after that version also works), then prove checkout binding in browser QA."
521
+ : "Rewrite each stale alias to its canonical Campaign Cart field name, then prove checkout binding in browser QA.",
404
522
  }));
405
523
  }
406
524
  if (unknown.length) {
@@ -411,7 +529,7 @@ export function inspectCheckoutFields(root, htmlFiles, contract, findings) {
411
529
  message: `${unknown.length} checkout field binding(s) are outside the known Campaign Cart field contract.`,
412
530
  confidence: "static_inference",
413
531
  evidence: dedupeBy(unknown, (entry) => entry.value)
414
- .map((entry) => ({ value: entry.value, path: entry.path, line: entry.line }))
532
+ .map((entry) => ({ value: entry.value, path: entry.path, line: entry.line, ...(entry.since ? { since: entry.since, sdk_version: entry.sdk_version } : {}) }))
415
533
  .slice(0, MAX_SAMPLE_COUNT),
416
534
  next_action: "Confirm the field against the Campaign Cart SDK before treating it as supported or repairing it.",
417
535
  }));
@@ -425,10 +543,11 @@ export function inspectCheckoutFields(root, htmlFiles, contract, findings) {
425
543
  };
426
544
  }
427
545
 
428
- function classifyFieldBinding(value, contract) {
429
- if (!value) return { classification: "unknown", canonical: null };
546
+ function classifyFieldBinding(value, contract, sdkVersion = null) {
547
+ if (!value) return { classification: "unknown", canonical: null, since: null };
430
548
  const canonicalFields = new Set(contract?.canonical_fields || []);
431
549
  const acceptedAliases = contract?.accepted_aliases || {};
550
+ const gatedAliases = contract?.version_gated_aliases || {};
432
551
  const staleAliases = contract?.stale_aliases || {};
433
552
  const prefixes = contract?.prefixes || [];
434
553
 
@@ -441,12 +560,15 @@ function classifyFieldBinding(value, contract) {
441
560
  break;
442
561
  }
443
562
  }
444
- if (canonicalFields.has(base)) return { classification: "supported", canonical: null };
445
- if (typeof acceptedAliases[base] === "string") return { classification: "supported", canonical: null };
563
+ if (canonicalFields.has(base)) return { classification: "supported", canonical: null, since: null };
564
+ if (typeof acceptedAliases[base] === "string") return { classification: "supported", canonical: null, since: null };
565
+ const gated = Object.hasOwn(gatedAliases, base) ? gatedAliases[base] : null;
566
+ const since = typeof gated?.since === "string" ? gated.since : null;
567
+ if (since && sdkVersionAtLeast(sdkVersion, since)) return { classification: "supported", canonical: null, since };
446
568
  if (typeof staleAliases[base] === "string") {
447
- return { classification: "stale_alias", canonical: `${prefix}${staleAliases[base]}` };
569
+ return { classification: "stale_alias", canonical: `${prefix}${staleAliases[base]}`, since };
448
570
  }
449
- return { classification: "unknown", canonical: null };
571
+ return { classification: "unknown", canonical: null, since };
450
572
  }
451
573
 
452
574
  function inspectPaymentSurfaces(root, htmlFiles, scriptFiles, findings) {
@@ -45,6 +45,7 @@ export const CAMPAIGN_IDENTITY = "built_output.campaign_identity";
45
45
  // Finding kinds, each with its own issue code under the gate id.
46
46
  export const CAMPAIGN_IDENTITY_KINDS = Object.freeze({
47
47
  api_key_drift: `${CAMPAIGN_IDENTITY}.api_key_drift`,
48
+ api_key_mismatch: `${CAMPAIGN_IDENTITY}.api_key_mismatch`,
48
49
  funnel_drift: `${CAMPAIGN_IDENTITY}.funnel_drift`,
49
50
  funnel_missing: `${CAMPAIGN_IDENTITY}.funnel_missing`,
50
51
  attribution_drift: `${CAMPAIGN_IDENTITY}.attribution_drift`,
@@ -270,7 +271,7 @@ function gateBase(subject) {
270
271
  * file?: string|null, content: string, spec_page?: boolean,
271
272
  * scripts?: Array<{ src: string, file?: string|null, content: string }> }> }} input
272
273
  */
273
- export function evaluateCampaignIdentity({ subject, pages = [] } = {}) {
274
+ export function evaluateCampaignIdentity({ subject, pages = [], expectedApiKey = null } = {}) {
274
275
  const all = Array.isArray(pages) ? pages : [];
275
276
  const skipped = all
276
277
  .filter((page) => isParkedPage(page.route ?? page.file ?? page.page_id))
@@ -298,7 +299,7 @@ export function evaluateCampaignIdentity({ subject, pages = [] } = {}) {
298
299
  // value. Meta and config are compared together on purpose: a page whose
299
300
  // meta names one campaign while its config.js names another is drift
300
301
  // inside a single page, and the SDK's precedence rule only hides it.
301
- const keyObservations = identities.flatMap((identity) => identity.api_keys.map((key) => ({ page_id: identity.page_id, ...key })));
302
+ const keyObservations = identities.flatMap((identity) => identity.api_keys.map((key) => ({ page_id: identity.page_id, page_file: identity.file || identity.page_id, ...key })));
302
303
  const firstKey = keyObservations[0] || null;
303
304
  for (const observation of keyObservations.slice(1)) {
304
305
  if (observation.value === firstKey.value) continue;
@@ -312,6 +313,32 @@ export function evaluateCampaignIdentity({ subject, pages = [] } = {}) {
312
313
  });
313
314
  break; // one finding per kind names the first pair; the rest follow from the same edit
314
315
  }
316
+ const wrongKeys = new Map();
317
+ if (expectedApiKey?.key) {
318
+ for (const observation of keyObservations) {
319
+ if (observation.value === expectedApiKey.key) continue;
320
+ if (!wrongKeys.has(observation.value)) wrongKeys.set(observation.value, new Map());
321
+ const sources = wrongKeys.get(observation.value);
322
+ const sourceId = `${observation.where}\u0000${observation.source}`;
323
+ if (!sources.has(sourceId)) sources.set(sourceId, { file: observation.where, source: observation.source, pages: new Set() });
324
+ sources.get(sourceId).pages.add(observation.page_file);
325
+ }
326
+ }
327
+ const wrongSourceFiles = [...new Set([...wrongKeys.values()].flatMap((sources) => [...sources.values()].map((source) => source.file)))];
328
+ if (wrongKeys.size) {
329
+ const firstWrong = keyObservations.find((observation) => observation.value !== expectedApiKey.key);
330
+ const wrongDetails = [...wrongKeys].map(([value, sources]) =>
331
+ `${[...sources.values()].map(({ file, source, pages }) => `${file} (${source}; used by ${[...pages].join(", ")})`).join(" and ")} has ${quote(value)}`,
332
+ ).join("; ");
333
+ findings.push({
334
+ kind: "api_key_mismatch",
335
+ code: CAMPAIGN_IDENTITY_KINDS.api_key_mismatch,
336
+ a: { page_id: null, file: expectedApiKey.source, value: expectedApiKey.key },
337
+ b: { page_id: firstWrong.page_id, file: firstWrong.where, source: firstWrong.source, value: firstWrong.value },
338
+ evidence: `Built API key in ${wrongDetails} differs from the Campaigns API key in ${expectedApiKey.source}.`,
339
+ message: `Built API key in ${wrongDetails} differs from the Campaigns API key ${quote(expectedApiKey.key)} in ${expectedApiKey.source}. Replace the built key in the named source with the campaign's own key, then rebuild.`,
340
+ });
341
+ }
315
342
 
316
343
  // 2. next-funnel: one value across pages, and present wherever the SDK is
317
344
  // bootstrapped (a page that declares next-page-type is SDK-bound).
@@ -422,7 +449,7 @@ export function evaluateCampaignIdentity({ subject, pages = [] } = {}) {
422
449
  // Short on purpose; the per-edit prose is findings[].message, which is
423
450
  // what each doctor error carries. A reason that concatenates them is
424
451
  // truncated by every single-line renderer.
425
- reason: `${findings.length} campaign identity drift finding(s) across ${scanned.length} built page(s) (${[...new Set(findings.map((finding) => finding.kind))].join(", ")}); see findings[] for the two files and two values of each.`,
452
+ reason: `${findings.length} campaign identity ${findings.every((finding) => finding.kind === "api_key_mismatch") ? "API key mismatch" : "drift"} finding(s) across ${scanned.length} built page(s) (${[...new Set(findings.map((finding) => finding.kind))].join(", ")}); see findings[] for the sources and values to repair.`,
426
453
  findings,
427
454
  pages_scanned: scanned.length,
428
455
  pages_skipped: skipped,
@@ -432,7 +459,9 @@ export function evaluateCampaignIdentity({ subject, pages = [] } = {}) {
432
459
  id: "repair_identity",
433
460
  kind: "edit",
434
461
  command: null,
435
- description: `Make every page name the same campaign: ${findings.map((finding) => `${finding.b?.file || finding.a.file} (${finding.kind.replace(/_/g, " ")})`).join("; ")}. Not waivable: two campaign identities on one funnel cannot both be intended.`,
462
+ description: findings.map((finding) => finding.kind === "api_key_mismatch"
463
+ ? `Replace the built key in ${wrongSourceFiles.join(", ")} with the campaign's key, then rebuild.`
464
+ : `Make every page name the same campaign: ${finding.b?.file || finding.a.file} (${finding.kind.replace(/_/g, " ")}).`).join(" "),
436
465
  },
437
466
  ],
438
467
  };