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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- **Breaking:** `.a11y-lint.yml`'s `hidden_wrapper_classes` key is now `inaccessible_wrapper_classes`. Behavior is unchanged. The rename brings the key into line with the new `accessible_wrapper_classes` (below). Projects with `hidden_wrapper_classes` in their config need to rename it

### Added

- `.a11y-lint.yml` now supports a top-level `accessible_wrapper_classes` list. Inside Phlex `a`/`button` tags whose direct child has a matching class, `AnchorMissingAccessibleName` and `ButtonMissingAccessibleName` treat ambiguous bare calls — receiverless calls whose name matches an HTML tag, with no arguments and no block — as text-emitting method calls rather than empty HTML elements. Fixes false positives on the `span(class: "sr-only") { label }` pattern, where `label` is an instance method that parses as a `Prism::CallNode` indistinguishable from the `<label>` HTML tag. Opt-in; default is no special handling. Distinct from `inaccessible_wrapper_classes` (which excludes the wrapper from the parent's accessible name); a wrapper-class list member here stays in the parent's children with `text_content?` set to true. No-op in the Slim and ERB pipelines, which have no analogous ambiguity

## [0.14.2] - 2026-05-05

### Fixed
Expand Down
11 changes: 8 additions & 3 deletions lib/a11y/lint/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,14 @@ def enabled?(rule_name)
@config.dig(rule_name, "Enabled") != false
end

def hidden_wrapper_classes
@hidden_wrapper_classes ||=
Array(@config["hidden_wrapper_classes"]).map(&:to_s).freeze
def inaccessible_wrapper_classes
@inaccessible_wrapper_classes ||=
Array(@config["inaccessible_wrapper_classes"]).map(&:to_s).freeze
end

def accessible_wrapper_classes
@accessible_wrapper_classes ||=
Array(@config["accessible_wrapper_classes"]).map(&:to_s).freeze
end

def enabled_rules
Expand Down
10 changes: 5 additions & 5 deletions lib/a11y/lint/erb_element_node.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def block_has_text_children?
end

def text_content?
return false if hidden_wrapper?(@nokogiri_node)
return false if inaccessible_wrapper?(@nokogiri_node)

visible_text_or_output?(@nokogiri_node)
end
Expand All @@ -58,7 +58,7 @@ def text_content?
# to the accessible name.
def children
@nokogiri_node.element_children.filter_map do |child|
next if hidden_wrapper?(child)
next if inaccessible_wrapper?(child)

ErbElementNode.new(
nokogiri_node: child, line: child.line,
Expand All @@ -70,7 +70,7 @@ def children
private

def visible_text_or_output?(node)
return false if hidden_wrapper?(node)
return false if inaccessible_wrapper?(node)
return true if own_text_or_marker?(node)

node.element_children.any? { |c| visible_text_or_output?(c) }
Expand All @@ -86,8 +86,8 @@ def own_text_or_marker?(node)
end
end

def hidden_wrapper?(node)
classes = configuration.hidden_wrapper_classes
def inaccessible_wrapper?(node)
classes = configuration.inaccessible_wrapper_classes
return false if classes.empty?

node_classes(node).any? { |klass| classes.include?(klass) }
Expand Down
14 changes: 7 additions & 7 deletions lib/a11y/lint/erb_runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ def visible_codes_and_text(block_content)
indexed_codes = []
html = indexed_marker_html(block_content, indexed_codes)
fragment = Nokogiri::HTML4::DocumentFragment.parse(html)
strip_hidden_wrappers!(fragment)
strip_inaccessible_wrappers!(fragment)

remaining = fragment.to_html
visible_codes = indexed_codes.each_with_index.filter_map do |code, i|
Expand All @@ -118,23 +118,23 @@ def non_marker_text?(html)
!html.gsub(/#{ERB_OUTPUT_MARKER}\d+_/, "").strip.empty?
end

def strip_hidden_wrappers!(node)
return if configuration.hidden_wrapper_classes.empty?
def strip_inaccessible_wrappers!(node)
return if configuration.inaccessible_wrapper_classes.empty?

node.element_children.each do |child|
if hidden_wrapper_element?(child)
if inaccessible_wrapper_element?(child)
child.remove
else
strip_hidden_wrappers!(child)
strip_inaccessible_wrappers!(child)
end
end
end

def hidden_wrapper_element?(node)
def inaccessible_wrapper_element?(node)
value = node.attributes["class"]&.value
return false unless value.is_a?(String)

classes = configuration.hidden_wrapper_classes
classes = configuration.inaccessible_wrapper_classes
value.split.any? { |klass| classes.include?(klass) }
end

Expand Down
7 changes: 7 additions & 0 deletions lib/a11y/lint/phlex_node.rb
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ def self.build_helper(
)
end

# Resolves a Phlex tag call's `class:` kwarg to a list of class names.
# Intentionally narrow: only matches a literal `Prism::StringNode`.
# Misses array form (`class: ["sr-only", other]`), interpolation
# (`class: "sr-only #{foo}"`), helper-wrapped (`class: cn("sr-only")`),
# and computed values (`class: some_method`). Wrapper-class config
# (`inaccessible_wrapper_classes`, `accessible_wrapper_classes`)
# therefore only matches authors who write a single static literal.
def self.kwarg_class_values(call_node)
return [] unless call_node.arguments

Expand Down
23 changes: 18 additions & 5 deletions lib/a11y/lint/phlex_runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -91,17 +91,30 @@ def gather_children(parent, result)

def gather_tag_child(call, result)
kids = collect_block_children(call.block_node)
has_text = call.arg_has_text? || call.block_has_text?(kids)
has_text = call.arg_has_text? ||
call.block_has_text?(
kids,
accessible_wrapper: accessible_wrapper_tag?(call)
)
tag = PhlexNode.build_tag(
call.call_node, children: kids, text_content: has_text,
configuration: configuration
)
check_node(tag)
result << tag unless hidden_wrapper_tag?(call)
result << tag unless inaccessible_wrapper_tag?(call)
end

def hidden_wrapper_tag?(call)
classes = configuration.hidden_wrapper_classes
def inaccessible_wrapper_tag?(call)
wrapper_class_match?(call, configuration.inaccessible_wrapper_classes)
end

def accessible_wrapper_tag?(call)
wrapper_class_match?(
call, configuration.accessible_wrapper_classes
)
end

def wrapper_class_match?(call, classes)
return false if classes.empty?

call.class_values.any? { |klass| classes.include?(klass) }
Expand Down Expand Up @@ -131,7 +144,7 @@ def visit_helper_block_child(child, codes)
end

def handle_call_block_child(child_call, codes)
return false if child_call.tag? && hidden_wrapper_tag?(child_call)
return false if child_call.tag? && inaccessible_wrapper_tag?(child_call)
return true if tag_call_has_text_block?(child_call)
return false if child_call.tag?

Expand Down
21 changes: 16 additions & 5 deletions lib/a11y/lint/phlex_runner/block_text_scanner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ class BlockTextScanner
highlight excerpt
].to_set.freeze

def self.scan(block, children:)
new(block, children).scan
def self.scan(block, children:, accessible_wrapper: false)
new(block, children:, accessible_wrapper:).scan
end

# Public recognizer for a single Prism node — used by PhlexRunner
# to inspect a tag's first positional argument, where Phlex emits
# the value as text content (`a("Click", href: "/x")`).
def self.text_emitting?(node)
new(nil, []).text_emitting?(node)
new(nil, children: []).text_emitting?(node)
end

def self.non_blank_string_literal?(node)
Expand All @@ -50,9 +50,10 @@ def self.non_blank_string_literal?(node)
end
end

def initialize(block, children)
def initialize(block, children:, accessible_wrapper: false)
@block = block
@children = children
@accessible_wrapper = accessible_wrapper
end

def scan
Expand Down Expand Up @@ -119,7 +120,9 @@ def auto_emitted_call?(call)
return true if call.receiver

name = call.name.to_s
return false if PhlexNode.html_tag?(name)
if PhlexNode.html_tag?(name)
return accessible_wrapper? && bare_call?(call)
end
return true if TEXT_CALLS.include?(name)

# Lowercase receiverless calls (locals, helper methods) auto-emit
Expand All @@ -128,6 +131,14 @@ def auto_emitted_call?(call)
lowercase_name?(name)
end

def bare_call?(call)
call.arguments.nil? && call.block.nil?
end

def accessible_wrapper?
@accessible_wrapper
end

def conditional_container?(node)
node.is_a?(Prism::IfNode) ||
node.is_a?(Prism::UnlessNode) ||
Expand Down
8 changes: 2 additions & 6 deletions lib/a11y/lint/phlex_runner/phlex_call.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,8 @@ def forwarded_block?
block.is_a?(Prism::BlockArgumentNode)
end

def block_children
block_node ? block_node.child_nodes.compact : []
end

def block_has_text?(children = [])
BlockTextScanner.scan(block_node, children: children)
def block_has_text?(children = [], accessible_wrapper: false)
BlockTextScanner.scan(block_node, children:, accessible_wrapper:)
end

# Phlex's tag API emits the first positional argument as text
Expand Down
12 changes: 6 additions & 6 deletions lib/a11y/lint/slim_node.rb
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ def parse_call_node

def collect_output_codes(sexp)
return [] unless sexp.is_a?(Array)
return [] if hidden_wrapper_sexp?(sexp)
return [] if inaccessible_wrapper_sexp?(sexp)
return [sexp[3]] if slim_output_sexp?(sexp)

sexp.flat_map { |child| collect_output_codes(child) }
Expand All @@ -103,26 +103,26 @@ def slim_output_sexp?(sexp)

def block_text_content?(sexp)
return false unless sexp.is_a?(Array)
return false if hidden_wrapper_sexp?(sexp)
return false if inaccessible_wrapper_sexp?(sexp)
return true if slim_text_sexp?(sexp) || html_tag_sexp?(sexp)

sexp.any? { |child| block_text_content?(child) }
end

def text_or_output?(sexp)
return false unless sexp.is_a?(Array)
return false if hidden_wrapper_sexp?(sexp)
return false if inaccessible_wrapper_sexp?(sexp)
return true if slim_text_sexp?(sexp) || slim_output_sexp?(sexp)

sexp.any? { |child| text_or_output?(child) }
end

def hidden_wrapper_sexp?(sexp)
def inaccessible_wrapper_sexp?(sexp)
return false unless html_tag_sexp?(sexp)
return false if configuration.hidden_wrapper_classes.empty?
return false if configuration.inaccessible_wrapper_classes.empty?

class_values(sexp[3]).any? do |klass|
configuration.hidden_wrapper_classes.include?(klass)
configuration.inaccessible_wrapper_classes.include?(klass)
end
end

Expand Down
10 changes: 0 additions & 10 deletions test/a11y/lint/phlex_runner/test_phlex_call.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,16 +76,6 @@ def test_forwarded_block_false_when_no_block
refute_predicate(wrap("img(src: 'x.jpg')"), :forwarded_block?)
end

def test_block_children_returns_children_when_block_present
call = wrap("div { p { 'hi' } }")

refute_empty(call.block_children)
end

def test_block_children_empty_when_no_block
assert_empty(wrap("img(src: 'x.jpg')").block_children)
end

def test_block_has_text_true_for_explicit_string
assert(wrap("a(href: '/x') { 'click' }").block_has_text?)
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ def test_sets_filename_on_offense
)
end

def test_anchor_with_hidden_wrapper_text_passes_by_default
def test_anchor_with_inaccessible_wrapper_text_passes_by_default
source = <<~ERB
<a href="/path">
<span class="popover">Move</span>
Expand All @@ -139,15 +139,15 @@ def test_anchor_with_hidden_wrapper_text_passes_by_default
assert_empty(offenses)
end

def test_anchor_with_hidden_wrapper_text_reports_when_configured
def test_anchor_with_inaccessible_wrapper_text_reports_when_configured
source = <<~ERB
<a href="/path">
<span class="popover">Move</span>
<img src="icon.svg">
</a>
ERB
configuration = Configuration.new(
"hidden_wrapper_classes" => ["popover"]
"inaccessible_wrapper_classes" => ["popover"]
)

offenses = run_linter(source, configuration:)
Expand Down
Loading
Loading