evilution 1.2.0 → 1.4.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 (213) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +100 -0
  3. data/.rubocop_todo.yml +14 -0
  4. data/CHANGELOG.md +124 -0
  5. data/README.md +142 -28
  6. data/docs/architecture.md +123 -9
  7. data/docs/ast_pattern_syntax.md +50 -5
  8. data/docs/isolation.md +39 -2
  9. data/docs/migration-from-mutant.md +1 -1
  10. data/lib/evilution/ast/aasm_declaration.rb +133 -0
  11. data/lib/evilution/ast/callback_declaration.rb +91 -0
  12. data/lib/evilution/ast/included_block.rb +27 -0
  13. data/lib/evilution/ast/literal_callable.rb +21 -0
  14. data/lib/evilution/ast/parser.rb +120 -16
  15. data/lib/evilution/ast/pattern/filter.rb +1 -0
  16. data/lib/evilution/ast/pattern/method_name.rb +63 -0
  17. data/lib/evilution/ast/pattern/parser.rb +15 -15
  18. data/lib/evilution/ast/pattern.rb +2 -0
  19. data/lib/evilution/ast/regexp_pattern.rb +104 -0
  20. data/lib/evilution/ast/scope_declaration.rb +45 -0
  21. data/lib/evilution/ast/sorbet_sig_detector.rb +1 -0
  22. data/lib/evilution/ast/uncovered_code.rb +88 -0
  23. data/lib/evilution/ast/value_object_definition.rb +25 -0
  24. data/lib/evilution/baseline/failure_formatter.rb +27 -0
  25. data/lib/evilution/baseline/report.rb +66 -0
  26. data/lib/evilution/baseline/spec_failure.rb +38 -0
  27. data/lib/evilution/baseline.rb +101 -42
  28. data/lib/evilution/child_output.rb +1 -1
  29. data/lib/evilution/cli/command.rb +1 -0
  30. data/lib/evilution/cli/commands/tests_list.rb +4 -3
  31. data/lib/evilution/cli/commands.rb +2 -0
  32. data/lib/evilution/cli/dispatcher.rb +2 -0
  33. data/lib/evilution/cli/exit_guard.rb +2 -0
  34. data/lib/evilution/cli/parsed_args.rb +2 -0
  35. data/lib/evilution/cli/parser/command_extractor.rb +2 -0
  36. data/lib/evilution/cli/parser/file_args.rb +4 -1
  37. data/lib/evilution/cli/parser/options_builder.rb +4 -1
  38. data/lib/evilution/cli/parser/stdin_reader.rb +1 -0
  39. data/lib/evilution/cli/parser.rb +1 -0
  40. data/lib/evilution/cli/printers/tests_list.rb +5 -4
  41. data/lib/evilution/cli/printers.rb +2 -0
  42. data/lib/evilution/cli/result.rb +2 -0
  43. data/lib/evilution/cli.rb +8 -2
  44. data/lib/evilution/config/builders.rb +2 -0
  45. data/lib/evilution/config/env_loader.rb +2 -0
  46. data/lib/evilution/config/file_loader.rb +1 -0
  47. data/lib/evilution/config/sources.rb +1 -0
  48. data/lib/evilution/config/validators/example_targeting_cache.rb +1 -0
  49. data/lib/evilution/config/validators/example_targeting_fallback.rb +1 -0
  50. data/lib/evilution/config/validators/example_targeting_strategy.rb +1 -0
  51. data/lib/evilution/config/validators/fail_fast.rb +1 -0
  52. data/lib/evilution/config/validators/hooks.rb +1 -0
  53. data/lib/evilution/config/validators/ignore_patterns.rb +1 -0
  54. data/lib/evilution/config/validators/integration.rb +1 -0
  55. data/lib/evilution/config/validators/isolation.rb +1 -0
  56. data/lib/evilution/config/validators/jobs.rb +1 -0
  57. data/lib/evilution/config/validators/preload.rb +1 -0
  58. data/lib/evilution/config/validators/profile.rb +1 -0
  59. data/lib/evilution/config/validators/spec_mappings.rb +3 -1
  60. data/lib/evilution/config/validators/spec_pattern.rb +1 -0
  61. data/lib/evilution/config/validators/warmup.rb +17 -0
  62. data/lib/evilution/config/validators.rb +2 -0
  63. data/lib/evilution/config.rb +7 -5
  64. data/lib/evilution/coverage.rb +1 -1
  65. data/lib/evilution/coverage_example_filter.rb +1 -1
  66. data/lib/evilution/diagnostic.rb +1 -1
  67. data/lib/evilution/equivalent/detector.rb +3 -1
  68. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
  69. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
  70. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
  71. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
  72. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
  73. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
  74. data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
  75. data/lib/evilution/example_filter.rb +46 -1
  76. data/lib/evilution/hooks/registry.rb +2 -1
  77. data/lib/evilution/integration/base.rb +2 -0
  78. data/lib/evilution/integration/known_failures.rb +26 -0
  79. data/lib/evilution/integration/loading/body_call_neutralizer.rb +139 -12
  80. data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
  81. data/lib/evilution/integration/loading/concern_redeclaration.rb +61 -0
  82. data/lib/evilution/integration/loading/concern_state_cleaner.rb +19 -12
  83. data/lib/evilution/integration/loading/mutation_applier.rb +16 -0
  84. data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
  85. data/lib/evilution/integration/loading/test_load_path.rb +21 -13
  86. data/lib/evilution/integration/minitest/test_ids.rb +21 -0
  87. data/lib/evilution/integration/minitest.rb +45 -6
  88. data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
  89. data/lib/evilution/integration/rspec/example_ids.rb +35 -0
  90. data/lib/evilution/integration/rspec/result_builder.rb +6 -1
  91. data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
  92. data/lib/evilution/integration/rspec/state_guard/configuration_state.rb +1 -0
  93. data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +1 -0
  94. data/lib/evilution/integration/rspec/state_guard/example_groups_constants.rb +1 -2
  95. data/lib/evilution/integration/rspec/state_guard/internals.rb +1 -2
  96. data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +1 -2
  97. data/lib/evilution/integration/rspec/state_guard/reporter_arrays.rb +1 -0
  98. data/lib/evilution/integration/rspec/state_guard/world_example_groups.rb +1 -0
  99. data/lib/evilution/integration/rspec/state_guard/world_filtered_examples.rb +1 -0
  100. data/lib/evilution/integration/rspec/state_guard/world_sources_by_path.rb +1 -0
  101. data/lib/evilution/integration/rspec/state_guard.rb +10 -1
  102. data/lib/evilution/integration/rspec.rb +22 -4
  103. data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
  104. data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
  105. data/lib/evilution/integration/test_unit.rb +37 -9
  106. data/lib/evilution/isolation/fork.rb +2 -1
  107. data/lib/evilution/isolation/in_process.rb +21 -4
  108. data/lib/evilution/mcp/info_tool/actions/environment.rb +1 -0
  109. data/lib/evilution/mcp/info_tool/actions/feedback.rb +1 -0
  110. data/lib/evilution/mcp/info_tool/actions/statuses.rb +1 -0
  111. data/lib/evilution/mcp/info_tool/actions/subjects.rb +1 -0
  112. data/lib/evilution/mcp/info_tool/actions/tests.rb +1 -0
  113. data/lib/evilution/mcp/info_tool/status_glossary.rb +2 -2
  114. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
  115. data/lib/evilution/memory/leak_check.rb +27 -3
  116. data/lib/evilution/mutation.rb +8 -3
  117. data/lib/evilution/mutator/base.rb +49 -5
  118. data/lib/evilution/mutator/operator/alias_removal.rb +126 -0
  119. data/lib/evilution/mutator/operator/argument_list_removal.rb +52 -0
  120. data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
  121. data/lib/evilution/mutator/operator/argument_propagation.rb +54 -0
  122. data/lib/evilution/mutator/operator/argument_removal.rb +9 -1
  123. data/lib/evilution/mutator/operator/array_coercion_to_literal.rb +34 -0
  124. data/lib/evilution/mutator/operator/attribute_write_to_read.rb +34 -0
  125. data/lib/evilution/mutator/operator/bang_method.rb +11 -3
  126. data/lib/evilution/mutator/operator/binary_operand_promotion.rb +63 -0
  127. data/lib/evilution/mutator/operator/block_body_promotion.rb +45 -0
  128. data/lib/evilution/mutator/operator/block_body_to_nil.rb +52 -0
  129. data/lib/evilution/mutator/operator/block_body_to_raise.rb +34 -0
  130. data/lib/evilution/mutator/operator/call_to_nil.rb +34 -0
  131. data/lib/evilution/mutator/operator/coercion_emptying.rb +60 -0
  132. data/lib/evilution/mutator/operator/collection_replacement.rb +19 -9
  133. data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
  134. data/lib/evilution/mutator/operator/comparison_replacement.rb +37 -12
  135. data/lib/evilution/mutator/operator/const_get_to_constant_path.rb +50 -0
  136. data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
  137. data/lib/evilution/mutator/operator/dig_to_fetch_chain.rb +45 -0
  138. data/lib/evilution/mutator/operator/double_negation_removal.rb +22 -0
  139. data/lib/evilution/mutator/operator/dynamic_dispatch_resolution.rb +79 -0
  140. data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
  141. data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
  142. data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
  143. data/lib/evilution/mutator/operator/inequality_to_negated_identity.rb +43 -0
  144. data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
  145. data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
  146. data/lib/evilution/mutator/operator/keyword_argument_removal.rb +47 -0
  147. data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
  148. data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
  149. data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
  150. data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
  151. data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
  152. data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
  153. data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
  154. data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
  155. data/lib/evilution/mutator/operator/proc_to_lambda.rb +43 -0
  156. data/lib/evilution/mutator/operator/receiver_constructor_swap.rb +51 -0
  157. data/lib/evilution/mutator/operator/reduce_to_sum.rb +58 -0
  158. data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
  159. data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
  160. data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
  161. data/lib/evilution/mutator/operator/regexp_anchor_to_predicate.rb +136 -0
  162. data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
  163. data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
  164. data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
  165. data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
  166. data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
  167. data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
  168. data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
  169. data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
  170. data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
  171. data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
  172. data/lib/evilution/mutator/operator/safe_navigation_removal.rb +67 -0
  173. data/lib/evilution/mutator/operator/send_mutation.rb +44 -6
  174. data/lib/evilution/mutator/operator/splat_operator.rb +38 -13
  175. data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
  176. data/lib/evilution/mutator/operator/symbol_to_proc_replacement.rb +59 -0
  177. data/lib/evilution/mutator/operator/to_i_to_integer.rb +38 -0
  178. data/lib/evilution/mutator/primitives.rb +25 -0
  179. data/lib/evilution/mutator/registry.rb +54 -2
  180. data/lib/evilution/mutator/rescue_handlers.rb +81 -0
  181. data/lib/evilution/parallel/pool.rb +1 -0
  182. data/lib/evilution/parallel_db_warning.rb +1 -1
  183. data/lib/evilution/process_supervisor.rb +3 -2
  184. data/lib/evilution/rails_warmup.rb +61 -0
  185. data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
  186. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  187. data/lib/evilution/reporter/html/report.rb +1 -0
  188. data/lib/evilution/reporter/json/baseline.rb +15 -0
  189. data/lib/evilution/reporter/json.rb +7 -2
  190. data/lib/evilution/result/baseline_neutralization.rb +10 -0
  191. data/lib/evilution/result/coverage_gap_grouper.rb +1 -0
  192. data/lib/evilution/result/mutation_result.rb +9 -1
  193. data/lib/evilution/result/subject_scorer.rb +1 -0
  194. data/lib/evilution/result/summary.rb +42 -2
  195. data/lib/evilution/runner/baseline_runner.rb +26 -11
  196. data/lib/evilution/runner/canary.rb +2 -57
  197. data/lib/evilution/runner/canary_failure_message.rb +101 -0
  198. data/lib/evilution/runner/isolation_resolver.rb +23 -2
  199. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +35 -14
  200. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
  201. data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
  202. data/lib/evilution/runner/mutation_executor.rb +1 -1
  203. data/lib/evilution/runner/mutation_planner.rb +11 -2
  204. data/lib/evilution/runner/report_publisher.rb +3 -2
  205. data/lib/evilution/runner/subject_pipeline.rb +46 -1
  206. data/lib/evilution/runner.rb +4 -1
  207. data/lib/evilution/source_ast_cache.rb +1 -1
  208. data/lib/evilution/spec_ast_cache.rb +1 -1
  209. data/lib/evilution/subject.rb +8 -2
  210. data/lib/evilution/version.rb +1 -1
  211. data/lib/evilution.rb +51 -0
  212. data/script/memory_check +62 -16
  213. metadata +108 -4
