Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CONTRIBUTING.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 13 additions & 3 deletions README.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion composer.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 22 additions & 0 deletions src/Rules/AbstractA11yRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ abstract class AbstractA11yRule extends AbstractRule implements EvaluatableRuleI
{
use TokenCollectorTrait;

private const KIND_CACHE_MAX = 500;

/**
* Maximum number of rule-file keys in the $emitted map. When exceeded the
* map is reset to prevent unbounded memory growth during long-running
* linting sessions (watch-mode, large CI pipelines, etc.).
*/
private const EMITTED_MAX = 2000;

/** Cached decision for the currently-processed file when rules are reused */
private ?bool $skipThisFile = null;

Expand All @@ -30,6 +39,11 @@ abstract class AbstractA11yRule extends AbstractRule implements EvaluatableRuleI
* Shared cache of TemplateKind decisions keyed by content hash to avoid
* repeatedly classifying the same file across multiple rule instances.
*
* Bounded to 500 entries to prevent unbounded memory growth when linting
* very large projects in a single PHP process (e.g. via a long-running CI
* worker or watch mode). When the limit is reached the cache is reset so
* the next classification starts fresh.
*
* @var array<string, TemplateKind>
*/
private static array $kindCache = [];
Expand Down Expand Up @@ -81,6 +95,10 @@ final protected function process(int $tokenIndex, Tokens $tokens): void
$hash = md5($content);

if (!isset(self::$kindCache[$hash])) {
if (count(self::$kindCache) >= self::KIND_CACHE_MAX) {
self::$kindCache = [];
}

self::$kindCache[$hash] = TemplateClassifier::classify($content);
}

Expand Down Expand Up @@ -128,6 +146,10 @@ private function createEmitter(Tokens $tokens): callable

$ruleFileKey = static::class.'::'.$hash;
if (!isset($this->emitted[$ruleFileKey])) {
if (count($this->emitted) >= self::EMITTED_MAX) {
$this->emitted = [];
}

$this->emitted[$ruleFileKey] = [];
}

Expand Down
13 changes: 1 addition & 12 deletions src/Rules/Anchor/AnchorAccessibleNameRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,7 @@ public function evaluate(Tokens $tokens, int $tokenIndex, callable $emit): void
return;
}

$full = $this->collectTag($tokenIndex, $tokens, 200);
if (!str_contains($full, '>')) {
$collected = $full;
$i = $tokenIndex + 1;
$limit = $tokenIndex + 200;
while ($i <= $limit && $tokens->has($i) && !str_contains($collected, '>')) {
$collected .= $tokens->get($i)->getValue();
++$i;
}

$full = $collected;
}
$full = $this->collectUntil($tokenIndex, $tokens, '</a>', 200);

