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,402 @@
1
+ /**
2
+ * ally scan-storybook command - Scans Storybook stories for accessibility issues
3
+ */
4
+ import { resolve } from 'path';
5
+ import { mkdir, writeFile } from 'fs/promises';
6
+ import { existsSync } from 'fs';
7
+ import chalk from 'chalk';
8
+ import { AccessibilityScanner, createReport, DEFAULT_STANDARD, } from '../utils/scanner.js';
9
+ import { printBanner, createSpinner, printSuccess, printError, printInfo, printWarning, printSummary, } from '../utils/ui.js';
10
+ import { withRetry } from '../utils/retry.js';
11
+ /**
12
+ * Fetch the Storybook stories index
13
+ * Tries Storybook 7+ format first, then falls back to Storybook 6
14
+ */
15
+ async function fetchStoriesIndex(storybookUrl) {
16
+ const baseUrl = storybookUrl.replace(/\/$/, '');
17
+ // Try Storybook 7+ index.json first
18
+ try {
19
+ const indexResponse = await fetch(`${baseUrl}/index.json`);
20
+ if (indexResponse.ok) {
21
+ const index = await indexResponse.json();
22
+ const entries = Object.values(index.entries)
23
+ .filter(entry => entry.type === 'story'); // Filter out docs
24
+ return entries;
25
+ }
26
+ }
27
+ catch {
28
+ // Fall through to try stories.json
29
+ }
30
+ // Try Storybook 6 stories.json
31
+ try {
32
+ const storiesResponse = await fetch(`${baseUrl}/stories.json`);
33
+ if (storiesResponse.ok) {
34
+ const storiesJson = await storiesResponse.json();
35
+ // Convert to common format
36
+ return Object.values(storiesJson.stories).map(story => ({
37
+ id: story.id,
38
+ title: story.title || story.kind,
39
+ name: story.name || story.story,
40
+ type: 'story',
41
+ }));
42
+ }
43
+ }
44
+ catch {
45
+ // Fall through to error
46
+ }
47
+ throw new Error(`Could not fetch Storybook stories index from ${baseUrl}. ` +
48
+ `Tried /index.json (Storybook 7+) and /stories.json (Storybook 6). ` +
49
+ `Make sure Storybook is running and accessible.`);
50
+ }
51
+ /**
52
+ * Build the iframe URL for a story
53
+ */
54
+ function getStoryIframeUrl(storybookUrl, storyId) {
55
+ const baseUrl = storybookUrl.replace(/\/$/, '');
56
+ return `${baseUrl}/iframe.html?viewMode=story&id=${encodeURIComponent(storyId)}`;
57
+ }
58
+ /**
59
+ * Group stories by component title
60
+ */
61
+ function groupStoriesByComponent(stories) {
62
+ const groups = new Map();
63
+ for (const story of stories) {
64
+ const existing = groups.get(story.title) || [];
65
+ existing.push(story);
66
+ groups.set(story.title, existing);
67
+ }
68
+ return groups;
69
+ }
70
+ /**
71
+ * Filter stories by pattern (matches against component title or story name)
72
+ */
73
+ function filterStories(stories, pattern) {
74
+ const regex = new RegExp(pattern, 'i');
75
+ return stories.filter(story => regex.test(story.title) || regex.test(story.name) || regex.test(story.id));
76
+ }
77
+ /**
78
+ * Count errors and warnings from a scan result
79
+ */
80
+ function countIssues(result) {
81
+ if (!result) {
82
+ return { errors: 0, warnings: 0 };
83
+ }
84
+ let errors = 0;
85
+ let warnings = 0;
86
+ for (const violation of result.violations) {
87
+ if (violation.impact === 'critical' || violation.impact === 'serious') {
88
+ errors++;
89
+ }
90
+ else {
91
+ warnings++;
92
+ }
93
+ }
94
+ return { errors, warnings };
95
+ }
96
+ /**
97
+ * Get status icon for a story result
98
+ */
99
+ function getStatusIcon(errors, warnings) {
100
+ if (errors > 0)
101
+ return chalk.red('X');
102
+ if (warnings > 0)
103
+ return chalk.yellow('!');
104
+ return chalk.green('v');
105
+ }
106
+ /**
107
+ * Format a single story result line
108
+ */
109
+ function formatStoryLine(storyName, errors, warnings, isLast, issueDetails) {
110
+ const prefix = isLast ? '`-- ' : '|-- ';
111
+ const icon = getStatusIcon(errors, warnings);
112
+ let status;
113
+ if (errors > 0) {
114
+ const details = issueDetails ? ` (${issueDetails})` : '';
115
+ status = chalk.red(`${errors} error${errors !== 1 ? 's' : ''}${details}`);
116
+ }
117
+ else if (warnings > 0) {
118
+ const details = issueDetails ? ` (${issueDetails})` : '';
119
+ status = chalk.yellow(`${warnings} warning${warnings !== 1 ? 's' : ''}${details}`);
120
+ }
121
+ else {
122
+ status = chalk.green('0 issues');
123
+ }
124
+ return ` ${prefix}${storyName}: ${icon} ${status}`;
125
+ }
126
+ /**
127
+ * Get the first issue ID from violations for display
128
+ */
129
+ function getFirstIssueId(result) {
130
+ if (!result || result.violations.length === 0)
131
+ return undefined;
132
+ return result.violations[0].id;
133
+ }
134
+ /**
135
+ * Print tree-style component results
136
+ */
137
+ function printComponentTree(componentResults) {
138
+ console.log(chalk.bold('\nComponent Results:'));
139
+ for (let i = 0; i < componentResults.length; i++) {
140
+ const component = componentResults[i];
141
+ const isLastComponent = i === componentResults.length - 1;
142
+ const componentPrefix = isLastComponent ? '`-- ' : '|-- ';
143
+ // Component header
144
+ console.log(chalk.cyan.bold(`${componentPrefix}${component.title}`));
145
+ // Stories
146
+ for (let j = 0; j < component.stories.length; j++) {
147
+ const story = component.stories[j];
148
+ const isLastStory = j === component.stories.length - 1;
149
+ const { errors, warnings } = countIssues(story.scanResult);
150
+ const issueId = getFirstIssueId(story.scanResult);
151
+ console.log(formatStoryLine(story.storyName, errors, warnings, isLastStory, issueId));
152
+ }
153
+ }
154
+ }
155
+ /**
156
+ * Print summary statistics
157
+ */
158
+ function printStorybookSummary(componentResults, totalStories, passingStories, totalErrors, totalWarnings) {
159
+ const withIssues = totalStories - passingStories;
160
+ const passingPercent = totalStories > 0 ? Math.round((passingStories / totalStories) * 100) : 0;
161
+ const issuesPercent = totalStories > 0 ? Math.round((withIssues / totalStories) * 100) : 0;
162
+ console.log(chalk.bold('\nSummary:'));
163
+ console.log(` Components: ${componentResults.length}`);
164
+ console.log(` Stories: ${totalStories}`);
165
+ console.log(` Passing: ${chalk.green(passingStories.toString())} (${passingPercent}%)`);
166
+ console.log(` With issues: ${chalk[withIssues > 0 ? 'yellow' : 'green'](withIssues.toString())} (${issuesPercent}%)`);
167
+ console.log();
168
+ console.log(`Total: ${chalk.red(`${totalErrors} error${totalErrors !== 1 ? 's' : ''}`)}, ${chalk.yellow(`${totalWarnings} warning${totalWarnings !== 1 ? 's' : ''}`)}`);
169
+ }
170
+ /**
171
+ * Convert results to CSV format
172
+ */
173
+ function convertToCsv(componentResults) {
174
+ const headers = ['component', 'story', 'story_id', 'errors', 'warnings', 'issues'];
175
+ const rows = [headers.join(',')];
176
+ for (const component of componentResults) {
177
+ for (const story of component.stories) {
178
+ const { errors, warnings } = countIssues(story.scanResult);
179
+ const issues = story.scanResult?.violations.map(v => v.id).join('; ') || '';
180
+ const row = [
181
+ escapeCsv(component.title),
182
+ escapeCsv(story.storyName),
183
+ escapeCsv(story.storyId),
184
+ errors.toString(),
185
+ warnings.toString(),
186
+ escapeCsv(issues),
187
+ ];
188
+ rows.push(row.join(','));
189
+ }
190
+ }
191
+ return rows.join('\n');
192
+ }
193
+ /**
194
+ * Escape a value for CSV output
195
+ */
196
+ function escapeCsv(value) {
197
+ if (value.includes(',') || value.includes('"') || value.includes('\n') || value.includes('\r')) {
198
+ return '"' + value.replace(/"/g, '""') + '"';
199
+ }
200
+ return value;
201
+ }
202
+ /**
203
+ * Main scan-storybook command
204
+ */
205
+ export async function scanStorybookCommand(options = {}) {
206
+ printBanner();
207
+ const { url = 'http://localhost:6006', timeout = 10000, filter, format = 'default', output = '.ally', standard = DEFAULT_STANDARD, } = options;
208
+ console.log(chalk.cyan(`Scanning Storybook at ${url}...`));
209
+ console.log();
210
+ // Fetch stories index
211
+ const fetchSpinner = createSpinner('Fetching stories index...');
212
+ fetchSpinner.start();
213
+ let stories;
214
+ try {
215
+ stories = await fetchStoriesIndex(url);
216
+ fetchSpinner.succeed(`Found ${stories.length} stories`);
217
+ }
218
+ catch (error) {
219
+ fetchSpinner.fail('Failed to fetch stories index');
220
+ printError(error instanceof Error ? error.message : String(error));
221
+ return null;
222
+ }
223
+ // Apply filter if provided
224
+ if (filter) {
225
+ const originalCount = stories.length;
226
+ stories = filterStories(stories, filter);
227
+ printInfo(`Filtered to ${stories.length} stories matching "${filter}" (from ${originalCount})`);
228
+ }
229
+ if (stories.length === 0) {
230
+ printWarning('No stories found to scan');
231
+ return null;
232
+ }
233
+ // Group by component
234
+ const groupedStories = groupStoriesByComponent(stories);
235
+ // Initialize scanner
236
+ const scanner = new AccessibilityScanner(timeout);
237
+ const componentResults = [];
238
+ const allScanResults = [];
239
+ try {
240
+ await scanner.init();
241
+ let scannedCount = 0;
242
+ const totalStories = stories.length;
243
+ // Scan each component's stories
244
+ for (const [componentTitle, componentStories] of groupedStories) {
245
+ const storyResults = [];
246
+ for (const story of componentStories) {
247
+ scannedCount++;
248
+ const storyUrl = getStoryIframeUrl(url, story.id);
249
+ const scanSpinner = createSpinner(`[${scannedCount}/${totalStories}] Scanning ${componentTitle}/${story.name}...`);
250
+ scanSpinner.start();
251
+ try {
252
+ // Scan with retry for transient errors
253
+ const result = await withRetry(() => scanner.scanUrl(storyUrl, standard), {
254
+ maxRetries: 2,
255
+ baseDelayMs: 500,
256
+ onRetry: (attempt, error, delayMs) => {
257
+ scanSpinner.stop();
258
+ printWarning(`Retry ${attempt}/2 for ${story.name} after ${delayMs / 1000}s (${error.message})`);
259
+ scanSpinner.start();
260
+ },
261
+ });
262
+ // Tag the result with story metadata
263
+ result.url = storyUrl;
264
+ result.file = `${componentTitle}/${story.name}`;
265
+ storyResults.push({
266
+ storyId: story.id,
267
+ storyName: story.name,
268
+ componentTitle: componentTitle,
269
+ scanResult: result,
270
+ });
271
+ allScanResults.push(result);
272
+ const { errors, warnings } = countIssues(result);
273
+ if (errors > 0) {
274
+ scanSpinner.fail(`${componentTitle}/${story.name}: ${errors} errors, ${warnings} warnings`);
275
+ }
276
+ else if (warnings > 0) {
277
+ scanSpinner.warn(`${componentTitle}/${story.name}: ${warnings} warnings`);
278
+ }
279
+ else {
280
+ scanSpinner.succeed(`${componentTitle}/${story.name}: No issues`);
281
+ }
282
+ }
283
+ catch (error) {
284
+ scanSpinner.fail(`${componentTitle}/${story.name}: Failed to scan`);
285
+ const errorMessage = error instanceof Error ? error.message : String(error);
286
+ printError(` ${errorMessage}`);
287
+ storyResults.push({
288
+ storyId: story.id,
289
+ storyName: story.name,
290
+ componentTitle: componentTitle,
291
+ scanResult: null,
292
+ error: errorMessage,
293
+ });
294
+ }
295
+ }
296
+ // Calculate component totals
297
+ let componentErrors = 0;
298
+ let componentWarnings = 0;
299
+ for (const storyResult of storyResults) {
300
+ const { errors, warnings } = countIssues(storyResult.scanResult);
301
+ componentErrors += errors;
302
+ componentWarnings += warnings;
303
+ }
304
+ componentResults.push({
305
+ title: componentTitle,
306
+ stories: storyResults,
307
+ totalErrors: componentErrors,
308
+ totalWarnings: componentWarnings,
309
+ });
310
+ }
311
+ // Calculate overall totals
312
+ let totalErrors = 0;
313
+ let totalWarnings = 0;
314
+ let passingStories = 0;
315
+ for (const component of componentResults) {
316
+ totalErrors += component.totalErrors;
317
+ totalWarnings += component.totalWarnings;
318
+ for (const story of component.stories) {
319
+ const { errors, warnings } = countIssues(story.scanResult);
320
+ if (errors === 0 && warnings === 0 && story.scanResult) {
321
+ passingStories++;
322
+ }
323
+ }
324
+ }
325
+ // Output results based on format
326
+ if (format === 'json') {
327
+ const report = createReport(allScanResults);
328
+ console.log(JSON.stringify(report, null, 2));
329
+ return report;
330
+ }
331
+ else if (format === 'csv') {
332
+ const csvOutput = convertToCsv(componentResults);
333
+ console.log(csvOutput);
334
+ // Still create and return the report
335
+ const report = createReport(allScanResults);
336
+ return report;
337
+ }
338
+ else {
339
+ // Default tree format
340
+ printComponentTree(componentResults);
341
+ printStorybookSummary(componentResults, stories.length, passingStories, totalErrors, totalWarnings);
342
+ // Create and save the report
343
+ const report = createReport(allScanResults);
344
+ // Print the standard summary box
345
+ if (allScanResults.length > 0) {
346
+ printSummary(report.summary);
347
+ }
348
+ // Save report
349
+ await saveReport(report, output, componentResults);
350
+ return report;
351
+ }
352
+ }
353
+ catch (error) {
354
+ printError(`Scan failed: ${error instanceof Error ? error.message : String(error)}`);
355
+ return null;
356
+ }
357
+ finally {
358
+ await scanner.close();
359
+ }
360
+ }
361
+ /**
362
+ * Save the scan report to disk
363
+ */
364
+ async function saveReport(report, outputDir, componentResults) {
365
+ try {
366
+ // Ensure output directory exists
367
+ if (!existsSync(outputDir)) {
368
+ await mkdir(outputDir, { recursive: true });
369
+ }
370
+ // Save standard JSON report
371
+ const reportPath = resolve(outputDir, 'storybook-scan.json');
372
+ await writeFile(reportPath, JSON.stringify(report, null, 2));
373
+ printSuccess(`Report saved to ${reportPath}`);
374
+ // Also save as scan.json for compatibility with other commands
375
+ const defaultPath = resolve(outputDir, 'scan.json');
376
+ await writeFile(defaultPath, JSON.stringify(report, null, 2));
377
+ printInfo(`Also saved to ${defaultPath} for use with other ally commands`);
378
+ // Save component summary
379
+ const summaryPath = resolve(outputDir, 'storybook-summary.json');
380
+ const summary = {
381
+ scanDate: report.scanDate,
382
+ components: componentResults.map(c => ({
383
+ title: c.title,
384
+ stories: c.stories.map(s => ({
385
+ id: s.storyId,
386
+ name: s.storyName,
387
+ errors: countIssues(s.scanResult).errors,
388
+ warnings: countIssues(s.scanResult).warnings,
389
+ issues: s.scanResult?.violations.map(v => v.id) || [],
390
+ })),
391
+ totalErrors: c.totalErrors,
392
+ totalWarnings: c.totalWarnings,
393
+ })),
394
+ };
395
+ await writeFile(summaryPath, JSON.stringify(summary, null, 2));
396
+ printInfo(`Component summary saved to ${summaryPath}`);
397
+ }
398
+ catch (error) {
399
+ printError(`Failed to save report: ${error instanceof Error ? error.message : String(error)}`);
400
+ }
401
+ }
402
+ export default scanStorybookCommand;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * ally scan command - Scans files for accessibility violations
3
+ */
4
+ import { type ColorBlindnessType, type WcagStandard, type BrowserType } from '../utils/scanner.js';
5
+ import type { AllyReport } from '../types/index.js';
6
+ type OutputFormat = 'json' | 'sarif' | 'junit' | 'csv';
7
+ interface ScanCommandOptions {
8
+ output?: string;
9
+ url?: string;
10
+ json?: boolean;
11
+ verbose?: boolean;
12
+ format?: OutputFormat;
13
+ simulate?: ColorBlindnessType;
14
+ standard?: WcagStandard;
15
+ timeout?: number;
16
+ noCache?: boolean;
17
+ ci?: boolean;
18
+ browser?: BrowserType;
19
+ maxFiles?: number;
20
+ baseline?: boolean;
21
+ compareBaseline?: boolean;
22
+ failOnRegression?: boolean;
23
+ }
24
+ export declare function scanCommand(targetPath?: string, options?: ScanCommandOptions): Promise<AllyReport | null>;
25
+ export default scanCommand;