rubocop-view_component 0.7.0 → 0.8.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: fa0434208243f306c17f51b681abdf59f6152d6afdd0014c608c5b4e08e893c2
4
- data.tar.gz: e709241be864e024f8ce410b6620d62bcdd12cd090e2593b378c0169a628f1e9
3
+ metadata.gz: ab88d6999ec2a99b6f9e459d22a14b9d727b62edd967088fa22aef709681dbdb
4
+ data.tar.gz: b06b97cebdb888ba95176af4d9d664c9f75bf75865eacb148bc327bba761f77f
5
5
  SHA512:
6
- metadata.gz: 6d0a9815a58ab3326c9366e7abb2d85cd18e1c87216c731c2fccfeba3d70eae2188856b1c619ba9be2d37c776d463d00efe4f2b7d680998e6e7679b582ab22da
7
- data.tar.gz: f195f94ca71e89a4f807a8074ddac30a65462faab27dc01b18f1f58f4a29d85df5699a4dcead7a670e7f311b3fe87816cd49ae98b3813540fa2e4c0707273fc8
6
+ metadata.gz: 8b6aa5e13ce63d13205335bb2d900a29d57e80ce1b9d1906c92e3548d85941875b18235a36775849e35752fed347c260e842289fb8e805ad0ee7670c252c89b1
7
+ data.tar.gz: b65833f97c6211d366b5c0cdeba807e501f6ef10a8a878f63e0379de23be3c2a4842542e50e43171254886204990ae5224a4d3e3abbe90f72be1e05f9cfb0f08
data/.rubocop.yml CHANGED
@@ -12,6 +12,9 @@ AllCops:
12
12
  TargetRubyVersion: 3.2
13
13
  NewCops: enable
14
14
  SuggestExtensions: false
15
+ # Required by this gem's own cops, which resolve ancestry through the
16
+ # project index (see docs/rubydex.md).
17
+ UseProjectIndex: true
15
18
  Exclude:
16
19
  - 'verification/**/*'
17
20
  - 'script/verify'
@@ -1,6 +1,6 @@
1
- # CLAUDE.md
1
+ # AGENTS.md
2
2
 
3
- This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
3
+ This file provides guidance to coding agents when working with code in this repository.
4
4
 
5
5
  ## Project Overview
6
6
 
@@ -40,7 +40,7 @@ All cops inherit from `RuboCop::Cop::Base` and are located in `lib/rubocop/cop/v
40
40
  ### Shared Modules
41
41
 
42
42
  **`ViewComponent::Base`** (`lib/rubocop/cop/view_component/base.rb`)
43
- - Includes `RuboCop::Cop::ProjectIndexHelp` for rubydex integration
43
+ - Includes `RuboCop::Cop::ProjectIndexHelp` for [rubydex integration](docs/rubydex.md)
44
44
  - Provides `view_component_class?(node)` - Detects ViewComponent classes via ancestry resolution
45
45
  - Provides `view_component_parent?(node)` - Checks if inheriting from ViewComponent::Base
46
46
  - Provides `view_component_parent_class?(node)` - Checks if a class is an abstract parent (has descendants)
data/README.md CHANGED
@@ -13,7 +13,7 @@ gem 'rubocop-view_component', require: false
13
13
  Add to your `.rubocop.yml`:
14
14
 
15
15
  ```yaml
16
- require:
16
+ plugins:
17
17
  - rubocop-view_component
