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 CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  [![CI](https://github.com/Dannyplusplus12/WCAG-A11y/actions/workflows/ci.yml/badge.svg)](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 a ready-to-paste fix prompt for each violation. You paste it into Cursor, Copilot, or Claude and the fix writes itself.
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
- The goal is to close the loop between finding an accessibility issue and actually fixing it, without interrupting your existing workflow.
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.4');
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('-r, --report', 'Save a full markdown report to a11y-report.md', false)
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('--no-terminal', 'Suppress terminal output (violations summary)')
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('-r, --report', 'Save a full markdown report to a11y-report.md', false)
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
- const root = document.getElementById('root') ?? document.getElementById('app') ?? document.body;
22
- if (root && Object.keys(root).some((k) => k.startsWith('__reactFiber') || k.startsWith('__reactContainer'))) {
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';
@@ -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
- 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}\``, '```html', v.html, '```', '');
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
- lines.push(`- \`${o.selector}\``);
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
- console.log(` ${chalk.gray('→')} ${chalk.dim(g.selectors[0])}`);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wcag-a11y",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "WCAG 2.1/2.2 accessibility auditor with AI-powered fixes",
5
5
  "type": "module",
6
6
  "bin": {