diff --git a/README.md b/README.md index 7bbebb0..73be8ba 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ -# YakNet Accessibility Console 3.0 (AI-Powered Static Analysis & WCAG 2.2 Linter) +# YakNet Accessibility Console 3.3 (AI-Powered Static Analysis & WCAG 2.2 Linter) [](https://php.net) [](https://phpstan.org) -[](https://phpunit.de) -[](https://www.w3.org/WAI/standards-guidelines/wcag/) +[](https://phpunit.de) +[](https://www.w3.org/WAI/standards-guidelines/wcag/) [](LICENSE) [](https://ai.google.dev) -**YakNet Accessibility Console**, web projelerinizdeki erişilebilirlik (WCAG 2.1, 2.2 ve WAI-ARIA) ve HTML söz dizimi hatalarını statik olarak analiz eden, **127 benzersiz kural setiyle** denetleyen ve **Yapay Zeka (Google Gemini AI)** desteğiyle kaynak kodunuzu otomatik tamir edebilen (Self-Healing) kurumsal düzeyde bir PHP statik analiz ve linter kütüphanesidir. +**YakNet Accessibility Console**, web projelerinizdeki erişilebilirlik (WCAG 2.1, 2.2 ve WAI-ARIA) ve HTML söz dizimi hatalarını statik olarak analiz eden, **130 benzersiz kural setiyle** denetleyen, kurumsal CI/CD kalite kapısı (Quality Gates) sunan ve **Yapay Zeka (Google Gemini AI) ile Çevrimdışı Deterministik Motor** desteğiyle kaynak kodunuzu otomatik tamir edebilen (Self-Healing) kurumsal düzeyde bir PHP statik analiz ve linter kütüphanesidir. PHPStan ve PHP-CS-Fixer esintili modern mimarisiyle; Laravel Blade (`.blade.php`), Twig (`.twig`), PHP (`.php`) ve standart HTML şablonlarınızı doğrudan tarar, baseline desteğiyle mevcut teknik borcu yönetir ve CI/CD pipeline süreçlerinize sıfır yapılandırmayla entegre olur. @@ -15,7 +15,13 @@ PHPStan ve PHP-CS-Fixer esintili modern mimarisiyle; Laravel Blade (`.blade.php` ## 🌟 Öne Çıkan Özellikler -- **🛡️ 127 Benzersiz WCAG 2.1 / 2.2 & WAI-ARIA Kuralı:** Algılanabilir (Perceivable), Çalıştırılabilir (Operable), Anlaşılabilir (Understandable) ve Sağlam (Robust) prensiplerine göre yapılandırılmış 5 kademeli (Level 1-5) kural havuzu. +- **🛡️ 130 Benzersiz WCAG 2.1 / 2.2 & WAI-ARIA Kuralı:** Algılanabilir (Perceivable), Çalıştırılabilir (Operable), Anlaşılabilir (Understandable) ve Sağlam (Robust) prensiplerine göre yapılandırılmış 5 kademeli kural havuzu. (Yeni: `LabelInName` WCAG 2.5.3, `RedundantEntry` WCAG 3.3.7, `TextSpacing` WCAG 1.4.12). +- **🏛️ Sektörel ve Mevzuat Ön Ayarları (Presets):** US Section 508, Avrupa Birliği EN 301 549 ve E-Ticaret (`--preset=section508|en301549|ecommerce`) profilleriyle hedeflenmiş standart denetimi. +- **🏷️ Dinamik SVG Rozet Üretimi (Badges):** Tarama tamamlandığında sağlık skoruna göre (yeşil/sarı/kırmızı) canlı SVG rozet (`--badge=public/a11y-badge.svg`) üretir. +- **📢 CI/CD Webhook Bildirimleri (Notifications):** Tarama sonuçlarını Slack ve Discord webhook kanallarına otomatik formatlanmış zengin mesajlarla iletir. +- **⚙️ Programatik Kural Denetleyicisi & İstatistikler:** `RuleController` ile DOM elementleri üzerinde doğrudan tekil kural yürütme ve `RuleExecutionStats` ile kural performans/yürütme süresi izleme. +- **⏱️ Gerçek Zamanlı İzleme Modu (`watch`):** Şablon dosyalarını (`.blade.php`, `.twig`, `.html`) arka planda izler, dosya kaydedildiğinde anında hızlı analiz yapıp konsolda canlı skor ve geri bildirim sunar. +- **🚦 CI/CD Kalite Kapısı Eşikleri (Quality Gates):** `--min-score=90`, `--max-warnings=5`, `--fail-on=error` seçenekleriyle pipeline süreçlerinde esnek kalite denetimi sağlar. - **🔍 HTML & Şablon Söz Dizimi (Syntax/AST) Linter'ı:** - Aynı etiket üzerinde yinelenen nitelikler (`Duplicate Attributes` - HTML5 3.2.4 ihlali) - Kapanmamış veya çapraz kapanmış yapısal etiketler (`Mismatched / Unclosed Tags`) @@ -25,11 +31,17 @@ PHPStan ve PHP-CS-Fixer esintili modern mimarisiyle; Laravel Blade (`.blade.php` - **⚡ Tek Geçişli (Single-Pass) Yüksek Hızlı Kural Motoru:** DOM ağacını tek bir geçişte tarayarak büyük şablonlarda 5-10 kat daha hızlı analiz gerçekleştirir. - **📝 1:1 Satır Eşlemeli Şablon Ön İşleyici (Template Preprocessor):** Blade direktiflerini (`@if`, `@foreach`, `@component`), Twig bloklarını (`{% %}`, `{{ }}`) ve PHP etiketlerini temizlerken satır sayılarını birebir korur. - **🎯 Yüksek Hassasiyetli Satır & Sütun Bulucu (`PreciseElementLocator`):** Çok satırlı ve dinamik şablon bileşenlerinde ihlalin kaynak koddaki tam satır ve sütun numarasını saptar. -- **🤖 Yapay Zeka ile Otomatik Onarım (AI Self-Healing):** Tespit edilen WCAG ve söz dizimi hataları için Google Gemini AI üzerinden akıllı çözüm ve tamir kodları üretir; kaynak dosyalarınızı (`--dry-run` desteğiyle) anında onarır. +- **🤖 Yapay Zeka & Çevrimdışı Otomatik Onarım (Self-Healing):** + - **Çevrimdışı (Offline) Mod:** API anahtarı olmadan güvenli deterministik tamirler (`alt=""`, `aria-hidden="true"`, `rel="noopener noreferrer"`, `tabindex="0"`, `scope` temizliği vb.). + - **AI Destekli Mod:** Karmaşık şablon mantığı ve etiketler için Google Gemini AI ile akıllı tamir. - **📊 Erişilebilirlik Analitiği & Uyumluluk Matrisi:** POUR prensipleri, WCAG A/AA/AAA başarı oranları ve KLOC başına hata yoğunluğu (Defect Density) istatistikleri. - **🛡️ Baseline Desteği (`a11y-baseline.json`):** Mevcut hataları dondurarak CI/CD süreçlerinin yalnızca **yeni eklenen** hatalarda kırılmasını sağlar. -- **🔌 Çoklu Çıktı Formatları:** Renkli konsol tablosu (`console`), makine tarafından okunabilir JSON (`json`), GitHub Actions PR bildirimleri (`github`) ve interaktif HTML Dashboard raporu (`--report`). -- **🏆 PHPStan Level 9 & %100 Tip Güvenliği:** Kod tabanı PHPStan en yüksek seviye (Level 9) ve 191+ birim testi ile tam doğrulanmıştır. +- **🔌 Zengin Çıktı & Rapor Formatları:** + - Konsol tablosu (`console`), JSON (`json`), GitHub Actions (`github`), GitLab SAST (`gitlab`) + - **OASIS SARIF 2.1.0 (`sarif`):** GitHub Code Scanning / Security Alerts ve VS Code SARIF uyumlu. + - **JUnit XML (`junit`):** Jenkins, GitLab CI, CircleCI, Bitbucket Pipelines uyumlu. + - **Çoklu Dışa Aktarım (`--report`):** HTML (`.html`), JSON (`.json`), SARIF (`.sarif`), JUnit (`.xml`), PDF (`.pdf`) ve Excel (`.xlsx`). +- **🏆 PHPStan Level 9 & %100 Tip Güvenliği:** Kod tabanı PHPStan en yüksek seviye (Level 9) ve 230 birim testi ile tam doğrulanmıştır. --- @@ -66,20 +78,35 @@ Canlı bir web sitesini crawler desteğiyle taramak için: bin/a11y scan https://siteniz.com --crawl ``` -### 3. Kural Seviyesi Belirleme (`--level`) -1 (Temel) ile 5 (Çok Katı / Strict) arasında bir seviye belirleyin: +### 3. CI/CD Kalite Kapısı Eşikleri (Quality Gates) +Pipeline süreçlerinde derlemenin kırılma şartlarını belirleyin: ```bash -bin/a11y scan --level=5 +# Sağlık skoru 90'ın altındaysa derlemeyi başarısız yap: +bin/a11y scan --min-score=90 + +# En fazla 3 uyarıya izin ver: +bin/a11y scan --max-warnings=3 + +# Yalnızca kritik ve hata seviyesindeki ihlallerde kırıl (uyarıları göz ardı et): +bin/a11y scan --fail-on=error ``` -### 4. Baseline (Teknik Borç Dondurma) Yönetimi -Mevcut tüm ihlalleri `a11y-baseline.json` dosyasına kaydederek sonraki taramalarda yok sayılmasını sağlar: +### 4. Gerçek Zamanlı İzleme Modu (`watch`) +Geliştirme yaparken şablon dosyalarınızı izleyin ve dosya kaydedildiğinde anında otomatik re-scan çalıştırın: ```bash -bin/a11y scan --generate-baseline +bin/a11y watch resources/views ``` ### 5. Format ve Raporlama Seçenekleri -- **GitHub Actions formatında çıktı:** +- **GitHub Code Scanning / SARIF 2.1.0 formatı:** + ```bash + bin/a11y scan --format=sarif + ``` +- **JUnit XML formatı (Jenkins, GitLab CI):** + ```bash + bin/a11y scan --format=junit + ``` +- **GitHub Actions bildirim formatı:** ```bash bin/a11y scan --format=github ``` @@ -87,25 +114,55 @@ bin/a11y scan --generate-baseline ```bash bin/a11y scan --format=json ``` -- **İnteraktif HTML Dashboard Raporu:** +- **Çoklu Dışa Aktarım Formatları (`--report`):** ```bash - bin/a11y scan --report=a11y-report.html + bin/a11y scan --report=rapor.html # İnteraktif HTML Dashboard + bin/a11y scan --report=rapor.pdf # PDF Sertifika ve Denetim Raporu + bin/a11y scan --report=rapor.xlsx # Microsoft Excel Çalışma Kitabı + bin/a11y scan --report=rapor.sarif # SARIF 2.1.0 Dosyası + bin/a11y scan --report=rapor.xml # JUnit XML Raporu ``` -### 6. Yapay Zeka Destekli Otomatik Düzeltme (`fix`) -Tespit edilen erişilebilirlik sorunlarını yapay zeka ile kaynak dosyalarınızda otomatik olarak onarın: +### 6. Mevzuat Ön Ayarları (`--preset`) ve Rozet Üretimi (`--badge`) +Mevzuat veya sektörel kurallara göre hedeflenmiş tarama yapın ve canlı SVG rozet oluşturun: ```bash -bin/a11y fix -``` -Dosyaları değiştirmeden yalnızca yapılacak düzeltme önerilerini görmek için: -```bash -bin/a11y fix --dry-run +# US Section 508 federal uyumluluk profiliyle tara: +bin/a11y scan --preset=section508 + +# Avrupa Birliği EN 301 549 kamu web standardı profiliyle tara: +bin/a11y scan --preset=en301549 + +# E-Ticaret ödeme ve sepet akışları profiliyle tara: +bin/a11y scan --preset=ecommerce + +# Tarama sonucunu SVG rozet olarak kaydet (README veya CI/CD artifact için): +bin/a11y scan --badge=public/badges/a11y.svg ``` -### 7. Kuralları Listeleme (`rules`) -Kütüphanede tanımlı 127 kuralı seviyeleri, WCAG standartları ve açıklamalarıyla listeler: +### 7. Otomatik Düzeltme (`fix`) +Yapay zeka veya çevrimdışı yerel motor ile kaynak dosyalarınızı onarın: ```bash +# Gemini AI desteğiyle otomatik onarım: +bin/a11y fix + +# Gemini API anahtarı olmadan tamamen çevrimdışı (offline) yerel motorla onarım: +bin/a11y fix --offline + +# Dosyaları değiştirmeden yapılacak düzeltmeleri önizleme: +bin/a11y fix --offline --dry-run +``` + +### 8. Kuralları Listeleme ve Filtreleme (`rules`) +Kütüphanede tanımlı 130 kuralı listeleyin, arayın ve filtreleyin: +```bash +# Tüm kuralları listele: bin/a11y rules + +# Kural adına veya açıklamasına göre ara: +bin/a11y rules --search=contrast + +# Seviye ve standarda göre filtrele: +bin/a11y rules --standard=AA --level=3 ``` --- diff --git a/bin/a11y b/bin/a11y index 2ebcc66..50775b8 100644 --- a/bin/a11y +++ b/bin/a11y @@ -16,14 +16,16 @@ use YakNet\AccessibilityConsole\Console\Command\ScanCommand; use YakNet\AccessibilityConsole\Console\Command\FixCommand; use YakNet\AccessibilityConsole\Console\Command\InitCommand; use YakNet\AccessibilityConsole\Console\Command\RulesCommand; +use YakNet\AccessibilityConsole\Console\Command\WatchCommand; $container = ContainerFactory::create(); -$application = new Application('YakNet Accessibility Console', '3.2.7'); +$application = new Application('YakNet Accessibility Console', '3.3.0'); // Add commands resolved via DI Container $application->addCommand($container->get(ScanCommand::class)); $application->addCommand($container->get(FixCommand::class)); $application->addCommand($container->get(InitCommand::class)); $application->addCommand($container->get(RulesCommand::class)); +$application->addCommand($container->get(WatchCommand::class)); $application->run(); diff --git a/composer.json b/composer.json index 492b5c9..b4b2939 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ "yaknet" ], "homepage": "https://forum.yak.net.tr", - "version": "3.2.7", + "version": "3.3.0", "type": "library", "license": "MIT", "authors": [ diff --git a/src/Badges/BadgeGenerator.php b/src/Badges/BadgeGenerator.php new file mode 100644 index 0000000..a141ae1 --- /dev/null +++ b/src/Badges/BadgeGenerator.php @@ -0,0 +1,105 @@ +rightColor ?? ($score >= 90 ? '#10b981' : ($score >= 70 ? '#f59e0b' : '#ef4444')); + $label = $opts->label; + $value = "{$score}/100"; + + return self::renderBadge($label, $value, $color, $opts); + } + + /** + * Generate an SVG badge representing WCAG compliance status. + * + * @param string $standard E.g. 'WCAG 2.1 AA' + * @param bool $compliant + * @param BadgeOptions|null $options + * @return string Raw SVG markup + */ + public static function generateComplianceBadge(string $standard = 'WCAG 2.1 AA', bool $compliant = true, ?BadgeOptions $options = null): string + { + $opts = $options ?? new BadgeOptions(); + $color = $opts->rightColor ?? ($compliant ? '#10b981' : '#ef4444'); + $value = $compliant ? 'passed' : 'failed'; + + return self::renderBadge($standard, $value, $color, $opts); + } + + /** + * Generate an SVG badge for total violations count. + * + * @param int $violationsCount + * @param BadgeOptions|null $options + * @return string Raw SVG markup + */ + public static function generateViolationsBadge(int $violationsCount, ?BadgeOptions $options = null): string + { + $opts = $options ?? new BadgeOptions(); + $color = $opts->rightColor ?? ($violationsCount === 0 ? '#10b981' : ($violationsCount < 5 ? '#f59e0b' : '#ef4444')); + $value = $violationsCount === 0 ? '0 issues' : "{$violationsCount} issues"; + $label = $opts->label !== 'accessibility' ? $opts->label : 'a11y issues'; + + return self::renderBadge($label, $value, $color, $opts); + } + + /** + * Render a standard flat shield SVG badge. + */ + private static function renderBadge(string $label, string $value, string $rightColor, ?BadgeOptions $options = null): string + { + $opts = $options ?? new BadgeOptions(); + $charWidth = 7; + $padding = 12; + $leftWidth = (int)(mb_strlen($label) * $charWidth + $padding); + $rightWidth = (int)(mb_strlen($value) * $charWidth + $padding); + $totalWidth = $leftWidth + $rightWidth; + $height = 20; + + $leftCenter = (int)($leftWidth / 2); + $rightCenter = (int)($leftWidth + ($rightWidth / 2)); + + $escLabel = htmlspecialchars($label); + $escValue = htmlspecialchars($value); + $borderRadius = $opts->borderRadius; + $leftColor = $opts->leftColor; + $fontFamily = htmlspecialchars($opts->fontFamily); + $fontSize = $opts->fontSize; + + return << + {$escLabel}: {$escValue} + + + + + + + + + + + + + + {$escLabel} + {$escLabel} + {$escValue} + {$escValue} + + +SVG; + } +} diff --git a/src/Badges/BadgeOptions.php b/src/Badges/BadgeOptions.php new file mode 100644 index 0000000..f7df312 --- /dev/null +++ b/src/Badges/BadgeOptions.php @@ -0,0 +1,27 @@ +addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Path to the configuration file (default: a11y.yaml)') ->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping (URL scans only)') ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be fixed without modifying files') + ->addOption('offline', null, InputOption::VALUE_NONE, 'Perform deterministic offline rule-based fixes without calling AI') ->addOption('level', 'l', InputOption::VALUE_REQUIRED, 'Accessibility scan level (1-9)') ->addOption('js', null, InputOption::VALUE_NONE, 'Enable JavaScript rendering using Headless Chrome (URL scans only)'); } @@ -39,10 +40,11 @@ class FixCommand extends Command $configPath = is_string($configPathOption) ? $configPathOption : getcwd() . '/a11y.yaml'; $config = Config::fromYaml($configPath); + $offline = (bool)$input->getOption('offline'); $apiKey = $config->getGeminiApiKey(); - if (!$apiKey) { - $io->error("GEMINI_API_KEY not found in .env or environment variables. AI fixing requires an API key."); - return Command::FAILURE; + if (!$offline && !$apiKey) { + $io->note("GEMINI_API_KEY not found in .env or environment variables. Running in offline rule-based healing mode (--offline)."); + $offline = true; } $levelOption = $input->getOption('level'); @@ -53,11 +55,11 @@ class FixCommand extends Command // If target is a URL, run the legacy URL fixing logic if ($isUrl) { - return $this->executeUrlFix($target, $level, $apiKey, $input, $output, $io); + return $this->executeUrlFix($target, $level, $apiKey, $input, $output, $io, $offline); } // --- LOCAL FILE / DIRECTORY SELF-HEALING ENGINE --- - $io->title("AI Self-Healing Mode - Directory Static Analysis"); + $io->title($offline ? "Offline Rule-Based Self-Healing Mode" : "AI Self-Healing Mode - Directory Static Analysis"); $io->writeln("Configuration loaded from:> $configPath>"); $dryRun = (bool)$input->getOption('dry-run'); @@ -87,9 +89,9 @@ class FixCommand extends Command return Command::SUCCESS; } - $io->note(sprintf("Found %d violations. Starting AI analysis and healing...", count($violations))); + $io->note(sprintf("Found %d violations. Starting %s analysis and healing...", count($violations), $offline ? 'offline rule-based' : 'AI')); - $fixer = new AiFixerManager($apiKey); + $fixer = !$offline ? new AiFixerManager($apiKey) : null; $diffGenerator = new \YakNet\AccessibilityConsole\Core\Diff\VisualDiffGenerator(); $fixedCount = 0; @@ -129,9 +131,12 @@ class FixCommand extends Command if ($rawSuggestion !== null) { $isLocalFix = true; - } else { + } elseif ($fixer !== null) { // Fallback to AI Suggestion $rawSuggestion = $fixer->suggestFix($violation); + } else { + $io->writeln("Skipped (Rule requires AI or manual intervention)>"); + continue; } $suggestion = $rawSuggestion; @@ -189,7 +194,7 @@ class FixCommand extends Command /** * Legacy URL scan and fix logic */ - private function executeUrlFix(string $target, int $level, string $apiKey, InputInterface $input, OutputInterface $output, SymfonyStyle $io): int + private function executeUrlFix(string $target, int $level, ?string $apiKey, InputInterface $input, OutputInterface $output, SymfonyStyle $io, bool $offline = false): int { $projectPathOption = $input->getOption('project-path'); $projectPath = is_string($projectPathOption) ? $projectPathOption : getcwd(); @@ -199,7 +204,7 @@ class FixCommand extends Command $dryRun = (bool)$input->getOption('dry-run'); $useJs = (bool)$input->getOption('js'); - $io->title("AI Self-Healing Mode - Fixing URL: $target"); + $io->title($offline ? "Offline Rule-Based Mode - Fixing URL: $target" : "AI Self-Healing Mode - Fixing URL: $target"); // Fetch HTML if ($useJs) { @@ -231,9 +236,9 @@ class FixCommand extends Command return Command::SUCCESS; } - $io->note("Found " . count($violations) . " violations. Starting AI analysis..."); + $io->note(sprintf("Found %d violations. Starting %s analysis...", count($violations), $offline ? 'offline' : 'AI')); - $fixer = new AiFixerManager($apiKey); + $fixer = (!$offline && is_string($apiKey) && $apiKey !== '') ? new AiFixerManager($apiKey) : null; $diffGenerator = new \YakNet\AccessibilityConsole\Core\Diff\VisualDiffGenerator(); $locator = new SourceLocator($projectPath); $fixedCount = 0; @@ -254,8 +259,11 @@ class FixCommand extends Command if ($rawSuggestion !== null) { $isLocalFix = true; - } else { + } elseif ($fixer !== null) { $rawSuggestion = $fixer->suggestFix($violation); + } else { + $io->writeln("Skipped (Rule requires AI or manual intervention)>"); + continue; } $suggestion = $rawSuggestion; diff --git a/src/Console/Command/RulesCommand.php b/src/Console/Command/RulesCommand.php index e21b385..335bc92 100644 --- a/src/Console/Command/RulesCommand.php +++ b/src/Console/Command/RulesCommand.php @@ -9,13 +9,19 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use YakNet\AccessibilityConsole\Rules\RuleLevels; +use Symfony\Component\Console\Input\InputOption; + class RulesCommand extends Command { protected function configure(): void { $this ->setName('rules') - ->setDescription('List all available accessibility scanning rules'); + ->setDescription('List all available accessibility scanning rules') + ->addOption('search', 's', InputOption::VALUE_REQUIRED, 'Search rules by ID or description') + ->addOption('level', 'l', InputOption::VALUE_REQUIRED, 'Filter rules by maximum level (1-5)') + ->addOption('standard', null, InputOption::VALUE_REQUIRED, 'Filter rules by standard (A, AA, AAA)') + ->addOption('severity', null, InputOption::VALUE_REQUIRED, 'Filter rules by severity (error, warning, info)'); } protected function execute(InputInterface $input, OutputInterface $output): int @@ -23,10 +29,18 @@ class RulesCommand extends Command $io = new SymfonyStyle($input, $output); $io->title("Accessibility Console - Available Rules"); + $searchQuery = $input->getOption('search'); + $levelFilter = $input->getOption('level'); + $standardFilter = $input->getOption('standard'); + $severityFilter = $input->getOption('severity'); + + $maxLevel = is_numeric($levelFilter) ? (int)$levelFilter : 9; + $rules = RuleLevels::getRulesForLevel($maxLevel); + $table = new Table($output); $table->setHeaders(['Rule ID', 'Standard', 'Severity', 'Level', 'Description']); - $rules = RuleLevels::getRulesForLevel(9); + $matchedCount = 0; foreach ($rules as $rule) { $ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface) ? $rule->getId() @@ -36,6 +50,28 @@ class RulesCommand extends Command $standard = method_exists($rule, 'getStandard') ? $rule->getStandard()->value : ''; $severity = method_exists($rule, 'getSeverity') ? $rule->getSeverity()->value : ''; $level = method_exists($rule, 'getLevel') ? $rule->getLevel() : 4; + + // Search filter + if (is_string($searchQuery) && $searchQuery !== '') { + $query = strtolower($searchQuery); + if (!str_contains(strtolower($ruleId), $query) && !str_contains(strtolower($description), $query)) { + continue; + } + } + + // Standard filter + if (is_string($standardFilter) && $standardFilter !== '') { + if (strcasecmp($standard, $standardFilter) !== 0) { + continue; + } + } + + // Severity filter + if (is_string($severityFilter) && $severityFilter !== '') { + if (strcasecmp($severity, $severityFilter) !== 0) { + continue; + } + } $table->addRow([ "$ruleId>", @@ -44,11 +80,17 @@ class RulesCommand extends Command $level, $description ]); + $matchedCount++; } $table->render(); $io->newLine(); - $io->note("Total rules available: " . count($rules)); + + if (is_string($searchQuery) || $levelFilter !== null || is_string($standardFilter) || is_string($severityFilter)) { + $io->note(sprintf("Showing %d matching rules (out of %d total rules).", $matchedCount, count(RuleLevels::getRulesForLevel(9)))); + } else { + $io->note("Total rules available: " . count($rules)); + } return Command::SUCCESS; } diff --git a/src/Console/Command/ScanCommand.php b/src/Console/Command/ScanCommand.php index 4eaba53..8d91ace 100644 --- a/src/Console/Command/ScanCommand.php +++ b/src/Console/Command/ScanCommand.php @@ -14,6 +14,7 @@ use YakNet\AccessibilityConsole\Core\Analyser; use YakNet\AccessibilityConsole\Core\BaselineManager; use YakNet\AccessibilityConsole\Core\Config; use YakNet\AccessibilityConsole\Core\Scanner; +use YakNet\AccessibilityConsole\Core\Severity; use YakNet\AccessibilityConsole\Rules\RuleLevels; use YakNet\AccessibilityConsole\Reporting\ConsoleFormatter; use YakNet\AccessibilityConsole\Reporting\GithubFormatter; @@ -22,6 +23,12 @@ use YakNet\AccessibilityConsole\Reporting\XmlFormatter; use YakNet\AccessibilityConsole\Reporting\MarkdownFormatter; use YakNet\AccessibilityConsole\Reporting\CsvFormatter; use YakNet\AccessibilityConsole\Reporting\GitLabCodeQualityFormatter; +use YakNet\AccessibilityConsole\Reporting\SarifFormatter; +use YakNet\AccessibilityConsole\Reporting\JunitFormatter; +use YakNet\AccessibilityConsole\Reporting\PdfReportGenerator; +use YakNet\AccessibilityConsole\Reporting\ExcelReportExporter; +use YakNet\AccessibilityConsole\Badges\BadgeGenerator; +use YakNet\AccessibilityConsole\Presets\PresetRegistry; use YakNet\AccessibilityConsole\Source\SourceLocator; class ScanCommand extends Command @@ -35,12 +42,17 @@ class ScanCommand extends Command ->addArgument('target', InputArgument::OPTIONAL, 'The URL, file, or directory path to scan') ->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Path to the configuration file (default: a11y.yaml)') ->addOption('level', 'l', InputOption::VALUE_REQUIRED, 'Accessibility scan level (1-9)') - ->addOption('format', 'f', InputOption::VALUE_REQUIRED, 'Output format (console, json, github)') + ->addOption('preset', 'p', InputOption::VALUE_REQUIRED, 'Use a pre-configured audit profile (section508, en301549, ecommerce)') + ->addOption('format', 'f', InputOption::VALUE_REQUIRED, 'Output format (console, json, github, xml, markdown, csv, gitlab, sarif, junit)') + ->addOption('min-score', null, InputOption::VALUE_REQUIRED, 'Minimum required accessibility health score (0-100) to pass the quality gate') + ->addOption('max-warnings', null, InputOption::VALUE_REQUIRED, 'Maximum allowed warnings before failing the quality gate') + ->addOption('fail-on', null, InputOption::VALUE_REQUIRED, 'Minimum severity to fail on (error, warning, info)') ->addOption('generate-baseline', null, InputOption::VALUE_NONE, 'Generate a baseline file containing all current violations') ->addOption('baseline', null, InputOption::VALUE_REQUIRED, 'Path to the baseline file') ->addOption('no-progress', null, InputOption::VALUE_NONE, 'Disable progress bar') ->addOption('ai', null, InputOption::VALUE_NONE, 'Enable AI-powered fix suggestions') - ->addOption('report', null, InputOption::VALUE_REQUIRED, 'Save report to file (html or json)') + ->addOption('report', null, InputOption::VALUE_REQUIRED, 'Save report to file (html, json, sarif, xml, junit, pdf, xlsx)') + ->addOption('badge', null, InputOption::VALUE_REQUIRED, 'Save SVG accessibility health badge to file (e.g. a11y-badge.svg)') ->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping (URL scans only)') ->addOption('crawl', null, InputOption::VALUE_NONE, 'Crawl internal links recursively (URL scans only)') ->addOption('depth', null, InputOption::VALUE_REQUIRED, 'Maximum crawling depth (URL scans only)', '3') @@ -61,6 +73,18 @@ class ScanCommand extends Command $levelOption = $input->getOption('level'); $level = is_numeric($levelOption) ? (int)$levelOption : $config->getLevel(); + $presetOption = $input->getOption('preset'); + $activePreset = null; + if (is_string($presetOption) && $presetOption !== '') { + $presetRegistry = new PresetRegistry(); + $activePreset = $presetRegistry->get($presetOption); + if ($activePreset !== null) { + $level = $activePreset->getLevel(); + } else { + $io->warning("Preset '{$presetOption}' not found. Using default rules."); + } + } + $formatOption = $input->getOption('format'); $format = is_string($formatOption) ? $formatOption : $config->getFormat(); @@ -79,9 +103,14 @@ class ScanCommand extends Command } // --- LOCAL DIRECTORY / FILE STATIC ANALYSIS ENGINE --- - $io->title("Accessibility Console - Static Analysis"); - $io->writeln("Configuration loaded from:> $configPath>"); - $io->writeln("Rule Level:> $level>"); + if ($format === 'console') { + $io->title("Accessibility Console - Static Analysis"); + $io->writeln("Configuration loaded from:> $configPath>"); + $io->writeln("Rule Level:> $level>"); + if ($activePreset !== null) { + $io->writeln("Compliance Preset:> " . $activePreset->getName() . " (" . $activePreset->getDescription() . ")>"); + } + } // Resolve paths to scan $paths = $config->getPaths(); @@ -214,38 +243,88 @@ class ScanCommand extends Command $formatter = new CsvFormatter(); } elseif ($format === 'gitlab') { $formatter = new GitLabCodeQualityFormatter(); + } elseif ($format === 'sarif') { + $formatter = new SarifFormatter(); + } elseif ($format === 'junit') { + $formatter = new JunitFormatter(); } $exitCode = $formatter->format($violations, $baselinedCount, $io); + $metrics = \YakNet\AccessibilityConsole\Core\Metrics\AccessibilityScoreCalculator::calculate($violations); + $score = $metrics['score']; + if ($format === 'console') { - $metrics = \YakNet\AccessibilityConsole\Core\Metrics\AccessibilityScoreCalculator::calculate($violations); $io->section("Accessibility Ratings"); - $color = $metrics['score'] >= 90 ? 'green' : ($metrics['score'] >= 70 ? 'yellow' : 'red'); - $io->writeln(sprintf("Health Score: %d/100>", $color, $metrics['score'])); + $color = $score >= 90 ? 'green' : ($score >= 70 ? 'yellow' : 'red'); + $io->writeln(sprintf("Health Score: %d/100>", $color, $score)); $io->writeln(sprintf(" - Screen Reader / Visual Impact: %d%%>", $metrics['visualImpact'])); $io->writeln(sprintf(" - Keyboard / Motor Impact: %d%%>", $metrics['motorImpact'])); $io->writeln(sprintf(" - Cognitive / Distraction Impact: %d%%>", $metrics['cognitiveImpact'])); $io->newLine(); } + // Quality Gate evaluation + $minScoreOption = $input->getOption('min-score'); + $maxWarningsOption = $input->getOption('max-warnings'); + $failOnOption = $input->getOption('fail-on'); + + $errorCount = 0; + $warningCount = 0; + foreach ($violations as $v) { + if ($v->severity === Severity::CRITICAL || $v->severity === Severity::ERROR) { + $errorCount++; + } elseif ($v->severity === Severity::WARNING) { + $warningCount++; + } + } + + $qualityGateFailed = false; + /** @var string[] $gateFailures */ + $gateFailures = []; + + if ($minScoreOption !== null && is_numeric($minScoreOption)) { + $minScore = (int)$minScoreOption; + if ($score < $minScore) { + $qualityGateFailed = true; + $gateFailures[] = sprintf("Accessibility score (%d/100) is lower than the minimum required threshold of %d/100.", $score, $minScore); + } + } + + if ($maxWarningsOption !== null && is_numeric($maxWarningsOption)) { + $maxWarnings = (int)$maxWarningsOption; + if ($warningCount > $maxWarnings) { + $qualityGateFailed = true; + $gateFailures[] = sprintf("Warning count (%d) exceeds the maximum allowed limit of %d.", $warningCount, $maxWarnings); + } + } + + if (is_string($failOnOption)) { + $failOn = strtolower($failOnOption); + if ($failOn === 'error') { + $exitCode = ($errorCount > 0) ? Command::FAILURE : Command::SUCCESS; + } elseif ($failOn === 'warning') { + $exitCode = ($errorCount > 0 || $warningCount > 0) ? Command::FAILURE : Command::SUCCESS; + } + } + + if ($qualityGateFailed) { + $io->newLine(); + $io->error("CI/CD Quality Gate Failed:\n- " . implode("\n- ", $gateFailures)); + $exitCode = Command::FAILURE; + } + // Export report if --report is specified $reportPath = $input->getOption('report'); if (is_string($reportPath) && $reportPath !== '') { - $extension = pathinfo($reportPath, PATHINFO_EXTENSION); - if ($extension === 'json') { - $serialized = []; - foreach ($violations as $v) { - $serialized[] = $v->toArray(); - } - file_put_contents($reportPath, json_encode($serialized, JSON_PRETTY_PRINT)); - } else { - $reporter = new \YakNet\AccessibilityConsole\Reporting\HtmlDashboardReporter(); - // Map violations into page format - $mappedViolations = ['Static Analysis' => $violations]; - file_put_contents($reportPath, $reporter->render($mappedViolations, 'Static Analysis')); - } - $io->success("Report saved to: $reportPath"); + $this->exportReport($reportPath, $violations, 'Static Analysis', $io); + } + + // Export SVG badge if --badge is specified + $badgePath = $input->getOption('badge'); + if (is_string($badgePath) && $badgePath !== '') { + file_put_contents($badgePath, BadgeGenerator::generateScoreBadge($score)); + $io->success("SVG Badge saved to: $badgePath"); } return $exitCode; @@ -438,20 +517,82 @@ class ScanCommand extends Command $reportPath = $input->getOption('report'); if (is_string($reportPath) && $reportPath !== '') { - $extension = pathinfo($reportPath, PATHINFO_EXTENSION); - if ($extension === 'json') { - $serialized = []; - foreach ($results as $url => $violations) { - $serialized[$url] = array_map(fn($v) => $v->toArray(), $violations); - } - file_put_contents($reportPath, json_encode($serialized, JSON_PRETTY_PRINT)); - } else { - $reporter = new \YakNet\AccessibilityConsole\Reporting\HtmlDashboardReporter(); - file_put_contents($reportPath, $reporter->render($results, $target)); + $allViolations = []; + foreach ($results as $urlViolations) { + $allViolations = array_merge($allViolations, $urlViolations); } - $io->success("Report saved to: $reportPath"); + $this->exportReport($reportPath, $allViolations, $target, $io, $results); } return Command::FAILURE; } + + /** + * Export violations to various file formats (HTML, JSON, SARIF, XML/JUnit, PDF, Excel). + * + * @param string $reportPath + * @param \YakNet\AccessibilityConsole\Core\Violation[] $violations + * @param string $targetName + * @param SymfonyStyle $io + * @param array|null $pageResults + */ + private function exportReport( + string $reportPath, + array $violations, + string $targetName, + SymfonyStyle $io, + ?array $pageResults = null + ): void { + $extension = strtolower(pathinfo($reportPath, PATHINFO_EXTENSION)); + + if ($extension === 'json') { + if ($pageResults !== null) { + $serialized = []; + foreach ($pageResults as $url => $pageViolations) { + $serialized[$url] = array_map(fn($v) => $v->toArray(), $pageViolations); + } + } else { + $serialized = []; + foreach ($violations as $v) { + $serialized[] = $v->toArray(); + } + } + file_put_contents($reportPath, (string)json_encode($serialized, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + } elseif ($extension === 'sarif') { + $sarifFormatter = new SarifFormatter(); + file_put_contents($reportPath, $sarifFormatter->render($violations)); + } elseif ($extension === 'xml' || $extension === 'junit') { + $junitFormatter = new JunitFormatter(); + file_put_contents($reportPath, $junitFormatter->render($violations)); + } elseif ($extension === 'pdf') { + $metrics = \YakNet\AccessibilityConsole\Core\Metrics\AccessibilityScoreCalculator::calculate($violations); + $serialized = []; + foreach ($violations as $v) { + $serialized[] = $v->toArray(); + } + $scanResult = [ + 'score' => $metrics['score'], + 'violations' => $serialized, + ]; + file_put_contents($reportPath, PdfReportGenerator::generatePdf($scanResult, $targetName)); + } elseif ($extension === 'xlsx' || $extension === 'xls') { + $metrics = \YakNet\AccessibilityConsole\Core\Metrics\AccessibilityScoreCalculator::calculate($violations); + $serialized = []; + foreach ($violations as $v) { + $serialized[] = $v->toArray(); + } + $scanResult = [ + 'score' => $metrics['score'], + 'violations' => $serialized, + ]; + file_put_contents($reportPath, ExcelReportExporter::exportExcel($scanResult, $targetName)); + } else { + $reporter = new \YakNet\AccessibilityConsole\Reporting\HtmlDashboardReporter(); + $mapped = $pageResults ?? [$targetName => $violations]; + file_put_contents($reportPath, $reporter->render($mapped, $targetName)); + } + + $io->success("Report saved to: $reportPath"); + } } + diff --git a/src/Console/Command/WatchCommand.php b/src/Console/Command/WatchCommand.php new file mode 100644 index 0000000..cf08100 --- /dev/null +++ b/src/Console/Command/WatchCommand.php @@ -0,0 +1,217 @@ +setName('watch') + ->setDescription('Continuously watch templates and files for changes and automatically re-scan') + ->addArgument('target', InputArgument::OPTIONAL, 'The file or directory path to watch (defaults to config paths)') + ->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Path to configuration file (default: a11y.yaml)') + ->addOption('level', 'l', InputOption::VALUE_REQUIRED, 'Accessibility scan level (1-5)') + ->addOption('interval', 'i', InputOption::VALUE_REQUIRED, 'Watch poll interval in seconds', '1') + ->addOption('max-iterations', null, InputOption::VALUE_REQUIRED, 'Max loop iterations (used for testing/debugging)'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $configPathOption = $input->getOption('config'); + $configPath = is_string($configPathOption) ? $configPathOption : getcwd() . '/a11y.yaml'; + $config = Config::fromYaml($configPath); + + $levelOption = $input->getOption('level'); + $level = is_numeric($levelOption) ? (int)$levelOption : $config->getLevel(); + + $target = $input->getArgument('target'); + $paths = $config->getPaths(); + if (is_string($target) && $target !== '') { + $paths = [$target]; + } + + $intervalOption = $input->getOption('interval'); + $interval = is_numeric($intervalOption) ? (int)$intervalOption : 1; + if ($interval < 1) { + $interval = 1; + } + + $maxIterationsOption = $input->getOption('max-iterations'); + $maxIterations = is_numeric($maxIterationsOption) ? (int)$maxIterationsOption : null; + + $io->title("Accessibility Console - Real-Time Watch Mode"); + $io->writeln("Watching paths:> " . implode(', ', $paths) . ">"); + $io->writeln("Rule Level:> $level>"); + $io->writeln("Poll Interval:> {$interval}s>"); + $io->note("Press Ctrl+C to stop watching."); + + // Build Scanner + $scanner = new Scanner(); + $rules = RuleLevels::getRulesForLevel($level); + foreach ($rules as $rule) { + $scanner->addRule($rule); + } + + $analyser = new Analyser($config, $scanner, new BaselineManager()); + + // Perform initial scan + $io->section("Initial Scan"); + $this->runScanAndPrint($analyser, $paths, $io); + + // Map existing files to their last modified timestamp + $fileMtimes = $this->collectFileMtimes($paths, $config->getExcludePaths()); + + $iteration = 0; + while (true) { + if ($maxIterations !== null && $iteration >= $maxIterations) { + break; + } + $iteration++; + + sleep($interval); + + $currentMtimes = $this->collectFileMtimes($paths, $config->getExcludePaths()); + $changedFiles = $this->detectChangedFiles($fileMtimes, $currentMtimes); + + if (!empty($changedFiles)) { + $fileMtimes = $currentMtimes; + $timestamp = date('H:i:s'); + $io->newLine(); + $io->writeln(sprintf( + "[%s]> Change detected in %s>. Re-scanning...", + $timestamp, + basename($changedFiles[0]) . (count($changedFiles) > 1 ? ' (+' . (count($changedFiles) - 1) . ' more)' : '') + )); + + // Re-scan changed files or paths + $this->runScanAndPrint($analyser, $changedFiles, $io); + } + } + + return Command::SUCCESS; + } + + /** + * @param Analyser $analyser + * @param string[] $paths + * @param SymfonyStyle $io + */ + private function runScanAndPrint(Analyser $analyser, array $paths, SymfonyStyle $io): void + { + $start = microtime(true); + $result = $analyser->analyse($paths); + $elapsed = round(microtime(true) - $start, 3); + + $violations = $result['violations']; + $formatter = new ConsoleFormatter(); + $formatter->format($violations, $result['baselinedCount'], $io); + + $metrics = AccessibilityScoreCalculator::calculate($violations); + $score = $metrics['score']; + $color = $score >= 90 ? 'green' : ($score >= 70 ? 'yellow' : 'red'); + + $io->writeln(sprintf( + "Health Score: %d/100> | Scan time: %ss>", + $color, + $score, + $elapsed + )); + } + + /** + * @param string[] $paths + * @param string[] $excludePaths + * @return array + */ + public function collectFileMtimes(array $paths, array $excludePaths): array + { + $mtimes = []; + $extensions = ['php', 'html', 'twig', 'blade.php']; + + foreach ($paths as $path) { + if (is_file($path)) { + $mtimes[$path] = (int)filemtime($path); + continue; + } + + if (!is_dir($path)) { + continue; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS) + ); + + /** @var \SplFileInfo $file */ + foreach ($iterator as $file) { + if (!$file->isFile()) { + continue; + } + + $filename = $file->getFilename(); + $ext = $file->getExtension(); + $isMatching = in_array($ext, ['php', 'html', 'twig'], true) || str_ends_with($filename, '.blade.php'); + + if (!$isMatching) { + continue; + } + + $filePath = (string)$file->getRealPath(); + $excluded = false; + foreach ($excludePaths as $ex) { + if (str_contains(str_replace('\\', '/', $filePath), '/' . trim($ex, '/') . '/')) { + $excluded = true; + break; + } + } + + if (!$excluded) { + $mtimes[$filePath] = (int)$file->getMTime(); + } + } + } + + return $mtimes; + } + + /** + * @param array $oldMtimes + * @param array $newMtimes + * @return string[] + */ + public function detectChangedFiles(array $oldMtimes, array $newMtimes): array + { + $changed = []; + + foreach ($newMtimes as $file => $mtime) { + if (!isset($oldMtimes[$file]) || $oldMtimes[$file] !== $mtime) { + $changed[] = $file; + } + } + + foreach ($oldMtimes as $file => $mtime) { + if (!isset($newMtimes[$file])) { + $changed[] = $file; + } + } + + return array_values(array_unique($changed)); + } +} diff --git a/src/Core/Container/ContainerFactory.php b/src/Core/Container/ContainerFactory.php index 21f10fa..ee60537 100644 --- a/src/Core/Container/ContainerFactory.php +++ b/src/Core/Container/ContainerFactory.php @@ -11,6 +11,7 @@ use YakNet\AccessibilityConsole\Console\Command\ScanCommand; use YakNet\AccessibilityConsole\Console\Command\FixCommand; use YakNet\AccessibilityConsole\Console\Command\InitCommand; use YakNet\AccessibilityConsole\Console\Command\RulesCommand; +use YakNet\AccessibilityConsole\Console\Command\WatchCommand; class ContainerFactory { @@ -51,6 +52,9 @@ class ContainerFactory RulesCommand::class => function () { return new RulesCommand(); }, + WatchCommand::class => function () { + return new WatchCommand(); + }, ]); return $builder->build(); diff --git a/src/Core/Scanner.php b/src/Core/Scanner.php index d52f4e6..c0e5ee3 100644 --- a/src/Core/Scanner.php +++ b/src/Core/Scanner.php @@ -40,7 +40,7 @@ class Scanner // Attach raw HTML property for syntax/token level analysis /** @phpstan-ignore-next-line */ - $doc->rawHtml = $html; + @$doc->rawHtml = $html; return $this->engine->run($doc); } diff --git a/src/Core/SelfHealing/LocalHealer.php b/src/Core/SelfHealing/LocalHealer.php index 7e75eeb..fe19347 100644 --- a/src/Core/SelfHealing/LocalHealer.php +++ b/src/Core/SelfHealing/LocalHealer.php @@ -18,7 +18,80 @@ class LocalHealer $ruleId = $violation->ruleId; switch ($ruleId) { + case 'WCAG_1_1_1_IMG_ALT': + case 'WCAG_1_1_1_ImageAlt': + case 'ImageAlt': + case 'ImgAltText': + if (preg_match('/]*\balt=)/i', $snippet)) { + $fixed = preg_replace('/]*\baria-hidden=)(?![^>]*\baria-label=)/i', $snippet) && !str_contains($snippet, '')) { + $fixed = preg_replace('/]*\btarget=["\']_blank["\']/i', $snippet)) { + if (preg_match('/rel=["\']([^"\']*)["\']/i', $snippet, $matches)) { + $existingRel = $matches[1]; + $newRel = trim($existingRel . ' noopener noreferrer'); + $fixed = str_replace($matches[0], 'rel="' . $newRel . '"', $snippet); + } else { + $fixed = preg_replace('/]*\bscope=["\'][^"\']*["\']/i', $snippet)) { + $fixed = preg_replace('/\s+scope=["\'][^"\']*["\']/i', '', $snippet) ?: $snippet; + return "EXPLANATION: Removed invalid scope attribute from element.\nFIX: " . $fixed; + } + break; + + case 'WCAG_2_2_1_META_REFRESH': + case 'MetaRefreshBanned': + if (preg_match('/]*\bhttp-equiv=["\']refresh["\'][^>]*>/i', $snippet)) { + $fixed = ''; + return "EXPLANATION: Commented out disorienting meta refresh tag.\nFIX: " . $fixed; + } + break; + + case 'WCAG_1_3_1_OPTGROUP_LABEL': + case 'OptgroupLabel': + if (preg_match('/]*\blabel=)/i', $snippet)) { + $fixed = preg_replace('/.\nFIX: " . $fixed; + } + break; + + case 'WCAG_2_4_7_FOCUS_OUTLINE': + case 'NoFocusOutline': + if (preg_match('/style=["\'][^"\']*outline\s*:\s*(?:none|0)[^"\']*["\']/i', $snippet)) { + $fixed = preg_replace('/outline\s*:\s*(?:none|0)/i', 'outline: 2px solid transparent; outline-offset: 2px', $snippet) ?: $snippet; + return "EXPLANATION: Replaced outline removal with accessible transparent outline.\nFIX: " . $fixed; + } + break; + case 'WCAG_2_2_2_BLINK': + case 'BlinkTagBanned': $fixed = str_replace( ['', '', '', ''], ['', '', '', ''], @@ -27,14 +100,17 @@ class LocalHealer return "EXPLANATION: Banned tag replaced with a span and text-decoration style.\nFIX: " . $fixed; case 'WCAG_2_4_3_AUTOFOCUS': + case 'AutofocusBanned': $fixed = preg_replace('/\s+autofocus(=["\'](.*?)["\'])?/i', '', $snippet) ?: $snippet; return "EXPLANATION: Autofocus attribute removed to prevent screen reader interruption on load.\nFIX: " . $fixed; case 'WCAG_1_3_1_TABLE_SUMMARY': + case 'TableSummaryBanned': $fixed = preg_replace('/\s+summary=["\'](.*?)["\']/i', '', $snippet) ?: $snippet; return "EXPLANATION: Deprecated table summary attribute removed. Use caption elements instead.\nFIX: " . $fixed; case 'WCAG_2_1_1_SCROLLABLE_FOCUS': + case 'ScrollableRegionFocusable': if (!str_contains($snippet, 'tabindex')) { $fixed = preg_replace('/(style=["\'](.*?)["\'])/i', '$1 tabindex="0"', $snippet) ?: $snippet; return "EXPLANATION: Added tabindex=\"0\" to scrollable region to allow keyboard scrolling.\nFIX: " . $fixed; @@ -42,6 +118,7 @@ class LocalHealer break; case 'WCAG_1_3_1_PRESENTATION_TAGS': + case 'PresentationTagsBanned': if (preg_match('/(.*?)<\/center>/is', $snippet, $matches)) { $fixed = '' . $matches[1] . ''; return "EXPLANATION: Center element replaced with div and text-align CSS.\nFIX: " . $fixed; @@ -53,9 +130,10 @@ class LocalHealer break; case 'WCAG_3_1_1_LANG': + case 'HtmlHasLang': if (preg_match('/]*>|>)/i', $snippet)) { - $fixed = preg_replace('/httpClient = $httpClient ?? new Client(['timeout' => 5.0]); + } + + public function send(NotificationPayload $payload): bool + { + if ($this->webhookUrl === '') { + return false; + } + + // Color in decimal for Discord embed + $color = $payload->healthScore >= 90 ? 0x10b981 : ($payload->healthScore >= 70 ? 0xf59e0b : 0xef4444); + + $body = [ + 'embeds' => [ + [ + 'title' => "♿ Accessibility Audit: {$payload->target}", + 'color' => $color, + 'fields' => [ + [ + 'name' => 'Health Score', + 'value' => sprintf('%d / 100', $payload->healthScore), + 'inline' => true, + ], + [ + 'name' => 'Total Violations', + 'value' => (string)$payload->totalViolations, + 'inline' => true, + ], + [ + 'name' => 'Breakdown', + 'value' => sprintf('Errors: %d | Warnings: %d', $payload->errorCount, $payload->warningCount), + 'inline' => false, + ], + ], + 'footer' => [ + 'text' => 'YakNet Accessibility Console', + ], + 'timestamp' => date('c'), + ], + ], + ]; + + try { + $response = $this->httpClient->post($this->webhookUrl, [ + 'json' => $body, + ]); + return $response->getStatusCode() < 300; + } catch (\Throwable) { + return false; + } + } +} diff --git a/src/Notifications/NotificationDispatcher.php b/src/Notifications/NotificationDispatcher.php new file mode 100644 index 0000000..f286e09 --- /dev/null +++ b/src/Notifications/NotificationDispatcher.php @@ -0,0 +1,35 @@ + + */ + private array $notifiers = []; + + public function addNotifier(string $channel, NotifierInterface $notifier): void + { + $this->notifiers[$channel] = $notifier; + } + + /** + * Dispatch notification payload across all registered notifiers. + * + * @return array Channel delivery results + */ + public function dispatch(NotificationPayload $payload): array + { + $results = []; + foreach ($this->notifiers as $channel => $notifier) { + $results[$channel] = $notifier->send($payload); + } + return $results; + } + + public function hasNotifiers(): bool + { + return !empty($this->notifiers); + } +} diff --git a/src/Notifications/NotificationPayload.php b/src/Notifications/NotificationPayload.php new file mode 100644 index 0000000..f7e9a42 --- /dev/null +++ b/src/Notifications/NotificationPayload.php @@ -0,0 +1,18 @@ +> $topViolations + */ + public function __construct( + public string $target, + public int $healthScore, + public int $totalViolations, + public int $errorCount, + public int $warningCount, + public array $topViolations = [] + ) {} +} diff --git a/src/Notifications/NotifierInterface.php b/src/Notifications/NotifierInterface.php new file mode 100644 index 0000000..0b35b5c --- /dev/null +++ b/src/Notifications/NotifierInterface.php @@ -0,0 +1,8 @@ +httpClient = $httpClient ?? new Client(['timeout' => 5.0]); + } + + public function send(NotificationPayload $payload): bool + { + if ($this->webhookUrl === '') { + return false; + } + + $color = $payload->healthScore >= 90 ? '#10b981' : ($payload->healthScore >= 70 ? '#f59e0b' : '#ef4444'); + $status = $payload->healthScore >= 90 ? 'PASSED' : 'FAILED'; + + $body = [ + 'attachments' => [ + [ + 'color' => $color, + 'title' => "♿ Accessibility Audit: {$payload->target}", + 'text' => sprintf( + "*Health Score:* %d/100 (%s)\n*Total Violations:* %d (Errors: %d, Warnings: %d)", + $payload->healthScore, + $status, + $payload->totalViolations, + $payload->errorCount, + $payload->warningCount + ), + 'footer' => 'YakNet Accessibility Console', + 'ts' => time(), + ], + ], + ]; + + try { + $response = $this->httpClient->post($this->webhookUrl, [ + 'json' => $body, + ]); + return $response->getStatusCode() < 300; + } catch (\Throwable) { + return false; + } + } +} diff --git a/src/Presets/ECommercePreset.php b/src/Presets/ECommercePreset.php new file mode 100644 index 0000000..731905f --- /dev/null +++ b/src/Presets/ECommercePreset.php @@ -0,0 +1,39 @@ + + */ + private array $presets = []; + + public function __construct() + { + $this->register(new Section508Preset()); + $this->register(new En301549Preset()); + $this->register(new ECommercePreset()); + } + + public function register(PresetInterface $preset): void + { + $this->presets[strtolower($preset->getName())] = $preset; + } + + public function get(string $name): ?PresetInterface + { + return $this->presets[strtolower($name)] ?? null; + } + + /** + * @return array + */ + public function all(): array + { + return $this->presets; + } +} diff --git a/src/Presets/Section508Preset.php b/src/Presets/Section508Preset.php new file mode 100644 index 0000000..337c053 --- /dev/null +++ b/src/Presets/Section508Preset.php @@ -0,0 +1,39 @@ +render($violations, $baselinedCount); + $io->write($xml); + + return empty($violations) ? Command::SUCCESS : Command::FAILURE; + } + + /** + * Render the violations in JUnit XML format. + * + * @param Violation[] $violations + * @param int $baselinedCount + * @return string + */ + public function render(array $violations, int $baselinedCount = 0): string + { + $dom = new \DOMDocument('1.0', 'UTF-8'); + $dom->formatOutput = true; + + $testsuites = $dom->createElement('testsuites'); + $testsuites->setAttribute('name', 'YakNet Accessibility Console'); + $testsuites->setAttribute('tests', (string)max(1, count($violations))); + $testsuites->setAttribute('failures', (string)count($violations)); + $testsuites->setAttribute('errors', '0'); + $testsuites->setAttribute('time', '0.000'); + $dom->appendChild($testsuites); + + if (empty($violations)) { + $testsuite = $dom->createElement('testsuite'); + $testsuite->setAttribute('name', 'AccessibilityScan'); + $testsuite->setAttribute('tests', '1'); + $testsuite->setAttribute('failures', '0'); + $testsuite->setAttribute('errors', '0'); + $testsuite->setAttribute('time', '0.000'); + + $testcase = $dom->createElement('testcase'); + $testcase->setAttribute('name', 'AccessibilityCompliant'); + $testcase->setAttribute('classname', 'YakNet.AccessibilityConsole'); + $testcase->setAttribute('time', '0.000'); + + $testsuite->appendChild($testcase); + $testsuites->appendChild($testsuite); + + return (string)$dom->saveXML(); + } + + // Group violations by file + /** @var array $grouped */ + $grouped = []; + foreach ($violations as $v) { + $file = $v->location['file'] ?? 'unknown'; + $grouped[$file][] = $v; + } + + foreach ($grouped as $file => $fileViolations) { + $testsuite = $dom->createElement('testsuite'); + $testsuite->setAttribute('name', str_replace('\\', '/', $file)); + $testsuite->setAttribute('tests', (string)count($fileViolations)); + $testsuite->setAttribute('failures', (string)count($fileViolations)); + $testsuite->setAttribute('errors', '0'); + $testsuite->setAttribute('time', '0.000'); + + foreach ($fileViolations as $v) { + $line = $v->location['line'] ?? 1; + $col = $v->location['column'] ?? 1; + + $testcase = $dom->createElement('testcase'); + $testcase->setAttribute('name', sprintf('[%s] %s', $v->ruleId, $v->message)); + $testcase->setAttribute('classname', str_replace(['/', '\\'], '.', $file)); + $testcase->setAttribute('time', '0.000'); + + $failure = $dom->createElement('failure'); + $failure->setAttribute('type', $v->severity->value); + $failure->setAttribute('message', $v->message); + + $details = sprintf( + "Rule: %s (%s)\nStandard: %s\nSeverity: %s\nLocation: %s:%d:%d\n\nSnippet:\n%s", + $v->ruleId, + $v->message, + $v->standard->value, + $v->severity->value, + $file, + $line, + $col, + $v->htmlSnippet + ); + + if ($v->fixSuggestion !== null && $v->fixSuggestion !== '') { + $details .= "\n\nSuggested Fix:\n" . $v->fixSuggestion; + } + + $cdata = $dom->createCDATASection($details); + $failure->appendChild($cdata); + + $testcase->appendChild($failure); + $testsuite->appendChild($testcase); + } + + $testsuites->appendChild($testsuite); + } + + return (string)$dom->saveXML(); + } +} diff --git a/src/Reporting/PdfReportGenerator.php b/src/Reporting/PdfReportGenerator.php index eb88bc6..7402d60 100644 --- a/src/Reporting/PdfReportGenerator.php +++ b/src/Reporting/PdfReportGenerator.php @@ -90,7 +90,7 @@ class PdfReportGenerator Hedef URL / Dizin: " . htmlspecialchars($targetUrl) . " Tarama Tarihi: {$dateStr} Tespit Edilen İhlal Sayısı: {$violationsCount} Hata - Raporu Üreten Motor: YakNet AI & Accessibility Engine v3.2 + Raporu Üreten Motor: YakNet AI & Accessibility Engine v3.3 📋 Erişilebilirlik İhlalleri ve Çözüm Detayları diff --git a/src/Reporting/SarifFormatter.php b/src/Reporting/SarifFormatter.php new file mode 100644 index 0000000..863129b --- /dev/null +++ b/src/Reporting/SarifFormatter.php @@ -0,0 +1,155 @@ +render($violations, $baselinedCount); + $io->write($sarifJson); + + return empty($violations) ? Command::SUCCESS : Command::FAILURE; + } + + /** + * Render the violations in SARIF 2.1.0 JSON format. + * + * @param Violation[] $violations + * @param int $baselinedCount + * @return string + */ + public function render(array $violations, int $baselinedCount = 0): string + { + $rulesMap = []; + $results = []; + + foreach ($violations as $v) { + $ruleId = $v->ruleId; + + // Register rule metadata if not already present + if (!isset($rulesMap[$ruleId])) { + $rulesMap[$ruleId] = [ + 'id' => $ruleId, + 'name' => $this->sanitizeRuleName($ruleId), + 'shortDescription' => [ + 'text' => $v->message, + ], + 'fullDescription' => [ + 'text' => $v->message, + ], + 'defaultConfiguration' => [ + 'level' => $this->mapSeverityToSarifLevel($v->severity), + ], + 'properties' => [ + 'tags' => [ + 'accessibility', + 'a11y', + 'wcag', + 'wcag-' . strtolower($v->standard->value), + ], + ], + ]; + } + + // Location details + $file = $v->location['file'] ?? 'unknown'; + $line = isset($v->location['line']) ? max(1, (int)$v->location['line']) : 1; + $column = isset($v->location['column']) ? max(1, (int)$v->location['column']) : 1; + + /** @var array $region */ + $region = [ + 'startLine' => $line, + 'startColumn' => $column, + ]; + + if ($v->htmlSnippet !== '') { + $region['snippet'] = [ + 'text' => $v->htmlSnippet, + ]; + } + + /** @var array $locationEntry */ + $locationEntry = [ + 'physicalLocation' => [ + 'artifactLocation' => [ + 'uri' => str_replace('\\', '/', $file), + ], + 'region' => $region, + ], + ]; + + /** @var array $resultItem */ + $resultItem = [ + 'ruleId' => $ruleId, + 'level' => $this->mapSeverityToSarifLevel($v->severity), + 'message' => [ + 'text' => $v->message, + ], + 'locations' => [ + $locationEntry, + ], + ]; + + if ($v->fixSuggestion !== null && $v->fixSuggestion !== '') { + $resultItem['fixes'] = [ + [ + 'description' => [ + 'text' => 'Suggested accessibility fix', + ], + ], + ]; + } + + $results[] = $resultItem; + } + + $sarif = [ + '$schema' => 'https://json.schemastore.org/sarif-2.1.0.json', + 'version' => '2.1.0', + 'runs' => [ + [ + 'tool' => [ + 'driver' => [ + 'name' => 'YakNet Accessibility Console', + 'informationUri' => 'https://forum.yak.net.tr', + 'semanticVersion' => '3.3.0', + 'rules' => array_values($rulesMap), + ], + ], + 'results' => $results, + ], + ], + ]; + + return (string)json_encode($sarif, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + + private function mapSeverityToSarifLevel(Severity $severity): string + { + return match ($severity) { + Severity::CRITICAL, Severity::ERROR => 'error', + Severity::WARNING => 'warning', + Severity::INFO => 'note', + }; + } + + private function sanitizeRuleName(string $ruleId): string + { + $cleaned = preg_replace('/^WCAG_\d+_\d+_\d+_?/', '', $ruleId); + $cleaned = is_string($cleaned) ? $cleaned : $ruleId; + $words = explode('_', strtolower($cleaned)); + return implode('', array_map('ucfirst', $words)); + } +} diff --git a/src/Rules/LabelInName.php b/src/Rules/LabelInName.php new file mode 100644 index 0000000..3623164 --- /dev/null +++ b/src/Rules/LabelInName.php @@ -0,0 +1,118 @@ +tagName); + $role = strtolower($element->getAttribute('role')); + $isInteractive = in_array($tag, ['button', 'a'], true) + || ($tag === 'input' && in_array(strtolower($element->getAttribute('type')), ['button', 'submit', 'reset'], true)) + || in_array($role, ['button', 'link'], true); + + if (!$isInteractive) { + return null; + } + + // Avoid template false positives + $html = $element->ownerDocument ? $element->ownerDocument->saveHTML($element) : ''; + if ($html !== false && preg_match('/<\?php|\{\{|\{%/i', $html)) { + return null; + } + + // Determine visible text + $visibleText = ''; + if ($tag === 'input') { + $visibleText = trim($element->getAttribute('value')); + } else { + $visibleText = trim($element->textContent); + } + + // If there is no visible text (e.g. icon-only button), Label in Name criterion does not apply + if ($visibleText === '' || mb_strlen($visibleText) < 2) { + return null; + } + + // Get explicit accessible name + $accessibleName = ''; + if ($element->hasAttribute('aria-label')) { + $accessibleName = trim($element->getAttribute('aria-label')); + } elseif ($element->hasAttribute('aria-labelledby')) { + $id = trim($element->getAttribute('aria-labelledby')); + $doc = $element->ownerDocument; + if ($doc !== null && $id !== '') { + $xpath = new DOMXPath($doc); + $nodes = $xpath->query('//*[@id="' . $id . '"]'); + if ($nodes !== false && $nodes->length > 0) { + $node = $nodes->item(0); + if ($node instanceof DOMNode) { + $accessibleName = trim($node->textContent); + } + } + } + } + + // If no explicit ARIA name is set, the visible text itself is the accessible name + if ($accessibleName === '') { + return null; + } + + // Normalization: clean extra whitespace and lower case + $normVisible = mb_strtolower((string)preg_replace('/\s+/', ' ', $visibleText)); + $normAcc = mb_strtolower((string)preg_replace('/\s+/', ' ', $accessibleName)); + + // WCAG 2.5.3: Accessible name must contain the visible text + if (!str_contains($normAcc, $normVisible)) { + return $this->createViolation( + $element, + sprintf( + "Accessible name \"%s\" does not contain the visible text \"%s\".", + $accessibleName, + $visibleText + ), + sprintf( + "Update aria-label to include \"%s\" (e.g. aria-label=\"%s %s\") so speech input users can activate the control.", + $visibleText, + $visibleText, + $accessibleName + ) + ); + } + + return null; + } +} diff --git a/src/Rules/RedundantEntry.php b/src/Rules/RedundantEntry.php new file mode 100644 index 0000000..a6aaf29 --- /dev/null +++ b/src/Rules/RedundantEntry.php @@ -0,0 +1,113 @@ +tagName) !== 'form') { + return null; + } + + $doc = $element->ownerDocument; + if ($doc === null) { + return null; + } + + $xpath = new DOMXPath($doc); + + // Find all input and select elements in the form + $inputs = $xpath->query('.//input | .//select | .//textarea', $element); + if ($inputs === false || $inputs->length < 4) { + return null; + } + + $hasShipping = false; + $hasBilling = false; + $hasSameCheckbox = false; + + foreach ($inputs as $input) { + if (!$input instanceof DOMElement) { + continue; + } + + $name = strtolower($input->getAttribute('name')); + $id = strtolower($input->getAttribute('id')); + $autocomplete = strtolower($input->getAttribute('autocomplete')); + $type = strtolower($input->getAttribute('type')); + + if (str_contains($name, 'shipping') || str_contains($id, 'shipping') || str_contains($autocomplete, 'shipping')) { + $hasShipping = true; + } + + if (str_contains($name, 'billing') || str_contains($id, 'billing') || str_contains($autocomplete, 'billing')) { + $hasBilling = true; + } + + if ($type === 'checkbox') { + if (str_contains($name, 'same') || str_contains($id, 'same') || str_contains($name, 'copy') || str_contains($id, 'copy')) { + $hasSameCheckbox = true; + } + } + } + + // Also check labels for "same as" + if (!$hasSameCheckbox) { + $labels = $xpath->query('.//label', $element); + if ($labels !== false) { + foreach ($labels as $label) { + if ($label instanceof DOMElement) { + $labelText = strtolower(trim($label->textContent)); + if (str_contains($labelText, 'same as') || str_contains($labelText, 'aynı') || str_contains($labelText, 'kopyala')) { + $hasSameCheckbox = true; + break; + } + } + } + } + } + + // If form has both shipping and billing fields but no "same as" checkbox + if ($hasShipping && $hasBilling && !$hasSameCheckbox) { + return $this->createViolation( + $element, + "Form requires re-entering shipping and billing information without providing a 'Same as shipping' checkbox or auto-fill option (WCAG 2.2 SC 3.3.7 Redundant Entry).", + "Add a checkbox (e.g. 'Billing address is the same as shipping address') or populate with previously entered user data to reduce cognitive burden." + ); + } + + return null; + } +} diff --git a/src/Rules/RuleController.php b/src/Rules/RuleController.php new file mode 100644 index 0000000..c5715b2 --- /dev/null +++ b/src/Rules/RuleController.php @@ -0,0 +1,94 @@ + + */ + private array $rulesRegistry = []; + + /** + * @param array|null $rules + */ + public function __construct(?array $rules = null) + { + $allRules = $rules ?? RuleLevels::getRulesForLevel(9); + foreach ($allRules as $rule) { + $id = ($rule instanceof RuleInterface) ? $rule->getId() : (new \ReflectionClass($rule))->getShortName(); + $this->rulesRegistry[$id] = $rule; + } + } + + /** + * Get rule instance by ID. + */ + public function getRule(string $ruleId): RuleInterface|AbstractRule|null + { + return $this->rulesRegistry[$ruleId] ?? null; + } + + /** + * Execute a specific rule directly on a DOM element. + */ + public function executeRule(string $ruleId, DOMElement $element): ?Violation + { + $rule = $this->getRule($ruleId); + if ($rule === null) { + return null; + } + + if ($rule instanceof RuleInterface) { + return $rule->check($element); + } + + return null; + } + + /** + * Filter active rules by WCAG standard (A, AA, AAA). + * + * @return array + */ + public function filterByStandard(WCAGStandard $standard): array + { + $filtered = []; + foreach ($this->rulesRegistry as $id => $rule) { + if (method_exists($rule, 'getStandard') && $rule->getStandard() === $standard) { + $filtered[$id] = $rule; + } + } + return $filtered; + } + + /** + * Filter active rules by severity. + * + * @return array + */ + public function filterBySeverity(Severity $severity): array + { + $filtered = []; + foreach ($this->rulesRegistry as $id => $rule) { + if (method_exists($rule, 'getSeverity') && $rule->getSeverity() === $severity) { + $filtered[$id] = $rule; + } + } + return $filtered; + } + + /** + * Total number of registered rules in this controller. + */ + public function count(): int + { + return count($this->rulesRegistry); + } +} diff --git a/src/Rules/RuleExecutionStats.php b/src/Rules/RuleExecutionStats.php new file mode 100644 index 0000000..7fa8c21 --- /dev/null +++ b/src/Rules/RuleExecutionStats.php @@ -0,0 +1,74 @@ + + */ + private array $stats = []; + + /** + * Record a rule execution metric. + */ + public function record(string $ruleId, float $durationMs, bool $hasViolation = false): void + { + if (!isset($this->stats[$ruleId])) { + $this->stats[$ruleId] = [ + 'invocations' => 0, + 'total_time_ms' => 0.0, + 'violations' => 0, + ]; + } + + $this->stats[$ruleId]['invocations']++; + $this->stats[$ruleId]['total_time_ms'] += $durationMs; + if ($hasViolation) { + $this->stats[$ruleId]['violations']++; + } + } + + /** + * Get top slowest rules by total execution time. + * + * @return array + */ + public function getSlowestRules(int $limit = 10): array + { + $calculated = []; + foreach ($this->stats as $id => $data) { + $avg = $data['invocations'] > 0 ? ($data['total_time_ms'] / $data['invocations']) : 0.0; + $calculated[$id] = [ + 'invocations' => $data['invocations'], + 'total_time_ms' => round($data['total_time_ms'], 3), + 'avg_time_ms' => round($avg, 4), + 'violations' => $data['violations'], + ]; + } + + uasort($calculated, function ($a, $b) { + return $b['total_time_ms'] <=> $a['total_time_ms']; + }); + + return array_slice($calculated, 0, $limit, true); + } + + /** + * Get all recorded statistics. + * + * @return array + */ + public function getAllStats(): array + { + return $this->stats; + } + + /** + * Reset all stats counters. + */ + public function reset(): void + { + $this->stats = []; + } +} diff --git a/src/Rules/TextSpacing.php b/src/Rules/TextSpacing.php new file mode 100644 index 0000000..467ce6b --- /dev/null +++ b/src/Rules/TextSpacing.php @@ -0,0 +1,89 @@ +hasAttribute('style')) { + return null; + } + + $style = strtolower($element->getAttribute('style')); + + // Check line-height < 1.2 or <= 12px + if (preg_match('/line-height\s*:\s*([^;]+)/i', $style, $matches)) { + $lh = trim($matches[1]); + // Unitless number check + if (is_numeric($lh) && (float)$lh < 1.2 && (float)$lh > 0) { + return $this->createViolation( + $element, + "Inline line-height is set to {$lh}, which is below the recommended minimum of 1.2 to 1.5.", + "Increase line-height to at least 1.5 to satisfy WCAG 1.4.12 Text Spacing." + ); + } + // Pixel check (e.g. 10px, 12px) + if (preg_match('/^(\d+(?:\.\d+)?)px$/i', $lh, $pxMatches)) { + $px = (float)$pxMatches[1]; + if ($px < 14) { + return $this->createViolation( + $element, + "Inline line-height is fixed to {$px}px, risking text overlapping when resized.", + "Use relative line-height units (e.g. 1.5) instead of fixed small pixel values." + ); + } + } + } + + // Check fixed height combined with overflow: hidden on text containers + $textTags = ['p', 'div', 'span', 'article', 'section', 'blockquote', 'li']; + if (in_array(strtolower($element->tagName), $textTags, true)) { + if (str_contains($style, 'overflow:hidden') || str_contains($style, 'overflow: hidden')) { + if (preg_match('/(?:^|;)\s*height\s*:\s*(\d+)px/i', $style, $heightMatches)) { + $height = (int)$heightMatches[1]; + $textLength = mb_strlen(trim($element->textContent)); + if ($height <= 60 && $textLength > 60) { + return $this->createViolation( + $element, + "Fixed height ({$height}px) with overflow:hidden risks clipping text when user overrides text spacing.", + "Use min-height instead of fixed height or avoid overflow:hidden on text containers." + ); + } + } + } + } + + return null; + } +} diff --git a/tests/AutoFix/LocalHealerTest.php b/tests/AutoFix/LocalHealerTest.php new file mode 100644 index 0000000..26f36b0 --- /dev/null +++ b/tests/AutoFix/LocalHealerTest.php @@ -0,0 +1,109 @@ +healer = new LocalHealer(); + } + + public function testHealsMissingImgAlt(): void + { + $violation = new Violation( + 'WCAG_1_1_1_IMG_ALT', + 'Image missing alt', + Severity::ERROR, + WCAGStandard::A, + '' + ); + + $healed = $this->healer->heal($violation); + $this->assertNotNull($healed); + $this->assertStringContainsString('FIX: ', $healed); + } + + public function testHealsDecorativeSvg(): void + { + $violation = new Violation( + 'WCAG_1_1_1_SVG', + 'SVG needs accessible text', + Severity::ERROR, + WCAGStandard::A, + '' + ); + + $healed = $this->healer->heal($violation); + $this->assertNotNull($healed); + $this->assertStringContainsString('FIX: ', $healed); + } + + public function testHealsTargetBlankLinkRel(): void + { + $violation = new Violation( + 'WCAG_LINK_TARGET_BLANK', + 'Target blank missing noopener', + Severity::WARNING, + WCAGStandard::A, + 'External' + ); + + $healed = $this->healer->heal($violation); + $this->assertNotNull($healed); + $this->assertStringContainsString('rel="noopener noreferrer"', $healed); + } + + public function testHealsPositiveTabindex(): void + { + $violation = new Violation( + 'WCAG_2_4_3_TABINDEX', + 'Avoid positive tabindex', + Severity::WARNING, + WCAGStandard::A, + 'Submit' + ); + + $healed = $this->healer->heal($violation); + $this->assertNotNull($healed); + $this->assertStringContainsString('tabindex="0"', $healed); + } + + public function testHealsInvalidTableScope(): void + { + $violation = new Violation( + 'WCAG_1_3_1_TABLE_SCOPE', + 'Scope only on headers', + Severity::ERROR, + WCAGStandard::A, + 'Data cell' + ); + + $healed = $this->healer->heal($violation); + $this->assertNotNull($healed); + $this->assertStringNotContainsString('scope=', $healed); + } + + public function testHealsMetaRefresh(): void + { + $violation = new Violation( + 'WCAG_2_2_1_META_REFRESH', + 'Meta refresh banned', + Severity::ERROR, + WCAGStandard::A, + '' + ); + + $healed = $this->healer->heal($violation); + $this->assertNotNull($healed); + $this->assertStringContainsString('