rubocop-view_component 0.5.1 → 0.6.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: 93f6d112da7bf592a3d0a114d492211798067caea8c8286feee1930d2b56810d
4
- data.tar.gz: b5bb3fcbf1e270cff0410dd743145130ba1de2089374c6f4c6b0618037e64e92
3
+ metadata.gz: be0a1c1c9a7489ed311953b5f67c7108dbca8b1f43debef36026ec011781a549
4
+ data.tar.gz: 0b62b04534ab81725ab96d28c0b1931671d92944277ac7b5217f4d36d9d91f9b
5
5
  SHA512:
6
- metadata.gz: 92322d18a8ced7cb42d053bbd595e926b11059a49162ae7c7b3e01721397d45b61ca4b00715955b808b1699fa4d8d2ecdc05a68f6f0114e62b32081a92e2b174
7
- data.tar.gz: d55bb666487e1782dfce9298d78a920c989c58c6c33fc34a7f47f4bddae52543e05741b1f18da1ecbb2fa847345d09cab01c23ef759958204982ac27ee889f8a
6
+ metadata.gz: 7fc23bef50c35486b422362f9e738e5347b41eaeb8171f2dfca21884dbd87542f2db1e22618c1b239a1bfd2d878b448cb208539678c37b6e622086c1ee4d2871
7
+ data.tar.gz: f67e8fc0ec682da84318f7078bc5eaffd4a7e9907e9d4e281a224e240e57a13b4f4ec010ef31cce3f20528b4f6fc4ce5acf538385736a837095ef21a90fcf641
data/.rubocop.yml CHANGED
@@ -1,7 +1,39 @@
1
1
  plugins:
2
2
  - rubocop-internal_affairs
3
+ - rubocop-rake
4
+ - rubocop-rspec
3
5
  - rubocop-view_component
4
6
 
7
+ inherit_mode:
8
+ merge:
9
+ - Exclude
10
+
11
+ AllCops:
12
+ TargetRubyVersion: 3.2
13
+ NewCops: enable
14
+ SuggestExtensions: false
15
+ Exclude:
16
+ - 'verification/**/*'
17
+ - 'script/verify'
18
+ - 'spec/fixtures/**/*'
19
+
20
+ Metrics/BlockLength:
21
+ Exclude:
22
+ - 'spec/**/*'
23
+ - '*.gemspec'
24
+
25
+ RSpec/ExampleLength:
26
+ Max: 20
27
+
28
+ Style/StringLiterals:
29
+ EnforcedStyle: double_quotes
30
+
31
+ Style/StringLiteralsInInterpolation:
32
+ EnforcedStyle: double_quotes
33
+
34
+ InternalAffairs/RedundantLetRuboCopConfigNew:
35
+ Enabled: false
36
+
5
37
  Naming/FileName:
6
38
  Exclude:
7
39
  - lib/rubocop-view_component.rb
data/CLAUDE.md CHANGED
@@ -11,7 +11,7 @@ This is a RuboCop extension that enforces ViewComponent best practices. It provi
11
11
  ### Testing
12
12
  - `rake spec` - Run all RSpec tests
13
13
  - `bundle exec rspec spec/rubocop/cop/view_component/FILENAME_spec.rb` - Run a specific spec file
14
- - `rake standard` - Run Standard (RuboCop) linting
14
+ - `rake rubocop` - Run RuboCop linting
15
15
  - `rake` - Run both tests and linting (default task)
16
16
 
17
17
  ### Verification
@@ -52,17 +52,17 @@ All cops inherit from `RuboCop::Cop::Base` and are located in `lib/rubocop/cop/v
52
52
 
53
53
  ### Configuration
54
54
 
55
- The `AllCops` config supports `ViewComponentParentClasses` to configure additional base classes beyond `ViewComponent::Base` and `ApplicationComponent`:
55
+ The `ViewComponent` config supports `ViewComponentParentClasses` to configure additional base classes beyond `ViewComponent::Base` and `ApplicationComponent`:
56
56
 
57
57
  ```yaml
58
- AllCops:
58
+ ViewComponent:
59
59
  ViewComponentParentClasses:
60
60
  - MyApp::BaseComponent
