evilution 0.3.0 → 0.5.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: 02de08018abc9539f569f074024674e60264f525d5693de8931bfc4e61dbe0fe
4
+ data.tar.gz: 3aab82bebf9a9f0d0732260e7852f72854b6aa774f548eff764127833af27445
5
5
  SHA512:
6
- metadata.gz: 38954ff26a4f48a050ca7a5d4270307157f7482836718152e03d17642df566110144c2773a5ce62ea8877ed2bdc97e6b2ccc33d49e9521e2074ad7cfc2b46b1e
7
- data.tar.gz: c4a4bcbf1f59e2f8ff01138bf690132dcb183330009340b76b4d8db1d8807411c04dcb8f3a52820857b9563158a49147903a7274e1395b0f4f76047f239d625e
6
+ metadata.gz: a3051426f0e7469408fc04850369a7df0022119b30a2cb56ea85a61e61caf239ec6e8b33bb59870c960c85492e2b32dca8a95015cb77778393038dfc4478d5bc
7
+ data.tar.gz: 252e15324dc314484fa3c136d25239856a39916da3af47fb5f5a399a5048804bdecbab54ac11f8c650a31033d485dc75f1c23132b2dddb6e8bdae59c31f5a6ec
@@ -1 +1 @@
1
- 1773300459
1
+ 1773589314
data/.beads/issues.jsonl CHANGED
@@ -13,7 +13,7 @@
13
13
  {"id":"EV-15","title":"Add line-range targeting (file:line-line syntax)","description":"Parse line-range syntax in CLI, store in Config, filter subjects in Runner. GH #29.","status":"closed","priority":2,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-08T00:36:29.164188342+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-08T00:38:02.739833414+07:00","closed_at":"2026-03-08T00:38:02.739833414+07:00","close_reason":"Implemented line-range targeting in CLI, Config, and Runner with full test coverage"}
14
14
  {"id":"EV-16","title":"Remove file-discovery logic from Integration::RSpec (GH #33)","description":"Integration::RSpec has detect_test_files, spec_file_candidates, and fallback_spec_dir that guess which specs to run. With precise targeting, agents can pass spec files directly. Simplify or remove this guessing logic, possibly adding a --spec flag.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-08T19:17:27.268579626+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-09T18:13:46.899195957+07:00","closed_at":"2026-03-09T18:13:46.899195957+07:00","close_reason":"Completed via PR #38 (tracked as EV-17)"}
15
15
  {"id":"EV-17","title":"Remove file-discovery logic from Integration::RSpec","description":"Add --spec flag, simplify RSpec integration by removing detect_test_files heuristics. GH #33","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-09T00:41:29.880486333+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-09T00:43:21.229172712+07:00","closed_at":"2026-03-09T00:43:21.229172712+07:00","close_reason":"Removed ~60 lines of file-discovery logic from Integration::RSpec, added --spec CLI flag, spec_files config, and wired through Runner"}
16
- {"id":"EV-18","title":"Add method-name targeting (Class#method) (GH #30)","description":"Allow specifying a fully-qualified method name: evilution run Foo::Bar#calculate. Resolve name to file and line range, then mutate only that method. Lower priority since it requires name-to-file resolution which can be ambiguous with reopened classes or metaprogramming. Update README, --help, and CHANGELOG.","status":"in_progress","priority":3,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-09T23:49:31.827723147+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-10T00:05:22.381962628+07:00"}
16
+ {"id":"EV-18","title":"Add method-name targeting (Class#method) (GH #30)","description":"Allow specifying a fully-qualified method name: evilution run Foo::Bar#calculate. Resolve name to file and line range, then mutate only that method. Lower priority since it requires name-to-file resolution which can be ambiguous with reopened classes or metaprogramming. Update README, --help, and CHANGELOG.","status":"closed","priority":3,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-09T23:49:31.827723147+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-16T10:11:01.311634237+07:00","closed_at":"2026-03-16T10:11:01.311634237+07:00","close_reason":"Already merged and released in v0.4.0"}
17
17
  {"id":"EV-19","title":"Add method-name targeting (--target flag)","description":"Users can target a specific method for mutation testing via --target METHOD flag. Matches against Subject.name.","status":"closed","priority":2,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-10T00:09:57.091870734+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-10T00:10:02.637447023+07:00","closed_at":"2026-03-10T00:10:02.637447023+07:00","close_reason":"Implemented --target flag in CLI, Config.target? predicate, Runner.filter_by_target, README docs, and specs"}
18
18
  {"id":"EV-2","title":"Phase 1: Foundation — End-to-End Single Mutation","description":"Build the core pipeline: parse Ruby with Prism, generate mutations, fork-based isolation, RSpec integration, JSON reporting. Milestone: Runner.new(files: ['lib/user.rb']).call produces JSON output.","status":"closed","priority":2,"issue_type":"epic","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:04:58.737191467+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T11:02:00.342745637+07:00","closed_at":"2026-03-02T11:02:00.342745637+07:00","close_reason":"Phase 1 Foundation complete: Config, AST::Parser, Subject, SourceSurgeon, Mutation, Mutator::Base+Registry, ComparisonReplacement, Isolation::Fork, Integration::RSpec, Result objects, Reporter::JSON, Runner — all 13 tasks done"}
19
19
  {"id":"EV-2.1","title":"Implement Evilution::Config","description":"Immutable configuration value object. Fields: target_files, jobs (default: Etc.nprocessors), timeout (default: 10s), format (:json/:text), diff_base (nil), min_score (0.0), integration (:rspec), config_file path. Merge from defaults + YAML + CLI flags. File: lib/evilution/config.rb + spec.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:05:50.275297792+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T10:43:14.688620834+07:00","closed_at":"2026-03-02T10:43:14.688620834+07:00","close_reason":"Closed","dependencies":[{"issue_id":"EV-2.1","depends_on_id":"EV-2","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]}
@@ -30,12 +30,12 @@
30
30
  {"id":"EV-2.8","title":"Implement Evilution::Result::MutationResult and Summary","description":"MutationResult: value object with fields: mutation, status (:killed/:survived/:timeout/:error), duration, killing_test (optional). Summary: aggregates MutationResult array into total/killed/survived/timed_out/errors/score/duration. Files: lib/evilution/result/mutation_result.rb, lib/evilution/result/summary.rb + specs.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:05:51.022249568+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T10:51:08.482621898+07:00","closed_at":"2026-03-02T10:51:08.482621898+07:00","close_reason":"Closed","dependencies":[{"issue_id":"EV-2.8","depends_on_id":"EV-2","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]}
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
- {"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"}
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"}
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"}
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":"closed","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-16T14:49:23.063583958+07:00","closed_at":"2026-03-16T14:49:23.063583958+07:00","close_reason":"All children complete: fail-fast, per-mutation spec targeting","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":"closed","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-16T10:11:01.311500554+07:00","closed_at":"2026-03-16T10:11:01.311500554+07:00","close_reason":"Already merged and released in v0.4.0"}
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":"closed","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-16T14:49:13.616876819+07:00","closed_at":"2026-03-16T14:49:13.616876819+07:00","close_reason":"Fixed and merged","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":"closed","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-16T11:15:24.900944562+07:00","closed_at":"2026-03-16T11:15:24.900944562+07:00","close_reason":"All children complete: structured errors, test_command in JSON, noise suppression","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
+ {"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":"closed","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-16T10:11:01.311631114+07:00","closed_at":"2026-03-16T10:11:01.311631114+07:00","close_reason":"Already merged and released in v0.4.0"}
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"}
41
41
  {"id":"EV-29","title":"Add --stdin flag to accept file list from stdin","description":"Add a --stdin flag that reads target file paths (one per line) from stdin. Enables workflows like: git diff --name-only | evilution run --stdin --format json. Each line can include line-range syntax (e.g. lib/foo.rb:15-30).","status":"open","priority":3,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-10T06:17:46.306306092+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-10T06:17:46.306306092+07:00"}
@@ -63,9 +63,9 @@
63
63
  {"id":"EV-3.9","title":"Implement HashLiteral operator","description":"Targets HashNode with pairs. Mutation: {k: v} -> {}. File: lib/evilution/mutator/operator/hash_literal.rb + spec.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:06:13.840779748+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T11:10:56.397570989+07:00","closed_at":"2026-03-02T11:10:56.397570989+07:00","close_reason":"All 3 operators implemented with passing specs","dependencies":[{"issue_id":"EV-3.9","depends_on_id":"EV-3","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-3.9","depends_on_id":"EV-2.6","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
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
- {"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"}
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":"closed","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-16T10:11:01.311639679+07:00","closed_at":"2026-03-16T10:11:01.311639679+07:00","close_reason":"Already merged and released in v0.4.0","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":"closed","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-16T10:11:01.311622041+07:00","closed_at":"2026-03-16T10:11:01.311622041+07:00","close_reason":"Already merged and released in v0.4.0"}
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":"closed","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-16T10:11:01.311627178+07:00","closed_at":"2026-03-16T10:11:01.311627178+07:00","close_reason":"Already merged and released in v0.4.0"}
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,11 @@
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":"closed","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-16T11:15:15.02691418+07:00","closed_at":"2026-03-16T11:15:15.02691418+07:00","close_reason":"Fixed and merged via PR #86"}
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":"closed","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-16T11:38:40.674664201+07:00","closed_at":"2026-03-16T11:38:40.674664201+07:00","close_reason":"Fixed and merged via PR #87"}
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":"closed","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-16T10:31:01.045381336+07:00","closed_at":"2026-03-16T10:31:01.045381336+07:00","close_reason":"Fixed and merged"}
88
+ {"id":"EV-44","title":"Re-introduce parallel execution","description":"Bring back parallel mutation execution. Consider whether two-level fork model is still right, temp-file isolation needs, and auto-detecting when parallelism is worthwhile. GH #35.","status":"closed","priority":2,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-16T10:15:42.050318104+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-16T16:26:30.341909415+07:00","closed_at":"2026-03-16T16:26:30.341909415+07:00","close_reason":"PR #90 merged — process-based parallel pool with --jobs flag"}
84
89
  {"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
90
  {"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
91
  {"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"}]}
data/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.0] - 2026-03-16
4
+
5
+ ### Added
6
+
7
+ - **Parallel execution** (`--jobs N` / `-j N`) — re-introduces parallel mutation execution using a process-based pool; each mutation runs in its own fork-isolated child process; fail-fast is checked between batches
8
+ - **Per-mutation spec targeting** — automatically resolves the matching spec file for each mutated source file using convention-based resolution; falls back to the full suite if no match; `--spec` flag overrides auto-detection
9
+ - **Progress indicator** — prints `mutation 3/19 killed` progress to stderr during text-mode runs so long-running sessions no longer appear stuck; only shown when stderr is a TTY, suppressed in quiet and JSON modes
10
+
11
+ ### Fixed
12
+
13
+ - **`--version` flag** — now correctly outputs the gem version instead of "version unknown"
14
+ - **RSpec noise suppression** — child process stdout/stderr is redirected to `/dev/null` so RSpec warnings no longer corrupt JSON output or flood the terminal
15
+
16
+ ## [0.4.0] - 2026-03-16
17
+
18
+ ### Added
19
+
20
+ - **`--fail-fast` flag** — stop after N surviving mutants (`--fail-fast`, `--fail-fast=3`, `--fail-fast 5`); defaults to 1 when given without a value
21
+ - **Structured JSON error responses** — errors in `--format json` mode now output structured JSON with `type`, `message`, and optional `file` fields
22
+ - **Convention-based spec file resolution** — automatically maps source files to their spec counterparts (`lib/` → `spec/`, `app/` → `spec/`)
23
+ - **`test_command` in mutation result JSON** — each mutation result now includes the RSpec command used, for easier debugging
24
+ - **Auto-detect changed files from git merge base** — when no explicit files are given, Evilution automatically finds changed `.rb` files under `lib/` and `app/` since the merge base with `main`/`master` (including `origin/` remotes for CI)
25
+
26
+ ### Changed
27
+
28
+ - Error classes (`ConfigError`, `ParseError`) now support a `file:` keyword for richer error context
29
+
30
+ ## [0.3.0] - 2026-03-13
31
+
32
+ ### Added
33
+
34
+ - **Sandbox-based temp directory cleanup** — leaked temp directories from timed-out children are now reliably cleaned up
35
+ - **Graceful timeout handling** — sends SIGTERM with a grace period before SIGKILL on child timeout
36
+
37
+ ### Changed
38
+
39
+ - Default per-mutation timeout increased from 10s to 30s
40
+ - Parent process now restores the original source file after each mutation (defense-in-depth)
41
+
3
42
  ## [0.2.0] - 2026-03-10
4
43
 
5
44
  ### Added
@@ -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,17 +46,23 @@ 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
52
53
  arg = argv[i]
53
- if %w[--jobs -j].include?(arg)
54
- warn("Warning: --jobs is no longer supported and will be ignored.")
54
+ if arg == "--fail-fast"
55
55
  next_arg = argv[i + 1]
56
- i += next_arg&.match?(/\A-?\d+\z/) ? 2 : 1
57
- elsif arg.start_with?("--jobs=") || arg.match?(/\A-j-?\d+\z/)
58
- warn("Warning: --jobs is no longer supported and will be ignored.")
56
+
57
+ if next_arg && next_arg.match?(/\A-?\d+\z/)
58
+ @options[:fail_fast] = next_arg
59
+ i += 2
60
+ else
61
+ result << arg
62
+ i += 1
63
+ end
64
+ elsif arg.start_with?("--fail-fast=")
65
+ @options[:fail_fast] = arg.delete_prefix("--fail-fast=")
59
66
  i += 1
60
67
  else
61
68
  result << arg
@@ -68,6 +75,7 @@ module Evilution
68
75
  def build_option_parser
69
76
  OptionParser.new do |opts|
70
77
  opts.banner = "Usage: evilution [command] [options] [files...]"
78
+ opts.version = VERSION
71
79
  add_separators(opts)
72
80
  add_options(opts)
73
81
  end
@@ -83,6 +91,7 @@ module Evilution
83
91
  end
84
92
 
85
93
  def add_options(opts)
94
+ opts.on("-j", "--jobs N", Integer, "Number of parallel workers (default: 1)") { |n| @options[:jobs] = n }
86
95
  opts.on("-t", "--timeout N", Integer, "Per-mutation timeout in seconds") { |n| @options[:timeout] = n }
87
96
  opts.on("-f", "--format FORMAT", "Output format: text, json") { |f| @options[:format] = f.to_sym }
88
97
  opts.on("--diff BASE", "DEPRECATED: Use line-range targeting instead") do |b|
@@ -97,6 +106,8 @@ module Evilution
97
106
  warn("Warning: --no-coverage is deprecated, currently has no effect, and will be removed in a future version.")
98
107
  @options[:coverage] = false
99
108
  end
109
+ opts.on("--fail-fast", "Stop after N surviving mutants " \
110
+ "(default: disabled; if provided without N, uses 1; use --fail-fast=N)") { @options[:fail_fast] ||= 1 }
100
111
  opts.on("-v", "--verbose", "Verbose output") { @options[:verbose] = true }
101
112
  opts.on("-q", "--quiet", "Suppress output") { @options[:quiet] = true }
102
113
  end
@@ -141,13 +152,37 @@ module Evilution
141
152
  end
142
153
 
143
154
  def run_mutations
155
+ file_options = Config.file_options
144
156
  config = Config.new(**@options, target_files: @files, line_ranges: @line_ranges)
145
157
  runner = Runner.new(config: config)
146
158
  summary = runner.call
147
159
  summary.success?(min_score: config.min_score) ? 0 : 1
148
160
  rescue Error => e
149
- warn("Error: #{e.message}")
161
+ if json_format?(config, file_options)
162
+ $stdout.puts(JSON.generate(error_payload(e)))
163
+ else
164
+ warn("Error: #{e.message}")
165
+ end
150
166
  2
151
167
  end
168
+
169
+ def json_format?(config, file_options)
170
+ return config.json? if config
171
+
172
+ format = @options[:format] || (file_options && file_options[:format])
173
+ format && format.to_sym == :json
174
+ end
175
+
176
+ def error_payload(error)
177
+ error_type = case error
178
+ when ConfigError then "config_error"
179
+ when ParseError then "parse_error"
180
+ else "runtime_error"
181
+ end
182
+
183
+ payload = { type: error_type, message: error.message }
184
+ payload[:file] = error.file if error.file
185
+ { error: payload }
186
+ end
152
187
  end
153
188
  end
@@ -16,13 +16,15 @@ module Evilution
16
16
  coverage: true,
17
17
  verbose: false,
18
18
  quiet: false,
19
+ jobs: 1,
20
+ fail_fast: nil,
19
21
  line_ranges: {},
20
22
  spec_files: []
21
23
  }.freeze
22
24
 
23
25
  attr_reader :target_files, :timeout, :format, :diff_base,
24
26
  :target, :min_score, :integration, :coverage, :verbose, :quiet,
25
- :line_ranges, :spec_files
27
+ :jobs, :fail_fast, :line_ranges, :spec_files
26
28
 
27
29
  def initialize(**options)
28
30
  file_options = options.delete(:skip_config_file) ? {} : load_config_file
@@ -38,6 +40,8 @@ module Evilution
38
40
  @coverage = merged[:coverage]
39
41
  @verbose = merged[:verbose]
40
42
  @quiet = merged[:quiet]
43
+ @jobs = validate_jobs(merged[:jobs])
44
+ @fail_fast = validate_fail_fast(merged[:fail_fast])
41
45
  @line_ranges = merged[:line_ranges] || {}
42
46
  @spec_files = Array(merged[:spec_files])
43
47
  freeze
@@ -63,6 +67,25 @@ module Evilution
63
67
  !target.nil?
64
68
  end
65
69
 
70
+ def fail_fast?
71
+ !fail_fast.nil?
72
+ end
73
+
74
+ def self.file_options
75
+ CONFIG_FILES.each do |path|
76
+ next unless File.exist?(path)
77
+
78
+ data = YAML.safe_load_file(path, symbolize_names: true)
79
+ return data.is_a?(Hash) ? data : {}
80
+ rescue Psych::SyntaxError, Psych::DisallowedClass => e
81
+ raise ConfigError.new("failed to parse config file #{path}: #{e.message}", file: path)
82
+ rescue SystemCallError => e
83
+ raise ConfigError.new("cannot read config file #{path}: #{e.message}", file: path)
84
+ end
85
+
86
+ {}
87
+ end
88
+
66
89
  # Generates a default config file template.
67
90
  def self.default_template
68
91
  <<~YAML
@@ -81,6 +104,12 @@ module Evilution
81
104
  # Test integration: rspec (default: rspec)
82
105
  # integration: rspec
83
106
 
107
+ # Number of parallel workers (default: 1)
108
+ # jobs: 1
109
+
110
+ # Stop after N surviving mutants (default: disabled)
111
+ # fail_fast: 1
112
+
84
113
  # DEPRECATED: Coverage filtering is deprecated and will be removed
85
114
  # coverage: true
86
115
  YAML
@@ -88,12 +117,29 @@ module Evilution
88
117
 
89
118
  private
90
119
 
91
- def warn_removed_options(merged, file_options)
92
- if merged.key?(:jobs)
93
- warn("Warning: 'jobs' option is no longer supported and will be ignored. " \
94
- "Remove it from your configuration or invocation.")
95
- end
120
+ def validate_fail_fast(value)
121
+ return nil if value.nil?
96
122
 
123
+ value = Integer(value)
124
+ raise ConfigError, "fail_fast must be a positive integer, got #{value}" unless value >= 1
125
+
126
+ value
127
+ rescue ::ArgumentError, ::TypeError
128
+ raise ConfigError, "fail_fast must be a positive integer, got #{value.inspect}"
129
+ end
130
+
131
+ def validate_jobs(value)
132
+ raise ConfigError, "jobs must be a positive integer, got #{value.inspect}" if value.is_a?(Float)
133
+
134
+ value = Integer(value)
135
+ raise ConfigError, "jobs must be a positive integer, got #{value}" unless value >= 1
136
+
137
+ value
138
+ rescue ::ArgumentError, ::TypeError
139
+ raise ConfigError, "jobs must be a positive integer, got #{value.inspect}"
140
+ end
141
+
142
+ def warn_removed_options(_merged, file_options)
97
143
  if file_options.key?(:coverage)
98
144
  warn("Warning: 'coverage' in config file is deprecated and ignored. " \
99
145
  "This option will be removed in a future version.")
@@ -106,14 +152,7 @@ module Evilution
106
152
  end
107
153
 
108
154
  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
- {}
155
+ self.class.file_options
117
156
  end
118
157
  end
119
158
  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
@@ -4,6 +4,7 @@ require "fileutils"
4
4
  require "stringio"
5
5
  require "tmpdir"
6
6
  require_relative "base"
7
+ require_relative "../spec_resolver"
7
8
 
8
9
  module Evilution
9
10
  module Integration
@@ -99,19 +100,28 @@ module Evilution
99
100
 
100
101
  out = StringIO.new
101
102
  err = StringIO.new
103
+ command = "rspec"
102
104
  args = build_args(mutation)
105
+ command = "rspec #{args.join(" ")}"
103
106
 
104
107
  status = ::RSpec::Core::Runner.run(args, out, err)
105
108
 
106
- { passed: status.zero? }
109
+ { passed: status.zero?, test_command: command }
107
110
  rescue StandardError => e
108
- { passed: false, error: e.message }
111
+ { passed: false, error: e.message, test_command: command }
109
112
  end
110
113
 
111
- def build_args(_mutation)
112
- files = test_files || ["spec"]
114
+ def build_args(mutation)
115
+ files = resolve_test_files(mutation)
113
116
  ["--format", "progress", "--no-color", "--order", "defined", *files]
114
117
  end
118
+
119
+ def resolve_test_files(mutation)
120
+ return test_files if test_files
121
+
122
+ resolved = SpecResolver.new.call(mutation.file_path)
123
+ resolved ? [resolved] : ["spec"]
124
+ end
115
125
  end
116
126
  end
117
127
  end
@@ -16,6 +16,7 @@ module Evilution
16
16
  pid = ::Process.fork do
17
17
  ENV["TMPDIR"] = sandbox_dir
18
18
  read_io.close
19
+ suppress_child_output
19
20
  result = execute_in_child(mutation, test_command)
20
21
  Marshal.dump(result, write_io)
21
22
  write_io.close
@@ -44,6 +45,11 @@ module Evilution
44
45
  warn("Warning: failed to restore #{mutation.file_path}: #{e.message}")
45
46
  end
46
47
 
48
+ def suppress_child_output
49
+ $stdout.reopen(File::NULL, "w")
50
+ $stderr.reopen(File::NULL, "w")
51
+ end
52
+
47
53
  def execute_in_child(mutation, test_command)
48
54
  test_command.call(mutation)
49
55
  rescue StandardError => e
@@ -91,7 +97,8 @@ module Evilution
91
97
  Result::MutationResult.new(
92
98
  mutation: mutation,
93
99
  status: status,
94
- duration: duration
100
+ duration: duration,
101
+ test_command: result[:test_command]
95
102
  )
96
103
  end
97
104
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution
4
+ module Parallel
5
+ class Pool
6
+ def initialize(size:)
7
+ raise ArgumentError, "pool size must be a positive integer, got #{size.inspect}" unless size.is_a?(Integer) && size >= 1
8
+
9
+ @size = size
10
+ end
11
+
12
+ def map(items, &block)
13
+ results = []
14
+
15
+ items.each_slice(@size) do |batch|
16
+ results.concat(run_batch(batch, &block))
17
+ end
18
+
19
+ results
20
+ end
21
+
22
+ private
23
+
24
+ def run_batch(items, &block)
25
+ entries = items.map do |item|
26
+ read_io, write_io = IO.pipe
27
+ pid = fork_worker(item, read_io, write_io, &block)
28
+ write_io.close
29
+ { pid: pid, read_io: read_io }
30
+ end
31
+
32
+ collect_results(entries)
33
+ end
34
+
35
+ def fork_worker(item, read_io, write_io, &block)
36
+ Process.fork do
37
+ read_io.close
38
+ result = block.call(item)
39
+ Marshal.dump(result, write_io)
40
+ rescue Exception => e # rubocop:disable Lint/RescueException
41
+ Marshal.dump(e, write_io)
42
+ ensure
43
+ write_io.close
44
+ exit!
45
+ end
46
+ end
47
+
48
+ def collect_results(entries)
49
+ entries.map do |entry|
50
+ data = entry[:read_io].read
51
+ entry[:read_io].close
52
+ Process.wait(entry[:pid])
53
+ raise Evilution::Error, "worker process failed with no result" if data.empty?
54
+
55
+ result = Marshal.load(data) # rubocop:disable Security/MarshalLoad
56
+ raise result if result.is_a?(Exception)
57
+
58
+ result
59
+ end
60
+ end
61
+ end
62
+ end
63
+ 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,8 +10,10 @@ 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"
16
+ require_relative "parallel/pool"
15
17
 
16
18
  module Evilution
17
19
  class Runner
@@ -32,10 +34,10 @@ module Evilution
32
34
  subjects = filter_by_line_ranges(subjects) if config.line_ranges?
33
35
  subjects = filter_by_diff(subjects) if config.diff?
34
36
  mutations = generate_mutations(subjects)
35
- results = run_mutations(mutations)
37
+ results, truncated = run_mutations(mutations)
36
38
  duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
37
39
 
38
- summary = Result::Summary.new(results: results, duration: duration)
40
+ summary = Result::Summary.new(results: results, duration: duration, truncated: truncated)
39
41
  output_report(summary)
40
42
 
41
43
  summary
@@ -46,7 +48,14 @@ module Evilution
46
48
  attr_reader :parser, :registry, :isolator
47
49
 
48
50
  def parse_subjects
49
- config.target_files.flat_map { |file| parser.call(file) }
51
+ files = resolve_target_files
52
+ files.flat_map { |file| parser.call(file) }
53
+ end
54
+
55
+ def resolve_target_files
56
+ return config.target_files unless config.target_files.empty?
57
+
58
+ Git::ChangedFiles.new.call
50
59
  end
51
60
 
52
61
  def filter_by_target(subjects)
@@ -78,16 +87,70 @@ module Evilution
78
87
  end
79
88
 
80
89
  def run_mutations(mutations)
90
+ if config.jobs > 1
91
+ run_mutations_parallel(mutations)
92
+ else
93
+ run_mutations_sequential(mutations)
94
+ end
95
+ end
96
+
97
+ def run_mutations_sequential(mutations)
81
98
  integration = build_integration
99
+ results = []
100
+ survived_count = 0
101
+ truncated = false
82
102
 
83
- mutations.map do |mutation|
103
+ mutations.each_with_index do |mutation, index|
84
104
  test_command = ->(m) { integration.call(m) }
85
- isolator.call(
105
+ result = isolator.call(
86
106
  mutation: mutation,
87
107
  test_command: test_command,
88
108
  timeout: config.timeout
89
109
  )
110
+ results << result
111
+ survived_count += 1 if result.survived?
112
+ log_progress(index + 1, mutations.length, result.status)
113
+
114
+ if config.fail_fast? && survived_count >= config.fail_fast && index < mutations.length - 1
115
+ truncated = true
116
+ break
117
+ end
118
+ end
119
+
120
+ [results, truncated]
121
+ end
122
+
123
+ def run_mutations_parallel(mutations)
124
+ integration = build_integration
125
+ pool = Parallel::Pool.new(size: config.jobs)
126
+ results = []
127
+ survived_count = 0
128
+ truncated = false
129
+ completed = 0
130
+
131
+ mutations.each_slice(config.jobs) do |batch|
132
+ break if truncated
133
+
134
+ batch_results = pool.map(batch) do |mutation|
135
+ test_command = ->(m) { integration.call(m) }
136
+ isolator.call(mutation: mutation, test_command: test_command, timeout: config.timeout)
137
+ end
138
+
139
+ batch_results.each do |result|
140
+ results << result
141
+ survived_count += 1 if result.survived?
142
+ completed += 1
143
+ log_progress(completed, mutations.length, result.status)
144
+ end
145
+
146
+ truncated = true if should_truncate?(survived_count, completed, mutations.length)
90
147
  end
148
+
149
+ [results, truncated]
150
+ end
151
+
152
+ def should_truncate?(survived_count, completed, total)
153
+ config.fail_fast? && survived_count >= config.fail_fast && completed < total
91
154
  end
92
155
 
93
156
  def build_integration
@@ -108,6 +171,12 @@ module Evilution
108
171
  $stdout.puts(output) unless config.quiet
109
172
  end
110
173
 
174
+ def log_progress(current, total, status)
175
+ return if config.quiet || !config.text? || !$stderr.tty?
176
+
177
+ $stderr.write("mutation #{current}/#{total} #{status}\n")
178
+ end
179
+
111
180
  def build_reporter
112
181
  case config.format
113
182
  when :json
@@ -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.5.0"
5
5
  end
data/lib/evilution.rb CHANGED
@@ -27,8 +27,10 @@ require_relative "evilution/mutator/operator/return_value_removal"
27
27
  require_relative "evilution/mutator/operator/collection_replacement"
28
28
  require_relative "evilution/mutator/registry"
29
29
  require_relative "evilution/isolation/fork"
30
+ require_relative "evilution/parallel/pool"
30
31
  require_relative "evilution/diff/parser"
31
32
  require_relative "evilution/diff/file_filter"
33
+ require_relative "evilution/git/changed_files"
32
34
  require_relative "evilution/integration/base"
33
35
  require_relative "evilution/integration/rspec"
34
36
  require_relative "evilution/result/mutation_result"
@@ -38,11 +40,20 @@ require_relative "evilution/reporter/cli"
38
40
  require_relative "evilution/reporter/suggestion"
39
41
  require_relative "evilution/coverage/collector"
40
42
  require_relative "evilution/coverage/test_map"
43
+ require_relative "evilution/spec_resolver"
41
44
  require_relative "evilution/cli"
42
45
  require_relative "evilution/runner"
43
46
 
44
47
  module Evilution
45
- class Error < StandardError; end
48
+ class Error < StandardError
49
+ attr_reader :file
50
+
51
+ def initialize(message = nil, file: nil)
52
+ super(message)
53
+ @file = file
54
+ end
55
+ end
56
+
46
57
  class ConfigError < Error; end
47
58
  class ParseError < Error; end
48
59
  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.5.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
@@ -90,12 +91,14 @@ files:
90
91
  - lib/evilution/mutator/operator/string_literal.rb
91
92
  - lib/evilution/mutator/operator/symbol_literal.rb
92
93
  - lib/evilution/mutator/registry.rb
94
+ - lib/evilution/parallel/pool.rb
93
95
  - lib/evilution/reporter/cli.rb
94
96
  - lib/evilution/reporter/json.rb
95
97
  - lib/evilution/reporter/suggestion.rb
96
98
  - lib/evilution/result/mutation_result.rb
97
99
  - lib/evilution/result/summary.rb
98
100
  - lib/evilution/runner.rb
101
+ - lib/evilution/spec_resolver.rb
99
102
  - lib/evilution/subject.rb
100
103
  - lib/evilution/version.rb
101
104
  - sig/evilution.rbs