From 164828f9f72ab5566fb2f476dd59ba796dc1193d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enes=20Yak=C4=B1=C5=9Ft=C4=B1r?= Date: Wed, 29 Jul 2026 06:31:35 +0300 Subject: [PATCH] fix: resolve PHPStan level 9 errors and bump version to v3.1.3 --- composer.json | 4 +- phpstan.neon | 3 + src/AI/Providers/ClaudeProvider.php | 6 +- src/AI/Providers/OpenAiProvider.php | 7 +- src/Console/Command/ScanCommand.php | 3 - src/Core/BaselineManager.php | 7 +- src/Core/Config.php | 12 +-- src/Rules/AreaAlt.php | 37 +++++++++ src/Rules/AriaHiddenFocusable.php | 12 +++ src/Rules/AriaRequiredAttr.php | 63 +++++++++++++++ src/Rules/AriaRequiredChildren.php | 85 +++++++++++++++++++++ src/Rules/AriaRequiredParent.php | 101 +++++++++++++++++++++++++ src/Rules/AriaValidAttr.php | 59 +++++++++++++++ src/Rules/AriaValidAttrValue.php | 78 +++++++++++++++++++ src/Rules/AudioTrack.php | 42 ++++++++++ src/Rules/AutocompleteValidTokens.php | 90 ++++++++++++++++++++++ src/Rules/ButtonName.php | 59 ++++++++++++++- src/Rules/CanvasAlt.php | 51 +++++++++++++ src/Rules/ClickHandlerKeyboard.php | 45 +++++++++++ src/Rules/ColorContrast.php | 53 ++++++++++++- src/Rules/DefinitionListStructure.php | 44 +++++++++++ src/Rules/DialogAccessibility.php | 41 ++++++++++ src/Rules/EmbedAccessibility.php | 38 ++++++++++ src/Rules/EmptyLink.php | 62 +++++++++++++-- src/Rules/FocusTrapping.php | 39 ++++++++++ src/Rules/FormLabel.php | 4 +- src/Rules/FormRequiredIndicator.php | 39 ++++++++++ src/Rules/ImageAltPlaceholder.php | 6 +- src/Rules/ImageAltRedundant.php | 4 +- src/Rules/InputImageAlt.php | 43 +++++++++++ src/Rules/InteractiveRoleTabindex.php | 50 ++++++++++++ src/Rules/LandmarkBanner.php | 61 +++++++++++++++ src/Rules/LandmarkContentInfo.php | 61 +++++++++++++++ src/Rules/LandmarkMain.php | 48 ++++++++++++ src/Rules/LandmarkNoDuplicate.php | 80 ++++++++++++++++++++ src/Rules/LangMismatch.php | 62 +++++++++++++++ src/Rules/LanguageSubtag.php | 2 +- src/Rules/LinkTextGeneric.php | 3 +- src/Rules/NestedInteractive.php | 43 +++++++++++ src/Rules/ProgressbarAccessibility.php | 53 +++++++++++++ src/Rules/SelectHasAccessibleName.php | 70 +++++++++++++++++ src/Rules/SkipNavigation.php | 59 +++++++++++++++ src/Rules/TableFakeHeading.php | 60 +++++++++++++++ src/Rules/ThHasScope.php | 58 ++++++++++++++ src/Rules/TitleEmpty.php | 67 ++++++++++++++++ src/Rules/VisuallyHiddenFocusable.php | 80 ++++++++++++++++++++ 46 files changed, 1960 insertions(+), 34 deletions(-) create mode 100644 src/Rules/AreaAlt.php create mode 100644 src/Rules/AriaRequiredAttr.php create mode 100644 src/Rules/AriaRequiredChildren.php create mode 100644 src/Rules/AriaRequiredParent.php create mode 100644 src/Rules/AriaValidAttr.php create mode 100644 src/Rules/AriaValidAttrValue.php create mode 100644 src/Rules/AudioTrack.php create mode 100644 src/Rules/AutocompleteValidTokens.php create mode 100644 src/Rules/CanvasAlt.php create mode 100644 src/Rules/ClickHandlerKeyboard.php create mode 100644 src/Rules/DefinitionListStructure.php create mode 100644 src/Rules/DialogAccessibility.php create mode 100644 src/Rules/EmbedAccessibility.php create mode 100644 src/Rules/FocusTrapping.php create mode 100644 src/Rules/FormRequiredIndicator.php create mode 100644 src/Rules/InputImageAlt.php create mode 100644 src/Rules/InteractiveRoleTabindex.php create mode 100644 src/Rules/LandmarkBanner.php create mode 100644 src/Rules/LandmarkContentInfo.php create mode 100644 src/Rules/LandmarkMain.php create mode 100644 src/Rules/LandmarkNoDuplicate.php create mode 100644 src/Rules/LangMismatch.php create mode 100644 src/Rules/NestedInteractive.php create mode 100644 src/Rules/ProgressbarAccessibility.php create mode 100644 src/Rules/SelectHasAccessibleName.php create mode 100644 src/Rules/SkipNavigation.php create mode 100644 src/Rules/TableFakeHeading.php create mode 100644 src/Rules/ThHasScope.php create mode 100644 src/Rules/TitleEmpty.php create mode 100644 src/Rules/VisuallyHiddenFocusable.php diff --git a/composer.json b/composer.json index f2b1816..500f7eb 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ "yaknet" ], "homepage": "https://forum.yak.net.tr", - "version": "3.1.2", + "version": "3.1.3", "type": "library", "license": "MIT", "authors": [ @@ -43,7 +43,7 @@ }, "require-dev": { "phpunit/phpunit": "^10.5", - "phpstan/phpstan": "^1.10" + "phpstan/phpstan": "^2.2" }, "autoload": { "files": [ diff --git a/phpstan.neon b/phpstan.neon index 3e639cd..3a65e7a 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,3 +2,6 @@ parameters: level: 9 paths: - src + bootstrapFiles: + - vendor/autoload.php + diff --git a/src/AI/Providers/ClaudeProvider.php b/src/AI/Providers/ClaudeProvider.php index b9a15e3..9c5fbeb 100644 --- a/src/AI/Providers/ClaudeProvider.php +++ b/src/AI/Providers/ClaudeProvider.php @@ -51,7 +51,11 @@ class ClaudeProvider implements AiProviderInterface ]); $data = json_decode($response->getBody()->getContents(), true); - if (is_array($data) && isset($data['content'][0]['text']) && is_string($data['content'][0]['text'])) { + if (is_array($data) + && isset($data['content']) && is_array($data['content']) + && isset($data['content'][0]) && is_array($data['content'][0]) + && isset($data['content'][0]['text']) && is_string($data['content'][0]['text']) + ) { return $data['content'][0]['text']; } return null; diff --git a/src/AI/Providers/OpenAiProvider.php b/src/AI/Providers/OpenAiProvider.php index ecb749b..de6fa94 100644 --- a/src/AI/Providers/OpenAiProvider.php +++ b/src/AI/Providers/OpenAiProvider.php @@ -50,7 +50,12 @@ class OpenAiProvider implements AiProviderInterface ]); $data = json_decode($response->getBody()->getContents(), true); - if (is_array($data) && isset($data['choices'][0]['message']['content']) && is_string($data['choices'][0]['message']['content'])) { + if (is_array($data) + && isset($data['choices']) && is_array($data['choices']) + && isset($data['choices'][0]) && is_array($data['choices'][0]) + && isset($data['choices'][0]['message']) && is_array($data['choices'][0]['message']) + && isset($data['choices'][0]['message']['content']) && is_string($data['choices'][0]['message']['content']) + ) { return $data['choices'][0]['message']['content']; } return null; diff --git a/src/Console/Command/ScanCommand.php b/src/Console/Command/ScanCommand.php index 2c9e2d9..4eaba53 100644 --- a/src/Console/Command/ScanCommand.php +++ b/src/Console/Command/ScanCommand.php @@ -302,9 +302,6 @@ class ScanCommand extends Command while (!empty($queue) && count($visited) < $maxPages) { $current = array_shift($queue); - if (!is_array($current)) { - continue; - } $url = $current['url']; $depth = (int)$current['depth']; diff --git a/src/Core/BaselineManager.php b/src/Core/BaselineManager.php index 7bc0a25..a2d7b08 100644 --- a/src/Core/BaselineManager.php +++ b/src/Core/BaselineManager.php @@ -24,7 +24,12 @@ class BaselineManager } foreach ($data['violations'] as $item) { - if (isset($item['file'], $item['ruleId'], $item['htmlSnippet'])) { + if (!is_array($item)) { + continue; + } + if (isset($item['file'], $item['ruleId'], $item['htmlSnippet']) + && is_string($item['file']) && is_string($item['ruleId']) && is_string($item['htmlSnippet']) + ) { $key = $this->calculateKey($item['file'], $item['ruleId'], $item['htmlSnippet']); $this->ignoredKeys[$key] = true; } diff --git a/src/Core/Config.php b/src/Core/Config.php index d3081c0..ffa6236 100644 --- a/src/Core/Config.php +++ b/src/Core/Config.php @@ -35,6 +35,7 @@ class Config try { $content = \Symfony\Component\Yaml\Yaml::parseFile($path); if (is_array($content)) { + /** @var array $content */ return new self($content); } } catch (\Throwable $e) { @@ -56,7 +57,7 @@ class Config { $paths = $this->get('paths'); if (is_array($paths)) { - return $paths; + return array_values(array_filter($paths, 'is_string')); } if (is_string($paths)) { return [$paths]; @@ -71,7 +72,7 @@ class Config { $exclude = $this->get('exclude_paths'); if (is_array($exclude)) { - return $exclude; + return array_values(array_filter($exclude, 'is_string')); } if (is_string($exclude)) { return [$exclude]; @@ -108,10 +109,10 @@ class Config if (is_array($rules)) { if (isset($rules['include']) && is_array($rules['include'])) { - $include = $rules['include']; + $include = array_values(array_filter($rules['include'], 'is_string')); } if (isset($rules['exclude']) && is_array($rules['exclude'])) { - $exclude = $rules['exclude']; + $exclude = array_values(array_filter($rules['exclude'], 'is_string')); } } @@ -123,6 +124,7 @@ class Config public function getGeminiApiKey(): ?string { - return $this->get('gemini_api_key') ?? $_ENV['GEMINI_API_KEY'] ?? getenv('GEMINI_API_KEY') ?: null; + $key = $this->get('gemini_api_key') ?? $_ENV['GEMINI_API_KEY'] ?? getenv('GEMINI_API_KEY') ?: null; + return is_string($key) ? $key : null; } } diff --git a/src/Rules/AreaAlt.php b/src/Rules/AreaAlt.php new file mode 100644 index 0000000..6d4f018 --- /dev/null +++ b/src/Rules/AreaAlt.php @@ -0,0 +1,37 @@ + elements in image maps have non-empty alt attributes.'; } + public function getStandard(): WCAGStandard { return WCAGStandard::A; } + public function getSeverity(): Severity { return Severity::ERROR; } + public function getLevel(): int { return 2; } + + public function check(DOMElement $element): ?Violation + { + if (strtolower($element->tagName) !== 'area') { + return null; + } + + $alt = $element->getAttribute('alt'); + $ariaLabel = $element->getAttribute('aria-label'); + + if (empty(trim($alt)) && empty(trim($ariaLabel))) { + return $this->createViolation( + $element, + ' öğesinde alt veya aria-label eksik.', + 'Provide a non-empty alt or aria-label attribute for the element.' + ); + } + + return null; + } +} diff --git a/src/Rules/AriaHiddenFocusable.php b/src/Rules/AriaHiddenFocusable.php index f5c3451..03892fb 100644 --- a/src/Rules/AriaHiddenFocusable.php +++ b/src/Rules/AriaHiddenFocusable.php @@ -57,6 +57,18 @@ class AriaHiddenFocusable extends AbstractRule return $type !== 'hidden' && !$element->hasAttribute('disabled'); } + if ($element->hasAttribute('contenteditable') && strtolower($element->getAttribute('contenteditable')) === 'true') { + return true; + } + + if (in_array($tagName, ['details', 'summary'], true)) { + return true; + } + + if (in_array($tagName, ['audio', 'video'], true) && $element->hasAttribute('controls')) { + return true; + } + if ($tagName === 'iframe') { return true; } diff --git a/src/Rules/AriaRequiredAttr.php b/src/Rules/AriaRequiredAttr.php new file mode 100644 index 0000000..0e83531 --- /dev/null +++ b/src/Rules/AriaRequiredAttr.php @@ -0,0 +1,63 @@ +hasAttribute('role')) { + return null; + } + + $roles = explode(' ', strtolower(trim($element->getAttribute('role')))); + $requiredMap = [ + 'slider' => ['aria-valuenow', 'aria-valuemin', 'aria-valuemax'], + 'checkbox' => ['aria-checked'], + 'combobox' => ['aria-expanded'], + 'scrollbar' => ['aria-controls', 'aria-valuenow', 'aria-valuemax', 'aria-valuemin', 'aria-orientation'], + 'spinbutton' => ['aria-valuenow', 'aria-valuemax', 'aria-valuemin'], + 'switch' => ['aria-checked'], + 'meter' => ['aria-valuenow'], + 'separator' => ['aria-valuenow'] // strictly when focusable, but we check if the role is present + ]; + + foreach ($roles as $role) { + if (isset($requiredMap[$role])) { + $missingAttrs = []; + foreach ($requiredMap[$role] as $reqAttr) { + if (!$element->hasAttribute($reqAttr)) { + $missingAttrs[] = $reqAttr; + } + } + + if (!empty($missingAttrs)) { + // Specific case: separator without tabindex is not focusable and doesn't require aria-valuenow in some contexts + if ($role === 'separator' && !$element->hasAttribute('tabindex')) { + continue; + } + + $missingStr = implode(', ', $missingAttrs); + return $this->createViolation( + $element, + "ARIA role '{$role}' eksik zorunlu niteliklere sahip: {$missingStr}.", + "Belirtilen ARIA rolü için zorunlu olan {$missingStr} niteliklerini ekleyin." + ); + } + } + } + + return null; + } +} diff --git a/src/Rules/AriaRequiredChildren.php b/src/Rules/AriaRequiredChildren.php new file mode 100644 index 0000000..7dd27c3 --- /dev/null +++ b/src/Rules/AriaRequiredChildren.php @@ -0,0 +1,85 @@ +hasAttribute('role')) { + return null; + } + + $roles = explode(' ', strtolower(trim($element->getAttribute('role')))); + $requiredChildrenMap = [ + 'list' => ['listitem'], + 'menu' => ['menuitem', 'menuitemcheckbox', 'menuitemradio'], + 'menubar' => ['menuitem', 'menuitemcheckbox', 'menuitemradio'], + 'tablist' => ['tab'], + 'tree' => ['treeitem'], + 'grid' => ['row', 'rowgroup'], + 'table' => ['row', 'rowgroup'], + 'radiogroup' => ['radio'], + 'listbox' => ['option'], + 'rowgroup' => ['row'], + 'row' => ['cell', 'gridcell', 'columnheader', 'rowheader'] + ]; + + foreach ($roles as $role) { + if (isset($requiredChildrenMap[$role])) { + $hasRequiredChild = $this->hasChildWithRole($element, $requiredChildrenMap[$role]); + + if (!$hasRequiredChild) { + // Check if children are dynamically generated with templates + $doc = $element->ownerDocument; + if ($doc !== null) { + $html = $doc->saveHTML($element); + if ($html !== false && preg_match('/(\{\{|\{%|<\?)/', $html)) { + continue; + } + } + + $expectedStr = implode(' veya ', $requiredChildrenMap[$role]); + return $this->createViolation( + $element, + "ARIA rolü '{$role}' olan eleman gerekli alt rollere sahip değil.", + "Bu elemanın içine rolü {$expectedStr} olan çocuk elemanlar ekleyin." + ); + } + } + } + + return null; + } + + /** + * @param array $requiredRoles + */ + private function hasChildWithRole(DOMElement $element, array $requiredRoles): bool + { + $doc = $element->ownerDocument; + if ($doc === null) { + return false; + } + $xpath = new \DOMXPath($doc); + foreach ($requiredRoles as $reqRole) { + $query = ".//*[@role='{$reqRole}' or contains(@role, ' {$reqRole}') or contains(@role, '{$reqRole} ')]"; + $nodes = $xpath->query($query, $element); + if ($nodes !== false && $nodes->length > 0) { + return true; + } + } + return false; + } +} diff --git a/src/Rules/AriaRequiredParent.php b/src/Rules/AriaRequiredParent.php new file mode 100644 index 0000000..60e2088 --- /dev/null +++ b/src/Rules/AriaRequiredParent.php @@ -0,0 +1,101 @@ +hasAttribute('role')) { + return null; + } + + $roles = explode(' ', strtolower(trim($element->getAttribute('role')))); + $requiredParentMap = [ + 'listitem' => ['list', 'group'], + 'menuitem' => ['menu', 'menubar', 'group'], + 'menuitemcheckbox' => ['menu', 'menubar'], + 'menuitemradio' => ['menu', 'menubar'], + 'tab' => ['tablist'], + 'treeitem' => ['tree', 'group'], + 'cell' => ['row'], + 'gridcell' => ['row'], + 'columnheader' => ['row'], + 'rowheader' => ['row'], + 'option' => ['listbox', 'group'], + 'row' => ['grid', 'table', 'treegrid', 'rowgroup'] + ]; + + foreach ($roles as $role) { + if (isset($requiredParentMap[$role])) { + $hasRequiredParent = $this->hasParentWithRole($element, $requiredParentMap[$role]); + + if (!$hasRequiredParent) { + $expectedStr = implode(' veya ', $requiredParentMap[$role]); + return $this->createViolation( + $element, + "ARIA rolü '{$role}' olan eleman gerekli üst eleman rollerinden hiçbirine sahip değil.", + "Bu elemanı rolü {$expectedStr} olan bir üst elemanın içine yerleştirin." + ); + } + } + } + + return null; + } + + /** + * @param array $requiredRoles + */ + private function hasParentWithRole(DOMElement $element, array $requiredRoles): bool + { + $parent = $element->parentNode; + while ($parent !== null && $parent instanceof DOMElement) { + if ($parent->hasAttribute('role')) { + $parentRoles = explode(' ', strtolower(trim($parent->getAttribute('role')))); + foreach ($parentRoles as $pRole) { + if (in_array($pRole, $requiredRoles)) { + return true; + } + } + } + // For native HTML semantics fallback (e.g.
    is a list) + $tagName = strtolower($parent->tagName); + $nativeRole = $this->getNativeRole($tagName); + if ($nativeRole && in_array($nativeRole, $requiredRoles)) { + return true; + } + + $parent = $parent->parentNode; + } + return false; + } + + private function getNativeRole(string $tagName): ?string + { + $map = [ + 'ul' => 'list', + 'ol' => 'list', + 'dl' => 'list', + 'table' => 'table', + 'tr' => 'row', + 'tbody' => 'rowgroup', + 'thead' => 'rowgroup', + 'tfoot' => 'rowgroup', + 'select' => 'listbox', + 'optgroup' => 'group' + ]; + return $map[$tagName] ?? null; + } +} diff --git a/src/Rules/AriaValidAttr.php b/src/Rules/AriaValidAttr.php new file mode 100644 index 0000000..0e1bf8d --- /dev/null +++ b/src/Rules/AriaValidAttr.php @@ -0,0 +1,59 @@ +attributes as $attr) { + /** @var \DOMAttr $attr */ + $name = strtolower($attr->nodeName); + if (strpos($name, 'aria-') === 0) { + if (!in_array($name, $validAttributes)) { + $invalidAttrs[] = $name; + } + } + } + + if (!empty($invalidAttrs)) { + $invalidStr = implode(', ', $invalidAttrs); + return $this->createViolation( + $element, + "Geçersiz ARIA nitelikleri bulundu: {$invalidStr}.", + "Sadece geçerli ARIA niteliklerini kullanın. Geçersiz olanları kaldırın veya düzeltin." + ); + } + + return null; + } +} diff --git a/src/Rules/AriaValidAttrValue.php b/src/Rules/AriaValidAttrValue.php new file mode 100644 index 0000000..0e911ee --- /dev/null +++ b/src/Rules/AriaValidAttrValue.php @@ -0,0 +1,78 @@ + ['true', 'false'], + 'aria-live' => ['off', 'polite', 'assertive'], + 'aria-expanded' => ['true', 'false', 'undefined'], + 'aria-pressed' => ['true', 'false', 'mixed', 'undefined'], + 'aria-checked' => ['true', 'false', 'mixed', 'undefined'], + 'aria-current' => ['page', 'step', 'location', 'date', 'time', 'true', 'false'], + 'aria-sort' => ['ascending', 'descending', 'none', 'other'], + 'aria-orientation' => ['horizontal', 'vertical', 'undefined'], + 'aria-haspopup' => ['true', 'false', 'menu', 'listbox', 'tree', 'grid', 'dialog'], + 'aria-invalid' => ['grammar', 'false', 'spelling', 'true'], + 'aria-autocomplete' => ['inline', 'list', 'both', 'none'], + 'aria-dropeffect' => ['copy', 'execute', 'link', 'move', 'none', 'popup'] + ]; + + for ($i = 0; $i < $element->attributes->length; $i++) { + $attr = $element->attributes->item($i); + if (!$attr instanceof \DOMAttr) { + continue; + } + $name = strtolower($attr->nodeName); + if (isset($validationMap[$name])) { + $value = strtolower(trim($attr->nodeValue ?? '')); + // Handle templates like {{ value }} or template syntax + if (preg_match('/^(\{\{|\{%).*(\}\}|\%\})$/', $value) || preg_match('/^' . preg_quote('<' . '?', '/') . '.*' . preg_quote('?' . '>', '/') . '$/', $value)) { + continue; + } + + if (!in_array($value, $validationMap[$name])) { + $validStr = implode(', ', $validationMap[$name]); + return $this->createViolation( + $element, + "{$name} niteliği geçersiz bir değere sahip: '{$value}'.", + "{$name} niteliğinin değerini geçerli olanlardan biriyle değiştirin: {$validStr}." + ); + } + } elseif ($name === 'aria-relevant') { + $values = explode(' ', strtolower(trim($attr->nodeValue ?? ''))); + $validRelevant = ['additions', 'removals', 'text', 'all']; + $rawValue = $attr->nodeValue ?? ''; + $isTemplate = preg_match('/^(\{\{|\{%).*(\}\}|\%\})$/', trim($rawValue)) || preg_match('/^' . preg_quote('<' . '?', '/') . '.*' . preg_quote('?' . '>', '/') . '$/', trim($rawValue)); + + if (!$isTemplate) { + foreach ($values as $val) { + if (!empty($val) && !in_array($val, $validRelevant)) { + return $this->createViolation( + $element, + "aria-relevant niteliği geçersiz bir değer içeriyor: '{$val}'.", + "aria-relevant değerlerini sadece additions, removals, text, all kelimelerinden oluşacak şekilde ayarlayın." + ); + } + } + } + } + } + + return null; + } +} diff --git a/src/Rules/AudioTrack.php b/src/Rules/AudioTrack.php new file mode 100644 index 0000000..30f7988 --- /dev/null +++ b/src/Rules/AudioTrack.php @@ -0,0 +1,42 @@ + elements have a child element for captions/descriptions.'; } + 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) !== 'audio') { + return null; + } + + $doc = $element->ownerDocument; + if ($doc === null) { + return null; + } + $xpath = new DOMXPath($doc); + $tracks = $xpath->query('./track', $element); + + if ($tracks === false || $tracks->length === 0) { + return $this->createViolation( + $element, + '