evilution 0.21.0 → 0.22.1
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/interactions.jsonl +16 -0
- data/.beads/issues.jsonl +9 -6
- data/.claude/settings.json +5 -0
- data/CHANGELOG.md +35 -0
- data/README.md +28 -13
- 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 +2 -1
- data/lib/evilution/config.rb +15 -3
- data/lib/evilution/disable_comment.rb +2 -1
- data/lib/evilution/integration/base.rb +124 -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 -100
- data/lib/evilution/isolation/fork.rb +11 -3
- data/lib/evilution/isolation/in_process.rb +12 -3
- data/lib/evilution/mcp/mutate_tool.rb +6 -6
- data/lib/evilution/mutator/base.rb +4 -0
- 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 +2 -2
- 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/symbol_literal.rb +9 -0
- data/lib/evilution/mutator/registry.rb +3 -0
- data/lib/evilution/reporter/cli.rb +19 -0
- data/lib/evilution/reporter/html.rb +12 -3
- data/lib/evilution/reporter/json.rb +14 -3
- data/lib/evilution/reporter/suggestion.rb +659 -2
- data/lib/evilution/result/mutation_result.rb +9 -2
- data/lib/evilution/runner.rb +56 -17
- data/lib/evilution/spec_resolver.rb +24 -16
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +4 -0
- data/script/memory_check +5 -5
- 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
|
@@ -6,11 +6,15 @@ class Evilution::Result::MutationResult
|
|
|
6
6
|
STATUSES = %i[killed survived timeout error neutral equivalent].freeze
|
|
7
7
|
|
|
8
8
|
attr_reader :mutation, :status, :duration, :killing_test, :test_command,
|
|
9
|
-
:child_rss_kb, :memory_delta_kb, :parent_rss_kb
|
|
9
|
+
:child_rss_kb, :memory_delta_kb, :parent_rss_kb,
|
|
10
|
+
:error_message, :error_class, :error_backtrace
|
|
10
11
|
|
|
12
|
+
# rubocop:disable Metrics/ParameterLists
|
|
11
13
|
def initialize(mutation:, status:, duration: 0.0, killing_test: nil,
|
|
12
14
|
test_command: nil, child_rss_kb: nil, memory_delta_kb: nil,
|
|
13
|
-
parent_rss_kb: nil
|
|
15
|
+
parent_rss_kb: nil, error_message: nil, error_class: nil,
|
|
16
|
+
error_backtrace: nil)
|
|
17
|
+
# rubocop:enable Metrics/ParameterLists
|
|
14
18
|
raise ArgumentError, "invalid status: #{status}" unless STATUSES.include?(status)
|
|
15
19
|
|
|
16
20
|
@mutation = mutation
|
|
@@ -21,6 +25,9 @@ class Evilution::Result::MutationResult
|
|
|
21
25
|
@child_rss_kb = child_rss_kb
|
|
22
26
|
@memory_delta_kb = memory_delta_kb
|
|
23
27
|
@parent_rss_kb = parent_rss_kb
|
|
28
|
+
@error_message = error_message
|
|
29
|
+
@error_class = error_class
|
|
30
|
+
@error_backtrace = error_backtrace.nil? ? nil : error_backtrace.dup.freeze
|
|
24
31
|
freeze
|
|
25
32
|
end
|
|
26
33
|
|
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"
|
|
@@ -26,6 +27,11 @@ require_relative "disable_comment"
|
|
|
26
27
|
require_relative "ast/sorbet_sig_detector"
|
|
27
28
|
|
|
28
29
|
class Evilution::Runner
|
|
30
|
+
INTEGRATIONS = {
|
|
31
|
+
rspec: Evilution::Integration::RSpec,
|
|
32
|
+
minitest: Evilution::Integration::Minitest
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
29
35
|
attr_reader :config
|
|
30
36
|
|
|
31
37
|
def initialize(config: Evilution::Config.new, on_result: nil, hooks: nil)
|
|
@@ -286,7 +292,8 @@ class Evilution::Runner
|
|
|
286
292
|
return nil unless config.baseline? && subjects.any?
|
|
287
293
|
|
|
288
294
|
log_baseline_start
|
|
289
|
-
|
|
295
|
+
integration_class = resolve_integration_class
|
|
296
|
+
baseline = Evilution::Baseline.new(timeout: config.timeout, **integration_class.baseline_options)
|
|
290
297
|
result = baseline.call(subjects)
|
|
291
298
|
log_baseline_complete(result)
|
|
292
299
|
result
|
|
@@ -305,7 +312,7 @@ class Evilution::Runner
|
|
|
305
312
|
|
|
306
313
|
def run_mutations_sequential(mutations, baseline_result = nil)
|
|
307
314
|
integration = build_integration
|
|
308
|
-
spec_resolver = baseline_result&.failed? ?
|
|
315
|
+
spec_resolver = baseline_result&.failed? ? build_neutralization_resolver : nil
|
|
309
316
|
results = []
|
|
310
317
|
survived_count = 0
|
|
311
318
|
truncated = false
|
|
@@ -334,7 +341,7 @@ class Evilution::Runner
|
|
|
334
341
|
integration = build_integration
|
|
335
342
|
pool = Evilution::Parallel::Pool.new(size: config.jobs, hooks: @hooks, item_timeout: config.timeout ? config.timeout * 2 : nil)
|
|
336
343
|
worker_isolator = Evilution::Isolation::InProcess.new
|
|
337
|
-
spec_resolver = baseline_result&.failed? ?
|
|
344
|
+
spec_resolver = baseline_result&.failed? ? build_neutralization_resolver : nil
|
|
338
345
|
state = { results: [], survived_count: 0, truncated: false, completed: 0 }
|
|
339
346
|
|
|
340
347
|
all_worker_stats = []
|
|
@@ -392,7 +399,7 @@ class Evilution::Runner
|
|
|
392
399
|
if config.spec_files.any?
|
|
393
400
|
neutralize = true
|
|
394
401
|
else
|
|
395
|
-
spec_file = spec_resolver.call(result.mutation.file_path) ||
|
|
402
|
+
spec_file = spec_resolver.call(result.mutation.file_path) || neutralization_fallback_dir
|
|
396
403
|
neutralize = baseline_result.failed_spec_files.include?(spec_file)
|
|
397
404
|
end
|
|
398
405
|
return result unless neutralize
|
|
@@ -404,7 +411,10 @@ class Evilution::Runner
|
|
|
404
411
|
test_command: result.test_command,
|
|
405
412
|
child_rss_kb: result.child_rss_kb,
|
|
406
413
|
memory_delta_kb: result.memory_delta_kb,
|
|
407
|
-
parent_rss_kb: result.parent_rss_kb
|
|
414
|
+
parent_rss_kb: result.parent_rss_kb,
|
|
415
|
+
error_message: result.error_message,
|
|
416
|
+
error_class: result.error_class,
|
|
417
|
+
error_backtrace: result.error_backtrace
|
|
408
418
|
)
|
|
409
419
|
end
|
|
410
420
|
|
|
@@ -416,7 +426,10 @@ class Evilution::Runner
|
|
|
416
426
|
test_command: result.test_command,
|
|
417
427
|
child_rss_kb: result.child_rss_kb,
|
|
418
428
|
memory_delta_kb: result.memory_delta_kb,
|
|
419
|
-
parent_rss_kb: result.parent_rss_kb
|
|
429
|
+
parent_rss_kb: result.parent_rss_kb,
|
|
430
|
+
error_message: result.error_message,
|
|
431
|
+
error_class: result.error_class,
|
|
432
|
+
error_backtrace: result.error_backtrace
|
|
420
433
|
}
|
|
421
434
|
end
|
|
422
435
|
|
|
@@ -430,7 +443,10 @@ class Evilution::Runner
|
|
|
430
443
|
test_command: data[:test_command],
|
|
431
444
|
child_rss_kb: data[:child_rss_kb],
|
|
432
445
|
memory_delta_kb: data[:memory_delta_kb],
|
|
433
|
-
parent_rss_kb: data[:parent_rss_kb]
|
|
446
|
+
parent_rss_kb: data[:parent_rss_kb],
|
|
447
|
+
error_message: data[:error_message],
|
|
448
|
+
error_class: data[:error_class],
|
|
449
|
+
error_backtrace: data[:error_backtrace]
|
|
434
450
|
)
|
|
435
451
|
end
|
|
436
452
|
end
|
|
@@ -472,16 +488,28 @@ class Evilution::Runner
|
|
|
472
488
|
:in_process
|
|
473
489
|
end
|
|
474
490
|
|
|
475
|
-
def
|
|
476
|
-
|
|
477
|
-
when :rspec
|
|
478
|
-
test_files = config.spec_files.empty? ? nil : config.spec_files
|
|
479
|
-
Evilution::Integration::RSpec.new(test_files: test_files, hooks: @hooks)
|
|
480
|
-
else
|
|
491
|
+
def resolve_integration_class
|
|
492
|
+
INTEGRATIONS.fetch(config.integration) do
|
|
481
493
|
raise Evilution::Error, "unknown integration: #{config.integration}"
|
|
482
494
|
end
|
|
483
495
|
end
|
|
484
496
|
|
|
497
|
+
def build_integration
|
|
498
|
+
klass = resolve_integration_class
|
|
499
|
+
test_files = config.spec_files.empty? ? nil : config.spec_files
|
|
500
|
+
klass.new(test_files: test_files, hooks: @hooks)
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def build_neutralization_resolver
|
|
504
|
+
options = resolve_integration_class.baseline_options
|
|
505
|
+
options[:spec_resolver] || Evilution::SpecResolver.new
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def neutralization_fallback_dir
|
|
509
|
+
options = resolve_integration_class.baseline_options
|
|
510
|
+
options[:fallback_dir] || "spec"
|
|
511
|
+
end
|
|
512
|
+
|
|
485
513
|
def output_report(summary)
|
|
486
514
|
reporter = build_reporter
|
|
487
515
|
return unless reporter
|
|
@@ -543,9 +571,20 @@ class Evilution::Runner
|
|
|
543
571
|
|
|
544
572
|
parts << gc_stats_string
|
|
545
573
|
|
|
546
|
-
|
|
574
|
+
$stderr.write("[verbose] #{result.mutation}: #{parts.join(", ")}\n") unless parts.empty?
|
|
547
575
|
|
|
548
|
-
|
|
576
|
+
log_mutation_error(result) if result.error?
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def log_mutation_error(result)
|
|
580
|
+
header = "[verbose] #{result.mutation}: error"
|
|
581
|
+
header += " #{result.error_class}" if result.error_class
|
|
582
|
+
header += ": #{result.error_message}" if result.error_message
|
|
583
|
+
$stderr.write("#{header}\n")
|
|
584
|
+
|
|
585
|
+
Array(result.error_backtrace).first(5).each do |line|
|
|
586
|
+
$stderr.write("[verbose] #{line}\n")
|
|
587
|
+
end
|
|
549
588
|
end
|
|
550
589
|
|
|
551
590
|
def gc_stats_string
|
|
@@ -604,11 +643,11 @@ class Evilution::Runner
|
|
|
604
643
|
def build_reporter
|
|
605
644
|
case config.format
|
|
606
645
|
when :json
|
|
607
|
-
Evilution::Reporter::JSON.new
|
|
646
|
+
Evilution::Reporter::JSON.new(integration: config.integration)
|
|
608
647
|
when :text
|
|
609
648
|
Evilution::Reporter::CLI.new
|
|
610
649
|
when :html
|
|
611
|
-
Evilution::Reporter::HTML.new(baseline: load_baseline_session)
|
|
650
|
+
Evilution::Reporter::HTML.new(baseline: load_baseline_session, integration: config.integration)
|
|
612
651
|
end
|
|
613
652
|
end
|
|
614
653
|
|
|
@@ -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
|
|
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"
|
data/script/memory_check
CHANGED
|
@@ -104,12 +104,12 @@ complex_mutations = complex_subjects.flat_map { |s| complex_registry.mutations_f
|
|
|
104
104
|
|
|
105
105
|
integration = Evilution::Integration::RSpec.new(test_files: [COMPLEX_FIXTURE_SPEC])
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
# Budget is generous: per-mutation require() adds the file to $LOADED_FEATURES and
|
|
108
|
+
# accumulates constant-redefinition warnings. Local runs land around 20-25 MB; CI
|
|
109
|
+
# varies up to ~30 MB depending on Ruby build and GC pressure, so we leave headroom.
|
|
110
|
+
all_passed &= run_check("RSpec integration per-mutation (Config)", iterations: 20, max_growth_kb: 40_960) do
|
|
108
111
|
mutation = complex_mutations.sample
|
|
109
|
-
|
|
110
|
-
raise "RSpec integration memory check failed: #{result[:error]}" if result[:error]
|
|
111
|
-
|
|
112
|
-
result
|
|
112
|
+
integration.call(mutation)
|
|
113
113
|
end
|
|
114
114
|
|
|
115
115
|
puts all_passed ? "All memory checks passed." : "Some memory checks failed!"
|
|
@@ -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: []
|