@@ -9,6 +9,22 @@ require_relative "../result/mutation_result"
9
9
  require_relative "../isolation"
10
10
 
11
11
  class Evilution::Isolation::InProcess
12
+ # Raised into the run when its time is up. A SignalException, because the
13
+ # timeout has to end the whole test run and a test framework rescues nearly
14
+ # everything a test raises, Timeout::Error included: it would record one
15
+ # failed test and go on to the next, which may reach the same endless loop
16
+ # with no timer left to stop it. Minitest, Test::Unit and RSpec all let a
17
+ # SignalException through.
18
+ #
19
+ # Not an Interrupt, though it is one of those: tooling that handles the
20
+ # user's Ctrl-C (maxitest) rescues Interrupt and skips every test from then
21
+ # on, for the rest of the process.
22
+ class Expired < SignalException
23
+ def initialize(_message = nil)
24
+ super("SIGALRM")
25
+ end
26
+ end
27
+
12
28
  @null_out = File.open(File::NULL, "w")
13
29
  @null_err = File.open(File::NULL, "w")
14
30
 
@@ -33,7 +49,7 @@ class Evilution::Isolation::InProcess
33
49
  private
34
50
 
35
51
  # The Dir.chdir block is inside the Timeout.timeout block so that a
36
- # Timeout::Error raised mid-call still unwinds through Dir.chdir's ensure
52
+ # timeout raised mid-call still unwinds through Dir.chdir's ensure
37
53
  # and restores the parent CWD before the rescue clause runs. The sandbox
