a11y-lint 0.14.1 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 17ce590d5fc702bdcf3feb01606767c2aaa0938ca6a40b3f456737d7e9f519f2
4
- data.tar.gz: b1ec19b360ccbc592fd42a9848cfdb351b924f3752b3041afa063f64bfd3e380
3
+ metadata.gz: 27003e9edd9b1055dd6c2ddd37ac6da8958bb3a608f5bb8fbfa103eb167ddb0e
4
+ data.tar.gz: 5af458356a27d126373c8780ca2cec362d7d2e05b70edfdb45236a90f70b82a9
5
5
  SHA512:
6
- metadata.gz: fa1db2cd985c446515c75808512e3663859af93d254949e51fb3638e0ee1fe7cf2172a55686f66b937476e8739a9f24910b8662ef03e285445dfab9079983be3
7
- data.tar.gz: 045c3ec7a7e4442a47517020aa2cf3da15496a3e304a1768e8e067d7767f2cd0693db025711f25c05e02736c6d3f0095378e9dfb68ed1a3b19ebcb3e8232a49e
6
+ metadata.gz: a03ec8dba3eea02eaf7195e6b06f7ef60e628fbe4fd0de1a36c646123e5a335f8244fb10d6fe1a10f3570d95d133e9e32ccdd3ab0619401b93ccf82c6040c4d7
7
+ data.tar.gz: 632ec73a902867c26723f05814717ec53f8f32df73d3d9db7f99afc5cfd1530899cb418e1172b4076de7b0b2f72a48f2ce179f08c506ec7a013cf4ebcae6b625
@@ -0,0 +1,46 @@
1
+ ---
2
+ description: Plan and implement a GitHub issue end-to-end on a new branch
3
+ argument-hint: <issue-number>
4
+ allowed-tools: Bash(gh issue view:*), Bash(gh repo view:*), Bash(git checkout:*), Bash(git switch:*), Bash(git branch:*), Bash(git status:*), Bash(git diff:*), Bash(bundle exec:*), Bash(bin/setup:*), Bash(bin/console:*), Bash(grep:*), Bash(rg:*), Bash(ls:*), Bash(find:*), Read, Edit, Write, Glob, Grep
5
+ ---
6
+
7
+ Plan and implement GitHub issue **#$ARGUMENTS** in this repo, end-to-end, on a fresh branch. Do **not** commit or push — leave the working tree dirty for the user to review.
8
+
9
+ ## What to do
10
+
11
+ 1. **Fetch the issue.** Run `gh issue view $ARGUMENTS` (and `gh issue view $ARGUMENTS --comments` if there are comments) to read the title, body, and any discussion. Do **not** pass `--repo`.
12
+
13
+ 2. **Create a branch.** From the current branch, run `git checkout -b <slug>` where `<slug>` is `issue-$ARGUMENTS-<short-kebab-summary>` (e.g. `issue-87-anchor-button-phlex-helpers`). Keep it under ~50 chars. If the branch already exists, switch to it instead.
14
+
15
+ 3. **Plan before coding.** Produce a short plan covering:
16
+ - Which files you'll touch or create
17
+ - For new rules: the WCAG principle directory under `lib/a11y/lint/rules/`, the rule class name (see CLAUDE.md "Rule Scoping Convention"), and whether it has an HTML/helper pair
18
+ - Tests to add (Slim + ERB + Phlex parity per `.claude/rules/testing.md`)
19
+ - Dummy app fixtures to add in `test/fixtures/dummy_app/app/views/home/home.html.{slim,erb}` and any Phlex view
20
+ - Open questions, if any — ask once, then proceed
21
+
22
+ 4. **Implement the plan.**
23
+ - Follow `CLAUDE.md` (rule scoping, node interface, configuration loading) and `.claude/rules/testing.md` (no `setup`, all three calling styles for `ruby_code` rules, Slim/ERB/Phlex parity, inline HEREDOC fixtures).
24
+ - For new rules: register the rule, add good/bad examples to dummy app fixtures, and add a docs page under `docs/src/rules/` if the issue is adding a user-visible rule.
25
+ - Add an entry under `[Unreleased]` in `CHANGELOG.md` for any user-visible change (bug fixes, new rules, behavior changes). Skip for internal refactors and test-only changes.
26
+
27
+ 5. **Run the suite until green.** `bundle exec rake` runs tests + RuboCop. Iterate on failures. If a fix is non-obvious, surface it before grinding.
28
+
29
+ 6. **Stop without committing.** Leave changes staged-or-unstaged in the working tree. In your final reply:
30
+ - List the branch name
31
+ - Summarize what changed (files added/modified, tests added)
32
+ - Confirm `bundle exec rake` is green (or call out what's still failing and why)
33
+ - Suggest the next command (`git add -A && git commit -m "..."` or a `gh pr create` once the user is ready) — but do **not** run it
34
+
35
+ ## Out of scope
36
+
37
+ - Committing, pushing, or opening a PR
38
+ - Cross-repo support
39
+ - Auto-closing the issue
40
+ - Picking the issue automatically (always passed explicitly)
41
+
42
+ ## Style notes
43
+
44
+ - Plan first, code second. A 5-line plan beats a 50-line one.
45
+ - Match the conventions of recent rules and recent commits — skim `git log --oneline -20` and a nearby rule file before inventing a new pattern.
46
+ - If the issue is ambiguous, ask **one** round of clarifying questions before implementing — voice input drops context, but too many questions defeat the point.
data/.rubocop.yml CHANGED
@@ -29,5 +29,4 @@ Metrics/AbcSize:
29
29
  - "test/**/*"
30
30
 
31
31
  Metrics/MethodLength:
32
- Exclude:
33
- - "test/**/*"
32
+ Enabled: false
data/CHANGELOG.md CHANGED
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.14.2] - 2026-05-05
11
+
12
+ ### Fixed
13
+
14
+ - `AnchorMissingAccessibleName` and `ButtonMissingAccessibleName` no longer report Phlex `a`/`button` tags that use the nested-hash shorthand for `aria-label` (e.g. `aria: { label: "..." }`). `PhlexNode` now flattens nested kwarg hashes the way Phlex does at render time (`aria: { label: "x" }` → `aria-label="x"`, `data: { controller: "y" }` → `data-controller="y"`), so every Phlex tag rule benefits
15
+ - `AnchorMissingAccessibleName` and `ButtonMissingAccessibleName` no longer report Phlex `a`/`button` tags whose accessible name comes from the block's auto-emitted return value. Phlex renders the block's last expression when it's a string-like value, and `PhlexRunner` now recognizes string literals (`{ "Clear all" }`, `{ "Hi #{name}" }`), method calls with a receiver (`{ account.email }`, `{ tab[:text] }`), lowercase receiverless calls (`{ external_url }`, `{ add_label }`), method-parameter / local-variable reads, and ternary / `if` / `unless` expressions whose branches resolve to text. Capitalized component calls (`{ Pencil(...) }`) and blank string literals are still treated as having no accessible name
16
+ - `AnchorMissingAccessibleName` and `ButtonMissingAccessibleName` no longer report Phlex `a`/`button` tags that forward a block argument (`a(href:, &block)`, `button(type: "submit", &block)`) inside a wrapper method. The accessible name is supplied by the caller's block, which can't be resolved from the tag call site, so forwarded blocks are now trusted by default
17
+ - `AnchorMissingAccessibleName` and `ButtonMissingAccessibleName` no longer report Phlex `a`/`button` tags whose accessible name is passed as a positional argument (`a("Click me", href: "/x")`, `button("Submit", type: :submit)`). Phlex's tag API renders the first positional argument as text content, and `PhlexRunner` now applies the same recognizer used for block content to the positional arg — string literals, interpolated strings, method calls (with or without a receiver), `t`/`translate` and other phlex-rails value helpers, and local variable / method-parameter reads. Empty/whitespace strings and capitalized component calls (`a(Pencil(...), href: ...)`) are still reported
18
+
10
19
  ## [0.14.1] - 2026-05-03
