evilution 0.22.7 → 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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +8 -0
  3. data/CHANGELOG.md +28 -0
  4. data/README.md +37 -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/command_extractor.rb +77 -0
  22. data/lib/evilution/cli/parser/file_args.rb +41 -0
  23. data/lib/evilution/cli/parser/options_builder.rb +103 -0
  24. data/lib/evilution/cli/parser/stdin_reader.rb +28 -0
  25. data/lib/evilution/cli/parser.rb +88 -0
  26. data/lib/evilution/cli/printers/environment.rb +53 -0
  27. data/lib/evilution/cli/printers/session_detail.rb +76 -0
  28. data/lib/evilution/cli/printers/session_diff.rb +57 -0
  29. data/lib/evilution/cli/printers/session_list.rb +48 -0
  30. data/lib/evilution/cli/printers/subjects.rb +35 -0
  31. data/lib/evilution/cli/printers/tests_list.rb +45 -0
  32. data/lib/evilution/cli/printers/util_mutation.rb +35 -0
  33. data/lib/evilution/cli/printers.rb +4 -0
  34. data/lib/evilution/cli/result.rb +9 -0
  35. data/lib/evilution/cli.rb +30 -850
  36. data/lib/evilution/config.rb +31 -3
  37. data/lib/evilution/integration/base.rb +23 -55
  38. data/lib/evilution/integration/minitest.rb +22 -4
  39. data/lib/evilution/integration/rspec.rb +28 -8
  40. data/lib/evilution/isolation/fork.rb +11 -9
  41. data/lib/evilution/isolation/in_process.rb +11 -9
  42. data/lib/evilution/mcp/info_tool.rb +261 -0
  43. data/lib/evilution/mcp/mutate_tool.rb +112 -19
  44. data/lib/evilution/mcp/server.rb +3 -4
  45. data/lib/evilution/mcp/session_diff_tool.rb +5 -1
  46. data/lib/evilution/mcp/session_list_tool.rb +5 -1
  47. data/lib/evilution/mcp/session_show_tool.rb +5 -1
  48. data/lib/evilution/mcp/session_tool.rb +157 -0
  49. data/lib/evilution/reporter/cli.rb +2 -1
  50. data/lib/evilution/reporter/html/assets/style.css +68 -0
  51. data/lib/evilution/reporter/html/baseline_keys.rb +28 -0
  52. data/lib/evilution/reporter/html/diff_formatter.rb +27 -0
  53. data/lib/evilution/reporter/html/escape.rb +12 -0
  54. data/lib/evilution/reporter/html/namespace.rb +11 -0
  55. data/lib/evilution/reporter/html/report.rb +68 -0
  56. data/lib/evilution/reporter/html/section.rb +21 -0
  57. data/lib/evilution/reporter/html/sections/baseline_comparison.rb +46 -0
  58. data/lib/evilution/reporter/html/sections/error_details.rb +30 -0
  59. data/lib/evilution/reporter/html/sections/error_entry.rb +22 -0
  60. data/lib/evilution/reporter/html/sections/file_section.rb +47 -0
  61. data/lib/evilution/reporter/html/sections/header.rb +29 -0
  62. data/lib/evilution/reporter/html/sections/mutation_map.rb +32 -0
  63. data/lib/evilution/reporter/html/sections/summary_cards.rb +11 -0
  64. data/lib/evilution/reporter/html/sections/survived_details.rb +35 -0
  65. data/lib/evilution/reporter/html/sections/survived_entry.rb +36 -0
  66. data/lib/evilution/reporter/html/sections/truncation_notice.rb +17 -0
  67. data/lib/evilution/reporter/html/sections.rb +4 -0
  68. data/lib/evilution/reporter/html/stylesheet.rb +14 -0
  69. data/lib/evilution/reporter/html/templates/baseline_comparison.html.erb +8 -0
  70. data/lib/evilution/reporter/html/templates/error_details.html.erb +6 -0
  71. data/lib/evilution/reporter/html/templates/error_entry.html.erb +10 -0
  72. data/lib/evilution/reporter/html/templates/file_section.html.erb +9 -0
  73. data/lib/evilution/reporter/html/templates/header.html.erb +4 -0
  74. data/lib/evilution/reporter/html/templates/mutation_map.html.erb +6 -0
  75. data/lib/evilution/reporter/html/templates/report.html.erb +17 -0
  76. data/lib/evilution/reporter/html/templates/summary_cards.html.erb +23 -0
  77. data/lib/evilution/reporter/html/templates/survived_details.html.erb +21 -0
  78. data/lib/evilution/reporter/html/templates/survived_entry.html.erb +8 -0
  79. data/lib/evilution/reporter/html/templates/truncation_notice.html.erb +1 -0
  80. data/lib/evilution/reporter/html.rb +11 -349
  81. data/lib/evilution/reporter/json.rb +12 -8
  82. data/lib/evilution/result/mutation_result.rb +5 -1
  83. data/lib/evilution/result/summary.rb +9 -1
  84. data/lib/evilution/runner/baseline_runner.rb +71 -0
  85. data/lib/evilution/runner/diagnostics.rb +105 -0
  86. data/lib/evilution/runner/isolation_resolver.rb +134 -0
  87. data/lib/evilution/runner/mutation_executor.rb +255 -0
  88. data/lib/evilution/runner/mutation_planner.rb +126 -0
  89. data/lib/evilution/runner/report_publisher.rb +60 -0
  90. data/lib/evilution/runner/subject_pipeline.rb +121 -0
  91. data/lib/evilution/runner.rb +57 -692
  92. data/lib/evilution/version.rb +1 -1
  93. metadata +71 -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: '0925ab348be65d181cad6ed2b45f80e6c22c0667e8377f954d37944c20335621'