38
54
  # contains any relative-path writes from path-relativizing mutations
39
55
  # (EV-wqxu / GH #1278). Evilution.with_isolated_worker signals the rest of
@@ -41,7 +57,7 @@ class Evilution::Isolation::InProcess
41
57
  # SourceEvaluator/Integration) to anchor project-relative paths to
42
58
  # PROJECT_ROOT for the duration of the call.
43
59
  def execute_with_timeout(mutation, test_command, timeout, sandbox_dir)
44
- result = Timeout.timeout(timeout) do
60
+ result = Timeout.timeout(timeout, Expired) do
45
61
  Evilution.with_isolated_worker do
46
62
  Dir.chdir(sandbox_dir) do
47
63
  suppress_output { test_command.call(mutation) }
@@ -49,7 +65,7 @@ class Evilution::Isolation::InProcess
49
65
  end
50
66
  end
51
67
  { timeout: false }.merge(result)
52
- rescue Timeout::Error
68
+ rescue Expired
53
69
  { timeout: true }
54
70
  rescue ScriptError, StandardError => e
55
71
  {
@@ -106,7 +122,8 @@ class Evilution::Isolation::InProcess
106
122
  message: result[:error],
107
123
  klass: result[:error_class],
108
124
  backtrace: result[:error_backtrace]
109
- )
125
+ ),
126
+ known_failures_only: result[:known_failures_only]
110
127
  )
