rubocop-view_component 0.6.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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +4 -0
  3. data/{CLAUDE.md → AGENTS.md} +6 -14
  4. data/Appraisals +5 -0
  5. data/README.md +50 -27
  6. data/config/default.yml +6 -5
  7. data/docs/rubydex.md +136 -0
  8. data/gemfiles/rubocop_minimum.gemfile +13 -0
  9. data/gemfiles/rubocop_minimum.gemfile.lock +162 -0
  10. data/lib/rubocop/cop/view_component/base.rb +56 -7
  11. data/lib/rubocop/cop/view_component/prefer_composition.rb +42 -9
  12. data/lib/rubocop/cop/view_component/prefer_private_methods.rb +46 -13
  13. data/lib/rubocop/cop/view_component/rspec_scope.rb +98 -0
  14. data/lib/rubocop/cop/view_component/supplementary_index.rb +142 -0
  15. data/lib/rubocop/cop/view_component/test_rendered_output.rb +107 -11
  16. data/lib/rubocop/view_component/version.rb +1 -1
  17. data/script/verify +29 -16
  18. data/spec/expected_flowbite_failures.yml +64 -0
  19. data/spec/expected_govuk_failures.yml +10 -12
  20. data/spec/expected_polaris_failures.yml +2 -0
  21. data/spec/expected_primer_failures.yml +60 -2
  22. data/spec/rubocop/cop/view_component/base_spec.rb +108 -0
  23. data/spec/rubocop/cop/view_component/component_suffix_spec.rb +0 -58
  24. data/spec/rubocop/cop/view_component/missing_preview_spec.rb +1 -42
  25. data/spec/rubocop/cop/view_component/prefer_composition_spec.rb +91 -4
  26. data/spec/rubocop/cop/view_component/prefer_private_methods_spec.rb +126 -4
  27. data/spec/rubocop/cop/view_component/test_rendered_output_spec.rb +287 -4
  28. data/spec/spec_helper.rb +10 -0
  29. data/spec/support/project_index_helpers.rb +71 -0
  30. data/verification/flowbite_rubocop_config.yml +12 -0
  31. data/verification/govuk_rubocop_config.yml +3 -6
  32. data/verification/libraries.yml +4 -0
  33. data/verification/polaris_rubocop_config.yml +3 -7
  34. data/verification/primer_rubocop_config.yml +5 -7
  35. metadata +29 -5
