evilution 0.1.0 → 0.3.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: 34fdc4c7cf9abac2c71dc0aa7d1aa4e4452e6237b8f1564c2dd92db2f5746cf5
4
- data.tar.gz: 6d7bb8ef692815bbc62d8ca2a3446ee642bdc2c1834856064a48e4c311256b63
3
+ metadata.gz: 646e7a441c39108d413a0b33cb28234b090e16c1614d343c40be84581fb45adf
4
+ data.tar.gz: fc65d99be854a51dc0354a77db413d90a8344611dc8d94a5e97ef61e91e764a9
5
5
  SHA512:
6
- metadata.gz: 9f08ab9b14a0b43e26a15e499d65ce819f5443efa77311ff45bccf3ae230b3f4fc61bc1d20cf4beb464b6c70ca82964a21a5f8d889edb30b5ab2f18f8af3ca44
7
- data.tar.gz: 84ba0567669d197cc56a2723a5e0fdeca84cf2b74085b5a273d9d7279211d156397ae520e7986fba81562393d25e21e3c99a1b6626cf472f79eeae11fede48ba
6
+ metadata.gz: 38954ff26a4f48a050ca7a5d4270307157f7482836718152e03d17642df566110144c2773a5ce62ea8877ed2bdc97e6b2ccc33d49e9521e2074ad7cfc2b46b1e
7
+ data.tar.gz: c4a4bcbf1f59e2f8ff01138bf690132dcb183330009340b76b4d8db1d8807411c04dcb8f3a52820857b9563158a49147903a7274e1395b0f4f76047f239d625e
@@ -1 +1 @@
1
- 1772686671
1
+ 1773300459
data/.beads/issues.jsonl CHANGED
@@ -5,10 +5,16 @@
5
5
  {"id":"EV-1.4","title":"Delete services.md prompt","description":"Remove .claude/prompts/services.md — not applicable to a gem project.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:05:17.649482526+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T10:41:19.226636126+07:00","closed_at":"2026-03-02T10:41:19.226636126+07:00","close_reason":"Closed","dependencies":[{"issue_id":"EV-1.4","depends_on_id":"EV-1","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]}
6
6
  {"id":"EV-1.5","title":"Update gemspec metadata","description":"Fill in summary, description, homepage, source_code_uri, changelog_uri. Add runtime dependency: diff-lcs (>= 1.5, < 3). Set allowed_push_host to rubygems.org.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:05:17.756448433+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T10:41:47.168270415+07:00","closed_at":"2026-03-02T10:41:47.168270415+07:00","close_reason":"Closed","dependencies":[{"issue_id":"EV-1.5","depends_on_id":"EV-1","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]}
7
7
  {"id":"EV-1.6","title":"Update .rubocop.yml for gem conventions","description":"Configure rubocop for gem: enable NewCops, set reasonable line length, exclude spec fixtures.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:05:17.855784448+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T10:42:03.456572349+07:00","closed_at":"2026-03-02T10:42:03.456572349+07:00","close_reason":"Closed","dependencies":[{"issue_id":"EV-1.6","depends_on_id":"EV-1","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]}
8
- {"id":"EV-10","title":"Publish v0.1.0 gem release","description":"Gemspec is ready. Tag v0.1.0, build the gem, and publish to RubyGems. Steps: verify gemspec metadata, run rake release (or manual gem build + gem push), create GitHub release with CHANGELOG notes.","status":"open","priority":3,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T16:21:53.571182801+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T16:21:53.571182801+07:00"}
8
+ {"id":"EV-10","title":"Publish v0.1.0 gem release","description":"Gemspec is ready. Tag v0.1.0, build the gem, and publish to RubyGems. Steps: verify gemspec metadata, run rake release (or manual gem build + gem push), create GitHub release with CHANGELOG notes.","status":"closed","priority":3,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T16:21:53.571182801+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-06T11:44:35.748868691+07:00","closed_at":"2026-03-06T11:44:35.748868691+07:00","close_reason":"Published to RubyGems"}
9
9
  {"id":"EV-11","title":"Wire coverage-based filtering into Runner","description":"Collector and TestMap exist and pass specs, but Runner never calls them. When config.coverage is true, Runner collects aggregate line-level coverage via Coverage::Collector, builds a Coverage::TestMap, and skips mutations on lines that no test exercises (marking them as survived with zero duration). Note: Ruby Coverage provides aggregate per-file line hit counts, not per-test-file tracking, so we filter out uncovered mutations rather than selecting specific test files.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-04T11:52:42.607616728+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-06T11:02:25.4848637+07:00","closed_at":"2026-03-05T14:53:24.894588088+07:00","close_reason":"Coverage-based test selection wired into Runner"}
10
10
  {"id":"EV-12","title":"Resolve suggestion gap","description":"The workflow section instructs agents to read a suggestion field from survived[], but the JSON reporter output does not include suggestion (lib/evilution/reporter/json.rb only emits operator/file/line/status/duration/diff). Either add suggestion to the JSON output/schema or update the workflow steps to match the actual report fields. See GitHub issue #12.","status":"closed","priority":2,"issue_type":"bug","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-05T12:33:23.674094791+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-05T13:05:48.785616141+07:00","closed_at":"2026-03-05T13:05:48.785616141+07:00","close_reason":"Wired Suggestion into JSON reporter for survived mutations, updated README schema, added specs"}
11
11
  {"id":"EV-13","title":"Enable true per-mutation isolation with temp file copies","description":"Replace direct file writes with temp-dir + $LOAD_PATH isolation so multiple workers can mutate the same file in parallel. Remove per-file grouping from Pool#partition.","status":"closed","priority":2,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-05T13:42:04.711580397+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-05T13:44:34.779951959+07:00","closed_at":"2026-03-05T13:44:34.779951959+07:00","close_reason":"Implemented temp file isolation via $LOAD_PATH and round-robin partition"}
12
+ {"id":"EV-14","title":"Add changelog_uri to gemspec metadata","description":"The published gem on RubyGems is missing the Changelog link. Add changelog_uri to spec.metadata in evilution.gemspec pointing to CHANGELOG.md on master.","status":"closed","priority":2,"issue_type":"bug","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-06T11:46:12.648668594+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-06T11:47:41.23691915+07:00","closed_at":"2026-03-06T11:47:41.23691915+07:00","close_reason":"Added changelog_uri to gemspec"}
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
+ {"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
+ {"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"}
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"}
12
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"}
13
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"}]}
14
20
  {"id":"EV-2.10","title":"Implement Evilution::Integration::Base and RSpec adapter","description":"Base: abstract adapter with interface #call(test_files) -> {passed: bool, example_count: int, failure_count: int}. RSpec: programmatic runner using RSpec::Core::Runner.run with StringIO for capture. Auto-detects spec/ directory. Files: lib/evilution/integration/base.rb, lib/evilution/integration/rspec.rb + specs.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:05:51.246990324+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T10:58:18.324768622+07:00","closed_at":"2026-03-02T10:58:18.324768622+07:00","close_reason":"Integration::Base and Integration::RSpec implemented with 8 passing specs","dependencies":[{"issue_id":"EV-2.10","depends_on_id":"EV-2","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]}
@@ -23,6 +29,16 @@
23
29
  {"id":"EV-2.7","title":"Implement ComparisonReplacement operator","description":"First mutation operator. Targets CallNode where name is :>, :<, :>=, :<=, :==, :!=. Replacements: > -> [>=, ==], < -> [<=, ==], >= -> [>, ==], <= -> [<, ==], == -> [!=], != -> [==]. Uses SourceSurgeon to apply text replacement at message_loc offsets. File: lib/evilution/mutator/operator/comparison_replacement.rb + spec.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:05:50.917501278+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T10:50:01.327985674+07:00","closed_at":"2026-03-02T10:50:01.327985674+07:00","close_reason":"Closed","dependencies":[{"issue_id":"EV-2.7","depends_on_id":"EV-2","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-2.7","depends_on_id":"EV-2.6","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-2.7","depends_on_id":"EV-2.4","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
24
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"}]}
25
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
+ {"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"}
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
+ {"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
+ {"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"}
26
42
  {"id":"EV-3","title":"Phase 2: Mutation Operators & CLI","description":"Implement remaining 17 mutation operators, build CLI with OptionParser, exe/evilution executable, human-readable reporter. Milestone: bundle exec evilution run lib/user.rb --format json","status":"closed","priority":2,"issue_type":"epic","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:05:00.492971295+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T11:21:32.168384165+07:00","closed_at":"2026-03-02T11:21:32.168384165+07:00","close_reason":"Phase 2 complete: all 18 operators, CLI, Reporter::CLI, Registry registration, executable","dependencies":[{"issue_id":"EV-3","depends_on_id":"EV-2","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
27
43
  {"id":"EV-3.1","title":"Implement ArithmeticReplacement operator","description":"Targets CallNode where name is :+, :-, :*, :/, :%, :**. Replacements: + <-> -, * <-> /, % -> /, ** -> *. File: lib/evilution/mutator/operator/arithmetic_replacement.rb + spec.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:06:13.025649082+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T11:06:10.922412304+07:00","closed_at":"2026-03-02T11:06:10.922412304+07:00","close_reason":"All 3 operators implemented with passing specs","dependencies":[{"issue_id":"EV-3.1","depends_on_id":"EV-3","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-3.1","depends_on_id":"EV-2.6","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
28
44
  {"id":"EV-3.10","title":"Implement SymbolLiteral operator","description":"Targets SymbolNode. Mutation: :foo -> :__evilution_mutated__. File: lib/evilution/mutator/operator/symbol_literal.rb + spec.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:06:13.935877816+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T11:13:36.541040613+07:00","closed_at":"2026-03-02T11:13:36.541040613+07:00","close_reason":"All 3 operators implemented with passing specs","dependencies":[{"issue_id":"EV-3.10","depends_on_id":"EV-3","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-3.10","depends_on_id":"EV-2.6","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
@@ -30,7 +46,7 @@
30
46
  {"id":"EV-3.12","title":"Implement ConditionalBranch operator","description":"Targets IfNode with both if and else branches. Mutations: replace entire if/else with if-branch only, replace with else-branch only. File: lib/evilution/mutator/operator/conditional_branch.rb + spec.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:06:14.138508575+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T11:13:36.541106251+07:00","closed_at":"2026-03-02T11:13:36.541106251+07:00","close_reason":"All 3 operators implemented with passing specs","dependencies":[{"issue_id":"EV-3.12","depends_on_id":"EV-3","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-3.12","depends_on_id":"EV-2.6","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
31
47
  {"id":"EV-3.13","title":"Implement StatementDeletion operator","description":"Targets StatementsNode bodies with 2+ statements. For each statement, produce a mutation that removes it. File: lib/evilution/mutator/operator/statement_deletion.rb + spec.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:06:14.240151154+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T11:16:08.954716442+07:00","closed_at":"2026-03-02T11:16:08.954716442+07:00","close_reason":"All 3 operators implemented with passing specs","dependencies":[{"issue_id":"EV-3.13","depends_on_id":"EV-3","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-3.13","depends_on_id":"EV-2.6","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
32
48
  {"id":"EV-3.14","title":"Implement MethodBodyReplacement operator","description":"Targets DefNode with body. Mutations: replace body with nil, replace with raise NotImplementedError. File: lib/evilution/mutator/operator/method_body_replacement.rb + spec.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:06:14.333934644+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T11:16:08.954887847+07:00","closed_at":"2026-03-02T11:16:08.954887847+07:00","close_reason":"All 3 operators implemented with passing specs","dependencies":[{"issue_id":"EV-3.14","depends_on_id":"EV-3","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-3.14","depends_on_id":"EV-2.6","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
33
- {"id":"EV-3.15","title":"Implement NegationInsertion operator","description":"Targets CallNode with predicate method names (ending in ?). Mutation: x.empty? -> !x.empty?. File: lib/evilution/mutator/operator/negation_insertion.rb + spec.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:06:14.438532333+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T11:16:08.95490096+07:00","closed_at":"2026-03-02T11:16:08.95490096+07:00","close_reason":"All 3 operators implemented with passing specs","dependencies":[{"issue_id":"EV-3.15","depends_on_id":"EV-3","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-3.15","depends_on_id":"EV-2.6","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
49
+ {"id":"EV-3.15","title":"Implement NegationInsertion operator","description":"Targets CallNode with predicate method names (ending in ?). Mutation: x.empty? -> !x.empty?. File: lib/evilution/mutator/operator/negation_insertion.rb + spec.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:06:14.438532333+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-06T16:11:55.50396051+07:00","closed_at":"2026-03-02T11:16:08.95490096+07:00","close_reason":"Commands now listed in --help output","dependencies":[{"issue_id":"EV-3.15","depends_on_id":"EV-3","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-3.15","depends_on_id":"EV-2.6","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
34
50
  {"id":"EV-3.16","title":"Implement ReturnValueRemoval operator","description":"Targets ReturnNode with arguments. Mutations: return x -> return, return x -> return nil. File: lib/evilution/mutator/operator/return_value_removal.rb + spec.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:06:14.541333631+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T11:18:14.396683739+07:00","closed_at":"2026-03-02T11:18:14.396683739+07:00","close_reason":"Both operators implemented with passing specs","dependencies":[{"issue_id":"EV-3.16","depends_on_id":"EV-3","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-3.16","depends_on_id":"EV-2.6","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
35
51
  {"id":"EV-3.17","title":"Implement CollectionReplacement operator","description":"Targets CallNode where name matches collection methods. Replacements: map->each, select<->reject, all?<->any?, first<->last, min<->max, flat_map->map. File: lib/evilution/mutator/operator/collection_replacement.rb + spec.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:06:14.635444378+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T11:18:14.396880048+07:00","closed_at":"2026-03-02T11:18:14.396880048+07:00","close_reason":"Both operators implemented with passing specs","dependencies":[{"issue_id":"EV-3.17","depends_on_id":"EV-3","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-3.17","depends_on_id":"EV-2.6","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
36
52
  {"id":"EV-3.18","title":"Implement Evilution::CLI","description":"OptionParser-based CLI. Commands: run [FILES...], version, init. Flags: --jobs/-j, --timeout/-t, --format/-f (json/text), --diff BASE, --target PATTERN, --min-score FLOAT, --no-coverage, --config FILE, --verbose/-v, --quiet/-q. Parses args into Config, delegates to Runner. File: lib/evilution/cli.rb + spec.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:06:26.540459363+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T11:20:50.084035782+07:00","closed_at":"2026-03-02T11:20:50.084035782+07:00","close_reason":"CLI, Reporter::CLI, and Registry registration all implemented","dependencies":[{"issue_id":"EV-3.18","depends_on_id":"EV-3","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-3.18","depends_on_id":"EV-2.1","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-3.18","depends_on_id":"EV-2.12","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
@@ -45,6 +61,16 @@
45
61
  {"id":"EV-3.7","title":"Implement StringLiteral operator","description":"Targets StringNode. Mutations: non-empty -> empty string, empty -> 'mutation'. File: lib/evilution/mutator/operator/string_literal.rb + spec.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:06:13.633726423+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T11:10:56.39748015+07:00","closed_at":"2026-03-02T11:10:56.39748015+07:00","close_reason":"All 3 operators implemented with passing specs","dependencies":[{"issue_id":"EV-3.7","depends_on_id":"EV-3","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-3.7","depends_on_id":"EV-2.6","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
46
62
  {"id":"EV-3.8","title":"Implement ArrayLiteral operator","description":"Targets ArrayNode with elements. Mutation: [a, b, c] -> []. File: lib/evilution/mutator/operator/array_literal.rb + spec.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:06:13.734617709+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T11:10:56.397565925+07:00","closed_at":"2026-03-02T11:10:56.397565925+07:00","close_reason":"All 3 operators implemented with passing specs","dependencies":[{"issue_id":"EV-3.8","depends_on_id":"EV-3","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-3.8","depends_on_id":"EV-2.6","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
47
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
+ {"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
+ {"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"}
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
+ {"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
+ {"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"}
72
+ {"id":"EV-38","title":"Use SIGTERM with grace period before SIGKILL on timeout","description":"Defense-in-depth for EV-35. Currently fork.rb:45 sends SIGKILL immediately on timeout. SIGKILL is untrappable so the child gets no chance to run ensure blocks. Fix: send SIGTERM first (trappable), wait a short grace period (1-2s), then SIGKILL if still alive. This lets the child's ensure block restore files naturally in most timeout cases.","status":"closed","priority":0,"issue_type":"bug","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-12T14:32:52.463953548+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-13T00:34:44.01894518+07:00","closed_at":"2026-03-13T00:34:44.01894518+07:00","close_reason":"Implemented SIGTERM with grace period before SIGKILL fallback"}
73
+ {"id":"EV-39","title":"Clean up leaked temp directories after child timeout (Strategy A)","description":"When Strategy A (temp dir, rspec.rb:44-49) is used and the child is killed on timeout, the temp directory is never cleaned up because the child's ensure block doesn't run. The parent should track and clean up any temp dirs created for mutation runs. Minor resource leak, not data corruption.","status":"closed","priority":0,"issue_type":"bug","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-12T14:32:54.466514555+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-13T00:34:31.932452641+07:00","closed_at":"2026-03-13T00:34:31.932452641+07:00","close_reason":"Implemented per-run sandbox TMPDIR for child processes, cleaned up by parent in ensure block"}
48
74
  {"id":"EV-4","title":"Phase 3: Performance Features","description":"Parallel execution pool, coverage-based mutation filtering, git diff-based targeting. Milestone: evilution run --diff HEAD~1 --jobs 4","status":"closed","priority":2,"issue_type":"epic","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:05:02.252877807+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-06T11:02:29.997677574+07:00","closed_at":"2026-03-02T11:54:32.782829837+07:00","close_reason":"Closed","dependencies":[{"issue_id":"EV-4","depends_on_id":"EV-3","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
49
75
  {"id":"EV-4.1","title":"Implement Evilution::Parallel::Pool","description":"Thread-based worker pool distributing fork workers. Initialize with job_count. Method: #run(mutations, test_command, timeout:) -> [MutationResult]. Uses Queue to distribute work to N threads, each thread calls Isolation::Fork. File: lib/evilution/parallel/pool.rb + spec.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:06:44.984077473+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T11:46:46.640997276+07:00","closed_at":"2026-03-02T11:46:46.640997276+07:00","close_reason":"Pool implementation and tests already exist and pass","dependencies":[{"issue_id":"EV-4.1","depends_on_id":"EV-4","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-4.1","depends_on_id":"EV-2.9","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
50
76
  {"id":"EV-4.2","title":"Implement Evilution::Parallel::Worker","description":"Individual worker abstraction managing fork lifecycle. Wraps Isolation::Fork with queue consumption loop. File: lib/evilution/parallel/worker.rb + spec.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:06:45.08570827+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T11:54:21.51846223+07:00","closed_at":"2026-03-02T11:54:21.51846223+07:00","close_reason":"Closed","dependencies":[{"issue_id":"EV-4.2","depends_on_id":"EV-4","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-4.2","depends_on_id":"EV-4.1","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
@@ -63,6 +89,6 @@
63
89
  {"id":"EV-5.5","title":"Write README.md","description":"Replace placeholder README with: gem description, installation, quick start, CLI usage, configuration, output formats (JSON schema), operator list, comparison with mutant, contributing guide, license.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:06:58.454635118+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T11:54:29.226280116+07:00","closed_at":"2026-03-02T11:54:29.226280116+07:00","close_reason":"Closed","dependencies":[{"issue_id":"EV-5.5","depends_on_id":"EV-5","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-5.5","depends_on_id":"EV-4.9","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-5.5","depends_on_id":"EV-5.1","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
64
90
  {"id":"EV-5.6","title":"Update CHANGELOG.md for v0.1.0","description":"Document all features in the initial release: operator list, RSpec integration, JSON/CLI output, parallel execution, coverage-based selection, diff-based targeting.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:06:58.571499415+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T11:54:29.22634981+07:00","closed_at":"2026-03-02T11:54:29.22634981+07:00","close_reason":"Closed","dependencies":[{"issue_id":"EV-5.6","depends_on_id":"EV-5","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-5.6","depends_on_id":"EV-5.5","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
65
91
  {"id":"EV-6","title":"Fix pool fork tests hanging in CI and WSL2","description":"The spec/evilution/parallel/pool_spec.rb fork-based tests hang on both WSL2 and GitHub Actions. CI currently excludes them via --exclude-pattern. Root cause is likely double-fork (Pool forks workers, each Worker uses Isolation::Fork which forks again) causing pipe/process management issues. Needs investigation and fix so pool tests run reliably in CI.","status":"closed","priority":1,"issue_type":"bug","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T16:21:43.62587758+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-05T12:31:51.831163433+07:00","closed_at":"2026-03-05T12:31:51.831163433+07:00","close_reason":"Closed"}
66
- {"id":"EV-7","title":"Enable true per-mutation isolation with temp file copies","description":"Currently all mutations for the same file go to one worker (PR #4 fix). This means single-file projects get no parallelism benefit. Implement temp file copy approach: each worker copies the source file to a temp location, mutates the copy, and runs tests against it. This enables safe parallel mutation of the same file across multiple workers.","status":"open","priority":2,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T16:21:46.820210376+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T16:21:46.820210376+07:00"}
92
+ {"id":"EV-7","title":"Enable true per-mutation isolation with temp file copies","description":"Currently all mutations for the same file go to one worker (PR #4 fix). This means single-file projects get no parallelism benefit. Implement temp file copy approach: each worker copies the source file to a temp location, mutates the copy, and runs tests against it. This enables safe parallel mutation of the same file across multiple workers.","status":"closed","priority":2,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T16:21:46.820210376+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-06T11:46:55.740292929+07:00","closed_at":"2026-03-06T11:46:55.740292929+07:00","close_reason":"Already merged in PR #20"}
67
93
  {"id":"EV-8","title":"Investigate RSpec state accumulation across mutation runs","description":"RSpec.reset is called between mutations but loaded spec files persist in memory. Long mutation runs may accumulate state from previously loaded specs, potentially causing false positives/negatives. Investigate whether RSpec::Core::Runner.run properly isolates between runs or if additional cleanup is needed.","status":"closed","priority":2,"issue_type":"bug","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T16:21:48.618669476+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-05T11:57:56.965910759+07:00","closed_at":"2026-03-05T11:57:56.965910759+07:00","close_reason":"Investigation complete: fork isolation already prevents state accumulation. Added documentation explaining the guarantee and tests verifying independent consecutive runs."}
68
94
  {"id":"EV-9","title":"Add multi-Ruby CI test matrix (3.2, 3.3, 4.0)","description":"CI currently only tests Ruby 4.0.1. Add a matrix strategy testing Ruby 3.2, 3.3, and 4.0 to ensure compatibility across supported Ruby versions. Prism ships with Ruby 3.3+ so 3.2 may need the prism gem as a dependency.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T16:21:51.239774764+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-05T12:31:51.83181612+07:00","closed_at":"2026-03-05T12:31:51.83181612+07:00","close_reason":"Closed"}
@@ -73,7 +73,6 @@ Evilution
73
73
  ::Coverage::TestMap # Source line → test file mapping
74
74
  ::Diff::Parser # Git diff output parser
75
75
  ::Diff::FileFilter # Filter subjects to changed code
76
- ::Parallel::Pool # Thread-based worker pool
77
76
  ::Result::MutationResult # Single mutation outcome
78
77
  ::Result::Summary # Aggregated results
79
78
  ::Reporter::JSON # Structured output for AI agents
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0] - 2026-03-10
4
+
5
+ ### Added
6
+
7
+ - **Line-range targeting** — scope mutations to exact lines: `lib/foo.rb:15-30`, `lib/foo.rb:15`, `lib/foo.rb:15-`
8
+ - **Method-name targeting** (`--target`) — mutate a single method by fully-qualified name (e.g. `Foo::Bar#calculate`)
9
+ - **Commands section** in `--help` output
10
+
11
+ ### Changed
12
+
13
+ - Added `changelog_uri` to gemspec metadata
14
+ - Added GitHub publish workflow
15
+
16
+ ### Deprecated
17
+
18
+ - **`--diff` flag** — use line-range targeting instead
19
+ - **Coverage-based filtering flags/config** (`--no-coverage` flag and `coverage` config key) — deprecated and now ignored; coverage-based filtering behavior has been removed from `Runner`
20
+
21
+ ### Removed
22
+
23
+ - **Parallel execution** (`--jobs` flag) — simplifies codebase for AI-agent-first design; will be reintroduced later
24
+ - **File-discovery logic** from `Integration::RSpec` — spec files are now passed explicitly or default to `spec/`
25
+
3
26
  ## [0.1.0] - 2026-03-02
4
27
 
5
28
  ### Added
data/README.md CHANGED
@@ -1,11 +1,13 @@
1
+ [![Gem Version](https://badge.fury.io/rb/evilution.svg)](https://badge.fury.io/rb/evilution)
2
+
1
3
  # Evilution — Mutation Testing for Ruby
2
4
 
3
5
  > **Purpose**: Validate test suite quality by injecting small code changes (mutations) and checking whether tests detect them. Surviving mutations indicate gaps in test coverage.
4
6
 
5
- **License**: MIT (free, no commercial restrictions)
6
- **Language**: Ruby >= 3.3
7
- **Parser**: Prism (Ruby's official AST parser, ships with Ruby 3.3+)
8
- **Test framework**: RSpec (currently the only supported integration)
7
+ * **License**: MIT (free, no commercial restrictions)
8
+ * **Language**: Ruby >= 3.3
9
+ * **Parser**: Prism (Ruby's official AST parser, ships with Ruby 3.3+)
10
+ * **Test framework**: RSpec (currently the only supported integration)
9
11
 
10
12
  ## Installation
11
13
 
@@ -37,12 +39,13 @@ evilution [command] [options] [files...]
37
39
 
38
40
  | Flag | Type | Default | Description |
39
41
  |-------------------------|---------|--------------|---------------------------------------------------|
40
- | `-j`, `--jobs N` | Integer | CPU cores | Parallel worker count. Use `1` for sequential. |
41
42
  | `-t`, `--timeout N` | Integer | 10 | Per-mutation timeout in seconds. |
42
43
  | `-f`, `--format FORMAT` | String | `text` | Output format: `text` or `json`. |
43
- | `--diff BASE` | String | _(none)_ | Git ref. Only mutate methods whose definition line changed since BASE. |
44
+ | `--target METHOD` | String | _(none)_ | Only mutate the named method (e.g. `Foo::Bar#calculate`). |
45
+ | `--diff BASE` | String | _(none)_ | **DEPRECATED**: Use line-range targeting instead. Git ref. Only mutate methods whose definition line changed since BASE. |
44
46
  | `--min-score FLOAT` | Float | 0.0 | Minimum mutation score (0.0–1.0) to pass. |
45
- | `--no-coverage` | Boolean | false | Reserved; currently has no effect. |
47
+ | `--spec FILES` | Array | _(none)_ | Spec files to run (comma-separated). Defaults to `spec/`. |
48
+ | `--no-coverage` | Boolean | false | **DEPRECATED, NO-OP**: Kept for backward compatibility. Will be removed. |
46
49
  | `-v`, `--verbose` | Boolean | false | Verbose output. |
47
50
  | `-q`, `--quiet` | Boolean | false | Suppress output. |
48
51
 
@@ -61,12 +64,10 @@ Generate default config: `bundle exec evilution init`
61
64
  Creates `.evilution.yml`:
62
65
 
63
66
  ```yaml
64
- # jobs: 4 # parallel workers
65
67
  # timeout: 10 # seconds per mutation
66
68
  # format: text # text | json
67
69
  # min_score: 0.0 # 0.0–1.0
68
70
  # integration: rspec # test framework
69
- # coverage: true # skip mutations on uncovered lines
70
71
  ```
71
72
 
72
73
  **Precedence**: CLI flags override `.evilution.yml` values.
@@ -137,20 +138,51 @@ Each operator name is stable and appears in JSON output under `survived[].operat
137
138
  ### 1. Full project scan
138
139
 
139
140
  ```bash
140
- bundle exec evilution run lib/ --format json --jobs 4 --min-score 0.8
141
+ bundle exec evilution run lib/ --format json --min-score 0.8
141
142
  ```
142
143
 
143
144
  Parse JSON output. Exit code 0 = pass, 1 = surviving mutants to address.
144
145
 
145
- ### 2. PR / diff-only scan (fast feedback)
146
+ ### 2. PR / changed-lines scan (fast feedback)
147
+
148
+ ```bash
149
+ bundle exec evilution run lib/foo.rb:15-30 lib/bar.rb:5-20 --format json --min-score 0.9
150
+ ```
151
+
152
+ Target the exact lines you changed for fast, focused mutation testing. See line-range syntax below.
153
+
154
+ > **Note**: `--diff BASE` is deprecated and will be removed in a future version. Prefer line-range targeting for new workflows.
155
+
156
+ ### 3. Line-range targeted scan (fastest)
157
+
158
+ ```bash
159
+ bundle exec evilution run lib/foo.rb:15-30 --format json
160
+ ```
161
+
162
+ Target exact lines you changed. Supports multiple syntaxes:
163
+
164
+ ```bash
165
+ evilution run lib/foo.rb:15-30 # lines 15 through 30
166
+ evilution run lib/foo.rb:15 # single line 15
167
+ evilution run lib/foo.rb:15- # from line 15 to end of file
168
+ evilution run lib/foo.rb # whole file (existing behavior)
169
+ ```
170
+
171
+ Methods whose body overlaps the requested range are included. Mix targeted and whole-file arguments freely:
172
+
173
+ ```bash
174
+ evilution run lib/foo.rb:15-30 lib/bar.rb --format json
175
+ ```
176
+
177
+ ### 4. Method-name targeted scan
146
178
 
147
179
  ```bash
148
- bundle exec evilution run lib/ --format json --diff main --min-score 0.9
180
+ bundle exec evilution run lib/foo.rb --target Foo::Bar#calculate --format json
149
181
  ```
150
182
 
151
- Mutates methods whose definition (starting) line is changed compared to `main` (the diff filter is based on the method’s first line, not any line in its body). Use this for incremental checks — it's fast and focused on newly added or moved methods and changed signatures.
183
+ Target a specific method by its fully-qualified name. Useful when you want to focus on a single method without knowing its exact line numbers.
152
184
 
153
- ### 3. Single-file targeted scan
185
+ ### 5. Single-file targeted scan
154
186
 
155
187
  ```bash
156
188
  bundle exec evilution run lib/specific_file.rb --format json
@@ -158,7 +190,7 @@ bundle exec evilution run lib/specific_file.rb --format json
158
190
 
159
191
  Use when you know which file was modified and want to verify its test coverage.
160
192
 
161
- ### 4. Fixing surviving mutants
193
+ ### 6. Fixing surviving mutants
162
194
 
163
195
  For each entry in `survived[]`:
164
196
  1. Read `file` at `line` to understand the code context
@@ -167,7 +199,7 @@ For each entry in `survived[]`:
167
199
  4. Write a test that would fail if the mutation were applied
168
200
  5. Re-run evilution on just that file to verify the mutant is now killed
169
201
 
170
- ### 5. CI gate
202
+ ### 7. CI gate
171
203
 
172
204
  ```bash
173
205
  bundle exec evilution run lib/ --format json --min-score 0.8 --quiet
data/lib/evilution/cli.rb CHANGED
@@ -11,7 +11,27 @@ module Evilution
11
11
  @options = {}
12
12
  @command = :run
13
13
  argv = argv.dup
14
+ argv = extract_command(argv)
15
+ argv = warn_removed_flags(argv)
16
+ raw_args = build_option_parser.parse!(argv)
17
+ @files, @line_ranges = parse_file_args(raw_args)
18
+ end
14
19
 
20
+ def call
21
+ case @command
22
+ when :version
23
+ $stdout.puts(VERSION)
24
+ 0
25
+ when :init
26
+ run_init
27
+ when :run
28
+ run_mutations
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def extract_command(argv)
15
35
  case argv.first
16
36
  when "version"
17
37
  @command = :version
@@ -22,37 +42,65 @@ module Evilution
22
42
  when "run"
23
43
  argv.shift
24
44
  end
45
+ argv
46
+ end
47
+
48
+ def warn_removed_flags(argv)
49
+ result = []
50
+ i = 0
51
+ while i < argv.length
52
+ arg = argv[i]
53
+ if %w[--jobs -j].include?(arg)
54
+ warn("Warning: --jobs is no longer supported and will be ignored.")
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.")
59
+ i += 1
60
+ else
61
+ result << arg
62
+ i += 1
63
+ end
64
+ end
65
+ result
66
+ end
25
67
 
26
- parser = OptionParser.new do |opts|
68
+ def build_option_parser
69
+ OptionParser.new do |opts|
27
70
  opts.banner = "Usage: evilution [command] [options] [files...]"
28
-
29
- opts.on("-j", "--jobs N", Integer, "Number of parallel workers") { |n| @options[:jobs] = n }
30
- opts.on("-t", "--timeout N", Integer, "Per-mutation timeout in seconds") { |n| @options[:timeout] = n }
31
- opts.on("-f", "--format FORMAT", "Output format: text, json") { |f| @options[:format] = f.to_sym }
32
- opts.on("--diff BASE", "Only mutate code changed since BASE") { |b| @options[:diff_base] = b }
33
- opts.on("--min-score FLOAT", Float, "Minimum mutation score to pass") { |s| @options[:min_score] = s }
34
- opts.on("--no-coverage", "Disable coverage-based filtering of uncovered mutations") { @options[:coverage] = false }
35
- opts.on("-v", "--verbose", "Verbose output") { @options[:verbose] = true }
36
- opts.on("-q", "--quiet", "Suppress output") { @options[:quiet] = true }
71
+ add_separators(opts)
72
+ add_options(opts)
37
73
  end
74
+ end
38
75
 
39
- @files = parser.parse!(argv)
76
+ def add_separators(opts)
77
+ opts.separator ""
78
+ opts.separator "Line-range targeting: lib/foo.rb:15-30, lib/foo.rb:15, lib/foo.rb:15-"
79
+ opts.separator ""
80
+ opts.separator "Commands: run (default), init, version"
81
+ opts.separator ""
82
+ opts.separator "Options:"
40
83
  end
41
84
 
42
- def call
43
- case @command
44
- when :version
45
- $stdout.puts(VERSION)
46
- 0
47
- when :init
48
- run_init
49
- when :run
50
- run_mutations
85
+ def add_options(opts)
86
+ opts.on("-t", "--timeout N", Integer, "Per-mutation timeout in seconds") { |n| @options[:timeout] = n }
87
+ opts.on("-f", "--format FORMAT", "Output format: text, json") { |f| @options[:format] = f.to_sym }
88
+ opts.on("--diff BASE", "DEPRECATED: Use line-range targeting instead") do |b|
89
+ warn("Warning: --diff is deprecated and will be removed in a future version. " \
90
+ "Use line-range targeting instead: evilution run lib/foo.rb:15-30")
91
+ @options[:diff_base] = b
51
92
  end
93
+ opts.on("--min-score FLOAT", Float, "Minimum mutation score to pass") { |s| @options[:min_score] = s }
94
+ opts.on("--spec FILES", Array, "Spec files to run (comma-separated)") { |f| @options[:spec_files] = f }
95
+ opts.on("--target METHOD", "Only mutate the named method (e.g. Foo::Bar#calculate)") { |m| @options[:target] = m }
96
+ opts.on("--no-coverage", "DEPRECATED: Has no effect and will be removed in a future version") do
97
+ warn("Warning: --no-coverage is deprecated, currently has no effect, and will be removed in a future version.")
98
+ @options[:coverage] = false
99
+ end
100
+ opts.on("-v", "--verbose", "Verbose output") { @options[:verbose] = true }
101
+ opts.on("-q", "--quiet", "Suppress output") { @options[:quiet] = true }
52
102
  end
53
103
 
54
- private
55
-
56
104
  def run_init
57
105
  path = ".evilution.yml"
58
106
  if File.exist?(path)
@@ -65,8 +113,35 @@ module Evilution
65
113
  0
66
114
  end
67
115
 
116
+ def parse_file_args(raw_args)
117
+ files = []
118
+ ranges = {}
119
+
120
+ raw_args.each do |arg|
121
+ file, range_str = arg.split(":", 2)
122
+ files << file
123
+ next unless range_str
124
+
125
+ ranges[file] = parse_line_range(range_str)
126
+ end
127
+
128
+ [files, ranges]
129
+ end
130
+
131
+ def parse_line_range(str)
132
+ if str.include?("-")
133
+ start_str, end_str = str.split("-", 2)
134
+ start_line = Integer(start_str)
135
+ end_line = end_str.empty? ? Float::INFINITY : Integer(end_str)
136
+ start_line..end_line
137
+ else
138
+ line = Integer(str)
139
+ line..line
140
+ end
141
+ end
142
+
68
143
  def run_mutations
69
- config = Config.new(**@options, target_files: @files)
144
+ config = Config.new(**@options, target_files: @files, line_ranges: @line_ranges)
70
145
  runner = Runner.new(config: config)
71
146
  summary = runner.call
72
147
  summary.success?(min_score: config.min_score) ? 0 : 1
@@ -7,8 +7,7 @@ module Evilution
7
7
  CONFIG_FILES = %w[.evilution.yml config/evilution.yml].freeze
8
8
 
9
9
  DEFAULTS = {
10
- jobs: nil,
11
- timeout: 10,
10
+ timeout: 30,
12
11
  format: :text,
13
12
  diff_base: nil,
14
13
  target: nil,
@@ -16,17 +15,20 @@ module Evilution
16
15
  integration: :rspec,
17
16
  coverage: true,
18
17
  verbose: false,
19
- quiet: false
18
+ quiet: false,
19
+ line_ranges: {},
20
+ spec_files: []
20
21
  }.freeze
21
22
 
22
- attr_reader :target_files, :jobs, :timeout, :format, :diff_base,
23
- :target, :min_score, :integration, :coverage, :verbose, :quiet
23
+ attr_reader :target_files, :timeout, :format, :diff_base,
24
+ :target, :min_score, :integration, :coverage, :verbose, :quiet,
25
+ :line_ranges, :spec_files
24
26
 
25
27
  def initialize(**options)
26
28
  file_options = options.delete(:skip_config_file) ? {} : load_config_file
27
29
  merged = DEFAULTS.merge(file_options).merge(options)
30
+ warn_removed_options(merged, file_options)
28
31
  @target_files = Array(merged[:target_files])
29
- @jobs = merged[:jobs] || default_jobs
30
32
  @timeout = merged[:timeout]
31
33
  @format = merged[:format].to_sym
32
34
  @diff_base = merged[:diff_base]
@@ -36,6 +38,8 @@ module Evilution
36
38
  @coverage = merged[:coverage]
37
39
  @verbose = merged[:verbose]
38
40
  @quiet = merged[:quiet]
41
+ @line_ranges = merged[:line_ranges] || {}
42
+ @spec_files = Array(merged[:spec_files])
39
43
  freeze
40
44
  end
41
45
 
@@ -51,17 +55,22 @@ module Evilution
51
55
  !diff_base.nil?
52
56
  end
53
57
 
58
+ def line_ranges?
59
+ !line_ranges.empty?
60
+ end
61
+
62
+ def target?
63
+ !target.nil?
64
+ end
65
+
54
66
  # Generates a default config file template.
55
67
  def self.default_template
56
68
  <<~YAML
57
69
  # Evilution configuration
58
70
  # See: https://github.com/marinazzio/evilution
59
71
 
60
- # Number of parallel workers (default: number of CPU cores)
61
- # jobs: 4
62
-
63
- # Per-mutation timeout in seconds (default: 10)
64
- # timeout: 10
72
+ # Per-mutation timeout in seconds (default: 30)
73
+ # timeout: 30
65
74
 
66
75
  # Output format: text or json (default: text)
67
76
  # format: text
@@ -72,13 +81,30 @@ module Evilution
72
81
  # Test integration: rspec (default: rspec)
73
82
  # integration: rspec
74
83
 
75
- # Skip mutations on uncovered lines (default: true)
84
+ # DEPRECATED: Coverage filtering is deprecated and will be removed
76
85
  # coverage: true
77
86
  YAML
78
87
  end
79
88
 
80
89
  private
81
90
 
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
96
+
97
+ if file_options.key?(:coverage)
98
+ warn("Warning: 'coverage' in config file is deprecated and ignored. " \
99
+ "This option will be removed in a future version.")
100
+ end
101
+
102
+ return unless file_options[:diff_base]
103
+
104
+ warn("Warning: 'diff_base' in config file is deprecated and will be removed in a future version. " \
105
+ "Use line-range targeting instead: evilution run lib/foo.rb:15-30")
106
+ end
107
+
82
108
  def load_config_file
83
109
  CONFIG_FILES.each do |path|
84
110
  next unless File.exist?(path)
@@ -89,10 +115,5 @@ module Evilution
89
115
 
90
116
  {}
91
117
  end
92
-
93
- def default_jobs
94
- require "etc"
95
- Etc.nprocessors
96
- end
97
118
  end
98
119
  end
@@ -108,77 +108,10 @@ module Evilution
108
108
  { passed: false, error: e.message }
109
109
  end
110
110
 
111
- def build_args(mutation)
112
- files = test_files || detect_test_files(mutation)
111
+ def build_args(_mutation)
112
+ files = test_files || ["spec"]
113
113
  ["--format", "progress", "--no-color", "--order", "defined", *files]
114
114
  end
115
-
116
- def detect_test_files(mutation)
117
- # Convention: lib/foo/bar.rb -> spec/foo/bar_spec.rb
118
- candidates = spec_file_candidates(mutation.file_path)
119
- found = candidates.select { |f| File.exist?(f) }
120
- return found unless found.empty?
121
-
122
- # Fallback: find spec/ directory relative to the mutation's project root
123
- fallback = fallback_spec_dir(mutation.file_path)
124
- return [fallback] if fallback
125
-
126
- []
127
- end
128
-
129
- def fallback_spec_dir(source_path)
130
- expanded = File.expand_path(source_path)
131
-
132
- # Derive spec/ from mutation's project, not CWD
133
- if expanded.include?("/lib/")
134
- project_root = expanded.split(%r{/lib/}, 2).first
135
- spec_dir = File.join(project_root, "spec")
136
- return spec_dir if Dir.exist?(spec_dir)
137
- end
138
-
139
- # Walk up from the file's directory looking for spec/
140
- dir = File.dirname(expanded)
141
- loop do
142
- spec_dir = File.join(dir, "spec")
143
- return spec_dir if Dir.exist?(spec_dir)
144
-
145
- parent = File.dirname(dir)
146
- break if parent == dir
147
-
148
- dir = parent
149
- end
150
-
151
- nil
152
- end
153
-
154
- def spec_file_candidates(source_path)
155
- candidates = []
156
-
157
- if source_path.start_with?("lib/")
158
- # lib/foo/bar.rb -> spec/foo/bar_spec.rb
159
- relative = source_path.sub(%r{^lib/}, "")
160
- spec_name = relative.sub(/\.rb$/, "_spec.rb")
161
- candidates << File.join("spec", spec_name)
162
- candidates << File.join("spec", "unit", spec_name)
163
- elsif source_path.include?("/lib/")
164
- # /absolute/path/lib/foo/bar.rb -> /absolute/path/spec/foo/bar_spec.rb
165
- prefix, relative = source_path.split(%r{/lib/}, 2)
166
- spec_name = relative.sub(/\.rb$/, "_spec.rb")
167
- candidates << File.join(prefix, "spec", spec_name)
168
- candidates << File.join(prefix, "spec", "unit", spec_name)
169
- end
170
-
171
- # Same directory: foo/bar.rb -> foo/bar_spec.rb
172
- sibling_spec = source_path.sub(/\.rb$/, "_spec.rb")
173
- candidates << sibling_spec
174
-
175
- # Subdirectory spec/ variant: foo/bar.rb -> foo/spec/bar_spec.rb
176
- dir = File.dirname(source_path)
177
- base = File.basename(source_path, ".rb")
178
- candidates << File.join(dir, "spec", "#{base}_spec.rb")
179
-
180
- candidates.uniq
181
- end
182
115
  end
183
116
  end
184
117
  end
@@ -1,13 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+ require "tmpdir"
5
+
3
6
  module Evilution
4
7
  module Isolation
5
8
  class Fork
9
+ GRACE_PERIOD = 2
10
+
6
11
  def call(mutation:, test_command:, timeout:)
12
+ sandbox_dir = Dir.mktmpdir("evilution-run")
7
13
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
8
14
  read_io, write_io = IO.pipe
9
15
 
10
16
  pid = ::Process.fork do
17
+ ENV["TMPDIR"] = sandbox_dir
11
18
  read_io.close
12
19
  result = execute_in_child(mutation, test_command)
13
20
  Marshal.dump(result, write_io)
@@ -23,10 +30,20 @@ module Evilution
23
30
  ensure
24
31
  read_io&.close
25
32
  write_io&.close
33
+ restore_original_source(mutation)
34
+ FileUtils.rm_rf(sandbox_dir) if sandbox_dir
26
35
  end
27
36
 
28
37
  private
29
38
 
39
+ def restore_original_source(mutation)
40
+ return if File.read(mutation.file_path) == mutation.original_source
41
+
42
+ File.write(mutation.file_path, mutation.original_source)
43
+ rescue StandardError => e
44
+ warn("Warning: failed to restore #{mutation.file_path}: #{e.message}")
45
+ end
46
+
30
47
  def execute_in_child(mutation, test_command)
31
48
  test_command.call(mutation)
32
49
  rescue StandardError => e
@@ -42,12 +59,24 @@ module Evilution
42
59
  ::Process.wait(pid) rescue nil # rubocop:disable Style/RescueModifier
43
60
  { timeout: false, passed: false, error: "empty result from child" }
44
61
  else
45
- ::Process.kill("KILL", pid) rescue nil # rubocop:disable Style/RescueModifier
46
- ::Process.wait(pid) rescue nil # rubocop:disable Style/RescueModifier
62
+ terminate_child(pid)
47
63
  { timeout: true }
48
64
  end
49
65
  end
50
66
 
67
+ def terminate_child(pid)
68
+ ::Process.kill("TERM", pid) rescue nil # rubocop:disable Style/RescueModifier
69
+ _, status = ::Process.waitpid2(pid, ::Process::WNOHANG)
70
+ return if status
71
+
72
+ sleep(GRACE_PERIOD)
73
+ _, status = ::Process.waitpid2(pid, ::Process::WNOHANG)
74
+ return if status
75
+
76
+ ::Process.kill("KILL", pid) rescue nil # rubocop:disable Style/RescueModifier
77
+ ::Process.wait(pid) rescue nil # rubocop:disable Style/RescueModifier
78
+ end
79
+
51
80
  def build_mutation_result(mutation, result, duration)
52
81
  status = if result[:timeout]
53
82
  :timeout
@@ -4,13 +4,10 @@ require_relative "config"
4
4
  require_relative "ast/parser"
5
5
  require_relative "mutator/registry"
6
6
  require_relative "isolation/fork"
7
- require_relative "parallel/pool"
8
7
  require_relative "integration/rspec"
9
8
  require_relative "reporter/json"
10
9
  require_relative "reporter/cli"
11
10
  require_relative "reporter/suggestion"
12
- require_relative "coverage/collector"
13
- require_relative "coverage/test_map"
14
11
  require_relative "diff/parser"
15
12
  require_relative "diff/file_filter"
16
13
  require_relative "result/mutation_result"
@@ -31,12 +28,11 @@ module Evilution
31
28
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
32
29
 
33
30
  subjects = parse_subjects
31
+ subjects = filter_by_target(subjects) if config.target?
32
+ subjects = filter_by_line_ranges(subjects) if config.line_ranges?
34
33
  subjects = filter_by_diff(subjects) if config.diff?
35
34
  mutations = generate_mutations(subjects)
36
- test_map = collect_coverage if config.coverage && config.integration == :rspec
37
- mutations, skipped = filter_by_coverage(mutations, test_map) if test_map
38
35
  results = run_mutations(mutations)
39
- results.concat(skipped) if skipped
40
36
  duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
41
37
 
42
38
  summary = Result::Summary.new(results: results, duration: duration)
@@ -53,6 +49,24 @@ module Evilution
53
49
  config.target_files.flat_map { |file| parser.call(file) }
54
50
  end
55
51
 
52
+ def filter_by_target(subjects)
53
+ matched = subjects.select { |s| s.name == config.target }
54
+ raise Error, "no method found matching '#{config.target}'" if matched.empty?
55
+
56
+ matched
57
+ end
58
+
59
+ def filter_by_line_ranges(subjects)
60
+ subjects.select do |subject|
61
+ range = config.line_ranges[subject.file_path]
62
+ next true unless range
63
+
64
+ subject_start = subject.line_number
65
+ subject_end = subject_start + subject.source.count("\n")
66
+ subject_start <= range.last && subject_end >= range.first
67
+ end
68
+ end
69
+
56
70
  def filter_by_diff(subjects)
57
71
  diff_parser = Diff::Parser.new
58
72
  changed_ranges = diff_parser.parse(config.diff_base)
@@ -63,37 +77,9 @@ module Evilution
63
77
  subjects.flat_map { |subject| registry.mutations_for(subject) }
64
78
  end
65
79
 
66
- def collect_coverage
67
- test_files = Dir.glob("spec/**/*_spec.rb")
68
- return nil if test_files.empty?
69
-
70
- data = Coverage::Collector.new.call(test_files: test_files)
71
- Coverage::TestMap.new(data)
72
- end
73
-
74
- def filter_by_coverage(mutations, test_map)
75
- covered, uncovered = mutations.partition do |m|
76
- test_map.covered?(File.expand_path(m.file_path), m.line)
77
- end
78
-
79
- skipped = uncovered.map do |m|
80
- Result::MutationResult.new(mutation: m, status: :survived, duration: 0.0)
81
- end
82
-
83
- [covered, skipped]
84
- end
85
-
86
80
  def run_mutations(mutations)
87
81
  integration = build_integration
88
82
 
89
- if config.jobs > 1 && mutations.size > 1
90
- run_parallel(mutations, integration)
91
- else
92
- run_sequential(mutations, integration)
93
- end
94
- end
95
-
96
- def run_sequential(mutations, integration)
97
83
  mutations.map do |mutation|
98
84
  test_command = ->(m) { integration.call(m) }
99
85
  isolator.call(
@@ -104,16 +90,11 @@ module Evilution
104
90
  end
105
91
  end
106
92
 
107
- def run_parallel(mutations, integration)
108
- pool = Parallel::Pool.new(jobs: config.jobs)
109
- test_command_builder = ->(_mutation) { ->(m) { integration.call(m) } }
110
- pool.call(mutations: mutations, test_command_builder: test_command_builder, timeout: config.timeout)
111
- end
112
-
113
93
  def build_integration
114
94
  case config.integration
115
95
  when :rspec
116
- Integration::RSpec.new
96
+ test_files = config.spec_files.empty? ? nil : config.spec_files
97
+ Integration::RSpec.new(test_files: test_files)
117
98
  else
118
99
  raise Error, "unknown integration: #{config.integration}"
119
100
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Evilution
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/evilution.rb CHANGED
@@ -27,8 +27,6 @@ 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/worker"
31
- require_relative "evilution/parallel/pool"
32
30
  require_relative "evilution/diff/parser"
33
31
  require_relative "evilution/diff/file_filter"
34
32
  require_relative "evilution/integration/base"
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: evilution
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Denis Kiselev
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-03-12 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: diff-lcs
@@ -89,8 +90,6 @@ files:
89
90
  - lib/evilution/mutator/operator/string_literal.rb
90
91
  - lib/evilution/mutator/operator/symbol_literal.rb
91
92
  - lib/evilution/mutator/registry.rb
92
- - lib/evilution/parallel/pool.rb
93
- - lib/evilution/parallel/worker.rb
94
93
  - lib/evilution/reporter/cli.rb
95
94
  - lib/evilution/reporter/json.rb
96
95
  - lib/evilution/reporter/suggestion.rb
@@ -107,9 +106,11 @@ metadata:
107
106
  allowed_push_host: https://rubygems.org
108
107
  bug_tracker_uri: https://github.com/marinazzio/evilution/issues
109
108
  documentation_uri: https://github.com/marinazzio/evilution/blob/master/README.md
109
+ changelog_uri: https://github.com/marinazzio/evilution/blob/master/CHANGELOG.md
110
110
  homepage_uri: https://github.com/marinazzio/evilution
111
111
  rubygems_mfa_required: 'true'
112
112
  source_code_uri: https://github.com/marinazzio/evilution
113
+ post_install_message:
113
114
  rdoc_options: []
114
115
  require_paths:
115
116
  - lib
@@ -124,7 +125,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
124
125
  - !ruby/object:Gem::Version
125
126
  version: '0'
126
127
  requirements: []
127
- rubygems_version: 4.0.3
128
+ rubygems_version: 3.5.22
129
+ signing_key:
128
130
  specification_version: 4
129
131
  summary: Free, MIT-licensed mutation testing for Ruby
130
132
  test_files: []
@@ -1,98 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Evilution
4
- module Parallel
5
- class Pool
6
- def initialize(jobs:)
7
- @jobs = jobs
8
- end
9
-
10
- # Executes mutations in parallel across N worker processes.
11
- #
12
- # @param mutations [Array] Array of mutation objects to run
13
- # @param test_command_builder [#call] Callable that receives a mutation and returns a test command callable
14
- # @param timeout [Numeric] Per-mutation timeout in seconds
15
- # @return [Array<Result::MutationResult>]
16
- def call(mutations:, test_command_builder:, timeout:)
17
- return [] if mutations.empty?
18
-
19
- worker_count = [@jobs, mutations.size].min
20
- chunks = partition(mutations, worker_count)
21
-
22
- pipes = worker_count.times.map { IO.pipe }
23
-
24
- pids = chunks.each_with_index.map do |chunk, index|
25
- _, write_io = pipes[index]
26
-
27
- pid = Process.fork do
28
- # Close all read ends in the child; close sibling write ends too
29
- pipes.each_with_index do |(r, w), i|
30
- if i == index
31
- r.close
32
- else
33
- r.close
34
- w.close
35
- end
36
- end
37
-
38
- results = run_chunk(chunk, test_command_builder, timeout)
39
- Marshal.dump(results, write_io)
40
- write_io.close
41
- exit!(0)
42
- end
43
-
44
- write_io.close
45
- pid
46
- end
47
-
48
- results = collect_results(pipes.map(&:first), pids)
49
-
50
- results
51
- ensure
52
- # Ensure all pipes are closed even if something goes wrong
53
- pipes&.each do |read_io, write_io|
54
- read_io.close unless read_io.closed?
55
- write_io.close unless write_io.closed?
56
- end
57
- end
58
-
59
- private
60
-
61
- attr_reader :jobs
62
-
63
- # Distributes mutations across N chunks using round-robin.
64
- # File isolation is handled by Integration::RSpec via temp directories
65
- # and $LOAD_PATH, so same-file mutations can safely run in parallel.
66
- def partition(mutations, n)
67
- chunks = Array.new(n) { [] }
68
- mutations.each_with_index { |m, i| chunks[i % n] << m }
69
- chunks
70
- end
71
-
72
- # Runs a chunk of mutations sequentially inside a worker process.
73
- def run_chunk(mutations, test_command_builder, timeout)
74
- worker = Worker.new
75
- worker.call(mutations: mutations, test_command_builder: test_command_builder, timeout: timeout)
76
- end
77
-
78
- # Reads results from all worker pipes and waits for workers to finish.
79
- def collect_results(read_ios, pids)
80
- results = []
81
-
82
- read_ios.each_with_index do |read_io, _index|
83
- data = read_io.read
84
- read_io.close
85
-
86
- unless data.empty?
87
- chunk_results = Marshal.load(data) # rubocop:disable Security/MarshalLoad
88
- results.concat(chunk_results)
89
- end
90
- end
91
-
92
- pids.each { |pid| Process.wait(pid) rescue nil } # rubocop:disable Style/RescueModifier
93
-
94
- results
95
- end
96
- end
97
- end
98
- end
@@ -1,24 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Evilution
4
- module Parallel
5
- class Worker
6
- def initialize(isolator: Isolation::Fork.new)
7
- @isolator = isolator
8
- end
9
-
10
- # Runs a batch of mutations sequentially using fork isolation.
11
- #
12
- # @param mutations [Array<Mutation>] Mutations to execute
13
- # @param test_command_builder [#call] Receives a mutation, returns a test command callable
14
- # @param timeout [Numeric] Per-mutation timeout in seconds
15
- # @return [Array<Result::MutationResult>]
16
- def call(mutations:, test_command_builder:, timeout:)
17
- mutations.map do |mutation|
18
- test_command = test_command_builder.call(mutation)
19
- @isolator.call(mutation: mutation, test_command: test_command, timeout: timeout)
20
- end
21
- end
22
- end
23
- end
24
- end