111
128
  end
112
129
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../actions"
3
4
  require_relative "base"
4
5
  require_relative "../../../config"
5
6
  require_relative "../../../version"
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../actions"
3
4
  require_relative "base"
4
5
  require_relative "../../../version"
5
6
  require_relative "../../../feedback"
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../actions"
3
4
  require_relative "base"
4
5
  require_relative "../status_glossary"
5
6
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../actions"
3
4
  require_relative "base"
4
5
  require_relative "../config_factory"
5
6
  require_relative "../../../runner"
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../actions"
3
4
  require_relative "base"
4
5
  require_relative "../config_factory"
5
6
  require_relative "../../../runner"
@@ -32,8 +32,8 @@ module Evilution::MCP::InfoTool::StatusGlossary
32
32
  },
33
33
  {
34
34
  "status" => "neutral",
35
- "meaning" => "Baseline tests already failed before the mutation was applied — pre-existing " \
36
- "test-suite problem (flaky spec, infra collision, fixture setup failure). " \
35
+ "meaning" => "No verdict: the tests failed only on examples that were already failing before " \
36
+ "the mutation was applied, or the test process crashed on infrastructure. " \
37
37
  "Not a meaningful mutation signal.",
38
38
  "counted_in_score" => false
39
39
  },
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require_relative "../mutate_tool"
5
5
  require_relative "../../reporter/suggestion"
6
+ require_relative "../../diagnostic"
6
7
 
7
8
  module Evilution::MCP::MutateTool::ProgressStreamer
8
9
  def self.build(server_context:, suggest_tests:, integration:)
@@ -21,7 +22,7 @@ module Evilution::MCP::MutateTool::ProgressStreamer
21
22
  detail = build_suggestion_detail(result.mutation, suggestion)
22
23
  server_context.report_progress(survivor_index, message: ::JSON.generate(detail))
23
24
  rescue StandardError => e
24
- warn "[evilution] progress stream disabled after error: #{e.class}: #{e.message}"
25
+ Evilution::Diagnostic.warn("[evilution] progress stream disabled after error: #{e.class}: #{e.message}")
25
26
  disabled = true
26
27
  end
27
28
  end
@@ -9,9 +9,11 @@ class Evilution::Memory::LeakCheck
9
9
 
10
10
  attr_reader :samples
11
11
 
12
- def initialize(iterations: DEFAULT_ITERATIONS, max_growth_kb: DEFAULT_MAX_GROWTH_KB)
12
+ def initialize(iterations: DEFAULT_ITERATIONS, max_growth_kb: DEFAULT_MAX_GROWTH_KB,
13
+ warmup_iterations: WARMUP_ITERATIONS)
13
14
  @iterations = iterations
14
15
  @max_growth_kb = max_growth_kb
16
+ @warmup_iterations = warmup_iterations
15
17
  @samples = []
16
18
  end
17
19
 
@@ -32,8 +34,23 @@ class Evilution::Memory::LeakCheck
32
34
  samples.last - samples.first
33
35
  end
34
36
 
35
- def passed?
37
+ # Growth from the first sample to the last, less the largest rise between two
38
+ # consecutive samples.
39
+ #
40
+ # RSS does not grow by the byte. The allocator takes memory in chunks, so a
41
+ # workload that has stopped growing can still step up once, by several MB,
42
+ # wherever the heap happens to cross a boundary, and that one step decided
43
+ # the endpoint reading. A leak is not one step: it grows across the run, and
44
+ # taking out its largest step leaves the rest of it.
45
+ def sustained_growth_kb
36
46
  kb = growth_kb
47
+ return kb if kb.nil? || samples.size < 2
48
+
49
+ kb - [largest_step_kb, 0].max
50
+ end
51
+
52
+ def passed?
53
+ kb = sustained_growth_kb
37
54
  return false if kb.nil?
38
55
 
39
56
  kb <= @max_growth_kb
