evilution 0.23.0 → 0.24.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +5 -0
  3. data/CHANGELOG.md +16 -0
  4. data/README.md +1 -0
  5. data/lib/evilution/cli/parser/command_extractor.rb +77 -0
  6. data/lib/evilution/cli/parser/file_args.rb +41 -0
  7. data/lib/evilution/cli/parser/options_builder.rb +103 -0
  8. data/lib/evilution/cli/parser/stdin_reader.rb +28 -0
  9. data/lib/evilution/cli/parser.rb +27 -196
  10. data/lib/evilution/config.rb +14 -1
  11. data/lib/evilution/integration/base.rb +11 -57
  12. data/lib/evilution/integration/minitest.rb +16 -3
  13. data/lib/evilution/integration/rspec.rb +19 -7
  14. data/lib/evilution/isolation/fork.rb +1 -0
  15. data/lib/evilution/isolation/in_process.rb +1 -0
  16. data/lib/evilution/reporter/cli.rb +2 -1
  17. data/lib/evilution/reporter/html/assets/style.css +68 -0
  18. data/lib/evilution/reporter/html/baseline_keys.rb +28 -0
  19. data/lib/evilution/reporter/html/diff_formatter.rb +27 -0
  20. data/lib/evilution/reporter/html/escape.rb +12 -0
  21. data/lib/evilution/reporter/html/namespace.rb +11 -0
  22. data/lib/evilution/reporter/html/report.rb +68 -0
  23. data/lib/evilution/reporter/html/section.rb +21 -0
  24. data/lib/evilution/reporter/html/sections/baseline_comparison.rb +46 -0
  25. data/lib/evilution/reporter/html/sections/error_details.rb +30 -0
  26. data/lib/evilution/reporter/html/sections/error_entry.rb +22 -0
  27. data/lib/evilution/reporter/html/sections/file_section.rb +47 -0
  28. data/lib/evilution/reporter/html/sections/header.rb +29 -0
  29. data/lib/evilution/reporter/html/sections/mutation_map.rb +32 -0
  30. data/lib/evilution/reporter/html/sections/summary_cards.rb +11 -0
  31. data/lib/evilution/reporter/html/sections/survived_details.rb +35 -0
  32. data/lib/evilution/reporter/html/sections/survived_entry.rb +36 -0
  33. data/lib/evilution/reporter/html/sections/truncation_notice.rb +17 -0
  34. data/lib/evilution/reporter/html/sections.rb +4 -0
  35. data/lib/evilution/reporter/html/stylesheet.rb +14 -0
  36. data/lib/evilution/reporter/html/templates/baseline_comparison.html.erb +8 -0
  37. data/lib/evilution/reporter/html/templates/error_details.html.erb +6 -0
  38. data/lib/evilution/reporter/html/templates/error_entry.html.erb +10 -0
  39. data/lib/evilution/reporter/html/templates/file_section.html.erb +9 -0
  40. data/lib/evilution/reporter/html/templates/header.html.erb +4 -0
  41. data/lib/evilution/reporter/html/templates/mutation_map.html.erb +6 -0
  42. data/lib/evilution/reporter/html/templates/report.html.erb +17 -0
  43. data/lib/evilution/reporter/html/templates/summary_cards.html.erb +23 -0
  44. data/lib/evilution/reporter/html/templates/survived_details.html.erb +21 -0
  45. data/lib/evilution/reporter/html/templates/survived_entry.html.erb +8 -0
  46. data/lib/evilution/reporter/html/templates/truncation_notice.html.erb +1 -0
  47. data/lib/evilution/reporter/html.rb +11 -390
  48. data/lib/evilution/reporter/json.rb +12 -8
  49. data/lib/evilution/result/mutation_result.rb +5 -1
  50. data/lib/evilution/result/summary.rb +9 -1
  51. data/lib/evilution/runner/baseline_runner.rb +71 -0
  52. data/lib/evilution/runner/diagnostics.rb +105 -0
  53. data/lib/evilution/runner/isolation_resolver.rb +134 -0
  54. data/lib/evilution/runner/mutation_executor.rb +255 -0
  55. data/lib/evilution/runner/mutation_planner.rb +126 -0
  56. data/lib/evilution/runner/report_publisher.rb +60 -0
  57. data/lib/evilution/runner/subject_pipeline.rb +121 -0
  58. data/lib/evilution/runner.rb +57 -694
  59. data/lib/evilution/version.rb +1 -1
  60. metadata +42 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7fe91007e7b5113b2e790e726991bcc872abe8fe8e3c321b1e1f5896f9ccec8d
