evilution 1.3.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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +62 -0
  3. data/.rubocop_todo.yml +5 -0
  4. data/CHANGELOG.md +77 -0
  5. data/README.md +80 -24
  6. data/docs/architecture.md +89 -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 +119 -16
  15. data/lib/evilution/ast/pattern/method_name.rb +63 -0
  16. data/lib/evilution/ast/pattern/parser.rb +14 -15
  17. data/lib/evilution/ast/regexp_pattern.rb +104 -0
  18. data/lib/evilution/ast/scope_declaration.rb +45 -0
  19. data/lib/evilution/ast/uncovered_code.rb +88 -0
  20. data/lib/evilution/ast/value_object_definition.rb +25 -0
  21. data/lib/evilution/baseline/failure_formatter.rb +27 -0
  22. data/lib/evilution/baseline/report.rb +66 -0
  23. data/lib/evilution/baseline/spec_failure.rb +38 -0
  24. data/lib/evilution/baseline.rb +67 -30
  25. data/lib/evilution/cli/parser/file_args.rb +2 -1
  26. data/lib/evilution/cli/parser/options_builder.rb +1 -1
  27. data/lib/evilution/cli.rb +3 -2
  28. data/lib/evilution/config/validators/spec_mappings.rb +2 -1
  29. data/lib/evilution/config.rb +3 -2
  30. data/lib/evilution/equivalent/detector.rb +3 -1
  31. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
  32. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
  33. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
  34. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
  35. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
  36. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
  37. data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
  38. data/lib/evilution/example_filter.rb +45 -0
  39. data/lib/evilution/hooks/registry.rb +2 -1
  40. data/lib/evilution/integration/base.rb +2 -0
  41. data/lib/evilution/integration/known_failures.rb +26 -0
  42. data/lib/evilution/integration/loading/body_call_neutralizer.rb +132 -13
  43. data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
  44. data/lib/evilution/integration/loading/concern_redeclaration.rb +61 -0
  45. data/lib/evilution/integration/loading/concern_state_cleaner.rb +19 -12
  46. data/lib/evilution/integration/loading/mutation_applier.rb +16 -0
  47. data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
  48. data/lib/evilution/integration/minitest/test_ids.rb +21 -0
  49. data/lib/evilution/integration/minitest.rb +45 -6
  50. data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
  51. data/lib/evilution/integration/rspec/example_ids.rb +35 -0
  52. data/lib/evilution/integration/rspec/result_builder.rb +6 -1
  53. data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
  54. data/lib/evilution/integration/rspec/state_guard.rb +4 -1
  55. data/lib/evilution/integration/rspec.rb +22 -4
  56. data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
  57. data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
  58. data/lib/evilution/integration/test_unit.rb +37 -9
  59. data/lib/evilution/isolation/fork.rb +2 -1
  60. data/lib/evilution/isolation/in_process.rb +21 -4
  61. data/lib/evilution/mcp/info_tool/status_glossary.rb +2 -2
  62. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
  63. data/lib/evilution/memory/leak_check.rb +27 -3
  64. data/lib/evilution/mutation.rb +7 -2
  65. data/lib/evilution/mutator/base.rb +49 -5
  66. data/lib/evilution/mutator/operator/alias_removal.rb +126 -0
  67. data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
  68. data/lib/evilution/mutator/operator/argument_removal.rb +9 -1
  69. data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
  70. data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
  71. data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
  72. data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
  73. data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
  74. data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
  75. data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
  76. data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
  77. data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
  78. data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
  79. data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
  80. data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
  81. data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
  82. data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
  83. data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
  84. data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
  85. data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
  86. data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
  87. data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
  88. data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
  89. data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
  90. data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
  91. data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
  92. data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
  93. data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
  94. data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
  95. data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
  96. data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
  97. data/lib/evilution/mutator/operator/send_mutation.rb +2 -0
  98. data/lib/evilution/mutator/operator/splat_operator.rb +38 -13
  99. data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
  100. data/lib/evilution/mutator/registry.rb +31 -2
  101. data/lib/evilution/mutator/rescue_handlers.rb +81 -0
  102. data/lib/evilution/process_supervisor.rb +3 -2
  103. data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
  104. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  105. data/lib/evilution/reporter/json/baseline.rb +15 -0
  106. data/lib/evilution/reporter/json.rb +7 -2
  107. data/lib/evilution/result/baseline_neutralization.rb +10 -0
  108. data/lib/evilution/result/mutation_result.rb +9 -1
  109. data/lib/evilution/result/summary.rb +42 -2
  110. data/lib/evilution/runner/baseline_runner.rb +9 -4
  111. data/lib/evilution/runner/canary.rb +2 -57
  112. data/lib/evilution/runner/canary_failure_message.rb +101 -0
  113. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +22 -8
  114. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
  115. data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
  116. data/lib/evilution/runner/mutation_executor.rb +1 -1
  117. data/lib/evilution/runner/mutation_planner.rb +11 -2
  118. data/lib/evilution/runner/report_publisher.rb +3 -2
  119. data/lib/evilution/runner/subject_pipeline.rb +46 -1
  120. data/lib/evilution/runner.rb +4 -1
  121. data/lib/evilution/subject.rb +8 -2
  122. data/lib/evilution/version.rb +1 -1
  123. data/lib/evilution.rb +28 -0
  124. data/script/memory_check +62 -16
  125. metadata +83 -4
