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.
Files changed (112) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +106 -0
  3. data/.rubocop_todo.yml +7 -0
  4. data/CHANGELOG.md +49 -0
  5. data/README.md +194 -8
  6. data/docs/versioning.md +53 -0
  7. data/lib/evilution/ast/constant_names.rb +28 -11
  8. data/lib/evilution/ast/heredoc_span.rb +99 -0
  9. data/lib/evilution/ast/pattern/parser.rb +29 -17
  10. data/lib/evilution/baseline.rb +15 -2
  11. data/lib/evilution/cli/commands/compare.rb +13 -0
  12. data/lib/evilution/cli/commands/session_diff.rb +6 -4
  13. data/lib/evilution/cli/commands/subjects.rb +6 -3
  14. data/lib/evilution/cli/commands/util_mutation.rb +24 -19
  15. data/lib/evilution/cli/parser/command_extractor.rb +12 -12
  16. data/lib/evilution/cli/parser/file_args.rb +3 -1
  17. data/lib/evilution/cli/parser/options_builder.rb +31 -3
  18. data/lib/evilution/cli/parser/stdin_reader.rb +2 -2
  19. data/lib/evilution/cli/parser.rb +18 -20
  20. data/lib/evilution/cli/printers/environment.rb +19 -19
  21. data/lib/evilution/cli/printers/session_diff.rb +8 -8
  22. data/lib/evilution/compare/normalizer.rb +10 -5
  23. data/lib/evilution/config/file_loader.rb +40 -1
  24. data/lib/evilution/config.rb +21 -11
  25. data/lib/evilution/disable_comment.rb +21 -12
  26. data/lib/evilution/equivalent/heuristic/dead_code.rb +8 -1
  27. data/lib/evilution/feedback/setup_warning.rb +79 -0
  28. data/lib/evilution/gem_detector.rb +132 -0
  29. data/lib/evilution/integration/loading/body_call_neutralizer.rb +190 -0
  30. data/lib/evilution/integration/loading/mutation_applier.rb +35 -15
  31. data/lib/evilution/integration/loading/redefinition_recovery.rb +58 -1
  32. data/lib/evilution/integration/minitest.rb +60 -16
  33. data/lib/evilution/integration/rspec/result_builder.rb +20 -1
  34. data/lib/evilution/integration/rspec.rb +20 -1
  35. data/lib/evilution/isolation/fork.rb +104 -27
  36. data/lib/evilution/mcp/info_tool/actions/subjects.rb +32 -23
  37. data/lib/evilution/mcp/info_tool/actions/tests.rb +22 -12
  38. data/lib/evilution/mcp/info_tool/request_parser.rb +3 -1
  39. data/lib/evilution/mcp/info_tool/response_formatter.rb +14 -1
  40. data/lib/evilution/mcp/info_tool.rb +10 -2
  41. data/lib/evilution/mcp/mutate_tool/option_parser.rb +4 -2
  42. data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +58 -1
  43. data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +19 -9
  44. data/lib/evilution/mcp/mutate_tool.rb +49 -17
  45. data/lib/evilution/mcp/session_tool.rb +34 -22
  46. data/lib/evilution/mcp.rb +6 -0
  47. data/lib/evilution/mutation.rb +26 -16
  48. data/lib/evilution/mutator/base.rb +66 -16
  49. data/lib/evilution/mutator/operator/argument_method_call_replacement.rb +59 -0
  50. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +11 -14
  51. data/lib/evilution/mutator/operator/argument_removal.rb +11 -14
  52. data/lib/evilution/mutator/operator/begin_unwrap.rb +17 -5
  53. data/lib/evilution/mutator/operator/bitwise_complement.rb +26 -19
  54. data/lib/evilution/mutator/operator/block_param_removal.rb +50 -8
  55. data/lib/evilution/mutator/operator/block_pass_removal.rb +19 -15
  56. data/lib/evilution/mutator/operator/case_when.rb +7 -5
  57. data/lib/evilution/mutator/operator/conditional_branch.rb +22 -22
  58. data/lib/evilution/mutator/operator/equality_to_identity.rb +8 -3
  59. data/lib/evilution/mutator/operator/explicit_super_mutation.rb +36 -14
  60. data/lib/evilution/mutator/operator/index_to_at.rb +18 -5
  61. data/lib/evilution/mutator/operator/index_to_dig.rb +12 -6
  62. data/lib/evilution/mutator/operator/index_to_fetch.rb +5 -4
  63. data/lib/evilution/mutator/operator/keyword_argument.rb +30 -25
  64. data/lib/evilution/mutator/operator/last_expression_removal.rb +46 -0
  65. data/lib/evilution/mutator/operator/mixin_removal.rb +20 -14
  66. data/lib/evilution/mutator/operator/multiple_assignment.rb +12 -13
  67. data/lib/evilution/mutator/operator/receiver_replacement.rb +38 -7
  68. data/lib/evilution/mutator/operator/regex_simplification.rb +62 -67
  69. data/lib/evilution/mutator/operator/rescue_body_replacement.rb +9 -8
  70. data/lib/evilution/mutator/operator/rescue_removal.rb +58 -12
  71. data/lib/evilution/mutator/operator/splat_operator.rb +28 -1
  72. data/lib/evilution/mutator/operator/string_literal.rb +83 -6
  73. data/lib/evilution/mutator/operator/superclass_removal.rb +21 -15
  74. data/lib/evilution/mutator/registry.rb +2 -0
  75. data/lib/evilution/parallel/work_queue/dispatcher.rb +15 -8
  76. data/lib/evilution/parallel/work_queue/worker.rb +10 -7
  77. data/lib/evilution/parallel/work_queue.rb +35 -18
  78. data/lib/evilution/reporter/cli/item_formatters/coverage_gap.rb +13 -8
  79. data/lib/evilution/reporter/cli/line_formatters/error_rate_warning.rb +29 -0
  80. data/lib/evilution/reporter/cli/line_formatters/mutations.rb +17 -8
  81. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  82. data/lib/evilution/reporter/json.rb +54 -18
  83. data/lib/evilution/reporter/suggestion/diff_helpers.rb +0 -13
  84. data/lib/evilution/reporter/suggestion/diff_lines.rb +28 -0
  85. data/lib/evilution/reporter/suggestion/templates/minitest.rb +20 -14
  86. data/lib/evilution/reporter/suggestion/templates/rspec.rb +19 -13
  87. data/lib/evilution/result/mutation_result.rb +12 -6
  88. data/lib/evilution/runner/baseline_runner.rb +20 -9
  89. data/lib/evilution/runner/diagnostics.rb +13 -9
  90. data/lib/evilution/runner/isolation_resolver.rb +75 -12
  91. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -1
  92. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +32 -10
  93. data/lib/evilution/runner/mutation_executor/strategy/sequential.rb +1 -1
  94. data/lib/evilution/runner/mutation_executor.rb +2 -0
  95. data/lib/evilution/runner/mutation_planner.rb +53 -16
  96. data/lib/evilution/runner/subject_pipeline.rb +21 -11
  97. data/lib/evilution/runner.rb +3 -3
  98. data/lib/evilution/session/diff.rb +15 -6
  99. data/lib/evilution/session/schema.rb +44 -0
  100. data/lib/evilution/session/store.rb +5 -1
  101. data/lib/evilution/spec_ast_cache.rb +26 -12
  102. data/lib/evilution/version.rb +1 -1
  103. data/lib/evilution.rb +2 -0
  104. data/schema/evilution.config.schema.json +205 -0
  105. data/script/build_runtime_snapshot +88 -0
  106. data/script/memory_check +11 -5
  107. data/script/run_self_baseline +79 -0
  108. data/script/run_self_validation +54 -0
  109. data/scripts/benchmark_density +10 -9
  110. data/scripts/compare_mutations +38 -21
  111. data/scripts/mutant_json_adapter +7 -4
  112. 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
