evilution 1.3.0 → 1.4.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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +62 -0
  3. data/.rubocop_todo.yml +5 -0
  4. data/CHANGELOG.md +77 -0
  5. data/README.md +80 -24
  6. data/docs/architecture.md +89 -9
  7. data/docs/ast_pattern_syntax.md +50 -5
  8. data/docs/isolation.md +39 -2
  9. data/docs/migration-from-mutant.md +1 -1
  10. data/lib/evilution/ast/aasm_declaration.rb +133 -0
  11. data/lib/evilution/ast/callback_declaration.rb +91 -0
  12. data/lib/evilution/ast/included_block.rb +27 -0
  13. data/lib/evilution/ast/literal_callable.rb +21 -0
  14. data/lib/evilution/ast/parser.rb +119 -16
  15. data/lib/evilution/ast/pattern/method_name.rb +63 -0
  16. data/lib/evilution/ast/pattern/parser.rb +14 -15
  17. data/lib/evilution/ast/regexp_pattern.rb +104 -0
  18. data/lib/evilution/ast/scope_declaration.rb +45 -0
  19. data/lib/evilution/ast/uncovered_code.rb +88 -0
  20. data/lib/evilution/ast/value_object_definition.rb +25 -0
  21. data/lib/evilution/baseline/failure_formatter.rb +27 -0
  22. data/lib/evilution/baseline/report.rb +66 -0
  23. data/lib/evilution/baseline/spec_failure.rb +38 -0
  24. data/lib/evilution/baseline.rb +67 -30
  25. data/lib/evilution/cli/parser/file_args.rb +2 -1
  26. data/lib/evilution/cli/parser/options_builder.rb +1 -1
  27. data/lib/evilution/cli.rb +3 -2
  28. data/lib/evilution/config/validators/spec_mappings.rb +2 -1
  29. data/lib/evilution/config.rb +3 -2
  30. data/lib/evilution/equivalent/detector.rb +3 -1
  31. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
  32. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
  33. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
  34. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
  35. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
  36. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
  37. data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
  38. data/lib/evilution/example_filter.rb +45 -0
  39. data/lib/evilution/hooks/registry.rb +2 -1
  40. data/lib/evilution/integration/base.rb +2 -0
  41. data/lib/evilution/integration/known_failures.rb +26 -0
  42. data/lib/evilution/integration/loading/body_call_neutralizer.rb +132 -13
  43. data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
  44. data/lib/evilution/integration/loading/concern_redeclaration.rb +61 -0
  45. data/lib/evilution/integration/loading/concern_state_cleaner.rb +19 -12
  46. data/lib/evilution/integration/loading/mutation_applier.rb +16 -0
  47. data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
  48. data/lib/evilution/integration/minitest/test_ids.rb +21 -0
  49. data/lib/evilution/integration/minitest.rb +45 -6
  50. data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
  51. data/lib/evilution/integration/rspec/example_ids.rb +35 -0
  52. data/lib/evilution/integration/rspec/result_builder.rb +6 -1
  53. data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
  54. data/lib/evilution/integration/rspec/state_guard.rb +4 -1
  55. data/lib/evilution/integration/rspec.rb +22 -4
  56. data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
  57. data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
  58. data/lib/evilution/integration/test_unit.rb +37 -9
  59. data/lib/evilution/isolation/fork.rb +2 -1
  60. data/lib/evilution/isolation/in_process.rb +21 -4
  61. data/lib/evilution/mcp/info_tool/status_glossary.rb +2 -2
  62. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
  63. data/lib/evilution/memory/leak_check.rb +27 -3
  64. data/lib/evilution/mutation.rb +7 -2
  65. data/lib/evilution/mutator/base.rb +49 -5
  66. data/lib/evilution/mutator/operator/alias_removal.rb +126 -0
  67. data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
  68. data/lib/evilution/mutator/operator/argument_removal.rb +9 -1
  69. data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
  70. data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
  71. data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
  72. data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
  73. data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
  74. data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
  75. data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
  76. data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
  77. data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
  78. data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
  79. data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
  80. data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
  81. data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
  82. data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
  83. data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
  84. data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
  85. data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
  86. data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
  87. data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
  88. data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
  89. data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
  90. data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
  91. data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
  92. data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
  93. data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
  94. data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
  95. data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
  96. data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
  97. data/lib/evilution/mutator/operator/send_mutation.rb +2 -0
  98. data/lib/evilution/mutator/operator/splat_operator.rb +38 -13
  99. data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
  100. data/lib/evilution/mutator/registry.rb +31 -2
  101. data/lib/evilution/mutator/rescue_handlers.rb +81 -0
  102. data/lib/evilution/process_supervisor.rb +3 -2
  103. data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
  104. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  105. data/lib/evilution/reporter/json/baseline.rb +15 -0
  106. data/lib/evilution/reporter/json.rb +7 -2
  107. data/lib/evilution/result/baseline_neutralization.rb +10 -0
  108. data/lib/evilution/result/mutation_result.rb +9 -1
  109. data/lib/evilution/result/summary.rb +42 -2
  110. data/lib/evilution/runner/baseline_runner.rb +9 -4
  111. data/lib/evilution/runner/canary.rb +2 -57
  112. data/lib/evilution/runner/canary_failure_message.rb +101 -0
  113. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +22 -8
  114. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
  115. data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
  116. data/lib/evilution/runner/mutation_executor.rb +1 -1
  117. data/lib/evilution/runner/mutation_planner.rb +11 -2
  118. data/lib/evilution/runner/report_publisher.rb +3 -2
  119. data/lib/evilution/runner/subject_pipeline.rb +46 -1
  120. data/lib/evilution/runner.rb +4 -1
  121. data/lib/evilution/subject.rb +8 -2
  122. data/lib/evilution/version.rb +1 -1
  123. data/lib/evilution.rb +28 -0
  124. data/script/memory_check +62 -16
  125. metadata +83 -4
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../baseline"
4
+
5
+ class Evilution::Baseline
6
+ ExampleFailure = Data.define(:id, :description, :message)
7
+
8
+ # Why one spec file was red in the baseline: the examples that failed, or the
9
+ # error that stopped it before any example could -- a load error, a runner
10
+ # that raised, a timeout. example_count is every failing example; examples
11
+ # holds only the first few, for display; failed_ids names all of them the
12
+ # way a mutation run does, for comparison.
13
+ SpecFailure = Data.define(:spec_file, :examples, :example_count, :error, :failed_ids) do
14
+ def self.from_report(spec_file, report)
15
+ examples = Array(report[:failures]).map { |failure| ExampleFailure.new(**failure) }
16
+ new(
17
+ spec_file: spec_file,
18
+ examples: examples,
19
+ example_count: report[:failure_count] || examples.length,
20
+ error: report[:error],
21
+ failed_ids: Array(report[:failed_ids])
22
+ )
23
+ end
24
+
25
+ def initialize(spec_file:, examples: [], example_count: 0, error: nil, failed_ids: [])
26
+ super
27
+ end
28
+
29
+ def to_h
30
+ {
31
+ spec_file: spec_file,
32
+ error: error,
33
+ failing_examples: example_count,
34
+ examples: examples.map(&:to_h)
35
+ }
36
+ end
37
+ end
38
+ end
@@ -2,10 +2,11 @@
2
2
 