@@ -13,19 +13,23 @@ class Evilution::Integration::Loading::ConcernStateCleaner
13
13
 
14
14
  # ObjectSpace hands us every Module in the VM, including ones that answer no
15
15
  # message honestly. ActiveSupport::Deprecation::DeprecatedConstantProxy
16
- # subclasses Module and undefines all of its instance methods, so a plain
17
- # `mod.singleton_class` lands in its method_missing and emits the
18
- # deprecation; where the deprecator's behaviour is :raise (grape's
19
- # spec_helper sets that), the sweep blows up on a module it merely walked
20
- # past. Calling through UnboundMethods bypasses the object's own method
21
- # table, so a proxy is never given the chance to intercept -- and this holds
22
- # for any method_missing-based wrapper, not just ActiveSupport's.
16
+ # subclasses Module and undefines all of its instance methods, so asking it
17
+ # anything lands in its method_missing and emits the deprecation; where the
18
+ # deprecator's behaviour is :raise (grape's spec_helper sets that), the
19
+ # sweep blows up on a module it merely walked past. So the module is never
20
+ # asked: `Concern === mod` is answered by Concern, and the ivars are reached
21
+ # through UnboundMethods, which bypass the object's own method table -- this
22
+ # holds for any method_missing-based wrapper, not just ActiveSupport's.
23
23
  # EV-v2rc / GH #1581.
24
- SINGLETON_CLASS = Object.instance_method(:singleton_class)
24
+ #
25
+ # `===` also leaves the module as it found it. Asking for its
26
+ # singleton_class instead would create one for every module in the VM, each
27
+ # of them a module the next sweep has to visit and give a singleton class
28
+ # of its own; a long in-process run doubles its module count per mutation.
25
29
  IVAR_DEFINED = Object.instance_method(:instance_variable_defined?)
26
30
  IVAR_GET = Object.instance_method(:instance_variable_get)
27
31
  REMOVE_IVAR = Object.instance_method(:remove_instance_variable)
28
- private_constant :SINGLETON_CLASS, :IVAR_DEFINED, :IVAR_GET, :REMOVE_IVAR
32
+ private_constant :IVAR_DEFINED, :IVAR_GET, :REMOVE_IVAR
29
33
 
30
34
  def initialize(subpath_resolver: Evilution::LoadPath::SubpathResolver.new)
31
35
  @subpath_resolver = subpath_resolver
@@ -34,11 +38,14 @@ class Evilution::Integration::Loading::ConcernStateCleaner
34
38
  def call(file_path)
35
39
  return unless defined?(ActiveSupport::Concern)
36
40
 
37
- absolute = File.expand_path(file_path)
38
- subpath = @subpath_resolver.call(file_path)
41
+ # Anchored on the project, not Dir.pwd: an isolated worker has chdir'd
42
+ # into a per-mutation sandbox, where a relative path names nothing and no
43
+ # concern would ever match.
44
+ absolute = File.expand_path(file_path, Evilution.project_base_dir)
45
+ subpath = @subpath_resolver.call(absolute)
39
46
 
40
47
  ObjectSpace.each_object(Module) do |mod|
41
- next unless SINGLETON_CLASS.bind_call(mod).ancestors.include?(ActiveSupport::Concern)
48
+ next unless ActiveSupport::Concern === mod # rubocop:disable Style/CaseEquality
42
49
 
43
50
  clear_concern_ivars(mod, absolute, subpath)
44
51
  end
@@ -6,6 +6,7 @@ require_relative "constant_pinner"
6
6
  require_relative "concern_state_cleaner"
7
7
  require_relative "source_evaluator"
