@nextcommerce/campaigns-os 1.61.0 → 1.62.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 (127) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +118 -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 +29 -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 +2 -0
  22. package/campaign-spec/dist/rules/index.js +6 -0
  23. package/campaign-spec/dist/types.d.ts +43 -9
  24. package/compatibility.json +1 -1
  25. package/contracts/commerce-surface-catalog.json +59 -53
  26. package/contracts/effects.v1.json +549 -165
  27. package/contracts/fixtures/runtime-recipe/accept/current.json +3 -0
  28. package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -0
  29. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  30. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -0
  31. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -0
  32. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -0
  33. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -0
  34. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -0
  35. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -0
  36. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -0
  37. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -0
  38. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +3 -0
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -0
  40. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -0
  41. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -0
  42. package/contracts/release-ledger.json +999 -0
  43. package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
  44. package/contracts/supported-surface.json +8 -8
  45. package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
  46. package/docs/build-packet.md +430 -37
  47. package/docs/campaign-standardization-report.md +4 -4
  48. package/docs/campaigns-os-build-flow.md +54 -3
  49. package/docs/campaignspec-authoring-examples.md +29 -4
  50. package/docs/design-source-package.md +18 -6
  51. package/docs/effects.md +17 -9
  52. package/docs/local-setup.md +1 -1
  53. package/docs/orientation-contract-reference.md +1 -1
  54. package/docs/polish-evidence.md +2 -0
  55. package/docs/progress-snapshots.md +7 -4
  56. package/docs/qa-and-test-orders.md +147 -1
  57. package/docs/readback.md +40 -5
  58. package/docs/runtime-readiness.md +6 -3
  59. package/docs/sdk-storage-compatibility.md +1 -1
  60. package/docs/skills-revision.md +10 -10
  61. package/package.json +1 -1
  62. package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
  63. package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
  64. package/schemas/campaign-spec.v4.schema.json +19 -3
  65. package/schemas/campaign-spec.v5.schema.json +19 -3
  66. package/schemas/campaigns-os-readback.v2.schema.json +62 -1
  67. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  68. package/skills/campaign-readback-classification/SKILL.md +3 -3
  69. package/skills/campaign-run-evidence/SKILL.md +3 -3
  70. package/skills/contribution-intake/SKILL.md +3 -3
  71. package/skills/next-campaigns-build/SKILL.md +4 -3
  72. package/skills/next-campaigns-os/SKILL.md +6 -4
  73. package/skills/next-campaigns-os-setup/SKILL.md +5 -3
  74. package/skills/next-campaigns-polish/SKILL.md +5 -4
  75. package/skills/next-campaigns-qa/SKILL.md +7 -4
  76. package/skills/next-campaigns-sdk-bump/SKILL.md +42 -9
  77. package/skills.json +11 -11
  78. package/src/brand-theme.mjs +2 -1
  79. package/src/build-brief.mjs +4 -1
  80. package/src/campaign-workspace.mjs +288 -15
  81. package/src/change-scope.mjs +284 -0
  82. package/src/cli.mjs +308 -86
  83. package/src/commercial-journey.mjs +16 -3
  84. package/src/commercial-parity.mjs +15 -7
  85. package/src/content-residue.mjs +4 -2
  86. package/src/design-source-package.mjs +36 -0
  87. package/src/design-source-publication.mjs +9 -6
  88. package/src/directory-lock.mjs +73 -19
  89. package/src/doctor/checks.mjs +79 -7
  90. package/src/doctor/ci.mjs +75 -10
  91. package/src/doctor/inspect.mjs +23 -6
  92. package/src/doctor/next-step.mjs +7 -0
  93. package/src/doctor-sidecar.mjs +3 -2
  94. package/src/evidence-debt.mjs +47 -7
  95. package/src/finding-cause.mjs +12 -11
  96. package/src/input-refresh.mjs +339 -32
  97. package/src/invocation.mjs +7 -1
  98. package/src/live-campaign-refs.mjs +7 -2
  99. package/src/local-preview-policy.mjs +35 -2
  100. package/src/page-kit-analytics-vendors.mjs +132 -0
  101. package/src/polish-browser.mjs +3 -1
  102. package/src/polish-readability.mjs +221 -9
  103. package/src/polish-visual.mjs +9 -2
  104. package/src/progress-node.mjs +3 -2
  105. package/src/qa-analytics-parity.mjs +17 -1
  106. package/src/qa-browser.mjs +40 -4
  107. package/src/qa-commercial-parity.mjs +50 -3
  108. package/src/qa-node.mjs +196 -16
  109. package/src/qa-publish.mjs +9 -8
  110. package/src/qa-sidecar.mjs +16 -4
  111. package/src/qa-vendor-beacons.mjs +232 -0
  112. package/src/qa-vendor-endpoints.mjs +306 -0
  113. package/src/qc-accept.mjs +29 -1
  114. package/src/qc-check-registry.mjs +5 -1
  115. package/src/qc-results.mjs +73 -26
  116. package/src/readback.mjs +58 -10
  117. package/src/route-identity.mjs +10 -0
  118. package/src/run-record.mjs +18 -10
  119. package/src/runtime-state-ignore.mjs +6 -2
  120. package/src/sdk-storage-compatibility.mjs +21 -2
  121. package/src/source-html-intake.mjs +15 -1
  122. package/src/spec-derive-campaign.mjs +216 -0
  123. package/src/spec-derive.mjs +473 -53
  124. package/src/spec-identity.mjs +14 -0
  125. package/src/stage-ledger.mjs +40 -1
  126. package/src/stage-record.mjs +58 -13
  127. package/src/target-lock.mjs +11 -1
@@ -22,6 +22,9 @@ import { normalizePublicRouteSlug } from "./route-identity.mjs";
22
22
 
23
23
  export const LOCAL_PREVIEW_POLICY = "local_preview";
24
24
  export const HOSTED_TEMPLATE_PREVIEW_POLICY = "hosted_template_preview";
25
+ // A page-only change since Polish last reviewed the campaign (#720): Polish
26
+ // is advisory for it, on any target, so its stale evidence is carried.
27
+ export const PAGE_ONLY_CHANGE_POLICY = "page_only_change";
25
28
  export const CARRIED_FORWARD = "carried_forward";
