evilution 0.24.0 → 0.26.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +210 -0
  3. data/.claude/prompts/architect.md +14 -1
  4. data/.claude/skills/create-issue/SKILL.md +55 -0
  5. data/CHANGELOG.md +51 -0
  6. data/README.md +80 -4
  7. data/exe/evil +6 -0
  8. data/lib/evilution/ast/constant_names.rb +34 -0
  9. data/lib/evilution/ast/source_surgeon.rb +15 -1
  10. data/lib/evilution/cli/commands/compare.rb +68 -0
  11. data/lib/evilution/cli/parser/command_extractor.rb +2 -1
  12. data/lib/evilution/cli/parser/options_builder.rb +21 -1
  13. data/lib/evilution/cli/printers/compare.rb +159 -0
  14. data/lib/evilution/cli.rb +1 -0
  15. data/lib/evilution/compare/categorizer.rb +109 -0
  16. data/lib/evilution/compare/detector.rb +21 -0
  17. data/lib/evilution/compare/fingerprint.rb +83 -0
  18. data/lib/evilution/compare/invalid_input.rb +12 -0
  19. data/lib/evilution/compare/normalizer.rb +106 -0
  20. data/lib/evilution/compare/record.rb +16 -0
  21. data/lib/evilution/compare.rb +6 -0
  22. data/lib/evilution/config.rb +165 -3
  23. data/lib/evilution/example_filter.rb +143 -0
  24. data/lib/evilution/integration/base.rb +4 -155
  25. data/lib/evilution/integration/crash_detector.rb +5 -2
  26. data/lib/evilution/integration/loading/concern_state_cleaner.rb +49 -0
  27. data/lib/evilution/integration/loading/constant_pinner.rb +24 -0
  28. data/lib/evilution/integration/loading/mutation_applier.rb +52 -0
  29. data/lib/evilution/integration/loading/redefinition_recovery.rb +54 -0
  30. data/lib/evilution/integration/loading/source_evaluator.rb +15 -0
  31. data/lib/evilution/integration/loading/syntax_validator.rb +19 -0
  32. data/lib/evilution/integration/loading.rb +6 -0
  33. data/lib/evilution/integration/minitest.rb +10 -5
  34. data/lib/evilution/integration/minitest_crash_detector.rb +5 -2
  35. data/lib/evilution/integration/rspec.rb +82 -7
  36. data/lib/evilution/isolation/fork.rb +25 -0
  37. data/lib/evilution/load_path/subpath_resolver.rb +25 -0
  38. data/lib/evilution/load_path.rb +4 -0
  39. data/lib/evilution/mcp/info_tool.rb +77 -5
  40. data/lib/evilution/mcp/mutate_tool/config_builder.rb +20 -0
  41. data/lib/evilution/mcp/mutate_tool/error_payload.rb +17 -0
  42. data/lib/evilution/mcp/mutate_tool/option_parser.rb +54 -0
  43. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +37 -0
  44. data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +31 -0
  45. data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +52 -0
  46. data/lib/evilution/mcp/mutate_tool.rb +34 -186
  47. data/lib/evilution/mutation.rb +43 -3
  48. data/lib/evilution/mutator/base.rb +39 -1
  49. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +5 -1
  50. data/lib/evilution/mutator/operator/argument_removal.rb +5 -1
  51. data/lib/evilution/parallel/work_queue.rb +149 -31
  52. data/lib/evilution/parallel_db_warning.rb +68 -0
  53. data/lib/evilution/reporter/cli.rb +37 -11
  54. data/lib/evilution/reporter/html/assets/style.css +17 -0
  55. data/lib/evilution/reporter/html/sections/file_section.rb +15 -0
  56. data/lib/evilution/reporter/html/sections/neutral_details.rb +25 -0
  57. data/lib/evilution/reporter/html/sections/unparseable_details.rb +25 -0
  58. data/lib/evilution/reporter/html/sections/unresolved_details.rb +25 -0
  59. data/lib/evilution/reporter/html/templates/file_section.html.erb +3 -0
  60. data/lib/evilution/reporter/html/templates/neutral_details.html.erb +14 -0
  61. data/lib/evilution/reporter/html/templates/summary_cards.html.erb +3 -0
  62. data/lib/evilution/reporter/html/templates/unparseable_details.html.erb +11 -0
  63. data/lib/evilution/reporter/html/templates/unresolved_details.html.erb +11 -0
  64. data/lib/evilution/reporter/json.rb +8 -2
  65. data/lib/evilution/reporter/suggestion/diff_helpers.rb +28 -0
  66. data/lib/evilution/reporter/suggestion/registry.rb +64 -0
  67. data/lib/evilution/reporter/suggestion/templates/generic.rb +55 -0
  68. data/lib/evilution/reporter/suggestion/templates/minitest.rb +659 -0
  69. data/lib/evilution/reporter/suggestion/templates/rspec.rb +613 -0
  70. data/lib/evilution/reporter/suggestion.rb +8 -1327
  71. data/lib/evilution/result/mutation_result.rb +5 -1
  72. data/lib/evilution/result/summary.rb +13 -1
  73. data/lib/evilution/runner/baseline_runner.rb +23 -2
  74. data/lib/evilution/runner/isolation_resolver.rb +12 -1
  75. data/lib/evilution/runner/mutation_executor.rb +83 -13
  76. data/lib/evilution/runner/subject_pipeline.rb +18 -8
  77. data/lib/evilution/runner.rb +6 -0
  78. data/lib/evilution/source_ast_cache.rb +39 -0
  79. data/lib/evilution/spec_ast_cache.rb +166 -0
  80. data/lib/evilution/spec_resolver.rb +6 -1
  81. data/lib/evilution/spec_selector.rb +39 -0
  82. data/lib/evilution/temp_dir_tracker.rb +23 -3
  83. data/lib/evilution/version.rb +1 -1
  84. data/script/memory_check +7 -5
  85. metadata +46 -5
  86. data/lib/evilution/mcp/session_diff_tool.rb +0 -63
  87. data/lib/evilution/mcp/session_list_tool.rb +0 -50
  88. data/lib/evilution/mcp/session_show_tool.rb +0 -57
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "prism"
4
3
  require_relative "../integration"