4
- data.tar.gz: 809753233bdd01ec72a9014e31dcc0f821f9e569b915bc58aee4ac69ce0168df
3
+ metadata.gz: '0925ab348be65d181cad6ed2b45f80e6c22c0667e8377f954d37944c20335621'
4
+ data.tar.gz: c14af9fa929612bf7dc8b87b62a8deddbd6d82535b5dff8c0d35405a47e62c6d
5
5
  SHA512:
6
- metadata.gz: 5e1a965330f8d02db82e5bee493682a70a3492f67985e2e0729a66354de51c9e8d3e0b2323617d82dc3209eb7791e5cfc0af2e735c5351081fe48f44d6e26f22
7
- data.tar.gz: 43bbd5cf785fe4370ef351642f697a205cd2af2490dbc15a187aae640a35df0d7a472f2d133ad45ac274fd0c5841401f1a6f8a42b395a97cb92b125b51ec84fb
6
+ metadata.gz: 0dda0d8fef652c798db27eb31e4065d191af1c662a39edb637a4e7883068f1716c859b8f72224c469239e37ec03b24d6151572c3051f812ba06607af6337f888
7
+ data.tar.gz: 8d1fc65e174a493c57c9267ebdf8acc17cd11477669ed766a8601f7812acc442faa5bf91c5efb4ae7e6bb88839db10d6f9f9d070e32922478adb525d71994dbd
@@ -20,3 +20,8 @@
20
20
  {"id":"int-a43dbc64","kind":"field_change","created_at":"2026-04-13T10:26:45.290646646Z","actor":"Denis Kiselev","issue_id":"EV-gs1r","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
21
21
  {"id":"int-ec0a4368","kind":"field_change","created_at":"2026-04-13T11:25:13.089935275Z","actor":"Denis Kiselev","issue_id":"EV-930z","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged in PR #687"}}
