@nextcommerce/campaigns-os 1.61.0 → 1.63.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.
Files changed (129) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +127 -0
  3. package/README.md +86 -438
  4. package/agents/claude/CLAUDE.md +9 -4
  5. package/agents/codex/AGENTS.md +8 -4
  6. package/agents/copilot/copilot-instructions.md +8 -4
  7. package/agents/cursor/campaigns-os.mdc +10 -4
  8. package/campaign-spec/README.md +7 -0
  9. package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
  10. package/campaign-spec/dist/analytics-vendors.js +44 -0
  11. package/campaign-spec/dist/checkout-flow.d.ts +18 -0
  12. package/campaign-spec/dist/checkout-flow.js +28 -0
  13. package/campaign-spec/dist/index.d.ts +4 -2
  14. package/campaign-spec/dist/index.js +5 -1
  15. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
  16. package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
  17. package/campaign-spec/dist/rules/campaign-currencies.d.ts +30 -0
  18. package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
  19. package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
  20. package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
  21. package/campaign-spec/dist/rules/index.d.ts +3 -0
  22. package/campaign-spec/dist/rules/index.js +9 -0
  23. package/campaign-spec/dist/rules/package-currency-prices.d.ts +29 -0
  24. package/campaign-spec/dist/rules/package-currency-prices.js +137 -0
  25. package/campaign-spec/dist/types.d.ts +59 -9
  26. package/compatibility.json +1 -1
  27. package/contracts/commerce-surface-catalog.json +59 -53
  28. package/contracts/effects.v1.json +549 -165
  29. package/contracts/fixtures/runtime-recipe/accept/current.json +4 -0
  30. package/contracts/fixtures/runtime-recipe/accept/minimal.json +4 -0
  31. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  32. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +4 -0
  33. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +4 -0
  34. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +4 -0
  35. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +4 -0
  36. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +4 -0
  37. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +4 -0
  38. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +4 -0
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +4 -0
  40. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +4 -0
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +4 -0
  42. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +4 -0
  43. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +4 -0
  44. package/contracts/release-ledger.json +1260 -0
  45. package/contracts/runtime-recipe.campaigns-os-node-v1.json +4 -0
  46. package/contracts/supported-surface.json +8 -8
  47. package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
  48. package/docs/build-packet.md +430 -37
  49. package/docs/campaign-standardization-report.md +4 -4
  50. package/docs/campaigns-os-build-flow.md +54 -3
  51. package/docs/campaignspec-authoring-examples.md +59 -4
  52. package/docs/design-source-package.md +18 -6
  53. package/docs/effects.md +17 -9
  54. package/docs/local-setup.md +1 -1
  55. package/docs/orientation-contract-reference.md +1 -1
  56. package/docs/polish-evidence.md +2 -0
  57. package/docs/progress-snapshots.md +7 -4
  58. package/docs/qa-and-test-orders.md +147 -1
  59. package/docs/readback.md +40 -5
  60. package/docs/runtime-readiness.md +7 -3
  61. package/docs/sdk-storage-compatibility.md +1 -1
  62. package/docs/skills-revision.md +10 -10
  63. package/package.json +1 -1
  64. package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
  65. package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
  66. package/schemas/campaign-spec.v4.schema.json +34 -3
  67. package/schemas/campaign-spec.v5.schema.json +34 -3
  68. package/schemas/campaigns-os-readback.v2.schema.json +62 -1
  69. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  70. package/skills/campaign-readback-classification/SKILL.md +3 -3
  71. package/skills/campaign-run-evidence/SKILL.md +3 -3
  72. package/skills/contribution-intake/SKILL.md +3 -3
  73. package/skills/next-campaigns-build/SKILL.md +4 -3
  74. package/skills/next-campaigns-os/SKILL.md +6 -4
  75. package/skills/next-campaigns-os-setup/SKILL.md +5 -3
  76. package/skills/next-campaigns-polish/SKILL.md +5 -4
  77. package/skills/next-campaigns-qa/SKILL.md +7 -4
  78. package/skills/next-campaigns-sdk-bump/SKILL.md +42 -9
  79. package/skills.json +11 -11
  80. package/src/brand-theme.mjs +2 -1
  81. package/src/build-brief.mjs +4 -1
  82. package/src/campaign-workspace.mjs +288 -15
  83. package/src/change-scope.mjs +284 -0
  84. package/src/cli.mjs +308 -86
  85. package/src/commercial-journey.mjs +16 -3
  86. package/src/commercial-parity.mjs +15 -7
  87. package/src/content-residue.mjs +4 -2
  88. package/src/design-source-package.mjs +36 -0
  89. package/src/design-source-publication.mjs +9 -6
  90. package/src/directory-lock.mjs +73 -19
  91. package/src/doctor/checks.mjs +79 -7
  92. package/src/doctor/ci.mjs +75 -10
  93. package/src/doctor/inspect.mjs +23 -6
  94. package/src/doctor/next-step.mjs +7 -0
  95. package/src/doctor-sidecar.mjs +3 -2
  96. package/src/evidence-debt.mjs +47 -7
  97. package/src/finding-cause.mjs +12 -11
  98. package/src/input-refresh.mjs +339 -32
  99. package/src/invocation.mjs +7 -1
  100. package/src/live-campaign-refs.mjs +7 -2
  101. package/src/local-preview-policy.mjs +35 -2
  102. package/src/page-kit-analytics-vendors.mjs +132 -0
  103. package/src/polish-browser.mjs +3 -1
  104. package/src/polish-readability.mjs +221 -9
  105. package/src/polish-visual.mjs +9 -2
  106. package/src/progress-node.mjs +3 -2
  107. package/src/qa-analytics-parity.mjs +17 -1
  108. package/src/qa-browser.mjs +40 -4
  109. package/src/qa-commercial-parity.mjs +50 -3
  110. package/src/qa-node.mjs +196 -16
  111. package/src/qa-publish.mjs +9 -8
  112. package/src/qa-sidecar.mjs +16 -4
  113. package/src/qa-vendor-beacons.mjs +232 -0
  114. package/src/qa-vendor-endpoints.mjs +306 -0
  115. package/src/qc-accept.mjs +29 -1
  116. package/src/qc-check-registry.mjs +5 -1
  117. package/src/qc-results.mjs +73 -26
  118. package/src/readback.mjs +58 -10
  119. package/src/route-identity.mjs +10 -0
  120. package/src/run-record.mjs +18 -10
  121. package/src/runtime-state-ignore.mjs +6 -2
  122. package/src/sdk-storage-compatibility.mjs +21 -2
  123. package/src/source-html-intake.mjs +15 -1
  124. package/src/spec-derive-campaign.mjs +216 -0
  125. package/src/spec-derive.mjs +473 -53
  126. package/src/spec-identity.mjs +14 -0
  127. package/src/stage-ledger.mjs +40 -1
  128. package/src/stage-record.mjs +58 -13
  129. package/src/target-lock.mjs +11 -1
