rubocop-flexport 0.9.0 → 0.10.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 611e7d0841b96380210c5b9c0e69e1faa39714f485c7534a3c99f55a9567a854
4
- data.tar.gz: 0300627c5f7459c722941ce9f7bf6bf594cbb8c6eb6a1e26791ceeeff8651f86
3
+ metadata.gz: 1111bda95a88ccde039176a94717252bb9828339ecc9ffef53134068ce9c119a
4
+ data.tar.gz: c93f725711efde4dd37de393c365c744545c4193e7cc8e433e58f23ca3915768
5
5
  SHA512:
6
- metadata.gz: b23d6b05fe8ad73bd029a38b2fa9c5e957ac50433510e0b7f4b37ee94b1ac3148b8f477a4de297c194fc7f136d40168182f1c0a1442218e27aa9070b60c20e5c
7
- data.tar.gz: 0b855624ddcfae29e8d211a2e05e54d401a0d06cbfd5e4a118ad6a3bccef1200e409d22f35380cbbf44ec3fc180ed99b34111978042437674675d30e864c651c
6
+ metadata.gz: e8428aff55f6b2bc1bd5b9657942d5f11eb84deb9d98b8e7441db6ff2a6161169e80cfa088b8c0a05e0d54c84643b04b5bc623fa7db1bd728c3f2be17f760d11
7
+ data.tar.gz: 44ba932445440f9a79e1d5169a62ba41221529d6e5ca3ca69f538776f86fd80727371ad8a7df57045d583f45097ce9024b69d761bb1be756e5a805bda02533be
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'digest/sha1'
4
+
3
5
  # rubocop:disable Metrics/ClassLength
4
6
  module RuboCop
5
7
  module Cop
@@ -39,6 +41,11 @@ module RuboCop
39
41
  # The cop detects cross-engine associations as well as cross-engine
40
42
  # module access.
41
43
  #
44
+ # The cop will complain if you use FactoryBot factories defined in other
45
+ # engines in your engine's specs. You can disable this check by adding
46
+ # the engine name to `AllowCrossEngineFactoryBotFromEngines` in
47
+ # .rubocop.yml.
48
+ #
42
49
  # # Isolation guarantee
43
50
  #
44
51
  # This cop can be easily circumvented with metaprogramming, so it cannot
@@ -141,6 +148,7 @@ module RuboCop
141
148
  class EngineApiBoundary < Cop
142
149
  include EngineApi
143
150
  include EngineNodeContext
151
+ include FactoryBotUsage
144
152
 
145
153
  MSG = 'Direct access of %<accessed_engine>s engine. ' \
146
154
  'Only access engine via %<accessed_engine>s::Api.'
@@ -160,6 +168,10 @@ module RuboCop
160
168
  (send _ {:belongs_to :has_one :has_many} sym $hash)
161
169
  PATTERN
162
170
 
171
+ class << self
172
+ attr_accessor :factory_engines_cache
173
+ end
174
+
163
175
  def on_const(node)
164
176
  return if in_module_or_class_declaration?(node)
165
177
  # There might be value objects that are named
@@ -179,19 +191,42 @@ module RuboCop
179
191
 
180
192
  def on_send(node)
181
193
  rails_association_hash_args(node) do |assocation_hash_args|
182
- class_name_node = extract_class_name_node(assocation_hash_args)
183
- next if class_name_node.nil?
184
-
185
- accessed_engine = extract_model_engine(class_name_node)
186
- next if accessed_engine.nil?
187
- next if valid_engine_access?(node, accessed_engine)
194
+ check_for_cross_engine_rails_association(node, assocation_hash_args)
195
+ end
196
+ return unless check_for_cross_engine_factory_bot?
188
197
 
189
- add_offense(class_name_node, message: message(accessed_engine))
198
+ factory_bot_usage(node) do |factory_node|
199
+ check_for_cross_engine_factory_bot_usage(node, factory_node)
190
200
  end
191
201
  end
192
202
 
