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 +4 -4
- data/.claude/commands/implement.md +46 -0
- data/.rubocop.yml +1 -2
- data/CHANGELOG.md +9 -0
- data/lib/a11y/lint/cli.rb +1 -1
- data/lib/a11y/lint/phlex_node.rb +32 -3
- data/lib/a11y/lint/phlex_runner/block_text_scanner.rb +180 -0
- data/lib/a11y/lint/phlex_runner/phlex_call.rb +93 -0
- data/lib/a11y/lint/phlex_runner.rb +58 -85
- data/lib/a11y/lint/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 27003e9edd9b1055dd6c2ddd37ac6da8958bb3a608f5bb8fbfa103eb167ddb0e
|
|
4
|
+
data.tar.gz: 5af458356a27d126373c8780ca2cec362d7d2e05b70edfdb45236a90f70b82a9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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
data/lib/a11y/lint/phlex_node.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
129
|
-
|
|
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
|
|
47
|
-
process_call(
|
|
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(
|
|
54
|
-
|
|
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(
|
|
62
|
-
children = collect_block_children(
|
|
63
|
-
has_text =
|
|
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
|
-
|
|
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(
|
|
75
|
-
codes, has_text = analyze_helper_block(
|
|
61
|
+
def check_helper(call)
|
|
62
|
+
codes, has_text = analyze_helper_block(call)
|
|
76
63
|
helper = PhlexNode.build_helper(
|
|
77
|
-
|
|
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(
|
|
70
|
+
walk_block(call.block_node)
|
|
84
71
|
end
|
|
85
72
|
|
|
86
|
-
def collect_block_children(
|
|
87
|
-
return [] unless
|
|
73
|
+
def collect_block_children(block_node)
|
|
74
|
+
return [] unless block_node
|
|
88
75
|
|
|
89
|
-
[].tap { |c| gather_children(
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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(
|
|
105
|
-
kids = collect_block_children(
|
|
106
|
-
has_text =
|
|
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
|
-
|
|
109
|
-
|
|
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?(
|
|
100
|
+
result << tag unless hidden_wrapper_tag?(call)
|
|
113
101
|
end
|
|
114
102
|
|
|
115
|
-
def hidden_wrapper_tag?(
|
|
103
|
+
def hidden_wrapper_tag?(call)
|
|
116
104
|
classes = configuration.hidden_wrapper_classes
|
|
117
105
|
return false if classes.empty?
|
|
118
106
|
|
|
119
|
-
|
|
107
|
+
call.class_values.any? { |klass| classes.include?(klass) }
|
|
120
108
|
end
|
|
121
109
|
|
|
122
|
-
def
|
|
123
|
-
|
|
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
|
-
|
|
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
|
|
167
|
-
|
|
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
|
-
|
|
138
|
+
codes << child_call.call_node.slice
|
|
139
|
+
false
|
|
170
140
|
end
|
|
171
141
|
|
|
172
|
-
def
|
|
173
|
-
|
|
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
|
|
177
|
-
|
|
147
|
+
def tag_call_has_text_block?(child_call)
|
|
148
|
+
child_call.tag? && !child_call.block.nil?
|
|
178
149
|
end
|
|
179
150
|
|
|
180
|
-
def
|
|
181
|
-
|
|
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)
|
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.14.
|
|
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
|