evilution 0.23.0 → 0.25.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/CHANGELOG.md +51 -0
- data/README.md +81 -4
- data/exe/evil +6 -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 +78 -0
- data/lib/evilution/cli/parser/file_args.rb +41 -0
- data/lib/evilution/cli/parser/options_builder.rb +123 -0
- data/lib/evilution/cli/parser/stdin_reader.rb +28 -0
- data/lib/evilution/cli/parser.rb +27 -196
- 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/normalizer.rb +106 -0
- data/lib/evilution/compare/record.rb +16 -0
- data/lib/evilution/compare.rb +15 -0
- data/lib/evilution/config.rb +178 -3
- data/lib/evilution/example_filter.rb +143 -0
- data/lib/evilution/integration/base.rb +11 -57
- data/lib/evilution/integration/crash_detector.rb +5 -2
- data/lib/evilution/integration/minitest.rb +25 -7
- data/lib/evilution/integration/minitest_crash_detector.rb +5 -2
- data/lib/evilution/integration/rspec.rb +99 -12
- data/lib/evilution/isolation/fork.rb +26 -0
- data/lib/evilution/isolation/in_process.rb +1 -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 +38 -11
- data/lib/evilution/reporter/html/assets/style.css +85 -0
- data/lib/evilution/reporter/html/baseline_keys.rb +28 -0
- data/lib/evilution/reporter/html/diff_formatter.rb +27 -0
- data/lib/evilution/reporter/html/escape.rb +12 -0
- data/lib/evilution/reporter/html/namespace.rb +11 -0
- data/lib/evilution/reporter/html/report.rb +68 -0
- data/lib/evilution/reporter/html/section.rb +21 -0
- data/lib/evilution/reporter/html/sections/baseline_comparison.rb +46 -0
- data/lib/evilution/reporter/html/sections/error_details.rb +30 -0
- data/lib/evilution/reporter/html/sections/error_entry.rb +22 -0
- data/lib/evilution/reporter/html/sections/file_section.rb +62 -0
- data/lib/evilution/reporter/html/sections/header.rb +29 -0
- data/lib/evilution/reporter/html/sections/mutation_map.rb +32 -0
- data/lib/evilution/reporter/html/sections/neutral_details.rb +25 -0
- data/lib/evilution/reporter/html/sections/summary_cards.rb +11 -0
- data/lib/evilution/reporter/html/sections/survived_details.rb +35 -0
- data/lib/evilution/reporter/html/sections/survived_entry.rb +36 -0
- data/lib/evilution/reporter/html/sections/truncation_notice.rb +17 -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/sections.rb +4 -0
- data/lib/evilution/reporter/html/stylesheet.rb +14 -0
- data/lib/evilution/reporter/html/templates/baseline_comparison.html.erb +8 -0
- data/lib/evilution/reporter/html/templates/error_details.html.erb +6 -0
- data/lib/evilution/reporter/html/templates/error_entry.html.erb +10 -0
- data/lib/evilution/reporter/html/templates/file_section.html.erb +12 -0
- data/lib/evilution/reporter/html/templates/header.html.erb +4 -0
- data/lib/evilution/reporter/html/templates/mutation_map.html.erb +6 -0
- data/lib/evilution/reporter/html/templates/neutral_details.html.erb +14 -0
- data/lib/evilution/reporter/html/templates/report.html.erb +17 -0
- data/lib/evilution/reporter/html/templates/summary_cards.html.erb +26 -0
- data/lib/evilution/reporter/html/templates/survived_details.html.erb +21 -0
- data/lib/evilution/reporter/html/templates/survived_entry.html.erb +8 -0
- data/lib/evilution/reporter/html/templates/truncation_notice.html.erb +1 -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/html.rb +11 -390
- data/lib/evilution/reporter/json.rb +19 -9
- 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 +9 -1
- data/lib/evilution/result/summary.rb +21 -1
- data/lib/evilution/runner/baseline_runner.rb +92 -0
- data/lib/evilution/runner/diagnostics.rb +105 -0
- data/lib/evilution/runner/isolation_resolver.rb +134 -0
- data/lib/evilution/runner/mutation_executor.rb +325 -0
- data/lib/evilution/runner/mutation_planner.rb +126 -0
- data/lib/evilution/runner/report_publisher.rb +60 -0
- data/lib/evilution/runner/subject_pipeline.rb +121 -0
- data/lib/evilution/runner.rb +61 -692
- 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 +75 -2
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../compare"
|
|
4
|
+
require_relative "record"
|
|
5
|
+
require_relative "fingerprint"
|
|
6
|
+
|
|
7
|
+
class Evilution::Compare::Normalizer
|
|
8
|
+
EVILUTION_BUCKETS = %w[killed survived timed_out errors neutral equivalent unresolved unparseable].freeze
|
|
9
|
+
EVILUTION_STATUS_MAP = {
|
|
10
|
+
"killed" => :killed,
|
|
11
|
+
"survived" => :survived,
|
|
12
|
+
"timeout" => :timeout,
|
|
13
|
+
"error" => :error,
|
|
14
|
+
"neutral" => :neutral,
|
|
15
|
+
"equivalent" => :equivalent,
|
|
16
|
+
"unresolved" => :unresolved,
|
|
17
|
+
"unparseable" => :unparseable
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def from_evilution(json)
|
|
21
|
+
records = []
|
|
22
|
+
EVILUTION_BUCKETS.each do |bucket|
|
|
23
|
+
Array(json[bucket]).each do |entry|
|
|
24
|
+
records << build_evilution_record(entry, index: records.size)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
records
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def from_mutant(json)
|
|
31
|
+
records = []
|
|
32
|
+
Array(json["subject_results"]).each do |subject|
|
|
33
|
+
source_path = subject["source_path"] or
|
|
34
|
+
raise Evilution::Compare::InvalidInput.new("missing 'source_path' on subject", index: records.size)
|
|
35
|
+
Array(subject["coverage_results"]).each do |cov|
|
|
36
|
+
records << build_mutant_record(cov, source_path: source_path, index: records.size)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
records
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def build_evilution_record(entry, index:)
|
|
45
|
+
file_path = entry["file"] or raise Evilution::Compare::InvalidInput.new("missing 'file' in record", index: index)
|
|
46
|
+
line = entry["line"] or raise Evilution::Compare::InvalidInput.new("missing 'line' in record", index: index)
|
|
47
|
+
diff = entry["diff"].to_s
|
|
48
|
+
status = EVILUTION_STATUS_MAP[entry["status"]] ||
|
|
49
|
+
raise(Evilution::Compare::InvalidInput.new("unknown status #{entry["status"].inspect}", index: index))
|
|
50
|
+
body = Evilution::Compare::Fingerprint.extract_from_evilution_diff(diff)
|
|
51
|
+
Evilution::Compare::Record.new(
|
|
52
|
+
source: :evilution,
|
|
53
|
+
file_path: file_path,
|
|
54
|
+
line: line,
|
|
55
|
+
status: status,
|
|
56
|
+
fingerprint: Evilution::Compare::Fingerprint.compute(file_path: file_path, line: line, body: body),
|
|
57
|
+
operator: entry["operator"],
|
|
58
|
+
diff_body: diff,
|
|
59
|
+
raw: entry
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def build_mutant_record(cov, source_path:, index:)
|
|
64
|
+
mr = cov["mutation_result"] or raise Evilution::Compare::InvalidInput.new("missing mutation_result", index: index)
|
|
65
|
+
cr = cov["criteria_result"] or raise Evilution::Compare::InvalidInput.new("missing criteria_result", index: index)
|
|
66
|
+
ident = mr["mutation_identification"].to_s
|
|
67
|
+
line = parse_mutant_line(ident, index)
|
|
68
|
+
diff = mr["mutation_diff"].to_s
|
|
69
|
+
status = derive_mutant_status(mr, cr, index)
|
|
70
|
+
body = Evilution::Compare::Fingerprint.extract_from_mutant_diff(diff)
|
|
71
|
+
Evilution::Compare::Record.new(
|
|
72
|
+
source: :mutant,
|
|
73
|
+
file_path: source_path,
|
|
74
|
+
line: line,
|
|
75
|
+
status: status,
|
|
76
|
+
fingerprint: Evilution::Compare::Fingerprint.compute(file_path: source_path, line: line, body: body),
|
|
77
|
+
operator: nil,
|
|
78
|
+
diff_body: diff,
|
|
79
|
+
raw: { "mutation_result" => mr, "criteria_result" => cr, "source_path" => source_path }
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# mutant_identification format: <type>:<subject>:<path>:<line>:<sha1[0..4]>.
|
|
84
|
+
# Line is always the second-to-last colon-separated field. Works with paths
|
|
85
|
+
# containing colons (e.g. Windows drive letters) because we index from the
|
|
86
|
+
# right, but a malformed path-less identification will raise InvalidInput.
|
|
87
|
+
def parse_mutant_line(ident, index)
|
|
88
|
+
parts = ident.split(":")
|
|
89
|
+
raise Evilution::Compare::InvalidInput.new("cannot parse line from #{ident.inspect}", index: index) if parts.length < 5
|
|
90
|
+
|
|
91
|
+
Integer(parts[-2])
|
|
92
|
+
rescue ArgumentError
|
|
93
|
+
raise Evilution::Compare::InvalidInput.new("non-integer line in #{ident.inspect}", index: index)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def derive_mutant_status(mr, cr, index)
|
|
97
|
+
type = mr["mutation_type"]
|
|
98
|
+
return :neutral if %w[neutral noop].include?(type)
|
|
99
|
+
return :timeout if cr["timeout"]
|
|
100
|
+
return :error if cr["process_abort"]
|
|
101
|
+
return :killed if cr["test_result"]
|
|
102
|
+
return :survived if type == "evil"
|
|
103
|
+
|
|
104
|
+
raise Evilution::Compare::InvalidInput.new("unknown mutant result shape: type=#{type.inspect} cr=#{cr.inspect}", index: index)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Style/OneClassPerFile
|
|
4
|
+
module Evilution::Compare
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
class Evilution::Compare::InvalidInput < StandardError
|
|
8
|
+
attr_reader :index
|
|
9
|
+
|
|
10
|
+
def initialize(message, index: nil)
|
|
11
|
+
super(message)
|
|
12
|
+
@index = index
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
# rubocop:enable Style/OneClassPerFile
|
data/lib/evilution/config.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "yaml"
|
|
4
|
+
require_relative "spec_resolver"
|
|
5
|
+
require_relative "spec_selector"
|
|
4
6
|
|
|
5
7
|
class Evilution::Config
|
|
6
8
|
CONFIG_FILES = %w[.evilution.yml config/evilution.yml].freeze
|
|
@@ -28,19 +30,32 @@ class Evilution::Config
|
|
|
28
30
|
baseline_session: nil,
|
|
29
31
|
skip_heredoc_literals: false,
|
|
30
32
|
related_specs_heuristic: false,
|
|
31
|
-
|
|
33
|
+
fallback_to_full_suite: false,
|
|
34
|
+
preload: nil,
|
|
35
|
+
spec_mappings: {},
|
|
36
|
+
spec_pattern: nil,
|
|
37
|
+
example_targeting: true,
|
|
38
|
+
example_targeting_fallback: :full_file,
|
|
39
|
+
example_targeting_cache: { max_files: 50, max_blocks: 10_000 }
|
|
32
40
|
}.freeze
|
|
33
41
|
|
|
42
|
+
EXAMPLE_TARGETING_FALLBACKS = %i[full_file unresolved].freeze
|
|
43
|
+
private_constant :EXAMPLE_TARGETING_FALLBACKS
|
|
44
|
+
|
|
34
45
|
attr_reader :target_files, :timeout, :format,
|
|
35
46
|
:target, :min_score, :integration, :verbose, :quiet,
|
|
36
47
|
:jobs, :fail_fast, :baseline, :isolation, :incremental, :suggest_tests,
|
|
37
48
|
:progress, :save_session, :line_ranges, :spec_files, :hooks,
|
|
38
49
|
:ignore_patterns, :show_disabled, :baseline_session,
|
|
39
|
-
:skip_heredoc_literals, :related_specs_heuristic,
|
|
50
|
+
:skip_heredoc_literals, :related_specs_heuristic,
|
|
51
|
+
:fallback_to_full_suite, :preload, :spec_mappings, :spec_pattern,
|
|
52
|
+
:example_targeting, :example_targeting_fallback, :example_targeting_cache,
|
|
53
|
+
:spec_selector
|
|
40
54
|
|
|
41
55
|
def initialize(**options)
|
|
42
56
|
file_options = options.delete(:skip_config_file) ? {} : load_config_file
|
|
43
|
-
|
|
57
|
+
env_options = load_env_options
|
|
58
|
+
merged = DEFAULTS.merge(file_options).merge(env_options).merge(options)
|
|
44
59
|
assign_attributes(merged)
|
|
45
60
|
freeze
|
|
46
61
|
end
|
|
@@ -101,6 +116,14 @@ class Evilution::Config
|
|
|
101
116
|
related_specs_heuristic
|
|
102
117
|
end
|
|
103
118
|
|
|
119
|
+
def example_targeting?
|
|
120
|
+
example_targeting
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def fallback_to_full_suite?
|
|
124
|
+
fallback_to_full_suite
|
|
125
|
+
end
|
|
126
|
+
|
|
104
127
|
def self.file_options
|
|
105
128
|
CONFIG_FILES.each do |path|
|
|
106
129
|
next unless File.exist?(path)
|
|
@@ -155,6 +178,12 @@ class Evilution::Config
|
|
|
155
178
|
# of N+1 regressions that only surface in higher-level specs.
|
|
156
179
|
# related_specs_heuristic: true
|
|
157
180
|
|
|
181
|
+
# When no matching spec resolves for a mutation's source file, the
|
|
182
|
+
# default is to skip that mutation and mark it :unresolved in the
|
|
183
|
+
# report (a coverage gap signal). Set to true to fall back to running
|
|
184
|
+
# the entire test suite for such mutations instead (slow, high memory).
|
|
185
|
+
# fallback_to_full_suite: false
|
|
186
|
+
|
|
158
187
|
# Preload file required in the parent process before forking workers.
|
|
159
188
|
# For Rails projects, spec/rails_helper.rb or test/test_helper.rb is
|
|
160
189
|
# auto-detected when isolation resolves to :fork. Set to false to disable.
|
|
@@ -165,6 +194,23 @@ class Evilution::Config
|
|
|
165
194
|
# worker_process_start: config/evilution_hooks/worker_start.rb
|
|
166
195
|
# mutation_insert_pre: config/evilution_hooks/mutation_pre.rb
|
|
167
196
|
|
|
197
|
+
# Per-mutation example targeting (default: true). When enabled, Evilution
|
|
198
|
+
# parses resolved spec files and restricts each mutation run to examples
|
|
199
|
+
# whose bodies reference the mutated method/class token. Set to false
|
|
200
|
+
# to run every example in the resolved spec files. You can also disable
|
|
201
|
+
# without editing the file by exporting EV_DISABLE_EXAMPLE_TARGETING=1.
|
|
202
|
+
# example_targeting: true
|
|
203
|
+
|
|
204
|
+
# Behavior when targeting finds no matching example (default: full_file).
|
|
205
|
+
# full_file - run every example in the resolved spec files
|
|
206
|
+
# unresolved - mark the mutation :unresolved and skip
|
|
207
|
+
# example_targeting_fallback: full_file
|
|
208
|
+
|
|
209
|
+
# LRU cache bounds for the spec AST parser that powers example targeting.
|
|
210
|
+
# example_targeting_cache:
|
|
211
|
+
# max_files: 50
|
|
212
|
+
# max_blocks: 10000
|
|
213
|
+
|
|
168
214
|
# AST patterns to skip during mutation generation (default: [])
|
|
169
215
|
# See docs/ast_pattern_syntax.md for pattern syntax
|
|
170
216
|
# ignore_patterns:
|
|
@@ -210,8 +256,91 @@ class Evilution::Config
|
|
|
210
256
|
@baseline_session = merged[:baseline_session]
|
|
211
257
|
@skip_heredoc_literals = merged[:skip_heredoc_literals]
|
|
212
258
|
@related_specs_heuristic = merged[:related_specs_heuristic]
|
|
259
|
+
@fallback_to_full_suite = merged[:fallback_to_full_suite]
|
|
213
260
|
@hooks = validate_hooks(merged[:hooks])
|
|
214
261
|
@preload = validate_preload(merged[:preload])
|
|
262
|
+
@spec_mappings = validate_spec_mappings(merged[:spec_mappings])
|
|
263
|
+
@spec_pattern = validate_spec_pattern(merged[:spec_pattern])
|
|
264
|
+
assign_example_targeting(merged)
|
|
265
|
+
@spec_selector = build_spec_selector
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def assign_example_targeting(merged)
|
|
269
|
+
@example_targeting = merged[:example_targeting] ? true : false
|
|
270
|
+
@example_targeting_fallback = validate_example_targeting_fallback(merged[:example_targeting_fallback])
|
|
271
|
+
@example_targeting_cache = validate_example_targeting_cache(merged[:example_targeting_cache])
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def build_spec_selector
|
|
275
|
+
Evilution::SpecSelector.new(
|
|
276
|
+
spec_files: @spec_files,
|
|
277
|
+
spec_mappings: @spec_mappings,
|
|
278
|
+
spec_pattern: @spec_pattern,
|
|
279
|
+
spec_resolver: build_spec_resolver
|
|
280
|
+
)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def build_spec_resolver
|
|
284
|
+
case @integration
|
|
285
|
+
when :minitest
|
|
286
|
+
Evilution::SpecResolver.new(test_dir: "test", test_suffix: "_test.rb", request_dir: "integration")
|
|
287
|
+
else
|
|
288
|
+
Evilution::SpecResolver.new
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def validate_spec_mappings(value)
|
|
293
|
+
return {} if value.nil?
|
|
294
|
+
|
|
295
|
+
raise Evilution::ConfigError, "spec_mappings must be a Hash, got #{value.class}" unless value.is_a?(Hash)
|
|
296
|
+
|
|
297
|
+
normalized = value.each_with_object({}) do |(source, specs), acc|
|
|
298
|
+
key = normalize_spec_mappings_key(source)
|
|
299
|
+
acc[key] = normalize_spec_mappings_value(key, specs)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
warn_missing_spec_mappings(normalized)
|
|
303
|
+
normalized
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def normalize_spec_mappings_key(source)
|
|
307
|
+
key = source.to_s
|
|
308
|
+
key = key.delete_prefix("#{Dir.pwd}/") if key.start_with?("/")
|
|
309
|
+
key.delete_prefix("./")
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def normalize_spec_mappings_value(source, specs)
|
|
313
|
+
case specs
|
|
314
|
+
when String then [specs]
|
|
315
|
+
when Array
|
|
316
|
+
specs.each do |entry|
|
|
317
|
+
unless entry.is_a?(String)
|
|
318
|
+
raise Evilution::ConfigError,
|
|
319
|
+
"spec_mappings[#{source.inspect}] entries must be string paths, got #{entry.class}"
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
specs
|
|
323
|
+
else
|
|
324
|
+
raise Evilution::ConfigError,
|
|
325
|
+
"spec_mappings[#{source.inspect}] must be a string or array of strings, got #{specs.class}"
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def warn_missing_spec_mappings(mappings)
|
|
330
|
+
mappings.each do |source, specs|
|
|
331
|
+
specs.each do |spec_path|
|
|
332
|
+
next if File.exist?(spec_path)
|
|
333
|
+
|
|
334
|
+
warn "[evilution] spec_mappings[#{source.inspect}]: #{spec_path} not found, skipping"
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def validate_spec_pattern(value)
|
|
340
|
+
return nil if value.nil?
|
|
341
|
+
return value if value.is_a?(String)
|
|
342
|
+
|
|
343
|
+
raise Evilution::ConfigError, "spec_pattern must be nil or a String glob, got #{value.class}"
|
|
215
344
|
end
|
|
216
345
|
|
|
217
346
|
def validate_preload(value)
|
|
@@ -266,6 +395,52 @@ class Evilution::Config
|
|
|
266
395
|
patterns
|
|
267
396
|
end
|
|
268
397
|
|
|
398
|
+
def validate_example_targeting_fallback(value)
|
|
399
|
+
unless value.is_a?(String) || value.is_a?(Symbol)
|
|
400
|
+
raise Evilution::ConfigError,
|
|
401
|
+
"example_targeting_fallback must be full_file or unresolved, got #{value.inspect}"
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
sym = value.to_sym
|
|
405
|
+
unless EXAMPLE_TARGETING_FALLBACKS.include?(sym)
|
|
406
|
+
raise Evilution::ConfigError,
|
|
407
|
+
"example_targeting_fallback must be full_file or unresolved, got #{sym.inspect}"
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
sym
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def validate_example_targeting_cache(value)
|
|
414
|
+
raise Evilution::ConfigError, "example_targeting_cache must be a Hash, got #{value.class}" unless value.is_a?(Hash)
|
|
415
|
+
|
|
416
|
+
normalized = value.each_with_object({}) do |(k, v), acc|
|
|
417
|
+
unless k.is_a?(String) || k.is_a?(Symbol)
|
|
418
|
+
raise Evilution::ConfigError,
|
|
419
|
+
"example_targeting_cache keys must be Strings or Symbols, got #{k.inspect}"
|
|
420
|
+
end
|
|
421
|
+
acc[k.to_sym] = v
|
|
422
|
+
end
|
|
423
|
+
merged = DEFAULTS[:example_targeting_cache].merge(normalized)
|
|
424
|
+
validate_positive_int!(merged, :max_files)
|
|
425
|
+
validate_positive_int!(merged, :max_blocks)
|
|
426
|
+
merged
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def validate_positive_int!(cache, key)
|
|
430
|
+
v = cache[key]
|
|
431
|
+
return if v.is_a?(Integer) && v >= 1
|
|
432
|
+
|
|
433
|
+
raise Evilution::ConfigError,
|
|
434
|
+
"example_targeting_cache.#{key} must be a positive integer, got #{v.inspect}"
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def load_env_options
|
|
438
|
+
opts = {}
|
|
439
|
+
val = ENV.fetch("EV_DISABLE_EXAMPLE_TARGETING", nil)
|
|
440
|
+
opts[:example_targeting] = false if val && !val.empty? && val != "0"
|
|
441
|
+
opts
|
|
442
|
+
end
|
|
443
|
+
|
|
269
444
|
def validate_hooks(value)
|
|
270
445
|
return {} if value.nil?
|
|
271
446
|
raise Evilution::ConfigError, "hooks must be a mapping of event names to file paths, got #{value.class}" unless value.is_a?(Hash)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require_relative "../evilution"
|
|
5
|
+
require_relative "spec_ast_cache"
|
|
6
|
+
|
|
7
|
+
class Evilution::ExampleFilter
|
|
8
|
+
VALID_FALLBACKS = %i[full_file unresolved].freeze
|
|
9
|
+
private_constant :VALID_FALLBACKS
|
|
10
|
+
|
|
11
|
+
def initialize(cache:, fallback: :full_file, source_cache: nil)
|
|
12
|
+
raise ArgumentError, "invalid fallback: #{fallback.inspect}" unless VALID_FALLBACKS.include?(fallback)
|
|
13
|
+
|
|
14
|
+
@cache = cache
|
|
15
|
+
@fallback = fallback
|
|
16
|
+
@source_cache = source_cache
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(mutation, spec_paths)
|
|
20
|
+
return fallback_result(spec_paths) if spec_paths.nil? || spec_paths.empty?
|
|
21
|
+
|
|
22
|
+
token = extract_token(mutation)
|
|
23
|
+
return fallback_result(spec_paths) unless token
|
|
24
|
+
|
|
25
|
+
locations = scan_specs(token, spec_paths)
|
|
26
|
+
return fallback_result(spec_paths) if locations.empty?
|
|
27
|
+
|
|
28
|
+
locations.sort
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def fallback_result(spec_paths)
|
|
34
|
+
case @fallback
|
|
35
|
+
when :full_file then spec_paths
|
|
36
|
+
when :unresolved then nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def extract_token(mutation)
|
|
41
|
+
result = if @source_cache.nil?
|
|
42
|
+
Prism.parse(mutation.original_source)
|
|
43
|
+
else
|
|
44
|
+
@source_cache.fetch(mutation.original_source)
|
|
45
|
+
end
|
|
46
|
+
return nil if result.failure?
|
|
47
|
+
|
|
48
|
+
finder = EnclosingNodeFinder.new(mutation.line)
|
|
49
|
+
finder.visit(result.value)
|
|
50
|
+
finder.token
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def scan_specs(token, spec_paths)
|
|
54
|
+
pattern = /(?<!\w)#{Regexp.escape(token.downcase)}(?!\w)/
|
|
55
|
+
locations = []
|
|
56
|
+
spec_paths.each do |path|
|
|
57
|
+
blocks = @cache.fetch(path)
|
|
58
|
+
matches = blocks.select { |b| pattern.match?(b.body_text) }
|
|
59
|
+
innermost = filter_innermost(matches)
|
|
60
|
+
innermost.each { |b| locations << "#{path}:#{b.line}" }
|
|
61
|
+
end
|
|
62
|
+
locations.uniq
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def filter_innermost(matches)
|
|
66
|
+
matches.reject do |outer|
|
|
67
|
+
matches.any? do |inner|
|
|
68
|
+
next false if inner.equal?(outer)
|
|
69
|
+
|
|
70
|
+
contained?(inner, outer)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def contained?(inner, outer)
|
|
76
|
+
inner.line >= outer.line && inner.end_line <= outer.end_line &&
|
|
77
|
+
!(inner.line == outer.line && inner.end_line == outer.end_line)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
class EnclosingNodeFinder < Prism::Visitor
|
|
81
|
+
attr_reader :token
|
|
82
|
+
|
|
83
|
+
def initialize(target_line)
|
|
84
|
+
@target_line = target_line
|
|
85
|
+
@def_stack = []
|
|
86
|
+
@class_stack = []
|
|
87
|
+
@token = nil
|
|
88
|
+
@found = false
|
|
89
|
+
super()
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def visit_def_node(node)
|
|
93
|
+
return if @found
|
|
94
|
+
return unless target_within?(node)
|
|
95
|
+
|
|
96
|
+
@def_stack.push(node.name.to_s)
|
|
97
|
+
capture_if_match(node)
|
|
98
|
+
super
|
|
99
|
+
@def_stack.pop
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def visit_class_node(node)
|
|
103
|
+
return if @found
|
|
104
|
+
return unless target_within?(node)
|
|
105
|
+
|
|
106
|
+
@class_stack.push(unqualified_name(node.constant_path))
|
|
107
|
+
capture_if_match(node)
|
|
108
|
+
super
|
|
109
|
+
@class_stack.pop
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def visit_module_node(node)
|
|
113
|
+
return if @found
|
|
114
|
+
return unless target_within?(node)
|
|
115
|
+
|
|
116
|
+
@class_stack.push(unqualified_name(node.constant_path))
|
|
117
|
+
capture_if_match(node)
|
|
118
|
+
super
|
|
119
|
+
@class_stack.pop
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def capture_if_match(node)
|
|
125
|
+
return if @found
|
|
126
|
+
return unless target_within?(node)
|
|
127
|
+
|
|
128
|
+
@token = @def_stack.last || @class_stack.last
|
|
129
|
+
@found = true if @def_stack.any?
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def target_within?(node)
|
|
133
|
+
loc = node.location
|
|
134
|
+
@target_line.between?(loc.start_line, loc.end_line)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def unqualified_name(constant_path)
|
|
138
|
+
raw = constant_path.respond_to?(:name) ? constant_path.name.to_s : constant_path.to_s
|
|
139
|
+
raw.split("::").last
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
private_constant :EnclosingNodeFinder
|
|
143
|
+
end
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "fileutils"
|
|
4
3
|
require "prism"
|
|
5
|
-
require "tmpdir"
|
|
6
4
|
require_relative "../integration"
|
|
7
|
-
require_relative "../temp_dir_tracker"
|
|
8
5
|
|
|
9
6
|
class Evilution::Integration::Base
|
|
10
7
|
def self.baseline_runner
|
|
@@ -20,7 +17,6 @@ class Evilution::Integration::Base
|
|
|
20
17
|
end
|
|
21
18
|
|
|
22
19
|
def call(mutation)
|
|
23
|
-
@temp_dir = nil
|
|
24
20
|
ensure_framework_loaded
|
|
25
21
|
fire_hook(:mutation_insert_pre, mutation: mutation, file_path: mutation.file_path)
|
|
26
22
|
load_error = apply_mutation(mutation)
|
|
@@ -28,8 +24,6 @@ class Evilution::Integration::Base
|
|
|
28
24
|
|
|
29
25
|
fire_hook(:mutation_insert_post, mutation: mutation, file_path: mutation.file_path)
|
|
30
26
|
run_tests(mutation)
|
|
31
|
-
ensure
|
|
32
|
-
restore_original(mutation)
|
|
33
27
|
end
|
|
34
28
|
|
|
35
29
|
private
|
|
@@ -58,15 +52,10 @@ class Evilution::Integration::Base
|
|
|
58
52
|
prism_error = validate_mutated_syntax(mutation.mutated_source)
|
|
59
53
|
return prism_error if prism_error
|
|
60
54
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if subpath
|
|
67
|
-
apply_via_require(mutation, subpath)
|
|
68
|
-
else
|
|
69
|
-
apply_via_load(mutation)
|
|
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)
|
|
70
59
|
end
|
|
71
60
|
nil
|
|
72
61
|
rescue SyntaxError => e
|
|
@@ -96,29 +85,14 @@ class Evilution::Integration::Base
|
|
|
96
85
|
}
|
|
97
86
|
end
|
|
98
87
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
$LOAD_PATH.unshift(@temp_dir)
|
|
104
|
-
displace_loaded_feature(mutation.file_path)
|
|
105
|
-
pin_autoloaded_constants(mutation.original_source)
|
|
106
|
-
clear_concern_state(mutation.file_path)
|
|
107
|
-
with_redefinition_recovery(mutation.original_source) do
|
|
108
|
-
require(subpath.delete_suffix(".rb"))
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def apply_via_load(mutation)
|
|
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)
|
|
113
92
|
absolute = File.expand_path(mutation.file_path)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
pin_autoloaded_constants(mutation.original_source)
|
|
118
|
-
clear_concern_state(mutation.file_path)
|
|
119
|
-
with_redefinition_recovery(mutation.original_source) do
|
|
120
|
-
load(dest)
|
|
121
|
-
end
|
|
93
|
+
# rubocop:disable Security/Eval
|
|
94
|
+
eval(mutation.mutated_source, TOPLEVEL_BINDING, absolute, 1)
|
|
95
|
+
# rubocop:enable Security/Eval
|
|
122
96
|
end
|
|
123
97
|
|
|
124
98
|
def with_redefinition_recovery(original_source)
|
|
@@ -134,18 +108,6 @@ class Evilution::Integration::Base
|
|
|
134
108
|
error.message.include?("already defined")
|
|
135
109
|
end
|
|
136
110
|
|
|
137
|
-
def restore_original(_mutation)
|
|
138
|
-
return unless @temp_dir
|
|
139
|
-
|
|
140
|
-
$LOAD_PATH.delete(@temp_dir)
|
|
141
|
-
$LOADED_FEATURES.reject! { |f| f.start_with?(@temp_dir) }
|
|
142
|
-
$LOADED_FEATURES << @displaced_feature if @displaced_feature && !$LOADED_FEATURES.include?(@displaced_feature)
|
|
143
|
-
@displaced_feature = nil
|
|
144
|
-
FileUtils.rm_rf(@temp_dir)
|
|
145
|
-
Evilution::TempDirTracker.unregister(@temp_dir)
|
|
146
|
-
@temp_dir = nil
|
|
147
|
-
end
|
|
148
|
-
|
|
149
111
|
def pin_autoloaded_constants(source)
|
|
150
112
|
collect_constant_names(Prism.parse(source).value).each do |name|
|
|
151
113
|
Object.const_get(name) if Object.const_defined?(name, false)
|
|
@@ -237,12 +199,4 @@ class Evilution::Integration::Base
|
|
|
237
199
|
|
|
238
200
|
best_subpath
|
|
239
201
|
end
|
|
240
|
-
|
|
241
|
-
def displace_loaded_feature(file_path)
|
|
242
|
-
absolute = File.expand_path(file_path)
|
|
243
|
-
return unless $LOADED_FEATURES.include?(absolute)
|
|
244
|
-
|
|
245
|
-
@displaced_feature = absolute
|
|
246
|
-
$LOADED_FEATURES.delete(absolute)
|
|
247
|
-
end
|
|
248
202
|
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
|