@@ -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
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "rubydex"
5
+ require "uri"
6
+
7
+ module RuboCop
8
+ module Cop
9
+ module ViewComponent
10
+ # Extends the project index with `AdditionalSourcePaths`: source
11
+ # directories the cops index themselves, so a chain that passes through a
12
+ # gem RuboCop does not index is still resolvable. The motivating case is a
13
+ # Rails engine's `app/components`, which `AllCops/ProjectIndexIncludesGems`
14
+ # misses because it only indexes a gem's `lib`.
15
+ module SupplementaryIndex
16
+ # A minimal stand-in for the framework base class, indexed alongside the
17
+ # configured paths so ancestry there can still reach
18
+ # `ViewComponent::Base` without indexing the whole view_component gem.
19
+ BASE_URI = "rubydex:view_component_base"
20
+ BASE_SOURCE = <<~RUBY
21
+ module ViewComponent
22
+ class Base
23
+ end
24
+ end
25
+ RUBY
26
+
27
+ # RuboCop's cache key, extended with the supplementary files' mtimes:
28
+ # those files are not part of RuboCop's own index documents, so a change
29
+ # to one would otherwise not invalidate a cached result.
30
+ def external_dependency_checksum
31
+ base = super
32
+ extra = supplementary_files_checksum
33
+ return base if extra.nil?
34
+ return extra if base.nil?
35
+
36
+ Digest::SHA1.hexdigest("#{base}:#{extra}")
37
+ end
38
+
39
+ private
40
+
41
+ # Resolve through RuboCop's project index first, then the supplementary one.
42
+ def resolve_constant_in_index(const_node)
43
+ super || resolve_constant_in_graph(supplementary_index, const_node)
44
+ end
45
+
46
+ def resolve_constant_in_graph(graph, const_node)
47
+ return nil unless graph
48
+
49
+ segments = const_node.const_name.split("::")
50
+ # The inspected file's lexical scopes live in the project index, not
51
+ # in the supplementary one, so resolving through them can fail (a
52
+ # scope name like `Ui` is simply absent here). Try them first, then
53
+ # resolve from the top level.
54
+ nesting_candidates(const_node).each do |nesting|
55
+ declaration = resolve_segments(graph, segments, nesting)
56
+ return declaration if declaration
57
+ end
58
+ nil
59
+ end
60
+
61
+ def nesting_candidates(const_node)
62
+ return [[]] if const_node.absolute?
63
+
64
+ [lexical_nesting_of(const_node), []].uniq
65
+ end
66
+
67
+ def resolve_segments(graph, segments, nesting)
68
+ declaration = graph.resolve_constant(segments.first, nesting)
69
+ return nil unless declaration
70
+
71
+ segments.drop(1).each do |segment|
72
+ return nil unless declaration.is_a?(Rubydex::Namespace)
73
+
74
+ declaration = declaration.find_member(segment)
75
+ return nil unless declaration
76
+ end
77
+ declaration
78
+ end
79
+
80
+ # Built once per cop and only when the option is set.
81
+ def supplementary_index
82
+ return @supplementary_index if defined?(@supplementary_index)
83
+
84
+ @supplementary_index = build_supplementary_index
85
+ end
86
+
87
+ def build_supplementary_index
88
+ files = supplementary_source_files
89
+ return nil if files.empty?
90
+
91
+ graph = Rubydex::Graph.new
92
+ graph.index_source(BASE_URI, BASE_SOURCE, "ruby")
93
+ index_files(graph, files)
94
+ graph.resolve
95
+ graph
96
+ rescue StandardError
97
+ nil
98
+ end
99
+
100
+ def index_files(graph, files)
101
+ # File by file, not `index_all`: in a forked RuboCop worker
102
+ # `index_all` crashes once the parent has built its own graph, while
103
+ # `index_source` is safe.
104
+ files.each do |file|
105
+ graph.index_source(file_uri(file), File.read(file), "ruby")
106
+ rescue StandardError
107
+ next
108
+ end
109
+ end
110
+
111
+ def file_uri(file)
112
+ "file://#{URI::DEFAULT_PARSER.escape(file)}"
113
+ end
114
+
115
+ def supplementary_source_files
116
+ configured_source_paths.flat_map { |path| Dir.glob(File.join(path, "**", "*.rb")) }.uniq.sort
117
+ end
118
+
119
+ def configured_source_paths
120
+ Array(cop_config["AdditionalSourcePaths"]).filter_map do |path|
121
+ expanded = File.expand_path(path, config.base_dir_for_path_parameters)
122
+ expanded if File.directory?(expanded)
123
+ end
124
+ end
125
+
126
+ def supplementary_files_checksum
127
+ files = supplementary_source_files
128
+ return nil if files.empty?
129
+
130
+ Digest::SHA1.hexdigest(files.map { |file| file_signature(file) }.join("\n"))
131
+ end
132
+
133
+ def file_signature(file)
134
+ stat = File.stat(file)
135
+ "#{file}:#{stat.mtime.to_f}:#{stat.size}"
136
+ rescue StandardError
137
+ file
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "rspec_scope"
4
+
3
5
  module RuboCop
4
6
  module Cop
5
7
  module ViewComponent
@@ -8,6 +10,11 @@ module RuboCop
8
10
  #
9
11
  # This cop is only enabled for test files by default (see config).
10
12
  #
13
+ # A test is considered to instantiate a component when it builds one
14
+ # directly (`UserComponent.new`), through `described_class.new` inside a
15
+ # `describe UserComponent` block, or by referencing a `let`/`subject`
16
+ # helper whose body instantiates a component.
17
+ #
11
18
  # @example
12
19
  # # bad
13
20
  # def test_formatted_title
@@ -21,10 +28,40 @@ module RuboCop
21
28
  # assert_text "HELLO"
22
29
  # end
23
30
  #
31
+ # # bad
32
+ # describe TitleComponent do
33
+ # it "formats the title" do
34
+ # component = described_class.new("hello")
35
+ # expect(component.formatted_title).to eq("HELLO")
36
+ # end
37
+ # end
38
+ #
39
+ # # good
40
+ # describe TitleComponent do
41
+ # it "formats the title" do
42
+ # render_inline described_class.new("hello")
43
+ # expect(page).to have_text("HELLO")
44
+ # end
45
+ # end
46
+ #
47
+ # # bad
48
+ # let(:component) { TitleComponent.new("hello") }
49
+ #
50
+ # it "formats the title" do
51
+ # expect(component.formatted_title).to eq("HELLO")
52
+ # end
53
+ #
24
54
  class TestRenderedOutput < RuboCop::Cop::Base
