@analitikisler/ai-tag-debugger 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mcp.js CHANGED
@@ -20,6 +20,10 @@ import { collect, pageView, SYSTEM_PROMPT } from "./agent.js";
20
20
  import { checkRun, groupFindings, summarize } from "./checks.js";
21
21
  import { LANGUAGES, messages } from "./i18n.js";
22
22
  import { loadConfig, readJson, runAudit, writeReports } from "./audit.js";
23
+ import { validatePlan } from "./schema.js";
24
+ import { readBaseline } from "./baseline.js";
25
+ import { MAX_CONCURRENCY } from "./pool.js";
26
+ import { scriptCspSource } from "./report.js";
23
27
  import { DEFAULT_DATA_DIR, resolvePlan } from "./workspace.js";
24
28
  import { checkTopic, discoverPlan, TOPICS } from "./discover.js";
25
29
  import { createProvider } from "./providers.js";
@@ -120,6 +124,8 @@ async function loadAgainst(ref, site, opts) {
120
124
  const found = await resolvePlan(ref, { dataDir: opts.dataDir ?? DEFAULT_DATA_DIR, site });
121
125
  const plan = await readJson(found.file);
122
126
  if (!Array.isArray(plan?.events)) throw new Error(`${found.file} is not a plan: it needs an "events" list.`);
127
+ // The same schema check the CLI gives a plan named with --against; the config's own plan was checked by loadConfig.
128
+ validatePlan(plan, { file: found.file });
123
129
  return { found, plan };
124
130
  }
125
131
 
@@ -387,14 +393,24 @@ export function buildServer(opts) {
387
393
  scripted: z.boolean().optional().describe("Replay fixed steps instead of browsing with an AI model. Default true."),
388
394
  language: z.enum(Object.keys(LANGUAGES)).optional(),
389
395
  branding: z.boolean().optional().describe("Set false to leave the Analitik İşler logo and credit line out of the report."),
396
+ concurrency: z.number().int().min(1).max(MAX_CONCURRENCY).optional().describe("How many journeys to run at once, each in its own browser. Default 1."),
390
397
  providers: z
391
398
  .array(z.enum(Object.keys(TRACKING_PROVIDERS)))
392
399
  .optional()
393
400
  .describe(`Tracking providers to check. Default ${DEFAULT_PROVIDERS.join(", ")} plus any other known platform the site sends to. When given, requests to the others are still answered locally, but they are only counted, not shown or checked.`),
401
+ baseline: z.string().optional().describe('Path to a baseline file of accepted findings, written by the CLI with --update-baseline. Default: the config\'s "baseline". Accepted findings are listed but not counted.'),
394
402
  },
395
403
  },
