factory_sloth 1.2.2 β†’ 1.3.1

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: c628e26d703d50de9a6f087174d01132942eb557ccbb4519a173116922cb2541
4
- data.tar.gz: 40c2d6873fb446d5a8dbba7339ebc481e766989ff722531e24d891213bac3434
3
+ metadata.gz: 1ca25fc42535f24034b3a52ddb2b88d9e64dbd68b83e2febb7ddb31679daa118
4
+ data.tar.gz: 92814709e951c1dc15040496ec1c5dea101f2bdcb8d58eb1298182a91a2df2f1
5
5
  SHA512:
6
- metadata.gz: aa04cdbe7e3bf0fe25b9754f6fa5d356db63c4cf18ef193c5a0b11ae8629c36cd3e39ffda5fa71e47cf1ddef16b0dc4b84564c87cc6e493169150bbe35aeccd3
7
- data.tar.gz: 8bfc7521d09398337217f0ed04adccda7380f653efd46c2c0d39858082d7d949996142b5bf074eaab36524b3df197a29875b72bd21a8b7a326886303cbd1d9fa
6
+ metadata.gz: 188f7f29920ba9f868171c65c21e7e4611e8c8c9cf41eb229e3f1a17fb98c6c970a491051fac6b501b5544b4298cef22f42335e8306e93370225c9acedfe8182
7
+ data.tar.gz: dbc6452a523efb648d0f2d71594d883c469194164b86a8f2f16ece21a171e8d82bd2a8260072e777b731b1001ad9f0eb0c5f51ce1610ebf3f8cc67b7d19e4471
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.3.1] - 2023-05-24
4
+
5
+ ### Fixed
6
+
7
+ - Stop trying to patch lines with multiple create calls, which was unreliable
8
+
9
+ ## [1.3.0] - 2023-05-22
10
+
11
+ ### Added
12
+
13
+ - Nicer output
14
+ - Verbose mode
15
+
3
16
  ## [1.2.2] - 2023-05-18
4
17
 
5
18
  ### Fixed
data/README.md CHANGED
@@ -27,6 +27,7 @@ Examples:
27
27
  Options:
28
28
  -f, --force Ignore ./.factory_sloth_done
29
29
  -l, --lint Dont fix, just list bad create calls
30
+ -V, --verbose Verbose output, useful for debugging
30
31
  -v, --version Show gem version
31
32
  -h, --help Show this help
32
33
  ```
@@ -36,20 +37,25 @@ Options:
36
37
  While running, `factory_sloth` produces output like this:
37
38
 
38
39
  ```
39
- Processing spec/features/sign_up_spec.rb ...
40
- 🟑 2 create calls found, 0 replaced
40
+ 🟑 spec/features/sign_up_spec.rb: 2 create calls found, 0 replaced
41
41
 
42
- Processing spec/lib/string_ext_spec.rb ...
43
- βšͺ️ 0 create calls found, 0 replaced
42
+ βšͺ️ spec/lib/string_ext_spec.rb: 0 create calls found, 0 replaced
44
43
 
45
- Processing spec/models/user_spec.rb ...
46
- - create in line 3 can be replaced with build
47
- - create_list in line 4 can be replaced with build_list
48
- 🟒 3 create calls found, 2 replaced
44
+ spec/models/user_spec.rb:3:2: create replaced with build
45
+ expect(create(:user)).not_to be_nil
46
+ ^^^^^^
49
47
 
50
- Processing spec/weird_dir/crazy_spec.rb ...
51
- - create in line 8 can be replaced with build_stubbed
52
- πŸ”΄ 33 create calls found, 0 replaced (conflict)
48
+ spec/models/user_spec.rb:4:2: create_list replaced with build_list
49
+ expect(create_list(:user, 2).count).to eq 2
50
+ ^^^^^^^^^^^
51
+
52
+ 🟒 spec/models/user_spec.rb: 3 create calls found, 2 replaced
53
+
54
+ spec/weird_dir/crazy_spec.rb:8:4: create replaced with build_stubbed
55
+ expect(create(:user)).not_to be_nil
56
+ ^^^^^^
57
+
58
+ πŸ”΄ spec/weird_dir/crazy_spec.rb: 33 create calls found, 0 replaced (conflict)
53
59
 
