evilution 1.3.0 → 1.5.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 (187) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +132 -0
  3. data/.rubocop_todo.yml +5 -0
  4. data/CHANGELOG.md +127 -0
  5. data/README.md +136 -38
  6. data/docs/architecture.md +135 -11
  7. data/docs/ast_pattern_syntax.md +50 -5
  8. data/docs/isolation.md +121 -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/class_body.rb +101 -0
  13. data/lib/evilution/ast/included_block.rb +41 -0
  14. data/lib/evilution/ast/literal_callable.rb +21 -0
  15. data/lib/evilution/ast/never_nil.rb +36 -0
  16. data/lib/evilution/ast/parser.rb +187 -21
  17. data/lib/evilution/ast/pattern/method_name.rb +63 -0
  18. data/lib/evilution/ast/pattern/parser.rb +14 -15
  19. data/lib/evilution/ast/regexp_pattern.rb +104 -0
  20. data/lib/evilution/ast/round_half_mode.rb +38 -0
  21. data/lib/evilution/ast/scope_declaration.rb +45 -0
  22. data/lib/evilution/ast/source_surgeon.rb +2 -3
  23. data/lib/evilution/ast/syntax_check.rb +56 -0
  24. data/lib/evilution/ast/uncovered_code.rb +88 -0
  25. data/lib/evilution/ast/value_object_definition.rb +25 -0
  26. data/lib/evilution/baseline/failure_formatter.rb +27 -0
  27. data/lib/evilution/baseline/report.rb +66 -0
  28. data/lib/evilution/baseline/spec_failure.rb +38 -0
  29. data/lib/evilution/baseline.rb +67 -30
  30. data/lib/evilution/cli/exit_guard.rb +26 -4
  31. data/lib/evilution/cli/parser/file_args.rb +2 -1
  32. data/lib/evilution/cli/parser/options_builder.rb +1 -1
  33. data/lib/evilution/cli.rb +3 -2
  34. data/lib/evilution/config/validators/spec_mappings.rb +2 -1
  35. data/lib/evilution/config.rb +3 -2
  36. data/lib/evilution/equivalent/detector.rb +3 -1
  37. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
  38. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
  39. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
  40. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
  41. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
  42. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
  43. data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
  44. data/lib/evilution/example_filter.rb +45 -0
  45. data/lib/evilution/hooks/registry.rb +2 -1
  46. data/lib/evilution/integration/base.rb +11 -1
  47. data/lib/evilution/integration/known_failures.rb +26 -0
  48. data/lib/evilution/integration/loading/body_call_neutralizer.rb +177 -13
  49. data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
  50. data/lib/evilution/integration/loading/concern_redeclaration.rb +80 -0
  51. data/lib/evilution/integration/loading/concern_state_cleaner.rb +22 -15
  52. data/lib/evilution/integration/loading/mutation_applier.rb +44 -1
  53. data/lib/evilution/integration/loading/own_methods.rb +87 -0
  54. data/lib/evilution/integration/loading/redefinition_recovery.rb +2 -3
  55. data/lib/evilution/integration/loading/source_evaluator.rb +9 -2
  56. data/lib/evilution/integration/loading/superseded_modules.rb +103 -0
  57. data/lib/evilution/integration/loading/syntax_validator.rb +2 -2
  58. data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
  59. data/lib/evilution/integration/minitest/test_ids.rb +21 -0
  60. data/lib/evilution/integration/minitest.rb +45 -6
  61. data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
  62. data/lib/evilution/integration/rspec/example_ids.rb +35 -0
  63. data/lib/evilution/integration/rspec/result_builder.rb +6 -1
  64. data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
  65. data/lib/evilution/integration/rspec/state_guard.rb +4 -1
  66. data/lib/evilution/integration/rspec.rb +22 -4
  67. data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
  68. data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
  69. data/lib/evilution/integration/test_unit.rb +37 -9
  70. data/lib/evilution/isolation/fork.rb +84 -11
  71. data/lib/evilution/isolation/in_process.rb +100 -8
  72. data/lib/evilution/mcp/info_tool/status_glossary.rb +15 -4
  73. data/lib/evilution/mcp/info_tool.rb +3 -2
  74. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
  75. data/lib/evilution/memory/leak_check.rb +27 -3
  76. data/lib/evilution/mutation.rb +21 -9
  77. data/lib/evilution/mutator/base.rb +69 -5
  78. data/lib/evilution/mutator/constant_value_subjects.rb +13 -0
  79. data/lib/evilution/mutator/operator/alias_removal.rb +56 -0
  80. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +2 -2
  81. data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
  82. data/lib/evilution/mutator/operator/argument_removal.rb +10 -2
  83. data/lib/evilution/mutator/operator/array_literal.rb +29 -0
  84. data/lib/evilution/mutator/operator/attr_accessor_swap.rb +68 -0
  85. data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +2 -0
  86. data/lib/evilution/mutator/operator/call_to_nil.rb +12 -3
  87. data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
  88. data/lib/evilution/mutator/operator/complex_literal.rb +39 -0
  89. data/lib/evilution/mutator/operator/compound_assignment.rb +47 -0
  90. data/lib/evilution/mutator/operator/constant_namespace_strip.rb +57 -0
  91. data/lib/evilution/mutator/operator/constant_read_to_nil.rb +58 -0
  92. data/lib/evilution/mutator/operator/constant_write_to_nil.rb +32 -0
  93. data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
  94. data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
  95. data/lib/evilution/mutator/operator/explicit_super_mutation.rb +2 -2
  96. data/lib/evilution/mutator/operator/float_literal.rb +54 -19
  97. data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
  98. data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
  99. data/lib/evilution/mutator/operator/freeze_removal.rb +86 -0
  100. data/lib/evilution/mutator/operator/hash_literal.rb +51 -0
  101. data/lib/evilution/mutator/operator/index_range_to_drop.rb +67 -0
  102. data/lib/evilution/mutator/operator/index_receiver_to_self.rb +20 -0
  103. data/lib/evilution/mutator/operator/index_to_key_predicate.rb +58 -0
  104. data/lib/evilution/mutator/operator/index_write_to_value.rb +37 -0
  105. data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
  106. data/lib/evilution/mutator/operator/integer_literal.rb +39 -9
  107. data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
  108. data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
  109. data/lib/evilution/mutator/operator/memoization_break.rb +18 -0
  110. data/lib/evilution/mutator/operator/mixin_removal.rb +18 -55
  111. data/lib/evilution/mutator/operator/nil_replacement.rb +2 -0
  112. data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
  113. data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
  114. data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
  115. data/lib/evilution/mutator/operator/operator_write_receiver_promotion.rb +38 -0
  116. data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
  117. data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
  118. data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
  119. data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
  120. data/lib/evilution/mutator/operator/range_replacement.rb +2 -0
  121. data/lib/evilution/mutator/operator/rational_literal.rb +40 -0
  122. data/lib/evilution/mutator/operator/reduce_to_sum.rb +1 -1
  123. data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
  124. data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
  125. data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
  126. data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
  127. data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
  128. data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
  129. data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
  130. data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
  131. data/lib/evilution/mutator/operator/rescue_class_widening.rb +54 -0
  132. data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
  133. data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
  134. data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
  135. data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
  136. data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
  137. data/lib/evilution/mutator/operator/round_half_mode_swap.rb +36 -0
  138. data/lib/evilution/mutator/operator/safe_navigation_insertion.rb +57 -0
  139. data/lib/evilution/mutator/operator/safe_navigation_removal.rb +2 -24
  140. data/lib/evilution/mutator/operator/send_mutation.rb +2 -0
  141. data/lib/evilution/mutator/operator/short_circuit_to_eager.rb +34 -0
  142. data/lib/evilution/mutator/operator/sleep_to_zero.rb +43 -0
  143. data/lib/evilution/mutator/operator/splat_operator.rb +41 -24
  144. data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
  145. data/lib/evilution/mutator/operator/string_literal.rb +29 -0
  146. data/lib/evilution/mutator/operator/superclass_removal.rb +14 -40
  147. data/lib/evilution/mutator/operator/symbol_literal.rb +47 -2
  148. data/lib/evilution/mutator/operator/to_i_to_integer.rb +1 -1
  149. data/lib/evilution/mutator/operator/variable_read_to_nil.rb +113 -0
  150. data/lib/evilution/mutator/operator/visibility_removal.rb +74 -0
  151. data/lib/evilution/mutator/primitives.rb +24 -2
  152. data/lib/evilution/mutator/receiver_positions.rb +79 -0
  153. data/lib/evilution/mutator/registry.rb +58 -2
  154. data/lib/evilution/mutator/rescue_handlers.rb +81 -0
  155. data/lib/evilution/parallel/work_queue/channel/frame.rb +6 -0
  156. data/lib/evilution/parallel/work_queue/channel.rb +58 -5
  157. data/lib/evilution/parallel/work_queue/dispatcher/reply.rb +41 -0
  158. data/lib/evilution/parallel/work_queue/dispatcher.rb +14 -1
  159. data/lib/evilution/parallel/work_queue/worker/loop.rb +5 -0
  160. data/lib/evilution/parallel/work_queue/worker.rb +13 -4
  161. data/lib/evilution/parallel/work_queue.rb +15 -9
  162. data/lib/evilution/process_supervisor.rb +3 -2
  163. data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
  164. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  165. data/lib/evilution/reporter/json/baseline.rb +15 -0
  166. data/lib/evilution/reporter/json.rb +7 -2
  167. data/lib/evilution/result/baseline_neutralization.rb +10 -0
  168. data/lib/evilution/result/mutation_result.rb +9 -1
  169. data/lib/evilution/result/summary.rb +42 -2
  170. data/lib/evilution/runner/baseline_runner.rb +9 -4
  171. data/lib/evilution/runner/canary.rb +2 -57
  172. data/lib/evilution/runner/canary_failure_message.rb +101 -0
  173. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +22 -8
  174. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
  175. data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
  176. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +10 -0
  177. data/lib/evilution/runner/mutation_executor.rb +1 -1
  178. data/lib/evilution/runner/mutation_planner.rb +11 -2
  179. data/lib/evilution/runner/report_publisher.rb +3 -2
  180. data/lib/evilution/runner/subject_pipeline/target.rb +86 -0
  181. data/lib/evilution/runner/subject_pipeline.rb +57 -43
  182. data/lib/evilution/runner.rb +5 -2
  183. data/lib/evilution/subject.rb +25 -2
  184. data/lib/evilution/version.rb +1 -1
  185. data/lib/evilution.rb +90 -0
  186. data/script/memory_check +62 -16
  187. metadata +113 -4