61
61
  ```
62
62
 
63
63
  ### Verification System
64
64
 
65
- The `script/verify` script downloads real component libraries, runs all ViewComponent cops, and compares results to checked-in snapshots. This catches regressions when cop behavior changes. Libraries are configured in `verification/libraries.yml`, downloaded to `verification/LIBRARY/`, and expected results stored in `spec/expected_LIBRARY_failures.json`.
65
+ The `script/verify` script downloads real component libraries, runs all ViewComponent cops, and compares results to checked-in snapshots. This catches regressions when cop behavior changes. Libraries are configured in `verification/libraries.yml`, downloaded to `verification/LIBRARY/`, and expected results stored in `spec/expected_LIBRARY_failures.yml`.
66
66
 
67
67
  ## Implementation Notes
68
68
 
data/README.md CHANGED
@@ -27,24 +27,49 @@ This gem provides several cops to enforce ViewComponent best practices:
27
27
  - **ViewComponent/PreferSlots** - Detect HTML parameters that should be slots
28
28
  - **ViewComponent/PreferComposition** - Avoid inheriting one ViewComponent from another (prefer composition)
29
29
  - **ViewComponent/TestRenderedOutput** - Encourage testing rendered output over private methods
30
- - **ViewComponent/MissingPreview** - Ensure every ViewComponent has a corresponding preview file (requires `PreviewPaths` configuration)
30
+ - **ViewComponent/MissingPreview** - Ensure every ViewComponent has a corresponding preview file (requires `PreviewPaths` configuration). Classes listed in `ViewComponentParentClasses` are automatically exempt, as abstract base classes are not rendered standalone.
31
31
 
32
32
  ## Optional Configuration
33
33
 
34
34
  ### Base Class
35
35
 
36
- By default, the cops detect classes that inherit from `ViewComponent::Base` or `ApplicationComponent`. If your project uses a different base class (e.g. `Primer::Component`), you can configure additional parent classes under `AllCops`, for example:
36
+ By default, the cops detect classes that inherit from `ViewComponent::Base` or `ApplicationComponent`. To add extra parent classes, use `inherit_mode: merge` so the defaults are preserved:
37
37
 
38
38
  ```yaml
39
39
  # .rubocop.yml
40
- AllCops:
40
+ ViewComponent:
41
+ inherit_mode:
42
+ merge:
43
+ - ViewComponentParentClasses
41
44
  ViewComponentParentClasses:
42
45
  - MyApp::BaseComponent
43
46
  ```
44
47
 
48
+ To replace the defaults entirely (e.g. if your project has renamed `ApplicationComponent`), omit `inherit_mode`:
49
+
50
+ ```yaml
51
+ # .rubocop.yml
52
+ ViewComponent:
53
+ ViewComponentParentClasses:
54
+ - ViewComponent::Base
55
+ - MyApp::BaseComponent
56
+ ```
57
+
58
+ ### Components Directory
59
+
60
+ 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`:
61
+
62
+ ```yaml
63
+ # .rubocop.yml
64
+ ViewComponent/ComponentSuffix:
65
+ Include:
66
+ - 'app/components/**/*.rb'
67
+ - 'engines/*/app/components/**/*.rb'
68
+ ```
69
+
45
70
  ### No Super
46
71
 
47
- View Component convention is to not calling `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:
72
+ 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:
48
73
 
49
74
  ```yaml
50
75
  # .rubocop.yml
data/Rakefile CHANGED
@@ -1,13 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "bundler/gem_tasks"
4
- require "minitest/test_task"
4
+ require "rubocop/rake_task"
5
5
 
6
- Minitest::TestTask.create
6
+ RuboCop::RakeTask.new
7
7
 
8
- require "standard/rake"
9
-
10
- task default: %i[spec standard]
8
+ task default: %i[spec rubocop]
11
9
 
12
10
  require "rspec/core/rake_task"
13
11
 
data/config/default.yml CHANGED
@@ -1,5 +1,7 @@
1
1
  ViewComponent:
2
- ViewComponentParentClasses: []
2
+ ViewComponentParentClasses:
3
+ - ViewComponent::Base
4
+ - ApplicationComponent
3
5
  ComponentNamespaces: []
4
6
 
5
7
  ViewComponent/ComponentSuffix:
@@ -8,6 +10,8 @@ ViewComponent/ComponentSuffix:
8
10
  VersionAdded: '0.1'
9
11
  Severity: convention
10
12
  StyleGuide: 'https://viewcomponent.org/best_practices.html'
13
+ Include:
14
+ - 'app/components/**/*.rb'
11
15
 
12
16
  ViewComponent/MissingPreview:
13
17
  Description: 'Ensure every ViewComponent has a corresponding preview file.'
@@ -52,6 +56,8 @@ ViewComponent/PreferComposition:
52
56
  VersionAdded: '0.3'
53
57
  Severity: convention
54
58
  StyleGuide: 'https://viewcomponent.org/best_practices.html#avoid-inheritance'
59
+ Include:
60
+ - 'app/components/**/*.rb'
55
61
  ComponentNamespaces: []
56
62
 
57
63
  ViewComponent/PreferSlots:
@@ -9,7 +9,7 @@ module RuboCop
9
9
  def view_component_class?(node)
10
10
  return false unless node&.class_type?
11
11
 
12
- class_source = node.identifier.source
12
+ class_source = fully_qualified_name(node)
13
13
  return true if component_namespaces.any? { |ns| class_source.start_with?(ns) }
14
14
 
15
15
  parent_class = node.parent_class
@@ -18,16 +18,26 @@ module RuboCop
18
18
  view_component_parent?(parent_class)
19
19
  end
20
20
 
21
- # Check if node represents ViewComponent::Base, ApplicationComponent,
22
- # or a configured additional parent class
21
+ # Check if node represents a configured parent class
23
22
  def view_component_parent?(node)
24
23
  return false unless node.const_type?
