rubocop-flexport 0.9.0 → 0.10.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: 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: []