@@ -2,18 +2,25 @@
2
2
 
3
3
  require_relative "../runner"
4
4
  require_relative "../ast/inheritance_scanner"
5
+ require_relative "../ast/uncovered_code"
6
+ require_relative "../diagnostic"
5
7
  require_relative "../git/changed_files"
6
8
 
7
9
  class Evilution::Runner::SubjectPipeline
8
- def initialize(config, parser:)
10
+ autoload :Target, File.expand_path("subject_pipeline/target", __dir__)
11
+
12
+ def initialize(config, parser:, registry: Evilution::Mutator::Registry.for_profile(config.profile))
9
13
  @config = config
10
14
  @parser = parser
15
+ @registry = registry
16
+ @target = Target.parse(config.target)
11
17
  end
12
18
 
13
19
  def call
14
20
  subjects = parse_subjects
15
- subjects = filter_by_descendants(subjects) if descendants_target?
16
- subjects = filter_by_target(subjects) if method_target?
21
+ @subjects_by_file = subjects.group_by(&:file_path)
22
+ subjects = filter_by_descendants(subjects) if target.descendants?
23
+ subjects = filter_by_target(subjects) if target.method?
17
24
  subjects = filter_by_line_ranges(subjects) if config.line_ranges?
18
25
  subjects
19
26
  end
