wcag-a11y 0.3.4 → 0.3.6
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.
- package/README.md +70 -2
- package/dist/ai/gemini.js +17 -0
- package/dist/ai/ollama.js +13 -0
- package/dist/ai/openai.js +18 -0
- package/dist/ai/patch-prompt.js +16 -0
- package/dist/cli.js +43 -4
- package/dist/crawler.js +9 -2
- package/dist/engine/index.js +40 -0
- package/dist/fixer.js +322 -0
- package/dist/reporter/markdown.js +4 -2
- package/dist/reporter/terminal.js +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/Dannyplusplus12/WCAG-A11y/actions/workflows/ci.yml)
|
|
4
4
|
|
|
5
|
-
Most accessibility auditors stop at detection — they tell you *what* is broken and leave the rest to you. `wcag-a11y` goes further. It crawls your running dev server with Playwright, runs 40+ WCAG 2.1/2.2 checks, and uses AI to generate
|
|
5
|
+
Most accessibility auditors stop at detection — they tell you *what* is broken and leave the rest to you. `wcag-a11y` goes further. It crawls your running dev server with Playwright, runs 40+ WCAG 2.1/2.2 checks, and uses AI to generate ready-to-paste fix prompts **or write the fixes directly into your source files**.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Two modes:
|
|
8
|
+
- **`scan`** — find violations + generate AI prompts you paste into Cursor, Copilot, or Claude
|
|
9
|
+
- **`fix`** — find violations + patch source files automatically (dry-run by default, `--apply` to write)
|
|
8
10
|
|
|
9
11
|
---
|
|
10
12
|
|
|
@@ -140,6 +142,72 @@ wcag-a11y scan -u http://localhost:3000 --no-ai --ci
|
|
|
140
142
|
|
|
141
143
|
---
|
|
142
144
|
|
|
145
|
+
### `wcag-a11y fix`
|
|
146
|
+
|
|
147
|
+
Scan for violations and apply AI-generated patches directly to your source files. Works with any framework — React, Vue, Angular, Svelte, or plain HTML.
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
# Dry-run: scan and show what would change (safe, no files written)
|
|
151
|
+
wcag-a11y fix -u http://localhost:3000
|
|
152
|
+
|
|
153
|
+
# Preview specific pages
|
|
154
|
+
wcag-a11y fix -u http://localhost:3000 --pages / /about /contact
|
|
155
|
+
|
|
156
|
+
# Write fixes to disk
|
|
157
|
+
wcag-a11y fix -u http://localhost:3000 --apply
|
|
158
|
+
|
|
159
|
+
# Auto-discover pages + write fixes
|
|
160
|
+
wcag-a11y fix -u http://localhost:3000 --crawl --apply
|
|
161
|
+
|
|
162
|
+
# Skip rescanning — load violations from an existing report
|
|
163
|
+
wcag-a11y fix --from-report
|
|
164
|
+
wcag-a11y fix --from-report ./reports/a11y-report.md --apply
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
**How it works:**
|
|
168
|
+
|
|
169
|
+
1. Runs the same scan as `wcag-a11y scan` (or loads an existing report with `--from-report`)
|
|
170
|
+
2. For each violation, locates the source file — checks `violation.source` (React dev mode) first, then falls back to grepping `./src` for unique identifiers in the HTML snippet (`id=`, `name=`, `for=`, local `src=`, text content)
|
|
171
|
+
3. Groups violations by file (multiple violations in the same file → one AI call)
|
|
172
|
+
4. Sends the full file content + violation list to your configured AI provider and asks for the corrected file
|
|
173
|
+
5. Shows a colored diff before writing anything
|
|
174
|
+
6. With `--apply`, overwrites the file; without it, only prints the diff
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
src/components/Navbar.jsx — 2 violation(s)
|
|
178
|
+
· [button-name] Buttons must have an accessible name
|
|
179
|
+
· [aria-valid-role] Elements must use valid ARIA roles
|
|
180
|
+
|
|
181
|
+
Requesting AI patch... done
|
|
182
|
+
+2 -1
|
|
183
|
+
<nav className="navbar">
|
|
184
|
+
- <button onClick={toggle}><MenuIcon /></button>
|
|
185
|
+
+ <button onClick={toggle} aria-label="Toggle navigation"><MenuIcon /></button>
|
|
186
|
+
<ul role="navigation">
|
|
187
|
+
- <li role="listbox">Home</li>
|
|
188
|
+
+ <li>Home</li>
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
| Flag | Default | Description |
|
|
192
|
+
|---|---|---|
|
|
193
|
+
| `-u, --url <url>` | — | Base URL of your running dev server. Required unless `--from-report` is used |
|
|
194
|
+
| `-p, --pages <pages...>` | `/` | Specific pages to scan |
|
|
195
|
+
| `-c, --crawl` | off | Auto-discover pages by following same-origin links |
|
|
196
|
+
| `--from-report [path]` | `a11y-report.md` | Load violations from an existing report instead of scanning. Useful when you already ran `scan --report` and just want to apply fixes |
|
|
197
|
+
| `--apply` | off | Write patched files to disk (dry-run without this flag) |
|
|
198
|
+
| `--provider <name>` | from config | Override AI provider for this run: `gemini`, `openai`, or `ollama` |
|
|
199
|
+
|
|
200
|
+
> **Tip:** Always run without `--apply` first to review the diff. The dry-run is safe — nothing is written to disk.
|
|
201
|
+
|
|
202
|
+
**Common workflow:** run `scan --report` to generate a report for review, then run `fix --from-report --apply` to patch the files — no second browser crawl needed.
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
wcag-a11y scan -u http://localhost:3000 --report # review a11y-report.md
|
|
206
|
+
wcag-a11y fix --from-report --apply # patch files from that report
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
143
211
|
## AI Providers
|
|
144
212
|
|
|
145
213
|
| Provider | Model | Cost | API Key |
|
package/dist/ai/gemini.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildPrompt } from './prompt.js';
|
|
2
|
+
import { buildPatchPrompt } from './patch-prompt.js';
|
|
2
3
|
import { groupViolations } from './group.js';
|
|
3
4
|
import { fallbackExplanation } from './fallback-explanation.js';
|
|
4
5
|
export class GeminiProvider {
|
|
@@ -29,6 +30,22 @@ export class GeminiProvider {
|
|
|
29
30
|
const text = data.candidates?.[0]?.content?.parts?.[0]?.text ?? '[]';
|
|
30
31
|
return this.parse(text, groups, framework);
|
|
31
32
|
}
|
|
33
|
+
async generateFilePatch(fileContent, violations, filePath, framework) {
|
|
34
|
+
const prompt = buildPatchPrompt(fileContent, violations, filePath, framework);
|
|
35
|
+
const url = `https://generativelanguage.googleapis.com/v1beta/models/${this.model}:generateContent?key=${this.apiKey}`;
|
|
36
|
+
const response = await fetch(url, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: { 'Content-Type': 'application/json' },
|
|
39
|
+
body: JSON.stringify({
|
|
40
|
+
contents: [{ parts: [{ text: prompt }] }],
|
|
41
|
+
generationConfig: { temperature: 0.1, maxOutputTokens: 16384 },
|
|
42
|
+
}),
|
|
43
|
+
});
|
|
44
|
+
if (!response.ok)
|
|
45
|
+
throw new Error(`Gemini API error: ${response.status} ${await response.text()}`);
|
|
46
|
+
const data = await response.json();
|
|
47
|
+
return data.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
|
|
48
|
+
}
|
|
32
49
|
parse(text, groups, framework) {
|
|
33
50
|
try {
|
|
34
51
|
const jsonMatch = text.match(/\[[\s\S]*\]/);
|
package/dist/ai/ollama.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildPrompt } from './prompt.js';
|
|
2
|
+
import { buildPatchPrompt } from './patch-prompt.js';
|
|
2
3
|
import { groupViolations } from './group.js';
|
|
3
4
|
import { fallbackExplanation } from './fallback-explanation.js';
|
|
4
5
|
export class OllamaProvider {
|
|
@@ -39,6 +40,18 @@ export class OllamaProvider {
|
|
|
39
40
|
return this.fallback(groups, framework);
|
|
40
41
|
}
|
|
41
42
|
}
|
|
43
|
+
async generateFilePatch(fileContent, violations, filePath, framework) {
|
|
44
|
+
const prompt = buildPatchPrompt(fileContent, violations, filePath, framework);
|
|
45
|
+
const response = await fetch(`${this.baseUrl}/api/generate`, {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: { 'Content-Type': 'application/json' },
|
|
48
|
+
body: JSON.stringify({ model: this.model, prompt, stream: false }),
|
|
49
|
+
});
|
|
50
|
+
if (!response.ok)
|
|
51
|
+
throw new Error(`Ollama error: ${response.status}. Is Ollama running? Run: ollama serve`);
|
|
52
|
+
const data = await response.json();
|
|
53
|
+
return data.response ?? '';
|
|
54
|
+
}
|
|
42
55
|
fallback(groups, framework) {
|
|
43
56
|
return groups.map((g) => this.fallbackFix(g, framework));
|
|
44
57
|
}
|
package/dist/ai/openai.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildPrompt } from './prompt.js';
|
|
2
|
+
import { buildPatchPrompt } from './patch-prompt.js';
|
|
2
3
|
import { groupViolations } from './group.js';
|
|
3
4
|
import { fallbackExplanation } from './fallback-explanation.js';
|
|
4
5
|
export class OpenAIProvider {
|
|
@@ -33,6 +34,23 @@ export class OpenAIProvider {
|
|
|
33
34
|
const text = data.choices?.[0]?.message?.content ?? '[]';
|
|
34
35
|
return this.parse(text, groups, framework);
|
|
35
36
|
}
|
|
37
|
+
async generateFilePatch(fileContent, violations, filePath, framework) {
|
|
38
|
+
const prompt = buildPatchPrompt(fileContent, violations, filePath, framework);
|
|
39
|
+
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiKey}` },
|
|
42
|
+
body: JSON.stringify({
|
|
43
|
+
model: this.model,
|
|
44
|
+
messages: [{ role: 'user', content: prompt }],
|
|
45
|
+
temperature: 0.1,
|
|
46
|
+
max_tokens: 16384,
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
if (!response.ok)
|
|
50
|
+
throw new Error(`OpenAI API error: ${response.status} ${await response.text()}`);
|
|
51
|
+
const data = await response.json();
|
|
52
|
+
return data.choices?.[0]?.message?.content ?? '';
|
|
53
|
+
}
|
|
36
54
|
parse(text, groups, framework) {
|
|
37
55
|
try {
|
|
38
56
|
const jsonMatch = text.match(/\[[\s\S]*\]/);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function buildPatchPrompt(fileContent, violations, filePath, framework) {
|
|
2
|
+
const fwLine = framework ? `\nFramework: ${framework}` : '';
|
|
3
|
+
const vList = violations
|
|
4
|
+
.map((v, i) => `${i + 1}. [${v.ruleId}] WCAG ${v.wcag} Level ${v.level} — ${v.description}\n Selector: ${v.selector}\n HTML: ${v.html.slice(0, 300)}`)
|
|
5
|
+
.join('\n\n');
|
|
6
|
+
return `You are a WCAG accessibility expert. Fix the accessibility violations in the source file below.
|
|
7
|
+
Return ONLY the complete fixed file content — no markdown fences, no explanations, no preamble.
|
|
8
|
+
|
|
9
|
+
File: ${filePath}${fwLine}
|
|
10
|
+
|
|
11
|
+
Violations to fix:
|
|
12
|
+
${vList}
|
|
13
|
+
|
|
14
|
+
FILE CONTENT:
|
|
15
|
+
${fileContent}`;
|
|
16
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -7,11 +7,13 @@ import { groupViolations } from './ai/group.js';
|
|
|
7
7
|
import { printTerminalReport, printAIPrompts } from './reporter/terminal.js';
|
|
8
8
|
import { generateMarkdownReport } from './reporter/markdown.js';
|
|
9
9
|
import { runDemo } from './demo.js';
|
|
10
|
+
import { runFix } from './fixer.js';
|
|
11
|
+
import { resolve } from 'path';
|
|
10
12
|
const program = new Command();
|
|
11
13
|
program
|
|
12
14
|
.name('wcag-a11y')
|
|
13
15
|
.description('WCAG 2.1/2.2 accessibility auditor with AI-powered fixes')
|
|
14
|
-
.version('0.3.
|
|
16
|
+
.version('0.3.6');
|
|
15
17
|
program
|
|
16
18
|
.command('init')
|
|
17
19
|
.description('Create a11y.config.json in the current directory')
|
|
@@ -25,10 +27,10 @@ program
|
|
|
25
27
|
.requiredOption('-u, --url <url>', 'Base URL of your dev server (e.g. http://localhost:3000)')
|
|
26
28
|
.option('-p, --pages <pages...>', 'Specific pages to scan (e.g. / /about /contact)', ['/'])
|
|
27
29
|
.option('-c, --crawl', 'Auto-discover pages by following same-origin links', false)
|
|
28
|
-
.option('-
|
|
30
|
+
.option('--no-report', 'Skip saving markdown report to a11y-report.md')
|
|
29
31
|
.option('--no-ai', 'Skip AI fix generation (faster, violations only)')
|
|
30
32
|
.option('--no-explain', 'Hide AI fix explanations in terminal output')
|
|
31
|
-
.option('--
|
|
33
|
+
.option('--terminal', 'Print violations summary to terminal', false)
|
|
32
34
|
.option('--fast-mode', 'Output only AI fix prompts — no summaries or explanations', false)
|
|
33
35
|
.option('--group <strategy>', 'Group violations by rule or show individually (rule|none)', 'rule')
|
|
34
36
|
.option('--ci', 'Exit with code 1 if any violations are found (for CI/CD pipelines)', false)
|
|
@@ -72,10 +74,47 @@ program
|
|
|
72
74
|
process.exit(1);
|
|
73
75
|
}
|
|
74
76
|
});
|
|
77
|
+
program
|
|
78
|
+
.command('fix')
|
|
79
|
+
.description('Scan for violations and apply AI fixes directly to source files')
|
|
80
|
+
.option('-u, --url <url>', 'Base URL of your dev server (e.g. http://localhost:3000)')
|
|
81
|
+
.option('-p, --pages <pages...>', 'Specific pages to scan', ['/'])
|
|
82
|
+
.option('-c, --crawl', 'Auto-discover pages by following same-origin links', false)
|
|
83
|
+
.option('--from-report [path]', 'Use an existing report instead of scanning (default: a11y-report.md)')
|
|
84
|
+
.option('--apply', 'Write fixes to source files (default: dry-run, shows diff only)', false)
|
|
85
|
+
.option('--provider <name>', 'Override the AI provider from config (gemini|openai|ollama)')
|
|
86
|
+
.action(async (opts) => {
|
|
87
|
+
if (!opts.url && !opts.fromReport) {
|
|
88
|
+
console.error('\nError: provide --url <url> to scan, or --from-report [path] to load an existing report.');
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const config = loadConfig();
|
|
93
|
+
if (opts.provider)
|
|
94
|
+
config.provider = opts.provider;
|
|
95
|
+
const provider = createAIProvider(config);
|
|
96
|
+
const reportPath = opts.fromReport
|
|
97
|
+
? (opts.fromReport === true ? 'a11y-report.md' : opts.fromReport)
|
|
98
|
+
: undefined;
|
|
99
|
+
await runFix({
|
|
100
|
+
url: opts.url,
|
|
101
|
+
pages: opts.pages,
|
|
102
|
+
crawl: opts.crawl,
|
|
103
|
+
reportPath,
|
|
104
|
+
apply: opts.apply,
|
|
105
|
+
provider,
|
|
106
|
+
srcDir: resolve(process.cwd(), 'src'),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
console.error(`\nError: ${err.message}`);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
75
114
|
program
|
|
76
115
|
.command('demo')
|
|
77
116
|
.description('Scan a built-in demo page with intentional violations — no dev server needed')
|
|
78
|
-
.option('-
|
|
117
|
+
.option('--no-report', 'Skip saving markdown report to a11y-report.md')
|
|
79
118
|
.option('--no-ai', 'Skip AI fix generation (faster, violations only)')
|
|
80
119
|
.action(async (opts) => {
|
|
81
120
|
try {
|
package/dist/crawler.js
CHANGED
|
@@ -18,9 +18,16 @@ async function detectFramework(page) {
|
|
|
18
18
|
return 'Vue 2';
|
|
19
19
|
if (document.querySelector('[ng-version]') !== null)
|
|
20
20
|
return 'Angular';
|
|
21
|
-
|
|
22
|
-
if (
|
|
21
|
+
// React: hook is registered by react-dom on load (most reliable, works with Vite HMR)
|
|
22
|
+
if (w['__REACT_DEVTOOLS_GLOBAL_HOOK__'])
|
|
23
23
|
return 'React';
|
|
24
|
+
// React fallback: fiber properties on root element
|
|
25
|
+
const root = document.getElementById('root') ?? document.getElementById('app') ?? document.body;
|
|
26
|
+
if (root) {
|
|
27
|
+
const allKeys = Object.getOwnPropertyNames(root);
|
|
28
|
+
if (allKeys.some((k) => k.startsWith('__reactFiber') || k.startsWith('__reactContainer'))) {
|
|
29
|
+
return 'React';
|
|
30
|
+
}
|
|
24
31
|
}
|
|
25
32
|
if (document.querySelector('[data-svelte-h]') !== null)
|
|
26
33
|
return 'Svelte';
|
package/dist/engine/index.js
CHANGED
|
@@ -1,3 +1,42 @@
|
|
|
1
|
+
async function resolveSourceLocations(page, violations) {
|
|
2
|
+
const selectors = [...new Set(violations.map((v) => v.selector))];
|
|
3
|
+
if (selectors.length === 0)
|
|
4
|
+
return;
|
|
5
|
+
try {
|
|
6
|
+
const sourceMap = await page.evaluate((sels) => {
|
|
7
|
+
const result = {};
|
|
8
|
+
for (const sel of sels) {
|
|
9
|
+
try {
|
|
10
|
+
const el = document.querySelector(sel);
|
|
11
|
+
if (!el)
|
|
12
|
+
continue;
|
|
13
|
+
const fiberKey = Object.getOwnPropertyNames(el).find((k) => k.startsWith('__reactFiber'));
|
|
14
|
+
if (!fiberKey)
|
|
15
|
+
continue;
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
17
|
+
let fiber = el[fiberKey];
|
|
18
|
+
while (fiber) {
|
|
19
|
+
if (fiber._debugSource) {
|
|
20
|
+
const { fileName, lineNumber } = fiber._debugSource;
|
|
21
|
+
const match = fileName.match(/[/\\]src[/\\].+/);
|
|
22
|
+
const display = match ? match[0].replace(/\\/g, '/').replace(/^\//, '') : fileName;
|
|
23
|
+
result[sel] = `${display}:${lineNumber}`;
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
fiber = fiber.return;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch { /* ignore */ }
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}, selectors);
|
|
33
|
+
for (const v of violations) {
|
|
34
|
+
if (sourceMap[v.selector])
|
|
35
|
+
v.source = sourceMap[v.selector];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch { /* non-React or production build — silently skip */ }
|
|
39
|
+
}
|
|
1
40
|
import { textAlternativeRules } from './rules/text-alternatives.js';
|
|
2
41
|
import { colorContrastRules } from './rules/color-contrast.js';
|
|
3
42
|
import { keyboardRules } from './rules/keyboard.js';
|
|
@@ -52,5 +91,6 @@ export async function scanPage(page, url) {
|
|
|
52
91
|
});
|
|
53
92
|
violations.push(...ruleViolations);
|
|
54
93
|
}
|
|
94
|
+
await resolveSourceLocations(page, violations);
|
|
55
95
|
return { url, violations };
|
|
56
96
|
}
|
package/dist/fixer.js
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, readdirSync, existsSync } from 'fs';
|
|
2
|
+
import { join, resolve } from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { crawl } from './crawler.js';
|
|
5
|
+
const SOURCE_EXTS = new Set(['.jsx', '.tsx', '.js', '.ts', '.vue', '.svelte', '.html']);
|
|
6
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.git', '.next', '.nuxt', 'coverage', 'public']);
|
|
7
|
+
export async function runFix(opts) {
|
|
8
|
+
let allViolations;
|
|
9
|
+
let framework;
|
|
10
|
+
if (opts.reportPath) {
|
|
11
|
+
const absReport = resolve(process.cwd(), opts.reportPath);
|
|
12
|
+
if (!existsSync(absReport)) {
|
|
13
|
+
throw new Error(`Report file not found: ${opts.reportPath}`);
|
|
14
|
+
}
|
|
15
|
+
console.log(`\nLoading violations from ${opts.reportPath}...`);
|
|
16
|
+
allViolations = parseReportViolations(absReport);
|
|
17
|
+
if (allViolations.length === 0) {
|
|
18
|
+
console.log(chalk.green('\nNo violations found in report.'));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
console.log(`Found ${allViolations.length} violation(s) in report. Locating source files...\n`);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
console.log(`\nScanning ${opts.url}...`);
|
|
25
|
+
const result = await crawl({ url: opts.url, pages: opts.pages, crawl: opts.crawl });
|
|
26
|
+
framework = result.framework;
|
|
27
|
+
if (result.totalViolations === 0) {
|
|
28
|
+
console.log(chalk.green('\nNo violations found.'));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
console.log(`Found ${result.totalViolations} violation(s). Locating source files...\n`);
|
|
32
|
+
allViolations = result.pages.flatMap((p) => p.violations);
|
|
33
|
+
}
|
|
34
|
+
// Group violations by resolved source file path
|
|
35
|
+
const fileGroups = new Map();
|
|
36
|
+
let unlocated = 0;
|
|
37
|
+
for (const violation of allViolations) {
|
|
38
|
+
const filePath = findSourceFile(violation, opts.srcDir);
|
|
39
|
+
if (!filePath) {
|
|
40
|
+
console.warn(chalk.yellow(` warn: no source file for [${violation.ruleId}] ${violation.selector.slice(0, 60)}`));
|
|
41
|
+
unlocated++;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (!fileGroups.has(filePath))
|
|
45
|
+
fileGroups.set(filePath, []);
|
|
46
|
+
fileGroups.get(filePath).push(violation);
|
|
47
|
+
}
|
|
48
|
+
if (fileGroups.size === 0) {
|
|
49
|
+
console.log(chalk.yellow('No source files located. Run from your project root and ensure sources are in ./src'));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const locatedCount = allViolations.length - unlocated;
|
|
53
|
+
console.log(`Grouped ${locatedCount} violation(s) across ${fileGroups.size} file(s).\n`);
|
|
54
|
+
if (!opts.apply)
|
|
55
|
+
console.log(chalk.gray('Dry-run mode — use --apply to write changes.\n'));
|
|
56
|
+
let modifiedFiles = 0;
|
|
57
|
+
let modifiedViolations = 0;
|
|
58
|
+
for (const [filePath, violations] of fileGroups) {
|
|
59
|
+
const rel = relativize(filePath);
|
|
60
|
+
console.log(chalk.bold(`\n${rel}`) + chalk.gray(` — ${violations.length} violation(s)`));
|
|
61
|
+
for (const v of violations) {
|
|
62
|
+
console.log(chalk.gray(` · [${v.ruleId}] ${v.description.slice(0, 80)}`));
|
|
63
|
+
}
|
|
64
|
+
let oldContent;
|
|
65
|
+
try {
|
|
66
|
+
oldContent = readFileSync(filePath, 'utf8');
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
console.warn(chalk.yellow(` Cannot read file, skipping.`));
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
process.stdout.write(' Requesting AI patch...');
|
|
73
|
+
let rawResponse;
|
|
74
|
+
try {
|
|
75
|
+
rawResponse = await opts.provider.generateFilePatch(oldContent, violations, rel, framework);
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
console.log('');
|
|
79
|
+
console.warn(chalk.yellow(` AI error: ${err.message}`));
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
console.log(' done');
|
|
83
|
+
const newContent = stripCodeFences(rawResponse).trim();
|
|
84
|
+
if (!newContent || newContent.length < oldContent.length * 0.3) {
|
|
85
|
+
console.warn(chalk.yellow(' AI returned unexpected output, skipping.'));
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const normalizedOld = oldContent.trim();
|
|
89
|
+
if (newContent === normalizedOld) {
|
|
90
|
+
console.log(chalk.gray(' No changes.'));
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
console.log(renderDiff(oldContent, newContent, rel));
|
|
94
|
+
if (opts.apply) {
|
|
95
|
+
writeFileSync(filePath, newContent + '\n', 'utf8');
|
|
96
|
+
console.log(chalk.green(` Written.`));
|
|
97
|
+
}
|
|
98
|
+
modifiedFiles++;
|
|
99
|
+
modifiedViolations += violations.length;
|
|
100
|
+
}
|
|
101
|
+
// Summary
|
|
102
|
+
console.log('\n' + '─'.repeat(60));
|
|
103
|
+
if (modifiedFiles === 0) {
|
|
104
|
+
console.log(chalk.gray('No files changed.'));
|
|
105
|
+
}
|
|
106
|
+
else if (opts.apply) {
|
|
107
|
+
console.log(chalk.green.bold(`Fixed ${modifiedViolations} violation(s) across ${modifiedFiles} file(s).`));
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
console.log(chalk.blue.bold(`Would fix ${modifiedViolations} violation(s) across ${modifiedFiles} file(s).`));
|
|
111
|
+
console.log(chalk.gray('Run with --apply to write changes to disk.'));
|
|
112
|
+
}
|
|
113
|
+
if (unlocated > 0) {
|
|
114
|
+
console.log(chalk.yellow(`${unlocated} violation(s) could not be located in source files.`));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function parseReportViolations(reportPath) {
|
|
118
|
+
const content = readFileSync(reportPath, 'utf8');
|
|
119
|
+
const violations = [];
|
|
120
|
+
const pageParts = content.split(/^## Page:/m).slice(1);
|
|
121
|
+
for (const part of pageParts) {
|
|
122
|
+
const pageUrl = part.split('\n')[0].trim();
|
|
123
|
+
if (part.includes('✅ No violations found'))
|
|
124
|
+
continue;
|
|
125
|
+
for (const group of part.split(/^---$/m)) {
|
|
126
|
+
const headingMatch = group.match(/^###\s+\S+\s+\[(\w+)\]\s+(.+)$/m);
|
|
127
|
+
if (!headingMatch)
|
|
128
|
+
continue;
|
|
129
|
+
const impact = headingMatch[1].toLowerCase();
|
|
130
|
+
const description = headingMatch[2].trim();
|
|
131
|
+
const ruleMatch = group.match(/\*\*Rule:\*\*\s+`([^`]+)`/);
|
|
132
|
+
if (!ruleMatch)
|
|
133
|
+
continue;
|
|
134
|
+
const ruleId = ruleMatch[1];
|
|
135
|
+
let wcag = '';
|
|
136
|
+
let level = 'A';
|
|
137
|
+
const wcagStd = group.match(/\*\*WCAG:\*\*\s+SC\s+([\d.]+)\s+\(Level\s+([A-Z]+)\)/);
|
|
138
|
+
if (wcagStd) {
|
|
139
|
+
wcag = wcagStd[1];
|
|
140
|
+
level = wcagStd[2];
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
const wcagAI = group.match(/\*\*WCAG:\*\*\s+WCAG\s+[\d.]+\s+SC\s+([\d.]+)/);
|
|
144
|
+
if (wcagAI)
|
|
145
|
+
wcag = wcagAI[1];
|
|
146
|
+
}
|
|
147
|
+
const repMatch = group.match(/\*\*Representative element:\*\*\s*\n`([^`]+)`(?:\s+—\s+`([^`]+)`)?/);
|
|
148
|
+
if (!repMatch)
|
|
149
|
+
continue;
|
|
150
|
+
const selector = repMatch[1];
|
|
151
|
+
const source = repMatch[2];
|
|
152
|
+
const htmlMatch = group.match(/\*\*Representative element:\*\*[\s\S]*?```html\n([\s\S]*?)\n```/);
|
|
153
|
+
const html = htmlMatch?.[1] ?? '';
|
|
154
|
+
violations.push({ ruleId, wcag, level, impact, description, selector, html, page: pageUrl, ...(source ? { source } : {}) });
|
|
155
|
+
const alsoSection = group.match(/\*\*Also affects[^*]*\*\*([\s\S]*?)(?:\n\n\*\*|\n---|\n```|$)/);
|
|
156
|
+
if (alsoSection) {
|
|
157
|
+
for (const line of alsoSection[1].split('\n')) {
|
|
158
|
+
const m = line.match(/^-\s+`([^`]+)`(?:\s+—\s+`([^`]+)`)?/);
|
|
159
|
+
if (!m)
|
|
160
|
+
continue;
|
|
161
|
+
violations.push({ ruleId, wcag, level, impact, description, selector: m[1], html: '', page: pageUrl, ...(m[2] ? { source: m[2] } : {}) });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return violations;
|
|
167
|
+
}
|
|
168
|
+
export function findSourceFile(violation, srcDir) {
|
|
169
|
+
// Priority 1: React dev-mode source annotation
|
|
170
|
+
if (violation.source) {
|
|
171
|
+
const filePart = violation.source.split(':')[0];
|
|
172
|
+
for (const candidate of [resolve(process.cwd(), filePart), resolve(filePart)]) {
|
|
173
|
+
if (existsSync(candidate))
|
|
174
|
+
return candidate;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// Priority 2: grep src/ for unique attributes/text from the HTML snippet
|
|
178
|
+
for (const needle of extractNeedles(violation.html)) {
|
|
179
|
+
const hits = grepDir(srcDir, needle);
|
|
180
|
+
if (hits.length > 0)
|
|
181
|
+
return hits[0];
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
function extractNeedles(html) {
|
|
186
|
+
const results = [];
|
|
187
|
+
const id = html.match(/\bid=["']([^"']{2,})["']/)?.[1];
|
|
188
|
+
if (id)
|
|
189
|
+
results.push(`id="${id}"`);
|
|
190
|
+
const name = html.match(/\bname=["']([^"']{2,})["']/)?.[1];
|
|
191
|
+
if (name)
|
|
192
|
+
results.push(`name="${name}"`);
|
|
193
|
+
const forAttr = html.match(/\bfor=["']([^"']{2,})["']/)?.[1];
|
|
194
|
+
if (forAttr)
|
|
195
|
+
results.push(`for="${forAttr}"`);
|
|
196
|
+
// local src paths only
|
|
197
|
+
const src = html.match(/\bsrc=["'](?!https?:\/\/)([^"']{4,})["']/)?.[1];
|
|
198
|
+
if (src)
|
|
199
|
+
results.push(src);
|
|
200
|
+
const text = html.match(/>([^<\s][^<]{4,60})</)?.[1]?.trim();
|
|
201
|
+
if (text)
|
|
202
|
+
results.push(text);
|
|
203
|
+
// fallback: first meaningful class name
|
|
204
|
+
if (results.length === 0) {
|
|
205
|
+
const cls = html.match(/\bclass=["']([^"']+)["']/)?.[1]?.split(/\s+/)[0];
|
|
206
|
+
if (cls && cls.length > 3)
|
|
207
|
+
results.push(cls);
|
|
208
|
+
}
|
|
209
|
+
return results.filter((s) => s.length >= 3);
|
|
210
|
+
}
|
|
211
|
+
function grepDir(dir, needle) {
|
|
212
|
+
if (!existsSync(dir))
|
|
213
|
+
return [];
|
|
214
|
+
const hits = [];
|
|
215
|
+
try {
|
|
216
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
217
|
+
if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name))
|
|
218
|
+
continue;
|
|
219
|
+
const full = join(dir, entry.name);
|
|
220
|
+
if (entry.isDirectory()) {
|
|
221
|
+
hits.push(...grepDir(full, needle));
|
|
222
|
+
}
|
|
223
|
+
else if (entry.isFile() && SOURCE_EXTS.has(extOf(entry.name))) {
|
|
224
|
+
try {
|
|
225
|
+
if (readFileSync(full, 'utf8').includes(needle))
|
|
226
|
+
hits.push(full);
|
|
227
|
+
}
|
|
228
|
+
catch { /* skip */ }
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
catch { /* skip unreadable dirs */ }
|
|
233
|
+
return hits;
|
|
234
|
+
}
|
|
235
|
+
function extOf(name) {
|
|
236
|
+
const i = name.lastIndexOf('.');
|
|
237
|
+
return i >= 0 ? name.slice(i) : '';
|
|
238
|
+
}
|
|
239
|
+
function stripCodeFences(text) {
|
|
240
|
+
const m = text.match(/^```[\w]*\n([\s\S]*?)\n?```\s*$/);
|
|
241
|
+
if (m)
|
|
242
|
+
return m[1];
|
|
243
|
+
return text.replace(/^```[\w]*\n?/, '').replace(/\n?```\s*$/, '');
|
|
244
|
+
}
|
|
245
|
+
function relativize(absPath) {
|
|
246
|
+
const cwd = process.cwd();
|
|
247
|
+
return absPath.startsWith(cwd)
|
|
248
|
+
? absPath.slice(cwd.length + 1).replace(/\\/g, '/')
|
|
249
|
+
: absPath.replace(/\\/g, '/');
|
|
250
|
+
}
|
|
251
|
+
function computeDiff(a, b) {
|
|
252
|
+
// Cap to avoid O(n²) freeze on huge files
|
|
253
|
+
if (a.length * b.length > 800_000) {
|
|
254
|
+
return [
|
|
255
|
+
...a.map((line) => ({ type: 'del', line })),
|
|
256
|
+
...b.map((line) => ({ type: 'add', line })),
|
|
257
|
+
];
|
|
258
|
+
}
|
|
259
|
+
const m = a.length, n = b.length;
|
|
260
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
261
|
+
for (let i = 1; i <= m; i++)
|
|
262
|
+
for (let j = 1; j <= n; j++)
|
|
263
|
+
dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]);
|
|
264
|
+
const result = [];
|
|
265
|
+
let i = m, j = n;
|
|
266
|
+
while (i > 0 || j > 0) {
|
|
267
|
+
if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
|
|
268
|
+
result.unshift({ type: 'eq', line: a[i - 1] });
|
|
269
|
+
i--;
|
|
270
|
+
j--;
|
|
271
|
+
}
|
|
272
|
+
else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
|
273
|
+
result.unshift({ type: 'add', line: b[j - 1] });
|
|
274
|
+
j--;
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
result.unshift({ type: 'del', line: a[i - 1] });
|
|
278
|
+
i--;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return result;
|
|
282
|
+
}
|
|
283
|
+
function renderDiff(oldContent, newContent, _filePath) {
|
|
284
|
+
const a = oldContent.split('\n');
|
|
285
|
+
const b = newContent.split('\n');
|
|
286
|
+
const diff = computeDiff(a, b);
|
|
287
|
+
const changeIdxs = [];
|
|
288
|
+
for (let k = 0; k < diff.length; k++)
|
|
289
|
+
if (diff[k].type !== 'eq')
|
|
290
|
+
changeIdxs.push(k);
|
|
291
|
+
if (changeIdxs.length === 0)
|
|
292
|
+
return chalk.gray(' (no changes)');
|
|
293
|
+
const CONTEXT = 3;
|
|
294
|
+
const hunks = [];
|
|
295
|
+
for (const idx of changeIdxs) {
|
|
296
|
+
const start = Math.max(0, idx - CONTEXT);
|
|
297
|
+
const end = Math.min(diff.length - 1, idx + CONTEXT);
|
|
298
|
+
const last = hunks[hunks.length - 1];
|
|
299
|
+
if (!last || start > last.end + 1)
|
|
300
|
+
hunks.push({ start, end });
|
|
301
|
+
else
|
|
302
|
+
last.end = Math.max(last.end, end);
|
|
303
|
+
}
|
|
304
|
+
const addCount = changeIdxs.filter((i) => diff[i].type === 'add').length;
|
|
305
|
+
const delCount = changeIdxs.filter((i) => diff[i].type === 'del').length;
|
|
306
|
+
const lines = [];
|
|
307
|
+
lines.push(` ${chalk.green(`+${addCount}`)} ${chalk.red(`-${delCount}`)}`);
|
|
308
|
+
for (let h = 0; h < hunks.length; h++) {
|
|
309
|
+
if (h > 0)
|
|
310
|
+
lines.push(chalk.gray(' ...'));
|
|
311
|
+
for (let k = hunks[h].start; k <= hunks[h].end; k++) {
|
|
312
|
+
const { type, line } = diff[k];
|
|
313
|
+
if (type === 'add')
|
|
314
|
+
lines.push(chalk.green(` + ${line}`));
|
|
315
|
+
else if (type === 'del')
|
|
316
|
+
lines.push(chalk.red(` - ${line}`));
|
|
317
|
+
else
|
|
318
|
+
lines.push(chalk.gray(` ${line}`));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return lines.join('\n');
|
|
322
|
+
}
|
|
@@ -63,11 +63,13 @@ function buildFullReport(result, fixes) {
|
|
|
63
63
|
const v = group[0];
|
|
64
64
|
const fix = fixes.find((f) => f.ruleId === v.ruleId);
|
|
65
65
|
const others = group.slice(1);
|
|
66
|
-
|
|
66
|
+
const sourceRef = v.source ? ` — \`${v.source}\`` : '';
|
|
67
|
+
lines.push(`### ${IMPACT_EMOJI[v.impact] ?? '⚪'} [${v.impact.toUpperCase()}] ${v.description}`, '', `**Rule:** \`${v.ruleId}\` `, `**WCAG:** ${fix?.wcagReference ?? `SC ${v.wcag} (Level ${v.level})`} `, `**Instances:** ${group.length}`, '', '**Representative element:**', `\`${v.selector}\`${sourceRef}`, '```html', v.html, '```', '');
|
|
67
68
|
if (others.length > 0) {
|
|
68
69
|
lines.push(`**Also affects ${others.length} more element${others.length > 1 ? 's' : ''} on this page:**`);
|
|
69
70
|
for (const o of others) {
|
|
70
|
-
|
|
71
|
+
const otherSource = o.source ? ` — \`${o.source}\`` : '';
|
|
72
|
+
lines.push(`- \`${o.selector}\`${otherSource}`);
|
|
71
73
|
}
|
|
72
74
|
lines.push('');
|
|
73
75
|
}
|
|
@@ -30,7 +30,8 @@ export function printTerminalReport(result) {
|
|
|
30
30
|
const tag = color(`[${g.impact.toUpperCase()}]`) + countSuffix;
|
|
31
31
|
const wcag = chalk.gray(`WCAG ${g.wcag}`);
|
|
32
32
|
console.log(` ${tag} ${g.description} ${wcag}`);
|
|
33
|
-
|
|
33
|
+
const sourceHint = g.representative.source ? chalk.dim(` ${g.representative.source}`) : '';
|
|
34
|
+
console.log(` ${chalk.gray('→')} ${chalk.dim(g.selectors[0])}${sourceHint}`);
|
|
34
35
|
if (g.count > 1) {
|
|
35
36
|
console.log(` ${chalk.gray(` …and ${g.count - 1} more`)}`);
|
|
36
37
|
}
|