@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,514 @@
1
+ // `campaigns-os spec derive`: write the fields the target repo already states
2
+ // into the packet's local CampaignSpec.
3
+ //
4
+ // Every field in a CampaignSpec has a class (#432): authored (a human writes
5
+ // it), mirrored (pulled from the Campaigns API or the store) or derived (the
6
+ // repo or the store already states it). Derived fields are generated, never
7
+ // typed, and this command is the generator for the repo-derived ones: the
8
+ // SDK pin from `_data/campaigns.json[<route>].sdk_version`, each page's public
9
+ // route from the page tree under `src/<route>/`, and the analytics ids the
10
+ // entry carries (`gtm_id`, `fb_pixel_id`). Doctor then compares generated
11
+ // against generated instead of refereeing a human's typing against the repo.
12
+ //
13
+ // The repo is the authority for exactly those fields. A spec value that looks
14
+ // authored but sits in a derived field is overwritten, with the before -> after
15
+ // line showing it; nothing outside the derived fields is written, ever.
16
+ // The store-derived fields (the nine campaign.store_* Store Profile fields,
17
+ // authority: the store's Admin API) are planned by spec-derive-store.mjs
18
+ // behind --from-store; their rows are applied here under the same guard.
19
+ //
20
+ // This module is pure: the CLI reads the packet, the spec, the entry and the
21
+ // page tree, and does the writing and the printing.
22
+ import { isReleasedSdkVersion } from "../campaign-spec/dist/index.js";
23
+ import { PAGE_KIT_CAMPAIGNS_REL_PATH } from "./page-kit-campaign-config.mjs";
24
+ import { PAGE_KIT_STORE_PROFILE_FIELDS } from "./page-kit-store-profile.mjs";
25
+ import { compareReleasedSdkVersions, entryInScaffoldState, resolveSpecSdkPin } from "./page-kit-sdk-version.mjs";
26
+ import { isAbsoluteHttpUrl, normalizePageKitRoute, runtimeRelativeRouteForSpecValue, stripPublicRoutePrefix } from "./route-identity.mjs";
27
+ import { publicRouteForPage } from "./source-html-intake.mjs";
28
+
29
+ // The spec fields this command may write, as path patterns. `applySpecDerive`
30
+ // refuses any change whose path matches none of them, so a plan row can never
31
+ // reach an authored field.
32
+ export const SPEC_DERIVE_FIELDS = Object.freeze([
33
+ "global_config.sdk_version",
34
+ "runtime.sdk_version",
35
+ "funnels[].pages[].page_url",
36
+ "funnel_pages[].page_url",
37
+ "analytics.providers.gtm.containerId",
38
+ "analytics.providers.facebook.pixelId",
39
+ ...PAGE_KIT_STORE_PROFILE_FIELDS.map((field) => `campaign.${field}`),
40
+ ]);
41
+
42
+ // campaigns.json analytics keys and the spec provider field each one derives.
43
+ export const ANALYTICS_ID_FIELDS = Object.freeze([
44
+ Object.freeze({ key: "gtm_id", provider: "gtm", property: "containerId", shape: /^GTM-[A-Z0-9]{4,}$/, describe: "a GTM container id (GTM-XXXXXXX)" }),
45
+ Object.freeze({ key: "fb_pixel_id", provider: "facebook", property: "pixelId", shape: /^\d{5,20}$/, describe: "a Meta pixel id (digits only)" }),
46
+ ]);
47
+
48
+ // The SDK routing meta tags a page's `sdk_hints.meta_tags` may carry, and the
49
+ // declared routing field (a page id) each one is the route of. A derived
50
+ // route change on the referenced page leaves such a hint stale; the hint is a
51
+ // spec projection the editor regenerates, so it is reported, not rewritten.
52
+ const ROUTING_HINT_FIELDS = Object.freeze({
53
+ "next-success-url": "success_url",
54
+ "next-upsell-accept-url": "on_accept",
55
+ "next-upsell-decline-url": "on_decline",
56
+ });
57
+
58
+ // The directories page-kit's own discovery ignores (`_layouts`, `_includes`;
59
+ // everything else under the campaign root, `assets/` and `_data/` included,
60
+ // is rendered when it is .html), plus the two no build reads. The CLI walker
61
+ // prunes with this and pageRouteForFile filters with it, so the list lives
62
+ // in one place.
63
+ export const PAGE_TREE_IGNORED_DIRS = Object.freeze(["_layouts", "_includes", "node_modules", ".git"]);
64
+ export function isPageTreeIgnoredDir(name) {
65
+ return PAGE_TREE_IGNORED_DIRS.includes(name);
66
+ }
67
+
68
+ function isPlainObject(value) {
69
+ return value !== null && typeof value === "object" && !Array.isArray(value);
70
+ }
71
+
72
+ function isNonEmptyString(value) {
73
+ return typeof value === "string" && value.trim().length > 0;
74
+ }
75
+
76
+ export function hasControlCharacters(value) {
77
+ // eslint-disable-next-line no-control-regex
78
+ return /[\u0000-\u001f\u007f]/.test(value);
79
+ }
80
+
81
+ // A repo value echoed in a reason is quoted short: a value mis-pasted into
82
+ // the wrong key (a token, a URL) must not travel whole into warnings.
83
+ export function quoteValue(value) {
84
+ const text = JSON.stringify(value);
85
+ return text.length > 44 ? `${text.slice(0, 40)}…"` : text;
86
+ }
87
+
88
+ // The placeholder ids the starter templates document (GTM-XXXXXXX, a run of
89
+ // one digit): well-formed, never a real container or pixel.
90
+ function isPlaceholderId(value) {
91
+ return /^GTM-X+$/i.test(value) || /^(\d)\1+$/.test(value);
92
+ }
93
+
94
+ // Whether the containers along a write path can take the value: an existing
95
+ // container of the wrong type (analytics: "off", global_config: []) is a
96
+ // spec defect the plan reports, so a dry run and a real run agree. Returns
97
+ // null when the path is writable, else the offending label.
98
+ function containerProblem(spec, path) {
99
+ let node = spec;
100
+ for (let index = 0; index < path.length - 1; index += 1) {
101
+ const segment = path[index];
102
+ const nextIsIndex = Number.isInteger(path[index + 1]);
103
+ const child = node?.[segment];
104
+ if (child === undefined || child === null) return nextIsIndex ? pathLabel(path.slice(0, index + 1)) : null;
105
+ if (nextIsIndex ? !Array.isArray(child) : !isPlainObject(child)) return pathLabel(path.slice(0, index + 1));
106
+ node = child;
107
+ }
108
+ return null;
109
+ }
110
+
111
+ // The public route a page-kit source file builds to without a permalink,
112
+ // relative to the campaign root. page-kit's resolveOutput routes by the
113
+ // FILENAME alone (`checkout.html` -> `checkout/`, `offers/upsell.html` ->
114
+ // `upsell/`, intermediate directories ignored with its NESTED_NO_PERMALINK
115
+ // warning), and `index.html` is the entry route "". A nested `index.html`
116
+ // would collide with the campaign root (page-kit's DUPLICATE_OUTPUT), so it
117
+ // is not a page this reads. Files under `_layouts/` or `_includes/` return
118
+ // null, as page-kit never renders them.
119
+ export function pageRouteForFile(relativePath) {
120
+ const path = String(relativePath || "").replace(/\\/g, "/").replace(/^\/+/, "");
121
+ if (!path || !/\.html$/i.test(path)) return null;
122
+ const segments = path.split("/");
123
+ if (segments.slice(0, -1).some(isPageTreeIgnoredDir)) return null;
124
+ const basename = segments[segments.length - 1].replace(/\.html$/i, "");
125
+ if (basename === "index") return segments.length === 1 ? "" : null;
126
+ return `${basename}/`;
127
+ }
128
+
129
+ // The campaign-relative route a frontmatter permalink states. page-kit serves
130
+ // a permalink verbatim at `/<permalink>/`, and prepare-build only ever writes
131
+ // the `/<slug>/<route>/` form, so that is the only form derive accepts: any
132
+ // other spelling (no slug prefix, another prefix, `.html`, `..`, a control
133
+ // character) is a repo defect reported with the URL page-kit would serve.
134
+ // Returns `{ route }` or `{ problem }`; the problem text carries the served
135
+ // URL where one can be named.
136
+ export function permalinkRoute(permalink, publicRouteSlug = "") {
137
+ const raw = String(permalink ?? "").trim();
138
+ if (hasControlCharacters(raw)) return { problem: "contains control characters" };
139
+ const stripped = raw.replace(/^\/+|\/+$/g, "");
140
+ const served = `/${stripped}/`;
141
+ const segments = stripped.split("/");
142
+ const slug = String(publicRouteSlug || "").trim();
143
+ if (!stripped || segments[0] !== slug) return { problem: `is served at ${served}, outside the campaign root /${slug || "<slug>"}/` };
144
+ const rest = segments.slice(1);
145
+ if (rest.some((segment) => segment === "" || segment === "." || segment === ".." || /\.html$/i.test(segment) || /[?#]/.test(segment))) {
146
+ return { problem: `is served at ${served}, which is not a page-kit route under /${slug}/ (each segment a plain name, no .html, no query)` };
147
+ }
148
+ return { route: rest.length ? `${rest.join("/")}/` : "" };
149
+ }
150
+
151
+ // A route the page tree states must be a relative page-kit route before it
152
+ // becomes an authoritative spec value: a permalink that is an absolute URL,
153
+ // climbs with `..`, carries an empty segment or a control character is a
154
+ // repo defect, reported rather than written. Returns null when the route is
155
+ // usable, else the reason.
156
+ export function derivedRouteProblem(route) {
157
+ if (typeof route !== "string") return "not a string";
158
+ if (route === "") return null;
159
+ if (hasControlCharacters(route)) return "contains control characters";
160
+ if (isAbsoluteHttpUrl(route)) return "is an absolute URL, not a page-kit route";
161
+ if (!/\/$/.test(route) || route.startsWith("/")) return "is not a relative page-kit route";
162
+ const segments = route.slice(0, -1).split("/");
163
+ if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) return "contains an empty, `.` or `..` segment";
164
+ return null;
165
+ }
166
+
167
+ // The read side of the walker's permalink: YAML idioms page-kit's own
168
+ // frontmatter reader (gray-matter) understands. `false`, `null` and `~` mean
169
+ // no permalink; a trailing `# comment` on an unquoted value is not part of
170
+ // it; surrounding quotes are dropped.
171
+ export function normalizePermalinkValue(value) {
172
+ if (typeof value !== "string") return null;
173
+ const text = value.trim();
174
+ // A quoted scalar ends at its closing quote; whatever follows (a comment)
175
+ // is not part of it. An unquoted scalar ends at ` #`.
176
+ const quoted = text.match(/^(["'])(.*?)\1(?:\s+#.*)?$/);
177
+ if (quoted) return quoted[2] || null;
178
+ const bare = text.replace(/\s+#.*$/, "").trim();
179
+ if (!bare || ["false", "null", "~"].includes(bare)) return null;
180
+ return bare;
181
+ }
182
+
183
+ // The raw `permalink:` scalar of a page file's frontmatter (the first line
184
+ // of the block that declares it), quoting and comment intact, or null when
185
+ // the block or the key is absent. A BOM and CRLF are read as page-kit's
186
+ // frontmatter reader reads them.
187
+ export function frontmatterPermalink(text) {
188
+ const normalized = String(text ?? "").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
189
+ const block = normalized.match(/^---\n([\s\S]*?)\n---/);
190
+ if (!block) return null;
191
+ for (const line of block[1].split("\n")) {
192
+ const match = line.match(/^permalink:\s*(.*?)\s*$/);
193
+ if (match) return normalizePermalinkValue(match[1]);
194
+ }
195
+ return null;
196
+ }
197
+
198
+ function terminalSegment(route) {
199
+ const parts = normalizePageKitRoute(route).replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
200
+ return parts.length ? parts[parts.length - 1] : "";
201
+ }
202
+
203
+ // Bind one active spec page to one file in the page tree. The packet's own
204
+ // projection (`source_html.pages[].page_kit.target_path`, the file the build
205
+ // stage wrote for this page id) binds first; without it, a file whose derived
206
+ // route equals the page's current route, whose terminal segment equals the
207
+ // current route's, or whose filename is the page id. Exactly one candidate
208
+ // binds; none or several is reported and the page is left as it is.
209
+ function bindPageFile(page, pageFiles, packetBindings) {
210
+ const bound = packetBindings instanceof Map ? packetBindings.get(page.id) : undefined;
211
+ if (isNonEmptyString(bound)) {
212
+ const match = pageFiles.find((file) => file.path === bound.replace(/^\/+/, ""));
213
+ if (match) return { file: match, via: "packet" };
214
+ }
215
+ const currentRoute = publicRouteForPage(page);
216
+ const currentTerminal = terminalSegment(currentRoute);
217
+ const candidates = new Set();
218
+ for (const file of pageFiles) {
219
+ if (file.route !== null && file.route === currentRoute) candidates.add(file);
220
+ else if (currentTerminal && file.route !== null && terminalSegment(file.route) === currentTerminal) candidates.add(file);
221
+ else if (file.basename === page.id) candidates.add(file);
222
+ }
223
+ if (candidates.size === 1) return { file: [...candidates][0], via: "page_tree" };
224
+ return { file: null, candidates: [...candidates].map((file) => file.path).sort(), via: candidates.size ? "ambiguous" : "none" };
225
+ }
226
+
227
+ // Every active page with the JSON path it lives at. `funnels[]` is
228
+ // authoritative; `funnel_pages[]` is the legacy mirror and is only the source
229
+ // when `funnels[]` is absent (the same fallback doctor's normalizeFunnels
230
+ // applies).
231
+ function activePagesWithPaths(spec) {
232
+ const pages = [];
233
+ const visit = (page, path) => {
234
+ if (isPlainObject(page) && page.enabled !== false && isNonEmptyString(page.id)) pages.push({ page, path });
235
+ };
236
+ if (Array.isArray(spec?.funnels)) {
237
+ spec.funnels.forEach((funnel, funnelIndex) => {
238
+ (Array.isArray(funnel?.pages) ? funnel.pages : []).forEach((page, pageIndex) => visit(page, ["funnels", funnelIndex, "pages", pageIndex]));
239
+ });
240
+ } else if (Array.isArray(spec?.funnel_pages)) {
241
+ spec.funnel_pages.forEach((page, index) => visit(page, ["funnel_pages", index]));
242
+ }
243
+ return pages;
244
+ }
245
+
246
+ function pathLabel(path) {
247
+ return path.map((segment, index) => (typeof segment === "number" ? `[${segment}]` : `${index ? "." : ""}${segment}`)).join("");
248
+ }
249
+
250
+ // The field-by-field plan. `changes` are the derived fields whose value will
251
+ // move, `unchanged` already match, `not_derived` are derived fields the repo
252
+ // cannot state for this campaign right now (with the reason), `not_in_target`
253
+ // are derived fields the repo simply does not carry (left as they are), and
254
+ // `stale_hints` are routing hints on other pages that a derived route change
255
+ // leaves pointing at the old route.
256
+ //
257
+ // Inputs, all read by the CLI: the parsed spec; the campaigns.json `entry`
258
+ // for the route; `pageFiles` as `{ path, basename, route, permalink }` rows for every
259
+ // page file under the campaign's source directory (null when the directory
260
+ // does not exist); `packetBindings` as a Map of page id -> target file path
261
+ // from the packet's page-kit projection; `waivedGates` as the checkpoint
262
+ // gates an active named-human waiver currently covers.
263
+ export function planSpecDerive({ spec, entry, pageFiles = null, packetBindings = new Map(), waivedGates = [], waiversUnknown = false, publicRouteSlug = "" } = {}) {
264
+ const target = isPlainObject(entry) ? entry : {};
265
+ const changes = [];
266
+ const unchanged = [];
267
+ const notDerived = [];
268
+ const notInTarget = [];
269
+ const staleHints = [];
270
+ const waivedBy = new Map(
271
+ (Array.isArray(waivedGates) ? waivedGates : [])
272
+ .filter((gate) => isPlainObject(gate) && typeof gate.scope === "string")
273
+ .map((gate) => [gate.scope, gate.waived_by || "a named human"]),
274
+ );
275
+
276
+ // SDK pin: the repo pin is what the funnel serves. It is written into the
277
+ // canonical field and, when the spec also declares the alias, into the alias
278
+ // too, so the two never conflict. It is not derived while the entry is still
279
+ // a scaffold (the pin is the starter's seed and page-kit sync's seeding rule
280
+ // owns that direction), when the repo pin is missing or not a released
281
+ // version, or while a named-human waiver covers the exact pair.
282
+ const observed = Object.hasOwn(target, "sdk_version") ? target.sdk_version : undefined;
283
+ const specPin = resolveSpecSdkPin(spec);
284
+ const sdkTargets = [
285
+ { field: "global_config.sdk_version", path: ["global_config", "sdk_version"], before: spec?.global_config?.sdk_version },
286
+ ...(spec?.runtime != null && Object.hasOwn(spec.runtime, "sdk_version")
287
+ ? [{ field: "runtime.sdk_version", path: ["runtime", "sdk_version"], before: spec.runtime.sdk_version }]
288
+ : []),
289
+ ];
290
+ const entrySource = (key) => `${PAGE_KIT_CAMPAIGNS_REL_PATH}[${publicRouteSlug || "<route>"}].${key}`;
291
+ const sdkSource = entrySource("sdk_version");
292
+ const sdkWaiver = waivedBy.get("page_kit.sdk_version") || null;
293
+ if (observed === undefined) {
294
+ notDerived.push({ field: "global_config.sdk_version", reason: "target_missing", detail: `the target entry has no sdk_version; add the Campaign Cart pin the funnel serves to ${sdkSource}, then derive again.` });
295
+ } else if (!isReleasedSdkVersion(observed)) {
296
+ notDerived.push({ field: "global_config.sdk_version", reason: "target_invalid", detail: `the target pin ${quoteValue(observed)} is not a released MAJOR.MINOR.PATCH version; correct ${sdkSource}, then derive again.` });
297
+ } else if (entryInScaffoldState(target)) {
298
+ notDerived.push({ field: "global_config.sdk_version", reason: "scaffold_seed", detail: `the target entry still carries the starter demo store profile, so its pin ${observed} is the starter's seed, not a version anyone chose; the spec seeds the pin in that state (page-kit sync). Sync the scaffold from the spec first, then derive.` });
299
+ } else if (sdkWaiver) {
300
+ notDerived.push({ field: "global_config.sdk_version", reason: "waived", detail: `the SDK pin is covered by an active page_kit.sdk_version waiver recorded by ${sdkWaiver}; spec derive leaves the spec as the waiver accepted it. Withdraw the waiver on the Assembly Report (waivers[]) to let derive write the repo pin.` });
301
+ } else if (waiversUnknown) {
302
+ // An unreadable Assembly Report means a named-human waiver on the pin
303
+ // may exist unseen; "unknown" is not "none", so the pin waits.
304
+ notDerived.push({ field: "global_config.sdk_version", reason: "waivers_unknown", detail: "the Assembly Report could not be read, so an active page_kit.sdk_version waiver cannot be ruled out; spec derive leaves the pin alone rather than reverse a decision it cannot see. Repair or restore the report, then derive again." });
305
+ } else {
306
+ // A spec pin ahead of the repo pin is the state doctor blocks on with
307
+ // page-kit sync as the repair (#413: a bump the repo never received, or
308
+ // a lost one); the repo moves forward, the spec is not moved back. Only
309
+ // one command may own that state, so derive reports it and writes nothing.
310
+ // Every released pin the spec declares is checked, so a conflicting pair
311
+ // (canonical ahead, alias behind) cannot slip a lowering past the rule.
312
+ const declaredAhead = sdkTargets.map((row) => row.before).filter((value) => isReleasedSdkVersion(value) && compareReleasedSdkVersions(value, observed) > 0);
313
+ if (declaredAhead.length) {
314
+ notDerived.push({ field: "global_config.sdk_version", reason: "spec_ahead", detail: `the spec pin ${declaredAhead[0]} is ahead of the target pin ${observed}; doctor blocks on that state and page-kit sync is its repair (it moves the repo forward, and never moves a configured campaign's pin backwards). Run page-kit sync, or lower the spec pin by hand if ${observed} is what should ship, then derive again.` });
315
+ } else {
316
+ for (const row of sdkTargets) {
317
+ const change = { field: row.field, path: row.path, before: row.before, after: observed, source: sdkSource };
318
+ const problem = containerProblem(spec, row.path);
319
+ if (problem) notDerived.push({ field: row.field, reason: "spec_container_invalid", detail: `the spec's ${problem} is not an object, so ${row.field} cannot be written; repair the spec, then derive again.` });
320
+ else if (row.before === observed) unchanged.push(change);
321
+ else changes.push(change);
322
+ }
323
+ }
324
+ }
325
+
326
+ // Page routes: page-kit routes by source filename (or permalink), so the
327
+ // tree is the authority for `page_url`.
328
+ const activePages = activePagesWithPaths(spec);
329
+ // A page id that appears twice cannot bind one route: doctor's
330
+ // PageIdUniqueness rule blocks the spec, and derive names it too rather
331
+ // than let the last binding win.
332
+ const idCounts = new Map();
333
+ for (const { page } of activePages) idCounts.set(page.id, (idCounts.get(page.id) || 0) + 1);
334
+ // The route every bound page derives to, changed or not: the standing
335
+ // check on routing hints reads it, so a hint left stale by an earlier run
336
+ // keeps surfacing until the Map is re-saved.
337
+ const derivedRouteByPageId = new Map();
338
+ if (pageFiles === null) {
339
+ for (const { page, path } of activePages) {
340
+ notDerived.push({ field: `${pathLabel(path)}.page_url`, page_id: page.id, reason: "page_tree_missing", detail: `the campaign's page tree does not exist in the target repo, so no route can be derived for page "${page.id}". Scaffold and build the campaign first, then derive.` });
341
+ }
342
+ } else {
343
+ const mirrorIndex = new Map();
344
+ if (Array.isArray(spec?.funnels) && Array.isArray(spec?.funnel_pages)) {
345
+ spec.funnel_pages.forEach((page, index) => {
346
+ if (isPlainObject(page) && isNonEmptyString(page.id) && !mirrorIndex.has(page.id)) mirrorIndex.set(page.id, index);
347
+ });
348
+ }
349
+ for (const { page, path } of activePages) {
350
+ const field = `${pathLabel(path)}.page_url`;
351
+ if (idCounts.get(page.id) > 1) {
352
+ notDerived.push({ field, page_id: page.id, reason: "page_id_duplicate", detail: `page id "${page.id}" appears more than once in the spec, so no single route can be derived for it; make page ids unique, then derive again.` });
353
+ continue;
354
+ }
355
+ const binding = bindPageFile(page, pageFiles, packetBindings);
356
+ if (!binding.file) {
357
+ notDerived.push(binding.via === "ambiguous"
358
+ ? { field, page_id: page.id, reason: "page_file_ambiguous", detail: `more than one page file could be page "${page.id}" (${binding.candidates.join(", ")}); add a permalink or rename so one file carries the route, then derive again.` }
359
+ : { field, page_id: page.id, reason: "page_file_not_found", detail: `no page file in the page tree binds to page "${page.id}" (by the packet's page-kit projection, the page's current route ${JSON.stringify(publicRouteForPage(page))}, or a file named after the page id); the page has not been built, or was renamed past recognition. Build it, or name the file after the page, then derive again.` });
360
+ continue;
361
+ }
362
+ const after = binding.file.route;
363
+ const before = Object.hasOwn(page, "page_url") ? page.page_url : undefined;
364
+ const source = `${binding.file.path}${binding.file.permalink ? " (permalink)" : ""}`;
365
+ const problem = binding.file.problem || derivedRouteProblem(after);
366
+ if (problem) {
367
+ notDerived.push({ field, page_id: page.id, reason: "target_invalid", detail: `the permalink ${source} states for page "${page.id}" (${quoteValue(binding.file.permalink ?? after)}) ${problem}; fix the file's permalink, then derive again.` });
368
+ continue;
369
+ }
370
+ const row = { field, page_id: page.id, path: [...path, "page_url"], before, after, source };
371
+ // The entry route is the empty string, and doctor honours an empty
372
+ // page_url only on a page flagged is_entry (publicRouteForPage falls
373
+ // back to the type's default route otherwise). Writing "" onto any
374
+ // other page would move it, in doctor's eyes, to a route that does not
375
+ // exist; the flag is authored in the Map, so it is asked for instead.
376
+ if (after === "" && page.is_entry !== true) {
377
+ notDerived.push({ field, page_id: page.id, reason: "entry_route_undeclared", detail: `page "${page.id}" binds to ${source}, the entry route, but the page is not flagged is_entry; doctor reads an empty page_url only on the entry page. Flag it in the Map (or give the file a permalink), then derive again.` });
378
+ continue;
379
+ }
380
+ derivedRouteByPageId.set(page.id, after);
381
+ const containerIssue = containerProblem(spec, row.path);
382
+ if (containerIssue) {
383
+ notDerived.push({ field, page_id: page.id, reason: "spec_container_invalid", detail: `the spec's ${containerIssue} is not an object, so ${field} cannot be written; repair the spec, then derive again.` });
384
+ continue;
385
+ }
386
+ // Compared the way prepare-build projects a route (normalized, slug
387
+ // prefix stripped): a value that differs only in spelling
388
+ // ("/slug/checkout/", "checkout") is the same route and is not
389
+ // rewritten; a value nested differently from the tree is not.
390
+ const sameRoute = typeof before === "string"
391
+ && stripPublicRoutePrefix(normalizePageKitRoute(before), publicRouteSlug) === after
392
+ && (before.trim() !== "" || page.is_entry === true);
393
+ if (sameRoute) unchanged.push(row);
394
+ else changes.push(row);
395
+ // The legacy mirror is reconciled for every derived page, changed or
396
+ // not: a mirror left stale by an earlier edit would otherwise stay so.
397
+ const mirrorAt = mirrorIndex.get(page.id);
398
+ if (mirrorAt !== undefined) {
399
+ const mirror = spec.funnel_pages[mirrorAt];
400
+ const mirrorBefore = Object.hasOwn(mirror, "page_url") ? mirror.page_url : undefined;
401
+ const mirrorSame = typeof mirrorBefore === "string"
402
+ && stripPublicRoutePrefix(normalizePageKitRoute(mirrorBefore), publicRouteSlug) === after
403
+ && (mirrorBefore.trim() !== "" || page.is_entry === true);
404
+ if (!mirrorSame) {
405
+ changes.push({ field: `funnel_pages[${mirrorAt}].page_url`, page_id: page.id, path: ["funnel_pages", mirrorAt, "page_url"], before: mirrorBefore, after, source: `mirror of ${field}` });
406
+ }
407
+ }
408
+ }
409
+ // A routing hint that names a page whose derived route it does not match
410
+ // is stale, whether the route moved in this run or an earlier one. Hints
411
+ // are a spec projection the editor regenerates (doctor reads them as
412
+ // build expectations), so they are reported, never rewritten.
413
+ for (const { page } of activePages) {
414
+ const metaTags = page.sdk_hints?.meta_tags;
415
+ if (!isPlainObject(metaTags)) continue;
416
+ for (const [tag, routingField] of Object.entries(ROUTING_HINT_FIELDS)) {
417
+ const value = metaTags[tag];
418
+ const targetId = page[routingField];
419
+ if (!isNonEmptyString(value) || !isNonEmptyString(targetId) || !derivedRouteByPageId.has(targetId)) continue;
420
+ const derived = derivedRouteByPageId.get(targetId);
421
+ if (runtimeRelativeRouteForSpecValue(value, publicRouteSlug) === runtimeRelativeRouteForSpecValue(derived, publicRouteSlug)) continue;
422
+ staleHints.push({ page_id: page.id, tag, value, target_page_id: targetId, derived_route: derived });
423
+ }
424
+ }
425
+ }
426
+
427
+ // Analytics ids the entry carries. A usable id is written into the provider
428
+ // block (created when the spec lacks it). An empty repo value never deletes
429
+ // a spec id: the mismatch is reported so someone decides which side is
430
+ // wrong.
431
+ for (const { key, provider, property, shape, describe } of ANALYTICS_ID_FIELDS) {
432
+ const field = `analytics.providers.${provider}.${property}`;
433
+ const raw = Object.hasOwn(target, key) ? target[key] : undefined;
434
+ const current = spec?.analytics?.providers?.[provider]?.[property];
435
+ const source = entrySource(key);
436
+ if (raw === undefined || raw === null || (typeof raw === "string" && !raw.trim())) {
437
+ if (isNonEmptyString(current)) {
438
+ notDerived.push({ field, reason: "target_empty", detail: `the target entry's ${key} is empty but the spec declares ${field} ${quoteValue(current)}; add the id to ${source} if the funnel should carry it, or remove it from the spec. Nothing was written.` });
439
+ } else notInTarget.push(field);
440
+ continue;
441
+ }
442
+ if (typeof raw !== "string" || hasControlCharacters(raw) || !shape.test(raw.trim())) {
443
+ notDerived.push({ field, reason: "target_invalid", detail: `the target entry's ${key} ${typeof raw === "string" ? quoteValue(raw) : `is ${Array.isArray(raw) ? "an array" : `a ${typeof raw}`}`} is not ${describe}; correct ${source}, then derive again.` });
444
+ continue;
445
+ }
446
+ const after = raw.trim();
447
+ if (isPlaceholderId(after)) {
448
+ notDerived.push({ field, reason: "target_invalid", detail: `the target entry's ${key} ${quoteValue(after)} is a placeholder, not a real id; writing it would declare an analytics contract QA then blocks on. Replace it in ${source} (or clear it), then derive again.` });
449
+ continue;
450
+ }
451
+ const row = { field, path: ["analytics", "providers", provider, property], before: current, after, source };
452
+ const problem = containerProblem(spec, row.path);
453
+ if (problem) notDerived.push({ field, reason: "spec_container_invalid", detail: `the spec's ${problem} is not an object, so ${field} cannot be written; repair the spec, then derive again.` });
454
+ else if (current === after) unchanged.push(row);
455
+ else changes.push(row);
456
+ }
457
+
458
+ // A provider block derive will create is a new analytics contract: QA
459
+ // stops treating analytics as advisory and expects that tag to fire.
460
+ const createdBlocks = ANALYTICS_ID_FIELDS
461
+ .filter(({ provider, property }) => changes.some((row) => row.field === `analytics.providers.${provider}.${property}`) && !isPlainObject(spec?.analytics?.providers?.[provider]))
462
+ .map(({ provider }) => `analytics.providers.${provider}`);
463
+
464
+ return { changes, unchanged, not_derived: notDerived, not_in_target: notInTarget, stale_hints: staleHints, created_blocks: [...new Set(createdBlocks)] };
465
+ }
466
+
467
+ function pathMatchesPattern(path, pattern) {
468
+ const expected = pattern.split(".").flatMap((segment) => (segment.endsWith("[]") ? [segment.slice(0, -2), "[]"] : [segment]));
469
+ if (expected.length !== path.length) return false;
470
+ return expected.every((segment, index) => (segment === "[]" ? Number.isInteger(path[index]) : path[index] === segment));
471
+ }
472
+
473
+ // Apply a plan to the parsed spec. Mutates ONLY the derived field each change
474
+ // names, in place, so key order and every other key survive; a missing
475
+ // container object is created (`global_config`, `analytics.providers.gtm`
476
+ // as `{ enabled: true }`), a missing array element is never invented. Returns
477
+ // the same spec object.
478
+ export function applySpecDerive(spec, plan) {
479
+ if (!isPlainObject(spec)) throw new Error("CampaignSpec must be a JSON object.");
480
+ for (const change of plan.changes) {
481
+ const path = Array.isArray(change.path) ? change.path : [];
482
+ if (!SPEC_DERIVE_FIELDS.some((pattern) => pathMatchesPattern(path, pattern))) {
483
+ throw new Error(`Refusing to write "${change.field}": spec derive governs only ${SPEC_DERIVE_FIELDS.join(", ")}.`);
484
+ }
485
+ let node = spec;
486
+ for (let index = 0; index < path.length - 1; index += 1) {
487
+ const segment = path[index];
488
+ if (Number.isInteger(segment)) {
489
+ if (!Array.isArray(node) || !isPlainObject(node[segment])) throw new Error(`Refusing to write "${change.field}": ${pathLabel(path.slice(0, index + 1))} is not an object in the spec.`);
490
+ node = node[segment];
491
+ continue;
492
+ }
493
+ const nextIsIndex = Number.isInteger(path[index + 1]);
494
+ if (node[segment] === undefined || node[segment] === null) {
495
+ if (nextIsIndex) throw new Error(`Refusing to write "${change.field}": ${pathLabel(path.slice(0, index + 1))} is not an array in the spec.`);
496
+ // A provider block created by derive is enabled: an id the repo
497
+ // carries is a tag the funnel fires, which is what QA then expects.
498
+ node[segment] = path[index - 1] === "providers" ? { enabled: true } : {};
499
+ } else if (nextIsIndex ? !Array.isArray(node[segment]) : !isPlainObject(node[segment])) {
500
+ throw new Error(`Refusing to write "${change.field}": ${pathLabel(path.slice(0, index + 1))} is not ${nextIsIndex ? "an array" : "an object"} in the spec.`);
501
+ }
502
+ node = node[segment];
503
+ }
504
+ node[path[path.length - 1]] = change.after;
505
+ }
506
+ return spec;
507
+ }
508
+
509
+ // One line per field for the printed diff. `undefined` (the spec had no such
510
+ // key) prints as (absent); everything else is JSON so a string that merely
511
+ // looks empty is visibly quoted.
512
+ export function formatDeriveValue(value) {
513
+ return value === undefined ? "(absent)" : JSON.stringify(value);
514
+ }
@@ -0,0 +1,66 @@
1
+ // The one fetch of a CampaignSpec by Map ID. `start`/`prepare-build --map-id`
2
+ // and packet-less `qa run --map-id` both read the same endpoint and must apply
3
+ // the same response rules; a second, more lenient reading once lived in the
4
+ // QA runner (which cannot import the CLI) and returned an `{ ok: false }`
5
+ // error body as the spec. A leaf: no imports.
6
+
7
+ export const DEFAULT_PROXY_BASE = "https://campaign-map.nextcommerce.com";
8
+
9
+ /**
10
+ * Fetch a CampaignSpec by Map ID from the proxy Worker.
11
+ *
12
+ * The Map Builder portal at campaign-map.nextcommerce.com is fronted by
13
+ * a backend service that persists saved specs and exposes them via
14
+ * GET /api/spec/<map-id>. Response shape is
15
+ * { ok: true, data: <spec> } or { ok: false, error: <message> } on a
16
+ * 200 with a logical failure.
17
+ *
18
+ * `fetchImpl` is parameterized for tests so a local mock server can
19
+ * stand in for the deployed Worker.
20
+ *
21
+ * @param {string} mapId — saved Map Builder identity (e.g. "veyra-v1-knp4")
22
+ * @param {object} [opts]
23
+ * @param {string} [opts.proxyBase] — proxy origin without trailing slash
24
+ * @param {Function} [opts.fetchImpl] — fetch shim for testing
25
+ * @returns {Promise<object>} parsed CampaignSpec
26
+ */
27
+ export async function fetchSpecByMapId(mapId, opts = {}) {
28
+ const trimmed = String(mapId || "").trim();
29
+ if (!trimmed) throw new Error("fetchSpecByMapId: mapId is required.");
30
+ const base = (opts.proxyBase || DEFAULT_PROXY_BASE).replace(/\/+$/, "");
31
+ const url = `${base}/api/spec/${encodeURIComponent(trimmed)}`;
32
+ const fetchImpl = opts.fetchImpl || globalThis.fetch;
33
+ if (typeof fetchImpl !== "function") {
34
+ throw new Error("Global fetch is not available. Upgrade to Node 18+ or pass fetchImpl.");
35
+ }
36
+ let res;
37
+ try {
38
+ res = await fetchImpl(url, { headers: { Accept: "application/json" } });
39
+ } catch (error) {
40
+ throw specFetchError(`Spec fetch network error: ${error.message} (${url})`, { kind: "network" });
41
+ }
42
+ if (!res.ok) {
43
+ throw specFetchError(`Spec fetch failed: ${res.status} ${res.statusText} (${url})`, { kind: "http", status: res.status });
44
+ }
45
+ let body;
46
+ try {
47
+ body = await res.json();
48
+ } catch (error) {
49
+ throw specFetchError(`Spec fetch returned invalid JSON: ${error.message} (${url})`, { kind: "invalid_json", status: res.status });
50
+ }
51
+ if (!body || body.ok === false || body.data == null) {
52
+ throw specFetchError(`Spec fetch returned ok=false: ${body?.error || "unknown error"} (${url})`, { kind: "not_ok", status: res.status });
53
+ }
54
+ return body.data;
55
+ }
56
+
57
+ // Every refusal carries what happened as data beside the prose: `kind`
58
+ // (network | http | invalid_json | not_ok) and, once a response arrived, its
59
+ // HTTP `status`. A caller that must route on a 404 reads the field, never the
60
+ // message.
61
+ function specFetchError(message, { kind, status = null }) {
62
+ const error = new Error(message);
63
+ error.kind = kind;
64
+ error.status = status;
65
+ return error;
66
+ }
@@ -0,0 +1,48 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Spec-hash identity comparison.
3
+ //
4
+ // A spec hash reaches a comparison from several producers (the saved Map, a
5
+ // Build Context, an Assembly Report, a stored QA verdict, a pricing
6
+ // calculation envelope) and they do not all spell it the same way: some carry
7
+ // the `sha256:` prefix, some do not; some upper-case the hex; some carry
8
+ // surrounding whitespace. A raw string compare turns those spellings into a
9
+ // false "stale" or "mismatch", and a consumer that normalises differently can
10
+ // disagree with this toolkit about whether the same verdict matches the same
11
+ // spec. Every "is this the same spec?" check goes through these three
12
+ // functions so the answer is the same everywhere.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ /**
16
+ * Canonical form of a spec hash: trimmed, lower-cased, with one leading
17
+ * `sha256:` removed. Returns null for any non-string, for empty/whitespace-only
18
+ * input and for a bare prefix with nothing after it.
19
+ */
20
+ export function normalizeSpecHash(value) {
21
+ // Only a string can carry a hash. Numbers, booleans, NaN, objects and
22
+ // arrays all stringify to something (`"nan"`, `"[object Object]"`) that two
23
+ // unrelated malformed documents would share, so they never normalise.
24
+ if (typeof value !== "string") return null;
25
+ const text = value.trim().toLowerCase().replace(/^sha256:/, "").trim();
26
+ return text || null;
27
+ }
28
+
29
+ /**
30
+ * True only when both sides normalise to a non-null value and those values
31
+ * are equal. Two missing hashes are NOT a match: absence carries no identity,
32
+ * and treating it as agreement would let an unhashed artifact pass as the
33
+ * same spec.
34
+ */
35
+ export function specHashesMatch(left, right) {
36
+ const a = normalizeSpecHash(left);
37
+ const b = normalizeSpecHash(right);
38
+ return a != null && b != null && a === b;
39
+ }
40
+
41
+ /**
42
+ * The spec hash a Map / CampaignSpec document carries, in either of the two
43
+ * places producers put it: a top-level `spec_hash`, or `spec_identity.spec_hash`.
44
+ * Returns the raw stored value (not normalised) or null.
45
+ */
46
+ export function specHashOf(doc) {
47
+ return doc?.spec_hash ?? doc?.spec_identity?.spec_hash ?? null;
48
+ }
@@ -0,0 +1,27 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ const VOLATILE_TOP_LEVEL_FIELDS = new Set(["spec_identity", "slug", "map_id", "saved_at"]);
4
+
5
+ function canonicalJson(value) {
6
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
7
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
8
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
9
+ }
10
+
11
+ function materialSpec(spec) {
12
+ if (!spec || typeof spec !== "object" || Array.isArray(spec)) return spec;
13
+ return Object.fromEntries(
14
+ Object.entries(spec).filter(([key]) => !VOLATILE_TOP_LEVEL_FIELDS.has(key)),
15
+ );
16
+ }
17
+
18
+ export function specMaterialHash(spec) {
19
+ return `sha256:${createHash("sha256").update(canonicalJson(materialSpec(spec))).digest("hex")}`;
20
+ }
21
+
22
+ // The three pure comparators live in `./spec-hash.mjs`, a dependency-free
23
+ // leaf, so that browser-targeted consumers of the public
24
+ // `./commercial-journey` export can reach them without pulling this module's
25
+ // `node:crypto` import into their bundle. They are re-exported here so every
26
+ // existing importer of `./spec-identity.mjs` keeps working unchanged.
27
+ export { normalizeSpecHash, specHashesMatch, specHashOf } from "./spec-hash.mjs";