@@ -22,28 +29,61 @@ class Evilution::Runner::SubjectPipeline
22
29
  @target_files ||= resolve_target_files
23
30
  end
24
31
 
32
+ # Warns about the targeted lines that hold code outside every subject and
33
+ # that no mutation reached either. mutated_lines: the lines mutations were
34
+ # generated for, per file -- known only once #call's subjects are planned.
35
+ #
36
+ # A line range is checked as given; a whole file only when it has no
37
+ # subjects at all -- every class with an `include` or a DSL call would
38
+ # otherwise warn.
39
+ def report_uncovered_code(mutated_lines = {})
40
+ by_file = @subjects_by_file || {}
41
+ @subjects_by_file = nil
42
+ @uncovered_code = target_files.filter_map do |file|
43
+ uncovered_entry(file, by_file.fetch(file, []), mutated_lines.fetch(file, []))
44
+ end
45
+ @uncovered_code.each { |entry| warn_uncovered(entry) }
46
+ end
47
+
48
+ # Those lines, as `{ file:, lines: ["2-4", "9"] }` per file, once
49
+ # #report_uncovered_code has run.
50
+ def uncovered_code
51
+ @uncovered_code || []
52
+ end
53
+
25
54
  private
26
55
 
27
- attr_reader :config, :parser
56
+ attr_reader :config, :parser, :registry, :target
28
57
 
58
+ # The parser finds every subject there is; those of a kind no operator of
59
+ # this run mutates are left out, so that they neither count as covering
60
+ # their lines nor show up with nothing generated for them.
29
61
  def parse_subjects
