@nextcommerce/campaigns-os 1.57.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 (41) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/campaign-spec/dist/checkout-flow.d.ts +7 -0
  3. package/campaign-spec/dist/checkout-flow.js +17 -1
  4. package/compatibility.json +1 -1
  5. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  6. package/contracts/commerce-surface-catalog.json +26 -27
  7. package/contracts/effects.v1.json +5 -5
  8. package/contracts/release-ledger.json +432 -0
  9. package/contracts/supported-surface.json +4 -4
  10. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  11. package/docs/build-packet.md +22 -8
  12. package/docs/local-setup.md +1 -1
  13. package/docs/orientation-contract-reference.md +1 -1
  14. package/docs/qa-and-test-orders.md +54 -12
  15. package/docs/runtime-readiness.md +1 -1
  16. package/docs/skills-revision.md +10 -10
  17. package/package.json +1 -1
  18. package/schemas/campaign-runtime-build-context.v0.schema.json +2 -1
  19. package/schemas/campaign-runtime-build-packet.v0.schema.json +2 -1
  20. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  21. package/skills/campaign-readback-classification/SKILL.md +3 -3
  22. package/skills/campaign-run-evidence/SKILL.md +3 -3
  23. package/skills/contribution-intake/SKILL.md +3 -3
  24. package/skills/next-campaigns-build/SKILL.md +6 -6
  25. package/skills/next-campaigns-os/SKILL.md +3 -3
  26. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  27. package/skills/next-campaigns-polish/SKILL.md +3 -3
  28. package/skills/next-campaigns-qa/SKILL.md +3 -3
  29. package/skills.json +10 -10
  30. package/src/built-site-scope.mjs +91 -0
  31. package/src/cli.mjs +15 -23
  32. package/src/doctor/checks.mjs +73 -3
  33. package/src/doctor/next-step.mjs +3 -14
  34. package/src/polish-node.mjs +5 -4
  35. package/src/qa-browser.mjs +137 -2
  36. package/src/qa-cart-entry.mjs +105 -0
  37. package/src/qa-node.mjs +3 -1
  38. package/src/qa-verdict.mjs +24 -1
  39. package/src/sdk-attribute-index.mjs +8 -7
  40. package/src/source-html-intake.mjs +27 -18
  41. package/src/stage-record.mjs +200 -33
package/src/cli.mjs CHANGED
@@ -343,7 +343,7 @@ Usage:
343
343
  campaigns-os record build --packet <campaign-runtime.build.json> [--build-environment <development|production>] [--adapter-decision <key>=<value>[,<key>=<value>...]] [--context <json>] [--report <json>] [--dry-run] [--json] # after page-kit build: records scalar adapter decisions on the Assembly Report (all pairs in one flag; a repeated flag keeps only the last), stamps stages.assembly.build_fingerprint from doctor's derived.build_output_fingerprint.value (and the Design Source Package material fingerprint when present), and makes Polish required unless its evidence is bound to this output; --build-environment records stages.assembly.evidence.build_environment (local proof mode records development)
344
344
  campaigns-os record polish --packet <campaign-runtime.build.json> --evidence <polish-evidence.json> [--context <json>] [--report <json>] [--dry-run] [--json] # after polish capture: stages.polish from the file's status (completed, completed_with_warnings, blocked with blockers, or skipped with skip_reason), evidence and optional repair_loop_defect, bound to doctor's current fingerprint; a completed status is refused, writing nothing, unless the polish gate doctor evaluates would pass. --dry-run runs every check and writes nothing
345
345
  campaigns-os record theme --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json] # after the brand layer is linked and build is recorded: report.theme becomes applied with load_order after-next-core, css_path, commerce_pages and per-page evidence, only when each built commerce page that loads next-core.css loads brand-theme.css (or checkout-brand.css) after it and at least one does; a page loading neither is left out as the design's own markup; refused, writing nothing, otherwise. --dry-run runs every check and writes nothing
346
- campaigns-os record deploy --packet <campaign-runtime.build.json> --base-url <served url> [--context <json>] [--report <json>] [--dry-run] [--json] # a local preview (deploy.target local-serve) after polish is recorded: GETs every built page under the loopback URL (the campaign route root), then records deploy.preview_url on the packet and stages.deploy completed with the URL in outputs and the build fingerprint it probed (a later record build of other output makes deploy required again); refused, writing nothing, when the URL is not loopback or not the route root, a page does not answer 2xx, the build changed since it was recorded, or the theme gate is blocked. --dry-run runs every check, the requests included, and writes nothing
346
+ campaigns-os record deploy --packet <campaign-runtime.build.json> --base-url <served url> [--context <json>] [--report <json>] [--dry-run] [--json] # a served deploy after polish is recorded: GETs every built page under the URL (the campaign route root), then records deploy.preview_url on the packet and stages.deploy completed with the URL in outputs and the build fingerprint it probed (a later record build of other output makes deploy required again). A local-serve packet takes a loopback URL and each page must answer 2xx; any other deploy target takes an https URL (plain http only on a loopback host), each page must answer 200, and every script and stylesheet the built pages load from that origin must be served byte-identical (sha256) to the built output (HTML is not compared). Refused, writing nothing, when the URL breaks those rules or is not the route root, a page does not answer, a served asset differs or is missing, the build changed since it was recorded, or the theme gate is blocked. --dry-run runs every check, the requests included, and writes nothing
347
347
  campaigns-os readback <target-repo-root> [--json] [--packet <path>] [--doctor <path>] [--context <path>] [--report <path>] [--qa-verdict <path>] [--findings <path>] # read-only projection of one run's emitted artifacts (packet, doctor output, build context, assembly report, QA verdict, findings export): artifact states, per-artifact freshness against the checkout's HEAD reflog, doctor warning grouping, skip cascades and cross-artifact divergences. Writes nothing, starts no process, touches no network, and records no lifecycle entry; --json emits one campaigns-os-readback/v2 object (docs/readback.md). Exit 2 for a missing target root or a Build Packet set freshness cannot single out.
