a11y-lint 0.7.2 → 0.10.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: fcce561f67949c3ba5eb95447325e06ce67c2e759733c0623168ce5948183222
4
- data.tar.gz: 59a739dcb7b14d79d2b8c7a4bb135853c04f3f1e79558cf8ddc05c0435c52a18
3
+ metadata.gz: ecfe715cda47636bb8dc87f9cf3655c641d33d2e10cfd8471be73b2dd7336a1f
4
+ data.tar.gz: 818bb7d22993ac9036ac148d60adce32c2ef7ee342bc741bbd8e64d893414d2f
5
5
  SHA512:
6
- metadata.gz: d03e078e621d82b48f114f6f8a2ecfa7bf05f746892bf5640c6ee5e5817ac740ee086ddb6dab3a7c993436d653bbd38f15967696a58f41802d335a67a22ab2ea
7
- data.tar.gz: 1a07a2c565896cde540e09adc8352602166ce7da1ecdcb555c52220a8fc764504abab43bf17ebefb0fe436ca4cff3835d9bae355fd620d08b3f64c6dcf83d291
6
+ metadata.gz: 3a0d354908cfc0c670db3556f0854e1e91f0486ae502ff6f93395a95923d1a6b7b456bd625ffb476ba8f7e8d3f8bea5f859ed4875e0cf172770be5e87da8517f
7
+ data.tar.gz: e0e5e582649edc60f0df35635af190beb49b2ab69c9da392e9ae31326918c6704962b5a5cd6d60f8e1bb8bf47474a9d4b2fdb18baf424f3013871a7d3cc17a10
data/.rubocop.yml CHANGED
@@ -1,5 +1,5 @@
1
1
  AllCops:
2
- TargetRubyVersion: 3.1
2
+ TargetRubyVersion: 3.3
3
3
 
4
4
  Style/StringLiterals:
5
5
  EnforcedStyle: double_quotes
@@ -13,6 +13,12 @@ Layout/LineLength:
13
13
  - "a11y-lint.gemspec"
14
14
 
15
15
  Metrics/ClassLength:
16
+ Enabled: false
17
+
18
+ Metrics/ModuleLength:
19
+ Enabled: false
20
+
21
+ Metrics/AbcSize:
16
22
  Exclude:
17
23
  - "test/**/*"
18
24
 
data/CHANGELOG.md CHANGED
@@ -7,6 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.10.0] - 2026-04-15
11
+
12
+ ### Changed
13
+
14
+ - Introduce `A11y::Lint::CallNode` wrapper so rules express accessibility logic, not AST traversal
15
+ - Expose Prism `CallNode` from `SlimNode` and `ErbNode` via the new `RubyCode` parser
16
+ - Expose Prism `CallNode` from `PhlexNode` instead of converting to string
17
+ - Extract Phlex HTML tag constants into `PhlexTags` module
18
+
19
+ ## [0.9.0] - 2026-04-15
20
+
21
+ ### Changed
22
+
23
+ - **Breaking:** Require Ruby >= 3.3.0 (was 3.1.0). Prism ships with Ruby 3.3+ as a bundled gem
24
+ - **Breaking:** `slim` is now an optional dependency. Add `gem "slim"` to your Gemfile if you lint `.slim` files. A `SlimLoadError` is raised with a helpful message if the gem is missing
25
+ - `prism` is no longer a declared dependency (bundled with Ruby 3.3+)
26
+ - Replace Ripper with Prism for Ruby code parsing in `ImageTagMissingAlt` and `MissingAccessibleName` rules
27
+ - Custom error classes are now defined in `lib/a11y/lint/errors.rb`
28
+
29
+ ### Fixed
30
+
31
+ - `MissingAccessibleName`: fix false positive when block contains visible text or HTML tags (e.g. `link_to do` with a `span` or plain text inside)
32
+ - Add `block_body_codes` and `block_has_text_children?` to all three node types (Slim, ERB, Phlex) so block content inspection works across all pipelines
33
+
34
+ ## [0.8.0] - 2026-04-14
35
+
36
+ ### Added
37
+
38
+ - Phlex view support: scans `.rb` files containing Phlex components
39
+ - Detects Phlex files by the presence of a `def view_template` method
40
+ - All existing rules (`ImgMissingAlt`, `ImageTagMissingAlt`, `ListInvalidChildren`, `MissingAccessibleName`) work with Phlex views
41
+ - CLI automatically discovers `.rb` files when scanning directories
42
+
43
+ ### Changed
44
+
45
+ - **Breaking:** `Node` has been renamed to `SlimNode` for consistency with `ErbNode` and `PhlexNode`
46
+
47
+ ### Dependencies
48
+
49
+ - Added `prism` gem for Ruby AST parsing
50
+
10
51
  ## [0.7.2] - 2026-04-13