if (!preg_match('/<\s*a\b([^>]*)>(.*?)<\s*\/\s*a\s*>/is', $full, $m)) {
return;
Expand Down
164 changes: 157 additions & 7 deletions src/Rules/Aria/AriaAllowedAttrRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,162 @@

final class AriaAllowedAttrRule extends AbstractA11yRule
{
// Simplified allowed attrs per role (not exhaustive)
/**
* Allowed WAI-ARIA attributes per explicit role.
*
* IMPORTANT — coverage note: this list covers a practical subset of the
* full WAI-ARIA 1.2 specification. Roles not listed here are silently
* skipped (no false positive), but also not validated (potential false
* negative). Expand this map as needed; the authoritative source is
* https://www.w3.org/TR/wai-aria-1.2/#role_definitions
*
* All roles inherit the global ARIA attributes (aria-label,
* aria-labelledby, aria-describedby, aria-hidden, aria-live, etc.), so
* those are included for every role in the list.
*
* @var array<string, string[]>
*/
private array $allowed = [
'button' => ['aria-pressed', 'aria-label', 'aria-labelledby'],
'textbox' => ['aria-label', 'aria-labelledby', 'aria-required'],
'img' => ['aria-label', 'aria-labelledby'],
// Global attrs shared by all roles — repeated per-role for explicitness.
'button' => [
'aria-pressed', 'aria-expanded', 'aria-haspopup', 'aria-disabled',
'aria-label', 'aria-labelledby', 'aria-describedby', 'aria-hidden',
'aria-controls', 'aria-owns',
],
'textbox' => [
'aria-label', 'aria-labelledby', 'aria-describedby', 'aria-hidden',
'aria-required', 'aria-invalid', 'aria-readonly', 'aria-disabled',
'aria-multiline', 'aria-placeholder', 'aria-autocomplete',
],
'img' => [
'aria-label', 'aria-labelledby', 'aria-describedby', 'aria-hidden',
'aria-roledescription',
],
'checkbox' => [
'aria-checked', 'aria-label', 'aria-labelledby', 'aria-describedby',
'aria-hidden', 'aria-required', 'aria-disabled', 'aria-readonly',
],
'radio' => [
'aria-checked', 'aria-label', 'aria-labelledby', 'aria-describedby',
'aria-hidden', 'aria-required', 'aria-disabled', 'aria-posinset',
'aria-setsize',
],
'combobox' => [
'aria-expanded', 'aria-haspopup', 'aria-autocomplete', 'aria-required',
'aria-invalid', 'aria-readonly', 'aria-disabled', 'aria-activedescendant',
'aria-controls', 'aria-owns', 'aria-label', 'aria-labelledby',
'aria-describedby', 'aria-hidden',
],
'listbox' => [
'aria-multiselectable', 'aria-required', 'aria-disabled', 'aria-readonly',
'aria-activedescendant', 'aria-orientation', 'aria-label', 'aria-labelledby',
'aria-describedby', 'aria-hidden', 'aria-owns',
],
'option' => [
'aria-selected', 'aria-disabled', 'aria-checked', 'aria-posinset',
'aria-setsize', 'aria-label', 'aria-labelledby', 'aria-describedby',
'aria-hidden',
],
'tab' => [
'aria-selected', 'aria-disabled', 'aria-expanded', 'aria-controls',
'aria-label', 'aria-labelledby', 'aria-describedby', 'aria-hidden',
'aria-posinset', 'aria-setsize',
],
'tabpanel' => [
'aria-label', 'aria-labelledby', 'aria-describedby', 'aria-hidden',
],
'dialog' => [
'aria-modal', 'aria-label', 'aria-labelledby', 'aria-describedby',
'aria-hidden',
],
'alertdialog' => [
'aria-modal', 'aria-label', 'aria-labelledby', 'aria-describedby',
'aria-hidden',
],
'grid' => [
'aria-multiselectable', 'aria-readonly', 'aria-disabled', 'aria-rowcount',
'aria-colcount', 'aria-label', 'aria-labelledby', 'aria-describedby',
'aria-hidden', 'aria-activedescendant',
],
'gridcell' => [
'aria-selected', 'aria-readonly', 'aria-required', 'aria-disabled',
'aria-expanded', 'aria-colspan', 'aria-rowspan', 'aria-colindex',
'aria-rowindex', 'aria-label', 'aria-labelledby', 'aria-describedby',
'aria-hidden',
],
'row' => [
'aria-selected', 'aria-expanded', 'aria-level', 'aria-rowindex',
'aria-label', 'aria-labelledby', 'aria-describedby', 'aria-hidden',
],
'rowgroup' => [
'aria-label', 'aria-labelledby', 'aria-describedby', 'aria-hidden',
],
'columnheader' => [
'aria-sort', 'aria-readonly', 'aria-required', 'aria-selected',
'aria-colspan', 'aria-rowspan', 'aria-colindex', 'aria-rowindex',
'aria-label', 'aria-labelledby', 'aria-describedby', 'aria-hidden',
],
'rowheader' => [
'aria-sort', 'aria-readonly', 'aria-required', 'aria-selected',
'aria-colspan', 'aria-rowspan', 'aria-colindex', 'aria-rowindex',
'aria-label', 'aria-labelledby', 'aria-describedby', 'aria-hidden',
],
'slider' => [
'aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-valuetext',
'aria-orientation', 'aria-disabled', 'aria-readonly', 'aria-label',
'aria-labelledby', 'aria-describedby', 'aria-hidden',
],
'spinbutton' => [
'aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-valuetext',
'aria-required', 'aria-invalid', 'aria-readonly', 'aria-disabled',
'aria-label', 'aria-labelledby', 'aria-describedby', 'aria-hidden',
],
'progressbar' => [
'aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-valuetext',
'aria-label', 'aria-labelledby', 'aria-describedby', 'aria-hidden',
],
'scrollbar' => [
'aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-orientation',
'aria-controls', 'aria-label', 'aria-labelledby', 'aria-describedby',
'aria-hidden', 'aria-disabled',
],
'separator' => [
'aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-orientation',
'aria-label', 'aria-labelledby', 'aria-describedby', 'aria-hidden',
'aria-disabled',
],
'menuitem' => [
'aria-disabled', 'aria-expanded', 'aria-haspopup', 'aria-posinset',
'aria-setsize', 'aria-label', 'aria-labelledby', 'aria-describedby',
'aria-hidden',
],
'menuitemcheckbox' => [
'aria-checked', 'aria-disabled', 'aria-expanded', 'aria-haspopup',
'aria-posinset', 'aria-setsize', 'aria-label', 'aria-labelledby',
'aria-describedby', 'aria-hidden',
],
'menuitemradio' => [
'aria-checked', 'aria-disabled', 'aria-posinset', 'aria-setsize',
'aria-label', 'aria-labelledby', 'aria-describedby', 'aria-hidden',
],
'treeitem' => [
'aria-expanded', 'aria-selected', 'aria-checked', 'aria-level',
'aria-posinset', 'aria-setsize', 'aria-disabled', 'aria-label',
'aria-labelledby', 'aria-describedby', 'aria-hidden',
],
'tree' => [
'aria-multiselectable', 'aria-required', 'aria-activedescendant',
'aria-orientation', 'aria-label', 'aria-labelledby', 'aria-describedby',
'aria-hidden',
],
'link' => [
'aria-disabled', 'aria-expanded', 'aria-haspopup', 'aria-label',
'aria-labelledby', 'aria-describedby', 'aria-hidden',
],
'switch' => [
'aria-checked', 'aria-readonly', 'aria-required', 'aria-disabled',
'aria-label', 'aria-labelledby', 'aria-describedby', 'aria-hidden',
],
];

public function evaluate(Tokens $tokens, int $tokenIndex, callable $emit): void
Expand All @@ -30,6 +178,8 @@ public function evaluate(Tokens $tokens, int $tokenIndex, callable $emit): void
return;
}

$idx = 0;

if (preg_match_all('/<([a-z0-9]+)([^>]*)\srole\s*=\s*(?:"|\')([^"\']+)(?:"|\')([^>]*)>/i', $full, $matches, PREG_SET_ORDER)) {
foreach ($matches as $m) {
$role = strtolower($m[3]);
Expand All @@ -44,10 +194,10 @@ public function evaluate(Tokens $tokens, int $tokenIndex, callable $emit): void
if (preg_match('/\baria-([a-z0-9-]+)/i', $ariaRaw, $an)) {
$name = strtolower($an[1]);
if (!in_array('aria-'.$name, $this->allowed[$role], true)) {
++$idx;
$fakeToken = $tokens->get(0);
$emit(sprintf('Attribute aria-%s is not allowed on role %s.', $name, $role), $fakeToken, 'AriaAllowed.Invalid');

return;
$id = 1 === $idx ? 'AriaAllowed.Invalid' : sprintf('AriaAllowed.Invalid#%d', $idx);
$emit(sprintf('Attribute aria-%s is not allowed on role %s.', $name, $role), $fakeToken, $id);
}
}
}
Expand Down
5 changes: 0 additions & 5 deletions src/Rules/Aria/AriaHiddenBodyRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,6 @@ public function evaluate(Tokens $tokens, int $tokenIndex, callable $emit): void

$full = $this->getFullContent($tokens);

// Only apply to full pages
if (!str_contains(strtoupper($full), '<!DOCTYPE') && !str_contains($full, '<body')) {
return;
}

if (preg_match('/<body[^>]*aria-hidden\s*=\s*(?:"|\')true(?:"|\')/i', $full)) {
$first = $tokens->get(0);
$emit('Do not set aria-hidden="true" on the <body> element.', $first, 'AriaHiddenBody.HiddenOnBody');
Expand Down
9 changes: 7 additions & 2 deletions src/Rules/Aria/AriaHiddenFocusRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,15 @@ public function evaluate(Tokens $tokens, int $tokenIndex, callable $emit): void
$tagName = strtolower($set[1]);
$attrs = $set[2];
if (preg_match('/aria-hidden\s*=\s*(?:"|\')true(?:"|\')/i', $attrs)) {
// Focusable detection: element tag or attributes indicating focusability
// Focusable detection: element tag or attributes indicating focusability.
// tabindex="-1" removes an element from tab order, so it is NOT considered
// tab-focusable. Only tabindex >= 0 creates a keyboard-focusable element.
$focusableTags = ['button', 'input', 'select', 'textarea', 'a'];
$hasPositiveTabindex = (bool) preg_match('/tabindex\s*=\s*(?:"|\')?\s*(\d+)/i', $attrs, $ti)
&& (int) $ti[1] >= 0;
$isFocusable = in_array($tagName, $focusableTags, true)
|| preg_match('/href\s*=|tabindex\s*=/i', $attrs);
|| preg_match('/href\s*=/i', $attrs)
|| $hasPositiveTabindex;

if ($isFocusable) {
// add a generic token (first text token) for location
Expand Down
7 changes: 6 additions & 1 deletion src/Rules/Aria/AriaLabelRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,16 @@ public function evaluate(Tokens $tokens, int $tokenIndex, callable $emit): void

$tag = $this->collectUntil($tokenIndex, $tokens, '>');

// If aria-label present and non-empty - OK
// aria-label present and non-empty - OK
if (preg_match('/aria-label\s*=\s*(?:"|\')([^"\']*)(?:"|\')/i', $tag, $m) && '' !== trim($m[1])) {
return;
}

// aria-labelledby present and non-empty - also acceptable per WCAG
if (preg_match('/aria-labelledby\s*=\s*(?:"|\')([^"\']*)(?:"|\')/i', $tag, $m) && '' !== trim($m[1])) {
return;
}

$emit(
'Landmark elements should have a non-empty aria-label.',
$token,
Expand Down
Loading