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