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.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +210 -0
  3. data/CHANGELOG.md +51 -0
  4. data/README.md +81 -4
  5. data/exe/evil +6 -0
  6. data/lib/evilution/ast/source_surgeon.rb +15 -1
  7. data/lib/evilution/cli/commands/compare.rb +68 -0
  8. data/lib/evilution/cli/parser/command_extractor.rb +78 -0
  9. data/lib/evilution/cli/parser/file_args.rb +41 -0
  10. data/lib/evilution/cli/parser/options_builder.rb +123 -0
  11. data/lib/evilution/cli/parser/stdin_reader.rb +28 -0
  12. data/lib/evilution/cli/parser.rb +27 -196
  13. data/lib/evilution/cli/printers/compare.rb +159 -0
  14. data/lib/evilution/cli.rb +1 -0
  15. data/lib/evilution/compare/categorizer.rb +109 -0
  16. data/lib/evilution/compare/detector.rb +21 -0
  17. data/lib/evilution/compare/fingerprint.rb +83 -0
  18. data/lib/evilution/compare/normalizer.rb +106 -0
  19. data/lib/evilution/compare/record.rb +16 -0
  20. data/lib/evilution/compare.rb +15 -0
  21. data/lib/evilution/config.rb +178 -3
  22. data/lib/evilution/example_filter.rb +143 -0
  23. data/lib/evilution/integration/base.rb +11 -57
  24. data/lib/evilution/integration/crash_detector.rb +5 -2
  25. data/lib/evilution/integration/minitest.rb +25 -7
  26. data/lib/evilution/integration/minitest_crash_detector.rb +5 -2
  27. data/lib/evilution/integration/rspec.rb +99 -12
  28. data/lib/evilution/isolation/fork.rb +26 -0
  29. data/lib/evilution/isolation/in_process.rb +1 -0
  30. data/lib/evilution/mcp/info_tool.rb +77 -5
  31. data/lib/evilution/mcp/mutate_tool/config_builder.rb +20 -0
  32. data/lib/evilution/mcp/mutate_tool/error_payload.rb +17 -0
  33. data/lib/evilution/mcp/mutate_tool/option_parser.rb +54 -0
  34. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +37 -0
  35. data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +31 -0
  36. data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +52 -0
  37. data/lib/evilution/mcp/mutate_tool.rb +34 -186
  38. data/lib/evilution/mutation.rb +43 -3
  39. data/lib/evilution/mutator/base.rb +39 -1
  40. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +5 -1
  41. data/lib/evilution/mutator/operator/argument_removal.rb +5 -1
  42. data/lib/evilution/parallel/work_queue.rb +149 -31
  43. data/lib/evilution/parallel_db_warning.rb +68 -0
  44. data/lib/evilution/reporter/cli.rb +38 -11
  45. data/lib/evilution/reporter/html/assets/style.css +85 -0
  46. data/lib/evilution/reporter/html/baseline_keys.rb +28 -0
  47. data/lib/evilution/reporter/html/diff_formatter.rb +27 -0
  48. data/lib/evilution/reporter/html/escape.rb +12 -0
  49. data/lib/evilution/reporter/html/namespace.rb +11 -0
  50. data/lib/evilution/reporter/html/report.rb +68 -0
  51. data/lib/evilution/reporter/html/section.rb +21 -0
  52. data/lib/evilution/reporter/html/sections/baseline_comparison.rb +46 -0
  53. data/lib/evilution/reporter/html/sections/error_details.rb +30 -0
  54. data/lib/evilution/reporter/html/sections/error_entry.rb +22 -0
  55. data/lib/evilution/reporter/html/sections/file_section.rb +62 -0
  56. data/lib/evilution/reporter/html/sections/header.rb +29 -0
  57. data/lib/evilution/reporter/html/sections/mutation_map.rb +32 -0
  58. data/lib/evilution/reporter/html/sections/neutral_details.rb +25 -0
  59. data/lib/evilution/reporter/html/sections/summary_cards.rb +11 -0
  60. data/lib/evilution/reporter/html/sections/survived_details.rb +35 -0
  61. data/lib/evilution/reporter/html/sections/survived_entry.rb +36 -0
  62. data/lib/evilution/reporter/html/sections/truncation_notice.rb +17 -0
  63. data/lib/evilution/reporter/html/sections/unparseable_details.rb +25 -0
  64. data/lib/evilution/reporter/html/sections/unresolved_details.rb +25 -0
  65. data/lib/evilution/reporter/html/sections.rb +4 -0
  66. data/lib/evilution/reporter/html/stylesheet.rb +14 -0
  67. data/lib/evilution/reporter/html/templates/baseline_comparison.html.erb +8 -0
  68. data/lib/evilution/reporter/html/templates/error_details.html.erb +6 -0
  69. data/lib/evilution/reporter/html/templates/error_entry.html.erb +10 -0
  70. data/lib/evilution/reporter/html/templates/file_section.html.erb +12 -0
  71. data/lib/evilution/reporter/html/templates/header.html.erb +4 -0
  72. data/lib/evilution/reporter/html/templates/mutation_map.html.erb +6 -0
  73. data/lib/evilution/reporter/html/templates/neutral_details.html.erb +14 -0
  74. data/lib/evilution/reporter/html/templates/report.html.erb +17 -0
  75. data/lib/evilution/reporter/html/templates/summary_cards.html.erb +26 -0
  76. data/lib/evilution/reporter/html/templates/survived_details.html.erb +21 -0
  77. data/lib/evilution/reporter/html/templates/survived_entry.html.erb +8 -0
  78. data/lib/evilution/reporter/html/templates/truncation_notice.html.erb +1 -0
  79. data/lib/evilution/reporter/html/templates/unparseable_details.html.erb +11 -0
  80. data/lib/evilution/reporter/html/templates/unresolved_details.html.erb +11 -0
  81. data/lib/evilution/reporter/html.rb +11 -390
  82. data/lib/evilution/reporter/json.rb +19 -9
  83. data/lib/evilution/reporter/suggestion/diff_helpers.rb +28 -0
  84. data/lib/evilution/reporter/suggestion/registry.rb +64 -0
  85. data/lib/evilution/reporter/suggestion/templates/generic.rb +55 -0
  86. data/lib/evilution/reporter/suggestion/templates/minitest.rb +659 -0
  87. data/lib/evilution/reporter/suggestion/templates/rspec.rb +613 -0
  88. data/lib/evilution/reporter/suggestion.rb +8 -1327
  89. data/lib/evilution/result/mutation_result.rb +9 -1
  90. data/lib/evilution/result/summary.rb +21 -1
  91. data/lib/evilution/runner/baseline_runner.rb +92 -0
  92. data/lib/evilution/runner/diagnostics.rb +105 -0
  93. data/lib/evilution/runner/isolation_resolver.rb +134 -0
  94. data/lib/evilution/runner/mutation_executor.rb +325 -0
  95. data/lib/evilution/runner/mutation_planner.rb +126 -0
  96. data/lib/evilution/runner/report_publisher.rb +60 -0
  97. data/lib/evilution/runner/subject_pipeline.rb +121 -0
  98. data/lib/evilution/runner.rb +61 -692
  99. data/lib/evilution/source_ast_cache.rb +39 -0
  100. data/lib/evilution/spec_ast_cache.rb +166 -0
  101. data/lib/evilution/spec_resolver.rb +6 -1
  102. data/lib/evilution/spec_selector.rb +39 -0
  103. data/lib/evilution/temp_dir_tracker.rb +23 -3
  104. data/lib/evilution/version.rb +1 -1
  105. data/script/memory_check +7 -5
  106. metadata +75 -2
@@ -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 = build_option_parser.parse!(@argv)
43
- @files, @line_ranges = parse_file_args(remaining)
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
- first = @argv.first
79
- if SIMPLE_COMMANDS.key?(first)
80
- @command = SIMPLE_COMMANDS[first]
81
- @argv.shift
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 build_option_parser
141
- OptionParser.new do |opts|
142
- opts.banner = "Usage: evilution [command] [options] [files...]"
143
- opts.version = Evilution::VERSION
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
- specs = Dir.glob(File.join(dir, "**/*_spec.rb"))
255
- @options[:spec_files] = Array(@options[:spec_files]) + specs
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