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.
- checksums.yaml +4 -4
- data/.beads/interactions.jsonl +210 -0
- data/.claude/prompts/architect.md +14 -1
- data/.claude/skills/create-issue/SKILL.md +55 -0
- data/CHANGELOG.md +51 -0
- data/README.md +80 -4
- data/exe/evil +6 -0
- data/lib/evilution/ast/constant_names.rb +34 -0
- data/lib/evilution/ast/source_surgeon.rb +15 -1
- data/lib/evilution/cli/commands/compare.rb +68 -0
- data/lib/evilution/cli/parser/command_extractor.rb +2 -1
- data/lib/evilution/cli/parser/options_builder.rb +21 -1
- data/lib/evilution/cli/printers/compare.rb +159 -0
- data/lib/evilution/cli.rb +1 -0
- data/lib/evilution/compare/categorizer.rb +109 -0
- data/lib/evilution/compare/detector.rb +21 -0
- data/lib/evilution/compare/fingerprint.rb +83 -0
- data/lib/evilution/compare/invalid_input.rb +12 -0
- data/lib/evilution/compare/normalizer.rb +106 -0
- data/lib/evilution/compare/record.rb +16 -0
- data/lib/evilution/compare.rb +6 -0
- data/lib/evilution/config.rb +165 -3
- data/lib/evilution/example_filter.rb +143 -0
- data/lib/evilution/integration/base.rb +4 -155
- data/lib/evilution/integration/crash_detector.rb +5 -2
- data/lib/evilution/integration/loading/concern_state_cleaner.rb +49 -0
- data/lib/evilution/integration/loading/constant_pinner.rb +24 -0
- data/lib/evilution/integration/loading/mutation_applier.rb +52 -0
- data/lib/evilution/integration/loading/redefinition_recovery.rb +54 -0
- data/lib/evilution/integration/loading/source_evaluator.rb +15 -0
- data/lib/evilution/integration/loading/syntax_validator.rb +19 -0
- data/lib/evilution/integration/loading.rb +6 -0
- data/lib/evilution/integration/minitest.rb +10 -5
- data/lib/evilution/integration/minitest_crash_detector.rb +5 -2
- data/lib/evilution/integration/rspec.rb +82 -7
- data/lib/evilution/isolation/fork.rb +25 -0
- data/lib/evilution/load_path/subpath_resolver.rb +25 -0
- data/lib/evilution/load_path.rb +4 -0
- data/lib/evilution/mcp/info_tool.rb +77 -5
- data/lib/evilution/mcp/mutate_tool/config_builder.rb +20 -0
- data/lib/evilution/mcp/mutate_tool/error_payload.rb +17 -0
- data/lib/evilution/mcp/mutate_tool/option_parser.rb +54 -0
- data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +37 -0
- data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +31 -0
- data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +52 -0
- data/lib/evilution/mcp/mutate_tool.rb +34 -186
- data/lib/evilution/mutation.rb +43 -3
- data/lib/evilution/mutator/base.rb +39 -1
- data/lib/evilution/mutator/operator/argument_nil_substitution.rb +5 -1
- data/lib/evilution/mutator/operator/argument_removal.rb +5 -1
- data/lib/evilution/parallel/work_queue.rb +149 -31
- data/lib/evilution/parallel_db_warning.rb +68 -0
- data/lib/evilution/reporter/cli.rb +37 -11
- data/lib/evilution/reporter/html/assets/style.css +17 -0
- data/lib/evilution/reporter/html/sections/file_section.rb +15 -0
- data/lib/evilution/reporter/html/sections/neutral_details.rb +25 -0
- data/lib/evilution/reporter/html/sections/unparseable_details.rb +25 -0
- data/lib/evilution/reporter/html/sections/unresolved_details.rb +25 -0
- data/lib/evilution/reporter/html/templates/file_section.html.erb +3 -0
- data/lib/evilution/reporter/html/templates/neutral_details.html.erb +14 -0
- data/lib/evilution/reporter/html/templates/summary_cards.html.erb +3 -0
- data/lib/evilution/reporter/html/templates/unparseable_details.html.erb +11 -0
- data/lib/evilution/reporter/html/templates/unresolved_details.html.erb +11 -0
- data/lib/evilution/reporter/json.rb +8 -2
- data/lib/evilution/reporter/suggestion/diff_helpers.rb +28 -0
- data/lib/evilution/reporter/suggestion/registry.rb +64 -0
- data/lib/evilution/reporter/suggestion/templates/generic.rb +55 -0
- data/lib/evilution/reporter/suggestion/templates/minitest.rb +659 -0
- data/lib/evilution/reporter/suggestion/templates/rspec.rb +613 -0
- data/lib/evilution/reporter/suggestion.rb +8 -1327
- data/lib/evilution/result/mutation_result.rb +5 -1
- data/lib/evilution/result/summary.rb +13 -1
- data/lib/evilution/runner/baseline_runner.rb +23 -2
- data/lib/evilution/runner/isolation_resolver.rb +12 -1
- data/lib/evilution/runner/mutation_executor.rb +83 -13
- data/lib/evilution/runner/subject_pipeline.rb +18 -8
- data/lib/evilution/runner.rb +6 -0
- data/lib/evilution/source_ast_cache.rb +39 -0
- data/lib/evilution/spec_ast_cache.rb +166 -0
- data/lib/evilution/spec_resolver.rb +6 -1
- data/lib/evilution/spec_selector.rb +39 -0
- data/lib/evilution/temp_dir_tracker.rb +23 -3
- data/lib/evilution/version.rb +1 -1
- data/script/memory_check +7 -5
- metadata +46 -5
- data/lib/evilution/mcp/session_diff_tool.rb +0 -63
- data/lib/evilution/mcp/session_list_tool.rb +0 -50
- 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 =
|
|
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
|
-
|
|
45
|
-
|
|
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
|
|
@@ -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
|
-
@
|
|
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 = @
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
@
|
|
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(
|
|
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 = @
|
|
170
|
-
|
|
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
|
|
250
|
+
return resolved unless @related_specs_heuristic_enabled
|
|
176
251
|
|
|
177
252
|
related = @related_spec_heuristic.call(mutation)
|
|
178
|
-
(
|
|
253
|
+
(resolved + related).uniq
|
|
179
254
|
end
|
|
180
255
|
|
|
181
256
|
def warn_unresolved_spec(file_path)
|