348
348
  campaigns-os readback --example [--json] # project the bundled synthetic sample; freshness is not computable for it by design
349
349
  campaigns-os validate-assembly-report --report <json> [--json]
@@ -4465,7 +4465,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4465
4465
  errors,
4466
4466
  warnings,
4467
4467
  ready,
4468
- prompt: `${picked.outcome === "hosted_preview_satisfies_deploy" ? picked.reason : "Pipeline complete. All stages in the assembly report are in a terminal status."} To repeat build work, do the work and run \`${cmd("record")} build --packet <path>\`; this makes downstream stages owed as needed. Run \`${cmd("qa")} run\` for QA.${picked.outcome === "hosted_preview_satisfies_deploy" ? "" : " For a hosted deploy, record the URL and stage outcome as the deploy prompt describes."} Then call \`${cmd("next")}\` again. If a run session is active, finish it with \`${cmd("run")} end\` so the Run Record is assembled and the session closes.${report?.stages?.qa?.status === "completed_with_warnings" ? " QA passed with exceptions; report them to the operator without clearing or waiving them or changing markup just to make them pass." : ""}`,
4468
+ prompt: `Pipeline complete. All stages in the assembly report are in a terminal status. To repeat build work, do the work and run \`${cmd("record")} build --packet <path>\`; this makes downstream stages owed as needed. Run \`${cmd("qa")} run\` for QA. For a hosted deploy, record the URL and stage outcome as the deploy prompt describes. Then call \`${cmd("next")}\` again. If a run session is active, finish it with \`${cmd("run")} end\` so the Run Record is assembled and the session closes.${report?.stages?.qa?.status === "completed_with_warnings" ? " QA passed with exceptions; report them to the operator without clearing or waiving them or changing markup just to make them pass." : ""}`,
4469
4469
  });
4470
4470
  }
4471
4471
  stage = picked.stage;
@@ -4877,8 +4877,8 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
4877
4877
  return actions;
4878
4878
  }
4879
4879
  if (result.stage === "polish" && polishGate?.carried_forward?.policy === "hosted_template_preview") {
4880
- push("next_qa", "command", `${cmd("next")} qa --packet ${shellToken(packetPath)}`, "Inspect QA for the hosted preview; missing Polish evidence remains a warning.");
4881
- push("qa_run", "command", qaRunCommand(packetPath, packet.deploy.preview_url), "Run browser and typed-card QA against the hosted preview URL.");
4880
+ push("record_deploy", "command", `${cmd("record")} deploy --packet ${shellToken(packetPath)} --base-url ${shellToken(packet.deploy.preview_url)}`, "Record the hosted preview deploy; missing Polish evidence remains a warning.");
4881
+ push("qa_run", "command", qaRunCommand(packetPath, packet.deploy.preview_url), "Then run browser and typed-card QA against the hosted preview URL.");
4882
4882
  return actions;
4883
4883
  }
4884
4884
  }
@@ -4934,20 +4934,13 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
4934
4934
  : `Run the visual polish pass and ${cmd("polish")} capture, then record polish with ${cmd("record")} polish --packet ${shellToken(packetPath)} --evidence <polish-evidence.json>.`);
4935
4935
  if (polishCheckpointGate?.status === "blocked") pushPolishCheckpointActions();
