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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +133 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +183 -0
  5. data/exe/pinspec +8 -0
  6. data/lib/pinspec/analyzer/app_profile_reader.rb +348 -0
  7. data/lib/pinspec/analyzer/factory_registry.rb +288 -0
  8. data/lib/pinspec/analyzer/inflector.rb +85 -0
  9. data/lib/pinspec/analyzer/schema_reader.rb +444 -0
  10. data/lib/pinspec/analyzer/source.rb +56 -0
  11. data/lib/pinspec/analyzer/target_parser.rb +710 -0
  12. data/lib/pinspec/cli.rb +585 -0
  13. data/lib/pinspec/emit/namer.rb +103 -0
  14. data/lib/pinspec/emit/spec_writer.rb +504 -0
  15. data/lib/pinspec/emit/stability_filter.rb +183 -0
  16. data/lib/pinspec/errors.rb +85 -0
  17. data/lib/pinspec/inputs/boundary.rb +112 -0
  18. data/lib/pinspec/inputs/corpus.rb +148 -0
  19. data/lib/pinspec/inputs/hydrator.rb +197 -0
  20. data/lib/pinspec/inputs/redactor.rb +138 -0
  21. data/lib/pinspec/inputs/sample_runner.rb +98 -0
  22. data/lib/pinspec/inputs/sampler.rb +187 -0
  23. data/lib/pinspec/report/summary.rb +348 -0
  24. data/lib/pinspec/runner/capture.rb +127 -0
  25. data/lib/pinspec/runner/probe_generator.rb +662 -0
  26. data/lib/pinspec/runner/sandbox.rb +121 -0
  27. data/lib/pinspec/setup/context_builder.rb +471 -0
  28. data/lib/pinspec/setup/dependency_resolver.rb +236 -0
  29. data/lib/pinspec/tags.rb +103 -0
  30. data/lib/pinspec/types.rb +497 -0
  31. data/lib/pinspec/validate/mutation_adapter.rb +108 -0
  32. data/lib/pinspec/validate/pin_scorer.rb +164 -0
  33. data/lib/pinspec/verify/verifier.rb +149 -0
  34. data/lib/pinspec/version.rb +8 -0
  35. data/lib/pinspec.rb +17 -0
  36. data/templates/factory_build.rb +54 -0
  37. data/templates/serializer.rb +243 -0
  38. data/templates/spec_support.rb +83 -0
  39. 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