25
24
 
26
- source = node.source
27
- return true if source == "ViewComponent::Base" || source == "ApplicationComponent"
25
+ (cop_config["ViewComponentParentClasses"] || []).include?(node.source)
26
+ end
27
+
28
+ # Check if a class node is itself one of the registered parent classes.
29
+ def view_component_parent_class?(node)
30
+ return false unless node&.class_type?
31
+
32
+ (cop_config["ViewComponentParentClasses"] || []).include?(fully_qualified_name(node))
33
+ end
34
+
35
+ def fully_qualified_name(node)
36
+ namespace = node.parent_module_name
37
+ short_name = node.identifier.source
38
+ return short_name if namespace.nil? || namespace == "Object"
28
39
 
29
- additional = cop_config["ViewComponentParentClasses"] || []
30
- additional.include?(source)
40
+ "#{namespace}::#{short_name}"
31
41
  end
32
42
 
33
43
  def component_namespaces
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/inflector"
4
+
3
5
  module RuboCop
4
6
  module Cop
5
7
  module ViewComponent
@@ -15,8 +17,9 @@ module RuboCop
15
17
 
16
18
  def on_class(node)
17
19
  return unless view_component_class?(node)
20
+ return if view_component_parent_class?(node)
18
21
 
19
- class_name = node.identifier.source
22
+ class_name = fully_qualified_name(node)
20
23
  return if preview_exists?(class_name)
21
24
 
22
25
  add_offense(node.identifier, message: format(MSG, component: class_name, paths: preview_paths.join(", ")))
@@ -33,12 +36,11 @@ module RuboCop
33
36
  end
34
37
 
35
38
  def candidate_filenames(class_name)
36
- base = class_name.gsub(/Component$/, "").gsub("::", "/").gsub(/([A-Z])/, '_\1').downcase.gsub("/_", "/")
37
- base = base.delete_prefix("_").delete_prefix("/")
38
- [
39
- "#{base}_preview.rb",
40
- "#{base}_component_preview.rb"
41
- ]
39
+ bases = [ActiveSupport::Inflector.underscore(class_name.delete_suffix("Component"))]
40
+ short_name = class_name.split("::").last
41
+ short_base = ActiveSupport::Inflector.underscore(short_name.delete_suffix("Component"))
42
+ bases << short_base if short_base != bases.first
43
+ bases.flat_map { |base| ["#{base}_preview.rb", "#{base}_component_preview.rb"] }
42
44
  end
43
45
 
44
46
  def preview_paths
@@ -45,18 +45,32 @@ module RuboCop
45
45
 
46
46
  RESTRICT_ON_SEND = GLOBAL_STATE_METHODS
47
47
 
48
+ # @!method global_state_access?(node)
48
49
  def_node_matcher :global_state_access?, <<~PATTERN
49
50
  (send nil? ${:params :request :session :cookies :flash} ...)
50
51
  PATTERN
51
52
 
53
+ # @!method sorbet_sig_block?(node)
54
+ def_node_matcher :sorbet_sig_block?, <<~PATTERN
55
+ (block (send nil? :sig) ...)
56
+ PATTERN
57
+
52
58
  def on_send(node)
53
59
  return unless inside_view_component?(node)
54
60
 
55
61
  method_name = global_state_access?(node)
56
62
  return unless method_name
63
+ return if sorbet_signature?(node)
57
64
 
58
65
  add_offense(node, message: format(MSG, method: method_name))
59
66
  end
67
+ alias on_csend on_send
68
+
69
+ private
70
+
71
+ def sorbet_signature?(node)
72
+ node.each_ancestor(:block).any? { |ancestor| sorbet_sig_block?(ancestor) }
73
+ end
60
74
  end
61
75
  end
62
76
  end
@@ -40,29 +40,32 @@ module RuboCop
40
40
  private
41
41
 
42
42
  def check_public_methods(class_node)
43
- current_visibility = :public
44
- template_method_calls = methods_called_in_templates
45
-
46
43
  body = class_node.body
47
44
  return unless body
48
45
 
49
46
  children = body.begin_type? ? body.children : [body]
47
+ check_children(children, methods_called_in_templates)
48
+ end
49
+
50
+ def check_children(children, template_method_calls)
51
+ current_visibility = :public
50
52
 
51
53
  children.each do |child|
52
54
  if visibility_modifier?(child)
53
55
  current_visibility = child.method_name
54
- next
56
+ elsif should_flag?(child, current_visibility, template_method_calls)
57
+ add_offense(child, message: format(MSG, method_name: child.method_name))
55
58
  end
56
-
57
- next unless child.def_type?
58
- next unless current_visibility == :public
59
- next if allowed_public_method?(child.method_name)
60
- next if template_method_calls.include?(child.method_name)
61
-
62
- add_offense(child, message: format(MSG, method_name: child.method_name))
63
59
  end
64
60
  end
65
61
 
