evilution 0.20.0 → 0.22.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/.gitignore +4 -0
- data/.beads/.migration-hint-ts +1 -1
- data/.beads/interactions.jsonl +12 -0
- data/.beads/issues.jsonl +22 -19
- data/CHANGELOG.md +35 -0
- data/README.md +17 -11
- data/comparison_results/baseline_2026-04-09.md +35 -0
- data/comparison_results/operator_classification.md +79 -0
- data/comparison_results/operator_prioritization.md +68 -0
- data/docs/mutation_density_benchmark.md +91 -0
- data/lib/evilution/ast/parser.rb +2 -1
- data/lib/evilution/baseline.rb +14 -11
- data/lib/evilution/cli.rb +13 -3
- data/lib/evilution/config.rb +27 -5
- data/lib/evilution/disable_comment.rb +2 -1
- data/lib/evilution/integration/base.rb +98 -1
- data/lib/evilution/integration/minitest.rb +145 -0
- data/lib/evilution/integration/minitest_crash_detector.rb +55 -0
- data/lib/evilution/integration/rspec.rb +33 -92
- data/lib/evilution/isolation/fork.rb +3 -6
- data/lib/evilution/mcp/mutate_tool.rb +6 -6
- data/lib/evilution/mutator/base.rb +5 -1
- data/lib/evilution/mutator/operator/bitwise_complement.rb +1 -1
- data/lib/evilution/mutator/operator/block_pass_removal.rb +30 -0
- data/lib/evilution/mutator/operator/ensure_removal.rb +1 -1
- data/lib/evilution/mutator/operator/index_to_at.rb +30 -0
- data/lib/evilution/mutator/operator/index_to_dig.rb +3 -3
- data/lib/evilution/mutator/operator/index_to_fetch.rb +2 -2
- data/lib/evilution/mutator/operator/keyword_argument.rb +1 -1
- data/lib/evilution/mutator/operator/regex_simplification.rb +169 -0
- data/lib/evilution/mutator/operator/rescue_body_replacement.rb +1 -1
- data/lib/evilution/mutator/operator/rescue_removal.rb +1 -1
- data/lib/evilution/mutator/operator/string_literal.rb +18 -0
- data/lib/evilution/mutator/registry.rb +12 -2
- data/lib/evilution/reporter/html.rb +2 -2
- data/lib/evilution/reporter/json.rb +2 -2
- data/lib/evilution/reporter/suggestion.rb +659 -2
- data/lib/evilution/runner.rb +59 -13
- data/lib/evilution/spec_resolver.rb +24 -16
- data/lib/evilution/temp_dir_tracker.rb +39 -0
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +4 -0
- data/scripts/benchmark_density +261 -0
- data/scripts/benchmark_density.yml +19 -0
- data/scripts/compare_mutations +404 -0
- data/scripts/compare_mutations.yml +24 -0
- data/scripts/mutant_json_adapter +224 -0
- metadata +17 -2
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Head-to-Head Mutation Comparison
|
|
5
|
+
#
|
|
6
|
+
# Runs evilution and a reference mutation testing tool on a set of files,
|
|
7
|
+
# catalogs every mutation the reference tool produces that evilution doesn't,
|
|
8
|
+
# and outputs a structured comparison report.
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# scripts/compare_mutations CONFIG_FILE [--verbose]
|
|
12
|
+
#
|
|
13
|
+
# See scripts/compare_mutations.yml for config format.
|
|
14
|
+
|
|
15
|
+
require "yaml"
|
|
16
|
+
require "json"
|
|
17
|
+
require "open3"
|
|
18
|
+
require "optparse"
|
|
19
|
+
require "fileutils"
|
|
20
|
+
|
|
21
|
+
module CompareMutations
|
|
22
|
+
class ConfigError < StandardError; end
|
|
23
|
+
class ToolError < StandardError; end
|
|
24
|
+
|
|
25
|
+
class Config
|
|
26
|
+
attr_reader :project_root, :reference_cmd, :output_dir, :files
|
|
27
|
+
|
|
28
|
+
def initialize(path)
|
|
29
|
+
data = YAML.safe_load_file(path, symbolize_names: true, permitted_classes: [Symbol])
|
|
30
|
+
raise ConfigError, "config file must contain a YAML mapping" unless data.is_a?(Hash)
|
|
31
|
+
|
|
32
|
+
@project_root = data[:project_root]
|
|
33
|
+
@reference_cmd = data.key?(:reference_cmd) ? data[:reference_cmd] : []
|
|
34
|
+
@output_dir = data[:output_dir] || "comparison_results"
|
|
35
|
+
@files = data.key?(:files) ? data[:files] : []
|
|
36
|
+
|
|
37
|
+
validate!
|
|
38
|
+
rescue Psych::Exception => e
|
|
39
|
+
raise ConfigError, "failed to parse config: #{e.message}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def validate!
|
|
45
|
+
raise ConfigError, "project_root is required in config" if @project_root.nil?
|
|
46
|
+
raise ConfigError, "project_root does not exist: #{@project_root}" unless Dir.exist?(@project_root)
|
|
47
|
+
raise ConfigError, "reference_cmd is required in config" if @reference_cmd.empty?
|
|
48
|
+
raise ConfigError, "files list is empty — add files to the config" if @files.empty?
|
|
49
|
+
|
|
50
|
+
@files.each_with_index { |entry, index| validate_file_entry!(entry, index) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def validate_file_entry!(entry, index)
|
|
54
|
+
raise ConfigError, "files[#{index}] must be a mapping" unless entry.is_a?(Hash)
|
|
55
|
+
|
|
56
|
+
path = entry[:path]
|
|
57
|
+
target = entry[:reference_target]
|
|
58
|
+
raise ConfigError, "files[#{index}] is missing required path" if path.nil? || path.to_s.strip.empty?
|
|
59
|
+
raise ConfigError, "files[#{index}] is missing required reference_target" if target.nil? || target.to_s.strip.empty?
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class MutationSet
|
|
64
|
+
attr_reader :mutations, :fingerprints
|
|
65
|
+
|
|
66
|
+
def self.from_json(data)
|
|
67
|
+
mutations = data.map do |entry|
|
|
68
|
+
entry = entry.transform_keys(&:to_s)
|
|
69
|
+
entry["fingerprint"] = compute_fingerprint(entry)
|
|
70
|
+
entry
|
|
71
|
+
end
|
|
72
|
+
new(mutations)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.compute_fingerprint(entry)
|
|
76
|
+
normalized_diff = entry["diff"].to_s.gsub(/[[:space:]]/, "")
|
|
77
|
+
"#{entry["line"]}:#{normalized_diff}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def initialize(mutations)
|
|
81
|
+
@mutations = mutations
|
|
82
|
+
@fingerprints = mutations.map { |m| m["fingerprint"] }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def size
|
|
86
|
+
@mutations.size
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def empty?
|
|
90
|
+
@mutations.empty?
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
class Comparison
|
|
95
|
+
attr_reader :evilution_set, :reference_set
|
|
96
|
+
|
|
97
|
+
def initialize(evilution:, reference:)
|
|
98
|
+
@evilution_set = evilution
|
|
99
|
+
@reference_set = reference
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def extra_in_reference
|
|
103
|
+
ev_fps = Set.new(@evilution_set.fingerprints)
|
|
104
|
+
@reference_set.mutations.reject { |m| ev_fps.include?(m["fingerprint"]) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def extra_in_evilution
|
|
108
|
+
ref_fps = Set.new(@reference_set.fingerprints)
|
|
109
|
+
@evilution_set.mutations.reject { |m| ref_fps.include?(m["fingerprint"]) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def density_ratio
|
|
113
|
+
return 0.0 if @evilution_set.empty?
|
|
114
|
+
|
|
115
|
+
@reference_set.size.to_f / @evilution_set.size
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
class Catalog
|
|
120
|
+
def initialize(extras)
|
|
121
|
+
@extras = extras
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def by_operator
|
|
125
|
+
@extras.group_by { |m| m["operator"] }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def summary
|
|
129
|
+
by_operator.map { |op, mutations| { operator: op, count: mutations.size } }
|
|
130
|
+
.sort_by { |entry| -entry[:count] }
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
class EvilutionCollector
|
|
135
|
+
def initialize(project_root)
|
|
136
|
+
@project_root = project_root
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def collect(file_path)
|
|
140
|
+
full_path = File.join(@project_root, file_path)
|
|
141
|
+
cmd = ["bundle", "exec", "evilution", "util", "mutation", full_path, "--format", "json"]
|
|
142
|
+
stdout, stderr, status = Open3.capture3(*cmd, chdir: @project_root)
|
|
143
|
+
|
|
144
|
+
raise ToolError, "evilution failed for #{file_path}: #{stderr.strip}" unless status.success?
|
|
145
|
+
|
|
146
|
+
JSON.parse(stdout)
|
|
147
|
+
rescue JSON::ParserError => e
|
|
148
|
+
raise ToolError, "evilution produced invalid JSON for #{file_path}: #{e.message}"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
class ReferenceCollector
|
|
153
|
+
def initialize(project_root, reference_cmd)
|
|
154
|
+
@project_root = project_root
|
|
155
|
+
@reference_cmd = reference_cmd
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def collect(reference_target)
|
|
159
|
+
cmd = @reference_cmd + [reference_target]
|
|
160
|
+
stdout, stderr, status = Open3.capture3(*cmd, chdir: @project_root)
|
|
161
|
+
|
|
162
|
+
raise ToolError, "reference tool failed for #{reference_target}: #{stderr.lines.first&.strip}" unless status.success?
|
|
163
|
+
|
|
164
|
+
JSON.parse(stdout)
|
|
165
|
+
rescue JSON::ParserError => e
|
|
166
|
+
raise ToolError, "reference tool produced invalid JSON for #{reference_target}: #{e.message}"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
class Reporter
|
|
171
|
+
def initialize(output_dir)
|
|
172
|
+
@output_dir = output_dir
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def write_file_report(file_path, comparison, catalog)
|
|
176
|
+
basename = File.basename(file_path, ".rb")
|
|
177
|
+
write_json_report(basename, file_path, comparison, catalog)
|
|
178
|
+
write_text_report(basename, file_path, comparison, catalog)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def write_summary(file_results)
|
|
182
|
+
write_summary_json(file_results)
|
|
183
|
+
write_summary_text(file_results)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
def write_json_report(basename, file_path, comparison, catalog)
|
|
189
|
+
data = {
|
|
190
|
+
file: file_path,
|
|
191
|
+
evilution_count: comparison.evilution_set.size,
|
|
192
|
+
reference_count: comparison.reference_set.size,
|
|
193
|
+
density_ratio: comparison.density_ratio.round(2),
|
|
194
|
+
extra_in_reference: comparison.extra_in_reference,
|
|
195
|
+
extra_in_evilution: comparison.extra_in_evilution,
|
|
196
|
+
operator_summary: catalog.summary
|
|
197
|
+
}
|
|
198
|
+
File.write(File.join(@output_dir, "#{basename}.json"), JSON.pretty_generate(data))
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def write_text_report(basename, file_path, comparison, catalog)
|
|
202
|
+
lines = build_file_header(file_path, comparison)
|
|
203
|
+
lines.concat(build_extra_reference_section(comparison, catalog))
|
|
204
|
+
lines.concat(build_extra_evilution_section(comparison))
|
|
205
|
+
File.write(File.join(@output_dir, "#{basename}.txt"), lines.join("\n"))
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def build_file_header(file_path, comparison)
|
|
209
|
+
[
|
|
210
|
+
"# Mutation Comparison: #{file_path}",
|
|
211
|
+
"",
|
|
212
|
+
"Evilution: #{comparison.evilution_set.size} mutations",
|
|
213
|
+
"Reference: #{comparison.reference_set.size} mutations",
|
|
214
|
+
"Ratio: #{comparison.density_ratio.round(2)}x",
|
|
215
|
+
""
|
|
216
|
+
]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def build_extra_reference_section(comparison, catalog)
|
|
220
|
+
extra = comparison.extra_in_reference
|
|
221
|
+
lines = ["## Extra in reference (#{extra.size})", ""]
|
|
222
|
+
|
|
223
|
+
catalog.summary.each do |entry|
|
|
224
|
+
lines << "### #{entry[:operator]} (#{entry[:count]})"
|
|
225
|
+
catalog.by_operator[entry[:operator]].each do |m|
|
|
226
|
+
lines << " Line #{m["line"]}:"
|
|
227
|
+
m["diff"].to_s.each_line { |l| lines << " #{l.chomp}" }
|
|
228
|
+
lines << ""
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
lines
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def build_extra_evilution_section(comparison)
|
|
236
|
+
ev_extra = comparison.extra_in_evilution
|
|
237
|
+
return [] if ev_extra.empty?
|
|
238
|
+
|
|
239
|
+
lines = ["## Extra in evilution (#{ev_extra.size})", ""]
|
|
240
|
+
ev_extra.each do |m|
|
|
241
|
+
lines << " #{m["operator"]} line #{m["line"]}: #{m["diff"].to_s.lines.first&.chomp}"
|
|
242
|
+
end
|
|
243
|
+
lines
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def write_summary_json(file_results)
|
|
247
|
+
data = {
|
|
248
|
+
timestamp: Time.now.iso8601,
|
|
249
|
+
files: file_results,
|
|
250
|
+
totals: compute_summary_totals(file_results)
|
|
251
|
+
}
|
|
252
|
+
File.write(File.join(@output_dir, "summary.json"), JSON.pretty_generate(data))
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def write_summary_text(file_results)
|
|
256
|
+
totals = compute_summary_totals(file_results)
|
|
257
|
+
lines = build_summary_header
|
|
258
|
+
lines.concat(build_summary_rows(file_results))
|
|
259
|
+
lines.concat(build_summary_totals(totals))
|
|
260
|
+
lines.concat(build_operator_breakdown(totals))
|
|
261
|
+
File.write(File.join(@output_dir, "summary.txt"), lines.join("\n"))
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def build_summary_header
|
|
265
|
+
header = "File " \
|
|
266
|
+
"Evilution Reference Ratio Extra"
|
|
267
|
+
["# Head-to-Head Mutation Comparison Summary", "", header, ("-" * 83)]
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def build_summary_rows(file_results)
|
|
271
|
+
file_results.map do |r|
|
|
272
|
+
format("%-45<file>s %10<ev>d %10<ref>d %7.2<ratio>fx %8<extra>d",
|
|
273
|
+
file: r[:file], ev: r[:evilution_count], ref: r[:reference_count],
|
|
274
|
+
ratio: r[:density_ratio], extra: r[:extra_count])
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def build_summary_totals(totals)
|
|
279
|
+
total_line = format("%-45<file>s %10<ev>d %10<ref>d %7.2<ratio>fx %8<extra>d",
|
|
280
|
+
file: "TOTAL", ev: totals[:evilution_total], ref: totals[:reference_total],
|
|
281
|
+
ratio: totals[:density_ratio], extra: totals[:extra_total])
|
|
282
|
+
[("-" * 83), total_line, ""]
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def build_operator_breakdown(totals)
|
|
286
|
+
lines = ["## Operator breakdown (reference extras)", ""]
|
|
287
|
+
totals[:operator_summary].each do |entry|
|
|
288
|
+
lines << " #{entry[:operator]}: #{entry[:count]}"
|
|
289
|
+
end
|
|
290
|
+
lines
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def compute_summary_totals(file_results)
|
|
294
|
+
ev_total = file_results.sum { |r| r[:evilution_count] }
|
|
295
|
+
ref_total = file_results.sum { |r| r[:reference_count] }
|
|
296
|
+
extra_total = file_results.sum { |r| r[:extra_count] }
|
|
297
|
+
ratio = ev_total.positive? ? (ref_total.to_f / ev_total).round(2) : 0.0
|
|
298
|
+
merged = merge_operator_summaries(file_results)
|
|
299
|
+
|
|
300
|
+
{ evilution_total: ev_total, reference_total: ref_total,
|
|
301
|
+
density_ratio: ratio, extra_total: extra_total, operator_summary: merged }
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def merge_operator_summaries(file_results)
|
|
305
|
+
all_operators = file_results.flat_map { |r| r[:operator_summary] }
|
|
306
|
+
all_operators.group_by { |e| e[:operator] }
|
|
307
|
+
.map { |op, entries| { operator: op, count: entries.sum { |e| e[:count] } } }
|
|
308
|
+
.sort_by { |e| -e[:count] }
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
class Runner
|
|
313
|
+
def initialize(config:, verbose: false)
|
|
314
|
+
@config = config
|
|
315
|
+
@verbose = verbose
|
|
316
|
+
@evilution = EvilutionCollector.new(config.project_root)
|
|
317
|
+
@reference = ReferenceCollector.new(config.project_root, config.reference_cmd)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def call
|
|
321
|
+
FileUtils.mkdir_p(@config.output_dir)
|
|
322
|
+
reporter = Reporter.new(@config.output_dir)
|
|
323
|
+
file_results = process_files(reporter)
|
|
324
|
+
reporter.write_summary(file_results)
|
|
325
|
+
print_summary(file_results)
|
|
326
|
+
0
|
|
327
|
+
rescue ToolError => e
|
|
328
|
+
warn "Tool error: #{e.message}"
|
|
329
|
+
1
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
private
|
|
333
|
+
|
|
334
|
+
def process_files(reporter)
|
|
335
|
+
@config.files.map do |entry|
|
|
336
|
+
path = entry[:path]
|
|
337
|
+
reference_target = entry[:reference_target]
|
|
338
|
+
|
|
339
|
+
warn " Comparing #{path}..." if @verbose
|
|
340
|
+
compare_file(path, reference_target, reporter)
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def compare_file(path, _reference_target, reporter)
|
|
345
|
+
full_path = File.join(@config.project_root, path)
|
|
346
|
+
ev_data = @evilution.collect(path)
|
|
347
|
+
ref_data = @reference.collect(full_path)
|
|
348
|
+
|
|
349
|
+
ev_set = MutationSet.from_json(ev_data)
|
|
350
|
+
ref_set = MutationSet.from_json(ref_data)
|
|
351
|
+
comparison = Comparison.new(evilution: ev_set, reference: ref_set)
|
|
352
|
+
catalog = Catalog.new(comparison.extra_in_reference)
|
|
353
|
+
|
|
354
|
+
reporter.write_file_report(path, comparison, catalog)
|
|
355
|
+
|
|
356
|
+
{ file: path, evilution_count: ev_set.size, reference_count: ref_set.size,
|
|
357
|
+
density_ratio: comparison.density_ratio.round(2),
|
|
358
|
+
extra_count: comparison.extra_in_reference.size,
|
|
359
|
+
operator_summary: catalog.summary }
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def print_summary(file_results)
|
|
363
|
+
ev_total = file_results.sum { |r| r[:evilution_count] }
|
|
364
|
+
ref_total = file_results.sum { |r| r[:reference_count] }
|
|
365
|
+
extra_total = file_results.sum { |r| r[:extra_count] }
|
|
366
|
+
ratio = ev_total.positive? ? (ref_total.to_f / ev_total).round(2) : 0.0
|
|
367
|
+
|
|
368
|
+
puts "Comparison complete. Results in #{@config.output_dir}/"
|
|
369
|
+
puts " Files: #{file_results.size}"
|
|
370
|
+
puts " Evilution: #{ev_total} mutations"
|
|
371
|
+
puts " Reference: #{ref_total} mutations"
|
|
372
|
+
puts " Ratio: #{ratio}x"
|
|
373
|
+
puts " Extra in reference: #{extra_total}"
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
if __FILE__ == $PROGRAM_NAME
|
|
379
|
+
verbose = false
|
|
380
|
+
parser = OptionParser.new do |opts|
|
|
381
|
+
opts.banner = "Usage: #{$PROGRAM_NAME} CONFIG_FILE [--verbose]"
|
|
382
|
+
opts.on("-v", "--verbose", "Show progress") { verbose = true }
|
|
383
|
+
opts.on("-h", "--help", "Show help") do
|
|
384
|
+
puts opts
|
|
385
|
+
exit 0
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
parser.parse!
|
|
389
|
+
|
|
390
|
+
config_path = ARGV.first
|
|
391
|
+
unless config_path
|
|
392
|
+
warn parser.banner
|
|
393
|
+
exit 2
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
begin
|
|
397
|
+
config = CompareMutations::Config.new(config_path)
|
|
398
|
+
runner = CompareMutations::Runner.new(config: config, verbose: verbose)
|
|
399
|
+
exit runner.call
|
|
400
|
+
rescue CompareMutations::ConfigError => e
|
|
401
|
+
warn "Config error: #{e.message}"
|
|
402
|
+
exit 2
|
|
403
|
+
end
|
|
404
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Head-to-head mutation comparison configuration
|
|
2
|
+
#
|
|
3
|
+
# Each file entry specifies a source file and the corresponding reference tool
|
|
4
|
+
# subject pattern. The reference_cmd must produce JSON output with the same
|
|
5
|
+
# schema as `evilution util mutation --format json`:
|
|
6
|
+
# [{"operator": "...", "line": N, "diff": "..."}, ...]
|
|
7
|
+
#
|
|
8
|
+
# To use: set project_root and reference_cmd for your environment.
|
|
9
|
+
#
|
|
10
|
+
# Example:
|
|
11
|
+
#
|
|
12
|
+
# project_root: /path/to/rails/app
|
|
13
|
+
# reference_cmd: ["ruby", "scripts/mutant_json_adapter", "--project-root", "/path/to/rails/app"]
|
|
14
|
+
# output_dir: comparison_results
|
|
15
|
+
# files:
|
|
16
|
+
# - path: app/models/user.rb
|
|
17
|
+
# reference_target: "User"
|
|
18
|
+
# - path: app/services/payment_processor.rb
|
|
19
|
+
# reference_target: "PaymentProcessor"
|
|
20
|
+
|
|
21
|
+
project_root: null
|
|
22
|
+
reference_cmd: []
|
|
23
|
+
output_dir: comparison_results
|
|
24
|
+
files: []
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Mutant JSON Adapter
|
|
5
|
+
#
|
|
6
|
+
# Extracts method bodies from a Ruby file using Prism, feeds each to
|
|
7
|
+
# `mutant util mutation -e`, parses the diff output, and produces JSON
|
|
8
|
+
# in the same schema as `evilution util mutation --format json`:
|
|
9
|
+
#
|
|
10
|
+
# [{"operator": "...", "subject": "...", "file": "...", "line": N, "diff": "..."}, ...]
|
|
11
|
+
#
|
|
12
|
+
# This works around mutant's inability to parse Ruby 4.0 files by processing
|
|
13
|
+
# individual method snippets through its eval mode.
|
|
14
|
+
#
|
|
15
|
+
# Usage:
|
|
16
|
+
# scripts/mutant_json_adapter FILE_PATH [--project-root DIR] [--verbose]
|
|
17
|
+
|
|
18
|
+
require "json"
|
|
19
|
+
require "open3"
|
|
20
|
+
require "optparse"
|
|
21
|
+
require "prism"
|
|
22
|
+
|
|
23
|
+
module MutantJsonAdapter
|
|
24
|
+
class ParseError < StandardError; end
|
|
25
|
+
|
|
26
|
+
class MethodExtractor
|
|
27
|
+
MethodInfo = Struct.new(:name, :line_number, :source)
|
|
28
|
+
|
|
29
|
+
def initialize(file_path)
|
|
30
|
+
@file_path = file_path
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def extract
|
|
34
|
+
source = File.read(@file_path)
|
|
35
|
+
binary = source.b
|
|
36
|
+
encoding = source.encoding
|
|
37
|
+
result = Prism.parse(source)
|
|
38
|
+
|
|
39
|
+
raise ParseError, "failed to parse #{@file_path}" if result.failure?
|
|
40
|
+
|
|
41
|
+
visitor = MethodVisitor.new(binary, encoding)
|
|
42
|
+
visitor.visit(result.value)
|
|
43
|
+
visitor.methods
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class MethodVisitor < Prism::Visitor
|
|
47
|
+
attr_reader :methods
|
|
48
|
+
|
|
49
|
+
def initialize(source_binary, encoding)
|
|
50
|
+
super()
|
|
51
|
+
@source_binary = source_binary
|
|
52
|
+
@encoding = encoding
|
|
53
|
+
@methods = []
|
|
54
|
+
@context = []
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def visit_module_node(node)
|
|
58
|
+
@context.push(node.constant_path&.full_name || node.name.to_s)
|
|
59
|
+
super
|
|
60
|
+
@context.pop
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def visit_class_node(node)
|
|
64
|
+
@context.push(node.constant_path&.full_name || node.name.to_s)
|
|
65
|
+
super
|
|
66
|
+
@context.pop
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def visit_def_node(node)
|
|
70
|
+
loc = node.location
|
|
71
|
+
text = @source_binary[loc.start_offset...loc.end_offset].force_encoding(@encoding)
|
|
72
|
+
separator = node.receiver ? "." : "#"
|
|
73
|
+
name = "#{@context.join("::")}#{separator}#{node.name}"
|
|
74
|
+
|
|
75
|
+
@methods << MethodInfo.new(name: name, line_number: loc.start_line, source: text)
|
|
76
|
+
super
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
class MutantRunner
|
|
82
|
+
def initialize(project_root: nil)
|
|
83
|
+
@project_root = project_root
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def mutations_for(source)
|
|
87
|
+
cmd = ["bundle", "exec", "mutant", "util", "mutation", "-e", source]
|
|
88
|
+
opts = @project_root ? { chdir: @project_root } : {}
|
|
89
|
+
stdout, _stderr, _status = Open3.capture3(*cmd, **opts)
|
|
90
|
+
stdout
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
class OutputParser
|
|
95
|
+
def parse(raw_output, subject:, file_path:)
|
|
96
|
+
return [] if raw_output.strip.empty?
|
|
97
|
+
|
|
98
|
+
mutations = []
|
|
99
|
+
current_diff_lines = []
|
|
100
|
+
|
|
101
|
+
raw_output.each_line do |line|
|
|
102
|
+
if line.start_with?("@@ ")
|
|
103
|
+
flush_mutation(mutations, current_diff_lines, subject, file_path) if current_diff_lines.any?
|
|
104
|
+
current_diff_lines = [line]
|
|
105
|
+
elsif line.start_with?("-", "+", " ") && current_diff_lines.any?
|
|
106
|
+
current_diff_lines << line
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
flush_mutation(mutations, current_diff_lines, subject, file_path) if current_diff_lines.any?
|
|
111
|
+
mutations
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def flush_mutation(mutations, diff_lines, subject, file_path)
|
|
117
|
+
diff_text = diff_lines.join
|
|
118
|
+
operator = infer_operator(diff_lines)
|
|
119
|
+
|
|
120
|
+
mutations << {
|
|
121
|
+
"operator" => operator,
|
|
122
|
+
"subject" => subject.name,
|
|
123
|
+
"file" => file_path,
|
|
124
|
+
"line" => subject.line_number,
|
|
125
|
+
"diff" => diff_text
|
|
126
|
+
}
|
|
127
|
+
diff_lines.clear
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def infer_operator(diff_lines)
|
|
131
|
+
removed = diff_lines.select { |l| l.start_with?("-") && !l.start_with?("---") }
|
|
132
|
+
.map { |l| l[1..].strip }
|
|
133
|
+
added = diff_lines.select { |l| l.start_with?("+") && !l.start_with?("+++") }
|
|
134
|
+
.map { |l| l[1..].strip }
|
|
135
|
+
|
|
136
|
+
categorize_mutation(removed, added)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def categorize_mutation(removed, added)
|
|
140
|
+
return "replacement" if removed.empty?
|
|
141
|
+
|
|
142
|
+
body_lines = added.reject { |l| l == "end" || l.start_with?("def ") }
|
|
143
|
+
return "removal" if body_lines.empty?
|
|
144
|
+
|
|
145
|
+
classify_added(body_lines.first)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def classify_added(added_body)
|
|
149
|
+
case added_body
|
|
150
|
+
when "raise" then "raise_insertion"
|
|
151
|
+
when "super" then "super_replacement"
|
|
152
|
+
when "nil" then "nil_replacement"
|
|
153
|
+
when "true", "false" then "boolean_swap"
|
|
154
|
+
when /\.eql\?/ then "equality_change"
|
|
155
|
+
when /\.equal\?/ then "identity_change"
|
|
156
|
+
when /-?\d+\b/ then "literal_boundary"
|
|
157
|
+
else "mutation"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
class Runner
|
|
163
|
+
def initialize(file_path:, project_root: nil, verbose: false)
|
|
164
|
+
@file_path = File.expand_path(file_path)
|
|
165
|
+
@project_root = project_root
|
|
166
|
+
@verbose = verbose
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def call
|
|
170
|
+
subjects = MethodExtractor.new(@file_path).extract
|
|
171
|
+
|
|
172
|
+
if subjects.empty?
|
|
173
|
+
warn "No methods found in #{@file_path}" if @verbose
|
|
174
|
+
puts "[]"
|
|
175
|
+
return 0
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
all_mutations = process_subjects(subjects)
|
|
179
|
+
puts JSON.pretty_generate(all_mutations)
|
|
180
|
+
0
|
|
181
|
+
rescue ParseError => e
|
|
182
|
+
warn e.message
|
|
183
|
+
2
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
def process_subjects(subjects)
|
|
189
|
+
mutant_runner = MutantRunner.new(project_root: @project_root)
|
|
190
|
+
parser = OutputParser.new
|
|
191
|
+
|
|
192
|
+
subjects.flat_map do |subject|
|
|
193
|
+
warn " Processing #{subject.name}..." if @verbose
|
|
194
|
+
raw = mutant_runner.mutations_for(subject.source)
|
|
195
|
+
parser.parse(raw, subject: subject, file_path: @file_path)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
if __FILE__ == $PROGRAM_NAME
|
|
202
|
+
verbose = false
|
|
203
|
+
project_root = nil
|
|
204
|
+
|
|
205
|
+
opt_parser = OptionParser.new do |opts|
|
|
206
|
+
opts.banner = "Usage: #{$PROGRAM_NAME} FILE_PATH [--project-root DIR] [--verbose]"
|
|
207
|
+
opts.on("-v", "--verbose", "Show progress") { verbose = true }
|
|
208
|
+
opts.on("-p", "--project-root DIR", "Project root for bundle exec") { |d| project_root = d }
|
|
209
|
+
opts.on("-h", "--help", "Show help") do
|
|
210
|
+
puts opts
|
|
211
|
+
exit 0
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
opt_parser.parse!
|
|
215
|
+
|
|
216
|
+
file_path = ARGV.first
|
|
217
|
+
unless file_path
|
|
218
|
+
warn opt_parser.banner
|
|
219
|
+
exit 2
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
runner = MutantJsonAdapter::Runner.new(file_path: file_path, project_root: project_root, verbose: verbose)
|
|
223
|
+
exit runner.call
|
|
224
|
+
end
|