pinspec 0.1.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 +7 -0
- data/CHANGELOG.md +133 -0
- data/LICENSE.txt +21 -0
- data/README.md +183 -0
- data/exe/pinspec +8 -0
- data/lib/pinspec/analyzer/app_profile_reader.rb +348 -0
- data/lib/pinspec/analyzer/factory_registry.rb +288 -0
- data/lib/pinspec/analyzer/inflector.rb +85 -0
- data/lib/pinspec/analyzer/schema_reader.rb +444 -0
- data/lib/pinspec/analyzer/source.rb +56 -0
- data/lib/pinspec/analyzer/target_parser.rb +710 -0
- data/lib/pinspec/cli.rb +585 -0
- data/lib/pinspec/emit/namer.rb +103 -0
- data/lib/pinspec/emit/spec_writer.rb +504 -0
- data/lib/pinspec/emit/stability_filter.rb +183 -0
- data/lib/pinspec/errors.rb +85 -0
- data/lib/pinspec/inputs/boundary.rb +112 -0
- data/lib/pinspec/inputs/corpus.rb +148 -0
- data/lib/pinspec/inputs/hydrator.rb +197 -0
- data/lib/pinspec/inputs/redactor.rb +138 -0
- data/lib/pinspec/inputs/sample_runner.rb +98 -0
- data/lib/pinspec/inputs/sampler.rb +187 -0
- data/lib/pinspec/report/summary.rb +348 -0
- data/lib/pinspec/runner/capture.rb +127 -0
- data/lib/pinspec/runner/probe_generator.rb +662 -0
- data/lib/pinspec/runner/sandbox.rb +121 -0
- data/lib/pinspec/setup/context_builder.rb +471 -0
- data/lib/pinspec/setup/dependency_resolver.rb +236 -0
- data/lib/pinspec/tags.rb +103 -0
- data/lib/pinspec/types.rb +497 -0
- data/lib/pinspec/validate/mutation_adapter.rb +108 -0
- data/lib/pinspec/validate/pin_scorer.rb +164 -0
- data/lib/pinspec/verify/verifier.rb +149 -0
- data/lib/pinspec/version.rb +8 -0
- data/lib/pinspec.rb +17 -0
- data/templates/factory_build.rb +54 -0
- data/templates/serializer.rb +243 -0
- data/templates/spec_support.rb +83 -0
- metadata +134 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Pinspec
|
|
7
|
+
module Analyzer
|
|
8
|
+
class AppProfileReader
|
|
9
|
+
include Source
|
|
10
|
+
|
|
11
|
+
RAILS_FLOOR = "6.0"
|
|
12
|
+
|
|
13
|
+
FLOOR_APIS = [
|
|
14
|
+
"insert_all - importing a sampled row without firing validations or callbacks",
|
|
15
|
+
"connects_to - detecting a second writing database",
|
|
16
|
+
"ActiveSupport::CurrentAttributes#reset_all - clearing state between cases"
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
HELPER_PATHS = [
|
|
20
|
+
"spec/rails_helper.rb",
|
|
21
|
+
"spec/spec_helper.rb",
|
|
22
|
+
"spec/support/**/*.rb",
|
|
23
|
+
"test/test_helper.rb",
|
|
24
|
+
"config/environments/test.rb"
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
MODEL_GLOB = "app/models/**/*.rb"
|
|
28
|
+
|
|
29
|
+
MODEL_MACROS = {
|
|
30
|
+
after_commit: :after_commit,
|
|
31
|
+
after_create_commit: :after_commit,
|
|
32
|
+
after_update_commit: :after_commit,
|
|
33
|
+
after_destroy_commit: :after_commit,
|
|
34
|
+
after_save_commit: :after_commit,
|
|
35
|
+
default_scope: :default_scope,
|
|
36
|
+
acts_as_tenant: :acts_as_tenant,
|
|
37
|
+
has_paper_trail: :paper_trail,
|
|
38
|
+
has_one_attached: :active_storage,
|
|
39
|
+
has_many_attached: :active_storage,
|
|
40
|
+
mount_uploader: :carrierwave,
|
|
41
|
+
has_attached_file: :paperclip,
|
|
42
|
+
connects_to: :connects_to,
|
|
43
|
+
acts_as_paranoid: :paranoia
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
CURRENT_ATTRIBUTES_BASE = "ActiveSupport::CurrentAttributes"
|
|
47
|
+
|
|
48
|
+
class << self
|
|
49
|
+
def read(app_root = ".", enforce_floor: true)
|
|
50
|
+
new(app_root).read(enforce_floor: enforce_floor)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def initialize(app_root)
|
|
55
|
+
@root = app_root
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def read(enforce_floor: true)
|
|
59
|
+
@notes = []
|
|
60
|
+
@findings = []
|
|
61
|
+
|
|
62
|
+
gems = read_gemfile_lock
|
|
63
|
+
scan_models
|
|
64
|
+
rails_version = version_of(gems, "rails") || version_of(gems, "activerecord")
|
|
65
|
+
floor_ok = floor_ok?(rails_version)
|
|
66
|
+
|
|
67
|
+
enforce_floor!(rails_version) if enforce_floor && floor_ok == false
|
|
68
|
+
|
|
69
|
+
AppProfile.new(
|
|
70
|
+
rails_version: rails_version,
|
|
71
|
+
ruby_version: @ruby_version,
|
|
72
|
+
rails_floor_ok: floor_ok,
|
|
73
|
+
auth: auth_for(gems),
|
|
74
|
+
authz: authz_for(gems),
|
|
75
|
+
tenancy: tenancy_for(gems),
|
|
76
|
+
soft_delete: soft_delete_for(gems),
|
|
77
|
+
versioning: gem?(gems, "paper_trail") ? :paper_trail : :none,
|
|
78
|
+
flags: gem?(gems, "flipper") ? :flipper : :none,
|
|
79
|
+
attachments: attachments_for(gems),
|
|
80
|
+
multi_db: multi_db?,
|
|
81
|
+
spring: gem?(gems, "spring"),
|
|
82
|
+
model_findings: @findings,
|
|
83
|
+
default_locale: detect_locale,
|
|
84
|
+
default_zone: detect_zone,
|
|
85
|
+
db_cleaner: detect_db_cleaner_strategy(gems),
|
|
86
|
+
transactional_fixtures: detect_transactional_fixtures,
|
|
87
|
+
queue_adapter_in_tests: detect_queue_adapter,
|
|
88
|
+
test_stack: test_stack_for(gems),
|
|
89
|
+
schema: SchemaReader.read(@root),
|
|
90
|
+
factories: FactoryRegistry.read(@root),
|
|
91
|
+
notes: @notes
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def read_gemfile_lock
|
|
98
|
+
path = File.join(@root, "Gemfile.lock")
|
|
99
|
+
|
|
100
|
+
unless File.file?(path)
|
|
101
|
+
note(:no_gemfile_lock,
|
|
102
|
+
"no Gemfile.lock at #{path}; every gem answer below is 'not found', " \
|
|
103
|
+
"which is not the same as 'not used'")
|
|
104
|
+
return {}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
gems = {}
|
|
108
|
+
|
|
109
|
+
Source.read(path).each_line do |line|
|
|
110
|
+
if (spec = line.match(/^ {4}([a-zA-Z0-9._-]+) \(([^)]+)\)$/))
|
|
111
|
+
gems[spec[1]] = spec[2]
|
|
112
|
+
elsif (ruby = line.match(/^ {3}ruby ([\d.]+)/))
|
|
113
|
+
@ruby_version = ruby[1]
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
@ruby_version ||= ruby_version_file
|
|
118
|
+
gems
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def ruby_version_file
|
|
122
|
+
path = File.join(@root, ".ruby-version")
|
|
123
|
+
return nil unless File.file?(path)
|
|
124
|
+
|
|
125
|
+
Source.read(path).strip[/\d+\.\d+(\.\d+)?/]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def gem?(gems, name)
|
|
129
|
+
gems.key?(name) || gems.keys.any? { |key| key == "#{name}-rails" || key.start_with?("#{name}-") }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def version_of(gems, name)
|
|
133
|
+
gems[name]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def floor_ok?(rails_version)
|
|
137
|
+
return nil if rails_version.nil?
|
|
138
|
+
|
|
139
|
+
Gem::Version.new(rails_version) >= Gem::Version.new(RAILS_FLOOR)
|
|
140
|
+
rescue ArgumentError
|
|
141
|
+
note(:unparseable_rails_version,
|
|
142
|
+
"could not read #{rails_version.inspect} as a version, so the Rails " \
|
|
143
|
+
"#{RAILS_FLOOR} floor was not checked")
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def enforce_floor!(rails_version)
|
|
148
|
+
raise UnsupportedRailsVersion,
|
|
149
|
+
"this app is on Rails #{rails_version}; pinspec needs #{RAILS_FLOOR} or " \
|
|
150
|
+
"newer. Missing pieces it depends on:\n - #{FLOOR_APIS.join("\n - ")}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def auth_for(gems)
|
|
154
|
+
return :devise if gem?(gems, "devise")
|
|
155
|
+
return :current_attributes if @findings.any? { |f| f.kind == :current_attributes }
|
|
156
|
+
|
|
157
|
+
:none
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def authz_for(gems)
|
|
161
|
+
return :pundit if gem?(gems, "pundit")
|
|
162
|
+
return :cancancan if gem?(gems, "cancancan")
|
|
163
|
+
|
|
164
|
+
:none
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def tenancy_for(gems)
|
|
168
|
+
return :apartment if gem?(gems, "ros-apartment") || gem?(gems, "apartment")
|
|
169
|
+
return :acts_as_tenant if gem?(gems, "acts_as_tenant")
|
|
170
|
+
|
|
171
|
+
:none
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def soft_delete_for(gems)
|
|
175
|
+
return :paranoia if gem?(gems, "paranoia")
|
|
176
|
+
return :discard if gem?(gems, "discard")
|
|
177
|
+
|
|
178
|
+
:none
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def attachments_for(gems)
|
|
182
|
+
found = []
|
|
183
|
+
found << :active_storage if @findings.any? { |f| f.kind == :active_storage } || active_storage_configured?
|
|
184
|
+
found << :carrierwave if gem?(gems, "carrierwave")
|
|
185
|
+
found << :paperclip if gem?(gems, "paperclip")
|
|
186
|
+
found
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def active_storage_configured?
|
|
190
|
+
config_sources.any? { |source| source.match?(/config\.active_storage/) }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def test_stack_for(gems)
|
|
194
|
+
TestStack.new(
|
|
195
|
+
framework: detect_framework(gems),
|
|
196
|
+
webmock: gem?(gems, "webmock"),
|
|
197
|
+
vcr: gem?(gems, "vcr"),
|
|
198
|
+
snapshot_backends: %w[insta approvals].select { |name| gem?(gems, name) }.map(&:to_sym),
|
|
199
|
+
database_cleaner_gem: gem?(gems, "database_cleaner")
|
|
200
|
+
)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def detect_framework(gems)
|
|
204
|
+
return :rspec if gem?(gems, "rspec-rails") || File.file?(File.join(@root, "spec/rails_helper.rb"))
|
|
205
|
+
return :minitest if File.file?(File.join(@root, "test/test_helper.rb"))
|
|
206
|
+
|
|
207
|
+
:none
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def detect_locale
|
|
211
|
+
found = config_sources.filter_map { |source| source[/config\.i18n\.default_locale\s*=\s*:(\w+)/, 1] }.last
|
|
212
|
+
(found || "en").to_sym
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def detect_zone
|
|
216
|
+
config_sources.filter_map { |source| source[/config\.time_zone\s*=\s*["']([^"']+)["']/, 1] }.last || "UTC"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def config_sources
|
|
220
|
+
@config_sources ||= ["config/application.rb", "config/environments/test.rb"]
|
|
221
|
+
.map { |relative| File.join(@root, relative) }
|
|
222
|
+
.select { |path| File.file?(path) }
|
|
223
|
+
.map { |path| Source.read(path) }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def helper_sources
|
|
227
|
+
@helper_sources ||= HELPER_PATHS
|
|
228
|
+
.flat_map { |pattern| Dir[File.join(@root, pattern)] }
|
|
229
|
+
.select { |path| File.file?(path) }
|
|
230
|
+
.sort
|
|
231
|
+
.map { |path| [path, Source.read(path)] }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def detect_db_cleaner_strategy(gems)
|
|
235
|
+
strategies = helper_sources.flat_map do |_path, source|
|
|
236
|
+
source.scan(/DatabaseCleaner(?:\[[^\]]*\])?\.strategy\s*=\s*\[?:(\w+)/).flatten
|
|
237
|
+
end.uniq
|
|
238
|
+
|
|
239
|
+
return :none if strategies.empty? && !gem?(gems, "database_cleaner")
|
|
240
|
+
return :none if strategies.empty?
|
|
241
|
+
|
|
242
|
+
if strategies.size > 1
|
|
243
|
+
note(:multiple_db_cleaner_strategies,
|
|
244
|
+
"rails_helper sets more than one DatabaseCleaner strategy " \
|
|
245
|
+
"(#{strategies.join(', ')}); pinspec assumes the first is the default")
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
strategies.first.to_sym
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def detect_transactional_fixtures
|
|
252
|
+
helper_sources.each do |_path, source|
|
|
253
|
+
match = source[/use_transactional_(?:fixtures|tests)\s*=\s*(true|false)/, 1]
|
|
254
|
+
return match == "true" unless match.nil?
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
nil
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def detect_queue_adapter
|
|
261
|
+
(helper_sources.map(&:last) + config_sources).each do |source|
|
|
262
|
+
match = source[/queue_adapter\s*=\s*:(\w+)/, 1]
|
|
263
|
+
return match.to_sym unless match.nil?
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
nil
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def multi_db?
|
|
270
|
+
return true if @findings.any? { |f| f.kind == :connects_to }
|
|
271
|
+
|
|
272
|
+
path = File.join(@root, "config", "database.yml")
|
|
273
|
+
return false unless File.file?(path)
|
|
274
|
+
|
|
275
|
+
data = load_database_yml(path)
|
|
276
|
+
return false unless data.is_a?(Hash)
|
|
277
|
+
|
|
278
|
+
data.each_value.any? { |env| multiple_writers?(env) }
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def load_database_yml(path)
|
|
282
|
+
stripped = Source.read(path).gsub(/<%=?.*?%>/m, "erb")
|
|
283
|
+
YAML.safe_load(stripped, aliases: true, permitted_classes: [Symbol])
|
|
284
|
+
rescue StandardError => e
|
|
285
|
+
note(:unreadable_database_yml,
|
|
286
|
+
"could not parse config/database.yml (#{e.class}); multi-database " \
|
|
287
|
+
"detection falls back to connects_to only")
|
|
288
|
+
nil
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def multiple_writers?(env)
|
|
292
|
+
return false unless env.is_a?(Hash)
|
|
293
|
+
|
|
294
|
+
nested = env.values.grep(Hash)
|
|
295
|
+
nested.reject { |config| config["replica"] }.size > 1
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def scan_models
|
|
299
|
+
Dir[File.join(@root, MODEL_GLOB)].sort.each do |path|
|
|
300
|
+
result = Prism.parse(Source.read(path))
|
|
301
|
+
|
|
302
|
+
unless result.success?
|
|
303
|
+
next note(:unparsable_model, "#{path} is not valid Ruby; its macros were not scanned")
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
walk_classes(result.value.statements, nil, path)
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def walk_classes(statements, prefix, path)
|
|
311
|
+
Array(statements&.body).each do |node|
|
|
312
|
+
next unless node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
|
|
313
|
+
|
|
314
|
+
name = [prefix, node.constant_path.slice].compact.join("::")
|
|
315
|
+
|
|
316
|
+
if node.is_a?(Prism::ClassNode) && node.superclass&.slice == CURRENT_ATTRIBUTES_BASE
|
|
317
|
+
add_finding(:current_attributes, name, path, node.location.start_line)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
scan_macros(node.body, name, path)
|
|
321
|
+
walk_classes(node.body, name, path)
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def scan_macros(body, model, path)
|
|
326
|
+
Array(body&.body).grep(Prism::CallNode).each do |call|
|
|
327
|
+
if call.name == :include
|
|
328
|
+
discard = Array(call.arguments&.arguments).any? { |arg| arg.slice.include?("Discard::Model") }
|
|
329
|
+
add_finding(:discard, model, path, call.location.start_line) if discard
|
|
330
|
+
next
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
kind = MODEL_MACROS[call.name]
|
|
334
|
+
add_finding(kind, model, path, call.location.start_line) if kind
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def add_finding(kind, model, path, line)
|
|
339
|
+
@findings << ModelFinding.new(kind: kind, model: model, file: path, line: line)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def note(kind, detail)
|
|
343
|
+
@notes << { kind: kind, detail: detail }
|
|
344
|
+
nil
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Pinspec
|
|
6
|
+
module Analyzer
|
|
7
|
+
class FactoryRegistry
|
|
8
|
+
include Source
|
|
9
|
+
|
|
10
|
+
SEARCH_PATHS = [
|
|
11
|
+
"spec/factories.rb",
|
|
12
|
+
"spec/factories/**/*.rb",
|
|
13
|
+
"test/factories.rb",
|
|
14
|
+
"test/factories/**/*.rb"
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
DSL_MODULES = %w[FactoryBot FactoryGirl Factory].freeze
|
|
18
|
+
LEGACY_MODULES = %w[FactoryGirl Factory].freeze
|
|
19
|
+
|
|
20
|
+
CALLBACK_HOOKS = %i[after before].freeze
|
|
21
|
+
CALLBACK_STAGES = %i[build create stub].freeze
|
|
22
|
+
|
|
23
|
+
HAZARD_CALLS = %i[skip_create initialize_with to_create].freeze
|
|
24
|
+
|
|
25
|
+
BARE_NON_ATTRIBUTES = (HAZARD_CALLS + %i[sequence]).freeze
|
|
26
|
+
|
|
27
|
+
STRUCTURAL_CALLS = %i[trait factory transient association sequence].freeze
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
def read(app_root = ".")
|
|
31
|
+
new(app_root).read
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def parse(path)
|
|
35
|
+
new(File.dirname(path)).parse_files([path])
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(app_root)
|
|
40
|
+
@app_root = app_root
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def read
|
|
44
|
+
parse_files(factory_files)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def parse_files(paths)
|
|
48
|
+
@factories = []
|
|
49
|
+
@skipped = []
|
|
50
|
+
@legacy_dsl = false
|
|
51
|
+
|
|
52
|
+
paths.each { |path| parse_file(path) }
|
|
53
|
+
resolve_models
|
|
54
|
+
|
|
55
|
+
FactoryIndex.new(
|
|
56
|
+
factories: @factories,
|
|
57
|
+
legacy_dsl: @legacy_dsl,
|
|
58
|
+
skipped: @skipped
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def factory_files
|
|
63
|
+
SEARCH_PATHS
|
|
64
|
+
.flat_map { |pattern| Dir[File.join(@app_root, pattern)] }
|
|
65
|
+
.select { |path| File.file?(path) }
|
|
66
|
+
.uniq
|
|
67
|
+
.sort
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def parse_file(path)
|
|
73
|
+
result = Prism.parse(Source.read(path))
|
|
74
|
+
|
|
75
|
+
unless result.success?
|
|
76
|
+
first = result.errors.first
|
|
77
|
+
return skip(path, :unparsable, "#{first.message} (line #{first.location.start_line})")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
found_define = false
|
|
81
|
+
|
|
82
|
+
each_node(result.value) do |node|
|
|
83
|
+
next unless node.is_a?(Prism::CallNode) && node.name == :define
|
|
84
|
+
|
|
85
|
+
receiver = node.receiver&.slice
|
|
86
|
+
next unless DSL_MODULES.include?(receiver)
|
|
87
|
+
|
|
88
|
+
found_define = true
|
|
89
|
+
@legacy_dsl ||= LEGACY_MODULES.include?(receiver)
|
|
90
|
+
visit_scope(node.block, path, nil)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
return if found_define
|
|
94
|
+
|
|
95
|
+
top_level = top_level_factories(result.value, path)
|
|
96
|
+
skip(path, :no_factories, "no #{DSL_MODULES.join('/')}.define block") if top_level.zero?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def top_level_factories(root, path)
|
|
100
|
+
count = 0
|
|
101
|
+
|
|
102
|
+
Array(root.statements&.body).each do |node|
|
|
103
|
+
next unless node.is_a?(Prism::CallNode) && node.name == :factory
|
|
104
|
+
|
|
105
|
+
visit_factory(node, path, nil)
|
|
106
|
+
count += 1
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
count
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def visit_scope(block, path, parent)
|
|
113
|
+
Array(block&.body&.body).each do |node|
|
|
114
|
+
next unless node.is_a?(Prism::CallNode)
|
|
115
|
+
next unless node.name == :factory
|
|
116
|
+
|
|
117
|
+
visit_factory(node, path, parent)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def visit_factory(node, path, parent)
|
|
122
|
+
args = Array(node.arguments&.arguments)
|
|
123
|
+
name = decode(args.first)&.to_sym
|
|
124
|
+
return unless name
|
|
125
|
+
|
|
126
|
+
options = keyword_options(args)
|
|
127
|
+
|
|
128
|
+
factory = Factory.new(
|
|
129
|
+
name: name,
|
|
130
|
+
model: options[:class]&.to_s,
|
|
131
|
+
parent: (options[:parent]&.to_sym || parent&.name),
|
|
132
|
+
aliases: Array(options[:aliases]).map(&:to_sym),
|
|
133
|
+
attributes: [],
|
|
134
|
+
traits: [],
|
|
135
|
+
callbacks: [],
|
|
136
|
+
hazards: [],
|
|
137
|
+
file: path,
|
|
138
|
+
line: node.location.start_line
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
slot = @factories.size
|
|
142
|
+
@factories << factory
|
|
143
|
+
@factories[slot] = factory.with(**collect_body(node.block, path, factory))
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def collect_body(block, path, factory)
|
|
147
|
+
attributes = []
|
|
148
|
+
traits = []
|
|
149
|
+
callbacks = []
|
|
150
|
+
hazards = []
|
|
151
|
+
|
|
152
|
+
Array(block&.body&.body).each do |node|
|
|
153
|
+
next unless node.is_a?(Prism::CallNode)
|
|
154
|
+
|
|
155
|
+
case node.name
|
|
156
|
+
when :factory
|
|
157
|
+
visit_factory(node, path, factory)
|
|
158
|
+
when :trait
|
|
159
|
+
traits << build_trait(node)
|
|
160
|
+
when :transient
|
|
161
|
+
attributes.concat(attributes_in(node.block, kind: :transient))
|
|
162
|
+
when *CALLBACK_HOOKS
|
|
163
|
+
callback = build_callback(node)
|
|
164
|
+
callback ? callbacks << callback : attributes << build_attribute(node)
|
|
165
|
+
when *HAZARD_CALLS
|
|
166
|
+
hazards << [node.name, node.location.start_line]
|
|
167
|
+
else
|
|
168
|
+
attributes << build_attribute(node)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
{ attributes: attributes.compact, traits: traits, callbacks: callbacks, hazards: hazards }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def build_trait(node)
|
|
176
|
+
FactoryTrait.new(
|
|
177
|
+
name: decode(Array(node.arguments&.arguments).first)&.to_sym,
|
|
178
|
+
attributes: attributes_in(node.block),
|
|
179
|
+
line: node.location.start_line
|
|
180
|
+
)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def attributes_in(block, kind: nil)
|
|
184
|
+
Array(block&.body&.body).filter_map do |node|
|
|
185
|
+
next unless node.is_a?(Prism::CallNode)
|
|
186
|
+
|
|
187
|
+
attribute = build_attribute(node)
|
|
188
|
+
next unless attribute
|
|
189
|
+
next attribute if kind.nil?
|
|
190
|
+
|
|
191
|
+
attribute.with(kind: kind)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def build_attribute(node)
|
|
196
|
+
args = Array(node.arguments&.arguments)
|
|
197
|
+
|
|
198
|
+
case node.name
|
|
199
|
+
when :association
|
|
200
|
+
name = decode(args.first)&.to_sym
|
|
201
|
+
return nil unless name
|
|
202
|
+
|
|
203
|
+
attribute(name, :association, node, factory: keyword_options(args)[:factory]&.to_sym, source: nil)
|
|
204
|
+
when :sequence
|
|
205
|
+
name = decode(args.first)&.to_sym
|
|
206
|
+
return nil unless name
|
|
207
|
+
|
|
208
|
+
attribute(name, :sequence, node)
|
|
209
|
+
else
|
|
210
|
+
classify_plain(node, args)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def classify_plain(node, args)
|
|
215
|
+
return nil unless node.receiver.nil?
|
|
216
|
+
return nil if BARE_NON_ATTRIBUTES.include?(node.name)
|
|
217
|
+
return nil if STRUCTURAL_CALLS.include?(node.name)
|
|
218
|
+
|
|
219
|
+
if node.block
|
|
220
|
+
attribute(node.name, :block, node)
|
|
221
|
+
elsif args.empty?
|
|
222
|
+
attribute(node.name, :association, node)
|
|
223
|
+
elsif args.all? { |arg| arg.is_a?(Prism::KeywordHashNode) }
|
|
224
|
+
attribute(node.name, :association, node, factory: keyword_options(args)[:factory]&.to_sym)
|
|
225
|
+
else
|
|
226
|
+
attribute(node.name, :static, node)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def attribute(name, kind, node, factory: nil, source: :from_node)
|
|
231
|
+
FactoryAttribute.new(
|
|
232
|
+
name: name.to_sym,
|
|
233
|
+
kind: kind,
|
|
234
|
+
source: source == :from_node ? attribute_source(node) : source,
|
|
235
|
+
factory: factory,
|
|
236
|
+
line: node.location.start_line
|
|
237
|
+
)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def attribute_source(node)
|
|
241
|
+
if node.block
|
|
242
|
+
node.block.body&.slice
|
|
243
|
+
else
|
|
244
|
+
args = Array(node.arguments&.arguments).reject { |a| a.is_a?(Prism::KeywordHashNode) }
|
|
245
|
+
args.empty? ? nil : args.map(&:slice).join(", ")
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def build_callback(node)
|
|
250
|
+
stage = decode(Array(node.arguments&.arguments).first)
|
|
251
|
+
return nil unless stage.is_a?(Symbol) && CALLBACK_STAGES.include?(stage)
|
|
252
|
+
|
|
253
|
+
FactoryCallback.new(hook: node.name, stage: stage, line: node.location.start_line)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def resolve_models
|
|
257
|
+
by_name = {}
|
|
258
|
+
@factories.each do |factory|
|
|
259
|
+
by_name[factory.name] = factory
|
|
260
|
+
factory.aliases.each { |name| by_name[name] ||= factory }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
@factories = @factories.map do |factory|
|
|
264
|
+
next factory if factory.model
|
|
265
|
+
|
|
266
|
+
declared = nil
|
|
267
|
+
root = factory
|
|
268
|
+
seen = []
|
|
269
|
+
current = factory
|
|
270
|
+
|
|
271
|
+
while current && !seen.include?(current.name)
|
|
272
|
+
seen << current.name
|
|
273
|
+
declared ||= current.model
|
|
274
|
+
root = current
|
|
275
|
+
current = current.parent && by_name[current.parent]
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
factory.with(model: declared || camelize(root.name.to_s))
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def skip(path, kind, detail)
|
|
283
|
+
@skipped << { file: path, kind: kind, detail: detail }
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|