62
+ def should_flag?(child, visibility, template_method_calls)
63
+ child.def_type? &&
64
+ visibility == :public &&
65
+ !allowed_public_method?(child.method_name) &&
66
+ !template_method_calls.include?(child.method_name)
67
+ end
68
+
66
69
  def allowed_public_method?(method_name)
67
70
  allowed_public_methods.include?(method_name.to_s) ||
68
71
  allowed_public_method_patterns.any? { |pattern| method_name.to_s.match?(pattern) }
@@ -84,8 +87,7 @@ module RuboCop
84
87
  template_paths.each_with_object(Set.new) do |path, methods|
85
88
  methods.merge(extract_method_calls(path))
86
89
  end
87
- rescue => e
88
- # Graceful degradation on errors
90
+ rescue StandardError => e
89
91
  warn "Warning: Failed to analyze templates: #{e.message}" if ENV["RUBOCOP_DEBUG"]
90
92
  Set.new
91
93
  end
@@ -31,6 +31,7 @@ module RuboCop
31
31
 
32
32
  HTML_PARAM_PATTERN = /_html$/
33
33
 
34
+ # @!method html_safe_call?(node)
34
35
  def_node_search :html_safe_call?, "(send _ :html_safe)"
35
36
 
36
37
  def on_class(node)
@@ -46,29 +47,23 @@ module RuboCop
46
47
 
47
48
  def find_initialize(class_node)
48
49
  class_node.each_descendant(:def).find do |def_node|
49
- def_node.method_name == :initialize
50
+ def_node.method?(:initialize)
50
51
  end
51
52
  end
52
53
 
53
54
  def check_initialize_params(initialize_node)
54
55
  initialize_node.arguments.each do |arg|
55
- next unless arg.kwoptarg_type? || arg.kwarg_type?
56
+ next unless arg.type?(:kwoptarg, :kwarg)
56
57
 
57
- param_name = arg.children[0]
58
+ check_param(arg)
59
+ end
60
+ end
58
61
 
59
- # Check parameter name patterns
60
- if html_param_name?(param_name)
61
- suggested_slot = suggest_slot_name(param_name)
62
- add_offense(arg, message: format(MSG, slot_method: suggested_slot))
63
- next
64
- end
62
+ def check_param(arg)
63
+ param_name = arg.children[0]
64
+ return unless html_param_name?(param_name) || (arg.kwoptarg_type? && html_safe_call?(arg))
65
65
 
66
- # Check for html_safe in default value
67
- if arg.kwoptarg_type? && html_safe_call?(arg)
68
- suggested_slot = suggest_slot_name(param_name)
69
- add_offense(arg, message: format(MSG, slot_method: suggested_slot))
70
- end
71
- end
66
+ add_offense(arg, message: format(MSG, slot_method: suggest_slot_name(param_name)))
72
67
  end
73
68
 
74
69
  def html_param_name?(name)
@@ -16,26 +16,26 @@ module RuboCop
16
16
  base_path = component_path.sub(/\.rb$/, "")
17
17
  component_dir = File.dirname(component_path)
18
18
  component_name = File.basename(component_path, ".rb")
19
+ sidecar_dir = File.join(component_dir, component_name)
19
20
 
20
- paths = []
21
-
22
- # Check for sibling template: same_name.html.erb
23
- sibling_template = "#{base_path}.html.erb"
24
- paths << sibling_template if File.exist?(sibling_template)
25
-
26
- # Check for sidecar template: same_name/same_name.html.erb
27
- sidecar_template = File.join(component_dir, component_name, "#{component_name}.html.erb")
28
- paths << sidecar_template if File.exist?(sidecar_template)
29
-
30
- # Check for variants: same_name.*.html.erb
31
- variant_pattern = "#{base_path}.*.html.erb"
32
- paths.concat(Dir.glob(variant_pattern))
21
+ paths = sibling_templates(base_path) + sidecar_templates(sidecar_dir, component_name)
22
+ paths.uniq
23
+ end
33
24
 
34
- # Check for sidecar variants: same_name/same_name.*.html.erb
35
- sidecar_variant_pattern = File.join(component_dir, component_name, "#{component_name}.*.html.erb")
36
- paths.concat(Dir.glob(sidecar_variant_pattern))
25
+ def sibling_templates(base_path)
26
+ paths = []
27
+ sibling = "#{base_path}.html.erb"
28
+ paths << sibling if File.exist?(sibling)
29
+ paths.concat(Dir.glob("#{base_path}.*.html.erb"))
30
+ paths
31
+ end
37
32
 
38
- paths.uniq
33
+ def sidecar_templates(sidecar_dir, component_name)
34
+ paths = []
35
+ sidecar = File.join(sidecar_dir, "#{component_name}.html.erb")
36
+ paths << sidecar if File.exist?(sidecar)
37
+ paths.concat(Dir.glob(File.join(sidecar_dir, "#{component_name}.*.html.erb")))
38
+ paths
39
39
  end
40
40
 
41
41
  # Extract method calls from an ERB template
@@ -49,7 +49,7 @@ module RuboCop
49
49
 
50
50
  # Parse the extracted Ruby code