8
8
  require_relative "redefinition_recovery"
9
+ require_relative "../../diagnostic"
9
10
 
10
11
  # Composes the load-time pipeline that applies a mutation's new source to the
11
12
  # running VM: syntax-validate -> pin top-level constants (beats Zeitwerk) ->
@@ -45,6 +46,21 @@ class Evilution::Integration::Loading::MutationApplier
45
46
  failure_result(e, "#{e.class}: #{e.message}")
46
47
  end
47
48
 
49
+ # Evaluates the mutation's restore_source, if it has one. Runs after the
50
+ # tests: a failure here must not change the mutation's result, so it is
51
+ # reported and swallowed. Concern state is cleared first, as it is before
52
+ # applying: a concern ignores an `included` block handed to it twice from
53
+ # the same place, and would keep the mutated one.
54
+ def restore(mutation)
55
+ source = mutation.respond_to?(:restore_source) ? mutation.restore_source : nil
56
+ return unless source
57
+
58
+ @concern_state_cleaner.call(mutation.file_path)
59
+ @source_evaluator.call(source, mutation.file_path)
60
+ rescue ScriptError, StandardError => e
61
+ Evilution::Diagnostic.warn("[evilution] could not restore #{mutation.file_path}: #{e.class}: #{e.message}")
62
+ end
63
+
48
64
  private
49
65
 
50
66
  def resolve_eval_target(mutation)
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../loading"
4
+
5
+ # Remembers which test classes each test file registered when it was loaded,
6
+ # so a process that runs many mutations loads a test file once.
7
+ #
8
+ # Minitest and Test::Unit learn of a test class when it is first defined.
9
+ # Loading its file again reopens the class and registers nothing, so the
10
+ # second mutation run in a process would find no tests. The classes of the
11
+ # first load are handed back instead.
12
+ #
13
+ # The block given to .new returns the classes registered so far; what a load
14
+ # adds to them is taken to be that file's.
15
+ class Evilution::Integration::Loading::TestClassCache
16
+ def initialize(&registered)
17
+ @registered = registered
18
+ @classes = {}
19
+ @partial = {}
20
+ end
21
+
22
+ # The classes the given files registered. Yields each file that has not
23
+ # been loaded yet; the block loads it.
24
+ def fetch(files, &)
25
+ files.flat_map { |file| @classes[file] ||= load_once(file, &) }.uniq
26
+ end
27
+
28
+ def clear
29
+ @classes.clear
30
+ @partial.clear
31
+ end
32
+
33
+ private
34
+
35
+ # A load that raises is tried again next time. Classes it defined before
36
+ # raising are already registered and will not register again, so they are
37
+ # kept for that next attempt.
38
+ def load_once(file)
39
+ before = @registered.call
40
+ yield file
41
+ @partial.fetch(file, []) | (@registered.call - before)
42
+ rescue ScriptError, StandardError
43
+ @partial[file] = @partial.fetch(file, []) | (@registered.call - before)
44
+ raise
45
+ end
46
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../minitest"
4
+
5
+ # Names a Minitest test the same way in the baseline and in a mutation run:
6
+ # its class and its method, `CalcTest#test_adds`.
7
+ #
8
+ # A test class without a name has only its method to go by, so two such
9
+ # classes with a method of the same name are told apart by neither run.
10
+ module Evilution::Integration::Minitest::TestIds
11
+ def self.of(result)
12
+ class_name = result.respond_to?(:class_name) ? result.class_name : result.class.name
13
+ "#{class_name}##{result.name}"
14
+ end
15
+
16
+ # The ids of the tests that failed, from the summary reporter of the run.
17
+ # It keeps every result that did not pass, skips included.
18
+ def self.failed(summary)
19
+ summary.results.reject(&:skipped?).map { |result| of(result) }
20
+ end
21
+ end
@@ -4,9 +4,12 @@ require "stringio"
4
4
  require_relative "base"
5
5
  require_relative "../diagnostic"
6
6
  require_relative "minitest_crash_detector"
7
+ require_relative "known_failures"
7
8
  require_relative "loading/test_load_path"
9
+ require_relative "loading/test_class_cache"
8
10
  require_relative "../spec_resolver"
9
11
  require_relative "../spec_selector"
12
+ require_relative "../baseline"
10
13
 
11
14
  require_relative "../integration"
12
15
 
@@ -47,12 +50,22 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
47
50
  options = ::Minitest.process_args(["--seed", "0"])
48
51
  options[:io] = out
49
52
  reporter = ::Minitest::CompositeReporter.new