@@ -41,8 +58,12 @@ class Evilution::Memory::LeakCheck
41
58
 
42
59
  private
43
60
 
61
+ def largest_step_kb
62
+ samples.each_cons(2).map { |before, after| after - before }.max
63
+ end
64
+
44
65
  def warmup(&block)
45
- WARMUP_ITERATIONS.times { block.call }
66
+ @warmup_iterations.times { block.call }
46
67
  GC.start
47
68
  GC.compact if GC.respond_to?(:compact)
48
69
  end
@@ -74,10 +95,13 @@ class Evilution::Memory::LeakCheck
74
95
  end
75
96
 
76
97
  def result
98
+ sustained_kb = sustained_growth_kb
77
99
  {
78
100
  passed: passed?,
79
101
  growth_kb: growth_kb,
80
102
  growth_mb: growth_kb ? growth_kb / 1024.0 : nil,
103
+ sustained_growth_kb: sustained_kb,
104
+ sustained_growth_mb: sustained_kb ? sustained_kb / 1024.0 : nil,
81
105
  samples: samples,
82
106
  iterations: @iterations,
83
107
  max_growth_kb: @max_growth_kb,
@@ -1,17 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "diff/lcs"
4
- require_relative "../evilution"
4
+ require_relative "version"
5
5
 
6
6
  class Evilution::Mutation
7
7
  Sources = Data.define(:original, :mutated)
8
8
  Slice = Data.define(:original, :mutated)
9
9
  Location = Data.define(:file_path, :line, :column)
10
10
 
11
- attr_reader :subject, :operator_name, :parse_status, :location
11
+ # restore_source: the source that puts back what applying this mutation
12
+ # changed and re-evaluating another mutation of the file would not -- a
13
+ # scope declaration (see Mutator::Base#build_restore_source). nil when
14
+ # nothing needs restoring.
15
+ attr_reader :subject, :operator_name, :parse_status, :location, :restore_source
12
16
 
13
17
  def initialize(subject:, operator_name:, sources:, location:,
14
- slice: nil, parse_status: :ok, eval_source: nil)
18
+ slice: nil, parse_status: :ok, eval_source: nil, restore_source: nil)
15
19
  @subject = subject
16
20
  @operator_name = operator_name
17
21
  @sources = sources
@@ -19,6 +23,7 @@ class Evilution::Mutation
19
23
  @slice = slice
20
24
  @parse_status = parse_status
21
25
  @eval_source = eval_source
26
+ @restore_source = restore_source
22
27
  @diff = nil
23
28
  end
24
29
 
@@ -84,12 +84,12 @@ class Evilution::Mutator::Base < Prism::Visitor
84
84
  replacement_bytesize: replacement.bytesize
85
85
  )
86
86
 
87
- mutation = build_mutation_record(node, surgery, slices)
87
+ mutation = build_mutation_record(node, surgery, slices, offset)
88
88
  @mutations << mutation
89
89
  mutation
90
90
  end
91
91
 
92
- def build_mutation_record(node, surgery, slices)
92
+ def build_mutation_record(node, surgery, slices, offset)
93
93
  Evilution::Mutation.new(
94
94
  subject: @subject,
95
95
  operator_name: self.class.operator_name,
@@ -101,7 +101,8 @@ class Evilution::Mutator::Base < Prism::Visitor
101
101
  column: node.location.start_column
102
102
  ),
103
103
  parse_status: surgery.status,
104
- eval_source: build_eval_source(surgery)
104
+ eval_source: build_eval_source(surgery, offset),
105
+ restore_source: build_restore_source(offset)
105
106
  )
106
107
  end
107
108
 
@@ -112,10 +113,44 @@ class Evilution::Mutator::Base < Prism::Visitor
112
113
  # them at apply time. Passing the subject's file path lets the neutralizer
113
114
  # skip files the parent never preloaded — those are lazy plugin files whose
114
115
  # DSL calls are still needed for the child fork's first-time load.
115
- def build_eval_source(surgery)
116
+ # A scope or aasm subject's mutation sits inside a class-body declaration,
117
+ # which must run again for the mutated body to replace the loaded one, so
118
+ # that one declaration is kept.
119
+ def build_eval_source(surgery, offset)
116
120
  return surgery.source unless surgery.ok?
117
121
 
