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.
- package/README.md +66 -11
- package/dist/astro/index.d.ts +1 -1
- package/dist/astro/index.js +1 -1
- package/dist/audit/runners/worker.js +5 -1
- package/dist/audit-DK2StT1_.js +2 -0
- package/dist/{audit-CPoZMXGM.js → audit-e_uixhPG.js} +123 -54
- package/dist/baseline-BscHfFSW.js +2 -0
- package/dist/{baseline-DB9CZGnV.js → baseline-D2qWmUC6.js} +40 -12
- package/dist/checklist-BtgkxMEa.js +44 -0
- package/dist/cli/index.js +153 -25
- package/dist/{collect-CFM8gEVv.js → collect-Cu1q9NTB.js} +34 -3
- package/dist/command-CZKp9Wi_.js +251 -0
- package/dist/{remediation-Dtowi2EC.js → completeness-Dwq3CPB3.js} +61 -43
- package/dist/{component-C3GL1Mnu.js → component-ChisoFyY.js} +1 -1
- package/dist/{component-DKd3EHOg.js → component-DcCn3BJS.js} +1 -1
- package/dist/coverage-7bQqWNfN.js +2 -0
- package/dist/{coverage-B4IBKMO8.js → coverage-BD2_CYns.js} +46 -8
- package/dist/{crawl-BiI1Lau_.js → crawl-BHkJmUkA.js} +119 -11
- package/dist/eleventy/index.d.ts +1 -1
- package/dist/eleventy/index.js +1 -1
- package/dist/fingerprint-BjYV_0F7.js +67 -0
- package/dist/{html-BGTO3ypW.js → html-Bmh7X_sM.js} +52 -30
- package/dist/{impact-DZt2oBCP.js → impact-luGCnN8F.js} +11 -1
- package/dist/index.d.ts +187 -5
- package/dist/index.js +3 -3
- package/dist/{init-DIWDE35F.js → init-BjKYttxc.js} +10 -4
- package/dist/{jsdom-DCpGSLfW.js → jsdom-CQar6OQS.js} +12 -4
- package/dist/jsdom-DMvU2h-f.js +3 -0
- package/dist/{json-QQuFIw1W.js → json-HJDgJp09.js} +1 -1
- package/dist/{json-DjEvy1nX.js → json-PNg6BeXp.js} +13 -18
- package/dist/load-Bc_xDGAh.js +2 -0
- package/dist/{load-5wRGLvub.js → load-vZdV_fD1.js} +137 -8
- package/dist/nuxt/index.d.ts +1 -1
- package/dist/nuxt/index.js +1 -1
- package/dist/{playwright-BWniOain.js → playwright-C-miJP2b.js} +32 -7
- package/dist/{pool-BMevaLWD.js → pool-dkexq5xo.js} +52 -2
- package/dist/{render-DrvXRCEn.js → render-CtsqR0k-.js} +150 -11
- package/dist/{result-DoamKFsp.js → result-DfUru6y_.js} +60 -3
- package/dist/review-CdMK2GpQ.js +161 -0
- package/dist/review-DIbOTmOM.js +2 -0
- package/dist/{run-DB34BSOZ.js → run-D7hdVXLA.js} +1 -1
- package/dist/{run-BMASMmwO.d.ts → run-YiqyQA2b.d.ts} +17 -0
- package/dist/{sarif-SR3_lLYd.js → sarif-BKvq_AUm.js} +25 -14
- package/dist/{schema-is6CGX2D.js → schema-DJSF4K05.js} +15 -1
- package/dist/statement/templates/es.en.md +125 -0
- package/dist/statement/templates/es.es.md +127 -0
- package/dist/statement/templates/fr.en.md +128 -0
- package/dist/statement/templates/fr.fr.md +131 -0
- package/dist/statement/templates/it.en.md +127 -0
- package/dist/statement/templates/it.it.md +130 -0
- package/dist/statement/templates/nl.en.md +125 -0
- package/dist/statement/templates/nl.nl.md +127 -0
- package/dist/{text-CKKpzkYM.js → text-BF1LHMiV.js} +22 -2
- package/dist/vite/index.d.ts +1 -1
- package/dist/vite/index.js +1 -1
- package/dist/webpack/index.d.ts +1 -1
- package/dist/webpack/index.js +1 -1
- package/dist/worksheet-eUjwYLxC.js +63 -0
- package/package.json +7 -3
- package/dist/audit-CpXH2Mk8.js +0 -2
- package/dist/baseline-22Y1NWxM.js +0 -2
- package/dist/command-D8l_oYbV.js +0 -77
- package/dist/fingerprint-DRoneAjj.js +0 -20
- package/dist/jsdom-4IMzv0eE.js +0 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { r as isFile } from "./fs-BmPtmFke.js";
|
|
2
|
-
import {
|
|
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-
|
|
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 {
|
|
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
|
|
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 !==
|
|
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(
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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 {
|
|
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 };
|
|
@@ -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-
|
|
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;
|