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.
- checksums.yaml +4 -4
- data/.beads/interactions.jsonl +3 -0
- data/CHANGELOG.md +12 -0
- data/README.md +36 -7
- data/lib/evilution/cli/command.rb +37 -0
- data/lib/evilution/cli/commands/environment_show.rb +20 -0
- data/lib/evilution/cli/commands/init.rb +24 -0
- data/lib/evilution/cli/commands/mcp.rb +19 -0
- data/lib/evilution/cli/commands/run.rb +68 -0
- data/lib/evilution/cli/commands/session_diff.rb +30 -0
- data/lib/evilution/cli/commands/session_gc.rb +46 -0
- data/lib/evilution/cli/commands/session_list.rb +51 -0
- data/lib/evilution/cli/commands/session_show.rb +27 -0
- data/lib/evilution/cli/commands/subjects.rb +50 -0
- data/lib/evilution/cli/commands/tests_list.rb +43 -0
- data/lib/evilution/cli/commands/util_mutation.rb +66 -0
- data/lib/evilution/cli/commands/version.rb +17 -0
- data/lib/evilution/cli/commands.rb +4 -0
- data/lib/evilution/cli/dispatcher.rb +23 -0
- data/lib/evilution/cli/parsed_args.rb +12 -0
- data/lib/evilution/cli/parser.rb +257 -0
- data/lib/evilution/cli/printers/environment.rb +53 -0
- data/lib/evilution/cli/printers/session_detail.rb +76 -0
- data/lib/evilution/cli/printers/session_diff.rb +57 -0
- data/lib/evilution/cli/printers/session_list.rb +48 -0
- data/lib/evilution/cli/printers/subjects.rb +35 -0
- data/lib/evilution/cli/printers/tests_list.rb +45 -0
- data/lib/evilution/cli/printers/util_mutation.rb +35 -0
- data/lib/evilution/cli/printers.rb +4 -0
- data/lib/evilution/cli/result.rb +9 -0
- data/lib/evilution/cli.rb +30 -850
- data/lib/evilution/config.rb +18 -3
- data/lib/evilution/integration/base.rb +14 -0
- data/lib/evilution/integration/minitest.rb +6 -1
- data/lib/evilution/integration/rspec.rb +10 -2
- data/lib/evilution/isolation/fork.rb +10 -9
- data/lib/evilution/isolation/in_process.rb +10 -9
- data/lib/evilution/mcp/info_tool.rb +261 -0
- data/lib/evilution/mcp/mutate_tool.rb +112 -19
- data/lib/evilution/mcp/server.rb +3 -4
- data/lib/evilution/mcp/session_diff_tool.rb +5 -1
- data/lib/evilution/mcp/session_list_tool.rb +5 -1
- data/lib/evilution/mcp/session_show_tool.rb +5 -1
- data/lib/evilution/mcp/session_tool.rb +157 -0
- data/lib/evilution/reporter/html.rb +41 -0
- data/lib/evilution/runner.rb +3 -1
- data/lib/evilution/version.rb +1 -1
- metadata +30 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7fe91007e7b5113b2e790e726991bcc872abe8fe8e3c321b1e1f5896f9ccec8d
|
|
4
|
+
data.tar.gz: 809753233bdd01ec72a9014e31dcc0f821f9e569b915bc58aee4ac69ce0168df
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5e1a965330f8d02db82e5bee493682a70a3492f67985e2e0729a66354de51c9e8d3e0b2323617d82dc3209eb7791e5cfc0af2e735c5351081fe48f44d6e26f22
|
|
7
|
+
data.tar.gz: 43bbd5cf785fe4370ef351642f697a205cd2af2490dbc15a187aae640a35df0d7a472f2d133ad45ac274fd0c5841401f1a6f8a42b395a97cb92b125b51ec84fb
|
data/.beads/interactions.jsonl
CHANGED
|
@@ -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
|
|
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
|
|
292
|
-
| `evilution-
|
|
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
|
|
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
|
|
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
|
-
|
|
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,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
|