@wcag-audit/cli 1.0.0-alpha.11

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 (79) hide show
  1. package/LICENSE +25 -0
  2. package/README.md +110 -0
  3. package/package.json +73 -0
  4. package/patches/@guidepup+guidepup+0.24.1.patch +30 -0
  5. package/src/__tests__/sanity.test.js +7 -0
  6. package/src/ai-fix-json.js +321 -0
  7. package/src/audit.js +199 -0
  8. package/src/cache/route-cache.js +46 -0
  9. package/src/cache/route-cache.test.js +96 -0
  10. package/src/checkers/ai-vision.js +102 -0
  11. package/src/checkers/auth.js +111 -0
  12. package/src/checkers/axe.js +65 -0
  13. package/src/checkers/consistency.js +222 -0
  14. package/src/checkers/forms.js +149 -0
  15. package/src/checkers/interaction.js +142 -0
  16. package/src/checkers/keyboard.js +351 -0
  17. package/src/checkers/media.js +102 -0
  18. package/src/checkers/motion.js +155 -0
  19. package/src/checkers/pointer.js +128 -0
  20. package/src/checkers/screen-reader.js +522 -0
  21. package/src/checkers/util/consistency-match.js +53 -0
  22. package/src/checkers/util/consistency-match.test.js +54 -0
  23. package/src/checkers/viewport.js +214 -0
  24. package/src/cli.js +169 -0
  25. package/src/commands/ci.js +63 -0
  26. package/src/commands/ci.test.js +55 -0
  27. package/src/commands/doctor.js +105 -0
  28. package/src/commands/doctor.test.js +81 -0
  29. package/src/commands/init.js +162 -0
  30. package/src/commands/init.test.js +83 -0
  31. package/src/commands/scan.js +362 -0
  32. package/src/commands/scan.test.js +139 -0
  33. package/src/commands/watch.js +89 -0
  34. package/src/config/global.js +60 -0
  35. package/src/config/global.test.js +58 -0
  36. package/src/config/project.js +35 -0
  37. package/src/config/project.test.js +44 -0
  38. package/src/devserver/spawn.js +82 -0
  39. package/src/devserver/spawn.test.js +58 -0
  40. package/src/discovery/astro.js +86 -0
  41. package/src/discovery/astro.test.js +76 -0
  42. package/src/discovery/crawl.js +93 -0
  43. package/src/discovery/crawl.test.js +93 -0
  44. package/src/discovery/dynamic-samples.js +44 -0
  45. package/src/discovery/dynamic-samples.test.js +66 -0
  46. package/src/discovery/manual.js +38 -0
  47. package/src/discovery/manual.test.js +52 -0
  48. package/src/discovery/nextjs.js +136 -0
  49. package/src/discovery/nextjs.test.js +141 -0
  50. package/src/discovery/registry.js +80 -0
  51. package/src/discovery/registry.test.js +33 -0
  52. package/src/discovery/remix.js +82 -0
  53. package/src/discovery/remix.test.js +77 -0
  54. package/src/discovery/sitemap.js +73 -0
  55. package/src/discovery/sitemap.test.js +69 -0
  56. package/src/discovery/sveltekit.js +85 -0
  57. package/src/discovery/sveltekit.test.js +76 -0
  58. package/src/discovery/vite.js +94 -0
  59. package/src/discovery/vite.test.js +144 -0
  60. package/src/license/log-usage.js +23 -0
  61. package/src/license/log-usage.test.js +45 -0
  62. package/src/license/request-free.js +46 -0
  63. package/src/license/request-free.test.js +57 -0
  64. package/src/license/validate.js +58 -0
  65. package/src/license/validate.test.js +58 -0
  66. package/src/output/agents-md.js +58 -0
  67. package/src/output/agents-md.test.js +62 -0
  68. package/src/output/cursor-rules.js +57 -0
  69. package/src/output/cursor-rules.test.js +62 -0
  70. package/src/output/excel-project.js +263 -0
  71. package/src/output/excel-project.test.js +165 -0
  72. package/src/output/markdown.js +119 -0
  73. package/src/output/markdown.test.js +95 -0
  74. package/src/report.js +239 -0
  75. package/src/util/anthropic.js +25 -0
  76. package/src/util/llm.js +159 -0
  77. package/src/util/screenshot.js +131 -0
  78. package/src/wcag-criteria.js +256 -0
  79. package/src/wcag-manual-steps.js +114 -0
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { requestFreeLicense } from "./request-free.js";
3
+
4
+ describe("requestFreeLicense", () => {
5
+ beforeEach(() => {
6
+ global.fetch = vi.fn();
7
+ });
8
+ afterEach(() => {
9
+ vi.restoreAllMocks();
10
+ });
11
+
12
+ it("rejects invalid email without hitting the network", async () => {
13
+ const result = await requestFreeLicense({ email: "not-an-email", machineId: "m1" });
14
+ expect(result.ok).toBe(false);
15
+ expect(result.error).toMatch(/email/i);
16
+ expect(global.fetch).not.toHaveBeenCalled();
17
+ });
18
+
19
+ it("rejects empty email", async () => {
20
+ const result = await requestFreeLicense({ email: "", machineId: "m1" });
21
+ expect(result.ok).toBe(false);
22
+ expect(global.fetch).not.toHaveBeenCalled();
23
+ });
24
+
25
+ it("returns ok when API returns 200", async () => {
26
+ global.fetch.mockResolvedValueOnce({
27
+ ok: true,
28
+ json: async () => ({ ok: true, message: "Check your email for your free license key" }),
29
+ });
30
+ const result = await requestFreeLicense({ email: "sai@example.com", machineId: "m1" });
31
+ expect(result.ok).toBe(true);
32
+ expect(result.message).toMatch(/email/i);
33
+ const [url, init] = global.fetch.mock.calls[0];
34
+ expect(url).toMatch(/\/v1\/license\/free$/);
35
+ const body = JSON.parse(init.body);
36
+ expect(body.email).toBe("sai@example.com");
37
+ expect(body.machineId).toBe("m1");
38
+ });
39
+
40
+ it("returns error on non-2xx", async () => {
41
+ global.fetch.mockResolvedValueOnce({
42
+ ok: false,
43
+ status: 429,
44
+ json: async () => ({ error: "Rate limit exceeded" }),
45
+ });
46
+ const result = await requestFreeLicense({ email: "sai@example.com", machineId: "m1" });
47
+ expect(result.ok).toBe(false);
48
+ expect(result.error).toMatch(/rate limit/i);
49
+ });
50
+
51
+ it("returns error when fetch throws", async () => {
52
+ global.fetch.mockRejectedValueOnce(new Error("ECONNREFUSED"));
53
+ const result = await requestFreeLicense({ email: "sai@example.com", machineId: "m1" });
54
+ expect(result.ok).toBe(false);
55
+ expect(result.error).toMatch(/network/i);
56
+ });
57
+ });
@@ -0,0 +1,58 @@
1
+ const LICENSE_KEY_RE = /^WCAG-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/;
2
+
3
+ const DEFAULT_API_URL = "https://wcagaudit.io/api/v1/license/validate";
4
+
5
+ export function isValidLicenseFormat(key) {
6
+ return typeof key === "string" && LICENSE_KEY_RE.test(key);
7
+ }
8
+
9
+ export async function validateLicense(key, { machineId, source = "cli", version = "1.0.0-alpha.1", apiUrl } = {}) {
10
+ const normalized = (key || "").trim().toUpperCase();
11
+
12
+ if (!isValidLicenseFormat(normalized)) {
13
+ return {
14
+ valid: false,
15
+ tier: "free",
16
+ error: "Invalid license key format. Expected WCAG-XXXX-XXXX-XXXX-XXXX.",
17
+ };
18
+ }
19
+
20
+ const url = apiUrl || process.env.WCAG_LICENSE_VALIDATE_URL || DEFAULT_API_URL;
21
+
22
+ try {
23
+ const res = await fetch(url, {
24
+ method: "POST",
25
+ headers: { "content-type": "application/json" },
26
+ body: JSON.stringify({
27
+ key: normalized,
28
+ machineId,
29
+ version,
30
+ source,
31
+ }),
32
+ });
33
+
34
+ const body = await res.json().catch(() => ({}));
35
+
36
+ if (!res.ok) {
37
+ return {
38
+ valid: false,
39
+ tier: body.tier || "free",
40
+ error: body.error || `License server returned ${res.status}`,
41
+ };
42
+ }
43
+
44
+ return {
45
+ valid: !!body.valid,
46
+ tier: body.tier || "free",
47
+ expiresAt: body.expiresAt || null,
48
+ creditsRemaining: body.creditsRemaining || null,
49
+ features: body.features || [],
50
+ };
51
+ } catch (err) {
52
+ return {
53
+ valid: false,
54
+ tier: "free",
55
+ error: `Network error: ${err.message}`,
56
+ };
57
+ }
58
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { validateLicense } from "./validate.js";
3
+
4
+ describe("validateLicense", () => {
5
+ beforeEach(() => {
6
+ global.fetch = vi.fn();
7
+ });
8
+ afterEach(() => {
9
+ vi.restoreAllMocks();
10
+ });
11
+
12
+ it("rejects invalid format without hitting the network", async () => {
13
+ const result = await validateLicense("not-a-key", { machineId: "m1" });
14
+ expect(result.valid).toBe(false);
15
+ expect(result.error).toMatch(/format/i);
16
+ expect(global.fetch).not.toHaveBeenCalled();
17
+ });
18
+
19
+ it("returns valid result for accepted license", async () => {
20
+ global.fetch.mockResolvedValueOnce({
21
+ ok: true,
22
+ json: async () => ({
23
+ valid: true,
24
+ tier: "pro",
25
+ expiresAt: null,
26
+ creditsRemaining: { server: 153, total: 153 },
27
+ }),
28
+ });
29
+ const result = await validateLicense("WCAG-TEST-AAAA-BBBB-CCCC", {
30
+ machineId: "m1",
31
+ });
32
+ expect(result.valid).toBe(true);
33
+ expect(result.tier).toBe("pro");
34
+ expect(result.creditsRemaining.total).toBe(153);
35
+ });
36
+
37
+ it("returns invalid result when API rejects the key", async () => {
38
+ global.fetch.mockResolvedValueOnce({
39
+ ok: false,
40
+ status: 403,
41
+ json: async () => ({ valid: false, error: "License revoked" }),
42
+ });
43
+ const result = await validateLicense("WCAG-TEST-AAAA-BBBB-CCCC", {
44
+ machineId: "m1",
45
+ });
46
+ expect(result.valid).toBe(false);
47
+ expect(result.error).toBe("License revoked");
48
+ });
49
+
50
+ it("returns network-error result when fetch throws", async () => {
51
+ global.fetch.mockRejectedValueOnce(new Error("ECONNREFUSED"));
52
+ const result = await validateLicense("WCAG-TEST-AAAA-BBBB-CCCC", {
53
+ machineId: "m1",
54
+ });
55
+ expect(result.valid).toBe(false);
56
+ expect(result.error).toMatch(/network|econnrefused/i);
57
+ });
58
+ });
@@ -0,0 +1,58 @@
1
+ import { readFile, writeFile } from "fs/promises";
2
+ import { join } from "path";
3
+
4
+ // Markers let us find and rewrite exactly our section without
5
+ // disturbing hand-written content before/after.
6
+ export const WCAG_SECTION_START = "<!-- wcag-audit:start -->";
7
+ export const WCAG_SECTION_END = "<!-- wcag-audit:end -->";
8
+
9
+ function buildSection(body) {
10
+ return [
11
+ WCAG_SECTION_START,
12
+ "## WCAG Accessibility Fixes",
13
+ "",
14
+ "The latest @wcag-audit/cli scan produced the fixes below.",
15
+ "Apply them when editing the affected files.",
16
+ "",
17
+ body.trimEnd(),
18
+ "",
19
+ WCAG_SECTION_END,
20
+ ].join("\n");
21
+ }
22
+
23
+ export async function upsertAgentsMd(projectDir, body) {
24
+ const path = join(projectDir, "AGENTS.md");
25
+ let current = "";
26
+ try {
27
+ current = await readFile(path, "utf8");
28
+ } catch {
29
+ // no existing file — we'll create one
30
+ }
31
+
32
+ const wantsRemoval = body === "";
33
+ const startIdx = current.indexOf(WCAG_SECTION_START);
34
+ const endIdx = current.indexOf(WCAG_SECTION_END);
35
+
36
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
37
+ const before = current.slice(0, startIdx).trimEnd();
38
+ const after = current.slice(endIdx + WCAG_SECTION_END.length).trimStart();
39
+ if (wantsRemoval) {
40
+ const merged = [before, after].filter(Boolean).join("\n\n").trimEnd() + "\n";
41
+ await writeFile(path, merged, "utf8");
42
+ return;
43
+ }
44
+ const merged = [before, buildSection(body), after].filter(Boolean).join("\n\n").trimEnd() + "\n";
45
+ await writeFile(path, merged, "utf8");
46
+ return;
47
+ }
48
+
49
+ if (wantsRemoval) {
50
+ // Nothing to remove — leave the file alone.
51
+ return;
52
+ }
53
+
54
+ const next = current.trim().length
55
+ ? current.trimEnd() + "\n\n" + buildSection(body) + "\n"
56
+ : buildSection(body) + "\n";
57
+ await writeFile(path, next, "utf8");
58
+ }
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtemp, rm, readFile, writeFile } from "fs/promises";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { upsertAgentsMd, WCAG_SECTION_START, WCAG_SECTION_END } from "./agents-md.js";
6
+
7
+ let dir;
8
+
9
+ beforeEach(async () => {
10
+ dir = await mkdtemp(join(tmpdir(), "agentsmd-"));
11
+ });
12
+
13
+ afterEach(async () => {
14
+ await rm(dir, { recursive: true, force: true });
15
+ });
16
+
17
+ describe("upsertAgentsMd", () => {
18
+ it("creates AGENTS.md when it does not exist", async () => {
19
+ await upsertAgentsMd(dir, "- Issue A\n- Issue B\n");
20
+ const raw = await readFile(join(dir, "AGENTS.md"), "utf8");
21
+ expect(raw).toMatch(WCAG_SECTION_START);
22
+ expect(raw).toMatch(/Issue A/);
23
+ expect(raw).toMatch(WCAG_SECTION_END);
24
+ });
25
+
26
+ it("appends the WCAG section to an existing AGENTS.md", async () => {
27
+ await writeFile(join(dir, "AGENTS.md"), "# Project\n\nExisting content.\n", "utf8");
28
+ await upsertAgentsMd(dir, "- Issue X\n");
29
+ const raw = await readFile(join(dir, "AGENTS.md"), "utf8");
30
+ expect(raw).toMatch(/Existing content/);
31
+ expect(raw).toMatch(WCAG_SECTION_START);
32
+ expect(raw).toMatch(/Issue X/);
33
+ });
34
+
35
+ it("replaces the WCAG section in-place when it already exists", async () => {
36
+ await writeFile(
37
+ join(dir, "AGENTS.md"),
38
+ `# Project\n\n${WCAG_SECTION_START}\n- Old issue\n${WCAG_SECTION_END}\n\nTrailing content.`,
39
+ "utf8",
40
+ );
41
+ await upsertAgentsMd(dir, "- Fresh issue\n");
42
+ const raw = await readFile(join(dir, "AGENTS.md"), "utf8");
43
+ expect(raw).not.toMatch(/Old issue/);
44
+ expect(raw).toMatch(/Fresh issue/);
45
+ expect(raw).toMatch(/Trailing content/);
46
+ // Single occurrence of the markers
47
+ expect(raw.match(new RegExp(WCAG_SECTION_START, "g"))?.length).toBe(1);
48
+ });
49
+
50
+ it("removes the WCAG section when body is empty string", async () => {
51
+ await writeFile(
52
+ join(dir, "AGENTS.md"),
53
+ `# Project\n\n${WCAG_SECTION_START}\n- Old\n${WCAG_SECTION_END}\n\nDone.\n`,
54
+ "utf8",
55
+ );
56
+ await upsertAgentsMd(dir, "");
57
+ const raw = await readFile(join(dir, "AGENTS.md"), "utf8");
58
+ expect(raw).not.toMatch(WCAG_SECTION_START);
59
+ expect(raw).not.toMatch(WCAG_SECTION_END);
60
+ expect(raw).toMatch(/Done\./);
61
+ });
62
+ });
@@ -0,0 +1,57 @@
1
+ // Renders `.cursor/rules/wcag-fixes.mdc` — Cursor's native rule format.
2
+ // When a user edits any file under the globs, Cursor auto-attaches the
3
+ // rule as context, so the agent "knows" about the WCAG issues in that
4
+ // file without needing a separate prompt.
5
+
6
+ export function renderCursorRules({ findings, projectName }) {
7
+ const frontMatter = [
8
+ "---",
9
+ "description: WCAG accessibility fixes generated by @wcag-audit/cli",
10
+ `globs: ${JSON.stringify(["src/app/**/*.tsx", "src/app/**/*.ts", "src/components/**/*.tsx"])}`,
11
+ "alwaysApply: false",
12
+ "---",
13
+ "",
14
+ ].join("\n");
15
+
16
+ if (!findings || findings.length === 0) {
17
+ return (
18
+ frontMatter +
19
+ `# WCAG Fixes (${projectName})\n\n✅ No outstanding WCAG issues found in the last scan.\n`
20
+ );
21
+ }
22
+
23
+ // Group findings by source file so the rule is contextually useful
24
+ // when the agent is editing one of those files.
25
+ const byFile = new Map();
26
+ for (const f of findings) {
27
+ const key = f.sourceFile || "(unknown)";
28
+ if (!byFile.has(key)) byFile.set(key, []);
29
+ byFile.get(key).push(f);
30
+ }
31
+
32
+ const lines = [
33
+ frontMatter,
34
+ `# WCAG Fixes (${projectName})`,
35
+ "",
36
+ "When editing any file listed below, apply the recommended accessibility fix.",
37
+ "Each item has the WCAG criterion, impact level, and specific remediation.",
38
+ "",
39
+ ];
40
+
41
+ for (const [file, items] of byFile.entries()) {
42
+ lines.push(`## ${file}`);
43
+ lines.push("");
44
+ for (const f of items) {
45
+ const criteriaList = Array.isArray(f.criteria) ? f.criteria : f.criteria ? [f.criteria] : [];
46
+ const crit = criteriaList.join(", ") || "?";
47
+ lines.push(`- **${f.description || f.ruleId}** — ${crit} (${f.level || "?"}, ${f.impact || "minor"})`);
48
+ if (f.help) lines.push(` - Fix: ${f.help}`);
49
+ if (f.evidence) lines.push(` - Evidence: \`${String(f.evidence).replace(/`/g, "'").slice(0, 120)}\``);
50
+ if (f.route) lines.push(` - Observed on route: \`${f.route}\``);
51
+ if (f.helpUrl) lines.push(` - Reference: ${f.helpUrl}`);
52
+ }
53
+ lines.push("");
54
+ }
55
+
56
+ return lines.join("\n");
57
+ }
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderCursorRules } from "./cursor-rules.js";
3
+
4
+ describe("renderCursorRules", () => {
5
+ const findings = [
6
+ {
7
+ source: "axe",
8
+ ruleId: "image-alt",
9
+ criteria: ["1.1.1"],
10
+ level: "A",
11
+ impact: "critical",
12
+ description: "Images must have alternate text",
13
+ help: "Add an alt attribute.",
14
+ helpUrl: "https://example/axe/image-alt",
15
+ selector: "img.hero",
16
+ evidence: "<img src='/x.png'>",
17
+ route: "/",
18
+ sourceFile: "src/app/page.tsx",
19
+ },
20
+ {
21
+ source: "playwright",
22
+ ruleId: "kbd/focus-visible",
23
+ criteria: "2.4.7",
24
+ level: "AA",
25
+ impact: "serious",
26
+ description: "No visible focus indicator.",
27
+ help: "Add :focus-visible styles.",
28
+ helpUrl: "https://example/focus-visible",
29
+ selector: "button",
30
+ evidence: "<button/>",
31
+ route: "/about",
32
+ sourceFile: "src/app/about/page.tsx",
33
+ },
34
+ ];
35
+
36
+ it("includes Cursor MDC front-matter with globs", () => {
37
+ const out = renderCursorRules({ findings, projectName: "demo" });
38
+ expect(out).toMatch(/^---\n/);
39
+ expect(out).toMatch(/description: WCAG accessibility fixes/);
40
+ expect(out).toMatch(/globs: \["src\/app\/\*\*\/\*.tsx","src\/app\/\*\*\/\*.ts","src\/components\/\*\*\/\*.tsx"\]/);
41
+ expect(out).toMatch(/alwaysApply: false/);
42
+ });
43
+
44
+ it("returns a minimal no-issues rule when findings is empty", () => {
45
+ const out = renderCursorRules({ findings: [], projectName: "demo" });
46
+ expect(out).toMatch(/^---\n/);
47
+ expect(out).toMatch(/No outstanding WCAG issues found/);
48
+ });
49
+
50
+ it("groups findings by source file so Cursor attaches the rule contextually", () => {
51
+ const out = renderCursorRules({ findings, projectName: "demo" });
52
+ expect(out).toMatch(/src\/app\/page\.tsx/);
53
+ expect(out).toMatch(/src\/app\/about\/page\.tsx/);
54
+ expect(out.indexOf("src/app/page.tsx")).toBeLessThan(out.indexOf("src/app/about/page.tsx"));
55
+ });
56
+
57
+ it("tags each finding with its criterion + impact", () => {
58
+ const out = renderCursorRules({ findings, projectName: "demo" });
59
+ expect(out).toMatch(/1\.1\.1 \(A, critical\)/);
60
+ expect(out).toMatch(/2\.4\.7 \(AA, serious\)/);
61
+ });
62
+ });
@@ -0,0 +1,263 @@
1
+ // Multi-page (project-wide) Excel report writer.
2
+ //
3
+ // Unlike src/report.js (which writes one Excel per page audit with
4
+ // embedded screenshots), this writer aggregates findings across all
5
+ // routes into three sheets: Summary, Issues, Coverage.
6
+ //
7
+ // No embedded screenshots — a 50-route project would produce a 500MB+
8
+ // file. The markdown output already has the text evidence, and users
9
+ // can run `wcag-audit audit <url>` for a single-page Excel with
10
+ // screenshots if they need them.
11
+
12
+ import ExcelJS from "exceljs";
13
+ import { WCAG_CRITERIA, filterCriteriaForScope } from "../wcag-criteria.js";
14
+ import { getManualGuide } from "../wcag-manual-steps.js";
15
+
16
+ const IMPACT_ORDER = ["critical", "serious", "moderate", "minor"];
17
+
18
+ function automationLabel(c) {
19
+ const hasAxe = !!c.axeRules && c.axeRules.length > 0;
20
+ const hasDynamic = !!c.playwrightCheckable;
21
+ const hasAi = !!c.aiReviewable;
22
+ if (c.automation === "automated") return "Automated";
23
+ if (hasAxe && (hasDynamic || hasAi)) return "Automated";
24
+ if (hasDynamic && hasAi) return "Automated";
25
+ if (hasAxe || hasDynamic || hasAi) return "Partial";
26
+ return "Manual";
27
+ }
28
+
29
+ export async function writeProjectReport({
30
+ findings,
31
+ meta,
32
+ outPath,
33
+ }) {
34
+ const wb = new ExcelJS.Workbook();
35
+ wb.creator = meta.generator || "@wcag-audit/cli";
36
+ wb.created = new Date();
37
+
38
+ // ── Summary sheet ─────────────────────────────────────────────
39
+ const sum = wb.addWorksheet("Summary");
40
+ sum.columns = [
41
+ { header: "", key: "k", width: 28 },
42
+ { header: "", key: "v", width: 60 },
43
+ ];
44
+
45
+ const impactCounts = countByImpact(findings);
46
+
47
+ const rows = [
48
+ ["WCAG Audit — Project Report"],
49
+ [],
50
+ ["Project", meta.projectName],
51
+ ["Generator", meta.generator],
52
+ ["Generated", new Date().toISOString()],
53
+ [],
54
+ ["Framework", meta.framework || "—"],
55
+ ["Strategy", meta.strategy || "—"],
56
+ ["WCAG version", meta.wcagVersion],
57
+ ["Levels", (meta.levels || []).join(", ")],
58
+ [],
59
+ ["Total pages audited", String(meta.totalPages)],
60
+ ["Pages with new audits", String(meta.newPagesCount ?? meta.totalPages)],
61
+ ["Pages served from cache", String(meta.cachedPagesCount ?? 0)],
62
+ [],
63
+ ["Total issues", String(findings.length)],
64
+ [" Critical", String(impactCounts.critical)],
65
+ [" Serious", String(impactCounts.serious)],
66
+ [" Moderate", String(impactCounts.moderate)],
67
+ [" Minor", String(impactCounts.minor)],
68
+ ];
69
+ rows.forEach((r) => sum.addRow(r));
70
+ sum.getRow(1).font = { bold: true, size: 14, color: { argb: "FF0969DA" } };
71
+
72
+ // ── Issues sheet ──────────────────────────────────────────────
73
+ const issues = wb.addWorksheet("Issues");
74
+ issues.columns = [
75
+ { header: "Route", key: "route", width: 28 },
76
+ { header: "File", key: "sourceFile", width: 38 },
77
+ { header: "Criterion", key: "criteria", width: 14 },
78
+ { header: "Level", key: "level", width: 8 },
79
+ { header: "Impact", key: "impact", width: 12 },
80
+ { header: "Rule", key: "ruleId", width: 24 },
81
+ { header: "Description", key: "description", width: 50 },
82
+ { header: "Fix", key: "help", width: 50 },
83
+ { header: "Detected by", key: "source", width: 14 },
84
+ { header: "Selector", key: "selector", width: 30 },
85
+ { header: "Evidence", key: "evidence", width: 40 },
86
+ { header: "Reference", key: "helpUrl", width: 40 },
87
+ ];
88
+ issues.getRow(1).font = { bold: true };
89
+ issues.getRow(1).fill = {
90
+ type: "pattern",
91
+ pattern: "solid",
92
+ fgColor: { argb: "FFF1F5F9" },
93
+ };
94
+
95
+ // Sort by impact desc, then by route, so critical issues surface first
96
+ const sorted = [...findings].sort((a, b) => {
97
+ const ai = IMPACT_ORDER.indexOf(a.impact);
98
+ const bi = IMPACT_ORDER.indexOf(b.impact);
99
+ const aImp = ai === -1 ? 4 : ai;
100
+ const bImp = bi === -1 ? 4 : bi;
101
+ if (aImp !== bImp) return aImp - bImp;
102
+ return String(a.route || "").localeCompare(String(b.route || ""));
103
+ });
104
+
105
+ for (const f of sorted) {
106
+ const criteria = Array.isArray(f.criteria) ? f.criteria.join(", ") : f.criteria || "";
107
+ issues.addRow({
108
+ route: f.route || "",
109
+ sourceFile: f.sourceFile || "",
110
+ criteria,
111
+ level: f.level || "",
112
+ impact: f.impact || "",
113
+ ruleId: f.ruleId || "",
114
+ description: truncate(f.description, 400),
115
+ help: truncate(f.help, 400),
116
+ source: f.source || "",
117
+ selector: truncate(f.selector, 200),
118
+ evidence: truncate(asText(f.evidence), 400),
119
+ helpUrl: f.helpUrl || "",
120
+ });
121
+ }
122
+
123
+ // Color-code impact column
124
+ issues.eachRow({ includeEmpty: false }, (row, rowNum) => {
125
+ if (rowNum === 1) return;
126
+ const impact = row.getCell("impact").value;
127
+ const fill = impactFill(impact);
128
+ if (fill) row.getCell("impact").fill = fill;
129
+ });
130
+
131
+ // Freeze header row
132
+ issues.views = [{ state: "frozen", ySplit: 1 }];
133
+ // Enable autofilter
134
+ issues.autoFilter = {
135
+ from: { row: 1, column: 1 },
136
+ to: { row: 1, column: issues.columns.length },
137
+ };
138
+
139
+ // ── Coverage sheet ────────────────────────────────────────────
140
+ // Lists EVERY WCAG criterion in scope (version + levels the user
141
+ // requested), not just the ones with violations. For each criterion
142
+ // the sheet shows:
143
+ // - automation status (Automated / Partial / Manual)
144
+ // - what our engines verified automatically
145
+ // - MANUAL STEPS a human must perform to confirm conformance
146
+ // - violations found in this scan (if any)
147
+ //
148
+ // Auditors can use this sheet as the checklist for a WCAG 2.2 AA
149
+ // conformance statement: partial + manual rows are where humans
150
+ // plug the gap.
151
+ const cov = wb.addWorksheet("Coverage");
152
+ cov.columns = [
153
+ { header: "Criterion", key: "id", width: 11 },
154
+ { header: "Title", key: "title", width: 36 },
155
+ { header: "Level", key: "level", width: 7 },
156
+ { header: "Added", key: "addedIn", width: 8 },
157
+ { header: "Status", key: "status", width: 12 },
158
+ { header: "What the tool verified", key: "automated", width: 50 },
159
+ { header: "Manual steps to verify", key: "manual", width: 60 },
160
+ { header: "Violations found", key: "count", width: 14 },
161
+ { header: "Routes affected", key: "routes", width: 28 },
162
+ { header: "Reference", key: "url", width: 42 },
163
+ ];
164
+ cov.getRow(1).font = { bold: true };
165
+ cov.getRow(1).fill = {
166
+ type: "pattern",
167
+ pattern: "solid",
168
+ fgColor: { argb: "FFF1F5F9" },
169
+ };
170
+
171
+ // Build { criterionId -> { count, routes } } from the scan findings.
172
+ const byCriterion = new Map();
173
+ for (const f of findings) {
174
+ const critList = Array.isArray(f.criteria) ? f.criteria : f.criteria ? [f.criteria] : [];
175
+ for (const c of critList) {
176
+ if (!byCriterion.has(c)) byCriterion.set(c, { routes: new Set(), count: 0 });
177
+ const entry = byCriterion.get(c);
178
+ entry.count++;
179
+ if (f.route) entry.routes.add(f.route);
180
+ }
181
+ }
182
+
183
+ // Emit one row PER criterion in scope (using the same scope the CLI
184
+ // used to build axe tags). This produces ~55 rows for WCAG 2.2 A+AA,
185
+ // versus the old behaviour of only emitting violated criteria.
186
+ const scoped = filterCriteriaForScope(meta.wcagVersion || "2.2", meta.levels || ["A", "AA"]);
187
+ const sortedScoped = [...scoped].sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true }));
188
+ for (const c of sortedScoped) {
189
+ const guide = getManualGuide(c.id);
190
+ const entry = byCriterion.get(c.id) || { routes: new Set(), count: 0 };
191
+ cov.addRow({
192
+ id: c.id,
193
+ title: c.title,
194
+ level: c.level,
195
+ addedIn: c.addedIn,
196
+ status: automationLabel(c),
197
+ automated: guide.automated || "",
198
+ manual: guide.manualSteps || "",
199
+ count: entry.count,
200
+ routes: [...entry.routes].sort().join(", "),
201
+ url: `https://www.w3.org/WAI/WCAG22/Understanding/${c.id.replace(/\./g, "-")}`,
202
+ });
203
+ }
204
+
205
+ // Color-code the Status column so Manual/Partial stand out.
206
+ cov.eachRow({ includeEmpty: false }, (row, rowNum) => {
207
+ if (rowNum === 1) return;
208
+ const status = String(row.getCell("status").value || "");
209
+ let fill = null;
210
+ if (status === "Automated") fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFDCFCE7" } };
211
+ else if (status === "Partial") fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFFEF3C7" } };
212
+ else if (status === "Manual") fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFE5E5E5" } };
213
+ if (fill) row.getCell("status").fill = fill;
214
+ // Wrap long text columns
215
+ row.getCell("title").alignment = { wrapText: true, vertical: "top" };
216
+ row.getCell("automated").alignment = { wrapText: true, vertical: "top" };
217
+ row.getCell("manual").alignment = { wrapText: true, vertical: "top" };
218
+ });
219
+ cov.views = [{ state: "frozen", ySplit: 1 }];
220
+ cov.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: cov.columns.length } };
221
+
222
+ await wb.xlsx.writeFile(outPath);
223
+ }
224
+
225
+ function countByImpact(findings) {
226
+ const counts = { critical: 0, serious: 0, moderate: 0, minor: 0 };
227
+ for (const f of findings) {
228
+ const key = IMPACT_ORDER.includes(f.impact) ? f.impact : "minor";
229
+ counts[key]++;
230
+ }
231
+ return counts;
232
+ }
233
+
234
+ function impactFill(impact) {
235
+ switch (impact) {
236
+ case "critical":
237
+ return { type: "pattern", pattern: "solid", fgColor: { argb: "FFFEE2E2" } };
238
+ case "serious":
239
+ return { type: "pattern", pattern: "solid", fgColor: { argb: "FFFFE4CC" } };
240
+ case "moderate":
241
+ return { type: "pattern", pattern: "solid", fgColor: { argb: "FFFEF3C7" } };
242
+ case "minor":
243
+ return { type: "pattern", pattern: "solid", fgColor: { argb: "FFE0E7FF" } };
244
+ default:
245
+ return null;
246
+ }
247
+ }
248
+
249
+ function truncate(value, max) {
250
+ const s = asText(value);
251
+ if (s.length <= max) return s;
252
+ return s.slice(0, max - 1) + "…";
253
+ }
254
+
255
+ function asText(value) {
256
+ if (value == null) return "";
257
+ if (typeof value === "string") return value;
258
+ try {
259
+ return JSON.stringify(value);
260
+ } catch {
261
+ return String(value);
262
+ }
263
+ }