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
data/lib/evilution/runner.rb
CHANGED
|
@@ -8,6 +8,7 @@ require_relative "mutator/registry"
|
|
|
8
8
|
require_relative "isolation/fork"
|
|
9
9
|
require_relative "isolation/in_process"
|
|
10
10
|
require_relative "integration/rspec"
|
|
11
|
+
require_relative "integration/minitest"
|
|
11
12
|
require_relative "reporter/json"
|
|
12
13
|
require_relative "reporter/cli"
|
|
13
14
|
require_relative "reporter/html"
|
|
@@ -21,10 +22,16 @@ require_relative "cache"
|
|
|
21
22
|
require_relative "parallel/pool"
|
|
22
23
|
require_relative "session/store"
|
|
23
24
|
require_relative "ast/pattern/filter"
|
|
25
|
+
require_relative "temp_dir_tracker"
|
|
24
26
|
require_relative "disable_comment"
|
|
25
27
|
require_relative "ast/sorbet_sig_detector"
|
|
26
28
|
|
|
27
29
|
class Evilution::Runner
|
|
30
|
+
INTEGRATIONS = {
|
|
31
|
+
rspec: Evilution::Integration::RSpec,
|
|
32
|
+
minitest: Evilution::Integration::Minitest
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
28
35
|
attr_reader :config
|
|
29
36
|
|
|
30
37
|
def initialize(config: Evilution::Config.new, on_result: nil, hooks: nil)
|
|
@@ -42,6 +49,7 @@ class Evilution::Runner
|
|
|
42
49
|
end
|
|
43
50
|
|
|
44
51
|
def call
|
|
52
|
+
install_signal_handlers
|
|
45
53
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
46
54
|
|
|
47
55
|
subjects = parse_and_filter_subjects
|
|
@@ -180,8 +188,9 @@ class Evilution::Runner
|
|
|
180
188
|
|
|
181
189
|
def generate_mutations(subjects)
|
|
182
190
|
filter = build_ignore_filter
|
|
191
|
+
operator_options = build_operator_options
|
|
183
192
|
mutations = subjects.flat_map do |subject|
|
|
184
|
-
registry.mutations_for(subject, filter: filter)
|
|
193
|
+
registry.mutations_for(subject, filter: filter, operator_options: operator_options)
|
|
185
194
|
end
|
|
186
195
|
skipped_count = filter ? filter.skipped_count : 0
|
|
187
196
|
|
|
@@ -252,6 +261,10 @@ class Evilution::Runner
|
|
|
252
261
|
end
|
|
253
262
|
end
|
|
254
263
|
|
|
264
|
+
def build_operator_options
|
|
265
|
+
{ skip_heredoc_literals: config.skip_heredoc_literals? }
|
|
266
|
+
end
|
|
267
|
+
|
|
255
268
|
def build_ignore_filter
|
|
256
269
|
patterns = config.ignore_patterns
|
|
257
270
|
return nil if patterns.nil? || patterns.empty?
|
|
@@ -279,7 +292,8 @@ class Evilution::Runner
|
|
|
279
292
|
return nil unless config.baseline? && subjects.any?
|
|
280
293
|
|
|
281
294
|
log_baseline_start
|
|
282
|
-
|
|
295
|
+
integration_class = resolve_integration_class
|
|
296
|
+
baseline = Evilution::Baseline.new(timeout: config.timeout, **integration_class.baseline_options)
|
|
283
297
|
result = baseline.call(subjects)
|
|
284
298
|
log_baseline_complete(result)
|
|
285
299
|
result
|
|
@@ -298,7 +312,7 @@ class Evilution::Runner
|
|
|
298
312
|
|
|
299
313
|
def run_mutations_sequential(mutations, baseline_result = nil)
|
|
300
314
|
integration = build_integration
|
|
301
|
-
spec_resolver = baseline_result&.failed? ?
|
|
315
|
+
spec_resolver = baseline_result&.failed? ? build_neutralization_resolver : nil
|
|
302
316
|
results = []
|
|
303
317
|
survived_count = 0
|
|
304
318
|
truncated = false
|
|
@@ -327,7 +341,7 @@ class Evilution::Runner
|
|
|
327
341
|
integration = build_integration
|
|
328
342
|
pool = Evilution::Parallel::Pool.new(size: config.jobs, hooks: @hooks, item_timeout: config.timeout ? config.timeout * 2 : nil)
|
|
329
343
|
worker_isolator = Evilution::Isolation::InProcess.new
|
|
330
|
-
spec_resolver = baseline_result&.failed? ?
|
|
344
|
+
spec_resolver = baseline_result&.failed? ? build_neutralization_resolver : nil
|
|
331
345
|
state = { results: [], survived_count: 0, truncated: false, completed: 0 }
|
|
332
346
|
|
|
333
347
|
all_worker_stats = []
|
|
@@ -385,7 +399,7 @@ class Evilution::Runner
|
|
|
385
399
|
if config.spec_files.any?
|
|
386
400
|
neutralize = true
|
|
387
401
|
else
|
|
388
|
-
spec_file = spec_resolver.call(result.mutation.file_path) ||
|
|
402
|
+
spec_file = spec_resolver.call(result.mutation.file_path) || neutralization_fallback_dir
|
|
389
403
|
neutralize = baseline_result.failed_spec_files.include?(spec_file)
|
|
390
404
|
end
|
|
391
405
|
return result unless neutralize
|
|
@@ -432,6 +446,26 @@ class Evilution::Runner
|
|
|
432
446
|
config.fail_fast? && survived_count >= config.fail_fast
|
|
433
447
|
end
|
|
434
448
|
|
|
449
|
+
def install_signal_handlers
|
|
450
|
+
%w[INT TERM].each { |sig| install_signal_handler(sig) }
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def install_signal_handler(sig)
|
|
454
|
+
prev_handler = Signal.trap(sig) do
|
|
455
|
+
Evilution::TempDirTracker.cleanup_all
|
|
456
|
+
|
|
457
|
+
case prev_handler
|
|
458
|
+
when Proc, Method
|
|
459
|
+
prev_handler.call
|
|
460
|
+
when "IGNORE"
|
|
461
|
+
# Do nothing — signal is ignored
|
|
462
|
+
else
|
|
463
|
+
Signal.trap(sig, "DEFAULT")
|
|
464
|
+
Process.kill(sig, Process.pid)
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
|
|
435
469
|
def build_isolator
|
|
436
470
|
case resolve_isolation
|
|
437
471
|
when :fork then Evilution::Isolation::Fork.new(hooks: @hooks)
|
|
@@ -445,16 +479,28 @@ class Evilution::Runner
|
|
|
445
479
|
:in_process
|
|
446
480
|
end
|
|
447
481
|
|
|
448
|
-
def
|
|
449
|
-
|
|
450
|
-
when :rspec
|
|
451
|
-
test_files = config.spec_files.empty? ? nil : config.spec_files
|
|
452
|
-
Evilution::Integration::RSpec.new(test_files: test_files, hooks: @hooks)
|
|
453
|
-
else
|
|
482
|
+
def resolve_integration_class
|
|
483
|
+
INTEGRATIONS.fetch(config.integration) do
|
|
454
484
|
raise Evilution::Error, "unknown integration: #{config.integration}"
|
|
455
485
|
end
|
|
456
486
|
end
|
|
457
487
|
|
|
488
|
+
def build_integration
|
|
489
|
+
klass = resolve_integration_class
|
|
490
|
+
test_files = config.spec_files.empty? ? nil : config.spec_files
|
|
491
|
+
klass.new(test_files: test_files, hooks: @hooks)
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def build_neutralization_resolver
|
|
495
|
+
options = resolve_integration_class.baseline_options
|
|
496
|
+
options[:spec_resolver] || Evilution::SpecResolver.new
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def neutralization_fallback_dir
|
|
500
|
+
options = resolve_integration_class.baseline_options
|
|
501
|
+
options[:fallback_dir] || "spec"
|
|
502
|
+
end
|
|
503
|
+
|
|
458
504
|
def output_report(summary)
|
|
459
505
|
reporter = build_reporter
|
|
460
506
|
return unless reporter
|
|
@@ -577,11 +623,11 @@ class Evilution::Runner
|
|
|
577
623
|
def build_reporter
|
|
578
624
|
case config.format
|
|
579
625
|
when :json
|
|
580
|
-
Evilution::Reporter::JSON.new
|
|
626
|
+
Evilution::Reporter::JSON.new(integration: config.integration)
|
|
581
627
|
when :text
|
|
582
628
|
Evilution::Reporter::CLI.new
|
|
583
629
|
when :html
|
|
584
|
-
Evilution::Reporter::HTML.new(baseline: load_baseline_session)
|
|
630
|
+
Evilution::Reporter::HTML.new(baseline: load_baseline_session, integration: config.integration)
|
|
585
631
|
end
|
|
586
632
|
end
|
|
587
633
|
|
|
@@ -4,11 +4,17 @@ class Evilution::SpecResolver
|
|
|
4
4
|
STRIPPABLE_PREFIXES = %w[lib/ app/].freeze
|
|
5
5
|
CONTROLLER_PREFIX = "controllers/"
|
|
6
6
|
|
|
7
|
+
def initialize(test_dir: "spec", test_suffix: "_spec.rb", request_dir: "requests")
|
|
8
|
+
@test_dir = test_dir
|
|
9
|
+
@test_suffix = test_suffix
|
|
10
|
+
@request_dir = request_dir
|
|
11
|
+
end
|
|
12
|
+
|
|
7
13
|
def call(source_path)
|
|
8
14
|
return nil if source_path.nil? || source_path.empty?
|
|
9
15
|
|
|
10
16
|
normalized = normalize_path(source_path)
|
|
11
|
-
candidates =
|
|
17
|
+
candidates = candidate_test_paths(normalized)
|
|
12
18
|
candidates.find { |path| File.exist?(path) }
|
|
13
19
|
end
|
|
14
20
|
|
|
@@ -24,16 +30,16 @@ class Evilution::SpecResolver
|
|
|
24
30
|
path
|
|
25
31
|
end
|
|
26
32
|
|
|
27
|
-
def
|
|
28
|
-
base = source_path.sub(/\.rb\z/,
|
|
33
|
+
def candidate_test_paths(source_path)
|
|
34
|
+
base = source_path.sub(/\.rb\z/, @test_suffix)
|
|
29
35
|
prefix = STRIPPABLE_PREFIXES.find { |p| source_path.start_with?(p) }
|
|
30
36
|
|
|
31
37
|
candidates = if prefix
|
|
32
38
|
stripped = base.delete_prefix(prefix)
|
|
33
|
-
|
|
34
|
-
[
|
|
39
|
+
request_test = controller_to_request_test(stripped)
|
|
40
|
+
[request_test, "#{@test_dir}/#{stripped}", "#{@test_dir}/#{base}"].compact
|
|
35
41
|
else
|
|
36
|
-
["
|
|
42
|
+
["#{@test_dir}/#{base}"]
|
|
37
43
|
end
|
|
38
44
|
|
|
39
45
|
fallbacks = candidates.flat_map { |c| parent_fallback_candidates(c) }.uniq
|
|
@@ -41,33 +47,35 @@ class Evilution::SpecResolver
|
|
|
41
47
|
candidates + fallbacks
|
|
42
48
|
end
|
|
43
49
|
|
|
44
|
-
def
|
|
50
|
+
def controller_to_request_test(stripped_path)
|
|
45
51
|
return nil unless stripped_path.start_with?(CONTROLLER_PREFIX)
|
|
46
|
-
|
|
52
|
+
|
|
53
|
+
controller_suffix = "_controller#{@test_suffix}"
|
|
54
|
+
return nil unless stripped_path.end_with?(controller_suffix)
|
|
47
55
|
|
|
48
56
|
request_path = stripped_path
|
|
49
57
|
.delete_prefix(CONTROLLER_PREFIX)
|
|
50
|
-
.sub(
|
|
51
|
-
"
|
|
58
|
+
.sub(/#{Regexp.escape(controller_suffix)}\z/, @test_suffix)
|
|
59
|
+
"#{@test_dir}/#{@request_dir}/#{request_path}"
|
|
52
60
|
end
|
|
53
61
|
|
|
54
|
-
def parent_fallback_candidates(
|
|
55
|
-
parts =
|
|
62
|
+
def parent_fallback_candidates(test_path)
|
|
63
|
+
parts = test_path.split("/")
|
|
56
64
|
# parts: ["spec", "foo", "bar_spec.rb"] — need at least 3 parts for fallback
|
|
57
65
|
return [] if parts.length < 3
|
|
58
66
|
|
|
59
67
|
candidates = []
|
|
60
68
|
# Remove filename, then progressively remove directories
|
|
61
|
-
dir_parts = parts[1..-2]
|
|
69
|
+
dir_parts = parts[1..-2]
|
|
62
70
|
|
|
63
71
|
(dir_parts.length - 1).downto(0) do |i|
|
|
64
|
-
file = "#{dir_parts[i]}
|
|
72
|
+
file = "#{dir_parts[i]}#{@test_suffix}"
|
|
65
73
|
|
|
66
74
|
if i.zero?
|
|
67
|
-
candidates << "
|
|
75
|
+
candidates << "#{@test_dir}/#{file}"
|
|
68
76
|
else
|
|
69
77
|
parent = dir_parts[0...i].join("/")
|
|
70
|
-
candidates << "
|
|
78
|
+
candidates << "#{@test_dir}/#{parent}/#{file}"
|
|
71
79
|
end
|
|
72
80
|
end
|
|
73
81
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "monitor"
|
|
5
|
+
require_relative "version"
|
|
6
|
+
|
|
7
|
+
module Evilution::TempDirTracker
|
|
8
|
+
@dirs = Set.new
|
|
9
|
+
@monitor = Monitor.new
|
|
10
|
+
@at_exit_registered = false
|
|
11
|
+
|
|
12
|
+
def self.register(dir)
|
|
13
|
+
@monitor.synchronize do
|
|
14
|
+
@dirs << dir
|
|
15
|
+
register_at_exit unless @at_exit_registered
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.unregister(dir)
|
|
20
|
+
@monitor.synchronize { @dirs.delete(dir) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.cleanup_all
|
|
24
|
+
@monitor.synchronize do
|
|
25
|
+
@dirs.each { |d| FileUtils.rm_rf(d) }
|
|
26
|
+
@dirs.clear
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.tracked_dirs
|
|
31
|
+
@monitor.synchronize { @dirs.dup }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.register_at_exit
|
|
35
|
+
at_exit { cleanup_all }
|
|
36
|
+
@at_exit_registered = true
|
|
37
|
+
end
|
|
38
|
+
private_class_method :register_at_exit
|
|
39
|
+
end
|
data/lib/evilution/version.rb
CHANGED
data/lib/evilution.rb
CHANGED
|
@@ -42,9 +42,11 @@ require_relative "evilution/mutator/operator/collection_replacement"
|
|
|
42
42
|
require_relative "evilution/mutator/operator/method_call_removal"
|
|
43
43
|
require_relative "evilution/mutator/operator/argument_removal"
|
|
44
44
|
require_relative "evilution/mutator/operator/block_removal"
|
|
45
|
+
require_relative "evilution/mutator/operator/block_pass_removal"
|
|
45
46
|
require_relative "evilution/mutator/operator/conditional_flip"
|
|
46
47
|
require_relative "evilution/mutator/operator/range_replacement"
|
|
47
48
|
require_relative "evilution/mutator/operator/regexp_mutation"
|
|
49
|
+
require_relative "evilution/mutator/operator/regex_simplification"
|
|
48
50
|
require_relative "evilution/mutator/operator/receiver_replacement"
|
|
49
51
|
require_relative "evilution/mutator/operator/send_mutation"
|
|
50
52
|
require_relative "evilution/mutator/operator/argument_nil_substitution"
|
|
@@ -69,6 +71,7 @@ require_relative "evilution/mutator/operator/bitwise_replacement"
|
|
|
69
71
|
require_relative "evilution/mutator/operator/bitwise_complement"
|
|
70
72
|
require_relative "evilution/mutator/operator/zsuper_removal"
|
|
71
73
|
require_relative "evilution/mutator/operator/explicit_super_mutation"
|
|
74
|
+
require_relative "evilution/mutator/operator/index_to_at"
|
|
72
75
|
require_relative "evilution/mutator/operator/index_to_fetch"
|
|
73
76
|
require_relative "evilution/mutator/operator/index_to_dig"
|
|
74
77
|
require_relative "evilution/mutator/operator/index_assignment_removal"
|
|
@@ -106,6 +109,7 @@ require_relative "evilution/git/changed_files"
|
|
|
106
109
|
require_relative "evilution/integration"
|
|
107
110
|
require_relative "evilution/integration/base"
|
|
108
111
|
require_relative "evilution/integration/rspec"
|
|
112
|
+
require_relative "evilution/integration/minitest"
|
|
109
113
|
require_relative "evilution/result/mutation_result"
|
|
110
114
|
require_relative "evilution/result/summary"
|
|
111
115
|
require_relative "evilution/reporter"
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Mutation Density Benchmark
|
|
5
|
+
#
|
|
6
|
+
# Compares mutation counts between evilution and a reference mutation testing
|
|
7
|
+
# tool on a corpus of files.
|
|
8
|
+
# See docs/mutation_density_benchmark.md for methodology.
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# scripts/benchmark_density CONFIG_FILE [--verbose]
|
|
12
|
+
#
|
|
13
|
+
# The config file is a YAML file listing the benchmark corpus.
|
|
14
|
+
# See scripts/benchmark_density.yml for the format.
|
|
15
|
+
|
|
16
|
+
require "yaml"
|
|
17
|
+
require "open3"
|
|
18
|
+
require "optparse"
|
|
19
|
+
|
|
20
|
+
module BenchmarkDensity
|
|
21
|
+
class ConfigError < StandardError; end
|
|
22
|
+
|
|
23
|
+
class Config
|
|
24
|
+
attr_reader :project_root, :target_ratio, :reference_cmd, :files
|
|
25
|
+
|
|
26
|
+
def initialize(path)
|
|
27
|
+
data = YAML.safe_load_file(path, symbolize_names: true, permitted_classes: [Symbol])
|
|
28
|
+
raise ConfigError, "config file must contain a YAML mapping" unless data.is_a?(Hash)
|
|
29
|
+
|
|
30
|
+
@project_root = data[:project_root]
|
|
31
|
+
@target_ratio = data.key?(:target_ratio) ? data[:target_ratio] : 1.5
|
|
32
|
+
@reference_cmd = data.key?(:reference_cmd) ? data[:reference_cmd] : []
|
|
33
|
+
@files = data.key?(:files) ? data[:files] : []
|
|
34
|
+
|
|
35
|
+
validate!
|
|
36
|
+
rescue Psych::Exception => e
|
|
37
|
+
raise ConfigError, "failed to parse config file #{path}: #{e.message}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def validate!
|
|
43
|
+
raise ConfigError, "project_root is required in config" if @project_root.nil?
|
|
44
|
+
raise ConfigError, "project_root does not exist: #{@project_root}" unless Dir.exist?(@project_root)
|
|
45
|
+
raise ConfigError, "reference_cmd is required in config" if @reference_cmd.empty?
|
|
46
|
+
raise ConfigError, "files list is empty — add benchmark files to the config" if @files.empty?
|
|
47
|
+
|
|
48
|
+
@files.each_with_index { |entry, index| validate_file_entry!(entry, index) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def validate_file_entry!(entry, index)
|
|
52
|
+
raise ConfigError, "files[#{index}] must be a mapping" unless entry.is_a?(Hash)
|
|
53
|
+
|
|
54
|
+
path = entry[:path]
|
|
55
|
+
target = entry[:reference_target]
|
|
56
|
+
raise ConfigError, "files[#{index}] is missing required path" if path.nil? || path.to_s.strip.empty?
|
|
57
|
+
raise ConfigError, "files[#{index}] is missing required reference_target" if target.nil? || target.to_s.strip.empty?
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
class EvilutionCounter
|
|
62
|
+
def initialize(project_root)
|
|
63
|
+
@project_root = project_root
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def count_mutations(file_path)
|
|
67
|
+
full_path = File.join(@project_root, file_path)
|
|
68
|
+
cmd = ["bundle", "exec", "evilution", "subjects", full_path]
|
|
69
|
+
stdout, stderr, status = Open3.capture3(*cmd, chdir: @project_root)
|
|
70
|
+
|
|
71
|
+
unless status.success?
|
|
72
|
+
warn " evilution failed for #{file_path}: #{stderr.strip}"
|
|
73
|
+
return nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
count = parse_subject_count(stdout)
|
|
77
|
+
if count.nil?
|
|
78
|
+
warn " evilution produced unparseable output for #{file_path}: #{stdout.lines.first&.strip}"
|
|
79
|
+
return nil
|
|
80
|
+
end
|
|
81
|
+
count
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def parse_subject_count(output)
|
|
87
|
+
match = output.match(/(\d+)\s+subjects,\s+(\d+)\s+mutations/)
|
|
88
|
+
match ? match[2].to_i : nil
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
class ReferenceCounter
|
|
93
|
+
def initialize(project_root, reference_cmd)
|
|
94
|
+
@project_root = project_root
|
|
95
|
+
@reference_cmd = reference_cmd
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def count_mutations(reference_target)
|
|
99
|
+
cmd = @reference_cmd + [reference_target]
|
|
100
|
+
stdout, stderr, _status = Open3.capture3(*cmd, chdir: @project_root)
|
|
101
|
+
|
|
102
|
+
# Reference tool may exit non-zero when mutants survive, which is expected.
|
|
103
|
+
# Only treat it as failure if there's no parseable output.
|
|
104
|
+
count = parse_mutation_count(stdout)
|
|
105
|
+
if count.nil?
|
|
106
|
+
warn " reference tool failed for #{reference_target}: #{stderr.lines.first&.strip}"
|
|
107
|
+
return nil
|
|
108
|
+
end
|
|
109
|
+
count
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def parse_mutation_count(output)
|
|
115
|
+
match = output.match(/mutations:\s*(\d+)/i)
|
|
116
|
+
match ? match[1].to_i : nil
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
class Runner
|
|
121
|
+
HEADER_FMT = "%-45<file>s %10<evilution>s %10<reference>s %8<ratio>s"
|
|
122
|
+
ROW_FMT = "%-45<file>s %10<evilution>s %10<reference>s %8<ratio>s"
|
|
123
|
+
TOTAL_FMT = "%-45<file>s %10<evilution>d %10<reference>d %7.2<ratio>fx"
|
|
124
|
+
|
|
125
|
+
def initialize(config:, verbose: false)
|
|
126
|
+
@config = config
|
|
127
|
+
@verbose = verbose
|
|
128
|
+
@evilution = EvilutionCounter.new(config.project_root)
|
|
129
|
+
@reference = ReferenceCounter.new(config.project_root, config.reference_cmd)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def call
|
|
133
|
+
results = collect_results
|
|
134
|
+
print_table(results)
|
|
135
|
+
print_summary(results)
|
|
136
|
+
passing?(results) ? 0 : 1
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def collect_results
|
|
142
|
+
@config.files.map do |entry|
|
|
143
|
+
path = entry[:path]
|
|
144
|
+
reference_target = entry[:reference_target]
|
|
145
|
+
|
|
146
|
+
warn " Measuring #{path}..." if @verbose
|
|
147
|
+
ev_count = @evilution.count_mutations(path)
|
|
148
|
+
ref_count = @reference.count_mutations(reference_target)
|
|
149
|
+
warn " done" if @verbose
|
|
150
|
+
|
|
151
|
+
{ path: path, evilution: ev_count, reference: ref_count }
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def print_table(results)
|
|
156
|
+
puts format(HEADER_FMT, file: "File", evilution: "Evilution", reference: "Reference", ratio: "Ratio")
|
|
157
|
+
puts "-" * 75
|
|
158
|
+
|
|
159
|
+
results.each do |r|
|
|
160
|
+
ratio = compute_ratio(r[:evilution], r[:reference])
|
|
161
|
+
ratio_str = ratio ? format("%.2fx", ratio) : "N/A"
|
|
162
|
+
ev_str = r[:evilution]&.to_s || "ERR"
|
|
163
|
+
ref_str = r[:reference]&.to_s || "ERR"
|
|
164
|
+
puts format(ROW_FMT, file: r[:path], evilution: ev_str, reference: ref_str, ratio: ratio_str)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
puts "-" * 75
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def print_summary(results)
|
|
171
|
+
totals = compute_totals(results)
|
|
172
|
+
print_total_line(totals)
|
|
173
|
+
puts ""
|
|
174
|
+
print_status_line(totals)
|
|
175
|
+
print_error_warning(totals[:error_count])
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def print_total_line(totals)
|
|
179
|
+
if totals[:invalid]
|
|
180
|
+
puts format(ROW_FMT,
|
|
181
|
+
file: "TOTAL", evilution: totals[:ev].to_s, reference: totals[:ref].to_s, ratio: "N/A")
|
|
182
|
+
else
|
|
183
|
+
puts format(TOTAL_FMT,
|
|
184
|
+
file: "TOTAL", evilution: totals[:ev], reference: totals[:ref], ratio: totals[:display_ratio])
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def compute_totals(results)
|
|
189
|
+
valid, errored = partition_results(results)
|
|
190
|
+
ev_total = valid.sum { |r| r[:evilution] }
|
|
191
|
+
ref_total = valid.sum { |r| r[:reference] }
|
|
192
|
+
invalid = errored.any? || ev_total.zero?
|
|
193
|
+
ratio = ev_total.positive? ? ref_total.to_f / ev_total : 0.0
|
|
194
|
+
|
|
195
|
+
{ ev: ev_total, ref: ref_total, ratio: ratio, display_ratio: ratio.round(2),
|
|
196
|
+
invalid: invalid, error_count: errored.length }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def partition_results(results)
|
|
200
|
+
valid = results.select { |r| r[:evilution] && r[:reference] }
|
|
201
|
+
errored = results - valid
|
|
202
|
+
[valid, errored]
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def print_status_line(totals)
|
|
206
|
+
target = @config.target_ratio
|
|
207
|
+
if totals[:invalid]
|
|
208
|
+
puts "Density ratio: INVALID (target: < #{target}x) — FAIL"
|
|
209
|
+
return
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
status = totals[:ratio] < target ? "PASS" : "FAIL"
|
|
213
|
+
puts "Density ratio: #{totals[:display_ratio]}x (target: < #{target}x) — #{status}"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def print_error_warning(error_count)
|
|
217
|
+
puts "WARNING: #{error_count} file(s) had errors — benchmark marked invalid" if error_count.positive?
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def passing?(results)
|
|
221
|
+
totals = compute_totals(results)
|
|
222
|
+
return false if totals[:invalid]
|
|
223
|
+
|
|
224
|
+
totals[:ratio] < @config.target_ratio
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def compute_ratio(evilution, reference)
|
|
228
|
+
return nil if evilution.nil? || reference.nil? || evilution.zero?
|
|
229
|
+
|
|
230
|
+
(reference.to_f / evilution).round(2)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
if __FILE__ == $PROGRAM_NAME
|
|
236
|
+
verbose = false
|
|
237
|
+
parser = OptionParser.new do |opts|
|
|
238
|
+
opts.banner = "Usage: #{$PROGRAM_NAME} CONFIG_FILE [--verbose]"
|
|
239
|
+
opts.on("-v", "--verbose", "Show progress") { verbose = true }
|
|
240
|
+
opts.on("-h", "--help", "Show help") do
|
|
241
|
+
puts opts
|
|
242
|
+
exit 0
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
parser.parse!
|
|
246
|
+
|
|
247
|
+
config_path = ARGV.first
|
|
248
|
+
unless config_path
|
|
249
|
+
warn parser.banner
|
|
250
|
+
exit 2
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
begin
|
|
254
|
+
config = BenchmarkDensity::Config.new(config_path)
|
|
255
|
+
runner = BenchmarkDensity::Runner.new(config: config, verbose: verbose)
|
|
256
|
+
exit runner.call
|
|
257
|
+
rescue BenchmarkDensity::ConfigError => e
|
|
258
|
+
warn "Config error: #{e.message}"
|
|
259
|
+
exit 2
|
|
260
|
+
end
|
|
261
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Benchmark corpus configuration
|
|
2
|
+
#
|
|
3
|
+
# See docs/mutation_density_benchmark.md for selection criteria and methodology.
|
|
4
|
+
#
|
|
5
|
+
# Example:
|
|
6
|
+
#
|
|
7
|
+
# project_root: /path/to/rails/app
|
|
8
|
+
# target_ratio: 1.5
|
|
9
|
+
# reference_cmd: ["bundle", "exec", "mutant", "run", "--integration", "rspec", "--fail-fast"]
|
|
10
|
+
# files:
|
|
11
|
+
# - path: app/models/user.rb
|
|
12
|
+
# reference_target: "User"
|
|
13
|
+
# - path: app/services/payment_processor.rb
|
|
14
|
+
# reference_target: "PaymentProcessor"
|
|
15
|
+
|
|
16
|
+
project_root: null # Set to the absolute path of the target project
|
|
17
|
+
target_ratio: 1.5
|
|
18
|
+
reference_cmd: [] # Command prefix for the reference tool
|
|
19
|
+
files: []
|