ally-a11y 1.0.0
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/ACCESSIBILITY.md +205 -0
- package/LICENSE +21 -0
- package/README.md +940 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +528 -0
- package/dist/commands/audit-palette.d.ts +18 -0
- package/dist/commands/audit-palette.js +613 -0
- package/dist/commands/auto-pr.d.ts +19 -0
- package/dist/commands/auto-pr.js +434 -0
- package/dist/commands/badge.d.ts +11 -0
- package/dist/commands/badge.js +143 -0
- package/dist/commands/completion.d.ts +4 -0
- package/dist/commands/completion.js +185 -0
- package/dist/commands/crawl.d.ts +12 -0
- package/dist/commands/crawl.js +249 -0
- package/dist/commands/doctor.d.ts +5 -0
- package/dist/commands/doctor.js +233 -0
- package/dist/commands/explain.d.ts +12 -0
- package/dist/commands/explain.js +233 -0
- package/dist/commands/fix.d.ts +13 -0
- package/dist/commands/fix.js +668 -0
- package/dist/commands/health.d.ts +11 -0
- package/dist/commands/health.js +367 -0
- package/dist/commands/history.d.ts +10 -0
- package/dist/commands/history.js +191 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.js +164 -0
- package/dist/commands/learn.d.ts +8 -0
- package/dist/commands/learn.js +592 -0
- package/dist/commands/pr-check.d.ts +12 -0
- package/dist/commands/pr-check.js +270 -0
- package/dist/commands/report.d.ts +11 -0
- package/dist/commands/report.js +375 -0
- package/dist/commands/scan-storybook.d.ts +18 -0
- package/dist/commands/scan-storybook.js +402 -0
- package/dist/commands/scan.d.ts +25 -0
- package/dist/commands/scan.js +673 -0
- package/dist/commands/stats.d.ts +5 -0
- package/dist/commands/stats.js +137 -0
- package/dist/commands/tree.d.ts +12 -0
- package/dist/commands/tree.js +635 -0
- package/dist/commands/triage.d.ts +13 -0
- package/dist/commands/triage.js +327 -0
- package/dist/commands/watch.d.ts +17 -0
- package/dist/commands/watch.js +302 -0
- package/dist/types/index.d.ts +60 -0
- package/dist/types/index.js +4 -0
- package/dist/utils/baseline.d.ts +62 -0
- package/dist/utils/baseline.js +169 -0
- package/dist/utils/browser.d.ts +78 -0
- package/dist/utils/browser.js +239 -0
- package/dist/utils/cache.d.ts +76 -0
- package/dist/utils/cache.js +178 -0
- package/dist/utils/config.d.ts +102 -0
- package/dist/utils/config.js +237 -0
- package/dist/utils/converters.d.ts +77 -0
- package/dist/utils/converters.js +200 -0
- package/dist/utils/copilot.d.ts +36 -0
- package/dist/utils/copilot.js +139 -0
- package/dist/utils/detect.d.ts +22 -0
- package/dist/utils/detect.js +197 -0
- package/dist/utils/enhanced-errors.d.ts +46 -0
- package/dist/utils/enhanced-errors.js +295 -0
- package/dist/utils/errors.d.ts +31 -0
- package/dist/utils/errors.js +149 -0
- package/dist/utils/fix-patterns.d.ts +56 -0
- package/dist/utils/fix-patterns.js +529 -0
- package/dist/utils/history-tracking.d.ts +94 -0
- package/dist/utils/history-tracking.js +230 -0
- package/dist/utils/history.d.ts +42 -0
- package/dist/utils/history.js +255 -0
- package/dist/utils/impact-scores.d.ts +44 -0
- package/dist/utils/impact-scores.js +257 -0
- package/dist/utils/retry.d.ts +24 -0
- package/dist/utils/retry.js +76 -0
- package/dist/utils/scanner.d.ts +74 -0
- package/dist/utils/scanner.js +606 -0
- package/dist/utils/scanner.test.d.ts +4 -0
- package/dist/utils/scanner.test.js +162 -0
- package/dist/utils/ui.d.ts +44 -0
- package/dist/utils/ui.js +276 -0
- package/mcp-server/dist/index.d.ts +8 -0
- package/mcp-server/dist/index.js +1923 -0
- package/package.json +88 -0
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ally fix command - Uses Copilot CLI agentic mode to fix violations
|
|
3
|
+
*/
|
|
4
|
+
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
5
|
+
import { existsSync } from 'fs';
|
|
6
|
+
import { resolve, dirname } from 'path';
|
|
7
|
+
import * as readline from 'readline';
|
|
8
|
+
import chalk from 'chalk';
|
|
9
|
+
import boxen from 'boxen';
|
|
10
|
+
import { printBanner, createSpinner, printError, printInfo, printWarning, printScoreChange, fileLink, } from '../utils/ui.js';
|
|
11
|
+
import { checkCopilotCli, generateFixPrompt, invokeCopilotFix, printCopilotInstructions, } from '../utils/copilot.js';
|
|
12
|
+
import { suggestInit, suggestRescan } from '../utils/errors.js';
|
|
13
|
+
import { generateSuggestedFix, getFixConfidence, getConfidenceLevel } from '../utils/fix-patterns.js';
|
|
14
|
+
// Track if we've shown Copilot instructions
|
|
15
|
+
let copilotInstructionsShown = false;
|
|
16
|
+
// WCAG criterion explanations for common violation IDs
|
|
17
|
+
const WCAG_EXPLANATIONS = {
|
|
18
|
+
'button-name': {
|
|
19
|
+
criterion: '4.1.2 Name, Role, Value (Level A)',
|
|
20
|
+
why: 'Buttons must have discernible text for screen readers.',
|
|
21
|
+
},
|
|
22
|
+
'image-alt': {
|
|
23
|
+
criterion: '1.1.1 Non-text Content (Level A)',
|
|
24
|
+
why: 'Images must have alternative text for users who cannot see them.',
|
|
25
|
+
},
|
|
26
|
+
'link-name': {
|
|
27
|
+
criterion: '2.4.4 Link Purpose (In Context) (Level A)',
|
|
28
|
+
why: 'Links must have discernible text to indicate their destination.',
|
|
29
|
+
},
|
|
30
|
+
'label': {
|
|
31
|
+
criterion: '1.3.1 Info and Relationships (Level A)',
|
|
32
|
+
why: 'Form inputs must have labels so users know what to enter.',
|
|
33
|
+
},
|
|
34
|
+
'html-has-lang': {
|
|
35
|
+
criterion: '3.1.1 Language of Page (Level A)',
|
|
36
|
+
why: 'Screen readers need to know the page language to pronounce text correctly.',
|
|
37
|
+
},
|
|
38
|
+
'color-contrast': {
|
|
39
|
+
criterion: '1.4.3 Contrast (Minimum) (Level AA)',
|
|
40
|
+
why: 'Text must have sufficient contrast with its background to be readable.',
|
|
41
|
+
},
|
|
42
|
+
'heading-order': {
|
|
43
|
+
criterion: '1.3.1 Info and Relationships (Level A)',
|
|
44
|
+
why: 'Headings must be in logical order for document structure navigation.',
|
|
45
|
+
},
|
|
46
|
+
'aria-hidden-focus': {
|
|
47
|
+
criterion: '4.1.2 Name, Role, Value (Level A)',
|
|
48
|
+
why: 'Hidden elements should not be focusable by keyboard users.',
|
|
49
|
+
},
|
|
50
|
+
'frame-title': {
|
|
51
|
+
criterion: '2.4.1 Bypass Blocks (Level A)',
|
|
52
|
+
why: 'iframes need titles so users understand their content without loading them.',
|
|
53
|
+
},
|
|
54
|
+
'select-name': {
|
|
55
|
+
criterion: '1.3.1 Info and Relationships (Level A)',
|
|
56
|
+
why: 'Select elements must have accessible names for form navigation.',
|
|
57
|
+
},
|
|
58
|
+
'bypass': {
|
|
59
|
+
criterion: '2.4.1 Bypass Blocks (Level A)',
|
|
60
|
+
why: 'Users need a way to skip repetitive content like navigation.',
|
|
61
|
+
},
|
|
62
|
+
'landmark-one-main': {
|
|
63
|
+
criterion: '1.3.1 Info and Relationships (Level A)',
|
|
64
|
+
why: 'Pages need a main landmark so users can quickly find primary content.',
|
|
65
|
+
},
|
|
66
|
+
'meta-viewport': {
|
|
67
|
+
criterion: '1.4.4 Resize Text (Level AA)',
|
|
68
|
+
why: 'Users must be able to zoom and resize text for readability.',
|
|
69
|
+
},
|
|
70
|
+
'document-title': {
|
|
71
|
+
criterion: '2.4.2 Page Titled (Level A)',
|
|
72
|
+
why: 'Pages need titles so users can identify them in tabs and history.',
|
|
73
|
+
},
|
|
74
|
+
'duplicate-id': {
|
|
75
|
+
criterion: '4.1.1 Parsing (Level A)',
|
|
76
|
+
why: 'Duplicate IDs cause assistive technologies to behave unpredictably.',
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
const MAX_FIX_HISTORY_ENTRIES = 100;
|
|
80
|
+
/**
|
|
81
|
+
* Format confidence score for display with color coding
|
|
82
|
+
* @param confidence - Confidence score between 0 and 1
|
|
83
|
+
* @returns Formatted string with color (green=high, yellow=medium, red=low)
|
|
84
|
+
*/
|
|
85
|
+
function formatConfidence(confidence) {
|
|
86
|
+
const percentage = Math.round(confidence * 100);
|
|
87
|
+
const level = getConfidenceLevel(confidence);
|
|
88
|
+
switch (level) {
|
|
89
|
+
case 'high':
|
|
90
|
+
return chalk.green(`${percentage}% confidence`);
|
|
91
|
+
case 'medium':
|
|
92
|
+
return chalk.yellow(`${percentage}% confidence`);
|
|
93
|
+
case 'low':
|
|
94
|
+
return chalk.red(`${percentage}% confidence`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Save a fix to the history file
|
|
99
|
+
*/
|
|
100
|
+
async function saveFixHistory(entry) {
|
|
101
|
+
const historyPath = resolve('.ally', 'fix-history.json');
|
|
102
|
+
// Ensure .ally directory exists
|
|
103
|
+
const dirPath = dirname(historyPath);
|
|
104
|
+
if (!existsSync(dirPath)) {
|
|
105
|
+
await mkdir(dirPath, { recursive: true });
|
|
106
|
+
}
|
|
107
|
+
// Load existing history
|
|
108
|
+
let history = [];
|
|
109
|
+
if (existsSync(historyPath)) {
|
|
110
|
+
try {
|
|
111
|
+
const content = await readFile(historyPath, 'utf-8');
|
|
112
|
+
history = JSON.parse(content);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// Start fresh if file is corrupted
|
|
116
|
+
history = [];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Append new entry
|
|
120
|
+
history.push(entry);
|
|
121
|
+
// Keep only the last MAX_FIX_HISTORY_ENTRIES entries
|
|
122
|
+
if (history.length > MAX_FIX_HISTORY_ENTRIES) {
|
|
123
|
+
history = history.slice(-MAX_FIX_HISTORY_ENTRIES);
|
|
124
|
+
}
|
|
125
|
+
// Write back
|
|
126
|
+
await writeFile(historyPath, JSON.stringify(history, null, 2), 'utf-8');
|
|
127
|
+
}
|
|
128
|
+
export async function fixCommand(options = {}) {
|
|
129
|
+
printBanner();
|
|
130
|
+
const { input = '.ally/scan.json', severity, auto = false, dryRun = false, yes = false } = options;
|
|
131
|
+
// Load scan results
|
|
132
|
+
const spinner = createSpinner('Loading scan results...');
|
|
133
|
+
spinner.start();
|
|
134
|
+
const reportPath = resolve(input);
|
|
135
|
+
if (!existsSync(reportPath)) {
|
|
136
|
+
spinner.stop();
|
|
137
|
+
suggestInit(reportPath);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
let report;
|
|
141
|
+
try {
|
|
142
|
+
const content = await readFile(reportPath, 'utf-8');
|
|
143
|
+
report = JSON.parse(content);
|
|
144
|
+
spinner.succeed(`Loaded ${report.summary.totalViolations} violations`);
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
spinner.fail('Failed to load scan results');
|
|
148
|
+
if (error instanceof SyntaxError) {
|
|
149
|
+
suggestRescan(reportPath);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
printError(error instanceof Error ? error.message : String(error));
|
|
153
|
+
}
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
// Group violations by file
|
|
157
|
+
const fileViolations = groupViolationsByFile(report, severity);
|
|
158
|
+
if (fileViolations.length === 0) {
|
|
159
|
+
printInfo('No violations to fix!');
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const initialScore = report.summary.score;
|
|
163
|
+
const fixResults = [];
|
|
164
|
+
let fixedCount = 0;
|
|
165
|
+
let skippedCount = 0;
|
|
166
|
+
// Interactive mode: --yes flag disables it
|
|
167
|
+
const interactive = !yes && !auto && !dryRun && process.stdout.isTTY;
|
|
168
|
+
if (interactive) {
|
|
169
|
+
// Collect all pending fixes for interactive review
|
|
170
|
+
const pendingFixes = [];
|
|
171
|
+
for (const { file, violations } of fileViolations) {
|
|
172
|
+
for (const violation of violations) {
|
|
173
|
+
const node = violation.nodes[0];
|
|
174
|
+
const beforeSnippet = node?.html || '';
|
|
175
|
+
const afterSnippet = node ? generateSuggestedFix(violation, node.html) : null;
|
|
176
|
+
const confidence = getFixConfidence(violation.id);
|
|
177
|
+
const wcagInfo = WCAG_EXPLANATIONS[violation.id] || null;
|
|
178
|
+
pendingFixes.push({
|
|
179
|
+
file,
|
|
180
|
+
violation,
|
|
181
|
+
beforeSnippet,
|
|
182
|
+
afterSnippet,
|
|
183
|
+
confidence,
|
|
184
|
+
wcagInfo,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// Run interactive review
|
|
189
|
+
const { accepted, skipped } = await runInteractiveReview(pendingFixes);
|
|
190
|
+
// Apply accepted fixes
|
|
191
|
+
for (const fix of accepted) {
|
|
192
|
+
const result = await applyFix(fix.file, fix.violation);
|
|
193
|
+
fixResults.push(result);
|
|
194
|
+
if (result.fixed)
|
|
195
|
+
fixedCount++;
|
|
196
|
+
}
|
|
197
|
+
// Record skipped fixes
|
|
198
|
+
for (const fix of skipped) {
|
|
199
|
+
fixResults.push({
|
|
200
|
+
file: fix.file,
|
|
201
|
+
line: 0,
|
|
202
|
+
violation: fix.violation.id,
|
|
203
|
+
fixed: false,
|
|
204
|
+
skipped: true,
|
|
205
|
+
});
|
|
206
|
+
skippedCount++;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
// Non-interactive mode (original behavior)
|
|
211
|
+
console.log();
|
|
212
|
+
console.log(chalk.bold.cyan('Fixing Accessibility Issues'));
|
|
213
|
+
console.log(chalk.dim('━'.repeat(50)));
|
|
214
|
+
console.log();
|
|
215
|
+
if (dryRun) {
|
|
216
|
+
printWarning('Dry run mode - no changes will be made');
|
|
217
|
+
console.log();
|
|
218
|
+
}
|
|
219
|
+
// Process each file
|
|
220
|
+
for (const { file, violations } of fileViolations) {
|
|
221
|
+
const linkedFile = fileLink(file);
|
|
222
|
+
console.log(chalk.bold(`\n📄 `) + chalk.bold(linkedFile));
|
|
223
|
+
console.log(chalk.dim(` ${violations.length} issue${violations.length === 1 ? '' : 's'} to fix`));
|
|
224
|
+
for (const violation of violations) {
|
|
225
|
+
const result = await processViolation(file, violation, { auto, dryRun });
|
|
226
|
+
fixResults.push(result);
|
|
227
|
+
if (result.fixed) {
|
|
228
|
+
fixedCount++;
|
|
229
|
+
}
|
|
230
|
+
else if (result.skipped) {
|
|
231
|
+
skippedCount++;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// Print summary
|
|
237
|
+
console.log();
|
|
238
|
+
console.log(chalk.dim('━'.repeat(50)));
|
|
239
|
+
console.log();
|
|
240
|
+
const summary = boxen(`
|
|
241
|
+
${chalk.bold('Fix Summary')}
|
|
242
|
+
|
|
243
|
+
${chalk.green('✓ Fixed:')} ${fixedCount}
|
|
244
|
+
${chalk.yellow('→ Skipped:')} ${skippedCount}
|
|
245
|
+
${chalk.dim('Total:')} ${fixResults.length}
|
|
246
|
+
`.trim(), {
|
|
247
|
+
padding: 1,
|
|
248
|
+
borderStyle: 'round',
|
|
249
|
+
borderColor: fixedCount > 0 ? 'green' : 'yellow',
|
|
250
|
+
});
|
|
251
|
+
console.log(summary);
|
|
252
|
+
// Estimate new score
|
|
253
|
+
const estimatedNewScore = Math.min(100, initialScore + (fixedCount * 3));
|
|
254
|
+
printScoreChange(initialScore, estimatedNewScore);
|
|
255
|
+
console.log();
|
|
256
|
+
printInfo('Run `ally scan` again to verify fixes and update your score');
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Clear the terminal screen
|
|
260
|
+
*/
|
|
261
|
+
function clearScreen() {
|
|
262
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Format code snippet with syntax highlighting using chalk
|
|
266
|
+
*/
|
|
267
|
+
function formatCodeSnippet(code, isAddition = false) {
|
|
268
|
+
const lines = code.split('\n');
|
|
269
|
+
const prefix = isAddition ? chalk.green(' + ') : chalk.red(' - ');
|
|
270
|
+
const color = isAddition ? chalk.green : chalk.red;
|
|
271
|
+
return lines.map((line) => prefix + color(line)).join('\n');
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Display a single fix in interactive mode
|
|
275
|
+
*/
|
|
276
|
+
function displayInteractiveFix(fix, index, total) {
|
|
277
|
+
clearScreen();
|
|
278
|
+
// Confidence display
|
|
279
|
+
const confidence = fix.confidence !== null ? Math.round(fix.confidence * 100) : null;
|
|
280
|
+
let confidenceStr = '';
|
|
281
|
+
if (confidence !== null) {
|
|
282
|
+
const level = getConfidenceLevel(fix.confidence);
|
|
283
|
+
const color = level === 'high' ? chalk.green : level === 'medium' ? chalk.yellow : chalk.red;
|
|
284
|
+
confidenceStr = color(`${confidence}% confidence`);
|
|
285
|
+
}
|
|
286
|
+
// Severity color
|
|
287
|
+
const severityColors = {
|
|
288
|
+
critical: chalk.red.bold,
|
|
289
|
+
serious: chalk.red,
|
|
290
|
+
moderate: chalk.yellow,
|
|
291
|
+
minor: chalk.blue,
|
|
292
|
+
};
|
|
293
|
+
const severityColor = severityColors[fix.violation.impact];
|
|
294
|
+
// Header box
|
|
295
|
+
const headerContent = [
|
|
296
|
+
` ${chalk.bold(`Fix ${index + 1} of ${total}:`)} ${chalk.cyan(fix.violation.id)}${confidenceStr ? ` (${confidenceStr})` : ''}`,
|
|
297
|
+
` ${chalk.dim('Severity:')} ${severityColor(fix.violation.impact.toUpperCase())}`,
|
|
298
|
+
` ${chalk.dim('File:')} ${chalk.white(fix.file)}`,
|
|
299
|
+
].join('\n');
|
|
300
|
+
console.log(boxen(headerContent, {
|
|
301
|
+
padding: { top: 0, bottom: 0, left: 0, right: 1 },
|
|
302
|
+
borderStyle: 'round',
|
|
303
|
+
borderColor: 'cyan',
|
|
304
|
+
}));
|
|
305
|
+
console.log();
|
|
306
|
+
// Issue description
|
|
307
|
+
console.log(chalk.bold.white(fix.violation.help));
|
|
308
|
+
console.log();
|
|
309
|
+
// Before/After comparison
|
|
310
|
+
if (fix.beforeSnippet) {
|
|
311
|
+
console.log(chalk.dim.underline('Before:'));
|
|
312
|
+
console.log(formatCodeSnippet(truncateCode(fix.beforeSnippet, 200), false));
|
|
313
|
+
console.log();
|
|
314
|
+
}
|
|
315
|
+
if (fix.afterSnippet) {
|
|
316
|
+
console.log(chalk.dim.underline('After:'));
|
|
317
|
+
console.log(formatCodeSnippet(truncateCode(fix.afterSnippet, 200), true));
|
|
318
|
+
console.log();
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
console.log(chalk.yellow(' (No automatic fix available - requires manual review)'));
|
|
322
|
+
console.log();
|
|
323
|
+
}
|
|
324
|
+
// WCAG explanation
|
|
325
|
+
if (fix.wcagInfo) {
|
|
326
|
+
console.log(chalk.dim.underline('Why:'));
|
|
327
|
+
console.log(chalk.white(` ${fix.wcagInfo.why}`));
|
|
328
|
+
console.log(chalk.dim(` WCAG: ${fix.wcagInfo.criterion}`));
|
|
329
|
+
console.log();
|
|
330
|
+
}
|
|
331
|
+
// Action prompt
|
|
332
|
+
console.log(chalk.cyan('[y] Apply [n] Skip [a] Apply all remaining [q] Quit [?] More info'));
|
|
333
|
+
process.stdout.write(chalk.cyan('> '));
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Truncate code to a maximum length for display
|
|
337
|
+
*/
|
|
338
|
+
function truncateCode(code, maxLength) {
|
|
339
|
+
const singleLine = code.replace(/\s+/g, ' ').trim();
|
|
340
|
+
if (singleLine.length <= maxLength)
|
|
341
|
+
return singleLine;
|
|
342
|
+
return singleLine.slice(0, maxLength - 3) + '...';
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Show detailed information about a fix
|
|
346
|
+
*/
|
|
347
|
+
function showFixDetails(fix) {
|
|
348
|
+
console.log();
|
|
349
|
+
console.log(chalk.bold.cyan('━━━ Additional Information ━━━'));
|
|
350
|
+
console.log();
|
|
351
|
+
// Full violation details
|
|
352
|
+
console.log(chalk.bold('Description:'));
|
|
353
|
+
console.log(` ${fix.violation.description}`);
|
|
354
|
+
console.log();
|
|
355
|
+
// Help URL
|
|
356
|
+
console.log(chalk.bold('Learn more:'));
|
|
357
|
+
console.log(chalk.blue(` ${fix.violation.helpUrl}`));
|
|
358
|
+
console.log();
|
|
359
|
+
// WCAG tags
|
|
360
|
+
const wcagTags = fix.violation.tags.filter((t) => t.startsWith('wcag') || t.startsWith('best-practice'));
|
|
361
|
+
if (wcagTags.length > 0) {
|
|
362
|
+
console.log(chalk.bold('WCAG Criteria:'));
|
|
363
|
+
wcagTags.forEach((tag) => console.log(` - ${tag}`));
|
|
364
|
+
console.log();
|
|
365
|
+
}
|
|
366
|
+
// Target elements
|
|
367
|
+
if (fix.violation.nodes.length > 0) {
|
|
368
|
+
console.log(chalk.bold('Affected elements:'));
|
|
369
|
+
fix.violation.nodes.slice(0, 5).forEach((node) => {
|
|
370
|
+
console.log(chalk.dim(` ${node.target.join(' > ')}`));
|
|
371
|
+
});
|
|
372
|
+
if (fix.violation.nodes.length > 5) {
|
|
373
|
+
console.log(chalk.dim(` ... and ${fix.violation.nodes.length - 5} more`));
|
|
374
|
+
}
|
|
375
|
+
console.log();
|
|
376
|
+
}
|
|
377
|
+
console.log(chalk.dim('Press any key to continue...'));
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Run interactive review mode for fixes
|
|
381
|
+
*/
|
|
382
|
+
async function runInteractiveReview(fixes) {
|
|
383
|
+
const accepted = [];
|
|
384
|
+
const skipped = [];
|
|
385
|
+
if (fixes.length === 0) {
|
|
386
|
+
return { accepted, skipped };
|
|
387
|
+
}
|
|
388
|
+
// Set up raw mode for single keypress input
|
|
389
|
+
const stdin = process.stdin;
|
|
390
|
+
const wasRaw = stdin.isRaw;
|
|
391
|
+
// Enable raw mode if available
|
|
392
|
+
if (stdin.setRawMode) {
|
|
393
|
+
stdin.setRawMode(true);
|
|
394
|
+
}
|
|
395
|
+
stdin.resume();
|
|
396
|
+
stdin.setEncoding('utf8');
|
|
397
|
+
let currentIndex = 0;
|
|
398
|
+
let applyAll = false;
|
|
399
|
+
return new Promise((resolve) => {
|
|
400
|
+
const processCurrentFix = () => {
|
|
401
|
+
if (currentIndex >= fixes.length) {
|
|
402
|
+
// Done - restore terminal
|
|
403
|
+
if (stdin.setRawMode) {
|
|
404
|
+
stdin.setRawMode(wasRaw || false);
|
|
405
|
+
}
|
|
406
|
+
stdin.pause();
|
|
407
|
+
clearScreen();
|
|
408
|
+
resolve({ accepted, skipped });
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const fix = fixes[currentIndex];
|
|
412
|
+
if (applyAll) {
|
|
413
|
+
// Auto-apply remaining
|
|
414
|
+
accepted.push(fix);
|
|
415
|
+
currentIndex++;
|
|
416
|
+
processCurrentFix();
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
displayInteractiveFix(fix, currentIndex, fixes.length);
|
|
420
|
+
};
|
|
421
|
+
const handleKeypress = (key) => {
|
|
422
|
+
const fix = fixes[currentIndex];
|
|
423
|
+
switch (key.toLowerCase()) {
|
|
424
|
+
case 'y':
|
|
425
|
+
accepted.push(fix);
|
|
426
|
+
currentIndex++;
|
|
427
|
+
processCurrentFix();
|
|
428
|
+
break;
|
|
429
|
+
case 'n':
|
|
430
|
+
skipped.push(fix);
|
|
431
|
+
currentIndex++;
|
|
432
|
+
processCurrentFix();
|
|
433
|
+
break;
|
|
434
|
+
case 'a':
|
|
435
|
+
// Apply all remaining
|
|
436
|
+
applyAll = true;
|
|
437
|
+
accepted.push(fix);
|
|
438
|
+
currentIndex++;
|
|
439
|
+
processCurrentFix();
|
|
440
|
+
break;
|
|
441
|
+
case 'q':
|
|
442
|
+
case '\x03': // Ctrl+C
|
|
443
|
+
// Quit - skip remaining
|
|
444
|
+
for (let i = currentIndex; i < fixes.length; i++) {
|
|
445
|
+
skipped.push(fixes[i]);
|
|
446
|
+
}
|
|
447
|
+
if (stdin.setRawMode) {
|
|
448
|
+
stdin.setRawMode(wasRaw || false);
|
|
449
|
+
}
|
|
450
|
+
stdin.pause();
|
|
451
|
+
clearScreen();
|
|
452
|
+
resolve({ accepted, skipped });
|
|
453
|
+
break;
|
|
454
|
+
case '?':
|
|
455
|
+
showFixDetails(fix);
|
|
456
|
+
// Wait for any key to continue
|
|
457
|
+
stdin.once('data', () => {
|
|
458
|
+
processCurrentFix();
|
|
459
|
+
});
|
|
460
|
+
break;
|
|
461
|
+
default:
|
|
462
|
+
// Ignore other keys, re-display prompt
|
|
463
|
+
process.stdout.write(chalk.cyan('> '));
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
stdin.on('data', handleKeypress);
|
|
468
|
+
processCurrentFix();
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
function groupViolationsByFile(report, severity) {
|
|
472
|
+
const fileMap = new Map();
|
|
473
|
+
for (const result of report.results) {
|
|
474
|
+
if (!result.file)
|
|
475
|
+
continue;
|
|
476
|
+
for (const violation of result.violations) {
|
|
477
|
+
if (severity && violation.impact !== severity)
|
|
478
|
+
continue;
|
|
479
|
+
const existing = fileMap.get(result.file) || [];
|
|
480
|
+
// Only add unique violations
|
|
481
|
+
if (!existing.some((v) => v.id === violation.id)) {
|
|
482
|
+
existing.push(violation);
|
|
483
|
+
}
|
|
484
|
+
fileMap.set(result.file, existing);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
// Sort by severity (critical first)
|
|
488
|
+
const severityOrder = {
|
|
489
|
+
critical: 0,
|
|
490
|
+
serious: 1,
|
|
491
|
+
moderate: 2,
|
|
492
|
+
minor: 3,
|
|
493
|
+
};
|
|
494
|
+
return Array.from(fileMap.entries())
|
|
495
|
+
.map(([file, violations]) => ({
|
|
496
|
+
file,
|
|
497
|
+
violations: violations.sort((a, b) => severityOrder[a.impact] - severityOrder[b.impact]),
|
|
498
|
+
}))
|
|
499
|
+
.sort((a, b) => {
|
|
500
|
+
// Sort files by highest severity issue
|
|
501
|
+
const aMin = Math.min(...a.violations.map((v) => severityOrder[v.impact]));
|
|
502
|
+
const bMin = Math.min(...b.violations.map((v) => severityOrder[v.impact]));
|
|
503
|
+
return aMin - bMin;
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
async function processViolation(file, violation, options) {
|
|
507
|
+
const severityColors = {
|
|
508
|
+
critical: chalk.red.bold,
|
|
509
|
+
serious: chalk.red,
|
|
510
|
+
moderate: chalk.yellow,
|
|
511
|
+
minor: chalk.blue,
|
|
512
|
+
};
|
|
513
|
+
const color = severityColors[violation.impact];
|
|
514
|
+
console.log();
|
|
515
|
+
console.log(color(` [${violation.impact.toUpperCase()}] ${violation.help}`));
|
|
516
|
+
// Show affected code
|
|
517
|
+
if (violation.nodes.length > 0) {
|
|
518
|
+
const node = violation.nodes[0];
|
|
519
|
+
console.log(chalk.dim(` Target: ${node.target.join(' > ')}`));
|
|
520
|
+
console.log(chalk.red(` - ${truncate(node.html, 80)}`));
|
|
521
|
+
// Generate suggested fix
|
|
522
|
+
const suggestedFix = generateSuggestedFix(violation, node.html);
|
|
523
|
+
if (suggestedFix) {
|
|
524
|
+
const confidence = getFixConfidence(violation.id);
|
|
525
|
+
const confidenceStr = confidence !== null ? ` (${formatConfidence(confidence)})` : '';
|
|
526
|
+
console.log(chalk.cyan(` Suggested fix${confidenceStr}:`));
|
|
527
|
+
console.log(chalk.green(` + ${truncate(suggestedFix, 80)}`));
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if (options.dryRun) {
|
|
531
|
+
return {
|
|
532
|
+
file,
|
|
533
|
+
line: 0,
|
|
534
|
+
violation: violation.id,
|
|
535
|
+
fixed: false,
|
|
536
|
+
skipped: true,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
if (options.auto) {
|
|
540
|
+
console.log(chalk.green(' ✓ Auto-applying fix'));
|
|
541
|
+
return {
|
|
542
|
+
file,
|
|
543
|
+
line: 0,
|
|
544
|
+
violation: violation.id,
|
|
545
|
+
fixed: true,
|
|
546
|
+
skipped: false,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
// Prompt for action
|
|
550
|
+
const action = await promptForAction();
|
|
551
|
+
switch (action) {
|
|
552
|
+
case 'y':
|
|
553
|
+
return await applyFix(file, violation);
|
|
554
|
+
case 's':
|
|
555
|
+
console.log(chalk.yellow(' → Skipped'));
|
|
556
|
+
return {
|
|
557
|
+
file,
|
|
558
|
+
line: 0,
|
|
559
|
+
violation: violation.id,
|
|
560
|
+
fixed: false,
|
|
561
|
+
skipped: true,
|
|
562
|
+
};
|
|
563
|
+
case 'n':
|
|
564
|
+
default:
|
|
565
|
+
console.log(chalk.dim(' → Declined'));
|
|
566
|
+
return {
|
|
567
|
+
file,
|
|
568
|
+
line: 0,
|
|
569
|
+
violation: violation.id,
|
|
570
|
+
fixed: false,
|
|
571
|
+
skipped: false,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
async function applyFix(file, violation) {
|
|
576
|
+
const copilot = checkCopilotCli();
|
|
577
|
+
const node = violation.nodes[0];
|
|
578
|
+
const suggestedFix = node ? generateSuggestedFix(violation, node.html) : null;
|
|
579
|
+
const beforeSnippet = node?.html || '';
|
|
580
|
+
// Extract WCAG criteria from violation tags
|
|
581
|
+
const wcagCriteria = violation.tags.filter((tag) => tag.startsWith('wcag') || tag.startsWith('best-practice'));
|
|
582
|
+
if (copilot.available) {
|
|
583
|
+
// Use Copilot CLI for AI-powered fix
|
|
584
|
+
printInfo('Invoking GitHub Copilot CLI...');
|
|
585
|
+
const prompt = generateFixPrompt(file, violation.help, node?.html || '', suggestedFix || undefined);
|
|
586
|
+
const result = await invokeCopilotFix(file, prompt, { allowEdits: true });
|
|
587
|
+
if (result.success) {
|
|
588
|
+
console.log(chalk.green(' ✓ Fix applied by Copilot'));
|
|
589
|
+
// Save to fix history
|
|
590
|
+
await saveFixHistory({
|
|
591
|
+
timestamp: new Date().toISOString(),
|
|
592
|
+
issueType: violation.id,
|
|
593
|
+
filePath: file,
|
|
594
|
+
beforeSnippet,
|
|
595
|
+
afterSnippet: result.output || suggestedFix || '[fix applied]',
|
|
596
|
+
wcagCriteria,
|
|
597
|
+
});
|
|
598
|
+
return {
|
|
599
|
+
file,
|
|
600
|
+
line: 0,
|
|
601
|
+
violation: violation.id,
|
|
602
|
+
fixed: true,
|
|
603
|
+
skipped: false,
|
|
604
|
+
diff: result.output,
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
else {
|
|
608
|
+
printWarning('Copilot could not apply fix automatically');
|
|
609
|
+
console.log(chalk.dim(` ${result.output}`));
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
// Show instructions once
|
|
614
|
+
if (!copilotInstructionsShown) {
|
|
615
|
+
printCopilotInstructions();
|
|
616
|
+
copilotInstructionsShown = true;
|
|
617
|
+
}
|
|
618
|
+
// Show manual fix command
|
|
619
|
+
console.log(chalk.cyan(' Manual fix command:'));
|
|
620
|
+
console.log(chalk.dim(` copilot -p "Fix: ${violation.help} in ${file}"`));
|
|
621
|
+
}
|
|
622
|
+
// Fall back to showing suggested fix
|
|
623
|
+
if (suggestedFix) {
|
|
624
|
+
console.log(chalk.green(' ✓ Suggested fix shown above'));
|
|
625
|
+
// Save suggested fix to history (user accepted the suggestion)
|
|
626
|
+
await saveFixHistory({
|
|
627
|
+
timestamp: new Date().toISOString(),
|
|
628
|
+
issueType: violation.id,
|
|
629
|
+
filePath: file,
|
|
630
|
+
beforeSnippet,
|
|
631
|
+
afterSnippet: suggestedFix,
|
|
632
|
+
wcagCriteria,
|
|
633
|
+
});
|
|
634
|
+
return {
|
|
635
|
+
file,
|
|
636
|
+
line: 0,
|
|
637
|
+
violation: violation.id,
|
|
638
|
+
fixed: true, // Mark as fixed since user accepted suggestion
|
|
639
|
+
skipped: false,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
return {
|
|
643
|
+
file,
|
|
644
|
+
line: 0,
|
|
645
|
+
violation: violation.id,
|
|
646
|
+
fixed: false,
|
|
647
|
+
skipped: false,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
function truncate(str, maxLength) {
|
|
651
|
+
const singleLine = str.replace(/\s+/g, ' ').trim();
|
|
652
|
+
if (singleLine.length <= maxLength)
|
|
653
|
+
return singleLine;
|
|
654
|
+
return singleLine.slice(0, maxLength - 3) + '...';
|
|
655
|
+
}
|
|
656
|
+
async function promptForAction() {
|
|
657
|
+
const rl = readline.createInterface({
|
|
658
|
+
input: process.stdin,
|
|
659
|
+
output: process.stdout,
|
|
660
|
+
});
|
|
661
|
+
return new Promise((resolve) => {
|
|
662
|
+
rl.question(chalk.cyan(' Apply fix? [Y/n/s(skip)] '), (answer) => {
|
|
663
|
+
rl.close();
|
|
664
|
+
resolve(answer.toLowerCase() || 'y');
|
|
665
|
+
});
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
export default fixCommand;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ally health command - Quick accessibility overview like npm audit
|
|
3
|
+
*/
|
|
4
|
+
import { type WcagStandard } from '../utils/scanner.js';
|
|
5
|
+
interface HealthOptions {
|
|
6
|
+
path?: string;
|
|
7
|
+
standard?: WcagStandard;
|
|
8
|
+
input?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function healthCommand(options?: HealthOptions): Promise<void>;
|
|
11
|
+
export default healthCommand;
|