@dreamdata_io/verify-tracking 0.1.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 (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +756 -0
  3. package/dist/ai/checker.js +222 -0
  4. package/dist/ai/checker.js.map +1 -0
  5. package/dist/ai/checklist.js +190 -0
  6. package/dist/ai/checklist.js.map +1 -0
  7. package/dist/ai/cli-presets.js +27 -0
  8. package/dist/ai/cli-presets.js.map +1 -0
  9. package/dist/ai/evidence-view.js +143 -0
  10. package/dist/ai/evidence-view.js.map +1 -0
  11. package/dist/ai/local-cli.js +58 -0
  12. package/dist/ai/local-cli.js.map +1 -0
  13. package/dist/ai/openai-compatible.js +78 -0
  14. package/dist/ai/openai-compatible.js.map +1 -0
  15. package/dist/ai/response-parser.js +105 -0
  16. package/dist/ai/response-parser.js.map +1 -0
  17. package/dist/ai/types.js +2 -0
  18. package/dist/ai/types.js.map +1 -0
  19. package/dist/analysis/consent-mode.js +225 -0
  20. package/dist/analysis/consent-mode.js.map +1 -0
  21. package/dist/analysis/deployment.js +2658 -0
  22. package/dist/analysis/deployment.js.map +1 -0
  23. package/dist/analysis/diagnosis/copy.en.js +303 -0
  24. package/dist/analysis/diagnosis/copy.en.js.map +1 -0
  25. package/dist/analysis/diagnosis/diagnose.js +172 -0
  26. package/dist/analysis/diagnosis/diagnose.js.map +1 -0
  27. package/dist/analysis/diagnosis/docs.js +118 -0
  28. package/dist/analysis/diagnosis/docs.js.map +1 -0
  29. package/dist/analysis/diagnosis/index.js +5 -0
  30. package/dist/analysis/diagnosis/index.js.map +1 -0
  31. package/dist/analysis/diagnosis/rules.js +656 -0
  32. package/dist/analysis/diagnosis/rules.js.map +1 -0
  33. package/dist/analysis/diagnosis/types.js +2 -0
  34. package/dist/analysis/diagnosis/types.js.map +1 -0
  35. package/dist/cli.js +344 -0
  36. package/dist/cli.js.map +1 -0
  37. package/dist/config/env.js +100 -0
  38. package/dist/config/env.js.map +1 -0
  39. package/dist/config/preflight.js +167 -0
  40. package/dist/config/preflight.js.map +1 -0
  41. package/dist/config/user-config.js +79 -0
  42. package/dist/config/user-config.js.map +1 -0
  43. package/dist/evidence/collect.js +527 -0
  44. package/dist/evidence/collect.js.map +1 -0
  45. package/dist/evidence/consent-accept.js +377 -0
  46. package/dist/evidence/consent-accept.js.map +1 -0
  47. package/dist/evidence/consent.js +340 -0
  48. package/dist/evidence/consent.js.map +1 -0
  49. package/dist/evidence/constants.js +615 -0
  50. package/dist/evidence/constants.js.map +1 -0
  51. package/dist/evidence/globals.js +132 -0
  52. package/dist/evidence/globals.js.map +1 -0
  53. package/dist/evidence/gtm-container.js +432 -0
  54. package/dist/evidence/gtm-container.js.map +1 -0
  55. package/dist/evidence/heuristic-banner.js +107 -0
  56. package/dist/evidence/heuristic-banner.js.map +1 -0
  57. package/dist/evidence/http-probe.js +139 -0
  58. package/dist/evidence/http-probe.js.map +1 -0
  59. package/dist/evidence/init-script.js +112 -0
  60. package/dist/evidence/init-script.js.map +1 -0
  61. package/dist/evidence/network.js +365 -0
  62. package/dist/evidence/network.js.map +1 -0
  63. package/dist/evidence/preference-center.js +368 -0
  64. package/dist/evidence/preference-center.js.map +1 -0
  65. package/dist/evidence/script.js +425 -0
  66. package/dist/evidence/script.js.map +1 -0
  67. package/dist/evidence/second-page.js +332 -0
  68. package/dist/evidence/second-page.js.map +1 -0
  69. package/dist/evidence/selector-probe.js +53 -0
  70. package/dist/evidence/selector-probe.js.map +1 -0
  71. package/dist/evidence/stealth.js +21 -0
  72. package/dist/evidence/stealth.js.map +1 -0
  73. package/dist/evidence/types.js +14 -0
  74. package/dist/evidence/types.js.map +1 -0
  75. package/dist/interactive/prompt.js +31 -0
  76. package/dist/interactive/prompt.js.map +1 -0
  77. package/dist/mcp/server.js +308 -0
  78. package/dist/mcp/server.js.map +1 -0
  79. package/dist/mcp/summary.js +41 -0
  80. package/dist/mcp/summary.js.map +1 -0
  81. package/dist/report/context.js +184 -0
  82. package/dist/report/context.js.map +1 -0
  83. package/dist/report/projection.js +125 -0
  84. package/dist/report/projection.js.map +1 -0
  85. package/dist/report/quote-check.js +90 -0
  86. package/dist/report/quote-check.js.map +1 -0
  87. package/dist/report/render.js +178 -0
  88. package/dist/report/render.js.map +1 -0
  89. package/dist/report/serializers.js +124 -0
  90. package/dist/report/serializers.js.map +1 -0
  91. package/dist/report/store.js +139 -0
  92. package/dist/report/store.js.map +1 -0
  93. package/dist/run/verify.js +77 -0
  94. package/dist/run/verify.js.map +1 -0
  95. package/dist/utils/duration.js +10 -0
  96. package/dist/utils/duration.js.map +1 -0
  97. package/dist/utils/errors.js +17 -0
  98. package/dist/utils/errors.js.map +1 -0
  99. package/dist/utils/logger.js +59 -0
  100. package/dist/utils/logger.js.map +1 -0
  101. package/dist/utils/runTimingsLog.js +27 -0
  102. package/dist/utils/runTimingsLog.js.map +1 -0
  103. package/dist/utils/slug.js +27 -0
  104. package/dist/utils/slug.js.map +1 -0
  105. package/dist/version.js +10 -0
  106. package/dist/version.js.map +1 -0
  107. package/package.json +57 -0
  108. package/scripts/install.sh +314 -0
@@ -0,0 +1,656 @@
1
+ import { combinedBundleTags, tagsGatedOnWrongSignal } from "../deployment.js";
2
+ const CONSENT_LEAK_CHECKS = [
3
+ "clean-before-consent",
4
+ "reject-blocks-normal",
5
+ "reject-writes-no-identity",
6
+ "second-page-honours-consent",
7
+ ];
8
+ /** Whether a phase saw any Dreamdata request at all, on either page view. */
9
+ function silent(phase) {
10
+ const second = phase.secondPage;
11
+ return (phase.normalRequests === 0 &&
12
+ phase.cookielessRequests === 0 &&
13
+ (second === null || (second.normalRequests === 0 && second.cookielessRequests === 0)));
14
+ }
15
+ /** Whether the normal bundle sent anything in a phase, on either page view. */
16
+ function normalFired(phase) {
17
+ return phase.normalRequests > 0 || (phase.secondPage?.normalRequests ?? 0) > 0;
18
+ }
19
+ /** Consent Mode read as permissive before the visitor had answered. */
20
+ function permissiveBeforeAnswer(phase) {
21
+ return phase.analyticsStorageSource === "implicit-default" || phase.analyticsStorage === "granted";
22
+ }
23
+ /** A phase where the banner was driven, so its consent state means something. */
24
+ function drivenPhases(facts) {
25
+ return facts.observed.filter((phase) => phase.phase !== "clean" && phase.consentDriven);
26
+ }
27
+ function hasDuplicateKind(facts, kind) {
28
+ return facts.duplicates.some((duplicate) => duplicate.kind === kind);
29
+ }
30
+ /**
31
+ * Every remaining unhealthy check. Used only by the rules that genuinely
32
+ * account for the whole run — nothing is installed, or nothing is delivered —
33
+ * where reporting a second cause would be describing a consequence of the
34
+ * first as though it were separate.
35
+ */
36
+ function everythingElse(facts) {
37
+ return facts.unhealthy;
38
+ }
39
+ export const CAUSE_RULES = [
40
+ // ── Not a verdict about the site. These pre-empt everything. ─────────────
41
+ {
42
+ id: "page-unreadable",
43
+ kind: "not-a-verdict",
44
+ trigger: [],
45
+ triggerMode: "any",
46
+ // Reached with every check already voided to inconclusive by voidChecks,
47
+ // so there is nothing to claim — the gate is pageReadability itself.
48
+ match: (facts) => (facts.analysis.pageReadability.unreadable ? { claims: everythingElse(facts), preempt: true } : null),
49
+ },
50
+ {
51
+ id: "third-party-segment",
52
+ kind: "not-a-verdict",
53
+ trigger: [],
54
+ triggerMode: "any",
55
+ match: (facts) => facts.analysis.shape === "third-party-segment-undetermined"
56
+ ? { claims: everythingElse(facts), preempt: true }
57
+ : null,
58
+ },
59
+ {
60
+ id: "banner-undrivable",
61
+ kind: "not-a-verdict",
62
+ trigger: [],
63
+ triggerMode: "any",
64
+ // Gates on the SHAPE, not on consentGateUndetermined alone: that flag can
65
+ // be true beside a concluded shape (see DeploymentAnalysis), and there
66
+ // the checks resting on the undriven phase are already inconclusive, so
67
+ // they never enter the ledger and no rule can misfire on them.
68
+ match: (facts) => (facts.analysis.shape === "undetermined" ? { claims: everythingElse(facts), preempt: true } : null),
69
+ },
70
+ // ── Blocking: data is being lost right now. ─────────────────────────────
71
+ {
72
+ id: "not-installed",
73
+ kind: "blocking",
74
+ trigger: ["script-installed"],
75
+ triggerMode: "any",
76
+ // Claims everything: with no script on the page, every other failing
77
+ // check is a restatement of the same fact.
78
+ match: (facts) => facts.analysis.shape === "nothing-installed" || facts.analysis.shape === "helper-only"
79
+ ? { claims: everythingElse(facts) }
80
+ : null,
81
+ },
82
+ {
83
+ id: "requests-never-reach-dreamdata",
84
+ kind: "blocking",
85
+ trigger: ["grant-enables-normal", "cookieless-covers-unconsented"],
86
+ triggerMode: "any",
87
+ /**
88
+ * The rocketbots_io signature: a bundle demonstrably ran — it is in the
89
+ * DOM and it wrote an identity cookie — and yet not one request left the
90
+ * browser in any phase. Every request-side field looks healthy, which is
91
+ * why this needs its own cause rather than reading as a consent problem.
92
+ */
93
+ match: (facts) => {
94
+ const everySilent = facts.observed.length > 0 && facts.observed.every(silent);
95
+ const bundlePresent = facts.observed.some((phase) => phase.normalBundleInDom || phase.cookielessBundleInDom);
96
+ const wroteIdentity = facts.observed.some((phase) => phase.identityCookiesSet.length > 0);
97
+ if (!everySilent || !bundlePresent || !wroteIdentity)
98
+ return null;
99
+ return {
100
+ claims: [
101
+ "second-page-keeps-tracking",
102
+ "soft-nav-keeps-tracking",
103
+ "request-delivery-accepted",
104
+ "request-payload-complete",
105
+ "global-handoff",
106
+ ],
107
+ };
108
+ },
109
+ },
110
+ {
111
+ id: "cookieless-not-installed",
112
+ kind: "blocking",
113
+ trigger: ["cookieless-covers-unconsented"],
114
+ triggerMode: "all",
115
+ match: (facts) => (facts.analysis.cookielessBundleConfigured ? null : { claims: [] }),
116
+ },
117
+ {
118
+ id: "cookieless-shares-normal-gate",
119
+ kind: "blocking",
120
+ trigger: ["cookieless-covers-unconsented"],
121
+ triggerMode: "all",
122
+ /**
123
+ * The most common cause in the corpus (7 of 25 sites), and it reads
124
+ * straight off the DOM: on gong_io, nteams_com, smartbear_com and
125
+ * team_wrike_com the cookieless bundle is absent from the page in clean
126
+ * and reject but present after a grant. A tag that only appears once
127
+ * consent is given is a tag sharing the normal bundle's consent gate,
128
+ * which defeats the entire point of having a cookieless fallback.
129
+ */
130
+ match: (facts) => {
131
+ if (!facts.analysis.cookielessBundleConfigured)
132
+ return null;
133
+ const failing = facts.failingPhases("cookieless-covers-unconsented");
134
+ if (failing.length === 0)
135
+ return null;
136
+ const absentWhereItFailed = failing.every((name) => {
137
+ const phase = facts.phase(name);
138
+ return phase !== undefined && !phase.cookielessBundleInDom && phase.cookielessRequests === 0;
139
+ });
140
+ const presentSomewhere = facts.observed.some((phase) => phase.cookielessBundleInDom);
141
+ return absentWhereItFailed && presentSomewhere ? { claims: [] } : null;
142
+ },
143
+ },
144
+ {
145
+ id: "consent-not-persisted-across-reload",
146
+ kind: "blocking",
147
+ trigger: ["consent-mode-signal"],
148
+ triggerMode: "all",
149
+ /**
150
+ * paytronix_com: the update DID fire while the banner was being answered
151
+ * (consentSignalConfirmed true) but after the post-consent reload
152
+ * analytics_storage reads its default again. The visitor's decision does
153
+ * not survive a page load, so on the page they actually browse every
154
+ * gated tag is still blocked — which is also why grant-enables-normal
155
+ * fails here and is claimed rather than reported separately.
156
+ */
157
+ match: (facts) => {
158
+ const stranded = drivenPhases(facts).some((phase) => phase.consentSignalConfirmed === true && phase.analyticsStorageSource !== "update");
159
+ if (!stranded)
160
+ return null;
161
+ return { claims: ["grant-enables-normal", "grant-sets-identity-cookies", "second-page-honours-consent"] };
162
+ },
163
+ },
164
+ {
165
+ id: "consent-mode-not-wired",
166
+ kind: "blocking",
167
+ trigger: ["clean-consent-mode-default", "consent-mode-signal"],
168
+ triggerMode: "all",
169
+ /**
170
+ * The composite that collapses nextecgroup_com from six failing checks to
171
+ * one root cause, and cmic_ca from three to two. BOTH halves must be
172
+ * failing: no denied default AND no update ever emitted. That conjunction
173
+ * is what separates it from nice_com, where the same leak checks fail but
174
+ * Consent Mode signals correctly and the bundle simply ignores them —
175
+ * a different cause with a different fix, handled below.
176
+ */
177
+ match: (facts) => {
178
+ const clean = facts.phase("clean");
179
+ const defaultPermissive = clean !== undefined && permissiveBeforeAnswer(clean);
180
+ const neverUpdated = drivenPhases(facts).some((phase) => phase.analyticsStorageSource !== "update");
181
+ if (!defaultPermissive || !neverUpdated)
182
+ return null;
183
+ return {
184
+ claims: [
185
+ ...CONSENT_LEAK_CHECKS,
186
+ "grant-enables-normal",
187
+ "grant-sets-identity-cookies",
188
+ "cookieless-covers-unconsented",
189
+ // Claimed so consent-mode-never-called below does not fire again
190
+ // saying a narrower version of the same thing. This cause is the
191
+ // fuller story: it has both halves of the evidence, not just the
192
+ // ics flag.
193
+ "consent-mode-v2-enabled",
194
+ ],
195
+ };
196
+ },
197
+ },
198
+ {
199
+ /**
200
+ * The half of F1 the rule above cannot see, and the most expensive
201
+ * failure in the catalog because nothing about it is visible from
202
+ * analytics_storage alone.
203
+ *
204
+ * A Google tag loads, creates all seven consent entries, and nothing
205
+ * ever calls gtag('consent', …). analytics_storage therefore reads
206
+ * `denied` off gtag.js's own fallback — which makes
207
+ * clean-consent-mode-default PASS, which is why consent-mode-not-wired
208
+ * (needing a permissive default) never fires. ics.active is the only
209
+ * field that tells this apart from a site that deliberately declared a
210
+ * denied default, and until it was collected this case reached a report
211
+ * as a silence nobody could explain.
212
+ *
213
+ * It matters because a consent-gated bundle in this state never
214
+ * initialises at all: it waits for a signal that no CMP is going to
215
+ * send, with no error and no request.
216
+ */
217
+ id: "consent-mode-never-called",
218
+ kind: "blocking",
219
+ trigger: ["consent-mode-v2-enabled"],
220
+ triggerMode: "all",
221
+ match: (facts) => facts.analysis.consentModeV2.outcome === "not-installed"
222
+ ? {
223
+ claims: ["grant-enables-normal", "cookieless-covers-unconsented"].filter((id) => facts.failingPhases(id).length > 0),
224
+ }
225
+ : null,
226
+ },
227
+ {
228
+ /**
229
+ * F6. The consent platform declares its defaults after tags have already
230
+ * run (ics.wasSetLate), so anything that fired first saw no consent
231
+ * state at all — a load-order fault, and one that changes with the
232
+ * visitor's network speed rather than with anything in the container.
233
+ *
234
+ * Read from either the settled read or the pre-decision one: on a driven
235
+ * phase the post-consent reload carries the CMP's own cookie and can
236
+ * declare earlier than a first visit does, so the fault hides in the
237
+ * settled read alone.
238
+ */
239
+ id: "consent-mode-set-late",
240
+ kind: "blocking",
241
+ trigger: ["consent-mode-v2-enabled"],
242
+ triggerMode: "all",
243
+ match: (facts) => {
244
+ if (!facts.analysis.consentModeV2.causes.includes("set-late"))
245
+ return null;
246
+ // Only the leak checks a late gate actually accounts for: a phase that
247
+ // leaked for some other reason belongs to another cause.
248
+ return { claims: CONSENT_LEAK_CHECKS.filter((id) => facts.failingPhases(id).length > 0) };
249
+ },
250
+ },
251
+ {
252
+ /**
253
+ * The tag requires a consent signal that is not analytics_storage, and
254
+ * the signal it requires is never granted here — so the tag is inert and
255
+ * the account records nothing at all, in every phase.
256
+ *
257
+ * telaid_com is the case: its standard-bundle tags require
258
+ * `targeted_advertising`, which is not one of Consent Mode's seven
259
+ * signals and which nothing on that page ever sets, so all three phases
260
+ * are silent and the run produced no cause whatsoever. The container is
261
+ * the only place this is visible — from behaviour alone it is
262
+ * indistinguishable from a trigger that never matched.
263
+ *
264
+ * Trigger-less and gated entirely in `match` (like the not-a-verdict
265
+ * rules) because there is no check whose failure means "gated on the
266
+ * wrong signal": the checks see silence, and silence has many causes.
267
+ * `normalFired` in no phase is what separates this from the hardening
268
+ * variant below, and it is what keeps this rule off any site whose tag
269
+ * actually works.
270
+ */
271
+ id: "consent-gate-wrong-signal",
272
+ kind: "blocking",
273
+ trigger: [],
274
+ triggerMode: "all",
275
+ match: (facts) => {
276
+ if (tagsGatedOnWrongSignal(facts.analysis.install.tagManagerContainers).length === 0)
277
+ return null;
278
+ if (facts.observed.some(normalFired))
279
+ return null;
280
+ return {
281
+ claims: ["grant-enables-normal", "cookieless-covers-unconsented", "core-tracking"].filter((id) => facts.failingPhases(id).length > 0),
282
+ };
283
+ },
284
+ },
285
+ {
286
+ id: "normal-bundle-never-fires",
287
+ kind: "blocking",
288
+ trigger: ["grant-enables-normal"],
289
+ triggerMode: "all",
290
+ // The bundle is on the page after the grant and still sends nothing —
291
+ // reached only when the total-silence signature above did not hold, so
292
+ // this is specifically "the identified bundle alone is broken".
293
+ match: (facts) => {
294
+ const grant = facts.phase("grant");
295
+ if (grant === undefined || !grant.normalBundleInDom || normalFired(grant))
296
+ return null;
297
+ return { claims: ["grant-sets-identity-cookies"] };
298
+ },
299
+ },
300
+ {
301
+ id: "router-no-page-call",
302
+ kind: "blocking",
303
+ trigger: ["soft-nav-keeps-tracking"],
304
+ triggerMode: "all",
305
+ /**
306
+ * Gated on the run-level reconciliation rather than on a re-scan of the
307
+ * phases — same set (summarizeSecondPage copies kind verbatim), one
308
+ * definition of "a soft navigation happened".
309
+ *
310
+ * softPhases, deliberately, and never mode: "client-routed-suspected" is
311
+ * a regex over served markup, and a regex must not be able to fire a
312
+ * blocking cause that tells a customer their site is a single-page app.
313
+ */
314
+ match: (facts) => {
315
+ const routing = facts.analysis.routing;
316
+ if (routing.softPhases.length === 0)
317
+ return null;
318
+ return { claims: [], vars: { navKind: "soft", framework: routing.frameworkMarkers[0] } };
319
+ },
320
+ },
321
+ {
322
+ id: "entry-page-only",
323
+ kind: "blocking",
324
+ trigger: ["second-page-keeps-tracking"],
325
+ triggerMode: "all",
326
+ match: (facts) => {
327
+ if (!facts.observed.some((phase) => phase.secondPage?.kind === "hard"))
328
+ return null;
329
+ const gtm = facts.analysis.install;
330
+ return {
331
+ claims: [],
332
+ vars: {
333
+ navKind: "hard",
334
+ // Read from the phases this check actually failed in, so a page that
335
+ // tracked in some other phase cannot soften the sentence.
336
+ secondPageStillTracked: facts
337
+ .failingPhases("second-page-keeps-tracking")
338
+ .map((name) => facts.phase(name)?.secondPage)
339
+ .some((second) => second !== undefined && second !== null && (second.normalRequests > 0 || second.cookielessRequests > 0)),
340
+ // Only true when the container was read and actually lists
341
+ // exceptions. Everything else — not scanned, parse failed, no
342
+ // exceptions — leaves the fix telling the reader to go and look
343
+ // rather than telling them what they will find.
344
+ tagHasBlockingTriggers: (gtm.tagManagerNormalBlockingTriggers?.length ?? 0) > 0,
345
+ },
346
+ };
347
+ },
348
+ },
349
+ // ── Leaking: visitors tracked who did not agree. ────────────────────────
350
+ {
351
+ id: "consent-default-not-denied",
352
+ kind: "leaking",
353
+ trigger: ["clean-consent-mode-default"],
354
+ triggerMode: "all",
355
+ /**
356
+ * aikido_dev: analytics_storage already reads granted before the banner
357
+ * is answered, so the gate never closes in the first place. Claims only
358
+ * the leak checks whose failing phases are ones where storage actually
359
+ * read permissive — a leak in a phase this cause cannot account for
360
+ * belongs to another cause.
361
+ */
362
+ match: (facts) => {
363
+ const clean = facts.phase("clean");
364
+ if (clean === undefined || !permissiveBeforeAnswer(clean))
365
+ return null;
366
+ const permissivePhases = new Set(facts.observed.filter(permissiveBeforeAnswer).map((phase) => phase.phase));
367
+ const claims = ["clean-before-consent", "second-page-honours-consent", "grant-sets-identity-cookies"].filter((id) => {
368
+ const failing = facts.failingPhases(id);
369
+ return failing.length > 0 && failing.every((phase) => permissivePhases.has(phase));
370
+ });
371
+ return { claims };
372
+ },
373
+ },
374
+ {
375
+ /**
376
+ * meteomatics_com. Consent Mode IS wired — the grant phase emitted
377
+ * `update: granted` and consentSignalConfirmed was true — but the refusal,
378
+ * which this run had to make through the preference centre, emitted
379
+ * nothing at all. analytics_storage stayed on its declared default, which
380
+ * happens to be `denied`, so the refusal produced the right end state and
381
+ * nothing leaked.
382
+ *
383
+ * Right by accident is what `hardening` means, and it is why this cannot
384
+ * be cmp-signal-not-emitted: that cause says the banner "never passes the
385
+ * choice on to Consent Mode" and links to how to turn Consent Mode ON,
386
+ * which is plainly untrue here and sends the reader to configure something
387
+ * already configured. What is actually broken is narrower and worse than
388
+ * it looks — a visitor who grants and later revokes leaves
389
+ * analytics_storage on `granted` from the earlier update, because the
390
+ * revocation emits nothing to override it. This run cannot see that: each
391
+ * phase is a fresh context that never granted first.
392
+ */
393
+ id: "refusal-not-passed-to-consent-mode",
394
+ kind: "hardening",
395
+ trigger: ["consent-mode-signal"],
396
+ triggerMode: "all",
397
+ match: (facts) => {
398
+ const driven = drivenPhases(facts);
399
+ const wired = driven.some((phase) => phase.consentSignalConfirmed === true || phase.analyticsStorageSource === "update");
400
+ const silent = driven.filter((phase) => phase.consentSignalConfirmed !== true && phase.analyticsStorageSource !== "update");
401
+ if (!wired || silent.length === 0)
402
+ return null;
403
+ // Only "held in check" while the default denies AND nothing ran anyway.
404
+ // Either failing makes this a real leak, which is the next rule's.
405
+ const heldByDefault = silent.every((phase) => phase.analyticsStorage === "denied" && !normalFired(phase));
406
+ return heldByDefault ? { claims: [] } : null;
407
+ },
408
+ },
409
+ {
410
+ id: "cmp-signal-not-emitted",
411
+ kind: "leaking",
412
+ trigger: ["consent-mode-signal"],
413
+ triggerMode: "all",
414
+ /**
415
+ * meteomatics_com's reject phase: the banner recorded the refusal but
416
+ * never emitted the Consent Mode update, so anything gated on it stayed
417
+ * on whatever it was. Distinguished from the reload case above by
418
+ * consentSignalConfirmed being false rather than true.
419
+ */
420
+ match: (facts) => {
421
+ const neverEmitted = drivenPhases(facts).some((phase) => phase.consentSignalConfirmed !== true && phase.analyticsStorageSource !== "update");
422
+ if (!neverEmitted)
423
+ return null;
424
+ const leaked = new Set(facts.observed.filter(normalFired).map((phase) => phase.phase));
425
+ const claims = ["reject-blocks-normal", "clean-before-consent", "grant-enables-normal"].filter((id) => {
426
+ const failing = facts.failingPhases(id);
427
+ return failing.length > 0 && failing.some((phase) => leaked.has(phase));
428
+ });
429
+ return { claims };
430
+ },
431
+ },
432
+ {
433
+ /**
434
+ * The snippet's own consent gate let the standard bundle through, on an
435
+ * install where the container has no gate to fall back on.
436
+ *
437
+ * A combined tag — both bundles in one Custom HTML tag — is NOT the
438
+ * defect and must never be reported as one: it is the install the
439
+ * documentation currently prescribes, and it behaves correctly whenever
440
+ * the snippet's gate does. This rule therefore requires an OBSERVED leak
441
+ * (normalFired in a phase that was not the grant), not merely the shape.
442
+ * sovos_com and tumodo_io both have a combined tag and neither fires
443
+ * this, which is the intended behaviour, not a gap.
444
+ *
445
+ * What the combined tag contributes once the gate has failed is the
446
+ * absence of a second line of defence. A GTM tag has one `consent` field
447
+ * and one trigger set, so the two bundles cannot be held to opposite
448
+ * consent states however it is configured — which is why the fix here is
449
+ * "split the tag", a different instruction from
450
+ * no-consent-gate-on-bundle's "add a consent requirement", and one the
451
+ * customer cannot carry out until the tag is split.
452
+ *
453
+ * Placed before no-consent-gate-on-bundle (array order is precedence) so
454
+ * that where the container shows the combined tag, the actionable
455
+ * instruction is the one reported. Where it does not — a hardcoded
456
+ * install, or evidence collected before TagManagerScan.containers
457
+ * existed — this returns null and the existing cause is unaffected.
458
+ *
459
+ * On getworkflex_com the failure is not a race, and the distinction
460
+ * decides what gets fixed: the clean phase leaked while
461
+ * analytics_storage resolved to `denied` from a `default` with no update
462
+ * anywhere in the run, so there was nothing for the bundle to race. The
463
+ * snippet's dataLayer.push wrapper reads neither raw[1] (default vs
464
+ * update) nor raw[2].region, so the CMP's global `granted` default —
465
+ * declared for visitors in regions this one was not in — read as the
466
+ * visitor's own consent.
467
+ */
468
+ id: "combined-tag-gate-failed",
469
+ kind: "leaking",
470
+ trigger: ["clean-before-consent", "reject-blocks-normal"],
471
+ triggerMode: "any",
472
+ match: (facts) => {
473
+ if (combinedBundleTags(facts.analysis.install.tagManagerContainers).length === 0)
474
+ return null;
475
+ // Same phase-awareness as no-consent-gate-on-bundle, and for the same
476
+ // reason: the mechanism named here is "the normal bundle ran when it
477
+ // should not have", so a leak check that failed in a phase where it
478
+ // sent nothing belongs to another cause.
479
+ const ranWithoutGrant = new Set(facts.observed
480
+ .filter((phase) => phase.phase !== "grant" && normalFired(phase))
481
+ .map((phase) => phase.phase));
482
+ if (ranWithoutGrant.size === 0)
483
+ return null;
484
+ return {
485
+ claims: CONSENT_LEAK_CHECKS.filter((id) => {
486
+ const failing = facts.failingPhases(id);
487
+ return failing.length > 0 && failing.some((phase) => ranWithoutGrant.has(phase));
488
+ }),
489
+ };
490
+ },
491
+ },
492
+ {
493
+ id: "no-consent-gate-on-bundle",
494
+ kind: "leaking",
495
+ trigger: ["clean-before-consent", "reject-blocks-normal"],
496
+ triggerMode: "any",
497
+ /**
498
+ * nice_com and manatal_com: Consent Mode reports the decision CORRECTLY
499
+ * (source "update", the right value) and the script runs anyway. Nothing
500
+ * is gating it. The gate on the two Consent Mode checks not failing is
501
+ * what keeps this apart from consent-mode-not-wired.
502
+ *
503
+ * The claim predicate is phase-aware on purpose, and this is where
504
+ * over-collapse is prevented: the mechanism named here is "the normal
505
+ * bundle ran", so a leak check that failed in a phase where the normal
506
+ * bundle sent nothing is not this cause's business. That is why
507
+ * getmoss_com's reject-writes-no-identity (reject sent no normal request)
508
+ * falls through to its own cause, and why manatal keeps three causes
509
+ * rather than collapsing to one.
510
+ */
511
+ match: (facts) => {
512
+ if (facts.statusOf("clean-consent-mode-default") === "fail")
513
+ return null;
514
+ if (facts.statusOf("consent-mode-signal") === "fail")
515
+ return null;
516
+ const ranWithoutGrant = new Set(facts.observed
517
+ .filter((phase) => phase.phase !== "grant" && normalFired(phase))
518
+ .map((phase) => phase.phase));
519
+ if (ranWithoutGrant.size === 0)
520
+ return null;
521
+ const claims = CONSENT_LEAK_CHECKS.filter((id) => {
522
+ const failing = facts.failingPhases(id);
523
+ return failing.length > 0 && failing.some((phase) => ranWithoutGrant.has(phase));
524
+ });
525
+ return { claims };
526
+ },
527
+ },
528
+ {
529
+ id: "navigation-treated-as-consent",
530
+ kind: "leaking",
531
+ trigger: ["second-page-honours-consent"],
532
+ triggerMode: "all",
533
+ // moxo_com. Reached only when no broader consent cause claimed it, which
534
+ // is exactly when the entry page behaved and the navigation did not.
535
+ match: () => ({ claims: [] }),
536
+ },
537
+ {
538
+ id: "reject-writes-identity",
539
+ kind: "leaking",
540
+ trigger: ["reject-writes-no-identity"],
541
+ triggerMode: "all",
542
+ match: () => ({ claims: [] }),
543
+ },
544
+ // ── Duplicate counting. ────────────────────────────────────────────────
545
+ // All four share one trigger, so the ledger guarantees exactly one fires.
546
+ // Ordered most-structural first; page-view-counted-once's own detail still
547
+ // lists every duplicate found, so nothing is hidden by picking one.
548
+ {
549
+ id: "duplicate-two-builds",
550
+ kind: "duplicate-counting",
551
+ trigger: ["page-view-counted-once"],
552
+ triggerMode: "all",
553
+ match: (facts) => hasDuplicateKind(facts, "two-builds") ? { claims: [], vars: { duplicateKind: "two-builds" } } : null,
554
+ },
555
+ {
556
+ id: "duplicate-replayed-buffer",
557
+ kind: "duplicate-counting",
558
+ trigger: ["page-view-counted-once"],
559
+ triggerMode: "all",
560
+ match: (facts) => hasDuplicateKind(facts, "replayed-buffer") ? { claims: [], vars: { duplicateKind: "replayed-buffer" } } : null,
561
+ },
562
+ {
563
+ id: "duplicate-same-build",
564
+ kind: "duplicate-counting",
565
+ trigger: ["page-view-counted-once"],
566
+ triggerMode: "all",
567
+ match: (facts) => hasDuplicateKind(facts, "same-build") ? { claims: [], vars: { duplicateKind: "same-build" } } : null,
568
+ },
569
+ {
570
+ id: "snippet-order",
571
+ kind: "duplicate-counting",
572
+ trigger: ["page-view-counted-once"],
573
+ triggerMode: "all",
574
+ // The one duplicate kind page-view-counted-once warns about rather than
575
+ // failing, because ingest collapses the pair (see isCollapsedByIngest).
576
+ // The race that produced it is identical either way, so the cause and the
577
+ // fix are the same and must still be given.
578
+ triggerOn: "unhealthy",
579
+ match: (facts) => hasDuplicateKind(facts, "mixed-modes") ? { claims: [], vars: { duplicateKind: "mixed-modes" } } : null,
580
+ },
581
+ // ── Hardening: correct today, but only by accident. ────────────────────
582
+ {
583
+ /**
584
+ * F2. Consent Mode is genuinely in use, but only v1: ad_user_data and
585
+ * ad_personalization are not among the declared signals.
586
+ *
587
+ * `hardening` rather than `blocking` because analytics_storage — the only
588
+ * signal Dreamdata's own gating reads — still works, so nothing about
589
+ * this run's tracking is wrong. What it costs the customer is on the ads
590
+ * side, which is worth telling them once and not failing them over.
591
+ *
592
+ * triggerOn "unhealthy": the check reports v1 as a `fail`, but a run that
593
+ * has v1 AND something the check downgraded lands on `warn`, and it is
594
+ * the same missing signals either way.
595
+ */
596
+ id: "consent-mode-v1-only",
597
+ kind: "hardening",
598
+ trigger: ["consent-mode-v2-enabled"],
599
+ triggerMode: "all",
600
+ triggerOn: "unhealthy",
601
+ match: (facts) => (facts.analysis.consentModeV2.causes.includes("v1-only") ? { claims: [] } : null),
602
+ },
603
+ {
604
+ /**
605
+ * F4, and the only cause here that is about how the install can be READ
606
+ * rather than how it behaves.
607
+ *
608
+ * The consent platform declares a permissive global default for
609
+ * analytics_storage and then overrides it for a list of regions. gtag.js
610
+ * resolves that correctly — `entries[k].default` is the region-resolved
611
+ * value — so the visitor's end state is right and nothing leaks, which is
612
+ * what makes it hardening.
613
+ *
614
+ * It is still worth a customer's attention, because everything that reads
615
+ * the dataLayer consent commands directly rather than the resolved state
616
+ * sees the global `granted` and takes it for a decision. Dreamdata's own
617
+ * GTM snippet was one of those readers, which is how this shape was
618
+ * found.
619
+ */
620
+ id: "consent-mode-region-shadowed",
621
+ kind: "hardening",
622
+ trigger: ["consent-mode-v2-enabled"],
623
+ triggerMode: "all",
624
+ triggerOn: "unhealthy",
625
+ match: (facts) => facts.analysis.consentModeV2.causes.includes("region-shadowed-default") ? { claims: [] } : null,
626
+ },
627
+ {
628
+ /**
629
+ * The tag is gated on a consent signal other than analytics_storage, and
630
+ * it does fire — so it works today, for a reason that is not the one the
631
+ * customer configured.
632
+ *
633
+ * tumodo_io requires `ad_storage`. That holds only because Cookiebot
634
+ * grants advertising and statistics from the same click; a visitor who
635
+ * accepts statistics and declines marketing gets no identified tracking,
636
+ * and a visitor who accepts marketing and declines statistics gets it
637
+ * against their choice. Neither shows up in a run that answers the banner
638
+ * with all-accept or all-reject, which is what every phase here does —
639
+ * so behaviour can never find this and the container is the only witness.
640
+ *
641
+ * `hardening` and claims nothing: nothing in this run failed because of
642
+ * it. Trigger-less for the same reason as the blocking variant above, and
643
+ * mutually exclusive with it on `normalFired`.
644
+ */
645
+ id: "consent-gate-broad-signal",
646
+ kind: "hardening",
647
+ trigger: [],
648
+ triggerMode: "all",
649
+ match: (facts) => {
650
+ if (tagsGatedOnWrongSignal(facts.analysis.install.tagManagerContainers).length === 0)
651
+ return null;
652
+ return facts.observed.some(normalFired) ? { claims: [] } : null;
653
+ },
654
+ },
655
+ ];
656
+ //# sourceMappingURL=rules.js.map