30
- target_files.flat_map { |file| parser.call(file) }
62
+ target_files.flat_map { |file| parser.call(file) }.select { |subject| registry.accepts?(subject) }
31
63
  end
32
64
 
33
- def source_glob_target?
34
- config.target&.start_with?("source:")
65
+ def uncovered_entry(file, file_subjects, mutated_lines)
66
+ range = config.line_ranges[file]
67
+ return unless range || file_subjects.empty?
68
+
69
+ lines = Evilution::AST::UncoveredCode.call(file, file_subjects, lines: range, mutated_lines: mutated_lines)
70
+ { file: file, lines: lines.map { |run| line_label(run) } } unless lines.empty?
35
71
  end
36
72
 
37
- def descendants_target?
38
- config.target&.start_with?("descendants:")
73
+ # Spelled the way a range is passed on the command line: `2-4`, or `9`.
74
+ def line_label(run)
75
+ run.size == 1 ? run.first.to_s : "#{run.first}-#{run.last}"
39
76
  end
40
77
 
41
- def method_target?
42
- config.target? && !source_glob_target? && !descendants_target?
78
+ def warn_uncovered(entry)
79
+ Evilution::Diagnostic.warn(
80
+ "[evilution] #{entry[:file]}:#{entry[:lines].join(", ")} holds code outside every subject " \
81
+ "(class-body code such as DSL calls is not mutated); no mutations target those lines."
82
+ )
43
83
  end
44
84
 
45
85
  def resolve_source_glob
46
- pattern = config.target.delete_prefix("source:")
86
+ pattern = target.value
47
87
  files = Dir.glob(pattern)
48
88
  raise Evilution::Error, "no files found matching '#{pattern}'" if files.empty?
49
89
 
@@ -51,10 +91,9 @@ class Evilution::Runner::SubjectPipeline
51
91
  end
52
92
 
53
93
  def filter_by_descendants(subjects)
54
- base_name = config.target.delete_prefix("descendants:")
55
94
  inheritance = Evilution::AST::InheritanceScanner.call(target_files)
56
- class_names = resolve_descendant_set(base_name, inheritance)
57
- raise Evilution::Error, "no classes found matching '#{config.target}'" if class_names.empty?
95
+ class_names = resolve_descendant_set(target.value, inheritance)
96
+ raise Evilution::Error, "no classes found matching '#{target}'" if class_names.empty?
58
97
 