50
- reporter << ::Minitest::SummaryReporter.new(out, options)
53
+ summary = ::Minitest::SummaryReporter.new(out, options)
54
+ reporter << summary
51
55
  initialize_minitest_state(reporter, options)
52
56
  reporter.start
53
57
  dispatch_minitest_suites(reporter, options)
54
58
  reporter.report
55
- reporter.passed?
59
+ Evilution::Baseline::Report.build(
60
+ passed: reporter.passed?, failures: baseline_failures(summary), failed_ids: TestIds.failed(summary)
61
+ )
62
+ end
63
+
64
+ # The summary reporter keeps every result that did not pass, skips included.
65
+ def self.baseline_failures(summary)
66
+ summary.results.reject(&:skipped?).map do |result|
67
+ { id: TestIds.of(result), description: "", message: result.failure.message }
68
+ end
56
69
  end
57
70
 
58
71
  # Mirror Minitest.run's preamble: seed setup + plugin init. Without seeding
@@ -84,6 +97,12 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
84
97
  end
85
98
  end
86
99
 
100
+ # Shared by every instance: the canary and the mutation runs build their
101
+ # own, and a file loaded for one must not be loaded again for the other.
102
+ def self.test_classes
103
+ @test_classes ||= Evilution::Integration::Loading::TestClassCache.new { ::Minitest::Runnable.runnables.dup }
104
+ end
105
+
87
106
  def self.baseline_options
88
107
  {
89
108
  runner: baseline_runner,
@@ -92,8 +111,10 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
92
111
  }
93
112
  end
94
113
 
95
- def initialize(test_files: nil, hooks: nil, fallback_to_full_suite: false, spec_selector: nil)
114
+ # known_failures: the ids of the tests the baseline saw failing (see TestIds).
115
+ def initialize(test_files: nil, hooks: nil, fallback_to_full_suite: false, spec_selector: nil, known_failures: [])
96
116
  @test_files = test_files
117
+ @known_failures = Evilution::Integration::KnownFailures.new(known_failures)
97
118
  @minitest_loaded = false
98
119
  @spec_selector = spec_selector || Evilution::SpecSelector.new(
99
120
  spec_resolver: Evilution::SpecResolver.new(test_dir: "test", test_suffix: "_test.rb", request_dir: "integration")
@@ -132,17 +153,27 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
132
153
  end
133
154
 
134
155
  def execute_minitest(mutation, files, command)
135
- Evilution::Integration::Loading::TestLoadPath.add!(files)
136
- files.each { |f| load(File.expand_path(f, Evilution.project_base_dir)) }
156
+ load_test_classes(files)
137
157
 
138
158
  detector = reset_crash_detector
139
159
  run = run_minitest(build_args(mutation), detector)
140
160
 
141
161
  return no_tests_ran_result(command) if run[:count].zero?
162
+ # Before the crash check: a test that was already failing may fail by
163
+ # raising now, and a crash in it is no more a kill than its failure was.
164
+ return known_failures_only_result(command) if @known_failures.only?(run[:failed_ids])
142
165
 
143
166
  build_minitest_result(run[:passed], command, detector)
144
167
  end
145
168
 
169
+ # Leaves Minitest with the test classes of these files and no others.
170
+ def load_test_classes(files)
171
+ Evilution::Integration::Loading::TestLoadPath.add!(files)
172
+ paths = files.map { |f| File.expand_path(f, Evilution.project_base_dir) }
173
+ classes = self.class.test_classes.fetch(paths) { |path| load(path) }
174
+ ::Minitest::Runnable.runnables.replace(classes)
175
+ end
176
+
146
177
  # Zero dispatched test methods means the run carries no signal — the result
147
178
  # is neither survived nor killed. Most common cause: the project's tests use
148
179
  # a framework other than Minitest (e.g. the test-unit gem, whose
@@ -160,6 +191,12 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
160
191
  }
161
192
  end
162
193
 
194
+ # Every test that failed was already failing in the baseline. Still a
195
+ # failed run, but flagged, so it is not taken for a kill.
196
+ def known_failures_only_result(command)
197
+ { passed: false, known_failures_only: true, test_command: command }
198
+ end
199
+
163
200
  def unresolved_result(mutation)