3
3
  require_relative "spec_resolver"
4
4
  require_relative "process_cleanup"
5
+ require_relative "diagnostic"
5
6
 
6
7
  class Evilution::Baseline
7
- Result = Struct.new(:failed_spec_files, :duration) do
8
- def initialize(**)
8
+ Result = Struct.new(:failed_spec_files, :duration, :failures) do
9
+ def initialize(failed_spec_files:, duration:, failures: [])
9
10
  super
10
11
  freeze
11
12
  end
@@ -13,6 +14,11 @@ class Evilution::Baseline
13
14
  def failed?
14
15
  !failed_spec_files.empty?
15
16
  end
17
+
18
+ # Every example known to have failed before any mutation ran.
19
+ def failed_example_ids
20
+ failures.flat_map(&:failed_ids).to_set
21
+ end
16
22
  end
17
23
 
18
24
  # spec_selector: the object `run` resolves with (integration layout plus
@@ -37,18 +43,20 @@ class Evilution::Baseline
37
43
 
38
44
  def call(subjects)
39
45
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
40
- spec_files = baseline_spec_files(subjects)
41
- failed = Set.new
42
-
43
- spec_files.each do |spec_file|
44
- failed.add(spec_file) unless run_spec_file(spec_file)
45
- end
46
+ failures = baseline_spec_files(subjects).filter_map { |spec_file| check_spec_file(spec_file) }
46
47
 