51
51
  parse_ruby_for_method_calls(ruby_code)
52
- rescue => e
52
+ rescue StandardError => e
53
53
  # Graceful degradation on parse errors
54
54
  warn "Warning: Failed to parse template #{template_path}: #{e.message}" if ENV["RUBOCOP_DEBUG"]
55
55
  Set.new
@@ -78,7 +78,7 @@ module RuboCop
78
78
  return unless node.respond_to?(:type)
79
79
 
80
80
  # Look for send nodes with nil receiver (local method calls)
81
- if node.type == :send && node.receiver.nil?
81
+ if node.send_type? && node.receiver.nil?
82
82
  method_name = node.method_name
83
83
  method_calls.add(method_name)
84
84
  end
@@ -28,6 +28,7 @@ module RuboCop
28
28
  # Check Minitest-style test methods
29
29
  def on_def(node)
30
30
  return unless within_test_paths?
31
+
31
32
  method_name = node.method_name.to_s
32
33
  return unless method_name.start_with?("test_")
33
34
  return unless instantiates_component?(node)
@@ -45,6 +46,7 @@ module RuboCop
45
46
 
46
47
  add_offense(node)
47
48
  end
49
+ alias on_numblock on_block
48
50
 
49
51
  private
50
52
 
@@ -58,7 +60,7 @@ module RuboCop
58
60
 
59
61
  def instantiates_component?(node)
60
62
  node.each_descendant(:send).any? do |send_node|
61
- next unless send_node.method_name == :new
63
+ next unless send_node.method?(:new)
62
64
 
63
65
  send_node.receiver&.const_type?
64
66
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RuboCop
4
4
  module ViewComponent
5
- VERSION = "0.5.1"
5
+ VERSION = "0.6.0"
6
6
  end
7
7
  end
data/script/verify CHANGED
@@ -68,8 +68,8 @@ def parse_library_arg(libraries)
68
68
  library_arg = ARGV.find { |arg| !arg.start_with?("--") }
69
69
 
70
70
  if library_arg.nil?
71
- abort "Usage: #{$PROGRAM_NAME} <library> [--regenerate] [--update]\n" \
72
- " library: #{libraries.keys.join(", ")}"
71
+ abort "Usage: #{$PROGRAM_NAME} <library> [--regenerate] [--update]\n " \
72
+ "library: #{libraries.keys.join(", ")}"
73
73
  end
74
74
 
75
75
  unless libraries.key?(library_arg)
@@ -79,8 +79,8 @@ def parse_library_arg(libraries)
79
79
  library_arg
80
80
  end
81
81
 
82
- def system!(*args)
83
- system(*args, exception: true)
82
+ def system!(*)
83
+ system(*, exception: true)
84
84
  end
85
85
 
86
86
  def download_source(dir, tarball_url, display_name)
@@ -97,10 +97,10 @@ def configure_rubocop(config_file, display_name)
97
97
  # Merge the library config into the existing config
98
98
  library_config.each do |key, value|
99
99
  config[key] = if config[key].is_a?(Hash) && value.is_a?(Hash)
100
- config[key].merge(value)
101
- else
102
- value
103
- end
100
+ config[key].merge(value)
101
+ else
102
+ value
103
+ end
104
104
  end
105
105
 
106
106
  File.write(".rubocop.yml", YAML.dump(config))
@@ -118,16 +118,15 @@ def run_rubocop
118
118
  puts "Running RuboCop (ViewComponent cops only)..."
119
119
  output, status = Open3.capture2(
120
120
  "bundle", "exec", "rubocop",
121
- "--require", "rubocop-view_component",
121
+ "--plugin", "rubocop-view_component",
122
122
  "--only", "ViewComponent",
123
+ "--config", ".rubocop.yml",
123
124
  "--format", "json"
124
125
  )
125
126
 
126
127
  puts "RuboCop exit status: #{status.exitstatus}"
127
128
 
128
- if output.strip.empty?
129
- abort "ERROR: RuboCop produced no output (exit status: #{status.exitstatus})"
130
- end
129
+ abort "ERROR: RuboCop produced no output (exit status: #{status.exitstatus})" if output.strip.empty?
131
130
 
132
131
  output
133
132
  end
@@ -166,11 +165,9 @@ def load_expected(results_file)
166
165
  end
167
166
 
168
167
  def verify(offenses, results_file)
169
- unless File.exist?(results_file)
170
- abort "ERROR: #{results_file} not found. Run '#{$PROGRAM_NAME} --regenerate' first."
171
- end
168
+ abort "ERROR: #{results_file} not found. Run '#{$PROGRAM_NAME} --regenerate' first." unless File.exist?(results_file)
172
169
 
173
- expected = load_expected(results_file)
170
+ expected = load_expected(results_file) || {}
174
171
 
175
172
  if offenses == expected
176
173
  puts "Verification passed: output matches #{results_file}"
@@ -22,7 +22,6 @@ ViewComponent/MissingPreview:
22
22
  - 'app/components/polaris/placeholder_component.rb'