18
18
  ```
19
19
  **Note:** From v0.7.0 onwards, this gem makes use of [rubydex](https://github.com/Shopify/rubydex) to discover which classes inherit from `ViewComponent::Base`. `UseProjectIndex` is required. Without it, cops that depend on ancestry detection will raise an error.
@@ -35,15 +35,15 @@ This gem provides several cops to enforce ViewComponent best practices:
35
35
  - **ViewComponent/NoGlobalState** - Prevent direct access to `params`, `request`, `session`, etc.
36
36
  - **ViewComponent/PreferPrivateMethods** - Suggest making helper methods private (analyzes ERB templates to avoid flagging methods used in views)
37
37
  - **ViewComponent/PreferSlots** - Detect HTML parameters that should be slots
38
- - **ViewComponent/PreferComposition** - Avoid inheriting one ViewComponent from another (prefer composition)
39
- - **ViewComponent/TestRenderedOutput** - Encourage testing rendered output over private methods
40
- - **ViewComponent/MissingPreview** - Ensure every ViewComponent has a corresponding preview file (requires `PreviewPaths` configuration). Abstract base classes with descendants are automatically exempt.
38
+ - **ViewComponent/PreferComposition** - Avoid inheriting one ViewComponent from another (prefer composition). Abstract bases without their own template, such as `ApplicationComponent`, are allowed
39
+ - **ViewComponent/TestRenderedOutput** - Encourage testing rendered output over private methods. Supports Minitest and RSpec styles, including components built via `described_class.new` or a `let`/`subject` helper
40
+ - **ViewComponent/MissingPreview** - Ensure every ViewComponent has a corresponding preview file (defaults to `test/components/previews` and `spec/components/previews` if `PreviewPaths` is not configured). Abstract base classes with descendants are automatically exempt.
41
41
 
42
42
  ## Optional Configuration
43
43
 
44
44
  ### Components Directory
45
45
 
46
- Several cops (`ComponentSuffix`, `PreferComposition`, `MissingPreview`, `TestRenderedOutput`) default to running only on files under `app/components/`. If your project uses a different path, override `Include` in your `.rubocop.yml`:
46
+ Several cops (`ComponentSuffix`, `PreferComposition`, `TestRenderedOutput`) default to running only on files under `app/components/`. If your project uses a different path, override `Include` in your `.rubocop.yml`:
47
47
 
48
48
  ```yaml
49
49
  # .rubocop.yml
@@ -53,6 +53,23 @@ ViewComponent/ComponentSuffix:
53
53
  - 'engines/*/app/components/**/*.rb'
54
54
  ```
55
55
 
56
+ ### Additional Source Paths
57
+
58
+ `AllCops/ProjectIndexIncludesGems` only indexes a gem's `lib`, so a class that inherits through a
59
+ Rails engine's `app/components` stays unresolved. If your components inherit from an engine gem,
60
+ point the cops at its sources:
61
+
62
+ ```yaml
63
+ # .rubocop.yml
64
+ ViewComponent:
65
+ AdditionalSourcePaths:
66
+ - ../my_engine/app/components
67
+ ```
68
+
69
+ Paths are relative to the config file that sets them. The cops index those directories themselves
70
+ and fall back to them when RuboCop's index can't resolve a constant. Unlike `ComponentNamespaces`,
71
+ this only adds declarations to the index — it does not treat a whole namespace as components.
72
+
56
73
  ### No Super
57
74
 
58
75
  ViewComponent convention is to not call `super` in component initializers, but that may cause `Lint/MissingSuper` failures from RuboCop. We suggest disabling that rule for your view components directory, for example:
@@ -143,6 +160,26 @@ To force download the latest Polaris source:
143
160
  script/verify polaris --update
144
161
  ```
145
162
 