- store = Evilution::Session::Store.new
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
- entries, total = collect_entries(subjects, config)
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
- [entries, total]
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
- source, file_path = resolve_util_mutation_source
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
- tmpfile = Tempfile.new(["evilution_eval", ".rb"])
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
- begin
47
- [File.read(path), path]
48
- rescue SystemCallError => e
49
- raise Evilution::Error, e.message
50
- end
51
- else
52
- raise Evilution::Error, "source required: use -e 'code' or provide a file path"
53
- end
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 == "run"
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
- extract_subcommand(ENVIRONMENT_SUBCOMMANDS, "environment", "show")
58
- elsif first == "util"
58
+ elsif SUBCOMMAND_FAMILIES.key?(first)
59
59
  @argv.shift
60
- extract_subcommand(UTIL_SUBCOMMANDS, "util", "mutation")
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
- [files, ranges]
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, tests {list},"
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
- opts.on("--stdin", "Read target file paths from stdin (one per line)") { @options[:stdin] = true }
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
- files, ranges = Evilution::CLI::Parser::FileArgs.parse(lines)
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
@@ -20,7 +20,9 @@ class Evilution::CLI::Parser
20
20
 
21
21
  preprocess_flags
22
22
  remaining = OptionsBuilder.build(@options).parse!(@argv)
