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) [![PHP Version](https://img.shields.io/badge/php-%5E8.2-blue.svg)](https://php.net) [![PHPStan](https://img.shields.io/badge/PHPStan-Level%209-brightgreen.svg)](https://phpstan.org) -[![Tests](https://img.shields.io/badge/PHPUnit-191%20passed-success.svg)](https://phpunit.de) -[![Rules](https://img.shields.io/badge/WCAG%20Rules-127%20Active-purple.svg)](https://www.w3.org/WAI/standards-guidelines/wcag/) +[![Tests](https://img.shields.io/badge/PHPUnit-230%20passed-success.svg)](https://phpunit.de) +[![Rules](https://img.shields.io/badge/WCAG%20Rules-130%20Active-purple.svg)](https://www.w3.org/WAI/standards-guidelines/wcag/) [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) [![AI Powered](https://img.shields.io/badge/AI%20Self--Healing-Google%20Gemini-orange.svg)](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} + + {$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('/<svg\b/i', '<svg aria-hidden="true"', $snippet) ?: $snippet; + return "EXPLANATION: Injected aria-hidden=\"true\" to decorative SVG element.\nFIX: " . $fixed; + } + break; + + case 'WCAG_LINK_TARGET_BLANK': + case 'LinkTargetBlank': + if (preg_match('/<a\b[^>]*\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('/<a\b/i', '<a rel="noopener noreferrer"', $snippet) ?: $snippet; + } + return "EXPLANATION: Added rel=\"noopener noreferrer\" to target=\"_blank\" link.\nFIX: " . $fixed; + } + break; + + case 'WCAG_2_4_3_TABINDEX': + case 'TabindexOrder': + if (preg_match('/\btabindex=["\'][1-9]\d*["\']/i', $snippet)) { + $fixed = preg_replace('/\btabindex=["\'][1-9]\d*["\']/i', 'tabindex="0"', $snippet) ?: $snippet; + return "EXPLANATION: Normalized positive tabindex to tabindex=\"0\" to maintain natural tab order.\nFIX: " . $fixed; + } + break; + + case 'WCAG_1_3_1_TABLE_SCOPE': + case 'ScopeOnlyOnHeaders': + if (preg_match('/<td\b[^>]*\bscope=["\'][^"\']*["\']/i', $snippet)) { + $fixed = preg_replace('/\s+scope=["\'][^"\']*["\']/i', '', $snippet) ?: $snippet; + return "EXPLANATION: Removed invalid scope attribute from <td> element.\nFIX: " . $fixed; + } + break; + + case 'WCAG_2_2_1_META_REFRESH': + case 'MetaRefreshBanned': + if (preg_match('/<meta\b[^>]*\bhttp-equiv=["\']refresh["\'][^>]*>/i', $snippet)) { + $fixed = '<!-- ' . trim($snippet) . ' (Removed: Meta refresh disorients users) -->'; + return "EXPLANATION: Commented out disorienting meta refresh tag.\nFIX: " . $fixed; + } + break; + + case 'WCAG_1_3_1_OPTGROUP_LABEL': + case 'OptgroupLabel': + if (preg_match('/<optgroup\b(?![^>]*\blabel=)/i', $snippet)) { + $fixed = preg_replace('/<optgroup\b/i', '<optgroup label="Options"', $snippet) ?: $snippet; + return "EXPLANATION: Added fallback label=\"Options\" attribute to <optgroup>.\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( ['<blink>', '</blink>', '<BLINK>', '</BLINK>'], ['<span style="text-decoration: blink;">', '</span>', '<span style="text-decoration: blink;">', '</span>'], @@ -27,14 +100,17 @@ class LocalHealer return "EXPLANATION: Banned <blink> 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>(.*?)<\/center>/is', $snippet, $matches)) { $fixed = '<div style="text-align: center;">' . $matches[1] . '</div>'; 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('/<html(\s+[^>]*>|>)/i', $snippet)) { - $fixed = preg_replace('/<html/i', '<html lang="en"', $snippet) ?: $snippet; - return "EXPLANATION: Injected default lang=\"en\" to HTML element.\nFIX: " . $fixed; + $fixed = preg_replace('/<html/i', '<html lang="tr"', $snippet) ?: $snippet; + return "EXPLANATION: Injected default lang=\"tr\" to HTML element.\nFIX: " . $fixed; } break; } diff --git a/src/Notifications/DiscordNotifier.php b/src/Notifications/DiscordNotifier.php new file mode 100644 index 0000000..2718031 --- /dev/null +++ b/src/Notifications/DiscordNotifier.php @@ -0,0 +1,66 @@ +<?php + +namespace YakNet\AccessibilityConsole\Notifications; + +use GuzzleHttp\Client; + +class DiscordNotifier implements NotifierInterface +{ + private Client $httpClient; + + public function __construct( + private string $webhookUrl, + ?Client $httpClient = null + ) { + $this->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 @@ +<?php + +namespace YakNet\AccessibilityConsole\Notifications; + +class NotificationDispatcher +{ + /** + * @var array<string, NotifierInterface> + */ + private array $notifiers = []; + + public function addNotifier(string $channel, NotifierInterface $notifier): void + { + $this->notifiers[$channel] = $notifier; + } + + /** + * Dispatch notification payload across all registered notifiers. + * + * @return array<string, bool> 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 @@ +<?php + +namespace YakNet\AccessibilityConsole\Notifications; + +class NotificationPayload +{ + /** + * @param array<int, array<string, mixed>> $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 @@ +<?php + +namespace YakNet\AccessibilityConsole\Notifications; + +interface NotifierInterface +{ + public function send(NotificationPayload $payload): bool; +} diff --git a/src/Notifications/SlackNotifier.php b/src/Notifications/SlackNotifier.php new file mode 100644 index 0000000..62872eb --- /dev/null +++ b/src/Notifications/SlackNotifier.php @@ -0,0 +1,55 @@ +<?php + +namespace YakNet\AccessibilityConsole\Notifications; + +use GuzzleHttp\Client; + +class SlackNotifier implements NotifierInterface +{ + private Client $httpClient; + + public function __construct( + private string $webhookUrl, + ?Client $httpClient = null + ) { + $this->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 @@ +<?php + +namespace YakNet\AccessibilityConsole\Presets; + +class ECommercePreset implements PresetInterface +{ + public function getName(): string + { + return 'ecommerce'; + } + + public function getDescription(): string + { + return 'E-Commerce storefront and checkout flow accessibility profile.'; + } + + public function getLevel(): int + { + return 4; + } + + public function getIncludedRules(): array + { + return [ + 'WCAG_1_1_1_IMG_ALT', + 'WCAG_4_1_2_BUTTON', + 'WCAG_1_3_1_FORM_LABEL', + 'WCAG_3_3_7_REDUNDANT_ENTRY', + 'WCAG_2_5_8_TARGET_SIZE', + 'WCAG_1_4_3_CONTRAST', + 'WCAG_4_1_2_DIALOG', + ]; + } + + public function getExcludedRules(): array + { + return []; + } +} diff --git a/src/Presets/En301549Preset.php b/src/Presets/En301549Preset.php new file mode 100644 index 0000000..4ed96af --- /dev/null +++ b/src/Presets/En301549Preset.php @@ -0,0 +1,37 @@ +<?php + +namespace YakNet\AccessibilityConsole\Presets; + +class En301549Preset implements PresetInterface +{ + public function getName(): string + { + return 'en301549'; + } + + public function getDescription(): string + { + return 'European Union EN 301 549 public sector digital accessibility profile.'; + } + + public function getLevel(): int + { + return 4; + } + + public function getIncludedRules(): array + { + return [ + 'WCAG_1_4_3_CONTRAST', + 'WCAG_2_4_7_FOCUS_OUTLINE', + 'WCAG_2_5_8_TARGET_SIZE', + 'WCAG_1_4_12_TEXT_SPACING', + 'WCAG_2_5_3_LABEL_IN_NAME', + ]; + } + + public function getExcludedRules(): array + { + return []; + } +} diff --git a/src/Presets/PresetInterface.php b/src/Presets/PresetInterface.php new file mode 100644 index 0000000..f833091 --- /dev/null +++ b/src/Presets/PresetInterface.php @@ -0,0 +1,22 @@ +<?php + +namespace YakNet\AccessibilityConsole\Presets; + +interface PresetInterface +{ + public function getName(): string; + + public function getDescription(): string; + + public function getLevel(): int; + + /** + * @return string[] + */ + public function getIncludedRules(): array; + + /** + * @return string[] + */ + public function getExcludedRules(): array; +} diff --git a/src/Presets/PresetRegistry.php b/src/Presets/PresetRegistry.php new file mode 100644 index 0000000..2f8b642 --- /dev/null +++ b/src/Presets/PresetRegistry.php @@ -0,0 +1,36 @@ +<?php + +namespace YakNet\AccessibilityConsole\Presets; + +class PresetRegistry +{ + /** + * @var array<string, PresetInterface> + */ + 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<string, PresetInterface> + */ + 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 @@ +<?php + +namespace YakNet\AccessibilityConsole\Presets; + +class Section508Preset implements PresetInterface +{ + public function getName(): string + { + return 'section508'; + } + + public function getDescription(): string + { + return 'US Federal Government Section 508 accessibility compliance profile.'; + } + + public function getLevel(): int + { + return 3; + } + + public function getIncludedRules(): array + { + return [ + 'WCAG_1_1_1_IMG_ALT', + 'WCAG_1_3_1_TABLE', + 'WCAG_1_3_1_TABLE_HEADERS_ATTR', + 'WCAG_1_3_1_FORM_LABEL', + 'WCAG_2_1_1_KEYBOARD_CLICK', + 'WCAG_2_4_2_PAGE_TITLE', + 'WCAG_3_1_1_LANG', + ]; + } + + public function getExcludedRules(): array + { + return []; + } +} diff --git a/src/Reporting/JunitFormatter.php b/src/Reporting/JunitFormatter.php new file mode 100644 index 0000000..9de6bde --- /dev/null +++ b/src/Reporting/JunitFormatter.php @@ -0,0 +1,122 @@ +<?php + +namespace YakNet\AccessibilityConsole\Reporting; + +use SimpleXMLElement; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Style\SymfonyStyle; +use YakNet\AccessibilityConsole\Core\Violation; + +class JunitFormatter implements FormatterInterface +{ + /** + * @param Violation[] $violations + * @param int $baselinedCount + * @param SymfonyStyle $io + * @return int + */ + public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int + { + $xml = $this->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<string, Violation[]> $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 <div><b>Hedef URL / Dizin:</b> " . htmlspecialchars($targetUrl) . "</div> <div><b>Tarama Tarihi:</b> {$dateStr}</div> <div><b>Tespit Edilen İhlal Sayısı:</b> {$violationsCount} Hata</div> - <div><b>Raporu Üreten Motor:</b> YakNet AI & Accessibility Engine v3.2</div> + <div><b>Raporu Üreten Motor:</b> YakNet AI & Accessibility Engine v3.3</div> </div> <h3 style='color:#0f172a; border-bottom:1px solid #cbd5e1; padding-bottom:6px;'>📋 Erişilebilirlik İhlalleri ve Çözüm Detayları</h3> 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 @@ +<?php + +namespace YakNet\AccessibilityConsole\Reporting; + +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Style\SymfonyStyle; +use YakNet\AccessibilityConsole\Core\Severity; +use YakNet\AccessibilityConsole\Core\Violation; + +class SarifFormatter implements FormatterInterface +{ + /** + * @param Violation[] $violations + * @param int $baselinedCount + * @param SymfonyStyle $io + * @return int + */ + public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int + { + $sarifJson = $this->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<string, mixed> $region */ + $region = [ + 'startLine' => $line, + 'startColumn' => $column, + ]; + + if ($v->htmlSnippet !== '') { + $region['snippet'] = [ + 'text' => $v->htmlSnippet, + ]; + } + + /** @var array<string, mixed> $locationEntry */ + $locationEntry = [ + 'physicalLocation' => [ + 'artifactLocation' => [ + 'uri' => str_replace('\\', '/', $file), + ], + 'region' => $region, + ], + ]; + + /** @var array<string, mixed> $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 @@ +<?php + +namespace YakNet\AccessibilityConsole\Rules; + +use DOMElement; +use DOMNode; +use DOMXPath; +use YakNet\AccessibilityConsole\Core\Severity; +use YakNet\AccessibilityConsole\Core\Violation; +use YakNet\AccessibilityConsole\Core\WCAGStandard; + +class LabelInName extends AbstractRule +{ + public function getId(): string + { + return 'WCAG_2_5_3_LABEL_IN_NAME'; + } + + public function getDescription(): string + { + return 'Accessible name must contain the visible text label of interactive controls (WCAG 2.5.3).'; + } + + public function getStandard(): WCAGStandard + { + return WCAGStandard::A; + } + + public function getSeverity(): Severity + { + return Severity::ERROR; + } + + public function getLevel(): int + { + return 3; + } + + public function check(DOMElement $element): ?Violation + { + $tag = strtolower($element->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 @@ +<?php + +namespace YakNet\AccessibilityConsole\Rules; + +use DOMElement; +use DOMXPath; +use YakNet\AccessibilityConsole\Core\Severity; +use YakNet\AccessibilityConsole\Core\Violation; +use YakNet\AccessibilityConsole\Core\WCAGStandard; + +class RedundantEntry extends AbstractRule +{ + public function getId(): string + { + return 'WCAG_3_3_7_REDUNDANT_ENTRY'; + } + + public function getDescription(): string + { + return 'Forms with repeated information (such as billing and shipping) must provide auto-population or a selection mechanism (WCAG 2.2 SC 3.3.7).'; + } + + public function getStandard(): WCAGStandard + { + return WCAGStandard::A; + } + + public function getSeverity(): Severity + { + return Severity::WARNING; + } + + public function getLevel(): int + { + return 3; + } + + public function check(DOMElement $element): ?Violation + { + if (strtolower($element->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 @@ +<?php + +namespace YakNet\AccessibilityConsole\Rules; + +use DOMElement; +use YakNet\AccessibilityConsole\Core\AbstractRule; +use YakNet\AccessibilityConsole\Core\Severity; +use YakNet\AccessibilityConsole\Core\Violation; +use YakNet\AccessibilityConsole\Core\WCAGStandard; + +class RuleController +{ + /** + * @var array<string, RuleInterface|AbstractRule> + */ + private array $rulesRegistry = []; + + /** + * @param array<int, RuleInterface|AbstractRule>|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<string, RuleInterface|AbstractRule> + */ + 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<string, RuleInterface|AbstractRule> + */ + 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 @@ +<?php + +namespace YakNet\AccessibilityConsole\Rules; + +class RuleExecutionStats +{ + /** + * @var array<string, array{invocations: int, total_time_ms: float, violations: int}> + */ + 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<string, array{invocations: int, total_time_ms: float, avg_time_ms: float, violations: int}> + */ + 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<string, array{invocations: int, total_time_ms: float, violations: int}> + */ + 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 @@ +<?php + +namespace YakNet\AccessibilityConsole\Rules; + +use DOMElement; +use YakNet\AccessibilityConsole\Core\Severity; +use YakNet\AccessibilityConsole\Core\Violation; +use YakNet\AccessibilityConsole\Core\WCAGStandard; + +class TextSpacing extends AbstractRule +{ + public function getId(): string + { + return 'WCAG_1_4_12_TEXT_SPACING'; + } + + public function getDescription(): string + { + return 'Ensure text containers do not restrict line-height or clip content when text spacing is adjusted (WCAG 1.4.12).'; + } + + public function getStandard(): WCAGStandard + { + return WCAGStandard::AA; + } + + public function getSeverity(): Severity + { + return Severity::WARNING; + } + + public function getLevel(): int + { + return 4; + } + + public function check(DOMElement $element): ?Violation + { + if (!$element->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 @@ +<?php + +namespace YakNet\AccessibilityConsole\Tests\AutoFix; + +use PHPUnit\Framework\TestCase; +use YakNet\AccessibilityConsole\Core\SelfHealing\LocalHealer; +use YakNet\AccessibilityConsole\Core\Severity; +use YakNet\AccessibilityConsole\Core\Violation; +use YakNet\AccessibilityConsole\Core\WCAGStandard; + +class LocalHealerTest extends TestCase +{ + private LocalHealer $healer; + + protected function setUp(): void + { + $this->healer = new LocalHealer(); + } + + public function testHealsMissingImgAlt(): void + { + $violation = new Violation( + 'WCAG_1_1_1_IMG_ALT', + 'Image missing alt', + Severity::ERROR, + WCAGStandard::A, + '<img src="photo.jpg">' + ); + + $healed = $this->healer->heal($violation); + $this->assertNotNull($healed); + $this->assertStringContainsString('FIX: <img alt="" src="photo.jpg">', $healed); + } + + public function testHealsDecorativeSvg(): void + { + $violation = new Violation( + 'WCAG_1_1_1_SVG', + 'SVG needs accessible text', + Severity::ERROR, + WCAGStandard::A, + '<svg class="icon"><path d="..."/></svg>' + ); + + $healed = $this->healer->heal($violation); + $this->assertNotNull($healed); + $this->assertStringContainsString('FIX: <svg aria-hidden="true" class="icon">', $healed); + } + + public function testHealsTargetBlankLinkRel(): void + { + $violation = new Violation( + 'WCAG_LINK_TARGET_BLANK', + 'Target blank missing noopener', + Severity::WARNING, + WCAGStandard::A, + '<a href="https://example.com" target="_blank">External</a>' + ); + + $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, + '<button tabindex="5">Submit</button>' + ); + + $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, + '<td scope="col">Data cell</td>' + ); + + $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, + '<meta http-equiv="refresh" content="30">' + ); + + $healed = $this->healer->heal($violation); + $this->assertNotNull($healed); + $this->assertStringContainsString('<!-- <meta http-equiv="refresh" content="30"> (Removed', $healed); + } +} diff --git a/tests/Badges/BadgeGeneratorTest.php b/tests/Badges/BadgeGeneratorTest.php new file mode 100644 index 0000000..55c0f4e --- /dev/null +++ b/tests/Badges/BadgeGeneratorTest.php @@ -0,0 +1,61 @@ +<?php + +namespace YakNet\AccessibilityConsole\Tests\Badges; + +use PHPUnit\Framework\TestCase; +use YakNet\AccessibilityConsole\Badges\BadgeGenerator; + +class BadgeGeneratorTest extends TestCase +{ + public function testGeneratesValidScoreBadgeSvg(): void + { + $svg = BadgeGenerator::generateScoreBadge(95); + + $this->assertStringContainsString('<svg', $svg); + $this->assertStringContainsString('</svg>', $svg); + $this->assertStringContainsString('accessibility', $svg); + $this->assertStringContainsString('95/100', $svg); + $this->assertStringContainsString('#10b981', $svg); // green color for >= 90 + } + + public function testGeneratesWarningScoreBadgeSvg(): void + { + $svg = BadgeGenerator::generateScoreBadge(65); + + $this->assertStringContainsString('65/100', $svg); + $this->assertStringContainsString('#ef4444', $svg); // red color for < 70 + } + + public function testGeneratesComplianceBadge(): void + { + $svg = BadgeGenerator::generateComplianceBadge('WCAG 2.1 AA', true); + + $this->assertStringContainsString('WCAG 2.1 AA', $svg); + $this->assertStringContainsString('passed', $svg); + } + + public function testGeneratesViolationsBadge(): void + { + $svg = BadgeGenerator::generateViolationsBadge(0); + $this->assertStringContainsString('0 issues', $svg); + + $svgIssues = BadgeGenerator::generateViolationsBadge(4); + $this->assertStringContainsString('4 issues', $svgIssues); + } + + public function testGeneratesBadgeWithCustomOptions(): void + { + $options = new \YakNet\AccessibilityConsole\Badges\BadgeOptions( + label: 'custom-a11y', + leftColor: '#003366', + rightColor: '#00cc66', + borderRadius: 6 + ); + + $svg = BadgeGenerator::generateScoreBadge(88, $options); + $this->assertStringContainsString('custom-a11y', $svg); + $this->assertStringContainsString('#003366', $svg); + $this->assertStringContainsString('#00cc66', $svg); + $this->assertStringContainsString('rx="6"', $svg); + } +} diff --git a/tests/Console/RulesCommandTest.php b/tests/Console/RulesCommandTest.php new file mode 100644 index 0000000..a1b5a87 --- /dev/null +++ b/tests/Console/RulesCommandTest.php @@ -0,0 +1,53 @@ +<?php + +namespace YakNet\AccessibilityConsole\Tests\Console; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Tester\CommandTester; +use YakNet\AccessibilityConsole\Console\Command\RulesCommand; + +class RulesCommandTest extends TestCase +{ + public function testListAllRules(): void + { + $command = new RulesCommand(); + $tester = new CommandTester($command); + + $exitCode = $tester->execute([]); + + $this->assertSame(0, $exitCode); + $output = $tester->getDisplay(); + $this->assertStringContainsString('Accessibility Console - Available Rules', $output); + $this->assertStringContainsString('Total rules available: 130', $output); + } + + public function testFilterRulesBySearch(): void + { + $command = new RulesCommand(); + $tester = new CommandTester($command); + + $exitCode = $tester->execute([ + '--search' => 'contrast', + ]); + + $this->assertSame(0, $exitCode); + $output = $tester->getDisplay(); + $this->assertStringContainsString('WCAG_1_4_3_CONTRAST', $output); + $this->assertStringContainsString('Showing', $output); + } + + public function testFilterRulesByStandardAndLevel(): void + { + $command = new RulesCommand(); + $tester = new CommandTester($command); + + $exitCode = $tester->execute([ + '--standard' => 'AA', + ]); + + $this->assertSame(0, $exitCode); + $output = $tester->getDisplay(); + $this->assertStringContainsString('AA', $output); + $this->assertStringContainsString('Showing', $output); + } +} diff --git a/tests/Console/WatchCommandTest.php b/tests/Console/WatchCommandTest.php new file mode 100644 index 0000000..808f1f5 --- /dev/null +++ b/tests/Console/WatchCommandTest.php @@ -0,0 +1,49 @@ +<?php + +namespace YakNet\AccessibilityConsole\Tests\Console; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Tester\CommandTester; +use YakNet\AccessibilityConsole\Console\Command\WatchCommand; + +class WatchCommandTest extends TestCase +{ + public function testWatchCommandRunsInitialScanAndExitsWithMaxIterations(): void + { + $command = new WatchCommand(); + $tester = new CommandTester($command); + + $exitCode = $tester->execute([ + 'target' => 'demo/views-fixed/home.php', + '--max-iterations' => '1', + '--interval' => '1', + ]); + + $this->assertSame(0, $exitCode); + $output = $tester->getDisplay(); + $this->assertStringContainsString('Accessibility Console - Real-Time Watch Mode', $output); + $this->assertStringContainsString('Initial Scan', $output); + $this->assertStringContainsString('Health Score:', $output); + } + + public function testDetectChangedFiles(): void + { + $command = new WatchCommand(); + + $old = [ + 'file1.php' => 100, + 'file2.php' => 200, + ]; + + $new = [ + 'file1.php' => 100, + 'file2.php' => 205, // modified + 'file3.php' => 300, // added + ]; + + $changed = $command->detectChangedFiles($old, $new); + $this->assertContains('file2.php', $changed); + $this->assertContains('file3.php', $changed); + $this->assertNotContains('file1.php', $changed); + } +} diff --git a/tests/Notifications/NotificationDispatcherTest.php b/tests/Notifications/NotificationDispatcherTest.php new file mode 100644 index 0000000..9b80a44 --- /dev/null +++ b/tests/Notifications/NotificationDispatcherTest.php @@ -0,0 +1,37 @@ +<?php + +namespace YakNet\AccessibilityConsole\Tests\Notifications; + +use PHPUnit\Framework\TestCase; +use YakNet\AccessibilityConsole\Notifications\NotificationDispatcher; +use YakNet\AccessibilityConsole\Notifications\NotificationPayload; +use YakNet\AccessibilityConsole\Notifications\NotifierInterface; + +class NotificationDispatcherTest extends TestCase +{ + public function testDispatchToMultipleNotifiers(): void + { + $dispatcher = new NotificationDispatcher(); + + $mockSlack = $this->createMock(NotifierInterface::class); + $mockSlack->expects($this->once()) + ->method('send') + ->willReturn(true); + + $mockDiscord = $this->createMock(NotifierInterface::class); + $mockDiscord->expects($this->once()) + ->method('send') + ->willReturn(true); + + $dispatcher->addNotifier('slack', $mockSlack); + $dispatcher->addNotifier('discord', $mockDiscord); + + $this->assertTrue($dispatcher->hasNotifiers()); + + $payload = new NotificationPayload('https://example.com', 95, 2, 0, 2); + $results = $dispatcher->dispatch($payload); + + $this->assertTrue($results['slack']); + $this->assertTrue($results['discord']); + } +} diff --git a/tests/Presets/PresetRegistryTest.php b/tests/Presets/PresetRegistryTest.php new file mode 100644 index 0000000..db72db9 --- /dev/null +++ b/tests/Presets/PresetRegistryTest.php @@ -0,0 +1,41 @@ +<?php + +namespace YakNet\AccessibilityConsole\Tests\Presets; + +use PHPUnit\Framework\TestCase; +use YakNet\AccessibilityConsole\Presets\PresetRegistry; +use YakNet\AccessibilityConsole\Presets\Section508Preset; +use YakNet\AccessibilityConsole\Presets\En301549Preset; +use YakNet\AccessibilityConsole\Presets\ECommercePreset; + +class PresetRegistryTest extends TestCase +{ + public function testRegistryLoadsDefaultPresets(): void + { + $registry = new PresetRegistry(); + $all = $registry->all(); + + $this->assertArrayHasKey('section508', $all); + $this->assertArrayHasKey('en301549', $all); + $this->assertArrayHasKey('ecommerce', $all); + } + + public function testGetPresetByName(): void + { + $registry = new PresetRegistry(); + + $preset = $registry->get('section508'); + $this->assertInstanceOf(Section508Preset::class, $preset); + $this->assertSame(3, $preset->getLevel()); + + $ecommerce = $registry->get('ecommerce'); + $this->assertInstanceOf(ECommercePreset::class, $ecommerce); + $this->assertContains('WCAG_3_3_7_REDUNDANT_ENTRY', $ecommerce->getIncludedRules()); + } + + public function testGetNonExistentPresetReturnsNull(): void + { + $registry = new PresetRegistry(); + $this->assertNull($registry->get('non_existent')); + } +} diff --git a/tests/Reporting/JunitFormatterTest.php b/tests/Reporting/JunitFormatterTest.php new file mode 100644 index 0000000..1d77e90 --- /dev/null +++ b/tests/Reporting/JunitFormatterTest.php @@ -0,0 +1,56 @@ +<?php + +namespace YakNet\AccessibilityConsole\Tests\Reporting; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\BufferedOutput; +use Symfony\Component\Console\Style\SymfonyStyle; +use YakNet\AccessibilityConsole\Core\Severity; +use YakNet\AccessibilityConsole\Core\Violation; +use YakNet\AccessibilityConsole\Core\WCAGStandard; +use YakNet\AccessibilityConsole\Reporting\JunitFormatter; + +class JunitFormatterTest extends TestCase +{ + public function testRendersValidJunitXmlWithViolations(): void + { + $formatter = new JunitFormatter(); + $violations = [ + new Violation( + 'WCAG_1_1_1_IMG_ALT', + 'Images must have alternative text.', + Severity::ERROR, + WCAGStandard::A, + '<img src="photo.png">', + ['file' => 'views/index.php', 'line' => 10, 'column' => 2] + ), + ]; + + $xml = $formatter->render($violations); + $this->assertStringContainsString('<?xml version="1.0" encoding="UTF-8"?>', $xml); + $this->assertStringContainsString('<testsuites', $xml); + $this->assertStringContainsString('failures="1"', $xml); + $this->assertStringContainsString('<failure', $xml); + $this->assertStringContainsString('WCAG_1_1_1_IMG_ALT', $xml); + + // Verify valid XML parsing + $dom = new \DOMDocument(); + $this->assertTrue($dom->loadXML($xml)); + } + + public function testRendersCleanXmlWhenEmpty(): void + { + $formatter = new JunitFormatter(); + $input = new ArrayInput([]); + $output = new BufferedOutput(); + $io = new SymfonyStyle($input, $output); + + $exitCode = $formatter->format([], 0, $io); + $this->assertSame(0, $exitCode); + + $xml = $output->fetch(); + $this->assertStringContainsString('failures="0"', $xml); + $this->assertStringContainsString('AccessibilityCompliant', $xml); + } +} diff --git a/tests/Reporting/SarifFormatterTest.php b/tests/Reporting/SarifFormatterTest.php new file mode 100644 index 0000000..a29b0d4 --- /dev/null +++ b/tests/Reporting/SarifFormatterTest.php @@ -0,0 +1,73 @@ +<?php + +namespace YakNet\AccessibilityConsole\Tests\Reporting; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\BufferedOutput; +use Symfony\Component\Console\Style\SymfonyStyle; +use YakNet\AccessibilityConsole\Core\Severity; +use YakNet\AccessibilityConsole\Core\Violation; +use YakNet\AccessibilityConsole\Core\WCAGStandard; +use YakNet\AccessibilityConsole\Reporting\SarifFormatter; + +class SarifFormatterTest extends TestCase +{ + public function testRendersValidSarifJson(): void + { + $formatter = new SarifFormatter(); + $violations = [ + new Violation( + 'WCAG_1_1_1_IMG_ALT', + 'Images must have alternative text.', + Severity::ERROR, + WCAGStandard::A, + '<img src="test.jpg">', + ['file' => 'templates/home.blade.php', 'line' => 14, 'column' => 5], + 'Add alt="" attribute' + ), + new Violation( + 'WCAG_1_4_3_CONTRAST', + 'Insufficient color contrast.', + Severity::WARNING, + WCAGStandard::AA, + '<span style="color:#ccc">Text</span>', + ['file' => 'templates/home.blade.php', 'line' => 20, 'column' => 1] + ), + ]; + + $json = $formatter->render($violations); + $this->assertJson($json); + + $decoded = json_decode($json, true); + $this->assertIsArray($decoded); + $this->assertSame('2.1.0', $decoded['version']); + $this->assertSame('YakNet Accessibility Console', $decoded['runs'][0]['tool']['driver']['name']); + $this->assertCount(2, $decoded['runs'][0]['results']); + + $firstResult = $decoded['runs'][0]['results'][0]; + $this->assertSame('WCAG_1_1_1_IMG_ALT', $firstResult['ruleId']); + $this->assertSame('error', $firstResult['level']); + $this->assertSame('templates/home.blade.php', $firstResult['locations'][0]['physicalLocation']['artifactLocation']['uri']); + $this->assertSame(14, $firstResult['locations'][0]['physicalLocation']['region']['startLine']); + } + + public function testFormatOutputsToSymfonyStyle(): void + { + $formatter = new SarifFormatter(); + $violations = []; + + $input = new ArrayInput([]); + $output = new BufferedOutput(); + $io = new SymfonyStyle($input, $output); + + $exitCode = $formatter->format($violations, 0, $io); + $this->assertSame(0, $exitCode); + + $content = $output->fetch(); + $this->assertJson($content); + $decoded = json_decode($content, true); + $this->assertIsArray($decoded); + $this->assertEmpty($decoded['runs'][0]['results']); + } +} diff --git a/tests/Rules/LabelInNameTest.php b/tests/Rules/LabelInNameTest.php new file mode 100644 index 0000000..4edbd22 --- /dev/null +++ b/tests/Rules/LabelInNameTest.php @@ -0,0 +1,56 @@ +<?php + +namespace YakNet\AccessibilityConsole\Tests\Rules; + +use PHPUnit\Framework\TestCase; +use YakNet\AccessibilityConsole\Core\Scanner; +use YakNet\AccessibilityConsole\Rules\LabelInName; + +class LabelInNameTest extends TestCase +{ + public function testFlagsButtonWhoseAriaLabelDoesNotContainVisibleText(): void + { + $scanner = new Scanner(); + $scanner->addRule(new LabelInName()); + + $html = '<button aria-label="Send Form">Submit</button>'; + $violations = $scanner->scan($html); + + $this->assertCount(1, $violations); + $this->assertSame('WCAG_2_5_3_LABEL_IN_NAME', $violations[0]->ruleId); + $this->assertStringContainsString('Submit', $violations[0]->message); + } + + public function testPassesButtonWhoseAriaLabelContainsVisibleText(): void + { + $scanner = new Scanner(); + $scanner->addRule(new LabelInName()); + + $html = '<button aria-label="Submit this inquiry">Submit</button>'; + $violations = $scanner->scan($html); + + $this->assertCount(0, $violations); + } + + public function testPassesButtonWithoutExplicitAriaLabel(): void + { + $scanner = new Scanner(); + $scanner->addRule(new LabelInName()); + + $html = '<button>Click Here</button>'; + $violations = $scanner->scan($html); + + $this->assertCount(0, $violations); + } + + public function testFlagsLinkWhoseAriaLabelDiffersFromVisibleText(): void + { + $scanner = new Scanner(); + $scanner->addRule(new LabelInName()); + + $html = '<a href="/contact" aria-label="Reach out to support">Contact Us</a>'; + $violations = $scanner->scan($html); + + $this->assertCount(1, $violations); + } +} diff --git a/tests/Rules/RedundantEntryTest.php b/tests/Rules/RedundantEntryTest.php new file mode 100644 index 0000000..57a9134 --- /dev/null +++ b/tests/Rules/RedundantEntryTest.php @@ -0,0 +1,44 @@ +<?php + +namespace YakNet\AccessibilityConsole\Tests\Rules; + +use PHPUnit\Framework\TestCase; +use YakNet\AccessibilityConsole\Core\Scanner; +use YakNet\AccessibilityConsole\Rules\RedundantEntry; + +class RedundantEntryTest extends TestCase +{ + public function testFlagsFormWithShippingAndBillingWithoutSameAsCheckbox(): void + { + $scanner = new Scanner(); + $scanner->addRule(new RedundantEntry()); + + $html = '<form> + <input type="text" name="shipping_address" id="shipping_address"> + <input type="text" name="shipping_city" id="shipping_city"> + <input type="text" name="billing_address" id="billing_address"> + <input type="text" name="billing_city" id="billing_city"> + </form>'; + $violations = $scanner->scan($html); + + $this->assertCount(1, $violations); + $this->assertSame('WCAG_3_3_7_REDUNDANT_ENTRY', $violations[0]->ruleId); + } + + public function testPassesFormWithSameAsShippingCheckbox(): void + { + $scanner = new Scanner(); + $scanner->addRule(new RedundantEntry()); + + $html = '<form> + <input type="text" name="shipping_address" id="shipping_address"> + <input type="text" name="shipping_city" id="shipping_city"> + <label><input type="checkbox" name="same_as_shipping"> Billing same as shipping</label> + <input type="text" name="billing_address" id="billing_address"> + <input type="text" name="billing_city" id="billing_city"> + </form>'; + $violations = $scanner->scan($html); + + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/RuleControllerTest.php b/tests/Rules/RuleControllerTest.php new file mode 100644 index 0000000..66027f7 --- /dev/null +++ b/tests/Rules/RuleControllerTest.php @@ -0,0 +1,56 @@ +<?php + +namespace YakNet\AccessibilityConsole\Tests\Rules; + +use PHPUnit\Framework\TestCase; +use YakNet\AccessibilityConsole\Core\Severity; +use YakNet\AccessibilityConsole\Core\WCAGStandard; +use YakNet\AccessibilityConsole\Rules\RuleController; + +class RuleControllerTest extends TestCase +{ + public function testInitializesAndRetrievesRule(): void + { + $controller = new RuleController(); + $this->assertGreaterThanOrEqual(130, $controller->count()); + + $rule = $controller->getRule('WCAG_2_5_3_LABEL_IN_NAME'); + $this->assertNotNull($rule); + } + + public function testFilterByStandard(): void + { + $controller = new RuleController(); + $aaRules = $controller->filterByStandard(WCAGStandard::AA); + + $this->assertNotEmpty($aaRules); + foreach ($aaRules as $rule) { + $this->assertSame(WCAGStandard::AA, $rule->getStandard()); + } + } + + public function testFilterBySeverity(): void + { + $controller = new RuleController(); + $errorRules = $controller->filterBySeverity(Severity::ERROR); + + $this->assertNotEmpty($errorRules); + foreach ($errorRules as $rule) { + $this->assertSame(Severity::ERROR, $rule->getSeverity()); + } + } + + public function testExecuteRuleDirectlyOnDomElement(): void + { + $controller = new RuleController(); + + $dom = new \DOMDocument(); + $dom->loadHTML('<button aria-label="Cancel">Submit</button>'); + $button = $dom->getElementsByTagName('button')->item(0); + $this->assertInstanceOf(\DOMElement::class, $button); + + $violation = $controller->executeRule('WCAG_2_5_3_LABEL_IN_NAME', $button); + $this->assertNotNull($violation); + $this->assertSame('WCAG_2_5_3_LABEL_IN_NAME', $violation->ruleId); + } +} diff --git a/tests/Rules/RuleExecutionStatsTest.php b/tests/Rules/RuleExecutionStatsTest.php new file mode 100644 index 0000000..3e1beb1 --- /dev/null +++ b/tests/Rules/RuleExecutionStatsTest.php @@ -0,0 +1,37 @@ +<?php + +namespace YakNet\AccessibilityConsole\Tests\Rules; + +use PHPUnit\Framework\TestCase; +use YakNet\AccessibilityConsole\Rules\RuleExecutionStats; + +class RuleExecutionStatsTest extends TestCase +{ + public function testRecordAndRetrieveStats(): void + { + $stats = new RuleExecutionStats(); + + $stats->record('WCAG_1_1_1_IMG_ALT', 2.5, true); + $stats->record('WCAG_1_1_1_IMG_ALT', 1.5, false); + $stats->record('WCAG_1_4_3_CONTRAST', 10.0, true); + + $all = $stats->getAllStats(); + $this->assertSame(2, $all['WCAG_1_1_1_IMG_ALT']['invocations']); + $this->assertSame(4.0, $all['WCAG_1_1_1_IMG_ALT']['total_time_ms']); + $this->assertSame(1, $all['WCAG_1_1_1_IMG_ALT']['violations']); + + $slowest = $stats->getSlowestRules(1); + $this->assertArrayHasKey('WCAG_1_4_3_CONTRAST', $slowest); + $this->assertSame(10.0, $slowest['WCAG_1_4_3_CONTRAST']['total_time_ms']); + } + + public function testResetStats(): void + { + $stats = new RuleExecutionStats(); + $stats->record('WCAG_1_1_1_IMG_ALT', 5.0, true); + $this->assertNotEmpty($stats->getAllStats()); + + $stats->reset(); + $this->assertEmpty($stats->getAllStats()); + } +} diff --git a/tests/Rules/TextSpacingTest.php b/tests/Rules/TextSpacingTest.php new file mode 100644 index 0000000..7d806a0 --- /dev/null +++ b/tests/Rules/TextSpacingTest.php @@ -0,0 +1,44 @@ +<?php + +namespace YakNet\AccessibilityConsole\Tests\Rules; + +use PHPUnit\Framework\TestCase; +use YakNet\AccessibilityConsole\Core\Scanner; +use YakNet\AccessibilityConsole\Rules\TextSpacing; + +class TextSpacingTest extends TestCase +{ + public function testFlagsConstrainedLineHeight(): void + { + $scanner = new Scanner(); + $scanner->addRule(new TextSpacing()); + + $html = '<p style="line-height: 1; font-size: 16px;">This text has insufficient line height.</p>'; + $violations = $scanner->scan($html); + + $this->assertCount(1, $violations); + $this->assertSame('WCAG_1_4_12_TEXT_SPACING', $violations[0]->ruleId); + } + + public function testPassesAdequateLineHeight(): void + { + $scanner = new Scanner(); + $scanner->addRule(new TextSpacing()); + + $html = '<p style="line-height: 1.6; font-size: 16px;">Proper line height content.</p>'; + $violations = $scanner->scan($html); + + $this->assertCount(0, $violations); + } + + public function testFlagsFixedHeightWithOverflowHiddenOnText(): void + { + $scanner = new Scanner(); + $scanner->addRule(new TextSpacing()); + + $html = '<div style="height: 30px; overflow: hidden;"><p>This is a long paragraph that might be clipped when spacing is enlarged by the user in accessible mode.</p></div>'; + $violations = $scanner->scan($html); + + $this->assertCount(1, $violations); + } +}