26
29
 
27
30
  // The checks carried forward, one by one. Each is evidence the campaign does
@@ -136,7 +139,9 @@ export function carried(gate, policy = LOCAL_PREVIEW_POLICY, packet = null) {
136
139
  ...(policy === HOSTED_TEMPLATE_PREVIEW_POLICY
137
140
  ? { required_actions: [hostedTemplateQaAction(previewUrl)] }
138
141
  : {}),
139
- carried_forward: { policy, from_status: gate.status, evidence: "missing" },
142
+ // A page-only carry stands on a review of an older build, so its evidence
143
+ // is stale, except where no Polish was ever recorded.
144
+ carried_forward: { policy, from_status: gate.status, evidence: policy === PAGE_ONLY_CHANGE_POLICY && gate.code !== "polish.evidence_missing" ? "stale" : "missing" },
140
145
  };
141
146
  }
142
147
 
@@ -156,13 +161,38 @@ export function applyLocalPreviewToCheckpoint(gate, { packet, report, baseUrl =
156
161
  }
157
162
 
158
163
  // The polish gate folds the checkpoint in, so it is carried forward only when
159
- // whatever it owns from the checkpoint was carried forward too.
164
+ // whatever it owns from the checkpoint was carried forward too. Advisory
165
+ // Polish for a page-only change is never a ladder step.
160
166
  export function polishCarriedForwardForLadder(report, gate) {
167
+ if (gate?.status === CARRIED_FORWARD && gate.carried_forward?.policy === PAGE_ONLY_CHANGE_POLICY) return true;
161
168
  return gate?.status === CARRIED_FORWARD
162
169
  && !report?.theme?.waiver
163
170
  && !report?.stages?.polish?.evidence?.visual_review?.page_load;
164
171
  }
165
172
 
173
+ // The Polish gate codes and checkpoint codes that mean "evidence for an older
174
+ // build": the only blocks a page-only change carries. A Polish recorded as
175
+ // blocked, self-certified or incomplete stays a block.
176
+ const PAGE_ONLY_POLISH_CODES = Object.freeze(new Set(["polish.evidence_missing", "polish.stale", "polish.output_drift"]));
177
+ const PAGE_ONLY_CHECKPOINT_CODES = Object.freeze(new Set(["polish.hidden_eager_media.capture_stale"]));
178
+
179
+ export function isPageOnlyCarry(gate) {
180
+ return gate?.status === CARRIED_FORWARD && gate.carried_forward?.policy === PAGE_ONLY_CHANGE_POLICY;
181
+ }
182
+
183
+ // `debt` is assessEvidenceDebt's result for the same packet and report.
184
+ export function applyPageOnlyChangeToCheckpoint(gate, debt) {
185
+ if (gate?.status !== "blocked" || debt?.polish?.advisory !== true || !PAGE_ONLY_CHECKPOINT_CODES.has(gate.code)) return gate;
186
+ return carried(gate, PAGE_ONLY_CHANGE_POLICY);
187
+ }
188
+
189
+ export function applyPageOnlyChangeToPolishGate(gate, { debt, checkpointGate = null } = {}) {
190
+ if (gate?.status !== "blocked" || debt?.polish?.advisory !== true) return gate;
191
+ if (gate.owned_checkpoint_only) return isPageOnlyCarry(checkpointGate) ? carried(gate, PAGE_ONLY_CHANGE_POLICY) : gate;
192
+ if (gate.owned_checkpoint_status === "blocked" && !isPageOnlyCarry(checkpointGate) && checkpointGate?.status !== CARRIED_FORWARD) return gate;
193
+ return PAGE_ONLY_POLISH_CODES.has(gate.code) ? carried(gate, PAGE_ONLY_CHANGE_POLICY) : gate;
194
+ }
195
+
166
196
  export function applyLocalPreviewToPolishGate(gate, { packet, checkpointGate = null, baseUrl = null } = {}) {
167
197
  if (gate?.status !== "blocked") return gate;
168
198
  // A template-stock map has no route that capture can measure, even when
@@ -189,6 +219,9 @@ export function starterResidueIsExpected(themeGate, { packet, baseUrl = null } =
189
219
  }
190
220
 
191
221
  export function carriedForwardMessage(gate) {
222
+ if (gate?.carried_forward?.policy === PAGE_ONLY_CHANGE_POLICY) {
223
+ return `Advisory for a page-only change: ${gate.reason} The change since Polish last reviewed this campaign touches only page copy, images or styling, so Polish is reported and not required; run it when the change needs a visual review.`;
224
+ }
192
225
  if (gate?.carried_forward?.policy === HOSTED_TEMPLATE_PREVIEW_POLICY) {
193
226
  return `Carried forward on the hosted preview: ${gate.reason} This evidence is missing, not passed; production QA still requires it.`;
194
227
  }
@@ -0,0 +1,132 @@
1
+ // Doctor's advisory read of the starter-template vendor keys (#689): whether
2
+ // the vendors the CampaignSpec declares under analytics.vendors and the
3
+ // vendors the target's _data/campaigns.json entry switches on agree.
4
+ //
5
+ // A vendor adapter loads when its id key in the entry is non-empty (absent or
6
+ // empty is off), so the entry is what ships. The spec is what the Map says
7
+ // should ship, and spec derive mirrors the id from the entry back into it.
8
+ // Every finding is a warning: a vendor that is off, on by accident or pointed
9
+ // at a placeholder account loses or pollutes attribution, but never an order,
10
+ // and the operator may have a reason. Read-only: nothing is written.
11
+ import { ANALYTICS_VENDORS, isPlaceholderVendorId } from "../campaign-spec/dist/index.js";
12
+ import { PAGE_KIT_CAMPAIGNS_REL_PATH } from "./page-kit-campaign-config.mjs";
13
+ import { SPEC_DERIVE_COMMAND } from "./page-kit-sdk-version.mjs";
14
+
15
+ export const PAGE_KIT_ANALYTICS_VENDORS_SCOPE = "page_kit.analytics_vendors";
16
+
17
+ function isPlainObject(value) {
18
+ return value !== null && typeof value === "object" && !Array.isArray(value);
19
+ }
20
+
21
+ // The analytics kill switch the Map Builder honours: with it set, the spec
22
+ // compiler writes no vendor key, so the spec declares no vendor.
23
+ function analyticsDisabled(analytics) {
24
+ return isPlainObject(analytics) && (analytics.enabled === false || analytics.mode === "disabled");
25
+ }
26
+
27
+ // The template gate is `campaign.<key> and campaign.<key> != ""`: any value
28
+ // but an absent, null, false or blank one switches the vendor on.
29
+ function keySwitchesVendorOn(value) {
30
+ if (value === undefined || value === null || value === false) return false;
31
+ return typeof value !== "string" || value.trim() !== "";
32
+ }
33
+
34
+ // The vendors the spec declares, keyed by vendor: an object entry not turned
35
+ // off with enabled:false. A declared vendor with no id is still declared (the
36
+ // AnalyticsContractShape rule names the missing id).
37
+ function declaredVendors(analytics) {
38
+ const declared = new Map();
39
+ if (!isPlainObject(analytics) || analyticsDisabled(analytics) || !isPlainObject(analytics.vendors)) return declared;
40
+ for (const { key } of ANALYTICS_VENDORS) {
41
+ const vendor = analytics.vendors[key];
42
+ if (isPlainObject(vendor) && vendor.enabled !== false) declared.set(key, vendor);
43
+ }
44
+ return declared;
45
+ }
46
+
47
+ // Compare the spec's vendors with the target entry. `targetLoad` is the
48
+ // loadPageKitCampaignEntry result doctor already holds. Returns the vendors
49
+ // either side switches on and one finding per disagreement; findings name keys
50
+ // and vendors only, never a value read from campaigns.json.
51
+ export function evaluatePageKitAnalyticsVendors({ spec, targetLoad }) {
52
+ // Without a readable spec there is nothing to compare the entry with; the
53
+ // spec checks report why it is unreadable.
54
+ if (!isPlainObject(spec) || targetLoad?.status !== "ok" || !isPlainObject(targetLoad.entry)) {
55
+ return { scope: PAGE_KIT_ANALYTICS_VENDORS_SCOPE, status: "not_applicable", vendors: [], findings: [] };
56
+ }
57
+ const entry = targetLoad.entry;
58
+ const slug = targetLoad.public_route_slug;
59
+ const source = (key) => `${PAGE_KIT_CAMPAIGNS_REL_PATH}[${slug}].${key}`;
60
+ const analytics = spec?.analytics;
61
+ const disabled = analyticsDisabled(analytics);
62
+ const declared = declaredVendors(analytics);
63
+ const vendors = [];
64
+ const findings = [];
65
+ const finding = (code, def, message, repair) => findings.push({
66
+ code: `${PAGE_KIT_ANALYTICS_VENDORS_SCOPE}.${code}`,
67
+ vendor: def.key,
68
+ id_key: def.idKey,
69
+ message,
70
+ repair,
71
+ });
72
+
73
+ for (const def of ANALYTICS_VENDORS) {
74
+ const field = `analytics.vendors.${def.key}`;
75
+ const raw = Object.hasOwn(entry, def.idKey) ? entry[def.idKey] : undefined;
76
+ const on = keySwitchesVendorOn(raw);
77
+ const specVendor = declared.get(def.key);
78
+ if (on || specVendor) vendors.push(def.key);
79
+ if (specVendor && !on) {
80
+ finding(
81
+ "key_missing",
82
+ def,
83
+ `The CampaignSpec declares ${field}, but ${source(def.idKey)} is absent or empty, so the template never loads ${def.label} and it receives no events. Set the key (the Map's spec compiler writes it), or remove the vendor from the Map if the campaign should not send to ${def.label}.`,
84
+ { kind: "edit", target: source(def.idKey) },
85
+ );
86
+ continue;
87
+ }
88
+ if (!specVendor && on) {
89
+ // spec derive can record an undeclared vendor; it cannot undo an
90
+ // authored "off", so that case is pointed at the Map instead.
91
+ const turnedOff = disabled
92
+ ? "turns analytics off (analytics.enabled false or mode disabled)"
93
+ : isPlainObject(analytics?.vendors?.[def.key]) ? `turns ${field} off (enabled: false)` : null;
94
+ finding(
95
+ "undeclared",
96
+ def,
97
+ turnedOff
98
+ ? `${source(def.idKey)} switches ${def.label} on, but the CampaignSpec ${turnedOff}. The template loads ${def.label} on every page regardless. Clear the key if ${def.label} should be off, or turn it back on in the Map.`
99
+ : `${source(def.idKey)} switches ${def.label} on, but the CampaignSpec does not declare ${field}. The template loads ${def.label} on every page. Record it in the spec with \`${SPEC_DERIVE_COMMAND}\` (or in the Map), or clear the key if ${def.label} should be off.`,
100
+ turnedOff ? { kind: "edit", target: source(def.idKey) } : { kind: "command", command: SPEC_DERIVE_COMMAND },
101
+ );
102
+ continue;
103
+ }
104
+ if (!specVendor) continue;
105
+ const value = typeof raw === "string" ? raw.trim() : null;
106
+ if (value !== null && isPlaceholderVendorId(value)) {
107
+ finding(
108
+ "key_placeholder",
109
+ def,
110
+ `${source(def.idKey)} holds a placeholder, not a real ${def.label} id. The template loads ${def.label} for any non-empty value, so events go to an account that does not exist. Replace it with the merchant's id.`,
111
+ { kind: "edit", target: source(def.idKey) },
112
+ );
113
+ continue;
114
+ }
115
+ const specId = typeof specVendor.id === "string" ? specVendor.id.trim() : "";
116
+ if (specId && value !== null && value !== specId) {
117
+ finding(
118
+ "id_mismatch",
119
+ def,
120
+ `${source(def.idKey)} and ${field}.id name different ${def.label} ids. The campaigns.json value is what ships; \`${SPEC_DERIVE_COMMAND}\` mirrors it into the spec, or correct the key if the spec is right.`,
121
+ { kind: "command", command: SPEC_DERIVE_COMMAND },
122
+ );
123
+ }
124
+ }
125
+
126
+ return {
127
+ scope: PAGE_KIT_ANALYTICS_VENDORS_SCOPE,
128
+ status: findings.length ? "advisory" : "ok",
129
+ vendors,
130
+ findings,
131
+ };
132
+ }
@@ -849,7 +849,8 @@ async function runImageProbe({ session, mainFrame, documentContextChanged, probe
849
849
  // screenshot failed, crop_unavailable; so does every crop taken when the
850
850
  // bound ends before the re-read. crop_ms runs from the first crop step to the
851
851
  // end of the re-read.
852
- // Returns { status, capped, coverage_gaps, elements, crops, probe_ms, crop_ms }.
852
+ // Returns { status, capped, coverage_gaps, elements, crops, layout, probe_ms,
853
+ // crop_ms }; layout only when the probe read it.
853
854
  async function probeReadabilityDocument(options) {
854
855
  const token = { cancelled: false };
855
856
  const cropToken = { cancelled: false };
@@ -946,6 +947,7 @@ async function runReadabilityProbe({ session, mainFrame, documentContextChanged,
946
947
  coverage_gaps: read.coverage_gaps,
947
948
  elements,
948
949
  crops,
950
+ ...(read.layout ? { layout: read.layout } : {}),
949
951
  probe_ms: probeMs,
950
952
  crop_ms: cropMs,
951
953
  };
@@ -17,14 +17,19 @@
17
17
  // the fields the helper derives from them;
18
18
  // - coverage_gaps[]: what the probe could not measure: closed shadow roots,
19
19
  // cross-origin frames, loading, hidden or generated-text role elements, and
20
- // selected, active or expanded states not present at load.
20
+ // selected, active or expanded states not present at load;
21
+ // - layout (cells of a record that carries layout_conventions, measured cells
22
+ // only): the viewport size and the floating CTA containers the probe found,
23
+ // read by the layout.conventions check (LAYOUT_QC_RULES).
21
24
  import { createHash } from "node:crypto";
22
- import { mkdirSync, writeFileSync } from "node:fs";
23
- import { join, relative, sep } from "node:path";
25
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
26
+ import { dirname, join, relative, sep } from "node:path";
27
+ import { fileURLToPath } from "node:url";
24
28
 
25
29
  import { resolveBuiltSiteScope } from "./built-site-scope.mjs";
26
30
  import { CART_PLACEHOLDERS_LIMITS } from "./cart-placeholders.mjs";
27
31
  import { contrastToolkit } from "./contrast.mjs";
32
+ import { canonicalJson } from "./polish-capture.mjs";
28
33
  import {
29
34
  READABILITY_ADDED_RUN_MS,
30
35
  READABILITY_CROP_RUN_MS,
@@ -35,6 +40,7 @@ import { MAX_POLISH_CAPTURE_ROUTES, POLISH_CAPTURE_VIEWPORTS } from "./polish-no
35
40
  import { QC_PRODUCERS, aggregateQcResults, polishRecordIntegrity } from "./qc-results.mjs";
36
41
 
37
42
  export const READABILITY_CHECK = "readability.contrast";
43
+ export const LAYOUT_CHECK = "layout.conventions";
38
44
  export const READABILITY_SCHEMA = "campaigns-os-polish-readability/v0";
39
45
  export const READABILITY_PRODUCER = QC_PRODUCERS.polish;
40
46
  export const READABILITY_ROUTE_SOURCE = "built_site";
@@ -402,6 +408,162 @@ export const READABILITY_QC_RULES = Object.freeze({
402
408
  },
403
409
  });
404
410
 
411
+ // ---------------------------------------------------------------------------
412
+ // Layout conventions
413
+
414
+ // The layout_conventions block of the shared commerce brand contract, without
415
+ // its prose (`rule` fields): the selectors the probe reads with and the
416
+ // thresholds the layout.conventions check judges with. A record stores the
417
+ // block it was measured with; a reader re-derives only with the same block.
418
+ const SHARED_COMMERCE_CONTRACT = join(dirname(fileURLToPath(import.meta.url)), "..", "contracts", "template-brand-contract.shared-commerce.v0.json");
419
+ const withoutProse = (value) => (isPlainObject(value)
420
+ ? Object.freeze(Object.fromEntries(Object.entries(value).filter(([field]) => field !== "rule").map(([field, entry]) => [field, withoutProse(entry)])))
421
+ : Array.isArray(value) ? Object.freeze(value.map(withoutProse)) : value);
422
+ export const LAYOUT_CONVENTIONS = withoutProse(JSON.parse(readFileSync(SHARED_COMMERCE_CONTRACT, "utf8")).layout_conventions);
423
+
424
+ // What the probe needs from the block: the floating CTA selector contract.
425
+ const layoutProbeArgs = () => {
426
+ const { positions, control_selector, exclude_selector, form_field_selector } = LAYOUT_CONVENTIONS.floating_cta;
427
+ return { positions, control_selector, exclude_selector, form_field_selector };
428
+ };
429
+
430
+ const isRect = (value) => isPlainObject(value) && ["x", "y", "width", "height"].every((field) => Number.isFinite(value[field]));
431
+ const isPathList = (value) => Array.isArray(value) && value.every(isNonEmptyString);
432
+
433
+ // The layout a measured cell records: the probe's viewport and one entry per
434
+ // floating CTA container, with its rect, its controls (each with its rect)
435
+ // and the selector paths of the visible text outside them.
436
+ export function layoutShapeOk(layout) {
437
+ return isPlainObject(layout)
438
+ && isPlainObject(layout.viewport) && Number.isFinite(layout.viewport.width) && Number.isFinite(layout.viewport.height)
439
+ && Array.isArray(layout.floating_ctas)
440
+ && layout.floating_ctas.every((entry) => isPlainObject(entry)
441
+ && isNonEmptyString(entry.selector_path)
442
+ && LAYOUT_CONVENTIONS.floating_cta.positions.includes(entry.position)
443
+ && isRect(entry.rect)
444
+ && Array.isArray(entry.controls) && entry.controls.every((control) => isPlainObject(control) && isNonEmptyString(control.selector_path) && isRect(control.rect))
445
+ && isPathList(entry.other_text)
446
+ && Number.isInteger(entry.other_text_count) && entry.other_text_count >= entry.other_text.length);
447
+ }
448
+
449
+ const FLOATING_FIELDS = Object.freeze(["selector_path", "position", "rect", "controls", "other_text", "other_text_count"]);
450
+
451
+ // The layout a cell keeps from the probe's observation, or null when the
452
+ // observation carries none.
453
+ function cellLayout(observation) {
454
+ const layout = observation?.layout;
455
+ if (!isPlainObject(layout)) return null;
456
+ return {
457
+ viewport: { width: layout.viewport?.width, height: layout.viewport?.height },
458
+ floating_ctas: (Array.isArray(layout.floating_ctas) ? layout.floating_ctas : []).map((entry) => ({
459
+ ...pick(entry, FLOATING_FIELDS),
460
+ controls: (Array.isArray(entry?.controls) ? entry.controls : []).map((control) => pick(control, ["selector_path", "rect"])),
461
+ })),
462
+ };
463
+ }
464
+
465
+ // The row keys the layout check names for a cell at its viewport, and
466
+ // `cell`; none at a viewport no convention applies to. Read without checking
467
+ // the cell, so an accept of a row of a cell that fails its checks lapses
468
+ // rather than orphans. Never throws.
469
+ export function layoutRowKeys(cell) {
470
+ const viewport = isPlainObject(cell) ? cell.viewport : null;
471
+ const keys = Object.values(LAYOUT_CONVENTIONS).filter((convention) => convention.viewports.includes(viewport)).map((convention) => convention.key);
472
+ return keys.length ? [...keys, "cell"].sort(byCodeUnit) : [];
473
+ }
474
+
475
+ // The layout.conventions results of one cell of the readability record,
476
+ // judged with `conventions`, one per convention that applies at the cell's
477
+ // viewport:
478
+ // - `floating_cta`: one member per floating CTA container no taller than
479
+ // max_viewport_height_share of the viewport, a warning when it holds other
480
+ // than one control or holds visible text outside its control; a cell with
481
+ // none reads pass;
482
+ // - `cta_label_size`: one member per rendered, enabled text element of the
483
+ // CTA roles, a warning under min_font_size_px; a cell with none reads pass.
484
+ // The elements are the readability record's, so a capped cell adds the
485
+ // page_coverage member.
486
+ // A cell that measured nothing reads `cell` unexercised with its status.
487
+ export function evaluateLayoutConventions(cell, conventions = LAYOUT_CONVENTIONS) {
488
+ if (!isPlainObject(cell)) invalid("is not an object");
489
+ if (canonicalJson(conventions) !== canonicalJson(LAYOUT_CONVENTIONS)) invalid("layout conventions are not the shared contract's");
490
+ if (!readabilityCellShapeOk(cell)) invalid("is not in the record vocabulary");
491
+ const { floating_cta: floating, cta_label: label } = LAYOUT_CONVENTIONS;
492
+ const applies = (convention) => convention.viewports.includes(cell.viewport);
493
+ if (!applies(floating) && !applies(label)) return [];
494
+ const results = [];
495
+ const push = (key, members, { state, observation = {}, capReason = null }) => {
496
+ const aggregated = aggregateQcResults(members, { capReason });
497
+ const result = aggregated.result;
498
+ results.push({
499
+ check: LAYOUT_CHECK,
500
+ subject: { check: LAYOUT_CHECK, page: cell.route, viewport: cell.viewport, key },
501
+ result,
502
+ reason_code: result === "pass" ? null : aggregated.reason_code,
503
+ members: aggregated.members,
504
+ accept_eligible: aggregated.accept_eligible,
505
+ coverage: result === "unexercised" ? { observed: 0, expected: 1, limits: [aggregated.reason_code] } : { observed: 1, expected: 1, limits: [] },
506
+ state,
507
+ observation,
508
+ });
509
+ };
510
+
511
+ if (cell.cell_status !== "measured") {
512
+ push("cell", [{ key: "cell_status", result: "unexercised", reason_code: cell.cell_status }], { state: { reason_code: cell.cell_status } });
513
+ return results;
514
+ }
515
+ if (!layoutShapeOk(cell.layout)) invalid("layout is not in the record vocabulary");
516
+
517
+ if (applies(floating)) {
518
+ const tallest = floating.max_viewport_height_share * cell.layout.viewport.height;
519
+ const containers = cell.layout.floating_ctas.filter((entry) => entry.rect.height <= tallest);
520
+ const holdsControlOnly = (entry) => entry.controls.length === 1 && entry.other_text_count === 0;
521
+ const members = containers.length
522
+ ? containers.map((entry) => (holdsControlOnly(entry)
523
+ ? { key: entry.selector_path, result: "pass", reason_code: null }
524
+ : { key: entry.selector_path, result: "warning", reason_code: floating.reason_code }))
525
+ : [{ key: "no_floating_cta", result: "pass", reason_code: null }];
526
+ push(floating.key, members, {
527
+ state: { containers: sortedUnique(containers.map(({ selector_path, controls, other_text, other_text_count }) => ({ selector_path, controls: controls.map((control) => control.selector_path), other_text, other_text_count }))) },
528
+ observation: {
529
+ max_height_px: tallest,
530
+ containers: containers.map(({ selector_path, position, rect, controls, other_text, other_text_count }) => ({ selector_path, position, rect, controls, other_text, other_text_count })),
531
+ },
532
+ });
533
+ }
534
+
535
+ if (applies(label)) {
536
+ const labels = cell.elements.filter((element) => label.roles.includes(element.role) && element.rendered && !element.disabled);
537
+ const members = labels.length
538
+ ? labels.map((element) => (element.font_size_px >= label.min_font_size_px
539
+ ? { key: element.selector_path, result: "pass", reason_code: null }
540
+ : { key: element.selector_path, result: "warning", reason_code: label.reason_code }))
541
+ : [{ key: "no_cta_label", result: "pass", reason_code: null }];
542
+ push(label.key, members, {
543
+ capReason: cell.capped ? "element_cap_reached" : null,
544
+ state: { min_font_size_px: label.min_font_size_px, members: sortedUnique(labels.map(({ role, selector_path, font_size_px }) => ({ role, selector_path, font_size_px }))) },
545
+ observation: {
546
+ min_font_size_px: label.min_font_size_px,
547
+ smallest_px: labels.length ? Math.min(...labels.map((element) => element.font_size_px)) : null,
548
+ members: labels.map(({ role, selector_path, font_size_px }) => ({ role, selector_path, font_size_px })),
549
+ },
550
+ });
551
+ }
552
+ return results;
553
+ }
554
+
555
+ // The layout.conventions rules, read from the readability record
556
+ // (src/qc-check-registry.mjs): `thresholds` is the conventions block a record
557
+ // must store to be judged. The reader re-derives the check's rows only
558
+ // for a record that stores `layout_conventions`; an earlier record holds no
559
+ // layout and the check reads not_captured_by_this_version.
560
+ export const LAYOUT_QC_RULES = Object.freeze({
561
+ thresholds: LAYOUT_CONVENTIONS,
562
+ vocabulary: Object.freeze({ key: Object.freeze([...Object.values(LAYOUT_CONVENTIONS).map((convention) => convention.key), "cell"]) }),
563
+ evaluate: evaluateLayoutConventions,
564
+ rowKeys: layoutRowKeys,
565
+ });
566
+
405
567
  // ---------------------------------------------------------------------------
406
568
  // Probe
407
569
 
@@ -419,7 +581,8 @@ export const READABILITY_QC_RULES = Object.freeze({
419
581
  // (or, for one inside a frame, that frame's element); `generatedText` is
420
582
  // generatedTextRenders. Every element carries its rectangle in the main
421
583
  // frame's viewport (`rect`, clipped to its frames) for crops, which the
422
- // record does not keep.
584
+ // record does not keep. With `limits.layout` it also returns `layout`: the
585
+ // viewport and the floating CTA containers, whose rects the record keeps.
423
586
  export function readabilityProbe(toolkit, limits, closedRoots, generatedText) {
424
587
  const win = window;
425
588
  const doc = document;
@@ -684,14 +847,56 @@ export function readabilityProbe(toolkit, limits, closedRoots, generatedText) {
684
847
  if (isVisible(frame) && !readable) gap("cross_origin_text", null, frame);
685
848
  }
686
849
 
687
- return { status: "measured", capped, coverage_gaps: gaps, elements, viewport };
850
+ // Floating CTA containers (limits.layout, the layout conventions' selector
851
+ // contract), in the main frame's trees: for each visible control, the
852
+ // nearest box at or around it whose computed position is one of
853
+ // `positions`, unless that box is hidden, matches or sits inside
854
+ // `exclude_selector`, or holds a visible form field. Each container keeps
855
+ // its rect, its outermost visible controls with their rects, and the
856
+ // selector paths of the visible text outside them (rendered or generated),
857
+ // the first ten listed and all counted.
858
+ let layout = null;
859
+ if (limits.layout) {
860
+ const { positions, control_selector: controlSelector, exclude_selector: excludeSelector, form_field_selector: fieldSelector } = limits.layout;
861
+ const main = { offset: { x: 0, y: 0 }, clip: null };
862
+ const positioned = (el) => positions.includes(win.getComputedStyle(el).getPropertyValue("position"));
863
+ const boxes = [];
864
+ for (const [el, scope] of inOrder) {
865
+ if (scope.win !== win || !el.matches(controlSelector) || !isVisible(el)) continue;
866
+ let box = el;
867
+ while (box && box.nodeType === 1 && !positioned(box)) box = flatParent(box);
868
+ if (box && box.nodeType === 1 && !boxes.includes(box)) boxes.push(box);
869
+ }
870
+ const floating_ctas = [];
871
+ for (const box of boxes) {
872
+ if (!isVisible(box) || flatClosest(box, excludeSelector)) continue;
873
+ const inside = flatSubtree(box);
874
+ if (inside.some((node) => node.matches(fieldSelector) && isVisible(node))) continue;
875
+ const visibleControls = inside.filter((node) => node.matches(controlSelector) && isVisible(node));
876
+ const controls = visibleControls.filter((node) => !visibleControls.some((other) => other !== node && flatContains(other, node)));
877
+ const other = inside.filter((node) => !controls.some((control) => flatContains(control, node))
878
+ && (toolkit.isTextBearing(node, windowOf(node)) || generatedText(node, windowOf(node))));
879
+ floating_ctas.push({
880
+ selector_path: toolkit.selectorPath(box),
881
+ position: win.getComputedStyle(box).getPropertyValue("position"),
882
+ rect: rectOf(box, main),
883
+ controls: controls.map((control) => ({ selector_path: toolkit.selectorPath(control), rect: rectOf(control, main) })),
884
+ other_text: other.slice(0, 10).map((node) => toolkit.selectorPath(node)),
885
+ other_text_count: other.length,
886
+ });
887
+ }
888
+ layout = { viewport, floating_ctas };
889
+ }
890
+
891
+ return { status: "measured", capped, coverage_gaps: gaps, elements, viewport, ...(layout ? { layout } : {}) };
688
892
  }
689
893
 
690
894
  // The function declaration the adapter calls in the isolated world: the
691
895
  // probe, composed with the shared contrast helper and generatedTextRenders
692
- // by source text.
896
+ // by source text, its limits carrying the layout conventions' selector
897
+ // contract.
693
898
  export function readabilityProbeSource() {
694
- return `function (limits, ...closedRoots) { return (${readabilityProbe.toString()})((${contrastToolkit.toString()})(), limits, closedRoots, ${generatedTextRenders.toString()}); }`;
899
+ return `function (limits, ...closedRoots) { return (${readabilityProbe.toString()})((${contrastToolkit.toString()})(), Object.assign({ layout: ${JSON.stringify(layoutProbeArgs())} }, limits), closedRoots, ${generatedTextRenders.toString()}); }`;
695
900
  }
696
901
 
697
902
  // Whether an element's ::before or ::after renders text: its computed
@@ -760,6 +965,7 @@ const pick = (value, fields) => Object.fromEntries(fields.map((field) => [field,
760
965
  export function readabilityObservationOk(observation) {
761
966
  return isPlainObject(observation)
762
967
  && inVocabulary("cell_status", observation.status)
968
+ && (observation.layout === undefined || isPlainObject(observation.layout))
763
969
  && (observation.status !== "measured"
764
970
  || (Array.isArray(observation.elements) && observation.elements.every(isPlainObject)
765
971
  && Array.isArray(observation.coverage_gaps) && observation.coverage_gaps.every(isPlainObject)
@@ -768,7 +974,9 @@ export function readabilityObservationOk(observation) {
768
974
 
769
975
  // One cell of the record. `status` is the cell status; a measured cell's
770
976
  // `observation` is the adapter's probe read ({capped, coverage_gaps,
771
- // elements, crops}). Only the record's fields are kept. Each crop the adapter
977
+ // elements, crops, layout}). Only the record's fields are kept; a layout
978
+ // outside the record vocabulary is dropped, which the layout check reads as
979
+ // not reproducible. Each crop the adapter
772
980
  // took (crops[] of {element, data}, base64 PNG) is written by `writeCrop`
773
981
  // and referenced by path and sha256; one that cannot be written reads
774
982
  // crop_unavailable. A measured cell outside the record vocabulary reads
@@ -789,6 +997,7 @@ export function buildReadabilityCell({ route, viewport, pageLoadIntegrity = null
789
997
  element.crop_reason = "crop_unavailable";
790
998
  }
791
999
  }
1000
+ const layout = cellLayout(observation);
792
1001
  const cell = {
793
1002
  route,
794
1003
  viewport,
@@ -797,13 +1006,15 @@ export function buildReadabilityCell({ route, viewport, pageLoadIntegrity = null
797
1006
  capped: observation.capped,
798
1007
  coverage_gaps: observation.coverage_gaps.map((entry) => pick(entry, GAP_FIELDS)),
799
1008
  elements,
1009
+ ...(layout && layoutShapeOk(layout) ? { layout } : {}),
800
1010
  };
801
1011
  return readabilityCellShapeOk(cell) ? cell : unmeasured("document_changed");
802
1012
  }
803
1013
 
804
1014
  // The record for one Polish run: its cells (one per captured route ×
805
1015
  // viewport), bound to the current build and source package; the built routes
806
- // over the route cap; whether the enumeration ceiling was reached.
1016
+ // over the route cap; whether the enumeration ceiling was reached; and, when
1017
+ // a cell recorded layout, the layout conventions it was measured with.
807
1018
  export function buildReadabilityRecord({ buildFingerprint, sourceFingerprint = null, slug, routes, uncapturedRoutes = [], routeEnumerationCapped = false, cells, measuredAt = new Date().toISOString() }) {
808
1019
  const record = {
809
1020
  schema_version: READABILITY_SCHEMA,
@@ -819,6 +1030,7 @@ export function buildReadabilityRecord({ buildFingerprint, sourceFingerprint = n
819
1030
  },
820
1031
  thresholds: { ...READABILITY_THRESHOLDS },
821
1032
  limits: { ...readabilityLimits() },
1033
+ ...(cells.some((cell) => isPlainObject(cell?.layout)) ? { layout_conventions: structuredClone(LAYOUT_CONVENTIONS) } : {}),
822
1034
  cells,
823
1035
  uncaptured_routes: [...uncapturedRoutes],
824
1036
  route_enumeration_capped: routeEnumerationCapped === true,
@@ -477,10 +477,17 @@ export function evaluateVisualReference(reference) {
477
477
  : reference.provenance?.class === "declared_source" ? member("provenance", null, "pass") : member("provenance", "provenance_unknown", "review"));
478
478
  if (reference.reference_cap_reached) members.push(member("reference_cap", "reference_cap_reached"));
479
479
  // A file refused for where it is, not what it is, names that location.
480
- if (level !== "decoded" && reference.verification?.reason === "path_outside_source_root") members.push(member("location", "path_outside_source_root"));
480
+ members.push(...referenceLocationMembers(reference));
481
481
  return aggregateQcResults(members);
482
482
  }
483
483
 
484
+ // The location member of a reference refused for where it is. It does not
485
+ // depend on the build, so a stale-bound row keeps it (#707).
486
+ function referenceLocationMembers(reference) {
487
+ return reference?.verification?.level !== "decoded" && reference?.verification?.reason === "path_outside_source_root"
488
+ ? [member("location", "path_outside_source_root")] : [];
489
+ }
490
+
484
491
  // The only diff decision table. Scores are intentionally absent from its predicates.
485
492
  export function evaluateVisualDiff(comparison, { record, reference, build, basis, readerFailure = null,
486
493
  staleBinding = false, pageMissing = false, fileAbsent = false, budgetExhausted = false } = {}) {
@@ -590,7 +597,7 @@ function evaluate(record, context = {}) {
590
597
  ? { ...original, reference_cap_reached: true } : original;
591
598
  row("visual.reference", ref, failure
592
599
  ? { result: "unexercised", reason_code: "evidence_not_reproducible", members: [member("evidence", failure)] }
593
- : context.staleBinding ? { result: "unexercised", reason_code: "stale_binding" } : evaluateVisualReference(ref));
600
+ : context.staleBinding ? { result: "unexercised", reason_code: "stale_binding", members: referenceLocationMembers(ref) } : evaluateVisualReference(ref));
594
601
  }
595
602
  for (const comparison of record.comparisons) {
596
603
  if (comparison.basis === "reference" && cappedReferences.has(referenceKey(comparison))) continue;
@@ -97,7 +97,7 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
97
97
  }
98
98
  function lock(dir,fn,{budgetMs=1500}={}) {
99
99
  const lockPath=join(dir,'.allocation-lock');
100
- return withDirectoryLock(lockPath,fn,{budgetMs,unavailable:(error)=>Object.assign(new Error('progress.lock_unavailable'),{lockPath,code:error?.code,obstruction:error?.obstruction})});
100
+ return withDirectoryLock(lockPath,fn,{budgetMs,unavailable:(error)=>Object.assign(new Error('progress.lock_unavailable'),{lockPath,code:error?.code,obstruction:error?.obstruction,holderPid:error?.holderPid})});
101
101
  }
102
102
  export async function persistProgressObservation(observation,{dir,now=()=>new Date(),historyLimit=32}={}) {
103
103
  mkdirSync(dir,{recursive:true,mode:0o700});
@@ -165,7 +165,8 @@ export async function observeProgress(args,continuation,{qaResult=null,packageVe
165
165
  if(remit.state==='failed')warn('[campaigns-os] Progress delivery pending; the local observation is retained. Lifecycle result is unchanged.');
166
166
  return {...remit,snapshot_id:snapshot.snapshot_id,reused};
167
167
  } catch(error) {
168
- if(error?.message==='progress.lock_unavailable'&&error.obstruction)warn(`[campaigns-os] Progress allocation lock path ${error.lockPath} is occupied by ${error.obstruction}; it is never removed automatically. Move it out of the way. Lifecycle result is unchanged.`);
168
+ if(error?.message==='progress.lock_unavailable'&&error.code==='ELOCKFORMAT')warn(`[campaigns-os] Progress allocation lock at ${error.lockPath} is held by process ${error.holderPid} of a different campaigns-os release; capture stops instead of waiting. Run one campaigns-os release against this target at a time. Lifecycle result is unchanged.`);
169
+ else if(error?.message==='progress.lock_unavailable'&&error.obstruction)warn(`[campaigns-os] Progress allocation lock path ${error.lockPath} is occupied by ${error.obstruction}; it is never removed automatically. Move it out of the way. Lifecycle result is unchanged.`);
169
170
  else if(error?.message==='progress.lock_unavailable')warn(`[campaigns-os] Progress allocation lock occupied${error.lockPath?` at ${error.lockPath}`:''}; wait for the current writer. If it stays occupied (an abandoned, ownerless or interrupted lock), stop all campaigns-os writers for this target, then remove that lock directory (docs/progress-snapshots.md). Lifecycle result is unchanged.`);
170
171
  else warn('[campaigns-os] Progress observation unavailable; lifecycle result is unchanged.');
171
172
  return {state:'failed',reason:'capture_unavailable'};
@@ -27,6 +27,7 @@
27
27
 
28
28
  import { SEVERITY, STATUS } from "./qa-verdict.mjs";
29
29
  import { redactUrlQuery } from "./qa-url-privacy.mjs";
30
+ import { readVendorBeacon, VENDOR_KEYS, vendorBeaconFor } from "./qa-vendor-endpoints.mjs";
30
31
 
31
32
  // Data layers the SDK and legacy funnels push through. The SDK's GTMAdapter
32
33
  // mirrors every `dl_*` event to window.dataLayer AND window.ElevarDataLayer;
@@ -280,6 +281,12 @@ export async function attachAnalyticsCapture(page, options = {}) {
280
281
  const url = typeof request.url === "function" ? request.url() : request.url;
281
282
  const fire = classifyTagFire(url, extraHosts);
282
283
  if (!fire) return;
284
+ // A listed vendor beacon can carry its id in the body (Snap, TikTok).
285
+ if (!fire.id) {
286
+ let body = null;
287
+ try { body = typeof request.postData === "function" ? request.postData() : null; } catch { /* no body */ }
288
+ fire.id = readVendorBeacon(url, body)?.ids[0] || null;
289
+ }
283
290
  let documentUrl = safePageUrl();
284
291
  try {
285
292
  let frame = typeof request.frame === "function" ? request.frame() : null;
@@ -420,6 +427,14 @@ export function classifyTagFire(urlString, extraHostSubstrings = []) {
420
427
  const fire = (kind, id) => ({ kind, id: id ? String(id) : null, host, params });
421
428
  const subs = [...DEFAULT_EXTRA_ANALYTICS_HOST_SUBSTRINGS, ...extraHostSubstrings];
422
429
 
430
+ // A listed vendor beacon (qa-vendor-endpoints.mjs) is that vendor's fire,
431
+ // answered locally or not, with the id from its query or path when it has one.
432
+ const beacon = vendorBeaconFor(url.href);
433
+ if (beacon) {
434
+ const pathId = beacon.idPath ? path.match(beacon.idPath)?.[1] : null;
435
+ return fire(beacon.vendor, pathId || (beacon.id && !beacon.id.includes(".") ? params[beacon.id] : null) || null);
436
+ }
437
+
423
438
  if (hostMatches(host, "googletagmanager.com")) {
424
439
  const id = params.id || null;
425
440
  if (id && /^GTM-/i.test(id)) return fire("gtm", id);
@@ -539,6 +554,7 @@ export function normalizeCapture({ events = [], tagFires = [] } = {}) {
539
554
  }
540
555
 
541
556
  const inventory = { gtm: [], ga4: [], google_ads: [], meta: [], tiktok: [], everflow: [], other: [] };
557
+ for (const vendor of VENDOR_KEYS) inventory[vendor] ||= [];
542
558
  // Pixel ids seen on a facebook.com request, apart from in-page fbq calls.
543
559
  const metaRequestIds = [];
544
560
  let metaPurchaseEventId = null;
@@ -787,7 +803,7 @@ export function diffAnalyticsParity(baseline, candidate, options = {}) {
787
803
 
788
804
  // 6. Carried-over tag regression — any baseline container/pixel absent on the
789
805
  // candidate is a WARN (likely attribution regression; human confirms drop).
790
- const kinds = ["gtm", "ga4", "google_ads", "meta", "tiktok", "everflow", "other"];
806
+ const kinds = [...new Set(["gtm", "ga4", "google_ads", "meta", "tiktok", "everflow", "other", ...VENDOR_KEYS])];
791
807
  for (const kind of kinds) {
792
808
  const baselineIds = (b.inventory?.[kind]) || [];
793
809
  const candidateIds = new Set((c.inventory?.[kind]) || []);