@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,359 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
const REQUIRED_STRING_FIELDS = Object.freeze([
|
|
4
|
+
"schema_version",
|
|
5
|
+
"campaign.name",
|
|
6
|
+
"campaign.slug",
|
|
7
|
+
"candidate_base_url",
|
|
8
|
+
"sdk_version",
|
|
9
|
+
"gtm_container_id",
|
|
10
|
+
"api_key_env",
|
|
11
|
+
"expected_analytics.purchase_event",
|
|
12
|
+
"known_good_disposition",
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
// Credential detection is TERM-based, not exact-name-based: keys are
|
|
16
|
+
// normalized (camelCase → snake_case, kebab → snake) and any credential term
|
|
17
|
+
// anywhere in the key rejects a literal string value — api_key, apiKey,
|
|
18
|
+
// vendor-api-key, client_secret, access_token, private_key, authPassword,
|
|
19
|
+
// credentials. `*_env` indirection keys are not exempt from scrutiny: they
|
|
20
|
+
// name an env var, so their value must LOOK like one (UPPER_SNAKE name),
|
|
21
|
+
// otherwise the "_env" suffix would smuggle an arbitrary literal through.
|
|
22
|
+
const CREDENTIAL_TERMS = /(^|_)(api_?key|secrets?|tokens?|passwords?|passwd|credentials?|private_?key|auth|authorizations?)(_|$)/;
|
|
23
|
+
const ENV_VAR_NAME_PATTERN = /^[A-Z][A-Z0-9_]*$/;
|
|
24
|
+
// The slug is a PATH SEGMENT: every live run joins it into --output-dir to
|
|
25
|
+
// place the evidence bundle and verdict. Anything but a single lowercase
|
|
26
|
+
// segment ("../", an absolute path, a drive letter, a separator) would write
|
|
27
|
+
// outside the output directory, so constrain it here at the load choke point.
|
|
28
|
+
const SLUG_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
|
|
29
|
+
|
|
30
|
+
function normalizeKey(key) {
|
|
31
|
+
return String(key)
|
|
32
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
33
|
+
.replace(/-/g, "_")
|
|
34
|
+
.toLowerCase();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isCredentialKey(key) {
|
|
38
|
+
const normalized = normalizeKey(key);
|
|
39
|
+
if (normalized.endsWith("_env")) return false;
|
|
40
|
+
return CREDENTIAL_TERMS.test(normalized);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A credential-term key ending in `_env` must reference an env var by name.
|
|
44
|
+
function isCredentialEnvKey(key) {
|
|
45
|
+
const normalized = normalizeKey(key);
|
|
46
|
+
return normalized.endsWith("_env") && CREDENTIAL_TERMS.test(normalized.slice(0, -"_env".length) + "_");
|
|
47
|
+
}
|
|
48
|
+
const KNOWN_DISPOSITIONS = new Set(["ready", "ready_with_exceptions", "blocked"]);
|
|
49
|
+
|
|
50
|
+
// Loads fixture data only. Runtime credentials remain an orchestrator concern:
|
|
51
|
+
// fixtures name an environment variable but may never carry its secret value.
|
|
52
|
+
export async function loadParityFixture(path) {
|
|
53
|
+
let fixture;
|
|
54
|
+
try {
|
|
55
|
+
fixture = JSON.parse(await readFile(path, "utf8"));
|
|
56
|
+
} catch (error) {
|
|
57
|
+
throw new Error(`Unable to load parity fixture: ${error.message}`, { cause: error });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const errors = validateParityFixture(fixture);
|
|
61
|
+
if (errors.length) {
|
|
62
|
+
throw new Error(`Invalid parity fixture:\n- ${errors.join("\n- ")}`);
|
|
63
|
+
}
|
|
64
|
+
return fixture;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function validateParityFixture(fixture) {
|
|
68
|
+
if (!isObject(fixture)) return ["fixture: must be an object"];
|
|
69
|
+
|
|
70
|
+
const errors = [];
|
|
71
|
+
for (const field of REQUIRED_STRING_FIELDS) {
|
|
72
|
+
if (!nonEmptyString(valueAt(fixture, field))) {
|
|
73
|
+
errors.push(`${field}: required non-empty string`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
validateConstrainedStrings(fixture, errors);
|
|
78
|
+
|
|
79
|
+
if (!isObject(fixture.campaign?.shadow_campaign_ids)) {
|
|
80
|
+
errors.push("campaign.shadow_campaign_ids: required object");
|
|
81
|
+
} else {
|
|
82
|
+
for (const [name, id] of Object.entries(fixture.campaign.shadow_campaign_ids)) {
|
|
83
|
+
if (!positiveInteger(id)) errors.push(`campaign.shadow_campaign_ids.${name}: must be a positive integer`);
|
|
84
|
+
}
|
|
85
|
+
if (Object.keys(fixture.campaign.shadow_campaign_ids).length === 0) {
|
|
86
|
+
errors.push("campaign.shadow_campaign_ids: must contain at least one campaign id");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
validateCredentialFields(fixture, errors);
|
|
91
|
+
if (nonEmptyString(fixture.api_key_env) && !/^[A-Z][A-Z0-9_]*$/.test(fixture.api_key_env)) {
|
|
92
|
+
errors.push("api_key_env: must be an uppercase environment variable name");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!Array.isArray(fixture.voucher_codes) || fixture.voucher_codes.length === 0) {
|
|
96
|
+
errors.push("voucher_codes: must be a non-empty array");
|
|
97
|
+
} else if (fixture.voucher_codes.some((code) => !nonEmptyString(code))) {
|
|
98
|
+
errors.push("voucher_codes: every value must be a non-empty string");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!Array.isArray(fixture.scenarios) || fixture.scenarios.length === 0) {
|
|
102
|
+
errors.push("scenarios: must contain at least one scenario");
|
|
103
|
+
} else {
|
|
104
|
+
fixture.scenarios.forEach((scenario, index) => validateScenario(
|
|
105
|
+
scenario,
|
|
106
|
+
index,
|
|
107
|
+
fixture.campaign?.shadow_campaign_ids,
|
|
108
|
+
fixture.voucher_codes,
|
|
109
|
+
errors,
|
|
110
|
+
));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
validateExpectedAnalytics(fixture.expected_analytics, errors);
|
|
114
|
+
validateAnalyticsContract(fixture.analytics_contract, errors);
|
|
115
|
+
return errors;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function validateConstrainedStrings(fixture, errors) {
|
|
119
|
+
if (nonEmptyString(fixture.schema_version) && fixture.schema_version !== "1") {
|
|
120
|
+
errors.push("schema_version: unsupported version; expected 1");
|
|
121
|
+
}
|
|
122
|
+
if (nonEmptyString(fixture.candidate_base_url) && !isHttpUrl(fixture.candidate_base_url)) {
|
|
123
|
+
errors.push("candidate_base_url: must be a valid http(s) URL");
|
|
124
|
+
}
|
|
125
|
+
if (nonEmptyString(fixture.campaign?.slug)
|
|
126
|
+
&& (!SLUG_PATTERN.test(fixture.campaign.slug) || fixture.campaign.slug.includes(".."))) {
|
|
127
|
+
errors.push("campaign.slug: must be a single lowercase path-safe segment (a-z, 0-9, dot, dash, underscore)");
|
|
128
|
+
}
|
|
129
|
+
// Optional: an absent baseline just skips the candidate-vs-baseline diff.
|
|
130
|
+
// Explicit null is absent too — the capture already reads it as "no
|
|
131
|
+
// baseline", so authors may write the key out. When a baseline IS named it
|
|
132
|
+
// drives a live browser fetch, so it gets the same scheme check as the
|
|
133
|
+
// candidate: a fixture may not point the capture at file:// or any other
|
|
134
|
+
// non-http transport.
|
|
135
|
+
if (fixture.baseline_url !== undefined && fixture.baseline_url !== null
|
|
136
|
+
&& (!nonEmptyString(fixture.baseline_url) || !isHttpUrl(fixture.baseline_url))) {
|
|
137
|
+
errors.push("baseline_url: must be a valid http(s) URL when present");
|
|
138
|
+
}
|
|
139
|
+
if (nonEmptyString(fixture.sdk_version) && !/^\d+\.\d+\.\d+$/.test(fixture.sdk_version)) {
|
|
140
|
+
errors.push("sdk_version: must match MAJOR.MINOR.PATCH");
|
|
141
|
+
}
|
|
142
|
+
if (nonEmptyString(fixture.gtm_container_id) && !/^GTM-[A-Z0-9]+$/.test(fixture.gtm_container_id)) {
|
|
143
|
+
errors.push("gtm_container_id: must match GTM-[A-Z0-9]+");
|
|
144
|
+
}
|
|
145
|
+
if (nonEmptyString(fixture.known_good_disposition) && !KNOWN_DISPOSITIONS.has(fixture.known_good_disposition)) {
|
|
146
|
+
errors.push("known_good_disposition: must be ready, ready_with_exceptions, or blocked");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function validateCredentialFields(value, errors, path = "", visited = new WeakSet()) {
|
|
151
|
+
if (value === null || typeof value !== "object" || visited.has(value)) return;
|
|
152
|
+
visited.add(value);
|
|
153
|
+
|
|
154
|
+
if (Array.isArray(value)) {
|
|
155
|
+
value.forEach((entry, index) => validateCredentialFields(entry, errors, `${path}[${index}]`, visited));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
160
|
+
const entryPath = path ? `${path}.${key}` : key;
|
|
161
|
+
if (isCredentialKey(key) && typeof entry === "string" && entry.length > 0) {
|
|
162
|
+
errors.push(`${entryPath}: literal values are forbidden; supply credentials at runtime via api_key_env or CLI`);
|
|
163
|
+
} else if (isCredentialEnvKey(key) && entry !== null
|
|
164
|
+
&& !(typeof entry === "string" && ENV_VAR_NAME_PATTERN.test(entry))) {
|
|
165
|
+
// Any non-name value on an _env key (number, object wrapper, empty or
|
|
166
|
+
// literal string) is a smuggling vector, not indirection — reject it.
|
|
167
|
+
errors.push(`${entryPath}: must name an environment variable (UPPER_SNAKE), not carry a literal value`);
|
|
168
|
+
}
|
|
169
|
+
validateCredentialFields(entry, errors, entryPath, visited);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function validateScenario(scenario, index, shadowCampaignIds, voucherCodes, errors) {
|
|
174
|
+
const prefix = `scenarios[${index}]`;
|
|
175
|
+
if (!isObject(scenario)) {
|
|
176
|
+
errors.push(`${prefix}: must be an object`);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for (const field of ["scenario_id", "scenario_type", "shadow_campaign", "offer", "currency"]) {
|
|
181
|
+
if (!nonEmptyString(scenario[field])) errors.push(`${prefix}.${field}: required non-empty string`);
|
|
182
|
+
}
|
|
183
|
+
if (!positiveInteger(scenario.shadow_campaign_id)) {
|
|
184
|
+
errors.push(`${prefix}.shadow_campaign_id: must be a positive integer`);
|
|
185
|
+
}
|
|
186
|
+
if (nonEmptyString(scenario.shadow_campaign) && isObject(shadowCampaignIds)) {
|
|
187
|
+
if (!Object.hasOwn(shadowCampaignIds, scenario.shadow_campaign)) {
|
|
188
|
+
errors.push(`${prefix}.shadow_campaign: must reference campaign.shadow_campaign_ids`);
|
|
189
|
+
} else if (scenario.shadow_campaign_id !== shadowCampaignIds[scenario.shadow_campaign]) {
|
|
190
|
+
errors.push(`${prefix}.shadow_campaign_id: must match campaign.shadow_campaign_ids.${scenario.shadow_campaign}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (
|
|
194
|
+
nonEmptyString(scenario.voucher_code)
|
|
195
|
+
&& Array.isArray(voucherCodes)
|
|
196
|
+
&& !voucherCodes.includes(scenario.voucher_code)
|
|
197
|
+
) {
|
|
198
|
+
errors.push(`${prefix}.voucher_code: must be listed in voucher_codes`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (scenario.scenario_type === "funnel_offer") {
|
|
202
|
+
validateFunnelOfferScenario(scenario, prefix, errors);
|
|
203
|
+
} else if (scenario.scenario_type === "pricing_ladder") {
|
|
204
|
+
validatePricingLadderScenario(scenario, prefix, errors);
|
|
205
|
+
} else if (nonEmptyString(scenario.scenario_type)) {
|
|
206
|
+
errors.push(`${prefix}.scenario_type: must be funnel_offer or pricing_ladder`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function validateFunnelOfferScenario(scenario, prefix, errors) {
|
|
211
|
+
for (const field of ["funnel_path", "voucher_code"]) {
|
|
212
|
+
if (!nonEmptyString(scenario[field])) errors.push(`${prefix}.${field}: required non-empty string`);
|
|
213
|
+
}
|
|
214
|
+
validateRelativePath(scenario.checkout_path, `${prefix}.checkout_path`, errors, { required: true });
|
|
215
|
+
// The whole path must be a valid --test-order mode: "checkout" alone, or a
|
|
216
|
+
// chain of accept/decline steps ("accept", "decline", "accept-decline-accept").
|
|
217
|
+
// A partially-valid path ("accept-foo") must fail at load time, not later in
|
|
218
|
+
// the live driver's testOrderPaths().
|
|
219
|
+
const funnelSteps = String(scenario.funnel_path || "").split("-").filter(Boolean);
|
|
220
|
+
const validFunnelPath = funnelSteps.length > 0 && (
|
|
221
|
+
(funnelSteps.length === 1 && funnelSteps[0] === "checkout")
|
|
222
|
+
|| funnelSteps.every((step) => step === "accept" || step === "decline")
|
|
223
|
+
);
|
|
224
|
+
if (nonEmptyString(scenario.funnel_path) && !validFunnelPath) {
|
|
225
|
+
errors.push(`${prefix}.funnel_path: must be "checkout" or a chain of accept/decline steps`);
|
|
226
|
+
}
|
|
227
|
+
const hasOfferStep = funnelSteps.some((step) => step === "accept" || step === "decline");
|
|
228
|
+
if (hasOfferStep) {
|
|
229
|
+
validateRelativePath(scenario.upsell_route, `${prefix}.upsell_route`, errors, { required: true });
|
|
230
|
+
} else if (scenario.upsell_route !== undefined) {
|
|
231
|
+
validateRelativePath(scenario.upsell_route, `${prefix}.upsell_route`, errors);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const lineItem = scenario.expected_order_readback?.line_item;
|
|
235
|
+
if (!isObject(lineItem)) {
|
|
236
|
+
errors.push(`${prefix}.expected_order_readback.line_item: required object`);
|
|
237
|
+
} else {
|
|
238
|
+
for (const field of ["title", "price_field"]) {
|
|
239
|
+
if (!nonEmptyString(lineItem[field])) errors.push(`${prefix}.expected_order_readback.line_item.${field}: required non-empty string`);
|
|
240
|
+
}
|
|
241
|
+
if (!positiveInteger(lineItem.quantity)) {
|
|
242
|
+
errors.push(`${prefix}.expected_order_readback.line_item.quantity: must be a positive integer`);
|
|
243
|
+
}
|
|
244
|
+
if (typeof lineItem.is_upsell !== "boolean") {
|
|
245
|
+
errors.push(`${prefix}.expected_order_readback.line_item.is_upsell: must be a boolean`);
|
|
246
|
+
}
|
|
247
|
+
for (const field of ["base_unit_price", "base_subtotal", "expected_line_total"]) {
|
|
248
|
+
validateMoney(lineItem[field], `${prefix}.expected_order_readback.line_item.${field}`, errors);
|
|
249
|
+
}
|
|
250
|
+
// Both fields are OPTIONAL corpus documentation, validated only when
|
|
251
|
+
// present: `dropped_voucher_line_total` records the known-bad base price a
|
|
252
|
+
// negative-control replay should carry (omitting it just means the corpus
|
|
253
|
+
// doesn't document that scenario's regression amount — no assertion is
|
|
254
|
+
// generated from it), and `discount_amount` records the offer math.
|
|
255
|
+
for (const field of ["discount_amount", "dropped_voucher_line_total"]) {
|
|
256
|
+
if (lineItem[field] !== undefined) validateMoney(lineItem[field], `${prefix}.expected_order_readback.line_item.${field}`, errors);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const purchase = scenario.expected_purchase;
|
|
261
|
+
if (!isObject(purchase)) {
|
|
262
|
+
errors.push(`${prefix}.expected_purchase: required object`);
|
|
263
|
+
} else {
|
|
264
|
+
for (const field of ["event", "currency"]) {
|
|
265
|
+
if (!nonEmptyString(purchase[field])) errors.push(`${prefix}.expected_purchase.${field}: required non-empty string`);
|
|
266
|
+
}
|
|
267
|
+
// null = "present with a finite client value" without pinning the amount
|
|
268
|
+
// (an unpinned checkout cart cannot honestly pin the main-order value).
|
|
269
|
+
if (purchase.value !== null) {
|
|
270
|
+
validateMoney(purchase.value, `${prefix}.expected_purchase.value`, errors);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function validateRelativePath(value, path, errors, { required = false } = {}) {
|
|
276
|
+
if (!nonEmptyString(value)) {
|
|
277
|
+
if (required) errors.push(`${path}: required non-empty relative path`);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const route = value.trim();
|
|
281
|
+
// Backslashes are rejected outright: WHATWG URL resolution normalizes
|
|
282
|
+
// "\\evil.test\x" to "https://evil.test/x", which would silently escape the
|
|
283
|
+
// candidate origin (and leak any configured auth headers to another host).
|
|
284
|
+
if (/^[a-z][a-z\d+.-]*:/i.test(route) || route.startsWith("//") || route.startsWith("#") || route.startsWith("?") || route.includes("\\")) {
|
|
285
|
+
errors.push(`${path}: must be a relative path`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function validatePricingLadderScenario(scenario, prefix, errors) {
|
|
290
|
+
if (!Array.isArray(scenario.expected_pricing) || scenario.expected_pricing.length === 0) {
|
|
291
|
+
errors.push(`${prefix}.expected_pricing: must be a non-empty array`);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
scenario.expected_pricing.forEach((entry, index) => {
|
|
295
|
+
const entryPrefix = `${prefix}.expected_pricing[${index}]`;
|
|
296
|
+
if (!isObject(entry)) {
|
|
297
|
+
errors.push(`${entryPrefix}: must be an object`);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (!positiveInteger(entry.quantity)) errors.push(`${entryPrefix}.quantity: must be a positive integer`);
|
|
301
|
+
validateMoney(entry.expected_total, `${entryPrefix}.expected_total`, errors);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function validateExpectedAnalytics(expected, errors) {
|
|
306
|
+
if (!isObject(expected)) {
|
|
307
|
+
errors.push("expected_analytics: required object");
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (typeof expected.purchase_expected !== "boolean") {
|
|
311
|
+
errors.push("expected_analytics.purchase_expected: must be a boolean");
|
|
312
|
+
}
|
|
313
|
+
const gtm = expected.candidate_inventory?.gtm;
|
|
314
|
+
if (!Array.isArray(gtm) || gtm.length === 0 || gtm.some((id) => !nonEmptyString(id))) {
|
|
315
|
+
errors.push("expected_analytics.candidate_inventory.gtm: must be a non-empty string array");
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function validateAnalyticsContract(contract, errors) {
|
|
320
|
+
if (contract === undefined) return;
|
|
321
|
+
if (!isObject(contract)) {
|
|
322
|
+
errors.push("analytics_contract: must be an object");
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (contract.providers !== undefined && !isObject(contract.providers)) {
|
|
326
|
+
errors.push("analytics_contract.providers: must be an object");
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function validateMoney(value, path, errors) {
|
|
331
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
332
|
+
errors.push(`${path}: must be a non-negative finite number`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function valueAt(object, path) {
|
|
337
|
+
return path.split(".").reduce((value, key) => value?.[key], object);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function isObject(value) {
|
|
341
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function nonEmptyString(value) {
|
|
345
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function positiveInteger(value) {
|
|
349
|
+
return Number.isInteger(value) && value > 0;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function isHttpUrl(value) {
|
|
353
|
+
try {
|
|
354
|
+
const url = new URL(value);
|
|
355
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
356
|
+
} catch {
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
}
|