evilution 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +210 -0
  3. data/CHANGELOG.md +51 -0
  4. data/README.md +81 -4
  5. data/exe/evil +6 -0
  6. data/lib/evilution/ast/source_surgeon.rb +15 -1
  7. data/lib/evilution/cli/commands/compare.rb +68 -0
  8. data/lib/evilution/cli/parser/command_extractor.rb +78 -0
  9. data/lib/evilution/cli/parser/file_args.rb +41 -0
  10. data/lib/evilution/cli/parser/options_builder.rb +123 -0
  11. data/lib/evilution/cli/parser/stdin_reader.rb +28 -0
  12. data/lib/evilution/cli/parser.rb +27 -196
  13. data/lib/evilution/cli/printers/compare.rb +159 -0
  14. data/lib/evilution/cli.rb +1 -0
  15. data/lib/evilution/compare/categorizer.rb +109 -0
  16. data/lib/evilution/compare/detector.rb +21 -0
  17. data/lib/evilution/compare/fingerprint.rb +83 -0
  18. data/lib/evilution/compare/normalizer.rb +106 -0
  19. data/lib/evilution/compare/record.rb +16 -0
  20. data/lib/evilution/compare.rb +15 -0
  21. data/lib/evilution/config.rb +178 -3
  22. data/lib/evilution/example_filter.rb +143 -0
  23. data/lib/evilution/integration/base.rb +11 -57
  24. data/lib/evilution/integration/crash_detector.rb +5 -2
  25. data/lib/evilution/integration/minitest.rb +25 -7
  26. data/lib/evilution/integration/minitest_crash_detector.rb +5 -2
  27. data/lib/evilution/integration/rspec.rb +99 -12
  28. data/lib/evilution/isolation/fork.rb +26 -0
  29. data/lib/evilution/isolation/in_process.rb +1 -0
  30. data/lib/evilution/mcp/info_tool.rb +77 -5
  31. data/lib/evilution/mcp/mutate_tool/config_builder.rb +20 -0
  32. data/lib/evilution/mcp/mutate_tool/error_payload.rb +17 -0
  33. data/lib/evilution/mcp/mutate_tool/option_parser.rb +54 -0
  34. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +37 -0
  35. data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +31 -0
  36. data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +52 -0
  37. data/lib/evilution/mcp/mutate_tool.rb +34 -186
  38. data/lib/evilution/mutation.rb +43 -3
  39. data/lib/evilution/mutator/base.rb +39 -1
  40. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +5 -1
  41. data/lib/evilution/mutator/operator/argument_removal.rb +5 -1
  42. data/lib/evilution/parallel/work_queue.rb +149 -31
  43. data/lib/evilution/parallel_db_warning.rb +68 -0
  44. data/lib/evilution/reporter/cli.rb +38 -11
  45. data/lib/evilution/reporter/html/assets/style.css +85 -0
  46. data/lib/evilution/reporter/html/baseline_keys.rb +28 -0
  47. data/lib/evilution/reporter/html/diff_formatter.rb +27 -0
  48. data/lib/evilution/reporter/html/escape.rb +12 -0
  49. data/lib/evilution/reporter/html/namespace.rb +11 -0
  50. data/lib/evilution/reporter/html/report.rb +68 -0
  51. data/lib/evilution/reporter/html/section.rb +21 -0
  52. data/lib/evilution/reporter/html/sections/baseline_comparison.rb +46 -0
  53. data/lib/evilution/reporter/html/sections/error_details.rb +30 -0
  54. data/lib/evilution/reporter/html/sections/error_entry.rb +22 -0
  55. data/lib/evilution/reporter/html/sections/file_section.rb +62 -0
  56. data/lib/evilution/reporter/html/sections/header.rb +29 -0
  57. data/lib/evilution/reporter/html/sections/mutation_map.rb +32 -0
  58. data/lib/evilution/reporter/html/sections/neutral_details.rb +25 -0
  59. data/lib/evilution/reporter/html/sections/summary_cards.rb +11 -0
  60. data/lib/evilution/reporter/html/sections/survived_details.rb +35 -0
  61. data/lib/evilution/reporter/html/sections/survived_entry.rb +36 -0
  62. data/lib/evilution/reporter/html/sections/truncation_notice.rb +17 -0
  63. data/lib/evilution/reporter/html/sections/unparseable_details.rb +25 -0
  64. data/lib/evilution/reporter/html/sections/unresolved_details.rb +25 -0
  65. data/lib/evilution/reporter/html/sections.rb +4 -0
  66. data/lib/evilution/reporter/html/stylesheet.rb +14 -0
  67. data/lib/evilution/reporter/html/templates/baseline_comparison.html.erb +8 -0
  68. data/lib/evilution/reporter/html/templates/error_details.html.erb +6 -0
  69. data/lib/evilution/reporter/html/templates/error_entry.html.erb +10 -0
  70. data/lib/evilution/reporter/html/templates/file_section.html.erb +12 -0
  71. data/lib/evilution/reporter/html/templates/header.html.erb +4 -0
  72. data/lib/evilution/reporter/html/templates/mutation_map.html.erb +6 -0
  73. data/lib/evilution/reporter/html/templates/neutral_details.html.erb +14 -0
  74. data/lib/evilution/reporter/html/templates/report.html.erb +17 -0
  75. data/lib/evilution/reporter/html/templates/summary_cards.html.erb +26 -0
  76. data/lib/evilution/reporter/html/templates/survived_details.html.erb +21 -0
  77. data/lib/evilution/reporter/html/templates/survived_entry.html.erb +8 -0
  78. data/lib/evilution/reporter/html/templates/truncation_notice.html.erb +1 -0
  79. data/lib/evilution/reporter/html/templates/unparseable_details.html.erb +11 -0
  80. data/lib/evilution/reporter/html/templates/unresolved_details.html.erb +11 -0
  81. data/lib/evilution/reporter/html.rb +11 -390
  82. data/lib/evilution/reporter/json.rb +19 -9
  83. data/lib/evilution/reporter/suggestion/diff_helpers.rb +28 -0
  84. data/lib/evilution/reporter/suggestion/registry.rb +64 -0
  85. data/lib/evilution/reporter/suggestion/templates/generic.rb +55 -0
  86. data/lib/evilution/reporter/suggestion/templates/minitest.rb +659 -0
  87. data/lib/evilution/reporter/suggestion/templates/rspec.rb +613 -0
  88. data/lib/evilution/reporter/suggestion.rb +8 -1327
  89. data/lib/evilution/result/mutation_result.rb +9 -1
  90. data/lib/evilution/result/summary.rb +21 -1
  91. data/lib/evilution/runner/baseline_runner.rb +92 -0
  92. data/lib/evilution/runner/diagnostics.rb +105 -0
  93. data/lib/evilution/runner/isolation_resolver.rb +134 -0
  94. data/lib/evilution/runner/mutation_executor.rb +325 -0
  95. data/lib/evilution/runner/mutation_planner.rb +126 -0
  96. data/lib/evilution/runner/report_publisher.rb +60 -0
  97. data/lib/evilution/runner/subject_pipeline.rb +121 -0
  98. data/lib/evilution/runner.rb +61 -692
  99. data/lib/evilution/source_ast_cache.rb +39 -0
  100. data/lib/evilution/spec_ast_cache.rb +166 -0
  101. data/lib/evilution/spec_resolver.rb +6 -1
  102. data/lib/evilution/spec_selector.rb +39 -0
  103. data/lib/evilution/temp_dir_tracker.rb +23 -3
  104. data/lib/evilution/version.rb +1 -1
  105. data/script/memory_check +7 -5
  106. metadata +75 -2
