evilution 0.28.0 → 0.30.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 (112) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +106 -0
  3. data/.rubocop_todo.yml +7 -0
  4. data/CHANGELOG.md +49 -0
  5. data/README.md +194 -8
  6. data/docs/versioning.md +53 -0
  7. data/lib/evilution/ast/constant_names.rb +28 -11
  8. data/lib/evilution/ast/heredoc_span.rb +99 -0
  9. data/lib/evilution/ast/pattern/parser.rb +29 -17
  10. data/lib/evilution/baseline.rb +15 -2
  11. data/lib/evilution/cli/commands/compare.rb +13 -0
  12. data/lib/evilution/cli/commands/session_diff.rb +6 -4
  13. data/lib/evilution/cli/commands/subjects.rb +6 -3
  14. data/lib/evilution/cli/commands/util_mutation.rb +24 -19
  15. data/lib/evilution/cli/parser/command_extractor.rb +12 -12
  16. data/lib/evilution/cli/parser/file_args.rb +3 -1
  17. data/lib/evilution/cli/parser/options_builder.rb +31 -3
  18. data/lib/evilution/cli/parser/stdin_reader.rb +2 -2
  19. data/lib/evilution/cli/parser.rb +18 -20
  20. data/lib/evilution/cli/printers/environment.rb +19 -19
  21. data/lib/evilution/cli/printers/session_diff.rb +8 -8
  22. data/lib/evilution/compare/normalizer.rb +10 -5
  23. data/lib/evilution/config/file_loader.rb +40 -1
  24. data/lib/evilution/config.rb +21 -11
  25. data/lib/evilution/disable_comment.rb +21 -12
  26. data/lib/evilution/equivalent/heuristic/dead_code.rb +8 -1
  27. data/lib/evilution/feedback/setup_warning.rb +79 -0
  28. data/lib/evilution/gem_detector.rb +132 -0
  29. data/lib/evilution/integration/loading/body_call_neutralizer.rb +190 -0
  30. data/lib/evilution/integration/loading/mutation_applier.rb +35 -15
  31. data/lib/evilution/integration/loading/redefinition_recovery.rb +58 -1
  32. data/lib/evilution/integration/minitest.rb +60 -16
  33. data/lib/evilution/integration/rspec/result_builder.rb +20 -1
  34. data/lib/evilution/integration/rspec.rb +20 -1
  35. data/lib/evilution/isolation/fork.rb +104 -27
  36. data/lib/evilution/mcp/info_tool/actions/subjects.rb +32 -23
  37. data/lib/evilution/mcp/info_tool/actions/tests.rb +22 -12
  38. data/lib/evilution/mcp/info_tool/request_parser.rb +3 -1
  39. data/lib/evilution/mcp/info_tool/response_formatter.rb +14 -1
  40. data/lib/evilution/mcp/info_tool.rb +10 -2
  41. data/lib/evilution/mcp/mutate_tool/option_parser.rb +4 -2
  42. data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +58 -1
  43. data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +19 -9
  44. data/lib/evilution/mcp/mutate_tool.rb +49 -17
  45. data/lib/evilution/mcp/session_tool.rb +34 -22
  46. data/lib/evilution/mcp.rb +6 -0
  47. data/lib/evilution/mutation.rb +26 -16
  48. data/lib/evilution/mutator/base.rb +66 -16
  49. data/lib/evilution/mutator/operator/argument_method_call_replacement.rb +59 -0
  50. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +11 -14
  51. data/lib/evilution/mutator/operator/argument_removal.rb +11 -14
  52. data/lib/evilution/mutator/operator/begin_unwrap.rb +17 -5
  53. data/lib/evilution/mutator/operator/bitwise_complement.rb +26 -19
  54. data/lib/evilution/mutator/operator/block_param_removal.rb +50 -8
  55. data/lib/evilution/mutator/operator/block_pass_removal.rb +19 -15
  56. data/lib/evilution/mutator/operator/case_when.rb +7 -5
  57. data/lib/evilution/mutator/operator/conditional_branch.rb +22 -22
  58. data/lib/evilution/mutator/operator/equality_to_identity.rb +8 -3
  59. data/lib/evilution/mutator/operator/explicit_super_mutation.rb +36 -14
  60. data/lib/evilution/mutator/operator/index_to_at.rb +18 -5
  61. data/lib/evilution/mutator/operator/index_to_dig.rb +12 -6
  62. data/lib/evilution/mutator/operator/index_to_fetch.rb +5 -4
  63. data/lib/evilution/mutator/operator/keyword_argument.rb +30 -25
  64. data/lib/evilution/mutator/operator/last_expression_removal.rb +46 -0
  65. data/lib/evilution/mutator/operator/mixin_removal.rb +20 -14
  66. data/lib/evilution/mutator/operator/multiple_assignment.rb +12 -13
  67. data/lib/evilution/mutator/operator/receiver_replacement.rb +38 -7
  68. data/lib/evilution/mutator/operator/regex_simplification.rb +62 -67
  69. data/lib/evilution/mutator/operator/rescue_body_replacement.rb +9 -8
  70. data/lib/evilution/mutator/operator/rescue_removal.rb +58 -12
  71. data/lib/evilution/mutator/operator/splat_operator.rb +28 -1
  72. data/lib/evilution/mutator/operator/string_literal.rb +83 -6
  73. data/lib/evilution/mutator/operator/superclass_removal.rb +21 -15
  74. data/lib/evilution/mutator/registry.rb +2 -0
  75. data/lib/evilution/parallel/work_queue/dispatcher.rb +15 -8
  76. data/lib/evilution/parallel/work_queue/worker.rb +10 -7
  77. data/lib/evilution/parallel/work_queue.rb +35 -18
  78. data/lib/evilution/reporter/cli/item_formatters/coverage_gap.rb +13 -8
  79. data/lib/evilution/reporter/cli/line_formatters/error_rate_warning.rb +29 -0
  80. data/lib/evilution/reporter/cli/line_formatters/mutations.rb +17 -8
  81. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  82. data/lib/evilution/reporter/json.rb +54 -18
  83. data/lib/evilution/reporter/suggestion/diff_helpers.rb +0 -13
  84. data/lib/evilution/reporter/suggestion/diff_lines.rb +28 -0
  85. data/lib/evilution/reporter/suggestion/templates/minitest.rb +20 -14
  86. data/lib/evilution/reporter/suggestion/templates/rspec.rb +19 -13
  87. data/lib/evilution/result/mutation_result.rb +12 -6
  88. data/lib/evilution/runner/baseline_runner.rb +20 -9
  89. data/lib/evilution/runner/diagnostics.rb +13 -9
  90. data/lib/evilution/runner/isolation_resolver.rb +75 -12
  91. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -1
  92. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +32 -10
  93. data/lib/evilution/runner/mutation_executor/strategy/sequential.rb +1 -1
  94. data/lib/evilution/runner/mutation_executor.rb +2 -0
  95. data/lib/evilution/runner/mutation_planner.rb +53 -16
  96. data/lib/evilution/runner/subject_pipeline.rb +21 -11
  97. data/lib/evilution/runner.rb +3 -3
  98. data/lib/evilution/session/diff.rb +15 -6
  99. data/lib/evilution/session/schema.rb +44 -0
  100. data/lib/evilution/session/store.rb +5 -1
  101. data/lib/evilution/spec_ast_cache.rb +26 -12
  102. data/lib/evilution/version.rb +1 -1
  103. data/lib/evilution.rb +2 -0
  104. data/schema/evilution.config.schema.json +205 -0
  105. data/script/build_runtime_snapshot +88 -0
  106. data/script/memory_check +11 -5
  107. data/script/run_self_baseline +79 -0
  108. data/script/run_self_validation +54 -0
  109. data/scripts/benchmark_density +10 -9
  110. data/scripts/compare_mutations +38 -21
  111. data/scripts/mutant_json_adapter +7 -4
  112. metadata +16 -2
