@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
package/src/audit.js ADDED
@@ -0,0 +1,199 @@
1
+ // Top-level audit orchestrator. Launches a Playwright browser, navigates to
2
+ // the page, and dispatches every checker in turn. Each checker returns a
3
+ // Finding[] array which is normalized and merged. At the end the report
4
+ // writer turns the merged findings + run metadata into an Excel workbook.
5
+
6
+ import { chromium } from "playwright";
7
+ import fs from "node:fs/promises";
8
+
9
+ import { filterCriteriaForScope, buildAxeTags } from "./wcag-criteria.js";
10
+ import { writeReport } from "./report.js";
11
+ import { writeAiFixJson } from "./ai-fix-json.js";
12
+
13
+ import { runAxe } from "./checkers/axe.js";
14
+ import { runAiVision } from "./checkers/ai-vision.js";
15
+ import { runKeyboard } from "./checkers/keyboard.js";
16
+ import { runViewport } from "./checkers/viewport.js";
17
+ import { runInteraction } from "./checkers/interaction.js";
18
+ import { runMotion } from "./checkers/motion.js";
19
+ import { runForms } from "./checkers/forms.js";
20
+ import { runPointer } from "./checkers/pointer.js";
21
+ import { runMedia } from "./checkers/media.js";
22
+ import { runConsistency } from "./checkers/consistency.js";
23
+ import { runAuth } from "./checkers/auth.js";
24
+ import { runScreenReader } from "./checkers/screen-reader.js";
25
+
26
+ // Each checker registers under a key matching the --skip option vocabulary.
27
+ // `optIn: true` means the checker only runs when the request explicitly
28
+ // includes its key (e.g. "screen-reader") in opts.include — they are slow
29
+ // or platform-bound so we don't run them on every audit.
30
+ const CHECKERS = [
31
+ { key: "axe", fn: runAxe, label: "axe-core" },
32
+ { key: "keyboard", fn: runKeyboard, label: "Keyboard / focus" },
33
+ { key: "viewport", fn: runViewport, label: "Viewport / reflow / text spacing" },
34
+ { key: "interaction", fn: runInteraction, label: "Hover & on-focus/on-input" },
35
+ { key: "motion", fn: runMotion, label: "Motion / autoplay / flashes" },
36
+ { key: "forms", fn: runForms, label: "Forms / error handling" },
37
+ { key: "pointer", fn: runPointer, label: "Pointer & gestures" },
38
+ { key: "media", fn: runMedia, label: "Audio / video presence" },
39
+ { key: "auth", fn: runAuth, label: "Accessible authentication" },
40
+ { key: "consistency", fn: runConsistency, label: "Multi-page consistency" },
41
+ { key: "ai", fn: runAiVision, label: "Claude AI vision review", requiresAi: true },
42
+ // Screen-reader walks the page and captures what each focusable element
43
+ // would announce. The walk + VoiceOver launch run with or without AI;
44
+ // AI is only used to grade the captured phrases for clarity. So we do
45
+ // NOT mark this requiresAi — the checker handles "no AI" gracefully.
46
+ { key: "screen-reader", fn: runScreenReader, label: "Real screen-reader walkthrough", optIn: true },
47
+ ];
48
+
49
+ // Run a complete audit and return findings + metadata WITHOUT writing any
50
+ // files. The HTTP server uses this directly so it can serialize the result
51
+ // over the wire instead of producing a local xlsx. The CLI's runAudit()
52
+ // wraps this and additionally writes the Excel report to disk.
53
+ export async function runAuditCore(opts, hooks = {}) {
54
+ const startedAt = new Date();
55
+ await fs.mkdir(opts.screenshotsDir, { recursive: true });
56
+
57
+ // Screen-reader checker requires a VISIBLE browser window — VoiceOver /
58
+ // NVDA can only attach to GUI windows. Force headed mode whenever it's
59
+ // in the include list, even if the caller didn't pass --headed.
60
+ const wantsScreenReader = (opts.include || []).includes("screen-reader");
61
+ const headless = wantsScreenReader ? false : !opts.headed;
62
+ if (wantsScreenReader && !opts.headed) {
63
+ console.log(" ℹ screen-reader requested → forcing visible browser window");
64
+ }
65
+
66
+ const browser = await chromium.launch({
67
+ headless,
68
+ slowMo: opts.slowMo || 0,
69
+ });
70
+ const contextOpts = { viewport: { width: 1366, height: 900 } };
71
+ if (opts.authStorage) contextOpts.storageState = opts.authStorage;
72
+ const context = await browser.newContext(contextOpts);
73
+ const page = await context.newPage();
74
+
75
+ let findings = [];
76
+ let perCheckerTimings = {};
77
+ let perCheckerErrors = {};
78
+ let aiUsage = null;
79
+ const skip = new Set(opts.skip || []);
80
+
81
+ try {
82
+ await page.goto(opts.url, { waitUntil: "networkidle", timeout: 60_000 });
83
+ await page.waitForTimeout(500); // let JS settle
84
+
85
+ const ctx = {
86
+ page,
87
+ context,
88
+ browser,
89
+ url: opts.url,
90
+ title: await page.title(),
91
+ wcagVersion: opts.wcagVersion,
92
+ levels: opts.levels,
93
+ axeTags: buildAxeTags(opts.wcagVersion, opts.levels),
94
+ scopeCriteria: filterCriteriaForScope(opts.wcagVersion, opts.levels),
95
+ screenshotsDir: opts.screenshotsDir,
96
+ maxPages: opts.maxPages,
97
+ ai: {
98
+ enabled: !!opts.aiEnabled,
99
+ provider: opts.aiProvider || "anthropic",
100
+ // `anthropicApiKey` is the back-compat name kept by the CLI flag.
101
+ // For non-Anthropic providers the same field carries the OpenAI
102
+ // or Google key — the worker route normalizes it.
103
+ apiKey: opts.aiKey || opts.anthropicApiKey,
104
+ model: opts.aiModel,
105
+ },
106
+ };
107
+
108
+ const include = new Set(opts.include || []);
109
+ const enabledCheckers = CHECKERS.filter((c) => {
110
+ if (skip.has(c.key)) return false;
111
+ if (c.optIn && !include.has(c.key)) return false;
112
+ if (c.requiresAi && (!opts.aiEnabled || !opts.anthropicApiKey)) return false;
113
+ return true;
114
+ });
115
+ let checkerIdx = 0;
116
+ for (const c of enabledCheckers) {
117
+ checkerIdx++;
118
+ hooks.onProgress?.({
119
+ phase: "checker",
120
+ checker: c.key,
121
+ label: c.label,
122
+ index: checkerIdx,
123
+ total: enabledCheckers.length,
124
+ });
125
+ const t0 = Date.now();
126
+ try {
127
+ const out = await c.fn(ctx);
128
+ const checkerFindings = out?.findings || [];
129
+ findings = findings.concat(checkerFindings);
130
+ if (out?.usage) aiUsage = mergeUsage(aiUsage, out.usage);
131
+ if (out?.error) {
132
+ perCheckerErrors[c.key] = out.error;
133
+ process.stdout.write(` ⚠ ${c.key}: ${out.error}\n`);
134
+ }
135
+ perCheckerTimings[c.key] = +((Date.now() - t0) / 1000).toFixed(1);
136
+ } catch (err) {
137
+ perCheckerErrors[c.key] = err.message;
138
+ process.stdout.write(` ✗ ${c.key} threw: ${err.message}\n`);
139
+ }
140
+ }
141
+ } finally {
142
+ await context.close().catch(() => {});
143
+ await browser.close().catch(() => {});
144
+ }
145
+
146
+ findings.forEach((f, i) => { f.issueNumber = i + 1; });
147
+
148
+ const meta = {
149
+ url: opts.url,
150
+ wcagVersion: opts.wcagVersion,
151
+ levels: opts.levels,
152
+ startedAt: startedAt.toISOString(),
153
+ finishedAt: new Date().toISOString(),
154
+ totalFindings: findings.length,
155
+ perCheckerTimings,
156
+ perCheckerErrors,
157
+ aiEnabled: !!opts.aiEnabled,
158
+ aiModel: opts.aiModel,
159
+ aiUsage,
160
+ scopeCriteria: filterCriteriaForScope(opts.wcagVersion, opts.levels),
161
+ };
162
+
163
+ return { findings, meta };
164
+ }
165
+
166
+ // CLI wrapper: runs the core, writes the Excel file, prints progress.
167
+ export async function runAudit(opts) {
168
+ console.log(`\n→ Auditing ${opts.url}`);
169
+ console.log(` WCAG ${opts.wcagVersion}, levels ${opts.levels.join("/")}`);
170
+ console.log(` Output: ${opts.outPath}\n`);
171
+
172
+ const { findings, meta } = await runAuditCore(opts, {
173
+ onProgress: (p) => {
174
+ if (p.phase === "checker") {
175
+ process.stdout.write(` ▸ ${p.label} (${p.index}/${p.total})\n`);
176
+ }
177
+ },
178
+ });
179
+
180
+ await writeReport({ findings, meta, outPath: opts.outPath });
181
+
182
+ // Write AI fix JSON alongside the Excel report
183
+ const jsonPath = opts.outPath.replace(/\.xlsx$/i, ".ai-fix.json");
184
+ const aiFixJson = await writeAiFixJson({ findings, meta, outPath: jsonPath });
185
+ console.log(`\n✓ Done. ${findings.length} finding(s)`);
186
+ console.log(` Excel → ${opts.outPath}`);
187
+ console.log(` AI fix → ${jsonPath}`);
188
+ if (meta.aiUsage) {
189
+ console.log(` AI tokens: ${meta.aiUsage.input_tokens} in / ${meta.aiUsage.output_tokens} out`);
190
+ }
191
+ }
192
+
193
+ function mergeUsage(a, b) {
194
+ if (!a) return { ...b };
195
+ return {
196
+ input_tokens: (a.input_tokens || 0) + (b.input_tokens || 0),
197
+ output_tokens: (a.output_tokens || 0) + (b.output_tokens || 0),
198
+ };
199
+ }
@@ -0,0 +1,46 @@
1
+ import { createHash } from "crypto";
2
+ import { readFile, writeFile, mkdir } from "fs/promises";
3
+ import { join } from "path";
4
+
5
+ export const CACHE_DIR = ".wcag-audit/cache";
6
+ // 24h — enough to cover a full dev workday without rescanning, but
7
+ // short enough that changes to the environment (deps, config) still
8
+ // force a fresh audit at least once a day.
9
+ export const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
10
+
11
+ export function hashContent(html, assetUrls = []) {
12
+ const h = createHash("sha256");
13
+ h.update(html || "");
14
+ // Sort assets so ordering doesn't invalidate the cache — Next.js
15
+ // sometimes reorders its chunks between dev-server restarts.
16
+ const sorted = [...assetUrls].sort();
17
+ h.update("\u0000");
18
+ h.update(sorted.join("\u0000"));
19
+ return h.digest("hex").slice(0, 16);
20
+ }
21
+
22
+ function cacheFileName(routePath) {
23
+ if (!routePath || routePath === "/") return "_root.json";
24
+ return "_" + routePath.replace(/^\//, "").replace(/[^a-zA-Z0-9]/g, "_") + ".json";
25
+ }
26
+
27
+ export async function readCacheEntry(projectDir, routePath, hash) {
28
+ const path = join(projectDir, CACHE_DIR, cacheFileName(routePath));
29
+ try {
30
+ const raw = await readFile(path, "utf8");
31
+ const parsed = JSON.parse(raw);
32
+ if (parsed.hash !== hash) return null;
33
+ if (!parsed.auditedAt || Date.now() - parsed.auditedAt > CACHE_TTL_MS) return null;
34
+ return parsed;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ export async function writeCacheEntry(projectDir, routePath, hash, { findings, auditedAt }) {
41
+ const dir = join(projectDir, CACHE_DIR);
42
+ await mkdir(dir, { recursive: true });
43
+ const path = join(dir, cacheFileName(routePath));
44
+ const payload = { hash, auditedAt, findings };
45
+ await writeFile(path, JSON.stringify(payload, null, 2), "utf8");
46
+ }
@@ -0,0 +1,96 @@
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 { hashContent, readCacheEntry, writeCacheEntry, CACHE_DIR, CACHE_TTL_MS } from "./route-cache.js";
6
+
7
+ let projDir;
8
+
9
+ beforeEach(async () => {
10
+ projDir = await mkdtemp(join(tmpdir(), "wcagcache-"));
11
+ });
12
+
13
+ afterEach(async () => {
14
+ await rm(projDir, { recursive: true, force: true });
15
+ });
16
+
17
+ describe("hashContent", () => {
18
+ it("produces stable hex digest for identical inputs", () => {
19
+ const a = hashContent("<html>hi</html>", ["a.css", "b.js"]);
20
+ const b = hashContent("<html>hi</html>", ["a.css", "b.js"]);
21
+ expect(a).toBe(b);
22
+ expect(a).toMatch(/^[a-f0-9]{16}$/);
23
+ });
24
+
25
+ it("changes when HTML changes", () => {
26
+ const a = hashContent("<html>hi</html>", []);
27
+ const b = hashContent("<html>bye</html>", []);
28
+ expect(a).not.toBe(b);
29
+ });
30
+
31
+ it("changes when asset list changes", () => {
32
+ const a = hashContent("<html/>", ["a.css"]);
33
+ const b = hashContent("<html/>", ["a.css", "b.css"]);
34
+ expect(a).not.toBe(b);
35
+ });
36
+
37
+ it("ignores asset order", () => {
38
+ const a = hashContent("<html/>", ["a.css", "b.css"]);
39
+ const b = hashContent("<html/>", ["b.css", "a.css"]);
40
+ expect(a).toBe(b);
41
+ });
42
+ });
43
+
44
+ describe("readCacheEntry / writeCacheEntry", () => {
45
+ it("returns null when no entry exists", async () => {
46
+ const entry = await readCacheEntry(projDir, "/", "abc123");
47
+ expect(entry).toBeNull();
48
+ });
49
+
50
+ it("returns the written entry on match", async () => {
51
+ await writeCacheEntry(projDir, "/", "abc123", {
52
+ findings: [{ ruleId: "image-alt", impact: "critical" }],
53
+ auditedAt: Date.now(),
54
+ });
55
+ const entry = await readCacheEntry(projDir, "/", "abc123");
56
+ expect(entry).not.toBeNull();
57
+ expect(entry.findings).toHaveLength(1);
58
+ expect(entry.findings[0].ruleId).toBe("image-alt");
59
+ });
60
+
61
+ it("returns null when hash does not match", async () => {
62
+ await writeCacheEntry(projDir, "/", "abc123", { findings: [], auditedAt: Date.now() });
63
+ const entry = await readCacheEntry(projDir, "/", "different-hash");
64
+ expect(entry).toBeNull();
65
+ });
66
+
67
+ it("returns null when entry is older than TTL", async () => {
68
+ await writeCacheEntry(projDir, "/", "abc123", {
69
+ findings: [],
70
+ auditedAt: Date.now() - CACHE_TTL_MS - 1000,
71
+ });
72
+ const entry = await readCacheEntry(projDir, "/", "abc123");
73
+ expect(entry).toBeNull();
74
+ });
75
+
76
+ it("writes cache to the expected directory", async () => {
77
+ await writeCacheEntry(projDir, "/about", "abc", { findings: [], auditedAt: Date.now() });
78
+ const raw = await readFile(join(projDir, CACHE_DIR, "_about.json"), "utf8");
79
+ const parsed = JSON.parse(raw);
80
+ expect(parsed.hash).toBe("abc");
81
+ });
82
+
83
+ it("handles root path `/` by naming the file `_root.json`", async () => {
84
+ await writeCacheEntry(projDir, "/", "abc", { findings: [], auditedAt: Date.now() });
85
+ const raw = await readFile(join(projDir, CACHE_DIR, "_root.json"), "utf8");
86
+ expect(JSON.parse(raw).hash).toBe("abc");
87
+ });
88
+
89
+ it("survives corrupt JSON by returning null", async () => {
90
+ const { mkdir } = await import("fs/promises");
91
+ await mkdir(join(projDir, CACHE_DIR), { recursive: true });
92
+ await writeFile(join(projDir, CACHE_DIR, "_root.json"), "{ not json", "utf8");
93
+ const entry = await readCacheEntry(projDir, "/", "abc");
94
+ expect(entry).toBeNull();
95
+ });
96
+ });
@@ -0,0 +1,102 @@
1
+ // AI vision pass — same prompts as the extension, ported to Node + the
2
+ // official Anthropic SDK. Reviews the visual-judgment criteria that no
3
+ // static rule engine can check (sensory characteristics, images of text,
4
+ // non-text contrast, etc.).
5
+
6
+ import { askClaudeForJsonArray } from "../util/anthropic.js";
7
+ import { captureElementBySelector, saveScreenshot } from "../util/screenshot.js";
8
+
9
+ const PROMPTS = {
10
+ "1.3.3": `Look for instructions in visible content that rely SOLELY on sensory characteristics — shape ("square button"), size, color ("the green link"), location ("on the right"), orientation, or sound ("after the beep"). Each must also be conveyed in a non-sensory way. Fail if any lacks a non-sensory alternative.`,
11
+ "1.4.5": `Look at the screenshot for IMAGES that contain text content. Logos, brand wordmarks, and decorative typography are exempt. Common offenders: hero banners with text overlays, infographics, navigation rendered as image sprites, buttons rendered as PNGs.`,
12
+ "1.4.8": `Inspect text blocks for: line length over ~80 characters, fully justified text, line spacing under 1.5x within paragraphs, paragraph spacing under 2x line height, horizontal scrolling at default width.`,
13
+ "1.4.9": `Stricter than 1.4.5 (AAA): NO images of text are allowed except logotypes or where presentation is essential.`,
14
+ "1.4.11": `Examine UI components (buttons, form inputs, focus indicators, tabs, links) and meaningful graphics in the screenshot. Boundaries / strokes need 3:1 contrast against the adjacent background. Flag specific components.`,
15
+ "1.4.12": `Inspect HTML/CSS for rules preventing user override of text spacing: line-height, letter-spacing, word-spacing or paragraph spacing set with !important, fixed-pixel heights on text containers, overflow:hidden on text blocks.`,
16
+ "2.4.5": `Check whether the page provides MORE THAN ONE way to find content: search box, sitemap link, table of contents, primary navigation, A-Z index. Fail if only one method exists (and the page isn't a step in a process).`,
17
+ "2.4.6": `Read every heading and visible form label. Mark any that are vague or non-descriptive ("More", "Click here", "Section 1", "Untitled"). Quote them.`,
18
+ "2.4.8": `Check if the page tells the user where they are within the site: breadcrumbs, "you are here" indicator, current item highlighted in nav. Fail if none.`,
19
+ "2.4.10": `Review the heading structure. Are large content sections missing headings? Are heading levels skipped (h1 → h3)?`,
20
+ "3.1.3": `Identify jargon, idioms, technical terms or unusual words in visible text whose meaning isn't obvious from context. AAA requires a glossary/definition mechanism.`,
21
+ "3.1.4": `Identify abbreviations and acronyms in visible text. WCAG requires the expanded form OR meaning to be available (e.g. <abbr title>). Quote them.`,
22
+ "3.1.5": `Estimate the reading level of the main content. AAA requires lower secondary (~9 years schooling) OR a simpler version. Fail with justification if significantly above.`,
23
+ };
24
+
25
+ function buildPrompt(criteria, pageMeta, html) {
26
+ const lines = [];
27
+ lines.push("You are a senior WCAG 2.2 accessibility auditor. Evaluate the screenshot and HTML against the listed criteria.");
28
+ lines.push("");
29
+ lines.push(`Page URL: ${pageMeta.url}`);
30
+ lines.push(`Page title: ${pageMeta.title}`);
31
+ lines.push("");
32
+ lines.push("Return a JSON array. For each criterion ONE object: { criterion, status (pass|fail|needs-review), summary, findings: [{ issue, evidence, selector, severity (minor|moderate|serious|critical) }] }");
33
+ lines.push("- 'fail' only with concrete evidence quoted from the screenshot or HTML.");
34
+ lines.push("- 'needs-review' when interaction/audio/video/multi-page context is needed. Do not guess.");
35
+ lines.push("- 'pass' only when affirmatively confirmed.");
36
+ lines.push("- selector: a CSS selector if you can identify it from the HTML, otherwise empty string.");
37
+ lines.push("- Return ONLY the JSON array, no prose, no markdown fences.");
38
+ lines.push("");
39
+ lines.push("CRITERIA:");
40
+ for (const c of criteria) {
41
+ lines.push("");
42
+ lines.push(`### ${c.id} ${c.title} (Level ${c.level})`);
43
+ lines.push(PROMPTS[c.id] || "");
44
+ }
45
+ lines.push("");
46
+ lines.push("--- PAGE HTML (truncated) ---");
47
+ lines.push((html || "").slice(0, 80000));
48
+ return lines.join("\n");
49
+ }
50
+
51
+ export async function runAiVision(ctx) {
52
+ const { page, ai } = ctx;
53
+ const reviewable = ctx.scopeCriteria.filter((c) => c.aiReviewable);
54
+ if (!reviewable.length) return { findings: [] };
55
+
56
+ const screenshot = await page.screenshot({ fullPage: true, type: "png" });
57
+ const html = await page.content();
58
+
59
+ const prompt = buildPrompt(reviewable, { url: ctx.url, title: ctx.title }, html);
60
+ const { array, usage } = await askClaudeForJsonArray({
61
+ provider: ai.provider,
62
+ apiKey: ai.apiKey,
63
+ model: ai.model,
64
+ prompt,
65
+ images: [{ buffer: screenshot, mimeType: "image/png" }],
66
+ });
67
+
68
+ const findings = [];
69
+ for (const r of array) {
70
+ if (r.status !== "fail" || !Array.isArray(r.findings)) continue;
71
+ const critEntry = reviewable.find((c) => c.id === r.criterion);
72
+ for (const f of r.findings) {
73
+ let screenshotFile = null;
74
+ if (f.selector) {
75
+ try {
76
+ const buf = await captureElementBySelector(page, f.selector, {
77
+ label: `AI · ${r.criterion}`,
78
+ });
79
+ screenshotFile = await saveScreenshot(buf, ctx.screenshotsDir, `ai_${r.criterion}`);
80
+ } catch (_) {}
81
+ }
82
+ findings.push({
83
+ source: "ai",
84
+ ruleId: `ai/${r.criterion}`,
85
+ criteria: r.criterion,
86
+ level: critEntry?.level || "",
87
+ impact: f.severity || "moderate",
88
+ description: f.issue || r.summary || "",
89
+ help: r.summary || "",
90
+ helpUrl: critEntry
91
+ ? `https://www.w3.org/WAI/WCAG22/Understanding/${critEntry.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}.html`
92
+ : "",
93
+ selector: f.selector || "",
94
+ evidence: f.evidence || "",
95
+ failureSummary: f.evidence || "",
96
+ screenshotFile,
97
+ aiVerdict: r,
98
+ });
99
+ }
100
+ }
101
+ return { findings, usage };
102
+ }
@@ -0,0 +1,111 @@
1
+ // Accessible authentication checker. Covers (substantially):
2
+ //
3
+ // 3.3.8 Accessible Authentication (Minimum) — added in WCAG 2.2
4
+ // 3.3.9 Accessible Authentication (Enhanced)
5
+ //
6
+ // We detect cognitive function tests by looking for common CAPTCHA scripts,
7
+ // math/text puzzles, and image-selection challenges. The criterion permits
8
+ // these only if there's an alternative method (e.g. WebAuthn) or a
9
+ // mechanism to assist (e.g. password manager support, copy-paste allowed).
10
+
11
+ export async function runAuth(ctx) {
12
+ const { page } = ctx;
13
+ const findings = [];
14
+
15
+ const detection = await page.evaluate(() => {
16
+ const out = { captchas: [], pasteBlocked: [], cognitivePuzzles: [] };
17
+
18
+ // Only flag 3.3.8 when BOTH a visible captcha widget AND an auth form
19
+ // are present on the page. Matching script strings alone is too noisy.
20
+ const hasAuthForm = !!(
21
+ document.querySelector("input[type='password']") ||
22
+ document.querySelector("form [type='email'],form [name*='email' i]") ||
23
+ document.querySelector(".cl-rootBox, .cl-signIn-root, .auth-form, [data-clerk-form]")
24
+ );
25
+ const captchaSelectors = [
26
+ [".g-recaptcha, iframe[src*='recaptcha']", "Google reCAPTCHA"],
27
+ [".h-captcha, iframe[src*='hcaptcha']", "hCaptcha"],
28
+ [".cf-turnstile, iframe[src*='challenges.cloudflare']", "Cloudflare Turnstile"],
29
+ ["iframe[src*='funcaptcha'], iframe[src*='arkoselabs']", "FunCaptcha"],
30
+ ];
31
+ if (hasAuthForm) {
32
+ for (const [sel, label] of captchaSelectors) {
33
+ const el = document.querySelector(sel);
34
+ if (el) {
35
+ const r = el.getBoundingClientRect();
36
+ if (r.width > 0 && r.height > 0) out.captchas.push(label);
37
+ }
38
+ }
39
+ }
40
+
41
+ // Inputs with onpaste="return false" or paste blocking
42
+ document.querySelectorAll("input").forEach((el) => {
43
+ const op = el.getAttribute("onpaste") || "";
44
+ if (/return false|preventDefault/i.test(op)) {
45
+ out.pasteBlocked.push({ name: el.name || el.id, type: el.type });
46
+ }
47
+ });
48
+
49
+ // Heuristic for math/text puzzles in labels
50
+ const labels = Array.from(document.querySelectorAll("label")).map((l) => l.innerText || "");
51
+ for (const text of labels) {
52
+ if (/what is\s+\d+\s*[+\-*x]\s*\d+/i.test(text) || /type the (word|text|number)/i.test(text)) {
53
+ out.cognitivePuzzles.push(text.trim().slice(0, 100));
54
+ }
55
+ }
56
+ return out;
57
+ });
58
+
59
+ if (detection.captchas.length) {
60
+ findings.push({
61
+ source: "playwright",
62
+ ruleId: "auth/captcha-detected",
63
+ criteria: "3.3.8",
64
+ level: "AA",
65
+ impact: "serious",
66
+ description: `CAPTCHA detected (${detection.captchas.join(", ")}). WCAG 3.3.8 (added in 2.2) forbids cognitive function tests for authentication unless an alternative or mechanism to assist is provided.`,
67
+ help: "Provide WebAuthn / passkey authentication, OR ensure the user can use a password manager and the captcha provides an audio / accessible alternative. Cloudflare Turnstile and hCaptcha can be configured to use device-bound puzzles which are 3.3.8-compliant.",
68
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/accessible-authentication-minimum.html",
69
+ selector: "",
70
+ evidence: detection.captchas.join(", "),
71
+ failureSummary: "CAPTCHA on authentication flow",
72
+ screenshotFile: null,
73
+ });
74
+ }
75
+
76
+ if (detection.pasteBlocked.length) {
77
+ findings.push({
78
+ source: "playwright",
79
+ ruleId: "auth/paste-blocked",
80
+ criteria: "3.3.8",
81
+ level: "AA",
82
+ impact: "serious",
83
+ description: `${detection.pasteBlocked.length} input field(s) block pasting. This breaks password managers and is a 3.3.8 failure.`,
84
+ help: "Remove onpaste handlers from password and username fields.",
85
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/accessible-authentication-minimum.html",
86
+ selector: detection.pasteBlocked.map((p) => `input[name='${p.name}']`).join(", "),
87
+ evidence: JSON.stringify(detection.pasteBlocked),
88
+ failureSummary: "Paste blocked on input",
89
+ screenshotFile: null,
90
+ });
91
+ }
92
+
93
+ if (detection.cognitivePuzzles.length) {
94
+ findings.push({
95
+ source: "playwright",
96
+ ruleId: "auth/cognitive-puzzle",
97
+ criteria: "3.3.8",
98
+ level: "AA",
99
+ impact: "serious",
100
+ description: `Cognitive function puzzle(s) detected in form labels: ${detection.cognitivePuzzles.join("; ")}`,
101
+ help: "Replace math/text puzzles with WebAuthn or other accessible authentication.",
102
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/accessible-authentication-minimum.html",
103
+ selector: "label",
104
+ evidence: detection.cognitivePuzzles.join("\n"),
105
+ failureSummary: "Math/text puzzle in form",
106
+ screenshotFile: null,
107
+ });
108
+ }
109
+
110
+ return { findings };
111
+ }
@@ -0,0 +1,65 @@
1
+ // axe-core via @axe-core/playwright. Same engine as the extension; this
2
+ // gives us the ~30 fully-automated WCAG criteria as the baseline.
3
+
4
+ import { AxeBuilder } from "@axe-core/playwright";
5
+ import { captureElement, saveScreenshot } from "../util/screenshot.js";
6
+
7
+ export async function runAxe(ctx) {
8
+ const { page } = ctx;
9
+ const builder = new AxeBuilder({ page }).withTags(ctx.axeTags);
10
+ const result = await builder.analyze();
11
+
12
+ const findings = [];
13
+ for (const violation of result.violations) {
14
+ for (const node of violation.nodes) {
15
+ const criteria = extractCriteria(violation.tags);
16
+ const level = extractLevels(violation.tags).join(",");
17
+ const targetStr = Array.isArray(node.target)
18
+ ? node.target.map((t) => (Array.isArray(t) ? t.join(" >> ") : t)).join(", ")
19
+ : String(node.target || "");
20
+
21
+ // Capture annotated screenshot
22
+ let screenshotFile = null;
23
+ try {
24
+ const buffer = await captureElement(page, page.locator(targetStr).first(), {
25
+ label: `axe · ${violation.id}`,
26
+ });
27
+ screenshotFile = await saveScreenshot(buffer, ctx.screenshotsDir, `axe_${violation.id}`);
28
+ } catch (_) {}
29
+
30
+ findings.push({
31
+ source: "axe",
32
+ ruleId: violation.id,
33
+ criteria: criteria.join(", "),
34
+ level,
35
+ impact: node.impact || violation.impact || "moderate",
36
+ description: violation.description || "",
37
+ help: violation.help || "",
38
+ helpUrl: violation.helpUrl || "",
39
+ selector: targetStr,
40
+ evidence: (node.html || "").slice(0, 500),
41
+ failureSummary: node.failureSummary || "",
42
+ screenshotFile,
43
+ });
44
+ }
45
+ }
46
+ return { findings };
47
+ }
48
+
49
+ function extractCriteria(tags) {
50
+ const out = [];
51
+ for (const t of tags || []) {
52
+ const m = /^wcag(\d)(\d)(\d+)$/i.exec(t);
53
+ if (m) out.push(`${m[1]}.${m[2]}.${m[3]}`);
54
+ }
55
+ return Array.from(new Set(out));
56
+ }
57
+
58
+ function extractLevels(tags) {
59
+ const levels = new Set();
60
+ for (const t of tags || []) {
61
+ const m = /^wcag2[12]?(a{1,3})$/i.exec(t);
62
+ if (m) levels.add(m[1].toUpperCase());
63
+ }
64
+ return Array.from(levels);
65
+ }