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,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, '&')
|
|
550
|
+
.replace(/</g, '<')
|
|
551
|
+
.replace(/>/g, '>')
|
|
552
|
+
.replace(/"/g, '"')
|
|
553
|
+
.replace(/'/g, ''');
|
|
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;
|