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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +205 -0
  3. data/CHANGELOG.md +35 -0
  4. data/README.md +80 -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 +2 -1
  9. data/lib/evilution/cli/parser/options_builder.rb +21 -1
  10. data/lib/evilution/cli/printers/compare.rb +159 -0
  11. data/lib/evilution/cli.rb +1 -0
  12. data/lib/evilution/compare/categorizer.rb +109 -0
  13. data/lib/evilution/compare/detector.rb +21 -0
  14. data/lib/evilution/compare/fingerprint.rb +83 -0
  15. data/lib/evilution/compare/normalizer.rb +106 -0
  16. data/lib/evilution/compare/record.rb +16 -0
  17. data/lib/evilution/compare.rb +15 -0
  18. data/lib/evilution/config.rb +165 -3
  19. data/lib/evilution/example_filter.rb +143 -0
  20. data/lib/evilution/integration/crash_detector.rb +5 -2
  21. data/lib/evilution/integration/minitest.rb +10 -5
  22. data/lib/evilution/integration/minitest_crash_detector.rb +5 -2
  23. data/lib/evilution/integration/rspec.rb +82 -7
  24. data/lib/evilution/isolation/fork.rb +25 -0
  25. data/lib/evilution/mcp/info_tool.rb +77 -5
  26. data/lib/evilution/mcp/mutate_tool/config_builder.rb +20 -0
  27. data/lib/evilution/mcp/mutate_tool/error_payload.rb +17 -0
  28. data/lib/evilution/mcp/mutate_tool/option_parser.rb +54 -0
  29. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +37 -0
  30. data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +31 -0
  31. data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +52 -0
  32. data/lib/evilution/mcp/mutate_tool.rb +34 -186
  33. data/lib/evilution/mutation.rb +43 -3
  34. data/lib/evilution/mutator/base.rb +39 -1
  35. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +5 -1
  36. data/lib/evilution/mutator/operator/argument_removal.rb +5 -1
  37. data/lib/evilution/parallel/work_queue.rb +149 -31
  38. data/lib/evilution/parallel_db_warning.rb +68 -0
  39. data/lib/evilution/reporter/cli.rb +37 -11
  40. data/lib/evilution/reporter/html/assets/style.css +17 -0
  41. data/lib/evilution/reporter/html/sections/file_section.rb +15 -0
  42. data/lib/evilution/reporter/html/sections/neutral_details.rb +25 -0
  43. data/lib/evilution/reporter/html/sections/unparseable_details.rb +25 -0
  44. data/lib/evilution/reporter/html/sections/unresolved_details.rb +25 -0
  45. data/lib/evilution/reporter/html/templates/file_section.html.erb +3 -0
  46. data/lib/evilution/reporter/html/templates/neutral_details.html.erb +14 -0
  47. data/lib/evilution/reporter/html/templates/summary_cards.html.erb +3 -0
  48. data/lib/evilution/reporter/html/templates/unparseable_details.html.erb +11 -0
  49. data/lib/evilution/reporter/html/templates/unresolved_details.html.erb +11 -0
  50. data/lib/evilution/reporter/json.rb +8 -2
  51. data/lib/evilution/reporter/suggestion/diff_helpers.rb +28 -0
  52. data/lib/evilution/reporter/suggestion/registry.rb +64 -0
  53. data/lib/evilution/reporter/suggestion/templates/generic.rb +55 -0
  54. data/lib/evilution/reporter/suggestion/templates/minitest.rb +659 -0
  55. data/lib/evilution/reporter/suggestion/templates/rspec.rb +613 -0
  56. data/lib/evilution/reporter/suggestion.rb +8 -1327
  57. data/lib/evilution/result/mutation_result.rb +5 -1
  58. data/lib/evilution/result/summary.rb +13 -1
  59. data/lib/evilution/runner/baseline_runner.rb +23 -2
  60. data/lib/evilution/runner/mutation_executor.rb +83 -13
  61. data/lib/evilution/runner.rb +6 -0
  62. data/lib/evilution/source_ast_cache.rb +39 -0
  63. data/lib/evilution/spec_ast_cache.rb +166 -0
  64. data/lib/evilution/spec_resolver.rb +6 -1
  65. data/lib/evilution/spec_selector.rb +39 -0
  66. data/lib/evilution/temp_dir_tracker.rb +23 -3
  67. data/lib/evilution/version.rb +1 -1
  68. data/script/memory_check +7 -5
  69. 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)
@@ -5,7 +5,8 @@ class Evilution::CLI::Parser::CommandExtractor
5
5
  "version" => :version,
6
6
  "init" => :init,
7
7
  "mcp" => :mcp,
8
- "subjects" => :subjects
8
+ "subjects" => :subjects,
9
+ "compare" => :compare
9
10
  }.freeze
10
11
 
11
12
  SESSION_SUBCOMMANDS = {
@@ -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,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../compare"
4
+
5
+ module Evilution::Compare
6
+ Record = Data.define(
7
+ :source,
8
+ :file_path,
9
+ :line,
10
+ :status,
11
+ :fingerprint,
12
+ :operator,
13
+ :diff_body,
14
+ :raw
15
+ )
16
+ 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