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,606 @@
1
+ /**
2
+ * Accessibility scanner using axe-core with Puppeteer or Playwright
3
+ */
4
+ import puppeteer from 'puppeteer';
5
+ import { AxePuppeteer } from '@axe-core/puppeteer';
6
+ import axe from 'axe-core';
7
+ import { glob } from 'glob';
8
+ import { resolve, extname } from 'path';
9
+ import { stat } from 'fs/promises';
10
+ import { createBrowser, } from './browser.js';
11
+ const SUPPORTED_EXTENSIONS = ['.html', '.htm'];
12
+ const COMPONENT_EXTENSIONS = ['.jsx', '.tsx', '.vue', '.svelte'];
13
+ // Map standard names to axe-core tags
14
+ export const standardToTags = {
15
+ 'wcag2a': ['wcag2a'],
16
+ 'wcag2aa': ['wcag2a', 'wcag2aa'],
17
+ 'wcag2aaa': ['wcag2a', 'wcag2aa', 'wcag2aaa'],
18
+ 'wcag21a': ['wcag2a', 'wcag21a'],
19
+ 'wcag21aa': ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'],
20
+ 'wcag21aaa': ['wcag2a', 'wcag2aa', 'wcag2aaa', 'wcag21a', 'wcag21aa', 'wcag21aaa'],
21
+ 'wcag22aa': ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'],
22
+ 'section508': ['section508'],
23
+ 'best-practice': ['best-practice'],
24
+ };
25
+ // Default standard
26
+ export const DEFAULT_STANDARD = 'wcag22aa';
27
+ // SVG filter matrices for color blindness simulation
28
+ // These use feColorMatrix values adapted for CSS filter
29
+ const COLOR_BLINDNESS_FILTERS = {
30
+ // Protanopia (red-blind)
31
+ protanopia: `
32
+ <svg xmlns="http://www.w3.org/2000/svg">
33
+ <filter id="protanopia">
34
+ <feColorMatrix type="matrix" values="
35
+ 0.567 0.433 0 0 0
36
+ 0.558 0.442 0 0 0
37
+ 0 0.242 0.758 0 0
38
+ 0 0 0 1 0
39
+ "/>
40
+ </filter>
41
+ </svg>
42
+ `,
43
+ // Deuteranopia (green-blind)
44
+ deuteranopia: `
45
+ <svg xmlns="http://www.w3.org/2000/svg">
46
+ <filter id="deuteranopia">
47
+ <feColorMatrix type="matrix" values="
48
+ 0.625 0.375 0 0 0
49
+ 0.7 0.3 0 0 0
50
+ 0 0.3 0.7 0 0
51
+ 0 0 0 1 0
52
+ "/>
53
+ </filter>
54
+ </svg>
55
+ `,
56
+ // Tritanopia (blue-blind)
57
+ tritanopia: `
58
+ <svg xmlns="http://www.w3.org/2000/svg">
59
+ <filter id="tritanopia">
60
+ <feColorMatrix type="matrix" values="
61
+ 0.95 0.05 0 0 0
62
+ 0 0.433 0.567 0 0
63
+ 0 0.475 0.525 0 0
64
+ 0 0 0 1 0
65
+ "/>
66
+ </filter>
67
+ </svg>
68
+ `,
69
+ };
70
+ /** Default page load timeout in milliseconds */
71
+ export const DEFAULT_TIMEOUT = 30000;
72
+ /** Default batch size for parallel scanning */
73
+ export const DEFAULT_BATCH_SIZE = 4;
74
+ /**
75
+ * Split an array into chunks of a specified size
76
+ */
77
+ function chunk(arr, size) {
78
+ return Array.from({ length: Math.ceil(arr.length / size) }, (_, i) => arr.slice(i * size, i * size + size));
79
+ }
80
+ /**
81
+ * Run axe-core analysis on a Playwright page by injecting axe-core source
82
+ */
83
+ async function runAxeOnPlaywrightPage(page, tags) {
84
+ // Get axe-core source code
85
+ const axeSource = axe.source;
86
+ // Get underlying page for direct evaluate access
87
+ const underlyingPage = page.getUnderlyingPage();
88
+ // Inject axe-core source
89
+ await underlyingPage.evaluate(axeSource);
90
+ // Run axe with tags - pass tags as a single argument object
91
+ const axeResults = await underlyingPage.evaluate((runTags) => {
92
+ const tagsArray = runTags;
93
+ return window.axe.run({
94
+ runOnly: {
95
+ type: 'tag',
96
+ values: tagsArray,
97
+ },
98
+ });
99
+ }, tags);
100
+ return axeResults;
101
+ }
102
+ /**
103
+ * Map raw axe violations to our Violation format
104
+ * Shared by both Puppeteer and Playwright code paths
105
+ * Handles axe-core Result type (which may have null impact)
106
+ */
107
+ function mapAxeViolations(axeViolations) {
108
+ return axeViolations.map((v) => ({
109
+ id: v.id,
110
+ impact: (v.impact || 'minor'),
111
+ description: v.description,
112
+ help: v.help,
113
+ helpUrl: v.helpUrl,
114
+ tags: v.tags,
115
+ nodes: v.nodes.map((n) => ({
116
+ html: n.html,
117
+ target: n.target.map(t => typeof t === 'string' ? t : t.join(' ')),
118
+ failureSummary: n.failureSummary || '',
119
+ })),
120
+ }));
121
+ }
122
+ /**
123
+ * Convert axe results to our Violation format
124
+ */
125
+ function convertAxeResults(results) {
126
+ const violations = mapAxeViolations(results.violations);
127
+ return {
128
+ violations,
129
+ passes: results.passes.length,
130
+ incomplete: results.incomplete.length,
131
+ };
132
+ }
133
+ export class AccessibilityScanner {
134
+ browser = null;
135
+ browserAdapter = null;
136
+ timeout;
137
+ browserType;
138
+ usePlaywright;
139
+ constructor(timeout = DEFAULT_TIMEOUT, browserType = 'chromium') {
140
+ this.timeout = timeout;
141
+ this.browserType = browserType;
142
+ // Use Playwright for Firefox and WebKit, Puppeteer for Chromium (default)
143
+ this.usePlaywright = browserType !== 'chromium';
144
+ }
145
+ async init() {
146
+ if (this.usePlaywright) {
147
+ // Use browser abstraction for Playwright browsers
148
+ this.browserAdapter = createBrowser(this.browserType);
149
+ await this.browserAdapter.launch();
150
+ }
151
+ else {
152
+ // Use Puppeteer directly for Chromium (faster, always available)
153
+ this.browser = await puppeteer.launch({
154
+ headless: true,
155
+ args: ['--no-sandbox', '--disable-setuid-sandbox'],
156
+ });
157
+ }
158
+ }
159
+ async close() {
160
+ if (this.browserAdapter) {
161
+ await this.browserAdapter.close();
162
+ this.browserAdapter = null;
163
+ }
164
+ if (this.browser) {
165
+ await this.browser.close();
166
+ this.browser = null;
167
+ }
168
+ }
169
+ /**
170
+ * Get the browser type being used
171
+ */
172
+ getBrowserType() {
173
+ return this.browserType;
174
+ }
175
+ async scanHtmlFile(filePath, standard = DEFAULT_STANDARD) {
176
+ const absolutePath = resolve(filePath);
177
+ const fileUrl = `file://${absolutePath}`;
178
+ const tags = standardToTags[standard];
179
+ if (this.usePlaywright && this.browserAdapter) {
180
+ // Playwright path
181
+ const page = await this.browserAdapter.newPage();
182
+ try {
183
+ await page.goto(fileUrl, { waitUntil: 'load', timeout: this.timeout });
184
+ await page.waitForSelector('body');
185
+ const results = await runAxeOnPlaywrightPage(page, tags);
186
+ const { violations, passes, incomplete } = convertAxeResults(results);
187
+ return {
188
+ url: fileUrl,
189
+ file: filePath,
190
+ timestamp: new Date().toISOString(),
191
+ violations,
192
+ passes,
193
+ incomplete,
194
+ };
195
+ }
196
+ finally {
197
+ await page.close();
198
+ }
199
+ }
200
+ else if (this.browser) {
201
+ // Puppeteer path
202
+ const page = await this.browser.newPage();
203
+ try {
204
+ await page.goto(fileUrl, { waitUntil: 'load', timeout: this.timeout });
205
+ await page.waitForSelector('body');
206
+ const results = await new AxePuppeteer(page)
207
+ .withTags(tags)
208
+ .analyze();
209
+ const violations = mapAxeViolations(results.violations);
210
+ return {
211
+ url: fileUrl,
212
+ file: filePath,
213
+ timestamp: new Date().toISOString(),
214
+ violations,
215
+ passes: results.passes.length,
216
+ incomplete: results.incomplete.length,
217
+ };
218
+ }
219
+ finally {
220
+ await page.close();
221
+ }
222
+ }
223
+ else {
224
+ throw new Error('Scanner not initialized. Call init() first.');
225
+ }
226
+ }
227
+ async scanUrl(url, standard = DEFAULT_STANDARD) {
228
+ const tags = standardToTags[standard];
229
+ if (this.usePlaywright && this.browserAdapter) {
230
+ // Playwright path
231
+ const page = await this.browserAdapter.newPage();
232
+ try {
233
+ await page.goto(url, { waitUntil: 'networkidle', timeout: this.timeout });
234
+ const results = await runAxeOnPlaywrightPage(page, tags);
235
+ const { violations, passes, incomplete } = convertAxeResults(results);
236
+ return {
237
+ url,
238
+ timestamp: new Date().toISOString(),
239
+ violations,
240
+ passes,
241
+ incomplete,
242
+ };
243
+ }
244
+ finally {
245
+ await page.close();
246
+ }
247
+ }
248
+ else if (this.browser) {
249
+ // Puppeteer path
250
+ const page = await this.browser.newPage();
251
+ try {
252
+ await page.goto(url, { waitUntil: 'networkidle2', timeout: this.timeout });
253
+ const results = await new AxePuppeteer(page)
254
+ .withTags(tags)
255
+ .analyze();
256
+ const violations = mapAxeViolations(results.violations);
257
+ return {
258
+ url,
259
+ timestamp: new Date().toISOString(),
260
+ violations,
261
+ passes: results.passes.length,
262
+ incomplete: results.incomplete.length,
263
+ };
264
+ }
265
+ finally {
266
+ await page.close();
267
+ }
268
+ }
269
+ else {
270
+ throw new Error('Scanner not initialized. Call init() first.');
271
+ }
272
+ }
273
+ async scanHtmlString(html, identifier = 'inline', standard = DEFAULT_STANDARD) {
274
+ const tags = standardToTags[standard];
275
+ if (this.usePlaywright && this.browserAdapter) {
276
+ // Playwright path
277
+ const page = await this.browserAdapter.newPage();
278
+ try {
279
+ await page.setContent(html, { waitUntil: 'domcontentloaded' });
280
+ const results = await runAxeOnPlaywrightPage(page, tags);
281
+ const { violations, passes, incomplete } = convertAxeResults(results);
282
+ return {
283
+ url: identifier,
284
+ file: identifier,
285
+ timestamp: new Date().toISOString(),
286
+ violations,
287
+ passes,
288
+ incomplete,
289
+ };
290
+ }
291
+ finally {
292
+ await page.close();
293
+ }
294
+ }
295
+ else if (this.browser) {
296
+ // Puppeteer path
297
+ const page = await this.browser.newPage();
298
+ try {
299
+ await page.setContent(html, { waitUntil: 'domcontentloaded' });
300
+ const results = await new AxePuppeteer(page)
301
+ .withTags(tags)
302
+ .analyze();
303
+ const violations = mapAxeViolations(results.violations);
304
+ return {
305
+ url: identifier,
306
+ file: identifier,
307
+ timestamp: new Date().toISOString(),
308
+ violations,
309
+ passes: results.passes.length,
310
+ incomplete: results.incomplete.length,
311
+ };
312
+ }
313
+ finally {
314
+ await page.close();
315
+ }
316
+ }
317
+ else {
318
+ throw new Error('Scanner not initialized. Call init() first.');
319
+ }
320
+ }
321
+ /**
322
+ * Scan multiple HTML files in parallel batches for improved performance
323
+ * @param files Array of file paths to scan
324
+ * @param standard WCAG standard to use for scanning
325
+ * @param batchSize Number of files to scan concurrently (default: 4)
326
+ * @param onProgress Optional callback for progress updates
327
+ * @returns Array of scan results (successful) and errors (failed)
328
+ */
329
+ async scanHtmlFilesParallel(files, standard = DEFAULT_STANDARD, batchSize = DEFAULT_BATCH_SIZE, onProgress) {
330
+ if (!this.browser && !this.browserAdapter) {
331
+ throw new Error('Scanner not initialized. Call init() first.');
332
+ }
333
+ const results = [];
334
+ const errors = [];
335
+ const batches = chunk(files, batchSize);
336
+ let completedCount = 0;
337
+ for (const batch of batches) {
338
+ // Scan files in this batch concurrently
339
+ const batchPromises = batch.map(async (file) => {
340
+ try {
341
+ const result = await this.scanHtmlFile(file, standard);
342
+ return { result };
343
+ }
344
+ catch (error) {
345
+ return {
346
+ error: {
347
+ path: file,
348
+ error: error instanceof Error ? error.message : String(error),
349
+ },
350
+ };
351
+ }
352
+ });
353
+ // Wait for all files in the batch to complete
354
+ const batchResults = await Promise.all(batchPromises);
355
+ // Aggregate results and errors
356
+ for (const item of batchResults) {
357
+ if (item.result) {
358
+ results.push(item.result);
359
+ }
360
+ else if (item.error) {
361
+ errors.push(item.error);
362
+ }
363
+ completedCount++;
364
+ // Report progress after each file completes
365
+ if (onProgress) {
366
+ onProgress(completedCount, files.length, item.result?.file || item.error?.path);
367
+ }
368
+ }
369
+ }
370
+ return { results, errors };
371
+ }
372
+ /**
373
+ * Extract all anchor links from a URL
374
+ * This method provides type-safe access to browser pages for link extraction
375
+ */
376
+ async extractLinks(url) {
377
+ if (this.usePlaywright && this.browserAdapter) {
378
+ const page = await this.browserAdapter.newPage();
379
+ try {
380
+ await page.goto(url, { waitUntil: 'networkidle', timeout: this.timeout });
381
+ const underlyingPage = page.getUnderlyingPage();
382
+ return await underlyingPage.evaluate(() => {
383
+ return Array.from(document.querySelectorAll('a[href]'))
384
+ .map(a => a.href)
385
+ .filter(href => href && !href.startsWith('javascript:'));
386
+ });
387
+ }
388
+ finally {
389
+ await page.close();
390
+ }
391
+ }
392
+ else if (this.browser) {
393
+ const page = await this.browser.newPage();
394
+ try {
395
+ await page.goto(url, { waitUntil: 'networkidle2', timeout: this.timeout });
396
+ return await page.evaluate(() => {
397
+ return Array.from(document.querySelectorAll('a[href]'))
398
+ .map(a => a.href)
399
+ .filter(href => href && !href.startsWith('javascript:'));
400
+ });
401
+ }
402
+ finally {
403
+ await page.close();
404
+ }
405
+ }
406
+ return [];
407
+ }
408
+ /**
409
+ * Take a screenshot of a URL with color blindness simulation applied
410
+ */
411
+ async simulateColorBlindness(url, type, outputPath) {
412
+ if (this.usePlaywright && this.browserAdapter) {
413
+ // Playwright path
414
+ const page = await this.browserAdapter.newPage();
415
+ try {
416
+ await page.setViewport({ width: 1280, height: 800 });
417
+ await page.goto(url, { waitUntil: 'networkidle', timeout: this.timeout });
418
+ const svgFilter = COLOR_BLINDNESS_FILTERS[type];
419
+ await page.addStyleTag({
420
+ content: `
421
+ html::before {
422
+ content: '';
423
+ position: fixed;
424
+ top: 0;
425
+ left: 0;
426
+ width: 0;
427
+ height: 0;
428
+ background-image: url('data:image/svg+xml,${encodeURIComponent(svgFilter.trim())}');
429
+ }
430
+ html {
431
+ filter: url('data:image/svg+xml,${encodeURIComponent(svgFilter.trim())}#${type}');
432
+ }
433
+ `,
434
+ });
435
+ await new Promise(resolve => setTimeout(resolve, 100));
436
+ await page.screenshot({ path: outputPath, fullPage: true });
437
+ }
438
+ finally {
439
+ await page.close();
440
+ }
441
+ }
442
+ else if (this.browser) {
443
+ // Puppeteer path
444
+ const page = await this.browser.newPage();
445
+ try {
446
+ await page.setViewport({ width: 1280, height: 800 });
447
+ await page.goto(url, { waitUntil: 'networkidle2', timeout: this.timeout });
448
+ const svgFilter = COLOR_BLINDNESS_FILTERS[type];
449
+ await page.addStyleTag({
450
+ content: `
451
+ html::before {
452
+ content: '';
453
+ position: fixed;
454
+ top: 0;
455
+ left: 0;
456
+ width: 0;
457
+ height: 0;
458
+ background-image: url('data:image/svg+xml,${encodeURIComponent(svgFilter.trim())}');
459
+ }
460
+ html {
461
+ filter: url('data:image/svg+xml,${encodeURIComponent(svgFilter.trim())}#${type}');
462
+ }
463
+ `,
464
+ });
465
+ await new Promise(resolve => setTimeout(resolve, 100));
466
+ await page.screenshot({ path: outputPath, fullPage: true });
467
+ }
468
+ finally {
469
+ await page.close();
470
+ }
471
+ }
472
+ else {
473
+ throw new Error('Scanner not initialized. Call init() first.');
474
+ }
475
+ }
476
+ }
477
+ /** Default ignore patterns applied to all scans */
478
+ const DEFAULT_IGNORE = ['**/node_modules/**', '**/dist/**', '**/build/**', '**/.git/**'];
479
+ export async function findHtmlFiles(targetPath, extraIgnore = []) {
480
+ // Check if targetPath is a file (not a directory)
481
+ try {
482
+ const stats = await stat(targetPath);
483
+ if (stats.isFile()) {
484
+ // If it's a file with a supported extension, return it directly
485
+ const ext = extname(targetPath).toLowerCase();
486
+ if (SUPPORTED_EXTENSIONS.includes(ext)) {
487
+ return [resolve(targetPath)];
488
+ }
489
+ // File exists but not a supported extension
490
+ return [];
491
+ }
492
+ }
493
+ catch {
494
+ // Path doesn't exist or can't be accessed, fall through to glob
495
+ }
496
+ // It's a directory, use glob to find files
497
+ const patterns = SUPPORTED_EXTENSIONS.map((ext) => `**/*${ext}`);
498
+ const files = await glob(patterns, {
499
+ cwd: targetPath,
500
+ ignore: [...DEFAULT_IGNORE, ...extraIgnore],
501
+ absolute: true,
502
+ });
503
+ return files;
504
+ }
505
+ export async function findComponentFiles(targetPath, extraIgnore = []) {
506
+ // Check if targetPath is a file (not a directory)
507
+ try {
508
+ const stats = await stat(targetPath);
509
+ if (stats.isFile()) {
510
+ // If it's a file with a supported extension, return it directly
511
+ const ext = extname(targetPath).toLowerCase();
512
+ if (COMPONENT_EXTENSIONS.includes(ext)) {
513
+ return [resolve(targetPath)];
514
+ }
515
+ // File exists but not a supported extension
516
+ return [];
517
+ }
518
+ }
519
+ catch {
520
+ // Path doesn't exist or can't be accessed, fall through to glob
521
+ }
522
+ // It's a directory, use glob to find files
523
+ const patterns = COMPONENT_EXTENSIONS.map((ext) => `**/*${ext}`);
524
+ const files = await glob(patterns, {
525
+ cwd: targetPath,
526
+ ignore: [...DEFAULT_IGNORE, ...extraIgnore],
527
+ absolute: true,
528
+ });
529
+ return files;
530
+ }
531
+ export function calculateScore(results) {
532
+ if (results.length === 0)
533
+ return 100;
534
+ const weights = {
535
+ critical: 25,
536
+ serious: 15,
537
+ moderate: 5,
538
+ minor: 1,
539
+ };
540
+ let totalPenalty = 0;
541
+ let totalPasses = 0;
542
+ for (const result of results) {
543
+ totalPasses += result.passes;
544
+ for (const violation of result.violations) {
545
+ const weight = weights[violation.impact] || 1;
546
+ const nodeCount = Math.min(violation.nodes.length, 10); // Cap at 10 to avoid extreme penalties
547
+ totalPenalty += weight * nodeCount;
548
+ }
549
+ }
550
+ // Score starts at 100 and decreases based on violations
551
+ // Maximum penalty is capped at 100
552
+ const score = Math.max(0, 100 - Math.min(totalPenalty, 100));
553
+ return Math.round(score);
554
+ }
555
+ export function generateSummary(results) {
556
+ const bySeverity = {
557
+ critical: 0,
558
+ serious: 0,
559
+ moderate: 0,
560
+ minor: 0,
561
+ };
562
+ const issueCount = new Map();
563
+ let totalViolations = 0;
564
+ for (const result of results) {
565
+ for (const violation of result.violations) {
566
+ totalViolations++;
567
+ bySeverity[violation.impact] = (bySeverity[violation.impact] || 0) + 1;
568
+ const existing = issueCount.get(violation.id);
569
+ if (existing) {
570
+ existing.count++;
571
+ }
572
+ else {
573
+ issueCount.set(violation.id, {
574
+ count: 1,
575
+ description: violation.help,
576
+ severity: violation.impact,
577
+ });
578
+ }
579
+ }
580
+ }
581
+ // Sort by count and take top 5
582
+ const topIssues = Array.from(issueCount.entries())
583
+ .sort((a, b) => b[1].count - a[1].count)
584
+ .slice(0, 5)
585
+ .map(([id, data]) => ({
586
+ id,
587
+ count: data.count,
588
+ description: data.description,
589
+ severity: data.severity,
590
+ }));
591
+ return {
592
+ totalViolations,
593
+ bySeverity,
594
+ score: calculateScore(results),
595
+ topIssues,
596
+ };
597
+ }
598
+ export function createReport(results) {
599
+ return {
600
+ version: '1.0.0',
601
+ scanDate: new Date().toISOString(),
602
+ totalFiles: results.length,
603
+ results,
604
+ summary: generateSummary(results),
605
+ };
606
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Tests for the accessibility scanner
3
+ */
4
+ export {};