@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,237 @@
|
|
|
1
|
+
// Private template family resolution: campaigns-os recognizes/certifies a
|
|
2
|
+
// private template family (its design, selectors, and business logic owned
|
|
3
|
+
// by a third-party repo, not this one) without that family's description
|
|
4
|
+
// ever being committed here. The public repo holds only a thin allowlist —
|
|
5
|
+
// contracts/private-template-sources.json — naming which families are
|
|
6
|
+
// private and which repo/path to fetch their contract fragment from. The
|
|
7
|
+
// fragment is resolved in-memory per run and never written back to disk.
|
|
8
|
+
//
|
|
9
|
+
// v1 resolves only from a local sibling checkout (matches this environment's
|
|
10
|
+
// worktree convention: sibling repos share a parent directory). A hosted CI
|
|
11
|
+
// runner without that checkout simply cannot certify a private family today —
|
|
12
|
+
// that's an intentional v1 boundary, not an oversight; see the transfer
|
|
13
|
+
// packet this module implements.
|
|
14
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
15
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { loadTemplateBrandContract, resolveContractExtendsChain } from "./template-brand-contract.mjs";
|
|
18
|
+
|
|
19
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
20
|
+
|
|
21
|
+
export const PRIVATE_TEMPLATE_SOURCE_SCHEMA = "private-template-source/v0";
|
|
22
|
+
export const PRIVATE_TEMPLATE_SOURCE_FRAGMENT_SCHEMA = "private-template-source-fragment/v0";
|
|
23
|
+
|
|
24
|
+
// One JSON read + parse path for every file this module reads, so a malformed
|
|
25
|
+
// file surfaces as a structured parse_error (with the syntax error preserved
|
|
26
|
+
// as `cause`) instead of a bare SyntaxError — the same convention the on-disk
|
|
27
|
+
// brand-contract loader uses. Callers that want to swallow it (e.g.
|
|
28
|
+
// certifiedTemplateFamilies) still catch a throw; callers that surface
|
|
29
|
+
// diagnostics get a code to key on.
|
|
30
|
+
function readJsonFile(path, what) {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
33
|
+
} catch (error) {
|
|
34
|
+
throw privateTemplateSourceError(
|
|
35
|
+
"parse_error",
|
|
36
|
+
`${what} ${path} failed to parse: ${error instanceof Error ? error.message : String(error)}.`,
|
|
37
|
+
error,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function defaultCommerceCatalogPath() {
|
|
43
|
+
return join(ROOT, "contracts", "commerce-surface-catalog.json");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const COMMERCE_CATALOG_FILE_NAME = "commerce-surface-catalog.json";
|
|
47
|
+
|
|
48
|
+
// The commerce catalog a packet reads. `assembly.commerce_catalog.path` is
|
|
49
|
+
// null when the packet was prepared against the toolkit's own catalog: that
|
|
50
|
+
// file ships with every install of the toolkit, so the packet does not record
|
|
51
|
+
// where one particular checkout kept it. A recorded path is an operator's
|
|
52
|
+
// explicit --commerce-catalog, resolved against the packet. A recorded path
|
|
53
|
+
// that no longer exists but names the catalog file is the pre-null shape:
|
|
54
|
+
// packets prepared before the catalog stopped being recorded wrote the
|
|
55
|
+
// toolkit's own file relative to the packet (a chain of ../ into the checkout
|
|
56
|
+
// that ran prepare-build), which is dead on any other machine or with the
|
|
57
|
+
// toolkit installed as a package. Readers resolve that to the running
|
|
58
|
+
// toolkit's catalog, and `source` says which of the four cases applied
|
|
59
|
+
// (toolkit_default, packet, stale_packet_path, missing_packet_path) so doctor
|
|
60
|
+
// can tell the operator without blocking.
|
|
61
|
+
export function resolvePacketCommerceCatalogPath(packetPath, catalogInfo = {}) {
|
|
62
|
+
const recorded = typeof catalogInfo?.path === "string" && catalogInfo.path.length > 0 ? catalogInfo.path : null;
|
|
63
|
+
if (!recorded) {
|
|
64
|
+
return { path: defaultCommerceCatalogPath(), source: "toolkit_default", recorded: null };
|
|
65
|
+
}
|
|
66
|
+
const resolved = resolve(dirname(resolve(packetPath)), recorded);
|
|
67
|
+
if (existsSync(resolved)) {
|
|
68
|
+
return { path: resolved, source: "packet", recorded };
|
|
69
|
+
}
|
|
70
|
+
if (basename(recorded) === COMMERCE_CATALOG_FILE_NAME) {
|
|
71
|
+
return { path: defaultCommerceCatalogPath(), source: "stale_packet_path", recorded };
|
|
72
|
+
}
|
|
73
|
+
// The operator named a file that is not there and is not the toolkit
|
|
74
|
+
// catalog: keep the resolved path so the caller's missing-file check names
|
|
75
|
+
// it, and say plainly that nothing resolved.
|
|
76
|
+
return { path: resolved, source: "missing_packet_path", recorded };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Overridable so tests can sandbox the allowlist against a fixture private
|
|
80
|
+
// source instead of mutating (or depending on) the real production file.
|
|
81
|
+
function privateTemplateSourcesPath() {
|
|
82
|
+
return process.env.PRIVATE_TEMPLATE_SOURCES_PATH || join(ROOT, "contracts", "private-template-sources.json");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// No caching, recomputed per call — matches certifiedTemplateFamilies()'s
|
|
86
|
+
// existing convention (cli.mjs) so a long-lived process never serves a stale
|
|
87
|
+
// allowlist after an edit.
|
|
88
|
+
export function loadPrivateTemplateSources() {
|
|
89
|
+
const path = privateTemplateSourcesPath();
|
|
90
|
+
if (!existsSync(path)) return {};
|
|
91
|
+
const parsed = readJsonFile(path, "Private template source allowlist");
|
|
92
|
+
if (!isPlainObject(parsed) || parsed.schema_version !== PRIVATE_TEMPLATE_SOURCE_SCHEMA) {
|
|
93
|
+
throw privateTemplateSourceError(
|
|
94
|
+
"schema_mismatch",
|
|
95
|
+
`Private template source allowlist ${path} has schema_version "${parsed?.schema_version}"; expected "${PRIVATE_TEMPLATE_SOURCE_SCHEMA}".`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return isPlainObject(parsed.sources) ? parsed.sources : {};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Base directory sibling repos are resolved under. One env var covers every
|
|
102
|
+
// private provider (there will be more than one over time) — mirrors the
|
|
103
|
+
// existing STARTER_TEMPLATES_PATH precedent (scripts/check-template-doctrine.mjs)
|
|
104
|
+
// generalized from "one specific sibling" to "wherever this environment keeps
|
|
105
|
+
// its siblings".
|
|
106
|
+
function privateTemplateSourcesRoot() {
|
|
107
|
+
return process.env.PRIVATE_TEMPLATE_SOURCES_ROOT || resolve(ROOT, "..");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The sibling checkout directory for a "org/name" repo string: only the final
|
|
111
|
+
// path segment (the repo name) is used as the directory; the org is metadata.
|
|
112
|
+
// The allowlist is committed in-repo and human-reviewed before merge, so the
|
|
113
|
+
// repo/contract_path fields are trusted input, not attacker-controlled — no
|
|
114
|
+
// shape/traversal guard here by design (see the module header's v1 boundary).
|
|
115
|
+
function siblingRepoDir(repo) {
|
|
116
|
+
const name = String(repo || "").trim().split("/").pop();
|
|
117
|
+
return name ? join(privateTemplateSourcesRoot(), name) : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Allowlist miss -> null (family isn't private; caller falls through to its
|
|
121
|
+
// own "unknown family" handling). Allowlist hit but no local checkout ->
|
|
122
|
+
// throws, deliberately: a private family must fail loudly and specifically
|
|
123
|
+
// when it's actually needed, never silently read as "uncertified" — that's a
|
|
124
|
+
// confusing dead end for whoever hits it.
|
|
125
|
+
export function resolvePrivateTemplateSourceFragment(family) {
|
|
126
|
+
const entry = loadPrivateTemplateSources()[family];
|
|
127
|
+
if (!entry) return null;
|
|
128
|
+
const repoDir = siblingRepoDir(entry.repo);
|
|
129
|
+
const fragmentPath = repoDir && typeof entry.contract_path === "string" ? join(repoDir, entry.contract_path) : null;
|
|
130
|
+
if (!fragmentPath || !existsSync(fragmentPath)) {
|
|
131
|
+
throw privateTemplateSourceError(
|
|
132
|
+
"private_source_not_checked_out",
|
|
133
|
+
`Template family "${family}" is a private family sourced from ${entry.repo}, but no checkout was found at ` +
|
|
134
|
+
`${repoDir || "(unresolved)"}. Clone ${entry.repo} as a sibling directory (or set PRIVATE_TEMPLATE_SOURCES_ROOT) to resolve it.`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
const fragment = readJsonFile(fragmentPath, "Private template source fragment");
|
|
138
|
+
if (!isPlainObject(fragment) || fragment.schema_version !== PRIVATE_TEMPLATE_SOURCE_FRAGMENT_SCHEMA) {
|
|
139
|
+
throw privateTemplateSourceError(
|
|
140
|
+
"schema_mismatch",
|
|
141
|
+
`Private template source fragment ${fragmentPath} has schema_version "${fragment?.schema_version}"; expected "${PRIVATE_TEMPLATE_SOURCE_FRAGMENT_SCHEMA}".`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
if (fragment.family !== family) {
|
|
145
|
+
throw privateTemplateSourceError(
|
|
146
|
+
"family_mismatch",
|
|
147
|
+
`Private template source fragment ${fragmentPath} declares family "${fragment.family}"; expected "${family}".`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
return { catalogFamily: fragment.catalog_family || null, brandContract: fragment.brand_contract || null, fragmentPath };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Enriches the raw commerce catalog with private family entries. The returned
|
|
154
|
+
// object carries an extra `_private_source_warnings` array (not present in the
|
|
155
|
+
// raw catalog) documenting any private-source fetch errors encountered while
|
|
156
|
+
// building the merged family map — callers that spread or JSON.stringify the
|
|
157
|
+
// result will see this key; callers that only read `.families` are unaffected.
|
|
158
|
+
// Private-source fetch errors are collected as warnings, never thrown here — a
|
|
159
|
+
// public-family run must not fail just because some other private repo isn't
|
|
160
|
+
// checked out locally. Only resolveTemplateBrandContract (below), for that
|
|
161
|
+
// specific family, throws.
|
|
162
|
+
export function resolveCommerceCatalog(catalogPath = defaultCommerceCatalogPath()) {
|
|
163
|
+
const catalog = existsSync(catalogPath) ? readJsonFile(catalogPath, "Commerce surface catalog") : { families: {} };
|
|
164
|
+
// Valid JSON that isn't an object (null / array / scalar) would make
|
|
165
|
+
// `catalog.families` throw a raw TypeError; surface it as the same
|
|
166
|
+
// structured schema_mismatch the allowlist/fragment reads already use.
|
|
167
|
+
if (!isPlainObject(catalog)) {
|
|
168
|
+
throw privateTemplateSourceError(
|
|
169
|
+
"schema_mismatch",
|
|
170
|
+
`Commerce catalog ${catalogPath} is not a JSON object.`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
const families = { ...(catalog.families || {}) };
|
|
174
|
+
const warnings = [];
|
|
175
|
+
for (const family of Object.keys(loadPrivateTemplateSources())) {
|
|
176
|
+
if (Object.prototype.hasOwnProperty.call(families, family)) continue;
|
|
177
|
+
try {
|
|
178
|
+
const fragment = resolvePrivateTemplateSourceFragment(family);
|
|
179
|
+
if (fragment?.catalogFamily) families[family] = fragment.catalogFamily;
|
|
180
|
+
} catch (error) {
|
|
181
|
+
warnings.push({ family, code: error.code || "load_error", message: error.message });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { ...catalog, families, _private_source_warnings: warnings };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Drop-in for loadTemplateBrandContract(family): tries the public loader
|
|
188
|
+
// first (zero behavior change for public families), then falls back to a
|
|
189
|
+
// privately-sourced fragment, run through the SAME extends/merge chain
|
|
190
|
+
// template-brand-contract.mjs already uses — `dir` is this repo's own
|
|
191
|
+
// contracts/ directory, since a private family's `extends` (e.g.
|
|
192
|
+
// "template-brand-contract.shared-commerce.v0.json") points at a genuinely
|
|
193
|
+
// shared, public file that lives here, not in the private repo.
|
|
194
|
+
//
|
|
195
|
+
// If a public contract file exists but fails to parse/validate, the error is
|
|
196
|
+
// caught and a private fragment is tried as a fallback (a corrected private
|
|
197
|
+
// fragment resolves even when a stale public stub is still on disk). Error
|
|
198
|
+
// precedence when the public load failed: if the family is NOT privately
|
|
199
|
+
// allowlisted, the original public error is re-thrown so the operator sees the
|
|
200
|
+
// root cause; if it IS allowlisted but fragment resolution itself throws (e.g.
|
|
201
|
+
// the sibling checkout is missing), that more-specific error surfaces instead.
|
|
202
|
+
export function resolveTemplateBrandContract(family) {
|
|
203
|
+
let publicContract = null;
|
|
204
|
+
let publicError = null;
|
|
205
|
+
try {
|
|
206
|
+
publicContract = loadTemplateBrandContract(family);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
publicError = err;
|
|
209
|
+
}
|
|
210
|
+
if (publicContract) return publicContract;
|
|
211
|
+
const fragment = resolvePrivateTemplateSourceFragment(family);
|
|
212
|
+
if (!fragment?.brandContract) {
|
|
213
|
+
if (publicError) throw publicError;
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
const contract = resolveContractExtendsChain(fragment.brandContract, {
|
|
217
|
+
dir: join(ROOT, "contracts"),
|
|
218
|
+
label: fragment.fragmentPath,
|
|
219
|
+
});
|
|
220
|
+
if (contract.family !== family) {
|
|
221
|
+
throw privateTemplateSourceError(
|
|
222
|
+
"family_mismatch",
|
|
223
|
+
`Private template brand contract ${fragment.fragmentPath} declares family "${contract.family}"; expected "${family}".`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
return contract;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function privateTemplateSourceError(code, message, cause = undefined) {
|
|
230
|
+
const error = new Error(message, cause ? { cause } : undefined);
|
|
231
|
+
error.code = code;
|
|
232
|
+
return error;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function isPlainObject(value) {
|
|
236
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
237
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Proof policy: the order-path depth a Build Packet declares, the one flag
|
|
2
|
+
// that sets it, and the one text that names the drift between the packet and
|
|
3
|
+
// the Assembly Report's mirror of it.
|
|
4
|
+
//
|
|
5
|
+
// `qa.proof_policy.order_path_depth` is seeded by prepare-build/start and
|
|
6
|
+
// mirrored into `report.proof_policy` at the same moment. The two are compared
|
|
7
|
+
// by `assessPurchaseProofCoverage` (cli.mjs): a disagreement is `unknown`,
|
|
8
|
+
// never one side's value. Doctor, `next` and the coverage reason all describe
|
|
9
|
+
// that state through the single action below, so the command an operator is
|
|
10
|
+
// told to run is spelled once. A leaf: gate-actions only.
|
|
11
|
+
|
|
12
|
+
import { requiredActionText } from "./gate-actions.mjs";
|
|
13
|
+
|
|
14
|
+
// The depths the setter accepts. `off` declares an intentional no-order run
|
|
15
|
+
// (`--test-order off` is then a diagnostic that owes no purchase proof);
|
|
16
|
+
// `common` and `full` match the `--test-order` modes of the same name.
|
|
17
|
+
export const ORDER_PATH_DEPTHS = Object.freeze(["off", "common", "full"]);
|
|
18
|
+
|
|
19
|
+
export const ORDER_PATH_DEPTH_FLAG = "order-path-depth";
|
|
20
|
+
|
|
21
|
+
export function isOrderPathDepth(value) {
|
|
22
|
+
return typeof value === "string" && ORDER_PATH_DEPTHS.includes(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The one spelling of "the packet and the report disagree": both present and
|
|
26
|
+
// different once case is ignored. `orderPathDepthDrift` (doctor and the
|
|
27
|
+
// coverage assessment) and the `next` action branch both ask this.
|
|
28
|
+
export function orderPathDepthsDisagree(packetDepth, reportDepth) {
|
|
29
|
+
return typeof packetDepth === "string" && typeof reportDepth === "string"
|
|
30
|
+
&& packetDepth.trim() !== "" && reportDepth.trim() !== ""
|
|
31
|
+
&& packetDepth.toLowerCase() !== reportDepth.toLowerCase();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// `--order-path-depth <off|common|full>`, validated with the other argv checks
|
|
35
|
+
// of whichever command carries it (`command` names it in the error). A bare
|
|
36
|
+
// flag parses as `true`; that is an operator's explicit intent with no value,
|
|
37
|
+
// so it is refused rather than silently defaulted. Case is ignored on input
|
|
38
|
+
// and the lower-case canonical form is what gets stored (`Off` writes `off`),
|
|
39
|
+
// matching the case-insensitive drift comparison. Returns null when the flag
|
|
40
|
+
// is absent.
|
|
41
|
+
export function parseOrderPathDepthFlag(args, { command = "qa policy set" } = {}) {
|
|
42
|
+
const raw = args?.[ORDER_PATH_DEPTH_FLAG];
|
|
43
|
+
if (raw === undefined) return null;
|
|
44
|
+
const accepted = `Accepted values: ${ORDER_PATH_DEPTHS.join(", ")}.`;
|
|
45
|
+
if (raw === true || raw === null || String(raw).trim() === "") {
|
|
46
|
+
throw new Error(`${command}: --${ORDER_PATH_DEPTH_FLAG} needs a value. ${accepted}`);
|
|
47
|
+
}
|
|
48
|
+
const typed = String(raw).trim();
|
|
49
|
+
const value = typed.toLowerCase();
|
|
50
|
+
if (!isOrderPathDepth(value)) {
|
|
51
|
+
throw new Error(`${command}: unsupported --${ORDER_PATH_DEPTH_FLAG} ${JSON.stringify(typed)}. ${accepted}`);
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const ORDER_PATH_DEPTH_DRIFT_CODE = "qa.proof_policy.order_path_depth_drift";
|
|
57
|
+
|
|
58
|
+
// The one action that reconciles a packet/report depth disagreement: rewrite
|
|
59
|
+
// the depth through the setter, which writes the packet field and refreshes
|
|
60
|
+
// the report mirror in the same run. The packet's value is offered when the
|
|
61
|
+
// setter accepts it (the packet is author intent); a hand-edited value outside
|
|
62
|
+
// the accepted set leaves the choice to the operator.
|
|
63
|
+
export function orderPathDepthReconcileAction({ packetDepth = null, reportDepth = null } = {}) {
|
|
64
|
+
const depth = isOrderPathDepth(packetDepth) ? packetDepth : `<${ORDER_PATH_DEPTHS.join("|")}>`;
|
|
65
|
+
return Object.freeze({
|
|
66
|
+
id: ORDER_PATH_DEPTH_DRIFT_CODE,
|
|
67
|
+
kind: "command",
|
|
68
|
+
command: `campaigns-os qa policy set --packet <packet> --${ORDER_PATH_DEPTH_FLAG} ${depth}`,
|
|
69
|
+
description: `The build packet declares an order-path depth of ${JSON.stringify(packetDepth ?? "unspecified")} while the assembly report's mirror of it reads ${JSON.stringify(reportDepth ?? "unspecified")}; neither is trusted until they agree.`,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The text doctor's warning, the coverage reason and the `next` action all
|
|
74
|
+
// carry for that state: the description, then the runnable command with the
|
|
75
|
+
// packet substituted (or the bare template when no packet path is known). A
|
|
76
|
+
// caller that already rendered the command (to publish it as the action's
|
|
77
|
+
// `command`) passes it in, so the prose and the action carry one string.
|
|
78
|
+
export function orderPathDepthDriftText({ packetDepth = null, reportDepth = null, packetPath = null, command = null } = {}) {
|
|
79
|
+
const action = orderPathDepthReconcileAction({ packetDepth, reportDepth });
|
|
80
|
+
const rendered = typeof command === "string" && command ? command : requiredActionText(action, { packetPath });
|
|
81
|
+
return `${action.description} Reconcile them with \`${rendered}\`, which writes the packet field and refreshes the report mirror together.`;
|
|
82
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// Analytics CORRECTNESS assessment (single funnel) — the foundation layer the
|
|
2
|
+
// migration parity differ sits on top of. Where parity asks "does candidate
|
|
3
|
+
// match baseline?", correctness has two deliberately separate authorities:
|
|
4
|
+
// campaign-root inventory proves declared providers/tags are present, while
|
|
5
|
+
// receipt evidence from the canonical typed-card order proves Purchase. A
|
|
6
|
+
// campaign-root visit cannot prove (or disprove) a receipt-only event.
|
|
7
|
+
//
|
|
8
|
+
// Driven by the CampaignSpec `analytics` block (campaign-spec AnalyticsContract).
|
|
9
|
+
// When no block is declared the assessment can't know the expected container/
|
|
10
|
+
// pixel ids, so it emits an INFO inventory only — nothing is gated. The contract
|
|
11
|
+
// is what turns observations into pass/fail. (Until the spec authoring tool
|
|
12
|
+
// emits the block, real specs won't carry one — so the no-contract path is the
|
|
13
|
+
// common case today and must stay non-blocking.)
|
|
14
|
+
//
|
|
15
|
+
// Receipt proof is source-aware by construction: it keys on OUTBOUND pixel
|
|
16
|
+
// fires (the network truth), via effectivePurchase, so a campaign that blocks
|
|
17
|
+
// the SDK dl_* event and fires the pixel manually still passes.
|
|
18
|
+
|
|
19
|
+
import { SEVERITY, STATUS } from "./qa-verdict.mjs";
|
|
20
|
+
import { effectivePurchase } from "./qa-analytics-parity.mjs";
|
|
21
|
+
import { redactUrlQuery } from "./qa-url-privacy.mjs";
|
|
22
|
+
|
|
23
|
+
// Inventory kinds classifyTagFire can recognize directly. Other declared
|
|
24
|
+
// out-of-band vendors (TriplePixel→triplewhale, etc.) can't be auto-detected
|
|
25
|
+
// without host wiring, so they degrade to manual review rather than false-fail.
|
|
26
|
+
const KNOWN_VENDOR_KINDS = new Set(["gtm", "ga4", "google_ads", "meta", "tiktok", "everflow"]);
|
|
27
|
+
|
|
28
|
+
// Packet 01 / INV-3(c): when the assessment knows the URL it audited, EVERY
|
|
29
|
+
// emitted assertion — pass and fail alike — carries it, top-level and in
|
|
30
|
+
// evidence, so a reader of a blocked verdict can always tell what was
|
|
31
|
+
// measured (only the sibling :capture assertion used to carry it).
|
|
32
|
+
function correctnessAssertion({ id, status, severity, expected, actual, evidence, waiver, url }) {
|
|
33
|
+
return {
|
|
34
|
+
id,
|
|
35
|
+
family: "analytics-correctness",
|
|
36
|
+
page: "analytics",
|
|
37
|
+
status,
|
|
38
|
+
...(url ? { url } : {}),
|
|
39
|
+
...(severity ? { severity } : {}),
|
|
40
|
+
...(waiver ? { waiver } : {}),
|
|
41
|
+
expected,
|
|
42
|
+
actual,
|
|
43
|
+
...(evidence || url ? { evidence: { ...(url ? { url } : {}), ...(evidence || {}) } } : {}),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The ONLY assertion the QA waiver lane covers today (packet 01, ratified
|
|
48
|
+
// I-9/I-16): a recorded `qa waive` decision for purchase-fires. The caller
|
|
49
|
+
// decides eligibility: only a genuine, recognized receipt with no effective
|
|
50
|
+
// Purchase may consume the waiver. Missing/unrecognized paths and capture
|
|
51
|
+
// errors cannot.
|
|
52
|
+
function purchaseFiresWaiver(options, eligible) {
|
|
53
|
+
if (!eligible) return null;
|
|
54
|
+
const waiver = options?.waivers?.["analytics-correctness:purchase-fires"];
|
|
55
|
+
if (!waiver || typeof waiver !== "object" || Array.isArray(waiver)) return null;
|
|
56
|
+
if (typeof waiver.reason !== "string" || !waiver.reason.trim()) return null;
|
|
57
|
+
return {
|
|
58
|
+
reason: waiver.reason.trim(),
|
|
59
|
+
waived_by: (typeof waiver.waived_by === "string" && waiver.waived_by.trim()) || "operator",
|
|
60
|
+
waived_at: (typeof waiver.waived_at === "string" && waiver.waived_at.trim()) || null,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function inventoryHas(inventory, kind, id) {
|
|
65
|
+
const ids = inventory[kind] || [];
|
|
66
|
+
return id ? ids.includes(String(id)) : ids.length > 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Assess the campaign-root capture against its declared analytics inventory.
|
|
70
|
+
// `contract` is the spec's `analytics` block (may be undefined/empty).
|
|
71
|
+
// `options.url` is the URL the capture actually visited (the resolved capture
|
|
72
|
+
// target) — stamped on every emitted assertion, pass and fail alike.
|
|
73
|
+
export function assessAnalyticsInventory(capture = {}, contract = {}, options = {}) {
|
|
74
|
+
const assertions = [];
|
|
75
|
+
const auditedUrl = (typeof options.url === "string" && options.url.trim()) ? options.url.trim() : null;
|
|
76
|
+
const emit = (fields) => correctnessAssertion({ url: auditedUrl, ...fields });
|
|
77
|
+
const inventory = capture.inventory || {};
|
|
78
|
+
const providers = (contract && contract.providers) || {};
|
|
79
|
+
const hasContract = !!(contract && (contract.providers || contract.out_of_band_pixels || contract.params || contract.manual_events));
|
|
80
|
+
|
|
81
|
+
// No declared contract → can't know expected ids; emit a non-gating inventory
|
|
82
|
+
// so the run still records what fired, and flag that nothing was validated.
|
|
83
|
+
if (!hasContract) {
|
|
84
|
+
assertions.push(emit({
|
|
85
|
+
id: "analytics-correctness:no-contract",
|
|
86
|
+
status: STATUS.MANUAL_REVIEW,
|
|
87
|
+
severity: SEVERITY.INFO,
|
|
88
|
+
expected: "a declared CampaignSpec analytics block to validate against",
|
|
89
|
+
actual: "no analytics contract declared — recorded the observed fires, gated nothing",
|
|
90
|
+
// Counts only — never publish raw container/pixel ids or any Purchase
|
|
91
|
+
// fields to the QA portal. Root inventory is not Purchase authority.
|
|
92
|
+
evidence: {
|
|
93
|
+
inventory: Object.fromEntries(Object.entries(inventory).map(([k, v]) => [k, v.length])),
|
|
94
|
+
},
|
|
95
|
+
}));
|
|
96
|
+
return assertions;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 1. GTM container fires (blocker when declared + enabled).
|
|
100
|
+
if (providers.gtm && providers.gtm.enabled !== false) {
|
|
101
|
+
const id = providers.gtm.containerId;
|
|
102
|
+
const present = inventoryHas(inventory, "gtm", id);
|
|
103
|
+
assertions.push(emit({
|
|
104
|
+
id: "analytics-correctness:tag:gtm",
|
|
105
|
+
status: present ? STATUS.PASS : STATUS.FAIL,
|
|
106
|
+
severity: SEVERITY.BLOCKER,
|
|
107
|
+
expected: `GTM ${id || "container"} fires on the page`,
|
|
108
|
+
actual: present ? "present" : `absent (${(inventory.gtm || []).length} gtm tag(s) fired, none matching)`,
|
|
109
|
+
evidence: { declared: id || null, observed_count: (inventory.gtm || []).length },
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 2. Meta pixel fires (blocker when declared + enabled).
|
|
114
|
+
if (providers.facebook && providers.facebook.enabled !== false) {
|
|
115
|
+
const id = providers.facebook.pixelId;
|
|
116
|
+
const present = inventoryHas(inventory, "meta", id);
|
|
117
|
+
assertions.push(emit({
|
|
118
|
+
id: "analytics-correctness:tag:meta",
|
|
119
|
+
status: present ? STATUS.PASS : STATUS.FAIL,
|
|
120
|
+
severity: SEVERITY.BLOCKER,
|
|
121
|
+
expected: `Meta pixel ${id || ""} fires on the page`.trim(),
|
|
122
|
+
actual: present ? "present" : `absent (${(inventory.meta || []).length} meta pixel(s) fired, none matching)`,
|
|
123
|
+
evidence: { declared: id || null, observed_count: (inventory.meta || []).length },
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 3. Out-of-band pixels declared as carried (Everflow / TriplePixel / …).
|
|
128
|
+
for (const [i, pixel] of (contract.out_of_band_pixels || []).entries()) {
|
|
129
|
+
if (!pixel || !pixel.vendor) continue;
|
|
130
|
+
const vendor = String(pixel.vendor).toLowerCase();
|
|
131
|
+
if (KNOWN_VENDOR_KINDS.has(vendor)) {
|
|
132
|
+
const present = inventoryHas(inventory, vendor, pixel.id);
|
|
133
|
+
assertions.push(emit({
|
|
134
|
+
id: `analytics-correctness:oob:${vendor}`,
|
|
135
|
+
status: present ? STATUS.PASS : STATUS.FAIL,
|
|
136
|
+
severity: SEVERITY.BLOCKER,
|
|
137
|
+
expected: `declared out-of-band ${vendor} pixel fires`,
|
|
138
|
+
actual: present ? "present" : "absent",
|
|
139
|
+
evidence: { vendor, declared_id: pixel.id || null, observed_count: (inventory[vendor] || []).length },
|
|
140
|
+
}));
|
|
141
|
+
} else {
|
|
142
|
+
// Vendor host not in the classifier (e.g. TriplePixel→triplewhale.com).
|
|
143
|
+
// Pass its name as --analytics-hosts to capture it; until then, review.
|
|
144
|
+
assertions.push(emit({
|
|
145
|
+
id: `analytics-correctness:oob:${vendor}`,
|
|
146
|
+
status: STATUS.MANUAL_REVIEW,
|
|
147
|
+
severity: SEVERITY.WARN,
|
|
148
|
+
expected: `declared out-of-band ${vendor} pixel fires`,
|
|
149
|
+
actual: `cannot auto-detect "${vendor}" — pass its host via --analytics-hosts to verify`,
|
|
150
|
+
evidence: { vendor, index: i },
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return assertions;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Finalize the one stable Purchase assertion from the private capture envelope
|
|
159
|
+
// returned by the canonical typed-card browser-order run. This function is
|
|
160
|
+
// intentionally pure and emits only a fixed, sanitized evidence projection;
|
|
161
|
+
// raw captures, event payloads, order identifiers, values, currencies, and URL
|
|
162
|
+
// query strings never cross into the verdict.
|
|
163
|
+
//
|
|
164
|
+
// The receipt is the qualification point, not the measurement point (#392).
|
|
165
|
+
// The SDK fires dl_purchase — and the outbound Purchase it drives — on the
|
|
166
|
+
// first `?ref_id=` page that fetched the order, which is the upsell page on a
|
|
167
|
+
// funnel that has one, and then dedupes the transaction so the receipt stays
|
|
168
|
+
// silent. So the reading is the whole post-checkout journey: an attempt's
|
|
169
|
+
// `journeyCapture` is the authority, and the receipt-document `capture` is
|
|
170
|
+
// kept as the diagnostic that says which document fired. An envelope that
|
|
171
|
+
// carries only a receipt capture (no journey reading) is judged on it, so a
|
|
172
|
+
// receipt-only funnel and older callers read exactly as before.
|
|
173
|
+
export function assessReceiptPurchase(receiptAnalytics = {}, options = {}) {
|
|
174
|
+
const plannedPlanIds = Array.isArray(receiptAnalytics?.plannedPlanIds)
|
|
175
|
+
? receiptAnalytics.plannedPlanIds.map(normalizePlanId).filter(Boolean)
|
|
176
|
+
: [];
|
|
177
|
+
const attempts = Array.isArray(receiptAnalytics?.attempts) ? receiptAnalytics.attempts : [];
|
|
178
|
+
const attemptedPlanIds = attempts.map((attempt) => normalizePlanId(attempt?.planId)).filter(Boolean);
|
|
179
|
+
const receipts = [];
|
|
180
|
+
const unqualifiedPlanIds = [];
|
|
181
|
+
const captureErrorPlanIds = [];
|
|
182
|
+
const noSignalPlanIds = [];
|
|
183
|
+
|
|
184
|
+
for (const planId of plannedPlanIds) {
|
|
185
|
+
const attempt = attempts.find((candidate) => normalizePlanId(candidate?.planId) === planId);
|
|
186
|
+
if (!attempt) {
|
|
187
|
+
unqualifiedPlanIds.push(planId);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (attempt.receiptRecognized !== true) {
|
|
191
|
+
unqualifiedPlanIds.push(planId);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const receiptCaptureAvailable = isCapture(attempt.capture);
|
|
196
|
+
const journeyCaptureAvailable = isCapture(attempt.journeyCapture);
|
|
197
|
+
// Journey when the envelope carries one; the receipt document otherwise.
|
|
198
|
+
// A journey reading that failed to collect is not replaced by the receipt
|
|
199
|
+
// document: a silent receipt on an upsell funnel is exactly the case a
|
|
200
|
+
// receipt-only fallback would misread as "no Purchase". And a receipt
|
|
201
|
+
// capture/settle error stays the explicit blocker it always was — the
|
|
202
|
+
// journey reading taken beside an unsettled receipt is not a settled one.
|
|
203
|
+
const scope = journeyCaptureAvailable
|
|
204
|
+
? "journey"
|
|
205
|
+
: (attempt.journeyCaptureError ? null : (receiptCaptureAvailable ? "receipt" : null));
|
|
206
|
+
const judged = scope === "journey" ? attempt.journeyCapture : scope === "receipt" ? attempt.capture : null;
|
|
207
|
+
const captureError = !judged || !!attempt.captureError;
|
|
208
|
+
if (captureError) captureErrorPlanIds.push(planId);
|
|
209
|
+
const effective = judged ? effectivePurchase(judged) : { fired: false, via: null };
|
|
210
|
+
if (!captureError && !effective.fired) noSignalPlanIds.push(planId);
|
|
211
|
+
// #198 is what happens when an unfalsifiable analytics reading is presented
|
|
212
|
+
// as a measurement. A failed capture and a receipt that genuinely fired
|
|
213
|
+
// nothing are BOTH blockers, but they are not the same fact, and a reader
|
|
214
|
+
// of a single receipt entry must be able to tell them apart without
|
|
215
|
+
// cross-referencing capture_error_plan_ids. Unmeasured entries carry
|
|
216
|
+
// measured:false and null signals rather than an all-false reading that
|
|
217
|
+
// looks like evidence.
|
|
218
|
+
const measured = !captureError;
|
|
219
|
+
// Which document fired is the diagnostic a reader of an upsell funnel
|
|
220
|
+
// needs: on a journey-scoped judgement the receipt-document reading is
|
|
221
|
+
// kept beside the judged one, and `fired_on` names the receipt when it
|
|
222
|
+
// fired there, `earlier-page` when only the journey did. A receipt-scoped
|
|
223
|
+
// judgement has no earlier page and no second reading to keep, so
|
|
224
|
+
// `receipt_signals` is null there. `scope` is null on an unmeasured entry.
|
|
225
|
+
const receiptFired = receiptCaptureAvailable && !attempt.captureError
|
|
226
|
+
? effectivePurchase(attempt.capture).fired
|
|
227
|
+
: null;
|
|
228
|
+
receipts.push({
|
|
229
|
+
plan_id: planId,
|
|
230
|
+
receipt_url: redactUrlQuery(attempt.receiptUrl),
|
|
231
|
+
measured,
|
|
232
|
+
scope: measured ? scope : null,
|
|
233
|
+
purchase_fired: measured && !!effective.fired,
|
|
234
|
+
via: measured ? (effective.via || null) : null,
|
|
235
|
+
signals: measured ? purchaseSignalsOf(judged) : null,
|
|
236
|
+
receipt_signals: measured && scope === "journey" && receiptFired !== null ? purchaseSignalsOf(attempt.capture) : null,
|
|
237
|
+
fired_on: !measured || !effective.fired ? null : receiptFired ? "receipt" : "earlier-page",
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const hasBlockingCaptureError = captureErrorPlanIds.length > 0;
|
|
242
|
+
const hasNoSignalReceipt = noSignalPlanIds.length > 0;
|
|
243
|
+
const failed = hasBlockingCaptureError || hasNoSignalReceipt;
|
|
244
|
+
const needsReview = !plannedPlanIds.length || unqualifiedPlanIds.length > 0;
|
|
245
|
+
const waiver = purchaseFiresWaiver(options, hasNoSignalReceipt && !hasBlockingCaptureError);
|
|
246
|
+
const status = failed ? STATUS.FAIL : needsReview ? STATUS.MANUAL_REVIEW : STATUS.PASS;
|
|
247
|
+
const severity = status === STATUS.FAIL
|
|
248
|
+
? (waiver ? SEVERITY.WARN : SEVERITY.BLOCKER)
|
|
249
|
+
: status === STATUS.MANUAL_REVIEW ? SEVERITY.WARN : undefined;
|
|
250
|
+
const evidence = {
|
|
251
|
+
attempted_plan_ids: attemptedPlanIds,
|
|
252
|
+
receipts,
|
|
253
|
+
unqualified_plan_ids: unique(unqualifiedPlanIds),
|
|
254
|
+
capture_error_plan_ids: unique(captureErrorPlanIds),
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
return correctnessAssertion({
|
|
258
|
+
id: "analytics-correctness:purchase-fires",
|
|
259
|
+
status,
|
|
260
|
+
severity,
|
|
261
|
+
...(waiver ? { waiver } : {}),
|
|
262
|
+
expected: "every deterministic receipt-qualified typed-card order emits Purchase via dataLayer, Meta, or GA4 on some page of its post-checkout journey.",
|
|
263
|
+
actual: receiptPurchaseActual({
|
|
264
|
+
plannedCount: plannedPlanIds.length,
|
|
265
|
+
receiptCount: receipts.length,
|
|
266
|
+
firedCount: receipts.filter((receipt) => receipt.purchase_fired).length,
|
|
267
|
+
unqualifiedCount: unique(unqualifiedPlanIds).length,
|
|
268
|
+
captureErrorCount: unique(captureErrorPlanIds).length,
|
|
269
|
+
waiver,
|
|
270
|
+
}),
|
|
271
|
+
evidence,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function normalizePlanId(value) {
|
|
276
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function unique(values) {
|
|
280
|
+
return [...new Set(values)];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function isCapture(value) {
|
|
284
|
+
return !!value && typeof value === "object";
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function purchaseSignalsOf(capture) {
|
|
288
|
+
const signals = capture?.purchaseSignals || {};
|
|
289
|
+
return {
|
|
290
|
+
dataLayer: !!(capture?.purchase?.present || signals.dataLayer),
|
|
291
|
+
meta: !!signals.meta,
|
|
292
|
+
ga4: !!signals.ga4,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function receiptPurchaseActual({ plannedCount, receiptCount, firedCount, unqualifiedCount, captureErrorCount, waiver }) {
|
|
297
|
+
if (!plannedCount) return "no canonical typed-card browser order was planned; Purchase requires receipt-qualified order evidence";
|
|
298
|
+
if (captureErrorCount) return `${captureErrorCount} planned order capture(s) failed; Purchase could not be verified`;
|
|
299
|
+
if (firedCount < receiptCount) {
|
|
300
|
+
const suffix = waiver
|
|
301
|
+
? ` — blocker waived by ${waiver.waived_by}${waiver.waived_at ? ` at ${waiver.waived_at}` : ""}: ${waiver.reason}`
|
|
302
|
+
: "";
|
|
303
|
+
return `${receiptCount - firedCount} receipt-qualified order(s) emitted no Purchase via dataLayer, Meta, or GA4 on any page of the journey${suffix}`;
|
|
304
|
+
}
|
|
305
|
+
if (unqualifiedCount) return `${unqualifiedCount} of ${plannedCount} planned order(s) did not reach a recognized receipt`;
|
|
306
|
+
return `${firedCount} of ${plannedCount} receipt-qualified order(s) emitted Purchase`;
|
|
307
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const ANALYTICS_CAPTURE_ERROR_DEFINITIONS = Object.freeze({
|
|
2
|
+
attach: Object.freeze({
|
|
3
|
+
code: "analytics_capture_attach_failed",
|
|
4
|
+
message: "analytics capture could not be attached",
|
|
5
|
+
}),
|
|
6
|
+
unreadable: Object.freeze({
|
|
7
|
+
code: "analytics_capture_unreadable",
|
|
8
|
+
message: "analytics capture could not be read from the settled page",
|
|
9
|
+
}),
|
|
10
|
+
settle: Object.freeze({
|
|
11
|
+
code: "analytics_settle_failed",
|
|
12
|
+
message: "analytics settle window could not complete",
|
|
13
|
+
}),
|
|
14
|
+
settleDeadline: Object.freeze({
|
|
15
|
+
code: "analytics_settle_deadline_exhausted",
|
|
16
|
+
message: "analytics settle window exceeded the typed-order deadline",
|
|
17
|
+
}),
|
|
18
|
+
collectionDeadline: Object.freeze({
|
|
19
|
+
code: "analytics_capture_collection_deadline_exhausted",
|
|
20
|
+
message: "analytics capture collection exceeded the typed-order deadline",
|
|
21
|
+
}),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export function analyticsCaptureError(kind) {
|
|
25
|
+
const definition = ANALYTICS_CAPTURE_ERROR_DEFINITIONS[kind]
|
|
26
|
+
|| ANALYTICS_CAPTURE_ERROR_DEFINITIONS.unreadable;
|
|
27
|
+
return { ...definition };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Only project errors from the fixed private vocabulary. Browser/Playwright
|
|
31
|
+
// detail can contain live URLs, query strings, and page-controlled text, so it
|
|
32
|
+
// must never cross into a verdict or parity bundle.
|
|
33
|
+
export function projectAnalyticsCaptureError(value, { fallbackKind = null } = {}) {
|
|
34
|
+
const definition = Object.values(ANALYTICS_CAPTURE_ERROR_DEFINITIONS)
|
|
35
|
+
.find((candidate) => candidate.code === value?.code);
|
|
36
|
+
if (definition) return { ...definition };
|
|
37
|
+
return fallbackKind ? analyticsCaptureError(fallbackKind) : null;
|
|
38
|
+
}
|