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,673 @@
1
+ /**
2
+ * ally scan command - Scans files for accessibility violations
3
+ */
4
+ import { resolve, relative } from 'path';
5
+ import { mkdir, writeFile, readFile } from 'fs/promises';
6
+ import { existsSync } from 'fs';
7
+ import cliProgress from 'cli-progress';
8
+ import { AccessibilityScanner, findHtmlFiles, findComponentFiles, createReport, DEFAULT_STANDARD, DEFAULT_TIMEOUT, DEFAULT_BATCH_SIZE, } from '../utils/scanner.js';
9
+ import { getCachedResult, cacheResult, } from '../utils/cache.js';
10
+ import { printBanner, createSpinner, printViolation, printSummary, printSuccess, printError, printInfo, printFileHeader, } from '../utils/ui.js';
11
+ import { detectProject, countProjectFiles, getProjectDescription, } from '../utils/detect.js';
12
+ import { formatScanError, isPuppeteerError, } from '../utils/errors.js';
13
+ import { throwEnhancedError, } from '../utils/enhanced-errors.js';
14
+ import { loadConfig, loadIgnorePatterns } from '../utils/config.js';
15
+ import { saveHistoryEntry as saveHistoryTracking, } from '../utils/history-tracking.js';
16
+ import { sortByImpact, detectPageContext, } from '../utils/impact-scores.js';
17
+ import { saveBaseline, loadBaseline, compareWithBaseline, formatRegression, } from '../utils/baseline.js';
18
+ /**
19
+ * Check if colors should be disabled (for CI environments or NO_COLOR)
20
+ */
21
+ function isNoColor() {
22
+ return !!(process.env.NO_COLOR || process.env.CI || !process.stdout.isTTY);
23
+ }
24
+ /**
25
+ * Retry logic for browser initialization with crash recovery
26
+ */
27
+ async function initScannerWithRetry(scanner, maxRetries = 2) {
28
+ let lastError;
29
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
30
+ try {
31
+ await scanner.init();
32
+ return;
33
+ }
34
+ catch (error) {
35
+ const err = error instanceof Error ? error : new Error(String(error));
36
+ lastError = err;
37
+ // Check if it's a browser crash or launch error
38
+ const isBrowserError = err.message.toLowerCase().includes('browser') ||
39
+ err.message.toLowerCase().includes('crashed') ||
40
+ err.message.toLowerCase().includes('closed');
41
+ // Only retry on browser-specific errors
42
+ if (!isBrowserError || attempt >= maxRetries) {
43
+ throw err;
44
+ }
45
+ // Wait before retrying (exponential backoff: 1s, 2s)
46
+ await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt)));
47
+ }
48
+ }
49
+ throw lastError || new Error('Failed to initialize scanner');
50
+ }
51
+ /**
52
+ * Print CI-friendly summary line
53
+ * Errors = critical + serious, Warnings = moderate + minor
54
+ */
55
+ function printCiSummary(summary) {
56
+ const errors = (summary.bySeverity.critical ?? 0) + (summary.bySeverity.serious ?? 0);
57
+ const warnings = (summary.bySeverity.moderate ?? 0) + (summary.bySeverity.minor ?? 0);
58
+ const symbol = errors > 0 ? '\u2717' : '\u2713';
59
+ console.log(`${symbol} ${errors} error${errors !== 1 ? 's' : ''}, ${warnings} warning${warnings !== 1 ? 's' : ''}`);
60
+ }
61
+ export async function scanCommand(targetPath = '.', options = {}) {
62
+ const ci = options.ci ?? false;
63
+ if (!ci) {
64
+ printBanner();
65
+ }
66
+ // Load config file (if exists)
67
+ let config = {};
68
+ try {
69
+ const { config: loadedConfig, configPath } = await loadConfig();
70
+ config = loadedConfig;
71
+ if (configPath && !ci) {
72
+ printInfo(`Using config from ${configPath}`);
73
+ }
74
+ }
75
+ catch (error) {
76
+ printError(`Config error: ${error instanceof Error ? error.message : String(error)}`);
77
+ return null;
78
+ }
79
+ // Merge config with CLI options (CLI takes precedence)
80
+ const mergedOutput = options.output ?? config.report?.output ?? '.ally';
81
+ const mergedFormat = options.format ?? config.report?.format;
82
+ const mergedStandard = options.standard ?? config.scan?.standard ?? DEFAULT_STANDARD;
83
+ // Load ignore patterns from .allyignore
84
+ const { patterns: ignorePatterns, ignorePath } = await loadIgnorePatterns();
85
+ // Merge with config ignore patterns
86
+ const allIgnorePatterns = [...ignorePatterns, ...(config.scan?.ignore ?? [])];
87
+ if (ignorePath && !ci) {
88
+ printInfo(`Using ignore patterns from ${ignorePath}`);
89
+ }
90
+ const { url, json = false, verbose = false, simulate, timeout, browser = 'chromium' } = options;
91
+ const mergedTimeout = timeout ?? DEFAULT_TIMEOUT;
92
+ const mergedBrowser = browser;
93
+ // URL scanning mode
94
+ if (url) {
95
+ return await scanUrl(url, mergedOutput, json, verbose, mergedFormat, simulate, mergedStandard, mergedTimeout, ci, mergedBrowser);
96
+ }
97
+ // File scanning mode
98
+ const noCache = options.noCache ?? false;
99
+ // Auto-detect project type when using default path
100
+ let projectInfo = null;
101
+ if (targetPath === '.') {
102
+ const absolutePath = resolve(targetPath);
103
+ projectInfo = await detectProject(absolutePath);
104
+ if (!ci && projectInfo.type !== 'unknown') {
105
+ const description = getProjectDescription(projectInfo.type);
106
+ const fileCount = await countProjectFiles(absolutePath, projectInfo.patterns);
107
+ printSuccess(`Detected: ${description}`);
108
+ printInfo(`Scanning: ${projectInfo.patterns.join(', ')}`);
109
+ printInfo(`Found: ${fileCount} ${projectInfo.type === 'html' ? 'HTML' : 'component'} files`);
110
+ console.log();
111
+ }
112
+ }
113
+ // Run scan
114
+ const report = await scanFiles(targetPath, mergedOutput, json, verbose, mergedFormat, mergedStandard, allIgnorePatterns, mergedTimeout, noCache, ci, projectInfo, mergedBrowser, options.maxFiles, options.compareBaseline, options.failOnRegression);
115
+ if (!report) {
116
+ return null;
117
+ }
118
+ // Handle baseline operations
119
+ if (options.baseline) {
120
+ if (!ci) {
121
+ printInfo('Setting accessibility baseline...');
122
+ }
123
+ await saveBaseline(report, mergedOutput);
124
+ if (!ci) {
125
+ printSuccess('Baseline saved! Future scans will track improvements against this baseline.');
126
+ }
127
+ }
128
+ // Handle baseline comparison
129
+ if (options.compareBaseline) {
130
+ const baseline = await loadBaseline(mergedOutput);
131
+ if (baseline) {
132
+ const analysis = compareWithBaseline(report, baseline);
133
+ if (!ci) {
134
+ console.log(formatRegression(analysis));
135
+ }
136
+ // Fail if regressions and fail-on-regression flag
137
+ if (options.failOnRegression && analysis.regressed > 0) {
138
+ if (ci) {
139
+ printError(`✗ Regression detected: ${analysis.regressed} file(s) have new violations`);
140
+ }
141
+ process.exit(1);
142
+ }
143
+ }
144
+ else if (!ci) {
145
+ printInfo('No baseline found. Run with --baseline first to set a baseline.');
146
+ }
147
+ }
148
+ return report;
149
+ }
150
+ async function scanUrl(url, outputDir, json, verbose, format, simulate, standard = DEFAULT_STANDARD, timeout = DEFAULT_TIMEOUT, ci = false, browser = 'chromium') {
151
+ let spinner = null;
152
+ const browserLabel = browser !== 'chromium' ? ` [${browser}]` : '';
153
+ if (!ci) {
154
+ spinner = createSpinner(`Scanning ${url} (${standard})${browserLabel}...`);
155
+ spinner.start();
156
+ }
157
+ const scanner = new AccessibilityScanner(timeout, browser);
158
+ try {
159
+ await scanner.init();
160
+ const result = await scanner.scanUrl(url, standard);
161
+ if (spinner) {
162
+ spinner.succeed(`Scanned ${url} using ${standard}`);
163
+ }
164
+ const report = createReport([result]);
165
+ // Save to history for progress tracking
166
+ await saveHistoryTracking(report, 'scan');
167
+ if (ci) {
168
+ // CI mode: only print summary line
169
+ printCiSummary(report.summary);
170
+ }
171
+ else {
172
+ // Print violations sorted by impact
173
+ if (result.violations.length > 0) {
174
+ console.log();
175
+ // Sort violations by impact score
176
+ const sortedViolations = sortByImpact(result.violations);
177
+ for (const { violation, impact } of sortedViolations) {
178
+ if (verbose || violation.impact === 'critical' || violation.impact === 'serious') {
179
+ printViolation(violation, undefined, undefined, impact);
180
+ }
181
+ }
182
+ }
183
+ // Print summary
184
+ printSummary(report.summary);
185
+ // Save report
186
+ await saveReport(report, outputDir, format);
187
+ // Color blindness simulation
188
+ if (simulate) {
189
+ const simSpinner = createSpinner(`Generating ${simulate} simulation...`);
190
+ simSpinner.start();
191
+ try {
192
+ // Ensure output directory exists
193
+ if (!existsSync(outputDir)) {
194
+ await mkdir(outputDir, { recursive: true });
195
+ }
196
+ const screenshotPath = resolve(outputDir, `simulation-${simulate}.png`);
197
+ await scanner.simulateColorBlindness(url, simulate, screenshotPath);
198
+ simSpinner.succeed(`Color blindness simulation saved`);
199
+ printInfo(`Screenshot saved to: ${screenshotPath}`);
200
+ }
201
+ catch (simError) {
202
+ simSpinner.fail(`Failed to generate simulation`);
203
+ printError(simError instanceof Error ? simError.message : String(simError));
204
+ }
205
+ }
206
+ // Output to stdout if requested
207
+ if (format === 'sarif') {
208
+ const sarifReport = convertToSarif(report);
209
+ console.log(JSON.stringify(sarifReport, null, 2));
210
+ }
211
+ else if (format === 'junit') {
212
+ const junitReport = convertToJunit(report);
213
+ console.log(junitReport);
214
+ }
215
+ else if (format === 'csv') {
216
+ const csvReport = convertToCsv(report);
217
+ console.log(csvReport);
218
+ }
219
+ else if (json) {
220
+ console.log(JSON.stringify(report, null, 2));
221
+ }
222
+ }
223
+ return report;
224
+ }
225
+ catch (error) {
226
+ if (spinner) {
227
+ spinner.fail(`Failed to scan ${url}`);
228
+ }
229
+ const err = error instanceof Error ? error : new Error(String(error));
230
+ if (isPuppeteerError(err)) {
231
+ formatScanError(err, url);
232
+ }
233
+ else {
234
+ printError(err.message);
235
+ }
236
+ return null;
237
+ }
238
+ finally {
239
+ await scanner.close();
240
+ }
241
+ }
242
+ async function scanFiles(targetPath, outputDir, json, verbose, format, standard = DEFAULT_STANDARD, ignorePatterns = [], timeout = DEFAULT_TIMEOUT, noCache = false, ci = false, projectInfo = null, browser = 'chromium', maxFiles, compareBaseline, failOnRegression) {
243
+ const absolutePath = resolve(targetPath);
244
+ const browserLabel = browser !== 'chromium' ? ` [${browser}]` : '';
245
+ // Find files
246
+ let findSpinner = null;
247
+ if (!ci) {
248
+ findSpinner = createSpinner(`Finding files to scan (${standard})${browserLabel}...`);
249
+ findSpinner.start();
250
+ }
251
+ const htmlFiles = await findHtmlFiles(absolutePath, ignorePatterns);
252
+ const componentFiles = await findComponentFiles(absolutePath, ignorePatterns);
253
+ let allFiles = [...htmlFiles];
254
+ // Apply max-files limit if specified
255
+ if (maxFiles && allFiles.length > maxFiles) {
256
+ if (!ci) {
257
+ printInfo(`Limiting to first ${maxFiles} files (use --max-files to adjust)`);
258
+ }
259
+ allFiles = allFiles.slice(0, maxFiles);
260
+ }
261
+ if (allFiles.length === 0) {
262
+ if (findSpinner) {
263
+ findSpinner.stop();
264
+ }
265
+ if (!ci) {
266
+ throwEnhancedError('NO_FILES_FOUND');
267
+ }
268
+ return null;
269
+ }
270
+ if (findSpinner) {
271
+ findSpinner.succeed(`Found ${allFiles.length} HTML file${allFiles.length === 1 ? '' : 's'} (using ${standard})`);
272
+ }
273
+ if (!ci && componentFiles.length > 0) {
274
+ printInfo(`Also found ${componentFiles.length} component files (use --url to scan rendered output)`);
275
+ }
276
+ // Scan files
277
+ const scanner = new AccessibilityScanner(timeout, browser);
278
+ const results = [];
279
+ const errors = [];
280
+ let skippedCount = 0;
281
+ // Use progress bar for multi-file scanning, or simple text for NO_COLOR
282
+ // In CI mode, skip all progress indicators
283
+ const useProgressBar = !ci && !isNoColor() && allFiles.length > 1;
284
+ let progressBar = null;
285
+ let scanSpinner = null;
286
+ if (!ci) {
287
+ if (useProgressBar) {
288
+ progressBar = new cliProgress.SingleBar({
289
+ format: 'Scanning |{bar}| {percentage}% | {value}/{total} files | {filename}',
290
+ barCompleteChar: '\u2588',
291
+ barIncompleteChar: '\u2591',
292
+ hideCursor: true,
293
+ clearOnComplete: true,
294
+ }, cliProgress.Presets.shades_classic);
295
+ progressBar.start(allFiles.length, 0, { filename: '' });
296
+ }
297
+ else {
298
+ scanSpinner = createSpinner(`Scanning ${allFiles.length} files...`);
299
+ scanSpinner.start();
300
+ }
301
+ }
302
+ try {
303
+ await initScannerWithRetry(scanner, 2);
304
+ // Phase 1: Check cache for all files (unless --no-cache)
305
+ const filesToScan = [];
306
+ if (!noCache) {
307
+ for (const file of allFiles) {
308
+ const cachedResult = await getCachedResult(file, standard);
309
+ if (cachedResult) {
310
+ results.push(cachedResult);
311
+ skippedCount++;
312
+ }
313
+ else {
314
+ filesToScan.push(file);
315
+ }
316
+ }
317
+ }
318
+ else {
319
+ filesToScan.push(...allFiles);
320
+ }
321
+ // Phase 2: Scan uncached files in parallel batches
322
+ if (filesToScan.length > 0) {
323
+ let scannedCount = 0;
324
+ const { results: scanResults, errors: scanErrors } = await scanner.scanHtmlFilesParallel(filesToScan, standard, DEFAULT_BATCH_SIZE, (completed, total, currentFile) => {
325
+ scannedCount = completed;
326
+ const relPath = currentFile ? relative(absolutePath, currentFile) : '';
327
+ if (progressBar) {
328
+ progressBar.update(skippedCount + completed, { filename: relPath });
329
+ }
330
+ else if (scanSpinner) {
331
+ scanSpinner.text = `Scanning ${relPath} (${skippedCount + completed}/${allFiles.length})`;
332
+ }
333
+ });
334
+ // Add scan results
335
+ results.push(...scanResults);
336
+ // Add scan errors
337
+ for (const err of scanErrors) {
338
+ errors.push({ path: relative(absolutePath, err.path), error: err.error });
339
+ }
340
+ // Phase 3: Cache new results
341
+ if (!noCache) {
342
+ for (const result of scanResults) {
343
+ if (result.file) {
344
+ await cacheResult(result.file, result, standard);
345
+ }
346
+ }
347
+ }
348
+ }
349
+ // Finish progress bar
350
+ if (progressBar) {
351
+ progressBar.update(allFiles.length, { filename: 'Done' });
352
+ progressBar.stop();
353
+ }
354
+ else if (scanSpinner) {
355
+ scanSpinner.succeed(`Scanned ${results.length} files`);
356
+ }
357
+ // Create report
358
+ const report = createReport(results);
359
+ // Save to history for progress tracking
360
+ await saveHistoryTracking(report, 'scan');
361
+ if (ci) {
362
+ // CI mode: only print summary line
363
+ printCiSummary(report.summary);
364
+ }
365
+ else {
366
+ // Print any errors that occurred
367
+ for (const err of errors) {
368
+ printError(`Failed to scan ${err.path}: ${err.error}`);
369
+ }
370
+ console.log();
371
+ const scannedCount = results.length - skippedCount;
372
+ if (skippedCount > 0) {
373
+ printSuccess(`Scanned ${scannedCount} files (${skippedCount} skipped, unchanged)`);
374
+ }
375
+ else {
376
+ printSuccess(`Scanned ${results.length} files`);
377
+ }
378
+ // Print per-file results with impact scoring
379
+ console.log();
380
+ for (const result of results) {
381
+ if (!result.file)
382
+ continue;
383
+ const relPath = relative(absolutePath, result.file);
384
+ // Pass absolute path for clickable hyperlinks
385
+ printFileHeader(relPath, result.violations.length, result.file);
386
+ if (result.violations.length > 0) {
387
+ // Detect page context from file content
388
+ let pageContext = {};
389
+ try {
390
+ const fileContent = await readFile(result.file, 'utf-8');
391
+ pageContext = detectPageContext(fileContent);
392
+ }
393
+ catch {
394
+ // If file read fails, use empty context
395
+ }
396
+ // Sort violations by impact score
397
+ const sortedViolations = sortByImpact(result.violations, pageContext);
398
+ for (const { violation, impact } of sortedViolations) {
399
+ if (verbose || violation.impact === 'critical' || violation.impact === 'serious') {
400
+ printViolation(violation, relPath, result.file, impact);
401
+ }
402
+ }
403
+ }
404
+ }
405
+ // Print summary
406
+ printSummary(report.summary);
407
+ // Save report
408
+ await saveReport(report, outputDir, format);
409
+ // Output to stdout if requested
410
+ if (format === 'sarif') {
411
+ const sarifReport = convertToSarif(report);
412
+ console.log(JSON.stringify(sarifReport, null, 2));
413
+ }
414
+ else if (format === 'junit') {
415
+ const junitReport = convertToJunit(report);
416
+ console.log(junitReport);
417
+ }
418
+ else if (format === 'csv') {
419
+ const csvReport = convertToCsv(report);
420
+ console.log(csvReport);
421
+ }
422
+ else if (json) {
423
+ console.log(JSON.stringify(report, null, 2));
424
+ }
425
+ }
426
+ return report;
427
+ }
428
+ catch (error) {
429
+ // Clean up progress indicators on error
430
+ if (progressBar) {
431
+ progressBar.stop();
432
+ }
433
+ else if (scanSpinner) {
434
+ scanSpinner.fail('Scan failed');
435
+ }
436
+ printError(error instanceof Error ? error.message : String(error));
437
+ return null;
438
+ }
439
+ finally {
440
+ await scanner.close();
441
+ }
442
+ }
443
+ /**
444
+ * Map axe-core severity to SARIF level
445
+ */
446
+ function severityToSarifLevel(severity) {
447
+ switch (severity) {
448
+ case 'critical':
449
+ case 'serious':
450
+ return 'error';
451
+ case 'moderate':
452
+ return 'warning';
453
+ case 'minor':
454
+ default:
455
+ return 'note';
456
+ }
457
+ }
458
+ /**
459
+ * Convert AllyReport to SARIF 2.1.0 format for GitHub Code Scanning integration
460
+ */
461
+ function convertToSarif(report) {
462
+ // Collect unique rules from all violations
463
+ const ruleMap = new Map();
464
+ for (const result of report.results) {
465
+ for (const violation of result.violations) {
466
+ if (!ruleMap.has(violation.id)) {
467
+ ruleMap.set(violation.id, { violation, severity: violation.impact });
468
+ }
469
+ }
470
+ }
471
+ // Build rules array
472
+ const rules = [];
473
+ const ruleIndexMap = new Map();
474
+ Array.from(ruleMap.entries()).forEach(([ruleId, { violation, severity }]) => {
475
+ ruleIndexMap.set(ruleId, rules.length);
476
+ rules.push({
477
+ id: ruleId,
478
+ name: ruleId,
479
+ shortDescription: { text: violation.help },
480
+ fullDescription: { text: violation.description },
481
+ helpUri: violation.helpUrl,
482
+ defaultConfiguration: {
483
+ level: severityToSarifLevel(severity),
484
+ },
485
+ properties: {
486
+ tags: violation.tags,
487
+ },
488
+ });
489
+ });
490
+ // Build results array
491
+ const results = [];
492
+ for (const scanResult of report.results) {
493
+ const fileUri = scanResult.file
494
+ ? relative(process.cwd(), scanResult.file)
495
+ : scanResult.url;
496
+ for (const violation of scanResult.violations) {
497
+ const ruleIndex = ruleIndexMap.get(violation.id) ?? 0;
498
+ // Create a result for each affected node
499
+ for (const node of violation.nodes) {
500
+ const locations = [
501
+ {
502
+ physicalLocation: {
503
+ artifactLocation: {
504
+ uri: fileUri,
505
+ uriBaseId: '%SRCROOT%',
506
+ },
507
+ region: {
508
+ startLine: 1, // Line info not available from axe-core
509
+ snippet: { text: node.html },
510
+ },
511
+ },
512
+ },
513
+ ];
514
+ results.push({
515
+ ruleId: violation.id,
516
+ ruleIndex,
517
+ level: severityToSarifLevel(violation.impact),
518
+ message: {
519
+ text: `${violation.help}. ${node.failureSummary}`,
520
+ },
521
+ locations,
522
+ });
523
+ }
524
+ }
525
+ }
526
+ return {
527
+ $schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
528
+ version: '2.1.0',
529
+ runs: [
530
+ {
531
+ tool: {
532
+ driver: {
533
+ name: 'ally',
534
+ version: '1.0.0',
535
+ informationUri: 'https://github.com/forbiddenlink/ally',
536
+ rules,
537
+ },
538
+ },
539
+ results,
540
+ },
541
+ ],
542
+ };
543
+ }
544
+ /**
545
+ * Escape XML special characters
546
+ */
547
+ function escapeXml(str) {
548
+ return str
549
+ .replace(/&/g, '&amp;')
550
+ .replace(/</g, '&lt;')
551
+ .replace(/>/g, '&gt;')
552
+ .replace(/"/g, '&quot;')
553
+ .replace(/'/g, '&apos;');
554
+ }
555
+ /**
556
+ * Convert AllyReport to JUnit XML format for CI/CD tools (Jenkins, GitLab, etc.)
557
+ * Each violation is represented as a test failure
558
+ */
559
+ function convertToJunit(report) {
560
+ // Count total tests (violations) and failures
561
+ let totalTests = 0;
562
+ let totalFailures = 0;
563
+ // Collect all violations with their file context
564
+ const testcases = [];
565
+ for (const result of report.results) {
566
+ const fileUri = result.file
567
+ ? relative(process.cwd(), result.file)
568
+ : result.url || 'unknown';
569
+ for (const violation of result.violations) {
570
+ for (const node of violation.nodes) {
571
+ totalTests++;
572
+ totalFailures++;
573
+ const wcagTags = violation.tags.filter(t => t.startsWith('wcag')).join(', ');
574
+ const failureMessage = escapeXml(`${violation.help}. ${node.failureSummary || ''}`);
575
+ const failureDetails = escapeXml(`File: ${fileUri}\n` +
576
+ `Selector: ${node.target.join(' > ')}\n` +
577
+ `HTML: ${node.html}\n` +
578
+ `WCAG: ${wcagTags}\n` +
579
+ `Help: ${violation.helpUrl}`);
580
+ testcases.push(` <testcase name="${escapeXml(violation.id)}" classname="${escapeXml(violation.impact)}" time="0">\n` +
581
+ ` <failure message="${failureMessage}" type="${escapeXml(violation.impact)}">\n` +
582
+ `${failureDetails}\n` +
583
+ ` </failure>\n` +
584
+ ` </testcase>`);
585
+ }
586
+ }
587
+ }
588
+ // Build the JUnit XML
589
+ const xml = [
590
+ '<?xml version="1.0" encoding="UTF-8"?>',
591
+ `<testsuites name="ally-a11y" tests="${totalTests}" failures="${totalFailures}" errors="0" time="0">`,
592
+ ` <testsuite name="accessibility" tests="${totalTests}" failures="${totalFailures}" errors="0" skipped="0" time="0">`,
593
+ ...testcases,
594
+ ' </testsuite>',
595
+ '</testsuites>',
596
+ ].join('\n');
597
+ return xml;
598
+ }
599
+ /**
600
+ * Escape a value for CSV output
601
+ */
602
+ function escapeCsv(value) {
603
+ // If value contains comma, quote, or newline, wrap in quotes and escape internal quotes
604
+ if (value.includes(',') || value.includes('"') || value.includes('\n') || value.includes('\r')) {
605
+ return '"' + value.replace(/"/g, '""') + '"';
606
+ }
607
+ return value;
608
+ }
609
+ /**
610
+ * Convert AllyReport to CSV format
611
+ * Headers: file,violation_id,impact,description,selector,wcag,help_url
612
+ */
613
+ function convertToCsv(report) {
614
+ const headers = ['file', 'violation_id', 'impact', 'description', 'selector', 'wcag', 'help_url'];
615
+ const rows = [headers.join(',')];
616
+ for (const result of report.results) {
617
+ const fileUri = result.file
618
+ ? relative(process.cwd(), result.file)
619
+ : result.url || 'unknown';
620
+ for (const violation of result.violations) {
621
+ const wcagTags = violation.tags.filter(t => t.startsWith('wcag')).join('; ');
622
+ for (const node of violation.nodes) {
623
+ const row = [
624
+ escapeCsv(fileUri),
625
+ escapeCsv(violation.id),
626
+ escapeCsv(violation.impact),
627
+ escapeCsv(violation.help),
628
+ escapeCsv(node.target.join(' > ')),
629
+ escapeCsv(wcagTags),
630
+ escapeCsv(violation.helpUrl),
631
+ ];
632
+ rows.push(row.join(','));
633
+ }
634
+ }
635
+ }
636
+ return rows.join('\n');
637
+ }
638
+ async function saveReport(report, outputDir, format, ci = false) {
639
+ try {
640
+ // Ensure output directory exists
641
+ if (!existsSync(outputDir)) {
642
+ await mkdir(outputDir, { recursive: true });
643
+ }
644
+ // Always save the JSON report
645
+ const reportPath = resolve(outputDir, 'scan.json');
646
+ await writeFile(reportPath, JSON.stringify(report, null, 2));
647
+ printSuccess(`Report saved to ${reportPath}`);
648
+ // Save format-specific reports if requested
649
+ if (format === 'sarif') {
650
+ const sarifReport = convertToSarif(report);
651
+ const sarifPath = resolve(outputDir, 'scan.sarif');
652
+ await writeFile(sarifPath, JSON.stringify(sarifReport, null, 2));
653
+ printSuccess(`SARIF report saved to ${sarifPath}`);
654
+ }
655
+ else if (format === 'junit') {
656
+ const junitReport = convertToJunit(report);
657
+ const junitPath = resolve(outputDir, 'scan.xml');
658
+ await writeFile(junitPath, junitReport);
659
+ printSuccess(`JUnit report saved to ${junitPath}`);
660
+ }
661
+ else if (format === 'csv') {
662
+ const csvReport = convertToCsv(report);
663
+ const csvPath = resolve(outputDir, 'scan.csv');
664
+ await writeFile(csvPath, csvReport);
665
+ printSuccess(`CSV report saved to ${csvPath}`);
666
+ }
667
+ // Note: History tracking is now handled by saveHistoryTracking() after createReport()
668
+ }
669
+ catch (error) {
670
+ printError(`Failed to save report: ${error instanceof Error ? error.message : String(error)}`);
671
+ }
672
+ }
673
+ export default scanCommand;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * ally stats command - Shows accessibility progress over time
3
+ */
4
+ export declare function statsCommand(): Promise<void>;
5
+ export default statsCommand;