agent-device-lab 0.0.0-stage → 0.3.1

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 (54) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +10 -0
  3. package/README.md +150 -2
  4. package/assets/dashboard/app.css +238 -0
  5. package/assets/dashboard/app.js +976 -0
  6. package/assets/dashboard/index.html +120 -0
  7. package/bin/agentlab.js +8 -0
  8. package/dist/cli/ci.js +443 -0
  9. package/dist/cli/flow.js +127 -0
  10. package/dist/cli/main.js +855 -0
  11. package/dist/cli/signals.js +38 -0
  12. package/dist/core/action-log.js +125 -0
  13. package/dist/core/auth.js +175 -0
  14. package/dist/core/bundle.js +432 -0
  15. package/dist/core/checks.js +137 -0
  16. package/dist/core/ci.js +389 -0
  17. package/dist/core/commands.js +366 -0
  18. package/dist/core/control.js +217 -0
  19. package/dist/core/detectors.js +392 -0
  20. package/dist/core/devices.js +83 -0
  21. package/dist/core/doctor.js +203 -0
  22. package/dist/core/explore-safety.js +110 -0
  23. package/dist/core/extract.js +1445 -0
  24. package/dist/core/feed.js +493 -0
  25. package/dist/core/findings.js +179 -0
  26. package/dist/core/format.js +311 -0
  27. package/dist/core/init.js +607 -0
  28. package/dist/core/lab.js +1979 -0
  29. package/dist/core/migrate.js +35 -0
  30. package/dist/core/observation.js +161 -0
  31. package/dist/core/ownership.js +142 -0
  32. package/dist/core/process-group.js +206 -0
  33. package/dist/core/process-identity.js +56 -0
  34. package/dist/core/profile.js +816 -0
  35. package/dist/core/project-runner.js +359 -0
  36. package/dist/core/replay.js +303 -0
  37. package/dist/core/reproduction.js +36 -0
  38. package/dist/core/scan-policy.js +156 -0
  39. package/dist/core/scan-report.js +145 -0
  40. package/dist/core/scan.js +455 -0
  41. package/dist/core/schema.js +39 -0
  42. package/dist/core/services.js +134 -0
  43. package/dist/core/steps.js +80 -0
  44. package/dist/core/sweep.js +138 -0
  45. package/dist/core/trace-sanitize.js +641 -0
  46. package/dist/core/url-redact.js +25 -0
  47. package/dist/core/versions.js +29 -0
  48. package/dist/core/zip.js +264 -0
  49. package/dist/daemon/main.js +160 -0
  50. package/dist/daemon/state.js +93 -0
  51. package/dist/dashboard/open.js +25 -0
  52. package/dist/dashboard/server.js +366 -0
  53. package/dist/mcp/server.js +89 -0
  54. package/package.json +63 -4