23
23
  - 'app/components/polaris/shopify_navigation_component.rb'
24
24
  - 'app/components/polaris/text_field_component.rb'
25
- - 'app/components/polaris/toast_component.rb'
26
25
  ViewComponent/NoGlobalState:
27
26
  - 'app/components/polaris/shopify_navigation_component.rb'
28
27
  ViewComponent/PreferPrivateMethods:
@@ -74,11 +74,11 @@ RSpec.describe RuboCop::Cop::ViewComponent::ComponentSuffix, :config do
74
74
  end
75
75
  end
76
76
 
77
- context "when ViewComponentParentClasses is configured" do
77
+ context "when ViewComponentParentClasses is configured with merge" do
78
78
  let(:config) do
79
79
  RuboCop::Config.new(
80
80
  "ViewComponent/ComponentSuffix" => {
81
- "ViewComponentParentClasses" => ["Primer::Component"]
81
+ "ViewComponentParentClasses" => ["ViewComponent::Base", "ApplicationComponent", "Primer::Component"]
82
82
  }
83
83
  )
84
84
  end
@@ -107,6 +107,31 @@ RSpec.describe RuboCop::Cop::ViewComponent::ComponentSuffix, :config do
107
107
  end
108
108
  end
109
109
 
110
+ context "when ViewComponentParentClasses is configured replacing defaults" do
111
+ let(:config) do
112
+ RuboCop::Config.new(
113
+ "ViewComponent/ComponentSuffix" => {
114
+ "ViewComponentParentClasses" => ["Primer::Component"]
115
+ }
116
+ )
117
+ end
118
+
119
+ it "registers an offense for classes inheriting from a configured parent" do
120
+ expect_offense(<<~RUBY)
121
+ class FooBar < Primer::Component
122
+ ^^^^^^ ViewComponent class names should end with `Component`.
123
+ end
124
+ RUBY
125
+ end
126
+
127
+ it "does not recognize default parent classes that were replaced" do
128
+ expect_no_offenses(<<~RUBY)
129
+ class FooBar < ViewComponent::Base
130
+ end
131
+ RUBY
132
+ end
133
+ end
134
+
110
135
  context "with compact nested class syntax" do
111
136
  it "registers offense for compact syntax" do
112
137
  expect_offense(<<~RUBY)
@@ -5,14 +5,16 @@ RSpec.describe RuboCop::Cop::ViewComponent::MissingPreview, :config do
5
5
  RuboCop::Config.new(
6
6
  "ViewComponent/MissingPreview" => {
7
7
  "Enabled" => true,
8
- "PreviewPaths" => ["/previews"]
8
+ "PreviewPaths" => ["/previews"],
9
+ "ViewComponentParentClasses" => %w[ViewComponent::Base ApplicationComponent]
9
10
  }
10
11
  )
11
12
  end
12
13
 
13
14
  context "when a preview file exists" do
14
15
  it "does not register an offense" do
15
- allow(File).to receive(:exist?).and_return(true)
16
+ allow(File).to receive(:exist?).and_return(false)
17
+ allow(File).to receive(:exist?).with("/previews/user_preview.rb").and_return(true)
16
18
 
17
19
  expect_no_offenses(<<~RUBY, "/app/components/user_component.rb")
18
20
  class UserComponent < ViewComponent::Base
@@ -23,7 +25,8 @@ RSpec.describe RuboCop::Cop::ViewComponent::MissingPreview, :config do
23
25
 
24
26
  context "when no preview file exists" do
25
27
  it "registers an offense" do
26
- allow(File).to receive(:exist?).and_return(false)
28
+ allow(File).to receive(:exist?).with("/previews/user_preview.rb").and_return(false)
29
+ allow(File).to receive(:exist?).with("/previews/user_component_preview.rb").and_return(false)
27
30
 
28
31
  expect_offense(<<~RUBY, "/app/components/user_component.rb")
29
32
  class UserComponent < ViewComponent::Base
@@ -45,7 +48,10 @@ RSpec.describe RuboCop::Cop::ViewComponent::MissingPreview, :config do
45
48
  end
46
49
 
47
50
  it "registers an offense for a component in a configured namespace" do
48
- allow(File).to receive(:exist?).and_return(false)
51
+ allow(File).to receive(:exist?).with("/previews/v2/table_preview.rb").and_return(false)
52
+ allow(File).to receive(:exist?).with("/previews/v2/table_component_preview.rb").and_return(false)
53
+ allow(File).to receive(:exist?).with("/previews/table_preview.rb").and_return(false)
54
+ allow(File).to receive(:exist?).with("/previews/table_component_preview.rb").and_return(false)
49
55
 
50
56
  expect_offense(<<~RUBY, "/app/components/v2/table.rb")
51
57
  class V2::Table < SomeBase
@@ -82,16 +88,129 @@ RSpec.describe RuboCop::Cop::ViewComponent::MissingPreview, :config do
82
88
  end
83
89
  RUBY
84
90
  end
