eaa-kit 0.5.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 (52) hide show
  1. package/README.md +35 -2
  2. package/dist/astro/index.d.ts +1 -1
  3. package/dist/astro/index.js +1 -1
  4. package/dist/audit/runners/worker.js +1 -1
  5. package/dist/audit-DK2StT1_.js +2 -0
  6. package/dist/{audit-B282A3EA.js → audit-e_uixhPG.js} +95 -48
  7. package/dist/baseline-BscHfFSW.js +2 -0
  8. package/dist/{baseline-CuAS2lMk.js → baseline-D2qWmUC6.js} +36 -9
  9. package/dist/checklist-BtgkxMEa.js +44 -0
  10. package/dist/cli/index.js +111 -14
  11. package/dist/{command-C3D7JWn6.js → command-CZKp9Wi_.js} +101 -10
  12. package/dist/{remediation-CMBIrnpN.js → completeness-Dwq3CPB3.js} +60 -42
  13. package/dist/{component-C3GL1Mnu.js → component-ChisoFyY.js} +1 -1
  14. package/dist/{component-DKd3EHOg.js → component-DcCn3BJS.js} +1 -1
  15. package/dist/coverage-7bQqWNfN.js +2 -0
  16. package/dist/{coverage-B_Y6l-Ra.js → coverage-BD2_CYns.js} +44 -7
  17. package/dist/{crawl-BUWPgaGW.js → crawl-BHkJmUkA.js} +42 -8
  18. package/dist/eleventy/index.d.ts +1 -1
  19. package/dist/eleventy/index.js +1 -1
  20. package/dist/{html-C4vktg0A.js → html-Bmh7X_sM.js} +52 -30
  21. package/dist/{impact-DZt2oBCP.js → impact-luGCnN8F.js} +11 -1
  22. package/dist/index.d.ts +68 -1
  23. package/dist/index.js +3 -3
  24. package/dist/{init-CW7LfGT5.js → init-BjKYttxc.js} +3 -3
  25. package/dist/{jsdom-B--cEH-G.js → jsdom-CQar6OQS.js} +1 -1
  26. package/dist/jsdom-DMvU2h-f.js +3 -0
  27. package/dist/{json-D_Mnnft5.js → json-HJDgJp09.js} +1 -1
  28. package/dist/{json-DROX33kh.js → json-PNg6BeXp.js} +11 -16
  29. package/dist/load-Bc_xDGAh.js +2 -0
  30. package/dist/{load-yAR4wzez.js → load-vZdV_fD1.js} +5 -1
  31. package/dist/nuxt/index.d.ts +1 -1
  32. package/dist/nuxt/index.js +1 -1
  33. package/dist/{playwright-BojtYVUa.js → playwright-C-miJP2b.js} +3 -2
  34. package/dist/{pool-BO25OIez.js → pool-dkexq5xo.js} +2 -2
  35. package/dist/{render-DbGOVmhx.js → render-CtsqR0k-.js} +105 -4
  36. package/dist/{result-BWcYXeRs.js → result-DfUru6y_.js} +24 -1
  37. package/dist/review-CdMK2GpQ.js +161 -0
  38. package/dist/review-DIbOTmOM.js +2 -0
  39. package/dist/{run-C2nKFcb-.js → run-D7hdVXLA.js} +1 -1
  40. package/dist/{run-CtcEUhbe.d.ts → run-YiqyQA2b.d.ts} +10 -0
  41. package/dist/{sarif-B-UBcVu8.js → sarif-BKvq_AUm.js} +23 -12
  42. package/dist/{text-CKKpzkYM.js → text-BF1LHMiV.js} +22 -2
  43. package/dist/vite/index.d.ts +1 -1
  44. package/dist/vite/index.js +1 -1
  45. package/dist/webpack/index.d.ts +1 -1
  46. package/dist/webpack/index.js +1 -1
  47. package/dist/worksheet-eUjwYLxC.js +63 -0
  48. package/package.json +2 -2
  49. package/dist/audit-DyHPmGeD.js +0 -2
  50. package/dist/baseline-0u4df12v.js +0 -2
  51. package/dist/jsdom-22Bkt65v.js +0 -3
  52. package/dist/load-CFq2VQtT.js +0 -2
