@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,399 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ import Ajv2020 from "ajv/dist/2020.js";
7
+
8
+ import { doctorSidecarBlocked } from "./doctor-sidecar.mjs";
9
+ import { validateVerdict } from "./qa-verdict.mjs";
10
+ import { specHashesMatch } from "./spec-identity.mjs";
11
+
12
+ const ROOT = resolve(fileURLToPath(new URL("..", import.meta.url)));
13
+ const CONTRACT_PATH = join(ROOT, "contracts/migration-sidecar-bundle.v0.json");
14
+ const RFC3339_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
15
+
16
+ export const SIDECAR_BUNDLE_CONFORMANCE_SCHEMA = "campaigns-os-sidecar-bundle-conformance/v0";
17
+ export const SIDECAR_BUNDLE_CONTRACT = JSON.parse(readFileSync(CONTRACT_PATH, "utf8"));
18
+
19
+ const schemaValidators = new Map();
20
+ for (const artifact of SIDECAR_BUNDLE_CONTRACT.artifacts) {
21
+ const schema = JSON.parse(readFileSync(join(ROOT, artifact.schema_path), "utf8"));
22
+ const ajv = new Ajv2020({ strict: false, allErrors: true, validateFormats: false });
23
+ schemaValidators.set(artifact.kind, ajv.compile(schema));
24
+ }
25
+
26
+ function digest(value) {
27
+ return `sha256:${createHash("sha256").update(value).digest("hex")}`;
28
+ }
29
+
30
+ function canonicalize(value) {
31
+ if (Array.isArray(value)) return value.map(canonicalize);
32
+ if (!value || typeof value !== "object") return value;
33
+ return Object.fromEntries(
34
+ Object.keys(value)
35
+ .sort()
36
+ .map((key) => [key, canonicalize(value[key])]),
37
+ );
38
+ }
39
+
40
+ function removePath(value, dottedPath) {
41
+ const parts = dottedPath.split(".");
42
+ let cursor = value;
43
+ for (let index = 0; index < parts.length - 1; index += 1) {
44
+ if (!cursor || typeof cursor !== "object") return;
45
+ cursor = cursor[parts[index]];
46
+ }
47
+ if (cursor && typeof cursor === "object") delete cursor[parts.at(-1)];
48
+ }
49
+
50
+ export function materialProjection(value, artifactContract) {
51
+ const projected = structuredClone(value);
52
+ for (const field of artifactContract.volatile_fields || []) removePath(projected, field);
53
+ return canonicalize(projected);
54
+ }
55
+
56
+ function artifactFinding(code, artifact, message, remedy) {
57
+ return { code, artifact, message, remedy };
58
+ }
59
+
60
+ function readJsonArtifact(path) {
61
+ const raw = readFileSync(path);
62
+ return { raw, value: JSON.parse(raw.toString("utf8")) };
63
+ }
64
+
65
+ function timestampOk(value) {
66
+ return typeof value === "string" && RFC3339_UTC.test(value) && Number.isFinite(Date.parse(value));
67
+ }
68
+
69
+ function valueAt(value, dottedPath) {
70
+ return dottedPath.split(".").reduce((current, key) => current?.[key], value);
71
+ }
72
+
73
+ // The two spec-identity fields compare through the shared spec-hash
74
+ // comparator so a `sha256:` prefix, hex case or whitespace difference between
75
+ // producers is not reported as drift. The set is explicit, not inferred from
76
+ // the field name: a future `*_hash` field with a different prefix convention
77
+ // must opt in here rather than have its prefix silently stripped. Every other
78
+ // identity field (map_id, slugs, paths) stays an exact compare.
79
+ const SPEC_HASH_IDENTITY_FIELDS = new Set(["spec_hash", "spec_material_hash"]);
80
+
81
+ function identityValuesAgree(identityField, left, right) {
82
+ if (SPEC_HASH_IDENTITY_FIELDS.has(identityField.name)) return specHashesMatch(left, right);
83
+ return left === right;
84
+ }
85
+
86
+ function compareIdentity(errors, records, identityField) {
87
+ const label = identityField.name;
88
+ const present = [];
89
+ for (const [kind, path] of Object.entries(identityField.artifact_paths)) {
90
+ if (!records.has(kind)) continue;
91
+ const value = valueAt(records.get(kind).value, path);
92
+ if (value == null || value === "") {
93
+ errors.push(artifactFinding(
94
+ `bundle.identity.${label}_missing`,
95
+ kind,
96
+ `${kind} is missing required bundle identity ${path}.`,
97
+ "Regenerate the sidecars from the same root Build Packet and explicit QA verdict.",
98
+ ));
99
+ continue;
100
+ }
101
+ present.push({ kind, value });
102
+ }
103
+ if (present.length < 2) return;
104
+ const expected = present[0].value;
105
+ const mismatch = present.find((entry) => !identityValuesAgree(identityField, entry.value, expected));
106
+ if (!mismatch) return;
107
+ errors.push(artifactFinding(
108
+ `bundle.identity.${label}_mismatch`,
109
+ mismatch.kind,
110
+ `${label} disagrees across bundle artifacts (${present.map((entry) => `${entry.kind}=${entry.value}`).join(", ")}).`,
111
+ "Regenerate the sidecars from the same root Build Packet and explicit QA verdict.",
112
+ ));
113
+ }
114
+
115
+ function compareSpecMaterialIdentity(errors, records, identityField) {
116
+ const contextMaterial = valueAt(records.get("build_context")?.value, identityField.artifact_paths.build_context);
117
+ const reportMaterial = valueAt(records.get("assembly_report")?.value, identityField.artifact_paths.assembly_report);
118
+
119
+ if (!contextMaterial && !reportMaterial) {
120
+ // The ordinary spec_hash identity field already compares the two legacy
121
+ // producer values. QA's spec_hash is semantic material identity even for
122
+ // legacy bundles, so it must never participate in the raw-byte check.
123
+ return;
124
+ }
125
+
126
+ const present = [];
127
+ const missing = [];
128
+ for (const [kind, path] of Object.entries(identityField.artifact_paths)) {
129
+ if (!records.has(kind)) continue;
130
+ const value = valueAt(records.get(kind).value, path);
131
+ if (value == null || value === "") missing.push({ kind, path });
132
+ else present.push({ kind, value });
133
+ }
134
+
135
+ if (missing.length) {
136
+ errors.push(artifactFinding(
137
+ "bundle.identity.spec_material_hash_incomplete",
138
+ missing[0].kind,
139
+ `Material spec identity is partially regenerated; missing ${missing.map((entry) => `${entry.kind}:${entry.path}`).join(", ")}.`,
140
+ "Regenerate Build Context, Assembly Report, and any required QA sidecar from the same CampaignSpec.",
141
+ ));
142
+ return;
143
+ }
144
+
145
+ const expected = present[0]?.value;
146
+ const mismatch = present.find((entry) => !identityValuesAgree(identityField, entry.value, expected));
147
+ if (mismatch) {
148
+ errors.push(artifactFinding(
149
+ "bundle.identity.spec_material_hash_mismatch",
150
+ mismatch.kind,
151
+ `spec_material_hash disagrees across bundle artifacts (${present.map((entry) => `${entry.kind}=${entry.value}`).join(", ")}).`,
152
+ "Regenerate the sidecars from the same CampaignSpec and explicit QA verdict.",
153
+ ));
154
+ }
155
+ }
156
+
157
+ function bundleRootFor(packetPath) {
158
+ const absolute = resolve(packetPath);
159
+ if (basename(dirname(absolute)) === ".campaign-runtime") return dirname(dirname(absolute));
160
+ return dirname(absolute);
161
+ }
162
+
163
+ function normalizeRepositoryRelativePath(value) {
164
+ if (typeof value !== "string" || !value.trim()) return null;
165
+ const path = value.trim();
166
+ if (path.startsWith("/") || path.includes("\\") || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(path)) return null;
167
+ const segments = path.split("/");
168
+ if (segments.includes("..")) return null;
169
+ return segments.filter((segment) => segment && segment !== ".").join("/") || ".";
170
+ }
171
+
172
+ export function inspectSidecarBundle({ packetPath, requireQa = false } = {}) {
173
+ if (!packetPath || typeof packetPath !== "string") {
174
+ throw new Error("bundle check requires --packet <campaign-runtime.build.json>.");
175
+ }
176
+
177
+ const root = bundleRootFor(packetPath);
178
+ const absolutePacket = resolve(packetPath);
179
+ const canonicalPacket = join(root, SIDECAR_BUNDLE_CONTRACT.packet_discovery.canonical_path);
180
+ const errors = [];
181
+ const warnings = [];
182
+ const records = new Map();
183
+ const artifacts = [];
184
+
185
+ if (absolutePacket !== canonicalPacket) {
186
+ const legacy = absolutePacket === join(root, SIDECAR_BUNDLE_CONTRACT.packet_discovery.legacy_path);
187
+ errors.push(artifactFinding(
188
+ legacy ? "bundle.packet.legacy_path" : "bundle.packet.noncanonical_path",
189
+ "build_packet",
190
+ legacy
191
+ ? `Build Packet is at the legacy sidecar path ${SIDECAR_BUNDLE_CONTRACT.packet_discovery.legacy_path}; default discovery reads only the repository root.`
192
+ : `Build Packet must be at repository-root ${SIDECAR_BUNDLE_CONTRACT.packet_discovery.canonical_path}.`,
193
+ legacy
194
+ ? SIDECAR_BUNDLE_CONTRACT.packet_discovery.legacy_remedy
195
+ : SIDECAR_BUNDLE_CONTRACT.packet_discovery.noncanonical_remedy,
196
+ ));
197
+ }
198
+
199
+ for (const artifactContract of SIDECAR_BUNDLE_CONTRACT.artifacts) {
200
+ const path = join(root, artifactContract.path);
201
+ const required = artifactContract.requirement === "required" || (artifactContract.kind === "qa_verdict" && requireQa);
202
+ const record = {
203
+ kind: artifactContract.kind,
204
+ path: artifactContract.path,
205
+ requirement: artifactContract.requirement,
206
+ present: existsSync(path),
207
+ schema_version: null,
208
+ generated_at: null,
209
+ sha256: null,
210
+ material_sha256: null,
211
+ };
212
+ artifacts.push(record);
213
+
214
+ if (!record.present) {
215
+ const finding = artifactFinding(
216
+ `bundle.${artifactContract.kind}.missing`,
217
+ artifactContract.kind,
218
+ `Missing canonical JSON artifact ${artifactContract.path}.`,
219
+ artifactContract.kind === "qa_verdict"
220
+ ? "Run QA, or explicitly promote one named full verdict with campaigns-os qa promote; never select a verdict by mtime."
221
+ : artifactContract.kind === "doctor_output"
222
+ ? "Run campaigns-os doctor --packet campaign-runtime.build.json --write --strip-paths to refresh it."
223
+ : "Regenerate the bundle with Campaigns OS while preserving authored packet, context, and assembly inputs.",
224
+ );
225
+ (required ? errors : warnings).push(finding);
226
+ continue;
227
+ }
228
+
229
+ try {
230
+ const parsed = readJsonArtifact(path);
231
+ records.set(artifactContract.kind, parsed);
232
+ record.schema_version = parsed.value?.schema_version ?? null;
233
+ record.generated_at = valueAt(parsed.value, artifactContract.freshness_field) ?? null;
234
+ record.sha256 = digest(parsed.raw);
235
+ record.material_sha256 = digest(JSON.stringify(materialProjection(parsed.value, artifactContract)));
236
+ } catch {
237
+ errors.push(artifactFinding(
238
+ `bundle.${artifactContract.kind}.invalid_json`,
239
+ artifactContract.kind,
240
+ `${artifactContract.path} is not valid JSON.`,
241
+ "Regenerate the artifact with its Campaigns OS producer; do not repair generated JSON by hand.",
242
+ ));
243
+ continue;
244
+ }
245
+
246
+ if (record.schema_version !== artifactContract.schema_version) {
247
+ errors.push(artifactFinding(
248
+ `bundle.${artifactContract.kind}.schema_version`,
249
+ artifactContract.kind,
250
+ `Expected schema_version ${artifactContract.schema_version}; found ${record.schema_version ?? "missing"}.`,
251
+ `Regenerate ${artifactContract.path} with a compatible Campaigns OS release.`,
252
+ ));
253
+ }
254
+ if (!timestampOk(record.generated_at)) {
255
+ errors.push(artifactFinding(
256
+ `bundle.${artifactContract.kind}.generated_at`,
257
+ artifactContract.kind,
258
+ `${artifactContract.path} needs a strict RFC3339 UTC generated_at timestamp.`,
259
+ `Regenerate ${artifactContract.path}; freshness is never inferred from filesystem mtime.`,
260
+ ));
261
+ }
262
+ const validateSchema = schemaValidators.get(artifactContract.kind);
263
+ if (!validateSchema(records.get(artifactContract.kind).value)) {
264
+ const details = validateSchema.errors
265
+ .slice(0, 5)
266
+ .map((error) => `${error.instancePath || "/"} ${error.message}`)
267
+ .join("; ");
268
+ errors.push(artifactFinding(
269
+ `bundle.${artifactContract.kind}.schema`,
270
+ artifactContract.kind,
271
+ `${artifactContract.path} failed ${artifactContract.schema_path}: ${details}`,
272
+ `Regenerate ${artifactContract.path} with its Campaigns OS producer; do not repair generated JSON by hand.`,
273
+ ));
274
+ }
275
+ }
276
+
277
+ const packet = records.get("build_packet")?.value;
278
+ const context = records.get("build_context")?.value;
279
+ const report = records.get("assembly_report")?.value;
280
+ const doctor = records.get("doctor_output")?.value;
281
+ const qaVerdict = records.get("qa_verdict")?.value;
282
+
283
+ if (context && normalizeRepositoryRelativePath(context.packet_path) !== "campaign-runtime.build.json") {
284
+ errors.push(artifactFinding(
285
+ "bundle.build_context.packet_path",
286
+ "build_context",
287
+ "Build Context packet_path must name repository-root campaign-runtime.build.json.",
288
+ "Regenerate the Build Context with canonical repository-relative output paths.",
289
+ ));
290
+ }
291
+ if (report && normalizeRepositoryRelativePath(report.inputs?.packet_path) !== "campaign-runtime.build.json") {
292
+ errors.push(artifactFinding(
293
+ "bundle.assembly_report.packet_path",
294
+ "assembly_report",
295
+ "Assembly Report inputs.packet_path must name repository-root campaign-runtime.build.json.",
296
+ "Regenerate the Assembly Report with canonical repository-relative output paths.",
297
+ ));
298
+ }
299
+ if (doctor?.stale === true) {
300
+ errors.push(artifactFinding(
301
+ "bundle.doctor_output.stale",
302
+ "doctor_output",
303
+ "Doctor output is explicitly marked stale after a later mutation.",
304
+ "Re-run campaigns-os doctor --packet campaign-runtime.build.json --write --strip-paths.",
305
+ ));
306
+ }
307
+ // A blocked doctor run is still a conformant artifact — the sidecars agree
308
+ // with each other and the contract — but its content says the campaign
309
+ // cannot proceed. Conformance is not readiness: surface the block as a
310
+ // warning by default and, under --require-qa, as an error, because a
311
+ // QA-complete handoff cannot ride a doctor that refused the build.
312
+ if (doctorSidecarBlocked(doctor)) {
313
+ (requireQa ? errors : warnings).push(artifactFinding(
314
+ "bundle.doctor_output.blocked",
315
+ "doctor_output",
316
+ `Doctor output records a blocked run (status ${doctor.status ?? "missing"}, ok ${doctor.ok ?? "missing"}); the campaign cannot proceed${requireQa ? ", so it cannot satisfy QA-complete handoff" : " even though the sidecars agree with each other"}.`,
317
+ "Resolve the doctor errors, then re-run campaigns-os doctor --packet campaign-runtime.build.json --write --strip-paths so the retained sidecar records a ready run.",
318
+ ));
319
+ }
320
+ if (qaVerdict) {
321
+ const qaErrors = validateVerdict(qaVerdict);
322
+ if (qaErrors.length) {
323
+ errors.push(artifactFinding(
324
+ "bundle.qa_verdict.shape",
325
+ "qa_verdict",
326
+ `QA Verdict sidecar failed its public shape: ${qaErrors.join("; ")}`,
327
+ "Re-run QA or explicitly promote a valid named full verdict with campaigns-os qa promote.",
328
+ ));
329
+ }
330
+ for (const field of ["entry_urls", "page_urls", "tested_urls", "test_orders"]) {
331
+ if (!Array.isArray(qaVerdict[field]) || qaVerdict[field].length !== 0) {
332
+ errors.push(artifactFinding(
333
+ `bundle.qa_verdict.${field}`,
334
+ "qa_verdict",
335
+ `Committed QA sidecar field ${field} must be an empty array.`,
336
+ "Regenerate the allowlist projection with campaigns-os qa promote; do not commit a full URL/order-bearing verdict.",
337
+ ));
338
+ }
339
+ }
340
+ // A blocked verdict on disk is always reported, so the Readiness line
341
+ // reads it whether or not QA is part of conformance; --require-qa is what
342
+ // turns the same finding into an error, because only then does the bundle
343
+ // claim QA-complete handoff.
344
+ if (qaVerdict.disposition === "blocked") {
345
+ (requireQa ? errors : warnings).push(artifactFinding(
346
+ "bundle.qa_verdict.blocked",
347
+ "qa_verdict",
348
+ requireQa
349
+ ? "QA Verdict is schema-valid but blocked, so it cannot satisfy QA-complete handoff."
350
+ : "QA Verdict is schema-valid but blocked; the bundle is conformant, but the campaign is not QA-ready.",
351
+ "Resolve the failing QA blockers and promote the resulting named verdict explicitly.",
352
+ ));
353
+ }
354
+ }
355
+
356
+ for (const identityField of SIDECAR_BUNDLE_CONTRACT.identity_fields) {
357
+ if (identityField.name === "spec_material_hash") compareSpecMaterialIdentity(errors, records, identityField);
358
+ else compareIdentity(errors, records, identityField);
359
+ }
360
+
361
+ const materialEntries = artifacts
362
+ .filter((artifact) => artifact.present && artifact.material_sha256)
363
+ .map((artifact) => [artifact.kind, artifact.material_sha256]);
364
+ const materialDigest = materialEntries.length
365
+ ? digest(JSON.stringify(materialEntries))
366
+ : null;
367
+
368
+ return {
369
+ schema_version: SIDECAR_BUNDLE_CONFORMANCE_SCHEMA,
370
+ bundle_id: SIDECAR_BUNDLE_CONTRACT.bundle_id,
371
+ ok: errors.length === 0,
372
+ status: errors.length === 0 ? "conformant" : "nonconformant",
373
+ // stage_blocked keeps its published meaning (a required QA verdict is
374
+ // blocked); the doctor case is carried by the bundle.doctor_output.blocked
375
+ // finding until the conformance schema is bumped to widen it.
376
+ stage_blocked: requireQa && qaVerdict?.disposition === "blocked",
377
+ root,
378
+ packet_generated_at: packet?.generated_at ?? null,
379
+ material_digest: materialDigest,
380
+ artifacts,
381
+ errors,
382
+ warnings,
383
+ };
384
+ }
385
+
386
+ // One line, distinct from `status`, for the operator reading text output:
387
+ // "conformant" only says the sidecars agree with each other and the contract.
388
+ // Whether doctor or QA passed is carried by the blocked findings (and, for a
389
+ // required QA verdict, stage_blocked); this line reads those so nobody has to
390
+ // infer readiness from "ok: true".
391
+ export function sidecarBundleReadinessLine(result) {
392
+ const blockers = [...(result.errors || []), ...(result.warnings || [])]
393
+ .filter((finding) => finding.code === "bundle.doctor_output.blocked" || finding.code === "bundle.qa_verdict.blocked")
394
+ .map((finding) => (finding.artifact === "doctor_output" ? "doctor run is blocked" : "QA verdict is blocked"));
395
+ if (result.stage_blocked || blockers.length) {
396
+ return `Readiness: BLOCKED (${blockers.join("; ") || "stage_blocked"}); conformant only means the sidecars agree with each other and the contract.`;
397
+ }
398
+ return "Readiness: no doctor or QA block recorded by the sidecars present (a missing sidecar is listed under Warnings); conformant only means the sidecars agree with each other and the contract.";
399
+ }