@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,165 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtemp, rm, readFile } from "fs/promises";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import ExcelJS from "exceljs";
6
+ import { writeProjectReport } from "./excel-project.js";
7
+
8
+ let dir;
9
+ beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), "excel-")); });
10
+ afterEach(async () => { await rm(dir, { recursive: true, force: true }); });
11
+
12
+ async function loadWorkbook(path) {
13
+ const wb = new ExcelJS.Workbook();
14
+ await wb.xlsx.readFile(path);
15
+ return wb;
16
+ }
17
+
18
+ const sampleMeta = {
19
+ projectName: "my-nextjs-app",
20
+ generator: "@wcag-audit/cli v1.0.0-alpha.8",
21
+ framework: "nextjs-app",
22
+ strategy: "source-walk",
23
+ wcagVersion: "2.2",
24
+ levels: ["A", "AA"],
25
+ totalPages: 3,
26
+ newPagesCount: 3,
27
+ cachedPagesCount: 0,
28
+ };
29
+
30
+ const sampleFindings = [
31
+ {
32
+ source: "axe", ruleId: "image-alt", criteria: ["1.1.1"], level: "A",
33
+ impact: "critical", description: "Images must have alt text",
34
+ help: "Add an alt attribute", helpUrl: "https://example/1",
35
+ selector: "img.hero", evidence: "<img>", route: "/", sourceFile: "app/page.tsx",
36
+ },
37
+ {
38
+ source: "playwright", ruleId: "kbd/focus", criteria: "2.4.7", level: "AA",
39
+ impact: "serious", description: "Focus indicator missing",
40
+ help: "Add :focus-visible", helpUrl: "https://example/2",
41
+ selector: "button", evidence: "<button>", route: "/about", sourceFile: "app/about/page.tsx",
42
+ },
43
+ {
44
+ source: "axe", ruleId: "color-contrast", criteria: "1.4.3", level: "AA",
45
+ impact: "minor", description: "Low contrast",
46
+ help: "Increase contrast", helpUrl: "https://example/3",
47
+ selector: ".muted", evidence: "rgb(150,150,150) on rgb(200,200,200)",
48
+ route: "/pricing", sourceFile: "app/pricing/page.tsx",
49
+ },
50
+ ];
51
+
52
+ describe("writeProjectReport", () => {
53
+ it("creates a workbook with 3 sheets: Summary, Issues, Coverage", async () => {
54
+ const outPath = join(dir, "report.xlsx");
55
+ await writeProjectReport({ findings: sampleFindings, meta: sampleMeta, outPath });
56
+ const wb = await loadWorkbook(outPath);
57
+ const names = wb.worksheets.map((ws) => ws.name);
58
+ expect(names).toEqual(["Summary", "Issues", "Coverage"]);
59
+ });
60
+
61
+ it("Summary sheet includes project metadata + impact counts", async () => {
62
+ const outPath = join(dir, "report.xlsx");
63
+ await writeProjectReport({ findings: sampleFindings, meta: sampleMeta, outPath });
64
+ const wb = await loadWorkbook(outPath);
65
+ const sum = wb.getWorksheet("Summary");
66
+ const cells = [];
67
+ sum.eachRow((row) => row.eachCell((c) => cells.push(String(c.value ?? ""))));
68
+ const joined = cells.join(" | ");
69
+ expect(joined).toContain("my-nextjs-app");
70
+ expect(joined).toContain("nextjs-app");
71
+ expect(joined).toContain("source-walk");
72
+ expect(joined).toContain("2.2");
73
+ // Impact counts: 1 critical, 1 serious, 0 moderate, 1 minor
74
+ expect(joined).toMatch(/Critical.*1/);
75
+ expect(joined).toMatch(/Serious.*1/);
76
+ expect(joined).toMatch(/Minor.*1/);
77
+ });
78
+
79
+ it("Issues sheet has one row per finding (+ header)", async () => {
80
+ const outPath = join(dir, "report.xlsx");
81
+ await writeProjectReport({ findings: sampleFindings, meta: sampleMeta, outPath });
82
+ const wb = await loadWorkbook(outPath);
83
+ const issues = wb.getWorksheet("Issues");
84
+ // 1 header row + 3 findings
85
+ expect(issues.rowCount).toBe(4);
86
+ });
87
+
88
+ it("Issues sheet sorts by impact severity (critical first)", async () => {
89
+ const outPath = join(dir, "report.xlsx");
90
+ await writeProjectReport({ findings: sampleFindings, meta: sampleMeta, outPath });
91
+ const wb = await loadWorkbook(outPath);
92
+ const issues = wb.getWorksheet("Issues");
93
+ // Columns: 1=route, 2=sourceFile, 3=criteria, 4=level, 5=impact
94
+ // Row 2 (first data row) should be critical
95
+ expect(String(issues.getRow(2).getCell(5).value)).toBe("critical");
96
+ expect(String(issues.getRow(3).getCell(5).value)).toBe("serious");
97
+ expect(String(issues.getRow(4).getCell(5).value)).toBe("minor");
98
+ });
99
+
100
+ it("Coverage sheet lists every scoped criterion and marks violated ones", async () => {
101
+ const outPath = join(dir, "report.xlsx");
102
+ await writeProjectReport({ findings: sampleFindings, meta: sampleMeta, outPath });
103
+ const wb = await loadWorkbook(outPath);
104
+ const cov = wb.getWorksheet("Coverage");
105
+ // WCAG 2.2 A+AA has ~55 criteria + 1 header row. Exact number can shift
106
+ // as W3C revises the spec, so just assert "significantly more than the
107
+ // number of violated criteria".
108
+ expect(cov.rowCount).toBeGreaterThan(40);
109
+ const ids = [];
110
+ const violatedWithCount = [];
111
+ cov.eachRow((row, i) => {
112
+ if (i === 1) return;
113
+ const id = String(row.getCell(1).value);
114
+ ids.push(id);
115
+ // Column 8 is violations found (after id,title,level,addedIn,status,automated,manual)
116
+ const count = Number(row.getCell(8).value) || 0;
117
+ if (count > 0) violatedWithCount.push({ id, count });
118
+ });
119
+ // The violated criteria from sampleFindings should appear with non-zero counts
120
+ expect(ids).toContain("1.1.1");
121
+ expect(ids).toContain("1.4.3");
122
+ expect(ids).toContain("2.4.7");
123
+ expect(violatedWithCount.map((v) => v.id).sort()).toEqual(["1.1.1", "1.4.3", "2.4.7"]);
124
+ });
125
+
126
+ it("Coverage sheet includes manual-verification steps for partial / manual criteria", async () => {
127
+ const outPath = join(dir, "report.xlsx");
128
+ await writeProjectReport({ findings: [], meta: sampleMeta, outPath });
129
+ const wb = await loadWorkbook(outPath);
130
+ const cov = wb.getWorksheet("Coverage");
131
+ let sawManualSteps = false;
132
+ cov.eachRow((row, i) => {
133
+ if (i === 1) return;
134
+ // Column 7 = manual steps
135
+ if (String(row.getCell(7).value || "").length > 20) sawManualSteps = true;
136
+ });
137
+ expect(sawManualSteps).toBe(true);
138
+ });
139
+
140
+ it("handles zero findings", async () => {
141
+ const outPath = join(dir, "report.xlsx");
142
+ await writeProjectReport({ findings: [], meta: sampleMeta, outPath });
143
+ const wb = await loadWorkbook(outPath);
144
+ const issues = wb.getWorksheet("Issues");
145
+ // Just the header row
146
+ expect(issues.rowCount).toBe(1);
147
+ const cov = wb.getWorksheet("Coverage");
148
+ // Coverage still has all scoped criteria even with zero findings
149
+ expect(cov.rowCount).toBeGreaterThan(40);
150
+ });
151
+
152
+ it("handles criteria as string or array", async () => {
153
+ const outPath = join(dir, "report.xlsx");
154
+ const mixed = [
155
+ { impact: "critical", criteria: ["1.1.1", "4.1.2"], ruleId: "a", route: "/" },
156
+ { impact: "minor", criteria: "2.4.3", ruleId: "b", route: "/x" },
157
+ ];
158
+ await writeProjectReport({ findings: mixed, meta: sampleMeta, outPath });
159
+ const wb = await loadWorkbook(outPath);
160
+ const issues = wb.getWorksheet("Issues");
161
+ // Column 3 = criteria. Impact sort: critical first, then minor.
162
+ expect(String(issues.getRow(2).getCell(3).value)).toBe("1.1.1, 4.1.2");
163
+ expect(String(issues.getRow(3).getCell(3).value)).toBe("2.4.3");
164
+ });
165
+ });
@@ -0,0 +1,119 @@
1
+ // Renders findings as an AI-ready markdown document. The format is
2
+ // designed for Cursor / Claude Code / Windsurf — each finding has
3
+ // enough context (route, file, snippet, criterion, fix hint) for an
4
+ // AI agent to open the file and apply the fix directly.
5
+
6
+ const IMPACT_ORDER = ["critical", "serious", "moderate", "minor"];
7
+
8
+ export function renderFindingsMarkdown({
9
+ projectName,
10
+ generator,
11
+ totalPages,
12
+ wcagVersion,
13
+ levels,
14
+ findings,
15
+ }) {
16
+ const header = [
17
+ `# WCAG Audit Findings — ${projectName}`,
18
+ ``,
19
+ `Generated by ${generator}`,
20
+ `Total pages audited: ${totalPages}`,
21
+ `Total issues: ${findings.length}`,
22
+ `Criteria: WCAG ${wcagVersion} Level ${levels.join(" + ")}`,
23
+ ``,
24
+ ].join("\n");
25
+
26
+ if (findings.length === 0) {
27
+ return header + "\n✅ No issues found!\n";
28
+ }
29
+
30
+ const merged = mergeFindings(findings);
31
+
32
+ const groups = new Map(IMPACT_ORDER.map((k) => [k, []]));
33
+ for (const f of merged) {
34
+ const bucket = IMPACT_ORDER.includes(f.impact) ? f.impact : "minor";
35
+ groups.get(bucket).push(f);
36
+ }
37
+
38
+ const sections = [];
39
+ for (const impact of IMPACT_ORDER) {
40
+ const items = groups.get(impact);
41
+ if (!items.length) continue;
42
+ sections.push(
43
+ `## ${capitalize(impact)} Issues (${items.length})`,
44
+ ``,
45
+ ...items.map((f, i) => renderFinding(f, i + 1)),
46
+ );
47
+ }
48
+
49
+ return header + "\n" + sections.join("\n") + "\n";
50
+ }
51
+
52
+ function renderFinding(f, index) {
53
+ // criteria may be a string like "1.1.1" or an array ["1.1.1", "2.4.7"]
54
+ const criteriaList = Array.isArray(f.criteria)
55
+ ? f.criteria
56
+ : f.criteria
57
+ ? [f.criteria]
58
+ : [];
59
+ const criteria = criteriaList.join(", ") || "—";
60
+ const routesLine =
61
+ f.routes.length === 1
62
+ ? `- **Route:** \`${f.routes[0]}\``
63
+ : `- Routes affected: ${f.routes.length}\n${f.routes.map((r) => `- \`${r}\``).join("\n")}`;
64
+
65
+ const fileLine = f.sourceFile
66
+ ? `- **File:** \`${f.sourceFile}\``
67
+ : "- **File:** _approximate — source map not available_";
68
+
69
+ const snippet = f.evidence
70
+ ? "\n**Current code:**\n\n```html\n" + escapeCodeBlock(f.evidence) + "\n```\n"
71
+ : "";
72
+
73
+ const fixHint = f.help
74
+ ? `\n**How to fix:** ${f.help}\n`
75
+ : "";
76
+
77
+ return [
78
+ `### ${index}. ${f.description || f.ruleId}`,
79
+ ``,
80
+ fileLine,
81
+ routesLine,
82
+ `- **Criterion:** ${criteria} (Level ${f.level || "?"})`,
83
+ `- **Rule:** \`${f.ruleId}\``,
84
+ `- **Detected by:** ${f.source}`,
85
+ f.helpUrl ? `- **Reference:** ${f.helpUrl}` : "",
86
+ snippet,
87
+ fixHint,
88
+ "---",
89
+ "",
90
+ ]
91
+ .filter(Boolean)
92
+ .join("\n");
93
+ }
94
+
95
+ function mergeFindings(findings) {
96
+ const byKey = new Map();
97
+ for (const f of findings) {
98
+ const key = `${f.ruleId}::${f.selector || ""}::${f.evidence || ""}`;
99
+ if (!byKey.has(key)) {
100
+ byKey.set(key, { ...f, routes: [], sourceFiles: new Set() });
101
+ }
102
+ const entry = byKey.get(key);
103
+ if (f.route) entry.routes.push(f.route);
104
+ if (f.sourceFile) entry.sourceFiles.add(f.sourceFile);
105
+ }
106
+ for (const entry of byKey.values()) {
107
+ entry.routes = [...new Set(entry.routes)];
108
+ entry.sourceFile = [...entry.sourceFiles][0] || null;
109
+ }
110
+ return [...byKey.values()];
111
+ }
112
+
113
+ function capitalize(s) {
114
+ return s.charAt(0).toUpperCase() + s.slice(1);
115
+ }
116
+
117
+ function escapeCodeBlock(s) {
118
+ return String(s).replace(/```/g, "\\`\\`\\`");
119
+ }
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderFindingsMarkdown } from "./markdown.js";
3
+
4
+ describe("renderFindingsMarkdown", () => {
5
+ const baseFinding = {
6
+ source: "axe",
7
+ ruleId: "image-alt",
8
+ criteria: ["1.1.1"],
9
+ level: "A",
10
+ impact: "critical",
11
+ description: "Images must have alternate text",
12
+ help: "Add an alt attribute describing the image",
13
+ helpUrl: "https://dequeuniversity.com/rules/axe/4.10/image-alt",
14
+ selector: "img.hero",
15
+ evidence: "<img src='/hero.png'>",
16
+ };
17
+
18
+ it("includes a header with project + generator metadata", () => {
19
+ const md = renderFindingsMarkdown({
20
+ projectName: "my-nextjs-app",
21
+ generator: "@wcag-audit/cli v1.0.0-alpha.1",
22
+ totalPages: 47,
23
+ wcagVersion: "2.2",
24
+ levels: ["A", "AA"],
25
+ findings: [],
26
+ });
27
+ expect(md).toMatch(/# WCAG Audit Findings — my-nextjs-app/);
28
+ expect(md).toMatch(/@wcag-audit\/cli v1\.0\.0-alpha\.1/);
29
+ expect(md).toMatch(/Total pages audited: 47/);
30
+ expect(md).toMatch(/WCAG 2\.2 Level A \+ AA/);
31
+ expect(md).toMatch(/No issues found!/);
32
+ });
33
+
34
+ it("groups findings by impact descending", () => {
35
+ const md = renderFindingsMarkdown({
36
+ projectName: "x",
37
+ generator: "x",
38
+ totalPages: 1,
39
+ wcagVersion: "2.2",
40
+ levels: ["A"],
41
+ findings: [
42
+ { ...baseFinding, impact: "minor", ruleId: "minor-rule" },
43
+ { ...baseFinding, impact: "critical", ruleId: "image-alt" },
44
+ { ...baseFinding, impact: "serious", ruleId: "serious-rule" },
45
+ ],
46
+ });
47
+ const criticalIdx = md.indexOf("## Critical");
48
+ const seriousIdx = md.indexOf("## Serious");
49
+ const minorIdx = md.indexOf("## Minor");
50
+ expect(criticalIdx).toBeGreaterThan(-1);
51
+ expect(criticalIdx).toBeLessThan(seriousIdx);
52
+ expect(seriousIdx).toBeLessThan(minorIdx);
53
+ });
54
+
55
+ it("renders a finding with route, file, criterion, and snippet", () => {
56
+ const md = renderFindingsMarkdown({
57
+ projectName: "x",
58
+ generator: "x",
59
+ totalPages: 1,
60
+ wcagVersion: "2.2",
61
+ levels: ["A"],
62
+ findings: [
63
+ {
64
+ ...baseFinding,
65
+ route: "/",
66
+ sourceFile: "src/app/page.tsx",
67
+ },
68
+ ],
69
+ });
70
+ expect(md).toMatch(/\*\*Route:\*\* `\/`/);
71
+ expect(md).toMatch(/\*\*File:\*\* `src\/app\/page\.tsx`/);
72
+ expect(md).toMatch(/1\.1\.1/);
73
+ expect(md).toMatch(/image-alt/);
74
+ expect(md).toMatch(/<img src='\/hero\.png'>/);
75
+ });
76
+
77
+ it("merges findings that share ruleId but appear on multiple routes", () => {
78
+ const md = renderFindingsMarkdown({
79
+ projectName: "x",
80
+ generator: "x",
81
+ totalPages: 3,
82
+ wcagVersion: "2.2",
83
+ levels: ["A"],
84
+ findings: [
85
+ { ...baseFinding, route: "/", sourceFile: "src/app/page.tsx" },
86
+ { ...baseFinding, route: "/about", sourceFile: "src/app/about/page.tsx" },
87
+ { ...baseFinding, route: "/pricing", sourceFile: "src/app/pricing/page.tsx" },
88
+ ],
89
+ });
90
+ expect(md).toMatch(/Routes affected: 3/);
91
+ expect(md).toMatch(/- `\/`/);
92
+ expect(md).toMatch(/- `\/about`/);
93
+ expect(md).toMatch(/- `\/pricing`/);
94
+ });
95
+ });
package/src/report.js ADDED
@@ -0,0 +1,239 @@
1
+ // Excel report writer. Mirrors the extension's three-sheet format
2
+ // (Summary / Coverage / Issues) but with an additional Source column and
3
+ // embedded screenshots loaded from disk rather than from in-memory buffers.
4
+
5
+ import ExcelJS from "exceljs";
6
+ import fs from "node:fs/promises";
7
+ import path from "node:path";
8
+ import { getManualGuide } from "./wcag-manual-steps.js";
9
+
10
+ const SHOT_MAX_W = 1000;
11
+ const SHOT_MAX_H = 800;
12
+
13
+ function pxToPt(px) {
14
+ return Math.ceil(px * 0.75) + 6;
15
+ }
16
+
17
+ async function imageDimensions(buffer) {
18
+ // Lightweight PNG dimension parse — avoids pulling in another dependency.
19
+ if (buffer[0] === 0x89 && buffer[1] === 0x50) {
20
+ const w = buffer.readUInt32BE(16);
21
+ const h = buffer.readUInt32BE(20);
22
+ return { width: w, height: h };
23
+ }
24
+ return { width: 800, height: 600 };
25
+ }
26
+
27
+ function fitDims(w, h) {
28
+ const scale = Math.min(SHOT_MAX_W / w, SHOT_MAX_H / h, 1);
29
+ return { width: Math.max(1, Math.round(w * scale)), height: Math.max(1, Math.round(h * scale)) };
30
+ }
31
+
32
+ export async function writeReport({ findings, meta, outPath }) {
33
+ const wb = new ExcelJS.Workbook();
34
+ wb.creator = "WCAG Documentation Creator CLI";
35
+ wb.created = new Date();
36
+
37
+ // ── Summary sheet ──
38
+ const sum = wb.addWorksheet("Summary");
39
+ sum.columns = [
40
+ { header: "", key: "k", width: 28 },
41
+ { header: "", key: "v", width: 90 },
42
+ ];
43
+ const rows = [
44
+ ["WCAG Documentation Creator — End-to-End Audit Report"],
45
+ [],
46
+ ["Page URL", meta.url],
47
+ ["WCAG version", meta.wcagVersion],
48
+ ["Levels", meta.levels.join(", ")],
49
+ ["Started", meta.startedAt],
50
+ ["Finished", meta.finishedAt],
51
+ ["Total findings", String(meta.totalFindings)],
52
+ [],
53
+ ["Per-checker timings (s)"],
54
+ ];
55
+ rows.forEach((r) => sum.addRow(r));
56
+ sum.getRow(1).font = { bold: true, size: 14, color: { argb: "FF0969DA" } };
57
+ for (const [k, v] of Object.entries(meta.perCheckerTimings || {})) {
58
+ sum.addRow([` ${k}`, String(v)]);
59
+ }
60
+ if (Object.keys(meta.perCheckerErrors || {}).length) {
61
+ sum.addRow([]);
62
+ sum.addRow(["Checker errors"]);
63
+ for (const [k, v] of Object.entries(meta.perCheckerErrors)) sum.addRow([` ${k}`, v]);
64
+ }
65
+ if (meta.aiEnabled) {
66
+ sum.addRow([]);
67
+ sum.addRow(["AI model", meta.aiModel || ""]);
68
+ if (meta.aiUsage) {
69
+ sum.addRow([
70
+ "AI tokens (in / out)",
71
+ `${meta.aiUsage.input_tokens || 0} / ${meta.aiUsage.output_tokens || 0}`,
72
+ ]);
73
+ }
74
+ }
75
+
76
+ // ── Coverage sheet ──
77
+ const cov = wb.addWorksheet("Coverage");
78
+ cov.columns = [
79
+ { header: "Criterion", key: "id", width: 12 },
80
+ { header: "Title", key: "title", width: 36 },
81
+ { header: "Level", key: "level", width: 8 },
82
+ { header: "Added in", key: "added", width: 12 },
83
+ { header: "axe", key: "axe", width: 12 },
84
+ { header: "Playwright", key: "pw", width: 12 },
85
+ { header: "AI vision", key: "ai", width: 12 },
86
+ { header: "Findings", key: "found", width: 10 },
87
+ { header: "Manual steps to verify", key: "manual", width: 60 },
88
+ { header: "Notes", key: "notes", width: 40 },
89
+ ];
90
+ const covHeader = cov.getRow(1);
91
+ covHeader.font = { bold: true, color: { argb: "FFFFFFFF" } };
92
+ covHeader.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF0969DA" } };
93
+ cov.views = [{ state: "frozen", ySplit: 1 }];
94
+
95
+ // Build per-criterion finding counts
96
+ const counts = Object.create(null);
97
+ for (const f of findings) {
98
+ const ids = String(f.criteria || "").split(",").map((s) => s.trim()).filter(Boolean);
99
+ for (const id of ids) counts[id] = (counts[id] || 0) + 1;
100
+ }
101
+
102
+ for (const c of meta.scopeCriteria) {
103
+ const axeStatus =
104
+ c.automation === "automated" ? "Automated" :
105
+ c.automation === "partial" ? "Partial" : "—";
106
+ const pwStatus = c.playwrightCheckable ? "Checked" : "—";
107
+ const aiStatus = c.aiReviewable ? (meta.aiEnabled ? "Reviewed" : "available") : "—";
108
+
109
+ const guide = getManualGuide(c.id);
110
+ const row = cov.addRow({
111
+ id: c.id,
112
+ title: c.title,
113
+ level: c.level,
114
+ added: c.addedIn + (c.removedIn ? ` (rm ${c.removedIn})` : ""),
115
+ axe: axeStatus,
116
+ pw: pwStatus,
117
+ ai: aiStatus,
118
+ found: counts[c.id] || 0,
119
+ manual: guide.manualSteps || "",
120
+ notes: c.notes || "",
121
+ });
122
+ row.alignment = { vertical: "top", wrapText: true };
123
+
124
+ const colorCell = (key, label) => {
125
+ const cell = row.getCell(key);
126
+ const colors = {
127
+ Automated: "FF116329", Partial: "FFCA8A04",
128
+ Checked: "FF0969DA",
129
+ Reviewed: "FF6D28D9", available: "FF8C959F",
130
+ };
131
+ if (colors[label]) {
132
+ cell.font = { bold: true, color: { argb: "FFFFFFFF" } };
133
+ cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: colors[label] } };
134
+ cell.alignment = { vertical: "middle", horizontal: "center" };
135
+ }
136
+ };
137
+ colorCell("axe", axeStatus);
138
+ colorCell("pw", pwStatus);
139
+ colorCell("ai", aiStatus);
140
+ if (counts[c.id]) row.getCell("found").font = { bold: true, color: { argb: "FFB91C1C" } };
141
+ }
142
+
143
+ // ── Issues sheet ──
144
+ const ws = wb.addWorksheet("Issues");
145
+ ws.columns = [
146
+ { header: "Issue #", key: "n", width: 8 },
147
+ { header: "Source", key: "src", width: 12 },
148
+ { header: "Rule ID", key: "rule", width: 28 },
149
+ { header: "WCAG", key: "crit", width: 12 },
150
+ { header: "Level", key: "level", width: 8 },
151
+ { header: "Severity", key: "sev", width: 12 },
152
+ { header: "Description", key: "desc", width: 50 },
153
+ { header: "How to fix", key: "help", width: 50 },
154
+ { header: "Help URL", key: "url", width: 36 },
155
+ ];
156
+ const wsHeader = ws.getRow(1);
157
+ wsHeader.font = { bold: true, color: { argb: "FFFFFFFF" } };
158
+ wsHeader.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF0969DA" } };
159
+ ws.views = [{ state: "frozen", ySplit: 1 }];
160
+
161
+ for (const f of findings) {
162
+ const metaRow = ws.addRow({
163
+ n: f.issueNumber,
164
+ src: f.source,
165
+ rule: f.ruleId,
166
+ crit: f.criteria,
167
+ level: f.level,
168
+ sev: f.impact,
169
+ desc: f.description + (f.help ? `\n\n${f.help}` : ""),
170
+ help: f.failureSummary || "",
171
+ url: f.helpUrl,
172
+ });
173
+ metaRow.alignment = { vertical: "top", wrapText: true };
174
+ metaRow.height = 90;
175
+
176
+ // Source colour-code
177
+ const srcCell = metaRow.getCell("src");
178
+ const srcColors = { axe: "FF0969DA", playwright: "FF116329", ai: "FF6D28D9" };
179
+ if (srcColors[f.source]) {
180
+ srcCell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: srcColors[f.source] } };
181
+ srcCell.font = { bold: true, color: { argb: "FFFFFFFF" } };
182
+ srcCell.alignment = { horizontal: "center", vertical: "middle" };
183
+ }
184
+
185
+ // Severity colour-code
186
+ const sevCell = metaRow.getCell("sev");
187
+ const sevColors = { critical: "FFB91C1C", serious: "FFEA580C", moderate: "FFCA8A04", minor: "FF6B7280" };
188
+ if (sevColors[f.impact]) {
189
+ sevCell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: sevColors[f.impact] } };
190
+ sevCell.font = { bold: true, color: { argb: "FFFFFFFF" } };
191
+ sevCell.alignment = { horizontal: "center", vertical: "middle" };
192
+ }
193
+
194
+ if (f.helpUrl) {
195
+ metaRow.getCell("url").value = { text: f.helpUrl, hyperlink: f.helpUrl };
196
+ metaRow.getCell("url").font = { color: { argb: "FF0969DA" }, underline: true };
197
+ }
198
+
199
+ // Selector + evidence row
200
+ const selRow = ws.addRow(["", "", "Selector:", f.selector || "", "", "", f.evidence || "", "", ""]);
201
+ ws.mergeCells(selRow.number, 4, selRow.number, 6);
202
+ ws.mergeCells(selRow.number, 7, selRow.number, 9);
203
+ selRow.getCell(3).font = { bold: true, italic: true, color: { argb: "FF57606A" } };
204
+ selRow.getCell(4).font = { name: "Menlo", size: 10 };
205
+ selRow.getCell(7).font = { name: "Menlo", size: 10 };
206
+ selRow.alignment = { vertical: "top", wrapText: true };
207
+ selRow.height = 60;
208
+
209
+ // Screenshot row
210
+ if (f.screenshotFile) {
211
+ try {
212
+ const buffer = await fs.readFile(f.screenshotFile);
213
+ const dims = await imageDimensions(buffer);
214
+ const fit = fitDims(dims.width, dims.height);
215
+ const imgRow = ws.addRow(["", "", "Screenshot", "", "", "", "", "", ""]);
216
+ imgRow.getCell(3).font = { bold: true, italic: true, color: { argb: "FF57606A" } };
217
+ ws.mergeCells(imgRow.number, 4, imgRow.number, 9);
218
+ imgRow.height = pxToPt(fit.height);
219
+ const imageId = wb.addImage({ buffer, extension: "png" });
220
+ ws.addImage(imageId, {
221
+ tl: { col: 3, row: imgRow.number - 1 },
222
+ ext: { width: fit.width, height: fit.height },
223
+ editAs: "oneCell",
224
+ });
225
+ } catch (_) {
226
+ const imgRow = ws.addRow(["", "", "Screenshot", "(load failed)"]);
227
+ imgRow.height = 24;
228
+ }
229
+ }
230
+
231
+ // Spacer
232
+ const spacer = ws.addRow([]);
233
+ spacer.height = 8;
234
+ spacer.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFEAEEF2" } };
235
+ }
236
+
237
+ await fs.mkdir(path.dirname(path.resolve(outPath)), { recursive: true });
238
+ await wb.xlsx.writeFile(outPath);
239
+ }
@@ -0,0 +1,25 @@
1
+ // Backwards-compatible thin wrapper. The original API was Anthropic-only;
2
+ // the codebase now supports Anthropic / OpenAI / Google via callLlmJsonArray
3
+ // in ./llm.js. We keep `askClaudeForJsonArray` as a delegating shim so
4
+ // every existing checker (screen-reader, forms, ai-vision) keeps working
5
+ // without an edit. The shim accepts an optional `provider` field — if
6
+ // omitted it defaults to anthropic, matching the historical behaviour.
7
+
8
+ import { callLlmJsonArray } from "./llm.js";
9
+
10
+ export async function askClaudeForJsonArray({
11
+ provider = "anthropic",
12
+ apiKey,
13
+ model,
14
+ prompt,
15
+ images = [],
16
+ maxTokens = 8192,
17
+ }) {
18
+ if (!apiKey) {
19
+ throw new Error(
20
+ `LLM API key required (provider=${provider}). Set --anthropic-api-key, ` +
21
+ `pass it in the audit request, or set the corresponding env var.`
22
+ );
23
+ }
24
+ return callLlmJsonArray({ provider, apiKey, model, prompt, images, maxTokens });
25
+ }