@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,53 @@
1
+ // Shared helpers for WCAG 3.2.3 / 3.2.4 consistency checks. Kept as a
2
+ // pure ES module so both the Node-side checker (cli/src/checkers/
3
+ // consistency.js) and the Vitest suite can import them. The extension's
4
+ // browser-context copy of the same logic lives inline in
5
+ // ext-checkers/consistency.js — keep the two in sync.
6
+
7
+ export function dedupPreserveOrder(arr) {
8
+ const seen = new Set();
9
+ const out = [];
10
+ for (const x of arr) if (!seen.has(x)) { seen.add(x); out.push(x); }
11
+ return out;
12
+ }
13
+
14
+ export function normalizeLabel(s) {
15
+ return (s || "")
16
+ .toLowerCase()
17
+ .replace(/[→←▸▶️→↗↘🡒→]/g, "")
18
+ .replace(/[^\p{L}\p{N}\s]/gu, " ")
19
+ .replace(/\s+/g, " ")
20
+ .trim();
21
+ }
22
+
23
+ // WCAG 3.2.4 is about FUNCTIONAL consistency. Two names are compatible
24
+ // when they describe the same component even if decorators differ.
25
+ export function namesCompatible(a, b) {
26
+ const na = normalizeLabel(a);
27
+ const nb = normalizeLabel(b);
28
+ if (!na || !nb) return true;
29
+ if (na === nb) return true;
30
+ if (na.includes(nb) || nb.includes(na)) return true;
31
+ const fa = na.split(" ")[0];
32
+ const fb = nb.split(" ")[0];
33
+ if (fa && fa === fb && fa.length >= 4) return true;
34
+ // All words of the shorter name appear in the longer name.
35
+ // Catches "CLI" ↔ "Full CLI docs" and "CLI docs" ↔ "CLI (npx …)".
36
+ const wa = na.split(" ");
37
+ const wb = nb.split(" ");
38
+ const [short, long] = wa.length <= wb.length ? [wa, wb] : [wb, wa];
39
+ const longSet = new Set(long);
40
+ if (short.every((w) => w.length >= 3 && longSet.has(w))) return true;
41
+ return false;
42
+ }
43
+
44
+ // Relative-order check: given two nav link arrays (already dedupped),
45
+ // return true when the subset of common labels appears in the SAME
46
+ // relative order on both sides.
47
+ export function navOrderMatches(a, b) {
48
+ const common = a.filter((x) => b.includes(x));
49
+ const aOrder = common.map((x) => a.indexOf(x));
50
+ const bOrder = common.map((x) => b.indexOf(x));
51
+ const increasing = (arr) => arr.every((v, i) => i === 0 || v > arr[i - 1]);
52
+ return increasing(aOrder) && increasing(bOrder);
53
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ dedupPreserveOrder,
4
+ normalizeLabel,
5
+ namesCompatible,
6
+ navOrderMatches,
7
+ } from "./consistency-match.js";
8
+
9
+ describe("dedupPreserveOrder", () => {
10
+ it("drops later duplicates and preserves first-seen order", () => {
11
+ expect(dedupPreserveOrder(["a", "b", "a", "c", "b"])).toEqual(["a", "b", "c"]);
12
+ });
13
+ it("handles empty arrays", () => {
14
+ expect(dedupPreserveOrder([])).toEqual([]);
15
+ });
16
+ });
17
+
18
+ describe("normalizeLabel", () => {
19
+ it("strips trailing arrow decorator", () => {
20
+ expect(normalizeLabel("View on npm →")).toBe("view on npm");
21
+ });
22
+ it("collapses whitespace and lowercases", () => {
23
+ expect(normalizeLabel(" CLI Docs ")).toBe("cli docs");
24
+ });
25
+ });
26
+
27
+ describe("namesCompatible", () => {
28
+ it("treats view on npm / view on npm → as compatible", () => {
29
+ expect(namesCompatible("view on npm", "View on npm →")).toBe(true);
30
+ });
31
+ it("treats CLI / CLI Docs as compatible (substring)", () => {
32
+ expect(namesCompatible("CLI", "CLI Docs")).toBe(true);
33
+ });
34
+ it("treats View on npm / View on npm → as compatible (trailing arrow decorator)", () => {
35
+ expect(namesCompatible("View on npm", "View on npm →")).toBe(true);
36
+ });
37
+ it("flags unrelated names", () => {
38
+ expect(namesCompatible("Pricing", "Contact")).toBe(false);
39
+ });
40
+ });
41
+
42
+ describe("navOrderMatches", () => {
43
+ it("returns true when duplicate nav is dedupped and order is identical", () => {
44
+ const raw = ["WCAG Audit", "Pricing", "CLI", "Download", "Contact", "Dashboard",
45
+ "Pricing", "CLI", "Download", "Contact", "Dashboard"];
46
+ const deduped = dedupPreserveOrder(raw);
47
+ expect(navOrderMatches(deduped, deduped)).toBe(true);
48
+ });
49
+ it("returns false when order differs after dedup", () => {
50
+ const a = ["Home", "Pricing", "CLI"];
51
+ const b = ["Home", "CLI", "Pricing"];
52
+ expect(navOrderMatches(a, b)).toBe(false);
53
+ });
54
+ });
@@ -0,0 +1,214 @@
1
+ // Viewport / CSS-injection checker. Covers:
2
+ //
3
+ // 1.4.4 Resize Text — emulate 200% browser zoom, look for content loss
4
+ // 1.4.10 Reflow — shrink viewport to 320 CSS px, check for h-scroll
5
+ // 1.4.12 Text Spacing — inject WCAG-required spacing CSS, check for clipped text
6
+ //
7
+ // Each test runs in a fresh page context built from the live URL so we
8
+ // don't pollute the main page state for downstream checkers.
9
+
10
+ import { saveScreenshot } from "../util/screenshot.js";
11
+
12
+ export async function runViewport(ctx) {
13
+ const findings = [];
14
+
15
+ // ── 1.4.10 Reflow ────────────────────────────────────────────────────
16
+ {
17
+ const sub = await ctx.context.newPage();
18
+ try {
19
+ await sub.setViewportSize({ width: 320, height: 256 });
20
+ await sub.goto(ctx.url, { waitUntil: "networkidle", timeout: 60_000 });
21
+ await sub.waitForTimeout(500);
22
+ const result = await sub.evaluate(() => {
23
+ return {
24
+ docWidth: document.documentElement.scrollWidth,
25
+ viewportWidth: window.innerWidth,
26
+ horizontalOverflowElems: (() => {
27
+ const out = [];
28
+ document.querySelectorAll("body *").forEach((el) => {
29
+ const r = el.getBoundingClientRect();
30
+ if (r.right > window.innerWidth + 5 && r.width > 50) {
31
+ out.push({
32
+ tag: el.tagName.toLowerCase(),
33
+ width: Math.round(r.width),
34
+ text: (el.innerText || "").trim().slice(0, 60),
35
+ });
36
+ }
37
+ });
38
+ return out.slice(0, 15);
39
+ })(),
40
+ };
41
+ });
42
+ if (result.docWidth > result.viewportWidth + 5) {
43
+ const buf = await sub.screenshot({ fullPage: false, type: "png" });
44
+ const screenshotFile = await saveScreenshot(buf, ctx.screenshotsDir, "reflow_320");
45
+ findings.push({
46
+ source: "playwright",
47
+ ruleId: "viewport/reflow",
48
+ criteria: "1.4.10",
49
+ level: "AA",
50
+ impact: "serious",
51
+ description: `Page requires horizontal scrolling at 320 CSS px (document width = ${result.docWidth}px). WCAG 1.4.10 requires reflow without two-dimensional scrolling at this width.`,
52
+ help: "Make layouts responsive. Avoid fixed-pixel widths on containers, tables, and images. Use min-width: 0 on flex children, allow long words to break, and use overflow-wrap: anywhere.",
53
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/reflow.html",
54
+ selector: result.horizontalOverflowElems.map((e) => e.tag).slice(0, 5).join(", "),
55
+ evidence: JSON.stringify(result.horizontalOverflowElems, null, 2),
56
+ failureSummary: `${result.horizontalOverflowElems.length} elements overflow the 320px viewport`,
57
+ screenshotFile,
58
+ });
59
+ }
60
+ } finally {
61
+ await sub.close().catch(() => {});
62
+ }
63
+ }
64
+
65
+ // ── 1.4.4 Resize Text (200% zoom) ────────────────────────────────────
66
+ {
67
+ const sub = await ctx.context.newPage();
68
+ try {
69
+ await sub.setViewportSize({ width: 1366, height: 900 });
70
+ await sub.goto(ctx.url, { waitUntil: "networkidle", timeout: 60_000 });
71
+ // Approximate 200% zoom by doubling the document fontSize and using
72
+ // CSS transform on body. Pure zoom would need DevTools Protocol; this
73
+ // catches most layout-loss bugs.
74
+ await sub.addStyleTag({
75
+ content: `html { font-size: 200% !important; } body { zoom: 2; }`,
76
+ });
77
+ await sub.waitForTimeout(400);
78
+ const overflow = await sub.evaluate(() => {
79
+ // Visually-hidden patterns (sr-only, screen-reader-only, etc.) are
80
+ // INTENDED to be 1x1px with clipped content — they're WCAG-approved
81
+ // for skip links and screen-reader-only instructions. Skip them.
82
+ function isVisuallyHidden(el, cs) {
83
+ const r = el.getBoundingClientRect();
84
+ if (r.width <= 2 && r.height <= 2) return true;
85
+ if (cs.clip && cs.clip !== "auto" && cs.clip !== "unset") return true;
86
+ if (cs.clipPath && cs.clipPath !== "none" && cs.clipPath.includes("inset(100%)")) return true;
87
+ const classes = el.className || "";
88
+ if (typeof classes === "string" && /\b(sr-only|visually-hidden|screen-reader|a11y-hidden)\b/.test(classes)) {
89
+ return true;
90
+ }
91
+ return false;
92
+ }
93
+ const out = [];
94
+ document.querySelectorAll("body *").forEach((el) => {
95
+ const cs = getComputedStyle(el);
96
+ if (isVisuallyHidden(el, cs)) return;
97
+ if (el.scrollHeight > el.clientHeight + 2 && cs.overflow === "hidden") {
98
+ const cls = (el.className && typeof el.className === "string") ? el.className.split(/\s+/).filter(Boolean).slice(0, 2).join(".") : "";
99
+ out.push({
100
+ tag: el.tagName.toLowerCase(),
101
+ id: el.id || "",
102
+ cls,
103
+ text: (el.innerText || "").trim().slice(0, 60),
104
+ });
105
+ }
106
+ });
107
+ return out.slice(0, 15);
108
+ });
109
+ // Keep only elements with a distinctive selector (id or class). A
110
+ // bare <a> or <div> with no class produces more noise than signal.
111
+ const informative = overflow.filter((e) => e.id || e.cls);
112
+ if (informative.length) {
113
+ const buf = await sub.screenshot({ fullPage: false, type: "png" });
114
+ const screenshotFile = await saveScreenshot(buf, ctx.screenshotsDir, "resize_text_200");
115
+ const sel = informative.slice(0, 5).map((e) => e.id ? `#${e.id}` : `${e.tag}.${e.cls}`).join(", ");
116
+ findings.push({
117
+ source: "playwright",
118
+ ruleId: "viewport/resize-text",
119
+ criteria: "1.4.4",
120
+ level: "AA",
121
+ impact: "serious",
122
+ description: `${informative.length} element(s) have clipped content when text is doubled in size (overflow:hidden with scroll content).`,
123
+ help: "WCAG 1.4.4 requires that text be resizable up to 200% without loss of content or function. Avoid fixed heights on text containers. Use min-height + padding instead of height.",
124
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/resize-text.html",
125
+ selector: sel,
126
+ evidence: JSON.stringify(informative.slice(0, 10), null, 2),
127
+ failureSummary: `${informative.length} clipped containers`,
128
+ screenshotFile,
129
+ });
130
+ }
131
+ } finally {
132
+ await sub.close().catch(() => {});
133
+ }
134
+ }
135
+
136
+ // ── 1.4.12 Text Spacing ──────────────────────────────────────────────
137
+ {
138
+ const sub = await ctx.context.newPage();
139
+ try {
140
+ await sub.setViewportSize({ width: 1366, height: 900 });
141
+ await sub.goto(ctx.url, { waitUntil: "networkidle", timeout: 60_000 });
142
+ // Inject the WCAG 1.4.12 mandatory spacing values
143
+ await sub.addStyleTag({
144
+ content: `
145
+ * {
146
+ line-height: 1.5 !important;
147
+ letter-spacing: 0.12em !important;
148
+ word-spacing: 0.16em !important;
149
+ }
150
+ p { margin-bottom: 2em !important; }
151
+ `,
152
+ });
153
+ await sub.waitForTimeout(400);
154
+ const clipped = await sub.evaluate(() => {
155
+ // Skip visually-hidden (sr-only) patterns — they're intentionally
156
+ // clipped and WCAG-approved for skip links.
157
+ function isVisuallyHidden(el, cs) {
158
+ const r = el.getBoundingClientRect();
159
+ if (r.width <= 2 && r.height <= 2) return true;
160
+ if (cs.clip && cs.clip !== "auto" && cs.clip !== "unset") return true;
161
+ if (cs.clipPath && cs.clipPath !== "none" && cs.clipPath.includes("inset(100%)")) return true;
162
+ const classes = el.className || "";
163
+ if (typeof classes === "string" && /\b(sr-only|visually-hidden|screen-reader|a11y-hidden)\b/.test(classes)) {
164
+ return true;
165
+ }
166
+ return false;
167
+ }
168
+ const out = [];
169
+ document.querySelectorAll("body *").forEach((el) => {
170
+ const cs = getComputedStyle(el);
171
+ if (isVisuallyHidden(el, cs)) return;
172
+ if (
173
+ (cs.overflow === "hidden" || cs.overflowY === "hidden") &&
174
+ el.scrollHeight > el.clientHeight + 4 &&
175
+ (el.innerText || "").trim().length > 10
176
+ ) {
177
+ const cls = (el.className && typeof el.className === "string") ? el.className.split(/\s+/).filter(Boolean).slice(0, 2).join(".") : "";
178
+ out.push({
179
+ tag: el.tagName.toLowerCase(),
180
+ id: el.id || "",
181
+ cls,
182
+ text: (el.innerText || "").trim().slice(0, 60),
183
+ });
184
+ }
185
+ });
186
+ return out.slice(0, 15);
187
+ });
188
+ const informative = clipped.filter((e) => e.id || e.cls);
189
+ if (informative.length) {
190
+ const buf = await sub.screenshot({ fullPage: false, type: "png" });
191
+ const screenshotFile = await saveScreenshot(buf, ctx.screenshotsDir, "text_spacing");
192
+ const sel = informative.slice(0, 5).map((e) => e.id ? `#${e.id}` : `${e.tag}.${e.cls}`).join(", ");
193
+ findings.push({
194
+ source: "playwright",
195
+ ruleId: "viewport/text-spacing",
196
+ criteria: "1.4.12",
197
+ level: "AA",
198
+ impact: "serious",
199
+ description: `${informative.length} element(s) clip their content when WCAG-required text-spacing values are applied (line-height 1.5, letter-spacing 0.12em, word-spacing 0.16em, paragraph spacing 2x).`,
200
+ help: "Remove fixed heights on text containers and avoid overflow:hidden on text. Allow text containers to grow with content.",
201
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/text-spacing.html",
202
+ selector: sel,
203
+ evidence: JSON.stringify(informative.slice(0, 10), null, 2),
204
+ failureSummary: `${informative.length} clipped containers under WCAG spacing`,
205
+ screenshotFile,
206
+ });
207
+ }
208
+ } finally {
209
+ await sub.close().catch(() => {});
210
+ }
211
+ }
212
+
213
+ return { findings };
214
+ }
package/src/cli.js ADDED
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env node
2
+ // CLI entry. Hosts multiple subcommands:
3
+ // wcag-audit init — interactive setup (~/.wcagauditrc)
4
+ // wcag-audit scan — audit current project (uses discovery)
5
+ // wcag-audit audit <url> — legacy single-URL mode (back-compat)
6
+ // wcag-audit config — view current config
7
+
8
+ import { Command } from "commander";
9
+ import { runInit } from "./commands/init.js";
10
+ import { runScan } from "./commands/scan.js";
11
+ import { runCi, FAIL_ON_LEVELS } from "./commands/ci.js";
12
+ import { runDoctor } from "./commands/doctor.js";
13
+ import { runWatch } from "./commands/watch.js";
14
+ import { readGlobalConfig } from "./config/global.js";
15
+ import { runAudit } from "./audit.js";
16
+
17
+ const program = new Command();
18
+
19
+ program
20
+ .name("wcag-audit")
21
+ .description("WCAG 2.1/2.2 auditor for web projects — with AI-ready fixes for Cursor / Claude Code / Windsurf.")
22
+ .version("1.0.0-alpha.11");
23
+
24
+ // ── init ─────────────────────────────────────────────────────────
25
+ program
26
+ .command("init")
27
+ .description("Interactive setup — saves license key + optional AI config to ~/.wcagauditrc")
28
+ .action(async () => {
29
+ const result = await runInit();
30
+ if (!result.ok) process.exit(1);
31
+ });
32
+
33
+ // ── scan ─────────────────────────────────────────────────────────
34
+ program
35
+ .command("scan")
36
+ .description("Audit every route in the current project")
37
+ .option("--dry-run", "Preview credit cost only; do not actually scan", false)
38
+ .option("--no-ai", "Skip AI vision review", false)
39
+ .option("--no-cache", "Ignore cached results from previous scans and re-audit every route", false)
40
+ .option("--url <url>", "Scan a deployed site via BFS crawl instead of local project")
41
+ .option("--routes <file>", "Load routes from a plain text file (one per line)")
42
+ .option("--crawl-depth <n>", "Max BFS depth when using --url", "2")
43
+ .action(async (opts) => {
44
+ const result = await runScan({
45
+ dryRun: !!opts.dryRun,
46
+ noAi: !opts.ai,
47
+ noCache: !opts.cache,
48
+ urlMode: opts.url || null,
49
+ routesFile: opts.routes || null,
50
+ crawlDepth: parseInt(opts.crawlDepth, 10) || 2,
51
+ });
52
+ if (!result.ok) {
53
+ console.error(`\n✗ ${result.error}`);
54
+ process.exit(1);
55
+ }
56
+ });
57
+
58
+ // ── config ───────────────────────────────────────────────────────
59
+ program
60
+ .command("config")
61
+ .description("Show current global config (~/.wcagauditrc)")
62
+ .action(async () => {
63
+ const cfg = await readGlobalConfig();
64
+ const redacted = {
65
+ ...cfg,
66
+ licenseKey: cfg.licenseKey ? maskKey(cfg.licenseKey) : null,
67
+ ai: {
68
+ ...cfg.ai,
69
+ apiKey: cfg.ai?.apiKey ? "***" : null,
70
+ },
71
+ };
72
+ console.log(JSON.stringify(redacted, null, 2));
73
+ });
74
+
75
+ // ── watch ────────────────────────────────────────────────────────
76
+ program
77
+ .command("watch")
78
+ .description("Watch source files and rescan on every change (debounced 2s).")
79
+ .option("--debounce <ms>", "Debounce interval between rescans", "2000")
80
+ .action(async (opts) => {
81
+ const result = await runWatch({
82
+ debounceMs: parseInt(opts.debounce, 10) || 2000,
83
+ });
84
+ if (!result.ok) process.exit(1);
85
+ });
86
+
87
+ // ── ci ───────────────────────────────────────────────────────────
88
+ program
89
+ .command("ci")
90
+ .description("CI-optimized scan. Exit 1 when findings meet the --fail-on threshold.")
91
+ .option(
92
+ `--fail-on <level>`,
93
+ `Fail when an issue of this impact or higher is found. One of: ${FAIL_ON_LEVELS.join(", ")}`,
94
+ "critical",
95
+ )
96
+ .action(async (opts) => {
97
+ const result = await runCi({ failOn: opts.failOn });
98
+ process.exit(result.exitCode || 0);
99
+ });
100
+
101
+ // ── doctor ───────────────────────────────────────────────────────
102
+ program
103
+ .command("doctor")
104
+ .description("Diagnose common setup issues (license, AI key, framework, dev script).")
105
+ .action(async () => {
106
+ const result = await runDoctor();
107
+ if (!result.ok) process.exit(1);
108
+ });
109
+
110
+ // ── audit (legacy) ───────────────────────────────────────────────
111
+ program
112
+ .command("audit")
113
+ .description("Single-URL audit (legacy). Prefer `scan` for local projects.")
114
+ .argument("<url>", "URL of the page (or starting page) to audit")
115
+ .option("--version-wcag <v>", "WCAG version: 2.1 or 2.2", "2.2")
116
+ .option("--levels <levels>", "Comma list of conformance levels (A,AA,AAA)", "A,AA")
117
+ .option("--out <path>", "Output xlsx path", "./wcag-report.xlsx")
118
+ .option("--screenshots-dir <path>", "Where to save annotated PNGs", "./wcag-screenshots")
119
+ .option("--max-pages <n>", "Max pages to crawl for multi-page consistency checks", "5")
120
+ .option("--auth-storage <path>", "Path to a Playwright storageState.json")
121
+ .option("--ai", "Enable AI review for visual-judgment criteria", false)
122
+ .option("--ai-provider <provider>", "LLM provider: anthropic | openai | google", "anthropic")
123
+ .option("--ai-model <model>", "Model id")
124
+ .option("--ai-key <key>", "API key for the chosen provider")
125
+ .option("--headed", "Run Chromium with a visible window", false)
126
+ .option("--slow-mo <ms>", "Slow each Playwright action by N ms", "0")
127
+ .option("--skip <checkers>", "Comma list of checkers to skip")
128
+ .option("--include <checkers>", "Comma list of opt-in checkers to enable")
129
+ .option("--screen-reader", "Shortcut for --include screen-reader", false)
130
+ .action(async (url, opts) => {
131
+ try {
132
+ await runAudit({
133
+ url,
134
+ wcagVersion: opts.versionWcag,
135
+ levels: opts.levels.split(",").map((s) => s.trim().toUpperCase()),
136
+ outPath: opts.out,
137
+ screenshotsDir: opts.screenshotsDir,
138
+ maxPages: parseInt(opts.maxPages, 10),
139
+ authStorage: opts.authStorage || null,
140
+ aiEnabled: !!opts.ai,
141
+ aiProvider: opts.aiProvider || "anthropic",
142
+ aiModel:
143
+ opts.aiModel ||
144
+ ({ anthropic: "claude-sonnet-4-6", openai: "gpt-4.1-mini", google: "gemini-2.5-flash" }[opts.aiProvider || "anthropic"]),
145
+ aiKey:
146
+ opts.aiKey ||
147
+ process.env[{ anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY", google: "GOOGLE_API_KEY" }[opts.aiProvider || "anthropic"]] ||
148
+ null,
149
+ headed: !!opts.headed,
150
+ slowMo: parseInt(opts.slowMo, 10),
151
+ skip: (opts.skip || "").split(",").map((s) => s.trim()).filter(Boolean),
152
+ include: [
153
+ ...((opts.include || "").split(",").map((s) => s.trim()).filter(Boolean)),
154
+ ...(opts.screenReader ? ["screen-reader"] : []),
155
+ ],
156
+ });
157
+ } catch (err) {
158
+ console.error("\n✗ Audit failed:", err.message);
159
+ if (process.env.DEBUG) console.error(err.stack);
160
+ process.exit(1);
161
+ }
162
+ });
163
+
164
+ function maskKey(key) {
165
+ if (!key || key.length < 12) return "***";
166
+ return key.slice(0, 9) + "…" + key.slice(-4);
167
+ }
168
+
169
+ program.parseAsync(process.argv);
@@ -0,0 +1,63 @@
1
+ import { runScan } from "./scan.js";
2
+
3
+ export const FAIL_ON_LEVELS = ["critical", "serious", "moderate", "minor", "none"];
4
+
5
+ // Pure function so it's easy to test in isolation. The runtime runCi
6
+ // below wraps runScan and uses this to decide the exit code.
7
+ export function decideCiResult({ findings, failOn }) {
8
+ if (!FAIL_ON_LEVELS.includes(failOn)) {
9
+ throw new Error(`Invalid failOn value: ${failOn}. Must be one of: ${FAIL_ON_LEVELS.join(", ")}`);
10
+ }
11
+ const counts = { critical: 0, serious: 0, moderate: 0, minor: 0 };
12
+ for (const f of findings) {
13
+ const key = FAIL_ON_LEVELS.includes(f.impact) ? f.impact : "minor";
14
+ counts[key] = (counts[key] || 0) + 1;
15
+ }
16
+ let shouldFail = false;
17
+ if (failOn !== "none") {
18
+ const threshold = FAIL_ON_LEVELS.indexOf(failOn);
19
+ for (let i = 0; i < threshold + 1; i++) {
20
+ if (counts[FAIL_ON_LEVELS[i]] > 0) {
21
+ shouldFail = true;
22
+ break;
23
+ }
24
+ }
25
+ }
26
+ const total = findings.length;
27
+ const summary = [
28
+ `${total} issues found`,
29
+ `(${counts.critical} critical, ${counts.serious} serious, ${counts.moderate} moderate, ${counts.minor} minor)`,
30
+ ].join(" ");
31
+ return { shouldFail, exitCode: shouldFail ? 1 : 0, counts, summary };
32
+ }
33
+
34
+ // Runtime wrapper — CI-flavored runScan: no spinners, structured logs,
35
+ // machine-readable exit code.
36
+ export async function runCi({ cwd = process.cwd(), failOn = "critical", log = console.log } = {}) {
37
+ const result = await runScan({
38
+ cwd,
39
+ dryRun: false,
40
+ noAi: true, // CI runs should be fast + deterministic
41
+ noCache: false,
42
+ log,
43
+ });
44
+
45
+ if (!result.ok) {
46
+ return { ok: false, error: result.error, exitCode: 1 };
47
+ }
48
+
49
+ const decision = decideCiResult({
50
+ findings: result.findings || [],
51
+ failOn,
52
+ });
53
+
54
+ log("");
55
+ log(`[wcag-audit ci] ${decision.summary}`);
56
+ if (decision.shouldFail) {
57
+ log(`[wcag-audit ci] ❌ Failing build — impact ≥ ${failOn}`);
58
+ } else {
59
+ log(`[wcag-audit ci] ✅ Passing — no impact ≥ ${failOn}`);
60
+ }
61
+
62
+ return { ok: true, exitCode: decision.exitCode, ...decision };
63
+ }
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { decideCiResult, FAIL_ON_LEVELS } from "./ci.js";
3
+
4
+ describe("decideCiResult", () => {
5
+ it("passes when there are zero findings", () => {
6
+ const r = decideCiResult({ findings: [], failOn: "critical" });
7
+ expect(r.shouldFail).toBe(false);
8
+ expect(r.exitCode).toBe(0);
9
+ expect(r.summary).toMatch(/0 issues/);
10
+ });
11
+
12
+ it("fails when critical findings exist and threshold is critical", () => {
13
+ const r = decideCiResult({
14
+ findings: [{ impact: "critical" }, { impact: "minor" }],
15
+ failOn: "critical",
16
+ });
17
+ expect(r.shouldFail).toBe(true);
18
+ expect(r.exitCode).toBe(1);
19
+ expect(r.summary).toMatch(/critical/);
20
+ });
21
+
22
+ it("fails when serious findings exist and threshold is serious", () => {
23
+ const r = decideCiResult({
24
+ findings: [{ impact: "serious" }],
25
+ failOn: "serious",
26
+ });
27
+ expect(r.shouldFail).toBe(true);
28
+ });
29
+
30
+ it("does NOT fail on serious findings when threshold is critical", () => {
31
+ const r = decideCiResult({
32
+ findings: [{ impact: "serious" }, { impact: "minor" }],
33
+ failOn: "critical",
34
+ });
35
+ expect(r.shouldFail).toBe(false);
36
+ expect(r.exitCode).toBe(0);
37
+ });
38
+
39
+ it("never fails when threshold is none", () => {
40
+ const r = decideCiResult({
41
+ findings: [{ impact: "critical" }],
42
+ failOn: "none",
43
+ });
44
+ expect(r.shouldFail).toBe(false);
45
+ expect(r.exitCode).toBe(0);
46
+ });
47
+
48
+ it("rejects unknown failOn value", () => {
49
+ expect(() => decideCiResult({ findings: [], failOn: "bogus" })).toThrow(/failOn/);
50
+ });
51
+
52
+ it("exposes the accepted levels for the CLI help text", () => {
53
+ expect(FAIL_ON_LEVELS).toEqual(["critical", "serious", "moderate", "minor", "none"]);
54
+ });
55
+ });