11
52
 
12
53
  ### Fixed
data/CLAUDE.md CHANGED
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
4
4
 
5
5
  ## Project Overview
6
6
 
7
- a11y-lint is a Ruby gem (v0.1.0) for accessibility linting. It uses the `A11y::Lint` module namespace. Requires Ruby >= 3.1.0.
7
+ a11y-lint is a Ruby gem (v0.1.0) for accessibility linting. It uses the `A11y::Lint` module namespace. Requires Ruby >= 3.3.0.
8
8
 
9
9
  ## Commands
10
10
 
@@ -19,19 +19,21 @@ a11y-lint is a Ruby gem (v0.1.0) for accessibility linting. It uses the `A11y::L
19
19
 
20
20
  ## Architecture
21
21
 
22
- - **CLI:** `lib/a11y/lint/cli.rb` — command-line interface; executable at `exe/a11y-lint`; routes `.slim` files to `SlimRunner` and `.erb` files to `ErbRunner`
22
+ - **CLI:** `lib/a11y/lint/cli.rb` — command-line interface; executable at `exe/a11y-lint`; routes `.slim` files to `SlimRunner`, `.erb` files to `ErbRunner`, and `.rb` files to `PhlexRunner`
23
23
  - **Entry point:** `lib/a11y/lint.rb` — defines the `A11y::Lint` module and `A11y::Lint::Error` exception
24
- - **Slim pipeline:** `SlimRunner` parses Slim templates via `Slim::Parser`; `Node` wraps Slim S-expressions
24
+ - **Slim pipeline:** `SlimRunner` parses Slim templates via `Slim::Parser`; `SlimNode` wraps Slim S-expressions
25
25
  - **ERB pipeline:** `ErbRunner` parses ERB templates via Nokogiri; `ErbNode` wraps Nokogiri nodes and extracted `<%= %>` Ruby code
26
+ - **Phlex pipeline:** `PhlexRunner` parses Phlex view classes via Prism; `PhlexNode` wraps Prism AST call nodes; detects Phlex files by the presence of `def view_template`
27
+ - **Ruby code parsing:** `RubyCode` (`lib/a11y/lint/ruby_code.rb`) — parses a Ruby code string once with Prism and exposes the resulting `CallNode`; used by `SlimNode` and `ErbNode` so rules work against a parsed AST instead of re-parsing strings
26
28
  - **Configuration:** `lib/a11y/lint/configuration.rb` — loads `.a11y-lint.yml` to enable/disable individual rules; searches upward from the target path
27
- - **Rules:** `lib/a11y/lint/rules/` — organized by WCAG principle (`perceivable/`, `operable/`, `understandable/`, `robust/`); rules implement `check(node)` against the shared node interface (`tag_name`, `attribute?`, `attributes`, `ruby_code`, `line`)
29
+ - **Rules:** `lib/a11y/lint/rules/` — organized by WCAG principle (`perceivable/`, `operable/`, `understandable/`, `robust/`); rules implement `check(node)` against the shared node interface (`tag_name`, `attribute?`, `attributes`, `call_node`, `line`)
28
30
  - **Version:** `lib/a11y/lint/version.rb`
29
31
  - **Type signatures (RBS):** `sig/a11y/lint.rbs`
30
32
  - **Tests:** `test/` directory using Minitest; test helper at `test/test_helper.rb`
31
- - **Dummy app:** `test/fixtures/dummy_app/` — a fixture app with Slim/ERB templates for end-to-end smoke testing before releases (`bundle exec a11y-lint test/fixtures/dummy_app`)
33
+ - **Dummy app:** `test/fixtures/dummy_app/` — a fixture app with Slim/ERB/Phlex templates for end-to-end smoke testing before releases (`bundle exec a11y-lint test/fixtures/dummy_app`)
32
34
 
33
35
  ## Code Style
34
36
 
35
37
  RuboCop is configured in `.rubocop.yml`:
36
- - Target Ruby version: 3.1
38
+ - Target Ruby version: 3.3
37
39
  - Enforces double quotes for strings
data/README.md CHANGED
@@ -33,7 +33,7 @@ a11y-lint app/views/
33
33
  a11y-lint app/views/home.html.slim app/views/about.html.slim
34
34
  ```
