@nextcommerce/campaigns-os 1.55.0 → 1.59.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 (100) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/CONTEXT.md +11 -0
  3. package/campaign-spec/README.md +15 -1
  4. package/campaign-spec/dist/checkout-flow.d.ts +97 -0
  5. package/campaign-spec/dist/checkout-flow.js +245 -0
  6. package/campaign-spec/dist/index.d.ts +1 -0
  7. package/campaign-spec/dist/index.js +6 -1
  8. package/campaign-spec/dist/normalize.d.ts +3 -2
  9. package/campaign-spec/dist/normalize.js +7 -5
  10. package/campaign-spec/dist/routing.d.ts +6 -3
  11. package/campaign-spec/dist/routing.js +6 -3
  12. package/campaign-spec/dist/rules/checkout-forward-target.d.ts +17 -0
  13. package/campaign-spec/dist/rules/checkout-forward-target.js +48 -0
  14. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.d.ts +12 -0
  15. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.js +37 -0
  16. package/campaign-spec/dist/rules/exit-intent-validation.js +2 -1
  17. package/campaign-spec/dist/rules/index.d.ts +4 -0
  18. package/campaign-spec/dist/rules/index.js +12 -0
  19. package/campaign-spec/dist/rules/one-checkout-per-path.d.ts +25 -0
  20. package/campaign-spec/dist/rules/one-checkout-per-path.js +84 -0
  21. package/campaign-spec/dist/rules/pre-payment-forward-target.d.ts +12 -0
  22. package/campaign-spec/dist/rules/pre-payment-forward-target.js +42 -0
  23. package/campaign-spec/dist/rules/promo-code-input-validation.js +2 -1
  24. package/campaign-spec/dist/rules/schema-version.d.ts +9 -4
  25. package/campaign-spec/dist/rules/schema-version.js +10 -5
  26. package/campaign-spec/dist/types.d.ts +20 -3
  27. package/campaign-spec/dist/types.js +3 -2
  28. package/compatibility.json +2 -2
  29. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  30. package/contracts/commerce-surface-catalog.json +26 -27
  31. package/contracts/effects.v1.json +5 -5
  32. package/contracts/fixtures/runtime-recipe/accept/current.json +6 -1
  33. package/contracts/fixtures/runtime-recipe/accept/minimal.json +6 -1
  34. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  35. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +6 -1
  36. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +6 -1
  37. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +6 -1
  38. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +6 -1
  39. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +6 -1
  40. package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +1 -1
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +6 -1
  42. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +6 -1
  43. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +6 -1
  44. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +5 -0
  45. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +6 -1
  46. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +6 -1
  47. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +6 -1
  48. package/contracts/release-ledger.json +1137 -0
  49. package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
  50. package/contracts/supported-surface.json +9 -5
  51. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  52. package/docs/build-packet.md +68 -10
  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 +124 -22
  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/package.json +2 -1
  61. package/schemas/campaign-runtime-build-context.v0.schema.json +4 -1
  62. package/schemas/campaign-runtime-build-packet.v0.schema.json +4 -1
  63. package/schemas/campaign-spec.v5.schema.json +586 -0
  64. package/skills/campaign-lifecycle-orientation/SKILL.md +10 -8
  65. package/skills/campaign-readback-classification/SKILL.md +3 -3
  66. package/skills/campaign-run-evidence/SKILL.md +3 -3
  67. package/skills/contribution-intake/SKILL.md +3 -3
  68. package/skills/next-campaigns-build/SKILL.md +6 -4
  69. package/skills/next-campaigns-os/SKILL.md +3 -3
  70. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  71. package/skills/next-campaigns-polish/SKILL.md +21 -6
  72. package/skills/next-campaigns-qa/SKILL.md +4 -3
  73. package/skills.json +10 -10
  74. package/src/adapter-decision-contract.mjs +1 -1
  75. package/src/build-brief.mjs +14 -5
  76. package/src/built-site-scope.mjs +91 -0
  77. package/src/cli-helpers.mjs +129 -0
  78. package/src/cli.mjs +117 -57
  79. package/src/commercial-journey.mjs +25 -2
  80. package/src/design-source-package.mjs +1 -1
  81. package/src/design-source-publication.mjs +7 -7
  82. package/src/doctor/checks.mjs +269 -16
  83. package/src/doctor/inspect.mjs +4 -2
  84. package/src/doctor/next-step.mjs +18 -9
  85. package/src/doctor-sidecar.mjs +5 -3
  86. package/src/gate-actions.mjs +21 -1
  87. package/src/input-refresh.mjs +6 -6
  88. package/src/local-preview-policy.mjs +110 -12
  89. package/src/polish-node.mjs +8 -7
  90. package/src/qa-browser.mjs +363 -36
  91. package/src/qa-cart-entry.mjs +183 -14
  92. package/src/qa-node.mjs +38 -34
  93. package/src/qa-test-order-topology.mjs +118 -1
  94. package/src/qa-verdict-discovery.mjs +21 -4
  95. package/src/qa-verdict.mjs +24 -1
  96. package/src/runtime-state-ignore.mjs +7 -5
  97. package/src/sdk-attribute-index.mjs +8 -7
  98. package/src/source-html-intake.mjs +58 -7
  99. package/src/stage-ledger.mjs +4 -2
  100. package/src/stage-record.mjs +203 -36
@@ -76,7 +76,7 @@ import {
76
76
  resolveTemplateBrandContract,
77
77
  } from "../private-template-source.mjs";
78
78
  import { assessTemplateFreshness, defaultSdkSupportPolicy, renderTemplateFreshness } from "../template-freshness.mjs";