164
201
  {
165
202
  passed: false,
@@ -190,7 +227,7 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
190
227
  dispatch_minitest_suites(reporter, options)
191
228
  reporter.report
192
229
 
193
- { passed: summary.passed?, count: minitest_method_count }
230
+ { passed: summary.passed?, count: minitest_method_count, failed_ids: TestIds.failed(summary) }
194
231
  end
195
232
 
196
233
  # Attach evilution's own reporters to the composite AFTER plugin init, and
@@ -293,3 +330,5 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
293
330
  end
294
331
  end
295
332
  end
333
+
334
+ require_relative "minitest/test_ids"
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "stringio"
3
4
  require_relative "../rspec"
5
+ require_relative "../../baseline"
6
+ require_relative "example_ids"
4
7
 
5
8
  class Evilution::Integration::RSpec::BaselineRunner
6
9
  def call(spec_file)
@@ -9,10 +12,51 @@ class Evilution::Integration::RSpec::BaselineRunner
9
12
  # FrameworkLoader#add_spec_load_path for rationale.
10
13
  spec_dir = File.expand_path("spec", Evilution.project_base_dir)
11
14
  $LOAD_PATH.unshift(spec_dir) unless $LOAD_PATH.include?(spec_dir)
12
- ::RSpec.reset
15
+ reset_examples
16
+ output = StringIO.new
13
17
  status = ::RSpec::Core::Runner.run(
14
- ["--format", "progress", "--no-color", "--order", "defined", spec_file]
18
+ ["--format", "progress", "--no-color", "--order", "defined", spec_file], output, output
15
19
  )
16
- status.zero?
20
+ report(status.zero?, output.string)
21
+ end
22
+
23
+ private
24
+
25
+ # Examples are cleared, the configuration is kept: a helper loaded by
26
+ # --preload is not loaded again by the spec's own require, so an
27
+ # RSpec.configure block dropped here never comes back, and a spec relying on
28
+ # it goes red in the baseline alone.
29
+ def reset_examples
30
+ ::RSpec.respond_to?(:clear_examples) ? ::RSpec.clear_examples : ::RSpec.reset
31
+ end
32
+
33
+ # What RSpec printed is the only account of a failure outside any example --
34
+ # a file that does not load, a suite hook that raises.
35
+ def report(passed, output)
36
+ return Evilution::Baseline::Report.build(passed: true) if passed
37
+
38
+ failures = failed_examples
39
+ Evilution::Baseline::Report.build(
40
+ passed: false, failures: failures, error: failures.empty? ? output : nil,
41
+ failed_ids: Evilution::Integration::RSpec::ExampleIds.failed(::RSpec.world)
42
+ )
43
+ end
44
+
45
+ def failed_examples
46
+ world = ::RSpec.world
47
+ return [] unless world.respond_to?(:all_examples)
48
+
49
+ world.all_examples
50
+ .select { |example| example.execution_result.status == :failed }
51
+ .map { |example| failure(example) }
52
+ end
53
+
54
+ def failure(example)
55
+ exception = example.execution_result.exception
56
+ {
57
+ id: example.id,
58
+ description: example.full_description,
59
+ message: exception ? "#{exception.class}: #{exception.message}" : nil
60
+ }
17
61
  end
18
62
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rspec"
4
+
5
+ # Names an example the same way wherever it runs.
6
+ #
7
+ # RSpec's own id starts with the spec file as it was given: `./spec/a_spec.rb`
8
+ # from the project root, an absolute path from an isolated worker that has
9
+ # changed directory. The baseline and a mutation run can therefore disagree
10
+ # about the same example, so the path is made absolute and symlinks are
11
+ # resolved before the position within the file is added.
12
+ module Evilution::Integration::RSpec::ExampleIds
13
+ FAILED = :failed
14
+
15
+ def self.of(example)
16
+ metadata = example.metadata
17
+ "#{canonical(metadata[:rerun_file_path])}[#{metadata[:scoped_id]}]"
18
+ end
19
+
20
+ # The ids of the examples that failed in the run RSpec.world still holds.
21
+ def self.failed(world)
22
+ return [] unless world.respond_to?(:all_examples)
23
+
24
+ world.all_examples
25
+ .select { |example| example.execution_result.status == FAILED }
26
+ .map { |example| of(example) }
27
+ end
28
+
29
+ def self.canonical(path)
30
+ File.realpath(path)
31
+ rescue SystemCallError
32
+ File.expand_path(path)
33
+ end
34
+ private_class_method :canonical
35
+ end
@@ -21,8 +21,13 @@ class Evilution::Integration::RSpec::ResultBuilder
21
21
  }
22
22
  end
23
23
 
