a11yanalyze
Version:
A command-line tool for developers and QA engineers to test web pages and websites for WCAG 2.2 AA accessibility compliance
787 lines (690 loc) • 23.4 kB
text/typescript
import { AccessibilityIssue, ScanResult, ScanMetadata } from '../types';
import { WCAGLevel } from '../types/wcag';
import { CrawlSession } from '../types/crawler';
/**
* Scoring algorithm configuration
*/
export interface ScoringConfig {
/** Scoring profile to use */
profile: ScoringProfile;
/** WCAG level weights (higher = more important) */
wcagLevelWeights: Record<WCAGLevel, number>;
/** Severity penalty multipliers */
severityPenalties: Record<string, number>;
/** Maximum penalty per issue type */
maxPenaltyPerIssue: number;
/** Minimum possible score */
minScore: number;
/** Maximum possible score */
maxScore: number;
/** Enable bonus scoring */
enableBonuses: boolean;
/** Enable penalty systems */
enablePenalties: boolean;
/** Scoring transparency level */
transparencyLevel: 'basic' | 'detailed' | 'debug';
}
/**
* Predefined scoring profiles for different use cases
*/
export type ScoringProfile =
| 'strict' // Strict compliance-focused scoring
| 'balanced' // Balanced approach for most use cases
| 'lenient' // More forgiving for development environments
| 'enterprise' // Enterprise-focused with business impact weighting
| 'custom'; // Fully customizable configuration
/**
* Detailed score breakdown for transparency
*/
export interface ScoreBreakdown {
/** Final calculated score */
finalScore: number;
/** Base score before adjustments */
baseScore: number;
/** Issue-based deductions */
issueDeductions: IssueDeduction[];
/** Performance bonuses applied */
bonuses: ScoreBonus[];
/** System penalties applied */
penalties: ScorePenalty[];
/** Coverage assessment */
coverage: CoverageAssessment;
/** Calculation metadata */
metadata: ScoreMetadata;
}
/**
* Individual issue deduction details
*/
export interface IssueDeduction {
/** Issue ID */
issueId: string;
/** WCAG reference */
wcagReference: string;
/** Issue severity */
severity: string;
/** WCAG level */
level: WCAGLevel;
/** Points deducted */
pointsDeducted: number;
/** Weight applied */
weight: number;
/** Reason for deduction */
reason: string;
}
/**
* Bonus points awarded
*/
export interface ScoreBonus {
/** Bonus type */
type: 'performance' | 'coverage' | 'bestPractices' | 'accessibility';
/** Points awarded */
points: number;
/** Description */
description: string;
/** Criteria met */
criteria: string;
}
/**
* System penalties applied
*/
export interface ScorePenalty {
/** Penalty type */
type: 'timeout' | 'error' | 'coverage' | 'critical';
/** Points deducted */
points: number;
/** Description */
description: string;
/** Affected element/area */
affectedArea?: string;
}
/**
* Coverage assessment details
*/
export interface CoverageAssessment {
/** Percentage of page successfully tested */
testCoverage: number;
/** Total elements on page */
totalElements: number;
/** Elements successfully tested */
testedElements: number;
/** Elements that failed to test */
failedElements: number;
/** Coverage quality score */
qualityScore: number;
/** Areas not covered */
uncoveredAreas: string[];
}
/**
* Score calculation metadata
*/
export interface ScoreMetadata {
/** Scoring algorithm version */
algorithmVersion: string;
/** Profile used */
profile: ScoringProfile;
/** Calculation timestamp */
calculatedAt: string;
/** Total calculation time (ms) */
calculationTime: number;
/** Configuration checksum */
configChecksum: string;
}
/**
* Site-wide scoring aggregation
*/
export interface SiteScore {
/** Overall site score */
overallScore: number;
/** Individual page scores */
pageScores: PageScore[];
/** Aggregation method used */
aggregationMethod: 'weighted' | 'average' | 'median' | 'worst';
/** Site-wide bonuses/penalties */
siteWideBonuses: ScoreBonus[];
/** Score distribution statistics */
distribution: ScoreDistribution;
/** Consistency metrics */
consistency: ConsistencyMetrics;
}
/**
* Individual page score summary
*/
export interface PageScore {
/** Page URL */
url: string;
/** Page score */
score: number;
/** Page weight in site calculation */
weight: number;
/** Issues contributing to score */
issueCount: number;
/** Page importance level */
importance: 'critical' | 'high' | 'medium' | 'low';
}
/**
* Score distribution across site
*/
export interface ScoreDistribution {
/** Average score */
average: number;
/** Median score */
median: number;
/** Minimum score */
minimum: number;
/** Maximum score */
maximum: number;
/** Standard deviation */
standardDeviation: number;
/** Score ranges */
ranges: {
excellent: number; // 90-100
good: number; // 80-89
fair: number; // 70-79
poor: number; // 60-69
critical: number; // <60
};
}
/**
* Site consistency metrics
*/
export interface ConsistencyMetrics {
/** Consistency score (0-100) */
consistencyScore: number;
/** Pages with scores significantly below average */
outlierPages: string[];
/** Most common issues across site */
commonIssues: string[];
/** Score variance between pages */
scoreVariance: number;
}
/**
* Comprehensive accessibility scoring algorithm
*/
export class AccessibilityScorer {
private config: ScoringConfig;
// Predefined scoring profiles
private static readonly SCORING_PROFILES: Record<ScoringProfile, Partial<ScoringConfig>> = {
strict: {
wcagLevelWeights: { A: 3.0, AA: 2.5, AAA: 1.5, ARIA: 2.0 },
severityPenalties: { critical: 15, serious: 10, moderate: 6, minor: 3, warning: 1 },
maxPenaltyPerIssue: 20,
minScore: 0,
enableBonuses: false,
enablePenalties: true,
},
balanced: {
wcagLevelWeights: { A: 2.5, AA: 2.0, AAA: 1.0, ARIA: 1.5 },
severityPenalties: { critical: 12, serious: 8, moderate: 5, minor: 2, warning: 0.5 },
maxPenaltyPerIssue: 15,
minScore: 10,
enableBonuses: true,
enablePenalties: true,
},
lenient: {
wcagLevelWeights: { A: 2.0, AA: 1.5, AAA: 0.5, ARIA: 1.0 },
severityPenalties: { critical: 8, serious: 5, moderate: 3, minor: 1, warning: 0 },
maxPenaltyPerIssue: 10,
minScore: 20,
enableBonuses: true,
enablePenalties: true,
},
enterprise: {
wcagLevelWeights: { A: 3.0, AA: 2.5, AAA: 0.8, ARIA: 2.2 },
severityPenalties: { critical: 20, serious: 12, moderate: 6, minor: 2, warning: 0.5 },
maxPenaltyPerIssue: 25,
minScore: 0,
enableBonuses: true,
enablePenalties: true,
},
custom: {},
};
constructor(config: Partial<ScoringConfig> = {}) {
this.config = this.mergeWithProfile(config);
}
/**
* Calculate accessibility score for a single page
*/
calculatePageScore(scanResult: ScanResult): ScoreBreakdown {
const startTime = Date.now();
// Start with perfect score
let baseScore = this.config.maxScore;
// Calculate coverage assessment
const coverage = this.assessCoverage(scanResult);
// Calculate issue deductions
const issueDeductions = this.calculateIssueDeductions(scanResult.issues);
const totalIssueDeduction = issueDeductions.reduce((sum, d) => sum + d.pointsDeducted, 0);
// Apply issue deductions
baseScore -= totalIssueDeduction;
// Calculate bonuses
const bonuses = this.config.enableBonuses
? this.calculateBonuses(scanResult, coverage)
: [];
const totalBonuses = bonuses.reduce((sum, b) => sum + b.points, 0);
// Calculate penalties
const penalties = this.config.enablePenalties
? this.calculatePenalties(scanResult, coverage)
: [];
const totalPenalties = penalties.reduce((sum, p) => sum + p.points, 0);
// Apply bonuses and penalties
const adjustedScore = baseScore + totalBonuses - totalPenalties;
// Ensure score is within bounds
const finalScore = Math.max(
this.config.minScore,
Math.min(this.config.maxScore, adjustedScore)
);
return {
finalScore: Math.round(finalScore * 100) / 100,
baseScore: this.config.maxScore,
issueDeductions,
bonuses,
penalties,
coverage,
metadata: {
algorithmVersion: '1.0.0',
profile: this.config.profile,
calculatedAt: new Date().toISOString(),
calculationTime: Date.now() - startTime,
configChecksum: this.generateConfigChecksum(),
},
};
}
/**
* Calculate site-wide accessibility score
*/
calculateSiteScore(
crawlSession: CrawlSession,
scanResults: ScanResult[],
aggregationMethod: SiteScore['aggregationMethod'] = 'weighted'
): SiteScore {
// Calculate individual page scores
const pageScores: PageScore[] = scanResults.map(result => {
const breakdown = this.calculatePageScore(result);
const urlEntry = crawlSession.urls.get(result.url);
return {
url: result.url,
score: breakdown.finalScore,
weight: this.calculatePageWeight(result, urlEntry?.depth || 0),
issueCount: result.issues.length,
importance: this.determinePageImportance(result.url, urlEntry?.depth || 0),
};
});
// Calculate overall score based on aggregation method
const overallScore = this.aggregateScores(pageScores, aggregationMethod);
// Calculate site-wide bonuses
const siteWideBonuses = this.calculateSiteWideBonuses(crawlSession, scanResults);
// Calculate distribution and consistency metrics
const distribution = this.calculateScoreDistribution(pageScores);
const consistency = this.calculateConsistencyMetrics(pageScores, scanResults);
return {
overallScore,
pageScores,
aggregationMethod,
siteWideBonuses,
distribution,
consistency,
};
}
/**
* Get scoring configuration
*/
getConfig(): ScoringConfig {
return { ...this.config };
}
/**
* Update scoring configuration
*/
updateConfig(newConfig: Partial<ScoringConfig>): void {
this.config = { ...this.config, ...newConfig };
}
/**
* Calculate issue deductions
* @private
*/
private calculateIssueDeductions(issues: AccessibilityIssue[]): IssueDeduction[] {
return issues.map(issue => {
const severityPenalty = this.config.severityPenalties[issue.severity] || 0;
const levelWeight = this.config.wcagLevelWeights[issue.level];
const weight = levelWeight * this.getIssueTypeMultiplier(issue);
const pointsDeducted = Math.min(
severityPenalty * weight,
this.config.maxPenaltyPerIssue
);
return {
issueId: issue.id,
wcagReference: issue.wcagReference,
severity: issue.severity,
level: issue.level,
pointsDeducted,
weight,
reason: `${issue.severity} ${issue.level} issue: ${issue.wcagReference}`,
};
});
}
/**
* Calculate performance and quality bonuses
* @private
*/
private calculateBonuses(scanResult: ScanResult, coverage: CoverageAssessment): ScoreBonus[] {
const bonuses: ScoreBonus[] = [];
// Performance bonus
if (scanResult.metadata?.pageLoadTime && scanResult.metadata.pageLoadTime < 2000) {
bonuses.push({
type: 'performance',
points: 2,
description: 'Fast page load time',
criteria: `Load time: ${scanResult.metadata.pageLoadTime}ms < 2000ms`,
});
}
// Coverage bonus
if (coverage.testCoverage > 95) {
bonuses.push({
type: 'coverage',
points: 3,
description: 'Excellent test coverage',
criteria: `Coverage: ${coverage.testCoverage}% > 95%`,
});
} else if (coverage.testCoverage > 90) {
bonuses.push({
type: 'coverage',
points: 1,
description: 'Good test coverage',
criteria: `Coverage: ${coverage.testCoverage}% > 90%`,
});
}
// Zero critical issues bonus
const criticalIssues = scanResult.issues.filter(i => i.severity === 'critical').length;
if (criticalIssues === 0 && scanResult.issues.length > 0) {
bonuses.push({
type: 'accessibility',
points: 5,
description: 'No critical accessibility issues',
criteria: 'Zero critical issues detected',
});
}
// Perfect accessibility bonus
if (scanResult.issues.length === 0) {
bonuses.push({
type: 'accessibility',
points: 10,
description: 'Perfect accessibility score',
criteria: 'No accessibility issues detected',
});
}
return bonuses;
}
/**
* Calculate system penalties
* @private
*/
private calculatePenalties(scanResult: ScanResult, coverage: CoverageAssessment): ScorePenalty[] {
const penalties: ScorePenalty[] = [];
// Error penalties
if (scanResult.errors && scanResult.errors.length > 0) {
scanResult.errors.forEach(error => {
penalties.push({
type: error.type === 'timeout' ? 'timeout' : 'error',
points: error.type === 'timeout' ? 10 : 5,
description: `Scan error: ${error.message}`,
affectedArea: 'page scanning',
});
});
}
// Coverage penalty
if (coverage.testCoverage < 80) {
penalties.push({
type: 'coverage',
points: Math.round((80 - coverage.testCoverage) * 0.5),
description: 'Low test coverage',
affectedArea: `${coverage.testCoverage}% coverage`,
});
}
// Critical issue penalty
const criticalIssues = scanResult.issues.filter(i => i.severity === 'critical').length;
if (criticalIssues > 3) {
penalties.push({
type: 'critical',
points: (criticalIssues - 3) * 5,
description: 'Multiple critical accessibility issues',
affectedArea: `${criticalIssues} critical issues`,
});
}
return penalties;
}
/**
* Assess test coverage quality
* @private
*/
private assessCoverage(scanResult: ScanResult): CoverageAssessment {
const metadata = scanResult.metadata;
const totalElements = metadata?.totalElements || 0;
const testedElements = metadata?.testedElements || 0;
const failedElements = Math.max(0, totalElements - testedElements);
const testCoverage = totalElements > 0 ? (testedElements / totalElements) * 100 : 100;
// Quality score based on coverage and error rate
const errorRate = failedElements / Math.max(totalElements, 1);
const qualityScore = Math.max(0, testCoverage - (errorRate * 50));
return {
testCoverage: Math.round(testCoverage * 100) / 100,
totalElements,
testedElements,
failedElements,
qualityScore: Math.round(qualityScore * 100) / 100,
uncoveredAreas: this.identifyUncoveredAreas(scanResult),
};
}
/**
* Helper methods for scoring calculations
* @private
*/
private getIssueTypeMultiplier(issue: AccessibilityIssue): number {
// Increase penalty for certain high-impact issue types
if (issue.wcagReference.startsWith('1.1.1')) return 1.2; // Alt text
if (issue.wcagReference.startsWith('2.1.1')) return 1.3; // Keyboard
if (issue.wcagReference.startsWith('1.4.3')) return 1.1; // Contrast
if (issue.wcagReference.startsWith('4.1.2')) return 1.2; // Name, Role, Value
return 1.0;
}
private calculatePageWeight(scanResult: ScanResult, depth: number): number {
// Higher weight for pages closer to root
const depthWeight = Math.max(0.3, 1 - (depth * 0.1));
// Bonus weight for important pages
if (this.isImportantPage(scanResult.url)) {
return depthWeight * 1.5;
}
return depthWeight;
}
private determinePageImportance(url: string, depth: number): PageScore['importance'] {
if (depth === 0) return 'critical'; // Home page
if (this.isImportantPage(url)) return 'high';
if (depth <= 2) return 'medium';
return 'low';
}
private isImportantPage(url: string): boolean {
const importantPaths = [
'/', '/home', '/index',
'/contact', '/about',
'/login', '/register', '/signup',
'/checkout', '/cart', '/payment',
'/search', '/help', '/support'
];
return importantPaths.some(path =>
url.endsWith(path) || url.includes(path + '/')
);
}
private aggregateScores(
pageScores: PageScore[],
method: SiteScore['aggregationMethod']
): number {
if (pageScores.length === 0) return 0;
const scores = pageScores.map(p => p.score);
switch (method) {
case 'weighted':
const totalWeight = pageScores.reduce((sum, p) => sum + p.weight, 0);
return pageScores.reduce((sum, p) => sum + (p.score * p.weight), 0) / totalWeight;
case 'average':
return scores.reduce((sum, score) => sum + score, 0) / scores.length;
case 'median':
const sorted = [...scores].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0
? ((sorted[mid - 1] || 0) + (sorted[mid] || 0)) / 2
: (sorted[mid] || 0);
case 'worst':
return Math.min(...scores);
default:
return this.aggregateScores(pageScores, 'weighted');
}
}
private calculateSiteWideBonuses(
crawlSession: CrawlSession,
scanResults: ScanResult[]
): ScoreBonus[] {
const bonuses: ScoreBonus[] = [];
// Comprehensive scanning bonus
const totalPages = crawlSession.urls.size;
const scannedPages = scanResults.length;
const scanRate = scannedPages / totalPages;
if (scanRate > 0.95) {
bonuses.push({
type: 'coverage',
points: 5,
description: 'Comprehensive site scanning',
criteria: `${scannedPages}/${totalPages} pages scanned (${Math.round(scanRate * 100)}%)`,
});
}
// Consistency bonus
const scores = scanResults.map(r => this.calculatePageScore(r).finalScore);
const variance = this.calculateVariance(scores);
if (variance < 100) { // Low variance indicates consistent quality
bonuses.push({
type: 'accessibility',
points: 3,
description: 'Consistent accessibility across site',
criteria: `Score variance: ${Math.round(variance)}`,
});
}
return bonuses;
}
private calculateScoreDistribution(pageScores: PageScore[]): ScoreDistribution {
const scores = pageScores.map(p => p.score);
const average = scores.reduce((sum, score) => sum + score, 0) / scores.length;
const sorted = [...scores].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
const median = sorted.length % 2 === 0
? ((sorted[mid - 1] || 0) + (sorted[mid] || 0)) / 2
: (sorted[mid] || 0);
const variance = this.calculateVariance(scores);
const standardDeviation = Math.sqrt(variance);
return {
average: Math.round(average * 100) / 100,
median: Math.round(median * 100) / 100,
minimum: Math.min(...scores),
maximum: Math.max(...scores),
standardDeviation: Math.round(standardDeviation * 100) / 100,
ranges: {
excellent: scores.filter(s => s >= 90).length,
good: scores.filter(s => s >= 80 && s < 90).length,
fair: scores.filter(s => s >= 70 && s < 80).length,
poor: scores.filter(s => s >= 60 && s < 70).length,
critical: scores.filter(s => s < 60).length,
},
};
}
private calculateConsistencyMetrics(
pageScores: PageScore[],
scanResults: ScanResult[]
): ConsistencyMetrics {
const scores = pageScores.map(p => p.score);
const average = scores.reduce((sum, score) => sum + score, 0) / scores.length;
const variance = this.calculateVariance(scores);
// Consistency score: higher variance = lower consistency
const consistencyScore = Math.max(0, 100 - (variance / 10));
// Find outlier pages (scores significantly below average)
const threshold = average - (Math.sqrt(variance) * 1.5);
const outlierPages = pageScores
.filter(p => p.score < threshold)
.map(p => p.url);
// Find most common issues across site
const allIssues = scanResults.flatMap(r => r.issues);
const issueFrequency = new Map<string, number>();
allIssues.forEach(issue => {
const key = issue.wcagReference;
issueFrequency.set(key, (issueFrequency.get(key) || 0) + 1);
});
const commonIssues = Array.from(issueFrequency.entries())
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([issue]) => issue);
return {
consistencyScore: Math.round(consistencyScore * 100) / 100,
outlierPages,
commonIssues,
scoreVariance: Math.round(variance * 100) / 100,
};
}
private calculateVariance(numbers: number[]): number {
if (numbers.length === 0) return 0;
const mean = numbers.reduce((sum, num) => sum + num, 0) / numbers.length;
const squaredDifferences = numbers.map(num => Math.pow(num - mean, 2));
return squaredDifferences.reduce((sum, sqDiff) => sum + sqDiff, 0) / numbers.length;
}
private identifyUncoveredAreas(scanResult: ScanResult): string[] {
const uncovered: string[] = [];
// This would be enhanced with actual coverage analysis
if (scanResult.errors?.some(e => e.type === 'timeout')) {
uncovered.push('Dynamic content (timeout during loading)');
}
if (scanResult.errors?.some(e => e.type === 'parsing')) {
uncovered.push('Malformed HTML content');
}
return uncovered;
}
private mergeWithProfile(config: Partial<ScoringConfig>): ScoringConfig {
const profile = config.profile || 'balanced';
const profileConfig = AccessibilityScorer.SCORING_PROFILES[profile];
const defaultConfig: ScoringConfig = {
profile,
wcagLevelWeights: { A: 2.5, AA: 2.0, AAA: 1.0, ARIA: 1.5 },
severityPenalties: { critical: 12, serious: 8, moderate: 5, minor: 2, warning: 0.5 },
maxPenaltyPerIssue: 15,
minScore: 10,
maxScore: 100,
enableBonuses: true,
enablePenalties: true,
transparencyLevel: 'detailed',
};
return { ...defaultConfig, ...profileConfig, ...config };
}
private generateConfigChecksum(): string {
// Simple checksum for configuration validation
const configStr = JSON.stringify(this.config);
let hash = 0;
for (let i = 0; i < configStr.length; i++) {
const char = configStr.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash).toString(16);
}
/**
* Static utility methods
*/
/**
* Get all available scoring profiles
*/
static getAvailableProfiles(): ScoringProfile[] {
return Object.keys(AccessibilityScorer.SCORING_PROFILES) as ScoringProfile[];
}
/**
* Get profile configuration
*/
static getProfileConfig(profile: ScoringProfile): Partial<ScoringConfig> {
return AccessibilityScorer.SCORING_PROFILES[profile] || {};
}
/**
* Create scorer with predefined profile
*/
static withProfile(profile: ScoringProfile, overrides?: Partial<ScoringConfig>): AccessibilityScorer {
return new AccessibilityScorer({ profile, ...overrides });
}
}