@@ -1,12 +1,14 @@
1
1
  import { localSpecIdentityFields } from "./spec-source-identity.mjs";
2
2
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
3
  import { randomUUID } from "node:crypto";
4
- import { dirname, join, resolve } from "node:path";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
5
 
6
+ import { packetRuntimeRelPath } from "./campaign-workspace.mjs";
6
7
  import { deriveExceptions, validateVerdict } from "./qa-verdict.mjs";
7
8
 
8
9
  // The committed QA verdict sidecar: `.campaign-runtime/qa-verdict.json` beside
9
- // the Build Packet, the artifact campaigns-agent's readback projects. Full
10
+ // the Build Packet (under the packet's own runtime directory when it is one
11
+ // of several funnels in its project), the artifact campaigns-agent's readback projects. Full
10
12
  // verdicts under qa-output/ are gitignored because they carry live storefront
11
13
  // URLs, request evidence, and order references; the sidecar is committed to
12
14
  // merchant repos, so it is an allowlist PROJECTION of one verdict, never a
@@ -100,8 +102,18 @@ export function projectVerdictForSidecar(verdict, { generatedAt }) {
100
102
  };
101
103
  }
102
104
 
103
- export function sidecarPathForPacket(packetPath) {
104
- return join(dirname(resolve(packetPath)), SIDECAR_RELATIVE_PATH);
105
+ // `packet` is read from `packetPath` when not given; an unreadable packet
106
+ // names the project-level location, which the writers refuse before using.
107
+ export function sidecarPathForPacket(packetPath, packet = readPacketOrNull(packetPath)) {
108
+ return join(dirname(resolve(packetPath)), packetRuntimeRelPath(packet), basename(SIDECAR_RELATIVE_PATH));
109
+ }
110
+
111
+ function readPacketOrNull(packetPath) {
112
+ try {
113
+ return JSON.parse(readFileSync(resolve(packetPath), "utf8"));
114
+ } catch {
115
+ return null;
116
+ }
105
117
  }
106
118
 
107
119
  const PACKET_SCHEMA = "campaign-runtime-build-packet/v0";
@@ -0,0 +1,232 @@
1
+ // Keeping test orders out of vendor data (campaigns-os#689, #683 item 5).
2
+ //
3
+ // A `qa run --test-order` run places real orders, and the page under test
4
+ // reports each purchase to every analytics vendor the campaign configures. By
5
+ // default every browser context the run opens answers requests to the listed
6
+ // vendor beacon endpoints (qa-vendor-endpoints.mjs) with a local 204, so they
7
+ // never reach the vendor. Loader scripts pass through. `--vendor-beacons live`
8
+ // turns that off.
9
+ //
10
+ // Either way each request to a listed endpoint is recorded, and the verdict
11
+ // carries the record as evidence: per vendor, how many requests, whether one
12
+ // named a purchase, the ids and event names, plus the configured vendors never
13
+ // seen and the vendors the list does not intercept. It is evidence only
14
+ // (decided 2026-10-29): "the page sent it" is never vendor purchase proof and
15
+ // never a blocker, since an answered request proves the page sent it, not that
16
+ // the vendor received it. Purchase proof stays with effectivePurchase.
17
+ //
18
+ // The intercepted requests still reach the page's own request listeners, so
19
+ // attachAnalyticsCapture's tagFires (via classifyTagFire) see them too.
20
+ //
21
+ // Page-method-only, like qa-analytics-parity.mjs: no playwright import.
22
+
23
+ import { refused } from "./lifecycle.mjs";
24
+ import { SEVERITY, STATUS } from "./qa-verdict.mjs";
25
+ import { configuredVendors, INTERCEPTED_VENDORS, NOT_INTERCEPTED_VENDORS, readVendorBeacon, VENDOR_KEYS, vendorBeaconFor } from "./qa-vendor-endpoints.mjs";
26
+
27
+ // The run's ledger rides on args under this key, like QA_RUN_CURRENCY, so every
28
+ // browser leg of one `qa run` records into the same one.
29
+ export const QA_VENDOR_BEACONS = Symbol("qa run --vendor-beacons");
30
+
31
+ export const VENDOR_BEACON_MODES = Object.freeze({
32
+ INTERCEPT: "intercept",
33
+ LIVE: "live",
34
+ });
35
+
36
+ // Kept per vendor; past these the counts still grow but no new value is kept.
37
+ const MAX_VALUES = 10;
38
+
39
+ // "intercept" or "live" for a run that drives browser test orders, null for
40
+ // one that does not (nothing is routed or recorded then). Any --vendor-beacons
41
+ // value but `live` is refused before anything runs, and so is the flag on a
42
+ // run with no browser test order, where it would do nothing.
43
+ export function vendorBeaconMode(args = {}) {
44
+ const flagged = Object.hasOwn(args, "vendor-beacons");
45
+ if (flagged) {
46
+ const value = args["vendor-beacons"];
47
+ if (typeof value !== "string" || value.trim().toLowerCase() !== VENDOR_BEACON_MODES.LIVE) {
48
+ const got = typeof value === "string" && value !== "" ? JSON.stringify(value) : "no value";
49
+ throw refused(`--vendor-beacons takes one value, live, which lets test-order runs send to the analytics vendors (got ${got}). Leave the flag off to answer vendor beacons locally.`);
50
+ }
51
+ }
52
+ const testOrder = String(args["test-order"] ?? "off").toLowerCase();
53
+ if (!testOrder || testOrder === "off" || testOrder === "false") {
54
+ if (flagged) throw refused("--vendor-beacons live applies only to a browser test-order run (--test-order <mode>); this run places no browser test orders and routes no vendor beacons. Drop --vendor-beacons, or add --test-order.");
55
+ return null;
56
+ }
57
+ return flagged ? VENDOR_BEACON_MODES.LIVE : VENDOR_BEACON_MODES.INTERCEPT;
58
+ }
59
+
60
+ // The per-run record. `configured` is the vendor keys the CampaignSpec
61
+ // analytics block declares.
62
+ export function createVendorBeaconLedger({ mode, analytics = null } = {}) {
63
+ const vendors = new Map();
64
+ return {
65
+ mode,
66
+ configured: configuredVendors(analytics),
67
+ vendors,
68
+ record(read) {
69
+ if (!read) return;
70
+ let entry = vendors.get(read.vendor);
71
+ if (!entry) {
72
+ entry = { requests: 0, purchase: read.purchase === null ? null : false, ids: new Set(), events: new Set(), endpoints: new Set() };
73
+ vendors.set(read.vendor, entry);
74
+ }
75
+ entry.requests += 1;
76
+ if (read.purchase === true) entry.purchase = true;
77
+ const keep = (set, values) => { for (const value of values) if (set.size < MAX_VALUES) set.add(value); };
78
+ keep(entry.ids, read.ids);
79
+ keep(entry.events, read.events);
80
+ keep(entry.endpoints, [read.endpoint]);
81
+ },
82
+ };
83
+ }
84
+
85
+ // The ledger to record into for this run, or null when nothing is routed.
86
+ export function vendorBeaconLedgerFor(args) {
87
+ return args?.[QA_VENDOR_BEACONS] || null;
88
+ }
89
+
90
+ function postDataOf(request) {
91
+ try {
92
+ const body = typeof request.postData === "function" ? request.postData() : null;
93
+ return typeof body === "string" ? body : null;
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ function headerOf(request, name) {
100
+ try {
101
+ const headers = typeof request.headers === "function" ? request.headers() : {};
102
+ return headers?.[name] || null;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ // The local answer: 204 with CORS headers for the requesting origin, so a
109
+ // beacon sent by fetch or XHR with credentials settles as a success rather
110
+ // than an error the vendor script retries.
111
+ function localAnswer(request) {
112
+ const origin = headerOf(request, "origin");
113
+ return {
114
+ status: 204,
115
+ headers: {
116
+ ...(origin
117
+ ? { "access-control-allow-origin": origin, "access-control-allow-credentials": "true", vary: "Origin" }
118
+ : { "access-control-allow-origin": "*" }),
119
+ "access-control-allow-methods": "GET, POST, OPTIONS",
120
+ "access-control-allow-headers": headerOf(request, "access-control-request-headers") || "content-type",
121
+ },
122
+ body: "",
123
+ };
124
+ }
125
+
126
+ function methodOf(request) {
127
+ try { return String(request.method()).toUpperCase(); } catch { return "GET"; }
128
+ }
129
+
130
+ // Route one browser context for the run. Intercept mode answers every request
131
+ // to a listed beacon endpoint locally and records it; anything else falls
132
+ // through to the context's other routes or the network. Live mode routes
133
+ // nothing and records the requests as they go out. Routing a context turns off
134
+ // its HTTP cache, which no QA check relies on (each leg opens a fresh context).
135
+ export async function routeVendorBeacons(context, ledger) {
136
+ if (!context || !ledger) return context;
137
+ const recordRequest = (request) => {
138
+ if (methodOf(request) === "OPTIONS") return;
139
+ ledger.record(readVendorBeacon(request.url(), postDataOf(request)));
140
+ };
141
+ if (ledger.mode === VENDOR_BEACON_MODES.LIVE) {
142
+ context.on("request", (request) => {
143
+ if (vendorBeaconFor(request.url())) recordRequest(request);
144
+ });
145
+ return context;
146
+ }
147
+ await context.route((url) => Boolean(vendorBeaconFor(url.href)), async (route) => {
148
+ const request = route.request();
149
+ recordRequest(request);
150
+ await route.fulfill(localAnswer(request));
151
+ });
152
+ return context;
153
+ }
154
+
155
+ // A browser whose every new context is routed for the run. The browser itself
156
+ // comes back unchanged when the run routes nothing. In intercept mode the
157
+ // context also blocks service workers: context.route does not see a request a
158
+ // service worker handles, so a page's worker could otherwise pass a beacon
159
+ // through to the vendor.
160
+ export function withVendorBeaconRouting(browser, ledger) {
161
+ if (!browser || !ledger) return browser;
162
+ const newContext = browser.newContext.bind(browser);
163
+ const intercept = ledger.mode !== VENDOR_BEACON_MODES.LIVE;
164
+ return new Proxy(browser, {
165
+ get(target, key, receiver) {
166
+ if (key === "newContext") {
167
+ return async (options, ...rest) => routeVendorBeacons(
168
+ await newContext(intercept ? { ...(options || {}), serviceWorkers: "block" } : options, ...rest),
169
+ ledger,
170
+ );
171
+ }
172
+ const value = Reflect.get(target, key, receiver);
173
+ return typeof value === "function" ? value.bind(target) : value;
174
+ },
175
+ });
176
+ }
177
+
178
+ // The verdict's evidence for the run.
179
+ export function vendorBeaconEvidence(ledger) {
180
+ const vendors = {};
181
+ for (const [vendor, entry] of ledger.vendors) {
182
+ vendors[vendor] = {
183
+ requests: entry.requests,
184
+ purchase_seen: entry.purchase,
185
+ ids: [...entry.ids],
186
+ events: [...entry.events],
187
+ endpoints: [...entry.endpoints],
188
+ };
189
+ }
190
+ // Only a vendor with listed beacon endpoints can be seen; a configured vendor
191
+ // without them (GTM, Northbeam) is named under configured_not_observable,
192
+ // never as one that sent nothing.
193
+ const observable = (vendor) => INTERCEPTED_VENDORS.includes(vendor);
194
+ const unlisted = new Map(NOT_INTERCEPTED_VENDORS.map((entry) => [entry.vendor, entry.reason]));
195
+ return {
196
+ mode: ledger.mode,
197
+ vendors,
198
+ configured: [...ledger.configured],
199
+ configured_not_seen: ledger.configured.filter((vendor) => observable(vendor) && !ledger.vendors.has(vendor)),
200
+ configured_not_observable: ledger.configured.filter((vendor) => !observable(vendor)),
201
+ // In live mode nothing is intercepted, so every vendor is listed; the
202
+ // unlisted ones keep their own reason.
203
+ not_intercepted: ledger.mode === VENDOR_BEACON_MODES.LIVE
204
+ ? VENDOR_KEYS.map((vendor) => ({ vendor, reason: unlisted.get(vendor) || "--vendor-beacons live" }))
205
+ : NOT_INTERCEPTED_VENDORS.map((entry) => ({ ...entry })),
206
+ };
207
+ }
208
+
209
+ function list(values) {
210
+ return values.length ? values.join(", ") : "none";
211
+ }
212
+
213
+ // One informational row on the verdict. It never fails and never blocks.
214
+ export function vendorBeaconAssertion(ledger) {
215
+ const evidence = vendorBeaconEvidence(ledger);
216
+ const seen = Object.entries(evidence.vendors).map(([vendor, entry]) => `${vendor} ${entry.requests}${entry.purchase_seen ? " (purchase)" : ""}`);
217
+ const live = ledger.mode === VENDOR_BEACON_MODES.LIVE;
218
+ const configured = new Set(evidence.configured);
219
+ const notIntercepted = live ? "" : `; not intercepted: ${list(evidence.not_intercepted.map((entry) => `${entry.vendor}${configured.has(entry.vendor) ? " (configured)" : ""}`))}`;
220
+ return {
221
+ id: "browser-test-order:vendor-beacons",
222
+ family: "browser-test-order",
223
+ page: "campaign",
224
+ status: STATUS.PASS,
225
+ severity: SEVERITY.INFO,
226
+ expected: live
227
+ ? "vendor beacons sent to the vendors (--vendor-beacons live); requests recorded as evidence, not purchase proof"
228
+ : "vendor beacons answered locally, so test orders stay out of vendor data; requests recorded as evidence, not purchase proof",
229
+ actual: `${live ? "sent" : "answered locally"}: ${list(seen)}; configured, never seen: ${list(evidence.configured_not_seen)}${notIntercepted}`,
230
+ evidence,
231
+ };
232
+ }
@@ -0,0 +1,306 @@
1
+ // The analytics vendor endpoints a `qa run --test-order` run holds back
2
+ // (campaigns-os#689, #683 item 5): one list, read by the browser router that
3
+ // answers the beacons locally and by classifyTagFire, so the tag inventory,
4
+ // parity and the verdict's vendor evidence all read the same requests.
5
+ //
6
+ // Vendor keys are the `analytics.vendors` keys Map Builder emits for the
7
+ // starter-template vendor adapters (its Route C vendor registry), plus `gtm`
8
+ // and `meta` for the SDK providers.
9
+ //
10
+ // Each entry:
11
+ // - loaders: requests that load the vendor's script. They always pass
12
+ // through: a blocked loader means the vendor function never exists, the
13
+ // adapter stays inactive and the run proves nothing.
14
+ // - beacons: requests that send data to the vendor, matched by host (or a
15
+ // subdomain of it) plus a path prefix, because some hosts serve both
16
+ // (analytics.tiktok.com). A loader match always wins over a beacon match.
17
+ // - id / event: the parameter carrying the vendor id and the event name,
18
+ // read from the query string first and then from the request body (a JSON
19
+ // object or array, dotted paths allowed, or URL-encoded lines). `idPath`
20
+ // reads the id from the URL path instead. null when the request does not
21
+ // name it in a field we could confirm.
22
+ // - purchaseEvents: the event names that mean a purchase for that vendor,
23
+ // as the starter-template adapters send them.
24
+ // - source: where the beacon endpoints were confirmed. Only an endpoint read
25
+ // from the vendor's public loader source or public documentation is wired
26
+ // in; a vendor with none is reported as not intercepted, by name, with
27
+ // `reason`.
28
+ //
29
+ // Loader sources were fetched read-only with curl on 2026-10-29 and read, never
30
+ // executed.
31
+
32
+ export const VENDOR_KEYS = Object.freeze([
33
+ "gtm",
34
+ "meta",
35
+ "ga4",
36
+ "axon",
37
+ "taboola",
38
+ "triplewhale",
39
+ "tiktok",
40
+ "northbeam",
41
+ "snap",
42
+ "pinterest",
43
+ ]);
44
+
45
+ export const VENDOR_ENDPOINTS = Object.freeze([
46
+ {
47
+ vendor: "gtm",
48
+ loaders: [{ host: "www.googletagmanager.com", path: "/gtm.js" }],
49
+ beacons: [],
50
+ id: null,
51
+ event: null,
52
+ purchaseEvents: [],
53
+ reason: "GTM sends no beacon of its own; each tag it loads sends to that tag's vendor, held back here only when the vendor is listed",
54
+ },
55
+ {
56
+ vendor: "meta",
57
+ loaders: [{ host: "connect.facebook.net", path: "/" }],
58
+ beacons: [
59
+ { host: "www.facebook.com", path: "/tr" },
60
+ { host: "www.facebook.com", path: "/privacy_sandbox/" },
61
+ ],
62
+ id: "id",
63
+ event: "ev",
64
+ purchaseEvents: ["Purchase"],
65
+ source: "fbevents.js (connect.facebook.net/en_US/fbevents.js) sends to https://www.facebook.com/tr and https://www.facebook.com/privacy_sandbox/…; id is the pixel id, ev the event name",
66
+ },
67
+ {
68
+ vendor: "ga4",
69
+ loaders: [{ host: "www.googletagmanager.com", path: "/gtag/js" }],
70
+ beacons: [
71
+ { host: "google-analytics.com", path: "/g/" },
72
+ { host: "analytics.google.com", path: "/g/" },
73
+ { host: "stats.g.doubleclick.net", path: "/g/" },
74
+ { host: "www.google.com", path: "/g/collect" },
75
+ ],
76
+ id: "tid",
77
+ event: "en",
78
+ purchaseEvents: ["purchase", "upsell_purchase"],
79
+ source: "gtag.js (www.googletagmanager.com/gtag/js) builds https://<region>.google-analytics.com/g/collect, https://analytics.google.com/g/collect and https://stats.g.doubleclick.net/g/collect, and retries a failed send at https://www.google.com/g/collect; tid is the measurement id, en the event name (batched events carry one en per body line)",
80
+ },
81
+ {
82
+ vendor: "axon",
83
+ loaders: [
84
+ { host: "s.axon.ai", path: "/" },
85
+ { host: "c.albss.com", path: "/" },
86
+ { host: "res4.applovin.com", path: "/" },
87
+ ],
88
+ beacons: [{ host: "b.applovin.com", path: "/v1/pixel" }],
89
+ id: "event_key",
90
+ // The payload is built through an obfuscated string table, so the field
91
+ // carrying the event name could not be confirmed.
92
+ event: null,
93
+ purchaseEvents: ["purchase"],
94
+ source: "pixel.js (s.axon.ai/pixel.js) sends to https://b.applovin.com/v1/pixel?event_key=<key>; loader.iife.js (c.albss.com) only loads scripts from res4.applovin.com",
95
+ },
96
+ {
97
+ vendor: "taboola",
98
+ loaders: [{ host: "cdn.taboola.com", path: "/" }],
99
+ beacons: [{ host: "trc.taboola.com", path: "/" }],
100
+ id: null,
101
+ idPath: /^\/(\d+)\//,
102
+ event: "en",
103
+ purchaseEvents: ["make_purchase", "upsell_purchase"],
104
+ source: "tfa.js (cdn.taboola.com/libtrc/unip/<account>/tfa.js) sends to https://trc.taboola.com/<account>/log/3/<type> and trc.taboola.com/sg/; the event name rides as en",
105
+ },
106
+ {
107
+ vendor: "triplewhale",
108
+ // /first returns the pixel source the base snippet evals; conf. serves its
109
+ // config model.
110
+ loaders: [
111
+ { host: "api.config-security.com", path: "/first" },
112
+ { host: "conf.config-security.com", path: "/" },
113
+ ],
114
+ // The purchase request's path is not readable in the obfuscated pixel, so
115
+ // every non-loader path on the API host is held back.
116
+ beacons: [
117
+ { host: "api.config-security.com", path: "/" },
118
+ { host: "api.whaledb.io", path: "/keep-alive" },
119
+ ],
120
+ id: "host",
121
+ event: "action",
122
+ purchaseEvents: ["Purchase", "UpsellPurchase"],
123
+ source: "the Triple Pixel headless base snippet (starter-template analytics-head.html) POSTs JSON {action, host, …} to https://api.config-security.com/event and loads the pixel from /first; the pixel's decoded string table names https://api.config-security.com and https://api.whaledb.io/keep-alive",
124
+ },
125
+ {
126
+ vendor: "tiktok",
127
+ loaders: [{ host: "analytics.tiktok.com", path: "/i18n/pixel/" }],
128
+ beacons: [
129
+ { host: "analytics.tiktok.com", path: "/api/v2/" },
130
+ { host: "analytics-ipv6.tiktokw.us", path: "/" },
131
+ ],
132
+ id: "context.pixel.code",
133
+ event: "event",
134
+ purchaseEvents: ["Purchase", "CompletePayment"],
135
+ source: "the TikTok pixel main script (analytics.tiktok.com/i18n/pixel/static/main.<id>.js) sends to https://analytics.tiktok.com/api/v2/pixel (and /api/v2/performance, /interaction, /monitor, /pixel/act) and https://analytics-ipv6.tiktokw.us/ipv6/enrich_ipv6; the JSON body carries event and context.pixel.code",
136
+ },
137
+ {
138
+ vendor: "northbeam",
139
+ loaders: [{ host: "j.northbeam.io", path: "/" }],
140
+ beacons: [],
141
+ id: null,
142
+ event: null,
143
+ purchaseEvents: [],
144
+ reason: "beacon endpoint unconfirmed: the loader (j.northbeam.io/ota-sp/<client id>.js) is per client and the public Northbeam docs name no beacon host",
145
+ },
146
+ {
147
+ vendor: "snap",
148
+ loaders: [{ host: "sc-static.net", path: "/" }],
149
+ beacons: [
150
+ { host: "tr.snapchat.com", path: "/" },
151
+ { host: "tr6.snapchat.com", path: "/" },
152
+ { host: "tr-shadow.snapchat.com", path: "/" },
153
+ ],
154
+ id: "pid",
155
+ event: "ev",
156
+ purchaseEvents: ["PURCHASE"],
157
+ source: "scevent.min.js (sc-static.net/scevent.min.js) sends to https://tr.snapchat.com (tr6. and tr-shadow. variants) at /p and /cm/*; the JSON body carries pid and ev",
158
+ },
159
+ {
160
+ vendor: "pinterest",
161
+ loaders: [
162
+ { host: "s.pinimg.com", path: "/" },
163
+ { host: "ct.pinterest.com", path: "/static/" },
164
+ { host: "ct.pinterest.com", path: "/ct.html" },
165
+ ],
166
+ beacons: [
167
+ { host: "ct.pinterest.com", path: "/v3/" },
168
+ { host: "ct.pinterest.com", path: "/user/" },
169
+ { host: "ct.pinterest.com", path: "/stats/" },
170
+ ],
171
+ id: "tid",
172
+ event: "event",
173
+ purchaseEvents: ["checkout"],
174
+ source: "core.js (s.pinimg.com/ct/core.js) loads s.pinimg.com/ct/lib/main.<hash>.js, which sends to https://ct.pinterest.com/v3/, /user/ and /stats/; Pinterest's base-code docs give the fallback https://ct.pinterest.com/v3/?event=init&tid=<tag id>",
175
+ },
176
+ ]);
177
+
178
+ const BY_VENDOR = new Map(VENDOR_ENDPOINTS.map((entry) => [entry.vendor, entry]));
179
+
180
+ // The vendors whose beacons are held back, and the ones that are not, with why.
181
+ export const INTERCEPTED_VENDORS = Object.freeze(VENDOR_ENDPOINTS.filter((entry) => entry.beacons.length > 0).map((entry) => entry.vendor));
182
+ export const NOT_INTERCEPTED_VENDORS = Object.freeze(
183
+ VENDOR_ENDPOINTS.filter((entry) => entry.beacons.length === 0).map((entry) => ({ vendor: entry.vendor, reason: entry.reason })),
184
+ );
185
+
186
+ // Bounds on what one request can contribute: the body read and the ids and
187
+ // event names kept from it.
188
+ const MAX_BODY_CHARS = 64 * 1024;
189
+ const MAX_FIELD_CHARS = 128;
190
+
191
+ export function vendorEndpoint(vendor) {
192
+ return BY_VENDOR.get(vendor) || null;
193
+ }
194
+
195
+ function hostMatches(host, suffix) {
196
+ return host === suffix || host.endsWith(`.${suffix}`);
197
+ }
198
+
199
+ // A path prefix matches whole segments: "/tr" is /tr and /tr/…, never /trust.
200
+ function matches(matcher, host, path) {
201
+ if (!hostMatches(host, matcher.host)) return false;
202
+ const prefix = matcher.path;
203
+ return path === prefix || path.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`);
204
+ }
205
+
206
+ function parseUrl(value) {
207
+ try {
208
+ const url = new URL(String(value));
209
+ return url.protocol === "http:" || url.protocol === "https:" ? url : null;
210
+ } catch {
211
+ return null;
212
+ }
213
+ }
214
+
215
+ // The listed vendor whose BEACON this URL is, or null. A loader is never a
216
+ // beacon, even on a host that also serves beacons.
217
+ export function vendorBeaconFor(urlValue) {
218
+ const url = parseUrl(urlValue);
219
+ if (!url) return null;
220
+ const host = url.hostname.toLowerCase();
221
+ const path = url.pathname;
222
+ if (VENDOR_ENDPOINTS.some((entry) => entry.loaders.some((loader) => matches(loader, host, path)))) return null;
223
+ return VENDOR_ENDPOINTS.find((entry) => entry.beacons.some((beacon) => matches(beacon, host, path))) || null;
224
+ }
225
+
226
+ function boundedText(value) {
227
+ if (value === null || value === undefined) return null;
228
+ if (typeof value !== "string" && typeof value !== "number") return null;
229
+ const text = String(value).trim();
230
+ return text ? text.slice(0, MAX_FIELD_CHARS) : null;
231
+ }
232
+
233
+ // A request body as records: each JSON object (a top-level array is a batch)
234
+ // or each URL-encoded line (GA4 batches one event per line).
235
+ function bodyRecords(body) {
236
+ if (typeof body !== "string" || !body) return [];
237
+ const text = body.length > MAX_BODY_CHARS ? body.slice(0, MAX_BODY_CHARS) : body;
238
+ try {
239
+ const parsed = JSON.parse(text);
240
+ const list = Array.isArray(parsed) ? parsed : [parsed];
241
+ return list.filter((item) => item && typeof item === "object" && !Array.isArray(item)).map((item) => ({ json: item }));
242
+ } catch {
243
+ return text.split(/\r?\n/).filter(Boolean).map((line) => ({ params: new URLSearchParams(line) }));
244
+ }
245
+ }
246
+
247
+ function recordField(record, name) {
248
+ if (record.params) return record.params.get(name);
249
+ let value = record.json;
250
+ for (const key of name.split(".")) {
251
+ if (!value || typeof value !== "object") return null;
252
+ value = value[key];
253
+ }
254
+ return value;
255
+ }
256
+
257
+ // One request to a listed beacon endpoint, read for evidence: the vendor, the
258
+ // ids and event names it carries, and whether one of those events is a
259
+ // purchase (null when the entry reads no event name). Nothing else from the
260
+ // URL or body is kept; `endpoint` is origin + path only.
261
+ export function readVendorBeacon(urlValue, body = null) {
262
+ const entry = vendorBeaconFor(urlValue);
263
+ if (!entry) return null;
264
+ const url = parseUrl(urlValue);
265
+ const records = bodyRecords(body);
266
+ const read = (name) => {
267
+ if (!name) return [];
268
+ const values = [url.searchParams.get(name), ...records.map((record) => recordField(record, name))];
269
+ return [...new Set(values.map(boundedText).filter(Boolean))];
270
+ };
271
+ const ids = entry.idPath ? [boundedText(url.pathname.match(entry.idPath)?.[1])].filter(Boolean) : read(entry.id);
272
+ const events = read(entry.event);
273
+ return {
274
+ vendor: entry.vendor,
275
+ endpoint: `${url.origin}${url.pathname}`,
276
+ ids,
277
+ events,
278
+ purchase: entry.event ? events.some((event) => entry.purchaseEvents.includes(event)) : null,
279
+ };
280
+ }
281
+
282
+ // The vendors a CampaignSpec analytics block configures, as vendor keys:
283
+ // analytics.vendors (the Map Builder shape), providers
284
+ // keyed by vendor (facebook is meta) and out-of-band pixels. Disabled entries
285
+ // and an analytics block switched off configure nothing.
286
+ const VENDOR_ALIASES = Object.freeze({ facebook: "meta", triplepixel: "triplewhale", snapchat: "snap" });
287
+
288
+ export function configuredVendors(analytics) {
289
+ if (!analytics || typeof analytics !== "object" || Array.isArray(analytics)) return [];
290
+ if (analytics.enabled === false || analytics.mode === "disabled") return [];
291
+ const found = new Set();
292
+ const add = (name) => {
293
+ const key = VENDOR_ALIASES[String(name).toLowerCase()] || String(name).toLowerCase();
294
+ if (BY_VENDOR.has(key)) found.add(key);
295
+ };
296
+ for (const block of [analytics.vendors, analytics.providers]) {
297
+ if (!block || typeof block !== "object" || Array.isArray(block)) continue;
298
+ for (const [name, value] of Object.entries(block)) {
299
+ if (value && typeof value === "object" && value.enabled !== false) add(name);
300
+ }
301
+ }
302
+ for (const pixel of Array.isArray(analytics.out_of_band_pixels) ? analytics.out_of_band_pixels : []) {
303
+ if (pixel?.vendor) add(pixel.vendor);
304
+ }
305
+ return VENDOR_KEYS.filter((key) => found.has(key));
306
+ }
package/src/qc-accept.mjs CHANGED
@@ -439,6 +439,34 @@ function readabilityReviewCrop(row) {
439
439
  return typeof ref?.path === "string" ? ref.path : undefined;
440
440
  }
441
441
 
442
+ // A layout.conventions warning in words, from its observation: each
443
+ // floating CTA container that holds more than its control, with what else it
444
+ // holds, or each CTA label under the minimum size with its measured size.
445
+ // Null for any other row.
446
+ function layoutSummary(row) {
447
+ if (row?.check !== "layout.conventions" || row.result !== "warning" || !isPlainObject(row.observation)) return null;
448
+ const flagged = new Set((Array.isArray(row.members) ? row.members : []).filter((member) => member?.result === "warning").map((member) => member.key));
449
+ const where = `${row.subject?.page ?? "(no page)"}${row.subject?.viewport ? ` ${row.subject.viewport}` : ""}`;
450
+ if (row.subject?.key === "floating_cta") {
451
+ const containers = (Array.isArray(row.observation.containers) ? row.observation.containers : []).filter((entry) => flagged.has(entry?.selector_path));
452
+ if (!containers.length) return null;
453
+ const texts = containers.map((entry) => {
454
+ const controls = Array.isArray(entry.controls) ? entry.controls.length : 0;
455
+ const other = Array.isArray(entry.other_text) ? entry.other_text : [];
456
+ const more = Number.isInteger(entry.other_text_count) && entry.other_text_count > other.length ? `, +${entry.other_text_count - other.length} more` : "";
457
+ const text = entry.other_text_count ? `; other text ${other.join(", ")}${more}` : "";
458
+ return `${entry.selector_path} (${entry.position}) holds ${controls} control${controls === 1 ? "" : "s"}${text}`;
459
+ });
460
+ return `${where}: floating CTA should hold its control alone: ${texts.join(" | ")}`;
461
+ }
462
+ if (row.subject?.key === "cta_label_size") {
463
+ const small = (Array.isArray(row.observation.members) ? row.observation.members : []).filter((member) => flagged.has(member?.selector_path));
464
+ if (!small.length) return null;
465
+ return `${where}: CTA label under ${row.observation.min_font_size_px}px: ${small.map((member) => `${member.selector_path} ${member.font_size_px}px (${member.role})`).join(", ")}`;
466
+ }
467
+ return null;
468
+ }
469
+
442
470
  function visualSummary(row) {
443
471
  const item = row.observation;
444
472
  if (!item || !row.check?.startsWith("visual.")) return null;
@@ -467,7 +495,7 @@ function handoffEntry(row) {
467
495
  reason_code: row.reason_code,
468
496
  accept_eligible: row.accept_eligible === true,
469
497
  members: Array.isArray(row.members) ? row.members : [],
470
- summary: designAccessSummary(row) ?? parityRowSummary(row) ?? visualSummary(row) ?? readabilityPairSummary([row]) ?? `${row.check} ${row.result}${row.reason_code ? ` (${row.reason_code})` : ""} on ${row.subject?.page ?? "(no page)"}${row.subject?.key == null ? "" : `: ${row.subject.key}`}`,
498
+ summary: designAccessSummary(row) ?? parityRowSummary(row) ?? visualSummary(row) ?? layoutSummary(row) ?? readabilityPairSummary([row]) ?? `${row.check} ${row.result}${row.reason_code ? ` (${row.reason_code})` : ""} on ${row.subject?.page ?? "(no page)"}${row.subject?.key == null ? "" : `: ${row.subject.key}`}`,
471
499
  ...(crop === undefined ? {} : { crop }),
472
500
  };
473
501
  }
@@ -13,7 +13,8 @@
13
13
  // - Polish checks: the record's rules, {thresholds, vocabulary,
14
14
  // evaluate(cell, thresholds) => Derived[]} (MEDIA_WEIGHT_QC_RULES for
15
15
  // media_weight; READABILITY_QC_RULES for readability, which also carries
16
- // the record's fixed producer values, limits and route set).
16
+ // the record's fixed producer values, limits and route set;
17
+ // LAYOUT_QC_RULES for the layout conventions read from the same record).
17
18
  // - Doctor checks recompute from the built HTML on every read and are called
18
19
  // by doctor itself; their entries name the evaluator for completeness.
19
20
  //
@@ -39,6 +40,9 @@ export const QC_CHECK_REGISTRY = Object.freeze({
39
40
  "policy.availability": Object.freeze({ leg: "qa", unit: "1.4", module: "./qa-policy-links.mjs", rederive: "rederiveQcResult" }),
40
41
  // Text readability
41
42
  "readability.contrast": Object.freeze({ leg: "polish", unit: "2.4", module: "./polish-readability.mjs", rederive: "READABILITY_QC_RULES", record: "readability" }),
43
+ // Layout conventions (shared commerce brand contract), read from the
44
+ // readability record
45
+ "layout.conventions": Object.freeze({ leg: "polish", unit: "2.5", module: "./polish-readability.mjs", rederive: "LAYOUT_QC_RULES", record: "readability" }),
42
46
  "visual.reference": Object.freeze({ leg: "polish", unit: "3.2", module: "./polish-visual.mjs", rederive: "VISUAL_QC_RULES", record: "captures" }),
43
47
  "visual.diff": Object.freeze({ leg: "polish", unit: "3.4", module: "./polish-visual.mjs", rederive: "VISUAL_QC_RULES", record: "captures" }),
44
48
  // Element parity against the source HTML