203
+ def check_for_cross_engine_rails_association(node, assocation_hash_args)
204
+ class_name_node = extract_class_name_node(assocation_hash_args)
205
+ return if class_name_node.nil?
206
+
207
+ accessed_engine = extract_model_engine(class_name_node)
208
+ return if accessed_engine.nil?
209
+ return if valid_engine_access?(node, accessed_engine)
210
+
211
+ add_offense(class_name_node, message: message(accessed_engine))
212
+ end
213
+
214
+ def check_for_cross_engine_factory_bot_usage(node, factory_node)
215
+ factory = factory_node.children[0]
216
+ accessed_engine, model_class_name = factory_engines[factory]
217
+ return if accessed_engine.nil?
218
+
219
+ model_class_node = parse_ast(model_class_name)
220
+ return if valid_engine_access?(model_class_node, accessed_engine)
221
+
222
+ add_offense(node, message: message(accessed_engine))
223
+ end
224
+
193
225
  def external_dependency_checksum
194
- engine_api_files_modified_time_checksum(engines_path)
226
+ checksum = engine_api_files_modified_time_checksum(engines_path)
227
+ return checksum unless check_for_cross_engine_factory_bot?
228
+
229
+ checksum + spec_factories_modified_time_checksum
195
230
  end
196
231
 
197
232
  private
@@ -296,14 +331,15 @@ module RuboCop
296
331
  end
297
332
 
298
333
  def current_engine
299
- @current_engine ||= begin
300
- file_path = processed_source.path
301
- if file_path&.include?(engines_path)
302
- parts = file_path.split(engines_path)
303
- engine_dir = parts.last.split('/').first
304
- ActiveSupport::Inflector.camelize(engine_dir) if engine_dir
305
- end
306
- end
334
+ @current_engine ||= engine_name_from_path(processed_source.path)
335
+ end
336
+
337
+ def engine_name_from_path(file_path)
338
+ return nil unless file_path&.include?(engines_path)
339
+
340
+ parts = file_path.split(engines_path)
341
+ engine_dir = parts.last.split('/').first
342
+ ActiveSupport::Inflector.camelize(engine_dir) if engine_dir
307
343
  end
308
344
 
309
345
  def in_engine_file?(accessed_engine)
@@ -333,7 +369,7 @@ module RuboCop
333
369
 
334
370
  depth = 0
335
371
  max_depth = 5
336
- while node.const_type? && depth < max_depth
372
+ while node&.const_type? && depth < max_depth
337
373
  full_const_name = remove_leading_colons(node.source)
338
374
  return true if allowlist.include?(full_const_name)
339
375
 
@@ -393,6 +429,37 @@ module RuboCop
393
429
  def strongly_protected_engine?(engine)
394
430
  strongly_protected_engines.include?(engine)
395
431
  end
432
+
433
+ def allow_cross_engine_factory_bot_from_engines
434
+ @allow_cross_engine_factory_bot_from_engines ||=
435
+ camelize_all(cop_config['AllowCrossEngineFactoryBotFromEngines'] || [])
436
+ end
437
+
438
+ def check_for_cross_engine_factory_bot?
439
+ spec_file? && !allow_cross_engine_factory_bot_from_engines.include?(current_engine)
440
+ end
441
+
442
+ # Maps factories to the engine where they are defined.
443
+ def factory_engines
444
+ # Cache factories at the class level so that we don't have to fetch
445
+ # them again for every file we lint.
446
+ self.class.factory_engines_cache ||= spec_factory_paths.each_with_object({}) do |path, h|
447
+ engine_name = engine_name_from_path(path)
448
+ ast = parse_ast(File.read(path))
449
+ find_factories(ast).each do |factory, model_class_name|
450
+ h[factory] = [engine_name, model_class_name]
451
+ end
452
+ end
453
+ end
454
+
455
+ def spec_factory_paths
456
+ @spec_factory_paths ||= Dir["#{engines_path}*/spec/factories/**/*.rb"]
457
+ end
458
+
459
+ def spec_factories_modified_time_checksum
460
+ mtimes = spec_factory_paths.sort.map { |f| File.mtime(f) }
461
+ Digest::SHA1.hexdigest(mtimes.join)
462
+ end
396
463
  end