79
- import { computeBuildFingerprint, resolveBuiltSiteScope } from "../built-site-scope.mjs";
79
+ import { BUILD_DRIFT_PATH_LIMIT, computeBuildFingerprint, describeBuildOutputDrift, resolveBuiltSiteScope } from "../built-site-scope.mjs";
80
80
  import {
81
81
  UPSELL_SELECTOR_SCOPE,
82
82
  builtPageTypeOverRouteGuess,
@@ -93,6 +93,7 @@ import { FIGMA_EXPORT_FILE_CODES, SOURCE_PROVENANCE_SCOPE, evaluateSourceProvena
93
93
  import { validateCampaignBuildBriefArtifact } from "../build-brief.mjs";
94
94
  import { briefFileUnusable, deriveInputCurrency, wellFormedBriefMaterial } from "../input-currency.mjs";
95
95
  import { ASSEMBLY_REPORT_STAGE_KEYS, stageIsTerminal } from "../orchestration-stage-contract.mjs";
96
+ import { assemblyReportStagesWithEvidence } from "../design-source-publication.mjs";
96
97
  import {
97
98
  assemblySourcePackageFingerprintMissing,
98
99
  assessAssemblySourcePackageFreshnessWaivers,
@@ -108,7 +109,7 @@ import { evaluatePageKitSdkVersion, PAGE_KIT_SDK_VERSION_SCOPE } from "../page-k
108
109
  // `npm run build:spec` (tsc -> campaign-spec/dist) so the package runs on the
109
110
  // node engine in package.json without type-stripping. build runs on `prepare`,
110
111
  // so a fresh install (including the git-ref consumer) always has dist.
111
- import { isReleasedSdkVersion, normalize as normalizeCampaignSpec, runRules, specOnlyRules } from "../../campaign-spec/dist/index.js";
112
+ import { CHECKOUT_FLOW_PAGE_TYPES, checkoutPathFrom, isReleasedSdkVersion, normalize as normalizeCampaignSpec, runRules, specOnlyRules, upgradeCampaignSpec } from "../../campaign-spec/dist/index.js";
112
113
  import { cmd, asInvocation } from "../install-invocation.mjs";
113
114
  import { specHashesMatch, specMaterialHash } from "../spec-identity.mjs";
114
115
  import {
@@ -237,8 +238,12 @@ const US_MARKET_COPY_PATTERNS = [
237
238
  const HARDCODED_CURRENCY_REGEX = /\$\s?\d[\d,]*(?:\.\d+)?(?:\/[A-Za-z]+)?/g;
238
239
  const HARDCODED_PHONE_REGEX = /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g;
239
240
 
241
+ // Pages read through here see the v5 vocabulary: a v4 spec's `checkout → checkout`
242
+ // chain is retyped `checkout_step` on read (campaigns-os#641), so every doctor
243
+ // check asks "is this the page that takes payment" of `type === "checkout"`.
240
244
  function normalizeFunnels(spec) {
241
- if (Array.isArray(spec?.funnels)) return spec.funnels;
245
+ const upgraded = upgradeCampaignSpec(spec);
246
+ if (Array.isArray(upgraded?.funnels)) return upgraded.funnels;
242
247
  if (Array.isArray(spec?.funnel_pages)) {
243
248
  return [{ id: "default", weight: 100, pages: spec.funnel_pages }];
244
249
  }
@@ -542,6 +547,11 @@ function validatePacket(packet, packetPath, errors, warnings, ready, derived, bu
542
547
  addIssue(errors, "packet.type", "Build Packet must be a JSON object.");
543
548
  return;
544
549
  }
550
+ Object.defineProperty(derived, "page_kit_entry_mappings", {
551
+ value: Object.fromEntries((packet.source_html?.pages || [])
552
+ .filter((page) => page?.page_kit?.target_path).map((page) => [page.page_id, page])),
553
+ configurable: true,
554
+ });
545
555
  const synthesizedBuiltSite = isSynthesizedBuiltSitePacket(packet);
546
556
  if (packet.schema_version !== PACKET_SCHEMA) addIssue(errors, "schema_version", `Expected ${PACKET_SCHEMA}.`);
547
557
  else ready.push(`Build Packet schema ${PACKET_SCHEMA}`);
@@ -1201,17 +1211,20 @@ function paymentLogoForcedOn(html, method) {
1201
1211
  // unless the checkout page passes show_<method>=false.
1202
1212
  export const STARTER_TEMPLATE_DEFAULT_ON_PAYMENT_METHODS = Object.freeze(["paypal", "klarna", "apple_pay", "google_pay"]);
1203
1213
 
1204
- // Built checkout pages on disk for the spec's active checkout pages: the
1205
- // rendered _site/<slug>/<route>/index.html files that exist. Empty before a
1206
- // build (or when the spec declares no checkout page), which is the pre-build
1207
- // state the spec-only advisory covers.
1214
+ // Built checkout-flow pages on disk for the spec's active select, checkout_step
1215
+ // and checkout pages: the rendered _site/<slug>/<route>/index.html files that
1216
+ // exist. Steps and select pages are included on purpose: they carry express
1217
+ // payment buttons upstream of the Checkout (campaigns-os#641), and an
1218
+ // unsupported method rendered there ships just the same. Empty before a build
1219
+ // (or when the spec declares no checkout page), which is the pre-build state
1220
+ // the spec-only advisory covers.
1208
1221
  function builtCheckoutPagesForSpec(spec, packet, derived = {}) {
1209
1222
  const targetRepo = derived?.target_repo;
1210
1223
  const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
1211
1224
  if (!targetRepo || !publicRouteSlug) return [];
1212
1225
  const built = [];
1213
1226
  for (const page of activeSpecPages(spec)) {
1214
- if (String(page?.type || page?.page_type || "").toLowerCase().trim() !== "checkout") continue;
1227
+ if (!CHECKOUT_FLOW_PAGE_TYPES.includes(String(page?.type || page?.page_type || "").toLowerCase().trim())) continue;
1215
1228
  const path = builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived);
1216
1229
  if (!path || !existsSync(path) || !statSync(path).isFile()) continue;
1217
1230
  built.push({ page_id: page.id, path, file: relative(targetRepo, path).split(sep).join("/") });
@@ -1882,12 +1895,33 @@ export function validateBuildOutputFingerprint(packet, errors, warnings, ready,
1882
1895
  );
1883
1896
  return;
1884
1897
  }
1898
+ const drift = describeBuildOutputDrift(buildState.report?.stages?.assembly, current);
1899
+ const capped = (paths) => paths.slice(0, BUILD_DRIFT_PATH_LIMIT);
1885
1900
  addIssue(
1886
1901
  assemblyComplete ? errors : warnings,
1887
1902
  "built_output.fingerprint_stale",
1888
1903
  `Built output under _site/${publicRouteSlug}/ no longer matches stages.assembly.build_fingerprint (recorded ${recorded}, current ${current.fingerprint}, ${current.file_count} file(s)). `
1904
+ + `${drift.summary} `
1889
1905
  + "The output changed after build recorded it; re-run build (page-kit build, then record the current fingerprint) before polish or QA evidence can bind to it.",
1890
- { root: `_site/${publicRouteSlug}/`, recorded, current: current.fingerprint, file_count: current.file_count, assembly_complete: assemblyComplete }
1906
+ {
1907
+ root: `_site/${publicRouteSlug}/`,
1908
+ recorded,
1909
+ current: current.fingerprint,
1910
+ file_count: current.file_count,
1911
+ assembly_complete: assemblyComplete,
1912
+ drift: drift.manifest === "recorded"
1913
+ ? {
1914
+ manifest: "recorded",
1915
+ extra_count: drift.extra.length,
1916
+ missing_count: drift.missing.length,
1917
+ changed_count: drift.changed.length,
1918
+ extra: capped(drift.extra),
1919
+ missing: capped(drift.missing),
1920
+ changed: capped(drift.changed),
1921
+ conflict_copies: capped(drift.conflict_copies),
1922
+ }
1923
+ : { manifest: "unavailable" },
1924
+ }
1891
1925
  );
1892
1926
  }
1893
1927
 
@@ -2815,7 +2849,7 @@ function bumpDisplaysPresent(block, displays) {
2815
2849
  return displays.some((name) => new RegExp(`data-next-toggle-display\\s*=\\s*["']${name}["']`, "i").test(block));
2816
2850
  }
2817
2851
 
2818
- const CHECKOUT_BUMP_PAGE_TYPES = new Set(["checkout", "select"]);
2852
+ const CHECKOUT_BUMP_PAGE_TYPES = new Set(CHECKOUT_FLOW_PAGE_TYPES);
2819
2853
 
2820
2854
  export function validateBuiltBumpPricing(content, builtPath, targetRepo, page, issueTarget) {
2821
2855
  const type = String(page?.type || page?.page_type || "").toLowerCase().trim();
@@ -3000,6 +3034,11 @@ function validateBuiltCommerceRefs(content, builtPath, targetRepo, page, spec, i
3000
3034
 
3001
3035
  function builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived = {}) {
3002
3036
  if (!targetRepo || !publicRouteSlug) return null;
3037
+ const entryRoute = entryPageKitServingRoute(targetRepo, publicRouteSlug, page, {
3038
+ targetOutputDir: derived?.target_output_dir,
3039
+ mapping: derived?.page_kit_entry_mappings?.[page?.id],
3040
+ });
3041
+ if (entryRoute) return join(targetRepo, "_site", publicRouteSlug, ...(entryRoute.route ? [entryRoute.route] : []), "index.html");
3003
3042
  const sourcePermalink = sourcePermalinkForPage(derived?.target_output_dir, publicRouteSlug, page);
3004
3043
  const route = sourcePermalink || runtimeRelativeRouteForSpecValue(publicRouteForPage(page), publicRouteSlug);
3005
3044
  if (!route) return join(targetRepo, "_site", publicRouteSlug, "index.html");
@@ -3007,6 +3046,63 @@ function builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived = {}) {
3007
3046
  return clean ? join(targetRepo, "_site", publicRouteSlug, clean, "index.html") : join(targetRepo, "_site", publicRouteSlug, "index.html");
3008
3047
  }
3009
3048
 
3049
+ export function isRootRoutedEntryPage(page, publicRouteSlug) {
3050
+ return Boolean(page?.is_entry) && runtimeRelativeRouteForSpecValue(publicRouteForPage(page), publicRouteSlug) === "";
3051
+ }
3052
+
3053
+ // An explicit permalink belongs to this entry's Page Kit source file. It wins
3054
+ // even when an older build left output at the filename-derived route.
3055
+ export function entryPageKitServingRoute(targetRepo, publicRouteSlug, page, { targetOutputDir = null, mapping = null } = {}) {
3056
+ if (!targetRepo || !publicRouteSlug || !isRootRoutedEntryPage(page, publicRouteSlug)) return null;
3057
+ const permalink = entrySourcePermalink(targetOutputDir, publicRouteSlug, page, mapping);
3058
+ if (permalink !== null) return { route: permalink, source: "permalink" };
3059
+ const root = join(targetRepo, "_site", publicRouteSlug, "index.html");
3060
+ if (existsSync(root) && statSync(root).isFile()) return { route: "", source: "root" };
3061
+ const fileRoute = entryPageKitFileRoute(targetRepo, publicRouteSlug, page, { targetOutputDir, mapping });
3062
+ return fileRoute ? { route: `${fileRoute}/`, source: "file" } : { route: "", source: "declared" };
3063
+ }
3064
+
3065
+ function entrySourcePermalink(targetOutputDir, publicRouteSlug, page, mapping) {
3066
+ if (!targetOutputDir || !existsSync(targetOutputDir) || !statSync(targetOutputDir).isDirectory()) return null;
3067
+ const names = mapping?.page_kit?.target_path
3068
+ ? [mapping.page_kit.target_path]
3069
+ : [...new Set([page.id, page.type].filter(isNonEmptyString).map((name) => `${name}.html`))];
3070
+ const routes = names
3071
+ .filter((name) => /^[a-zA-Z0-9][a-zA-Z0-9_-]*\.html$/.test(name))
3072
+ .map((name) => join(targetOutputDir, name))
3073
+ .filter((path) => existsSync(path) && statSync(path).isFile())
3074
+ .map((path) => extractFrontmatterValue(readFileSync(path, "utf8"), "permalink"))
3075
+ .filter(isNonEmptyString)
3076
+ .map((value) => stripPublicRoutePrefix(normalizePageKitRoute(value), publicRouteSlug));
3077
+ return routes[0] ?? null;
3078
+ }
3079
+
3080
+ // Page Kit serves a page file without a permalink at its basename route.
3081
+ // Only the recorded target file (or the family's own materialised stock file)
3082
+ // may identify an entry fallback; an unrelated built index is not evidence.
3083
+ export function entryPageKitFileRoute(targetRepo, publicRouteSlug, page, { targetOutputDir = null, mapping = null } = {}) {
3084
+ if (!targetRepo || !publicRouteSlug || !isRootRoutedEntryPage(page, publicRouteSlug)) return null;
3085
+ if (entrySourcePermalink(targetOutputDir, publicRouteSlug, page, mapping) !== null) return null;
3086
+ const root = join(targetRepo, "_site", publicRouteSlug, "index.html");
3087
+ if (existsSync(root) && statSync(root).isFile()) return null;
3088
+ let names;
3089
+ if (mapping?.page_kit?.target_path) {
3090
+ names = [mapping.page_kit.target_path];
3091
+ } else {
3092
+ if (!targetOutputDir) return null;
3093
+ names = [...new Set([page.id, page.type].filter(isNonEmptyString).map((name) => `${name}.html`))];
3094
+ }
3095
+ const routes = names
3096
+ .filter((name) => /^[a-zA-Z0-9][a-zA-Z0-9_-]*\.html$/.test(name) && name !== "index.html")
3097
+ .filter((name) => mapping?.page_kit || (existsSync(join(targetOutputDir, name)) && statSync(join(targetOutputDir, name)).isFile()))
3098
+ .map((name) => name.slice(0, -".html".length))
3099
+ .filter((route) => {
3100
+ const built = join(targetRepo, "_site", publicRouteSlug, route, "index.html");
3101
+ return existsSync(built) && statSync(built).isFile();
3102
+ });
3103
+ return routes[0] ?? null;
3104
+ }
3105
+
3010
3106
  function sourcePermalinkForPage(targetOutputDir, publicRouteSlug, page) {
3011
3107
  if (!targetOutputDir || !existsSync(targetOutputDir) || !statSync(targetOutputDir).isDirectory()) return null;
3012
3108
 
@@ -3267,7 +3363,7 @@ function collectHardcodedPhoneMatches(scanRoots, storePhone) {
3267
3363
  const matches = [];
3268
3364
  for (const { label: surface, root } of scanRoots) {
3269
3365
  for (const file of collectHtmlFiles(root)) {
3270
- const content = maskMarketLintIgnoredRegions(readHtmlScanText(join(root, file.path)));
3366
+ const content = maskNonRenderedMarkup(maskMarketLintIgnoredRegions(readHtmlScanText(join(root, file.path))));
3271
3367
  for (const match of content.matchAll(HARDCODED_PHONE_REGEX)) {
3272
3368
  const found = normalizePhoneNumber(match[0]);
3273
3369
  if (!found || found === expected) continue;
@@ -3295,6 +3391,18 @@ function maskMarketLintIgnoredRegions(content) {
3295
3391
  .replace(ignoredTag, preserveNewlinesMask);
3296
3392
  }
3297
3393
 
3394
+ // Markup a shopper never reads: HTML comments, <style> blocks, and CSS
3395
+ // comments (in style attributes too). Design exports leave long digit runs
3396
+ // there, such as a Figma frame name "Group 1000003339", which the phone
3397
+ // pattern would otherwise read as a number (campaigns-os#660). Masking keeps
3398
+ // offsets and newlines, so reported line numbers stay true.
3399
+ function maskNonRenderedMarkup(content) {
3400
+ return content
3401
+ .replace(/<!--[\s\S]*?-->/g, preserveNewlinesMask)
3402
+ .replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/gi, preserveNewlinesMask)
3403
+ .replace(/\/\*[\s\S]*?\*\//g, preserveNewlinesMask);
3404
+ }
3405
+
3298
3406
  function preserveNewlinesMask(value) {
3299
3407
  return value.replace(/[^\n]/g, " ");
3300
3408
  }
@@ -3446,7 +3554,7 @@ function validateSpecPublicRoutes(spec, errors, ready) {
3446
3554
  }
3447
3555
 
3448
3556
  function pageRole(type) {
3449
- if (["checkout", "upsell", "downsell", "thankyou", "receipt", "select"].includes(type)) return "runtime";
3557
+ if (["checkout", "checkout_step", "upsell", "downsell", "thankyou", "receipt", "select"].includes(type)) return "runtime";
3450
3558
  return "visual";
3451
3559
  }
3452
3560
 
@@ -3550,6 +3658,7 @@ function templateStockDecision(buildState, pageId) {
3550
3658
  function validateSourceCoverage(packet, packetPath, spec, errors, warnings, ready, derived = {}, buildState = {}) {
3551
3659
  const pages = packet.source_html?.pages || [];
3552
3660
  const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
3661
+ const routeRoot = campaignRouteRoot(packet);
3553
3662
  const materialisedTemplateStock = [];
3554
3663
  const sourceRoot = resolveFromFile(packetPath, packet.source_html?.root);
3555
3664
  validateSourceHtmlManifestAtRoot(sourceRoot, {
@@ -3577,6 +3686,20 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
3577
3686
  }
3578
3687
  mappedIds.add(page.page_id);
3579
3688
  const specPage = activeById.get(page.page_id);
3689
+ const entryRoute = specPage
3690
+ ? entryPageKitServingRoute(derived.target_repo, publicRouteSlug, specPage, {
3691
+ targetOutputDir: derived.target_output_dir,
3692
+ mapping: page,
3693
+ })
3694
+ : null;
3695
+ const builtRoute = entryRoute ? entryRoute.route : specPage ? publicRouteForPage(specPage) : null;
3696
+ const rootOutput = entryRoute?.route ? join(derived.target_repo, "_site", publicRouteSlug, "index.html") : null;
3697
+ if (rootOutput && (entryRoute.source === "file" || entryRoute.source === "permalink")
3698
+ && !(existsSync(rootOutput) && statSync(rootOutput).isFile())) {
3699
+ const emittedRoute = `${routeRoot}${entryRoute.route}`;
3700
+ const pageKitRoot = `/${normalizePublicRouteSlug(publicRouteSlug)}/`;
3701
+ addIssue(warnings, "entry_route.unserved", `CampaignSpec entry page "${specPage.id}" is routed to ${routeRoot}, but Page Kit emitted it at ${emittedRoute} and nothing serves ${routeRoot}. Add a redirect from ${routeRoot} to ${emittedRoute}, or set \`permalink: ${pageKitRoot}\` (the campaign root as Page Kit emits it) on the entry page.`, { page_id: specPage.id, root_route: routeRoot, emitted_route: emittedRoute });
3702
+ }
3580
3703
  if (!activeIds.has(page.page_id)) {
3581
3704
  addIssue(warnings, "source_html.pages.extra", `Source mapping "${page.page_id}" is not an active CampaignSpec page.`);
3582
3705
  }
@@ -3611,7 +3734,7 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
3611
3734
  page_id: specPage.id,
3612
3735
  type: specPage.type || "page",
3613
3736
  role: pageRole(specPage.type),
3614
- route: publicRouteForPage(specPage),
3737
+ route: builtRoute,
3615
3738
  source_path: page.path,
3616
3739
  });
3617
3740
  }
@@ -3627,7 +3750,7 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
3627
3750
  page_id: specPage.id,
3628
3751
  type: specPage.type || "page",
3629
3752
  role: pageRole(specPage.type),
3630
- route: publicRouteForPage(specPage),
3753
+ route: builtRoute,
3631
3754
  source_path: null,
3632
3755
  template_stock: true,
3633
3756
  template_family: family,
@@ -3956,6 +4079,134 @@ function packageRefsFromEntries(entries) {
3956
4079
  .filter((value) => value !== undefined && value !== null && String(value).trim().length > 0)
3957
4080
  .map((value) => String(value));
3958
4081
  }
4082
+
4083
+ function supportsCheckoutVariantSlots(agentContract) {
4084
+ const required = frontmatterList(agentContract, "requiredWhenCloning");
4085
+ return required.includes("variant_slots[].id") && required.includes("variant_slots[].quantity");
4086
+ }
4087
+
4088
+ function selectableVariantMatrixCount(packages) {
4089
+ const selectable = Array.isArray(packages)
4090
+ ? packages.filter((pkg) => pkg && pkg.is_order_bump !== true && pkg.is_upsell !== true)
4091
+ : [];
4092
+ const quantityRows = selectable.filter((pkg) => String(pkg.qty ?? "").trim());
4093
+ if (quantityRows.length <= 1) return 0;
4094
+ const attributeIdentity = (pkg) => {
4095
+ if (!Array.isArray(pkg.variant_attributes) || pkg.variant_attributes.length === 0) return null;
4096
+ const attributes = pkg.variant_attributes.map((attribute) => {
4097
+ const value = String(attribute?.value ?? "").trim();
4098
+ if (!value) return null;
4099
+ return [String(attribute?.code || attribute?.name || "").trim(), value];
4100
+ });
4101
+ return attributes.every(Boolean) ? JSON.stringify(attributes.sort(([a], [b]) => a.localeCompare(b))) : null;
4102
+ };
4103
+ const textIdentity = (key) => (pkg) => {
4104
+ const value = pkg[key];
4105
+ return typeof value === "string" && value.trim() ? value.trim() : null;
4106
+ };
4107
+ for (const identity of [attributeIdentity, textIdentity("product_variant_name")]) {
4108
+ const values = quantityRows.map(identity);
4109
+ if (!values.every(Boolean)) continue;
4110
+ const quantitiesByVariant = new Map();
4111
+ for (const [index, value] of values.entries()) {
4112
+ const qty = String(quantityRows[index].qty).trim();
4113
+ if (!quantitiesByVariant.has(value)) quantitiesByVariant.set(value, new Set());
4114
+ quantitiesByVariant.get(value).add(qty);
4115
+ }
4116
+ return quantitiesByVariant.size > 1 && [...quantitiesByVariant.values()].some((quantities) => quantities.size > 1)
4117
+ ? quantitiesByVariant.size
4118
+ : 0;
4119
+ }
4120
+ return 0;
4121
+ }
4122
+
4123
+ function warnCheckoutPackageFamilyFit(specPages, family, catalog, warnings, report) {
4124
+ const selectedContract = catalog.families?.[family]?.agentContract;
4125
+ if (
4126
+ !selectedContract ||
4127
+ !isCertifiedTemplateFamily(family, catalog) ||
4128
+ !frontmatterList(selectedContract, "requiredWhenCloning").includes("packages.main_package") ||
4129
+ supportsCheckoutVariantSlots(selectedContract)
4130
+ ) return;
4131
+ const fittingFamilies = [...certifiedTemplateFamilies(catalog)]
4132
+ .filter((candidate) => supportsCheckoutVariantSlots(catalog.families[candidate]?.agentContract))
4133
+ .sort();
4134
+ const checkoutPages = specPages.filter((page) => CHECKOUT_FLOW_PAGE_TYPES.includes(page.type));
4135
+ const byFunnel = new Map();
4136
+ for (const page of checkoutPages) {
4137
+ if (!byFunnel.has(page.funnel_id)) byFunnel.set(page.funnel_id, []);
4138
+ byFunnel.get(page.funnel_id).push(page);
4139
+ }
4140
+ const paths = new Map();
4141
+ for (const page of checkoutPages) {
4142
+ const pages = byFunnel.get(page.funnel_id);
4143
+ const path = checkoutPathFrom(pages, page) || [page];
4144
+ const end = path[path.length - 1];
4145
+ const key = `${page.funnel_id}:${end.id}`;
4146
+ if (!paths.has(key)) paths.set(key, []);
4147
+ const pathPages = paths.get(key);
4148
+ for (const candidate of path) {
4149
+ if (!pathPages.includes(candidate)) pathPages.push(candidate);
4150
+ }
4151
+ }
4152
+ const hasStageEvidence = assemblyReportStagesWithEvidence(report).length > 0;
4153
+ const multiStep = [...paths.values()].some((pathPages) =>
4154
+ pathPages.some((candidate) => candidate.type === "checkout_step" || candidate.type === "select"));
4155
+ for (const pathPages of paths.values()) {
4156
+ const page = pathPages.find((candidate) => selectableVariantMatrixCount(candidate.packages) > 1);
4157
+ if (!page) continue;
4158
+ const variantCount = selectableVariantMatrixCount(page.packages);
4159
+ const shapeMatches = fittingFamilies.filter((candidate) => Boolean(catalog.families[candidate]?.canonicalSurfaces?.selectStep) === multiStep);
4160
+ // The catalog has no base-family field; its family-name prefix is the shared-base convention.
4161
+ const baseMatches = shapeMatches.filter((candidate) => candidate.startsWith(`${family}-`));
4162
+ const ranked = [...baseMatches, ...shapeMatches.filter((candidate) => !baseMatches.includes(candidate))];
4163
+ const action = hasStageEvidence
4164
+ ? "The template family is a build-time decision; changing it requires re-running intake with --force (destructive; clears recorded stage evidence)."
4165
+ : "The family is chosen at intake with --template-family.";
4166
+ addIssue(
4167
+ warnings,
4168
+ "template_contract.checkout_package_fit",
4169
+ `Checkout path through page "${page.id}" has ${variantCount} variants, some offered at several quantities, but template family "${family}" has no configurable variant_slots[] checkout surface. ${ranked.length ? `Families that can present it: ${ranked.join(", ")}.` : "No certified family has a matching configurable variant-slot checkout surface."} ${action}`,
4170
+ { page_id: page.id, funnel_id: page.funnel_id, template_family: family, distinct_product_variants: variantCount, fitting_families: ranked },
4171
+ );
4172
+ }
4173
+ }
4174
+ // A family that puts package selection on its own `select` page (the catalog
4175
+ // names a selectStep surface) and carries no selector on checkout (no
4176
+ // mainSelector surface) can only fill the cart from a declared select page.
4177
+ // Without one on the path into a Checkout, QA resolves the landing as the
4178
+ // entry page, finds no add-to-cart control, and every order path fails after
4179
+ // the whole build (campaigns-os#658). Families whose checkout carries its own
4180
+ // selector, and families that fill the cart on the landing page, are not
4181
+ // affected.
4182
+ function warnMissingSelectStep(specPages, family, catalog, warnings) {
4183
+ const surfaces = catalog.families?.[family]?.canonicalSurfaces;
4184
+ if (!surfaces?.selectStep || surfaces.mainSelector) return;
4185
+ const byFunnel = new Map();
4186
+ for (const page of specPages) {
4187
+ if (!byFunnel.has(page.funnel_id)) byFunnel.set(page.funnel_id, []);
4188
+ byFunnel.get(page.funnel_id).push(page);
4189
+ }
4190
+ for (const [funnelId, pages] of byFunnel) {
4191
+ const reachedFromSelect = new Set();
4192
+ for (const select of pages.filter((page) => page.type === "select")) {
4193
+ const path = checkoutPathFrom(pages, select);
4194
+ if (path) reachedFromSelect.add(path[path.length - 1]);
4195
+ }
4196
+ for (const checkout of pages.filter((page) => page.type === "checkout")) {
4197
+ if (reachedFromSelect.has(checkout)) continue;
4198
+ // A split-test path built on another family is judged by that family.
4199
+ const pageFamily = checkout.sdk_hints?.template_family;
4200
+ if (isNonEmptyString(pageFamily) && pageFamily !== family) continue;
4201
+ addIssue(
4202
+ warnings,
4203
+ "template_contract.select_step_missing",
4204
+ `Template family "${family}" puts package selection on a select page, and its checkout carries no selector, but no select page leads to Checkout "${checkout.id}" in funnel "${funnelId}". QA will find no add-to-cart control on the entry page and every order path will fail. Fix: add a Select page between the entry page and checkout in Map Builder.`,
4205
+ { funnel_id: funnelId, checkout_page_id: checkout.id, template_family: family },
4206
+ );
4207
+ }
4208
+ }
4209
+ }
3959
4210
  function offerRefsFromEntries(entries) {
3960
4211
  if (!Array.isArray(entries)) return [];
3961
4212
  return entries
@@ -4140,6 +4391,8 @@ export function validateCommerceCatalog(packet, packetPath, spec, errors, warnin
4140
4391
 
4141
4392
  // ADR-003 step 2: template-contract checks ported from the private doctor.
4142
4393
  const specPages = activeSpecPages(spec);
4394
+ warnCheckoutPackageFamilyFit(specPages, family, catalog, warnings, buildState.report);
4395
+ warnMissingSelectStep(specPages, family, catalog, warnings);
4143
4396
 
4144
4397
  const mismatchedFamilies = specPages.filter(
4145
4398
  (page) => isNonEmptyString(page.sdk_hints?.template_family) && page.sdk_hints.template_family !== family
@@ -4153,7 +4406,7 @@ export function validateCommerceCatalog(packet, packetPath, spec, errors, warnin
4153
4406
  }
4154
4407
 
4155
4408
  const checkoutPackageRefs = specPages
4156
- .filter((page) => page.type === "checkout" || page.type === "select")
4409
+ .filter((page) => CHECKOUT_FLOW_PAGE_TYPES.includes(page.type))
4157
4410
  .flatMap((page) => packageRefsFromEntries(page.packages));
4158
4411
  if (familyAutomatable && contractMentions(contract, /\b(packages\.main_package|single_offer\.package_id|variant_slots)\b/) && checkoutPackageRefs.length === 0) {
4159
4412
  addIssue(
@@ -4268,7 +4521,7 @@ export function validateExitPopContract(contract, spec, family, warnings, ready,
4268
4521
  const exitPop = contract?.exit_pop;
4269
4522
  if (!exitPop || !spec) return;
4270
4523
  const hasGovernedOfferSurface = activeSpecPages(spec).some((page) => (
4271
- page?.type === "checkout" && (page?.exit_intent?.enabled === true || page?.promo_code_input?.enabled === true)
4524
+ CHECKOUT_FLOW_PAGE_TYPES.includes(page?.type) && (page?.exit_intent?.enabled === true || page?.promo_code_input?.enabled === true)
4272
4525
  ));
4273
4526
  if (hasGovernedOfferSurface) {
4274
4527
  ready.push(`${family} exit-pop/promo-code behavior is governed by CampaignSpec offer-surface fields`);
@@ -34,6 +34,7 @@ import {
34
34
  readJsonIfExists,
35
35
  relFromDir,
36
36
  isLocalAbsolutePath,
37
+ portableArtifactPaths,
37
38
  addIssue,
38
39
  } from "../cli-helpers.mjs";
39
40
  import {
@@ -187,18 +188,19 @@ export function doctorCommand(args, { runDoctor = doctorPacket, liveCampaign = u
187
188
  commitAssemblyReport(workspace, (report) => recordDoctorStageOutcome(report, result, {
188
189
  command: `campaigns-os ${DOCTOR_PRODUCER}`,
189
190
  doctorOutPath: workspace.doctorOutPath,
191
+ targetRepo: workspace.targetRepo,
190
192
  }), { stage: "doctor", command: DOCTOR_PRODUCER, refreshDoctor: () => result });
191
193
  }
192
194
  return result;
193
195
  }
194
196
 
195
- function recordDoctorStageOutcome(report, result, { command, doctorOutPath }) {
197
+ function recordDoctorStageOutcome(report, result, { command, doctorOutPath, targetRepo }) {
196
198
  return recordProducerStageOutcome(report, {
197
199
  stage: "doctor",
198
200
  disposition: result.ok ? (result.warnings?.length ? "ready_with_warnings" : "ready") : "blocked",
199
201
  timestamp: result.generated_at,
200
202
  command,
201
- outputs: [doctorOutPath],
203
+ outputs: portableArtifactPaths([doctorOutPath], targetRepo),
202
204
  blockers: (result.errors || []).map((issue) => issue?.message).filter(isNonEmptyString),
203
205
  warnings: (result.warnings || []).map((issue) => issue?.message).filter(isNonEmptyString),
204
206
  });
@@ -1,6 +1,6 @@
1
1
  // The next step doctor recommends, and the gate issues `next` reads from doctor.
2
2
  import { campaignIdentitiesMatch } from "../spec-source-identity.mjs";
3
- import { polishCarriedForwardForLadder } from "../local-preview-policy.mjs";
3
+ import { NO_CAPTURABLE_ROUTES_CODE, hostedTemplateNeedsUsablePreview, polishCarriedForwardForLadder, polishGateCanCarryPreview } from "../local-preview-policy.mjs";
4
4
  import { resolve } from "node:path";
5
5
  import { orderPathDepthDriftText } from "../proof-policy.mjs";
6
6
  import { anyAssemblyReportStageBlocked, qaRecordedBuildFingerprint, qaRecordedForCurrentBuild } from "../stage-ledger.mjs";
@@ -19,6 +19,7 @@ import {
19
19
  import { currentBuildFingerprint, evaluatePolishGate } from "../polish-gate.mjs";
20
20
  import { effectiveStageStatus, effectiveStatusIsTerminal } from "../input-currency.mjs";
21
21
  import { cmd } from "../install-invocation.mjs";
22
+ import { shellToken } from "../shell-token.mjs";
22
23
  import { isObject, isNonEmptyString, optionalString, resolveFromFile, addIssue, filesystemPathsMatch } from "../cli-helpers.mjs";
23
24
  import { orderPathDepthDrift } from "./checks.mjs";
24
25
 
@@ -412,7 +413,7 @@ export function assessPurchaseProofCoverage({ packet = null, report = null } = {
412
413
  };
413
414
  }
414
415
 
415
- function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate, purchaseProof = null) {
416
+ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate, purchaseProof = null, packet = null) {
416
417
  const polishGate = derived?.polish_gate || evaluatePolishGate({ report });
417
418
  const polishCheckpointGate = derived?.polish_checkpoint_gate || null;
418
419
  // prepare-build is the earliest lifecycle prerequisite. Surface its
@@ -519,6 +520,7 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
519
520
 
520
521
  return {
521
522
  stage: "done",
523
+ outcome: "standard_closeout",
522
524
  reason: "All stages are in a terminal status (completed / completed_with_warnings / skipped). Pipeline is complete.",
523
525
  };
524
526
  }
@@ -577,7 +579,7 @@ export const DOCTOR_NEXT_STAGE_OWNERS = NEXT_STAGE_OWNERS;
577
579
  // The code -> action strings doctor prints under `Next:`. They describe the
578
580
  // repairs the findings ask for and are independent of which stage the picker
579
581
  // names, so they survive the picker consolidation unchanged.
580
- function doctorNextActions(errors, warnings, derived, { polishBlocked, polishGate, polishCheckpointGate, packetRef = derived.packet_path || "<packet>" }) {
582
+ function doctorNextActions(errors, warnings, derived, { polishBlocked, polishGate, polishCheckpointGate, packet = null, packetRef = derived.packet_path || "<packet>" }) {
581
583
  const codes = new Set([...errors, ...warnings].map((issue) => issue.code));
582
584
  const onlyPolishErrors = doctorErrorsAreOnlyPolishGate(errors);
583
585
  const actions = [];
@@ -633,11 +635,17 @@ function doctorNextActions(errors, warnings, derived, { polishBlocked, polishGat
633
635
  actions.push(`Target campaign output directory is missing; run ${cmd("next")} setup --packet ${packetRef} before build.`);
634
636
  }
635
637
  if (polishBlocked) {
636
- if (polishGate.status === "blocked") {
638
+ const needsHostedPreview = hostedTemplateNeedsUsablePreview(packet, polishCheckpointGate);
639
+ if (needsHostedPreview) {
640
+ actions.push(`Every mapped page is template stock and needs a hosted preview URL that differs from the production URL. Run ${cmd("qa")} policy set --packet ${packetRef} --preview-url <url>, then run ${cmd("next")} --packet ${packetRef}.`);
641
+ }
642
+ if (polishGate.status === "blocked" && (!needsHostedPreview || !polishGateCanCarryPreview(polishGate))) {
637
643
  actions.push(`${polishGate.reason} Run next-campaigns-polish and record structured evidence before deploy/QA handoff.`);
638
644
  }
639
- if (polishCheckpointGate?.status === "blocked") {
640
- actions.push(`${polishCheckpointGate.reason} Run ${cmd("polish")} capture before marking Polish complete.`);
645
+ if (polishCheckpointGate?.status === "blocked" && !needsHostedPreview) {
646
+ actions.push(polishCheckpointGate.code === NO_CAPTURABLE_ROUTES_CODE
647
+ ? `${polishCheckpointGate.reason} ${polishCheckpointGate.required_actions?.[0]?.description || "Map a design route and rerun intake before QA."}`
648
+ : `${polishCheckpointGate.reason} Run ${cmd("polish")} capture before marking Polish complete.`);
641
649
  }
642
650
  }
643
651
  return actions;
@@ -656,7 +664,7 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
656
664
  && (polishGate.status === "blocked" || polishCheckpointGate?.status === "blocked");
657
665
  const codes = new Set([...errors, ...warnings].map((issue) => issue.code));
658
666
  const purchaseProof = report ? assessPurchaseProofCoverage({ packet, report }) : null;
659
- const picked = pickNextStage(report, { errors, derived }, prepareBuildGate, purchaseProof);
667
+ const picked = pickNextStage(report, { errors, derived }, prepareBuildGate, purchaseProof, packet);
660
668
  // The picker's vocabulary and this table must not drift apart: a stage the
661
669
  // table does not know would otherwise be relabelled as an operator step and
662
670
  // sliced into the whole ladder. Fail loudly instead.
@@ -665,8 +673,8 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
665
673
  }
666
674
  // An explicit --context / --report is carried into every recommended
667
675
  // command, so a recovery reads the same artifacts the recommendation did.
668
- const packetRef = `${derived.packet_path || "<packet>"}${sidecarArgs}`;
669
- const actions = doctorNextActions(errors, warnings, derived, { polishBlocked, polishGate, polishCheckpointGate, packetRef });
676
+ const packetRef = `${derived.packet_path ? shellToken(derived.packet_path) : "<packet>"}${sidecarArgs}`;
677
+ const actions = doctorNextActions(errors, warnings, derived, { polishBlocked, polishGate, polishCheckpointGate, packet, packetRef });
670
678
  const deployStatus = String(report?.stages?.deploy?.status || "");
671
679
  const deploySatisfied = ["completed", "completed_with_warnings", "ready_with_exceptions"].some((prefix) => deployStatus.startsWith(prefix))
672
680
  || Boolean(deployUrlFromReportOutputs(report));
@@ -733,6 +741,7 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
733
741
  default_skill: owners.default_skill,
734
742
  command,
735
743
  reason: picked.reason,
744
+ ...(picked.outcome ? { outcome: picked.outcome } : {}),
736
745
  actions: actions.length ? actions : [fallbackAction],
737
746
  // Gate-blocked stages stay listed even when the recommended stage is
738
747
  // runnable (a Design Source Package change after assembly names build,
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
+ import { portableArtifactPaths } from "./cli-helpers.mjs";
4
5
 
5
6
  export const DOCTOR_SIDECAR_REL_PATH = ".campaign-runtime/doctor-output.json";
6
7
  export const DOCTOR_SIDECAR_SCHEMA = "campaigns-os-doctor-output/v0";
@@ -61,8 +62,9 @@ export function stampDoctorProducer(doctor, command) {
61
62
  // through this (or through `stampDoctorProducer` when its own transactional
62
63
  // writer must do the rename), so `generated_by` cannot be skipped by one of
63
64
  // them the way #327's cause labels once were.
64
- export function writeDoctorSidecar(path, doctor, { command } = {}) {
65
- writeJsonAtomic(path, stampDoctorProducer(doctor, command));
65
+ export function writeDoctorSidecar(path, doctor, { command, targetRepo = null } = {}) {
66
+ const stamped = stampDoctorProducer(doctor, command);
67
+ writeJsonAtomic(path, targetRepo ? portableArtifactPaths(stamped, targetRepo) : stamped);
66
68
  return path;
67
69
  }
68
70
 
@@ -89,7 +91,7 @@ export function markDoctorSidecarStale(targetBaseDir, { command = null, reason =
89
91
  stale_reason: reason
90
92
  || "A later command changed doctor inputs after this snapshot was written. Re-run campaigns-os doctor (or campaigns-os next) for current state.",
91
93
  };
92
- writeJsonAtomic(path, stamped);
94
+ writeJsonAtomic(path, portableArtifactPaths(stamped, targetBaseDir));
93
95
  return path;
94
96
  }
95
97
 
@@ -68,10 +68,30 @@ export const HIDDEN_EAGER_MEDIA_ACTIONS = Object.freeze({
68
68
  id: "polish.hidden_eager_media.map_design_route",
69
69
  kind: "manual",
70
70
  command: null,
71
- description: "Every mapped page is template stock, so polish capture has nothing to measure. Map at least one page to its design source HTML (source_html.pages[].path) and rerun intake, or prove the campaign on the local preview (deploy.target local-serve served from localhost), where this missing evidence is carried forward as a warning.",
71
+ description: "Every mapped page is template stock, so there is no design route to measure. Map at least one page to its design source HTML (source_html.pages[].path) and rerun intake, or use a local or hosted preview for QA with the missing evidence carried forward as a warning.",
72
72
  }),
73
73
  });
74
74
 
75
+ export function qaRunCommand(packetPath, url, bumpCart = null) {
76
+ return `${invocationPrefixFor(PACKAGE_ROOT)} qa run --packet ${packetPath ? shellToken(packetPath) : "<packet>"} --base-url ${url === "<preview-url>" ? url : shellToken(url)} --browser --test-order common${bumpCart ? ` --cart ${shellToken(bumpCart.cart)}` : ""}`;
77
+ }
78
+
79
+ export function hostedTemplateQaAction(previewUrl) {
80
+ return {
81
+ id: "polish.hidden_eager_media.hosted_preview_qa",
82
+ kind: "command",
83
+ command: qaRunCommand(null, previewUrl),
84
+ description: "Run QA against the packet's hosted preview URL; missing Polish evidence remains a warning.",
85
+ };
86
+ }
87
+
88
+ export const HOSTED_TEMPLATE_PREVIEW_URL_ACTION = Object.freeze({
89
+ id: "polish.hidden_eager_media.set_hosted_preview_url",
90
+ kind: "command",
91
+ command: "campaigns-os qa policy set --packet <packet> --preview-url <url>",
92
+ description: "Record a hosted preview URL that differs from the production URL with qa policy set, then run next again; missing Polish evidence can be carried forward on that preview.",
93
+ });
94
+
75
95
  const PACKET_PLACEHOLDER = "--packet <packet>";
76
96
 
77
97
  // The placeholder becomes the packet this run read, shell-quoted, so the