59
98
  subjects.select { |s| class_names.include?(s.name.split(/[#.]/).first) }
60
99
  end
@@ -80,14 +119,14 @@ class Evilution::Runner::SubjectPipeline
80
119
  end
81
120
 
82
121
  def filter_by_target(subjects)
83
- matched = subjects.select(&target_matcher)
122
+ matched = subjects.select { |subject| target.selects?(subject) }
84
123
  raise Evilution::Error, build_no_match_error if matched.empty?
85
124
 
86
125
  matched
87
126
  end
88
127
 
89
128
  def resolve_target_files
90
- return resolve_source_glob if source_glob_target?
129
+ return resolve_source_glob if target.source_glob?
91
130
  return config.target_files unless config.target_files.empty?
92
131
 
93
132
  @used_git_fallback = true
@@ -95,38 +134,13 @@ class Evilution::Runner::SubjectPipeline
95
134
  end
96
135
 
97
136
  def build_no_match_error
98
- base = "no subject matched '#{config.target}'"
137
+ base = "no subject matched '#{target}'"
99
138
  return base unless @used_git_fallback
100
139
 
101
140
  "#{base}; scanned git-changed files only. Pass file paths or " \
102
141
  "--target source:<glob> to scan the full codebase."
103
142
  end
104
143
 
105
- def target_matcher
106
- target = config.target
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}.") }
128
- end
129
-
130
144
  def filter_by_line_ranges(subjects)
131
145
  subjects.select do |subject|
132
146
  range = config.line_ranges[subject.file_path]
@@ -38,6 +38,7 @@ class Evilution::Runner
38
38
  baseline_result = run_baseline(subjects)
39
39
 
40
40
  plan = mutation_planner.call(subjects)
41
+ subject_pipeline.report_uncovered_code(plan.mutated_lines)
41
42
  release_subject_nodes(subjects)
42
43
  clear_operator_caches
43
44
  execution = run_mutations(plan.enabled, baseline_result)
@@ -52,7 +53,9 @@ class Evilution::Runner
52
53
  disabled_mutations: plan.disabled_mutations,
53
54
  unresolved_target_files: target_spec_audit.call(audited_files),
54
55
  target_file_count: audited_files.length,
55
- infra_retried: execution.infra_retried)
56
+ infra_retried: execution.infra_retried,
57
+ uncovered_code: subject_pipeline.uncovered_code,
58
+ baseline_failures: baseline_result ? baseline_result.failures : [])
56
59
  output_report(summary)
57
60
  save_session(summary)
58
61
 
@@ -68,7 +71,7 @@ class Evilution::Runner
68
71
  attr_reader :parser, :registry, :cache, :on_result, :hooks
69
72
 
70
73
  def subject_pipeline
71
- @subject_pipeline ||= Evilution::Runner::SubjectPipeline.new(config, parser: parser)
74
+ @subject_pipeline ||= Evilution::Runner::SubjectPipeline.new(config, parser: parser, registry: registry)
72
75
  end
73
76
 
74
77
  def mutation_planner
@@ -1,14 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # What mutations are generated for and reported against. A :method subject is
4
+ # a method definition; a :constant subject is a definition assigned to a
5
+ # constant outside any method, such as `Point = Data.define(:x, :y)`. A :scope
6
+ # or :aasm subject is the body of a callable handed to a class-body
7
+ # declaration.
8
+ #
9
+ # A :constant_write subject is any other assignment to a constant, the whole
10
+ # of it: `LIMIT = 10`. Applying a mutation re-evaluates the file, which
11
+ # assigns the constant again, and evaluating the original after the tests
12
+ # puts it back. What read the constant while the file first loaded is
13
+ # not run again -- a class-body call (`validates length: { maximum: LIMIT }`)
14
+ # or a constant computed from it in another file keeps the old value -- so
15
+ # only code that reads the constant when called sees the mutant.
16
+ #
17
+ # A subject written in a concern's `included` or `prepended` block is marked
18
+ # `concern_hook`: the block runs in each class that takes the concern, so a
19
+ # mutation of it has to be run again on the classes that took it already.
3
20
  class Evilution::Subject
4
- attr_reader :name, :file_path, :line_number, :source, :node
21
+ attr_reader :name, :file_path, :line_number, :source, :node, :kind
5
22
 
6
- def initialize(name:, file_path:, line_number:, source:, node:)
23
+ def initialize(name:, file_path:, line_number:, source:, node:, kind: :method, concern_hook: false)
7
24
  @name = name
8
25
  @file_path = file_path
9
26
  @line_number = line_number
10
27
  @source = source
11
28
  @node = node
29
+ @kind = kind
30
+ @concern_hook = concern_hook
31
+ end
32
+
33
+ def concern_hook?
34
+ @concern_hook
12
35
  end
13
36
 
14
37
  def release_node!
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Evilution
4
- VERSION = "1.3.0"
4
+ VERSION = "1.5.0"
5
5
  end
data/lib/evilution.rb CHANGED
@@ -14,6 +14,7 @@ require_relative "evilution/ast/inheritance_scanner"
14
14
  require_relative "evilution/ast/pattern"
15
15
  require_relative "evilution/ast/sorbet_sig_detector"
16
16
  require_relative "evilution/ast/local_reads"
17
+ require_relative "evilution/ast/regexp_pattern"
17
18
  require_relative "evilution/ast/pattern/matcher"
18
19
  require_relative "evilution/ast/pattern/parser"
19
20
  require_relative "evilution/hooks"
@@ -26,6 +27,8 @@ require_relative "evilution/mutator/operator/comparison_replacement"
26
27
  require_relative "evilution/mutator/operator/boolean_literal_replacement"
27
28
  require_relative "evilution/mutator/operator/integer_literal"
28
29
  require_relative "evilution/mutator/operator/float_literal"
30
+ require_relative "evilution/mutator/operator/complex_literal"
31
+ require_relative "evilution/mutator/operator/rational_literal"
29
32
  require_relative "evilution/mutator/operator/nil_replacement"
30
33
  require_relative "evilution/mutator/operator/boolean_operator_replacement"
31
34
  require_relative "evilution/mutator/operator/boolean_operand_promotion"
@@ -53,6 +56,51 @@ require_relative "evilution/mutator/operator/receiver_constructor_swap"
53
56
  require_relative "evilution/mutator/operator/block_body_to_nil"
54
57
  require_relative "evilution/mutator/operator/block_body_to_raise"
55
58
  require_relative "evilution/mutator/operator/block_body_promotion"
59
+ require_relative "evilution/mutator/operator/data_struct_member"
60
+ require_relative "evilution/mutator/operator/pin_operator_removal"
61
+ require_relative "evilution/mutator/operator/rightward_assignment"
62
+ require_relative "evilution/mutator/operator/numbered_parameter_swap"
63
+ require_relative "evilution/mutator/operator/forwarded_argument_drop"
64
+ require_relative "evilution/mutator/operator/pattern_wildcard_widening"
65
+ require_relative "evilution/mutator/operator/no_matching_pattern_else"
66
+ require_relative "evilution/mutator/operator/argument_order_permutation"
67
+ require_relative "evilution/mutator/operator/keyword_value_swap"
68
+ require_relative "evilution/mutator/operator/comparison_operand_swap"
69
+ require_relative "evilution/mutator/operator/integer_division_to_fdiv"
70
+ require_relative "evilution/mutator/operator/round_half_mode_swap"
71
+ require_relative "evilution/mutator/operator/off_by_one_boundary"
72
+ require_relative "evilution/mutator/operator/format_specifier_swap"
73
+ require_relative "evilution/mutator/operator/exception_swallow"
74
+ require_relative "evilution/mutator/operator/statement_reorder"
75
+ require_relative "evilution/mutator/operator/alias_removal"
76
+ require_relative "evilution/mutator/operator/regexp_character_type_complement"
77
+ require_relative "evilution/mutator/operator/regexp_anchor_promotion"
78
+ require_relative "evilution/mutator/operator/regexp_alternation_branch_deletion"
79
+ require_relative "evilution/mutator/operator/regexp_quantifier_minimum_swap"
80
+ require_relative "evilution/mutator/operator/regexp_capture_to_passive"
81
+ require_relative "evilution/mutator/operator/regexp_named_group_rename"
82
+ require_relative "evilution/mutator/operator/regexp_option_removal"
83
+ require_relative "evilution/mutator/operator/rescue_handler_promotion"
84
+ require_relative "evilution/mutator/operator/rescue_handler_concatenation"
85
+ require_relative "evilution/mutator/operator/rescue_else_concatenation"
86
+ require_relative "evilution/mutator/operator/return_keyword_removal"
87
+ require_relative "evilution/mutator/operator/memoization_break"
88
+ require_relative "evilution/mutator/operator/short_circuit_to_eager"
89
+ require_relative "evilution/mutator/operator/freeze_removal"
90
+ require_relative "evilution/mutator/operator/rescue_class_widening"
91
+ require_relative "evilution/mutator/operator/visibility_removal"
92
+ require_relative "evilution/mutator/operator/attr_accessor_swap"
93
+ require_relative "evilution/mutator/operator/sleep_to_zero"
94
+ require_relative "evilution/mutator/operator/index_receiver_to_self"
95
+ require_relative "evilution/mutator/operator/index_to_key_predicate"
96
+ require_relative "evilution/mutator/operator/index_range_to_drop"
97
+ require_relative "evilution/mutator/operator/index_write_to_value"
98
+ require_relative "evilution/mutator/operator/constant_read_to_nil"
99
+ require_relative "evilution/mutator/operator/constant_namespace_strip"
100
+ require_relative "evilution/mutator/operator/constant_write_to_nil"
101
+ require_relative "evilution/mutator/operator/variable_read_to_nil"
102
+ require_relative "evilution/mutator/operator/operator_write_receiver_promotion"
103
+ require_relative "evilution/mutator/operator/safe_navigation_insertion"
56
104
  require_relative "evilution/mutator/operator/arithmetic_replacement"
57
105
  require_relative "evilution/mutator/operator/string_literal"
58
106
  require_relative "evilution/mutator/operator/array_literal"
@@ -205,6 +253,48 @@ module Evilution
205
253
  @in_isolated_worker = previous
206
254
  end
207
255
 
256
+ # Marks the stretch in which the tests run against a mutation whose source has
257
+ # already loaded. A fork child that dies in it reports the death as the
258
+ # mutation's doing; one that dies earlier cannot say so.
259
+ #
260
+ # The child asks at exit, and by then the block is over in one of two ways.
261
+ # A stack overflow that exhausts the machine stack before the VM's own makes
262
+ # Ruby run neither `rescue` nor `ensure`, so the mark is still set. Which of
263
+ # the two stacks gives out first depends on the stack limit of the process,
264
+ # not on the Ruby version. Otherwise the exception unwinds through here, the
265
+ # mark is cleared, and the exception itself is kept to be recognised by.
266
+ def self.while_running_tests
267
+ previous = @running_tests
268
+ @running_tests = true
269
+ @tests_left_by = nil
270
+ yield
271
+ rescue Exception => e # rubocop:disable Lint/RescueException -- recorded and re-raised, never handled
272
+ @tests_left_by = e
273
+ raise
274
+ ensure
275
+ @running_tests = previous
276
+ end
277
+
278
+ def self.running_tests?
279
+ @running_tests == true
280
+ end
281
+
282
+ # Whether a process dying on `error` is dying of its test run.
283
+ def self.died_running_tests?(error)
284
+ running_tests? || @tests_left_by.equal?(error)
285
+ end
286
+
287
+ # Set by fork isolation in the child it forks for one mutation. Such a
288
+ # process applies its mutation, runs the tests and ends, so work that only
289
+ # serves a later mutation in the same process can be left out of it.
290
+ def self.single_mutation_process!
291
+ @single_mutation_process = true
292
+ end
293
+
294
+ def self.single_mutation_process?
295
+ @single_mutation_process == true
296
+ end
297
+
208
298
  # Base directory for resolving project-relative paths. An isolated worker
209
299
  # has chdir'd into a per-mutation sandbox (EV-wqxu / GH #1278), so callers
210
300
  # in that context must anchor against PROJECT_ROOT rather than Dir.pwd —
data/script/memory_check CHANGED
@@ -11,6 +11,13 @@ COMPLEX_FIXTURE = File.expand_path("../spec/support/fixtures/memory_check/target
11
11
  COMPLEX_FIXTURE_SPEC = File.expand_path("../spec/support/fixtures/memory_check/target_examples.rb", __dir__)
12
12
  ITERATIONS = Integer(ENV.fetch("MEMORY_CHECK_ITERATIONS", 50))
13
13
  MAX_GROWTH_KB = Integer(ENV.fetch("MEMORY_CHECK_MAX_GROWTH_KB", 10_240))
14
+ # Every check that picks mutations at random draws from its own Random seeded
15
+ # with this, so a run exercises the same mutations every time and a failure can
16
+ # be replayed by passing the printed seed back.
17
+ SEED = Integer(ENV.fetch("MEMORY_CHECK_SEED", 42))
18
+ COMPLEX_POOL_SIZE = 10
19
+ COMPLEX_ITERATIONS = Integer(ENV.fetch("MEMORY_CHECK_COMPLEX_ITERATIONS", 100))
20
+ COMPLEX_MAX_GROWTH_KB = Integer(ENV.fetch("MEMORY_CHECK_COMPLEX_MAX_GROWTH_KB", 10_240))
14
21
 
15
22
  def setup_workload
16
23
  parser = Evilution::AST::Parser.new
@@ -21,25 +28,40 @@ def setup_workload
21
28
  mutations
22
29
  end
23
30
 
24
- def run_check(name, iterations: ITERATIONS, max_growth_kb: MAX_GROWTH_KB, &)
25
- check = Evilution::Memory::LeakCheck.new(iterations: iterations, max_growth_kb: max_growth_kb)
31
+ def run_check(name, iterations: ITERATIONS, max_growth_kb: MAX_GROWTH_KB, picked: nil,
32
+ warmup_iterations: Evilution::Memory::LeakCheck::WARMUP_ITERATIONS, &)
33
+ check = Evilution::Memory::LeakCheck.new(iterations: iterations, max_growth_kb: max_growth_kb,
34
+ warmup_iterations: warmup_iterations)
26
35
  result = check.run(&)
27
- report(name, result)
36
+ report(name, result, picked)
28
37
  result[:passed]
29
38
  end
30
39
 
31
- def report(name, result)
40
+ def report(name, result, picked)
32
41
  status = result[:passed] ? "PASS" : "FAIL"
33
42
  metrics = format_metrics(result)
34
43
 
35
44
  puts "[#{status}] #{name}"
36
- puts " Growth: #{metrics[:growth]} (max: #{metrics[:max]})"
45
+ puts " Sustained growth: #{metrics[:sustained]} (max: #{metrics[:max]})"
46
+ puts " Growth: #{metrics[:growth]}"
37
47
  puts " Samples (MB): #{metrics[:samples]}"
48
+ report_picked(picked) unless result[:passed]
38
49
  puts
39
50
  end
40
51
 
52
+ # What a failed run exercised, so it can be replayed: the seed, and how often
53
+ # each operator came up among the mutations the check picked.
54
+ def report_picked(picked)
55
+ puts " Seed: #{SEED} (MEMORY_CHECK_SEED)"
56
+ return if picked.nil? || picked.empty?
57
+
58
+ tally = picked.map(&:operator_name).tally.sort_by { |operator, count| [-count, operator.to_s] }
59
+ puts " Operators (#{picked.size} picks): #{tally.map { |operator, count| "#{operator} x#{count}" }.join(", ")}"
60
+ end
61
+
41
62
  def format_metrics(result)
42
63
  {
64
+ sustained: result[:sustained_growth_mb] ? format("%.1f MB", result[:sustained_growth_mb]) : "N/A",
43
65
  growth: result[:growth_mb] ? format("%.1f MB", result[:growth_mb]) : "N/A",
44
66
  max: format("%.1f MB", result[:max_growth_kb] / 1024.0),
45
67
  samples: result[:samples].map { |s| s ? format("%.1f", s / 1024.0) : "N/A" }.join(" -> ")
@@ -53,15 +75,21 @@ stub_test_command = ->(_m) { { passed: false } }
53
75
  all_passed = true
54
76
 
55
77
  # 1. InProcess isolation
56
- all_passed &= run_check("InProcess isolation") do
57
- mutation = mutations.sample
78
+ random = Random.new(SEED)
79
+ picked = []
80
+ all_passed &= run_check("InProcess isolation", picked: picked) do
81
+ mutation = mutations.sample(random: random)
82
+ picked << mutation
58
83
  isolator = Evilution::Isolation::InProcess.new
59
84
  isolator.call(mutation: mutation, test_command: stub_test_command, timeout: 5)
60
85
  end
61
86
 
62
87
  # 2. Fork isolation
63
- all_passed &= run_check("Fork isolation") do
64
- mutation = mutations.sample
88
+ random = Random.new(SEED)
89
+ picked = []
90
+ all_passed &= run_check("Fork isolation", picked: picked) do
91
+ mutation = mutations.sample(random: random)
92
+ picked << mutation
65
93
  isolator = Evilution::Isolation::Fork.new
66
94
  isolator.call(mutation: mutation, test_command: stub_test_command, timeout: 5)
67
95
  end
@@ -113,13 +141,31 @@ complex_subjects = complex_parser.call(COMPLEX_FIXTURE)
113
141
  complex_mutations = complex_subjects.flat_map { |s| complex_registry.mutations_for(s) }
114
142
 
115
143
  integration = Evilution::Integration::RSpec.new(test_files: [COMPLEX_FIXTURE_SPEC])
116
-
117
- # Budget is generous: per-mutation require() adds the file to $LOADED_FEATURES and
118
- # accumulates constant-redefinition warnings. Local runs land around 20 MB; CI
119
- # varies up to ~30 MB depending on Ruby build and GC pressure, so we leave headroom.
120
- all_passed &= run_check("RSpec integration per-mutation (Config)", iterations: 20, max_growth_kb: 40_960) do
121
- mutation = complex_mutations.sample
122
- integration.call(mutation)
144
+ complex_pool = complex_mutations.sample(COMPLEX_POOL_SIZE, random: Random.new(SEED))
145
+ complex_turn = complex_pool.cycle
146
+
147
+ # This check used to grow 20-41 MB over 20 mutations, which was put down to
148
+ # per-mutation require() and redefinition warnings and given a 40 MB budget. It
149
+ # was a leak: every run left its examples reachable from
150
+ # RSpec::Core::AnonymousExampleGroup, about 1 MB per mutation (fixed by
151
+ # StateGuard::AnonymousExampleGroupExamples).
152
+ #
153
+ # The first run of a mutation still grows the process, as the heap reaches the
154
+ # size that mutation needs, and the growth depends on which mutation it is. So
155
+ # the check cycles through a fixed pool: warm-up runs each mutation twice, and
156
+ # every measured run repeats work the warm-up already did. Growth from there
157
+ # on is retained per run.
158
+ #
159
+ # The budget is 10 MB over 100 runs, about 100 KB per mutation: a leak of 200 KB
160
+ # per mutation grows about 18 MB once its largest step is taken out, and fails.
161
+ # Measured on Linux (aarch64) over seeds 1, 7, 42 and 99, sustained growth was
162
+ # -0.9 to 0.6 MB on Ruby 3.4.10 and 0.7 to 2.8 MB on 4.0.7. Ruby 4.0.7 creeps
163
+ # by about 30 KB per mutation in RSS with no Ruby objects retained, which is
164
+ # below what this check can tell apart from allocator noise.
165
+ all_passed &= run_check("RSpec integration per-mutation (Config)",
166
+ iterations: COMPLEX_ITERATIONS, max_growth_kb: COMPLEX_MAX_GROWTH_KB,
167
+ warmup_iterations: COMPLEX_POOL_SIZE * 2, picked: complex_pool) do
168
+ integration.call(complex_turn.next)
123
169
  end
124
170
 
125
171
  puts all_passed ? "All memory checks passed." : "Some memory checks failed!"