24
- def from_run(status, command, detector, examples_loaded: nil)
24
+ # known_failures_only: every example that failed was already failing in the
25
+ # baseline. Still a failed run, but flagged, so it is not taken for a kill.
26
+ def from_run(status, command, detector, examples_loaded: nil, known_failures_only: false)
25
27
  return { passed: true, test_command: command } if status.zero?
28
+ # Before the crash check: an example that was already failing may fail by
29
+ # raising now, and a crash in it is no more a kill than its failure was.
30
+ return { passed: false, known_failures_only: true, test_command: command } if known_failures_only
26
31
 
27
32
  if detector.only_crashes?
28
33
  classes = detector.unique_crash_classes
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../state_guard"
4
+ require_relative "../../rspec"
5
+
6
+ # Drops the examples a run registers on RSpec::Core::AnonymousExampleGroup.
7
+ #
8
+ # Example#initialize appends every example to its group's `examples`. Most
9
+ # groups are thrown away with the run, but AnonymousExampleGroup is a constant:
10
+ # whatever lands there stays for the life of the process. A run with a suite
11
+ # hook puts a SuiteHookContext there, and the reporter notifying a non-example
12
+ # exception puts an Example there. Each holds that run's reporter, and the
13
+ # reporter holds every example the run loaded, so one in-process run after
14
+ # another retained them all, about 1 MB per mutation on the memory_check
15
+ # fixture.
16
+ #
17
+ # Forked runs never leak this (the child dies), only the in-process path.
18
+ class Evilution::Integration::RSpec::StateGuard::AnonymousExampleGroupExamples
19
+ def snapshot
20
+ examples&.length
21
+ end
22
+
23
+ def release(length)
24
+ return unless length
25
+
26
+ examples&.slice!(length..)
27
+ end
28
+
29
+ private
30
+
31
+ def examples
32
+ return nil unless defined?(::RSpec::Core::AnonymousExampleGroup)
33
+
34
+ ::RSpec::Core::AnonymousExampleGroup.examples
35
+ end
36
+ end
@@ -12,8 +12,10 @@ require_relative "state_guard/world_example_groups"
12
12
  require_relative "state_guard/world_sources_by_path"
13
13
  require_relative "state_guard/world_filtered_examples"
14
14
  require_relative "state_guard/reporter_arrays"
15
+ require_relative "state_guard/anonymous_example_group_examples"
15
16
  require_relative "state_guard/example_groups_constants"
16
17
  require_relative "state_guard/configuration_state"
18
+ require_relative "../../diagnostic"
17
19
 
18
20
  class Evilution::Integration::RSpec::StateGuard
19
21
  DEFAULT_STRATEGIES = [
@@ -22,6 +24,7 @@ class Evilution::Integration::RSpec::StateGuard
22
24
  WorldSourcesByPath.new,
23
25
  WorldFilteredExamples.new,
24
26
  ReporterArrays.new,
27
+ AnonymousExampleGroupExamples.new,
25
28
  ExampleGroupsConstants.new,
26
29
  ConfigurationState.new
27
30
  ].freeze
@@ -43,6 +46,6 @@ class Evilution::Integration::RSpec::StateGuard
43
46
  def release_one(strategy, captured)
44
47
  strategy.release(captured)
45
48
  rescue StandardError => e
46
- warn "[evilution] state release failed for #{strategy.class.name}: #{e.class}: #{e.message}"
49
+ Evilution::Diagnostic.warn("[evilution] state release failed for #{strategy.class.name}: #{e.class}: #{e.message}")
47
50
  end
48
51
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "stringio"
4
4
  require_relative "base"
5
+ require_relative "known_failures"
5
6
  require_relative "../spec_resolver"
6
7
  require_relative "../spec_selector"
7
8
  require_relative "../related_spec_heuristic"
@@ -24,6 +25,7 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
24
25
  fallback_to_full_suite: false,
25
26
  spec_selector: nil,
26
27
  example_filter: nil,
28
+ known_failures: [],
27
29
  framework_loader: FrameworkLoader.new,
28
30
  test_file_resolver: nil,
29
31
  example_filter_applier: nil,
@@ -44,6 +46,7 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
44
46
  @crash_detector_lifecycle = crash_detector_lifecycle
45
47
  @result_builder = result_builder
46
48
  @state_guard = state_guard
49
+ @known_failures = Evilution::Integration::KnownFailures.new(known_failures)
47
50
  super(hooks: hooks)
48
51
  end
49
52
 
@@ -71,7 +74,7 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
71
74
  return @result_builder.unresolved_example(mutation) if targets.nil?
72
75
 
73
76
  result = run_targets(targets)
