@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,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AnalyticsContractShape — validates the optional top-level `analytics` block
|
|
3
|
+
* when present. The block declares a campaign's analytics/attribution/param
|
|
4
|
+
* contract so doctor + QA can validate against intent (cf. the Chamelo Shield
|
|
5
|
+
* `?reviews=n`-has-no-handler QA finding and the Walla Sound Redtrack param
|
|
6
|
+
* conflict — both are gaps that had no declared contract to check against).
|
|
7
|
+
*
|
|
8
|
+
* The block is fully OPTIONAL — a spec without `analytics` is silent (SDK
|
|
9
|
+
* defaults apply, exactly as today). When `analytics` IS set, this rule catches
|
|
10
|
+
* authoring drift before doctor/QA see it. Every check is `warning` severity:
|
|
11
|
+
* authoring guidance, not a build blocker (matches DesignSourceShape).
|
|
12
|
+
*
|
|
13
|
+
* Checks:
|
|
14
|
+
* 0. If `analytics` is present but not a plain object (a non-plain-object
|
|
15
|
+
* value: string, array, number, boolean, boxed primitive, class instance,
|
|
16
|
+
* etc.), warn once and stop — there is no contract shape to inspect.
|
|
17
|
+
* Genuinely-absent `analytics` stays silent (optional, non-gating).
|
|
18
|
+
* 1. `mode`, if present, is one of auto | manual | disabled.
|
|
19
|
+
* 2. Each provider: `blockedEvents` (when present) is a string[], and each
|
|
20
|
+
* entry is a known SDK `dl_*` event (a misspelled/legacy name like
|
|
21
|
+
* "purchase" blocks nothing — the original drift bug this keystone closes);
|
|
22
|
+
* an enabled gtm provider should declare `containerId`, facebook `pixelId`,
|
|
23
|
+
* custom `endpoint` (warning — the id is what doctor/QA bind to).
|
|
24
|
+
* 3. Each `out_of_band_pixels[]` entry has a non-empty `vendor`.
|
|
25
|
+
* 4. Each `manual_events[]` entry has a non-empty `event`; if it names a
|
|
26
|
+
* `page`, that page id must exist; a purchase manual event SHOULD name a
|
|
27
|
+
* page (the first-upsell placement footgun — beacons lost in the
|
|
28
|
+
* checkout→upsell redirect when placed on checkout).
|
|
29
|
+
* 5. Each `params.content[]` entry has a non-empty `name`; referenced `pages`
|
|
30
|
+
* must exist (a content param pointing at a missing page is the
|
|
31
|
+
* `?reviews=n`-with-no-handler gap, inverted).
|
|
32
|
+
* 6. `params.tracking.click_id`, when present, declares both `inbound` and
|
|
33
|
+
* `maps_to` (half a mapping silently drops the affiliate click id).
|
|
34
|
+
* 7. `params.tracking.preserve` / `utmTransfer.paramsToCopy`, when present,
|
|
35
|
+
* are string[].
|
|
36
|
+
* 8. If analytics is active (the block is present and mode is not
|
|
37
|
+
* "disabled"; missing mode means SDK defaults apply), runtime/global_config
|
|
38
|
+
* sdk_version should be an exact released semver >= the SDK identity
|
|
39
|
+
* baseline so events carry campaign/session ids.
|
|
40
|
+
*/
|
|
41
|
+
import { CAMPAIGN_CART_ANALYTICS_IDENTITY_MIN_SDK_VERSION, isKnownDlEvent, } from "../analytics-vocabulary.js";
|
|
42
|
+
const VALID_MODES = new Set(['auto', 'manual', 'disabled']);
|
|
43
|
+
const PURCHASE_EVENT = /^(?:dl_)?purchase$/i;
|
|
44
|
+
function isNonEmptyString(value) {
|
|
45
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
46
|
+
}
|
|
47
|
+
function isStringArray(value) {
|
|
48
|
+
return Array.isArray(value) && value.every((v) => typeof v === 'string');
|
|
49
|
+
}
|
|
50
|
+
function isPlainAnalyticsObject(value) {
|
|
51
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
52
|
+
return false;
|
|
53
|
+
const proto = Object.getPrototypeOf(value);
|
|
54
|
+
return proto === Object.prototype || proto === null;
|
|
55
|
+
}
|
|
56
|
+
function parseReleasedVersion(version) {
|
|
57
|
+
const match = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)$/);
|
|
58
|
+
if (!match)
|
|
59
|
+
return null;
|
|
60
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
61
|
+
}
|
|
62
|
+
function compareVersions(a, b) {
|
|
63
|
+
const left = parseReleasedVersion(a);
|
|
64
|
+
const right = parseReleasedVersion(b);
|
|
65
|
+
if (!left || !right)
|
|
66
|
+
return null;
|
|
67
|
+
for (let i = 0; i < 3; i += 1) {
|
|
68
|
+
if (left[i] > right[i])
|
|
69
|
+
return 1;
|
|
70
|
+
if (left[i] < right[i])
|
|
71
|
+
return -1;
|
|
72
|
+
}
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
function sdkVersionRef(spec) {
|
|
76
|
+
const runtimeVersion = spec.runtime?.sdk_version;
|
|
77
|
+
if (isNonEmptyString(runtimeVersion))
|
|
78
|
+
return { version: runtimeVersion, path: '/runtime/sdk_version' };
|
|
79
|
+
const globalVersion = spec.global_config?.sdk_version;
|
|
80
|
+
if (isNonEmptyString(globalVersion))
|
|
81
|
+
return { version: globalVersion, path: '/global_config/sdk_version' };
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
function analyticsContractIsActive(analytics) {
|
|
85
|
+
// A present analytics block with no mode still means "SDK defaults apply";
|
|
86
|
+
// either supported explicit disable mechanism opts out of the SDK identity
|
|
87
|
+
// baseline check.
|
|
88
|
+
return analytics.enabled !== false && analytics.mode !== 'disabled';
|
|
89
|
+
}
|
|
90
|
+
function collectPageIds(spec) {
|
|
91
|
+
const pageIds = new Set();
|
|
92
|
+
const checkoutPageIds = new Set();
|
|
93
|
+
for (const funnel of spec.funnels ?? []) {
|
|
94
|
+
for (const page of funnel.pages ?? []) {
|
|
95
|
+
if (isNonEmptyString(page.id)) {
|
|
96
|
+
pageIds.add(page.id);
|
|
97
|
+
if (page.type === 'checkout')
|
|
98
|
+
checkoutPageIds.add(page.id);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { pageIds, checkoutPageIds };
|
|
103
|
+
}
|
|
104
|
+
export const AnalyticsContractShape = {
|
|
105
|
+
id: 'AnalyticsContractShape',
|
|
106
|
+
severity: 'warning',
|
|
107
|
+
// Dual tag is intentional and matches every sibling pure-spec rule
|
|
108
|
+
// (StoreProfileShape, DesignSourceShape, AssemblyHintsShape, …): the rule is
|
|
109
|
+
// cheap enough for per-keystroke Map Builder (`fast`) AND needs no live
|
|
110
|
+
// deployment (`spec-only`). `fastRules`/`specOnlyRules` are mutually-exclusive
|
|
111
|
+
// FILTERED VIEWS of `allRules` (see rules/index.ts) — a validation pass runs
|
|
112
|
+
// exactly one RuleSet, and `allRules` lists each rule once, so a dual-tagged
|
|
113
|
+
// rule never double-runs. (Surfaced in a downstream consumer-PR review.)
|
|
114
|
+
tags: ['fast', 'spec-only'],
|
|
115
|
+
check(spec) {
|
|
116
|
+
const violations = [];
|
|
117
|
+
const analytics = spec.analytics;
|
|
118
|
+
// Genuinely-absent analytics stays silent: the block is OPTIONAL and
|
|
119
|
+
// non-gating (SDK defaults apply). But a PRESENT-but-non-object value
|
|
120
|
+
// (`analytics: "auto"`, an array, a primitive) is authoring drift the
|
|
121
|
+
// author wants to hear about — warn, then stop (no shape to inspect).
|
|
122
|
+
if (analytics === undefined || analytics === null)
|
|
123
|
+
return violations;
|
|
124
|
+
// Reject any non-plain-object value: primitives, arrays, and boxed
|
|
125
|
+
// primitives (new String("auto") passes `typeof === 'object'` but is not a
|
|
126
|
+
// plain contract block). Only Object.prototype and null-prototype objects
|
|
127
|
+
// are accepted as valid analytics blocks.
|
|
128
|
+
if (!isPlainAnalyticsObject(analytics)) {
|
|
129
|
+
const label = Array.isArray(analytics) ? 'an array' : typeof analytics !== 'object' ? typeof analytics : 'a non-plain object';
|
|
130
|
+
violations.push({
|
|
131
|
+
ruleId: 'AnalyticsContractShape',
|
|
132
|
+
severity: 'warning',
|
|
133
|
+
message: 'analytics must be an object (the analytics/attribution contract block); got ' + label + '.',
|
|
134
|
+
path: '/analytics',
|
|
135
|
+
data: { check: 'analytics-shape' },
|
|
136
|
+
});
|
|
137
|
+
return violations;
|
|
138
|
+
}
|
|
139
|
+
const warn = (message, path, check, data = {}) => {
|
|
140
|
+
violations.push({
|
|
141
|
+
ruleId: 'AnalyticsContractShape',
|
|
142
|
+
severity: 'warning',
|
|
143
|
+
message,
|
|
144
|
+
path,
|
|
145
|
+
data: { check, ...data },
|
|
146
|
+
});
|
|
147
|
+
};
|
|
148
|
+
// 8. SDK identity baseline
|
|
149
|
+
const versionRef = sdkVersionRef(spec);
|
|
150
|
+
if (analyticsContractIsActive(analytics) && versionRef) {
|
|
151
|
+
const comparison = compareVersions(versionRef.version, CAMPAIGN_CART_ANALYTICS_IDENTITY_MIN_SDK_VERSION);
|
|
152
|
+
if (comparison === null) {
|
|
153
|
+
warn(`analytics is declared, but Campaign Cart SDK version "${versionRef.version}" is not an exact released semver pin. Use a concrete release such as "${CAMPAIGN_CART_ANALYTICS_IDENTITY_MIN_SDK_VERSION}" so Campaigns OS can verify whether campaign_* identifiers and ncsid-backed campaign_session_id are stamped on every event.`, versionRef.path, 'sdk-version-unparseable', {
|
|
154
|
+
sdkVersion: versionRef.version,
|
|
155
|
+
expectedFormat: 'MAJOR.MINOR.PATCH',
|
|
156
|
+
minimumSdkVersion: CAMPAIGN_CART_ANALYTICS_IDENTITY_MIN_SDK_VERSION,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
else if (comparison < 0) {
|
|
160
|
+
warn(`analytics is declared, but Campaign Cart SDK ${versionRef.version} is below ${CAMPAIGN_CART_ANALYTICS_IDENTITY_MIN_SDK_VERSION}. campaign_* identifiers and ncsid-backed campaign_session_id are stamped on every event starting in ${CAMPAIGN_CART_ANALYTICS_IDENTITY_MIN_SDK_VERSION}; upgrade the SDK for analytics attribution/joinability or record the intentional pin.`, versionRef.path, 'sdk-identity-baseline', {
|
|
161
|
+
sdkVersion: versionRef.version,
|
|
162
|
+
minimumSdkVersion: CAMPAIGN_CART_ANALYTICS_IDENTITY_MIN_SDK_VERSION,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// 1. mode
|
|
167
|
+
if (analytics.mode !== undefined && !VALID_MODES.has(String(analytics.mode))) {
|
|
168
|
+
warn(`analytics.mode "${String(analytics.mode)}" is not recognized; expected auto | manual | disabled.`, '/analytics/mode', 'mode-invalid', { mode: analytics.mode });
|
|
169
|
+
}
|
|
170
|
+
// 2. providers
|
|
171
|
+
const providers = analytics.providers;
|
|
172
|
+
if (providers && typeof providers === 'object' && !Array.isArray(providers)) {
|
|
173
|
+
for (const [kind, provider] of Object.entries(providers)) {
|
|
174
|
+
if (!provider || typeof provider !== 'object')
|
|
175
|
+
continue;
|
|
176
|
+
const base = `/analytics/providers/${kind}`;
|
|
177
|
+
// Asymmetry accepted by design (downstream consumer-PR review): a
|
|
178
|
+
// provider with `enabled:false` that still carries containerId/pixelId/
|
|
179
|
+
// endpoint gets no signal. A disabled provider keeping its id is a
|
|
180
|
+
// legitimate, common pattern (staged rollout, env-toggled, kept for
|
|
181
|
+
// reference) — flagging it would be noise, and "off accidentally" is not
|
|
182
|
+
// distinguishable from "off by design" at spec level. We only validate
|
|
183
|
+
// ENABLED providers' binding ids; disabled providers are left alone.
|
|
184
|
+
const enabled = provider.enabled !== false;
|
|
185
|
+
if (provider.blockedEvents !== undefined && !isStringArray(provider.blockedEvents)) {
|
|
186
|
+
warn(`analytics.providers.${kind}.blockedEvents must be an array of event-name strings.`, `${base}/blockedEvents`, 'blocked-events-shape', { kind });
|
|
187
|
+
}
|
|
188
|
+
else if (isStringArray(provider.blockedEvents)) {
|
|
189
|
+
// Each blocked event must be a known SDK dl_* event. blockedEvents
|
|
190
|
+
// matches by EXACT event name at runtime, so a misspelled or legacy
|
|
191
|
+
// name (e.g. "purchase" instead of "dl_purchase") silently blocks
|
|
192
|
+
// nothing — caught here at doctor time, not in production.
|
|
193
|
+
const blocked = provider.blockedEvents;
|
|
194
|
+
blocked.forEach((evt, i) => {
|
|
195
|
+
if (!isKnownDlEvent(evt)) {
|
|
196
|
+
warn(`analytics.providers.${kind}.blockedEvents[${i}] "${evt}" is not a known SDK dl_* event. blockedEvents matches by exact event name, so a misspelled or legacy name (e.g. "purchase" instead of "dl_purchase") blocks nothing.`, `${base}/blockedEvents/${i}`, 'blocked-event-unknown', { kind, event: evt });
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
if (enabled && kind === 'gtm' && !isNonEmptyString(provider.containerId)) {
|
|
201
|
+
warn(`analytics.providers.gtm is enabled but has no containerId (e.g. "GTM-…"); doctor/QA bind to it.`, `${base}/containerId`, 'gtm-container-missing', { kind });
|
|
202
|
+
}
|
|
203
|
+
if (enabled && kind === 'facebook' && !isNonEmptyString(provider.pixelId)) {
|
|
204
|
+
warn(`analytics.providers.facebook is enabled but has no pixelId; doctor/QA bind to it.`, `${base}/pixelId`, 'facebook-pixel-missing', { kind });
|
|
205
|
+
}
|
|
206
|
+
if (enabled && kind === 'custom' && !isNonEmptyString(provider.endpoint)) {
|
|
207
|
+
warn(`analytics.providers.custom is enabled but has no endpoint URL.`, `${base}/endpoint`, 'custom-endpoint-missing', { kind });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const { pageIds, checkoutPageIds } = collectPageIds(spec);
|
|
212
|
+
// 3. out_of_band_pixels
|
|
213
|
+
if (analytics.out_of_band_pixels !== undefined) {
|
|
214
|
+
if (!Array.isArray(analytics.out_of_band_pixels)) {
|
|
215
|
+
warn(`analytics.out_of_band_pixels must be an array.`, '/analytics/out_of_band_pixels', 'oob-not-array');
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
analytics.out_of_band_pixels.forEach((pixel, i) => {
|
|
219
|
+
if (!pixel || typeof pixel !== 'object' || !isNonEmptyString(pixel.vendor)) {
|
|
220
|
+
warn(`out_of_band_pixels[${i}] is missing a vendor (e.g. "everflow", "triplepixel").`, `/analytics/out_of_band_pixels/${i}/vendor`, 'oob-vendor-missing', { index: i });
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// 4. manual_events
|
|
226
|
+
if (analytics.manual_events !== undefined) {
|
|
227
|
+
if (!Array.isArray(analytics.manual_events)) {
|
|
228
|
+
warn(`analytics.manual_events must be an array.`, '/analytics/manual_events', 'manual-events-not-array');
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
analytics.manual_events.forEach((evt, i) => {
|
|
232
|
+
const base = `/analytics/manual_events/${i}`;
|
|
233
|
+
if (!evt || typeof evt !== 'object' || !isNonEmptyString(evt.event)) {
|
|
234
|
+
warn(`manual_events[${i}] is missing an event name.`, `${base}/event`, 'manual-event-name-missing', { index: i });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (isNonEmptyString(evt.page) && !pageIds.has(evt.page)) {
|
|
238
|
+
warn(`manual_events[${i}] (${evt.event}) names page "${evt.page}", which is not a page id in this spec.`, `${base}/page`, 'manual-event-page-unknown', { index: i, page: evt.page });
|
|
239
|
+
}
|
|
240
|
+
if (PURCHASE_EVENT.test(evt.event) && !isNonEmptyString(evt.page)) {
|
|
241
|
+
warn(`manual_events[${i}] is a purchase fire but declares no page. Purchase beacons placed on checkout are lost in the checkout→upsell redirect — declare the page they live on (typically the first upsell).`, `${base}/page`, 'manual-purchase-page-missing', { index: i });
|
|
242
|
+
}
|
|
243
|
+
if (PURCHASE_EVENT.test(evt.event) && isNonEmptyString(evt.page) && checkoutPageIds.has(evt.page)) {
|
|
244
|
+
warn(`manual_events[${i}] is a purchase fire on checkout page "${evt.page}". Purchase beacons on checkout are lost in the checkout→upsell redirect — move the fire to the first upsell page.`, `${base}/page`, 'manual-purchase-on-checkout', { index: i, page: evt.page });
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
// 5 + 6. params
|
|
250
|
+
const params = analytics.params;
|
|
251
|
+
if (params && typeof params === 'object') {
|
|
252
|
+
const content = params.content;
|
|
253
|
+
if (content !== undefined) {
|
|
254
|
+
if (!Array.isArray(content)) {
|
|
255
|
+
warn(`analytics.params.content must be an array.`, '/analytics/params/content', 'content-not-array');
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
content.forEach((cp, i) => {
|
|
259
|
+
const base = `/analytics/params/content/${i}`;
|
|
260
|
+
if (!cp || typeof cp !== 'object' || !isNonEmptyString(cp.name)) {
|
|
261
|
+
warn(`params.content[${i}] is missing a param name.`, `${base}/name`, 'content-name-missing', { index: i });
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (cp.pages !== undefined && !Array.isArray(cp.pages)) {
|
|
265
|
+
warn(`params.content[${i}] (?${cp.name}) pages must be an array of page-id strings.`, `${base}/pages`, 'content-pages-shape', { index: i, name: cp.name });
|
|
266
|
+
}
|
|
267
|
+
else if (Array.isArray(cp.pages) && cp.pages.length === 0) {
|
|
268
|
+
// Explicit empty array applies to no page — a silent no-op the
|
|
269
|
+
// author almost certainly didn't intend. Omit `pages` to apply to
|
|
270
|
+
// all pages, or list the ids to scope to.
|
|
271
|
+
warn(`params.content[${i}] (?${cp.name}) has an empty pages array, so it applies to no page. Omit pages to apply to all pages, or list the page ids it should scope to.`, `${base}/pages`, 'content-pages-empty', { index: i, name: cp.name });
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
for (const pageRef of cp.pages ?? []) {
|
|
275
|
+
if (!pageIds.has(pageRef)) {
|
|
276
|
+
warn(`params.content[${i}] (?${cp.name}) references page "${pageRef}", which is not a page id in this spec.`, `${base}/pages`, 'content-page-unknown', { index: i, name: cp.name, page: pageRef });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const tracking = params.tracking;
|
|
284
|
+
if (tracking && typeof tracking === 'object') {
|
|
285
|
+
if (tracking.preserve !== undefined && !isStringArray(tracking.preserve)) {
|
|
286
|
+
warn(`analytics.params.tracking.preserve must be an array of param-name strings.`, '/analytics/params/tracking/preserve', 'preserve-shape');
|
|
287
|
+
}
|
|
288
|
+
const clickId = tracking.click_id;
|
|
289
|
+
if (clickId && typeof clickId === 'object') {
|
|
290
|
+
if (!isNonEmptyString(clickId.inbound) || !isNonEmptyString(clickId.maps_to)) {
|
|
291
|
+
warn(`analytics.params.tracking.click_id needs both "inbound" (the querystring param) and "maps_to" (the SDK attribution field); half a mapping silently drops the affiliate click id.`, '/analytics/params/tracking/click_id', 'click-id-incomplete');
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// 7. utmTransfer.paramsToCopy
|
|
297
|
+
const utm = analytics.utmTransfer;
|
|
298
|
+
if (utm && typeof utm === 'object' && utm.paramsToCopy !== undefined && !isStringArray(utm.paramsToCopy)) {
|
|
299
|
+
warn(`analytics.utmTransfer.paramsToCopy must be an array of param-name strings.`, '/analytics/utmTransfer/paramsToCopy', 'utm-params-shape');
|
|
300
|
+
}
|
|
301
|
+
return violations;
|
|
302
|
+
},
|
|
303
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AssemblyHintsShape — validates the optional authoring-time build hints
|
|
3
|
+
* introduced in Slice 4a and extended in Slice 4b:
|
|
4
|
+
*
|
|
5
|
+
* 1. campaign.preferred_template_family — which starter family the
|
|
6
|
+
* campaign was authored against. Already read by campaigns-os
|
|
7
|
+
* preferredTemplateFamily() (three locations); this rule blesses
|
|
8
|
+
* the convention and warns when the value isn't a recognized
|
|
9
|
+
* family.
|
|
10
|
+
* 2. page.upsell_template_pattern — per-page UI variant hint
|
|
11
|
+
* (mv | bundle_tier_pills | bundle_tier_cards | single). Warns
|
|
12
|
+
* when set on a non-upsell page (meaningless) or when the value
|
|
13
|
+
* isn't recognized.
|
|
14
|
+
* 3. page.upsell_mv_tiers — per-page MV tier range `{min, max}`
|
|
15
|
+
* declaring the inclusive quantity-tier subset to render. Warns
|
|
16
|
+
* when set on a non-upsell page, when shape is malformed (missing
|
|
17
|
+
* field, non-integer, non-positive), or when min > max.
|
|
18
|
+
*
|
|
19
|
+
* All three are HINTS, not contracts. The build agent uses them as
|
|
20
|
+
* defaults; CLI/operator overrides win. Hence the warning severity
|
|
21
|
+
* across the board — these never block a build, they just nudge
|
|
22
|
+
* authoring quality.
|
|
23
|
+
*
|
|
24
|
+
* Doctrine note: template family is fundamentally a build-time
|
|
25
|
+
* decision and CLI/operator overrides always win. Authoring-time
|
|
26
|
+
* hints are allowed because designers / campaign owners often know
|
|
27
|
+
* the answer at authoring time, and re-deciding at build was
|
|
28
|
+
* repeated friction — hence warning severity, never a build blocker.
|
|
29
|
+
*
|
|
30
|
+
* Cross-field constraint enforcement (upsell_mv_tiers min <= max): the
|
|
31
|
+
* JSON Schema (schemas/campaign-runtime-build-packet.v0.schema.json)
|
|
32
|
+
* does NOT express this constraint — plain JSON Schema 2020-12 cannot
|
|
33
|
+
* say "field A must be less than or equal to field B" without
|
|
34
|
+
* $data-style extensions. Defense in depth lives at two layers
|
|
35
|
+
* instead: this rule warns the author at authoring/QA time, and the
|
|
36
|
+
* campaigns-os consumer's normalizedMvTiers() silently drops the field
|
|
37
|
+
* when min > max so a half-state spec never reaches the build agent
|
|
38
|
+
* through the packet. Both layers must stay aligned; loosening either
|
|
39
|
+
* one is a contract change worth a coordinated PR.
|
|
40
|
+
*/
|
|
41
|
+
import type { Rule } from '../types.ts';
|
|
42
|
+
export declare const AssemblyHintsShape: Rule;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AssemblyHintsShape — validates the optional authoring-time build hints
|
|
3
|
+
* introduced in Slice 4a and extended in Slice 4b:
|
|
4
|
+
*
|
|
5
|
+
* 1. campaign.preferred_template_family — which starter family the
|
|
6
|
+
* campaign was authored against. Already read by campaigns-os
|
|
7
|
+
* preferredTemplateFamily() (three locations); this rule blesses
|
|
8
|
+
* the convention and warns when the value isn't a recognized
|
|
9
|
+
* family.
|
|
10
|
+
* 2. page.upsell_template_pattern — per-page UI variant hint
|
|
11
|
+
* (mv | bundle_tier_pills | bundle_tier_cards | single). Warns
|
|
12
|
+
* when set on a non-upsell page (meaningless) or when the value
|
|
13
|
+
* isn't recognized.
|
|
14
|
+
* 3. page.upsell_mv_tiers — per-page MV tier range `{min, max}`
|
|
15
|
+
* declaring the inclusive quantity-tier subset to render. Warns
|
|
16
|
+
* when set on a non-upsell page, when shape is malformed (missing
|
|
17
|
+
* field, non-integer, non-positive), or when min > max.
|
|
18
|
+
*
|
|
19
|
+
* All three are HINTS, not contracts. The build agent uses them as
|
|
20
|
+
* defaults; CLI/operator overrides win. Hence the warning severity
|
|
21
|
+
* across the board — these never block a build, they just nudge
|
|
22
|
+
* authoring quality.
|
|
23
|
+
*
|
|
24
|
+
* Doctrine note: template family is fundamentally a build-time
|
|
25
|
+
* decision and CLI/operator overrides always win. Authoring-time
|
|
26
|
+
* hints are allowed because designers / campaign owners often know
|
|
27
|
+
* the answer at authoring time, and re-deciding at build was
|
|
28
|
+
* repeated friction — hence warning severity, never a build blocker.
|
|
29
|
+
*
|
|
30
|
+
* Cross-field constraint enforcement (upsell_mv_tiers min <= max): the
|
|
31
|
+
* JSON Schema (schemas/campaign-runtime-build-packet.v0.schema.json)
|
|
32
|
+
* does NOT express this constraint — plain JSON Schema 2020-12 cannot
|
|
33
|
+
* say "field A must be less than or equal to field B" without
|
|
34
|
+
* $data-style extensions. Defense in depth lives at two layers
|
|
35
|
+
* instead: this rule warns the author at authoring/QA time, and the
|
|
36
|
+
* campaigns-os consumer's normalizedMvTiers() silently drops the field
|
|
37
|
+
* when min > max so a half-state spec never reaches the build agent
|
|
38
|
+
* through the packet. Both layers must stay aligned; loosening either
|
|
39
|
+
* one is a contract change worth a coordinated PR.
|
|
40
|
+
*/
|
|
41
|
+
import { KNOWN_TEMPLATE_FAMILY_HINTS } from "../types.js";
|
|
42
|
+
const KNOWN_TEMPLATE_FAMILIES = new Set(KNOWN_TEMPLATE_FAMILY_HINTS);
|
|
43
|
+
const KNOWN_UPSELL_PATTERNS = new Set([
|
|
44
|
+
'mv',
|
|
45
|
+
'bundle_tier_pills',
|
|
46
|
+
'bundle_tier_cards',
|
|
47
|
+
'single',
|
|
48
|
+
]);
|
|
49
|
+
function isNonEmptyString(value) {
|
|
50
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
51
|
+
}
|
|
52
|
+
function isPositiveInteger(value) {
|
|
53
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 1;
|
|
54
|
+
}
|
|
55
|
+
export const AssemblyHintsShape = {
|
|
56
|
+
id: 'AssemblyHintsShape',
|
|
57
|
+
severity: 'warning',
|
|
58
|
+
tags: ['fast', 'spec-only'],
|
|
59
|
+
check(spec) {
|
|
60
|
+
const violations = [];
|
|
61
|
+
// 1. Campaign-level template family hint.
|
|
62
|
+
const templateFamily = spec.campaign?.preferred_template_family;
|
|
63
|
+
if (templateFamily !== undefined) {
|
|
64
|
+
if (!isNonEmptyString(templateFamily)) {
|
|
65
|
+
violations.push({
|
|
66
|
+
ruleId: 'AssemblyHintsShape',
|
|
67
|
+
severity: 'warning',
|
|
68
|
+
message: 'campaign.preferred_template_family is set but empty; remove the field or set it to a known family.',
|
|
69
|
+
path: '/campaign/preferred_template_family',
|
|
70
|
+
data: { check: 'template-family-empty' },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
else if (!KNOWN_TEMPLATE_FAMILIES.has(templateFamily)) {
|
|
74
|
+
violations.push({
|
|
75
|
+
ruleId: 'AssemblyHintsShape',
|
|
76
|
+
severity: 'warning',
|
|
77
|
+
message: `campaign.preferred_template_family "${templateFamily}" is not in the known set (${[...KNOWN_TEMPLATE_FAMILIES].sort().join(', ')}). The build agent will still try to use it as a hint, but consider correcting the value if this is a typo.`,
|
|
78
|
+
path: '/campaign/preferred_template_family',
|
|
79
|
+
data: { check: 'template-family-unknown', value: templateFamily },
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// 2. Per-page upsell template pattern.
|
|
84
|
+
spec.funnels.forEach((funnel, funnelIdx) => {
|
|
85
|
+
const pages = funnel.pages ?? [];
|
|
86
|
+
pages.forEach((page, pageIdx) => {
|
|
87
|
+
const pattern = page.upsell_template_pattern;
|
|
88
|
+
if (pattern === undefined)
|
|
89
|
+
return;
|
|
90
|
+
const path = `/funnels/${funnelIdx}/pages/${pageIdx}/upsell_template_pattern`;
|
|
91
|
+
const pageLabel = page.label || page.id || '(unnamed page)';
|
|
92
|
+
if (!isNonEmptyString(pattern)) {
|
|
93
|
+
violations.push({
|
|
94
|
+
ruleId: 'AssemblyHintsShape',
|
|
95
|
+
severity: 'warning',
|
|
96
|
+
message: `"${pageLabel}" — upsell_template_pattern is set but empty; remove the field or pick one of: ${[...KNOWN_UPSELL_PATTERNS].join(', ')}.`,
|
|
97
|
+
path,
|
|
98
|
+
data: { pageId: page.id, check: 'pattern-empty' },
|
|
99
|
+
});
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (page.type !== 'upsell') {
|
|
103
|
+
violations.push({
|
|
104
|
+
ruleId: 'AssemblyHintsShape',
|
|
105
|
+
severity: 'warning',
|
|
106
|
+
message: `"${pageLabel}" — upsell_template_pattern is set on a non-upsell page (type=${page.type}). The hint is meaningful only on upsell pages; remove it or move it to an upsell page.`,
|
|
107
|
+
path,
|
|
108
|
+
data: { pageId: page.id, pageType: page.type, check: 'pattern-on-non-upsell' },
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (!KNOWN_UPSELL_PATTERNS.has(pattern)) {
|
|
112
|
+
violations.push({
|
|
113
|
+
ruleId: 'AssemblyHintsShape',
|
|
114
|
+
severity: 'warning',
|
|
115
|
+
message: `"${pageLabel}" — upsell_template_pattern "${pattern}" is not in the known set (${[...KNOWN_UPSELL_PATTERNS].join(', ')}). Build will use it as a hint, but confirm the value matches a template-family variant.`,
|
|
116
|
+
path,
|
|
117
|
+
data: { pageId: page.id, check: 'pattern-unknown', value: pattern },
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
// 3. Per-page MV upsell tier range.
|
|
122
|
+
pages.forEach((page, pageIdx) => {
|
|
123
|
+
const tiers = page.upsell_mv_tiers;
|
|
124
|
+
if (tiers === undefined)
|
|
125
|
+
return;
|
|
126
|
+
const path = `/funnels/${funnelIdx}/pages/${pageIdx}/upsell_mv_tiers`;
|
|
127
|
+
const pageLabel = page.label || page.id || '(unnamed page)';
|
|
128
|
+
if (tiers === null || typeof tiers !== 'object' || Array.isArray(tiers)) {
|
|
129
|
+
violations.push({
|
|
130
|
+
ruleId: 'AssemblyHintsShape',
|
|
131
|
+
severity: 'warning',
|
|
132
|
+
message: `"${pageLabel}" — upsell_mv_tiers must be an object with numeric "min" and "max" fields; got ${Array.isArray(tiers) ? 'array' : typeof tiers}.`,
|
|
133
|
+
path,
|
|
134
|
+
data: { pageId: page.id, check: 'tiers-bad-shape' },
|
|
135
|
+
});
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const tiersObj = tiers;
|
|
139
|
+
const hasMin = 'min' in tiersObj;
|
|
140
|
+
const hasMax = 'max' in tiersObj;
|
|
141
|
+
// Extract to local bindings so TS can narrow across the type-guard
|
|
142
|
+
// calls below. Property accessors on a shared object don't carry
|
|
143
|
+
// narrowing through subsequent reads, which is what forced the
|
|
144
|
+
// earlier `as number` cast at the range check; local bindings fix
|
|
145
|
+
// that and the cast goes away.
|
|
146
|
+
const min = tiersObj.min;
|
|
147
|
+
const max = tiersObj.max;
|
|
148
|
+
if (page.type !== 'upsell') {
|
|
149
|
+
violations.push({
|
|
150
|
+
ruleId: 'AssemblyHintsShape',
|
|
151
|
+
severity: 'warning',
|
|
152
|
+
message: `"${pageLabel}" — upsell_mv_tiers is set on a non-upsell page (type=${page.type}). The hint is meaningful only on upsell pages; remove it or move it to an upsell page.`,
|
|
153
|
+
path,
|
|
154
|
+
data: { pageId: page.id, pageType: page.type, check: 'tiers-on-non-upsell' },
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
if (!hasMin || !hasMax) {
|
|
158
|
+
violations.push({
|
|
159
|
+
ruleId: 'AssemblyHintsShape',
|
|
160
|
+
severity: 'warning',
|
|
161
|
+
message: `"${pageLabel}" — upsell_mv_tiers is missing ${!hasMin && !hasMax ? 'both "min" and "max"' : !hasMin ? '"min"' : '"max"'}; declare both as positive integers (e.g. {"min": 1, "max": 5}) or remove the field.`,
|
|
162
|
+
path,
|
|
163
|
+
data: { pageId: page.id, check: 'tiers-missing-field', hasMin, hasMax },
|
|
164
|
+
});
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (!isPositiveInteger(min) || !isPositiveInteger(max)) {
|
|
168
|
+
violations.push({
|
|
169
|
+
ruleId: 'AssemblyHintsShape',
|
|
170
|
+
severity: 'warning',
|
|
171
|
+
message: `"${pageLabel}" — upsell_mv_tiers requires positive integers for "min" and "max"; got min=${JSON.stringify(min)}, max=${JSON.stringify(max)}.`,
|
|
172
|
+
path,
|
|
173
|
+
data: { pageId: page.id, check: 'tiers-bad-type', min, max },
|
|
174
|
+
});
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
// min and max are narrowed to number here via the type guards above.
|
|
178
|
+
if (min > max) {
|
|
179
|
+
violations.push({
|
|
180
|
+
ruleId: 'AssemblyHintsShape',
|
|
181
|
+
severity: 'warning',
|
|
182
|
+
message: `"${pageLabel}" — upsell_mv_tiers has min=${min} greater than max=${max}; swap the values or fix the range.`,
|
|
183
|
+
path,
|
|
184
|
+
data: { pageId: page.id, check: 'tiers-bad-range', min, max },
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
return violations;
|
|
190
|
+
},
|
|
191
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CampaignMetadata — bundles two campaign-level metadata warnings:
|
|
3
|
+
* 1. Missing payment_env_key — required for spec export
|
|
4
|
+
* 2. Missing ref_id — needed for multi-campaign API disambiguation
|
|
5
|
+
*
|
|
6
|
+
* Both are warning severity. Bundled into one rule because they share a
|
|
7
|
+
* domain (campaign metadata completeness) and no caller has expressed
|
|
8
|
+
* a need to subset them. If that need shows up, split into
|
|
9
|
+
* CampaignPaymentKey and CampaignRefId.
|
|
10
|
+
*
|
|
11
|
+
* Message text inherited verbatim from the pre-#110 validator at migration time.
|
|
12
|
+
*/
|
|
13
|
+
import type { Rule } from '../types.ts';
|
|
14
|
+
export declare const CampaignMetadata: Rule;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CampaignMetadata — bundles two campaign-level metadata warnings:
|
|
3
|
+
* 1. Missing payment_env_key — required for spec export
|
|
4
|
+
* 2. Missing ref_id — needed for multi-campaign API disambiguation
|
|
5
|
+
*
|
|
6
|
+
* Both are warning severity. Bundled into one rule because they share a
|
|
7
|
+
* domain (campaign metadata completeness) and no caller has expressed
|
|
8
|
+
* a need to subset them. If that need shows up, split into
|
|
9
|
+
* CampaignPaymentKey and CampaignRefId.
|
|
10
|
+
*
|
|
11
|
+
* Message text inherited verbatim from the pre-#110 validator at migration time.
|
|
12
|
+
*/
|
|
13
|
+
export const CampaignMetadata = {
|
|
14
|
+
id: 'CampaignMetadata',
|
|
15
|
+
severity: 'warning',
|
|
16
|
+
tags: ['fast', 'spec-only'],
|
|
17
|
+
check(spec) {
|
|
18
|
+
const violations = [];
|
|
19
|
+
const campaign = spec.campaign ?? {};
|
|
20
|
+
if (!campaign.payment_env_key) {
|
|
21
|
+
violations.push({
|
|
22
|
+
ruleId: 'CampaignMetadata',
|
|
23
|
+
severity: 'warning',
|
|
24
|
+
message: 'No campaign loaded — campaign key required for spec export.',
|
|
25
|
+
path: '/campaign/payment_env_key',
|
|
26
|
+
data: { missing: 'payment_env_key' },
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
if (campaign.ref_id == null) {
|
|
30
|
+
violations.push({
|
|
31
|
+
ruleId: 'CampaignMetadata',
|
|
32
|
+
severity: 'warning',
|
|
33
|
+
message: 'Campaign has no numeric ref_id; API refresh cannot disambiguate multi-campaign responses.',
|
|
34
|
+
path: '/campaign/ref_id',
|
|
35
|
+
data: { missing: 'ref_id' },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return violations;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CheckoutHasSuccessUrl — when the spec includes any upsell page, every
|
|
3
|
+
* checkout must declare SOME forward route so the runtime knows where to send
|
|
4
|
+
* the shopper after order placement.
|
|
5
|
+
*
|
|
6
|
+
* The check is on the shopper being able to continue, NOT on which field
|
|
7
|
+
* carried that intent. `success_url` and `next_page` express the same edge, and
|
|
8
|
+
* twelve pages across ten certified fixtures use `next_page` on their checkout
|
|
9
|
+
* — warning at those told authors to rename a field they had already filled in
|
|
10
|
+
* correctly. A
|
|
11
|
+
* campaign is a free-form journey; the tool has no business having an opinion
|
|
12
|
+
* about the field name when the route is unambiguous.
|
|
13
|
+
*
|
|
14
|
+
* The "any upsell present" precondition is the legacy heuristic for
|
|
15
|
+
* "this spec needs a multi-step post-checkout flow." Specs without upsells
|
|
16
|
+
* route to thankyou via the default thankyou path and don't need a
|
|
17
|
+
* checkout-level forward route declared.
|
|
18
|
+
*
|
|
19
|
+
* "Forward route" is whatever campaign-spec/routing.ts resolves — the same
|
|
20
|
+
* resolver that source intake and the QA topology extractor consume. Narrowing
|
|
21
|
+
* this rule is only safe because that edge now actually wires; narrowed alone
|
|
22
|
+
* it would have removed the sole signal on a still-dropped edge.
|
|
23
|
+
*
|
|
24
|
+
* The rule ID keeps its original name for consumer stability even though the
|
|
25
|
+
* condition is now field-agnostic. The violation path points at the page, not
|
|
26
|
+
* at /success_url, because no single field is the answer any more.
|
|
27
|
+
*
|
|
28
|
+
* Warning severity (not error) — preserves legacy classification.
|
|
29
|
+
*/
|
|
30
|
+
import type { Rule } from '../types.ts';
|
|
31
|
+
export declare const CheckoutHasSuccessUrl: Rule;
|