evilution 0.23.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 +210 -0
- data/CHANGELOG.md +51 -0
- data/README.md +81 -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 +78 -0
- data/lib/evilution/cli/parser/file_args.rb +41 -0
- data/lib/evilution/cli/parser/options_builder.rb +123 -0
- data/lib/evilution/cli/parser/stdin_reader.rb +28 -0
- data/lib/evilution/cli/parser.rb +27 -196
- 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 +178 -3
- data/lib/evilution/example_filter.rb +143 -0
- data/lib/evilution/integration/base.rb +11 -57
- data/lib/evilution/integration/crash_detector.rb +5 -2
- data/lib/evilution/integration/minitest.rb +25 -7
- data/lib/evilution/integration/minitest_crash_detector.rb +5 -2
- data/lib/evilution/integration/rspec.rb +99 -12
- data/lib/evilution/isolation/fork.rb +26 -0
- data/lib/evilution/isolation/in_process.rb +1 -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 +38 -11
- data/lib/evilution/reporter/html/assets/style.css +85 -0
- data/lib/evilution/reporter/html/baseline_keys.rb +28 -0
- data/lib/evilution/reporter/html/diff_formatter.rb +27 -0
- data/lib/evilution/reporter/html/escape.rb +12 -0
- data/lib/evilution/reporter/html/namespace.rb +11 -0
- data/lib/evilution/reporter/html/report.rb +68 -0
- data/lib/evilution/reporter/html/section.rb +21 -0
- data/lib/evilution/reporter/html/sections/baseline_comparison.rb +46 -0
- data/lib/evilution/reporter/html/sections/error_details.rb +30 -0
- data/lib/evilution/reporter/html/sections/error_entry.rb +22 -0
- data/lib/evilution/reporter/html/sections/file_section.rb +62 -0
- data/lib/evilution/reporter/html/sections/header.rb +29 -0
- data/lib/evilution/reporter/html/sections/mutation_map.rb +32 -0
- data/lib/evilution/reporter/html/sections/neutral_details.rb +25 -0
- data/lib/evilution/reporter/html/sections/summary_cards.rb +11 -0
- data/lib/evilution/reporter/html/sections/survived_details.rb +35 -0
- data/lib/evilution/reporter/html/sections/survived_entry.rb +36 -0
- data/lib/evilution/reporter/html/sections/truncation_notice.rb +17 -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/sections.rb +4 -0
- data/lib/evilution/reporter/html/stylesheet.rb +14 -0
- data/lib/evilution/reporter/html/templates/baseline_comparison.html.erb +8 -0
- data/lib/evilution/reporter/html/templates/error_details.html.erb +6 -0
- data/lib/evilution/reporter/html/templates/error_entry.html.erb +10 -0
- data/lib/evilution/reporter/html/templates/file_section.html.erb +12 -0
- data/lib/evilution/reporter/html/templates/header.html.erb +4 -0
- data/lib/evilution/reporter/html/templates/mutation_map.html.erb +6 -0
- data/lib/evilution/reporter/html/templates/neutral_details.html.erb +14 -0
- data/lib/evilution/reporter/html/templates/report.html.erb +17 -0
- data/lib/evilution/reporter/html/templates/summary_cards.html.erb +26 -0
- data/lib/evilution/reporter/html/templates/survived_details.html.erb +21 -0
- data/lib/evilution/reporter/html/templates/survived_entry.html.erb +8 -0
- data/lib/evilution/reporter/html/templates/truncation_notice.html.erb +1 -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/html.rb +11 -390
- data/lib/evilution/reporter/json.rb +19 -9
- 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 +9 -1
- data/lib/evilution/result/summary.rb +21 -1
- data/lib/evilution/runner/baseline_runner.rb +92 -0
- data/lib/evilution/runner/diagnostics.rb +105 -0
- data/lib/evilution/runner/isolation_resolver.rb +134 -0
- data/lib/evilution/runner/mutation_executor.rb +325 -0
- data/lib/evilution/runner/mutation_planner.rb +126 -0
- data/lib/evilution/runner/report_publisher.rb +60 -0
- data/lib/evilution/runner/subject_pipeline.rb +121 -0
- data/lib/evilution/runner.rb +61 -692
- 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 +75 -2
data/lib/evilution/cli/parser.rb
CHANGED
|
@@ -1,28 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "optparse"
|
|
4
|
-
require_relative "../version"
|
|
5
3
|
require_relative "parsed_args"
|
|
6
4
|
|
|
7
5
|
class Evilution::CLI::Parser
|
|
8
|
-
SIMPLE_COMMANDS = {
|
|
9
|
-
"version" => :version,
|
|
10
|
-
"init" => :init,
|
|
11
|
-
"mcp" => :mcp,
|
|
12
|
-
"subjects" => :subjects
|
|
13
|
-
}.freeze
|
|
14
|
-
|
|
15
|
-
SESSION_SUBCOMMANDS = {
|
|
16
|
-
"list" => :session_list,
|
|
17
|
-
"show" => :session_show,
|
|
18
|
-
"diff" => :session_diff,
|
|
19
|
-
"gc" => :session_gc
|
|
20
|
-
}.freeze
|
|
21
|
-
|
|
22
|
-
TESTS_SUBCOMMANDS = { "list" => :tests_list }.freeze
|
|
23
|
-
ENVIRONMENT_SUBCOMMANDS = { "show" => :environment_show }.freeze
|
|
24
|
-
UTIL_SUBCOMMANDS = { "mutation" => :util_mutation }.freeze
|
|
25
|
-
|
|
26
6
|
def initialize(argv, stdin: $stdin)
|
|
27
7
|
@argv = argv.dup
|
|
28
8
|
@stdin = stdin
|
|
@@ -39,76 +19,19 @@ class Evilution::CLI::Parser
|
|
|
39
19
|
return build_parsed_args if @command == :parse_error
|
|
40
20
|
|
|
41
21
|
preprocess_flags
|
|
42
|
-
remaining =
|
|
43
|
-
@files, @line_ranges =
|
|
22
|
+
remaining = OptionsBuilder.build(@options).parse!(@argv)
|
|
23
|
+
@files, @line_ranges = FileArgs.parse(remaining)
|
|
44
24
|
read_stdin_files if @options.delete(:stdin) && %i[run subjects].include?(@command)
|
|
45
25
|
build_parsed_args
|
|
46
26
|
end
|
|
47
27
|
|
|
48
28
|
private
|
|
49
29
|
|
|
50
|
-
def read_stdin_files
|
|
51
|
-
if @files.any?
|
|
52
|
-
@stdin_error = "--stdin cannot be combined with positional file arguments"
|
|
53
|
-
return
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
lines = []
|
|
57
|
-
@stdin.each_line do |line|
|
|
58
|
-
line = line.strip
|
|
59
|
-
lines << line unless line.empty?
|
|
60
|
-
end
|
|
61
|
-
stdin_files, stdin_ranges = parse_file_args(lines)
|
|
62
|
-
@files = stdin_files
|
|
63
|
-
@line_ranges = @line_ranges.merge(stdin_ranges)
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def build_parsed_args
|
|
67
|
-
Evilution::CLI::ParsedArgs.new(
|
|
68
|
-
command: @command,
|
|
69
|
-
options: @options,
|
|
70
|
-
files: @files,
|
|
71
|
-
line_ranges: @line_ranges,
|
|
72
|
-
stdin_error: @stdin_error,
|
|
73
|
-
parse_error: @parse_error
|
|
74
|
-
)
|
|
75
|
-
end
|
|
76
|
-
|
|
77
30
|
def extract_command
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
elsif first == "run"
|
|
83
|
-
@argv.shift
|
|
84
|
-
elsif first == "session"
|
|
85
|
-
@argv.shift
|
|
86
|
-
extract_subcommand(SESSION_SUBCOMMANDS, "session", "list, show, diff, gc")
|
|
87
|
-
elsif first == "tests"
|
|
88
|
-
@argv.shift
|
|
89
|
-
extract_subcommand(TESTS_SUBCOMMANDS, "tests", "list")
|
|
90
|
-
elsif first == "environment"
|
|
91
|
-
@argv.shift
|
|
92
|
-
extract_subcommand(ENVIRONMENT_SUBCOMMANDS, "environment", "show")
|
|
93
|
-
elsif first == "util"
|
|
94
|
-
@argv.shift
|
|
95
|
-
extract_subcommand(UTIL_SUBCOMMANDS, "util", "mutation")
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def extract_subcommand(table, family, available)
|
|
100
|
-
sub = @argv.first
|
|
101
|
-
if table.key?(sub)
|
|
102
|
-
@command = table[sub]
|
|
103
|
-
@argv.shift
|
|
104
|
-
elsif sub.nil?
|
|
105
|
-
@command = :parse_error
|
|
106
|
-
@parse_error = "Missing #{family} subcommand. Available subcommands: #{available}"
|
|
107
|
-
else
|
|
108
|
-
@command = :parse_error
|
|
109
|
-
@parse_error = "Unknown #{family} subcommand: #{sub}. Available subcommands: #{available}"
|
|
110
|
-
@argv.shift
|
|
111
|
-
end
|
|
31
|
+
result = CommandExtractor.call(@argv)
|
|
32
|
+
@command = result.command
|
|
33
|
+
@argv = result.remaining_argv
|
|
34
|
+
@parse_error = result.parse_error
|
|
112
35
|
end
|
|
113
36
|
|
|
114
37
|
def preprocess_flags
|
|
@@ -137,121 +60,29 @@ class Evilution::CLI::Parser
|
|
|
137
60
|
@argv = result
|
|
138
61
|
end
|
|
139
62
|
|
|
140
|
-
def
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
add_separators(opts)
|
|
145
|
-
add_options(opts)
|
|
146
|
-
end
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
def add_separators(opts)
|
|
150
|
-
opts.separator ""
|
|
151
|
-
opts.separator "Line-range targeting: lib/foo.rb:15-30, lib/foo.rb:15, lib/foo.rb:15-"
|
|
152
|
-
opts.separator ""
|
|
153
|
-
opts.separator "Commands: run (default), init, session {list,show,diff,gc}, subjects, tests {list},"
|
|
154
|
-
opts.separator " util {mutation}, environment {show}, mcp, version"
|
|
155
|
-
opts.separator ""
|
|
156
|
-
opts.separator "Options:"
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
def add_options(opts)
|
|
160
|
-
add_core_options(opts)
|
|
161
|
-
add_filter_options(opts)
|
|
162
|
-
add_flag_options(opts)
|
|
163
|
-
add_session_options(opts)
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
def add_core_options(opts)
|
|
167
|
-
opts.on("-j", "--jobs N", Integer, "Number of parallel workers (default: 1)") { |n| @options[:jobs] = n }
|
|
168
|
-
opts.on("-t", "--timeout N", Integer, "Per-mutation timeout in seconds") { |n| @options[:timeout] = n }
|
|
169
|
-
opts.on("-f", "--format FORMAT", "Output format: text, json, html") { |f| @options[:format] = f.to_sym }
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
def add_filter_options(opts)
|
|
173
|
-
opts.on("--min-score FLOAT", Float, "Minimum mutation score to pass") { |s| @options[:min_score] = s }
|
|
174
|
-
opts.on("--spec FILES", Array, "Spec files to run (comma-separated)") { |f| @options[:spec_files] = f }
|
|
175
|
-
opts.on("--spec-dir DIR", "Include all specs in DIR") { |d| expand_spec_dir(d) }
|
|
176
|
-
opts.on("--target EXPR",
|
|
177
|
-
"Filter: method (Foo#bar), type (Foo#/Foo.), namespace (Foo*),",
|
|
178
|
-
"class (Foo), glob (source:**/*.rb), hierarchy (descendants:Foo)") do |m|
|
|
179
|
-
@options[:target] = m
|
|
180
|
-
end
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
def add_flag_options(opts)
|
|
184
|
-
opts.on("--fail-fast", "Stop after N surviving mutants " \
|
|
185
|
-
"(default: disabled; if provided without N, uses 1; use --fail-fast=N)") { @options[:fail_fast] ||= 1 }
|
|
186
|
-
opts.on("--no-baseline", "Skip baseline test suite check") { @options[:baseline] = false }
|
|
187
|
-
opts.on("--incremental", "Cache killed/timeout results; skip re-running them on unchanged files") { @options[:incremental] = true }
|
|
188
|
-
opts.on("--integration NAME", "Test integration: rspec, minitest (default: rspec)") { |i| @options[:integration] = i }
|
|
189
|
-
opts.on("--isolation STRATEGY", "Isolation: auto, fork, in_process (default: auto)") { |s| @options[:isolation] = s }
|
|
190
|
-
opts.on("--preload FILE", "Preload FILE in the parent process before forking " \
|
|
191
|
-
"(default: auto-detect spec/rails_helper.rb for Rails projects)") { |f| @options[:preload] = f }
|
|
192
|
-
opts.on("--no-preload", "Disable parent-process preload even for Rails projects") { @options[:preload] = false }
|
|
193
|
-
opts.on("--stdin", "Read target file paths from stdin (one per line)") { @options[:stdin] = true }
|
|
194
|
-
opts.on("--suggest-tests", "Generate concrete test code in suggestions (RSpec or Minitest)") { @options[:suggest_tests] = true }
|
|
195
|
-
opts.on("--no-progress", "Disable progress bar") { @options[:progress] = false }
|
|
196
|
-
add_extra_flag_options(opts)
|
|
197
|
-
end
|
|
198
|
-
|
|
199
|
-
def add_extra_flag_options(opts)
|
|
200
|
-
opts.on("--skip-heredoc-literals", "Skip all string literal mutations inside heredocs") { @options[:skip_heredoc_literals] = true }
|
|
201
|
-
opts.on("--related-specs-heuristic", "Append related request/integration/feature/system specs for includes() mutations") do
|
|
202
|
-
@options[:related_specs_heuristic] = true
|
|
203
|
-
end
|
|
204
|
-
opts.on("--show-disabled", "Report mutations skipped by # evilution:disable") { @options[:show_disabled] = true }
|
|
205
|
-
opts.on("--baseline-session PATH", "Compare against a baseline session in HTML report") { |p| @options[:baseline_session] = p }
|
|
206
|
-
opts.on("--save-session", "Save session results to .evilution/results/") { @options[:save_session] = true }
|
|
207
|
-
opts.on("-e", "--eval CODE", "Evaluate code snippet (for util mutation)") { |c| @options[:eval] = c }
|
|
208
|
-
opts.on("-v", "--verbose", "Verbose output") { @options[:verbose] = true }
|
|
209
|
-
opts.on("-q", "--quiet", "Suppress output") { @options[:quiet] = true }
|
|
210
|
-
end
|
|
211
|
-
|
|
212
|
-
def add_session_options(opts)
|
|
213
|
-
opts.on("--results-dir DIR", "Session results directory") { |d| @options[:results_dir] = d }
|
|
214
|
-
opts.on("--limit N", Integer, "Show only the N most recent sessions") { |n| @options[:limit] = n }
|
|
215
|
-
opts.on("--since DATE", "Show sessions since DATE (YYYY-MM-DD)") { |d| @options[:since] = d }
|
|
216
|
-
opts.on("--older-than DURATION", "Delete sessions older than DURATION (e.g., 30d, 24h, 1w)") do |d|
|
|
217
|
-
@options[:older_than] = d
|
|
218
|
-
end
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
def parse_file_args(raw_args)
|
|
222
|
-
files = []
|
|
223
|
-
ranges = {}
|
|
224
|
-
|
|
225
|
-
raw_args.each do |arg|
|
|
226
|
-
file, range_str = arg.split(":", 2)
|
|
227
|
-
files << file
|
|
228
|
-
next unless range_str
|
|
229
|
-
|
|
230
|
-
ranges[file] = parse_line_range(range_str)
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
[files, ranges]
|
|
234
|
-
end
|
|
235
|
-
|
|
236
|
-
def parse_line_range(str)
|
|
237
|
-
if str.include?("-")
|
|
238
|
-
start_str, end_str = str.split("-", 2)
|
|
239
|
-
start_line = Integer(start_str)
|
|
240
|
-
end_line = end_str.empty? ? Float::INFINITY : Integer(end_str)
|
|
241
|
-
start_line..end_line
|
|
242
|
-
else
|
|
243
|
-
line = Integer(str)
|
|
244
|
-
line..line
|
|
245
|
-
end
|
|
246
|
-
end
|
|
247
|
-
|
|
248
|
-
def expand_spec_dir(dir)
|
|
249
|
-
unless File.directory?(dir)
|
|
250
|
-
warn("Error: #{dir} is not a directory")
|
|
63
|
+
def read_stdin_files
|
|
64
|
+
result = StdinReader.call(@stdin, existing_files: @files)
|
|
65
|
+
if result.error
|
|
66
|
+
@stdin_error = result.error
|
|
251
67
|
return
|
|
252
68
|
end
|
|
69
|
+
@files = result.files
|
|
70
|
+
@line_ranges = @line_ranges.merge(result.ranges)
|
|
71
|
+
end
|
|
253
72
|
|
|
254
|
-
|
|
255
|
-
|
|
73
|
+
def build_parsed_args
|
|
74
|
+
Evilution::CLI::ParsedArgs.new(
|
|
75
|
+
command: @command,
|
|
76
|
+
options: @options,
|
|
77
|
+
files: @files,
|
|
78
|
+
line_ranges: @line_ranges,
|
|
79
|
+
stdin_error: @stdin_error,
|
|
80
|
+
parse_error: @parse_error
|
|
81
|
+
)
|
|
256
82
|
end
|
|
257
83
|
end
|
|
84
|
+
|
|
85
|
+
require_relative "parser/command_extractor"
|
|
86
|
+
require_relative "parser/file_args"
|
|
87
|
+
require_relative "parser/stdin_reader"
|
|
88
|
+
require_relative "parser/options_builder"
|
|
@@ -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
|