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,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Historical Tracking System
|
|
3
|
+
*
|
|
4
|
+
* Tracks scan history over time to show progress and trends.
|
|
5
|
+
* Quick win: 3 days effort, HIGH ROI - shows teams their improvement.
|
|
6
|
+
*/
|
|
7
|
+
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
8
|
+
import { existsSync } from 'fs';
|
|
9
|
+
import { resolve } from 'path';
|
|
10
|
+
const HISTORY_FILE = '.ally/history.json';
|
|
11
|
+
const MAX_ENTRIES = 100; // Keep last 100 scans
|
|
12
|
+
/**
|
|
13
|
+
* Load history from file
|
|
14
|
+
*/
|
|
15
|
+
export async function loadHistory() {
|
|
16
|
+
if (!existsSync(HISTORY_FILE)) {
|
|
17
|
+
return {
|
|
18
|
+
entries: [],
|
|
19
|
+
firstScan: new Date().toISOString(),
|
|
20
|
+
lastScan: new Date().toISOString(),
|
|
21
|
+
totalScans: 0,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const content = await readFile(HISTORY_FILE, 'utf-8');
|
|
26
|
+
const data = JSON.parse(content);
|
|
27
|
+
// Ensure entries is an array
|
|
28
|
+
if (!Array.isArray(data.entries)) {
|
|
29
|
+
data.entries = [];
|
|
30
|
+
}
|
|
31
|
+
return data;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return {
|
|
35
|
+
entries: [],
|
|
36
|
+
firstScan: new Date().toISOString(),
|
|
37
|
+
lastScan: new Date().toISOString(),
|
|
38
|
+
totalScans: 0,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Save history entry
|
|
44
|
+
*/
|
|
45
|
+
export async function saveHistoryEntry(report, command = 'scan') {
|
|
46
|
+
const history = await loadHistory();
|
|
47
|
+
// Get git info if available
|
|
48
|
+
let branch;
|
|
49
|
+
let commit;
|
|
50
|
+
try {
|
|
51
|
+
const { execSync } = await import('child_process');
|
|
52
|
+
branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim();
|
|
53
|
+
commit = execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }).trim();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Git not available or not a git repo
|
|
57
|
+
}
|
|
58
|
+
const entry = {
|
|
59
|
+
timestamp: report.scanDate,
|
|
60
|
+
score: report.summary.score,
|
|
61
|
+
totalViolations: report.summary.totalViolations,
|
|
62
|
+
bySeverity: report.summary.bySeverity,
|
|
63
|
+
filesScanned: report.totalFiles,
|
|
64
|
+
command,
|
|
65
|
+
branch,
|
|
66
|
+
commit,
|
|
67
|
+
};
|
|
68
|
+
history.entries.push(entry);
|
|
69
|
+
// Keep only last MAX_ENTRIES
|
|
70
|
+
if (history.entries.length > MAX_ENTRIES) {
|
|
71
|
+
history.entries = history.entries.slice(-MAX_ENTRIES);
|
|
72
|
+
}
|
|
73
|
+
// Update metadata
|
|
74
|
+
if (history.entries.length === 1) {
|
|
75
|
+
history.firstScan = entry.timestamp;
|
|
76
|
+
}
|
|
77
|
+
history.lastScan = entry.timestamp;
|
|
78
|
+
history.totalScans = history.entries.length;
|
|
79
|
+
// Ensure directory exists
|
|
80
|
+
const dir = resolve('.ally');
|
|
81
|
+
if (!existsSync(dir)) {
|
|
82
|
+
await mkdir(dir, { recursive: true });
|
|
83
|
+
}
|
|
84
|
+
// Save to file
|
|
85
|
+
await writeFile(HISTORY_FILE, JSON.stringify(history, null, 2), 'utf-8');
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Get trend direction (improving, declining, stable)
|
|
89
|
+
*/
|
|
90
|
+
export function getTrend(history, lookback = 5) {
|
|
91
|
+
if (history.entries.length < 2) {
|
|
92
|
+
return 'stable';
|
|
93
|
+
}
|
|
94
|
+
const recentEntries = history.entries.slice(-lookback);
|
|
95
|
+
const scores = recentEntries.map(e => e.score);
|
|
96
|
+
// Calculate average of first half vs second half
|
|
97
|
+
const midpoint = Math.floor(scores.length / 2);
|
|
98
|
+
const firstHalf = scores.slice(0, midpoint);
|
|
99
|
+
const secondHalf = scores.slice(midpoint);
|
|
100
|
+
const avgFirst = firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length;
|
|
101
|
+
const avgSecond = secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length;
|
|
102
|
+
const diff = avgSecond - avgFirst;
|
|
103
|
+
if (diff > 2)
|
|
104
|
+
return 'improving';
|
|
105
|
+
if (diff < -2)
|
|
106
|
+
return 'declining';
|
|
107
|
+
return 'stable';
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Get score change from previous scan
|
|
111
|
+
*/
|
|
112
|
+
export function getScoreChange(history) {
|
|
113
|
+
if (history.entries.length < 2) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
const latest = history.entries[history.entries.length - 1];
|
|
117
|
+
const previous = history.entries[history.entries.length - 2];
|
|
118
|
+
return latest.score - previous.score;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Get best score ever achieved
|
|
122
|
+
*/
|
|
123
|
+
export function getBestScore(history) {
|
|
124
|
+
if (history.entries.length === 0) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
return Math.max(...history.entries.map(e => e.score));
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Get worst score ever recorded
|
|
131
|
+
*/
|
|
132
|
+
export function getWorstScore(history) {
|
|
133
|
+
if (history.entries.length === 0) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
return Math.min(...history.entries.map(e => e.score));
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Get average score over time
|
|
140
|
+
*/
|
|
141
|
+
export function getAverageScore(history) {
|
|
142
|
+
if (history.entries.length === 0) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
const sum = history.entries.reduce((acc, e) => acc + e.score, 0);
|
|
146
|
+
return Math.round(sum / history.entries.length);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Get total violations fixed since first scan
|
|
150
|
+
*/
|
|
151
|
+
export function getTotalFixed(history) {
|
|
152
|
+
if (history.entries.length < 2) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const first = history.entries[0];
|
|
156
|
+
const latest = history.entries[history.entries.length - 1];
|
|
157
|
+
return first.totalViolations - latest.totalViolations;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Get streak (consecutive scans with improving or stable scores)
|
|
161
|
+
*/
|
|
162
|
+
export function getStreak(history) {
|
|
163
|
+
if (history.entries.length < 2) {
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
let streak = 0;
|
|
167
|
+
for (let i = history.entries.length - 1; i > 0; i--) {
|
|
168
|
+
const current = history.entries[i];
|
|
169
|
+
const previous = history.entries[i - 1];
|
|
170
|
+
if (current.score >= previous.score) {
|
|
171
|
+
streak++;
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return streak;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Format time ago (e.g., "2 hours ago", "3 days ago")
|
|
181
|
+
*/
|
|
182
|
+
export function timeAgo(timestamp) {
|
|
183
|
+
const now = new Date();
|
|
184
|
+
const then = new Date(timestamp);
|
|
185
|
+
const diffMs = now.getTime() - then.getTime();
|
|
186
|
+
const seconds = Math.floor(diffMs / 1000);
|
|
187
|
+
const minutes = Math.floor(seconds / 60);
|
|
188
|
+
const hours = Math.floor(minutes / 60);
|
|
189
|
+
const days = Math.floor(hours / 24);
|
|
190
|
+
if (days > 0)
|
|
191
|
+
return `${days} day${days === 1 ? '' : 's'} ago`;
|
|
192
|
+
if (hours > 0)
|
|
193
|
+
return `${hours} hour${hours === 1 ? '' : 's'} ago`;
|
|
194
|
+
if (minutes > 0)
|
|
195
|
+
return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;
|
|
196
|
+
return 'just now';
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Get recent entries (last N scans)
|
|
200
|
+
*/
|
|
201
|
+
export function getRecentEntries(history, count = 10) {
|
|
202
|
+
return history.entries.slice(-count);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Get entries by branch
|
|
206
|
+
*/
|
|
207
|
+
export function getEntriesByBranch(history, branch) {
|
|
208
|
+
return history.entries.filter(e => e.branch === branch);
|
|
209
|
+
}
|
|
210
|
+
export function getStats(history) {
|
|
211
|
+
if (history.entries.length === 0) {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
const latest = history.entries[history.entries.length - 1];
|
|
215
|
+
const previous = history.entries.length > 1 ? history.entries[history.entries.length - 2] : null;
|
|
216
|
+
return {
|
|
217
|
+
currentScore: latest.score,
|
|
218
|
+
previousScore: previous?.score ?? null,
|
|
219
|
+
scoreChange: getScoreChange(history),
|
|
220
|
+
trend: getTrend(history),
|
|
221
|
+
bestScore: getBestScore(history),
|
|
222
|
+
worstScore: getWorstScore(history),
|
|
223
|
+
averageScore: getAverageScore(history),
|
|
224
|
+
totalScans: history.totalScans,
|
|
225
|
+
totalFixed: getTotalFixed(history),
|
|
226
|
+
streak: getStreak(history),
|
|
227
|
+
lastScan: latest.timestamp,
|
|
228
|
+
firstScan: history.firstScan,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* History management utilities for tracking accessibility progress over time
|
|
3
|
+
*/
|
|
4
|
+
export interface HistoryEntry {
|
|
5
|
+
date: string;
|
|
6
|
+
score: number;
|
|
7
|
+
errors: number;
|
|
8
|
+
warnings: number;
|
|
9
|
+
filesScanned: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Save a new history entry
|
|
13
|
+
*/
|
|
14
|
+
export declare function saveHistoryEntry(projectPath: string, entry: HistoryEntry): Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* Load history entries (last N days by default)
|
|
17
|
+
*/
|
|
18
|
+
export declare function loadHistory(projectPath: string, days?: number): Promise<HistoryEntry[]>;
|
|
19
|
+
/**
|
|
20
|
+
* Calculate trend information from history
|
|
21
|
+
*/
|
|
22
|
+
export interface TrendInfo {
|
|
23
|
+
lastWeekChange: number | null;
|
|
24
|
+
lastScanChange: number | null;
|
|
25
|
+
periodChange: number | null;
|
|
26
|
+
direction: 'up' | 'down' | 'stable';
|
|
27
|
+
periodDays: number;
|
|
28
|
+
}
|
|
29
|
+
export declare function calculateTrend(history: HistoryEntry[], currentScore: number): TrendInfo;
|
|
30
|
+
/**
|
|
31
|
+
* Format trend string with arrow and color
|
|
32
|
+
*/
|
|
33
|
+
export declare function formatTrend(trend: TrendInfo): string;
|
|
34
|
+
/**
|
|
35
|
+
* Render an ASCII chart of score history
|
|
36
|
+
* Uses block characters for a more polished look
|
|
37
|
+
*/
|
|
38
|
+
export declare function renderAsciiChart(history: HistoryEntry[], currentScore: number, width?: number, height?: number): string;
|
|
39
|
+
/**
|
|
40
|
+
* Create a history entry from scan results
|
|
41
|
+
*/
|
|
42
|
+
export declare function createHistoryEntry(score: number, bySeverity: Record<string, number>, filesScanned: number): HistoryEntry;
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* History management utilities for tracking accessibility progress over time
|
|
3
|
+
*/
|
|
4
|
+
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
5
|
+
import { existsSync } from 'fs';
|
|
6
|
+
import { resolve } from 'path';
|
|
7
|
+
import chalk from 'chalk';
|
|
8
|
+
// Path to history file relative to project
|
|
9
|
+
const HISTORY_FILE = 'history.json';
|
|
10
|
+
const ALLY_DIR = '.ally';
|
|
11
|
+
/**
|
|
12
|
+
* Get the path to the history file for a project
|
|
13
|
+
*/
|
|
14
|
+
function getHistoryPath(projectPath) {
|
|
15
|
+
return resolve(projectPath, ALLY_DIR, HISTORY_FILE);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Save a new history entry
|
|
19
|
+
*/
|
|
20
|
+
export async function saveHistoryEntry(projectPath, entry) {
|
|
21
|
+
const allyDir = resolve(projectPath, ALLY_DIR);
|
|
22
|
+
const historyPath = getHistoryPath(projectPath);
|
|
23
|
+
// Ensure .ally directory exists
|
|
24
|
+
if (!existsSync(allyDir)) {
|
|
25
|
+
await mkdir(allyDir, { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
// Load existing history
|
|
28
|
+
let history = [];
|
|
29
|
+
if (existsSync(historyPath)) {
|
|
30
|
+
try {
|
|
31
|
+
const content = await readFile(historyPath, 'utf-8');
|
|
32
|
+
history = JSON.parse(content);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
history = [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Add new entry (limit to last 90 days of entries)
|
|
39
|
+
history.push(entry);
|
|
40
|
+
if (history.length > 90) {
|
|
41
|
+
history = history.slice(-90);
|
|
42
|
+
}
|
|
43
|
+
await writeFile(historyPath, JSON.stringify(history, null, 2));
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Load history entries (last N days by default)
|
|
47
|
+
*/
|
|
48
|
+
export async function loadHistory(projectPath, days = 30) {
|
|
49
|
+
const historyPath = getHistoryPath(projectPath);
|
|
50
|
+
if (!existsSync(historyPath)) {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const content = await readFile(historyPath, 'utf-8');
|
|
55
|
+
const history = JSON.parse(content);
|
|
56
|
+
// Filter to last N days
|
|
57
|
+
const cutoff = new Date();
|
|
58
|
+
cutoff.setDate(cutoff.getDate() - days);
|
|
59
|
+
return history.filter(entry => new Date(entry.date) >= cutoff);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export function calculateTrend(history, currentScore) {
|
|
66
|
+
if (history.length === 0) {
|
|
67
|
+
return {
|
|
68
|
+
lastWeekChange: null,
|
|
69
|
+
lastScanChange: null,
|
|
70
|
+
periodChange: null,
|
|
71
|
+
direction: 'stable',
|
|
72
|
+
periodDays: 0,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
// Change from last scan
|
|
76
|
+
const lastEntry = history[history.length - 1];
|
|
77
|
+
const lastScanChange = currentScore - lastEntry.score;
|
|
78
|
+
// Change from one week ago
|
|
79
|
+
const oneWeekAgo = new Date();
|
|
80
|
+
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
|
|
81
|
+
const weekOldEntry = history.find(entry => new Date(entry.date) >= oneWeekAgo);
|
|
82
|
+
const lastWeekChange = weekOldEntry ? currentScore - weekOldEntry.score : null;
|
|
83
|
+
// Change over entire period
|
|
84
|
+
const firstEntry = history[0];
|
|
85
|
+
const periodChange = currentScore - firstEntry.score;
|
|
86
|
+
const firstDate = new Date(firstEntry.date);
|
|
87
|
+
const periodDays = Math.ceil((Date.now() - firstDate.getTime()) / (1000 * 60 * 60 * 24));
|
|
88
|
+
// Determine direction
|
|
89
|
+
let direction = 'stable';
|
|
90
|
+
if (lastScanChange > 0)
|
|
91
|
+
direction = 'up';
|
|
92
|
+
else if (lastScanChange < 0)
|
|
93
|
+
direction = 'down';
|
|
94
|
+
return {
|
|
95
|
+
lastWeekChange,
|
|
96
|
+
lastScanChange,
|
|
97
|
+
periodChange,
|
|
98
|
+
direction,
|
|
99
|
+
periodDays,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Format trend string with arrow and color
|
|
104
|
+
*/
|
|
105
|
+
export function formatTrend(trend) {
|
|
106
|
+
const lines = [];
|
|
107
|
+
if (trend.lastScanChange !== null && trend.lastScanChange !== 0) {
|
|
108
|
+
const arrow = trend.lastScanChange > 0 ? '\u2191' : '\u2193';
|
|
109
|
+
const sign = trend.lastScanChange > 0 ? '+' : '';
|
|
110
|
+
const color = trend.lastScanChange > 0 ? chalk.green : chalk.red;
|
|
111
|
+
lines.push(color(`${arrow} ${sign}${trend.lastScanChange} from last scan`));
|
|
112
|
+
}
|
|
113
|
+
if (trend.lastWeekChange !== null && trend.lastWeekChange !== 0) {
|
|
114
|
+
const arrow = trend.lastWeekChange > 0 ? '\u2191' : '\u2193';
|
|
115
|
+
const sign = trend.lastWeekChange > 0 ? '+' : '';
|
|
116
|
+
const color = trend.lastWeekChange > 0 ? chalk.green : chalk.red;
|
|
117
|
+
lines.push(color(`${arrow} ${sign}${trend.lastWeekChange} from last week`));
|
|
118
|
+
}
|
|
119
|
+
if (trend.periodChange !== null && trend.periodDays > 7 && trend.periodChange !== 0) {
|
|
120
|
+
const arrow = trend.periodChange > 0 ? '\u2191' : '\u2193';
|
|
121
|
+
const sign = trend.periodChange > 0 ? '+' : '';
|
|
122
|
+
const color = trend.periodChange > 0 ? chalk.green : chalk.red;
|
|
123
|
+
lines.push(color(`${arrow} ${sign}${trend.periodChange} points over ${trend.periodDays} days`));
|
|
124
|
+
}
|
|
125
|
+
return lines.join('\n');
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Render an ASCII chart of score history
|
|
129
|
+
* Uses block characters for a more polished look
|
|
130
|
+
*/
|
|
131
|
+
export function renderAsciiChart(history, currentScore, width = 50, height = 8) {
|
|
132
|
+
if (history.length === 0) {
|
|
133
|
+
return chalk.dim(' No history data available yet.');
|
|
134
|
+
}
|
|
135
|
+
// Include current score in the data
|
|
136
|
+
const scores = [...history.map(h => h.score), currentScore];
|
|
137
|
+
const dates = [...history.map(h => h.date), new Date().toISOString()];
|
|
138
|
+
// Calculate min/max for scaling
|
|
139
|
+
const minScore = Math.min(...scores, 0);
|
|
140
|
+
const maxScore = Math.max(...scores, 100);
|
|
141
|
+
const range = maxScore - minScore || 1;
|
|
142
|
+
// Y-axis labels
|
|
143
|
+
const yAxisWidth = 4;
|
|
144
|
+
const chartWidth = width - yAxisWidth - 1;
|
|
145
|
+
// Sample data points to fit chart width
|
|
146
|
+
const step = Math.max(1, Math.floor(scores.length / chartWidth));
|
|
147
|
+
const sampledScores = [];
|
|
148
|
+
const sampledDates = [];
|
|
149
|
+
for (let i = 0; i < scores.length; i += step) {
|
|
150
|
+
sampledScores.push(scores[i]);
|
|
151
|
+
sampledDates.push(dates[i]);
|
|
152
|
+
}
|
|
153
|
+
// Ensure we include the last point
|
|
154
|
+
if (sampledScores[sampledScores.length - 1] !== scores[scores.length - 1]) {
|
|
155
|
+
sampledScores.push(scores[scores.length - 1]);
|
|
156
|
+
sampledDates.push(dates[dates.length - 1]);
|
|
157
|
+
}
|
|
158
|
+
const lines = [];
|
|
159
|
+
// Header
|
|
160
|
+
lines.push(chalk.bold.cyan('Score History (last 30 days):'));
|
|
161
|
+
lines.push('');
|
|
162
|
+
// Chart area - build from top to bottom
|
|
163
|
+
for (let row = height - 1; row >= 0; row--) {
|
|
164
|
+
const threshold = minScore + (range * (row + 1) / height);
|
|
165
|
+
const prevThreshold = minScore + (range * row / height);
|
|
166
|
+
// Y-axis label (only show a few)
|
|
167
|
+
let yLabel = '';
|
|
168
|
+
if (row === height - 1) {
|
|
169
|
+
yLabel = maxScore.toString().padStart(yAxisWidth - 1) + '\u2502';
|
|
170
|
+
}
|
|
171
|
+
else if (row === 0) {
|
|
172
|
+
yLabel = minScore.toString().padStart(yAxisWidth - 1) + '\u2502';
|
|
173
|
+
}
|
|
174
|
+
else if (row === Math.floor(height / 2)) {
|
|
175
|
+
const midVal = Math.round((minScore + maxScore) / 2);
|
|
176
|
+
yLabel = midVal.toString().padStart(yAxisWidth - 1) + '\u2502';
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
yLabel = ' '.repeat(yAxisWidth - 1) + '\u2502';
|
|
180
|
+
}
|
|
181
|
+
// Chart line
|
|
182
|
+
let chartLine = '';
|
|
183
|
+
for (let col = 0; col < sampledScores.length; col++) {
|
|
184
|
+
const score = sampledScores[col];
|
|
185
|
+
if (score >= threshold) {
|
|
186
|
+
// Full block
|
|
187
|
+
chartLine += getBlockChar(score, true);
|
|
188
|
+
}
|
|
189
|
+
else if (score > prevThreshold) {
|
|
190
|
+
// Partial block (top)
|
|
191
|
+
chartLine += getBlockChar(score, false);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
// Empty
|
|
195
|
+
chartLine += ' ';
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
lines.push(yLabel + chartLine);
|
|
199
|
+
}
|
|
200
|
+
// X-axis
|
|
201
|
+
const xAxisLine = ' '.repeat(yAxisWidth - 1) + '\u2514' + '\u2500'.repeat(sampledScores.length);
|
|
202
|
+
lines.push(xAxisLine);
|
|
203
|
+
// X-axis labels (dates)
|
|
204
|
+
if (sampledDates.length > 0) {
|
|
205
|
+
const firstDate = formatDateShort(sampledDates[0]);
|
|
206
|
+
const lastDate = formatDateShort(sampledDates[sampledDates.length - 1]);
|
|
207
|
+
const midIdx = Math.floor(sampledDates.length / 2);
|
|
208
|
+
const midDate = sampledDates[midIdx] ? formatDateShort(sampledDates[midIdx]) : '';
|
|
209
|
+
// Calculate positions
|
|
210
|
+
const spacing = sampledScores.length - firstDate.length - lastDate.length;
|
|
211
|
+
let dateLabel = ' '.repeat(yAxisWidth) + firstDate;
|
|
212
|
+
if (spacing > midDate.length + 4) {
|
|
213
|
+
// Add middle date
|
|
214
|
+
const midPos = Math.floor(sampledScores.length / 2) - Math.floor(midDate.length / 2);
|
|
215
|
+
const leftPad = midPos - firstDate.length;
|
|
216
|
+
const rightPad = sampledScores.length - midPos - midDate.length - lastDate.length;
|
|
217
|
+
dateLabel = ' '.repeat(yAxisWidth) + firstDate + ' '.repeat(Math.max(1, leftPad)) + midDate + ' '.repeat(Math.max(1, rightPad)) + lastDate;
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
dateLabel = ' '.repeat(yAxisWidth) + firstDate + ' '.repeat(Math.max(1, spacing)) + lastDate;
|
|
221
|
+
}
|
|
222
|
+
lines.push(chalk.dim(dateLabel));
|
|
223
|
+
}
|
|
224
|
+
return lines.join('\n');
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Get block character for chart based on score
|
|
228
|
+
*/
|
|
229
|
+
function getBlockChar(score, full) {
|
|
230
|
+
const color = score >= 75 ? chalk.green
|
|
231
|
+
: score >= 50 ? chalk.yellow
|
|
232
|
+
: chalk.red;
|
|
233
|
+
// Use full or partial blocks
|
|
234
|
+
return color(full ? '\u2588' : '\u2584');
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Format date for X-axis labels
|
|
238
|
+
*/
|
|
239
|
+
function formatDateShort(isoDate) {
|
|
240
|
+
const date = new Date(isoDate);
|
|
241
|
+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
242
|
+
return `${months[date.getMonth()]} ${date.getDate()}`;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Create a history entry from scan results
|
|
246
|
+
*/
|
|
247
|
+
export function createHistoryEntry(score, bySeverity, filesScanned) {
|
|
248
|
+
return {
|
|
249
|
+
date: new Date().toISOString(),
|
|
250
|
+
score,
|
|
251
|
+
errors: (bySeverity.critical ?? 0) + (bySeverity.serious ?? 0),
|
|
252
|
+
warnings: (bySeverity.moderate ?? 0) + (bySeverity.minor ?? 0),
|
|
253
|
+
filesScanned,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Impact Scoring System
|
|
3
|
+
*
|
|
4
|
+
* Scores accessibility violations by real-world user impact and business cost.
|
|
5
|
+
* Research shows developers are overwhelmed by violations - impact scoring
|
|
6
|
+
* helps prioritize what actually matters.
|
|
7
|
+
*/
|
|
8
|
+
import type { Violation } from '../types/index.js';
|
|
9
|
+
export interface ImpactScore {
|
|
10
|
+
score: number;
|
|
11
|
+
reasoning: string;
|
|
12
|
+
affectedUsers: string[];
|
|
13
|
+
businessImpact: 'critical' | 'high' | 'medium' | 'low';
|
|
14
|
+
estimatedUsers: string;
|
|
15
|
+
wcagLevel: 'A' | 'AA' | 'AAA';
|
|
16
|
+
}
|
|
17
|
+
export interface PageContext {
|
|
18
|
+
pageType?: 'landing' | 'checkout' | 'form' | 'content' | 'navigation' | 'dashboard';
|
|
19
|
+
hasForm?: boolean;
|
|
20
|
+
hasCheckout?: boolean;
|
|
21
|
+
isNavigation?: boolean;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Detect page context from HTML content
|
|
25
|
+
*/
|
|
26
|
+
export declare function detectPageContext(html: string): PageContext;
|
|
27
|
+
/**
|
|
28
|
+
* Calculate impact score for a violation
|
|
29
|
+
*/
|
|
30
|
+
export declare function calculateImpactScore(violation: Violation, context?: PageContext): ImpactScore;
|
|
31
|
+
/**
|
|
32
|
+
* Sort violations by impact score (highest first)
|
|
33
|
+
*/
|
|
34
|
+
export declare function sortByImpact(violations: Violation[], context?: PageContext): Array<{
|
|
35
|
+
violation: Violation;
|
|
36
|
+
impact: ImpactScore;
|
|
37
|
+
}>;
|
|
38
|
+
/**
|
|
39
|
+
* Group violations by impact category
|
|
40
|
+
*/
|
|
41
|
+
export declare function groupByImpact(violations: Violation[], context?: PageContext): Record<string, Array<{
|
|
42
|
+
violation: Violation;
|
|
43
|
+
impact: ImpactScore;
|
|
44
|
+
}>>;
|