@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,2658 @@
1
+ import { COOKIELESS_GLOBAL_KEY, DD_COOKIE_NAMES, describesFailure, isDreamdataUrl, mentionsDreamdata, SEGMENT_COMPAT_GLOBAL_KEY, } from "../evidence/constants.js";
2
+ import { extractLibraries, extractMessageId, formatLibraryBuild } from "../evidence/network.js";
3
+ import { judgeConsentModeV2, sawGoogleTag } from "./consent-mode.js";
4
+ import { diagnose } from "./diagnosis/diagnose.js";
5
+ function storedIdentityNames(storage) {
6
+ // Older evidence (collected before per-value capture existed) has no
7
+ // `values`/`ajsValues` at all — treat that the same as "nothing stored"
8
+ // rather than crashing replay of that evidence.
9
+ const dd = Object.entries(storage.values ?? {});
10
+ const ajs = Object.entries(storage.ajsValues ?? {});
11
+ return [...dd, ...ajs].filter(([, value]) => value !== null && value !== "").map(([name]) => name);
12
+ }
13
+ /**
14
+ * The stored identity names, each said once, with where it lives.
15
+ *
16
+ * An SDK writes its anonymous id to BOTH a cookie and localStorage, so
17
+ * concatenating the two lists printed "dd_anonymous_id, dd_anonymous_id" —
18
+ * which reads as a bug in the report rather than as one entry in two places,
19
+ * and told the reader nothing about which store to go and clear.
20
+ */
21
+ function describeIdentityStores(cookies, localStorage) {
22
+ const stores = new Map();
23
+ for (const name of cookies)
24
+ stores.set(name, [...(stores.get(name) ?? []), "cookie"]);
25
+ for (const name of localStorage)
26
+ stores.set(name, [...(stores.get(name) ?? []), "localStorage"]);
27
+ return [...stores].map(([name, where]) => `${name} (${where.join(" + ")})`).join(", ");
28
+ }
29
+ function identityCookiesSet(phase) {
30
+ return storedIdentityNames(phase.cookies);
31
+ }
32
+ /**
33
+ * True when a second page was actually reached. `attempted` alone is not
34
+ * enough: a click that changed nothing leaves a phase with no second page to
35
+ * judge, and treating that as observed would turn "we could not look" into a
36
+ * failure.
37
+ */
38
+ function secondPageObserved(second) {
39
+ return second !== null && second.attempted && second.urlChanged;
40
+ }
41
+ function summarizeSecondPage(second) {
42
+ if (second === null)
43
+ return null;
44
+ return {
45
+ kind: second.kind,
46
+ observed: secondPageObserved(second),
47
+ toUrl: second.toUrl,
48
+ skippedReason: second.skippedReason,
49
+ normalRequests: second.network.normalRequestCount,
50
+ cookielessRequests: second.network.cookielessRequestCount,
51
+ identityCookiesSet: storedIdentityNames(second.cookies),
52
+ };
53
+ }
54
+ function summarizePhase(phase) {
55
+ return {
56
+ phase: phase.phase,
57
+ consentDriven: phase.consentAcceptance.attempted && phase.consentAcceptance.succeeded,
58
+ consentDriveDetail: phase.consentAcceptance.detail,
59
+ consentUndrivenReason: phase.consentAcceptance.undrivenReason,
60
+ consentSignalConfirmed: phase.consentAcceptance.consentSignalConfirmed,
61
+ cmpDetected: phase.consent.cmpDetected,
62
+ bannerReveal: phase.consent.bannerReveal,
63
+ normalRequests: phase.network.normalRequestCount,
64
+ cookielessRequests: phase.network.cookielessRequestCount,
65
+ identityCookiesSet: identityCookiesSet(phase),
66
+ analyticsStorage: phase.consent.analyticsStorageState ?? "unreadable",
67
+ analyticsStorageSource: phase.consent.analyticsStorageSource ?? "none",
68
+ cmpHasResponse: phase.consent.cmpHasResponse,
69
+ normalBundleInDom: phase.script.normalSnippet,
70
+ cookielessBundleInDom: phase.script.cookielessSnippet,
71
+ secondPage: summarizeSecondPage(phase.secondPage),
72
+ };
73
+ }
74
+ const NOT_RUN = (id, title, phase) => ({
75
+ id,
76
+ title,
77
+ status: "inconclusive",
78
+ detail: `The ${phase} phase was not run, so this could not be checked.`,
79
+ evidence: `phases collected: none named "${phase}"`,
80
+ phases: [phase],
81
+ });
82
+ /**
83
+ * How to describe a phase whose consent drive was not certified.
84
+ *
85
+ * The three ways a drive falls short are not the same finding, and reporting
86
+ * them all as "could not be driven" misreads two of them. Only
87
+ * "not-submitted" and "did-not-survive-reload" mean the numbers under the
88
+ * phase's label were measured on a page with the banner still unanswered.
89
+ * "not-persisted" means the answer WAS submitted and the page WAS reloaded
90
+ * afterwards — the numbers describe the answered page; what is missing is
91
+ * only our evidence that the CMP filed the answer away, which some CMPs
92
+ * simply do not expose. Telling an operator their refusal was never driven,
93
+ * when the refusal demonstrably moved Consent Mode, sends them to debug the
94
+ * wrong thing.
95
+ */
96
+ function undrivenDetail(acceptance) {
97
+ const label = acceptance.direction === "deny" ? "refusal" : "grant";
98
+ const asked = acceptance.direction === "deny" ? "told no" : "asked to turn tracking on";
99
+ const trace = acceptance.detail ? ` (${acceptance.detail})` : "";
100
+ switch (acceptance.undrivenReason) {
101
+ case "not-persisted":
102
+ return (`The ${label} was submitted${trace} and the page was reloaded afterwards, so the values below were measured after the decision — ` +
103
+ `but the CMP was never seen to record it, so this pass cannot be certified as a real ${label}.` +
104
+ (acceptance.consentSignalConfirmed === true
105
+ ? " Consent Mode did move to the expected value, which is independent evidence the decision landed; the gap is in what this harness can read back from the CMP, not necessarily in the site."
106
+ : ""));
107
+ case "did-not-survive-reload":
108
+ return (`The ${label} was submitted${trace} but the reloaded page reported the banner unanswered again, ` +
109
+ `so the values below describe a visit with the banner still open, under a ${label} label.`);
110
+ default:
111
+ return (`The ${label} could not be driven${trace}, so the site was never actually ${asked} — ` +
112
+ `the values below describe a visit with the banner still unanswered.`);
113
+ }
114
+ }
115
+ /**
116
+ * Is the missing cookieless bundle a configuration choice this run can SEE,
117
+ * rather than an absence it merely failed to observe?
118
+ *
119
+ * The difference is the published tag-manager container. It is the
120
+ * deployment's own manifest — every tag, whatever its trigger, including the
121
+ * ones no crawl would ever make fire — so a container that was read in full
122
+ * and ships the standard bundle's tag and no cookieless one is the install
123
+ * stating what it is: consent-gated, standard-only, unconsented visitors
124
+ * deliberately unrecorded. beckman.com is exactly that shape, and reporting
125
+ * it as a failure told a customer whose GTM container does precisely what
126
+ * they built it to do that their install is broken.
127
+ *
128
+ * A snippet install offers nothing equivalent: "no cookieless tag in the DOM"
129
+ * is an observation about the pages this run happened to load, so the absence
130
+ * stays a finding there. Requiring `errors` to be empty matters for the same
131
+ * reason — a container that half-parsed could be hiding the tag in the part
132
+ * that did not.
133
+ */
134
+ function cookielessAbsentByDesign(phases) {
135
+ const scan = phases
136
+ .map((p) => p.script.tagManager)
137
+ .find((tm) => tm.scanned && tm.errors.length === 0 && tm.normalBundleTagFound && !tm.cookielessBundleTagFound);
138
+ if (!scan)
139
+ return null;
140
+ const containers = scan.containerIds.length > 0 ? `container ${scan.containerIds.join(", ")}` : "the container";
141
+ return {
142
+ containers,
143
+ evidence: `script.tagManager: scanned, containerIds [${scan.containerIds.join(", ")}], normalBundleTagFound: true, cookielessBundleTagFound: false, errors: []`,
144
+ };
145
+ }
146
+ /**
147
+ * The checks that only exist across phases. Each one is a comparison — "did
148
+ * behaviour change when the answer changed?" — which is precisely what a
149
+ * single-state run can never answer, because a correctly gated bundle and a
150
+ * broken one both look like silence.
151
+ */
152
+ function buildChecks(byPhase, facts) {
153
+ const checks = [];
154
+ const clean = byPhase.get("clean");
155
+ const grant = byPhase.get("grant");
156
+ const reject = byPhase.get("reject");
157
+ // Read here rather than threaded through `facts`, which is deliberately a
158
+ // Pick of three fields: the container scan does not change between phases,
159
+ // so the longest read is the one whose fetch completed.
160
+ const cookielessGateContainers = [...byPhase.values()]
161
+ .map((p) => p.script.tagManager.containers ?? [])
162
+ .reduce((best, current) => (current.length > best.length ? current : best), []);
163
+ // 1. Nothing identified may fire before the visitor has answered.
164
+ if (!clean) {
165
+ checks.push(NOT_RUN("clean-before-consent", "No identified tracking before consent", "clean"));
166
+ }
167
+ else if (!clean.consent.cmpDetected || clean.consent.cmpHasResponse === true) {
168
+ checks.push({
169
+ id: "clean-before-consent",
170
+ title: "No identified tracking before consent",
171
+ status: "not_applicable",
172
+ detail: clean.consent.cmpDetected
173
+ ? "The banner was already answered on first load (an auto-accept or a carried-over decision), so this load is not a pre-consent one."
174
+ : "No consent platform was detected, so there is no gate for tracking to precede.",
175
+ evidence: `clean.consent.cmpDetected: ${clean.consent.cmpDetected}, clean.consent.cmpHasResponse: ${clean.consent.cmpHasResponse}`,
176
+ phases: ["clean"],
177
+ });
178
+ }
179
+ else {
180
+ const cookies = identityCookiesSet(clean);
181
+ const bad = clean.network.normalRequestCount > 0 || cookies.length > 0;
182
+ checks.push({
183
+ id: "clean-before-consent",
184
+ title: "No identified tracking before consent",
185
+ status: bad ? "fail" : "pass",
186
+ detail: bad
187
+ ? `The banner was unanswered, but identified tracking already ran: ${clean.network.normalRequestCount} normal request(s)${cookies.length > 0 ? ` and identity cookies ${cookies.join(", ")}` : ""}.`
188
+ : "With the banner unanswered, no normal-bundle request fired and no identity cookie was written.",
189
+ evidence: `clean.network.normalRequestCount: ${clean.network.normalRequestCount}, clean identity cookies: [${cookies.join(", ")}]`,
190
+ phases: ["clean"],
191
+ });
192
+ }
193
+ // 1b. Before any decision, Consent Mode has to default to denied. Unlike
194
+ // the driven grant/reject phases, there is no choice for the clean
195
+ // phase to "carry" — nothing has been declared yet, so
196
+ // analyticsStorageSource reading implicit-default here is the expected,
197
+ // correct shape of a working install, not a coincidence to be
198
+ // suspicious of.
199
+ //
200
+ // A default of granted is a pass, not a finding, when the run can show
201
+ // something ELSE held the line: abcam declares analytics_storage
202
+ // granted before the banner is answered, and the normal bundle still
203
+ // never fires, because its GTM tag carries a blocking trigger on a
204
+ // OneTrust group. The observed behaviour is correct in all three
205
+ // phases, and this reader only reports what a run actually did — that
206
+ // the surviving layer is a single point of failure is a future
207
+ // configuration risk, not a defect in this install, so it is stated in
208
+ // the detail and costs the check nothing.
209
+ if (!clean) {
210
+ checks.push(NOT_RUN("clean-consent-mode-default", "Consent Mode defaults to denied before consent", "clean"));
211
+ }
212
+ else if (!clean.consent.cmpDetected) {
213
+ checks.push({
214
+ id: "clean-consent-mode-default",
215
+ title: "Consent Mode defaults to denied before consent",
216
+ status: "not_applicable",
217
+ detail: "No consent platform was detected, so there is no Consent Mode default to check.",
218
+ evidence: "clean.consent.cmpDetected: false",
219
+ phases: ["clean"],
220
+ });
221
+ }
222
+ else if (clean.consent.analyticsStorageState === null) {
223
+ checks.push({
224
+ id: "clean-consent-mode-default",
225
+ title: "Consent Mode defaults to denied before consent",
226
+ status: "not_applicable",
227
+ detail: "The page exposes no Google Consent Mode state at all (no gtag.js), so it cannot be held to a default.",
228
+ evidence: "clean.consent.analyticsStorageState: null",
229
+ phases: ["clean"],
230
+ });
231
+ }
232
+ else {
233
+ const denied = clean.consent.analyticsStorageState === "denied";
234
+ const blockingTriggers = clean.script.tagManager.normalBundleBlockingTriggers ?? [];
235
+ // "Nothing identified ran" is checked here rather than taken from
236
+ // check 1a, so the pass rests on this phase's own evidence.
237
+ const cleanPhaseHeldUp = clean.network.normalRequestCount === 0 && identityCookiesSet(clean).length === 0;
238
+ const heldBySomethingElse = !denied && blockingTriggers.length > 0 && cleanPhaseHeldUp;
239
+ const gateList = blockingTriggers.join("; or ");
240
+ checks.push({
241
+ id: "clean-consent-mode-default",
242
+ title: "Consent Mode defaults to denied before consent",
243
+ status: denied || heldBySomethingElse ? "pass" : "fail",
244
+ detail: denied
245
+ ? `Before the banner was answered, analytics_storage already read denied (${clean.consent.analyticsStorageSource ?? "no source"}) — this is expected even when the source is gtag.js's own implicit default, since nothing has been declared yet.`
246
+ : heldBySomethingElse
247
+ ? `Before the banner was answered, analytics_storage already read ${clean.consent.analyticsStorageState} (${clean.consent.analyticsStorageSource ?? "no source"}), so Consent Mode is not what keeps identified tracking off an unanswered page here. What keeps it off is a blocking trigger on the normal bundle's own tag manager tag: it is blocked whenever ${gateList}. That held in this run — the clean phase sent no normal request and wrote no identity cookie — so nothing identified ran before the visitor answered.`
248
+ : `Before the banner was answered, analytics_storage already read ${clean.consent.analyticsStorageState} (${clean.consent.analyticsStorageSource ?? "no source"}) — tracking is not actually gated on the decision.`,
249
+ evidence: `clean.consent.analyticsStorageState: ${clean.consent.analyticsStorageState}, clean.consent.analyticsStorageSource: ${clean.consent.analyticsStorageSource ?? "none"}` +
250
+ (heldBySomethingElse
251
+ ? `, clean.script.tagManager.normalBundleBlockingTriggers: ${gateList}, clean.network.normalRequestCount: 0`
252
+ : ""),
253
+ phases: ["clean"],
254
+ });
255
+ }
256
+ // 2. Granting consent has to actually turn identified tracking on.
257
+ if (!grant) {
258
+ checks.push(NOT_RUN("grant-enables-normal", "Granting consent enables the normal bundle", "grant"));
259
+ }
260
+ else if (!facts.normalBundleConfigured) {
261
+ checks.push({
262
+ id: "grant-enables-normal",
263
+ title: "Granting consent enables the normal bundle",
264
+ status: "not_applicable",
265
+ detail: "No normal bundle is part of this deployment, so there is nothing for a grant to enable.",
266
+ evidence: "script.normalBundleSignals: []",
267
+ phases: ["grant"],
268
+ });
269
+ }
270
+ else if (!grant.consentAcceptance.succeeded && !grant.consent.cmpDetected) {
271
+ checks.push({
272
+ id: "grant-enables-normal",
273
+ title: "Granting consent enables the normal bundle",
274
+ status: "not_applicable",
275
+ detail: "No consent platform was detected, so there is no gate for a grant to enable — whatever the normal bundle does here is not evidence of a working consent gate.",
276
+ evidence: "grant.consent.cmpDetected: false",
277
+ phases: ["grant"],
278
+ });
279
+ }
280
+ else if (!grant.consentAcceptance.succeeded) {
281
+ checks.push({
282
+ id: "grant-enables-normal",
283
+ title: "Granting consent enables the normal bundle",
284
+ status: "inconclusive",
285
+ detail: undrivenDetail(grant.consentAcceptance),
286
+ evidence: `grant.consentAcceptance.succeeded: false, undrivenReason: ${grant.consentAcceptance.undrivenReason ?? "none"}`,
287
+ phases: ["grant"],
288
+ });
289
+ }
290
+ else if (grant.network.normalRequestCount > 0) {
291
+ checks.push({
292
+ id: "grant-enables-normal",
293
+ title: "Granting consent enables the normal bundle",
294
+ status: "pass",
295
+ detail: `After the grant, the normal bundle sent ${grant.network.normalRequestCount} request(s).`,
296
+ evidence: `grant.network.normalRequestCount: ${grant.network.normalRequestCount}`,
297
+ phases: ["grant"],
298
+ });
299
+ }
300
+ else {
301
+ // No signal proves a tag ever reached the page: everything we know comes
302
+ // from a container, a loader's reference to its companion, or a queue. A
303
+ // tag manager has firing triggers of its own on top of the consent gate,
304
+ // so the operator needs to check those too before blaming the bundle.
305
+ const neverSeenInPage = !facts.normalBundleSignals.some((s) => s.source === "dom-script" ||
306
+ s.source === "cmp-blocked-script" ||
307
+ s.source === "request" ||
308
+ s.source === "beacon");
309
+ checks.push({
310
+ id: "grant-enables-normal",
311
+ title: "Granting consent enables the normal bundle",
312
+ status: "fail",
313
+ detail: `Consent was granted and reloaded, but no normal-bundle request ever fired — the visitor agreed to identified tracking and did not get it. ` +
314
+ (grant.consentAcceptance.consentSignalConfirmed === false
315
+ ? "The CMP recorded the choice but never emitted a Consent Mode update, which is the most likely cause. "
316
+ : grant.consent.analyticsStorageState !== null && grant.consent.analyticsStorageState !== "granted"
317
+ ? `After the post-consent reload analytics_storage reads ${grant.consent.analyticsStorageState} (${grant.consent.analyticsStorageSource ?? "no source"}) despite the grant, which is the most likely cause: anything gated on Consent Mode is still blocked on the very page the visitor lands on. `
318
+ : "") +
319
+ (neverSeenInPage
320
+ ? "No normal-bundle tag was seen on the page in any phase — it is known only from the tag manager container and the cookieless loader's companion check — so also confirm the tag's own firing trigger covers this page."
321
+ : ""),
322
+ evidence: `grant.network.normalRequestCount: 0, grant.consentAcceptance.succeeded: true, script.normalBundleSignals: ${facts.normalBundleSignals
323
+ .map((s) => s.source)
324
+ .join(", ")}`,
325
+ phases: ["grant"],
326
+ });
327
+ }
328
+ // 3. Refusing consent has to actually keep identified tracking off.
329
+ if (!reject) {
330
+ checks.push(NOT_RUN("reject-blocks-normal", "Refusing consent blocks the normal bundle", "reject"));
331
+ }
332
+ else if (!reject.consentAcceptance.succeeded && !reject.consent.cmpDetected) {
333
+ checks.push({
334
+ id: "reject-blocks-normal",
335
+ title: "Refusing consent blocks the normal bundle",
336
+ status: "not_applicable",
337
+ detail: "No consent platform was detected, so there is no gate for a refusal to block — whatever the normal bundle does here is not evidence of a working consent gate.",
338
+ evidence: "reject.consent.cmpDetected: false",
339
+ phases: ["reject"],
340
+ });
341
+ }
342
+ else if (!reject.consentAcceptance.succeeded) {
343
+ checks.push({
344
+ id: "reject-blocks-normal",
345
+ title: "Refusing consent blocks the normal bundle",
346
+ status: "inconclusive",
347
+ detail: undrivenDetail(reject.consentAcceptance),
348
+ evidence: `reject.consentAcceptance.succeeded: false, undrivenReason: ${reject.consentAcceptance.undrivenReason ?? "none"}`,
349
+ phases: ["reject"],
350
+ });
351
+ }
352
+ else {
353
+ const fired = reject.network.normalRequestCount > 0;
354
+ checks.push({
355
+ id: "reject-blocks-normal",
356
+ title: "Refusing consent blocks the normal bundle",
357
+ status: fired ? "fail" : "pass",
358
+ detail: fired
359
+ ? `Consent was explicitly refused, and the normal bundle still sent ${reject.network.normalRequestCount} request(s).`
360
+ : "After an explicit refusal, no normal-bundle request fired.",
361
+ evidence: `reject.network.normalRequestCount: ${reject.network.normalRequestCount}`,
362
+ phases: ["reject"],
363
+ });
364
+ }
365
+ // 4. The cookieless bundle exists to cover exactly the states above — but
366
+ // only where the normal bundle is not already running. The loader stands
367
+ // down on purpose when it finds an unblocked #dreamdata-analytics, so a
368
+ // silent cookieless bundle next to a live normal one is correct
369
+ // behaviour, and whether normal SHOULD be live there is the
370
+ // clean-before-consent check's question, not this one.
371
+ // A reject phase whose refusal was never confirmed to survive its own
372
+ // reload (see collect.ts) measured an indeterminate page, not a refusal —
373
+ // its silence would otherwise read as "cookieless doesn't cover a refusal"
374
+ // when it may only mean "the refusal itself never took".
375
+ const unreliableReject = reject !== undefined && !reject.consentAcceptance.succeeded && reject.consentAcceptance.attempted;
376
+ const unconsented = [clean, unreliableReject ? undefined : reject].filter((p) => p !== undefined);
377
+ const unconsentedNames = unconsented.map((p) => p.phase);
378
+ const uncovered = unconsented.filter((p) => p.network.normalRequestCount === 0);
379
+ if (!facts.cookielessBundleConfigured) {
380
+ // No cookieless bundle at all is a legitimate architecture choice when
381
+ // there is no consent gate to cover. When a CMP IS present, an
382
+ // unconsented visitor records nothing, and that is only waved through
383
+ // where the deployment says in its own published container that this is
384
+ // what it is — see cookielessAbsentByDesign. Absent that, silence where
385
+ // a fallback belongs stays a finding.
386
+ const uncoveredNoCmp = uncovered.filter((p) => p.consent.cmpDetected);
387
+ const byDesign = cookielessAbsentByDesign([...byPhase.values()]);
388
+ if (uncoveredNoCmp.length > 0 && byDesign !== null) {
389
+ checks.push({
390
+ id: "cookieless-covers-unconsented",
391
+ title: "Cookieless tracking covers the unconsented states",
392
+ status: "not_applicable",
393
+ detail: `No cookieless bundle is part of this deployment, and the published tag-manager container says so on purpose: ` +
394
+ `${byDesign.containers} ships the standard bundle's tag and no cookieless one. This deployment is configured to ` +
395
+ `track only visitors who have agreed, so the ${uncoveredNoCmp.map((p) => p.phase).join(" and ")} phase(s) ` +
396
+ `recording nothing is the install behaving as built, not a fault in it. Those visits do go unrecorded: adding ` +
397
+ `Cookieless Account Analytics is how to change that, but it is a decision about coverage, not a defect to fix.`,
398
+ evidence: `${byDesign.evidence}; ${uncoveredNoCmp.map((p) => `${p.phase}.network.normalRequestCount: 0`).join(", ")}`,
399
+ phases: unconsentedNames,
400
+ });
401
+ }
402
+ else if (uncoveredNoCmp.length > 0) {
403
+ checks.push({
404
+ id: "cookieless-covers-unconsented",
405
+ title: "Cookieless tracking covers the unconsented states",
406
+ status: "fail",
407
+ detail: `A consent banner is present, but no cookieless bundle is part of this deployment, so the ${uncoveredNoCmp
408
+ .map((p) => p.phase)
409
+ .join(" and ")} phase(s) recorded nothing at all while consent was withheld or unanswered — those visits went untracked entirely instead of falling back to cookieless tracking.`,
410
+ evidence: uncoveredNoCmp.map((p) => `${p.phase}.network.normalRequestCount: 0`).join(", "),
411
+ phases: uncoveredNoCmp.map((p) => p.phase),
412
+ });
413
+ }
414
+ else {
415
+ checks.push({
416
+ id: "cookieless-covers-unconsented",
417
+ title: "Cookieless tracking covers the unconsented states",
418
+ status: "not_applicable",
419
+ detail: "No cookieless bundle is part of this deployment.",
420
+ evidence: "no cookieless snippet, request or global in any phase",
421
+ phases: unconsentedNames,
422
+ });
423
+ }
424
+ }
425
+ else if (unconsented.length === 0) {
426
+ checks.push(unreliableReject
427
+ ? {
428
+ id: "cookieless-covers-unconsented",
429
+ title: "Cookieless tracking covers the unconsented states",
430
+ status: "inconclusive",
431
+ detail: `The clean phase did not run, and the reject phase's refusal was not certified: ${undrivenDetail(reject.consentAcceptance)} So no unconsented state was reliably observed.`,
432
+ evidence: `clean: not run; reject.consentAcceptance.succeeded: false, undrivenReason: ${reject.consentAcceptance.undrivenReason ?? "none"}`,
433
+ phases: ["reject"],
434
+ }
435
+ : NOT_RUN("cookieless-covers-unconsented", "Cookieless tracking covers the unconsented states", "clean"));
436
+ }
437
+ else if (uncovered.length === 0) {
438
+ checks.push({
439
+ id: "cookieless-covers-unconsented",
440
+ title: "Cookieless tracking covers the unconsented states",
441
+ status: "not_applicable",
442
+ detail: "The normal bundle ran in every unconsented state, so the cookieless bundle correctly stood down — nothing was left uncovered." +
443
+ (unreliableReject
444
+ ? ` (The reject phase's refusal was not certified — ${reject.consentAcceptance.undrivenReason ?? "unknown reason"} — so it was left out of this check.)`
445
+ : ""),
446
+ evidence: unconsented
447
+ .map((p) => `${p.phase}.network.normalRequestCount: ${p.network.normalRequestCount}`)
448
+ .join(", "),
449
+ phases: unconsentedNames,
450
+ });
451
+ }
452
+ else {
453
+ const silent = uncovered.filter((p) => p.network.cookielessRequestCount === 0);
454
+ checks.push({
455
+ id: "cookieless-covers-unconsented",
456
+ title: "Cookieless tracking covers the unconsented states",
457
+ status: silent.length === 0 ? "pass" : "fail",
458
+ detail: silent.length === 0
459
+ ? `The cookieless bundle fired in every unconsented state where nothing else was tracking (${uncovered
460
+ .map((p) => p.phase)
461
+ .join(", ")}).`
462
+ : `A cookieless bundle is installed but the ${silent
463
+ .map((p) => p.phase)
464
+ .join(" and ")} phase(s) recorded nothing at all — neither bundle sent an event, so those visits went untracked entirely.` +
465
+ (unreliableReject
466
+ ? ` (The reject phase's refusal was not certified — ${reject.consentAcceptance.undrivenReason ?? "unknown reason"} — so it was left out of this check.)`
467
+ : ""),
468
+ // The container's own account of the cookieless tag's gate, when it has
469
+ // one, appended to the behavioural counts. Silence in the unconsented
470
+ // phases is the symptom; a consent requirement on the cookieless tag is
471
+ // the reason, and until it was read the fix rested on inference from
472
+ // request counts alone. checkproof.com is the case: tag 96 (standard)
473
+ // and tag 97 (cookieless) both require analytics_storage.
474
+ evidence: [
475
+ uncovered
476
+ .map((p) => `${p.phase}.network.cookielessRequestCount: ${p.network.cookielessRequestCount} (normal: ${p.network.normalRequestCount})`)
477
+ .join(", "),
478
+ ...cookielessTagGates(cookielessGateContainers).map(({ containerId, tag }) => `${containerId} tag ${tag.tagIndex} (cookieless) consent: ${tag.consent === null ? "none" : `[${tag.consent.join(", ")}]`}${tag.blockedWhen.length > 0 ? `, blocked when ${tag.blockedWhen.join("; or ")}` : ""}`),
479
+ ]
480
+ .filter((part) => part.length > 0)
481
+ .join("; "),
482
+ // A failure belongs to the phases that actually went untracked, not to
483
+ // every unconsented phase the check looked at.
484
+ phases: (silent.length === 0 ? uncovered : silent).map((p) => p.phase),
485
+ });
486
+ }
487
+ // 5/6. Identity storage has to follow the answer, in both directions.
488
+ if (!grant) {
489
+ checks.push(NOT_RUN("grant-sets-identity-cookies", "Identity cookies are written after a grant", "grant"));
490
+ }
491
+ else if (!grant.consentAcceptance.succeeded && !grant.consent.cmpDetected) {
492
+ // Storage seen here is the ungated default, not the effect of a grant.
493
+ // Calling it a pass would credit consent handling to a site that has no
494
+ // consent gate at all — the reader would then trust a grant path that was
495
+ // never exercised.
496
+ checks.push({
497
+ id: "grant-sets-identity-cookies",
498
+ title: "Identity cookies are written after a grant",
499
+ status: "not_applicable",
500
+ detail: "No consent platform was detected, so there is no grant for identity storage to follow.",
501
+ evidence: "grant.consent.cmpDetected: false",
502
+ phases: ["grant"],
503
+ });
504
+ }
505
+ else if (!grant.consentAcceptance.succeeded) {
506
+ checks.push({
507
+ id: "grant-sets-identity-cookies",
508
+ title: "Identity cookies are written after a grant",
509
+ status: "inconclusive",
510
+ detail: `${undrivenDetail(grant.consentAcceptance)} Any storage seen here is not attributable to a grant.`,
511
+ evidence: `grant.consentAcceptance.succeeded: false, undrivenReason: ${grant.consentAcceptance.undrivenReason ?? "none"}`,
512
+ phases: ["grant"],
513
+ });
514
+ }
515
+ else if (grant.network.normalRequestCount === 0) {
516
+ checks.push({
517
+ id: "grant-sets-identity-cookies",
518
+ title: "Identity cookies are written after a grant",
519
+ status: "not_applicable",
520
+ detail: "The normal bundle never ran in the grant phase, so there was nothing to write cookies.",
521
+ evidence: "grant.network.normalRequestCount: 0",
522
+ phases: ["grant"],
523
+ });
524
+ }
525
+ else {
526
+ const cookies = identityCookiesSet(grant);
527
+ const hasAnonymous = cookies.some((name) => name.endsWith("anonymous_id"));
528
+ checks.push({
529
+ id: "grant-sets-identity-cookies",
530
+ title: "Identity cookies are written after a grant",
531
+ status: hasAnonymous ? "pass" : "fail",
532
+ detail: hasAnonymous
533
+ ? `An anonymous id was stored after the grant (${cookies.join(", ")}).`
534
+ : "The normal bundle ran after the grant but stored no anonymous id, so visitors cannot be stitched across events.",
535
+ evidence: `grant identity cookies: [${cookies.join(", ")}]`,
536
+ phases: ["grant"],
537
+ });
538
+ }
539
+ if (!reject) {
540
+ checks.push(NOT_RUN("reject-writes-no-identity", "No identity storage after a refusal", "reject"));
541
+ }
542
+ else if (!reject.consentAcceptance.succeeded && !reject.consent.cmpDetected) {
543
+ checks.push({
544
+ id: "reject-writes-no-identity",
545
+ title: "No identity storage after a refusal",
546
+ status: "not_applicable",
547
+ detail: "No consent platform was detected, so there is no refusal for identity storage to follow.",
548
+ evidence: "reject.consent.cmpDetected: false",
549
+ phases: ["reject"],
550
+ });
551
+ }
552
+ else if (!reject.consentAcceptance.succeeded) {
553
+ checks.push({
554
+ id: "reject-writes-no-identity",
555
+ title: "No identity storage after a refusal",
556
+ status: "inconclusive",
557
+ detail: `${undrivenDetail(reject.consentAcceptance)} Any storage seen here is not attributable to a refusal.`,
558
+ evidence: `reject.consentAcceptance.succeeded: false, undrivenReason: ${reject.consentAcceptance.undrivenReason ?? "none"}`,
559
+ phases: ["reject"],
560
+ });
561
+ }
562
+ else {
563
+ const cookies = identityCookiesSet(reject);
564
+ const stored = Object.entries(reject.cookies.localStorageValues)
565
+ .filter(([, v]) => v !== null)
566
+ .map(([k]) => k);
567
+ const bad = cookies.length > 0 || stored.length > 0;
568
+ checks.push({
569
+ id: "reject-writes-no-identity",
570
+ title: "No identity storage after a refusal",
571
+ status: bad ? "fail" : "pass",
572
+ detail: bad
573
+ ? `Consent was refused, but identity was still stored: ${describeIdentityStores(cookies, stored)}.`
574
+ : `No ${DD_COOKIE_NAMES[0]}-style cookie or localStorage entry survived the refusal.`,
575
+ evidence: `reject identity cookies: [${cookies.join(", ")}], localStorage: [${stored.join(", ")}]`,
576
+ phases: ["reject"],
577
+ });
578
+ }
579
+ // 7. Consent Mode v2 has to carry the decision, not just the CMP's own store.
580
+ const driven = [grant, reject].filter((p) => p?.consentAcceptance.succeeded === true);
581
+ if (driven.length === 0) {
582
+ const anyCmpDetected = [grant, reject].some((p) => p?.consent.cmpDetected);
583
+ checks.push({
584
+ id: "consent-mode-signal",
585
+ title: "Google Consent Mode v2 reflects the decision",
586
+ status: anyCmpDetected ? "inconclusive" : "not_applicable",
587
+ detail: anyCmpDetected
588
+ ? "No consent choice was successfully driven, so no Consent Mode transition could be observed."
589
+ : "No consent platform was detected, so there is no decision for Consent Mode to reflect.",
590
+ evidence: anyCmpDetected
591
+ ? "no phase with consentAcceptance.succeeded: true"
592
+ : "consent.cmpDetected: false in grant and reject",
593
+ phases: [grant, reject].filter((p) => p !== undefined).map((p) => p.phase),
594
+ });
595
+ }
596
+ else if (driven.every((p) => p.consentAcceptance.consentSignalConfirmed === null && p.consent.analyticsStorageState === null)) {
597
+ checks.push({
598
+ id: "consent-mode-signal",
599
+ title: "Google Consent Mode v2 reflects the decision",
600
+ status: "not_applicable",
601
+ detail: "The page exposes no Google Consent Mode state at all (no gtag.js), so it cannot be held to one.",
602
+ evidence: "consentAcceptance.consentSignalConfirmed: null and analytics_storage unreadable in every driven phase",
603
+ phases: driven.map((p) => p.phase),
604
+ });
605
+ }
606
+ else {
607
+ const verdicts = driven.map(consentModeVerdict);
608
+ const broken = verdicts.filter((v) => v.verdict === "broken");
609
+ const unconfirmed = verdicts.filter((v) => v.verdict === "unconfirmed");
610
+ const status = broken.length > 0 ? "fail" : unconfirmed.length > 0 ? "inconclusive" : "pass";
611
+ const relevant = broken.length > 0 ? broken : unconfirmed.length > 0 ? unconfirmed : verdicts;
612
+ checks.push({
613
+ id: "consent-mode-signal",
614
+ title: "Google Consent Mode v2 reflects the decision",
615
+ status,
616
+ detail: status === "pass"
617
+ ? `analytics_storage followed every choice this run drove (${verdicts.map((v) => v.phase).join(", ")}), and the value was still there after the post-consent reload.`
618
+ : relevant.map((v) => v.why).join(" "),
619
+ evidence: verdicts.map((v) => v.evidence).join("; "),
620
+ phases: relevant.map((v) => v.phase),
621
+ });
622
+ }
623
+ // 8. And the mechanism itself has to be installed, complete and in the
624
+ // right order — which is a different question from either of the two
625
+ // above and, until now, one no check asked.
626
+ //
627
+ // clean-consent-mode-default asks whether the default denies.
628
+ // consent-mode-signal asks whether a driven decision is carried. Both
629
+ // read analytics_storage, and both are satisfiable by a page that never
630
+ // called gtag('consent', …) in its life: gtag/GTM creates the entries
631
+ // itself as soon as a container loads, so `denied` on an unanswered
632
+ // page can equally be a declared default or gtag.js's own fallback on a
633
+ // site with no Consent Mode at all. ics.active is what separates them.
634
+ //
635
+ // SCOPE BOUNDARY, and it matters for how a reader takes a pass here:
636
+ // this says nothing about whether an unanswered visitor is COVERED.
637
+ // A site can pass this check while every unconsented visit goes
638
+ // untracked, because the standard bundle's loader correctly declines to
639
+ // initialise and no cookieless bundle was installed behind it. That is
640
+ // cookieless-covers-unconsented's question, and the two must not be
641
+ // read as one.
642
+ const observedPhases = [...byPhase.values()];
643
+ checks.push(consentModeV2Check(observedPhases, judgeConsentModeV2(observedPhases)));
644
+ return checks;
645
+ }
646
+ const CONSENT_MODE_V2_TITLE = "Google Consent Mode v2 is correctly enabled";
647
+ /** Turns the run-level Consent Mode v2 verdict into the customer-facing check. */
648
+ function consentModeV2Check(phases, verdict) {
649
+ const observed = verdict.perPhase.map((entry) => entry.phase);
650
+ const check = (status, detail) => ({
651
+ id: "consent-mode-v2-enabled",
652
+ title: CONSENT_MODE_V2_TITLE,
653
+ status,
654
+ detail,
655
+ evidence: verdict.evidence,
656
+ phases: observed,
657
+ });
658
+ if (verdict.outcome === "unreadable") {
659
+ // A page with no gtag.js has nothing to be held to. A page that HAS one
660
+ // and still exposed no readable state is a gap in what this run could
661
+ // see, which is a different thing and must not read as a pass.
662
+ return sawGoogleTag(phases)
663
+ ? check("inconclusive", "A Google tag is on the page, but it exposed no readable Consent Mode state, so whether Consent Mode v2 is enabled could not be established.")
664
+ : check("not_applicable", "The page carries no Google tag, so there is no Google Consent Mode to enable. Consent still has to be honoured — the checks above are what say whether it is.");
665
+ }
666
+ if (verdict.outcome === "not-installed") {
667
+ return check("fail", "Google Consent Mode v2 is not enabled. A Google tag is on the page and it created the consent signal entries itself, which is why analytics_storage looks readable, but nothing ever called gtag('consent', …) — so no consent decision reaches any tag gated on it, and a tag waiting for one waits forever.");
668
+ }
669
+ if (verdict.outcome === "misconfigured") {
670
+ // v1 and a late-setting CMP are faults in what the site DECLARES and in
671
+ // what order — the mechanism is incomplete or arrives too late to gate
672
+ // anything — so they fail.
673
+ //
674
+ // A shadowed default or a closed wait window is a fault in what the
675
+ // declared state can be READ as. Google resolved both correctly, so
676
+ // anything gated on analytics_storage got the right answer and neither
677
+ // can, by construction, be what let a tag through. Deliberately NOT
678
+ // upgraded to a fail when this run also leaked: on workflex.com the leak
679
+ // is a tag manager tag that declares no consent requirement at all, which
680
+ // clean-before-consent already fails on, and blaming it on the shadowed
681
+ // default would both double-count it and tell a customer their Consent
682
+ // Mode is broken when it is the one part that works.
683
+ const structural = verdict.causes.includes("v1-only") || verdict.causes.includes("set-late");
684
+ return check(structural ? "fail" : "warn", describeMisconfigured(verdict));
685
+ }
686
+ if (verdict.outcome === "enabled-not-yet-answered") {
687
+ return check("pass", "Google Consent Mode v2 is enabled: both signals v2 added are declared and gtag('consent', …) is in use. No consent update applied during this run, which is the correct and normal state for a visitor who has not answered the banner — it is not evidence of a fault.");
688
+ }
689
+ return check("pass", "Google Consent Mode v2 is enabled and carrying decisions: both signals v2 added are declared, gtag('consent', …) is in use, and a consent update was applied for the choice this run drove.");
690
+ }
691
+ /** One sentence per cause, in the verdict's own fixed order. */
692
+ function describeMisconfigured(verdict) {
693
+ const sentences = [];
694
+ if (verdict.causes.includes("v1-only")) {
695
+ sentences.push("Consent Mode is running v1, not v2: ad_user_data and ad_personalization are not among the declared signals.");
696
+ }
697
+ if (verdict.causes.includes("set-late")) {
698
+ sentences.push("Consent was set after tags had already run (ics.wasSetLate), so the consent platform loads too late to gate them — the tags that fire before it see no consent state at all.");
699
+ }
700
+ if (verdict.causes.includes("region-shadowed-default")) {
701
+ sentences.push("The consent platform declares a permissive global default for analytics_storage and then overrides it for specific regions. Google resolves that correctly, so the end state is right, but anything reading the dataLayer consent commands directly sees the global 'granted' and takes it for a decision.");
702
+ }
703
+ if (verdict.causes.includes("wait-period-timed-out")) {
704
+ sentences.push("A wait_for_update window closed with no consent update in it, so tags fell back to the declared defaults rather than the visitor's answer.");
705
+ }
706
+ return sentences.join(" ");
707
+ }
708
+ /**
709
+ * What Consent Mode actually did with one driven decision.
710
+ *
711
+ * The transient confirmation is not enough on its own. `consentSignalConfirmed`
712
+ * is read on the answered page, before the post-consent reload, so a CMP that
713
+ * emits the update and then fails to restore it on the next load looks
714
+ * identical to one that works — and every gated tag on every subsequent page
715
+ * stays frozen. The phase's own end state is therefore judged too, and only a
716
+ * value whose source is an explicit `update` is taken as agreement. A site
717
+ * that declares analytics_storage denied by default, or one running on
718
+ * gtag.js's own fallback because nothing declared covers this visitor, reads
719
+ * "denied" on every reject phase whether or not the refusal was ever carried
720
+ * — a match by coincidence is not evidence.
721
+ */
722
+ function consentModeVerdict(phase) {
723
+ const expected = phase.phase === "grant" ? "granted" : "denied";
724
+ const observed = phase.consent.analyticsStorageState;
725
+ const source = phase.consent.analyticsStorageSource;
726
+ const confirmed = phase.consentAcceptance.consentSignalConfirmed;
727
+ const evidence = `${phase.phase}: analytics_storage ${observed ?? "unreadable"} (${source ?? "no source"}), expected ${expected}, signalConfirmed ${confirmed}`;
728
+ const of = (verdict, why) => ({
729
+ phase: phase.phase,
730
+ verdict,
731
+ why,
732
+ evidence,
733
+ });
734
+ if (confirmed === false) {
735
+ return of("broken", `The CMP recorded the ${phase.phase === "grant" ? "grant" : "refusal"} but analytics_storage never moved to ${expected} at all, so nothing gated on Consent Mode could react.`);
736
+ }
737
+ if (observed === null) {
738
+ return of("unconfirmed", `The ${phase.phase} phase saw analytics_storage reach ${expected} while the banner was being answered, but the reloaded page exposes no Consent Mode state to check it against.`);
739
+ }
740
+ if (observed !== expected) {
741
+ return of("broken", `The ${phase.phase} phase drove the banner and analytics_storage read ${expected} while it was being answered, but after the post-consent reload it reads ${observed} (${source ?? "no source"}) — the decision does not survive a page load, so on the page the visitor actually browses every tag gated on Consent Mode stays ${observed}.`);
742
+ }
743
+ if (source !== "update") {
744
+ return of("unconfirmed", `The ${phase.phase} phase ends with analytics_storage ${observed}, which is what was wanted, but ` +
745
+ (source === "default"
746
+ ? "it comes from a default the site itself declares rather than from a consent update"
747
+ : "its source is gtag.js's own implicit default — nothing declared by the site or CMP applies to this visitor") +
748
+ `, so the value matches by coincidence and is not evidence the choice was carried into Consent Mode.`);
749
+ }
750
+ return of("confirmed", `The ${phase.phase} phase ends with analytics_storage ${observed} (${source}).`);
751
+ }
752
+ /**
753
+ * Whether tracking survived the walk to the second page, for one phase.
754
+ *
755
+ * Two asymmetries are deliberate. A normal bundle that stops is always a
756
+ * failure — the visitor consented and stopped being counted. A cookieless
757
+ * bundle that stops is only a failure when nothing replaced it, because the
758
+ * loader stands down on purpose when it finds the normal bundle live, exactly
759
+ * as `cookieless-covers-unconsented` allows on the first page. And a mode
760
+ * APPEARING on the second page is never this check's business: that is a
761
+ * consent question, and second-page-honours-consent asks it.
762
+ */
763
+ function judgeContinuity({ phase, second }) {
764
+ // A page the install was never put on cannot show tracking stopping.
765
+ // whereby.com serves /blog/ from a separate Gatsby build with no tag
766
+ // manager, no script and no global; judged as continuity it says "tracking
767
+ // stopped after the first page view" about a site whose every other page
768
+ // tracks. The walk now steps past such pages (see SECOND_PAGE_MAX_RETRIES);
769
+ // this is what happens when every candidate it could reach was one.
770
+ // Optional-chained rather than compared to null: evidence collected before
771
+ // the second-page install probe existed has no such field at all, and
772
+ // replaying the stored corpus must not crash on it.
773
+ if (second.install?.present === false) {
774
+ return { phase: phase.phase, judged: false, outsideInstall: true, failures: [] };
775
+ }
776
+ const firstNormal = phase.network.normalRequestCount;
777
+ const firstCookieless = phase.network.cookielessRequestCount;
778
+ const nextNormal = second.network.normalRequestCount;
779
+ const nextCookieless = second.network.cookielessRequestCount;
780
+ const failures = [];
781
+ if (firstNormal > 0 && nextNormal === 0) {
782
+ failures.push(`in the ${phase.phase} phase the normal bundle sent ${firstNormal} request(s) on the first page and none on ${second.toUrl}` +
783
+ (nextCookieless > 0
784
+ ? ", where only the cookieless bundle fired — a visitor whose consent state allows identified tracking is tracked anonymously from the second page onwards, and their two page views cannot be stitched together"
785
+ : ""));
786
+ }
787
+ if (firstCookieless > 0 && nextCookieless === 0 && nextNormal === 0) {
788
+ failures.push(`in the ${phase.phase} phase the cookieless bundle covered the first page and nothing at all tracked ${second.toUrl}`);
789
+ }
790
+ return {
791
+ phase: phase.phase,
792
+ judged: firstNormal > 0 || firstCookieless > 0,
793
+ outsideInstall: false,
794
+ failures,
795
+ };
796
+ }
797
+ /**
798
+ * Pages the walk stepped past, as one clause.
799
+ *
800
+ * They are a finding in their own right and must not vanish into the evidence
801
+ * JSON: a section of the site that ships no bundle at all — whereby.com's blog
802
+ * before a consent decision is recorded — is worth a reader's attention even
803
+ * though the check itself went on to judge somewhere else.
804
+ */
805
+ function walkedPastNote(walked) {
806
+ const skipped = [
807
+ ...new Set(walked.flatMap((w) => w.second.rejectedCandidates.map((c) => c.url))),
808
+ ];
809
+ return skipped.length === 0
810
+ ? ""
811
+ : ` The walk stepped past ${skipped.join(", ")} first: no Dreamdata script, global or tag manager was on ${skipped.length > 1 ? "those pages" : "that page"}, so nothing there could be judged either way — worth checking by hand if that section is meant to be tracked.`;
812
+ }
813
+ function continuityEvidence(walked) {
814
+ return walked
815
+ .map(({ phase, second }) => `${phase.phase}: first page ${phase.network.normalRequestCount}n/${phase.network.cookielessRequestCount}c → second page ${second.network.normalRequestCount}n/${second.network.cookielessRequestCount}c (${second.kind}${second.install?.present === false ? ", no install on that page" : ""})`)
816
+ .join("; ");
817
+ }
818
+ /**
819
+ * The continuity checks, split by how the site navigates.
820
+ *
821
+ * One question — "does tracking survive a page change?" — but the two answers
822
+ * are fixed in different places and by different people, so they are reported
823
+ * as two checks rather than one. A document load that stops tracking is a
824
+ * deployment problem: the bundle or its tag-manager trigger is not on the
825
+ * second page. A same-document route change that stops tracking is a code
826
+ * problem: the bundle is right there and nobody calls page() when the route
827
+ * changes. Every phase falls into exactly one of them, so nothing is counted
828
+ * twice.
829
+ */
830
+ function buildContinuityCheck(kind, phases, walkedByKind) {
831
+ const id = kind === "hard" ? "second-page-keeps-tracking" : "soft-nav-keeps-tracking";
832
+ const title = kind === "hard" ? "Tracking survives a page change" : "Tracking survives an in-app route change";
833
+ if (phases.every((p) => p.secondPage === null)) {
834
+ return {
835
+ id,
836
+ title,
837
+ status: "inconclusive",
838
+ detail: "No second page was visited this run (--no-second-page), so nothing beyond the first page view was observed.",
839
+ evidence: "secondPage: null in every phase",
840
+ phases: phases.map((p) => p.phase),
841
+ };
842
+ }
843
+ if (walkedByKind.length === 0) {
844
+ const walkedOther = phases.filter((p) => secondPageObserved(p.secondPage));
845
+ if (walkedOther.length > 0) {
846
+ return {
847
+ id,
848
+ title,
849
+ status: "not_applicable",
850
+ detail: kind === "hard"
851
+ ? "Every navigation this run followed was handled inside the same document by the site's own router, so there was no second document load to judge — soft-nav-keeps-tracking covers those."
852
+ : "Every navigation this run followed loaded a new document, so the site does not navigate in-app on the paths tested — second-page-keeps-tracking covers those.",
853
+ evidence: walkedOther
854
+ .map((p) => `${p.phase}.secondPage.kind: ${p.secondPage?.kind}`)
855
+ .join(", "),
856
+ // Every one of those phases is the other check's to judge, so this one
857
+ // claims none of them.
858
+ phases: [],
859
+ };
860
+ }
861
+ const reasons = phases
862
+ .filter((p) => p.secondPage !== null)
863
+ .map((p) => `${p.phase}: ${p.secondPage?.skippedReason ?? "no navigation observed"}`);
864
+ return {
865
+ id,
866
+ title,
867
+ status: "inconclusive",
868
+ detail: `No second page could be reached, so whether tracking continues past the first page view is unknown. ${reasons.join("; ")}`,
869
+ evidence: reasons.join("; "),
870
+ phases: phases.filter((p) => p.secondPage !== null).map((p) => p.phase),
871
+ };
872
+ }
873
+ const verdicts = walkedByKind.map(judgeContinuity);
874
+ const judged = verdicts.filter((v) => v.judged);
875
+ if (judged.length === 0) {
876
+ const outside = verdicts.filter((v) => v.outsideInstall);
877
+ const landings = walkedByKind
878
+ .filter((w) => w.second.install?.present === false)
879
+ .map((w) => `${w.phase.phase}: ${w.second.toUrl}`);
880
+ return {
881
+ id,
882
+ title,
883
+ status: "not_applicable",
884
+ detail: outside.length === verdicts.length
885
+ ? `Every page the walk could reach is outside this install — no Dreamdata script, global or tag manager is on any of them (${landings.join("; ")}), which is what a separately-built section of the same domain looks like. Nothing about whether tracking survives a page change follows from them, in either direction. Check a page the install does cover by hand.`
886
+ : "Nothing tracked the first page in any phase that reached a second one, so there was no tracking for a page change to interrupt.",
887
+ evidence: continuityEvidence(walkedByKind),
888
+ phases: walkedByKind.map((w) => w.phase.phase),
889
+ };
890
+ }
891
+ const failures = judged.flatMap((v) => v.failures);
892
+ const mechanism = kind === "hard"
893
+ ? "The second page was a full document load, so every bundle got a fresh chance to run: check that the bundle — and, if a tag manager injects it, that tag's own firing trigger — is present on pages other than the entry page."
894
+ : "The second page was a same-document route change, so no script re-ran. A bundle that only tracks on load will never fire again: the site's router has to call page() itself on every route change.";
895
+ const walkedPast = walkedPastNote(walkedByKind);
896
+ return {
897
+ id,
898
+ title,
899
+ status: failures.length === 0 ? "pass" : "fail",
900
+ detail: failures.length === 0
901
+ ? `Tracking continued onto the second page in every phase that reached one (${judged
902
+ .map((v) => v.phase)
903
+ .join(", ")}).${walkedPast}`
904
+ : `Tracking stopped after the first page view: ${failures.join("; ")}. ${mechanism}${walkedPast}`,
905
+ evidence: continuityEvidence(walkedByKind),
906
+ // Only the phases that actually stopped tracking. One broken phase out of
907
+ // three used to read as all three being broken to anything reading the
908
+ // status alone.
909
+ phases: (failures.length === 0 ? judged : judged.filter((v) => v.failures.length > 0)).map((v) => v.phase),
910
+ };
911
+ }
912
+ /**
913
+ * Whether the consent decision still held one page later.
914
+ *
915
+ * Only the states where identified tracking is not allowed can fail this. The
916
+ * usual cause is a CMP configured to treat a navigation as implied consent,
917
+ * which is invisible to any single-page-load check and turns an unanswered or
918
+ * refused banner into a live normal bundle on page two.
919
+ */
920
+ function buildSecondPageConsentCheck(phases, walked) {
921
+ const id = "second-page-honours-consent";
922
+ const title = "The consent decision still holds one page later";
923
+ const candidates = walked.filter(({ phase }) => phase.phase === "clean" || phase.phase === "reject");
924
+ if (candidates.length === 0) {
925
+ return {
926
+ id,
927
+ title,
928
+ status: "inconclusive",
929
+ detail: "Neither the clean nor the reject phase reached a second page, so whether an unanswered or refused banner still holds after a navigation is unknown.",
930
+ evidence: walked.length === 0 ? "no phase reached a second page" : `second pages reached: ${walked.map((w) => w.phase.phase).join(", ")}`,
931
+ phases: phases.filter((p) => p.phase !== "grant").map((p) => p.phase),
932
+ };
933
+ }
934
+ // A phase only has a consent decision to still honour one page later if it
935
+ // actually had one: "clean" needs a banner that exists and is genuinely
936
+ // unanswered, "reject" needs the refusal to have actually been driven. A
937
+ // site with no CMP at all firing the normal bundle everywhere is not this
938
+ // check's business — there is no decision for the second page to betray.
939
+ const gated = candidates.filter(({ phase }) => phase.phase === "clean"
940
+ ? phase.consent.cmpDetected && phase.consent.cmpHasResponse !== true
941
+ : phase.consentAcceptance.succeeded);
942
+ if (gated.length === 0) {
943
+ const anyCmpDetected = phases.some((p) => p.consent.cmpDetected);
944
+ const reasons = candidates.map(({ phase }) => phase.phase === "clean"
945
+ ? phase.consent.cmpDetected
946
+ ? `${phase.phase}: the banner was already answered on first load, so there was no unanswered state to carry forward`
947
+ : `${phase.phase}: no consent platform was detected, so there is no gate for tracking to honour`
948
+ : `${phase.phase}: ${undrivenDetail(phase.consentAcceptance)}`);
949
+ return {
950
+ id,
951
+ title,
952
+ status: anyCmpDetected ? "inconclusive" : "not_applicable",
953
+ detail: `${reasons.join("; ")}. A normal bundle firing on every page is expected when there is no consent decision to honour.`,
954
+ evidence: candidates
955
+ .map(({ phase }) => `${phase.phase}.consent.cmpDetected: ${phase.consent.cmpDetected}, consentAcceptance.succeeded: ${phase.consentAcceptance.succeeded}`)
956
+ .join("; "),
957
+ phases: [],
958
+ };
959
+ }
960
+ // Kept per-phase rather than flattened straight to strings, so the failure
961
+ // can be attributed to the state that broke instead of to both.
962
+ const perPhase = gated.map(({ phase, second }) => {
963
+ const stored = storedIdentityNames(second.cookies);
964
+ const problems = [];
965
+ if (second.network.normalRequestCount > 0) {
966
+ problems.push(`${phase.phase}: the normal bundle sent ${second.network.normalRequestCount} request(s) on ${second.toUrl} even though consent was ${phase.phase === "reject" ? "explicitly refused" : "never given"}`);
967
+ }
968
+ if (stored.length > 0 && identityCookiesSet(phase).length === 0) {
969
+ problems.push(`${phase.phase}: identity storage appeared only after the navigation (${stored.join(", ")}), so the page change itself was treated as consent`);
970
+ }
971
+ return { phase: phase.phase, problems };
972
+ });
973
+ const failures = perPhase.flatMap((p) => p.problems);
974
+ return {
975
+ id,
976
+ title,
977
+ status: failures.length === 0 ? "pass" : "fail",
978
+ detail: failures.length === 0
979
+ ? `Navigating to another page did not turn identified tracking on in the ${gated
980
+ .map((w) => w.phase.phase)
981
+ .join(" or ")} phase(s).`
982
+ : `${failures.join("; ")}. A CMP that treats a navigation as implied consent is the usual cause, and no single page load can reveal it.`,
983
+ evidence: gated
984
+ .map(({ phase, second }) => `${phase.phase}.secondPage.normalRequestCount: ${second.network.normalRequestCount}, identity storage: [${storedIdentityNames(second.cookies).join(", ")}]`)
985
+ .join("; "),
986
+ phases: failures.length === 0
987
+ ? gated.map((w) => w.phase.phase)
988
+ : perPhase.filter((p) => p.problems.length > 0).map((p) => p.phase),
989
+ };
990
+ }
991
+ /**
992
+ * Which identity fields a request must carry, decided by the bundle that sent
993
+ * it.
994
+ *
995
+ * The asymmetry is the whole point: a cookieless event carries NO anonymousId
996
+ * by design — identity is resolved server-side — so requiring one of it would
997
+ * report the cookieless bundle working exactly as built as a defect. An
998
+ * unknown mode is held only to the write key, since which bundle's rules
999
+ * apply cannot be read off the payload. hasUserId/hasUserData are optional in
1000
+ * every mode: they depend on the site calling identify(), which this harness
1001
+ * never triggers.
1002
+ */
1003
+ function requiredIdentityFields(mode) {
1004
+ return mode === "normal" ? ["hasWriteKey", "hasAnonymousId"] : ["hasWriteKey"];
1005
+ }
1006
+ function inspectRequests(phases) {
1007
+ const inspected = [];
1008
+ for (const phase of phases) {
1009
+ const sources = [
1010
+ [phase.network.requests, "first page"],
1011
+ [phase.secondPage?.network.requests ?? [], "second page"],
1012
+ ];
1013
+ for (const [requests, where] of sources) {
1014
+ for (const request of requests) {
1015
+ inspected.push({
1016
+ phase: phase.phase,
1017
+ where,
1018
+ mode: request.mode,
1019
+ endpointName: request.endpointName ?? "unknown",
1020
+ missing: requiredIdentityFields(request.mode).filter((field) => !request.identity[field]),
1021
+ status: request.status,
1022
+ });
1023
+ }
1024
+ }
1025
+ }
1026
+ return inspected;
1027
+ }
1028
+ /**
1029
+ * Every event that reached Dreamdata carried the identity its own bundle owes.
1030
+ *
1031
+ * A request missing a field its mode requires is an event the server cannot
1032
+ * attribute — a normal-bundle page view with no anonymousId cannot be joined
1033
+ * to the visitor's other page views, so it lands as an orphan.
1034
+ *
1035
+ * The severity turns on whether ANY request of that mode got it right in the
1036
+ * same phase, and the distinction is load-bearing rather than cosmetic. All
1037
+ * of them malformed means that bundle's identity is genuinely broken and the
1038
+ * events are being lost — a failure. Some malformed alongside well-formed
1039
+ * ones means the page view WAS delivered attributably and something sent an
1040
+ * extra, thinner copy: a real defect that this run proved is not costing the
1041
+ * customer the page view, which is exactly what "warn" is for. Reporting the
1042
+ * second as a failure would sink a run whose tracking demonstrably works.
1043
+ */
1044
+ function buildRequestPayloadCheck(phases) {
1045
+ const id = "request-payload-complete";
1046
+ const title = "Every event carried the identity its bundle owes";
1047
+ const inspected = inspectRequests(phases);
1048
+ if (inspected.length === 0) {
1049
+ return {
1050
+ id,
1051
+ title,
1052
+ status: "not_applicable",
1053
+ detail: "No request reached Dreamdata in any phase, so there was no payload to inspect. Whether anything should have fired is a question for the consent and core-tracking checks, not this one.",
1054
+ evidence: `requests observed: 0 across ${phases.length} phase(s)`,
1055
+ phases: [],
1056
+ };
1057
+ }
1058
+ const malformed = inspected.filter((b) => b.missing.length > 0);
1059
+ if (malformed.length === 0) {
1060
+ return {
1061
+ id,
1062
+ title,
1063
+ status: "pass",
1064
+ detail: `All ${inspected.length} event(s) carried the identity fields their bundle requires.`,
1065
+ evidence: `${inspected.length} request(s) inspected; 0 missing a required identity field`,
1066
+ phases: [...new Set(inspected.map((b) => b.phase))],
1067
+ };
1068
+ }
1069
+ // Per phase AND mode: "the normal bundle never identified anything in the
1070
+ // reject phase" is a different statement from "one of its three events in
1071
+ // the grant phase went out thin", and only the first is a failure.
1072
+ const brokenGroups = [...new Set(malformed.map((b) => `${b.phase}/${b.mode}`))].filter((key) => inspected.filter((b) => `${b.phase}/${b.mode}` === key).every((b) => b.missing.length > 0));
1073
+ const describe = (b) => `${b.phase}/${b.where}: a ${b.mode} ${b.endpointName} event with no ${b.missing.join(" or ")}`;
1074
+ return {
1075
+ id,
1076
+ title,
1077
+ status: brokenGroups.length > 0 ? "fail" : "warn",
1078
+ detail: brokenGroups.length > 0
1079
+ ? `${malformed.map(describe).join("; ")}. Every ${brokenGroups.join(" and ")} event this run saw was missing a required field, so those events cannot be attributed to the visitor who generated them.`
1080
+ : `${malformed.map(describe).join("; ")}. The same page view was also delivered with full identity in that phase, so the visitor is attributed and nothing was lost — but something is sending an extra, thinner copy of the event, which is worth removing.`,
1081
+ evidence: malformed
1082
+ .map((b) => `${b.phase}.${b.where === "first page" ? "network" : "secondPage.network"}.requests: ${b.mode} ${b.endpointName} missing ${b.missing.join(", ")}`)
1083
+ .join("; "),
1084
+ phases: [...new Set(malformed.map((b) => b.phase))],
1085
+ };
1086
+ }
1087
+ /**
1088
+ * What the install said about itself in the console.
1089
+ *
1090
+ * The collector keeps only messages mentioning "dreamdata" (see
1091
+ * attachConsoleCollector), so this is a narrow signal in both directions: a
1092
+ * message here is almost certainly about the install, and an empty list says
1093
+ * nothing whatsoever about the page's overall health. The detail says so
1094
+ * rather than letting "no errors" read as "the page is clean".
1095
+ *
1096
+ * **This check never fails**, whatever severity the message was printed at.
1097
+ * A console message is the install narrating itself; it is not evidence that
1098
+ * anything the customer cares about broke, and every consequence that would
1099
+ * matter is already observed directly somewhere else — a bundle that never
1100
+ * loaded fails script-installed, events that never fired fail the consent
1101
+ * checks, events that were rejected fail request-delivery-accepted. Two runs
1102
+ * settled it: blackline.com prints "Plugin loaded - YOU SHOULD SEE THIS IN
1103
+ * PRODUCTION" through console.error on every page load, and qa.com's install
1104
+ * reports "Failed to enable Dynamics 365 Marketing" — a third-party
1105
+ * destination that was not ready — while its own page views were delivered
1106
+ * and answered 2xx in all three phases. Both sank a report that was otherwise
1107
+ * clean, on a line of text no visitor sees and no event lost.
1108
+ *
1109
+ * So the message is reported, quoted in full, and graded a warning: a real
1110
+ * defect worth fixing that this run showed cost nothing (see CrossPhaseCheck).
1111
+ * describesFailure only decides how it is worded — whether the install is
1112
+ * reporting a failure or narrating itself in production.
1113
+ *
1114
+ * Kept apart from global-handoff, which uses these same messages only to
1115
+ * EXPLAIN a stub-queue failure it detected another way. A site can log
1116
+ * "Dreamdata snippet included twice" while the handoff still completes, and
1117
+ * that is worth reporting on its own.
1118
+ */
1119
+ function buildConsoleCheck(phases) {
1120
+ const id = "console-clean";
1121
+ const title = "No Dreamdata errors in the console";
1122
+ // Filtered again here, not only in the collector: replay re-scores evidence
1123
+ // JSON collected by an older build, and idtdna.com's corpus entry carries a
1124
+ // CSP error that only matched because the policy it printed allows
1125
+ // cdn.dreamdata.cloud. Re-running the crawl is not a reasonable price for
1126
+ // correcting a false positive in a stored run.
1127
+ const errors = phases.flatMap((p) => p.console.errors.filter(mentionsDreamdata).map((text) => ({ phase: p.phase, text })));
1128
+ const warnings = phases.flatMap((p) => p.console.warnings.filter(mentionsDreamdata).map((text) => ({ phase: p.phase, text })));
1129
+ const logged = [...errors, ...warnings];
1130
+ const quote = (entries) => entries.map((e) => `${e.phase}: "${e.text}"`).join("; ");
1131
+ if (logged.length > 0) {
1132
+ // Severity is the site's choice and a poor guide to it: blackline.com's
1133
+ // announcement arrives at error level, and plenty of real trouble arrives
1134
+ // as a warning. What the message SAYS is the better reading, and it only
1135
+ // shapes the sentence — never the status.
1136
+ const reported = errors.filter((e) => describesFailure(e.text));
1137
+ const chatter = errors.filter((e) => !describesFailure(e.text));
1138
+ const parts = [
1139
+ `The install logged ${logged.length} Dreamdata message(s) to the console: ${quote(logged)}.`,
1140
+ reported.length > 0
1141
+ ? `${reported.length} of them report something the install could not do.`
1142
+ : "",
1143
+ chatter.length > 0
1144
+ ? `${chatter.length} ${chatter.length === 1 ? "is" : "are"} printed at error level but report nothing going wrong — debug output left on in production.`
1145
+ : "",
1146
+ "Nothing in this run showed tracking failing because of any of them; delivery, consent and identity are judged by their own checks.",
1147
+ ];
1148
+ return {
1149
+ id,
1150
+ title,
1151
+ status: "warn",
1152
+ detail: parts.filter((part) => part !== "").join(" "),
1153
+ evidence: quote(logged),
1154
+ phases: [...new Set(logged.map((e) => e.phase))],
1155
+ };
1156
+ }
1157
+ return {
1158
+ id,
1159
+ title,
1160
+ status: "pass",
1161
+ detail: "No Dreamdata error or warning was logged in any phase. Only messages mentioning Dreamdata are captured, so this says nothing about the page's other scripts.",
1162
+ evidence: `console.errors and console.warnings empty across ${phases.length} phase(s)`,
1163
+ phases: phases.map((p) => p.phase),
1164
+ };
1165
+ }
1166
+ /**
1167
+ * Everything this run set out to observe, it observed.
1168
+ *
1169
+ * The other half of debugging-signals. console-clean speaks for what the
1170
+ * install printed; this speaks for what the harness itself could not reach —
1171
+ * a phase that timed out, a banner it failed to drive, a navigation it gave
1172
+ * up on. Those are limits of the observation, never a defect of the site, so
1173
+ * this check never fails: it is "inconclusive", which says the run saw less
1174
+ * than it meant to without pretending the site did something wrong.
1175
+ *
1176
+ * It exists so debugging-signals is decided by checks rather than by the
1177
+ * model's reading (see CHECK_DECIDED_ITEMS). Without it, the one thing left
1178
+ * for the model to score the item down for was a collection error, and it had
1179
+ * to be trusted to tell that apart from a broken install.
1180
+ */
1181
+ function buildCollectionCompleteCheck(phases) {
1182
+ const id = "collection-complete";
1183
+ const title = "The run observed everything it set out to";
1184
+ const errors = phases.flatMap((p) => p.collectionErrors.map((text) => ({ phase: p.phase, text })));
1185
+ if (errors.length > 0) {
1186
+ return {
1187
+ id,
1188
+ title,
1189
+ status: "inconclusive",
1190
+ detail: `The harness logged ${errors.length} collection error(s): ${errors.map((e) => `${e.phase}: "${e.text}"`).join("; ")}. These limit what this run could see; they say nothing about the install.`,
1191
+ evidence: errors.map((e) => `${e.phase}.collectionErrors: "${e.text}"`).join("; "),
1192
+ phases: [...new Set(errors.map((e) => e.phase))],
1193
+ };
1194
+ }
1195
+ return {
1196
+ id,
1197
+ title,
1198
+ status: "pass",
1199
+ detail: "No phase logged a collection error, so nothing in the report is limited by something the harness failed to reach.",
1200
+ evidence: `collectionErrors empty across ${phases.length} phase(s)`,
1201
+ phases: phases.map((p) => p.phase),
1202
+ };
1203
+ }
1204
+ /**
1205
+ * The events went to the site's own domain, and the writeKey proves they are
1206
+ * this install's.
1207
+ *
1208
+ * respond.io delivers to dd.respond.io, a first-party CNAME onto Dreamdata.
1209
+ * Nothing in the URL says so, and the run that could not see it reported "no
1210
+ * request reached Dreamdata in any phase" about a site whose every page view
1211
+ * was being delivered and accepted — the worst kind of wrong a verification
1212
+ * report can be. The collector now claims a Segment-shaped request on any host
1213
+ * when it carries the same writeKey the installed bundle does (see
1214
+ * attributeTo); this check is where that claim is stated out loud, because a
1215
+ * host the run decided to trust on its own should never be something the
1216
+ * reader has to reverse-engineer from a URL in the request log.
1217
+ *
1218
+ * The leftovers matter too, and differently:
1219
+ * - No key anywhere in the page (a bundled npm install) means there was
1220
+ * nothing to match against, so a real first-party host would look exactly
1221
+ * like this. That is a warn naming --tracking-host, not a silent pass.
1222
+ * - A key that simply differs is somebody else's Segment-shaped collector.
1223
+ * Not this install, and not this check's business.
1224
+ */
1225
+ function buildFirstPartyDeliveryCheck(phases, install) {
1226
+ const id = "first-party-delivery-host";
1227
+ const title = "Events delivered to a first-party domain";
1228
+ const hostOf = (url) => {
1229
+ try {
1230
+ return new URL(url).hostname;
1231
+ }
1232
+ catch {
1233
+ return url;
1234
+ }
1235
+ };
1236
+ // A host the operator named with --tracking-host is not a discovery: the run
1237
+ // was told to watch it, and saying the writeKey is what claimed it would
1238
+ // credit this check with work the flag did.
1239
+ const configured = [...new Set(phases.flatMap((p) => p.network.trackingHosts))];
1240
+ const offHost = (requests) => requests.map((r) => hostOf(r.url)).filter((host) => !isDreamdataUrl(`https://${host}/`, configured));
1241
+ const claimed = [...new Set(phases.flatMap((p) => offHost(p.network.requests)))];
1242
+ const leftover = phases.flatMap((p) => p.network.unattributedRequests);
1243
+ const leftoverHosts = [...new Set(leftover.map((b) => hostOf(b.url)))];
1244
+ if (claimed.length > 0) {
1245
+ return {
1246
+ id,
1247
+ title,
1248
+ status: "pass",
1249
+ detail: `Events are delivered to ${claimed.join(", ")} rather than to a Dreamdata host directly — a first-party CNAME. They are counted as this install's because every one carries writeKey ${install.writeKey}, the key the installed bundle itself uses.`,
1250
+ evidence: `network.requests hosts: ${claimed.join(", ")}; install.writeKey: ${install.writeKey ?? "null"}`,
1251
+ phases: phases.filter((p) => offHost(p.network.requests).length > 0).map((p) => p.phase),
1252
+ };
1253
+ }
1254
+ if (leftover.length > 0 && install.writeKey === null) {
1255
+ return {
1256
+ id,
1257
+ title,
1258
+ status: "warn",
1259
+ detail: `${leftover.length} request(s) with the right shape went to ${leftoverHosts.join(", ")}, but this install exposes no writeKey anywhere in the page, so nothing can confirm they are Dreamdata's rather than another Segment-compatible tool's. Re-run with --tracking-host ${leftoverHosts[0]} if that host is yours.`,
1260
+ evidence: `${leftoverHosts.join(", ")}; install.writeKey: null`,
1261
+ phases: [...new Set(phases.filter((p) => p.network.unattributedRequests.length > 0).map((p) => p.phase))],
1262
+ };
1263
+ }
1264
+ if (leftover.length > 0) {
1265
+ return {
1266
+ id,
1267
+ title,
1268
+ status: "not_applicable",
1269
+ detail: `${leftover.length} Segment-shaped request(s) went to ${leftoverHosts.join(", ")} carrying a different writeKey than this install's (${install.writeKey}), so they belong to another tool and are not counted here.`,
1270
+ evidence: `${leftoverHosts.join(", ")} carry a writeKey other than ${install.writeKey}`,
1271
+ phases: [...new Set(phases.filter((p) => p.network.unattributedRequests.length > 0).map((p) => p.phase))],
1272
+ };
1273
+ }
1274
+ return {
1275
+ id,
1276
+ title,
1277
+ status: "not_applicable",
1278
+ detail: "Every request this run saw went to a host it was already watching, so no first-party delivery domain had to be recognised.",
1279
+ evidence: `no request outside the watched hosts (${["Dreamdata-owned", ...configured].join(", ")})`,
1280
+ phases: phases.map((p) => p.phase),
1281
+ };
1282
+ }
1283
+ /**
1284
+ * Dreamdata accepted the events the site sent it.
1285
+ *
1286
+ * A request leaving the browser is not the same as an event being recorded:
1287
+ * the request can be answered 400 for a write key the account does not
1288
+ * recognise, 403, or 5xx. Every other check in this file reads the REQUEST —
1289
+ * whether a bundle fired, what it carried, when — and would report a site
1290
+ * whose every event is being rejected as tracking perfectly, because from the
1291
+ * page's side it is. This is the one that reads the answer.
1292
+ *
1293
+ * A request whose response never came back is not counted either way: the page
1294
+ * navigated away or the phase ended before it resolved, which is a limit of
1295
+ * the observation and not a verdict about the server.
1296
+ */
1297
+ function buildRequestDeliveryCheck(phases) {
1298
+ const id = "request-delivery-accepted";
1299
+ const title = "Dreamdata accepted the events it was sent";
1300
+ const answered = inspectRequests(phases).filter((b) => b.status !== undefined);
1301
+ if (answered.length === 0) {
1302
+ return {
1303
+ id,
1304
+ title,
1305
+ status: "not_applicable",
1306
+ detail: "No event's response was observed in any phase, so whether Dreamdata accepted anything could not be read. That is a limit of what this run could see, not a statement about the server.",
1307
+ evidence: "requests with an observed response status: 0",
1308
+ phases: [],
1309
+ };
1310
+ }
1311
+ const rejected = answered.filter((b) => b.status < 200 || b.status >= 300);
1312
+ return {
1313
+ id,
1314
+ title,
1315
+ status: rejected.length === 0 ? "pass" : "fail",
1316
+ detail: rejected.length === 0
1317
+ ? `All ${answered.length} event(s) whose response was observed were accepted (2xx).`
1318
+ : `${rejected
1319
+ .map((b) => `${b.phase}/${b.where}: a ${b.mode} ${b.endpointName} event was answered HTTP ${b.status}`)
1320
+ .join("; ")}. Those events left the browser but were not recorded, so the site looks instrumented from the page's side while Dreamdata receives nothing. A write key the account does not recognise is the usual cause of a 4xx here.`,
1321
+ evidence: rejected.length === 0
1322
+ ? `${answered.length} request(s) answered 2xx`
1323
+ : rejected.map((b) => `${b.phase}: ${b.mode} ${b.endpointName} status ${b.status}`).join("; "),
1324
+ phases: rejected.length === 0 ? [...new Set(answered.map((b) => b.phase))] : [...new Set(rejected.map((b) => b.phase))],
1325
+ };
1326
+ }
1327
+ /**
1328
+ * The visitor's anonymous id, wherever this install keeps it.
1329
+ *
1330
+ * Cookie first, then localStorage: a bundle configured for localStorage
1331
+ * storage keeps no cookie at all, and reading only the cookie would report
1332
+ * that install as having no id rather than as storing it elsewhere.
1333
+ *
1334
+ * The value is normalised before anyone compares two of them, because the
1335
+ * SAME id genuinely arrives in three different spellings: JSON-quoted in
1336
+ * localStorage (`"abc"`), percent-encoded in a cookie (`%22abc%22`), and bare
1337
+ * (`abc`). manatal.com serves the entry page's cookie percent-encoded and the
1338
+ * second page's bare, so a comparison on the raw strings reports a stable id
1339
+ * as having changed — a defect this harness would have manufactured for
1340
+ * itself. Decode first, then strip the quotes.
1341
+ */
1342
+ function anonymousIdOf(storage) {
1343
+ const unquote = (value) => {
1344
+ let decoded = value;
1345
+ try {
1346
+ decoded = decodeURIComponent(value);
1347
+ }
1348
+ catch {
1349
+ // A malformed escape is not an id worth rewriting; compare it as-is.
1350
+ }
1351
+ return decoded.replace(/^"|"$/g, "");
1352
+ };
1353
+ const candidates = [
1354
+ storage.values?.["dd_anonymous_id"],
1355
+ storage.ajsValues?.["ajs_anonymous_id"],
1356
+ storage.localStorageValues?.["dd_anonymous_id"],
1357
+ storage.ajsLocalStorageValues?.["ajs_anonymous_id"],
1358
+ ];
1359
+ for (const value of candidates) {
1360
+ if (typeof value === "string" && value !== "")
1361
+ return unquote(value);
1362
+ }
1363
+ return null;
1364
+ }
1365
+ /**
1366
+ * One visitor must stay one visitor across a navigation.
1367
+ *
1368
+ * An anonymous id that changes between the entry page and the page the phase
1369
+ * walked to splits a single session into two visitors: the two page views
1370
+ * cannot be stitched together, every downstream journey is cut in half, and
1371
+ * nothing about a single page load can reveal it — the entry page looks
1372
+ * perfect, and so does the second one in isolation.
1373
+ *
1374
+ * Deliberately narrow. The id APPEARING only on the second page is a consent
1375
+ * question, not a continuity one, and second-page-honours-consent already
1376
+ * asks it; judging it here too would report one defect as two. This check
1377
+ * only ever fires when both pages had an id and the id is not the same one.
1378
+ */
1379
+ function buildAnonymousIdStableCheck(phases, walked) {
1380
+ const id = "anonymous-id-stable";
1381
+ const title = "The anonymous id survives a page change";
1382
+ const compared = walked
1383
+ .map(({ phase, second }) => ({
1384
+ phase: phase.phase,
1385
+ first: anonymousIdOf(phase.cookies),
1386
+ next: anonymousIdOf(second.cookies),
1387
+ toUrl: second.toUrl,
1388
+ }))
1389
+ .filter((c) => c.first !== null && c.next !== null);
1390
+ if (compared.length === 0) {
1391
+ const anyIdAnywhere = phases.some((p) => anonymousIdOf(p.cookies) !== null);
1392
+ return {
1393
+ id,
1394
+ title,
1395
+ status: anyIdAnywhere ? "inconclusive" : "not_applicable",
1396
+ detail: anyIdAnywhere
1397
+ ? "No phase reached a second page while holding an anonymous id on both sides of the navigation, so whether the id survives a page change was not observed."
1398
+ : "No anonymous id was stored in any phase — expected for a cookieless-by-design install, and for any phase where consent was withheld — so there is no id whose stability could be judged.",
1399
+ evidence: walked.length === 0
1400
+ ? "no phase reached a second page"
1401
+ : walked
1402
+ .map(({ phase, second }) => `${phase.phase}: first ${anonymousIdOf(phase.cookies) ?? "none"}, second ${anonymousIdOf(second.cookies) ?? "none"}`)
1403
+ .join("; "),
1404
+ phases: walked.map((w) => w.phase.phase),
1405
+ };
1406
+ }
1407
+ const changed = compared.filter((c) => c.first !== c.next);
1408
+ return {
1409
+ id,
1410
+ title,
1411
+ status: changed.length === 0 ? "pass" : "fail",
1412
+ detail: changed.length === 0
1413
+ ? `The anonymous id was the same before and after the navigation in the ${compared.map((c) => c.phase).join(" and ")} phase(s), so both page views belong to one visitor.`
1414
+ : `${changed
1415
+ .map((c) => `in the ${c.phase} phase the anonymous id changed from ${c.first} to ${c.next} on the way to ${c.toUrl}`)
1416
+ .join("; ")}. One visitor is being recorded as two, so their page views cannot be stitched into a single journey. A bundle re-initialising with a fresh id, or an id written to a storage the next page cannot read back (a cookie scoped to the wrong path or subdomain), are the usual causes.`,
1417
+ evidence: compared
1418
+ .map((c) => `${c.phase}: cookies ${c.first} -> secondPage ${c.next}`)
1419
+ .join("; "),
1420
+ phases: changed.length === 0 ? compared.map((c) => c.phase) : changed.map((c) => c.phase),
1421
+ };
1422
+ }
1423
+ /** The checks that need a second page view, and so a navigation, to exist at all. */
1424
+ function buildSecondPageChecks(phases) {
1425
+ const walked = phases
1426
+ .filter((phase) => secondPageObserved(phase.secondPage))
1427
+ .map((phase) => ({ phase, second: phase.secondPage }));
1428
+ return [
1429
+ buildContinuityCheck("hard", phases, walked.filter((w) => w.second.kind === "hard")),
1430
+ buildContinuityCheck("soft", phases, walked.filter((w) => w.second.kind === "soft")),
1431
+ buildSecondPageConsentCheck(phases, walked),
1432
+ buildAnonymousIdStableCheck(phases, walked),
1433
+ ];
1434
+ }
1435
+ /**
1436
+ * True when a real consent gate exists on the page but every attempt to
1437
+ * answer it (grant and/or reject, whichever ran) failed to actually drive
1438
+ * it. Distinct from "no CMP" (nothing to drive, so silence is expected) and
1439
+ * from a phase simply not being requested (phasesMissing already covers
1440
+ * that): this is specifically "a banner was seen, and this run could not get
1441
+ * past it", which means any conclusion resting on "the normal bundle never
1442
+ * showed up" — cookieless-by-design, nothing-installed — was never actually
1443
+ * tested against the answered state and cannot be trusted.
1444
+ */
1445
+ function isConsentGateUndetermined(phases) {
1446
+ const nonClean = phases.filter((p) => p.phase !== "clean");
1447
+ if (nonClean.length === 0)
1448
+ return false;
1449
+ const anyCmpDetected = phases.some((p) => p.consent.cmpDetected);
1450
+ if (!anyCmpDetected)
1451
+ return false;
1452
+ return nonClean.every((p) => p.consentAcceptance.attempted && !p.consentAcceptance.succeeded);
1453
+ }
1454
+ /**
1455
+ * True when this phase's browser found NOTHING — not "nothing Dreamdata", but
1456
+ * nothing at all.
1457
+ *
1458
+ * Every clause on its own is a normal thing to see on some real site, which is
1459
+ * why the composite matters: a site can genuinely have no Dreamdata tag, or no
1460
+ * consent banner, or (rarely) no followable internal link. A page with none of
1461
+ * them, no bundle global on window, and not one request to any host we watch
1462
+ * is not a site — it is a document with no site in it.
1463
+ *
1464
+ * Strictly the BROWSER's own evidence. Notably NOT
1465
+ * script.tagManager.containerIds, which by this point may hold ids the
1466
+ * plain-HTTP probe supplied (see scanTagManagerContainers) — using a field the
1467
+ * probe feeds would let the corroboration corroborate itself, and the
1468
+ * comparison below is the whole basis of the call.
1469
+ */
1470
+ function phaseFoundNothing(phase) {
1471
+ return (!phase.script.present &&
1472
+ phase.script.matches.length === 0 &&
1473
+ !phase.globals.dreamdataExists &&
1474
+ !phase.globals.cookielessGlobalExists &&
1475
+ !phase.globals.segmentCompatGlobalExists &&
1476
+ !phase.consent.cmpDetected &&
1477
+ phase.network.requests.length === 0 &&
1478
+ (phase.secondPage === null || !phase.secondPage.urlChanged));
1479
+ }
1480
+ /**
1481
+ * Decides whether the browser was ever shown the real page — see
1482
+ * PageReadability for why this has to be asked before anything else.
1483
+ *
1484
+ * Asserted only on a CONTRADICTION, never on the browser's emptiness alone.
1485
+ * "The browser found nothing" is the ordinary shape of a site with nothing
1486
+ * installed and must stay reportable as one; what makes it a blocked run is
1487
+ * the same URL, fetched without a browser, coming back with the site in it.
1488
+ * With no probe to compare against, this stays false and the run reports what
1489
+ * it saw — a false negative is better than a fabricated excuse for one.
1490
+ */
1491
+ function assessPageReadability(phases, httpProbe) {
1492
+ const blind = phases.filter(phaseFoundNothing);
1493
+ if (blind.length === 0 || phases.length === 0) {
1494
+ return {
1495
+ unreadable: false,
1496
+ reason: "The browser found page content in at least one phase, so it was served the site.",
1497
+ signals: [],
1498
+ };
1499
+ }
1500
+ const blindLabel = blind.length === phases.length ? "every phase" : `the ${blind.map((p) => p.phase).join(", ")} phase(s)`;
1501
+ if (httpProbe === null || !httpProbe.attempted) {
1502
+ return {
1503
+ unreadable: false,
1504
+ reason: `${blindLabel} found no script, no tag-manager container, no consent banner, no request and no second page — but no plain-HTTP fetch was taken, so there is nothing to tell "nothing is installed" apart from "the browser was served a different page". Re-run without --no-http-probe.`,
1505
+ signals: [],
1506
+ };
1507
+ }
1508
+ if (httpProbe.failedReason !== null) {
1509
+ return {
1510
+ unreadable: false,
1511
+ reason: `${blindLabel} found nothing at all, and the plain-HTTP fetch that would corroborate it also failed (${httpProbe.failedReason}), so this run cannot say whether the site is empty or the browser was turned away.`,
1512
+ signals: [],
1513
+ };
1514
+ }
1515
+ // A refusal is a finding, not a missing one. The browser found nothing and
1516
+ // the plain HTTP GET was turned away outright — two independent clients,
1517
+ // both refused, which is what a host defending itself against automation
1518
+ // looks like and is not what a site with nothing installed looks like. This
1519
+ // is the branch zoominfo.com lands on when the wall extends to the probe:
1520
+ // asserting "nothing is installed" off the back of two refusals is the
1521
+ // false negative this whole assessment exists to prevent.
1522
+ const status = httpProbe.status;
1523
+ if (status !== null && (status < 200 || status >= 300)) {
1524
+ return {
1525
+ unreadable: true,
1526
+ reason: `${blindLabel} found no script, no tag-manager container, no consent banner, no request and no second page, ` +
1527
+ `and a plain HTTP GET of the same URL was refused as well (HTTP ${status}, ${httpProbe.bytes} bytes — a body far too small to be the site). ` +
1528
+ "Both clients were turned away, so this run never reached this site's page and nothing here is a finding about its tracking. " +
1529
+ "A --headed re-run is worth one try, but a behavioural challenge (a press-and-hold control, a \"verify you are human\" step) " +
1530
+ "usually survives it — expect to confirm this install in a real browser by hand.",
1531
+ signals: [
1532
+ `the plain HTTP GET was answered HTTP ${status} with a ${httpProbe.bytes}-byte body rather than the site's page`,
1533
+ ],
1534
+ };
1535
+ }
1536
+ // What the served document has that the rendered page did not. Each of
1537
+ // these alone is the same page answering the same question two ways.
1538
+ const signals = [];
1539
+ if (httpProbe.sameOriginLinkCount > 0) {
1540
+ signals.push(`a plain HTTP GET of the same URL was served ${httpProbe.sameOriginLinkCount} same-origin link(s), while the rendered page had none to walk to`);
1541
+ }
1542
+ if (httpProbe.tagManagerContainerIds.length > 0) {
1543
+ signals.push(`the served markup names tag-manager container(s) ${httpProbe.tagManagerContainerIds.join(", ")}, which the rendered page did not`);
1544
+ }
1545
+ if (httpProbe.cmpMarkers.length > 0) {
1546
+ signals.push(`the served markup loads ${httpProbe.cmpMarkers.join(", ")}, while no consent platform was detected on the rendered page`);
1547
+ }
1548
+ if (httpProbe.script?.present) {
1549
+ signals.push("the served markup contains a Dreamdata tag that was not in the rendered DOM");
1550
+ }
1551
+ if (signals.length === 0) {
1552
+ return {
1553
+ unreadable: false,
1554
+ reason: `${blindLabel} found nothing, and a plain HTTP GET of the same URL (HTTP ${httpProbe.status}) found nothing either — the two agree, so this reads as a genuinely empty page rather than a blocked run.`,
1555
+ signals: [],
1556
+ };
1557
+ }
1558
+ return {
1559
+ unreadable: true,
1560
+ reason: `${blindLabel} found no script, no tag-manager container, no consent banner, no request and no second page, ` +
1561
+ `yet a plain HTTP GET of the same URL (HTTP ${httpProbe.status}, ${httpProbe.bytes} bytes) was served a real page. ` +
1562
+ "The browser was shown something else — a bot wall's challenge document is the usual cause — so this run observed " +
1563
+ "no page belonging to this site and cannot judge its tracking. A --headed re-run is worth one try, but a " +
1564
+ "behavioural challenge (a press-and-hold control, a \"verify you are human\" step) usually survives it, so expect " +
1565
+ "to confirm this in a real browser by hand. What IS installed was still recovered, from the served markup and the " +
1566
+ "tag-manager container it names — that part of the report the wall did not cost.",
1567
+ signals,
1568
+ };
1569
+ }
1570
+ /**
1571
+ * Every reason to believe a REAL (non-Dreamdata) Segment install is on this
1572
+ * page, across phases — the clio_com case: Segment forwards to Dreamdata
1573
+ * server-to-server, so no Dreamdata script, global or request ever appears,
1574
+ * and this is the only client-side trace such a pipeline leaves.
1575
+ *
1576
+ * The tag-manager container matters as much as the DOM/request here, and for
1577
+ * the same reason it matters for Dreamdata's own bundles: clio.com ships its
1578
+ * Segment tag from GTM container GTM-TXX2PT8, fired on its own trigger, so a
1579
+ * bounded crawl that never satisfies that trigger sees nothing in the live
1580
+ * DOM or on the wire even though nothing is gating it on consent — reading
1581
+ * the published container is the only way to see it at all.
1582
+ */
1583
+ function thirdPartySegmentSignals(phases) {
1584
+ const signals = [];
1585
+ const seenContainers = new Set();
1586
+ for (const phase of phases) {
1587
+ // Older evidence (collected before this signal existed) has no
1588
+ // `thirdPartySegment` field at all — that is "not collected", not "no
1589
+ // signal found", but it must not crash the offline replay of that
1590
+ // evidence either. Skip the phase rather than fabricate a value.
1591
+ const seg = phase.thirdPartySegment;
1592
+ if (!seg)
1593
+ continue;
1594
+ if (seg.scriptDetected) {
1595
+ signals.push(`${phase.phase}: <script src=${seg.scriptSrc}> loads Segment's own library, not a Dreamdata bundle`);
1596
+ }
1597
+ if (seg.requestCount > 0) {
1598
+ signals.push(`${phase.phase}: ${seg.requestCount} request(s) sent to Segment's own collection endpoint (api.segment.io)`);
1599
+ }
1600
+ const tagManager = phase.script.tagManager;
1601
+ if (tagManager.thirdPartySegmentTagFound) {
1602
+ const key = tagManager.containerIds.join(",");
1603
+ if (!seenContainers.has(key)) {
1604
+ seenContainers.add(key);
1605
+ signals.push(`GTM container ${tagManager.containerIds.join(", ")} ships a tag loading Segment's own library (cdn.segment.com), not a Dreamdata tag`);
1606
+ }
1607
+ }
1608
+ }
1609
+ return signals;
1610
+ }
1611
+ /**
1612
+ * Reconciles the per-phase navigation observations into one statement, and
1613
+ * subordinates the served markup to them.
1614
+ *
1615
+ * Behaviour decides, in this order, and a marker only ever changes the
1616
+ * wording — except in branch 3, where there is no behaviour left to describe.
1617
+ */
1618
+ function classifyRouting(phases, httpProbe) {
1619
+ const markers = httpProbe?.frameworkMarkers ?? [];
1620
+ const named = markers.join(", ");
1621
+ const softPhases = phases.filter((p) => p.secondPage?.kind === "soft").map((p) => p.phase);
1622
+ const hardPhases = phases.filter((p) => p.secondPage?.kind === "hard").map((p) => p.phase);
1623
+ const pathsFollowed = softPhases.length + hardPhases.length;
1624
+ const base = { softPhases, hardPhases, frameworkMarkers: markers, pathsFollowed };
1625
+ // One observed router proves a router exists, so a mixed run resolves here
1626
+ // — but the reason names both lists rather than hiding the hard phases,
1627
+ // because a consent-gated router would look exactly like this.
1628
+ if (softPhases.length > 0) {
1629
+ const mixed = hardPhases.length > 0 ? ` The ${hardPhases.join(", ")} phase(s) loaded a new document for the link they followed.` : "";
1630
+ const corroboration = markers.length > 0 ? ` The served markup names ${named}.` : "";
1631
+ return {
1632
+ ...base,
1633
+ mode: "client-routed",
1634
+ observed: true,
1635
+ reason: `The site's own router handled the link this run followed in the ${softPhases.join(", ")} phase(s) without loading a new document.${mixed}${corroboration}`,
1636
+ };
1637
+ }
1638
+ if (hardPhases.length > 0) {
1639
+ // The honesty guarantee, and the corpus contains the site that needs it:
1640
+ // yougov.com serves ng-version, ships the Angular router, and loaded a
1641
+ // new document for every link followed. The marker is reported and then
1642
+ // explicitly denied any authority over what was observed.
1643
+ const corroboration = markers.length > 0
1644
+ ? ` The served markup names ${named}, which does not by itself mean client-side routing.`
1645
+ : "";
1646
+ return {
1647
+ ...base,
1648
+ mode: "document-loads",
1649
+ observed: true,
1650
+ reason: `Every link this run followed loaded a new document (the ${hardPhases.join(", ")} phase(s)).${corroboration}`,
1651
+ };
1652
+ }
1653
+ if (markers.length > 0) {
1654
+ return {
1655
+ ...base,
1656
+ mode: "client-routed-suspected",
1657
+ observed: false,
1658
+ reason: `No navigation was classified in any phase, so how this site changes pages was not observed. The served markup names ${named}, which ships a client-side router — a suspicion about the markup, not a finding about behaviour.`,
1659
+ };
1660
+ }
1661
+ return {
1662
+ ...base,
1663
+ mode: "unobserved",
1664
+ observed: false,
1665
+ reason: "No navigation was classified in any phase and the served markup names no framework, so how this site changes pages was not observed.",
1666
+ };
1667
+ }
1668
+ function classifyShape(phases, normalBundleConfigured, cookielessBundleConfigured) {
1669
+ const anyPresent = phases.some((p) => p.script.present);
1670
+ const anyRequest = phases.some((p) => p.network.requests.length > 0);
1671
+ if (!anyPresent && !anyRequest && !normalBundleConfigured && !cookielessBundleConfigured) {
1672
+ return {
1673
+ shape: "nothing-installed",
1674
+ shapeReason: "No Dreamdata bundle, request or tag-manager tag was found in any phase.",
1675
+ };
1676
+ }
1677
+ if (!normalBundleConfigured && !cookielessBundleConfigured && phases.some((p) => p.script.helperScriptOnly)) {
1678
+ return {
1679
+ shape: "helper-only",
1680
+ shapeReason: "The only Dreamdata script found was a helper (identify-form), not a tracking bundle.",
1681
+ };
1682
+ }
1683
+ if (normalBundleConfigured && cookielessBundleConfigured) {
1684
+ return {
1685
+ shape: "consent-gated-dual",
1686
+ shapeReason: "Both bundles belong to this deployment, so which one runs is expected to follow the visitor's consent choice.",
1687
+ };
1688
+ }
1689
+ if (normalBundleConfigured) {
1690
+ // Said with the container's authority when there is one: "only the
1691
+ // standard bundle is installed" reads as a gap on its own, and on a
1692
+ // tag-manager install it is a published configuration.
1693
+ const byDesign = cookielessAbsentByDesign(phases);
1694
+ return {
1695
+ shape: "normal-only",
1696
+ shapeReason: byDesign
1697
+ ? `Only the standard cookie-based bundle is installed, by configuration: the published tag-manager ${byDesign.containers} ships its tag and no cookieless one.`
1698
+ : "Only the standard cookie-based bundle is installed.",
1699
+ };
1700
+ }
1701
+ return {
1702
+ shape: "cookieless-by-design",
1703
+ shapeReason: "Only the cookieless bundle is installed: no normal-bundle tag, tag-manager tag, blocked tag, loader companion reference or request was found in any phase.",
1704
+ };
1705
+ }
1706
+ /**
1707
+ * The page URL a `page` request reported, or null if this is not one.
1708
+ *
1709
+ * Scoped to the dedicated endpoint on purpose: a `batch` envelope can carry
1710
+ * page events too, but reading duplicates out of one would mean deciding
1711
+ * whether two events inside a single envelope are two page views or one
1712
+ * delivery — and a missed duplicate is a far cheaper mistake here than an
1713
+ * invented one.
1714
+ */
1715
+ function pageRequestUrl(request) {
1716
+ if (request.endpointName !== "page")
1717
+ return null;
1718
+ const payload = request.payload;
1719
+ const url = payload?.properties?.url ?? payload?.context?.page?.url;
1720
+ return typeof url === "string" && url.length > 0 ? url : null;
1721
+ }
1722
+ /** The `name@version` build(s) this request's own payload says sent it. */
1723
+ function librariesOf(request) {
1724
+ return extractLibraries(request.payload).map(formatLibraryBuild);
1725
+ }
1726
+ /**
1727
+ * The build VERSIONS behind a set of requests — the part that separates two
1728
+ * copies of the script from one install's two bundles.
1729
+ *
1730
+ * One install ships its normal and cookieless bundles at the same version
1731
+ * under two different `library.name`s, so the name alone would read a
1732
+ * correctly gated pair (paytronix.com) as a duplicate install. A second
1733
+ * COPY is a second thing someone added — an npm build inside the app next to
1734
+ * a CDN build from a tag manager — and that shows in the version.
1735
+ */
1736
+ function libraryVersionsOf(requests) {
1737
+ return [...new Set(requests.flatMap((r) => extractLibraries(r.payload).map((build) => build.version)))];
1738
+ }
1739
+ function observedPageViews(phase) {
1740
+ const views = [
1741
+ {
1742
+ phase: phase.phase,
1743
+ label: `${phase.phase} phase, first page`,
1744
+ requests: phase.network.requests,
1745
+ loadedTwiceAroundConsent: phase.network.spansConsentAnswer,
1746
+ },
1747
+ ];
1748
+ const second = phase.secondPage;
1749
+ if (secondPageObserved(second)) {
1750
+ // A page the run walked to with the consent state already settled: one
1751
+ // load, one chance for each bundle to fire, and the page view where a
1752
+ // duplicate loader has nowhere left to hide.
1753
+ views.push({
1754
+ phase: phase.phase,
1755
+ label: `${phase.phase} phase, ${second.toUrl}`,
1756
+ requests: second.network.requests,
1757
+ loadedTwiceAroundConsent: false,
1758
+ });
1759
+ }
1760
+ // The document load taken when the walk did not produce one. On a site that
1761
+ // routes internally this is the phase's ONLY unambiguous page view — the
1762
+ // entry page was loaded twice around the consent answer and the walked page
1763
+ // never re-ran a script, so without this a doubled page view has nowhere it
1764
+ // can be seen. See SecondPageEvidence.hardLoad.
1765
+ const hardLoad = second?.hardLoad;
1766
+ if (hardLoad && hardLoad.failedReason === null) {
1767
+ views.push({
1768
+ phase: phase.phase,
1769
+ label: `${phase.phase} phase, ${hardLoad.url} (document load)`,
1770
+ requests: hardLoad.network.requests,
1771
+ loadedTwiceAroundConsent: false,
1772
+ });
1773
+ }
1774
+ return views;
1775
+ }
1776
+ function findDuplicatePageViews({ phase, label, requests, loadedTwiceAroundConsent }) {
1777
+ const byUrl = new Map();
1778
+ for (const request of requests) {
1779
+ const url = pageRequestUrl(request);
1780
+ if (url === null)
1781
+ continue;
1782
+ byUrl.set(url, [...(byUrl.get(url) ?? []), request]);
1783
+ }
1784
+ return [...byUrl.entries()]
1785
+ .filter(([, sent]) => sent.length > 1)
1786
+ .map(([url, sent]) => {
1787
+ const libraryVersions = libraryVersionsOf(sent);
1788
+ return {
1789
+ phase,
1790
+ where: label,
1791
+ url,
1792
+ modes: sent.map((r) => r.mode),
1793
+ messageIds: sent.map((r) => extractMessageId(r.payload)).filter((id) => id !== null),
1794
+ libraries: [...new Set(sent.flatMap(librariesOf))],
1795
+ libraryVersions,
1796
+ // Two loads around a consent answer are two runs of one install, at
1797
+ // one version. Two versions cannot be that, whatever the timing, so
1798
+ // they are judged even on the page where the banner was answered.
1799
+ judged: !loadedTwiceAroundConsent || libraryVersions.length > 1,
1800
+ };
1801
+ });
1802
+ }
1803
+ /**
1804
+ * Which mechanism produced one double-counted page view.
1805
+ *
1806
+ * Split out of explainDuplicate's prose so the answer is available as DATA:
1807
+ * the diagnosis layer needs to pick a root cause and a fix from it, and the
1808
+ * four mechanisms are fixed in four different places — a tag manager, a
1809
+ * snippet's placement, an app's own bundle, a second copy that must be
1810
+ * removed rather than reconfigured. The branch order is unchanged, and
1811
+ * explainDuplicate still produces the identical sentence for each.
1812
+ */
1813
+ function classifyDuplicate(duplicate) {
1814
+ const { modes, messageIds, libraryVersions } = duplicate;
1815
+ if (messageIds.length > 1 && new Set(messageIds).size === 1)
1816
+ return "replayed-buffer";
1817
+ if (libraryVersions.length > 1)
1818
+ return "two-builds";
1819
+ if (modes.includes("normal") && modes.includes("cookieless"))
1820
+ return "mixed-modes";
1821
+ return "same-build";
1822
+ }
1823
+ /**
1824
+ * Names the mechanism behind one double-counted page view, because the
1825
+ * causes are fixed in different places — a tag manager, a snippet's
1826
+ * placement, an app's own bundle — and the numbers alone do not say which
1827
+ * one this is.
1828
+ */
1829
+ function explainDuplicate(duplicate) {
1830
+ const { where, url, modes, messageIds, libraries, libraryVersions } = duplicate;
1831
+ const head = `${where}: ${modes.length} page events for ${url}`;
1832
+ switch (classifyDuplicate(duplicate)) {
1833
+ case "replayed-buffer":
1834
+ return `${head} sharing one messageId (${messageIds[0]}) — the SAME event delivered twice, which no single healthy instance does: a second bundle replayed a buffer the first had already sent`;
1835
+ case "two-builds":
1836
+ return `${head} from ${libraryVersions.length} different builds of the script (${libraries.join(", ")}) — one install ships every bundle it has at ONE version, so this page is running ${libraryVersions.length} separate copies, each firing its own page() on load. The usual pair is a copy bundled into the site's own app (\`npm:\` versions) next to a CDN copy a tag manager injects. The messageIds differ (${messageIds.join(", ") || "unavailable"}), so ingest cannot collapse them: every document load is counted ${libraryVersions.length} times. A soft (router) navigation hides it, since only the copy wired to the router fires there`;
1837
+ case "mixed-modes":
1838
+ return `${head}, one from each bundle — the page view was counted twice, once identified and once anonymous. The two are built to be mutually exclusive, so this is a snippet PLACEMENT error: the cookieless snippet sits above the normal one, or a tag manager injects them in that order, and the cookieless guards ran before the normal tag existed to be seen`;
1839
+ case "same-build":
1840
+ return `${head} from the ${modes[0]} bundle alone (messageIds ${messageIds.join(", ") || "unavailable"}) — one loader cannot produce two page() calls for one load, so more than one copy of the same snippet is running, typically one hardcoded and one injected by a tag manager`;
1841
+ }
1842
+ }
1843
+ /**
1844
+ * What the duplicate actually costs, which is not the same answer for each
1845
+ * kind.
1846
+ *
1847
+ * The blanket "every affected page view is inflated in reporting" this
1848
+ * replaced was wrong for mixed-modes, and wrong in the direction that gets a
1849
+ * report disbelieved: ingest recognises a cookieless event arriving for a
1850
+ * session that already has an anonymous id and deduplicates it — Dreamdata's
1851
+ * own debugger calls it a Tracking Reset and says in as many words that the
1852
+ * cookieless tracks "will be deduplicated but should not happen". A customer
1853
+ * who checks nuvei.com's events and finds no duplicates there has just been
1854
+ * told something they can see is untrue, about a defect that IS real.
1855
+ *
1856
+ * The two-builds text already carries its own reasoning about whether ingest
1857
+ * can collapse the events (it cannot: the messageIds differ), so the rule
1858
+ * this follows is not new — it is applied consistently now.
1859
+ */
1860
+ /**
1861
+ * True when every duplicate here is the pair ingest is known to collapse.
1862
+ *
1863
+ * This is the whole basis for warning rather than failing (see
1864
+ * CrossPhaseCheck): a warn is a real configuration defect that this run showed
1865
+ * is currently held in check by something else, which is exactly a mixed-mode
1866
+ * pair — the page is wrong, and the numbers a customer reads are not. A single
1867
+ * duplicate of any OTHER kind in the same run sinks it back to a failure,
1868
+ * because nothing is holding those in check.
1869
+ */
1870
+ function isCollapsedByIngest(duplicates) {
1871
+ const kinds = new Set(duplicates.map(classifyDuplicate));
1872
+ return kinds.size === 1 && kinds.has("mixed-modes");
1873
+ }
1874
+ function duplicateConsequence(duplicates) {
1875
+ if (isCollapsedByIngest(duplicates)) {
1876
+ return ("Ingest deduplicates this pair, so the page-view counts a customer reads are not inflated by it — but the visitor's " +
1877
+ "session is reset and re-stitched on every affected page view, each one costs an ingested event, and the correction " +
1878
+ "depends on a safety net rather than on the page behaving. It stops when the extra loader stops firing.");
1879
+ }
1880
+ return "Every affected page view is inflated in reporting until the extra loader is removed";
1881
+ }
1882
+ /**
1883
+ * The duplicates page-view-counted-once actually judges, as data.
1884
+ *
1885
+ * Called by that check and, separately, by analyzeDeployment for the
1886
+ * diagnosis. Computing it twice rather than threading it through is
1887
+ * deliberate: it is a pure function of the phases, so the two callers cannot
1888
+ * disagree, whereas a parameter carrying a second derivation could drift out
1889
+ * of step with the check's own prose.
1890
+ */
1891
+ export function findJudgedDuplicates(phases) {
1892
+ return phases
1893
+ .flatMap(observedPageViews)
1894
+ .filter((view) => view.requests.some((r) => pageRequestUrl(r) !== null))
1895
+ .flatMap(findDuplicatePageViews)
1896
+ .filter((duplicate) => duplicate.judged)
1897
+ .map((duplicate) => ({ phase: duplicate.phase, kind: classifyDuplicate(duplicate) }));
1898
+ }
1899
+ /**
1900
+ * Whether one page view produced exactly one page event.
1901
+ *
1902
+ * Deliberately scoped to page events sharing a URL WITHIN a single observed
1903
+ * page view, rather than to a phase's mode counts. A phase's counts span two
1904
+ * page views, so a cookieless entry page followed by a normal second page
1905
+ * adds up to "both modes fired" while being nothing of the sort — that
1906
+ * combination is a consent question, and second-page-honours-consent asks it.
1907
+ * Only the same page, counted more than once, is this check's business.
1908
+ *
1909
+ * And "one page view" mostly excludes the entry page of a phase that
1910
+ * ANSWERED the banner, because there the run loaded one URL twice: once
1911
+ * unanswered, then again with the decision in force. A correct consent-gated
1912
+ * install tracks both — the cookieless bundle anonymously before the answer,
1913
+ * the normal bundle with an anonymousId after it — so two page events for
1914
+ * that URL are the upgrade the visitor just consented to, not a second
1915
+ * loader. Reading them as a duplicate failed real installs (maxio.com among
1916
+ * them) for behaving exactly as designed.
1917
+ *
1918
+ * "Mostly", because that story has a limit: two loads are two runs of the
1919
+ * SAME install and report one `context.library` build. When the page events
1920
+ * name two different builds, no ordering of loads, queued requests or retried
1921
+ * deliveries can produce them — two copies of the script are installed — so
1922
+ * those are judged on the answered entry page too. getmoss.com is the case:
1923
+ * an npm copy in the site's own Next.js bundle firing alongside the CDN copy
1924
+ * GTM injects, two page events per document load with different messageIds,
1925
+ * and every other field on both looking perfectly healthy.
1926
+ *
1927
+ * A duplicate the check does leave unjudged still shows elsewhere, since a
1928
+ * second loader fires on EVERY load: on the unanswered entry page, and on
1929
+ * the page each phase walks to afterwards.
1930
+ */
1931
+ function buildPageViewCountedOnceCheck(phases) {
1932
+ const id = "page-view-counted-once";
1933
+ const title = "Each page view is counted once";
1934
+ const views = phases.flatMap(observedPageViews);
1935
+ const tracked = views.filter((view) => view.requests.some((r) => pageRequestUrl(r) !== null));
1936
+ const judged = tracked.filter((view) => !view.loadedTwiceAroundConsent);
1937
+ const consentTransition = tracked.filter((view) => view.loadedTwiceAroundConsent);
1938
+ const duplicates = tracked.flatMap(findDuplicatePageViews).filter((duplicate) => duplicate.judged);
1939
+ const describe = ({ label, requests, loadedTwiceAroundConsent }) => {
1940
+ const sent = requests.filter((r) => pageRequestUrl(r) !== null);
1941
+ const builds = [...new Set(sent.flatMap(librariesOf))];
1942
+ const note = loadedTwiceAroundConsent
1943
+ ? " — this URL was loaded twice, either side of the consent answer, so a second event from the same build is not judged"
1944
+ : "";
1945
+ const from = builds.length > 0 ? ` from ${builds.join(", ")}` : "";
1946
+ return `${label}: ${sent.length} page request(s) [${sent.map((r) => r.mode).join(", ")}]${from}${note}`;
1947
+ };
1948
+ const evidence = tracked.map(describe).join("; ");
1949
+ if (judged.length === 0 && duplicates.length === 0) {
1950
+ return {
1951
+ id,
1952
+ title,
1953
+ status: "not_applicable",
1954
+ detail: consentTransition.length === 0
1955
+ ? "No automatic page view reached the wire in any phase, so there was nothing that could be counted twice. Whether one should have fired is core-tracking's question."
1956
+ : `The only page views that tracked were entry pages loaded twice around a consent answer (${consentTransition.length}), where a second page event is the expected consent upgrade rather than a duplicate. No single-load page view was observed, so exclusivity could not be judged — run the clean phase, or let the second-page probe run, to get one.`,
1957
+ evidence: evidence || `page requests observed: 0 across ${views.length} observed page view(s)`,
1958
+ phases: phases.map((p) => p.phase),
1959
+ };
1960
+ }
1961
+ const scope = consentTransition.length === 0
1962
+ ? `${judged.length} page view(s) observed`
1963
+ : `${judged.length} single-load page view(s) judged; ${consentTransition.length} entry page(s) loaded twice around a consent answer judged only on whether they name more than one build, since a second event from ONE build is the consent upgrade working`;
1964
+ return {
1965
+ id,
1966
+ title,
1967
+ status: duplicates.length === 0 ? "pass" : isCollapsedByIngest(duplicates) ? "warn" : "fail",
1968
+ detail: duplicates.length === 0
1969
+ ? `Every page view that tracked produced exactly one page event (${scope}).`
1970
+ : `${duplicates.map(explainDuplicate).join("; ")}. ${duplicateConsequence(duplicates)} (${scope}).`,
1971
+ evidence,
1972
+ phases: [...new Set((duplicates.length === 0 ? judged : duplicates).map((v) => v.phase))],
1973
+ };
1974
+ }
1975
+ /** The handoff is confirmed by either signal; neither one's absence condemns it. */
1976
+ function handoffCompleted(s) {
1977
+ return s.initialized || s.readyFired;
1978
+ }
1979
+ /**
1980
+ * Only two states are actually broken: the global is still the loader's stub
1981
+ * array, or the normal bundle sent requests and left the cookieless global it
1982
+ * deletes on a successful handoff in place while never confirming its own.
1983
+ */
1984
+ function handoffBroken(s) {
1985
+ if (handoffCompleted(s))
1986
+ return false;
1987
+ return s.isStub || s.cookielessGlobalSurvived;
1988
+ }
1989
+ /**
1990
+ * The global a phase is answerable for, picked from the bundle that
1991
+ * demonstrably ran there rather than from the deployment shape.
1992
+ *
1993
+ * A phase where nothing sent a request is not judged at all: a gated bundle
1994
+ * that never loaded has no handoff to complete, and holding it to one is the
1995
+ * failure mode this check exists to avoid.
1996
+ */
1997
+ function handoffSubject(phase, segmentCompat) {
1998
+ const g = phase.globals;
1999
+ const base = { phase: phase.phase, cookielessGlobalSurvived: false };
2000
+ if (phase.network.normalRequestCount > 0) {
2001
+ const key = segmentCompat ? SEGMENT_COMPAT_GLOBAL_KEY : "dreamdata";
2002
+ const exists = segmentCompat ? g.segmentCompatGlobalExists : g.dreamdataExists;
2003
+ const initialized = segmentCompat ? g.segmentCompatInitialized : g.initialized;
2004
+ const isStub = segmentCompat ? g.segmentCompatIsStubQueue : g.dreamdataIsStubQueue;
2005
+ return {
2006
+ ...base,
2007
+ key,
2008
+ exists,
2009
+ initialized,
2010
+ readyFired: g.readyCallbackFired && g.readyCallbackGlobal === key,
2011
+ isStub,
2012
+ cookielessGlobalSurvived: g.cookielessGlobalExists,
2013
+ evidence: `${phase.phase}: normalRequestCount ${phase.network.normalRequestCount}, window.${key} exists ${exists}, ` +
2014
+ `initialized ${initialized}, ready() fired ${g.readyCallbackFired && g.readyCallbackGlobal === key}, ` +
2015
+ `stub array ${isStub}, window['${COOKIELESS_GLOBAL_KEY}'] still present ${g.cookielessGlobalExists}`,
2016
+ };
2017
+ }
2018
+ if (phase.network.cookielessRequestCount > 0) {
2019
+ return {
2020
+ ...base,
2021
+ key: COOKIELESS_GLOBAL_KEY,
2022
+ exists: g.cookielessGlobalExists,
2023
+ initialized: g.cookielessInitialized,
2024
+ readyFired: g.readyCallbackFired && g.readyCallbackGlobal === COOKIELESS_GLOBAL_KEY,
2025
+ isStub: false,
2026
+ evidence: `${phase.phase}: cookielessRequestCount ${phase.network.cookielessRequestCount}, ` +
2027
+ `window['${COOKIELESS_GLOBAL_KEY}'] exists ${g.cookielessGlobalExists}, initialized ${g.cookielessInitialized}`,
2028
+ };
2029
+ }
2030
+ return null;
2031
+ }
2032
+ /**
2033
+ * Whether the bundle that ran finished becoming its global.
2034
+ *
2035
+ * Computed here rather than argued from the evidence each run, because the
2036
+ * absence of a global has two opposite meanings and the model reliably picked
2037
+ * the wrong one. A loader-based install that never becomes its global is
2038
+ * broken in a way the wire cannot show: the automatic page view looks
2039
+ * perfect and every `dreamdata.track()` the site makes goes into a dead
2040
+ * array. A BUNDLED install has no global by construction — the site imported
2041
+ * the SDK and calls the instance directly — and elliptic.co was failed for
2042
+ * exactly that, an install whose every other check passed.
2043
+ *
2044
+ * So the question is asked as "did the global this phase's own requests imply
2045
+ * actually initialize", and only where a global was there to initialize.
2046
+ */
2047
+ function buildGlobalHandoffCheck(phases, install, segmentCompat) {
2048
+ const id = "global-handoff";
2049
+ const title = "The bundle finished handing off to its global";
2050
+ const subjects = phases
2051
+ .map((phase) => handoffSubject(phase, segmentCompat))
2052
+ .filter((s) => s !== null);
2053
+ const evidence = subjects.length > 0
2054
+ ? subjects.map((s) => s.evidence).join("; ")
2055
+ : `no bundle sent a request in any phase (${phases.map((p) => p.phase).join(", ") || "none collected"})`;
2056
+ if (install.method === "bundled-no-global") {
2057
+ // The strongest corroboration available: the SDK has no route-change
2058
+ // tracking of its own, so a soft navigation that still tracked can only
2059
+ // be the site's own code calling page() on a reference it holds — which
2060
+ // is the very call the "missing global" reading claims is impossible.
2061
+ const softNavTracked = phases.filter((p) => secondPageObserved(p.secondPage) &&
2062
+ p.secondPage.kind === "soft" &&
2063
+ p.secondPage.network.normalRequestCount + p.secondPage.network.cookielessRequestCount > 0);
2064
+ const corroboration = softNavTracked.length > 0
2065
+ ? ` A page event also fired on a route change the site's own router handled (${softNavTracked
2066
+ .map((p) => p.phase)
2067
+ .join(", ")}), which the SDK cannot do by itself — so the site demonstrably holds a working reference and can call track/identify on it.`
2068
+ : "";
2069
+ return {
2070
+ id,
2071
+ title,
2072
+ status: "not_applicable",
2073
+ detail: "The SDK is built into the site's own bundle and never touches window, so there is no handoff to complete and no global to look for." +
2074
+ corroboration +
2075
+ " Nothing is missing from the wire; what is missing is only the window handle, which costs no events but does mean console debugging and any window.dreamdata call from outside the bundle (a GTM custom HTML tag, a third-party script) will not find it.",
2076
+ evidence,
2077
+ phases: subjects.map((s) => s.phase),
2078
+ };
2079
+ }
2080
+ const broken = subjects.filter((s) => s.exists && handoffBroken(s));
2081
+ if (broken.length > 0) {
2082
+ const mechanism = broken
2083
+ .map((s) => {
2084
+ const shape = s.isStub
2085
+ ? `window.${s.key} is still the loader's stub ARRAY, so every call the site makes is pushed into a buffer nothing drains`
2086
+ : `window.${s.key} exists but neither reported initialized nor ran its ready() callback`;
2087
+ const proof = s.cookielessGlobalSurvived
2088
+ ? `, and window['${COOKIELESS_GLOBAL_KEY}'] survived, which a completed handoff deletes`
2089
+ : "";
2090
+ return `${s.phase}: the bundle sent requests and ${shape}${proof}`;
2091
+ })
2092
+ .join("; ");
2093
+ return {
2094
+ id,
2095
+ title,
2096
+ status: "fail",
2097
+ detail: `${mechanism}. The automatic page view looks fine on the wire while every dreamdata.track/identify call the site's own code makes is silently dropped — the usual causes are a write key that never reached the bundle, two copies of the snippet on the page, or a CSP blocking the bundle's CDN.`,
2098
+ evidence,
2099
+ phases: broken.map((s) => s.phase),
2100
+ };
2101
+ }
2102
+ const completed = subjects.filter(handoffCompleted);
2103
+ if (completed.length > 0) {
2104
+ return {
2105
+ id,
2106
+ title,
2107
+ status: "pass",
2108
+ detail: `The bundle that ran finished handing off to its global (${completed
2109
+ .map((s) => `${s.phase}: window.${s.key}`)
2110
+ .join(", ")}), so calls the site's own code makes reach the real SDK.`,
2111
+ evidence,
2112
+ phases: completed.map((s) => s.phase),
2113
+ };
2114
+ }
2115
+ if (subjects.length === 0) {
2116
+ return {
2117
+ id,
2118
+ title,
2119
+ status: "not_applicable",
2120
+ detail: "No bundle sent a request in any phase, so no global was expected to appear. Whether one should have is script-presence and core-tracking's question.",
2121
+ evidence,
2122
+ phases: phases.map((p) => p.phase),
2123
+ };
2124
+ }
2125
+ return {
2126
+ id,
2127
+ title,
2128
+ status: "inconclusive",
2129
+ detail: `Requests reached Dreamdata (${subjects
2130
+ .map((s) => s.phase)
2131
+ .join(", ")}), and the global they imply neither confirmed its handoff (no initialized flag, no ready() callback) nor showed any of the broken states — so this is neither a confirmed handoff nor a confirmed stall. Check by hand whether window.${subjects[0].key} is the real SDK once the page has settled.`,
2132
+ evidence,
2133
+ phases: subjects.map((s) => s.phase),
2134
+ };
2135
+ }
2136
+ const BUNDLE_LABEL = {
2137
+ normal: "the standard cookie-based bundle",
2138
+ cookieless: "the cookieless bundle",
2139
+ helper: "a helper script (identify-form)",
2140
+ unknown: "a Dreamdata script whose bundle could not be read off the tag",
2141
+ };
2142
+ function bundleList(kinds) {
2143
+ return kinds.map((k) => BUNDLE_LABEL[k]).join(" and ");
2144
+ }
2145
+ function sentenceCase(text) {
2146
+ return text.charAt(0).toUpperCase() + text.slice(1);
2147
+ }
2148
+ /**
2149
+ * Which bundles the DOM carried, across every phase.
2150
+ *
2151
+ * A DOM tag does NOT mean the tag is hardcoded: `page.content()` is the live
2152
+ * document, so a tag GTM injected is in it too. That ambiguity is resolved
2153
+ * below by the container scan, which is the only thing that can say a tag was
2154
+ * published through GTM.
2155
+ */
2156
+ function domBundlesSeen(phases) {
2157
+ const kinds = new Set();
2158
+ for (const p of phases)
2159
+ for (const m of p.script.matches)
2160
+ kinds.add(m.kind);
2161
+ return [...kinds];
2162
+ }
2163
+ /**
2164
+ * True when no Dreamdata global was ever on `window` — not the real SDK
2165
+ * object, not the loader's stub array, in any phase.
2166
+ *
2167
+ * This is the one thing that separates a bundled (npm) install from a loader
2168
+ * this run could not name. Every loader-based install touches `window`: the
2169
+ * snippet creates its global as a stub array before the bundle even arrives,
2170
+ * and a bare CDN tag creates it on load. So requests on the wire with nothing
2171
+ * on `window` anywhere means the SDK was imported as a module and lives in
2172
+ * the site's own bundle scope, where there is no global to create.
2173
+ */
2174
+ function noGlobalInAnyPhase(phases) {
2175
+ return phases.every((p) => !p.globals.dreamdataExists && !p.globals.cookielessGlobalExists && !p.globals.segmentCompatGlobalExists);
2176
+ }
2177
+ /**
2178
+ * Tags that load BOTH bundles from one GTM tag.
2179
+ *
2180
+ * The single definition, because three readers need it and must agree: the
2181
+ * install `detail`, the `bundles-share-one-tag` cause, and the check that
2182
+ * cause claims. One tag has one `consent` field and one trigger set, so a
2183
+ * combined tag cannot hold the two bundles to opposite consent states however
2184
+ * it is configured — the gate necessarily falls to the snippet's own JS.
2185
+ */
2186
+ export function combinedBundleTags(containers) {
2187
+ return containers.flatMap((container) => container.tags
2188
+ .filter((tag) => tag.bundles.includes("normal") && tag.bundles.includes("cookieless"))
2189
+ .map((tag) => ({ containerId: container.containerId, tag })));
2190
+ }
2191
+ /**
2192
+ * The consent signal a Dreamdata tag has to be gated on, and the only one.
2193
+ *
2194
+ * A gate on any other signal is either inert (telaid.com's normal tag
2195
+ * requires `targeted_advertising`, which nothing on that page grants, and it
2196
+ * never fires at all) or right by coincidence (tumodo.io's requires
2197
+ * `ad_storage`, which Cookiebot happens to grant alongside analytics — and
2198
+ * which stops being true for a visitor who accepts analytics and declines
2199
+ * marketing).
2200
+ */
2201
+ const REQUIRED_CONSENT_SIGNAL = "analytics_storage";
2202
+ /** Dreamdata tags gated on some consent signal, but not on analytics_storage. */
2203
+ export function tagsGatedOnWrongSignal(containers) {
2204
+ return containers.flatMap((container) => container.tags
2205
+ .filter((tag) => tag.bundles.includes("normal") &&
2206
+ tag.consent !== null &&
2207
+ tag.consent.length > 0 &&
2208
+ !tag.consent.includes(REQUIRED_CONSENT_SIGNAL))
2209
+ .map((tag) => ({ containerId: container.containerId, tag })));
2210
+ }
2211
+ /** The cookieless tag's own gate, when the container shows it has one. */
2212
+ export function cookielessTagGates(containers) {
2213
+ return containers.flatMap((container) => container.tags
2214
+ .filter((tag) => tag.bundles.includes("cookieless") && ((tag.consent?.length ?? 0) > 0 || tag.blockedWhen.length > 0))
2215
+ .map((tag) => ({ containerId: container.containerId, tag })));
2216
+ }
2217
+ /**
2218
+ * Which container ships which bundle, in words.
2219
+ *
2220
+ * Replaces "ships from one of them", which was all the flat fields could say
2221
+ * about a page loading several containers — and which hid that checkproof.com
2222
+ * loads two and only GTM-WXSP5VK carries any Dreamdata tag at all.
2223
+ */
2224
+ function describeContainerAttribution(containers) {
2225
+ const parts = containers.map((container) => {
2226
+ if (!container.read)
2227
+ return `${container.containerId} could not be read`;
2228
+ if (container.tags.length === 0)
2229
+ return `${container.containerId} carries no Dreamdata tag`;
2230
+ const combined = container.tags.filter((t) => t.bundles.length > 1).length;
2231
+ const shipped = combined > 0 ? `${bundleList(container.bundles)} from one combined tag` : bundleList(container.bundles);
2232
+ // The gate belongs in the same clause as the container that carries it.
2233
+ // Naming it once at the end of the sentence instead produced "GTM-TKR4CP3
2234
+ // carries no Dreamdata tag, gated in GTM on analytics_storage", which
2235
+ // attributes checkproof.com's gate to the container that has no tag.
2236
+ const normal = container.tags.find((tag) => tag.bundles.includes("normal"));
2237
+ const consent = normal?.consent?.length ? ` gated on ${normal.consent.join(" + ")}` : "";
2238
+ const blocked = normal?.blockedWhen.length
2239
+ ? `${consent ? " and" : ""} blocked whenever ${normal.blockedWhen.join("; or ")}`
2240
+ : "";
2241
+ // The event requirement is the gate a reader is least likely to guess at
2242
+ // and the one most likely to be the actual fault: verda.com's tag fires
2243
+ // only on virtualPageView or cookie_consent_update, so it is not on "all
2244
+ // pages" at all and a route change that skips the push goes untracked.
2245
+ const events = normal?.requiresEvents?.length
2246
+ ? `${consent || blocked ? " and" : ""} fired only on the ${normal.requiresEvents.join(" or ")} dataLayer event${normal.requiresEvents.length > 1 ? "s" : ""}`
2247
+ : "";
2248
+ const gate = consent || blocked || events ? `,${consent}${blocked}${events}` : "";
2249
+ return `${container.containerId} ships ${shipped}${gate}`;
2250
+ });
2251
+ return parts.join("; ");
2252
+ }
2253
+ function summarizeInstall(phases) {
2254
+ const containerIds = [...new Set(phases.flatMap((p) => p.script.tagManager.containerIds))];
2255
+ const tagManagerScanned = phases.some((p) => p.script.tagManager.scanned);
2256
+ const tagManagerBundles = [];
2257
+ if (phases.some((p) => p.script.tagManager.normalBundleTagFound))
2258
+ tagManagerBundles.push("normal");
2259
+ if (phases.some((p) => p.script.tagManager.cookielessBundleTagFound))
2260
+ tagManagerBundles.push("cookieless");
2261
+ const tagManagerNormalConsentGate = phases.find((p) => p.script.tagManager.normalBundleConsentGate !== null)?.script.tagManager
2262
+ .normalBundleConsentGate ?? null;
2263
+ const tagManagerNormalBlockingTriggers = phases.find((p) => p.script.tagManager.normalBundleBlockingTriggers !== null)?.script.tagManager
2264
+ .normalBundleBlockingTriggers ?? null;
2265
+ const tagManagerErrors = [...new Set(phases.flatMap((p) => p.script.tagManager.errors))];
2266
+ // One phase's scan, not a merge across phases: the published container does
2267
+ // not change between them, and the phase that read the most containers is
2268
+ // the one whose read completed. Merging would have to reconcile tag indexes
2269
+ // across separately-parsed bodies for no gain.
2270
+ const tagManagerContainers = phases
2271
+ .map((p) => p.script.tagManager.containers ?? [])
2272
+ .reduce((best, current) => (current.length > best.length ? current : best), []);
2273
+ const domBundles = domBundlesSeen(phases);
2274
+ const domTracking = domBundles.filter((k) => k !== "helper");
2275
+ const segmentCompatSnippet = phases.some((p) => p.script.segmentCompatSnippet);
2276
+ const blockedTagVendors = [
2277
+ ...new Set(phases.flatMap((p) => p.script.matches.map((m) => m.blockedBy)).filter((v) => v !== null)),
2278
+ ];
2279
+ // script.writeKey is only ever extracted from a matched snippet, so it is
2280
+ // null for every install that has no snippet to read — a bundled-no-global
2281
+ // (npm) install has no tag in the DOM at all, and a tag-manager-injected
2282
+ // one carries the key inside the container rather than in the page. The key
2283
+ // those installs DO expose is the one their requests put on the wire, which
2284
+ // is the same key and is directly observed. Falling back to it here is what
2285
+ // the checklist has so far been asking the model to do in prose ("check
2286
+ // network.observedWriteKeys before calling it a failure"); a field that is
2287
+ // null while the run plainly observed the key is a fact the report should
2288
+ // not be leaving to a language model to reconstruct.
2289
+ const writeKey = phases.find((p) => p.script.writeKey !== null)?.script.writeKey ??
2290
+ phases.flatMap((p) => p.network.observedWriteKeys)[0] ??
2291
+ null;
2292
+ const requests = phases.some((p) => p.network.requests.length > 0);
2293
+ const containers = containerIds.join(", ");
2294
+ const gtmShipsDreamdata = tagManagerBundles.length > 0;
2295
+ // Per-container attribution when the scan recorded it, so a page loading
2296
+ // several containers says WHICH one ships the tag rather than "one of
2297
+ // them". Falls back to the old phrasing for evidence collected before
2298
+ // TagManagerScan.containers existed.
2299
+ const gtmNote = tagManagerScanned
2300
+ ? `GTM container${containerIds.length > 1 ? "s" : ""} ${containers} read; ${tagManagerContainers.length > 0
2301
+ ? describeContainerAttribution(tagManagerContainers)
2302
+ : gtmShipsDreamdata
2303
+ ? `${bundleList(tagManagerBundles)} ${tagManagerBundles.length > 1 ? "ship" : "ships"} from ${containerIds.length > 1 ? "one of them" : "it"}`
2304
+ : "no Dreamdata tag in it"}`
2305
+ : "no GTM container was found on the page (or --no-tag-manager-scan skipped the read)";
2306
+ const base = {
2307
+ tagManagerContainerIds: containerIds,
2308
+ tagManagerScanned,
2309
+ tagManagerBundles,
2310
+ tagManagerContainers,
2311
+ tagManagerNormalConsentGate,
2312
+ tagManagerNormalBlockingTriggers,
2313
+ tagManagerErrors,
2314
+ domBundles,
2315
+ segmentCompatSnippet,
2316
+ blockedTagVendors,
2317
+ writeKey,
2318
+ };
2319
+ // Only the container can prove a tag is published through GTM, so it is
2320
+ // read first: a hardcoded verdict is what is left when it says no.
2321
+ if (gtmShipsDreamdata) {
2322
+ // The container ships one bundle and the DOM carries another: the second
2323
+ // one cannot have come from the container, so something else placed it.
2324
+ const extraInDom = domTracking.filter((k) => k === "normal" || k === "cookieless").filter((k) => !tagManagerBundles.includes(k));
2325
+ // Both halves of the gate are named, because a tag whose consent list is
2326
+ // satisfied can still be held back by an exception — and then the thing
2327
+ // keeping tracking off before consent is not Consent Mode at all.
2328
+ const consentGate = tagManagerNormalConsentGate?.length
2329
+ ? `, gated in GTM on ${tagManagerNormalConsentGate.join(" + ")}`
2330
+ : "";
2331
+ const blockingGate = tagManagerNormalBlockingTriggers?.length
2332
+ ? `${consentGate ? " and" : ","} blocked in GTM whenever ${tagManagerNormalBlockingTriggers.join("; or ")}`
2333
+ : "";
2334
+ // Suppressed when describeContainerAttribution already named the gate
2335
+ // against its own container — otherwise the same clause appears twice.
2336
+ const gate = tagManagerContainers.length > 0 ? "" : `${consentGate}${blockingGate}`;
2337
+ if (extraInDom.length > 0) {
2338
+ return {
2339
+ ...base,
2340
+ method: "hardcoded-and-tag-manager",
2341
+ label: `Google Tag Manager (${containers}) + a snippet on the page`,
2342
+ detail: `${gtmNote}${gate}. On top of that, ${bundleList(extraInDom)} was in the DOM without the container shipping it, so that one is placed on the page directly. Two loaders for one account is the usual cause of double-counted page views.`,
2343
+ };
2344
+ }
2345
+ return {
2346
+ ...base,
2347
+ method: "google-tag-manager",
2348
+ label: `Google Tag Manager (${containers})`,
2349
+ detail: `${gtmNote}${gate}. ${domTracking.length > 0
2350
+ ? `The matching tag was also in the live DOM, which is what GTM injecting it looks like — a hardcoded second copy cannot be ruled out from the DOM alone.`
2351
+ : `The tag was never in the DOM in any phase, so it is installed but did not fire under the conditions this run created.`}`,
2352
+ };
2353
+ }
2354
+ if (domTracking.length > 0) {
2355
+ return {
2356
+ ...base,
2357
+ method: "hardcoded-snippet",
2358
+ label: segmentCompatSnippet
2359
+ ? "Hardcoded on the page — Segment-compatible inline snippet"
2360
+ : "Hardcoded on the page — <script> tag",
2361
+ detail: `${sentenceCase(bundleList(domTracking))} is in the page's own markup as ${segmentCompatSnippet ? "an inline loader snippet" : "a script tag"}, and ${gtmNote}, so no tag manager is placing it.${blockedTagVendors.length > 0
2362
+ ? ` ${blockedTagVendors.join(", ")} neutralised the tag rather than removing it — installed, gated.`
2363
+ : ""}`,
2364
+ };
2365
+ }
2366
+ if (requests && noGlobalInAnyPhase(phases)) {
2367
+ return {
2368
+ ...base,
2369
+ method: "bundled-no-global",
2370
+ label: "Built into the site's own bundle — module-scoped SDK, no window global",
2371
+ detail: `Requests reached Dreamdata, no Dreamdata tag was in the DOM in any phase, ${gtmNote}, and no Dreamdata global — not the SDK object, not the loader's stub array — was ever on window. That combination is what importing the SDK as a package (npm) and holding the instance in module scope looks like: there is no loader, so there is nothing to create a global. The install is working; the only cost is that code outside that bundle (a browser console, a GTM custom HTML tag, a third-party script) cannot reach it through window.dreamdata, and the bundle version is pinned at build time rather than following the CDN.`,
2372
+ };
2373
+ }
2374
+ if (requests) {
2375
+ return {
2376
+ ...base,
2377
+ method: "injected-unidentified",
2378
+ label: "Injected at runtime — no snippet and no GTM tag found",
2379
+ detail: `Requests reached Dreamdata, but no Dreamdata tag was in the DOM in any phase and ${gtmNote}. Something injects the loader that this run cannot name — another tag manager, a CMP-managed loader, or a bundler that inlines it after load.`,
2380
+ };
2381
+ }
2382
+ if (domBundles.includes("helper")) {
2383
+ return {
2384
+ ...base,
2385
+ method: "not-installed",
2386
+ label: "Helper script only — no tracking bundle",
2387
+ detail: `The only Dreamdata script on the page is a helper (identify-form). ${gtmNote}.`,
2388
+ };
2389
+ }
2390
+ // Nothing anywhere is only a real answer once the container was actually
2391
+ // readable; an unread container is a gap, not an absence.
2392
+ if (!tagManagerScanned && containerIds.length === 0 && tagManagerErrors.length > 0) {
2393
+ return {
2394
+ ...base,
2395
+ method: "undetermined",
2396
+ label: "Could not tell how the script is installed",
2397
+ detail: `No tag was found in the DOM and the tag-manager read failed: ${tagManagerErrors.join("; ")}.`,
2398
+ };
2399
+ }
2400
+ return {
2401
+ ...base,
2402
+ method: "not-installed",
2403
+ label: "No Dreamdata script installed",
2404
+ detail: `No Dreamdata tag in the DOM, no request on the wire, and ${gtmNote}.`,
2405
+ };
2406
+ }
2407
+ function summarizeCmp(phases) {
2408
+ const detected = phases.some((p) => p.consent.cmpDetected);
2409
+ // A vendor recognised in one phase and only text-matched in another is that
2410
+ // vendor: prefer the confirmed reading over the heuristic one.
2411
+ const withVendor = phases.find((p) => p.consent.cmpVendorConfirmed) ?? phases.find((p) => p.consent.cmpVendor !== null);
2412
+ const vendor = withVendor?.consent.cmpVendor ?? null;
2413
+ const vendorConfirmed = withVendor?.consent.cmpVendorConfirmed ?? false;
2414
+ const driven = phases.filter((p) => p.consentAcceptance.succeeded && p.consentAcceptance.vendor !== null);
2415
+ const drivenVendor = driven[0]?.consentAcceptance.vendor ?? null;
2416
+ const drivenPhases = driven.map((p) => p.phase);
2417
+ const blockedTagVendors = [
2418
+ ...new Set(phases.flatMap((p) => p.script.matches.map((m) => m.blockedBy)).filter((v) => v !== null)),
2419
+ ];
2420
+ const corroboration = blockedTagVendors.length > 0 ? ` A Dreamdata tag on the page is neutralised by ${blockedTagVendors.join(", ")}.` : "";
2421
+ if (!detected) {
2422
+ return {
2423
+ detected: false,
2424
+ vendor: null,
2425
+ vendorConfirmed: false,
2426
+ label: "No CMP detected",
2427
+ detail: `No recognised consent platform global, no known banner element, and no banner-shaped accept/reject control in any phase.${corroboration}`,
2428
+ drivenVendor,
2429
+ drivenPhases,
2430
+ };
2431
+ }
2432
+ if (vendor === "unknown-custom" || !vendorConfirmed) {
2433
+ return {
2434
+ detected: true,
2435
+ vendor,
2436
+ vendorConfirmed: false,
2437
+ label: "Custom banner — vendor not identified",
2438
+ detail: `A consent banner is on the page, but it exposes no recognised vendor global or element id: it was matched only by its accept/reject button text, so the vendor cannot be named.${corroboration}`,
2439
+ drivenVendor,
2440
+ drivenPhases,
2441
+ };
2442
+ }
2443
+ return {
2444
+ detected: true,
2445
+ vendor,
2446
+ vendorConfirmed: true,
2447
+ label: vendor ?? "unrecognised",
2448
+ detail: `Recognised by its own global/banner element.${drivenVendor !== null ? ` This run drove it successfully in the ${drivenPhases.join(" and ")} phase(s).` : " This run never managed to drive it."}${corroboration}`,
2449
+ drivenVendor,
2450
+ drivenPhases,
2451
+ };
2452
+ }
2453
+ /**
2454
+ * Every behavioural check re-stated as "we could not look".
2455
+ *
2456
+ * Each check's own status was derived from the page the browser was shown, so
2457
+ * on a blocked run every one of them is describing a document that does not
2458
+ * belong to this site — including the not_applicable ones, which reached that
2459
+ * status from the same blindness ("no CMP was detected, so there is no gate").
2460
+ * Leaving any of them standing is what turned a wall into a verdict, so they
2461
+ * all go, uniformly, rather than being triaged one by one.
2462
+ */
2463
+ /**
2464
+ * A Dreamdata bundle is part of this deployment, and it knows which account
2465
+ * it belongs to.
2466
+ *
2467
+ * Every trap in this question is about an ABSENCE that is not a missing
2468
+ * install, and each one is already resolved upstream — which is why this
2469
+ * check reads `shape` rather than any phase's `script.present`:
2470
+ *
2471
+ * - a consent-gated bundle is absent from the DOM until the banner is
2472
+ * answered, so a per-phase reading of `script.present` says "not installed"
2473
+ * about a healthy install. The shape is a union across every phase.
2474
+ * - a CMP-neutralised tag is installed and deliberately not executing;
2475
+ * install.blockedTagVendors names who did it.
2476
+ * - a bundled-no-global (npm) install has no tag in the DOM in ANY phase and
2477
+ * never will, because the SDK is imported as a package.
2478
+ * - a tag-manager-injected bundle lives in the container, not the page.
2479
+ *
2480
+ * The write key is the second half: a bundle that loaded but names no account
2481
+ * cannot deliver anything. It is only ever "warn" here, never "fail" — the
2482
+ * install is present either way, and a run that saw requests has already
2483
+ * proven delivery works regardless of where the key was read from.
2484
+ */
2485
+ function buildScriptInstalledCheck(phases, shape, shapeReason, install) {
2486
+ const id = "script-installed";
2487
+ const title = "A Dreamdata bundle is installed, with a write key";
2488
+ const everyPhase = phases.map((p) => p.phase);
2489
+ if (shape === "nothing-installed" || shape === "helper-only") {
2490
+ const helperOnly = phases.length > 0 && phases.every((p) => p.script.helperScriptOnly);
2491
+ return {
2492
+ id,
2493
+ title,
2494
+ status: "fail",
2495
+ detail: helperOnly
2496
+ ? `The only Dreamdata script found in any phase was a helper (identify-form), which is not a tracking bundle, so nothing on this site can send an event. ${shapeReason}`
2497
+ : `No Dreamdata tracking bundle was found in any phase — not in the DOM, not in a tag-manager container, and no request left the page. ${shapeReason}`,
2498
+ evidence: `deployment.shape: ${shape}, install.method: ${install.method}, install.writeKey: ${install.writeKey ?? "null"}`,
2499
+ phases: everyPhase,
2500
+ };
2501
+ }
2502
+ if (shape === "undetermined" || shape === "third-party-segment-undetermined" || shape === "page-unreadable") {
2503
+ return {
2504
+ id,
2505
+ title,
2506
+ status: "inconclusive",
2507
+ detail: `Whether a Dreamdata bundle is installed could not be settled by this run. ${shapeReason}`,
2508
+ evidence: `deployment.shape: ${shape}, install.method: ${install.method}`,
2509
+ phases: everyPhase,
2510
+ };
2511
+ }
2512
+ const blocked = install.blockedTagVendors.length > 0
2513
+ ? ` A tag neutralised by ${install.blockedTagVendors.join(" and ")} is installed and gated, not absent.`
2514
+ : "";
2515
+ if (install.writeKey === null) {
2516
+ return {
2517
+ id,
2518
+ title,
2519
+ status: "warn",
2520
+ detail: `${install.label} is installed, but no write key was found — neither in a snippet nor on any request this run saw — so which Dreamdata account it reports to could not be confirmed.${blocked}`,
2521
+ evidence: `deployment.shape: ${shape}, install.method: ${install.method}, install.writeKey: null`,
2522
+ phases: everyPhase,
2523
+ };
2524
+ }
2525
+ return {
2526
+ id,
2527
+ title,
2528
+ status: "pass",
2529
+ detail: `${install.label} is installed and configured with a write key.${blocked}`,
2530
+ evidence: `deployment.shape: ${shape}, install.method: ${install.method}, install.writeKey: ${install.writeKey}`,
2531
+ phases: everyPhase,
2532
+ };
2533
+ }
2534
+ /**
2535
+ * Every check on a run where the browser was never served the page.
2536
+ *
2537
+ * This must stay ORDERED BEFORE the diagnosis (see analyzeDeployment): the
2538
+ * diagnosis reads statuses to pick a root cause, and voiding first is what
2539
+ * leaves it nothing to claim, so the only cause that can fire is
2540
+ * `page-unreadable`. Diagnose the pre-void array and the report confidently
2541
+ * tells a customer whose GTM container ships both bundles to install the
2542
+ * script.
2543
+ */
2544
+ function voidChecks(checks) {
2545
+ return checks.map((check) => ({
2546
+ ...check,
2547
+ status: "inconclusive",
2548
+ // Points at pageReadability rather than restating it: the reason is a
2549
+ // paragraph, and repeating it under all thirteen checks buries the report
2550
+ // it is supposed to qualify.
2551
+ detail: "Not evaluated: the browser was never served this site's page, so there was no behaviour to observe. See deployment.pageReadability for what was found instead and what to do about it.",
2552
+ evidence: `deployment.pageReadability.unreadable: true (was: ${check.status} — ${check.evidence})`,
2553
+ }));
2554
+ }
2555
+ export function analyzeDeployment(evidence, requested) {
2556
+ const phases = evidence.phases;
2557
+ const byPhase = new Map(phases.map((p) => [p.phase, p]));
2558
+ // Union across phases: a bundle absent from the DOM pre-consent and present
2559
+ // post-consent is one deployment, not two.
2560
+ const normalBundleSignals = [...new Map(phases.flatMap((p) => p.script.normalBundleSignals).map((s) => [s.detail, s])).values()];
2561
+ const normalBundleConfigured = normalBundleSignals.length > 0;
2562
+ const cookielessBundleConfigured = phases.some((p) => p.script.cookielessSnippet ||
2563
+ p.network.cookielessRequestCount > 0 ||
2564
+ p.globals.cookielessGlobalExists ||
2565
+ p.script.tagManager.cookielessBundleTagFound);
2566
+ // Asked before the shape, because a shape derived from a page the browser
2567
+ // never saw is the exact failure this guards against.
2568
+ const pageReadability = assessPageReadability(phases, evidence.httpProbe);
2569
+ const routing = classifyRouting(phases, evidence.httpProbe);
2570
+ let { shape, shapeReason } = classifyShape(phases, normalBundleConfigured, cookielessBundleConfigured);
2571
+ // A blocked run can still have real install evidence — the plain-HTTP probe
2572
+ // recovers the served markup and the tag-manager container it names, and
2573
+ // neither depends on the browser having rendered anything. So the shape is
2574
+ // only overridden when there is nothing left to conclude it from: with the
2575
+ // install known, "consent-gated-dual" stands as a statement about what is
2576
+ // installed, and pageReadability is what says its BEHAVIOUR is unverified.
2577
+ if (pageReadability.unreadable && !normalBundleConfigured && !cookielessBundleConfigured) {
2578
+ shape = "page-unreadable";
2579
+ shapeReason = pageReadability.reason;
2580
+ }
2581
+ else if (pageReadability.unreadable) {
2582
+ shapeReason = `${shapeReason} This is what is INSTALLED, recovered from the markup the site serves a plain HTTP GET and the tag-manager container that markup names; how it BEHAVES was not observed, because the browser was never served this site's page — see deployment.pageReadability.`;
2583
+ }
2584
+ // A run that would otherwise read "nothing tracking-related is here" can
2585
+ // still have found a REAL Segment install — not a Dreamdata bundle, but
2586
+ // evidence the site sends events somewhere. If Dreamdata receives a copy at
2587
+ // all it is via a server-side Segment destination this harness cannot see
2588
+ // (see thirdPartySegmentSignals), so this is reported as its own
2589
+ // undetermined shape rather than as "nothing-installed" — which would
2590
+ // wrongly read as "go install the script" on a site that already has one,
2591
+ // just not Dreamdata's. Skipped on a blocked run: pageReadability already
2592
+ // has the more fundamental explanation for why nothing showed up.
2593
+ const thirdPartySegmentEvidence = thirdPartySegmentSignals(phases);
2594
+ if (thirdPartySegmentEvidence.length > 0 && !pageReadability.unreadable && (shape === "nothing-installed" || shape === "helper-only")) {
2595
+ shapeReason =
2596
+ `Would otherwise read as "${shape}", but a real Segment install was found instead of a Dreamdata bundle: ` +
2597
+ `${thirdPartySegmentEvidence.join("; ")}. If Dreamdata receives these events at all, it is via a ` +
2598
+ "server-side Segment destination/connection, which happens entirely outside the browser and cannot be " +
2599
+ "confirmed or denied by this run. Check Segment's own source/destination configuration, or Dreamdata's " +
2600
+ "ingestion records, directly.";
2601
+ shape = "third-party-segment-undetermined";
2602
+ }
2603
+ // Both remaining shapes this run could still land on hinge on "the normal
2604
+ // bundle was never seen" — which is not trustworthy evidence when the
2605
+ // banner itself was never successfully answered. Downgrade to
2606
+ // "undetermined" rather than asserting either one with confidence.
2607
+ const consentGateUndetermined = isConsentGateUndetermined(phases);
2608
+ if (consentGateUndetermined && (shape === "nothing-installed" || shape === "cookieless-by-design")) {
2609
+ shapeReason =
2610
+ `Would otherwise read as "${shape}", but a consent banner was detected and this run could not ` +
2611
+ `successfully drive it (grant/reject) in any phase — so the absence of a normal bundle cannot be ` +
2612
+ `trusted. This may be a genuine ${shape === "cookieless-by-design" ? "cookieless-by-design install" : "install with nothing tracking"}, ` +
2613
+ "or the normal bundle may simply be gated behind a banner this harness could not click. Verify the banner manually.";
2614
+ shape = "undetermined";
2615
+ }
2616
+ const segmentCompat = phases.some((p) => p.globals.segmentCompatGlobalExists || p.cookies.usesAjsCompatPrefix);
2617
+ // Read before the checks, because global-handoff's whole question turns on
2618
+ // whether a loader was ever involved.
2619
+ const install = summarizeInstall(phases);
2620
+ const checks = [
2621
+ ...buildChecks(byPhase, { normalBundleConfigured, cookielessBundleConfigured, normalBundleSignals }),
2622
+ ...buildSecondPageChecks(phases),
2623
+ buildPageViewCountedOnceCheck(phases),
2624
+ buildGlobalHandoffCheck(phases, install, segmentCompat),
2625
+ buildRequestPayloadCheck(phases),
2626
+ buildRequestDeliveryCheck(phases),
2627
+ buildConsoleCheck(phases),
2628
+ buildCollectionCompleteCheck(phases),
2629
+ buildScriptInstalledCheck(phases, shape, shapeReason, install),
2630
+ buildFirstPartyDeliveryCheck(phases, install),
2631
+ ];
2632
+ // Built in two steps so the diagnosis sees the RESOLVED checks. Diagnosing
2633
+ // the pre-void array on a blocked run would produce a confident root cause
2634
+ // ("no tracking script is installed") about a bot wall's challenge
2635
+ // document, which is the one thing this layer must never do.
2636
+ const analysis = {
2637
+ shape,
2638
+ shapeReason,
2639
+ pageReadability,
2640
+ consentGateUndetermined,
2641
+ segmentCompat,
2642
+ normalBundleConfigured,
2643
+ normalBundleSignals,
2644
+ cookielessBundleConfigured,
2645
+ thirdPartySegmentSignals: thirdPartySegmentEvidence,
2646
+ cmpVendor: phases.find((p) => p.consent.cmpVendor !== null)?.consent.cmpVendor ?? null,
2647
+ install,
2648
+ cmp: summarizeCmp(phases),
2649
+ consentModeV2: judgeConsentModeV2(phases),
2650
+ routing,
2651
+ phasesObserved: phases.map((p) => p.phase),
2652
+ phasesMissing: requested.filter((name) => !byPhase.has(name)),
2653
+ matrix: phases.map(summarizePhase),
2654
+ checks: pageReadability.unreadable ? voidChecks(checks) : checks,
2655
+ };
2656
+ return { ...analysis, diagnosis: diagnose(analysis, { duplicates: findJudgedDuplicates(phases) }) };
2657
+ }
2658
+ //# sourceMappingURL=deployment.js.map