@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,557 @@
1
+ // Per-finding cause class — "did the change under test cause this?"
2
+ //
3
+ // A run that surfaces eleven findings, none of them caused by the change being
4
+ // tested, reads exactly like a run that broke eleven things. The operator has
5
+ // no mechanical way to tell the two apart, so every bump run ends in a manual
6
+ // read of every finding. This module attaches one cause class to each finding
7
+ // so the answer is on the report.
8
+ //
9
+ // Deliberately mechanical. Every class here is derived from something the
10
+ // toolkit ALREADY records: the assertion identity the verdict already uses to
11
+ // derive exceptions, the doctor issue codes the Run Record already snapshots,
12
+ // the runner's own environment markers, and the SDK-pin checkpoint the doctor
13
+ // already evaluates. Nothing is inferred from message text, severity, or
14
+ // plausibility. When no class can be assigned from recorded data the answer is
15
+ // `unknown` with a reason, never a guess.
16
+
17
+ export const CAUSE_CLASSES = Object.freeze({
18
+ CAUSED_BY_CHANGE: "caused_by_change",
19
+ PRE_EXISTING: "pre_existing",
20
+ TEST_ENVIRONMENT: "test_environment",
21
+ UPSTREAM_DRIFT: "upstream_drift",
22
+ UNKNOWN: "unknown",
23
+ });
24
+
25
+ // Report order: the class an operator is looking for first comes first.
26
+ const CAUSE_CLASS_VOCABULARY = Object.freeze([
27
+ CAUSE_CLASSES.CAUSED_BY_CHANGE,
28
+ CAUSE_CLASSES.PRE_EXISTING,
29
+ CAUSE_CLASSES.TEST_ENVIRONMENT,
30
+ CAUSE_CLASSES.UPSTREAM_DRIFT,
31
+ CAUSE_CLASSES.UNKNOWN,
32
+ ]);
33
+
34
+ const CAUSE_CLASS_LABELS = Object.freeze({
35
+ [CAUSE_CLASSES.CAUSED_BY_CHANGE]: "caused by this change",
36
+ [CAUSE_CLASSES.PRE_EXISTING]: "pre-existing",
37
+ [CAUSE_CLASSES.TEST_ENVIRONMENT]: "test environment",
38
+ [CAUSE_CLASSES.UPSTREAM_DRIFT]: "upstream drift",
39
+ [CAUSE_CLASSES.UNKNOWN]: "unknown",
40
+ });
41
+
42
+ // Doctor issue codes that ARE an already-detected version disagreement between
43
+ // what the CampaignSpec pins and what the target carries. Enumerated, not
44
+ // prefix-matched: the sibling `page_kit.sdk_version.spec_missing` /
45
+ // `.target_missing` / `.waiver_inert` codes are configuration gaps and waiver
46
+ // hygiene, not drift, and calling them drift would tell an operator the
47
+ // upstream moved when it did not.
48
+ const UPSTREAM_DRIFT_DOCTOR_CODES = Object.freeze([
49
+ // observed target SDK version != the CampaignSpec pin (the blocking form)
50
+ "page_kit.sdk_version",
51
+ // the repo pin moved ahead of the spec's build hint (the advisory form, #413)
52
+ "page_kit.sdk_version.repo_newer",
53
+ // the same disagreement, accepted under a named-human waiver
54
+ "page_kit.sdk_version.waived",
55
+ // two spec-side declarations of the pin disagree with each other
56
+ "page_kit.sdk_version.spec_conflict",
57
+ ]);
58
+
59
+ const UPSTREAM_DRIFT_DOCTOR_CODE_SET = new Set(UPSTREAM_DRIFT_DOCTOR_CODES);
60
+
61
+ function text(value) {
62
+ return typeof value === "string" && value.trim() ? value.trim() : "";
63
+ }
64
+
65
+ /**
66
+ * Stable identity for one QA assertion.
67
+ *
68
+ * Reuses the identity the verdict already projects in deriveExceptions —
69
+ * family, id, page — and nothing else. `url` is deliberately excluded: the
70
+ * same campaign QA'd locally and then against its published deploy produces
71
+ * different URLs for the identical assertion, and including it would report
72
+ * every finding of the published run as new.
73
+ */
74
+ export function qaAssertionFingerprint(assertion) {
75
+ return `qa:${text(assertion?.family)}|${text(assertion?.id)}|${text(assertion?.page)}`;
76
+ }
77
+
78
+ /**
79
+ * Stable identity for one doctor issue.
80
+ *
81
+ * Code granularity, because the code is what the Run Record already snapshots
82
+ * (observations.doctor.error_codes / warning_codes) and therefore the only
83
+ * doctor identity a previous run can be compared against. Two distinct
84
+ * violations sharing a code are one finding to this comparison.
85
+ */
86
+ export function doctorIssueFingerprint(issue) {
87
+ return `doctor:${text(issue?.code)}`;
88
+ }
89
+
90
+ /**
91
+ * Environment classification for a QA assertion, using the runner's own
92
+ * markers only:
93
+ * - the order-creation budget safety stop, which the runner records as
94
+ * `evidence.order_creation_budget` and which is explicitly not a broken
95
+ * checkout;
96
+ * - a `<leg>:runner` assertion, the id the analytics and test-order legs
97
+ * emit when the capture itself failed to complete rather than when the
98
+ * page was wrong.
99
+ * Returns a reason string, or null when the assertion is not environmental.
100
+ */
101
+ export function qaEnvironmentReason(assertion) {
102
+ const evidence = assertion?.evidence;
103
+ if (evidence && typeof evidence === "object" && !Array.isArray(evidence) && evidence.order_creation_budget) {
104
+ return "order_creation_budget_stop";
105
+ }
106
+ if (text(assertion?.id).endsWith(":runner")) return "runner_capture_failure";
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * Upstream-drift classification for a doctor issue: true only for the codes
112
+ * the SDK-pin checkpoint already emits for an observed-vs-declared version
113
+ * disagreement.
114
+ */
115
+ export function doctorUpstreamDriftReason(issue) {
116
+ return UPSTREAM_DRIFT_DOCTOR_CODE_SET.has(text(issue?.code)) ? "sdk_pin_disagreement" : null;
117
+ }
118
+
119
+ /**
120
+ * The prior-run comparison set: a Map of fingerprint -> status. `status` is
121
+ * whatever the surface calls a status ("fail"/"warn"/... for QA,
122
+ * "error"/"warning" for doctor). Same fingerprint AND same status is
123
+ * pre-existing; a fingerprint whose status moved is not.
124
+ */
125
+ export function priorSetFromEntries(entries = []) {
126
+ const map = new Map();
127
+ for (const entry of entries) {
128
+ const fingerprint = text(entry?.fingerprint);
129
+ if (!fingerprint) continue;
130
+ if (!map.has(fingerprint)) map.set(fingerprint, text(entry?.status));
131
+ }
132
+ return map;
133
+ }
134
+
135
+ /** Prior comparison set built from a previous run's QA verdict object. */
136
+ function priorSetFromVerdict(verdict, { isFinding }) {
137
+ const assertions = Array.isArray(verdict?.assertions) ? verdict.assertions : [];
138
+ return priorSetFromEntries(
139
+ assertions
140
+ .filter((assertion) => assertion && typeof assertion === "object" && isFinding(assertion))
141
+ .map((assertion) => ({ fingerprint: qaAssertionFingerprint(assertion), status: assertion.status })),
142
+ );
143
+ }
144
+
145
+ /**
146
+ * Prior comparison set built from a previous Run Record's doctor observations.
147
+ * These are code lists the record already carries, so this works against every
148
+ * Run Record ever written — no new field, no upgrade window.
149
+ */
150
+ function priorSetFromRunRecordDoctor(record) {
151
+ const doctor = record?.observations?.doctor;
152
+ if (!doctor || typeof doctor !== "object") return null;
153
+ const entries = [];
154
+ for (const code of Array.isArray(doctor.error_codes) ? doctor.error_codes : []) {
155
+ entries.push({ fingerprint: doctorIssueFingerprint({ code }), status: "error" });
156
+ }
157
+ for (const code of Array.isArray(doctor.warning_codes) ? doctor.warning_codes : []) {
158
+ entries.push({ fingerprint: doctorIssueFingerprint({ code }), status: "warning" });
159
+ }
160
+ return priorSetFromEntries(entries);
161
+ }
162
+
163
+ /**
164
+ * The classification itself, for one finding. `prior` is a Map from
165
+ * priorSetFromEntries, or null when no previous run was found.
166
+ *
167
+ * Order is load-bearing. Environment and upstream drift are decided first,
168
+ * because a finding the runner already knows is environmental stays
169
+ * environmental whether or not it also happened last time — labelling a
170
+ * Chromium failure "pre-existing" would tell the operator the campaign is at
171
+ * fault. Only then does the previous-run comparison run.
172
+ */
173
+ function classifyFinding({ fingerprint, status, environmentReason = null, upstreamDriftReason = null, prior = null, noPriorReason = "no_prior_run" }) {
174
+ if (environmentReason) {
175
+ return { cause: CAUSE_CLASSES.TEST_ENVIRONMENT, cause_reason: environmentReason };
176
+ }
177
+ if (upstreamDriftReason) {
178
+ return { cause: CAUSE_CLASSES.UPSTREAM_DRIFT, cause_reason: upstreamDriftReason };
179
+ }
180
+ if (!prior) {
181
+ return { cause: CAUSE_CLASSES.UNKNOWN, cause_reason: noPriorReason };
182
+ }
183
+ if (!prior.has(fingerprint)) {
184
+ return { cause: CAUSE_CLASSES.CAUSED_BY_CHANGE, cause_reason: "new_since_prior_run" };
185
+ }
186
+ const priorStatus = prior.get(fingerprint);
187
+ const currentStatus = text(status);
188
+ if (priorStatus === currentStatus) {
189
+ return { cause: CAUSE_CLASSES.PRE_EXISTING, cause_reason: "same_status_in_prior_run" };
190
+ }
191
+ return {
192
+ cause: CAUSE_CLASSES.CAUSED_BY_CHANGE,
193
+ cause_reason: `status_changed_since_prior_run:${priorStatus || "unknown"}->${currentStatus || "unknown"}`,
194
+ };
195
+ }
196
+
197
+ /** Classify one QA assertion. Mutates nothing; returns the cause fields. */
198
+ export function classifyQaAssertion(assertion, { prior = null, noPriorReason = "no_prior_run" } = {}) {
199
+ return classifyFinding({
200
+ fingerprint: qaAssertionFingerprint(assertion),
201
+ status: assertion?.status,
202
+ environmentReason: qaEnvironmentReason(assertion),
203
+ prior,
204
+ noPriorReason,
205
+ });
206
+ }
207
+
208
+ /** Classify one doctor issue. `status` is "error" or "warning". */
209
+ export function classifyDoctorIssue(issue, status, { prior = null, noPriorReason = "no_prior_run" } = {}) {
210
+ return classifyFinding({
211
+ fingerprint: doctorIssueFingerprint(issue),
212
+ status,
213
+ upstreamDriftReason: doctorUpstreamDriftReason(issue),
214
+ prior,
215
+ noPriorReason,
216
+ });
217
+ }
218
+
219
+ /**
220
+ * Count findings by cause class. `total` is the number of findings counted,
221
+ * which is the number the summary line leads with — an operator comparing
222
+ * "11 findings" against the per-class counts must be able to add them up.
223
+ */
224
+ export function summarizeCauses(findings = []) {
225
+ const counts = Object.fromEntries(CAUSE_CLASS_VOCABULARY.map((cause) => [cause, 0]));
226
+ let total = 0;
227
+ for (const finding of findings) {
228
+ const cause = text(finding?.cause);
229
+ if (!cause) continue;
230
+ total += 1;
231
+ if (cause in counts) counts[cause] += 1;
232
+ }
233
+ return { total, counts };
234
+ }
235
+
236
+ /**
237
+ * The one-line report header. Reads as prose, not as a JSON dump, and names
238
+ * the previous Run Record when a comparison actually happened, so the answer
239
+ * is checkable.
240
+ *
241
+ * "Compared against" is claimed only when `comparison` says a comparison
242
+ * happened. A prior record that exists but carries no usable evidence has a
243
+ * run id, and naming it here would read as though it had been compared —
244
+ * formatCauseBasisLine is where that case gets explained.
245
+ */
246
+ export function formatCauseSummaryLine(summary) {
247
+ if (!summary || !summary.total) return "Causes: no findings.";
248
+ const parts = CAUSE_CLASS_VOCABULARY
249
+ .filter((cause) => summary.counts[cause] > 0)
250
+ .map((cause) => `${summary.counts[cause]} ${CAUSE_CLASS_LABELS[cause]}`);
251
+ const id = text(summary.prior_run_id);
252
+ let compared = " (no previous run to compare against)";
253
+ if (summary.comparison === "prior_run" && id) {
254
+ // The QA comparison reads that record's FINAL QA attempt, which is not the
255
+ // same artifact as the record itself. Say so, or a reader checking the
256
+ // record by hand will look at the wrong attempt.
257
+ compared = summary.surface === "qa"
258
+ ? ` (compared against the final QA attempt of run ${id})`
259
+ : ` (compared against run ${id})`;
260
+ } else if (summary.comparison && summary.comparison !== "prior_run") {
261
+ compared = " (no comparison was possible)";
262
+ }
263
+ return `Causes: ${summary.total} finding${summary.total === 1 ? "" : "s"} — ${parts.join(", ")}${compared}.`;
264
+ }
265
+
266
+ // Why no comparison happened, one sentence per reason. "No previous run" and
267
+ // "a previous run whose evidence is missing" are different facts and need
268
+ // different sentences: telling an operator who already has a prior record that
269
+ // the labels will improve once a second run exists is simply untrue, and sends
270
+ // them to re-run something that will fail the same way.
271
+ const CAUSE_BASIS_SENTENCES = Object.freeze({
272
+ no_prior_run: () => "There is no previous run for this campaign to compare against, so every finding is labelled unknown. The comparison starts working once a Run Record exists.",
273
+ prior_run_without_qa_verdict: (id) => `Previous run ${id} exists but references no QA verdict, so there was nothing to compare against and every finding is labelled unknown.`,
274
+ prior_run_verdict_unreadable: (id) => `Previous run ${id} exists but its QA verdict is missing or unreadable, so there was nothing to compare against and every finding is labelled unknown.`,
275
+ prior_run_verdict_unlocated: (id) => `Previous run ${id} exists and references a QA verdict written outside the packet directory, but no verdict matching that reference could be located under the target repo's qa-output/, so there was nothing to compare against and every finding is labelled unknown.`,
276
+ prior_run_without_doctor_observations: (id) => `Previous run ${id} exists but carries no doctor observations, so there was nothing to compare against and every finding is labelled unknown.`,
277
+ });
278
+
279
+ /**
280
+ * The follow-up line explaining a missing comparison, or null when one
281
+ * happened. One formatter, used by both the QA and the doctor report, so the
282
+ * two commands can never explain the same state differently.
283
+ */
284
+ export function formatCauseBasisLine(summary) {
285
+ if (!summary || !summary.comparison || summary.comparison === "prior_run") return null;
286
+ const reason = text(summary.comparison);
287
+ const id = text(summary.prior_run_id) || "(unidentified)";
288
+ const sentence = CAUSE_BASIS_SENTENCES[reason];
289
+ const explanation = sentence
290
+ ? sentence(id)
291
+ : "No previous-run comparison was possible, so every finding is labelled unknown.";
292
+ return ` Comparison basis: ${reason}. ${explanation}`;
293
+ }
294
+
295
+ /**
296
+ * The cause block a human report prints: the summary line, then the basis
297
+ * line when no comparison happened. One function for the QA and the doctor
298
+ * report, so the two cannot print the same summary differently.
299
+ */
300
+ export function formatCauseReportLines(summary) {
301
+ if (!summary) return [];
302
+ const lines = [formatCauseSummaryLine(summary)];
303
+ const basis = formatCauseBasisLine(summary);
304
+ if (basis) lines.push(basis);
305
+ return lines;
306
+ }
307
+
308
+ /** The short per-finding tag the report prints beside each finding. */
309
+ export function formatCauseTag(finding) {
310
+ const cause = text(finding?.cause);
311
+ if (!cause) return "";
312
+ const label = CAUSE_CLASS_LABELS[cause] || cause;
313
+ const reason = text(finding?.cause_reason);
314
+ return reason ? `[${label}: ${reason}]` : `[${label}]`;
315
+ }
316
+
317
+ // ---------------------------------------------------------------------------
318
+ // Previous-run lookup.
319
+ //
320
+ // Reuses the existing Run Record discovery (readRunRecordsForTarget, which
321
+ // orders `.campaign-runtime/run-records/` newest-first by minted run id and
322
+ // swallows unreadable files). No second scanner, no second ordering rule.
323
+ // ---------------------------------------------------------------------------
324
+
325
+ import { existsSync, readFileSync, statSync } from "node:fs";
326
+ import { isAbsolute, resolve as resolvePath } from "node:path";
327
+ import { iterateQaVerdicts } from "./qa-verdict-discovery.mjs";
328
+
329
+ import { readRunRecordsForTarget } from "./run-record.mjs";
330
+
331
+ /**
332
+ * The most recent Run Record for the same campaign identity under `baseDir`,
333
+ * or null. Records are discovered newest-first, and the current run's record
334
+ * does not exist yet at classification time (both `qa run` and `doctor`
335
+ * complete before `run-record` assembles anything), so the first identity
336
+ * match is the previous run. `currentRunId` is skipped anyway, defensively.
337
+ *
338
+ * Only the FIRST identity match is considered. Walking further back to find a
339
+ * record that happens to carry usable evidence would silently compare this run
340
+ * against a non-adjacent one and report findings introduced in between as
341
+ * pre-existing.
342
+ */
343
+ export function findPriorRunRecord({ baseDir, mapId = null, currentRunId = null } = {}) {
344
+ if (!text(baseDir)) return null;
345
+ for (const entry of readRunRecordsForTarget(baseDir)) {
346
+ const record = entry?.record;
347
+ if (!record || typeof record !== "object" || Array.isArray(record)) continue;
348
+ if (currentRunId && record.run_id === currentRunId) continue;
349
+ if (text(mapId) && text(record.identity?.map_id) !== text(mapId)) continue;
350
+ return record;
351
+ }
352
+ return null;
353
+ }
354
+
355
+ const EXTERNAL_REF_PREFIX = "external:";
356
+
357
+ function resolveArtifactPath(baseDir, artifactPath) {
358
+ return isAbsolute(artifactPath) ? artifactPath : resolvePath(baseDir, artifactPath);
359
+ }
360
+
361
+ // One read of a verdict file; null when the comparison cannot use it (missing,
362
+ // not a file, not JSON, or carrying no assertions array).
363
+ function readPriorVerdictFile(path) {
364
+ try {
365
+ if (!existsSync(path) || !statSync(path).isFile()) return null;
366
+ const verdict = JSON.parse(readFileSync(path, "utf8"));
367
+ if (!verdict || typeof verdict !== "object" || Array.isArray(verdict) || !Array.isArray(verdict.assertions)) return null;
368
+ return verdict;
369
+ } catch {
370
+ return null;
371
+ }
372
+ }
373
+
374
+ // The identity a Run Record carries, in the shape the verdict discovery leaf
375
+ // reads (`spec.map_id` / `campaign.public_route_slug`), so the previous run's
376
+ // verdicts are filed and matched under the names the record itself stores.
377
+ function recordIdentityForDiscovery(record) {
378
+ return {
379
+ spec: { map_id: text(record?.identity?.map_id) || null },
380
+ campaign: { public_route_slug: text(record?.identity?.campaign_slug) || null },
381
+ };
382
+ }
383
+
384
+ /**
385
+ * Locate a previous run's QA verdict that its Run Record references as
386
+ * `external:qa_verdict` — the reference the record writes when the verdict
387
+ * lived outside the packet directory. That is the ordinary layout whenever
388
+ * `assembly.target_repo` is not the packet's own directory: `qa run` writes
389
+ * the full verdict under `<target repo>/qa-output/<identifier>/` and the
390
+ * record, relativizing against the packet directory, keeps only the kind and
391
+ * the file's digest. The digest is the one thing that ties a file on disk to
392
+ * THAT reference, so the target repo's `qa-output/` directories are searched
393
+ * for the file whose bytes hash to it: the referenced verdict itself, never a
394
+ * neighbouring attempt.
395
+ *
396
+ * The committed sidecar (`.campaign-runtime/qa-verdict.json`) is deliberately
397
+ * not a fallback. It is a projection, so its digest cannot match, and the
398
+ * record stores no verdict run id to match it by; any other rule (same
399
+ * campaign, older than the record, same disposition) admits a projection of a
400
+ * different attempt — one restored by `qa promote`, say — and would report a
401
+ * finding that attempt carried and the recorded final attempt had fixed as
402
+ * pre-existing. Until the record can name the attempt, an unlocated reference
403
+ * stays `prior_run_verdict_unlocated`.
404
+ *
405
+ * Null is returned without a search when no target repo is known or the
406
+ * reference carries no digest, as well as when the search finds no match; the
407
+ * basis sentence for `prior_run_verdict_unlocated` is worded to be true in all
408
+ * three cases (nothing matching the reference could be located).
409
+ *
410
+ * Returns `{ verdict, path }` or null. The single-record boundary holds: this
411
+ * reads the verdict the ONE previous record references, not the newest file
412
+ * lying around.
413
+ */
414
+ function locateExternalPriorVerdict({ targetRepo, record, ref }) {
415
+ const digest = text(ref?.sha256);
416
+ if (!digest || !text(targetRepo)) return null;
417
+ const identity = recordIdentityForDiscovery(record);
418
+ for (const candidate of iterateQaVerdicts({ packet: identity, roots: [targetRepo], withDigest: true })) {
419
+ if (candidate.sha256 !== digest) continue;
420
+ const verdict = candidate.verdict && Array.isArray(candidate.verdict.assertions) ? candidate.verdict : null;
421
+ return verdict ? { verdict, path: candidate.path } : null;
422
+ }
423
+ return null;
424
+ }
425
+
426
+ /**
427
+ * The previous run's FINAL QA verdict, reached through that run's own Run
428
+ * Record artifact reference. Returns `{ verdict, record, path, reason }`;
429
+ * `verdict` is null whenever `reason` is set.
430
+ *
431
+ * The LAST qa_verdict reference, not the first. A run session that needed
432
+ * repair and re-test carries one qa_verdict artifact per attempt, appended in
433
+ * session order with the run's canonical verdict last — so the first reference
434
+ * is typically the blocked attempt that triggered the repair. Comparing
435
+ * against it would report a defect that was fixed before that run closed, and
436
+ * reintroduced by the change under test, as pre-existing: precisely the
437
+ * false-clean answer this label exists to prevent.
438
+ *
439
+ * A reference of `external:qa_verdict` is a reference, not an absence: it is
440
+ * resolved by digest through `targetRepo` (the packet's `assembly.target_repo`)
441
+ * — see locateExternalPriorVerdict. Only a record with no qa_verdict artifact
442
+ * at all reports `prior_run_without_qa_verdict`.
443
+ *
444
+ * The single-record boundary is unchanged: this still reads the final attempt
445
+ * of exactly one earlier run, never a merged view across runs.
446
+ */
447
+ export function loadPriorQaVerdict({ baseDir, targetRepo = null, mapId = null, currentRunId = null } = {}) {
448
+ const record = findPriorRunRecord({ baseDir, mapId, currentRunId });
449
+ if (!record) return { verdict: null, record: null, path: null, reason: "no_prior_run" };
450
+ const ref = (Array.isArray(record.artifacts) ? record.artifacts : []).findLast((artifact) => artifact?.kind === "qa_verdict");
451
+ const refPath = text(ref?.path);
452
+ if (!refPath) return { verdict: null, record, path: null, reason: "prior_run_without_qa_verdict" };
453
+ if (refPath.startsWith(EXTERNAL_REF_PREFIX)) {
454
+ const located = locateExternalPriorVerdict({ targetRepo, record, ref });
455
+ return located
456
+ ? { verdict: located.verdict, record, path: located.path, reason: null }
457
+ : { verdict: null, record, path: null, reason: "prior_run_verdict_unlocated" };
458
+ }
459
+ const path = resolveArtifactPath(baseDir, refPath);
460
+ const verdict = readPriorVerdictFile(path);
461
+ if (!verdict) return { verdict: null, record, path, reason: "prior_run_verdict_unreadable" };
462
+ return { verdict, record, path, reason: null };
463
+ }
464
+
465
+ /**
466
+ * The previous run's doctor findings, read from the Run Record's own doctor
467
+ * observations (error_codes / warning_codes). Every Run Record ever written
468
+ * carries these, so doctor cause labels work against existing history with no
469
+ * upgrade window. Returns `{ prior, record, reason }`.
470
+ */
471
+ function loadPriorDoctorFindings({ baseDir, mapId = null, currentRunId = null } = {}) {
472
+ const record = findPriorRunRecord({ baseDir, mapId, currentRunId });
473
+ if (!record) return { prior: null, record: null, reason: "no_prior_run" };
474
+ const prior = priorSetFromRunRecordDoctor(record);
475
+ if (!prior) return { prior: null, record, reason: "prior_run_without_doctor_observations" };
476
+ return { prior, record, reason: null };
477
+ }
478
+
479
+ // ---------------------------------------------------------------------------
480
+ // The QA wiring: classify one run's findings in place and return the summary
481
+ // the report leads with.
482
+ // ---------------------------------------------------------------------------
483
+
484
+ /**
485
+ * Stamp `cause` and `cause_reason` onto every FINDING assertion in `assertions`
486
+ * (passes are left alone — a passing assertion has no cause to explain), and
487
+ * return the summary block for the verdict.
488
+ *
489
+ * `baseDir` is the Build Packet directory, the same root the Run Record uses;
490
+ * `targetRepo` is where `qa run` writes full verdicts, needed when it is not
491
+ * the packet directory (see loadPriorQaVerdict). Packet-less runs (`--site`,
492
+ * raw map id) have no Run Record home, so they get `unknown` / `no_prior_run`
493
+ * throughout — which is the truth, not a silence.
494
+ */
495
+ export function annotateQaAssertionCauses(assertions, { baseDir = null, targetRepo = null, mapId = null, currentRunId = null, isFinding } = {}) {
496
+ const lookup = baseDir
497
+ ? loadPriorQaVerdict({ baseDir, targetRepo, mapId, currentRunId })
498
+ : { verdict: null, record: null, path: null, reason: "no_prior_run" };
499
+ const prior = lookup.verdict ? priorSetFromVerdict(lookup.verdict, { isFinding }) : null;
500
+ const noPriorReason = lookup.reason || "no_prior_run";
501
+ const findings = [];
502
+ for (const assertion of Array.isArray(assertions) ? assertions : []) {
503
+ if (!assertion || typeof assertion !== "object" || !isFinding(assertion)) continue;
504
+ const { cause, cause_reason } = classifyQaAssertion(assertion, { prior, noPriorReason });
505
+ assertion.cause = cause;
506
+ assertion.cause_reason = cause_reason;
507
+ findings.push(assertion);
508
+ }
509
+ const { total, counts } = summarizeCauses(findings);
510
+ return {
511
+ schema_version: CAUSE_SUMMARY_SCHEMA,
512
+ surface: "qa",
513
+ total,
514
+ counts,
515
+ // The RUN RECORD's id, the same identity the doctor summary reports, so
516
+ // "which previous run was this compared against" has one answer across
517
+ // both surfaces. The attempt actually read rides alongside under its own
518
+ // name rather than being conflated with it.
519
+ prior_run_id: text(lookup.record?.run_id) || null,
520
+ prior_qa_attempt_run_id: text(lookup.verdict?.run_id) || null,
521
+ comparison: lookup.verdict ? "prior_run" : noPriorReason,
522
+ };
523
+ }
524
+
525
+ const CAUSE_SUMMARY_SCHEMA = "campaigns-os-finding-cause/v0";
526
+
527
+ /**
528
+ * The doctor twin. `errors` and `warnings` are the doctor output's own arrays;
529
+ * both are stamped in place. Returns the same summary shape.
530
+ */
531
+ export function annotateDoctorIssueCauses({ errors = [], warnings = [], baseDir = null, mapId = null, currentRunId = null } = {}) {
532
+ const lookup = baseDir
533
+ ? loadPriorDoctorFindings({ baseDir, mapId, currentRunId })
534
+ : { prior: null, record: null, reason: "no_prior_run" };
535
+ const noPriorReason = lookup.reason || "no_prior_run";
536
+ const findings = [];
537
+ const stamp = (issues, status) => {
538
+ for (const issue of Array.isArray(issues) ? issues : []) {
539
+ if (!issue || typeof issue !== "object" || Array.isArray(issue)) continue;
540
+ const { cause, cause_reason } = classifyDoctorIssue(issue, status, { prior: lookup.prior, noPriorReason });
541
+ issue.cause = cause;
542
+ issue.cause_reason = cause_reason;
543
+ findings.push(issue);
544
+ }
545
+ };
546
+ stamp(errors, "error");
547
+ stamp(warnings, "warning");
548
+ const { total, counts } = summarizeCauses(findings);
549
+ return {
550
+ schema_version: CAUSE_SUMMARY_SCHEMA,
551
+ surface: "doctor",
552
+ total,
553
+ counts,
554
+ prior_run_id: text(lookup.record?.run_id) || null,
555
+ comparison: lookup.prior ? "prior_run" : noPriorReason,
556
+ };
557
+ }