163
+ ### Flowbite Components
164
+
165
+ To verify against [substancelab/flowbite-components](https://github.com/substancelab/flowbite-components) locally:
166
+
167
+ ```bash
168
+ script/verify flowbite
169
+ ```
170
+
171
+ If you intentionally change cop behavior, regenerate the snapshot:
172
+
173
+ ```bash
174
+ script/verify flowbite --regenerate
175
+ ```
176
+
177
+ To force download the latest Flowbite source:
178
+
179
+ ```bash
180
+ script/verify flowbite --update
181
+ ```
182
+
146
183
  ## Contributing
147
184
 
148
185
  Bug reports and pull requests are welcome on GitHub at https://github.com/andyw8/rubocop-view_component.
data/config/default.yml CHANGED
@@ -1,5 +1,11 @@
1
1
  ViewComponent:
2
2
  ComponentNamespaces: []
3
+ # Source directories the cops index themselves, in addition to RuboCop's
4
+ # project index. Use this when ancestry passes through a gem RuboCop does not
5
+ # index (notably a Rails engine's app/components, which
6
+ # AllCops/ProjectIndexIncludesGems misses because it only covers a gem's lib).
7
+ # Paths are relative to the config file that sets them.
8
+ AdditionalSourcePaths: []
3
9
 
4
10
  ViewComponent/ComponentSuffix:
5
11
  Description: 'Enforce -Component suffix for ViewComponent classes.'
@@ -15,8 +21,6 @@ ViewComponent/MissingPreview:
15
21
  Enabled: true
16
22
  VersionAdded: '0.5.0'
17
23
  Severity: convention
18
- Include:
19
- - 'app/components/**/*_component.rb'
20
24
  PreviewPaths:
21
25
  - test/components/previews
22
26
  - spec/components/previews
data/docs/rubydex.md ADDED
@@ -0,0 +1,136 @@
1
+ # rubydex integration
2
+
3
+ This gem uses [rubydex](https://github.com/Shopify/rubydex), Shopify's high-performance
4
+ static analysis toolkit for Ruby, to answer questions that cannot be answered from a
5
+ single file's AST.
6
+
7
+ Most ViewComponent cops only care about *ancestry*: is this class (or the class it
8
+ inherits from) ultimately a `ViewComponent::Base` descendant? A class may inherit from
9
+ `ApplicationComponent`, which inherits from a gem's base class, which finally inherits
10
+ from `ViewComponent::Base` — none of which is visible in the file being inspected.
11
+ Resolving that chain requires a project-wide index, and that is what rubydex provides.
12
+
13
+ ## How RuboCop exposes the index
14
+
15
+ RuboCop 1.89+ integrates rubydex behind an opt-in flag. This gem requires that
16
+ version (see `rubocop-view_component.gemspec`).
17
+
18
+ 1. When `AllCops/UseProjectIndex` is `true`, RuboCop builds a resolved
19
+ `Rubydex::Graph` for the whole project (`RuboCop::ProjectIndexLoader`).
20
+ 2. The graph is exposed to cops through `RuboCop::Cop::Base#project_index`.
21
+ 3. Cops that mix in `RuboCop::Cop::ProjectIndexHelp` also get an
22
+ `external_dependency_checksum` override, which invalidates RuboCop's result cache
23
+ whenever an indexed file changes on disk.
24
+
25
+ If the `rubydex` gem is not installed, or the running Ruby is older than 3.2, RuboCop
26
+ warns and degrades to file-local analysis (`project_index` is `nil`).
27
+
28
+ ## How this gem uses it
29
+
30
+ `ViewComponent::Base` (`lib/rubocop/cop/view_component/base.rb`) includes
31
+ `ProjectIndexHelp` and queries the index through `resolve_constant_in_index`:
32
+
33
+ - `view_component_class?` — resolves a class's superclass expression and checks it.
34
+ - `view_component_parent?` — resolves a constant and calls
35
+ `declaration.has_ancestor?("ViewComponent::Base")`.
36
+ - `view_component_parent_class?` — checks `has_ancestor?` plus `declaration.descendants`
37
+ to detect abstract base classes that have their own subclasses in the project.
38
+ - `resolved_index_declaration` — returns the raw declaration for a constant, or nil
39
+ when it isn't defined in the index. This lets a cop distinguish "resolved but not a
40
+ ViewComponent" from "not resolvable", and inspect the declaration (for example, to
41
+ locate the class's source file).
42
+ - `inside_view_component?` — finds the enclosing class and delegates to
43
+ `view_component_class?`.
44
+
45
+ `PreferComposition` uses `resolved_index_declaration` together with the parent's
46
+ template to tell a concrete component apart from an abstract base: it flags a parent
47
+ only when its name follows the component convention *and* the index resolves it to a
48
+ ViewComponent that has its own template. Bases without a template (such as
49
+ `ApplicationComponent`) and constants that resolve to something other than a
50
+ ViewComponent are allowed.
51
+
52
+ All of these call `require_project_index!` first, which raises with an actionable
53
+ message if the index is unavailable:
54
+
55
+ ```
56
+ ViewComponent cops require `AllCops/UseProjectIndex: true` in your .rubocop.yml
57
+ for ancestry-based class detection.
58
+ ```
59
+
60
+ The one exception is `ComponentNamespaces`: if a class's fully-qualified name matches a
61
+ configured namespace prefix, `view_component_class?` can return `true` without touching
62
+ the index, so projects that use explicit namespaces can avoid the requirement in that
63
+ specific case.
64
+
65
+ ## Configuration
66
+
67
+ Enable the index in your `.rubocop.yml`:
68
+
69
+ ```yaml
70
+ AllCops:
71
+ UseProjectIndex: true
72
+ ```
73
+
74
+ If your components inherit from a library (e.g. Primer) whose base class lives in a gem,
75
+ also enable gem indexing so the ancestry chain can be followed through the gem's source:
76
+
77
+ ```yaml
78
+ AllCops:
79
+ ProjectIndexIncludesGems: true
80
+ ```
81
+
82
+ ### Rails engines
83
+
84
+ Gem indexing only covers a gem's `lib`. A Rails engine's components live under `app/components`,
85
+ so a chain that passes through one — such as a project's components inheriting
86
+ `MyEngine::ApplicationComponent` — stays unresolved even with the flag on. List the engine's
87
+ sources in `ViewComponent/AdditionalSourcePaths` and the cops index them in a small supplementary
88
+ graph of their own, used as a fallback when RuboCop's index can't resolve a constant:
89
+
90
+ ```yaml
91
+ ViewComponent:
92
+ AdditionalSourcePaths:
93
+ - ../my_engine/app/components
94
+ ```
95
+
96
+ Paths are relative to the config file that sets them, and the supplementary files take part in the
97
+ result cache's checksum, so changing one invalidates a cached run. `ComponentNamespaces` is not a
98
+ substitute here: it treats *every* class under the namespace as a component, which is wrong for a
99
+ namespace that also holds helpers, tests, or other application code.
100
+
101
+ ## Performance notes
102
+
103
+ - The index always covers the whole project (rooted at the directory of the config that
104
+ enabled it), not just the files in the current run, so cross-file offenses are
105
+ consistent between full runs, editor runs, and CI shards.
106
+ - `external_dependency_checksum` ties the result cache to the indexed files' mtimes and
107
+ sizes, so a warm cache is reused until a relevant file changes.
108
+ - On Windows, parallel inspection is skipped when the index is enabled.
109
+
110
+ ## Testing
111
+
112
+ Specs do not build a full RuboCop run. `spec/support/project_index_helpers.rb` builds a
113
+ `Rubydex::Graph` directly from a set of in-memory sources:
114
+
115
+ ```ruby
116
+ graph = Rubydex::Graph.new
117
+ graph.index_source("file:///user_component.rb", source, "ruby")
118
+ graph.resolve
119
+ ```
120
+
121
+ `ProjectIndexHelpers::LazyIndex` wraps the graph and lazily indexes the source under
122
+ inspection on first miss, mirroring how the real runner indexes every project file
123
+ (including the one being inspected). `spec_helper.rb` assigns this to each cop's
124
+ `project_index` before examples run.
125
+
126
+ The real-world verification suites (`verification/*_rubocop_config.yml`) set
127
+ `UseProjectIndex: true` so the cops are exercised through the actual RuboCop runner.
128
+
129
+ ## Further reading
130
+
131
+ - rubydex repository: <https://github.com/Shopify/rubydex>
132
+ - Ruby API documentation: <https://shopify.github.io/rubydex/>
133
+ - "One engine, many tools — Introducing Rubydex":
134
+ <https://railsatscale.com/2026-05-12-one-engine-many-tools/>
135
+ - RuboCop 1.89 project-wide analysis announcement:
136
+ <https://metaredux.com/posts/2026/08/05/rubocop-1-89.html>
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rubydex"
4
+ require_relative "supplementary_index"
4
5
 
5
6
  module RuboCop
6
7
  module Cop
@@ -9,6 +10,7 @@ module RuboCop
9
10
  # Requires rubydex with UseProjectIndex: true for ancestry resolution.
10
11
  module Base
11
12
  include ProjectIndexHelp
13
+ include SupplementaryIndex
12
14
 
13
15
  VC_BASE = "ViewComponent::Base"
14
16
 
@@ -29,12 +31,8 @@ module RuboCop
29
31
 
30
32
  # Check if a constant node resolves to a class with ViewComponent::Base as ancestor
31
33
  def view_component_parent?(node)
32
- return false unless node.const_type?
33
-
34
- require_project_index!
35
-
36
- declaration = resolve_constant_in_index(node)
37
- return false unless declaration.respond_to?(:has_ancestor?)
34
+ declaration = view_component_declaration(node)
35
+ return false unless declaration
38
36
 
39
37
  declaration.name == VC_BASE || declaration.has_ancestor?(VC_BASE)
40
38
  end
@@ -44,15 +42,24 @@ module RuboCop
44
42
  def view_component_parent_class?(node)
45
43
  return false unless node&.class_type?
46
44
 
47
- require_project_index!
48
-
49
- declaration = resolve_constant_in_index(node.identifier)
50
- return false unless declaration.respond_to?(:has_ancestor?)
45
+ declaration = view_component_declaration(node.identifier)
46
+ return false unless declaration
51
47
 
52
48
  declaration.has_ancestor?(VC_BASE) &&
53
49
  declaration.descendants.any? { |d| d.name != declaration.name }
54
50
  end
55
51
 
52
+ # Resolve a constant node to its declaration in the project index, or
53
+ # nil when the node is not a constant or isn't defined anywhere in the
54
+ # index. The declaration may or may not be a ViewComponent; callers can
55
+ # inspect it (for example, to locate the class's source file).
56
+ def resolved_index_declaration(node)
57
+ return nil unless node&.const_type?
58
+
59
+ require_project_index!
60
+ resolve_constant_in_index(node)
61
+ end
62
+
56
63
  def fully_qualified_name(node)
57
64
  namespace = node.parent_module_name
58
65
  short_name = node.identifier.source
@@ -88,6 +95,16 @@ module RuboCop
88
95
  for ancestry-based class detection.
89
96
  MSG
90
97
  end
98
+
99
+ private
100
+
101
+ # Resolve a constant node to a namespace declaration in the project
102
+ # index, or nil when the node is not a constant or does not resolve to a
103
+ # class or module.
104
+ def view_component_declaration(node)
105
+ declaration = resolved_index_declaration(node)
106
+ declaration if declaration.respond_to?(:has_ancestor?)
107
+ end
91
108
  end
92
109
  end
93
110
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "template_analyzer"
4
+
3
5
  module RuboCop
4
6
  module Cop
5
7
  module ViewComponent
@@ -7,6 +9,13 @@ module RuboCop
7
9
  # instead of using composition. Inheriting one component from another
8
10
  # causes confusion when each has its own template.
9
11
  #
12
+ # A parent is only flagged when its name follows the component convention
13
+ # (the `Component` suffix, or a configured `ComponentNamespaces` prefix)
14
+ # and the project index confirms it is a ViewComponent with its own
15
+ # template. Abstract bases without a template, such as
16
+ # `ApplicationComponent`, are allowed, as are classes the index resolves
17
+ # to something other than a ViewComponent.
18
+ #
10
19
  # @example
11
20
  # # bad
12
21
  # class UserCardComponent < BaseCardComponent
@@ -19,31 +28,55 @@ module RuboCop
19
28
  #
20
29
  class PreferComposition < RuboCop::Cop::Base
21
30
  include ViewComponent::Base
31
+ include TemplateAnalyzer
22
32
 
23
33
  MSG = "Avoid inheriting from another ViewComponent."
24
34
 
25
35
  def on_class(node)
26
36
  parent_class = node.parent_class
27
- return unless parent_class
28
- return if view_component_parent?(parent_class)
29
- return unless component_like_parent?(parent_class)
37
+ return unless parent_class&.const_type?
38
+ return unless inheritance_violation?(parent_class)
30
39
 
31
40
  add_offense(parent_class)
32
41
  end
33
42
 
34
43
  private
35
44
 
36
- def component_like_parent?(node)
37
- return false unless node.const_type?
45
+ # The name heuristic decides which parents are worth checking; the
46
+ # project index then confirms whether the parent really is a concrete
47
+ # ViewComponent. A parent the index can't resolve falls back to the name
48
+ # heuristic so projects without gem indexing are unaffected.
49
+ def inheritance_violation?(parent_class)
50
+ return false unless component_like_parent?(parent_class)
51
+
52
+ declaration = resolved_index_declaration(parent_class)
53
+ return true if declaration.nil?
54
+ return false unless view_component_parent?(parent_class)
55
+
56
+ parent_has_template?(declaration)
57
+ end
58
+
59
+ def parent_has_template?(declaration)
60
+ declaration.definitions.any? do |definition|
61
+ path = definition_source_path(definition)
62
+ path && template_paths_for(path).any?
63
+ end
64
+ rescue StandardError
65
+ false
66
+ end
67
+
68
+ def definition_source_path(definition)
69
+ location = definition.location
70
+ return nil unless location.uri.start_with?("file://")
71
+
72
+ location.to_file_path
73
+ end
38
74
 
75
+ def component_like_parent?(node)
39
76
  source = node.source
40
77
  source.end_with?("Component") ||
41
78
  component_namespaces.any? { |ns| source.start_with?(ns) }
42
79
  end
43
-
44
- def component_namespaces
45
- cop_config.fetch("ComponentNamespaces", [])
46
- end
47
80
  end
48
81
  end
49
82
  end
@@ -44,28 +44,68 @@ module RuboCop
44
44
  return unless body
45
45
 
46
46
  children = body.begin_type? ? body.children : [body]
47
- check_children(children, methods_called_in_templates)
47
+ check_children(children, methods_called_in_templates, indexed_visibilities(class_node))
48
48
  end
49
49
 
50
- def check_children(children, template_method_calls)
50
+ def check_children(children, template_method_calls, indexed_visibilities)
51
51
  current_visibility = :public
52
52
 
53
53
  children.each do |child|
54
- if visibility_modifier?(child)
54
+ if bare_visibility_modifier?(child)
55
55
  current_visibility = child.method_name
56
- elsif should_flag?(child, current_visibility, template_method_calls)
56
+ elsif child.def_type?
57
+ visibility = indexed_visibilities.fetch(child.method_name, current_visibility)
58
+ next unless should_flag?(child, visibility, template_method_calls)
59
+
57
60
  add_offense(child, message: format(MSG, method_name: child.method_name))
58
61
  end
59
62
  end
60
63
  end
61
64
 
62
65
  def should_flag?(child, visibility, template_method_calls)
63
- child.def_type? &&
64
- visibility == :public &&
66
+ visibility == :public &&
65
67
  !allowed_public_method?(child.method_name) &&
66
68
  !template_method_calls.include?(child.method_name)
67
69
  end
68
70
 
71
+ # The effective visibility of each method defined in the class, taken
72
+ # from the project index. This covers the forms an in-file walk cannot
73
+ # see: `private :name`, `private def name`, `public :name`, and
74
+ # visibility applied in another file. Empty when the class is not in
75
+ # the index (for example with `ComponentNamespaces`, which does not
76
+ # require one), in which case the in-file keyword below is used
77
+ # instead.
78
+ def indexed_visibilities(class_node)
79
+ return {} unless project_index && class_node.identifier
80
+
81
+ declaration = resolved_index_declaration(class_node.identifier)
82
+ return {} unless declaration.is_a?(Rubydex::Namespace)
83
+
84
+ declaration.members.each_with_object({}) do |member, visibilities|
85
+ next unless member.is_a?(Rubydex::Method)
86
+
87
+ visibilities[member_method_name(member)] = member.visibility
88
+ end
89
+ rescue StandardError
90
+ {}
91
+ end
92
+
93
+ def member_method_name(member)
94
+ member.unqualified_name.sub(/\(.*\)\z/, "").to_sym
95
+ end
96
+
97
+ # A `private`/`protected`/`public` call with no arguments, which
98
+ # changes the visibility of every method that follows it. Forms that
99
+ # name their methods do not change it: `private :name` and
100
+ # `private def name` are handled by the index above.
101
+ def bare_visibility_modifier?(node)
102
+ return false unless node.send_type?
103
+ return false unless node.receiver.nil?
104
+ return false unless %i[private protected public].include?(node.method_name)
105
+
106
+ node.arguments.empty?
107
+ end
108
+
69
109
  def allowed_public_method?(method_name)
70
110
  allowed_public_methods.include?(method_name.to_s) ||
71
111
  allowed_public_method_patterns.any? { |pattern| method_name.to_s.match?(pattern) }
@@ -91,13 +131,6 @@ module RuboCop
91
131
  warn "Warning: Failed to analyze templates: #{e.message}" if ENV["RUBOCOP_DEBUG"]
92
132
  Set.new
93
133
  end
94
-
95
- def visibility_modifier?(node)
96
- return false unless node.send_type?
97
- return false unless node.receiver.nil?
98
-
99
- %i[private protected public].include?(node.method_name)
100
- end
101
134
  end
102
135
  end
103
136
  end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module ViewComponent
6
+ # Helpers for navigating the RSpec example-group structure of a spec file.
7
+ #
8
+ # RSpec specs often build a component indirectly, through `described_class`
9
+ # inside a `describe SomeComponent` block or through a `let`/`subject`
10
+ # helper. This module locates those constructs without knowing whether the
11
+ # referenced constant is actually a ViewComponent; that judgement is left
12
+ # to the cop, which has access to the project index.
13
+ module RSpecScope
14
+ EXAMPLE_METHODS = %i[it specify example].freeze
15
+ EXAMPLE_GROUP_METHODS = %i[describe context feature example_group].freeze
16
+ MEMOIZED_HELPER_METHODS = %i[let let! subject subject!].freeze
17
+
18
+ def rspec_example_block?(node)
19
+ EXAMPLE_METHODS.include?(node.send_node&.method_name)
20
+ end
21
+
22
+ # The constant argument of the nearest enclosing `describe` block, if any.
23
+ # `each_ancestor` yields the closest ancestor first, so an inner
24
+ # `describe` wins over an outer one.
25
+ def enclosing_described_class(node)
26
+ node.each_ancestor(:block) do |ancestor|
27
+ send_node = ancestor.send_node
28
+ next unless send_node&.method?(:describe)
29
+
30
+ first_argument = send_node.first_argument
31
+ return first_argument if first_argument&.const_type?
32
+ end
33
+
34
+ nil
35
+ end
36
+
37
+ def described_class_reference?(node)
38
+ node.send_type? && node.method?(:described_class)
39
+ end
40
+
41
+ # Bare method calls (no receiver, no arguments) within +node+, e.g.
42
+ # `component` in `expect(component.title)`.
43
+ def referenced_helper_names(node)
44
+ node.each_node(:send).filter_map do |send_node|
45
+ next unless send_node.receiver.nil?
46
+ next unless send_node.arguments.empty?
47
+
48
+ send_node.method_name
49
+ end
50
+ end
51
+
52
+ # Maps helper name to block body for every `let`/`subject` defined in an
53
+ # enclosing example group. Inner definitions are visited first and win
54
+ # over outer ones, matching how the name resolves at runtime.
55
+ def helper_definitions(node)
56
+ node.each_ancestor(:block).with_object({}) do |ancestor, definitions|
57
+ next unless example_group_block?(ancestor)
58
+
59
+ each_helper_definition(ancestor) do |name, body|
60
+ definitions[name] ||= body
61
+ end
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def each_helper_definition(group)
68
+ return unless group.body
69
+
70
+ statements_of(group.body).each do |statement|
71
+ next unless statement.block_type?
72
+
73
+ name = helper_name(statement.send_node)
74
+ yield(name, statement.body) if name
75
+ end
76
+ end
77
+
78
+ def statements_of(node)
79
+ node.begin_type? ? node.children : [node]
80
+ end
81
+
82
+ def helper_name(send_node)
83
+ return nil unless MEMOIZED_HELPER_METHODS.include?(send_node&.method_name)
84
+
85
+ first_argument = send_node.first_argument
86
+ return first_argument.value if first_argument&.sym_type?
87
+ return :subject if %i[subject subject!].include?(send_node.method_name)
88
+
89
+ nil
90
+ end
91
+
92
+ def example_group_block?(node)
93
+ EXAMPLE_GROUP_METHODS.include?(node.send_node&.method_name)
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end