118
- @body_call_neutralizer.call(surgery.source, file_path: @subject.file_path)
122
+ @body_call_neutralizer.call(surgery.source, file_path: @subject.file_path, **redeclaration(offset))
123
+ end
124
+
125
+ # Re-evaluating any mutation of a file redefines all its methods, which is
126
+ # how an in-process run puts a mutated method back. Class-body calls are
127
+ # neutralized in that source, so a mutated scope or aasm callable would stay
128
+ # installed; the original file with just that declaration kept replaces it
129
+ # again.
130
+ def build_restore_source(offset)
131
+ return nil unless redeclared_subject?
132
+
133
+ @body_call_neutralizer.call(@file_source, file_path: @subject.file_path, **redeclaration(offset))
134
+ end
135
+
136
+ # Subjects whose body is written inside a class-body declaration and only
137
+ # takes effect when that declaration runs again.
138
+ REDECLARED_KINDS = %i[scope aasm callback].freeze
139
+ private_constant :REDECLARED_KINDS
140
+
141
+ def redeclared_subject?
142
+ REDECLARED_KINDS.include?(@subject.kind)
143
+ end
144
+
145
+ # What the neutralizer needs to keep a redeclared subject's declaration:
146
+ # where the mutation is, and the lines the declaration takes up in the
147
+ # file as it was loaded -- which is where the callbacks it registered then
148
+ # say they come from, whatever the mutation does to its length.
149
+ def redeclaration(offset)
150
+ return {} unless redeclared_subject?
151
+
152
+ first = @subject.line_number
153
+ { keep_offset: offset, keep_lines: first..(first + @subject.source.count("\n")) }
119
154
  end
120
155
 
121
156
  NEWLINE_BYTE = 10
@@ -157,6 +192,15 @@ class Evilution::Mutator::Base < Prism::Visitor
157
192
  .downcase
158
193
  end
159
194
 
195
+ # The kinds of Subject the operator mutates. Operators work on method
196
+ # bodies, and on the bodies of callables handed to a scope, aasm or
197
+ # callback declaration, which run as part of the class's behaviour; one that also has
198
+ # something to say about a constant subject (a definition outside any
199
+ # method) adds :constant.
200
+ def self.subject_kinds
201
+ %i[method scope aasm callback]
202
+ end
203
+
160
204
  @parse_cache = {}
161
205
 
