@nextcommerce/campaigns-os 1.47.0 → 1.48.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 (44) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/README.md +30 -3
  3. package/agents/claude/CLAUDE.md +2 -0
  4. package/agents/codex/AGENTS.md +1 -0
  5. package/agents/copilot/copilot-instructions.md +1 -0
  6. package/agents/cursor/campaigns-os.mdc +1 -1
  7. package/compatibility.json +1 -1
  8. package/contracts/commerce-surface-catalog.json +17 -17
  9. package/contracts/release-ledger.json +465 -0
  10. package/contracts/supported-surface.json +1 -1
  11. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  12. package/docs/build-packet.md +1 -1
  13. package/docs/campaign-build-brief.md +25 -28
  14. package/docs/local-setup.md +7 -2
  15. package/docs/orientation-contract-reference.md +1 -1
  16. package/docs/qa-and-test-orders.md +49 -2
  17. package/docs/runtime-readiness.md +1 -1
  18. package/docs/skills-revision.md +10 -10
  19. package/package.json +1 -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 +9 -4
  23. package/skills/contribution-intake/SKILL.md +3 -3
  24. package/skills/next-campaigns-build/SKILL.md +4 -3
  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 +4 -4
  28. package/skills/next-campaigns-qa/SKILL.md +3 -3
  29. package/skills.json +10 -10
  30. package/src/cli.mjs +6 -3
  31. package/src/content-residue.mjs +18 -90
  32. package/src/doctor/checks.mjs +21 -29
  33. package/src/doctor/inspect.mjs +13 -3
  34. package/src/doctor/next-step.mjs +4 -0
  35. package/src/gate-actions.mjs +8 -0
  36. package/src/local-preview-policy.mjs +92 -0
  37. package/src/page-kit-sdk-version.mjs +8 -1
  38. package/src/polish-node.mjs +26 -2
  39. package/src/progress-node.mjs +5 -1
  40. package/src/qa-binding-evidence.mjs +1 -1
  41. package/src/qa-browser.mjs +62 -13
  42. package/src/qa-node.mjs +33 -4
  43. package/src/readback.mjs +19 -10
  44. package/src/source-prep.mjs +1 -1
