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:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 8b441520e98f7b859443aefbd06f3d9794600b702d8b70ad37f1a5a072898947
|
4
|
+
data.tar.gz: 0c3767638f4c7303b0ff941cdbf16a589b757628404ae9b9f3a2b5505a9c8f37
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: '08541d28bbedcf79eb1e570b97de445b965eed4c6ed266a4d6333bed6904f6e07ae63e2286a05607660cb7771829bddb830785a4565af1222eb4cb9f46f4d321'
|
7
|
+
data.tar.gz: a4ca03afba08fc095a8e6f152abe7ee8527b1eb80cd55f34a7d5c95bd4f86a46b34cb389d5d4af9cd1fc21fa13d30ca8624d79aa8f0c0f870c2a3e93247f4e2d
|
data/config/default.yml
CHANGED
@@ -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
|
@@ -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
|
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.
|
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
|