a11y-lint 0.14.1 → 0.15.0

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: 8621f3bad2e339bfff4c19dda38dc6f9e46a1195dd832c1d41506435f7836701
4
+ data.tar.gz: e7522df50471a67ec8693b7f1a006190dff8d319628ed30ffd978a6540287692
5
5
  SHA512:
6
- metadata.gz: fa1db2cd985c446515c75808512e3663859af93d254949e51fb3638e0ee1fe7cf2172a55686f66b937476e8739a9f24910b8662ef03e285445dfab9079983be3
7
- data.tar.gz: 045c3ec7a7e4442a47517020aa2cf3da15496a3e304a1768e8e067d7767f2cd0693db025711f25c05e02736c6d3f0095378e9dfb68ed1a3b19ebcb3e8232a49e
6
+ metadata.gz: c18b6aea457244af85b7e21d749f32841c0cd45cb898eda52a0f8e271478ee0ad99da9f1f539c5c6ae97537edfe212b820b3139a6865dcc46a77396511c2d2f8
7
+ data.tar.gz: f9dc521203ff94d686ef5d4cb01cf0d382ff18d1be8e83807197a8de54e0dba441fbe0d23fd6301fac0523c13d582d70ab4c5763eaf002ecb9c6963e7579c78f
@@ -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.
@@ -0,0 +1,62 @@
1
+ ---
2
+ description: Scaffold gem release prep — preflight, bump, smoke, then push a release branch and open a PR
3
+ argument-hint: [patch|minor|major|X.Y.Z]
4
+ allowed-tools: Bash(git status:*), Bash(git fetch:*), Bash(git log:*), Bash(git diff:*), Bash(git tag:*), Bash(git rev-parse:*), Bash(git rev-list:*), Bash(git branch:*), Bash(git checkout:*), Bash(git switch:*), Bash(git restore:*), Bash(git add:*), Bash(git commit:*), Bash(git push:*), Bash(gh pr create:*), Bash(gh pr view:*), Bash(bundle install:*), Bash(bundle exec:*), Bash(grep:*), Bash(rg:*), Bash(ls:*), Bash(date:*), Read, Edit, Write, Glob, Grep
5
+ ---
6
+
7
+ Prepare a gem release. Bump kind: **$ARGUMENTS** (empty = infer from commits since last tag).
8
+
9
+ End state: a `release-X.Y.Z` branch pushed to origin with an open PR titled `Release X.Y.Z` and body `🎉`. The PR diff is the review surface — the CHANGELOG is the release notes.
10
+
11
+ The user runs the irreversible steps after the PR merges. **Never** run `bundle exec rake release`, `gem push pkg/a11y-lint-X.Y.Z.gem`, or `git tag vX.Y.Z && git push origin vX.Y.Z`.
12
+
13
+ ## Preflight
14
+
15
+ Bail loudly on any failure — don't try to fix state silently.
16
+
17
+ - On `main`, working tree clean, not behind `origin/main` (`git fetch origin main` then `git rev-list --count HEAD..origin/main` → `0`).
18
+ - `lib/a11y/lint/version.rb` `VERSION`, `Gemfile.lock`'s `a11y-lint (X.Y.Z)` line under `PATH → specs:`, and the latest `git tag --sort=-v:refname | head -1` (stripped of leading `v`) all agree. A mismatch usually means a prior release was half-finished — surface it and stop.
19
+
20
+ ## Bump
21
+
22
+ If `$ARGUMENTS` is empty, infer from `git log <latest_tag>..HEAD`:
23
+
24
+ - Any commit subject containing `Breaking`, or starting with `Add ` or `Change ` → **minor**
25
+ - Otherwise → **patch**
26
+
27
+ This gem is pre-1.0; breaking changes still bump the minor (no major path). Print the inferred bump and the resulting `X.Y.Z`, then proceed without confirming.
28
+
29
+ If `$ARGUMENTS` is `patch`/`minor`/`major`, bump that segment. If it's `X.Y.Z`, use literally. Anything else → bail.
30
+
31
+ ## Apply
32
+
33
+ 1. Edit `lib/a11y/lint/version.rb` — replace the `VERSION = "..."` literal.
34
+ 2. Edit `CHANGELOG.md` — insert `## [X.Y.Z] - YYYY-MM-DD` (today, absolute via `date +%Y-%m-%d`) directly below `## [Unreleased]` with blank lines around it. The Unreleased entries shift down to attribute the new release; the new Unreleased block stays empty.
35
+ 3. `bundle install` — should change only `Gemfile.lock`'s `a11y-lint (X.Y.Z)` line. Surface anything else.
36
+
37
+ ## Verify
38
+
39
+ - `bundle exec rake` — tests + RuboCop. Bail on red.
40
+ - `bundle exec a11y-lint test/fixtures/dummy_app` — capture offense count. Then run the same command against the prior release's fixtures with the *current* linter to isolate any linter regression on stable input:
41
+ ```
42
+ git checkout <latest_tag> -- test/fixtures/dummy_app
43
+ bundle exec a11y-lint test/fixtures/dummy_app
44
+ git checkout HEAD -- test/fixtures/dummy_app
45
+ ```
46
+ Report `prev → current`. A non-trivial change is a linter regression — surface it.
47
+
48
+ ## Shape check
49
+
50
+ `git diff --stat` should touch only `lib/a11y/lint/version.rb`, `CHANGELOG.md`, `Gemfile.lock`. Anything else is a red flag — stop and ask before pushing.
51
+
52
+ ## Branch + PR
53
+
54
+ ```
55
+ git checkout -b release-X.Y.Z
56
+ git add lib/a11y/lint/version.rb CHANGELOG.md Gemfile.lock
57
+ git commit -m "Release X.Y.Z"
58
+ git push -u origin release-X.Y.Z
59
+ gh pr create --title "Release X.Y.Z" --body "🎉"
60
+ ```
61
+
62
+ Print the PR URL.
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,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.15.0] - 2026-05-06
11
+
12
+ ### Changed
13
+
14
+ - **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
15
+
16
+ ### Added
17
+
18
+ - `.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
19
+
20
+ ## [0.14.2] - 2026-05-05
21
+
22
+ ### Fixed
23
+
24
+ - `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
25
+ - `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
26
+ - `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
27
+ - `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
28
+
10
29
  ## [0.14.1] - 2026-05-03
11
30
 
12
31
  ### 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
 
@@ -42,9 +42,14 @@ module A11y
42
42
  @config.dig(rule_name, "Enabled") != false
43
43
  end
44
44
 
45
- def hidden_wrapper_classes
46
- @hidden_wrapper_classes ||=
47
- Array(@config["hidden_wrapper_classes"]).map(&:to_s).freeze
45
+ def inaccessible_wrapper_classes
46
+ @inaccessible_wrapper_classes ||=
47
+ Array(@config["inaccessible_wrapper_classes"]).map(&:to_s).freeze
48
+ end
49
+
50
+ def accessible_wrapper_classes
51
+ @accessible_wrapper_classes ||=
52
+ Array(@config["accessible_wrapper_classes"]).map(&:to_s).freeze
48
53
  end
49
54
 
50
55
  def enabled_rules
@@ -47,7 +47,7 @@ module A11y
47
47
  end
48
48
 
49
49
  def text_content?
50
- return false if hidden_wrapper?(@nokogiri_node)
50
+ return false if inaccessible_wrapper?(@nokogiri_node)
51
51
 
52
52
  visible_text_or_output?(@nokogiri_node)
53
53
  end
@@ -58,7 +58,7 @@ module A11y
58
58
  # to the accessible name.
59
59
  def children
60
60
  @nokogiri_node.element_children.filter_map do |child|
61
- next if hidden_wrapper?(child)
61
+ next if inaccessible_wrapper?(child)
62
62
 
63
63
  ErbElementNode.new(
64
64
  nokogiri_node: child, line: child.line,
@@ -70,7 +70,7 @@ module A11y
70
70
  private
71
71
 
72
72
  def visible_text_or_output?(node)
73
- return false if hidden_wrapper?(node)
73
+ return false if inaccessible_wrapper?(node)
74
74
  return true if own_text_or_marker?(node)
75
75
 
76
76
  node.element_children.any? { |c| visible_text_or_output?(c) }
@@ -86,8 +86,8 @@ module A11y
86
86
  end
87
87
  end
88
88
 
89
- def hidden_wrapper?(node)
90
- classes = configuration.hidden_wrapper_classes
89
+ def inaccessible_wrapper?(node)
90
+ classes = configuration.inaccessible_wrapper_classes
91
91
  return false if classes.empty?
92
92
 
93
93
  node_classes(node).any? { |klass| classes.include?(klass) }
@@ -98,7 +98,7 @@ module A11y
98
98
  indexed_codes = []
99
99
  html = indexed_marker_html(block_content, indexed_codes)
100
100
  fragment = Nokogiri::HTML4::DocumentFragment.parse(html)
101
- strip_hidden_wrappers!(fragment)
101
+ strip_inaccessible_wrappers!(fragment)
102
102
 
103
103
  remaining = fragment.to_html
104
104
  visible_codes = indexed_codes.each_with_index.filter_map do |code, i|
@@ -118,23 +118,23 @@ module A11y
118
118
  !html.gsub(/#{ERB_OUTPUT_MARKER}\d+_/, "").strip.empty?
119
119
  end
120
120
 
121
- def strip_hidden_wrappers!(node)
122
- return if configuration.hidden_wrapper_classes.empty?
121
+ def strip_inaccessible_wrappers!(node)
122
+ return if configuration.inaccessible_wrapper_classes.empty?
123
123
 
124
124
  node.element_children.each do |child|
125
- if hidden_wrapper_element?(child)
125
+ if inaccessible_wrapper_element?(child)
126
126
  child.remove
127
127
  else
128
- strip_hidden_wrappers!(child)
128
+ strip_inaccessible_wrappers!(child)
129
129
  end
130
130
  end
131
131
  end
132
132
 
133
- def hidden_wrapper_element?(node)
133
+ def inaccessible_wrapper_element?(node)
134
134
  value = node.attributes["class"]&.value
135
135
  return false unless value.is_a?(String)
136
136
 
137
- classes = configuration.hidden_wrapper_classes
137
+ classes = configuration.inaccessible_wrapper_classes
138
138
  value.split.any? { |klass| classes.include?(klass) }
139
139
  end
140
140
 
@@ -85,6 +85,13 @@ module A11y
85
85
  )
86
86
  end
87
87
 
88
+ # Resolves a Phlex tag call's `class:` kwarg to a list of class names.
89
+ # Intentionally narrow: only matches a literal `Prism::StringNode`.
90
+ # Misses array form (`class: ["sr-only", other]`), interpolation
91
+ # (`class: "sr-only #{foo}"`), helper-wrapped (`class: cn("sr-only")`),
92
+ # and computed values (`class: some_method`). Wrapper-class config
93
+ # (`inaccessible_wrapper_classes`, `accessible_wrapper_classes`)
94
+ # therefore only matches authors who write a single static literal.
88
95
  def self.kwarg_class_values(call_node)
89
96
  return [] unless call_node.arguments
90
97
 
@@ -100,7 +107,31 @@ module A11y
100
107
 
101
108
  kwarg_nodes(call_node).each_with_object({}) do |elem, h|
102
109
  key = kwarg_key(elem.key)
103
- h[key] = kwarg_value(elem.value) if key
110
+ next unless key
111
+
112
+ if elem.value.is_a?(Prism::HashNode)
113
+ flatten_nested_hash(key, elem.value, h)
114
+ else
115
+ h[key] = kwarg_value(elem.value)
116
+ end
117
+ end
118
+ end
119
+
120
+ # Mirrors Phlex's render-time flattening of nested kwarg hashes
121
+ # (e.g. `aria: { label: "x" }` -> `aria-label="x"`). Inner key
122
+ # underscores become dashes the same way Phlex/Rails do.
123
+ def self.flatten_nested_hash(prefix, hash_node, attrs)
124
+ hash_node.elements.each do |inner|
125
+ next unless inner.is_a?(Prism::AssocNode)
126
+ next unless (inner_key = kwarg_key(inner.key))
127
+
128
+ full_key = "#{prefix}-#{inner_key.tr("_", "-")}"
129
+
130
+ if inner.value.is_a?(Prism::HashNode)
131
+ flatten_nested_hash(full_key, inner.value, attrs)
132
+ else
133
+ attrs[full_key] = kwarg_value(inner.value)
134
+ end
104
135
  end
105
136
  end
106
137
 
@@ -125,8 +156,13 @@ module A11y
125
156
  end
126
157
  end
127
158
 
128
- private_class_method :kwarg_key, :kwarg_nodes, :kwarg_value,
129
- :extract_attributes
159
+ private_class_method(
160
+ :extract_attributes,
161
+ :flatten_nested_hash,
162
+ :kwarg_key,
163
+ :kwarg_nodes,
164
+ :kwarg_value
165
+ )
130
166
  end
131
167
  end
132
168
  end
@@ -0,0 +1,191 @@
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:, accessible_wrapper: false)
26
+ new(block, children:, accessible_wrapper:).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, children: []).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:, accessible_wrapper: false)
54
+ @block = block
55
+ @children = children
56
+ @accessible_wrapper = accessible_wrapper
57
+ end
58
+
59
+ def scan
60
+ return false unless @block.is_a?(Prism::BlockNode)
61
+
62
+ scan_for_text(@block) || @children.any?(&:text_content?)
63
+ end
64
+
65
+ def text_emitting?(node)
66
+ auto_emitted_text?(node)
67
+ end
68
+
69
+ private
70
+
71
+ # A Phlex tag block produces accessible text if any statement
72
+ # contains an explicit text marker (yield, plain/text/t/..., a
73
+ # non-blank string literal), or if the block's last expression
74
+ # auto-emits a value Phlex renders as text.
75
+ def scan_for_text(block)
76
+ statements = block_statements(block)
77
+ return false if statements.empty?
78
+
79
+ return true if statements.any? { |stmt| explicit_text?(stmt) }
80
+
81
+ auto_emitted_text?(statements.last)
82
+ end
83
+
84
+ def block_statements(block)
85
+ return [] unless block.is_a?(Prism::BlockNode)
86
+
87
+ body = block.body
88
+ body.is_a?(Prism::StatementsNode) ? body.body : []
89
+ end
90
+
91
+ def explicit_text?(node)
92
+ return false if node.nil?
93
+ return true if node.is_a?(Prism::YieldNode)
94
+ return true if self.class.non_blank_string_literal?(node)
95
+ return text_call?(node) if node.is_a?(Prism::CallNode)
96
+
97
+ # Recurse only into conditional/grouping nodes — descending into
98
+ # call args would treat string literals like `foo("bar")`'s "bar"
99
+ # as block text, which it isn't.
100
+ return false unless conditional_container?(node)
101
+
102
+ node.child_nodes.compact.any? { |c| explicit_text?(c) }
103
+ end
104
+
105
+ def auto_emitted_text?(node)
106
+ return false if node.nil?
107
+ return true if explicit_text?(node)
108
+
109
+ case node
110
+ when Prism::LocalVariableReadNode
111
+ true
112
+ when Prism::CallNode
113
+ auto_emitted_call?(node)
114
+ when Prism::IfNode, Prism::UnlessNode
115
+ if_branch_lasts(node).any? { |last| auto_emitted_text?(last) }
116
+ end
117
+ end
118
+
119
+ def auto_emitted_call?(call)
120
+ return true if call.receiver
121
+
122
+ name = call.name.to_s
123
+ if PhlexNode.html_tag?(name)
124
+ return accessible_wrapper? && bare_call?(call)
125
+ end
126
+ return true if TEXT_CALLS.include?(name)
127
+
128
+ # Lowercase receiverless calls (locals, helper methods) auto-emit
129
+ # their return value. Capitalized names are Phlex components,
130
+ # which emit their own HTML, not text.
131
+ lowercase_name?(name)
132
+ end
133
+
134
+ def bare_call?(call)
135
+ call.arguments.nil? && call.block.nil?
136
+ end
137
+
138
+ def accessible_wrapper?
139
+ @accessible_wrapper
140
+ end
141
+
142
+ def conditional_container?(node)
143
+ node.is_a?(Prism::IfNode) ||
144
+ node.is_a?(Prism::UnlessNode) ||
145
+ node.is_a?(Prism::BeginNode) ||
146
+ node.is_a?(Prism::StatementsNode) ||
147
+ node.is_a?(Prism::ElseNode) ||
148
+ node.is_a?(Prism::ParenthesesNode)
149
+ end
150
+
151
+ def lowercase_name?(name)
152
+ first = name[0]
153
+ first && first == first.downcase && first != first.upcase
154
+ end
155
+
156
+ def if_branch_lasts(node)
157
+ if_branch_statements(node).flat_map do |stmts|
158
+ stmts.is_a?(Prism::StatementsNode) ? [stmts.body.last].compact : []
159
+ end
160
+ end
161
+
162
+ def if_branch_statements(node)
163
+ branches = [node.statements]
164
+ collect_else_branches(if_else_clause(node), branches)
165
+ branches.compact
166
+ end
167
+
168
+ def collect_else_branches(current, branches)
169
+ while current.is_a?(Prism::IfNode) || current.is_a?(Prism::UnlessNode)
170
+ branches << current.statements
171
+ current = if_else_clause(current)
172
+ end
173
+ branches << current.statements if current.is_a?(Prism::ElseNode)
174
+ end
175
+
176
+ def if_else_clause(node)
177
+ case node
178
+ when Prism::IfNode then node.subsequent
179
+ when Prism::UnlessNode then node.else_clause
180
+ end
181
+ end
182
+
183
+ def text_call?(node)
184
+ node.is_a?(Prism::CallNode) &&
185
+ node.receiver.nil? &&
186
+ TEXT_CALLS.include?(node.name.to_s)
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,89 @@
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_has_text?(children = [], accessible_wrapper: false)
61
+ BlockTextScanner.scan(block_node, children:, accessible_wrapper:)
62
+ end
63
+
64
+ # Phlex's tag API emits the first positional argument as text
65
+ # content (`a("Click me", href: "/x")` is equivalent to
66
+ # `a(href: "/x") { "Click me" }`).
67
+ def arg_has_text?
68
+ arg = first_positional_arg
69
+ return false if arg.nil?
70
+
71
+ BlockTextScanner.text_emitting?(arg)
72
+ end
73
+
74
+ def first_positional_arg
75
+ return nil unless call_node.arguments
76
+
77
+ call_node.arguments.arguments.find do |arg|
78
+ !arg.is_a?(Prism::KeywordHashNode) &&
79
+ !arg.is_a?(Prism::BlockArgumentNode)
80
+ end
81
+ end
82
+
83
+ def class_values
84
+ PhlexNode.kwarg_class_values(call_node)
85
+ end
86
+ end
87
+ end
88
+ end
89
+ 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,113 @@ 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? ||
95
+ call.block_has_text?(
96
+ kids,
97
+ accessible_wrapper: accessible_wrapper_tag?(call)
98
+ )
107
99
  tag = PhlexNode.build_tag(
108
- child, children: kids, text_content: has_text,
109
- configuration: configuration
100
+ call.call_node, children: kids, text_content: has_text,
101
+ configuration: configuration
110
102
  )
111
103
  check_node(tag)
112
- result << tag unless hidden_wrapper_tag?(child)
104
+ result << tag unless inaccessible_wrapper_tag?(call)
113
105
  end
114
106
 
115
- def hidden_wrapper_tag?(call_node)
116
- classes = configuration.hidden_wrapper_classes
117
- return false if classes.empty?
118
-
119
- tag_class_values(call_node).any? { |klass| classes.include?(klass) }
107
+ def inaccessible_wrapper_tag?(call)
108
+ wrapper_class_match?(call, configuration.inaccessible_wrapper_classes)
120
109
  end
121
110
 
122
- def tag_class_values(call_node)
123
- return [] unless call_node.arguments
124
-
125
- PhlexNode.kwarg_class_values(call_node)
111
+ def accessible_wrapper_tag?(call)
112
+ wrapper_class_match?(
113
+ call, configuration.accessible_wrapper_classes
114
+ )
126
115
  end
127
116
 
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
117
+ def wrapper_class_match?(call, classes)
118
+ return false if classes.empty?
133
119
 
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
120
+ call.class_values.any? { |klass| classes.include?(klass) }
139
121
  end
140
122
 
141
- def analyze_helper_block(call_node)
142
- block = call_node.block
143
- return [nil, false] unless block.is_a?(Prism::BlockNode)
123
+ def analyze_helper_block(call)
124
+ block = call.block_node
125
+ return [nil, false] unless block
144
126
 
145
127
  codes = []
146
128
  has_text = scan_block_content(block, codes)
147
129
  [codes.empty? ? nil : codes, has_text]
148
130
  end
149
131
 
150
- # rubocop:disable Metrics/CyclomaticComplexity
151
- # rubocop:disable Metrics/PerceivedComplexity
152
132
  def scan_block_content(node, codes)
153
133
  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)
134
+ return true if visit_helper_block_child(child, codes)
160
135
  end
161
136
  false
162
137
  end
163
- # rubocop:enable Metrics/CyclomaticComplexity
164
- # rubocop:enable Metrics/PerceivedComplexity
165
138
 
166
- def walk_block(block)
167
- return unless block.is_a?(Prism::BlockNode)
139
+ def visit_helper_block_child(child, codes)
140
+ child_call = PhlexCall.wrap(child)
141
+ return handle_call_block_child(child_call, codes) if child_call
142
+
143
+ block_child_text?(child) || scan_block_content(child, codes)
144
+ end
145
+
146
+ def handle_call_block_child(child_call, codes)
147
+ return false if child_call.tag? && inaccessible_wrapper_tag?(child_call)
148
+ return true if tag_call_has_text_block?(child_call)
149
+ return false if child_call.tag?
168
150
 
169
- block.child_nodes.compact.each { |c| walk(c) }
151
+ codes << child_call.call_node.slice
152
+ false
170
153
  end
171
154
 
172
- def tag_call?(node)
173
- receiverless_call?(node) && PhlexNode.html_tag?(node.name.to_s)
155
+ def block_child_text?(child)
156
+ child.is_a?(Prism::YieldNode) ||
157
+ BlockTextScanner.non_blank_string_literal?(child)
174
158
  end
175
159
 
176
- def text_call?(node)
177
- receiverless_call?(node) && TEXT_CALLS.include?(node.name.to_s)
160
+ def tag_call_has_text_block?(child_call)
161
+ child_call.tag? && !child_call.block.nil?
178
162
  end
179
163
 
180
- def receiverless_call?(node)
181
- node.is_a?(Prism::CallNode) && node.receiver.nil?
164
+ def walk_block(block_node)
165
+ return unless block_node
166
+
167
+ block_node.child_nodes.compact.each { |c| walk(c) }
182
168
  end
183
169
 
184
170
  def check_node(node)
@@ -91,7 +91,7 @@ module A11y
91
91
 
92
92
  def collect_output_codes(sexp)
93
93
  return [] unless sexp.is_a?(Array)
94
- return [] if hidden_wrapper_sexp?(sexp)
94
+ return [] if inaccessible_wrapper_sexp?(sexp)
95
95
  return [sexp[3]] if slim_output_sexp?(sexp)
96
96
 
97
97
  sexp.flat_map { |child| collect_output_codes(child) }
@@ -103,7 +103,7 @@ module A11y
103
103
 
104
104
  def block_text_content?(sexp)
105
105
  return false unless sexp.is_a?(Array)
106
- return false if hidden_wrapper_sexp?(sexp)
106
+ return false if inaccessible_wrapper_sexp?(sexp)
107
107
  return true if slim_text_sexp?(sexp) || html_tag_sexp?(sexp)
108
108
 
109
109
  sexp.any? { |child| block_text_content?(child) }
@@ -111,18 +111,18 @@ module A11y
111
111
 
112
112
  def text_or_output?(sexp)
113
113
  return false unless sexp.is_a?(Array)
114
- return false if hidden_wrapper_sexp?(sexp)
114
+ return false if inaccessible_wrapper_sexp?(sexp)
115
115
  return true if slim_text_sexp?(sexp) || slim_output_sexp?(sexp)
116
116
 
117
117
  sexp.any? { |child| text_or_output?(child) }
118
118
  end
119
119
 
120
- def hidden_wrapper_sexp?(sexp)
120
+ def inaccessible_wrapper_sexp?(sexp)
121
121
  return false unless html_tag_sexp?(sexp)
122
- return false if configuration.hidden_wrapper_classes.empty?
122
+ return false if configuration.inaccessible_wrapper_classes.empty?
123
123
 
124
124
  class_values(sexp[3]).any? do |klass|
125
- configuration.hidden_wrapper_classes.include?(klass)
125
+ configuration.inaccessible_wrapper_classes.include?(klass)
126
126
  end
127
127
  end
128
128
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module A11y
4
4
  module Lint
5
- VERSION = "0.14.1"
5
+ VERSION = "0.15.0"
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.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdullah Hashim
@@ -45,6 +45,8 @@ executables:
45
45
  extensions: []
46
46
  extra_rdoc_files: []
47
47
  files:
48
+ - ".claude/commands/implement.md"
49
+ - ".claude/commands/prepare-release.md"
48
50
  - ".claude/commands/task.md"
49
51
  - ".claude/rules/testing.md"
50
52
  - ".rubocop.yml"
@@ -117,6 +119,8 @@ files:
117
119
  - lib/a11y/lint/offense.rb
118
120
  - lib/a11y/lint/phlex_node.rb
119
121
  - lib/a11y/lint/phlex_runner.rb
122
+ - lib/a11y/lint/phlex_runner/block_text_scanner.rb
123
+ - lib/a11y/lint/phlex_runner/phlex_call.rb
120
124
  - lib/a11y/lint/phlex_tags.rb
121
125
  - lib/a11y/lint/ruby_code.rb
122
126
  - lib/a11y/lint/rules/perceivable/area_missing_alt.rb