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 +4 -4
- data/.claude/commands/prepare-release.md +62 -0
- data/CHANGELOG.md +10 -0
- data/lib/a11y/lint/configuration.rb +8 -3
- data/lib/a11y/lint/erb_element_node.rb +5 -5
- data/lib/a11y/lint/erb_runner.rb +7 -7
- data/lib/a11y/lint/phlex_node.rb +7 -0
- data/lib/a11y/lint/phlex_runner/block_text_scanner.rb +16 -5
- data/lib/a11y/lint/phlex_runner/phlex_call.rb +2 -6
- data/lib/a11y/lint/phlex_runner.rb +18 -5
- data/lib/a11y/lint/slim_node.rb +6 -6
- data/lib/a11y/lint/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8621f3bad2e339bfff4c19dda38dc6f9e46a1195dd832c1d41506435f7836701
|
|
4
|
+
data.tar.gz: e7522df50471a67ec8693b7f1a006190dff8d319628ed30ffd978a6540287692
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
46
|
-
@
|
|
47
|
-
Array(@config["
|
|
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
|
|
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
|
|
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
|
|
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
|
|
90
|
-
classes = configuration.
|
|
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) }
|
data/lib/a11y/lint/erb_runner.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
122
|
-
return if configuration.
|
|
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
|
|
125
|
+
if inaccessible_wrapper_element?(child)
|
|
126
126
|
child.remove
|
|
127
127
|
else
|
|
128
|
-
|
|
128
|
+
strip_inaccessible_wrappers!(child)
|
|
129
129
|
end
|
|
130
130
|
end
|
|
131
131
|
end
|
|
132
132
|
|
|
133
|
-
def
|
|
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.
|
|
137
|
+
classes = configuration.inaccessible_wrapper_classes
|
|
138
138
|
value.split.any? { |klass| classes.include?(klass) }
|
|
139
139
|
end
|
|
140
140
|
|
data/lib/a11y/lint/phlex_node.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
61
|
-
block_node
|
|
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? ||
|
|
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
|
|
104
|
+
result << tag unless inaccessible_wrapper_tag?(call)
|
|
101
105
|
end
|
|
102
106
|
|
|
103
|
-
def
|
|
104
|
-
|
|
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? &&
|
|
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
|
|
data/lib/a11y/lint/slim_node.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
120
|
+
def inaccessible_wrapper_sexp?(sexp)
|
|
121
121
|
return false unless html_tag_sexp?(sexp)
|
|
122
|
-
return false if configuration.
|
|
122
|
+
return false if configuration.inaccessible_wrapper_classes.empty?
|
|
123
123
|
|
|
124
124
|
class_values(sexp[3]).any? do |klass|
|
|
125
|
-
configuration.
|
|
125
|
+
configuration.inaccessible_wrapper_classes.include?(klass)
|
|
126
126
|
end
|
|
127
127
|
end
|
|
128
128
|
|
data/lib/a11y/lint/version.rb
CHANGED
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.
|
|
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"
|