eaa-kit 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +66 -11
  2. package/dist/astro/index.d.ts +1 -1
  3. package/dist/astro/index.js +1 -1
  4. package/dist/audit/runners/worker.js +5 -1
  5. package/dist/audit-DK2StT1_.js +2 -0
  6. package/dist/{audit-CPoZMXGM.js → audit-e_uixhPG.js} +123 -54
  7. package/dist/baseline-BscHfFSW.js +2 -0
  8. package/dist/{baseline-DB9CZGnV.js → baseline-D2qWmUC6.js} +40 -12
  9. package/dist/checklist-BtgkxMEa.js +44 -0
  10. package/dist/cli/index.js +153 -25
  11. package/dist/{collect-CFM8gEVv.js → collect-Cu1q9NTB.js} +34 -3
  12. package/dist/command-CZKp9Wi_.js +251 -0
  13. package/dist/{remediation-Dtowi2EC.js → completeness-Dwq3CPB3.js} +61 -43
  14. package/dist/{component-C3GL1Mnu.js → component-ChisoFyY.js} +1 -1
  15. package/dist/{component-DKd3EHOg.js → component-DcCn3BJS.js} +1 -1
  16. package/dist/coverage-7bQqWNfN.js +2 -0
  17. package/dist/{coverage-B4IBKMO8.js → coverage-BD2_CYns.js} +46 -8
  18. package/dist/{crawl-BiI1Lau_.js → crawl-BHkJmUkA.js} +119 -11
  19. package/dist/eleventy/index.d.ts +1 -1
  20. package/dist/eleventy/index.js +1 -1
  21. package/dist/fingerprint-BjYV_0F7.js +67 -0
  22. package/dist/{html-BGTO3ypW.js → html-Bmh7X_sM.js} +52 -30
  23. package/dist/{impact-DZt2oBCP.js → impact-luGCnN8F.js} +11 -1
  24. package/dist/index.d.ts +187 -5
  25. package/dist/index.js +3 -3
  26. package/dist/{init-DIWDE35F.js → init-BjKYttxc.js} +10 -4
  27. package/dist/{jsdom-DCpGSLfW.js → jsdom-CQar6OQS.js} +12 -4
  28. package/dist/jsdom-DMvU2h-f.js +3 -0
  29. package/dist/{json-QQuFIw1W.js → json-HJDgJp09.js} +1 -1
  30. package/dist/{json-DjEvy1nX.js → json-PNg6BeXp.js} +13 -18
  31. package/dist/load-Bc_xDGAh.js +2 -0
  32. package/dist/{load-5wRGLvub.js → load-vZdV_fD1.js} +137 -8
  33. package/dist/nuxt/index.d.ts +1 -1
  34. package/dist/nuxt/index.js +1 -1
  35. package/dist/{playwright-BWniOain.js → playwright-C-miJP2b.js} +32 -7
  36. package/dist/{pool-BMevaLWD.js → pool-dkexq5xo.js} +52 -2
  37. package/dist/{render-DrvXRCEn.js → render-CtsqR0k-.js} +150 -11
  38. package/dist/{result-DoamKFsp.js → result-DfUru6y_.js} +60 -3
  39. package/dist/review-CdMK2GpQ.js +161 -0
  40. package/dist/review-DIbOTmOM.js +2 -0
  41. package/dist/{run-DB34BSOZ.js → run-D7hdVXLA.js} +1 -1
  42. package/dist/{run-BMASMmwO.d.ts → run-YiqyQA2b.d.ts} +17 -0
  43. package/dist/{sarif-SR3_lLYd.js → sarif-BKvq_AUm.js} +25 -14
  44. package/dist/{schema-is6CGX2D.js → schema-DJSF4K05.js} +15 -1
  45. package/dist/statement/templates/es.en.md +125 -0
  46. package/dist/statement/templates/es.es.md +127 -0
  47. package/dist/statement/templates/fr.en.md +128 -0
  48. package/dist/statement/templates/fr.fr.md +131 -0
  49. package/dist/statement/templates/it.en.md +127 -0
  50. package/dist/statement/templates/it.it.md +130 -0
  51. package/dist/statement/templates/nl.en.md +125 -0
  52. package/dist/statement/templates/nl.nl.md +127 -0
  53. package/dist/{text-CKKpzkYM.js → text-BF1LHMiV.js} +22 -2
  54. package/dist/vite/index.d.ts +1 -1
  55. package/dist/vite/index.js +1 -1
  56. package/dist/webpack/index.d.ts +1 -1
  57. package/dist/webpack/index.js +1 -1
  58. package/dist/worksheet-eUjwYLxC.js +63 -0
  59. package/package.json +7 -3
  60. package/dist/audit-CpXH2Mk8.js +0 -2
  61. package/dist/baseline-22Y1NWxM.js +0 -2
  62. package/dist/command-D8l_oYbV.js +0 -77
  63. package/dist/fingerprint-DRoneAjj.js +0 -20
  64. package/dist/jsdom-4IMzv0eE.js +0 -3
