@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,405 @@
|
|
|
1
|
+
// Cross-page campaign identity (#301).
|
|
2
|
+
//
|
|
3
|
+
// Every page of a built campaign tells the Campaign Cart SDK which campaign it
|
|
4
|
+
// belongs to through three signals, and the SDK reads each of them per page
|
|
5
|
+
// with no cross-page reconciliation:
|
|
6
|
+
//
|
|
7
|
+
// API key <meta name="next-api-key"> beats window.nextConfig.apiKey
|
|
8
|
+
// (inline, or in the config.js the page loads). Selects the
|
|
9
|
+
// campaign whose packages, prices and orders the page acts on.
|
|
10
|
+
// next-funnel <meta name="next-funnel">. Attributed onto the order.
|
|
11
|
+
// setAttribution({ funnel }) A script call that overrides the funnel tag.
|
|
12
|
+
//
|
|
13
|
+
// The failure this module gates is a page borrowed from another funnel: a
|
|
14
|
+
// copied upsell or receipt page that still carries the other campaign's API
|
|
15
|
+
// key, funnel tag, or setAttribution call. Nothing else notices. The SDK binds
|
|
16
|
+
// each page happily, the build succeeds, the page renders, and the order is
|
|
17
|
+
// created against (or attributed to) the wrong campaign. It has shipped twice
|
|
18
|
+
// and been written up as advice both times; advice is not a gate.
|
|
19
|
+
//
|
|
20
|
+
// Hence a static blocker over built output that asserts CONSISTENCY only:
|
|
21
|
+
//
|
|
22
|
+
// - every API key observed anywhere in the campaign is the same key;
|
|
23
|
+
// - every next-funnel meta is the same value, and once any page carries one,
|
|
24
|
+
// every page that declares a next-page-type (so the SDK is bootstrapped on
|
|
25
|
+
// it) carries one too;
|
|
26
|
+
// - every setAttribution({ funnel }) agrees with the next-funnel of the page
|
|
27
|
+
// that calls it.
|
|
28
|
+
//
|
|
29
|
+
// Presence is deliberately NOT asserted. Whether a checkout page must call
|
|
30
|
+
// setAttribution is a separate question (the SDK reads ?funnel=, then the
|
|
31
|
+
// remembered funnel, then the tag, and the platform fills the campaign name
|
|
32
|
+
// when the tag is absent), and a campaign whose pages carry no key at all is a
|
|
33
|
+
// different defect with its own check.
|
|
34
|
+
//
|
|
35
|
+
// Not waivable. The two values in a finding cannot both be right, so there is
|
|
36
|
+
// no bounded human decision to record — the fix is a one-line edit, and the
|
|
37
|
+
// message names the two files and the two values so it is exactly that.
|
|
38
|
+
//
|
|
39
|
+
// Pure: callers read the built HTML and the local scripts each page loads and
|
|
40
|
+
// hand them in. Nothing here touches the filesystem, so the packet doctor path
|
|
41
|
+
// and the built-site-only path (`doctor --built`) drive the same evaluator.
|
|
42
|
+
|
|
43
|
+
export const CAMPAIGN_IDENTITY = "built_output.campaign_identity";
|
|
44
|
+
|
|
45
|
+
// Finding kinds, each with its own issue code under the gate id.
|
|
46
|
+
export const CAMPAIGN_IDENTITY_KINDS = Object.freeze({
|
|
47
|
+
api_key_drift: `${CAMPAIGN_IDENTITY}.api_key_drift`,
|
|
48
|
+
funnel_drift: `${CAMPAIGN_IDENTITY}.funnel_drift`,
|
|
49
|
+
funnel_missing: `${CAMPAIGN_IDENTITY}.funnel_missing`,
|
|
50
|
+
attribution_drift: `${CAMPAIGN_IDENTITY}.attribution_drift`,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const HTML_COMMENT = /<!--[\s\S]*?-->/g;
|
|
54
|
+
const INLINE_SCRIPT = /<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi;
|
|
55
|
+
const SET_ATTRIBUTION_FUNNEL = /setAttribution\s*\(\s*\{[^}]*?\bfunnel\s*:\s*(["'`])([^"'`]*)\1/g;
|
|
56
|
+
|
|
57
|
+
// A page that is a parked copy rather than a served page: `checkout-backup-2`,
|
|
58
|
+
// `upsell-old`, `old-receipt`. The issue names `*-backup-*` and `*-old-*`; the
|
|
59
|
+
// segment-anchored form also catches the marker at either end of a segment so
|
|
60
|
+
// `upsell-old/` and `old-upsell/` are both skipped, while `bold-claims/` and
|
|
61
|
+
// `holdout/` are not. Skipped pages are reported, never silently dropped.
|
|
62
|
+
const PARKED_SEGMENT = /(^|-)(backup|old)(-|$)/i;
|
|
63
|
+
|
|
64
|
+
export function isParkedPage(routeOrFile) {
|
|
65
|
+
const value = String(routeOrFile || "").replace(/\\/g, "/").replace(/\/index\.html?$/i, "").replace(/\.html?$/i, "");
|
|
66
|
+
return value.split("/").some((segment) => PARKED_SEGMENT.test(segment));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function metaContent(html, name) {
|
|
70
|
+
const tag = new RegExp(`<meta\\b(?=[^>]*\\bname=["']${name}["'])([^>]*)>`, "i").exec(html);
|
|
71
|
+
if (!tag) return null;
|
|
72
|
+
const content = /\bcontent=["']([^"']*)["']/i.exec(tag[1]);
|
|
73
|
+
return content ? content[1].trim() : "";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function hasSrcAttribute(attrs) {
|
|
77
|
+
return /(?:^|\s)src\s*=/i.test(attrs || "");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The `src` of every external `<script>` on a page, in document order, with
|
|
82
|
+
* HTML comments stripped first so a commented-out loader is not followed.
|
|
83
|
+
* The caller resolves and reads them (that needs the filesystem) and hands
|
|
84
|
+
* them back as `page.scripts[]`.
|
|
85
|
+
*/
|
|
86
|
+
export function externalScriptSources(html) {
|
|
87
|
+
const source = String(html || "").replace(HTML_COMMENT, "");
|
|
88
|
+
return [...source.matchAll(/<script\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi)].map((match) => match[1]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Inline `<script>` bodies of a page, in document order. External scripts are
|
|
93
|
+
* the caller's to resolve (they need the filesystem); they arrive as
|
|
94
|
+
* `page.scripts[]`.
|
|
95
|
+
*/
|
|
96
|
+
export function inlineScriptBodies(html) {
|
|
97
|
+
const source = String(html || "").replace(HTML_COMMENT, "");
|
|
98
|
+
const bodies = [];
|
|
99
|
+
for (const match of source.matchAll(INLINE_SCRIPT)) {
|
|
100
|
+
if (hasSrcAttribute(match[1])) continue;
|
|
101
|
+
if (match[2].trim()) bodies.push(match[2]);
|
|
102
|
+
}
|
|
103
|
+
return bodies;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Walk a JS object literal from its opening brace and return the string value
|
|
107
|
+
// of a given key at depth 1, skipping strings, template literals and comments
|
|
108
|
+
// so a commented-out `apiKey: "your-api-key"` or a nested
|
|
109
|
+
// `googleMaps: { apiKey: "" }` (both present in the starter config.js) cannot
|
|
110
|
+
// be read as the campaign key. Returns null when the key is absent or its
|
|
111
|
+
// value is not a plain string literal (a computed value is not something a
|
|
112
|
+
// static check can compare).
|
|
113
|
+
function depthOneStringValue(js, openIndex, key) {
|
|
114
|
+
let depth = 0;
|
|
115
|
+
let i = openIndex;
|
|
116
|
+
const n = js.length;
|
|
117
|
+
// Sticky, so a test at position i reads from i without slicing the source.
|
|
118
|
+
const keyPattern = new RegExp(`(?:${key}|["']${key}["'])\\s*:\\s*(["'\`])((?:\\\\.|(?!\\1)[^\\\\])*)\\1`, "y");
|
|
119
|
+
const identifier = /[A-Za-z0-9_$]*/y;
|
|
120
|
+
const tryKey = (at) => {
|
|
121
|
+
keyPattern.lastIndex = at;
|
|
122
|
+
const hit = keyPattern.exec(js);
|
|
123
|
+
return hit ? hit[2] : null;
|
|
124
|
+
};
|
|
125
|
+
while (i < n) {
|
|
126
|
+
const ch = js[i];
|
|
127
|
+
const next = js[i + 1];
|
|
128
|
+
if (ch === "/" && next === "/") {
|
|
129
|
+
const end = js.indexOf("\n", i);
|
|
130
|
+
i = end === -1 ? n : end + 1;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (ch === "/" && next === "*") {
|
|
134
|
+
const end = js.indexOf("*/", i + 2);
|
|
135
|
+
i = end === -1 ? n : end + 2;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
139
|
+
// At depth 1 a string may be the quoted key itself; test before skipping.
|
|
140
|
+
if (depth === 1) {
|
|
141
|
+
const value = tryKey(i);
|
|
142
|
+
if (value != null) return value;
|
|
143
|
+
}
|
|
144
|
+
i += 1;
|
|
145
|
+
while (i < n && js[i] !== ch) {
|
|
146
|
+
if (js[i] === "\\") i += 1;
|
|
147
|
+
i += 1;
|
|
148
|
+
}
|
|
149
|
+
i += 1;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (ch === "{") {
|
|
153
|
+
depth += 1;
|
|
154
|
+
i += 1;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (ch === "}") {
|
|
158
|
+
depth -= 1;
|
|
159
|
+
if (depth <= 0) return null;
|
|
160
|
+
i += 1;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (/[A-Za-z_$]/.test(ch)) {
|
|
164
|
+
// An identifier: test it as the key once (at depth 1, and only when it
|
|
165
|
+
// starts a word) and then step past the whole identifier in one jump
|
|
166
|
+
// rather than re-testing at every character inside it.
|
|
167
|
+
if (depth === 1 && (i === 0 || !/[A-Za-z0-9_$.]/.test(js[i - 1]))) {
|
|
168
|
+
const value = tryKey(i);
|
|
169
|
+
if (value != null) return value;
|
|
170
|
+
}
|
|
171
|
+
identifier.lastIndex = i;
|
|
172
|
+
identifier.exec(js);
|
|
173
|
+
i = Math.max(identifier.lastIndex, i + 1);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
i += 1;
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Every campaign API key a script text declares, with the spelling it used.
|
|
183
|
+
*
|
|
184
|
+
* window.nextConfig = { apiKey: "…" } -> "nextConfig.apiKey"
|
|
185
|
+
* window.nextConfig.apiKey = "…" -> "nextConfig.apiKey"
|
|
186
|
+
* nextCampaign.config({ apiKey: "…" }) -> "nextCampaign.config apiKey" (legacy)
|
|
187
|
+
*/
|
|
188
|
+
export function collectScriptApiKeys(js) {
|
|
189
|
+
const source = String(js || "");
|
|
190
|
+
const keys = [];
|
|
191
|
+
for (const match of source.matchAll(/\bnextConfig\s*=\s*\{/g)) {
|
|
192
|
+
const value = depthOneStringValue(source, match.index + match[0].length - 1, "apiKey");
|
|
193
|
+
if (value != null) keys.push({ source: "nextConfig.apiKey", value: value.trim() });
|
|
194
|
+
}
|
|
195
|
+
for (const match of source.matchAll(/\bnextConfig\s*\.\s*apiKey\s*=\s*(["'`])((?:\\.|(?!\1)[^\\])*)\1/g)) {
|
|
196
|
+
keys.push({ source: "nextConfig.apiKey", value: match[2].trim() });
|
|
197
|
+
}
|
|
198
|
+
for (const match of source.matchAll(/\bnextCampaign\s*\.\s*config\s*\(\s*\{/g)) {
|
|
199
|
+
const value = depthOneStringValue(source, match.index + match[0].length - 1, "apiKey");
|
|
200
|
+
if (value != null) keys.push({ source: "nextCampaign.config apiKey", value: value.trim() });
|
|
201
|
+
}
|
|
202
|
+
return keys;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function collectSetAttributionFunnels(js) {
|
|
206
|
+
return [...String(js || "").matchAll(SET_ATTRIBUTION_FUNNEL)].map((match) => match[2].trim());
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The identity signals one built page carries.
|
|
211
|
+
*
|
|
212
|
+
* @param {{ page_id: string, file?: string|null, content: string,
|
|
213
|
+
* scripts?: Array<{ src: string, file?: string|null, content: string }> }} page
|
|
214
|
+
*/
|
|
215
|
+
export function collectPageIdentity(page) {
|
|
216
|
+
const html = String(page?.content || "");
|
|
217
|
+
const stripped = html.replace(HTML_COMMENT, "");
|
|
218
|
+
// Normalized once: an empty content="" is "no meta", here and on the record.
|
|
219
|
+
const apiKeyMeta = metaContent(stripped, "next-api-key") || null;
|
|
220
|
+
const apiKeys = [];
|
|
221
|
+
if (apiKeyMeta) apiKeys.push({ source: 'meta name="next-api-key"', where: page.file || page.page_id, value: apiKeyMeta });
|
|
222
|
+
const attributions = [];
|
|
223
|
+
|
|
224
|
+
for (const body of inlineScriptBodies(html)) {
|
|
225
|
+
for (const key of collectScriptApiKeys(body)) apiKeys.push({ ...key, source: `inline ${key.source}`, where: page.file || page.page_id });
|
|
226
|
+
for (const funnel of collectSetAttributionFunnels(body)) attributions.push({ where: page.file || page.page_id, value: funnel });
|
|
227
|
+
}
|
|
228
|
+
for (const script of Array.isArray(page?.scripts) ? page.scripts : []) {
|
|
229
|
+
const where = script.file || script.src;
|
|
230
|
+
for (const key of collectScriptApiKeys(script.content)) apiKeys.push({ ...key, source: `${script.src} ${key.source}`, where });
|
|
231
|
+
for (const funnel of collectSetAttributionFunnels(script.content)) attributions.push({ where, value: funnel });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
page_id: page.page_id,
|
|
236
|
+
file: page.file || null,
|
|
237
|
+
api_key_meta: apiKeyMeta,
|
|
238
|
+
api_keys: apiKeys,
|
|
239
|
+
funnel: metaContent(stripped, "next-funnel"),
|
|
240
|
+
page_type: metaContent(stripped, "next-page-type"),
|
|
241
|
+
attributions,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function quote(value) {
|
|
246
|
+
return value == null ? "(none)" : `"${value}"`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function gateBase(subject) {
|
|
250
|
+
return {
|
|
251
|
+
id: CAMPAIGN_IDENTITY,
|
|
252
|
+
scope: CAMPAIGN_IDENTITY,
|
|
253
|
+
waivable: false,
|
|
254
|
+
subject: subject && typeof subject === "object" ? subject : {},
|
|
255
|
+
waiver: null,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Evaluate cross-page campaign identity over built pages.
|
|
261
|
+
*
|
|
262
|
+
* @param {{ subject: object, pages: Array<{ page_id: string, route?: string|null,
|
|
263
|
+
* file?: string|null, content: string,
|
|
264
|
+
* scripts?: Array<{ src: string, file?: string|null, content: string }> }> }} input
|
|
265
|
+
*/
|
|
266
|
+
export function evaluateCampaignIdentity({ subject, pages = [] } = {}) {
|
|
267
|
+
const all = Array.isArray(pages) ? pages : [];
|
|
268
|
+
const skipped = all
|
|
269
|
+
.filter((page) => isParkedPage(page.route ?? page.file ?? page.page_id))
|
|
270
|
+
.map((page) => page.file || page.page_id);
|
|
271
|
+
const scanned = all.filter((page) => !isParkedPage(page.route ?? page.file ?? page.page_id));
|
|
272
|
+
|
|
273
|
+
if (scanned.length === 0) {
|
|
274
|
+
return {
|
|
275
|
+
...gateBase(subject),
|
|
276
|
+
status: "not_applicable",
|
|
277
|
+
code: `${CAMPAIGN_IDENTITY}.not_applicable`,
|
|
278
|
+
reason: "No built page was available to scan; campaign identity becomes mandatory once pages are built.",
|
|
279
|
+
findings: [],
|
|
280
|
+
pages_scanned: 0,
|
|
281
|
+
pages_skipped: skipped,
|
|
282
|
+
identity: null,
|
|
283
|
+
required_actions: [],
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const identities = scanned.map(collectPageIdentity);
|
|
288
|
+
const findings = [];
|
|
289
|
+
|
|
290
|
+
// 1. API key: every observed key, from every source on every page, is one
|
|
291
|
+
// value. Meta and config are compared together on purpose: a page whose
|
|
292
|
+
// meta names one campaign while its config.js names another is drift
|
|
293
|
+
// inside a single page, and the SDK's precedence rule only hides it.
|
|
294
|
+
const keyObservations = identities.flatMap((identity) => identity.api_keys.map((key) => ({ page_id: identity.page_id, ...key })));
|
|
295
|
+
const firstKey = keyObservations[0] || null;
|
|
296
|
+
for (const observation of keyObservations.slice(1)) {
|
|
297
|
+
if (observation.value === firstKey.value) continue;
|
|
298
|
+
findings.push({
|
|
299
|
+
kind: "api_key_drift",
|
|
300
|
+
code: CAMPAIGN_IDENTITY_KINDS.api_key_drift,
|
|
301
|
+
a: { page_id: firstKey.page_id, file: firstKey.where, source: firstKey.source, value: firstKey.value },
|
|
302
|
+
b: { page_id: observation.page_id, file: observation.where, source: observation.source, value: observation.value },
|
|
303
|
+
message: `API key differs across pages: ${firstKey.where} has ${quote(firstKey.value)} (${firstKey.source}) but ${observation.where} has ${quote(observation.value)} (${observation.source}). One of these pages was borrowed from another campaign; make both name the same key.`,
|
|
304
|
+
});
|
|
305
|
+
break; // one finding per kind names the first pair; the rest follow from the same edit
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// 2. next-funnel: one value across pages, and present wherever the SDK is
|
|
309
|
+
// bootstrapped (a page that declares next-page-type is SDK-bound).
|
|
310
|
+
const funnelPages = identities.filter((identity) => identity.funnel);
|
|
311
|
+
const firstFunnel = funnelPages[0] || null;
|
|
312
|
+
for (const identity of funnelPages.slice(1)) {
|
|
313
|
+
if (identity.funnel === firstFunnel.funnel) continue;
|
|
314
|
+
findings.push({
|
|
315
|
+
kind: "funnel_drift",
|
|
316
|
+
code: CAMPAIGN_IDENTITY_KINDS.funnel_drift,
|
|
317
|
+
a: { page_id: firstFunnel.page_id, file: firstFunnel.file || firstFunnel.page_id, value: firstFunnel.funnel },
|
|
318
|
+
b: { page_id: identity.page_id, file: identity.file || identity.page_id, value: identity.funnel },
|
|
319
|
+
message: `next-funnel differs across pages: ${firstFunnel.file || firstFunnel.page_id} has ${quote(firstFunnel.funnel)} but ${identity.file || identity.page_id} has ${quote(identity.funnel)}. Orders from the second page attribute to the other funnel; set the same <meta name="next-funnel"> on both.`,
|
|
320
|
+
});
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
// "Missing" is judged against the campaign, not in the abstract: a
|
|
324
|
+
// campaign that tags no page at all is consistent (the platform fills the
|
|
325
|
+
// campaign name when the tag is absent), and blocking it would be the
|
|
326
|
+
// presence assertion this check deliberately does not make. Once ANY
|
|
327
|
+
// page carries the tag, an SDK-bound page without it attributes its
|
|
328
|
+
// orders differently from the rest, which is drift.
|
|
329
|
+
// One finding, like the other kinds: the repair is the same edit on every
|
|
330
|
+
// untagged page, and a run should not be dominated by it repeated N times.
|
|
331
|
+
if (firstFunnel) {
|
|
332
|
+
for (const identity of identities) {
|
|
333
|
+
if (identity.funnel || !identity.page_type) continue;
|
|
334
|
+
findings.push({
|
|
335
|
+
kind: "funnel_missing",
|
|
336
|
+
code: CAMPAIGN_IDENTITY_KINDS.funnel_missing,
|
|
337
|
+
a: { page_id: identity.page_id, file: identity.file || identity.page_id, value: null },
|
|
338
|
+
b: { page_id: firstFunnel.page_id, file: firstFunnel.file || firstFunnel.page_id, value: firstFunnel.funnel },
|
|
339
|
+
message: `${identity.file || identity.page_id} declares next-page-type=${quote(identity.page_type)} but no <meta name="next-funnel">, while ${firstFunnel.file || firstFunnel.page_id} carries ${quote(firstFunnel.funnel)}. Orders from the untagged page attribute differently from the rest; add the same next-funnel meta.`,
|
|
340
|
+
});
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// 3. setAttribution({ funnel }) agrees with the page's own tag. A page with
|
|
346
|
+
// no tag of its own is held to the campaign's tag (the one every other
|
|
347
|
+
// page agrees on) so a borrowed script on an untagged page still fails.
|
|
348
|
+
for (const identity of identities) {
|
|
349
|
+
const expected = identity.funnel || (firstFunnel ? firstFunnel.funnel : null);
|
|
350
|
+
if (!expected) continue;
|
|
351
|
+
const expectedWhere = identity.funnel ? identity.file || identity.page_id : firstFunnel.file || firstFunnel.page_id;
|
|
352
|
+
for (const attribution of identity.attributions) {
|
|
353
|
+
if (attribution.value === expected) continue;
|
|
354
|
+
findings.push({
|
|
355
|
+
kind: "attribution_drift",
|
|
356
|
+
code: CAMPAIGN_IDENTITY_KINDS.attribution_drift,
|
|
357
|
+
a: { page_id: identity.page_id, file: expectedWhere, value: expected },
|
|
358
|
+
b: { page_id: identity.page_id, file: attribution.where, value: attribution.value },
|
|
359
|
+
message: `setAttribution({ funnel: ${quote(attribution.value)} }) in ${attribution.where} disagrees with next-funnel ${quote(expected)} in ${expectedWhere}. The call overrides the tag, so orders attribute to ${quote(attribution.value)}; change the call to ${quote(expected)} or remove it.`,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const identity = {
|
|
365
|
+
api_key: firstKey ? firstKey.value : null,
|
|
366
|
+
api_key_source: firstKey ? firstKey.source : null,
|
|
367
|
+
funnel: firstFunnel ? firstFunnel.funnel : null,
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
if (findings.length === 0) {
|
|
371
|
+
return {
|
|
372
|
+
...gateBase(subject),
|
|
373
|
+
status: "pass",
|
|
374
|
+
code: `${CAMPAIGN_IDENTITY}.pass`,
|
|
375
|
+
reason: `All ${scanned.length} built page(s) agree on campaign identity (API key ${identity.api_key ? "present" : "not declared"}, next-funnel ${quote(identity.funnel)}).`,
|
|
376
|
+
findings: [],
|
|
377
|
+
pages_scanned: scanned.length,
|
|
378
|
+
pages_skipped: skipped,
|
|
379
|
+
identity,
|
|
380
|
+
required_actions: [],
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
...gateBase(subject),
|
|
386
|
+
status: "blocked",
|
|
387
|
+
code: CAMPAIGN_IDENTITY,
|
|
388
|
+
// Short on purpose; the per-edit prose is findings[].message, which is
|
|
389
|
+
// what each doctor error carries. A reason that concatenates them is
|
|
390
|
+
// truncated by every single-line renderer.
|
|
391
|
+
reason: `${findings.length} campaign identity drift finding(s) across ${scanned.length} built page(s) (${[...new Set(findings.map((finding) => finding.kind))].join(", ")}); see findings[] for the two files and two values of each.`,
|
|
392
|
+
findings,
|
|
393
|
+
pages_scanned: scanned.length,
|
|
394
|
+
pages_skipped: skipped,
|
|
395
|
+
identity,
|
|
396
|
+
required_actions: [
|
|
397
|
+
{
|
|
398
|
+
id: "repair_identity",
|
|
399
|
+
kind: "edit",
|
|
400
|
+
command: null,
|
|
401
|
+
description: `Make every page name the same campaign: ${findings.map((finding) => `${finding.b?.file || finding.a.file} (${finding.kind.replace(/_/g, " ")})`).join("; ")}. Not waivable: two campaign identities on one funnel cannot both be intended.`,
|
|
402
|
+
},
|
|
403
|
+
],
|
|
404
|
+
};
|
|
405
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Campaign workspace: where a Build Packet's sidecars live, resolved once.
|
|
2
|
+
//
|
|
3
|
+
// Two roots matter. The TARGET REPO (`packet.assembly.target_repo` resolved
|
|
4
|
+
// against the packet's directory, else that directory) carries the build
|
|
5
|
+
// sidecars under `.campaign-runtime/` and the local QA verdicts under
|
|
6
|
+
// `qa-output/`; the packet's own directory (`baseDir`) carries what is written
|
|
7
|
+
// beside the packet (run records, the committed QA verdict sidecar). They
|
|
8
|
+
// coincide for a packet at the target root and diverge for `prepare-build
|
|
9
|
+
// --out` elsewhere — exactly when a stage spelling the rule for itself drifts.
|
|
10
|
+
// A leaf: node built-ins and the sidecar leaves only.
|
|
11
|
+
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { dirname, join, resolve } from "node:path";
|
|
14
|
+
import { DOCTOR_SIDECAR_REL_PATH } from "./doctor-sidecar.mjs";
|
|
15
|
+
import { absentOrMalformed, sameFile } from "./fs-identity.mjs";
|
|
16
|
+
|
|
17
|
+
export const BUILD_CONTEXT_REL_PATH = ".campaign-runtime/build-context.json";
|
|
18
|
+
export const ASSEMBLY_REPORT_REL_PATH = ".campaign-runtime/assembly-report.json";
|
|
19
|
+
export const QA_OUTPUT_REL_PATH = "qa-output";
|
|
20
|
+
|
|
21
|
+
// The target repo a packet builds into.
|
|
22
|
+
export function targetRepoFor(packetPath, packet) {
|
|
23
|
+
const packetDir = dirname(resolve(packetPath));
|
|
24
|
+
const declared = packet?.assembly?.target_repo;
|
|
25
|
+
return typeof declared === "string" && declared.trim() ? resolve(packetDir, declared) : packetDir;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The default location of every build sidecar a target repo carries.
|
|
29
|
+
export function campaignSidecarPaths(targetRepo) {
|
|
30
|
+
return {
|
|
31
|
+
contextPath: join(targetRepo, BUILD_CONTEXT_REL_PATH),
|
|
32
|
+
reportPath: join(targetRepo, ASSEMBLY_REPORT_REL_PATH),
|
|
33
|
+
doctorOutPath: join(targetRepo, DOCTOR_SIDECAR_REL_PATH),
|
|
34
|
+
qaOutputDir: join(targetRepo, QA_OUTPUT_REL_PATH),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The report a printed remediation must name, or null. `checkpoint waive`,
|
|
39
|
+
// `theme waive` and `polish capture` all default to the packet-inferred
|
|
40
|
+
// <target repo>/.campaign-runtime/assembly-report.json, so a command printed
|
|
41
|
+
// for a report that came from anywhere else (--report, or a Build Context
|
|
42
|
+
// binding) has to carry `--report <path>` or it acts on a different report —
|
|
43
|
+
// possibly one that does not exist. Quiet in the common case: null when the
|
|
44
|
+
// report IS that default, so the printed command stays short; the path when
|
|
45
|
+
// it is not, or when the target repo is unknown and the default cannot be
|
|
46
|
+
// ruled out. Doctor's report and the QA runner's both print through this.
|
|
47
|
+
export function explicitReportPath(reportPath, targetRepo) {
|
|
48
|
+
if (typeof reportPath !== "string" || !reportPath) return null;
|
|
49
|
+
const inferred = typeof targetRepo === "string" && targetRepo ? campaignSidecarPaths(targetRepo).reportPath : null;
|
|
50
|
+
return reportPath !== inferred ? reportPath : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Best-effort read of the Build Context for the report binding only: a
|
|
54
|
+
// missing or malformed context binds nothing. A caller that must refuse a
|
|
55
|
+
// malformed context reads it again strictly. Any other read failure (a
|
|
56
|
+
// permission error, a directory where the file should be) is not absence:
|
|
57
|
+
// binding the default report over it would record a stage outcome into a
|
|
58
|
+
// report the campaign is not bound to, so it reaches the caller.
|
|
59
|
+
function readContextForBinding(contextPath) {
|
|
60
|
+
if (!contextPath) return null;
|
|
61
|
+
try {
|
|
62
|
+
const parsed = JSON.parse(readFileSync(contextPath, "utf8"));
|
|
63
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (absentOrMalformed(error)) return null;
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The one resolver. `contextPath` / `reportPath` / `doctorOutPath` are the
|
|
71
|
+
// operator's explicit choices, resolved by the caller from whichever flag it
|
|
72
|
+
// owns: a string is used as given, `null` switches that sidecar off (no
|
|
73
|
+
// context to bind from; `reportPath: null`), `undefined` derives the default.
|
|
74
|
+
//
|
|
75
|
+
// `followContextPointer` is required on purpose. The Build Context records
|
|
76
|
+
// where prepare-build wrote the report (`--report-out`), relative to the
|
|
77
|
+
// target repo. A stage that follows it acts on the report the campaign is
|
|
78
|
+
// bound to; one that does not acts on the default sidecar. Both are
|
|
79
|
+
// legitimate (doctor's stage write-back must not restate its outcome into a
|
|
80
|
+
// report it did not inspect), but a stage that never said which is how the QA
|
|
81
|
+
// stage came to be recorded into a report `next` never reads. Say it.
|
|
82
|
+
//
|
|
83
|
+
// A context binds a report for the packet it names: prepare-build writes
|
|
84
|
+
// `packet_path` beside `report_path`, and when two packets of one campaign
|
|
85
|
+
// share a target repo the default context belongs to whichever ran last, so
|
|
86
|
+
// a pointer from a context naming another packet is not followed. A context
|
|
87
|
+
// naming no packet binds by location, as it always did.
|
|
88
|
+
export function resolveCampaignWorkspace(packetPath, {
|
|
89
|
+
packet = undefined,
|
|
90
|
+
contextPath = undefined,
|
|
91
|
+
reportPath = undefined,
|
|
92
|
+
doctorOutPath = undefined,
|
|
93
|
+
followContextPointer,
|
|
94
|
+
} = {}) {
|
|
95
|
+
if (typeof followContextPointer !== "boolean") {
|
|
96
|
+
throw new TypeError("resolveCampaignWorkspace requires followContextPointer: true (act on the report the Build Context binds) or false (act on the default sidecar).");
|
|
97
|
+
}
|
|
98
|
+
const absolutePacketPath = resolve(packetPath);
|
|
99
|
+
const loadedPacket = packet === undefined ? JSON.parse(readFileSync(absolutePacketPath, "utf8")) : packet;
|
|
100
|
+
const baseDir = dirname(absolutePacketPath);
|
|
101
|
+
const targetRepo = targetRepoFor(absolutePacketPath, loadedPacket);
|
|
102
|
+
const defaults = campaignSidecarPaths(targetRepo);
|
|
103
|
+
const resolvedContextPath = contextPath === undefined ? defaults.contextPath : contextPath;
|
|
104
|
+
const context = followContextPointer ? readContextForBinding(resolvedContextPath) : null;
|
|
105
|
+
const recorded = typeof context?.report_path === "string" && context.report_path.trim() ? context.report_path.trim() : null;
|
|
106
|
+
const named = typeof context?.packet_path === "string" && context.packet_path.trim() ? context.packet_path.trim() : null;
|
|
107
|
+
// Both sides must be on disk: a pointer naming a packet that is not there
|
|
108
|
+
// cannot be shown to name this one.
|
|
109
|
+
const bound = recorded && (!named || sameFile(resolve(targetRepo, named), absolutePacketPath, { requireExisting: true }));
|
|
110
|
+
|
|
111
|
+
const resolvedReportPath = reportPath !== undefined
|
|
112
|
+
? reportPath
|
|
113
|
+
: bound
|
|
114
|
+
? resolve(targetRepo, recorded)
|
|
115
|
+
: defaults.reportPath;
|
|
116
|
+
return {
|
|
117
|
+
packet: loadedPacket,
|
|
118
|
+
packetPath: absolutePacketPath,
|
|
119
|
+
baseDir,
|
|
120
|
+
targetRepo,
|
|
121
|
+
contextPath: resolvedContextPath,
|
|
122
|
+
reportPath: resolvedReportPath,
|
|
123
|
+
defaultReportPath: defaults.reportPath,
|
|
124
|
+
doctorOutPath: doctorOutPath === undefined ? defaults.doctorOutPath : doctorOutPath,
|
|
125
|
+
qaOutputDir: defaults.qaOutputDir,
|
|
126
|
+
};
|
|
127
|
+
}
|