11
20
 
12
21
  ### Fixed
data/lib/a11y/lint/cli.rb CHANGED
@@ -34,7 +34,7 @@ module A11y
34
34
  option_parser.parse!(@argv)
35
35
  end
36
36
 
37
- def option_parser # rubocop:disable Metrics/MethodLength
37
+ def option_parser
38
38
  OptionParser.new do |opts|
39
39
  opts.banner = "Usage: a11y-lint [options] [file_or_directory ...]"
40
40
 
@@ -100,7 +100,31 @@ module A11y
100
100
 
101
101
  kwarg_nodes(call_node).each_with_object({}) do |elem, h|
102
102
  key = kwarg_key(elem.key)
103
- h[key] = kwarg_value(elem.value) if key
103
+ next unless key
104
+
105
+ if elem.value.is_a?(Prism::HashNode)
106
+ flatten_nested_hash(key, elem.value, h)
107
+ else
108
+ h[key] = kwarg_value(elem.value)
109
+ end
110
+ end
111
+ end
112
+
113
+ # Mirrors Phlex's render-time flattening of nested kwarg hashes
114
+ # (e.g. `aria: { label: "x" }` -> `aria-label="x"`). Inner key
115
+ # underscores become dashes the same way Phlex/Rails do.
116
+ def self.flatten_nested_hash(prefix, hash_node, attrs)
117
+ hash_node.elements.each do |inner|
118
+ next unless inner.is_a?(Prism::AssocNode)
119
+ next unless (inner_key = kwarg_key(inner.key))
120
+
121
+ full_key = "#{prefix}-#{inner_key.tr("_", "-")}"
122
+
123
+ if inner.value.is_a?(Prism::HashNode)
124
+ flatten_nested_hash(full_key, inner.value, attrs)
125
+ else
126
+ attrs[full_key] = kwarg_value(inner.value)
127
+ end
104
128
  end