4936
4936
  } else if (result.stage === "deploy") {
4937
- if (polishGate?.carried_forward?.policy === "hosted_template_preview" && packet.deploy?.preview_url) {
4938
- push("next_qa", "command", `${cmd("next")} qa --packet ${shellToken(packetPath)}`, "Inspect the QA stage for this hosted preview without recording a hosted deploy.");
4939
- push("qa_run", "command", qaRunCommand(packetPath, packet.deploy.preview_url), "Run browser and typed-card QA against the hosted preview; its missing Polish evidence remains a warning.");
4940
- } else if (packet.deploy?.target === LOCAL_SERVE_DEPLOY_TARGET) {
4937
+ if (packet.deploy?.target === LOCAL_SERVE_DEPLOY_TARGET) {
4941
4938
  const plan = localServePlan(packet);
4942
4939
  push("deploy", "manual", null, `Serve the built ${plan.dir} output locally as the origin root (deploy.target is local-serve)${plan.rewrite ? ` — ${plan.rewrite}` : ""}, then run ${cmd("record")} deploy --packet ${shellToken(packetPath)} --base-url <served url>: it checks every built page answers and records deploy.preview_url and stages.deploy. Localhost on any port is a Development domain: SDK allowed, analytics suppressed.`);
4943
4940
  } else {
4944
- push("deploy", "manual", null, `Deploy _site/ output to ${packet.deploy?.target || "the deploy target"}, then record deploy.preview_url (or production_url) on the packet and stages.deploy in the assembly report.`);
4945
- }
4946
- if (polishGate?.carried_forward?.policy === "hosted_template_preview" && packet.deploy?.preview_url) {
4947
- push("advance", "command", `${cmd("next")} qa --packet ${shellToken(packetPath)}`, "Inspect QA for the hosted preview without a deploy record.");
4948
- } else {
4949
- push("advance", "command", `${cmd("next")} --packet ${shellToken(packetPath)} --json`, "Advance to QA once the deploy URL is recorded.");
4941
+ push("deploy", "manual", null, `Deploy _site/ output to ${packet.deploy?.target || "the deploy target"} with _site/ as the origin root, then run ${cmd("record")} deploy --packet ${shellToken(packetPath)} --base-url ${packet.deploy?.preview_url ? shellToken(packet.deploy.preview_url) : "<https preview url>"}: it checks every built page answers HTTP 200 and every script and stylesheet the pages load from that origin is served byte-identical to the build, and records deploy.preview_url and stages.deploy.`);
4950
4942
  }
4943
+ push("advance", "command", `${cmd("next")} --packet ${shellToken(packetPath)} --json`, "Advance to QA once the deploy URL is recorded.");
4951
4944
  } else if (result.stage === "qa") {
4952
4945
  const url = packet.deploy?.preview_url || packet.deploy?.production_url || "<preview-url>";
4953
4946
  push("install_browser", "command", `${cmd("qa")} install-browser`, "Install the Playwright browser once after install/update (npm run qa:install-browser from a checkout).");
@@ -5108,8 +5101,8 @@ Rules:
5108
5101
  - For one-time prepurchase/order-bump packages outside the main bundles, default package_sync=false and show_line_total_price=false unless the spec explicitly requires quantity sync.
5109
5102
  - Record spec-driven removals, especially unsupported payment methods, so polish does not reintroduce them.
5110
5103
  - Replace demo refs; do not copy Olympus-style shipping_methods into shop-three-step.
5111
- - Multi-step checkout paths run select -> checkout_step... -> checkout; only the checkout page takes payment. On a select page the swap-mode bundle selector (data-next-bundle-selector with data-next-selection-mode="swap") writes the cart and the SDK persists it between pages; a checkout button or link (href from the page's next_url) goes to the next page. Use forcePackageId only on a page with no selector, such as a landing call-to-action into a checkout that renders none.
5112
- - Each checkout_step page's checkout form (<form data-next-checkout="form">) carries data-next-checkout-step="<its next_url>" and data-next-step-number="<step_number>" (Build Context page_kit frontmatter step_number); the checkout page's form carries neither. On every page that feeds a Checkout (select, checkout_step, or a landing page with express checkout buttons), next-success-url is that Checkout's post-payment destination (frontmatter success_url), not the next step, because the SDK reads it for express orders; forward navigation uses the step form attribute or a plain link, never that meta tag.
5104
+ - Multi-step checkout paths run select -> checkout_step... -> checkout; only the checkout page takes payment. On a select page the swap-mode bundle selector (data-next-bundle-selector with data-next-selection-mode="swap") writes the cart and the SDK persists it between pages; a checkout button or link (href from the page's next_step) goes to the next page. Use forcePackageId only on a page with no selector, such as a landing call-to-action into a checkout that renders none.
5105
+ - Each checkout_step page's checkout form (<form data-next-checkout="form">) carries data-next-checkout-step="<its next_step>" and data-next-step-number="<step_number>" (Build Context page_kit frontmatter next_step and step_number); the checkout page's form carries neither. On select and checkout_step pages, frontmatter next_url is the Checkout's post-payment destination, the same meaning it has on the checkout page, and the layout renders it as next-success-url because the SDK reads that tag for express orders; next_step is the forward link. A landing page keeps next_url as its call-to-action target; if it has express checkout buttons, set its next-success-url to the Checkout's own next_url from the Build Context page_map, never to the landing page's next_url. Forward navigation uses the step form attribute or a plain link, never that meta tag. Page Kit frontmatter has no success_url key (success_url remains a CampaignSpec field on checkout pages).
5113
5106
  - After page-kit build, inspect rendered _site output before handoff: each active page should have a body, Campaign Cart runtime markers, SDK meta tags from CampaignSpec sdk_hints.meta_tags, and no stale copied funnel attribution.
5114
5107
  - Run page-kit build and SDK/template lint, then record build before polish: \`${cmd("record")} build --packet ${packetPath}${isLocalServePacket(packet) ? ` --build-environment ${LOCAL_PROOF_BUILD_ENVIRONMENT}` : ""} --adapter-decision <key>=<value>[,<key>=<value>...]\`. Record the value that is true for this build: for example, raw_html_conversion_status=completed once source HTML is converted, or not_required when there is none. Put all scalar adapter decisions in one comma-separated flag; a repeated flag keeps only the last. Use the allowed values in docs/build-packet.md. Record the nine recordable scalar decisions with the command, never by editing those fields in the Assembly Report. wrapper_policy is selected at intake with prepare-build --wrapper-policy or the source-html manifest's wrapper_policy option; the object-valued template_files_copied proof has no flag. It stamps stages.assembly.build_fingerprint with the fingerprint doctor computes from the built output (derived.build_output_fingerprint.value, sha256 over the sorted path+sha256 manifest of _site/<slug>/; doctor reports built_output.fingerprint_stale whenever the output on disk stops matching the recorded value), records report.design_source_package.material_fingerprint on stages.assembly.source_package_material_fingerprint when present, and sets stages.polish to "required" (required_by="build", required_for=["qa"]). Re-run it after every rebuild; never hand-edit these fields. Build must not mark stages.polish as completed/completed_with_warnings/skipped. If you applied a brand theme, run \`${cmd("record")} theme --packet ${packetPath}\` after record build: it reads each built commerce page's stylesheet links and records report.theme (status applied, load_order=after-next-core, css_path, commerce_pages, evidence), and refuses, writing nothing, when a page that loads next-core.css does not load the brand layer after it. Never hand-edit report.theme.
5115
5108
  - Capture the machine-readable build summary as an artifact: \`${PAGE_KIT_BUILD_SUMMARY_CAPTURE_COMMAND}\` (requires next-campaign-page-kit >= 0.1.4). Doctor verifies it for per-page build errors and Page Kit shape warnings (NESTED_NO_PERMALINK, DUPLICATE_OUTPUT, MISSING_FRONTMATTER, LAYOUT_NOT_FOUND). If the installed page-kit predates --json, record that in the assembly report instead of skipping silently.${localProofPromptLines(packet, packetPath)}`;
@@ -5193,7 +5186,7 @@ function polishPrompt(packetPath, reportPath, packet, intent, polishCheckpointGa
5193
5186
  return `Every mapped page is template stock, so there is no design route to capture. Missing Polish evidence is carried forward only on a hosted preview whose deploy.preview_url is a non-loopback http(s) URL different from deploy.production_url; this packet's is not. Set one with \`${cmd("qa")} policy set --packet ${shellToken(packetPath)} --preview-url <url>\`, then \`${cmd("next")} --packet ${shellToken(packetPath)}\`.`;
5194
5187
  }
5195
5188
  return packet.deploy?.preview_url
5196
- ? `Every mapped page is template stock, so there is no design route to capture. Missing Polish evidence is carried forward on the hosted preview. Run \`${cmd("next")} qa --packet ${shellToken(packetPath)}\`, then \`${qaRunCommand(packetPath, packet.deploy.preview_url)}\`. Production QA remains blocked without the evidence.`
5189
+ ? `Every mapped page is template stock, so there is no design route to capture. Missing Polish evidence is carried forward on the hosted preview. Run \`${cmd("record")} deploy --packet ${shellToken(packetPath)} --base-url ${shellToken(packet.deploy.preview_url)}\`, then \`${qaRunCommand(packetPath, packet.deploy.preview_url)}\`. Production QA remains blocked without the evidence.`
5197
5190
  : `Every mapped page is template stock, so there is no design route to capture. Run \`${cmd("qa")} policy set --packet ${shellToken(packetPath)} --preview-url <url>\` to record the hosted preview URL, then \`${cmd("next")} --packet ${shellToken(packetPath)}\`.`;
5198
5191
  }
5199
5192
  return `${campaignIntentPromptHeader(intent)}Use next-campaigns-polish for this built campaign.
@@ -5258,7 +5251,7 @@ function deployPrompt(packetPath, reportPath, packet, polishGate = null, polishC
5258
5251
  if (packet.deploy?.target !== LOCAL_SERVE_DEPLOY_TARGET
5259
5252
  && polishCheckpointGate?.code === "polish.hidden_eager_media.no_capturable_routes"
5260
5253
  && !packet.deploy?.preview_url) {
5261
- return `Every mapped page is template stock, and the hosted preview URL is not recorded. Run \`${cmd("qa")} policy set --packet ${shellToken(packetPath)} --preview-url <url>\` after the preview is deployed, then \`${cmd("next")} --packet ${shellToken(packetPath)}\`. There is no hosted record deploy command.`;
5254
+ return `Every mapped page is template stock, and the hosted preview URL is not recorded. Run \`${cmd("qa")} policy set --packet ${shellToken(packetPath)} --preview-url <url>\` after the preview is deployed, then \`${cmd("next")} --packet ${shellToken(packetPath)}\`.`;
5262
5255
  }
5263
5256
  const target = packet.deploy?.target || "unknown";
5264
5257
  const liveUrlPath = packet.deploy?.live_url_path || packet.campaign?.live_url_path || campaignRouteRoot(packet) || "/<slug>/";
@@ -5282,7 +5275,7 @@ Once the server is up:
5282
5275
  If the served build cannot be reached, set stages.deploy.status to "blocked" with a clear reason in outputs so the orchestration loop surfaces it rather than skipping past.`;
5283
5276
  }
5284
5277
  if (polishGate?.carried_forward?.policy === "hosted_template_preview" && packet.deploy?.preview_url) {
5285
- return `The hosted preview URL is recorded at deploy.preview_url. Every mapped page is template stock, so missing Polish evidence is carried forward as a warning for this preview. Run \`${cmd("next")} qa --packet ${shellToken(packetPath)}\` to inspect the QA stage, then \`${qaRunCommand(packetPath, packet.deploy.preview_url)}\` to test the preview. The recorded hosted preview satisfies this deploy handoff; after QA records its verdict, run \`${cmd("next")} --packet ${shellToken(packetPath)}\` for closeout. Production QA remains strict.`;
5278
+ return `The hosted preview URL is recorded at deploy.preview_url. Every mapped page is template stock, so missing Polish evidence is carried forward as a warning for this preview. Run \`${cmd("record")} deploy --packet ${shellToken(packetPath)} --base-url ${shellToken(packet.deploy.preview_url)}\` to record the deploy, then \`${qaRunCommand(packetPath, packet.deploy.preview_url)}\` to test the preview. Production QA remains strict.`;
5286
5279
  }
5287
5280
  return `Deploy the built campaign to ${target}.
5288
5281
 
@@ -5295,10 +5288,9 @@ Read first:
5295
5288
  Deploy is currently an out-of-band step: the page-kit build produces _site/ output; you (or your CI) ship it to ${target}. Use the deploy target's normal tooling (netlify deploy, wrangler pages deploy, vercel deploy, etc.).
5296
5289
 
5297
5290
  After deploy succeeds:
5298
- 1. Record the resulting URL on the packet at deploy.preview_url (preview deploys) or deploy.production_url (production).
5299
- 2. Update the assembly report's stages.deploy.status to "completed" with the URL and any relevant notes in outputs.
5300
- 3. Verify the SDK initialises on the tested origin. Localhost on any port is globally available as a Campaigns App Development domain (analytics suppressed). Non-localhost preview/production hosts must be in the Campaigns App SDK origin allowlist before QA.
5301
- 4. Run \`${cmd("next")} --packet ${packetPath}\` to advance to QA.
5291
+ 1. Run \`${cmd("record")} deploy --packet ${packetPath} --base-url <https preview origin>${liveUrlPath}\`. It requests every built page under that URL (each must answer HTTP 200) and compares every script and stylesheet the built pages load from that origin with the built output by sha256 (HTML is not compared; hosts inject markup), then records the URL at deploy.preview_url and stages.deploy as completed with the URL in outputs and the build fingerprint it probed. It refuses, writing nothing, naming each page or asset that does not match.
5292
+ 2. Verify the SDK initialises on the tested origin. Localhost on any port is globally available as a Campaigns App Development domain (analytics suppressed). Non-localhost preview/production hosts must be in the Campaigns App SDK origin allowlist before QA.
5293
+ 3. Run \`${cmd("next")} --packet ${packetPath}\` to advance to QA.
5302
5294
 
5303
5295
  If the deploy is blocked (non-localhost allowed-domain not yet added, CI permission missing, host-side outage), set stages.deploy.status to "blocked" with a clear reason in outputs so the orchestration loop surfaces it rather than skipping past.`;
5304
5296
  }
