feat: Release v3.0.0 - PHPStan-style analysis, configuration, baseline, and advanced WCAG rules

This commit is contained in:
Enes Yakıştır
2026-06-23 01:02:15 +03:00
parent 84c6521195
commit d5165f0be4
29 changed files with 2192 additions and 167 deletions
+288
View File
@@ -0,0 +1,288 @@
<?php
namespace YakNet\AccessibilityConsole\Core;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
use YakNet\AccessibilityConsole\Rules\RuleLevels;
class Analyser
{
private Config $config;
private Scanner $scanner;
private BaselineManager $baselineManager;
private string $projectRoot;
public function __construct(?Config $config = null, ?Scanner $scanner = null, ?BaselineManager $baselineManager = null)
{
$this->config = $config ?? new Config();
$this->scanner = $scanner ?? $this->setupScanner();
$this->baselineManager = $baselineManager ?? new BaselineManager();
$cwd = getcwd();
$this->projectRoot = $cwd ? str_replace('\\', '/', realpath($cwd) ?: $cwd) : '.';
$baselinePath = $this->config->getBaselinePath();
if ($baselinePath) {
$this->baselineManager->load($this->projectRoot . '/' . $baselinePath);
}
}
private function setupScanner(): Scanner
{
$scanner = new Scanner();
$rules = RuleLevels::getRulesForLevel($this->config->getLevel());
$rulesConfig = $this->config->getRulesConfig();
$activeRules = [];
foreach ($rules as $rule) {
$ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface)
? $rule->getId()
: (new \ReflectionClass($rule))->getShortName();
if (in_array($ruleId, $rulesConfig['exclude'], true)) {
continue;
}
$activeRules[$ruleId] = $rule;
}
// Include custom rules
foreach ($rulesConfig['include'] as $customRuleClass) {
if (class_exists($customRuleClass)) {
try {
$reflection = new \ReflectionClass($customRuleClass);
if ($reflection->isInstantiable()) {
$rule = $reflection->newInstance();
$ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface)
? $rule->getId()
: $reflection->getShortName();
$activeRules[$ruleId] = $rule;
}
} catch (\Throwable $e) {
// Ignore instantiation failures
}
}
}
foreach ($activeRules as $rule) {
$scanner->addRule($rule);
}
return $scanner;
}
/**
* Analyse files and return a list of violations.
*
* @param array<int, string>|null $pathsOverride
* @param callable|null $onProgress Function called for progress tracking: fn(string $file, int $index, int $total)
* @return array{violations: Violation[], baselinedCount: int}
*/
public function analyse(?array $pathsOverride = null, ?callable $onProgress = null): array
{
$paths = $pathsOverride ?? $this->config->getPaths();
$excludePaths = $this->config->getExcludePaths();
// Resolve all files to scan
$filesToScan = [];
foreach ($paths as $path) {
if (is_file($path)) {
$filesToScan[] = str_replace('\\', '/', realpath($path) ?: $path);
} elseif (is_dir($path)) {
$files = $this->findFiles($path, $excludePaths);
$filesToScan = array_merge($filesToScan, $files);
}
}
$filesToScan = array_unique($filesToScan);
$totalFiles = count($filesToScan);
$violations = [];
$baselinedCount = 0;
foreach ($filesToScan as $index => $file) {
if ($onProgress) {
$onProgress($file, $index + 1, $totalFiles);
}
$content = @file_get_contents($file);
if ($content === false || trim($content) === '') {
continue;
}
// Scan file content
try {
$fileViolations = $this->scanner->scan($content);
} catch (\Throwable $e) {
// Skip if parsing failed dramatically
continue;
}
$relativeFile = $this->makePathRelative($file);
foreach ($fileViolations as $violation) {
// Find line number using line-by-line helper
$line = $this->locateLineInContent($content, $violation->htmlSnippet);
// Store location in violation
$violation->location = [
'file' => $relativeFile,
'line' => $line
];
// Check if ignored in baseline
if ($this->baselineManager->isIgnored($relativeFile, $violation->ruleId, $violation->htmlSnippet)) {
$baselinedCount++;
} else {
$violations[] = $violation;
}
}
}
return [
'violations' => $violations,
'baselinedCount' => $baselinedCount
];
}
public function getBaselineManager(): BaselineManager
{
return $this->baselineManager;
}
public function makePathRelative(string $absolutePath): string
{
$absolutePath = str_replace('\\', '/', realpath($absolutePath) ?: $absolutePath);
if ($this->projectRoot !== '.' && str_starts_with($absolutePath, $this->projectRoot)) {
$relative = ltrim(substr($absolutePath, strlen($this->projectRoot)), '/');
return $relative === '' ? '.' : $relative;
}
return $absolutePath;
}
/**
* @param array<int, string> $excludePaths
* @return array<int, string>
*/
private function findFiles(string $dir, array $excludePaths): array
{
$realDir = realpath($dir);
if ($realDir === false) {
return [];
}
$files = [];
try {
$directory = new RecursiveDirectoryIterator($realDir, \FilesystemIterator::SKIP_DOTS);
$filter = new \RecursiveCallbackFilterIterator($directory, function ($current) use ($excludePaths) {
/** @var SplFileInfo $current */
$filename = $current->getFilename();
$pathname = str_replace('\\', '/', $current->getPathname());
// Exclude common directories/files
foreach ($excludePaths as $exclude) {
$normalizedExclude = str_replace('\\', '/', $exclude);
if ($filename === $normalizedExclude || str_contains($pathname, '/' . $normalizedExclude . '/')) {
return false;
}
}
return true;
});
$iterator = new RecursiveIteratorIterator($filter, RecursiveIteratorIterator::SELF_FIRST);
$extensions = ['php', 'html', 'twig', 'blade.php'];
foreach ($iterator as $file) {
/** @var SplFileInfo $file */
if ($file->isDir()) {
continue;
}
$filename = $file->getFilename();
$isValidExt = false;
foreach ($extensions as $ext) {
if (str_ends_with($filename, '.' . $ext) || ($ext === 'blade.php' && str_ends_with($filename, '.blade.php'))) {
$isValidExt = true;
break;
}
}
if ($isValidExt) {
$files[] = str_replace('\\', '/', $file->getPathname());
}
}
} catch (\Throwable $e) {
// Ignore directory iteration errors
}
return $files;
}
private function locateLineInContent(string $content, string $snippet): int
{
$replaced = preg_replace('/\s+/', ' ', $snippet);
$cleanSnippet = is_string($replaced) ? trim($replaced) : '';
if ($cleanSnippet === '') {
return 1;
}
$searchSnippet = mb_strlen($cleanSnippet) > 150 ? mb_substr($cleanSnippet, 0, 150) : $cleanSnippet;
$lines = explode("\n", $content);
// 1. Exact match pass
foreach ($lines as $index => $line) {
$replacedLine = preg_replace('/\s+/', ' ', $line);
$cleanLine = is_string($replacedLine) ? trim($replacedLine) : '';
if (str_contains($cleanLine, $searchSnippet)) {
return $index + 1;
}
}
// 2. Parse snippet to find tag and attributes for fuzzy match pass
preg_match('/<([a-zA-Z0-9]+)/', $snippet, $matches);
$tagName = $matches[1] ?? null;
if (!$tagName) {
return 1;
}
preg_match_all('/([a-zA-Z0-9-]+)=["\']([^"\']*)["\']/', $snippet, $attrMatches, PREG_SET_ORDER);
$attributes = [];
foreach ($attrMatches as $match) {
$attributes[$match[1]] = $match[2];
}
$bestLine = 1;
$maxScore = 0;
foreach ($lines as $index => $line) {
$replacedLine = preg_replace('/\s+/', ' ', $line);
$cleanLine = is_string($replacedLine) ? trim($replacedLine) : '';
if (!str_contains(strtolower($cleanLine), '<' . strtolower($tagName))) {
continue;
}
$score = 1.0;
if (in_array(strtolower($tagName), ['html', 'head', 'body', 'title'], true)) {
$score += 1.0;
}
foreach ($attributes as $name => $value) {
if (str_contains($cleanLine, "$name=\"$value\"") || str_contains($cleanLine, "$name='$value'")) {
$score += 2.0;
} elseif (str_contains($cleanLine, $name . '=')) {
$score += 0.5;
}
}
if ($score > $maxScore) {
$maxScore = $score;
$bestLine = $index + 1;
}
}
return $bestLine;
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace YakNet\AccessibilityConsole\Core;
class BaselineManager
{
/** @var array<string, bool> */
private array $ignoredKeys = [];
public function load(string $baselinePath): bool
{
if (!file_exists($baselinePath) || !is_readable($baselinePath)) {
return false;
}
$content = file_get_contents($baselinePath);
if ($content === false) {
return false;
}
$data = json_decode($content, true);
if (!is_array($data) || !isset($data['violations']) || !is_array($data['violations'])) {
return false;
}
foreach ($data['violations'] as $item) {
if (isset($item['file'], $item['ruleId'], $item['htmlSnippet'])) {
$key = $this->calculateKey($item['file'], $item['ruleId'], $item['htmlSnippet']);
$this->ignoredKeys[$key] = true;
}
}
return true;
}
/**
* @param Violation[] $violations
*/
public function generate(array $violations, string $baselinePath): bool
{
$serialized = [
'generator' => 'YakNet Accessibility Console',
'violations' => []
];
foreach ($violations as $v) {
$file = $v->location['file'] ?? '';
$serialized['violations'][] = [
'file' => $file,
'ruleId' => $v->ruleId,
'htmlSnippet' => $v->htmlSnippet
];
}
// Sort baseline violations by file and ruleId for cleaner git diffs
usort($serialized['violations'], function ($a, $b) {
$fileCmp = strcmp($a['file'], $b['file']);
if ($fileCmp !== 0) {
return $fileCmp;
}
return strcmp($a['ruleId'], $b['ruleId']);
});
$json = json_encode($serialized, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false) {
return false;
}
$dir = dirname($baselinePath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
return file_put_contents($baselinePath, $json) !== false;
}
public function isIgnored(string $file, string $ruleId, string $htmlSnippet): bool
{
$key = $this->calculateKey($file, $ruleId, $htmlSnippet);
return isset($this->ignoredKeys[$key]);
}
private function calculateKey(string $file, string $ruleId, string $htmlSnippet): string
{
// Normalize whitespaces and trim to prevent minor formatting updates from breaking baseline
$normalizedSnippet = preg_replace('/\s+/', ' ', trim($htmlSnippet));
if ($normalizedSnippet === null) {
$normalizedSnippet = trim($htmlSnippet);
}
// Normalize file separator to forward slashes for cross-platform compatibility
$normalizedFile = str_replace('\\', '/', $file);
return md5($normalizedFile . '::' . $ruleId . '::' . $normalizedSnippet);
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace YakNet\AccessibilityConsole\Core;
use HeadlessChromium\BrowserFactory;
use HeadlessChromium\Navigation;
class ChromeRenderer
{
/**
* Navigate to URL using headless Chrome and return fully rendered HTML.
*/
public function render(string $url): string
{
$browserFactory = new BrowserFactory();
$options = [
'windowSize' => [1280, 1024],
'noSandbox' => true,
];
// Windows Chrome binary path auto-detection override
$chromeBinary = $_ENV['CHROME_BINARY'] ?? getenv('CHROME_BINARY') ?: null;
if (!$chromeBinary && PHP_OS_FAMILY === 'Windows') {
$standardPaths = [
'C:/Program Files/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
'C:/Program Files/Google/Chrome/Application/chrome.exe'
];
foreach ($standardPaths as $path) {
if (file_exists($path)) {
$chromeBinary = $path;
break;
}
}
}
if ($chromeBinary) {
$options['chromeBinary'] = $chromeBinary;
}
$browser = $browserFactory->createBrowser($options);
try {
$page = $browser->createPage();
// Navigate and wait for onload event
$navigation = $page->navigate($url);
$navigation->waitForNavigation(\HeadlessChromium\Page::LOAD, 10000); // 10 seconds timeout
// Extra wait for async JS rendering to complete
usleep(1500000); // 1.5s
$html = $page->getHtml();
} finally {
$browser->close();
}
return $html;
}
}
+96 -1
View File
@@ -13,11 +13,34 @@ class Config
public function __construct(array $settings = [])
{
$this->settings = $settings;
$this->loadDotenv();
}
private function loadDotenv(): void
{
$cwd = getcwd();
if ($cwd && file_exists($cwd . '/.env')) {
try {
$dotenv = \Dotenv\Dotenv::createImmutable($cwd);
$dotenv->safeLoad();
} catch (\Throwable $e) {
// Ignore Dotenv errors silently
}
}
}
public static function fromYaml(string $path): self
{
// Placeholder for YAML loading logic
if (file_exists($path)) {
try {
$content = \Symfony\Component\Yaml\Yaml::parseFile($path);
if (is_array($content)) {
return new self($content);
}
} catch (\Throwable $e) {
// Ignore parsing errors
}
}
return new self([]);
}
@@ -26,6 +49,78 @@ class Config
return $this->settings[$key] ?? $default;
}
/**
* @return array<int, string>
*/
public function getPaths(): array
{
$paths = $this->get('paths');
if (is_array($paths)) {
return $paths;
}
if (is_string($paths)) {
return [$paths];
}
return ['.']; // default to current directory
}
/**
* @return array<int, string>
*/
public function getExcludePaths(): array
{
$exclude = $this->get('exclude_paths');
if (is_array($exclude)) {
return $exclude;
}
if (is_string($exclude)) {
return [$exclude];
}
return ['vendor', 'node_modules', 'tests', 'storage', '.git']; // sensible defaults
}
public function getLevel(): int
{
$level = $this->get('level', 4);
return is_numeric($level) ? (int)$level : 4;
}
public function getBaselinePath(): ?string
{
$baseline = $this->get('baseline');
return is_string($baseline) ? $baseline : null;
}
public function getFormat(): string
{
$format = $this->get('format', 'console');
return is_string($format) ? $format : 'console';
}
/**
* @return array{include: array<int, string>, exclude: array<int, string>}
*/
public function getRulesConfig(): array
{
$rules = $this->get('rules', []);
$include = [];
$exclude = [];
if (is_array($rules)) {
if (isset($rules['include']) && is_array($rules['include'])) {
$include = $rules['include'];
}
if (isset($rules['exclude']) && is_array($rules['exclude'])) {
$exclude = $rules['exclude'];
}
}
return [
'include' => $include,
'exclude' => $exclude
];
}
public function getGeminiApiKey(): ?string
{
return $this->get('gemini_api_key') ?? $_ENV['GEMINI_API_KEY'] ?? getenv('GEMINI_API_KEY') ?: null;