105
129
  end
106
130
 
@@ -125,8 +149,13 @@ module A11y
125
149
  end
126
150
  end
127
151
 
128
- private_class_method :kwarg_key, :kwarg_nodes, :kwarg_value,
129
- :extract_attributes
152
+ private_class_method(
153
+ :extract_attributes,
154
+ :flatten_nested_hash,
155
+ :kwarg_key,
156
+ :kwarg_nodes,
157
+ :kwarg_value
158
+ )
130
159
  end
131
160
  end
132
161
  end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module A11y
6
+ module Lint
7
+ class PhlexRunner
8
+ # Decides whether a Phlex tag block produces accessible text.
9
+ # Stateless: only reads the given Prism block node and pre-collected
10
+ # child tags.
11
+ class BlockTextScanner
12
+ # Phlex auto-emits the return value of these calls into the document:
13
+ # `plain` / `text` are built-in; the rest are registered as value
14
+ # helpers by phlex-rails via `register_value_helper`.
15
+ TEXT_CALLS = %w[
16
+ plain text
17
+ t translate l localize
18
+ pluralize truncate
19
+ number_to_currency number_to_human number_to_human_size
20
+ number_to_percentage number_to_phone
21
+ number_with_delimiter number_with_precision
22
+ highlight excerpt
23
+ ].to_set.freeze
24
+
25
+ def self.scan(block, children:)
26
+ new(block, children).scan
27
+ end
28
+
29
+ # Public recognizer for a single Prism node — used by PhlexRunner
30
+ # to inspect a tag's first positional argument, where Phlex emits
31
+ # the value as text content (`a("Click", href: "/x")`).
32
+ def self.text_emitting?(node)
33
+ new(nil, []).text_emitting?(node)
34
+ end
35
+
36
+ def self.non_blank_string_literal?(node)
37
+ case node
38
+ when Prism::StringNode
39
+ !node.unescaped.strip.empty?
40
+ when Prism::InterpolatedStringNode
41
+ node.parts.any? do |part|
42
+ if part.is_a?(Prism::StringNode)
43
+ !part.unescaped.strip.empty?
44
+ else
45
+ true
46
+ end
47
+ end
48
+ else
49
+ false
50
+ end
51
+ end
52
+
53
+ def initialize(block, children)
54
+ @block = block
55
+ @children = children
56
+ end
57
+
58
+ def scan
59
+ return false unless @block.is_a?(Prism::BlockNode)
60
+
61
+ scan_for_text(@block) || @children.any?(&:text_content?)
62
+ end
63
+
64
+ def text_emitting?(node)
65
+ auto_emitted_text?(node)
66
+ end
67
+
68
+ private
69
+
70
+ # A Phlex tag block produces accessible text if any statement
71
+ # contains an explicit text marker (yield, plain/text/t/..., a
72
+ # non-blank string literal), or if the block's last expression
73
+ # auto-emits a value Phlex renders as text.
74
+ def scan_for_text(block)
75
+ statements = block_statements(block)
76
+ return false if statements.empty?
77
+
78
+ return true if statements.any? { |stmt| explicit_text?(stmt) }
79
+
80
+ auto_emitted_text?(statements.last)
81
+ end
82
+
83
+ def block_statements(block)
84
+ return [] unless block.is_a?(Prism::BlockNode)
85
+
86
+ body = block.body
87
+ body.is_a?(Prism::StatementsNode) ? body.body : []
88
+ end
89
+
90
+ def explicit_text?(node)
91
+ return false if node.nil?
92
+ return true if node.is_a?(Prism::YieldNode)
93
+ return true if self.class.non_blank_string_literal?(node)
94
+ return text_call?(node) if node.is_a?(Prism::CallNode)
95
+
96
+ # Recurse only into conditional/grouping nodes — descending into
97
+ # call args would treat string literals like `foo("bar")`'s "bar"
98
+ # as block text, which it isn't.
99
+ return false unless conditional_container?(node)
100
+
101
+ node.child_nodes.compact.any? { |c| explicit_text?(c) }
102
+ end
103
+
104
+ def auto_emitted_text?(node)
105
+ return false if node.nil?
106
+ return true if explicit_text?(node)
107
+
108
+ case node
109
+ when Prism::LocalVariableReadNode
110
+ true
111
+ when Prism::CallNode
112
+ auto_emitted_call?(node)
113
+ when Prism::IfNode, Prism::UnlessNode
114
+ if_branch_lasts(node).any? { |last| auto_emitted_text?(last) }
115
+ end
116
+ end
117
+
118
+ def auto_emitted_call?(call)
119
+ return true if call.receiver
120
+
121
+ name = call.name.to_s
122
+ return false if PhlexNode.html_tag?(name)
123
+ return true if TEXT_CALLS.include?(name)
124
+
125
+ # Lowercase receiverless calls (locals, helper methods) auto-emit
126
+ # their return value. Capitalized names are Phlex components,
127
+ # which emit their own HTML, not text.
128
+ lowercase_name?(name)
129
+ end
130
+
131
+ def conditional_container?(node)
132
+ node.is_a?(Prism::IfNode) ||
133
+ node.is_a?(Prism::UnlessNode) ||
134
+ node.is_a?(Prism::BeginNode) ||
135
+ node.is_a?(Prism::StatementsNode) ||
136
+ node.is_a?(Prism::ElseNode) ||
137
+ node.is_a?(Prism::ParenthesesNode)
138
+ end
139
+
140
+ def lowercase_name?(name)
141
+ first = name[0]
142
+ first && first == first.downcase && first != first.upcase
143
+ end
144
+
145
+ def if_branch_lasts(node)
146
+ if_branch_statements(node).flat_map do |stmts|
147
+ stmts.is_a?(Prism::StatementsNode) ? [stmts.body.last].compact : []
148
+ end
149
+ end
150
+
151
+ def if_branch_statements(node)
152
+ branches = [node.statements]
153
+ collect_else_branches(if_else_clause(node), branches)
154
+ branches.compact
155
+ end
156
+
157
+ def collect_else_branches(current, branches)
158
+ while current.is_a?(Prism::IfNode) || current.is_a?(Prism::UnlessNode)
159
+ branches << current.statements
160
+ current = if_else_clause(current)
161
+ end
162
+ branches << current.statements if current.is_a?(Prism::ElseNode)
163
+ end
164
+
165
+ def if_else_clause(node)
166
+ case node
167
+ when Prism::IfNode then node.subsequent
168
+ when Prism::UnlessNode then node.else_clause
169
+ end
170
+ end
171
+
172
+ def text_call?(node)
173
+ node.is_a?(Prism::CallNode) &&
174
+ node.receiver.nil? &&
175
+ TEXT_CALLS.include?(node.name.to_s)
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module A11y
6
+ module Lint
7
+ class PhlexRunner
8
+ # Wraps a `Prism::CallNode` with the predicates and block accessors
9
+ # PhlexRunner uses to classify and traverse Phlex view code:
10
+ # tag vs helper, block form (`do...end`, `&block`, none),
11
+ # first positional arg, class values.
12
+ class PhlexCall
13
+ # Phlex tag/helper calls are always receiverless; a call like
14
+ # `helpers.image_tag(...)` has a receiver and is skipped.
15
+ def self.wrap(node)
16
+ return unless node.is_a?(Prism::CallNode) && node.receiver.nil?
17
+
18
+ new(node)
19
+ end
20
+
21
+ def initialize(call_node)
22
+ @call_node = call_node
23
+ end
24
+
25
+ attr_reader(:call_node)
26
+
27
+ def name
28
+ call_node.name.to_s
29
+ end
30
+
31
+ def line
32
+ call_node.location.start_line
33
+ end
34
+
35
+ def tag?
36
+ PhlexNode.html_tag?(name)
37
+ end
38
+
39
+ def helper?
40
+ !tag?
41
+ end
42
+
43
+ def block
44
+ call_node.block
45
+ end
46
+
47
+ # The block as a Prism::BlockNode, or nil for `&block` forwards
48
+ # and calls without a block.
49
+ def block_node
50
+ block.is_a?(Prism::BlockNode) ? block : nil
51
+ end
52
+
53
+ # `tag(&block)` defers content to the caller; we can't see what
54
+ # they emit, so trust the wrapper rather than report a false
55
+ # positive.
56
+ def forwarded_block?
57
+ block.is_a?(Prism::BlockArgumentNode)
58
+ end
59
+
60
+ def block_children
61
+ block_node ? block_node.child_nodes.compact : []
62
+ end
63
+
64
+ def block_has_text?(children = [])
65
+ BlockTextScanner.scan(block_node, children: children)
66
+ end
67
+
68
+ # Phlex's tag API emits the first positional argument as text
69
+ # content (`a("Click me", href: "/x")` is equivalent to
70
+ # `a(href: "/x") { "Click me" }`).
71
+ def arg_has_text?
72
+ arg = first_positional_arg
73
+ return false if arg.nil?
74
+
75
+ BlockTextScanner.text_emitting?(arg)
76
+ end
77
+
78
+ def first_positional_arg
79
+ return nil unless call_node.arguments
80
+
81
+ call_node.arguments.arguments.find do |arg|
82
+ !arg.is_a?(Prism::KeywordHashNode) &&
83
+ !arg.is_a?(Prism::BlockArgumentNode)
84
+ end
85
+ end
86
+
87
+ def class_values
88
+ PhlexNode.kwarg_class_values(call_node)
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "prism"
4
+ require_relative "phlex_runner/block_text_scanner"
5
+ require_relative "phlex_runner/phlex_call"
4
6
 