397
464
  end
398
465
  end
@@ -45,8 +45,14 @@ module RuboCop
45
45
  # # No direct association to global models.
46
46
  # end
47
47
  #
48
+ # This cop will also complain if you try to use global FactoryBot
49
+ # factories in your engine's specs. To disable this behavior for your
50
+ # engine, add it to the `AllowGlobalFactoryBotFromEngines` list in
51
+ # .rubocop.yml.
52
+ #
48
53
  class GlobalModelAccessFromEngine < Cop
49
54
  include EngineNodeContext
55
+ include FactoryBotUsage
50
56
 
51
57
  MSG = 'Direct access of global model `%<model>s` ' \
52
58
  'from within Rails Engine.'
@@ -55,6 +61,10 @@ module RuboCop
55
61
  (send _ {:belongs_to :has_one :has_many} sym $hash)
56
62
  PATTERN
57
63
 
64
+ class << self
65
+ attr_accessor :global_factories_cache
66
+ end
67
+
58
68
  def on_const(node)
59
69
  return unless in_enforced_engine_file?
60
70
  return unless global_model_const?(node)
@@ -69,19 +79,41 @@ module RuboCop
69
79
  return unless in_enforced_engine_file?
70
80
 
71
81
  rails_association_hash_args(node) do |assocation_hash_args|
72
- class_name_node = extract_class_name_node(assocation_hash_args)
73
- class_name = class_name_node&.value
74
- next unless global_model?(class_name)
82
+ check_for_rails_association_with_global_model(assocation_hash_args)
83
+ end
84
+
85
+ return unless check_for_global_factory_bot?
75
86
 
76
- add_offense(class_name_node, message: message(class_name))
87
+ factory_bot_usage(node) do |factory_node|
88
+ check_for_global_factory_bot_usage(node, factory_node)
77
89
  end
78
90
  end
79
91
 
92
+ def check_for_rails_association_with_global_model(assocation_hash_args)
93
+ class_name_node = extract_class_name_node(assocation_hash_args)
94
+ class_name = class_name_node&.value
95
+ return unless global_model?(class_name)
96
+
97
+ add_offense(class_name_node, message: message(class_name))
98
+ end
99
+
100
+ def check_for_global_factory_bot_usage(node, factory_node)
101
+ factory = factory_node.children[0]
102
+ return unless global_factory?(factory)
103
+
104
+ model_class_name = global_factories[factory]
105
+ add_offense(node, message: message(model_class_name))
106
+ end
107
+
80
108
  # Because this cop's behavior depends on the state of external files,
81
109
  # we override this method to bust the RuboCop cache when those files
82
110
  # change.
83
111
  def external_dependency_checksum
84
- Digest::SHA1.hexdigest(model_dir_paths.join)
112
+ if check_for_global_factory_bot?
113
+ Digest::SHA1.hexdigest((model_dir_paths + global_factories.keys.sort).join)
114
+ else
115
+ Digest::SHA1.hexdigest(model_dir_paths.join)
116
+ end
85
117
  end
86
118
 
87
119
  private
@@ -94,10 +126,26 @@ module RuboCop
94
126
  @global_model_names ||= calculate_global_models
95
127
  end
96
128
 
129
+ def global_factories
130
+ # Cache factories at the class level so that we don't have to fetch
131
+ # them again for every file we lint.
132
+ self.class.global_factories_cache ||= spec_factory_paths.each_with_object({}) do |path, h|
133
+ source_code = File.read(path)
134
+ source = RuboCop::ProcessedSource.new(source_code, RUBY_VERSION.to_f)
135
+ find_factories(source.ast).each do |factory, model_class_name|
136
+ h[factory] = model_class_name
137
+ end
138
+ end
139
+ end
140
+
97
141
  def model_dir_paths
98
142
  Dir[File.join(global_models_path, '**/*.rb')]
99
143
  end