@@ -3,7 +3,7 @@
3
3
  require_relative "../result"
4
4
 
5
5
  class Evilution::Result::MutationResult
6
- STATUSES = %i[killed survived timeout error neutral equivalent].freeze
6
+ STATUSES = %i[killed survived timeout error neutral equivalent unresolved unparseable].freeze
7
7
 
8
8
  attr_reader :mutation, :status, :duration, :killing_test, :test_command,
9
9
  :child_rss_kb, :memory_delta_kb, :parent_rss_kb,
@@ -54,4 +54,12 @@ class Evilution::Result::MutationResult
54
54
  def equivalent?
55
55
  status == :equivalent
56
56
  end
57
+
58
+ def unresolved?
59
+ status == :unresolved
60
+ end
61
+
62
+ def unparseable?
63
+ status == :unparseable
64
+ end
57
65
  end
@@ -47,8 +47,20 @@ class Evilution::Result::Summary
47
47
  results.count(&:equivalent?)
48
48
  end
49
49
 
50
+ def unresolved
51
+ results.count(&:unresolved?)
52
+ end
53
+
54
+ def unparseable
55
+ results.count(&:unparseable?)
56
+ end
57
+
58
+ def score_denominator
59
+ total - errors - neutral - equivalent - unresolved - unparseable
60
+ end
61
+
50
62
  def score