@@ -1,6 +1,20 @@
1
- import { i as stripBom } from "./collect-CFM8gEVv.js";
1
+ import { a as stripBom } from "./collect-Cu1q9NTB.js";
2
2
  /** Requests in flight at once. Politeness, not throughput. */
3
3
  const REQUEST_CONCURRENCY = 4;
4
+ /**
5
+ * Largest response body this will read, in bytes.
6
+ *
7
+ * A crawl reads whatever the server sends, and nothing obliges a server to send
8
+ * something reasonable — a misconfigured export endpoint, a log streamed as
9
+ * text/html, or a host that simply does not stop. `response.text()` buffers all
10
+ * of it before anyone can object, so the ceiling has to be applied while the
11
+ * body is still arriving.
12
+ *
13
+ * Matches the on-disk page limit: the two paths audit the same kind of
14
+ * document, and a page that would be declined off disk should not be accepted
15
+ * because it arrived over HTTP instead.
16
+ */
17
+ const MAX_BODY_BYTES = 33554432;
4
18
  var CrawlError = class extends Error {
5
19
  name = "CrawlError";
6
20
  };
@@ -79,7 +93,7 @@ function urlsFromSitemap(xml, origin) {
79
93
  return urls;
80
94
  }
81
95
  /** One request, with a timeout, returning HTML or a reason it is not a page. */
