@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,1100 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import {
4
+ buildPolishCaptureIntegrity,
5
+ canonicalJson,
6
+ captureOrigin,
7
+ documentResponseAcceptable,
8
+ hiddenEagerSourceUnresolved,
9
+ ledgerProblemCounts,
10
+ MAX_PAGE_LOAD_MEDIA_ANCESTORS,
11
+ MAX_PAGE_LOAD_MEDIA_ELEMENTS,
12
+ MAX_PAGE_LOAD_MEDIA_SOURCES_PER_ELEMENT,
13
+ MAX_PAGE_LOAD_RESOURCE_LEDGER_ENTRIES,
14
+ mediaCollectionStatus,
15
+ mediaFetchTotals,
16
+ mediaFetchedResources,
17
+ normalizedBuildFingerprint,
18
+ normalizedCampaignSlug,
19
+ normalizedNetworkidle,
20
+ normalizedViewport,
21
+ normalizePageLoadRoute,
22
+ originMatchesCapture,
23
+ POLISH_CAPTURE_PRODUCER,
24
+ POLISH_CAPTURE_INTEGRITY_ALGORITHM,
25
+ POLISH_CAPTURE_INTEGRITY_SCHEMA_VERSION,
26
+ POLISH_CAPTURE_WARNING_PROBLEM_CODES,
27
+ POLISH_PRELOAD_ATTRIBUTES,
28
+ POLISH_RESOURCE_TYPES,
29
+ POLISH_ROUTE_CAPTURE_SCHEMA_VERSION,
30
+ preloadDefersFetch,
31
+ producerStatus,
32
+ resourceLedgerMetrics,
33
+ resourceLedgerSort,
34
+ resourceTypeKnown,
35
+ responseCollectionProblemCode,
36
+ sourceReferenceSort,
37
+ unattributedMediaTransfers,
38
+ failedRequestProblemCode,
39
+ polishCaptureMeasurementStatus,
40
+ redactCaptureUrl,
41
+ } from "./polish-capture.mjs";
42
+ import {
43
+ assessCheckpointWaivers,
44
+ checkpointStateFingerprint,
45
+ projectCheckpointWaiverAssessment,
46
+ } from "./checkpoint-waiver.mjs";
47
+
48
+ export const HIDDEN_EAGER_MEDIA_SCOPE = "polish.hidden_eager_media";
49
+ export const HIDDEN_EAGER_MEDIA_THRESHOLD_BYTES = 1_048_576;
50
+ export const POLISH_PAGE_LOAD_SCHEMA_VERSION = "campaigns-os-polish-page-load/v0";
51
+ export const POLISH_PAGE_LOAD_PRODUCER = POLISH_CAPTURE_PRODUCER;
52
+ const MAX_HIDDEN_EAGER_MEDIA_FINDING_RESOURCES = 64;
53
+
54
+ const RESOURCE_TYPES = new Set(POLISH_RESOURCE_TYPES);
55
+ const PRELOAD_ATTRIBUTES = new Set(POLISH_PRELOAD_ATTRIBUTES);
56
+ const RESOURCE_TYPE_STATUSES = new Set(["ambiguous", "known", "unknown"]);
57
+ const SOURCE_KINDS = new Set(["current_src", "observed_source", "source_src_attribute", "src_attribute"]);
58
+ const SOURCE_SENTINELS = new Set(["[malformed-url]", "[non-http-url]", "[url-too-long]"]);
59
+ export const POLISH_CAPTURE_PROBLEM_CODES = Object.freeze([
60
+ "browser_unavailable",
61
+ "cache_observed",
62
+ "capture_binding_mismatch",
63
+ "capture_integrity_invalid",
64
+ "capture_shape_invalid",
65
+ "capture_subject_invalid",
66
+ "cross_origin_request_failed",
67
+ "dependency_request_failed",
68
+ "document_response_ambiguous",
69
+ "document_response_error",
70
+ "document_response_missing",
71
+ "document_context_changed",
72
+ "final_document_route_mismatch",
73
+ "media_collection_unavailable",
74
+ "media_element_overflow",
75
+ "media_measurement_failed",
76
+ "media_ancestor_overflow",
77
+ "media_source_unresolvable",
78
+ "media_source_overflow",
79
+ "media_transfer_unattributed",
80
+ "networkidle_measurement_invalid",
81
+ "producer_failed",
82
+ "producer_timeout",
83
+ "resource_aliases_invalid",
84
+ "resource_ledger_overflow",
85
+ "resource_type_ambiguous",
86
+ "resource_type_unknown",
87
+ "resource_url_unresolvable",
88
+ "response_collection_empty",
89
+ "response_collection_failed",
90
+ "response_collection_status_invalid",
91
+ "response_collection_unavailable",
92
+ "response_record_overflow",
93
+ "service_worker_observed",
94
+ "transfer_size_unavailable",
95
+ "url_length_overflow",
96
+ ]);
97
+ const CAPTURE_PROBLEM_CODES = new Set(POLISH_CAPTURE_PROBLEM_CODES);
98
+ const CAPTURE_WARNING_PROBLEM_CODES = new Set(POLISH_CAPTURE_WARNING_PROBLEM_CODES);
99
+ const MAX_CAPTURE_WARNING_ORIGINS = 32;
100
+
101
+ function normalizeString(value) {
102
+ return typeof value === "string" && value.trim() ? value.trim() : null;
103
+ }
104
+
105
+ function normalizedStrings(values) {
106
+ return [...new Set((Array.isArray(values) ? values : [])
107
+ .map((value) => normalizeString(value)?.toLocaleLowerCase("en-US"))
108
+ .filter((value) => value && /^[a-z0-9][a-z0-9_-]{0,63}$/.test(value))
109
+ .filter(Boolean))].sort();
110
+ }
111
+
112
+ function normalizedRoutes(values) {
113
+ return [...new Set((Array.isArray(values) ? values : [])
114
+ .map(normalizePageLoadRoute)
115
+ .filter(Boolean))].sort();
116
+ }
117
+
118
+ function emptyWaiverAssessment() {
119
+ return {
120
+ active: null,
121
+ inert_counts: { stale: 0, foreign: 0, malformed: 0, expired: 0 },
122
+ };
123
+ }
124
+
125
+ function pageLoadCheckpointSubject({ buildFingerprint, slug, routeScope, routes, viewports } = {}) {
126
+ return {
127
+ build_fingerprint: normalizedBuildFingerprint(buildFingerprint),
128
+ campaign_slug: normalizedCampaignSlug(slug),
129
+ route_scope: normalizeString(routeScope)?.toLocaleLowerCase("en-US") || null,
130
+ routes: normalizedRoutes(routes),
131
+ viewports: normalizedStrings(viewports),
132
+ };
133
+ }
134
+
135
+ function captureKey(route, viewport) {
136
+ return `${route}\u0000${viewport}`;
137
+ }
138
+
139
+ function isNonnegativeInteger(value) {
140
+ return Number.isInteger(value) && value >= 0;
141
+ }
142
+
143
+ function projectedInteger(value) {
144
+ return isNonnegativeInteger(value) ? value : null;
145
+ }
146
+
147
+ function projectedBoolean(value) {
148
+ return typeof value === "boolean" ? value : null;
149
+ }
150
+
151
+ function isSha256(value) {
152
+ return typeof value === "string" && /^sha256:[a-f0-9]{64}$/.test(value);
153
+ }
154
+
155
+ function isSortedUnique(values, compare = (a, b) => a.localeCompare(b)) {
156
+ if (!Array.isArray(values)) return false;
157
+ const canonical = [...new Set(values)].sort(compare);
158
+ return canonical.length === values.length && canonical.every((value, index) => value === values[index]);
159
+ }
160
+
161
+ function safeHttpUrl(value) {
162
+ if (typeof value !== "string" || !/^https?:\/\//.test(value) || /[?#]/.test(value)) return null;
163
+ const projected = redactCaptureUrl(value);
164
+ return projected && /^https?:\/\//.test(projected) ? projected : null;
165
+ }
166
+
167
+ function safeSourceUrl(value) {
168
+ if (value === null) return null;
169
+ if (SOURCE_SENTINELS.has(value)) return value;
170
+ return safeHttpUrl(value) || "[malformed-url]";
171
+ }
172
+
173
+ // A subject is valid when the producer's normalizers leave every field as
174
+ // stated and none of them null.
175
+ function validCaptureSubject(subject) {
176
+ if (!subject || typeof subject !== "object" || Array.isArray(subject)) return false;
177
+ const projected = projectCaptureSubject(subject);
178
+ return Object.values(projected).every((value) => value !== null)
179
+ && canonicalJson(projected) === canonicalJson(subject);
180
+ }
181
+
182
+ function projectCaptureSubject(subject) {
183
+ return {
184
+ build_fingerprint: normalizedBuildFingerprint(subject?.build_fingerprint),
185
+ campaign_slug: normalizedCampaignSlug(subject?.campaign_slug),
186
+ requested_route: normalizePageLoadRoute(subject?.requested_route),
187
+ final_document_route: normalizePageLoadRoute(subject?.final_document_route),
188
+ viewport: normalizedViewport(subject?.viewport),
189
+ };
190
+ }
191
+
192
+ function hasOwn(value, field) {
193
+ return Boolean(value) && Object.prototype.hasOwnProperty.call(value, field);
194
+ }
195
+
196
+ function hasDeclaredLedgerShape(resource) {
197
+ return hasOwn(resource, "declared_bytes")
198
+ || hasOwn(resource, "canceled_request_count")
199
+ || hasOwn(resource, "declared_request_count");
200
+ }
201
+
202
+ function validResourceLedgerEntry(resource) {
203
+ if (!resource || typeof resource !== "object" || Array.isArray(resource)) return false;
204
+ if (!isSha256(resource.resource_id) || safeHttpUrl(resource.url) !== resource.url) return false;
205
+ if (!RESOURCE_TYPES.has(resource.resource_type)
206
+ || !RESOURCE_TYPE_STATUSES.has(resource.resource_type_status)) return false;
207
+ if ((resource.resource_type_status === "known") !== resourceTypeKnown(resource.resource_type)) return false;
208
+ for (const field of [
209
+ "transferred_bytes",
210
+ "request_count",
211
+ "unmeasured_request_count",
212
+ "failed_request_count",
213
+ "partial_request_count",
214
+ "cross_origin_request_count",
215
+ "cache_request_count",
216
+ "service_worker_request_count",
217
+ ]) {
218
+ if (!isNonnegativeInteger(resource[field])) return false;
219
+ }
220
+ const declaredShape = hasDeclaredLedgerShape(resource);
221
+ if (declaredShape && (!isNonnegativeInteger(resource.declared_bytes)
222
+ || !isNonnegativeInteger(resource.canceled_request_count)
223
+ || !isNonnegativeInteger(resource.declared_request_count))) return false;
224
+ for (const field of [
225
+ "unmeasured_request_count",
226
+ "failed_request_count",
227
+ "partial_request_count",
228
+ "cross_origin_request_count",
229
+ "cache_request_count",
230
+ "service_worker_request_count",
231
+ ]) {
232
+ if (resource[field] > resource.request_count) return false;
233
+ }
234
+ if (declaredShape && (resource.canceled_request_count > resource.request_count
235
+ || resource.declared_request_count > resource.request_count
236
+ || resource.declared_request_count > resource.canceled_request_count
237
+ || (resource.declared_request_count === 0 && resource.declared_bytes !== 0))) return false;
238
+ if (resource.cross_origin_request_count !== 0
239
+ && resource.cross_origin_request_count !== resource.request_count) return false;
240
+ if (!isSortedUnique(resource.statuses, (a, b) => a - b)
241
+ || resource.statuses.some((status) => !Number.isInteger(status))) return false;
242
+ if ((resource.partial_request_count > 0) !== resource.statuses.includes(206)) return false;
243
+ if (!isSortedUnique(resource.match_resource_ids)
244
+ || resource.match_resource_ids.some((id) => !isSha256(id))
245
+ || !resource.match_resource_ids.includes(resource.resource_id)) return false;
246
+ return true;
247
+ }
248
+
249
+ function projectResourceLedgerEntry(resource) {
250
+ return {
251
+ resource_id: isSha256(resource?.resource_id) ? resource.resource_id : null,
252
+ url: safeHttpUrl(resource?.url),
253
+ resource_type: RESOURCE_TYPES.has(resource?.resource_type) ? resource.resource_type : "unknown",
254
+ resource_type_status: RESOURCE_TYPE_STATUSES.has(resource?.resource_type_status)
255
+ ? resource.resource_type_status
256
+ : "unknown",
257
+ transferred_bytes: projectedInteger(resource?.transferred_bytes),
258
+ request_count: projectedInteger(resource?.request_count),
259
+ ...(hasDeclaredLedgerShape(resource) ? {
260
+ declared_bytes: projectedInteger(resource?.declared_bytes),
261
+ canceled_request_count: projectedInteger(resource?.canceled_request_count),
262
+ declared_request_count: projectedInteger(resource?.declared_request_count),
263
+ } : {}),
264
+ unmeasured_request_count: projectedInteger(resource?.unmeasured_request_count),
265
+ failed_request_count: projectedInteger(resource?.failed_request_count),
266
+ statuses: (Array.isArray(resource?.statuses) ? resource.statuses : [])
267
+ .filter(Number.isInteger)
268
+ .sort((a, b) => a - b),
269
+ partial_request_count: projectedInteger(resource?.partial_request_count),
270
+ cross_origin_request_count: projectedInteger(resource?.cross_origin_request_count),
271
+ cache_request_count: projectedInteger(resource?.cache_request_count),
272
+ service_worker_request_count: projectedInteger(resource?.service_worker_request_count),
273
+ match_resource_ids: (Array.isArray(resource?.match_resource_ids) ? resource.match_resource_ids : [])
274
+ .filter(isSha256)
275
+ .sort(),
276
+ };
277
+ }
278
+
279
+ function validResourceLedger(ledger) {
280
+ if (!ledger || typeof ledger !== "object" || Array.isArray(ledger)) return false;
281
+ if (ledger.limit !== MAX_PAGE_LOAD_RESOURCE_LEDGER_ENTRIES
282
+ || !isNonnegativeInteger(ledger.total_resource_count)
283
+ || !isNonnegativeInteger(ledger.omitted_resource_count)
284
+ || !isNonnegativeInteger(ledger.omitted_request_count)
285
+ || !Array.isArray(ledger.entries)
286
+ || ledger.entries.length > ledger.limit
287
+ || ledger.entries.some((resource) => !validResourceLedgerEntry(resource))) return false;
288
+ if (ledger.total_resource_count !== ledger.entries.length + ledger.omitted_resource_count) return false;
289
+ if (!isSortedUnique(ledger.entries, resourceLedgerSort)) return false;
290
+ return new Set(ledger.entries.map((resource) => resource.resource_id)).size === ledger.entries.length;
291
+ }
292
+
293
+ function projectResourceLedger(ledger) {
294
+ return {
295
+ limit: projectedInteger(ledger?.limit),
296
+ total_resource_count: projectedInteger(ledger?.total_resource_count),
297
+ omitted_resource_count: projectedInteger(ledger?.omitted_resource_count),
298
+ omitted_request_count: projectedInteger(ledger?.omitted_request_count),
299
+ entries: (Array.isArray(ledger?.entries) ? ledger.entries : [])
300
+ .slice(0, MAX_PAGE_LOAD_RESOURCE_LEDGER_ENTRIES)
301
+ .map(projectResourceLedgerEntry)
302
+ .sort(resourceLedgerSort),
303
+ };
304
+ }
305
+
306
+ function projectLargestResource(resource) {
307
+ if (!resource || typeof resource !== "object" || Array.isArray(resource)) return null;
308
+ return {
309
+ resource_id: isSha256(resource.resource_id) ? resource.resource_id : null,
310
+ url: safeHttpUrl(resource.url),
311
+ resource_type: RESOURCE_TYPES.has(resource.resource_type) ? resource.resource_type : "unknown",
312
+ transferred_bytes: projectedInteger(resource.transferred_bytes),
313
+ request_count: projectedInteger(resource.request_count),
314
+ };
315
+ }
316
+
317
+ function projectMetrics(metrics) {
318
+ if (!metrics || typeof metrics !== "object" || Array.isArray(metrics)) return null;
319
+ return {
320
+ total_transferred_bytes: projectedInteger(metrics.total_transferred_bytes),
321
+ request_count: projectedInteger(metrics.request_count),
322
+ largest_resource: projectLargestResource(metrics.largest_resource),
323
+ cross_origin_request_count: projectedInteger(metrics.cross_origin_request_count),
324
+ cache_request_count: projectedInteger(metrics.cache_request_count),
325
+ service_worker_request_count: projectedInteger(metrics.service_worker_request_count),
326
+ };
327
+ }
328
+
329
+ function validSourceReference(reference) {
330
+ if (!reference || typeof reference !== "object" || Array.isArray(reference)) return false;
331
+ if (!SOURCE_KINDS.has(reference.source_kind) || !isNonnegativeInteger(reference.source_index)) return false;
332
+ if ((reference.source_kind === "current_src" || reference.source_kind === "src_attribute")
333
+ && reference.source_index !== 0) return false;
334
+ const safeUrl = safeSourceUrl(reference.url);
335
+ if (safeUrl !== reference.url) return false;
336
+ return safeHttpUrl(reference.url)
337
+ ? isSha256(reference.resource_id)
338
+ : reference.resource_id === null && SOURCE_SENTINELS.has(reference.url);
339
+ }
340
+
341
+ function projectSourceReference(reference) {
342
+ return {
343
+ source_kind: SOURCE_KINDS.has(reference?.source_kind) ? reference.source_kind : null,
344
+ source_index: projectedInteger(reference?.source_index),
345
+ url: safeSourceUrl(reference?.url),
346
+ resource_id: isSha256(reference?.resource_id) ? reference.resource_id : null,
347
+ };
348
+ }
349
+
350
+ function explicitSourceDescriptors(media) {
351
+ const descriptors = [];
352
+ if (media.current_src !== null) descriptors.push({ source_kind: "current_src", source_index: 0, url: media.current_src });
353
+ if (media.src_attribute !== null) descriptors.push({ source_kind: "src_attribute", source_index: 0, url: media.src_attribute });
354
+ media.source_src_attributes.forEach((url, sourceIndex) => {
355
+ if (url !== null) descriptors.push({ source_kind: "source_src_attribute", source_index: sourceIndex, url });
356
+ });
357
+ media.observed_source_urls.forEach((url, sourceIndex) => {
358
+ if (url !== null) descriptors.push({ source_kind: "observed_source", source_index: sourceIndex, url });
359
+ });
360
+ return descriptors.sort(sourceReferenceSort);
361
+ }
362
+
363
+ function validFetchedResource(resource) {
364
+ return Boolean(resource)
365
+ && typeof resource === "object"
366
+ && !Array.isArray(resource)
367
+ && isSha256(resource.resource_id)
368
+ && safeHttpUrl(resource.url) === resource.url
369
+ && RESOURCE_TYPES.has(resource.resource_type)
370
+ && isNonnegativeInteger(resource.transferred_bytes)
371
+ && (!hasOwn(resource, "declared_bytes") || isNonnegativeInteger(resource.declared_bytes))
372
+ && isNonnegativeInteger(resource.request_count)
373
+ && isSortedUnique(resource.matched_source_resource_ids)
374
+ && resource.matched_source_resource_ids.every(isSha256);
375
+ }
376
+
377
+ function projectFetchedResource(resource) {
378
+ return {
379
+ resource_id: isSha256(resource?.resource_id) ? resource.resource_id : null,
380
+ url: safeHttpUrl(resource?.url),
381
+ resource_type: RESOURCE_TYPES.has(resource?.resource_type) ? resource.resource_type : "unknown",
382
+ transferred_bytes: projectedInteger(resource?.transferred_bytes),
383
+ request_count: projectedInteger(resource?.request_count),
384
+ ...(hasOwn(resource, "declared_bytes") ? { declared_bytes: projectedInteger(resource?.declared_bytes) } : {}),
385
+ matched_source_resource_ids: (Array.isArray(resource?.matched_source_resource_ids)
386
+ ? resource.matched_source_resource_ids
387
+ : []).filter(isSha256).sort(),
388
+ };
389
+ }
390
+
391
+ function validMediaProjection(media, ledgerEntries) {
392
+ if (!media || typeof media !== "object" || Array.isArray(media)) return false;
393
+ if (media.tag_name !== "video" && media.tag_name !== "audio") return false;
394
+ if (!isNonnegativeInteger(media.element_index)) return false;
395
+ if (safeSourceUrl(media.current_src) !== media.current_src
396
+ || safeSourceUrl(media.src_attribute) !== media.src_attribute
397
+ || !Array.isArray(media.source_src_attributes)
398
+ || media.source_src_attributes.some((value) => safeSourceUrl(value) !== value)
399
+ || !Array.isArray(media.observed_source_urls)
400
+ || media.observed_source_urls.some((value) => safeSourceUrl(value) !== value)
401
+ || media.source_src_attributes.length > MAX_PAGE_LOAD_MEDIA_SOURCES_PER_ELEMENT
402
+ || media.observed_source_urls.length > MAX_PAGE_LOAD_MEDIA_SOURCES_PER_ELEMENT) return false;
403
+ if (!Array.isArray(media.source_references)
404
+ || media.source_references.some((reference) => !validSourceReference(reference))
405
+ || !isSortedUnique(media.source_references, sourceReferenceSort)) return false;
406
+ const descriptors = explicitSourceDescriptors(media);
407
+ const referenceDescriptors = media.source_references.map(({ source_kind, source_index, url }) => ({
408
+ source_kind,
409
+ source_index,
410
+ url,
411
+ }));
412
+ if (canonicalJson(descriptors) !== canonicalJson(referenceDescriptors)) return false;
413
+ if (!PRELOAD_ATTRIBUTES.has(media.preload_attribute)
414
+ || typeof media.preload_defers_fetch !== "boolean"
415
+ || media.preload_defers_fetch !== preloadDefersFetch(media.preload_attribute)) return false;
416
+ if (typeof media.hidden_at_load !== "boolean"
417
+ || (media.zero_size_at_load !== null && typeof media.zero_size_at_load !== "boolean")) return false;
418
+ if (!isSortedUnique(media.hidden_by)
419
+ || media.hidden_by.some((kind) => kind !== "display_none"
420
+ && kind !== "visibility_hidden"
421
+ && kind !== "visibility_collapse")
422
+ || media.hidden_at_load !== (media.hidden_by.length > 0)) return false;
423
+ if (!isNonnegativeInteger(media.fetched_bytes)
424
+ || (hasOwn(media, "declared_bytes") && !isNonnegativeInteger(media.declared_bytes))
425
+ || !isNonnegativeInteger(media.fetched_request_count)
426
+ || !Array.isArray(media.fetched_resources)
427
+ || media.fetched_resources.some((resource) => !validFetchedResource(resource))) return false;
428
+ const expected = mediaFetchedResources(media, ledgerEntries);
429
+ const projectedFetched = media.fetched_resources.map(projectFetchedResource).sort(resourceLedgerSort);
430
+ if (canonicalJson(projectedFetched) !== canonicalJson(expected)) return false;
431
+ const totals = mediaFetchTotals(expected);
432
+ return media.fetched_bytes === totals.fetched_bytes
433
+ && (hasOwn(media, "declared_bytes") ? media.declared_bytes === totals.declared_bytes : totals.declared_bytes === 0)
434
+ && media.fetched_request_count === totals.fetched_request_count;
435
+ }
436
+
437
+ function projectMedia(media) {
438
+ return {
439
+ tag_name: media?.tag_name === "video" || media?.tag_name === "audio" ? media.tag_name : null,
440
+ element_index: projectedInteger(media?.element_index),
441
+ current_src: safeSourceUrl(media?.current_src),
442
+ src_attribute: safeSourceUrl(media?.src_attribute),
443
+ source_src_attributes: (Array.isArray(media?.source_src_attributes) ? media.source_src_attributes : [])
444
+ .map(safeSourceUrl),
445
+ observed_source_urls: (Array.isArray(media?.observed_source_urls) ? media.observed_source_urls : [])
446
+ .map(safeSourceUrl),
447
+ source_references: (Array.isArray(media?.source_references) ? media.source_references : [])
448
+ .map(projectSourceReference)
449
+ .sort(sourceReferenceSort),
450
+ preload_attribute: PRELOAD_ATTRIBUTES.has(media?.preload_attribute) ? media.preload_attribute : "other",
451
+ preload_defers_fetch: projectedBoolean(media?.preload_defers_fetch),
452
+ hidden_at_load: projectedBoolean(media?.hidden_at_load),
453
+ hidden_by: (Array.isArray(media?.hidden_by) ? media.hidden_by : [])
454
+ .filter((kind) => kind === "display_none"
455
+ || kind === "visibility_hidden"
456
+ || kind === "visibility_collapse")
457
+ .sort(),
458
+ zero_size_at_load: media?.zero_size_at_load === null ? null : projectedBoolean(media?.zero_size_at_load),
459
+ fetched_bytes: projectedInteger(media?.fetched_bytes),
460
+ fetched_request_count: projectedInteger(media?.fetched_request_count),
461
+ ...(hasOwn(media, "declared_bytes") ? { declared_bytes: projectedInteger(media?.declared_bytes) } : {}),
462
+ fetched_resources: (Array.isArray(media?.fetched_resources) ? media.fetched_resources : [])
463
+ .map(projectFetchedResource)
464
+ .sort(resourceLedgerSort),
465
+ };
466
+ }
467
+
468
+ function problemCount(capture, code) {
469
+ return capture.problems
470
+ .filter((problem) => problem.code === code)
471
+ .reduce((sum, problem) => sum + problem.count, 0);
472
+ }
473
+
474
+ function validProblems(capture) {
475
+ if (!Array.isArray(capture.problems)
476
+ || capture.problems.some((problem) => !problem
477
+ || typeof problem !== "object"
478
+ || Array.isArray(problem)
479
+ || !CAPTURE_PROBLEM_CODES.has(problem.code)
480
+ || !Number.isInteger(problem.count)
481
+ || problem.count <= 0)) return false;
482
+ if (!isSortedUnique(capture.problems.map(({ code }) => code))) return false;
483
+ return capture.measurement_status === polishCaptureMeasurementStatus(capture.problems);
484
+ }
485
+
486
+ // The demoted failures behind a capture warning, read back off the ledger:
487
+ // which hosts they went to and which resource roles were demoted. The roles
488
+ // matter because the demotion is a trade-off, not a fact about the page — a
489
+ // warning that names only its origins leaves the operator unable to see
490
+ // which beacon roles were forgiven. The role vocabulary is the beacon
491
+ // allowlist in polish-capture.mjs, so the type list is bounded by that closed
492
+ // set and needs no separate cap; the origin list is unbounded and is capped
493
+ // by the caller.
494
+ function captureWarningAttribution(capture) {
495
+ const entries = Array.isArray(capture?.resource_ledger?.entries) ? capture.resource_ledger.entries : [];
496
+ const origins = new Set();
497
+ const resourceTypes = new Set();
498
+ for (const resource of entries) {
499
+ if (resource.failed_request_count === 0) continue;
500
+ if (failedRequestProblemCode({
501
+ crossOrigin: resource.cross_origin_request_count > 0,
502
+ resourceType: resource.resource_type,
503
+ }) !== "cross_origin_request_failed") continue;
504
+ // A ledger URL has already passed safeHttpUrl in validResourceLedger, so a
505
+ // parse failure here is a bug worth throwing on, not a case to swallow.
506
+ const origin = captureOrigin(resource.url);
507
+ if (origin === null) throw new Error("polish capture: resource ledger URL is not an HTTP(S) URL");
508
+ origins.add(origin);
509
+ resourceTypes.add(resource.resource_type);
510
+ }
511
+ return { origins: [...origins].sort(), resourceTypes: [...resourceTypes].sort() };
512
+ }
513
+
514
+ function projectCaptureIntegrity(integrity) {
515
+ return {
516
+ schema_version: integrity?.schema_version === POLISH_CAPTURE_INTEGRITY_SCHEMA_VERSION
517
+ ? POLISH_CAPTURE_INTEGRITY_SCHEMA_VERSION
518
+ : null,
519
+ algorithm: integrity?.algorithm === POLISH_CAPTURE_INTEGRITY_ALGORITHM
520
+ ? POLISH_CAPTURE_INTEGRITY_ALGORITHM
521
+ : null,
522
+ association_fingerprint: isSha256(integrity?.association_fingerprint)
523
+ ? integrity.association_fingerprint
524
+ : null,
525
+ projection_fingerprint: isSha256(integrity?.projection_fingerprint)
526
+ ? integrity.projection_fingerprint
527
+ : null,
528
+ };
529
+ }
530
+
531
+ function validCaptureIntegrity(capture) {
532
+ const integrity = projectCaptureIntegrity(capture?.integrity);
533
+ if (Object.values(integrity).some((value) => value === null)) return false;
534
+ const expected = buildPolishCaptureIntegrity(projectCapturePayload(capture));
535
+ return canonicalJson(integrity) === canonicalJson(expected);
536
+ }
537
+
538
+ // An origin field is trusted only when it is exactly what the one origin
539
+ // parser returns for itself: no path, no query, no credentials, no case drift.
540
+ function safeOrigin(value) {
541
+ return captureOrigin(value) === value ? value : null;
542
+ }
543
+
544
+ function projectDocumentResponse(value) {
545
+ const statuses = new Set(["ambiguous", "complete", "error", "missing"]);
546
+ const mimeTypes = new Set(["html", "other", "unknown", "xhtml"]);
547
+ return {
548
+ status: statuses.has(value?.status) ? value.status : "missing",
549
+ url: safeHttpUrl(value?.url),
550
+ resource_id: isSha256(value?.resource_id) ? value.resource_id : null,
551
+ http_status: Number.isInteger(value?.http_status) && value.http_status >= 100 && value.http_status <= 599
552
+ ? value.http_status
553
+ : null,
554
+ mime_type: mimeTypes.has(value?.mime_type) ? value.mime_type : "unknown",
555
+ context_fingerprint: isSha256(value?.context_fingerprint) ? value.context_fingerprint : null,
556
+ capture_origin: safeOrigin(value?.capture_origin),
557
+ final_origin: safeOrigin(value?.final_origin),
558
+ origin_matches_capture: projectedBoolean(value?.origin_matches_capture),
559
+ };
560
+ }
561
+
562
+ function validDocumentResponse(capture, entries) {
563
+ if (!capture.document_response || typeof capture.document_response !== "object"
564
+ || Array.isArray(capture.document_response)) return false;
565
+ const projected = projectDocumentResponse(capture.document_response);
566
+ if (canonicalJson(projected) !== canonicalJson(capture.document_response)) return false;
567
+ if (projected.origin_matches_capture
568
+ !== originMatchesCapture(projected.capture_origin, projected.final_origin)) return false;
569
+ const problemStatus = problemCount(capture, "document_response_ambiguous") > 0
570
+ ? "ambiguous"
571
+ : problemCount(capture, "document_response_missing") > 0
572
+ ? "missing"
573
+ : problemCount(capture, "document_response_error") > 0 ? "error" : "complete";
574
+ if (projected.status !== problemStatus) return false;
575
+ if (projected.status === "missing" || projected.status === "ambiguous") {
576
+ return projected.resource_id === null
577
+ && projected.http_status === null
578
+ && projected.mime_type === "unknown"
579
+ && projected.context_fingerprint === null;
580
+ }
581
+ if (!projected.resource_id || !projected.context_fingerprint) return false;
582
+ const resource = entries.find((entry) => entry.resource_id === projected.resource_id);
583
+ if (!resource || resource.resource_type !== "document" || resource.url !== projected.url) return false;
584
+ if (projected.http_status !== null && !resource.statuses.includes(projected.http_status)) return false;
585
+ return (projected.status === "complete") === documentResponseAcceptable(projected);
586
+ }
587
+
588
+ // The shape rules. Each names one statement a capture makes about itself
589
+ // and recomputes it from the ledger, the media list or the problems with the
590
+ // producer's own derivation (imported from polish-capture.mjs), so the
591
+ // producer and the validator cannot drift apart. A "stated"/"derived" rule
592
+ // holds when the two canonicalize to the same bytes; a "holds" rule is a
593
+ // structural check the derivations rely on. Order matters: a rule may assume
594
+ // every rule before it held, and the first failure is the one reported.
595
+ const CAPTURE_SHAPE_RULES = Object.freeze([
596
+ { name: "capture_object", holds: (capture) => Boolean(capture) && typeof capture === "object" && !Array.isArray(capture) },
597
+ { name: "schema_version", holds: (capture) => capture.schema_version === POLISH_ROUTE_CAPTURE_SCHEMA_VERSION },
598
+ { name: "performed_by", holds: (capture) => capture.performed_by === POLISH_CAPTURE_PRODUCER },
599
+ {
600
+ name: "measurement_status_token",
601
+ holds: (capture) => capture.measurement_status === "complete" || capture.measurement_status === "incomplete",
602
+ },
603
+ { name: "subject", holds: (capture) => validCaptureSubject(capture.subject) },
604
+ { name: "problems", holds: (capture) => validProblems(capture) },
605
+ { name: "integrity", holds: (capture) => validCaptureIntegrity(capture) },
606
+ {
607
+ name: "producer_status",
608
+ holds: (capture) => capture.producer_status === "complete" || capture.producer_status === "failed",
609
+ stated: (capture) => capture.producer_status,
610
+ derived: (capture) => producerStatus(capture.problems),
611
+ },
612
+ {
613
+ name: "response_collection_fields",
614
+ holds: (capture) => Boolean(capture.response_collection)
615
+ && ["complete", "failed", "invalid"].includes(capture.response_collection.status)
616
+ && isNonnegativeInteger(capture.response_collection.observed_response_count)
617
+ && isNonnegativeInteger(capture.response_collection.unattributed_response_count),
618
+ },
619
+ {
620
+ name: "response_collection_status",
621
+ stated: (capture) => ["response_collection_failed", "response_collection_status_invalid"]
622
+ .filter((code) => problemCount(capture, code) > 0),
623
+ derived: (capture) => [responseCollectionProblemCode(capture.response_collection.status)].filter(Boolean),
624
+ },
625
+ {
626
+ name: "response_collection_empty",
627
+ stated: (capture) => problemCount(capture, "response_collection_empty") > 0,
628
+ derived: (capture) => capture.response_collection.status === "complete"
629
+ && capture.response_collection.observed_response_count === 0,
630
+ },
631
+ {
632
+ name: "media_collection_fields",
633
+ holds: (capture) => Boolean(capture.media_collection)
634
+ && ["complete", "failed", "partial"].includes(capture.media_collection.status)
635
+ && ["observed_element_count", "failed_element_count", "omitted_element_count",
636
+ "source_overflow_element_count", "ancestor_overflow_element_count"]
637
+ .every((field) => isNonnegativeInteger(capture.media_collection[field])),
638
+ },
639
+ {
640
+ name: "media_collection_status",
641
+ stated: (capture) => capture.media_collection.status,
642
+ derived: (capture) => (problemCount(capture, "media_collection_unavailable") > 0
643
+ ? (mediaCollectionStatus(capture.media_collection) === "complete" ? "failed" : "invalid")
644
+ : mediaCollectionStatus(capture.media_collection)),
645
+ },
646
+ {
647
+ name: "media_collection_counts",
648
+ stated: (capture) => [
649
+ capture.media_collection.failed_element_count,
650
+ capture.media_collection.omitted_element_count,
651
+ capture.media_collection.source_overflow_element_count,
652
+ capture.media_collection.ancestor_overflow_element_count,
653
+ ],
654
+ derived: (capture) => [
655
+ problemCount(capture, "media_measurement_failed"),
656
+ problemCount(capture, "media_element_overflow"),
657
+ problemCount(capture, "media_source_overflow"),
658
+ problemCount(capture, "media_ancestor_overflow"),
659
+ ],
660
+ },
661
+ {
662
+ name: "networkidle",
663
+ holds: (capture) => Boolean(capture.networkidle) && typeof capture.networkidle === "object",
664
+ stated: (capture) => capture.networkidle,
665
+ derived: (capture) => normalizedNetworkidle(capture.networkidle),
666
+ },
667
+ {
668
+ name: "networkidle_problem",
669
+ stated: (capture) => problemCount(capture, "networkidle_measurement_invalid") > 0,
670
+ derived: (capture) => capture.networkidle.status === "invalid",
671
+ },
672
+ { name: "resource_ledger", holds: (capture) => validResourceLedger(capture.resource_ledger) },
673
+ { name: "document_response", holds: (capture) => validDocumentResponse(capture, capture.resource_ledger.entries) },
674
+ {
675
+ name: "metrics",
676
+ holds: (capture) => Boolean(capture.metrics) && typeof capture.metrics === "object" && !Array.isArray(capture.metrics),
677
+ stated: (capture) => projectMetrics(capture.metrics),
678
+ derived: (capture) => resourceLedgerMetrics(capture.resource_ledger.entries),
679
+ },
680
+ {
681
+ name: "response_accounting",
682
+ stated: (capture) => capture.response_collection.observed_response_count,
683
+ derived: (capture) => capture.metrics.request_count
684
+ + capture.resource_ledger.omitted_request_count
685
+ + capture.response_collection.unattributed_response_count,
686
+ },
687
+ {
688
+ name: "resource_ledger_overflow",
689
+ stated: (capture) => problemCount(capture, "resource_ledger_overflow"),
690
+ derived: (capture) => capture.resource_ledger.omitted_resource_count,
691
+ },
692
+ {
693
+ name: "media",
694
+ holds: (capture) => Array.isArray(capture.media)
695
+ && capture.media.length <= MAX_PAGE_LOAD_MEDIA_ELEMENTS
696
+ && capture.media.every((media) => validMediaProjection(media, capture.resource_ledger.entries))
697
+ && isSortedUnique(capture.media.map((media) => media.element_index), (a, b) => a - b),
698
+ },
699
+ {
700
+ name: "media_element_accounting",
701
+ holds: (capture) => capture.media_collection.status === "failed"
702
+ || (capture.media.every((media, index) => media.element_index === index)
703
+ && capture.media_collection.observed_element_count === capture.media.length
704
+ + capture.media_collection.failed_element_count
705
+ + capture.media_collection.omitted_element_count),
706
+ },
707
+ {
708
+ name: "media_source_unresolvable",
709
+ stated: (capture) => problemCount(capture, "media_source_unresolvable"),
710
+ derived: (capture) => capture.media.filter(hiddenEagerSourceUnresolved).length,
711
+ },
712
+ {
713
+ name: "media_transfer_unattributed",
714
+ stated: (capture) => problemCount(capture, "media_transfer_unattributed"),
715
+ derived: (capture) => unattributedMediaTransfers(capture.media, capture.resource_ledger.entries).length,
716
+ },
717
+ {
718
+ name: "final_document_route_mismatch",
719
+ stated: (capture) => problemCount(capture, "final_document_route_mismatch") > 0,
720
+ derived: (capture) => capture.subject.final_document_route !== capture.subject.requested_route,
721
+ },
722
+ // The ledger-implied problem counts and the collection verdict can only be
723
+ // recomputed when no entry was omitted: an omitted entry contributed to
724
+ // the counts but is not there to recompute from.
725
+ {
726
+ name: "dependency_failure_voids_collection",
727
+ holds: (capture) => capture.resource_ledger.omitted_resource_count > 0
728
+ || ledgerProblemCounts(capture.resource_ledger.entries).dependency_request_failed === 0
729
+ || capture.response_collection.status !== "complete",
730
+ },
731
+ {
732
+ name: "ledger_problem_counts",
733
+ stated: (capture) => (capture.resource_ledger.omitted_resource_count > 0
734
+ ? null
735
+ : Object.fromEntries(Object.keys(ledgerProblemCounts([]))
736
+ .map((code) => [code, problemCount(capture, code)]))),
737
+ derived: (capture) => (capture.resource_ledger.omitted_resource_count > 0
738
+ ? null
739
+ : ledgerProblemCounts(capture.resource_ledger.entries)),
740
+ },
741
+ ]);
742
+
743
+ export const POLISH_CAPTURE_SHAPE_RULES = Object.freeze(CAPTURE_SHAPE_RULES.map((rule) => rule.name));
744
+
745
+ // The name of the first shape rule the capture breaks, or null when every
746
+ // rule holds. The name is one of POLISH_CAPTURE_SHAPE_RULES and never
747
+ // carries capture content.
748
+ export function captureShapeViolation(capture) {
749
+ for (const rule of CAPTURE_SHAPE_RULES) {
750
+ if (rule.holds && !rule.holds(capture)) return rule.name;
751
+ if (rule.stated && canonicalJson(rule.stated(capture)) !== canonicalJson(rule.derived(capture))) return rule.name;
752
+ }
753
+ return null;
754
+ }
755
+
756
+ function projectCapturePayload(capture) {
757
+ const problemCounts = new Map();
758
+ for (const problem of (Array.isArray(capture?.problems) ? capture.problems : [])) {
759
+ if (!CAPTURE_PROBLEM_CODES.has(problem?.code)) continue;
760
+ const count = Number.isInteger(problem.count) && problem.count > 0 ? problem.count : 1;
761
+ problemCounts.set(problem.code, (problemCounts.get(problem.code) || 0) + count);
762
+ }
763
+ return {
764
+ schema_version: capture?.schema_version === POLISH_ROUTE_CAPTURE_SCHEMA_VERSION
765
+ ? POLISH_ROUTE_CAPTURE_SCHEMA_VERSION
766
+ : null,
767
+ performed_by: capture?.performed_by === POLISH_CAPTURE_PRODUCER ? POLISH_CAPTURE_PRODUCER : null,
768
+ subject: projectCaptureSubject(capture?.subject),
769
+ measurement_status: capture?.measurement_status === "complete" || capture?.measurement_status === "incomplete"
770
+ ? capture.measurement_status
771
+ : "incomplete",
772
+ producer_status: capture?.producer_status === "complete" || capture?.producer_status === "failed"
773
+ ? capture.producer_status
774
+ : "invalid",
775
+ response_collection: capture?.response_collection && typeof capture.response_collection === "object" ? {
776
+ status: ["complete", "failed", "invalid"].includes(capture.response_collection.status)
777
+ ? capture.response_collection.status
778
+ : "invalid",
779
+ observed_response_count: projectedInteger(capture.response_collection.observed_response_count),
780
+ unattributed_response_count: projectedInteger(capture.response_collection.unattributed_response_count),
781
+ } : null,
782
+ document_response: projectDocumentResponse(capture?.document_response),
783
+ media_collection: capture?.media_collection && typeof capture.media_collection === "object" ? {
784
+ status: ["complete", "failed", "partial"].includes(capture.media_collection.status)
785
+ ? capture.media_collection.status
786
+ : "failed",
787
+ observed_element_count: projectedInteger(capture.media_collection.observed_element_count),
788
+ failed_element_count: projectedInteger(capture.media_collection.failed_element_count),
789
+ omitted_element_count: projectedInteger(capture.media_collection.omitted_element_count),
790
+ source_overflow_element_count: projectedInteger(capture.media_collection.source_overflow_element_count),
791
+ ancestor_overflow_element_count: projectedInteger(capture.media_collection.ancestor_overflow_element_count),
792
+ } : null,
793
+ networkidle: capture?.networkidle && typeof capture.networkidle === "object" ? {
794
+ status: ["invalid", "settled", "timeout"].includes(capture.networkidle.status)
795
+ ? capture.networkidle.status
796
+ : "invalid",
797
+ duration_ms: capture.networkidle.duration_ms === null ? null : projectedInteger(capture.networkidle.duration_ms),
798
+ } : null,
799
+ resource_ledger: projectResourceLedger(capture?.resource_ledger),
800
+ metrics: projectMetrics(capture?.metrics),
801
+ media: (Array.isArray(capture?.media) ? capture.media : []).map(projectMedia),
802
+ problems: [...problemCounts]
803
+ .map(([code, count]) => ({ code, count }))
804
+ .sort((a, b) => a.code.localeCompare(b.code)),
805
+ };
806
+ }
807
+
808
+ function projectCapture(capture) {
809
+ return {
810
+ ...projectCapturePayload(capture),
811
+ integrity: projectCaptureIntegrity(capture?.integrity),
812
+ };
813
+ }
814
+
815
+ function addProjectedProblem(problems, code) {
816
+ if (!problems.some((problem) => problem.code === code)) problems.push({ code, count: 1 });
817
+ }
818
+
819
+ function captureBindingMatches(capture, pageSubject) {
820
+ const subject = capture?.subject;
821
+ return subject?.build_fingerprint === pageSubject.build_fingerprint
822
+ && subject?.campaign_slug === pageSubject.campaign_slug
823
+ && subject?.requested_route === subject?.final_document_route
824
+ && pageSubject.routes.includes(subject?.requested_route)
825
+ && pageSubject.viewports.includes(subject?.viewport);
826
+ }
827
+
828
+ function stableFindingSort(a, b) {
829
+ return String(a.route).localeCompare(String(b.route))
830
+ || String(a.viewport).localeCompare(String(b.viewport))
831
+ || a.element_index - b.element_index
832
+ || a.sources.join("\u0000").localeCompare(b.sources.join("\u0000"))
833
+ || String(a.resource_identity_fingerprint).localeCompare(String(b.resource_identity_fingerprint));
834
+ }
835
+
836
+ function hiddenEagerMediaFindings(captures) {
837
+ const findings = [];
838
+ for (const capture of captures) {
839
+ for (const media of (Array.isArray(capture?.media) ? capture.media : [])) {
840
+ if (!media?.hidden_at_load || media?.preload_defers_fetch) continue;
841
+ const assessedBytes = Math.max(media?.fetched_bytes || 0, media?.declared_bytes || 0);
842
+ if (!Number.isInteger(assessedBytes)
843
+ || assessedBytes <= HIDDEN_EAGER_MEDIA_THRESHOLD_BYTES) continue;
844
+ const allSources = [...new Set((Array.isArray(media.fetched_resources) ? media.fetched_resources : [])
845
+ .map((resource) => resource?.url)
846
+ .filter(Boolean))].sort();
847
+ const allResourceIds = [...new Set((Array.isArray(media.fetched_resources) ? media.fetched_resources : [])
848
+ .map((resource) => resource?.resource_id)
849
+ .filter(isSha256))].sort();
850
+ findings.push({
851
+ code: HIDDEN_EAGER_MEDIA_SCOPE,
852
+ route: capture.subject.requested_route,
853
+ viewport: capture.subject.viewport,
854
+ tag_name: media.tag_name,
855
+ element_index: media.element_index,
856
+ sources: allSources.slice(0, MAX_HIDDEN_EAGER_MEDIA_FINDING_RESOURCES),
857
+ source_count: allSources.length,
858
+ resource_ids: allResourceIds.slice(0, MAX_HIDDEN_EAGER_MEDIA_FINDING_RESOURCES),
859
+ resource_id_count: allResourceIds.length,
860
+ resource_identity_fingerprint: `sha256:${createHash("sha256")
861
+ .update(JSON.stringify(allResourceIds))
862
+ .digest("hex")}`,
863
+ transferred_bytes: media.fetched_bytes,
864
+ declared_bytes: media.declared_bytes || 0,
865
+ assessed_bytes: assessedBytes,
866
+ threshold_bytes: HIDDEN_EAGER_MEDIA_THRESHOLD_BYTES,
867
+ preload_attribute: media.preload_attribute,
868
+ hidden_by: [...(Array.isArray(media.hidden_by) ? media.hidden_by : [])].sort(),
869
+ });
870
+ }
871
+ }
872
+ return findings.sort(stableFindingSort);
873
+ }
874
+
875
+ export function buildPolishPageLoadEvidence({
876
+ buildFingerprint,
877
+ slug,
878
+ routeScope,
879
+ routes,
880
+ viewports,
881
+ captures,
882
+ } = {}) {
883
+ const subject = pageLoadCheckpointSubject({ buildFingerprint, slug, routeScope, routes, viewports });
884
+ const shapeViolations = new Map();
885
+ const records = (Array.isArray(captures) ? captures : [])
886
+ .map((capture) => {
887
+ const integrityValid = validCaptureIntegrity(capture);
888
+ const shapeViolation = captureShapeViolation(capture);
889
+ const bindingValid = captureBindingMatches(capture, subject);
890
+ const projected = projectCapture(capture);
891
+ if (!integrityValid) addProjectedProblem(projected.problems, "capture_integrity_invalid");
892
+ if (shapeViolation) addProjectedProblem(projected.problems, "capture_shape_invalid");
893
+ if (!bindingValid) addProjectedProblem(projected.problems, "capture_binding_mismatch");
894
+ projected.problems.sort((a, b) => a.code.localeCompare(b.code));
895
+ const record = {
896
+ ...projected,
897
+ measurement_status: !shapeViolation && bindingValid ? projected.measurement_status : "incomplete",
898
+ };
899
+ shapeViolations.set(record, shapeViolation);
900
+ return record;
901
+ })
902
+ .sort((a, b) => String(a.subject.requested_route).localeCompare(String(b.subject.requested_route))
903
+ || String(a.subject.viewport).localeCompare(String(b.subject.viewport)));
904
+ const expected = subject.routes.flatMap((route) => subject.viewports.map((viewport) => ({ route, viewport })));
905
+ const counts = new Map();
906
+ for (const capture of records) {
907
+ const key = captureKey(capture.subject.requested_route, capture.subject.viewport);
908
+ counts.set(key, (counts.get(key) || 0) + 1);
909
+ }
910
+ const expectedKeys = new Set(expected.map(({ route, viewport }) => captureKey(route, viewport)));
911
+ const missing = expected.filter(({ route, viewport }) => !counts.has(captureKey(route, viewport)));
912
+ const duplicate = expected.filter(({ route, viewport }) => (counts.get(captureKey(route, viewport)) || 0) > 1);
913
+ const unexpected = records
914
+ .filter((capture) => !expectedKeys.has(captureKey(capture.subject.requested_route, capture.subject.viewport)))
915
+ .map((capture) => ({
916
+ route: capture.subject.requested_route,
917
+ viewport: capture.subject.viewport,
918
+ }));
919
+ // A cell whose capture failed the shape rules also names the first rule it
920
+ // broke, so a reader can see which statement the capture could not back
921
+ // up without re-running the rules. A cell that is incomplete for another
922
+ // reason carries no such field.
923
+ const incomplete = records
924
+ .filter((capture) => capture.measurement_status !== "complete")
925
+ .map((capture) => ({
926
+ route: capture.subject.requested_route,
927
+ viewport: capture.subject.viewport,
928
+ problem_codes: capture.problems.map((problem) => problem.code).sort(),
929
+ ...(shapeViolations.get(capture) ? { shape_violation: shapeViolations.get(capture) } : {}),
930
+ }));
931
+ // Complete captures that still carry warning-class problems. They do not
932
+ // block, but the operator and the merchant should see them in the verdict
933
+ // without opening the assembly report.
934
+ const warnings = records
935
+ .filter((capture) => capture.measurement_status === "complete"
936
+ && capture.problems.some((problem) => CAPTURE_WARNING_PROBLEM_CODES.has(problem.code)))
937
+ .map((capture) => {
938
+ const { origins, resourceTypes } = captureWarningAttribution(capture);
939
+ return {
940
+ route: capture.subject.requested_route,
941
+ viewport: capture.subject.viewport,
942
+ problem_codes: capture.problems
943
+ .filter((problem) => CAPTURE_WARNING_PROBLEM_CODES.has(problem.code))
944
+ .map((problem) => problem.code)
945
+ .sort(),
946
+ resource_types: resourceTypes,
947
+ failed_origins: origins.slice(0, MAX_CAPTURE_WARNING_ORIGINS),
948
+ failed_origin_count: origins.length,
949
+ };
950
+ });
951
+ const subjectComplete = Boolean(isSha256(subject.build_fingerprint)
952
+ && subject.campaign_slug
953
+ && subject.route_scope
954
+ && subject.routes.length
955
+ && subject.viewports.length);
956
+ const measurementComplete = subjectComplete
957
+ && missing.length === 0
958
+ && duplicate.length === 0
959
+ && unexpected.length === 0
960
+ && incomplete.length === 0;
961
+
962
+ return {
963
+ schema_version: POLISH_PAGE_LOAD_SCHEMA_VERSION,
964
+ performed_by: POLISH_PAGE_LOAD_PRODUCER,
965
+ threshold_bytes: HIDDEN_EAGER_MEDIA_THRESHOLD_BYTES,
966
+ subject,
967
+ measurement: {
968
+ status: measurementComplete ? "complete" : "incomplete",
969
+ expected_capture_count: expected.length,
970
+ captured_count: records.length,
971
+ missing,
972
+ duplicate,
973
+ unexpected,
974
+ incomplete,
975
+ warnings,
976
+ },
977
+ captures: records,
978
+ findings: hiddenEagerMediaFindings(records),
979
+ };
980
+ }
981
+
982
+ // A block carries the recomputed measurement when it has one, so a verdict
983
+ // reader can see which route and viewport failed and on which problem code
984
+ // without opening the assembly report. The measurement is the deterministic
985
+ // projection this module already computes (routes, viewports, problem codes,
986
+ // origin-only warning hosts); it names no URL.
987
+ function nonwaivableBlock(code, reason, subject, measurement = null) {
988
+ return {
989
+ id: HIDDEN_EAGER_MEDIA_SCOPE,
990
+ scope: HIDDEN_EAGER_MEDIA_SCOPE,
991
+ status: "blocked",
992
+ checkpoint_status: "blocked",
993
+ severity: "blocker",
994
+ code,
995
+ reason,
996
+ waivable: false,
997
+ subject,
998
+ state: null,
999
+ state_fingerprint: null,
1000
+ findings: [],
1001
+ measurement,
1002
+ waiver: null,
1003
+ waiver_assessment: emptyWaiverAssessment(),
1004
+ };
1005
+ }
1006
+
1007
+ export function evaluateHiddenEagerMediaCheckpoint({
1008
+ pageLoad,
1009
+ buildFingerprint,
1010
+ slug,
1011
+ routeScope,
1012
+ routes,
1013
+ viewports,
1014
+ waivers = [],
1015
+ now = new Date().toISOString(),
1016
+ } = {}) {
1017
+ const subject = pageLoadCheckpointSubject({ buildFingerprint, slug, routeScope, routes, viewports });
1018
+ if (!pageLoad || typeof pageLoad !== "object" || Array.isArray(pageLoad)
1019
+ || pageLoad.schema_version !== POLISH_PAGE_LOAD_SCHEMA_VERSION
1020
+ || pageLoad.performed_by !== POLISH_PAGE_LOAD_PRODUCER
1021
+ || pageLoad.threshold_bytes !== HIDDEN_EAGER_MEDIA_THRESHOLD_BYTES) {
1022
+ return nonwaivableBlock(
1023
+ "polish.hidden_eager_media.capture_malformed",
1024
+ "Package-owned page-load evidence is missing or malformed; incomplete capture evidence cannot be waived.",
1025
+ subject,
1026
+ );
1027
+ }
1028
+ if (canonicalJson(pageLoad.subject) !== canonicalJson(subject)) {
1029
+ return nonwaivableBlock(
1030
+ "polish.hidden_eager_media.capture_stale",
1031
+ "Page-load evidence does not match the current build fingerprint, campaign, route scope, routes, and viewports.",
1032
+ subject,
1033
+ );
1034
+ }
1035
+ const recomputed = buildPolishPageLoadEvidence({
1036
+ buildFingerprint,
1037
+ slug,
1038
+ routeScope,
1039
+ routes,
1040
+ viewports,
1041
+ captures: pageLoad.captures,
1042
+ });
1043
+ if (recomputed.measurement.status !== "complete") {
1044
+ return nonwaivableBlock(
1045
+ "polish.hidden_eager_media.capture_incomplete",
1046
+ "Page-load capture failed or lacks complete route and viewport measurement; incomplete capture evidence cannot be waived.",
1047
+ subject,
1048
+ recomputed.measurement,
1049
+ );
1050
+ }
1051
+ if (canonicalJson(pageLoad.measurement) !== canonicalJson(recomputed.measurement)
1052
+ || canonicalJson(pageLoad.findings) !== canonicalJson(recomputed.findings)) {
1053
+ return nonwaivableBlock(
1054
+ "polish.hidden_eager_media.capture_malformed",
1055
+ "Page-load measurement or findings do not match the package-owned deterministic projection.",
1056
+ subject,
1057
+ );
1058
+ }
1059
+
1060
+ const findings = recomputed.findings;
1061
+ const state = { findings };
1062
+ const state_fingerprint = checkpointStateFingerprint({
1063
+ scope: HIDDEN_EAGER_MEDIA_SCOPE,
1064
+ subject,
1065
+ state,
1066
+ });
1067
+ const checkpoint = { scope: HIDDEN_EAGER_MEDIA_SCOPE, subject, state_fingerprint };
1068
+ const waiver_assessment = findings.length
1069
+ ? projectCheckpointWaiverAssessment(
1070
+ assessCheckpointWaivers(waivers, checkpoint, { now }),
1071
+ checkpoint,
1072
+ )
1073
+ : emptyWaiverAssessment();
1074
+ const waiver = findings.length ? waiver_assessment.active : null;
1075
+ const status = findings.length ? (waiver ? "waived" : "blocked") : "pass";
1076
+ return {
1077
+ id: HIDDEN_EAGER_MEDIA_SCOPE,
1078
+ scope: HIDDEN_EAGER_MEDIA_SCOPE,
1079
+ status,
1080
+ checkpoint_status: status === "waived" ? "ready_with_waivers" : status === "pass" ? "ready" : "blocked",
1081
+ severity: status === "waived" ? "warn" : status === "pass" ? "info" : "blocker",
1082
+ code: status === "waived"
1083
+ ? "polish.hidden_eager_media.waived"
1084
+ : status === "pass"
1085
+ ? "polish.hidden_eager_media.pass"
1086
+ : HIDDEN_EAGER_MEDIA_SCOPE,
1087
+ reason: status === "waived"
1088
+ ? `Page-load evidence has ${findings.length} hidden eager-media finding(s) covered by an exact named-human checkpoint waiver.`
1089
+ : status === "pass"
1090
+ ? "Page-load evidence has no hidden eager media above 1,048,576 bytes."
1091
+ : `Page-load evidence has ${findings.length} hidden eager-media finding(s) above 1,048,576 bytes.`,
1092
+ waivable: findings.length > 0,
1093
+ subject,
1094
+ state,
1095
+ state_fingerprint,
1096
+ findings,
1097
+ waiver,
1098
+ waiver_assessment,
1099
+ };
1100
+ }