@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,473 @@
|
|
|
1
|
+
// Template brand contracts: per-family declarations of required brand-token
|
|
2
|
+
// overrides, starter-default residue that must not ship, CSS load-order rules,
|
|
3
|
+
// and the selectors QA inspects to prove the brand layer applied.
|
|
4
|
+
//
|
|
5
|
+
// Contract files live at contracts/template-brand-contract.<family>.v0.json.
|
|
6
|
+
// Family contracts may `extends` a shared contract file in the same directory;
|
|
7
|
+
// arrays and scalar values replace parent values, object values merge.
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
13
|
+
|
|
14
|
+
export const TEMPLATE_BRAND_CONTRACT_SCHEMA = "template-brand-contract/v0";
|
|
15
|
+
|
|
16
|
+
export function templateBrandContractPath(family) {
|
|
17
|
+
if (typeof family !== "string" || !family.trim()) return null;
|
|
18
|
+
return join(ROOT, "contracts", `template-brand-contract.${family.trim()}.v0.json`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function loadTemplateBrandContract(family) {
|
|
22
|
+
const path = templateBrandContractPath(family);
|
|
23
|
+
if (!path || !existsSync(path)) return null;
|
|
24
|
+
const contract = loadTemplateBrandContractFile(path);
|
|
25
|
+
if (contract.family !== family) {
|
|
26
|
+
throw templateBrandContractError("family_mismatch", `Template brand contract ${path} declares family "${contract.family}"; expected "${family}".`);
|
|
27
|
+
}
|
|
28
|
+
return contract;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function loadTemplateBrandContractFile(path, seen = new Set()) {
|
|
32
|
+
let contract = null;
|
|
33
|
+
try {
|
|
34
|
+
contract = JSON.parse(readFileSync(path, "utf8"));
|
|
35
|
+
} catch (error) {
|
|
36
|
+
throw templateBrandContractError(
|
|
37
|
+
"parse_error",
|
|
38
|
+
`Template brand contract ${path} failed to parse: ${error instanceof Error ? error.message : String(error)}.`,
|
|
39
|
+
error,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return resolveContractExtendsChain(contract, { dir: dirname(path), label: path, seen });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Walks the `extends` chain starting from an already-parsed contract object,
|
|
46
|
+
// resolving each `extends` filename against `dir`. Shared by the on-disk
|
|
47
|
+
// loader above and by privately-sourced contract fragments (fetched from a
|
|
48
|
+
// third-party repo, not read from a file at `dir`) that still need to extend
|
|
49
|
+
// a shared file living in this repo's own contracts/ directory — callers
|
|
50
|
+
// pass that directory in as `dir` regardless of where the leaf contract
|
|
51
|
+
// itself came from. Exported so private-template-source.mjs reuses this
|
|
52
|
+
// instead of re-implementing cycle-detection/merge.
|
|
53
|
+
export function resolveContractExtendsChain(contract, { dir, label = dir, seen = new Set() } = {}) {
|
|
54
|
+
if (seen.has(label)) throw templateBrandContractError("extends_cycle", `Template brand contract extends cycle at ${label}.`);
|
|
55
|
+
// The outermost call sees the fully merged contract; validation that spans
|
|
56
|
+
// parent and child fields (a family adding a chrome asset the shared hash
|
|
57
|
+
// map does not cover) has to run there, not per file.
|
|
58
|
+
const outermost = seen.size === 0;
|
|
59
|
+
seen.add(label);
|
|
60
|
+
if (!isPlainObject(contract) || contract.schema_version !== TEMPLATE_BRAND_CONTRACT_SCHEMA) {
|
|
61
|
+
throw templateBrandContractError(
|
|
62
|
+
"schema_mismatch",
|
|
63
|
+
`Template brand contract ${label} has schema_version "${contract?.schema_version}"; expected "${TEMPLATE_BRAND_CONTRACT_SCHEMA}".`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
const parentRef = typeof contract.extends === "string" && contract.extends.trim() ? contract.extends.trim() : null;
|
|
67
|
+
let resolved = contract;
|
|
68
|
+
if (parentRef) {
|
|
69
|
+
const parentPath = join(dir, parentRef);
|
|
70
|
+
if (!existsSync(parentPath)) {
|
|
71
|
+
throw templateBrandContractError("extends_missing_parent", `Template brand contract ${label} extends missing file "${parentRef}".`);
|
|
72
|
+
}
|
|
73
|
+
resolved = mergeContractObjects(loadTemplateBrandContractFile(parentPath, seen), contract);
|
|
74
|
+
delete resolved.extends;
|
|
75
|
+
}
|
|
76
|
+
if (outermost) paymentChromeAssetHashes(resolved.default_residue?.payment_chrome, { label });
|
|
77
|
+
return resolved;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function templateBrandContractError(code, message, cause = undefined) {
|
|
81
|
+
const error = new Error(message, cause ? { cause } : undefined);
|
|
82
|
+
error.code = code;
|
|
83
|
+
return error;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function mergeContractObjects(parent, child) {
|
|
87
|
+
const merged = { ...parent };
|
|
88
|
+
for (const [key, value] of Object.entries(child)) {
|
|
89
|
+
if (isPlainObject(value) && isPlainObject(parent?.[key])) {
|
|
90
|
+
merged[key] = mergeContractObjects(parent[key], value);
|
|
91
|
+
} else {
|
|
92
|
+
merged[key] = value;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return merged;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isPlainObject(value) {
|
|
99
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Normalize a CSS color to a comparable form. Computed styles come back as
|
|
103
|
+
// rgb()/rgba(); contracts declare hex. Compare in rgb space.
|
|
104
|
+
export function normalizeCssColor(value) {
|
|
105
|
+
if (typeof value !== "string") return null;
|
|
106
|
+
const v = value.trim().toLowerCase();
|
|
107
|
+
const hex = v.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/);
|
|
108
|
+
if (hex) {
|
|
109
|
+
let h = hex[1];
|
|
110
|
+
if (h.length === 3) h = h.split("").map((c) => c + c).join("");
|
|
111
|
+
const n = parseInt(h, 16);
|
|
112
|
+
return `rgb(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255})`;
|
|
113
|
+
}
|
|
114
|
+
const rgb = v.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)$/);
|
|
115
|
+
if (rgb) {
|
|
116
|
+
// Near-invisible alpha is not a shipped color (and an alpha hack to dodge
|
|
117
|
+
// the forbidden-palette check, e.g. rgba(60,125,255,0.01), should read as
|
|
118
|
+
// "no visible color" — which residue checks treat as suspicious anyway).
|
|
119
|
+
// Above the threshold the alpha is dropped: a half-transparent starter
|
|
120
|
+
// blue still ships the starter palette.
|
|
121
|
+
if (rgb[4] !== undefined && Number(rgb[4]) < 0.1) return null;
|
|
122
|
+
return `rgb(${rgb[1]}, ${rgb[2]}, ${rgb[3]})`;
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Forbidden computed colors for a family, normalized to rgb. Used by browser
|
|
128
|
+
// QA to fail commerce pages that still render the starter palette.
|
|
129
|
+
export function forbiddenComputedColors(contract) {
|
|
130
|
+
const entries = contract?.qa_inspection?.forbidden_computed_colors;
|
|
131
|
+
if (!Array.isArray(entries)) return [];
|
|
132
|
+
return entries
|
|
133
|
+
.map((entry) => ({
|
|
134
|
+
token: entry.token || null,
|
|
135
|
+
hex: entry.hex || null,
|
|
136
|
+
rgb: normalizeCssColor(entry.rgb || entry.hex),
|
|
137
|
+
}))
|
|
138
|
+
.filter((entry) => entry.rgb);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The page types template-residue inspection runs against at all. Lives here
|
|
142
|
+
// rather than in the browser runner so a consumer that only wants to know
|
|
143
|
+
// WHETHER residue checks apply does not have to import the runner.
|
|
144
|
+
export const RESIDUE_PAGE_TYPES = ["checkout", "select", "upsell", "downsell", "receipt"];
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The computed-style (palette) residue checks that will actually run for one
|
|
148
|
+
* contract and page type — the single predicate behind "will QA block this
|
|
149
|
+
* campaign on the starter palette?".
|
|
150
|
+
*
|
|
151
|
+
* Both conditions are load-bearing and neither implies the other: a contract
|
|
152
|
+
* can list forbidden colors with no selector to inspect them on, and it can
|
|
153
|
+
* list selectors with no forbidden color to compare against. Either way the
|
|
154
|
+
* run produces no palette assertion, so anything that warns about one must ask
|
|
155
|
+
* this, not "does a contract exist".
|
|
156
|
+
*/
|
|
157
|
+
export function paletteResidueStyleChecks(contract, pageType) {
|
|
158
|
+
if (!forbiddenComputedColors(contract).length) return [];
|
|
159
|
+
return styleChecksForPageType(contract, pageType);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The selector half, split out so a caller asking about every page type
|
|
163
|
+
// normalizes the forbidden-color list once instead of per type.
|
|
164
|
+
function styleChecksForPageType(contract, pageType) {
|
|
165
|
+
const type = String(pageType || "").toLowerCase();
|
|
166
|
+
if (!contract || !RESIDUE_PAGE_TYPES.includes(type)) return [];
|
|
167
|
+
return (contract.qa_inspection?.computed_style_checks || [])
|
|
168
|
+
.filter((check) => (check.page_types || []).includes(type));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Does this contract produce palette-residue checks on ANY commerce page type?
|
|
173
|
+
*
|
|
174
|
+
* A family outside the certified set — `custom`, `undecided`, or any family the
|
|
175
|
+
* catalog does not carry a contract for — resolves to no contract at all, so
|
|
176
|
+
* browser QA emits no `template-residue:*:style:*` rows for it and there is no
|
|
177
|
+
* starter palette to block on. Warning such an operator that QA will block, and
|
|
178
|
+
* recommending a waiver or a brand-layer rewrite to clear a block that will
|
|
179
|
+
* never happen, is worse than saying nothing.
|
|
180
|
+
*/
|
|
181
|
+
export function contractHasPaletteResidueChecks(contract) {
|
|
182
|
+
// Normalize the forbidden colors once for the whole sweep: they do not vary
|
|
183
|
+
// by page type, and normalizing a color list five times to answer one
|
|
184
|
+
// question is work nobody asked for.
|
|
185
|
+
if (!forbiddenComputedColors(contract).length) return false;
|
|
186
|
+
return RESIDUE_PAGE_TYPES.some((pageType) => styleChecksForPageType(contract, pageType).length > 0);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function escapeContractRegExp(value) {
|
|
190
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function normalizePageTypes(value) {
|
|
194
|
+
return Array.isArray(value) ? value.map((entry) => String(entry).toLowerCase()) : null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Placeholder text-residue contract (H3.1): literal template copy that must
|
|
198
|
+
// never survive into rendered output (Lorem / Placeholder / TODO / Product
|
|
199
|
+
// Name ...). Mirrors the forbidden-computed-colors model — data lives in the
|
|
200
|
+
// contract (shared-commerce, inherited by every family), matchers compile in
|
|
201
|
+
// code — so the gate is data-driven and a family can override the term set.
|
|
202
|
+
export function placeholderTextResidueConfig(contract) {
|
|
203
|
+
const cfg = contract?.qa_inspection?.placeholder_text_residue;
|
|
204
|
+
if (!isPlainObject(cfg)) return null;
|
|
205
|
+
const terms = Array.isArray(cfg.terms)
|
|
206
|
+
// Dedupe so a contract that lists a term twice does not double-count the
|
|
207
|
+
// same occurrence in evidence / summarizePlaceholderTerms.
|
|
208
|
+
? [...new Set(cfg.terms.map((term) => String(term)).filter((term) => term.trim()))]
|
|
209
|
+
: [];
|
|
210
|
+
if (!terms.length) return null;
|
|
211
|
+
return {
|
|
212
|
+
terms,
|
|
213
|
+
pageTypes: normalizePageTypes(cfg.page_types),
|
|
214
|
+
rule: typeof cfg.rule === "string" ? cfg.rule : null,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Pure: every literal placeholder-term occurrence in `text`. Word-boundary,
|
|
219
|
+
// case-insensitive; multi-word terms keep flexible internal whitespace so a
|
|
220
|
+
// reflowed "Product Name" still matches. Boundaries only apply where the
|
|
221
|
+
// term itself starts/ends with a word char, so "lorem ipsum" still anchors.
|
|
222
|
+
export function placeholderTextResidueMatches(text, terms) {
|
|
223
|
+
if (typeof text !== "string" || !text || !Array.isArray(terms) || !terms.length) return [];
|
|
224
|
+
const matches = [];
|
|
225
|
+
for (const term of terms) {
|
|
226
|
+
const raw = String(term || "").trim();
|
|
227
|
+
if (!raw) continue;
|
|
228
|
+
const body = escapeContractRegExp(raw).replace(/\s+/g, "\\s+");
|
|
229
|
+
const startBoundary = /^\w/.test(raw) ? "\\b" : "";
|
|
230
|
+
const endBoundary = /\w$/.test(raw) ? "\\b" : "";
|
|
231
|
+
const regex = new RegExp(`${startBoundary}${body}${endBoundary}`, "gi");
|
|
232
|
+
for (const match of text.matchAll(regex)) {
|
|
233
|
+
matches.push({ term: raw, match: match[0], index: match.index ?? 0 });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return matches.sort((a, b) => a.index - b.index);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// The distinct placeholder terms found, in first-seen order, for verdict copy.
|
|
240
|
+
export function summarizePlaceholderTerms(matches) {
|
|
241
|
+
const seen = [];
|
|
242
|
+
for (const match of matches || []) {
|
|
243
|
+
if (!seen.includes(match.term)) seen.push(match.term);
|
|
244
|
+
}
|
|
245
|
+
return seen;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Demo-asset fidelity contract (H3.2): the template's own demo placeholder
|
|
249
|
+
// assets (1x1 spacers, starter imagery) that should be re-skinned. Data-driven
|
|
250
|
+
// per family so the flag is declarative, not hardcoded: `demo_assets.assets`
|
|
251
|
+
// is the whole vocabulary, and a contract with no assets declares no check.
|
|
252
|
+
export function demoAssetConfig(contract) {
|
|
253
|
+
const cfg = contract?.demo_assets;
|
|
254
|
+
if (!isPlainObject(cfg)) return null;
|
|
255
|
+
const assets = Array.isArray(cfg.assets)
|
|
256
|
+
? cfg.assets.map((asset) => String(asset)).filter((asset) => asset.trim())
|
|
257
|
+
: [];
|
|
258
|
+
if (!assets.length) return null;
|
|
259
|
+
return {
|
|
260
|
+
assets,
|
|
261
|
+
assetBasenames: [...new Set(assets.map((asset) => asset.split("/").pop()).filter(Boolean))],
|
|
262
|
+
pageTypes: normalizePageTypes(cfg.page_types),
|
|
263
|
+
rule: typeof cfg.rule === "string" ? cfg.rule : null,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const SIMPLE_CLASS_SELECTOR = /^\.([A-Za-z0-9_-]+)$/;
|
|
268
|
+
|
|
269
|
+
// Selectors/assets belonging to one payment method under the contract's
|
|
270
|
+
// default_residue.payment_chrome, plus shared chrome assets (those naming no
|
|
271
|
+
// contract method, e.g. upsell-payment-logos.svg) which count as implied residue
|
|
272
|
+
// for any unsupported method per the contract rule. One partition for both
|
|
273
|
+
// consumers: browser QA (visible selectors + fetched assets) and doctor's static
|
|
274
|
+
// scan of the built checkout.
|
|
275
|
+
export function paymentChromeArtifacts(chrome, method) {
|
|
276
|
+
const compact = (value) => String(value || "").toLowerCase().replace(/[\s_-]+/g, "");
|
|
277
|
+
const token = compact(method);
|
|
278
|
+
const methodTokens = (chrome?.methods || []).map(compact).filter(Boolean);
|
|
279
|
+
const selectors = (chrome?.selectors || []).filter((selector) => compact(selector).includes(token));
|
|
280
|
+
const assets = (chrome?.assets || []).filter((asset) => {
|
|
281
|
+
const normalized = compact(asset);
|
|
282
|
+
if (normalized.includes(token)) return true;
|
|
283
|
+
return !methodTokens.some((candidate) => normalized.includes(candidate));
|
|
284
|
+
});
|
|
285
|
+
return { selectors, assets };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// The shipped bytes of each chrome asset, by basename: `payment_chrome.asset_sha256`
|
|
289
|
+
// keyed by the same path `assets[]` lists, lower-cased hex. Browser QA hashes the
|
|
290
|
+
// bytes a page actually serves against this map, which is what tells an untouched
|
|
291
|
+
// starter strip (residue, whatever its markup says) from one edited in place
|
|
292
|
+
// (manual review).
|
|
293
|
+
//
|
|
294
|
+
// Strict, and run at contract load: a hash that is not 64 hex chars, or a
|
|
295
|
+
// listed asset the map does not cover, throws with the asset named. A dropped
|
|
296
|
+
// or missing hash would send that asset back to the markup token match — the
|
|
297
|
+
// path this map exists to close — and a contract author would learn of the
|
|
298
|
+
// typo only from a deployed strip reading as edited. A contract with no
|
|
299
|
+
// `asset_sha256` at all declares no hashes (every asset uses the token match),
|
|
300
|
+
// which keeps a privately-sourced contract that predates the field loadable.
|
|
301
|
+
export function paymentChromeAssetHashes(chrome, { label = "template brand contract" } = {}) {
|
|
302
|
+
const byBasename = new Map();
|
|
303
|
+
if (!isPlainObject(chrome) || chrome.asset_sha256 === undefined) return byBasename;
|
|
304
|
+
if (!isPlainObject(chrome.asset_sha256)) {
|
|
305
|
+
throw templateBrandContractError("payment_chrome_hash_invalid", `${label}: default_residue.payment_chrome.asset_sha256 must be an object keyed by asset path.`);
|
|
306
|
+
}
|
|
307
|
+
for (const [asset, digest] of Object.entries(chrome.asset_sha256)) {
|
|
308
|
+
const basename = String(asset || "").split("/").pop();
|
|
309
|
+
const hex = typeof digest === "string" ? digest.trim().toLowerCase() : "";
|
|
310
|
+
if (!basename || !/^[0-9a-f]{64}$/.test(hex)) {
|
|
311
|
+
throw templateBrandContractError(
|
|
312
|
+
"payment_chrome_hash_invalid",
|
|
313
|
+
`${label}: default_residue.payment_chrome.asset_sha256["${asset}"] is not a 64-char hex sha256 (${JSON.stringify(digest)}).`,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
byBasename.set(basename, hex);
|
|
317
|
+
}
|
|
318
|
+
for (const asset of Array.isArray(chrome.assets) ? chrome.assets : []) {
|
|
319
|
+
const basename = String(asset || "").split("/").pop();
|
|
320
|
+
if (basename && !byBasename.has(basename)) {
|
|
321
|
+
throw templateBrandContractError(
|
|
322
|
+
"payment_chrome_hash_missing",
|
|
323
|
+
`${label}: default_residue.payment_chrome.assets lists "${asset}" with no asset_sha256 entry; record the sha256 of the shipped file or drop the asset.`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return byBasename;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Pure, static: the markers in rendered checkout HTML that say a payment method
|
|
331
|
+
// shipped. Three sources, in order of authority: the SDK-owned
|
|
332
|
+
// data-next-payment-method attribute every starter-template payment-methods
|
|
333
|
+
// include renders per method (underscore spelling canonical, legacy hyphen
|
|
334
|
+
// accepted); the contract's payment_chrome class selectors for the method
|
|
335
|
+
// (simple .class selectors only — a static scan cannot evaluate compound
|
|
336
|
+
// selectors or visibility, browser QA does that); and the method-named chrome
|
|
337
|
+
// assets by basename. Shared chrome assets that name no method are left to
|
|
338
|
+
// browser QA, which fetches them to attribute the mark; a static scan cannot
|
|
339
|
+
// tell a paypal strip from a card-only one by its filename.
|
|
340
|
+
export function paymentMethodMarkupMatches(html, method, chrome = null) {
|
|
341
|
+
const text = typeof html === "string" ? html : "";
|
|
342
|
+
const canonical = String(method || "").toLowerCase().replace(/[\s-]+/g, "_");
|
|
343
|
+
if (!canonical) return [];
|
|
344
|
+
const matches = [];
|
|
345
|
+
const spellings = [...new Set([canonical, canonical.replace(/_/g, "-")])].map(escapeContractRegExp).join("|");
|
|
346
|
+
const attribute = new RegExp(`data-next-payment-method\\s*=\\s*["'](?:${spellings})["']`, "i").exec(text);
|
|
347
|
+
if (attribute) matches.push(attribute[0].replace(/\s+/g, ""));
|
|
348
|
+
const artifacts = paymentChromeArtifacts(chrome, canonical);
|
|
349
|
+
for (const selector of artifacts.selectors) {
|
|
350
|
+
const className = SIMPLE_CLASS_SELECTOR.exec(selector)?.[1];
|
|
351
|
+
if (!className) continue;
|
|
352
|
+
if (new RegExp(`class\\s*=\\s*["'](?:[^"']*\\s)?${escapeContractRegExp(className)}(?:\\s|["'])`, "i").test(text)) matches.push(selector);
|
|
353
|
+
}
|
|
354
|
+
const compact = (value) => String(value || "").toLowerCase().replace(/[\s_-]+/g, "");
|
|
355
|
+
const token = compact(canonical);
|
|
356
|
+
for (const asset of artifacts.assets) {
|
|
357
|
+
if (!compact(asset).includes(token)) continue;
|
|
358
|
+
const basename = asset.split("/").pop();
|
|
359
|
+
if (basename && text.includes(basename)) matches.push(basename);
|
|
360
|
+
}
|
|
361
|
+
return [...new Set(matches)];
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// The part of a method's payment_chrome a static HTML scan cannot attribute:
|
|
365
|
+
// compound selectors (need a live DOM) and shared chrome assets naming no
|
|
366
|
+
// method (need a fetch to attribute the mark). Browser QA covers both; doctor
|
|
367
|
+
// names them so "no markup found" is never read as "nothing left to check".
|
|
368
|
+
export function paymentMethodStaticScanGaps(chrome, method) {
|
|
369
|
+
const canonical = String(method || "").toLowerCase().replace(/[\s-]+/g, "_");
|
|
370
|
+
if (!canonical) return { compound_selectors: [], shared_assets: [] };
|
|
371
|
+
const artifacts = paymentChromeArtifacts(chrome, canonical);
|
|
372
|
+
const compact = (value) => String(value || "").toLowerCase().replace(/[\s_-]+/g, "");
|
|
373
|
+
const token = compact(canonical);
|
|
374
|
+
return {
|
|
375
|
+
compound_selectors: artifacts.selectors.filter((selector) => !SIMPLE_CLASS_SELECTOR.test(selector)),
|
|
376
|
+
shared_assets: artifacts.assets.filter((asset) => !compact(asset).includes(token)).map((asset) => asset.split("/").pop()).filter(Boolean),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
// Pure: which demo-asset basenames are referenced in rendered HTML. Mirrors
|
|
382
|
+
// referencedAssetBasenames in qa-browser (payment-chrome residue).
|
|
383
|
+
export function referencedDemoAssetBasenames(html, basenames) {
|
|
384
|
+
const text = typeof html === "string" ? html : "";
|
|
385
|
+
return (basenames || []).filter((basename) => basename && text.includes(basename));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Scan campaign CSS text for rules that hide pricing surfaces with
|
|
389
|
+
// display:none. Returns one finding per offending selector occurrence.
|
|
390
|
+
//
|
|
391
|
+
// Deliberately a brace-depth walker, not a flat regex: a flat
|
|
392
|
+
// `selector { decls }` regex cannot see inside `@media` / `@supports` /
|
|
393
|
+
// `@container` blocks, so a mobile-only price hide would bypass the scan —
|
|
394
|
+
// the exact escape this check exists to close. The walker recurses into
|
|
395
|
+
// at-rule and CSS-nesting blocks and matches targets against the full
|
|
396
|
+
// selector context (ancestor preludes joined with the leaf selector). It is
|
|
397
|
+
// still a lint, not a full CSS parser; pathological inputs (braces inside
|
|
398
|
+
// attribute-selector strings) may mis-scan, which is acceptable for a
|
|
399
|
+
// deterministic warning surface.
|
|
400
|
+
export function findForbiddenPriceHides(contract, cssText) {
|
|
401
|
+
const targets = contract?.pricing_surfaces?.forbidden_css_hides;
|
|
402
|
+
if (!Array.isArray(targets) || !targets.length || typeof cssText !== "string") return [];
|
|
403
|
+
const findings = [];
|
|
404
|
+
const stripped = cssText.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
405
|
+
scanCssBlock(stripped, [], targets, findings);
|
|
406
|
+
return findings;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Token-boundary match: the target must not be a substring of a longer CSS
|
|
410
|
+
// identifier, so ".summary_price" matches ".summary_price.cc-sm" but not
|
|
411
|
+
// ".summary_price-row", and a future ".price" target cannot blanket-match
|
|
412
|
+
// every ".price-*" class. CSS identifiers also allow code points >= U+0080,
|
|
413
|
+
// so those count as identifier characters too (".price" must not match
|
|
414
|
+
// inside ".priceΑ" or ".price-événement").
|
|
415
|
+
const CSS_IDENT_CHAR = "A-Za-z0-9_\\u0080-\\uFFFF-";
|
|
416
|
+
|
|
417
|
+
function selectorContextMatches(context, target) {
|
|
418
|
+
const raw = String(target);
|
|
419
|
+
const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
420
|
+
const identChar = new RegExp(`^[${CSS_IDENT_CHAR}]$`, "u");
|
|
421
|
+
// Boundary guards apply only where the target itself starts/ends with an
|
|
422
|
+
// identifier character. A target starting with "." or "[" is already
|
|
423
|
+
// delimited by that symbol — and the symbol may legally follow an ident
|
|
424
|
+
// char in compound selectors (div.price-wrapper), so a blanket lookbehind
|
|
425
|
+
// would miss those.
|
|
426
|
+
const pre = identChar.test(raw[0] || "") ? `(?<![${CSS_IDENT_CHAR}])` : "";
|
|
427
|
+
const post = identChar.test(raw[raw.length - 1] || "") ? `(?![${CSS_IDENT_CHAR}])` : "";
|
|
428
|
+
return new RegExp(`${pre}${escaped}${post}`, "u").test(context);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function scanCssBlock(text, contextPreludes, targets, findings) {
|
|
432
|
+
let index = 0;
|
|
433
|
+
while (index < text.length) {
|
|
434
|
+
const open = text.indexOf("{", index);
|
|
435
|
+
if (open === -1) return;
|
|
436
|
+
// Statement at-rules (`@import url(x);`, `@charset "utf-8";`) end with a
|
|
437
|
+
// semicolon and never open a block, so the next rule's prelude is the
|
|
438
|
+
// text AFTER the last `;` — slicing without the split would misread
|
|
439
|
+
// `@import x; .foo { … }` as an at-rule named "@import x; .foo".
|
|
440
|
+
const prelude = text.slice(index, open).split(";").pop().trim();
|
|
441
|
+
let depth = 1;
|
|
442
|
+
let cursor = open + 1;
|
|
443
|
+
while (cursor < text.length && depth > 0) {
|
|
444
|
+
if (text[cursor] === "{") depth += 1;
|
|
445
|
+
else if (text[cursor] === "}") depth -= 1;
|
|
446
|
+
cursor += 1;
|
|
447
|
+
}
|
|
448
|
+
const body = text.slice(open + 1, cursor - (depth === 0 ? 1 : 0));
|
|
449
|
+
if (prelude.startsWith("@")) {
|
|
450
|
+
// Conditional group rules (@media/@supports/@container/@layer) nest
|
|
451
|
+
// full rules: recurse without adding selector context. Declaration-only
|
|
452
|
+
// at-rules (@font-face, @page) cannot hide price rows; recursing into
|
|
453
|
+
// them is harmless because they contain no nested selectors.
|
|
454
|
+
scanCssBlock(body, contextPreludes, targets, findings);
|
|
455
|
+
} else {
|
|
456
|
+
const nestedStart = body.indexOf("{");
|
|
457
|
+
// Own declarations = body text outside any nested blocks (CSS nesting).
|
|
458
|
+
const ownDeclarations = nestedStart === -1 ? body : body.slice(0, body.lastIndexOf(";", nestedStart) + 1);
|
|
459
|
+
const selectorContext = [...contextPreludes, prelude].join(" ").replace(/\s+/g, " ").trim();
|
|
460
|
+
if (/display\s*:\s*none/i.test(ownDeclarations)) {
|
|
461
|
+
for (const target of targets) {
|
|
462
|
+
if (selectorContextMatches(selectorContext, target)) {
|
|
463
|
+
findings.push({ target, selector: selectorContext });
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (nestedStart !== -1) {
|
|
468
|
+
scanCssBlock(body, [...contextPreludes, prelude], targets, findings);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
index = cursor;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// Template certification freshness (#263): surface, per certified family, the
|
|
2
|
+
// SDK version the family was last verified against and its delta from the
|
|
3
|
+
// current SDK — in the places an operator already looks (the certified-template
|
|
4
|
+
// gate output and standardization/doctor reporting).
|
|
5
|
+
//
|
|
6
|
+
// Data source discipline: everything here reads the VENDORED contract set —
|
|
7
|
+
// the commerce surface catalog snapshot (which the refresh script now stamps
|
|
8
|
+
// with per-family `verification` blocks copied from the starter repo's
|
|
9
|
+
// template-verification.json at the same pinned `_synced_from_sha`) and the
|
|
10
|
+
// vendored SDK support policy. No live fetches, no new data source.
|
|
11
|
+
//
|
|
12
|
+
// "Current SDK" definition: the newest released Campaign Cart SDK the vendored
|
|
13
|
+
// contracts record — the semver maximum over the SDK support policy's
|
|
14
|
+
// `provenance.latest_known_release` and every family verification record's
|
|
15
|
+
// `sdk_version` in the catalog snapshot. A verification record itself proves
|
|
16
|
+
// that release exists, so the definition can never call a family "ahead" of a
|
|
17
|
+
// release the snapshot already documents. The campaign's own pinned SDK is a
|
|
18
|
+
// different question (owned by the page_kit.sdk_version checkpoint) and is
|
|
19
|
+
// deliberately not folded into this definition.
|
|
20
|
+
//
|
|
21
|
+
// Doctrine (portal copy + docs): output must say which SDK is current and
|
|
22
|
+
// which SDK was last verified; an older evidence record is not current
|
|
23
|
+
// certification.
|
|
24
|
+
|
|
25
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
26
|
+
import { dirname, join } from "node:path";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
|
|
29
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
30
|
+
|
|
31
|
+
export const SDK_SUPPORT_POLICY_PATH = "contracts/campaign-cart-sdk-support-policy.v0.json";
|
|
32
|
+
|
|
33
|
+
// Parse a released "major.minor.patch" SDK version. Returns null for anything
|
|
34
|
+
// else (pre-releases, ranges, missing values) — freshness only ever compares
|
|
35
|
+
// released versions, mirroring the strict pin the CampaignSpec rule enforces.
|
|
36
|
+
export function parseSdkVersion(value) {
|
|
37
|
+
if (typeof value !== "string") return null;
|
|
38
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value.trim());
|
|
39
|
+
if (!match) return null;
|
|
40
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Standard semver ordering over released versions. Returns null when either
|
|
44
|
+
// side does not parse, so callers degrade to "unknown" instead of guessing.
|
|
45
|
+
export function compareSdkVersions(a, b) {
|
|
46
|
+
const left = parseSdkVersion(a);
|
|
47
|
+
const right = parseSdkVersion(b);
|
|
48
|
+
if (!left || !right) return null;
|
|
49
|
+
for (let index = 0; index < 3; index += 1) {
|
|
50
|
+
if (left[index] !== right[index]) return left[index] < right[index] ? -1 : 1;
|
|
51
|
+
}
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Best-effort read of the vendored SDK support policy. Absent or malformed
|
|
56
|
+
// policy degrades freshness to whatever the catalog verification records
|
|
57
|
+
// prove — it never throws, because freshness reporting must not block a gate.
|
|
58
|
+
export function defaultSdkSupportPolicy() {
|
|
59
|
+
const path = join(ROOT, ...SDK_SUPPORT_POLICY_PATH.split("/"));
|
|
60
|
+
if (!existsSync(path)) return null;
|
|
61
|
+
try {
|
|
62
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
63
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The family's verification record as vendored on the catalog snapshot.
|
|
70
|
+
// Returns null when the snapshot carries none for this family (private
|
|
71
|
+
// families resolved from fragments, or a snapshot predating the verification
|
|
72
|
+
// sync) — the caller reports that honestly as unknown freshness.
|
|
73
|
+
export function familyVerification(catalog, family) {
|
|
74
|
+
const record = catalog?.families?.[String(family || "")]?.verification;
|
|
75
|
+
if (!record || typeof record !== "object" || Array.isArray(record)) return null;
|
|
76
|
+
if (!parseSdkVersion(record.sdk_version)) return null;
|
|
77
|
+
return {
|
|
78
|
+
sdk_version: record.sdk_version,
|
|
79
|
+
verified_at: typeof record.verified_at === "string" ? record.verified_at : null,
|
|
80
|
+
evidence: typeof record.evidence === "string" ? record.evidence : null,
|
|
81
|
+
status: typeof record.status === "string" ? record.status : null,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// "Current SDK" per the definition at the top of this file. Returns
|
|
86
|
+
// { version, source } or null when the vendored contracts record no released
|
|
87
|
+
// SDK at all.
|
|
88
|
+
export function resolveCurrentSdkVersion({ catalog, sdkSupportPolicy } = {}) {
|
|
89
|
+
let best = null;
|
|
90
|
+
const consider = (version, source) => {
|
|
91
|
+
if (!parseSdkVersion(version)) return;
|
|
92
|
+
if (!best || compareSdkVersions(version, best.version) === 1) {
|
|
93
|
+
best = { version, source };
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
consider(sdkSupportPolicy?.provenance?.latest_known_release, "sdk_support_policy.latest_known_release");
|
|
97
|
+
for (const [family, entry] of Object.entries(catalog?.families || {})) {
|
|
98
|
+
consider(entry?.verification?.sdk_version, `catalog_verification:${family}`);
|
|
99
|
+
}
|
|
100
|
+
return best;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// One assessment per family: what the snapshot proves, what "current" is, and
|
|
104
|
+
// the relation between them. States:
|
|
105
|
+
// current — last verified against exactly the current SDK
|
|
106
|
+
// stale — last verified against an OLDER SDK than current
|
|
107
|
+
// ahead — verified newer than current (defensive: impossible under the
|
|
108
|
+
// default current-SDK definition, reachable with injected inputs)
|
|
109
|
+
// unknown — no verification record, or no current SDK to compare against
|
|
110
|
+
export function assessTemplateFreshness({ family, catalog, sdkSupportPolicy } = {}) {
|
|
111
|
+
const verification = familyVerification(catalog, family);
|
|
112
|
+
const current = resolveCurrentSdkVersion({ catalog, sdkSupportPolicy });
|
|
113
|
+
const base = {
|
|
114
|
+
family: String(family || ""),
|
|
115
|
+
verified_sdk_version: verification?.sdk_version || null,
|
|
116
|
+
verified_at: verification?.verified_at || null,
|
|
117
|
+
current_sdk_version: current?.version || null,
|
|
118
|
+
current_sdk_source: current?.source || null,
|
|
119
|
+
delta: null,
|
|
120
|
+
};
|
|
121
|
+
if (!verification || !current) return { ...base, state: "unknown" };
|
|
122
|
+
const order = compareSdkVersions(verification.sdk_version, current.version);
|
|
123
|
+
if (order === null) return { ...base, state: "unknown" };
|
|
124
|
+
const state = order === 0 ? "current" : order < 0 ? "stale" : "ahead";
|
|
125
|
+
return { ...base, state, delta: describeVersionDelta(verification.sdk_version, current.version) };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Human description of the version gap. For a shared major.minor line the
|
|
129
|
+
// patch-number gap is exact version arithmetic ("3 patch versions behind");
|
|
130
|
+
// across minor/major lines only the two versions themselves are honest.
|
|
131
|
+
function describeVersionDelta(verified, current) {
|
|
132
|
+
const from = parseSdkVersion(verified);
|
|
133
|
+
const to = parseSdkVersion(current);
|
|
134
|
+
if (!from || !to) return null;
|
|
135
|
+
if (from[0] === to[0] && from[1] === to[1]) {
|
|
136
|
+
const gap = to[2] - from[2];
|
|
137
|
+
if (gap === 0) return "0 patch versions";
|
|
138
|
+
const magnitude = Math.abs(gap);
|
|
139
|
+
return `${magnitude} patch version${magnitude === 1 ? "" : "s"} ${gap > 0 ? "behind" : "ahead"}`;
|
|
140
|
+
}
|
|
141
|
+
return `${verified} vs ${current}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Only a well-formed ISO calendar date prefix (YYYY-MM-DD, optionally the
|
|
145
|
+
// start of a full timestamp) is ever interpolated into the operator line. The
|
|
146
|
+
// vendored snapshot carries full "YYYY-MM-DDTHH:MM:SSZ" timestamps, but the
|
|
147
|
+
// same code path runs against whatever the upstream template-verification.json
|
|
148
|
+
// carries — a malformed value omits the parenthetical instead of surfacing
|
|
149
|
+
// garbage like "(2026-13-45)" in operator output.
|
|
150
|
+
function isoDatePrefix(value) {
|
|
151
|
+
if (typeof value !== "string") return null;
|
|
152
|
+
// A suffix, when present, must look like an ISO time fragment (T + HH:MM at
|
|
153
|
+
// minimum); ISO 8601 timestamps never use a space separator, and a garbage
|
|
154
|
+
// suffix must reject rather than silently normalize to its date prefix.
|
|
155
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/.exec(value.trim());
|
|
156
|
+
if (!match) return null;
|
|
157
|
+
const [, year, month, day] = match;
|
|
158
|
+
const parsed = new Date(`${year}-${month}-${day}T00:00:00Z`);
|
|
159
|
+
if (Number.isNaN(parsed.getTime())) return null;
|
|
160
|
+
// Date rolls non-calendar values over (2026-13-45 -> 2027-02-14); reject
|
|
161
|
+
// anything that does not round-trip.
|
|
162
|
+
if (parsed.getUTCMonth() + 1 !== Number(month) || parsed.getUTCDate() !== Number(day)) return null;
|
|
163
|
+
return `${year}-${month}-${day}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// The single freshness line every operator surface prints, so the gate, the
|
|
167
|
+
// doctor, and the standardization report can never tell different stories.
|
|
168
|
+
// Total over any input: a null/undefined assessment (or one with fields
|
|
169
|
+
// missing) renders the unknown-state line instead of throwing or
|
|
170
|
+
// interpolating "undefined" — freshness reporting must not block a gate.
|
|
171
|
+
export function renderTemplateFreshness(assessment) {
|
|
172
|
+
const {
|
|
173
|
+
family = "",
|
|
174
|
+
state = "unknown",
|
|
175
|
+
verified_sdk_version = null,
|
|
176
|
+
verified_at = null,
|
|
177
|
+
current_sdk_version = null,
|
|
178
|
+
delta = null,
|
|
179
|
+
} = assessment || {};
|
|
180
|
+
const verifiedAtDate = isoDatePrefix(verified_at);
|
|
181
|
+
const verifiedAtSuffix = verifiedAtDate ? ` (${verifiedAtDate})` : "";
|
|
182
|
+
switch (state) {
|
|
183
|
+
case "current":
|
|
184
|
+
return `Template family "${family}" certification is current: last verified against SDK ${verified_sdk_version}${verifiedAtSuffix}, the current SDK recorded by the vendored contracts.`;
|
|
185
|
+
case "stale":
|
|
186
|
+
return `Template family "${family}" was last verified against SDK ${verified_sdk_version}${verifiedAtSuffix}; the current SDK is ${current_sdk_version}${delta ? ` (${delta})` : ""}. An older evidence record is not current certification — treat the family as pending re-verification against ${current_sdk_version}.`;
|
|
187
|
+
case "ahead":
|
|
188
|
+
// Deliberately kept so the renderer stays total over the documented
|
|
189
|
+
// state union, even though assessTemplateFreshness cannot currently
|
|
190
|
+
// produce "ahead" (the current-SDK max includes the family's own
|
|
191
|
+
// verification record); injected/external assessments render honestly.
|
|
192
|
+
return `Template family "${family}" was last verified against SDK ${verified_sdk_version}${verifiedAtSuffix}, which is newer than the current SDK ${current_sdk_version} recorded by the vendored contracts; refresh the SDK support policy capture.`;
|
|
193
|
+
default:
|
|
194
|
+
return `Template family "${family}" has no verification record in the vendored catalog snapshot${current_sdk_version ? ` (current SDK: ${current_sdk_version})` : ""}; certification freshness is unknown. An older evidence record is not current certification — confirm the family's last-verified SDK before relying on it.`;
|
|
195
|
+
}
|
|
196
|
+
}
|