74
- return result unless confirm_survivor?(result, targets, files)
77
+ return result unless confirm?(result, targets, files)
75
78
 
76
79
  run_targets(files)
77
80
  end
@@ -85,8 +88,12 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
85
88
  # So a survivor is re-run against the whole resolved file, and that run is the
86
89
  # one reported. Only survivors pay for it, and only where the subset was
87
90
  # actually narrower than the file.
88
- def confirm_survivor?(result, targets, files)
89
- result[:passed] && targets != files
91
+ #
92
+ # The same goes for a subset in which nothing failed but examples that were
93
+ # failing before any mutation ran: that is no verdict either, and the rest of
94
+ # the file may hold one.
95
+ def confirm?(result, targets, files)
96
+ (result[:passed] || result[:known_failures_only]) && targets != files
90
97
  end
91
98
 
92
99
  def run_targets(targets)
@@ -142,7 +149,8 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
142
149
  claim_rspec_streams(out, err)
143
150
  detector = @crash_detector_lifecycle.register
144
151
  status = ::RSpec::Core::Runner.run(args, err, out)
145
- @result_builder.from_run(status, command, detector, examples_loaded:)
152
+ flagged = known_failures_only?(status)
153
+ @result_builder.from_run(status, command, detector, examples_loaded:, known_failures_only: flagged)
146
154
  rescue StandardError => e
147
155
  { passed: false, error: e.message, test_command: command }
148
156
  ensure
@@ -188,12 +196,22 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
188
196
  nil
189
197
  end
190
198
 
199
+ # Whether the run failed on nothing but examples the baseline already saw
200
+ # failing. Decided here, in the process that ran them: the names of every
201
+ # failing example need not travel back, only the answer.
202
+ def known_failures_only?(status)
203
+ return false if status.zero? || @known_failures.empty?
204
+
205
+ @known_failures.only?(ExampleIds.failed(::RSpec.world))
206
+ end
207
+
191
208
  def reset_examples
192
209
  ::RSpec.respond_to?(:clear_examples) ? ::RSpec.clear_examples : ::RSpec.reset
193
210
  end
194
211
  end
195
212
 
196
213
  require_relative "rspec/framework_loader"
214
+ require_relative "rspec/example_ids"
197
215
  require_relative "rspec/test_file_resolver"
198
216
  require_relative "rspec/unresolved_spec_warner"
199
217
  require_relative "rspec/example_filter_applier"
@@ -21,6 +21,12 @@ module Evilution::Integration::TestUnit::ResultBuilder
21
21
  end
22
22
  end
23
23
 
24
+ # Every test that failed was already failing in the baseline. Still a
25
+ # failed run, but flagged, so it is not taken for a kill.
26
+ def known_failures_only(command)
27
+ { passed: false, known_failures_only: true, test_command: command }
28
+ end
29
+
24
30
  def no_tests_ran(command)
