rubocop-flexport 0.2.0 → 0.3.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: 21c9588d65a1cc1b11a85277a94b02caf3dd37bb067b8a2ae09b53da39052bb0
4
- data.tar.gz: 2ca05dbe05457065dcfd40e703e6a9c29340fd44a20862df21364ebc1c31e76f
3
+ metadata.gz: 8b441520e98f7b859443aefbd06f3d9794600b702d8b70ad37f1a5a072898947
4
+ data.tar.gz: 0c3767638f4c7303b0ff941cdbf16a589b757628404ae9b9f3a2b5505a9c8f37
5
5
  SHA512:
6
- metadata.gz: 375ce90040f7a4185de7bc785867b46574dbc04a24ac0f17d8d3213075b9f689c60aec9e127830aea48005f76ca3f8a8023948a792284f45460fc80ce704ff87
7
- data.tar.gz: 560c5d8655494e752fae1db186c5c7ee1d514fa938fb977c07d09a1c87b9edcffc17d9bec31aa6b42a25436df480f63e70caee00bc3b52f3cdd786cb18962aaf
6
+ metadata.gz: '08541d28bbedcf79eb1e570b97de445b965eed4c6ed266a4d6333bed6904f6e07ae63e2286a05607660cb7771829bddb830785a4565af1222eb4cb9f46f4d321'
7
+ data.tar.gz: a4ca03afba08fc095a8e6f152abe7ee8527b1eb80cd55f34a7d5c95bd4f86a46b34cb389d5d4af9cd1fc21fa13d30ca8624d79aa8f0c0f870c2a3e93247f4e2d
@@ -1,3 +1,11 @@
1
+ Flexport/EngineApiBoundary:
2
+ Description: 'Use Rails Engine APIs instead of arbitrary code access.'
3
+ Enabled: false
4
+ VersionAdded: '0.3.0'
5
+ EnginesPath: 'engines/'
6
+ UnprotectedEngines: []
7
+ EngineSpecificOverrides: []
8
+
1
9
  Flexport/GlobalModelAccessFromEngine:
2
10
  Description: 'Do not directly access global models from within Rails Engines.'
3
11
  Enabled: false
