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,327 @@
1
+ /**
2
+ * ally triage command - Interactive issue categorization and prioritization
3
+ */
4
+ import inquirer from 'inquirer';
5
+ import { readFile, writeFile, mkdir } from 'fs/promises';
6
+ import { existsSync } from 'fs';
7
+ import { resolve, dirname } from 'path';
8
+ import chalk from 'chalk';
9
+ import boxen from 'boxen';
10
+ import { printBanner, createSpinner, printError, printInfo, printSuccess, } from '../utils/ui.js';
11
+ import { suggestInit } from '../utils/errors.js';
12
+ // Severity colors and icons for consistent display
13
+ const severityColors = {
14
+ critical: chalk.red.bold,
15
+ serious: chalk.red,
16
+ moderate: chalk.yellow,
17
+ minor: chalk.blue,
18
+ };
19
+ const severityIcons = {
20
+ critical: '!!!',
21
+ serious: '!!',
22
+ moderate: '!',
23
+ minor: 'i',
24
+ };
25
+ const severityLabels = {
26
+ critical: 'CRITICAL',
27
+ serious: 'SERIOUS',
28
+ moderate: 'MODERATE',
29
+ minor: 'MINOR',
30
+ };
31
+ /**
32
+ * Format a violation for display in the menu
33
+ */
34
+ function formatViolationChoice(group) {
35
+ const color = severityColors[group.impact];
36
+ const icon = severityIcons[group.impact];
37
+ const label = severityLabels[group.impact];
38
+ // Format: [SEVERITY] id (N occurrences) - description
39
+ const occurrences = group.count === 1 ? '1 occurrence' : `${group.count} occurrences`;
40
+ return `${color(`[${icon}] ${label}`)} ${chalk.white(group.id)} ${chalk.dim(`(${occurrences})`)} - ${group.help}`;
41
+ }
42
+ /**
43
+ * Group violations by type/id and count occurrences
44
+ */
45
+ function groupViolationsByType(report) {
46
+ const groupMap = new Map();
47
+ for (const result of report.results) {
48
+ for (const violation of result.violations) {
49
+ const existing = groupMap.get(violation.id);
50
+ if (existing) {
51
+ // Sum up all node occurrences
52
+ existing.count += violation.nodes.length;
53
+ }
54
+ else {
55
+ groupMap.set(violation.id, {
56
+ id: violation.id,
57
+ description: violation.description,
58
+ help: violation.help,
59
+ helpUrl: violation.helpUrl,
60
+ impact: violation.impact,
61
+ count: violation.nodes.length,
62
+ tags: violation.tags,
63
+ });
64
+ }
65
+ }
66
+ }
67
+ // Sort by severity (critical first), then by count (highest first)
68
+ const severityOrder = {
69
+ critical: 0,
70
+ serious: 1,
71
+ moderate: 2,
72
+ minor: 3,
73
+ };
74
+ return Array.from(groupMap.values()).sort((a, b) => {
75
+ const severityDiff = severityOrder[a.impact] - severityOrder[b.impact];
76
+ if (severityDiff !== 0)
77
+ return severityDiff;
78
+ return b.count - a.count;
79
+ });
80
+ }
81
+ /**
82
+ * Load existing .allyignore patterns
83
+ */
84
+ async function loadIgnorePatterns() {
85
+ const ignorePath = resolve('.allyignore');
86
+ if (!existsSync(ignorePath)) {
87
+ return [];
88
+ }
89
+ try {
90
+ const content = await readFile(ignorePath, 'utf-8');
91
+ return content
92
+ .split('\n')
93
+ .map(line => line.trim())
94
+ .filter(line => line && !line.startsWith('#'));
95
+ }
96
+ catch {
97
+ return [];
98
+ }
99
+ }
100
+ /**
101
+ * Save patterns to .allyignore
102
+ */
103
+ async function saveIgnorePatterns(patterns) {
104
+ const ignorePath = resolve('.allyignore');
105
+ const existingPatterns = await loadIgnorePatterns();
106
+ // Combine existing and new patterns, removing duplicates
107
+ const allPatterns = [...new Set([...existingPatterns, ...patterns])];
108
+ // Build file content with header
109
+ const content = `# Ally accessibility ignore file
110
+ # Patterns listed here will be skipped during scans
111
+ # Generated by ally triage
112
+
113
+ ${allPatterns.join('\n')}
114
+ `;
115
+ await writeFile(ignorePath, content, 'utf-8');
116
+ }
117
+ /**
118
+ * Load existing backlog
119
+ */
120
+ async function loadBacklog() {
121
+ const backlogPath = resolve('.ally', 'backlog.json');
122
+ if (!existsSync(backlogPath)) {
123
+ return [];
124
+ }
125
+ try {
126
+ const content = await readFile(backlogPath, 'utf-8');
127
+ return JSON.parse(content);
128
+ }
129
+ catch {
130
+ return [];
131
+ }
132
+ }
133
+ /**
134
+ * Save backlog items
135
+ */
136
+ async function saveBacklog(items) {
137
+ const backlogPath = resolve('.ally', 'backlog.json');
138
+ // Ensure .ally directory exists
139
+ const dirPath = dirname(backlogPath);
140
+ if (!existsSync(dirPath)) {
141
+ await mkdir(dirPath, { recursive: true });
142
+ }
143
+ const existingBacklog = await loadBacklog();
144
+ // Merge with existing, updating counts for existing items
145
+ const backlogMap = new Map();
146
+ for (const item of existingBacklog) {
147
+ backlogMap.set(item.id, item);
148
+ }
149
+ for (const item of items) {
150
+ backlogMap.set(item.id, item);
151
+ }
152
+ await writeFile(backlogPath, JSON.stringify(Array.from(backlogMap.values()), null, 2), 'utf-8');
153
+ }
154
+ /**
155
+ * Save fix queue for later use by ally fix
156
+ */
157
+ async function saveFixQueue(violationIds) {
158
+ const fixQueuePath = resolve('.ally', 'fix-queue.json');
159
+ // Ensure .ally directory exists
160
+ const dirPath = dirname(fixQueuePath);
161
+ if (!existsSync(dirPath)) {
162
+ await mkdir(dirPath, { recursive: true });
163
+ }
164
+ const queue = {
165
+ createdAt: new Date().toISOString(),
166
+ violations: violationIds,
167
+ };
168
+ await writeFile(fixQueuePath, JSON.stringify(queue, null, 2), 'utf-8');
169
+ }
170
+ export async function triageCommand(options = {}) {
171
+ printBanner();
172
+ const { input = '.ally/scan.json' } = options;
173
+ // Load scan results
174
+ const spinner = createSpinner('Loading scan results...');
175
+ spinner.start();
176
+ const reportPath = resolve(input);
177
+ if (!existsSync(reportPath)) {
178
+ spinner.stop();
179
+ suggestInit(reportPath);
180
+ return;
181
+ }
182
+ let report;
183
+ try {
184
+ const content = await readFile(reportPath, 'utf-8');
185
+ report = JSON.parse(content);
186
+ spinner.succeed(`Loaded scan results`);
187
+ }
188
+ catch (error) {
189
+ spinner.fail('Failed to load scan results');
190
+ printError(error instanceof Error ? error.message : String(error));
191
+ return;
192
+ }
193
+ // Group violations by type
194
+ const violationGroups = groupViolationsByType(report);
195
+ if (violationGroups.length === 0) {
196
+ printSuccess('No violations to triage! Your code is fully accessible.');
197
+ return;
198
+ }
199
+ console.log();
200
+ console.log(chalk.bold.cyan('Interactive Issue Triage'));
201
+ console.log(chalk.dim('Categorize each violation type to prioritize your accessibility work'));
202
+ console.log(chalk.dim('━'.repeat(60)));
203
+ console.log();
204
+ // Show summary of what we're triaging
205
+ const totalOccurrences = violationGroups.reduce((sum, g) => sum + g.count, 0);
206
+ printInfo(`Found ${violationGroups.length} unique violation types (${totalOccurrences} total occurrences)`);
207
+ console.log();
208
+ // Triage results
209
+ const result = {
210
+ fix: [],
211
+ ignore: [],
212
+ backlog: [],
213
+ };
214
+ const backlogItems = [];
215
+ // Process each violation group
216
+ for (let i = 0; i < violationGroups.length; i++) {
217
+ const group = violationGroups[i];
218
+ const progress = chalk.dim(`[${i + 1}/${violationGroups.length}]`);
219
+ console.log();
220
+ console.log(`${progress} ${formatViolationChoice(group)}`);
221
+ console.log(chalk.dim(` ${group.description}`));
222
+ console.log(chalk.dim(` Learn more: ${group.helpUrl}`));
223
+ console.log();
224
+ // Interactive prompt for each violation
225
+ const { action } = await inquirer.prompt([
226
+ {
227
+ type: 'list',
228
+ name: 'action',
229
+ message: 'What would you like to do with this violation?',
230
+ choices: [
231
+ {
232
+ name: chalk.green('Fix now') + chalk.dim(' - Add to fix queue'),
233
+ value: 'fix',
234
+ },
235
+ {
236
+ name: chalk.yellow('Backlog') + chalk.dim(' - Defer for later'),
237
+ value: 'backlog',
238
+ },
239
+ {
240
+ name: chalk.red('Ignore') + chalk.dim(' - Add to .allyignore'),
241
+ value: 'ignore',
242
+ },
243
+ {
244
+ name: chalk.blue('Skip') + chalk.dim(' - Decide later'),
245
+ value: 'skip',
246
+ },
247
+ ],
248
+ default: 'fix',
249
+ },
250
+ ]);
251
+ switch (action) {
252
+ case 'fix':
253
+ result.fix.push(group.id);
254
+ console.log(chalk.green(` + Added to fix queue`));
255
+ break;
256
+ case 'backlog':
257
+ result.backlog.push(group.id);
258
+ backlogItems.push({
259
+ id: group.id,
260
+ description: group.help,
261
+ impact: group.impact,
262
+ count: group.count,
263
+ addedAt: new Date().toISOString(),
264
+ });
265
+ console.log(chalk.yellow(` -> Added to backlog`));
266
+ break;
267
+ case 'ignore':
268
+ result.ignore.push(group.id);
269
+ console.log(chalk.red(` x Will be ignored`));
270
+ break;
271
+ case 'skip':
272
+ console.log(chalk.dim(` - Skipped for now`));
273
+ break;
274
+ }
275
+ }
276
+ // Save results
277
+ console.log();
278
+ console.log(chalk.dim('━'.repeat(60)));
279
+ console.log();
280
+ const saveSpinner = createSpinner('Saving triage results...');
281
+ saveSpinner.start();
282
+ try {
283
+ // Save .allyignore
284
+ if (result.ignore.length > 0) {
285
+ await saveIgnorePatterns(result.ignore);
286
+ }
287
+ // Save backlog
288
+ if (backlogItems.length > 0) {
289
+ await saveBacklog(backlogItems);
290
+ }
291
+ // Save fix queue
292
+ if (result.fix.length > 0) {
293
+ await saveFixQueue(result.fix);
294
+ }
295
+ saveSpinner.succeed('Triage results saved');
296
+ }
297
+ catch (error) {
298
+ saveSpinner.fail('Failed to save triage results');
299
+ printError(error instanceof Error ? error.message : String(error));
300
+ }
301
+ // Print summary
302
+ console.log();
303
+ const summaryContent = `
304
+ ${chalk.bold('Triage Complete!')}
305
+
306
+ ${chalk.green('Fix now:')} ${result.fix.length} violation type${result.fix.length === 1 ? '' : 's'}
307
+ ${chalk.yellow('Backlog:')} ${result.backlog.length} violation type${result.backlog.length === 1 ? '' : 's'}
308
+ ${chalk.red('Ignored:')} ${result.ignore.length} violation type${result.ignore.length === 1 ? '' : 's'}
309
+ `.trim();
310
+ console.log(boxen(summaryContent, {
311
+ padding: 1,
312
+ borderStyle: 'round',
313
+ borderColor: 'cyan',
314
+ }));
315
+ // Show next steps
316
+ console.log();
317
+ if (result.fix.length > 0) {
318
+ printSuccess(`Run ${chalk.cyan('ally fix')} to fix the ${result.fix.length} queued issue${result.fix.length === 1 ? '' : 's'}`);
319
+ }
320
+ if (result.ignore.length > 0) {
321
+ printInfo(`${result.ignore.length} pattern${result.ignore.length === 1 ? '' : 's'} added to ${chalk.cyan('.allyignore')}`);
322
+ }
323
+ if (result.backlog.length > 0) {
324
+ printInfo(`${result.backlog.length} item${result.backlog.length === 1 ? '' : 's'} saved to ${chalk.cyan('.ally/backlog.json')}`);
325
+ }
326
+ }
327
+ export default triageCommand;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * ally watch command - Continuous accessibility testing for development
3
+ *
4
+ * Watches a directory for HTML file changes and automatically scans
5
+ * them for accessibility violations.
6
+ */
7
+ interface WatchCommandOptions {
8
+ port?: number;
9
+ debounce?: number;
10
+ clear?: boolean;
11
+ fixOnSave?: boolean;
12
+ }
13
+ /**
14
+ * Main watch command
15
+ */
16
+ export declare function watchCommand(targetPath?: string, options?: WatchCommandOptions): Promise<void>;
17
+ export default watchCommand;
@@ -0,0 +1,302 @@
1
+ /**
2
+ * ally watch command - Continuous accessibility testing for development
3
+ *
4
+ * Watches a directory for HTML file changes and automatically scans
5
+ * them for accessibility violations.
6
+ */
7
+ import { resolve, relative, extname } from 'path';
8
+ import { watch, existsSync, statSync } from 'fs';
9
+ import { readdir, readFile, writeFile } from 'fs/promises';
10
+ import chalk from 'chalk';
11
+ import { AccessibilityScanner, calculateScore, } from '../utils/scanner.js';
12
+ import { printBanner, printInfo, printError, } from '../utils/ui.js';
13
+ import { generateSuggestedFix, getFixConfidence, } from '../utils/fix-patterns.js';
14
+ const SUPPORTED_EXTENSIONS = ['.html', '.htm'];
15
+ /**
16
+ * Auto-apply high-confidence fixes to a file
17
+ * Returns the number of fixes applied
18
+ */
19
+ async function autoFixFile(filePath, violations) {
20
+ let content = await readFile(filePath, 'utf-8');
21
+ let fixesApplied = 0;
22
+ // Only apply high-confidence fixes (>= 0.9)
23
+ const highConfidenceViolations = violations.filter((v) => {
24
+ const confidence = getFixConfidence(v.id);
25
+ return confidence !== null && confidence >= 0.9;
26
+ });
27
+ for (const violation of highConfidenceViolations) {
28
+ for (const node of violation.nodes) {
29
+ if (node.html) {
30
+ const fixedHtml = generateSuggestedFix(violation, node.html);
31
+ if (fixedHtml && fixedHtml !== node.html) {
32
+ // Apply the fix by replacing the HTML
33
+ content = content.replace(node.html, fixedHtml);
34
+ fixesApplied++;
35
+ }
36
+ }
37
+ }
38
+ }
39
+ if (fixesApplied > 0) {
40
+ await writeFile(filePath, content, 'utf-8');
41
+ }
42
+ return fixesApplied;
43
+ }
44
+ /**
45
+ * Format time for log output
46
+ */
47
+ function formatTime() {
48
+ const now = new Date();
49
+ return chalk.dim(`[${now.getHours().toString().padStart(2, '0')}:${now
50
+ .getMinutes()
51
+ .toString()
52
+ .padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}]`);
53
+ }
54
+ /**
55
+ * Debounce function to prevent rapid-fire scans
56
+ */
57
+ function debounce(fn, delay) {
58
+ let timeoutId = null;
59
+ return (filePath) => {
60
+ if (timeoutId) {
61
+ clearTimeout(timeoutId);
62
+ }
63
+ timeoutId = setTimeout(() => fn(filePath), delay);
64
+ };
65
+ }
66
+ /**
67
+ * Get severity icon
68
+ */
69
+ function getSeverityIcon(severity) {
70
+ const icons = {
71
+ critical: chalk.red('!!!'),
72
+ serious: chalk.red('!!'),
73
+ moderate: chalk.yellow('!'),
74
+ minor: chalk.blue('i'),
75
+ };
76
+ return icons[severity];
77
+ }
78
+ /**
79
+ * Print compact violation for watch mode
80
+ */
81
+ function printCompactViolation(violation) {
82
+ const icon = getSeverityIcon(violation.impact);
83
+ const count = violation.nodes.length;
84
+ const countText = count > 1 ? chalk.dim(` (${count} instances)`) : '';
85
+ console.log(` - ${chalk.cyan(violation.id)}: ${violation.help}${countText}`);
86
+ }
87
+ /**
88
+ * Print file scan result in watch mode format
89
+ */
90
+ function printWatchResult(file, result, basePath, fixesApplied) {
91
+ const relPath = relative(basePath, file);
92
+ const score = calculateScore([result]);
93
+ const violations = result.violations;
94
+ console.log();
95
+ console.log(`${formatTime()} ${chalk.bold(relPath)} changed`);
96
+ if (fixesApplied && fixesApplied > 0) {
97
+ const fixText = fixesApplied === 1 ? 'fix' : 'fixes';
98
+ console.log(chalk.green(` ✓ Auto-applied ${fixesApplied} ${fixText}`));
99
+ }
100
+ if (violations.length === 0) {
101
+ console.log(chalk.green(` No issues found (score: ${score})`));
102
+ }
103
+ else {
104
+ const issueText = violations.length === 1 ? 'issue' : 'issues';
105
+ const scoreColor = score >= 75 ? chalk.green : score >= 50 ? chalk.yellow : chalk.red;
106
+ console.log(chalk.yellow(` ${violations.length} ${issueText} found`) +
107
+ ` (score: ${scoreColor(score.toString())})`);
108
+ // Group by severity for compact output
109
+ const bySeverity = {};
110
+ for (const v of violations) {
111
+ if (!bySeverity[v.impact]) {
112
+ bySeverity[v.impact] = [];
113
+ }
114
+ bySeverity[v.impact].push(v);
115
+ }
116
+ // Print critical and serious first
117
+ const order = ['critical', 'serious', 'moderate', 'minor'];
118
+ for (const severity of order) {
119
+ const sViolations = bySeverity[severity];
120
+ if (sViolations) {
121
+ for (const v of sViolations) {
122
+ printCompactViolation(v);
123
+ }
124
+ }
125
+ }
126
+ }
127
+ }
128
+ /**
129
+ * Print watch summary on exit
130
+ */
131
+ function printWatchSummary(stats, startTime) {
132
+ const duration = Math.round((Date.now() - startTime.getTime()) / 1000);
133
+ const minutes = Math.floor(duration / 60);
134
+ const seconds = duration % 60;
135
+ const durationStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
136
+ console.log();
137
+ console.log(chalk.bold.cyan('\nWatch Session Summary'));
138
+ console.log(chalk.dim('-'.repeat(40)));
139
+ console.log(` Duration: ${durationStr}`);
140
+ console.log(` Files scanned: ${stats.filesScanned}`);
141
+ console.log(` Total violations: ${stats.totalViolations}`);
142
+ console.log(` Clean scans: ${stats.cleanScans}`);
143
+ if (stats.autoFixed > 0) {
144
+ console.log(chalk.green(` Auto-fixed: ${stats.autoFixed}`));
145
+ }
146
+ console.log();
147
+ }
148
+ /**
149
+ * Recursively watch a directory
150
+ */
151
+ async function watchDirectory(dirPath, callback) {
152
+ const watchers = [];
153
+ const watchedDirs = new Set();
154
+ async function addWatcher(dir) {
155
+ if (watchedDirs.has(dir))
156
+ return;
157
+ watchedDirs.add(dir);
158
+ try {
159
+ const watcher = watch(dir, { recursive: false }, (eventType, filename) => {
160
+ if (filename && isHtmlFile(filename)) {
161
+ const fullPath = resolve(dir, filename);
162
+ callback(fullPath);
163
+ }
164
+ });
165
+ watchers.push(watcher);
166
+ // Watch subdirectories
167
+ const entries = await readdir(dir, { withFileTypes: true });
168
+ for (const entry of entries) {
169
+ if (entry.isDirectory() &&
170
+ !entry.name.startsWith('.') &&
171
+ entry.name !== 'node_modules' &&
172
+ entry.name !== 'dist' &&
173
+ entry.name !== 'build') {
174
+ await addWatcher(resolve(dir, entry.name));
175
+ }
176
+ }
177
+ }
178
+ catch (error) {
179
+ // Directory might not exist or be inaccessible
180
+ }
181
+ }
182
+ // Use recursive watching on supported platforms
183
+ try {
184
+ const watcher = watch(dirPath, { recursive: true }, (eventType, filename) => {
185
+ if (filename && isHtmlFile(filename)) {
186
+ const fullPath = resolve(dirPath, filename);
187
+ if (existsSync(fullPath)) {
188
+ callback(fullPath);
189
+ }
190
+ }
191
+ });
192
+ watchers.push(watcher);
193
+ }
194
+ catch (error) {
195
+ // Fall back to manual recursive watching
196
+ await addWatcher(dirPath);
197
+ }
198
+ return () => {
199
+ for (const watcher of watchers) {
200
+ watcher.close();
201
+ }
202
+ };
203
+ }
204
+ /**
205
+ * Check if file is an HTML file
206
+ */
207
+ function isHtmlFile(filename) {
208
+ const ext = extname(filename).toLowerCase();
209
+ return SUPPORTED_EXTENSIONS.includes(ext);
210
+ }
211
+ /**
212
+ * Main watch command
213
+ */
214
+ export async function watchCommand(targetPath = '.', options = {}) {
215
+ const { debounce: debounceMs = 500, clear = false, fixOnSave = false } = options;
216
+ const absolutePath = resolve(targetPath);
217
+ // Verify path exists
218
+ if (!existsSync(absolutePath)) {
219
+ printError(`Path does not exist: ${absolutePath}`);
220
+ process.exit(1);
221
+ }
222
+ const pathStat = statSync(absolutePath);
223
+ if (!pathStat.isDirectory()) {
224
+ printError(`Path is not a directory: ${absolutePath}`);
225
+ process.exit(1);
226
+ }
227
+ printBanner();
228
+ console.log(chalk.cyan.bold('Watching for accessibility changes...'));
229
+ console.log(chalk.dim(` Directory: ${absolutePath}`));
230
+ console.log(chalk.dim(` Debounce: ${debounceMs}ms`));
231
+ if (fixOnSave) {
232
+ console.log(chalk.green(' Auto-fix: ON (confidence ≥ 90%)'));
233
+ }
234
+ console.log(chalk.dim(' Press Ctrl+C to stop\n'));
235
+ // Initialize scanner
236
+ const scanner = new AccessibilityScanner();
237
+ await scanner.init();
238
+ // Track stats
239
+ const stats = {
240
+ filesScanned: 0,
241
+ totalViolations: 0,
242
+ cleanScans: 0,
243
+ autoFixed: 0,
244
+ };
245
+ const startTime = new Date();
246
+ // Scan a file
247
+ const scanFile = async (filePath) => {
248
+ if (!existsSync(filePath))
249
+ return;
250
+ try {
251
+ if (clear) {
252
+ console.clear();
253
+ console.log(chalk.cyan.bold('Watching for accessibility changes...'));
254
+ console.log(chalk.dim(' Press Ctrl+C to stop\n'));
255
+ }
256
+ const result = await scanner.scanHtmlFile(filePath);
257
+ stats.filesScanned++;
258
+ stats.totalViolations += result.violations.length;
259
+ if (result.violations.length === 0) {
260
+ stats.cleanScans++;
261
+ }
262
+ // Auto-fix if enabled
263
+ let fixesApplied = 0;
264
+ if (fixOnSave && result.violations.length > 0) {
265
+ fixesApplied = await autoFixFile(filePath, result.violations);
266
+ stats.autoFixed += fixesApplied;
267
+ // Rescan after fixes to show updated violations
268
+ if (fixesApplied > 0) {
269
+ const updatedResult = await scanner.scanHtmlFile(filePath);
270
+ printWatchResult(filePath, updatedResult, absolutePath, fixesApplied);
271
+ return;
272
+ }
273
+ }
274
+ printWatchResult(filePath, result, absolutePath);
275
+ }
276
+ catch (error) {
277
+ console.log();
278
+ console.log(`${formatTime()} ${chalk.red('Error scanning')} ${relative(absolutePath, filePath)}`);
279
+ printError(error instanceof Error ? error.message : String(error));
280
+ }
281
+ };
282
+ // Debounced scan
283
+ const debouncedScan = debounce(scanFile, debounceMs);
284
+ // Set up file watching
285
+ const stopWatching = await watchDirectory(absolutePath, (filename) => {
286
+ debouncedScan(filename);
287
+ });
288
+ // Handle graceful shutdown
289
+ const cleanup = async () => {
290
+ stopWatching();
291
+ await scanner.close();
292
+ printWatchSummary(stats, startTime);
293
+ process.exit(0);
294
+ };
295
+ process.on('SIGINT', cleanup);
296
+ process.on('SIGTERM', cleanup);
297
+ // Initial message about watching
298
+ printInfo('Waiting for file changes...');
299
+ // Keep the process running
300
+ await new Promise(() => { });
301
+ }
302
+ export default watchCommand;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Core types for Ally accessibility scanner
3
+ */
4
+ export type Severity = 'critical' | 'serious' | 'moderate' | 'minor';
5
+ export interface Violation {
6
+ id: string;
7
+ impact: Severity;
8
+ description: string;
9
+ help: string;
10
+ helpUrl: string;
11
+ nodes: ViolationNode[];
12
+ tags: string[];
13
+ }
14
+ export interface ViolationNode {
15
+ html: string;
16
+ target: string[];
17
+ failureSummary: string;
18
+ }
19
+ export interface ScanResult {
20
+ url: string;
21
+ file?: string;
22
+ timestamp: string;
23
+ violations: Violation[];
24
+ passes: number;
25
+ incomplete: number;
26
+ }
27
+ export interface AllyReport {
28
+ version: string;
29
+ scanDate: string;
30
+ totalFiles: number;
31
+ results: ScanResult[];
32
+ summary: ReportSummary;
33
+ }
34
+ export interface ReportSummary {
35
+ totalViolations: number;
36
+ bySeverity: Record<Severity, number>;
37
+ score: number;
38
+ topIssues: TopIssue[];
39
+ }
40
+ export interface TopIssue {
41
+ id: string;
42
+ count: number;
43
+ description: string;
44
+ severity: Severity;
45
+ }
46
+ export interface FixResult {
47
+ file: string;
48
+ line: number;
49
+ violation: string;
50
+ fixed: boolean;
51
+ skipped: boolean;
52
+ diff?: string;
53
+ }
54
+ export interface ScanOptions {
55
+ path: string;
56
+ include?: string[];
57
+ exclude?: string[];
58
+ standards?: ('wcag2a' | 'wcag2aa' | 'wcag2aaa' | 'wcag21a' | 'wcag21aa' | 'wcag22aa')[];
59
+ output?: string;
60
+ }