4
+ require_relative "loading/mutation_applier"
5
5
 
6
6
  class Evilution::Integration::Base
7
7
  def self.baseline_runner
@@ -12,14 +12,15 @@ class Evilution::Integration::Base
12
12
  raise NotImplementedError, "#{name}.baseline_options must be implemented"
13
13
  end
14
14
 
15
- def initialize(hooks: nil)
15
+ def initialize(hooks: nil, mutation_applier: Evilution::Integration::Loading::MutationApplier.new)
16
16
  @hooks = hooks
17
+ @mutation_applier = mutation_applier
17
18
  end
18
19
 
19
20
  def call(mutation)
20
21
  ensure_framework_loaded
21
22
  fire_hook(:mutation_insert_pre, mutation: mutation, file_path: mutation.file_path)
22
- load_error = apply_mutation(mutation)
23
+ load_error = @mutation_applier.call(mutation)
23
24
  return load_error if load_error
24
25
 
25
26
  fire_hook(:mutation_insert_post, mutation: mutation, file_path: mutation.file_path)
@@ -47,156 +48,4 @@ class Evilution::Integration::Base
47
48
  def fire_hook(event, **payload)
48
49
  @hooks.fire(event, **payload) if @hooks
49
50
  end
50
-
51
- def apply_mutation(mutation)
52
- prism_error = validate_mutated_syntax(mutation.mutated_source)
53
- return prism_error if prism_error
54
-
55
- pin_autoloaded_constants(mutation.original_source)
56
- clear_concern_state(mutation.file_path)
57
- with_redefinition_recovery(mutation.original_source) do
58
- eval_mutated_source(mutation)
59
- end
60
- nil
61
- rescue SyntaxError => e
62
- {
63
- passed: false,
64
- error: "syntax error in mutated source: #{e.message}",
65
- error_class: e.class.name,
66
- error_backtrace: Array(e.backtrace).first(5)
67
- }
68
- rescue ScriptError, StandardError => e
69
- {
70
- passed: false,
71
- error: "#{e.class}: #{e.message}",
72
- error_class: e.class.name,
73
- error_backtrace: Array(e.backtrace).first(5)
74
- }
75
- end
76
-
77
- def validate_mutated_syntax(source)
78
- return nil if Prism.parse(source).success?
79
-
80
- {
81
- passed: false,
82
- error: "mutated source has syntax errors",
83
- error_class: "SyntaxError",
84
- error_backtrace: []
85
- }
86
- end
87
-
88
- # Evaluate the mutated source with __FILE__ set to the original path so
89
- # that `require_relative` and `__dir__` resolve against the real source
90
- # tree, where sibling files actually exist.
91
- def eval_mutated_source(mutation)
92
- absolute = File.expand_path(mutation.file_path)
93
- # rubocop:disable Security/Eval
94
- eval(mutation.mutated_source, TOPLEVEL_BINDING, absolute, 1)
95
- # rubocop:enable Security/Eval
96
- end
97
-
98
- def with_redefinition_recovery(original_source)
99
- yield
100
- rescue ArgumentError => e
101
- raise unless redefinition_conflict?(e)
102
-
103
- remove_defined_constants(original_source)
104
- yield
105
- end
106
-
107
- def redefinition_conflict?(error)
108
- error.message.include?("already defined")
109
- end
110
-
111
- def pin_autoloaded_constants(source)
112
- collect_constant_names(Prism.parse(source).value).each do |name|
113
- Object.const_get(name) if Object.const_defined?(name, false)
114
- rescue NameError # :nodoc:
115
- nil
116
- end
117
- end
118
-
119
- def collect_constant_names(node, nesting = [])
120
- names = []
121
- case node
122
- when Prism::ModuleNode, Prism::ClassNode
123
- const = node.constant_path.full_name
124
- qualified = nesting.any? && !const.include?("::") ? "#{nesting.join("::")}::#{const}" : const
125
- names << qualified
126
- names.concat(collect_constant_names(node.body, nesting + [const])) if node.body
127
- when Prism::ProgramNode
128
- names.concat(collect_constant_names(node.statements, nesting)) if node.statements
129
- when Prism::StatementsNode
130
- node.body.each { |child| names.concat(collect_constant_names(child, nesting)) }
131
- end
132
- names
133
- end
134
-
135
- def remove_defined_constants(source)
136
- collect_constant_names(Prism.parse(source).value).reverse_each do |name|
137
- parent_name, _, local_name = name.rpartition("::")
138
- parent = resolve_loaded_constant_parent(parent_name)
139
- next unless parent
140
- next unless parent.const_defined?(local_name, false)
141
- next if parent.autoload?(local_name)
142
-
143
- parent.send(:remove_const, local_name.to_sym)
144
- end
145
- end
146
-
147
- def resolve_loaded_constant_parent(parent_name)
148
- return Object if parent_name.empty?
149
-
150
- parent_name.split("::").reduce(Object) do |mod, part|
151
- return nil unless mod.const_defined?(part, false)
152
- return nil if mod.autoload?(part)
153
-
154
- resolved = mod.const_get(part, false)
155
- return nil unless resolved.is_a?(Module)
156
-
157
- resolved
158
- end
159
- end
160
-
161
- def clear_concern_state(file_path)
162
- return unless defined?(ActiveSupport::Concern)
163
-
164
- absolute = File.expand_path(file_path)
165
- subpath = resolve_require_subpath(file_path)
166
-
167
- ObjectSpace.each_object(Module) do |mod|
168
- next unless mod.singleton_class.ancestors.include?(ActiveSupport::Concern)
169
-
170
- %i[@_included_block @_prepended_block].each do |ivar|
171
- next unless mod.instance_variable_defined?(ivar)
172
-
173
- block = mod.instance_variable_get(ivar)
174
- block_file = block.source_location&.first
175
- next unless block_file
176
-
177
- expanded = File.expand_path(block_file)
178
- mod.remove_instance_variable(ivar) if source_matches?(expanded, absolute, subpath)
179
- end
180
- end
181
- end
182
-
183
- def source_matches?(block_path, absolute, subpath)
184
- block_path == absolute || (subpath && block_path.end_with?("/#{subpath}"))
185
- end
186
-
187
- def resolve_require_subpath(file_path)
188
- absolute = File.expand_path(file_path)
189
- best_subpath = nil
190
-
191
- $LOAD_PATH.each do |entry|
192
- dir = File.expand_path(entry)
193
- prefix = dir.end_with?("/") ? dir : "#{dir}/"
194
- next unless absolute.start_with?(prefix)
195
-
196
- candidate = absolute.delete_prefix(prefix)
197
- best_subpath = candidate if best_subpath.nil? || candidate.length < best_subpath.length
198
- end
199
-
200
- best_subpath
201
- end
202
51
  end
