evilution 0.22.7 → 0.23.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +3 -0
  3. data/CHANGELOG.md +12 -0
  4. data/README.md +36 -7
  5. data/lib/evilution/cli/command.rb +37 -0
  6. data/lib/evilution/cli/commands/environment_show.rb +20 -0
  7. data/lib/evilution/cli/commands/init.rb +24 -0
  8. data/lib/evilution/cli/commands/mcp.rb +19 -0
  9. data/lib/evilution/cli/commands/run.rb +68 -0
  10. data/lib/evilution/cli/commands/session_diff.rb +30 -0
  11. data/lib/evilution/cli/commands/session_gc.rb +46 -0
  12. data/lib/evilution/cli/commands/session_list.rb +51 -0
  13. data/lib/evilution/cli/commands/session_show.rb +27 -0
  14. data/lib/evilution/cli/commands/subjects.rb +50 -0
  15. data/lib/evilution/cli/commands/tests_list.rb +43 -0
  16. data/lib/evilution/cli/commands/util_mutation.rb +66 -0
  17. data/lib/evilution/cli/commands/version.rb +17 -0
  18. data/lib/evilution/cli/commands.rb +4 -0
  19. data/lib/evilution/cli/dispatcher.rb +23 -0
  20. data/lib/evilution/cli/parsed_args.rb +12 -0
  21. data/lib/evilution/cli/parser.rb +257 -0
  22. data/lib/evilution/cli/printers/environment.rb +53 -0
  23. data/lib/evilution/cli/printers/session_detail.rb +76 -0
  24. data/lib/evilution/cli/printers/session_diff.rb +57 -0
  25. data/lib/evilution/cli/printers/session_list.rb +48 -0
  26. data/lib/evilution/cli/printers/subjects.rb +35 -0
  27. data/lib/evilution/cli/printers/tests_list.rb +45 -0
  28. data/lib/evilution/cli/printers/util_mutation.rb +35 -0
  29. data/lib/evilution/cli/printers.rb +4 -0
  30. data/lib/evilution/cli/result.rb +9 -0
  31. data/lib/evilution/cli.rb +30 -850
  32. data/lib/evilution/config.rb +18 -3
  33. data/lib/evilution/integration/base.rb +14 -0
  34. data/lib/evilution/integration/minitest.rb +6 -1
  35. data/lib/evilution/integration/rspec.rb +10 -2
  36. data/lib/evilution/isolation/fork.rb +10 -9
  37. data/lib/evilution/isolation/in_process.rb +10 -9
  38. data/lib/evilution/mcp/info_tool.rb +261 -0
  39. data/lib/evilution/mcp/mutate_tool.rb +112 -19
  40. data/lib/evilution/mcp/server.rb +3 -4
  41. data/lib/evilution/mcp/session_diff_tool.rb +5 -1
  42. data/lib/evilution/mcp/session_list_tool.rb +5 -1
  43. data/lib/evilution/mcp/session_show_tool.rb +5 -1
  44. data/lib/evilution/mcp/session_tool.rb +157 -0
  45. data/lib/evilution/reporter/html.rb +41 -0
  46. data/lib/evilution/runner.rb +3 -1
  47. data/lib/evilution/version.rb +1 -1
  48. metadata +30 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 953b824799c03617e8e41a67f759c5edbd122b63046f8916e19d215021de5871
4
- data.tar.gz: 38e3498c7cc763e44a5cded169c035b1a48d02c14c95b9ff4ae0800dd63389cd
3
+ metadata.gz: 7fe91007e7b5113b2e790e726991bcc872abe8fe8e3c321b1e1f5896f9ccec8d
4
+ data.tar.gz: 809753233bdd01ec72a9014e31dcc0f821f9e569b915bc58aee4ac69ce0168df
5
5
  SHA512:
