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,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
|
+
}
|