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.
Files changed (84) hide show
  1. package/ACCESSIBILITY.md +205 -0
  2. package/LICENSE +21 -0
  3. package/README.md +940 -0
  4. package/dist/cli.d.ts +7 -0
  5. package/dist/cli.js +528 -0
  6. package/dist/commands/audit-palette.d.ts +18 -0
  7. package/dist/commands/audit-palette.js +613 -0
  8. package/dist/commands/auto-pr.d.ts +19 -0
  9. package/dist/commands/auto-pr.js +434 -0
  10. package/dist/commands/badge.d.ts +11 -0
  11. package/dist/commands/badge.js +143 -0
  12. package/dist/commands/completion.d.ts +4 -0
  13. package/dist/commands/completion.js +185 -0
  14. package/dist/commands/crawl.d.ts +12 -0
  15. package/dist/commands/crawl.js +249 -0
  16. package/dist/commands/doctor.d.ts +5 -0
  17. package/dist/commands/doctor.js +233 -0
  18. package/dist/commands/explain.d.ts +12 -0
  19. package/dist/commands/explain.js +233 -0
  20. package/dist/commands/fix.d.ts +13 -0
  21. package/dist/commands/fix.js +668 -0
  22. package/dist/commands/health.d.ts +11 -0
  23. package/dist/commands/health.js +367 -0
  24. package/dist/commands/history.d.ts +10 -0
  25. package/dist/commands/history.js +191 -0
  26. package/dist/commands/init.d.ts +9 -0
  27. package/dist/commands/init.js +164 -0
  28. package/dist/commands/learn.d.ts +8 -0
  29. package/dist/commands/learn.js +592 -0
  30. package/dist/commands/pr-check.d.ts +12 -0
  31. package/dist/commands/pr-check.js +270 -0
  32. package/dist/commands/report.d.ts +11 -0
  33. package/dist/commands/report.js +375 -0
  34. package/dist/commands/scan-storybook.d.ts +18 -0
  35. package/dist/commands/scan-storybook.js +402 -0
  36. package/dist/commands/scan.d.ts +25 -0
  37. package/dist/commands/scan.js +673 -0
  38. package/dist/commands/stats.d.ts +5 -0
  39. package/dist/commands/stats.js +137 -0
  40. package/dist/commands/tree.d.ts +12 -0
  41. package/dist/commands/tree.js +635 -0
  42. package/dist/commands/triage.d.ts +13 -0
  43. package/dist/commands/triage.js +327 -0
  44. package/dist/commands/watch.d.ts +17 -0
  45. package/dist/commands/watch.js +302 -0
  46. package/dist/types/index.d.ts +60 -0
  47. package/dist/types/index.js +4 -0
  48. package/dist/utils/baseline.d.ts +62 -0
  49. package/dist/utils/baseline.js +169 -0
  50. package/dist/utils/browser.d.ts +78 -0
  51. package/dist/utils/browser.js +239 -0
  52. package/dist/utils/cache.d.ts +76 -0
  53. package/dist/utils/cache.js +178 -0
  54. package/dist/utils/config.d.ts +102 -0
  55. package/dist/utils/config.js +237 -0
  56. package/dist/utils/converters.d.ts +77 -0
  57. package/dist/utils/converters.js +200 -0
  58. package/dist/utils/copilot.d.ts +36 -0
  59. package/dist/utils/copilot.js +139 -0
  60. package/dist/utils/detect.d.ts +22 -0
  61. package/dist/utils/detect.js +197 -0
  62. package/dist/utils/enhanced-errors.d.ts +46 -0
  63. package/dist/utils/enhanced-errors.js +295 -0
  64. package/dist/utils/errors.d.ts +31 -0
  65. package/dist/utils/errors.js +149 -0
  66. package/dist/utils/fix-patterns.d.ts +56 -0
  67. package/dist/utils/fix-patterns.js +529 -0
  68. package/dist/utils/history-tracking.d.ts +94 -0
  69. package/dist/utils/history-tracking.js +230 -0
  70. package/dist/utils/history.d.ts +42 -0
  71. package/dist/utils/history.js +255 -0
  72. package/dist/utils/impact-scores.d.ts +44 -0
  73. package/dist/utils/impact-scores.js +257 -0
  74. package/dist/utils/retry.d.ts +24 -0
  75. package/dist/utils/retry.js +76 -0
  76. package/dist/utils/scanner.d.ts +74 -0
  77. package/dist/utils/scanner.js +606 -0
  78. package/dist/utils/scanner.test.d.ts +4 -0
  79. package/dist/utils/scanner.test.js +162 -0
  80. package/dist/utils/ui.d.ts +44 -0
  81. package/dist/utils/ui.js +276 -0
  82. package/mcp-server/dist/index.d.ts +8 -0
  83. package/mcp-server/dist/index.js +1923 -0
  84. 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;