162
206
  def self.parsed_tree_for(file_path, file_source)
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../operator"
6
+
7
+ # Drop an alias declaration from a class or module body: `alias length size`
8
+ # and `alias_method :count, :size` are removed.
9
+ #
10
+ # A survivor means no example calls the method by its alias, so the alias is
11
+ # a surface the suite never exercises — dead, or reached only where nothing
12
+ # asserts.
13
+ #
14
+ # Declarations sit in the class body, outside every method, where no subject
15
+ # reaches them. They are attributed to the first method of the innermost
16
+ # enclosing class, module or `class << self`, the way DataStructMember and
17
+ # MixinRemoval attribute theirs. An `alias_method` call inside a method is
18
+ # left to the generic call operators, and global-variable aliases
19
+ # (`alias $new $old`) are not method surface.
20
+ class Evilution::Mutator::Operator::AliasRemoval < Evilution::Mutator::Base
21
+ def call(subject, filter: nil)
22
+ @subject = subject
23
+ @file_source = File.read(subject.file_path)
24
+ @mutations = []
25
+ @filter = filter
26
+
27
+ declarations_for(subject).each { |declaration| remove(declaration) }
28
+ @mutations
29
+ end
30
+
31
+ private
32
+
33
+ def remove(declaration)
34
+ add_mutation(
35
+ offset: declaration.location.start_offset,
36
+ length: declaration.location.length,
37
+ replacement: "",
38
+ node: declaration
39
+ )
40
+ end
41
+
42
+ def declarations_for(subject)
43
+ tree = self.class.parsed_tree_for(subject.file_path, @file_source)
44
+ collector = DeclarationCollector.new
45
+ collector.visit(tree)
46
+
47
+ collector.declarations.filter_map do |declaration, scope|
48
+ declaration if first_method_line(scope) == subject.line_number
49
+ end
50
+ end
51
+
52
+ def first_method_line(scope)
53
+ finder = FirstMethodFinder.new
54
+ scope.compact_child_nodes.each { |child| finder.visit(child) }
55
+ finder.line
56
+ end
57
+
58
+ # Collects alias declarations written in a class, module or singleton class
59
+ # body, each paired with the innermost such scope. Method bodies are not
60
+ # searched, and a top-level alias has no scope to attribute it to.
61
+ class DeclarationCollector < Prism::Visitor
62
+ attr_reader :declarations
63
+
64
+ def initialize
65
+ super
66
+ @declarations = []
67
+ @scopes = []
68
+ end
69
+
70
+ def visit_class_node(node)
71
+ within(node) { super }
72
+ end
73
+
74
+ def visit_module_node(node)
75
+ within(node) { super }
76
+ end
77
+
78
+ def visit_singleton_class_node(node)
79
+ within(node) { super }
80
+ end
81
+
82
+ def visit_def_node(_node); end
83
+
84
+ def visit_alias_method_node(node)
85
+ record(node)
86
+ super
87
+ end
88
+
89
+ # Blocks in the body (`included do ... end`) are searched too: an alias
90
+ # there still belongs to the enclosing scope.
91
+ def visit_call_node(node)
92
+ record(node) if node.name == :alias_method && node.receiver.nil?
93
+ super
94
+ end
95
+
96
+ private
97
+
98
+ def record(node)
99
+ @declarations << [node, @scopes.last] unless @scopes.empty?
100
+ end
101
+
102
+ def within(scope)
103
+ @scopes.push(scope)
104
+ yield
105
+ ensure
106
+ @scopes.pop
107
+ end
108
+ end
109
+
110
+ # Finds the line of the first method written in a scope. Nested classes,
111
+ # modules and singleton classes are not searched: their methods are subjects
112
+ # of that inner scope.
113
+ class FirstMethodFinder < Prism::Visitor
114
+ attr_reader :line
115
+
116
+ def visit_class_node(_node); end
117
+
118
+ def visit_module_node(_node); end
119
+
120
+ def visit_singleton_class_node(_node); end
121
+
122
+ def visit_def_node(node)
123
+ @line = node.location.start_line if @line.nil?
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Drop a call's whole argument list: `combine(a, b)` becomes `combine`,
6
+ # `normalize(value)` becomes `normalize`. Every argument kind goes —
7
+ # positional, splat, keywords and `...` — while a block-pass stays attached
8
+ # (`each_slice(2, &block)` becomes `each_slice(&block)`) and so does a
9
+ # literal block.
10
+ #
11
+ # A kill by ArgumentError proves arity is asserted; a survivor means the
12
+ # arguments never mattered. argument_removal drops one argument at a time and
13
+ # only from calls with two or more.
14
+ #
15
+ # Operator methods (`a + b`, `a[i]`) are left to binary operand promotion and
16
+ # attribute writes to attribute_write_to_read.
17
+ class Evilution::Mutator::Operator::ArgumentListRemoval < Evilution::Mutator::Base
18
+ IDENTIFIER = /\A[[:alpha:]_]/
19
+ private_constant :IDENTIFIER
20
+
21
+ def visit_call_node(node)
22
+ remove_argument_list(node) if removable?(node)
23
+ super
24
+ end
25
+
26
+ private
27
+
28
+ def removable?(node)
29
+ node.arguments && node.message_loc && node.name.match?(IDENTIFIER) && !node.attribute_write?
30
+ end
31
+
32
+ def remove_argument_list(node)
33
+ block_pass = node.block if node.block.is_a?(Prism::BlockArgumentNode)
34
+ start = node.message_loc.end_offset
35
+
36
+ add_mutation(
37
+ offset: start,
38
+ length: argument_list_end(node, block_pass) - start,
39
+ replacement: block_pass ? "(#{block_pass.slice})" : "",
40
+ node: node
41
+ )
42
+ end
43
+
44
+ # Without parentheses the list ends at its last element: the block-pass
45
+ # when there is one (it always comes last), else the last argument. The
46
+ # call node itself would reach past a literal `do ... end` block.
47
+ def argument_list_end(node, block_pass)
48
+ return node.closing_loc.end_offset if node.closing_loc
49
+
50
+ (block_pass || node.arguments).end_offset
51
+ end
52
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Swap neighbouring positional arguments: `compute(a, b)` becomes
6
+ # `compute(b, a)`.
7
+ #
8
+ # A survivor means no example tells the two values apart — typically a test
9
+ # whose fixtures are symmetric, so the method could take them in either order
10
+ # unnoticed.
11
+ #
12
+ # Keyword arguments and a block argument stay where they are, and a splat
13
+ # keeps its neighbours in place: it stands for an unknown number of
14
+ # arguments, so a value swapped with it would not land where the original
15
+ # stood. Identical neighbours are not swapped, since that would reproduce the
16
+ # original call.
17
+ class Evilution::Mutator::Operator::ArgumentOrderPermutation < Evilution::Mutator::Base
18
+ # `raise ArgumentError, "message"` reversed raises TypeError wherever it is
19
+ # reached, which only shows that the line ran.
20
+ RAISING_METHODS = %i[raise fail].freeze
21
+
22
+ # The format string swapped with a value raises TypeError the same way;
23
+ # what the string renders is FormatSpecifierSwap's job.
24
+ FORMAT_METHODS = %i[format sprintf printf].freeze
25
+
26
+ # Core methods that treat their arguments as an unordered set of
27
+ # alternatives, so any order gives the same answer.
28
+ ORDER_FREE_METHODS = %i[start_with? end_with?].freeze
29
+
30
+ # Arguments whose position does not map to a single value.
31
+ POSITIONLESS_TYPES = [Prism::SplatNode, Prism::ForwardingArgumentsNode].freeze
32
+
33
+ def visit_call_node(node)
34
+ swap_arguments(node) if swappable_call?(node)
35
+ super
36
+ end
37
+
38
+ def visit_super_node(node)
39
+ swap_arguments(node)
40
+ super
41
+ end
42
+
43
+ def visit_yield_node(node)
44
+ swap_arguments(node)
45
+ super
46
+ end
47
+
48
+ private
49
+
50
+ # The last argument of an index write is the value being stored, not a peer
51
+ # of the index.
52
+ def swappable_call?(node)
53
+ return false if node.name == :[]=
54
+ return false if ORDER_FREE_METHODS.include?(node.name)
55
+ return false if set_literal?(node)
56
+
57
+ !(node.receiver.nil? && (RAISING_METHODS.include?(node.name) || FORMAT_METHODS.include?(node.name)))
58
+ end
59
+
60
+ # `Set[a, b]` builds a set, where the order of elements does not count.
61
+ def set_literal?(node)
62
+ return false unless node.name == :[]
63
+
64
+ receiver = node.receiver
65
+ case receiver
66
+ when Prism::ConstantReadNode then receiver.name == :Set
67
+ when Prism::ConstantPathNode then receiver.parent.nil? && receiver.name == :Set
68
+ else false
69
+ end
70
+ end
71
+
72
+ def swap_arguments(node)
73
+ return if node.arguments.nil?
74
+
75
+ positionals = node.arguments.arguments.grep_v(Prism::KeywordHashNode)
76
+ positionals.each_cons(2) do |left, right|
77
+ emit_swap(node, left, right) if swappable_pair?(left, right)
78
+ end
79
+ end
80
+
81
+ def swappable_pair?(left, right)
82
+ return false if [left, right].any? { |argument| POSITIONLESS_TYPES.include?(argument.class) }
83
+
84
+ left.slice != right.slice
85
+ end
86
+
87
+ # Whatever separates the two arguments — the comma, line breaks, comments —
88
+ # stays between them, so the call keeps its layout.
89
+ def emit_swap(node, left, right)
90
+ start_offset = left.location.start_offset
91
+ end_offset = right.location.end_offset
92
+ separator = byteslice_source(left.location.end_offset, right.location.start_offset - left.location.end_offset)
93
+
94
+ add_mutation(
95
+ offset: start_offset,
96
+ length: end_offset - start_offset,
97
+ replacement: "#{right.slice}#{separator}#{left.slice}",
98
+ node: node
99
+ )
100
+ end
101
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Replace a call with its only argument: `normalize(value)` becomes `value`.
6
+ #
7
+ # A survivor means the method's transformation of its input is never
8
+ # observed — the tests would pass if the value went through untouched.
9
+ # Distinct from method_call_removal, which keeps the receiver instead.
10
+ #
11
+ # Only identifier-named methods with a single positional argument qualify.
12
+ # Operator methods (`a + b`, `a[i]`) are left to binary operand promotion,
13
+ # attribute writes to attribute_write_to_read, and a call in void statement
14
+ # position to statement_deletion, since there the promoted argument's value
15
+ # is discarded anyway.
16
+ class Evilution::Mutator::Operator::ArgumentPropagation < Evilution::Mutator::Base
17
+ NON_POSITIONAL = [
18
+ Prism::SplatNode,
19
+ Prism::KeywordHashNode,
20
+ Prism::BlockArgumentNode,
21
+ Prism::ForwardingArgumentsNode
22
+ ].freeze
23
+ private_constant :NON_POSITIONAL
24
+
25
+ IDENTIFIER = /\A[[:alpha:]_]/
26
+ private_constant :IDENTIFIER
27
+
28
+ def call(subject, **)
29
+ @void_statements = Set.new
30
+ super
31
+ end
32
+
33
+ def visit_statements_node(node)
34
+ @void_statements.merge(node.body[...-1])
35
+ super
36
+ end
37
+
38
+ def visit_call_node(node)
39
+ promote_child(node, sole_positional_argument(node)) if propagatable?(node)
40
+ super
41
+ end
42
+
43
+ private
44
+
45
+ def sole_positional_argument(node)
46
+ return unless node.arguments in Prism::ArgumentsNode[arguments: [argument]]
47
+
48
+ argument unless NON_POSITIONAL.any? { |type| argument.is_a?(type) }
49
+ end
50
+
51
+ def propagatable?(node)
52
+ node.name.match?(IDENTIFIER) && !node.attribute_write? && !@void_statements.include?(node)
53
+ end
54
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../operator"
4
+ require_relative "../../ast/value_object_definition"
4
5
 
5
6
  class Evilution::Mutator::Operator::ArgumentRemoval < Evilution::Mutator::Base
6
7
  SKIP_TYPES = [
@@ -30,7 +31,14 @@ class Evilution::Mutator::Operator::ArgumentRemoval < Evilution::Mutator::Base
30
31
  end
31
32
 
32
33
  def mutable?(node, args)
33
- args && args.length >= 2 && positional_only?(args) && node.name != :[]=
34
+ args && args.length >= 2 && positional_only?(args) && node.name != :[]= && !member_list?(node)
35
+ end
36
+
37
+ # DataStructMember owns the member list of `Data.define` / `Struct.new` and
38
+ # drops each member itself; removing an argument here would emit the same
39
+ # mutant a second time.
40
+ def member_list?(node)
41
+ Evilution::AST::ValueObjectDefinition.match?(node)
34
42
  end
35
43
 
36
44
  def positional_only?(args)