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.
- checksums.yaml +4 -4
- data/.beads/interactions.jsonl +5 -0
- data/CHANGELOG.md +16 -0
- data/README.md +1 -0
- data/lib/evilution/cli/parser/command_extractor.rb +77 -0
- data/lib/evilution/cli/parser/file_args.rb +41 -0
- data/lib/evilution/cli/parser/options_builder.rb +103 -0
- data/lib/evilution/cli/parser/stdin_reader.rb +28 -0
- data/lib/evilution/cli/parser.rb +27 -196
- data/lib/evilution/config.rb +14 -1
- data/lib/evilution/integration/base.rb +11 -57
- data/lib/evilution/integration/minitest.rb +16 -3
- data/lib/evilution/integration/rspec.rb +19 -7
- data/lib/evilution/isolation/fork.rb +1 -0
- data/lib/evilution/isolation/in_process.rb +1 -0
- data/lib/evilution/reporter/cli.rb +2 -1
- data/lib/evilution/reporter/html/assets/style.css +68 -0
- data/lib/evilution/reporter/html/baseline_keys.rb +28 -0
- data/lib/evilution/reporter/html/diff_formatter.rb +27 -0
- data/lib/evilution/reporter/html/escape.rb +12 -0
- data/lib/evilution/reporter/html/namespace.rb +11 -0
- data/lib/evilution/reporter/html/report.rb +68 -0
- data/lib/evilution/reporter/html/section.rb +21 -0
- data/lib/evilution/reporter/html/sections/baseline_comparison.rb +46 -0
- data/lib/evilution/reporter/html/sections/error_details.rb +30 -0
- data/lib/evilution/reporter/html/sections/error_entry.rb +22 -0
- data/lib/evilution/reporter/html/sections/file_section.rb +47 -0
- data/lib/evilution/reporter/html/sections/header.rb +29 -0
- data/lib/evilution/reporter/html/sections/mutation_map.rb +32 -0
- data/lib/evilution/reporter/html/sections/summary_cards.rb +11 -0
- data/lib/evilution/reporter/html/sections/survived_details.rb +35 -0
- data/lib/evilution/reporter/html/sections/survived_entry.rb +36 -0
- data/lib/evilution/reporter/html/sections/truncation_notice.rb +17 -0
- data/lib/evilution/reporter/html/sections.rb +4 -0
- data/lib/evilution/reporter/html/stylesheet.rb +14 -0
- data/lib/evilution/reporter/html/templates/baseline_comparison.html.erb +8 -0
- data/lib/evilution/reporter/html/templates/error_details.html.erb +6 -0
- data/lib/evilution/reporter/html/templates/error_entry.html.erb +10 -0
- data/lib/evilution/reporter/html/templates/file_section.html.erb +9 -0
- data/lib/evilution/reporter/html/templates/header.html.erb +4 -0
- data/lib/evilution/reporter/html/templates/mutation_map.html.erb +6 -0
- data/lib/evilution/reporter/html/templates/report.html.erb +17 -0
- data/lib/evilution/reporter/html/templates/summary_cards.html.erb +23 -0
- data/lib/evilution/reporter/html/templates/survived_details.html.erb +21 -0
- data/lib/evilution/reporter/html/templates/survived_entry.html.erb +8 -0
- data/lib/evilution/reporter/html/templates/truncation_notice.html.erb +1 -0
- data/lib/evilution/reporter/html.rb +11 -390
- data/lib/evilution/reporter/json.rb +12 -8
- data/lib/evilution/result/mutation_result.rb +5 -1
- data/lib/evilution/result/summary.rb +9 -1
- data/lib/evilution/runner/baseline_runner.rb +71 -0
- data/lib/evilution/runner/diagnostics.rb +105 -0
- data/lib/evilution/runner/isolation_resolver.rb +134 -0
- data/lib/evilution/runner/mutation_executor.rb +255 -0
- data/lib/evilution/runner/mutation_planner.rb +126 -0
- data/lib/evilution/runner/report_publisher.rb +60 -0
- data/lib/evilution/runner/subject_pipeline.rb +121 -0
- data/lib/evilution/runner.rb +57 -694
- data/lib/evilution/version.rb +1 -1
- metadata +42 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '0925ab348be65d181cad6ed2b45f80e6c22c0667e8377f954d37944c20335621'
|
|
4
|
+
data.tar.gz: c14af9fa929612bf7dc8b87b62a8deddbd6d82535b5dff8c0d35405a47e62c6d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0dda0d8fef652c798db27eb31e4065d191af1c662a39edb637a4e7883068f1716c859b8f72224c469239e37ec03b24d6151572c3051f812ba06607af6337f888
|
|
7
|
+
data.tar.gz: 8d1fc65e174a493c57c9267ebdf8acc17cd11477669ed766a8601f7812acc442faa5bf91c5efb4ae7e6bb88839db10d6f9f9d070e32922478adb525d71994dbd
|
data/.beads/interactions.jsonl
CHANGED
|
@@ -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
|
data/lib/evilution/cli/parser.rb
CHANGED
|
@@ -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 =
|
|
43
|
-
@files, @line_ranges =
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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"
|
data/lib/evilution/config.rb
CHANGED
|
@@ -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,
|
|
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
|