6
- metadata.gz: cceb0046285ad1efdd63914af840da66354006d97f1f9da0642dcf649e3628a342c6ed5f3c6776f3b76dee7eb41af833033a4c9c18992eaa6773bf1c5da8ca75
7
- data.tar.gz: 6928979ecd999f213b87c3460179f4a09008894866e31d5e822f214ab521235e8463a389768e40bff065512fc1e55355d0ca249e739cb571210ccd429d94db82
6
+ metadata.gz: 5e1a965330f8d02db82e5bee493682a70a3492f67985e2e0729a66354de51c9e8d3e0b2323617d82dc3209eb7791e5cfc0af2e735c5351081fe48f44d6e26f22
7
+ data.tar.gz: 43bbd5cf785fe4370ef351642f697a205cd2af2490dbc15a187aae640a35df0d7a472f2d133ad45ac274fd0c5841401f1a6f8a42b395a97cb92b125b51ec84fb
@@ -17,3 +17,6 @@
17
17
  {"id":"int-d3431bcd","kind":"field_change","created_at":"2026-04-12T02:57:43.902279367Z","actor":"Denis Kiselev","issue_id":"EV-86l6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
18
18
  {"id":"int-f409d79d","kind":"field_change","created_at":"2026-04-12T02:57:44.180309214Z","actor":"Denis Kiselev","issue_id":"EV-o28o","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
19
19
  {"id":"int-ba5d5d3e","kind":"field_change","created_at":"2026-04-12T03:42:58.408103757Z","actor":"Denis Kiselev","issue_id":"EV-1fq8","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}}
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
+ {"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
+ {"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"}}
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.23.0] - 2026-04-14
4
+
5
+ ### Changed
6
+
7
+ - **Internal CLI refactoring** — `lib/evilution/cli.rb` was decomposed into smaller, focused units for readability and maintainability; no user-visible behavior change (#485, PR #704)
8
+
9
+ ### Fixed
10
+
11
+ - **`session show` / `session diff` / `util mutation` diff output double-spaced between lines** — the text printers piped each diff line through `io.puts` even though `String#each_line` already yields a trailing newline, so every diff line was separated by a blank line; now `line.chomp` strips the trailing newline before `puts` re-adds it, rendering diffs with correct spacing (PR #704)
12
+ - **`subjects` summary printed "1 subjects, 1 mutations"** — the summary line always pluralized, producing grammatically incorrect output for single-element results; now pluralizes both nouns independently via a `pluralize` helper so single counts read "1 subject, 1 mutation" (PR #704)
13
+ - **`session show` crashed on permission errors and other `SystemCallError`s** — `Session::Store#load` can raise `Errno::EACCES` or other `SystemCallError`s (e.g. permission denied on `File.read`) which were not rescued, causing a hard crash instead of a clean exit 2; now wraps `SystemCallError` as `Evilution::Error` in `Commands::SessionShow` for parity with `Commands::SessionDiff` (PR #704)
14
+
3
15
  ## [0.22.7] - 2026-04-13
4
16
 
5
17
  ### Fixed
data/README.md CHANGED
@@ -95,7 +95,7 @@ Creates `.evilution.yml`:
95
95
  # save_session: false # persist results under .evilution/results/
96
96
  # isolation: auto # auto | fork | in_process (auto selects fork for Rails)
97
97
  # preload: null # path to preload before forking; false to disable; auto-detects for Rails
98
- # skip_heredoc_literals: false # skip all string literal mutations inside heredocs
98
+ # skip_heredoc_literals: false # skip string literal mutations inside heredocs (recommended for Rails: heredoc SQL/templates rarely have test coverage)
99
99
  # show_disabled: false # report mutations skipped by disable comments
100
100
  # baseline_session: null # path to session file for HTML comparison
101
101
  # ignore_patterns: [] # AST patterns to exclude (see docs/ast_pattern_syntax.md)
@@ -288,13 +288,12 @@ The server exposes the following tools:
288
288
  | Tool | Description |
289
289
  |---|---|
290
290
  | `evilution-mutate` | Run mutation testing on target files with structured JSON results |
291
- | `evilution-session-list` | Browse saved session history |
292
- | `evilution-session-show` | Display detailed session results |
293
- | `evilution-session-diff` | Compare two sessions (fixed/new/persistent survivors, score delta) |
291
+ | `evilution-session` | Inspect mutation testing history — `action: list` browses saved sessions, `action: show` displays one, `action: diff` compares two (fixed/new/persistent survivors, score delta) |
292
+ | `evilution-info` | Discovery before mutation `action: subjects` lists mutatable methods with mutation counts, `action: tests` resolves which specs cover given sources, `action: environment` dumps the effective config |
294
293
 
295
294
  ### Verbosity Control
296
295
 
297
- The MCP tool accepts a `verbosity` parameter to control response size:
296
+ The `evilution-mutate` tool accepts a `verbosity` parameter to control response size:
298
297
 
299
298
  | Level | Default | What's included |
300
299
  |-------------|---------|--------------------------------------------------------------|
@@ -304,11 +303,41 @@ The MCP tool accepts a `verbosity` parameter to control response size:
304
303
 
305
304
  Use `minimal` when context window budget is tight and you only need to see what survived. Use `full` when you need to inspect killed/neutral/equivalent entries for debugging.
306
305
 
306
+ ### Enriched Survived Entries
307
+
308
+ Unlike `evilution --format json`, every survived entry returned by `evilution-mutate` carries extra fields so the agent can act without a second round-trip:
309
+
310
+ | Field | What it gives you |
311
+ |---|---|
312
+ | `subject` | `Class#method` for the mutated subject — points at the exact method to test |
313
+ | `spec_file` | Resolved spec/test path (when one exists) — e.g. an RSpec spec file or Minitest test file, so you can drop new tests straight into it |
314
+ | `next_step` | Concrete natural-language hint — "add a test in X that fails against this mutation at Y:line" |
315
+
316
+ These fields are added in addition to the existing `operator`, `file`, `line`, `diff`, `suggestion`, and `test_command` so agents can triage survivors in one pass.
317
+
307
318
  ### Concrete Test Suggestions
308
319
 
309
- The MCP tool accepts a `suggest_tests` boolean parameter (default: `false`). When enabled, survived mutation suggestions contain concrete test code that an agent can drop into a test file, instead of static description text. The MCP tool currently generates RSpec-style suggestions (`it`/`expect` blocks).
320
+ The `evilution-mutate` tool accepts a `suggest_tests` boolean parameter (default: `false`). When enabled, survived mutation suggestions contain concrete test code that an agent can drop into a test file, instead of static description text. It currently generates RSpec-style suggestions (`it`/`expect` blocks).
321
+
322
+ Pass `suggest_tests: true` in the `evilution-mutate` call to activate this mode. The CLI also supports `--suggest-tests`; when using the CLI, generated suggestions match the `--integration` setting (RSpec `it`/`expect` blocks or Minitest `def test_`/`assert_equal` methods).
310
323
 
311
- Pass `suggest_tests: true` in the MCP tool call to activate this mode. The CLI also supports `--suggest-tests`; when using the CLI, generated suggestions match the `--integration` setting (RSpec `it`/`expect` blocks or Minitest `def test_`/`assert_equal` methods).
324
+ ### Project Config File
325
+
326
+ `evilution-mutate` and `evilution-info` load `.evilution.yml` (or `config/evilution.yml`) by default, matching `evilution` CLI behavior — so timeout, jobs, integration, target, ignore_patterns, and other project settings carry over without the agent having to re-pass them on every call. Explicit tool parameters still win over file settings.
327
+
328
+ Pass `skip_config: true` to ignore the project config file. This skips loading `.evilution.yml` / `config/evilution.yml`, but MCP-specific overrides (JSON output, quiet mode, preload disabled) and explicit tool parameters still apply.
329
+
330
+ ### Iterative Workflow Parameters
331
+
332
+ `evilution-mutate` exposes the full set of CLI knobs agents need for iterative TDD:
333
+
334
+ | Parameter | Purpose |
335
+ |---|---|
336
+ | `incremental` | Cache killed/timeout results across runs — set `true` when iterating on the same files |
337
+ | `integration` | `rspec` or `minitest` |
338
+ | `isolation` | `auto`, `fork`, or `in_process` |
339
+ | `baseline` | `false` to skip the baseline suite check when you already know it's green |
340
+ | `save_session` | Persist results to `.evilution/results/` for inspection via `evilution-session` |
312
341
 
313
342
  > **Note**: `.mcp.json` is gitignored by default since it is a local editor/agent configuration file.
314
343
 
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "result"
4
+
5
+ class Evilution::CLI::Command
6
+ def initialize(parsed_args, stdout: $stdout, stderr: $stderr)
7
+ @options = parsed_args.options
8
+ @files = parsed_args.files
9
+ @line_ranges = parsed_args.line_ranges
10
+ @stdin_error = parsed_args.stdin_error
11
+ @stdout = stdout
12
+ @stderr = stderr
13
+ end
14
+
15
+ def call
16
+ Evilution::CLI::Result.new(exit_code: perform)
17
+ rescue Evilution::Error => e
18
+ Evilution::CLI::Result.new(exit_code: 2, error: e)
19
+ end
20
+
21
+ private
22
+
23
+ def perform
24
+ raise NotImplementedError
25
+ end
26
+
27
+ def build_operator_options(config)
28
+ { skip_heredoc_literals: config.skip_heredoc_literals? }
29
+ end
30
+
31
+ def build_subject_filter(config)
32
+ return nil if config.ignore_patterns.empty?
33
+
34
+ require_relative "../ast/pattern/filter"
35
+ Evilution::AST::Pattern::Filter.new(config.ignore_patterns)
36
+ end
37
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../commands"
4
+ require_relative "../command"
5
+ require_relative "../dispatcher"
6
+ require_relative "../printers/environment"
7
+ require_relative "../../config"
8
+
9
+ class Evilution::CLI::Commands::EnvironmentShow < Evilution::CLI::Command
10
+ private
11
+
12
+ def perform
13
+ config = Evilution::Config.new(**@options)
14
+ config_file = Evilution::Config::CONFIG_FILES.find { |path| File.exist?(path) }
15
+ Evilution::CLI::Printers::Environment.new(config, config_file: config_file).render(@stdout)
16
+ 0
17
+ end
18
+ end
19
+
20
+ Evilution::CLI::Dispatcher.register(:environment_show, Evilution::CLI::Commands::EnvironmentShow)
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../commands"
4
+ require_relative "../command"
5
+ require_relative "../dispatcher"
6
+ require_relative "../../config"
7
+
8
+ class Evilution::CLI::Commands::Init < Evilution::CLI::Command
9
+ private
10
+
11
+ def perform
12
+ path = ".evilution.yml"
13
+ if File.exist?(path)
14
+ @stderr.puts("#{path} already exists")
15
+ return 1
16
+ end
17
+
18
+ File.write(path, Evilution::Config.default_template)
19
+ @stdout.puts("Created #{path}")
20
+ 0
21
+ end
22
+ end
23
+
24
+ Evilution::CLI::Dispatcher.register(:init, Evilution::CLI::Commands::Init)
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../commands"
4
+ require_relative "../command"
5
+ require_relative "../dispatcher"
6
+
7
+ class Evilution::CLI::Commands::Mcp < Evilution::CLI::Command
8
+ private
9
+
10
+ def perform
11
+ require_relative "../../mcp/server"
12
+ server = Evilution::MCP::Server.build
13
+ transport = ::MCP::Server::Transports::StdioTransport.new(server)
14
+ transport.open
15
+ 0
16
+ end
17
+ end
18
+
19
+ Evilution::CLI::Dispatcher.register(:mcp, Evilution::CLI::Commands::Mcp)
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../commands"
5
+ require_relative "../command"
6
+ require_relative "../result"
7
+ require_relative "../dispatcher"
8
+ require_relative "../../config"
9
+ require_relative "../../runner"
10
+ require_relative "../../hooks"
11
+ require_relative "../../hooks/registry"
12
+ require_relative "../../hooks/loader"
13
+
14
+ class Evilution::CLI::Commands::Run < Evilution::CLI::Command
15
+ def call
16
+ file_options = Evilution::Config.file_options
17
+ config = nil
18
+ raise Evilution::ConfigError, @stdin_error if @stdin_error
19
+
20
+ config = Evilution::Config.new(**@options, target_files: @files, line_ranges: @line_ranges)
21
+ hooks = build_hooks(config)
22
+ runner = Evilution::Runner.new(config: config, hooks: hooks)
23
+ summary = runner.call
24
+ exit_code = summary.success?(min_score: config.min_score) ? 0 : 1
25
+ Evilution::CLI::Result.new(exit_code: exit_code)
26
+ rescue Evilution::Error => e
27
+ handle_error(e, config, file_options)
28
+ end
29
+
30
+ private
31
+
32
+ def handle_error(error, config, file_options)
33
+ if json_format?(config, file_options)
34
+ @stdout.puts(JSON.generate(error_payload(error)))
35
+ Evilution::CLI::Result.new(exit_code: 2, error: error, error_rendered: true)
36
+ else
37
+ Evilution::CLI::Result.new(exit_code: 2, error: error)
38
+ end
39
+ end
40
+
41
+ def build_hooks(config)
42
+ return nil if config.hooks.empty?
43
+
44
+ registry = Evilution::Hooks::Registry.new
45
+ Evilution::Hooks::Loader.call(registry, config.hooks)
46
+ registry
47
+ end
48
+
49
+ def json_format?(config, file_options)
50
+ return config.json? if config
51
+
52
+ fmt = @options[:format] || (file_options && file_options[:format])
53
+ fmt && fmt.to_sym == :json
54
+ end
55
+
56
+ def error_payload(error)
57
+ type = case error
58
+ when Evilution::ConfigError then "config_error"
59
+ when Evilution::ParseError then "parse_error"
60
+ else "runtime_error"
61
+ end
62
+ payload = { type: type, message: error.message }
63
+ payload[:file] = error.file if error.respond_to?(:file) && error.file
64
+ { error: payload }
65
+ end
66
+ end
67
+
68
+ Evilution::CLI::Dispatcher.register(:run, Evilution::CLI::Commands::Run)
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../commands"
5
+ require_relative "../command"
6
+ require_relative "../dispatcher"
7
+ require_relative "../printers/session_diff"
8
+ require_relative "../../session/store"
9
+ require_relative "../../session/diff"
10
+
11
+ class Evilution::CLI::Commands::SessionDiff < Evilution::CLI::Command
12
+ private
13
+
14
+ def perform
15
+ raise Evilution::ConfigError, "two session file paths required" unless @files.length == 2
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)
21
+ Evilution::CLI::Printers::SessionDiff.new(result, format: @options[:format]).render(@stdout)
22
+ 0
23
+ rescue ::JSON::ParserError => e
24
+ raise Evilution::Error, "invalid session file: #{e.message}"
25
+ rescue SystemCallError => e
26
+ raise Evilution::Error, e.message
27
+ end
28
+ end
29
+
30
+ Evilution::CLI::Dispatcher.register(:session_diff, Evilution::CLI::Commands::SessionDiff)
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../commands"
4
+ require_relative "../command"
5
+ require_relative "../dispatcher"
6
+ require_relative "../../session/store"
7
+
8
+ class Evilution::CLI::Commands::SessionGc < Evilution::CLI::Command
9
+ private
10
+
11
+ def perform
12
+ raise Evilution::ConfigError, "--older-than is required for session gc" unless @options[:older_than]
13
+
14
+ cutoff = parse_duration(@options[:older_than])
15
+ store_opts = {}
16
+ store_opts[:results_dir] = @options[:results_dir] if @options[:results_dir]
17
+ store = Evilution::Session::Store.new(**store_opts)
18
+ deleted = store.gc(older_than: cutoff)
19
+
20
+ if deleted.empty?
21
+ @stdout.puts("No sessions to delete")
22
+ else
23
+ @stdout.puts("Deleted #{deleted.length} session#{"s" unless deleted.length == 1}")
24
+ end
25
+
26
+ 0
27
+ end
28
+
29
+ def parse_duration(value)
30
+ match = value.match(/\A(\d+)([dhw])\z/)
31
+ unless match
32
+ raise Evilution::ConfigError,
33
+ "invalid --older-than format: #{value.inspect}. Use Nd, Nh, or Nw (e.g., 30d)"
34
+ end
35
+
36
+ amount = match[1].to_i
37
+ seconds = case match[2]
38
+ when "h" then amount * 3600
39
+ when "d" then amount * 86_400
40
+ when "w" then amount * 604_800
41
+ end
42
+ Time.now - seconds
43
+ end
44
+ end
45
+
46
+ Evilution::CLI::Dispatcher.register(:session_gc, Evilution::CLI::Commands::SessionGc)
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "../commands"
5
+ require_relative "../command"
6
+ require_relative "../dispatcher"
7
+ require_relative "../printers/session_list"
8
+ require_relative "../../session/store"
9
+
10
+ class Evilution::CLI::Commands::SessionList < Evilution::CLI::Command
11
+ private
12
+
13
+ def perform
14
+ store_opts = {}
15
+ store_opts[:results_dir] = @options[:results_dir] if @options[:results_dir]
16
+ store = Evilution::Session::Store.new(**store_opts)
17
+ sessions = filter_sessions(store.list)
18
+
19
+ if sessions.empty?
20
+ @stdout.puts("No sessions found")
21
+ return 0
22
+ end
23
+
24
+ Evilution::CLI::Printers::SessionList.new(sessions, format: @options[:format]).render(@stdout)
25
+ 0
26
+ end
27
+
28
+ def filter_sessions(sessions)
29
+ if @options[:since]
30
+ cutoff = parse_date(@options[:since])
31
+ sessions = sessions.select do |s|
32
+ ts = s[:timestamp]
33
+ next false unless ts.is_a?(String)
34
+
35
+ Time.parse(ts) >= cutoff
36
+ rescue ArgumentError
37
+ false
38
+ end
39
+ end
40
+ sessions = sessions.first(@options[:limit]) if @options[:limit]
41
+ sessions
42
+ end
43
+
44
+ def parse_date(value)
45
+ Time.parse(value)
46
+ rescue ArgumentError
47
+ raise Evilution::ConfigError, "invalid --since date: #{value.inspect}. Use YYYY-MM-DD format"
48
+ end
49
+ end
50
+
51
+ Evilution::CLI::Dispatcher.register(:session_list, Evilution::CLI::Commands::SessionList)
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../commands"
5
+ require_relative "../command"
6
+ require_relative "../dispatcher"
7
+ require_relative "../printers/session_detail"
8
+ require_relative "../../session/store"
9
+
10
+ class Evilution::CLI::Commands::SessionShow < Evilution::CLI::Command
11
+ private
12
+
13
+ def perform
14
+ path = @files.first
15
+ raise Evilution::ConfigError, "session file path required" unless path
16
+
17
+ data = Evilution::Session::Store.new.load(path)
18
+ Evilution::CLI::Printers::SessionDetail.new(data, format: @options[:format]).render(@stdout)
19
+ 0
20
+ rescue ::JSON::ParserError => e
21
+ raise Evilution::Error, "invalid session file: #{e.message}"
22
+ rescue ::SystemCallError => e
23
+ raise Evilution::Error, e.message
24
+ end
25
+ end
26
+
27
+ Evilution::CLI::Dispatcher.register(:session_show, Evilution::CLI::Commands::SessionShow)
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../commands"
4
+ require_relative "../command"
5
+ require_relative "../dispatcher"
6
+ require_relative "../printers/subjects"
7
+ require_relative "../../config"
8
+ require_relative "../../runner"
9
+ require_relative "../../mutator"
10
+
11
+ class Evilution::CLI::Commands::Subjects < Evilution::CLI::Command
12
+ private
13
+
14
+ def perform
15
+ raise Evilution::ConfigError, @stdin_error if @stdin_error
16
+
17
+ config = Evilution::Config.new(target_files: @files, line_ranges: @line_ranges, **@options)
18
+ runner = Evilution::Runner.new(config: config)
19
+ subjects = runner.parse_and_filter_subjects
20
+
21
+ if subjects.empty?
22
+ @stdout.puts("No subjects found")
23
+ return 0
24
+ end
25
+
26
+ entries, total = collect_entries(subjects, config)
27
+ Evilution::CLI::Printers::Subjects.new(entries, total_mutations: total).render(@stdout)
28
+ 0
29
+ end
30
+
31
+ def collect_entries(subjects, config)
32
+ registry = Evilution::Mutator::Registry.default
33
+ filter = build_subject_filter(config)
34
+ operator_options = build_operator_options(config)
35
+ entries = []
36
+ total = 0
37
+
38
+ subjects.each do |subj|
39
+ count = registry.mutations_for(subj, filter: filter, operator_options: operator_options).length
40
+ total += count
41
+ entries << { name: subj.name, file_path: subj.file_path, line_number: subj.line_number, mutation_count: count }
42
+ ensure
43
+ subj.release_node!
44
+ end
45
+
46
+ [entries, total]
47
+ end
48
+ end
49
+
50
+ Evilution::CLI::Dispatcher.register(:subjects, Evilution::CLI::Commands::Subjects)
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../commands"
4
+ require_relative "../command"
5
+ require_relative "../dispatcher"
6
+ require_relative "../printers/tests_list"
7
+ require_relative "../../config"
8
+ require_relative "../../spec_resolver"
9
+ require_relative "../../git/changed_files"
10
+
11
+ class Evilution::CLI::Commands::TestsList < Evilution::CLI::Command
12
+ private
13
+
14
+ def perform
15
+ config = Evilution::Config.new(target_files: @files, line_ranges: @line_ranges, **@options)
16
+
17
+ if config.spec_files.any?
18
+ Evilution::CLI::Printers::TestsList.new(mode: :explicit, specs: config.spec_files).render(@stdout)
19
+ return 0
20
+ end
21
+
22
+ source_files = resolve_source_files(config)
23
+ if source_files.empty?
24
+ @stdout.puts("No source files found")
25
+ return 0
26
+ end
27
+
28
+ resolver = Evilution::SpecResolver.new
29
+ entries = source_files.map { |source| { source: source, spec: resolver.call(source) } }
30
+ Evilution::CLI::Printers::TestsList.new(mode: :resolved, entries: entries).render(@stdout)
31
+ 0
32
+ end
33
+
34
+ def resolve_source_files(config)
35
+ return config.target_files unless config.target_files.empty?
36
+
37
+ Evilution::Git::ChangedFiles.new.call
38
+ rescue Evilution::Error
39
+ []
40
+ end
41
+ end
42
+
43
+ Evilution::CLI::Dispatcher.register(:tests_list, Evilution::CLI::Commands::TestsList)
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+ require "prism"
5
+ require_relative "../commands"
6
+ require_relative "../command"
7
+ require_relative "../dispatcher"
8
+ require_relative "../printers/util_mutation"
9
+ require_relative "../../config"
10
+ require_relative "../../mutator/registry"
11
+ require_relative "../../ast/parser"
12
+
13
+ class Evilution::CLI::Commands::UtilMutation < Evilution::CLI::Command
14
+ private
15
+
16
+ def perform
17
+ source, file_path = resolve_util_mutation_source
18
+ subjects = parse_source_to_subjects(source, file_path)
19
+ config = Evilution::Config.new(**@options)
20
+ registry = Evilution::Mutator::Registry.default
21
+ operator_options = build_operator_options(config)
22
+ mutations = subjects.flat_map { |s| registry.mutations_for(s, operator_options: operator_options) }
23
+
24
+ if mutations.empty?
25
+ @stdout.puts("No mutations generated")
26
+ return 0
27
+ end
28
+
29
+ Evilution::CLI::Printers::UtilMutation.new(mutations, format: @options[:format]).render(@stdout)
30
+ 0
31
+ ensure
32
+ @util_tmpfile.close! if @util_tmpfile
33
+ end
34
+
35
+ 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)
45
+
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
54
+ end
55
+
56
+ def parse_source_to_subjects(source, file_label)
57
+ result = Prism.parse(source)
58
+ raise Evilution::Error, "failed to parse source: #{result.errors.map(&:message).join(", ")}" if result.failure?
59
+
60
+ finder = Evilution::AST::SubjectFinder.new(source, file_label)
61
+ finder.visit(result.value)
62
+ finder.subjects
63
+ end
64
+ end
65
+
66
+ Evilution::CLI::Dispatcher.register(:util_mutation, Evilution::CLI::Commands::UtilMutation)
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../commands"
4
+ require_relative "../command"
5
+ require_relative "../dispatcher"
6
+ require_relative "../../version"
7
+
8
+ class Evilution::CLI::Commands::Version < Evilution::CLI::Command
9
+ private
10
+
11
+ def perform
12
+ @stdout.puts(Evilution::VERSION)
13
+ 0
14
+ end
15
+ end
16
+
17
+ Evilution::CLI::Dispatcher.register(:version, Evilution::CLI::Commands::Version)
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution::CLI::Commands
4
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution::CLI::Dispatcher
4
+ @commands = {}
5
+
6
+ class << self
7
+ def register(symbol, klass)
8
+ @commands[symbol] = klass
9
+ end
10
+
11
+ def lookup(symbol)
12
+ @commands.fetch(symbol) { raise KeyError, "unknown command: #{symbol.inspect}" }
13
+ end
14
+
15
+ def registered?(symbol)
16
+ @commands.key?(symbol)
17
+ end
18
+
19
+ private
20
+
21
+ attr_reader :commands
22
+ end
23
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Evilution::CLI
4
+ ParsedArgs = Struct.new(
5
+ :command, :options, :files, :line_ranges, :stdin_error, :parse_error,
6
+ keyword_init: true
7
+ ) do
8
+ def initialize(command:, options: {}, files: [], line_ranges: {}, stdin_error: nil, parse_error: nil)
9
+ super
10
+ end
11
+ end
12
+ end