@@ -41,8 +41,11 @@ class Evilution::Integration::CrashDetector
41
41
  def crash_summary
42
42
  return nil if @crashes.empty?
43
43
 
44
- types = @crashes.map { |e| e.class.name }.uniq
45
- "#{types.join(", ")} (#{@crashes.length} crash#{"es" unless @crashes.length == 1})"
44
+ "#{unique_crash_classes.join(", ")} (#{@crashes.length} crash#{"es" unless @crashes.length == 1})"
45
+ end
46
+
47
+ def unique_crash_classes
48
+ @crashes.map { |e| e.class.name }.uniq
46
49
  end
47
50
 
48
51
  private
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../loading"
4
+ require_relative "../../load_path/subpath_resolver"
5
+
6
+ # Re-evaluating an `ActiveSupport::Concern` module raises
7
+ # "MultipleIncludedBlocks" because AS::Concern records the block source
8
+ # location on the first include/prepend call. Before a re-eval we clear the
9
+ # `@_included_block` / `@_prepended_block` ivar on modules whose block came
10
+ # from the file we're about to re-eval.
11
+ class Evilution::Integration::Loading::ConcernStateCleaner
12
+ IVARS = %i[@_included_block @_prepended_block].freeze
13
+
14
+ def initialize(subpath_resolver: Evilution::LoadPath::SubpathResolver.new)
15
+ @subpath_resolver = subpath_resolver
16
+ end
17
+
18
+ def call(file_path)
19
+ return unless defined?(ActiveSupport::Concern)
20
+
21
+ absolute = File.expand_path(file_path)
22
+ subpath = @subpath_resolver.call(file_path)
23
+
24
+ ObjectSpace.each_object(Module) do |mod|
25
+ next unless mod.singleton_class.ancestors.include?(ActiveSupport::Concern)
26
+
27
+ clear_concern_ivars(mod, absolute, subpath)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def clear_concern_ivars(mod, absolute, subpath)
34
+ IVARS.each do |ivar|
35
+ next unless mod.instance_variable_defined?(ivar)
36
+
37
+ block = mod.instance_variable_get(ivar)
38
+ block_file = block.source_location&.first
39
+ next unless block_file
40
+
41
+ expanded = File.expand_path(block_file)
42
+ mod.remove_instance_variable(ivar) if source_matches?(expanded, absolute, subpath)
43
+ end
44
+ end
45
+
46
+ def source_matches?(block_path, absolute, subpath)
47
+ block_path == absolute || (subpath && block_path.end_with?("/#{subpath}"))
48
+ end
49
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../loading"
4
+ require_relative "../../ast/constant_names"
5
+
6
+ # Defeat Zeitwerk's re-autoload hook when we re-eval a file in place. Walking
7
+ # the source AST for top-level class/module names and calling `const_get` on
8
+ # each tells Zeitwerk "this constant is loaded" so our re-eval does not lose
9
+ # state (e.g. `@_included_block`) to a follow-up autoload.
10
+ class Evilution::Integration::Loading::ConstantPinner
11
+ def initialize(constant_names: Evilution::AST::ConstantNames.new)
12
+ @constant_names = constant_names
13
+ end
14
+
15
+ def call(source)
16
+ names = @constant_names.call(source)
17
+ names.each do |name|
18
+ Object.const_get(name) if Object.const_defined?(name, false)
19
+ rescue NameError # :nodoc:
20
+ nil
21
+ end
22
+ names
23
+ end
24
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../loading"
4
+ require_relative "syntax_validator"
5
+ require_relative "constant_pinner"
6
+ require_relative "concern_state_cleaner"
7
+ require_relative "source_evaluator"
8
+ require_relative "redefinition_recovery"
9
+
10
+ # Composes the load-time pipeline that applies a mutation's new source to the
11
+ # running VM: syntax-validate -> pin top-level constants (beats Zeitwerk) ->
12
+ # clear AS::Concern state -> eval inside a redefinition-recovery wrapper.
13
+ # Returns nil on success or a failure-shaped hash on any error.
14
+ class Evilution::Integration::Loading::MutationApplier
15
+ def initialize(syntax_validator: Evilution::Integration::Loading::SyntaxValidator.new,
16
+ constant_pinner: Evilution::Integration::Loading::ConstantPinner.new,
17
+ concern_state_cleaner: Evilution::Integration::Loading::ConcernStateCleaner.new,
18
+ source_evaluator: Evilution::Integration::Loading::SourceEvaluator.new,
19
+ redefinition_recovery: Evilution::Integration::Loading::RedefinitionRecovery.new)
20
+ @syntax_validator = syntax_validator
21
+ @constant_pinner = constant_pinner
22
+ @concern_state_cleaner = concern_state_cleaner
23
+ @source_evaluator = source_evaluator
24
+ @redefinition_recovery = redefinition_recovery
25
+ end
26
+
27
+ def call(mutation)
28
+ syntax_error = @syntax_validator.call(mutation.mutated_source)
29
+ return syntax_error if syntax_error
30
+
31
+ @constant_pinner.call(mutation.original_source)
32
+ @concern_state_cleaner.call(mutation.file_path)
33
+ @redefinition_recovery.call(mutation.original_source) do
34
+ @source_evaluator.call(mutation.mutated_source, mutation.file_path)
35
+ end
36
+ nil
37
+ rescue SyntaxError => e
38
+ {
39
+ passed: false,
40
+ error: "syntax error in mutated source: #{e.message}",
41
+ error_class: e.class.name,
42
+ error_backtrace: Array(e.backtrace).first(5)
43
+ }
44
+ rescue ScriptError, StandardError => e
45
+ {
46
+ passed: false,
47
+ error: "#{e.class}: #{e.message}",
48
+ error_class: e.class.name,
49
+ error_backtrace: Array(e.backtrace).first(5)
50
+ }
51
+ end
52
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../loading"
4
+ require_relative "../../ast/constant_names"
5
+
6
+ # Some DSLs (Rails 8 enum, define_method guards) raise ArgumentError on
7
+ # re-declaration. On such a conflict we strip constants declared in the source
8
+ # and retry the load once against a fresh namespace.
9
+ class Evilution::Integration::Loading::RedefinitionRecovery
10
+ def initialize(constant_names: Evilution::AST::ConstantNames.new)
11
+ @constant_names = constant_names
12
+ end
13
+
14
+ def call(source, &block)
15
+ block.call
16
+ rescue ArgumentError => e
17
+ raise unless redefinition_conflict?(e)
18
+
19
+ remove_defined_constants(source)
20
+ block.call
21
+ end
22
+
23
+ private
24
+
25
+ def redefinition_conflict?(error)
26
+ error.message.include?("already defined")
27
+ end
28
+
29
+ def remove_defined_constants(source)
30
+ @constant_names.call(source).reverse_each do |name|
31
+ parent_name, _, local_name = name.rpartition("::")
32
+ parent = resolve_loaded_constant_parent(parent_name)
33
+ next unless parent
34
+ next unless parent.const_defined?(local_name, false)
35
+ next if parent.autoload?(local_name)
36
+
37
+ parent.send(:remove_const, local_name.to_sym)
38
+ end
39
+ end
40
+
41
+ def resolve_loaded_constant_parent(parent_name)
42
+ return Object if parent_name.empty?
43
+
44
+ parent_name.split("::").reduce(Object) do |mod, part|
45
+ return nil unless mod.const_defined?(part, false)
46
+ return nil if mod.autoload?(part)
47
+
48
+ resolved = mod.const_get(part, false)
49
+ return nil unless resolved.is_a?(Module)
50
+
51
+ resolved
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../loading"
4
+
5
+ # Evaluate source with __FILE__ set to the absolute original path so that
6
+ # `require_relative` and `__dir__` resolve against the real source tree, where
7
+ # sibling files actually exist.
8
+ class Evilution::Integration::Loading::SourceEvaluator
9
+ def call(source, file_path)
10
+ absolute = File.expand_path(file_path)
11
+ # rubocop:disable Security/Eval
12
+ eval(source, TOPLEVEL_BINDING, absolute, 1)
13
+ # rubocop:enable Security/Eval
14
+ end
15
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../loading"
5
+
6
+ class Evilution::Integration::Loading::SyntaxValidator
7
+ ERROR_MESSAGE = "mutated source has syntax errors"
8
+
9
+ def call(source)
10
+ return nil if Prism.parse(source).success?
11
+
12
+ {
13
+ passed: false,
14
+ error: ERROR_MESSAGE,
15
+ error_class: "SyntaxError",
16
+ error_backtrace: []
17
+ }
18
+ end
19
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../integration"
4
+
5
+ module Evilution::Integration::Loading
6
+ end
@@ -4,6 +4,7 @@ require "stringio"
4
4
  require_relative "base"