91
+
92
+ it "registers an offense for a component declared with nested module form when no preview exists" do
93
+ allow(File).to receive(:exist?).and_return(false)
94
+
95
+ expect_offense(<<~RUBY, "/app/components/v2/table.rb")
96
+ module V2
97
+ class Table < SomeBase
98
+ ^^^^^ No preview found for V2::Table (looked in: /previews).
99
+ end
100
+ end
101
+ RUBY
102
+ end
103
+
104
+ it "does not register an offense for a component declared with nested module form when preview exists" do
105
+ allow(File).to receive(:exist?).and_return(false)
106
+ allow(File).to receive(:exist?).with("/previews/v2/grouped_multi_select_preview.rb").and_return(true)
107
+
108
+ expect_no_offenses(<<~RUBY, "/app/components/v2/grouped_multi_select.rb")
109
+ module V2
110
+ class GroupedMultiSelect < V2::MultiSelect
111
+ end
112
+ end
113
+ RUBY
114
+ end
85
115
  end
86
116
 
87
- context "when not a ViewComponent" do
88
- it "does not register an offense" do
117
+ context "when the component is declared with nested module form" do
118
+ it "does not register an offense when preview exists at the namespaced path" do
119
+ allow(File).to receive(:exist?).and_return(false)
120
+ allow(File).to receive(:exist?).with("/previews/admin/page_component_preview.rb").and_return(true)
121
+
122
+ expect_no_offenses(<<~RUBY, "/app/components/admin/page_component.rb")
123
+ module Admin
124
+ class PageComponent < ApplicationComponent
125
+ end
126
+ end
127
+ RUBY
128
+ end
129
+
130
+ it "registers an offense when no preview exists" do
89
131
  allow(File).to receive(:exist?).and_return(false)
90
132
 
133
+ expect_offense(<<~RUBY, "/app/components/admin/page_component.rb")
134
+ module Admin
135
+ class PageComponent < ApplicationComponent
136
+ ^^^^^^^^^^^^^ No preview found for Admin::PageComponent (looked in: /previews).
137
+ end
138
+ end
139
+ RUBY
140
+ end
141
+ end
142
+
143
+ context "when the namespace contains an acronym" do
144
+ it "does not register an offense when preview exists at the correct path" do
145
+ ActiveSupport::Inflector.inflections(:en) { |i| i.acronym "UI" }
146
+
147
+ allow(File).to receive(:exist?).and_return(false)
148
+ allow(File).to receive(:exist?).with("/previews/ui/button_component_preview.rb").and_return(true)
149
+
150
+ expect_no_offenses(<<~RUBY, "/app/components/ui/button_component.rb")
151
+ module UI
152
+ class ButtonComponent < ViewComponent::Base
153
+ end
154
+ end
155
+ RUBY
156
+ end
157
+ end
158
+
159
+ context "when not a ViewComponent" do
160
+ it "does not register an offense" do
91
161
  expect_no_offenses(<<~RUBY, "/app/components/user_component.rb")
92
162
  class UserComponent
93
163
  end
94
164
  RUBY
95
165
  end
96
166
  end
167
+
168
+ context "when the class is itself a registered parent class" do
169
+ it "does not register an offense for a built-in parent class" do
170
+ expect_no_offenses(<<~RUBY, "/app/components/application_component.rb")
171
+ class ApplicationComponent < ViewComponent::Base
172
+ end
173
+ RUBY
174
+ end
175
+
176
+ context "when a custom parent class is configured" do
177
+ let(:config) do
178
+ RuboCop::Config.new(
179
+ "ViewComponent/MissingPreview" => {
180
+ "Enabled" => true,
181
+ "PreviewPaths" => ["/previews"],
182
+ "ViewComponentParentClasses" => %w[ViewComponent::Base ApplicationComponent BaseComponent]
183
+ }
184
+ )
185
+ end
186
+
187
+ it "does not register an offense for the custom parent class" do
188
+ expect_no_offenses(<<~RUBY, "/app/components/base_component.rb")
189
+ class BaseComponent < ViewComponent::Base
190
+ end
191
+ RUBY
192
+ end
193
+ end
194
+
195
+ context "when a namespaced custom parent class is configured" do
196
+ let(:config) do
197
+ RuboCop::Config.new(
198
+ "ViewComponent/MissingPreview" => {
199
+ "Enabled" => true,
200
+ "PreviewPaths" => ["/previews"],
201
+ "ViewComponentParentClasses" => %w[ViewComponent::Base ApplicationComponent MyApp::BaseComponent]
202
+ }
203
+ )
204
+ end
205
+
206
+ it "does not register an offense for the namespaced custom parent class" do
207
+ expect_no_offenses(<<~RUBY, "/app/components/my_app/base_component.rb")
208
+ module MyApp
209
+ class BaseComponent < ViewComponent::Base
210
+ end
211
+ end
212
+ RUBY
213
+ end
214
+ end
215
+ end
97
216
  end
@@ -105,6 +105,19 @@ RSpec.describe RuboCop::Cop::ViewComponent::NoGlobalState, :config do
105
105
  end
106
106
  end
107
107
 