@@ -93,7 +93,7 @@ function urlsFromSitemap(xml, origin) {
93
93
  return urls;
94
94
  }
95
95
  /** One request, with a timeout, returning HTML or a reason it is not a page. */
96
- async function fetchPage(url, impl, timeoutMs, origin, maxBodyBytes) {
96
+ async function fetchPage(url, impl, timeoutMs, origin, maxBodyBytes, headers) {
97
97
  const controller = new AbortController();
98
98
  const timer = setTimeout(() => controller.abort(), timeoutMs);
99
99
  try {
@@ -102,7 +102,8 @@ async function fetchPage(url, impl, timeoutMs, origin, maxBodyBytes) {
102
102
  redirect: "follow",
103
103
  headers: {
104
104
  accept: "text/html,application/xhtml+xml",
105
- "user-agent": "eaa-kit"
105
+ "user-agent": "eaa-kit",
106
+ ...headers
106
107
  }
107
108
  });
108
109
  if (!response.ok) return {
@@ -213,13 +214,14 @@ function disallowedPaths(robots) {
213
214
  * that is not a page. A site that does not publish one is the ordinary case,
214
215
  * not a failure, so it comes back empty either way.
215
216
  */
216
- async function fetchSiteFile(entry, impl, name, timeoutMs, maxBodyBytes) {
217
+ async function fetchSiteFile(entry, impl, name, timeoutMs, maxBodyBytes, headers) {
217
218
  const controller = new AbortController();
218
219
  const timer = setTimeout(() => controller.abort(), timeoutMs);
219
220
  try {
220
221
  const response = await impl(new URL(name, entry).href, {
221
222
  redirect: "follow",
222
- signal: controller.signal
223
+ signal: controller.signal,
224
+ ...headers === void 0 ? {} : { headers }
223
225
  });
224
226
  if (!response.ok) return void 0;
225
227
  if (new URL(response.url || new URL(name, entry).href).origin !== entry.origin) return void 0;
@@ -244,7 +246,7 @@ async function crawlSite(entry, options = {}) {
244
246
  const timeoutMs = options.timeoutMs ?? 15e3;
245
247
  const maxBodyBytes = options.maxBodyBytes ?? MAX_BODY_BYTES;
246
248
  const failures = [];
247
- const robots = options.ignoreRobots ? void 0 : await fetchSiteFile(entry, impl, "/robots.txt", timeoutMs, maxBodyBytes);
249
+ const robots = options.ignoreRobots ? void 0 : await fetchSiteFile(entry, impl, "/robots.txt", timeoutMs, maxBodyBytes, options.headers);
248
250
  const blocked = robots === void 0 ? [] : disallowedPaths(robots);
249
251
  const allowed = (url) => !blocked.some((path) => url.pathname.startsWith(path));
250
252
  let discovery = "links";
@@ -258,7 +260,7 @@ async function crawlSite(entry, options = {}) {
258
260
  depth
259
261
  });
260
262
  };
261
- const sitemap = await fetchSiteFile(entry, impl, options.sitemap ?? "/sitemap.xml", timeoutMs, maxBodyBytes);
263
+ const sitemap = await fetchSiteFile(entry, impl, options.sitemap ?? "/sitemap.xml", timeoutMs, maxBodyBytes, options.headers);
262
264
  const listed = sitemap === void 0 ? [] : urlsFromSitemap(sitemap, entry);
263
265
  if (listed.length > 0) {
264
266
  discovery = "sitemap";
@@ -266,11 +268,12 @@ async function crawlSite(entry, options = {}) {
266
268
  }
267
269
  enqueue(entry, 0);
268
270
  const pages = [];
271
+ const redirects = [];
269
272
  while (queue.length > 0 && pages.length < maxPages) {
270
273
  const batch = queue.splice(0, Math.min(REQUEST_CONCURRENCY, maxPages - pages.length));
271
274
  const results = await Promise.all(batch.map(async (item) => ({
272
275
  item,
273
- result: await fetchPage(item.url, impl, timeoutMs, entry.origin, maxBodyBytes)
276
+ result: await fetchPage(item.url, impl, timeoutMs, entry.origin, maxBodyBytes, options.headers)
274
277
  })));
275
278
  for (const { item, result } of results) {
276
279
  if (!result.ok) {
@@ -281,6 +284,10 @@ async function crawlSite(entry, options = {}) {
281
284
  continue;
282
285
  }
283
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
+ });
284
291
  pages.push({
285
292
  absolutePath: url.href,
286
293
  relativePath: pageIdentity(url),
@@ -294,11 +301,38 @@ async function crawlSite(entry, options = {}) {
294
301
  pages: byIdentity(pages),
295
302
  origin: entry.origin,
296
303
  failures,
304
+ redirects,
297
305
  truncated: queue.length > 0,
298
306
  discovery
299
307
  };
300
308
  }
301
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
+ /**
302
336
  * One page per identity, in a stable order.
303
337
  *
304
338
  * The queue already refuses a URL it has seen, so this catches the case the
@@ -315,4 +349,4 @@ function byIdentity(pages) {
315
349
  }).sort((a, b) => a.relativePath.localeCompare(b.relativePath));
316
350
  }
317
351
  //#endregion
318
- export { CrawlError, crawlSite, parseEntryUrl };
352
+ export { CrawlError, collapsedOnto, crawlSite, parseEntryUrl };
@@ -1,4 +1,4 @@
1
- import { n as IntegrationOptions, t as BuildAuditError } from "../run-CtcEUhbe.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-C2nKFcb-.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
  /**
@@ -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-CMBIrnpN.js";
5
- import { r as componentPath } from "./component-C3GL1Mnu.js";
6
- import { i as understandingUrl, r as manualCheckFor, t as buildCoverage } from "./coverage-B_Y6l-Ra.js";
7
- import { n as buildSummary } from "./json-DROX33kh.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 };
package/dist/index.d.ts CHANGED
@@ -136,6 +136,10 @@ declare const auditSchema: Schema<ObjectOf<{
136
136
  manual: OptionalSchema<boolean>;
137
137
  /** List every WCAG 2.2 A/AA criterion and what the run reached on it. */
138
138
  coverage: OptionalSchema<boolean>;
139
+ /** Path to a review record: what a person checked, for the criteria no engine reaches. */
140
+ review: OptionalSchema<string>;
141
+ /** Days after which a recorded review stops counting. */
142
+ reviewMaxAge: OptionalSchema<number>;
139
143
  /**
140
144
  * False is `--no-build`: never run the project's build or start its server to
141
145
  * find something to audit. Written in the positive because that is the state
@@ -232,6 +236,10 @@ declare const configSchema: Schema<ObjectOf<{
232
236
  manual: OptionalSchema<boolean>;
233
237
  /** List every WCAG 2.2 A/AA criterion and what the run reached on it. */
234
238
  coverage: OptionalSchema<boolean>;
239
+ /** Path to a review record: what a person checked, for the criteria no engine reaches. */
240
+ review: OptionalSchema<string>;
241
+ /** Days after which a recorded review stops counting. */
242
+ reviewMaxAge: OptionalSchema<number>;
235
243
  /**
236
244
  * False is `--no-build`: never run the project's build or start its server to
237
245
  * find something to audit. Written in the positive because that is the state
@@ -349,6 +357,24 @@ declare class StatementError extends Error {
349
357
  readonly name = "StatementError";
350
358
  }
351
359
  //#endregion
360
+ //#region src/audit/review.d.ts
361
+ declare const reviewSchema: Schema<ObjectOf<{
362
+ schemaVersion: Schema<number>;
363
+ /** Who carried out the review, for the record as a whole. */
364
+ reviewedBy: OptionalSchema<string>;
365
+ /** Keyed by success criterion number, e.g. '1.2.1'. */
366
+ criteria: DefaultedSchema<Record<string, ObjectOf<{
367
+ result: DefaultedSchema<"met" | "not-applicable" | "not-met" | "unreviewed">;
368
+ /** ISO date the check was done. Without it the entry cannot be aged. */
369
+ reviewedOn: OptionalSchema<string>;
370
+ /** Who did it, when that is not the person named on the record. */
371
+ reviewedBy: OptionalSchema<string>;
372
+ /** What was checked and what was found. Free text, for whoever reads it. */
373
+ note: OptionalSchema<string>;
374
+ }>>>;
375
+ }>>;
376
+ type ReviewRecord = Infer<typeof reviewSchema>;
377
+ //#endregion
352
378
  //#region src/statement/findings.d.ts
353
379
  /** One rule that failed, folded across every page it failed on. */
354
380
  interface AuditFinding {
@@ -394,6 +420,47 @@ declare function summariseAuditReport(value: unknown, source?: string): AuditSum
394
420
  /** Read and summarise a report written by `eaa-kit audit --format json`. */
395
421
  declare function readAuditReport(file: string, cwd?: string): Promise<AuditSummary>;
396
422
  //#endregion
423
+ //#region src/statement/evidence.d.ts
424
+ /**
425
+ * Whether the statement's claim survives contact with the evidence beside it.
426
+ *
427
+ * The README has said since 0.1 that "a statement claiming full conformance for
428
+ * a site that is not conformant is worse than no statement at all", and until
429
+ * now the tool did nothing about it: `compliance.status` came out of the config
430
+ * file, the audit report came in through `--audit`, and nothing ever put the
431
+ * two in the same room. A run could print a document claiming full conformance
432
+ * directly above the list of barriers that disproves it.
433
+ *
434
+ * This is the check that was missing. It is deliberately narrow: it does not
435
+ * decide whether a site is accessible, and it cannot — most of WCAG is not
436
+ * automatable and the whole tool is built around saying so. It refuses only
437
+ * claims the evidence in hand already contradicts, and warns where a document
438
+ * is citing evidence that no longer describes the site.
439
+ *
440
+ * Nothing here needs an audit report. Given none, only the dates are checked,
441
+ * and a project that has never run one gets exactly the statement it always
442
+ * got — the tool cannot audit what it was not shown.
443
+ */
444
+ /** How much a problem matters. `refuses` stops the statement being written. */
445
+ type EvidenceSeverity = 'refuses' | 'warns';
446
+ interface EvidenceProblem {
447
+ severity: EvidenceSeverity;
448
+ /** One sentence stating the contradiction, and one naming the fix. */
449
+ message: string;
450
+ }
451
+ interface EvidenceInput {
452
+ config: EaaConfig;
453
+ /** From `--audit`. Without it only the dates can be checked. */
454
+ audit?: AuditSummary;
455
+ /** From `--review`. Read for this check alone; it does not reach the text. */
456
+ review?: ReviewRecord;
457
+ /** Injectable so the ageing checks can be tested without waiting. */
458
+ today?: Date;
459
+ }
460
+ declare function checkStatementEvidence(input: EvidenceInput): EvidenceProblem[];
461
+ /** Whether any problem is bad enough to stop the document being written. */
462
+ declare function refuses(problems: readonly EvidenceProblem[]): boolean;
463
+ //#endregion
397
464
  //#region src/statement/html.d.ts
398
465
  /**
399
466
  * Markdown-to-HTML for exactly the subset the statement templates emit, and
@@ -466,4 +533,4 @@ interface RenderedStatement {
466
533
  */
467
534
  declare function renderStatement(config: EaaConfig, options?: RenderStatementOptions): Promise<RenderedStatement>;
468
535
  //#endregion
469
- export { ASSESSMENT_METHODS, type AssessmentMethod, type AuditFinding, type AuditSummary, COMPLIANCE_STATUSES, CONFIG_FILENAMES, COUNTRIES, type ComplianceStatus, ConfigError, type Country, type EaaConfig, type EaaConfigInput, type HtmlDocumentOptions, ISSUE_REASONS, type IssueReason, type KnownIssue, type LoadConfigOptions, type LoadedConfig, type RenderStatementOptions, type RenderedStatement, STATEMENT_LOCALES, StatementError, type StatementLocale, configSchema, defineConfig, findConfigFile, loadConfig, parseConfig, readAuditReport, renderStatement, summariseAuditReport, toHtmlBody, toHtmlDocument };
536
+ export { ASSESSMENT_METHODS, type AssessmentMethod, type AuditFinding, type AuditSummary, COMPLIANCE_STATUSES, CONFIG_FILENAMES, COUNTRIES, type ComplianceStatus, ConfigError, type Country, type EaaConfig, type EaaConfigInput, type EvidenceInput, type EvidenceProblem, type EvidenceSeverity, type HtmlDocumentOptions, ISSUE_REASONS, type IssueReason, type KnownIssue, type LoadConfigOptions, type LoadedConfig, type RenderStatementOptions, type RenderedStatement, STATEMENT_LOCALES, StatementError, type StatementLocale, checkStatementEvidence, configSchema, defineConfig, findConfigFile, loadConfig, parseConfig, readAuditReport, refuses, renderStatement, summariseAuditReport, toHtmlBody, toHtmlDocument };
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- import { a as ASSESSMENT_METHODS, c as ConfigError, d as configSchema, f as defineConfig, i as loadConfig, l as ISSUE_REASONS, n as findConfigFile, o as COMPLIANCE_STATUSES, p as parseConfig, s as COUNTRIES, t as CONFIG_FILENAMES, u as STATEMENT_LOCALES } from "./load-yAR4wzez.js";
2
- import { a as summariseAuditReport, i as readAuditReport, n as toHtmlBody, o as StatementError, r as toHtmlDocument, t as renderStatement } from "./render-DbGOVmhx.js";
3
- export { ASSESSMENT_METHODS, COMPLIANCE_STATUSES, CONFIG_FILENAMES, COUNTRIES, ConfigError, ISSUE_REASONS, STATEMENT_LOCALES, StatementError, configSchema, defineConfig, findConfigFile, loadConfig, parseConfig, readAuditReport, renderStatement, summariseAuditReport, toHtmlBody, toHtmlDocument };
1
+ import { a as ASSESSMENT_METHODS, c as ConfigError, d as configSchema, f as defineConfig, i as loadConfig, l as ISSUE_REASONS, n as findConfigFile, o as COMPLIANCE_STATUSES, p as parseConfig, s as COUNTRIES, t as CONFIG_FILENAMES, u as STATEMENT_LOCALES } from "./load-vZdV_fD1.js";
2
+ import { a as summariseAuditReport, c as StatementError, i as readAuditReport, n as toHtmlBody, o as checkStatementEvidence, r as toHtmlDocument, s as refuses, t as renderStatement } from "./render-CtsqR0k-.js";
3
+ export { ASSESSMENT_METHODS, COMPLIANCE_STATUSES, CONFIG_FILENAMES, COUNTRIES, ConfigError, ISSUE_REASONS, STATEMENT_LOCALES, StatementError, checkStatementEvidence, configSchema, defineConfig, findConfigFile, loadConfig, parseConfig, readAuditReport, refuses, renderStatement, summariseAuditReport, toHtmlBody, toHtmlDocument };
@@ -1,7 +1,7 @@
1
- import { t as DEFAULT_FAIL_ON } from "./impact-DZt2oBCP.js";
2
- import { s as COUNTRIES, t as CONFIG_FILENAMES } from "./load-yAR4wzez.js";
1
+ import { t as DEFAULT_FAIL_ON } from "./impact-luGCnN8F.js";
2
+ import { s as COUNTRIES, t as CONFIG_FILENAMES } from "./load-vZdV_fD1.js";
3
3
  import { t as exists } from "./fs-BmPtmFke.js";
4
- import { l as warn, o as fail, s as note } from "./command-C3D7JWn6.js";
4
+ import { l as warn, o as fail, s as note } from "./command-CZKp9Wi_.js";
5
5
  import { readFile, writeFile } from "node:fs/promises";
6
6
  import path from "node:path";
7
7
  import pc from "picocolors";
@@ -1,4 +1,4 @@
1
- import { a as failedPage, i as blindRulesInScope, l as runOptions, n as DEFAULT_TAGS, s as pageUrl, t as DEFAULT_PAGE_TIMEOUT_MS, u as shapeResults } from "./result-BWcYXeRs.js";
1
+ import { a as failedPage, i as blindRulesInScope, l as runOptions, n as DEFAULT_TAGS, s as pageUrl, t as DEFAULT_PAGE_TIMEOUT_MS, u as shapeResults } from "./result-DfUru6y_.js";
2
2
  import axe from "axe-core";
3
3
  import { Script } from "node:vm";
4
4
  import { JSDOM, VirtualConsole } from "jsdom";
@@ -0,0 +1,3 @@
1
+ import "./result-DfUru6y_.js";
2
+ import { n as runJsdomAudit } from "./jsdom-CQar6OQS.js";
3
+ export { runJsdomAudit };
@@ -1,2 +1,2 @@
1
- import { r as serialiseJsonReport, t as buildJsonReport } from "./json-DROX33kh.js";
1
+ import { r as serialiseJsonReport, t as buildJsonReport } from "./json-PNg6BeXp.js";
2
2
  export { buildJsonReport, serialiseJsonReport };
@@ -1,8 +1,8 @@
1
- import { i as impactLabel, o as isImpactLevel, r as countAtOrAbove } from "./impact-DZt2oBCP.js";
1
+ import { a as impactLabel, i as countAtOrAbove, s as isImpactLevel } from "./impact-luGCnN8F.js";
2
2
  import { t as TOOL_VERSION } from "./version-B3v4rNoG.js";
3
3
  import { t as elementFingerprint } from "./fingerprint-BjYV_0F7.js";
4
- import { c as ruleOutcomes } from "./result-BWcYXeRs.js";
5
- import { t as buildCoverage } from "./coverage-B_Y6l-Ra.js";
4
+ import { c as runEngine, f as uniqueRuleOutcomes } from "./result-DfUru6y_.js";
5
+ import { n as buildCoverage } from "./coverage-BD2_CYns.js";
6
6
  import axe from "axe-core";
7
7
  /**
8
8
  * Build the machine-readable report.
@@ -24,7 +24,7 @@ function buildJsonReport(audits, options) {
24
24
  axeCore: axe.version
25
25
  },
26
26
  generatedAt,
27
- engine: audits[0]?.engine ?? "jsdom",
27
+ engine: runEngine(audits),
28
28
  target: {
29
29
  source: options.directory,
30
30
  kind: options.sourceKind ?? "directory",
@@ -33,24 +33,19 @@ function buildJsonReport(audits, options) {
33
33
  },
34
34
  summary: buildSummary(audits, options.failOn),
35
35
  completeness: options.completeness,
36
- coverage: buildCoverage(audits),
36
+ coverage: buildCoverage(audits, void 0, options.review),
37
37
  rules: buildRuleIndex(audits),
38
38
  pages: audits.map(toJsonPage)
39
39
  };
40
40
  }
41
41
  /** Every rule mentioned by any page, keyed by id and sorted for stable diffs. */
42
42
  function buildRuleIndex(audits) {
43
- const index = /* @__PURE__ */ new Map();
44
- for (const audit of audits) for (const outcome of ruleOutcomes(audit)) {
45
- if (index.has(outcome.ruleId)) continue;
46
- index.set(outcome.ruleId, {
47
- help: outcome.help,
48
- helpUrl: outcome.helpUrl,
49
- successCriteria: outcome.successCriteria,
50
- en301549: outcome.enClauses
51
- });
52
- }
53
- return Object.fromEntries([...index].sort(([a], [b]) => a.localeCompare(b)));
43
+ return Object.fromEntries(uniqueRuleOutcomes(audits).map((outcome) => [outcome.ruleId, {
44
+ help: outcome.help,
45
+ helpUrl: outcome.helpUrl,
46
+ successCriteria: outcome.successCriteria,
47
+ en301549: outcome.enClauses
48
+ }]));
54
49
  }
55
50
  /** Serialised form written to stdout or to --output, with a trailing newline. */
56
51
  function serialiseJsonReport(report) {
@@ -0,0 +1,2 @@
1
+ import { r as loadAuditConfig } from "./load-vZdV_fD1.js";
2
+ export { loadAuditConfig };
@@ -1,4 +1,4 @@
1
- import { n as IMPACT_LEVELS } from "./impact-DZt2oBCP.js";
1
+ import { n as IMPACT_LEVELS } from "./impact-luGCnN8F.js";
2
2
  import { _ as union, a as integer, d as optional, f as pipe, g as transform, h as string, i as enumeration, m as safeParse, n as boolean, o as isoDate, r as email, t as array, u as object, v as url, y as withDefault } from "./schema-DJSF4K05.js";
3
3
  import { r as isFile } from "./fs-BmPtmFke.js";
4
4
  import { readFile } from "node:fs/promises";
@@ -122,6 +122,10 @@ const auditSchema = object({
122
122
  manual: optional(boolean()),
123
123
  /** List every WCAG 2.2 A/AA criterion and what the run reached on it. */
124
124
  coverage: optional(boolean()),
125
+ /** Path to a review record: what a person checked, for the criteria no engine reaches. */
126
+ review: optional(string({ min: 1 })),
127
+ /** Days after which a recorded review stops counting. */
128
+ reviewMaxAge: optional(integer({ min: 0 })),
125
129
  /**
126
130
  * False is `--no-build`: never run the project's build or start its server to
127
131
  * find something to audit. Written in the positive because that is the state
@@ -1,4 +1,4 @@
1
- import { n as IntegrationOptions, t as BuildAuditError } from "../run-CtcEUhbe.js";
1
+ import { n as IntegrationOptions, t as BuildAuditError } from "../run-YiqyQA2b.js";
2
2
  //#region src/nuxt/index.d.ts
3
3
  /** The part of the Nitro instance this reads, handed over by `nitro:init`. */
4
4
  interface NitroLike {
@@ -1,4 +1,4 @@
1
- import { n as auditBuild, r as stderrLogger, t as BuildAuditError } from "../run-C2nKFcb-.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/nuxt/index.ts
4
4
  function eaaKitModule(options = {}, nuxt) {
@@ -1,5 +1,5 @@
1
1
  import { r as isFile } from "./fs-BmPtmFke.js";
2
- import { a as failedPage, l as runOptions, n as DEFAULT_TAGS, s as pageUrl, u as shapeResults } from "./result-BWcYXeRs.js";
2
+ import { a as failedPage, l as runOptions, n as DEFAULT_TAGS, s as pageUrl, u as shapeResults } from "./result-DfUru6y_.js";
3
3
  import { createRequire } from "node:module";
4
4
  import { stat } from "node:fs/promises";
5
5
  import path from "node:path";
@@ -165,7 +165,8 @@ async function runBrowserAudit(directory, pages, options = {}) {
165
165
  try {
166
166
  const context = await browser.newContext({
167
167
  viewport,
168
- bypassCSP: true
168
+ bypassCSP: true,
169
+ ...options.headers === void 0 ? {} : { extraHTTPHeaders: options.headers }
169
170
  });
170
171
  const audits = Array.from({ length: pages.length });
171
172
  const lanes = Math.max(1, Math.min(options.concurrency ?? DEFAULT_CONCURRENCY, pages.length));
@@ -1,5 +1,5 @@
1
1
  import { r as isFile } from "./fs-BmPtmFke.js";
2
- import { a as failedPage, s as pageUrl } from "./result-BWcYXeRs.js";
2
+ import { a as failedPage, s as pageUrl } from "./result-DfUru6y_.js";
3
3
  import { Worker } from "node:worker_threads";
4
4
  import { availableParallelism } from "node:os";
5
5
  //#region src/audit/runners/pool.ts
@@ -106,7 +106,7 @@ async function runPooledAudit(pages, options = {}) {
106
106
  return runWorkers(pages, runnerOptions, Math.min(workers, pages.length), entry);
107
107
  }
108
108
  async function auditHere(pages, options) {
109
- const { runJsdomAudit } = await import("./jsdom-22Bkt65v.js");
109
+ const { runJsdomAudit } = await import("./jsdom-DMvU2h-f.js");
110
110
  return runJsdomAudit(pages, options);
111
111
  }
112
112
  /**