@nextcommerce/campaigns-os 1.54.0 → 1.57.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 (111) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/CONTEXT.md +19 -5
  3. package/README.md +26 -12
  4. package/campaign-spec/README.md +15 -1
  5. package/campaign-spec/dist/checkout-flow.d.ts +90 -0
  6. package/campaign-spec/dist/checkout-flow.js +229 -0
  7. package/campaign-spec/dist/index.d.ts +1 -0
  8. package/campaign-spec/dist/index.js +6 -1
  9. package/campaign-spec/dist/normalize.d.ts +3 -2
  10. package/campaign-spec/dist/normalize.js +7 -5
  11. package/campaign-spec/dist/routing.d.ts +6 -3
  12. package/campaign-spec/dist/routing.js +6 -3
  13. package/campaign-spec/dist/rules/checkout-forward-target.d.ts +17 -0
  14. package/campaign-spec/dist/rules/checkout-forward-target.js +48 -0
  15. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.d.ts +12 -0
  16. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.js +37 -0
  17. package/campaign-spec/dist/rules/exit-intent-validation.js +2 -1
  18. package/campaign-spec/dist/rules/index.d.ts +4 -0
  19. package/campaign-spec/dist/rules/index.js +12 -0
  20. package/campaign-spec/dist/rules/one-checkout-per-path.d.ts +25 -0
  21. package/campaign-spec/dist/rules/one-checkout-per-path.js +84 -0
  22. package/campaign-spec/dist/rules/pre-payment-forward-target.d.ts +12 -0
  23. package/campaign-spec/dist/rules/pre-payment-forward-target.js +42 -0
  24. package/campaign-spec/dist/rules/promo-code-input-validation.js +2 -1
  25. package/campaign-spec/dist/rules/schema-version.d.ts +9 -4
  26. package/campaign-spec/dist/rules/schema-version.js +10 -5
  27. package/campaign-spec/dist/types.d.ts +20 -3
  28. package/campaign-spec/dist/types.js +3 -2
  29. package/compatibility.json +2 -2
  30. package/contracts/fixtures/runtime-recipe/accept/current.json +6 -1
  31. package/contracts/fixtures/runtime-recipe/accept/minimal.json +6 -1
  32. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  33. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +6 -1
  34. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +6 -1
  35. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +6 -1
  36. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +6 -1
  37. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +6 -1
  38. package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +1 -1
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +6 -1
  40. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +6 -1
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +6 -1
  42. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +5 -0
  43. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +6 -1
  44. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +6 -1
  45. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +6 -1
  46. package/contracts/release-ledger.json +1146 -0
  47. package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
  48. package/contracts/supported-surface.json +9 -5
  49. package/docs/brand-theme-bridge.md +10 -0
  50. package/docs/build-packet.md +86 -26
  51. package/docs/campaigns-os-build-flow.md +1 -1
  52. package/docs/effects.md +2 -5
  53. package/docs/local-setup.md +1 -1
  54. package/docs/orientation-contract-reference.md +1 -1
  55. package/docs/qa-and-test-orders.md +133 -29
  56. package/docs/runtime-readiness.md +8 -3
  57. package/docs/skills-revision.md +10 -10
  58. package/docs/supported-surface.md +1 -1
  59. package/docs/versioning.md +4 -1
  60. package/docs/workflow-findings-sidecar.md +57 -2
  61. package/package.json +4 -1
  62. package/schemas/campaign-runtime-build-context.v0.schema.json +3 -1
  63. package/schemas/campaign-runtime-build-packet.v0.schema.json +3 -1
  64. package/schemas/campaign-spec.v5.schema.json +586 -0
  65. package/schemas/campaigns-os-run-record.v0.schema.json +20 -15
  66. package/skills/campaign-lifecycle-orientation/SKILL.md +10 -8
  67. package/skills/campaign-readback-classification/SKILL.md +3 -3
  68. package/skills/campaign-run-evidence/SKILL.md +3 -3
  69. package/skills/contribution-intake/SKILL.md +3 -3
  70. package/skills/next-campaigns-build/SKILL.md +6 -4
  71. package/skills/next-campaigns-os/SKILL.md +3 -3
  72. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  73. package/skills/next-campaigns-polish/SKILL.md +21 -6
  74. package/skills/next-campaigns-qa/SKILL.md +5 -4
  75. package/skills.json +10 -10
  76. package/src/adapter-decision-contract.mjs +27 -13
  77. package/src/brand-theme.mjs +44 -8
  78. package/src/build-brief.mjs +14 -5
  79. package/src/campaign-identity.mjs +33 -4
  80. package/src/cli-helpers.mjs +129 -0
  81. package/src/cli.mjs +156 -111
  82. package/src/commercial-journey.mjs +25 -2
  83. package/src/consent.mjs +3 -2
  84. package/src/design-source-package.mjs +1 -1
  85. package/src/design-source-publication.mjs +8 -8
  86. package/src/deviation.mjs +2 -1
  87. package/src/doctor/checks.mjs +212 -19
  88. package/src/doctor/inspect.mjs +4 -2
  89. package/src/doctor/next-step.mjs +46 -16
  90. package/src/doctor-sidecar.mjs +5 -3
  91. package/src/gate-actions.mjs +21 -1
  92. package/src/input-refresh.mjs +6 -6
  93. package/src/invocation.mjs +4 -3
  94. package/src/lifecycle.mjs +88 -6
  95. package/src/local-preview-policy.mjs +116 -12
  96. package/src/login.mjs +2 -1
  97. package/src/polish-browser.mjs +8 -2
  98. package/src/polish-node.mjs +9 -4
  99. package/src/polish-readability.mjs +2 -2
  100. package/src/qa-browser.mjs +741 -120
  101. package/src/qa-cart-entry.mjs +107 -20
  102. package/src/qa-node.mjs +179 -72
  103. package/src/qa-test-order-topology.mjs +118 -1
  104. package/src/qa-verdict-discovery.mjs +21 -4
  105. package/src/qa-verdict-publish.mjs +2 -1
  106. package/src/run-record.mjs +46 -34
  107. package/src/runtime-state-ignore.mjs +7 -5
  108. package/src/sdk-markup.mjs +41 -1
  109. package/src/source-html-intake.mjs +46 -4
  110. package/src/stage-ledger.mjs +18 -4
  111. package/src/stage-record.mjs +53 -16
