diff --git a/phpstan.neon b/phpstan.neon index 6e451fa..3e639cd 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,4 +1,4 @@ parameters: - level: 6 + level: 9 paths: - src diff --git a/src/Console/Command/FixCommand.php b/src/Console/Command/FixCommand.php index 0fc8c6f..52307e7 100644 --- a/src/Console/Command/FixCommand.php +++ b/src/Console/Command/FixCommand.php @@ -30,8 +30,16 @@ class FixCommand extends Command { $io = new SymfonyStyle($input, $output); $target = $input->getArgument('target'); - $projectPath = $input->getOption('project-path') ?? getcwd(); - $dryRun = $input->getOption('dry-run'); + if (!is_string($target)) { + $io->error("Target must be a string URL or file path."); + return Command::FAILURE; + } + $projectPathOption = $input->getOption('project-path'); + $projectPath = is_string($projectPathOption) ? $projectPathOption : getcwd(); + if ($projectPath === false) { + $projectPath = '.'; + } + $dryRun = (bool)$input->getOption('dry-run'); $config = new Config(); $apiKey = $config->getGeminiApiKey(); @@ -45,7 +53,7 @@ class FixCommand extends Command // 1. Fetch HTML $html = @file_get_contents($target); - if (!$html) { + if ($html === false || $html === '') { $io->error("Could not read target: $target"); return Command::FAILURE; } diff --git a/src/Console/Command/ScanCommand.php b/src/Console/Command/ScanCommand.php index 30b6408..d4f58c9 100644 --- a/src/Console/Command/ScanCommand.php +++ b/src/Console/Command/ScanCommand.php @@ -33,14 +33,22 @@ class ScanCommand extends Command { $io = new SymfonyStyle($input, $output); $target = $input->getArgument('target'); - $enableAi = $input->getOption('ai'); - $projectPath = $input->getOption('project-path') ?? getcwd(); + if (!is_string($target)) { + $io->error("Target must be a string URL or file path."); + return Command::FAILURE; + } + $enableAi = (bool)$input->getOption('ai'); + $projectPathOption = $input->getOption('project-path'); + $projectPath = is_string($projectPathOption) ? $projectPathOption : getcwd(); + if ($projectPath === false) { + $projectPath = '.'; + } $io->title("Accessibility Console - Scanning: $target"); // 1. Fetch HTML $html = @file_get_contents($target); - if (!$html) { + if ($html === false || $html === '') { $io->error("Could not read target: $target"); return Command::FAILURE; } @@ -97,7 +105,7 @@ class ScanCommand extends Command // Export Report $reportPath = $input->getOption('report'); - if ($reportPath) { + if (is_string($reportPath) && $reportPath !== '') { $extension = pathinfo($reportPath, PATHINFO_EXTENSION); if ($extension === 'json') { $data = array_map(fn($v) => $v->toArray(), $violations); diff --git a/src/Core/AbstractRule.php b/src/Core/AbstractRule.php index 3c3e76a..33f9b13 100644 --- a/src/Core/AbstractRule.php +++ b/src/Core/AbstractRule.php @@ -21,12 +21,21 @@ abstract class AbstractRule protected function createViolation(string $message, \DOMElement $element): Violation { + $doc = $element->ownerDocument; + $html = ''; + if ($doc !== null) { + $htmlVal = $doc->saveHTML($element); + if (is_string($htmlVal)) { + $html = $htmlVal; + } + } + return new Violation( ruleId: (new \ReflectionClass($this))->getShortName(), message: $message, severity: $this->getSeverity(), standard: $this->getStandard(), - htmlSnippet: $element->ownerDocument->saveHTML($element) + htmlSnippet: $html ); } } diff --git a/src/Core/RuleEngine.php b/src/Core/RuleEngine.php index f2dc10a..38eadc3 100644 --- a/src/Core/RuleEngine.php +++ b/src/Core/RuleEngine.php @@ -19,25 +19,21 @@ class RuleEngine { $allViolations = []; foreach ($this->rules as $rule) { - if (method_exists($rule, 'check')) { - // Determine if it's a document-level rule or element-level rule - $reflection = new \ReflectionMethod($rule, 'check'); - $params = $reflection->getParameters(); - - $type = $params[0]->getType(); - if (isset($params[0]) && $type instanceof \ReflectionNamedType && $type->getName() === 'DOMDocument') { - // Document-level rule (Core\AbstractRule style) - $violations = $rule->check($doc); - $allViolations = array_merge($allViolations, $violations); - } else { - // Element-level rule (Rules\RuleInterface style) - // We need to iterate over all elements - $xpath = new \DOMXPath($doc); - $elements = $xpath->query('//*'); + if ($rule instanceof AbstractRule) { + // Document-level rule (Core\AbstractRule style) + $violations = $rule->check($doc); + $allViolations = array_merge($allViolations, $violations); + } elseif ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface) { + // Element-level rule (Rules\RuleInterface style) + $xpath = new \DOMXPath($doc); + $elements = $xpath->query('//*'); + if ($elements !== false) { foreach ($elements as $element) { - $violation = $rule->check($element); - if ($violation) { - $allViolations[] = $violation; + if ($element instanceof \DOMElement) { + $violation = $rule->check($element); + if ($violation) { + $allViolations[] = $violation; + } } } } diff --git a/src/Core/Scanner.php b/src/Core/Scanner.php index 405ccc5..5ef4b17 100644 --- a/src/Core/Scanner.php +++ b/src/Core/Scanner.php @@ -4,11 +4,11 @@ namespace YakNet\AccessibilityConsole\Core; class Scanner { - public function __construct(private ?RuleEngine $engine = null) + private RuleEngine $engine; + + public function __construct(?RuleEngine $engine = null) { - if ($this->engine === null) { - $this->engine = new RuleEngine(); - } + $this->engine = $engine ?? new RuleEngine(); } public function addRule(mixed $rule): void diff --git a/src/Rules/AbstractRule.php b/src/Rules/AbstractRule.php index 571cf7b..552b44a 100644 --- a/src/Rules/AbstractRule.php +++ b/src/Rules/AbstractRule.php @@ -16,12 +16,21 @@ abstract class AbstractRule implements RuleInterface protected function createViolation(DOMElement $element, string $message, ?string $suggestion = null): Violation { + $doc = $element->ownerDocument; + $html = ''; + if ($doc !== null) { + $htmlVal = $doc->saveHTML($element); + if (is_string($htmlVal)) { + $html = $htmlVal; + } + } + return new Violation( ruleId: $this->getId(), message: $message, severity: $this->getSeverity(), standard: $this->getStandard(), - htmlSnippet: $element->ownerDocument->saveHTML($element), + htmlSnippet: $html, fixSuggestion: $suggestion ); } diff --git a/src/Rules/AriaRole.php b/src/Rules/AriaRole.php index 5c7134b..0b5d799 100644 --- a/src/Rules/AriaRole.php +++ b/src/Rules/AriaRole.php @@ -28,43 +28,47 @@ class AriaRole extends AbstractRule // 1. Check WAI-ARIA Role Validity $roleElements = $xpath->query('//*[@role]'); - foreach ($roleElements as $el) { - if (!$el instanceof \DOMElement) { - continue; - } - $role = trim($el->getAttribute('role')); - if ($role === '') { - continue; - } - $roles = array_filter(explode(' ', $role)); - foreach ($roles as $r) { - if (!in_array(strtolower($r), self::$validRoles, true)) { - $violations[] = $this->createViolation( - "Invalid ARIA role \"{$r}\" declared on element. Must use standard WAI-ARIA roles.", - $el - ); + if ($roleElements !== false) { + foreach ($roleElements as $el) { + if (!$el instanceof \DOMElement) { + continue; + } + $role = trim($el->getAttribute('role')); + if ($role === '') { + continue; + } + $roles = array_filter(explode(' ', $role)); + foreach ($roles as $r) { + if (!in_array(strtolower($r), self::$validRoles, true)) { + $violations[] = $this->createViolation( + "Invalid ARIA role \"{$r}\" declared on element. Must use standard WAI-ARIA roles.", + $el + ); + } } } } // 2. Check aria-controls Targets Existence $controlsElements = $xpath->query('//*[@aria-controls]'); - foreach ($controlsElements as $el) { - if (!$el instanceof \DOMElement) { - continue; - } - $targetsStr = trim($el->getAttribute('aria-controls')); - if ($targetsStr === '') { - continue; - } - $targets = array_filter(explode(' ', $targetsStr)); - foreach ($targets as $targetId) { - $targetQuery = $xpath->query('//*[@id="' . $targetId . '"]'); - if ($targetQuery->length === 0) { - $violations[] = $this->createViolation( - "Attribute aria-controls=\"{$targetId}\" targets an element that does not exist in the document.", - $el - ); + if ($controlsElements !== false) { + foreach ($controlsElements as $el) { + if (!$el instanceof \DOMElement) { + continue; + } + $targetsStr = trim($el->getAttribute('aria-controls')); + if ($targetsStr === '') { + continue; + } + $targets = array_filter(explode(' ', $targetsStr)); + foreach ($targets as $targetId) { + $targetQuery = $xpath->query('//*[@id="' . $targetId . '"]'); + if ($targetQuery !== false && $targetQuery->length === 0) { + $violations[] = $this->createViolation( + "Attribute aria-controls=\"{$targetId}\" targets an element that does not exist in the document.", + $el + ); + } } } } diff --git a/src/Rules/ColorContrast.php b/src/Rules/ColorContrast.php index e1f227b..1ef844b 100644 --- a/src/Rules/ColorContrast.php +++ b/src/Rules/ColorContrast.php @@ -99,14 +99,14 @@ class ColorContrast extends AbstractRule { $hex = ltrim($hex, '#'); if (strlen($hex) === 3) { - $r = hexdec(substr($hex, 0, 1) . substr($hex, 0, 1)); - $g = hexdec(substr($hex, 1, 1) . substr($hex, 1, 1)); - $b = hexdec(substr($hex, 2, 1) . substr($hex, 2, 1)); + $r = (int)hexdec(substr($hex, 0, 1) . substr($hex, 0, 1)); + $g = (int)hexdec(substr($hex, 1, 1) . substr($hex, 1, 1)); + $b = (int)hexdec(substr($hex, 2, 1) . substr($hex, 2, 1)); return [$r, $g, $b]; } elseif (strlen($hex) === 6) { - $r = hexdec(substr($hex, 0, 2)); - $g = hexdec(substr($hex, 2, 2)); - $b = hexdec(substr($hex, 4, 2)); + $r = (int)hexdec(substr($hex, 0, 2)); + $g = (int)hexdec(substr($hex, 2, 2)); + $b = (int)hexdec(substr($hex, 4, 2)); return [$r, $g, $b]; } return null; diff --git a/src/Rules/DuplicateId.php b/src/Rules/DuplicateId.php index 6c834f3..5e5bbb2 100644 --- a/src/Rules/DuplicateId.php +++ b/src/Rules/DuplicateId.php @@ -13,6 +13,9 @@ class DuplicateId extends AbstractRule $violations = []; $xpath = new \DOMXPath($doc); $elements = $xpath->query('//*[@id]'); + if ($elements === false) { + return []; + } $ids = []; foreach ($elements as $el) { diff --git a/src/Rules/FormLabel.php b/src/Rules/FormLabel.php index 176967f..53968d2 100644 --- a/src/Rules/FormLabel.php +++ b/src/Rules/FormLabel.php @@ -29,10 +29,13 @@ class FormLabel extends AbstractRule if ($element->hasAttribute('id')) { $id = $element->getAttribute('id'); - $xpath = new \DOMXPath($element->ownerDocument); - $labels = $xpath->query("//label[@for='$id']"); - if ($labels->length > 0) { - return null; + $doc = $element->ownerDocument; + if ($doc !== null) { + $xpath = new \DOMXPath($doc); + $labels = $xpath->query("//label[@for='$id']"); + if ($labels !== false && $labels->length > 0) { + return null; + } } } diff --git a/src/Rules/HeadingOrderRule.php b/src/Rules/HeadingOrderRule.php index 0e7de0d..2406ca2 100644 --- a/src/Rules/HeadingOrderRule.php +++ b/src/Rules/HeadingOrderRule.php @@ -13,6 +13,9 @@ class HeadingOrderRule extends AbstractRule $violations = []; $xpath = new \DOMXPath($doc); $headings = $xpath->query('//h1|//h2|//h3|//h4|//h5|//h6'); + if ($headings === false) { + return []; + } $lastLevel = 0; foreach ($headings as $h) { diff --git a/src/Rules/StandardRuleSet.php b/src/Rules/StandardRuleSet.php index f663c68..fb27976 100644 --- a/src/Rules/StandardRuleSet.php +++ b/src/Rules/StandardRuleSet.php @@ -9,7 +9,8 @@ class StandardRuleSet */ public static function all(): array { - return [ + /** @var RuleInterface[] $rules */ + $rules = [ new HtmlHasLang(), new ImgAltText(), new EmptyLink(), @@ -24,5 +25,6 @@ class StandardRuleSet new AriaRole(), new IframeTitle(), ]; + return $rules; } } diff --git a/src/Source/SourceLocator.php b/src/Source/SourceLocator.php index 11c01a3..3493136 100644 --- a/src/Source/SourceLocator.php +++ b/src/Source/SourceLocator.php @@ -24,7 +24,8 @@ class SourceLocator return null; } - $cleanSnippet = trim(preg_replace('/\s+/', ' ', $snippet)); + $replaced = preg_replace('/\s+/', ' ', $snippet); + $cleanSnippet = is_string($replaced) ? trim($replaced) : ''; if ($cleanSnippet === '') { return null; } @@ -81,7 +82,7 @@ class SourceLocator } /** - * @return array{type: string, score?: float, location?: array{file: string, line: int}} + * @return array{type: 'exact', location: array{file: string, line: int}}|array{type: 'fuzzy', score: float, location: array{file: string, line: int}}|array{type: 'none'} */ private function searchInFile(string $filepath, string $searchSnippet, string $fullSnippet): array { @@ -98,7 +99,8 @@ class SourceLocator foreach ($lines as $index => $line) { $lineNumber = $index + 1; - $cleanLine = trim(preg_replace('/\s+/', ' ', $line)); + $replacedLine = preg_replace('/\s+/', ' ', $line); + $cleanLine = is_string($replacedLine) ? trim($replacedLine) : ''; if (str_contains($cleanLine, $searchSnippet)) { return [