82
- async function fetchPage(url, impl, timeoutMs, origin) {
96
+ async function fetchPage(url, impl, timeoutMs, origin, maxBodyBytes, headers) {
83
97
  const controller = new AbortController();
84
98
  const timer = setTimeout(() => controller.abort(), timeoutMs);
85
99
  try {
@@ -88,7 +102,8 @@ async function fetchPage(url, impl, timeoutMs, origin) {
88
102
  redirect: "follow",
89
103
  headers: {
90
104
  accept: "text/html,application/xhtml+xml",
91
- "user-agent": "eaa-kit"
105
+ "user-agent": "eaa-kit",
106
+ ...headers
92
107
  }
93
108
  });
94
109
  if (!response.ok) return {
@@ -106,11 +121,16 @@ async function fetchPage(url, impl, timeoutMs, origin) {
106
121
  ok: false,
107
122
  reason: `not HTML (${type.split(";")[0] || "no content-type"})`
108
123
  };
124
+ const body = await readCapped(response, maxBodyBytes);
125
+ if (body === void 0) return {
126
+ ok: false,
127
+ reason: `larger than the ${maxBodyBytes} byte limit for one page`
128
+ };
109
129
  return {
110
130
  ok: true,
111
131
  value: {
112
132
  url: finalUrl,
113
- html: await response.text()
133
+ html: body
114
134
  }
115
135
  };
116
136
  } catch (cause) {
@@ -123,6 +143,51 @@ async function fetchPage(url, impl, timeoutMs, origin) {
123
143
  clearTimeout(timer);
124
144
  }
125
145
  }
146
+ /**
147
+ * A response body, up to a limit, or undefined when it runs past it.
148
+ *
149
+ * Streamed rather than buffered, because `response.text()` has already read
150
+ * everything by the time it could be checked, and a content-length header is
151
+ * both optional and unverified — a chunked response carries no length at all,
152
+ * and one that carries a length is not obliged to tell the truth. Counting the
153
+ * bytes as they arrive is the only check that holds either way, and the body is
154
+ * cancelled the moment it goes over so nothing keeps arriving.
155
+ */
156
+ async function readCapped(response, limit) {
157
+ const body = response.body;
158
+ if (!body) {
159
+ const text = await response.text();
160
+ return text.length > limit ? void 0 : text;
161
+ }
162
+ const reader = body.getReader();
163
+ const chunks = [];
164
+ let total = 0;
165
+ try {
166
+ while (true) {
167
+ const { done, value } = await reader.read();
168
+ if (done) break;
169
+ if (value === void 0) continue;
170
+ total += value.byteLength;
171
+ if (total > limit) {
172
+ await reader.cancel();
173
+ return;
174
+ }
175
+ chunks.push(value);
176
+ }
177
+ } finally {
178
+ reader.releaseLock();
179
+ }
180
+ return new TextDecoder().decode(concat(chunks, total));
181
+ }
182
+ function concat(chunks, total) {
183
+ const joined = new Uint8Array(total);
184
+ let at = 0;
185
+ for (const chunk of chunks) {
186
+ joined.set(chunk, at);
187
+ at += chunk.byteLength;
188
+ }
189
+ return joined;
190
+ }
126
191
  /** Paths robots.txt disallows for us. Only the wildcard group is read. */
127
192
  function disallowedPaths(robots) {
128
193
  const lines = robots.split(/\r?\n/).map((line) => line.replace(/#.*$/, "").trim());
@@ -149,12 +214,22 @@ function disallowedPaths(robots) {
149
214
  * that is not a page. A site that does not publish one is the ordinary case,
150
215
  * not a failure, so it comes back empty either way.
151
216
  */
152
- async function fetchSiteFile(entry, impl, name) {
217
+ async function fetchSiteFile(entry, impl, name, timeoutMs, maxBodyBytes, headers) {
218
+ const controller = new AbortController();
219
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
153
220
  try {
154
- const response = await impl(new URL(name, entry).href, { redirect: "follow" });
155
- return response.ok ? await response.text() : void 0;
221
+ const response = await impl(new URL(name, entry).href, {
222
+ redirect: "follow",
223
+ signal: controller.signal,
224
+ ...headers === void 0 ? {} : { headers }
225
+ });
226
+ if (!response.ok) return void 0;
227
+ if (new URL(response.url || new URL(name, entry).href).origin !== entry.origin) return void 0;
228
+ return await readCapped(response, maxBodyBytes);
156
229
  } catch {
157
230
  return;
231
+ } finally {
232
+ clearTimeout(timer);
158
233
  }
159
234
  }
160
235
  /**
@@ -169,8 +244,9 @@ async function crawlSite(entry, options = {}) {
169
244
  const maxPages = options.maxPages ?? 200;
170
245
  const maxDepth = options.maxDepth ?? 3;
171
246
  const timeoutMs = options.timeoutMs ?? 15e3;
247
+ const maxBodyBytes = options.maxBodyBytes ?? MAX_BODY_BYTES;
172
248
  const failures = [];
173
- const robots = options.ignoreRobots ? void 0 : await fetchSiteFile(entry, impl, "/robots.txt");
249
+ const robots = options.ignoreRobots ? void 0 : await fetchSiteFile(entry, impl, "/robots.txt", timeoutMs, maxBodyBytes, options.headers);
174
250
  const blocked = robots === void 0 ? [] : disallowedPaths(robots);
175
251
  const allowed = (url) => !blocked.some((path) => url.pathname.startsWith(path));
176
252
  let discovery = "links";
@@ -184,7 +260,7 @@ async function crawlSite(entry, options = {}) {
184
260
  depth
185
261
  });
186
262
  };
187
- const sitemap = await fetchSiteFile(entry, impl, options.sitemap ?? "/sitemap.xml");
263
+ const sitemap = await fetchSiteFile(entry, impl, options.sitemap ?? "/sitemap.xml", timeoutMs, maxBodyBytes, options.headers);
188
264
  const listed = sitemap === void 0 ? [] : urlsFromSitemap(sitemap, entry);
189
265
  if (listed.length > 0) {
190
266
  discovery = "sitemap";
@@ -192,11 +268,12 @@ async function crawlSite(entry, options = {}) {
192
268
  }
193
269
  enqueue(entry, 0);
194
270
  const pages = [];
271
+ const redirects = [];
195
272
  while (queue.length > 0 && pages.length < maxPages) {
196
273
  const batch = queue.splice(0, Math.min(REQUEST_CONCURRENCY, maxPages - pages.length));
197
274
  const results = await Promise.all(batch.map(async (item) => ({
198
275
  item,
199
- result: await fetchPage(item.url, impl, timeoutMs, entry.origin)
276
+ result: await fetchPage(item.url, impl, timeoutMs, entry.origin, maxBodyBytes, options.headers)
200
277
  })));
201
278
  for (const { item, result } of results) {
202
279
  if (!result.ok) {
@@ -207,6 +284,10 @@ async function crawlSite(entry, options = {}) {
207
284
  continue;
208
285
  }
209
286
  const { url, html } = result.value;
287
+ if (url.href !== item.url.href) redirects.push({
288
+ requested: item.url.href,
289
+ landedOn: url.href
290
+ });
210
291
  pages.push({
211
292
  absolutePath: url.href,
212
293
  relativePath: pageIdentity(url),
@@ -220,11 +301,38 @@ async function crawlSite(entry, options = {}) {
220
301
  pages: byIdentity(pages),
221
302
  origin: entry.origin,
222
303
  failures,
304
+ redirects,
223
305
  truncated: queue.length > 0,
224
306
  discovery
225
307
  };
226
308
  }
227
309
  /**
310
+ * The requested URLs that a login wall swallowed, or none.
311
+ *
312
+ * A redirect on its own says nothing: sites normalise trailing slashes and send
313
+ * `/` to `/en/` all day. What is not ordinary is *several* different URLs
314
+ * answering at one address, or a whole crawl coming back as the single page the
315
+ * entry was redirected to. Both are the shape of a sign-in page standing in
316
+ * front of the site, and both otherwise produce a report about a page nobody
317
+ * asked for that says it audited everything it set out to.
318
+ *
319
+ * Returns what was asked for and never reached, which is the one thing the run
320
+ * has to say out loud. Deliberately shy of naming the cause: a single-page site
321
+ * that redirects its entry looks identical from here, and being told where the
322
+ * run actually landed is useful either way.
323
+ */
324
+ function collapsedOnto(result) {
325
+ if (result.redirects.length === 0) return [];
326
+ const byDestination = /* @__PURE__ */ new Map();
327
+ for (const redirect of result.redirects) byDestination.set(redirect.landedOn, [...byDestination.get(redirect.landedOn) ?? [], redirect.requested]);
328
+ const collapsed = [];
329
+ for (const [landedOn, requested] of byDestination) if (requested.length > 1 || result.pages.length === 1) for (const from of requested) collapsed.push({
330
+ requested: from,
331
+ landedOn
332
+ });
333
+ return collapsed;
334
+ }
335
+ /**
228
336
  * One page per identity, in a stable order.
229
337
  *
230
338
  * The queue already refuses a URL it has seen, so this catches the case the
@@ -241,4 +349,4 @@ function byIdentity(pages) {
241
349
  }).sort((a, b) => a.relativePath.localeCompare(b.relativePath));
242
350
  }
243
351
  //#endregion
244
- export { CrawlError, crawlSite, parseEntryUrl };
352
+ export { CrawlError, collapsedOnto, crawlSite, parseEntryUrl };
@@ -1,4 +1,4 @@
1
- import { n as IntegrationOptions, t as BuildAuditError } from "../run-BMASMmwO.js";
1
+ import { n as IntegrationOptions, t as BuildAuditError } from "../run-YiqyQA2b.js";
2
2
  //#region src/eleventy/index.d.ts
3
3
  /** What `eleventy.after` is given. Only two of its fields are read. */
4
4
  interface EleventyAfterEvent {
@@ -1,4 +1,4 @@
1
- import { n as auditBuild, r as stderrLogger, t as BuildAuditError } from "../run-DB34BSOZ.js";
1
+ import { n as auditBuild, r as stderrLogger, t as BuildAuditError } from "../run-D7hdVXLA.js";
2
2
  import path from "node:path";
3
3
  //#region src/eleventy/index.ts
4
4
  /**
@@ -0,0 +1,67 @@
1
+ import { createHash } from "node:crypto";
2
+ //#region src/audit/fingerprint.ts
3
+ /**
4
+ * A stable identity for one violating element.
5
+ *
6
+ * Derived from the rule, the selector and the element's own opening tag, and
7
+ * deliberately not from the file it was found in. Two consumers need this and
8
+ * they need it to agree: SARIF, so that moving a page does not close one code
9
+ * scanning alert and open an identical one, and the baseline, so that an
10
+ * accepted violation stays accepted when the surrounding page changes.
11
+ *
12
+ * Sixteen hex characters. This identifies a defect for humans and tooling, not
13
+ * a secret, and a full digest in every entry would make a baseline file for a
14
+ * large site unreadable.
15
+ */
16
+ function elementFingerprint(ruleId, selector, html) {
17
+ return createHash("sha256").update(`${ruleId}\n${selector}\n${openingTag(html)}`).digest("hex").slice(0, 16);
18
+ }
19
+ /**
20
+ * The element's own tag, without anything nested inside it.
21
+ *
22
+ * axe-core hands back the failing element's outerHTML, which for a leaf like
23
+ * `<img>` is the element and for a container is the element and every
24
+ * descendant it has. Hashing all of that made the identity of a container
25
+ * depend on its contents — and the container that matters here is `<html>`,
26
+ * which every document-level rule fails against: `html-has-lang`,
27
+ * `document-title`, `landmark-one-main`, `page-has-heading-one`.
28
+ *
29
+ * Their outerHTML is the whole page. So adding one paragraph anywhere changed
30
+ * the fingerprint of every document-level violation on that page, and all three
31
+ * consumers believed it:
32
+ *
33
+ * - the baseline stopped suppressing barriers it had accepted, and the build
34
+ * went red on a page whose only change was a typo fix;
35
+ * - `diff` reported the same untouched barrier as both new and **fixed**, which
36
+ * is the one thing it exists to refuse to do;
37
+ * - SARIF churned its partialFingerprints, so code scanning closed an alert and
38
+ * opened an identical one on every edit.
39
+ *
40
+ * The identity of an element is its own tag and attributes. Where two elements
41
+ * share those, axe-core's selector already tells them apart — it qualifies
42
+ * ambiguous matches with `:nth-child(…)` — so nothing that was distinguishable
43
+ * before stops being distinguishable now.
44
+ *
45
+ * Anything that is not an element — the empty string a rule with no attached
46
+ * node carries — is returned unchanged, so those keep the identity they had.
47
+ */
48
+ function openingTag(html) {
49
+ const trimmed = html.trim();
50
+ if (!trimmed.startsWith("<")) return trimmed;
51
+ let quote;
52
+ for (let i = 1; i < trimmed.length; i += 1) {
53
+ const char = trimmed[i];
54
+ if (quote !== void 0) {
55
+ if (char === quote) quote = void 0;
56
+ continue;
57
+ }
58
+ if (char === "\"" || char === "'") {
59
+ quote = char;
60
+ continue;
61
+ }
62
+ if (char === ">") return trimmed.slice(0, i + 1);
63
+ }
64
+ return trimmed;
65
+ }
66
+ //#endregion
67
+ export { elementFingerprint as t };
@@ -1,10 +1,12 @@
1
- import { a as impactRank, i as impactLabel, r as countAtOrAbove } from "./impact-DZt2oBCP.js";
2
- import { i as escapeText, n as count, o as standardsReference, r as escapeAttribute, t as collapse } from "./text-CKKpzkYM.js";
1
+ import { a as impactLabel, r as byImpactThenRule } from "./impact-luGCnN8F.js";
2
+ import { i as escapeText, n as count, r as escapeAttribute, s as standardsReference, t as collapse } from "./text-BF1LHMiV.js";
3
3
  import { t as TOOL_VERSION } from "./version-B3v4rNoG.js";
4
- import { a as isShared, i as groupIssues, n as blindRules, o as discoveryLabel, r as coverageParts, s as missedParts, t as remediationFor } from "./remediation-Dtowi2EC.js";
5
- import { r as componentPath } from "./component-C3GL1Mnu.js";
6
- import { i as understandingUrl, r as manualCheckFor, t as buildCoverage } from "./coverage-B4IBKMO8.js";
7
- import { n as buildSummary } from "./json-DjEvy1nX.js";
4
+ import { c as runEngine } from "./result-DfUru6y_.js";
5
+ import { l as reviewSentence } from "./review-CdMK2GpQ.js";
6
+ import { r as componentPath } from "./component-ChisoFyY.js";
7
+ import { i as reviewSummary, n as buildCoverage, o as manualCheckFor, s as understandingUrl } from "./coverage-BD2_CYns.js";
8
+ import { a as blindRules, c as isShared, i as remediationFor, l as issueTotals, n as missedParts, o as coverageParts, s as groupIssues, t as discoveryLabel } from "./completeness-Dwq3CPB3.js";
9
+ import { n as buildSummary } from "./json-PNg6BeXp.js";
8
10
  import axe from "axe-core";
9
11
  //#region src/audit/report/html.ts
10
12
  /**
@@ -36,8 +38,8 @@ const MAX_NODES = 5;
36
38
  /** Longest element markup shown before it is truncated. */
37
39
  const MAX_SNIPPET = 200;
38
40
  function buildHtmlReport(audits, options) {
39
- const engine = audits[0]?.engine ?? "jsdom";
40
- const failing = countAtOrAbove(audits, options.failOn);
41
+ const engine = runEngine(audits);
42
+ const totals = buildSummary(audits, options.failOn);
41
43
  const generatedAt = (options.now ?? /* @__PURE__ */ new Date()).toISOString();
42
44
  const title = `Accessibility audit · ${options.directory}`;
43
45
  return `<!doctype html>
@@ -54,15 +56,15 @@ ${STYLES}
54
56
  <body>
55
57
  <main>
56
58
  <h1>Accessibility audit</h1>
57
- ${verdict(audits, failing, options)}
59
+ ${verdict(totals, options)}
58
60
  ${scoreboard(audits)}
59
61
  ${issues(audits, options)}
60
62
  ${runDetails(audits, engine, generatedAt, options)}
61
63
  ${notMeasured(options)}
62
- ${summary(audits, failing, options)}
64
+ ${summary(totals, options)}
63
65
  ${pages(audits)}
64
66
  ${notEvaluated(audits)}
65
- ${coverageSection(audits)}
67
+ ${coverageSection(audits, options)}
66
68
  ${footer()}
67
69
  </main>
68
70
  </body>
@@ -77,11 +79,11 @@ ${footer()}
77
79
  * which would be an embarrassing thing for this document in particular to get
78
80
  * wrong.
79
81
  */
80
- function verdict(audits, failing, options) {
81
- const unaudited = audits.filter((audit) => audit.error).length;
82
+ function verdict(totals, options) {
83
+ const unaudited = totals.pagesNotAudited;
82
84
  if (unaudited > 0) return banner("broken", "Could not finish", `${count(unaudited, "page")} could not be audited, so this run reached no verdict.`);
83
- if (failing > 0) return banner("fail", "Violations found", `${count(failing, "violation")} at or above ${escapeText(options.failOn)}.`);
84
- const below = totalViolations(audits) - failing;
85
+ if (totals.failing > 0) return banner("fail", "Violations found", `${count(totals.failing, "violation")} at or above ${escapeText(options.failOn)}.`);
86
+ const below = totals.violations - totals.failing;
85
87
  if (below > 0) return banner("pass", "No violations at the threshold", `${count(below, "violation")} below ${escapeText(options.failOn)}, which do not fail the run.`);
86
88
  const completeness = options.completeness;
87
89
  if (completeness && !completeness.complete) return banner("partial", "No violations found, but the whole site was not measured", `${escapeText(missedParts(completeness).join(", "))}. This report describes ${count(completeness.audited, "page")}, not the site.`);
@@ -137,13 +139,12 @@ function notMeasured(options) {
137
139
  * the kind of bug nobody notices until a client does, so there is one place
138
140
  * that counts.
139
141
  */
140
- function summary(audits, failing, options) {
141
- const totals = buildSummary(audits, options.failOn);
142
+ function summary(totals, options) {
142
143
  const impacts = IMPACT_ORDER.filter((impact) => totals.byImpact[impact] > 0).map((impact) => ` <li><span class="badge ${escapeAttribute(impact)}">${escapeText(impact)}</span> ${totals.byImpact[impact]}</li>`).join("\n");
143
144
  return `<h2>Summary</h2>
144
145
  <ul class="counts">
145
146
  <li><strong>${count(totals.violations, "violation")}</strong> on ${totals.pagesWithViolations} of ${count(totals.pages, "page")}, across ${count(totals.violatingElements, "element")}</li>
146
- <li><strong>${failing}</strong> at or above ${escapeText(options.failOn)}</li>
147
+ <li><strong>${totals.failing}</strong> at or above ${escapeText(options.failOn)}</li>
147
148
  <li><strong>${totals.needsReview}</strong> ${totals.needsReview === 1 ? "rule needs" : "rules need"} manual review</li>
148
149
  <li><strong>${totals.notEvaluated}</strong> ${totals.notEvaluated === 1 ? "rule was" : "rules were"} not evaluated by this engine</li>
149
150
  ${totals.accepted > 0 ? ` <li><strong>${totals.accepted}</strong> ${totals.accepted === 1 ? "element is" : "elements are"} accepted by the baseline, and not counted above</li>` : ""}
@@ -236,8 +237,8 @@ ${manualBlock(ruleId, finding)}
236
237
  * majority of WCAG, and a percentage would present a limit of automated testing
237
238
  * as a property of this site.
238
239
  */
239
- function coverageSection(audits) {
240
- const coverage = buildCoverage(audits);
240
+ function coverageSection(audits, options) {
241
+ const coverage = buildCoverage(audits, void 0, options.review);
241
242
  const rows = coverage.criteria.map((criterion) => {
242
243
  const url = understandingUrl(criterion.number);
243
244
  const name = escapeText(`${criterion.number} ${criterion.title}`);
@@ -245,7 +246,7 @@ function coverageSection(audits) {
245
246
  return ` <tr class="${escapeAttribute(criterion.status)}">
246
247
  <td>${linked}</td>
247
248
  <td>${escapeText(criterion.level)}</td>
248
- <td>${statusText(criterion)}</td>
249
+ <td>${statusText(criterion)}${reviewCell(criterion)}</td>
249
250
  </tr>`;
250
251
  }).join("\n");
251
252
  return `<h2>Coverage of WCAG 2.2 AA</h2>
@@ -263,6 +264,7 @@ function coverageSection(audits) {
263
264
  of WCAG cannot be automated, and a percentage here would present that limit of automated
264
265
  testing as though it were a measurement of this site.
265
266
  </p>
267
+ ${reviewParagraph(coverage)}
266
268
  <div class="scroll">
267
269
  <table class="coverage">
268
270
  <thead><tr><th>Success criterion</th><th>Level</th><th>This run</th></tr></thead>
@@ -272,6 +274,20 @@ ${rows}
272
274
  </table>
273
275
  </div>`;
274
276
  }
277
+ /**
278
+ * What a person recorded, where a review was supplied.
279
+ *
280
+ * Its own paragraph, under the four counts and visibly apart from them: the
281
+ * counts are what an engine measured and this is what somebody says they
282
+ * checked. This document is the one that leaves the building, so the difference
283
+ * between the two has to survive being quoted.
284
+ */
285
+ function reviewParagraph(coverage) {
286
+ const summary = reviewSummary(coverage);
287
+ if (summary === void 0) return "";
288
+ return `<p class="note review">${escapeText(summary)} eaa-kit cannot check that anything
289
+ recorded in a review is true; it reports it as the claim it is.</p>`;
290
+ }
275
291
  function statusText(criterion) {
276
292
  const browser = criterion.browserWouldAnswer ? " <em>(a real browser would answer this)</em>" : "";
277
293
  switch (criterion.status) {
@@ -281,6 +297,16 @@ function statusText(criterion) {
281
297
  case "no-automated-rule": return "No automated rule exists; a person must check it";
282
298
  }
283
299
  }
300
+ /**
301
+ * What a person recorded about this criterion, in the cell that says what the
302
+ * run reached. The same sentence the console report prints, from the same
303
+ * function, so two documents of one run cannot word it differently.
304
+ */
305
+ function reviewCell(criterion) {
306
+ const review = criterion.review;
307
+ if (review === void 0) return "";
308
+ return `<br><span class="reviewed">${escapeText(reviewSentence(review))}</span>`;
309
+ }
284
310
  function footer() {
285
311
  return `<hr>
286
312
  <footer>
@@ -303,12 +329,6 @@ function standards(finding) {
303
329
  function standardsText(finding) {
304
330
  return escapeText(standardsReference(finding.successCriteria, finding.enClauses));
305
331
  }
306
- function totalViolations(audits) {
307
- return audits.reduce((total, audit) => total + audit.violations.length, 0);
308
- }
309
- function byImpactThenRule(a, b) {
310
- return impactRank(a.impact) - impactRank(b.impact) || a.ruleId.localeCompare(b.ruleId);
311
- }
312
332
  const STYLES = `:root { color-scheme: light dark; }
313
333
  body { margin: 0; background: #ffffff; color: #1a1a1a;
314
334
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; line-height: 1.6; }
@@ -362,6 +382,8 @@ table.coverage tr.evaluated td:last-child { color: #216e39; }
362
382
  table.coverage em { font-style: normal; opacity: 0.75; }
363
383
  ul.unreachable li { margin-bottom: 0.2rem; overflow-wrap: anywhere; }
364
384
  code.selector { color: #4a4a4a; }
385
+ .reviewed { color: #4a4a4a; font-size: 0.95em; }
386
+ p.note.review { border-left: 3px solid #c9c9c9; padding-left: 0.75rem; }
365
387
  p.clean { color: #216e39; }
366
388
  p.note { font-size: 0.95rem; }
367
389
  hr { border: 0; border-top: 1px solid #d4d4d4; margin: 3rem 0 1.5rem; }
@@ -401,7 +423,8 @@ ul.page-list { margin: 0.35rem 0 0; padding-left: 1.25rem; }
401
423
  pre { background: #1e1e1e; }
402
424
  li.finding { border-color: #3a3a3a; }
403
425
  p.standards, p.coverage, .reason, li.more, code.selector, footer,
404
- p.accepted-heading, ul.accepted { color: #b6b6b6; }
426
+ p.accepted-heading, ul.accepted, .reviewed { color: #b6b6b6; }
427
+ p.note.review { border-left-color: #555; }
405
428
  p.clean { color: #7ee2a8; }
406
429
  .verdict.pass { background: #10240f; border-color: #7ee2a8; }
407
430
  .verdict.fail { background: #2b1111; border-color: #ff9d9d; }
@@ -467,8 +490,7 @@ function scoreboard(audits) {
467
490
  function issues(audits, options) {
468
491
  const found = groupIssues(audits);
469
492
  if (found.length === 0) return "";
470
- const elements = found.reduce((total, issue) => total + issue.elements.length, 0);
471
- const occurrences = found.reduce((total, issue) => total + issue.occurrences, 0);
493
+ const { elements, occurrences } = issueTotals(found);
472
494
  const intro = occurrences === elements ? `${count(elements, "distinct element")} to fix.` : `${count(occurrences, "violation")} across the site, from ${count(elements, "distinct element")}.`;
473
495
  return `<h2 id="issues">What to fix</h2>
474
496
  <p class="intro">${escapeText(intro)}</p>
@@ -36,6 +36,16 @@ function impactRank(impact) {
36
36
  function impactLabel(impact) {
37
37
  return impact !== null && isImpactLevel(impact) ? impact : "unclassified";
38
38
  }
39
+ /**
40
+ * Worst first, then by rule id so two runs of the same build agree.
41
+ *
42
+ * The order every format lists a page's findings in. Issues are sorted by
43
+ * `bySeverityThenReach` instead, which adds the reach a grouped issue has and a
44
+ * single page's findings do not.
45
+ */
46
+ function byImpactThenRule(a, b) {
47
+ return impactRank(a.impact) - impactRank(b.impact) || a.ruleId.localeCompare(b.ruleId);
48
+ }
39
49
  /** Violations at or above `threshold`, counted per rule per page. */
40
50
  function countAtOrAbove(audits, threshold) {
41
51
  let total = 0;
@@ -43,4 +53,4 @@ function countAtOrAbove(audits, threshold) {
43
53
  return total;
44
54
  }
45
55
  //#endregion
46
- export { impactRank as a, impactLabel as i, IMPACT_LEVELS as n, isImpactLevel as o, countAtOrAbove as r, meetsThreshold as s, DEFAULT_FAIL_ON as t };
56
+ export { impactLabel as a, meetsThreshold as c, countAtOrAbove as i, IMPACT_LEVELS as n, impactRank as o, byImpactThenRule as r, isImpactLevel as s, DEFAULT_FAIL_ON as t };