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.
- checksums.yaml +4 -4
- data/.beads/interactions.jsonl +62 -0
- data/.rubocop_todo.yml +5 -0
- data/CHANGELOG.md +77 -0
- data/README.md +80 -24
- data/docs/architecture.md +89 -9
- data/docs/ast_pattern_syntax.md +50 -5
- data/docs/isolation.md +39 -2
- data/docs/migration-from-mutant.md +1 -1
- data/lib/evilution/ast/aasm_declaration.rb +133 -0
- data/lib/evilution/ast/callback_declaration.rb +91 -0
- data/lib/evilution/ast/included_block.rb +27 -0
- data/lib/evilution/ast/literal_callable.rb +21 -0
- data/lib/evilution/ast/parser.rb +119 -16
- data/lib/evilution/ast/pattern/method_name.rb +63 -0
- data/lib/evilution/ast/pattern/parser.rb +14 -15
- data/lib/evilution/ast/regexp_pattern.rb +104 -0
- data/lib/evilution/ast/scope_declaration.rb +45 -0
- data/lib/evilution/ast/uncovered_code.rb +88 -0
- data/lib/evilution/ast/value_object_definition.rb +25 -0
- data/lib/evilution/baseline/failure_formatter.rb +27 -0
- data/lib/evilution/baseline/report.rb +66 -0
- data/lib/evilution/baseline/spec_failure.rb +38 -0
- data/lib/evilution/baseline.rb +67 -30
- data/lib/evilution/cli/parser/file_args.rb +2 -1
- data/lib/evilution/cli/parser/options_builder.rb +1 -1
- data/lib/evilution/cli.rb +3 -2
- data/lib/evilution/config/validators/spec_mappings.rb +2 -1
- data/lib/evilution/config.rb +3 -2
- data/lib/evilution/equivalent/detector.rb +3 -1
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
- data/lib/evilution/example_filter.rb +45 -0
- data/lib/evilution/hooks/registry.rb +2 -1
- data/lib/evilution/integration/base.rb +2 -0
- data/lib/evilution/integration/known_failures.rb +26 -0
- data/lib/evilution/integration/loading/body_call_neutralizer.rb +132 -13
- data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
- data/lib/evilution/integration/loading/concern_redeclaration.rb +61 -0
- data/lib/evilution/integration/loading/concern_state_cleaner.rb +19 -12
- data/lib/evilution/integration/loading/mutation_applier.rb +16 -0
- data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
- data/lib/evilution/integration/minitest/test_ids.rb +21 -0
- data/lib/evilution/integration/minitest.rb +45 -6
- data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
- data/lib/evilution/integration/rspec/example_ids.rb +35 -0
- data/lib/evilution/integration/rspec/result_builder.rb +6 -1
- data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
- data/lib/evilution/integration/rspec/state_guard.rb +4 -1
- data/lib/evilution/integration/rspec.rb +22 -4
- data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
- data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
- data/lib/evilution/integration/test_unit.rb +37 -9
- data/lib/evilution/isolation/fork.rb +2 -1
- data/lib/evilution/isolation/in_process.rb +21 -4
- data/lib/evilution/mcp/info_tool/status_glossary.rb +2 -2
- data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
- data/lib/evilution/memory/leak_check.rb +27 -3
- data/lib/evilution/mutation.rb +7 -2
- data/lib/evilution/mutator/base.rb +49 -5
- data/lib/evilution/mutator/operator/alias_removal.rb +126 -0
- data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
- data/lib/evilution/mutator/operator/argument_removal.rb +9 -1
- data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
- data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
- data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
- data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
- data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
- data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
- data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
- data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
- data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
- data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
- data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
- data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
- data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
- data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
- data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
- data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
- data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
- data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
- data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
- data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
- data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
- data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
- data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
- data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
- data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
- data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
- data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
- data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
- data/lib/evilution/mutator/operator/send_mutation.rb +2 -0
- data/lib/evilution/mutator/operator/splat_operator.rb +38 -13
- data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
- data/lib/evilution/mutator/registry.rb +31 -2
- data/lib/evilution/mutator/rescue_handlers.rb +81 -0
- data/lib/evilution/process_supervisor.rb +3 -2
- data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
- data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
- data/lib/evilution/reporter/json/baseline.rb +15 -0
- data/lib/evilution/reporter/json.rb +7 -2
- data/lib/evilution/result/baseline_neutralization.rb +10 -0
- data/lib/evilution/result/mutation_result.rb +9 -1
- data/lib/evilution/result/summary.rb +42 -2
- data/lib/evilution/runner/baseline_runner.rb +9 -4
- data/lib/evilution/runner/canary.rb +2 -57
- data/lib/evilution/runner/canary_failure_message.rb +101 -0
- data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +22 -8
- data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
- data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
- data/lib/evilution/runner/mutation_executor.rb +1 -1
- data/lib/evilution/runner/mutation_planner.rb +11 -2
- data/lib/evilution/runner/report_publisher.rb +3 -2
- data/lib/evilution/runner/subject_pipeline.rb +46 -1
- data/lib/evilution/runner.rb +4 -1
- data/lib/evilution/subject.rb +8 -2
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +28 -0
- data/script/memory_check +62 -16
- 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
|
data/lib/evilution/baseline.rb
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
warned(SpecFailure.new(spec_file: spec_file, error: "#{e.class}: #{e.message}"))
|
|
62
70
|
ensure
|
|
63
|
-
read_io
|
|
64
|
-
write_io
|
|
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
|
-
|
|
75
|
-
Marshal.dump(
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
53
|
+
Evilution::Diagnostic.warn("[evilution] spec_mappings[#{source.inspect}]: #{spec_path} not found, skipping")
|
|
53
54
|
end
|
|
54
55
|
end
|
|
55
56
|
end
|
data/lib/evilution/config.rb
CHANGED
|
@@ -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
|
|
220
|
-
# `
|
|
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
|