@@ -18,8 +18,17 @@ class Evilution::Runner::MutationExecutor::Strategy::Parallel
18
18
  @notifier.start(mutations.length)
19
19
  pool = @pool_factory.call
20
20
  state = { results: [], truncated: false, completed: 0 }
21
- all_worker_stats = []
21
+ all_worker_stats = run_batches(mutations, pool, baseline_result, integration, state)
22
22
 
23
+ log_worker_diagnostics(all_worker_stats)
24
+ @notifier.finish
25
+ build_result(state)
26
+ end
27
+
28
+ private
29
+
30
+ def run_batches(mutations, pool, baseline_result, integration, state)
31
+ all_worker_stats = []
23
32
  mutations.each_slice(@config.jobs) do |batch|
24
33
  break if state[:truncated]
25
34
 
@@ -27,24 +36,37 @@ class Evilution::Runner::MutationExecutor::Strategy::Parallel
27
36
  all_worker_stats.concat(pool.worker_stats)
28
37
  process_batch(batch_results, baseline_result, state)
29
38
  end
39
+ all_worker_stats
40
+ end
30
41
 
31
- @diagnostics.log_worker_stats(@diagnostics.aggregate_worker_stats(all_worker_stats)) if @diagnostics
32
- @notifier.finish
33
- [state[:results], state[:truncated]]
42
+ def log_worker_diagnostics(all_worker_stats)
43
+ return unless @diagnostics
44
+
45
+ @diagnostics.log_worker_stats(@diagnostics.aggregate_worker_stats(all_worker_stats))
34
46
  end