5
7
  module A11y
6
8
  module Lint
@@ -9,19 +11,6 @@ module A11y
9
11
  class PhlexRunner
10
12
  PHLEX_PATTERN = /\bdef\s+view_template\b/
11
13
 
12
- # Phlex auto-emits the return value of these calls into the document:
13
- # `plain` / `text` are built-in; the rest are registered as value
14
- # helpers by phlex-rails via `register_value_helper`.
15
- TEXT_CALLS = %w[
16
- plain text
17
- t translate l localize
18
- pluralize truncate
19
- number_to_currency number_to_human number_to_human_size
20
- number_to_percentage number_to_phone
21
- number_with_delimiter number_with_precision
22
- highlight excerpt
23
- ].to_set.freeze
24
-
25
14
  def initialize(rules = nil, configuration: Configuration.new)
26
15
  @rules = rules || configuration.enabled_rules
27
16
  @configuration = configuration
@@ -43,27 +32,25 @@ module A11y
43
32
  attr_reader :rules, :configuration
44
33
 
45
34
  def walk(node)
46
- if receiverless_call?(node)
47
- process_call(node)
35
+ if (call = PhlexCall.wrap(node))
36
+ process_call(call)
48
37
  else
49
38
  node.child_nodes.compact.each { |c| walk(c) }
