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,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ally health command - Quick accessibility overview like npm audit
|
|
3
|
+
*/
|
|
4
|
+
import { readFile } from 'fs/promises';
|
|
5
|
+
import { existsSync } from 'fs';
|
|
6
|
+
import { resolve } from 'path';
|
|
7
|
+
import chalk from 'chalk';
|
|
8
|
+
import boxen from 'boxen';
|
|
9
|
+
import { printBanner, createSpinner, printError, printInfo, } from '../utils/ui.js';
|
|
10
|
+
import { AccessibilityScanner, findHtmlFiles, createReport, DEFAULT_STANDARD, DEFAULT_TIMEOUT, DEFAULT_BATCH_SIZE, } from '../utils/scanner.js';
|
|
11
|
+
import { loadConfig, loadIgnorePatterns } from '../utils/config.js';
|
|
12
|
+
const CATEGORY_CONFIG = {
|
|
13
|
+
'html-structure': {
|
|
14
|
+
name: 'HTML Structure',
|
|
15
|
+
icon: '1',
|
|
16
|
+
patterns: [
|
|
17
|
+
/^heading-order$/,
|
|
18
|
+
/^document-title$/,
|
|
19
|
+
/^html-has-lang$/,
|
|
20
|
+
/^html-lang-valid$/,
|
|
21
|
+
/^bypass$/,
|
|
22
|
+
/^landmark-/,
|
|
23
|
+
/^region$/,
|
|
24
|
+
/^page-has-heading-one$/,
|
|
25
|
+
/^meta-viewport$/,
|
|
26
|
+
/^frame-title$/,
|
|
27
|
+
/^valid-lang$/,
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
'color-contrast': {
|
|
31
|
+
name: 'Color Contrast',
|
|
32
|
+
icon: '2',
|
|
33
|
+
patterns: [
|
|
34
|
+
/^color-contrast/,
|
|
35
|
+
/^link-in-text-block$/,
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
'keyboard-nav': {
|
|
39
|
+
name: 'Keyboard Nav',
|
|
40
|
+
icon: '3',
|
|
41
|
+
patterns: [
|
|
42
|
+
/^tabindex$/,
|
|
43
|
+
/^focus-/,
|
|
44
|
+
/^scrollable-region-focusable$/,
|
|
45
|
+
/^accesskey/,
|
|
46
|
+
/^skip-link$/,
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
'aria-usage': {
|
|
50
|
+
name: 'ARIA Usage',
|
|
51
|
+
icon: '4',
|
|
52
|
+
patterns: [
|
|
53
|
+
/^aria-/,
|
|
54
|
+
/^role-/,
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
'forms': {
|
|
58
|
+
name: 'Forms',
|
|
59
|
+
icon: '5',
|
|
60
|
+
patterns: [
|
|
61
|
+
/^label$/,
|
|
62
|
+
/^label-/,
|
|
63
|
+
/^autocomplete-valid$/,
|
|
64
|
+
/^input-/,
|
|
65
|
+
/^select-/,
|
|
66
|
+
/^form-field-multiple-labels$/,
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
'images': {
|
|
70
|
+
name: 'Images',
|
|
71
|
+
icon: '6',
|
|
72
|
+
patterns: [
|
|
73
|
+
/^image-alt$/,
|
|
74
|
+
/^svg-img-alt$/,
|
|
75
|
+
/^image-redundant-alt$/,
|
|
76
|
+
/^object-alt$/,
|
|
77
|
+
/^area-alt$/,
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Severity penalty points
|
|
83
|
+
*/
|
|
84
|
+
const SEVERITY_PENALTY = {
|
|
85
|
+
critical: 25,
|
|
86
|
+
serious: 15,
|
|
87
|
+
moderate: 10,
|
|
88
|
+
minor: 5,
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Determine which category a violation belongs to based on its ID
|
|
92
|
+
*/
|
|
93
|
+
function categorizeViolation(violationId) {
|
|
94
|
+
for (const [category, config] of Object.entries(CATEGORY_CONFIG)) {
|
|
95
|
+
for (const pattern of config.patterns) {
|
|
96
|
+
if (pattern.test(violationId)) {
|
|
97
|
+
return category;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Calculate scores by category from violations
|
|
105
|
+
*/
|
|
106
|
+
function calculateCategoryScores(violations) {
|
|
107
|
+
// Initialize category data
|
|
108
|
+
const categoryData = {
|
|
109
|
+
'html-structure': { penalty: 0, violations: 0 },
|
|
110
|
+
'color-contrast': { penalty: 0, violations: 0 },
|
|
111
|
+
'keyboard-nav': { penalty: 0, violations: 0 },
|
|
112
|
+
'aria-usage': { penalty: 0, violations: 0 },
|
|
113
|
+
'forms': { penalty: 0, violations: 0 },
|
|
114
|
+
'images': { penalty: 0, violations: 0 },
|
|
115
|
+
};
|
|
116
|
+
// Process each violation
|
|
117
|
+
for (const violation of violations) {
|
|
118
|
+
const category = categorizeViolation(violation.id);
|
|
119
|
+
if (category) {
|
|
120
|
+
const penalty = SEVERITY_PENALTY[violation.impact] || 5;
|
|
121
|
+
// Count each node instance, capped at 10 per violation
|
|
122
|
+
const nodeCount = Math.min(violation.nodes.length, 10);
|
|
123
|
+
categoryData[category].penalty += penalty * nodeCount;
|
|
124
|
+
categoryData[category].violations += nodeCount;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Convert to scores
|
|
128
|
+
const categoryOrder = ['html-structure', 'color-contrast', 'keyboard-nav', 'aria-usage', 'forms', 'images'];
|
|
129
|
+
return categoryOrder.map((category) => {
|
|
130
|
+
const config = CATEGORY_CONFIG[category];
|
|
131
|
+
const data = categoryData[category];
|
|
132
|
+
// Score starts at 100, subtract penalties, minimum 0
|
|
133
|
+
const score = Math.max(0, 100 - Math.min(data.penalty, 100));
|
|
134
|
+
return {
|
|
135
|
+
category,
|
|
136
|
+
name: config.name,
|
|
137
|
+
score: Math.round(score),
|
|
138
|
+
violations: data.violations,
|
|
139
|
+
icon: config.icon,
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Get score label based on value
|
|
145
|
+
*/
|
|
146
|
+
function getScoreLabel(score) {
|
|
147
|
+
if (score >= 90)
|
|
148
|
+
return 'Excellent';
|
|
149
|
+
if (score >= 75)
|
|
150
|
+
return 'Good';
|
|
151
|
+
if (score >= 50)
|
|
152
|
+
return 'Needs Work';
|
|
153
|
+
return 'Poor';
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Get score icon based on value
|
|
157
|
+
*/
|
|
158
|
+
function getScoreIcon(score) {
|
|
159
|
+
if (score >= 75)
|
|
160
|
+
return chalk.green('\u2713'); // checkmark
|
|
161
|
+
if (score >= 50)
|
|
162
|
+
return chalk.yellow('\u26A0'); // warning
|
|
163
|
+
return chalk.red('\u2717'); // x mark
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Generate top recommendations from violations
|
|
167
|
+
*/
|
|
168
|
+
function generateRecommendations(violations, categoryScores) {
|
|
169
|
+
// Group violations by category and count
|
|
170
|
+
const categoryViolations = new Map();
|
|
171
|
+
for (const violation of violations) {
|
|
172
|
+
const category = categorizeViolation(violation.id);
|
|
173
|
+
if (!category)
|
|
174
|
+
continue;
|
|
175
|
+
if (!categoryViolations.has(category)) {
|
|
176
|
+
categoryViolations.set(category, new Map());
|
|
177
|
+
}
|
|
178
|
+
const catMap = categoryViolations.get(category);
|
|
179
|
+
const existing = catMap.get(violation.id);
|
|
180
|
+
const nodeCount = violation.nodes.length;
|
|
181
|
+
if (existing) {
|
|
182
|
+
existing.count += nodeCount;
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
catMap.set(violation.id, { count: nodeCount, description: violation.help });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// Sort categories by score (lowest first) to prioritize recommendations
|
|
189
|
+
const sortedCategories = [...categoryScores].sort((a, b) => a.score - b.score);
|
|
190
|
+
const recommendations = [];
|
|
191
|
+
for (const catScore of sortedCategories) {
|
|
192
|
+
if (catScore.score >= 90)
|
|
193
|
+
continue; // Skip excellent categories
|
|
194
|
+
const catViolations = categoryViolations.get(catScore.category);
|
|
195
|
+
if (!catViolations)
|
|
196
|
+
continue;
|
|
197
|
+
// Get the most common violation in this category
|
|
198
|
+
let topViolation = null;
|
|
199
|
+
for (const [id, data] of catViolations.entries()) {
|
|
200
|
+
if (!topViolation || data.count > topViolation.count) {
|
|
201
|
+
topViolation = { id, count: data.count, description: data.description };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (topViolation) {
|
|
205
|
+
recommendations.push({
|
|
206
|
+
message: `Fix ${topViolation.count} ${topViolation.description.toLowerCase()}`,
|
|
207
|
+
category: catScore.category,
|
|
208
|
+
count: topViolation.count,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (recommendations.length >= 3)
|
|
212
|
+
break; // Limit to 3 recommendations
|
|
213
|
+
}
|
|
214
|
+
return recommendations;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Get the lowest scoring category for the fix command suggestion
|
|
218
|
+
*/
|
|
219
|
+
function getWorstCategory(categoryScores) {
|
|
220
|
+
let worst = categoryScores[0];
|
|
221
|
+
for (const score of categoryScores) {
|
|
222
|
+
if (score.score < worst.score) {
|
|
223
|
+
worst = score;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return worst.category;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Render the health check box
|
|
230
|
+
*/
|
|
231
|
+
function renderHealthBox(overallScore, categoryScores, recommendations) {
|
|
232
|
+
const boxWidth = 48;
|
|
233
|
+
const scoreLabel = getScoreLabel(overallScore);
|
|
234
|
+
const scoreColor = overallScore >= 75 ? chalk.green : overallScore >= 50 ? chalk.yellow : chalk.red;
|
|
235
|
+
// Build content lines
|
|
236
|
+
const lines = [];
|
|
237
|
+
// Header
|
|
238
|
+
lines.push(chalk.bold(' Accessibility Health Check'));
|
|
239
|
+
lines.push('');
|
|
240
|
+
// Overall score
|
|
241
|
+
lines.push(` Overall Score: ${scoreColor.bold(`${overallScore}/100`)} (${scoreLabel})`);
|
|
242
|
+
lines.push('');
|
|
243
|
+
// Category scores
|
|
244
|
+
for (const cat of categoryScores) {
|
|
245
|
+
const icon = getScoreIcon(cat.score);
|
|
246
|
+
const nameWidth = 18;
|
|
247
|
+
const paddedName = cat.name.padEnd(nameWidth);
|
|
248
|
+
const scoreStr = `${cat.score}/100`;
|
|
249
|
+
lines.push(` ${icon} ${paddedName} ${chalk.dim('|')} ${scoreStr}`);
|
|
250
|
+
}
|
|
251
|
+
lines.push('');
|
|
252
|
+
// Recommendations
|
|
253
|
+
if (recommendations.length > 0) {
|
|
254
|
+
lines.push(chalk.bold(' Top Recommendations:'));
|
|
255
|
+
recommendations.forEach((rec, i) => {
|
|
256
|
+
lines.push(` ${i + 1}. ${rec.message}`);
|
|
257
|
+
});
|
|
258
|
+
lines.push('');
|
|
259
|
+
}
|
|
260
|
+
// Fix command suggestion
|
|
261
|
+
const worstCategory = getWorstCategory(categoryScores);
|
|
262
|
+
lines.push(chalk.dim(` Run: ally fix --category ${worstCategory}`));
|
|
263
|
+
return lines.join('\n');
|
|
264
|
+
}
|
|
265
|
+
export async function healthCommand(options = {}) {
|
|
266
|
+
printBanner();
|
|
267
|
+
const { path: targetPath = '.', standard = DEFAULT_STANDARD, input, } = options;
|
|
268
|
+
let report = null;
|
|
269
|
+
// Check if we should use existing scan results
|
|
270
|
+
if (input) {
|
|
271
|
+
const inputPath = resolve(input);
|
|
272
|
+
if (!existsSync(inputPath)) {
|
|
273
|
+
printError(`Input file not found: ${input}`);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const spinner = createSpinner('Loading scan results...');
|
|
277
|
+
spinner.start();
|
|
278
|
+
try {
|
|
279
|
+
const content = await readFile(inputPath, 'utf-8');
|
|
280
|
+
report = JSON.parse(content);
|
|
281
|
+
spinner.succeed('Loaded existing scan results');
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
spinner.fail('Failed to load scan results');
|
|
285
|
+
printError(error instanceof Error ? error.message : String(error));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
// Check for existing .ally/scan.json
|
|
291
|
+
const defaultScanPath = resolve(targetPath, '.ally', 'scan.json');
|
|
292
|
+
if (existsSync(defaultScanPath)) {
|
|
293
|
+
const spinner = createSpinner('Loading existing scan results...');
|
|
294
|
+
spinner.start();
|
|
295
|
+
try {
|
|
296
|
+
const content = await readFile(defaultScanPath, 'utf-8');
|
|
297
|
+
report = JSON.parse(content);
|
|
298
|
+
spinner.succeed('Using existing scan from .ally/scan.json');
|
|
299
|
+
printInfo('Run `ally scan` to update results');
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
// Fall through to new scan
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// If no existing results, run a scan
|
|
306
|
+
if (!report) {
|
|
307
|
+
const spinner = createSpinner('Scanning project for accessibility issues...');
|
|
308
|
+
spinner.start();
|
|
309
|
+
try {
|
|
310
|
+
// Load config
|
|
311
|
+
const { config } = await loadConfig();
|
|
312
|
+
const { patterns: ignorePatterns } = await loadIgnorePatterns();
|
|
313
|
+
const allIgnorePatterns = [...ignorePatterns, ...(config.scan?.ignore ?? [])];
|
|
314
|
+
// Find HTML files
|
|
315
|
+
const absolutePath = resolve(targetPath);
|
|
316
|
+
const htmlFiles = await findHtmlFiles(absolutePath, allIgnorePatterns);
|
|
317
|
+
if (htmlFiles.length === 0) {
|
|
318
|
+
spinner.fail('No HTML files found');
|
|
319
|
+
printInfo('Use `ally scan --url <url>` to scan a live URL');
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
spinner.text = `Scanning ${htmlFiles.length} files...`;
|
|
323
|
+
// Scan files
|
|
324
|
+
const scanner = new AccessibilityScanner(DEFAULT_TIMEOUT);
|
|
325
|
+
await scanner.init();
|
|
326
|
+
try {
|
|
327
|
+
const { results } = await scanner.scanHtmlFilesParallel(htmlFiles, standard, DEFAULT_BATCH_SIZE);
|
|
328
|
+
report = createReport(results);
|
|
329
|
+
spinner.succeed(`Scanned ${results.length} files`);
|
|
330
|
+
}
|
|
331
|
+
finally {
|
|
332
|
+
await scanner.close();
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
spinner.fail('Scan failed');
|
|
337
|
+
printError(error instanceof Error ? error.message : String(error));
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (!report) {
|
|
343
|
+
printError('No scan results available');
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
// Collect all violations from the report
|
|
347
|
+
const allViolations = [];
|
|
348
|
+
for (const result of report.results) {
|
|
349
|
+
allViolations.push(...result.violations);
|
|
350
|
+
}
|
|
351
|
+
// Calculate category scores
|
|
352
|
+
const categoryScores = calculateCategoryScores(allViolations);
|
|
353
|
+
// Calculate overall score (average of category scores)
|
|
354
|
+
const overallScore = Math.round(categoryScores.reduce((sum, cat) => sum + cat.score, 0) / categoryScores.length);
|
|
355
|
+
// Generate recommendations
|
|
356
|
+
const recommendations = generateRecommendations(allViolations, categoryScores);
|
|
357
|
+
// Render the health box
|
|
358
|
+
const content = renderHealthBox(overallScore, categoryScores, recommendations);
|
|
359
|
+
const borderColor = overallScore >= 75 ? 'green' : overallScore >= 50 ? 'yellow' : 'red';
|
|
360
|
+
console.log(boxen(content, {
|
|
361
|
+
padding: { top: 1, bottom: 1, left: 0, right: 0 },
|
|
362
|
+
borderStyle: 'round',
|
|
363
|
+
borderColor,
|
|
364
|
+
}));
|
|
365
|
+
console.log();
|
|
366
|
+
}
|
|
367
|
+
export default healthCommand;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ally history command - View scan history and progress
|
|
3
|
+
*/
|
|
4
|
+
interface HistoryCommandOptions {
|
|
5
|
+
limit?: number;
|
|
6
|
+
branch?: string;
|
|
7
|
+
verbose?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare function historyCommand(options?: HistoryCommandOptions): Promise<void>;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ally history command - View scan history and progress
|
|
3
|
+
*/
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import boxen from 'boxen';
|
|
6
|
+
import { loadHistory, getStats, timeAgo, } from '../utils/history-tracking.js';
|
|
7
|
+
import { printError, printInfo } from '../utils/ui.js';
|
|
8
|
+
/**
|
|
9
|
+
* Display a single history entry
|
|
10
|
+
*/
|
|
11
|
+
function printEntry(entry, index, total) {
|
|
12
|
+
const num = total - index;
|
|
13
|
+
const scoreColor = entry.score >= 90 ? chalk.green :
|
|
14
|
+
entry.score >= 75 ? chalk.yellow :
|
|
15
|
+
entry.score >= 50 ? chalk.yellow.dim :
|
|
16
|
+
chalk.red;
|
|
17
|
+
const branchInfo = entry.branch ? chalk.dim(` [${entry.branch}`) + (entry.commit ? chalk.dim(`@${entry.commit}]`) : chalk.dim(']')) : '';
|
|
18
|
+
console.log(chalk.dim(`#${num}`) +
|
|
19
|
+
' ' +
|
|
20
|
+
chalk.cyan(timeAgo(entry.timestamp)) +
|
|
21
|
+
' ' +
|
|
22
|
+
scoreColor(`${entry.score}/100`) +
|
|
23
|
+
' ' +
|
|
24
|
+
chalk.dim(`(${entry.totalViolations} issues, ${entry.filesScanned} files)`) +
|
|
25
|
+
branchInfo);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Display stats summary
|
|
29
|
+
*/
|
|
30
|
+
function printStats(stats) {
|
|
31
|
+
const scoreColor = stats.currentScore >= 90 ? chalk.green.bold :
|
|
32
|
+
stats.currentScore >= 75 ? chalk.yellow.bold :
|
|
33
|
+
stats.currentScore >= 50 ? chalk.yellow :
|
|
34
|
+
chalk.red.bold;
|
|
35
|
+
const trendIcon = stats.trend === 'improving' ? chalk.green('↗ improving') :
|
|
36
|
+
stats.trend === 'declining' ? chalk.red('↘ declining') :
|
|
37
|
+
chalk.blue('→ stable');
|
|
38
|
+
const changeText = stats.scoreChange !== null
|
|
39
|
+
? stats.scoreChange > 0
|
|
40
|
+
? chalk.green(`+${stats.scoreChange}`)
|
|
41
|
+
: stats.scoreChange < 0
|
|
42
|
+
? chalk.red(`${stats.scoreChange}`)
|
|
43
|
+
: chalk.dim('no change')
|
|
44
|
+
: chalk.dim('first scan');
|
|
45
|
+
const fixedText = stats.totalFixed !== null
|
|
46
|
+
? stats.totalFixed > 0
|
|
47
|
+
? chalk.green(`${stats.totalFixed} fixed`)
|
|
48
|
+
: stats.totalFixed < 0
|
|
49
|
+
? chalk.red(`${Math.abs(stats.totalFixed)} added`)
|
|
50
|
+
: chalk.dim('no change')
|
|
51
|
+
: chalk.dim('no data');
|
|
52
|
+
const streakText = stats.streak > 0
|
|
53
|
+
? chalk.green(`${stats.streak} scan${stats.streak === 1 ? '' : 's'}`)
|
|
54
|
+
: chalk.dim('0 scans');
|
|
55
|
+
const summary = `
|
|
56
|
+
${chalk.bold('Current Score:')} ${scoreColor(stats.currentScore.toString())}${chalk.dim('/100')} ${changeText}
|
|
57
|
+
|
|
58
|
+
${chalk.bold('Trend:')} ${trendIcon}
|
|
59
|
+
${chalk.bold('Streak:')} ${streakText} improving/stable
|
|
60
|
+
|
|
61
|
+
${chalk.bold('Statistics:')}
|
|
62
|
+
Best: ${chalk.green(stats.bestScore?.toString() ?? 'N/A')}${stats.bestScore ? '/100' : ''}
|
|
63
|
+
Average: ${chalk.yellow(stats.averageScore?.toString() ?? 'N/A')}${stats.averageScore ? '/100' : ''}
|
|
64
|
+
Worst: ${chalk.red(stats.worstScore?.toString() ?? 'N/A')}${stats.worstScore ? '/100' : ''}
|
|
65
|
+
|
|
66
|
+
${chalk.bold('Progress:')}
|
|
67
|
+
Total scans: ${chalk.cyan(stats.totalScans.toString())}
|
|
68
|
+
Issues fixed: ${fixedText}
|
|
69
|
+
First scan: ${chalk.dim(timeAgo(stats.firstScan))}
|
|
70
|
+
Last scan: ${chalk.dim(timeAgo(stats.lastScan))}
|
|
71
|
+
`;
|
|
72
|
+
console.log(boxen(summary.trim(), {
|
|
73
|
+
padding: 1,
|
|
74
|
+
margin: 1,
|
|
75
|
+
borderStyle: 'round',
|
|
76
|
+
borderColor: stats.currentScore >= 75 ? 'green' : stats.currentScore >= 50 ? 'yellow' : 'red',
|
|
77
|
+
title: '📊 Accessibility Progress',
|
|
78
|
+
titleAlignment: 'center',
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Create ASCII sparkline from scores
|
|
83
|
+
*/
|
|
84
|
+
function createSparkline(entries, width = 50) {
|
|
85
|
+
if (entries.length === 0)
|
|
86
|
+
return '';
|
|
87
|
+
const scores = entries.map(e => e.score);
|
|
88
|
+
const min = Math.min(...scores);
|
|
89
|
+
const max = Math.max(...scores);
|
|
90
|
+
const range = max - min || 1;
|
|
91
|
+
const bars = '▁▂▃▄▅▆▇█';
|
|
92
|
+
// Sample scores to fit width
|
|
93
|
+
const step = Math.max(1, Math.ceil(scores.length / width));
|
|
94
|
+
const sampledScores = [];
|
|
95
|
+
for (let i = 0; i < scores.length; i += step) {
|
|
96
|
+
sampledScores.push(scores[i]);
|
|
97
|
+
}
|
|
98
|
+
return sampledScores
|
|
99
|
+
.map(score => {
|
|
100
|
+
const normalized = (score - min) / range;
|
|
101
|
+
const index = Math.min(bars.length - 1, Math.floor(normalized * bars.length));
|
|
102
|
+
const char = bars[index];
|
|
103
|
+
// Color based on score
|
|
104
|
+
if (score >= 90)
|
|
105
|
+
return chalk.green(char);
|
|
106
|
+
if (score >= 75)
|
|
107
|
+
return chalk.yellow(char);
|
|
108
|
+
if (score >= 50)
|
|
109
|
+
return chalk.yellow.dim(char);
|
|
110
|
+
return chalk.red(char);
|
|
111
|
+
})
|
|
112
|
+
.join('');
|
|
113
|
+
}
|
|
114
|
+
export async function historyCommand(options = {}) {
|
|
115
|
+
const { limit = 10, branch, verbose = false } = options;
|
|
116
|
+
// Load history
|
|
117
|
+
const history = await loadHistory();
|
|
118
|
+
if (history.entries.length === 0) {
|
|
119
|
+
printInfo('No scan history yet. Run `ally scan` to start tracking progress.');
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
// Get stats
|
|
123
|
+
const stats = getStats(history);
|
|
124
|
+
if (!stats) {
|
|
125
|
+
printError('Unable to calculate statistics');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
// Print stats summary
|
|
129
|
+
printStats(stats);
|
|
130
|
+
// Filter by branch if requested
|
|
131
|
+
let entries = history.entries;
|
|
132
|
+
if (branch) {
|
|
133
|
+
entries = entries.filter(e => e.branch === branch);
|
|
134
|
+
if (entries.length === 0) {
|
|
135
|
+
printError(`No scans found for branch: ${branch}`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
printInfo(`Showing scans for branch: ${branch}`);
|
|
139
|
+
}
|
|
140
|
+
// Print sparkline
|
|
141
|
+
if (entries.length > 1) {
|
|
142
|
+
console.log();
|
|
143
|
+
console.log(chalk.bold('Score History:'));
|
|
144
|
+
console.log(createSparkline(entries));
|
|
145
|
+
console.log(chalk.dim(`${entries[0].score}${' '.repeat(42)}${entries[entries.length - 1].score}`));
|
|
146
|
+
console.log();
|
|
147
|
+
}
|
|
148
|
+
// Print recent entries
|
|
149
|
+
console.log(chalk.bold(`Recent Scans (last ${Math.min(limit, entries.length)}):`));
|
|
150
|
+
console.log();
|
|
151
|
+
const recentEntries = entries.slice(-limit).reverse();
|
|
152
|
+
recentEntries.forEach((entry, index) => {
|
|
153
|
+
printEntry(entry, index, entries.length);
|
|
154
|
+
});
|
|
155
|
+
// Motivational message
|
|
156
|
+
console.log();
|
|
157
|
+
const motivation = getMotivationalMessage(stats);
|
|
158
|
+
if (motivation) {
|
|
159
|
+
console.log(chalk.cyan('💡 ' + motivation));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Get motivational message based on stats
|
|
164
|
+
*/
|
|
165
|
+
function getMotivationalMessage(stats) {
|
|
166
|
+
if (stats.currentScore === 100) {
|
|
167
|
+
return 'Perfect score! Your site is fully accessible. 🎉';
|
|
168
|
+
}
|
|
169
|
+
if (stats.trend === 'improving' && stats.streak >= 3) {
|
|
170
|
+
return `Great momentum! You've improved for ${stats.streak} consecutive scans. Keep it up!`;
|
|
171
|
+
}
|
|
172
|
+
if (stats.trend === 'improving') {
|
|
173
|
+
return 'You\'re on the right track! Keep fixing those issues.';
|
|
174
|
+
}
|
|
175
|
+
if (stats.trend === 'declining') {
|
|
176
|
+
return 'Score is declining. Time to address those new violations!';
|
|
177
|
+
}
|
|
178
|
+
if (stats.totalFixed && stats.totalFixed > 10) {
|
|
179
|
+
return `Awesome! You've fixed ${stats.totalFixed} violations since starting.`;
|
|
180
|
+
}
|
|
181
|
+
if (stats.currentScore >= 90) {
|
|
182
|
+
return 'Almost perfect! Just a few more fixes to go.';
|
|
183
|
+
}
|
|
184
|
+
if (stats.currentScore >= 75) {
|
|
185
|
+
return 'Good accessibility! Focus on critical and serious issues.';
|
|
186
|
+
}
|
|
187
|
+
if (stats.currentScore >= 50) {
|
|
188
|
+
return 'Making progress! Prioritize high-impact violations first.';
|
|
189
|
+
}
|
|
190
|
+
return 'Start by fixing critical violations - they block the most users.';
|
|
191
|
+
}
|