35
47
 
36
- private
48
+ def build_result(state)
49
+ Evilution::Runner::MutationExecutor::ExecutionResult.new(results: state[:results], truncated: state[:truncated])
50
+ end
37
51
 
38
52
  def run_batch(batch, pool, integration)
39
- uncached_indices, cached_results = @cache.partition(batch, packer: @packer)
40
- worker_results = run_uncached(batch, uncached_indices, pool, integration)
41
- compact_results = merge(batch, uncached_indices, cached_results, worker_results)
42
- batch_results = batch.zip(compact_results).map { |m, h| @packer.rebuild(m, h) }
43
- uncached_indices.each { |i| @cache.store(batch_results[i].mutation, batch_results[i]) }
53
+ partition = @cache.partition(batch, packer: @packer)
54
+ worker_results = run_uncached(batch, partition.uncached_indices, pool, integration)
55
+ compact_results = merge(batch, partition.uncached_indices, partition.cached_results, worker_results)
56
+ batch_results = rebuild_results(batch, compact_results)
57
+ cache_results(batch_results, partition.uncached_indices)
44
58
  batch.each(&:strip_sources!)
45
59
  batch_results
46
60
  end
47
61
 
62
+ def rebuild_results(batch, compact_results)
63
+ batch.zip(compact_results).map { |m, h| @packer.rebuild(m, h) }
64
+ end
65
+
66
+ def cache_results(batch_results, uncached_indices)
67
+ uncached_indices.each { |i| @cache.store(batch_results[i].mutation, batch_results[i]) }
68
+ end
69
+
48
70
  def run_uncached(batch, uncached_indices, pool, integration)
49
71
  return [] if uncached_indices.empty?
50
72
 
@@ -27,6 +27,6 @@ class Evilution::Runner::MutationExecutor::Strategy::Sequential
27
27
  end
28
28
 
29
29
  @notifier.finish
30
- [results, truncated]
30
+ Evilution::Runner::MutationExecutor::ExecutionResult.new(results: results, truncated: truncated)
31
31
  end
32
32
  end
@@ -3,6 +3,8 @@
3
3
  require_relative "../runner"
4
4
 
5
5
  class Evilution::Runner::MutationExecutor
6
+ ExecutionResult = Data.define(:results, :truncated)
7
+
6
8
  autoload :ResultCache, File.expand_path("mutation_executor/result_cache", __dir__)
7
9
  autoload :ResultPacker, File.expand_path("mutation_executor/result_packer", __dir__)
8
10
  autoload :ResultNotifier, File.expand_path("mutation_executor/result_notifier", __dir__)
@@ -9,6 +9,12 @@ require_relative "../equivalent/detector"
9
9
  class Evilution::Runner::MutationPlanner
10
10
  Plan = Struct.new(:enabled, :equivalent, :skipped_count, :disabled_mutations, keyword_init: true)
11
11
 
12
+ GenerationResult = Data.define(:mutations, :skipped)
13
+ DisabledFilterResult = Data.define(:enabled, :disabled)
14
+ SigFilterResult = Data.define(:enabled, :skipped)
15
+ EquivalentFilterResult = Data.define(:equivalent, :enabled)
16
+ private_constant :GenerationResult, :DisabledFilterResult, :SigFilterResult, :EquivalentFilterResult
17
+
12
18
  def initialize(config, registry:, disable_detector: Evilution::DisableComment.new,
13
19
  sig_detector: Evilution::AST::SorbetSigDetector.new)
14
20
  @config = config
@@ -20,25 +26,55 @@ class Evilution::Runner::MutationPlanner
20
26
  end
21
27
 
22
28
  def call(subjects)