396
- async ({ config: configFile, against, scripted, language, branding, providers }) => {
404
+ async ({ config: configFile, against, scripted, language, branding, concurrency, providers, baseline: baselineFile }) => {
397
405
  const loaded = await loadConfig(configFile);
406
+ let baseline = null;
407
+ if (baselineFile ?? loaded.baselineFile) {
408
+ try {
409
+ baseline = await readBaseline(baselineFile ?? loaded.baselineFile);
410
+ } catch (err) {
411
+ return fail(err.message);
412
+ }
413
+ }
398
414
  let againstPlan = null;
399
415
  if (against) {
400
416
  try {
@@ -412,7 +428,7 @@ export function buildServer(opts) {
412
428
  const replay = scripted !== false;
413
429
  let result;
414
430
  try {
415
- result = await runAudit({ ...loaded, scripted: replay, ai: !replay, lang: language, branding, providers, against: againstPlan, outDir: path.join(opts.reportsDir, new Date().toISOString().replace(/[:.]/g, "-")) });
431
+ result = await runAudit({ ...loaded, scripted: replay, ai: !replay, lang: language, branding, concurrency, providers, against: againstPlan, baseline, dataDir: opts.dataDir, outDir: path.join(opts.reportsDir, new Date().toISOString().replace(/[:.]/g, "-")) });
416
432
  } catch (err) {
417
433
  // The CLI wording (--scripted --no-ai) means nothing to a chat app, so say it in this tool's terms.
418
434
  if (err.code !== "NO_API_KEY") throw err;
@@ -425,8 +441,9 @@ export function buildServer(opts) {
425
441
  return text(
426
442
  [
427
443
  m.ui.counts(result.counts),
444
+ ...(result.baseline ? [`${result.baseline.accepted} accepted from the baseline${result.baseline.partial ? `, ${result.baseline.partial} accepted in some places only (still counted)` : ""}, ${result.baseline.stale.length} stale baseline entries`] : []),
428
445
  ...result.runs.map((r) => `${r.journeyId} (${r.viewport}): ${r.completed ? "completed" : "not completed"}, ${r.note}`),
429
- ...grouped.map((f) => `- ${m.severity[f.severity]}: ${f.detail}`),
446
+ ...grouped.map((f) => `- ${m.severity[f.severity]}${f.accepted ? ` (${m.ui.accepted})` : ""}: ${f.detail}`),
430
447
  `Report: ${result.reportPath}`,
431
448
  ].join("\n"),
432
449
  );
@@ -475,7 +492,8 @@ export async function startMcp({ http = false, port = 8787, host = "127.0.0.1",
475
492
  return m ? { ok: timingSafeEqual(digest(m[1]), secretDigest), rest: m[2] ?? "" } : { ok: false, rest: "" };
476
493
  };
477
494
  const securityHeaders = {
478
- "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; img-src data:",
495
+ // The report's only script is the fixed inline one (tabs, deep links, theme), allowed by its hash; nothing else may load or run.
496
+ "Content-Security-Policy": `default-src 'none'; style-src 'unsafe-inline'; img-src data:; script-src ${scriptCspSource()}`,
479
497
  "X-Content-Type-Options": "nosniff",
480
498
  "Referrer-Policy": "no-referrer",
481
499
  };
package/src/metrics.js ADDED
@@ -0,0 +1,482 @@
1
+ // Numbers for the report, computed from the captured data alone: a verdict and a data quality score,
2
+ // plan coverage, the expected-versus-observed funnel of each journey, platform parity, consent
3
+ // compliance, conversion integrity, timing, and where the problems sit. Every function is pure over
4
+ // { plan, runs, findings, consentScenarios } (no I/O, no AI, no HTML) and returns plain data; the
5
+ // report decides how to show it. The rules are documented in docs/design/metrics.md.
6
+
7
+ import { canonicalEvent, groupFindings, hasParam, hitMatchesEvent, isConsentCommand, PLAN_BUCKETS, planCheck, summarize } from "./checks.js";
8
+ import { AD_PLATFORMS, CONSENT_STATES, consentTable } from "./consent.js";
9
+ import { presetFor } from "./journeys.js";
10
+ import { OWNERS, ownerOf } from "./verify.js";
11
+
12
+ /**
13
+ * What one problem costs on the data quality score, out of 100. The score counts distinct problems
14
+ * (the same finding on desktop and mobile is one), so a site does not score lower only because it
15
+ * was walked in more viewports. A problem on a conversion event is multiplied, because that is the
16
+ * data the money decisions rest on. Change the numbers here; nothing else encodes them.
17
+ */
18
+ export const SCORE_WEIGHTS = {
19
+ severity: { broken: 15, risk: 10, warning: 5, info: 0, ok: 0 },
20
+ // An unfinished journey hides every event after the point it stopped, so it costs as much as a broken event.
21
+ check: { journey_incomplete: 15 },
22
+ conversion: 2,
23
+ };
24
+
25
+ /** Events whose data decides revenue or lead counts, in the canonical form canonicalEvent gives. */
26
+ export const CONVERSION_EVENTS = new Set(["purchase", "generatelead", "lead", "signup", "completeregistration", "subscribe"]);
27
+
28
+ const PROBLEM_SEVERITIES = ["broken", "risk", "warning"];
29
+ const SEVERITY_ORDER = { broken: 0, risk: 1, warning: 2, info: 3, ok: 4 };
30
+ const GOOGLE_PLATFORMS = new Set(["ga4", "google_ads"]);
31
+ // Consent Mode v2 added two signals to ad_storage and analytics_storage. Google requires them for EEA/UK traffic.
32
+ const V2_SIGNALS = ["ad_user_data", "ad_personalization"];
33
+ // Consent findings that say the visitor's "no" was not honoured, against the ones that say the setup is risky.
34
+ const CONSENT_FAIL_CHECKS = new Set(["after_reject", "cookies_after_reject", "reject_ignored", "reject_button_failed"]);
35
+ const CONSENT_ATTENTION_CHECKS = new Set(["before_consent", "cookies_before_consent", "consent_default_missing", "consent_default_late", "consent_v2_missing", "consent_v2_update"]);
36
+ const CONSENT_CHECK = /^(consent_|cookies_|reject_|before_consent|after_reject)/;
37
+
38
+ // Ecommerce parameters sites send under another name (UA-style products/revenue, order ids), as checks.js reads them.
39
+ const PARAM_ALIASES = {
40
+ items: ["products", "item_list", "contents"],
41
+ value: ["revenue", "total"],
42
+ transaction_id: ["order_id", "orderid", "oid"],
43
+ currency: ["currency_code", "cu"],
44
+ };
45
+ const EMPTY_LIST = /^\s*(\[\s*\])?\s*$/;
46
+
47
+ const isConversion = (name) => CONVERSION_EVENTS.has(canonicalEvent(name));
48
+ const isProblem = (f) => PROBLEM_SEVERITIES.includes(f.severity);
49
+ const expectedIn = (ev, journeyId) => !ev.journeys || ev.journeys.includes(journeyId);
50
+ const checked = (ev, run) => !run.providers || run.providers.includes(ev.platform);
51
+ const lowerPlatform = (ev) => ({ ...ev, platform: String(ev.platform ?? "").toLowerCase() });
52
+ const round = (n, digits = 0) => (n == null ? null : Math.round(n * 10 ** digits) / 10 ** digits);
53
+ const percent = (part, whole) => (whole ? Math.round((part / whole) * 100) : null);
54
+ const emptyCounts = () => ({ broken: 0, risk: 0, warning: 0, info: 0, ok: 0 });
55
+ /** Whether a happened before b: an earlier step, or the same step and an earlier time when both have one. */
56
+ const sentBefore = (a, b) => a.step < b.step || (a.step === b.step && a.t != null && b.t != null && a.t < b.t);
57
+ const byTime = (a, b) => (sentBefore(a, b) ? -1 : sentBefore(b, a) ? 1 : 0);
58
+ const uniq = (list) => [...new Set(list)];
59
+
60
+ /** A hit's parameter under its own name or the first common alias it carries. */
61
+ function paramOf(hit, name) {
62
+ for (const n of [name, ...(PARAM_ALIASES[name] ?? [])]) {
63
+ if (hit.params?.[n] !== undefined && hit.params[n] !== "") return hit.params[n];
64
+ }
65
+ return undefined;
66
+ }
67
+
68
+ /** The run's step with this index, or with this label when the finding only carries the label. */
69
+ const stepOf = (run, ref) => (run?.steps ?? []).find((s) => (typeof ref === "number" ? s.index === ref : s.label === ref)) ?? null;
70
+
71
+ /** Points one grouped finding takes off the score: its severity weight (or the check's own), doubled on a conversion event. */
72
+ function deductionFor(f) {
73
+ const base = SCORE_WEIGHTS.check[f.check] ?? SCORE_WEIGHTS.severity[f.severity] ?? 0;
74
+ const conversion = Boolean(f.event && isConversion(f.event));
75
+ return { points: base * (conversion ? SCORE_WEIGHTS.conversion : 1), conversion };
76
+ }
77
+
78
+ /**
79
+ * Pass, fail or attention, from the findings and whether every journey finished. Fail: any broken
80
+ * finding or any journey that did not complete. Attention: only risks or warnings, or nothing to
81
+ * judge (no runs, or runs that captured no hit at all). Pass: neither. Counts are over distinct
82
+ * problems (groupFindings), as the report's overview shows them. The headline is a key for the
83
+ * renderer's texts: fail_broken, fail_incomplete, attention_risk, attention_warning,
84
+ * attention_nothing_tested, attention_no_hits, pass.
85
+ */
86
+ export function verdict({ findings = [], runs = [] } = {}) {
87
+ const counts = summarize(groupFindings(findings));
88
+ const incompleteRuns = runs.length
89
+ ? runs.filter((r) => !r.completed).map((r) => `${r.journeyId}|${r.viewport}`)
90
+ : findings.filter((f) => f.check === "journey_incomplete").map((f) => `${f.journey}|${f.viewport}`);
91
+ const incomplete = uniq(incompleteRuns).length;
92
+ const journeys = { total: runs.length, completed: runs.filter((r) => r.completed).length, incomplete };
93
+ const hits = runs.reduce((n, r) => n + (r.hits?.length ?? 0), 0);
94
+ let status = "pass";
95
+ let headline = "pass";
96
+ if (!runs.length && !findings.length) [status, headline] = ["attention", "attention_nothing_tested"];
97
+ else if (counts.broken) [status, headline] = ["fail", "fail_broken"];
98
+ else if (incomplete) [status, headline] = ["fail", "fail_incomplete"];
99
+ else if (counts.risk) [status, headline] = ["attention", "attention_risk"];
100
+ else if (counts.warning) [status, headline] = ["attention", "attention_warning"];
101
+ else if (runs.length && !hits) [status, headline] = ["attention", "attention_no_hits"];
102
+ return { status, headline, counts, journeys, problems: counts.broken + counts.risk + counts.warning };
103
+ }
104
+
105
+ /**
106
+ * Data quality score, 0 to 100: start at 100 and take off SCORE_WEIGHTS points for every distinct
107
+ * problem, doubled when the problem is on a conversion event, never below 0. The deductions list
108
+ * says exactly what moved the score and who owns the fix. A run with no findings and no runs is
109
+ * not a 100; it is null, because nothing was measured. Problems a baseline accepted cost nothing,
110
+ * as the verdict skips them too.
111
+ */
112
+ export function qualityScore({ findings = [], runs = [] } = {}) {
113
+ if (!runs.length && !findings.length) return { score: null, deductions: [], lost: 0 };
114
+ const deductions = groupFindings(findings)
115
+ .filter((f) => isProblem(f) && !f.accepted)
116
+ .map((f) => {
117
+ const { points, conversion } = deductionFor(f);
118
+ return { check: f.check, severity: f.severity, platform: f.platform ?? null, event: f.event ?? null, owner: ownerOf(f), conversion, points, where: f.where.length };
119
+ })
120
+ .filter((d) => d.points > 0)
121
+ .sort((a, b) => b.points - a.points || SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
122
+ const lost = deductions.reduce((n, d) => n + d.points, 0);
123
+ return { score: Math.max(0, 100 - lost), deductions, lost };
124
+ }
125
+
126
+ /** Bucket counts and shares for one list of planCheck entries. Shares are of the checkable planned events; unplanned events are counted apart. */
127
+ function coverageOf(entries) {
128
+ const counts = Object.fromEntries(PLAN_BUCKETS.map((b) => [b, entries.filter((e) => e.bucket === b).length]));
129
+ const planned = entries.length - counts.unplanned;
130
+ const checkable = planned - counts.not_checked;
131
+ const shares = Object.fromEntries(["implemented", "missing", "missing_params", "wrong_values"].map((b) => [b, percent(counts[b], checkable)]));
132
+ return { planned, checkable, counts, percent: shares };
133
+ }
134
+
135
+ /**
136
+ * How much of the plan the site implements, from planCheck: per bucket (implemented, missing,
137
+ * missing_params, wrong_values, unplanned, not_checked) as counts and as a share of the planned
138
+ * events this run could check, overall and per platform. Null without a plan, since without one
139
+ * every event would be "unplanned" and nothing would be "implemented".
140
+ */
141
+ export function planCoverage({ plan = null, runs = [], findings = [] } = {}) {
142
+ if (!plan?.events?.length) return null;
143
+ const check = planCheck(plan, runs, findings);
144
+ const platforms = uniq(check.events.map((e) => e.platform)).sort();
145
+ return {
146
+ ...coverageOf(check.events),
147
+ platforms: Object.fromEntries(platforms.map((p) => [p, coverageOf(check.events.filter((e) => e.platform === p))])),
148
+ events: check.events,
149
+ };
150
+ }
151
+
152
+ /** The events one run was expected to send, in plan order, or the ready-made journey's events when the plan names none for it. */
153
+ function expectedEvents(plan, run) {
154
+ const fromPlan = (plan?.events ?? []).map(lowerPlatform).filter((e) => expectedIn(e, run.journeyId) && checked(e, run));
155
+ if (fromPlan.length) return { source: "plan", events: fromPlan };
156
+ const preset = presetFor(run.journeyId);
157
+ const fromPreset = (preset?.events ?? []).filter((e) => checked(e, run));
158
+ return fromPreset.length ? { source: "preset", events: fromPreset } : { source: "none", events: [] };
159
+ }
160
+
161
+ /**
162
+ * Expected versus observed, per journey and viewport: each expected event in the order the plan (or
163
+ * the ready-made journey) lists it, whether it fired, the step and page where it first fired, how
164
+ * many times, and whether every send carried the required parameters. `firstMissing` is where the
165
+ * funnel breaks: the first expected event that never fired.
166
+ */
167
+ export function journeyFunnel({ plan = null, runs = [] } = {}) {
168
+ return runs.map((run) => {
169
+ const { source, events } = expectedEvents(plan, run);
170
+ const rows = events.map((ev) => {
171
+ const hits = (run.hits ?? []).filter((h) => hitMatchesEvent(ev, h)).sort(byTime);
172
+ const first = hits[0] ?? null;
173
+ const step = first ? stepOf(run, first.step) : null;
174
+ const required = ev.required_params ?? [];
175
+ const missingParams = uniq(hits.flatMap((h) => required.filter((p) => !hasParam(h, p))));
176
+ const status = !first ? "missing" : missingParams.length ? "missing_params" : "ok";
177
+ return {
178
+ platform: ev.platform, event: ev.name, conversion: isConversion(ev.name), observed: Boolean(first), count: hits.length,
179
+ step: first?.step ?? null, stepLabel: step?.label ?? null, page: step?.pagePath ?? first?.pageUrl ?? null,
180
+ required, missingParams, paramsOk: Boolean(first) && !missingParams.length, status,
181
+ };
182
+ });
183
+ const observed = rows.filter((r) => r.observed).length;
184
+ return {
185
+ journey: run.journeyId, viewport: run.viewport, completed: Boolean(run.completed), source,
186
+ expected: rows.length, observed, ok: rows.filter((r) => r.status === "ok").length, percent: percent(observed, rows.length),
187
+ firstMissing: rows.find((r) => !r.observed)?.event ?? null, events: rows,
188
+ };
189
+ });
190
+ }
191
+
192
+ /**
193
+ * The same event seen on more than one platform (purchase on GA4 and Purchase on Meta), per journey
194
+ * and viewport: the count, value and currency each platform reported and which of those differ. Plus
195
+ * the events only one platform sent, which is where a missing Meta or TikTok tag shows. Events are
196
+ * matched by canonicalEvent, so AddToCart pairs with add_to_cart and ViewContent with view_item.
197
+ */
198
+ export function platformParity({ runs = [] } = {}) {
199
+ return runs.map((run) => {
200
+ const groups = new Map();
201
+ for (const h of run.hits ?? []) {
202
+ const k = canonicalEvent(h.name);
203
+ if (!groups.has(k)) groups.set(k, new Map());
204
+ const byPlatform = groups.get(k);
205
+ byPlatform.set(h.platform, [...(byPlatform.get(h.platform) ?? []), h]);
206
+ }
207
+ const shared = [];
208
+ const onlyOn = [];
209
+ for (const [event, byPlatform] of groups) {
210
+ const platforms = Object.fromEntries([...byPlatform].map(([platform, hits]) => {
211
+ const valued = hits.find((h) => paramOf(h, "value") !== undefined) ?? hits.find((h) => paramOf(h, "currency") !== undefined) ?? hits[0];
212
+ const value = paramOf(valued, "value");
213
+ // A value that is not a number (a template left unresolved, a product name) stays as the text the
214
+ // tag sent, so the report shows what the platform received instead of NaN. Only numbers are compared.
215
+ const n = Number(value);
216
+ return [platform, { names: uniq(hits.map((h) => h.name)), count: hits.length, value: value === undefined ? null : Number.isFinite(n) ? n : String(value), currency: paramOf(valued, "currency") ?? null }];
217
+ }));
218
+ if (byPlatform.size < 2) {
219
+ const [[platform, p]] = Object.entries(platforms);
220
+ onlyOn.push({ event, name: p.names[0], platform, count: p.count, conversion: CONVERSION_EVENTS.has(event) });
221
+ continue;
222
+ }
223
+ const list = Object.values(platforms);
224
+ const mismatch = [];
225
+ if (uniq(list.map((p) => p.count)).length > 1) mismatch.push("count");
226
+ // Numbers compare within rounding; once a side sent text, the platforms agree only on the same text.
227
+ const sent = list.map((p) => p.value).filter((v) => v !== null);
228
+ const numbers = sent.filter((v) => Number.isFinite(v));
229
+ const differs = numbers.length === sent.length ? Math.max(...numbers) - Math.min(...numbers) > 0.005 : uniq(sent.map(String)).length > 1;
230
+ if (sent.length > 1 && differs) mismatch.push("value");
231
+ const currencies = uniq(list.map((p) => p.currency).filter(Boolean).map((c) => c.toUpperCase()));
232
+ if (currencies.length > 1) mismatch.push("currency");
233
+ // A value one platform has and the other lacks is a mismatch too: the platforms will not agree on revenue.
234
+ if (list.some((p) => p.value !== null) && list.some((p) => p.value === null)) mismatch.push("value_missing");
235
+ shared.push({ event, conversion: CONVERSION_EVENTS.has(event), platforms, mismatch });
236
+ }
237
+ const order = (a, b) => Number(b.conversion) - Number(a.conversion) || a.event.localeCompare(b.event);
238
+ return { journey: run.journeyId, viewport: run.viewport, shared: shared.sort(order), onlyOn: onlyOn.sort(order), mismatches: shared.filter((s) => s.mismatch.length).length };
239
+ });
240
+ }
241
+
242
+ /** When the Consent Mode default was pushed in one run: the step, how many ms into it, and whether a Google hit beat it. */
243
+ function consentDefaultIn(run) {
244
+ const log = run.dataLayerLog ?? [];
245
+ const entry = log.find((e) => isConsentCommand(e, "default")) ?? null;
246
+ const firstGoogleHit = (run.hits ?? []).filter((h) => GOOGLE_PLATFORMS.has(h.platform)).sort(byTime)[0] ?? null;
247
+ const step = entry ? stepOf(run, entry.step) : null;
248
+ const declared = uniq(log.filter((e) => isConsentCommand(e, "default")).flatMap((e) => Object.keys(e.value[2] ?? {})));
249
+ const missing = V2_SIGNALS.filter((k) => !declared.includes(k));
250
+ return {
251
+ journey: run.journeyId, viewport: run.viewport, present: Boolean(entry),
252
+ step: entry?.step ?? null, stepLabel: step?.label ?? null, ms: entry && step && entry.t != null && step.t != null ? entry.t - step.t : null,
253
+ beforeFirstGoogleHit: entry && firstGoogleHit ? !sentBefore(firstGoogleHit, entry) : null,
254
+ v2Signals: { present: Boolean(entry) && !missing.length, missing: entry ? missing : V2_SIGNALS },
255
+ };
256
+ }
257
+
258
+ /**
259
+ * Whether the site respects the visitor's choice, per platform and overall. From the findings: tags
260
+ * sent before consent and after "reject"; from the consent scenarios: tags sent after each choice on
261
+ * the start page, with the ones a choice should have silenced; from the runs: when the Consent Mode
262
+ * default was pushed and whether it carried the v2 signals. Status per platform and overall: fail
263
+ * when a "no" was not honoured, attention when tags fired before any choice or the Consent Mode setup
264
+ * is incomplete, pass otherwise; unknown when nothing was captured.
265
+ */
266
+ export function consentCompliance({ plan = null, runs = [], findings = [], consentScenarios = null } = {}) {
267
+ const table = consentScenarios ? consentTable(consentScenarios) : null;
268
+ const consentFindings = findings.filter((f) => CONSENT_CHECK.test(f.check));
269
+ const platforms = uniq([...runs.flatMap((r) => (r.hits ?? []).map((h) => h.platform)), ...(table?.rows.map((r) => r.platform) ?? [])]).sort();
270
+ const distinct = (check, platform) => uniq(consentFindings.filter((f) => f.check === check && f.platform === platform).map((f) => f.event ?? "")).length;
271
+ const scenarioCount = (platform, state, problemsOnly) => {
272
+ const ran = state === "before" ? Boolean(consentScenarios?.before) : Boolean(consentScenarios?.states?.[state]?.after);
273
+ if (!table || !ran) return null;
274
+ return table.rows.filter((r) => r.platform === platform).reduce((n, r) => {
275
+ const cell = r.cells.find((c) => c.state === state);
276
+ return n + (problemsOnly ? Number(cell.problem) : cell.count ?? 0);
277
+ }, 0);
278
+ };
279
+ const rows = platforms.map((platform) => {
280
+ const beforeConsent = distinct("before_consent", platform);
281
+ const afterReject = distinct("after_reject", platform) + (scenarioCount(platform, "reject", true) ?? 0);
282
+ const afterAnalyticsOnly = AD_PLATFORMS.has(platform) ? scenarioCount(platform, "analytics", true) : null;
283
+ const status = afterReject ? "fail" : beforeConsent || afterAnalyticsOnly ? "attention" : "pass";
284
+ const scenarios = table ? Object.fromEntries(["before", ...CONSENT_STATES].map((s) => [s, scenarioCount(platform, s, false)])) : null;
285
+ return { platform, ad: AD_PLATFORMS.has(platform), beforeConsent, afterReject, afterAnalyticsOnly, scenarios, status };
286
+ });
287
+ const defaults = runs.map(consentDefaultIn);
288
+ const consentMode = {
289
+ expected: Boolean(plan?.consent_mode),
290
+ defaultPresent: defaults.length ? defaults.every((d) => d.present) : null,
291
+ defaultOnTime: defaults.some((d) => d.beforeFirstGoogleHit !== null) ? defaults.every((d) => d.beforeFirstGoogleHit !== false) : null,
292
+ v2Signals: { present: defaults.length ? defaults.every((d) => d.v2Signals.present) : null, missing: uniq(defaults.flatMap((d) => d.v2Signals.missing)) },
293
+ runs: defaults,
294
+ };
295
+ const scenarios = consentScenarios ? {
296
+ cmp: consentScenarios.cmp ?? null,
297
+ states: Object.fromEntries(CONSENT_STATES.map((s) => [s, Boolean(consentScenarios.states?.[s]?.after)])),
298
+ problems: (table?.rows ?? []).flatMap((r) => r.cells.filter((c) => c.problem).map((c) => ({ platform: r.platform, event: r.event, state: c.state, count: c.count }))),
299
+ } : null;
300
+ const checks = uniq(consentFindings.map((f) => f.check));
301
+ let status = "pass";
302
+ if (rows.some((r) => r.status === "fail") || checks.some((c) => CONSENT_FAIL_CHECKS.has(c))) status = "fail";
303
+ else if (rows.some((r) => r.status === "attention") || checks.some((c) => CONSENT_ATTENTION_CHECKS.has(c))) status = "attention";
304
+ else if (!runs.length && !consentScenarios) status = "unknown";
305
+ return { status, consentMode, platforms: rows, scenarios, findings: checks };
306
+ }
307
+
308
+ /** Whether a hit's item list is non-empty: GA4 items (pr1..), Meta contents or content_ids, or the parsed items the capture kept. */
309
+ function itemsState(hit) {
310
+ if (Array.isArray(hit.items)) return hit.items.length ? "pass" : "fail";
311
+ const listed = ["items", "contents", "content_ids", "products", "item_list"].filter((n) => hit.params?.[n] !== undefined);
312
+ if (listed.length) return listed.every((n) => EMPTY_LIST.test(String(hit.params[n]))) ? "fail" : "pass";
313
+ // GA4 purchases without items lose every product report; the other platforms treat items as optional.
314
+ return hit.platform === "ga4" ? "fail" : null;
315
+ }
316
+
317
+ /** The purchase on another platform that reports the same order: by transaction id, else the only other purchase in the run. */
318
+ function counterpartOf(hit, purchases) {
319
+ const others = purchases.filter((h) => h.platform !== hit.platform);
320
+ const id = paramOf(hit, "transaction_id");
321
+ const sameId = id !== undefined && others.find((h) => paramOf(h, "transaction_id") === id);
322
+ if (sameId) return sameId;
323
+ const mine = purchases.filter((h) => h.platform === hit.platform);
324
+ return mine.length === 1 && others.length === 1 && (id === undefined || paramOf(others[0], "transaction_id") === undefined) ? others[0] : null;
325
+ }
326
+
327
+ /**
328
+ * One row per purchase observed, with pass, fail or null (not applicable) per property: value (a
329
+ * number above 0), currency (an ISO code), transaction id, items (non-empty), duplicate (the same
330
+ * order sent twice), parity (the same value and currency as the other platform's purchase) and
331
+ * eventId (present, required once a server container is in play). `pass` is the row's overall state.
332
+ */
333
+ export function conversionIntegrity({ runs = [] } = {}) {
334
+ const rows = [];
335
+ for (const run of runs) {
336
+ const purchases = (run.hits ?? []).filter((h) => canonicalEvent(h.name) === "purchase").sort(byTime);
337
+ const serverSeen = (run.hits ?? []).some((h) => h.delivery === "server");
338
+ for (const hit of purchases) {
339
+ const value = paramOf(hit, "value");
340
+ const currency = paramOf(hit, "currency");
341
+ const transactionId = paramOf(hit, "transaction_id");
342
+ const n = Number(value);
343
+ const sameOrder = purchases.filter((h) => h !== hit && h.platform === hit.platform && (transactionId !== undefined ? paramOf(h, "transaction_id") === transactionId : h.step === hit.step));
344
+ const duplicate = sameOrder.some((h) => !h.eventId || h.eventId !== hit.eventId);
345
+ const other = counterpartOf(hit, purchases);
346
+ // Parity compares value and currency with the other platform's purchase. A field only one side
347
+ // sent is a mismatch (the platforms will not agree on revenue); nothing to compare on either side is null.
348
+ let parity = null;
349
+ if (other) {
350
+ const [ov, oc] = [paramOf(other, "value"), paramOf(other, "currency")];
351
+ const comparable = [value, ov, currency, oc].some((v) => v !== undefined);
352
+ const valueDiffers = value !== undefined && ov !== undefined ? Math.abs(Number(value) - Number(ov)) > 0.005 : value !== ov;
353
+ const currencyDiffers = currency !== undefined && oc !== undefined ? String(currency).toUpperCase() !== String(oc).toUpperCase() : currency !== oc;
354
+ parity = !comparable ? null : valueDiffers || currencyDiffers ? "fail" : "pass";
355
+ }
356
+ const step = stepOf(run, hit.step);
357
+ const checks = {
358
+ value: value === undefined || String(value).trim() === "" || !Number.isFinite(n) || n <= 0 ? "fail" : "pass",
359
+ currency: currency === undefined || !/^[A-Z]{3}$/.test(String(currency)) ? "fail" : "pass",
360
+ // Meta and TikTok have no standard order id; one is only expected there when the site sends it.
361
+ transactionId: transactionId !== undefined ? "pass" : hit.platform === "ga4" ? "fail" : null,
362
+ items: itemsState(hit),
363
+ duplicate: duplicate ? "fail" : "pass",
364
+ parity,
365
+ eventId: hit.eventId ? "pass" : serverSeen ? "fail" : null,
366
+ };
367
+ rows.push({
368
+ journey: run.journeyId, viewport: run.viewport, platform: hit.platform, event: hit.name, delivery: hit.delivery ?? "browser",
369
+ step: hit.step, stepLabel: step?.label ?? null, page: step?.pagePath ?? hit.pageUrl ?? null,
370
+ transactionId: transactionId ?? null, value: value === undefined ? null : Number.isFinite(n) ? n : String(value), currency: currency ?? null,
371
+ items: Array.isArray(hit.items) ? hit.items.length : null, eventId: hit.eventId ?? null, counterpart: other ? other.platform : null,
372
+ checks, failed: Object.keys(checks).filter((k) => checks[k] === "fail"), pass: !Object.values(checks).includes("fail"),
373
+ });
374
+ }
375
+ }
376
+ return { rows, total: rows.length, passed: rows.filter((r) => r.pass).length, percent: percent(rows.filter((r) => r.pass).length, rows.length) };
377
+ }
378
+
379
+ /**
380
+ * How long things took, from the timestamps on steps and hits: per run the total duration, per step
381
+ * the time from the step's action to its first and last tracking hit, and the slowest hits of the
382
+ * run. A step's clock starts when its action begins, so a page load is part of the delay. Runs that
383
+ * recorded no timestamps get null durations.
384
+ */
385
+ export function timing({ runs = [], slowest = 5 } = {}) {
386
+ return runs.map((run) => {
387
+ const steps = (run.steps ?? []).map((s, i, all) => {
388
+ const next = all[i + 1];
389
+ const times = (run.hits ?? []).filter((h) => h.step === s.index && h.t != null).map((h) => h.t);
390
+ const timed = s.t != null && times.length;
391
+ return {
392
+ index: s.index, label: s.label, action: s.action ?? null, page: s.pagePath ?? null, hits: (run.hits ?? []).filter((h) => h.step === s.index).length,
393
+ durationMs: s.t != null && next?.t != null ? next.t - s.t : null,
394
+ firstHitMs: timed ? Math.min(...times) - s.t : null, lastHitMs: timed ? Math.max(...times) - s.t : null,
395
+ };
396
+ });
397
+ const delays = (run.hits ?? []).flatMap((h) => {
398
+ const s = stepOf(run, h.step);
399
+ return h.t != null && s?.t != null ? [{ platform: h.platform, event: h.name, step: h.step, stepLabel: s.label, ms: h.t - s.t }] : [];
400
+ }).sort((a, b) => b.ms - a.ms);
401
+ const stamps = [...(run.steps ?? []).map((s) => s.t), ...(run.hits ?? []).map((h) => h.t)].filter((t) => t != null);
402
+ const start = run.startedAt ?? (stamps.length ? Math.min(...stamps) : null);
403
+ const withHits = steps.filter((s) => s.firstHitMs !== null);
404
+ return {
405
+ journey: run.journeyId, viewport: run.viewport,
406
+ durationMs: start != null && stamps.length ? Math.max(...stamps) - start : null,
407
+ medianFirstHitMs: withHits.length ? round(median(withHits.map((s) => s.firstHitMs))) : null,
408
+ steps, slowest: delays.slice(0, slowest),
409
+ };
410
+ });
411
+ }
412
+
413
+ function median(list) {
414
+ const s = [...list].sort((a, b) => a - b);
415
+ const mid = Math.floor(s.length / 2);
416
+ return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
417
+ }
418
+
419
+ /** Problem findings grouped under one key each, with severity counts, worst first. */
420
+ function groupBy(findings, keyOf) {
421
+ const groups = new Map();
422
+ for (const f of findings) {
423
+ const k = keyOf(f) ?? null;
424
+ if (!groups.has(k)) groups.set(k, { key: k, counts: emptyCounts(), findings: [] });
425
+ const g = groups.get(k);
426
+ g.counts[f.severity] += 1;
427
+ g.findings.push(f);
428
+ }
429
+ const worst = (g) => PROBLEM_SEVERITIES.findIndex((s) => g.counts[s] > 0);
430
+ return [...groups.values()]
431
+ .map((g) => ({ ...g, total: g.findings.length }))
432
+ .sort((a, b) => worst(a) - worst(b) || b.total - a.total || String(a.key).localeCompare(String(b.key)));
433
+ }
434
+
435
+ /**
436
+ * Where the problems are: the same findings counted by the page they happened on (the step's page;
437
+ * null for journey-wide findings such as a missing event), by owner, by platform and by journey.
438
+ * Counts are per occurrence, so a problem seen on two pages or two viewports counts on each.
439
+ */
440
+ export function findingsBy({ findings = [], runs = [] } = {}) {
441
+ const problems = findings.filter(isProblem);
442
+ const runOf = (f) => runs.find((r) => r.journeyId === f.journey && r.viewport === f.viewport);
443
+ const pageOf = (f) => (f.step ? stepOf(runOf(f), f.step)?.pagePath ?? null : null);
444
+ return {
445
+ byPage: groupBy(problems, pageOf),
446
+ byOwner: groupBy(problems, ownerOf),
447
+ byPlatform: groupBy(problems, (f) => f.platform ?? null),
448
+ byJourney: groupBy(problems, (f) => f.journey),
449
+ };
450
+ }
451
+
452
+ /**
453
+ * The fix list per owner: distinct problems (groupFindings) sorted by the points they cost on the
454
+ * score, with the top three per owner and how many points fixing all of theirs would recover. Owners
455
+ * come in the order of points recovered, so the group that moves the score most is first. Accepted
456
+ * problems cost no points, so they are not listed.
457
+ */
458
+ export function prioritiesByOwner(findings = [], { top = 3 } = {}) {
459
+ const grouped = groupFindings(findings).filter((f) => isProblem(f) && !f.accepted).map((f) => ({ ...f, owner: ownerOf(f), ...deductionFor(f) }));
460
+ return OWNERS.map((owner) => {
461
+ const mine = grouped.filter((f) => f.owner === owner).sort((a, b) => b.points - a.points || SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
462
+ const counts = emptyCounts();
463
+ for (const f of mine) counts[f.severity] += 1;
464
+ return { owner, total: mine.length, counts, points: mine.reduce((n, f) => n + f.points, 0), top: mine.slice(0, top) };
465
+ }).filter((g) => g.total).sort((a, b) => b.points - a.points || a.owner.localeCompare(b.owner));
466
+ }
467
+
468
+ /** Every metric at once, from the capture as audit.js writes it. */
469
+ export function computeMetrics({ plan = null, runs = [], findings = [], consentScenarios = null } = {}) {
470
+ return {
471
+ verdict: verdict({ findings, runs }),
472
+ score: qualityScore({ findings, runs }),
473
+ coverage: planCoverage({ plan, runs, findings }),
474
+ funnel: journeyFunnel({ plan, runs }),
475
+ parity: platformParity({ runs }),
476
+ consent: consentCompliance({ plan, runs, findings, consentScenarios }),
477
+ conversions: conversionIntegrity({ runs }),
478
+ timing: timing({ runs }),
479
+ where: findingsBy({ findings, runs }),
480
+ priorities: prioritiesByOwner(findings),
481
+ };
482
+ }
package/src/notify.js CHANGED
@@ -7,6 +7,7 @@
7
7
  import { connect as tlsConnect } from "node:tls";
8
8
  import { connect as netConnect } from "node:net";
9
9
  import { changesLines } from "./changes.js";
10
+ import { historyAlertLines } from "./history.js";
10
11
  import { messages } from "./i18n.js";
11
12
 
12
13
  /** True when there is an earlier run and something was fixed, broke or took a new route since. */
@@ -14,23 +15,36 @@ export const hasChanges = (ch) => Boolean(ch?.previous && (ch.fixed.length || ch
14
15
 
15
16
  const list = (v) => (Array.isArray(v) ? v : v ? [v] : []);
16
17
 
17
- /** The alert's subject and plain-text body. changes carries the run id as `run` (see sinceLastRun). */
18
- export function alertMessage({ site, changes, lang = "en", reportPath = null }) {
19
- const subject = `${site}: ${messages(lang).ui.changesLine(changes)}`;
20
- const body = [`${site}`, ...changesLines(changes, lang), ...(reportPath ? ["", reportPath] : [])].join("\n");
18
+ /**
19
+ * The alert's subject and plain-text body. changes carries the run id as `run` (see sinceLastRun).
20
+ * verdict is { status, statusText, headline, score } from verdictHeadline (report.js) and trend the
21
+ * site's trend (history.js); both are optional, so an alert built from the diff alone still reads.
22
+ * The subject carries the verdict first ("site: Fail · 2 new problems · purchase tracking is broken on
23
+ * mobile"), which is what should wake someone up; the trend lines come after the diff.
24
+ */
25
+ export function alertMessage({ site, changes, lang = "en", reportPath = null, verdict = null, trend = null }) {
26
+ const m = messages(lang);
27
+ const subject = `${site}: ${[verdict?.statusText, m.ui.changesLine(changes), verdict?.headline].filter(Boolean).join(" · ")}`;
28
+ const body = [
29
+ `${site}`,
30
+ ...(verdict ? [m.ui.cli.verdict(verdict.statusText, verdict.headline, verdict.score ?? null)] : []),
31
+ ...changesLines(changes, lang),
32
+ ...historyAlertLines(trend, lang),
33
+ ...(reportPath ? ["", reportPath] : []),
34
+ ].join("\n");
21
35
  return { subject, body };
22
36
  }
23
37
 
24
38
  /**
25
39
  * Sends the diff to every channel in `notify`. Never throws: returns { sent: [...], errors: [...] }.
26
- * @param {{ notify: any, site: string, changes: any, lang?: string, reportPath?: string,
40
+ * @param {{ notify: any, site: string, changes: any, lang?: string, reportPath?: string, verdict?: any, trend?: any,
27
41
  * env?: Record<string, string | undefined>, fetchImpl?: typeof fetch, smtp?: (url: string, mail: any) => Promise<void> }} opts
28
42
  */
29
- export async function sendAlerts({ notify, site, changes, lang = "en", reportPath = null, env = process.env, fetchImpl = fetch, smtp = sendSmtp }) {
43
+ export async function sendAlerts({ notify, site, changes, lang = "en", reportPath = null, verdict = null, trend = null, env = process.env, fetchImpl = fetch, smtp = sendSmtp }) {
30
44
  const sent = [];
31
45
  const errors = [];
32
46
  if (!notify || !hasChanges(changes)) return { sent, errors };
33
- const { subject, body } = alertMessage({ site, changes, lang, reportPath });
47
+ const { subject, body } = alertMessage({ site, changes, lang, reportPath, verdict, trend });
34
48
  const slack = notify.slack === true ? env.SLACK_WEBHOOK_URL : notify.slack;
35
49
  if (slack) {
36
50
  try {