@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.
- package/AGENTS.md +204 -0
- package/CHANGELOG.md +5002 -0
- package/CONTEXT.md +685 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +368 -0
- package/agents/claude/CLAUDE.md +32 -0
- package/agents/codex/AGENTS.md +27 -0
- package/agents/copilot/copilot-instructions.md +14 -0
- package/agents/cursor/campaigns-os.mdc +13 -0
- package/bin/campaigns-os.mjs +38 -0
- package/campaign-spec/README.md +138 -0
- package/campaign-spec/dist/analytics-vocabulary.d.ts +47 -0
- package/campaign-spec/dist/analytics-vocabulary.js +74 -0
- package/campaign-spec/dist/index.d.ts +40 -0
- package/campaign-spec/dist/index.js +77 -0
- package/campaign-spec/dist/normalize.d.ts +22 -0
- package/campaign-spec/dist/normalize.js +41 -0
- package/campaign-spec/dist/routing.d.ts +190 -0
- package/campaign-spec/dist/routing.js +263 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +42 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.js +303 -0
- package/campaign-spec/dist/rules/assembly-hints-shape.d.ts +42 -0
- package/campaign-spec/dist/rules/assembly-hints-shape.js +191 -0
- package/campaign-spec/dist/rules/campaign-metadata.d.ts +14 -0
- package/campaign-spec/dist/rules/campaign-metadata.js +40 -0
- package/campaign-spec/dist/rules/checkout-has-success-url.d.ts +31 -0
- package/campaign-spec/dist/rules/checkout-has-success-url.js +66 -0
- package/campaign-spec/dist/rules/cycle-detection.d.ts +12 -0
- package/campaign-spec/dist/rules/cycle-detection.js +141 -0
- package/campaign-spec/dist/rules/design-source-shape.d.ts +29 -0
- package/campaign-spec/dist/rules/design-source-shape.js +142 -0
- package/campaign-spec/dist/rules/downsell-without-upsell.d.ts +11 -0
- package/campaign-spec/dist/rules/downsell-without-upsell.js +40 -0
- package/campaign-spec/dist/rules/exit-intent-validation.d.ts +23 -0
- package/campaign-spec/dist/rules/exit-intent-validation.js +147 -0
- package/campaign-spec/dist/rules/funnel-count.d.ts +9 -0
- package/campaign-spec/dist/rules/funnel-count.js +37 -0
- package/campaign-spec/dist/rules/funnel-hypothesis-length.d.ts +22 -0
- package/campaign-spec/dist/rules/funnel-hypothesis-length.js +60 -0
- package/campaign-spec/dist/rules/funnel-identity.d.ts +15 -0
- package/campaign-spec/dist/rules/funnel-identity.js +57 -0
- package/campaign-spec/dist/rules/funnel-weight-sum.d.ts +20 -0
- package/campaign-spec/dist/rules/funnel-weight-sum.js +66 -0
- package/campaign-spec/dist/rules/index.d.ts +69 -0
- package/campaign-spec/dist/rules/index.js +131 -0
- package/campaign-spec/dist/rules/offer-ref-integrity.d.ts +13 -0
- package/campaign-spec/dist/rules/offer-ref-integrity.js +59 -0
- package/campaign-spec/dist/rules/package-pricing-sanity.d.ts +12 -0
- package/campaign-spec/dist/rules/package-pricing-sanity.js +42 -0
- package/campaign-spec/dist/rules/page-count.d.ts +11 -0
- package/campaign-spec/dist/rules/page-count.js +31 -0
- package/campaign-spec/dist/rules/page-id-uniqueness.d.ts +14 -0
- package/campaign-spec/dist/rules/page-id-uniqueness.js +47 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.d.ts +8 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.js +126 -0
- package/campaign-spec/dist/rules/promo-codes-shape.d.ts +30 -0
- package/campaign-spec/dist/rules/promo-codes-shape.js +187 -0
- package/campaign-spec/dist/rules/route-field-ignored-for-page-type.d.ts +33 -0
- package/campaign-spec/dist/rules/route-field-ignored-for-page-type.js +81 -0
- package/campaign-spec/dist/rules/route-target-resolves.d.ts +32 -0
- package/campaign-spec/dist/rules/route-target-resolves.js +112 -0
- package/campaign-spec/dist/rules/schema-version.d.ts +21 -0
- package/campaign-spec/dist/rules/schema-version.js +53 -0
- package/campaign-spec/dist/rules/sdk-version.d.ts +26 -0
- package/campaign-spec/dist/rules/sdk-version.js +96 -0
- package/campaign-spec/dist/rules/shipping-countries-shape.d.ts +10 -0
- package/campaign-spec/dist/rules/shipping-countries-shape.js +30 -0
- package/campaign-spec/dist/rules/shipping-methods-present.d.ts +10 -0
- package/campaign-spec/dist/rules/shipping-methods-present.js +26 -0
- package/campaign-spec/dist/rules/store-profile-shape.d.ts +30 -0
- package/campaign-spec/dist/rules/store-profile-shape.js +127 -0
- package/campaign-spec/dist/rules/thank-you-requirement.d.ts +16 -0
- package/campaign-spec/dist/rules/thank-you-requirement.js +50 -0
- package/campaign-spec/dist/rules/unknown-top-level-fields.d.ts +28 -0
- package/campaign-spec/dist/rules/unknown-top-level-fields.js +114 -0
- package/campaign-spec/dist/rules/upsell-has-packages.d.ts +9 -0
- package/campaign-spec/dist/rules/upsell-has-packages.js +35 -0
- package/campaign-spec/dist/rules/upsell-routing-complete.d.ts +10 -0
- package/campaign-spec/dist/rules/upsell-routing-complete.js +45 -0
- package/campaign-spec/dist/rules/upsell-without-checkout.d.ts +11 -0
- package/campaign-spec/dist/rules/upsell-without-checkout.js +44 -0
- package/campaign-spec/dist/rules/variant-labels-shape.d.ts +28 -0
- package/campaign-spec/dist/rules/variant-labels-shape.js +86 -0
- package/campaign-spec/dist/sdk-version-parse.d.ts +43 -0
- package/campaign-spec/dist/sdk-version-parse.js +62 -0
- package/campaign-spec/dist/types.d.ts +674 -0
- package/campaign-spec/dist/types.js +40 -0
- package/campaign-spec/package.json +12 -0
- package/compatibility.json +25 -0
- package/contracts/agent-relevant-change-policy.v1.json +111 -0
- package/contracts/brand-theme-source-defaults.figma-sections-export.v0.json +32 -0
- package/contracts/brand-theme-target-tokens.next-core.v0.json +65 -0
- package/contracts/campaign-cart-checkout-field-contract.v0.json +45 -0
- package/contracts/campaign-cart-sdk-support-policy.v0.json +11 -0
- package/contracts/commerce-surface-catalog.json +2452 -0
- package/contracts/fixtures/orientation/canonicalization/v1.json +34 -0
- package/contracts/fixtures/orientation/envelope/current.json +95 -0
- package/contracts/fixtures/orientation/envelope/freshness_unknown.json +97 -0
- package/contracts/fixtures/orientation/envelope/legacy_baseline.json +95 -0
- package/contracts/fixtures/orientation/envelope/orientation_available.json +136 -0
- package/contracts/fixtures/orientation/envelope/recovered_interrupted_update.json +136 -0
- package/contracts/fixtures/orientation/envelope/refused.json +100 -0
- package/contracts/fixtures/orientation/envelope/restart_required.json +137 -0
- package/contracts/fixtures/orientation/envelope/updated.json +135 -0
- package/contracts/fixtures/orientation/hostile-target/README.md +61 -0
- package/contracts/fixtures/orientation/hostile-target/manifest.json +82 -0
- package/contracts/fixtures/orientation/hostile-target/repo/CHANGELOG.md +18 -0
- package/contracts/fixtures/orientation/hostile-target/repo/bin/intended.mjs +12 -0
- package/contracts/fixtures/orientation/hostile-target/repo/bin/tripwire.mjs +15 -0
- package/contracts/fixtures/orientation/hostile-target/repo/contracts/release-ledger.json +48 -0
- package/contracts/fixtures/orientation/hostile-target/repo/contracts/supported-surface.json +17 -0
- package/contracts/fixtures/orientation/hostile-target/repo/docs/example-contract.md +13 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-checkout +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-merge +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/pre-commit +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/package.json +15 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/tripwire.mjs +9 -0
- package/contracts/fixtures/orientation/hostile-target/repo/package.json +20 -0
- package/contracts/fixtures/orientation/hostile-target/repo/schemas/example.v0.schema.json +15 -0
- package/contracts/fixtures/orientation/release-gate/cases.json +1073 -0
- package/contracts/fixtures/runtime-recipe/accept/current.json +299 -0
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +294 -0
- package/contracts/fixtures/runtime-recipe/dist-states.json +51 -0
- package/contracts/fixtures/runtime-recipe/manifest.json +85 -0
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +297 -0
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +251 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +310 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +299 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/assembly-report.json +180 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/build-context.json +115 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/doctor-output.json +29 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/qa-verdict.json +27 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/campaign-runtime.build.json +141 -0
- package/contracts/migration-sidecar-bundle.v0.json +149 -0
- package/contracts/orientation-limits.v1.json +41 -0
- package/contracts/orientation-reason-codes.v1.json +196 -0
- package/contracts/private-template-sources.json +8 -0
- package/contracts/release-ledger.json +4598 -0
- package/contracts/reserved-skill-names.json +13 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +299 -0
- package/contracts/supported-surface.json +180 -0
- package/contracts/template-brand-contract.apollo-mv-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.apollo.v0.json +27 -0
- package/contracts/template-brand-contract.demeter.v0.json +27 -0
- package/contracts/template-brand-contract.olympus-mv-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.olympus-mv-two-step.v0.json +28 -0
- package/contracts/template-brand-contract.olympus.v0.json +27 -0
- package/contracts/template-brand-contract.shared-commerce.v0.json +190 -0
- package/contracts/template-brand-contract.shop-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.shop-three-step.v0.json +29 -0
- package/contracts/template-slot-manifest.apollo-mv-single-step.v0.json +14 -0
- package/contracts/template-slot-manifest.apollo.v0.json +12 -0
- package/contracts/template-slot-manifest.demeter.v0.json +30 -0
- package/contracts/template-slot-manifest.olympus-mv-single-step.v0.json +14 -0
- package/contracts/template-slot-manifest.olympus-mv-two-step.v0.json +15 -0
- package/contracts/template-slot-manifest.olympus.v0.json +12 -0
- package/contracts/template-slot-manifest.shared-content-core.v0.json +4254 -0
- package/contracts/template-slot-manifest.shop-single-step.v0.json +47 -0
- package/contracts/template-slot-manifest.shop-three-step.v0.json +33 -0
- package/docs/brand-theme-bridge.md +159 -0
- package/docs/build-packet.md +1300 -0
- package/docs/campaign-build-brief.md +145 -0
- package/docs/campaign-standardization-report.md +329 -0
- package/docs/campaigns-os-build-flow.md +117 -0
- package/docs/design-source-package.md +784 -0
- package/docs/legacy-migration.md +58 -0
- package/docs/migration-sidecar-bundle.md +139 -0
- package/docs/orientation-contract-reference.md +1220 -0
- package/docs/polish-evidence.md +502 -0
- package/docs/qa-and-test-orders.md +1691 -0
- package/docs/release-ledger-authoring-guide.md +274 -0
- package/docs/runtime-readiness.md +211 -0
- package/docs/supported-surface.md +83 -0
- package/docs/versioning.md +55 -0
- package/docs/workflow-findings-sidecar.md +588 -0
- package/package.json +135 -0
- package/prompts/first-build.md +27 -0
- package/prompts/friction-log.md +30 -0
- package/schemas/campaign-build-brief.v1.schema.json +149 -0
- package/schemas/campaign-design-source-package.v0.schema.json +697 -0
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +390 -0
- package/schemas/campaign-runtime-build-context.v0.schema.json +339 -0
- package/schemas/campaign-runtime-build-packet.v0.schema.json +452 -0
- package/schemas/campaign-spec.v4.schema.json +582 -0
- package/schemas/campaigns-os-doctor-output.v0.schema.json +43 -0
- package/schemas/campaigns-os-legacy-migration-inventory.v0.schema.json +112 -0
- package/schemas/campaigns-os-legacy-provisioning-plan.v0.schema.json +38 -0
- package/schemas/campaigns-os-legacy-provisioning-receipt.v0.schema.json +57 -0
- package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +150 -0
- package/schemas/campaigns-os-qa-verdict.v0.schema.json +438 -0
- package/schemas/campaigns-os-release-ledger.v1.schema.json +162 -0
- package/schemas/campaigns-os-run-record.v0.schema.json +343 -0
- package/schemas/campaigns-os-runtime-recipe.v1.schema.json +313 -0
- package/schemas/campaigns-os-sidecar-bundle-conformance.v0.schema.json +78 -0
- package/schemas/campaigns-os-tooling-orientation.v1.schema.json +381 -0
- package/schemas/campaigns-os-workflow-finding.v0.schema.json +134 -0
- package/schemas/source-html-manifest.v0.schema.json +252 -0
- package/skills/next-campaigns-build/SKILL.md +73 -0
- package/skills/next-campaigns-os/SKILL.md +101 -0
- package/skills/next-campaigns-os/references/session-intake.md +160 -0
- package/skills/next-campaigns-os-setup/SKILL.md +22 -0
- package/skills/next-campaigns-polish/SKILL.md +145 -0
- package/skills/next-campaigns-qa/SKILL.md +92 -0
- package/skills.json +56 -0
- package/skills.sh +64 -0
- package/src/adapter-decision-contract.mjs +333 -0
- package/src/brand-theme.mjs +1151 -0
- package/src/browser-launch.mjs +79 -0
- package/src/build-brief.mjs +781 -0
- package/src/built-site-scope.mjs +312 -0
- package/src/campaign-ecosystem.mjs +734 -0
- package/src/campaign-identity.mjs +405 -0
- package/src/campaign-workspace.mjs +127 -0
- package/src/checkpoint-waiver.mjs +302 -0
- package/src/cli.mjs +13442 -0
- package/src/commercial-journey.mjs +1119 -0
- package/src/commercial-parity.mjs +965 -0
- package/src/consent.mjs +347 -0
- package/src/content-residue.mjs +322 -0
- package/src/deadline.mjs +81 -0
- package/src/design-source-package.mjs +2604 -0
- package/src/deviation.mjs +107 -0
- package/src/doctor-check-registry.mjs +49 -0
- package/src/doctor-sidecar.mjs +106 -0
- package/src/finding-cause.mjs +557 -0
- package/src/findings.mjs +326 -0
- package/src/fs-identity.mjs +68 -0
- package/src/gate-actions.mjs +105 -0
- package/src/html-scan.mjs +53 -0
- package/src/install-mode.mjs +272 -0
- package/src/legacy-migration.d.ts +128 -0
- package/src/legacy-migration.mjs +510 -0
- package/src/lifecycle.mjs +338 -0
- package/src/local-proof.mjs +401 -0
- package/src/map-pin-writeback.mjs +210 -0
- package/src/orchestration-stage-contract.mjs +81 -0
- package/src/package-install-fixture.mjs +33 -0
- package/src/page-kit-build-summary.mjs +175 -0
- package/src/page-kit-campaign-config.mjs +57 -0
- package/src/page-kit-sdk-version.mjs +392 -0
- package/src/page-kit-store-profile.mjs +369 -0
- package/src/page-kit-sync.mjs +162 -0
- package/src/polish-browser.mjs +867 -0
- package/src/polish-capture.mjs +1094 -0
- package/src/polish-deadline.mjs +51 -0
- package/src/polish-gate.mjs +739 -0
- package/src/polish-node.mjs +639 -0
- package/src/polish-page-load.mjs +1100 -0
- package/src/private-template-source.mjs +237 -0
- package/src/proof-policy.mjs +82 -0
- package/src/qa-analytics-correctness.mjs +307 -0
- package/src/qa-analytics-errors.mjs +38 -0
- package/src/qa-analytics-parity.mjs +699 -0
- package/src/qa-binding-evidence.mjs +140 -0
- package/src/qa-browser.mjs +6608 -0
- package/src/qa-cart-entry.mjs +406 -0
- package/src/qa-commercial-parity.mjs +641 -0
- package/src/qa-node.mjs +3620 -0
- package/src/qa-order-bump.mjs +381 -0
- package/src/qa-parity-capture.mjs +428 -0
- package/src/qa-parity-fixture.mjs +359 -0
- package/src/qa-publish.mjs +362 -0
- package/src/qa-purchase-data-layer.mjs +263 -0
- package/src/qa-route-probe.mjs +272 -0
- package/src/qa-sidecar.mjs +188 -0
- package/src/qa-test-order-topology.mjs +207 -0
- package/src/qa-url-privacy.mjs +13 -0
- package/src/qa-verdict-discovery.mjs +192 -0
- package/src/qa-verdict-publish.mjs +105 -0
- package/src/qa-verdict.mjs +287 -0
- package/src/remit.mjs +388 -0
- package/src/repo-scan.mjs +83 -0
- package/src/route-identity.mjs +133 -0
- package/src/run-record-closeout.mjs +229 -0
- package/src/run-record.mjs +839 -0
- package/src/run-session.mjs +226 -0
- package/src/runtime-state-ignore.mjs +113 -0
- package/src/sdk-attribute-index.mjs +212 -0
- package/src/sdk-markup.mjs +358 -0
- package/src/sdk-meta-tags.mjs +52 -0
- package/src/shell-token.mjs +7 -0
- package/src/sidecar-bundle.mjs +399 -0
- package/src/source-asset-crawl.mjs +469 -0
- package/src/source-html-intake.mjs +627 -0
- package/src/source-html-manifest.mjs +276 -0
- package/src/source-prep.mjs +284 -0
- package/src/spec-derive-store.mjs +431 -0
- package/src/spec-derive.mjs +514 -0
- package/src/spec-fetch.mjs +66 -0
- package/src/spec-hash.mjs +48 -0
- package/src/spec-identity.mjs +27 -0
- package/src/stage-ledger.mjs +509 -0
- package/src/standardization-report.mjs +1297 -0
- package/src/template-brand-contract.mjs +473 -0
- package/src/template-freshness.mjs +196 -0
- package/src/template-reference.mjs +81 -0
- package/src/template-slot-manifest.mjs +150 -0
- package/src/text-safety.mjs +62 -0
- package/src/theme-gate.mjs +185 -0
- 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
|
+
}
|