rubocop-flexport 0.2.0 → 0.3.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: 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