22
22
  {"id":"int-a59bbad4","kind":"field_change","created_at":"2026-04-13T16:01:10.349431405Z","actor":"Denis Kiselev","issue_id":"EV-fu7n","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
23
+ {"id":"int-5a773d98","kind":"field_change","created_at":"2026-04-14T07:15:14.387885641Z","actor":"Denis Kiselev","issue_id":"EV-6e58","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
24
+ {"id":"int-bef8f44b","kind":"field_change","created_at":"2026-04-14T09:12:39.415079726Z","actor":"Denis Kiselev","issue_id":"EV-ruc4","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
25
+ {"id":"int-501725bd","kind":"field_change","created_at":"2026-04-14T09:55:34.799184486Z","actor":"Denis Kiselev","issue_id":"EV-2qeo","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
26
+ {"id":"int-7ea18f00","kind":"field_change","created_at":"2026-04-14T14:17:33.257720856Z","actor":"Denis Kiselev","issue_id":"EV-dqrk","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged as PR #709."}}
27
+ {"id":"int-3d40763b","kind":"field_change","created_at":"2026-04-14T14:17:35.373478774Z","actor":"Denis Kiselev","issue_id":"EV-rqy0","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged as PR #711: Runner refactored into 7 SOLID collaborators (825→193 lines)."}}
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.24.0] - 2026-04-14
4
+
5
+ ### Added
6
+
7
+ - **`--fallback-full-suite` CLI flag** — when a mutation has no matching spec/test (spec resolver finds nothing), run the whole test suite instead of marking the mutation `:unresolved` and skipping; opt-in so the default remains fast (#697, PR #707)
8
+
9
+ ### Fixed
10
+
11
+ - **`require_relative` in mutated files broken for sibling files** — the previous temp-dir copy strategy wrote the mutated source to a scratch directory where sibling source files did not exist, so any `require_relative "./sibling"` inside a mutated file failed to resolve; `Evilution::Integration::Base` now evaluates mutated source via `eval` with `__FILE__` set to the original path, so `require_relative` and `__dir__` resolve against the real source tree (#700, PR #708)
12
+
13
+ ### Changed
14
+
15
+ - **Internal `Evilution::Reporter::HTML` refactor** — `lib/evilution/reporter/html.rb` (previously 410 lines) decomposed into section collaborators with one ERB template per section and CSS extracted to `lib/evilution/reporter/html/assets/style.css`; no output changes (#487, PR #712)
16
+ - **Internal `Evilution::Runner` refactor** — extracted `BaselineRunner`, `IsolationResolver`, `MutationPlanner`, `SubjectPipeline`, `Diagnostics`, `MutationExecutor`, and `ReportPublisher` collaborators; no user-visible behavior change (#486, PR #711)
17
+ - **Internal `Evilution::CLI::Parser` refactor** — decomposed into `CommandExtractor`, `FileArgs`, `OptionsBuilder`, and `StdinReader`; no user-visible behavior change (#703, PR #706)
18
+
3
19
  ## [0.23.0] - 2026-04-14
4
20
 
5
21
  ### Changed
data/README.md CHANGED
@@ -69,6 +69,7 @@ evilution [command] [options] [files...]
69
69
  | `--no-preload` | Boolean | _(enabled)_ | Disable parent-process preload. |
70
70
  | `--skip-heredoc-literals` | Boolean | false | Skip all string literal mutations inside heredocs. |
71
71
  | `--show-disabled` | Boolean | false | Report mutations skipped by `# evilution:disable` comments. |
72
+ | `--fallback-full-suite` | Boolean | false | When no matching spec/test resolves for a mutation, run the whole test suite instead of marking it `:unresolved` and skipping. |
72
73
  | `--baseline-session PATH` | String | _(none)_ | Saved session file for HTML report comparison. |
73
74
  | `-e CODE`, `--eval CODE` | String | _(none)_ | Inline Ruby code for `util mutation` command. |
74
75
 
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Evilution::CLI::Parser::CommandExtractor
4
+ SIMPLE_COMMANDS = {
5
+ "version" => :version,
6
+ "init" => :init,
7
+ "mcp" => :mcp,
8
+ "subjects" => :subjects
9
+ }.freeze
10
+
11
+ SESSION_SUBCOMMANDS = {
12
+ "list" => :session_list,
13
+ "show" => :session_show,
14
+ "diff" => :session_diff,
15
+ "gc" => :session_gc
16
+ }.freeze
17
+
18
+ TESTS_SUBCOMMANDS = { "list" => :tests_list }.freeze
19
+ ENVIRONMENT_SUBCOMMANDS = { "show" => :environment_show }.freeze
20
+ UTIL_SUBCOMMANDS = { "mutation" => :util_mutation }.freeze
21
+
22
+ Result = Struct.new(:command, :remaining_argv, :parse_error)
23
+
24
+ def self.call(argv)
25
+ new(argv).call
26
+ end
27
+
28
+ def initialize(argv)
29
+ @argv = argv.dup
30
+ @command = :run
31
+ @parse_error = nil
32
+ end
33
+
34
+ def call
35
+ extract
36
+ Result.new(@command, @argv, @parse_error)
37
+ end
38
+
39
+ private
40
+
41
+ def extract
42
+ first = @argv.first
43
+ if SIMPLE_COMMANDS.key?(first)
44
+ @command = SIMPLE_COMMANDS[first]
45
+ @argv.shift
46
+ elsif first == "run"
47
+ @argv.shift
48
+ elsif first == "session"
49
+ @argv.shift
50
+ extract_subcommand(SESSION_SUBCOMMANDS, "session", "list, show, diff, gc")
51
+ elsif first == "tests"
52
+ @argv.shift
53
+ extract_subcommand(TESTS_SUBCOMMANDS, "tests", "list")
54
+ elsif first == "environment"
55
+ @argv.shift
56
+ extract_subcommand(ENVIRONMENT_SUBCOMMANDS, "environment", "show")
57
+ elsif first == "util"
58
+ @argv.shift
59
+ extract_subcommand(UTIL_SUBCOMMANDS, "util", "mutation")
60
+ end
61
+ end
62
+
63
+ def extract_subcommand(table, family, available)
64
+ sub = @argv.first
65
+ if table.key?(sub)
66
+ @command = table[sub]
67
+ @argv.shift
68
+ elsif sub.nil?
69
+ @command = :parse_error
70
+ @parse_error = "Missing #{family} subcommand. Available subcommands: #{available}"
71
+ else
72
+ @command = :parse_error
73
+ @parse_error = "Unknown #{family} subcommand: #{sub}. Available subcommands: #{available}"
74
+ @argv.shift
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution::CLI::Parser::FileArgs
4
+ module_function
5
+
6
+ def parse(raw_args)
7
+ files = []
8
+ ranges = {}
9
+
10
+ raw_args.each do |arg|
11
+ file, range_str = arg.split(":", 2)
12
+ files << file
13
+ next unless range_str
14
+
15
+ ranges[file] = parse_line_range(range_str)
16
+ end
17
+
18
+ [files, ranges]
19
+ end
20
+
21
+ def expand_spec_dir(dir)
22
+ unless File.directory?(dir)
23
+ warn("Error: #{dir} is not a directory")
24
+ return []
25
+ end
26
+
27
+ Dir.glob(File.join(dir, "**/*_spec.rb"))
28
+ end
29
+
30
+ def parse_line_range(str)
31
+ if str.include?("-")
32
+ start_str, end_str = str.split("-", 2)
33
+ start_line = Integer(start_str)
34
+ end_line = end_str.empty? ? Float::INFINITY : Integer(end_str)
35
+ start_line..end_line
36
+ else
37
+ line = Integer(str)
38
+ line..line
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "../../version"
5
+ require_relative "file_args"
6
+
7
+ class Evilution::CLI::Parser::OptionsBuilder
8
+ def self.build(options)
9
+ new(options).build
10
+ end
11
+
12
+ def initialize(options)
13
+ @options = options
14
+ end
15
+
16
+ def build
17
+ OptionParser.new do |opts|
18
+ opts.banner = "Usage: evilution [command] [options] [files...]"
19
+ opts.version = Evilution::VERSION
20
+ add_separators(opts)
21
+ add_core_options(opts)
22
+ add_filter_options(opts)
23
+ add_flag_options(opts)
24
+ add_extra_flag_options(opts)
25
+ add_session_options(opts)
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def add_separators(opts)
32
+ opts.separator ""
33
+ opts.separator "Line-range targeting: lib/foo.rb:15-30, lib/foo.rb:15, lib/foo.rb:15-"
34
+ opts.separator ""
35
+ opts.separator "Commands: run (default), init, session {list,show,diff,gc}, subjects, tests {list},"
36
+ opts.separator " util {mutation}, environment {show}, mcp, version"
37
+ opts.separator ""
38
+ opts.separator "Options:"
39
+ end
40
+
41
+ def add_core_options(opts)
42
+ opts.on("-j", "--jobs N", Integer, "Number of parallel workers (default: 1)") { |n| @options[:jobs] = n }
43
+ opts.on("-t", "--timeout N", Integer, "Per-mutation timeout in seconds") { |n| @options[:timeout] = n }
44
+ opts.on("-f", "--format FORMAT", "Output format: text, json, html") { |f| @options[:format] = f.to_sym }
45
+ end
46
+
47
+ def add_filter_options(opts)
48
+ opts.on("--min-score FLOAT", Float, "Minimum mutation score to pass") { |s| @options[:min_score] = s }
49
+ opts.on("--spec FILES", Array, "Spec files to run (comma-separated)") { |f| @options[:spec_files] = f }
50
+ opts.on("--spec-dir DIR", "Include all specs in DIR") { |d| expand_spec_dir(d) }
51
+ opts.on("--target EXPR",
52
+ "Filter: method (Foo#bar), type (Foo#/Foo.), namespace (Foo*),",
53
+ "class (Foo), glob (source:**/*.rb), hierarchy (descendants:Foo)") do |m|
54
+ @options[:target] = m
55
+ end
56
+ end
57
+
58
+ def add_flag_options(opts)
59
+ opts.on("--fail-fast", "Stop after N surviving mutants " \
60
+ "(default: disabled; if provided without N, uses 1; use --fail-fast=N)") { @options[:fail_fast] ||= 1 }
61
+ opts.on("--no-baseline", "Skip baseline test suite check") { @options[:baseline] = false }
62
+ opts.on("--incremental", "Cache killed/timeout results; skip re-running them on unchanged files") { @options[:incremental] = true }
63
+ opts.on("--integration NAME", "Test integration: rspec, minitest (default: rspec)") { |i| @options[:integration] = i }
64
+ opts.on("--isolation STRATEGY", "Isolation: auto, fork, in_process (default: auto)") { |s| @options[:isolation] = s }
65
+ opts.on("--preload FILE", "Preload FILE in the parent process before forking " \
66
+ "(default: auto-detect spec/rails_helper.rb for Rails projects)") { |f| @options[:preload] = f }
67
+ opts.on("--no-preload", "Disable parent-process preload even for Rails projects") { @options[:preload] = false }
68
+ opts.on("--stdin", "Read target file paths from stdin (one per line)") { @options[:stdin] = true }
69
+ opts.on("--suggest-tests", "Generate concrete test code in suggestions (RSpec or Minitest)") { @options[:suggest_tests] = true }
70
+ opts.on("--no-progress", "Disable progress bar") { @options[:progress] = false }
71
+ end
72
+
73
+ def add_extra_flag_options(opts)
74
+ opts.on("--skip-heredoc-literals", "Skip all string literal mutations inside heredocs") { @options[:skip_heredoc_literals] = true }
75
+ opts.on("--related-specs-heuristic", "Append related request/integration/feature/system specs for includes() mutations") do
76
+ @options[:related_specs_heuristic] = true
77
+ end
78
+ opts.on("--fallback-full-suite", "Run the whole test suite when no matching spec/test resolves " \
79
+ "for a mutation (default: mark the mutation :unresolved and skip)") do
80
+ @options[:fallback_to_full_suite] = true
81
+ end
82
+ opts.on("--show-disabled", "Report mutations skipped by # evilution:disable") { @options[:show_disabled] = true }
83
+ opts.on("--baseline-session PATH", "Compare against a baseline session in HTML report") { |p| @options[:baseline_session] = p }
84
+ opts.on("--save-session", "Save session results to .evilution/results/") { @options[:save_session] = true }
85
+ opts.on("-e", "--eval CODE", "Evaluate code snippet (for util mutation)") { |c| @options[:eval] = c }
86
+ opts.on("-v", "--verbose", "Verbose output") { @options[:verbose] = true }
87
+ opts.on("-q", "--quiet", "Suppress output") { @options[:quiet] = true }
88
+ end
89
+
90
+ def add_session_options(opts)
91
+ opts.on("--results-dir DIR", "Session results directory") { |d| @options[:results_dir] = d }
92
+ opts.on("--limit N", Integer, "Show only the N most recent sessions") { |n| @options[:limit] = n }
93
+ opts.on("--since DATE", "Show sessions since DATE (YYYY-MM-DD)") { |d| @options[:since] = d }
94
+ opts.on("--older-than DURATION", "Delete sessions older than DURATION (e.g., 30d, 24h, 1w)") do |d|
95
+ @options[:older_than] = d
96
+ end
97
+ end
98
+
99
+ def expand_spec_dir(dir)
100
+ specs = Evilution::CLI::Parser::FileArgs.expand_spec_dir(dir)
101
+ @options[:spec_files] = Array(@options[:spec_files]) + specs
102
+ end
103
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "file_args"
4
+
5
+ class Evilution::CLI::Parser::StdinReader
6
+ Result = Struct.new(:files, :ranges, :error)
7
+
8
+ def self.call(io, existing_files:)
9
+ new(io, existing_files: existing_files).call
10
+ end
11
+
12
+ def initialize(io, existing_files:)
13
+ @io = io
14
+ @existing_files = existing_files
15
+ end
16
+
17
+ def call
18
+ return Result.new([], {}, "--stdin cannot be combined with positional file arguments") if @existing_files.any?
19
+
20
+ lines = []
21
+ @io.each_line do |line|
22
+ line = line.strip
23
+ lines << line unless line.empty?
24
+ end
25
+ files, ranges = Evilution::CLI::Parser::FileArgs.parse(lines)
26
+ Result.new(files, ranges, nil)
27
+ end
28
+ end
@@ -1,28 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "optparse"
4
- require_relative "../version"
5
3
  require_relative "parsed_args"
6
4
 
7
5
  class Evilution::CLI::Parser
8
- SIMPLE_COMMANDS = {
9
- "version" => :version,
10
- "init" => :init,
11
- "mcp" => :mcp,
12
- "subjects" => :subjects
13
- }.freeze
14
-
15
- SESSION_SUBCOMMANDS = {
16
- "list" => :session_list,
17
- "show" => :session_show,
18
- "diff" => :session_diff,
19
- "gc" => :session_gc
20
- }.freeze
21
-
22
- TESTS_SUBCOMMANDS = { "list" => :tests_list }.freeze
23
- ENVIRONMENT_SUBCOMMANDS = { "show" => :environment_show }.freeze
24
- UTIL_SUBCOMMANDS = { "mutation" => :util_mutation }.freeze
25
-
26
6
  def initialize(argv, stdin: $stdin)
27
7
  @argv = argv.dup
28
8
  @stdin = stdin
@@ -39,76 +19,19 @@ class Evilution::CLI::Parser
39
19
  return build_parsed_args if @command == :parse_error
40
20
 
41
21
  preprocess_flags
42
- remaining = build_option_parser.parse!(@argv)
43
- @files, @line_ranges = parse_file_args(remaining)
22
+ remaining = OptionsBuilder.build(@options).parse!(@argv)
23
+ @files, @line_ranges = FileArgs.parse(remaining)
44
24
  read_stdin_files if @options.delete(:stdin) && %i[run subjects].include?(@command)
45
25
  build_parsed_args
46
26
  end
47
27
 
48
28
  private
49
29
 
50
- def read_stdin_files
51
- if @files.any?
52
- @stdin_error = "--stdin cannot be combined with positional file arguments"
53
- return
54
- end
55
-
56
- lines = []
57
- @stdin.each_line do |line|
58
- line = line.strip
59
- lines << line unless line.empty?
60
- end
61
- stdin_files, stdin_ranges = parse_file_args(lines)
62
- @files = stdin_files
63
- @line_ranges = @line_ranges.merge(stdin_ranges)
64
- end
65
-
66
- def build_parsed_args
67
- Evilution::CLI::ParsedArgs.new(
68
- command: @command,
69
- options: @options,
70
- files: @files,
71
- line_ranges: @line_ranges,
72
- stdin_error: @stdin_error,
73
- parse_error: @parse_error
74
- )
75
- end
76
-
77
30
  def extract_command
78
- first = @argv.first
79
- if SIMPLE_COMMANDS.key?(first)
80
- @command = SIMPLE_COMMANDS[first]
81
- @argv.shift
82
- elsif first == "run"
83
- @argv.shift
84
- elsif first == "session"
85
- @argv.shift
86
- extract_subcommand(SESSION_SUBCOMMANDS, "session", "list, show, diff, gc")
87
- elsif first == "tests"
88
- @argv.shift
89
- extract_subcommand(TESTS_SUBCOMMANDS, "tests", "list")
90
- elsif first == "environment"
91
- @argv.shift
92
- extract_subcommand(ENVIRONMENT_SUBCOMMANDS, "environment", "show")
93
- elsif first == "util"
94
- @argv.shift
95
- extract_subcommand(UTIL_SUBCOMMANDS, "util", "mutation")
96
- end
97
- end
98
-
99
- def extract_subcommand(table, family, available)
100
- sub = @argv.first
101
- if table.key?(sub)
102
- @command = table[sub]
103
- @argv.shift
104
- elsif sub.nil?
105
- @command = :parse_error
106
- @parse_error = "Missing #{family} subcommand. Available subcommands: #{available}"
107
- else
108
- @command = :parse_error
109
- @parse_error = "Unknown #{family} subcommand: #{sub}. Available subcommands: #{available}"
110
- @argv.shift
111
- end
31
+ result = CommandExtractor.call(@argv)
32
+ @command = result.command
33
+ @argv = result.remaining_argv
34
+ @parse_error = result.parse_error
112
35
  end
113
36
 
114
37
  def preprocess_flags
@@ -137,121 +60,29 @@ class Evilution::CLI::Parser
137
60
  @argv = result
138
61
  end
139
62
 
140
- def build_option_parser
141
- OptionParser.new do |opts|
142
- opts.banner = "Usage: evilution [command] [options] [files...]"
143
- opts.version = Evilution::VERSION
144
- add_separators(opts)
145
- add_options(opts)
146
- end
147
- end
148
-
149
- def add_separators(opts)
150
- opts.separator ""
151
- opts.separator "Line-range targeting: lib/foo.rb:15-30, lib/foo.rb:15, lib/foo.rb:15-"
152
- opts.separator ""
153
- opts.separator "Commands: run (default), init, session {list,show,diff,gc}, subjects, tests {list},"
154
- opts.separator " util {mutation}, environment {show}, mcp, version"
155
- opts.separator ""
156
- opts.separator "Options:"
157
- end
158
-
159
- def add_options(opts)
160
- add_core_options(opts)
161
- add_filter_options(opts)
162
- add_flag_options(opts)
163
- add_session_options(opts)
164
- end
165
-
166
- def add_core_options(opts)
167
- opts.on("-j", "--jobs N", Integer, "Number of parallel workers (default: 1)") { |n| @options[:jobs] = n }
168
- opts.on("-t", "--timeout N", Integer, "Per-mutation timeout in seconds") { |n| @options[:timeout] = n }
169
- opts.on("-f", "--format FORMAT", "Output format: text, json, html") { |f| @options[:format] = f.to_sym }
170
- end
171
-
172
- def add_filter_options(opts)
173
- opts.on("--min-score FLOAT", Float, "Minimum mutation score to pass") { |s| @options[:min_score] = s }
174
- opts.on("--spec FILES", Array, "Spec files to run (comma-separated)") { |f| @options[:spec_files] = f }
175
- opts.on("--spec-dir DIR", "Include all specs in DIR") { |d| expand_spec_dir(d) }
176
- opts.on("--target EXPR",
177
- "Filter: method (Foo#bar), type (Foo#/Foo.), namespace (Foo*),",
178
- "class (Foo), glob (source:**/*.rb), hierarchy (descendants:Foo)") do |m|
179
- @options[:target] = m
180
- end
181
- end
182
-
183
- def add_flag_options(opts)
184
- opts.on("--fail-fast", "Stop after N surviving mutants " \
185
- "(default: disabled; if provided without N, uses 1; use --fail-fast=N)") { @options[:fail_fast] ||= 1 }
186
- opts.on("--no-baseline", "Skip baseline test suite check") { @options[:baseline] = false }
187
- opts.on("--incremental", "Cache killed/timeout results; skip re-running them on unchanged files") { @options[:incremental] = true }
188
- opts.on("--integration NAME", "Test integration: rspec, minitest (default: rspec)") { |i| @options[:integration] = i }
189
- opts.on("--isolation STRATEGY", "Isolation: auto, fork, in_process (default: auto)") { |s| @options[:isolation] = s }
190
- opts.on("--preload FILE", "Preload FILE in the parent process before forking " \
191
- "(default: auto-detect spec/rails_helper.rb for Rails projects)") { |f| @options[:preload] = f }
192
- opts.on("--no-preload", "Disable parent-process preload even for Rails projects") { @options[:preload] = false }
193
- opts.on("--stdin", "Read target file paths from stdin (one per line)") { @options[:stdin] = true }
194
- opts.on("--suggest-tests", "Generate concrete test code in suggestions (RSpec or Minitest)") { @options[:suggest_tests] = true }
195
- opts.on("--no-progress", "Disable progress bar") { @options[:progress] = false }
196
- add_extra_flag_options(opts)
197
- end
198
-
199
- def add_extra_flag_options(opts)
200
- opts.on("--skip-heredoc-literals", "Skip all string literal mutations inside heredocs") { @options[:skip_heredoc_literals] = true }
201
- opts.on("--related-specs-heuristic", "Append related request/integration/feature/system specs for includes() mutations") do
202
- @options[:related_specs_heuristic] = true
203
- end
204
- opts.on("--show-disabled", "Report mutations skipped by # evilution:disable") { @options[:show_disabled] = true }
205
- opts.on("--baseline-session PATH", "Compare against a baseline session in HTML report") { |p| @options[:baseline_session] = p }
206
- opts.on("--save-session", "Save session results to .evilution/results/") { @options[:save_session] = true }
207
- opts.on("-e", "--eval CODE", "Evaluate code snippet (for util mutation)") { |c| @options[:eval] = c }
208
- opts.on("-v", "--verbose", "Verbose output") { @options[:verbose] = true }
209
- opts.on("-q", "--quiet", "Suppress output") { @options[:quiet] = true }
210
- end
211
-
212
- def add_session_options(opts)
213
- opts.on("--results-dir DIR", "Session results directory") { |d| @options[:results_dir] = d }
214
- opts.on("--limit N", Integer, "Show only the N most recent sessions") { |n| @options[:limit] = n }
215
- opts.on("--since DATE", "Show sessions since DATE (YYYY-MM-DD)") { |d| @options[:since] = d }
216
- opts.on("--older-than DURATION", "Delete sessions older than DURATION (e.g., 30d, 24h, 1w)") do |d|
217
- @options[:older_than] = d
218
- end
219
- end
220
-
221
- def parse_file_args(raw_args)
222
- files = []
223
- ranges = {}
224
-
225
- raw_args.each do |arg|
226
- file, range_str = arg.split(":", 2)
227
- files << file
228
- next unless range_str
229
-
230
- ranges[file] = parse_line_range(range_str)
231
- end
232
-
233
- [files, ranges]
234
- end
235
-
236
- def parse_line_range(str)
237
- if str.include?("-")
238
- start_str, end_str = str.split("-", 2)
239
- start_line = Integer(start_str)
240
- end_line = end_str.empty? ? Float::INFINITY : Integer(end_str)
241
- start_line..end_line
242
- else
243
- line = Integer(str)
244
- line..line
245
- end
246
- end
247
-
248
- def expand_spec_dir(dir)
249
- unless File.directory?(dir)
250
- warn("Error: #{dir} is not a directory")
63
+ def read_stdin_files
64
+ result = StdinReader.call(@stdin, existing_files: @files)
65
+ if result.error
66
+ @stdin_error = result.error
251
67
  return
252
68
  end
69
+ @files = result.files
70
+ @line_ranges = @line_ranges.merge(result.ranges)
71
+ end
253
72
 
254
- specs = Dir.glob(File.join(dir, "**/*_spec.rb"))
255
- @options[:spec_files] = Array(@options[:spec_files]) + specs
73
+ def build_parsed_args
74
+ Evilution::CLI::ParsedArgs.new(
75
+ command: @command,
76
+ options: @options,
77
+ files: @files,
78
+ line_ranges: @line_ranges,
79
+ stdin_error: @stdin_error,
80
+ parse_error: @parse_error
81
+ )
256
82
  end
257
83
  end
84
+
85
+ require_relative "parser/command_extractor"
86
+ require_relative "parser/file_args"
87
+ require_relative "parser/stdin_reader"
88
+ require_relative "parser/options_builder"
@@ -28,6 +28,7 @@ class Evilution::Config
28
28
  baseline_session: nil,
29
29
  skip_heredoc_literals: false,
30
30
  related_specs_heuristic: false,
31
+ fallback_to_full_suite: false,
31
32
  preload: nil
32
33
  }.freeze
33
34
 
@@ -36,7 +37,8 @@ class Evilution::Config
36
37
  :jobs, :fail_fast, :baseline, :isolation, :incremental, :suggest_tests,
37
38
  :progress, :save_session, :line_ranges, :spec_files, :hooks,
38
39
  :ignore_patterns, :show_disabled, :baseline_session,
39
- :skip_heredoc_literals, :related_specs_heuristic, :preload
40
+ :skip_heredoc_literals, :related_specs_heuristic,
41
+ :fallback_to_full_suite, :preload
40
42
 
41
43
  def initialize(**options)
42
44
  file_options = options.delete(:skip_config_file) ? {} : load_config_file
@@ -101,6 +103,10 @@ class Evilution::Config
101
103
  related_specs_heuristic
102
104
  end
103
105
 
106
+ def fallback_to_full_suite?
107
+ fallback_to_full_suite
108
+ end
109
+
104
110
  def self.file_options
105
111
  CONFIG_FILES.each do |path|
106
112
  next unless File.exist?(path)
@@ -155,6 +161,12 @@ class Evilution::Config
155
161
  # of N+1 regressions that only surface in higher-level specs.
156
162
  # related_specs_heuristic: true
157
163
 
164
+ # When no matching spec resolves for a mutation's source file, the
165
+ # default is to skip that mutation and mark it :unresolved in the
166
+ # report (a coverage gap signal). Set to true to fall back to running
167
+ # the entire test suite for such mutations instead (slow, high memory).
168
+ # fallback_to_full_suite: false
169
+
158
170
  # Preload file required in the parent process before forking workers.
159
171
  # For Rails projects, spec/rails_helper.rb or test/test_helper.rb is
160
172
  # auto-detected when isolation resolves to :fork. Set to false to disable.
@@ -210,6 +222,7 @@ class Evilution::Config
210
222
  @baseline_session = merged[:baseline_session]
211
223
  @skip_heredoc_literals = merged[:skip_heredoc_literals]
212
224
  @related_specs_heuristic = merged[:related_specs_heuristic]
225
+ @fallback_to_full_suite = merged[:fallback_to_full_suite]
213
226
  @hooks = validate_hooks(merged[:hooks])
214
227
  @preload = validate_preload(merged[:preload])
215
228
  end