47
48
  duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
48
- Result.new(failed_spec_files: failed, duration: duration)
49
+ Result.new(failed_spec_files: failures.to_set(&:spec_file), duration: duration, failures: failures)
49
50
  end
50
51
 
51
52
  def run_spec_file(spec_file)
53
+ check_spec_file(spec_file).nil?
54
+ end
55
+
56
+ # nil when the spec file passed, otherwise why it did not. Anything that
57
+ # stops the baseline from getting an answer counts as failing, and is
58
+ # recorded as the reason.
59
+ def check_spec_file(spec_file)
52
60
  raise Evilution::Error, "no baseline runner configured" unless @runner
53
61
 
54
62
  read_io, write_io = IO.pipe
@@ -57,11 +65,11 @@ class Evilution::Baseline
57
65
  read_result(read_io, pid, spec_file)
58
66
  rescue Evilution::Error
59
67
  raise
60
- rescue StandardError
61
- false
68
+ rescue StandardError => e
69
+ warned(SpecFailure.new(spec_file: spec_file, error: "#{e.class}: #{e.message}"))
62
70
  ensure
63
- read_io&.close
64
- write_io&.close
71
+ read_io.close if read_io
72
+ write_io.close if write_io
65
73
  end
66
74
 
67
75
  def fork_spec_runner(spec_file, read_io, write_io)
@@ -71,32 +79,28 @@ class Evilution::Baseline
71
79
  $stdout.reopen(File::NULL, "w")
72
80
  $stderr.reopen(File::NULL, "w")
73
81
 
74
- passed = runner.call(spec_file)
75
- Marshal.dump({ passed: passed }, write_io)
82
+ report = child_report(runner, spec_file)
83
+ Marshal.dump(report, write_io)
76
84
  write_io.close
77
- exit!(passed ? 0 : 1)
85
+ exit!(report[:passed] ? 0 : 1)
78
86
  end
79
87
  end
80
88
 
81
89
  GRACE_PERIOD = 0.5
82
90
 
83
91
  def read_result(read_io, pid, spec_file)
84
- if read_io.wait_readable(@timeout)
85
- data = read_io.read
86
- Process.wait(pid)
87
- return false if data.empty?
88
-
89
- result = Marshal.load(data)
90
- result[:passed]
91
- else
92
- terminate_child(pid)
93
- warn_timeout(spec_file)
94
- false
95
- end
92
+ return timed_out(pid, spec_file) unless read_io.wait_readable(@timeout)
93
+
94
+ data = read_io.read
95
+ _, status = Process.wait2(pid)
96
+ return warned(SpecFailure.new(spec_file: spec_file, error: unreported_error(status))) if data.empty?
97
+
98
+ report = Marshal.load(data)
99
+ report[:passed] ? nil : warned(SpecFailure.from_report(spec_file, report))
96
100
  end
97
101
 
98
102
  def warn_timeout(spec_file)
99
- warn "[evilution] Baseline for #{spec_file} timed out after #{@timeout}s; treating it as failing."
103
+ Evilution::Diagnostic.warn("[evilution] Baseline for #{spec_file} timed out after #{@timeout}s; treating it as failing.")
100
104
  end
101
105
 
102
106
  def terminate_child(pid)
@@ -114,6 +118,35 @@ class Evilution::Baseline
114
118
 
115
119
  private
116
120
 