@@ -363,7 +363,7 @@ const SPEC_DOCTOR_CHECKS = createDoctorCheckRegistry([
363
363
  {
364
364
  id: PAGE_KIT_SDK_VERSION_SCOPE,
365
365
  phase: "target",
366
- run: ({ spec, errors, warnings, ready, derived, buildState }) => validateTargetSdkVersion(spec, errors, warnings, ready, derived, buildState),
366
+ run: ({ spec, packet, errors, warnings, ready, derived, buildState }) => validateTargetSdkVersion(spec, errors, warnings, ready, derived, buildState, packet),
367
367
  },
368
368
  {
369
369
  id: PAGE_KIT_STORE_PROFILE_SCOPE,
@@ -1159,17 +1159,22 @@ function validateLocalProof(packet, report, errors, warnings, ready) {
1159
1159
  addIssue(errors, LOCAL_PROOF_PARITY_SCOPE, `Production parity FAILED${difference ? ` — first non-gated difference: ${singleLineField(String(difference.kind))} at ${singleLineField(String(difference.route))}${difference.line ? ` line ${difference.line}` : ""}: ${singleLineField(String(difference.detail || ""))}` : `: ${singleLineField(String(parity.summary || ""))}`}. Rebuild in development, re-prove, and run ${parityCommand} again. ${LOCAL_PROOF_NEVER_EDIT_RULE}`, difference ? { first_difference: difference } : undefined);
1160
1160
  }
1161
1161
 
1162
- function validateTargetSdkVersion(spec, errors, warnings, ready, derived, buildState) {
1162
+ function validateTargetSdkVersion(spec, errors, warnings, ready, derived, buildState, packet = null) {
1163
1163
  const report = buildState?.report || null;
1164
1164
  const required = stageIsTerminal(report?.stages?.setup?.status)
1165
1165
  || stageIsTerminal(report?.stages?.assembly?.status)
1166
1166
  || derived.scaffold_required !== true;
1167
+ const family = packet?.assembly?.template_family;
1168
+ const verified = isNonEmptyString(family) && isCertifiedTemplateFamily(family)
1169
+ ? assessTemplateFreshness({ family, catalog: resolveCommerceCatalog(), sdkSupportPolicy: defaultSdkSupportPolicy() }).verified_sdk_version
1170
+ : null;
1167
1171
  const gate = evaluatePageKitSdkVersion({
1168
1172
  spec,
1169
1173
  specStatus: buildState?.specStatus || "ok",
1170
1174
  targetLoad: buildState?.pageKitCampaignConfig,
1171
1175
  waivers: report?.waivers,
1172
1176
  required,
1177
+ familyVerification: verified ? { family, sdk_version: verified } : null,
1173
1178
  });
1174
1179
  derived.checkpoint_gates.push(gate);
1175
1180
 
@@ -3199,7 +3204,7 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
3199
3204
  addIssue(
3200
3205
  warnings,
3201
3206
  "source_html.pages.source_hash",
3202
- `Source page "${page.page_id}" hash mismatch — file at ${page.path} has changed since the manifest was written (manifest sha256=${expectedHash.slice(0, 12)}…, on-disk sha256=${actualHash.slice(0, 12)}…). Re-run the producer to refresh the manifest, or accept the local edits.`,
3207
+ `Source page "${page.page_id}" hash mismatch — file at ${page.path} has changed since intake recorded it in the Build Packet (recorded sha256=${expectedHash.slice(0, 12)}…, on-disk sha256=${actualHash.slice(0, 12)}…). This warning does not block. Re-running start or prepare-build with --force records the current file and clears recorded stage evidence; editing the source-html manifest alone does not change the hash the packet holds.`,
3203
3208
  );
3204
3209
  }
3205
3210
  }
@@ -4018,11 +4023,11 @@ function collectPlaceholderTextResidueMatches(root, terms) {
4018
4023
  }
4019
4024
 
4020
4025
  // Rendered-output content-residue scan (assembly-surfaces prototype): scans
4021
- // BUILT pages for the needs-merchant-input marker and urgency chrome rendered
4022
- // without verified offer urgency (blockers), plus demo/placeholder residue and
4023
- // generic content anti-pattern hits (warnings feeding the review/attestation
4024
- // queue). Scans _site output, not frontmatter — layout/script-rendered chrome
4025
- // only exists after the build.
4026
+ // BUILT pages for the needs-merchant-input marker and, on a brief-backed
4027
+ // build, starter countdown chrome the brief does not verify (blockers), plus
4028
+ // template demo/placeholder residue (warnings). The merchant's own proof and
4029
+ // urgency copy is not scanned. Scans _site output, not frontmatter —
4030
+ // layout/script-rendered chrome only exists after the build.
4026
4031
  export function validateBuiltContentResidue(packet, errors, warnings, ready, derived, buildState = {}) {
4027
4032
  if (!isStageComplete(buildState.report, "assembly")) return;
4028
4033
  // Scan the RENDERED _site output, not the campaign source dir: the Liquid
@@ -4035,7 +4040,7 @@ export function validateBuiltContentResidue(packet, errors, warnings, ready, der
4035
4040
  const urgencyVerified = briefUrgencyVerified(brief?.payload);
4036
4041
  const findings = scanBuiltOutputContentResidue(siteRoot, { urgencyVerified });
4037
4042
  if (!findings.length) {
4038
- ready.push("Built output carries no needs-input markers, unverified urgency chrome, or content-residue hits");
4043
+ ready.push("Built output carries no needs-input markers, unverified urgency chrome, or template demo residue");
4039
4044
  return;
4040
4045
  }
4041
4046
  // One issue per finding id, carrying the FULL file inventory: the id keeps
@@ -4068,9 +4073,8 @@ export function validateBuiltContentResidue(packet, errors, warnings, ready, der
4068
4073
  );
4069
4074
  } else if (id === "unverified_urgency_countdown") {
4070
4075
  // The scanner emits this only when the brief does not verify urgency
4071
- // (urgencyVerified=false). Severity keys on the path: with a readable
4072
- // brief payload (AI-assembled) it blocks; a designed-source campaign
4073
- // with no brief payload may carry an intentional countdown — warning.
4076
+ // (urgencyVerified=false). Only a readable brief payload (AI-assembled)
4077
+ // makes it a finding.
4074
4078
  if (briefReadable) {
4075
4079
  addIssue(
4076
4080
  errors,
@@ -4078,30 +4082,18 @@ export function validateBuiltContentResidue(packet, errors, warnings, ready, der
4078
4082
  `Built output renders countdown chrome without verified offer urgency (${where}). Set offer.urgency.verified in ${BRIEF_PAYLOAD_REL_PATH} from a real promotion window, or blank the urgency slots.`,
4079
4083
  );
4080
4084
  } else if (brief?.error) {
4081
- // Unreadable brief payload: the run is already blocked by
4082
- // proof_attestation.unreadable with the right remediation (repair the
4083
- // brief). Urgency is re-evaluated on the next doctor run once the
4084
- // brief parses — "confirm the urgency is real" copy here would point
4085
- // the operator at the wrong fix.
4086
- } else {
4087
- addIssue(
4088
- warnings,
4089
- "content_residue.urgency_unattested",
4090
- `Built output renders countdown chrome and no brief payload attests the promotion window (${where}). Confirm the urgency is real (a genuine offer window or live inventory) before launch.`,
4091
- );
4085
+ // Deliberately nothing: an unreadable brief payload already blocks
4086
+ // through proof_attestation.unreadable (validateProofAttestation),
4087
+ // and urgency is re-evaluated once the brief parses.
4092
4088
  }
4089
+ // No brief payload at all: the countdown is the merchant's content and
4090
+ // is not ours to question.
4093
4091
  } else if (id === "demo_residue_term" || id === "bracket_placeholder_stub") {
4094
4092
  addIssue(
4095
4093
  warnings,
4096
4094
  "content_residue.demo_residue",
4097
4095
  `Built output carries template demo/placeholder residue (${where}; e.g. "${finding.excerpt}"). Fill or blank the slot — demo values must never ship.`,
4098
4096
  );
4099
- } else {
4100
- addIssue(
4101
- warnings,
4102
- "content_residue.anti_pattern",
4103
- `Built output matches content anti-pattern "${id}" (${where}; e.g. "${finding.excerpt}"). ${finding.rule || "Remove it or route it through brief-sourced proof."} This is a review warning and nothing downstream blocks on it: the claim is the operator's and the client's responsibility — remove or evidence it, never make it more plausible.`,
4104
- );
4105
4097
  }
4106
4098
  }
4107
4099
  }
@@ -1,5 +1,6 @@
1
1
  // Doctor entry points: the doctor command, packet inspection and built-output inspection.
2
2
  import { resolveCampaignIdentity } from "../spec-source-identity.mjs";
3
+ import { applyLocalPreviewToCheckpoint, applyLocalPreviewToPolishGate, carriedForwardMessage, CARRIED_FORWARD } from "../local-preview-policy.mjs";
3
4
  import { withHtmlScanSnapshot } from "../html-scan.mjs";
4
5
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
5
6
  import { dirname, join, resolve } from "node:path";
@@ -506,16 +507,23 @@ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath =
506
507
  }
507
508
  runPricingCssHideCheck({ packet, derived, warnings, ready, report });
508
509
 
509
- const polishCheckpointGate = evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report });
510
+ // The local preview policy (local-preview-policy.mjs) carries missing
511
+ // polish and page-load evidence forward as warnings on a local-serve packet.
512
+ const polishCheckpointGate = applyLocalPreviewToCheckpoint(
513
+ evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report }),
514
+ { packet, report },
515
+ );
510
516
  derived.polish_checkpoint_gate = polishCheckpointGate;
