@nextcommerce/campaigns-os 1.33.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 (310) hide show
  1. package/AGENTS.md +204 -0
  2. package/CHANGELOG.md +5002 -0
  3. package/CONTEXT.md +685 -0
  4. package/LICENSE +202 -0
  5. package/NOTICE +4 -0
  6. package/README.md +368 -0
  7. package/agents/claude/CLAUDE.md +32 -0
  8. package/agents/codex/AGENTS.md +27 -0
  9. package/agents/copilot/copilot-instructions.md +14 -0
  10. package/agents/cursor/campaigns-os.mdc +13 -0
  11. package/bin/campaigns-os.mjs +38 -0
  12. package/campaign-spec/README.md +138 -0
  13. package/campaign-spec/dist/analytics-vocabulary.d.ts +47 -0
  14. package/campaign-spec/dist/analytics-vocabulary.js +74 -0
  15. package/campaign-spec/dist/index.d.ts +40 -0
  16. package/campaign-spec/dist/index.js +77 -0
  17. package/campaign-spec/dist/normalize.d.ts +22 -0
  18. package/campaign-spec/dist/normalize.js +41 -0
  19. package/campaign-spec/dist/routing.d.ts +190 -0
  20. package/campaign-spec/dist/routing.js +263 -0
  21. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +42 -0
  22. package/campaign-spec/dist/rules/analytics-contract-shape.js +303 -0
  23. package/campaign-spec/dist/rules/assembly-hints-shape.d.ts +42 -0
  24. package/campaign-spec/dist/rules/assembly-hints-shape.js +191 -0
  25. package/campaign-spec/dist/rules/campaign-metadata.d.ts +14 -0
  26. package/campaign-spec/dist/rules/campaign-metadata.js +40 -0
  27. package/campaign-spec/dist/rules/checkout-has-success-url.d.ts +31 -0
  28. package/campaign-spec/dist/rules/checkout-has-success-url.js +66 -0
  29. package/campaign-spec/dist/rules/cycle-detection.d.ts +12 -0
  30. package/campaign-spec/dist/rules/cycle-detection.js +141 -0
  31. package/campaign-spec/dist/rules/design-source-shape.d.ts +29 -0
  32. package/campaign-spec/dist/rules/design-source-shape.js +142 -0
  33. package/campaign-spec/dist/rules/downsell-without-upsell.d.ts +11 -0
  34. package/campaign-spec/dist/rules/downsell-without-upsell.js +40 -0
  35. package/campaign-spec/dist/rules/exit-intent-validation.d.ts +23 -0
  36. package/campaign-spec/dist/rules/exit-intent-validation.js +147 -0
  37. package/campaign-spec/dist/rules/funnel-count.d.ts +9 -0
  38. package/campaign-spec/dist/rules/funnel-count.js +37 -0
  39. package/campaign-spec/dist/rules/funnel-hypothesis-length.d.ts +22 -0
  40. package/campaign-spec/dist/rules/funnel-hypothesis-length.js +60 -0
  41. package/campaign-spec/dist/rules/funnel-identity.d.ts +15 -0
  42. package/campaign-spec/dist/rules/funnel-identity.js +57 -0
  43. package/campaign-spec/dist/rules/funnel-weight-sum.d.ts +20 -0
  44. package/campaign-spec/dist/rules/funnel-weight-sum.js +66 -0
  45. package/campaign-spec/dist/rules/index.d.ts +69 -0
  46. package/campaign-spec/dist/rules/index.js +131 -0
  47. package/campaign-spec/dist/rules/offer-ref-integrity.d.ts +13 -0
  48. package/campaign-spec/dist/rules/offer-ref-integrity.js +59 -0
  49. package/campaign-spec/dist/rules/package-pricing-sanity.d.ts +12 -0
  50. package/campaign-spec/dist/rules/package-pricing-sanity.js +42 -0
  51. package/campaign-spec/dist/rules/page-count.d.ts +11 -0
  52. package/campaign-spec/dist/rules/page-count.js +31 -0
  53. package/campaign-spec/dist/rules/page-id-uniqueness.d.ts +14 -0
  54. package/campaign-spec/dist/rules/page-id-uniqueness.js +47 -0
  55. package/campaign-spec/dist/rules/promo-code-input-validation.d.ts +8 -0
  56. package/campaign-spec/dist/rules/promo-code-input-validation.js +126 -0
  57. package/campaign-spec/dist/rules/promo-codes-shape.d.ts +30 -0
  58. package/campaign-spec/dist/rules/promo-codes-shape.js +187 -0
  59. package/campaign-spec/dist/rules/route-field-ignored-for-page-type.d.ts +33 -0
  60. package/campaign-spec/dist/rules/route-field-ignored-for-page-type.js +81 -0
  61. package/campaign-spec/dist/rules/route-target-resolves.d.ts +32 -0
  62. package/campaign-spec/dist/rules/route-target-resolves.js +112 -0
  63. package/campaign-spec/dist/rules/schema-version.d.ts +21 -0
  64. package/campaign-spec/dist/rules/schema-version.js +53 -0
  65. package/campaign-spec/dist/rules/sdk-version.d.ts +26 -0
  66. package/campaign-spec/dist/rules/sdk-version.js +96 -0
  67. package/campaign-spec/dist/rules/shipping-countries-shape.d.ts +10 -0
  68. package/campaign-spec/dist/rules/shipping-countries-shape.js +30 -0
  69. package/campaign-spec/dist/rules/shipping-methods-present.d.ts +10 -0
  70. package/campaign-spec/dist/rules/shipping-methods-present.js +26 -0
  71. package/campaign-spec/dist/rules/store-profile-shape.d.ts +30 -0
  72. package/campaign-spec/dist/rules/store-profile-shape.js +127 -0
  73. package/campaign-spec/dist/rules/thank-you-requirement.d.ts +16 -0
  74. package/campaign-spec/dist/rules/thank-you-requirement.js +50 -0
  75. package/campaign-spec/dist/rules/unknown-top-level-fields.d.ts +28 -0
  76. package/campaign-spec/dist/rules/unknown-top-level-fields.js +114 -0
  77. package/campaign-spec/dist/rules/upsell-has-packages.d.ts +9 -0
  78. package/campaign-spec/dist/rules/upsell-has-packages.js +35 -0
  79. package/campaign-spec/dist/rules/upsell-routing-complete.d.ts +10 -0
  80. package/campaign-spec/dist/rules/upsell-routing-complete.js +45 -0
  81. package/campaign-spec/dist/rules/upsell-without-checkout.d.ts +11 -0
  82. package/campaign-spec/dist/rules/upsell-without-checkout.js +44 -0
  83. package/campaign-spec/dist/rules/variant-labels-shape.d.ts +28 -0
  84. package/campaign-spec/dist/rules/variant-labels-shape.js +86 -0
  85. package/campaign-spec/dist/sdk-version-parse.d.ts +43 -0
  86. package/campaign-spec/dist/sdk-version-parse.js +62 -0
  87. package/campaign-spec/dist/types.d.ts +674 -0
  88. package/campaign-spec/dist/types.js +40 -0
  89. package/campaign-spec/package.json +12 -0
  90. package/compatibility.json +25 -0
  91. package/contracts/agent-relevant-change-policy.v1.json +111 -0
  92. package/contracts/brand-theme-source-defaults.figma-sections-export.v0.json +32 -0
  93. package/contracts/brand-theme-target-tokens.next-core.v0.json +65 -0
  94. package/contracts/campaign-cart-checkout-field-contract.v0.json +45 -0
  95. package/contracts/campaign-cart-sdk-support-policy.v0.json +11 -0
  96. package/contracts/commerce-surface-catalog.json +2452 -0
  97. package/contracts/fixtures/orientation/canonicalization/v1.json +34 -0
  98. package/contracts/fixtures/orientation/envelope/current.json +95 -0
  99. package/contracts/fixtures/orientation/envelope/freshness_unknown.json +97 -0
  100. package/contracts/fixtures/orientation/envelope/legacy_baseline.json +95 -0
  101. package/contracts/fixtures/orientation/envelope/orientation_available.json +136 -0
  102. package/contracts/fixtures/orientation/envelope/recovered_interrupted_update.json +136 -0
  103. package/contracts/fixtures/orientation/envelope/refused.json +100 -0
  104. package/contracts/fixtures/orientation/envelope/restart_required.json +137 -0
  105. package/contracts/fixtures/orientation/envelope/updated.json +135 -0
  106. package/contracts/fixtures/orientation/hostile-target/README.md +61 -0
  107. package/contracts/fixtures/orientation/hostile-target/manifest.json +82 -0
  108. package/contracts/fixtures/orientation/hostile-target/repo/CHANGELOG.md +18 -0
  109. package/contracts/fixtures/orientation/hostile-target/repo/bin/intended.mjs +12 -0
  110. package/contracts/fixtures/orientation/hostile-target/repo/bin/tripwire.mjs +15 -0
  111. package/contracts/fixtures/orientation/hostile-target/repo/contracts/release-ledger.json +48 -0
  112. package/contracts/fixtures/orientation/hostile-target/repo/contracts/supported-surface.json +17 -0
  113. package/contracts/fixtures/orientation/hostile-target/repo/docs/example-contract.md +13 -0
  114. package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-checkout +5 -0
  115. package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-merge +5 -0
  116. package/contracts/fixtures/orientation/hostile-target/repo/hooks/pre-commit +5 -0
  117. package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/package.json +15 -0
  118. package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/tripwire.mjs +9 -0
  119. package/contracts/fixtures/orientation/hostile-target/repo/package.json +20 -0
  120. package/contracts/fixtures/orientation/hostile-target/repo/schemas/example.v0.schema.json +15 -0
  121. package/contracts/fixtures/orientation/release-gate/cases.json +1073 -0
  122. package/contracts/fixtures/runtime-recipe/accept/current.json +299 -0
  123. package/contracts/fixtures/runtime-recipe/accept/minimal.json +294 -0
  124. package/contracts/fixtures/runtime-recipe/dist-states.json +51 -0
  125. package/contracts/fixtures/runtime-recipe/manifest.json +85 -0
  126. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +299 -0
  127. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +297 -0
  128. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +299 -0
  129. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +299 -0
  130. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +299 -0
  131. package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +251 -0
  132. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +299 -0
  133. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +299 -0
  134. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +310 -0
  135. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +299 -0
  136. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +299 -0
  137. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +299 -0
  138. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +299 -0
  139. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/assembly-report.json +180 -0
  140. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/build-context.json +115 -0
  141. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/doctor-output.json +29 -0
  142. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/qa-verdict.json +27 -0
  143. package/contracts/fixtures/sidecar-bundle/production-shaped/campaign-runtime.build.json +141 -0
  144. package/contracts/migration-sidecar-bundle.v0.json +149 -0
  145. package/contracts/orientation-limits.v1.json +41 -0
  146. package/contracts/orientation-reason-codes.v1.json +196 -0
  147. package/contracts/private-template-sources.json +8 -0
  148. package/contracts/release-ledger.json +4598 -0
  149. package/contracts/reserved-skill-names.json +13 -0
  150. package/contracts/runtime-recipe.campaigns-os-node-v1.json +299 -0
  151. package/contracts/supported-surface.json +180 -0
  152. package/contracts/template-brand-contract.apollo-mv-single-step.v0.json +27 -0
  153. package/contracts/template-brand-contract.apollo.v0.json +27 -0
  154. package/contracts/template-brand-contract.demeter.v0.json +27 -0
  155. package/contracts/template-brand-contract.olympus-mv-single-step.v0.json +27 -0
  156. package/contracts/template-brand-contract.olympus-mv-two-step.v0.json +28 -0
  157. package/contracts/template-brand-contract.olympus.v0.json +27 -0
  158. package/contracts/template-brand-contract.shared-commerce.v0.json +190 -0
  159. package/contracts/template-brand-contract.shop-single-step.v0.json +27 -0
  160. package/contracts/template-brand-contract.shop-three-step.v0.json +29 -0
  161. package/contracts/template-slot-manifest.apollo-mv-single-step.v0.json +14 -0
  162. package/contracts/template-slot-manifest.apollo.v0.json +12 -0
  163. package/contracts/template-slot-manifest.demeter.v0.json +30 -0
  164. package/contracts/template-slot-manifest.olympus-mv-single-step.v0.json +14 -0
  165. package/contracts/template-slot-manifest.olympus-mv-two-step.v0.json +15 -0
  166. package/contracts/template-slot-manifest.olympus.v0.json +12 -0
  167. package/contracts/template-slot-manifest.shared-content-core.v0.json +4254 -0
  168. package/contracts/template-slot-manifest.shop-single-step.v0.json +47 -0
  169. package/contracts/template-slot-manifest.shop-three-step.v0.json +33 -0
  170. package/docs/brand-theme-bridge.md +159 -0
  171. package/docs/build-packet.md +1300 -0
  172. package/docs/campaign-build-brief.md +145 -0
  173. package/docs/campaign-standardization-report.md +329 -0
  174. package/docs/campaigns-os-build-flow.md +117 -0
  175. package/docs/design-source-package.md +784 -0
  176. package/docs/legacy-migration.md +58 -0
  177. package/docs/migration-sidecar-bundle.md +139 -0
  178. package/docs/orientation-contract-reference.md +1220 -0
  179. package/docs/polish-evidence.md +502 -0
  180. package/docs/qa-and-test-orders.md +1691 -0
  181. package/docs/release-ledger-authoring-guide.md +274 -0
  182. package/docs/runtime-readiness.md +211 -0
  183. package/docs/supported-surface.md +83 -0
  184. package/docs/versioning.md +55 -0
  185. package/docs/workflow-findings-sidecar.md +588 -0
  186. package/package.json +135 -0
  187. package/prompts/first-build.md +27 -0
  188. package/prompts/friction-log.md +30 -0
  189. package/schemas/campaign-build-brief.v1.schema.json +149 -0
  190. package/schemas/campaign-design-source-package.v0.schema.json +697 -0
  191. package/schemas/campaign-runtime-assembly-report.v0.schema.json +390 -0
  192. package/schemas/campaign-runtime-build-context.v0.schema.json +339 -0
  193. package/schemas/campaign-runtime-build-packet.v0.schema.json +452 -0
  194. package/schemas/campaign-spec.v4.schema.json +582 -0
  195. package/schemas/campaigns-os-doctor-output.v0.schema.json +43 -0
  196. package/schemas/campaigns-os-legacy-migration-inventory.v0.schema.json +112 -0
  197. package/schemas/campaigns-os-legacy-provisioning-plan.v0.schema.json +38 -0
  198. package/schemas/campaigns-os-legacy-provisioning-receipt.v0.schema.json +57 -0
  199. package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +150 -0
  200. package/schemas/campaigns-os-qa-verdict.v0.schema.json +438 -0
  201. package/schemas/campaigns-os-release-ledger.v1.schema.json +162 -0
  202. package/schemas/campaigns-os-run-record.v0.schema.json +343 -0
  203. package/schemas/campaigns-os-runtime-recipe.v1.schema.json +313 -0
  204. package/schemas/campaigns-os-sidecar-bundle-conformance.v0.schema.json +78 -0
  205. package/schemas/campaigns-os-tooling-orientation.v1.schema.json +381 -0
  206. package/schemas/campaigns-os-workflow-finding.v0.schema.json +134 -0
  207. package/schemas/source-html-manifest.v0.schema.json +252 -0
  208. package/skills/next-campaigns-build/SKILL.md +73 -0
  209. package/skills/next-campaigns-os/SKILL.md +101 -0
  210. package/skills/next-campaigns-os/references/session-intake.md +160 -0
  211. package/skills/next-campaigns-os-setup/SKILL.md +22 -0
  212. package/skills/next-campaigns-polish/SKILL.md +145 -0
  213. package/skills/next-campaigns-qa/SKILL.md +92 -0
  214. package/skills.json +56 -0
  215. package/skills.sh +64 -0
  216. package/src/adapter-decision-contract.mjs +333 -0
  217. package/src/brand-theme.mjs +1151 -0
  218. package/src/browser-launch.mjs +79 -0
  219. package/src/build-brief.mjs +781 -0
  220. package/src/built-site-scope.mjs +312 -0
  221. package/src/campaign-ecosystem.mjs +734 -0
  222. package/src/campaign-identity.mjs +405 -0
  223. package/src/campaign-workspace.mjs +127 -0
  224. package/src/checkpoint-waiver.mjs +302 -0
  225. package/src/cli.mjs +13442 -0
  226. package/src/commercial-journey.mjs +1119 -0
  227. package/src/commercial-parity.mjs +965 -0
  228. package/src/consent.mjs +347 -0
  229. package/src/content-residue.mjs +322 -0
  230. package/src/deadline.mjs +81 -0
  231. package/src/design-source-package.mjs +2604 -0
  232. package/src/deviation.mjs +107 -0
  233. package/src/doctor-check-registry.mjs +49 -0
  234. package/src/doctor-sidecar.mjs +106 -0
  235. package/src/finding-cause.mjs +557 -0
  236. package/src/findings.mjs +326 -0
  237. package/src/fs-identity.mjs +68 -0
  238. package/src/gate-actions.mjs +105 -0
  239. package/src/html-scan.mjs +53 -0
  240. package/src/install-mode.mjs +272 -0
  241. package/src/legacy-migration.d.ts +128 -0
  242. package/src/legacy-migration.mjs +510 -0
  243. package/src/lifecycle.mjs +338 -0
  244. package/src/local-proof.mjs +401 -0
  245. package/src/map-pin-writeback.mjs +210 -0
  246. package/src/orchestration-stage-contract.mjs +81 -0
  247. package/src/package-install-fixture.mjs +33 -0
  248. package/src/page-kit-build-summary.mjs +175 -0
  249. package/src/page-kit-campaign-config.mjs +57 -0
  250. package/src/page-kit-sdk-version.mjs +392 -0
  251. package/src/page-kit-store-profile.mjs +369 -0
  252. package/src/page-kit-sync.mjs +162 -0
  253. package/src/polish-browser.mjs +867 -0
  254. package/src/polish-capture.mjs +1094 -0
  255. package/src/polish-deadline.mjs +51 -0
  256. package/src/polish-gate.mjs +739 -0
  257. package/src/polish-node.mjs +639 -0
  258. package/src/polish-page-load.mjs +1100 -0
  259. package/src/private-template-source.mjs +237 -0
  260. package/src/proof-policy.mjs +82 -0
  261. package/src/qa-analytics-correctness.mjs +307 -0
  262. package/src/qa-analytics-errors.mjs +38 -0
  263. package/src/qa-analytics-parity.mjs +699 -0
  264. package/src/qa-binding-evidence.mjs +140 -0
  265. package/src/qa-browser.mjs +6608 -0
  266. package/src/qa-cart-entry.mjs +406 -0
  267. package/src/qa-commercial-parity.mjs +641 -0
  268. package/src/qa-node.mjs +3620 -0
  269. package/src/qa-order-bump.mjs +381 -0
  270. package/src/qa-parity-capture.mjs +428 -0
  271. package/src/qa-parity-fixture.mjs +359 -0
  272. package/src/qa-publish.mjs +362 -0
  273. package/src/qa-purchase-data-layer.mjs +263 -0
  274. package/src/qa-route-probe.mjs +272 -0
  275. package/src/qa-sidecar.mjs +188 -0
  276. package/src/qa-test-order-topology.mjs +207 -0
  277. package/src/qa-url-privacy.mjs +13 -0
  278. package/src/qa-verdict-discovery.mjs +192 -0
  279. package/src/qa-verdict-publish.mjs +105 -0
  280. package/src/qa-verdict.mjs +287 -0
  281. package/src/remit.mjs +388 -0
  282. package/src/repo-scan.mjs +83 -0
  283. package/src/route-identity.mjs +133 -0
  284. package/src/run-record-closeout.mjs +229 -0
  285. package/src/run-record.mjs +839 -0
  286. package/src/run-session.mjs +226 -0
  287. package/src/runtime-state-ignore.mjs +113 -0
  288. package/src/sdk-attribute-index.mjs +212 -0
  289. package/src/sdk-markup.mjs +358 -0
  290. package/src/sdk-meta-tags.mjs +52 -0
  291. package/src/shell-token.mjs +7 -0
  292. package/src/sidecar-bundle.mjs +399 -0
  293. package/src/source-asset-crawl.mjs +469 -0
  294. package/src/source-html-intake.mjs +627 -0
  295. package/src/source-html-manifest.mjs +276 -0
  296. package/src/source-prep.mjs +284 -0
  297. package/src/spec-derive-store.mjs +431 -0
  298. package/src/spec-derive.mjs +514 -0
  299. package/src/spec-fetch.mjs +66 -0
  300. package/src/spec-hash.mjs +48 -0
  301. package/src/spec-identity.mjs +27 -0
  302. package/src/stage-ledger.mjs +509 -0
  303. package/src/standardization-report.mjs +1297 -0
  304. package/src/template-brand-contract.mjs +473 -0
  305. package/src/template-freshness.mjs +196 -0
  306. package/src/template-reference.mjs +81 -0
  307. package/src/template-slot-manifest.mjs +150 -0
  308. package/src/text-safety.mjs +62 -0
  309. package/src/theme-gate.mjs +185 -0
  310. package/src/upsell-selector-scope.mjs +299 -0
