@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,641 @@
|
|
|
1
|
+
import { runWithDeadline } from "./deadline.mjs";
|
|
2
|
+
import { specHashOf } from "./spec-hash.mjs";
|
|
3
|
+
import {
|
|
4
|
+
CommercialJourneyLimitError,
|
|
5
|
+
PricingState,
|
|
6
|
+
normalizeJourney,
|
|
7
|
+
planScenarios,
|
|
8
|
+
} from "./commercial-journey.mjs";
|
|
9
|
+
import {
|
|
10
|
+
COMMERCIAL_PARITY_SCHEMA_VERSION,
|
|
11
|
+
CommercialParityLimitError,
|
|
12
|
+
createCommercialParityReport,
|
|
13
|
+
extractCommercialClaims,
|
|
14
|
+
} from "./commercial-parity.mjs";
|
|
15
|
+
|
|
16
|
+
export const COMMERCIAL_QA_LIMITS = Object.freeze({
|
|
17
|
+
max_html_bytes: 2 * 1024 * 1024,
|
|
18
|
+
max_aggregate_html_bytes: 16 * 1024 * 1024,
|
|
19
|
+
max_proxy_bytes: 1024 * 1024,
|
|
20
|
+
max_scenarios: 256,
|
|
21
|
+
max_aggregate_claims: 256,
|
|
22
|
+
concurrency: 4,
|
|
23
|
+
request_timeout_ms: 20_000,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const ALLOWED_API_KEY_ENV_NAMES = new Set(["CAMPAIGNS_API_KEY"]);
|
|
27
|
+
|
|
28
|
+
export class QaResponseLimitError extends Error {
|
|
29
|
+
constructor(kind, limit, actual) {
|
|
30
|
+
super(`${kind} response exceeds ${limit} bytes${actual === null ? "" : ` (${actual})`}.`);
|
|
31
|
+
this.name = "QaResponseLimitError";
|
|
32
|
+
this.code = `${kind}_response_too_large`;
|
|
33
|
+
this.limit = limit;
|
|
34
|
+
this.actual = actual;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function array(value) {
|
|
39
|
+
return Array.isArray(value) ? value : [];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function present(value) {
|
|
43
|
+
return value !== undefined && value !== null && String(value).trim() !== "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function nonEmptyString(value) {
|
|
47
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function resolveCommercialApiKey(resolved, env = process.env) {
|
|
51
|
+
const packetCampaign = resolved?.packet?.campaign;
|
|
52
|
+
const packetKey = nonEmptyString(packetCampaign?.campaigns_api_key)
|
|
53
|
+
|| nonEmptyString(packetCampaign?.api_key);
|
|
54
|
+
if (packetKey) return { value: packetKey, source: "packet" };
|
|
55
|
+
|
|
56
|
+
const spec = resolved?.rawSpec || resolved?.spec || {};
|
|
57
|
+
const specKey = nonEmptyString(spec?.campaign?.campaigns_api_key)
|
|
58
|
+
|| nonEmptyString(spec?.campaigns_api_key)
|
|
59
|
+
|| nonEmptyString(spec?.campaign?.api_key);
|
|
60
|
+
if (specKey) return { value: specKey, source: "spec" };
|
|
61
|
+
|
|
62
|
+
const declaredSource = nonEmptyString(packetCampaign?.api_key_source);
|
|
63
|
+
if (declaredSource?.startsWith("env:")) {
|
|
64
|
+
const name = declaredSource.slice("env:".length).trim();
|
|
65
|
+
if (!ALLOWED_API_KEY_ENV_NAMES.has(name)) {
|
|
66
|
+
return { value: null, source: declaredSource, unsupported: true };
|
|
67
|
+
}
|
|
68
|
+
const value = name ? nonEmptyString(env?.[name]) : null;
|
|
69
|
+
if (value) return { value, source: declaredSource };
|
|
70
|
+
}
|
|
71
|
+
return { value: null, source: declaredSource || null };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function errorMessage(error) {
|
|
75
|
+
return error instanceof Error ? error.message : String(error);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function positiveLimit(value, fallback) {
|
|
79
|
+
const parsed = Number(value);
|
|
80
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function responseLength(response) {
|
|
84
|
+
const raw = response?.headers?.get?.("content-length");
|
|
85
|
+
if (!present(raw)) return null;
|
|
86
|
+
const parsed = Number(raw);
|
|
87
|
+
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Read a response body once while enforcing a byte ceiling during streaming. */
|
|
91
|
+
export async function readBoundedResponseText(response, {
|
|
92
|
+
maxBytes,
|
|
93
|
+
kind = "http",
|
|
94
|
+
} = {}) {
|
|
95
|
+
const limit = positiveLimit(maxBytes, COMMERCIAL_QA_LIMITS.max_html_bytes);
|
|
96
|
+
const declared = responseLength(response);
|
|
97
|
+
if (declared !== null && declared > limit) {
|
|
98
|
+
throw new QaResponseLimitError(kind, limit, declared);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (response?.body && typeof response.body.getReader === "function") {
|
|
102
|
+
const reader = response.body.getReader();
|
|
103
|
+
const decoder = new TextDecoder();
|
|
104
|
+
let received = 0;
|
|
105
|
+
let text = "";
|
|
106
|
+
try {
|
|
107
|
+
while (true) {
|
|
108
|
+
const { done, value } = await reader.read();
|
|
109
|
+
if (done) break;
|
|
110
|
+
received += value?.byteLength || 0;
|
|
111
|
+
if (received > limit) {
|
|
112
|
+
await reader.cancel().catch(() => {});
|
|
113
|
+
throw new QaResponseLimitError(kind, limit, received);
|
|
114
|
+
}
|
|
115
|
+
text += decoder.decode(value, { stream: true });
|
|
116
|
+
}
|
|
117
|
+
text += decoder.decode();
|
|
118
|
+
return text;
|
|
119
|
+
} finally {
|
|
120
|
+
reader.releaseLock?.();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Lightweight fetch stubs do not always expose a ReadableStream. Keep that
|
|
125
|
+
// compatibility seam bounded after the stub resolves its text.
|
|
126
|
+
const text = typeof response?.text === "function" ? await response.text() : "";
|
|
127
|
+
const received = new TextEncoder().encode(text).byteLength;
|
|
128
|
+
if (received > limit) throw new QaResponseLimitError(kind, limit, received);
|
|
129
|
+
return text;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function commercialSpecPages(spec, { maxPages = Number.POSITIVE_INFINITY } = {}) {
|
|
133
|
+
const pages = [];
|
|
134
|
+
const seen = new Set();
|
|
135
|
+
const requestedLimit = Number(maxPages);
|
|
136
|
+
const limit = Number.isInteger(requestedLimit) && requestedLimit >= 0
|
|
137
|
+
? requestedLimit
|
|
138
|
+
: Number.POSITIVE_INFINITY;
|
|
139
|
+
if (limit === 0) return pages;
|
|
140
|
+
const funnels = array(spec?.funnels).length
|
|
141
|
+
? spec.funnels
|
|
142
|
+
: array(spec?.funnel_pages).length
|
|
143
|
+
? [{ id: "default", pages: spec.funnel_pages }]
|
|
144
|
+
: [];
|
|
145
|
+
for (const funnel of funnels) {
|
|
146
|
+
for (const page of array(funnel?.pages)) {
|
|
147
|
+
if (page?.enabled === false || array(page?.packages).length === 0 || !present(page?.id)) continue;
|
|
148
|
+
const key = `id:${String(page.id)}`;
|
|
149
|
+
if (seen.has(key)) continue;
|
|
150
|
+
seen.add(key);
|
|
151
|
+
pages.push(page);
|
|
152
|
+
if (pages.length >= limit) return pages;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return pages;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function captureFailure(page, code, error) {
|
|
159
|
+
return {
|
|
160
|
+
page_id: present(page?.page_id) ? String(page.page_id) : null,
|
|
161
|
+
...(present(page?.url) ? { url: String(page.url) } : {}),
|
|
162
|
+
price_claims: [],
|
|
163
|
+
recurrence_claims: [],
|
|
164
|
+
vouchers: [],
|
|
165
|
+
extraction_errors: [{
|
|
166
|
+
type: code,
|
|
167
|
+
...(error ? { message: errorMessage(error) } : {}),
|
|
168
|
+
}],
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function captureCommercialClaims(page, html) {
|
|
173
|
+
try {
|
|
174
|
+
return extractCommercialClaims(html, {
|
|
175
|
+
pageId: present(page?.page_id) ? String(page.page_id) : null,
|
|
176
|
+
url: page?.url,
|
|
177
|
+
});
|
|
178
|
+
} catch (error) {
|
|
179
|
+
const code = error instanceof CommercialParityLimitError
|
|
180
|
+
? error.code
|
|
181
|
+
: "commercial_html_extraction_error";
|
|
182
|
+
return captureFailure(page, code, error);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function unavailableCommercialCapture(page, error) {
|
|
187
|
+
return captureFailure(page, "commercial_html_unavailable", error);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// The deadline rejects with `timeoutCode` whether or not the operation honours
|
|
191
|
+
// the abort signal it is handed, so a fetch that ignores its signal cannot
|
|
192
|
+
// hold the run open past the request budget.
|
|
193
|
+
function withTimeout(operation, timeoutMs, timeoutCode) {
|
|
194
|
+
const controller = new AbortController();
|
|
195
|
+
return runWithDeadline(() => operation(controller.signal), {
|
|
196
|
+
timeoutMs,
|
|
197
|
+
onTimeout: () => controller.abort(),
|
|
198
|
+
timeoutError: () => Object.assign(new Error(`Request exceeded its ${timeoutMs}ms deadline.`), { code: timeoutCode }),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function createPageSourceLoader({
|
|
203
|
+
authCookie = null,
|
|
204
|
+
fetchImpl = globalThis.fetch,
|
|
205
|
+
limits: limitOverrides = {},
|
|
206
|
+
} = {}) {
|
|
207
|
+
const maxBytes = positiveLimit(limitOverrides.max_html_bytes, COMMERCIAL_QA_LIMITS.max_html_bytes);
|
|
208
|
+
const maxAggregateBytes = positiveLimit(
|
|
209
|
+
limitOverrides.max_aggregate_html_bytes,
|
|
210
|
+
COMMERCIAL_QA_LIMITS.max_aggregate_html_bytes,
|
|
211
|
+
);
|
|
212
|
+
const timeoutMs = positiveLimit(limitOverrides.request_timeout_ms, COMMERCIAL_QA_LIMITS.request_timeout_ms);
|
|
213
|
+
const cache = new Map();
|
|
214
|
+
let retainedBytes = 0;
|
|
215
|
+
let aggregateLimitReached = false;
|
|
216
|
+
let previousAdmission = Promise.resolve();
|
|
217
|
+
|
|
218
|
+
const fetchPage = async (page) => {
|
|
219
|
+
if (!present(page?.url)) {
|
|
220
|
+
return { ok: false, error_code: "missing_url", error: "No page URL was resolved." };
|
|
221
|
+
}
|
|
222
|
+
if (typeof fetchImpl !== "function") {
|
|
223
|
+
return { ok: false, error_code: "page_fetch_unavailable", error: "Fetch is unavailable." };
|
|
224
|
+
}
|
|
225
|
+
if (aggregateLimitReached || retainedBytes >= maxAggregateBytes) {
|
|
226
|
+
return {
|
|
227
|
+
ok: false,
|
|
228
|
+
error_code: "page_html_aggregate_limit",
|
|
229
|
+
error: `Retained page HTML reached the ${maxAggregateBytes}-byte run limit.`,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
return await withTimeout(async (signal) => {
|
|
234
|
+
const headers = { Accept: "text/html,application/xhtml+xml" };
|
|
235
|
+
if (present(authCookie)) headers.Cookie = String(authCookie);
|
|
236
|
+
const response = await fetchImpl(page.url, { headers, signal });
|
|
237
|
+
if (!response?.ok) {
|
|
238
|
+
return {
|
|
239
|
+
ok: false,
|
|
240
|
+
error_code: "http_status",
|
|
241
|
+
status: response?.status ?? null,
|
|
242
|
+
status_text: response?.statusText || "",
|
|
243
|
+
error: `${response?.status ?? "unknown"} ${response?.statusText || ""}`.trim(),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
const html = await readBoundedResponseText(response, { maxBytes, kind: "page_html" });
|
|
247
|
+
return {
|
|
248
|
+
ok: true,
|
|
249
|
+
status: response.status,
|
|
250
|
+
status_text: response.statusText || "",
|
|
251
|
+
final_url: response.url || null,
|
|
252
|
+
html,
|
|
253
|
+
};
|
|
254
|
+
}, timeoutMs, "page_fetch_timeout");
|
|
255
|
+
} catch (error) {
|
|
256
|
+
return {
|
|
257
|
+
ok: false,
|
|
258
|
+
error_code: error?.name === "AbortError" ? "page_fetch_timeout" : (error?.code || "page_fetch_error"),
|
|
259
|
+
error: errorMessage(error),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
// Requests may finish in any order. Admit their retained bodies in invocation
|
|
265
|
+
// order so aggregate-budget decisions match the sequential runner exactly.
|
|
266
|
+
const load = async (page) => {
|
|
267
|
+
const turn = previousAdmission;
|
|
268
|
+
let release;
|
|
269
|
+
previousAdmission = new Promise(resolve => { release = resolve; });
|
|
270
|
+
try {
|
|
271
|
+
const source = await fetchPage(page);
|
|
272
|
+
await turn;
|
|
273
|
+
if (!present(page?.url) || typeof fetchImpl !== "function") return source;
|
|
274
|
+
if (aggregateLimitReached || retainedBytes >= maxAggregateBytes) {
|
|
275
|
+
return { ok: false, error_code: "page_html_aggregate_limit",
|
|
276
|
+
error: `Retained page HTML reached the ${maxAggregateBytes}-byte run limit.` };
|
|
277
|
+
}
|
|
278
|
+
if (!source.ok) return source;
|
|
279
|
+
const htmlBytes = new TextEncoder().encode(source.html).byteLength;
|
|
280
|
+
if (retainedBytes + htmlBytes > maxAggregateBytes) {
|
|
281
|
+
aggregateLimitReached = true;
|
|
282
|
+
return { ok: false, error_code: "page_html_aggregate_limit",
|
|
283
|
+
error: `Retained page HTML would exceed the ${maxAggregateBytes}-byte run limit.` };
|
|
284
|
+
}
|
|
285
|
+
retainedBytes += htmlBytes;
|
|
286
|
+
return source;
|
|
287
|
+
} finally {
|
|
288
|
+
await turn;
|
|
289
|
+
release();
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
return (page) => {
|
|
294
|
+
const key = String(page?.url || `missing:${page?.page_id || "page"}`);
|
|
295
|
+
if (!cache.has(key)) cache.set(key, load(page));
|
|
296
|
+
return cache.get(key);
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function previewDescriptor(descriptor, { proxyBase, apiKey, fetchImpl, limits }) {
|
|
301
|
+
const url = `${String(proxyBase).replace(/\/+$/, "")}/api/price-preview`;
|
|
302
|
+
const requestBody = { upsell: descriptor.upsell, scenario: descriptor.body };
|
|
303
|
+
try {
|
|
304
|
+
const { response, text } = await withTimeout(async (signal) => {
|
|
305
|
+
const responseValue = await fetchImpl(url, {
|
|
306
|
+
method: "POST",
|
|
307
|
+
headers: {
|
|
308
|
+
"Content-Type": "application/json",
|
|
309
|
+
"X-Campaign-Key": apiKey,
|
|
310
|
+
},
|
|
311
|
+
body: JSON.stringify(requestBody),
|
|
312
|
+
signal,
|
|
313
|
+
});
|
|
314
|
+
const responseText = await readBoundedResponseText(responseValue, {
|
|
315
|
+
maxBytes: limits.max_proxy_bytes,
|
|
316
|
+
kind: "price_preview",
|
|
317
|
+
});
|
|
318
|
+
return { response: responseValue, text: responseText };
|
|
319
|
+
}, limits.request_timeout_ms, "price_preview_timeout");
|
|
320
|
+
let body = null;
|
|
321
|
+
let parseError = null;
|
|
322
|
+
try {
|
|
323
|
+
body = text ? JSON.parse(text) : null;
|
|
324
|
+
} catch (error) {
|
|
325
|
+
parseError = error;
|
|
326
|
+
}
|
|
327
|
+
const bodyStatus = Number(body?.status);
|
|
328
|
+
const status = Number.isInteger(bodyStatus) && bodyStatus >= 100 && bodyStatus <= 599
|
|
329
|
+
? bodyStatus
|
|
330
|
+
: Number(response?.status || 500);
|
|
331
|
+
return {
|
|
332
|
+
envelope: {
|
|
333
|
+
id: descriptor.id,
|
|
334
|
+
status,
|
|
335
|
+
response: body?.data ?? body ?? {},
|
|
336
|
+
...(present(body?.calculated_at) ? { calculated_at: body.calculated_at } : {}),
|
|
337
|
+
request: { url, body: descriptor.body },
|
|
338
|
+
},
|
|
339
|
+
ok: response?.ok === true && body?.ok !== false && !parseError,
|
|
340
|
+
...(parseError ? { error_code: "price_preview_invalid_json", error: errorMessage(parseError) } : {}),
|
|
341
|
+
...(!response?.ok || body?.ok === false
|
|
342
|
+
? { error_code: `price_preview_status_${status}` }
|
|
343
|
+
: {}),
|
|
344
|
+
};
|
|
345
|
+
} catch (error) {
|
|
346
|
+
return {
|
|
347
|
+
envelope: {
|
|
348
|
+
id: descriptor.id,
|
|
349
|
+
status: 599,
|
|
350
|
+
response: {},
|
|
351
|
+
request: { url, body: descriptor.body },
|
|
352
|
+
},
|
|
353
|
+
ok: false,
|
|
354
|
+
error_code: error?.name === "AbortError" ? "price_preview_timeout" : (error?.code || "price_preview_fetch_error"),
|
|
355
|
+
error: errorMessage(error),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export async function mapConcurrent(values, limit, operation) {
|
|
361
|
+
const results = new Array(values.length);
|
|
362
|
+
let cursor = 0;
|
|
363
|
+
const worker = async () => {
|
|
364
|
+
while (cursor < values.length) {
|
|
365
|
+
const index = cursor;
|
|
366
|
+
cursor += 1;
|
|
367
|
+
results[index] = await operation(values[index], index);
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
await Promise.all(Array.from({ length: Math.min(limit, values.length) }, worker));
|
|
371
|
+
return results;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function planCommercialParity(spec, { maxScenarios = COMMERCIAL_QA_LIMITS.max_scenarios } = {}) {
|
|
375
|
+
const limit = positiveLimit(maxScenarios, COMMERCIAL_QA_LIMITS.max_scenarios);
|
|
376
|
+
const pages = commercialSpecPages(spec, { maxPages: limit + 1 });
|
|
377
|
+
const plan = [];
|
|
378
|
+
let overflow = false;
|
|
379
|
+
for (const page of pages) {
|
|
380
|
+
const remaining = limit - plan.length;
|
|
381
|
+
if (remaining <= 0) {
|
|
382
|
+
overflow = true;
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
plan.push(...planScenarios(page, spec, { maxScenarios: remaining }));
|
|
387
|
+
} catch (error) {
|
|
388
|
+
if (!(error instanceof CommercialJourneyLimitError)) throw error;
|
|
389
|
+
overflow = true;
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
pages,
|
|
395
|
+
plan,
|
|
396
|
+
overflow,
|
|
397
|
+
observed_scenarios: overflow ? limit + 1 : plan.length,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function countBy(values, keyFor) {
|
|
402
|
+
const counts = {};
|
|
403
|
+
values.forEach((value) => {
|
|
404
|
+
const key = keyFor(value);
|
|
405
|
+
counts[key] = (counts[key] || 0) + 1;
|
|
406
|
+
});
|
|
407
|
+
return Object.fromEntries(Object.entries(counts).sort(([left], [right]) => left.localeCompare(right)));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function catalogImportedAt(spec) {
|
|
411
|
+
return spec?.catalog_imported_at
|
|
412
|
+
?? spec?._provenance?.api?.imported_at
|
|
413
|
+
?? spec?._provenance?.api?.fetched_at
|
|
414
|
+
?? spec?.campaign?._provenance?.api?.imported_at
|
|
415
|
+
?? null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function captureClaimCount(capture) {
|
|
419
|
+
return array(capture?.price_claims).length
|
|
420
|
+
+ array(capture?.recurrence_claims).length
|
|
421
|
+
+ array(capture?.vouchers).length;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function captureIssues(captures) {
|
|
425
|
+
const issues = [];
|
|
426
|
+
array(captures).forEach((capture) => {
|
|
427
|
+
array(capture?.extraction_errors).forEach((error) => {
|
|
428
|
+
if (error?.type === "commercial_html_claims_limit") {
|
|
429
|
+
issues.push({ code: "commercial_html_claims_limit" });
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
return issues;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function compactReport(report, journey, plan, executed, issues, observedClaims, claimLimit, plannedScenarioCount = plan.length) {
|
|
437
|
+
const exactPages = array(journey?.pages).filter((page) => page?.state === PricingState.Exact).length;
|
|
438
|
+
const stalePages = array(journey?.pages).filter((page) => page?.state === PricingState.Stale).length;
|
|
439
|
+
const proofComplete = report.coverage_complete
|
|
440
|
+
&& issues.length === 0
|
|
441
|
+
&& exactPages === array(journey?.pages).length;
|
|
442
|
+
const status = plannedScenarioCount === 0 && issues.length === 0
|
|
443
|
+
? "not_applicable"
|
|
444
|
+
: !proofComplete
|
|
445
|
+
? "incomplete"
|
|
446
|
+
: report.findings.length
|
|
447
|
+
? "mismatch"
|
|
448
|
+
: "pass";
|
|
449
|
+
return {
|
|
450
|
+
schema_version: COMMERCIAL_PARITY_SCHEMA_VERSION,
|
|
451
|
+
status,
|
|
452
|
+
coverage_complete: proofComplete,
|
|
453
|
+
checked_pages: report.checked_pages,
|
|
454
|
+
exact_pages: exactPages,
|
|
455
|
+
stale_pages: stalePages,
|
|
456
|
+
missing_page_count: report.missing_page_count,
|
|
457
|
+
missing_pages: report.missing_pages,
|
|
458
|
+
unmatched_page_count: report.unmatched_page_count,
|
|
459
|
+
unmatched_pages: report.unmatched_pages,
|
|
460
|
+
invalid_capture_count: report.invalid_capture_count,
|
|
461
|
+
invalid_captures: report.invalid_captures,
|
|
462
|
+
planned_scenarios: plannedScenarioCount,
|
|
463
|
+
executed_scenarios: executed.filter((entry) => entry?.envelope).length,
|
|
464
|
+
failed_scenarios: executed.filter((entry) => entry?.ok === false).length,
|
|
465
|
+
extracted_price_claims: report.extracted_price_claims,
|
|
466
|
+
compared_price_claims: report.compared_price_claims,
|
|
467
|
+
unresolved_price_claims: report.unresolved_price_claims,
|
|
468
|
+
extracted_recurrence_claims: report.extracted_recurrence_claims,
|
|
469
|
+
compared_recurrence_claims: report.compared_recurrence_claims,
|
|
470
|
+
unresolved_recurrence_claims: report.unresolved_recurrence_claims,
|
|
471
|
+
extracted_voucher_claims: report.extracted_voucher_claims,
|
|
472
|
+
compared_voucher_claims: report.compared_voucher_claims,
|
|
473
|
+
unresolved_voucher_claims: report.unresolved_voucher_claims,
|
|
474
|
+
serialized_assertion_count: report.serialized_assertion_count,
|
|
475
|
+
omitted_assertion_count: report.omitted_assertion_count,
|
|
476
|
+
observed_claims: observedClaims,
|
|
477
|
+
claim_limit: claimLimit,
|
|
478
|
+
finding_count: report.findings.length,
|
|
479
|
+
finding_counts: countBy(report.findings, (finding) => finding.type),
|
|
480
|
+
findings: report.findings,
|
|
481
|
+
issues: Object.entries(countBy(issues, (issue) => issue.code)).map(([code, count]) => ({ code, count })),
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function withScenarioOverflowPages(journey, plannedPages) {
|
|
486
|
+
const normalizedPages = array(journey?.pages);
|
|
487
|
+
const normalizedIds = new Set(normalizedPages.map((page) => String(page?.page_id)));
|
|
488
|
+
const reason = "Unresolved: commercial scenario limit exceeded";
|
|
489
|
+
const overflowPages = array(plannedPages)
|
|
490
|
+
.filter((page) => present(page?.id) && !normalizedIds.has(String(page.id)))
|
|
491
|
+
.map((page) => ({
|
|
492
|
+
page_id: String(page.id),
|
|
493
|
+
page_type: page?.type ?? null,
|
|
494
|
+
page_label: page?.label ?? page?.name ?? page?.id ?? null,
|
|
495
|
+
page_order: Number(page?.order) || 0,
|
|
496
|
+
state: PricingState.Unresolved,
|
|
497
|
+
reason,
|
|
498
|
+
rows: [],
|
|
499
|
+
offers: [],
|
|
500
|
+
representative_total: { state: PricingState.Unresolved, reason },
|
|
501
|
+
shipping: { present: false, state: PricingState.Unresolved, reason },
|
|
502
|
+
bumps: [],
|
|
503
|
+
}));
|
|
504
|
+
if (!overflowPages.length) return journey;
|
|
505
|
+
return {
|
|
506
|
+
...journey,
|
|
507
|
+
state: PricingState.Unresolved,
|
|
508
|
+
reason: journey?.reason || reason,
|
|
509
|
+
pages: [...normalizedPages, ...overflowPages]
|
|
510
|
+
.sort((left, right) => left.page_order - right.page_order),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export function unavailableCommercialReport(code, { status = "not_run" } = {}) {
|
|
515
|
+
return {
|
|
516
|
+
schema_version: COMMERCIAL_PARITY_SCHEMA_VERSION,
|
|
517
|
+
status,
|
|
518
|
+
coverage_complete: false,
|
|
519
|
+
checked_pages: 0,
|
|
520
|
+
exact_pages: 0,
|
|
521
|
+
stale_pages: 0,
|
|
522
|
+
missing_page_count: 0,
|
|
523
|
+
missing_pages: [],
|
|
524
|
+
unmatched_page_count: 0,
|
|
525
|
+
unmatched_pages: [],
|
|
526
|
+
invalid_capture_count: 0,
|
|
527
|
+
invalid_captures: [],
|
|
528
|
+
planned_scenarios: 0,
|
|
529
|
+
executed_scenarios: 0,
|
|
530
|
+
failed_scenarios: 0,
|
|
531
|
+
extracted_price_claims: 0,
|
|
532
|
+
compared_price_claims: 0,
|
|
533
|
+
unresolved_price_claims: 0,
|
|
534
|
+
extracted_recurrence_claims: 0,
|
|
535
|
+
compared_recurrence_claims: 0,
|
|
536
|
+
unresolved_recurrence_claims: 0,
|
|
537
|
+
extracted_voucher_claims: 0,
|
|
538
|
+
compared_voucher_claims: 0,
|
|
539
|
+
unresolved_voucher_claims: 0,
|
|
540
|
+
serialized_assertion_count: 0,
|
|
541
|
+
omitted_assertion_count: 0,
|
|
542
|
+
observed_claims: 0,
|
|
543
|
+
claim_limit: COMMERCIAL_QA_LIMITS.max_aggregate_claims,
|
|
544
|
+
finding_count: 0,
|
|
545
|
+
finding_counts: {},
|
|
546
|
+
findings: [],
|
|
547
|
+
issues: [{ code, count: 1 }],
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Run the portable commercial planner against the public proxy contract and
|
|
553
|
+
* serialize proven mismatches into ordinary QA assertions.
|
|
554
|
+
*/
|
|
555
|
+
export async function runCommercialParity({
|
|
556
|
+
resolved,
|
|
557
|
+
captures = [],
|
|
558
|
+
fetchImpl = globalThis.fetch,
|
|
559
|
+
limits: limitOverrides = {},
|
|
560
|
+
planning = null,
|
|
561
|
+
maxAssertions = Number.POSITIVE_INFINITY,
|
|
562
|
+
} = {}) {
|
|
563
|
+
const limits = {
|
|
564
|
+
...COMMERCIAL_QA_LIMITS,
|
|
565
|
+
...Object.fromEntries(Object.entries(limitOverrides).map(([key, value]) => [
|
|
566
|
+
key,
|
|
567
|
+
positiveLimit(value, COMMERCIAL_QA_LIMITS[key]),
|
|
568
|
+
])),
|
|
569
|
+
};
|
|
570
|
+
const planningSpec = resolved?.rawSpec || resolved?.spec || {};
|
|
571
|
+
const commercialPlanning = planning || planCommercialParity(planningSpec, {
|
|
572
|
+
maxScenarios: limits.max_scenarios,
|
|
573
|
+
});
|
|
574
|
+
const plan = commercialPlanning.plan;
|
|
575
|
+
const scenarioOverflow = commercialPlanning.overflow || plan.length > limits.max_scenarios;
|
|
576
|
+
const observedClaims = array(captures).reduce((sum, capture) => sum + captureClaimCount(capture), 0);
|
|
577
|
+
const aggregateClaimOverflow = observedClaims > limits.max_aggregate_claims;
|
|
578
|
+
const orderedPreflightIssues = [
|
|
579
|
+
{ exceeded: aggregateClaimOverflow, code: "commercial_aggregate_claim_limit" },
|
|
580
|
+
{ exceeded: scenarioOverflow, code: "commercial_scenario_limit" },
|
|
581
|
+
];
|
|
582
|
+
const issues = [
|
|
583
|
+
...captureIssues(captures),
|
|
584
|
+
...orderedPreflightIssues.filter((issue) => issue.exceeded).map(({ code }) => ({ code })),
|
|
585
|
+
];
|
|
586
|
+
const apiKeyResolution = resolveCommercialApiKey(resolved);
|
|
587
|
+
const apiKey = apiKeyResolution.value;
|
|
588
|
+
let executed = [];
|
|
589
|
+
|
|
590
|
+
if (aggregateClaimOverflow || scenarioOverflow) {
|
|
591
|
+
// Do not execute or compare a partial claim/scenario set. Independent
|
|
592
|
+
// preflight issues above remain visible when multiple ceilings are hit.
|
|
593
|
+
} else if (apiKeyResolution.unsupported) {
|
|
594
|
+
issues.push({ code: "unsupported_campaigns_api_key_source" });
|
|
595
|
+
} else if (plan.some((descriptor) => !descriptor?.unresolved) && !present(apiKey)) {
|
|
596
|
+
issues.push({ code: "missing_campaigns_api_key" });
|
|
597
|
+
} else if (typeof fetchImpl !== "function") {
|
|
598
|
+
issues.push({ code: "price_preview_fetch_unavailable" });
|
|
599
|
+
} else {
|
|
600
|
+
const executable = plan.filter((descriptor) => !descriptor?.unresolved);
|
|
601
|
+
executed = await mapConcurrent(executable, limits.concurrency, (descriptor) => previewDescriptor(descriptor, {
|
|
602
|
+
proxyBase: resolved?.proxyBase,
|
|
603
|
+
apiKey: String(apiKey || ""),
|
|
604
|
+
fetchImpl,
|
|
605
|
+
limits,
|
|
606
|
+
}));
|
|
607
|
+
executed.filter((entry) => entry?.ok === false).forEach((entry) => {
|
|
608
|
+
issues.push({ code: entry.error_code || "price_preview_failed" });
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const journey = normalizeJourney(
|
|
613
|
+
plan,
|
|
614
|
+
executed.map((entry) => entry.envelope),
|
|
615
|
+
resolved?.rawSpec || resolved?.spec || {},
|
|
616
|
+
{
|
|
617
|
+
spec_hash: specHashOf(resolved?.rawSpec || resolved?.spec),
|
|
618
|
+
catalog_imported_at: catalogImportedAt(resolved?.rawSpec || resolved?.spec),
|
|
619
|
+
},
|
|
620
|
+
);
|
|
621
|
+
const parityJourney = scenarioOverflow
|
|
622
|
+
? withScenarioOverflowPages(journey, commercialPlanning.pages)
|
|
623
|
+
: journey;
|
|
624
|
+
const parity = createCommercialParityReport(captures, parityJourney, {
|
|
625
|
+
maxAssertions,
|
|
626
|
+
countsOnly: aggregateClaimOverflow,
|
|
627
|
+
});
|
|
628
|
+
return {
|
|
629
|
+
assertions: parity.assertions,
|
|
630
|
+
commercial: compactReport(
|
|
631
|
+
parity,
|
|
632
|
+
parityJourney,
|
|
633
|
+
plan,
|
|
634
|
+
executed,
|
|
635
|
+
issues,
|
|
636
|
+
observedClaims,
|
|
637
|
+
limits.max_aggregate_claims,
|
|
638
|
+
commercialPlanning.observed_scenarios ?? plan.length,
|
|
639
|
+
),
|
|
640
|
+
};
|
|
641
|
+
}
|