@nextcommerce/campaigns-os 1.52.0 → 1.54.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 (76) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/README.md +1 -0
  3. package/agents/claude/CLAUDE.md +2 -0
  4. package/agents/codex/AGENTS.md +2 -0
  5. package/agents/copilot/copilot-instructions.md +2 -0
  6. package/agents/cursor/campaigns-os.mdc +2 -0
  7. package/compatibility.json +1 -1
  8. package/contracts/campaign-cart-checkout-field-contract.v0.json +10 -0
  9. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  10. package/contracts/commerce-surface-catalog.json +27 -27
  11. package/contracts/effects.v1.json +295 -47
  12. package/contracts/release-ledger.json +660 -0
  13. package/contracts/supported-surface.json +3 -3
  14. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  15. package/docs/brand-theme-bridge.md +2 -0
  16. package/docs/build-packet.md +67 -19
  17. package/docs/campaign-build-brief.md +48 -17
  18. package/docs/campaign-standardization-report.md +32 -0
  19. package/docs/design-source-package.md +7 -3
  20. package/docs/effects.md +10 -1
  21. package/docs/local-setup.md +1 -1
  22. package/docs/orientation-contract-reference.md +1 -1
  23. package/docs/polish-evidence.md +2 -0
  24. package/docs/progress-snapshots.md +5 -5
  25. package/docs/qa-and-test-orders.md +30 -3
  26. package/docs/readback.md +19 -2
  27. package/docs/runtime-readiness.md +1 -1
  28. package/docs/sdk-storage-compatibility.md +15 -0
  29. package/docs/skills-revision.md +10 -10
  30. package/docs/supported-surface.md +1 -0
  31. package/package.json +1 -1
  32. package/schemas/campaign-runtime-assembly-report.v0.schema.json +5 -1
  33. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  34. package/skills/campaign-readback-classification/SKILL.md +3 -3
  35. package/skills/campaign-run-evidence/SKILL.md +3 -3
  36. package/skills/contribution-intake/SKILL.md +3 -3
  37. package/skills/next-campaigns-build/SKILL.md +5 -3
  38. package/skills/next-campaigns-os/SKILL.md +10 -19
  39. package/skills/next-campaigns-os/references/session-intake.md +10 -6
  40. package/skills/next-campaigns-os-setup/SKILL.md +4 -3
  41. package/skills/next-campaigns-polish/SKILL.md +7 -5
  42. package/skills/next-campaigns-qa/SKILL.md +5 -3
  43. package/skills.json +10 -10
  44. package/src/brand-theme.mjs +21 -20
  45. package/src/brief-summary.mjs +275 -0
  46. package/src/build-brief.mjs +167 -15
  47. package/src/campaign-ecosystem.mjs +153 -31
  48. package/src/cli.mjs +182 -115
  49. package/src/contrast.mjs +649 -0
  50. package/src/design-source-publication.mjs +110 -3
  51. package/src/directory-lock.mjs +79 -7
  52. package/src/doctor/checks.mjs +141 -16
  53. package/src/doctor/inspect.mjs +13 -0
  54. package/src/doctor/next-step.mjs +15 -6
  55. package/src/input-currency.mjs +394 -0
  56. package/src/input-refresh.mjs +708 -0
  57. package/src/invocation.mjs +6 -2
  58. package/src/polish-browser.mjs +503 -201
  59. package/src/polish-deadline.mjs +7 -0
  60. package/src/polish-node.mjs +326 -46
  61. package/src/polish-readability.mjs +828 -0
  62. package/src/progress-node.mjs +18 -5
  63. package/src/qa-browser.mjs +211 -93
  64. package/src/qa-node.mjs +47 -7
  65. package/src/qc-accept.mjs +44 -8
  66. package/src/qc-check-registry.mjs +15 -7
  67. package/src/qc-results.mjs +326 -34
  68. package/src/readback.mjs +58 -10
  69. package/src/sdk-attribute-index.mjs +111 -23
  70. package/src/sdk-markup.mjs +49 -10
  71. package/src/sdk-repin.mjs +282 -0
  72. package/src/stage-ledger.mjs +235 -44
  73. package/src/stage-record.mjs +154 -30
  74. package/src/standardization-report.mjs +26 -1
  75. package/src/target-lock.mjs +9 -1
  76. package/src/tooling-setup.mjs +1 -1
@@ -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) {