50
39
  end
51
40
  end
52
41
 
53
- def process_call(node)
54
- if PhlexNode.html_tag?(node.name.to_s)
55
- check_tag(node)
56
- else
57
- check_helper(node)
58
- end
42
+ def process_call(call)
43
+ call.tag? ? check_tag(call) : check_helper(call)
59
44
  end
60
45
 
61
- def check_tag(node)
62
- children = collect_block_children(node.block)
63
- has_text = tag_block_has_text?(node.block, children)
46
+ def check_tag(call)
47
+ children = collect_block_children(call.block_node)
48
+ has_text = call.forwarded_block? ||
49
+ call.arg_has_text? ||
50
+ call.block_has_text?(children)
64
51
  check_node(
65
52
  PhlexNode.build_tag(
66
- node,
53
+ call.call_node,
67
54
  children: children,
68
55
  text_content: has_text,
69
56
  configuration: configuration
@@ -71,114 +58,100 @@ module A11y
71
58
  )
72
59
  end
73
60
 
74
- def check_helper(node)
75
- codes, has_text = analyze_helper_block(node)
61
+ def check_helper(call)
62
+ codes, has_text = analyze_helper_block(call)
76
63
  helper = PhlexNode.build_helper(
77
- node,
64
+ call.call_node,
78
65
  block_body_codes: codes,
79
66
  block_has_text_children: has_text,
80
67
  configuration: configuration
81
68
  )
82
69
  check_node(helper)
83
- walk_block(node.block)
70
+ walk_block(call.block_node)
84
71
  end
85
72
 
86
- def collect_block_children(block)
87
- return [] unless block.is_a?(Prism::BlockNode)
73
+ def collect_block_children(block_node)
74
+ return [] unless block_node
88
75
 
89
- [].tap { |c| gather_children(block, c) }
76
+ [].tap { |c| gather_children(block_node, c) }
90
77
  end
91
78
 
92
79
  def gather_children(parent, result)
93
80
  parent.child_nodes.compact.each do |child|
94
- if tag_call?(child)
95
- gather_tag_child(child, result)
96
- elsif receiverless_call?(child)
97
- check_helper(child)
81
+ child_call = PhlexCall.wrap(child)
82
+ if child_call&.tag?
83
+ gather_tag_child(child_call, result)
84
+ elsif child_call
85
+ check_helper(child_call)
98
86
  else
99
87
  gather_children(child, result)
100
88
  end
101
89
  end
102
90
  end
103
91
 
104
- def gather_tag_child(child, result)
105
- kids = collect_block_children(child.block)
106
- has_text = tag_block_has_text?(child.block, kids)
92
+ def gather_tag_child(call, result)
93
+ kids = collect_block_children(call.block_node)
94
+ has_text = call.arg_has_text? || call.block_has_text?(kids)
107
95
  tag = PhlexNode.build_tag(
108
- child, children: kids, text_content: has_text,
109
- configuration: configuration
96
+ call.call_node, children: kids, text_content: has_text,
97
+ configuration: configuration
110
98
  )
111
99
  check_node(tag)
112
- result << tag unless hidden_wrapper_tag?(child)
100
+ result << tag unless hidden_wrapper_tag?(call)
113
101
  end
114
102
 
115
- def hidden_wrapper_tag?(call_node)
103
+ def hidden_wrapper_tag?(call)
116
104
  classes = configuration.hidden_wrapper_classes
117
105
  return false if classes.empty?
118
106
 
119
- tag_class_values(call_node).any? { |klass| classes.include?(klass) }
107
+ call.class_values.any? { |klass| classes.include?(klass) }
120
108
  end
121
109
 
122
- def tag_class_values(call_node)
123
- return [] unless call_node.arguments
124
-
125
- PhlexNode.kwarg_class_values(call_node)
126
- end
127
-
128
- def tag_block_has_text?(block, children)
129
- return false unless block.is_a?(Prism::BlockNode)
130
-
131
- scan_for_text(block) || children.any?(&:text_content?)
132
- end
133
-
134
- def scan_for_text(node)
135
- node.child_nodes.compact.any? do |child|
136
- text_call?(child) || child.is_a?(Prism::YieldNode) ||
137
- (!receiverless_call?(child) && scan_for_text(child))
138
- end
139
- end
140
-
141
- def analyze_helper_block(call_node)
142
- block = call_node.block
143
- return [nil, false] unless block.is_a?(Prism::BlockNode)
110
+ def analyze_helper_block(call)
111
+ block = call.block_node
112
+ return [nil, false] unless block
144
113
 
145
114
  codes = []
146
115
  has_text = scan_block_content(block, codes)
147
116
  [codes.empty? ? nil : codes, has_text]
148
117
  end
149
118
 
150
- # rubocop:disable Metrics/CyclomaticComplexity
151
- # rubocop:disable Metrics/PerceivedComplexity
152
119
  def scan_block_content(node, codes)
153
120
  node.child_nodes.compact.each do |child|
154
- next if tag_call?(child) && hidden_wrapper_tag?(child)
155
- return true if child.is_a?(Prism::YieldNode)
156
- return true if tag_call?(child) && child.block
157
- next if tag_call?(child)
158
- next codes << child.slice if receiverless_call?(child)
159
- return true if scan_block_content(child, codes)
121
+ return true if visit_helper_block_child(child, codes)
160
122
  end
161
123
  false
162
124
  end
163
- # rubocop:enable Metrics/CyclomaticComplexity
164
- # rubocop:enable Metrics/PerceivedComplexity
165
125
 
166
- def walk_block(block)
167
- return unless block.is_a?(Prism::BlockNode)
126
+ def visit_helper_block_child(child, codes)
127
+ child_call = PhlexCall.wrap(child)
128
+ return handle_call_block_child(child_call, codes) if child_call
129
+
130
+ block_child_text?(child) || scan_block_content(child, codes)
131
+ end
132
+
133
+ def handle_call_block_child(child_call, codes)
134
+ return false if child_call.tag? && hidden_wrapper_tag?(child_call)
135
+ return true if tag_call_has_text_block?(child_call)
136
+ return false if child_call.tag?
168
137
 
169
- block.child_nodes.compact.each { |c| walk(c) }
138
+ codes << child_call.call_node.slice
139
+ false
170
140
  end
171
141
 
172
- def tag_call?(node)
173
- receiverless_call?(node) && PhlexNode.html_tag?(node.name.to_s)
142
+ def block_child_text?(child)
143
+ child.is_a?(Prism::YieldNode) ||
144
+ BlockTextScanner.non_blank_string_literal?(child)
174
145
  end
175
146
 
176
- def text_call?(node)
177
- receiverless_call?(node) && TEXT_CALLS.include?(node.name.to_s)
147
+ def tag_call_has_text_block?(child_call)
148
+ child_call.tag? && !child_call.block.nil?
178
149
  end
179
150
 
180
- def receiverless_call?(node)
181
- node.is_a?(Prism::CallNode) && node.receiver.nil?
151
+ def walk_block(block_node)
152
+ return unless block_node
153
+
154
+ block_node.child_nodes.compact.each { |c| walk(c) }
182
155
  end
183
156
 
184
157
  def check_node(node)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module A11y
4
4
  module Lint
5
- VERSION = "0.14.1"
5
+ VERSION = "0.14.2"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: a11y-lint
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.1
4
+ version: 0.14.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdullah Hashim
@@ -45,6 +45,7 @@ executables:
45
45
  extensions: []
46
46
  extra_rdoc_files: []
47
47
  files:
48
+ - ".claude/commands/implement.md"
48
49
  - ".claude/commands/task.md"
49
50
  - ".claude/rules/testing.md"
50
51
  - ".rubocop.yml"
@@ -117,6 +118,8 @@ files:
117
118
  - lib/a11y/lint/offense.rb
118
119
  - lib/a11y/lint/phlex_node.rb
119
120
  - lib/a11y/lint/phlex_runner.rb
121
+ - lib/a11y/lint/phlex_runner/block_text_scanner.rb
122
+ - lib/a11y/lint/phlex_runner/phlex_call.rb
120
123
  - lib/a11y/lint/phlex_tags.rb
121
124
  - lib/a11y/lint/ruby_code.rb
122
125
  - lib/a11y/lint/rules/perceivable/area_missing_alt.rb