@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,272 @@
1
+ import { normalizePublicRouteSlug } from "./route-identity.mjs";
2
+
3
+ // Route probe: the reachability half of `qa resolve`.
4
+ //
5
+ // Doctrine: `qa resolve` derives every route URL from the packet — the packet
6
+ // is the authority on where a campaign is served — and until #273 it reported
7
+ // `ready` on that derivation alone, never asking the deployment whether the
8
+ // URLs it just printed exist. Pointed at a host serving the same campaign under
9
+ // a different route root it printed nine entry URLs and `ready`; all nine were
10
+ // 404, and the three checkpoints below them also said `pass` because they read
11
+ // the packet rather than the deployment. The existing operator guidance —
12
+ // "empty Entry URLs mean a dead preview or a wrong --base-url" — does not cover
13
+ // that case, because the URLs are non-empty and all wrong.
14
+ //
15
+ // So resolve probes what it derived. Two rules shape the module:
16
+ //
17
+ // 1. A derivation is not a verification. `ready` now means the routes were
18
+ // probed AND resolved; a route set that does not resolve gets its own
19
+ // terminal status naming the first URL that failed.
20
+ // 2. Probing must never make resolve unusable. An HTTP response saying 404 is
21
+ // evidence about the deployment; a transport error is evidence about this
22
+ // machine's network, which is not the deployment's fault. The first fails
23
+ // the probe, the second degrades to a named `not_probed` state that stays
24
+ // usable offline and in CI.
25
+
26
+ export const ROUTE_PROBE_DEFAULT_TIMEOUT_MS = 5000;
27
+ // Entry URLs are one per funnel, so a real campaign is well inside this. The
28
+ // cap exists so a pathological topology cannot turn a diagnostic command into
29
+ // an unbounded network sweep; anything past it is reported as skipped rather
30
+ // than silently dropped.
31
+ export const ROUTE_PROBE_MAX_URLS = 25;
32
+ const ROUTE_PROBE_CONCURRENCY = 6;
33
+ // A static host that refuses HEAD is answering about the method, not the route.
34
+ const METHOD_NOT_SUPPORTED = new Set([405, 501]);
35
+
36
+ /**
37
+ * Probe one URL. A response — any response — is evidence about the deployment;
38
+ * a throw is evidence about the transport between here and it.
39
+ */
40
+ export async function probeOneUrl(url, { timeoutMs = ROUTE_PROBE_DEFAULT_TIMEOUT_MS, fetchImpl = fetch } = {}) {
41
+ const attempt = async (method) => fetchImpl(url, {
42
+ method,
43
+ redirect: "follow",
44
+ signal: AbortSignal.timeout(timeoutMs),
45
+ });
46
+ try {
47
+ let method = "HEAD";
48
+ let response = await attempt(method);
49
+ if (METHOD_NOT_SUPPORTED.has(Number(response?.status))) {
50
+ method = "GET";
51
+ response = await attempt(method);
52
+ }
53
+ const httpStatus = Number(response?.status);
54
+ return {
55
+ url,
56
+ outcome: httpStatus >= 400 ? "unresolved" : "resolved",
57
+ method,
58
+ http_status: Number.isFinite(httpStatus) ? httpStatus : null,
59
+ error: null,
60
+ };
61
+ } catch (error) {
62
+ return {
63
+ url,
64
+ outcome: "unreachable",
65
+ method: "HEAD",
66
+ http_status: null,
67
+ error: transportErrorText(error, timeoutMs),
68
+ };
69
+ }
70
+ }
71
+
72
+ function transportErrorText(error, timeoutMs) {
73
+ if (error?.name === "TimeoutError") return `no response within ${timeoutMs}ms`;
74
+ const cause = error?.cause?.code || error?.code || null;
75
+ const message = String(error?.message || error || "transport error");
76
+ return cause ? `${message} (${cause})` : message;
77
+ }
78
+
79
+ // Bounded-concurrency probe that preserves INPUT order. Workers pull indices
80
+ // off a shared cursor and complete in whatever order the network allows, but
81
+ // each writes to `results[index]` — its own input slot — so `results` reads back
82
+ // in the order the entry URLs were derived, not the order the responses landed.
83
+ // `first_failure` and the printed "First failure:" line depend on that, so a
84
+ // future refactor to `push()` or `Promise.all(map(...))`-with-append would
85
+ // silently change what "first" means.
86
+ async function probeAll(urls, options) {
87
+ const results = new Array(urls.length);
88
+ let cursor = 0;
89
+ const worker = async () => {
90
+ for (let index = cursor++; index < urls.length; index = cursor++) {
91
+ results[index] = await probeOneUrl(urls[index], options);
92
+ }
93
+ };
94
+ await Promise.all(
95
+ Array.from({ length: Math.min(ROUTE_PROBE_CONCURRENCY, urls.length) }, worker),
96
+ );
97
+ return results;
98
+ }
99
+
100
+ /**
101
+ * The route root this resolve derived, with the packet's public_route_slug
102
+ * stripped back off.
103
+ *
104
+ * `resolve` appends `public_route_slug` unconditionally, so no documented input
105
+ * points the harness at a deployment served under a different route root — the
106
+ * packet is the authority, and that is intended. The failure mode is what was
107
+ * not intended: the operator saw `ready` rather than a message saying so. When
108
+ * every derived route is dead, one extra probe of the host without the slug
109
+ * separates "this deployment is down" from "this deployment does not serve the
110
+ * slug this packet declares", which is the actionable half.
111
+ */
112
+ export function baseUrlWithoutSlug(baseUrl, publicRouteSlug) {
113
+ const slug = normalizePublicRouteSlug(publicRouteSlug);
114
+ if (!slug || !baseUrl) return null;
115
+ try {
116
+ const url = new URL(baseUrl);
117
+ const segments = url.pathname.split("/").filter(Boolean);
118
+ if (segments.at(-1) !== slug) return null;
119
+ url.pathname = `/${segments.slice(0, -1).join("/")}${segments.length > 1 ? "/" : ""}`;
120
+ return url.toString();
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+
126
+ function summarize(results) {
127
+ const counts = { resolved: 0, unresolved: 0, unreachable: 0, skipped: 0 };
128
+ for (const result of results) counts[result.outcome] = (counts[result.outcome] || 0) + 1;
129
+ return counts;
130
+ }
131
+
132
+ function describeFailure(result) {
133
+ if (!result) return "";
134
+ return result.outcome === "unresolved"
135
+ ? `${result.url} (HTTP ${result.http_status})`
136
+ : `${result.url} (${result.error})`;
137
+ }
138
+
139
+ /**
140
+ * Probe the entry URLs resolve just derived.
141
+ *
142
+ * @returns {{
143
+ * status: "pass"|"failed"|"not_probed",
144
+ * code: string,
145
+ * reason: string,
146
+ * counts: { resolved: number, unresolved: number, unreachable: number, skipped: number },
147
+ * first_failure: object|null, // first result that did not cleanly resolve, in the order the
148
+ * // entry URLs were derived; null only when every probed URL
149
+ * // resolved, or when nothing was probed at all.
150
+ * results: object[],
151
+ * route_root_hint: object|null,
152
+ * }}
153
+ */
154
+ export async function probeRouteUrls({
155
+ entryUrls = [],
156
+ baseUrl = null,
157
+ publicRouteSlug = null,
158
+ enabled = true,
159
+ timeoutMs = ROUTE_PROBE_DEFAULT_TIMEOUT_MS,
160
+ fetchImpl = fetch,
161
+ skippedReason = null,
162
+ } = {}) {
163
+ const urls = [];
164
+ const seen = new Set();
165
+ for (const entry of Array.isArray(entryUrls) ? entryUrls : []) {
166
+ const url = typeof entry === "string" ? entry : entry?.url;
167
+ if (typeof url !== "string" || !url.trim() || seen.has(url)) continue;
168
+ seen.add(url);
169
+ urls.push(url);
170
+ }
171
+
172
+ const notProbed = (code, reason, results = []) => ({
173
+ status: "not_probed",
174
+ code,
175
+ reason,
176
+ counts: summarize(results),
177
+ first_failure: null,
178
+ results,
179
+ route_root_hint: null,
180
+ });
181
+
182
+ if (!urls.length) {
183
+ return notProbed(
184
+ "route_probe.no_routes",
185
+ "No entry URLs were derived, so there was nothing to probe. An empty Entry URL list means a dead preview or a missing --base-url.",
186
+ );
187
+ }
188
+ if (!enabled) {
189
+ return notProbed(
190
+ "route_probe.disabled",
191
+ `Route probing is off${skippedReason ? ` (${skippedReason})` : ""}; the ${urls.length} derived entry URL(s) were not checked against the deployment.`,
192
+ urls.map((url) => ({ url, outcome: "skipped", method: null, http_status: null, error: null })),
193
+ );
194
+ }
195
+
196
+ const probeUrls = urls.slice(0, ROUTE_PROBE_MAX_URLS);
197
+ const skipped = urls.slice(ROUTE_PROBE_MAX_URLS)
198
+ .map((url) => ({ url, outcome: "skipped", method: null, http_status: null, error: null }));
199
+ const results = [...await probeAll(probeUrls, { timeoutMs, fetchImpl }), ...skipped];
200
+ const counts = summarize(results);
201
+
202
+ if (counts.unresolved > 0) {
203
+ const firstFailure = results.find((result) => result.outcome === "unresolved");
204
+ const hint = await routeRootHint({ results, baseUrl, publicRouteSlug, timeoutMs, fetchImpl });
205
+ const scope = counts.resolved === 0
206
+ ? `All ${counts.unresolved} derived entry URL(s) are dead on this host`
207
+ : `${counts.unresolved} of ${counts.unresolved + counts.resolved} derived entry URL(s) are dead on this host`;
208
+ return {
209
+ status: "failed",
210
+ code: "route_probe.routes_unresolved",
211
+ // The route-root diagnosis is long and belongs in exactly one place, so
212
+ // it stays on route_root_hint.reason rather than being inlined here too.
213
+ reason: `${scope}. First failure: ${describeFailure(firstFailure)}.`,
214
+ counts,
215
+ first_failure: firstFailure,
216
+ results,
217
+ route_root_hint: hint,
218
+ };
219
+ }
220
+
221
+ if (counts.resolved === 0) {
222
+ const firstFailure = results.find((result) => result.outcome === "unreachable");
223
+ return {
224
+ ...notProbed(
225
+ "route_probe.unreachable",
226
+ `No entry URL could be reached, so the route set is unverified rather than proven dead — a transport failure is evidence about this machine's network, not about the deployment. First failure: ${describeFailure(firstFailure)}.`,
227
+ results,
228
+ ),
229
+ first_failure: firstFailure,
230
+ };
231
+ }
232
+
233
+ // A pass reached over some unreachable URLs is partial reachability, not a
234
+ // clean sweep. `first_failure` carries the first of them so the structured
235
+ // field means the same thing on every branch, and the reason names it so an
236
+ // operator does not have to infer which URL from a bare count.
237
+ const firstUnreached = results.find((result) => result.outcome === "unreachable") || null;
238
+ return {
239
+ status: "pass",
240
+ code: "route_probe.all_resolved",
241
+ reason: firstUnreached
242
+ ? `${counts.resolved} entry URL(s) resolved; ${counts.unreachable} could not be reached from this machine. First unreached: ${describeFailure(firstUnreached)}.`
243
+ : `All ${counts.resolved} derived entry URL(s) resolved on this host.`,
244
+ counts,
245
+ first_failure: firstUnreached,
246
+ results,
247
+ route_root_hint: null,
248
+ };
249
+ }
250
+
251
+ async function routeRootHint({ results, baseUrl, publicRouteSlug, timeoutMs, fetchImpl }) {
252
+ // Only worth asking when the slug-rooted derivation is dead across the board.
253
+ if (results.some((result) => result.outcome === "resolved")) return null;
254
+ const withoutSlug = baseUrlWithoutSlug(baseUrl, publicRouteSlug);
255
+ if (!withoutSlug) return null;
256
+ const probe = await probeOneUrl(withoutSlug, { timeoutMs, fetchImpl });
257
+ const slug = normalizePublicRouteSlug(publicRouteSlug);
258
+ if (probe.outcome !== "resolved") {
259
+ return {
260
+ code: "route_probe.host_also_dead",
261
+ probed_url: withoutSlug,
262
+ result: probe,
263
+ reason: `${withoutSlug} does not resolve either, so the preview itself is likely dead rather than served under a different route root.`,
264
+ };
265
+ }
266
+ return {
267
+ code: "route_probe.route_root_mismatch",
268
+ probed_url: withoutSlug,
269
+ result: probe,
270
+ reason: `${withoutSlug} resolves but ${baseUrl} does not, so this host does not serve the campaign under the packet's public_route_slug "${slug}". resolve appends that slug from the packet on purpose — the packet is the authority on where the campaign is served — so the fix is to correct campaign.public_route_slug (or declare campaign.route_root) in the packet, not to pass a different --base-url.`,
271
+ };
272
+ }
@@ -0,0 +1,188 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
3
+ import { dirname, join, resolve } from "node:path";
4
+
5
+ import { deriveExceptions, validateVerdict } from "./qa-verdict.mjs";
6
+
7
+ // The committed QA verdict sidecar: `.campaign-runtime/qa-verdict.json` beside
8
+ // the Build Packet, the artifact campaigns-agent's readback projects. Full
9
+ // verdicts under qa-output/ are gitignored because they carry live storefront
10
+ // URLs, request evidence, and order references; the sidecar is committed to
11
+ // merchant repos, so it is an allowlist PROJECTION of one verdict, never a
12
+ // copy. Same schema ("1.0") — a second schema would fork the read end.
13
+
14
+ export const SIDECAR_RELATIVE_PATH = ".campaign-runtime/qa-verdict.json";
15
+
16
+ // Assertion fields that survive projection. Everything else — url, expected,
17
+ // actual, evidence, request/response captures — stays in the full verdict.
18
+ // `cause`/`cause_reason` survive projection: they are a short enum plus a
19
+ // mechanical reason code — no URL, no order reference, no capture body — and
20
+ // the committed sidecar is the artifact a readback reads, so stripping them
21
+ // would leave the one consumer that cannot re-run QA unable to tell a
22
+ // pre-existing finding from a regression.
23
+ const ASSERTION_FIELDS = ["id", "family", "page", "status", "severity", "blocked_by", "cause", "cause_reason"];
24
+ const EXCEPTION_FIELDS = ["id", "family", "page", "status", "severity", "cause", "cause_reason"];
25
+
26
+ function pick(source, fields) {
27
+ const out = {};
28
+ for (const field of fields) {
29
+ if (source?.[field] !== undefined) out[field] = source[field];
30
+ }
31
+ return out;
32
+ }
33
+
34
+ /**
35
+ * Project one full QA verdict into its committable sidecar form. Pure:
36
+ * `generatedAt` is the promotion instant the caller stamps (writers pass the
37
+ * wall clock; tests pass a fixture). The projection must itself pass
38
+ * validateVerdict — the readback recognizes the sidecar by the same minimal
39
+ * schema as the full verdict.
40
+ */
41
+ export function projectVerdictForSidecar(verdict, { generatedAt }) {
42
+ const errors = validateVerdict(verdict);
43
+ if (errors.length) {
44
+ throw new Error(`QA verdict failed validation before sidecar projection:\n- ${errors.join("\n- ")}`);
45
+ }
46
+ // Trust segregation (the readback chokepoint): the QA verdict receiver
47
+ // stamps `trusted: false` onto records that arrived without the ingest
48
+ // credential — anonymous submissions. Shape validity is NOT trust: a forged
49
+ // verdict passes validateVerdict by design. A record carrying that stamp
50
+ // must never be laundered into the committed sidecar campaigns-agent's
51
+ // readback consumes, so the projection refuses it outright instead of
52
+ // silently stripping the stamp. Fresh `qa run` verdicts never carry the
53
+ // field (it is server-stamped, not CLI-emitted), so this only ever fires on
54
+ // an explicit `qa promote` of a fetched, untrusted record. See
55
+ // docs/qa-and-test-orders.md (Verdict schema and trust semantics).
56
+ if (verdict.trusted === false) {
57
+ throw new Error(
58
+ "QA verdict is stamped trusted: false (an anonymous submission classified by the QA verdict receiver); "
59
+ + "refusing to project it into the committed sidecar. Promote a verdict this runner produced, or re-run QA.",
60
+ );
61
+ }
62
+ // Strict RFC3339 UTC with a Z suffix, not bare Date.parse: downstream
63
+ // freshness (readback staleness) documents the Z-suffixed form, and
64
+ // Date.parse would admit zone-less or locale strings that consumers can
65
+ // mis-parse.
66
+ if (typeof generatedAt !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/.test(generatedAt)) {
67
+ throw new Error("Sidecar projection requires an ISO-8601 UTC generatedAt with a Z suffix.");
68
+ }
69
+ const assertions = (verdict.assertions || [])
70
+ .filter((assertion) => assertion && typeof assertion === "object")
71
+ .map((assertion) => pick(assertion, ASSERTION_FIELDS));
72
+ // Exceptions are re-derived from the PROJECTED assertions — which no longer
73
+ // carry url/expected/actual, so deriveExceptions can only emit null/absent
74
+ // for those keys — then pick() drops the null-valued keys themselves.
75
+ const exceptions = deriveExceptions(assertions).map((exception) => pick(exception, EXCEPTION_FIELDS));
76
+ return {
77
+ schema_version: verdict.schema_version,
78
+ run_id: verdict.run_id,
79
+ campaign_slug: verdict.campaign_slug,
80
+ ...(verdict.public_route_slug != null ? { public_route_slug: verdict.public_route_slug } : {}),
81
+ ...(verdict.campaign_ref_id != null ? { campaign_ref_id: verdict.campaign_ref_id } : {}),
82
+ spec_version: verdict.spec_version,
83
+ spec_hash: verdict.spec_hash,
84
+ started_at: verdict.started_at,
85
+ completed_at: verdict.completed_at,
86
+ generated_at: generatedAt,
87
+ runtime: verdict.runtime,
88
+ disposition: verdict.disposition,
89
+ entry_urls: [],
90
+ page_urls: [],
91
+ tested_urls: [],
92
+ assertions,
93
+ test_orders: [],
94
+ exceptions,
95
+ ...(verdict.cause_summary && typeof verdict.cause_summary === "object" && !Array.isArray(verdict.cause_summary)
96
+ ? { cause_summary: verdict.cause_summary }
97
+ : {}),
98
+ };
99
+ }
100
+
101
+ export function sidecarPathForPacket(packetPath) {
102
+ return join(dirname(resolve(packetPath)), SIDECAR_RELATIVE_PATH);
103
+ }
104
+
105
+ const PACKET_SCHEMA = "campaign-runtime-build-packet/v0";
106
+
107
+ /**
108
+ * Assert the sidecar anchor is a real Build Packet before any write. A typo'd
109
+ * or stale --packet path would otherwise scatter qa-verdict.json sidecars
110
+ * into unrelated trees — exactly the artifact sprawl the contracted home
111
+ * exists to prevent.
112
+ */
113
+ function assertPacketAnchor(packetPath) {
114
+ let raw;
115
+ try {
116
+ raw = readFileSync(resolve(packetPath), "utf8");
117
+ } catch (error) {
118
+ // error.code only, never error.message: raw fs messages can carry local
119
+ // uid/path detail that does not belong in surfaced output.
120
+ throw new Error(`Sidecar anchor ${packetPath} is not a readable Build Packet (${error.code || "unreadable"}).`);
121
+ }
122
+ let packet;
123
+ try {
124
+ packet = JSON.parse(raw);
125
+ } catch {
126
+ throw new Error(`Sidecar anchor ${packetPath} is not a readable Build Packet (not valid JSON).`);
127
+ }
128
+ if (packet?.schema_version !== PACKET_SCHEMA) {
129
+ throw new Error(`Sidecar anchor ${packetPath} is not a Build Packet (expected schema_version ${PACKET_SCHEMA}).`);
130
+ }
131
+ }
132
+
133
+ function writeJsonAtomicAt(path, value) {
134
+ mkdirSync(dirname(path), { recursive: true });
135
+ const tmp = `${path}.${randomUUID()}.tmp`;
136
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
137
+ renameSync(tmp, path);
138
+ }
139
+
140
+ /**
141
+ * Write the sidecar for a just-finalized verdict object. Called by `qa run`
142
+ * after local validation, before the exit-code decision, so blocked runs get
143
+ * a sidecar too (disposition is a recorded fact, not a write gate) and a run
144
+ * that dies before finalization never touches an existing sidecar.
145
+ */
146
+ export function writeQaSidecar({ verdict, packetPath, now = () => new Date().toISOString() }) {
147
+ assertPacketAnchor(packetPath);
148
+ const projected = projectVerdictForSidecar(verdict, { generatedAt: now() });
149
+ const destination = sidecarPathForPacket(packetPath);
150
+ writeJsonAtomicAt(destination, projected);
151
+ return { path: destination, run_id: projected.run_id, disposition: projected.disposition, generated_at: projected.generated_at };
152
+ }
153
+
154
+ /**
155
+ * Explicit backfill: promote one named full verdict file to the sidecar.
156
+ * Both paths are required; nothing is selected by mtime or filename order —
157
+ * "latest" scans are how a wrong run gets promoted. The destination sidecar
158
+ * is refused as a source so CI cannot launder an old projection into a fresh
159
+ * timestamp. Validation happens before any write; on failure the prior
160
+ * sidecar bytes are untouched.
161
+ */
162
+ export function promoteQaVerdict({ verdictPath, packetPath, now = () => new Date().toISOString() }) {
163
+ if (!verdictPath || !packetPath) {
164
+ throw new Error("qa promote requires both --verdict <full-verdict.json> and --packet <campaign-runtime.build.json>.");
165
+ }
166
+ assertPacketAnchor(packetPath);
167
+ const source = resolve(verdictPath);
168
+ const destination = sidecarPathForPacket(packetPath);
169
+ if (source === destination) {
170
+ throw new Error("qa promote refuses the destination sidecar as its own source: promote from the full verdict under qa-output/, not from a prior projection.");
171
+ }
172
+ let verdict;
173
+ try {
174
+ verdict = JSON.parse(readFileSync(source, "utf8"));
175
+ } catch (error) {
176
+ throw new Error(`qa promote could not read the source verdict at ${source}: ${error.message}`);
177
+ }
178
+ const projected = projectVerdictForSidecar(verdict, { generatedAt: now() });
179
+ writeJsonAtomicAt(destination, projected);
180
+ return {
181
+ ok: true,
182
+ source: source,
183
+ destination,
184
+ run_id: projected.run_id,
185
+ disposition: projected.disposition,
186
+ generated_at: projected.generated_at,
187
+ };
188
+ }
@@ -0,0 +1,207 @@
1
+ export const OFFER_PAGE_TYPES = new Set(["upsell", "downsell"]);
2
+ export const RECEIPT_PAGE_TYPES = new Set(["receipt", "thankyou"]);
3
+ const ACTIONS = Object.freeze([
4
+ ["decline", "expected_decline_url"],
5
+ ["accept", "expected_accept_url"],
6
+ ]);
7
+
8
+ export function resolveTestOrderTopology(topology = {}, checkoutPage = null) {
9
+ const pages = Array.isArray(topology?.pages) ? topology.pages : [];
10
+ const checkout = checkoutPage || pages.find((page) => pageType(page) === "checkout") || null;
11
+ const pagesByUrl = new Map();
12
+ for (const page of pages) {
13
+ const key = canonicalHttpUrl(page?.url);
14
+ if (key && !pagesByUrl.has(key)) pagesByUrl.set(key, page);
15
+ }
16
+ const topologyOrigin = httpOrigin(checkout?.url) || pages.map((page) => httpOrigin(page?.url)).find(Boolean) || null;
17
+
18
+ const terminalPaths = [];
19
+ const invalidPaths = [];
20
+ const entry = checkout ? targetNode(checkout.expected_next_url, pagesByUrl, topologyOrigin) : null;
21
+ if (entry?.kind === "offer") {
22
+ walkOffer(entry.page, [], new Set(), pagesByUrl, topologyOrigin, terminalPaths, invalidPaths);
23
+ } else if (entry?.kind === "invalid") {
24
+ invalidPaths.push({ path: "checkout", reason: entry.reason, target: entry.target });
25
+ }
26
+ const recognizedTerminals = dedupeTerminals([
27
+ ...pages
28
+ .filter((page) => RECEIPT_PAGE_TYPES.has(pageType(page)) && canonicalHttpUrl(page?.url))
29
+ .map((page) => ({ kind: "receipt", page_id: page.page_id || null, url: page.url })),
30
+ ...(entry?.kind === "terminal" ? [entry.terminal] : []),
31
+ ...terminalPaths.map((candidate) => candidate.terminal),
32
+ ]);
33
+
34
+ return {
35
+ topology_id: topology?.funnel_id || "default",
36
+ checkout_page_id: checkout?.page_id || null,
37
+ checkout_url: checkout?.url || null,
38
+ // Retain every declared page for diagnostics, including malformed rows.
39
+ // pageAtUrl still matches only canonical HTTP URLs, but the resolved plan
40
+ // does not silently erase the topology evidence that explains a miss.
41
+ route_pages: pages.map((page) => ({
42
+ page_id: page.page_id || null,
43
+ page_type: page.page_type || null,
44
+ url: page.url || null,
45
+ })),
46
+ has_offer_entry: entry?.kind === "offer",
47
+ full_paths: terminalPaths.map((candidate) => candidate.path),
48
+ terminal_paths: terminalPaths,
49
+ invalid_paths: invalidPaths,
50
+ recognized_terminals: recognizedTerminals,
51
+ };
52
+ }
53
+
54
+ export function terminalAtUrl(resolvedTopology, value) {
55
+ const key = canonicalHttpUrl(value);
56
+ if (!key) return null;
57
+ return (resolvedTopology?.recognized_terminals || []).find((terminal) => canonicalHttpUrl(terminal?.url) === key) || null;
58
+ }
59
+
60
+ export function pageAtUrl(resolvedTopology, value) {
61
+ const key = canonicalHttpUrl(value);
62
+ if (!key) return null;
63
+ return (resolvedTopology?.route_pages || []).find((page) => canonicalHttpUrl(page?.url) === key) || null;
64
+ }
65
+
66
+ export function remainingActionDisposition(resolvedTopology, value, remainingActions = []) {
67
+ if (!Array.isArray(remainingActions) || !remainingActions.length) return { stop: false };
68
+ const terminal = terminalAtUrl(resolvedTopology, value);
69
+ if (!terminal) return { stop: false };
70
+ return { stop: true, terminal, remaining_actions: [...remainingActions] };
71
+ }
72
+
73
+ export function fullTestOrderPaths(resolvedTopology) {
74
+ const invalid = Array.isArray(resolvedTopology?.invalid_paths) ? resolvedTopology.invalid_paths : [];
75
+ if (invalid.length) {
76
+ const details = invalid
77
+ .map((candidate) => `${candidate.path || "checkout"}: ${candidate.reason}${candidate.target ? ` (${candidate.target})` : ""}`)
78
+ .join("; ");
79
+ throw new Error(
80
+ `--test-order full cannot enumerate actual terminal paths for funnel ${resolvedTopology?.topology_id || "default"}: ${details}`,
81
+ );
82
+ }
83
+ return ["checkout", ...(resolvedTopology?.full_paths || [])];
84
+ }
85
+
86
+ export function commonTestOrderPaths(resolvedTopology) {
87
+ if (resolvedTopology?.has_offer_entry !== true) return ["checkout"];
88
+ const paths = ["checkout", "accept", "decline"];
89
+ const receiptPaths = (resolvedTopology?.terminal_paths || [])
90
+ .filter((candidate) => candidate?.terminal?.kind === "receipt")
91
+ .slice()
92
+ .sort(compareCommonReceiptPaths);
93
+ const shortest = receiptPaths[0]?.path;
94
+ if (shortest && !paths.includes(shortest)) paths.push(shortest);
95
+ return paths;
96
+ }
97
+
98
+ function compareCommonReceiptPaths(left, right) {
99
+ const lengthDelta = (left?.steps?.length || 0) - (right?.steps?.length || 0);
100
+ if (lengthDelta) return lengthDelta;
101
+ if (left?.path === "accept-decline" && right?.path !== "accept-decline") return -1;
102
+ if (right?.path === "accept-decline" && left?.path !== "accept-decline") return 1;
103
+ return compareActionSteps(left?.steps || [], right?.steps || []);
104
+ }
105
+
106
+ function compareActionSteps(left, right) {
107
+ const rank = { accept: 0, decline: 1 };
108
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
109
+ if (left[index] === right[index]) continue;
110
+ return (rank[left[index]] ?? 2) - (rank[right[index]] ?? 2);
111
+ }
112
+ return left.length - right.length;
113
+ }
114
+
115
+ function walkOffer(page, steps, visited, pagesByUrl, topologyOrigin, terminalPaths, invalidPaths) {
116
+ const pageKey = canonicalHttpUrl(page?.url) || `page:${page?.page_id || "unknown"}`;
117
+ if (visited.has(pageKey)) {
118
+ invalidPaths.push({ path: steps.join("-"), reason: "cycle", target: page?.url || page?.page_id || null });
119
+ return;
120
+ }
121
+ const nextVisited = new Set(visited).add(pageKey);
122
+
123
+ for (const [action, field] of ACTIONS) {
124
+ const nextSteps = [...steps, action];
125
+ const target = targetNode(page?.[field], pagesByUrl, topologyOrigin);
126
+ if (target?.kind === "terminal") {
127
+ terminalPaths.push({
128
+ path: nextSteps.join("-"),
129
+ steps: nextSteps,
130
+ terminal: target.terminal,
131
+ });
132
+ } else if (target?.kind === "offer") {
133
+ walkOffer(target.page, nextSteps, nextVisited, pagesByUrl, topologyOrigin, terminalPaths, invalidPaths);
134
+ } else if (target?.kind === "invalid") {
135
+ invalidPaths.push({ path: nextSteps.join("-"), reason: target.reason, target: target.target });
136
+ }
137
+ }
138
+ }
139
+
140
+ function targetNode(value, pagesByUrl, topologyOrigin) {
141
+ const key = canonicalHttpUrl(value);
142
+ if (!key) {
143
+ return value == null || String(value).trim() === ""
144
+ ? { kind: "invalid", reason: "missing_route", target: null }
145
+ : { kind: "invalid", reason: "unresolved_target", target: String(value) };
146
+ }
147
+ const page = pagesByUrl.get(key);
148
+ if (page) {
149
+ const type = pageType(page);
150
+ if (RECEIPT_PAGE_TYPES.has(type)) {
151
+ return {
152
+ kind: "terminal",
153
+ terminal: { kind: "receipt", page_id: page.page_id || null, url: page.url || value },
154
+ };
155
+ }
156
+ if (OFFER_PAGE_TYPES.has(type)) return { kind: "offer", page };
157
+ return { kind: "invalid", reason: "nonterminal_target", target: page.url || value };
158
+ }
159
+ if (topologyOrigin && httpOrigin(key) !== topologyOrigin) {
160
+ return {
161
+ kind: "terminal",
162
+ terminal: { kind: "external_handoff", page_id: null, url: value },
163
+ };
164
+ }
165
+ return { kind: "invalid", reason: "unresolved_target", target: value };
166
+ }
167
+
168
+ function dedupeTerminals(terminals) {
169
+ const seen = new Set();
170
+ const deduped = [];
171
+ for (const terminal of terminals) {
172
+ const key = canonicalHttpUrl(terminal?.url);
173
+ if (!key || seen.has(key)) continue;
174
+ seen.add(key);
175
+ deduped.push(terminal);
176
+ }
177
+ return deduped;
178
+ }
179
+
180
+ export function canonicalHttpUrl(value) {
181
+ if (typeof value !== "string" || !value.trim()) return null;
182
+ try {
183
+ const url = new URL(value.trim());
184
+ if (!/^https?:$/.test(url.protocol)) return null;
185
+ url.search = "";
186
+ url.hash = "";
187
+ url.pathname = url.pathname
188
+ .replace(/\/index\.html$/i, "/")
189
+ .replace(/\/+$/, "") || "/";
190
+ return url.toString();
191
+ } catch {
192
+ return null;
193
+ }
194
+ }
195
+
196
+ function httpOrigin(value) {
197
+ try {
198
+ const url = new URL(String(value || ""));
199
+ return /^https?:$/.test(url.protocol) ? url.origin : null;
200
+ } catch {
201
+ return null;
202
+ }
203
+ }
204
+
205
+ function pageType(page) {
206
+ return String(page?.page_type || "").toLowerCase().replace(/[-_]/g, "");
207
+ }
@@ -0,0 +1,13 @@
1
+ // Canonical URL projection for QA evidence and private capture attribution.
2
+ // Query strings and fragments may contain ref/order identifiers, so every
3
+ // caller gets the same conservative projection, including malformed inputs.
4
+ export function redactUrlQuery(value) {
5
+ if (value === null || value === undefined) return null;
6
+ const text = String(value);
7
+ try {
8
+ const url = new URL(text);
9
+ return `${url.origin}${url.pathname}`;
10
+ } catch {
11
+ return text.split(/[?#]/)[0] || null;
12
+ }
13
+ }