121
+ # Runs in the child. A runner that raises -- a spec helper that cannot load,
122
+ # a framework that is not there -- is a failure with a reason, not a child
123
+ # that dies without a word.
124
+ def child_report(runner, spec_file)
125
+ Report.from(runner.call(spec_file))
126
+ rescue StandardError, ScriptError => e
127
+ Report.build(passed: false, error: "#{e.class}: #{e.message}")
128
+ end
129
+
130
+ def timed_out(pid, spec_file)
131
+ terminate_child(pid)
132
+ warn_timeout(spec_file)
133
+ SpecFailure.new(spec_file: spec_file, error: "timed out after #{@timeout}s")
134
+ end
135
+
136
+ def unreported_error(status)
137
+ cause = status.signaled? ? "signal #{status.termsig}" : "exit status #{status.exitstatus}"
138
+ "baseline process ended without reporting (#{cause})"
139
+ end
140
+
141
+ def warned(failure)
142
+ detail = FailureFormatter.new.call(failure).map { |line| " #{line}" }
143
+ Evilution::Diagnostic.warn(
144
+ ["[evilution] Baseline failed for #{failure.spec_file}; " \
145
+ "a mutation that fails only its already-failing examples will be reported neutral.", *detail].join("\n")
146
+ )
147
+ failure
148
+ end
149
+
117
150
  def baseline_spec_files(subjects)
118
151
  return Array(@test_files).uniq if @test_files && !@test_files.empty?
119
152
 
@@ -137,7 +170,7 @@ class Evilution::Baseline
137
170
 
138
171
  def warn_no_matching_test(file_path)
139
172
  action = @fallback_to_full_suite ? "running full suite" : "marking its mutations unresolved"
140
- warn "[evilution] No matching test found for #{file_path}, #{action}. #{no_match_hint(file_path)}"
173
+ Evilution::Diagnostic.warn("[evilution] No matching test found for #{file_path}, #{action}. #{no_match_hint(file_path)}")
141
174
  end
142
175
 
143
176
  def no_match_hint(file_path)
@@ -150,3 +183,7 @@ class Evilution::Baseline
150
183
  @fallback_to_full_suite ? "#{hint}." : "#{hint}, or --fallback-full-suite to run the whole suite."
151
184
  end
152
185
  end
186
+
187
+ require_relative "baseline/report"
188
+ require_relative "baseline/spec_failure"
189
+ require_relative "baseline/failure_formatter"
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../parser"
4
+ require_relative "../../diagnostic"
4
5
 
5
6
  module Evilution::CLI::Parser::FileArgs
6
7
  ParsedPaths = Data.define(:files, :ranges)
@@ -24,7 +25,7 @@ module Evilution::CLI::Parser::FileArgs
24
25
 
25
26
  def expand_spec_dir(dir)
26
27
  unless File.directory?(dir)
27
- warn("Error: #{dir} is not a directory")
28
+ Evilution::Diagnostic.warn("Error: #{dir} is not a directory")
28
29
  return []
29
30
  end
30
31
 
@@ -140,7 +140,7 @@ class Evilution::CLI::Parser::OptionsBuilder
140
140
 
141
141
  def add_profile_options(opts)
142
142
  opts.on("--profile NAME", "Operator profile: default, strict (default: default). " \
143
- "strict adds aggressive truthiness mutators for pre-merge audits.") { |p| @options[:profile] = p }
143
+ "strict adds aggressive mutators for pre-merge audits.") { |p| @options[:profile] = p }
144
144
  opts.on("--strict", "Shortcut for --profile=strict") { @options[:profile] = "strict" }
145
145
  end
146
146
 
data/lib/evilution/cli.rb CHANGED
@@ -24,6 +24,7 @@ require_relative "cli/commands/session_gc"
24
24
  require_relative "cli/commands/compare"
25
25
  require_relative "cli/commands/run"
26
26
  require_relative "cli/exit_guard"
27
+ require_relative "diagnostic"
27
28
 
28
29
  # @api private
29
30
  class Evilution::CLI
@@ -42,14 +43,14 @@ class Evilution::CLI
42
43
  return run_subcommand_error(@parse_error) if @command == :parse_error
43
44
 
44
45
  result = Dispatcher.lookup(@command).new(@parsed, stdout: $stdout, stderr: $stderr).call
45
- warn("Error: #{result.error.message}") if result.error && !result.error_rendered
46
+ Evilution::Diagnostic.warn("Error: #{result.error.message}") if result.error && !result.error_rendered
46
47
  result.exit_code
47
48
  end
48
49
 
49
50
  private
50
51
 
51
52
  def run_subcommand_error(message)
52
- warn("Error: #{message}")
53
+ Evilution::Diagnostic.warn("Error: #{message}")
53
54
  2
