a11y-lint 0.14.2 → 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: 27003e9edd9b1055dd6c2ddd37ac6da8958bb3a608f5bb8fbfa103eb167ddb0e
4
- data.tar.gz: 5af458356a27d126373c8780ca2cec362d7d2e05b70edfdb45236a90f70b82a9
3
+ metadata.gz: 8621f3bad2e339bfff4c19dda38dc6f9e46a1195dd832c1d41506435f7836701
4
+ data.tar.gz: e7522df50471a67ec8693b7f1a006190dff8d319628ed30ffd978a6540287692
5
5
  SHA512:
6
- metadata.gz: a03ec8dba3eea02eaf7195e6b06f7ef60e628fbe4fd0de1a36c646123e5a335f8244fb10d6fe1a10f3570d95d133e9e32ccdd3ab0619401b93ccf82c6040c4d7
7
- data.tar.gz: 632ec73a902867c26723f05814717ec53f8f32df73d3d9db7f99afc5cfd1530899cb418e1172b4076de7b0b2f72a48f2ce179f08c506ec7a013cf4ebcae6b625
6
+ metadata.gz: c18b6aea457244af85b7e21d749f32841c0cd45cb898eda52a0f8e271478ee0ad99da9f1f539c5c6ae97537edfe212b820b3139a6865dcc46a77396511c2d2f8
7
+ data.tar.gz: f9dc521203ff94d686ef5d4cb01cf0d382ff18d1be8e83807197a8de54e0dba441fbe0d23fd6301fac0523c13d582d70ab4c5763eaf002ecb9c6963e7579c78f
@@ -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/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ 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
+
10
20
  ## [0.14.2] - 2026-05-05
11
21
 
12
22
  ### Fixed
@@ -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
 
@@ -22,15 +22,15 @@ module A11y
22
22
  highlight excerpt
23
23
  ].to_set.freeze
24
24
 
25
- def self.scan(block, children:)
26
- new(block, children).scan
25
+ def self.scan(block, children:, accessible_wrapper: false)
26
+ new(block, children:, accessible_wrapper:).scan
27
27
  end
28
28
 
29
29
  # Public recognizer for a single Prism node — used by PhlexRunner
30
30
  # to inspect a tag's first positional argument, where Phlex emits
31
31
  # the value as text content (`a("Click", href: "/x")`).
32
32
  def self.text_emitting?(node)
33
- new(nil, []).text_emitting?(node)
33
+ new(nil, children: []).text_emitting?(node)
34
34
  end
35
35
 
36
36
  def self.non_blank_string_literal?(node)
@@ -50,9 +50,10 @@ module A11y
50
50
  end
51
51
  end
52
52
 
53
- def initialize(block, children)
53
+ def initialize(block, children:, accessible_wrapper: false)
54
54
  @block = block
55
55
  @children = children
56
+ @accessible_wrapper = accessible_wrapper
56
57
  end
57
58
 
58
59
  def scan
@@ -119,7 +120,9 @@ module A11y
119
120
  return true if call.receiver
120
121
 
121
122
  name = call.name.to_s
122
- return false if PhlexNode.html_tag?(name)
123
+ if PhlexNode.html_tag?(name)
124
+ return accessible_wrapper? && bare_call?(call)
125
+ end
123
126
  return true if TEXT_CALLS.include?(name)
124
127
 
125
128
  # Lowercase receiverless calls (locals, helper methods) auto-emit
@@ -128,6 +131,14 @@ module A11y
128
131
  lowercase_name?(name)
129
132
  end
130
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
+
131
142
  def conditional_container?(node)
132
143
  node.is_a?(Prism::IfNode) ||
133
144
  node.is_a?(Prism::UnlessNode) ||
@@ -57,12 +57,8 @@ module A11y
57
57
  block.is_a?(Prism::BlockArgumentNode)
58
58
  end
59
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)
60
+ def block_has_text?(children = [], accessible_wrapper: false)
61
+ BlockTextScanner.scan(block_node, children:, accessible_wrapper:)
66
62
  end
67
63
 
68
64
  # Phlex's tag API emits the first positional argument as text
@@ -91,17 +91,30 @@ module A11y
91
91
 
92
92
  def gather_tag_child(call, result)
93
93
  kids = collect_block_children(call.block_node)
94
- has_text = call.arg_has_text? || call.block_has_text?(kids)
94
+ has_text = call.arg_has_text? ||
95
+ call.block_has_text?(
96
+ kids,
97
+ accessible_wrapper: accessible_wrapper_tag?(call)
98
+ )
95
99
  tag = PhlexNode.build_tag(
96
100
  call.call_node, children: kids, text_content: has_text,
97
101
  configuration: configuration
98
102
  )
99
103
  check_node(tag)
100
- result << tag unless hidden_wrapper_tag?(call)
104
+ result << tag unless inaccessible_wrapper_tag?(call)
101
105
  end
102
106
 
103
- def hidden_wrapper_tag?(call)
104
- classes = configuration.hidden_wrapper_classes
107
+ def inaccessible_wrapper_tag?(call)
108
+ wrapper_class_match?(call, configuration.inaccessible_wrapper_classes)
109
+ end
110
+
111
+ def accessible_wrapper_tag?(call)
112
+ wrapper_class_match?(
113
+ call, configuration.accessible_wrapper_classes
114
+ )
115
+ end
116
+
117
+ def wrapper_class_match?(call, classes)
105
118
  return false if classes.empty?
106
119
 
107
120
  call.class_values.any? { |klass| classes.include?(klass) }
@@ -131,7 +144,7 @@ module A11y
131
144
  end
132
145
 
133
146
  def handle_call_block_child(child_call, codes)
134
- return false if child_call.tag? && hidden_wrapper_tag?(child_call)
147
+ return false if child_call.tag? && inaccessible_wrapper_tag?(child_call)
135
148
  return true if tag_call_has_text_block?(child_call)
136
149
  return false if child_call.tag?
137
150
 
@@ -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.2"
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.2
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdullah Hashim
@@ -46,6 +46,7 @@ extensions: []
46
46
  extra_rdoc_files: []
47
47
  files:
48
48
  - ".claude/commands/implement.md"
49
+ - ".claude/commands/prepare-release.md"
49
50
  - ".claude/commands/task.md"
50
51
  - ".claude/rules/testing.md"
51
52
  - ".rubocop.yml"