Compare commits

..
1 Commits
43 changed files with 2598 additions and 81 deletions
+83 -26
View File
@@ -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
```
---
+3 -1
View File
@@ -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();
+1 -1
View File
@@ -12,7 +12,7 @@
"yaknet"
],
"homepage": "https://forum.yak.net.tr",
"version": "3.2.7",
"version": "3.3.0",
"type": "library",
"license": "MIT",
"authors": [
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace YakNet\AccessibilityConsole\Badges;
class BadgeGenerator
{
/**
* Generate an SVG badge representing the Accessibility Health Score.
*
* @param int $score 0-100
* @param BadgeOptions|null $options
* @return string Raw SVG markup
*/
public static function generateScoreBadge(int $score, ?BadgeOptions $options = null): string
{
$opts = $options ?? new BadgeOptions();
$color = $opts->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 <<<SVG
<svg xmlns="http://www.w3.org/2000/svg" width="{$totalWidth}" height="{$height}" role="img" aria-label="{$escLabel}: {$escValue}">
<title>{$escLabel}: {$escValue}</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="{$totalWidth}" height="{$height}" rx="{$borderRadius}" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="{$leftWidth}" height="{$height}" fill="{$leftColor}"/>
<rect x="{$leftWidth}" width="{$rightWidth}" height="{$height}" fill="{$rightColor}"/>
<rect width="{$totalWidth}" height="{$height}" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="{$fontFamily}" text-rendering="geometricPrecision" font-size="{$fontSize}">
<text aria-hidden="true" x="{$leftCenter}" y="15" fill="#010101" fill-opacity=".3">{$escLabel}</text>
<text x="{$leftCenter}" y="14">{$escLabel}</text>
<text aria-hidden="true" x="{$rightCenter}" y="15" fill="#010101" fill-opacity=".3">{$escValue}</text>
<text x="{$rightCenter}" y="14">{$escValue}</text>
</g>
</svg>
SVG;
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace YakNet\AccessibilityConsole\Badges;
/**
* Options DTO for customizing SVG badge generation.
*/
class BadgeOptions
{
/**
* @param string $label Default left container text label
* @param string $leftColor Hex color code for left container background
* @param string|null $rightColor Optional hex color override for right container background
* @param int $borderRadius Corner radius for SVG clipPath
* @param string $fontFamily Font family CSS rule for badge typography
* @param int $fontSize Font size in pixels
*/
public function __construct(
public readonly string $label = 'accessibility',
public readonly string $leftColor = '#1f2937',
public readonly ?string $rightColor = null,
public readonly int $borderRadius = 3,
public readonly string $fontFamily = 'Verdana,Geneva,DejaVu Sans,sans-serif',
public readonly int $fontSize = 11,
) {
}
}
+21 -13
View File
@@ -26,6 +26,7 @@ class FixCommand extends Command
->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("<fg=gray>Configuration loaded from:</> <fg=cyan>$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("<fg=yellow>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("<fg=yellow>Skipped (Rule requires AI or manual intervention)</>");
continue;
}
$suggestion = $rawSuggestion;
+45 -3
View File
@@ -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([
"<fg=cyan;options=bold>$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;
}
+174 -33
View File
@@ -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("<fg=gray>Configuration loaded from:</> <fg=cyan>$configPath</>");
$io->writeln("<fg=gray>Rule Level:</> <fg=yellow>$level</>");
if ($format === 'console') {
$io->title("Accessibility Console - Static Analysis");
$io->writeln("<fg=gray>Configuration loaded from:</> <fg=cyan>$configPath</>");
$io->writeln("<fg=gray>Rule Level:</> <fg=yellow>$level</>");
if ($activePreset !== null) {
$io->writeln("<fg=gray>Compliance Preset:</> <fg=magenta>" . $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: <fg=%s>%d/100</>", $color, $metrics['score']));
$color = $score >= 90 ? 'green' : ($score >= 70 ? 'yellow' : 'red');
$io->writeln(sprintf("Health Score: <fg=%s>%d/100</>", $color, $score));
$io->writeln(sprintf(" - Screen Reader / Visual Impact: <fg=cyan>%d%%</>", $metrics['visualImpact']));
$io->writeln(sprintf(" - Keyboard / Motor Impact: <fg=cyan>%d%%</>", $metrics['motorImpact']));
$io->writeln(sprintf(" - Cognitive / Distraction Impact: <fg=cyan>%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<string, \YakNet\AccessibilityConsole\Core\Violation[]>|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");
}
}
+217
View File
@@ -0,0 +1,217 @@
<?php
namespace YakNet\AccessibilityConsole\Console\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use YakNet\AccessibilityConsole\Core\Analyser;
use YakNet\AccessibilityConsole\Core\BaselineManager;
use YakNet\AccessibilityConsole\Core\Config;
use YakNet\AccessibilityConsole\Core\Metrics\AccessibilityScoreCalculator;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Reporting\ConsoleFormatter;
use YakNet\AccessibilityConsole\Rules\RuleLevels;
class WatchCommand extends Command
{
protected function configure(): void
{
$this
->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("<fg=gray>Watching paths:</> <fg=cyan>" . implode(', ', $paths) . "</>");
$io->writeln("<fg=gray>Rule Level:</> <fg=yellow>$level</>");
$io->writeln("<fg=gray>Poll Interval:</> <fg=yellow>{$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(
"<fg=yellow>[%s]</> Change detected in <fg=cyan>%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: <fg=%s;options=bold>%d/100</> | Scan time: <fg=gray>%ss</>",
$color,
$score,
$elapsed
));
}
/**
* @param string[] $paths
* @param string[] $excludePaths
* @return array<string, int>
*/
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<string, int> $oldMtimes
* @param array<string, int> $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));
}
}
+4
View File
@@ -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();
+1 -1
View File
@@ -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);
}
+80 -2
View File
@@ -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('/<img\b(?![^>]*\balt=)/i', $snippet)) {
$fixed = preg_replace('/<img\b/i', '<img alt=""', $snippet) ?: $snippet;
return "EXPLANATION: Injected missing alt=\"\" attribute for decorative image.\nFIX: " . $fixed;
}
break;
case 'WCAG_1_1_1_SVG':
case 'SvgAltText':
if (preg_match('/<svg\b(?![^>]*\baria-hidden=)(?![^>]*\baria-label=)/i', $snippet) && !str_contains($snippet, '<title>')) {
$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;
}
+66
View File
@@ -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;
}
}
}
@@ -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);
}
}
+18
View File
@@ -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 = []
) {}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace YakNet\AccessibilityConsole\Notifications;
interface NotifierInterface
{
public function send(NotificationPayload $payload): bool;
}
+55
View File
@@ -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;
}
}
}
+39
View File
@@ -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 [];
}
}
+37
View File
@@ -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 [];
}
}
+22
View File
@@ -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;
}
+36
View File
@@ -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;
}
}
+39
View File
@@ -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 [];
}
}
+122
View File
@@ -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();
}
}
+1 -1
View File
@@ -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>
+155
View File
@@ -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));
}
}
+118
View File
@@ -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;
}
}
+113
View File
@@ -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;
}
}
+94
View File
@@ -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);
}
}
+74
View File
@@ -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 = [];
}
}
+89
View File
@@ -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;
}
}
+109
View File
@@ -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);
}
}
+61
View File
@@ -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);
}
}
+53
View File
@@ -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);
}
}
+49
View File
@@ -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);
}
}
@@ -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']);
}
}
+41
View File
@@ -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'));
}
}
+56
View File
@@ -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);
}
}
+73
View File
@@ -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']);
}
}
+56
View File
@@ -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);
}
}
+44
View File
@@ -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);
}
}
+56
View File
@@ -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);
}
}
+37
View File
@@ -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());
}
}
+44
View File
@@ -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);
}
}