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
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "../compare"
|
|
5
|
+
|
|
6
|
+
module Evilution::Compare::Fingerprint
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def extract_from_evilution_diff(diff)
|
|
10
|
+
minus = []
|
|
11
|
+
plus = []
|
|
12
|
+
diff.to_s.each_line do |line|
|
|
13
|
+
line = line.chomp
|
|
14
|
+
if line.start_with?("- ")
|
|
15
|
+
minus << line[2..]
|
|
16
|
+
elsif line.start_with?("+ ")
|
|
17
|
+
plus << line[2..]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
{ minus: minus, plus: plus }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def extract_from_mutant_diff(diff)
|
|
24
|
+
minus = []
|
|
25
|
+
plus = []
|
|
26
|
+
diff.to_s.each_line do |line|
|
|
27
|
+
line = line.chomp
|
|
28
|
+
next if line.start_with?("---", "+++", "@@")
|
|
29
|
+
|
|
30
|
+
if line.start_with?("-")
|
|
31
|
+
minus << line[1..]
|
|
32
|
+
elsif line.start_with?("+")
|
|
33
|
+
plus << line[1..]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
{ minus: minus, plus: plus }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# v1 limitation: only " and ' literals are preserved. Regex literals (/.../),
|
|
40
|
+
# heredocs, %w[], %q{} forms are treated as ordinary code — whitespace runs
|
|
41
|
+
# inside them collapse. A mutation touching whitespace inside a regex may
|
|
42
|
+
# false-match across tools.
|
|
43
|
+
# rubocop:disable Metrics/PerceivedComplexity, Style/MultipleComparison
|
|
44
|
+
def normalize_line(line)
|
|
45
|
+
out = +""
|
|
46
|
+
i = 0
|
|
47
|
+
in_literal = nil
|
|
48
|
+
last_was_space = false
|
|
49
|
+
chars = line.chars
|
|
50
|
+
while i < chars.length
|
|
51
|
+
ch = chars[i]
|
|
52
|
+
if in_literal
|
|
53
|
+
out << ch
|
|
54
|
+
if ch == "\\" && i + 1 < chars.length
|
|
55
|
+
out << chars[i + 1]
|
|
56
|
+
i += 2
|
|
57
|
+
next
|
|
58
|
+
end
|
|
59
|
+
in_literal = nil if ch == in_literal
|
|
60
|
+
elsif ch == '"' || ch == "'"
|
|
61
|
+
in_literal = ch
|
|
62
|
+
out << ch
|
|
63
|
+
last_was_space = false
|
|
64
|
+
elsif ch == " " || ch == "\t"
|
|
65
|
+
out << " " unless last_was_space || out.empty?
|
|
66
|
+
last_was_space = true
|
|
67
|
+
else
|
|
68
|
+
out << ch
|
|
69
|
+
last_was_space = false
|
|
70
|
+
end
|
|
71
|
+
i += 1
|
|
72
|
+
end
|
|
73
|
+
out.rstrip
|
|
74
|
+
end
|
|
75
|
+
# rubocop:enable Metrics/PerceivedComplexity, Style/MultipleComparison
|
|
76
|
+
|
|
77
|
+
def compute(file_path:, line:, body:)
|
|
78
|
+
minus = body[:minus].map { |l| normalize_line(l) }
|
|
79
|
+
plus = body[:plus].map { |l| normalize_line(l) }
|
|
80
|
+
payload = [file_path, line.to_s, minus.join("\n"), plus.join("\n")].join("\x00")
|
|
81
|
+
Digest::SHA256.hexdigest(payload)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -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
|
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
|
|
@@ -29,20 +31,31 @@ class Evilution::Config
|
|
|
29
31
|
skip_heredoc_literals: false,
|
|
30
32
|
related_specs_heuristic: false,
|
|
31
33
|
fallback_to_full_suite: false,
|
|
32
|
-
preload: nil
|
|
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 }
|
|
33
40
|
}.freeze
|
|
34
41
|
|
|
42
|
+
EXAMPLE_TARGETING_FALLBACKS = %i[full_file unresolved].freeze
|
|
43
|
+
private_constant :EXAMPLE_TARGETING_FALLBACKS
|
|
44
|
+
|
|
35
45
|
attr_reader :target_files, :timeout, :format,
|
|
36
46
|
:target, :min_score, :integration, :verbose, :quiet,
|
|
37
47
|
:jobs, :fail_fast, :baseline, :isolation, :incremental, :suggest_tests,
|
|
38
48
|
:progress, :save_session, :line_ranges, :spec_files, :hooks,
|
|
39
49
|
:ignore_patterns, :show_disabled, :baseline_session,
|
|
40
50
|
:skip_heredoc_literals, :related_specs_heuristic,
|
|
41
|
-
:fallback_to_full_suite, :preload
|
|
51
|
+
:fallback_to_full_suite, :preload, :spec_mappings, :spec_pattern,
|
|
52
|
+
:example_targeting, :example_targeting_fallback, :example_targeting_cache,
|
|
53
|
+
:spec_selector
|
|
42
54
|
|
|
43
55
|
def initialize(**options)
|
|
44
56
|
file_options = options.delete(:skip_config_file) ? {} : load_config_file
|
|
45
|
-
|
|
57
|
+
env_options = load_env_options
|
|
58
|
+
merged = DEFAULTS.merge(file_options).merge(env_options).merge(options)
|
|
46
59
|
assign_attributes(merged)
|
|
47
60
|
freeze
|
|
48
61
|
end
|
|
@@ -103,6 +116,10 @@ class Evilution::Config
|
|
|
103
116
|
related_specs_heuristic
|
|
104
117
|
end
|
|
105
118
|
|
|
119
|
+
def example_targeting?
|
|
120
|
+
example_targeting
|
|
121
|
+
end
|
|
122
|
+
|
|
106
123
|
def fallback_to_full_suite?
|
|
107
124
|
fallback_to_full_suite
|
|
108
125
|
end
|
|
@@ -177,6 +194,23 @@ class Evilution::Config
|
|
|
177
194
|
# worker_process_start: config/evilution_hooks/worker_start.rb
|
|
178
195
|
# mutation_insert_pre: config/evilution_hooks/mutation_pre.rb
|
|
179
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
|
+
|
|
180
214
|
# AST patterns to skip during mutation generation (default: [])
|
|
181
215
|
# See docs/ast_pattern_syntax.md for pattern syntax
|
|
182
216
|
# ignore_patterns:
|
|
@@ -225,6 +259,88 @@ class Evilution::Config
|
|
|
225
259
|
@fallback_to_full_suite = merged[:fallback_to_full_suite]
|
|
226
260
|
@hooks = validate_hooks(merged[:hooks])
|
|
227
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}"
|
|
228
344
|
end
|
|
229
345
|
|
|
230
346
|
def validate_preload(value)
|
|
@@ -279,6 +395,52 @@ class Evilution::Config
|
|
|
279
395
|
patterns
|
|
280
396
|
end
|
|
281
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
|
+
|
|
282
444
|
def validate_hooks(value)
|
|
283
445
|
return {} if value.nil?
|
|
284
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
|