@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,362 @@
1
+ import { readFile, writeFile } from "fs/promises";
2
+ import { join, basename, resolve } from "path";
3
+ import { randomUUID } from "crypto";
4
+ import getPort from "get-port";
5
+
6
+ import { readGlobalConfig } from "../config/global.js";
7
+ import { readProjectConfig } from "../config/project.js";
8
+ import { validateLicense } from "../license/validate.js";
9
+ import { logUsage } from "../license/log-usage.js";
10
+ import { detectFramework } from "../discovery/registry.js";
11
+ import { loadManualRoutes } from "../discovery/manual.js";
12
+ import { crawlRoutes } from "../discovery/crawl.js";
13
+ import { expandDynamicRoutes } from "../discovery/dynamic-samples.js";
14
+ import { startDevServer, detectDevCommand } from "../devserver/spawn.js";
15
+ import { renderFindingsMarkdown } from "../output/markdown.js";
16
+ import { renderCursorRules } from "../output/cursor-rules.js";
17
+ import { upsertAgentsMd } from "../output/agents-md.js";
18
+ import { writeProjectReport } from "../output/excel-project.js";
19
+ import { hashContent, readCacheEntry, writeCacheEntry } from "../cache/route-cache.js";
20
+
21
+ const CLI_VERSION = "1.0.0-alpha.11";
22
+
23
+ export async function runScan({
24
+ cwd = process.cwd(),
25
+ dryRun = false,
26
+ noAi = false,
27
+ noCache = false,
28
+ urlMode = null,
29
+ routesFile = null,
30
+ crawlDepth = 2,
31
+ log = console.log,
32
+ runAuditForRoute,
33
+ } = {}) {
34
+ // 1. Load global + project config
35
+ const globalCfg = await readGlobalConfig();
36
+ if (!globalCfg.licenseKey) {
37
+ return {
38
+ ok: false,
39
+ error: "No license key found. Run `npx wcag-audit init` first.",
40
+ };
41
+ }
42
+ const projectCfg = await readProjectConfig(cwd);
43
+
44
+ // 2-3. Detect framework + discover routes.
45
+ // Priority: --url crawl > --routes file > framework detection.
46
+ let framework = null;
47
+ let strategy = null;
48
+ let routes = [];
49
+
50
+ if (urlMode) {
51
+ framework = "crawl";
52
+ strategy = "url-crawl";
53
+ log(`✓ Crawl mode: starting at ${urlMode} (depth ${crawlDepth})`);
54
+ routes = await crawlRoutes(urlMode, {
55
+ maxDepth: crawlDepth,
56
+ excludePaths: projectCfg.excludePaths,
57
+ });
58
+ } else if (routesFile) {
59
+ framework = "manual";
60
+ strategy = "routes-file";
61
+ log(`✓ Manual routes: ${routesFile}`);
62
+ routes = await loadManualRoutes(routesFile, {
63
+ excludePaths: projectCfg.excludePaths,
64
+ });
65
+ } else {
66
+ const det = await detectFramework(cwd);
67
+ if (det) {
68
+ framework = det.framework;
69
+ strategy = det.strategy;
70
+ log(`✓ Detected ${framework} (${strategy})`);
71
+ routes = await det.discoverRoutes(cwd, {
72
+ excludePaths: projectCfg.excludePaths,
73
+ });
74
+ routes = expandDynamicRoutes(routes, projectCfg.dynamicRouteSamples || {});
75
+ }
76
+ }
77
+
78
+ if (routes.length === 0) {
79
+ return {
80
+ ok: false,
81
+ error:
82
+ "No routes discovered. Use --url to crawl a deployed site, --routes to provide a manual list, or add dynamicRouteSamples to .wcagauditrc.",
83
+ };
84
+ }
85
+ log(`✓ Discovered ${routes.length} route${routes.length === 1 ? "" : "s"}`);
86
+
87
+ // 4. Validate license + credit budget
88
+ const machineId = randomUUID();
89
+ const license = await validateLicense(globalCfg.licenseKey, {
90
+ machineId,
91
+ source: "cli",
92
+ version: CLI_VERSION,
93
+ });
94
+ if (!license.valid) {
95
+ return { ok: false, error: `License check failed: ${license.error}` };
96
+ }
97
+ const creditsRemaining = license.creditsRemaining?.total ?? Infinity;
98
+ const creditsNeeded = routes.length;
99
+
100
+ log(
101
+ `✓ License: ${license.tier} plan (${creditsRemaining === Infinity ? "unlimited" : creditsRemaining} credits remaining)`,
102
+ );
103
+ log("");
104
+ log("Scan preview:");
105
+ log(` ${routes.length} routes × 1 credit/route = ${creditsNeeded} credits`);
106
+ if (creditsRemaining !== Infinity) {
107
+ log(` Remaining after scan: ${creditsRemaining - creditsNeeded}`);
108
+ }
109
+ log("");
110
+
111
+ if (license.tier !== "enterprise" && creditsNeeded > creditsRemaining) {
112
+ return {
113
+ ok: false,
114
+ error: `Not enough credits (${creditsNeeded} needed, ${creditsRemaining} remaining). Top up at https://wcagaudit.io/checkout/topup`,
115
+ creditsNeeded,
116
+ creditsRemaining,
117
+ routes,
118
+ framework,
119
+ };
120
+ }
121
+
122
+ if (dryRun) {
123
+ return {
124
+ ok: true,
125
+ dryRun: true,
126
+ framework,
127
+ routes,
128
+ creditsNeeded,
129
+ creditsRemaining,
130
+ license,
131
+ };
132
+ }
133
+
134
+ // 5. Spawn dev server
135
+ const pkgJson = JSON.parse(await readFile(join(cwd, "package.json"), "utf8"));
136
+ const devCmd = projectCfg.devServer.command
137
+ ? { cmd: "sh", args: ["-c", projectCfg.devServer.command] }
138
+ : detectDevCommand(pkgJson);
139
+ if (!devCmd) {
140
+ return {
141
+ ok: false,
142
+ error: "Could not determine dev server command. Add `dev` script to package.json or set devServer.command in .wcagauditrc.",
143
+ };
144
+ }
145
+
146
+ const port = projectCfg.devServer.port || (await getPort({ port: 3000 }));
147
+ log(`Starting dev server on port ${port}...`);
148
+
149
+ let server;
150
+ try {
151
+ server = await startDevServer({
152
+ cwd,
153
+ cmd: devCmd.cmd,
154
+ args: devCmd.args,
155
+ port,
156
+ healthPath: projectCfg.devServer.healthCheck || "/",
157
+ startupTimeout: projectCfg.devServer.startupTimeout || 60000,
158
+ });
159
+ log(`✓ Server ready on ${server.url}`);
160
+ log("");
161
+ } catch (err) {
162
+ return { ok: false, error: err.message };
163
+ }
164
+
165
+ // 6. Audit each route
166
+ const allFindings = [];
167
+ const runFn = runAuditForRoute || (await importAuditFn());
168
+ let newPagesCount = 0;
169
+ let cachedPagesCount = 0;
170
+ log("Auditing routes...");
171
+ for (let i = 0; i < routes.length; i++) {
172
+ const route = routes[i];
173
+ const url = `http://localhost:${port}${route.path}`;
174
+ const label = `[${i + 1}/${routes.length}] ${route.path}`;
175
+ try {
176
+ // Fetch the rendered HTML once to compute the cache key. This is
177
+ // cheap (<100ms) compared to running the full checker pipeline.
178
+ const cacheHtml = await fetch(url).then((r) => r.text()).catch(() => "");
179
+ const hash = hashContent(cacheHtml, []);
180
+ const cached = noCache ? null : await readCacheEntry(cwd, route.path, hash);
181
+ if (cached) {
182
+ const tagged = cached.findings.map((f) => ({
183
+ ...f,
184
+ route: route.path,
185
+ sourceFile: route.sourceFile,
186
+ }));
187
+ allFindings.push(...tagged);
188
+ cachedPagesCount++;
189
+ log(`${label} → ${tagged.length} issues (cached)`);
190
+ continue;
191
+ }
192
+
193
+ const findings = await runFn({
194
+ url,
195
+ wcagVersion: globalCfg.defaults.wcagVersion,
196
+ levels: globalCfg.defaults.conformanceLevel,
197
+ // Consistency checker needs multi-page crawl to compare nav/help/
198
+ // component names across pages. 5 pages balances coverage vs speed.
199
+ maxPages: 5,
200
+ aiEnabled: !noAi && globalCfg.ai.enabled,
201
+ aiProvider: globalCfg.ai.provider,
202
+ aiModel: globalCfg.ai.model,
203
+ aiKey: globalCfg.ai.apiKey,
204
+ headed: false,
205
+ slowMo: 0,
206
+ });
207
+ const tagged = findings.map((f) => ({
208
+ ...f,
209
+ route: route.path,
210
+ sourceFile: route.sourceFile,
211
+ }));
212
+ allFindings.push(...tagged);
213
+ newPagesCount++;
214
+ // Store the bare findings (without route/sourceFile stamps) so
215
+ // re-tagging works even if route metadata changes.
216
+ await writeCacheEntry(cwd, route.path, hash, {
217
+ findings,
218
+ auditedAt: Date.now(),
219
+ });
220
+ log(`${label} → ${tagged.length} issues`);
221
+ } catch (err) {
222
+ log(`${label} → ERROR: ${err.message}`);
223
+ }
224
+ }
225
+
226
+ // 7. Cleanup dev server
227
+ await server.dispose();
228
+
229
+ // 8. Write outputs
230
+ const outputs = new Set(projectCfg.outputs || ["excel", "markdown"]);
231
+ const projectName = basename(cwd);
232
+
233
+ if (outputs.has("markdown")) {
234
+ const md = renderFindingsMarkdown({
235
+ projectName,
236
+ generator: `@wcag-audit/cli v${CLI_VERSION}`,
237
+ totalPages: routes.length,
238
+ wcagVersion: globalCfg.defaults.wcagVersion,
239
+ levels: globalCfg.defaults.conformanceLevel,
240
+ findings: allFindings,
241
+ });
242
+ await writeFile(resolve(cwd, "WCAG_FIXES.md"), md, "utf8");
243
+ log(`✓ WCAG_FIXES.md (${allFindings.length} findings)`);
244
+ }
245
+
246
+ if (outputs.has("json")) {
247
+ // Structured JSON output for programmatic consumers (custom
248
+ // dashboards, CI integrations, historical trend tracking).
249
+ const json = {
250
+ generator: `@wcag-audit/cli v${CLI_VERSION}`,
251
+ generatedAt: new Date().toISOString(),
252
+ projectName,
253
+ framework,
254
+ strategy,
255
+ wcagVersion: globalCfg.defaults.wcagVersion,
256
+ levels: globalCfg.defaults.conformanceLevel,
257
+ totalPages: routes.length,
258
+ newPagesCount,
259
+ cachedPagesCount,
260
+ totalIssues: allFindings.length,
261
+ findings: allFindings,
262
+ };
263
+ await writeFile(
264
+ resolve(cwd, "wcag-report.json"),
265
+ JSON.stringify(json, null, 2),
266
+ "utf8",
267
+ );
268
+ log(`✓ wcag-report.json`);
269
+ }
270
+
271
+ if (outputs.has("cursor-rules")) {
272
+ const mdc = renderCursorRules({ projectName, findings: allFindings });
273
+ const dir = resolve(cwd, ".cursor/rules");
274
+ const { mkdir } = await import("fs/promises");
275
+ await mkdir(dir, { recursive: true });
276
+ await writeFile(resolve(dir, "wcag-fixes.mdc"), mdc, "utf8");
277
+ log(`✓ .cursor/rules/wcag-fixes.mdc`);
278
+ }
279
+
280
+ if (outputs.has("agents-md")) {
281
+ // Build a compact bulleted list for AGENTS.md — just the per-file
282
+ // summary, not the full finding detail (full detail lives in
283
+ // WCAG_FIXES.md).
284
+ const byFile = new Map();
285
+ for (const f of allFindings) {
286
+ const key = f.sourceFile || "(unknown)";
287
+ if (!byFile.has(key)) byFile.set(key, []);
288
+ byFile.get(key).push(f);
289
+ }
290
+ let body = "";
291
+ if (byFile.size === 0) {
292
+ body = "No outstanding WCAG issues from the last scan.\n";
293
+ } else {
294
+ for (const [file, items] of byFile.entries()) {
295
+ body += `### ${file}\n`;
296
+ for (const f of items.slice(0, 10)) {
297
+ const crit = Array.isArray(f.criteria) ? f.criteria.join(", ") : f.criteria || "?";
298
+ body += `- ${f.description || f.ruleId} — ${crit} (${f.impact || "minor"})\n`;
299
+ }
300
+ body += "\n";
301
+ }
302
+ }
303
+ await upsertAgentsMd(cwd, body);
304
+ log(`✓ AGENTS.md WCAG section updated`);
305
+ }
306
+
307
+ if (outputs.has("excel")) {
308
+ await writeProjectReport({
309
+ findings: allFindings,
310
+ meta: {
311
+ projectName,
312
+ generator: `@wcag-audit/cli v${CLI_VERSION}`,
313
+ framework,
314
+ strategy,
315
+ wcagVersion: globalCfg.defaults.wcagVersion,
316
+ levels: globalCfg.defaults.conformanceLevel,
317
+ totalPages: routes.length,
318
+ newPagesCount,
319
+ cachedPagesCount,
320
+ },
321
+ outPath: resolve(cwd, "wcag-report.xlsx"),
322
+ });
323
+ log(`✓ wcag-report.xlsx`);
324
+ }
325
+
326
+ // 9. Log usage for billing
327
+ await logUsage({
328
+ licenseKey: globalCfg.licenseKey,
329
+ pagesCount: routes.length,
330
+ newPagesCount,
331
+ cachedPagesCount,
332
+ issuesFound: allFindings.length,
333
+ source: "cli",
334
+ cliVersion: CLI_VERSION,
335
+ framework,
336
+ });
337
+
338
+ log("");
339
+ log(`✓ Scan complete — ${allFindings.length} issues across ${routes.length} pages (${newPagesCount} audited, ${cachedPagesCount} cached)`);
340
+ return {
341
+ ok: true,
342
+ framework,
343
+ routes,
344
+ findings: allFindings,
345
+ creditsUsed: creditsNeeded,
346
+ };
347
+ }
348
+
349
+ async function importAuditFn() {
350
+ // runAuditCore returns { findings, meta } without writing any files.
351
+ // runAudit (the legacy wrapper) writes a per-page Excel and returns
352
+ // void, which loses the findings.
353
+ const { runAuditCore } = await import("../audit.js");
354
+ return async (opts) => {
355
+ const tmpScreens = `/tmp/wcag-screens-${Math.random().toString(36).slice(2)}`;
356
+ const result = await runAuditCore({
357
+ ...opts,
358
+ screenshotsDir: tmpScreens,
359
+ });
360
+ return result?.findings || [];
361
+ };
362
+ }
@@ -0,0 +1,139 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { mkdtemp, rm, mkdir, writeFile } from "fs/promises";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { runScan } from "./scan.js";
6
+
7
+ let tmpHome;
8
+ let projDir;
9
+
10
+ async function touch(relPath) {
11
+ const abs = join(projDir, relPath);
12
+ await mkdir(join(abs, ".."), { recursive: true });
13
+ await writeFile(abs, "// test", "utf8");
14
+ }
15
+
16
+ beforeEach(async () => {
17
+ tmpHome = await mkdtemp(join(tmpdir(), "wcagscan-home-"));
18
+ projDir = await mkdtemp(join(tmpdir(), "wcagscan-proj-"));
19
+ process.env.HOME = tmpHome;
20
+ global.fetch = vi.fn();
21
+ });
22
+
23
+ afterEach(async () => {
24
+ await rm(tmpHome, { recursive: true, force: true });
25
+ await rm(projDir, { recursive: true, force: true });
26
+ vi.restoreAllMocks();
27
+ });
28
+
29
+ describe("runScan — dry run preview", () => {
30
+ it("fails when no config is found", async () => {
31
+ const result = await runScan({ cwd: projDir, dryRun: true, log: () => {} });
32
+ expect(result.ok).toBe(false);
33
+ expect(result.error).toMatch(/init/i);
34
+ });
35
+
36
+ it("reports discovered routes and credit cost", async () => {
37
+ await writeFile(
38
+ join(tmpHome, ".wcagauditrc"),
39
+ JSON.stringify({
40
+ licenseKey: "WCAG-TEST-AAAA-BBBB-CCCC",
41
+ ai: { enabled: false, provider: "anthropic", apiKey: null },
42
+ }),
43
+ "utf8"
44
+ );
45
+
46
+ await writeFile(
47
+ join(projDir, "package.json"),
48
+ JSON.stringify({ dependencies: { next: "15.0.0" }, scripts: { dev: "next dev" } }),
49
+ "utf8"
50
+ );
51
+ await touch("app/page.tsx");
52
+ await touch("app/about/page.tsx");
53
+ await touch("app/pricing/page.tsx");
54
+
55
+ global.fetch.mockResolvedValueOnce({
56
+ ok: true,
57
+ json: async () => ({
58
+ valid: true,
59
+ tier: "pro",
60
+ creditsRemaining: { total: 200 },
61
+ }),
62
+ });
63
+
64
+ const logs = [];
65
+ const result = await runScan({
66
+ cwd: projDir,
67
+ dryRun: true,
68
+ log: (m) => logs.push(m),
69
+ });
70
+
71
+ expect(result.ok).toBe(true);
72
+ expect(result.framework).toBe("nextjs-app");
73
+ expect(result.routes.map((r) => r.path).sort()).toEqual([
74
+ "/",
75
+ "/about",
76
+ "/pricing",
77
+ ]);
78
+ expect(result.creditsNeeded).toBe(3);
79
+ expect(result.creditsRemaining).toBe(200);
80
+ const joined = logs.join("\n");
81
+ expect(joined).toMatch(/Detected nextjs-app/);
82
+ expect(joined).toMatch(/3 routes/);
83
+ expect(joined).toMatch(/3 credits/);
84
+ });
85
+
86
+ it("fails when credits insufficient", async () => {
87
+ await writeFile(
88
+ join(tmpHome, ".wcagauditrc"),
89
+ JSON.stringify({
90
+ licenseKey: "WCAG-TEST-AAAA-BBBB-CCCC",
91
+ ai: { enabled: false, provider: "anthropic", apiKey: null },
92
+ }),
93
+ "utf8"
94
+ );
95
+ await writeFile(
96
+ join(projDir, "package.json"),
97
+ JSON.stringify({ dependencies: { next: "15.0.0" } }),
98
+ "utf8"
99
+ );
100
+ await touch("app/page.tsx");
101
+ await touch("app/a/page.tsx");
102
+ await touch("app/b/page.tsx");
103
+ await touch("app/c/page.tsx");
104
+ await touch("app/d/page.tsx");
105
+
106
+ global.fetch.mockResolvedValueOnce({
107
+ ok: true,
108
+ json: async () => ({
109
+ valid: true,
110
+ tier: "pro",
111
+ creditsRemaining: { total: 2 },
112
+ }),
113
+ });
114
+
115
+ const result = await runScan({ cwd: projDir, dryRun: true, log: () => {} });
116
+ expect(result.ok).toBe(false);
117
+ expect(result.error).toMatch(/not enough credits/i);
118
+ });
119
+
120
+ it("returns error when framework not detected", async () => {
121
+ await writeFile(
122
+ join(tmpHome, ".wcagauditrc"),
123
+ JSON.stringify({
124
+ licenseKey: "WCAG-TEST-AAAA-BBBB-CCCC",
125
+ ai: { enabled: false },
126
+ }),
127
+ "utf8"
128
+ );
129
+
130
+ global.fetch.mockResolvedValueOnce({
131
+ ok: true,
132
+ json: async () => ({ valid: true, tier: "pro", creditsRemaining: { total: 100 } }),
133
+ });
134
+
135
+ const result = await runScan({ cwd: projDir, dryRun: true, log: () => {} });
136
+ expect(result.ok).toBe(false);
137
+ expect(result.error).toMatch(/routes discovered|framework/i);
138
+ });
139
+ });
@@ -0,0 +1,89 @@
1
+ import { watch } from "fs";
2
+ import { join } from "path";
3
+ import { runScan } from "./scan.js";
4
+
5
+ // Watch mode: rescan every time a source file changes. Debounced so a
6
+ // burst of saves (prettier-on-save, hot reload) only triggers one scan.
7
+ //
8
+ // The watcher only fires on changes to /src, /app, /pages, /components
9
+ // — not node_modules or .next. Ignores .wcag-audit/cache so cache
10
+ // writes don't re-trigger scans.
11
+ export async function runWatch({
12
+ cwd = process.cwd(),
13
+ debounceMs = 2000,
14
+ log = console.log,
15
+ } = {}) {
16
+ const watchDirs = ["src", "app", "pages", "components"]
17
+ .map((d) => join(cwd, d));
18
+
19
+ log("");
20
+ log("wcag-audit watch — rescanning on source changes");
21
+ log(`Watched dirs: ${watchDirs.map((d) => d.replace(cwd + "/", "")).join(", ")}`);
22
+ log("Press Ctrl+C to stop.");
23
+ log("");
24
+
25
+ // Run once on startup so the first report lands before any edits.
26
+ await runScan({ cwd, log });
27
+
28
+ let pending = null;
29
+ let running = false;
30
+ let needsRerun = false;
31
+
32
+ const trigger = (reason) => {
33
+ if (pending) clearTimeout(pending);
34
+ pending = setTimeout(async () => {
35
+ pending = null;
36
+ if (running) {
37
+ needsRerun = true;
38
+ return;
39
+ }
40
+ running = true;
41
+ log("");
42
+ log(`─── change detected (${reason}) — rescanning ───`);
43
+ try {
44
+ await runScan({ cwd, log });
45
+ } catch (err) {
46
+ log(`✗ Scan failed: ${err.message}`);
47
+ }
48
+ running = false;
49
+ if (needsRerun) {
50
+ needsRerun = false;
51
+ trigger("queued changes");
52
+ }
53
+ }, debounceMs);
54
+ };
55
+
56
+ const watchers = [];
57
+ for (const dir of watchDirs) {
58
+ try {
59
+ const w = watch(dir, { recursive: true }, (_event, filename) => {
60
+ if (!filename) return;
61
+ // Ignore cache writes so our own output doesn't loop
62
+ if (filename.startsWith(".wcag-audit")) return;
63
+ if (filename.includes("node_modules")) return;
64
+ trigger(filename);
65
+ });
66
+ watchers.push(w);
67
+ } catch {
68
+ // Directory doesn't exist — skip silently
69
+ }
70
+ }
71
+
72
+ if (watchers.length === 0) {
73
+ log("! No source directories found to watch. Tried: " + watchDirs.join(", "));
74
+ return { ok: false, error: "No source directories to watch" };
75
+ }
76
+
77
+ // Keep the process alive until SIGINT
78
+ return new Promise((resolve) => {
79
+ const cleanup = () => {
80
+ for (const w of watchers) w.close();
81
+ if (pending) clearTimeout(pending);
82
+ log("");
83
+ log("Watcher stopped.");
84
+ resolve({ ok: true });
85
+ };
86
+ process.on("SIGINT", cleanup);
87
+ process.on("SIGTERM", cleanup);
88
+ });
89
+ }
@@ -0,0 +1,60 @@
1
+ import { readFile, writeFile, chmod } from "fs/promises";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+
5
+ export const DEFAULT_GLOBAL_CONFIG = {
6
+ licenseKey: null,
7
+ ai: {
8
+ enabled: false,
9
+ provider: "anthropic",
10
+ apiKey: null,
11
+ model: null,
12
+ groups: ["visual", "structure", "language"],
13
+ },
14
+ defaults: {
15
+ conformanceLevel: ["A", "AA"],
16
+ wcagVersion: "2.2",
17
+ includeBestPractices: false,
18
+ },
19
+ };
20
+
21
+ function configPath() {
22
+ return join(process.env.HOME || homedir(), ".wcagauditrc");
23
+ }
24
+
25
+ export async function readGlobalConfig() {
26
+ try {
27
+ const raw = await readFile(configPath(), "utf8");
28
+ const parsed = JSON.parse(raw);
29
+ return deepMerge(DEFAULT_GLOBAL_CONFIG, parsed);
30
+ } catch {
31
+ return structuredClone(DEFAULT_GLOBAL_CONFIG);
32
+ }
33
+ }
34
+
35
+ export async function writeGlobalConfig(config) {
36
+ const path = configPath();
37
+ const merged = deepMerge(DEFAULT_GLOBAL_CONFIG, config);
38
+ await writeFile(path, JSON.stringify(merged, null, 2), "utf8");
39
+ await chmod(path, 0o600);
40
+ }
41
+
42
+ export async function mergeGlobalConfig(patch) {
43
+ const current = await readGlobalConfig();
44
+ const next = deepMerge(current, patch);
45
+ await writeGlobalConfig(next);
46
+ }
47
+
48
+ function deepMerge(base, override) {
49
+ if (override == null) return structuredClone(base);
50
+ if (typeof base !== "object" || typeof override !== "object") return override;
51
+ if (Array.isArray(base) || Array.isArray(override)) return override;
52
+ const out = { ...base };
53
+ for (const key of Object.keys(override)) {
54
+ out[key] =
55
+ key in base && typeof base[key] === "object" && !Array.isArray(base[key])
56
+ ? deepMerge(base[key], override[key])
57
+ : override[key];
58
+ }
59
+ return out;
60
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtemp, rm, readFile, stat } from "fs/promises";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import {
6
+ readGlobalConfig,
7
+ writeGlobalConfig,
8
+ mergeGlobalConfig,
9
+ DEFAULT_GLOBAL_CONFIG,
10
+ } from "./global.js";
11
+
12
+ let tmpHome;
13
+
14
+ beforeEach(async () => {
15
+ tmpHome = await mkdtemp(join(tmpdir(), "wcagrc-"));
16
+ process.env.HOME = tmpHome;
17
+ });
18
+
19
+ afterEach(async () => {
20
+ await rm(tmpHome, { recursive: true, force: true });
21
+ });
22
+
23
+ describe("global config", () => {
24
+ it("returns defaults when no file exists", async () => {
25
+ const cfg = await readGlobalConfig();
26
+ expect(cfg).toEqual(DEFAULT_GLOBAL_CONFIG);
27
+ });
28
+
29
+ it("persists config and chmods to 600", async () => {
30
+ await writeGlobalConfig({
31
+ licenseKey: "WCAG-TEST-AAAA-BBBB-CCCC",
32
+ ai: { enabled: true, provider: "anthropic", apiKey: "sk-ant-x" },
33
+ });
34
+ const cfg = await readGlobalConfig();
35
+ expect(cfg.licenseKey).toBe("WCAG-TEST-AAAA-BBBB-CCCC");
36
+ expect(cfg.ai.provider).toBe("anthropic");
37
+ const s = await stat(join(tmpHome, ".wcagauditrc"));
38
+ expect(s.mode & 0o777).toBe(0o600);
39
+ });
40
+
41
+ it("mergeGlobalConfig overlays new fields onto existing", async () => {
42
+ await writeGlobalConfig({ licenseKey: "WCAG-A-B-C-D", ai: { enabled: false } });
43
+ await mergeGlobalConfig({ ai: { enabled: true, provider: "openai" } });
44
+ const cfg = await readGlobalConfig();
45
+ expect(cfg.licenseKey).toBe("WCAG-A-B-C-D");
46
+ expect(cfg.ai.enabled).toBe(true);
47
+ expect(cfg.ai.provider).toBe("openai");
48
+ });
49
+
50
+ it("corrupt JSON falls back to defaults", async () => {
51
+ await writeGlobalConfig({ licenseKey: "WCAG-X" });
52
+ const path = join(tmpHome, ".wcagauditrc");
53
+ const { writeFile } = await import("fs/promises");
54
+ await writeFile(path, "{ not json ");
55
+ const cfg = await readGlobalConfig();
56
+ expect(cfg).toEqual(DEFAULT_GLOBAL_CONFIG);
57
+ });
58
+ });