@@ -0,0 +1,3620 @@
1
+ import { expectedBinding, createBindingScriptLoader, observeBinding, bindingAssertion } from './qa-binding-evidence.mjs';
2
+ import { shellToken } from "./shell-token.mjs";
3
+ import { requiredActionText } from "./gate-actions.mjs";
4
+ import { parseOrderPathDepthFlag } from "./proof-policy.mjs";
5
+ import {
6
+ isAbsoluteHttpUrl,
7
+ normalizePageKitRoute,
8
+ normalizePublicRouteSlug,
9
+ resolveRouteRoot,
10
+ runtimeRelativeRouteForSpecValue,
11
+ stripPublicRoutePrefix,
12
+ } from "./route-identity.mjs";
13
+ import { singleLineFragment } from "./text-safety.mjs";
14
+ import { absentOrMalformed } from "./fs-identity.mjs";
15
+ import { DEFAULT_PROXY_BASE, fetchSpecByMapId } from "./spec-fetch.mjs";
16
+ import { specMaterialHash } from "./spec-identity.mjs";
17
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
18
+ import { PLAYWRIGHT_INSTALL_HINT } from "./browser-launch.mjs";
19
+ import { dirname, join, relative, resolve, isAbsolute } from "node:path";
20
+ import { spawnSync } from "node:child_process";
21
+ import { createRequire } from "node:module";
22
+ import { invocationPrefixFor } from "./install-mode.mjs";
23
+ import { dirname as installModeDirname, resolve as installModeResolve } from "node:path";
24
+ import { fileURLToPath as installModeFileUrl } from "node:url";
25
+ const PACKAGE_ROOT = installModeResolve(installModeDirname(installModeFileUrl(import.meta.url)), "..");
26
+ // Commands this module produces are spelled for the install they come from
27
+ // (see install-mode.mjs), once, at the producer.
28
+ function cmd(verb, rest = "") {
29
+ return `${invocationPrefixFor(PACKAGE_ROOT)} ${verb}${rest ? ` ${rest}` : ""}`;
30
+ }
31
+ import { runAnalyticsCorrectnessChecks, runAnalyticsParityChecks, runBrowserChecks, runBrowserTestOrders, testEmail, validatedOrderCreationLimit } from "./qa-browser.mjs";
32
+ import { assessReceiptPurchase } from "./qa-analytics-correctness.mjs";
33
+ import { createVerdict, isFindingAssertion, QA_ASSERTION_FAMILY_VOCABULARY, SESSION_ENDING_DISPOSITIONS, SEVERITY, STATUS, validateVerdict } from "./qa-verdict.mjs";
34
+ import { normalizeSdkMetaName, lookupSdkIgnoredMetaTag } from "./sdk-meta-tags.mjs";
35
+ import { annotateQaAssertionCauses, formatCauseReportLines, formatCauseTag } from "./finding-cause.mjs";
36
+ import { promoteQaVerdict, writeQaSidecar } from "./qa-sidecar.mjs";
37
+ import { publishQaVerdict, qaPortalUrl, qaVerdictPublishBlock, QA_VERDICT_PUBLISHERS, skippedQaVerdictPublish } from "./qa-verdict-publish.mjs";
38
+ import { publishStoredVerdict, qaPublishTextLines, QA_PUBLISH_EXIT_CODES } from "./qa-publish.mjs";
39
+ // Shared outgoing-edge resolver, so QA expectations and build-time wiring
40
+ // cannot drift on which declared routing field wins.
41
+ import {
42
+ acceptRouteTarget,
43
+ applicableForwardFields,
44
+ declineRouteTarget,
45
+ forwardRouteTarget,
46
+ inapplicableForwardFields,
47
+ } from "../campaign-spec/dist/index.js";
48
+ import { evaluateThemeGate } from "./theme-gate.mjs";
49
+ import { probeRouteUrls, ROUTE_PROBE_DEFAULT_TIMEOUT_MS } from "./qa-route-probe.mjs";
50
+ import { resolveCommerceCatalog, resolvePacketCommerceCatalogPath, resolveTemplateBrandContract } from "./private-template-source.mjs";
51
+ import { computeBuildFingerprint, resolveBuiltSiteScope, topologiesFromBuiltSiteScope } from "./built-site-scope.mjs";
52
+ import { evaluatePolishGate } from "./polish-gate.mjs";
53
+ import { evaluateRecordedHiddenEagerMediaCheckpoint } from "./polish-node.mjs";
54
+ import { HIDDEN_EAGER_MEDIA_SCOPE, POLISH_CAPTURE_PROBLEM_CODES } from "./polish-page-load.mjs";
55
+ import {
56
+ normalizePageLoadRoute,
57
+ POLISH_PRELOAD_ATTRIBUTES,
58
+ redactCaptureUrl,
59
+ } from "./polish-capture.mjs";
60
+ import { resolveConsent } from "./consent.mjs";
61
+ import { markDoctorSidecarStale } from "./doctor-sidecar.mjs";
62
+ import { commitAssemblyReport } from "./stage-ledger.mjs";
63
+ import { campaignSidecarPaths, explicitReportPath, resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
64
+ import { loadParityFixture } from "./qa-parity-fixture.mjs";
65
+ import { assessParityCapture, resolveParityScenario, runParityCapture } from "./qa-parity-capture.mjs";
66
+ import { loadPageKitCampaignEntry, PAGE_KIT_CAMPAIGNS_REL_PATH } from "./page-kit-campaign-config.mjs";
67
+ import {
68
+ evaluatePageKitStoreProfile,
69
+ PAGE_KIT_STORE_PROFILE_FIELDS,
70
+ PAGE_KIT_STORE_PROFILE_SCOPE,
71
+ } from "./page-kit-store-profile.mjs";
72
+ import {
73
+ evaluatePageKitSdkVersion,
74
+ PAGE_KIT_SDK_VERSION_SCOPE,
75
+ } from "./page-kit-sdk-version.mjs";
76
+ import {
77
+ captureCommercialClaims,
78
+ COMMERCIAL_QA_LIMITS,
79
+ mapConcurrent,
80
+ createPageSourceLoader,
81
+ planCommercialParity,
82
+ runCommercialParity,
83
+ unavailableCommercialCapture,
84
+ unavailableCommercialReport,
85
+ } from "./qa-commercial-parity.mjs";
86
+
87
+ // The producing runtime identity on every verdict. Read from package.json so
88
+ // a verdict names the release that made it; a literal here outlived three
89
+ // hundred releases as `0.1.0-alpha.0`.
90
+ const RUNTIME = `campaigns-os-node-qa@${JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version}`;
91
+ const QA_VERDICT_ASSERTION_LIMIT = 500;
92
+
93
+ const HELP = `campaigns-os qa — Node/npm spec-aware QA
94
+
95
+ Usage:
96
+ campaigns-os qa parity --fixture <parity-fixture.json> --scenario <scenario-id> [--base-url <override>] [--baseline <url>] [--parity-order-json <file>] [--no-post-verdict]
97
+ campaigns-os qa resolve --packet <campaign-runtime.build.json> [--base-url <url>] [--no-probe] [--probe-timeout-ms <ms>] [--json]
98
+ campaigns-os qa run --packet <campaign-runtime.build.json> [--base-url <url>] [--output-dir <dir>] [--no-remit] [--json]
99
+ campaigns-os qa policy set --packet <campaign-runtime.build.json> [--allowed-domains-confirmed true|false] [--deploy-target <target>] [--preview-url <url>] [--production-url <url>] [--order-path-depth <off|common|full>] [--json]
100
+ campaigns-os qa waive --packet <campaign-runtime.build.json> --assertion analytics-correctness:purchase-fires --reason "<why>" [--waived-by <who>] [--report <assembly-report.json>] [--json]
101
+ campaigns-os qa promote --packet <campaign-runtime.build.json> --verdict <full-verdict.json> [--json] # project one explicit qa-output verdict to the committed .campaign-runtime/qa-verdict.json sidecar
102
+ campaigns-os qa publish --packet <campaign-runtime.build.json> [--verdict <full-verdict.json>] [--republish] [--proxy-base <url>] [--json] # post an already-stored verdict to the QA portal; no re-run, no orders
103
+ campaigns-os qa resolve <map-id> --spec <campaign-spec.json> [--base-url <url>]
104
+ campaigns-os qa run <map-id> --spec <campaign-spec.json> --base-url <url>
105
+ campaigns-os qa run --site <page-kit-target-repo> --base-url <url> --family <family> [--slug <slug>] [--browser] # L7: QA a built _site/ with no packet/spec
106
+ campaigns-os qa install-browser [--json] # one-time: install the package-owned Playwright Chromium (same as npm run qa:install-browser from a checkout)
107
+
108
+ Options:
109
+ --fixture <path> Parity-capture fixture (required by qa parity).
110
+ --scenario <id|offer> Fixture scenario_id or unique offer selector (required by qa parity).
111
+ --baseline <url> Optional live analytics baseline for qa parity.
112
+ --parity-order-json <path> Assessment-only saved order + analytics capture bundle; skips Playwright.
113
+ --packet <path> Read Map ID, local CampaignSpec, deploy URL, and QA metadata from a Build Packet.
114
+ --site <path> L7 non-packet QA: resolve scope (pages + funnel types) from a built page-kit _site/.
115
+ Requires --base-url and --family. No Map ID / CampaignSpec needed.
116
+ --spec <path> Local exported CampaignSpec JSON for the non-packet Map ID flow.
117
+ Packet QA always uses packet.spec.local_path and rejects this override.
118
+ --proxy-base <url> Campaign Map proxy base for /api/spec, /api/price-preview, and verdict publishing.
119
+ --base-url <url> Deployed campaign root. Packet deploy URL is used when omitted.
120
+ Commercial pages are checked automatically against /api/price-preview;
121
+ no commercial sidecar or extra catalog flag is required.
122
+ HTML/proxy bodies, parser work, claims, and scenarios are hard-bounded;
123
+ unavailable proof is recorded as incomplete rather than a guessed mismatch.
124
+ --no-probe qa resolve: skip the reachability probe of the derived entry URLs.
125
+ resolve probes them by default, because a route set derived from the
126
+ packet is not evidence that the deployment serves it: a dead route set
127
+ reports status routes_unresolved and names the first URL that failed.
128
+ Probing needs no flag to survive an offline run — a transport failure
129
+ degrades to status ready_unprobed rather than failing — so use this only
130
+ for hermetic runs that must make no outbound request at all.
131
+ --probe-timeout-ms <ms> qa resolve: per-URL reachability probe timeout. Default: 5000.
132
+ --output-dir <path> Local verdict directory. Default: qa-output under the packet's
133
+ target repo (assembly.target_repo, else the packet's directory);
134
+ qa-output under the current directory for packet-less runs.
135
+ qa publish reads the same directory when looking up the sidecar's run.
136
+ --post-verdict (default) Publish the verdict to the QA portal at
137
+ <proxy-base>/api/qa/verdicts and print the QA portal link.
138
+ Publishing is automatic; this flag is retained for clarity.
139
+ --no-post-verdict, --local-only Skip publishing; write only the local verdict copy (offline / dev / CI).
140
+ Publish it later, without a re-run, with qa publish.
141
+ --verdict <path> qa promote / qa publish: the full verdict file under qa-output/. qa publish
142
+ defaults to the run the committed .campaign-runtime/qa-verdict.json names,
143
+ preferring that run's full verdict under <target-repo>/qa-output/ and falling
144
+ back to the projection itself. Refuses a verdict whose spec_hash no longer
145
+ matches the packet's spec (spec_hash_mismatch), one the portal already holds
146
+ per the Run Record (already_published), an untrusted one, or one for another
147
+ campaign. Exit 2 on a refusal, 1 on a failed post, 0 when published.
148
+ --republish qa publish: post a verdict its Run Record already records as published.
149
+ --no-remit When an ambient run session is active, write the local Run Record but skip Run Telemetry remit.
150
+ --auth-cookie <cookie> Cookie header for protected previews.
151
+ --browser Run Playwright-rendered browser checks after static Node checks.
152
+ Requires one-time setup: campaigns-os qa install-browser
153
+ (npm run qa:install-browser from a checkout).
154
+ --headed Show the Playwright browser window when --browser is set.
155
+ --browser-width <px> Browser viewport width. Default: 1440.
156
+ --browser-height <px> Browser viewport height. Default: 1200.
157
+ --browser-timeout <ms> Browser navigation timeout. Default: 30000.
158
+ --test-order <off|common|checkout|accept|decline|both|full|tiers[:checkout|common|full]|accept-decline[-accept...]>
159
+ Create Playwright typed-card test orders through the tested checkout page.
160
+ Test cards bypass the gateway and create no transactions, so no permission
161
+ flags or packet policy are needed — just pick a mode. Default mode (bare
162
+ --test-order, or "common") runs checkout, first-offer accept/decline, and a
163
+ deduplicated shortest real receipt path when needed (at most 4 orders). "full" walks
164
+ every actual terminal path; cycles, missing routes, and reachable nonterminals
165
+ block before browser launch. The default cap is 6; overflow names the exact raise.
166
+ "tiers" is spec-driven: one strict-selection order per selector tier the
167
+ CampaignSpec declares on the checkout page (order-bump rows marked
168
+ is_upsell are add-ons, never tiers), plus one coupon order per declared
169
+ offer code (checkout exit_intent / promo_code_input); "tiers:common" and
170
+ "tiers:full" cross every tier with those path shapes. --select-package
171
+ <ref[:qty],...> narrows a tiers run to the listed declared tiers;
172
+ --apply-coupon is incompatible (tiers derives coupons from the spec).
173
+ Requires one-time setup: campaigns-os qa install-browser
174
+ (npm run qa:install-browser from a checkout).
175
+ --max-test-orders <n> Accidental-flood guard for planned browser order paths (not a permission gate). Default: 6.
176
+ --max-order-creations <n> Hard bound on REAL order creations in this run, reserved before each submit
177
+ click. Default: the planned path count. A path whose failure is confirmed to
178
+ have created an order is inspected read-only, never resubmitted.
179
+ --allowed-domains-confirmed <bool>
180
+ qa policy set: persist non-localhost SDK-origin confirmation.
181
+ Localhost on any port is a global Development domain with analytics suppressed.
182
+ --preview-url <url> qa policy set: persist packet deploy.preview_url.
183
+ --production-url <url> qa policy set: persist packet deploy.production_url.
184
+ --deploy-target <target> qa policy set: persist packet deploy.target.
185
+ --order-path-depth <depth> qa policy set: persist packet qa.proof_policy.order_path_depth (off, common or full)
186
+ and refresh the assembly report's proof_policy mirror when one exists, so the
187
+ two never disagree. "off" declares an intentional no-order run: a
188
+ --test-order off pass then owes no purchase proof and next can reach done.
189
+ --step-timeout-ms <ms> Typed-card test-order per-step timeout. Default: 45000.
190
+ --order-timeout-ms <ms> Typed-card test-order per-path overall timeout. Default: 240000.
191
+ --theme-waive <reason> Waive a blocked theme gate for this run with an explicit operator reason
192
+ (recorded in the verdict; downgrades template-residue blockers to warnings).
193
+ --test-card <number> Test card number for browser checkout. Default: Discover sandbox card 6011...1117.
194
+ --test-cvv <cvv> Test card CVV. Default: 123.
195
+ --test-exp-month <mm> Test card expiration month. Default: 12.
196
+ --test-exp-year <yyyy> Test card expiration year. Default: 2030.
197
+ --test-email <email> Customer email for browser test orders. Env CAMPAIGNS_OS_QA_TEST_EMAIL is also recognized.
198
+ --test-email-prefix <prefix> Legacy unique email prefix override.
199
+ --legacy-api-test-order <off|accept|decline|both>
200
+ Diagnostic-only direct Campaigns API order creation; bypasses deployed checkout.
201
+ --api-key <key> Campaigns API key for legacy direct API diagnostics. Env QA_CAMPAIGNS_API_KEY is also recognized.
202
+ --campaigns-api-base <url> Campaigns API base URL for legacy direct API diagnostics. Env CAMPAIGNS_API_BASE is also recognized.
203
+ --cart <package-ref:qty,...> Optional target cart/package selector for browser or legacy diagnostics.
204
+ --analytics-baseline <url> Analytics-parity leg (opt-in): URL of the legacy funnel to diff against (e.g. the
205
+ legacy receipt/thank-you page). Launches a Playwright browser to capture the live
206
+ dataLayer + GTM/pixel tag-fires on both URLs and diffs them into parity assertions.
207
+ Requires one-time setup: campaigns-os qa install-browser
208
+ (npm run qa:install-browser from a checkout).
209
+ --analytics-candidate <url> Analytics-PARITY leg only: explicit candidate receipt URL paired with the
210
+ --analytics-baseline legacy receipt (receipt-capture pairing). When omitted,
211
+ the parity candidate and correctness root-inventory phase capture the URL
212
+ resolved from campaign identity, never a raw --base-url. Correctness Purchase
213
+ proof reuses the canonical typed-card order's settled receipt capture.
214
+ --assertion <id> qa waive: the blocking assertion to waive. The waiver lane is scoped to
215
+ analytics-correctness:purchase-fires — other assertions are refused.
216
+ --reason <text> qa waive: REQUIRED — why this blocker is acceptable for this campaign.
217
+ Recorded verbatim on the Assembly Report and carried into the verdict.
218
+ --waived-by <who> qa waive: named human accepting the blocker. Default: $USER@local
219
+ (or "operator" when $USER is unset), matching themeWaive's default lane.
220
+ --report <path> qa waive: explicit Assembly Report path. Default: the packet target repo's
221
+ .campaign-runtime/assembly-report.json.
222
+ --analytics-settle <ms> Settle time after analytics page loads and recognized typed-order receipts.
223
+ Receipt settling must fit inside the order deadline. Default: 5000.
224
+ --analytics-hosts <h1,h2,...> Extra third-party host patterns to intercept for tag-fire capture (comma-separated).
225
+ `;
226
+
227
+ export async function runQaCli(args, { ambient = null } = {}) {
228
+ const subcommand = args._[1] || "help";
229
+ if (subcommand === "help" || args.help) {
230
+ console.log(HELP);
231
+ return null;
232
+ }
233
+ if (subcommand === "resolve") {
234
+ const resolved = await resolveQaInputs(args);
235
+ const routeProbe = await resolveRouteProbe(resolved, args);
236
+ const result = resolvePayload(resolved, { routeProbe });
237
+ output(result, args);
238
+ return result;
239
+ }
240
+ if (subcommand === "run") {
241
+ const result = await runQa(args, { runSessionActive: Boolean(ambient?.session?.run_id) });
242
+ output(result, args);
243
+ process.exitCode = result.verdict.disposition === "blocked" ? 4 : 0;
244
+ return result;
245
+ }
246
+ if (subcommand === "parity") {
247
+ const result = await runParityQa(args);
248
+ output(result, args);
249
+ process.exitCode = result.verdict.disposition === "blocked" ? 4 : 0;
250
+ return result;
251
+ }
252
+ if (subcommand === "policy") {
253
+ if (args._[2] !== "set") throw new Error(`Unknown qa policy command. Use: ${cmd("qa")} policy set --packet <campaign-runtime.build.json>`);
254
+ const result = updateQaPolicy(args);
255
+ output(result, args);
256
+ return result;
257
+ }
258
+ if (subcommand === "waive") {
259
+ const result = qaWaive(args);
260
+ output(result, args);
261
+ return result;
262
+ }
263
+ if (subcommand === "promote") {
264
+ const result = promoteQaVerdict({ verdictPath: args.verdict, packetPath: args.packet });
265
+ output(result, args);
266
+ return result;
267
+ }
268
+ if (subcommand === "publish") {
269
+ const result = await publishStoredVerdict(args);
270
+ output(result, args);
271
+ process.exitCode = QA_PUBLISH_EXIT_CODES[result.status] ?? 1;
272
+ return result;
273
+ }
274
+ if (subcommand === "install-browser") {
275
+ const result = installQaBrowser({ json: Boolean(args.json) });
276
+ if (args.json) {
277
+ console.log(JSON.stringify(result, null, 2));
278
+ } else {
279
+ for (const line of installBrowserTextLines(result)) console.log(line);
280
+ }
281
+ process.exitCode = result.ok ? 0 : 1;
282
+ return result;
283
+ }
284
+ throw new Error(`Unknown qa command: ${subcommand}`);
285
+ }
286
+
287
+ // The package-owned browser install, runnable from any install mode. It is
288
+ // the same step as `npm run qa:install-browser` (a checkout script), but a
289
+ // global or npx install has no checkout to run scripts in, and the printed
290
+ // recovery commands must work where the operator actually is. It drives the
291
+ // Playwright CLI bundled with THIS package so the browser matches the
292
+ // Playwright version the QA and polish producers load.
293
+ export function installBrowserTextLines(result) {
294
+ const lines = [`Status: ${String(result.status || "unknown").toUpperCase()}`];
295
+ if (result.command) lines.push(`Command: ${result.command}`);
296
+ if (Number.isInteger(result.exit_code) && !result.ok) lines.push(`Exit code: ${result.exit_code}`);
297
+ if (result.note) lines.push(result.note);
298
+ return lines;
299
+ }
300
+
301
+ export function installQaBrowser({ spawn = spawnSync, json = false } = {}) {
302
+ const require = createRequire(import.meta.url);
303
+ let playwrightCli;
304
+ try {
305
+ // playwright's exports map does not expose cli.js; its package.json is
306
+ // exported, and the CLI lives beside it (package.json "bin": "cli.js").
307
+ playwrightCli = join(dirname(require.resolve("playwright/package.json")), "cli.js");
308
+ if (!existsSync(playwrightCli)) throw Object.assign(new Error("playwright/cli.js is missing"), { code: "MODULE_NOT_FOUND" });
309
+ } catch (error) {
310
+ if (error?.code !== "MODULE_NOT_FOUND") throw error;
311
+ return {
312
+ ok: false,
313
+ status: "playwright_missing",
314
+ command: null,
315
+ note: PLAYWRIGHT_INSTALL_HINT,
316
+ };
317
+ }
318
+ // Playwright reports download progress on stdout. In --json mode stdout is
319
+ // the result document, so the child's stdout is routed to stderr (fd 2);
320
+ // otherwise the operator sees the progress inline.
321
+ const run = spawn(process.execPath, [playwrightCli, "install", "chromium"], {
322
+ stdio: json ? ["inherit", 2, "inherit"] : "inherit",
323
+ });
324
+ const ok = run.status === 0;
325
+ return {
326
+ ok,
327
+ status: ok ? "installed" : "install_failed",
328
+ command: `${process.execPath} ${playwrightCli} install chromium`,
329
+ exit_code: run.status,
330
+ note: ok
331
+ ? "Playwright Chromium is installed for this package; browser QA and polish capture can run."
332
+ : `Playwright browser install exited ${run.status}; rerun ${cmd("qa")} install-browser after fixing the reported error.`,
333
+ };
334
+ }
335
+
336
+ async function resolveQaInputs(args, {
337
+ readJsonFile = readJson,
338
+ loadCampaignEntry = loadPageKitCampaignEntry,
339
+ } = {}) {
340
+ if (args.packet && args.spec) {
341
+ throw new Error("Packet QA does not accept --spec; it always uses packet.spec.local_path.");
342
+ }
343
+ // Non-packet mode (learnings L7): QA a `campaign-build`'d page-kit campaign
344
+ // that has only a built _site/ and a served URL — no Build Packet, no Map ID,
345
+ // no CampaignSpec. Scope (pages + funnel types) is resolved from the built
346
+ // output; the residue/placeholder/demo gates run against --family.
347
+ if ((args.site || args.built) && !args.packet) {
348
+ return resolveQaInputsFromSite(args);
349
+ }
350
+ const checkpointPreflight = args.packet
351
+ ? resolvePacketCheckpointPreflight(args, { readJsonFile, loadCampaignEntry })
352
+ : null;
353
+ if (checkpointPreflight && checkpointPreflight.specStatus !== "ok") {
354
+ return resolvedFromBlockedCheckpointPreflight(checkpointPreflight, args);
355
+ }
356
+ // Packet mode owns one immutable local snapshot: every checkpoint, runtime
357
+ // identity/topology decision, theme/polish gate, and QA waiver consumes the
358
+ // same packet, local spec, target campaign entry, and Assembly Report read by
359
+ // preflight. A later file generation must never authorize work that the
360
+ // checkpoint generation did not evaluate (or vice versa).
361
+ const packetPath = checkpointPreflight?.packetPath || (args.packet ? resolve(args.packet) : null);
362
+ const packet = checkpointPreflight?.packet || (packetPath ? readJsonFile(packetPath) : null);
363
+ const mapId = stringArg(args["map-id"])
364
+ || stringArg(args._[2])
365
+ || stringArg(packet?.spec?.map_id);
366
+ if (!mapId) throw new Error("QA requires a Map ID. Provide --packet or positional <map-id>.");
367
+
368
+ const proxyBase = stringArg(args["proxy-base"]) || DEFAULT_PROXY_BASE;
369
+ const inputBaseUrl = normalizeBaseUrl(stringArg(args["base-url"]) || packet?.deploy?.preview_url || packet?.deploy?.production_url || null);
370
+ const specPath = checkpointPreflight
371
+ ? checkpointPreflight.specPath
372
+ : args.spec
373
+ ? resolve(args.spec)
374
+ : packetPath && packet?.spec?.local_path
375
+ ? resolveFromFile(packetPath, packet.spec.local_path)
376
+ : null;
377
+
378
+ let rawSpec;
379
+ let specSource;
380
+ if (checkpointPreflight) {
381
+ rawSpec = checkpointPreflight.rawSpec;
382
+ specSource = specPath;
383
+ } else if (specPath) {
384
+ rawSpec = readJsonFile(specPath);
385
+ specSource = specPath;
386
+ } else {
387
+ rawSpec = await fetchSpecByMapId(mapId, { proxyBase });
388
+ specSource = `${proxyBase.replace(/\/+$/, "")}/api/spec/${encodeURIComponent(mapId)}`;
389
+ }
390
+
391
+ const normalized = normalizeSpec(rawSpec);
392
+ const publicRouteSlug = resolvePublicRouteSlug({ packet, spec: normalized, rawSpec });
393
+ const baseUrl = normalizeQaBaseUrl(inputBaseUrl, publicRouteSlug);
394
+ // Packet 01: the analytics legs capture ONE URL derived from resolved
395
+ // identity (public_route_slug + route_root), composed here where the raw
396
+ // operator/deploy base is still in hand.
397
+ const routeRootNotes = [];
398
+ const analyticsCaptureTarget = resolveAnalyticsCaptureTarget({
399
+ inputBaseUrl,
400
+ publicRouteSlug,
401
+ routeRoot: resolveCampaignRouteRoot({ packet, spec: normalized, rawSpec, publicRouteSlug, notes: routeRootNotes }),
402
+ routeRootNote: routeRootNotes[0] || null,
403
+ });
404
+ const specHash = computeSpecHash(rawSpec);
405
+ const templateFamily = stringArg(packet?.assembly?.template_family)
406
+ || stringArg(normalized?.spec_identity?.preferred_template_family)
407
+ || stringArg(normalized?.campaign?.preferred_template_family)
408
+ || null;
409
+ const commerceStructureContract = loadCommerceStructureContract({ packet, packetPath, templateFamily });
410
+ const topologies = extractTopologies(normalized, { baseUrl, publicRouteSlug, templateFamily, commerceStructureContract });
411
+ const themeGate = resolveThemeGate({
412
+ packetPath,
413
+ topologies,
414
+ waive: stringArg(args["theme-waive"]),
415
+ report: checkpointPreflight?.runtimeReport,
416
+ });
417
+ const hiddenEagerMediaGate = checkpointPreflight?.checkpointGates
418
+ ?.find((gate) => gate?.id === HIDDEN_EAGER_MEDIA_SCOPE);
419
+ const polishGate = resolvePolishGate({
420
+ packetPath,
421
+ report: checkpointPreflight?.runtimeReport,
422
+ hiddenEagerMediaGate,
423
+ });
424
+ const qaWaivers = resolveQaWaivers({ packetPath, report: checkpointPreflight?.runtimeReport });
425
+ const brandContract = loadBrandContract(templateFamily);
426
+ return {
427
+ themeGate,
428
+ polishGate,
429
+ qaWaivers,
430
+ analyticsCaptureTarget,
431
+ brandContract: brandContract.contract,
432
+ brandContractStatus: brandContract.status,
433
+ packetPath,
434
+ packet,
435
+ mapId,
436
+ publicRouteSlug,
437
+ proxyBase,
438
+ baseUrl,
439
+ specPath,
440
+ specSource,
441
+ // Portal-managed: the spec was fetched from the portal for this run, so
442
+ // the verdict belongs on the portal QA tab by default (#172).
443
+ portalManaged: specPath == null,
444
+ rawSpec,
445
+ spec: normalized,
446
+ specVersion: String(rawSpec.schema_version || rawSpec.schemaVersion || "unknown"),
447
+ specHash,
448
+ templateFamily,
449
+ commerceStructureContract,
450
+ topologies,
451
+ checkpointGates: checkpointPreflight?.checkpointGates || nonPacketCheckpointGates(),
452
+ // The report the checkpoint gates were evaluated on, and the target repo
453
+ // whose default it may or may not be: what the printed remediation names.
454
+ reportPath: checkpointPreflight?.reportPath || null,
455
+ targetRepo: checkpointPreflight?.targetRepo || null,
456
+ };
457
+ }
458
+
459
+ function resolvePacketCheckpointPreflight(args, {
460
+ readJsonFile = readJson,
461
+ loadCampaignEntry = loadPageKitCampaignEntry,
462
+ } = {}) {
463
+ const packetPath = resolve(String(args.packet));
464
+ const packet = readJsonFile(packetPath);
465
+ const specPath = stringArg(args.spec)
466
+ ? resolve(String(args.spec))
467
+ : stringArg(packet?.spec?.local_path)
468
+ ? resolveFromFile(packetPath, packet.spec.local_path)
469
+ : null;
470
+ let rawSpec = null;
471
+ let specStatus = "missing";
472
+ if (specPath && existsSync(specPath)) {
473
+ try {
474
+ rawSpec = readJsonFile(specPath);
475
+ specStatus = isPlainObject(rawSpec) ? "ok" : "root_not_object";
476
+ } catch {
477
+ specStatus = "invalid_json";
478
+ }
479
+ }
480
+ const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
481
+ // Follows the Build Context's report_path, like `next` and the QA stage
482
+ // record: a `prepare-build --report-out` campaign's report is the bound one,
483
+ // not the default sidecar.
484
+ const { targetRepo, reportPath } = resolveCampaignWorkspace(packetPath, {
485
+ packet,
486
+ contextPath: stringArg(args.context) ? resolve(String(args.context)) : undefined,
487
+ reportPath: stringArg(args.report) ? resolve(String(args.report)) : undefined,
488
+ followContextPointer: true,
489
+ });
490
+ const targetLoad = loadCampaignEntry({ targetRepo, publicRouteSlug });
491
+ let report = null;
492
+ if (existsSync(reportPath)) {
493
+ try {
494
+ const loaded = readJsonFile(reportPath);
495
+ report = isPlainObject(loaded) ? loaded : null;
496
+ } catch {
497
+ report = null;
498
+ }
499
+ }
500
+ const checkpointGates = [
501
+ evaluatePageKitStoreProfile({
502
+ specCampaign: rawSpec?.campaign || null,
503
+ specStatus,
504
+ targetLoad,
505
+ waivers: report?.waivers,
506
+ required: true,
507
+ }),
508
+ evaluatePageKitSdkVersion({
509
+ spec: rawSpec,
510
+ specStatus,
511
+ targetLoad,
512
+ waivers: report?.waivers,
513
+ required: true,
514
+ }),
515
+ evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report }),
516
+ ];
517
+ const runtimeReport = reportMatchesPacketIdentity(report, packet) ? report : null;
518
+ return {
519
+ packetPath,
520
+ packet,
521
+ specPath,
522
+ rawSpec,
523
+ specStatus,
524
+ targetLoad,
525
+ targetRepo,
526
+ reportPath,
527
+ report,
528
+ runtimeReport,
529
+ checkpointGates,
530
+ };
531
+ }
532
+
533
+ function reportMatchesPacketIdentity(report, packet) {
534
+ if (!isPlainObject(report) || !isPlainObject(report.identity)) return false;
535
+ const packetMapId = stringArg(packet?.spec?.map_id);
536
+ const reportMapId = stringArg(report.identity.map_id);
537
+ const packetSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
538
+ const reportSlug = normalizePublicRouteSlug(report.identity.public_route_slug);
539
+ return !!packetMapId
540
+ && packetMapId === reportMapId
541
+ && !!packetSlug
542
+ && packetSlug === reportSlug;
543
+ }
544
+
545
+ function nonPacketStoreProfileGate(slug = "") {
546
+ const gate = evaluatePageKitStoreProfile({
547
+ specCampaign: {},
548
+ targetLoad: {
549
+ status: "file_missing",
550
+ public_route_slug: normalizePublicRouteSlug(slug),
551
+ target_path: PAGE_KIT_CAMPAIGNS_REL_PATH,
552
+ entry: null,
553
+ },
554
+ required: false,
555
+ });
556
+ return {
557
+ ...gate,
558
+ code: "page_kit.store_profile.not_applicable",
559
+ reason: "Non-packet QA has no Build Packet target/config pair; Store Profile parity is not applicable.",
560
+ };
561
+ }
562
+
563
+ function nonPacketSdkVersionGate(slug = "") {
564
+ const gate = evaluatePageKitSdkVersion({
565
+ spec: { runtime: { sdk_version: "0.0.0" } },
566
+ targetLoad: {
567
+ status: "file_missing",
568
+ public_route_slug: normalizePublicRouteSlug(slug),
569
+ target_path: PAGE_KIT_CAMPAIGNS_REL_PATH,
570
+ entry: null,
571
+ },
572
+ required: false,
573
+ });
574
+ return {
575
+ ...gate,
576
+ code: "page_kit.sdk_version.not_applicable",
577
+ reason: "Non-packet QA has no Build Packet spec/target pair; SDK-pin parity is not applicable.",
578
+ expected_sdk_version: null,
579
+ observed_sdk_version: null,
580
+ expected_source: null,
581
+ };
582
+ }
583
+
584
+ function nonPacketCheckpointGates(slug = "") {
585
+ const hiddenGate = evaluateRecordedHiddenEagerMediaCheckpoint();
586
+ return [
587
+ nonPacketStoreProfileGate(slug),
588
+ nonPacketSdkVersionGate(slug),
589
+ {
590
+ ...hiddenGate,
591
+ reason: "Non-packet QA has no Build Packet and Assembly Report pair; recorded page-load evidence is not applicable.",
592
+ },
593
+ ];
594
+ }
595
+
596
+ function resolvedFromBlockedCheckpointPreflight(preflight, args) {
597
+ const rawSpec = isPlainObject(preflight.rawSpec) ? preflight.rawSpec : {};
598
+ const spec = normalizeSpec(rawSpec);
599
+ const publicRouteSlug = normalizePublicRouteSlug(preflight.packet?.campaign?.public_route_slug) || null;
600
+ const inputBaseUrl = normalizeBaseUrl(stringArg(args["base-url"])
601
+ || preflight.packet?.deploy?.preview_url
602
+ || preflight.packet?.deploy?.production_url
603
+ || null);
604
+ const topologies = [];
605
+ const themeGate = resolveThemeGate({
606
+ packetPath: preflight.packetPath,
607
+ topologies,
608
+ waive: stringArg(args["theme-waive"]),
609
+ report: preflight.runtimeReport,
610
+ });
611
+ const hiddenEagerMediaGate = preflight.checkpointGates
612
+ .find((gate) => gate?.id === HIDDEN_EAGER_MEDIA_SCOPE);
613
+ const polishGate = resolvePolishGate({
614
+ packetPath: preflight.packetPath,
615
+ report: preflight.runtimeReport,
616
+ hiddenEagerMediaGate,
617
+ });
618
+ return {
619
+ themeGate,
620
+ polishGate,
621
+ qaWaivers: resolveQaWaivers({ packetPath: preflight.packetPath, report: preflight.runtimeReport }),
622
+ analyticsCaptureTarget: { url: null, source: "unresolved" },
623
+ brandContract: null,
624
+ brandContractStatus: "not_evaluated",
625
+ packetPath: preflight.packetPath,
626
+ packet: preflight.packet,
627
+ mapId: stringArg(preflight.packet?.spec?.map_id) || "unknown-map",
628
+ publicRouteSlug,
629
+ proxyBase: stringArg(args["proxy-base"]) || DEFAULT_PROXY_BASE,
630
+ baseUrl: inputBaseUrl,
631
+ specPath: preflight.specPath,
632
+ specSource: `packet_local_${preflight.specStatus}`,
633
+ portalManaged: false,
634
+ rawSpec,
635
+ spec,
636
+ specVersion: String(rawSpec.schema_version || rawSpec.schemaVersion || "unavailable"),
637
+ specHash: computeSpecHash(rawSpec),
638
+ templateFamily: stringArg(preflight.packet?.assembly?.template_family) || null,
639
+ commerceStructureContract: null,
640
+ topologies,
641
+ checkpointGates: preflight.checkpointGates,
642
+ reportPath: preflight.reportPath || null,
643
+ targetRepo: preflight.targetRepo || null,
644
+ };
645
+ }
646
+
647
+ // L7 non-packet QA: resolve QA inputs from a built _site/ + served base URL,
648
+ // with no Build Packet / Map ID / CampaignSpec. The theme gate is evaluated
649
+ // from the derived topology scope alone (no theme artifacts exist), which
650
+ // yields "not_applicable" rather than blocking, so browser QA still runs the
651
+ // residue/placeholder/demo gates. Test orders are not attempted (no policy).
652
+ export function resolveQaInputsFromSite(args) {
653
+ const targetRepo = resolve(String(args.site || args.built));
654
+ const scope = resolveBuiltSiteScope(targetRepo, { slug: stringArg(args.slug) });
655
+ if (!scope.ok) {
656
+ throw new Error(scope.error || `Could not resolve a built campaign from ${targetRepo}.`);
657
+ }
658
+ const baseUrl = normalizeBaseUrl(stringArg(args["base-url"]));
659
+ if (!baseUrl) {
660
+ throw new Error("Non-packet site QA requires --base-url <served-campaign-root> so built pages have a fetchable URL.");
661
+ }
662
+ const templateFamily = stringArg(args.family);
663
+ if (!templateFamily) {
664
+ throw new Error("Non-packet site QA requires --family <template-family> so residue, placeholder, and demo-asset gates can load the family brand contract.");
665
+ }
666
+ const brandContract = loadBrandContract(templateFamily);
667
+ if (brandContract.status !== "loaded" || !brandContract.contract) {
668
+ throw new Error(`Non-packet site QA requires a loadable template brand contract for --family "${templateFamily}" (got ${brandContract.status}).`);
669
+ }
670
+ const topologies = topologiesFromBuiltSiteScope(scope, baseUrl);
671
+ const mapId = stringArg(args["map-id"]) || scope.slug || "local-site";
672
+ const themeGate = resolveThemeGate({ packetPath: null, topologies, waive: stringArg(args["theme-waive"]) });
673
+ const polishGate = { status: "not_applicable", code: "polish.not_applicable", reason: "Non-packet built-site QA has no Assembly Report polish gate." };
674
+ // Include family + target_repo so two built sites with the same slug/route
675
+ // structure but different families or locations do not hash identically
676
+ // (run-identity dedupe / idempotent remit must tell them apart).
677
+ const specHash = computeSpecHash({
678
+ built_site: scope.slug,
679
+ family: templateFamily || null,
680
+ target_repo: scope.campaign_dir,
681
+ pages: scope.pages.map((page) => `${page.page_type}:${page.route}`),
682
+ });
683
+ return {
684
+ themeGate,
685
+ polishGate,
686
+ // Non-packet site QA has no Assembly Report, so no recorded waivers.
687
+ qaWaivers: {},
688
+ // Non-packet site QA: --base-url IS the served campaign root by this
689
+ // mode's contract (no spec identity exists to compose from).
690
+ analyticsCaptureTarget: buildAnalyticsCaptureTarget({
691
+ url: ensureUrlTrailingSlash(baseUrl),
692
+ publicRouteSlug: scope.slug || null,
693
+ routeRoot: null,
694
+ source: "built_site_base_url",
695
+ }),
696
+ brandContract: brandContract.contract,
697
+ brandContractStatus: brandContract.status,
698
+ packetPath: null,
699
+ packet: null,
700
+ mapId,
701
+ proxyBase: DEFAULT_PROXY_BASE,
702
+ baseUrl,
703
+ specPath: null,
704
+ specSource: `built-site:${scope.campaign_dir}`,
705
+ rawSpec: {},
706
+ spec: { campaign: {} },
707
+ specVersion: "built-site",
708
+ specHash,
709
+ templateFamily,
710
+ commerceStructureContract: null,
711
+ topologies,
712
+ checkpointGates: nonPacketCheckpointGates(scope.slug),
713
+ builtSite: { slug: scope.slug, campaign_dir: scope.campaign_dir, html_count: scope.html_count },
714
+ };
715
+ }
716
+
717
+ function loadCommerceStructureContract({ packet, packetPath, templateFamily }) {
718
+ if (!packet || !packetPath || !templateFamily) return null;
719
+ // A null path is the toolkit's own catalog; a recorded path that is dead
720
+ // here but names the catalog file also falls back to it (see
721
+ // resolvePacketCommerceCatalogPath).
722
+ const catalogResolution = resolvePacketCommerceCatalogPath(packetPath, packet.assembly?.commerce_catalog);
723
+ const catalogPathValue = catalogResolution.recorded;
724
+ const catalogPath = catalogResolution.path;
725
+ if (!catalogPath || !existsSync(catalogPath)) return { family: templateFamily, status: "missing_catalog", pages: {} };
726
+ try {
727
+ const catalog = resolveCommerceCatalog(catalogPath);
728
+ const qaStructure = catalog?.families?.[templateFamily]?.agentContract?.qaStructure;
729
+ return {
730
+ family: templateFamily,
731
+ status: qaStructure && typeof qaStructure === "object" ? "loaded" : "missing_family_qa_structure",
732
+ pages: qaStructure && typeof qaStructure === "object" ? qaStructure : {},
733
+ catalog_path: catalogPathValue,
734
+ };
735
+ } catch (error) {
736
+ return {
737
+ family: templateFamily,
738
+ status: "catalog_parse_error",
739
+ pages: {},
740
+ catalog_path: catalogPathValue,
741
+ error: serializeThrownValue(error),
742
+ };
743
+ }
744
+ }
745
+
746
+ // Theme gate pre-flight: the deterministic stage gate QA shares with doctor/next.
747
+ // Packet QA supplies the canonical Assembly Report snapshot from checkpoint
748
+ // preflight; build-context and doctor-output remain separate sidecar reads. When
749
+ // doctor output is missing, commerce scope comes from the spec topologies in hand.
750
+ function resolveThemeGate({ packetPath, topologies, waive, report: reportOverride = undefined }) {
751
+ const report = reportOverride === undefined
752
+ ? loadRuntimeArtifact(packetPath, "assembly-report.json")
753
+ : reportOverride;
754
+ const context = loadRuntimeArtifact(packetPath, "build-context.json");
755
+ const doctor = loadRuntimeArtifact(packetPath, "doctor-output.json");
756
+ const specScope = themeGateScopeFromTopologies(topologies);
757
+ const scope = mergeThemeGateScope(doctor?.derived?.scope || null, specScope);
758
+ const gate = evaluateThemeGate({
759
+ reportTheme: report?.theme || null,
760
+ contextTheme: context?.theme || null,
761
+ scope,
762
+ packetPath,
763
+ waive: waive || null,
764
+ });
765
+ // Audit the scope source: a direct `qa run` without a prior doctor run is a
766
+ // legitimate CI path, but its commerce-page scope comes from spec
767
+ // topologies rather than the doctor's richer derived scope. Make that
768
+ // visible in the gate (and therefore in the verdict) instead of deciding
769
+ // from an unstated source.
770
+ gate.scope_source = themeGateScopeSource(doctor?.derived?.scope || null, specScope);
771
+ return gate;
772
+ }
773
+
774
+ // The declared funnel is the authority on WHETHER a campaign ships commerce
775
+ // pages; the doctor's derived scope is the authority on what this build
776
+ // produced. Preferring the doctor's scope outright meant a thin one — a
777
+ // partial build, or a doctor-output.json written before the commerce pages
778
+ // existed — could retire the gate on a funnel whose own route listing, printed
779
+ // two lines above, enumerated a checkout and five upsells (#274). Union the two
780
+ // so a commerce page declared in either source keeps the gate live, and record
781
+ // which sources contributed rather than deciding from an unstated one.
782
+ function mergeThemeGateScope(doctorScope, specScope) {
783
+ if (!doctorScope) return specScope;
784
+ const pageKey = (page) => String(page?.page_id || page?.route || page?.type || "");
785
+ const known = new Set([
786
+ ...(Array.isArray(doctorScope.built_pages) ? doctorScope.built_pages : []),
787
+ ...(Array.isArray(doctorScope.out_of_scope_pages) ? doctorScope.out_of_scope_pages : []),
788
+ ].map(pageKey));
789
+ const missing = (specScope?.built_pages || []).filter((page) => !known.has(pageKey(page)));
790
+ if (!missing.length) return doctorScope;
791
+ return {
792
+ ...doctorScope,
793
+ built_pages: [...(Array.isArray(doctorScope.built_pages) ? doctorScope.built_pages : []), ...missing],
794
+ };
795
+ }
796
+
797
+ function themeGateScopeSource(doctorScope, specScope) {
798
+ if (!doctorScope) return "spec_topologies";
799
+ const merged = mergeThemeGateScope(doctorScope, specScope);
800
+ return merged === doctorScope ? "doctor_derived_scope" : "doctor_derived_scope+spec_topologies";
801
+ }
802
+
803
+ // The sidecars live where the producers write them — under the target repo,
804
+ // with the report the Build Context binds — never merely beside the packet. A
805
+ // packet that cannot be read at this moment costs the target-repo resolution,
806
+ // not the read: the workspace then falls back to the packet's directory, which
807
+ // is what this reader always used.
808
+ // A missing or malformed packet or artifact is absent (null); any other read
809
+ // failure is not and reaches the caller.
810
+ function loadRuntimeArtifact(packetPath, name) {
811
+ if (!packetPath) return null;
812
+ try {
813
+ let packet = null;
814
+ try {
815
+ packet = readJson(packetPath);
816
+ } catch (error) {
817
+ if (!absentOrMalformed(error)) throw error;
818
+ }
819
+
820
+ const workspace = resolveCampaignWorkspace(packetPath, { packet, followContextPointer: true });
821
+ const path = {
822
+ "assembly-report.json": workspace.reportPath,
823
+ "build-context.json": workspace.contextPath,
824
+ "doctor-output.json": workspace.doctorOutPath,
825
+ }[name];
826
+ if (!path || !existsSync(path)) return null;
827
+ return readJson(path);
828
+ } catch (error) {
829
+ if (absentOrMalformed(error)) return null;
830
+ throw error;
831
+ }
832
+ }
833
+
834
+ // The fingerprint of the built output on disk right now (null when the packet
835
+ // or the built route root cannot be resolved), so the polish gate binds QA to
836
+ // the output QA is about to test rather than to the string build recorded.
837
+ function currentBuiltOutputFingerprint(packetPath) {
838
+ if (!packetPath) return null;
839
+ try {
840
+ const packet = readJson(packetPath);
841
+ const slug = stringArg(packet?.campaign?.public_route_slug);
842
+ if (!slug) return null;
843
+ const current = computeBuildFingerprint(join(targetRepoFor(packetPath, packet), "_site", slug));
844
+ return current.ok ? current.fingerprint : null;
845
+ } catch (error) {
846
+ if (absentOrMalformed(error)) return null;
847
+ throw error;
848
+ }
849
+ }
850
+
851
+ function resolvePolishGate({
852
+ packetPath,
853
+ report: reportOverride = undefined,
854
+ hiddenEagerMediaGate = undefined,
855
+ }) {
856
+ const report = reportOverride === undefined
857
+ ? loadRuntimeArtifact(packetPath, "assembly-report.json")
858
+ : reportOverride;
859
+ const gate = evaluatePolishGate({
860
+ report,
861
+ required: true,
862
+ hiddenEagerMediaGate,
863
+ currentOutputFingerprint: currentBuiltOutputFingerprint(packetPath),
864
+ });
865
+ gate.scope_source = report ? "assembly_report" : "missing_assembly_report";
866
+ return gate;
867
+ }
868
+
869
+ const THEME_GATE_COMMERCE_TYPES = new Set(["checkout", "upsell", "downsell", "receipt", "thankyou"]);
870
+
871
+ function themeGateScopeFromTopologies(topologies = []) {
872
+ const built_pages = [];
873
+ for (const topology of topologies) {
874
+ for (const page of topology?.pages || []) {
875
+ const type = String(page?.page_type || "").toLowerCase();
876
+ if (!THEME_GATE_COMMERCE_TYPES.has(type)) continue;
877
+ built_pages.push({ page_id: page.page_id, type, role: "runtime" });
878
+ }
879
+ }
880
+ return { built_pages };
881
+ }
882
+
883
+ function loadBrandContract(templateFamily) {
884
+ if (!templateFamily) return { contract: null, status: "no_template_family" };
885
+ try {
886
+ const contract = resolveTemplateBrandContract(templateFamily);
887
+ return { contract, status: contract ? "loaded" : "none" };
888
+ } catch (error) {
889
+ return { contract: null, status: `error: ${error instanceof Error ? error.message : String(error)}` };
890
+ }
891
+ }
892
+
893
+ // Map a theme gate result to its verdict assertion. Blocked gates produce the
894
+ // single blocker assertion the verdict carries; every other status produces an
895
+ // audit-trail pass assertion so the gate decision is visible in the verdict.
896
+ function themeGateAssertion(gate) {
897
+ const page = { page_id: "campaign" };
898
+ if (gate.status === "blocked") {
899
+ return assertion({
900
+ id: gate.code,
901
+ family: "theme_gate",
902
+ page,
903
+ status: STATUS.FAIL,
904
+ severity: SEVERITY.BLOCKER,
905
+ expected: "brand layer applied to commerce pages, or an explicit operator waiver",
906
+ actual: gate.reason,
907
+ evidence: {
908
+ reason: gate.reason,
909
+ commerce_pages: gate.commerce_pages,
910
+ required_actions: gate.required_actions,
911
+ scope_source: gate.scope_source || null,
912
+ },
913
+ });
914
+ }
915
+ if (gate.status === "waived") {
916
+ return assertion({
917
+ id: gate.code,
918
+ family: "theme_gate",
919
+ page,
920
+ status: STATUS.PASS,
921
+ expected: "brand layer applied to commerce pages, or an explicit operator waiver",
922
+ actual: gate.reason,
923
+ evidence: { waiver: gate.waiver, commerce_pages: gate.commerce_pages, scope_source: gate.scope_source || null },
924
+ });
925
+ }
926
+ return assertion({
927
+ id: gate.code,
928
+ family: "theme_gate",
929
+ page,
930
+ status: STATUS.PASS,
931
+ expected: "theme gate pass or not applicable",
932
+ actual: gate.reason,
933
+ evidence: { code: gate.code, commerce_pages: gate.commerce_pages, scope_source: gate.scope_source || null },
934
+ });
935
+ }
936
+
937
+ function polishGateAssertion(gate) {
938
+ const page = { page_id: "campaign" };
939
+ const evidence = {
940
+ build_fingerprint: gate.build_fingerprint || null,
941
+ source_build_fingerprint: gate.source_build_fingerprint || null,
942
+ source_package_material_fingerprint: gate.source_package_material_fingerprint || null,
943
+ current_source_package_material_fingerprint: gate.current_source_package_material_fingerprint || null,
944
+ assembly_source_package_material_fingerprint: gate.assembly_source_package_material_fingerprint || null,
945
+ performed_by: gate.performed_by || null,
946
+ waiver: gate.waiver || null,
947
+ expired_waiver: gate.expired_waiver || null,
948
+ scope_source: gate.scope_source || null,
949
+ };
950
+ if (gate.status === "blocked") {
951
+ return assertion({
952
+ id: gate.code,
953
+ family: "polish_gate",
954
+ page,
955
+ status: STATUS.FAIL,
956
+ severity: SEVERITY.BLOCKER,
957
+ expected: "current structured Polish evidence produced by next-campaigns-polish",
958
+ actual: gate.reason,
959
+ // blocked carries the same evidence as pass/waived, plus reason/problems/actions
960
+ evidence: {
961
+ ...evidence,
962
+ reason: gate.reason,
963
+ problems: gate.problems || [],
964
+ required_actions: gate.required_actions || [],
965
+ },
966
+ });
967
+ }
968
+ if (gate.status === "not_applicable") {
969
+ return assertion({
970
+ id: gate.code,
971
+ family: "polish_gate",
972
+ page,
973
+ status: STATUS.SKIPPED,
974
+ expected: "Polish gate evaluated only when an assembly report is available after build completion",
975
+ actual: gate.reason,
976
+ evidence,
977
+ });
978
+ }
979
+ if (gate.status === "waived") {
980
+ return assertion({
981
+ id: gate.code,
982
+ family: "polish_gate",
983
+ page,
984
+ status: STATUS.SKIPPED,
985
+ expected: "current structured Polish evidence with an explicit source-freshness waiver",
986
+ actual: gate.reason,
987
+ evidence,
988
+ });
989
+ }
990
+ return assertion({
991
+ id: gate.code,
992
+ family: "polish_gate",
993
+ page,
994
+ status: STATUS.PASS,
995
+ expected: "current structured Polish evidence produced by next-campaigns-polish, with any source-freshness exception explicitly waived",
996
+ actual: gate.reason,
997
+ evidence,
998
+ });
999
+ }
1000
+
1001
+ // Template-residue findings stay blockers while the gate is live; a waived (or
1002
+ // inapplicable) gate means the operator accepted the starter palette, so the
1003
+ // same findings downgrade to warnings rather than re-blocking the run.
1004
+ function residueSeverityForThemeGate(status) {
1005
+ return status === "waived" || status === "not_applicable" ? SEVERITY.WARN : SEVERITY.BLOCKER;
1006
+ }
1007
+
1008
+ function templateBrandContractAssertion(resolved) {
1009
+ const family = stringArg(resolved?.templateFamily);
1010
+ if (!family) return null;
1011
+ const page = { page_id: "campaign" };
1012
+ if (family === "undecided" || family === "custom") {
1013
+ return assertion({
1014
+ id: `template-brand-contract:${family}`,
1015
+ family: "template_residue",
1016
+ page,
1017
+ status: STATUS.SKIPPED,
1018
+ expected: "selected template family has a brand/residue/pricing contract, or is intentionally exempt",
1019
+ actual: `family is ${family}; brand/residue/pricing contract not asserted`,
1020
+ evidence: { template_family: family, reason: "doctor exempts undecided/custom template families" },
1021
+ });
1022
+ }
1023
+ if (resolved.brandContractStatus === "loaded") {
1024
+ return assertion({
1025
+ id: `template-brand-contract:${family}`,
1026
+ family: "template_residue",
1027
+ page,
1028
+ status: STATUS.PASS,
1029
+ expected: "selected template family has a brand/residue/pricing contract",
1030
+ actual: `loaded for ${family}`,
1031
+ evidence: { template_family: family },
1032
+ });
1033
+ }
1034
+ return assertion({
1035
+ id: `template-brand-contract:${family}`,
1036
+ family: "template_residue",
1037
+ page,
1038
+ status: STATUS.FAIL,
1039
+ severity: SEVERITY.BLOCKER,
1040
+ expected: "selected template family has a brand/residue/pricing contract",
1041
+ actual: resolved.brandContractStatus || "none",
1042
+ evidence: {
1043
+ template_family: family,
1044
+ next_step: `Add contracts/template-brand-contract.${family}.v0.json before treating this family as promoted/agent-ready.`,
1045
+ },
1046
+ });
1047
+ }
1048
+
1049
+ function supportedPaymentMethodsFromSpec(spec) {
1050
+ const campaign = spec?.campaign || {};
1051
+ const normalizeMethod = (method) =>
1052
+ String(method && typeof method === "object" ? method.code : method).toLowerCase().replace(/[\s-]+/g, "_");
1053
+ const declared = [
1054
+ ...(Array.isArray(campaign.available_payment_methods) ? campaign.available_payment_methods : []),
1055
+ ...(Array.isArray(campaign.available_express_payment_methods) ? campaign.available_express_payment_methods : []),
1056
+ ].map(normalizeMethod).filter(Boolean);
1057
+ // null means the spec does not declare its methods (unknown != empty); chrome
1058
+ // residue checks only run against an explicit declaration, like doctor R2-B5.
1059
+ return declared.length ? [...new Set(declared)] : null;
1060
+ }
1061
+
1062
+ function themeGateSummary(gate) {
1063
+ return {
1064
+ status: gate.status,
1065
+ code: gate.code,
1066
+ reason: gate.reason,
1067
+ ...(gate.waiver ? { waiver: gate.waiver } : {}),
1068
+ ...(gate.required_actions?.length ? { required_actions: gate.required_actions } : {}),
1069
+ };
1070
+ }
1071
+
1072
+ function polishGateSummary(gate) {
1073
+ return {
1074
+ status: gate.status,
1075
+ code: gate.code,
1076
+ reason: gate.reason,
1077
+ ...(gate.waiver ? { waiver: gate.waiver } : {}),
1078
+ ...(gate.required_actions?.length ? { required_actions: gate.required_actions } : {}),
1079
+ };
1080
+ }
1081
+
1082
+ function polishBlockedAssertions(polishGate, themeGate) {
1083
+ const skippedByGate = (family, id) => assertion({
1084
+ id,
1085
+ family,
1086
+ page: { page_id: "campaign" },
1087
+ status: STATUS.SKIPPED,
1088
+ expected: `${family} checks executed`,
1089
+ actual: "Skipped: polish gate is blocked; no browser or test-order checks ran.",
1090
+ evidence: { blocked_by: polishGate.code },
1091
+ });
1092
+ return [
1093
+ ...(polishGate?.owned_checkpoint_only ? [] : [polishGateAssertion(polishGate)]),
1094
+ themeGateAssertion(themeGate),
1095
+ ...GATE_SUPPRESSED_FAMILIES
1096
+ .filter((family) => family !== "polish_gate")
1097
+ .map((family) => skippedByGate(family, `${family}.blocked_by_polish_gate`)),
1098
+ ];
1099
+ }
1100
+
1101
+ function themeBlockedAssertions(themeGate, polishGate) {
1102
+ const skippedByGate = (family, id) => assertion({
1103
+ id,
1104
+ family,
1105
+ page: { page_id: "campaign" },
1106
+ status: STATUS.SKIPPED,
1107
+ expected: `${family} checks executed`,
1108
+ actual: "Skipped: theme gate is blocked; no browser or test-order checks ran.",
1109
+ evidence: { blocked_by: themeGate.code },
1110
+ });
1111
+ return [
1112
+ ...(polishGate?.owned_checkpoint_only ? [] : [polishGateAssertion(polishGate)]),
1113
+ themeGateAssertion(themeGate),
1114
+ ...GATE_SUPPRESSED_FAMILIES
1115
+ .filter((family) => family !== "polish_gate")
1116
+ .map((family) => skippedByGate(family, `${family}.blocked_by_gate`)),
1117
+ ];
1118
+ }
1119
+
1120
+ const HIDDEN_EAGER_MEDIA_VIEWPORTS = new Set(["desktop", "mobile"]);
1121
+ const HIDDEN_EAGER_MEDIA_PRELOAD_ATTRIBUTES = new Set(POLISH_PRELOAD_ATTRIBUTES);
1122
+ const HIDDEN_EAGER_MEDIA_HIDDEN_BY = new Set([
1123
+ "display_none",
1124
+ "visibility_collapse",
1125
+ "visibility_hidden",
1126
+ ]);
1127
+ const MAX_HIDDEN_EAGER_MEDIA_SUMMARY_RESOURCES = 64;
1128
+
1129
+ function safeNonnegativeInteger(value) {
1130
+ return Number.isInteger(value) && value >= 0 ? value : null;
1131
+ }
1132
+
1133
+ function hiddenEagerMediaSubject(subject) {
1134
+ const fingerprint = stringArg(subject?.build_fingerprint);
1135
+ const slug = stringArg(subject?.campaign_slug);
1136
+ const routeScope = stringArg(subject?.route_scope)?.toLowerCase();
1137
+ const routes = [...new Set((Array.isArray(subject?.routes) ? subject.routes : [])
1138
+ .map(normalizePageLoadRoute)
1139
+ .filter(Boolean))].sort();
1140
+ const viewports = [...new Set((Array.isArray(subject?.viewports) ? subject.viewports : [])
1141
+ .map((viewport) => stringArg(viewport)?.toLowerCase())
1142
+ .filter((viewport) => HIDDEN_EAGER_MEDIA_VIEWPORTS.has(viewport)))].sort();
1143
+ return {
1144
+ build_fingerprint: /^sha256:[a-f0-9]{64}$/.test(fingerprint || "") ? fingerprint : null,
1145
+ campaign_slug: /^[a-z0-9][a-z0-9_-]{0,127}$/i.test(slug || "") ? slug : null,
1146
+ route_scope: routeScope === "all" || routeScope === "selected" ? routeScope : null,
1147
+ routes,
1148
+ viewports,
1149
+ };
1150
+ }
1151
+
1152
+ function hiddenEagerMediaFinding(finding) {
1153
+ const route = normalizePageLoadRoute(finding?.route);
1154
+ const viewport = stringArg(finding?.viewport)?.toLowerCase();
1155
+ const tagName = stringArg(finding?.tag_name)?.toLowerCase();
1156
+ const preload = stringArg(finding?.preload_attribute)?.toLowerCase();
1157
+ const allSources = [...new Set((Array.isArray(finding?.sources) ? finding.sources : [])
1158
+ .map((source) => redactCaptureUrl(source))
1159
+ .filter((source) => typeof source === "string" && /^https?:\/\//.test(source)))].sort();
1160
+ const allResourceIds = [...new Set((Array.isArray(finding?.resource_ids) ? finding.resource_ids : [])
1161
+ .filter((resourceId) => /^sha256:[a-f0-9]{64}$/.test(resourceId)))].sort();
1162
+ return {
1163
+ code: finding?.code === HIDDEN_EAGER_MEDIA_SCOPE ? HIDDEN_EAGER_MEDIA_SCOPE : null,
1164
+ route,
1165
+ viewport: HIDDEN_EAGER_MEDIA_VIEWPORTS.has(viewport) ? viewport : null,
1166
+ tag_name: tagName === "video" || tagName === "audio" ? tagName : null,
1167
+ element_index: safeNonnegativeInteger(finding?.element_index),
1168
+ sources: allSources.slice(0, MAX_HIDDEN_EAGER_MEDIA_SUMMARY_RESOURCES),
1169
+ source_count: Math.max(allSources.length, safeNonnegativeInteger(finding?.source_count) || 0),
1170
+ resource_ids: allResourceIds.slice(0, MAX_HIDDEN_EAGER_MEDIA_SUMMARY_RESOURCES),
1171
+ resource_id_count: Math.max(allResourceIds.length, safeNonnegativeInteger(finding?.resource_id_count) || 0),
1172
+ resource_identity_fingerprint: /^sha256:[a-f0-9]{64}$/.test(finding?.resource_identity_fingerprint || "")
1173
+ ? finding.resource_identity_fingerprint
1174
+ : null,
1175
+ transferred_bytes: safeNonnegativeInteger(finding?.transferred_bytes),
1176
+ threshold_bytes: safeNonnegativeInteger(finding?.threshold_bytes),
1177
+ preload_attribute: HIDDEN_EAGER_MEDIA_PRELOAD_ATTRIBUTES.has(preload) ? preload : "other",
1178
+ hidden_by: [...new Set((Array.isArray(finding?.hidden_by) ? finding.hidden_by : [])
1179
+ .filter((kind) => HIDDEN_EAGER_MEDIA_HIDDEN_BY.has(kind)))].sort(),
1180
+ };
1181
+ }
1182
+
1183
+ function hiddenEagerMediaFindings(gate) {
1184
+ const findings = Array.isArray(gate?.state?.findings)
1185
+ ? gate.state.findings
1186
+ : Array.isArray(gate?.findings)
1187
+ ? gate.findings
1188
+ : [];
1189
+ return findings
1190
+ .filter(isPlainObject)
1191
+ .map(hiddenEagerMediaFinding)
1192
+ .sort((left, right) => String(left.route).localeCompare(String(right.route))
1193
+ || String(left.viewport).localeCompare(String(right.viewport))
1194
+ || (left.element_index ?? -1) - (right.element_index ?? -1));
1195
+ }
1196
+
1197
+ const HIDDEN_EAGER_MEDIA_PROBLEM_CODES = new Set(POLISH_CAPTURE_PROBLEM_CODES);
1198
+ // planPolishCapture allows 128 routes; two viewports per route is the full
1199
+ // supported matrix, so a run inside the limits never truncates. The cap is
1200
+ // applied once across all four cell lists together; anything past it, and
1201
+ // any record that does not conform to the closed vocabularies, is counted
1202
+ // per list, never dropped silently.
1203
+ const MAX_HIDDEN_EAGER_MEDIA_MEASUREMENT_CELLS = 256;
1204
+ const HIDDEN_EAGER_MEDIA_MEASUREMENT_LISTS = Object.freeze(["missing", "duplicate", "unexpected", "incomplete"]);
1205
+
1206
+ // A cell is kept only when it conforms: a path-only route and a viewport
1207
+ // from the closed vocabulary. Anything else is omitted and counted.
1208
+ function hiddenEagerMediaMeasurementCell(cell, { withProblemCodes = false } = {}) {
1209
+ if (!isPlainObject(cell)) return null;
1210
+ const route = normalizePageLoadRoute(cell.route);
1211
+ const viewport = stringArg(cell.viewport)?.toLowerCase();
1212
+ if (!route || !HIDDEN_EAGER_MEDIA_VIEWPORTS.has(viewport)) return null;
1213
+ const projected = { route, viewport };
1214
+ if (withProblemCodes) {
1215
+ projected.problem_codes = [...new Set((Array.isArray(cell.problem_codes) ? cell.problem_codes : [])
1216
+ .filter((code) => HIDDEN_EAGER_MEDIA_PROBLEM_CODES.has(code)))].sort();
1217
+ }
1218
+ return projected;
1219
+ }
1220
+
1221
+ // The per-route, per-viewport measurement a blocked checkpoint carries: which
1222
+ // cells are missing, duplicated, unexpected, or incomplete and on which
1223
+ // problem codes. Routes are path-only, viewports and codes come from the
1224
+ // closed vocabularies, so nothing here can carry a URL or an operator string.
1225
+ // Counts are integers (0 when the input carries none).
1226
+ function hiddenEagerMediaMeasurement(gate) {
1227
+ const measurement = gate?.measurement;
1228
+ if (!isPlainObject(measurement)) return null;
1229
+ const lists = {};
1230
+ const omittedByList = {};
1231
+ let budget = MAX_HIDDEN_EAGER_MEDIA_MEASUREMENT_CELLS;
1232
+ for (const name of HIDDEN_EAGER_MEDIA_MEASUREMENT_LISTS) {
1233
+ const cells = [];
1234
+ let omitted = 0;
1235
+ for (const raw of (Array.isArray(measurement[name]) ? measurement[name] : [])) {
1236
+ const cell = hiddenEagerMediaMeasurementCell(raw, { withProblemCodes: name === "incomplete" });
1237
+ if (!cell || budget === 0) {
1238
+ omitted += 1;
1239
+ continue;
1240
+ }
1241
+ cells.push(cell);
1242
+ budget -= 1;
1243
+ }
1244
+ lists[name] = cells;
1245
+ omittedByList[name] = omitted;
1246
+ }
1247
+ return {
1248
+ status: measurement.status === "complete" ? "complete" : "incomplete",
1249
+ expected_capture_count: safeNonnegativeInteger(measurement.expected_capture_count) ?? 0,
1250
+ captured_count: safeNonnegativeInteger(measurement.captured_count) ?? 0,
1251
+ ...lists,
1252
+ omitted_cell_count: Object.values(omittedByList).reduce((total, count) => total + count, 0),
1253
+ omitted_cell_count_by_list: omittedByList,
1254
+ };
1255
+ }
1256
+
1257
+ function hiddenEagerMediaGateAssertion(gate) {
1258
+ const summary = checkpointGateSummary(gate);
1259
+ const page = { page_id: "campaign" };
1260
+ const common = {
1261
+ id: HIDDEN_EAGER_MEDIA_SCOPE,
1262
+ family: "polish_gate",
1263
+ page,
1264
+ expected: "complete package-owned page-load evidence with no hidden eager media above 1,048,576 bytes, or an exact named-human checkpoint waiver",
1265
+ actual: `${summary.code}: ${summary.reason}`,
1266
+ evidence: summary,
1267
+ };
1268
+ if (summary.status === "blocked") {
1269
+ return assertion({ ...common, status: STATUS.FAIL, severity: SEVERITY.BLOCKER });
1270
+ }
1271
+ if (summary.status === "waived") {
1272
+ return assertion({ ...common, status: STATUS.WARN, severity: SEVERITY.WARN, waiver: summary.waiver });
1273
+ }
1274
+ if (summary.status === "not_applicable") {
1275
+ return assertion({ ...common, status: STATUS.SKIPPED });
1276
+ }
1277
+ return assertion({ ...common, status: STATUS.PASS });
1278
+ }
1279
+
1280
+ function storeProfileGateAssertion(gate) {
1281
+ const page = { page_id: "campaign" };
1282
+ const evidence = {
1283
+ code: gate.code,
1284
+ subject: gate.subject,
1285
+ state: gate.state,
1286
+ state_fingerprint: gate.state_fingerprint,
1287
+ matrix: gate.matrix,
1288
+ blocker_fields: gate.blocker_fields,
1289
+ warning_fields: gate.warning_fields,
1290
+ waivable: gate.waivable,
1291
+ waiver: gate.waiver,
1292
+ waiver_assessment: gate.waiver_assessment,
1293
+ required_actions: gate.required_actions,
1294
+ };
1295
+ if (gate.status === "blocked") {
1296
+ return assertion({
1297
+ id: PAGE_KIT_STORE_PROFILE_SCOPE,
1298
+ family: "api-metadata",
1299
+ page,
1300
+ status: STATUS.FAIL,
1301
+ severity: SEVERITY.BLOCKER,
1302
+ expected: `CampaignSpec and target ${PAGE_KIT_CAMPAIGNS_REL_PATH} match across all ${PAGE_KIT_STORE_PROFILE_FIELDS.length} governed Store Profile fields`,
1303
+ actual: `${gate.code}: ${gate.reason}`,
1304
+ evidence,
1305
+ });
1306
+ }
1307
+ if (gate.status === "waived") {
1308
+ return assertion({
1309
+ id: PAGE_KIT_STORE_PROFILE_SCOPE,
1310
+ family: "api-metadata",
1311
+ page,
1312
+ status: STATUS.WARN,
1313
+ severity: SEVERITY.WARN,
1314
+ expected: "Store Profile parity passes, or an explicit named-human I-16 exception accepts the exact blocked state",
1315
+ actual: gate.reason,
1316
+ evidence,
1317
+ waiver: gate.waiver,
1318
+ });
1319
+ }
1320
+ if (gate.status === "not_applicable") {
1321
+ return assertion({
1322
+ id: PAGE_KIT_STORE_PROFILE_SCOPE,
1323
+ family: "api-metadata",
1324
+ page,
1325
+ status: STATUS.SKIPPED,
1326
+ expected: "Packet QA evaluates Store Profile parity before runtime work",
1327
+ actual: gate.reason,
1328
+ evidence,
1329
+ });
1330
+ }
1331
+ if (gate.warning_fields?.length) {
1332
+ return assertion({
1333
+ id: PAGE_KIT_STORE_PROFILE_SCOPE,
1334
+ family: "api-metadata",
1335
+ page,
1336
+ status: STATUS.WARN,
1337
+ severity: SEVERITY.WARN,
1338
+ expected: "Target-only Store Profile values remain visible for operator review",
1339
+ actual: gate.reason,
1340
+ evidence,
1341
+ });
1342
+ }
1343
+ return assertion({
1344
+ id: PAGE_KIT_STORE_PROFILE_SCOPE,
1345
+ family: "api-metadata",
1346
+ page,
1347
+ status: STATUS.PASS,
1348
+ expected: `CampaignSpec and target ${PAGE_KIT_CAMPAIGNS_REL_PATH} match across all ${PAGE_KIT_STORE_PROFILE_FIELDS.length} governed Store Profile fields`,
1349
+ actual: gate.reason,
1350
+ evidence,
1351
+ });
1352
+ }
1353
+
1354
+ function sdkVersionGateAssertion(gate) {
1355
+ const page = { page_id: "campaign" };
1356
+ const evidence = {
1357
+ code: gate.code,
1358
+ subject: gate.subject,
1359
+ state: gate.state,
1360
+ state_fingerprint: gate.state_fingerprint,
1361
+ expected_sdk_version: gate.expected_sdk_version,
1362
+ observed_sdk_version: gate.observed_sdk_version,
1363
+ expected_source: gate.expected_source,
1364
+ waivable: gate.waivable,
1365
+ waiver: gate.waiver,
1366
+ waiver_assessment: gate.waiver_assessment,
1367
+ required_actions: gate.required_actions,
1368
+ ...(Array.isArray(gate.advisory_actions) ? { advisory_actions: gate.advisory_actions } : {}),
1369
+ };
1370
+ if (gate.status === "blocked") {
1371
+ return assertion({
1372
+ id: PAGE_KIT_SDK_VERSION_SCOPE,
1373
+ family: "api-metadata",
1374
+ page,
1375
+ status: STATUS.FAIL,
1376
+ severity: SEVERITY.BLOCKER,
1377
+ expected: `CampaignSpec and target ${PAGE_KIT_CAMPAIGNS_REL_PATH} declare the same released SDK version`,
1378
+ actual: `${gate.code}: ${gate.reason}`,
1379
+ evidence,
1380
+ });
1381
+ }
1382
+ if (gate.status === "waived") {
1383
+ return assertion({
1384
+ id: PAGE_KIT_SDK_VERSION_SCOPE,
1385
+ family: "api-metadata",
1386
+ page,
1387
+ status: STATUS.WARN,
1388
+ severity: SEVERITY.WARN,
1389
+ expected: "SDK-pin parity passes, or a bounded named-human decision accepts the exact expected/observed pair",
1390
+ actual: gate.reason,
1391
+ evidence,
1392
+ waiver: gate.waiver,
1393
+ });
1394
+ }
1395
+ if (gate.status === "not_applicable") {
1396
+ return assertion({
1397
+ id: PAGE_KIT_SDK_VERSION_SCOPE,
1398
+ family: "api-metadata",
1399
+ page,
1400
+ status: STATUS.SKIPPED,
1401
+ expected: "Packet QA evaluates SDK-pin parity before runtime work",
1402
+ actual: gate.reason,
1403
+ evidence,
1404
+ });
1405
+ }
1406
+ if (gate.code === "page_kit.sdk_version.repo_newer") {
1407
+ return assertion({
1408
+ id: PAGE_KIT_SDK_VERSION_SCOPE,
1409
+ family: "api-metadata",
1410
+ page,
1411
+ status: STATUS.WARN,
1412
+ severity: SEVERITY.WARN,
1413
+ expected: `Target ${PAGE_KIT_CAMPAIGNS_REL_PATH} declares the released SDK version that ships; a CampaignSpec pin behind it is a stale build hint`,
1414
+ actual: gate.reason,
1415
+ evidence,
1416
+ });
1417
+ }
1418
+ return assertion({
1419
+ id: PAGE_KIT_SDK_VERSION_SCOPE,
1420
+ family: "api-metadata",
1421
+ page,
1422
+ status: STATUS.PASS,
1423
+ expected: `CampaignSpec and target ${PAGE_KIT_CAMPAIGNS_REL_PATH} declare the same released SDK version`,
1424
+ actual: gate.reason,
1425
+ evidence,
1426
+ });
1427
+ }
1428
+
1429
+ function checkpointGateAssertion(gate) {
1430
+ if (gate?.id === PAGE_KIT_STORE_PROFILE_SCOPE) return storeProfileGateAssertion(gate);
1431
+ if (gate?.id === PAGE_KIT_SDK_VERSION_SCOPE) return sdkVersionGateAssertion(gate);
1432
+ if (gate?.id === HIDDEN_EAGER_MEDIA_SCOPE) return hiddenEagerMediaGateAssertion(gate);
1433
+ throw new Error(`QA cannot project unknown checkpoint gate ${JSON.stringify(gate?.id)}.`);
1434
+ }
1435
+
1436
+ function checkpointBlockedAssertions(checkpointGates, polishGate, themeGate) {
1437
+ const blockedIds = checkpointGates
1438
+ .filter((gate) => gate?.status === "blocked")
1439
+ .map((gate) => gate.id);
1440
+ const blockedLabel = blockedIds.join(", ");
1441
+ const skippedByCheckpoint = (family) => assertion({
1442
+ id: `${family}.blocked_by_checkpoint`,
1443
+ family,
1444
+ page: { page_id: "campaign" },
1445
+ status: STATUS.SKIPPED,
1446
+ expected: `${family} checks executed`,
1447
+ actual: `Skipped: checkpoint gate(s) ${blockedLabel} blocked; no HTTP, browser, analytics, or test-order work ran.`,
1448
+ evidence: { blocked_by: blockedIds },
1449
+ });
1450
+ return [
1451
+ ...(polishGate?.owned_checkpoint_only ? [] : [polishGateAssertion(polishGate)]),
1452
+ themeGateAssertion(themeGate),
1453
+ ...checkpointGates.map(checkpointGateAssertion),
1454
+ ...GATE_SUPPRESSED_FAMILIES
1455
+ .filter((family) => family !== "polish_gate" && family !== "api-metadata")
1456
+ .map(skippedByCheckpoint),
1457
+ ];
1458
+ }
1459
+
1460
+ function serializeThrownValue(error) {
1461
+ const diagnostic = { message: String(error) };
1462
+ if (error && typeof error === "object") {
1463
+ const record = error;
1464
+ if (typeof record.name === "string" && record.name) diagnostic.name = record.name;
1465
+ if (typeof record.message === "string" && record.message) diagnostic.message = record.message;
1466
+ if (typeof record.stack === "string" && record.stack) diagnostic.stack = record.stack;
1467
+ }
1468
+ return diagnostic;
1469
+ }
1470
+
1471
+ // `report_path` is carried only when the report the gates were evaluated on
1472
+ // is not the packet-inferred default — the same rule as doctor's
1473
+ // `derived.assembly_report_path` handling — so the printed remediations can
1474
+ // name it and a default-report campaign's output is unchanged.
1475
+ function reportPathField(resolved) {
1476
+ const reportPath = explicitReportPath(resolved?.reportPath, resolved?.targetRepo);
1477
+ return reportPath ? { report_path: reportPath } : {};
1478
+ }
1479
+
1480
+ function resolvePayload(resolved, { routeProbe = null } = {}) {
1481
+ const entryUrls = deriveEntryUrls(resolved.topologies);
1482
+ const pageUrls = derivePageUrls(resolved.topologies);
1483
+ const checkpointGates = Array.isArray(resolved.checkpointGates)
1484
+ ? resolved.checkpointGates.map(checkpointGateSummary)
1485
+ : [];
1486
+ const hasBlockedCheckpoint = checkpointGates.some((gate) => gate.status === "blocked");
1487
+ const hasCheckpointWarning = checkpointGates.some(checkpointGateHasWarning);
1488
+ const status = resolveStatus({ hasBlockedCheckpoint, hasCheckpointWarning, routeProbe });
1489
+ return {
1490
+ ok: status !== "blocked" && status !== "routes_unresolved",
1491
+ status,
1492
+ map_id: resolved.mapId,
1493
+ ...(resolved.packetPath ? { packet_path: resolved.packetPath } : {}),
1494
+ ...reportPathField(resolved),
1495
+ ...(resolved.proxyBase && resolved.proxyBase !== DEFAULT_PROXY_BASE ? { proxy_base: resolved.proxyBase } : {}),
1496
+ spec_source: resolved.specSource,
1497
+ spec_version: resolved.specVersion,
1498
+ spec_hash: resolved.specHash,
1499
+ base_url: resolved.baseUrl,
1500
+ entry_urls: entryUrls,
1501
+ page_urls: pageUrls,
1502
+ tested_urls: [],
1503
+ campaign: {
1504
+ name: resolved.spec.campaign?.name || null,
1505
+ slug: resolved.spec.campaign?.slug || null,
1506
+ ref_id: resolved.spec.campaign?.ref_id || null,
1507
+ },
1508
+ checkpoint_gates: checkpointGates,
1509
+ theme_gate: themeGateSummary(resolved.themeGate),
1510
+ polish_gate: polishGateSummary(resolved.polishGate),
1511
+ ...(routeProbe ? { route_probe: routeProbe } : {}),
1512
+ funnels: resolved.topologies,
1513
+ };
1514
+ }
1515
+
1516
+ // The resolve status ladder, ordered by how much of the deployment this run
1517
+ // actually verified rather than by which axis complained. Before #273 the top
1518
+ // two rungs did not exist, so a route set that was 100% dead reported `ready`
1519
+ // and printed a next command that could not succeed.
1520
+ //
1521
+ // blocked a checkpoint gate blocks; the routes were not probed.
1522
+ // routes_unresolved the routes were probed and at least one did not resolve.
1523
+ // ready_unprobed there were routes to probe and none produced a response.
1524
+ // ready_with_exceptions / ready as before, now over probed-and-resolved routes.
1525
+ //
1526
+ // `ready_unprobed` sits above `ready_with_exceptions` on purpose: a checkpoint
1527
+ // warning is a known, named exception an operator can read, while an unprobed
1528
+ // route set means the deployment half of the run was never checked at all. The
1529
+ // checkpoint warnings stay fully visible in checkpoint_gates[] either way.
1530
+ function resolveStatus({ hasBlockedCheckpoint, hasCheckpointWarning, routeProbe }) {
1531
+ if (hasBlockedCheckpoint) return "blocked";
1532
+ if (routeProbe?.status === "failed") return "routes_unresolved";
1533
+ if (routeProbe?.status === "not_probed" && routeProbe.code !== "route_probe.no_routes") {
1534
+ return "ready_unprobed";
1535
+ }
1536
+ return hasCheckpointWarning ? "ready_with_exceptions" : "ready";
1537
+ }
1538
+
1539
+ // Probing is opt-out, not opt-in: the silent `ready` in #273 happened because
1540
+ // nothing checked by default. `--no-probe` exists for hermetic runs that must
1541
+ // make no outbound request at all; an ordinary offline run needs no flag,
1542
+ // because a transport failure already degrades to `ready_unprobed`.
1543
+ async function resolveRouteProbe(resolved, args, { fetchImpl = fetch } = {}) {
1544
+ const entryUrls = deriveEntryUrls(resolved.topologies);
1545
+ const blocked = (resolved.checkpointGates || []).some((gate) => gate?.status === "blocked");
1546
+ const disabled = args["no-probe"] === true;
1547
+ const timeout = Number(args["probe-timeout-ms"]);
1548
+ return probeRouteUrls({
1549
+ entryUrls,
1550
+ baseUrl: resolved.baseUrl,
1551
+ publicRouteSlug: resolved.publicRouteSlug,
1552
+ enabled: !disabled && !blocked,
1553
+ timeoutMs: Number.isFinite(timeout) && timeout > 0 ? timeout : ROUTE_PROBE_DEFAULT_TIMEOUT_MS,
1554
+ fetchImpl,
1555
+ skippedReason: blocked && !disabled ? "a checkpoint gate blocks this run" : disabled ? "--no-probe" : null,
1556
+ });
1557
+ }
1558
+
1559
+ function checkpointGateHasWarning(gate) {
1560
+ return gate?.status === "waived"
1561
+ || (Array.isArray(gate?.warning_fields) && gate.warning_fields.length > 0);
1562
+ }
1563
+
1564
+ function checkpointGateSummary(gate) {
1565
+ const hiddenEagerMedia = gate?.id === HIDDEN_EAGER_MEDIA_SCOPE;
1566
+ const subject = hiddenEagerMedia
1567
+ ? hiddenEagerMediaSubject(gate?.subject)
1568
+ : {
1569
+ public_route_slug: stringArg(gate?.subject?.public_route_slug) || "",
1570
+ target_path: stringArg(gate?.subject?.target_path) || PAGE_KIT_CAMPAIGNS_REL_PATH,
1571
+ };
1572
+ const waiver = checkpointWaiverSummary(gate?.waiver, subject);
1573
+ const waiverAssessment = {
1574
+ active: checkpointWaiverSummary(gate?.waiver_assessment?.active, subject),
1575
+ inert_counts: Object.fromEntries(
1576
+ ["stale", "foreign", "malformed", "expired"].map((kind) => [
1577
+ kind,
1578
+ Number.isInteger(gate?.waiver_assessment?.inert_counts?.[kind])
1579
+ ? gate.waiver_assessment.inert_counts[kind]
1580
+ : 0,
1581
+ ]),
1582
+ ),
1583
+ };
1584
+ const requiredActions = Array.isArray(gate?.required_actions)
1585
+ ? gate.required_actions
1586
+ .filter(isPlainObject)
1587
+ .map((action) => ({
1588
+ id: stringArg(action.id),
1589
+ kind: stringArg(action.kind),
1590
+ command: stringArg(action.command),
1591
+ description: stringArg(action.description),
1592
+ }))
1593
+ : [];
1594
+ const summary = {
1595
+ id: stringArg(gate?.id),
1596
+ scope: stringArg(gate?.scope),
1597
+ status: stringArg(gate?.status),
1598
+ code: stringArg(gate?.code),
1599
+ reason: stringArg(gate?.reason),
1600
+ waivable: gate?.waivable === true,
1601
+ subject,
1602
+ state_fingerprint: stringArg(gate?.state_fingerprint),
1603
+ waiver,
1604
+ waiver_assessment: waiverAssessment,
1605
+ required_actions: requiredActions,
1606
+ };
1607
+ if (hiddenEagerMedia) {
1608
+ const findings = hiddenEagerMediaFindings(gate);
1609
+ return {
1610
+ ...summary,
1611
+ state: { findings },
1612
+ findings,
1613
+ measurement: hiddenEagerMediaMeasurement(gate),
1614
+ };
1615
+ }
1616
+ if (gate?.id === PAGE_KIT_STORE_PROFILE_SCOPE) {
1617
+ return {
1618
+ ...summary,
1619
+ state: {
1620
+ ...(stringArg(gate?.state?.spec_status) ? { spec_status: stringArg(gate.state.spec_status) } : {}),
1621
+ ...(stringArg(gate?.state?.target_status) ? { target_status: stringArg(gate.state.target_status) } : {}),
1622
+ discrepancies: Array.isArray(gate?.state?.discrepancies)
1623
+ ? gate.state.discrepancies.map((row) => ({
1624
+ field: stringArg(row?.field),
1625
+ kind: stringArg(row?.kind),
1626
+ spec: typeof row?.spec === "string" ? row.spec : null,
1627
+ target: typeof row?.target === "string" ? row.target : null,
1628
+ }))
1629
+ : [],
1630
+ },
1631
+ matrix: Array.isArray(gate?.matrix)
1632
+ ? gate.matrix.map((row) => ({
1633
+ field: stringArg(row?.field),
1634
+ kind: stringArg(row?.kind),
1635
+ spec: typeof row?.spec === "string" ? row.spec : null,
1636
+ target: typeof row?.target === "string" ? row.target : null,
1637
+ severity: stringArg(row?.severity),
1638
+ }))
1639
+ : [],
1640
+ blocker_fields: Array.isArray(gate?.blocker_fields) ? gate.blocker_fields.filter((field) => typeof field === "string") : [],
1641
+ warning_fields: Array.isArray(gate?.warning_fields) ? gate.warning_fields.filter((field) => typeof field === "string") : [],
1642
+ };
1643
+ }
1644
+ if (gate?.id === PAGE_KIT_SDK_VERSION_SCOPE) {
1645
+ return {
1646
+ ...summary,
1647
+ state: {
1648
+ ...(stringArg(gate?.state?.spec_status) ? { spec_status: stringArg(gate.state.spec_status) } : {}),
1649
+ ...(stringArg(gate?.state?.target_status) ? { target_status: stringArg(gate.state.target_status) } : {}),
1650
+ ...(Array.isArray(gate?.state?.invalid_declarations)
1651
+ ? { invalid_declarations: gate.state.invalid_declarations.filter((field) => typeof field === "string") }
1652
+ : {}),
1653
+ ...(typeof gate?.state?.expected === "string" ? { expected: gate.state.expected } : {}),
1654
+ ...(typeof gate?.state?.observed === "string" ? { observed: gate.state.observed } : {}),
1655
+ },
1656
+ expected_sdk_version: typeof gate?.expected_sdk_version === "string" ? gate.expected_sdk_version : null,
1657
+ observed_sdk_version: typeof gate?.observed_sdk_version === "string" ? gate.observed_sdk_version : null,
1658
+ expected_source: typeof gate?.expected_source === "string" ? gate.expected_source : null,
1659
+ };
1660
+ }
1661
+ throw new Error(`QA cannot project unknown checkpoint gate ${JSON.stringify(gate?.id)}.`);
1662
+ }
1663
+
1664
+ function checkpointWaiverSummary(waiver, subject) {
1665
+ if (!isPlainObject(waiver)) return null;
1666
+ return {
1667
+ scope: stringArg(waiver.scope),
1668
+ subject,
1669
+ state_fingerprint: stringArg(waiver.state_fingerprint),
1670
+ reason: stringArg(waiver.reason),
1671
+ waived_by: stringArg(waiver.waived_by),
1672
+ waived_at: stringArg(waiver.waived_at),
1673
+ ...(stringArg(waiver.expires_at) ? { expires_at: stringArg(waiver.expires_at) } : {}),
1674
+ ...(stringArg(waiver.review_condition) ? { review_condition: stringArg(waiver.review_condition) } : {}),
1675
+ };
1676
+ }
1677
+
1678
+ // Run-record closeout is required for every QA workflow. With an active run
1679
+ // session, blocked attempts stay attached to that session while repair
1680
+ // continues; the first ready outcome auto-closes with all attempt references.
1681
+ // This action is the explicit contract for sessionless or manually ended paths.
1682
+ // Packetless modes (qa --site, parity fixtures) get no action: run-record
1683
+ // requires a Build Packet, and a required-but-impossible command is worse
1684
+ // than none (Kilo review, PR #176). Paths are shell-quoted when needed.
1685
+ export function buildQaCloseoutActions({ packetPath = null, localPath = null, runSessionActive = false, disposition = null } = {}) {
1686
+ if (!packetPath) return [];
1687
+ const verdictRef = localPath ? ` --qa-verdict ${shellToken(localPath)}` : "";
1688
+ // Will the session STILL hold this run_id by the time the operator runs the
1689
+ // printed command? Only when this attempt does not end the session — a
1690
+ // blocked one, which stays open for repair, or any disposition this version
1691
+ // does not recognise. Both sides read SESSION_ENDING_DISPOSITIONS, so the
1692
+ // answer here and the auto-end's own answer cannot drift apart.
1693
+ // A session that stays open closes later — the eventual ready auto-end, or
1694
+ // `run end` — assembling and remitting under this same run_id. Remit is a plain POST with no replace verb
1695
+ // and the receiver refuses a second POST for a stored run_id with 409, so a
1696
+ // command printed without `--no-remit` spends the id on the interim record
1697
+ // and leaves the session's final record — the one carrying every QA attempt
1698
+ // and the aggregated lifecycle — refused at the door.
1699
+ //
1700
+ // A session-ending disposition auto-ends the session IN THIS SAME PROCESS, before
1701
+ // the operator can type anything: the record is already assembled and the
1702
+ // session cleared, so the printed command mints its own run_id and there is
1703
+ // nothing to collide with. Printing `--no-remit` there would be worse than
1704
+ // useless — it would write a local-only record that never reaches the
1705
+ // receiver, and if the auto-end's own remit had failed, that newer closed
1706
+ // record would bury the failure the operator still has to recover from. The
1707
+ // auto-end prints that recovery command itself; see autoEndCloseoutNotice.
1708
+ const sessionRetainsRunId = runSessionActive && !SESSION_ENDING_DISPOSITIONS.has(disposition);
1709
+ const remitRef = sessionRetainsRunId ? " --no-remit" : "";
1710
+ const sessionNote = sessionRetainsRunId
1711
+ ? " This attempt does not end the run session, so the session stays open and this writes the local record only (--no-remit): it shares the session's run id, and the session's own close is what remits that id once."
1712
+ : "";
1713
+ return [
1714
+ {
1715
+ id: "run_record_closeout",
1716
+ kind: "command",
1717
+ required: true,
1718
+ stage: "qa",
1719
+ command: `${cmd("run-record")} --packet ${shellToken(packetPath)}${verdictRef}${remitRef} --json`,
1720
+ description: `Assemble the durable Run Record closeout for this QA workflow, including blocked outcomes. If an active session remains open after a blocked attempt, repair and re-test first (or use run end to close manually); a ready attempt auto-assembles one record that references every attempt.${sessionNote}`,
1721
+ },
1722
+ ];
1723
+ }
1724
+
1725
+ const REMOVED_QA_POLICY_FLAGS = ["test-orders-allowed", "sandbox-test-card-confirmed"];
1726
+
1727
+ function updateQaPolicy(args) {
1728
+ const packetPath = args.packet ? resolve(args.packet) : null;
1729
+ if (!packetPath) throw new Error("qa policy set requires --packet <campaign-runtime.build.json>.");
1730
+ const packet = readJson(packetPath);
1731
+ packet.campaign ||= {};
1732
+ packet.deploy ||= {};
1733
+ packet.qa ||= {};
1734
+
1735
+ // Test Orders have no permission flag. The two flags that once set one
1736
+ // were removed with their packet fields in supported surface 1.28.0; a
1737
+ // script still passing them gets told so instead of a silent no-op.
1738
+ const removedFlags = REMOVED_QA_POLICY_FLAGS.filter((flag) => flag in args);
1739
+ if (removedFlags.length) {
1740
+ throw new Error(`qa policy set: ${removedFlags.map((flag) => `--${flag}`).join(" and ")} ${removedFlags.length > 1 ? "were" : "was"} removed in supported surface 1.28.0 (test orders run from --test-order <mode> alone; there is no permission flag). Drop the flag${removedFlags.length > 1 ? "s" : ""}. Accepted: --allowed-domains-confirmed, --deploy-target, --preview-url, --production-url, --order-path-depth.`);
1741
+ }
1742
+ // Validated with the other argv checks, before anything is written.
1743
+ const orderPathDepth = parseOrderPathDepthFlag(args, { command: "qa policy set" });
1744
+
1745
+ const changed = [];
1746
+ setOptionalBoolean(packet.campaign, "allowed_domains_confirmed", args, "allowed-domains-confirmed", changed);
1747
+ setOptionalString(packet.deploy, "preview_url", args, "preview-url", changed);
1748
+ setOptionalString(packet.deploy, "production_url", args, "production-url", changed);
1749
+ setOptionalString(packet.deploy, "target", args, "deploy-target", changed);
1750
+ if (orderPathDepth) {
1751
+ packet.qa.proof_policy = isPlainObject(packet.qa.proof_policy) ? packet.qa.proof_policy : {};
1752
+ setIfChanged(packet.qa.proof_policy, "order_path_depth", orderPathDepth, changed);
1753
+ }
1754
+
1755
+ const staleReason = `The Build Packet changed after this doctor snapshot (qa policy set). Re-run ${cmd("doctor")} (or next) for current state.`;
1756
+ if (changed.length) {
1757
+ writeJson(packetPath, packet);
1758
+ // #171: packet edits change what doctor would conclude; the retained
1759
+ // doctor sidecar (if any) now predates them.
1760
+ markDoctorSidecarStale(targetRepoFor(packetPath, packet), {
1761
+ command: "qa policy set",
1762
+ reason: staleReason,
1763
+ });
1764
+ }
1765
+ // The assembly report mirrors qa.proof_policy from prepare-build, and
1766
+ // assessPurchaseProofCoverage reads a packet/report disagreement as an
1767
+ // unknown depth that holds `next` short of done. Whenever a depth is set,
1768
+ // the mirror is refreshed through the same ledger write every other report
1769
+ // edit uses — even when the packet already held that value, so a hand-edited
1770
+ // packet whose mirror lags is reconciled by re-stating the packet's value.
1771
+ // A packet with no report yet (pre-prepare-build) is left alone.
1772
+ let reportMirror = null;
1773
+ if (orderPathDepth) {
1774
+ const workspace = resolveCampaignWorkspace(packetPath, { packet, followContextPointer: true });
1775
+ if (existsSync(workspace.reportPath)) {
1776
+ const outcome = commitAssemblyReport(workspace, (report) => {
1777
+ const mirror = isPlainObject(report.proof_policy) ? report.proof_policy : {};
1778
+ if (mirror.order_path_depth === orderPathDepth) return null;
1779
+ report.proof_policy = { ...mirror, order_path_depth: orderPathDepth };
1780
+ return report;
1781
+ }, { command: "qa policy set", staleReason });
1782
+ reportMirror = { report_path: outcome.reportPath, written: outcome.written, order_path_depth: orderPathDepth };
1783
+ if (outcome.written) changed.push("report.proof_policy.order_path_depth");
1784
+ }
1785
+ }
1786
+ return {
1787
+ ok: true,
1788
+ action: "qa-policy-set",
1789
+ packet_path: packetPath,
1790
+ changed,
1791
+ policy: policySnapshot(packet),
1792
+ ...(reportMirror ? { report_mirror: reportMirror } : {}),
1793
+ };
1794
+ }
1795
+
1796
+ // The complete set of QA assertions `qa waive` accepts today. Deliberately a
1797
+ // one-element list (packet 01, ratified I-9/I-16): analytics-correctness:
1798
+ // purchase-fires is the one unwaivable blocker in the estate that gained a
1799
+ // named-human lane. Extending the lane to another assertion is a design
1800
+ // decision with its own ratification, not a flag — refuse anything else.
1801
+ const WAIVABLE_QA_ASSERTIONS = Object.freeze(["analytics-correctness:purchase-fires"]);
1802
+
1803
+ // `qa waive`: the ONLY sanctioned way to accept a failing
1804
+ // analytics-correctness:purchase-fires blocker. Modeled on `theme waive`
1805
+ // (cli.mjs themeWaive): refuses without --reason, records waived_by, stamps
1806
+ // waived_at, and writes ONE explicit decision onto the Assembly Report's qa
1807
+ // stage ($defs/stage is additionalProperties:true — no schema change, no
1808
+ // surface_version bump) so the correctness leg reads a named human's decision
1809
+ // instead of an agent improvising past a blocker. The waiver record mirrors
1810
+ // report.theme.waiver: { reason, waived_by, waived_at }.
1811
+ export function qaWaive(args) {
1812
+ const packetPath = args.packet ? resolve(args.packet) : null;
1813
+ if (!packetPath) throw new Error("qa waive requires --packet <campaign-runtime.build.json>.");
1814
+ const packet = readJson(packetPath);
1815
+ const assertionId = stringArg(args.assertion);
1816
+ if (!assertionId) {
1817
+ throw new Error(`qa waive requires --assertion <id>. Waivable assertions: ${WAIVABLE_QA_ASSERTIONS.join(", ")}.`);
1818
+ }
1819
+ if (!WAIVABLE_QA_ASSERTIONS.includes(assertionId)) {
1820
+ throw new Error(
1821
+ `qa waive does not accept --assertion "${assertionId}". The waiver lane is scoped to exactly: ${WAIVABLE_QA_ASSERTIONS.join(", ")}. `
1822
+ + "Extending the lane to another assertion is a design decision, not a flag.",
1823
+ );
1824
+ }
1825
+ const reason = stringArg(args.reason);
1826
+ if (!reason) {
1827
+ throw new Error("qa waive requires --reason \"<why this failing blocker is acceptable for this campaign>\".");
1828
+ }
1829
+ const workspace = resolveCampaignWorkspace(packetPath, {
1830
+ packet,
1831
+ reportPath: args.report ? resolve(String(args.report)) : undefined,
1832
+ followContextPointer: true,
1833
+ });
1834
+ const { reportPath } = workspace;
1835
+ if (!existsSync(reportPath)) {
1836
+ throw new Error(`qa waive needs an assembly report at ${reportPath}; run prepare-build/start first.`);
1837
+ }
1838
+ const waiver = {
1839
+ reason,
1840
+ // Named-human lane: default to the operator identity the QA verdict
1841
+ // already records ($USER@local), falling back to themeWaive's "operator".
1842
+ waived_by: stringArg(args["waived-by"]) || (process.env.USER ? `${process.env.USER}@local` : "operator"),
1843
+ waived_at: new Date().toISOString(),
1844
+ };
1845
+ commitAssemblyReport(workspace, (report) => {
1846
+ const stages = isPlainObject(report.stages) ? report.stages : {};
1847
+ const stageQa = isPlainObject(stages.qa) ? stages.qa : { stage: "qa", status: "pending" };
1848
+ stageQa.waivers = { ...(isPlainObject(stageQa.waivers) ? stageQa.waivers : {}), [assertionId]: waiver };
1849
+ stages.qa = stageQa;
1850
+ report.stages = stages;
1851
+ if (Array.isArray(report.evidence)) {
1852
+ report.evidence.push(`QA waiver: ${assertionId} waived by ${waiver.waived_by} at ${waiver.waived_at}: ${reason}`);
1853
+ }
1854
+ return report;
1855
+ }, {
1856
+ // #171: the waiver changes what the next qa run concludes; the retained
1857
+ // doctor sidecar (if any) now predates this report edit.
1858
+ command: "qa waive",
1859
+ staleReason: `A QA assertion waiver was recorded after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
1860
+ });
1861
+ return {
1862
+ ok: true,
1863
+ action: "qa-waive",
1864
+ assertion: assertionId,
1865
+ waiver,
1866
+ report_path: reportPath,
1867
+ note: `The next qa run downgrades a FAILING ${assertionId} blocker to a warning with this attribution; the disposition becomes ready_with_exceptions, never plain ready. A passing run ignores the waiver.`,
1868
+ };
1869
+ }
1870
+
1871
+ // Read the recorded `qa waive` decisions off the Assembly Report for a run.
1872
+ // Only assertions inside the sanctioned lane with a non-empty reason count —
1873
+ // anything else on the report is inert data (reverting the waiver feature
1874
+ // must leave recorded waivers harmless, per packet 01's revert contract).
1875
+ function resolveQaWaivers({ packetPath, report: reportOverride = undefined }) {
1876
+ const report = reportOverride === undefined
1877
+ ? loadRuntimeArtifact(packetPath, "assembly-report.json")
1878
+ : reportOverride;
1879
+ const recorded = report?.stages?.qa?.waivers;
1880
+ if (!isPlainObject(recorded)) return {};
1881
+ const waivers = {};
1882
+ for (const [assertionId, waiver] of Object.entries(recorded)) {
1883
+ if (!WAIVABLE_QA_ASSERTIONS.includes(assertionId)) continue;
1884
+ if (!isPlainObject(waiver)) continue;
1885
+ if (typeof waiver.reason !== "string" || !waiver.reason.trim()) continue;
1886
+ waivers[assertionId] = {
1887
+ reason: waiver.reason.trim(),
1888
+ waived_by: stringArg(waiver.waived_by) || "operator",
1889
+ waived_at: stringArg(waiver.waived_at) || null,
1890
+ };
1891
+ }
1892
+ return waivers;
1893
+ }
1894
+
1895
+ function isPlainObject(value) {
1896
+ return !!value && typeof value === "object" && !Array.isArray(value);
1897
+ }
1898
+
1899
+ // Every assertion family a non-blocked run can emit beyond theme_gate. A
1900
+ // gate-blocked verdict carries one skipped audit assertion per family so the
1901
+ // verdict shape never drifts for consumers walking assertions[] by family.
1902
+ // Derived from the canonical vocabulary in qa-verdict.mjs (which preserves
1903
+ // this list's emission order); drift against the actual emitters is enforced
1904
+ // by tests that collect `family:` literals and assert set equality — adding
1905
+ // an emitter without updating the vocabulary fails CI.
1906
+ export const GATE_SUPPRESSED_FAMILIES = Object.freeze(
1907
+ QA_ASSERTION_FAMILY_VOCABULARY.filter((family) => family !== "theme_gate"),
1908
+ );
1909
+
1910
+ // A requested browser pass a blocked gate refused.
1911
+ //
1912
+ // `qa run --browser` behind a blocked gate finalizes the blocked verdict before
1913
+ // any page is rendered — the gate decision is the point, and neither it nor the
1914
+ // exit code changes here. What used to be missing is any trace of the
1915
+ // downgrade: the verdict was byte-identical to the same run without the flag
1916
+ // (no browser-runtime assertions, `tested_urls: []`), and stderr said nothing,
1917
+ // so an operator who asked for browser QA got none and had no way to tell.
1918
+ // The stamp below rides the verdict for machine readers and
1919
+ // `reportBrowserSkippedByGate` says it once for the human.
1920
+ export const BROWSER_SKIPPED_GATE_BLOCKED = "skipped_gate_blocked";
1921
+
1922
+ // How many of a gate's required actions the one-line notice quotes before it
1923
+ // defers to the verdict. Exported so a test pins the number rather than
1924
+ // re-deriving it from the behaviour it governs.
1925
+ export const MAX_BROWSER_SKIP_ACTIONS = 3;
1926
+
1927
+ // What actually clears this gate, taken from the gate itself.
1928
+ //
1929
+ // Deliberately not prose written here: "re-run Polish" is wrong for
1930
+ // polish.assembly_source_package_stale (only a fresh Build refreshes the
1931
+ // assembly fingerprint), and "record a waiver" is wrong for every non-waivable
1932
+ // checkpoint state — checkpointWaive refuses those outright. The evaluators
1933
+ // already publish the correct repair for the exact state they blocked on, and
1934
+ // the waive command appears among them only when the gate is waivable, so the
1935
+ // notice quotes required_actions and invents nothing.
1936
+ function gateClearingHint(gates) {
1937
+ const actions = (Array.isArray(gates) ? gates : [gates])
1938
+ .filter(isPlainObject)
1939
+ .flatMap((gate) => (Array.isArray(gate.required_actions) ? gate.required_actions.filter(isPlainObject) : []));
1940
+ const unique = [];
1941
+ for (const action of actions) {
1942
+ // The rendering rule doctor and next use, minus the packet: the verdict
1943
+ // is a public artifact that never carries a local path, so the
1944
+ // `--packet <packet>` placeholder stays and only the install prefix is
1945
+ // applied; a kind: "manual" action falls back to its instruction.
1946
+ const text = singleLineFragment(requiredActionText(action));
1947
+ if (text && !unique.includes(text)) unique.push(text);
1948
+ }
1949
+ // Deduplicated BEFORE the cap, and truncation is measured against that count:
1950
+ // two identical actions are one repair, and claiming a "rest" the reader
1951
+ // would not find on the verdict is worse than saying nothing.
1952
+ const named = unique.slice(0, MAX_BROWSER_SKIP_ACTIONS);
1953
+ if (!named.length) {
1954
+ // Two different silences. A gate that published nothing has nothing for the
1955
+ // reader to look up beyond its own reason; a gate that published actions
1956
+ // this notice could not render (no command, no readable description) has
1957
+ // repair steps ON the verdict, and calling that "no actions" would hide
1958
+ // them.
1959
+ return actions.length
1960
+ ? "The gate published repair steps this notice could not render; read its required_actions on the verdict for what clears it, then re-run with --browser."
1961
+ : "Read the gate's own reason and required_actions on the verdict for what clears it, then re-run with --browser.";
1962
+ }
1963
+ const more = unique.length > named.length ? " (and the rest of the gate's required_actions on the verdict)" : "";
1964
+ return `The gate's required actions clear it: ${named.join("; ")}${more}. Then re-run with --browser.`;
1965
+ }
1966
+
1967
+ function browserSkippedByGate({ args, blockedBy, gateLabel, gates }) {
1968
+ // No flag, no claim: a run that never asked for a browser pass is not
1969
+ // "skipping" one, and stamping it would make the field unreadable.
1970
+ if (args?.browser !== true) return null;
1971
+ const codes = (Array.isArray(blockedBy) ? blockedBy : [blockedBy])
1972
+ .filter((code) => typeof code === "string" && code.length > 0);
1973
+ const named = codes.length ? ` (${codes.join(", ")})` : "";
1974
+ return {
1975
+ requested: true,
1976
+ status: BROWSER_SKIPPED_GATE_BLOCKED,
1977
+ blocked_by: codes,
1978
+ reason: `Browser QA was requested with --browser but no browser launched: the ${gateLabel}${named} blocked this run before any page was rendered. ${gateClearingHint(gates)}`,
1979
+ };
1980
+ }
1981
+
1982
+ // One stderr line, on the same seam as reportCommercialRunnerError: suppressed
1983
+ // under --json, where the stamp itself is already in the emitted verdict.
1984
+ function reportBrowserSkippedByGate(args, browser, write = (message) => process.stderr.write(message)) {
1985
+ if (!browser || browser.status !== BROWSER_SKIPPED_GATE_BLOCKED) return false;
1986
+ if (args?.json === true) return false;
1987
+ write(`[campaigns-os] ${browser.reason}\n`);
1988
+ return true;
1989
+ }
1990
+
1991
+ function parityReplayEvidence(bundle) {
1992
+ const order = bundle?.order || bundle?.orders?.[0] || null;
1993
+ const capture = bundle?.capture || bundle?.candidate_capture || bundle?.captures?.candidate || null;
1994
+ const baselineCapture = bundle?.baseline_capture || bundle?.baselineCapture || bundle?.captures?.baseline || null;
1995
+ if (!order || !capture) {
1996
+ throw new Error("--parity-order-json must contain order + capture evidence (or orders[0] + captures.candidate).");
1997
+ }
1998
+ return { order, capture, baselineCapture, orders: Array.isArray(bundle.orders) ? bundle.orders : [order] };
1999
+ }
2000
+
2001
+ async function runParityQa(args) {
2002
+ // Checked here as well as on the budget itself: the budget is built after a
2003
+ // browser has launched, and a flag the operator typed wrong should cost them
2004
+ // nothing. The budget stays the authority — this is fail-fast, not the gate.
2005
+ validatedOrderCreationLimit(args);
2006
+ const fixturePath = stringArg(args.fixture);
2007
+ const scenarioId = stringArg(args.scenario) || stringArg(args._[2]);
2008
+ if (!fixturePath) throw new Error("QA parity requires --fixture <parity-fixture.json>.");
2009
+ if (!scenarioId) throw new Error("QA parity requires --scenario <scenario-id>.");
2010
+
2011
+ const fixture = await loadParityFixture(resolve(fixturePath));
2012
+ const scenario = resolveParityScenario(fixture, scenarioId);
2013
+ const baseUrl = normalizeBaseUrl(stringArg(args["base-url"]) || fixture.candidate_base_url);
2014
+ const startedAt = new Date().toISOString();
2015
+ const runId = generateRunId();
2016
+ let result;
2017
+
2018
+ if (stringArg(args["parity-order-json"])) {
2019
+ const replay = parityReplayEvidence(readJson(resolve(args["parity-order-json"])));
2020
+ result = {
2021
+ assertions: assessParityCapture({
2022
+ fixture,
2023
+ scenario,
2024
+ order: replay.order,
2025
+ capture: replay.capture,
2026
+ baselineCapture: replay.baselineCapture,
2027
+ }),
2028
+ orders: replay.orders,
2029
+ captures: { candidate: replay.capture, ...(replay.baselineCapture ? { baseline: replay.baselineCapture } : {}) },
2030
+ };
2031
+ } else {
2032
+ result = await runParityCapture({ fixture, scenarioId, args: { ...args, "base-url": baseUrl, run_id: runId } });
2033
+ // Persist the live evidence bundle beside the verdict: replay runs
2034
+ // (--parity-order-json) and negative controls assess the exact same
2035
+ // order + capture the live traversal produced.
2036
+ const bundleDir = campaignOutputDir(args["output-dir"] || "qa-output", fixture.campaign.slug);
2037
+ mkdirSync(bundleDir, { recursive: true });
2038
+ writeJson(join(bundleDir, `${runId}.parity-bundle.json`), {
2039
+ scenario_id: scenario.scenario_id,
2040
+ // Both shapes on purpose: `order` is the assessed primary,
2041
+ // `orders` mirrors the in-memory result so bundle readers and
2042
+ // parityReplayEvidence see the same array shape as the live run.
2043
+ order: result.orders[0] || null,
2044
+ orders: result.orders,
2045
+ capture: result.captures?.candidate || null,
2046
+ ...(result.captures?.baseline ? { baseline_capture: result.captures.baseline } : {}),
2047
+ });
2048
+ }
2049
+
2050
+ const page = { page_id: "parity", page_type: "checkout", url: baseUrl };
2051
+ const resolved = {
2052
+ themeGate: { status: "not_applicable", code: "theme_gate.not_applicable", reason: "Parity capture is fixture-driven." },
2053
+ polishGate: { status: "not_applicable", code: "polish.not_applicable", reason: "Parity capture is fixture-driven." },
2054
+ mapId: fixture.campaign.slug,
2055
+ proxyBase: stringArg(args["proxy-base"]) || DEFAULT_PROXY_BASE,
2056
+ baseUrl,
2057
+ spec: { campaign: { ref_id: String(scenario.shadow_campaign_id) } },
2058
+ specVersion: `parity-fixture-${fixture.schema_version}`,
2059
+ specHash: computeSpecHash(fixture),
2060
+ topologies: [{ topology_id: `parity-${scenario.scenario_id}`, pages: [page] }],
2061
+ };
2062
+ return finalizeQaRun({
2063
+ args,
2064
+ resolved,
2065
+ runId,
2066
+ startedAt,
2067
+ assertions: result.assertions,
2068
+ testOrders: result.orders,
2069
+ });
2070
+ }
2071
+
2072
+ async function runQa(args, options = {}) {
2073
+ // Fail-fast before anything resolves or launches. The authoritative check
2074
+ // lives on the creation budget itself, which every browser path builds.
2075
+ validatedOrderCreationLimit(args);
2076
+ const resolved = await resolveQaInputs(args);
2077
+ return runResolvedQa(args, resolved, options);
2078
+ }
2079
+
2080
+ // `runSessionActive` is threaded in from the CLI's single ambient-session read
2081
+ // rather than re-discovered here, so the closeout command this run prints and
2082
+ // the run_id the session will close under come from the same observation.
2083
+ async function runResolvedQa(args, resolved, { runSessionActive = false } = {}) {
2084
+ const startedAt = new Date().toISOString();
2085
+ const runId = generateRunId();
2086
+ const gate = resolved.themeGate;
2087
+ const polishGate = resolved.polishGate;
2088
+ const checkpointGates = Array.isArray(resolved.checkpointGates)
2089
+ ? resolved.checkpointGates
2090
+ : nonPacketCheckpointGates(resolved.publicRouteSlug);
2091
+ const blockedCheckpoints = checkpointGates.filter((checkpoint) => checkpoint?.status === "blocked");
2092
+ if (blockedCheckpoints.length) {
2093
+ return finalizeQaRun({
2094
+ args,
2095
+ resolved,
2096
+ runId,
2097
+ startedAt,
2098
+ assertions: checkpointBlockedAssertions(checkpointGates, polishGate, gate),
2099
+ testOrders: [],
2100
+ commercial: unavailableCommercialReport("checkpoint_gate_blocked"),
2101
+ runSessionActive,
2102
+ browser: browserSkippedByGate({
2103
+ args,
2104
+ blockedBy: blockedCheckpoints.map((checkpoint) => checkpoint.id),
2105
+ gateLabel: "checkpoint gate",
2106
+ gates: blockedCheckpoints,
2107
+ }),
2108
+ });
2109
+ }
2110
+ const checkpointAssertions = checkpointGates.map(checkpointGateAssertion);
2111
+ if (polishGate.status === "blocked") {
2112
+ return finalizeQaRun({
2113
+ args,
2114
+ resolved,
2115
+ runId,
2116
+ startedAt,
2117
+ assertions: [...checkpointAssertions, ...polishBlockedAssertions(polishGate, gate).filter((item) => item.family !== "api-metadata")],
2118
+ testOrders: [],
2119
+ commercial: unavailableCommercialReport("polish_gate_blocked"),
2120
+ runSessionActive,
2121
+ browser: browserSkippedByGate({
2122
+ args,
2123
+ blockedBy: [polishGate.code],
2124
+ gateLabel: "polish gate",
2125
+ gates: [polishGate],
2126
+ }),
2127
+ });
2128
+ }
2129
+ // Blocked theme gate refuses the whole run: the verdict carries the gate
2130
+ // blocker plus skipped audit assertions for every suppressed check family,
2131
+ // so the verdict shape stays stable for consumers (exit code 4).
2132
+ if (gate.status === "blocked") {
2133
+ return finalizeQaRun({
2134
+ args,
2135
+ resolved,
2136
+ runId,
2137
+ startedAt,
2138
+ assertions: [...checkpointAssertions, ...themeBlockedAssertions(gate, polishGate).filter((item) => item.family !== "api-metadata")],
2139
+ testOrders: [],
2140
+ commercial: unavailableCommercialReport("theme_gate_blocked"),
2141
+ runSessionActive,
2142
+ browser: browserSkippedByGate({
2143
+ args,
2144
+ blockedBy: [gate.code],
2145
+ gateLabel: "theme gate",
2146
+ gates: [gate],
2147
+ }),
2148
+ });
2149
+ }
2150
+
2151
+ const assertions = [
2152
+ ...checkpointAssertions,
2153
+ ...(polishGate?.owned_checkpoint_only ? [] : [polishGateAssertion(polishGate)]),
2154
+ themeGateAssertion(gate),
2155
+ ];
2156
+ const contractAssertion = templateBrandContractAssertion(resolved);
2157
+ if (contractAssertion) assertions.push(contractAssertion);
2158
+ const commercialPlanning = planCommercialParity(resolved.rawSpec || resolved.spec);
2159
+ const sourceLoader = createPageSourceLoader({ authCookie: args["auth-cookie"] });
2160
+ const commercialIds = new Set(commercialPlanning.pages
2161
+ .filter((page) => page?.id !== undefined && page?.id !== null)
2162
+ .map((page) => String(page.id)));
2163
+ const capturesByPageId = new Map();
2164
+ const bindingExpected = expectedBinding(resolved);
2165
+ const bindingScriptLoader = createBindingScriptLoader();
2166
+ const pages = resolved.topologies.flatMap(topology => topology.pages);
2167
+ const pageResults = await mapConcurrent(pages, COMMERCIAL_QA_LIMITS.concurrency, page =>
2168
+ runPageChecks(page, args, { sourceLoader, bindingExpected, bindingScriptLoader, captureCommercial: commercialIds.has(String(page.page_id)) }));
2169
+ for (const [index, page] of pages.entries()) {
2170
+ const pageResult = pageResults[index];
2171
+ assertions.push(...pageResult.assertions);
2172
+ if (commercialIds.has(String(page.page_id)) && pageResult.commercialCapture && !capturesByPageId.has(String(page.page_id))) {
2173
+ capturesByPageId.set(String(page.page_id), pageResult.commercialCapture);
2174
+ }
2175
+ }
2176
+ if (args.browser === true) {
2177
+ assertions.push(...await runBrowserChecks(resolved.topologies, args, {
2178
+ brandContract: resolved.brandContract,
2179
+ residueSeverity: residueSeverityForThemeGate(gate.status),
2180
+ supportedPaymentMethods: supportedPaymentMethodsFromSpec(resolved.spec),
2181
+ }));
2182
+ }
2183
+
2184
+ const testOrders = await runAnalyticsOrderSequence({ args, resolved, runId, assertions });
2185
+ const remainingAssertionBudget = Math.max(0, QA_VERDICT_ASSERTION_LIMIT - assertions.length);
2186
+ let commercialResult;
2187
+ try {
2188
+ commercialResult = await runCommercialParity({
2189
+ resolved,
2190
+ captures: [...capturesByPageId.values()],
2191
+ planning: commercialPlanning,
2192
+ maxAssertions: remainingAssertionBudget,
2193
+ });
2194
+ } catch (error) {
2195
+ commercialResult = {
2196
+ assertions: [],
2197
+ commercial: unavailableCommercialReport("commercial_runner_error"),
2198
+ };
2199
+ reportCommercialRunnerError(args, error);
2200
+ }
2201
+ assertions.push(...commercialResult.assertions);
2202
+ return finalizeQaRun({
2203
+ args,
2204
+ resolved,
2205
+ runId,
2206
+ startedAt,
2207
+ assertions,
2208
+ testOrders,
2209
+ commercial: commercialResult.commercial,
2210
+ runSessionActive,
2211
+ });
2212
+ }
2213
+
2214
+ function reportCommercialRunnerError(args, error, write = (message) => process.stderr.write(message)) {
2215
+ if (args?.json === true) return false;
2216
+ write(`[campaigns-os] Commercial parity could not complete: ${error instanceof Error ? error.message : String(error)}\n`);
2217
+ return true;
2218
+ }
2219
+
2220
+ // Keep the correctness/order chronology in one testable seam. Root inventory
2221
+ // happens first, parity remains between the two correctness phases, then the
2222
+ // one canonical typed-card run captures its settled terminal and only then do
2223
+ // we finalize the stable purchase-fires assertion. There is no receipt replay
2224
+ // and no second order.
2225
+ async function runAnalyticsOrderSequence({ args, resolved, runId, assertions }, overrides = {}) {
2226
+ const operations = {
2227
+ runInventory: runAnalyticsCorrectnessChecks,
2228
+ runParity: runAnalyticsParityChecks,
2229
+ runOrders: maybeRunTestOrders,
2230
+ assessReceipt: assessReceiptPurchase,
2231
+ ...overrides,
2232
+ };
2233
+ const analyticsContract = resolved.spec?.analytics;
2234
+ const analyticsLeg = analyticsCorrectnessLegDecision(forcedAnalyticsCorrectness(args), analyticsContract);
2235
+
2236
+ // Packet 04 Stage A / IC-2: the flag remains tri-state. Explicit false is a
2237
+ // visible skip; true forces both phases; absent defers to the spec.
2238
+ if (analyticsLeg === "disabled") {
2239
+ assertions.push(analyticsCorrectnessDisabledAssertion(analyticsContract));
2240
+ } else if (analyticsLeg === "run") {
2241
+ assertions.push(...await operations.runInventory(args, analyticsContract || {}, {
2242
+ target: resolved.analyticsCaptureTarget,
2243
+ }));
2244
+ }
2245
+
2246
+ // Analytics parity is unchanged and remains opt-in between root inventory
2247
+ // and typed-card receipt capture.
2248
+ if (stringArg(args["analytics-baseline"])) {
2249
+ assertions.push(...await operations.runParity(args, { target: resolved.analyticsCaptureTarget }));
2250
+ }
2251
+
2252
+ const result = await operations.runOrders({
2253
+ args,
2254
+ resolved,
2255
+ runId,
2256
+ assertions,
2257
+ captureAnalytics: analyticsLeg === "run",
2258
+ });
2259
+ if (analyticsLeg === "run") {
2260
+ assertions.push(operations.assessReceipt(result.receiptAnalytics, { waivers: resolved.qaWaivers }));
2261
+ }
2262
+ return result.orders;
2263
+ }
2264
+
2265
+ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, testOrders, commercial = null, runSessionActive = false, browser = null }) {
2266
+ const entryUrls = deriveEntryUrls(resolved.topologies);
2267
+ const pageUrls = derivePageUrls(resolved.topologies);
2268
+ const testedUrls = deriveTestedUrlsFromAssertions(assertions, pageUrls);
2269
+ // Per-finding cause classification happens BEFORE the verdict is assembled,
2270
+ // so the assertions, the derived exceptions, the committed sidecar and the
2271
+ // printed report all carry the same labels. The comparison root is the Build
2272
+ // Packet directory — the same root the Run Record writes under — so the
2273
+ // previous run is found through the existing Run Record discovery rather
2274
+ // than a second scan of qa-output/. The target repo rides along because the
2275
+ // previous run's full verdict lives under ITS qa-output/ (the same default
2276
+ // this run writes to below), which the record references only as
2277
+ // `external:qa_verdict` whenever that is not the packet directory.
2278
+ const causeSummary = annotateQaAssertionCauses(assertions, {
2279
+ baseDir: resolved.packetPath ? dirname(resolved.packetPath) : null,
2280
+ targetRepo: resolved.packetPath ? targetRepoFor(resolved.packetPath, resolved.packet) : null,
2281
+ mapId: resolved.mapId,
2282
+ currentRunId: runId,
2283
+ isFinding: isFindingAssertion,
2284
+ });
2285
+ const verdict = createVerdict({
2286
+ runId,
2287
+ mapId: resolved.mapId,
2288
+ publicRouteSlug: resolved.publicRouteSlug || null,
2289
+ campaignRefId: resolved.spec.campaign?.ref_id || null,
2290
+ specVersion: resolved.specVersion,
2291
+ specHash: resolved.specHash,
2292
+ startedAt,
2293
+ completedAt: new Date().toISOString(),
2294
+ runtime: RUNTIME,
2295
+ operator: process.env.USER ? `${process.env.USER}@local` : "",
2296
+ baseUrl: resolved.baseUrl,
2297
+ entryUrls,
2298
+ pageUrls,
2299
+ testedUrls,
2300
+ assertions,
2301
+ testOrders,
2302
+ commercial,
2303
+ causeSummary,
2304
+ browser,
2305
+ });
2306
+
2307
+ const validationErrors = validateVerdict(verdict);
2308
+ if (validationErrors.length) throw new Error(`QA verdict failed local validation:\n- ${validationErrors.join("\n- ")}`);
2309
+ // Said before the verdict is written and published: a publish that hangs or
2310
+ // fails must not be what decides whether the operator hears about the
2311
+ // downgrade they asked for.
2312
+ reportBrowserSkippedByGate(args, verdict.browser);
2313
+ // The full verdict lands beside the campaign, not beside the caller. The
2314
+ // Run Record reads verdicts back from <target-repo>/qa-output/<slug>/ by
2315
+ // convention, so a default rooted at cwd wrote the file where nothing
2316
+ // would find it and left the record with no path. Packet-less runs
2317
+ // (--site / raw map-id) have no target repo and keep the cwd default;
2318
+ // --output-dir is explicit and always wins.
2319
+ const outputDir = args["output-dir"]
2320
+ ? resolve(args["output-dir"])
2321
+ : resolved.packetPath
2322
+ ? campaignSidecarPaths(targetRepoFor(resolved.packetPath, resolved.packet)).qaOutputDir
2323
+ : resolve("qa-output");
2324
+ const localPath = writeLocalVerdict(verdict, outputDir);
2325
+ // The committed sidecar lands beside the Build Packet regardless of
2326
+ // --output-dir, for every finalized disposition including blocked: it is
2327
+ // what campaigns-agent's readback consumes, and a blocked run the agent can
2328
+ // read faithfully beats a missing artifact. Packet-less runs (--site / raw
2329
+ // map-id) have no packet home, so there is nowhere contracted to write.
2330
+ const sidecar = resolved.packetPath ? writeQaSidecar({ verdict, packetPath: resolved.packetPath }) : null;
2331
+ // Publish to the QA portal by default so runs land in the Campaign Map QA tab without the
2332
+ // operator needing to know a flag (LLM/agent UIs are the primary interface). Opt out with
2333
+ // --no-post-verdict / --local-only / --post-verdict false. Never fail the run if publish is unreachable.
2334
+ // #172: the default rides the telemetry consent seam — consent off means
2335
+ // local-only for non-portal-managed runs; portal-managed campaigns keep
2336
+ // publish-by-default; explicit flags always win.
2337
+ // resolveConsent's default warn stays on: a malformed env value, malformed
2338
+ // config, or scope mismatch should be visible on the QA path too, not just
2339
+ // on remit (Kilo review, PR #177).
2340
+ const consent = resolveConsent({ proxyBase: resolved.proxyBase });
2341
+ const publishDecision = decidePublishVerdict({ args, portalManaged: resolved.portalManaged === true, consent });
2342
+ if (publishDecision.flag_invalid) {
2343
+ process.stderr.write(`[campaigns-os] --post-verdict "${args["post-verdict"]}" is not a recognized value (use true|1|yes|y|on or false|0|no|n|off); the flag was ignored and the default publish decision applied.\n`);
2344
+ }
2345
+ const shouldPublish = publishDecision.publish;
2346
+ const publishDestination = `${resolved.proxyBase.replace(/\/+$/, "")}/api/qa/verdicts`;
2347
+ // One rail with `qa publish`: the outcome is classified by what the portal
2348
+ // answered (a 409 is already_stored, an ok), and the block the Run Record
2349
+ // carries is built here so the auto-end can stamp it without re-deriving.
2350
+ const publishOutcome = shouldPublish ? await publishQaVerdict(verdict, resolved.proxyBase) : skippedQaVerdictPublish();
2351
+ const postResult = publishOutcome.ok ? (publishOutcome.response ?? { ok: true }) : null;
2352
+ const postError = publishOutcome.attempted && !publishOutcome.ok ? publishOutcome.error : null;
2353
+ const dashboardUrl = publishOutcome.ok
2354
+ ? qaPortalUrl(resolved.proxyBase, resolved.mapId, verdict.run_id)
2355
+ : null;
2356
+ return {
2357
+ ok: verdict.disposition !== "blocked",
2358
+ status: verdict.disposition,
2359
+ run_id: verdict.run_id,
2360
+ map_id: resolved.mapId,
2361
+ public_route_slug: resolved.publicRouteSlug || null,
2362
+ ...reportPathField(resolved),
2363
+ base_url: resolved.baseUrl,
2364
+ entry_urls: entryUrls,
2365
+ page_urls: pageUrls,
2366
+ tested_urls: testedUrls,
2367
+ dashboard_url: dashboardUrl,
2368
+ local_path: localPath,
2369
+ qa_sidecar: sidecar,
2370
+ posted: postResult,
2371
+ post_error: postError,
2372
+ publish_skipped: !shouldPublish,
2373
+ // The classified outcome (attempted, ok, error, endpoint, result,
2374
+ // http_status, base_kind) and the Run Record block derived from it.
2375
+ publish: { ...publishOutcome, response: undefined },
2376
+ qa_verdict_publish: qaVerdictPublishBlock(publishOutcome, { verdictRunId: verdict.run_id, publisher: QA_VERDICT_PUBLISHERS.run, publishedAt: new Date().toISOString() }),
2377
+ publish_decision: { ...publishDecision, destination: publishDestination, consent_state: consent?.state ?? null },
2378
+ counts: countAssertions(verdict.assertions),
2379
+ theme_gate: themeGateSummary(resolved.themeGate),
2380
+ polish_gate: polishGateSummary(resolved.polishGate),
2381
+ browser: verdict.browser || null,
2382
+ commercial: verdict.commercial || null,
2383
+ next_actions: buildQaCloseoutActions({ packetPath: resolved.packetPath, localPath, runSessionActive, disposition: verdict.disposition }),
2384
+ verdict,
2385
+ };
2386
+ }
2387
+
2388
+ const ENTRY_PAGE_TYPES = new Set([
2389
+ "entry",
2390
+ "presell",
2391
+ "landing",
2392
+ "lander",
2393
+ "opt-in",
2394
+ "optin",
2395
+ "advertorial",
2396
+ "listicle",
2397
+ "review",
2398
+ ]);
2399
+
2400
+ function deriveEntryUrls(topologies) {
2401
+ const entries = [];
2402
+ for (const topology of topologyList(topologies)) {
2403
+ const pages = Array.isArray(topology?.pages) ? topology.pages.filter((page) => page?.url) : [];
2404
+ if (!pages.length) continue;
2405
+ const page = pages.find(isEntryLikePage) || pages[0];
2406
+ entries.push({
2407
+ funnel_id: topology.funnel_id || "default",
2408
+ funnel_name: topology.funnel_name || topology.funnel_id || "default",
2409
+ page_id: page.page_id || null,
2410
+ page_type: page.page_type || null,
2411
+ label: page.label || null,
2412
+ url: page.url,
2413
+ });
2414
+ }
2415
+ return entries;
2416
+ }
2417
+
2418
+ function isEntryLikePage(page) {
2419
+ const type = String(page?.page_type || "").toLowerCase().trim();
2420
+ return ENTRY_PAGE_TYPES.has(type);
2421
+ }
2422
+
2423
+ function derivePageUrls(topologies) {
2424
+ const seen = new Set();
2425
+ const urls = [];
2426
+ for (const topology of topologyList(topologies)) {
2427
+ for (const page of Array.isArray(topology?.pages) ? topology.pages : []) {
2428
+ if (!page?.url || seen.has(page.url)) continue;
2429
+ seen.add(page.url);
2430
+ urls.push({
2431
+ funnel_id: topology.funnel_id || "default",
2432
+ page_id: page.page_id || null,
2433
+ page_type: page.page_type || null,
2434
+ label: page.label || null,
2435
+ url: page.url,
2436
+ });
2437
+ }
2438
+ }
2439
+ return urls;
2440
+ }
2441
+
2442
+ function deriveTestedUrlsFromAssertions(assertions, pageUrls = []) {
2443
+ const knownByUrl = new Map();
2444
+ const knownByPageId = new Map();
2445
+ for (const entry of Array.isArray(pageUrls) ? pageUrls : []) {
2446
+ if (entry?.url && !knownByUrl.has(entry.url)) knownByUrl.set(entry.url, entry);
2447
+ if (entry?.page_id && !knownByPageId.has(entry.page_id)) knownByPageId.set(entry.page_id, entry);
2448
+ }
2449
+
2450
+ const seen = new Set();
2451
+ const tested = [];
2452
+ for (const assertion of Array.isArray(assertions) ? assertions : []) {
2453
+ if (!String(assertion?.id || "").startsWith("http:") || !assertion?.url || seen.has(assertion.url)) continue;
2454
+ seen.add(assertion.url);
2455
+ const known = knownByUrl.get(assertion.url) || knownByPageId.get(String(assertion.id).slice("http:".length));
2456
+ tested.push(known || {
2457
+ funnel_id: null,
2458
+ page_id: String(assertion.id).slice("http:".length) || assertion.page || null,
2459
+ page_type: null,
2460
+ label: null,
2461
+ url: assertion.url,
2462
+ });
2463
+ }
2464
+ return tested;
2465
+ }
2466
+
2467
+ function topologyList(topologies) {
2468
+ return Array.isArray(topologies) ? topologies : [];
2469
+ }
2470
+
2471
+ async function runPageChecks(page, args, {
2472
+ sourceLoader = createPageSourceLoader({ authCookie: args["auth-cookie"] }),
2473
+ captureCommercial = false,
2474
+ bindingExpected = { value: null },
2475
+ bindingScriptLoader = createBindingScriptLoader(),
2476
+ } = {}) {
2477
+ const assertions = [];
2478
+ if (!page.url) {
2479
+ assertions.push(bindingAssertion(page, await observeBinding({ source: null, page, expected: bindingExpected, scriptLoader: bindingScriptLoader })));
2480
+ assertions.push(assertion({
2481
+ id: `route-url:${page.page_id}`,
2482
+ family: "funnel-flow",
2483
+ page,
2484
+ status: STATUS.FAIL,
2485
+ severity: SEVERITY.BLOCKER,
2486
+ expected: "deployed URL",
2487
+ actual: null,
2488
+ evidence: { transport_error: { code: "missing_url", message: "No page URL could be resolved. Provide --base-url or explicit spec page URLs." } },
2489
+ }));
2490
+ return {
2491
+ assertions,
2492
+ commercialCapture: captureCommercial
2493
+ ? unavailableCommercialCapture(page, new Error("No page URL was resolved."))
2494
+ : null,
2495
+ };
2496
+ }
2497
+
2498
+ const source = await sourceLoader(page);
2499
+ assertions.push(bindingAssertion(page, await observeBinding({ source, page, expected: bindingExpected, scriptLoader: bindingScriptLoader })));
2500
+ if (!source.ok) {
2501
+ const isHttpStatus = source.error_code === "http_status";
2502
+ assertions.push(assertion({
2503
+ id: `http:${page.page_id}`,
2504
+ family: "funnel-flow",
2505
+ page,
2506
+ status: STATUS.FAIL,
2507
+ severity: SEVERITY.BLOCKER,
2508
+ expected: isHttpStatus ? "2xx HTTP response" : "fetchable bounded deployed page",
2509
+ actual: isHttpStatus ? `${source.status} ${source.status_text}`.trim() : null,
2510
+ evidence: { transport_error: { code: source.error_code, message: source.error } },
2511
+ }));
2512
+ return {
2513
+ assertions,
2514
+ commercialCapture: captureCommercial
2515
+ ? unavailableCommercialCapture(page, new Error(`${source.error_code}: ${source.error}`))
2516
+ : null,
2517
+ };
2518
+ }
2519
+ const html = source.html;
2520
+ const commercialCapture = captureCommercial ? captureCommercialClaims(page, html) : null;
2521
+ assertions.push(assertion({
2522
+ id: `http:${page.page_id}`,
2523
+ family: "funnel-flow",
2524
+ page,
2525
+ status: STATUS.PASS,
2526
+ expected: "2xx HTTP response",
2527
+ actual: `${source.status} ${source.status_text}`.trim(),
2528
+ }));
2529
+
2530
+ const expectedMeta = page.expected_meta_tags || {};
2531
+ const actualMeta = extractMetaTags(html);
2532
+ for (const [name, expected] of Object.entries(expectedMeta)) {
2533
+ // Redact both credential-shaped hints from this generic serializer. The legacy
2534
+ // next-campaign-api-key alias can be authored here, but is not SDK binding evidence.
2535
+ if (["next-api-key", "next-campaign-api-key"].includes(name)) continue;
2536
+ const actual = actualMeta[name] || null;
2537
+ const unsupportedHint = unsupportedSdkMetaHint(name);
2538
+ if (unsupportedHint) {
2539
+ // A spec key the SDK does not read is a stale Map page hint, whether or
2540
+ // not the tag rendered: nothing for a human to review, so `warn`, never
2541
+ // `manual_review`. Doctor reports the same key as
2542
+ // sdk_hints.meta_tags.ignored_by_sdk from the same list.
2543
+ assertions.push(assertion({
2544
+ id: `meta:${page.page_id}:${name}`,
2545
+ family: "meta-tags",
2546
+ page,
2547
+ status: STATUS.WARN,
2548
+ severity: SEVERITY.WARN,
2549
+ expected: unsupportedHint.expected,
2550
+ actual: actual
2551
+ ? `${actual} (present but ignored by Campaign Cart)`
2552
+ : unsupportedHint.actual,
2553
+ evidence: {
2554
+ expected,
2555
+ actual,
2556
+ note: unsupportedHint.note,
2557
+ },
2558
+ }));
2559
+ continue;
2560
+ }
2561
+ const matches = metaTagMatches(name, actual, expected);
2562
+ assertions.push(assertion({
2563
+ id: `meta:${page.page_id}:${name}`,
2564
+ family: "meta-tags",
2565
+ page,
2566
+ status: matches ? STATUS.PASS : STATUS.FAIL,
2567
+ severity: matches ? undefined : SEVERITY.BLOCKER,
2568
+ expected,
2569
+ actual,
2570
+ evidence: matches ? undefined : { expected, actual },
2571
+ }));
2572
+ }
2573
+
2574
+ // A page that declared a forward step and resolved to nothing is a dead end:
2575
+ // the shopper reaches it and cannot continue. Asserted BEFORE the loop below,
2576
+ // because that loop skips on a falsy URL — which is correct for a page that
2577
+ // terminates on purpose and silently wrong for this one. Without this, the
2578
+ // funnel-flow family emits ZERO assertions for the broken page and the run
2579
+ // comes back clean.
2580
+ const ignoredForwardFields = Array.isArray(page.ignored_forward_fields) ? page.ignored_forward_fields : [];
2581
+ if (!page.expected_next_url && ignoredForwardFields.length) {
2582
+ const fields = ignoredForwardFields.join(", ");
2583
+ const applicable = Array.isArray(page.applicable_forward_fields) ? page.applicable_forward_fields : [];
2584
+ // Name the fields this page type can actually route from. `next_page` is
2585
+ // right for a selector step and wrong for an upsell, whose forward step is
2586
+ // `on_accept` — a hint that named one field for every page type would send
2587
+ // an upsell author straight past their own offer.
2588
+ const remedy = applicable.length
2589
+ ? `Declare one of the forward fields this page type can route from: ${applicable.join(", ")}.`
2590
+ : "Declare a forward route this page type can satisfy.";
2591
+ assertions.push(assertion({
2592
+ id: `forward-route:${page.page_id}:resolves`,
2593
+ family: "funnel-flow",
2594
+ page,
2595
+ status: STATUS.FAIL,
2596
+ severity: SEVERITY.BLOCKER,
2597
+ expected: "a forward route the shopper can follow",
2598
+ actual: `none — "${fields}" declared but ignored on a "${page.page_type}" page`,
2599
+ evidence: {
2600
+ ignored_forward_fields: ignoredForwardFields,
2601
+ applicable_forward_fields: applicable,
2602
+ note: `"${fields}" cannot route from a "${page.page_type}" page, and no other forward field is declared, so the built page has no next link. ${remedy}`,
2603
+ },
2604
+ }));
2605
+ }
2606
+
2607
+ for (const [kind, expectedUrl] of [
2608
+ ["next", page.expected_next_url],
2609
+ ["accept", page.expected_accept_url],
2610
+ ["decline", page.expected_decline_url],
2611
+ ]) {
2612
+ if (!expectedUrl) continue;
2613
+ const staticFound = htmlIncludesRouteReference(html, expectedUrl);
2614
+ const sdkAction = staticFound ? null : findSdkRouteAction(html, kind, page);
2615
+ const found = staticFound || Boolean(sdkAction);
2616
+ assertions.push(assertion({
2617
+ id: `route-link:${page.page_id}:${kind}`,
2618
+ family: "funnel-flow",
2619
+ page,
2620
+ status: found ? STATUS.PASS : STATUS.MANUAL_REVIEW,
2621
+ severity: found ? undefined : SEVERITY.WARN,
2622
+ expected: expectedUrl,
2623
+ actual: staticFound ? expectedUrl : sdkAction || "not found in static HTML",
2624
+ evidence: found ? (sdkAction ? { expected: expectedUrl, sdk_action: sdkAction } : undefined) : { expected: expectedUrl, note: "Route may be SDK/runtime-derived; verify manually if absent from static HTML." },
2625
+ }));
2626
+ }
2627
+
2628
+ return { assertions, commercialCapture };
2629
+ }
2630
+
2631
+ async function maybeRunTestOrders(
2632
+ { args, resolved, runId, assertions, captureAnalytics = false },
2633
+ overrides = {},
2634
+ ) {
2635
+ const operations = {
2636
+ runBrowser: runBrowserTestOrders,
2637
+ runLegacy: maybeRunLegacyApiTestOrders,
2638
+ ...overrides,
2639
+ };
2640
+ const mode = String(args["test-order"] || "off").toLowerCase();
2641
+ const legacyMode = String(args["legacy-api-test-order"] || "off").toLowerCase();
2642
+ const emptyReceiptAnalytics = () => ({ plannedPlanIds: [], attempts: [] });
2643
+ if ((!mode || mode === "off") && (!legacyMode || legacyMode === "off")) {
2644
+ return { orders: [], receiptAnalytics: emptyReceiptAnalytics() };
2645
+ }
2646
+ if (mode && mode !== "off") {
2647
+ // Test Orders use global test cards: they bypass the payment gateway, create
2648
+ // no transactions, and need no merchant setup or approval. `--test-order
2649
+ // <mode>` is sufficient intent — no permission flags or packet policy gate.
2650
+ const result = await operations.runBrowser(resolved.topologies, args, runId, { captureAnalytics });
2651
+ assertions.push(...result.assertions);
2652
+ return { orders: result.orders, receiptAnalytics: result.receiptAnalytics || emptyReceiptAnalytics() };
2653
+ }
2654
+
2655
+ const orders = await operations.runLegacy({ args: { ...args, "test-order": legacyMode }, resolved, runId, assertions });
2656
+ // Direct API diagnostics never qualify as canonical receipt proof.
2657
+ return { orders, receiptAnalytics: emptyReceiptAnalytics() };
2658
+ }
2659
+
2660
+ async function maybeRunLegacyApiTestOrders({ args, resolved, runId, assertions }) {
2661
+ const mode = String(args["test-order"] || "off").toLowerCase();
2662
+ if (!mode || mode === "off") return [];
2663
+ // Diagnostic-only legacy path. Like the browser path, it needs no permission
2664
+ // flags or packet policy gate — test cards bypass the gateway. It still needs
2665
+ // API credentials because it talks to the Campaigns API directly.
2666
+ const apiKey = stringArg(args["api-key"]) || process.env.QA_CAMPAIGNS_API_KEY;
2667
+ const apiBase = stringArg(args["campaigns-api-base"]) || process.env.CAMPAIGNS_API_BASE;
2668
+ if (!apiKey || !apiBase) throw new Error("Legacy direct API test orders require --api-key/QA_CAMPAIGNS_API_KEY and --campaigns-api-base/CAMPAIGNS_API_BASE.");
2669
+ const cart = parseCart(args.cart);
2670
+ if (!cart.length) throw new Error("--test-order requires --cart package_id:quantity pairs.");
2671
+ const checkout = findPage(resolved.topologies, "checkout");
2672
+ if (!checkout?.url) throw new Error("--test-order requires a checkout page URL.");
2673
+ const upsell = findPage(resolved.topologies, "upsell");
2674
+ const paths = mode === "both" ? ["accept", "decline"] : [mode];
2675
+ const orders = [];
2676
+ for (const path of paths) {
2677
+ if (!["accept", "decline"].includes(path)) throw new Error(`Unknown --test-order mode: ${mode}`);
2678
+ const create = await createTestOrder({ apiBase, apiKey, cart, runId, successUrl: checkout.expected_next_url || upsell?.url || checkout.url, spec: resolved.spec, args });
2679
+ const verification = { expected_line_count: cart.length, actual_line_count: 0, diff: [], verified: false };
2680
+ if (!create.ok) {
2681
+ verification.error = create.error || "order create failed";
2682
+ assertions.push(assertion({
2683
+ id: `test-order:${path}`,
2684
+ family: "api-metadata",
2685
+ page: checkout,
2686
+ status: STATUS.FAIL,
2687
+ severity: SEVERITY.BLOCKER,
2688
+ expected: "test order created",
2689
+ actual: create.error || create.status,
2690
+ }));
2691
+ } else {
2692
+ assertions.push(assertion({
2693
+ id: `test-order:${path}`,
2694
+ family: "api-metadata",
2695
+ page: checkout,
2696
+ status: STATUS.PASS,
2697
+ expected: "test order created",
2698
+ actual: create.number || create.ref_id,
2699
+ }));
2700
+ verification.actual_line_count = Array.isArray(create.raw?.lines) ? create.raw.lines.length : cart.length;
2701
+ verification.verified = true;
2702
+ }
2703
+ orders.push({
2704
+ path,
2705
+ next_order_id: create.number,
2706
+ qa_run_id_tag: runId,
2707
+ cart_state: { packages: cart.map((item) => ({ ref_id: item.packageId, quantity: item.quantity })) },
2708
+ receipt_line_items: extractReceiptLines(create.raw),
2709
+ verification,
2710
+ });
2711
+ }
2712
+ return orders;
2713
+ }
2714
+
2715
+ async function createTestOrder({ apiBase, apiKey, cart, runId, successUrl, spec, args = {} }) {
2716
+ const shippingMethod = firstShippingMethod(spec);
2717
+ const body = {
2718
+ user: { email: testEmail(args), first_name: "QA", last_name: "Test" },
2719
+ lines: cart.map((item) => ({ package_id: Number(item.packageId), quantity: item.quantity })),
2720
+ shipping_address: {
2721
+ first_name: "QA",
2722
+ last_name: "Test",
2723
+ line1: "123 Test St",
2724
+ line4: "Austin",
2725
+ state: "TX",
2726
+ postcode: "78701",
2727
+ phone_number: "+14807581224",
2728
+ country: "US",
2729
+ },
2730
+ billing_same_as_shipping_address: true,
2731
+ payment_detail: { payment_method: "card_token", card_token: "test_card" },
2732
+ shipping_method: shippingMethod,
2733
+ success_url: successUrl,
2734
+ payment_failed_url: `${successUrl}${successUrl.includes("?") ? "&" : "?"}payment_failed=true`,
2735
+ attribution: { utm_source: "agentic_qa", qa_run_id: runId },
2736
+ };
2737
+ try {
2738
+ const response = await fetch(`${apiBase.replace(/\/+$/, "")}/orders/`, {
2739
+ method: "POST",
2740
+ headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: apiKey },
2741
+ body: JSON.stringify(body),
2742
+ });
2743
+ const raw = await response.json().catch(() => null);
2744
+ return {
2745
+ ok: response.ok,
2746
+ status: response.status,
2747
+ raw,
2748
+ ref_id: typeof raw?.ref_id === "string" ? raw.ref_id : null,
2749
+ number: Number.isFinite(Number(raw?.number || raw?.id)) ? Number(raw?.number || raw?.id) : null,
2750
+ error: response.ok ? null : extractApiError(raw) || `${response.status} ${response.statusText}`,
2751
+ };
2752
+ } catch (error) {
2753
+ return { ok: false, status: null, raw: null, ref_id: null, number: null, error: error instanceof Error ? error.message : String(error) };
2754
+ }
2755
+ }
2756
+
2757
+ function normalizeSpec(raw) {
2758
+ if (Array.isArray(raw?.funnels)) return raw;
2759
+ if (Array.isArray(raw?.funnel_pages)) {
2760
+ return { ...raw, funnels: [{ id: "default", name: "Default", weight: 100, pages: raw.funnel_pages }] };
2761
+ }
2762
+ return { ...raw, funnels: [] };
2763
+ }
2764
+
2765
+ function extractTopologies(spec, { baseUrl = null, publicRouteSlug = null, templateFamily = null, commerceStructureContract = null } = {}) {
2766
+ const pageById = new Map();
2767
+ for (const funnel of spec.funnels || []) {
2768
+ for (const page of funnel.pages || []) pageById.set(page.id, page);
2769
+ }
2770
+ const urlById = new Map();
2771
+ for (const [id, page] of pageById) {
2772
+ urlById.set(id, resolvePageUrl(page, baseUrl, publicRouteSlug));
2773
+ }
2774
+ return (spec.funnels || []).map((funnel) => ({
2775
+ funnel_id: funnel.id || "default",
2776
+ funnel_name: funnel.name || funnel.id || "Default",
2777
+ weight: Number(funnel.weight) || 0,
2778
+ pages: (funnel.pages || [])
2779
+ .filter((page) => page.enabled !== false)
2780
+ .slice()
2781
+ .sort((a, b) => (a.order || 0) - (b.order || 0))
2782
+ .map((page) => ({
2783
+ page_id: page.id,
2784
+ page_type: page.type || "page",
2785
+ order: page.order || 0,
2786
+ label: page.label || page.id,
2787
+ url: urlById.get(page.id) || null,
2788
+ is_entry: Boolean(page.is_entry),
2789
+ expected_meta_tags: extractExpectedMetaTags(page, { baseUrl, pageById, urlById, publicRouteSlug }),
2790
+ // Same resolver as build-time wiring. This previously read
2791
+ // `next_page || success_url` and ignored on_accept entirely, so the QA
2792
+ // expectation and the built page could disagree about which declared
2793
+ // field wins on any page carrying more than one.
2794
+ expected_next_url: resolveSibling(pageById, urlById, forwardRouteTarget(page), baseUrl, publicRouteSlug),
2795
+ // acceptRouteTarget, never `page.on_accept` raw. Reading the field
2796
+ // directly outlived its correctness: since #234 gated `on_accept` to
2797
+ // offer pages, a `select` page's inert on_accept produced an
2798
+ // expected_accept_url, so QA hunted for an accept link the built page
2799
+ // correctly does not have and flagged a correct build.
2800
+ expected_accept_url: resolveSibling(pageById, urlById, acceptRouteTarget(page), baseUrl, publicRouteSlug),
2801
+ expected_decline_url: resolveSibling(pageById, urlById, declineRouteTarget(page), baseUrl, publicRouteSlug),
2802
+ // Forward fields the author declared that routing skipped because this
2803
+ // page type cannot satisfy them. Carried into the topology so QA can
2804
+ // tell "terminates on purpose" (a thank-you page, a partial-scope
2805
+ // hand-off) apart from "meant to continue and lost its only edge",
2806
+ // which are indistinguishable from a null expected_next_url alone.
2807
+ ...(inapplicableForwardFields(page).length
2808
+ ? {
2809
+ ignored_forward_fields: inapplicableForwardFields(page),
2810
+ // The fields this page TYPE could route from, so the remediation
2811
+ // hint can name the right one. Derived from the resolver, never
2812
+ // hardcoded: telling an upsell author to "set next_page" would
2813
+ // route the shopper past the offer entirely.
2814
+ applicable_forward_fields: applicableForwardFields(page),
2815
+ }
2816
+ : {}),
2817
+ packages: page.packages || [],
2818
+ ...(page.exit_intent !== undefined ? { exit_intent: page.exit_intent } : {}),
2819
+ ...(page.promo_code_input !== undefined ? { promo_code_input: page.promo_code_input } : {}),
2820
+ template_family: templateFamily || undefined,
2821
+ commerce_structure_contract: commerceStructureContract?.pages?.[page.type || "page"] || undefined,
2822
+ commerce_structure_contract_status: commerceStructureContract?.status || undefined,
2823
+ })),
2824
+ }));
2825
+ }
2826
+
2827
+ function resolvePageUrl(page, baseUrl, publicRouteSlug = null) {
2828
+ if (typeof page.url === "string" && page.url.trim()) return page.url.trim();
2829
+ if (!baseUrl) return null;
2830
+ const route = typeof page.page_url === "string" && page.page_url.trim()
2831
+ ? runtimeRelativeRouteForSpecValue(page.page_url, publicRouteSlug)
2832
+ : page.is_entry
2833
+ ? ""
2834
+ : defaultRouteForType(page.type);
2835
+ if (isAbsoluteHttpUrl(route)) return route;
2836
+ try {
2837
+ return joinBaseUrl(baseUrl, route);
2838
+ } catch {
2839
+ return null;
2840
+ }
2841
+ }
2842
+
2843
+ function defaultRouteForType(type) {
2844
+ if (type === "thankyou") return "receipt/";
2845
+ if (["presell", "landing", "checkout", "upsell", "downsell"].includes(type)) return `${type}/`;
2846
+ return `${type || "page"}/`;
2847
+ }
2848
+
2849
+ function resolveSibling(pageById, urlById, ref, baseUrl, publicRouteSlug = null) {
2850
+ if (typeof ref !== "string" || !ref.trim()) return undefined;
2851
+ if (urlById.has(ref)) return urlById.get(ref) || null;
2852
+ if (isAbsoluteHttpUrl(ref)) return ref;
2853
+ if (baseUrl && (ref.startsWith("/") || ref.includes(".") || ref.endsWith("/"))) {
2854
+ try {
2855
+ return joinBaseUrl(baseUrl, runtimeRelativeRouteForSpecValue(ref, publicRouteSlug) || ref);
2856
+ } catch {
2857
+ return ref;
2858
+ }
2859
+ }
2860
+ return ref;
2861
+ }
2862
+
2863
+ function joinBaseUrl(baseUrl, route) {
2864
+ const base = new URL(baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`);
2865
+ const normalizedRoute = route.replace(/^\/+/, "");
2866
+ const baseSegments = base.pathname.split("/").filter(Boolean);
2867
+ const routeSegments = normalizedRoute.split("/").filter(Boolean);
2868
+ if (baseSegments.length && routeSegments.length && baseSegments.at(-1) === routeSegments[0]) {
2869
+ return new URL(normalizedRoute, `${base.origin}/`).toString();
2870
+ }
2871
+ return new URL(normalizedRoute, base).toString();
2872
+ }
2873
+
2874
+ function extractExpectedMetaTags(page, { baseUrl, pageById, urlById, publicRouteSlug } = {}) {
2875
+ const source = page.sdk_hints?.meta_tags;
2876
+ if (!source || typeof source !== "object") return undefined;
2877
+ const out = {};
2878
+ for (const [key, value] of Object.entries(source)) {
2879
+ if (typeof value !== "string") continue;
2880
+ if (isRoutingMetaTag(key)) {
2881
+ const resolved = resolveSibling(pageById || new Map(), urlById || new Map(), value, baseUrl, publicRouteSlug);
2882
+ out[key] = stripOrigin(resolved || value);
2883
+ } else {
2884
+ out[key] = value;
2885
+ }
2886
+ }
2887
+ return Object.keys(out).length ? out : undefined;
2888
+ }
2889
+
2890
+ function extractMetaTags(html) {
2891
+ const meta = {};
2892
+ const tagPattern = /<meta\b[^>]*>/gi;
2893
+ const attrPattern = /([a-zA-Z_:.-]+)\s*=\s*["']([^"']*)["']/g;
2894
+ for (const tag of html.match(tagPattern) || []) {
2895
+ const attrs = {};
2896
+ for (const match of tag.matchAll(attrPattern)) attrs[match[1].toLowerCase()] = decodeHtml(match[2]);
2897
+ const key = attrs.name || attrs.property;
2898
+ if (key && attrs.content !== undefined) meta[key] = attrs.content;
2899
+ }
2900
+ return meta;
2901
+ }
2902
+
2903
+ // Packet 04 Stage A / IC-2 dispatch table for the analytics-correctness leg.
2904
+ // forced is the tri-state from forcedAnalyticsCorrectness():
2905
+ // false → "disabled" (explicit opt-out wins, even over a declared analytics block)
2906
+ // true → "run" (forced, with or without a block)
2907
+ // undefined → "run" iff the spec declares an analytics block, else "not-applicable"
2908
+ function analyticsCorrectnessLegDecision(forced, analyticsContract) {
2909
+ if (forced === false) return "disabled";
2910
+ if (analyticsContract || forced === true) return "run";
2911
+ return "not-applicable";
2912
+ }
2913
+
2914
+ // The visible marker for an explicit opt-out — same skipped-assertion
2915
+ // convention the polish/theme gates use. SKIPPED is disposition-neutral
2916
+ // (computeDisposition ignores it), so the marker records the decision without
2917
+ // gating the run.
2918
+ function analyticsCorrectnessDisabledAssertion(analyticsContract) {
2919
+ return assertion({
2920
+ id: "analytics-correctness:disabled-by-flag",
2921
+ family: "analytics-correctness",
2922
+ page: { page_id: "campaign" },
2923
+ status: STATUS.SKIPPED,
2924
+ expected: "analytics-correctness leg runs when the spec declares an analytics block",
2925
+ actual: "--analytics-correctness false — leg explicitly disabled by operator flag",
2926
+ evidence: {
2927
+ flag: "analytics-correctness=false",
2928
+ spec_declares_analytics: !!analyticsContract,
2929
+ },
2930
+ });
2931
+ }
2932
+
2933
+ function assertion({ id, family, page, status, severity, expected, actual, evidence, waiver }) {
2934
+ return {
2935
+ id,
2936
+ family,
2937
+ page: page.page_id || page.label || "campaign",
2938
+ url: page.url || undefined,
2939
+ status,
2940
+ ...(severity ? { severity } : {}),
2941
+ ...(expected !== undefined ? { expected } : {}),
2942
+ ...(actual !== undefined ? { actual } : {}),
2943
+ ...(evidence ? { evidence } : {}),
2944
+ ...(waiver ? { waiver } : {}),
2945
+ };
2946
+ }
2947
+
2948
+ // Every QA artifact lands in <output-dir>/<campaign-slug>/. The slug can come
2949
+ // from fixture or spec data, so containment is asserted here rather than
2950
+ // trusted: a run refuses to write outside the directory the operator named.
2951
+ function campaignOutputDir(outputDir, slug) {
2952
+ const root = resolve(outputDir);
2953
+ const dir = resolve(root, String(slug || ""));
2954
+ const rel = relative(root, dir);
2955
+ if (!rel || rel.startsWith("..") || isAbsolute(rel)) {
2956
+ throw new Error(`QA output slug "${slug}" escapes the output directory ${root}.`);
2957
+ }
2958
+ return dir;
2959
+ }
2960
+
2961
+ function writeLocalVerdict(verdict, outputDir) {
2962
+ const dir = campaignOutputDir(outputDir, verdict.campaign_slug);
2963
+ mkdirSync(dir, { recursive: true });
2964
+ const path = join(dir, `${verdict.run_id}.json`);
2965
+ writeFileSync(path, `${JSON.stringify(verdict, null, 2)}\n`);
2966
+ return path;
2967
+ }
2968
+
2969
+ function output(value, args) {
2970
+ if (args.json) {
2971
+ console.log(JSON.stringify(value, null, 2));
2972
+ return;
2973
+ }
2974
+ if (value.action === "qa-publish") {
2975
+ for (const line of qaPublishTextLines(value, { cmd })) console.log(line);
2976
+ return;
2977
+ }
2978
+ if (value.action === "qa-policy-set") {
2979
+ console.log(`QA metadata updated.`);
2980
+ console.log(`Packet: ${value.packet_path}`);
2981
+ console.log(`Changed: ${value.changed.length ? value.changed.join(", ") : "(none)"}`);
2982
+ return;
2983
+ }
2984
+ if (value.verdict) {
2985
+ console.log(`QA run complete.`);
2986
+ console.log(`Map ID: ${value.map_id}`);
2987
+ console.log(`Base URL: ${value.base_url || "(missing)"}`);
2988
+ printEntryUrlLines(value.entry_urls);
2989
+ console.log(`Run ID: ${value.run_id}`);
2990
+ console.log(`Disposition: ${value.verdict.disposition}`);
2991
+ console.log(`Counts: ${Object.entries(value.counts).map(([status, count]) => `${count} ${status}`).join(", ")}`);
2992
+ printCauseLines(value.verdict);
2993
+ printThemeGateLines(value.theme_gate, value.packet_path, value.report_path);
2994
+ if (value.commercial) {
2995
+ console.log(`Commercial parity: ${value.commercial.status} (${value.commercial.finding_count || 0} findings, ${value.commercial.checked_pages || 0} pages checked)`);
2996
+ }
2997
+ console.log(`Local copy: ${value.local_path}`);
2998
+ if (value.posted?.ok && value.dashboard_url) {
2999
+ console.log(`QA portal: ${value.dashboard_url}`);
3000
+ } else if (value.publish_skipped && value.publish_decision?.reason === "consent_off") {
3001
+ console.log(`QA portal: publish skipped — telemetry consent is off, so this non-portal-managed verdict stays local.`);
3002
+ console.log(` Destination would be ${value.publish_decision.destination}. Opt in for this run with --post-verdict, or enable with \`${cmd("telemetry")} on\`.`);
3003
+ } else if (value.publish_skipped) {
3004
+ console.log(`QA portal: publish skipped (--no-post-verdict); local verdict only.`);
3005
+ } else {
3006
+ console.log(`QA portal: publish failed${value.post_error ? ` (${value.post_error})` : ""}; local verdict kept at ${value.local_path}. Re-run with network access, or pass --no-post-verdict to silence.`);
3007
+ }
3008
+ for (const action of value.next_actions || []) {
3009
+ if (action.required) {
3010
+ console.log(`Required next: ${action.command}`);
3011
+ console.log(` ${action.description}`);
3012
+ }
3013
+ }
3014
+ console.log(`Workflow finding? ${cmd("findings")} add --stage qa --kind missing_prompt --summary "..." --qa-run-id ${value.run_id}`);
3015
+ return;
3016
+ }
3017
+ console.log(`QA resolve complete.`);
3018
+ console.log(`Status: ${value.status}`);
3019
+ console.log(`Map ID: ${value.map_id}`);
3020
+ console.log(`Spec: ${value.spec_source}`);
3021
+ console.log(`Base URL: ${value.base_url || "(missing)"}`);
3022
+ printEntryUrlLines(value.entry_urls);
3023
+ for (const funnel of value.funnels) {
3024
+ console.log(`\n${funnel.funnel_name} (${funnel.funnel_id}, ${funnel.weight}%)`);
3025
+ for (const page of funnel.pages) console.log(`- [${page.page_type}] ${page.label}: ${page.url || "(missing)"}`);
3026
+ }
3027
+ console.log("");
3028
+ printCheckpointGateLines(value.checkpoint_gates, value.packet_path, value.report_path);
3029
+ printThemeGateLines(value.theme_gate, value.packet_path, value.report_path);
3030
+ printRouteProbeLines(value.route_probe);
3031
+ const nextProofLines = qaResolveNextProofLines(value);
3032
+ if (nextProofLines.length) {
3033
+ console.log("");
3034
+ for (const line of nextProofLines) console.log(line);
3035
+ }
3036
+ }
3037
+
3038
+ // The checkpoint block of the `qa resolve` text report. Returns the lines in
3039
+ // order so the text is assertable without a subprocess; the printer prints
3040
+ // the join. Each action is rendered by the one rule doctor uses (the packet
3041
+ // substituted, else the description, and `--report` carried into a
3042
+ // packet-scoped command when the gates were evaluated on a non-default
3043
+ // report), so the same blocked gate reads the same way in both reports.
3044
+ export function checkpointGateLines(checkpointGates, packetPath, reportPath = null) {
3045
+ const lines = [];
3046
+ for (const gate of checkpointGates || []) {
3047
+ lines.push(`Checkpoint ${gate.id}: ${gate.status} (${gate.code}) — ${gate.reason}`);
3048
+ if (gate.waiver) {
3049
+ lines.push(` Waiver: ${gate.waiver.waived_by} at ${gate.waiver.waived_at} — ${gate.waiver.reason}`);
3050
+ if (gate.waiver.expires_at) lines.push(` Expires: ${gate.waiver.expires_at}`);
3051
+ if (gate.waiver.review_condition) lines.push(` Review condition: ${gate.waiver.review_condition}`);
3052
+ }
3053
+ const counts = gate.waiver_assessment?.inert_counts || {};
3054
+ lines.push(` Inert waiver decisions: stale=${counts.stale || 0}, foreign=${counts.foreign || 0}, malformed=${counts.malformed || 0}, expired=${counts.expired || 0}`);
3055
+ if (gate.required_actions?.length) {
3056
+ lines.push(" Required actions:");
3057
+ for (const action of gate.required_actions) {
3058
+ lines.push(` - ${requiredActionText(action, { packetPath, reportPath })}`);
3059
+ }
3060
+ }
3061
+ }
3062
+ return lines;
3063
+ }
3064
+
3065
+ function printCheckpointGateLines(checkpointGates, packetPath, reportPath = null) {
3066
+ for (const line of checkpointGateLines(checkpointGates, packetPath, reportPath)) console.log(line);
3067
+ }
3068
+
3069
+ function printEntryUrlLines(entryUrls) {
3070
+ if (!Array.isArray(entryUrls) || !entryUrls.length) return;
3071
+ console.log("Entry URLs:");
3072
+ for (const entry of entryUrls) {
3073
+ const label = [entry.funnel_name || entry.funnel_id, entry.page_type, entry.label]
3074
+ .filter(Boolean)
3075
+ .join(" / ");
3076
+ console.log(`- ${label || entry.page_id || "entry"}: ${entry.url}`);
3077
+ }
3078
+ }
3079
+
3080
+ function printRouteProbeLines(routeProbe) {
3081
+ if (!routeProbe) return;
3082
+ console.log(`Route probe: ${routeProbe.status} (${routeProbe.code}) — ${routeProbe.reason}`);
3083
+ if (routeProbe.route_root_hint) {
3084
+ console.log(` Diagnosis (${routeProbe.route_root_hint.code}): ${routeProbe.route_root_hint.reason}`);
3085
+ }
3086
+ // Per-URL evidence for anything that did not cleanly resolve, on EVERY status
3087
+ // rather than only on failure: a `pass` reached over some unreachable URLs is
3088
+ // partial reachability an operator should be able to read, not infer from an
3089
+ // aggregate count. The single-row case is skipped because every reason line
3090
+ // that can produce one already names that URL.
3091
+ const rows = (routeProbe.results || [])
3092
+ .filter((result) => result.outcome === "unresolved" || result.outcome === "unreachable");
3093
+ if (!rows.length) return;
3094
+ if (rows.length === 1 && rows[0].url === routeProbe.first_failure?.url) return;
3095
+ console.log(" Entry URLs that did not resolve:");
3096
+ for (const row of rows) {
3097
+ console.log(` - ${row.url} (${row.outcome === "unresolved" ? `HTTP ${row.http_status}` : row.error})`);
3098
+ }
3099
+ }
3100
+
3101
+ // The cause read: one summary line, then one line per finding carrying its
3102
+ // class. This is the answer to "are these findings related to the change I am
3103
+ // testing?" — the question a bump run left unanswerable before the label
3104
+ // existed. Printed right under the status counts, above the gate lines,
3105
+ // because it is what the operator is looking for.
3106
+ function printCauseLines(verdict) {
3107
+ // One formatter, shared with the doctor report: a prior record that exists
3108
+ // but has no usable QA verdict is not the same state as no prior record, and
3109
+ // the two commands must not describe it differently.
3110
+ for (const line of formatCauseReportLines(verdict?.cause_summary)) console.log(line);
3111
+ const exceptions = Array.isArray(verdict.exceptions) ? verdict.exceptions : [];
3112
+ if (!exceptions.length) return;
3113
+ console.log("Findings:");
3114
+ for (const exception of exceptions) {
3115
+ const identity = [exception.id, exception.page].filter(Boolean).join(" @ ") || "(unidentified finding)";
3116
+ const tag = formatCauseTag(exception);
3117
+ console.log(`- ${identity} (${exception.status || "unknown"})${tag ? ` ${tag}` : ""}`);
3118
+ }
3119
+ }
3120
+
3121
+ // The theme-gate block of the `qa resolve` / `qa run` text report, as lines.
3122
+ // The gate bakes the packet into its commands when it is evaluated, so the
3123
+ // substitution here is the same rule applied uniformly, not a change of text.
3124
+ export function themeGateLines(themeGate, packetPath = null, reportPath = null) {
3125
+ if (!themeGate) return [];
3126
+ const lines = [`Theme gate: ${themeGate.status} (${themeGate.code}) — ${themeGate.reason}`];
3127
+ if (themeGate.status !== "blocked") return lines;
3128
+ lines.push("Required actions:");
3129
+ for (const action of themeGate.required_actions || []) {
3130
+ lines.push(` - ${requiredActionText(action, { packetPath, reportPath })}`);
3131
+ }
3132
+ lines.push("Or rerun with --theme-waive \"<reason>\" to record an ephemeral waiver for this run.");
3133
+ return lines;
3134
+ }
3135
+
3136
+ function printThemeGateLines(themeGate, packetPath = null, reportPath = null) {
3137
+ for (const line of themeGateLines(themeGate, packetPath, reportPath)) console.log(line);
3138
+ }
3139
+
3140
+ export function qaResolveNextProofLines(value) {
3141
+ if (value?.status === "blocked") return [];
3142
+ // #273: the printed next command was the most misleading part of a `ready`
3143
+ // over a dead route set — `qa run` against those URLs cannot succeed. Name
3144
+ // the route set as the thing to fix instead of suggesting the run.
3145
+ if (value?.status === "routes_unresolved") {
3146
+ const failure = value.route_probe?.first_failure;
3147
+ const hint = value.route_probe?.route_root_hint;
3148
+ const remedy = hint?.code === "route_probe.route_root_mismatch"
3149
+ ? "Correct campaign.public_route_slug (or declare campaign.route_root) in the packet so the derived routes match this host, then resolve again."
3150
+ : hint?.code === "route_probe.host_also_dead"
3151
+ ? "Confirm the preview is live, then resolve again."
3152
+ : "Confirm the preview is live and that --base-url names the host serving this packet's campaign, then resolve again.";
3153
+ return [
3154
+ `Next expected proof: none — the derived route set does not resolve on ${value.base_url || "(missing base URL)"}.`,
3155
+ `First failure: ${failure?.url || "(unknown)"}${failure?.http_status ? ` (HTTP ${failure.http_status})` : ""}.`,
3156
+ remedy,
3157
+ ];
3158
+ }
3159
+ if (!value?.base_url) {
3160
+ return [
3161
+ "Next expected proof: provide --base-url with the preview/local campaign URL, then run browser QA + typed-card proof with --browser --test-order common.",
3162
+ "Localhost on any port is SDK-allowed with analytics suppressed; non-localhost preview/production origins still need SDK origin allowlist confirmation.",
3163
+ ];
3164
+ }
3165
+
3166
+ return [
3167
+ `Next expected proof: ${qaRunCommandFromResolve(value)}`,
3168
+ `Entry URL(s) resolved: ${formatEntryUrlsForProof(value.entry_urls)}`,
3169
+ "Typed-card test orders use global test cards (no transactions/no permission gate); QA publishes to the portal by default.",
3170
+ ];
3171
+ }
3172
+
3173
+ function formatEntryUrlsForProof(entryUrls) {
3174
+ if (!Array.isArray(entryUrls) || !entryUrls.length) return "(none)";
3175
+ return entryUrls.map((entry) => entry.url).filter(Boolean).join(", ") || "(none)";
3176
+ }
3177
+
3178
+ function qaRunCommandFromResolve(value) {
3179
+ const base = shellToken(value.base_url);
3180
+ const proxy = value.proxy_base ? ` --proxy-base ${shellToken(value.proxy_base)}` : "";
3181
+ if (value.packet_path) {
3182
+ return `${cmd("qa")} run --packet ${shellToken(value.packet_path)}${proxy} --base-url ${base} --browser --test-order common`;
3183
+ }
3184
+ if (isLocalFilePath(value.spec_source)) {
3185
+ return `${cmd("qa")} run ${shellToken(value.map_id)} --spec ${shellToken(value.spec_source)}${proxy} --base-url ${base} --browser --test-order common`;
3186
+ }
3187
+ return `${cmd("qa")} run ${shellToken(value.map_id)}${proxy} --base-url ${base} --browser --test-order common`;
3188
+ }
3189
+
3190
+ function isLocalFilePath(value) {
3191
+ return typeof value === "string" && value.trim() && !isAbsoluteHttpUrl(value);
3192
+ }
3193
+
3194
+
3195
+ function countAssertions(assertions) {
3196
+ const counts = {};
3197
+ for (const assertion of assertions) counts[assertion.status] = (counts[assertion.status] || 0) + 1;
3198
+ return counts;
3199
+ }
3200
+
3201
+ function computeSpecHash(spec) {
3202
+ return specMaterialHash(spec);
3203
+ }
3204
+
3205
+ function generateRunId() {
3206
+ const timestamp = Date.now().toString(36).toUpperCase().padStart(8, "0");
3207
+ const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUV";
3208
+ const suffix = Array.from({ length: 18 }, () => alphabet[Math.floor(Math.random() * alphabet.length)]).join("");
3209
+ return `${timestamp}${suffix}`;
3210
+ }
3211
+
3212
+ function resolveFromFile(filePath, targetPath) {
3213
+ if (!targetPath) return null;
3214
+ if (isAbsoluteHttpUrl(targetPath) || targetPath.startsWith("/")) return targetPath;
3215
+ return resolve(dirname(resolve(filePath)), targetPath);
3216
+ }
3217
+
3218
+ function readJson(path) {
3219
+ if (!existsSync(path)) {
3220
+ const error = new Error(`File does not exist: ${path}`);
3221
+ error.code = "ENOENT";
3222
+ throw error;
3223
+ }
3224
+ return JSON.parse(readFileSync(path, "utf8"));
3225
+ }
3226
+
3227
+ function writeJson(path, value) {
3228
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
3229
+ }
3230
+
3231
+ function stringArg(value) {
3232
+ return typeof value === "string" && value.trim() ? value.trim() : null;
3233
+ }
3234
+
3235
+ function setOptionalBoolean(target, property, args, key, changed) {
3236
+ if (!(key in args)) return;
3237
+ const value = booleanArg(args[key], key);
3238
+ setIfChanged(target, property, value, changed);
3239
+ }
3240
+
3241
+ function setOptionalString(target, property, args, key, changed) {
3242
+ if (!(key in args)) return;
3243
+ const value = stringArg(args[key]);
3244
+ if (!value) throw new Error(`--${key} requires a value.`);
3245
+ setIfChanged(target, property, value, changed);
3246
+ }
3247
+
3248
+ function setIfChanged(target, property, value, changed) {
3249
+ if (target[property] === value) return;
3250
+ target[property] = value;
3251
+ changed.push(property);
3252
+ }
3253
+
3254
+ function booleanArg(value, key) {
3255
+ if (value === true) return true;
3256
+ const normalized = String(value || "").trim().toLowerCase();
3257
+ if (["true", "1", "yes", "y", "on"].includes(normalized)) return true;
3258
+ if (["false", "0", "no", "n", "off"].includes(normalized)) return false;
3259
+ throw new Error(`--${key} must be true or false.`);
3260
+ }
3261
+
3262
+ // Tri-state (packet 04 Stage A / IC-2): `undefined` means the flag was not
3263
+ // supplied — defer to the spec; `true` forces the leg; `false` explicitly
3264
+ // DISABLES it. Absent must stay non-throwing: before NEXT-114 (dogfood finding
3265
+ // wf_1785565103144), a spec with no analytics block made booleanArg(undefined)
3266
+ // throw on every qa run that omitted --analytics-correctness. Explicit garbage
3267
+ // values still error.
3268
+ export function forcedAnalyticsCorrectness(args) {
3269
+ if (args?.["analytics-correctness"] == null) return undefined;
3270
+ return booleanArg(args["analytics-correctness"], "analytics-correctness");
3271
+ }
3272
+
3273
+ // #172: the default verdict POST sits inside the telemetry consent seam.
3274
+ // Precedence: explicit flags > portal-managed default > consent state >
3275
+ // legacy publish-by-default. Portal-managed means the spec for this run was
3276
+ // resolved FROM the portal (no local spec file) — those verdicts are the
3277
+ // product surface of the QA tab and keep publish-by-default. For everything
3278
+ // else (client projects, fixtures, local shakeouts on a local spec), consent
3279
+ // off (CAMPAIGNS_OS_TELEMETRY=off / `campaigns-os telemetry off`) means the
3280
+ // verdict stays local, with the destination and the opt-in flag named in the
3281
+ // run output. Publishing for the portal path is never weakened.
3282
+ export function decidePublishVerdict({ args = {}, portalManaged = false, consent = null } = {}) {
3283
+ if (args["no-post-verdict"] === true || args["local-only"] === true) {
3284
+ return { publish: false, reason: "flag_opt_out" };
3285
+ }
3286
+ let flagInvalid = false;
3287
+ if ("post-verdict" in args) {
3288
+ const value = args["post-verdict"];
3289
+ if (value === true) return { publish: true, reason: "flag_opt_in" };
3290
+ const normalized = String(value).trim().toLowerCase();
3291
+ if (["true", "1", "yes", "y", "on"].includes(normalized)) return { publish: true, reason: "flag_opt_in" };
3292
+ if (["false", "0", "no", "n", "off"].includes(normalized)) return { publish: false, reason: "flag_opt_out" };
3293
+ // Unrecognized value: never a silent opt-in (Kilo review, PR #177) —
3294
+ // ignore the flag, continue the precedence chain, and surface the
3295
+ // garbage value on the decision so the run summary shows the signal.
3296
+ flagInvalid = true;
3297
+ }
3298
+ const decorate = (decision) => (flagInvalid ? { ...decision, flag_invalid: true } : decision);
3299
+ if (portalManaged) return decorate({ publish: true, reason: "portal_managed_default" });
3300
+ if (consent?.state === "off") return decorate({ publish: false, reason: "consent_off" });
3301
+ return decorate({ publish: true, reason: "default" });
3302
+ }
3303
+
3304
+ function policySnapshot(packet) {
3305
+ return {
3306
+ campaign: {
3307
+ allowed_domains_confirmed: packet.campaign?.allowed_domains_confirmed ?? null,
3308
+ },
3309
+ deploy: {
3310
+ target: packet.deploy?.target ?? null,
3311
+ preview_url: packet.deploy?.preview_url ?? null,
3312
+ production_url: packet.deploy?.production_url ?? null,
3313
+ },
3314
+ qa: {
3315
+ order_path_depth: packet.qa?.proof_policy?.order_path_depth ?? null,
3316
+ },
3317
+ };
3318
+ }
3319
+
3320
+ function normalizeBaseUrl(value) {
3321
+ return typeof value === "string" && value.trim() ? value.trim() : null;
3322
+ }
3323
+
3324
+ function normalizeQaBaseUrl(value, publicRouteSlug) {
3325
+ const baseUrl = normalizeBaseUrl(value);
3326
+ if (!baseUrl) return null;
3327
+ const slug = normalizePublicRouteSlug(publicRouteSlug);
3328
+ if (!slug) return ensureUrlTrailingSlash(baseUrl);
3329
+ try {
3330
+ const url = new URL(ensureUrlTrailingSlash(baseUrl));
3331
+ const segments = url.pathname.split("/").filter(Boolean);
3332
+ if (segments.at(-1) === slug) return ensureUrlTrailingSlash(url.toString());
3333
+ return new URL(`${slug}/`, url).toString();
3334
+ } catch {
3335
+ return ensureUrlTrailingSlash(baseUrl);
3336
+ }
3337
+ }
3338
+
3339
+ // The campaign's served route root — "/", "/<slug>/", or null — read by the
3340
+ // one rule every stage shares (route-identity.mjs): the packet under its exact
3341
+ // canonical form, the spec under prepare-build's intake form. A declaration
3342
+ // that rule does not honour NEVER roots a check; it falls through to the
3343
+ // slug-prefixed default, exactly like doctor. What QA adds is the record: the
3344
+ // slug default then audits a DIFFERENT page than the one declared — a
3345
+ // hand-edited packet ("/<slug>" without its slash, which doctor blocks by
3346
+ // name), a multi-segment root like "/<slug>/offer/", or a foreign root — so
3347
+ // the discard is written onto the evidence rather than swallowed.
3348
+ function resolveCampaignRouteRoot({ packet, spec, rawSpec, publicRouteSlug, notes = null }) {
3349
+ const slug = normalizePublicRouteSlug(publicRouteSlug);
3350
+ const resolved = resolveRouteRoot({ packet, spec, rawSpec, publicRouteSlug });
3351
+ if (notes && resolved.accepted === false) {
3352
+ notes.push({
3353
+ code: "route_root.declared_discarded",
3354
+ declared: resolved.declared,
3355
+ resolved: resolved.route_root,
3356
+ reason: slug
3357
+ ? `Declared route_root "${resolved.declared}" is neither "/" nor "/${slug}/", so QA fell back to the slug default "/${slug}/". If the campaign really is served at "${resolved.declared}", QA is auditing the wrong page.`
3358
+ : `Declared route_root "${resolved.declared}" could not be checked against a public_route_slug (no slug resolved), so QA fell back to no route root.`,
3359
+ });
3360
+ }
3361
+ return resolved.route_root;
3362
+ }
3363
+
3364
+ // The ONE place the analytics capture-target shape is defined. Every producer
3365
+ // of a capture target — identity-composed (resolveAnalyticsCaptureTarget) and
3366
+ // built-site (resolveQaInputsFromSite) — constructs through here, so a new
3367
+ // diagnostic field or a new `source` value cannot land on one path and
3368
+ // silently skip the other. The shape is enforced by construction, not by a
3369
+ // test that happens to cover one branch.
3370
+ function buildAnalyticsCaptureTarget({ url, publicRouteSlug, routeRoot, source, routeRootNote = null }) {
3371
+ return {
3372
+ url: url || null,
3373
+ public_route_slug: publicRouteSlug || null,
3374
+ route_root: routeRoot || null,
3375
+ source,
3376
+ // Loud-not-silent: set only when a declared route_root was discarded in
3377
+ // favour of the slug default, so the discard rides on the evidence
3378
+ // instead of vanishing. Null on the overwhelmingly common clean path.
3379
+ route_root_note: routeRootNote || null,
3380
+ };
3381
+ }
3382
+
3383
+ // Packet 01 / INV-2: the ONE URL both analytics legs visit derives from the
3384
+ // campaign's resolved identity — public_route_slug AND route_root — never
3385
+ // from a raw operator argument.
3386
+ //
3387
+ // Composition mirrors doctor's served-route rule (validateBuiltRouteDrift):
3388
+ // route_root "/" → the funnel lives at the SITE ROOT, so the target
3389
+ // is the base URL's origin root; the slug stays
3390
+ // identity, not a path prefix, and any stray path
3391
+ // on --base-url is discarded. The query string and
3392
+ // fragment go with it: the capture target is the
3393
+ // canonical page identity both legs must agree on,
3394
+ // and an operator's debugging query (?utm_source=qa)
3395
+ // would otherwise ride into the parity comparison as
3396
+ // if it were part of the campaign's address. Pass
3397
+ // such parameters to the browser leg, not here.
3398
+ // route_root "/<slug>/" → the slug-prefixed default: the slug is appended
3399
+ // to the operator/deploy base unless its last
3400
+ // segment already is the slug (normalizeQaBaseUrl
3401
+ // semantics, subdirectory deploys preserved).
3402
+ function resolveAnalyticsCaptureTarget({ inputBaseUrl, publicRouteSlug, routeRoot, routeRootNote = null }) {
3403
+ const slug = normalizePublicRouteSlug(publicRouteSlug) || null;
3404
+ const target = buildAnalyticsCaptureTarget({
3405
+ url: null,
3406
+ publicRouteSlug: slug,
3407
+ routeRoot: routeRoot || (slug ? `/${slug}/` : null),
3408
+ source: "unresolved",
3409
+ routeRootNote,
3410
+ });
3411
+ const base = normalizeBaseUrl(inputBaseUrl);
3412
+ if (!base) return target;
3413
+ if (target.route_root === "/") {
3414
+ try {
3415
+ target.url = new URL("/", base).toString();
3416
+ target.source = "resolved_identity:route_root";
3417
+ return target;
3418
+ } catch {
3419
+ target.url = ensureUrlTrailingSlash(base);
3420
+ target.source = "base_url:unparseable";
3421
+ return target;
3422
+ }
3423
+ }
3424
+ if (slug) {
3425
+ target.url = normalizeQaBaseUrl(base, slug);
3426
+ target.source = "resolved_identity:public_route_slug";
3427
+ return target;
3428
+ }
3429
+ // No slug, no route root (parity fixtures / degenerate specs): the base URL
3430
+ // is the only identity available.
3431
+ target.url = ensureUrlTrailingSlash(base);
3432
+ target.source = "base_url";
3433
+ return target;
3434
+ }
3435
+
3436
+ function resolvePublicRouteSlug({ packet, spec, rawSpec }) {
3437
+ return stringArg(packet?.campaign?.public_route_slug)
3438
+ || stringArg(packet?.deploy?.live_url_path)?.replace(/^\/+|\/+$/g, "")
3439
+ || stringArg(spec?.spec_identity?.public_route_slug)
3440
+ || stringArg(rawSpec?.spec_identity?.public_route_slug)
3441
+ || stringArg(spec?.campaign?.slug)
3442
+ || stringArg(rawSpec?.campaign?.slug)
3443
+ || null;
3444
+ }
3445
+
3446
+ function ensureUrlTrailingSlash(value) {
3447
+ return value.endsWith("/") ? value : `${value}/`;
3448
+ }
3449
+
3450
+ function stripOrigin(value) {
3451
+ try {
3452
+ const url = new URL(value);
3453
+ return `${url.pathname}${url.search}${url.hash}`;
3454
+ } catch {
3455
+ return value;
3456
+ }
3457
+ }
3458
+
3459
+ function isRoutingMetaTag(name) {
3460
+ return [
3461
+ "next-success-url",
3462
+ "next-upsell-accept-url",
3463
+ "next-upsell-decline-url",
3464
+ "next-failure-url",
3465
+ ].includes(normalizeMetaName(name));
3466
+ }
3467
+
3468
+ // Meta names reach these matchers from spec-declared keys and from
3469
+ // extractMetaTags, which does not trim the parsed `name` value. Normalize case
3470
+ // and surrounding whitespace so a stray-space tag lands on the intended branch
3471
+ // instead of falling through to the strict comparison as a BLOCKER.
3472
+ function normalizeMetaName(name) {
3473
+ return normalizeSdkMetaName(name);
3474
+ }
3475
+
3476
+ // The SDK-ignored list lives in sdk-meta-tags.mjs and doctor reads the same
3477
+ // map, so QA and doctor can never disagree about which spec keys the SDK
3478
+ // reads. Returns the map entry ({ expected, actual, note }) or null.
3479
+ function unsupportedSdkMetaHint(name) {
3480
+ return lookupSdkIgnoredMetaTag(name);
3481
+ }
3482
+
3483
+ function metaTagMatches(name, actual, expected) {
3484
+ if (actual === expected) return true;
3485
+ if (!isRoutingMetaTag(name)) return false;
3486
+ return comparableRoute(actual) === comparableRoute(expected);
3487
+ }
3488
+
3489
+ function comparableRoute(value) {
3490
+ if (typeof value !== "string" || !value.trim()) return "";
3491
+ const raw = value.trim();
3492
+ try {
3493
+ const url = new URL(raw);
3494
+ return normalizeRoutePath(`${url.pathname}${url.search}${url.hash}`);
3495
+ } catch {
3496
+ return normalizeRoutePath(raw);
3497
+ }
3498
+ }
3499
+
3500
+ function normalizeRoutePath(value) {
3501
+ const raw = String(value || "").trim();
3502
+ if (!raw) return "";
3503
+ if (raw.startsWith("/")) return normalizePathTrailingSlash(raw);
3504
+ return normalizePathTrailingSlash(normalizePageKitRoute(raw) || raw);
3505
+ }
3506
+
3507
+ function normalizePathTrailingSlash(value) {
3508
+ if (!value || /[?#]/.test(value) || value.endsWith("/")) return value;
3509
+ return `${value}/`;
3510
+ }
3511
+
3512
+ function htmlIncludesRouteReference(html, expectedUrl) {
3513
+ if (!expectedUrl) return false;
3514
+ const path = stripOrigin(expectedUrl);
3515
+ return html.includes(expectedUrl) || html.includes(path);
3516
+ }
3517
+
3518
+ function findSdkRouteAction(html, kind, page) {
3519
+ if (page.page_type !== "upsell") return null;
3520
+ if (kind === "accept" && /\bdata-next-upsell-action\s*=\s*["']add["']/i.test(html)) {
3521
+ return 'SDK upsell accept control: data-next-upsell-action="add"';
3522
+ }
3523
+ if (kind === "decline" && /\bdata-next-upsell-action\s*=\s*["']skip["']/i.test(html)) {
3524
+ return 'SDK upsell decline control: data-next-upsell-action="skip"';
3525
+ }
3526
+ return null;
3527
+ }
3528
+
3529
+ function decodeHtml(value) {
3530
+ return value
3531
+ .replace(/&quot;/g, "\"")
3532
+ .replace(/&#39;/g, "'")
3533
+ .replace(/&amp;/g, "&")
3534
+ .replace(/&lt;/g, "<")
3535
+ .replace(/&gt;/g, ">");
3536
+ }
3537
+
3538
+ function parseCart(value) {
3539
+ if (!value) return [];
3540
+ return String(value).split(",").map((part) => {
3541
+ const [packageId, quantity] = part.split(":").map((item) => item.trim());
3542
+ return { packageId, quantity: Number.parseInt(quantity || "1", 10) || 1 };
3543
+ }).filter((item) => item.packageId);
3544
+ }
3545
+
3546
+ function findPage(topologies, type) {
3547
+ for (const topology of topologies) {
3548
+ const page = topology.pages.find((candidate) => candidate.page_type === type);
3549
+ if (page) return page;
3550
+ }
3551
+ return null;
3552
+ }
3553
+
3554
+ function firstShippingMethod(spec) {
3555
+ const first = Array.isArray(spec.shipping_methods) ? spec.shipping_methods[0] : null;
3556
+ return Number(first?.ref_id || first?.id || 1);
3557
+ }
3558
+
3559
+ function extractReceiptLines(raw) {
3560
+ const lines = Array.isArray(raw?.lines) ? raw.lines : [];
3561
+ return lines.map((line) => ({
3562
+ ref_id: line.package_id || line.ref_id || line.id || "",
3563
+ name: line.name || line.title || "Line item",
3564
+ quantity: Number(line.quantity || 1),
3565
+ price_cents: Number(line.price_cents || line.total_cents || 0),
3566
+ }));
3567
+ }
3568
+
3569
+ function extractApiError(raw) {
3570
+ if (!raw || typeof raw !== "object") return null;
3571
+ if (typeof raw.detail === "string") return raw.detail;
3572
+ if (typeof raw.error === "string") return raw.error;
3573
+ if (typeof raw.message === "string") return raw.message;
3574
+ return null;
3575
+ }
3576
+
3577
+ export const __qaNodeTestHooks = Object.freeze({
3578
+ extractTopologies,
3579
+ validatedOrderCreationLimit,
3580
+ resolveQaInputs,
3581
+ runResolvedQa,
3582
+ runPageChecks,
3583
+ analyticsCorrectnessLegDecision,
3584
+ analyticsCorrectnessDisabledAssertion,
3585
+ runAnalyticsOrderSequence,
3586
+ maybeRunTestOrders,
3587
+ campaignOutputDir,
3588
+ polishBlockedAssertions,
3589
+ polishGateAssertion,
3590
+ themeBlockedAssertions,
3591
+ themeGateAssertion,
3592
+ themeGateScopeFromTopologies,
3593
+ residueSeverityForThemeGate,
3594
+ supportedPaymentMethodsFromSpec,
3595
+ themeGateSummary,
3596
+ templateBrandContractAssertion,
3597
+ deriveEntryUrls,
3598
+ derivePageUrls,
3599
+ deriveTestedUrlsFromAssertions,
3600
+ resolvePayload,
3601
+ resolveThemeGate,
3602
+ resolveStatus,
3603
+ resolveRouteProbe,
3604
+ mergeThemeGateScope,
3605
+ themeGateScopeSource,
3606
+ checkpointGateSummary,
3607
+ hiddenEagerMediaGateAssertion,
3608
+ checkpointBlockedAssertions,
3609
+ resolveQaInputsFromSite,
3610
+ resolveQaWaivers,
3611
+ resolveCampaignRouteRoot,
3612
+ resolveAnalyticsCaptureTarget,
3613
+ buildAnalyticsCaptureTarget,
3614
+ isRoutingMetaTag,
3615
+ unsupportedSdkMetaHint,
3616
+ reportCommercialRunnerError,
3617
+ browserSkippedByGate,
3618
+ reportBrowserSkippedByGate,
3619
+ gateClearingHint,
3620
+ });