511
- const polishGate = evaluatePolishGate({
517
+ const polishGate = applyLocalPreviewToPolishGate(evaluatePolishGate({
512
518
  report,
513
519
  hiddenEagerMediaGate: polishCheckpointGate,
514
520
  currentOutputFingerprint: derived.build_output_fingerprint?.value || null,
515
- });
521
+ }), { packet, checkpointGate: polishCheckpointGate });
516
522
  derived.polish_gate = polishGate;
517
523
  if (polishGate.status === "blocked" && !polishGate.owned_checkpoint_only) {
518
524
  pushGateIssue({ errors, warnings }, gateIssue("polish_gate", polishGate));
525
+ } else if (polishGate.status === CARRIED_FORWARD && !polishGate.owned_checkpoint_only) {
526
+ addIssue(warnings, polishGate.code, carriedForwardMessage(polishGate), { polish_gate: polishGate });
519
527
  } else if (polishGate.status === "waived" && !polishGate.owned_checkpoint_only) {
520
528
  ready.push(`Polish gate passed under waiver: ${polishGate.waiver?.reason || "(no reason recorded)"}`);
521
529
  } else if (polishGate.status === "pass") {
@@ -524,6 +532,8 @@ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath =
524
532
 
525
533
  if (polishCheckpointGate.status === "blocked") {
526
534
  pushGateIssue({ errors, warnings }, gateIssue("polish_checkpoint_gate", polishCheckpointGate));
535
+ } else if (polishCheckpointGate.status === CARRIED_FORWARD) {
536
+ addIssue(warnings, polishCheckpointGate.code, carriedForwardMessage(polishCheckpointGate), { polish_checkpoint_gate: polishCheckpointGate });
527
537
  } else if (polishCheckpointGate.status === "waived") {
528
538
  addIssue(
529
539
  warnings,
@@ -1,5 +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 { CARRIED_FORWARD } from "../local-preview-policy.mjs";
3
4
  import { resolve } from "node:path";
4
5
  import { orderPathDepthDriftText } from "../proof-policy.mjs";
5
6
  import { anyAssemblyReportStageBlocked } from "../stage-ledger.mjs";
@@ -462,6 +463,9 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
462
463
  }
463
464
 
464
465
  for (const cliStage of NEXT_STAGE_ORDER) {
466
+ // On the local preview, missing polish is carried forward as a warning
467
+ // (local-preview-policy.mjs): the ladder moves on to deploy and QA.
468
+ if (cliStage === "polish" && polishGate.status === CARRIED_FORWARD) continue;
465
469
  const reportKey = reportKeyForCliStage(cliStage);
466
470
  const stage = report.stages[reportKey];
467
471
  if (!stage) {
@@ -62,6 +62,14 @@ export const HIDDEN_EAGER_MEDIA_ACTIONS = Object.freeze({
62
62
  command: null,
63
63
  description: localProofRebuildText(),
64
64
  }),
65
+ // Every mapped page is template stock: capture has no design route, so
66
+ // neither a recapture nor an authority repair can produce the evidence.
67
+ map_design_route: Object.freeze({
68
+ id: "polish.hidden_eager_media.map_design_route",
69
+ kind: "manual",
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.",
72
+ }),
65
73
  });
66
74
 
67
75
  const PACKET_PLACEHOLDER = "--packet <packet>";
@@ -0,0 +1,92 @@
1
+ // The local preview policy: one place that decides which missing evidence a
2
+ // localhost preview carries forward as a warning instead of a blocker.
3
+ //
4
+ // The local preview is a packet whose deploy target is `local-serve`, served
5
+ // from a loopback host. There a campaign must still prove its commerce (store
6
+ // and campaign binding, routes, SDK loading, prices, a typed-card order), but
7
+ // evidence that only design work or launch produces may be missing. Missing
8
+ // evidence is reported as missing, never as passed: a carried-forward gate is
9
+ // a warning in doctor, `next` does not stop on it, and QA records it as a
10
+ // `warn` row, so the verdict is at best `ready_with_exceptions`. A hosted
11
+ // preview or production packet never takes this path, and every check not
12
+ // named below keeps its meaning on every path.
13
+ //
14
+ // Doctor and QA apply this to the gates they evaluate; `next` reads doctor's
15
+ // gates. Recording a stage (`record polish`) and the waiver commands keep the
16
+ // strict gates.
17
+ import { isLocalServePacket } from "./local-proof.mjs";
18
+ import { isLoopbackHostname } from "./remit.mjs";
19
+
20
+ export const LOCAL_PREVIEW_POLICY = "local_preview";
21
+ export const CARRIED_FORWARD = "carried_forward";
22
+
23
+ // The checks carried forward, one by one. Each is evidence the campaign does
24
+ // not have yet, not evidence that something is wrong:
25
+ // - polish was never recorded for this build;
26
+ // - no page-load capture exists, either because polish capture never ran or
27
+ // because every mapped page is template stock and there is no design route
28
+ // to capture.
29
+ const CARRIED_POLISH_CODES = Object.freeze(new Set([
30
+ "polish.report_missing",
31
+ "polish.evidence_missing",
32
+ ]));
33
+ export const NO_CAPTURABLE_ROUTES_CODE = "polish.hidden_eager_media.no_capturable_routes";
34
+ const MISSING_CAPTURE_CODE = "polish.hidden_eager_media.capture_malformed";
35
+
36
+ // With no generatable brand theme the starter template is the design, so its
37
+ // palette and chrome are expected on the local preview: residue findings are
38
+ // warnings there, as they are under a waived theme gate.
39
+ const STARTER_IS_THE_DESIGN_THEME_CODE = "theme_gate.nothing_generatable";
40
+
41
+ function loopbackOrAbsent(url) {
42
+ if (url === undefined || url === null || url === "") return true;
43
+ try {
44
+ return isLoopbackHostname(new URL(String(url)).hostname);
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ export function isLocalPreview(packet, { baseUrl = null } = {}) {
51
+ return isLocalServePacket(packet)
52
+ && loopbackOrAbsent(packet?.deploy?.preview_url)
53
+ && loopbackOrAbsent(baseUrl);
54
+ }
55
+
56
+ function pageLoadRecorded(report) {
57
+ return report?.stages?.polish?.evidence?.visual_review?.page_load != null;
58
+ }
59
+
60
+ function carried(gate) {
61
+ return {
62
+ ...gate,
63
+ status: CARRIED_FORWARD,
64
+ carried_forward: { policy: LOCAL_PREVIEW_POLICY, from_status: gate.status, evidence: "missing" },
65
+ };
66
+ }
67
+
68
+ // The hidden eager-media checkpoint, evaluated on its own.
69
+ export function applyLocalPreviewToCheckpoint(gate, { packet, report, baseUrl = null } = {}) {
70
+ if (gate?.status !== "blocked" || !isLocalPreview(packet, { baseUrl })) return gate;
71
+ const missing = gate.code === NO_CAPTURABLE_ROUTES_CODE
72
+ || (gate.code === MISSING_CAPTURE_CODE && !pageLoadRecorded(report));
73
+ return missing ? carried(gate) : gate;
74
+ }
75
+
76
+ // The polish gate folds the checkpoint in, so it is carried forward only when
77
+ // whatever it owns from the checkpoint was carried forward too.
78
+ export function applyLocalPreviewToPolishGate(gate, { packet, checkpointGate = null, baseUrl = null } = {}) {
79
+ if (gate?.status !== "blocked" || !isLocalPreview(packet, { baseUrl })) return gate;
80
+ const checkpointCarried = checkpointGate?.status === CARRIED_FORWARD;
81
+ if (gate.owned_checkpoint_status === "blocked" && !checkpointCarried) return gate;
82
+ if (gate.owned_checkpoint_only) return checkpointCarried ? carried(gate) : gate;
83
+ return CARRIED_POLISH_CODES.has(gate.code) ? carried(gate) : gate;
84
+ }
85
+
86
+ export function starterResidueIsExpected(themeGate, { packet, baseUrl = null } = {}) {
87
+ return themeGate?.code === STARTER_IS_THE_DESIGN_THEME_CODE && isLocalPreview(packet, { baseUrl });
88
+ }
89
+
90
+ export function carriedForwardMessage(gate) {
91
+ return `Carried forward on the local preview: ${gate.reason} This evidence is missing, not passed; a hosted preview or production run requires it.`;
92
+ }
@@ -92,6 +92,10 @@ export function evaluatePageKitSdkVersion({
92
92
  waivers = [],
93
93
  required = true,
94
94
  now = new Date().toISOString(),
95
+ // { family, sdk_version } of the selected certified template family, when
96
+ // the caller knows it: the missing-pin reason names that verified SDK so the
97
+ // pin is not chosen by searching docs.
98
+ familyVerification = null,
95
99
  } = {}) {
96
100
  const pin = resolveSpecSdkPin(spec);
97
101
  const { has_canonical: hasCanonical, has_alias: hasAlias } = pin;
@@ -139,7 +143,10 @@ export function evaluatePageKitSdkVersion({
139
143
  scope: PAGE_KIT_SDK_VERSION_SCOPE,
140
144
  status: "blocked",
141
145
  code: "page_kit.sdk_version.spec_missing",
142
- reason: "CampaignSpec is missing global_config.sdk_version (and the runtime.sdk_version alias); add an explicit released SDK pin before build or QA.",
146
+ reason: "CampaignSpec is missing global_config.sdk_version (and the runtime.sdk_version alias); add an explicit released SDK pin before build or QA."
147
+ + (isReleasedSdkVersion(familyVerification?.sdk_version) && typeof familyVerification?.family === "string"
148
+ ? ` The "${familyVerification.family}" template family was last verified against ${familyVerification.sdk_version}.`
149
+ : ""),
143
150
  waivable: false,
144
151
  subject,
145
152
  state: { spec_status: "missing" },
@@ -1,4 +1,5 @@
1
1
  import { campaignIdentitiesMatch, localSpecIdentityFields } from "./spec-source-identity.mjs";
2
+ import { NO_CAPTURABLE_ROUTES_CODE } from "./local-preview-policy.mjs";
2
3
  import { createHash } from "node:crypto";
3
4
  import { HIDDEN_EAGER_MEDIA_ACTIONS } from "./gate-actions.mjs";
4
5
  import { dirname, join, resolve } from "node:path";
@@ -93,6 +94,8 @@ function mappedSpecRoute(value, pageId) {
93
94
  return route;
94
95
  }
95
96
 
97
+ const NO_CAPTURABLE_ROUTES_ERROR = "no_capturable_routes";
98
+
96
99
  export function planPolishCapture({ packet, baseUrl } = {}) {
97
100
  if (!isPlainObject(packet) || !Array.isArray(packet?.source_html?.pages) || packet.source_html.pages.length === 0) {
98
101
  throw new Error("polish capture requires packet.source_html.pages mappings.");
@@ -129,7 +132,14 @@ export function planPolishCapture({ packet, baseUrl } = {}) {
129
132
  });
130
133
  }
131
134
 
132
- if (routes.length === 0) throw new Error("polish capture has no mapped non-skipped routes to capture.");
135
+ if (routes.length === 0) {
136
+ const error = new Error(
137
+ "polish capture has no mapped non-skipped routes to capture: every mapped page is template stock (skip_reason), so there is no design route to compare."
138
+ + " On a local-serve preview, missing polish evidence is carried forward as a warning; run `next` for the next stage.",
139
+ );
140
+ error.code = NO_CAPTURABLE_ROUTES_ERROR;
141
+ throw error;
142
+ }
133
143
  routes.sort((a, b) => a.requested_route.localeCompare(b.requested_route) || a.page_id.localeCompare(b.page_id));
134
144
  for (let index = 1; index < routes.length; index += 1) {
135
145
  if (routes[index - 1].requested_route === routes[index].requested_route) {
@@ -189,6 +199,19 @@ function recordedAuthorityBlock({ packet, report, plan = null, now } = {}) {
189
199
  };
190
200
  }
191
201
 
202
+ // Every mapped page is template stock: there is nothing for polish capture to
203
+ // measure, which is missing evidence rather than a malformed packet. It stays
204
+ // a non-waivable block; the local preview policy carries it forward.
205
+ function noCapturableRoutesBlock({ packet, report, now } = {}) {
206
+ const block = recordedAuthorityBlock({ packet, report, now });
207
+ return {
208
+ ...block,
209
+ code: NO_CAPTURABLE_ROUTES_CODE,
210
+ reason: "Every mapped page is template stock (skip_reason), so polish capture has no design route to capture and this build has no page-load evidence.",
211
+ required_actions: [HIDDEN_EAGER_MEDIA_ACTIONS.map_design_route],
212
+ };
213
+ }
214
+
192
215
  function recordedCheckpointNotApplicable() {
193
216
  return {
194
217
  id: HIDDEN_EAGER_MEDIA_SCOPE,
@@ -219,7 +242,8 @@ export function evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report, now
219
242
  let plan;
220
243
  try {
221
244
  plan = planPolishCapture({ packet, baseUrl: "https://polish-capture.invalid" });
222
- } catch {
245
+ } catch (error) {
246
+ if (error?.code === NO_CAPTURABLE_ROUTES_ERROR) return noCapturableRoutesBlock({ packet, report, now });
223
247
  return recordedAuthorityBlock({ packet, report, now });
224
248
  }
225
249
 
@@ -58,7 +58,11 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
58
58
  binding:reportBound&&id(verdict.run_id)&&id(verdict.run_id)===id(report?.stages?.qa?.verdict_run_id)&&build&&qaSource===build&&doctor?.derived?.build_output_fingerprint?.status==='pass'&&hash(verdict.spec_hash)===localHash?'matching':'unconfirmed',
59
59
  publish_state:accepted(qaResult?.qa_verdict_publish?.state,['skipped','ok','failed']),
60
60
  }:null;
61
- const gates=(Array.isArray(continuation?.gates)?continuation.gates:[]).slice(0,PROGRESS_GATE_LIMIT).map(gate=>({id:accepted(gate?.id,PROGRESS_GATE_IDS),state:PROGRESS_GATE_IDS.includes(gate?.id)?accepted(gate?.status,['pass','blocked','waived','not_applicable']):'unknown'}));
61
+ // The snapshot schema has no carried-forward state: a gate the local preview
62
+ // policy carried forward does not apply on this path, so it is not_applicable
63
+ // here (never pass).
64
+ const gateState=status=>accepted(status==='carried_forward'?'not_applicable':status,['pass','blocked','waived','not_applicable']);
65
+ const gates=(Array.isArray(continuation?.gates)?continuation.gates:[]).slice(0,PROGRESS_GATE_LIMIT).map(gate=>({id:accepted(gate?.id,PROGRESS_GATE_IDS),state:PROGRESS_GATE_IDS.includes(gate?.id)?gateState(gate?.status):'unknown'}));
62
66
  const actions=[...new Set((Array.isArray(continuation?.next_actions)?continuation.next_actions:[]).slice(0,64).map(action=>accepted(action?.id,PROGRESS_ACTION_IDS)))];
63
67
  const stage=accepted(continuation?.stage,PROGRESS_CONTINUATIONS);
64
68
  const preview=typeof packet?.deploy?.preview_url==='string'&&packet.deploy.preview_url?packet.deploy.preview_url:null;
@@ -5,7 +5,7 @@ import { HTML_NAMESPACE, baseInEffect, documentBases, endsUnclosed, frozenBaseUr
5
5
 
6
6
  export const BINDING_SCHEMA = 'campaigns-os-page-binding/v0';
7
7
  export const BINDING_LIMITS = Object.freeze({ scripts_per_page: 6, scripts_per_run: 24, script_bytes: 262144, timeout_ms: 5000 });
8
- const SDK = /^https:\/\/cdn\.jsdelivr\.net\/gh\/NextCommerceCo\/campaign-cart@[^/]+\/(?:dist\/index\.js|public\/loader\.js)(?:\?[^#]*)?$/;
8
+ const SDK = /^https:\/\/cdn\.jsdelivr\.net\/gh\/NextCommerceCo\/campaign-cart@[^/]+\/(?:dist\/(?:index|loader)\.js|public\/loader\.js)(?:\?[^#]*)?$/;
9
9
  const str = value => typeof value === 'string' && value.length ? value : null;
10
10
 
11
11
  // The expected credential uses the existing producer resolver. Disagreement
@@ -2822,7 +2822,22 @@ async function pricingVisibilityAssertions(browserPage, page, options = {}) {
2822
2822
  selectors.length ? countVisiblePriceRows(browserPage, selectors) : 0,
2823
2823
  countVisiblePriceRows(browserPage, totalSelectors),
2824
2824
  ]);
2825
- return [checkoutPriceVisibilityAssertion({ page, selectors, visibleCount, totalSelectors, totalVisibleCount })];
2825
+ // Nothing priced is visible. When the checkout also has no package
2826
+ // selection of its own and the SDK reports an empty cart, the cart is
2827
+ // filled on an earlier page (a landing link carrying forcePackageId), and
2828
+ // opening the checkout directly shows a state no shopper reaches. The
2829
+ // test-order path enters that cart from the landing page instead.
2830
+ const emptyEntry = visibleCount === 0 && totalVisibleCount === 0
2831
+ ? await Promise.all([
2832
+ browserPage.evaluate(() => (typeof window.next?.getCartCount === "function" ? window.next.getCartCount() : null)),
2833
+ browserPage.evaluate(checkoutSelectionSurfaceScript()),
2834
+ ]).then(
2835
+ ([cart_count, selection_surface]) => ({ cart_count, selection_surface }),
2836
+ // A failed probe leaves the row failed, and says why on the row.
2837
+ (error) => ({ probe_error: trim(error?.message || String(error)).slice(0, 200) }),
2838
+ )
2839
+ : null;
2840
+ return [checkoutPriceVisibilityAssertion({ page, selectors, visibleCount, totalSelectors, totalVisibleCount, emptyEntry })];
2826
2841
  }
2827
2842
  return [];
2828
2843
  }
@@ -2896,25 +2911,37 @@ function upsellPriceVisibilityAssertion({ page, selectors, visibleCount }) {
2896
2911
  // no `totalSelectors` ran the bundle-row check alone, and the row says so
2897
2912
  // rather than reporting an empty selector list that reads like a check that
2898
2913
  // ran and found nothing.
2899
- function checkoutPriceVisibilityAssertion({ page, selectors, visibleCount, totalSelectors, totalVisibleCount = 0 }) {
2914
+ //
2915
+ // `emptyEntry` ({ cart_count, selection_surface }, or { probe_error }) is read
2916
+ // only when nothing priced is visible. An empty SDK cart (cart_count 0, never
2917
+ // null: null means no SDK was read) on a checkout with no package selection of
2918
+ // its own is not a price the shopper fails to see: the row is skipped with
2919
+ // that reason rather than failed, and only that row carries the probe values.
2920
+ function checkoutPriceVisibilityAssertion({ page, selectors, visibleCount, totalSelectors, totalVisibleCount = 0, emptyEntry = null }) {
2900
2921
  const totalChecked = Array.isArray(totalSelectors);
2901
2922
  const ok = visibleCount >= 1 || (totalChecked && totalVisibleCount >= 1);
2923
+ const enteredUpstream = !ok && emptyEntry?.cart_count === 0 && emptyEntry?.selection_surface?.count === 0;
2924
+ const counts = totalChecked
2925
+ ? `${visibleCount} visible price row(s); ${totalVisibleCount} visible cart-summary total(s)`
2926
+ : `${visibleCount} visible price row(s)`;
2902
2927
  return assertion({
2903
2928
  id: "pricing.checkout_price_visible",
2904
2929
  family: "pricing",
2905
2930
  page,
2906
- status: ok ? STATUS.PASS : STATUS.FAIL,
2907
- severity: ok ? undefined : SEVERITY.WARN,
2931
+ status: ok ? STATUS.PASS : enteredUpstream ? STATUS.SKIPPED : STATUS.FAIL,
2932
+ severity: ok || enteredUpstream ? undefined : SEVERITY.WARN,
2908
2933
  expected: totalChecked
2909
2934
  ? "at least one visible checkout bundle price row or a visible cart-summary total"
2910
2935
  : "at least one visible checkout bundle price row",
2911
- actual: totalChecked
2912
- ? `${visibleCount} visible price row(s); ${totalVisibleCount} visible cart-summary total(s)`
2913
- : `${visibleCount} visible price row(s)`,
2936
+ actual: enteredUpstream
2937
+ ? `${counts}: opened directly, the checkout has an empty cart and no package selection of its own; its cart is filled on an earlier page, and the test order enters it from there`
2938
+ : counts,
2914
2939
  evidence: {
2915
2940
  selectors,
2916
2941
  visible_count: visibleCount,
2917
2942
  ...(totalChecked ? { total_selectors: totalSelectors, total_visible_count: totalVisibleCount } : {}),
2943
+ ...(enteredUpstream ? { cart_count: emptyEntry.cart_count, checkout_selection_surface: emptyEntry.selection_surface } : {}),
2944
+ ...(emptyEntry?.probe_error ? { empty_cart_probe_error: emptyEntry.probe_error } : {}),
2918
2945
  page_url: page.url,
2919
2946
  },
2920
2947
  });
@@ -4962,10 +4989,7 @@ async function waitForCheckoutResult(page, events = null) {
4962
4989
  if (outcomeUrl.test(String(safePageUrl(page) || ""))) break;
4963
4990
  if (events) {
4964
4991
  const rejected = rejectedOrderCreateResponse(events);
4965
- if (rejected) {
4966
- const detail = typeof rejected.body?.detail === "string" ? `: ${trim(rejected.body.detail)}` : "";
4967
- throw new Error(`order create rejected: HTTP ${rejected.status}${detail}`);
4968
- }
4992
+ if (rejected) throw new Error(orderCreateRejectionMessage(rejected));
4969
4993
  const failed = failedOrderCreateRequest(events);
4970
4994
  if (failed) throw new Error(`order create request failed: ${failed.failure || "network failure"}`);
4971
4995
  }
@@ -4979,6 +5003,23 @@ async function waitForCheckoutResult(page, events = null) {
4979
5003
  // The MOST RECENT create response decides the outcome: SDKs/platforms retry
4980
5004
  // transient create failures, so [..., 400, 201] means the retry succeeded and
4981
5005
  // the earlier rejection is history, not the result.
5006
+ // The platform refuses an order whose customer, items and total match one it
5007
+ // accepted or is still processing in the last 30 minutes, and reports it in
5008
+ // `payment_details`. QA reuses one test customer, so two runs against the same
5009
+ // campaign at once, or a rerun after an attempt that died mid-submit, trip it.
5010
+ const DUPLICATE_ORDER_PATTERN = /duplicate order/i;
5011
+ const DUPLICATE_ORDER_REMEDY =
5012
+ "duplicate_order: the platform refused an order matching a recent one from the same test customer with the same items and total, " +
5013
+ "for up to 30 minutes. Concurrent QA runs that share a test customer collide. Re-run with a different --test-email-prefix " +
5014
+ "(or --test-email), or wait. The shipping address is not part of the match";
5015
+
5016
+ function orderCreateRejectionMessage(rejected) {
5017
+ const body = rejected?.body;
5018
+ const reason = [body?.detail, body?.payment_details].find((value) => typeof value === "string" && value.trim());
5019
+ const base = `order create rejected: HTTP ${rejected?.status}${reason ? `: ${trim(reason)}` : ""}`;
5020
+ return reason && DUPLICATE_ORDER_PATTERN.test(reason) ? `${base} (${DUPLICATE_ORDER_REMEDY})` : base;
5021
+ }
5022
+
4982
5023
  function rejectedOrderCreateResponse(events) {
4983
5024
  for (let index = events.responses.length - 1; index >= 0; index -= 1) {
4984
5025
  const response = events.responses[index];
@@ -6393,7 +6434,7 @@ function sanitizedEvents(events) {
6393
6434
  responses: events.responses.slice(-20).map((response) => ({
6394
6435
  status: response.status,
6395
6436
  url: response.url,
6396
- body: summarizeResponseBody(response.body),
6437
+ body: summarizeResponseBody(response.body, { status: response.status }),
6397
6438
  })),
6398
6439
  failed: events.failed.slice(-20),
6399
6440
  console: events.console.slice(-20),
@@ -6420,7 +6461,7 @@ function summarizeRequestPostData(value) {
6420
6461
  }
6421
6462
  }
6422
6463
 
6423
- function summarizeResponseBody(body) {
6464
+ function summarizeResponseBody(body, { status = null } = {}) {
6424
6465
  if (typeof body === "string") return trim(body).slice(0, 1000);
6425
6466
  if (!body || typeof body !== "object" || Array.isArray(body)) return body;
6426
6467
  return {
@@ -6432,6 +6473,12 @@ function summarizeResponseBody(body) {
6432
6473
  ...(body.checkout_url ? { checkout_url: body.checkout_url } : {}),
6433
6474
  ...(Array.isArray(body.lines) ? { lines: extractReceiptLines(body) } : {}),
6434
6475
  ...(body.detail ? { detail: body.detail } : {}),
6476
+ // A rejected request names its reason here (for example the
6477
+ // duplicate-order refusal). Kept only on an error response, so a
6478
+ // successful create never carries payment_details into evidence.
6479
+ ...(Number(status) >= 400 && typeof body.payment_details === "string"
6480
+ ? { payment_details: trim(body.payment_details).slice(0, 300) }
6481
+ : {}),
6435
6482
  };
6436
6483
  }
6437
6484
 
@@ -7789,6 +7836,8 @@ export const __qaBrowserTestHooks = Object.freeze({
7789
7836
  linePriceDeltaEvidence,
7790
7837
  packageMatchesLine,
7791
7838
  rejectedOrderCreateResponse,
7839
+ orderCreateRejectionMessage,
7840
+ summarizeResponseBody,
7792
7841
  failedOrderCreateRequest,
7793
7842
  extractOrderVouchers,
7794
7843
  orderDiscountTotal,
package/src/qa-node.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch } from "./spec-source-identity.mjs";
2
+ import { applyLocalPreviewToCheckpoint, applyLocalPreviewToPolishGate, carriedForwardMessage, CARRIED_FORWARD, starterResidueIsExpected } from "./local-preview-policy.mjs";
2
3
  import { expectedBinding, createBindingScriptLoader, observeBinding, bindingAssertion, scriptParseAssertion } from './qa-binding-evidence.mjs';
3
4
  import { shellToken } from "./shell-token.mjs";
4
5
  import { applyQaBuildScope, specForQaScope } from "./qa-build-scope.mjs";
@@ -481,6 +482,8 @@ async function resolveQaInputs(args, {
481
482
  packetPath,
482
483
  report: checkpointPreflight?.runtimeReport,
483
484
  hiddenEagerMediaGate,
485
+ packet,
486
+ baseUrl: stringArg(args["base-url"]),
484
487
  });
485
488
  const qaWaivers = resolveQaWaivers({ packetPath, report: checkpointPreflight?.runtimeReport });
486
489
  const qaScope = applyQaBuildScope(topologies, {
@@ -593,7 +596,10 @@ function resolvePacketCheckpointPreflight(args, {
593
596
  waivers: report?.waivers,
594
597
  required: true,
595
598
  }),
596
- evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report }),
599
+ applyLocalPreviewToCheckpoint(
600
+ evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report }),
601
+ { packet, report, baseUrl: stringArg(args["base-url"]) },
602
+ ),
597
603
  ];
598
604
  const runtimeReport = reportMatchesPacketIdentity(report, packet) ? report : null;
599
605
  return {
@@ -692,6 +698,8 @@ function resolvedFromBlockedCheckpointPreflight(preflight, args) {
692
698
  packetPath: preflight.packetPath,
693
699
  report: preflight.runtimeReport,
694
700
  hiddenEagerMediaGate,
701
+ packet: preflight.packet,
702
+ baseUrl: stringArg(args["base-url"]),
695
703
  });
696
704
  return {
697
705
  themeGate,
@@ -931,16 +939,18 @@ function resolvePolishGate({
931
939
  packetPath,
932
940
  report: reportOverride = undefined,
933
941
  hiddenEagerMediaGate = undefined,
942
+ packet = null,
943
+ baseUrl = null,
934
944
  }) {
935
945
  const report = reportOverride === undefined
936
946
  ? loadRuntimeArtifact(packetPath, "assembly-report.json")
937
947
  : reportOverride;
938
- const gate = evaluatePolishGate({
948
+ const gate = applyLocalPreviewToPolishGate(evaluatePolishGate({
939
949
  report,
940
950
  required: true,
941
951
  hiddenEagerMediaGate,
942
952
  currentOutputFingerprint: currentBuiltOutputFingerprint(packetPath),
943
- });
953
+ }), { packet, checkpointGate: hiddenEagerMediaGate, baseUrl });
944
954
  gate.scope_source = report ? "assembly_report" : "missing_assembly_report";
945
955
  return gate;
946
956
  }
@@ -1044,6 +1054,18 @@ function polishGateAssertion(gate) {
1044
1054
  },
1045
1055
  });
1046
1056
  }
1057
+ if (gate.status === CARRIED_FORWARD) {
1058
+ return assertion({
1059
+ id: gate.code,
1060
+ family: "polish_gate",
1061
+ page,
1062
+ status: STATUS.WARN,
1063
+ severity: SEVERITY.WARN,
1064
+ expected: "current structured Polish evidence produced by next-campaigns-polish",
1065
+ actual: carriedForwardMessage(gate),
1066
+ evidence: { ...evidence, reason: gate.reason, carried_forward: gate.carried_forward },
1067
+ });
1068
+ }
1047
1069
  if (gate.status === "not_applicable") {
1048
1070
  return assertion({
1049
1071
  id: gate.code,
@@ -1350,6 +1372,9 @@ function hiddenEagerMediaGateAssertion(gate) {
1350
1372
  if (summary.status === "waived") {
1351
1373
  return assertion({ ...common, status: STATUS.WARN, severity: SEVERITY.WARN, waiver: summary.waiver });
1352
1374
  }
1375
+ if (summary.status === CARRIED_FORWARD) {
1376
+ return assertion({ ...common, status: STATUS.WARN, severity: SEVERITY.WARN, actual: `${summary.code}: ${carriedForwardMessage(summary)}` });
1377
+ }
1353
1378
  if (summary.status === "not_applicable") {
1354
1379
  return assertion({ ...common, status: STATUS.SKIPPED });
1355
1380
  }
@@ -2306,7 +2331,11 @@ async function runResolvedQa(args, resolved, { runSessionActive = false, liveCam
2306
2331
  if (args.browser === true) {
2307
2332
  assertions.push(...await runBrowserChecks(resolved.topologies, args, {
2308
2333
  brandContract: resolved.brandContract,
2309
- residueSeverity: residueSeverityForThemeGate(gate.status),
2334
+ // With no generatable brand theme on the local preview, the starter
2335
+ // template is the design: its residue is a warning (local-preview-policy.mjs).
2336
+ residueSeverity: starterResidueIsExpected(gate, { packet: resolved.packet, baseUrl: resolved.baseUrl })
2337
+ ? SEVERITY.WARN
2338
+ : residueSeverityForThemeGate(gate.status),
2310
2339
  supportedPaymentMethods: supportedPaymentMethodsFromSpec(resolved.spec),
2311
2340
  }));
2312
2341
  }