4
+ data.tar.gz: c14af9fa929612bf7dc8b87b62a8deddbd6d82535b5dff8c0d35405a47e62c6d
5
5
  SHA512:
6
- metadata.gz: cceb0046285ad1efdd63914af840da66354006d97f1f9da0642dcf649e3628a342c6ed5f3c6776f3b76dee7eb41af833033a4c9c18992eaa6773bf1c5da8ca75
7
- data.tar.gz: 6928979ecd999f213b87c3460179f4a09008894866e31d5e822f214ab521235e8463a389768e40bff065512fc1e55355d0ca249e739cb571210ccd429d94db82
6
+ metadata.gz: 0dda0d8fef652c798db27eb31e4065d191af1c662a39edb637a4e7883068f1716c859b8f72224c469239e37ec03b24d6151572c3051f812ba06607af6337f888
7
+ data.tar.gz: 8d1fc65e174a493c57c9267ebdf8acc17cd11477669ed766a8601f7812acc442faa5bf91c5efb4ae7e6bb88839db10d6f9f9d070e32922478adb525d71994dbd
@@ -17,3 +17,11 @@
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"}}
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,33 @@
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
+
19
+ ## [0.23.0] - 2026-04-14
20
+
21
+ ### Changed
22
+
23
+ - **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)
24
+
25
+ ### Fixed
26
+
27
+ - **`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)
28
+ - **`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)
29
+ - **`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)
30
+
3
31
  ## [0.22.7] - 2026-04-13
4
32
 
5
33
  ### Fixed
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
 
@@ -95,7 +96,7 @@ Creates `.evilution.yml`:
95
96
  # save_session: false # persist results under .evilution/results/
96
97
  # isolation: auto # auto | fork | in_process (auto selects fork for Rails)
97
98
  # 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
99
+ # skip_heredoc_literals: false # skip string literal mutations inside heredocs (recommended for Rails: heredoc SQL/templates rarely have test coverage)
99
100
  # show_disabled: false # report mutations skipped by disable comments
100
101
  # baseline_session: null # path to session file for HTML comparison
101
102
  # ignore_patterns: [] # AST patterns to exclude (see docs/ast_pattern_syntax.md)
@@ -288,13 +289,12 @@ The server exposes the following tools:
288
289
  | Tool | Description |
289
290
  |---|---|
290
291
  | `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) |
292
+ | `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) |
293
+ | `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
294
 
295
295
  ### Verbosity Control
296
296
 
297
- The MCP tool accepts a `verbosity` parameter to control response size:
297
+ The `evilution-mutate` tool accepts a `verbosity` parameter to control response size:
298
298
 
299
299
  | Level | Default | What's included |
300
300
  |-------------|---------|--------------------------------------------------------------|
@@ -304,11 +304,41 @@ The MCP tool accepts a `verbosity` parameter to control response size:
304
304
 
305
305
  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
306
 
307
+ ### Enriched Survived Entries
308
+
309
+ Unlike `evilution --format json`, every survived entry returned by `evilution-mutate` carries extra fields so the agent can act without a second round-trip:
310
+
311
+ | Field | What it gives you |
312
+ |---|---|
313
+ | `subject` | `Class#method` for the mutated subject — points at the exact method to test |
314
+ | `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 |
315
+ | `next_step` | Concrete natural-language hint — "add a test in X that fails against this mutation at Y:line" |
316
+
317
+ 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.
318
+
307
319
  ### Concrete Test Suggestions
308
320
 
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).
321
+ 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).
322
+
323
+ 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
324
 
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).
325
+ ### Project Config File
326
+
327
+ `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.
328
+
329
+ 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.
330
+
331
+ ### Iterative Workflow Parameters
332
+
333
+ `evilution-mutate` exposes the full set of CLI knobs agents need for iterative TDD:
334
+
335
+ | Parameter | Purpose |
336
+ |---|---|
337
+ | `incremental` | Cache killed/timeout results across runs — set `true` when iterating on the same files |
338
+ | `integration` | `rspec` or `minitest` |
339
+ | `isolation` | `auto`, `fork`, or `in_process` |
340
+ | `baseline` | `false` to skip the baseline suite check when you already know it's green |
341
+ | `save_session` | Persist results to `.evilution/results/` for inspection via `evilution-session` |
312
342
 
313
343
  > **Note**: `.mcp.json` is gitignored by default since it is a local editor/agent configuration file.
314
344
 
@@ -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