54
55
  end
55
56
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../validators"
4
4
  require_relative "base"
5
+ require_relative "../../diagnostic"
5
6
 
6
7
  class Evilution::Config::Validators::SpecMappings < Evilution::Config::Validators::Base
7
8
  def self.call(value)
@@ -49,7 +50,7 @@ class Evilution::Config::Validators::SpecMappings < Evilution::Config::Validator
49
50
  specs.each do |spec_path|
50
51
  next if File.exist?(spec_path)
51
52
 
52
- warn "[evilution] spec_mappings[#{source.inspect}]: #{spec_path} not found, skipping"
53
+ Evilution::Diagnostic.warn("[evilution] spec_mappings[#{source.inspect}]: #{spec_path} not found, skipping")
53
54
  end
54
55
  end
55
56
  end
@@ -216,8 +216,9 @@ class Evilution::Config
216
216
  # - "call{name=debug|warn}"
217
217
 
218
218
  # Operator profile: default or strict (default: default).
219
- # strict adds aggressive truthiness mutators (e.g. replaces
220
- # `x.predicate?` with `nil`) intended for pre-merge audits.
219
+ # strict adds aggressive mutators (e.g. replaces `x.predicate?` with
220
+ # `nil`, or appends `rescue nil` to a raising call) intended for
221
+ # pre-merge audits.
221
222
  # profile: default
222
223
  YAML
223
224
  end
@@ -7,6 +7,7 @@ require_relative "heuristic/dead_code"
7
7
  require_relative "heuristic/arithmetic_identity"
8
8
  require_relative "heuristic/comment_marking"
9
9
  require_relative "heuristic/void_context"
10
+ require_relative "heuristic/guarded_index_fetch"
10
11
 
11
12
  require_relative "../equivalent"
12
13
 
@@ -40,7 +41,8 @@ class Evilution::Equivalent::Detector
40
41
  Evilution::Equivalent::Heuristic::DeadCode.new,
41
42
  Evilution::Equivalent::Heuristic::ArithmeticIdentity.new,
42
43
  Evilution::Equivalent::Heuristic::CommentMarking.new,
43
- Evilution::Equivalent::Heuristic::VoidContext.new
44
+ Evilution::Equivalent::Heuristic::VoidContext.new,
45
+ Evilution::Equivalent::Heuristic::GuardedIndexFetch.new
44
46
  ]
45
47
  end
46
48
  end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../guarded_index_fetch"
