@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,965 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency-free extraction and Exact-only commercial parity comparison.
|
|
3
|
+
*
|
|
4
|
+
* Source HTML is intentional: Campaign Cart replaces authored placeholders
|
|
5
|
+
* after readiness, which can hide an authored mismatch in the rendered DOM.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
CALCULATED_PAIR_EVIDENCE,
|
|
10
|
+
PricingState,
|
|
11
|
+
centsToUsd,
|
|
12
|
+
moneyToCents,
|
|
13
|
+
} from "./commercial-journey.mjs";
|
|
14
|
+
|
|
15
|
+
export const COMMERCIAL_PARITY_SCHEMA_VERSION = "campaigns-os-commercial-parity/v0";
|
|
16
|
+
|
|
17
|
+
export const COMMERCIAL_PARITY_LIMITS = Object.freeze({
|
|
18
|
+
max_html_bytes: 2 * 1024 * 1024,
|
|
19
|
+
max_nodes: 50_000,
|
|
20
|
+
max_depth: 128,
|
|
21
|
+
max_claims: 500,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export class CommercialParityLimitError extends Error {
|
|
25
|
+
constructor(kind, limit, actual) {
|
|
26
|
+
super(`Commercial HTML ${kind} limit exceeded (${actual} > ${limit}).`);
|
|
27
|
+
this.name = "CommercialParityLimitError";
|
|
28
|
+
this.code = `commercial_html_${kind}_limit`;
|
|
29
|
+
this.kind = kind;
|
|
30
|
+
this.limit = limit;
|
|
31
|
+
this.actual = actual;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const VOID_TAGS = new Set([
|
|
36
|
+
"area", "base", "br", "col", "embed", "hr", "img", "input", "link",
|
|
37
|
+
"meta", "param", "source", "track", "wbr",
|
|
38
|
+
]);
|
|
39
|
+
const RAW_TAGS = new Set(["script", "style", "template", "textarea"]);
|
|
40
|
+
const COMPARISON_TAGS = new Set(["del", "s", "strike"]);
|
|
41
|
+
const COUNT_WORDS = Object.freeze({
|
|
42
|
+
a: 1,
|
|
43
|
+
an: 1,
|
|
44
|
+
one: 1,
|
|
45
|
+
two: 2,
|
|
46
|
+
three: 3,
|
|
47
|
+
four: 4,
|
|
48
|
+
five: 5,
|
|
49
|
+
six: 6,
|
|
50
|
+
seven: 7,
|
|
51
|
+
eight: 8,
|
|
52
|
+
nine: 9,
|
|
53
|
+
ten: 10,
|
|
54
|
+
twelve: 12,
|
|
55
|
+
});
|
|
56
|
+
const MONEY_SOURCE = String.raw`((?:(?:USD|US\$)\s*)?\$\s*\d[\d,]*(?:\.\d{1,2})?)`;
|
|
57
|
+
const COUNT_SOURCE = String.raw`(\d+|a|an|one|two|three|four|five|six|seven|eight|nine|ten|twelve)`;
|
|
58
|
+
const INTERVAL_SOURCE = String.raw`(days?|d|weeks?|wks?|wk|months?|mos?|mo|years?|yrs?|yr)`;
|
|
59
|
+
|
|
60
|
+
function array(value) {
|
|
61
|
+
return Array.isArray(value) ? value : [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function present(value) {
|
|
65
|
+
return value !== undefined && value !== null && String(value).trim() !== "";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function decodeEntities(value) {
|
|
69
|
+
const named = {
|
|
70
|
+
amp: "&",
|
|
71
|
+
apos: "'",
|
|
72
|
+
dollar: "$",
|
|
73
|
+
euro: "€",
|
|
74
|
+
gt: ">",
|
|
75
|
+
ldquo: "“",
|
|
76
|
+
lt: "<",
|
|
77
|
+
mdash: "—",
|
|
78
|
+
minus: "−",
|
|
79
|
+
nbsp: " ",
|
|
80
|
+
ndash: "–",
|
|
81
|
+
pound: "£",
|
|
82
|
+
quot: '"',
|
|
83
|
+
rdquo: "”",
|
|
84
|
+
};
|
|
85
|
+
return String(value || "").replace(/&(#x[\da-f]+|#\d+|[a-z][\da-z]+);/gi, (match, entity) => {
|
|
86
|
+
if (entity[0] === "#") {
|
|
87
|
+
const hexadecimal = entity[1]?.toLowerCase() === "x";
|
|
88
|
+
const parsed = Number.parseInt(entity.slice(hexadecimal ? 2 : 1), hexadecimal ? 16 : 10);
|
|
89
|
+
const validScalar = Number.isInteger(parsed)
|
|
90
|
+
&& parsed >= 0
|
|
91
|
+
&& parsed <= 0x10FFFF
|
|
92
|
+
&& !(parsed >= 0xD800 && parsed <= 0xDFFF);
|
|
93
|
+
return validScalar ? String.fromCodePoint(parsed) : match;
|
|
94
|
+
}
|
|
95
|
+
return named[entity.toLowerCase()] ?? match;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function findTagEnd(source, start) {
|
|
100
|
+
let quote = null;
|
|
101
|
+
for (let index = start + 1; index < source.length; index += 1) {
|
|
102
|
+
const character = source[index];
|
|
103
|
+
if (quote) {
|
|
104
|
+
if (character === quote) quote = null;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (character === '"' || character === "'") {
|
|
108
|
+
quote = character;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (character === ">") return index;
|
|
112
|
+
}
|
|
113
|
+
return -1;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function findRawClosingTag(source, tag, start) {
|
|
117
|
+
let cursor = start;
|
|
118
|
+
while (cursor < source.length) {
|
|
119
|
+
const candidate = source.indexOf("</", cursor);
|
|
120
|
+
if (candidate < 0) return -1;
|
|
121
|
+
const name = source.slice(candidate + 2, candidate + 2 + tag.length);
|
|
122
|
+
const boundary = source[candidate + 2 + tag.length];
|
|
123
|
+
if (name.toLowerCase() === tag && (boundary === ">" || boundary === "/" || /\s/.test(boundary || ""))) {
|
|
124
|
+
return candidate;
|
|
125
|
+
}
|
|
126
|
+
cursor = candidate + 2;
|
|
127
|
+
}
|
|
128
|
+
return -1;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parseAttributes(source) {
|
|
132
|
+
const attributes = Object.create(null);
|
|
133
|
+
const pattern = /([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
|
134
|
+
let match;
|
|
135
|
+
while ((match = pattern.exec(source)) !== null) {
|
|
136
|
+
const name = match[1].toLowerCase();
|
|
137
|
+
attributes[name] = decodeEntities(match[2] ?? match[3] ?? match[4] ?? "");
|
|
138
|
+
}
|
|
139
|
+
return attributes;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function extractionLimits(options = {}) {
|
|
143
|
+
const requested = options.limits && typeof options.limits === "object" ? options.limits : {};
|
|
144
|
+
const value = (field) => {
|
|
145
|
+
const candidate = Number(requested[field] ?? COMMERCIAL_PARITY_LIMITS[field]);
|
|
146
|
+
return Number.isInteger(candidate) && candidate > 0
|
|
147
|
+
? candidate
|
|
148
|
+
: COMMERCIAL_PARITY_LIMITS[field];
|
|
149
|
+
};
|
|
150
|
+
return {
|
|
151
|
+
max_html_bytes: value("max_html_bytes"),
|
|
152
|
+
max_nodes: value("max_nodes"),
|
|
153
|
+
max_depth: value("max_depth"),
|
|
154
|
+
max_claims: value("max_claims"),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function parseHtml(sourceValue, limits) {
|
|
159
|
+
const source = String(sourceValue || "");
|
|
160
|
+
const byteLength = new TextEncoder().encode(source).byteLength;
|
|
161
|
+
if (byteLength > limits.max_html_bytes) {
|
|
162
|
+
throw new CommercialParityLimitError("bytes", limits.max_html_bytes, byteLength);
|
|
163
|
+
}
|
|
164
|
+
const root = { tag: "#root", attrs: Object.create(null), children: [], parent: null };
|
|
165
|
+
const stack = [root];
|
|
166
|
+
const openIndexesByTag = new Map();
|
|
167
|
+
let nodeCount = 0;
|
|
168
|
+
let index = 0;
|
|
169
|
+
|
|
170
|
+
const appendText = (text) => {
|
|
171
|
+
if (text) stack.at(-1).children.push(text);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const pushOpenNode = (node) => {
|
|
175
|
+
const stackIndex = stack.length;
|
|
176
|
+
stack.push(node);
|
|
177
|
+
const indexes = openIndexesByTag.get(node.tag) || [];
|
|
178
|
+
indexes.push(stackIndex);
|
|
179
|
+
openIndexesByTag.set(node.tag, indexes);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const closeOpenNode = (tag) => {
|
|
183
|
+
const indexes = openIndexesByTag.get(tag);
|
|
184
|
+
if (!indexes?.length) return;
|
|
185
|
+
const stackIndex = indexes.at(-1);
|
|
186
|
+
for (let openIndex = stack.length - 1; openIndex >= stackIndex; openIndex -= 1) {
|
|
187
|
+
const openNode = stack[openIndex];
|
|
188
|
+
const openIndexes = openIndexesByTag.get(openNode.tag);
|
|
189
|
+
openIndexes.pop();
|
|
190
|
+
if (openIndexes.length === 0) openIndexesByTag.delete(openNode.tag);
|
|
191
|
+
}
|
|
192
|
+
stack.length = stackIndex;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
while (index < source.length) {
|
|
196
|
+
const nextTag = source.indexOf("<", index);
|
|
197
|
+
if (nextTag < 0) {
|
|
198
|
+
appendText(source.slice(index));
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
if (nextTag > index) appendText(source.slice(index, nextTag));
|
|
202
|
+
|
|
203
|
+
if (source.startsWith("<!--", nextTag)) {
|
|
204
|
+
const commentEnd = source.indexOf("-->", nextTag + 4);
|
|
205
|
+
index = commentEnd < 0 ? source.length : commentEnd + 3;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const end = findTagEnd(source, nextTag);
|
|
210
|
+
if (end < 0) {
|
|
211
|
+
appendText(source.slice(nextTag));
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
const token = source.slice(nextTag + 1, end).trim();
|
|
215
|
+
index = end + 1;
|
|
216
|
+
if (!token || token[0] === "!" || token[0] === "?") continue;
|
|
217
|
+
|
|
218
|
+
if (token[0] === "/") {
|
|
219
|
+
const closing = token.slice(1).trim().match(/^([a-z][^\s/>]*)/i)?.[1]?.toLowerCase();
|
|
220
|
+
if (!closing) continue;
|
|
221
|
+
closeOpenNode(closing);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const selfClosing = /\/\s*$/.test(token);
|
|
226
|
+
const opening = token.match(/^([a-z][^\s/>]*)/i);
|
|
227
|
+
if (!opening) {
|
|
228
|
+
appendText(source.slice(nextTag, end + 1));
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const tag = opening[1].toLowerCase();
|
|
232
|
+
nodeCount += 1;
|
|
233
|
+
if (nodeCount > limits.max_nodes) {
|
|
234
|
+
throw new CommercialParityLimitError("nodes", limits.max_nodes, nodeCount);
|
|
235
|
+
}
|
|
236
|
+
if (stack.length > limits.max_depth) {
|
|
237
|
+
throw new CommercialParityLimitError("depth", limits.max_depth, stack.length);
|
|
238
|
+
}
|
|
239
|
+
const attributeSource = token.slice(opening[0].length).replace(/\/\s*$/, "");
|
|
240
|
+
const node = {
|
|
241
|
+
tag,
|
|
242
|
+
attrs: parseAttributes(attributeSource),
|
|
243
|
+
children: [],
|
|
244
|
+
parent: stack.at(-1),
|
|
245
|
+
};
|
|
246
|
+
stack.at(-1).children.push(node);
|
|
247
|
+
|
|
248
|
+
if (RAW_TAGS.has(tag)) {
|
|
249
|
+
const closeStart = findRawClosingTag(source, tag, index);
|
|
250
|
+
if (closeStart < 0) {
|
|
251
|
+
index = source.length;
|
|
252
|
+
} else {
|
|
253
|
+
const closeEnd = findTagEnd(source, closeStart);
|
|
254
|
+
index = closeEnd < 0 ? source.length : closeEnd + 1;
|
|
255
|
+
}
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (!selfClosing && !VOID_TAGS.has(tag)) pushOpenNode(node);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return root;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function indexNodes(root) {
|
|
265
|
+
const nodes = [];
|
|
266
|
+
const packageAncestor = new Map();
|
|
267
|
+
const nearestToggle = new Map();
|
|
268
|
+
const hidden = new Map();
|
|
269
|
+
const comparison = new Map();
|
|
270
|
+
const pending = [...root.children].reverse();
|
|
271
|
+
while (pending.length) {
|
|
272
|
+
const value = pending.pop();
|
|
273
|
+
if (!value || typeof value === "string") continue;
|
|
274
|
+
nodes.push(value);
|
|
275
|
+
const parent = value.parent;
|
|
276
|
+
packageAncestor.set(value, hasAttribute(parent, "data-next-package-id")
|
|
277
|
+
|| packageAncestor.get(parent) === true);
|
|
278
|
+
nearestToggle.set(value, hasAttribute(value, "data-next-package-toggle")
|
|
279
|
+
? value
|
|
280
|
+
: nearestToggle.get(parent) || null);
|
|
281
|
+
hidden.set(value, nodeIsHidden(value) || hidden.get(parent) === true);
|
|
282
|
+
comparison.set(value, COMPARISON_TAGS.has(value.tag) || comparison.get(parent) === true);
|
|
283
|
+
for (let index = value.children.length - 1; index >= 0; index -= 1) {
|
|
284
|
+
pending.push(value.children[index]);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const packageCounts = new Map();
|
|
289
|
+
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
|
290
|
+
const node = nodes[index];
|
|
291
|
+
let count = hasAttribute(node, "data-next-package-id") ? 1 : 0;
|
|
292
|
+
array(node.children).forEach((child) => {
|
|
293
|
+
if (child && typeof child !== "string") count += packageCounts.get(child) || 0;
|
|
294
|
+
});
|
|
295
|
+
packageCounts.set(node, count);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
nodes,
|
|
300
|
+
packageAncestor,
|
|
301
|
+
nearestToggle,
|
|
302
|
+
hidden,
|
|
303
|
+
comparison,
|
|
304
|
+
packageCounts,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function hasAttribute(node, name) {
|
|
309
|
+
return Object.prototype.hasOwnProperty.call(node?.attrs || {}, name);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function nodeIsHidden(node) {
|
|
313
|
+
if (!node) return false;
|
|
314
|
+
if (hasAttribute(node, "hidden")) return true;
|
|
315
|
+
if (String(node.attrs?.["aria-hidden"] || "").toLowerCase() === "true") return true;
|
|
316
|
+
const style = String(node.attrs?.style || "");
|
|
317
|
+
return /(?:^|;)\s*(?:display\s*:\s*none|visibility\s*:\s*hidden)(?:\s*!important)?\s*(?:;|$)/i.test(style);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function textContent(node, options = {}) {
|
|
321
|
+
if (options.excludeComparison && COMPARISON_TAGS.has(node?.tag)) return "";
|
|
322
|
+
let result = "";
|
|
323
|
+
const pending = [...array(node?.children)].reverse();
|
|
324
|
+
while (pending.length) {
|
|
325
|
+
const value = pending.pop();
|
|
326
|
+
if (typeof value === "string") {
|
|
327
|
+
result += value;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (!value || nodeIsHidden(value)) continue;
|
|
331
|
+
if (options.excludeComparison && COMPARISON_TAGS.has(value.tag)) continue;
|
|
332
|
+
for (let index = value.children.length - 1; index >= 0; index -= 1) {
|
|
333
|
+
pending.push(value.children[index]);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return decodeEntities(result).replace(/\s+/g, " ").trim();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function normalizeMoney(value) {
|
|
340
|
+
const text = decodeEntities(value).replace(/[\u00a0\s]+/g, " ").trim();
|
|
341
|
+
const match = text.match(/^(?:(?:USD|US\$)\s*|\$\s*)?([+-]?)(\d{1,3}(?:,\d{3})*|\d+)(?:\.(\d{1,2}))?$/i);
|
|
342
|
+
if (!match) return null;
|
|
343
|
+
const integer = match[2].replace(/,/g, "");
|
|
344
|
+
const normalized = `${match[1]}${integer}.${String(match[3] || "").padEnd(2, "0")}`;
|
|
345
|
+
const cents = moneyToCents(normalized);
|
|
346
|
+
return cents === null ? null : centsToUsd(cents);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function positiveInteger(value) {
|
|
350
|
+
if (!present(value)) return null;
|
|
351
|
+
const parsed = Number(value);
|
|
352
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function intervalUnit(value) {
|
|
356
|
+
const unit = String(value || "").toLowerCase();
|
|
357
|
+
if (["d", "day", "days"].includes(unit)) return "day";
|
|
358
|
+
if (["wk", "wks", "week", "weeks"].includes(unit)) return "week";
|
|
359
|
+
if (["mo", "mos", "month", "months"].includes(unit)) return "month";
|
|
360
|
+
if (["yr", "yrs", "year", "years"].includes(unit)) return "year";
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function intervalCount(value, fallback = null) {
|
|
365
|
+
if (!present(value)) return fallback;
|
|
366
|
+
const lower = String(value).toLowerCase();
|
|
367
|
+
return positiveInteger(lower) ?? COUNT_WORDS[lower] ?? null;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function recurrenceClaims(textValue) {
|
|
371
|
+
const text = decodeEntities(textValue).replace(/\s+/g, " ");
|
|
372
|
+
const claims = [];
|
|
373
|
+
const decorativeContext = (start, end) => {
|
|
374
|
+
const before = text.slice(Math.max(0, start - 64), start);
|
|
375
|
+
const after = text.slice(end, Math.min(text.length, end + 48));
|
|
376
|
+
const comparisonBefore = /\b(?:save|saving|savings|discount|discounted|off|compare|compared|comparison|versus|vs|was|retail|regular(?:ly)?|normally|worth)\b[^.!?;:]{0,32}$/i;
|
|
377
|
+
const comparisonAfter = /^[^.!?;:]{0,24}\b(?:in savings|savings?|discount|off)\b/i;
|
|
378
|
+
return comparisonBefore.test(before) || comparisonAfter.test(after);
|
|
379
|
+
};
|
|
380
|
+
const add = (amountValue, countValue, unitValue, start, end) => {
|
|
381
|
+
if (decorativeContext(start, end)) return;
|
|
382
|
+
const amount = normalizeMoney(amountValue);
|
|
383
|
+
const count = intervalCount(countValue, 1);
|
|
384
|
+
const interval = intervalUnit(unitValue);
|
|
385
|
+
if (amount && count && interval) claims.push({ amount, interval_count: count, interval });
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const slash = new RegExp(`${MONEY_SOURCE}\\s*\\/\\s*${COUNT_SOURCE}?\\s*${INTERVAL_SOURCE}\\b`, "gi");
|
|
389
|
+
const repeating = new RegExp(`${MONEY_SOURCE}\\s*(?:every|per)\\s*${COUNT_SOURCE}?\\s*${INTERVAL_SOURCE}\\b`, "gi");
|
|
390
|
+
const adverb = new RegExp(`${MONEY_SOURCE}\\s+(daily|weekly|monthly|yearly|annually)\\b`, "gi");
|
|
391
|
+
let match;
|
|
392
|
+
while ((match = slash.exec(text)) !== null) add(match[1], match[2], match[3], match.index, slash.lastIndex);
|
|
393
|
+
while ((match = repeating.exec(text)) !== null) add(match[1], match[2], match[3], match.index, repeating.lastIndex);
|
|
394
|
+
while ((match = adverb.exec(text)) !== null) {
|
|
395
|
+
const units = { daily: "day", weekly: "week", monthly: "month", yearly: "year", annually: "year" };
|
|
396
|
+
add(match[1], 1, units[match[2].toLowerCase()], match.index, adverb.lastIndex);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const seen = new Set();
|
|
400
|
+
return claims.filter((claim) => {
|
|
401
|
+
const key = `${claim.amount}:${claim.interval_count}:${claim.interval}`;
|
|
402
|
+
if (seen.has(key)) return false;
|
|
403
|
+
seen.add(key);
|
|
404
|
+
return true;
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function governedRecurrenceRoot(packageNode, nodeIndex) {
|
|
409
|
+
if (nodeIndex.packageAncestor.get(packageNode) === true) return null;
|
|
410
|
+
const toggle = nodeIndex.nearestToggle.get(packageNode);
|
|
411
|
+
if (toggle) return nodeIndex.packageCounts.get(toggle) === 1 ? toggle : null;
|
|
412
|
+
return nodeIndex.packageCounts.get(packageNode) === 1 ? packageNode : null;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function governedPriceBinding(value) {
|
|
416
|
+
return typeof value === "string" && /^bundle\.[^.]+\.price$/.test(value.trim());
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function extractPrices(nodeIndex, consumeClaim) {
|
|
420
|
+
const seen = new Set();
|
|
421
|
+
const prices = [];
|
|
422
|
+
const candidates = nodeIndex.nodes.filter((node) => {
|
|
423
|
+
const binding = String(node.attrs?.["data-next-display"] || "").trim();
|
|
424
|
+
const format = String(node.attrs?.["data-next-format"] || "").trim().toLowerCase();
|
|
425
|
+
return governedPriceBinding(binding)
|
|
426
|
+
&& format === "currency"
|
|
427
|
+
&& nodeIndex.hidden.get(node) !== true
|
|
428
|
+
&& nodeIndex.comparison.get(node) !== true;
|
|
429
|
+
});
|
|
430
|
+
const candidateSet = new Set(candidates);
|
|
431
|
+
const candidateCounts = new Map();
|
|
432
|
+
for (let index = nodeIndex.nodes.length - 1; index >= 0; index -= 1) {
|
|
433
|
+
const node = nodeIndex.nodes[index];
|
|
434
|
+
let count = candidateSet.has(node) ? 1 : 0;
|
|
435
|
+
array(node.children).forEach((child) => {
|
|
436
|
+
if (child && typeof child !== "string") count += candidateCounts.get(child) || 0;
|
|
437
|
+
});
|
|
438
|
+
candidateCounts.set(node, count);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
candidates.forEach((node) => {
|
|
442
|
+
const binding = String(node.attrs?.["data-next-display"] || "").trim();
|
|
443
|
+
const format = String(node.attrs?.["data-next-format"] || "").trim().toLowerCase();
|
|
444
|
+
if (!governedPriceBinding(binding)
|
|
445
|
+
|| format !== "currency"
|
|
446
|
+
|| nodeIndex.hidden.get(node) === true
|
|
447
|
+
|| nodeIndex.comparison.get(node) === true
|
|
448
|
+
|| candidateCounts.get(node) !== 1) return;
|
|
449
|
+
const value = normalizeMoney(textContent(node, { excludeComparison: true }));
|
|
450
|
+
if (!value) return;
|
|
451
|
+
const key = `${binding}\u0000${value}`;
|
|
452
|
+
if (seen.has(key)) return;
|
|
453
|
+
seen.add(key);
|
|
454
|
+
consumeClaim();
|
|
455
|
+
prices.push({ binding, value });
|
|
456
|
+
});
|
|
457
|
+
return prices;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function extractRecurrences(nodeIndex, errors, consumeClaim) {
|
|
461
|
+
const seenRoots = new Set();
|
|
462
|
+
const seenClaims = new Set();
|
|
463
|
+
const recurrences = [];
|
|
464
|
+
nodeIndex.nodes.forEach((node) => {
|
|
465
|
+
if (hasAttribute(node, "data-next-package-id")
|
|
466
|
+
&& positiveInteger(node.attrs["data-next-package-id"]) === null) {
|
|
467
|
+
errors.push({ type: "malformed-package-id" });
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const packageId = positiveInteger(node.attrs?.["data-next-package-id"]);
|
|
471
|
+
if (packageId === null) return;
|
|
472
|
+
const root = governedRecurrenceRoot(node, nodeIndex);
|
|
473
|
+
if (!root || nodeIndex.hidden.get(root) === true || nodeIndex.comparison.get(root) === true) return;
|
|
474
|
+
if (seenRoots.has(root)) return;
|
|
475
|
+
seenRoots.add(root);
|
|
476
|
+
recurrenceClaims(textContent(root, { excludeComparison: true })).forEach((claim) => {
|
|
477
|
+
const key = `${packageId}:${claim.amount}:${claim.interval_count}:${claim.interval}`;
|
|
478
|
+
if (seenClaims.has(key)) return;
|
|
479
|
+
seenClaims.add(key);
|
|
480
|
+
consumeClaim();
|
|
481
|
+
recurrences.push({ package_id: packageId, ...claim });
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
return recurrences;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function extractVouchers(nodeIndex, errors, consumeClaim) {
|
|
488
|
+
const seen = new Set();
|
|
489
|
+
const vouchers = [];
|
|
490
|
+
nodeIndex.nodes.forEach((node) => {
|
|
491
|
+
if (!hasAttribute(node, "data-next-bundle-vouchers")) return;
|
|
492
|
+
let parsed;
|
|
493
|
+
try {
|
|
494
|
+
parsed = JSON.parse(node.attrs["data-next-bundle-vouchers"]);
|
|
495
|
+
} catch (_error) {
|
|
496
|
+
errors.push({ type: "malformed-voucher-json" });
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
if (!Array.isArray(parsed)
|
|
500
|
+
|| parsed.some((value) => typeof value !== "string" || !value.trim())) {
|
|
501
|
+
errors.push({ type: "malformed-voucher-shape" });
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
parsed.forEach((value) => {
|
|
505
|
+
const code = value.trim();
|
|
506
|
+
const key = code.toUpperCase();
|
|
507
|
+
if (seen.has(key)) return;
|
|
508
|
+
seen.add(key);
|
|
509
|
+
consumeClaim();
|
|
510
|
+
vouchers.push({ code });
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
return vouchers;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Extract only contract-governed authored claims from one page's source HTML.
|
|
518
|
+
*/
|
|
519
|
+
export function extractCommercialClaims(html, options = {}) {
|
|
520
|
+
const limits = extractionLimits(options);
|
|
521
|
+
const root = parseHtml(html, limits);
|
|
522
|
+
const nodeIndex = indexNodes(root);
|
|
523
|
+
const extractionErrors = [];
|
|
524
|
+
let claimCount = 0;
|
|
525
|
+
const consumeClaim = () => {
|
|
526
|
+
claimCount += 1;
|
|
527
|
+
if (claimCount > limits.max_claims) {
|
|
528
|
+
throw new CommercialParityLimitError("claims", limits.max_claims, claimCount);
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
const priceClaims = extractPrices(nodeIndex, consumeClaim);
|
|
532
|
+
const recurrenceClaims = extractRecurrences(nodeIndex, extractionErrors, consumeClaim);
|
|
533
|
+
const vouchers = extractVouchers(nodeIndex, extractionErrors, consumeClaim);
|
|
534
|
+
return {
|
|
535
|
+
page_id: present(options.pageId) ? String(options.pageId) : null,
|
|
536
|
+
...(present(options.url) ? { url: String(options.url) } : {}),
|
|
537
|
+
price_claims: priceClaims,
|
|
538
|
+
recurrence_claims: recurrenceClaims,
|
|
539
|
+
vouchers,
|
|
540
|
+
...(extractionErrors.length ? { extraction_errors: extractionErrors } : {}),
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function record(value) {
|
|
545
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function validPriceClaim(claim) {
|
|
549
|
+
return record(claim)
|
|
550
|
+
&& governedPriceBinding(claim.binding)
|
|
551
|
+
&& normalizeMoney(claim.value) !== null;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function validRecurrenceClaim(claim) {
|
|
555
|
+
return record(claim)
|
|
556
|
+
&& positiveInteger(claim.package_id) !== null
|
|
557
|
+
&& normalizeMoney(claim.amount) !== null
|
|
558
|
+
&& positiveInteger(claim.interval_count) !== null
|
|
559
|
+
&& intervalUnit(claim.interval) !== null;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function validVoucherClaim(claim) {
|
|
563
|
+
return record(claim) && typeof claim.code === "string" && present(claim.code);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function validCaptureEnvelope(capture) {
|
|
567
|
+
if (!record(capture)) return false;
|
|
568
|
+
if (capture.page_id !== undefined
|
|
569
|
+
&& capture.page_id !== null
|
|
570
|
+
&& (typeof capture.page_id !== "string" || !present(capture.page_id))) return false;
|
|
571
|
+
if (capture.url !== undefined && (typeof capture.url !== "string" || !present(capture.url))) return false;
|
|
572
|
+
if (capture.extraction_errors !== undefined
|
|
573
|
+
&& (!Array.isArray(capture.extraction_errors) || capture.extraction_errors.length > 0)) return false;
|
|
574
|
+
return Array.isArray(capture.price_claims)
|
|
575
|
+
&& Array.isArray(capture.recurrence_claims)
|
|
576
|
+
&& Array.isArray(capture.vouchers);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function validCapture(capture) {
|
|
580
|
+
return validCaptureEnvelope(capture)
|
|
581
|
+
&& capture.price_claims.every(validPriceClaim)
|
|
582
|
+
&& capture.recurrence_claims.every(validRecurrenceClaim)
|
|
583
|
+
&& capture.vouchers.every(validVoucherClaim);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function captureCandidates(capturesValue) {
|
|
587
|
+
if (Array.isArray(capturesValue)) return capturesValue;
|
|
588
|
+
return capturesValue === undefined || capturesValue === null ? [] : [capturesValue];
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function partitionCaptures(capturesValue, { countsOnly = false } = {}) {
|
|
592
|
+
const valid = [];
|
|
593
|
+
const invalid = [];
|
|
594
|
+
captureCandidates(capturesValue).forEach((capture, index) => {
|
|
595
|
+
if ((countsOnly ? validCaptureEnvelope : validCapture)(capture)) {
|
|
596
|
+
valid.push(capture);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
invalid.push({
|
|
600
|
+
index,
|
|
601
|
+
...(record(capture) && present(capture.page_id) ? { page_id: String(capture.page_id) } : {}),
|
|
602
|
+
...(record(capture) && present(capture.url) ? { url: String(capture.url) } : {}),
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
return { valid, invalid };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function priceComparisonClaims(capture, page) {
|
|
609
|
+
const claims = array(capture?.price_claims);
|
|
610
|
+
if (!exactMoneyFact(page?.representative_total)) {
|
|
611
|
+
return { compared: [], unresolved: claims.length };
|
|
612
|
+
}
|
|
613
|
+
const bindings = [...new Set(claims.map((claim) => String(claim.binding)))];
|
|
614
|
+
const explicit = present(page?.representative_price_binding)
|
|
615
|
+
? String(page.representative_price_binding)
|
|
616
|
+
: null;
|
|
617
|
+
const binding = explicit
|
|
618
|
+
? (bindings.includes(explicit) ? explicit : null)
|
|
619
|
+
: (bindings.length === 1 ? bindings[0] : null);
|
|
620
|
+
const compared = binding ? claims.filter((claim) => String(claim.binding) === binding) : [];
|
|
621
|
+
return { compared, unresolved: claims.length - compared.length };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function pageForCapture(capture, journey) {
|
|
625
|
+
const pages = array(journey?.pages);
|
|
626
|
+
if (present(capture?.page_id)) {
|
|
627
|
+
return pages.find((page) => String(page?.page_id) === String(capture.page_id)) || null;
|
|
628
|
+
}
|
|
629
|
+
return pages.length === 1 ? pages[0] : null;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function exactMoneyFact(fact) {
|
|
633
|
+
if (fact?.state !== PricingState.Exact) return null;
|
|
634
|
+
const value = normalizeMoney(fact.value);
|
|
635
|
+
if (!value) return null;
|
|
636
|
+
return { value, cents: moneyToCents(value) };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function exactRecurrence(row) {
|
|
640
|
+
const recurrence = row?.recurrence;
|
|
641
|
+
if (row?.state !== PricingState.Exact
|
|
642
|
+
|| recurrence?.state !== PricingState.Exact
|
|
643
|
+
|| recurrence?.amount?.state !== PricingState.Exact) return null;
|
|
644
|
+
const amount = normalizeMoney(recurrence.amount.value);
|
|
645
|
+
const count = positiveInteger(recurrence.interval_count);
|
|
646
|
+
const interval = intervalUnit(recurrence.interval);
|
|
647
|
+
if (!amount || count === null || !interval) return null;
|
|
648
|
+
return { amount, interval_count: count, interval };
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function recurrenceEquivalent(claim, truth) {
|
|
652
|
+
if (moneyToCents(claim.amount) !== moneyToCents(truth.amount)) return false;
|
|
653
|
+
if (claim.interval_count === truth.interval_count && claim.interval === truth.interval) return true;
|
|
654
|
+
|
|
655
|
+
// Uzzle's accepted monthly disclosure is the one narrow proven equivalence:
|
|
656
|
+
// its API cadence is expressed as 30 days. Do not generalize rate math.
|
|
657
|
+
return (claim.interval_count === 1 && claim.interval === "month"
|
|
658
|
+
&& truth.interval_count === 30 && truth.interval === "day")
|
|
659
|
+
|| (truth.interval_count === 1 && truth.interval === "month"
|
|
660
|
+
&& claim.interval_count === 30 && claim.interval === "day");
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function recurrenceComparisonClaims(capture, page) {
|
|
664
|
+
const compared = [];
|
|
665
|
+
let unresolved = 0;
|
|
666
|
+
array(capture?.recurrence_claims).forEach((claim) => {
|
|
667
|
+
const packageId = positiveInteger(claim?.package_id);
|
|
668
|
+
const matchingRows = array(page?.rows).filter((row) => String(row?.package_id) === String(packageId));
|
|
669
|
+
const truths = [];
|
|
670
|
+
const seen = new Set();
|
|
671
|
+
matchingRows.map(exactRecurrence).filter(Boolean).forEach((truth) => {
|
|
672
|
+
const key = `${truth.amount}:${truth.interval_count}:${truth.interval}`;
|
|
673
|
+
if (seen.has(key)) return;
|
|
674
|
+
seen.add(key);
|
|
675
|
+
truths.push(truth);
|
|
676
|
+
});
|
|
677
|
+
if (packageId === null || truths.length !== 1) {
|
|
678
|
+
unresolved += 1;
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
compared.push({ claim, truth: truths[0] });
|
|
682
|
+
});
|
|
683
|
+
return { compared, unresolved };
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function voucherComparisonClaims(capture, page) {
|
|
687
|
+
const compared = [];
|
|
688
|
+
let unresolved = 0;
|
|
689
|
+
array(capture?.vouchers).forEach((voucher) => {
|
|
690
|
+
const code = String(voucher?.code || "").trim();
|
|
691
|
+
const statuses = [...new Set(array(page?.offers)
|
|
692
|
+
.filter((offer) => String(offer?.code || "").toUpperCase() === code.toUpperCase()
|
|
693
|
+
&& offer?.calculation_evidence === CALCULATED_PAIR_EVIDENCE
|
|
694
|
+
&& offer?.state === PricingState.Exact
|
|
695
|
+
&& (offer?.status === "Applied" || offer?.status === "Not applied"))
|
|
696
|
+
.map((offer) => offer.status))];
|
|
697
|
+
if (!code || statuses.length !== 1) {
|
|
698
|
+
unresolved += 1;
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
compared.push({ voucher: { code }, status: statuses[0] });
|
|
702
|
+
});
|
|
703
|
+
return { compared, unresolved };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function findingPage(capture, page) {
|
|
707
|
+
return String(capture?.page_id ?? page?.page_id ?? "campaign");
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Pure, Exact-only diff. Unresolved, Stale, unbound, and malformed facts are
|
|
712
|
+
* deliberately silent.
|
|
713
|
+
*/
|
|
714
|
+
export function diffCommercialParity(capturesValue, journey) {
|
|
715
|
+
const captures = partitionCaptures(capturesValue).valid;
|
|
716
|
+
const findings = [];
|
|
717
|
+
|
|
718
|
+
captures.forEach((capture) => {
|
|
719
|
+
const page = pageForCapture(capture, journey);
|
|
720
|
+
if (!page) return;
|
|
721
|
+
const pageId = findingPage(capture, page);
|
|
722
|
+
const source = present(capture.url) ? { url: String(capture.url) } : {};
|
|
723
|
+
const representative = exactMoneyFact(page.representative_total);
|
|
724
|
+
if (representative) {
|
|
725
|
+
priceComparisonClaims(capture, page).compared.forEach((claim) => {
|
|
726
|
+
const claimed = normalizeMoney(claim?.value);
|
|
727
|
+
if (!claimed || !present(claim?.binding) || moneyToCents(claimed) === representative.cents) return;
|
|
728
|
+
findings.push({
|
|
729
|
+
type: "price-claim-mismatch",
|
|
730
|
+
page_id: pageId,
|
|
731
|
+
...source,
|
|
732
|
+
binding: String(claim.binding),
|
|
733
|
+
claimed,
|
|
734
|
+
calculated: representative.value,
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
recurrenceComparisonClaims(capture, page).compared.forEach(({ claim, truth }) => {
|
|
740
|
+
const packageId = positiveInteger(claim?.package_id);
|
|
741
|
+
const claimed = {
|
|
742
|
+
amount: normalizeMoney(claim?.amount),
|
|
743
|
+
interval_count: positiveInteger(claim?.interval_count),
|
|
744
|
+
interval: intervalUnit(claim?.interval),
|
|
745
|
+
};
|
|
746
|
+
if (packageId === null || !claimed.amount || claimed.interval_count === null || !claimed.interval) return;
|
|
747
|
+
if (recurrenceEquivalent(claimed, truth)) return;
|
|
748
|
+
findings.push({
|
|
749
|
+
type: "cadence-disclosure-mismatch",
|
|
750
|
+
page_id: pageId,
|
|
751
|
+
...source,
|
|
752
|
+
package_id: packageId,
|
|
753
|
+
claimed,
|
|
754
|
+
calculated: truth,
|
|
755
|
+
});
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
voucherComparisonClaims(capture, page).compared.forEach(({ voucher, status }) => {
|
|
759
|
+
if (status === "Applied") return;
|
|
760
|
+
const code = voucher.code;
|
|
761
|
+
findings.push({
|
|
762
|
+
type: "voucher-not-applied",
|
|
763
|
+
page_id: pageId,
|
|
764
|
+
...source,
|
|
765
|
+
code,
|
|
766
|
+
claimed: "Applied",
|
|
767
|
+
calculated: "Not applied",
|
|
768
|
+
});
|
|
769
|
+
});
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
return findings;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function component(value) {
|
|
776
|
+
return String(value ?? "unknown").replace(/[^a-z0-9._-]+/gi, "-");
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function cadenceText(value) {
|
|
780
|
+
const plural = value.interval_count === 1 ? value.interval : `${value.interval}s`;
|
|
781
|
+
return `${value.amount} / ${value.interval_count} ${plural}`;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function assertionBaseId(finding) {
|
|
785
|
+
if (finding.type === "price-claim-mismatch") {
|
|
786
|
+
return `${finding.type}:${component(finding.page_id)}:${component(finding.binding)}`;
|
|
787
|
+
}
|
|
788
|
+
if (finding.type === "cadence-disclosure-mismatch") {
|
|
789
|
+
return `${finding.type}:${component(finding.page_id)}:package-${component(finding.package_id)}`;
|
|
790
|
+
}
|
|
791
|
+
if (finding.type === "voucher-not-applied") {
|
|
792
|
+
return `${finding.type}:${component(finding.page_id)}:${component(finding.code)}`;
|
|
793
|
+
}
|
|
794
|
+
throw new TypeError(`Unknown commercial parity finding type: ${String(finding?.type)}`);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function assertionForFinding(finding, id) {
|
|
798
|
+
const common = {
|
|
799
|
+
id,
|
|
800
|
+
family: "pricing",
|
|
801
|
+
page: finding.page_id || "campaign",
|
|
802
|
+
...(finding.url ? { url: finding.url } : {}),
|
|
803
|
+
status: "warn",
|
|
804
|
+
severity: "warn",
|
|
805
|
+
};
|
|
806
|
+
if (finding.type === "price-claim-mismatch") {
|
|
807
|
+
return {
|
|
808
|
+
...common,
|
|
809
|
+
expected: finding.calculated,
|
|
810
|
+
actual: finding.claimed,
|
|
811
|
+
evidence: { type: finding.type, binding: finding.binding },
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
if (finding.type === "cadence-disclosure-mismatch") {
|
|
815
|
+
return {
|
|
816
|
+
...common,
|
|
817
|
+
expected: cadenceText(finding.calculated),
|
|
818
|
+
actual: cadenceText(finding.claimed),
|
|
819
|
+
evidence: {
|
|
820
|
+
type: finding.type,
|
|
821
|
+
package_id: finding.package_id,
|
|
822
|
+
claimed: finding.claimed,
|
|
823
|
+
calculated: finding.calculated,
|
|
824
|
+
},
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
if (finding.type === "voucher-not-applied") {
|
|
828
|
+
return {
|
|
829
|
+
...common,
|
|
830
|
+
expected: "Applied",
|
|
831
|
+
actual: "Not applied",
|
|
832
|
+
evidence: { type: finding.type, code: finding.code },
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
throw new TypeError(`Unknown commercial parity finding type: ${String(finding?.type)}`);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
export function serializeCommercialFindings(findingsValue, options = {}) {
|
|
839
|
+
const findings = array(findingsValue);
|
|
840
|
+
const requestedAssertionLimit = Number(options.maxAssertions);
|
|
841
|
+
const maxAssertions = Number.isInteger(requestedAssertionLimit) && requestedAssertionLimit >= 0
|
|
842
|
+
? requestedAssertionLimit
|
|
843
|
+
: Number.POSITIVE_INFINITY;
|
|
844
|
+
const overflow = Math.max(0, findings.length - maxAssertions);
|
|
845
|
+
const detailedFindingCount = overflow > 0 && maxAssertions > 0
|
|
846
|
+
? maxAssertions - 1
|
|
847
|
+
: Math.min(findings.length, maxAssertions);
|
|
848
|
+
const idCounts = new Map();
|
|
849
|
+
const assertions = findings.slice(0, detailedFindingCount).map((finding) => {
|
|
850
|
+
const base = assertionBaseId(finding);
|
|
851
|
+
const count = idCounts.get(base) || 0;
|
|
852
|
+
idCounts.set(base, count + 1);
|
|
853
|
+
const suffix = count === 0 ? "" : `:${count + 1}`;
|
|
854
|
+
return assertionForFinding(finding, `${base}${suffix}`);
|
|
855
|
+
});
|
|
856
|
+
const omittedFindingCount = findings.length - detailedFindingCount;
|
|
857
|
+
if (omittedFindingCount > 0 && maxAssertions > 0) {
|
|
858
|
+
assertions.push({
|
|
859
|
+
id: "commercial-parity:additional-findings",
|
|
860
|
+
family: "pricing",
|
|
861
|
+
page: "campaign",
|
|
862
|
+
status: "warn",
|
|
863
|
+
severity: "warn",
|
|
864
|
+
expected: "Every proven commercial mismatch serialized individually",
|
|
865
|
+
actual: `${omittedFindingCount} additional proven mismatch${omittedFindingCount === 1 ? "" : "es"} retained in verdict.commercial.findings`,
|
|
866
|
+
evidence: {
|
|
867
|
+
type: "commercial-assertion-overflow",
|
|
868
|
+
omitted_finding_count: omittedFindingCount,
|
|
869
|
+
},
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
return { assertions, omittedFindingCount };
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Build the runner-facing commercial evidence section and flat QA assertions.
|
|
877
|
+
* `countsOnly` retains matched array lengths without walking claim elements.
|
|
878
|
+
*/
|
|
879
|
+
export function createCommercialParityReport(capturesValue, journey, options = {}) {
|
|
880
|
+
const countsOnly = options.countsOnly === true;
|
|
881
|
+
const { valid: captures, invalid: invalidCaptures } = partitionCaptures(capturesValue, { countsOnly });
|
|
882
|
+
const findings = countsOnly ? [] : diffCommercialParity(captures, journey);
|
|
883
|
+
const { assertions, omittedFindingCount } = countsOnly
|
|
884
|
+
? { assertions: [], omittedFindingCount: 0 }
|
|
885
|
+
: serializeCommercialFindings(findings, options);
|
|
886
|
+
const captureMatches = captures.map((capture) => ({ capture, page: pageForCapture(capture, journey) }));
|
|
887
|
+
const matched = captureMatches.filter((entry) => entry.page);
|
|
888
|
+
const unmatchedPages = captureMatches.filter((entry) => !entry.page)
|
|
889
|
+
.map(({ capture }) => ({
|
|
890
|
+
page_id: present(capture.page_id) ? String(capture.page_id) : null,
|
|
891
|
+
...(present(capture.url) ? { url: String(capture.url) } : {}),
|
|
892
|
+
}));
|
|
893
|
+
const pageKeys = new Set(matched.map(({ page }) => String(page.page_id ?? "page")));
|
|
894
|
+
const expectedPages = array(journey?.pages);
|
|
895
|
+
const missingPages = expectedPages
|
|
896
|
+
.filter((page) => !pageKeys.has(String(page?.page_id ?? "page")))
|
|
897
|
+
.map((page) => ({ page_id: present(page?.page_id) ? String(page.page_id) : null }));
|
|
898
|
+
const extractedPriceClaims = matched.reduce(
|
|
899
|
+
(sum, { capture }) => sum + capture.price_claims.length,
|
|
900
|
+
0,
|
|
901
|
+
);
|
|
902
|
+
const extractedRecurrenceClaims = matched.reduce(
|
|
903
|
+
(sum, { capture }) => sum + capture.recurrence_claims.length,
|
|
904
|
+
0,
|
|
905
|
+
);
|
|
906
|
+
const extractedVoucherClaims = matched.reduce(
|
|
907
|
+
(sum, { capture }) => sum + capture.vouchers.length,
|
|
908
|
+
0,
|
|
909
|
+
);
|
|
910
|
+
const priceComparisonCounts = countsOnly
|
|
911
|
+
? { compared: 0, unresolved: extractedPriceClaims }
|
|
912
|
+
: matched.reduce((counts, { capture, page }) => {
|
|
913
|
+
const comparison = priceComparisonClaims(capture, page);
|
|
914
|
+
counts.compared += comparison.compared.length;
|
|
915
|
+
counts.unresolved += comparison.unresolved;
|
|
916
|
+
return counts;
|
|
917
|
+
}, { compared: 0, unresolved: 0 });
|
|
918
|
+
const recurrenceComparisonCounts = countsOnly
|
|
919
|
+
? { compared: 0, unresolved: extractedRecurrenceClaims }
|
|
920
|
+
: matched.reduce((counts, { capture, page }) => {
|
|
921
|
+
const comparison = recurrenceComparisonClaims(capture, page);
|
|
922
|
+
counts.compared += comparison.compared.length;
|
|
923
|
+
counts.unresolved += comparison.unresolved;
|
|
924
|
+
return counts;
|
|
925
|
+
}, { compared: 0, unresolved: 0 });
|
|
926
|
+
const voucherComparisonCounts = countsOnly
|
|
927
|
+
? { compared: 0, unresolved: extractedVoucherClaims }
|
|
928
|
+
: matched.reduce((counts, { capture, page }) => {
|
|
929
|
+
const comparison = voucherComparisonClaims(capture, page);
|
|
930
|
+
counts.compared += comparison.compared.length;
|
|
931
|
+
counts.unresolved += comparison.unresolved;
|
|
932
|
+
return counts;
|
|
933
|
+
}, { compared: 0, unresolved: 0 });
|
|
934
|
+
|
|
935
|
+
return {
|
|
936
|
+
schema_version: COMMERCIAL_PARITY_SCHEMA_VERSION,
|
|
937
|
+
checked_pages: pageKeys.size,
|
|
938
|
+
invalid_capture_count: invalidCaptures.length,
|
|
939
|
+
invalid_captures: invalidCaptures,
|
|
940
|
+
unmatched_page_count: unmatchedPages.length,
|
|
941
|
+
unmatched_pages: unmatchedPages,
|
|
942
|
+
missing_page_count: missingPages.length,
|
|
943
|
+
missing_pages: missingPages,
|
|
944
|
+
coverage_complete: !countsOnly
|
|
945
|
+
&& invalidCaptures.length === 0
|
|
946
|
+
&& unmatchedPages.length === 0
|
|
947
|
+
&& missingPages.length === 0
|
|
948
|
+
&& priceComparisonCounts.unresolved === 0
|
|
949
|
+
&& recurrenceComparisonCounts.unresolved === 0
|
|
950
|
+
&& voucherComparisonCounts.unresolved === 0,
|
|
951
|
+
extracted_price_claims: extractedPriceClaims,
|
|
952
|
+
compared_price_claims: priceComparisonCounts.compared,
|
|
953
|
+
unresolved_price_claims: priceComparisonCounts.unresolved,
|
|
954
|
+
extracted_recurrence_claims: extractedRecurrenceClaims,
|
|
955
|
+
compared_recurrence_claims: recurrenceComparisonCounts.compared,
|
|
956
|
+
unresolved_recurrence_claims: recurrenceComparisonCounts.unresolved,
|
|
957
|
+
extracted_voucher_claims: extractedVoucherClaims,
|
|
958
|
+
compared_voucher_claims: voucherComparisonCounts.compared,
|
|
959
|
+
unresolved_voucher_claims: voucherComparisonCounts.unresolved,
|
|
960
|
+
serialized_assertion_count: assertions.length,
|
|
961
|
+
omitted_assertion_count: omittedFindingCount,
|
|
962
|
+
findings,
|
|
963
|
+
assertions,
|
|
964
|
+
};
|
|
965
|
+
}
|