100
144
 
145
+ def spec_factory_paths
146
+ @spec_factory_paths ||= Dir['spec/factories/**/*.rb']
147
+ end
148
+
101
149
  def calculate_global_models
102
150
  all_model_paths = model_dir_paths.reject do |path|
103
151
  path.include?('/concerns/')
@@ -133,6 +181,12 @@ module RuboCop
133
181
  end
134
182
  end
135
183
 
184
+ def check_for_global_factory_bot?
185
+ spec_file? && allow_global_factory_bot_from_engines.none? do |engine|
186
+ processed_source.path.include?(File.join(engines_path, engine, ''))
187
+ end
188
+ end
189
+
136
190
  def global_model_const?(const_node)
137
191
  # Remove leading `::`, if any.
138
192
  class_name = const_node.source.sub(/^:*/, '')
@@ -144,6 +198,10 @@ module RuboCop
144
198
  global_model_names.include?(class_name)
145
199
  end
146
200
 
201
+ def global_factory?(factory_name)
202
+ global_factories.include?(factory_name)
203
+ end
204
+
147
205
  def child_of_const?(node)
148
206
  node.parent.const_type?
149
207
  end
@@ -165,6 +223,10 @@ module RuboCop
165
223
  end
166
224
  end
167
225
 
226
+ def allow_global_factory_bot_from_engines
227
+ cop_config['AllowGlobalFactoryBotFromEngines'] || []
228
+ end
229
+
168
230
  def allowed_global_models
169
231
  cop_config['AllowedGlobalModels'] || []
170
232
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'mixin/engine_api'
4
4
  require_relative 'mixin/engine_node_context'
5
+ require_relative 'mixin/factory_bot_usage'
5
6
 
6
7
  require_relative 'flexport/engine_api_boundary'
7
8
  require_relative 'flexport/global_model_access_from_engine'
@@ -62,8 +62,7 @@ module RuboCop
62
62
  File.join(engines_path, "#{raw_name}/app/api/#{raw_name}/api/")
63
63
  end
64
64
 
65
- def parse_ast(file_path)
66
- source_code = File.read(file_path)
65
+ def parse_ast(source_code)
67
66
  source = RuboCop::ProcessedSource.new(source_code, RUBY_VERSION.to_f)
68
67
  source.ast
69
68
  end
@@ -103,7 +102,7 @@ module RuboCop
103
102
  # s(:const, nil, :Trucking), :LoadTypes)), :freeze)))
104
103
  #
105
104
  # We want the :begin in the 2nd case, the :module in the 1st case.
106
- module_node = parse_ast(path)
105
+ module_node = parse_ast(File.read(path))
107
106
  module_block_node = module_node&.children&.[](1)
108
107
  if module_block_node&.begin_type?