5
5
  require_relative "minitest_crash_detector"
6
6
  require_relative "../spec_resolver"
7
+ require_relative "../spec_selector"
7
8
 
8
9
  require_relative "../integration"
9
10
 
@@ -35,10 +36,12 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
35
36
  }
36
37
  end
37
38
 
38
- def initialize(test_files: nil, hooks: nil, fallback_to_full_suite: false)
39
+ def initialize(test_files: nil, hooks: nil, fallback_to_full_suite: false, spec_selector: nil)
39
40
  @test_files = test_files
40
41
  @minitest_loaded = false
41
- @spec_resolver = Evilution::SpecResolver.new(test_dir: "test", test_suffix: "_test.rb", request_dir: "integration")
42
+ @spec_selector = spec_selector || Evilution::SpecSelector.new(
43
+ spec_resolver: Evilution::SpecResolver.new(test_dir: "test", test_suffix: "_test.rb", request_dir: "integration")
44
+ )
42
45
  @fallback_to_full_suite = fallback_to_full_suite
43
46
  @crash_detector = nil
44
47
  @warned_files = Set.new
@@ -124,10 +127,12 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
124
127
  if passed
125
128
  { passed: true, test_command: command }
126
129
  elsif detector.only_crashes?
130
+ classes = detector.unique_crash_classes
127
131
  {
128
132
  passed: false,
129
133
  test_crashed: true,
130
134
  error: "test crashes: #{detector.crash_summary}",
135
+ error_class: (classes.first if classes.length == 1),
131
136
  test_command: command
132
137
  }