@@ -0,0 +1,320 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Flexport
6
+ # This cop prevents code outside of a Rails Engine from directly
7
+ # accessing the engine without going through an API. The goal is
8
+ # to improve modularity and enforce separation of concerns.
9
+ #
10
+ # # Defining an engine's API
11
+ #
12
+ # The cop looks inside an engine's `api/` directory to determine its
13
+ # API. API surface can be defined in two ways:
14
+ #
15
+ # - Add source files to `api/`. Code defined in these modules
16
+ # will be accessible outside your engine. For example, adding
17
+ # `api/foo_service.rb` will allow code outside your engine to
18
+ # invoke eg `MyEngine::Api::FooService.bar(baz)`.
19
+ # - Create a `_whitelist.rb` file in `api/`. Modules listed in
20
+ # this file are accessible to code outside the engine. The file
21
+ # must have this name and a particular format (see below).
22
+ #
23
+ # Both of these approaches can be used concurrently in the same engine.
24
+ # Due to Rails Engine directory conventions, the API directory should
25
+ # generally be located at eg `engines/my_engine/app/api/my_engine/api/`.
26
+ #
27
+ # # Usage
28
+ #
29
+ # This cop can be useful when splitting apart a legacy codebase.
30
+ # In particular, you might move some code into an engine without
31
+ # enabling the cop, and then enable the cop to see where the engine
32
+ # boundary is crossed. For each violation, you can either:
33
+ #
34
+ # - Expose new API surface from your engine
35
+ # - Move the violating file into the engine
36
+ # - Add the violating file to `_legacy_dependents.rb` (see below)
37
+ #
38
+ # The cop detects cross-engine associations as well as cross-engine
39
+ # module access.
40
+ #
41
+ # # Isolation guarantee
42
+ #
43
+ # This cop can be easily circumvented with metaprogramming, so it cannot
44
+ # strongly guarantee the isolation of engines. But it can serve as
45
+ # a useful guardrail during development, especially during incremental
46
+ # migrations.
47
+ #
48
+ # Consider using plain-old Ruby objects instead of ActiveRecords as the
49
+ # exchange value between engines. If one engine gets a reference to an
50
+ # ActiveRecord object for a model in another engine, it will be able
51
+ # to perform arbitrary reads and writes via associations and `.save`.
52
+ #
53
+ # # Example `api/_legacy_dependents.rb` file
54
+ #
55
+ # This file contains a burn-down list of source code files that still
56
+ # do direct access to an engine "under the hood", without using the
57
+ # API. It must have this structure.
58
+ #
59
+ # ```rb
60
+ # module MyEngine::Api::LegacyDependents
61
+ # FILES_WITH_DIRECT_ACCESS = [
62
+ # "app/models/some_old_legacy_model.rb",
63
+ # "engines/other_engine/app/services/other_engine/other_service.rb",
64
+ # ]
65
+ # end
66
+ # ```
67
+ #
68
+ # # Example `api/_whitelist.rb` file
69
+ #
70
+ # This file contains a list of modules that are allowed to be accessed
71
+ # by code outside the engine. It must have this structure.
72
+ #
73
+ # ```rb
74
+ # module MyEngine::Api::Whitelist
75
+ # PUBLIC_MODULES = [
76
+ # MyEngine::BarService,
77
+ # MyEngine::BazService,
78
+ # MyEngine::BatConstants,
79
+ # ]
80
+ # end
81
+ # ```
82
+ #
83
+ # @example
84
+ #
85
+ # # bad
86
+ # class MyService
87
+ # m = ReallyImportantSharedEngine::InternalModel.find(123)
88
+ # m.destroy
89
+ # end
90
+ #
91
+ # # good
92
+ # class MyService
93
+ # ReallyImportantSharedEngine::Api::SomeService.execute(123)
94
+ # end
95
+ #
96
+ # @example
97
+ #
98
+ # # bad
99
+ #
100
+ # class MyEngine::MyModel < ApplicationModel
101
+ # has_one :foo_model, class_name: "SharedEngine::FooModel"
102
+ # end
103
+ #
104
+ # # good
105
+ #
106
+ # class MyEngine::MyModel < ApplicationModel
107
+ # # (No direct associations to models in API-protected engines.)
108
+ # end
109
+ #
110
+ class EngineApiBoundary < Cop
111
+ include EngineApi
112
+
113
+ MSG = 'Direct access of %<engine>s engine. ' \
114
+ 'Only access engine via %<engine>s::Api.'
115
+
116
+ def_node_matcher :rails_association_hash_args, <<-PATTERN
117
+ (send _ {:belongs_to :has_one :has_many} sym $hash)
118
+ PATTERN
119
+
120
+ def on_const(node)
121
+ # Sometimes modules/class are declared with the same name as an
122
+ # engine. For example, you might have:
123
+ #
124
+ # /engines/foo
125
+ # /app/graph/types/foo
126
+ #
127
+ # We ignore instead of yielding false positive for the module
128
+ # declaration in the latter.
129
+ return if in_module_or_class_declaration?(node)
130
+ # Similarly, you might have value objects that are named
131
+ # the same as engines like:
132
+ #
133
+ # Warehouse.new
134
+ #
135
+ # We don't want to warn on these cases either.
136
+ return if sending_method_to_namespace_itself?(node)
137
+
138
+ engine = extract_engine(node)
139
+ return unless engine
140
+ return if valid_engine_access?(node, engine)
141
+
142
+ add_offense(node, message: format(MSG, engine: engine))
143
+ end
144
+
145
+ def on_send(node)
146
+ rails_association_hash_args(node) do |assocation_hash_args|
147
+ class_name_node = extract_class_name_node(assocation_hash_args)
148
+ next if class_name_node.nil?
149
+
150
+ engine = extract_model_engine(class_name_node)
151
+ next if engine.nil?
152
+ next if valid_engine_access?(node, engine)
153
+
154
+ add_offense(class_name_node, message: format(MSG, engine: engine))
155
+ end
156
+ end
157
+
158
+ def external_dependency_checksum
159
+ engine_api_files_modified_time_checksum(engines_path)
160
+ end
161
+
162
+ private
163
+
164
+ def extract_engine(node)
165
+ return nil unless protected_engines.include?(node.const_name)
166
+
167
+ node.const_name
168
+ end
169
+
170
+ def engines_path
171
+ path = cop_config['EnginesPath']
172
+ path += '/' unless path.end_with?('/')
173
+ path
174
+ end
175
+
176
+ def protected_engines
177
+ @protected_engines ||= begin
178
+ unprotected = cop_config['UnprotectedEngines'] || []
179
+ unprotected_camelized = camelize_all(unprotected)
180
+ all_engines_camelized - unprotected_camelized
181
+ end
182
+ end
183
+
184
+ def all_engines_camelized
185
+ all_snake_case = Dir["#{engines_path}*"].map do |e|
186
+ e.gsub(engines_path, '')
187
+ end
188
+ camelize_all(all_snake_case)
189
+ end
190
+
191
+ def camelize_all(names)
192
+ names.map { |n| ActiveSupport::Inflector.camelize(n) }
193
+ end
194
+
195
+ def in_module_or_class_declaration?(node)
196
+ depth = 0
197
+ max_depth = 10
198
+ while node.const_type? && depth < max_depth
199
+ node = node.parent
200
+ depth += 1
201
+ end
202
+ node.module_type? || node.class_type?
203
+ end
204
+
205
+ def sending_method_to_namespace_itself?(node)
206
+ node.parent.send_type?
207
+ end
208
+
209
+ def valid_engine_access?(node, engine)
210
+ (
211
+ in_engine_file?(engine) ||
212
+ in_legacy_dependent_file?(engine) ||
213
+ through_api?(node) ||
214
+ whitelisted?(node, engine) ||
215
+ engine_specific_override?(node)
216
+ )
217
+ end
218
+
219
+ def extract_model_engine(class_name_node)
220
+ class_name = class_name_node.value
221
+ prefix = class_name.split('::')[0]
222
+ is_engine_model = prefix && protected_engines.include?(prefix)
223
+ is_engine_model ? prefix : nil
224
+ end
225
+
226
+ def extract_class_name_node(assocation_hash_args)
227
+ return nil unless assocation_hash_args
228
+
229
+ assocation_hash_args.each_pair do |key, value|
230
+ # Note: The "value.str_type?" is necessary because you can do this:
231
+ #
232
+ # TYPE_CLIENT = "Client".freeze
233
+ # belongs_to :recipient, class_name: TYPE_CLIENT
234
+ #
235
+ # The cop just ignores these cases. We could try to resolve the
236
+ # value of the const from the source but that seems brittle.
237
+ return value if key.value == :class_name && value.str_type?
238
+ end
239
+ nil
240
+ end
241
+
242
+ def current_engine
243
+ @current_engine ||= begin
244
+ file_path = processed_source.path
245
+ if file_path&.include?(engines_path)
246
+ parts = file_path.split(engines_path)
247
+ engine_dir = parts.last.split('/').first
248
+ ActiveSupport::Inflector.camelize(engine_dir) if engine_dir
249
+ end
250
+ end
251
+ end
252
+
253
+ def in_engine_file?(engine)
254
+ current_engine == engine
255
+ end
256
+
257
+ def in_legacy_dependent_file?(engine)
258
+ legacy_dependents = read_api_file(engine, :legacy_dependents)
259
+ # The file names are strings so we need to remove the escaped quotes
260
+ # on either side from the source code.
261
+ legacy_dependents = legacy_dependents.map do |source|
262
+ source.delete('"')
263
+ end
264
+ legacy_dependents.any? do |legacy_dependent|
265
+ processed_source.path.include?(legacy_dependent)
266
+ end
267
+ end
268
+
269
+ def through_api?(node)
270
+ node.parent&.const_type? && node.parent.children.last == :Api
271
+ end
272
+
273
+ def whitelisted?(node, engine)
274
+ whitelist = read_api_file(engine, :whitelist)
275
+ return false if whitelist.empty?
276
+
277
+ depth = 0
278
+ max_depth = 5
279
+ while node.const_type? && depth < max_depth
280
+ full_const_name = remove_leading_colons(node.source)
281
+ return true if whitelist.include?(full_const_name)
282
+
283
+ node = node.parent
284
+ depth += 1
285
+ end
286
+
287
+ false
288
+ end
289
+
290
+ def remove_leading_colons(str)
291
+ str.sub(/^:*/, '')
292
+ end
293
+
294
+ def read_api_file(engine, file_basename)
295
+ extract_api_list(engines_path, engine, file_basename)
296
+ end
297
+
298
+ def overrides_by_engine
299
+ overrides_by_engine = {}
300
+ raw_overrides = cop_config['EngineSpecificOverrides']
301
+ return overrides_by_engine if raw_overrides.nil?
302
+
303
+ raw_overrides.each do |raw_override|
304
+ engine = ActiveSupport::Inflector.camelize(raw_override['Engine'])
305
+ overrides_by_engine[engine] = raw_override['AllowedModels']
306
+ end
307
+ overrides_by_engine
308
+ end
309
+
310
+ def engine_specific_override?(node)
311
+ model_name = node.parent.source
312
+ model_names_allowed_by_override = overrides_by_engine[current_engine]
313
+ return false unless model_names_allowed_by_override
314
+
315
+ model_names_allowed_by_override.include?(model_name)
316
+ end
317
+ end
318
+ end
319
+ end
320
+ end
@@ -1,4 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'mixin/engine_api'
4
+
5
+ require_relative 'flexport/engine_api_boundary'
3
6
  require_relative 'flexport/global_model_access_from_engine'