35
35
 
36
- With no arguments, it scans the current directory recursively for `.slim` and `.erb` files:
36
+ With no arguments, it scans the current directory recursively for `.slim`, `.erb`, and `.rb` (Phlex) files:
37
37
 
38
38
  ```bash
39
39
  a11y-lint
@@ -64,10 +64,21 @@ a11y-lint --config path/to/.a11y-lint.yml app/views/
64
64
  ```ruby
65
65
  require "a11y/lint"
66
66
 
67
+ # Slim
67
68
  source = File.read("app/views/home.html.slim")
68
- runner = A11y::Lint::SlimRunner.new([A11y::Lint::Rules::ImgMissingAlt.new])
69
+ runner = A11y::Lint::SlimRunner.new([A11y::Lint::Rules::ImgMissingAlt])
69
70
  offenses = runner.run(source, filename: "app/views/home.html.slim")
70
71
 
72
+ # ERB
73
+ source = File.read("app/views/home.html.erb")
74
+ runner = A11y::Lint::ErbRunner.new([A11y::Lint::Rules::ImgMissingAlt])
75
+ offenses = runner.run(source, filename: "app/views/home.html.erb")
76
+
77
+ # Phlex
78
+ source = File.read("app/views/home_view.rb")
79
+ runner = A11y::Lint::PhlexRunner.new([A11y::Lint::Rules::ImgMissingAlt])
80
+ offenses = runner.run(source, filename: "app/views/home_view.rb")
81
+
71
82
  offenses.each do |offense|
72
83
  puts "#{offense.filename}:#{offense.line} [#{offense.rule}] #{offense.message}"