109
108
  module_block_node
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/inflector'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ # Helpers for detecting FactoryBot usage.
8
+ module FactoryBotUsage
9
+ extend NodePattern::Macros
10
+
11
+ FACTORY_BOT_METHODS = %i[
12
+ attributes_for
13
+ attributes_for_list
14
+ build
15
+ build_list
16
+ build_pair
17
+ build_stubbed
18
+ build_stubbed_list
19
+ create
20
+ create_list
21
+ create_pair
22
+ ].freeze
23
+
24
+ def_node_matcher :factory_bot_usage, <<~PATTERN
25
+ (send _ {#{FACTORY_BOT_METHODS.map(&:inspect).join(' ')}} $sym)
26
+ PATTERN
27
+
28
+ def spec_file?
29
+ processed_source&.path&.match?(/_spec\.rb$/) || false
30
+ end
31
+
32
+ # Recursively traverses a Parser::AST::Node, returning an array of
33
+ # [factory_name, model_class_name] 2-tuples.
34
+ def find_factories(node, model_class_name = nil)
35
+ factories = []
36
+ return factories unless node.is_a?(Parser::AST::Node)
37
+
38
+ factory_node = extract_factory_node(node)
39
+ if factory_node
40
+ factory_name, aliases, model_class_name = parse_factory_node(factory_node, model_class_name)
41
+ if factory_node?(node)
42
+ ([factory_name] + aliases).each do |name|
43
+ factories << [name, model_class_name]
44
+ end
45
+ end
46
+ end
47
+
48
+ factories + node.children.flat_map { |child| find_factories(child, model_class_name) }
49
+ end
50
+
51
+ private
52
+
53
+ def extract_factory_node(node)
54
+ return node.children[0] if factory_block?(node)
55
+ return node if factory_node?(node)
56
+ end
57
+
58
+ def factory_block?(node)
59
+ return false if node&.type != :block
60
+
61
+ factory_node?(node.children[0])
62
+ end
63
+
64
+ def factory_node?(node)
65
+ node&.type == :send && node.children[1] == :factory
66
+ end
67
+
68
+ def parse_factory_node(node, model_class_name_from_parent_factory = nil)
69
+ factory_name_node, factory_config_node = node.children[2..3]
70
+
71
+ factory_name = factory_name_node.children[0]
72
+ aliases = extract_aliases(factory_config_node)
73
+ explicit_model_class_name = extract_model_class_name(factory_config_node)
74
+ model_class_name = explicit_model_class_name ||
75
+ model_class_name_from_parent_factory ||
76
+ ActiveSupport::Inflector.camelize(factory_name)
77
+
78
+ [factory_name, aliases, model_class_name]
79
+ end
80
+
81
+ def extract_aliases(factory_config_hash_node)
82
+ aliases_array = extract_hash_value(factory_config_hash_node, :aliases)
83
+ return [] if aliases_array&.type != :array
84
+
85
+ aliases_array.children.map(&:value)
86
+ end
87
+
88
+ def extract_model_class_name(factory_config_hash_node)
89
+ model_class_name_node = extract_hash_value(factory_config_hash_node, :class)
90
+
91
+ case model_class_name_node&.type
92
+ when :const
93
+ model_class_name_node.source.sub(/^::/, '')
94
+ when :str
95
+ model_class_name_node.value.sub(/^::/, '')
96
+ end
97
+ end
98
+
99
+ def extract_hash_value(node, hash_key)
100
+ return nil if node&.type != :hash
101
+
102
+ pairs = node.children.select { |child| child.type == :pair }
103
+ pairs.each do |pair|
104
+ key, value = pair.children
105
+ return value if key.value == hash_key
106
+ end
107
+
108
+ nil
109
+ end
110
+ end
111
+ end
112
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RuboCop
4
4
  module Flexport
5
- VERSION = '0.9.0'
5
+ VERSION = '0.10.0'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubocop-flexport
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Flexport Engineering
8
- autorequire:
8
+ autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-06-24 00:00:00.000000000 Z
11
+ date: 2020-07-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -57,6 +57,7 @@ files:
57
57
  - lib/rubocop/cop/flexport_cops.rb
58
58
  - lib/rubocop/cop/mixin/engine_api.rb
59
59
  - lib/rubocop/cop/mixin/engine_node_context.rb
60
+ - lib/rubocop/cop/mixin/factory_bot_usage.rb
60
61
  - lib/rubocop/flexport.rb
61
62
  - lib/rubocop/flexport/inject.rb
62
63
  - lib/rubocop/flexport/version.rb
@@ -64,7 +65,7 @@ homepage: https://github.com/flexport/rubocop-flexport
64
65
  licenses:
65
66
  - MIT
66
67
  metadata: {}
67
- post_install_message:
68
+ post_install_message:
68
69
  rdoc_options: []
69
70
  require_paths:
70
71
  - lib
@@ -79,9 +80,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
79
80
  - !ruby/object:Gem::Version
80
81
  version: '0'
81
82
  requirements: []
82
- rubyforge_project:
83
- rubygems_version: 2.7.6
84
- signing_key:
83
+ rubygems_version: 3.1.2
84
+ signing_key:
85
85
  specification_version: 4
86
86
  summary: RuboCop cops used at Flexport.
87
87
  test_files: []