Git Komut Rehberi & Terminal Asistanı
Sıfırdan Yeni Bir Depo Başlatın:
Mevcut Bir Git Deposu veya Klasörü Gönderin:
Hataları Geri Alma, İptal & Temizlik:
Son commit'i geri al (yapılan değişiklikleri korur):
git reset --soft HEAD~1
Yapılan yerel değişiklikleri iptal et (çalışma ağacını temizler):
git restore .
Takip edilmeyen gereksiz dosyaları sil:
git clean -fd
Değişiklikleri geçici sakla ve geri getir (stash):
git stash git stash pop
Dal (Branch) ve Sürüm Etiketleri:
Yeni dal aç ve geçiş yap:
git checkout -b yeni-ozellik git push -u origin yeni-ozellik
Sürüm etiketi (tag) oluştur ve gönder:
git tag -a v1.0.0 -m "Sürüm 1.0.0" git push origin --tags
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef8ccb9f75 |
@@ -1,13 +1,13 @@
|
||||
# YakNet Accessibility Console 3.0 (AI-Powered Static Analysis & WCAG 2.2 Linter)
|
||||
# YakNet Accessibility Console 3.3 (AI-Powered Static Analysis & WCAG 2.2 Linter)
|
||||
|
||||
[](https://php.net)
|
||||
[](https://phpstan.org)
|
||||
[](https://phpunit.de)
|
||||
[](https://www.w3.org/WAI/standards-guidelines/wcag/)
|
||||
[](https://phpunit.de)
|
||||
[](https://www.w3.org/WAI/standards-guidelines/wcag/)
|
||||
[](LICENSE)
|
||||
[](https://ai.google.dev)
|
||||
|
||||
**YakNet Accessibility Console**, web projelerinizdeki erişilebilirlik (WCAG 2.1, 2.2 ve WAI-ARIA) ve HTML söz dizimi hatalarını statik olarak analiz eden, **127 benzersiz kural setiyle** denetleyen ve **Yapay Zeka (Google Gemini AI)** desteğiyle kaynak kodunuzu otomatik tamir edebilen (Self-Healing) kurumsal düzeyde bir PHP statik analiz ve linter kütüphanesidir.
|
||||
**YakNet Accessibility Console**, web projelerinizdeki erişilebilirlik (WCAG 2.1, 2.2 ve WAI-ARIA) ve HTML söz dizimi hatalarını statik olarak analiz eden, **130 benzersiz kural setiyle** denetleyen, kurumsal CI/CD kalite kapısı (Quality Gates) sunan ve **Yapay Zeka (Google Gemini AI) ile Çevrimdışı Deterministik Motor** desteğiyle kaynak kodunuzu otomatik tamir edebilen (Self-Healing) kurumsal düzeyde bir PHP statik analiz ve linter kütüphanesidir.
|
||||
|
||||
PHPStan ve PHP-CS-Fixer esintili modern mimarisiyle; Laravel Blade (`.blade.php`), Twig (`.twig`), PHP (`.php`) ve standart HTML şablonlarınızı doğrudan tarar, baseline desteğiyle mevcut teknik borcu yönetir ve CI/CD pipeline süreçlerinize sıfır yapılandırmayla entegre olur.
|
||||
|
||||
@@ -15,7 +15,13 @@ PHPStan ve PHP-CS-Fixer esintili modern mimarisiyle; Laravel Blade (`.blade.php`
|
||||
|
||||
## 🌟 Öne Çıkan Özellikler
|
||||
|
||||
- **🛡️ 127 Benzersiz WCAG 2.1 / 2.2 & WAI-ARIA Kuralı:** Algılanabilir (Perceivable), Çalıştırılabilir (Operable), Anlaşılabilir (Understandable) ve Sağlam (Robust) prensiplerine göre yapılandırılmış 5 kademeli (Level 1-5) kural havuzu.
|
||||
- **🛡️ 130 Benzersiz WCAG 2.1 / 2.2 & WAI-ARIA Kuralı:** Algılanabilir (Perceivable), Çalıştırılabilir (Operable), Anlaşılabilir (Understandable) ve Sağlam (Robust) prensiplerine göre yapılandırılmış 5 kademeli kural havuzu. (Yeni: `LabelInName` WCAG 2.5.3, `RedundantEntry` WCAG 3.3.7, `TextSpacing` WCAG 1.4.12).
|
||||
- **🏛️ Sektörel ve Mevzuat Ön Ayarları (Presets):** US Section 508, Avrupa Birliği EN 301 549 ve E-Ticaret (`--preset=section508|en301549|ecommerce`) profilleriyle hedeflenmiş standart denetimi.
|
||||
- **🏷️ Dinamik SVG Rozet Üretimi (Badges):** Tarama tamamlandığında sağlık skoruna göre (yeşil/sarı/kırmızı) canlı SVG rozet (`--badge=public/a11y-badge.svg`) üretir.
|
||||
- **📢 CI/CD Webhook Bildirimleri (Notifications):** Tarama sonuçlarını Slack ve Discord webhook kanallarına otomatik formatlanmış zengin mesajlarla iletir.
|
||||
- **⚙️ Programatik Kural Denetleyicisi & İstatistikler:** `RuleController` ile DOM elementleri üzerinde doğrudan tekil kural yürütme ve `RuleExecutionStats` ile kural performans/yürütme süresi izleme.
|
||||
- **⏱️ Gerçek Zamanlı İzleme Modu (`watch`):** Şablon dosyalarını (`.blade.php`, `.twig`, `.html`) arka planda izler, dosya kaydedildiğinde anında hızlı analiz yapıp konsolda canlı skor ve geri bildirim sunar.
|
||||
- **🚦 CI/CD Kalite Kapısı Eşikleri (Quality Gates):** `--min-score=90`, `--max-warnings=5`, `--fail-on=error` seçenekleriyle pipeline süreçlerinde esnek kalite denetimi sağlar.
|
||||
- **🔍 HTML & Şablon Söz Dizimi (Syntax/AST) Linter'ı:**
|
||||
- Aynı etiket üzerinde yinelenen nitelikler (`Duplicate Attributes` - HTML5 3.2.4 ihlali)
|
||||
- Kapanmamış veya çapraz kapanmış yapısal etiketler (`Mismatched / Unclosed Tags`)
|
||||
@@ -25,11 +31,17 @@ PHPStan ve PHP-CS-Fixer esintili modern mimarisiyle; Laravel Blade (`.blade.php`
|
||||
- **⚡ Tek Geçişli (Single-Pass) Yüksek Hızlı Kural Motoru:** DOM ağacını tek bir geçişte tarayarak büyük şablonlarda 5-10 kat daha hızlı analiz gerçekleştirir.
|
||||
- **📝 1:1 Satır Eşlemeli Şablon Ön İşleyici (Template Preprocessor):** Blade direktiflerini (`@if`, `@foreach`, `@component`), Twig bloklarını (`{% %}`, `{{ }}`) ve PHP etiketlerini temizlerken satır sayılarını birebir korur.
|
||||
- **🎯 Yüksek Hassasiyetli Satır & Sütun Bulucu (`PreciseElementLocator`):** Çok satırlı ve dinamik şablon bileşenlerinde ihlalin kaynak koddaki tam satır ve sütun numarasını saptar.
|
||||
- **🤖 Yapay Zeka ile Otomatik Onarım (AI Self-Healing):** Tespit edilen WCAG ve söz dizimi hataları için Google Gemini AI üzerinden akıllı çözüm ve tamir kodları üretir; kaynak dosyalarınızı (`--dry-run` desteğiyle) anında onarır.
|
||||
- **🤖 Yapay Zeka & Çevrimdışı Otomatik Onarım (Self-Healing):**
|
||||
- **Çevrimdışı (Offline) Mod:** API anahtarı olmadan güvenli deterministik tamirler (`alt=""`, `aria-hidden="true"`, `rel="noopener noreferrer"`, `tabindex="0"`, `scope` temizliği vb.).
|
||||
- **AI Destekli Mod:** Karmaşık şablon mantığı ve etiketler için Google Gemini AI ile akıllı tamir.
|
||||
- **📊 Erişilebilirlik Analitiği & Uyumluluk Matrisi:** POUR prensipleri, WCAG A/AA/AAA başarı oranları ve KLOC başına hata yoğunluğu (Defect Density) istatistikleri.
|
||||
- **🛡️ Baseline Desteği (`a11y-baseline.json`):** Mevcut hataları dondurarak CI/CD süreçlerinin yalnızca **yeni eklenen** hatalarda kırılmasını sağlar.
|
||||
- **🔌 Çoklu Çıktı Formatları:** Renkli konsol tablosu (`console`), makine tarafından okunabilir JSON (`json`), GitHub Actions PR bildirimleri (`github`) ve interaktif HTML Dashboard raporu (`--report`).
|
||||
- **🏆 PHPStan Level 9 & %100 Tip Güvenliği:** Kod tabanı PHPStan en yüksek seviye (Level 9) ve 191+ birim testi ile tam doğrulanmıştır.
|
||||
- **🔌 Zengin Çıktı & Rapor Formatları:**
|
||||
- Konsol tablosu (`console`), JSON (`json`), GitHub Actions (`github`), GitLab SAST (`gitlab`)
|
||||
- **OASIS SARIF 2.1.0 (`sarif`):** GitHub Code Scanning / Security Alerts ve VS Code SARIF uyumlu.
|
||||
- **JUnit XML (`junit`):** Jenkins, GitLab CI, CircleCI, Bitbucket Pipelines uyumlu.
|
||||
- **Çoklu Dışa Aktarım (`--report`):** HTML (`.html`), JSON (`.json`), SARIF (`.sarif`), JUnit (`.xml`), PDF (`.pdf`) ve Excel (`.xlsx`).
|
||||
- **🏆 PHPStan Level 9 & %100 Tip Güvenliği:** Kod tabanı PHPStan en yüksek seviye (Level 9) ve 230 birim testi ile tam doğrulanmıştır.
|
||||
|
||||
---
|
||||
|
||||
@@ -66,20 +78,35 @@ Canlı bir web sitesini crawler desteğiyle taramak için:
|
||||
bin/a11y scan https://siteniz.com --crawl
|
||||
```
|
||||
|
||||
### 3. Kural Seviyesi Belirleme (`--level`)
|
||||
1 (Temel) ile 5 (Çok Katı / Strict) arasında bir seviye belirleyin:
|
||||
### 3. CI/CD Kalite Kapısı Eşikleri (Quality Gates)
|
||||
Pipeline süreçlerinde derlemenin kırılma şartlarını belirleyin:
|
||||
```bash
|
||||
bin/a11y scan --level=5
|
||||
# Sağlık skoru 90'ın altındaysa derlemeyi başarısız yap:
|
||||
bin/a11y scan --min-score=90
|
||||
|
||||
# En fazla 3 uyarıya izin ver:
|
||||
bin/a11y scan --max-warnings=3
|
||||
|
||||
# Yalnızca kritik ve hata seviyesindeki ihlallerde kırıl (uyarıları göz ardı et):
|
||||
bin/a11y scan --fail-on=error
|
||||
```
|
||||
|
||||
### 4. Baseline (Teknik Borç Dondurma) Yönetimi
|
||||
Mevcut tüm ihlalleri `a11y-baseline.json` dosyasına kaydederek sonraki taramalarda yok sayılmasını sağlar:
|
||||
### 4. Gerçek Zamanlı İzleme Modu (`watch`)
|
||||
Geliştirme yaparken şablon dosyalarınızı izleyin ve dosya kaydedildiğinde anında otomatik re-scan çalıştırın:
|
||||
```bash
|
||||
bin/a11y scan --generate-baseline
|
||||
bin/a11y watch resources/views
|
||||
```
|
||||
|
||||
### 5. Format ve Raporlama Seçenekleri
|
||||
- **GitHub Actions formatında çıktı:**
|
||||
- **GitHub Code Scanning / SARIF 2.1.0 formatı:**
|
||||
```bash
|
||||
bin/a11y scan --format=sarif
|
||||
```
|
||||
- **JUnit XML formatı (Jenkins, GitLab CI):**
|
||||
```bash
|
||||
bin/a11y scan --format=junit
|
||||
```
|
||||
- **GitHub Actions bildirim formatı:**
|
||||
```bash
|
||||
bin/a11y scan --format=github
|
||||
```
|
||||
@@ -87,25 +114,55 @@ bin/a11y scan --generate-baseline
|
||||
```bash
|
||||
bin/a11y scan --format=json
|
||||
```
|
||||
- **İnteraktif HTML Dashboard Raporu:**
|
||||
- **Çoklu Dışa Aktarım Formatları (`--report`):**
|
||||
```bash
|
||||
bin/a11y scan --report=a11y-report.html
|
||||
bin/a11y scan --report=rapor.html # İnteraktif HTML Dashboard
|
||||
bin/a11y scan --report=rapor.pdf # PDF Sertifika ve Denetim Raporu
|
||||
bin/a11y scan --report=rapor.xlsx # Microsoft Excel Çalışma Kitabı
|
||||
bin/a11y scan --report=rapor.sarif # SARIF 2.1.0 Dosyası
|
||||
bin/a11y scan --report=rapor.xml # JUnit XML Raporu
|
||||
```
|
||||
|
||||
### 6. Yapay Zeka Destekli Otomatik Düzeltme (`fix`)
|
||||
Tespit edilen erişilebilirlik sorunlarını yapay zeka ile kaynak dosyalarınızda otomatik olarak onarın:
|
||||
### 6. Mevzuat Ön Ayarları (`--preset`) ve Rozet Üretimi (`--badge`)
|
||||
Mevzuat veya sektörel kurallara göre hedeflenmiş tarama yapın ve canlı SVG rozet oluşturun:
|
||||
```bash
|
||||
bin/a11y fix
|
||||
```
|
||||
Dosyaları değiştirmeden yalnızca yapılacak düzeltme önerilerini görmek için:
|
||||
```bash
|
||||
bin/a11y fix --dry-run
|
||||
# US Section 508 federal uyumluluk profiliyle tara:
|
||||
bin/a11y scan --preset=section508
|
||||
|
||||
# Avrupa Birliği EN 301 549 kamu web standardı profiliyle tara:
|
||||
bin/a11y scan --preset=en301549
|
||||
|
||||
# E-Ticaret ödeme ve sepet akışları profiliyle tara:
|
||||
bin/a11y scan --preset=ecommerce
|
||||
|
||||
# Tarama sonucunu SVG rozet olarak kaydet (README veya CI/CD artifact için):
|
||||
bin/a11y scan --badge=public/badges/a11y.svg
|
||||
```
|
||||
|
||||
### 7. Kuralları Listeleme (`rules`)
|
||||
Kütüphanede tanımlı 127 kuralı seviyeleri, WCAG standartları ve açıklamalarıyla listeler:
|
||||
### 7. Otomatik Düzeltme (`fix`)
|
||||
Yapay zeka veya çevrimdışı yerel motor ile kaynak dosyalarınızı onarın:
|
||||
```bash
|
||||
# Gemini AI desteğiyle otomatik onarım:
|
||||
bin/a11y fix
|
||||
|
||||
# Gemini API anahtarı olmadan tamamen çevrimdışı (offline) yerel motorla onarım:
|
||||
bin/a11y fix --offline
|
||||
|
||||
# Dosyaları değiştirmeden yapılacak düzeltmeleri önizleme:
|
||||
bin/a11y fix --offline --dry-run
|
||||
```
|
||||
|
||||
### 8. Kuralları Listeleme ve Filtreleme (`rules`)
|
||||
Kütüphanede tanımlı 130 kuralı listeleyin, arayın ve filtreleyin:
|
||||
```bash
|
||||
# Tüm kuralları listele:
|
||||
bin/a11y rules
|
||||
|
||||
# Kural adına veya açıklamasına göre ara:
|
||||
bin/a11y rules --search=contrast
|
||||
|
||||
# Seviye ve standarda göre filtrele:
|
||||
bin/a11y rules --standard=AA --level=3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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
@@ -12,7 +12,7 @@
|
||||
"yaknet"
|
||||
],
|
||||
"homepage": "https://forum.yak.net.tr",
|
||||
"version": "3.2.7",
|
||||
"version": "3.3.0",
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 = []
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Notifications;
|
||||
|
||||
interface NotifierInterface
|
||||
{
|
||||
public function send(NotificationPayload $payload): bool;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user