73
84
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A11y
4
+ module Lint
5
+ # Shared block-content queries for node types.
6
+ # Depends on the host class implementing
7
+ # #block_has_text_children? and #block_body_codes.
8
+ module BlockInspection
9
+ ICON_HELPERS = %w[inline_svg icon image_tag svg_icon].freeze
10
+
11
+ def block_has_only_icon_helpers?
12
+ return false if block_has_text_children?
13
+
14
+ codes = block_body_codes
15
+ return true unless codes&.any?
16
+
17
+ codes.all? { |code| icon_helper_call?(code) }
18
+ end
19
+
20
+ private
21
+
22
+ def icon_helper_call?(code)
23
+ call = RubyCode.new(code).call_node
24
+ call && ICON_HELPERS.include?(call.method_name)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module A11y
6
+ module Lint
7
+ # Wraps a Prism::CallNode with a rule-friendly query API.
8
+ class CallNode
9
+ attr_reader :prism_node
10
+
11
+ def initialize(prism_node)
12
+ @prism_node = prism_node
13
+ end
14
+
15
+ def method_name
16
+ @prism_node.name.to_s
17
+ end
18
+
19
+ # Checks for a keyword argument by name.
20
+ # keyword?(:alt) => alt: or "alt" =>
21
+ # keyword?(:aria, :label) => aria: { label: ... }
22
+ # keyword?(:"aria-label") => "aria-label" =>
23
+ def keyword?(*keys)
24
+ return false unless (kw_hash = find_keyword_hash)
25
+
26
+ if keys.length == 1
27
+ flat_keyword?(kw_hash, keys[0])
28
+ else
29
+ nested_keyword?(kw_hash, keys[0], keys[1])
30
+ end
31
+ end
32
+
33
+ def positional_args
34
+ return [] unless @prism_node.arguments
35
+
36
+ @prism_node.arguments.arguments.reject do |a|
37
+ a.is_a?(Prism::KeywordHashNode)
38
+ end
39
+ end
40
+
41
+ def first_positional_arg_empty_string?
42
+ first = positional_args.first
43
+ first.is_a?(Prism::StringNode) && first.unescaped.empty?
44
+ end
45
+
46
+ def block?
47
+ !@prism_node.block.nil?
48
+ end
49
+
50
+ # Finds a receiverless call by method name in this node's
51
+ # subtree (including self). Returns a CallNode or nil.
52
+ def find(name)
53
+ found = search_for_call(@prism_node, name)
54
+ found ? self.class.new(found) : nil
55
+ end
56
+
57
+ private
58
+
59
+ def flat_keyword?(kw_hash, key)
60
+ kw_hash.elements.any? do |assoc|
61
+ key_name(assoc) == key.to_s
62
+ end
63
+ end
64
+
65
+ def nested_keyword?(kw_hash, outer_key, inner_key)
66
+ assoc = kw_hash.elements.find do |a|
67
+ key_name(a) == outer_key.to_s
68
+ end
69
+ return false unless assoc&.value.is_a?(Prism::HashNode)
70
+
71
+ assoc.value.elements.any? do |inner|
72
+ key_name(inner) == inner_key.to_s
73
+ end
74
+ end
75
+
76
+ def find_keyword_hash
77
+ return unless @prism_node.arguments
78
+
79
+ @prism_node.arguments.arguments.find do |arg|
80
+ arg.is_a?(Prism::KeywordHashNode)
81
+ end
82
+ end
83
+
84
+ def key_name(assoc)
85
+ return unless assoc.is_a?(Prism::AssocNode)
86
+
87
+ case assoc.key
88
+ when Prism::SymbolNode then assoc.key.unescaped
89
+ when Prism::StringNode then assoc.key.unescaped
90
+ end
91
+ end
92
+
93
+ def search_for_call(node, name)
94
+ if node.is_a?(Prism::CallNode) &&
95
+ node.receiver.nil? &&
96
+ node.name.to_s == name
97
+ return node
98
+ end
99
+
100
+ node.child_nodes.compact.each do |child|
101
+ found = search_for_call(child, name)
102
+ return found if found
103
+ end
104
+ nil
105
+ end
106
+ end
107
+ end
108
+ end
data/lib/a11y/lint/cli.rb CHANGED
@@ -4,8 +4,7 @@ require "optparse"
4
4
 
5
5
  module A11y
6
6
  module Lint
7
- # Command-line interface for running accessibility
8
- # linting on Slim templates.
7
+ # Command-line interface for running accessibility linting.
9
8
  class CLI
10
9
  def initialize(argv, stdout: $stdout, stderr: $stderr)
11
10
  @argv = argv
@@ -19,7 +18,7 @@ module A11y
19
18
  files = resolve_files(@argv)
20
19
 
21
20
  if files.empty?
22
- @stderr.puts("No .slim or .erb files found")
21
+ @stderr.puts("No .slim, .erb, or .rb files found")
23
22
  return 0
24
23
  end
25
24
 
@@ -62,7 +61,7 @@ module A11y
62
61
 
63
62
  def expand_path(path)
64
63
  if File.directory?(path)
65
- Dir.glob(File.join(path, "**", "*.{slim,erb}"))
64
+ Dir.glob(File.join(path, "**", "*.{slim,erb,rb}"))
66
65
  elsif File.file?(path)
67
66
  [path]
68
67
  else
@@ -75,14 +74,23 @@ module A11y
75
74
  rules = all_rules
76
75
  slim_runner = SlimRunner.new(rules)
77
76
  erb_runner = ErbRunner.new(rules)
77
+ phlex_runner = PhlexRunner.new(rules)
78
78
 
79
79
  files.flat_map do |file|