55
+ include ViewComponent::Base
56
+ include RSpecScope
57
+
25
58
  MSG = "Test instantiates a component but doesn't use `render_inline` or `render_preview`. " \
26
59
  "Test the rendered output instead of component methods directly."
27
60
 
61
+ # `render_in_view_context` renders through a view context (it is what
62
+ # form-builder tests need), so it is a rendered-output test too.
63
+ RENDER_METHODS = %i[render_inline render_preview render_in_view_context].freeze
64
+
28
65
  # Check Minitest-style test methods
29
66
  def on_def(node)
30
67
  return unless within_test_paths?
@@ -32,7 +69,7 @@ module RuboCop
32
69
  method_name = node.method_name.to_s
33
70
  return unless method_name.start_with?("test_")
34
71
  return unless instantiates_component?(node)
35
- return if contains_render_method?(node)
72
+ return if renders_output?(node)
36
73
 
37
74
  add_offense(node)
38
75
  end
@@ -40,13 +77,14 @@ module RuboCop
40
77
  # Check RSpec-style it blocks
41
78
  def on_block(node)
42
79
  return unless within_test_paths?
43
- return unless rspec_it_block?(node)
80
+ return unless rspec_example_block?(node)
44
81
  return unless instantiates_component?(node)
45
- return if contains_render_method?(node)
82
+ return if renders_output?(node)
46
83
 
47
84
  add_offense(node)
48
85
  end
49
86
  alias on_numblock on_block
87
+ alias on_itblock on_block
50
88
 
51
89
  private
52
90
 
@@ -59,24 +97,82 @@ module RuboCop
59
97
  end
60
98
 
61
99
  def instantiates_component?(node)
62
- node.each_descendant(:send).any? do |send_node|
100
+ component_new_call?(node) ||
101
+ referenced_helper?(node) { |body| component_new_call?(body) }
102
+ end
103
+
104
+ def renders_output?(node)
105
+ render_call?(node) ||
106
+ referenced_helper?(node) { |body| render_call?(body) }
107
+ end
108
+
109
+ # True when the node calls `.new` on a component constant or on
110
+ # `described_class` within a `describe` block.
111
+ def component_new_call?(node)
112
+ node.each_node(:send).any? do |send_node|
63
113
  next unless send_node.method?(:new)
114
+ next if inside_error_expectation?(send_node)
64
115
 
65
- send_node.receiver&.const_type?
116
+ component_receiver?(send_node.receiver)
66
117
  end
67
118
  end
68
119
 
69
- def contains_render_method?(node)
70
- node.each_descendant(:send).any? do |send_node|
71
- %i[render_inline render_preview].include?(send_node.method_name)
120
+ # A component built inside an error assertion is exercising
121
+ # initialisation behaviour rather than rendered output, so there is
122
+ # nothing to render. Covers RSpec's `expect { ... }.to raise_error` and
123
+ # Minitest's `assert_raises`.
124
+ def inside_error_expectation?(node)
125
+ node.each_ancestor(:block).any? do |ancestor|
126
+ error_assertion?(ancestor)
72
127
  end
73
128
  end
74
129
 
75
- def rspec_it_block?(node)
76
- send_node = node.send_node
130
+ def error_assertion?(block)
131
+ send_node = block.send_node
77
132
  return false unless send_node
133
+ return true if send_node.method?(:assert_raises)
134
+
135
+ send_node.method?(:expect) && error_expectation?(block)
136
+ end
137
+
138
+ def error_expectation?(expect_block)
139
+ parent = expect_block.parent
140
+ return false unless parent&.send_type?
141
+ return false unless %i[to not_to].include?(parent.method_name)
142
+
143
+ error_matcher?(parent.first_argument)
144
+ end
145
+
146
+ def error_matcher?(matcher)
147
+ matcher = matcher.send_node if matcher&.block_type?
148
+ matcher&.send_type? &&
149
+ %i[raise_error raise_exception].include?(matcher.method_name)
150
+ end
78
151
 
