evilution 0.29.0 → 0.30.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 +54 -0
- data/.rubocop_todo.yml +7 -0
- data/CHANGELOG.md +42 -0
- data/README.md +194 -8
- data/docs/versioning.md +53 -0
- data/lib/evilution/ast/heredoc_span.rb +99 -0
- data/lib/evilution/baseline.rb +15 -2
- data/lib/evilution/cli/commands/compare.rb +13 -0
- data/lib/evilution/cli/parser/command_extractor.rb +3 -1
- data/lib/evilution/cli/parser/options_builder.rb +2 -2
- data/lib/evilution/config/file_loader.rb +40 -1
- data/lib/evilution/config.rb +11 -1
- data/lib/evilution/equivalent/heuristic/dead_code.rb +8 -1
- data/lib/evilution/feedback/setup_warning.rb +79 -0
- data/lib/evilution/gem_detector.rb +132 -0
- data/lib/evilution/integration/loading/body_call_neutralizer.rb +190 -0
- data/lib/evilution/integration/loading/mutation_applier.rb +20 -5
- data/lib/evilution/integration/loading/redefinition_recovery.rb +58 -1
- data/lib/evilution/integration/minitest.rb +37 -2
- data/lib/evilution/integration/rspec/result_builder.rb +20 -1
- data/lib/evilution/integration/rspec.rb +16 -1
- data/lib/evilution/isolation/fork.rb +77 -10
- data/lib/evilution/mcp/info_tool/response_formatter.rb +14 -1
- data/lib/evilution/mcp/info_tool.rb +3 -1
- data/lib/evilution/mcp/mutate_tool/option_parser.rb +1 -1
- data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +58 -1
- data/lib/evilution/mcp/mutate_tool.rb +22 -3
- data/lib/evilution/mcp/session_tool.rb +7 -4
- data/lib/evilution/mcp.rb +6 -0
- data/lib/evilution/mutation.rb +13 -1
- data/lib/evilution/mutator/base.rb +49 -1
- data/lib/evilution/mutator/operator/argument_method_call_replacement.rb +59 -0
- data/lib/evilution/mutator/operator/block_param_removal.rb +32 -0
- data/lib/evilution/mutator/operator/explicit_super_mutation.rb +20 -2
- data/lib/evilution/mutator/operator/index_to_at.rb +13 -1
- data/lib/evilution/mutator/operator/last_expression_removal.rb +46 -0
- data/lib/evilution/mutator/operator/receiver_replacement.rb +29 -1
- data/lib/evilution/mutator/operator/rescue_removal.rb +59 -10
- data/lib/evilution/mutator/operator/splat_operator.rb +28 -1
- data/lib/evilution/mutator/operator/string_literal.rb +83 -6
- data/lib/evilution/mutator/registry.rb +2 -0
- data/lib/evilution/reporter/cli/line_formatters/error_rate_warning.rb +29 -0
- data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
- data/lib/evilution/reporter/json.rb +2 -0
- data/lib/evilution/result/mutation_result.rb +12 -6
- data/lib/evilution/runner/baseline_runner.rb +5 -1
- data/lib/evilution/runner/isolation_resolver.rb +69 -8
- data/lib/evilution/runner/mutation_planner.rb +18 -1
- data/lib/evilution/session/schema.rb +44 -0
- data/lib/evilution/session/store.rb +5 -1
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +2 -0
- data/schema/evilution.config.schema.json +205 -0
- data/script/build_runtime_snapshot +88 -0
- data/script/run_self_baseline +79 -0
- data/script/run_self_validation +54 -0
- metadata +15 -2
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../ast"
|
|
6
|
+
|
|
7
|
+
# Computes the byte-length needed for a mutation whose target range contains
|
|
8
|
+
# heredoc anchors (`<<~MARKER` / `<<-MARKER` / `<<MARKER`).
|
|
9
|
+
#
|
|
10
|
+
# Prism reports a heredoc anchor's `location` as the inline range of just
|
|
11
|
+
# `<<~MARKER` — the body lines and the closing terminator live in `closing_loc`
|
|
12
|
+
# which is on a later line. An operator that builds a byte edit from the
|
|
13
|
+
# anchor's inline range (e.g. `argument_removal` using
|
|
14
|
+
# `node.arguments.location`) covers the anchor but leaves the body+terminator
|
|
15
|
+
# in place, producing an orphaned heredoc fragment that the parser rejects.
|
|
16
|
+
#
|
|
17
|
+
# `extend_length` walks the supplied AST node for heredoc descendants whose
|
|
18
|
+
# anchor falls inside `[offset, offset + length)` and returns a length wide
|
|
19
|
+
# enough to also cover those descendants' `closing_loc.end_offset` — so the
|
|
20
|
+
# mutation's `replacement` replaces the heredoc body and terminator along with
|
|
21
|
+
# the anchor.
|
|
22
|
+
module Evilution::AST::HeredocSpan
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def extend_length(node:, offset:, length:)
|
|
26
|
+
return length if node.nil?
|
|
27
|
+
|
|
28
|
+
end_offset = offset + length
|
|
29
|
+
max_end = end_offset
|
|
30
|
+
Walker.new(offset, end_offset) do |closing_end|
|
|
31
|
+
max_end = closing_end if closing_end > max_end
|
|
32
|
+
end.visit(node)
|
|
33
|
+
max_end - offset
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class Walker < Prism::Visitor
|
|
37
|
+
def initialize(start_offset, end_offset, &block)
|
|
38
|
+
super()
|
|
39
|
+
@start_offset = start_offset
|
|
40
|
+
@end_offset = end_offset
|
|
41
|
+
@block = block
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def visit_string_node(node)
|
|
45
|
+
record_if_heredoc(node)
|
|
46
|
+
super
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def visit_interpolated_string_node(node)
|
|
50
|
+
record_if_heredoc(node)
|
|
51
|
+
super
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def visit_x_string_node(node)
|
|
55
|
+
record_if_heredoc(node)
|
|
56
|
+
super
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def visit_interpolated_x_string_node(node)
|
|
60
|
+
record_if_heredoc(node)
|
|
61
|
+
super
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def record_if_heredoc(node)
|
|
67
|
+
return unless heredoc?(node)
|
|
68
|
+
|
|
69
|
+
closing = node.closing_loc
|
|
70
|
+
return unless anchor_in_range?(node) && closing
|
|
71
|
+
|
|
72
|
+
@block.call(closing_end_excluding_trailing_newline(closing))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def heredoc?(node)
|
|
76
|
+
node.respond_to?(:heredoc?) && node.heredoc?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Anchor must be inside the mutation's target range; only then does its
|
|
80
|
+
# heredoc body sit outside the range and need pulling in.
|
|
81
|
+
def anchor_in_range?(node)
|
|
82
|
+
opening = node.opening_loc
|
|
83
|
+
return false if opening.nil?
|
|
84
|
+
|
|
85
|
+
opening.start_offset >= @start_offset && opening.start_offset < @end_offset
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Prism's closing_loc covers the terminator including the trailing
|
|
89
|
+
# newline. Excluding that newline preserves line structure after the
|
|
90
|
+
# replacement (any code that follows lands on its own line).
|
|
91
|
+
def closing_end_excluding_trailing_newline(closing)
|
|
92
|
+
end_off = closing.end_offset
|
|
93
|
+
slice = closing.slice
|
|
94
|
+
end_off -= 1 if slice && slice.end_with?("\n")
|
|
95
|
+
end_off
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
private_constant :Walker
|
|
99
|
+
end
|
data/lib/evilution/baseline.rb
CHANGED
|
@@ -15,16 +15,18 @@ class Evilution::Baseline
|
|
|
15
15
|
end
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def initialize(spec_resolver: Evilution::SpecResolver.new, timeout: 30, runner: nil,
|
|
18
|
+
def initialize(spec_resolver: Evilution::SpecResolver.new, timeout: 30, runner: nil,
|
|
19
|
+
fallback_dir: "spec", test_files: nil)
|
|
19
20
|
@spec_resolver = spec_resolver
|
|
20
21
|
@timeout = timeout
|
|
21
22
|
@runner = runner
|
|
22
23
|
@fallback_dir = fallback_dir
|
|
24
|
+
@test_files = test_files
|
|
23
25
|
end
|
|
24
26
|
|
|
25
27
|
def call(subjects)
|
|
26
28
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
27
|
-
spec_files =
|
|
29
|
+
spec_files = baseline_spec_files(subjects)
|
|
28
30
|
failed = Set.new
|
|
29
31
|
|
|
30
32
|
spec_files.each do |spec_file|
|
|
@@ -96,6 +98,17 @@ class Evilution::Baseline
|
|
|
96
98
|
|
|
97
99
|
private
|
|
98
100
|
|
|
101
|
+
# When --spec was provided, run those files only. Auto-discovery is skipped
|
|
102
|
+
# entirely — the user has declared what covers their subjects and any
|
|
103
|
+
# mismatch between auto-discovery and their declaration is what produced
|
|
104
|
+
# the misleading "No matching test found" warning users have reported even
|
|
105
|
+
# while passing --spec.
|
|
106
|
+
def baseline_spec_files(subjects)
|
|
107
|
+
return Array(@test_files).uniq if @test_files && !@test_files.empty?
|
|
108
|
+
|
|
109
|
+
resolve_unique_spec_files(subjects)
|
|
110
|
+
end
|
|
111
|
+
|
|
99
112
|
def resolve_unique_spec_files(subjects)
|
|
100
113
|
warned = Set.new
|
|
101
114
|
subjects.map do |s|
|
|
@@ -9,6 +9,7 @@ require_relative "../../compare"
|
|
|
9
9
|
require_relative "../../compare/categorizer"
|
|
10
10
|
require_relative "../../compare/detector"
|
|
11
11
|
require_relative "../../compare/normalizer"
|
|
12
|
+
require_relative "../../session/schema"
|
|
12
13
|
|
|
13
14
|
class Evilution::CLI::Commands::Compare < Evilution::CLI::Command
|
|
14
15
|
SUPPORTED_FORMATS = %i[json text].freeze
|
|
@@ -46,6 +47,7 @@ class Evilution::CLI::Commands::Compare < Evilution::CLI::Command
|
|
|
46
47
|
raise Evilution::Error, "file not found: #{path}" unless File.exist?(path)
|
|
47
48
|
|
|
48
49
|
json = JSON.parse(File.read(path))
|
|
50
|
+
validate_session_schema(json, path)
|
|
49
51
|
tool = Evilution::Compare::Detector.call(json)
|
|
50
52
|
normalize(json, tool)
|
|
51
53
|
rescue ::JSON::ParserError => e
|
|
@@ -56,6 +58,17 @@ class Evilution::CLI::Commands::Compare < Evilution::CLI::Command
|
|
|
56
58
|
raise Evilution::Error, e.message
|
|
57
59
|
end
|
|
58
60
|
|
|
61
|
+
# Validate before detection: schema_version is an evilution-only marker. A
|
|
62
|
+
# future schema may rearrange the shape enough that Detector cannot classify
|
|
63
|
+
# it; in that case the user must still see "Upgrade the evilution gem", not
|
|
64
|
+
# "cannot detect tool".
|
|
65
|
+
def validate_session_schema(json, path)
|
|
66
|
+
return unless json.is_a?(Hash)
|
|
67
|
+
return unless json.key?("schema_version") || json.key?(:schema_version)
|
|
68
|
+
|
|
69
|
+
Evilution::Session::Schema.validate!(json, source: path)
|
|
70
|
+
end
|
|
71
|
+
|
|
59
72
|
def normalize(json, tool)
|
|
60
73
|
normalizer = Evilution::Compare::Normalizer.new
|
|
61
74
|
case tool
|
|
@@ -16,6 +16,8 @@ class Evilution::CLI::Parser::CommandExtractor
|
|
|
16
16
|
"gc" => :session_gc
|
|
17
17
|
}.freeze
|
|
18
18
|
|
|
19
|
+
RUN_ALIASES = %w[run mutate].freeze
|
|
20
|
+
|
|
19
21
|
TESTS_SUBCOMMANDS = { "list" => :tests_list }.freeze
|
|
20
22
|
ENVIRONMENT_SUBCOMMANDS = { "show" => :environment_show }.freeze
|
|
21
23
|
UTIL_SUBCOMMANDS = { "mutation" => :util_mutation }.freeze
|
|
@@ -51,7 +53,7 @@ class Evilution::CLI::Parser::CommandExtractor
|
|
|
51
53
|
if SIMPLE_COMMANDS.key?(first)
|
|
52
54
|
@command = SIMPLE_COMMANDS[first]
|
|
53
55
|
@argv.shift
|
|
54
|
-
elsif first
|
|
56
|
+
elsif RUN_ALIASES.include?(first)
|
|
55
57
|
@argv.shift
|
|
56
58
|
elsif SUBCOMMAND_FAMILIES.key?(first)
|
|
57
59
|
@argv.shift
|
|
@@ -36,8 +36,8 @@ class Evilution::CLI::Parser::OptionsBuilder
|
|
|
36
36
|
opts.separator ""
|
|
37
37
|
opts.separator "Line-range targeting: lib/foo.rb:15-30, lib/foo.rb:15, lib/foo.rb:15-"
|
|
38
38
|
opts.separator ""
|
|
39
|
-
opts.separator "Commands: run (default), init, session {list,show,diff,gc}, subjects,
|
|
40
|
-
opts.separator " util {mutation}, environment {show}, compare, mcp, version"
|
|
39
|
+
opts.separator "Commands: run (default; alias: mutate), init, session {list,show,diff,gc}, subjects,"
|
|
40
|
+
opts.separator " tests {list}, util {mutation}, environment {show}, compare, mcp, version"
|
|
41
41
|
opts.separator ""
|
|
42
42
|
opts.separator "Options:"
|
|
43
43
|
end
|
|
@@ -5,12 +5,19 @@ require "yaml"
|
|
|
5
5
|
module Evilution::Config::FileLoader
|
|
6
6
|
module_function
|
|
7
7
|
|
|
8
|
+
# Keys recognised in YAML config files. `target_files` is intentionally excluded
|
|
9
|
+
# because it is CLI-positional (the file paths after `evilution run`).
|
|
10
|
+
KNOWN_KEYS = (Evilution::Config::DEFAULTS.keys + %i[hooks]).uniq.freeze
|
|
11
|
+
|
|
8
12
|
def load
|
|
9
13
|
Evilution::Config::CONFIG_FILES.each do |path|
|
|
10
14
|
next unless File.exist?(path)
|
|
11
15
|
|
|
12
16
|
data = YAML.safe_load_file(path, symbolize_names: true)
|
|
13
|
-
return data.is_a?(Hash)
|
|
17
|
+
return {} unless data.is_a?(Hash)
|
|
18
|
+
|
|
19
|
+
validate_schema!(data, path: path) if data.key?(:schema_version)
|
|
20
|
+
return data
|
|
14
21
|
rescue Psych::SyntaxError, Psych::DisallowedClass => e
|
|
15
22
|
raise Evilution::ConfigError.new("failed to parse config file #{path}: #{e.message}", file: path)
|
|
16
23
|
rescue SystemCallError => e
|
|
@@ -19,4 +26,36 @@ module Evilution::Config::FileLoader
|
|
|
19
26
|
|
|
20
27
|
{}
|
|
21
28
|
end
|
|
29
|
+
|
|
30
|
+
def validate_schema!(data, path:)
|
|
31
|
+
validate_schema_version_value!(data[:schema_version], path: path)
|
|
32
|
+
validate_known_keys!(data.keys, path: path)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def validate_schema_version_value!(version, path:)
|
|
36
|
+
unless version.is_a?(Integer) && version.positive?
|
|
37
|
+
raise Evilution::ConfigError.new(
|
|
38
|
+
"invalid schema_version #{version.inspect} in #{path}: must be a positive Integer",
|
|
39
|
+
file: path
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
return if version <= Evilution::Config::CURRENT_SCHEMA_VERSION
|
|
44
|
+
|
|
45
|
+
raise Evilution::ConfigError.new(
|
|
46
|
+
"schema_version #{version} in #{path} is newer than this evilution gem supports " \
|
|
47
|
+
"(current: #{Evilution::Config::CURRENT_SCHEMA_VERSION}). Upgrade the gem.",
|
|
48
|
+
file: path
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def validate_known_keys!(keys, path:)
|
|
53
|
+
unknown = keys - KNOWN_KEYS
|
|
54
|
+
return if unknown.empty?
|
|
55
|
+
|
|
56
|
+
raise Evilution::ConfigError.new(
|
|
57
|
+
"unknown key(s) #{unknown.inspect} in #{path}. Known keys: #{KNOWN_KEYS.sort.inspect}",
|
|
58
|
+
file: path
|
|
59
|
+
)
|
|
60
|
+
end
|
|
22
61
|
end
|
data/lib/evilution/config.rb
CHANGED
|
@@ -6,8 +6,10 @@ require_relative "spec_selector"
|
|
|
6
6
|
|
|
7
7
|
class Evilution::Config
|
|
8
8
|
CONFIG_FILES = %w[.evilution.yml config/evilution.yml].freeze
|
|
9
|
+
CURRENT_SCHEMA_VERSION = 1
|
|
9
10
|
|
|
10
11
|
DEFAULTS = {
|
|
12
|
+
schema_version: CURRENT_SCHEMA_VERSION,
|
|
11
13
|
timeout: 30, format: :text, target: nil, min_score: 0.0, integration: :rspec,
|
|
12
14
|
verbose: false, quiet: false, jobs: 1, fail_fast: nil, baseline: true,
|
|
13
15
|
isolation: :auto, incremental: false, suggest_tests: false, progress: true,
|
|
@@ -21,7 +23,7 @@ class Evilution::Config
|
|
|
21
23
|
profile: :default
|
|
22
24
|
}.freeze
|
|
23
25
|
|
|
24
|
-
attr_reader :target_files, :timeout, :format,
|
|
26
|
+
attr_reader :target_files, :schema_version, :timeout, :format,
|
|
25
27
|
:target, :min_score, :integration, :verbose, :quiet,
|
|
26
28
|
:jobs, :fail_fast, :baseline, :isolation, :incremental, :suggest_tests,
|
|
27
29
|
:progress, :save_session, :line_ranges, :spec_files, :hooks,
|
|
@@ -112,6 +114,13 @@ class Evilution::Config
|
|
|
112
114
|
# Evilution configuration
|
|
113
115
|
# See: https://github.com/marinazzio/evilution
|
|
114
116
|
|
|
117
|
+
# Schema version for this config file (current: #{CURRENT_SCHEMA_VERSION}).
|
|
118
|
+
# Declaring schema_version opts the file into strict validation:
|
|
119
|
+
# unknown keys raise ConfigError, and a future schema_version is
|
|
120
|
+
# rejected so an old gem cannot silently misread a newer config.
|
|
121
|
+
# Omit to keep the legacy lenient behavior (unknown keys ignored).
|
|
122
|
+
schema_version: #{CURRENT_SCHEMA_VERSION}
|
|
123
|
+
|
|
115
124
|
# Per-mutation timeout in seconds (default: 30)
|
|
116
125
|
# timeout: 30
|
|
117
126
|
|
|
@@ -208,6 +217,7 @@ class Evilution::Config
|
|
|
208
217
|
|
|
209
218
|
SIMPLE_ATTR_TRANSFORMS = {
|
|
210
219
|
target_files: ->(v) { Array(v) },
|
|
220
|
+
schema_version: nil,
|
|
211
221
|
timeout: nil,
|
|
212
222
|
format: :to_sym.to_proc,
|
|
213
223
|
target: nil,
|
|
@@ -3,8 +3,15 @@
|
|
|
3
3
|
require_relative "../heuristic"
|
|
4
4
|
|
|
5
5
|
class Evilution::Equivalent::Heuristic::DeadCode
|
|
6
|
+
# Both operators produce statement-deletion-shaped edits. MutationPlanner
|
|
7
|
+
# dedupes by (file_path, mutated_source); whichever operator is registered
|
|
8
|
+
# first surfaces its name on the surviving mutation. Classify equivalence
|
|
9
|
+
# by edit shape, not by operator label, so dead-code classification holds
|
|
10
|
+
# regardless of registry order (EV-74e3 PR #1236 review).
|
|
11
|
+
STATEMENT_DELETION_OPERATORS = %w[statement_deletion last_expression_removal].to_set.freeze
|
|
12
|
+
|
|
6
13
|
def match?(mutation)
|
|
7
|
-
return false unless mutation.operator_name
|
|
14
|
+
return false unless STATEMENT_DELETION_OPERATORS.include?(mutation.operator_name)
|
|
8
15
|
|
|
9
16
|
node = mutation.subject.node
|
|
10
17
|
return false unless node
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../feedback"
|
|
4
|
+
|
|
5
|
+
# Detects "setup misconfiguration" patterns where mutation testing returned a
|
|
6
|
+
# result that is technically valid (score 0.0 with all-errored mutations) but
|
|
7
|
+
# is almost certainly the wrong score because the worker process couldn't
|
|
8
|
+
# evaluate any mutated source.
|
|
9
|
+
#
|
|
10
|
+
# Most common cause: MCP runs default to `preload: false` to keep the long-lived
|
|
11
|
+
# MCP server clean. Rails / Zeitwerk projects that depend on autoload then fail
|
|
12
|
+
# in every worker with `NameError: uninitialized constant ...`. The user sees
|
|
13
|
+
# "0% PASS-but-FAIL" with no obvious hint that they need to pass an explicit
|
|
14
|
+
# `preload: spec/rails_helper.rb` option.
|
|
15
|
+
#
|
|
16
|
+
# When triggered, this returns a warning string the MCP response can surface
|
|
17
|
+
# alongside the trimmed report — turning a silent wrong score into a loud
|
|
18
|
+
# pointer at the likely fix.
|
|
19
|
+
module Evilution::Feedback::SetupWarning
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
ERROR_DOMINANCE_THRESHOLD = 0.8
|
|
23
|
+
ERROR_CLASS_CLUSTER_THRESHOLD = 0.8
|
|
24
|
+
|
|
25
|
+
def call(summary)
|
|
26
|
+
return nil if summary.nil?
|
|
27
|
+
return nil unless errors_dominate?(summary)
|
|
28
|
+
|
|
29
|
+
errored = summary.results.select(&:error?)
|
|
30
|
+
dominant_class = dominant_error_class(errored)
|
|
31
|
+
return nil unless dominant_class
|
|
32
|
+
|
|
33
|
+
message_for(dominant_class, errored.size, summary.total)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def errors_dominate?(summary)
|
|
37
|
+
return false if summary.total.zero?
|
|
38
|
+
|
|
39
|
+
summary.errors.to_f / summary.total >= ERROR_DOMINANCE_THRESHOLD
|
|
40
|
+
end
|
|
41
|
+
private_class_method :errors_dominate?
|
|
42
|
+
|
|
43
|
+
def dominant_error_class(errored_results)
|
|
44
|
+
return nil if errored_results.empty?
|
|
45
|
+
|
|
46
|
+
counts = errored_results.each_with_object(Hash.new(0)) do |result, acc|
|
|
47
|
+
acc[result.error_class] += 1 if result.error_class
|
|
48
|
+
end
|
|
49
|
+
return nil if counts.empty?
|
|
50
|
+
|
|
51
|
+
klass, count = counts.max_by { |_, v| v }
|
|
52
|
+
return nil if count.to_f / errored_results.size < ERROR_CLASS_CLUSTER_THRESHOLD
|
|
53
|
+
|
|
54
|
+
klass
|
|
55
|
+
end
|
|
56
|
+
private_class_method :dominant_error_class
|
|
57
|
+
|
|
58
|
+
NAME_ERROR_HINT = "Most mutations errored with NameError. This usually means autoloaded constants " \
|
|
59
|
+
"(Rails / Zeitwerk) weren't available when the mutation re-evaluated the source. " \
|
|
60
|
+
"Pass `preload: 'spec/rails_helper.rb'` (or your project's preload entry) so the " \
|
|
61
|
+
"MCP server requires it before forking workers."
|
|
62
|
+
|
|
63
|
+
LOAD_ERROR_HINT = "Most mutations errored with LoadError. A `require` in the mutated source path " \
|
|
64
|
+
"failed before any test ran. Check that the file's dependencies are reachable from " \
|
|
65
|
+
"the MCP server's load path, or pass `preload: '<entrypoint>'` to set them up."
|
|
66
|
+
|
|
67
|
+
GENERIC_HINT_TEMPLATE = "Most mutations errored with %<klass>s (%<count>d / %<total>d). The mutation " \
|
|
68
|
+
"score reflects this setup failure, not the test suite. Try the CLI for an " \
|
|
69
|
+
"independent reading, or pass `preload: '<path>'` if the failure is autoload-related."
|
|
70
|
+
|
|
71
|
+
def message_for(klass, count, total)
|
|
72
|
+
case klass.to_s
|
|
73
|
+
when "NameError" then NAME_ERROR_HINT
|
|
74
|
+
when "LoadError" then LOAD_ERROR_HINT
|
|
75
|
+
else format(GENERIC_HINT_TEMPLATE, klass: klass, count: count, total: total)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
private_class_method :message_for
|
|
79
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Evilution::GemDetector
|
|
4
|
+
@cache = {}
|
|
5
|
+
@mutex = Mutex.new
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
def gem_root_for(path)
|
|
9
|
+
return nil if path.nil?
|
|
10
|
+
|
|
11
|
+
dir = starting_dir(path)
|
|
12
|
+
return nil if dir.nil?
|
|
13
|
+
|
|
14
|
+
@mutex.synchronize do
|
|
15
|
+
return @cache[dir] if @cache.key?(dir)
|
|
16
|
+
|
|
17
|
+
@cache[dir] = walk_up(dir)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def gem_root_for_any(paths)
|
|
22
|
+
Array(paths).each do |path|
|
|
23
|
+
root = gem_root_for(path)
|
|
24
|
+
return root if root
|
|
25
|
+
end
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def gem_entry_for(root, target_paths: nil)
|
|
30
|
+
gem_name = gem_name_for(root, target_paths: target_paths)
|
|
31
|
+
return nil unless gem_name
|
|
32
|
+
|
|
33
|
+
dotted = File.join(root, "lib", "#{gem_name.tr("-", "/")}.rb")
|
|
34
|
+
return dotted if File.file?(dotted)
|
|
35
|
+
|
|
36
|
+
flat = File.join(root, "lib", "#{gem_name}.rb")
|
|
37
|
+
return flat if File.file?(flat)
|
|
38
|
+
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def reset_cache!
|
|
43
|
+
@mutex.synchronize { @cache.clear }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def starting_dir(path)
|
|
49
|
+
return File.expand_path(path) if File.directory?(path)
|
|
50
|
+
return File.expand_path(File.dirname(path)) if File.file?(path)
|
|
51
|
+
|
|
52
|
+
parent = File.expand_path(File.dirname(path))
|
|
53
|
+
File.directory?(parent) ? parent : nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def walk_up(dir)
|
|
57
|
+
current = dir
|
|
58
|
+
loop do
|
|
59
|
+
return current unless Dir.glob(File.join(current, "*.gemspec")).empty?
|
|
60
|
+
|
|
61
|
+
parent = File.dirname(current)
|
|
62
|
+
return nil if parent == current
|
|
63
|
+
|
|
64
|
+
current = parent
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# When the root has multiple gemspecs (e.g. dotenv ships dotenv.gemspec
|
|
69
|
+
# alongside dotenv-rails.gemspec), `Dir.glob.first` is filesystem-order-
|
|
70
|
+
# dependent and often picks the wrong one — preloading the rails companion
|
|
71
|
+
# then raises `uninitialized constant Rails`. Disambiguate by:
|
|
72
|
+
# 1. exact-entry match — if a target is *exactly* the lib entry for a
|
|
73
|
+
# gemspec (`lib/dotenv/rails.rb` for `dotenv-rails.gemspec`), use it
|
|
74
|
+
# 2. first-lib-subdir match — `lib/dotenv/parser.rb` matches `dotenv`
|
|
75
|
+
# 3. fall back to the shortest gemspec basename — `dotenv` <
|
|
76
|
+
# `dotenv-rails`, which is the conventional "parent" gem.
|
|
77
|
+
def gem_name_for(root, target_paths: nil)
|
|
78
|
+
names = Dir.glob(File.join(root, "*.gemspec")).map { |p| File.basename(p, ".gemspec") }
|
|
79
|
+
return nil if names.empty?
|
|
80
|
+
return names.first if names.length == 1
|
|
81
|
+
|
|
82
|
+
paths = Array(target_paths)
|
|
83
|
+
match_by_exact_entry(root, names, paths) ||
|
|
84
|
+
match_by_subdir(root, names, paths) ||
|
|
85
|
+
names.min_by(&:length)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def match_by_exact_entry(root, names, paths)
|
|
89
|
+
paths.each do |path|
|
|
90
|
+
next if path.nil?
|
|
91
|
+
|
|
92
|
+
expanded = File.expand_path(path)
|
|
93
|
+
match = names.find { |n| entry_paths_for(root, n).include?(expanded) }
|
|
94
|
+
return match if match
|
|
95
|
+
end
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def entry_paths_for(root, gem_name)
|
|
100
|
+
[
|
|
101
|
+
File.join(root, "lib", "#{gem_name.tr("-", "/")}.rb"),
|
|
102
|
+
File.join(root, "lib", "#{gem_name}.rb")
|
|
103
|
+
]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def match_by_subdir(root, names, paths)
|
|
107
|
+
paths.each do |path|
|
|
108
|
+
subdir = lib_subdir_for(root, path)
|
|
109
|
+
next if subdir.nil?
|
|
110
|
+
|
|
111
|
+
match = names.find { |n| n == subdir }
|
|
112
|
+
return match if match
|
|
113
|
+
end
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# For `<root>/lib/dotenv/parser.rb` returns "dotenv". For
|
|
118
|
+
# `<root>/lib/dotenv-rails.rb` returns "dotenv-rails". Returns nil when
|
|
119
|
+
# the target isn't under `<root>/lib/`.
|
|
120
|
+
def lib_subdir_for(root, path)
|
|
121
|
+
return nil if path.nil?
|
|
122
|
+
|
|
123
|
+
expanded = File.expand_path(path)
|
|
124
|
+
lib_root = File.join(File.expand_path(root), "lib")
|
|
125
|
+
return nil unless expanded.start_with?("#{lib_root}/")
|
|
126
|
+
|
|
127
|
+
relative = expanded[(lib_root.length + 1)..]
|
|
128
|
+
first_segment = relative.split("/", 2).first
|
|
129
|
+
File.basename(first_segment, ".rb")
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|