@@ -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,
@@ -1895,12 +1895,33 @@ export function validateBuildOutputFingerprint(packet, errors, warnings, ready,
1895
1895
  );
1896
1896
  return;
1897
1897
  }
1898
+ const drift = describeBuildOutputDrift(buildState.report?.stages?.assembly, current);
1899
+ const capped = (paths) => paths.slice(0, BUILD_DRIFT_PATH_LIMIT);
1898
1900
  addIssue(
1899
1901
  assemblyComplete ? errors : warnings,
1900
1902
  "built_output.fingerprint_stale",
1901
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} `
1902
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.",
1903
- { 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
+ }
1904
1925
  );
1905
1926
  }
1906
1927
 
@@ -3342,7 +3363,7 @@ function collectHardcodedPhoneMatches(scanRoots, storePhone) {
3342
3363
  const matches = [];
3343
3364
  for (const { label: surface, root } of scanRoots) {
3344
3365
  for (const file of collectHtmlFiles(root)) {
3345
- const content = maskMarketLintIgnoredRegions(readHtmlScanText(join(root, file.path)));
3366
+ const content = maskNonRenderedMarkup(maskMarketLintIgnoredRegions(readHtmlScanText(join(root, file.path))));
3346
3367
  for (const match of content.matchAll(HARDCODED_PHONE_REGEX)) {
3347
3368
  const found = normalizePhoneNumber(match[0]);
3348
3369
  if (!found || found === expected) continue;
@@ -3370,6 +3391,18 @@ function maskMarketLintIgnoredRegions(content) {
3370
3391
  .replace(ignoredTag, preserveNewlinesMask);
3371
3392
  }
3372
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
+
3373
3406
  function preserveNewlinesMask(value) {
3374
3407
  return value.replace(/[^\n]/g, " ");
3375
3408
  }
@@ -4138,6 +4171,42 @@ function warnCheckoutPackageFamilyFit(specPages, family, catalog, warnings, repo
4138
4171
  );
4139
4172
  }
4140
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
+ }
4141
4210
  function offerRefsFromEntries(entries) {
4142
4211
  if (!Array.isArray(entries)) return [];
4143
4212
  return entries
@@ -4323,6 +4392,7 @@ export function validateCommerceCatalog(packet, packetPath, spec, errors, warnin
4323
4392
  // ADR-003 step 2: template-contract checks ported from the private doctor.
4324
4393
  const specPages = activeSpecPages(spec);
4325
4394
  warnCheckoutPackageFamilyFit(specPages, family, catalog, warnings, buildState.report);
4395
+ warnMissingSelectStep(specPages, family, catalog, warnings);
4326
4396
 
4327
4397
  const mismatchedFamilies = specPages.filter(
4328
4398
  (page) => isNonEmptyString(page.sdk_hints?.template_family) && page.sdk_hints.template_family !== family
@@ -477,12 +477,6 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
477
477
  // A first local preview may carry missing Polish forward. A theme waiver
478
478
  // or prior capture makes the recorded Polish stage owed again.
479
479
  if (cliStage === "polish" && polishCarriedForwardForLadder(report, polishGate)) continue;
480
- // A hosted all-template preview has no hosted deploy recorder. The
481
- // recorded preview URL satisfies this one deploy handoff while the
482
- // carried-forward gate identifies the exact preview-only shape.
483
- if (cliStage === "deploy" && packet?.deploy?.preview_url
484
- && polishGate?.carried_forward?.policy === "hosted_template_preview"
485
- && polishCheckpointGate?.carried_forward?.policy === "hosted_template_preview") continue;
486
480
  const reportKey = reportKeyForCliStage(cliStage);
487
481
  const stage = report.stages[reportKey];
488
482
  if (!stage) {
@@ -524,13 +518,10 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
524
518
  }
525
519
  }
526
520
 
527
- const hostedPreview = Boolean(packet?.deploy?.preview_url && polishGate?.carried_forward?.policy === "hosted_template_preview");
528
521
  return {
529
522
  stage: "done",
530
- outcome: hostedPreview ? "hosted_preview_satisfies_deploy" : "standard_closeout",
531
- reason: hostedPreview
532
- ? "The recorded hosted preview satisfies deploy, and QA is terminal for the current build. Pipeline is complete."
533
- : "All stages are in a terminal status (completed / completed_with_warnings / skipped). Pipeline is complete.",
523
+ outcome: "standard_closeout",
524
+ reason: "All stages are in a terminal status (completed / completed_with_warnings / skipped). Pipeline is complete.",
534
525
  };
535
526
  }
536
527
 
@@ -730,9 +721,7 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
730
721
  ? `${cmd("next")} --packet ${packetRef}`
731
722
  : `${cmd("next")} ${picked.stage} --packet ${packetRef}`;
732
723
  const fallbackAction = picked.stage === "done"
733
- ? (picked.outcome === "hosted_preview_satisfies_deploy"
734
- ? `${picked.reason} Run ${cmd("next")} to confirm the closeout actions.`
735
- : `All stages are recorded as terminal; run ${cmd("next")} to confirm the closeout actions.`)
724
+ ? `All stages are recorded as terminal; run ${cmd("next")} to confirm the closeout actions.`
736
725
  : `Run ${command}.`;
737
726
  if (picked.stage === "done") {
738
727
  actions.unshift(fallbackAction);
@@ -3,7 +3,7 @@ import { NO_CAPTURABLE_ROUTES_CODE } from "./local-preview-policy.mjs";
3
3
  import { createHash } from "node:crypto";
4
4
  import { HIDDEN_EAGER_MEDIA_ACTIONS } from "./gate-actions.mjs";
5
5
  import { dirname, join, resolve } from "node:path";
6
- import { computeBuildFingerprint } from "./built-site-scope.mjs";
6
+ import { computeBuildFingerprint, describeBuildOutputDrift } from "./built-site-scope.mjs";
7
7
  import {
8
8
  buildPageLoadCapture,
9
9
  MAX_POLISH_CAPTURE_URL_LENGTH,
@@ -402,7 +402,7 @@ export function createPolishCaptureBinding({ packet, report, plan, packetPath, t
402
402
  // an output that has drifted from what build recorded would be evidence
403
403
  // about a build that no longer exists, and an output that changes during the
404
404
  // browser pass fails the unchanged-binding assertion after it.
405
- const outputFingerprint = boundOutputFingerprint(join(resolve(targetRepo), "_site", slug), slug, buildFingerprint);
405
+ const outputFingerprint = boundOutputFingerprint(join(resolve(targetRepo), "_site", slug), slug, buildFingerprint, report.stages.assembly);
406
406
  const runId = nonemptyString(report.run_id);
407
407
  const reportPacketPath = nonemptyString(report?.inputs?.packet_path);
408
408
  if (!runId || !reportPacketPath) {
@@ -585,7 +585,7 @@ function uncapturedPages(packet, plan) {
585
585
  // (permissions, a file vanishing mid-walk), or an output that no longer
586
586
  // matches what build recorded. None of them may surface as an uncaught
587
587
  // filesystem error, and none may bind as a null.
588
- function boundOutputFingerprint(outputRoot, slug, buildFingerprint) {
588
+ function boundOutputFingerprint(outputRoot, slug, buildFingerprint, assembly = null) {
589
589
  let current;
590
590
  try {
591
591
  current = computeBuildFingerprint(outputRoot);
@@ -604,7 +604,8 @@ function boundOutputFingerprint(outputRoot, slug, buildFingerprint) {
604
604
  if (current.fingerprint !== buildFingerprint) {
605
605
  throw new Error(
606
606
  `polish capture refuses: built output under _site/${slug}/ no longer matches stages.assembly.build_fingerprint `
607
- + `(recorded ${buildFingerprint}, current ${current.fingerprint}). Re-run build and record the current fingerprint first.`,
607
+ + `(recorded ${buildFingerprint}, current ${current.fingerprint}). ${describeBuildOutputDrift(assembly, current).summary} `
608
+ + "Re-run build and record the current fingerprint first.",
608
609
  );
609
610
  }
610
611
  return current.fingerprint;
@@ -52,6 +52,13 @@ import {
52
52
  isCartEntryCode,
53
53
  resolveCartEntryPage,
54
54
  sdkCartSnapshotScript,
55
+ SLOT_VARIANT_DROPDOWN_ROW_SELECTOR,
56
+ SLOT_VARIANT_DROPDOWN_TOGGLE_SELECTOR,
57
+ SLOT_VARIANT_FIELD_SELECTOR,
58
+ SLOT_VARIANT_SELECT_SELECTOR,
59
+ slotVariantKey,
60
+ slotVariantLabel,
61
+ slotVariantStepScript,
55
62
  summarizeSelectionSurface,
56
63
  UNDECLARED_ROUTE_ATTRIBUTES,
57
64
  } from "./qa-cart-entry.mjs";
@@ -304,7 +311,7 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
304
311
  const identifier = planId(plan);
305
312
  const pageForPlan = (typeof plan === "object" && plan?.checkout_page?.url) ? plan.checkout_page : checkoutPage;
306
313
  const firstAttempt = await runSingle(context, pageForPlan, plan, args, runId, attemptOptions);
307
- orders.push(firstAttempt.order);
314
+ orders.push(testOrderEntry(firstAttempt.order, identifier, 1));
308
315
  orderPlans.push(plan);
309
316
  // Every attempt this path actually SUBMITTED, in order. The confirmed
310
317
  // creation count is read from these and never from the deciding result
@@ -364,7 +371,7 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
364
371
  const rerunAttempt = await runSingle(context, pageForPlan, plan, args, runId, attemptOptions);
365
372
  attemptsForPlan.push(rerunAttempt);
366
373
  if (rerunAttempt.order) {
367
- orders.push(rerunAttempt.order);
374
+ orders.push(testOrderEntry(rerunAttempt.order, identifier, 2));
368
375
  orderPlans.push(plan);
369
376
  }
370
377
  if (rerunAttempt.budget_exhausted) {
@@ -500,6 +507,17 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
500
507
  return { orders, assertions, receiptAnalytics, journeyAnalytics, creationBudget, qcResults };
501
508
  }
502
509
 
510
+ // One test_orders[] entry per attempt, never one per planned path: a re-run
511
+ // places its own real order and the operator cleaning up test orders needs both.
512
+ // So each entry names the planned path it belongs to (`plan_id`, the same id the
513
+ // creation budget charges) and which attempt it was, 1-based, so a reader can
514
+ // tell 5 paths with one retry each from 10 paths (#661). A copy, so the attempt
515
+ // result the assertions read is left as the runner returned it.
516
+ function testOrderEntry(order, identifier, attempt) {
517
+ if (!order || typeof order !== "object") return order;
518
+ return { ...order, plan_id: order.plan_id ?? identifier, attempt };
519
+ }
520
+
503
521
  // Analytics-parity leg: capture the live dataLayer event stream + GTM/pixel
504
522
  // tag-fires on a baseline (legacy) URL and a candidate (migrated) URL, then
505
523
  // diff them into parity assertions. Highest-value target is the thank-you /
@@ -4378,6 +4396,11 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
4378
4396
  cardSelection = choice.select_card ? await selectPackageCard(page, choice.select_card, []) : null;
4379
4397
  control.package_id = choice.select_card?.packageId || await selectedBundleCardPackage(page);
4380
4398
  }
4399
+ // Each bundle slot may need a variant (size, colour) before the page lets
4400
+ // Next through (campaigns-os#667). Fill every visible empty slot variant
4401
+ // select with its first in-stock option now, after the card that decides
4402
+ // the slots, and refuse by name when one has nothing to choose.
4403
+ const variantSelections = control.kind === "checkout_button" ? await fillSlotVariants(page) : [];
4381
4404
  // The index is the control's position among what its own locator matches,
4382
4405
  // so it replays with nth(); no selector is rebuilt from an attribute value.
4383
4406
  const controlSelector = control.kind === "add_to_cart"
@@ -4414,6 +4437,7 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
4414
4437
  control_kind: control.kind,
4415
4438
  package_id: control.package_id || null,
4416
4439
  ...(cardSelection ? { bundle_card_selection: cardSelection } : {}),
4440
+ ...(variantSelections.length ? { variant_selections: variantSelections } : {}),
4417
4441
  sdk_ready: sdkReady,
4418
4442
  arrived_url: redactUrlQuery(safePageUrl(page)),
4419
4443
  checkout_selection_surface: surface,
@@ -4422,6 +4446,117 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
4422
4446
  };
4423
4447
  }
4424
4448
 
4449
+ // Chooses a select page's slot variants one at a time (a choice can
4450
+ // re-render its slot) and returns the choices. A field showing a dropdown UI
4451
+ // is picked like a shopper, toggle then row, even when its select is
4452
+ // pre-filled, because a page may count a field as chosen only when a row was
4453
+ // clicked (campaigns-os#667). A select with no dropdown UI is filled only when
4454
+ // empty; a pre-filled native-only page returns [] untouched. Throws
4455
+ // ENTRY_VARIANT_UNFILLED, before any click on the checkout control, when a
4456
+ // field has no in-stock row or option, a pick cannot be clicked, or the page
4457
+ // keeps asking for more choices.
4458
+ const SLOT_VARIANT_FILL_LIMIT = 40;
4459
+ const SLOT_VARIANT_ROW_WAIT_MS = 2000;
4460
+
4461
+ // The slot variant field a pick names, found again by its slot identity
4462
+ // (data-next-bundle-id, data-next-slot-index, variant code) so a re-render
4463
+ // since the pick was planned does not leave a stale index; the planned index
4464
+ // is the fallback for a field that carries no slot index.
4465
+ async function slotVariantField(page, pick) {
4466
+ const fields = page.locator(SLOT_VARIANT_FIELD_SELECTOR);
4467
+ const index = await fields.evaluateAll((nodes, want) => {
4468
+ const clean = (value) => String(value || "").replace(/\s+/g, " ").trim();
4469
+ const found = nodes.findIndex((node) => {
4470
+ const slotIndex = Number.parseInt(node.getAttribute("data-next-slot-index") ?? "", 10);
4471
+ if (!Number.isFinite(slotIndex) || slotIndex + 1 !== want.slot) return false;
4472
+ if ((clean(node.getAttribute("data-next-bundle-id")) || null) !== want.bundle_id) return false;
4473
+ const select = node.querySelector("select");
4474
+ const code = clean(select?.getAttribute("data-next-variant-code") || select?.getAttribute("data-variant-code") || node.getAttribute("data-next-variant-code") || select?.name) || null;
4475
+ return code === want.variant_code;
4476
+ });
4477
+ return found >= 0 ? found : want.field_index;
4478
+ }, pick).catch(() => pick.field_index);
4479
+ return fields.nth(index);
4480
+ }
4481
+
4482
+ // The pick's row, matched by its value among the field's rows (no selector is
4483
+ // built from the value), once it is visible; null if it does not show in time.
4484
+ async function visibleSlotVariantRow(page, pick, timeoutMs) {
4485
+ const deadline = Date.now() + timeoutMs;
4486
+ for (;;) {
4487
+ const field = await slotVariantField(page, pick);
4488
+ const rows = field.locator(SLOT_VARIANT_DROPDOWN_ROW_SELECTOR);
4489
+ const index = await rows.evaluateAll((nodes, value) => nodes.findIndex((node) => {
4490
+ if (String(node.getAttribute("value") || "").replace(/\s+/g, " ").trim() !== value) return false;
4491
+ const rect = node.getBoundingClientRect();
4492
+ const style = getComputedStyle(node);
4493
+ return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden";
4494
+ }), pick.value).catch(() => -1);
4495
+ if (index >= 0) return rows.nth(index);
4496
+ if (Date.now() >= deadline) return null;
4497
+ await page.waitForTimeout(100);
4498
+ }
4499
+ }
4500
+ async function fillSlotVariants(page) {
4501
+ const selections = [];
4502
+ const picked = [];
4503
+ const step = () => page.evaluate(slotVariantStepScript(), {
4504
+ selector: SLOT_VARIANT_SELECT_SELECTOR,
4505
+ fieldSelector: SLOT_VARIANT_FIELD_SELECTOR,
4506
+ toggleSelector: SLOT_VARIANT_DROPDOWN_TOGGLE_SELECTOR,
4507
+ rowSelector: SLOT_VARIANT_DROPDOWN_ROW_SELECTOR,
4508
+ picked,
4509
+ }).catch(() => ({ done: true }));
4510
+ for (let pass = 0; pass < SLOT_VARIANT_FILL_LIMIT; pass += 1) {
4511
+ const result = await step();
4512
+ if (result?.done) {
4513
+ if (selections.length) await page.waitForLoadState("networkidle", { timeout: 3000 }).catch(() => {});
4514
+ return selections;
4515
+ }
4516
+ if (result?.unfillable?.detail) {
4517
+ throw codedError(CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED, `${slotVariantLabel(result.unfillable)}: ${result.unfillable.detail}; the page would refuse Next`);
4518
+ }
4519
+ if (result?.unfillable) {
4520
+ throw codedError(
4521
+ CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED,
4522
+ `${slotVariantLabel(result.unfillable)} has no in-stock ${result.unfillable.via === "dropdown" ? "dropdown row" : "option"} to choose (${result.unfillable.options} ${result.unfillable.via === "dropdown" ? "row" : "option"}(s), all disabled or placeholder); the page would refuse Next`,
4523
+ );
4524
+ }
4525
+ const choice = result.pick || result.filled;
4526
+ if (result.pick) {
4527
+ try {
4528
+ const field = await slotVariantField(page, result.pick);
4529
+ await clickControl(field.locator(SLOT_VARIANT_DROPDOWN_TOGGLE_SELECTOR).filter({ visible: true }).first(), { timeout: 5000 });
4530
+ // The menu may open late or re-render on toggle: re-resolve the field
4531
+ // and the row by its value, and wait for that row to show.
4532
+ const row = await visibleSlotVariantRow(page, result.pick, SLOT_VARIANT_ROW_WAIT_MS);
4533
+ if (!row) {
4534
+ throw codedError(
4535
+ CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED,
4536
+ `${slotVariantLabel(choice)} dropdown row "${choice.value}" did not show within ${SLOT_VARIANT_ROW_WAIT_MS}ms of opening the dropdown; the page would refuse Next`,
4537
+ );
4538
+ }
4539
+ await clickControl(row, { timeout: 5000 });
4540
+ } catch (error) {
4541
+ if (error?.code === CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED) throw error;
4542
+ throw codedError(
4543
+ CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED,
4544
+ `${slotVariantLabel(choice)} dropdown row "${choice.value}" could not be clicked (${String(error?.message || error).split("\n")[0]}); the page would refuse Next`,
4545
+ );
4546
+ }
4547
+ picked.push(slotVariantKey(choice));
4548
+ }
4549
+ selections.push({ bundle_id: choice.bundle_id, slot: choice.slot, variant_code: choice.variant_code, value: choice.value, via: result.pick ? "dropdown" : "select" });
4550
+ await page.waitForTimeout(250);
4551
+ }
4552
+ const pending = await step();
4553
+ if (pending?.done) return selections;
4554
+ throw codedError(
4555
+ CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED,
4556
+ `${slotVariantLabel(pending.pick || pending.filled || pending.unfillable)} still needed a choice after ${SLOT_VARIANT_FILL_LIMIT} variant choices; the page keeps clearing them`,
4557
+ );
4558
+ }
4559
+
4425
4560
  // The package of the bundle card a select page shows as selected, or null.
4426
4561
  async function selectedBundleCardPackage(page) {
4427
4562
  return page.evaluate((cardSelector) => {