23
- @files, @line_ranges = FileArgs.parse(remaining)
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
- arg = @argv[i]
42
- if arg == "--fail-fast"
43
- next_arg = @argv[i + 1]
42
+ i = consume_token(i, result) while i < @argv.length
43
+ @argv = result
44
+ end
44
45
 
45
- if next_arg && next_arg.match?(/\A-?\d+\z/)
46
- @options[:fail_fast] = next_arg
47
- i += 2
48
- else
49
- result << arg
50
- i += 1
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
- @argv = result
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
- " timeout: #{@config.timeout}",
35
- " format: #{@config.format}",
36
- " integration: #{@config.integration}",
37
- " jobs: #{@config.jobs}",
38
- " isolation: #{@config.isolation}",
39
- " baseline: #{@config.baseline}",
40
- " incremental: #{@config.incremental}",
41
- " verbose: #{@config.verbose}",
42
- " quiet: #{@config.quiet}",
43
- " progress: #{@config.progress}",
44
- " fail_fast: #{@config.fail_fast || "(disabled)"}",
45
- " min_score: #{@config.min_score}",
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(format("Base score: %<score>6.2f%% (%<killed>d/%<total>d killed)",
39
- score: summary.base_score * 100, killed: summary.base_killed,
40
- total: summary.base_total))
41
- io.puts(format("Head score: %<score>6.2f%% (%<killed>d/%<total>d killed)",
42
- score: summary.head_score * 100, killed: summary.head_killed,
43
- total: summary.head_total))
44
- io.puts("Delta: #{delta_str}")
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 = entry["file"] or raise Evilution::Compare::InvalidInput.new("missing 'file' in record", index: index)
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) ? data : {}
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
@@ -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
- @integration = Validators::Integration.call(merged[:integration])
244
- @jobs = Validators::Jobs.call(merged[:jobs])
245
- @fail_fast = Validators::FailFast.call(merged[:fail_fast])
246
- @isolation = Validators::Isolation.call(merged[:isolation])
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 do |comment|
24
- loc = comment.location
25
- text = source.byteslice(loc.start_offset, loc.end_offset - loc.start_offset)
26
- .force_encoding(source.encoding)
27
-
28
- if text.match?(DISABLE_MARKER)
29
- line = source.lines[loc.start_line - 1]
30
- standalone = line.strip == text.strip
31
- { type: :disable, line: loc.start_line, standalone: standalone }
32
- elsif text.match?(ENABLE_MARKER)
33
- { type: :enable, line: loc.start_line }
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 == "statement_deletion"
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