54
60
  Scanned 4 files, found 2 unnecessary create calls across 1 files and 1 broken specs
55
61
  ```
@@ -7,8 +7,7 @@ module FactorySloth
7
7
  def call(argv = ARGV)
8
8
  args = option_parser.parse!(argv)
9
9
  specs = SpecPicker.call(paths: args)
10
- forced_files = @force ? specs : args
11
- results = FileProcessor.call(files: specs, forced_files: forced_files, dry_run: @lint)
10
+ results = FileProcessor.call(files: specs, forced_files: args)
12
11
  print_summary(results)
13
12
  end
14
13
 
@@ -29,11 +28,16 @@ module FactorySloth
29
28
  opts.separator 'Options:'
30
29
 
31
30
  opts.on('-f', '--force', "Ignore #{DoneTracker.file}") do
32
- @force = true
31
+ FactorySloth.force = true
33
32
  end
34
33
 
35
34
  opts.on('-l', '--lint', 'Dont fix, just list bad create calls') do
36
- @lint = true
35
+ FactorySloth.dry_run = true
36
+ FactorySloth.lint = true
37
+ end
38
+
39
+ opts.on('-V', '--verbose', 'Verbose output, useful for debugging') do
40
+ FactorySloth.verbose = true
37
41
  end
38
42
 
39
43
  opts.on('-v', '--version', 'Show gem version') do
@@ -49,14 +53,14 @@ module FactorySloth
49
53
  end
50
54
 
51
55
  def print_summary(results)
52
- unnecessary_call_count = results.values.sum { |v| v[:changed_create_calls].count }
53
- changed_specs = results.keys.select { |path| results[path][:changed_create_calls].any? }
56
+ change_sum = results.values.sum { |v| v[:change_count] }
57
+ changed_specs = results.keys.select { |path| results[path][:change_count] > 0 }
54
58
  broken_specs = results.keys.select { |path| !results[path][:ok] }
55
- stats = "Scanned #{results.count} files, found #{unnecessary_call_count}"\
59
+ stats = "Scanned #{results.count} files, found #{change_sum}"\
56
60
  " unnecessary create calls across #{changed_specs.count} files"\
57
61
  "#{" and #{broken_specs.count} broken specs" if broken_specs.any?}"
58
62
 
59
- if @lint && unnecessary_call_count > 0
63
+ if FactorySloth.lint && change_sum > 0
60
64
  warn "#{stats}:\n#{(changed_specs + broken_specs).join("\n")}"
61
65
  exit 1
62
66
  else
@@ -1,116 +1,132 @@
1
- class FactorySloth::CodeMod
2
- attr_reader :create_calls, :changed_create_calls, :path, :original_code, :patched_code
1
+ module FactorySloth
2
+ class CodeMod
3
+ attr_reader :create_calls, :changed_create_calls, :path, :original_code, :patched_code
3
4
 
4
- def self.call(path, code)
5
- new(path, code).tap(&:call)
6
- end
5
+ require 'forwardable'
6
+ extend Forwardable
7
7
 
8
- def initialize(path, code)
9
- self.path = path
10
- self.original_code = code
11
- self.patched_code = code
12
- end
8
+ def_delegator :changed_create_calls, :any?, :changed?
9
+ def_delegator :changed_create_calls, :count, :change_count
10
+ def_delegator :create_calls, :count, :create_count
13
11
 
14
- def call
15
- self.create_calls = FactorySloth::CreateCallFinder.call(code: original_code)
12
+ def self.call(path, code)
13
+ new(path, code).tap(&:call)
14
+ end
16
15
 
17
- # Performance note: it might be faster to write ALL possible patches for a
18
- # given spec file to tempfiles first, and then run them all in a single
19
- # rspec call. However, this would make it impossible to use `--fail-fast`,
20
- # and might make examples fail that are not as idempotent as they should be.
21
- self.changed_create_calls =
22
- create_calls.sort_by { |call| [-call.line, -call.column] }.select do |call|
23
- build_result = try_patch(call, 'build')
24
- next if build_result == ABORT
16
+ def initialize(path, code)
17
+ self.path = path
18
+ self.original_code = code
19
+ self.patched_code = code
20
+ end
25
21
 
26
- build_result == SUCCESS || try_patch(call, 'build_stubbed') == SUCCESS
27
- end
22
+ def call
23
+ self.create_calls = CreateCallFinder.call(code: original_code)
28
24
 
29
- # validate whole spec after changes, e.g. to detect side-effects
30
- self.ok = changed_create_calls.none? ||
31
- FactorySloth::SpecRunner.call(path, patched_code).success?
32
- changed_create_calls.clear unless ok?
33
- patched_code.replace(original_code) unless ok?
34
- end
25
+ self.changed_create_calls = find_changeable_create_calls
35
26
 
36
- def ok?
37
- @ok
38
- end
27
+ # validate whole spec after changes, e.g. to detect side-effects
28
+ self.ok = changed_create_calls.none? || begin
29
+ FactorySloth.verbose && puts("Checking whole file after changes")
30
+ run(patched_code).success?
31
+ end
32
+ ok? || changed_create_calls.clear && patched_code.replace(original_code)
33
+ end
39
34
 
40
- def changed?
41
- change_count > 0
42
- end
35
+ def ok?
36
+ @ok
37
+ end
43
38
 
44
- def create_count
45
- create_calls.count
46
- end
39
+ def message
40
+ stats = "#{path}: #{create_count} create calls found, #{change_count} "\
41
+ "#{FactorySloth.dry_run ? 'replaceable' : 'replaced'}"
47
42
 
48
- def change_count
49
- changed_create_calls.count
50
- end
43
+ return "πŸ”΄ #{stats} (conflict)" unless ok?
51
44
 
52
- private
53
-
54
- attr_writer :create_calls, :changed_create_calls, :ok, :path, :original_code, :patched_code
55
-
56
- def try_patch(call, base_variant)
57
- variant = call.name.sub('create', base_variant)
58
- new_patched_code = patched_code.sub(
59
- /\A(?:.*\n){#{call.line - 1}}.{#{call.column}}\K#{call.name}/,
60
- variant
61
- )
62
- checked_patched_code = new_patched_code + checks(call.line, variant)
63
-
64
- result = FactorySloth::SpecRunner.call(path, checked_patched_code, line: call.line)
65
- if result.success?
66
- puts "- #{call.name} in line #{call.line} can be replaced with #{variant}"
67
- self.patched_code = new_patched_code
68
- SUCCESS
69
- elsif result.exitstatus == FACTORY_UNUSED_CODE
70
- puts "- #{call.name} in line #{call.line} is never executed, skipping"
71
- ABORT
72
- elsif result.exitstatus == FACTORY_PERSISTED_LATER_CODE
73
- ABORT
45
+ if create_count == 0
46
+ "βšͺ️ #{stats}"
47
+ elsif change_count == 0
48
+ "🟑 #{stats}"
49
+ else
50
+ "🟒 #{stats}"
51
+ end
74
52
  end
75
- end
76
53
 
77
- ABORT = :ABORT # returned if there is no need to try other variants
78
- SUCCESS = :SUCCESS
79
-
80
- FACTORY_UNUSED_CODE = 77
81
- FACTORY_PERSISTED_LATER_CODE = 78
82
-
83
- # This adds code that makes a spec run fail and thus prevents changes if:
84
- # a) the patched factory in the given line is never called
85
- # b) the built record was persisted later anyway
86
- # The rationale behind a) is that things like skipped examples should not
87
- # be broken. The rationale behind b) is that not much DB work would be saved,
88
- # but diff noise would be increased and ease of editing the example reduced.
89
- def checks(line, variant)
90
- <<~RUBY
91
- ; defined?(FactoryBot) && defined?(RSpec) && RSpec.configure do |config|
92
- records_by_line = {} # track records initialized through factories per line
93
-
94
- FactoryBot::Syntax::Methods.class_eval do
95
- alias ___original_#{variant} #{variant} # e.g. ___original_build build
96
-
97
- define_method("#{variant}") do |*args, **kwargs, &blk| # e.g. build
98
- result = ___original_#{variant}(*args, **kwargs, &blk)
99
- list = records_by_line[caller_locations(1, 1)&.first&.lineno] ||= []
100
- list.concat([result].flatten) # to work with single, list, and pair
101
- result
102
- end
103
- end
54
+ private
55
+
56
+ attr_writer :create_calls, :changed_create_calls, :ok, :path, :original_code, :patched_code
104
57
 
105
- config.after(:suite) do
106
- records = records_by_line[#{line}]
107
- records&.any? || exit!(#{FACTORY_UNUSED_CODE})
108
- unless "#{variant}".include?('stub') # factory_bot stub stubs persisted? as true
109
- records.any? { |r| r.respond_to?(:persisted?) && r.persisted? } &&
110
- exit!(#{FACTORY_PERSISTED_LATER_CODE})
58
+ # Performance note: it might be faster to write ALL possible patches for a
59
+ # given spec file to tempfiles first, and then run them all in a single
60
+ # rspec call. However, this would make it impossible to use `--fail-fast`,
61
+ # and might make examples fail that are not as idempotent as they should be.
62
+ def find_changeable_create_calls
63
+ lines = create_calls.map(&:line)
64
+
65
+ self.changed_create_calls =
66
+ create_calls.sort_by { |call| [-call.line, -call.column] }.select do |call|
67
+ if lines.count(call.line) > 1
68
+ print_call_info(call, 'multiple create calls per line are unsupported, skipping')
69
+ next
111
70
  end
71
+
72
+ build_result = try_patch(call, 'build')
73
+ next if build_result == ABORT
74
+
75
+ build_result == SUCCESS || try_patch(call, 'build_stubbed') == SUCCESS
112
76
  end
77
+ end
78
+
79
+ def try_patch(call, base_variant)
80
+ variant = call.name.sub('create', base_variant)
81
+ FactorySloth.verbose && puts("#{link_to_call(call)}: trying #{variant} ...")
82
+
83
+ new_patched_code = patched_code.sub(
84
+ /\A(?:.*\R){#{call.line - 1}}.{#{call.column}}\K#{call.name}/,
85
+ variant
86
+ )
87
+ checked_patched_code = new_patched_code + ExecutionCheck.for(call.line, variant)
88
+
89
+ result = run(checked_patched_code, line: call.line)
90
+
91
+ if result.success?
92
+ info = FactorySloth.dry_run ? 'can be replaced' : 'replaced'
93
+ print_call_info(call, "#{info} with #{variant}")
94
+ self.patched_code = new_patched_code
95
+ SUCCESS
96
+ elsif result.exitstatus == ExecutionCheck::FACTORY_UNUSED_CODE
97
+ print_call_info(call, "is never executed, skipping")
98
+ ABORT
99
+ elsif result.exitstatus == ExecutionCheck::FACTORY_PERSISTED_LATER_CODE
100
+ FactorySloth.verbose && print_call_info("record is persisted later, skipping")
101
+ ABORT
113
102
  end
114
- RUBY
103
+ end
104
+
105
+ def run(code, line: nil)
106
+ result = SpecRunner.call(path, code, line: line)
107
+ FactorySloth.verbose && puts(' RSpec output:', result.output.gsub(/^/, ' '))
108
+ result
109
+ end
110
+
111
+ ABORT = :ABORT # returned if there is no need to try other variants
112
+ SUCCESS = :SUCCESS
113
+
114
+ def print_call_info(call, message)
115
+ line_content = original_code[/\A(?:.*\R){#{call.line - 1}}\K.*/]
116
+ indentation = line_content[/^\s*/]
117
+ underline = Color.yellow('^' * call.name.size)
118
+
119
+ puts(
120
+ "#{link_to_call(call)}: #{call.name} #{message}",
121
+ " #{line_content.delete_prefix(indentation)}",
122
+ " #{' ' * (call.column - indentation.size)}#{underline}",
123
+ "",
124
+ )
125
+ end
126
+
127
+ def link_to_call(call)
128
+ # note: column from Ripper is 0-indexed, editors expect 1-indexed columns
129
+ Color.light_blue("#{path}:#{call.line}:#{call.column + 1}")
130
+ end
115
131
  end
116
132
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FactorySloth::Color
4
+ extend self
5
+
6
+ def yellow(str)
7
+ colorize(str, 33)
8
+ end
9
+
10
+ def light_blue(str)
11
+ colorize(str, 36)
12
+ end
13
+
14
+ private
15
+
16
+ def colorize(str, color_code)
17
+ return str unless tty?
18
+
19
+ "\e[#{color_code}m#{str}\e[0m"
20
+ end
21
+
22
+ def tty?
23
+ $stdout.is_a?(IO) && $stdout.tty?
24
+ end
25
+ end
@@ -1 +1 @@
1
- FactorySloth::CreateCall = Struct.new(:name, :line, :column, keyword_init: true)
1
+ FactorySloth::CreateCall = Struct.new(:column, :line, :name, keyword_init: true)
@@ -0,0 +1,43 @@
1
+ # This adds code that makes a spec run fail and thus prevents changes if:
2
+ # a) the patched factory in the given line is never called
3
+ # b) the built record was persisted later anyway
4
+ # The rationale behind a) is that things like skipped examples should not
5
+ # be broken. The rationale behind b) is that not much DB work would be saved,
6
+ # but diff noise would be increased and ease of editing the example reduced.
7
+ #
8
+ # Note: the caller column is only available in a roundabout way in Ruby >= 3.1,
9
+ # https://bugs.ruby-lang.org/issues/17930, 19452, so multiple replacements
10
+ # in one line would not be validated correctly iff they had mixed validity.
11
+
12
+ module FactorySloth::ExecutionCheck
13
+ FACTORY_UNUSED_CODE = 77
14
+ FACTORY_PERSISTED_LATER_CODE = 78
15
+
16
+ def self.for(line, variant)
17
+ <<~RUBY
18
+ ; defined?(FactoryBot) && defined?(RSpec) && RSpec.configure do |config|
19
+ records_by_line = {} # track records initialized through factories per line
20
+
21
+ FactoryBot::Syntax::Methods.class_eval do
22
+ original_variant = instance_method("#{variant}") # e.g. build
23
+
24
+ define_method("#{variant}") do |*args, **kwargs, &blk|
25
+ result = original_variant.bind_call(self, *args, **kwargs, &blk)
26
+ list = records_by_line[caller_locations(1, 1)&.first&.lineno] ||= []
27
+ list.concat([result].flatten) # to work with single, list, and pair
28
+ result
29
+ end
30
+ end
31
+
32
+ config.after(:suite) do
33
+ records = records_by_line[#{line}]
34
+ records&.any? || exit!(#{FACTORY_UNUSED_CODE})
35
+ unless "#{variant}".include?('stub') # factory_bot stub stubs persisted? as true
36
+ records.any? { |r| r.respond_to?(:persisted?) && r.persisted? } &&
37
+ exit!(#{FACTORY_PERSISTED_LATER_CODE})
38
+ end
39
+ end
40
+ end
41
+ RUBY
42
+ end
43
+ end
@@ -2,46 +2,30 @@ module FactorySloth
2
2
  module FileProcessor
3
3
  extend self
4
4
 
5
- def call(files:, forced_files: [], dry_run: false)
5
+ def call(files:, forced_files: [])
6
6
  files.each_with_object({}) do |path, acc|
7
- puts "Processing #{path} ..."
8
-
9
- if DoneTracker.done?(path) && !forced_files.include?(path)
10
- puts "πŸ”΅ Skipped (marked as done in #{DoneTracker.file})", ''
7
+ if DoneTracker.done?(path) &&
8
+ !(FactorySloth.force || forced_files.include?(path))
9
+ puts "πŸ”΅ #{path}: skipped (marked as done in #{DoneTracker.file})", ''
11
10
  next
12
11
  end
13
12
 
14
- result = process(path, dry_run: dry_run)
15
- acc[path] = { ok: result.ok?, changed_create_calls: result.changed_create_calls }
13
+ result = process(path)
14
+ acc[path] = { ok: result.ok?, change_count: result.change_count }
16
15
  DoneTracker.mark_as_done(path)
17
16
  end
18
17
  end
19
18
 
20
19
  private
21
20
 
22
- def process(path, dry_run:)
21
+ def process(path)
23
22
  code = File.read(path)
24
23
  result = CodeMod.call(path, code)
25
- unless dry_run
24
+ unless FactorySloth.dry_run
26
25
  File.write(path, result.patched_code) if result.changed?
27
26
  end
28
- puts result_message(result, dry_run), ''
27
+ puts result.message, ''
29
28
  result
30
29
  end
31
-
32
- def result_message(result, dry_run)
33
- stats = "#{result.create_count} create calls found, "\
34
- "#{result.change_count} #{dry_run ? 'replaceable' : 'replaced'}"
35
-
36
- return "πŸ”΄ #{stats} (conflict)" unless result.ok?
37
-
38
- if result.create_count == 0
39
- "βšͺ️ #{stats}"
40
- elsif result.change_count == 0
41
- "🟑 #{stats}"
42
- else
43
- "🟒 #{stats}"
44
- end
45
- end
46
30
  end
47
31
  end
@@ -8,8 +8,14 @@ module FactorySloth::SpecRunner
8
8
  File.write(path, spec_code)
9
9
  path_arg = [path, line].compact.map(&:to_s).join(':')
10
10
  command = "bundle exec rspec #{path_arg} --fail-fast --order defined 2>&1"
11
- _output, status = Open3.capture2(command)
12
- status
11
+ output, process_status = Open3.capture2(command)
12
+ Result.new(output: output, process_status: process_status)
13
+ end
14
+
15
+ Result = Struct.new(:output, :process_status, keyword_init: true) do
16
+ require 'forwardable'
17
+ extend Forwardable
18
+ def_delegators :process_status, :exitstatus, :success?
13
19
  end
14
20
 
15
21
  def self.tmpdir
@@ -1,3 +1,3 @@
1
1
  module FactorySloth
2
- VERSION = '1.2.2'
2
+ VERSION = '1.3.1'
3
3
  end
data/lib/factory_sloth.rb CHANGED
@@ -1,10 +1,14 @@
1
- module FactorySloth; end
1
+ module FactorySloth
2
+ singleton_class.attr_accessor :dry_run, :force, :lint, :verbose
3
+ end
2
4
 
3
5
  require_relative 'factory_sloth/cli'
4
6
  require_relative 'factory_sloth/code_mod'
7
+ require_relative 'factory_sloth/color'
5
8
  require_relative 'factory_sloth/create_call'
6
9
  require_relative 'factory_sloth/create_call_finder'
7
10
  require_relative 'factory_sloth/done_tracker'
11
+ require_relative 'factory_sloth/execution_check'
8
12
  require_relative 'factory_sloth/file_processor'
9
13
  require_relative 'factory_sloth/spec_picker'
10
14
  require_relative 'factory_sloth/spec_runner'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: factory_sloth
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.2
4
+ version: 1.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Janosch Müller
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-05-19 00:00:00.000000000 Z
11
+ date: 2023-05-24 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description:
14
14
  email:
@@ -28,9 +28,11 @@ files:
28
28
  - lib/factory_sloth.rb
29
29
  - lib/factory_sloth/cli.rb
30
30
  - lib/factory_sloth/code_mod.rb
31
+ - lib/factory_sloth/color.rb
31
32
  - lib/factory_sloth/create_call.rb
32
33
  - lib/factory_sloth/create_call_finder.rb
33
34
  - lib/factory_sloth/done_tracker.rb
35
+ - lib/factory_sloth/execution_check.rb
34
36
  - lib/factory_sloth/file_processor.rb
35
37
  - lib/factory_sloth/spec_picker.rb
36
38
  - lib/factory_sloth/spec_runner.rb