@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/cli.js CHANGED
@@ -5,7 +5,9 @@ import { parseArgs } from "node:util";
5
5
  import { loadConfig, runAudit } from "./audit.js";
6
6
  import { messages } from "./i18n.js";
7
7
  import { helpText } from "./help.js";
8
+ import { validatePlan } from "./schema.js";
8
9
  import { replaySavedSite, writeSavedSteps } from "./replay.js";
10
+ import { readBaseline, writeBaseline } from "./baseline.js";
9
11
  import { savePlan } from "./plan.js";
10
12
  import { DEFAULT_DATA_DIR } from "./workspace.js";
11
13
  import { commandText, intentToArgs, isSentence, needsWhatToTest, translate } from "./ask.js";
@@ -25,6 +27,7 @@ async function main(args = process.argv.slice(2)) {
25
27
  out: { type: "string" },
26
28
  journey: { type: "string", multiple: true },
27
29
  viewport: { type: "string", multiple: true },
30
+ concurrency: { type: "string" },
28
31
  platforms: { type: "string", multiple: true },
29
32
  providers: { type: "string", multiple: true },
30
33
  "allow-host": { type: "string", multiple: true },
@@ -36,6 +39,8 @@ async function main(args = process.argv.slice(2)) {
36
39
  "base-url": { type: "string" },
37
40
  lang: { type: "string" },
38
41
  "fail-on": { type: "string", default: "broken" },
42
+ baseline: { type: "string" },
43
+ "update-baseline": { type: "boolean", default: false },
39
44
  headed: { type: "boolean", default: false },
40
45
  "no-branding": { type: "boolean", default: false },
41
46
  "keep-bodies": { type: "boolean", default: false },
@@ -53,6 +58,8 @@ async function main(args = process.argv.slice(2)) {
53
58
  from: { type: "string" },
54
59
  topic: { type: "string" },
55
60
  format: { type: "string" },
61
+ limit: { type: "string" },
62
+ json: { type: "boolean", default: false },
56
63
  force: { type: "boolean", default: false },
57
64
  yes: { type: "boolean", short: "y", default: false },
58
65
  help: { type: "boolean", short: "h", default: false },
@@ -131,6 +138,18 @@ async function main(args = process.argv.slice(2)) {
131
138
  console.log(`✓ Saved plan ${id}: ${plan.events.length} events (${custom} custom) and ${plan.user_properties.length} user properties in ${rel.startsWith("..") ? dir : rel}`);
132
139
  return;
133
140
  }
141
+ if (command === "history" && !values.help) {
142
+ await showHistory(values);
143
+ return;
144
+ }
145
+ if (command === "doctor" && !values.help) {
146
+ const { doctorChecks } = await import("./doctor.js");
147
+ const checks = await doctorChecks({ site: values.site, dataDir: values["data-dir"] });
148
+ for (const c of checks) console.log(`${c.ok ? "✓" : "✗"} ${c.text}`);
149
+ // Only a check an audit cannot run without fails the command; a missing AI key is advice.
150
+ if (checks.some((c) => c.hard && !c.ok)) process.exitCode = 1;
151
+ return;
152
+ }
134
153
  if (values.help || command !== "run" || (!values.config && !values.site && !values.against)) {
135
154
  console.log(helpText(command));
136
155
  process.exit(values.help ? 0 : 2);
@@ -161,6 +180,8 @@ async function main(args = process.argv.slice(2)) {
161
180
  against = await resolvePlan(values.against, { dataDir: values["data-dir"] ?? DEFAULT_DATA_DIR, site: loaded.config.site });
162
181
  const plan = await readJson(against.file);
163
182
  if (!Array.isArray(plan?.events)) throw new Error(`${against.file} is not a plan: it needs an "events" list.`);
183
+ // loadConfig checked the config's own plan; the one --against names replaces it and gets the same check.
184
+ for (const w of validatePlan(plan, { file: against.file }).warnings) console.log(w);
164
185
  if (!loaded.config.site) {
165
186
  if (!plan.site) throw new Error(`${against.file} has no "site". Add --site <url>.`);
166
187
  loaded.config = { ...loaded.config, site: plan.site };
@@ -178,6 +199,17 @@ async function main(args = process.argv.slice(2)) {
178
199
  if (maxSteps !== undefined && !(Number.isInteger(maxSteps) && maxSteps > 0)) throw new Error("--max-steps must be a positive whole number.");
179
200
  // run --out is a hidden alias kept for one minor version; --data-dir replaces it.
180
201
  if (values.out) console.log("Note: run --out will be removed in a later version. Runs are saved in aitd/<domain>/runs/<id>/; use --data-dir <dir> to keep them elsewhere.");
202
+ const baselineFile = values.baseline ?? loaded.baselineFile ?? null;
203
+ if (values["update-baseline"] && !baselineFile) throw new Error('--update-baseline needs --baseline <file> or "baseline" in the config.');
204
+ // A baseline that doesn't exist yet is fine when this run writes it.
205
+ const baseline = baselineFile
206
+ ? await readBaseline(baselineFile).catch((err) => {
207
+ if (values["update-baseline"] && err.code === "ENOENT") return null;
208
+ throw err;
209
+ })
210
+ : null;
211
+ // A baseline written for another site matches nothing of this one, so say so, but let the run go on.
212
+ if (baseline?.site && baseline.site !== loaded.config.site) console.log(`Warning: the baseline ${baselineFile} was written for ${baseline.site}, not ${loaded.config.site}. Its entries may not match this site.`);
181
213
  const result = await runAudit({
182
214
  ...loaded,
183
215
  journeys: values.journey,
@@ -185,6 +217,7 @@ async function main(args = process.argv.slice(2)) {
185
217
  events: (values.events ?? []).flatMap((e) => e.split(",")).map((e) => e.trim()).filter(Boolean),
186
218
  maxSteps,
187
219
  viewports: values.viewport,
220
+ concurrency: values.concurrency,
188
221
  providers: platforms.length ? platforms.flatMap((p) => p.split(",")) : undefined,
189
222
  scripted: values.scripted,
190
223
  ai: !values["no-ai"],
@@ -198,12 +231,27 @@ async function main(args = process.argv.slice(2)) {
198
231
  dataDir: values["data-dir"],
199
232
  saveSite: values["save-site"],
200
233
  against,
234
+ failOn,
235
+ baseline,
201
236
  log: (msg) => console.log(msg),
202
237
  });
203
238
 
204
- console.log(`\n${messages(result.lang).ui.counts(result.counts)}`);
205
- const rel = path.relative(process.cwd(), result.reportPath);
206
- console.log(`Report: ${rel.startsWith("..") ? result.reportPath : rel}`);
239
+ const m = messages(result.lang);
240
+ console.log(`\n${m.ui.counts(result.counts)}`);
241
+ if (result.baseline) console.log(`${result.baseline.accepted} accepted (baseline ${baselineFile}), ${result.baseline.partial ? `${result.baseline.partial} ${result.baseline.partial === 1 ? "problem" : "problems"} accepted in some places only (still counted), ` : ""}${result.baseline.stale.length} stale baseline ${result.baseline.stale.length === 1 ? "entry" : "entries"}`);
242
+ if (result.verdict) console.log(m.ui.cli.verdict(result.verdict.statusText, result.verdict.headline, result.verdict.score));
243
+ // The trend needs two saved runs; with one there is nothing to say yet.
244
+ if (result.trend?.runs >= 2) {
245
+ const { historySummaryLines } = await import("./history.js");
246
+ for (const line of historySummaryLines(result.trend, result.lang, result.stability ?? []).slice(0, 3)) console.log(line);
247
+ }
248
+ const shown = (p) => (path.relative(process.cwd(), p).startsWith("..") ? p : path.relative(process.cwd(), p));
249
+ console.log(`Report: ${shown(result.reportPath)}`);
250
+ console.log(`Summary: ${shown(result.summaryPath)} (junit.xml next to it)`);
251
+ if (values["update-baseline"]) {
252
+ const n = await writeBaseline(baselineFile, { site: loaded.config.site, findings: result.findings });
253
+ console.log(`Baseline ${baselineFile} now holds ${n} ${n === 1 ? "finding" : "findings"}. Later runs with --baseline fail only on new ones.`);
254
+ }
207
255
 
208
256
  if (!against && !values.out) await offerLearnedPlan(result, loaded.config.site, values);
209
257
 
@@ -224,6 +272,39 @@ async function main(args = process.argv.slice(2)) {
224
272
  }
225
273
  }
226
274
 
275
+ // aitd history --site <url>: the trend of the site's saved runs as a small table, the text sparkline and the summary lines.
276
+ async function showHistory(values) {
277
+ if (!values.site) throw new Error("history needs --site <url>: aitd history --site https://www.example.com");
278
+ const limit = values.limit ? Number(values.limit) : 30;
279
+ if (!(Number.isInteger(limit) && limit > 0)) throw new Error("--limit must be a positive whole number.");
280
+ const lang = values.lang ?? "en";
281
+ const m = messages(lang);
282
+ const { domainOf } = await import("./workspace.js");
283
+ domainOf(values.site);
284
+ const { findingTimeline, historySummaryLines, journeyStability, loadHistory, sparkline, trend } = await import("./history.js");
285
+ const history = await loadHistory(values["data-dir"] ?? DEFAULT_DATA_DIR, values.site, { limit });
286
+ const t = trend(history);
287
+ const stability = journeyStability(history);
288
+ if (values.json) {
289
+ console.log(JSON.stringify({ site: values.site, runs: history.length, trend: t, timeline: findingTimeline(history), stability }, null, 2));
290
+ return;
291
+ }
292
+ if (!history.length) {
293
+ console.log(m.ui.history.noRuns);
294
+ return;
295
+ }
296
+ const cols = [m.ui.cli.runCol, m.severity.broken, m.severity.risk, m.severity.warning, m.ui.cli.scoreCol, m.ui.cli.completeCol];
297
+ const rows = t.series.map((p) => [p.id, p.broken, p.risk, p.warning, p.score, p.complete ? m.ui.md.yes : m.ui.md.no].map(String));
298
+ const widths = cols.map((c, i) => Math.max(c.length, ...rows.map((r) => r[i].length)));
299
+ const line = (cells) => cells.map((c, i) => (i ? c.padStart(widths[i]) : c.padEnd(widths[i]))).join(" ");
300
+ console.log(line(cols));
301
+ for (const r of rows) console.log(line(r));
302
+ console.log("");
303
+ const s = m.ui.history;
304
+ if (t.runs >= 2) console.log(s.sparkline(sparkline(t), s.direction[t.direction], t.runs, t.first.slice(0, 10), t.last.slice(0, 10)));
305
+ for (const l of historySummaryLines(t, lang, stability)) console.log(l);
306
+ }
307
+
227
308
  // The first run of a site with no plan: offer what it already tracks as its plan.
228
309
  async function offerLearnedPlan(result, site, values) {
229
310
  const dataDir = values["data-dir"] ?? DEFAULT_DATA_DIR;
package/src/consent.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // (accept all, reject all, analytics only), to show which tags each choice lets through.
3
3
  // Runs on its own with every audit when the page shows a consent banner.
4
4
  import { AuditSession, CMP_BANNER_SELECTORS } from "./browser.js";
5
+ import { runPool } from "./pool.js";
5
6
 
6
7
  export const CONSENT_STATES = ["accept", "reject", "analytics"];
7
8
  // Tags that are advertising, so an "analytics only" choice should keep them quiet.
@@ -110,6 +111,7 @@ async function runState(config, state, opts) {
110
111
  /**
111
112
  * Runs every consent scenario on the site's start page. Returns null when the page has no consent
112
113
  * banner the tool can answer (the first choice found nothing to click), so the report leaves the section out.
114
+ * opts.concurrency runs the other choices side by side, each in its own browser; default 1.
113
115
  * @returns {Promise<null | { cmp: string, before: object, states: Record<string, { via: string|null, after?: object, error?: string }> }>}
114
116
  */
115
117
  export async function runConsentScenarios(config, opts = {}) {
@@ -117,8 +119,8 @@ export async function runConsentScenarios(config, opts = {}) {
117
119
  const first = await runState(config, "accept", opts);
118
120
  if (!first.via) return null;
119
121
  log(`\n▶ consent scenarios (${first.via})`);
120
- const results = [first];
121
- for (const state of CONSENT_STATES.slice(1)) results.push(await runState(config, state, opts));
122
+ // The first choice shows whether there is a banner to answer at all; the others don't depend on each other.
123
+ const results = [first, ...(await runPool(CONSENT_STATES.slice(1), opts.concurrency ?? 1, (state) => runState(config, state, opts)))];
122
124
  const states = {};
123
125
  for (const r of results) {
124
126
  states[r.state] = r.error ? { via: null, error: r.error } : { via: r.via, ...(r.after ? { after: r.after } : {}) };
package/src/doctor.js ADDED
@@ -0,0 +1,68 @@
1
+ // `aitd doctor`: one line per thing an audit needs, so a broken setup is found before a run starts.
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { access, constants, stat } from "node:fs/promises";
4
+ import { lookup } from "node:dns/promises";
5
+ import path from "node:path";
6
+ import { aiStatus } from "./providers.js";
7
+ import { DEFAULT_DATA_DIR } from "./workspace.js";
8
+
9
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
10
+
11
+ // The same as minNodeMajor() in scripts/setup.js, which the published package doesn't ship.
12
+ const minNodeMajor = (range) => Number(/(\d+)/.exec(range ?? "")?.[1] ?? 0);
13
+
14
+ /** The nearest existing directory at or above dir, since the data dir is only created by the first run. */
15
+ function existingAncestor(dir, exists) {
16
+ let d = dir;
17
+ while (!exists(d) && path.dirname(d) !== d) d = path.dirname(d);
18
+ return d;
19
+ }
20
+
21
+ /**
22
+ * Runs the checks and returns [{ ok, hard, text }]. A failed hard check means audits can't run;
23
+ * a soft one is advice. Everything that touches the machine can be injected, so tests need no
24
+ * browser or network.
25
+ */
26
+ export async function doctorChecks({ site, dataDir = DEFAULT_DATA_DIR, nodeVersion = process.version, chromiumPath, env = process.env, exists = existsSync, writable = writableDir, resolve = lookup } = {}) {
27
+ const checks = [];
28
+ const range = pkg.engines?.node;
29
+ const nodeOk = Number(/^v?(\d+)/.exec(nodeVersion)?.[1] ?? 0) >= minNodeMajor(range);
30
+ checks.push({ ok: nodeOk, hard: true, text: nodeOk ? `Node ${nodeVersion} (needs ${range})` : `Node ${nodeVersion} is too old: this needs Node ${range}. Install a newer Node, for example with nvm: nvm install 22` });
31
+
32
+ const pw = pkg.dependencies.playwright;
33
+ chromiumPath ??= (await import("playwright")).chromium.executablePath();
34
+ const chromiumOk = Boolean(chromiumPath && exists(chromiumPath));
35
+ checks.push({ ok: chromiumOk, hard: true, text: chromiumOk ? `Playwright ${pw} with Chromium at ${chromiumPath}` : `Playwright ${pw} has no Chromium installed. Run: npx -y playwright@${pw} install chromium` });
36
+
37
+ // The same line `aitd` with no arguments prints. No key is not a failure: --no-ai and replay work without one.
38
+ const ai = aiStatus({}, env);
39
+ const aiOk = !ai.startsWith("AI: no key set");
40
+ checks.push({ ok: aiOk, hard: false, text: aiOk ? ai : `${ai}. Optional: --no-ai and replay work without a key` });
41
+
42
+ const dir = path.resolve(dataDir);
43
+ const probe = existingAncestor(dir, exists);
44
+ const dirOk = await writable(probe).then(() => true, () => false);
45
+ checks.push({ ok: dirOk, hard: true, text: dirOk ? `Data folder ${dir} is writable${probe === dir ? "" : " (it will be created)"}` : `Data folder ${dir} is not writable (checked ${probe}). Pick another with --data-dir <dir>` });
46
+
47
+ if (site !== undefined) checks.push(await siteCheck(site, resolve));
48
+ return checks;
49
+ }
50
+
51
+ /** Rejects unless d is a directory the process may write to: a data dir under a file can never be created, whatever the permissions say. */
52
+ async function writableDir(d) {
53
+ if (!(await stat(d)).isDirectory()) throw new Error(`${d} is not a directory`);
54
+ await access(d, constants.W_OK);
55
+ }
56
+
57
+ /** The site is an http(s) address whose host resolves. Nothing is fetched. */
58
+ async function siteCheck(site, resolve) {
59
+ let u;
60
+ try {
61
+ u = new URL(site);
62
+ } catch {
63
+ return { ok: false, hard: true, text: `"${site}" is not a full URL. Use the form https://www.example.com` };
64
+ }
65
+ if (!["http:", "https:"].includes(u.protocol)) return { ok: false, hard: true, text: `${site} is not an http or https address, so the browser can't open it` };
66
+ const found = await resolve(u.hostname).then(() => true, () => false);
67
+ return { ok: found, hard: true, text: found ? `${u.hostname} resolves` : `${u.hostname} does not resolve. Check the address, or your network and DNS` };
68
+ }
package/src/gtm.js CHANGED
@@ -1,10 +1,22 @@
1
1
  // Reads a GTM container export (Admin → Export container) to name the tag and trigger
2
2
  // behind a hit. Matching is deterministic: no API call and no AI model.
3
3
 
4
- import { canonicalEvent } from "./checks.js";
4
+ import { canonicalEvent, isConsentCommand } from "./checks.js";
5
+ import { messages } from "./i18n.js";
5
6
 
6
7
  // GTM's built-in "All Pages" trigger has no entry in the export's trigger list.
7
8
  const BUILT_IN_TRIGGERS = { 2147479553: { name: "All Pages", type: "PAGEVIEW" } };
9
+ // The dataLayer events GTM raises for page triggers; the init script records them like any push.
10
+ const PAGE_EVENTS = { PAGEVIEW: "gtm.js", DOM_READY: "gtm.dom", WINDOW_LOADED: "gtm.load" };
11
+ // Triggers GTM evaluates from what happens in the page. Their events reach the dataLayer only
12
+ // while such a trigger is listening, so the dataLayer can confirm one but not rule it out.
13
+ const PAGE_ACTION_EVENTS = {
14
+ CLICK: "gtm.click", LINK_CLICK: "gtm.linkClick", FORM_SUBMIT: "gtm.formSubmit", FORM_SUBMISSION: "gtm.formSubmit", HISTORY_CHANGE: "gtm.historyChange",
15
+ ELEMENT_VISIBILITY: "gtm.elementVisibility", TIMER: "gtm.timer", SCROLL_DEPTH: "gtm.scrollDepth", JS_ERROR: "gtm.pageError", YOU_TUBE_VIDEO: "gtm.video",
16
+ };
17
+ // Older exports write the custom event filter's operators in short form.
18
+ const SHORT_OPS = { _eq: "EQUALS", _cn: "CONTAINS", _sw: "STARTS_WITH", _ew: "ENDS_WITH", _re: "MATCH_REGEX", _lt: "LESS", _le: "LESS_OR_EQUALS", _gt: "GREATER", _ge: "GREATER_OR_EQUALS", _css: "CSS_SELECTOR", _um: "URL_MATCHES" };
19
+ const PAGE_VARIABLES = { "Page URL": "URL", "Page Path": "PATH", "Page Hostname": "HOST" };
8
20
  const EVENT_PARAMS = ["eventName", "event_name", "event", "eventType", "standardEventName", "customEventName", "eventTypeStandard", "eventTypeCustom"];
9
21
 
10
22
  /** A tag parameter list as a flat { key: value } map; nested lists and maps are kept as JSON text. */
@@ -79,14 +91,208 @@ export function tagsForEvent(tags, platform, event) {
79
91
  return tags.filter((t) => t.platform === platform && t.events.some((e) => canonicalEvent(e) === want));
80
92
  }
81
93
 
82
- /** Adds `gtm` to each finding about one platform event: the tags that send it, or an empty list when none does. */
83
- export function annotateFindings(findings, container) {
94
+ /** The event name and data a push adds to GTM's data model: an object as it is, a gtag() call as its event and eventModel. */
95
+ function pushModel(value) {
96
+ if (Array.isArray(value)) return value[0] === "event" && typeof value[1] === "string" ? { event: value[1], data: { event: value[1], eventModel: value[2] ?? {} } } : null;
97
+ if (value && typeof value === "object") return { event: typeof value.event === "string" ? value.event : undefined, data: value };
98
+ return null;
99
+ }
100
+
101
+ /** Merges a push into the running data layer the way GTM's version 2 model does: objects and arrays merge key by key, so a value persists until it is overwritten or cleared with null. */
102
+ function merge(state, push) {
103
+ for (const [k, v] of Object.entries(push)) {
104
+ const same = v && typeof v === "object" && state[k] && typeof state[k] === "object" && Array.isArray(v) === Array.isArray(state[k]);
105
+ if (same) merge(state[k], v);
106
+ else state[k] = v && typeof v === "object" ? structuredClone(v) : v;
107
+ }
108
+ return state;
109
+ }
110
+
111
+ /** A value from the data layer by dot path, as a Data Layer Variable reads it; version 1 reads the key as it is. */
112
+ function readPath(state, name, version) {
113
+ if (String(version) === "1") return state[name];
114
+ return String(name).split(".").reduce((v, k) => (v && typeof v === "object" ? v[k] : undefined), state);
115
+ }
116
+
117
+ /** A part of the page address at the push, or undefined when it is not a valid address. */
118
+ function urlPart(url, component) {
119
+ try {
120
+ const u = new URL(url);
121
+ return { URL: u.href, PATH: u.pathname, HOST: u.hostname, PROTOCOL: u.protocol.replace(":", ""), PORT: u.port, FRAGMENT: u.hash.replace("#", ""), QUERY: u.search.replace("?", "") }[component];
122
+ } catch {
123
+ return undefined;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * The value of one GTM variable at a push: { value, field } when the dataLayer can give it, where `field` is how the
129
+ * report names it (a Data Layer Variable by its path), or { unknown: name } when only the page could tell.
130
+ */
131
+ function resolveVariable(name, ctx, variables) {
132
+ if (name === "_event" || name === "Event") return { value: ctx.event, field: "event" };
133
+ if (PAGE_VARIABLES[name]) return ctx.url ? { value: urlPart(ctx.url, PAGE_VARIABLES[name]), field: name } : { unknown: name };
134
+ const v = variables.find((x) => x.name === name);
135
+ const p = params(v?.parameter);
136
+ if (v?.type === "v" && p.name) return { value: readPath(ctx.state, p.name, p.dataLayerVersion ?? "2"), field: p.name };
137
+ if (v?.type === "c") return { value: p.value, field: name };
138
+ if (v?.type === "u" && ctx.url) return { value: urlPart(ctx.url, p.component ?? "URL"), field: name };
139
+ return { unknown: name };
140
+ }
141
+
142
+ /** A condition argument with its {{variables}} filled in; a lone reference keeps the value's type. */
143
+ function resolveArg(text, ctx, variables) {
144
+ const whole = /^\{\{(.+)\}\}$/.exec(String(text ?? ""));
145
+ if (whole) return resolveVariable(whole[1], ctx, variables);
146
+ let unknown = null;
147
+ const value = String(text ?? "").replace(/\{\{(.+?)\}\}/g, (_, n) => {
148
+ const r = resolveVariable(n, ctx, variables);
149
+ if (r.unknown) unknown = r.unknown;
150
+ return r.value ?? "";
151
+ });
152
+ return unknown ? { unknown } : { value, field: String(text ?? "") };
153
+ }
154
+
155
+ /** GTM's comparison for one condition type: true, false, or "unknown" for the ones only the page can answer. */
156
+ function compare(type, a, b, ignoreCase) {
157
+ const s = (v) => (ignoreCase ? String(v ?? "").toLowerCase() : String(v ?? ""));
158
+ const n = (v) => (v === "" || v === null || v === undefined || typeof v === "object" ? NaN : Number(v));
159
+ switch (type) {
160
+ case "EQUALS": return s(a) === s(b);
161
+ case "CONTAINS": return s(a).includes(s(b));
162
+ case "STARTS_WITH": return s(a).startsWith(s(b));
163
+ case "ENDS_WITH": return s(a).endsWith(s(b));
164
+ case "MATCH_REGEX":
165
+ try {
166
+ return new RegExp(String(b ?? ""), ignoreCase ? "i" : "").test(String(a ?? ""));
167
+ } catch {
168
+ return "unknown";
169
+ }
170
+ case "LESS": return n(a) < n(b);
171
+ case "LESS_OR_EQUALS": return n(a) <= n(b);
172
+ case "GREATER": return n(a) > n(b);
173
+ case "GREATER_OR_EQUALS": return n(a) >= n(b);
174
+ default: return "unknown";
175
+ }
176
+ }
177
+
178
+ /** A value as the reason shows it: numbers bare, text quoted, objects as JSON. */
179
+ const show = (v, m) => (v === undefined || v === null ? m.nothing : typeof v === "object" ? JSON.stringify(v) : typeof v === "string" && !/^-?\d+(\.\d+)?$/.test(v) ? `"${v}"` : String(v));
180
+
181
+ /** One trigger condition at a push: { ok: true | false | "unknown", reason }, with the reason the report shows when it is not met. */
182
+ function condition(c, ctx, variables, trigger, m) {
183
+ const type = SHORT_OPS[c.type] ?? c.type;
184
+ const p = params(c.parameter);
185
+ if (!m.ops[type]) return { ok: "unknown", reason: m.conditionType(trigger, type) };
186
+ const a = resolveArg(p.arg0, ctx, variables);
187
+ const b = resolveArg(p.arg1, ctx, variables);
188
+ const unknown = a.unknown ?? b.unknown;
189
+ if (unknown) return { ok: "unknown", reason: m.conditionUnknown(trigger, unknown) };
190
+ const negate = String(p.negate) === "true";
191
+ let ok = compare(type, a.value, b.value, String(p.ignore_case) === "true");
192
+ if (ok === "unknown") return { ok, reason: m.conditionType(trigger, type) };
193
+ if (negate) ok = !ok;
194
+ return { ok, reason: ok ? "" : m.condition(trigger, a.field, m.ops[type], show(b.value, m), show(a.value, m), negate) };
195
+ }
196
+
197
+ /**
198
+ * Whether a GTM trigger fired in a journey, judged from the recorded dataLayer pushes alone.
199
+ * @param {any} trigger A trigger from the container export.
200
+ * @param {{ value: any, url?: string }[]} pushes The run's dataLayerLog entries, in order.
201
+ * @param {any[]} [variables] The export's variable list, for conditions that read Data Layer Variables.
202
+ * @returns {{ fired: true | false | "unknown", reason: string, matched?: number }} `matched` is the index of the push it fired on.
203
+ */
204
+ export function evaluateTrigger(trigger, pushes, variables = [], lang = "en") {
205
+ const m = messages(lang).gtm;
206
+ const name = trigger?.name ?? "?";
207
+ const type = trigger?.type ?? "unknown";
208
+ const custom = type === "CUSTOM_EVENT";
209
+ const event = PAGE_EVENTS[type] ?? PAGE_ACTION_EVENTS[type];
210
+ if (!custom && !event) return { fired: "unknown", reason: m.unknownType(name, type) };
211
+ const state = {};
212
+ let seen = false;
213
+ let failed = null;
214
+ let unknown = null;
215
+ for (let i = 0; i < pushes.length; i++) {
216
+ const model = pushModel(pushes[i]?.value);
217
+ if (!model) continue;
218
+ merge(state, model.data);
219
+ const ctx = { event: model.event, state, url: pushes[i].url };
220
+ // The event conditions decide whether the push concerns this trigger at all; the filter decides whether it fires.
221
+ const events = custom ? (trigger.customEventFilter ?? []).map((c) => condition(c, ctx, variables, name, m)) : [{ ok: model.event === event }];
222
+ if (events.some((r) => r.ok === false)) continue;
223
+ const vague = events.find((r) => r.ok === "unknown");
224
+ if (vague) {
225
+ unknown ??= vague;
226
+ continue;
227
+ }
228
+ seen = true;
229
+ const result = (trigger.filter ?? []).map((c) => condition(c, ctx, variables, name, m)).find((r) => r.ok !== true);
230
+ if (!result) return { fired: true, reason: m.triggerFired(name, model.event ?? event), matched: i };
231
+ if (result.ok === "unknown") unknown ??= result;
232
+ else failed ??= result;
233
+ }
234
+ if (unknown) return { fired: "unknown", reason: unknown.reason };
235
+ if (failed) return { fired: false, reason: failed.reason };
236
+ if (PAGE_ACTION_EVENTS[type] && !seen) return { fired: "unknown", reason: m.pageTrigger(name, type.toLowerCase().replace(/_/g, " ")) };
237
+ if (PAGE_EVENTS[type] && !seen) return { fired: false, reason: m.noPageEvent(name, event) };
238
+ return { fired: false, reason: m.noPush(triggerEvents(trigger).join(", ") || event || name) };
239
+ }
240
+
241
+ /** The Consent Mode state after the pushes up to and including `upTo`; a type nobody set is not denied. */
242
+ function consentAt(pushes, upTo) {
243
+ const state = {};
244
+ for (const e of pushes.slice(0, upTo + 1)) if (isConsentCommand(e, "default") || isConsentCommand(e, "update")) Object.assign(state, e.value[2] ?? {});
245
+ return state;
246
+ }
247
+
248
+ /** Why one tag did or did not fire: its pause state, its triggers, its exceptions and its consent needs, in that order. */
249
+ function evaluateTag(tag, byId, pushes, variables, m, lang) {
250
+ if (tag.paused) return { fired: false, why: m.paused };
251
+ if (!tag.triggers.length) return { fired: false, why: m.noTrigger };
252
+ const raw = (t) => byId.get(String(t.id)) ?? (BUILT_IN_TRIGGERS[t.id] ? { ...BUILT_IN_TRIGGERS[t.id], triggerId: t.id } : null);
253
+ const evaluate = (t) => (raw(t) ? evaluateTrigger(raw(t), pushes, variables, lang) : { fired: "unknown", reason: m.missingTrigger(t.name) });
254
+ const results = tag.triggers.map(evaluate);
255
+ const hit = results.find((r) => r.fired === true);
256
+ if (!hit) {
257
+ const vague = results.find((r) => r.fired === "unknown");
258
+ return vague ? { fired: "unknown", why: vague.reason } : { fired: false, why: results.map((r) => r.reason).join("; ") };
259
+ }
260
+ // An exception only stops the tag when it fires on the same push as the firing trigger.
261
+ for (const b of tag.blocking) {
262
+ const r = evaluate(b);
263
+ if (r.fired === true && r.matched === hit.matched) return { fired: false, why: m.blocked(b.name) };
264
+ }
265
+ const consent = consentAt(pushes, hit.matched);
266
+ const denied = tag.consent.find((type) => consent[type] === "denied");
267
+ if (denied) return { fired: false, why: m.consentDenied(denied) };
268
+ return { fired: true, why: m.fired };
269
+ }
270
+
271
+ /**
272
+ * Adds `evaluation: { fired, why }` to each tag in `finding.gtm`, judged from the run's dataLayer pushes:
273
+ * whether the tag's triggers fired, whether an exception blocked it, and whether consent allowed it.
274
+ */
275
+ export function explainTags(finding, run, container, lang = "en") {
276
+ const m = messages(lang).gtm;
277
+ const v = container?.containerVersion ?? container ?? {};
278
+ const byId = new Map((v.trigger ?? []).map((t) => [String(t.triggerId), t]));
279
+ for (const tag of finding.gtm ?? []) tag.evaluation = evaluateTag(tag, byId, run?.dataLayerLog ?? [], v.variable ?? [], m, lang);
280
+ return finding;
281
+ }
282
+
283
+ /**
284
+ * Adds `gtm` to each finding about one platform event: the tags that send it, or an empty list when none does.
285
+ * With `runs`, each tag also says whether it should have fired in the finding's journey, and why not.
286
+ */
287
+ export function annotateFindings(findings, container, runs, { lang = "en" } = {}) {
84
288
  if (!container) return findings;
85
289
  const tags = containerTags(container);
86
290
  if (!tags.length) return findings;
87
291
  for (const f of findings) {
88
292
  if (!f.platform || !f.event || f.severity === "ok") continue;
89
293
  f.gtm = tagsForEvent(tags, f.platform, f.event).map(({ name, type, paused, consent, triggers, blocking }) => ({ name, type, paused, consent, triggers, blocking }));
294
+ const run = runs?.find((r) => r.journeyId === f.journey && r.viewport === f.viewport);
295
+ if (run) explainTags(f, run, container, lang);
90
296
  }
91
297
  return findings;
92
298
  }
package/src/help.js CHANGED
@@ -13,6 +13,8 @@ Commands:
13
13
  run Audit what a site actually sends, and compare it to a plan
14
14
  replay Re-check saved sites offline, without AI
15
15
  mcp Start the MCP server for Claude, ChatGPT and other chat apps
16
+ doctor Check that Node, Chromium, an AI key and the data folder are ready
17
+ history Show how a site's saved runs trend: counts, open problems, flapping, journeys
16
18
 
17
19
  Examples:
18
20
  aitd plan --site https://example.com
@@ -40,12 +42,16 @@ Options:
40
42
  --lang <code> Report language: ${Object.keys(LANGUAGES).join(" or ")}
41
43
  --headed Show the browser window
42
44
  --fail-on <level> Exit 1 at this level: broken (default), risk, warning, never
45
+ --baseline <file> Known findings to accept; they don't count toward --fail-on
46
+ --update-baseline Write the baseline file after the run (creates it)
43
47
 
44
48
  AI: --provider <name> --model <id|cheap> (default: whichever API key is set)
45
49
 
46
50
  Advanced:
47
- --max-steps <n> --platforms <list> --viewport <name> --data-dir <dir>
48
- --scripted --no-ai --save-site <dir> --save-steps <file> (see the README)`;
51
+ --max-steps <n> --platforms <list> --viewport <name> --concurrency <n> --data-dir <dir>
52
+ --scripted --no-ai --save-site <dir> --save-steps <file> (see the README)
53
+ --scripted replays each journey's fixed steps; a journey without any repeats the steps
54
+ of its last completed run, which every run saves in its folder as steps.json.`;
49
55
 
50
56
  const PLAN = `Usage: aitd plan --site <url>
51
57
  aitd plan list --site <url>
@@ -99,7 +105,31 @@ Advanced:
99
105
  --reports <dir> Where finished audits write reports (default: ~/ai-tag-debugger-reports)
100
106
  --public-url <url> The HTTPS address of your tunnel or host, so audits can link to reports`;
101
107
 
102
- const HELP = { run: RUN, plan: PLAN, replay: REPLAY, mcp: MCP };
108
+ const DOCTOR = `Usage: aitd doctor [--site <url>] [--data-dir <dir>]
109
+
110
+ Check that this machine can run an audit: the Node version, Playwright's Chromium, whether
111
+ an AI provider key is set, and that the data folder is writable. Exits with code 1 when a
112
+ check an audit needs fails. A missing AI key is only reported: --no-ai and replay work without one.
113
+
114
+ Options:
115
+ --site <url> Also check that the site is an http(s) address whose host resolves (nothing is fetched)
116
+ --data-dir <dir> The folder plans and runs are saved in (default: aitd)`;
117
+
118
+ const HISTORY = `Usage: aitd history --site <url> [--limit <n>] [--json] [--data-dir <dir>]
119
+
120
+ Show the trend of a site's saved runs: one row per run with its problem counts and score, a
121
+ text sparkline, the problems open longest, the ones that come and go, and the journeys that do
122
+ not finish every time. Reads the runs in aitd/<domain>/runs/ and its history.json; no browser,
123
+ no AI, nothing is changed.
124
+
125
+ Options:
126
+ --site <url> The site (required)
127
+ --limit <n> How many of the newest runs to read (default 30)
128
+ --json Print the trend, the finding timeline and the journey stability as JSON
129
+ --lang <code> Language of the lines: ${Object.keys(LANGUAGES).join(" or ")}
130
+ --data-dir <dir> The folder plans and runs are saved in (default: aitd)`;
131
+
132
+ const HELP = { run: RUN, plan: PLAN, replay: REPLAY, mcp: MCP, doctor: DOCTOR, history: HISTORY };
103
133
 
104
134
  /** Help text for a command, or the top-level help when the command is unknown or missing. */
105
135
  export function helpText(command) {