@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
package/src/consent.mjs
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// Run Telemetry consent — machine/user-level opt-OUT, resolved through one
|
|
2
|
+
// shared resolver that EVERY remitting command calls (not a start-only
|
|
3
|
+
// prompt). See docs/workflow-findings-sidecar.md (Consent).
|
|
4
|
+
//
|
|
5
|
+
// Consent gates REMIT only. Local capture (the Run Record) always happens.
|
|
6
|
+
// Resolution precedence: env override > user-level config file > default.
|
|
7
|
+
// The DEFAULT is ON for the canonical NEXT endpoint only (announced at remit
|
|
8
|
+
// time with the endpoint and the opt-out command); any other endpoint stays
|
|
9
|
+
// fail-closed until explicitly consented. An unknown env value fails closed
|
|
10
|
+
// (no remit) with a warning, never a silent guess; a malformed config file
|
|
11
|
+
// also resolves OFF — an unreadable prior choice is never overridden by the
|
|
12
|
+
// default.
|
|
13
|
+
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
|
|
18
|
+
export const TELEMETRY_ENV_VAR = "CAMPAIGNS_OS_TELEMETRY";
|
|
19
|
+
export const TELEMETRY_CONFIG_SCHEMA = "campaigns-os-telemetry-config/v0";
|
|
20
|
+
const PACKAGE_NAME = "@nextcommerce/campaigns-os";
|
|
21
|
+
|
|
22
|
+
// Env override accepts exactly these tokens. Anything else is "unknown" and
|
|
23
|
+
// fails closed — strictness is the point (an env typo must not silently remit).
|
|
24
|
+
const ENV_TRUE = new Set(["1", "true", "on"]);
|
|
25
|
+
const ENV_FALSE = new Set(["0", "false", "off"]);
|
|
26
|
+
|
|
27
|
+
function isNonEmptyString(value) {
|
|
28
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function normalizeConsentScope(value) {
|
|
32
|
+
if (!isNonEmptyString(value)) return null;
|
|
33
|
+
const raw = value.trim();
|
|
34
|
+
const normalizeUrl = (input) => {
|
|
35
|
+
try {
|
|
36
|
+
const url = new URL(input);
|
|
37
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
38
|
+
return `${url.origin}${path}`;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const direct = normalizeUrl(raw);
|
|
44
|
+
if (direct) return direct;
|
|
45
|
+
if (!/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(raw)) {
|
|
46
|
+
return normalizeUrl(`https://${raw}`);
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The canonical NEXT remit endpoint. Default-on consent applies ONLY to this
|
|
52
|
+
// scope; any other proxy base needs an explicit operator choice. Stored in
|
|
53
|
+
// NORMALIZED form (and asserted at module load) so a scheme change, typo, or
|
|
54
|
+
// trailing slash here cannot silently flip the default; compare requested
|
|
55
|
+
// scopes against this export, never against a string literal.
|
|
56
|
+
export const CANONICAL_REMIT_SCOPE = normalizeConsentScope("https://campaign-map.nextcommerce.com");
|
|
57
|
+
if (!CANONICAL_REMIT_SCOPE) {
|
|
58
|
+
throw new Error("CANONICAL_REMIT_SCOPE failed to normalize; default-on consent would misfire.");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function assertConsentState(state) {
|
|
62
|
+
if (state !== "on" && state !== "off") {
|
|
63
|
+
throw new Error('Telemetry consent state must be "on" or "off".');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function defaultWarn(message) {
|
|
68
|
+
process.stderr.write(`${message}\n`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function configState(state) {
|
|
72
|
+
assertConsentState(state);
|
|
73
|
+
return state === "on";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Parse the CAMPAIGNS_OS_TELEMETRY env value.
|
|
78
|
+
* Returns `{ state: "on"|"off"|null, present, unknown }`.
|
|
79
|
+
* - absent/empty: `{ state: null, present: false, unknown: false }`
|
|
80
|
+
* - recognized: `{ state, present: true, unknown: false }`
|
|
81
|
+
* - anything else:`{ state: null, present: true, unknown: true }` (fail closed)
|
|
82
|
+
*/
|
|
83
|
+
export function parseEnvConsent(raw) {
|
|
84
|
+
if (raw == null) return { state: null, present: false, unknown: false };
|
|
85
|
+
const normalized = String(raw).trim().toLowerCase();
|
|
86
|
+
if (normalized === "") return { state: null, present: false, unknown: false };
|
|
87
|
+
if (ENV_TRUE.has(normalized)) return { state: "on", present: true, unknown: false };
|
|
88
|
+
if (ENV_FALSE.has(normalized)) return { state: "off", present: true, unknown: false };
|
|
89
|
+
return { state: null, present: true, unknown: true };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* User-level config path. Consent belongs to the operator/machine, not the
|
|
94
|
+
* campaign. Honors XDG_CONFIG_HOME, else ~/.config/campaigns-os/config.json.
|
|
95
|
+
*/
|
|
96
|
+
export function resolveConfigPath({ env = process.env, home = homedir() } = {}) {
|
|
97
|
+
const base = isNonEmptyString(env.XDG_CONFIG_HOME) ? env.XDG_CONFIG_HOME : join(home, ".config");
|
|
98
|
+
return join(base, "campaigns-os", "config.json");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Read the config file. Never throws — a missing file is `{ ok: false }`, a
|
|
103
|
+
* malformed file is `{ malformed: true }` (the resolver treats that as OFF).
|
|
104
|
+
*/
|
|
105
|
+
export function readConfig(configPath) {
|
|
106
|
+
if (!isNonEmptyString(configPath) || !existsSync(configPath)) {
|
|
107
|
+
return { ok: false, config: null, malformed: false };
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const config = JSON.parse(readFileSync(configPath, "utf8"));
|
|
111
|
+
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
|
112
|
+
return { ok: false, config: null, malformed: true };
|
|
113
|
+
}
|
|
114
|
+
return { ok: true, config, malformed: false };
|
|
115
|
+
} catch {
|
|
116
|
+
return { ok: false, config: null, malformed: true };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function fileConsentState(config) {
|
|
121
|
+
const telemetry = config?.telemetry;
|
|
122
|
+
if (telemetry && typeof telemetry === "object") {
|
|
123
|
+
if (telemetry.enabled === true) return "on";
|
|
124
|
+
if (telemetry.enabled === false) return "off";
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function fileConsentScope(config) {
|
|
130
|
+
return normalizeConsentScope(config?.telemetry?.scope);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function scopeMatches(storedScope, requestedScope) {
|
|
134
|
+
const requested = normalizeConsentScope(requestedScope);
|
|
135
|
+
if (!requested) return true;
|
|
136
|
+
const stored = normalizeConsentScope(storedScope);
|
|
137
|
+
return Boolean(stored && stored === requested);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Persist consent at user level. Records its own schema_version, the package
|
|
142
|
+
* name, the proxy/endpoint scope (ON only — an OFF record is unscoped), a
|
|
143
|
+
* timestamp, and the value source — so the decision is auditable. A named
|
|
144
|
+
* `proxyBase` that is not a URL is refused for either state. Returns
|
|
145
|
+
* `{ configPath, config }`.
|
|
146
|
+
*/
|
|
147
|
+
export function writeConsentConfig(state, {
|
|
148
|
+
configPath = resolveConfigPath(),
|
|
149
|
+
proxyBase = null,
|
|
150
|
+
source = "telemetry-command",
|
|
151
|
+
now = new Date(),
|
|
152
|
+
} = {}) {
|
|
153
|
+
const enabled = configState(state);
|
|
154
|
+
const normalizedScope = normalizeConsentScope(proxyBase);
|
|
155
|
+
// A named base that does not normalize is refused for either state: on an
|
|
156
|
+
// ON grant it would be stored as `scope: null`, which matches no endpoint
|
|
157
|
+
// and turns the grant into a silent OFF at every remit; on an OFF record
|
|
158
|
+
// it would be dropped without a word, hiding a typo the caller meant to
|
|
159
|
+
// be honoured. (An absent base still writes `scope: null`: an unscoped
|
|
160
|
+
// record, matched only by a caller that names no endpoint.)
|
|
161
|
+
if (isNonEmptyString(proxyBase) && !normalizedScope) {
|
|
162
|
+
throw new Error(`Telemetry consent scope is not a URL: ${proxyBase.trim()}`);
|
|
163
|
+
}
|
|
164
|
+
// An OFF choice is machine-wide — it disables remit to every endpoint — so
|
|
165
|
+
// the record carries no scope: `enabled: false, scope: <url>` would read
|
|
166
|
+
// as a still-granted endpoint on disk and in `telemetry status`.
|
|
167
|
+
const scope = enabled ? normalizedScope : null;
|
|
168
|
+
const config = {
|
|
169
|
+
schema_version: TELEMETRY_CONFIG_SCHEMA,
|
|
170
|
+
package: PACKAGE_NAME,
|
|
171
|
+
telemetry: {
|
|
172
|
+
enabled,
|
|
173
|
+
scope,
|
|
174
|
+
updated_at: now.toISOString(),
|
|
175
|
+
source,
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
179
|
+
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
180
|
+
return { configPath, config };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The command that records file consent for one endpoint. The canonical
|
|
185
|
+
* endpoint needs no flag; any other base is named explicitly so the grant is
|
|
186
|
+
* visibly scoped to it.
|
|
187
|
+
*/
|
|
188
|
+
export function scopedConsentCommand(scope) {
|
|
189
|
+
const normalized = normalizeConsentScope(scope);
|
|
190
|
+
if (!normalized || normalized === CANONICAL_REMIT_SCOPE) return "campaigns-os telemetry on";
|
|
191
|
+
return `campaigns-os telemetry on --proxy-base ${shellArgument(normalized)}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// The generated command is meant to be pasted into a shell. A URL made of
|
|
195
|
+
// the characters below passes through every common shell as one word; any
|
|
196
|
+
// other character (the brackets of an IPv6 loopback such as http://[::1]:4399
|
|
197
|
+
// glob in zsh) gets single quotes so the copy still grants the endpoint.
|
|
198
|
+
const SHELL_SAFE_ARGUMENT = /^[A-Za-z0-9._~:/@%+=,-]+$/;
|
|
199
|
+
|
|
200
|
+
function shellArgument(value) {
|
|
201
|
+
if (SHELL_SAFE_ARGUMENT.test(value)) return value;
|
|
202
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The shared resolver every remitting command calls. Returns
|
|
207
|
+
* `{ state: "on"|"off", source: "env"|"file"|"default", resolved }`.
|
|
208
|
+
* With no env and no usable file, the CANONICAL endpoint resolves to the
|
|
209
|
+
* announced default: `{ state: "on", source: "default", resolved: true,
|
|
210
|
+
* default_on: true }`. `resolved` is false only for the remaining
|
|
211
|
+
* fail-closed defaults — a malformed config file, a scope mismatch, or a
|
|
212
|
+
* non-canonical endpoint with no explicit choice — where an interactive
|
|
213
|
+
* command may then prompt to set it.
|
|
214
|
+
*/
|
|
215
|
+
export function resolveConsent({
|
|
216
|
+
env = process.env,
|
|
217
|
+
configPath = resolveConfigPath(),
|
|
218
|
+
proxyBase = null,
|
|
219
|
+
warn = defaultWarn,
|
|
220
|
+
} = {}) {
|
|
221
|
+
const raw = env[TELEMETRY_ENV_VAR];
|
|
222
|
+
const parsed = parseEnvConsent(raw);
|
|
223
|
+
// Normalized once; both the file-scope branch and the default branch
|
|
224
|
+
// compare against this same value.
|
|
225
|
+
const requestedScope = normalizeConsentScope(proxyBase);
|
|
226
|
+
if (parsed.unknown) {
|
|
227
|
+
warn(`[campaigns-os] ${TELEMETRY_ENV_VAR}="${raw}" is not a recognized value (use 1|true|on|0|false|off). Telemetry remit is OFF for safety.`);
|
|
228
|
+
return { state: "off", source: "env", resolved: true };
|
|
229
|
+
}
|
|
230
|
+
if (parsed.state) {
|
|
231
|
+
// The env override is a machine-wide answer with no scope: it applies to
|
|
232
|
+
// whatever endpoint the command names. When that is not the canonical
|
|
233
|
+
// endpoint, say so — the operator gets the persistent, scoped route.
|
|
234
|
+
if (parsed.state === "on" && requestedScope && requestedScope !== CANONICAL_REMIT_SCOPE) {
|
|
235
|
+
warn(`[campaigns-os] ${TELEMETRY_ENV_VAR}=${raw} bypasses consent scope checking: remitting to ${requestedScope} because the env override is set, not because this endpoint was consented to. To consent to it on this machine instead, run: ${scopedConsentCommand(requestedScope)}`);
|
|
236
|
+
return { state: "on", source: "env", resolved: true, scope: requestedScope, scope_bypassed: true };
|
|
237
|
+
}
|
|
238
|
+
if (parsed.state === "on") {
|
|
239
|
+
return { state: "on", source: "env", resolved: true, scope: requestedScope || CANONICAL_REMIT_SCOPE };
|
|
240
|
+
}
|
|
241
|
+
return { state: "off", source: "env", resolved: true };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const { ok, config, malformed } = readConfig(configPath);
|
|
245
|
+
if (malformed) {
|
|
246
|
+
warn(`[campaigns-os] telemetry config at ${configPath} is malformed; treating telemetry as OFF.`);
|
|
247
|
+
return { state: "off", source: "default", resolved: false };
|
|
248
|
+
}
|
|
249
|
+
if (ok) {
|
|
250
|
+
const fileState = fileConsentState(config);
|
|
251
|
+
if (fileState === "off") return { state: "off", source: "file", resolved: true };
|
|
252
|
+
if (fileState === "on") {
|
|
253
|
+
const storedScope = fileConsentScope(config);
|
|
254
|
+
if (scopeMatches(storedScope, requestedScope)) {
|
|
255
|
+
return { state: "on", source: "file", resolved: true, scope: storedScope };
|
|
256
|
+
}
|
|
257
|
+
warn(`[campaigns-os] telemetry consent at ${configPath} is scoped to ${storedScope || "(unscoped)"}, not ${requestedScope}; treating telemetry as OFF for this endpoint. To consent to it on this machine, run: ${scopedConsentCommand(requestedScope)}`);
|
|
258
|
+
return {
|
|
259
|
+
state: "off",
|
|
260
|
+
source: "default",
|
|
261
|
+
resolved: false,
|
|
262
|
+
scope_mismatch: true,
|
|
263
|
+
consent_scope: storedScope,
|
|
264
|
+
requested_scope: requestedScope,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// No env, no usable file → default ON for the canonical NEXT endpoint.
|
|
270
|
+
// Run Telemetry is how the toolchain improves (capture is always local;
|
|
271
|
+
// this gates only the remit), so an operator who never expressed a choice
|
|
272
|
+
// shares by default and opts out with `campaigns-os telemetry off` or
|
|
273
|
+
// CAMPAIGNS_OS_TELEMETRY=off. The grant is exactly two cases:
|
|
274
|
+
// 1. proxyBase ABSENT (null/undefined/empty) — remitting commands fall
|
|
275
|
+
// back to the canonical default endpoint, so the scope IS canonical;
|
|
276
|
+
// 2. proxyBase normalizes to the canonical scope.
|
|
277
|
+
// A non-empty proxyBase that fails to normalize is NOT canonical — it
|
|
278
|
+
// stays fail-closed like any other unapproved endpoint. The remit-time
|
|
279
|
+
// announcement names the endpoint explicitly.
|
|
280
|
+
// (An absent proxyBase always normalizes to a null requestedScope, so the
|
|
281
|
+
// two cases below are disjoint: absent → canonical fallback; present →
|
|
282
|
+
// must normalize to the canonical scope exactly.)
|
|
283
|
+
const proxyBaseAbsent = !isNonEmptyString(proxyBase);
|
|
284
|
+
if (proxyBaseAbsent || requestedScope === CANONICAL_REMIT_SCOPE) {
|
|
285
|
+
return { state: "on", source: "default", resolved: true, default_on: true, scope: CANONICAL_REMIT_SCOPE };
|
|
286
|
+
}
|
|
287
|
+
return { state: "off", source: "default", resolved: false };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Announce default-on telemetry at most once per process, naming the exact
|
|
291
|
+
// endpoint so the operator knows where the data goes before it goes there.
|
|
292
|
+
// Once-per-PROCESS is the designed semantic: the CLI is one-shot, so this is
|
|
293
|
+
// once per command for normal usage, while a long-lived harness importing
|
|
294
|
+
// remitting commands directly gets one announcement per process instead of
|
|
295
|
+
// stderr noise on every record. The injectable `write` keeps the contract
|
|
296
|
+
// testable without exposing the latch.
|
|
297
|
+
let defaultOnAnnounced = false;
|
|
298
|
+
export function announceDefaultOnTelemetry(endpoint, { write = (line) => process.stderr.write(line) } = {}) {
|
|
299
|
+
if (defaultOnAnnounced) return false;
|
|
300
|
+
defaultOnAnnounced = true;
|
|
301
|
+
write(`[campaigns-os] Run telemetry is ON by default: anonymized run records are sent to ${endpoint || CANONICAL_REMIT_SCOPE} to improve templates, tooling, and guidance. Disable with \`campaigns-os telemetry off\` or CAMPAIGNS_OS_TELEMETRY=off.\n`);
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function defaultAsk(question) {
|
|
306
|
+
const { createInterface } = await import("node:readline/promises");
|
|
307
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
308
|
+
try {
|
|
309
|
+
return await rl.question(question);
|
|
310
|
+
} finally {
|
|
311
|
+
rl.close();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The up-front, ask-once prompt. Only prompts when interactive (TTY) AND no
|
|
317
|
+
* resolved state exists yet — which, under default-on for the canonical
|
|
318
|
+
* endpoint, means the prompt effectively fires only for NON-canonical remit
|
|
319
|
+
* scopes (staging/self-hosted) that need an explicit yes. Persists the
|
|
320
|
+
* answer so later runs don't re-ask. Non-interactive unresolved callers stay
|
|
321
|
+
* fail-closed OFF without blocking — telemetry never blocks a build. `ask`
|
|
322
|
+
* is injectable for tests.
|
|
323
|
+
*
|
|
324
|
+
* The [Y/n] capitalization makes "on" the default for an empty Enter, matching
|
|
325
|
+
* the design's plainly-worded prompt.
|
|
326
|
+
*/
|
|
327
|
+
export async function promptAndPersistConsent({
|
|
328
|
+
configPath = resolveConfigPath(),
|
|
329
|
+
env = process.env,
|
|
330
|
+
proxyBase = null,
|
|
331
|
+
isTTY = Boolean(process.stdin && process.stdin.isTTY),
|
|
332
|
+
ask = defaultAsk,
|
|
333
|
+
now = new Date(),
|
|
334
|
+
} = {}) {
|
|
335
|
+
// An explicit env/file decision is never overridden by a prompt.
|
|
336
|
+
const existing = resolveConsent({ env, configPath, proxyBase, warn: () => {} });
|
|
337
|
+
if (existing.resolved) return { ...existing, prompted: false };
|
|
338
|
+
if (!isTTY) return { state: "off", source: "default", resolved: false, prompted: false };
|
|
339
|
+
|
|
340
|
+
const answer = await ask(
|
|
341
|
+
"Campaigns OS can send build telemetry to Next Commerce to improve templates, tools, and guidance. Share telemetry from this machine? [Y/n] (change any time): ",
|
|
342
|
+
);
|
|
343
|
+
const normalized = String(answer || "").trim().toLowerCase();
|
|
344
|
+
const state = (normalized === "" || ["y", "yes", "1", "true", "on"].includes(normalized)) ? "on" : "off";
|
|
345
|
+
writeConsentConfig(state, { configPath, proxyBase, source: "prompt", now });
|
|
346
|
+
return { state, source: "file", resolved: true, prompted: true };
|
|
347
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// Rendered-output content-residue scan + proof-attestation gate.
|
|
2
|
+
//
|
|
3
|
+
// Scans BUILT campaign output (_site HTML), not frontmatter: layout- or
|
|
4
|
+
// script-rendered proof/urgency chrome only exists after the build, which is
|
|
5
|
+
// why frontmatter-level checks missed the hardcoded rating/countdown chrome.
|
|
6
|
+
//
|
|
7
|
+
// Pattern provenance: the generic anti-pattern classes distilled from the
|
|
8
|
+
// 2026-07 winning-campaign content audit (invented counts, fabricated
|
|
9
|
+
// verified-buyer chrome, fictional bylines, borrowed authority, science
|
|
10
|
+
// theater, scarcity theater, fake comparisons, unlinked press marquees).
|
|
11
|
+
// Only GENERIC patterns and the public starter-template demo strings live
|
|
12
|
+
// here; merchant-specific residue fingerprints are deliberately not carried
|
|
13
|
+
// in this public package.
|
|
14
|
+
//
|
|
15
|
+
// Posture (fail closed, two tiers):
|
|
16
|
+
// - hard: the literal needs-merchant-input marker, and urgency chrome
|
|
17
|
+
// rendered without verified offer urgency — blockers (collect-inputs).
|
|
18
|
+
// - review: anti-pattern hits and demo/placeholder residue — warnings that
|
|
19
|
+
// feed the review/attestation queue; a hit means "remove or demand
|
|
20
|
+
// brief/source evidence", never "make it more plausible".
|
|
21
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
22
|
+
import { join, relative } from "node:path";
|
|
23
|
+
|
|
24
|
+
export const NEEDS_INPUT_MARKER = /NEEDS[ -]MERCHANT[ -]INPUT/i;
|
|
25
|
+
|
|
26
|
+
// Countdown chrome as rendered by the starter presell templates.
|
|
27
|
+
export const COUNTDOWN_CHROME = /data-countdown-(?:hrs|min|sec)/;
|
|
28
|
+
|
|
29
|
+
// Bracket-style demo stubs ("[Product]", "[Author Name]", "[Real Review
|
|
30
|
+
// Proof Goes Here]"). Requires a capitalized first word so CSS/JS attribute
|
|
31
|
+
// selectors ([data-x], [href]) never match.
|
|
32
|
+
export const BRACKET_STUB = /\[(?:[A-Z][A-Za-z0-9/&().,'’-]*)(?:\s+[A-Za-z0-9/&().,'’-]+)*\]/;
|
|
33
|
+
|
|
34
|
+
// Public starter-template demo values that must never survive into a built
|
|
35
|
+
// campaign. The templates deliberately keep realistic, proof-shaped demo
|
|
36
|
+
// content (demo teaches the SHAPE of typical content); this exact-string list
|
|
37
|
+
// is what keeps that demo content from silently shipping — a hit means an
|
|
38
|
+
// unreplaced demo slot or a stale template.
|
|
39
|
+
export const DEMO_RESIDUE_TERMS = Object.freeze([
|
|
40
|
+
"Sarah Mitchell",
|
|
41
|
+
"Wellness Insider",
|
|
42
|
+
"10 Reasons Why You Need This",
|
|
43
|
+
"10 Reasons Why Thousands Are Switching",
|
|
44
|
+
"Backed by Over 1,200 Five-Star Reviews",
|
|
45
|
+
"Trusted by 50,000+ Happy Customers",
|
|
46
|
+
"Doctor-Formulated for Real Results",
|
|
47
|
+
"Made in an FDA-Registered Facility",
|
|
48
|
+
"Lowest Price of the Year",
|
|
49
|
+
"DEAL ENDING IN:",
|
|
50
|
+
"1,247 reviews",
|
|
51
|
+
"48,312",
|
|
52
|
+
"48,000+",
|
|
53
|
+
"Sandra M.",
|
|
54
|
+
"Derek H.",
|
|
55
|
+
"Sell-Out Risk: High",
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
// Generic anti-pattern classes (review tier). Each id is stable so the
|
|
59
|
+
// attestation/review UX can key on it.
|
|
60
|
+
export const CONTENT_ANTI_PATTERNS = Object.freeze([
|
|
61
|
+
{
|
|
62
|
+
id: "invented_counts",
|
|
63
|
+
antiPattern: 1,
|
|
64
|
+
rule: "Counts/ratings/percent-recommend claims require a real, brief-sourced basis.",
|
|
65
|
+
// Count branch requires a proof-scaled number (comma groups, 4+ digits, or
|
|
66
|
+
// a trailing +) so ordinary commerce quantities ("Choose 3 pairs and
|
|
67
|
+
// save") never enter the review queue; "pairs"/"sold" dropped as nouns
|
|
68
|
+
// for the same reason. Small invented counts are a knowingly accepted gap.
|
|
69
|
+
regex: /\b(?:\d{1,3}(?:,\d{3})+|\d{4,}|\d+\+)\s+(?:reviews?|ratings?|customers?|users?|famil(?:y|ies)|wearers?|people)\b|\b(?:4\.[5-9]|5\.0)\s*(?:\/\s*5|stars?)|\b(?:9[0-9]|100)%\b[^<]{0,60}\b(?:recommend|reported|said|would)\b/i,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "verified_buyer_chrome",
|
|
73
|
+
antiPattern: 2,
|
|
74
|
+
rule: "'Verified' labels must resolve to a real approved review source.",
|
|
75
|
+
regex: /Verified\s+(?:Buyer|Customer|Purchase)|What\s+(?:Our\s+)?Customers\s+(?:Think|Say)|Real\s+(?:People|Customers)[^<]{0,20}Real\s+(?:Results|Relief)|5[- ]Star\s+Review/i,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: "byline_persona",
|
|
79
|
+
antiPattern: 3,
|
|
80
|
+
rule: "Advertorial identities must be real, brief-supplied, and authorized.",
|
|
81
|
+
regex: /Mom\s+of\s+Two|Consumer\s+Report|Review\s+Team|Wellness\s+Educator|Licensed\s+(?:Physiotherapist|Professional)/i,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: "borrowed_authority",
|
|
85
|
+
antiPattern: 4,
|
|
86
|
+
rule: "Expert/clinician/institution references require name, credential, permission, and source.",
|
|
87
|
+
regex: /\bDr\.\s+[A-Z]|\bM\.?D\.?\b|doctor[- ]recommended|clinically\s+(?:recognized|recommended)|expert\s+(?:says|recommends)/i,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: "press_marquee",
|
|
91
|
+
antiPattern: 8,
|
|
92
|
+
rule: "Press mentions require a brief-supplied working URL for the exact merchant and product.",
|
|
93
|
+
regex: /As\s+Seen\s+(?:On|In)|Featured\s+(?:On|In)/i,
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: "science_theater",
|
|
97
|
+
antiPattern: 9,
|
|
98
|
+
rule: "Study/clinical/certification claims require citation metadata and approved wording.",
|
|
99
|
+
regex: /peer[- ]reviewed|science[- ]backed|backed\s+by\s+science|stud(?:y|ies)\s+(?:show|prove|confirm)|researchers\s+found|clinically\s+(?:proven|shown|tested)|NASA[- ]developed/i,
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: "scarcity_theater",
|
|
103
|
+
antiPattern: 10,
|
|
104
|
+
rule: "Urgency renders only from a real, approved promotion window or live inventory source.",
|
|
105
|
+
regex: /ENDS\s+AT\s+MIDNIGHT|Offer\s+Expires|Deal\s+Ending|Only\s+\d+\s+(?:Units\s+)?Left|Stock\s+(?:Levels?\s+)?Low|\d+%\s+Sold|Sell[- ]?Out\s+Risk|supplies\s+are\s+limited/i,
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: "fake_comparison",
|
|
109
|
+
antiPattern: 11,
|
|
110
|
+
rule: "Tested-N/showdown framing requires a brief-supplied comparison matrix and test record.",
|
|
111
|
+
regex: /(?:we\s+)?tested\s+\d+\s+(?:contenders|products|devices|gloves|combinations|brands)|only\s+one\s+(?:survived|worked|stood)|Competitor\s+[12]\b/i,
|
|
112
|
+
},
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
const ENTITIES = new Map([
|
|
116
|
+
["&", "&"], ["<", "<"], [">", ">"], [""", '"'],
|
|
117
|
+
["'", "'"], ["'", "'"], [" ", " "], ["’", "’"], ["—", "—"],
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
function decodeEntities(value) {
|
|
121
|
+
return String(value)
|
|
122
|
+
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
|
|
123
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16)))
|
|
124
|
+
.replace(/&[a-z]+;/gi, (m) => ENTITIES.get(m.toLowerCase()) ?? m);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Markup view: comments and script/style BODIES removed, tags and attributes
|
|
128
|
+
// kept. The scan surface for hard checks — a `<!-- NEEDS MERCHANT INPUT -->`
|
|
129
|
+
// note or a `querySelector('[data-countdown-hrs]')` reference must not block,
|
|
130
|
+
// but a marker in an alt attribute is shipped content and must.
|
|
131
|
+
//
|
|
132
|
+
// `keepLines`: every removed span leaves its newlines behind, so a match index
|
|
133
|
+
// in the view still maps to the source line number (the doctor reports
|
|
134
|
+
// file:line). The default view collapses whitespace and is index-free.
|
|
135
|
+
export function markupView(html, { keepLines = false } = {}) {
|
|
136
|
+
const newlines = (span) => span.replace(/[^\n]/g, "");
|
|
137
|
+
return String(html)
|
|
138
|
+
.replace(/<!--[\s\S]*?-->/g, keepLines ? (m) => ` ${newlines(m)} ` : " ")
|
|
139
|
+
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, keepLines ? (m) => `<script>${newlines(m)}</script>` : "<script></script>")
|
|
140
|
+
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, keepLines ? (m) => `<style>${newlines(m)}</style>` : "<style></style>");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Visible-text view: markup view with tags stripped and entities decoded —
|
|
144
|
+
// what a shopper (or screen reader, via appended alt text) actually reads.
|
|
145
|
+
// Attribute values (a `placeholder="…"` input hint, a data-* hook) are not
|
|
146
|
+
// visible text and never reach this view, which is what lets a static scan
|
|
147
|
+
// agree with a browser's body.innerText.
|
|
148
|
+
//
|
|
149
|
+
// `keepLines` keeps source newlines (see markupView) and inlines each alt text
|
|
150
|
+
// where its tag stood instead of appending it, so every index maps to a line.
|
|
151
|
+
export function visibleText(html, { keepLines = false } = {}) {
|
|
152
|
+
if (keepLines) {
|
|
153
|
+
const text = markupView(html, { keepLines: true }).replace(/<[^>]*>/g, (tag) => {
|
|
154
|
+
const alt = /\balt="([^"]*)"/i.exec(tag);
|
|
155
|
+
const newlines = tag.replace(/[^\n]/g, "");
|
|
156
|
+
return alt ? ` ${alt[1].replace(/\n/g, " ")} ${newlines}` : ` ${newlines}`;
|
|
157
|
+
});
|
|
158
|
+
return decodeEntities(text).replace(/[^\S\n]+/g, " ");
|
|
159
|
+
}
|
|
160
|
+
const markup = markupView(html);
|
|
161
|
+
const altText = [...markup.matchAll(/\balt="([^"]*)"/gi)].map((m) => m[1]).join(" ");
|
|
162
|
+
return decodeEntities(`${markup.replace(/<[^>]*>/g, " ")} ${altText}`).replace(/\s+/g, " ");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function excerptAt(html, index, span = 80) {
|
|
166
|
+
const start = Math.max(0, index - 20);
|
|
167
|
+
return html.slice(start, start + span).replace(/\s+/g, " ").trim();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Pure scan of one rendered HTML document. Returns { hard: [], review: [] };
|
|
171
|
+
// each finding: { id, tier, rule?, excerpt }. Hard checks run on the markup
|
|
172
|
+
// view (attributes count, comments/scripts do not); review checks run on the
|
|
173
|
+
// visible-text view for precision, except exact-string demo terms and bracket
|
|
174
|
+
// stubs which run on markup so attribute residue is still caught.
|
|
175
|
+
export function scanRenderedHtml(html, { urgencyVerified = false } = {}) {
|
|
176
|
+
const markup = markupView(typeof html === "string" ? html : "");
|
|
177
|
+
const text = visibleText(typeof html === "string" ? html : "");
|
|
178
|
+
const hard = [];
|
|
179
|
+
const review = [];
|
|
180
|
+
|
|
181
|
+
const marker = NEEDS_INPUT_MARKER.exec(markup);
|
|
182
|
+
if (marker) {
|
|
183
|
+
hard.push({ id: "needs_merchant_input_marker", tier: "hard", excerpt: excerptAt(markup, marker.index) });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const countdown = COUNTDOWN_CHROME.exec(markup);
|
|
187
|
+
if (countdown && !urgencyVerified) {
|
|
188
|
+
hard.push({
|
|
189
|
+
id: "unverified_urgency_countdown",
|
|
190
|
+
tier: "hard",
|
|
191
|
+
rule: "Countdown chrome rendered without verified offer urgency (offer.urgency.verified).",
|
|
192
|
+
excerpt: excerptAt(markup, countdown.index),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const bracket = BRACKET_STUB.exec(markup.replace(/<[^>]*>/g, " "));
|
|
197
|
+
if (bracket) {
|
|
198
|
+
review.push({ id: "bracket_placeholder_stub", tier: "review", excerpt: bracket[0] });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
for (const term of DEMO_RESIDUE_TERMS) {
|
|
202
|
+
const index = markup.indexOf(term);
|
|
203
|
+
if (index !== -1) {
|
|
204
|
+
review.push({ id: "demo_residue_term", tier: "review", term, excerpt: excerptAt(markup, index) });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
for (const pattern of CONTENT_ANTI_PATTERNS) {
|
|
209
|
+
if (pattern.id === "scarcity_theater" && urgencyVerified) continue;
|
|
210
|
+
const match = pattern.regex.exec(text);
|
|
211
|
+
if (match) {
|
|
212
|
+
review.push({
|
|
213
|
+
id: pattern.id,
|
|
214
|
+
tier: "review",
|
|
215
|
+
antiPattern: pattern.antiPattern,
|
|
216
|
+
rule: pattern.rule,
|
|
217
|
+
excerpt: excerptAt(text, match.index),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return { hard, review };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Walk a built output dir for rendered pages (same skip rules as the
|
|
225
|
+
// placeholder-residue walker: page HTML only, never _includes/_layouts).
|
|
226
|
+
export function collectRenderedHtmlFiles(rootDir) {
|
|
227
|
+
const files = [];
|
|
228
|
+
const walk = (dir) => {
|
|
229
|
+
for (const entry of readdirSync(dir)) {
|
|
230
|
+
if (entry === "_includes" || entry === "_layouts" || entry.startsWith(".")) continue;
|
|
231
|
+
const full = join(dir, entry);
|
|
232
|
+
const stat = statSync(full);
|
|
233
|
+
if (stat.isDirectory()) walk(full);
|
|
234
|
+
else if (entry.endsWith(".html")) files.push(full);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
if (existsSync(rootDir) && statSync(rootDir).isDirectory()) walk(rootDir);
|
|
238
|
+
return files;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function scanBuiltOutputContentResidue(outputDir, { urgencyVerified = false } = {}) {
|
|
242
|
+
const findings = [];
|
|
243
|
+
for (const file of collectRenderedHtmlFiles(outputDir)) {
|
|
244
|
+
const { hard, review } = scanRenderedHtml(readFileSync(file, "utf8"), { urgencyVerified });
|
|
245
|
+
for (const finding of [...hard, ...review]) {
|
|
246
|
+
findings.push({ ...finding, file: relative(outputDir, file) });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return findings;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// Proof-attestation gate over the copy-gen brief payload's proof_assets.
|
|
254
|
+
//
|
|
255
|
+
// The brief payload (layer-2 intake contract) is written by the assembly
|
|
256
|
+
// write path at .campaign-runtime/input/brief-payload.json. Each proof asset:
|
|
257
|
+
// { id, modality, content, source, verified, attestable, attestation_status }.
|
|
258
|
+
// Usable = verified:true OR attestation_status:"accepted". A non-usable asset
|
|
259
|
+
// only blocks when its content actually appears in the built output —
|
|
260
|
+
// producers are expected to exclude it, and the corpus brief deliberately
|
|
261
|
+
// seeds non-usable candidates to test exactly that.
|
|
262
|
+
export const BRIEF_PAYLOAD_REL_PATH = ".campaign-runtime/input/brief-payload.json";
|
|
263
|
+
|
|
264
|
+
export function loadBriefPayload(targetRepo) {
|
|
265
|
+
if (!targetRepo) return null;
|
|
266
|
+
const path = join(targetRepo, BRIEF_PAYLOAD_REL_PATH);
|
|
267
|
+
if (!existsSync(path)) return null;
|
|
268
|
+
try {
|
|
269
|
+
return { path, payload: JSON.parse(readFileSync(path, "utf8")) };
|
|
270
|
+
} catch (error) {
|
|
271
|
+
return { path, error: error instanceof Error ? error.message : String(error) };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function briefUrgencyVerified(payload) {
|
|
276
|
+
return payload?.offer?.urgency?.verified === true;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function normalizeForMatch(value) {
|
|
280
|
+
return String(value || "").toLowerCase().replace(/\s+/g, " ").trim();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Pure: attestation findings for one set of proof assets against the
|
|
284
|
+
// rendered output. Pass the RAW built HTML (or a concatenation of pages);
|
|
285
|
+
// matching happens against the decoded visible-text view so entity encoding
|
|
286
|
+
// (`&`) and inline tags (`<strong>` inside a claim) cannot hide shipped
|
|
287
|
+
// proof. Returns findings: { id, assetId, modality, state, shipped } where
|
|
288
|
+
// state is "verified" | "accepted" | "pending" | "non_attestable".
|
|
289
|
+
export function evaluateProofAssets(proofAssets, renderedHtml) {
|
|
290
|
+
const findings = [];
|
|
291
|
+
const haystack = normalizeForMatch(visibleText(renderedHtml || ""));
|
|
292
|
+
for (const asset of Array.isArray(proofAssets) ? proofAssets : []) {
|
|
293
|
+
const verified = asset?.verified === true;
|
|
294
|
+
const accepted = asset?.attestation_status === "accepted";
|
|
295
|
+
const attestable = asset?.attestable === true;
|
|
296
|
+
const state = verified ? "verified" : accepted ? "accepted" : attestable ? "pending" : "non_attestable";
|
|
297
|
+
// Match on the whole normalized content, falling back to a distinctive
|
|
298
|
+
// 60-char prefix for long assets. Fail-closed direction: a false
|
|
299
|
+
// "shipped" costs a review, a false "absent" ships unapproved proof.
|
|
300
|
+
// Short claims ("4.9/5", "89%") still count, but match on token
|
|
301
|
+
// boundaries so "4.9" cannot fire inside "$14.99" — a bare substring
|
|
302
|
+
// scan over the whole visible-text haystack would flag noise.
|
|
303
|
+
const normalized = normalizeForMatch(asset?.content);
|
|
304
|
+
const needle = normalized.slice(0, 60);
|
|
305
|
+
let shipped = false;
|
|
306
|
+
if (needle.length >= 8) {
|
|
307
|
+
shipped = haystack.includes(needle);
|
|
308
|
+
} else if (needle.length >= 3) {
|
|
309
|
+
const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
310
|
+
shipped = new RegExp(`(?<![\\w.$])${escaped}(?![\\w.%])`).test(haystack);
|
|
311
|
+
}
|
|
312
|
+
findings.push({ assetId: asset?.id ?? null, modality: asset?.modality ?? null, state, shipped });
|
|
313
|
+
}
|
|
314
|
+
return findings;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function attestationBlockers(findings) {
|
|
318
|
+
return {
|
|
319
|
+
shippedNonAttestable: findings.filter((f) => f.state === "non_attestable" && f.shipped),
|
|
320
|
+
shippedPending: findings.filter((f) => f.state === "pending" && f.shipped),
|
|
321
|
+
};
|
|
322
|
+
}
|