51
- denominator = total - errors - neutral - equivalent
63
+ denominator = score_denominator
52
64
  return 0.0 if denominator.zero?
53
65
 
54
66
  killed.to_f / denominator
@@ -74,6 +86,14 @@ class Evilution::Result::Summary
74
86
  results.select(&:equivalent?)
75
87
  end
76
88
 
89
+ def unresolved_results
90
+ results.select(&:unresolved?)
91
+ end
92
+
93
+ def unparseable_results
94
+ results.select(&:unparseable?)
95
+ end
96
+
77
97
  def coverage_gaps
78
98
  Evilution::Result::CoverageGapGrouper.new.call(survived_results)
79
99
  end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../baseline"
4
+ require_relative "../spec_resolver"
5
+ require_relative "../integration/rspec"
6
+ require_relative "../integration/minitest"
7
+ require_relative "../example_filter"
8
+ require_relative "../spec_ast_cache"
9
+ require_relative "../source_ast_cache"
10
+
11
+ class Evilution::Runner; end unless defined?(Evilution::Runner) # rubocop:disable Lint/EmptyClass
12
+
13
+ unless defined?(Evilution::Runner::INTEGRATIONS)
14
+ Evilution::Runner::INTEGRATIONS = {
15
+ rspec: Evilution::Integration::RSpec,
16
+ minitest: Evilution::Integration::Minitest
17
+ }.freeze
18
+ end
19
+
20
+ class Evilution::Runner::BaselineRunner
21
+ def initialize(config, hooks: nil)
22
+ @config = config
23
+ @hooks = hooks
24
+ end
25
+
26
+ def integration_class
27
+ @integration_class ||= Evilution::Runner::INTEGRATIONS.fetch(config.integration) do
28
+ raise Evilution::Error, "unknown integration: #{config.integration}"
29
+ end
30
+ end
31
+
32
+ def build_integration
33
+ klass = integration_class
34
+ test_files = config.spec_files.empty? ? nil : config.spec_files
35
+ kwargs = {
36
+ test_files: test_files,
37
+ hooks: hooks,
38
+ fallback_to_full_suite: config.fallback_to_full_suite?,
39
+ spec_selector: config.spec_selector
40
+ }
41
+ if klass == Evilution::Integration::RSpec
42
+ kwargs[:related_specs_heuristic] = config.related_specs_heuristic?
43
+ kwargs[:example_filter] = build_example_filter
44
+ end
45
+ klass.new(**kwargs)
46
+ end
47
+
48
+ def call(subjects)
49
+ return nil unless config.baseline? && subjects.any?
50
+
51
+ log_start
52
+ baseline = Evilution::Baseline.new(timeout: config.timeout, **integration_class.baseline_options)
53
+ result = baseline.call(subjects)
54
+ log_complete(result)
55
+ result
56
+ end
57
+
58
+ def neutralization_resolver
59
+ integration_class.baseline_options[:spec_resolver] || Evilution::SpecResolver.new
60
+ end
61
+
62
+ def neutralization_fallback_dir
63
+ integration_class.baseline_options[:fallback_dir] || "spec"
64
+ end
65
+
66
+ private
67
+
68
+ attr_reader :config, :hooks
69
+
70
+ def build_example_filter
71
+ return nil unless config.example_targeting?
72
+
73
+ Evilution::ExampleFilter.new(
74
+ cache: Evilution::SpecAstCache.new(**config.example_targeting_cache),
75
+ fallback: config.example_targeting_fallback,
76
+ source_cache: Evilution::SourceAstCache.new
77
+ )
78
+ end
79
+
80
+ def log_start
81
+ return if config.quiet || !config.text? || !$stderr.tty?
82
+
83
+ $stderr.write("Running baseline test suite...\n")
84
+ end
85
+
86
+ def log_complete(result)
87
+ return if config.quiet || !config.text? || !$stderr.tty?
88
+
89
+ count = result.failed_spec_files.size
90
+ $stderr.write("Baseline complete: #{count} failing spec file#{"s" unless count == 1}\n")
91
+ end
92
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../memory"
4
+ require_relative "../parallel/pool"
5
+
6
+ class Evilution::Runner; end unless defined?(Evilution::Runner) # rubocop:disable Lint/EmptyClass
7
+
8
+ class Evilution::Runner::Diagnostics
9
+ def initialize(config, stderr: $stderr)
10
+ @config = config
11
+ @stderr = stderr
12
+ end
13
+
14
+ def log_memory(phase, context = nil)
15
+ return unless verbose?
16
+
17
+ rss = Evilution::Memory.rss_mb
18
+ return unless rss
19
+
20
+ gc = gc_stats_string
21
+ msg = format("[memory] %<phase>s: %<rss>.1f MB", phase: phase, rss: rss)
22
+ ctx = [context, gc].compact.join(", ")
23
+ msg += " (#{ctx})" unless ctx.empty?
24
+ stderr.write("#{msg}\n")
25
+ end
26
+
27
+ def log_progress(current, status)
28
+ return unless text_tty?
29
+
30
+ stderr.write("mutation #{current} #{status}\n")
31
+ end
32
+
33
+ def log_mutation_diagnostics(result)
34
+ return unless verbose?
35
+
36
+ parts = []
37
+ parts << format("child_rss: %<mb>.1f MB", mb: result.child_rss_kb / 1024.0) if result.child_rss_kb
38
+
39
+ if result.memory_delta_kb
40
+ sign = result.memory_delta_kb.negative? ? "" : "+"
41
+ parts << format("delta: %<sign>s%<mb>.1f MB", sign: sign, mb: result.memory_delta_kb / 1024.0)
42
+ end
43
+
44
+ parts << gc_stats_string
45
+
46
+ stderr.write("[verbose] #{result.mutation}: #{parts.join(", ")}\n") unless parts.empty?
47
+
48
+ log_mutation_error(result) if result.error?
49
+ end
50
+
51
+ def log_worker_stats(stats)
52
+ return unless verbose? && stats.any?
53
+
54
+ stats.each do |stat|
55
+ pct = format("%.1f", stat.utilization * 100)
56
+ stderr.write("[verbose] worker #{stat.pid}: #{stat.items_completed} items, utilization #{pct}%\n")
57
+ end
58
+ end
59
+
60
+ def aggregate_worker_stats(stats)
61
+ return stats if stats.empty?
62
+
63
+ stats.group_by(&:pid).map do |pid, entries|
64
+ Evilution::Parallel::WorkQueue::WorkerStat.new(
65
+ pid,
66
+ entries.sum(&:items_completed),
67
+ entries.sum(&:busy_time),
68
+ entries.sum(&:wall_time)
69
+ )
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ attr_reader :config, :stderr
76
+
77
+ def verbose?
78
+ config.verbose && !config.quiet
79
+ end
80
+
81
+ def text_tty?
82
+ !config.quiet && config.text? && stderr.tty?
83
+ end
84
+
85
+ def log_mutation_error(result)
86
+ header = "[verbose] #{result.mutation}: error"
87
+ header += " #{result.error_class}" if result.error_class
88
+ header += ": #{result.error_message}" if result.error_message
89
+ stderr.write("#{header}\n")
90
+
91
+ Array(result.error_backtrace).first(5).each do |line|
92
+ stderr.write("[verbose] #{line}\n")
93
+ end
94
+ end
95
+
96
+ def gc_stats_string
97
+ stats = GC.stat
98
+ format(
99
+ "heap_live_slots: %<live>d, allocated: %<alloc>d, freed: %<freed>d",
100
+ live: stats[:heap_live_slots],
101
+ alloc: stats[:total_allocated_objects],
102
+ freed: stats[:total_freed_objects]
103
+ )
104
+ end
105
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../isolation/fork"
4
+ require_relative "../isolation/in_process"
5
+ require_relative "../rails_detector"
6
+
7
+ class Evilution::Runner; end unless defined?(Evilution::Runner) # rubocop:disable Lint/EmptyClass
8
+
9
+ class Evilution::Runner::IsolationResolver
10
+ PRELOAD_CANDIDATES = [
11
+ File.join("spec", "rails_helper.rb"),
12
+ File.join("test", "test_helper.rb")
13
+ ].freeze
14
+
15
+ def initialize(config, target_files:, hooks:)
16
+ @config = config
17
+ @target_files_callback = target_files
18
+ @hooks = hooks
19
+ end
20
+
21
+ def isolator
22
+ @isolator ||= build_isolator
23
+ end
24
+
25
+ def rails_root_detected?
26
+ return @rails_root_detected if defined?(@rails_root_detected)
27
+
28
+ @rails_root_detected = !detected_rails_root.nil?
29
+ end
30
+
31
+ def perform_preload
32
+ return if config.preload == false
33
+ return unless resolve_isolation == :fork
34
+
35
+ path = resolve_preload_path
36
+ return unless path
37
+
38
+ prepare_load_path_for_preload
39
+ require File.expand_path(path)
40
+ rescue ScriptError, StandardError => e
41
+ raise Evilution::ConfigError.new(
42
+ "failed to preload #{path.inspect}: #{e.class}: #{e.message}",
43
+ file: path
44
+ )
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :config, :hooks
50
+
51
+ def target_files
52
+ @target_files ||= @target_files_callback.call
53
+ end
54
+
55
+ def build_isolator
56
+ case resolve_isolation
57
+ when :fork then Evilution::Isolation::Fork.new(hooks: hooks)
58
+ when :in_process then Evilution::Isolation::InProcess.new
59
+ end
60
+ end
61
+
62
+ def resolve_isolation
63
+ case config.isolation
64
+ when :fork
65
+ :fork
66
+ when :in_process
67
+ warn_in_process_under_rails if rails_root_detected?
68
+ :in_process
69
+ else # :auto
70
+ rails_root_detected? ? :fork : :in_process
71
+ end
72
+ end
73
+
74
+ def detected_rails_root
75
+ return @detected_rails_root if defined?(@detected_rails_root)
76
+
77
+ @detected_rails_root = Evilution::RailsDetector.rails_root_for_any(target_files)
78
+ end
79
+
80
+ # Preload files (e.g. spec/rails_helper.rb) typically `require 'spec_helper'`
81
+ # which needs spec/ on $LOAD_PATH, and use `RSpec.configure` which needs
82
+ # rspec/core loaded. The RSpec CLI normally sets this up, but evilution
83
+ # calls Runner.run directly.
84
+ def prepare_load_path_for_preload
85
+ spec_dir = File.expand_path(resolve_spec_dir)
86
+ $LOAD_PATH.unshift(spec_dir) unless $LOAD_PATH.include?(spec_dir)
87
+ require "rspec/core" if config.integration == :rspec
88
+ end
89
+
90
+ def resolve_spec_dir
91
+ root = detected_rails_root
92
+ return File.join(root, "spec") if root
93
+
94
+ "spec"
95
+ end
96
+
97
+ def resolve_preload_path
98
+ if config.preload.is_a?(String)
99
+ unless File.file?(config.preload)
100
+ raise Evilution::ConfigError.new(
101
+ "preload file not found: #{config.preload.inspect}",
102
+ file: config.preload
103
+ )
104
+ end
105
+ return config.preload
106
+ end
107
+
108
+ root = detected_rails_root
109
+ return nil unless root
110
+
111
+ PRELOAD_CANDIDATES.each do |rel|
112
+ abs = File.join(root, rel)
113
+ return abs if File.file?(abs)
114
+ end
115
+ nil
116
+ end
117
+
118
+ # When the user explicitly requests InProcess on a Rails project, warn once
119
+ # per run. Rails wraps ActiveRecord transactions in
120
+ # Thread.handle_interrupt(Exception => :never), which defers Timeout's
121
+ # Thread#raise indefinitely — making InProcess unable to kill runaway mutants.
122
+ def warn_in_process_under_rails
123
+ return if config.quiet
124
+ return if @warned_in_process_under_rails
125
+
126
+ @warned_in_process_under_rails = true
127
+ $stderr.write(
128
+ "[evilution] warning: --isolation in_process is unsafe on Rails projects. " \
129
+ "ActiveRecord wraps transactions in Thread.handle_interrupt(Exception => :never), " \
130
+ "which swallows Timeout.timeout and can cause evilution to hang indefinitely on " \
131
+ "mutants that introduce infinite loops. Use --isolation fork for reliable interruption.\n"
132
+ )
133
+ end
134
+ end