23
- mutations, generation_skipped = generate(subjects)
24
- mutations, disabled = filter_disabled(mutations)
25
- disabled.each(&:strip_sources!) if config.show_disabled?
26
- disabled_mutations = config.show_disabled? ? disabled : []
29
+ generation = generate(subjects)
30
+ deduped = deduplicate(generation.mutations)
31
+ disabled_filter = filter_disabled(deduped)
32
+ disabled_mutations = compute_disabled_mutations(disabled_filter)
33
+ sig_filter = filter_sig_blocks(disabled_filter.enabled)
34
+ equivalent_filter = filter_equivalent(sig_filter.enabled)
35
+
36
+ build_plan(equivalent_filter, disabled_mutations, total_skipped(generation, disabled_filter, sig_filter))
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :config, :registry
42
+
43
+ def compute_disabled_mutations(disabled_filter)
44
+ return [] unless config.show_disabled?
45
+
46
+ disabled_filter.disabled.each(&:strip_sources!)
47
+ disabled_filter.disabled
48
+ end
27
49
 
28
- mutations, sig_skipped = filter_sig_blocks(mutations)
29
- equivalent, enabled = filter_equivalent(mutations)
50
+ def total_skipped(generation, disabled_filter, sig_filter)
51
+ generation.skipped + disabled_filter.disabled.length + sig_filter.skipped
52
+ end
30
53
 
54
+ def build_plan(equivalent_filter, disabled_mutations, skipped_count)
31
55
  Plan.new(
32
- enabled: enabled,
33
- equivalent: equivalent,
34
- skipped_count: generation_skipped + disabled.length + sig_skipped,
56
+ enabled: equivalent_filter.enabled,
57
+ equivalent: equivalent_filter.equivalent,
58
+ skipped_count: skipped_count,
35
59
  disabled_mutations: disabled_mutations
36
60
  )
37
61
  end
38
62
 
39
- private
40
-
41
- attr_reader :config, :registry
63
+ # Two operators can independently emit the same byte-level mutation
64
+ # (statement_deletion deleting a trailing literal AND last_expression_removal
65
+ # producing the same result, for example). Running both is wasted compute
66
+ # and inflates the denominator. Key is (file_path, mutated_source) — two
67
+ # mutations producing the same resulting source are operationally identical
68
+ # regardless of operator name. Order-stable: first occurrence wins, so
69
+ # operator-name ordering in the registry determines which name surfaces.
70
+ def deduplicate(mutations)
71
+ seen = {}
72
+ mutations.each do |mutation|
73
+ key = [mutation.file_path, mutation.mutated_source]
74
+ seen[key] ||= mutation
75
+ end
76
+ seen.values
77
+ end
42
78
 
43
79
  def generate(subjects)
44
80
  filter = build_ignore_filter
@@ -47,7 +83,7 @@ class Evilution::Runner::MutationPlanner
47
83
  registry.mutations_for(subject, filter: filter, operator_options: operator_options)
48
84
  end
49
85
  skipped = filter ? filter.skipped_count : 0
50
- [mutations, skipped]
86
+ GenerationResult.new(mutations: mutations, skipped: skipped)
51
87
  end
52
88
 
53
89
  def build_operator_options
@@ -73,7 +109,7 @@ class Evilution::Runner::MutationPlanner
73
109
  end
74
110
  end
75
111
 
76
- [enabled, disabled]
112
+ DisabledFilterResult.new(enabled: enabled, disabled: disabled)
77
113
  end
78
114
 
79
115
  def mutation_disabled?(mutation)
@@ -102,7 +138,7 @@ class Evilution::Runner::MutationPlanner
102
138
  end
103
139
  end
104
140
 
105
- [enabled, skipped]
141
+ SigFilterResult.new(enabled: enabled, skipped: skipped)
106
142
  end
107
143
 
108
144
  def mutation_in_sig_block?(mutation)
@@ -120,6 +156,7 @@ class Evilution::Runner::MutationPlanner
120
156
  end
121
157
 
122
158
  def filter_equivalent(mutations)
123
- Evilution::Equivalent::Detector.new.call(mutations)
159
+ equivalent, enabled = Evilution::Equivalent::Detector.new.call(mutations)
160
+ EquivalentFilterResult.new(equivalent: equivalent, enabled: enabled)
124
161
  end
125
162
  end
@@ -104,17 +104,27 @@ class Evilution::Runner::SubjectPipeline
104
104
 
105
105
  def target_matcher
106
106
  target = config.target
