@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,405 @@
1
+ // Cross-page campaign identity (#301).
2
+ //
3
+ // Every page of a built campaign tells the Campaign Cart SDK which campaign it
4
+ // belongs to through three signals, and the SDK reads each of them per page
5
+ // with no cross-page reconciliation:
6
+ //
7
+ // API key <meta name="next-api-key"> beats window.nextConfig.apiKey
8
+ // (inline, or in the config.js the page loads). Selects the
9
+ // campaign whose packages, prices and orders the page acts on.
10
+ // next-funnel <meta name="next-funnel">. Attributed onto the order.
11
+ // setAttribution({ funnel }) A script call that overrides the funnel tag.
12
+ //
13
+ // The failure this module gates is a page borrowed from another funnel: a
14
+ // copied upsell or receipt page that still carries the other campaign's API
15
+ // key, funnel tag, or setAttribution call. Nothing else notices. The SDK binds
16
+ // each page happily, the build succeeds, the page renders, and the order is
17
+ // created against (or attributed to) the wrong campaign. It has shipped twice
18
+ // and been written up as advice both times; advice is not a gate.
19
+ //
20
+ // Hence a static blocker over built output that asserts CONSISTENCY only:
21
+ //
22
+ // - every API key observed anywhere in the campaign is the same key;
23
+ // - every next-funnel meta is the same value, and once any page carries one,
24
+ // every page that declares a next-page-type (so the SDK is bootstrapped on
25
+ // it) carries one too;
26
+ // - every setAttribution({ funnel }) agrees with the next-funnel of the page
27
+ // that calls it.
28
+ //
29
+ // Presence is deliberately NOT asserted. Whether a checkout page must call
30
+ // setAttribution is a separate question (the SDK reads ?funnel=, then the
31
+ // remembered funnel, then the tag, and the platform fills the campaign name
32
+ // when the tag is absent), and a campaign whose pages carry no key at all is a
33
+ // different defect with its own check.
34
+ //
35
+ // Not waivable. The two values in a finding cannot both be right, so there is
36
+ // no bounded human decision to record — the fix is a one-line edit, and the
37
+ // message names the two files and the two values so it is exactly that.
38
+ //
39
+ // Pure: callers read the built HTML and the local scripts each page loads and
40
+ // hand them in. Nothing here touches the filesystem, so the packet doctor path
41
+ // and the built-site-only path (`doctor --built`) drive the same evaluator.
42
+
43
+ export const CAMPAIGN_IDENTITY = "built_output.campaign_identity";
44
+
45
+ // Finding kinds, each with its own issue code under the gate id.
46
+ export const CAMPAIGN_IDENTITY_KINDS = Object.freeze({
47
+ api_key_drift: `${CAMPAIGN_IDENTITY}.api_key_drift`,
48
+ funnel_drift: `${CAMPAIGN_IDENTITY}.funnel_drift`,
49
+ funnel_missing: `${CAMPAIGN_IDENTITY}.funnel_missing`,
50
+ attribution_drift: `${CAMPAIGN_IDENTITY}.attribution_drift`,
51
+ });
52
+
53
+ const HTML_COMMENT = /<!--[\s\S]*?-->/g;
54
+ const INLINE_SCRIPT = /<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi;
55
+ const SET_ATTRIBUTION_FUNNEL = /setAttribution\s*\(\s*\{[^}]*?\bfunnel\s*:\s*(["'`])([^"'`]*)\1/g;
56
+
57
+ // A page that is a parked copy rather than a served page: `checkout-backup-2`,
58
+ // `upsell-old`, `old-receipt`. The issue names `*-backup-*` and `*-old-*`; the
59
+ // segment-anchored form also catches the marker at either end of a segment so
60
+ // `upsell-old/` and `old-upsell/` are both skipped, while `bold-claims/` and
61
+ // `holdout/` are not. Skipped pages are reported, never silently dropped.
62
+ const PARKED_SEGMENT = /(^|-)(backup|old)(-|$)/i;
63
+
64
+ export function isParkedPage(routeOrFile) {
65
+ const value = String(routeOrFile || "").replace(/\\/g, "/").replace(/\/index\.html?$/i, "").replace(/\.html?$/i, "");
66
+ return value.split("/").some((segment) => PARKED_SEGMENT.test(segment));
67
+ }
68
+
69
+ function metaContent(html, name) {
70
+ const tag = new RegExp(`<meta\\b(?=[^>]*\\bname=["']${name}["'])([^>]*)>`, "i").exec(html);
71
+ if (!tag) return null;
72
+ const content = /\bcontent=["']([^"']*)["']/i.exec(tag[1]);
73
+ return content ? content[1].trim() : "";
74
+ }
75
+
76
+ function hasSrcAttribute(attrs) {
77
+ return /(?:^|\s)src\s*=/i.test(attrs || "");
78
+ }
79
+
80
+ /**
81
+ * The `src` of every external `<script>` on a page, in document order, with
82
+ * HTML comments stripped first so a commented-out loader is not followed.
83
+ * The caller resolves and reads them (that needs the filesystem) and hands
84
+ * them back as `page.scripts[]`.
85
+ */
86
+ export function externalScriptSources(html) {
87
+ const source = String(html || "").replace(HTML_COMMENT, "");
88
+ return [...source.matchAll(/<script\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi)].map((match) => match[1]);
89
+ }
90
+
91
+ /**
92
+ * Inline `<script>` bodies of a page, in document order. External scripts are
93
+ * the caller's to resolve (they need the filesystem); they arrive as
94
+ * `page.scripts[]`.
95
+ */
96
+ export function inlineScriptBodies(html) {
97
+ const source = String(html || "").replace(HTML_COMMENT, "");
98
+ const bodies = [];
99
+ for (const match of source.matchAll(INLINE_SCRIPT)) {
100
+ if (hasSrcAttribute(match[1])) continue;
101
+ if (match[2].trim()) bodies.push(match[2]);
102
+ }
103
+ return bodies;
104
+ }
105
+
106
+ // Walk a JS object literal from its opening brace and return the string value
107
+ // of a given key at depth 1, skipping strings, template literals and comments
108
+ // so a commented-out `apiKey: "your-api-key"` or a nested
109
+ // `googleMaps: { apiKey: "" }` (both present in the starter config.js) cannot
110
+ // be read as the campaign key. Returns null when the key is absent or its
111
+ // value is not a plain string literal (a computed value is not something a
112
+ // static check can compare).
113
+ function depthOneStringValue(js, openIndex, key) {
114
+ let depth = 0;
115
+ let i = openIndex;
116
+ const n = js.length;
117
+ // Sticky, so a test at position i reads from i without slicing the source.
118
+ const keyPattern = new RegExp(`(?:${key}|["']${key}["'])\\s*:\\s*(["'\`])((?:\\\\.|(?!\\1)[^\\\\])*)\\1`, "y");
119
+ const identifier = /[A-Za-z0-9_$]*/y;
120
+ const tryKey = (at) => {
121
+ keyPattern.lastIndex = at;
122
+ const hit = keyPattern.exec(js);
123
+ return hit ? hit[2] : null;
124
+ };
125
+ while (i < n) {
126
+ const ch = js[i];
127
+ const next = js[i + 1];
128
+ if (ch === "/" && next === "/") {
129
+ const end = js.indexOf("\n", i);
130
+ i = end === -1 ? n : end + 1;
131
+ continue;
132
+ }
133
+ if (ch === "/" && next === "*") {
134
+ const end = js.indexOf("*/", i + 2);
135
+ i = end === -1 ? n : end + 2;
136
+ continue;
137
+ }
138
+ if (ch === '"' || ch === "'" || ch === "`") {
139
+ // At depth 1 a string may be the quoted key itself; test before skipping.
140
+ if (depth === 1) {
141
+ const value = tryKey(i);
142
+ if (value != null) return value;
143
+ }
144
+ i += 1;
145
+ while (i < n && js[i] !== ch) {
146
+ if (js[i] === "\\") i += 1;
147
+ i += 1;
148
+ }
149
+ i += 1;
150
+ continue;
151
+ }
152
+ if (ch === "{") {
153
+ depth += 1;
154
+ i += 1;
155
+ continue;
156
+ }
157
+ if (ch === "}") {
158
+ depth -= 1;
159
+ if (depth <= 0) return null;
160
+ i += 1;
161
+ continue;
162
+ }
163
+ if (/[A-Za-z_$]/.test(ch)) {
164
+ // An identifier: test it as the key once (at depth 1, and only when it
165
+ // starts a word) and then step past the whole identifier in one jump
166
+ // rather than re-testing at every character inside it.
167
+ if (depth === 1 && (i === 0 || !/[A-Za-z0-9_$.]/.test(js[i - 1]))) {
168
+ const value = tryKey(i);
169
+ if (value != null) return value;
170
+ }
171
+ identifier.lastIndex = i;
172
+ identifier.exec(js);
173
+ i = Math.max(identifier.lastIndex, i + 1);
174
+ continue;
175
+ }
176
+ i += 1;
177
+ }
178
+ return null;
179
+ }
180
+
181
+ /**
182
+ * Every campaign API key a script text declares, with the spelling it used.
183
+ *
184
+ * window.nextConfig = { apiKey: "…" } -> "nextConfig.apiKey"
185
+ * window.nextConfig.apiKey = "…" -> "nextConfig.apiKey"
186
+ * nextCampaign.config({ apiKey: "…" }) -> "nextCampaign.config apiKey" (legacy)
187
+ */
188
+ export function collectScriptApiKeys(js) {
189
+ const source = String(js || "");
190
+ const keys = [];
191
+ for (const match of source.matchAll(/\bnextConfig\s*=\s*\{/g)) {
192
+ const value = depthOneStringValue(source, match.index + match[0].length - 1, "apiKey");
193
+ if (value != null) keys.push({ source: "nextConfig.apiKey", value: value.trim() });
194
+ }
195
+ for (const match of source.matchAll(/\bnextConfig\s*\.\s*apiKey\s*=\s*(["'`])((?:\\.|(?!\1)[^\\])*)\1/g)) {
196
+ keys.push({ source: "nextConfig.apiKey", value: match[2].trim() });
197
+ }
198
+ for (const match of source.matchAll(/\bnextCampaign\s*\.\s*config\s*\(\s*\{/g)) {
199
+ const value = depthOneStringValue(source, match.index + match[0].length - 1, "apiKey");
200
+ if (value != null) keys.push({ source: "nextCampaign.config apiKey", value: value.trim() });
201
+ }
202
+ return keys;
203
+ }
204
+
205
+ export function collectSetAttributionFunnels(js) {
206
+ return [...String(js || "").matchAll(SET_ATTRIBUTION_FUNNEL)].map((match) => match[2].trim());
207
+ }
208
+
209
+ /**
210
+ * The identity signals one built page carries.
211
+ *
212
+ * @param {{ page_id: string, file?: string|null, content: string,
213
+ * scripts?: Array<{ src: string, file?: string|null, content: string }> }} page
214
+ */
215
+ export function collectPageIdentity(page) {
216
+ const html = String(page?.content || "");
217
+ const stripped = html.replace(HTML_COMMENT, "");
218
+ // Normalized once: an empty content="" is "no meta", here and on the record.
219
+ const apiKeyMeta = metaContent(stripped, "next-api-key") || null;
220
+ const apiKeys = [];
221
+ if (apiKeyMeta) apiKeys.push({ source: 'meta name="next-api-key"', where: page.file || page.page_id, value: apiKeyMeta });
222
+ const attributions = [];
223
+
224
+ for (const body of inlineScriptBodies(html)) {
225
+ for (const key of collectScriptApiKeys(body)) apiKeys.push({ ...key, source: `inline ${key.source}`, where: page.file || page.page_id });
226
+ for (const funnel of collectSetAttributionFunnels(body)) attributions.push({ where: page.file || page.page_id, value: funnel });
227
+ }
228
+ for (const script of Array.isArray(page?.scripts) ? page.scripts : []) {
229
+ const where = script.file || script.src;
230
+ for (const key of collectScriptApiKeys(script.content)) apiKeys.push({ ...key, source: `${script.src} ${key.source}`, where });
231
+ for (const funnel of collectSetAttributionFunnels(script.content)) attributions.push({ where, value: funnel });
232
+ }
233
+
234
+ return {
235
+ page_id: page.page_id,
236
+ file: page.file || null,
237
+ api_key_meta: apiKeyMeta,
238
+ api_keys: apiKeys,
239
+ funnel: metaContent(stripped, "next-funnel"),
240
+ page_type: metaContent(stripped, "next-page-type"),
241
+ attributions,
242
+ };
243
+ }
244
+
245
+ function quote(value) {
246
+ return value == null ? "(none)" : `"${value}"`;
247
+ }
248
+
249
+ function gateBase(subject) {
250
+ return {
251
+ id: CAMPAIGN_IDENTITY,
252
+ scope: CAMPAIGN_IDENTITY,
253
+ waivable: false,
254
+ subject: subject && typeof subject === "object" ? subject : {},
255
+ waiver: null,
256
+ };
257
+ }
258
+
259
+ /**
260
+ * Evaluate cross-page campaign identity over built pages.
261
+ *
262
+ * @param {{ subject: object, pages: Array<{ page_id: string, route?: string|null,
263
+ * file?: string|null, content: string,
264
+ * scripts?: Array<{ src: string, file?: string|null, content: string }> }> }} input
265
+ */
266
+ export function evaluateCampaignIdentity({ subject, pages = [] } = {}) {
267
+ const all = Array.isArray(pages) ? pages : [];
268
+ const skipped = all
269
+ .filter((page) => isParkedPage(page.route ?? page.file ?? page.page_id))
270
+ .map((page) => page.file || page.page_id);
271
+ const scanned = all.filter((page) => !isParkedPage(page.route ?? page.file ?? page.page_id));
272
+
273
+ if (scanned.length === 0) {
274
+ return {
275
+ ...gateBase(subject),
276
+ status: "not_applicable",
277
+ code: `${CAMPAIGN_IDENTITY}.not_applicable`,
278
+ reason: "No built page was available to scan; campaign identity becomes mandatory once pages are built.",
279
+ findings: [],
280
+ pages_scanned: 0,
281
+ pages_skipped: skipped,
282
+ identity: null,
283
+ required_actions: [],
284
+ };
285
+ }
286
+
287
+ const identities = scanned.map(collectPageIdentity);
288
+ const findings = [];
289
+
290
+ // 1. API key: every observed key, from every source on every page, is one
291
+ // value. Meta and config are compared together on purpose: a page whose
292
+ // meta names one campaign while its config.js names another is drift
293
+ // inside a single page, and the SDK's precedence rule only hides it.
294
+ const keyObservations = identities.flatMap((identity) => identity.api_keys.map((key) => ({ page_id: identity.page_id, ...key })));
295
+ const firstKey = keyObservations[0] || null;
296
+ for (const observation of keyObservations.slice(1)) {
297
+ if (observation.value === firstKey.value) continue;
298
+ findings.push({
299
+ kind: "api_key_drift",
300
+ code: CAMPAIGN_IDENTITY_KINDS.api_key_drift,
301
+ a: { page_id: firstKey.page_id, file: firstKey.where, source: firstKey.source, value: firstKey.value },
302
+ b: { page_id: observation.page_id, file: observation.where, source: observation.source, value: observation.value },
303
+ message: `API key differs across pages: ${firstKey.where} has ${quote(firstKey.value)} (${firstKey.source}) but ${observation.where} has ${quote(observation.value)} (${observation.source}). One of these pages was borrowed from another campaign; make both name the same key.`,
304
+ });
305
+ break; // one finding per kind names the first pair; the rest follow from the same edit
306
+ }
307
+
308
+ // 2. next-funnel: one value across pages, and present wherever the SDK is
309
+ // bootstrapped (a page that declares next-page-type is SDK-bound).
310
+ const funnelPages = identities.filter((identity) => identity.funnel);
311
+ const firstFunnel = funnelPages[0] || null;
312
+ for (const identity of funnelPages.slice(1)) {
313
+ if (identity.funnel === firstFunnel.funnel) continue;
314
+ findings.push({
315
+ kind: "funnel_drift",
316
+ code: CAMPAIGN_IDENTITY_KINDS.funnel_drift,
317
+ a: { page_id: firstFunnel.page_id, file: firstFunnel.file || firstFunnel.page_id, value: firstFunnel.funnel },
318
+ b: { page_id: identity.page_id, file: identity.file || identity.page_id, value: identity.funnel },
319
+ message: `next-funnel differs across pages: ${firstFunnel.file || firstFunnel.page_id} has ${quote(firstFunnel.funnel)} but ${identity.file || identity.page_id} has ${quote(identity.funnel)}. Orders from the second page attribute to the other funnel; set the same <meta name="next-funnel"> on both.`,
320
+ });
321
+ break;
322
+ }
323
+ // "Missing" is judged against the campaign, not in the abstract: a
324
+ // campaign that tags no page at all is consistent (the platform fills the
325
+ // campaign name when the tag is absent), and blocking it would be the
326
+ // presence assertion this check deliberately does not make. Once ANY
327
+ // page carries the tag, an SDK-bound page without it attributes its
328
+ // orders differently from the rest, which is drift.
329
+ // One finding, like the other kinds: the repair is the same edit on every
330
+ // untagged page, and a run should not be dominated by it repeated N times.
331
+ if (firstFunnel) {
332
+ for (const identity of identities) {
333
+ if (identity.funnel || !identity.page_type) continue;
334
+ findings.push({
335
+ kind: "funnel_missing",
336
+ code: CAMPAIGN_IDENTITY_KINDS.funnel_missing,
337
+ a: { page_id: identity.page_id, file: identity.file || identity.page_id, value: null },
338
+ b: { page_id: firstFunnel.page_id, file: firstFunnel.file || firstFunnel.page_id, value: firstFunnel.funnel },
339
+ message: `${identity.file || identity.page_id} declares next-page-type=${quote(identity.page_type)} but no <meta name="next-funnel">, while ${firstFunnel.file || firstFunnel.page_id} carries ${quote(firstFunnel.funnel)}. Orders from the untagged page attribute differently from the rest; add the same next-funnel meta.`,
340
+ });
341
+ break;
342
+ }
343
+ }
344
+
345
+ // 3. setAttribution({ funnel }) agrees with the page's own tag. A page with
346
+ // no tag of its own is held to the campaign's tag (the one every other
347
+ // page agrees on) so a borrowed script on an untagged page still fails.
348
+ for (const identity of identities) {
349
+ const expected = identity.funnel || (firstFunnel ? firstFunnel.funnel : null);
350
+ if (!expected) continue;
351
+ const expectedWhere = identity.funnel ? identity.file || identity.page_id : firstFunnel.file || firstFunnel.page_id;
352
+ for (const attribution of identity.attributions) {
353
+ if (attribution.value === expected) continue;
354
+ findings.push({
355
+ kind: "attribution_drift",
356
+ code: CAMPAIGN_IDENTITY_KINDS.attribution_drift,
357
+ a: { page_id: identity.page_id, file: expectedWhere, value: expected },
358
+ b: { page_id: identity.page_id, file: attribution.where, value: attribution.value },
359
+ message: `setAttribution({ funnel: ${quote(attribution.value)} }) in ${attribution.where} disagrees with next-funnel ${quote(expected)} in ${expectedWhere}. The call overrides the tag, so orders attribute to ${quote(attribution.value)}; change the call to ${quote(expected)} or remove it.`,
360
+ });
361
+ }
362
+ }
363
+
364
+ const identity = {
365
+ api_key: firstKey ? firstKey.value : null,
366
+ api_key_source: firstKey ? firstKey.source : null,
367
+ funnel: firstFunnel ? firstFunnel.funnel : null,
368
+ };
369
+
370
+ if (findings.length === 0) {
371
+ return {
372
+ ...gateBase(subject),
373
+ status: "pass",
374
+ code: `${CAMPAIGN_IDENTITY}.pass`,
375
+ reason: `All ${scanned.length} built page(s) agree on campaign identity (API key ${identity.api_key ? "present" : "not declared"}, next-funnel ${quote(identity.funnel)}).`,
376
+ findings: [],
377
+ pages_scanned: scanned.length,
378
+ pages_skipped: skipped,
379
+ identity,
380
+ required_actions: [],
381
+ };
382
+ }
383
+
384
+ return {
385
+ ...gateBase(subject),
386
+ status: "blocked",
387
+ code: CAMPAIGN_IDENTITY,
388
+ // Short on purpose; the per-edit prose is findings[].message, which is
389
+ // what each doctor error carries. A reason that concatenates them is
390
+ // truncated by every single-line renderer.
391
+ reason: `${findings.length} campaign identity drift finding(s) across ${scanned.length} built page(s) (${[...new Set(findings.map((finding) => finding.kind))].join(", ")}); see findings[] for the two files and two values of each.`,
392
+ findings,
393
+ pages_scanned: scanned.length,
394
+ pages_skipped: skipped,
395
+ identity,
396
+ required_actions: [
397
+ {
398
+ id: "repair_identity",
399
+ kind: "edit",
400
+ command: null,
401
+ description: `Make every page name the same campaign: ${findings.map((finding) => `${finding.b?.file || finding.a.file} (${finding.kind.replace(/_/g, " ")})`).join("; ")}. Not waivable: two campaign identities on one funnel cannot both be intended.`,
402
+ },
403
+ ],
404
+ };
405
+ }
@@ -0,0 +1,127 @@
1
+ // Campaign workspace: where a Build Packet's sidecars live, resolved once.
2
+ //
3
+ // Two roots matter. The TARGET REPO (`packet.assembly.target_repo` resolved
4
+ // against the packet's directory, else that directory) carries the build
5
+ // sidecars under `.campaign-runtime/` and the local QA verdicts under
6
+ // `qa-output/`; the packet's own directory (`baseDir`) carries what is written
7
+ // beside the packet (run records, the committed QA verdict sidecar). They
8
+ // coincide for a packet at the target root and diverge for `prepare-build
9
+ // --out` elsewhere — exactly when a stage spelling the rule for itself drifts.
10
+ // A leaf: node built-ins and the sidecar leaves only.
11
+
12
+ import { readFileSync } from "node:fs";
13
+ import { dirname, join, resolve } from "node:path";
14
+ import { DOCTOR_SIDECAR_REL_PATH } from "./doctor-sidecar.mjs";
15
+ import { absentOrMalformed, sameFile } from "./fs-identity.mjs";
16
+
17
+ export const BUILD_CONTEXT_REL_PATH = ".campaign-runtime/build-context.json";
18
+ export const ASSEMBLY_REPORT_REL_PATH = ".campaign-runtime/assembly-report.json";
19
+ export const QA_OUTPUT_REL_PATH = "qa-output";
20
+
21
+ // The target repo a packet builds into.
22
+ export function targetRepoFor(packetPath, packet) {
23
+ const packetDir = dirname(resolve(packetPath));
24
+ const declared = packet?.assembly?.target_repo;
25
+ return typeof declared === "string" && declared.trim() ? resolve(packetDir, declared) : packetDir;
26
+ }
27
+
28
+ // The default location of every build sidecar a target repo carries.
29
+ export function campaignSidecarPaths(targetRepo) {
30
+ return {
31
+ contextPath: join(targetRepo, BUILD_CONTEXT_REL_PATH),
32
+ reportPath: join(targetRepo, ASSEMBLY_REPORT_REL_PATH),
33
+ doctorOutPath: join(targetRepo, DOCTOR_SIDECAR_REL_PATH),
34
+ qaOutputDir: join(targetRepo, QA_OUTPUT_REL_PATH),
35
+ };
36
+ }
37
+
38
+ // The report a printed remediation must name, or null. `checkpoint waive`,
39
+ // `theme waive` and `polish capture` all default to the packet-inferred
40
+ // <target repo>/.campaign-runtime/assembly-report.json, so a command printed
41
+ // for a report that came from anywhere else (--report, or a Build Context
42
+ // binding) has to carry `--report <path>` or it acts on a different report —
43
+ // possibly one that does not exist. Quiet in the common case: null when the
44
+ // report IS that default, so the printed command stays short; the path when
45
+ // it is not, or when the target repo is unknown and the default cannot be
46
+ // ruled out. Doctor's report and the QA runner's both print through this.
47
+ export function explicitReportPath(reportPath, targetRepo) {
48
+ if (typeof reportPath !== "string" || !reportPath) return null;
49
+ const inferred = typeof targetRepo === "string" && targetRepo ? campaignSidecarPaths(targetRepo).reportPath : null;
50
+ return reportPath !== inferred ? reportPath : null;
51
+ }
52
+
53
+ // Best-effort read of the Build Context for the report binding only: a
54
+ // missing or malformed context binds nothing. A caller that must refuse a
55
+ // malformed context reads it again strictly. Any other read failure (a
56
+ // permission error, a directory where the file should be) is not absence:
57
+ // binding the default report over it would record a stage outcome into a
58
+ // report the campaign is not bound to, so it reaches the caller.
59
+ function readContextForBinding(contextPath) {
60
+ if (!contextPath) return null;
61
+ try {
62
+ const parsed = JSON.parse(readFileSync(contextPath, "utf8"));
63
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
64
+ } catch (error) {
65
+ if (absentOrMalformed(error)) return null;
66
+ throw error;
67
+ }
68
+ }
69
+
70
+ // The one resolver. `contextPath` / `reportPath` / `doctorOutPath` are the
71
+ // operator's explicit choices, resolved by the caller from whichever flag it
72
+ // owns: a string is used as given, `null` switches that sidecar off (no
73
+ // context to bind from; `reportPath: null`), `undefined` derives the default.
74
+ //
75
+ // `followContextPointer` is required on purpose. The Build Context records
76
+ // where prepare-build wrote the report (`--report-out`), relative to the
77
+ // target repo. A stage that follows it acts on the report the campaign is
78
+ // bound to; one that does not acts on the default sidecar. Both are
79
+ // legitimate (doctor's stage write-back must not restate its outcome into a
80
+ // report it did not inspect), but a stage that never said which is how the QA
81
+ // stage came to be recorded into a report `next` never reads. Say it.
82
+ //
83
+ // A context binds a report for the packet it names: prepare-build writes
84
+ // `packet_path` beside `report_path`, and when two packets of one campaign
85
+ // share a target repo the default context belongs to whichever ran last, so
86
+ // a pointer from a context naming another packet is not followed. A context
87
+ // naming no packet binds by location, as it always did.
88
+ export function resolveCampaignWorkspace(packetPath, {
89
+ packet = undefined,
90
+ contextPath = undefined,
91
+ reportPath = undefined,
92
+ doctorOutPath = undefined,
93
+ followContextPointer,
94
+ } = {}) {
95
+ if (typeof followContextPointer !== "boolean") {
96
+ throw new TypeError("resolveCampaignWorkspace requires followContextPointer: true (act on the report the Build Context binds) or false (act on the default sidecar).");
97
+ }
98
+ const absolutePacketPath = resolve(packetPath);
99
+ const loadedPacket = packet === undefined ? JSON.parse(readFileSync(absolutePacketPath, "utf8")) : packet;
100
+ const baseDir = dirname(absolutePacketPath);
101
+ const targetRepo = targetRepoFor(absolutePacketPath, loadedPacket);
102
+ const defaults = campaignSidecarPaths(targetRepo);
103
+ const resolvedContextPath = contextPath === undefined ? defaults.contextPath : contextPath;
104
+ const context = followContextPointer ? readContextForBinding(resolvedContextPath) : null;
105
+ const recorded = typeof context?.report_path === "string" && context.report_path.trim() ? context.report_path.trim() : null;
106
+ const named = typeof context?.packet_path === "string" && context.packet_path.trim() ? context.packet_path.trim() : null;
107
+ // Both sides must be on disk: a pointer naming a packet that is not there
108
+ // cannot be shown to name this one.
109
+ const bound = recorded && (!named || sameFile(resolve(targetRepo, named), absolutePacketPath, { requireExisting: true }));
110
+
111
+ const resolvedReportPath = reportPath !== undefined
112
+ ? reportPath
113
+ : bound
114
+ ? resolve(targetRepo, recorded)
115
+ : defaults.reportPath;
116
+ return {
117
+ packet: loadedPacket,
118
+ packetPath: absolutePacketPath,
119
+ baseDir,
120
+ targetRepo,
121
+ contextPath: resolvedContextPath,
122
+ reportPath: resolvedReportPath,
123
+ defaultReportPath: defaults.reportPath,
124
+ doctorOutPath: doctorOutPath === undefined ? defaults.doctorOutPath : doctorOutPath,
125
+ qaOutputDir: defaults.qaOutputDir,
126
+ };
127
+ }