80
80
  source = File.read(file)
81
- runner = file.end_with?(".erb") ? erb_runner : slim_runner
81
+ runner = runner_for(file, slim_runner, erb_runner, phlex_runner)
82
82
  runner.run(source, filename: file)
83
83
  end
84
84
  end
85
85
 
86
+ def runner_for(file, slim_runner, erb_runner, phlex_runner)
87
+ case File.extname(file)
88
+ when ".erb" then erb_runner
89
+ when ".rb" then phlex_runner
90
+ else slim_runner
91
+ end
92
+ end
93
+
86
94
  def all_rules
87
95
  configuration = Configuration.load(
88
96
  @config_path,
@@ -5,16 +5,26 @@ module A11y
5
5
  # Wraps a Nokogiri node or extracted ERB output tag
6
6
  # as a queryable node for lint rules.
7
7
  class ErbNode
8
- attr_reader :line
8
+ include BlockInspection
9
9
 
10
- def initialize(line:, nokogiri_node: nil, ruby_code: nil)
11
- @nokogiri_node = nokogiri_node
12
- @ruby_code_string = ruby_code
10
+ attr_reader(:block_body_codes, :line, :ruby_code)
11
+
12
+ def initialize(
13
+ line:,
14
+ block_body_codes: nil,
15
+ block_has_text_children: false,
16
+ nokogiri_node: nil,
17
+ ruby_code: nil
18
+ )
13
19
  @line = line
20
+ @block_body_codes = block_body_codes
21
+ @block_has_text_children = block_has_text_children
22
+ @nokogiri_node = nokogiri_node
23
+ @ruby_code = ruby_code
14
24
  end
15
25
 
16
26
  def tag_name
17
- @nokogiri_node&.name
27
+ nokogiri_node&.name
18
28
  end
19
29
 
20
30
  def attribute?(name)
@@ -25,25 +35,31 @@ module A11y
25
35
  @attributes ||= extract_attributes
26
36
  end
27
37
 
28
- def ruby_code
29
- @ruby_code_string
38
+ def call_node
39
+ @call_node ||= RubyCode.new(ruby_code).call_node
40
+ end
41
+
42
+ def block_has_text_children?
43
+ @block_has_text_children
30
44
  end
31
45
 
32
46
  # Returns direct element children wrapped as ErbNode objects.
33
47
  def children
34
- return [] unless @nokogiri_node
48
+ return [] unless nokogiri_node
35
49
 
36
- @nokogiri_node.element_children.map do |child|
50
+ nokogiri_node.element_children.map do |child|
37
51
  ErbNode.new(nokogiri_node: child, line: child.line)
38
52
  end
39
53
  end
40
54
 
41
55
  private
42
56
 
57
+ attr_reader(:nokogiri_node)
58
+
43
59
  def extract_attributes
44
- return {} unless @nokogiri_node
60
+ return {} unless nokogiri_node
45
61
 
46
- @nokogiri_node
62
+ nokogiri_node
47
63
  .attributes
48
64
  .each_with_object({}) do |(name, _attr), result|
49
65
  result[name] = true
@@ -53,12 +53,38 @@ module A11y
53
53
  source.scan(ERB_OUTPUT_TAG) do
54
54
  match = Regexp.last_match
55
55
  code = match[1]
56
- line_number = source[0...match.begin(0)].count("\n") + 1
57
- node = ErbNode.new(ruby_code: code, line: line_number)
58
- check_node(node)
56
+ line = source[0...match.begin(0)].count("\n") + 1
57
+ check_node(
58
+ build_erb_output_node(source, code, line, match.end(0))
59
+ )
59
60
  end
60
61
  end
61
62
 
63
+ def build_erb_output_node(source, code, line, match_end)
64
+ block_body_codes, block_has_text =
65
+ extract_block_info(source, code, match_end)
66
+
67
+ ErbNode.new(
68
+ ruby_code: code, line: line,
69
+ block_body_codes: block_body_codes,
70
+ block_has_text_children: block_has_text
71
+ )
72
+ end
73
+
74
+ def extract_block_info(source, code, match_end)
75
+ return [nil, false] unless code.match?(/\s+do\s*\z/)
76
+
77
+ rest = source[match_end..]
78
+ end_match = rest.match(/<%-?\s*end\s*-?%>/m)
79
+ return [nil, false] unless end_match
80
+
81
+ block_content = rest[0...end_match.begin(0)]
82
+ codes = block_content.scan(ERB_OUTPUT_TAG).map { |m| m[0].strip }
83
+ text_only = block_content.gsub(ERB_TAG, "").strip
84
+
85
+ [codes, !text_only.empty?]
86
+ end
87
+
62
88
  def check_node(node)
63
89
  rules.each do |rule_class|
64
90
  message = rule_class.check(node)
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A11y
4
+ module Lint
5
+ class Error < StandardError; end
6
+
7
+ # Raised when slim is not installed.
8
+ class SlimLoadError < Error
9
+ def initialize
10
+ super(
11
+ "a11y-lint needs the `slim` gem to lint .slim files. " \
12
+ "Add `gem \"slim\"` to your Gemfile."
13
+ )
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A11y
4
+ module Lint
5
+ # Wraps a Phlex HTML tag call or helper method call
6
+ # as a queryable node for lint rules.
7
+ class PhlexNode
8
+ include BlockInspection
9
+ extend PhlexTags
10
+
11
+ attr_reader(
12
+ :attributes,
13
+ :block_body_codes,
14
+ :call_node,
15
+ :children,
16
+ :line,
17
+ :tag_name
18
+ )
19
+
20
+ # rubocop:disable Metrics/ParameterLists
21
+ def initialize(
22
+ line:, tag_name: nil, attributes: {},
23
+ call_node: nil, children: [],
24
+ block_body_codes: nil,
25
+ block_has_text_children: false
26
+ )
27
+ @tag_name = tag_name
28
+ @attributes = attributes
29
+ @call_node = call_node
30
+ @line = line
31
+ @children = children
32
+ @block_body_codes = block_body_codes
33
+ @block_has_text_children = block_has_text_children
34
+ end
35
+ # rubocop:enable Metrics/ParameterLists
36
+
37
+ def ruby_code
38
+ nil
39
+ end
40
+
41
+ def attribute?(name)
42
+ attributes.key?(name)
43
+ end
44
+
45
+ def block_has_text_children?
46
+ @block_has_text_children
47
+ end
48
+
49
+ def self.build_tag(call_node, children: [])
50
+ name = call_node.name.to_s
51
+ new(
52
+ tag_name: html_tag_name(name),
53
+ attributes: extract_attributes(call_node),
54
+ line: call_node.location.start_line,
55
+ children: children
56
+ )
57
+ end
58
+
59
+ def self.build_helper(
60
+ call_node,
61
+ block_body_codes: nil,
62
+ block_has_text_children: false
63
+ )
64
+ new(
65
+ call_node: CallNode.new(call_node),
66
+ line: call_node.location.start_line,
67
+ block_body_codes: block_body_codes,
68
+ block_has_text_children: block_has_text_children
69
+ )
70
+ end
71
+
72
+ def self.extract_attributes(call_node)
73
+ return {} unless call_node.arguments
74
+
75
+ kwarg_nodes(call_node).each_with_object({}) do |elem, h|
76
+ key = kwarg_key(elem.key)
77
+ h[key] = true if key
78
+ end
79
+ end
80
+
81
+ def self.kwarg_nodes(call_node)
82
+ args = call_node.arguments.arguments
83
+ args.select { |a| a.is_a?(Prism::KeywordHashNode) }
84
+ .flat_map { |a| a.elements.select { |e| e.is_a?(Prism::AssocNode) } }
85
+ end
86
+
87
+ def self.kwarg_key(key_node)
88
+ case key_node
89
+ when Prism::SymbolNode then key_node.value
90
+ when Prism::StringNode then key_node.unescaped
91
+ end
92
+ end
93
+
94
+ private_class_method :kwarg_key, :kwarg_nodes,
95
+ :extract_attributes
96
+ end
97
+ end
98
+ end