107
- if target.end_with?("*")
108
- prefix = target.chomp("*")
109
- ->(s) { s.name.split(/[#.]/).first.start_with?(prefix) }
110
- elsif target.end_with?("#", ".")
111
- prefix = target
112
- ->(s) { s.name.start_with?(prefix) }
113
- elsif target.include?("#") || target.include?(".")
114
- ->(s) { s.name == target }
115
- else
116
- ->(s) { s.name.start_with?("#{target}#") || s.name.start_with?("#{target}.") }
117
- end
107
+ return wildcard_matcher(target.chomp("*")) if target.end_with?("*")
108
+ return prefix_matcher(target) if target.end_with?("#", ".")
109
+ return exact_matcher(target) if target.include?("#") || target.include?(".")
110
+
111
+ class_matcher(target)
112
+ end
113
+
114
+ def wildcard_matcher(prefix)
115
+ ->(s) { s.name.split(/[#.]/).first.start_with?(prefix) }
116
+ end
117
+
118
+ def prefix_matcher(prefix)
119
+ ->(s) { s.name.start_with?(prefix) }
120
+ end
121
+
122
+ def exact_matcher(target)
123
+ ->(s) { s.name == target }
124
+ end
125
+
126
+ def class_matcher(target)
127
+ ->(s) { s.name.start_with?("#{target}#") || s.name.start_with?("#{target}.") }
118
128
  end
119
129
 
120
130
  def filter_by_line_ranges(subjects)
@@ -32,13 +32,13 @@ class Evilution::Runner
32
32
  plan = mutation_planner.call(subjects)
33
33
  release_subject_nodes(subjects)
34
34
  clear_operator_caches
35
- results, truncated = run_mutations(plan.enabled, baseline_result)
36
- results += equivalent_results(plan.equivalent)
35
+ execution = run_mutations(plan.enabled, baseline_result)
36
+ results = execution.results + equivalent_results(plan.equivalent)
37
37
  log_memory("after run_mutations", "#{results.length} results")
38
38
 
39
39
  duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
40
40
 
41
- summary = Evilution::Result::Summary.new(results: results, duration: duration, truncated: truncated,
41
+ summary = Evilution::Result::Summary.new(results: results, duration: duration, truncated: execution.truncated,
42
42
  skipped: plan.skipped_count,
43
43
  disabled_mutations: plan.disabled_mutations)
44
44
  output_report(summary)
@@ -38,20 +38,29 @@ class Evilution::Session::Diff
38
38
  def call(base_data, head_data)
39
39
  base_survivors = base_data["survived"] || []
40
40
  head_survivors = head_data["survived"] || []
41
-
42
- base_keys = base_survivors.to_set { |m| mutation_key(m) }
43
- head_keys = head_survivors.to_set { |m| mutation_key(m) }
41
+ fixed, new_survivors, persistent = partition_survivors(base_survivors, head_survivors)
44
42
 
45
43
  Result.new(
46
44
  summary: build_summary_diff(base_data, head_data),
47
- fixed: base_survivors.reject { |m| head_keys.include?(mutation_key(m)) },
48
- new_survivors: head_survivors.reject { |m| base_keys.include?(mutation_key(m)) },
49
- persistent: head_survivors.select { |m| base_keys.include?(mutation_key(m)) }
45
+ fixed: fixed,
46
+ new_survivors: new_survivors,
47
+ persistent: persistent
50
48
  )
51
49
  end
52
50
 
53
51
  private
54
52
 
53
+ def partition_survivors(base_survivors, head_survivors)
54
+ base_keys = base_survivors.to_set { |m| mutation_key(m) }
55
+ head_keys = head_survivors.to_set { |m| mutation_key(m) }
56
+
57
+ [
58
+ base_survivors.reject { |m| head_keys.include?(mutation_key(m)) },
59
+ head_survivors.reject { |m| base_keys.include?(mutation_key(m)) },
60
+ head_survivors.select { |m| base_keys.include?(mutation_key(m)) }
61
+ ]
62
+ end
63
+
55
64
  def build_summary_diff(base_data, head_data)
56
65
  base = extract_summary_values(base_data)
57
66
  head = extract_summary_values(head_data)
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../session"
4
+
5
+ module Evilution::Session::Schema
6
+ CURRENT_VERSION = 1
7
+
8
+ module_function
9
+
10
+ # Validates the schema_version of a parsed session JSON Hash.
11
+ #
12
+ # Sessions written before schema_version was introduced (key absent
13
+ # entirely) are treated as CURRENT_VERSION — the JSON shape that defined
14
+ # version 1. A key that is explicitly present but null/non-positive/non-
15
+ # integer is rejected as invalid; "missing" and "corrupted" must not
16
+ # collapse into the same lenient bucket. A schema_version newer than this
17
+ # gem supports raises Evilution::Error with an explicit "upgrade the gem"
18
+ # message so future writers cannot be silently misread.
19
+ def validate!(data, source: nil)
20
+ return unless data.key?("schema_version") || data.key?(:schema_version)
21
+
22
+ raw = data.fetch("schema_version") { data[:schema_version] }
23
+ raise_invalid!(raw, source) unless raw.is_a?(Integer) && raw.positive?
24
+ return if raw <= CURRENT_VERSION
25
+
26
+ raise_future!(raw, source)
27
+ end
28
+
29
+ def raise_invalid!(value, source)
30
+ raise Evilution::Error,
31
+ "invalid schema_version #{value.inspect}#{location_clause(source)}: must be a positive Integer"
32
+ end
33
+
34
+ def raise_future!(value, source)
35
+ raise Evilution::Error,
36
+ "session file#{location_clause(source)} has schema_version #{value}, " \
37
+ "newer than this evilution gem supports (current: #{CURRENT_VERSION}). " \
38
+ "Upgrade the evilution gem."
39
+ end
40
+
41
+ def location_clause(source)
42
+ source ? " at #{source}" : ""
43
+ end
44
+ end
@@ -6,6 +6,7 @@ require "time"
6
6
  require "fileutils"
7
7
 
8
8
  require_relative "../session"
9
+ require_relative "schema"
9
10
 
10
11
  class Evilution::Session::Store
11
12
  DEFAULT_DIR = ".evilution/results"
@@ -38,7 +39,9 @@ class Evilution::Session::Store
38
39
  def load(path)
39
40
  raise Evilution::Error, "session file not found: #{path}" unless File.exist?(path)
40
41
 
41
- JSON.parse(File.read(path))
42
+ data = JSON.parse(File.read(path))
43
+ Evilution::Session::Schema.validate!(data, source: path) if data.is_a?(Hash)
44
+ data
42
45
  end
43
46
 
44
47
  def gc(older_than:)
@@ -60,6 +63,7 @@ class Evilution::Session::Store
60
63
 
61
64
  def build_session_data(summary, now)
62
65
  {
66
+ schema_version: Evilution::Session::Schema::CURRENT_VERSION,
63
67
  version: Evilution::VERSION,
64
68
  timestamp: now.iso8601,
65
69
  git: git_context,
@@ -62,18 +62,27 @@ class Evilution::SpecAstCache
62
62
  raise Evilution::ParseError.new("file not found: #{path}", file: path) unless File.exist?(path)
63
63
 
64
64
  source = read_source(path)
65
+ result = parse_source(path, source)
66
+ collect_blocks(source, result, extract_comment_ranges(result))
67
+ end
68
+
69
+ def parse_source(path, source)
65
70
  result = Prism.parse(source)
71
+ return result unless result.failure?
66
72
 
67
- if result.failure?
68
- raise Evilution::ParseError.new(
69
- "failed to parse #{path}: #{result.errors.map(&:message).join(", ")}",
70
- file: path
71
- )
72
- end
73
+ raise Evilution::ParseError.new(
74
+ "failed to parse #{path}: #{result.errors.map(&:message).join(", ")}",
75
+ file: path
76
+ )
77
+ end
73
78
 
74
- comment_ranges = result.comments
75
- .map { |c| c.location.start_offset...c.location.end_offset }
76
- .sort_by(&:begin)
79
+ def extract_comment_ranges(result)
80
+ result.comments
81
+ .map { |c| c.location.start_offset...c.location.end_offset }
82
+ .sort_by(&:begin)
83
+ end
84
+
85
+ def collect_blocks(source, result, comment_ranges)
77
86
  collector = BlockCollector.new(source, comment_ranges)
78
87
  collector.visit(result.value)
79
88
  collector.blocks
@@ -133,16 +142,21 @@ class Evilution::SpecAstCache
133
142
  def strip_comments(slice, base_offset)
134
143
  return slice if @comment_ranges.empty?
135
144
 
136
- ranges = comment_ranges_within(base_offset, base_offset + slice.bytesize)
145
+ end_offset = base_offset + slice.bytesize
146
+ ranges = comment_ranges_within(base_offset, end_offset)
137
147
  return slice if ranges.empty?
138
148
 
149
+ splice_excluding_ranges(base_offset, end_offset, ranges)
150
+ end
151
+
152
+ def splice_excluding_ranges(start_off, end_off, ranges)
139
153
  result = +""
140
- cursor = base_offset
154
+ cursor = start_off
141
155
  ranges.each do |range|
142
156
  result << @source.byteslice(cursor, range.begin - cursor)
143
157
  cursor = range.end
144
158
  end
145
- result << @source.byteslice(cursor, base_offset + slice.bytesize - cursor)
159
+ result << @source.byteslice(cursor, end_off - cursor)
146
160
  result
147
161
  end
148
162
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Evilution
4
- VERSION = "0.28.0"
4
+ VERSION = "0.30.0"
5
5
  end
data/lib/evilution.rb CHANGED
@@ -94,6 +94,8 @@ require_relative "evilution/mutator/operator/equality_to_identity"
94
94
  require_relative "evilution/mutator/operator/lambda_body"
95
95
  require_relative "evilution/mutator/operator/begin_unwrap"
96
96
  require_relative "evilution/mutator/operator/block_param_removal"
97
+ require_relative "evilution/mutator/operator/last_expression_removal"
98
+ require_relative "evilution/mutator/operator/argument_method_call_replacement"
97
99
  require_relative "evilution/mutator/registry"
98
100
  require_relative "evilution/equivalent"
99
101
  require_relative "evilution/equivalent/heuristic"
@@ -0,0 +1,205 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/marinazzio/evilution/blob/master/schema/evilution.config.schema.json",
4
+ "title": "Evilution Configuration",
5
+ "description": "Schema for .evilution.yml / config/evilution.yml. Declaring `schema_version` opts the file into strict validation at load time.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "schema_version": {
10
+ "type": "integer",
11
+ "enum": [1],
12
+ "description": "Config schema version. Currently must be 1. Declaring it enables strict validation: unknown keys are rejected and a future schema_version is refused."
13
+ },
14
+ "timeout": {
15
+ "type": "integer",
16
+ "minimum": 1,
17
+ "default": 30,
18
+ "description": "Per-mutation timeout in seconds."
19
+ },
20
+ "format": {
21
+ "type": "string",
22
+ "enum": ["text", "json", "html"],
23
+ "default": "text",
24
+ "description": "Output format."
25
+ },
26
+ "target": {
27
+ "type": ["string", "null"],
28
+ "default": null,
29
+ "description": "Filter expression: method (Foo#bar), class (Foo), namespace wildcard (Foo*), method-type selector (Foo# / Foo.), descendants (descendants:Foo), or source glob (source:**/*.rb)."
30
+ },
31
+ "min_score": {
32
+ "type": "number",
33
+ "minimum": 0.0,
34
+ "maximum": 1.0,
35
+ "default": 0.0,
36
+ "description": "Minimum mutation score (0.0–1.0) for exit code 0."
37
+ },
38
+ "integration": {
39
+ "type": "string",
40
+ "enum": ["rspec", "minitest"],
41
+ "default": "rspec",
42
+ "description": "Test framework integration."
43
+ },
44
+ "verbose": {
45
+ "type": "boolean",
46
+ "default": false,
47
+ "description": "Verbose output (RSS/GC stats per phase, error details for errored mutations)."
48
+ },
49
+ "quiet": {
50
+ "type": "boolean",
51
+ "default": false,
52
+ "description": "Suppress output."
53
+ },
54
+ "jobs": {
55
+ "type": "integer",
56
+ "minimum": 1,
57
+ "default": 1,
58
+ "description": "Number of parallel workers."
59
+ },
60
+ "fail_fast": {
61
+ "type": ["integer", "null"],
62
+ "minimum": 1,
63
+ "default": null,
64
+ "description": "Stop after N surviving mutants. null or omitted = disabled."
65
+ },
66
+ "baseline": {
67
+ "type": "boolean",
68
+ "default": true,
69
+ "description": "Run baseline test suite first to detect pre-existing failures and mark those mutations :neutral."
70
+ },
71
+ "isolation": {
72
+ "type": "string",
73
+ "enum": ["auto", "fork", "in_process"],
74
+ "default": "auto",
75
+ "description": "Isolation strategy. auto selects fork for Rails projects."
76
+ },
77
+ "incremental": {
78
+ "type": "boolean",
79
+ "default": false,
80
+ "description": "Cache killed/timeout results across runs and skip them when source is unchanged."
81
+ },
82
+ "suggest_tests": {
83
+ "type": "boolean",
84
+ "default": false,
85
+ "description": "Generate concrete test code in survivor suggestions (RSpec or Minitest, matching integration)."
86
+ },
87
+ "progress": {
88
+ "type": "boolean",
89
+ "default": true,
90
+ "description": "TTY progress bar."
91
+ },
92
+ "save_session": {
93
+ "type": "boolean",
94
+ "default": false,
95
+ "description": "Save session JSON under .evilution/results/."
96
+ },
97
+ "line_ranges": {
98
+ "type": "object",
99
+ "default": {},
100
+ "description": "Per-file line-range constraints. Typically set via CLI; rare in YAML.",
101
+ "additionalProperties": true
102
+ },
103
+ "spec_files": {
104
+ "type": "array",
105
+ "items": { "type": "string" },
106
+ "default": [],
107
+ "description": "Explicit spec files to run. Bypasses auto-detection when non-empty."
108
+ },
109
+ "ignore_patterns": {
110
+ "type": "array",
111
+ "items": { "type": "string" },
112
+ "default": [],
113
+ "description": "AST patterns to skip during mutation generation. See docs/ast_pattern_syntax.md."
114
+ },
115
+ "show_disabled": {
116
+ "type": "boolean",
117
+ "default": false,
118
+ "description": "Report mutations skipped by `# evilution:disable` comments."
119
+ },
120
+ "baseline_session": {
121
+ "type": ["string", "null"],
122
+ "default": null,
123
+ "description": "Saved session file path for HTML report comparison."
124
+ },
125
+ "skip_heredoc_literals": {
126
+ "type": "boolean",
127
+ "default": false,
128
+ "description": "Skip all string literal mutations inside heredocs (recommended for Rails: heredoc SQL/templates rarely have coverage)."
129
+ },
130
+ "related_specs_heuristic": {
131
+ "type": "boolean",
132
+ "default": false,
133
+ "description": "When a mutation removes an `includes(...)` call, also run matching specs from spec/{requests,integration,features,system}."
134
+ },
135
+ "fallback_to_full_suite": {
136
+ "type": "boolean",
137
+ "default": false,
138
+ "description": "When no matching spec resolves, run the entire suite instead of marking the mutation :unresolved."
139
+ },
140
+ "preload": {
141
+ "type": ["string", "boolean", "null"],
142
+ "default": null,
143
+ "description": "Path to preload before forking workers. false to disable. null to auto-detect spec/rails_helper.rb -> spec/spec_helper.rb -> test/test_helper.rb for Rails projects."
144
+ },
145
+ "spec_mappings": {
146
+ "type": "object",
147
+ "default": {},
148
+ "description": "Custom mapping from source file to spec file(s). Keys are source paths; values are spec path strings or arrays of spec paths.",
149
+ "additionalProperties": {
150
+ "oneOf": [
151
+ { "type": "string" },
152
+ { "type": "array", "items": { "type": "string" } }
153
+ ]
154
+ }
155
+ },
156
+ "spec_pattern": {
157
+ "type": ["string", "null"],
158
+ "default": null,
159
+ "description": "Glob restricting resolved spec candidates to files matching this pattern."
160
+ },
161
+ "example_targeting": {
162
+ "type": "boolean",
163
+ "default": true,
164
+ "description": "Per-mutation example-level targeting via body-token scan."
165
+ },
166
+ "example_targeting_fallback": {
167
+ "type": "string",
168
+ "enum": ["full_file", "unresolved"],
169
+ "default": "full_file",
170
+ "description": "Behavior when targeting finds no matching example."
171
+ },
172
+ "example_targeting_cache": {
173
+ "type": "object",
174
+ "default": { "max_files": 50, "max_blocks": 10000 },
175
+ "additionalProperties": false,
176
+ "description": "LRU cache bounds for the spec AST parser that powers example targeting.",
177
+ "properties": {
178
+ "max_files": { "type": "integer", "minimum": 1 },
179
+ "max_blocks": { "type": "integer", "minimum": 1 }
180
+ }
181
+ },
182
+ "quiet_children": {
183
+ "type": "boolean",
184
+ "default": false,
185
+ "description": "Redirect each worker's stdout/stderr to per-pid files under quiet_children_dir."
186
+ },
187
+ "quiet_children_dir": {
188
+ "type": "string",
189
+ "default": "tmp/evilution_children",
190
+ "description": "Directory for --quiet-children per-pid log files."
191
+ },
192
+ "profile": {
193
+ "type": "string",
194
+ "enum": ["default", "strict"],
195
+ "default": "default",
196
+ "description": "Operator profile. strict adds aggressive truthiness mutators on top of default."
197
+ },
198
+ "hooks": {
199
+ "type": "object",
200
+ "default": {},
201
+ "additionalProperties": { "type": "string" },
202
+ "description": "Lifecycle hooks: keys are event names (e.g. worker_process_start, mutation_insert_pre); values are paths to Ruby files returning a Proc."
203
+ }
204
+ }
205
+ }