4
+
5
+ # Whether a condition, when truthy, says the read's key is there.
6
+ #
7
+ # That is the read itself, `recv[key].present?`, `recv.dig(key)`, or a key
8
+ # predicate on the same receiver and key -- alone or as one side of an `&&`,
9
+ # since both sides hold when the whole does. `refutes?` answers the mirror
10
+ # question, for conditions that are truthy when the key is missing.
11
+ class Evilution::Equivalent::Heuristic::GuardedIndexFetch::Condition
12
+ KEY_PREDICATES = %i[key? has_key? include? member?].freeze
13
+ SINGLE_KEY_LOOKUPS = (KEY_PREDICATES + [:dig]).freeze
14
+ ABSENCE_TESTS = %i[nil? blank?].freeze
15
+
16
+ def initialize(read)
17
+ @read = read
18
+ end
19
+
20
+ def proves?(node)
21
+ case node
22
+ when Prism::ParenthesesNode then parenthesized?(node)
23
+ when Prism::AndNode then proves?(node.left) || proves?(node.right)
24
+ when Prism::CallNode then read_present?(node)
25
+ else false
26
+ end
27
+ end
28
+
29
+ # Whether a condition, when falsy, says the read's key is there: the
30
+ # negation of something that proves it, `recv[key].nil?` / `.blank?`, or
31
+ # either as one side of an `||`, since both sides are falsy when the whole
32
+ # is. This is what an early exit tests: `return if config[:k].nil?`.
33
+ def refutes?(node)
34
+ case node
35
+ when Prism::ParenthesesNode then single_statement(node) { |inner| refutes?(inner) }
36
+ when Prism::OrNode then refutes?(node.left) || refutes?(node.right)
37
+ when Prism::CallNode then read_absent?(node)
38
+ else false
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def parenthesized?(node)
45
+ single_statement(node) { |inner| proves?(inner) }
46
+ end
47
+
48
+ def single_statement(node)
49
+ body = node.body
50
+ body.is_a?(Prism::StatementsNode) && body.body.length == 1 && yield(body.body.first)
51
+ end
52
+
53
+ # `!cond` and `not cond` are both a call to `!`.
54
+ def read_absent?(call)
55
+ return proves?(call.receiver) if call.name == :!
56
+
57
+ ABSENCE_TESTS.include?(call.name) && @read.same?(call.receiver)
58
+ end
59
+
60
+ def read_present?(call)
61
+ return true if @read.same?(call)
62
+ return @read.same?(call.receiver) if call.name == :present? && call.arguments.nil?
63
+
64
+ SINGLE_KEY_LOOKUPS.include?(call.name) && @read.on_receiver?(call) && @read.keyed?(call)
65
+ end
66
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../guarded_index_fetch"
4
+
5
+ # Whether a guarded branch does anything that could take the key away again
6
+ # after the guard saw it: assigns the variable the receiver hangs off, or
7
+ # calls something on the receiver that changes it.
8
+ #
9
+ # The whole branch is searched, not just what precedes the read; inside a loop
10
+ # "before" and "after" trade places.
11
+ class Evilution::Equivalent::Heuristic::GuardedIndexFetch::Disturbance
12
+ LOCAL_WRITES = [
13
+ Prism::LocalVariableWriteNode, Prism::LocalVariableOperatorWriteNode, Prism::LocalVariableOrWriteNode,
14
+ Prism::LocalVariableAndWriteNode, Prism::LocalVariableTargetNode
15
+ ].freeze
16
+ INSTANCE_VARIABLE_WRITES = [
17
+ Prism::InstanceVariableWriteNode, Prism::InstanceVariableOperatorWriteNode,
18
+ Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableAndWriteNode, Prism::InstanceVariableTargetNode
19
+ ].freeze
20
+ MUTATORS = %i[[]= delete clear replace store update shift pop delete_if keep_if].freeze
21
+
22
+ def initialize(read)
23
+ @read = read
24
+ end
25
+
26
+ def in?(node)
27
+ disturbs?(node) || node.compact_child_nodes.any? { |child| in?(child) }
28
+ end
29
+
30
+ private
31
+
32
+ def disturbs?(node)
33
+ reassigns_root?(node) || mutates_receiver?(node)
34
+ end
35
+
36
+ def reassigns_root?(node)
37
+ writes = if @read.local_root?
38
+ LOCAL_WRITES
39
+ elsif @read.instance_variable_root?
40
+ INSTANCE_VARIABLE_WRITES
41
+ else
42
+ []
43
+ end
44
+ writes.any? { |type| node.is_a?(type) } && node.name == @read.root_name
45
+ end
46
+
47
+ def mutates_receiver?(node)
48
+ node.is_a?(Prism::CallNode) && @read.on_receiver?(node) && mutator?(node.name)
49
+ end
50
+
51
+ def mutator?(name)
52
+ MUTATORS.include?(name) || name.end_with?("!")
53
+ end
54
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../guarded_index_fetch"
4
+
5
+ # A guard that leaves when the key is missing protects what comes after it in
6
+ # the same statement list:
7
+ #
8
+ # return unless config[:k]
9
+ # style << config[:k] # fetch cannot raise here
10
+ #
11
+ # The guard is an `unless` whose condition says the key is there, an `if`
12
+ # whose condition says it is not, or `cond or return` -- with a body ending in
13
+ # `return`, `next`, `break`, `raise` or `fail`, and no else branch to fall
14
+ # through. `next` and `break` leave only the current iteration, which is all
15
+ # the statements after them in the block's body belong to.
16
+ #
17
+ # Nothing between the guard and the read, nor the statement holding the read,
18
+ # may change the receiver; that statement is checked whole, since a loop in it
19
+ # runs its end before its start the second time round.
20
+ class Evilution::Equivalent::Heuristic::GuardedIndexFetch::EarlyExit
21
+ EXIT_NODES = [Prism::ReturnNode, Prism::NextNode, Prism::BreakNode].freeze
22
+ EXIT_CALLS = %i[raise fail].freeze
23
+
24
+ def initialize(statements, read)
25
+ @statements = statements
26
+ @read = read
27
+ @condition = Evilution::Equivalent::Heuristic::GuardedIndexFetch::Condition.new(read)
28
+ end
29
+
30
+ # child: the statement of this list that holds the read.
31
+ def protects?(child)
32
+ return false unless @statements.is_a?(Prism::StatementsNode)
33
+
34
+ list = @statements.body
35
+ position = list.index { |statement| statement.equal?(child) }
36
+ guard = position && nearest_guard(list, position)
37
+ return false unless guard
38
+
39
+ list[(guard + 1)..position].none? { |statement| disturbance.in?(statement) }
40
+ end
41
+
42
+ private
43
+
44
+ # The last guard before the read: an earlier one would have more statements
45
+ # after it to answer for, not fewer.
46
+ def nearest_guard(list, position)
47
+ (0...position).reverse_each.find { |index| guard?(list[index]) }
48
+ end
49
+
50
+ def guard?(statement)
51
+ case statement
52
+ when Prism::UnlessNode then leaves?(statement, statement.else_clause) && @condition.proves?(statement.predicate)
53
+ when Prism::IfNode then leaves?(statement, statement.subsequent) && @condition.refutes?(statement.predicate)
54
+ when Prism::OrNode then exit?(statement.right) && @condition.proves?(statement.left)
55
+ else false
56
+ end
57
+ end
58
+
59
+ def leaves?(conditional, other_branch)
60
+ body = conditional.statements
61
+ other_branch.nil? && body.is_a?(Prism::StatementsNode) && exit?(body.body.last)
62
+ end
63
+
64
+ def exit?(node)
65
+ return true if EXIT_NODES.any? { |type| node.is_a?(type) }
66
+
67
+ node.is_a?(Prism::CallNode) && node.receiver.nil? && EXIT_CALLS.include?(node.name)
68
+ end
69
+
70
+ def disturbance
71
+ @disturbance ||= Evilution::Equivalent::Heuristic::GuardedIndexFetch::Disturbance.new(@read)
72
+ end
73
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../guarded_index_fetch"
4
+
5
+ # One node that may guard a read: an `if` (ternary and modifier forms
6
+ # included), an `unless` with an else, or an `&&`. It protects the part that
7
+ # runs only when its condition held -- provided the condition says the key is
8
+ # there and that part leaves the receiver alone.
9
+ class Evilution::Equivalent::Heuristic::GuardedIndexFetch::Guard
10
+ def initialize(node, read)
11
+ @node = node
12
+ @read = read
13
+ end
14
+
15
+ def protects?(child)
16
+ guarded = guarded_part
17
+ return false unless guarded && guarded.equal?(child)
18
+
19
+ Evilution::Equivalent::Heuristic::GuardedIndexFetch::Condition.new(@read).proves?(condition) &&
20
+ !Evilution::Equivalent::Heuristic::GuardedIndexFetch::Disturbance.new(@read).in?(guarded)
21
+ end
22
+
23
+ private
24
+
25
+ def guarded_part
26
+ case @node
27
+ when Prism::IfNode then @node.statements
28
+ when Prism::UnlessNode then @node.else_clause
29
+ when Prism::AndNode then @node.right
30
+ end
31
+ end
32
+
33
+ def condition
34
+ @node.is_a?(Prism::AndNode) ? @node.left : @node.predicate
35
+ end
36
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../guarded_index_fetch"
4
+
5
+ # One `recv[key]` read, reduced to what makes another read "the same": the
6
+ # receiver's source and a literal key.
7
+ #
8
+ # The receiver has to be something that can be named twice and mean the same
9
+ # thing both times: a variable, a constant, or calls without arguments or
10
+ # block on one of those. `lookup(id)[:k]` is not.
11
+ class Evilution::Equivalent::Heuristic::GuardedIndexFetch::IndexRead
12
+ KEY_TYPES = [Prism::SymbolNode, Prism::StringNode, Prism::IntegerNode].freeze
13
+ NAMED_TYPES = [
14
+ Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode, Prism::ClassVariableReadNode,
15
+ Prism::GlobalVariableReadNode, Prism::ConstantReadNode, Prism::ConstantPathNode, Prism::SelfNode
16
+ ].freeze
17
+
18
+ attr_reader :receiver_source, :key_source, :root
19
+
20
+ def self.from(node)
21
+ key = single_argument(node)
22
+ return nil unless key && literal?(key) && node.receiver && stable?(node.receiver)
23
+
24
+ new(receiver_source: node.receiver.slice, key_source: key.slice, root: root_of(node.receiver))
25
+ end
26
+
27
+ def self.single_argument(node)
28
+ return nil unless node.arguments && node.arguments.arguments.length == 1
29
+
30
+ node.arguments.arguments.first
31
+ end
32
+
33
+ def self.literal?(node)
34
+ KEY_TYPES.any? { |type| node.is_a?(type) }
35
+ end
36
+
37
+ def self.stable?(node)
38
+ return true if NAMED_TYPES.any? { |type| node.is_a?(type) }
39
+ return false unless node.is_a?(Prism::CallNode) && node.arguments.nil? && node.block.nil?
40
+
41
+ node.receiver.nil? || stable?(node.receiver)
42
+ end
43
+
44
+ # The variable a receiver hangs off: `user` in `user.settings`.
45
+ def self.root_of(node)
46
+ node = node.receiver while node.is_a?(Prism::CallNode) && node.receiver
47
+ node
48
+ end
49
+
50
+ def initialize(receiver_source:, key_source:, root:)
51
+ @receiver_source = receiver_source
52
+ @key_source = key_source
53
+ @root = root
54
+ end
55
+
56
+ def root_name
57
+ root.respond_to?(:name) ? root.name : nil
58
+ end
59
+
60
+ def local_root?
61
+ root.is_a?(Prism::LocalVariableReadNode)
62
+ end
63
+
64
+ def instance_variable_root?
65
+ root.is_a?(Prism::InstanceVariableReadNode)
66
+ end
67
+
68
+ # Whether a node is this read again: `recv[key]`.
69
+ def same?(node)
70
+ node.is_a?(Prism::CallNode) && node.name == :[] && on_receiver?(node) && keyed?(node)
71
+ end
72
+
73
+ def on_receiver?(call)
74
+ !call.receiver.nil? && call.receiver.slice == receiver_source
75
+ end
76
+
77
+ # Whether a call's only argument is this read's key.
78
+ def keyed?(call)
79
+ key = self.class.single_argument(call)
80
+ !key.nil? && key.slice == key_source
81
+ end
82
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../guarded_index_fetch"
4
+
5
+ # The nodes from the subject down to the `recv[key]` call a mutation rewrote,
6
+ # outermost first; nil when it cannot be found.
7
+ #
8
+ # Line and column are not enough: in `a[:x][:y]` the outer and the inner read
9
+ # start at the same place. The mutated source settles it -- the rewritten read
10
+ # is the one whose receiver is now followed by `.fetch(`.
11
+ class Evilution::Equivalent::Heuristic::GuardedIndexFetch::NodePath
12
+ FETCH_CALL = ".fetch("
13
+
14
+ def call(mutation)
15
+ root = mutation.subject.node
16
+ mutated_source = mutation.mutated_source
17
+ return nil unless root && mutated_source
18
+
19
+ search(root, mutation, mutated_source, [])
20
+ end
21
+
22
+ private
23
+
24
+ def search(node, mutation, mutated_source, ancestors)
25
+ path = ancestors + [node]
26
+ return path if rewritten?(node, mutation, mutated_source)
27
+
28
+ node.compact_child_nodes.each do |child|
29
+ found = search(child, mutation, mutated_source, path)
30
+ return found if found
31
+ end
32
+ nil
33
+ end
34
+
35
+ def rewritten?(node, mutation, mutated_source)
36
+ return false unless node.is_a?(Prism::CallNode) && node.name == :[] && node.receiver
37
+
38
+ location = node.location
39
+ return false unless location.start_line == mutation.line && location.start_column == mutation.column
40
+
41
+ mutated_source.byteslice(node.receiver.location.end_offset, FETCH_CALL.bytesize) == FETCH_CALL
42
+ end
43
+ end