evilution 0.24.0 → 0.25.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 +205 -0
- data/CHANGELOG.md +35 -0
- data/README.md +80 -4
- data/exe/evil +6 -0
- data/lib/evilution/ast/source_surgeon.rb +15 -1
- data/lib/evilution/cli/commands/compare.rb +68 -0
- data/lib/evilution/cli/parser/command_extractor.rb +2 -1
- data/lib/evilution/cli/parser/options_builder.rb +21 -1
- data/lib/evilution/cli/printers/compare.rb +159 -0
- data/lib/evilution/cli.rb +1 -0
- data/lib/evilution/compare/categorizer.rb +109 -0
- data/lib/evilution/compare/detector.rb +21 -0
- data/lib/evilution/compare/fingerprint.rb +83 -0
- data/lib/evilution/compare/normalizer.rb +106 -0
- data/lib/evilution/compare/record.rb +16 -0
- data/lib/evilution/compare.rb +15 -0
- data/lib/evilution/config.rb +165 -3
- data/lib/evilution/example_filter.rb +143 -0
- data/lib/evilution/integration/crash_detector.rb +5 -2
- data/lib/evilution/integration/minitest.rb +10 -5
- data/lib/evilution/integration/minitest_crash_detector.rb +5 -2
- data/lib/evilution/integration/rspec.rb +82 -7
- data/lib/evilution/isolation/fork.rb +25 -0
- data/lib/evilution/mcp/info_tool.rb +77 -5
- data/lib/evilution/mcp/mutate_tool/config_builder.rb +20 -0
- data/lib/evilution/mcp/mutate_tool/error_payload.rb +17 -0
- data/lib/evilution/mcp/mutate_tool/option_parser.rb +54 -0
- data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +37 -0
- data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +31 -0
- data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +52 -0
- data/lib/evilution/mcp/mutate_tool.rb +34 -186
- data/lib/evilution/mutation.rb +43 -3
- data/lib/evilution/mutator/base.rb +39 -1
- data/lib/evilution/mutator/operator/argument_nil_substitution.rb +5 -1
- data/lib/evilution/mutator/operator/argument_removal.rb +5 -1
- data/lib/evilution/parallel/work_queue.rb +149 -31
- data/lib/evilution/parallel_db_warning.rb +68 -0
- data/lib/evilution/reporter/cli.rb +37 -11
- data/lib/evilution/reporter/html/assets/style.css +17 -0
- data/lib/evilution/reporter/html/sections/file_section.rb +15 -0
- data/lib/evilution/reporter/html/sections/neutral_details.rb +25 -0
- data/lib/evilution/reporter/html/sections/unparseable_details.rb +25 -0
- data/lib/evilution/reporter/html/sections/unresolved_details.rb +25 -0
- data/lib/evilution/reporter/html/templates/file_section.html.erb +3 -0
- data/lib/evilution/reporter/html/templates/neutral_details.html.erb +14 -0
- data/lib/evilution/reporter/html/templates/summary_cards.html.erb +3 -0
- data/lib/evilution/reporter/html/templates/unparseable_details.html.erb +11 -0
- data/lib/evilution/reporter/html/templates/unresolved_details.html.erb +11 -0
- data/lib/evilution/reporter/json.rb +8 -2
- data/lib/evilution/reporter/suggestion/diff_helpers.rb +28 -0
- data/lib/evilution/reporter/suggestion/registry.rb +64 -0
- data/lib/evilution/reporter/suggestion/templates/generic.rb +55 -0
- data/lib/evilution/reporter/suggestion/templates/minitest.rb +659 -0
- data/lib/evilution/reporter/suggestion/templates/rspec.rb +613 -0
- data/lib/evilution/reporter/suggestion.rb +8 -1327
- data/lib/evilution/result/mutation_result.rb +5 -1
- data/lib/evilution/result/summary.rb +13 -1
- data/lib/evilution/runner/baseline_runner.rb +23 -2
- data/lib/evilution/runner/mutation_executor.rb +83 -13
- data/lib/evilution/runner.rb +6 -0
- data/lib/evilution/source_ast_cache.rb +39 -0
- data/lib/evilution/spec_ast_cache.rb +166 -0
- data/lib/evilution/spec_resolver.rb +6 -1
- data/lib/evilution/spec_selector.rb +39 -0
- data/lib/evilution/temp_dir_tracker.rb +23 -3
- data/lib/evilution/version.rb +1 -1
- data/script/memory_check +7 -5
- metadata +34 -2
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
3
5
|
require_relative "../ast"
|
|
4
6
|
|
|
5
7
|
module Evilution::AST::SourceSurgeon
|
|
8
|
+
Result = Struct.new(:source, :status, keyword_init: true) do
|
|
9
|
+
def ok?
|
|
10
|
+
status == :ok
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def unparseable?
|
|
14
|
+
status == :unparseable
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
6
18
|
def self.apply(source, offset:, length:, replacement:)
|
|
7
19
|
binary = source.b
|
|
8
20
|
binary[offset, length] = replacement.b
|
|
9
|
-
binary.force_encoding(source.encoding)
|
|
21
|
+
mutated = binary.force_encoding(source.encoding)
|
|
22
|
+
status = Prism.parse(mutated).success? ? :ok : :unparseable
|
|
23
|
+
Result.new(source: mutated, status: status).freeze
|
|
10
24
|
end
|
|
11
25
|
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../commands"
|
|
5
|
+
require_relative "../command"
|
|
6
|
+
require_relative "../dispatcher"
|
|
7
|
+
require_relative "../printers/compare"
|
|
8
|
+
require_relative "../../compare"
|
|
9
|
+
require_relative "../../compare/categorizer"
|
|
10
|
+
require_relative "../../compare/detector"
|
|
11
|
+
require_relative "../../compare/normalizer"
|
|
12
|
+
|
|
13
|
+
class Evilution::CLI::Commands::Compare < Evilution::CLI::Command
|
|
14
|
+
SUPPORTED_FORMATS = %i[json text].freeze
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def perform
|
|
19
|
+
paths = resolve_paths
|
|
20
|
+
raise Evilution::ConfigError, "exactly two file paths required for compare" unless paths.length == 2
|
|
21
|
+
|
|
22
|
+
fmt = @options[:format] || :json
|
|
23
|
+
raise Evilution::ConfigError, "compare supports --format text or json, got #{fmt.inspect}" unless SUPPORTED_FORMATS.include?(fmt)
|
|
24
|
+
|
|
25
|
+
against = load_and_normalize(paths[0])
|
|
26
|
+
current = load_and_normalize(paths[1])
|
|
27
|
+
buckets = Evilution::Compare::Categorizer.call(against, current)
|
|
28
|
+
Evilution::CLI::Printers::Compare.new(buckets, format: fmt).render(@stdout)
|
|
29
|
+
0
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Flags bind to roles (--against -> slot 0, --current -> slot 1);
|
|
33
|
+
# positional @files fill whatever role the flags didn't claim, in order.
|
|
34
|
+
# Extra positional args after both slots are filled are a user error.
|
|
35
|
+
def resolve_paths
|
|
36
|
+
positional = @files.dup
|
|
37
|
+
against = @options[:against] || positional.shift
|
|
38
|
+
current = @options[:current] || positional.shift
|
|
39
|
+
|
|
40
|
+
raise Evilution::ConfigError, "exactly two file paths required for compare" unless positional.empty?
|
|
41
|
+
|
|
42
|
+
[against, current].compact
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def load_and_normalize(path)
|
|
46
|
+
raise Evilution::Error, "file not found: #{path}" unless File.exist?(path)
|
|
47
|
+
|
|
48
|
+
json = JSON.parse(File.read(path))
|
|
49
|
+
tool = Evilution::Compare::Detector.call(json)
|
|
50
|
+
normalize(json, tool)
|
|
51
|
+
rescue ::JSON::ParserError => e
|
|
52
|
+
raise Evilution::Error, "invalid JSON in #{path}: #{e.message}"
|
|
53
|
+
rescue Evilution::Compare::InvalidInput => e
|
|
54
|
+
raise Evilution::Error, "#{path}: #{e.message}"
|
|
55
|
+
rescue SystemCallError => e
|
|
56
|
+
raise Evilution::Error, e.message
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def normalize(json, tool)
|
|
60
|
+
normalizer = Evilution::Compare::Normalizer.new
|
|
61
|
+
case tool
|
|
62
|
+
when :mutant then normalizer.from_mutant(json)
|
|
63
|
+
when :evilution then normalizer.from_evilution(json)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
Evilution::CLI::Dispatcher.register(:compare, Evilution::CLI::Commands::Compare)
|
|
@@ -23,6 +23,7 @@ class Evilution::CLI::Parser::OptionsBuilder
|
|
|
23
23
|
add_flag_options(opts)
|
|
24
24
|
add_extra_flag_options(opts)
|
|
25
25
|
add_session_options(opts)
|
|
26
|
+
add_compare_options(opts)
|
|
26
27
|
end
|
|
27
28
|
end
|
|
28
29
|
|
|
@@ -33,7 +34,7 @@ class Evilution::CLI::Parser::OptionsBuilder
|
|
|
33
34
|
opts.separator "Line-range targeting: lib/foo.rb:15-30, lib/foo.rb:15, lib/foo.rb:15-"
|
|
34
35
|
opts.separator ""
|
|
35
36
|
opts.separator "Commands: run (default), init, session {list,show,diff,gc}, subjects, tests {list},"
|
|
36
|
-
opts.separator " util {mutation}, environment {show}, mcp, version"
|
|
37
|
+
opts.separator " util {mutation}, environment {show}, compare, mcp, version"
|
|
37
38
|
opts.separator ""
|
|
38
39
|
opts.separator "Options:"
|
|
39
40
|
end
|
|
@@ -48,6 +49,16 @@ class Evilution::CLI::Parser::OptionsBuilder
|
|
|
48
49
|
opts.on("--min-score FLOAT", Float, "Minimum mutation score to pass") { |s| @options[:min_score] = s }
|
|
49
50
|
opts.on("--spec FILES", Array, "Spec files to run (comma-separated)") { |f| @options[:spec_files] = f }
|
|
50
51
|
opts.on("--spec-dir DIR", "Include all specs in DIR") { |d| expand_spec_dir(d) }
|
|
52
|
+
opts.on("--spec-pattern GLOB",
|
|
53
|
+
"Restrict resolved spec candidates to files matching GLOB") { |p| @options[:spec_pattern] = p }
|
|
54
|
+
opts.on("--no-example-targeting",
|
|
55
|
+
"Disable per-mutation example targeting (run all examples in resolved spec files)") do
|
|
56
|
+
@options[:example_targeting] = false
|
|
57
|
+
end
|
|
58
|
+
opts.on("--example-targeting-fallback MODE", %w[full_file unresolved],
|
|
59
|
+
"Fallback when example targeting finds no match: full_file (default) or unresolved") do |m|
|
|
60
|
+
@options[:example_targeting_fallback] = m
|
|
61
|
+
end
|
|
51
62
|
opts.on("--target EXPR",
|
|
52
63
|
"Filter: method (Foo#bar), type (Foo#/Foo.), namespace (Foo*),",
|
|
53
64
|
"class (Foo), glob (source:**/*.rb), hierarchy (descendants:Foo)") do |m|
|
|
@@ -96,6 +107,15 @@ class Evilution::CLI::Parser::OptionsBuilder
|
|
|
96
107
|
end
|
|
97
108
|
end
|
|
98
109
|
|
|
110
|
+
def add_compare_options(opts)
|
|
111
|
+
opts.on("--against PATH", "Prior mutation run to compare against (used with `compare` command)") do |p|
|
|
112
|
+
@options[:against] = p
|
|
113
|
+
end
|
|
114
|
+
opts.on("--current PATH", "Current mutation run to compare (used with `compare` command)") do |p|
|
|
115
|
+
@options[:current] = p
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
99
119
|
def expand_spec_dir(dir)
|
|
100
120
|
specs = Evilution::CLI::Parser::FileArgs.expand_spec_dir(dir)
|
|
101
121
|
@options[:spec_files] = Array(@options[:spec_files]) + specs
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../printers"
|
|
5
|
+
|
|
6
|
+
class Evilution::CLI::Printers::Compare
|
|
7
|
+
SCHEMA = {
|
|
8
|
+
"shared" => %w[file line operator fp],
|
|
9
|
+
"alive_only" => %w[file line operator fp other_status]
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
FILE_LINE_WIDTH = 40
|
|
13
|
+
OPERATOR_WIDTH = 22
|
|
14
|
+
FP_LENGTH = 7
|
|
15
|
+
MUTANT_OPERATOR = "(mutant)"
|
|
16
|
+
ABSENT_STATUS = "absent"
|
|
17
|
+
|
|
18
|
+
def initialize(buckets, format: :json)
|
|
19
|
+
@buckets = buckets
|
|
20
|
+
@format = format || :json
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def render(io)
|
|
24
|
+
case @format
|
|
25
|
+
when :json then render_json(io)
|
|
26
|
+
when :text then render_text(io)
|
|
27
|
+
else raise Evilution::Error, "unknown compare format: #{@format.inspect}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def render_json(io)
|
|
34
|
+
payload = {
|
|
35
|
+
"schema" => SCHEMA,
|
|
36
|
+
"summary" => summary_hash,
|
|
37
|
+
"alive_only_against" => @buckets[:alive_only_against].map { |e| alive_entry_array(e) },
|
|
38
|
+
"alive_only_current" => @buckets[:alive_only_current].map { |e| alive_entry_array(e) },
|
|
39
|
+
"shared_alive" => @buckets[:shared_alive].map { |e| shared_entry_array(e) },
|
|
40
|
+
"shared_dead" => @buckets[:shared_dead].map { |e| shared_entry_array(e) }
|
|
41
|
+
}
|
|
42
|
+
io.puts(JSON.generate(payload))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def render_text(io)
|
|
46
|
+
io.puts("Compare results")
|
|
47
|
+
io.puts("-" * 15)
|
|
48
|
+
io.puts(summary_line)
|
|
49
|
+
|
|
50
|
+
if fully_empty?
|
|
51
|
+
io.puts("No mutations to compare.")
|
|
52
|
+
return
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
print_alive_block(io, :alive_only_against, "current")
|
|
56
|
+
print_alive_block(io, :alive_only_current, "against")
|
|
57
|
+
print_shared_block(io, :shared_alive)
|
|
58
|
+
print_shared_block(io, :shared_dead)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def summary_hash
|
|
62
|
+
against_count = @buckets[:alive_only_against].length
|
|
63
|
+
current_count = @buckets[:alive_only_current].length
|
|
64
|
+
{
|
|
65
|
+
"alive_only_against" => against_count,
|
|
66
|
+
"alive_only_current" => current_count,
|
|
67
|
+
"shared_alive" => @buckets[:shared_alive].length,
|
|
68
|
+
"shared_dead" => @buckets[:shared_dead].length,
|
|
69
|
+
"excluded_against" => @buckets[:excluded_against],
|
|
70
|
+
"excluded_current" => @buckets[:excluded_current],
|
|
71
|
+
"delta" => current_count - against_count
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def summary_line
|
|
76
|
+
s = summary_hash
|
|
77
|
+
parts = [
|
|
78
|
+
"summary:",
|
|
79
|
+
"alive_only_against=#{s["alive_only_against"]}",
|
|
80
|
+
"alive_only_current=#{s["alive_only_current"]}",
|
|
81
|
+
"shared_alive=#{s["shared_alive"]}",
|
|
82
|
+
"shared_dead=#{s["shared_dead"]}",
|
|
83
|
+
"excluded=#{s["excluded_against"]}/#{s["excluded_current"]}",
|
|
84
|
+
"delta=#{format_delta(s["delta"])}"
|
|
85
|
+
]
|
|
86
|
+
parts.join(" ")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def format_delta(delta)
|
|
90
|
+
return "\u00B10" if delta.zero?
|
|
91
|
+
|
|
92
|
+
format("%+d", delta)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def fully_empty?
|
|
96
|
+
@buckets[:alive_only_against].empty? &&
|
|
97
|
+
@buckets[:alive_only_current].empty? &&
|
|
98
|
+
@buckets[:shared_alive].empty? &&
|
|
99
|
+
@buckets[:shared_dead].empty? &&
|
|
100
|
+
@buckets[:excluded_against].zero? &&
|
|
101
|
+
@buckets[:excluded_current].zero?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def alive_entry_array(entry)
|
|
105
|
+
r = entry[:record]
|
|
106
|
+
peer = entry[:peer_status]
|
|
107
|
+
peer_str = peer.nil? ? ABSENT_STATUS : peer.to_s
|
|
108
|
+
[r.file_path, r.line, r.operator, r.fingerprint, peer_str]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def shared_entry_array(entry)
|
|
112
|
+
r = entry[:against]
|
|
113
|
+
[r.file_path, r.line, shared_operator(entry), r.fingerprint]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Mutant-sourced records always have operator=nil. When comparing mutant
|
|
117
|
+
# vs evilution, prefer whichever side has an operator so the shared row
|
|
118
|
+
# stays informative.
|
|
119
|
+
def shared_operator(entry)
|
|
120
|
+
entry[:against].operator || entry[:current].operator
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def print_alive_block(io, bucket_key, peer_side_label)
|
|
124
|
+
entries = @buckets[bucket_key]
|
|
125
|
+
return if entries.empty?
|
|
126
|
+
|
|
127
|
+
io.puts("")
|
|
128
|
+
io.puts("#{bucket_key} (#{entries.length}):")
|
|
129
|
+
entries.each { |entry| io.puts(format_alive_row(entry, peer_side_label)) }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def print_shared_block(io, bucket_key)
|
|
133
|
+
entries = @buckets[bucket_key]
|
|
134
|
+
return if entries.empty?
|
|
135
|
+
|
|
136
|
+
io.puts("")
|
|
137
|
+
io.puts("#{bucket_key} (#{entries.length}):")
|
|
138
|
+
entries.each { |entry| io.puts(format_shared_row(entry)) }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def format_alive_row(entry, peer_side_label)
|
|
142
|
+
r = entry[:record]
|
|
143
|
+
peer = entry[:peer_status]
|
|
144
|
+
peer_str = peer.nil? ? ABSENT_STATUS : peer.to_s
|
|
145
|
+
" #{row_prefix(r)} (#{peer_side_label}: #{peer_str})"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def format_shared_row(entry)
|
|
149
|
+
r = entry[:against]
|
|
150
|
+
" #{row_prefix(r, operator: shared_operator(entry))}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def row_prefix(record, operator: record.operator)
|
|
154
|
+
file_line = "#{record.file_path}:#{record.line}"
|
|
155
|
+
op_label = operator || MUTANT_OPERATOR
|
|
156
|
+
fp = record.fingerprint.to_s[0, FP_LENGTH]
|
|
157
|
+
"#{file_line.ljust(FILE_LINE_WIDTH)}#{op_label.ljust(OPERATOR_WIDTH)}#{fp}"
|
|
158
|
+
end
|
|
159
|
+
end
|
data/lib/evilution/cli.rb
CHANGED
|
@@ -16,6 +16,7 @@ require_relative "cli/commands/session_list"
|
|
|
16
16
|
require_relative "cli/commands/session_show"
|
|
17
17
|
require_relative "cli/commands/session_diff"
|
|
18
18
|
require_relative "cli/commands/session_gc"
|
|
19
|
+
require_relative "cli/commands/compare"
|
|
19
20
|
require_relative "cli/commands/run"
|
|
20
21
|
|
|
21
22
|
class Evilution::CLI
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../compare"
|
|
4
|
+
require_relative "record"
|
|
5
|
+
|
|
6
|
+
module Evilution::Compare::Categorizer
|
|
7
|
+
ALIVE = %i[survived].freeze
|
|
8
|
+
DEAD = %i[killed timeout error].freeze
|
|
9
|
+
# neutral, equivalent, unresolved, unparseable are non-actionable signals
|
|
10
|
+
# — excluded from alive/dead buckets, counted in summary.
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# @param against [Array<Record>] prior run (baseline)
|
|
15
|
+
# @param current [Array<Record>] current run
|
|
16
|
+
# @return [Hash] bucketed comparison result with keys:
|
|
17
|
+
# - `:alive_only_against` => `Array<{record: Record, peer_status: Symbol|nil}>`
|
|
18
|
+
# records that survived in against but not in current (or absent in current).
|
|
19
|
+
# `peer_status` is the current-side record's status symbol, or `nil` when
|
|
20
|
+
# no current-side record exists for that fingerprint.
|
|
21
|
+
# - `:alive_only_current` => `Array<{record: Record, peer_status: Symbol|nil}>`
|
|
22
|
+
# mirror of the above from the current side.
|
|
23
|
+
# - `:shared_alive` => `Array<{against: Record, current: Record}>`
|
|
24
|
+
# mutations that survived in both runs.
|
|
25
|
+
# - `:shared_dead` => `Array<{against: Record, current: Record}>`
|
|
26
|
+
# mutations killed/timed-out/errored in both runs.
|
|
27
|
+
# - `:excluded_against` => `Integer`
|
|
28
|
+
# count of against records with non-actionable statuses (neutral,
|
|
29
|
+
# equivalent, unresolved, unparseable).
|
|
30
|
+
# - `:excluded_current` => `Integer` mirror for the current side.
|
|
31
|
+
def call(against, current)
|
|
32
|
+
# Duplicate fingerprints within one side should not happen (Normalizer
|
|
33
|
+
# invariant). If they do, last write wins — we do not dedupe proactively.
|
|
34
|
+
against_by_fp = index_by_fingerprint(against)
|
|
35
|
+
current_by_fp = index_by_fingerprint(current)
|
|
36
|
+
|
|
37
|
+
buckets = {
|
|
38
|
+
alive_only_against: [],
|
|
39
|
+
alive_only_current: [],
|
|
40
|
+
shared_alive: [],
|
|
41
|
+
shared_dead: [],
|
|
42
|
+
excluded_against: 0,
|
|
43
|
+
excluded_current: 0
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
(against_by_fp.keys | current_by_fp.keys).each do |fp|
|
|
47
|
+
classify(against_by_fp[fp], current_by_fp[fp], buckets)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
sort_buckets!(buckets)
|
|
51
|
+
buckets
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Dispatches one fingerprint pair into buckets.
|
|
55
|
+
# Either record may be nil (fingerprint present on only one side).
|
|
56
|
+
def classify(against_record, current_record, buckets)
|
|
57
|
+
count_excluded(against_record, current_record, buckets)
|
|
58
|
+
a_kind = kind_of(against_record)
|
|
59
|
+
c_kind = kind_of(current_record)
|
|
60
|
+
|
|
61
|
+
if a_kind == :alive && c_kind == :alive
|
|
62
|
+
buckets[:shared_alive] << { against: against_record, current: current_record }
|
|
63
|
+
elsif a_kind == :dead && c_kind == :dead
|
|
64
|
+
buckets[:shared_dead] << { against: against_record, current: current_record }
|
|
65
|
+
else
|
|
66
|
+
bucket_single_sided(against_record, current_record, a_kind, c_kind, buckets)
|
|
67
|
+
end
|
|
68
|
+
# A dead-only fingerprint (dead on one side, absent on the other) is
|
|
69
|
+
# intentionally not bucketed and not counted as excluded.
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def count_excluded(against_record, current_record, buckets)
|
|
73
|
+
buckets[:excluded_against] += 1 if against_record && kind_of(against_record) == :excluded
|
|
74
|
+
buckets[:excluded_current] += 1 if current_record && kind_of(current_record) == :excluded
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def bucket_single_sided(against_record, current_record, a_kind, c_kind, buckets)
|
|
78
|
+
# peer_status is the peer record's status symbol, or nil if peer absent.
|
|
79
|
+
# When the peer is excluded, its status symbol (e.g. :neutral) flows through.
|
|
80
|
+
a_peer = current_record && current_record.status
|
|
81
|
+
c_peer = against_record && against_record.status
|
|
82
|
+
buckets[:alive_only_against] << { record: against_record, peer_status: a_peer } if a_kind == :alive
|
|
83
|
+
buckets[:alive_only_current] << { record: current_record, peer_status: c_peer } if c_kind == :alive
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Returns :alive, :dead, :excluded, or nil (for nil records).
|
|
87
|
+
def kind_of(record)
|
|
88
|
+
return nil if record.nil?
|
|
89
|
+
return :alive if ALIVE.include?(record.status)
|
|
90
|
+
return :dead if DEAD.include?(record.status)
|
|
91
|
+
|
|
92
|
+
:excluded
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def sort_buckets!(buckets)
|
|
96
|
+
buckets[:alive_only_against].sort_by! { |e| sort_key(e[:record]) }
|
|
97
|
+
buckets[:alive_only_current].sort_by! { |e| sort_key(e[:record]) }
|
|
98
|
+
buckets[:shared_alive].sort_by! { |e| sort_key(e[:against]) }
|
|
99
|
+
buckets[:shared_dead].sort_by! { |e| sort_key(e[:against]) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def sort_key(record)
|
|
103
|
+
[record.file_path, record.line, record.fingerprint]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def index_by_fingerprint(records)
|
|
107
|
+
records.to_h { |r| [r.fingerprint, r] }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../compare"
|
|
4
|
+
require_relative "normalizer"
|
|
5
|
+
|
|
6
|
+
module Evilution::Compare::Detector
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def call(json)
|
|
10
|
+
raise Evilution::Compare::InvalidInput, "expected Hash, got #{json.class}" unless json.is_a?(Hash)
|
|
11
|
+
|
|
12
|
+
mutant = json.key?("subject_results")
|
|
13
|
+
evilution = json.key?("summary") && Evilution::Compare::Normalizer::EVILUTION_BUCKETS.any? { |k| json.key?(k) }
|
|
14
|
+
|
|
15
|
+
raise Evilution::Compare::InvalidInput, "ambiguous JSON shape - both mutant and evilution markers present" if mutant && evilution
|
|
16
|
+
return :mutant if mutant
|
|
17
|
+
return :evilution if evilution
|
|
18
|
+
|
|
19
|
+
raise Evilution::Compare::InvalidInput, "cannot detect tool from JSON shape"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "../compare"
|
|
5
|
+
|
|
6
|
+
module Evilution::Compare::Fingerprint
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def extract_from_evilution_diff(diff)
|
|
10
|
+
minus = []
|
|
11
|
+
plus = []
|
|
12
|
+
diff.to_s.each_line do |line|
|
|
13
|
+
line = line.chomp
|
|
14
|
+
if line.start_with?("- ")
|
|
15
|
+
minus << line[2..]
|
|
16
|
+
elsif line.start_with?("+ ")
|
|
17
|
+
plus << line[2..]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
{ minus: minus, plus: plus }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def extract_from_mutant_diff(diff)
|
|
24
|
+
minus = []
|
|
25
|
+
plus = []
|
|
26
|
+
diff.to_s.each_line do |line|
|
|
27
|
+
line = line.chomp
|
|
28
|
+
next if line.start_with?("---", "+++", "@@")
|
|
29
|
+
|
|
30
|
+
if line.start_with?("-")
|
|
31
|
+
minus << line[1..]
|
|
32
|
+
elsif line.start_with?("+")
|
|
33
|
+
plus << line[1..]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
{ minus: minus, plus: plus }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# v1 limitation: only " and ' literals are preserved. Regex literals (/.../),
|
|
40
|
+
# heredocs, %w[], %q{} forms are treated as ordinary code — whitespace runs
|
|
41
|
+
# inside them collapse. A mutation touching whitespace inside a regex may
|
|
42
|
+
# false-match across tools.
|
|
43
|
+
# rubocop:disable Metrics/PerceivedComplexity, Style/MultipleComparison
|
|
44
|
+
def normalize_line(line)
|
|
45
|
+
out = +""
|
|
46
|
+
i = 0
|
|
47
|
+
in_literal = nil
|
|
48
|
+
last_was_space = false
|
|
49
|
+
chars = line.chars
|
|
50
|
+
while i < chars.length
|
|
51
|
+
ch = chars[i]
|
|
52
|
+
if in_literal
|
|
53
|
+
out << ch
|
|
54
|
+
if ch == "\\" && i + 1 < chars.length
|
|
55
|
+
out << chars[i + 1]
|
|
56
|
+
i += 2
|
|
57
|
+
next
|
|
58
|
+
end
|
|
59
|
+
in_literal = nil if ch == in_literal
|
|
60
|
+
elsif ch == '"' || ch == "'"
|
|
61
|
+
in_literal = ch
|
|
62
|
+
out << ch
|
|
63
|
+
last_was_space = false
|
|
64
|
+
elsif ch == " " || ch == "\t"
|
|
65
|
+
out << " " unless last_was_space || out.empty?
|
|
66
|
+
last_was_space = true
|
|
67
|
+
else
|
|
68
|
+
out << ch
|
|
69
|
+
last_was_space = false
|
|
70
|
+
end
|
|
71
|
+
i += 1
|
|
72
|
+
end
|
|
73
|
+
out.rstrip
|
|
74
|
+
end
|
|
75
|
+
# rubocop:enable Metrics/PerceivedComplexity, Style/MultipleComparison
|
|
76
|
+
|
|
77
|
+
def compute(file_path:, line:, body:)
|
|
78
|
+
minus = body[:minus].map { |l| normalize_line(l) }
|
|
79
|
+
plus = body[:plus].map { |l| normalize_line(l) }
|
|
80
|
+
payload = [file_path, line.to_s, minus.join("\n"), plus.join("\n")].join("\x00")
|
|
81
|
+
Digest::SHA256.hexdigest(payload)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../compare"
|
|
4
|
+
require_relative "record"
|
|
5
|
+
require_relative "fingerprint"
|
|
6
|
+
|
|
7
|
+
class Evilution::Compare::Normalizer
|
|
8
|
+
EVILUTION_BUCKETS = %w[killed survived timed_out errors neutral equivalent unresolved unparseable].freeze
|
|
9
|
+
EVILUTION_STATUS_MAP = {
|
|
10
|
+
"killed" => :killed,
|
|
11
|
+
"survived" => :survived,
|
|
12
|
+
"timeout" => :timeout,
|
|
13
|
+
"error" => :error,
|
|
14
|
+
"neutral" => :neutral,
|
|
15
|
+
"equivalent" => :equivalent,
|
|
16
|
+
"unresolved" => :unresolved,
|
|
17
|
+
"unparseable" => :unparseable
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def from_evilution(json)
|
|
21
|
+
records = []
|
|
22
|
+
EVILUTION_BUCKETS.each do |bucket|
|
|
23
|
+
Array(json[bucket]).each do |entry|
|
|
24
|
+
records << build_evilution_record(entry, index: records.size)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
records
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def from_mutant(json)
|
|
31
|
+
records = []
|
|
32
|
+
Array(json["subject_results"]).each do |subject|
|
|
33
|
+
source_path = subject["source_path"] or
|
|
34
|
+
raise Evilution::Compare::InvalidInput.new("missing 'source_path' on subject", index: records.size)
|
|
35
|
+
Array(subject["coverage_results"]).each do |cov|
|
|
36
|
+
records << build_mutant_record(cov, source_path: source_path, index: records.size)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
records
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def build_evilution_record(entry, index:)
|
|
45
|
+
file_path = entry["file"] or raise Evilution::Compare::InvalidInput.new("missing 'file' in record", index: index)
|
|
46
|
+
line = entry["line"] or raise Evilution::Compare::InvalidInput.new("missing 'line' in record", index: index)
|
|
47
|
+
diff = entry["diff"].to_s
|
|
48
|
+
status = EVILUTION_STATUS_MAP[entry["status"]] ||
|
|
49
|
+
raise(Evilution::Compare::InvalidInput.new("unknown status #{entry["status"].inspect}", index: index))
|
|
50
|
+
body = Evilution::Compare::Fingerprint.extract_from_evilution_diff(diff)
|
|
51
|
+
Evilution::Compare::Record.new(
|
|
52
|
+
source: :evilution,
|
|
53
|
+
file_path: file_path,
|
|
54
|
+
line: line,
|
|
55
|
+
status: status,
|
|
56
|
+
fingerprint: Evilution::Compare::Fingerprint.compute(file_path: file_path, line: line, body: body),
|
|
57
|
+
operator: entry["operator"],
|
|
58
|
+
diff_body: diff,
|
|
59
|
+
raw: entry
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def build_mutant_record(cov, source_path:, index:)
|
|
64
|
+
mr = cov["mutation_result"] or raise Evilution::Compare::InvalidInput.new("missing mutation_result", index: index)
|
|
65
|
+
cr = cov["criteria_result"] or raise Evilution::Compare::InvalidInput.new("missing criteria_result", index: index)
|
|
66
|
+
ident = mr["mutation_identification"].to_s
|
|
67
|
+
line = parse_mutant_line(ident, index)
|
|
68
|
+
diff = mr["mutation_diff"].to_s
|
|
69
|
+
status = derive_mutant_status(mr, cr, index)
|
|
70
|
+
body = Evilution::Compare::Fingerprint.extract_from_mutant_diff(diff)
|
|
71
|
+
Evilution::Compare::Record.new(
|
|
72
|
+
source: :mutant,
|
|
73
|
+
file_path: source_path,
|
|
74
|
+
line: line,
|
|
75
|
+
status: status,
|
|
76
|
+
fingerprint: Evilution::Compare::Fingerprint.compute(file_path: source_path, line: line, body: body),
|
|
77
|
+
operator: nil,
|
|
78
|
+
diff_body: diff,
|
|
79
|
+
raw: { "mutation_result" => mr, "criteria_result" => cr, "source_path" => source_path }
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# mutant_identification format: <type>:<subject>:<path>:<line>:<sha1[0..4]>.
|
|
84
|
+
# Line is always the second-to-last colon-separated field. Works with paths
|
|
85
|
+
# containing colons (e.g. Windows drive letters) because we index from the
|
|
86
|
+
# right, but a malformed path-less identification will raise InvalidInput.
|
|
87
|
+
def parse_mutant_line(ident, index)
|
|
88
|
+
parts = ident.split(":")
|
|
89
|
+
raise Evilution::Compare::InvalidInput.new("cannot parse line from #{ident.inspect}", index: index) if parts.length < 5
|
|
90
|
+
|
|
91
|
+
Integer(parts[-2])
|
|
92
|
+
rescue ArgumentError
|
|
93
|
+
raise Evilution::Compare::InvalidInput.new("non-integer line in #{ident.inspect}", index: index)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def derive_mutant_status(mr, cr, index)
|
|
97
|
+
type = mr["mutation_type"]
|
|
98
|
+
return :neutral if %w[neutral noop].include?(type)
|
|
99
|
+
return :timeout if cr["timeout"]
|
|
100
|
+
return :error if cr["process_abort"]
|
|
101
|
+
return :killed if cr["test_result"]
|
|
102
|
+
return :survived if type == "evil"
|
|
103
|
+
|
|
104
|
+
raise Evilution::Compare::InvalidInput.new("unknown mutant result shape: type=#{type.inspect} cr=#{cr.inspect}", index: index)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Style/OneClassPerFile
|
|
4
|
+
module Evilution::Compare
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
class Evilution::Compare::InvalidInput < StandardError
|
|
8
|
+
attr_reader :index
|
|
9
|
+
|
|
10
|
+
def initialize(message, index: nil)
|
|
11
|
+
super(message)
|
|
12
|
+
@index = index
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
# rubocop:enable Style/OneClassPerFile
|