evilution 0.28.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 +106 -0
- data/.rubocop_todo.yml +7 -0
- data/CHANGELOG.md +49 -0
- data/README.md +194 -8
- data/docs/versioning.md +53 -0
- data/lib/evilution/ast/constant_names.rb +28 -11
- data/lib/evilution/ast/heredoc_span.rb +99 -0
- data/lib/evilution/ast/pattern/parser.rb +29 -17
- data/lib/evilution/baseline.rb +15 -2
- data/lib/evilution/cli/commands/compare.rb +13 -0
- data/lib/evilution/cli/commands/session_diff.rb +6 -4
- data/lib/evilution/cli/commands/subjects.rb +6 -3
- data/lib/evilution/cli/commands/util_mutation.rb +24 -19
- data/lib/evilution/cli/parser/command_extractor.rb +12 -12
- data/lib/evilution/cli/parser/file_args.rb +3 -1
- data/lib/evilution/cli/parser/options_builder.rb +31 -3
- data/lib/evilution/cli/parser/stdin_reader.rb +2 -2
- data/lib/evilution/cli/parser.rb +18 -20
- data/lib/evilution/cli/printers/environment.rb +19 -19
- data/lib/evilution/cli/printers/session_diff.rb +8 -8
- data/lib/evilution/compare/normalizer.rb +10 -5
- data/lib/evilution/config/file_loader.rb +40 -1
- data/lib/evilution/config.rb +21 -11
- data/lib/evilution/disable_comment.rb +21 -12
- 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 +35 -15
- data/lib/evilution/integration/loading/redefinition_recovery.rb +58 -1
- data/lib/evilution/integration/minitest.rb +60 -16
- data/lib/evilution/integration/rspec/result_builder.rb +20 -1
- data/lib/evilution/integration/rspec.rb +20 -1
- data/lib/evilution/isolation/fork.rb +104 -27
- data/lib/evilution/mcp/info_tool/actions/subjects.rb +32 -23
- data/lib/evilution/mcp/info_tool/actions/tests.rb +22 -12
- data/lib/evilution/mcp/info_tool/request_parser.rb +3 -1
- data/lib/evilution/mcp/info_tool/response_formatter.rb +14 -1
- data/lib/evilution/mcp/info_tool.rb +10 -2
- data/lib/evilution/mcp/mutate_tool/option_parser.rb +4 -2
- data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +58 -1
- data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +19 -9
- data/lib/evilution/mcp/mutate_tool.rb +49 -17
- data/lib/evilution/mcp/session_tool.rb +34 -22
- data/lib/evilution/mcp.rb +6 -0
- data/lib/evilution/mutation.rb +26 -16
- data/lib/evilution/mutator/base.rb +66 -16
- data/lib/evilution/mutator/operator/argument_method_call_replacement.rb +59 -0
- data/lib/evilution/mutator/operator/argument_nil_substitution.rb +11 -14
- data/lib/evilution/mutator/operator/argument_removal.rb +11 -14
- data/lib/evilution/mutator/operator/begin_unwrap.rb +17 -5
- data/lib/evilution/mutator/operator/bitwise_complement.rb +26 -19
- data/lib/evilution/mutator/operator/block_param_removal.rb +50 -8
- data/lib/evilution/mutator/operator/block_pass_removal.rb +19 -15
- data/lib/evilution/mutator/operator/case_when.rb +7 -5
- data/lib/evilution/mutator/operator/conditional_branch.rb +22 -22
- data/lib/evilution/mutator/operator/equality_to_identity.rb +8 -3
- data/lib/evilution/mutator/operator/explicit_super_mutation.rb +36 -14
- data/lib/evilution/mutator/operator/index_to_at.rb +18 -5
- data/lib/evilution/mutator/operator/index_to_dig.rb +12 -6
- data/lib/evilution/mutator/operator/index_to_fetch.rb +5 -4
- data/lib/evilution/mutator/operator/keyword_argument.rb +30 -25
- data/lib/evilution/mutator/operator/last_expression_removal.rb +46 -0
- data/lib/evilution/mutator/operator/mixin_removal.rb +20 -14
- data/lib/evilution/mutator/operator/multiple_assignment.rb +12 -13
- data/lib/evilution/mutator/operator/receiver_replacement.rb +38 -7
- data/lib/evilution/mutator/operator/regex_simplification.rb +62 -67
- data/lib/evilution/mutator/operator/rescue_body_replacement.rb +9 -8
- data/lib/evilution/mutator/operator/rescue_removal.rb +58 -12
- data/lib/evilution/mutator/operator/splat_operator.rb +28 -1
- data/lib/evilution/mutator/operator/string_literal.rb +83 -6
- data/lib/evilution/mutator/operator/superclass_removal.rb +21 -15
- data/lib/evilution/mutator/registry.rb +2 -0
- data/lib/evilution/parallel/work_queue/dispatcher.rb +15 -8
- data/lib/evilution/parallel/work_queue/worker.rb +10 -7
- data/lib/evilution/parallel/work_queue.rb +35 -18
- data/lib/evilution/reporter/cli/item_formatters/coverage_gap.rb +13 -8
- data/lib/evilution/reporter/cli/line_formatters/error_rate_warning.rb +29 -0
- data/lib/evilution/reporter/cli/line_formatters/mutations.rb +17 -8
- data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
- data/lib/evilution/reporter/json.rb +54 -18
- data/lib/evilution/reporter/suggestion/diff_helpers.rb +0 -13
- data/lib/evilution/reporter/suggestion/diff_lines.rb +28 -0
- data/lib/evilution/reporter/suggestion/templates/minitest.rb +20 -14
- data/lib/evilution/reporter/suggestion/templates/rspec.rb +19 -13
- data/lib/evilution/result/mutation_result.rb +12 -6
- data/lib/evilution/runner/baseline_runner.rb +20 -9
- data/lib/evilution/runner/diagnostics.rb +13 -9
- data/lib/evilution/runner/isolation_resolver.rb +75 -12
- data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -1
- data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +32 -10
- data/lib/evilution/runner/mutation_executor/strategy/sequential.rb +1 -1
- data/lib/evilution/runner/mutation_executor.rb +2 -0
- data/lib/evilution/runner/mutation_planner.rb +53 -16
- data/lib/evilution/runner/subject_pipeline.rb +21 -11
- data/lib/evilution/runner.rb +3 -3
- data/lib/evilution/session/diff.rb +15 -6
- data/lib/evilution/session/schema.rb +44 -0
- data/lib/evilution/session/store.rb +5 -1
- data/lib/evilution/spec_ast_cache.rb +26 -12
- 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/memory_check +11 -5
- data/script/run_self_baseline +79 -0
- data/script/run_self_validation +54 -0
- data/scripts/benchmark_density +10 -9
- data/scripts/compare_mutations +38 -21
- data/scripts/mutant_json_adapter +7 -4
- metadata +16 -2
|
@@ -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
|
|
@@ -14,10 +14,7 @@ class Evilution::CLI::Commands::SessionDiff < Evilution::CLI::Command
|
|
|
14
14
|
def perform
|
|
15
15
|
raise Evilution::ConfigError, "two session file paths required" unless @files.length == 2
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
base_data = store.load(@files[0])
|
|
19
|
-
head_data = store.load(@files[1])
|
|
20
|
-
result = Evilution::Session::Diff.new.call(base_data, head_data)
|
|
17
|
+
result = compute_diff(@files)
|
|
21
18
|
Evilution::CLI::Printers::SessionDiff.new(result, format: @options[:format]).render(@stdout)
|
|
22
19
|
0
|
|
23
20
|
rescue ::JSON::ParserError => e
|
|
@@ -25,6 +22,11 @@ class Evilution::CLI::Commands::SessionDiff < Evilution::CLI::Command
|
|
|
25
22
|
rescue SystemCallError => e
|
|
26
23
|
raise Evilution::Error, e.message
|
|
27
24
|
end
|
|
25
|
+
|
|
26
|
+
def compute_diff(files)
|
|
27
|
+
store = Evilution::Session::Store.new
|
|
28
|
+
Evilution::Session::Diff.new.call(store.load(files[0]), store.load(files[1]))
|
|
29
|
+
end
|
|
28
30
|
end
|
|
29
31
|
|
|
30
32
|
Evilution::CLI::Dispatcher.register(:session_diff, Evilution::CLI::Commands::SessionDiff)
|
|
@@ -9,6 +9,9 @@ require_relative "../../runner"
|
|
|
9
9
|
require_relative "../../mutator"
|
|
10
10
|
|
|
11
11
|
class Evilution::CLI::Commands::Subjects < Evilution::CLI::Command
|
|
12
|
+
EntriesResult = Data.define(:entries, :total)
|
|
13
|
+
private_constant :EntriesResult
|
|
14
|
+
|
|
12
15
|
private
|
|
13
16
|
|
|
14
17
|
def perform
|
|
@@ -23,8 +26,8 @@ class Evilution::CLI::Commands::Subjects < Evilution::CLI::Command
|
|
|
23
26
|
return 0
|
|
24
27
|
end
|
|
25
28
|
|
|
26
|
-
|
|
27
|
-
Evilution::CLI::Printers::Subjects.new(entries, total_mutations: total).render(@stdout)
|
|
29
|
+
result = collect_entries(subjects, config)
|
|
30
|
+
Evilution::CLI::Printers::Subjects.new(result.entries, total_mutations: result.total).render(@stdout)
|
|
28
31
|
0
|
|
29
32
|
end
|
|
30
33
|
|
|
@@ -43,7 +46,7 @@ class Evilution::CLI::Commands::Subjects < Evilution::CLI::Command
|
|
|
43
46
|
subj.release_node!
|
|
44
47
|
end
|
|
45
48
|
|
|
46
|
-
|
|
49
|
+
EntriesResult.new(entries: entries, total: total)
|
|
47
50
|
end
|
|
48
51
|
end
|
|
49
52
|
|
|
@@ -11,11 +11,14 @@ require_relative "../../mutator/registry"
|
|
|
11
11
|
require_relative "../../ast/parser"
|
|
12
12
|
|
|
13
13
|
class Evilution::CLI::Commands::UtilMutation < Evilution::CLI::Command
|
|
14
|
+
SourceInput = Data.define(:source, :file_path)
|
|
15
|
+
private_constant :SourceInput
|
|
16
|
+
|
|
14
17
|
private
|
|
15
18
|
|
|
16
19
|
def perform
|
|
17
|
-
|
|
18
|
-
subjects = parse_source_to_subjects(source, file_path)
|
|
20
|
+
input = resolve_util_mutation_source
|
|
21
|
+
subjects = parse_source_to_subjects(input.source, input.file_path)
|
|
19
22
|
config = Evilution::Config.new(**@options)
|
|
20
23
|
registry = Evilution::Mutator::Registry.default
|
|
21
24
|
operator_options = build_operator_options(config)
|
|
@@ -33,24 +36,26 @@ class Evilution::CLI::Commands::UtilMutation < Evilution::CLI::Command
|
|
|
33
36
|
end
|
|
34
37
|
|
|
35
38
|
def resolve_util_mutation_source
|
|
36
|
-
if @options[:eval]
|
|
37
|
-
|
|
38
|
-
tmpfile.write(@options[:eval])
|
|
39
|
-
tmpfile.flush
|
|
40
|
-
@util_tmpfile = tmpfile
|
|
41
|
-
[@options[:eval], tmpfile.path]
|
|
42
|
-
elsif @files.first
|
|
43
|
-
path = @files.first
|
|
44
|
-
raise Evilution::Error, "file not found: #{path}" unless File.exist?(path)
|
|
39
|
+
return build_eval_source(@options[:eval]) if @options[:eval]
|
|
40
|
+
return build_file_source(@files.first) if @files.first
|
|
45
41
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
42
|
+
raise Evilution::Error, "source required: use -e 'code' or provide a file path"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def build_eval_source(code)
|
|
46
|
+
tmpfile = Tempfile.new(["evilution_eval", ".rb"])
|
|
47
|
+
tmpfile.write(code)
|
|
48
|
+
tmpfile.flush
|
|
49
|
+
@util_tmpfile = tmpfile
|
|
50
|
+
SourceInput.new(source: code, file_path: tmpfile.path)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def build_file_source(path)
|
|
54
|
+
raise Evilution::Error, "file not found: #{path}" unless File.exist?(path)
|
|
55
|
+
|
|
56
|
+
SourceInput.new(source: File.read(path), file_path: path)
|
|
57
|
+
rescue SystemCallError => e
|
|
58
|
+
raise Evilution::Error, e.message
|
|
54
59
|
end
|
|
55
60
|
|
|
56
61
|
def parse_source_to_subjects(source, file_label)
|
|
@@ -16,10 +16,19 @@ 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
|
|
22
24
|
|
|
25
|
+
SUBCOMMAND_FAMILIES = {
|
|
26
|
+
"session" => [SESSION_SUBCOMMANDS, "session", "list, show, diff, gc"],
|
|
27
|
+
"tests" => [TESTS_SUBCOMMANDS, "tests", "list"],
|
|
28
|
+
"environment" => [ENVIRONMENT_SUBCOMMANDS, "environment", "show"],
|
|
29
|
+
"util" => [UTIL_SUBCOMMANDS, "util", "mutation"]
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
23
32
|
Result = Struct.new(:command, :remaining_argv, :parse_error)
|
|
24
33
|
|
|
25
34
|
def self.call(argv)
|
|
@@ -44,20 +53,11 @@ class Evilution::CLI::Parser::CommandExtractor
|
|
|
44
53
|
if SIMPLE_COMMANDS.key?(first)
|
|
45
54
|
@command = SIMPLE_COMMANDS[first]
|
|
46
55
|
@argv.shift
|
|
47
|
-
elsif first
|
|
48
|
-
@argv.shift
|
|
49
|
-
elsif first == "session"
|
|
50
|
-
@argv.shift
|
|
51
|
-
extract_subcommand(SESSION_SUBCOMMANDS, "session", "list, show, diff, gc")
|
|
52
|
-
elsif first == "tests"
|
|
53
|
-
@argv.shift
|
|
54
|
-
extract_subcommand(TESTS_SUBCOMMANDS, "tests", "list")
|
|
55
|
-
elsif first == "environment"
|
|
56
|
+
elsif RUN_ALIASES.include?(first)
|
|
56
57
|
@argv.shift
|
|
57
|
-
|
|
58
|
-
elsif first == "util"
|
|
58
|
+
elsif SUBCOMMAND_FAMILIES.key?(first)
|
|
59
59
|
@argv.shift
|
|
60
|
-
extract_subcommand(
|
|
60
|
+
extract_subcommand(*SUBCOMMAND_FAMILIES[first])
|
|
61
61
|
end
|
|
62
62
|
end
|
|
63
63
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Evilution::CLI::Parser::FileArgs
|
|
4
|
+
ParsedPaths = Data.define(:files, :ranges)
|
|
5
|
+
|
|
4
6
|
module_function
|
|
5
7
|
|
|
6
8
|
def parse(raw_args)
|
|
@@ -15,7 +17,7 @@ module Evilution::CLI::Parser::FileArgs
|
|
|
15
17
|
ranges[file] = parse_line_range(range_str)
|
|
16
18
|
end
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
ParsedPaths.new(files: files, ranges: ranges)
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
def expand_spec_dir(dir)
|
|
@@ -21,6 +21,8 @@ class Evilution::CLI::Parser::OptionsBuilder
|
|
|
21
21
|
add_core_options(opts)
|
|
22
22
|
add_filter_options(opts)
|
|
23
23
|
add_flag_options(opts)
|
|
24
|
+
add_runner_mode_options(opts)
|
|
25
|
+
add_output_options(opts)
|
|
24
26
|
add_profile_options(opts)
|
|
25
27
|
add_extra_flag_options(opts)
|
|
26
28
|
add_session_options(opts)
|
|
@@ -34,8 +36,8 @@ class Evilution::CLI::Parser::OptionsBuilder
|
|
|
34
36
|
opts.separator ""
|
|
35
37
|
opts.separator "Line-range targeting: lib/foo.rb:15-30, lib/foo.rb:15, lib/foo.rb:15-"
|
|
36
38
|
opts.separator ""
|
|
37
|
-
opts.separator "Commands: run (default), init, session {list,show,diff,gc}, subjects,
|
|
38
|
-
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"
|
|
39
41
|
opts.separator ""
|
|
40
42
|
opts.separator "Options:"
|
|
41
43
|
end
|
|
@@ -47,11 +49,19 @@ class Evilution::CLI::Parser::OptionsBuilder
|
|
|
47
49
|
end
|
|
48
50
|
|
|
49
51
|
def add_filter_options(opts)
|
|
52
|
+
add_spec_filter_options(opts)
|
|
53
|
+
add_targeting_options(opts)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def add_spec_filter_options(opts)
|
|
50
57
|
opts.on("--min-score FLOAT", Float, "Minimum mutation score to pass") { |s| @options[:min_score] = s }
|
|
51
58
|
opts.on("--spec FILES", Array, "Spec files to run (comma-separated)") { |f| @options[:spec_files] = f }
|
|
52
59
|
opts.on("--spec-dir DIR", "Include all specs in DIR") { |d| expand_spec_dir(d) }
|
|
53
60
|
opts.on("--spec-pattern GLOB",
|
|
54
61
|
"Restrict resolved spec candidates to files matching GLOB") { |p| @options[:spec_pattern] = p }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def add_targeting_options(opts)
|
|
55
65
|
opts.on("--no-example-targeting",
|
|
56
66
|
"Disable per-mutation example targeting (run all examples in resolved spec files)") do
|
|
57
67
|
@options[:example_targeting] = false
|
|
@@ -76,13 +86,19 @@ class Evilution::CLI::Parser::OptionsBuilder
|
|
|
76
86
|
"Use --no-incremental to override `incremental: true` from the config file for one run.") do |v|
|
|
77
87
|
@options[:incremental] = v
|
|
78
88
|
end
|
|
89
|
+
opts.on("--stdin", "Read target file paths from stdin (one per line)") { @options[:stdin] = true }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def add_runner_mode_options(opts)
|
|
79
93
|
opts.on("--integration NAME", "Test integration: rspec, minitest (default: rspec)") { |i| @options[:integration] = i }
|
|
80
94
|
opts.on("--isolation STRATEGY", "Isolation: auto, fork, in_process (default: auto)") { |s| @options[:isolation] = s }
|
|
81
95
|
opts.on("--preload FILE", "Preload FILE in the parent process before forking " \
|
|
82
96
|
"(default: auto-detect spec/rails_helper.rb -> spec/spec_helper.rb -> " \
|
|
83
97
|
"test/test_helper.rb for Rails projects)") { |f| @options[:preload] = f }
|
|
84
98
|
opts.on("--no-preload", "Disable parent-process preload even for Rails projects") { @options[:preload] = false }
|
|
85
|
-
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def add_output_options(opts)
|
|
86
102
|
opts.on("--suggest-tests", "Generate concrete test code in suggestions (RSpec or Minitest)") { @options[:suggest_tests] = true }
|
|
87
103
|
opts.on("--no-progress", "Disable progress bar") { @options[:progress] = false }
|
|
88
104
|
opts.on("--quiet-children",
|
|
@@ -103,6 +119,12 @@ class Evilution::CLI::Parser::OptionsBuilder
|
|
|
103
119
|
end
|
|
104
120
|
|
|
105
121
|
def add_extra_flag_options(opts)
|
|
122
|
+
add_mutation_behavior_options(opts)
|
|
123
|
+
add_session_persistence_options(opts)
|
|
124
|
+
add_misc_extra_options(opts)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def add_mutation_behavior_options(opts)
|
|
106
128
|
opts.on("--skip-heredoc-literals", "Skip all string literal mutations inside heredocs") { @options[:skip_heredoc_literals] = true }
|
|
107
129
|
opts.on("--related-specs-heuristic", "Append related request/integration/feature/system specs for includes() mutations") do
|
|
108
130
|
@options[:related_specs_heuristic] = true
|
|
@@ -112,8 +134,14 @@ class Evilution::CLI::Parser::OptionsBuilder
|
|
|
112
134
|
@options[:fallback_to_full_suite] = true
|
|
113
135
|
end
|
|
114
136
|
opts.on("--show-disabled", "Report mutations skipped by # evilution:disable") { @options[:show_disabled] = true }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def add_session_persistence_options(opts)
|
|
115
140
|
opts.on("--baseline-session PATH", "Compare against a baseline session in HTML report") { |p| @options[:baseline_session] = p }
|
|
116
141
|
opts.on("--save-session", "Save session results to .evilution/results/") { @options[:save_session] = true }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def add_misc_extra_options(opts)
|
|
117
145
|
opts.on("-e", "--eval CODE", "Evaluate code snippet (for util mutation)") { |c| @options[:eval] = c }
|
|
118
146
|
opts.on("-v", "--verbose", "Verbose output") { @options[:verbose] = true }
|
|
119
147
|
opts.on("-q", "--quiet", "Suppress output") { @options[:quiet] = true }
|
|
@@ -22,7 +22,7 @@ class Evilution::CLI::Parser::StdinReader
|
|
|
22
22
|
line = line.strip
|
|
23
23
|
lines << line unless line.empty?
|
|
24
24
|
end
|
|
25
|
-
|
|
26
|
-
Result.new(files, ranges, nil)
|
|
25
|
+
parsed_paths = Evilution::CLI::Parser::FileArgs.parse(lines)
|
|
26
|
+
Result.new(parsed_paths.files, parsed_paths.ranges, nil)
|
|
27
27
|
end
|
|
28
28
|
end
|
data/lib/evilution/cli/parser.rb
CHANGED
|
@@ -20,7 +20,9 @@ class Evilution::CLI::Parser
|
|
|
20
20
|
|
|
21
21
|
preprocess_flags
|
|
22
22
|
remaining = OptionsBuilder.build(@options).parse!(@argv)
|
|
23
|
-
|
|
23
|
+
parsed_paths = FileArgs.parse(remaining)
|
|
24
|
+
@files = parsed_paths.files
|
|
25
|
+
@line_ranges = parsed_paths.ranges
|
|
24
26
|
read_stdin_files if @options.delete(:stdin) && %i[run subjects].include?(@command)
|
|
25
27
|
build_parsed_args
|
|
26
28
|
end
|
|
@@ -37,27 +39,23 @@ class Evilution::CLI::Parser
|
|
|
37
39
|
def preprocess_flags
|
|
38
40
|
result = []
|
|
39
41
|
i = 0
|
|
40
|
-
while i < @argv.length
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
next_arg = @argv[i + 1]
|
|
42
|
+
i = consume_token(i, result) while i < @argv.length
|
|
43
|
+
@argv = result
|
|
44
|
+
end
|
|
44
45
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
end
|
|
52
|
-
elsif arg.start_with?("--fail-fast=")
|
|
53
|
-
@options[:fail_fast] = arg.delete_prefix("--fail-fast=")
|
|
54
|
-
i += 1
|
|
55
|
-
else
|
|
56
|
-
result << arg
|
|
57
|
-
i += 1
|
|
58
|
-
end
|
|
46
|
+
def consume_token(i, result)
|
|
47
|
+
arg = @argv[i]
|
|
48
|
+
next_arg = @argv[i + 1]
|
|
49
|
+
if arg == "--fail-fast" && !next_arg.nil? && next_arg.match?(/\A-?\d+\z/)
|
|
50
|
+
@options[:fail_fast] = next_arg
|
|
51
|
+
return i + 2
|
|
59
52
|
end
|
|
60
|
-
|
|
53
|
+
if arg.start_with?("--fail-fast=")
|
|
54
|
+
@options[:fail_fast] = arg.delete_prefix("--fail-fast=")
|
|
55
|
+
return i + 1
|
|
56
|
+
end
|
|
57
|
+
result << arg
|
|
58
|
+
i + 1
|
|
61
59
|
end
|
|
62
60
|
|
|
63
61
|
def read_stdin_files
|
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
require_relative "../printers"
|
|
4
4
|
|
|
5
5
|
class Evilution::CLI::Printers::Environment
|
|
6
|
+
PLAIN_SETTINGS = %i[
|
|
7
|
+
timeout format integration jobs isolation baseline incremental
|
|
8
|
+
verbose quiet progress min_score suggest_tests save_session
|
|
9
|
+
skip_heredoc_literals
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
6
12
|
def initialize(config, config_file:)
|
|
7
13
|
@config = config
|
|
8
14
|
@config_file = config_file
|
|
@@ -30,24 +36,18 @@ class Evilution::CLI::Printers::Environment
|
|
|
30
36
|
end
|
|
31
37
|
|
|
32
38
|
def settings_lines
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
" suggest_tests: #{@config.suggest_tests}",
|
|
47
|
-
" save_session: #{@config.save_session}",
|
|
48
|
-
" target: #{@config.target || "(all files)"}",
|
|
49
|
-
" skip_heredoc_literals: #{@config.skip_heredoc_literals}",
|
|
50
|
-
" ignore_patterns: #{@config.ignore_patterns.empty? ? "(none)" : @config.ignore_patterns.inspect}"
|
|
51
|
-
]
|
|
39
|
+
plain_lines = PLAIN_SETTINGS.map { |k| setting_line(k, @config.public_send(k)) }
|
|
40
|
+
plain_lines.insert(10, setting_line(:fail_fast, @config.fail_fast || "(disabled)"))
|
|
41
|
+
plain_lines.insert(14, setting_line(:target, @config.target || "(all files)"))
|
|
42
|
+
plain_lines << setting_line(:ignore_patterns, format_ignore_patterns(@config.ignore_patterns))
|
|
43
|
+
plain_lines
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def setting_line(key, value)
|
|
47
|
+
" #{key}: #{value}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def format_ignore_patterns(patterns)
|
|
51
|
+
patterns.empty? ? "(none)" : patterns.inspect
|
|
52
52
|
end
|
|
53
53
|
end
|
|
@@ -32,16 +32,16 @@ class Evilution::CLI::Printers::SessionDiff
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def print_summary(io, summary)
|
|
35
|
-
delta_str = format("%+.2f%%", summary.score_delta * 100)
|
|
36
35
|
io.puts("Session Diff")
|
|
37
36
|
io.puts("=" * 40)
|
|
38
|
-
io.puts(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
37
|
+
io.puts(score_line("Base", summary.base_score, summary.base_killed, summary.base_total))
|
|
38
|
+
io.puts(score_line("Head", summary.head_score, summary.head_killed, summary.head_total))
|
|
39
|
+
io.puts("Delta: #{format("%+.2f%%", summary.score_delta * 100)}")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def score_line(label, score, killed, total)
|
|
43
|
+
format("%<label>s score: %<score>6.2f%% (%<killed>d/%<total>d killed)",
|
|
44
|
+
label: label, score: score * 100, killed: killed, total: total)
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
def print_section(io, title, mutations, color)
|
|
@@ -57,11 +57,7 @@ class Evilution::Compare::Normalizer
|
|
|
57
57
|
private
|
|
58
58
|
|
|
59
59
|
def build_evilution_record(entry, index:)
|
|
60
|
-
file_path
|
|
61
|
-
line = entry["line"] or raise Evilution::Compare::InvalidInput.new("missing 'line' in record", index: index)
|
|
62
|
-
diff = entry["diff"].to_s
|
|
63
|
-
status = EVILUTION_STATUS_MAP[entry["status"]] ||
|
|
64
|
-
raise(Evilution::Compare::InvalidInput.new("unknown status #{entry["status"].inspect}", index: index))
|
|
60
|
+
file_path, line, diff, status = extract_evilution_fields(entry, index)
|
|
65
61
|
Evilution::Compare::Record.new(
|
|
66
62
|
source: :evilution,
|
|
67
63
|
file_path: file_path,
|
|
@@ -74,6 +70,15 @@ class Evilution::Compare::Normalizer
|
|
|
74
70
|
)
|
|
75
71
|
end
|
|
76
72
|
|
|
73
|
+
def extract_evilution_fields(entry, index)
|
|
74
|
+
file_path = entry["file"] or raise Evilution::Compare::InvalidInput.new("missing 'file' in record", index: index)
|
|
75
|
+
line = entry["line"] or raise Evilution::Compare::InvalidInput.new("missing 'line' in record", index: index)
|
|
76
|
+
diff = entry["diff"].to_s
|
|
77
|
+
status = EVILUTION_STATUS_MAP[entry["status"]] ||
|
|
78
|
+
raise(Evilution::Compare::InvalidInput.new("unknown status #{entry["status"].inspect}", index: index))
|
|
79
|
+
[file_path, line, diff, status]
|
|
80
|
+
end
|
|
81
|
+
|
|
77
82
|
def build_mutant_record(cov, source_path:, index:)
|
|
78
83
|
mr = cov["mutation_result"] or raise Evilution::Compare::InvalidInput.new("missing mutation_result", index: index)
|
|
79
84
|
cr = cov["criteria_result"] or raise Evilution::Compare::InvalidInput.new("missing criteria_result", index: index)
|
|
@@ -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,
|
|
@@ -239,17 +249,17 @@ class Evilution::Config
|
|
|
239
249
|
end
|
|
240
250
|
end
|
|
241
251
|
|
|
252
|
+
VALIDATED_ATTRS = %i[
|
|
253
|
+
integration jobs fail_fast isolation ignore_patterns
|
|
254
|
+
hooks preload spec_mappings spec_pattern profile
|
|
255
|
+
].freeze
|
|
256
|
+
private_constant :VALIDATED_ATTRS
|
|
257
|
+
|
|
242
258
|
def assign_validated_attributes(merged)
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
@ignore_patterns = Validators::IgnorePatterns.call(merged[:ignore_patterns])
|
|
248
|
-
@hooks = Validators::Hooks.call(merged[:hooks])
|
|
249
|
-
@preload = Validators::Preload.call(merged[:preload])
|
|
250
|
-
@spec_mappings = Validators::SpecMappings.call(merged[:spec_mappings])
|
|
251
|
-
@spec_pattern = Validators::SpecPattern.call(merged[:spec_pattern])
|
|
252
|
-
@profile = Validators::Profile.call(merged[:profile])
|
|
259
|
+
VALIDATED_ATTRS.each do |key|
|
|
260
|
+
validator = Validators.const_get(key.to_s.split("_").map(&:capitalize).join)
|
|
261
|
+
instance_variable_set(:"@#{key}", validator.call(merged[key]))
|
|
262
|
+
end
|
|
253
263
|
end
|
|
254
264
|
|
|
255
265
|
def assign_example_targeting(merged)
|
|
@@ -20,21 +20,30 @@ class Evilution::DisableComment
|
|
|
20
20
|
private
|
|
21
21
|
|
|
22
22
|
def classify_comments(parse_result, source)
|
|
23
|
-
parse_result.comments.filter_map
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
end
|
|
23
|
+
parse_result.comments.filter_map { |comment| classify_comment(comment, source) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def classify_comment(comment, source)
|
|
27
|
+
loc = comment.location
|
|
28
|
+
text = comment_text(loc, source)
|
|
29
|
+
|
|
30
|
+
if text.match?(DISABLE_MARKER)
|
|
31
|
+
disable_entry(loc, text, source)
|
|
32
|
+
elsif text.match?(ENABLE_MARKER)
|
|
33
|
+
{ type: :enable, line: loc.start_line }
|
|
35
34
|
end
|
|
36
35
|
end
|
|
37
36
|
|
|
37
|
+
def comment_text(loc, source)
|
|
38
|
+
source.byteslice(loc.start_offset, loc.end_offset - loc.start_offset)
|
|
39
|
+
.force_encoding(source.encoding)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def disable_entry(loc, text, source)
|
|
43
|
+
standalone = source.lines[loc.start_line - 1].strip == text.strip
|
|
44
|
+
{ type: :disable, line: loc.start_line, standalone: standalone }
|
|
45
|
+
end
|
|
46
|
+
|
|
38
47
|
def scan_comments(comments, method_ranges, total_lines)
|
|
39
48
|
disabled = []
|
|
40
49
|
range_start = 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
|