@@ -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."));
@@ -3,6 +3,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
3
3
  import { basename, extname, join, resolve } from "node:path";
4
4
  import { parse as parseYaml } from "yaml";
5
5
 
6
+ import { CHECKOUT_FLOW_PAGE_TYPES } from "../campaign-spec/dist/index.js";
6
7
  import { cmd } from "./install-invocation.mjs";
7
8
  import { canonicalJson } from "./polish-capture.mjs";
8
9
  import { escapeRegExp } from "./repo-scan.mjs";
@@ -537,7 +538,7 @@ function draftCampaignBuildBrief({ spec, activePages, pageMappings, templateFami
537
538
 
538
539
  const variantSignals = collectVariantSignals(spec, sourceAssetCrawl);
539
540
  const paymentMethods = collectSpecPaymentMethods(spec);
540
- const hasExitPop = activePages?.some((page) => page?.type === "checkout" && page?.exit_intent?.enabled === true) === true;
541
+ const hasExitPop = activePages?.some((page) => isCheckoutFlowPage(page) && page?.exit_intent?.enabled === true) === true;
541
542
  const hasOrderBump = hasOrderBumpSignals(spec);
542
543
  // With no CampaignSpec surface to fill them, the template's promo
543
544
  // placeholders are removed: "none" for both.
@@ -718,8 +719,15 @@ function addQuestion(questions, id, { detail = null, options = [] } = {}) {
718
719
  });
719
720
  }
720
721
 
722
+ // select, checkout_step and checkout all render the SDK checkout surface, so
723
+ // payment-page features (exit pop, promo input) and the conversion goal apply
724
+ // to each of them (campaigns-os#641).
725
+ function isCheckoutFlowPage(page) {
726
+ return CHECKOUT_FLOW_PAGE_TYPES.includes(page?.type);
727
+ }
728
+
721
729
  function inferConversionGoal(activePages = []) {
722
- const hasCheckout = activePages.some((page) => page?.type === "checkout");
730
+ const hasCheckout = activePages.some((page) => isCheckoutFlowPage(page));
723
731
  const hasUpsell = activePages.some((page) => /upsell|downsell/i.test(String(page?.type || "")));
724
732
  if (hasCheckout && hasUpsell) return "direct response funnel with post-purchase offers";
725
733
  if (hasCheckout) return "single-product direct response funnel";
@@ -837,9 +845,10 @@ function templatePromoSurfaces({ spec = null, activePages = [] } = {}) {
837
845
  surfaces.push("promo codes (funnels[].promo_codes)");
838
846
  }
839
847
  const pages = Array.isArray(activePages) ? activePages : [];
840
- // Checkout-only and enabled: true, the rule hasExitPop, doctor's exit-pop
841
- // contract and QA's coupon orders use for these surfaces.
842
- const checkoutSurface = (page, key) => page?.type === "checkout" && page?.[key]?.enabled === true;
848
+ // Checkout-flow pages only (select, checkout_step, checkout) and enabled:
849
+ // true, the rule hasExitPop, doctor's exit-pop contract and QA's coupon
850
+ // orders use for these surfaces.
851
+ const checkoutSurface = (page, key) => isCheckoutFlowPage(page) && page?.[key]?.enabled === true;
843
852
  if (pages.some((page) => checkoutSurface(page, "exit_intent"))) {
844
853
  surfaces.push("an exit-intent offer (exit_intent)");
845
854
  }
@@ -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
  };