133
138
  else
@@ -138,13 +143,13 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
138
143
  def resolve_test_files(mutation)
139
144
  return test_files if test_files
140
145
 
141
- resolved = @spec_resolver.call(mutation.file_path)
142
- unless resolved
146
+ resolved = Array(@spec_selector.call(mutation.file_path))
147
+ if resolved.empty?
143
148
  warn_unresolved_test(mutation.file_path)
144
149
  return @fallback_to_full_suite ? glob_test_files : nil
145
150
  end
146
151
 
147
- [resolved]
152
+ resolved
148
153
  end
149
154
 
150
155
  def glob_test_files
@@ -49,7 +49,10 @@ class Evilution::Integration::MinitestCrashDetector
49
49
  def crash_summary
50
50
  return nil if @crashes.empty?
51
51
 
52
- types = @crashes.map { |e| e.class.name }.uniq
53
- "#{types.join(", ")} (#{@crashes.length} crash#{"es" unless @crashes.length == 1})"
52
+ "#{unique_crash_classes.join(", ")} (#{@crashes.length} crash#{"es" unless @crashes.length == 1})"
53
+ end
54
+
55
+ def unique_crash_classes
56
+ @crashes.map { |e| e.class.name }.uniq
54
57
  end
55
58
  end
@@ -4,6 +4,7 @@ require "stringio"
4
4
  require_relative "base"