25
31
  {
26
32
  passed: false,
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_unit"
4
+
5
+ # Names a Test::Unit test the same way in the baseline and in a mutation run:
6
+ # the name the framework gives it, `test_adds(CalcTest)`.
7
+ module Evilution::Integration::TestUnit::TestIds
8
+ # Only failures and errors fail a run; pendings, omissions and notifications
9
+ # are faults too, and are left out.
10
+ def self.faults(result)
11
+ result.failures + result.errors
12
+ end
13
+
14
+ # The ids of the tests that failed or raised in a finished run.
15
+ def self.failed(result)
16
+ faults(result).map(&:test_name)
17
+ end
18
+ end
@@ -2,9 +2,12 @@
2
2
 
3
3
  require_relative "base"
4
4
  require_relative "test_unit_crash_detector"
5
+ require_relative "known_failures"
5
6
  require_relative "loading/test_load_path"
7
+ require_relative "loading/test_class_cache"
6
8
  require_relative "../spec_resolver"
7
9
  require_relative "../spec_selector"
10
+ require_relative "../baseline"
8
11
 
9
12
  require_relative "../integration"
10
13
 
@@ -28,6 +31,12 @@ class Evilution::Integration::TestUnit < Evilution::Integration::Base
28
31
  Evilution::SpecResolver.new(test_dir: "test", test_suffix: "_test.rb", request_dir: "integration")
29
32
  end
30
33
 
34
+ # Shared by every instance: the canary and the mutation runs build their
35
+ # own, and a file loaded for one must not be loaded again for the other.
36
+ def self.test_classes
37
+ @test_classes ||= Evilution::Integration::Loading::TestClassCache.new { SubjectClassRegistry.descendants }
38
+ end
39
+
31
40
  def self.baseline_options
32
41
  {
33
42
  runner: baseline_runner,
@@ -40,25 +49,41 @@ class Evilution::Integration::TestUnit < Evilution::Integration::Base
40
49
  require_relative "test_unit/framework_loader"
41
50
  require_relative "test_unit/subject_class_registry"
42
51
  require_relative "test_unit/dispatcher"
52
+ require_relative "test_unit/test_ids"
43
53
  FrameworkLoader.new.call
44
54
  files = baseline_test_files(test_file)
45
55
  Evilution::Integration::Loading::TestLoadPath.add!(files)
46
56
  new_classes = SubjectClassRegistry.newly_loaded do
47
57
  files.each { |f| load(File.expand_path(f)) }
48
58
  end
49
- Dispatcher.call(new_classes, name: "evilution baseline").passed?
59
+ baseline_report(Dispatcher.call(new_classes, name: "evilution baseline"))
60
+ end
61
+
62
+ def self.baseline_report(result)
63
+ Evilution::Baseline::Report.build(
64
+ passed: result.passed?, failures: baseline_failures(result), failed_ids: TestIds.failed(result)
65
+ )
66
+ end
67
+
68
+ def self.baseline_failures(result)
69
+ TestIds.faults(result).map do |fault|
70
+ { id: fault.test_name, description: "", message: fault.message }
71
+ end
50
72
  end
51
73
 
52
74
  def self.baseline_test_files(test_file)
53
75
  File.directory?(test_file) ? Dir.glob(File.join(test_file, "**/*_test.rb")) : [test_file]
54
76
  end
55
77
 
56
- def initialize(test_files: nil, hooks: nil, fallback_to_full_suite: false, spec_selector: nil)
78
+ # known_failures: the ids of the tests the baseline saw failing (see TestIds).
79
+ def initialize(test_files: nil, hooks: nil, fallback_to_full_suite: false, spec_selector: nil, known_failures: [])
57
80
  require_relative "test_unit/framework_loader"
58
81
  require_relative "test_unit/subject_class_registry"
59
82
  require_relative "test_unit/dispatcher"
60
83
  require_relative "test_unit/test_file_resolver"
61
84
  require_relative "test_unit/result_builder"
85
+ require_relative "test_unit/test_ids"
86
+ @known_failures = Evilution::Integration::KnownFailures.new(known_failures)
62
87
  @framework_loader = FrameworkLoader.new
63
88
  @file_resolver = TestFileResolver.new(
64
89
  test_files: test_files,
@@ -97,22 +122,25 @@ class Evilution::Integration::TestUnit < Evilution::Integration::Base
97
122
 
98
123
  detector = reset_crash_detector
99
124
  result = Dispatcher.call(new_classes, name: "evilution-mutation")
125
+ # Before the crash check: a test that was already failing may fail by
126
+ # raising now, and a crash in it is no more a kill than its failure was.
127
+ return ResultBuilder.known_failures_only(command) if @known_failures.only?(TestIds.failed(result))
128
+
100
129
  result.faults.each { |fault| detector.record(fault) }
101
130
  ResultBuilder.call(passed: result.passed?, command: command, detector: detector)
102
131
  end
103
132
 
104
133
  def load_test_classes(files)
105
134
  Evilution::Integration::Loading::TestLoadPath.add!(files)
106
- SubjectClassRegistry.newly_loaded do
107
- files.each { |f| load(File.expand_path(f, Evilution.project_base_dir)) }
108
- end
135
+ paths = files.map { |f| File.expand_path(f, Evilution.project_base_dir) }
136
+ self.class.test_classes.fetch(paths) { |path| load(path) }
109
137
  end
110
138
 
111
139
  # Test::Unit has no public registry-clear analogous to
112
- # Minitest::Runnable.runnables.clear. SubjectClassRegistry's newly_loaded
113
- # block scopes each dispatch to classes loaded in *this* round, so stale
114
- # classes from prior mutations sit dormant on ObjectSpace without polluting
115
- # the run. #reset_state stays as a contract no-op for parity with Minitest.
140
+ # Minitest::Runnable.runnables.clear. Each dispatch is handed the classes of
141
+ # the files resolved for this mutation, so classes of other files sit
142
+ # dormant on ObjectSpace without polluting the run. #reset_state stays as a
143
+ # contract no-op for parity with Minitest.
116
144
  def reset_state
117
145
  # no-op — see comment above
118
146
  end