108
+ context "with Sorbet signatures" do
109
+ it "does not register offense for params in sig block" do
110
+ expect_no_offenses(<<~RUBY)
111
+ class UserComponent < ViewComponent::Base
112
+ sig { params(descriptor: String, admin_view: T::Boolean).returns(T.nilable(String)) }
113
+ def call(descriptor:, admin_view:)
114
+ @descriptor = descriptor
115
+ end
116
+ end
117
+ RUBY
118
+ end
119
+ end
120
+
108
121
  context "when not in a ViewComponent" do
109
122
  it "does not register offense in regular classes" do
110
123
  expect_no_offenses(<<~RUBY)
@@ -144,10 +144,11 @@ RSpec.describe RuboCop::Cop::ViewComponent::PreferPrivateMethods, :config do
144
144
  context "with custom patterns" do
145
145
  let(:config) do
146
146
  RuboCop::Config.new(
147
- "AllCops" => {"DisplayCopNames" => true},
147
+ "AllCops" => { "DisplayCopNames" => true },
148
148
  "ViewComponent/PreferPrivateMethods" => {
149
149
  "AllowedPublicMethods" => %w[initialize call],
150
- "AllowedPublicMethodPatterns" => ["^render_", "^with_"]
150
+ "AllowedPublicMethodPatterns" => ["^render_", "^with_"],
151
+ "ViewComponentParentClasses" => %w[ViewComponent::Base ApplicationComponent]
151
152
  }
152
153
  )
153
154
  end
@@ -186,10 +187,11 @@ RSpec.describe RuboCop::Cop::ViewComponent::PreferPrivateMethods, :config do
186
187
  context "with custom AllowedPublicMethods" do
187
188
  let(:config) do
188
189
  RuboCop::Config.new(
189
- "AllCops" => {"DisplayCopNames" => true},
190
+ "AllCops" => { "DisplayCopNames" => true },
190
191
  "ViewComponent/PreferPrivateMethods" => {
191
192
  "AllowedPublicMethods" => %w[initialize call custom_public_method],
192
- "AllowedPublicMethodPatterns" => []
193
+ "AllowedPublicMethodPatterns" => [],
194
+ "ViewComponentParentClasses" => %w[ViewComponent::Base ApplicationComponent]
193
195
  }
194
196
  )
195
197
  end
@@ -261,7 +263,7 @@ RSpec.describe RuboCop::Cop::ViewComponent::PreferPrivateMethods, :config do
261
263
  context "with template files" do
262
264
  let(:component_file) { "spec/fixtures/components/template_method_component.rb" }
263
265
 
264
- it "does not flag methods called in template, but flags unused methods" do
266
+ it "registers methods called in template, but flags unused methods" do
265
267
  expect_offense(<<~RUBY, component_file)
266
268
  # frozen_string_literal: true
267
269
 
@@ -99,7 +99,7 @@ RSpec.describe RuboCop::Cop::ViewComponent::TestRenderedOutput, :config do
99
99
  context "with TestPaths configured" do
100
100
  let(:config) do
101
101
  RuboCop::Config.new(
102
- "AllCops" => {"DisplayCopNames" => true},
102
+ "AllCops" => { "DisplayCopNames" => true },
103
103
  "ViewComponent/TestRenderedOutput" => {
104
104
  "TestPaths" => ["spec/components/v2/"]
105
105
  }
@@ -1,4 +1,7 @@
1
1
  ViewComponent:
2
+ inherit_mode:
3
+ merge:
4
+ - ViewComponentParentClasses
2
5
  ViewComponentParentClasses:
3
6
  - GovukComponent::Base
4
7
 
@@ -1,4 +1,7 @@
1
1
  ViewComponent:
2
+ inherit_mode:
3
+ merge:
4
+ - ViewComponentParentClasses
2
5
  ViewComponentParentClasses:
3
6
  - Polaris::Component
4
7
  - Component
@@ -1,4 +1,7 @@
1
1
  ViewComponent:
2
+ inherit_mode:
3
+ merge:
4
+ - ViewComponentParentClasses
2
5
  ViewComponentParentClasses:
3
6
  - Primer::Component
4
7
  - Primer::BaseComponent
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubocop-view_component
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Waite
@@ -9,6 +9,20 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activesupport
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: herb
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -118,6 +132,7 @@ metadata:
118
132
  homepage_uri: https://github.com/andyw8/rubocop-view_component
119
133
  source_code_uri: https://github.com/andyw8/rubocop-view_component
120
134
  default_lint_roller_plugin: RuboCop::ViewComponent::Plugin
135
+ rubygems_mfa_required: 'true'
121
136
  rdoc_options: []
122
137
  require_paths:
123
138
  - lib
@@ -125,7 +140,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
125
140
  requirements:
126
141
  - - ">="
127
142
  - !ruby/object:Gem::Version
128
- version: 2.7.0
143
+ version: 3.2.0
129
144
  required_rubygems_version: !ruby/object:Gem::Requirement
130
145
  requirements:
131
146
  - - ">="