5
5
  require_relative "crash_detector"
6
6
  require_relative "../spec_resolver"
7
+ require_relative "../spec_selector"
7
8
  require_relative "../related_spec_heuristic"
8
9
 
9
10
  require_relative "../integration"
@@ -26,13 +27,15 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
26
27
  { runner: baseline_runner }
27
28
  end
28
29
 
29
- def initialize(test_files: nil, hooks: nil, related_specs_heuristic: false, fallback_to_full_suite: false)
30
+ def initialize(test_files: nil, hooks: nil, related_specs_heuristic: false, fallback_to_full_suite: false,
31
+ spec_selector: nil, example_filter: nil)
30
32
  @test_files = test_files
31
33
  @rspec_loaded = false
32
- @spec_resolver = Evilution::SpecResolver.new
34
+ @spec_selector = spec_selector || Evilution::SpecSelector.new
33
35
  @related_spec_heuristic = Evilution::RelatedSpecHeuristic.new
34
36
  @related_specs_heuristic_enabled = related_specs_heuristic
35
37
  @fallback_to_full_suite = fallback_to_full_suite
38
+ @example_filter = example_filter
36
39
  @crash_detector = nil
37
40
  @warned_files = Set.new
38
41
  super(hooks: hooks)
@@ -61,13 +64,18 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
61
64
  files = resolve_test_files(mutation)