@@ -79,6 +79,134 @@ function isLocalAbsolutePath(value) {
79
79
  return isNonEmptyString(value) && !isAbsoluteHttpUrl(value) && isAbsolute(value);
80
80
  }
81
81
 
82
+ // Commit-ready JSON has a finite set of filesystem fields. Route roots and URL
83
+ // paths are deliberately absent: a leading slash does not make one a file.
84
+ function portableArtifactPath(targetRepo, path, { baseDir = targetRepo } = {}) {
85
+ const rel = relative(resolve(baseDir), resolve(path));
86
+ return !rel ? "." : rel.startsWith(".") ? rel : `./${rel}`;
87
+ }
88
+
89
+ const ARTIFACT_PATH_FIELDS = Object.freeze({
90
+ "campaign-runtime-build-packet/v0": [
91
+ "spec.local_path", "design_source_package.path", "source_html.root",
92
+ "source_html.pages.*.path", "source_html.pages.*.page_kit.target_path",
93
+ "source_html.pages.*.page_kit.output_path", "build_brief.input_path",
94
+ "build_brief.normalized_path", "assembly.target_repo", "assembly.output_dir",
95
+ "assembly.commerce_catalog.path",
96
+ ],
97
+ "campaign-runtime-build-context/v0": [
98
+ "packet_path", "report_path", "intake.spec_path", "intake.source_root",
99
+ "intake.target_repo", "intake.brief_path", "intake.design_manifest_path",
100
+ "intake.packet_path", "design_source_package.path", "spec.path", "source.root",
101
+ "build_brief.input_path", "build_brief.normalized_path",
102
+ "page_map.*.source_path", "page_map.*.output_path",
103
+ "page_map.*.page_kit.target_path", "page_map.*.page_kit.output_path",
104
+ "scaffold.target_repo", "scaffold.output_dir", "scaffold.handoff_artifact",
105
+ "theme.source_files.*.path", "theme.generated.css_path", "theme.generated.report_path",
106
+ ],
107
+ "campaign-runtime-assembly-report/v0": [
108
+ "inputs.packet_path", "inputs.context_path", "inputs.build_brief_path",
109
+ "inputs.spec_path", "inputs.source.root", "inputs.target_repo",
110
+ "design_source_package.path", "build_brief.input_path", "build_brief.normalized_path",
111
+ // `**` also reaches each stage's archived entries.
112
+ "theme.css_path", "stages.**.inputs.*", "stages.**.outputs.*",
113
+ ],
114
+ "campaigns-os-doctor-output/v0": [
115
+ "packet_path", "assembly_report_path", "spec_path", "emitted_packet_path",
116
+ "derived.packet_path", "derived.assembly_report_path", "derived.spec_path",
117
+ "derived.target_repo", "derived.source_root", "derived.output_dir", "derived.target_output_dir",
118
+ "errors.*.detail.spec_path", "errors.*.detail.input_path",
119
+ "warnings.*.detail.spec_path", "warnings.*.detail.input_path",
120
+ ],
121
+ "campaigns-os-build-brief/v1": ["_meta.input_path"],
122
+ });
123
+
124
+ const ARTIFACT_COMMAND_FIELDS = Object.freeze({
125
+ "campaign-runtime-build-packet/v0": ["source_html.pages.*.skip_reason"],
126
+ "campaign-runtime-build-context/v0": [
127
+ "decisions.*.decision", "decisions.*.evidence.*",
128
+ "prompts_required.*.message", "page_map.*.skip_reason", "source.manifest_warnings.*",
129
+ ],
130
+ "campaign-runtime-assembly-report/v0": [
131
+ "next.command", "stages.**.commands.*",
132
+ "decisions.*.decision", "decisions.*.evidence.*",
133
+ "**.message", "**.blockers.*", "**.warnings.*",
134
+ ],
135
+ "campaigns-os-doctor-output/v0": [
136
+ "next.command", "next.actions.*", "next_actions.*.command", "**.message", "**.blockers.*", "**.warnings.*",
137
+ ],
138
+ });
139
+
140
+ function fieldMatches(pattern, segments) {
141
+ const parts = pattern.split(".");
142
+ function matches(at, index) {
143
+ if (at === parts.length) return index === segments.length;
144
+ if (parts[at] === "**") return matches(at + 1, index) || (index < segments.length && matches(at, index + 1));
145
+ return index < segments.length && (parts[at] === "*" || parts[at] === segments[index]) && matches(at + 1, index + 1);
146
+ }
147
+ return matches(0, 0);
148
+ }
149
+
150
+ function portableArtifactPaths(value, targetRepo, { artifactPath = null } = {}) {
151
+ const schema = isObject(value) ? value.schema_version : null;
152
+ const pathFields = ARTIFACT_PATH_FIELDS[schema] || [];
153
+ const commandFields = ARTIFACT_COMMAND_FIELDS[schema] || [];
154
+ const artifactDir = artifactPath ? dirname(resolve(artifactPath)) : null;
155
+ const packet = schema === "campaign-runtime-build-packet/v0";
156
+ const context = schema === "campaign-runtime-build-context/v0";
157
+ const report = schema === "campaign-runtime-assembly-report/v0";
158
+ const sourceField = packet ? value.source_html?.root : context ? value.source?.root : report ? value.inputs?.source?.root : null;
159
+ const sourceBase = packet ? artifactDir : targetRepo;
160
+ const sourceRoot = isNonEmptyString(sourceField) && sourceBase ? resolve(sourceBase, sourceField) : null;
161
+ const outputField = packet ? value.assembly?.output_dir : context ? value.scaffold?.output_dir : null;
162
+ const outputBase = targetRepo;
163
+ const outputDir = isNonEmptyString(outputField) && outputBase ? resolve(outputBase, outputField) : null;
164
+ const roots = [...new Set([targetRepo, sourceRoot].filter(Boolean).flatMap((root) => [resolve(root), canonicalPath(root)]))]
165
+ .filter((root) => root !== "/")
166
+ .sort((left, right) => right.length - left.length);
167
+ function fieldBase(segments) {
168
+ const path = segments.join(".");
169
+ if (packet) {
170
+ if (path === "source_html.pages.*.path") return sourceRoot;
171
+ if (path === "source_html.pages.*.page_kit.target_path") return outputDir;
172
+ if (path === "source_html.pages.*.page_kit.output_path") return targetRepo;
173
+ if (path === "assembly.output_dir") return targetRepo;
174
+ return artifactDir;
175
+ }
176
+ if (context) {
177
+ if (path === "page_map.*.source_path") return sourceRoot;
178
+ if (path === "page_map.*.page_kit.target_path") return outputDir;
179
+ if (path === "design_source_package.path") return artifactDir;
180
+ }
181
+ if (report && path === "design_source_package.path") return artifactDir;
182
+ if (schema === "campaigns-os-build-brief/v1") return artifactDir;
183
+ return targetRepo;
184
+ }
185
+ function portableCommand(command) {
186
+ let output = command;
187
+ for (const root of roots) {
188
+ const escaped = root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
189
+ const replacement = portableArtifactPath(targetRepo, root);
190
+ output = output.replace(new RegExp(`(^|[\\s"'\`(=,;\\[<])${escaped}(?=$|[/\\s"'\`.,:;)\\]>])`, "g"),
191
+ (_match, prefix) => `${prefix}${replacement}`);
192
+ }
193
+ return output;
194
+ }
195
+ function visit(entry, segments = []) {
196
+ if (Array.isArray(entry)) return entry.map((item) => visit(item, [...segments, "*"]));
197
+ if (isObject(entry)) return Object.fromEntries(Object.entries(entry).map(([field, item]) => [field, visit(item, [...segments, field])]));
198
+ if (typeof entry !== "string") return entry;
199
+ if (Array.isArray(value) && segments.length === 1 && isLocalAbsolutePath(entry)) return portableArtifactPath(targetRepo, entry);
200
+ if (pathFields.some((pattern) => fieldMatches(pattern, segments)) && isLocalAbsolutePath(entry)) {
201
+ const baseDir = fieldBase(segments);
202
+ return baseDir ? portableArtifactPath(targetRepo, entry, { baseDir }) : entry;
203
+ }
204
+ if (commandFields.some((pattern) => fieldMatches(pattern, segments))) return portableCommand(entry);
205
+ return entry;
206
+ }
207
+ return visit(value);
208
+ }
209
+
82
210
  function resolveFromFile(filePath, targetPath) {
83
211
  if (!isNonEmptyString(targetPath)) return null;
84
212
  if (isAbsoluteHttpUrl(targetPath)) return targetPath;
@@ -111,6 +239,7 @@ export {
111
239
  sha256File,
112
240
  relFromDir,
113
241
  isLocalAbsolutePath,
242
+ portableArtifactPaths,
114
243
  resolveFromFile,
115
244
  filesystemPathsMatch,
116
245
  extractFrontmatterValue,