4
7
  require_relative 'flexport/new_global_model'
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/inflector'
4
+ require 'digest/sha1'
5
+
6
+ module RuboCop
7
+ module Cop
8
+ # Functionality for reading Rails Engine API declaration files.
9
+ module EngineApi
10
+ extend NodePattern::Macros
11
+
12
+ API_FILE_DETAILS = {
13
+ whitelist: {
14
+ file_basename: '_whitelist.rb',
15
+ array_matcher: :whitelist_array
16
+ },
17
+ legacy_dependents: {
18
+ file_basename: '_legacy_dependents.rb',
19
+ array_matcher: :legacy_dependents_array
20
+ }
21
+ }.freeze
22
+
23
+ def extract_api_list(engines_path, engine, api_file)
24
+ key = cache_key(engine, api_file)
25
+ @cache ||= {}
26
+ cached = @cache[key]
27
+ return cached if cached
28
+
29
+ details = API_FILE_DETAILS[api_file]
30
+
31
+ path = full_path(engines_path, engine, details)
32
+ return [] unless File.file?(path)
33
+
34
+ list = extract_array(path, details[:array_matcher])
35
+
36
+ @cache[key] = list
37
+ list
38
+ end
39
+
40
+ def engine_api_files_modified_time_checksum(engines_path)
41
+ api_files = Dir.glob(File.join(engines_path, '**/app/api/**/api/**/*'))
42
+ mtimes = api_files.sort.map { |f| File.mtime(f) }
43
+ Digest::SHA1.hexdigest(mtimes.join)
44
+ end
45
+
46
+ private
47
+
48
+ def full_path(engines_path, engine, details)
49
+ api_path(engines_path, engine) + details[:file_basename]
50
+ end
51
+
52
+ def cache_key(engine, api_file)
53
+ "#{engine}-#{api_file}"
54
+ end
55
+
56
+ def api_path(engines_path, engine)
57
+ raw_name = ActiveSupport::Inflector.underscore(engine.to_s)
58
+ File.join(engines_path, "#{raw_name}/app/api/#{raw_name}/api/")
59
+ end
60
+
61
+ def parse_ast(file_path)
62
+ source_code = File.read(file_path)
63
+ source = RuboCop::ProcessedSource.new(source_code, RUBY_VERSION.to_f)
64
+ source.ast
65
+ end
66
+
67
+ def extract_module_root(path)
68
+ # The AST for the whitelist definition looks like this:
69
+ #
70
+ # (:module,
71
+ # (:const,
72
+ # (:const, nil, :Trucking), :Api),
73
+ # (:casgn, nil, :PUBLIC_SERVICES,
74
+ # (:array,
75
+ # (:const,
76
+ # s(:const, nil, :Trucking), :CancelDeliveryOrderService),
77
+ # (:const,
78
+ # s(:const, nil, :Trucking), :FclFulfillmentDetailsService))
79
+ #
80
+ # Or, in the case of two separate whitelists:
81
+ #
82
+ # (:module,
83
+ # (:const,
84
+ # (:const, nil, :Trucking), :Api),
85
+ # s(:begin,
86
+ # s(:casgn, nil, :PUBLIC_SERVICES,
87
+ # s(:send,
88
+ # s(:array,
89
+ # s(:const,
90
+ # s(:const, nil, :Trucking), :CancelDeliveryOrderService),
91
+ # s(:const,
92
+ # s(:const, nil, :Trucking), :ContainerUseService))),
93
+ # s(:casgn, nil, :PUBLIC_CONSTANTS,
94
+ # s(:send,
95
+ # s(:array,
96
+ # s(:const,
97
+ # s(:const, nil, :Trucking), :DeliveryStatuses),
98
+ # s(:const,
99
+ # s(:const, nil, :Trucking), :LoadTypes)), :freeze)))
100
+ #
101
+ # We want the :begin in the 2nd case, the :module in the 1st case.
102
+ module_node = parse_ast(path)
103
+ module_block_node = module_node&.children&.[](1)
104
+ if module_block_node&.begin_type?
105
+ module_block_node
106
+ else
107
+ module_node
108
+ end
109
+ end
110
+
111
+ def_node_matcher :whitelist_array, <<-PATTERN
112
+ (casgn nil? {:PUBLIC_MODULES :PUBLIC_SERVICES :PUBLIC_CONSTANTS :PUBLIC_TYPES} {$array (send $array ...)})
113
+ PATTERN
114
+
115
+ def_node_matcher :legacy_dependents_array, <<-PATTERN
116
+ (casgn nil? {:FILES_WITH_DIRECT_ACCESS} {$array (send $array ...)})
117
+ PATTERN
118
+
119
+ def extract_array(path, array_matcher)
120
+ list = []
121
+ root_node = extract_module_root(path)
122
+ root_node.children.each do |module_child|
123
+ array_node = send(array_matcher, module_child)
124
+ next if array_node.nil?
125
+
126
+ array_node.children.map do |item|
127
+ list << item.source
128
+ end
129
+ end
130
+ list
131
+ end
132
+ end
133
+ end
134
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RuboCop
4
4
  module Flexport
5
- VERSION = '0.2.0'
5
+ VERSION = '0.3.0'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubocop-flexport
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Flexport Engineering
@@ -51,9 +51,11 @@ files:
51
51
  - bin/setup
52
52
  - config/default.yml
53
53
  - lib/rubocop-flexport.rb
54
+ - lib/rubocop/cop/flexport/engine_api_boundary.rb
54
55
  - lib/rubocop/cop/flexport/global_model_access_from_engine.rb
55
56
  - lib/rubocop/cop/flexport/new_global_model.rb
56
57
  - lib/rubocop/cop/flexport_cops.rb
58
+ - lib/rubocop/cop/mixin/engine_api.rb
57
59
  - lib/rubocop/flexport.rb
58
60
  - lib/rubocop/flexport/inject.rb
59
61
  - lib/rubocop/flexport/version.rb