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.
- checksums.yaml +4 -4
- data/.rubocop.yml +4 -0
- data/{CLAUDE.md → AGENTS.md} +6 -14
- data/Appraisals +5 -0
- data/README.md +50 -27
- data/config/default.yml +6 -5
- data/docs/rubydex.md +136 -0
- data/gemfiles/rubocop_minimum.gemfile +13 -0
- data/gemfiles/rubocop_minimum.gemfile.lock +162 -0
- data/lib/rubocop/cop/view_component/base.rb +56 -7
- data/lib/rubocop/cop/view_component/prefer_composition.rb +42 -9
- data/lib/rubocop/cop/view_component/prefer_private_methods.rb +46 -13
- data/lib/rubocop/cop/view_component/rspec_scope.rb +98 -0
- data/lib/rubocop/cop/view_component/supplementary_index.rb +142 -0
- data/lib/rubocop/cop/view_component/test_rendered_output.rb +107 -11
- data/lib/rubocop/view_component/version.rb +1 -1
- data/script/verify +29 -16
- data/spec/expected_flowbite_failures.yml +64 -0
- data/spec/expected_govuk_failures.yml +10 -12
- data/spec/expected_polaris_failures.yml +2 -0
- data/spec/expected_primer_failures.yml +60 -2
- data/spec/rubocop/cop/view_component/base_spec.rb +108 -0
- data/spec/rubocop/cop/view_component/component_suffix_spec.rb +0 -58
- data/spec/rubocop/cop/view_component/missing_preview_spec.rb +1 -42
- data/spec/rubocop/cop/view_component/prefer_composition_spec.rb +91 -4
- data/spec/rubocop/cop/view_component/prefer_private_methods_spec.rb +126 -4
- data/spec/rubocop/cop/view_component/test_rendered_output_spec.rb +287 -4
- data/spec/spec_helper.rb +10 -0
- data/spec/support/project_index_helpers.rb +71 -0
- data/verification/flowbite_rubocop_config.yml +12 -0
- data/verification/govuk_rubocop_config.yml +3 -6
- data/verification/libraries.yml +4 -0
- data/verification/polaris_rubocop_config.yml +3 -7
- data/verification/primer_rubocop_config.yml +5 -7
- 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
|
|
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
|
-
|
|
37
|
-
|
|
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
|
|
54
|
+
if bare_visibility_modifier?(child)
|
|
55
55
|
current_visibility = child.method_name
|
|
56
|
-
elsif
|
|
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
|
-
|
|
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
|
|
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
|
|
80
|
+
return unless rspec_example_block?(node)
|
|
44
81
|
return unless instantiates_component?(node)
|
|
45
|
-
return if
|
|
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
|
|
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
|
|
116
|
+
component_receiver?(send_node.receiver)
|
|
66
117
|
end
|
|
67
118
|
end
|
|
68
119
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
76
|
-
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
|
-
|
|
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
|
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
|
-
|
|
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 "
|
|
94
|
-
config =
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|