@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,192 @@
1
+ // QA verdict discovery: the one walk over the local verdicts a campaign has
2
+ // written, with the projections each reader needs.
3
+ //
4
+ // `qa run` writes full verdicts to `<target repo>/qa-output/<campaign_slug>/
5
+ // <run_id>.json`, and the Assembly Report's qa stage records the paths it
6
+ // produced. Three readers walked that on their own — the ledger-divergence
7
+ // check (repo-relative list), run-record inference (the best candidate by
8
+ // identity score and time) and closeout (digests of the recorded paths) —
9
+ // each with its own copy of the identity rule and the directory spelling.
10
+ //
11
+ // A leaf: node built-ins, the workspace constant and the string leaves only.
12
+
13
+ import { createHash } from "node:crypto";
14
+ import { readdirSync, readFileSync, statSync } from "node:fs";
15
+ import { dirname, join, relative, resolve } from "node:path";
16
+ import { QA_OUTPUT_REL_PATH } from "./campaign-workspace.mjs";
17
+ import { absentOrMalformed } from "./fs-identity.mjs";
18
+ import { isPlainObject as isObject, normalizeString as optionalString } from "./repo-scan.mjs";
19
+ import { normalizePublicRouteSlug } from "./route-identity.mjs";
20
+
21
+ // The slug as identity, null when absent: the one spelling the identifiers
22
+ // and the candidate score both read.
23
+ function qaVerdictSlug(packet) {
24
+ return normalizePublicRouteSlug(packet?.campaign?.public_route_slug) || null;
25
+ }
26
+
27
+ // The names a campaign's verdicts are filed and stamped under: the map id and
28
+ // the slug as identity. A verdict is this campaign's when its campaign_slug
29
+ // is one of them.
30
+ export function qaVerdictIdentifiers(packet) {
31
+ return [...new Set([optionalString(packet?.spec?.map_id), qaVerdictSlug(packet)].filter(Boolean))];
32
+ }
33
+
34
+ export function qaVerdictIdentityMatch(verdict, packet) {
35
+ const slug = optionalString(verdict?.campaign_slug);
36
+ return Boolean(slug) && qaVerdictIdentifiers(packet).includes(slug);
37
+ }
38
+
39
+ // `<root>/qa-output/<identifier>` — the directory `qa run` writes under.
40
+ export function qaVerdictDir(root, identifier) {
41
+ return join(root, QA_OUTPUT_REL_PATH, identifier);
42
+ }
43
+
44
+ // The verdict paths the Assembly Report's qa stage records (any `*.json`
45
+ // string under a path/file/verdict/output-named key, or inside outputs[] /
46
+ // artifacts[] / qa), relative to the report unless absolute.
47
+ export function qaVerdictPathHints(report) {
48
+ const paths = [];
49
+ const visit = (value) => {
50
+ if (Array.isArray(value)) {
51
+ for (const item of value) visit(item);
52
+ return;
53
+ }
54
+ if (isObject(value)) {
55
+ for (const [key, item] of Object.entries(value)) {
56
+ if (typeof item === "string" && /(?:path|file|verdict|output)$/i.test(key)) visit(item);
57
+ else if (key === "outputs" || key === "artifacts" || key === "qa") visit(item);
58
+ }
59
+ return;
60
+ }
61
+ if (typeof value === "string" && /\.json(?:[?#].*)?$/i.test(value.trim())) paths.push(value.trim());
62
+ };
63
+ visit(report?.qa || null);
64
+ visit(report?.stages?.qa || null);
65
+ return [...new Set(paths)];
66
+ }
67
+
68
+ function readVerdictFile(path) {
69
+ try {
70
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
71
+ return isObject(parsed) ? parsed : null;
72
+ } catch (error) {
73
+ if (absentOrMalformed(error)) return null;
74
+ throw error;
75
+ }
76
+ }
77
+
78
+ function sha256File(path) {
79
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
80
+ }
81
+
82
+ // Every verdict candidate for a campaign: the files the report records
83
+ // (source "assembly_report", resolved against the report) and every `*.json`
84
+ // under `qa-output/<identifier>/` beneath each root (source "qa_output").
85
+ // { path, repoRelPath, source, verdict, sha256, mtimeMs, identityMatch, trusted }
86
+ // `verdict` is null for a recorded path that exists but is not a JSON object
87
+ // (it still has a digest: closeout compares bytes, not shape). `trusted` is
88
+ // false only for a verdict the receiver stamped `trusted: false` (an
89
+ // anonymous submission); locally written verdicts never carry the field.
90
+ // An absent directory or file, or a file that is not JSON, is no candidate;
91
+ // any other read failure (a permission error, an I/O error) is the caller's.
92
+ //
93
+ // Lazy per candidate: a directory's names are listed when the walk reaches
94
+ // it, but each file is read (and hashed, when asked) only as the consumer
95
+ // pulls it, so a reader that stops early — closeout after a few digests —
96
+ // pays for what it took, not for every recorded path or every listed file.
97
+ export function* iterateQaVerdicts({ packet = null, report = null, reportPath = null, roots = [], withDigest = false } = {}) {
98
+ const seen = new Set();
99
+ const candidate = (path, source, repoRelPath) => {
100
+ const absolute = resolve(path);
101
+ if (seen.has(absolute)) return null;
102
+ let stats;
103
+ try {
104
+ stats = statSync(absolute);
105
+ } catch (error) {
106
+ if (absentOrMalformed(error)) return null;
107
+ throw error;
108
+ }
109
+ if (!stats.isFile()) return null;
110
+ seen.add(absolute);
111
+ const verdict = readVerdictFile(absolute);
112
+ let sha256 = null;
113
+ if (withDigest) {
114
+ try {
115
+ sha256 = sha256File(absolute);
116
+ } catch (error) {
117
+ // The file can vanish between the stat and the read; nothing else may.
118
+ if (!absentOrMalformed(error)) throw error;
119
+ }
120
+ }
121
+ return {
122
+ path: absolute,
123
+ repoRelPath,
124
+ source,
125
+ verdict,
126
+ sha256,
127
+ mtimeMs: stats.mtimeMs,
128
+ identityMatch: verdict ? qaVerdictIdentityMatch(verdict, packet) : false,
129
+ trusted: verdict?.trusted !== false,
130
+ };
131
+ };
132
+
133
+ const reportBase = reportPath ? dirname(resolve(reportPath)) : null;
134
+ for (const hint of qaVerdictPathHints(report)) {
135
+ const found = candidate(reportBase ? resolve(reportBase, hint) : hint, "assembly_report", null);
136
+ if (found) yield found;
137
+ }
138
+
139
+ const uniqueRoots = [...new Set(roots.filter((root) => typeof root === "string" && root).map((root) => resolve(root)))];
140
+ for (const root of uniqueRoots) {
141
+ for (const identifier of qaVerdictIdentifiers(packet)) {
142
+ const dir = qaVerdictDir(root, identifier);
143
+ let names = [];
144
+ try {
145
+ names = readdirSync(dir, { withFileTypes: true })
146
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
147
+ .map((entry) => entry.name)
148
+ .sort();
149
+ } catch (error) {
150
+ if (absentOrMalformed(error)) continue;
151
+ throw error;
152
+ }
153
+
154
+ for (const name of names) {
155
+ const path = join(dir, name);
156
+ // Repo-relative on purpose: divergence evidence must read the same
157
+ // wherever the repo sits on disk.
158
+ const found = candidate(path, "qa_output", relative(root, path).split("\\").join("/"));
159
+ if (found) yield found;
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ export function discoverQaVerdicts(options = {}) {
166
+ return [...iterateQaVerdicts(options)];
167
+ }
168
+
169
+ // Run-record inference: how well a candidate answers "this campaign, this
170
+ // deployment". Identity first, then the verdict schema, then a deploy origin
171
+ // the assertions actually visited.
172
+ export function qaVerdictCandidateScore(candidate, packet) {
173
+ const verdict = candidate?.verdict || {};
174
+ const mapId = optionalString(packet?.spec?.map_id);
175
+ const slug = qaVerdictSlug(packet);
176
+ let score = 0;
177
+ if (mapId && verdict.campaign_slug === mapId) score += 100;
178
+ if (slug && verdict.campaign_slug === slug) score += 80;
179
+ if (verdict.schema_version === "1.0" || verdict.schema_version === "campaigns-os-qa-verdict/v0") score += 10;
180
+ const deployOrigins = [optionalString(packet?.deploy?.preview_url), optionalString(packet?.deploy?.production_url)].filter(Boolean);
181
+ const assertionUrls = Array.isArray(verdict.assertions)
182
+ ? verdict.assertions.map((assertion) => optionalString(assertion?.url)).filter(Boolean)
183
+ : [];
184
+ if (deployOrigins.some((origin) => assertionUrls.some((url) => url.startsWith(origin)))) score += 25;
185
+ return score;
186
+ }
187
+
188
+ export function qaVerdictCandidateTime(candidate) {
189
+ const completedAt = Date.parse(candidate?.verdict?.completed_at || "");
190
+ if (Number.isFinite(completedAt)) return completedAt;
191
+ return Number(candidate?.mtimeMs || 0);
192
+ }
@@ -0,0 +1,105 @@
1
+ // The one QA-verdict publish rail. `qa run` and `qa publish` both post a
2
+ // verdict to the QA portal through `publishQaVerdict`, and both stamp what
3
+ // came back onto the Run Record through `qaVerdictPublishBlock`, so a verdict
4
+ // published at the end of a run and one published later from disk are
5
+ // classified by the same rule and recorded in the same shape.
6
+ //
7
+ // The outcome is classified the way the Run Record remit is (#397): by what
8
+ // the receiver answered, not by whether the transport threw. A 409 is
9
+ // `already_stored` — the portal already holds this run_id, which is the
10
+ // outcome the send was for — and a 2xx with a non-JSON body is an
11
+ // acknowledgement whose payload could not be read, not a refusal. Only
12
+ // another non-2xx, or a transport failure, is a failed publish.
13
+
14
+ import { classifyRemitOutcome, describeRemitBaseKind, remit } from "./remit.mjs";
15
+
16
+ export const QA_VERDICT_PUBLISH_ENDPOINT = "/api/qa/verdicts";
17
+
18
+ // Who published: the run that produced the verdict, or a later `qa publish`
19
+ // of the stored copy. Carried on the Run Record block so a reader can tell a
20
+ // verdict that went out with its run from one published from disk.
21
+ export const QA_VERDICT_PUBLISHERS = Object.freeze({
22
+ run: "qa run",
23
+ publish: "qa publish",
24
+ });
25
+
26
+ // The durable states the Run Record block carries. `skipped` is a run whose
27
+ // publish was off (`--no-post-verdict`, consent off); `ok` and `failed` are a
28
+ // send the receiver answered or did not, classified in `result`.
29
+ export const QA_VERDICT_PUBLISH_STATES = Object.freeze(["skipped", "ok", "failed"]);
30
+
31
+ /**
32
+ * POST `verdict` to `<proxyBase>/api/qa/verdicts` and classify the answer.
33
+ * Never throws for a receiver or transport failure — the caller decides what
34
+ * a failed publish means (a `qa run` keeps its local verdict and reports; a
35
+ * `qa publish` exits non-zero). Returns:
36
+ *
37
+ * { attempted: true, ok, error, endpoint, result, http_status, base_kind, response }
38
+ *
39
+ * `response` is the parsed receiver body on a parsed 2xx, else null. The
40
+ * publish attaches no credential, and says so to the transport gate: plain
41
+ * http is still refused for anything but a loopback receiver, but the warning
42
+ * must not claim a credential is travelling in clear.
43
+ */
44
+ export async function publishQaVerdict(verdict, proxyBase, { fetchImpl = globalThis.fetch, remitImpl = remit } = {}) {
45
+ let httpStatus = null;
46
+ let response = null;
47
+ let failure = null;
48
+ try {
49
+ response = await remitImpl(QA_VERDICT_PUBLISH_ENDPOINT, verdict, proxyBase, {
50
+ fetchImpl,
51
+ label: "QA verdict publish",
52
+ credential: null,
53
+ onResponse: (answer) => {
54
+ httpStatus = Number(answer?.status) || null;
55
+ },
56
+ });
57
+ } catch (error) {
58
+ failure = error;
59
+ }
60
+ const outcome = classifyRemitOutcome(failure);
61
+ return {
62
+ attempted: true,
63
+ ok: outcome.ok,
64
+ error: outcome.error,
65
+ endpoint: QA_VERDICT_PUBLISH_ENDPOINT,
66
+ result: outcome.result,
67
+ http_status: outcome.http_status ?? httpStatus,
68
+ base_kind: describeRemitBaseKind(proxyBase),
69
+ response: outcome.ok && !failure ? response : null,
70
+ };
71
+ }
72
+
73
+ /** The outcome block for a run whose publish was turned off: nothing sent, nothing known. */
74
+ export function skippedQaVerdictPublish() {
75
+ return { attempted: false, ok: null, error: null, endpoint: null, result: null, http_status: null, base_kind: null, response: null };
76
+ }
77
+
78
+ /**
79
+ * The Run Record's `qa_verdict_publish` block for one publish outcome. The
80
+ * verdict's run_id is the idempotency key a later `qa publish` reads; the
81
+ * state is derived from the outcome the same way `remit_state` is from a
82
+ * remit (`ok` when the receiver's answer counts as stored, `failed` when it
83
+ * refused or never answered, `skipped` when nothing was sent).
84
+ */
85
+ export function qaVerdictPublishBlock(outcome, { verdictRunId, publisher, publishedAt = null }) {
86
+ const attempted = Boolean(outcome?.attempted);
87
+ const ok = attempted ? outcome.ok === true : null;
88
+ return {
89
+ verdict_run_id: String(verdictRunId),
90
+ publisher,
91
+ attempted,
92
+ ok,
93
+ error: attempted && typeof outcome.error === "string" ? outcome.error : null,
94
+ endpoint: attempted ? outcome.endpoint ?? QA_VERDICT_PUBLISH_ENDPOINT : null,
95
+ state: attempted ? (ok ? "ok" : "failed") : "skipped",
96
+ result: attempted ? outcome.result ?? null : null,
97
+ base_kind: attempted ? outcome.base_kind ?? null : null,
98
+ published_at: attempted && ok ? publishedAt : null,
99
+ };
100
+ }
101
+
102
+ /** The QA portal link for a published verdict. */
103
+ export function qaPortalUrl(proxyBase, mapId, runId) {
104
+ return `${String(proxyBase).replace(/\/+$/, "")}/qa?slug=${encodeURIComponent(mapId)}&run=${encodeURIComponent(runId)}`;
105
+ }
@@ -0,0 +1,287 @@
1
+ export const QA_SCHEMA_VERSION = "1.0";
2
+
3
+ export const STATUS = Object.freeze({
4
+ PASS: "pass",
5
+ FAIL: "fail",
6
+ WARN: "warn",
7
+ SKIPPED: "skipped",
8
+ MANUAL_REVIEW: "manual_review",
9
+ });
10
+
11
+ // The dispositions that END a run session, and the single definition of that
12
+ // set. A blocked attempt keeps the session open for repair; these close it.
13
+ //
14
+ // Both sides of the decision read this: autoEndRunSessionAfterTerminalQa, which
15
+ // closes the session and remits under its run_id, and buildQaCloseoutActions,
16
+ // which prints the run-record command an operator runs next. Membership is
17
+ // enumerated rather than excluded so an unrecognised disposition — a newer
18
+ // toolkit's verdict, a foreign one — falls on the session-keeping side for
19
+ // BOTH. Getting that backwards on one side only is the whole bug class: the
20
+ // printed command would spend a run_id the session still holds, and the
21
+ // session's own close would then be refused 409 by the receiver, which stores
22
+ // one record per run_id and has no replace verb.
23
+ export const SESSION_ENDING_DISPOSITIONS = Object.freeze(new Set(["ready", "ready_with_exceptions"]));
24
+
25
+ export const SEVERITY = Object.freeze({
26
+ INFO: "info",
27
+ WARN: "warn",
28
+ BLOCKER: "blocker",
29
+ });
30
+
31
+ // Canonical vocabulary of every assertion family the QA runner emits. External
32
+ // consumers (e.g. the portal proxy's verdict allowlist) import this instead of
33
+ // scanning emitter source for `family:` literals. Order is load-bearing:
34
+ // theme_gate first, then the gate-suppressed families in the order a
35
+ // gate-blocked verdict emits its skipped audit assertions —
36
+ // GATE_SUPPRESSED_FAMILIES in qa-node.mjs derives from this list. A drift test
37
+ // in qa-verdict.test.mjs asserts set equality against the literals the
38
+ // emitters actually use.
39
+ export const QA_ASSERTION_FAMILY_VOCABULARY = Object.freeze([
40
+ "theme_gate",
41
+ "polish_gate",
42
+ "funnel-flow",
43
+ "meta-tags",
44
+ "api-metadata",
45
+ "browser-runtime",
46
+ "template_residue",
47
+ "pricing",
48
+ "browser-test-order",
49
+ "browser-receipt-rendering",
50
+ "analytics-correctness",
51
+ "analytics-parity",
52
+ "parity-capture",
53
+ ]);
54
+
55
+ // A waiver record riding an assertion (packet 01: the `qa waive` lane for
56
+ // analytics-correctness:purchase-fires). Shape mirrors report.theme.waiver:
57
+ // { reason, waived_by, waived_at }.
58
+ function assertionCarriesWaiver(assertion) {
59
+ const waiver = assertion?.waiver;
60
+ return !!(waiver && typeof waiver === "object" && !Array.isArray(waiver));
61
+ }
62
+
63
+ export function computeDisposition(assertions) {
64
+ let hasBlocker = false;
65
+ let hasSoftIssue = false;
66
+ for (const assertion of assertions) {
67
+ if (assertion.status === STATUS.FAIL && assertion.severity === SEVERITY.BLOCKER) {
68
+ hasBlocker = true;
69
+ } else if (
70
+ assertion.status === STATUS.WARN ||
71
+ assertion.status === STATUS.MANUAL_REVIEW ||
72
+ (assertion.status === STATUS.FAIL && assertion.severity === SEVERITY.WARN) ||
73
+ // Packet 01 / ratified I-9: a waived blocker is an accepted exception,
74
+ // never a clean pass — any assertion carrying a waiver record keeps the
75
+ // disposition at ready_with_exceptions, never plain ready. (An unwaived
76
+ // failing blocker still lands in the branch above and blocks.)
77
+ assertionCarriesWaiver(assertion)
78
+ ) {
79
+ hasSoftIssue = true;
80
+ }
81
+ }
82
+ if (hasBlocker) return "blocked";
83
+ if (hasSoftIssue) return "ready_with_exceptions";
84
+ return "ready";
85
+ }
86
+
87
+ function dispositionWithCommercial(assertions, commercial) {
88
+ const disposition = computeDisposition(assertions);
89
+ if (disposition === "blocked") return disposition;
90
+ return commercial?.status === "mismatch"
91
+ ? "ready_with_exceptions"
92
+ : disposition;
93
+ }
94
+
95
+ export function createVerdict({
96
+ runId,
97
+ mapId,
98
+ publicRouteSlug = null,
99
+ campaignRefId = null,
100
+ specVersion,
101
+ specHash,
102
+ startedAt,
103
+ completedAt,
104
+ runtime,
105
+ operator = "",
106
+ baseUrl = null,
107
+ entryUrls = [],
108
+ pageUrls = [],
109
+ testedUrls = [],
110
+ assertions,
111
+ testOrders = [],
112
+ exceptions = null,
113
+ commercial = null,
114
+ causeSummary = null,
115
+ browser = null,
116
+ }) {
117
+ const normalizedExceptions = Array.isArray(exceptions)
118
+ ? exceptions
119
+ : deriveExceptions(assertions);
120
+ const normalizedBaseUrl = optionalString(baseUrl);
121
+ const normalizedEntryUrls = Array.isArray(entryUrls) ? entryUrls : [];
122
+ const normalizedPageUrls = Array.isArray(pageUrls) ? pageUrls : [];
123
+ const normalizedTestedUrls = Array.isArray(testedUrls) ? testedUrls : [];
124
+ return {
125
+ schema_version: QA_SCHEMA_VERSION,
126
+ run_id: runId,
127
+ // campaign_slug carries the Map ID for schema back-compat; the true public
128
+ // route slug rides alongside so consumers stop conflating the two.
129
+ campaign_slug: mapId,
130
+ public_route_slug: optionalString(publicRouteSlug),
131
+ campaign_ref_id: campaignRefId,
132
+ spec_version: specVersion,
133
+ spec_hash: specHash,
134
+ started_at: startedAt,
135
+ completed_at: completedAt,
136
+ runtime,
137
+ operator,
138
+ ...(normalizedBaseUrl ? { base_url: normalizedBaseUrl } : {}),
139
+ entry_urls: normalizedEntryUrls,
140
+ page_urls: normalizedPageUrls,
141
+ tested_urls: normalizedTestedUrls,
142
+ disposition: dispositionWithCommercial(assertions, commercial),
143
+ assertions,
144
+ test_orders: testOrders,
145
+ exceptions: normalizedExceptions,
146
+ ...(commercial && typeof commercial === "object" && !Array.isArray(commercial)
147
+ ? { commercial }
148
+ : {}),
149
+ // Additive, and present ONLY when the run has something to say about a
150
+ // requested browser pass it did not perform — today, a --browser run a
151
+ // blocked gate finalized before any page was rendered. Absence therefore
152
+ // means "this verdict makes no claim about the browser pass", not "the
153
+ // browser pass ran": consumers read the browser-runtime assertions and
154
+ // tested_urls for that, exactly as they did before the field existed.
155
+ ...(browser && typeof browser === "object" && !Array.isArray(browser)
156
+ ? { browser }
157
+ : {}),
158
+ // Additive, and absent on verdicts emitted before per-finding cause
159
+ // classification existed. Consumers must tolerate its absence rather than
160
+ // read a missing summary as "nothing was caused by the change".
161
+ ...(causeSummary && typeof causeSummary === "object" && !Array.isArray(causeSummary)
162
+ ? { cause_summary: causeSummary }
163
+ : {}),
164
+ };
165
+ }
166
+
167
+ function optionalString(value) {
168
+ if (value == null) return null;
169
+ const text = String(value).trim();
170
+ return text || null;
171
+ }
172
+
173
+ /**
174
+ * Is this assertion a FINDING — something the operator has to look at — rather
175
+ * than a clean pass? One definition, because the exception projection and the
176
+ * per-finding cause classification must count the same set: a summary that
177
+ * says "11 findings, 9 pre-existing" is unreadable if "finding" means one
178
+ * thing in the count and another in the list.
179
+ */
180
+ export function isFindingAssertion(assertion) {
181
+ return assertion?.status === STATUS.FAIL
182
+ || assertion?.status === STATUS.WARN
183
+ || assertion?.status === STATUS.MANUAL_REVIEW
184
+ || assertion?.severity === SEVERITY.WARN
185
+ || assertion?.severity === SEVERITY.BLOCKER;
186
+ }
187
+
188
+ export function deriveExceptions(assertions = []) {
189
+ if (!Array.isArray(assertions)) return [];
190
+ return assertions
191
+ .filter((assertion) => isFindingAssertion(assertion))
192
+ .map((assertion) => ({
193
+ id: assertion.id || null,
194
+ family: assertion.family || null,
195
+ page: assertion.page || null,
196
+ url: assertion.url || null,
197
+ status: assertion.status || null,
198
+ severity: assertion.severity || null,
199
+ expected: assertion.expected,
200
+ actual: assertion.actual,
201
+ // Cause rides the projection so the exceptions list — the quick read an
202
+ // operator or portal actually looks at — answers "was this us?" without
203
+ // re-joining back to the assertions array.
204
+ ...(assertion.cause !== undefined ? { cause: assertion.cause } : {}),
205
+ ...(assertion.cause_reason !== undefined ? { cause_reason: assertion.cause_reason } : {}),
206
+ }));
207
+ }
208
+
209
+ export function validateVerdict(verdict) {
210
+ const errors = [];
211
+ if (!verdict || typeof verdict !== "object" || Array.isArray(verdict)) {
212
+ return ["verdict: must be an object"];
213
+ }
214
+ for (const field of ["run_id", "campaign_slug", "spec_version", "spec_hash", "started_at", "completed_at", "runtime", "disposition"]) {
215
+ if (typeof verdict[field] !== "string" || verdict[field].length === 0) errors.push(`${field}: required non-empty string`);
216
+ }
217
+ if (verdict.schema_version !== QA_SCHEMA_VERSION) errors.push(`schema_version: expected ${QA_SCHEMA_VERSION}`);
218
+ if (!Array.isArray(verdict.assertions)) errors.push("assertions: must be an array");
219
+ if (!Array.isArray(verdict.test_orders)) errors.push("test_orders: must be an array");
220
+ if (!Array.isArray(verdict.exceptions)) errors.push("exceptions: must be an array");
221
+ if (verdict.commercial !== undefined
222
+ && (!verdict.commercial || typeof verdict.commercial !== "object" || Array.isArray(verdict.commercial))) {
223
+ errors.push("commercial: must be an object when present");
224
+ }
225
+ return errors;
226
+ }
227
+
228
+ // Purchase-proof coverage summary.
229
+ //
230
+ // COUNTS ONLY, deliberately. This summary is written onto the Assembly Report's
231
+ // qa stage, which is committed and rides into the readback bundle. The sidecar
232
+ // projection strips order ids, refs, emails and checkout URLs out of the verdict
233
+ // before it can be published, and sidecar-bundle conformance FAILS a bundle
234
+ // whose projected order arrays are non-empty. So the signal that a purchase
235
+ // actually happened has to be numbers, never the orders themselves.
236
+ export function summarizePurchaseProof({ verdict = null, proofPolicy = null } = {}) {
237
+ const orders = Array.isArray(verdict?.test_orders) ? verdict.test_orders.filter((order) => order && typeof order === "object") : [];
238
+ // A real id is a non-empty trimmed string or a POSITIVE number. Numeric `0`
239
+ // is the conventional placeholder the runner emits when an id was never
240
+ // received, and `Number.isFinite(0)` is true — counting it would report an
241
+ // order as created on a run that created nothing. Note the `??` chain stops
242
+ // at a literal `0` (it is not nullish), so a zero here is never rescued by
243
+ // the next field either; both facts push the same way, toward not counting.
244
+ const created = orders.filter((order) => {
245
+ const id = order.next_order_id ?? order.order_id ?? order.ref_id;
246
+ if (typeof id === "string") return id.trim().length > 0;
247
+ return Number.isFinite(id) && id > 0;
248
+ });
249
+ return {
250
+ declared_order_path_depth: optionalString(proofPolicy?.order_path_depth),
251
+ declared_typed_card_depth: optionalString(proofPolicy?.typed_card_depth),
252
+ order_paths_executed: orders.length,
253
+ orders_created: created.length,
254
+ orders_verified: orders.filter((order) => order.verification?.verified === true).length,
255
+ // null, not false, when nothing ran: "no order was out of test mode" and
256
+ // "no order ran at all" are different facts.
257
+ all_orders_test_mode: orders.length ? orders.every((order) => order.is_test === true) : null,
258
+ };
259
+ }
260
+
261
+ // The browser placeholder-text residue gate (H3.1) emits one assertion per
262
+ // page as `template-residue:<page_id>:placeholder-text`. Both the emitter
263
+ // (qa-browser.mjs) and the stage-ledger summary below build the id from this
264
+ // suffix so the two cannot drift.
265
+ export const PLACEHOLDER_TEXT_ASSERTION_SUFFIX = ":placeholder-text";
266
+
267
+ export function isPlaceholderTextAssertion(assertion) {
268
+ const id = optionalString(assertion?.id);
269
+ return Boolean(id && id.startsWith("template-residue:") && id.endsWith(PLACEHOLDER_TEXT_ASSERTION_SUFFIX));
270
+ }
271
+
272
+ // What the verdict says about the placeholder-text gate, in a shape the
273
+ // Assembly Report's qa stage can carry: `pass` only when the gate ran on at
274
+ // least one page and no page failed; `fail` when any page failed; null when
275
+ // the gate never ran (gate-skipped verdict, a non-browser run, an older
276
+ // toolkit), so a doctor reading the report cannot mistake silence for a pass.
277
+ export function summarizePlaceholderTextGate(verdict) {
278
+ const assertions = (Array.isArray(verdict?.assertions) ? verdict.assertions : []).filter(isPlaceholderTextAssertion);
279
+ const evaluated = assertions.filter((assertion) => assertion.status === STATUS.PASS || assertion.status === STATUS.FAIL);
280
+ if (!evaluated.length) return null;
281
+ const failed = evaluated.filter((assertion) => assertion.status === STATUS.FAIL);
282
+ return {
283
+ status: failed.length ? STATUS.FAIL : STATUS.PASS,
284
+ pages_checked: evaluated.length,
285
+ pages_failed: failed.length,
286
+ };
287
+ }