79
- %i[it specify example].include?(send_node.method_name)
152
+ def component_receiver?(receiver)
153
+ return false unless receiver
154
+ return view_component_parent?(receiver) if receiver.const_type?
155
+ return false unless described_class_reference?(receiver)
156
+
157
+ described_class = enclosing_described_class(receiver)
158
+ described_class && view_component_parent?(described_class)
159
+ end
160
+
161
+ def render_call?(node)
162
+ node.each_node(:send).any? do |send_node|
163
+ RENDER_METHODS.include?(send_node.method_name)
164
+ end
165
+ end
166
+
167
+ # True when the node references a `let`/`subject` helper whose body
168
+ # satisfies the given block.
169
+ def referenced_helper?(node)
170
+ helper_bodies = helper_definitions(node)
171
+
172
+ referenced_helper_names(node).any? do |name|
173
+ body = helper_bodies[name]
174
+ body && yield(body)
175
+ end
80
176
  end
81
177
  end
82
178
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RuboCop
4
4
  module ViewComponent
5
- VERSION = "0.6.0"
5
+ VERSION = "0.8.0"
6
6
  end
7
7
  end
data/script/verify CHANGED
@@ -49,7 +49,7 @@ def main
49
49
 
50
50
  Dir.chdir(config[:verification_dir]) do
51
51
  configure_rubocop(config[:config_file], config[:display_name])
52
- add_gem_to_gemfile
52
+ replace_gemfile
53
53
 
54
54
  Bundler.with_unbundled_env do
55
55
  output = run_rubocop
@@ -89,29 +89,39 @@ def download_source(dir, tarball_url, display_name)
89
89
  system!("tar", "xz", "-C", dir, "--strip-components=1", "-f", "#{dir}/source.tar.gz")
90
90
  end
91
91
 
92
+ # Replace the library's .rubocop.yml with a config that only runs our
93
+ # ViewComponent cops. The library's own config may reference gems outside the
94
+ # minimal bundle (see replace_gemfile), e.g. inherit_gem, require, or config
95
+ # for cops from other RuboCop extensions.
92
96
  def configure_rubocop(config_file, display_name)
93
- puts "Configuring .rubocop.yml with #{display_name} overrides..."
94
- config = File.exist?(".rubocop.yml") ? YAML.load_file(".rubocop.yml") : {}
95
- library_config = YAML.load_file(config_file)
96
-
97
- # Merge the library config into the existing config
98
- library_config.each do |key, value|
99
- config[key] = if config[key].is_a?(Hash) && value.is_a?(Hash)
100
- config[key].merge(value)
101
- else
102
- value
103
- end
104
- end
97
+ puts "Replacing .rubocop.yml with #{display_name} overrides..."
98
+ config = YAML.load_file(config_file)
99
+
100
+ # Silence warnings about unconfigured new cops - only ViewComponent cops run
101
+ config["AllCops"] ||= {}
102
+ config["AllCops"]["NewCops"] = "disable"
105
103
 
106
104
  File.write(".rubocop.yml", YAML.dump(config))
107
105
  end
108
106
 
109
- def add_gem_to_gemfile
110
- puts "Adding rubocop-view_component gem to Gemfile..."
111
- File.open("Gemfile", "a") { |f| f.puts "gem 'rubocop-view_component', path: '#{GEM_DIR}'" }
107
+ # Replace the library's Gemfile with a minimal one. This avoids installing the
108
+ # library's own dependencies, which are not needed for static analysis and can
109
+ # fail to install or conflict with the rubocop version we require.
110
+ # `view_component` must be present so the project index can resolve
111
+ # `ViewComponent::Base` for ancestry-based detection.
112
+ def replace_gemfile
113
+ puts "Replacing Gemfile with minimal setup..."
114
+ File.write("Gemfile", <<~RUBY)
115
+ source "https://rubygems.org"
116
+
117
+ gem "rubocop"
118
+ gem "rubocop-view_component", path: "#{GEM_DIR}"
119
+ gem "view_component"
120
+ RUBY
112
121
  end
113
122
 
114
123
  def run_rubocop
124
+ FileUtils.rm_f("Gemfile.lock")
115
125
  puts "Running bundle install..."
116
126
  system!("bundle", "install")
117
127
 
@@ -126,6 +136,9 @@ def run_rubocop
126
136
 
127
137
  puts "RuboCop exit status: #{status.exitstatus}"
128
138
 
139
+ # Exit status 0 means no offenses, 1 means offenses found (expected, since
140
+ # the libraries have known offenses). 2 means RuboCop itself failed.
141
+ abort "ERROR: RuboCop failed (exit status: #{status.exitstatus})" if status.exitstatus == 2
129
142
  abort "ERROR: RuboCop produced no output (exit status: #{status.exitstatus})" if output.strip.empty?
130
143
 
131
144
  output