@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,734 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import { shellToken } from "./shell-token.mjs";
|
|
6
|
+
import {
|
|
7
|
+
compareVersions,
|
|
8
|
+
escapeRegExp,
|
|
9
|
+
extractVersion,
|
|
10
|
+
listFiles as scanFiles,
|
|
11
|
+
normalizeString,
|
|
12
|
+
relPath,
|
|
13
|
+
rootId,
|
|
14
|
+
shouldSkipDir as skipDir,
|
|
15
|
+
unique,
|
|
16
|
+
} from "./repo-scan.mjs";
|
|
17
|
+
|
|
18
|
+
const CONTRACTS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "contracts");
|
|
19
|
+
const FIELD_CONTRACT_PATH = join(CONTRACTS_DIR, "campaign-cart-checkout-field-contract.v0.json");
|
|
20
|
+
const SDK_POLICY_PATH = join(CONTRACTS_DIR, "campaign-cart-sdk-support-policy.v0.json");
|
|
21
|
+
|
|
22
|
+
const SKIP_DIRS = new Set([
|
|
23
|
+
".git",
|
|
24
|
+
".campaign-runtime",
|
|
25
|
+
"_site",
|
|
26
|
+
"node_modules",
|
|
27
|
+
"qa-output",
|
|
28
|
+
"dist",
|
|
29
|
+
"build",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const listFiles = (root, options = {}) => scanFiles(root, { ...options, skipDirs: SKIP_DIRS });
|
|
33
|
+
const shouldSkipDir = (name) => skipDir(name, SKIP_DIRS);
|
|
34
|
+
const HTML_EXTENSIONS = new Set([".html", ".htm"]);
|
|
35
|
+
const SCRIPT_EXTENSIONS = new Set([".js", ".mjs", ".cjs", ".ts"]);
|
|
36
|
+
const MAX_SAMPLE_COUNT = 8;
|
|
37
|
+
|
|
38
|
+
// Any http(s) reference to a campaign-cart artifact, pinned or not. The
|
|
39
|
+
// semver (when pinned) is extracted separately so @latest/@main/commit pins
|
|
40
|
+
// are still discovered — they just can't be evaluated against version policy.
|
|
41
|
+
const LOADER_URL_PATTERN = /https?:\/\/[^"'\s]*campaign-cart@([^"'\s/]+)[^"'\s]*/g;
|
|
42
|
+
const LOADER_ARTIFACT_PATTERN = /\/loader(?:\.min)?\.js\b|\/dist\//;
|
|
43
|
+
const CAMPAIGN_ID_META_PATTERN = /<meta\s+[^>]*name=["']next-campaign-id["'][^>]*>/gi;
|
|
44
|
+
const NEXT_CONFIG_PATTERN = /window\.nextConfig\s*=/;
|
|
45
|
+
const DATA_NEXT_PATTERN = /\bdata-next-[a-zA-Z0-9_-]+/g;
|
|
46
|
+
const PAYMENT_RADIO_TAG_PATTERN = /<input\b[^>]*name=["']payment_method["'][^>]*>/gi;
|
|
47
|
+
|
|
48
|
+
// Proximity window for markup-embedded sync evidence: a payment_method
|
|
49
|
+
// reference and the dispatchEvent that fires the change must sit within this
|
|
50
|
+
// many characters of each other. ~600 chars ≈ a typical handler function body,
|
|
51
|
+
// so a realistic handler co-locates both tokens; the rejected alternative —
|
|
52
|
+
// treating any file-level co-occurrence as evidence (an analytics dispatchEvent
|
|
53
|
+
// elsewhere in the file) — would overclaim wiring. Script scopes below
|
|
54
|
+
// PAYMENT_SYNC_SCRIPT_SCOPE_MAX_CHARS are scanned whole (see hasSyncSignals) so
|
|
55
|
+
// a handler longer than this window still counts; over-cap script content
|
|
56
|
+
// (vendored bundles) is never claimed as sync evidence at all — the proof
|
|
57
|
+
// state stays honestly unproven instead of overclaimed. Proximity applies only
|
|
58
|
+
// to markup with script bodies masked out.
|
|
59
|
+
const PAYMENT_SYNC_PROXIMITY_WINDOW_CHARS = 600;
|
|
60
|
+
const PAYMENT_SYNC_SCRIPT_SCOPE_MAX_CHARS = 20000;
|
|
61
|
+
const SCRIPT_BLOCK_PATTERN = /(<script\b[^>]*>)([\s\S]*?)(<\/script\s*>)/gi;
|
|
62
|
+
|
|
63
|
+
export const CAMPAIGN_CART_APP_CAPABILITIES = [
|
|
64
|
+
"campaign_cart_runtime_inventory",
|
|
65
|
+
"sdk_loader_discovery",
|
|
66
|
+
"sdk_version_policy",
|
|
67
|
+
"checkout_field_contract",
|
|
68
|
+
"payment_interaction_risk",
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
export function loadCheckoutFieldContract(override = null) {
|
|
72
|
+
if (override && typeof override === "object") return override;
|
|
73
|
+
const contract = readJson(FIELD_CONTRACT_PATH);
|
|
74
|
+
if (!contract) {
|
|
75
|
+
throw new Error(`Checkout field contract is missing or unparseable: ${FIELD_CONTRACT_PATH}`);
|
|
76
|
+
}
|
|
77
|
+
return contract;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function loadSdkSupportPolicy(override = null) {
|
|
81
|
+
if (override && typeof override === "object") {
|
|
82
|
+
return {
|
|
83
|
+
source: normalizeString(override.source) || "inline_policy",
|
|
84
|
+
minimum_supported: normalizeString(override.minimum_supported),
|
|
85
|
+
preferred_minimum: normalizeString(override.preferred_minimum),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const policy = readJson(SDK_POLICY_PATH);
|
|
89
|
+
if (!policy) {
|
|
90
|
+
throw new Error(`SDK support policy contract is missing or unparseable: ${SDK_POLICY_PATH}`);
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
source: normalizeString(policy?.source) || "contracts/campaign-cart-sdk-support-policy.v0.json",
|
|
94
|
+
minimum_supported: normalizeString(policy?.minimum_supported),
|
|
95
|
+
preferred_minimum: normalizeString(policy?.preferred_minimum),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function discoverCampaignCartAppRoots(targetRepo, { excludeRoots = [] } = {}) {
|
|
100
|
+
const target = resolve(targetRepo);
|
|
101
|
+
if (!existsSync(target) || !statSync(target).isDirectory()) return [];
|
|
102
|
+
const excluded = excludeRoots.map((root) => resolve(root));
|
|
103
|
+
const rootEvidence = new Map();
|
|
104
|
+
|
|
105
|
+
for (const file of listFiles(target)) {
|
|
106
|
+
if (excluded.some((root) => file === root || file.startsWith(`${root}${sep}`))) continue;
|
|
107
|
+
const ext = extname(file).toLowerCase();
|
|
108
|
+
const isHtml = HTML_EXTENSIONS.has(ext);
|
|
109
|
+
const isScript = SCRIPT_EXTENSIONS.has(ext);
|
|
110
|
+
if (!isHtml && !isScript) continue;
|
|
111
|
+
const raw = safeReadText(file);
|
|
112
|
+
if (raw === null) continue;
|
|
113
|
+
const content = isHtml ? maskHtmlComments(raw) : raw;
|
|
114
|
+
|
|
115
|
+
const signals = [];
|
|
116
|
+
for (const match of content.matchAll(withGlobal(LOADER_URL_PATTERN))) {
|
|
117
|
+
if (LOADER_ARTIFACT_PATTERN.test(match[0])) {
|
|
118
|
+
signals.push({ signal: "campaign_cart_loader", strength: "strong", detail: match[0] });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (isHtml && withGlobal(CAMPAIGN_ID_META_PATTERN).test(content)) {
|
|
122
|
+
signals.push({ signal: "next_campaign_id_meta", strength: "strong", detail: null });
|
|
123
|
+
}
|
|
124
|
+
if (NEXT_CONFIG_PATTERN.test(content)) {
|
|
125
|
+
signals.push({ signal: "next_config_global", strength: "strong", detail: null });
|
|
126
|
+
}
|
|
127
|
+
if (isHtml) {
|
|
128
|
+
const anchors = content.match(DATA_NEXT_PATTERN) || [];
|
|
129
|
+
if (anchors.length) {
|
|
130
|
+
signals.push({ signal: "data_next_anchors", strength: "weak", count: anchors.length, detail: "data-next anchors" });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!signals.length) continue;
|
|
134
|
+
|
|
135
|
+
const rootPath = attributeRoot(target, file);
|
|
136
|
+
if (!rootEvidence.has(rootPath)) rootEvidence.set(rootPath, []);
|
|
137
|
+
const bucket = rootEvidence.get(rootPath);
|
|
138
|
+
for (const entry of signals) {
|
|
139
|
+
bucket.push({ ...entry, path: relPath(rootPath, file) });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const roots = [];
|
|
144
|
+
for (const [rootPath, evidence] of rootEvidence) {
|
|
145
|
+
const strong = evidence.filter((entry) => entry.strength === "strong");
|
|
146
|
+
const weakAnchorTotal = evidence
|
|
147
|
+
.filter((entry) => entry.signal === "data_next_anchors")
|
|
148
|
+
.reduce((total, entry) => total + (Number.isFinite(entry.count) ? entry.count : 0), 0);
|
|
149
|
+
if (strong.length >= 1 || weakAnchorTotal >= 5) {
|
|
150
|
+
roots.push({ rootPath, evidence: dedupeEvidence(evidence) });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return roots.sort((a, b) => a.rootPath.localeCompare(b.rootPath));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function scanCampaignCartAppRoot({
|
|
157
|
+
targetRepo,
|
|
158
|
+
rootPath,
|
|
159
|
+
evidence = [],
|
|
160
|
+
fieldContract = null,
|
|
161
|
+
sdkSupportPolicy = null,
|
|
162
|
+
excludeRoots = [],
|
|
163
|
+
}) {
|
|
164
|
+
const target = resolve(targetRepo);
|
|
165
|
+
const root = resolve(rootPath);
|
|
166
|
+
const contract = loadCheckoutFieldContract(fieldContract);
|
|
167
|
+
const policy = loadSdkSupportPolicy(sdkSupportPolicy);
|
|
168
|
+
// Only sibling roots nested INSIDE this root are excluded; ancestors are
|
|
169
|
+
// irrelevant (listFiles never leaves root) and must not mask the scan.
|
|
170
|
+
const excluded = excludeRoots
|
|
171
|
+
.map((entry) => resolve(entry))
|
|
172
|
+
.filter((entry) => entry !== root && entry.startsWith(`${root}${sep}`));
|
|
173
|
+
const files = listFiles(root)
|
|
174
|
+
.filter((file) => !excluded.some((other) => file === other || file.startsWith(`${other}${sep}`)));
|
|
175
|
+
const htmlFiles = files.filter((file) => HTML_EXTENSIONS.has(extname(file).toLowerCase()));
|
|
176
|
+
const scriptFiles = files.filter((file) => SCRIPT_EXTENSIONS.has(extname(file).toLowerCase()));
|
|
177
|
+
const findings = [];
|
|
178
|
+
|
|
179
|
+
const frameworks = detectFrameworks(root);
|
|
180
|
+
const loader = collectLoaderReferences(root, [...htmlFiles, ...scriptFiles]);
|
|
181
|
+
// Delivery can be a hosted loader URL OR a bundled npm dependency. When the
|
|
182
|
+
// SDK ships as a dependency there is no loader ref to discover, but the pin
|
|
183
|
+
// is still a real version signal — record it and feed a resolved semver into
|
|
184
|
+
// version policy like a discovered version (source-distinguishable below).
|
|
185
|
+
const bundled = detectBundledSdkDependency(root);
|
|
186
|
+
loader.bundled_dependency = bundled
|
|
187
|
+
? { name: bundled.name, version: bundled.version, resolved_version: bundled.resolved_version }
|
|
188
|
+
: null;
|
|
189
|
+
// A prerelease pin sorts strictly before its GA release, so it is never fed
|
|
190
|
+
// into version policy as a clean semver — the numeric triple would wrongly
|
|
191
|
+
// pass the release gate. It is recorded on sdk_loader and flagged instead.
|
|
192
|
+
const bundledPolicyVersion = bundled?.resolved_version && !bundled.prerelease
|
|
193
|
+
? bundled.resolved_version
|
|
194
|
+
: null;
|
|
195
|
+
const versionEntries = [
|
|
196
|
+
...loader.versions.map((version) => ({ version, source: "loader" })),
|
|
197
|
+
...(bundledPolicyVersion && !loader.versions.includes(bundledPolicyVersion)
|
|
198
|
+
? [{ version: bundledPolicyVersion, source: "bundled_dependency" }]
|
|
199
|
+
: []),
|
|
200
|
+
];
|
|
201
|
+
const versionPolicy = evaluateVersionPolicy(versionEntries, policy, findings);
|
|
202
|
+
if (bundled?.prerelease) {
|
|
203
|
+
findings.push(finding({
|
|
204
|
+
severity: "warning",
|
|
205
|
+
category: "standardization_warning",
|
|
206
|
+
code: "version.sdk_prerelease_pin",
|
|
207
|
+
message: `Bundled Campaign Cart dependency pins prerelease ${bundled.resolved_version} (${bundled.name}@${bundled.version}); a prerelease sorts before its release and cannot be evaluated against the release support policy.`,
|
|
208
|
+
confidence: "static_contract",
|
|
209
|
+
evidence: { name: bundled.name, version: bundled.version, resolved_version: bundled.resolved_version },
|
|
210
|
+
next_action: "Pin the bundled campaign-cart dependency to a released version so version policy can be evaluated.",
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
213
|
+
const checkoutFields = inspectCheckoutFields(root, htmlFiles, contract, findings);
|
|
214
|
+
const payment = inspectPaymentSurfaces(root, htmlFiles, scriptFiles, findings);
|
|
215
|
+
const dataNext = inspectDataNext(root, htmlFiles);
|
|
216
|
+
const runtime = inspectRuntimeArtifacts(root, target, findings);
|
|
217
|
+
const campaignIds = collectCampaignIds(htmlFiles);
|
|
218
|
+
|
|
219
|
+
const unpinned = loader.references.filter((entry) => !entry.version);
|
|
220
|
+
if (unpinned.length) {
|
|
221
|
+
findings.push(finding({
|
|
222
|
+
severity: "warning",
|
|
223
|
+
category: "standardization_warning",
|
|
224
|
+
code: "version.sdk_loader_unpinned",
|
|
225
|
+
message: `${unpinned.length} Campaign Cart loader reference(s) use a non-semver ref (${unique(unpinned.map((entry) => entry.ref)).join(", ")}); version policy cannot be evaluated against an unpinned loader.`,
|
|
226
|
+
confidence: "static_contract",
|
|
227
|
+
evidence: unpinned.slice(0, MAX_SAMPLE_COUNT).map(({ path, line, url }) => ({ path, line, url })),
|
|
228
|
+
next_action: "Pin the loader to an explicit supported version so version policy and QA reproducibility hold.",
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
231
|
+
if (!loader.references.length && !bundled) {
|
|
232
|
+
findings.push(finding({
|
|
233
|
+
severity: "operator_readiness",
|
|
234
|
+
category: "operator_readiness",
|
|
235
|
+
code: "version.sdk_version_unknown",
|
|
236
|
+
message: "Campaign Cart evidence exists but no loader reference or bundled dependency could be discovered from source.",
|
|
237
|
+
confidence: "static_inference",
|
|
238
|
+
next_action: "Confirm how the SDK is delivered (bundled, self-hosted, or injected) before applying version policy.",
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
id: rootId(target, root),
|
|
244
|
+
status: "unknown",
|
|
245
|
+
ok: false,
|
|
246
|
+
implementation: {
|
|
247
|
+
kind: "campaign_cart_app",
|
|
248
|
+
evidence,
|
|
249
|
+
frameworks,
|
|
250
|
+
},
|
|
251
|
+
capabilities: [...CAMPAIGN_CART_APP_CAPABILITIES],
|
|
252
|
+
identity: {
|
|
253
|
+
repo: basename(target),
|
|
254
|
+
target_repo: target,
|
|
255
|
+
campaign_root: root,
|
|
256
|
+
campaign_root_relative: relPath(target, root),
|
|
257
|
+
campaign_ids: campaignIds,
|
|
258
|
+
sdk_versions: loader.versions,
|
|
259
|
+
has_campaign_runtime: runtime.present,
|
|
260
|
+
},
|
|
261
|
+
sdk_loader: loader,
|
|
262
|
+
version_policy: versionPolicy,
|
|
263
|
+
checkout_fields: checkoutFields,
|
|
264
|
+
payment,
|
|
265
|
+
runtime_contract: {
|
|
266
|
+
data_next: dataNext.summary,
|
|
267
|
+
checkout_surface: dataNext.checkout_surface,
|
|
268
|
+
upsell_surface: dataNext.upsell_surface,
|
|
269
|
+
receipt_surface: dataNext.receipt_surface,
|
|
270
|
+
campaign_runtime: runtime.summary,
|
|
271
|
+
},
|
|
272
|
+
findings,
|
|
273
|
+
remediation: buildRemediation({ checkoutFields, versionPolicy, payment, root }),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Detect a bundled Campaign Cart SDK delivered as an npm dependency. Returns
|
|
278
|
+
// the pin spec plus a resolved version (extracted from ranges like `^0.4.30`,
|
|
279
|
+
// prerelease suffix preserved: `^0.4.30-beta.1` → `0.4.30-beta.1`) or null
|
|
280
|
+
// when no such dependency is declared. A prerelease pin sorts strictly BEFORE
|
|
281
|
+
// its GA release in semver, so `prerelease: true` tells the caller the pin
|
|
282
|
+
// must NOT be evaluated against the release policy as if it were the release.
|
|
283
|
+
function detectBundledSdkDependency(root) {
|
|
284
|
+
const pkg = readJson(join(root, "package.json"));
|
|
285
|
+
const deps = { ...(pkg?.dependencies || {}), ...(pkg?.devDependencies || {}) };
|
|
286
|
+
for (const name of ["campaign-cart", "@nextcommerce/campaign-cart"]) {
|
|
287
|
+
const spec = deps[name];
|
|
288
|
+
if (typeof spec !== "string" || !spec.trim()) continue;
|
|
289
|
+
const semver = spec.match(/(\d+\.\d+\.\d+)(-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?/);
|
|
290
|
+
return {
|
|
291
|
+
name,
|
|
292
|
+
version: spec,
|
|
293
|
+
resolved_version: semver ? `${semver[1]}${semver[2] || ""}` : null,
|
|
294
|
+
prerelease: Boolean(semver && semver[2]),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function detectFrameworks(root) {
|
|
301
|
+
const pkg = readJson(join(root, "package.json"));
|
|
302
|
+
const deps = { ...(pkg?.dependencies || {}), ...(pkg?.devDependencies || {}) };
|
|
303
|
+
const known = ["vite", "react", "express", "next", "svelte", "vue", "astro"];
|
|
304
|
+
return known.filter((name) => typeof deps[name] === "string");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function collectLoaderReferences(root, files) {
|
|
308
|
+
const references = [];
|
|
309
|
+
for (const file of files) {
|
|
310
|
+
const raw = safeReadText(file);
|
|
311
|
+
if (raw === null) continue;
|
|
312
|
+
const content = HTML_EXTENSIONS.has(extname(file).toLowerCase()) ? maskHtmlComments(raw) : raw;
|
|
313
|
+
for (const match of content.matchAll(withGlobal(LOADER_URL_PATTERN))) {
|
|
314
|
+
if (!LOADER_ARTIFACT_PATTERN.test(match[0])) continue;
|
|
315
|
+
const semver = match[1].match(/^v?(\d+\.\d+\.\d+)$/);
|
|
316
|
+
references.push({
|
|
317
|
+
path: relPath(root, file),
|
|
318
|
+
line: lineOf(content, match.index || 0),
|
|
319
|
+
url: match[0],
|
|
320
|
+
ref: match[1],
|
|
321
|
+
version: semver ? semver[1] : null,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
references,
|
|
327
|
+
versions: unique(references.map((entry) => entry.version)),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function evaluateVersionPolicy(versionEntries, policy, findings) {
|
|
332
|
+
const evaluations = versionEntries.map(({ version, source }) => ({
|
|
333
|
+
version,
|
|
334
|
+
source,
|
|
335
|
+
meets_minimum: policy.minimum_supported ? compareVersions(version, policy.minimum_supported) >= 0 : null,
|
|
336
|
+
meets_preferred: policy.preferred_minimum ? compareVersions(version, policy.preferred_minimum) >= 0 : null,
|
|
337
|
+
}));
|
|
338
|
+
for (const evaluation of evaluations) {
|
|
339
|
+
if (evaluation.meets_minimum === false) {
|
|
340
|
+
findings.push(finding({
|
|
341
|
+
severity: "blocker",
|
|
342
|
+
category: "standardization_blocker",
|
|
343
|
+
code: "version.sdk_below_minimum_supported",
|
|
344
|
+
message: `Campaign Cart SDK ${evaluation.version} is below the minimum supported ${policy.minimum_supported} (policy: ${policy.source}).`,
|
|
345
|
+
confidence: "static_contract",
|
|
346
|
+
next_action: "Upgrade the Campaign Cart loader pin to a supported version and re-run browser QA.",
|
|
347
|
+
}));
|
|
348
|
+
} else if (evaluation.meets_preferred === false) {
|
|
349
|
+
findings.push(finding({
|
|
350
|
+
severity: "warning",
|
|
351
|
+
category: "standardization_warning",
|
|
352
|
+
code: "version.sdk_below_preferred_policy",
|
|
353
|
+
message: `Campaign Cart SDK ${evaluation.version} is below the preferred minimum ${policy.preferred_minimum} (policy: ${policy.source}).`,
|
|
354
|
+
confidence: "static_contract",
|
|
355
|
+
next_action: "Confirm whether the campaign intentionally pins the SDK before recommending an upgrade.",
|
|
356
|
+
}));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
source: policy.source,
|
|
361
|
+
minimum_supported: policy.minimum_supported,
|
|
362
|
+
preferred_minimum: policy.preferred_minimum,
|
|
363
|
+
evaluations,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function inspectCheckoutFields(root, htmlFiles, contract, findings) {
|
|
368
|
+
const bindings = [];
|
|
369
|
+
const attributes = contract?.binding_attributes || ["data-next-checkout-field", "os-checkout-field"];
|
|
370
|
+
const attributePattern = new RegExp(`\\b(${attributes.map(escapeRegExp).join("|")})\\s*=\\s*(["'])([^"']*)\\2`, "g");
|
|
371
|
+
for (const file of htmlFiles) {
|
|
372
|
+
const raw = safeReadText(file);
|
|
373
|
+
if (raw === null) continue;
|
|
374
|
+
const content = maskHtmlComments(raw);
|
|
375
|
+
for (const match of content.matchAll(attributePattern)) {
|
|
376
|
+
const value = match[3].trim();
|
|
377
|
+
const verdict = classifyFieldBinding(value, contract);
|
|
378
|
+
bindings.push({
|
|
379
|
+
attribute: match[1],
|
|
380
|
+
value,
|
|
381
|
+
path: relPath(root, file),
|
|
382
|
+
line: lineOf(content, match.index || 0),
|
|
383
|
+
supported: verdict.classification === "supported",
|
|
384
|
+
classification: verdict.classification,
|
|
385
|
+
canonical: verdict.canonical,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const unsupported = bindings.filter((entry) => entry.classification === "stale_alias");
|
|
391
|
+
const unknown = bindings.filter((entry) => entry.classification === "unknown");
|
|
392
|
+
|
|
393
|
+
if (unsupported.length) {
|
|
394
|
+
findings.push(finding({
|
|
395
|
+
severity: "blocker",
|
|
396
|
+
category: "standardization_blocker",
|
|
397
|
+
code: "checkout.unsupported_field_binding",
|
|
398
|
+
message: `${unsupported.length} checkout field binding(s) use stale aliases the Campaign Cart SDK does not consume (contract: ${contract?.schema_version || "unknown"}).`,
|
|
399
|
+
confidence: "static_contract",
|
|
400
|
+
evidence: dedupeBy(unsupported, (entry) => entry.value)
|
|
401
|
+
.map((entry) => ({ value: entry.value, canonical: entry.canonical, path: entry.path, line: entry.line }))
|
|
402
|
+
.slice(0, MAX_SAMPLE_COUNT),
|
|
403
|
+
next_action: "Rewrite each stale alias to its canonical Campaign Cart field name, then prove checkout binding in browser QA.",
|
|
404
|
+
}));
|
|
405
|
+
}
|
|
406
|
+
if (unknown.length) {
|
|
407
|
+
findings.push(finding({
|
|
408
|
+
severity: "warning",
|
|
409
|
+
category: "standardization_warning",
|
|
410
|
+
code: "checkout.unknown_field_binding",
|
|
411
|
+
message: `${unknown.length} checkout field binding(s) are outside the known Campaign Cart field contract.`,
|
|
412
|
+
confidence: "static_inference",
|
|
413
|
+
evidence: dedupeBy(unknown, (entry) => entry.value)
|
|
414
|
+
.map((entry) => ({ value: entry.value, path: entry.path, line: entry.line }))
|
|
415
|
+
.slice(0, MAX_SAMPLE_COUNT),
|
|
416
|
+
next_action: "Confirm the field against the Campaign Cart SDK before treating it as supported or repairing it.",
|
|
417
|
+
}));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
contract: contract?.schema_version || null,
|
|
422
|
+
bindings,
|
|
423
|
+
unsupported,
|
|
424
|
+
unknown,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function classifyFieldBinding(value, contract) {
|
|
429
|
+
if (!value) return { classification: "unknown", canonical: null };
|
|
430
|
+
const canonicalFields = new Set(contract?.canonical_fields || []);
|
|
431
|
+
const acceptedAliases = contract?.accepted_aliases || {};
|
|
432
|
+
const staleAliases = contract?.stale_aliases || {};
|
|
433
|
+
const prefixes = contract?.prefixes || [];
|
|
434
|
+
|
|
435
|
+
let base = value;
|
|
436
|
+
let prefix = "";
|
|
437
|
+
for (const candidate of prefixes) {
|
|
438
|
+
if (value.startsWith(candidate)) {
|
|
439
|
+
prefix = candidate;
|
|
440
|
+
base = value.slice(candidate.length);
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (canonicalFields.has(base)) return { classification: "supported", canonical: null };
|
|
445
|
+
if (typeof acceptedAliases[base] === "string") return { classification: "supported", canonical: null };
|
|
446
|
+
if (typeof staleAliases[base] === "string") {
|
|
447
|
+
return { classification: "stale_alias", canonical: `${prefix}${staleAliases[base]}` };
|
|
448
|
+
}
|
|
449
|
+
return { classification: "unknown", canonical: null };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function inspectPaymentSurfaces(root, htmlFiles, scriptFiles, findings) {
|
|
453
|
+
const radioFiles = [];
|
|
454
|
+
const hiddenRadioFiles = [];
|
|
455
|
+
const customTriggerFiles = [];
|
|
456
|
+
const syncFiles = [];
|
|
457
|
+
|
|
458
|
+
for (const file of htmlFiles) {
|
|
459
|
+
const raw = safeReadText(file);
|
|
460
|
+
if (raw === null) continue;
|
|
461
|
+
const content = maskHtmlComments(raw);
|
|
462
|
+
const radios = content.match(withGlobal(PAYMENT_RADIO_TAG_PATTERN)) || [];
|
|
463
|
+
if (radios.length) radioFiles.push(relPath(root, file));
|
|
464
|
+
if (radios.some((tag) => /display\s*:\s*none/i.test(tag) || /(?<![\w-])hidden(?![\w-])/i.test(tag))) {
|
|
465
|
+
hiddenRadioFiles.push(relPath(root, file));
|
|
466
|
+
}
|
|
467
|
+
if (/\bdata-pay\b|\bdata-payment-method-trigger\b/i.test(content)) {
|
|
468
|
+
customTriggerFiles.push(relPath(root, file));
|
|
469
|
+
}
|
|
470
|
+
if (hasSyncSignals(content)) syncFiles.push(relPath(root, file));
|
|
471
|
+
}
|
|
472
|
+
for (const file of scriptFiles) {
|
|
473
|
+
const content = safeReadText(file);
|
|
474
|
+
if (content === null) continue;
|
|
475
|
+
if (hasSyncSignals(content, { isScript: true })) syncFiles.push(relPath(root, file));
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const customControls = hiddenRadioFiles.length > 0 || customTriggerFiles.length > 0;
|
|
479
|
+
const syncDetected = syncFiles.length > 0;
|
|
480
|
+
// "not_applicable" only when no SDK payment radios exist at all. When radios
|
|
481
|
+
// exist but no custom-control evidence was found, stay "undetermined" —
|
|
482
|
+
// external CSS can hide radios in ways static scanning cannot exclude, so
|
|
483
|
+
// the scanner never affirms that behavioral proof is unnecessary.
|
|
484
|
+
const proofState = customControls
|
|
485
|
+
? "runtime_proof_required"
|
|
486
|
+
: radioFiles.length
|
|
487
|
+
? "undetermined"
|
|
488
|
+
: "not_applicable";
|
|
489
|
+
|
|
490
|
+
if (customControls) {
|
|
491
|
+
findings.push(finding({
|
|
492
|
+
severity: syncDetected ? "operator_readiness" : "warning",
|
|
493
|
+
category: syncDetected ? "operator_readiness" : "standardization_warning",
|
|
494
|
+
code: "payment.custom_controls_proof_required",
|
|
495
|
+
message: syncDetected
|
|
496
|
+
? "Custom payment controls sit over hidden SDK payment_method radios; a synchronization script that dispatches change events was detected, but the interaction still requires behavioral (DOM/browser) proof. Missing proof is not a defect claim."
|
|
497
|
+
: "Custom payment controls sit over hidden SDK payment_method radios and no synchronization script was detected; the interaction requires behavioral (DOM/browser) proof before the surface can be trusted. Missing proof is not a defect claim.",
|
|
498
|
+
confidence: "runtime_proof_required",
|
|
499
|
+
evidence: {
|
|
500
|
+
payment_method_radio_files: radioFiles.slice(0, MAX_SAMPLE_COUNT),
|
|
501
|
+
hidden_radio_files: hiddenRadioFiles.slice(0, MAX_SAMPLE_COUNT),
|
|
502
|
+
custom_trigger_files: customTriggerFiles.slice(0, MAX_SAMPLE_COUNT),
|
|
503
|
+
synchronization_files: syncFiles.slice(0, MAX_SAMPLE_COUNT),
|
|
504
|
+
},
|
|
505
|
+
next_action: "Exercise the custom payment controls in a deterministic DOM/browser test and confirm the real payment_method radio state changes and dispatches change events.",
|
|
506
|
+
}));
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return {
|
|
510
|
+
sdk_method_radios: { detected: radioFiles.length > 0, files: unique(radioFiles).slice(0, MAX_SAMPLE_COUNT) },
|
|
511
|
+
hidden_radios: { detected: hiddenRadioFiles.length > 0, files: unique(hiddenRadioFiles).slice(0, MAX_SAMPLE_COUNT) },
|
|
512
|
+
custom_triggers: { detected: customTriggerFiles.length > 0, files: unique(customTriggerFiles).slice(0, MAX_SAMPLE_COUNT) },
|
|
513
|
+
synchronization_script: { detected: syncDetected, files: unique(syncFiles).slice(0, MAX_SAMPLE_COUNT) },
|
|
514
|
+
proof_state: proofState,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Synchronization evidence requires a dispatchEvent co-located with a
|
|
519
|
+
// payment_method reference. "Co-located" is scoped, not file-global: a real
|
|
520
|
+
// wiring handler keeps both tokens in one handler/script body, so each script
|
|
521
|
+
// scope (standalone script file or inline <script> block) under the size cap
|
|
522
|
+
// is scanned whole. Over-cap script content is never claimed as sync evidence
|
|
523
|
+
// — a vendored bundle's file-level co-occurrence is not proof of wiring, and
|
|
524
|
+
// under-claiming keeps the proof state honest (runtime_proof_required /
|
|
525
|
+
// undetermined) rather than overclaimed. The proximity window applies only to
|
|
526
|
+
// markup, with script bodies masked out first.
|
|
527
|
+
function hasSyncSignals(content, { isScript = false } = {}) {
|
|
528
|
+
const text = String(content || "");
|
|
529
|
+
if (isScript) {
|
|
530
|
+
return text.length <= PAYMENT_SYNC_SCRIPT_SCOPE_MAX_CHARS && scopeHasSyncSignals(text);
|
|
531
|
+
}
|
|
532
|
+
for (const match of text.matchAll(SCRIPT_BLOCK_PATTERN)) {
|
|
533
|
+
const body = match[2] || "";
|
|
534
|
+
if (body.length <= PAYMENT_SYNC_SCRIPT_SCOPE_MAX_CHARS && scopeHasSyncSignals(body)) return true;
|
|
535
|
+
}
|
|
536
|
+
return proximityHasSyncSignals(maskScriptBodies(text));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Blank out <script> bodies (preserving offsets/newlines) so the markup
|
|
540
|
+
// proximity fallback never reads script content that the scoped scan above
|
|
541
|
+
// already judged — including over-cap bundles it deliberately excluded.
|
|
542
|
+
function maskScriptBodies(text) {
|
|
543
|
+
return text.replace(SCRIPT_BLOCK_PATTERN, (_match, open, body, close) =>
|
|
544
|
+
`${open}${body.replace(/[^\r\n]/g, " ")}${close}`);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Whole-scope co-occurrence: both tokens present anywhere in one script scope.
|
|
548
|
+
function scopeHasSyncSignals(text) {
|
|
549
|
+
return text.includes("payment_method") && /dispatchEvent\s*\(/.test(text);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Proximity co-occurrence: a dispatchEvent within the window of a
|
|
553
|
+
// payment_method reference (used for markup outside a single script scope).
|
|
554
|
+
function proximityHasSyncSignals(text) {
|
|
555
|
+
let index = text.indexOf("payment_method");
|
|
556
|
+
while (index !== -1) {
|
|
557
|
+
const window = text.slice(
|
|
558
|
+
Math.max(0, index - PAYMENT_SYNC_PROXIMITY_WINDOW_CHARS),
|
|
559
|
+
index + PAYMENT_SYNC_PROXIMITY_WINDOW_CHARS,
|
|
560
|
+
);
|
|
561
|
+
if (/dispatchEvent\s*\(/.test(window)) return true;
|
|
562
|
+
index = text.indexOf("payment_method", index + 1);
|
|
563
|
+
}
|
|
564
|
+
return false;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Blank out HTML comment bodies while preserving offsets/line numbers so
|
|
568
|
+
// commented-out markup never produces bindings, radios, or sync evidence.
|
|
569
|
+
function maskHtmlComments(content) {
|
|
570
|
+
const masked = String(content || "").replace(/<!--[\s\S]*?-->/g, (match) => match.replace(/[^\r\n]/g, " "));
|
|
571
|
+
// After closed comments are masked, any surviving `<!--` opens a comment
|
|
572
|
+
// that never closes (truncated/corrupt file). Everything from that opener to
|
|
573
|
+
// EOF is commented-out, so mask it too — otherwise a stale binding/radio/
|
|
574
|
+
// loader in the unterminated tail would reintroduce false positives.
|
|
575
|
+
const openIndex = masked.indexOf("<!--");
|
|
576
|
+
if (openIndex === -1) return masked;
|
|
577
|
+
const tail = masked.slice(openIndex).replace(/[^\r\n]/g, " ");
|
|
578
|
+
return `${masked.slice(0, openIndex)}${tail}`;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function inspectDataNext(root, htmlFiles) {
|
|
582
|
+
const counts = new Map();
|
|
583
|
+
const checkoutFiles = new Set();
|
|
584
|
+
const upsellFiles = new Set();
|
|
585
|
+
const receiptFiles = new Set();
|
|
586
|
+
for (const file of htmlFiles) {
|
|
587
|
+
const raw = safeReadText(file);
|
|
588
|
+
if (raw === null) continue;
|
|
589
|
+
const content = maskHtmlComments(raw);
|
|
590
|
+
const rel = relPath(root, file);
|
|
591
|
+
for (const match of content.matchAll(DATA_NEXT_PATTERN)) {
|
|
592
|
+
counts.set(match[0], (counts.get(match[0]) || 0) + 1);
|
|
593
|
+
}
|
|
594
|
+
if (/checkout/i.test(rel) || /data-next-checkout|data-next-action=["'](?:checkout|create-order|add-to-cart)/i.test(content)) {
|
|
595
|
+
checkoutFiles.add(rel);
|
|
596
|
+
}
|
|
597
|
+
if (/upsell/i.test(rel) || /data-next-upsell/i.test(content)) upsellFiles.add(rel);
|
|
598
|
+
if (/receipt|thank/i.test(rel) || /\bdata-next-(?:order|receipt)[a-zA-Z0-9_-]*/i.test(content)) receiptFiles.add(rel);
|
|
599
|
+
}
|
|
600
|
+
const attrs = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
601
|
+
return {
|
|
602
|
+
summary: {
|
|
603
|
+
total_occurrences: attrs.reduce((total, [, count]) => total + count, 0),
|
|
604
|
+
unique_attributes: attrs.map(([name]) => name),
|
|
605
|
+
top_attributes: attrs.slice(0, 12).map(([name, count]) => ({ name, count })),
|
|
606
|
+
},
|
|
607
|
+
checkout_surface: { detected: checkoutFiles.size > 0, files: [...checkoutFiles].slice(0, MAX_SAMPLE_COUNT) },
|
|
608
|
+
upsell_surface: { detected: upsellFiles.size > 0, files: [...upsellFiles].slice(0, MAX_SAMPLE_COUNT) },
|
|
609
|
+
receipt_surface: { detected: receiptFiles.size > 0, files: [...receiptFiles].slice(0, MAX_SAMPLE_COUNT) },
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function inspectRuntimeArtifacts(rootPath, targetRepo, findings) {
|
|
614
|
+
const runtimeDirs = unique([join(rootPath, ".campaign-runtime"), join(targetRepo, ".campaign-runtime")]);
|
|
615
|
+
const artifacts = [];
|
|
616
|
+
for (const dir of runtimeDirs) {
|
|
617
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) continue;
|
|
618
|
+
artifacts.push(...listFiles(dir, { includeRuntime: true }).map((file) => relPath(rootPath, file)));
|
|
619
|
+
}
|
|
620
|
+
const present = artifacts.length > 0;
|
|
621
|
+
if (!present) {
|
|
622
|
+
findings.push(finding({
|
|
623
|
+
severity: "warning",
|
|
624
|
+
category: "standardization_warning",
|
|
625
|
+
code: "campaigns_os.artifacts_missing",
|
|
626
|
+
message: "No .campaign-runtime artifacts were found for this root; Campaigns OS build/QA provenance is missing, which is a proof gap, not a confirmed failure.",
|
|
627
|
+
confidence: "static_inference",
|
|
628
|
+
next_action: "Attach Campaigns OS run artifacts (Build Packet, QA verdict) when workflow provenance matters for this campaign.",
|
|
629
|
+
}));
|
|
630
|
+
}
|
|
631
|
+
return {
|
|
632
|
+
present,
|
|
633
|
+
summary: { present, artifact_count: artifacts.length, artifacts: artifacts.slice(0, 40) },
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function collectCampaignIds(htmlFiles) {
|
|
638
|
+
const ids = new Set();
|
|
639
|
+
for (const file of htmlFiles) {
|
|
640
|
+
const raw = safeReadText(file);
|
|
641
|
+
if (raw === null) continue;
|
|
642
|
+
const content = maskHtmlComments(raw);
|
|
643
|
+
for (const match of content.matchAll(withGlobal(CAMPAIGN_ID_META_PATTERN))) {
|
|
644
|
+
const value = match[0].match(/content=["']([^"']+)["']/i);
|
|
645
|
+
if (value) ids.add(value[1]);
|
|
646
|
+
}
|
|
647
|
+
const config = content.match(/campaignId\s*:\s*["']?(\d+)["']?/);
|
|
648
|
+
if (config) ids.add(config[1]);
|
|
649
|
+
}
|
|
650
|
+
return [...ids];
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function buildRemediation({ checkoutFields, versionPolicy, payment, root }) {
|
|
654
|
+
const safe = [];
|
|
655
|
+
const clarification = [];
|
|
656
|
+
const risks = [];
|
|
657
|
+
if (checkoutFields.unsupported.length) {
|
|
658
|
+
safe.push("Rewrite stale checkout field aliases to their canonical Campaign Cart names (contract-backed), then prove the checkout binds in browser QA.");
|
|
659
|
+
}
|
|
660
|
+
if (checkoutFields.unknown.length) {
|
|
661
|
+
clarification.push("Confirm unknown checkout field bindings against the Campaign Cart SDK before repair.");
|
|
662
|
+
}
|
|
663
|
+
const failsPolicy = versionPolicy.evaluations.some((entry) => entry.meets_minimum === false || entry.meets_preferred === false);
|
|
664
|
+
if (failsPolicy) {
|
|
665
|
+
risks.push("SDK loader version bumps can change runtime behavior; confirm against campaign QA scope before changing the pin.");
|
|
666
|
+
}
|
|
667
|
+
if (payment.proof_state === "runtime_proof_required") {
|
|
668
|
+
clarification.push("Custom payment controls require deterministic DOM/browser interaction proof; do not repair or clear them from static evidence alone.");
|
|
669
|
+
}
|
|
670
|
+
return {
|
|
671
|
+
safe_agent_repairs: unique(safe),
|
|
672
|
+
clarification_needed: unique(clarification),
|
|
673
|
+
product_or_merchant_risks: unique(risks),
|
|
674
|
+
proof_commands: [
|
|
675
|
+
`campaigns-os standardize --target ${shellToken(root)} --json`,
|
|
676
|
+
],
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function attributeRoot(target, file) {
|
|
681
|
+
let dir = dirname(file);
|
|
682
|
+
while (dir.startsWith(target)) {
|
|
683
|
+
if (existsSync(join(dir, "package.json"))) return dir;
|
|
684
|
+
if (dir === target) break;
|
|
685
|
+
dir = dirname(dir);
|
|
686
|
+
}
|
|
687
|
+
return target;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function dedupeEvidence(entries) {
|
|
691
|
+
return dedupeBy(entries, (entry) => `${entry.signal}:${entry.path}`)
|
|
692
|
+
.map(({ signal, strength, path, detail }) => ({ signal, strength, path, detail }));
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function dedupeBy(entries, keyOf) {
|
|
696
|
+
const seen = new Set();
|
|
697
|
+
const result = [];
|
|
698
|
+
for (const entry of entries) {
|
|
699
|
+
const key = keyOf(entry);
|
|
700
|
+
if (seen.has(key)) continue;
|
|
701
|
+
seen.add(key);
|
|
702
|
+
result.push(entry);
|
|
703
|
+
}
|
|
704
|
+
return result;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function safeReadText(path) {
|
|
708
|
+
try {
|
|
709
|
+
return readFileSync(path, "utf8");
|
|
710
|
+
} catch {
|
|
711
|
+
return null;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function readJson(path) {
|
|
716
|
+
try {
|
|
717
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
718
|
+
} catch {
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function finding({ severity, category, code, message, confidence = null, evidence = null, next_action = null }) {
|
|
724
|
+
return { severity, category, code, message, confidence, evidence, next_action };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function lineOf(content, index) {
|
|
728
|
+
return content.slice(0, Math.max(0, index)).split(/\r?\n/).length;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function withGlobal(pattern) {
|
|
732
|
+
return new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
|
|
733
|
+
}
|
|
734
|
+
|