140 lines
4.8 KiB
JavaScript
140 lines
4.8 KiB
JavaScript
#!/usr/bin/env node
|
|
import { spawnSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
const BASELINE_PATH = ".eslintrc.a11y-baseline.json";
|
|
const DEFAULT_SCOPE = ["apps/web/app/**/*.tsx", "packages/ui/src/stories/**/*.tsx"];
|
|
|
|
function readBaseline(rootDir) {
|
|
return JSON.parse(fs.readFileSync(path.join(rootDir, BASELINE_PATH), "utf8"));
|
|
}
|
|
|
|
function baselineScope(baseline) {
|
|
if (Array.isArray(baseline.scope)) return baseline.scope;
|
|
if (typeof baseline.scope === "string" && baseline.scope.trim().length > 0) return [baseline.scope];
|
|
return DEFAULT_SCOPE;
|
|
}
|
|
|
|
function baselineRules(baseline) {
|
|
return Object.keys(baseline.rules ?? {}).sort();
|
|
}
|
|
|
|
function runEslint(rootDir, scope, rules) {
|
|
const ruleArgs = rules.flatMap((rule) => ["--rule", `${rule}:error`]);
|
|
const result = spawnSync("pnpm", ["exec", "eslint", "--format", "json", "--no-ignore", "--ext", ".tsx", ...ruleArgs, ...scope], {
|
|
cwd: rootDir,
|
|
encoding: "utf8",
|
|
maxBuffer: 1024 * 1024 * 20,
|
|
});
|
|
|
|
if (result.error) {
|
|
throw result.error;
|
|
}
|
|
|
|
if (![0, 1].includes(result.status)) {
|
|
throw new Error(result.stderr || `eslint exited with status ${result.status}`);
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(result.stdout || "[]");
|
|
} catch (error) {
|
|
throw new Error(`failed to parse ESLint JSON output: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
function countRuleMessages(eslintResults, rules) {
|
|
const counts = Object.fromEntries(rules.map((rule) => [rule, 0]));
|
|
const examples = Object.fromEntries(rules.map((rule) => [rule, []]));
|
|
|
|
for (const result of eslintResults) {
|
|
for (const message of result.messages ?? []) {
|
|
if (!rules.includes(message.ruleId)) continue;
|
|
counts[message.ruleId] += 1;
|
|
if (examples[message.ruleId].length < 3) {
|
|
examples[message.ruleId].push(`${path.relative(process.cwd(), result.filePath)}:${message.line}:${message.column}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { counts, examples };
|
|
}
|
|
|
|
export function verifyA11yBaseline(rootDir = process.cwd()) {
|
|
const baseline = readBaseline(rootDir);
|
|
const scope = baselineScope(baseline);
|
|
const rules = baselineRules(baseline);
|
|
const eslintResults = runEslint(rootDir, scope, rules);
|
|
const { counts, examples } = countRuleMessages(eslintResults, rules);
|
|
const failures = [];
|
|
const observations = [];
|
|
const passes = [];
|
|
|
|
for (const rule of rules) {
|
|
const expected = Number(baseline.rules[rule]?.violations ?? 0);
|
|
const actual = counts[rule] ?? 0;
|
|
if (actual > expected) {
|
|
failures.push(`${rule}: ${actual} violation(s), baseline allows ${expected}`);
|
|
continue;
|
|
}
|
|
if (actual < expected) {
|
|
observations.push(`${rule}: ${actual} violation(s), below baseline ${expected}; update ${BASELINE_PATH} when ratcheting`);
|
|
} else {
|
|
passes.push(`${rule}: ${actual} violation(s) matches baseline`);
|
|
}
|
|
}
|
|
|
|
const totalExpected = Number(baseline.totalViolations ?? 0);
|
|
const totalActual = Object.values(counts).reduce((sum, count) => sum + count, 0);
|
|
if (totalActual > totalExpected) {
|
|
failures.push(`totalViolations: ${totalActual}, baseline allows ${totalExpected}`);
|
|
} else if (totalActual < totalExpected) {
|
|
observations.push(`totalViolations: ${totalActual}, below baseline ${totalExpected}; update ${BASELINE_PATH} when ratcheting`);
|
|
} else {
|
|
passes.push(`totalViolations: ${totalActual} matches baseline`);
|
|
}
|
|
|
|
return { passes, observations, failures, counts, examples, scope };
|
|
}
|
|
|
|
export function formatA11yBaselineResult(result) {
|
|
const status = result.failures.length > 0 ? "FAIL" : "PASS";
|
|
const lines = [
|
|
`${status} accessibility baseline verification`,
|
|
`Scope: ${result.scope.join(", ")}`,
|
|
...Object.entries(result.counts).map(([rule, count]) => `COUNT ${rule}: ${count}`),
|
|
...result.passes.map((pass) => `PASS ${pass}`),
|
|
...result.observations.map((observation) => `OBSERVE ${observation}`),
|
|
...result.failures.map((failure) => `FAIL ${failure}`),
|
|
];
|
|
|
|
if (result.failures.length > 0) {
|
|
for (const [rule, examples] of Object.entries(result.examples)) {
|
|
if (examples.length > 0) {
|
|
lines.push(`EXAMPLE ${rule}: ${examples.join(", ")}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
lines.push(`Summary: ${result.passes.length} passed, ${result.observations.length} observed, ${result.failures.length} failed`, "");
|
|
return lines.join("\n");
|
|
}
|
|
|
|
const isCli = process.argv[1] && path.resolve(process.argv[1]) === new URL(import.meta.url).pathname;
|
|
|
|
if (isCli) {
|
|
try {
|
|
const result = verifyA11yBaseline(process.cwd());
|
|
const output = formatA11yBaselineResult(result);
|
|
if (result.failures.length > 0) {
|
|
process.stderr.write(output);
|
|
process.exitCode = 1;
|
|
} else {
|
|
process.stdout.write(output);
|
|
}
|
|
} catch (error) {
|
|
process.stderr.write(`FAIL accessibility baseline verification\n${error.message}\n`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|