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,5 +1,5 @@
1
1
  import { r as isFile } from "./fs-BmPtmFke.js";
2
- import { i as failedPage, o as pageUrl } from "./result-DoamKFsp.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
@@ -52,6 +52,16 @@ const WORK_PER_WORKER_MS = 1600;
52
52
  /** Threading at all means at least two, or there is nothing to overlap with. */
53
53
  const MIN_WORKERS = 2;
54
54
  /**
55
+ * Grace above the runner's own per-page timeout before a worker is killed.
56
+ *
57
+ * The runner races axe-core against a timer, which works whenever the work
58
+ * yields to the event loop: the page is reported with an error and the thread
59
+ * lives on to take the next one. That is the better outcome, so it is given
60
+ * room to happen first. This is the backstop for when it cannot — see
61
+ * `watchdog` below.
62
+ */
63
+ const HARD_TIMEOUT_GRACE_MS = 5e3;
64
+ /**
55
65
  * Ceiling on workers regardless of core count. Past this, the run is bounded by
56
66
  * memory bandwidth and by each worker's own start-up rather than by cores, and
57
67
  * every extra thread holds another jsdom document tree.
@@ -96,10 +106,33 @@ async function runPooledAudit(pages, options = {}) {
96
106
  return runWorkers(pages, runnerOptions, Math.min(workers, pages.length), entry);
97
107
  }
98
108
  async function auditHere(pages, options) {
99
- const { runJsdomAudit } = await import("./jsdom-4IMzv0eE.js");
109
+ const { runJsdomAudit } = await import("./jsdom-DMvU2h-f.js");
100
110
  return runJsdomAudit(pages, options);
101
111
  }
112
+ /**
113
+ * The only place a per-page ceiling can actually be enforced.
114
+ *
115
+ * The runner's own timeout is a `Promise.race`, and a race cannot interrupt
116
+ * synchronous work: jsdom's parse and axe-core's walk of the tree both hold the
117
+ * thread, so the timer that is meant to stop them never gets to run. Measured
118
+ * on a 120,000-element document with a two-second ceiling, the audit was still
119
+ * going more than ten minutes later — and because the pool waits on its
120
+ * workers, the whole run went with it. A CI job hung until the platform killed
121
+ * it, which is the failure the ceiling exists to prevent.
122
+ *
123
+ * `worker.terminate()` is the answer, because it stops the thread whatever it
124
+ * is doing. So the supervisor keeps its own deadline per page and kills the
125
+ * thread that overruns it, records that page as unaudited, and lets the rest of
126
+ * the run carry on. The page is reported as a failure rather than as a clean
127
+ * page, which the CLI already turns into exit 2.
128
+ *
129
+ * Two runs still have no hard ceiling, because both refuse the threads that
130
+ * would carry it: `--concurrency 1`, and a machine with too few cores to spare
131
+ * one. Both are documented rather than papered over, and the size cap in
132
+ * `collectPages` is what keeps them bounded in practice.
133
+ */
102
134
  async function runWorkers(pages, options, count, entry) {
135
+ const deadlineMs = (options.timeoutMs ?? 3e4) + HARD_TIMEOUT_GRACE_MS;
103
136
  const audits = Array.from({ length: pages.length });
104
137
  let next = 0;
105
138
  await Promise.all(Array.from({ length: count }, () => {
@@ -113,7 +146,13 @@ async function runWorkers(pages, options, count, entry) {
113
146
  }
114
147
  let inFlight;
115
148
  let completed = 0;
149
+ let watchdog;
150
+ const disarm = () => {
151
+ if (watchdog !== void 0) clearTimeout(watchdog);
152
+ watchdog = void 0;
153
+ };
116
154
  const finish = () => {
155
+ disarm();
117
156
  worker.terminate();
118
157
  resolve();
119
158
  };
@@ -127,8 +166,18 @@ async function runWorkers(pages, options, count, entry) {
127
166
  next += 1;
128
167
  inFlight = index;
129
168
  worker.postMessage(pages[index]);
169
+ watchdog = setTimeout(() => {
170
+ watchdog = void 0;
171
+ if (inFlight !== void 0) {
172
+ audits[inFlight] = failedPage(identity(pages[inFlight], options), `the audit worker was stopped after ${deadlineMs}ms on this page`);
173
+ inFlight = void 0;
174
+ }
175
+ finish();
176
+ }, deadlineMs);
177
+ watchdog.unref?.();
130
178
  };
131
179
  worker.on("message", (audit) => {
180
+ disarm();
132
181
  if (inFlight !== void 0) {
133
182
  audits[inFlight] = audit;
134
183
  completed += 1;
@@ -136,6 +185,7 @@ async function runWorkers(pages, options, count, entry) {
136
185
  feed();
137
186
  });
138
187
  worker.on("error", (cause) => {
188
+ disarm();
139
189
  if (inFlight !== void 0 && completed > 0) audits[inFlight] = failedPage(identity(pages[inFlight], options), `audit worker failed: ${cause.message}`);
140
190
  finish();
141
191
  });
@@ -1,7 +1,8 @@
1
- import { c as number, f as record, l as object, m as string, o as isoDateTime, p as safeParse, s as nullable, t as array, v as withDefault } from "./schema-is6CGX2D.js";
1
+ import { n as IMPACT_LEVELS, o as impactRank } from "./impact-luGCnN8F.js";
2
+ import { c as nullable, h as string, l as number, m as safeParse, p as record, s as isoDateTime, t as array, u as object, y as withDefault } from "./schema-DJSF4K05.js";
3
+ import { u as STATEMENT_LOCALES } from "./load-vZdV_fD1.js";
2
4
  import { n as isDirectory } from "./fs-BmPtmFke.js";
3
- import { a as impactRank, n as IMPACT_LEVELS } from "./impact-DZt2oBCP.js";
4
- import { i as escapeText, o as standardsReference, r as escapeAttribute } from "./text-CKKpzkYM.js";
5
+ import { a as isoDate, i as escapeText, n as count, r as escapeAttribute, s as standardsReference } from "./text-BF1LHMiV.js";
5
6
  import { t as TOOL_VERSION } from "./version-B3v4rNoG.js";
6
7
  import { readFile, readdir } from "node:fs/promises";
7
8
  import path from "node:path";
@@ -19,6 +20,107 @@ import { fileURLToPath } from "node:url";
19
20
  var StatementError = class extends Error {
20
21
  name = "StatementError";
21
22
  };
23
+ //#endregion
24
+ //#region src/statement/evidence.ts
25
+ /**
26
+ * How old a claim or a report may be before the document says so.
27
+ *
28
+ * A year, because a statement is a document about a site that changes, and one
29
+ * dated further back than that is describing a site nobody has checked since.
30
+ * It is a warning and never a refusal: how often a statement must be revisited
31
+ * is a question about a legal regime and a rate of change, and this tool is in
32
+ * no position to answer it for somebody.
33
+ */
34
+ const STALE_DAYS = 365;
35
+ function checkStatementEvidence(input) {
36
+ const today = input.today ?? /* @__PURE__ */ new Date();
37
+ return [
38
+ ...claimAgainstAudit(input),
39
+ ...claimAgainstReview(input),
40
+ ...dates(input, today)
41
+ ];
42
+ }
43
+ /** Whether any problem is bad enough to stop the document being written. */
44
+ function refuses(problems) {
45
+ return problems.some((problem) => problem.severity === "refuses");
46
+ }
47
+ /**
48
+ * A claim of full conformance, against barriers the audit found.
49
+ *
50
+ * Refused rather than warned about. Every other output of this tool is a report
51
+ * somebody reads; this one is a legal document published under their name, and
52
+ * the failure mode is not a wrong number in a terminal but a false statement on
53
+ * a website. The fix is in the message because it is not obvious to somebody
54
+ * filling in a config file for the first time that `partially-compliant` is the
55
+ * ordinary answer rather than an admission of defeat.
56
+ */
57
+ function claimAgainstAudit(input) {
58
+ const audit = input.audit;
59
+ if (audit === void 0 || input.config.compliance.status !== "compliant") return [];
60
+ if (audit.findings.length === 0) return [];
61
+ const pages = new Set(audit.findings.flatMap((finding) => finding.pages)).size;
62
+ return [{
63
+ severity: "refuses",
64
+ message: `The config claims full conformance, and the audit report lists ${count(audit.findings.length, "barrier")} on ${count(pages, "page")}.\n A statement claiming full conformance for a site that is not conformant is worse\n than no statement at all, so this one was not written.\n Either fix the barriers and audit again, or set compliance.status to\n "partially-compliant", which is the honest answer for most sites and the one that\n carries the obligation to list what is missing.`
65
+ }];
66
+ }
67
+ /**
68
+ * A claim of full conformance, against criteria a person recorded as not met.
69
+ *
70
+ * The review record is the only evidence this tool has about the 34 criteria no
71
+ * engine can reach, and a `not-met` in it is somebody's own finding. Claiming
72
+ * full conformance over the top of it is the same false statement as claiming
73
+ * it over the top of a violation, so it gets the same refusal.
74
+ */
75
+ function claimAgainstReview(input) {
76
+ const review = input.review;
77
+ if (review === void 0 || input.config.compliance.status !== "compliant") return [];
78
+ const notMet = Object.entries(review.criteria).filter(([, entry]) => entry.result === "not-met").map(([number]) => number);
79
+ if (notMet.length === 0) return [];
80
+ return [{
81
+ severity: "refuses",
82
+ message: `The config claims full conformance, and the review record has ${count(notMet.length, "criterion")} recorded as not met: ${notMet.join(", ")}.\n Somebody checked those and wrote down that the site does not meet them, so this\n statement was not written. Fix them and record the result, or set\n compliance.status to "partially-compliant" and describe them in\n compliance.knownIssues.`
83
+ }];
84
+ }
85
+ /**
86
+ * Whether the document's dates still describe the site.
87
+ *
88
+ * All warnings. A date being old is not a false claim — it is a document nobody
89
+ * has revisited, which is a different problem with a different fix, and the
90
+ * person publishing it is the one who knows whether the site has moved.
91
+ */
92
+ function dates(input, today) {
93
+ const problems = [];
94
+ const assessedOn = input.config.compliance.assessedOn;
95
+ const now = isoDate(today);
96
+ if (assessedOn > now) problems.push({
97
+ severity: "warns",
98
+ message: `compliance.assessedOn is ${assessedOn}, which is in the future. A statement dated ahead of its own assessment is a typo somebody will notice.`
99
+ });
100
+ else if (daysBetween(assessedOn, now) > STALE_DAYS) problems.push({
101
+ severity: "warns",
102
+ message: `compliance.assessedOn is ${assessedOn}, ${count(daysBetween(assessedOn, now), "day")} ago. The statement will say so, and it describes a site nobody has assessed since.`
103
+ });
104
+ const audit = input.audit;
105
+ if (audit === void 0) return problems;
106
+ const auditDay = audit.generatedAt.slice(0, 10);
107
+ if (auditDay > assessedOn) problems.push({
108
+ severity: "warns",
109
+ message: `The audit ran on ${auditDay}, after the ${assessedOn} this statement gives as its assessment date. Move compliance.assessedOn forward, or cite the report from that day.`
110
+ });
111
+ if (daysBetween(auditDay, now) > STALE_DAYS) problems.push({
112
+ severity: "warns",
113
+ message: `The audit report is from ${auditDay}, ${count(daysBetween(auditDay, now), "day")} ago. The barriers it lists are the ones the site had then.`
114
+ });
115
+ return problems;
116
+ }
117
+ /** Whole days between two ISO dates, both UTC, without a date library. */
118
+ function daysBetween(from, to) {
119
+ const start = Date.parse(`${from}T00:00:00Z`);
120
+ const end = Date.parse(`${to}T00:00:00Z`);
121
+ if (Number.isNaN(start) || Number.isNaN(end)) return 0;
122
+ return Math.round((end - start) / 864e5);
123
+ }
22
124
  /**
23
125
  * Only the fields the statement reads. Everything else in the report — node
24
126
  * markup, selectors, passes, inapplicable — is audit detail with no place in a
@@ -58,7 +160,7 @@ function summariseAuditReport(value, source = "audit report") {
58
160
  const result = safeParse(reportSchema, value);
59
161
  if (!result.success) throw new StatementError(`${source} is not an eaa-kit JSON report (${result.error.issues.map((issue) => `${issue.path.join(".") || "document"}: ${issue.message}`).slice(0, 5).join("; ")})`);
60
162
  const report = result.data;
61
- if (report.schemaVersion !== 1) throw new StatementError(`${source} has schemaVersion ${report.schemaVersion}; this version of eaa-kit reads 1`);
163
+ if (report.schemaVersion !== 2) throw new StatementError(`${source} has schemaVersion ${report.schemaVersion}; this version of eaa-kit reads 2`);
62
164
  const byRule = /* @__PURE__ */ new Map();
63
165
  const rules = new Map(Object.entries(report.rules));
64
166
  for (const page of report.pages) for (const violation of page.violations) {
@@ -411,7 +513,7 @@ async function renderStatement(config, options = {}) {
411
513
  const country = options.country ?? config.enforcement.country;
412
514
  const locale = options.locale ?? defaultLocale(config);
413
515
  const template = `${country.toLowerCase()}.${locale}`;
414
- const markdown = tidy(renderTemplate(await loadTemplate(template), buildScope(config, locale, options.audit)));
516
+ const markdown = tidy(renderTemplate(await loadTemplate(country, locale), buildScope(config, locale, options.audit)));
415
517
  return {
416
518
  markdown,
417
519
  html: toHtmlDocument(markdown, {
@@ -423,9 +525,16 @@ async function renderStatement(config, options = {}) {
423
525
  template
424
526
  };
425
527
  }
426
- /** A German-language site gets a German statement unless told otherwise. */
528
+ /**
529
+ * A site gets its statement in its own language where there is one for it.
530
+ *
531
+ * From `site.locale`, which is a BCP 47 tag: `de-AT` and `de` both mean the
532
+ * German document. English is the fallback because every country has an English
533
+ * template, being the language a statement is most often also published in.
534
+ */
427
535
  function defaultLocale(config) {
428
- return config.site.locale.toLowerCase().startsWith("de") ? "de" : "en";
536
+ const language = config.site.locale.toLowerCase().split("-")[0];
537
+ return STATEMENT_LOCALES.find((candidate) => candidate === language) ?? "en";
429
538
  }
430
539
  /**
431
540
  * Values the templates interpolate.
@@ -522,6 +631,22 @@ function reasonScope(reason) {
522
631
  };
523
632
  }
524
633
  /**
634
+ * Where each statement language formats its dates.
635
+ *
636
+ * A region is named for every one, because a bare language tag leaves the
637
+ * format to whatever ICU picks: `de` is de-DE, and this tool's German documents
638
+ * have always been dated the Austrian way. `en-GB` for the same reason —
639
+ * 20 August 2026, not August 20, 2026, in a European legal document.
640
+ */
641
+ const DATE_LOCALES = {
642
+ de: "de-AT",
643
+ en: "en-GB",
644
+ es: "es-ES",
645
+ fr: "fr-FR",
646
+ it: "it-IT",
647
+ nl: "nl-NL"
648
+ };
649
+ /**
525
650
  * 2026-08-20 becomes 20. August 2026 or 20 August 2026.
526
651
  *
527
652
  * Every date reaching this has been through a schema that checks it, so the
@@ -533,7 +658,7 @@ function reasonScope(reason) {
533
658
  function formatDate(iso, locale) {
534
659
  const date = /* @__PURE__ */ new Date(`${iso}T00:00:00Z`);
535
660
  if (Number.isNaN(date.getTime())) return iso;
536
- return new Intl.DateTimeFormat(locale === "de" ? "de-AT" : "en-GB", {
661
+ return new Intl.DateTimeFormat(DATE_LOCALES[locale], {
537
662
  day: "numeric",
538
663
  month: "long",
539
664
  year: "numeric",
@@ -549,14 +674,28 @@ function tidy(markdown) {
549
674
  return `${markdown.replace(/[ \t]+$/gm, "").replace(/\n{3,}/g, "\n\n").trim()}\n`;
550
675
  }
551
676
  let templateDirectory;
552
- async function loadTemplate(name) {
677
+ /**
678
+ * The document for a country in a language, if there is one.
679
+ *
680
+ * The matrix is deliberately sparse: a country's statement is written under its
681
+ * own law and published in the language the law is administered in, plus
682
+ * English. Asking for a combination nobody wrote is an error naming the
683
+ * languages that country does have — not a fall back to another language, which
684
+ * would hand somebody a document in a language their readers may not have and
685
+ * do it quietly.
686
+ */
687
+ async function loadTemplate(country, locale) {
553
688
  templateDirectory ??= await findTemplateDirectory();
554
689
  const directory = templateDirectory;
690
+ const name = `${country.toLowerCase()}.${locale}`;
555
691
  const file = path.join(directory, `${name}.md`);
556
692
  try {
557
693
  return await readFile(file, "utf8");
558
694
  } catch {
559
- throw new StatementError(`No statement template for ${name}. Available: ${(await readdir(directory)).filter((entry) => entry.endsWith(".md")).map((entry) => entry.replace(/\.md$/, "")).sort().join(", ")}`);
695
+ const templates = (await readdir(directory)).filter((entry) => entry.endsWith(".md")).map((entry) => entry.replace(/\.md$/, "")).sort();
696
+ const prefix = `${country.toLowerCase()}.`;
697
+ const forCountry = templates.filter((entry) => entry.startsWith(prefix)).map((entry) => entry.slice(prefix.length));
698
+ throw new StatementError(forCountry.length > 0 ? `No ${country} statement in ${locale}. ${country} has: ${forCountry.join(", ")}` : `No statement template for ${name}. Available: ${templates.join(", ")}`);
560
699
  }
561
700
  }
562
701
  /**
@@ -576,4 +715,4 @@ async function findTemplateDirectory() {
576
715
  throw new StatementError(`Could not locate the statement templates. Looked in: ${candidates.join(", ")}`);
577
716
  }
578
717
  //#endregion
579
- export { summariseAuditReport as a, readAuditReport as i, toHtmlBody as n, StatementError as o, toHtmlDocument as r, renderStatement as t };
718
+ export { summariseAuditReport as a, StatementError as c, readAuditReport as i, toHtmlBody as n, checkStatementEvidence as o, toHtmlDocument as r, refuses as s, renderStatement as t };
@@ -1,6 +1,15 @@
1
1
  import { pathToFileURL } from "node:url";
2
2
  import axe from "axe-core";
3
3
  //#region src/audit/result.ts
4
+ /**
5
+ * Per-page ceiling for an audit, in milliseconds.
6
+ *
7
+ * Lives here rather than in the jsdom runner because the worker pool needs it
8
+ * to set its own deadline, and the pool deliberately does not import jsdom —
9
+ * loading 630 ms of dependency to supervise threads that each load their own
10
+ * would be pure overhead. The same reason ENGINE_BLIND_RULES sits here.
11
+ */
12
+ const DEFAULT_PAGE_TIMEOUT_MS = 3e4;
4
13
  /** WCAG 2.2 AA and everything it builds on. Best-practice rules stay off. */
5
14
  const DEFAULT_TAGS = [
6
15
  "wcag2a",
@@ -122,17 +131,65 @@ function ruleOutcomes(audit) {
122
131
  ...audit.inapplicable
123
132
  ];
124
133
  }
125
- function runOptions(tags) {
134
+ /**
135
+ * Which engine produced this run, as every report format has to label it.
136
+ *
137
+ * Taken from the first page rather than tracked separately: a run uses one
138
+ * engine for all of its pages, and an empty run has none to report, which is
139
+ * the jsdom default the CLI would have used.
140
+ */
141
+ function runEngine(audits) {
142
+ return audits[0]?.engine ?? "jsdom";
143
+ }
144
+ /**
145
+ * Every rule the run mentions anywhere, once each, sorted by id.
146
+ *
147
+ * Both machine-readable formats need this and neither can take it from the
148
+ * other: the JSON report keys its `rules` map by id, and SARIF needs the same
149
+ * catalogue as a list its results index into. First mention wins because a
150
+ * rule's help text and criteria do not vary between the pages it appears on.
151
+ */
152
+ function uniqueRuleOutcomes(audits) {
153
+ const byId = /* @__PURE__ */ new Map();
154
+ for (const audit of audits) for (const outcome of ruleOutcomes(audit)) if (!byId.has(outcome.ruleId)) byId.set(outcome.ruleId, outcome);
155
+ return [...byId.values()].sort((a, b) => a.ruleId.localeCompare(b.ruleId));
156
+ }
157
+ function runOptions(tags, options = {}) {
126
158
  return {
127
159
  runOnly: {
128
160
  type: "tag",
129
161
  values: [...tags]
130
162
  },
131
163
  resultTypes: ["violations", "incomplete"],
132
- preload: false
164
+ preload: false,
165
+ ...options.skipBlindRules ? { rules: disabledBlindRules() } : {}
133
166
  };
134
167
  }
135
168
  /**
169
+ * The rules this engine cannot decide, switched off rather than run.
170
+ *
171
+ * Normally they are run and their verdict discarded: axe-core computes colour
172
+ * contrast against a stylesheet jsdom never fetched, and `shapeResults` throws
173
+ * the answer away as untrustworthy. The work is wasted either way, and it is
174
+ * not a small amount — measured at 14-19% of a page, since colour contrast is
175
+ * the most expensive rule axe-core has.
176
+ *
177
+ * What is lost is the element list. A rule that runs reports *which* elements
178
+ * it could not decide, and those are the elements a person has to check by
179
+ * hand; a rule that never runs cannot name them. That is the whole trade, and
180
+ * it is why this is behind a flag rather than the default.
181
+ *
182
+ * What does not change is the verdict. `shapeResults` already has a pass for
183
+ * rules axe-core skipped entirely — the preload-dependent ones arrive that way
184
+ * — and files them as unevaluated with the same reason. A criterion this run
185
+ * could not reach still reads as unreached.
186
+ */
187
+ function disabledBlindRules() {
188
+ const rules = {};
189
+ for (const ruleId of Object.keys(ENGINE_BLIND_RULES)) rules[ruleId] = { enabled: false };
190
+ return rules;
191
+ }
192
+ /**
136
193
  * Turn raw axe-core output into a PageAudit, shared by both engines so their
137
194
  * reports are directly comparable.
138
195
  *
@@ -279,4 +336,4 @@ function enClauses(tags) {
279
336
  return [...clauses].sort();
280
337
  }
281
338
  //#endregion
282
- export { findingElements as a, runOptions as c, failedPage as i, shapeResults as l, ENGINE_BLIND_RULES as n, pageUrl as o, blindRulesInScope as r, ruleOutcomes as s, DEFAULT_TAGS as t, successCriteria as u };
339
+ export { failedPage as a, runEngine as c, successCriteria as d, uniqueRuleOutcomes as f, blindRulesInScope as i, runOptions as l, DEFAULT_TAGS as n, findingElements as o, ENGINE_BLIND_RULES as r, pageUrl as s, DEFAULT_PAGE_TIMEOUT_MS as t, shapeResults as u };
@@ -0,0 +1,161 @@
1
+ import { d as optional, h as string, i as enumeration, l as number, m as safeParse, o as isoDate, p as record, u as object, y as withDefault } from "./schema-DJSF4K05.js";
2
+ import { a as isoDate$1 } from "./text-BF1LHMiV.js";
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ /** Default filename, used by the CLI when no path is given. */
6
+ const DEFAULT_REVIEW_FILE = "eaa-review.json";
7
+ /**
8
+ * What a person concluded about one criterion.
9
+ *
10
+ * `not-applicable` is kept apart from `met` for the reason `inapplicable` is
11
+ * kept apart from `passes` everywhere else in this tool: a criterion with
12
+ * nothing on the site to which it applies has not been met, it has been ruled
13
+ * out, and folding the two together would let an empty site look conformant.
14
+ */
15
+ const REVIEW_RESULTS = [
16
+ "met",
17
+ "not-met",
18
+ "not-applicable",
19
+ "unreviewed"
20
+ ];
21
+ const entrySchema = object({
22
+ result: withDefault(enumeration(REVIEW_RESULTS), () => "unreviewed"),
23
+ /** ISO date the check was done. Without it the entry cannot be aged. */
24
+ reviewedOn: optional(isoDate()),
25
+ /** Who did it, when that is not the person named on the record. */
26
+ reviewedBy: optional(string()),
27
+ /** What was checked and what was found. Free text, for whoever reads it. */
28
+ note: optional(string())
29
+ });
30
+ const reviewSchema = object({
31
+ schemaVersion: number(),
32
+ /** Who carried out the review, for the record as a whole. */
33
+ reviewedBy: optional(string()),
34
+ /** Keyed by success criterion number, e.g. '1.2.1'. */
35
+ criteria: withDefault(record(entrySchema), () => ({}))
36
+ });
37
+ var ReviewError = class extends Error {
38
+ name = "ReviewError";
39
+ };
40
+ /**
41
+ * What the record says about one criterion, and whether it counts.
42
+ *
43
+ * Returns undefined when there is nothing recorded, which is the ordinary case
44
+ * for most criteria of most sites: a missing entry and an `unreviewed` one mean
45
+ * the same thing and are reported the same way.
46
+ */
47
+ function criterionReview(criterionNumber, options, engineReachedVerdict) {
48
+ const entry = options.record.criteria[criterionNumber];
49
+ if (entry === void 0 || entry.result === "unreviewed") return void 0;
50
+ const attributed = {
51
+ ...entry,
52
+ ...entry.reviewedBy === void 0 && options.record.reviewedBy !== void 0 ? { reviewedBy: options.record.reviewedBy } : {}
53
+ };
54
+ const ignored = ignoredReason(entry, options, engineReachedVerdict);
55
+ return ignored === void 0 ? {
56
+ ...attributed,
57
+ counts: true
58
+ } : {
59
+ ...attributed,
60
+ counts: false,
61
+ ignored
62
+ };
63
+ }
64
+ function ignoredReason(entry, options, engineReachedVerdict) {
65
+ if (engineReachedVerdict) return "engine-reached-a-verdict";
66
+ if (options.maxAgeDays === void 0) return void 0;
67
+ if (entry.reviewedOn === void 0) return "undated";
68
+ return olderThan(entry.reviewedOn, options.maxAgeDays, options.today ?? /* @__PURE__ */ new Date()) ? "stale" : void 0;
69
+ }
70
+ /** Whole days between an ISO date and today, without pulling in a date library. */
71
+ function olderThan(reviewedOn, maxAgeDays, today) {
72
+ const then = Date.parse(`${reviewedOn}T00:00:00Z`);
73
+ if (Number.isNaN(then)) return true;
74
+ return (Date.parse(`${isoDate$1(today)}T00:00:00Z`) - then) / 864e5 > maxAgeDays;
75
+ }
76
+ /**
77
+ * What a person recorded, as the sentence both reports print under the criterion.
78
+ *
79
+ * One sentence rather than one per format: the console report and the HTML one
80
+ * were composing the same four parts in the same order, with two tables of
81
+ * reasons that had already drifted apart in wording. A reader comparing the two
82
+ * documents of one run should not have to work out whether they mean the same
83
+ * thing.
84
+ *
85
+ * An entry that was not counted still gets a sentence, with the reason. Dropping
86
+ * it would hide the one thing a reader has to act on: a review that has aged
87
+ * out, or one recorded against a criterion the run decided for itself.
88
+ */
89
+ function reviewSentence(review) {
90
+ const by = review.reviewedBy === void 0 ? "" : ` by ${review.reviewedBy}`;
91
+ const on = review.reviewedOn ?? "no date recorded";
92
+ const because = review.counts ? "" : `, not counted: ${IGNORED_REASONS[review.ignored ?? "stale"]}`;
93
+ return `checked by hand${by} (${on}): ${review.result}${because}`;
94
+ }
95
+ /** Why an entry was read and not counted, in words rather than a code. */
96
+ const IGNORED_REASONS = {
97
+ "engine-reached-a-verdict": "this run reached its own verdict here",
98
+ stale: "older than the maximum age this run was given",
99
+ undated: "no date recorded, and a maximum age was set"
100
+ };
101
+ /** A record with an entry for every criterion, keeping any answers already given. */
102
+ function blankReview(criteria, existing) {
103
+ const entries = {};
104
+ for (const criterion of criteria) entries[criterion.number] = existing?.criteria[criterion.number] ?? { result: "unreviewed" };
105
+ for (const [number, entry] of Object.entries(existing?.criteria ?? {})) entries[number] ??= entry;
106
+ return {
107
+ schemaVersion: 1,
108
+ ...existing?.reviewedBy ? { reviewedBy: existing.reviewedBy } : {},
109
+ criteria: sortByCriterion(entries)
110
+ };
111
+ }
112
+ /** How many criteria the record has a usable answer for, before any run is considered. */
113
+ function answeredCount(record) {
114
+ return Object.values(record.criteria).filter((entry) => entry.result !== "unreviewed").length;
115
+ }
116
+ /** Numerically by section, so 1.4.10 sorts after 1.4.5 rather than before it. */
117
+ function sortByCriterion(entries) {
118
+ return Object.fromEntries(Object.entries(entries).sort(([a], [b]) => compareCriteria(a, b)));
119
+ }
120
+ function compareCriteria(a, b) {
121
+ const left = a.split(".").map(Number);
122
+ const right = b.split(".").map(Number);
123
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
124
+ const difference = (left[index] ?? 0) - (right[index] ?? 0);
125
+ if (difference !== 0) return difference;
126
+ }
127
+ return 0;
128
+ }
129
+ function serialiseReview(record) {
130
+ return `${JSON.stringify(record, null, 2)}\n`;
131
+ }
132
+ async function readReview(file, cwd = process.cwd()) {
133
+ const target = path.resolve(cwd, file);
134
+ let raw;
135
+ try {
136
+ raw = await readFile(target, "utf8");
137
+ } catch {
138
+ throw new ReviewError(`Could not read the review record at ${file}. Create one with: eaa-kit checklist`);
139
+ }
140
+ let value;
141
+ try {
142
+ value = JSON.parse(raw);
143
+ } catch (cause) {
144
+ throw new ReviewError(`${path.basename(target)} is not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`);
145
+ }
146
+ const result = safeParse(reviewSchema, value);
147
+ if (!result.success) {
148
+ const issues = result.error.issues.map((issue) => `${issue.path.join(".") || "document"}: ${issue.message}`).slice(0, 5);
149
+ throw new ReviewError(`${path.basename(target)} is not an eaa-kit review record (${issues.join("; ")})`);
150
+ }
151
+ if (result.data.schemaVersion !== 1) throw new ReviewError(`${path.basename(target)} has schemaVersion ${result.data.schemaVersion}; this version of eaa-kit reads 1.`);
152
+ return result.data;
153
+ }
154
+ async function writeReview(file, record, cwd = process.cwd()) {
155
+ const target = path.resolve(cwd, file);
156
+ await mkdir(path.dirname(target), { recursive: true });
157
+ await writeFile(target, serialiseReview(record), "utf8");
158
+ return target;
159
+ }
160
+ //#endregion
161
+ export { blankReview as a, readReview as c, writeReview as d, answeredCount as i, reviewSentence as l, REVIEW_RESULTS as n, compareCriteria as o, ReviewError as r, criterionReview as s, DEFAULT_REVIEW_FILE as t, serialiseReview as u };
@@ -0,0 +1,2 @@
1
+ import { a as blankReview, c as readReview, d as writeReview, i as answeredCount, r as ReviewError } from "./review-CdMK2GpQ.js";
2
+ export { ReviewError, answeredCount, blankReview, readReview, writeReview };
@@ -35,7 +35,7 @@ async function auditBuild(directory, options, logger) {
35
35
  logger.info("skipped (enabled: false)");
36
36
  return;
37
37
  }
38
- const { runAuditCommand } = await import("./audit-CpXH2Mk8.js");
38
+ const { runAuditCommand } = await import("./audit-DK2StT1_.js");
39
39
  const { exitCode } = await runAuditCommand(directory, auditOptions);
40
40
  if (exitCode === 0) {
41
41
  logger.info("no violations at or above the threshold");
@@ -20,8 +20,25 @@ interface IntegrationOptions {
20
20
  baseUrl?: string;
21
21
  /** Audit in real Chromium. Needs the playwright peer. */
22
22
  browser?: boolean;
23
+ /**
24
+ * Skip the rules the browserless engine cannot decide rather than running
25
+ * them and discarding the answer. The verdict does not move — those rules are
26
+ * still reported as not evaluated — so what a build gives up is the list of
27
+ * elements a person would check by hand. No effect under `browser`.
28
+ */
29
+ fast?: boolean;
23
30
  concurrency?: number;
24
31
  baseline?: string;
32
+ /**
33
+ * Path to a review record: what a person checked, for the criteria no engine
34
+ * can reach. It changes nothing about whether the build fails — a review is a
35
+ * claim beside the run's own findings, never a verdict on them — but it is
36
+ * what makes the coverage in a build log describe the whole standard rather
37
+ * than only the automatable fraction of it.
38
+ */
39
+ review?: string;
40
+ /** Days after which a recorded review stops counting. */
41
+ reviewMaxAge?: number;
25
42
  format?: OutputFormat;
26
43
  /** Write the report here instead of the build log. */
27
44
  output?: string;