62
65
  return unresolved_result(mutation) if files.nil?
63
66
 
67
+ targets = apply_example_filter(mutation, files)
68
+ return unresolved_example_result(mutation) if targets.nil?
69
+
64
70
  out = StringIO.new
65
71
  err = StringIO.new
66
- args = build_args(files)
72
+ args = build_args(targets)
67
73
  command = "rspec #{args.join(" ")}"
68
74
 
69
75
  detector = reset_crash_detector
70
76
  eg_before = snapshot_example_groups
77
+ fe_before = snapshot_filtered_examples_keys
78
+ rep_before = snapshot_reporter_lengths
71
79
  status = ::RSpec::Core::Runner.run(args, out, err)
72
80
 
73
81
  build_rspec_result(status, command, detector)
@@ -75,12 +83,20 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
75
83
  { passed: false, error: e.message, test_command: command }
76
84
  ensure
77
85
  release_rspec_state(eg_before)
86
+ release_filtered_examples(fe_before)
87
+ release_reporter_state(rep_before)
78
88
  end
79
89
 
80
90
  def build_args(files)
81
91
  ["--format", "progress", "--no-color", "--order", "defined", *files]
82
92
  end
83
93
 
94
+ def apply_example_filter(mutation, files)
95
+ return files unless @example_filter
96
+
97
+ @example_filter.call(mutation, files)
98
+ end
99
+
84
100
  def unresolved_result(mutation)
85
101
  {
86
102
  passed: false,
@@ -90,6 +106,15 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
90
106
  }
91
107
  end
92
108
 
