@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,473 @@
1
+ // Template brand contracts: per-family declarations of required brand-token
2
+ // overrides, starter-default residue that must not ship, CSS load-order rules,
3
+ // and the selectors QA inspects to prove the brand layer applied.
4
+ //
5
+ // Contract files live at contracts/template-brand-contract.<family>.v0.json.
6
+ // Family contracts may `extends` a shared contract file in the same directory;
7
+ // arrays and scalar values replace parent values, object values merge.
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
13
+
14
+ export const TEMPLATE_BRAND_CONTRACT_SCHEMA = "template-brand-contract/v0";
15
+
16
+ export function templateBrandContractPath(family) {
17
+ if (typeof family !== "string" || !family.trim()) return null;
18
+ return join(ROOT, "contracts", `template-brand-contract.${family.trim()}.v0.json`);
19
+ }
20
+
21
+ export function loadTemplateBrandContract(family) {
22
+ const path = templateBrandContractPath(family);
23
+ if (!path || !existsSync(path)) return null;
24
+ const contract = loadTemplateBrandContractFile(path);
25
+ if (contract.family !== family) {
26
+ throw templateBrandContractError("family_mismatch", `Template brand contract ${path} declares family "${contract.family}"; expected "${family}".`);
27
+ }
28
+ return contract;
29
+ }
30
+
31
+ function loadTemplateBrandContractFile(path, seen = new Set()) {
32
+ let contract = null;
33
+ try {
34
+ contract = JSON.parse(readFileSync(path, "utf8"));
35
+ } catch (error) {
36
+ throw templateBrandContractError(
37
+ "parse_error",
38
+ `Template brand contract ${path} failed to parse: ${error instanceof Error ? error.message : String(error)}.`,
39
+ error,
40
+ );
41
+ }
42
+ return resolveContractExtendsChain(contract, { dir: dirname(path), label: path, seen });
43
+ }
44
+
45
+ // Walks the `extends` chain starting from an already-parsed contract object,
46
+ // resolving each `extends` filename against `dir`. Shared by the on-disk
47
+ // loader above and by privately-sourced contract fragments (fetched from a
48
+ // third-party repo, not read from a file at `dir`) that still need to extend
49
+ // a shared file living in this repo's own contracts/ directory — callers
50
+ // pass that directory in as `dir` regardless of where the leaf contract
51
+ // itself came from. Exported so private-template-source.mjs reuses this
52
+ // instead of re-implementing cycle-detection/merge.
53
+ export function resolveContractExtendsChain(contract, { dir, label = dir, seen = new Set() } = {}) {
54
+ if (seen.has(label)) throw templateBrandContractError("extends_cycle", `Template brand contract extends cycle at ${label}.`);
55
+ // The outermost call sees the fully merged contract; validation that spans
56
+ // parent and child fields (a family adding a chrome asset the shared hash
57
+ // map does not cover) has to run there, not per file.
58
+ const outermost = seen.size === 0;
59
+ seen.add(label);
60
+ if (!isPlainObject(contract) || contract.schema_version !== TEMPLATE_BRAND_CONTRACT_SCHEMA) {
61
+ throw templateBrandContractError(
62
+ "schema_mismatch",
63
+ `Template brand contract ${label} has schema_version "${contract?.schema_version}"; expected "${TEMPLATE_BRAND_CONTRACT_SCHEMA}".`,
64
+ );
65
+ }
66
+ const parentRef = typeof contract.extends === "string" && contract.extends.trim() ? contract.extends.trim() : null;
67
+ let resolved = contract;
68
+ if (parentRef) {
69
+ const parentPath = join(dir, parentRef);
70
+ if (!existsSync(parentPath)) {
71
+ throw templateBrandContractError("extends_missing_parent", `Template brand contract ${label} extends missing file "${parentRef}".`);
72
+ }
73
+ resolved = mergeContractObjects(loadTemplateBrandContractFile(parentPath, seen), contract);
74
+ delete resolved.extends;
75
+ }
76
+ if (outermost) paymentChromeAssetHashes(resolved.default_residue?.payment_chrome, { label });
77
+ return resolved;
78
+ }
79
+
80
+ function templateBrandContractError(code, message, cause = undefined) {
81
+ const error = new Error(message, cause ? { cause } : undefined);
82
+ error.code = code;
83
+ return error;
84
+ }
85
+
86
+ function mergeContractObjects(parent, child) {
87
+ const merged = { ...parent };
88
+ for (const [key, value] of Object.entries(child)) {
89
+ if (isPlainObject(value) && isPlainObject(parent?.[key])) {
90
+ merged[key] = mergeContractObjects(parent[key], value);
91
+ } else {
92
+ merged[key] = value;
93
+ }
94
+ }
95
+ return merged;
96
+ }
97
+
98
+ function isPlainObject(value) {
99
+ return value !== null && typeof value === "object" && !Array.isArray(value);
100
+ }
101
+
102
+ // Normalize a CSS color to a comparable form. Computed styles come back as
103
+ // rgb()/rgba(); contracts declare hex. Compare in rgb space.
104
+ export function normalizeCssColor(value) {
105
+ if (typeof value !== "string") return null;
106
+ const v = value.trim().toLowerCase();
107
+ const hex = v.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/);
108
+ if (hex) {
109
+ let h = hex[1];
110
+ if (h.length === 3) h = h.split("").map((c) => c + c).join("");
111
+ const n = parseInt(h, 16);
112
+ return `rgb(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255})`;
113
+ }
114
+ const rgb = v.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)$/);
115
+ if (rgb) {
116
+ // Near-invisible alpha is not a shipped color (and an alpha hack to dodge
117
+ // the forbidden-palette check, e.g. rgba(60,125,255,0.01), should read as
118
+ // "no visible color" — which residue checks treat as suspicious anyway).
119
+ // Above the threshold the alpha is dropped: a half-transparent starter
120
+ // blue still ships the starter palette.
121
+ if (rgb[4] !== undefined && Number(rgb[4]) < 0.1) return null;
122
+ return `rgb(${rgb[1]}, ${rgb[2]}, ${rgb[3]})`;
123
+ }
124
+ return null;
125
+ }
126
+
127
+ // Forbidden computed colors for a family, normalized to rgb. Used by browser
128
+ // QA to fail commerce pages that still render the starter palette.
129
+ export function forbiddenComputedColors(contract) {
130
+ const entries = contract?.qa_inspection?.forbidden_computed_colors;
131
+ if (!Array.isArray(entries)) return [];
132
+ return entries
133
+ .map((entry) => ({
134
+ token: entry.token || null,
135
+ hex: entry.hex || null,
136
+ rgb: normalizeCssColor(entry.rgb || entry.hex),
137
+ }))
138
+ .filter((entry) => entry.rgb);
139
+ }
140
+
141
+ // The page types template-residue inspection runs against at all. Lives here
142
+ // rather than in the browser runner so a consumer that only wants to know
143
+ // WHETHER residue checks apply does not have to import the runner.
144
+ export const RESIDUE_PAGE_TYPES = ["checkout", "select", "upsell", "downsell", "receipt"];
145
+
146
+ /**
147
+ * The computed-style (palette) residue checks that will actually run for one
148
+ * contract and page type — the single predicate behind "will QA block this
149
+ * campaign on the starter palette?".
150
+ *
151
+ * Both conditions are load-bearing and neither implies the other: a contract
152
+ * can list forbidden colors with no selector to inspect them on, and it can
153
+ * list selectors with no forbidden color to compare against. Either way the
154
+ * run produces no palette assertion, so anything that warns about one must ask
155
+ * this, not "does a contract exist".
156
+ */
157
+ export function paletteResidueStyleChecks(contract, pageType) {
158
+ if (!forbiddenComputedColors(contract).length) return [];
159
+ return styleChecksForPageType(contract, pageType);
160
+ }
161
+
162
+ // The selector half, split out so a caller asking about every page type
163
+ // normalizes the forbidden-color list once instead of per type.
164
+ function styleChecksForPageType(contract, pageType) {
165
+ const type = String(pageType || "").toLowerCase();
166
+ if (!contract || !RESIDUE_PAGE_TYPES.includes(type)) return [];
167
+ return (contract.qa_inspection?.computed_style_checks || [])
168
+ .filter((check) => (check.page_types || []).includes(type));
169
+ }
170
+
171
+ /**
172
+ * Does this contract produce palette-residue checks on ANY commerce page type?
173
+ *
174
+ * A family outside the certified set — `custom`, `undecided`, or any family the
175
+ * catalog does not carry a contract for — resolves to no contract at all, so
176
+ * browser QA emits no `template-residue:*:style:*` rows for it and there is no
177
+ * starter palette to block on. Warning such an operator that QA will block, and
178
+ * recommending a waiver or a brand-layer rewrite to clear a block that will
179
+ * never happen, is worse than saying nothing.
180
+ */
181
+ export function contractHasPaletteResidueChecks(contract) {
182
+ // Normalize the forbidden colors once for the whole sweep: they do not vary
183
+ // by page type, and normalizing a color list five times to answer one
184
+ // question is work nobody asked for.
185
+ if (!forbiddenComputedColors(contract).length) return false;
186
+ return RESIDUE_PAGE_TYPES.some((pageType) => styleChecksForPageType(contract, pageType).length > 0);
187
+ }
188
+
189
+ function escapeContractRegExp(value) {
190
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
191
+ }
192
+
193
+ function normalizePageTypes(value) {
194
+ return Array.isArray(value) ? value.map((entry) => String(entry).toLowerCase()) : null;
195
+ }
196
+
197
+ // Placeholder text-residue contract (H3.1): literal template copy that must
198
+ // never survive into rendered output (Lorem / Placeholder / TODO / Product
199
+ // Name ...). Mirrors the forbidden-computed-colors model — data lives in the
200
+ // contract (shared-commerce, inherited by every family), matchers compile in
201
+ // code — so the gate is data-driven and a family can override the term set.
202
+ export function placeholderTextResidueConfig(contract) {
203
+ const cfg = contract?.qa_inspection?.placeholder_text_residue;
204
+ if (!isPlainObject(cfg)) return null;
205
+ const terms = Array.isArray(cfg.terms)
206
+ // Dedupe so a contract that lists a term twice does not double-count the
207
+ // same occurrence in evidence / summarizePlaceholderTerms.
208
+ ? [...new Set(cfg.terms.map((term) => String(term)).filter((term) => term.trim()))]
209
+ : [];
210
+ if (!terms.length) return null;
211
+ return {
212
+ terms,
213
+ pageTypes: normalizePageTypes(cfg.page_types),
214
+ rule: typeof cfg.rule === "string" ? cfg.rule : null,
215
+ };
216
+ }
217
+
218
+ // Pure: every literal placeholder-term occurrence in `text`. Word-boundary,
219
+ // case-insensitive; multi-word terms keep flexible internal whitespace so a
220
+ // reflowed "Product Name" still matches. Boundaries only apply where the
221
+ // term itself starts/ends with a word char, so "lorem ipsum" still anchors.
222
+ export function placeholderTextResidueMatches(text, terms) {
223
+ if (typeof text !== "string" || !text || !Array.isArray(terms) || !terms.length) return [];
224
+ const matches = [];
225
+ for (const term of terms) {
226
+ const raw = String(term || "").trim();
227
+ if (!raw) continue;
228
+ const body = escapeContractRegExp(raw).replace(/\s+/g, "\\s+");
229
+ const startBoundary = /^\w/.test(raw) ? "\\b" : "";
230
+ const endBoundary = /\w$/.test(raw) ? "\\b" : "";
231
+ const regex = new RegExp(`${startBoundary}${body}${endBoundary}`, "gi");
232
+ for (const match of text.matchAll(regex)) {
233
+ matches.push({ term: raw, match: match[0], index: match.index ?? 0 });
234
+ }
235
+ }
236
+ return matches.sort((a, b) => a.index - b.index);
237
+ }
238
+
239
+ // The distinct placeholder terms found, in first-seen order, for verdict copy.
240
+ export function summarizePlaceholderTerms(matches) {
241
+ const seen = [];
242
+ for (const match of matches || []) {
243
+ if (!seen.includes(match.term)) seen.push(match.term);
244
+ }
245
+ return seen;
246
+ }
247
+
248
+ // Demo-asset fidelity contract (H3.2): the template's own demo placeholder
249
+ // assets (1x1 spacers, starter imagery) that should be re-skinned. Data-driven
250
+ // per family so the flag is declarative, not hardcoded: `demo_assets.assets`
251
+ // is the whole vocabulary, and a contract with no assets declares no check.
252
+ export function demoAssetConfig(contract) {
253
+ const cfg = contract?.demo_assets;
254
+ if (!isPlainObject(cfg)) return null;
255
+ const assets = Array.isArray(cfg.assets)
256
+ ? cfg.assets.map((asset) => String(asset)).filter((asset) => asset.trim())
257
+ : [];
258
+ if (!assets.length) return null;
259
+ return {
260
+ assets,
261
+ assetBasenames: [...new Set(assets.map((asset) => asset.split("/").pop()).filter(Boolean))],
262
+ pageTypes: normalizePageTypes(cfg.page_types),
263
+ rule: typeof cfg.rule === "string" ? cfg.rule : null,
264
+ };
265
+ }
266
+
267
+ const SIMPLE_CLASS_SELECTOR = /^\.([A-Za-z0-9_-]+)$/;
268
+
269
+ // Selectors/assets belonging to one payment method under the contract's
270
+ // default_residue.payment_chrome, plus shared chrome assets (those naming no
271
+ // contract method, e.g. upsell-payment-logos.svg) which count as implied residue
272
+ // for any unsupported method per the contract rule. One partition for both
273
+ // consumers: browser QA (visible selectors + fetched assets) and doctor's static
274
+ // scan of the built checkout.
275
+ export function paymentChromeArtifacts(chrome, method) {
276
+ const compact = (value) => String(value || "").toLowerCase().replace(/[\s_-]+/g, "");
277
+ const token = compact(method);
278
+ const methodTokens = (chrome?.methods || []).map(compact).filter(Boolean);
279
+ const selectors = (chrome?.selectors || []).filter((selector) => compact(selector).includes(token));
280
+ const assets = (chrome?.assets || []).filter((asset) => {
281
+ const normalized = compact(asset);
282
+ if (normalized.includes(token)) return true;
283
+ return !methodTokens.some((candidate) => normalized.includes(candidate));
284
+ });
285
+ return { selectors, assets };
286
+ }
287
+
288
+ // The shipped bytes of each chrome asset, by basename: `payment_chrome.asset_sha256`
289
+ // keyed by the same path `assets[]` lists, lower-cased hex. Browser QA hashes the
290
+ // bytes a page actually serves against this map, which is what tells an untouched
291
+ // starter strip (residue, whatever its markup says) from one edited in place
292
+ // (manual review).
293
+ //
294
+ // Strict, and run at contract load: a hash that is not 64 hex chars, or a
295
+ // listed asset the map does not cover, throws with the asset named. A dropped
296
+ // or missing hash would send that asset back to the markup token match — the
297
+ // path this map exists to close — and a contract author would learn of the
298
+ // typo only from a deployed strip reading as edited. A contract with no
299
+ // `asset_sha256` at all declares no hashes (every asset uses the token match),
300
+ // which keeps a privately-sourced contract that predates the field loadable.
301
+ export function paymentChromeAssetHashes(chrome, { label = "template brand contract" } = {}) {
302
+ const byBasename = new Map();
303
+ if (!isPlainObject(chrome) || chrome.asset_sha256 === undefined) return byBasename;
304
+ if (!isPlainObject(chrome.asset_sha256)) {
305
+ throw templateBrandContractError("payment_chrome_hash_invalid", `${label}: default_residue.payment_chrome.asset_sha256 must be an object keyed by asset path.`);
306
+ }
307
+ for (const [asset, digest] of Object.entries(chrome.asset_sha256)) {
308
+ const basename = String(asset || "").split("/").pop();
309
+ const hex = typeof digest === "string" ? digest.trim().toLowerCase() : "";
310
+ if (!basename || !/^[0-9a-f]{64}$/.test(hex)) {
311
+ throw templateBrandContractError(
312
+ "payment_chrome_hash_invalid",
313
+ `${label}: default_residue.payment_chrome.asset_sha256["${asset}"] is not a 64-char hex sha256 (${JSON.stringify(digest)}).`,
314
+ );
315
+ }
316
+ byBasename.set(basename, hex);
317
+ }
318
+ for (const asset of Array.isArray(chrome.assets) ? chrome.assets : []) {
319
+ const basename = String(asset || "").split("/").pop();
320
+ if (basename && !byBasename.has(basename)) {
321
+ throw templateBrandContractError(
322
+ "payment_chrome_hash_missing",
323
+ `${label}: default_residue.payment_chrome.assets lists "${asset}" with no asset_sha256 entry; record the sha256 of the shipped file or drop the asset.`,
324
+ );
325
+ }
326
+ }
327
+ return byBasename;
328
+ }
329
+
330
+ // Pure, static: the markers in rendered checkout HTML that say a payment method
331
+ // shipped. Three sources, in order of authority: the SDK-owned
332
+ // data-next-payment-method attribute every starter-template payment-methods
333
+ // include renders per method (underscore spelling canonical, legacy hyphen
334
+ // accepted); the contract's payment_chrome class selectors for the method
335
+ // (simple .class selectors only — a static scan cannot evaluate compound
336
+ // selectors or visibility, browser QA does that); and the method-named chrome
337
+ // assets by basename. Shared chrome assets that name no method are left to
338
+ // browser QA, which fetches them to attribute the mark; a static scan cannot
339
+ // tell a paypal strip from a card-only one by its filename.
340
+ export function paymentMethodMarkupMatches(html, method, chrome = null) {
341
+ const text = typeof html === "string" ? html : "";
342
+ const canonical = String(method || "").toLowerCase().replace(/[\s-]+/g, "_");
343
+ if (!canonical) return [];
344
+ const matches = [];
345
+ const spellings = [...new Set([canonical, canonical.replace(/_/g, "-")])].map(escapeContractRegExp).join("|");
346
+ const attribute = new RegExp(`data-next-payment-method\\s*=\\s*["'](?:${spellings})["']`, "i").exec(text);
347
+ if (attribute) matches.push(attribute[0].replace(/\s+/g, ""));
348
+ const artifacts = paymentChromeArtifacts(chrome, canonical);
349
+ for (const selector of artifacts.selectors) {
350
+ const className = SIMPLE_CLASS_SELECTOR.exec(selector)?.[1];
351
+ if (!className) continue;
352
+ if (new RegExp(`class\\s*=\\s*["'](?:[^"']*\\s)?${escapeContractRegExp(className)}(?:\\s|["'])`, "i").test(text)) matches.push(selector);
353
+ }
354
+ const compact = (value) => String(value || "").toLowerCase().replace(/[\s_-]+/g, "");
355
+ const token = compact(canonical);
356
+ for (const asset of artifacts.assets) {
357
+ if (!compact(asset).includes(token)) continue;
358
+ const basename = asset.split("/").pop();
359
+ if (basename && text.includes(basename)) matches.push(basename);
360
+ }
361
+ return [...new Set(matches)];
362
+ }
363
+
364
+ // The part of a method's payment_chrome a static HTML scan cannot attribute:
365
+ // compound selectors (need a live DOM) and shared chrome assets naming no
366
+ // method (need a fetch to attribute the mark). Browser QA covers both; doctor
367
+ // names them so "no markup found" is never read as "nothing left to check".
368
+ export function paymentMethodStaticScanGaps(chrome, method) {
369
+ const canonical = String(method || "").toLowerCase().replace(/[\s-]+/g, "_");
370
+ if (!canonical) return { compound_selectors: [], shared_assets: [] };
371
+ const artifacts = paymentChromeArtifacts(chrome, canonical);
372
+ const compact = (value) => String(value || "").toLowerCase().replace(/[\s_-]+/g, "");
373
+ const token = compact(canonical);
374
+ return {
375
+ compound_selectors: artifacts.selectors.filter((selector) => !SIMPLE_CLASS_SELECTOR.test(selector)),
376
+ shared_assets: artifacts.assets.filter((asset) => !compact(asset).includes(token)).map((asset) => asset.split("/").pop()).filter(Boolean),
377
+ };
378
+ }
379
+
380
+
381
+ // Pure: which demo-asset basenames are referenced in rendered HTML. Mirrors
382
+ // referencedAssetBasenames in qa-browser (payment-chrome residue).
383
+ export function referencedDemoAssetBasenames(html, basenames) {
384
+ const text = typeof html === "string" ? html : "";
385
+ return (basenames || []).filter((basename) => basename && text.includes(basename));
386
+ }
387
+
388
+ // Scan campaign CSS text for rules that hide pricing surfaces with
389
+ // display:none. Returns one finding per offending selector occurrence.
390
+ //
391
+ // Deliberately a brace-depth walker, not a flat regex: a flat
392
+ // `selector { decls }` regex cannot see inside `@media` / `@supports` /
393
+ // `@container` blocks, so a mobile-only price hide would bypass the scan —
394
+ // the exact escape this check exists to close. The walker recurses into
395
+ // at-rule and CSS-nesting blocks and matches targets against the full
396
+ // selector context (ancestor preludes joined with the leaf selector). It is
397
+ // still a lint, not a full CSS parser; pathological inputs (braces inside
398
+ // attribute-selector strings) may mis-scan, which is acceptable for a
399
+ // deterministic warning surface.
400
+ export function findForbiddenPriceHides(contract, cssText) {
401
+ const targets = contract?.pricing_surfaces?.forbidden_css_hides;
402
+ if (!Array.isArray(targets) || !targets.length || typeof cssText !== "string") return [];
403
+ const findings = [];
404
+ const stripped = cssText.replace(/\/\*[\s\S]*?\*\//g, "");
405
+ scanCssBlock(stripped, [], targets, findings);
406
+ return findings;
407
+ }
408
+
409
+ // Token-boundary match: the target must not be a substring of a longer CSS
410
+ // identifier, so ".summary_price" matches ".summary_price.cc-sm" but not
411
+ // ".summary_price-row", and a future ".price" target cannot blanket-match
412
+ // every ".price-*" class. CSS identifiers also allow code points >= U+0080,
413
+ // so those count as identifier characters too (".price" must not match
414
+ // inside ".priceΑ" or ".price-événement").
415
+ const CSS_IDENT_CHAR = "A-Za-z0-9_\\u0080-\\uFFFF-";
416
+
417
+ function selectorContextMatches(context, target) {
418
+ const raw = String(target);
419
+ const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
420
+ const identChar = new RegExp(`^[${CSS_IDENT_CHAR}]$`, "u");
421
+ // Boundary guards apply only where the target itself starts/ends with an
422
+ // identifier character. A target starting with "." or "[" is already
423
+ // delimited by that symbol — and the symbol may legally follow an ident
424
+ // char in compound selectors (div.price-wrapper), so a blanket lookbehind
425
+ // would miss those.
426
+ const pre = identChar.test(raw[0] || "") ? `(?<![${CSS_IDENT_CHAR}])` : "";
427
+ const post = identChar.test(raw[raw.length - 1] || "") ? `(?![${CSS_IDENT_CHAR}])` : "";
428
+ return new RegExp(`${pre}${escaped}${post}`, "u").test(context);
429
+ }
430
+
431
+ function scanCssBlock(text, contextPreludes, targets, findings) {
432
+ let index = 0;
433
+ while (index < text.length) {
434
+ const open = text.indexOf("{", index);
435
+ if (open === -1) return;
436
+ // Statement at-rules (`@import url(x);`, `@charset "utf-8";`) end with a
437
+ // semicolon and never open a block, so the next rule's prelude is the
438
+ // text AFTER the last `;` — slicing without the split would misread
439
+ // `@import x; .foo { … }` as an at-rule named "@import x; .foo".
440
+ const prelude = text.slice(index, open).split(";").pop().trim();
441
+ let depth = 1;
442
+ let cursor = open + 1;
443
+ while (cursor < text.length && depth > 0) {
444
+ if (text[cursor] === "{") depth += 1;
445
+ else if (text[cursor] === "}") depth -= 1;
446
+ cursor += 1;
447
+ }
448
+ const body = text.slice(open + 1, cursor - (depth === 0 ? 1 : 0));
449
+ if (prelude.startsWith("@")) {
450
+ // Conditional group rules (@media/@supports/@container/@layer) nest
451
+ // full rules: recurse without adding selector context. Declaration-only
452
+ // at-rules (@font-face, @page) cannot hide price rows; recursing into
453
+ // them is harmless because they contain no nested selectors.
454
+ scanCssBlock(body, contextPreludes, targets, findings);
455
+ } else {
456
+ const nestedStart = body.indexOf("{");
457
+ // Own declarations = body text outside any nested blocks (CSS nesting).
458
+ const ownDeclarations = nestedStart === -1 ? body : body.slice(0, body.lastIndexOf(";", nestedStart) + 1);
459
+ const selectorContext = [...contextPreludes, prelude].join(" ").replace(/\s+/g, " ").trim();
460
+ if (/display\s*:\s*none/i.test(ownDeclarations)) {
461
+ for (const target of targets) {
462
+ if (selectorContextMatches(selectorContext, target)) {
463
+ findings.push({ target, selector: selectorContext });
464
+ }
465
+ }
466
+ }
467
+ if (nestedStart !== -1) {
468
+ scanCssBlock(body, [...contextPreludes, prelude], targets, findings);
469
+ }
470
+ }
471
+ index = cursor;
472
+ }
473
+ }
@@ -0,0 +1,196 @@
1
+ // Template certification freshness (#263): surface, per certified family, the
2
+ // SDK version the family was last verified against and its delta from the
3
+ // current SDK — in the places an operator already looks (the certified-template
4
+ // gate output and standardization/doctor reporting).
5
+ //
6
+ // Data source discipline: everything here reads the VENDORED contract set —
7
+ // the commerce surface catalog snapshot (which the refresh script now stamps
8
+ // with per-family `verification` blocks copied from the starter repo's
9
+ // template-verification.json at the same pinned `_synced_from_sha`) and the
10
+ // vendored SDK support policy. No live fetches, no new data source.
11
+ //
12
+ // "Current SDK" definition: the newest released Campaign Cart SDK the vendored
13
+ // contracts record — the semver maximum over the SDK support policy's
14
+ // `provenance.latest_known_release` and every family verification record's
15
+ // `sdk_version` in the catalog snapshot. A verification record itself proves
16
+ // that release exists, so the definition can never call a family "ahead" of a
17
+ // release the snapshot already documents. The campaign's own pinned SDK is a
18
+ // different question (owned by the page_kit.sdk_version checkpoint) and is
19
+ // deliberately not folded into this definition.
20
+ //
21
+ // Doctrine (portal copy + docs): output must say which SDK is current and
22
+ // which SDK was last verified; an older evidence record is not current
23
+ // certification.
24
+
25
+ import { existsSync, readFileSync } from "node:fs";
26
+ import { dirname, join } from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+
29
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
30
+
31
+ export const SDK_SUPPORT_POLICY_PATH = "contracts/campaign-cart-sdk-support-policy.v0.json";
32
+
33
+ // Parse a released "major.minor.patch" SDK version. Returns null for anything
34
+ // else (pre-releases, ranges, missing values) — freshness only ever compares
35
+ // released versions, mirroring the strict pin the CampaignSpec rule enforces.
36
+ export function parseSdkVersion(value) {
37
+ if (typeof value !== "string") return null;
38
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value.trim());
39
+ if (!match) return null;
40
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
41
+ }
42
+
43
+ // Standard semver ordering over released versions. Returns null when either
44
+ // side does not parse, so callers degrade to "unknown" instead of guessing.
45
+ export function compareSdkVersions(a, b) {
46
+ const left = parseSdkVersion(a);
47
+ const right = parseSdkVersion(b);
48
+ if (!left || !right) return null;
49
+ for (let index = 0; index < 3; index += 1) {
50
+ if (left[index] !== right[index]) return left[index] < right[index] ? -1 : 1;
51
+ }
52
+ return 0;
53
+ }
54
+
55
+ // Best-effort read of the vendored SDK support policy. Absent or malformed
56
+ // policy degrades freshness to whatever the catalog verification records
57
+ // prove — it never throws, because freshness reporting must not block a gate.
58
+ export function defaultSdkSupportPolicy() {
59
+ const path = join(ROOT, ...SDK_SUPPORT_POLICY_PATH.split("/"));
60
+ if (!existsSync(path)) return null;
61
+ try {
62
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
63
+ return parsed && typeof parsed === "object" ? parsed : null;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ // The family's verification record as vendored on the catalog snapshot.
70
+ // Returns null when the snapshot carries none for this family (private
71
+ // families resolved from fragments, or a snapshot predating the verification
72
+ // sync) — the caller reports that honestly as unknown freshness.
73
+ export function familyVerification(catalog, family) {
74
+ const record = catalog?.families?.[String(family || "")]?.verification;
75
+ if (!record || typeof record !== "object" || Array.isArray(record)) return null;
76
+ if (!parseSdkVersion(record.sdk_version)) return null;
77
+ return {
78
+ sdk_version: record.sdk_version,
79
+ verified_at: typeof record.verified_at === "string" ? record.verified_at : null,
80
+ evidence: typeof record.evidence === "string" ? record.evidence : null,
81
+ status: typeof record.status === "string" ? record.status : null,
82
+ };
83
+ }
84
+
85
+ // "Current SDK" per the definition at the top of this file. Returns
86
+ // { version, source } or null when the vendored contracts record no released
87
+ // SDK at all.
88
+ export function resolveCurrentSdkVersion({ catalog, sdkSupportPolicy } = {}) {
89
+ let best = null;
90
+ const consider = (version, source) => {
91
+ if (!parseSdkVersion(version)) return;
92
+ if (!best || compareSdkVersions(version, best.version) === 1) {
93
+ best = { version, source };
94
+ }
95
+ };
96
+ consider(sdkSupportPolicy?.provenance?.latest_known_release, "sdk_support_policy.latest_known_release");
97
+ for (const [family, entry] of Object.entries(catalog?.families || {})) {
98
+ consider(entry?.verification?.sdk_version, `catalog_verification:${family}`);
99
+ }
100
+ return best;
101
+ }
102
+
103
+ // One assessment per family: what the snapshot proves, what "current" is, and
104
+ // the relation between them. States:
105
+ // current — last verified against exactly the current SDK
106
+ // stale — last verified against an OLDER SDK than current
107
+ // ahead — verified newer than current (defensive: impossible under the
108
+ // default current-SDK definition, reachable with injected inputs)
109
+ // unknown — no verification record, or no current SDK to compare against
110
+ export function assessTemplateFreshness({ family, catalog, sdkSupportPolicy } = {}) {
111
+ const verification = familyVerification(catalog, family);
112
+ const current = resolveCurrentSdkVersion({ catalog, sdkSupportPolicy });
113
+ const base = {
114
+ family: String(family || ""),
115
+ verified_sdk_version: verification?.sdk_version || null,
116
+ verified_at: verification?.verified_at || null,
117
+ current_sdk_version: current?.version || null,
118
+ current_sdk_source: current?.source || null,
119
+ delta: null,
120
+ };
121
+ if (!verification || !current) return { ...base, state: "unknown" };
122
+ const order = compareSdkVersions(verification.sdk_version, current.version);
123
+ if (order === null) return { ...base, state: "unknown" };
124
+ const state = order === 0 ? "current" : order < 0 ? "stale" : "ahead";
125
+ return { ...base, state, delta: describeVersionDelta(verification.sdk_version, current.version) };
126
+ }
127
+
128
+ // Human description of the version gap. For a shared major.minor line the
129
+ // patch-number gap is exact version arithmetic ("3 patch versions behind");
130
+ // across minor/major lines only the two versions themselves are honest.
131
+ function describeVersionDelta(verified, current) {
132
+ const from = parseSdkVersion(verified);
133
+ const to = parseSdkVersion(current);
134
+ if (!from || !to) return null;
135
+ if (from[0] === to[0] && from[1] === to[1]) {
136
+ const gap = to[2] - from[2];
137
+ if (gap === 0) return "0 patch versions";
138
+ const magnitude = Math.abs(gap);
139
+ return `${magnitude} patch version${magnitude === 1 ? "" : "s"} ${gap > 0 ? "behind" : "ahead"}`;
140
+ }
141
+ return `${verified} vs ${current}`;
142
+ }
143
+
144
+ // Only a well-formed ISO calendar date prefix (YYYY-MM-DD, optionally the
145
+ // start of a full timestamp) is ever interpolated into the operator line. The
146
+ // vendored snapshot carries full "YYYY-MM-DDTHH:MM:SSZ" timestamps, but the
147
+ // same code path runs against whatever the upstream template-verification.json
148
+ // carries — a malformed value omits the parenthetical instead of surfacing
149
+ // garbage like "(2026-13-45)" in operator output.
150
+ function isoDatePrefix(value) {
151
+ if (typeof value !== "string") return null;
152
+ // A suffix, when present, must look like an ISO time fragment (T + HH:MM at
153
+ // minimum); ISO 8601 timestamps never use a space separator, and a garbage
154
+ // suffix must reject rather than silently normalize to its date prefix.
155
+ const match = /^(\d{4})-(\d{2})-(\d{2})(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/.exec(value.trim());
156
+ if (!match) return null;
157
+ const [, year, month, day] = match;
158
+ const parsed = new Date(`${year}-${month}-${day}T00:00:00Z`);
159
+ if (Number.isNaN(parsed.getTime())) return null;
160
+ // Date rolls non-calendar values over (2026-13-45 -> 2027-02-14); reject
161
+ // anything that does not round-trip.
162
+ if (parsed.getUTCMonth() + 1 !== Number(month) || parsed.getUTCDate() !== Number(day)) return null;
163
+ return `${year}-${month}-${day}`;
164
+ }
165
+
166
+ // The single freshness line every operator surface prints, so the gate, the
167
+ // doctor, and the standardization report can never tell different stories.
168
+ // Total over any input: a null/undefined assessment (or one with fields
169
+ // missing) renders the unknown-state line instead of throwing or
170
+ // interpolating "undefined" — freshness reporting must not block a gate.
171
+ export function renderTemplateFreshness(assessment) {
172
+ const {
173
+ family = "",
174
+ state = "unknown",
175
+ verified_sdk_version = null,
176
+ verified_at = null,
177
+ current_sdk_version = null,
178
+ delta = null,
179
+ } = assessment || {};
180
+ const verifiedAtDate = isoDatePrefix(verified_at);
181
+ const verifiedAtSuffix = verifiedAtDate ? ` (${verifiedAtDate})` : "";
182
+ switch (state) {
183
+ case "current":
184
+ return `Template family "${family}" certification is current: last verified against SDK ${verified_sdk_version}${verifiedAtSuffix}, the current SDK recorded by the vendored contracts.`;
185
+ case "stale":
186
+ return `Template family "${family}" was last verified against SDK ${verified_sdk_version}${verifiedAtSuffix}; the current SDK is ${current_sdk_version}${delta ? ` (${delta})` : ""}. An older evidence record is not current certification — treat the family as pending re-verification against ${current_sdk_version}.`;
187
+ case "ahead":
188
+ // Deliberately kept so the renderer stays total over the documented
189
+ // state union, even though assessTemplateFreshness cannot currently
190
+ // produce "ahead" (the current-SDK max includes the family's own
191
+ // verification record); injected/external assessments render honestly.
192
+ return `Template family "${family}" was last verified against SDK ${verified_sdk_version}${verifiedAtSuffix}, which is newer than the current SDK ${current_sdk_version} recorded by the vendored contracts; refresh the SDK support policy capture.`;
193
+ default:
194
+ return `Template family "${family}" has no verification record in the vendored catalog snapshot${current_sdk_version ? ` (current SDK: ${current_sdk_version})` : ""}; certification freshness is unknown. An older evidence record is not current certification — confirm the family's last-verified SDK before relying on it.`;
195
+ }
196
+ }