@@ -0,0 +1,36 @@
1
+ /** How many of the most recent steps a session's reproduction history keeps (the first step is always kept). */
2
+ export const HISTORY_KEEP = 200;
3
+ /** How many console errors and failed requests a session keeps in memory (counters stay exact). */
4
+ export const EVENTS_KEEP = 500;
5
+ const OMITTED = /^… (\d+) earlier steps omitted$/;
6
+ /**
7
+ * Append a reproduction step, keeping the first step (the `open …` line) and the latest `keep`, with one
8
+ * "… N earlier steps omitted" line between them. The array is edited in place: findings, sweeps and
9
+ * scans hold it by reference and copy it when they record a finding.
10
+ */
11
+ export function appendStep(history, step, keep = HISTORY_KEEP) {
12
+ history.push(step);
13
+ const marker = history.length > 1 ? OMITTED.exec(history[1]) : null;
14
+ const tailStart = marker ? 2 : 1;
15
+ const drop = history.length - tailStart - keep;
16
+ if (drop <= 0)
17
+ return;
18
+ history.splice(tailStart, drop);
19
+ const text = `… ${(marker ? Number(marker[1]) : 0) + drop} earlier steps omitted`;
20
+ if (marker)
21
+ history[1] = text;
22
+ else
23
+ history.splice(1, 0, text);
24
+ }
25
+ /** Add to a list that keeps only the latest `keep` entries; the caller counts the true total. */
26
+ export function pushCapped(list, item, keep = EVENTS_KEEP) {
27
+ list.push(item);
28
+ if (list.length > keep)
29
+ list.splice(0, list.length - keep);
30
+ }
31
+ /** Entries added after `mark` (a total taken earlier), as far as the capped list still holds them. */
32
+ export function since(list, total, mark) {
33
+ const n = Math.min(total - mark, list.length);
34
+ return n > 0 ? list.slice(-n) : [];
35
+ }
36
+ //# sourceMappingURL=reproduction.js.map
@@ -0,0 +1,156 @@
1
+ // Pure scan policy: suppression matching, grouping by fingerprint and the pass/fail verdict.
2
+ export const DEFAULT_POLICY = { failOn: 'high', failOnErrors: false, failOnHeuristic: false };
3
+ export const KIND_TITLES = {
4
+ 'horizontal-overflow': 'Page is wider than the screen',
5
+ 'control-clipped': 'Control cut off by the screen edge',
6
+ 'horizontal-pan-required': 'Sideways scrolling needed to reach a control',
7
+ 'control-obstructed': 'Control covered by another element',
8
+ 'container-clipped': 'Clipped by its container',
9
+ 'text-clipped': 'Text cut off',
10
+ 'text-truncated': 'Text truncated',
11
+ 'fixed-collision': 'Fixed elements collide',
12
+ 'content-under-fixed': 'Content hidden behind a fixed bar',
13
+ 'modal-overflow': 'Dialog or drawer overflows the screen',
14
+ 'unreachable-content': 'Content cannot be scrolled to',
15
+ 'outside-container': 'Control sticks out of its container',
16
+ 'content-scroll-x': 'Unexpected horizontal scrolling',
17
+ 'tap-target': 'Tap target too small or too close',
18
+ 'layout-shift': 'Layout shifts while the state settles',
19
+ 'text-wrap-change': 'Label wraps at a narrower width',
20
+ };
21
+ const SEVERITY_RANK = { high: 3, medium: 2, low: 1 };
22
+ function globToRegExp(glob) {
23
+ const body = glob.split('*').map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('[\\s\\S]*');
24
+ return new RegExp(`^${body}$`);
25
+ }
26
+ /** Exact, or a glob where `*` matches any run of characters (including `/`), anchored at both ends. */
27
+ function globMatch(pattern, value) {
28
+ return pattern.includes('*') ? globToRegExp(pattern).test(value) : pattern === value;
29
+ }
30
+ function ruleMatches(rule, f) {
31
+ if (rule.kind !== undefined) {
32
+ const kinds = Array.isArray(rule.kind) ? rule.kind : [rule.kind];
33
+ if (!kinds.includes(f.kind))
34
+ return false;
35
+ }
36
+ if (rule.fingerprint !== undefined && rule.fingerprint !== f.fingerprint)
37
+ return false;
38
+ if (rule.route !== undefined && !globMatch(rule.route, f.route))
39
+ return false;
40
+ if (rule.target?.role !== undefined && f.target?.role !== rule.target.role)
41
+ return false;
42
+ if (rule.target?.name !== undefined && (f.target === undefined || !globMatch(rule.target.name, f.target.name)))
43
+ return false;
44
+ if (rule.scenario !== undefined && f.scenario !== rule.scenario)
45
+ return false;
46
+ if (rule.device !== undefined && f.device !== rule.device)
47
+ return false;
48
+ return true;
49
+ }
50
+ export function matchSuppressions(findings, rules, ctx) {
51
+ const applied = new Map();
52
+ const statuses = [];
53
+ rules.forEach((rule, index) => {
54
+ const base = { rule: index, reason: rule.reason, ...(rule.expires ? { expires: rule.expires } : {}) };
55
+ if (rule.expires !== undefined && rule.expires < ctx.today) {
56
+ statuses.push({ ...base, status: 'expired', matched: [] });
57
+ return;
58
+ }
59
+ const matched = [];
60
+ for (const f of findings) {
61
+ if (!ruleMatches(rule, f))
62
+ continue;
63
+ matched.push(f.id);
64
+ if (!applied.has(f.id))
65
+ applied.set(f.id, { ...base, rule: index });
66
+ }
67
+ if (matched.length) {
68
+ statuses.push({ ...base, status: 'applied', matched });
69
+ return;
70
+ }
71
+ const outOfScope = (rule.scenario !== undefined && !ctx.scenarios.includes(rule.scenario))
72
+ || (rule.device !== undefined && !ctx.devices.includes(rule.device));
73
+ statuses.push({ ...base, status: outOfScope ? 'not-evaluated' : 'unmatched', matched });
74
+ });
75
+ return { statuses, applied };
76
+ }
77
+ const idNumber = (id) => Number.parseInt(id.replace(/\D+/g, ''), 10) || 0;
78
+ export function groupFindings(findings) {
79
+ const byFingerprint = new Map();
80
+ for (const f of findings) {
81
+ const list = byFingerprint.get(f.fingerprint);
82
+ if (list)
83
+ list.push(f);
84
+ else
85
+ byFingerprint.set(f.fingerprint, [f]);
86
+ }
87
+ const groups = [];
88
+ for (const [fingerprint, members] of byFingerprint) {
89
+ const live = members.filter((f) => !f.suppressed);
90
+ const counted = live.length ? live : members;
91
+ const severity = counted.reduce((max, f) => (SEVERITY_RANK[f.severity] > SEVERITY_RANK[max] ? f.severity : max), 'low');
92
+ const confidence = counted.some((f) => f.confidence === 'confirmed') ? 'confirmed' : 'heuristic';
93
+ // The most severe member (confirmed first, then input order) speaks for the group.
94
+ const lead = members.reduce((best, f) => {
95
+ const a = SEVERITY_RANK[f.severity] * 2 + (f.confidence === 'confirmed' ? 1 : 0);
96
+ const b = SEVERITY_RANK[best.severity] * 2 + (best.confidence === 'confirmed' ? 1 : 0);
97
+ return a > b ? f : best;
98
+ });
99
+ const target = lead.target ? (({ ref: _ref, ...rest }) => rest)(lead.target) : undefined;
100
+ groups.push({
101
+ fingerprint,
102
+ kind: lead.kind,
103
+ title: `${KIND_TITLES[lead.kind]}: ${target ? `${target.role} "${target.name}"` : lead.route}`,
104
+ severity,
105
+ confidence,
106
+ route: lead.route,
107
+ ...(target ? { target } : {}),
108
+ scenarios: [...new Set(members.map((f) => f.scenario ?? 'session'))].sort(),
109
+ devices: [...new Set(members.map((f) => f.device))].sort(),
110
+ findings: members.map((f) => f.id),
111
+ suppressed: members.every((f) => f.suppressed !== undefined),
112
+ });
113
+ }
114
+ groups.sort((a, b) => Number(a.suppressed) - Number(b.suppressed)
115
+ || SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]
116
+ || Number(b.confidence === 'confirmed') - Number(a.confidence === 'confirmed')
117
+ || idNumber(a.findings[0] ?? '') - idNumber(b.findings[0] ?? ''));
118
+ return groups.map((g, i) => ({ id: `G${i + 1}`, ...g }));
119
+ }
120
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
121
+ export function evaluatePolicy(findings, runs, policy) {
122
+ const live = findings.filter((f) => !f.suppressed);
123
+ const suppressedCount = findings.length - live.length;
124
+ const threshold = policy.failOn === 'none' ? Infinity : SEVERITY_RANK[policy.failOn];
125
+ const eligible = live.filter((f) => policy.failOnHeuristic || f.confidence === 'confirmed');
126
+ const failing = eligible.filter((f) => SEVERITY_RANK[f.severity] >= threshold);
127
+ const failedRuns = runs.filter((r) => r.status === 'failed');
128
+ const errorsFail = policy.failOnErrors && failedRuns.length > 0;
129
+ const reasons = [];
130
+ if (failing.length) {
131
+ const noun = policy.failOnHeuristic ? plural(failing.length, 'finding', 'findings') : plural(failing.length, 'confirmed finding', 'confirmed findings');
132
+ reasons.push(`${noun} at or above ${policy.failOn} severity (${failing.map((f) => f.id).join(', ')})`);
133
+ }
134
+ if (failedRuns.length) {
135
+ const list = failedRuns.map((r) => `"${r.scenario}" on ${r.device}${r.failedAt ? ` (${r.failedAt})` : ''}`).join(', ');
136
+ reasons.push(policy.failOnErrors
137
+ ? `${plural(failedRuns.length, 'scenario run', 'scenario runs')} failed: ${list}`
138
+ : `${plural(failedRuns.length, 'scenario run', 'scenario runs')} failed but failOnErrors is off: ${list}`);
139
+ }
140
+ const notFailing = eligible.filter((f) => !failing.includes(f));
141
+ if (notFailing.length) {
142
+ reasons.push(policy.failOn === 'none'
143
+ ? `${plural(notFailing.length, 'finding', 'findings')} not counted (failOn is none)`
144
+ : `${policy.failOnHeuristic ? plural(notFailing.length, 'finding', 'findings') : plural(notFailing.length, 'confirmed finding', 'confirmed findings')} below ${policy.failOn} severity ${notFailing.length === 1 ? 'does' : 'do'} not fail the scan (failOn is ${policy.failOn})`);
145
+ }
146
+ const heuristic = live.filter((f) => f.confidence === 'heuristic');
147
+ if (!policy.failOnHeuristic && heuristic.length) {
148
+ reasons.push(`${plural(heuristic.length, 'heuristic warning', 'heuristic warnings')} ${heuristic.length === 1 ? 'is' : 'are'} not counted (failOnHeuristic is off)`);
149
+ }
150
+ if (suppressedCount)
151
+ reasons.push(`${plural(suppressedCount, 'suppressed finding', 'suppressed findings')} ${suppressedCount === 1 ? 'is' : 'are'} not counted`);
152
+ if (!reasons.length)
153
+ reasons.push('no findings and no failed scenario runs');
154
+ return { result: failing.length || errorsFail ? 'fail' : 'pass', reasons, policy };
155
+ }
156
+ //# sourceMappingURL=scan-policy.js.map
@@ -0,0 +1,145 @@
1
+ import path from 'node:path';
2
+ // The scan's HTML report: one self-contained document, no scripts, no external resources.
3
+ // Everything that came from the app under test is untrusted, so every string goes through esc().
4
+ export const esc = (v) => String(v).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
5
+ export const CSS = `
6
+ :root{--bg:#fafaf9;--fg:#1c1917;--muted:#78716c;--line:#e7e5e4;--card:#fff;--high:#b91c1c;--med:#b45309;--low:#57534e;--ok:#15803d;--warn-bg:#fef3c7}
7
+ @media (prefers-color-scheme:dark){:root{--bg:#171717;--fg:#e7e5e4;--muted:#a8a29e;--line:#3f3f46;--card:#202020;--high:#f87171;--med:#fbbf24;--low:#a8a29e;--ok:#4ade80;--warn-bg:#3a2f0b}}
8
+ body{margin:0;padding:24px;background:var(--bg);color:var(--fg);font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
9
+ main{max-width:1100px;margin:0 auto}
10
+ h1{margin:0 0 4px;font-size:24px}h2{margin:32px 0 8px;font-size:18px;border-bottom:1px solid var(--line);padding-bottom:4px}h3{margin:0 0 4px;font-size:15px}
11
+ table{border-collapse:collapse;width:100%;margin:8px 0;background:var(--card)}
12
+ th,td{border:1px solid var(--line);padding:4px 8px;text-align:left;vertical-align:top;font-size:13px}th{color:var(--muted);font-weight:600}
13
+ code{font:12px ui-monospace,SFMono-Regular,Menlo,monospace;overflow-wrap:anywhere}
14
+ .verdict{display:inline-block;padding:2px 12px;border-radius:4px;font-weight:700;font-size:20px;color:#fff}
15
+ .verdict.pass{background:var(--ok)}.verdict.fail{background:var(--high)}
16
+ .muted{color:var(--muted)}
17
+ .group{border:1px solid var(--line);border-radius:6px;background:var(--card);padding:12px;margin:12px 0}
18
+ .group.quiet{opacity:.85;border-style:dashed}
19
+ .badge{display:inline-block;padding:0 6px;border-radius:3px;font-size:12px;font-weight:600;border:1px solid currentColor}
20
+ .badge.high{color:var(--high)}.badge.medium{color:var(--med)}.badge.low{color:var(--low)}
21
+ .frames{display:flex;flex-wrap:wrap;gap:8px;margin:8px 0}
22
+ figure{margin:0;flex:0 1 220px;max-width:100%}figure img{max-width:100%;height:auto;border:1px solid var(--line);display:block}
23
+ figcaption{font-size:12px;color:var(--muted);overflow-wrap:anywhere}
24
+ td.failed{background:var(--warn-bg);color:var(--high)}td.ok{color:var(--ok)}
25
+ ol{margin:4px 0;padding-left:24px}
26
+ details{margin:6px 0}
27
+ `;
28
+ /** Path relative to the run directory, or undefined when the file is outside it. */
29
+ function relInside(dir, file) {
30
+ const rel = path.relative(dir, file);
31
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel))
32
+ return undefined;
33
+ return rel.split(path.sep).map(encodeURIComponent).join('/');
34
+ }
35
+ function figure(dir, file, caption) {
36
+ if (!file)
37
+ return '';
38
+ const rel = relInside(dir, file);
39
+ if (!rel)
40
+ return '';
41
+ return `<figure><img src="${esc(rel)}" alt="${esc(caption)}" loading="lazy"><figcaption>${esc(caption)}</figcaption></figure>`;
42
+ }
43
+ export const badge = (sev) => `<span class="badge ${esc(sev)}">${esc(sev)}</span>`;
44
+ const evidenceText = (e) => Object.entries(e).map(([k, v]) => `${k}=${v}`).join(' ');
45
+ export function findingsTable(fs) {
46
+ const rows = fs.map((f) => `<tr><td>${esc(f.id)}</td><td>${esc(f.device)}</td><td>${esc(f.viewportWidth)}</td><td>${esc(f.scenario ?? 'session')}</td>` +
47
+ `<td>${esc(f.state ?? '')}${f.states && f.states.length > 1 ? ` <span class="muted">(${esc(f.states.join(', '))})</span>` : ''}</td>` +
48
+ `<td>${badge(f.severity)}</td><td>${esc(f.confidence)}</td><td>${esc(f.confidenceScore.toFixed(2))}</td>` +
49
+ `<td><code>${esc(evidenceText(f.evidence))}</code></td><td>${esc(f.message)}</td>` +
50
+ (f.suppressed ? `<td>rule ${esc(f.suppressed.rule)}: ${esc(f.suppressed.reason)}${f.suppressed.expires ? ` (expires ${esc(f.suppressed.expires)})` : ''}</td>` : '') + '</tr>');
51
+ const suppressed = fs.some((f) => f.suppressed);
52
+ return `<table><thead><tr><th>Id</th><th>Device</th><th>Width</th><th>Scenario</th><th>State</th><th>Severity</th><th>Confidence</th><th>Score</th><th>Evidence</th><th>Message</th>${suppressed ? '<th>Suppression</th>' : ''}</tr></thead><tbody>${rows.join('')}</tbody></table>`;
53
+ }
54
+ function framesRow(dir, fs) {
55
+ const items = [];
56
+ for (const f of fs) {
57
+ items.push(figure(dir, f.frame, `${f.device} · ${f.state ?? 'session'}`));
58
+ for (const fr of f.frames ?? [])
59
+ items.push(figure(dir, fr.path, fr.label));
60
+ }
61
+ const html = items.join('');
62
+ return html ? `<div class="frames">${html}</div>` : '';
63
+ }
64
+ export function groupHtml(dir, g, byId, quiet) {
65
+ const fs = g.findings.map((id) => byId.get(id)).filter((f) => !!f);
66
+ const first = fs[0];
67
+ const det = first ? `${first.detector.name} v${first.detector.version}` : '';
68
+ const steps = first ? `<details><summary>Reproduction steps (${esc(first.id)})</summary><ol>${first.reproduction.map((s) => `<li>${esc(s)}</li>`).join('')}</ol></details>` : '';
69
+ return `<div class="group${quiet ? ' quiet' : ''}" id="${esc(g.id)}">` +
70
+ `<h3>${esc(g.id)} · ${esc(g.title)} ${badge(g.severity)}</h3>` +
71
+ `<div class="muted">kind <code>${esc(g.kind)}</code> · detector <code>${esc(det)}</code> · route <code>${esc(g.route)}</code></div>` +
72
+ `<div class="muted">scenarios: ${esc(g.scenarios.join(', '))} · devices: ${esc(g.devices.join(', '))}</div>` +
73
+ (g.target?.selector ? `<div class="muted">target <code>${esc(g.target.selector)}</code></div>` : '') +
74
+ findingsTable(fs) + framesRow(dir, fs) + steps + '</div>';
75
+ }
76
+ function matrix(r) {
77
+ const scenarios = [...new Set(r.runs.map((x) => x.scenario))];
78
+ const cell = (run) => {
79
+ if (!run)
80
+ return '<td class="muted">not run</td>';
81
+ if (run.status === 'ok')
82
+ return `<td class="ok">ok · ${esc(run.states.length)} states · ${esc(run.findings.length)} findings</td>`;
83
+ const early = run.failedAt === 'context' || run.failedAt === 'load' || run.failedAt === 'setup';
84
+ return `<td class="failed"><strong>failed at ${esc(run.failedAt ?? 'unknown')}</strong>${early ? ' · failed before checks completed' : ''}: ${esc(run.error?.message ?? 'no message')}</td>`;
85
+ };
86
+ const rows = scenarios.map((s) => `<tr><th>${esc(s)}</th>${r.devices.map((d) => cell(r.runs.find((x) => x.scenario === s && x.device === d))).join('')}</tr>`);
87
+ return `<table><thead><tr><th>Scenario</th>${r.devices.map((d) => `<th>${esc(d)}</th>`).join('')}</tr></thead><tbody>${rows.join('')}</tbody></table>`;
88
+ }
89
+ function exploration(r) {
90
+ const runs = r.runs.filter((x) => x.states.some((s) => s.id !== 's0') || x.decisions.length);
91
+ if (!runs.length)
92
+ return '';
93
+ const parts = runs.map((run) => {
94
+ const states = run.states.map((s) => `<tr><td>${esc(s.id)}</td><td>${esc(s.label)}</td><td>${esc(s.path.join(' → '))}</td><td>${esc(s.status)}</td><td>${esc(s.restore ?? '')}</td><td>${esc(s.findings.join(', '))}</td><td>${esc((s.blocked ?? []).join(', '))}</td></tr>`).join('');
95
+ const skipped = run.decisions.filter((d) => d.verdict === 'skip').map((d) => `<tr><td>${esc(d.role)}</td><td>${esc(d.name)}</td><td>${esc(d.context ?? '')}</td><td>${esc(d.reason)}</td></tr>`).join('');
96
+ return `<h3>${esc(run.scenario)} @ ${esc(run.device)}</h3>` +
97
+ `<table><thead><tr><th>State</th><th>Label</th><th>Path</th><th>Status</th><th>Restore</th><th>Findings</th><th>Blocked requests</th></tr></thead><tbody>${states}</tbody></table>` +
98
+ (skipped ? `<p class="muted">Skipped controls${run.decisionsOmitted ? ` (${esc(run.decisionsOmitted)} more decisions omitted)` : ''}</p><table><thead><tr><th>Role</th><th>Name</th><th>Context</th><th>Reason</th></tr></thead><tbody>${skipped}</tbody></table>` : '') +
99
+ (run.limits.length ? `<ul>${run.limits.map((l) => `<li>${esc(l)}</li>`).join('')}</ul>` : '');
100
+ });
101
+ return `<h2>Exploration</h2>${parts.join('')}`;
102
+ }
103
+ export function scanReportHtml(result, dir) {
104
+ const r = result;
105
+ const byId = new Map(r.findings.map((f) => [f.id, f]));
106
+ const confirmed = r.groups.filter((g) => !g.suppressed && g.confidence === 'confirmed');
107
+ const heuristic = r.groups.filter((g) => !g.suppressed && g.confidence === 'heuristic');
108
+ const suppressed = r.groups.filter((g) => g.suppressed);
109
+ const failedRuns = r.runs.filter((x) => x.status === 'failed').length;
110
+ const p = r.verdict.policy;
111
+ const list = (gs, quiet, none) => gs.length ? gs.map((g) => groupHtml(dir, g, byId, quiet)).join('') : `<p class="muted">${esc(none)}</p>`;
112
+ const suppressedFindings = r.findings.filter((f) => f.suppressed);
113
+ const suppressedSection = suppressed.length
114
+ ? suppressed.map((g) => groupHtml(dir, g, byId, true)).join('')
115
+ : '<p class="muted">No group is fully suppressed.</p>';
116
+ // A partly suppressed group appears above with its live members; list its suppressed findings here too.
117
+ const partial = suppressedFindings.filter((f) => !suppressed.some((g) => g.findings.includes(f.id)));
118
+ const partialHtml = partial.length ? `<h3>Suppressed findings in groups that are still reported</h3>${findingsTable(partial)}` : '';
119
+ const supRows = r.suppressions.map((s) => {
120
+ const label = s.status === 'applied' ? `applied to ${s.matched.length} findings (${s.matched.join(', ')})`
121
+ : s.status === 'unmatched' ? 'stale — matched nothing in this run'
122
+ : s.status === 'expired' ? `expired on ${s.expires ?? 'an earlier date'}`
123
+ : 'not evaluated (scope not in this run)';
124
+ return `<tr><td>${esc(s.rule)}</td><td>${esc(s.reason)}</td><td>${esc(label)}</td><td>${esc(s.expires ?? '')}</td></tr>`;
125
+ }).join('');
126
+ const jsonRel = relInside(dir, r.reports.json) ?? r.reports.json;
127
+ return `<!doctype html>
128
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
129
+ <title>Scan ${esc(r.id)}</title><style>${CSS}</style></head><body><main>
130
+ <h1>Scan ${esc(r.id)} <span class="verdict ${r.verdict.result === 'pass' ? 'pass' : 'fail'}">${r.verdict.result.toUpperCase()}</span></h1>
131
+ <ul>${r.verdict.reasons.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>
132
+ <p class="muted">Policy: failOn=${esc(p.failOn)}, failOnErrors=${esc(p.failOnErrors)}, failOnHeuristic=${esc(p.failOnHeuristic)}<br>
133
+ Started ${esc(r.startedAt)} · ${esc(r.ms)} ms · devices: ${esc(r.devices.join(', '))} · exploration ${r.explore ? 'on' : 'off'}<br>
134
+ ${esc(confirmed.length)} confirmed, ${esc(heuristic.length)} heuristic, ${esc(suppressed.length)} suppressed groups · ${esc(failedRuns)} failed runs</p>
135
+ <h2>Scenario matrix</h2>${matrix(r)}
136
+ <h2>Confirmed problems</h2>${list(confirmed, false, 'None.')}
137
+ <h2>Heuristic warnings</h2>${list(heuristic, true, 'None.')}
138
+ <h2>Suppressed findings</h2>${suppressedSection}${partialHtml}
139
+ <h2>Suppressions</h2>${r.suppressions.length ? `<table><thead><tr><th>Rule</th><th>Reason</th><th>Status</th><th>Expires</th></tr></thead><tbody>${supRows}</tbody></table>` : '<p class="muted">No suppressions configured.</p>'}
140
+ ${exploration(r)}
141
+ <p class="muted">Full data, including every measurement and reproduction, is in the JSON report: <code>${esc(jsonRel)}</code></p>
142
+ </main></body></html>
143
+ `;
144
+ }
145
+ //# sourceMappingURL=scan-report.js.map