109
+ def unresolved_example_result(mutation)
110
+ {
111
+ passed: false,
112
+ unresolved: true,
113
+ error: "no matching example found for #{mutation.file_path}",
114
+ test_command: "rspec (skipped: no matching example for #{mutation.file_path})"
115
+ }
116
+ end
117
+
93
118
  def reset_state
94
119
  if ::RSpec.respond_to?(:clear_examples)
95
120
  ::RSpec.clear_examples
@@ -138,6 +163,54 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
138
163
  world.instance_variable_set(:@sources_by_path, {}) if world.instance_variable_defined?(:@sources_by_path)
139
164
  end
140
165
 
166
+ def snapshot_filtered_examples_keys
167
+ fe = rspec_world_ivar(:@filtered_examples)
168
+ fe ? Set.new(fe.keys.map(&:object_id)) : nil
169
+ end
170
+
171
+ def snapshot_reporter_lengths
172
+ reporter = rspec_config_ivar(:@reporter)
173
+ return nil unless reporter
174
+
175
+ %i[@examples @failed_examples @pending_examples].each_with_object({}) do |ivar, acc|
176
+ next unless reporter.instance_variable_defined?(ivar)
177
+
178
+ arr = reporter.instance_variable_get(ivar)
179
+ acc[ivar] = arr.length if arr.is_a?(Array)
180
+ end
181
+ end
182
+
183
+ def release_filtered_examples(snapshot_keys)
184
+ fe = rspec_world_ivar(:@filtered_examples)
185
+ return unless fe && snapshot_keys
186
+
187
+ fe.each_key.to_a.each do |k|
188
+ fe.delete(k) unless snapshot_keys.include?(k.object_id)
189
+ end
190
+ end
191
+
192
+ def release_reporter_state(lengths)
193
+ return unless lengths
194
+
195
+ reporter = rspec_config_ivar(:@reporter)
196
+ return unless reporter
197
+
198
+ lengths.each do |ivar, length|
199
+ arr = reporter.instance_variable_get(ivar)
200
+ arr.slice!(length..) if arr.is_a?(Array) && arr.length > length
201
+ end
202
+ end
203
+
204
+ def rspec_world_ivar(ivar)
205
+ world = ::RSpec.world
206
+ world.instance_variable_defined?(ivar) ? world.instance_variable_get(ivar) : nil
207
+ end
208
+
209
+ def rspec_config_ivar(ivar)
210
+ config = ::RSpec.configuration
211
+ config.instance_variable_defined?(ivar) ? config.instance_variable_get(ivar) : nil
212
+ end
213
+
141
214
  def reset_crash_detector
142
215
  if @crash_detector
143
216
  @crash_detector.reset
@@ -152,10 +225,12 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
152
225
  if status.zero?
153
226
  { passed: true, test_command: command }
154
227
  elsif detector.only_crashes?
228
+ classes = detector.unique_crash_classes
155
229
  {
156
230
  passed: false,
157
231
  test_crashed: true,
158
232
  error: "test crashes: #{detector.crash_summary}",
233
+ error_class: (classes.first if classes.length == 1),
159
234
  test_command: command
160
235
  }
161
236
  else
@@ -166,16 +241,16 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
166
241
  def resolve_test_files(mutation)
167
242
  return test_files if test_files
168
243
 
169
- resolved = @spec_resolver.call(mutation.file_path)
170
- unless resolved
244
+ resolved = Array(@spec_selector.call(mutation.file_path))
245
+ if resolved.empty?
171
246
  warn_unresolved_spec(mutation.file_path)
172
247
  return @fallback_to_full_suite ? ["spec"] : nil
173
248
  end
174
249
 
175
- return [resolved] unless @related_specs_heuristic_enabled
250
+ return resolved unless @related_specs_heuristic_enabled
176
251
 
177
252
  related = @related_spec_heuristic.call(mutation)
178
- ([resolved] + related).uniq
253
+ (resolved + related).uniq
179
254
  end
180
255
 
181
256
  def warn_unresolved_spec(file_path)