evilution 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 646e7a441c39108d413a0b33cb28234b090e16c1614d343c40be84581fb45adf
4
- data.tar.gz: fc65d99be854a51dc0354a77db413d90a8344611dc8d94a5e97ef61e91e764a9
3
+ metadata.gz: 67bff7d0366e8fe092534c14669ec3637ba1d95934a003f595519dca8f16fe68
4
+ data.tar.gz: b4545412acaedb471187e9522cb8858c07b9a83b748a58ffbd8aa2a1f0ec7a29
5
5
  SHA512:
6
- metadata.gz: 38954ff26a4f48a050ca7a5d4270307157f7482836718152e03d17642df566110144c2773a5ce62ea8877ed2bdc97e6b2ccc33d49e9521e2074ad7cfc2b46b1e
7
- data.tar.gz: c4a4bcbf1f59e2f8ff01138bf690132dcb183330009340b76b4d8db1d8807411c04dcb8f3a52820857b9563158a49147903a7274e1395b0f4f76047f239d625e
6
+ metadata.gz: 15c2460a72fc5822ddecc3e38c4e60424bc96a24274542ce2492c55e9a847ca02484ec103513791f91c285f3ee18b8fb3017009a146fc517822c9a1749f2cd93
7
+ data.tar.gz: 98f93f7b5bbe2baf2172bee631a44fa8cde99e5913e3484b91c06d780ea2f0d7f462e6a030187d3179dfbe0b0bd9d78afdeff29dd28e38e7d7b9f6efa90763c1
@@ -1 +1 @@
1
- 1773300459
1
+ 1773589314
data/.beads/issues.jsonl CHANGED
@@ -31,10 +31,10 @@
31
31
  {"id":"EV-2.9","title":"Implement Evilution::Isolation::Fork","description":"Fork-based process isolation. Method: run(mutation, test_command, timeout:) -> MutationResult. Flow: create pipe, fork child, child applies mutation via eval, child runs test command, marshal result back via pipe, parent reads with IO.select timeout, SIGKILL on deadline. File: lib/evilution/isolation/fork.rb + spec.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:05:51.142167275+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T10:54:08.819310594+07:00","closed_at":"2026-03-02T10:54:08.819310594+07:00","close_reason":"Fork isolation implemented with 6 passing specs","dependencies":[{"issue_id":"EV-2.9","depends_on_id":"EV-2","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-2.9","depends_on_id":"EV-2.8","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
32
32
  {"id":"EV-20","title":"Deprecate coverage filtering","description":"With line-range and method-name targeting, coverage collection adds overhead for little benefit. Deprecate --no-coverage flag, coverage config key, and remove coverage logic from runner.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-10T00:30:58.837659684+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-10T00:33:06.453669976+07:00","closed_at":"2026-03-10T00:33:06.453669976+07:00","close_reason":"Deprecated coverage filtering: CLI warns on --no-coverage, config warns on coverage key, runner no longer uses coverage logic"}
33
33
  {"id":"EV-21","title":"Epic: Speed & Performance","description":"Reduce mutation testing wall-clock time for fast agent feedback loops. Includes fail-fast, parallel execution, and per-mutation spec targeting.","status":"open","priority":1,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-10T06:17:26.608316104+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-10T06:17:26.608316104+07:00","dependencies":[{"issue_id":"EV-21","depends_on_id":"EV-22","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-21","depends_on_id":"EV-23","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
34
- {"id":"EV-22","title":"Add --fail-fast flag with survivor threshold","description":"Add --fail-fast [N] CLI flag and fail_fast config option to stop mutation testing after N surviving mutants (default N=1 when flag given without value). Agents fix gaps iteratively, so discovering all survivors upfront is wasted work. A threshold gives control over the speed/thoroughness tradeoff: --fail-fast 1 for quick checks, --fail-fast 5 for CI gates, omit for full scans. Runner#call should stop running mutations and return a partial Summary once the threshold is reached. JSON output should include a 'truncated: true' field when fail-fast triggered.","status":"open","priority":2,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-10T06:17:28.018733235+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-10T10:29:42.228568154+07:00"}
34
+ {"id":"EV-22","title":"Add --fail-fast flag with survivor threshold","description":"Add --fail-fast [N] CLI flag and fail_fast config option to stop mutation testing after N surviving mutants (default N=1 when flag given without value). Agents fix gaps iteratively, so discovering all survivors upfront is wasted work. A threshold gives control over the speed/thoroughness tradeoff: --fail-fast 1 for quick checks, --fail-fast 5 for CI gates, omit for full scans. Runner#call should stop running mutations and return a partial Summary once the threshold is reached. JSON output should include a 'truncated: true' field when fail-fast triggered.","status":"in_progress","priority":2,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-10T06:17:28.018733235+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-13T08:24:05.534981769+07:00"}
35
35
  {"id":"EV-23","title":"Per-mutation spec targeting","description":"Instead of running the full spec suite for every mutation, map each mutated source file to its relevant spec file(s) using convention-based resolution (e.g. lib/foo/bar.rb -> spec/foo/bar_spec.rb) and only run those. This dramatically reduces per-mutation test time. Depends on convention-based spec file resolution being implemented first.","status":"open","priority":2,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-10T06:17:28.98620973+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-10T06:17:28.98620973+07:00","dependencies":[{"issue_id":"EV-23","depends_on_id":"EV-34","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
36
- {"id":"EV-24","title":"Epic: JSON Output Improvements","description":"Make JSON output fully machine-parseable in all scenarios, including errors. Add diagnostic fields that help agents debug failures.","status":"open","priority":2,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-10T06:17:37.450686472+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-10T06:17:37.450686472+07:00","dependencies":[{"issue_id":"EV-24","depends_on_id":"EV-25","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-24","depends_on_id":"EV-26","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
37
- {"id":"EV-25","title":"Structured error responses in JSON mode","description":"When --format json is used and exit code is 2 (error), output a JSON object with error details instead of unstructured stderr text. Schema: { \"error\": { \"type\": \"config_error|parse_error|runtime_error\", \"message\": \"...\", \"file\": \"...\" } }. Agents currently have to regex-parse stderr which is fragile.","status":"open","priority":2,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-10T06:17:38.283715502+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-10T06:17:38.283715502+07:00"}
36
+ {"id":"EV-24","title":"Epic: JSON Output Improvements","description":"Make JSON output fully machine-parseable in all scenarios, including errors. Add diagnostic fields that help agents debug failures.","status":"open","priority":1,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-10T06:17:37.450686472+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-13T09:57:11.531255221+07:00","dependencies":[{"issue_id":"EV-24","depends_on_id":"EV-25","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-24","depends_on_id":"EV-26","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-24","depends_on_id":"EV-40","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
37
+ {"id":"EV-25","title":"Structured error responses in JSON mode","description":"When --format json is used and exit code is 2 (error), output a JSON object with error details instead of unstructured stderr text. Schema: { \"error\": { \"type\": \"config_error|parse_error|runtime_error\", \"message\": \"...\", \"file\": \"...\" } }. Agents currently have to regex-parse stderr which is fragile.","status":"closed","priority":1,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-10T06:17:38.283715502+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-15T22:41:54.370789377+07:00","closed_at":"2026-03-15T22:41:54.370789377+07:00","close_reason":"Merged PR #74 — structured JSON error output in CLI"}
38
38
  {"id":"EV-26","title":"Include test command in mutation result JSON","description":"Add a 'test_command' field to each mutation result in JSON output showing the exact command that was run to test that mutation. Helps agents debug when a mutation errors out or times out.","status":"open","priority":3,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-10T06:17:39.227881462+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-10T06:17:39.227881462+07:00"}
39
39
  {"id":"EV-27","title":"Epic: Workflow Integration","description":"Make evilution easier to invoke from AI agent toolchains — MCP server for direct tool calls, stdin mode for piping.","status":"open","priority":2,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-10T06:17:43.883767452+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-10T06:17:43.883767452+07:00","dependencies":[{"issue_id":"EV-27","depends_on_id":"EV-28","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-27","depends_on_id":"EV-29","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
40
40
  {"id":"EV-28","title":"MCP server for direct tool invocation","description":"Implement a Model Context Protocol (MCP) server that exposes evilution as a tool. Agents could call evilution directly instead of shelling out and parsing output. The server should expose a 'mutate' tool that accepts target files, options, and returns structured results.","status":"open","priority":2,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-10T06:17:45.29866593+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-10T06:17:45.29866593+07:00"}
@@ -64,8 +64,8 @@
64
64
  {"id":"EV-30","title":"Epic: Smarter Suggestions","description":"Improve the suggestion field in mutation results to provide concrete, actionable test code that agents can use directly.","status":"open","priority":2,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-10T06:18:03.590579846+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-10T06:18:03.590579846+07:00","dependencies":[{"issue_id":"EV-30","depends_on_id":"EV-31","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
65
65
  {"id":"EV-31","title":"Generate concrete RSpec test code in suggestions","description":"Replace prose suggestions like 'add a test that checks the boundary' with actual RSpec code snippets that would kill the mutant. Use the mutation's operator, file context, and method signature to generate a concrete test example. The suggestion should be a valid RSpec 'it' block that an agent can drop into a spec file.","status":"open","priority":2,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-10T06:18:05.964542284+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-10T06:18:05.964542284+07:00"}
66
66
  {"id":"EV-32","title":"Epic: Zero-friction Defaults","description":"Make evilution work well with zero configuration. Auto-detect what to mutate and which specs to run based on git state and file conventions.","status":"open","priority":2,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-10T06:18:06.843446356+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-10T06:18:06.843446356+07:00","dependencies":[{"issue_id":"EV-32","depends_on_id":"EV-33","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-32","depends_on_id":"EV-34","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
67
- {"id":"EV-33","title":"Auto-detect changed files from git merge base","description":"When evilution is run with no file arguments, default to mutating files changed since the merge base of the current branch (git merge-base HEAD main). This makes 'evilution run --format json' just work in a feature branch without specifying files. Should be skippable if explicit files are provided.","status":"open","priority":2,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-10T06:18:08.235421922+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-10T06:18:08.235421922+07:00"}
68
- {"id":"EV-34","title":"Convention-based spec file resolution","description":"Implement a mapping from source files to their spec files using Ruby/RSpec conventions: lib/foo/bar.rb -> spec/foo/bar_spec.rb, app/models/user.rb -> spec/models/user_spec.rb. This is a foundational piece used by both zero-friction defaults (auto-detect specs) and per-mutation spec targeting (run only relevant specs). Should handle common Rails and gem layouts.","status":"open","priority":2,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-10T06:18:09.380269033+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-10T06:18:09.380269033+07:00"}
67
+ {"id":"EV-33","title":"Auto-detect changed files from git merge base","description":"When evilution is run with no file arguments, default to mutating files changed since the merge base of the current branch (git merge-base HEAD main). This makes 'evilution run --format json' just work in a feature branch without specifying files. Should be skippable if explicit files are provided.","status":"in_progress","priority":2,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-10T06:18:08.235421922+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-15T23:20:23.413703926+07:00"}
68
+ {"id":"EV-34","title":"Convention-based spec file resolution","description":"Implement a mapping from source files to their spec files using Ruby/RSpec conventions: lib/foo/bar.rb -> spec/foo/bar_spec.rb, app/models/user.rb -> spec/models/user_spec.rb. This is a foundational piece used by both zero-friction defaults (auto-detect specs) and per-mutation spec targeting (run only relevant specs). Should handle common Rails and gem layouts.","status":"in_progress","priority":2,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-10T06:18:09.380269033+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-15T22:50:31.462784114+07:00"}
69
69
  {"id":"EV-35","title":"File left in mutated state on timeout","description":"Epic: File left in mutated state on timeout. Root cause: SIGKILL (fork.rb:45) is untrappable — child's ensure block (rspec.rb:24-26) never runs, so restore_original never executes. Affects Strategy B (direct file write) with data corruption and Strategy A (temp dir) with resource leaks. Decomposed into EV-37, EV-38, EV-39.","status":"closed","priority":1,"issue_type":"bug","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-12T14:27:59.54425065+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-13T00:34:44.130203912+07:00","closed_at":"2026-03-13T00:34:44.130203912+07:00","close_reason":"All sub-issues (EV-37, EV-38, EV-39) completed","dependencies":[{"issue_id":"EV-35","depends_on_id":"EV-37","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-35","depends_on_id":"EV-38","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-35","depends_on_id":"EV-39","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
70
70
  {"id":"EV-36","title":"Raise default timeout from 10s to 30s","description":"Default 10s timeout is too low when running the full test suite per mutation (no smart test selection yet). Agent testing showed 30s was needed. Raising the default prevents confusing timeout failures for new users.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-12T14:27:59.761882257+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-12T14:45:27.102490973+07:00","closed_at":"2026-03-12T14:45:27.102490973+07:00","close_reason":"Closed"}
71
71
  {"id":"EV-37","title":"Parent-side backup & restore for direct-write mutations","description":"Root cause: SIGKILL is untrappable, so when a mutation times out the child's ensure block (rspec.rb:24-26) never runs. If Strategy B (direct file write, rspec.rb:50-58) was used, the original file is left in a mutated state on disk. Fix: before forking, the parent must save a backup of the original file content. After the child exits (normally or via kill), the parent verifies the file is restored and restores it if not. This is the primary fix for EV-35.","status":"closed","priority":0,"issue_type":"bug","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-12T14:32:49.590919391+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-12T23:04:21.990924732+07:00","closed_at":"2026-03-12T23:04:21.990924732+07:00","close_reason":"Implemented parent-side restore in Fork#call ensure block with read-before-write guard"}
@@ -81,6 +81,10 @@
81
81
  {"id":"EV-4.7","title":"Integrate parallel execution into Runner","description":"Update Runner to use Parallel::Pool when jobs > 1. Replace sequential mutation execution with pool.run. Verify results are identical. File: lib/evilution/runner.rb (edit).","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:06:45.637953763+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T11:54:25.596270941+07:00","closed_at":"2026-03-02T11:54:25.596270941+07:00","close_reason":"Closed","dependencies":[{"issue_id":"EV-4.7","depends_on_id":"EV-4","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-4.7","depends_on_id":"EV-4.1","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-4.7","depends_on_id":"EV-2.12","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
82
82
  {"id":"EV-4.8","title":"Integrate coverage-based filtering into Runner","description":"Update Runner to optionally collect coverage on first test suite run, build TestMap, and skip mutations on uncovered lines. Controlled by --no-coverage flag. File: lib/evilution/runner.rb (edit).","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:06:45.745583233+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-06T11:02:35.638752306+07:00","closed_at":"2026-03-02T11:54:25.596274893+07:00","close_reason":"Closed","dependencies":[{"issue_id":"EV-4.8","depends_on_id":"EV-4","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-4.8","depends_on_id":"EV-4.4","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-4.8","depends_on_id":"EV-2.12","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
83
83
  {"id":"EV-4.9","title":"Integrate diff-based targeting into Runner","description":"Update Runner to optionally use Diff::Parser + FileFilter when --diff flag is set. Filter subjects before mutation generation. File: lib/evilution/runner.rb (edit).","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:06:45.851982561+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T11:54:25.596280246+07:00","closed_at":"2026-03-02T11:54:25.596280246+07:00","close_reason":"Closed","dependencies":[{"issue_id":"EV-4.9","depends_on_id":"EV-4","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-4.9","depends_on_id":"EV-4.6","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-4.9","depends_on_id":"EV-2.12","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
84
+ {"id":"EV-40","title":"Separate rspec noise from evilution output (stdout/stderr)","description":"RSpec warnings flood stdout and corrupt JSON output. When using --format json, the JSON gets buried in hundreds of lines of rspec warnings. Fix: redirect rspec subprocess output to stderr (or /dev/null), keep evilution results on stdout. Critical for piping JSON output.","status":"open","priority":1,"issue_type":"bug","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-13T09:56:58.604909176+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-13T09:56:58.604909176+07:00"}
85
+ {"id":"EV-41","title":"Progress indicator during mutation runs","description":"Zero output during multi-minute runs makes it look stuck. Add a simple 'mutation 3/19 killed...' progress line to stderr so users know work is happening. Only show in text mode or when stderr is a TTY.","status":"open","priority":2,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-13T09:57:01.023293323+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-13T09:57:01.023293323+07:00"}
86
+ {"id":"EV-42","title":"Expand mutation operators (method call removal, receiver replacement, etc.)","description":"Currently 18 operators generating ~19 mutations vs mutant's 78 for the same method. Missing operators: method call removal, self receiver replacement, additional boolean logic mutations, nil substitutions. Higher operator count catches more subtle bugs and produces a more meaningful mutation score.","status":"open","priority":2,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-13T09:57:03.039944039+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-13T09:57:03.039944039+07:00"}
87
+ {"id":"EV-43","title":"Fix --version flag (returns 'version unknown')","description":"Running 'evilution --version' returns 'version unknown' instead of the actual version. The 'evilution version' subcommand works, but --version as a flag does not. Users expect --version to work.","status":"open","priority":1,"issue_type":"bug","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-13T09:57:05.048211823+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-13T09:57:05.048211823+07:00"}
84
88
  {"id":"EV-5","title":"Phase 4: Polish","description":"Suggestion generator, .evilution.yml config file loading, evilution init subcommand, error handling, README","status":"closed","priority":2,"issue_type":"epic","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:05:03.497091872+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T11:54:32.782883746+07:00","closed_at":"2026-03-02T11:54:32.782883746+07:00","close_reason":"Closed","dependencies":[{"issue_id":"EV-5","depends_on_id":"EV-4","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
85
89
  {"id":"EV-5.1","title":"Implement Evilution::Reporter::Suggestion","description":"Generates actionable fix suggestions per surviving mutant. Each operator type has a suggestion template. E.g., ComparisonReplacement >= -> > suggests 'Add a test for the boundary case where value equals exactly [threshold]'. File: lib/evilution/reporter/suggestion.rb + spec.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:06:58.038411009+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T11:54:21.518470747+07:00","closed_at":"2026-03-02T11:54:21.518470747+07:00","close_reason":"Closed","dependencies":[{"issue_id":"EV-5.1","depends_on_id":"EV-5","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-5.1","depends_on_id":"EV-2.8","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-5.1","depends_on_id":"EV-3.21","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
86
90
  {"id":"EV-5.2","title":"Implement .evilution.yml config file loading","description":"Load config from .evilution.yml / config/evilution.yml if present. YAML keys map to Config fields. CLI flags override file values. File: update lib/evilution/config.rb + spec.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:06:58.142538793+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T11:54:21.518473527+07:00","closed_at":"2026-03-02T11:54:21.518473527+07:00","close_reason":"Closed","dependencies":[{"issue_id":"EV-5.2","depends_on_id":"EV-5","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-5.2","depends_on_id":"EV-2.1","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
@@ -6,12 +6,16 @@ module Evilution
6
6
  module AST
7
7
  class Parser
8
8
  def call(file_path)
9
- raise ParseError, "file not found: #{file_path}" unless File.exist?(file_path)
9
+ raise ParseError.new("file not found: #{file_path}", file: file_path) unless File.exist?(file_path)
10
10
 
11
- source = File.read(file_path)
11
+ begin
12
+ source = File.read(file_path)
13
+ rescue SystemCallError => e
14
+ raise ParseError.new("cannot read #{file_path}: #{e.message}", file: file_path)
15
+ end
12
16
  result = Prism.parse(source)
13
17
 
14
- raise ParseError, "failed to parse #{file_path}: #{result.errors.map(&:message).join(", ")}" if result.failure?
18
+ raise ParseError.new("failed to parse #{file_path}: #{result.errors.map(&:message).join(", ")}", file: file_path) if result.failure?
15
19
 
16
20
  extract_subjects(result.value, source, file_path)
17
21
  end
data/lib/evilution/cli.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require "optparse"
4
5
  require_relative "version"
5
6
  require_relative "config"
@@ -12,7 +13,7 @@ module Evilution
12
13
  @command = :run
13
14
  argv = argv.dup
14
15
  argv = extract_command(argv)
15
- argv = warn_removed_flags(argv)
16
+ argv = preprocess_flags(argv)
16
17
  raw_args = build_option_parser.parse!(argv)
17
18
  @files, @line_ranges = parse_file_args(raw_args)
18
19
  end
@@ -45,7 +46,7 @@ module Evilution
45
46
  argv
46
47
  end
47
48
 
48
- def warn_removed_flags(argv)
49
+ def preprocess_flags(argv)
49
50
  result = []
50
51
  i = 0
51
52
  while i < argv.length
@@ -53,10 +54,24 @@ module Evilution
53
54
  if %w[--jobs -j].include?(arg)
54
55
  warn("Warning: --jobs is no longer supported and will be ignored.")
55
56
  next_arg = argv[i + 1]
56
- i += next_arg&.match?(/\A-?\d+\z/) ? 2 : 1
57
+ numeric_next = next_arg && next_arg.match?(/\A-?\d+\z/)
58
+ i += numeric_next ? 2 : 1
57
59
  elsif arg.start_with?("--jobs=") || arg.match?(/\A-j-?\d+\z/)
58
60
  warn("Warning: --jobs is no longer supported and will be ignored.")
59
61
  i += 1
62
+ elsif arg == "--fail-fast"
63
+ next_arg = argv[i + 1]
64
+
65
+ if next_arg && next_arg.match?(/\A-?\d+\z/)
66
+ @options[:fail_fast] = next_arg
67
+ i += 2
68
+ else
69
+ result << arg
70
+ i += 1
71
+ end
72
+ elsif arg.start_with?("--fail-fast=")
73
+ @options[:fail_fast] = arg.delete_prefix("--fail-fast=")
74
+ i += 1
60
75
  else
61
76
  result << arg
62
77
  i += 1
@@ -97,6 +112,8 @@ module Evilution
97
112
  warn("Warning: --no-coverage is deprecated, currently has no effect, and will be removed in a future version.")
98
113
  @options[:coverage] = false
99
114
  end
115
+ opts.on("--fail-fast", "Stop after N surviving mutants " \
116
+ "(default: disabled; if provided without N, uses 1; use --fail-fast=N)") { @options[:fail_fast] ||= 1 }
100
117
  opts.on("-v", "--verbose", "Verbose output") { @options[:verbose] = true }
101
118
  opts.on("-q", "--quiet", "Suppress output") { @options[:quiet] = true }
102
119
  end
@@ -141,13 +158,37 @@ module Evilution
141
158
  end
142
159
 
143
160
  def run_mutations
161
+ file_options = Config.file_options
144
162
  config = Config.new(**@options, target_files: @files, line_ranges: @line_ranges)
145
163
  runner = Runner.new(config: config)
146
164
  summary = runner.call
147
165
  summary.success?(min_score: config.min_score) ? 0 : 1
148
166
  rescue Error => e
149
- warn("Error: #{e.message}")
167
+ if json_format?(config, file_options)
168
+ $stdout.puts(JSON.generate(error_payload(e)))
169
+ else
170
+ warn("Error: #{e.message}")
171
+ end
150
172
  2
151
173
  end
174
+
175
+ def json_format?(config, file_options)
176
+ return config.json? if config
177
+
178
+ format = @options[:format] || (file_options && file_options[:format])
179
+ format && format.to_sym == :json
180
+ end
181
+
182
+ def error_payload(error)
183
+ error_type = case error
184
+ when ConfigError then "config_error"
185
+ when ParseError then "parse_error"
186
+ else "runtime_error"
187
+ end
188
+
189
+ payload = { type: error_type, message: error.message }
190
+ payload[:file] = error.file if error.file
191
+ { error: payload }
192
+ end
152
193
  end
153
194
  end
@@ -16,13 +16,14 @@ module Evilution
16
16
  coverage: true,
17
17
  verbose: false,
18
18
  quiet: false,
19
+ fail_fast: nil,
19
20
  line_ranges: {},
20
21
  spec_files: []
21
22
  }.freeze
22
23
 
23
24
  attr_reader :target_files, :timeout, :format, :diff_base,
24
25
  :target, :min_score, :integration, :coverage, :verbose, :quiet,
25
- :line_ranges, :spec_files
26
+ :fail_fast, :line_ranges, :spec_files
26
27
 
27
28
  def initialize(**options)
28
29
  file_options = options.delete(:skip_config_file) ? {} : load_config_file
@@ -38,6 +39,7 @@ module Evilution
38
39
  @coverage = merged[:coverage]
39
40
  @verbose = merged[:verbose]
40
41
  @quiet = merged[:quiet]
42
+ @fail_fast = validate_fail_fast(merged[:fail_fast])
41
43
  @line_ranges = merged[:line_ranges] || {}
42
44
  @spec_files = Array(merged[:spec_files])
43
45
  freeze
@@ -63,6 +65,25 @@ module Evilution
63
65
  !target.nil?
64
66
  end
65
67
 
68
+ def fail_fast?
69
+ !fail_fast.nil?
70
+ end
71
+
72
+ def self.file_options
73
+ CONFIG_FILES.each do |path|
74
+ next unless File.exist?(path)
75
+
76
+ data = YAML.safe_load_file(path, symbolize_names: true)
77
+ return data.is_a?(Hash) ? data : {}
78
+ rescue Psych::SyntaxError, Psych::DisallowedClass => e
79
+ raise ConfigError.new("failed to parse config file #{path}: #{e.message}", file: path)
80
+ rescue SystemCallError => e
81
+ raise ConfigError.new("cannot read config file #{path}: #{e.message}", file: path)
82
+ end
83
+
84
+ {}
85
+ end
86
+
66
87
  # Generates a default config file template.
67
88
  def self.default_template
68
89
  <<~YAML
@@ -81,6 +102,9 @@ module Evilution
81
102
  # Test integration: rspec (default: rspec)
82
103
  # integration: rspec
83
104
 
105
+ # Stop after N surviving mutants (default: disabled)
106
+ # fail_fast: 1
107
+
84
108
  # DEPRECATED: Coverage filtering is deprecated and will be removed
85
109
  # coverage: true
86
110
  YAML
@@ -88,6 +112,17 @@ module Evilution
88
112
 
89
113
  private
90
114
 
115
+ def validate_fail_fast(value)
116
+ return nil if value.nil?
117
+
118
+ value = Integer(value)
119
+ raise ConfigError, "fail_fast must be a positive integer, got #{value}" unless value >= 1
120
+
121
+ value
122
+ rescue ::ArgumentError, ::TypeError
123
+ raise ConfigError, "fail_fast must be a positive integer, got #{value.inspect}"
124
+ end
125
+
91
126
  def warn_removed_options(merged, file_options)
92
127
  if merged.key?(:jobs)
93
128
  warn("Warning: 'jobs' option is no longer supported and will be ignored. " \
@@ -106,14 +141,7 @@ module Evilution
106
141
  end
107
142
 
108
143
  def load_config_file
109
- CONFIG_FILES.each do |path|
110
- next unless File.exist?(path)
111
-
112
- data = YAML.safe_load_file(path, symbolize_names: true)
113
- return data.is_a?(Hash) ? data : {}
114
- end
115
-
116
- {}
144
+ self.class.file_options
117
145
  end
118
146
  end
119
147
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+
5
+ module Evilution
6
+ module Git
7
+ class ChangedFiles
8
+ MAIN_BRANCHES = %w[main master origin/main origin/master].freeze
9
+ SOURCE_PREFIXES = %w[lib/ app/].freeze
10
+
11
+ def call
12
+ main_branch = detect_main_branch
13
+ merge_base = run_git("merge-base", "HEAD", main_branch)
14
+ diff_output = run_git("diff", "--name-only", "--diff-filter=ACMR", "#{merge_base}..HEAD")
15
+
16
+ files = diff_output.split("\n").select { |f| ruby_source_file?(f) }
17
+ raise Error, "no changed Ruby files found since merge base with #{main_branch}" if files.empty?
18
+
19
+ files
20
+ end
21
+
22
+ private
23
+
24
+ def detect_main_branch
25
+ MAIN_BRANCHES.each do |branch|
26
+ return branch if branch_exists?(branch)
27
+ end
28
+
29
+ raise Error, "could not detect main branch (tried #{MAIN_BRANCHES.join(", ")})"
30
+ end
31
+
32
+ def branch_exists?(name)
33
+ run_git("rev-parse", "--verify", name)
34
+ true
35
+ rescue Error => e
36
+ raise if e.message.include?("not a git repository")
37
+
38
+ false
39
+ end
40
+
41
+ def ruby_source_file?(path)
42
+ path.end_with?(".rb") && SOURCE_PREFIXES.any? { |prefix| path.start_with?(prefix) }
43
+ end
44
+
45
+ def run_git(*args)
46
+ output = `git #{args.join(" ")} 2>&1`.strip
47
+ raise Error, "not a git repository" if output.include?("not a git repository")
48
+ raise Error, "git command failed: git #{args.join(" ")}: #{output}" unless $CHILD_STATUS.success?
49
+
50
+ output
51
+ end
52
+ end
53
+ end
54
+ end
@@ -100,12 +100,13 @@ module Evilution
100
100
  out = StringIO.new
101
101
  err = StringIO.new
102
102
  args = build_args(mutation)
103
+ command = "rspec #{args.join(" ")}"
103
104
 
104
105
  status = ::RSpec::Core::Runner.run(args, out, err)
105
106
 
106
- { passed: status.zero? }
107
+ { passed: status.zero?, test_command: command }
107
108
  rescue StandardError => e
108
- { passed: false, error: e.message }
109
+ { passed: false, error: e.message, test_command: command }
109
110
  end
110
111
 
111
112
  def build_args(_mutation)
@@ -91,7 +91,8 @@ module Evilution
91
91
  Result::MutationResult.new(
92
92
  mutation: mutation,
93
93
  status: status,
94
- duration: duration
94
+ duration: duration,
95
+ test_command: result[:test_command]
95
96
  )
96
97
  end
97
98
  end
@@ -23,6 +23,7 @@ module Evilution
23
23
  end
24
24
 
25
25
  lines << ""
26
+ lines << "[TRUNCATED] Stopped early due to --fail-fast" if summary.truncated?
26
27
  lines << result_line(summary)
27
28
 
28
29
  lines.join("\n")
@@ -30,7 +30,7 @@ module Evilution
30
30
  end
31
31
 
32
32
  def build_summary(summary)
33
- {
33
+ data = {
34
34
  total: summary.total,
35
35
  killed: summary.killed,
36
36
  survived: summary.survived,
@@ -39,6 +39,8 @@ module Evilution
39
39
  score: summary.score.round(4),
40
40
  duration: summary.duration.round(4)
41
41
  }
42
+ data[:truncated] = true if summary.truncated?
43
+ data
42
44
  end
43
45
 
44
46
  def build_mutation_detail(result)
@@ -52,6 +54,7 @@ module Evilution
52
54
  diff: mutation.diff
53
55
  }
54
56
  detail[:suggestion] = @suggestion.suggestion_for(mutation) if result.status == :survived
57
+ detail[:test_command] = result.test_command if result.test_command
55
58
  detail
56
59
  end
57
60
  end
@@ -5,15 +5,16 @@ module Evilution
5
5
  class MutationResult
6
6
  STATUSES = %i[killed survived timeout error].freeze
7
7
 
8
- attr_reader :mutation, :status, :duration, :killing_test
8
+ attr_reader :mutation, :status, :duration, :killing_test, :test_command
9
9
 
10
- def initialize(mutation:, status:, duration: 0.0, killing_test: nil)
10
+ def initialize(mutation:, status:, duration: 0.0, killing_test: nil, test_command: nil)
11
11
  raise ArgumentError, "invalid status: #{status}" unless STATUSES.include?(status)
12
12
 
13
13
  @mutation = mutation
14
14
  @status = status
15
15
  @duration = duration
16
16
  @killing_test = killing_test
17
+ @test_command = test_command
17
18
  freeze
18
19
  end
19
20
 
@@ -5,12 +5,17 @@ module Evilution
5
5
  class Summary
6
6
  attr_reader :results, :duration
7
7
 
8
- def initialize(results:, duration: 0.0)
8
+ def initialize(results:, duration: 0.0, truncated: false)
9
9
  @results = results
10
10
  @duration = duration
11
+ @truncated = truncated
11
12
  freeze
12
13
  end
13
14
 
15
+ def truncated?
16
+ @truncated
17
+ end
18
+
14
19
  def total
15
20
  results.length
16
21
  end
@@ -10,6 +10,7 @@ require_relative "reporter/cli"
10
10
  require_relative "reporter/suggestion"
11
11
  require_relative "diff/parser"
12
12
  require_relative "diff/file_filter"
13
+ require_relative "git/changed_files"
13
14
  require_relative "result/mutation_result"
14
15
  require_relative "result/summary"
15
16
 
@@ -32,10 +33,10 @@ module Evilution
32
33
  subjects = filter_by_line_ranges(subjects) if config.line_ranges?
33
34
  subjects = filter_by_diff(subjects) if config.diff?
34
35
  mutations = generate_mutations(subjects)
35
- results = run_mutations(mutations)
36
+ results, truncated = run_mutations(mutations)
36
37
  duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
37
38
 
38
- summary = Result::Summary.new(results: results, duration: duration)
39
+ summary = Result::Summary.new(results: results, duration: duration, truncated: truncated)
39
40
  output_report(summary)
40
41
 
41
42
  summary
@@ -46,7 +47,14 @@ module Evilution
46
47
  attr_reader :parser, :registry, :isolator
47
48
 
48
49
  def parse_subjects
49
- config.target_files.flat_map { |file| parser.call(file) }
50
+ files = resolve_target_files
51
+ files.flat_map { |file| parser.call(file) }
52
+ end
53
+
54
+ def resolve_target_files
55
+ return config.target_files unless config.target_files.empty?
56
+
57
+ Git::ChangedFiles.new.call
50
58
  end
51
59
 
52
60
  def filter_by_target(subjects)
@@ -79,15 +87,27 @@ module Evilution
79
87
 
80
88
  def run_mutations(mutations)
81
89
  integration = build_integration
90
+ results = []
91
+ survived_count = 0
92
+ truncated = false
82
93
 
83
- mutations.map do |mutation|
94
+ mutations.each_with_index do |mutation, index|
84
95
  test_command = ->(m) { integration.call(m) }
85
- isolator.call(
96
+ result = isolator.call(
86
97
  mutation: mutation,
87
98
  test_command: test_command,
88
99
  timeout: config.timeout
89
100
  )
101
+ results << result
102
+ survived_count += 1 if result.survived?
103
+
104
+ if config.fail_fast? && survived_count >= config.fail_fast && index < mutations.length - 1
105
+ truncated = true
106
+ break
107
+ end
90
108
  end
109
+
110
+ [results, truncated]
91
111
  end
92
112
 
93
113
  def build_integration
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution
4
+ class SpecResolver
5
+ STRIPPABLE_PREFIXES = %w[lib/ app/].freeze
6
+
7
+ def call(source_path)
8
+ return nil if source_path.nil? || source_path.empty?
9
+
10
+ normalized = normalize_path(source_path)
11
+ candidates = candidate_spec_paths(normalized)
12
+ candidates.find { |path| File.exist?(path) }
13
+ end
14
+
15
+ def resolve_all(source_paths)
16
+ Array(source_paths).filter_map { |path| call(path) }.uniq
17
+ end
18
+
19
+ private
20
+
21
+ def normalize_path(path)
22
+ path = path.delete_prefix("./")
23
+ path = path.delete_prefix("#{Dir.pwd}/") if path.start_with?("/")
24
+ path
25
+ end
26
+
27
+ def candidate_spec_paths(source_path)
28
+ base = source_path.sub(/\.rb\z/, "_spec.rb")
29
+ prefix = STRIPPABLE_PREFIXES.find { |p| source_path.start_with?(p) }
30
+
31
+ if prefix
32
+ stripped = "spec/#{base.delete_prefix(prefix)}"
33
+ kept = "spec/#{base}"
34
+ [stripped, kept]
35
+ else
36
+ ["spec/#{base}"]
37
+ end
38
+ end
39
+ end
40
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Evilution
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/evilution.rb CHANGED
@@ -29,6 +29,7 @@ require_relative "evilution/mutator/registry"
29
29
  require_relative "evilution/isolation/fork"
30
30
  require_relative "evilution/diff/parser"
31
31
  require_relative "evilution/diff/file_filter"
32
+ require_relative "evilution/git/changed_files"
32
33
  require_relative "evilution/integration/base"
33
34
  require_relative "evilution/integration/rspec"
34
35
  require_relative "evilution/result/mutation_result"
@@ -38,11 +39,20 @@ require_relative "evilution/reporter/cli"
38
39
  require_relative "evilution/reporter/suggestion"
39
40
  require_relative "evilution/coverage/collector"
40
41
  require_relative "evilution/coverage/test_map"
42
+ require_relative "evilution/spec_resolver"
41
43
  require_relative "evilution/cli"
42
44
  require_relative "evilution/runner"
43
45
 
44
46
  module Evilution
45
- class Error < StandardError; end
47
+ class Error < StandardError
48
+ attr_reader :file
49
+
50
+ def initialize(message = nil, file: nil)
51
+ super(message)
52
+ @file = file
53
+ end
54
+ end
55
+
46
56
  class ConfigError < Error; end
47
57
  class ParseError < Error; end
48
58
  class IsolationError < Error; end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: evilution
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Denis Kiselev
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-03-12 00:00:00.000000000 Z
11
+ date: 2026-03-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: diff-lcs
@@ -66,6 +66,7 @@ files:
66
66
  - lib/evilution/coverage/test_map.rb
67
67
  - lib/evilution/diff/file_filter.rb
68
68
  - lib/evilution/diff/parser.rb
69
+ - lib/evilution/git/changed_files.rb
69
70
  - lib/evilution/integration/base.rb
70
71
  - lib/evilution/integration/rspec.rb
71
72
  - lib/evilution/isolation/fork.rb
@@ -96,6 +97,7 @@ files:
96
97
  - lib/evilution/result/mutation_result.rb
97
98
  - lib/evilution/result/summary.rb
98
99
  - lib/evilution/runner.rb
100
+ - lib/evilution/spec_resolver.rb
99
101
  - lib/evilution/subject.rb
100
102
  - lib/evilution/version.rb
101
103
  - sig/evilution.rbs