evilution 0.7.0 → 0.9.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: c22da4a2f18a35a0947f708163505e560fef557fb50595cbb735676d220fb17c
4
- data.tar.gz: a3427ee2af916eed4e59a73eb6431626e0089b7fb826136675e228ed712ddf08
3
+ metadata.gz: bd6e8e6df2bd5ec7096d3d8ddf35106323f25fab3a7e0aa0ad215aab2081a43d
4
+ data.tar.gz: 3178e1d157672ad425758e9122b7c151e899ea561af9a00b0e40d6f899b97bbd
5
5
  SHA512:
6
- metadata.gz: ebf55a8e9623bededf357bb684ff2a3d3a7e3af2e743e91d5d0875488118184cd1a2699905df5752f4784c8c7e6bef8c24f766430adf325850690ed0cd70ed45
7
- data.tar.gz: c06b6ecece15c2695bc86f0baaed67217887777336abeb6635b263d3aa68bc17f4d2b68be826ccc4fa8ae9ae2794d167174b24e380675e5660c19b759bd54c1c
6
+ metadata.gz: 178f85a6accb6c0e5a844e2b3b749909b9ee1f68a7e660fe88a79eeb8624cd6a6b7112443fc35ac9a0d1401477fd986b51d4b7055be654443f1f5e0da2626cf1
7
+ data.tar.gz: 0f5f209a4073f8de346baf2252f318dbefd40609eb163e2cd17b8339c311cfe2b520e53c23ac9d97de009a3788af7f0618a3d870afc76aeb6185374441c0275d
@@ -1 +1 @@
1
- 1773850606
1
+ 1773937412
data/.beads/issues.jsonl CHANGED
@@ -83,14 +83,14 @@
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
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
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"}
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-19T15:45:33.1262644+07:00","external_ref":"gh-76","dependencies":[{"issue_id":"EV-42","depends_on_id":"EV-45","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]}
87
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
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"}
89
- {"id":"EV-45","title":"Epic: Close mutation operator gap with mutant","description":"Track all missing mutation operators to approach mutant's ~78 mutations per method. Currently at ~19 with 18 operators. This is a long-term effort — prioritize high-value operators first.","status":"open","priority":2,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-16T21:50:07.597313227+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-17T10:14:41.815885279+07:00","dependencies":[{"issue_id":"EV-45","depends_on_id":"EV-46","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-45","depends_on_id":"EV-47","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-45","depends_on_id":"EV-48","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-45","depends_on_id":"EV-49","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-45","depends_on_id":"EV-50","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-45","depends_on_id":"EV-51","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-45","depends_on_id":"EV-52","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-45","depends_on_id":"EV-53","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-45","depends_on_id":"EV-54","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-45","depends_on_id":"EV-42","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
90
- {"id":"EV-46","title":"Operator: MethodCallRemoval","description":"Remove method call, keep receiver. e.g. obj.foo(x) → obj. High bug-finding value — catches untested side effects.","status":"closed","priority":3,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-16T21:50:22.890467537+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-17T09:29:15.588415782+07:00","closed_at":"2026-03-17T09:29:15.588415782+07:00","close_reason":"PR merged — MethodCallRemoval operator (19th operator)"}
91
- {"id":"EV-47","title":"Operator: BlockRemoval","description":"Remove block from method call. e.g. items.map { |x| x * 2 } → items.map. Catches untested block logic.","status":"open","priority":3,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-16T21:50:23.067266925+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-16T21:50:23.067266925+07:00"}
92
- {"id":"EV-48","title":"Operator: RangeReplacement","description":"Swap inclusive/exclusive ranges. e.g. 1..10 → 1...10 and vice versa. Trivial to implement.","status":"open","priority":3,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-16T21:50:23.172934653+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-16T21:50:23.172934653+07:00"}
93
- {"id":"EV-49","title":"Operator: RegexpMutation","description":"Replace regexp with always-failing pattern. e.g. /pattern/ → /a\\A/. Catches untested regex matching.","status":"open","priority":3,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-16T21:50:23.274989762+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-16T21:50:23.274989762+07:00"}
89
+ {"id":"EV-45","title":"Epic: Close mutation operator gap with mutant","description":"Track all missing mutation operators to approach mutant's ~78 mutations per method. Currently at ~19 with 18 operators. This is a long-term effort — prioritize high-value operators first.","status":"open","priority":2,"issue_type":"epic","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-16T21:50:07.597313227+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-19T15:29:57.905178598+07:00"}
90
+ {"id":"EV-46","title":"Operator: MethodCallRemoval","description":"Remove method call, keep receiver. e.g. obj.foo(x) → obj. High bug-finding value — catches untested side effects.","status":"closed","priority":3,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-16T21:50:22.890467537+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-19T15:45:33.224167585+07:00","closed_at":"2026-03-17T09:29:15.588415782+07:00","close_reason":"PR merged — MethodCallRemoval operator (19th operator)","external_ref":"gh-94","dependencies":[{"issue_id":"EV-46","depends_on_id":"EV-45","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]}
91
+ {"id":"EV-47","title":"Operator: BlockRemoval","description":"Remove block from method call. e.g. items.map { |x| x * 2 } → items.map. Catches untested block logic.","status":"closed","priority":3,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-16T21:50:23.067266925+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-19T16:07:53.696798964+07:00","closed_at":"2026-03-19T16:07:53.696798964+07:00","close_reason":"Implemented BlockRemoval operator with 8 specs","external_ref":"gh-95","dependencies":[{"issue_id":"EV-47","depends_on_id":"EV-45","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]}
92
+ {"id":"EV-48","title":"Operator: RangeReplacement","description":"Swap inclusive/exclusive ranges. e.g. 1..10 → 1...10 and vice versa. Trivial to implement.","status":"closed","priority":3,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-16T21:50:23.172934653+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-19T18:45:22.811573395+07:00","closed_at":"2026-03-19T18:45:22.811573395+07:00","close_reason":"Implemented RangeReplacement operator with 6 specs, 100% mutation score","external_ref":"gh-96","dependencies":[{"issue_id":"EV-48","depends_on_id":"EV-45","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]}
93
+ {"id":"EV-49","title":"Operator: RegexpMutation","description":"Replace regexp with always-failing pattern. e.g. /pattern/ → /a\\A/. Catches untested regex matching.","status":"closed","priority":3,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-16T21:50:23.274989762+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-19T20:02:42.413023336+07:00","closed_at":"2026-03-19T20:02:42.413023336+07:00","close_reason":"Implemented RegexpMutation operator with 7 specs, 100% mutation score","external_ref":"gh-97","dependencies":[{"issue_id":"EV-49","depends_on_id":"EV-45","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]}
94
94
  {"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"}]}
95
95
  {"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"}]}
96
96
  {"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"}]}
@@ -98,11 +98,18 @@
98
98
  {"id":"EV-5.4","title":"Add error handling and edge cases","description":"Handle: no test files found, no subjects found, fork failures, marshal errors, invalid config, files that fail to parse. Use Evilution::Error subclasses. Ensure clean exit codes (2 for tool errors).","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-02T00:06:58.353265504+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-02T11:54:25.59628785+07:00","closed_at":"2026-03-02T11:54:25.59628785+07:00","close_reason":"Closed","dependencies":[{"issue_id":"EV-5.4","depends_on_id":"EV-5","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"EV-5.4","depends_on_id":"EV-2.12","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
99
99
  {"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"}]}
100
100
  {"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"}]}
101
- {"id":"EV-50","title":"Operator: ConditionalFlip","description":"Flip if/unless. e.g. if cond → unless cond. Catches single-branch conditional testing.","status":"open","priority":3,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-16T21:50:23.379431887+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-16T21:50:23.379431887+07:00"}
102
- {"id":"EV-51","title":"Operator: SendMutation","description":"Replace known method families. e.g. flat_map → map, public_send → send, gsub → sub. Catches untested method semantics.","status":"open","priority":4,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-16T21:50:23.483414117+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-16T21:50:23.483414117+07:00"}
103
- {"id":"EV-52","title":"Operator: ArgumentRemoval","description":"Remove, permute, and replace individual method call arguments. e.g. foo(a, b) → foo(a), foo(b, a), foo(a, nil). This is the #1 cited gap vs mutant — where mutant catches the most bugs evilution misses. Includes: removing each argument individually, swapping argument order, replacing with nil.","status":"closed","priority":2,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-16T21:50:23.587291445+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-17T23:16:28.920737471+07:00","closed_at":"2026-03-17T23:16:28.920737471+07:00","close_reason":"Merged"}
104
- {"id":"EV-53","title":"Operator: ReceiverReplacement","description":"Drop explicit self receiver. e.g. self.foo → foo. Low bug-finding value.","status":"open","priority":3,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-16T21:50:23.692244528+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-17T10:14:41.617278357+07:00"}
105
- {"id":"EV-54","title":"Deepen existing operators with more replacement variants","description":"Existing operators generate limited variants per node. E.g. integer 5 only produces 0 and 1, but could also produce -1 and nil. Expanding variant sets across all 18 operators would significantly increase mutation coverage.","status":"open","priority":3,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-16T21:50:23.797359784+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-17T10:14:41.719145856+07:00"}
101
+ {"id":"EV-50","title":"Operator: ConditionalFlip","description":"Flip if/unless. e.g. if cond → unless cond. Catches single-branch conditional testing.","status":"closed","priority":3,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-16T21:50:23.379431887+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-19T16:25:00.912700206+07:00","closed_at":"2026-03-19T16:25:00.912700206+07:00","close_reason":"Implemented ConditionalFlip operator with 10 specs","external_ref":"gh-98","dependencies":[{"issue_id":"EV-50","depends_on_id":"EV-45","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]}
102
+ {"id":"EV-51","title":"Operator: SendMutation","description":"Replace known method families. e.g. flat_map → map, public_send → send, gsub → sub. Catches untested method semantics.","status":"open","priority":4,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-16T21:50:23.483414117+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-19T15:45:33.737392246+07:00","external_ref":"gh-99","dependencies":[{"issue_id":"EV-51","depends_on_id":"EV-45","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]}
103
+ {"id":"EV-52","title":"Operator: ArgumentRemoval","description":"Remove, permute, and replace individual method call arguments. e.g. foo(a, b) → foo(a), foo(b, a), foo(a, nil). This is the #1 cited gap vs mutant — where mutant catches the most bugs evilution misses. Includes: removing each argument individually, swapping argument order, replacing with nil.","status":"closed","priority":2,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-16T21:50:23.587291445+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-19T15:45:33.832675896+07:00","closed_at":"2026-03-17T23:16:28.920737471+07:00","close_reason":"Merged","external_ref":"gh-100","dependencies":[{"issue_id":"EV-52","depends_on_id":"EV-45","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]}
104
+ {"id":"EV-53","title":"Operator: ReceiverReplacement","description":"Drop explicit self receiver. e.g. self.foo → foo. Low bug-finding value.","status":"closed","priority":3,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-16T21:50:23.692244528+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-19T20:56:21.943424181+07:00","closed_at":"2026-03-19T20:56:21.943424181+07:00","close_reason":"Implemented ReceiverReplacement operator with 8 specs, 100% mutation score","external_ref":"gh-101","dependencies":[{"issue_id":"EV-53","depends_on_id":"EV-45","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]}
105
+ {"id":"EV-54","title":"Deepen existing operators with more replacement variants","description":"Existing operators generate limited variants per node. E.g. integer 5 only produces 0 and 1, but could also produce -1 and nil. Expanding variant sets across all 18 operators would significantly increase mutation coverage.","status":"open","priority":3,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-16T21:50:23.797359784+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-19T15:45:34.022534916+07:00","external_ref":"gh-102","dependencies":[{"issue_id":"EV-54","depends_on_id":"EV-45","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]}
106
+ {"id":"EV-54.1","title":"Add nil variants to literal operators","description":"Add nil as a mutation variant to IntegerLiteral, FloatLiteral, StringLiteral, BooleanLiteralReplacement, ArrayLiteral, HashLiteral, and SymbolLiteral operators. Nil-safety is one of the most common sources of production bugs in Ruby, making this the highest-ROI expansion.","status":"open","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-19T21:21:06.464726693+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-19T21:22:01.743744776+07:00","external_ref":"gh-167","dependencies":[{"issue_id":"EV-54.1","depends_on_id":"EV-54","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]}
107
+ {"id":"EV-54.2","title":"Expand NilReplacement with false, 0, and empty string variants","description":"Currently nil only mutates to true. Add false, 0, and empty string variants since nil is commonly used in boolean, numeric, and string contexts.","status":"open","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-19T21:21:20.731673229+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-19T21:22:01.844521612+07:00","external_ref":"gh-168","dependencies":[{"issue_id":"EV-54.2","depends_on_id":"EV-54","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]}
108
+ {"id":"EV-54.3","title":"Expand CollectionReplacement with more method swaps","description":"Add sort↔sort_by, find↔detect, any?↔all?, count↔length swaps. These are very common collection methods with subtle behavioral differences.","status":"open","priority":3,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-19T21:21:20.842448904+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-19T21:22:01.9421689+07:00","external_ref":"gh-169","dependencies":[{"issue_id":"EV-54.3","depends_on_id":"EV-54","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]}
109
+ {"id":"EV-54.4","title":"Expand ComparisonReplacement with opposite direction flips","description":"Currently > only mutates to >= and ==. Add full opposite flips: >→<, >=→<=, <→>, <=→>=. Catches inverted comparison bugs.","status":"open","priority":3,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-19T21:21:20.944147572+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-19T21:22:02.036010574+07:00","external_ref":"gh-170","dependencies":[{"issue_id":"EV-54.4","depends_on_id":"EV-54","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]}
110
+ {"id":"EV-54.5","title":"Add always-matching regexp variant","description":"Currently regexp only mutates to never-matching /a\\A/. Add always-matching /.*/ variant to test both match and no-match code paths.","status":"open","priority":3,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-19T21:21:21.049402671+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-19T21:22:02.124434713+07:00","external_ref":"gh-171","dependencies":[{"issue_id":"EV-54.5","depends_on_id":"EV-54","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]}
111
+ {"id":"EV-54.6","title":"Expand ArithmeticReplacement with bitwise shift operators","description":"Add <<→>> swap. Niche but catches bitwise shift bugs. Lowest priority in this epic.","status":"open","priority":4,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-19T21:21:21.152296396+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-19T21:22:02.210556018+07:00","external_ref":"gh-172","dependencies":[{"issue_id":"EV-54.6","depends_on_id":"EV-54","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]}
112
+ {"id":"EV-54.7","title":"Operator: ArgumentNilSubstitution","description":"Replace each method argument with nil, one at a time. E.g. foo(a, b) → foo(nil, b) and foo(a, nil). Works for single-arg calls too (unlike ArgumentRemoval which requires 2+). This catches 'default value' bugs where a method has a sensible fallback for nil — e.g. I18n.with_locale(user.locale) → I18n.with_locale(nil) silently falls back to default locale. Identified from real-world feedback where mutant caught a locale test gap that evilution missed (v0.7.0).","status":"open","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-19T22:09:42.995246472+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-19T22:09:57.489403188+07:00","external_ref":"gh-175","dependencies":[{"issue_id":"EV-54.7","depends_on_id":"EV-54","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]}
106
113
  {"id":"EV-55","title":"Concrete suggestions: Comparison & Arithmetic operators","description":"Generate concrete RSpec it blocks for ComparisonReplacement and ArithmeticReplacement mutations. Test should assert exact return value at boundary conditions.","status":"open","priority":3,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-17T09:58:33.26716126+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-17T09:58:33.26716126+07:00"}
107
114
  {"id":"EV-56","title":"Concrete suggestions: Boolean operators","description":"Generate concrete RSpec it blocks for BooleanOperatorReplacement, BooleanLiteralReplacement, and NegationInsertion mutations. Test should assert true/false explicitly.","status":"open","priority":3,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-17T09:58:33.370014645+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-17T09:58:33.370014645+07:00"}
108
115
  {"id":"EV-57","title":"Concrete suggestions: Literal operators","description":"Generate concrete RSpec it blocks for IntegerLiteral, FloatLiteral, StringLiteral, and SymbolLiteral mutations. Test should assert exact value returned.","status":"open","priority":3,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-17T09:58:33.473990949+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-17T09:58:33.473990949+07:00"}
@@ -113,9 +120,9 @@
113
120
  {"id":"EV-61","title":"Concrete suggestions: Nil operator","description":"Generate concrete RSpec it block for NilReplacement mutations. Test should assert non-nil return value.","status":"open","priority":3,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-17T09:58:33.882878252+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-17T09:58:33.882878252+07:00"}
114
121
  {"id":"EV-62","title":"Neutral mutation detection","description":"Run the original unmutated code once before mutation testing. Flag any tests that already fail, so surviving mutants caused by pre-broken tests are reported as neutral rather than gaps. Eliminates false positives in mutation score.","status":"closed","priority":2,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-17T10:15:03.670730866+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-17T23:16:28.920679692+07:00","closed_at":"2026-03-17T23:16:28.920679692+07:00","close_reason":"Merged"}
115
122
  {"id":"EV-63","title":"In-process mutation for performance","description":"Replace fork+file-write isolation with in-memory mutation (constant replacement or load-path override) for the common case. Current approach: 3.0 mutations/s vs mutant's 17.6 mutations/s (6x slower). Fork overhead is the main bottleneck. Keep fork as fallback for safety.","status":"closed","priority":2,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-17T10:15:03.765021194+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-18T18:31:15.344270883+07:00","closed_at":"2026-03-18T18:31:15.344270883+07:00","close_reason":"GH issue #114 closed, PR #123 merged"}
116
- {"id":"EV-64","title":"Class-level --target filtering","description":"Extend --target to accept class names without method (e.g. --target Game) to mutate all methods in the class. Currently only supports fully-qualified method names like Game#method.","status":"open","priority":3,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-17T10:15:03.858147999+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-17T10:15:03.858147999+07:00"}
117
- {"id":"EV-65","title":"Incremental mode with result caching","description":"Cache mutation results and only re-run mutations on changed lines/methods. Big CI speed win for large codebases. Could key cache on file content hash + mutation fingerprint.","status":"open","priority":3,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-17T10:15:03.95132569+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-17T10:15:03.95132569+07:00"}
118
- {"id":"EV-66","title":"Scope-aware spec resolution","description":"Improve convention-based spec resolution for nested modules. When a method is nested in a module, prefer spec/models/game_spec.rb over a generic match. Handle edge cases in app/ directory structures.","status":"open","priority":3,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-17T10:15:04.04579177+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-17T10:15:04.04579177+07:00"}
123
+ {"id":"EV-64","title":"Class-level --target filtering","description":"Extend --target to accept class names without method (e.g. --target Game) to mutate all methods in the class. Currently only supports fully-qualified method names like Game#method.","status":"closed","priority":3,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-17T10:15:03.858147999+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-19T21:31:38.601679951+07:00","closed_at":"2026-03-19T21:31:38.601679951+07:00","close_reason":"Implemented class-level --target filtering with 3 new specs, 100% mutation score"}
124
+ {"id":"EV-65","title":"Incremental mode with result caching","description":"Cache mutation results and only re-run mutations on changed lines/methods. Big CI speed win for large codebases. Could key cache on file content hash + mutation fingerprint.","status":"closed","priority":3,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-17T10:15:03.95132569+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-19T21:47:19.95617067+07:00","closed_at":"2026-03-19T21:47:19.95617067+07:00","close_reason":"Implemented incremental mode with result caching. Cache class (9 specs), runner integration (4 specs), CLI --incremental flag, config support. 100% mutation score on cache."}
125
+ {"id":"EV-66","title":"Scope-aware spec resolution","description":"Improve convention-based spec resolution for nested modules. When a method is nested in a module, prefer spec/models/game_spec.rb over a generic match. Handle edge cases in app/ directory structures.","status":"closed","priority":3,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-17T10:15:04.04579177+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-19T22:15:01.15206232+07:00","closed_at":"2026-03-19T22:15:01.15206232+07:00","close_reason":"Implemented parent-path fallback in SpecResolver for nested module spec resolution"}
119
126
  {"id":"EV-67","title":"HTML report with visual mutation map","description":"Generate an HTML report showing which lines are well-tested vs vulnerable. Visual mutation map per file with color-coded coverage.","status":"open","priority":4,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-17T10:15:04.13552276+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-17T10:15:04.13552276+07:00"}
120
127
  {"id":"EV-68","title":"Equivalent mutation detection","description":"Add heuristics to skip mutations that produce provably identical behavior (dead code paths, no-op transformations). Reduces noise in mutation results.","status":"open","priority":4,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-17T10:15:04.226297798+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-17T10:15:04.226297798+07:00"}
121
128
  {"id":"EV-69","title":"Minitest integration","description":"Add Minitest support alongside existing RSpec integration. Broader ecosystem support for non-RSpec projects.","status":"open","priority":4,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-17T10:15:04.313129974+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-17T10:15:04.313129974+07:00"}
@@ -135,5 +142,7 @@
135
142
  {"id":"EV-70.7","title":"Eliminate double forking in parallel mode","description":"When jobs>1 with auto isolation, Pool already forks workers. Using Isolation::Fork inside each worker creates a redundant second fork. Fix: use in-process isolation inside pool workers (they already have process isolation from the pool fork). This is likely the single biggest contributor to 17 GB memory usage.","status":"closed","priority":1,"issue_type":"bug","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-18T18:52:02.309469841+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-19T00:06:16.517207415+07:00","closed_at":"2026-03-19T00:06:16.517207415+07:00","close_reason":"Eliminated double forking: parallel pool workers now use InProcess isolation since Pool already provides fork-based process isolation. Simplified resolve_isolation to never auto-select Fork.","external_ref":"gh-131","labels":["v0.7.0"],"dependencies":[{"issue_id":"EV-70.7","depends_on_id":"EV-70","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]}
136
143
  {"id":"EV-70.8","title":"Lazy/streaming mutation generation","description":"Currently generate_mutations builds ALL mutations into an array before any are executed (runner.rb:38). For large codebases this holds thousands of Mutation objects (each with 2x full source strings + AST node) in memory simultaneously. Change to lazy enumeration so mutations are generated per-subject as needed.","status":"closed","priority":1,"issue_type":"bug","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-18T18:52:05.552816007+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-19T00:26:32.161184282+07:00","closed_at":"2026-03-19T00:26:32.161184282+07:00","close_reason":"Changed generate_mutations to lazy enumerator (subjects.lazy.flat_map). Mutations generated per-subject on demand, not all materialized upfront. Baseline now receives subjects instead of mutations. Pre-computed mutation_count passed to run methods for progress/fail_fast.","external_ref":"gh-132","labels":["v0.7.0"],"dependencies":[{"issue_id":"EV-70.8","depends_on_id":"EV-70","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]}
137
144
  {"id":"EV-70.9","title":"Strip source strings from Mutation after result creation","description":"Each MutationResult retains its Mutation which holds original_source and mutated_source (full file contents). These are only needed for diff generation in reporters. Compute and cache the diff eagerly, then release the source strings to allow GC. Or store only the diff in MutationResult.","status":"closed","priority":2,"issue_type":"bug","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-18T18:52:08.1904857+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-19T10:31:19.673577645+07:00","closed_at":"2026-03-19T10:31:19.673577645+07:00","close_reason":"Implemented strip_sources! on Mutation (lazy diff caching, nil out source strings after execution). Wired into Runner sequential and parallel paths.","external_ref":"gh-133","labels":["v0.7.0"],"dependencies":[{"issue_id":"EV-70.9","depends_on_id":"EV-70","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]}
145
+ {"id":"EV-71","title":"Trim MCP tool response to reduce context window usage","description":"The evilution MCP tool returns full JSON report with all killed mutation diffs (~10k tokens). Only survived mutations are actionable. Trim killed/neutral/timed_out/errors detail arrays in MutateTool.call, keeping summary counts and survived details only.","status":"closed","priority":1,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-19T23:23:32.672265145+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-19T23:31:18.511115181+07:00","closed_at":"2026-03-19T23:31:18.511115181+07:00","close_reason":"Trim killed/neutral/timed_out/errors detail arrays from MCP tool response, keeping only summary and survived details","external_ref":"marinazzio/evilution#178"}
146
+ {"id":"EV-72","title":"Add verbosity control to MCP tool response","description":"Add a verbosity parameter (full/summary/minimal) to the MCP tool to control response size. Default to summary for best context efficiency. Full keeps all entries without diffs, summary omits killed/neutral arrays, minimal keeps only summary + survived.","status":"open","priority":3,"issue_type":"feature","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-19T23:41:41.837080367+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-19T23:41:41.837080367+07:00","external_ref":"marinazzio/evilution#179"}
138
147
  {"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."}
139
148
  {"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"}
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.0] - 2026-03-19
4
+
5
+ ### Added
6
+
7
+ - **ReceiverReplacement operator** — new mutation operator that drops explicit `self` receiver from method calls (e.g. `self.foo` → `foo`); catches untested self-dispatch semantics
8
+ - **Class-level `--target` filtering** — `--target Foo` now matches all methods in the `Foo` class, not just `Foo#method`; instance method targeting (`Foo#bar`) continues to work as before
9
+ - **Incremental mode** (`--incremental`) — caches killed/timeout results keyed by file content SHA256 + mutation fingerprint; skips re-running unchanged mutations on subsequent runs; atomic file-based cache in `tmp/evilution_cache/`
10
+ - **Scope-aware spec resolution** — `SpecResolver` now walks up the directory tree when an exact spec file isn't found (e.g. `app/models/game/round.rb` → `spec/models/game_spec.rb`); works with both stripped (`spec/`) and kept (`spec/lib/`) layouts
11
+
12
+ ### Changed
13
+
14
+ - **MCP tool response trimming** — diffs are stripped from killed and neutral mutation entries to reduce context window usage (~36% smaller responses); survived, timed_out, and errors retain full diffs for actionability
15
+
16
+ ## [0.8.0] - 2026-03-19
17
+
18
+ ### Added
19
+
20
+ - **BlockRemoval operator** — new mutation operator that removes blocks from method calls (e.g. `items.map { |x| x * 2 }` → `items.map`); catches untested block logic
21
+ - **ConditionalFlip operator** — new mutation operator that flips `if` to `unless` and vice versa (e.g. `if cond` → `unless cond`); skips ternaries and `elsif` branches; catches single-branch conditional testing
22
+ - **RangeReplacement operator** — new mutation operator that swaps inclusive/exclusive ranges (e.g. `1..10` → `1...10` and vice versa)
23
+ - **RegexpMutation operator** — new mutation operator that replaces regexp patterns with a never-matching pattern (`/a\A/`), preserving flags; catches untested regex matching
24
+
3
25
  ## [0.7.0] - 2026-03-19
4
26
 
5
27
  ### Added
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "fileutils"
6
+
7
+ module Evilution
8
+ class Cache
9
+ DEFAULT_DIR = "tmp/evilution_cache"
10
+
11
+ def initialize(cache_dir: DEFAULT_DIR)
12
+ @cache_dir = cache_dir
13
+ end
14
+
15
+ def fetch(mutation)
16
+ return nil if mutation.original_source.nil?
17
+
18
+ file_key = file_key(mutation)
19
+ entry_key = entry_key(mutation)
20
+ data = read_file(file_key)
21
+ return nil unless data
22
+
23
+ entry = data[entry_key]
24
+ return nil unless entry.is_a?(Hash) && entry["status"].is_a?(String)
25
+
26
+ { status: entry["status"].to_sym, duration: entry["duration"],
27
+ killing_test: entry["killing_test"], test_command: entry["test_command"] }
28
+ end
29
+
30
+ def store(mutation, result_data)
31
+ file_key = file_key(mutation)
32
+ entry_key = entry_key(mutation)
33
+ data = read_file(file_key) || {}
34
+
35
+ data[entry_key] = {
36
+ "status" => result_data[:status].to_s,
37
+ "duration" => result_data[:duration],
38
+ "killing_test" => result_data[:killing_test],
39
+ "test_command" => result_data[:test_command]
40
+ }
41
+
42
+ write_file(file_key, data)
43
+ end
44
+
45
+ def clear
46
+ FileUtils.rm_rf(@cache_dir)
47
+ end
48
+
49
+ private
50
+
51
+ def file_key(mutation)
52
+ content_hash = Digest::SHA256.hexdigest(mutation.original_source)
53
+ "#{safe_filename(mutation.file_path)}_#{content_hash[0, 16]}"
54
+ end
55
+
56
+ def entry_key(mutation)
57
+ "#{mutation.operator_name}:#{mutation.line}:#{mutation.column}"
58
+ end
59
+
60
+ def safe_filename(path)
61
+ path.gsub(%r{[/\\]}, "_").gsub(/[^a-zA-Z0-9._-]/, "")
62
+ end
63
+
64
+ def read_file(file_key)
65
+ path = cache_path(file_key)
66
+ return nil unless File.exist?(path)
67
+
68
+ JSON.parse(File.read(path))
69
+ rescue JSON::ParserError
70
+ nil
71
+ end
72
+
73
+ def write_file(file_key, data)
74
+ FileUtils.mkdir_p(@cache_dir)
75
+ path = cache_path(file_key)
76
+ tmp = "#{path}.#{Process.pid}.tmp"
77
+ File.write(tmp, JSON.generate(data))
78
+ File.rename(tmp, path)
79
+ end
80
+
81
+ def cache_path(file_key)
82
+ File.join(@cache_dir, "#{file_key}.json")
83
+ end
84
+ end
85
+ end
data/lib/evilution/cli.rb CHANGED
@@ -128,6 +128,7 @@ module Evilution
128
128
  opts.on("--fail-fast", "Stop after N surviving mutants " \
129
129
  "(default: disabled; if provided without N, uses 1; use --fail-fast=N)") { @options[:fail_fast] ||= 1 }
130
130
  opts.on("--no-baseline", "Skip baseline test suite check") { @options[:baseline] = false }
131
+ opts.on("--incremental", "Cache killed/timeout results; skip re-running them on unchanged files") { @options[:incremental] = true }
131
132
  opts.on("--isolation STRATEGY", "Isolation: auto, fork, in_process (default: auto)") { |s| @options[:isolation] = s }
132
133
  opts.on("--stdin", "Read target file paths from stdin (one per line)") { @options[:stdin] = true }
133
134
  opts.on("-v", "--verbose", "Verbose output") { @options[:verbose] = true }
@@ -20,13 +20,14 @@ module Evilution
20
20
  fail_fast: nil,
21
21
  baseline: true,
22
22
  isolation: :auto,
23
+ incremental: false,
23
24
  line_ranges: {},
24
25
  spec_files: []
25
26
  }.freeze
26
27
 
27
28
  attr_reader :target_files, :timeout, :format, :diff_base,
28
29
  :target, :min_score, :integration, :coverage, :verbose, :quiet,
29
- :jobs, :fail_fast, :baseline, :isolation, :line_ranges, :spec_files
30
+ :jobs, :fail_fast, :baseline, :isolation, :incremental, :line_ranges, :spec_files
30
31
 
31
32
  def initialize(**options)
32
33
  file_options = options.delete(:skip_config_file) ? {} : load_config_file
@@ -64,6 +65,10 @@ module Evilution
64
65
  baseline
65
66
  end
66
67
 
68
+ def incremental?
69
+ incremental
70
+ end
71
+
67
72
  def self.file_options
68
73
  CONFIG_FILES.each do |path|
69
74
  next unless File.exist?(path)
@@ -136,6 +141,7 @@ module Evilution
136
141
  @fail_fast = validate_fail_fast(merged[:fail_fast])
137
142
  @baseline = merged[:baseline]
138
143
  @isolation = validate_isolation(merged[:isolation])
144
+ @incremental = merged[:incremental]
139
145
  @line_ranges = merged[:line_ranges] || {}
140
146
  @spec_files = Array(merged[:spec_files])
141
147
  end
@@ -50,8 +50,9 @@ module Evilution
50
50
  runner = Runner.new(config: config)
51
51
  summary = runner.call
52
52
  report = Reporter::JSON.new.call(summary)
53
+ compact = trim_report(report)
53
54
 
54
- ::MCP::Tool::Response.new([{ type: "text", text: report }])
55
+ ::MCP::Tool::Response.new([{ type: "text", text: compact }])
55
56
  rescue Evilution::Error => e
56
57
  error_payload = build_error_payload(e)
57
58
  ::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(error_payload) }], error: true)
@@ -98,6 +99,19 @@ module Evilution
98
99
  opts
99
100
  end
100
101
 
102
+ def trim_report(json_string)
103
+ data = ::JSON.parse(json_string)
104
+ strip_diffs(data, "killed")
105
+ strip_diffs(data, "neutral")
106
+ ::JSON.generate(data)
107
+ end
108
+
109
+ def strip_diffs(data, key)
110
+ return unless data[key]
111
+
112
+ data[key].each { |entry| entry.delete("diff") }
113
+ end
114
+
101
115
  def build_error_payload(error)
102
116
  error_type = case error
103
117
  when ConfigError then "config_error"
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution
4
+ module Mutator
5
+ module Operator
6
+ class BlockRemoval < Base
7
+ def visit_call_node(node)
8
+ if node.block
9
+ block_node = node.block
10
+ call_end = block_node.location.start_offset
11
+ call_start = node.location.start_offset
12
+ call_without_block = @file_source.byteslice(call_start...call_end).rstrip
13
+
14
+ add_mutation(
15
+ offset: call_start,
16
+ length: node.location.length,
17
+ replacement: call_without_block,
18
+ node: node
19
+ )
20
+ end
21
+
22
+ super
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution
4
+ module Mutator
5
+ module Operator
6
+ class ConditionalFlip < Base
7
+ def visit_if_node(node)
8
+ if node.if_keyword == "if" && !elsif?(node)
9
+ add_mutation(
10
+ offset: node.if_keyword_loc.start_offset,
11
+ length: node.if_keyword_loc.length,
12
+ replacement: "unless",
13
+ node: node
14
+ )
15
+ end
16
+
17
+ super
18
+ end
19
+
20
+ def visit_unless_node(node)
21
+ add_mutation(
22
+ offset: node.keyword_loc.start_offset,
23
+ length: node.keyword_loc.length,
24
+ replacement: "if",
25
+ node: node
26
+ )
27
+
28
+ super
29
+ end
30
+
31
+ private
32
+
33
+ def elsif?(node)
34
+ node.subsequent.is_a?(Prism::IfNode)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution
4
+ module Mutator
5
+ module Operator
6
+ class RangeReplacement < Base
7
+ def visit_range_node(node)
8
+ replacement = node.operator == ".." ? "..." : ".."
9
+
10
+ add_mutation(
11
+ offset: node.operator_loc.start_offset,
12
+ length: node.operator_loc.length,
13
+ replacement: replacement,
14
+ node: node
15
+ )
16
+
17
+ super
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution
4
+ module Mutator
5
+ module Operator
6
+ class ReceiverReplacement < Base
7
+ def visit_call_node(node)
8
+ if node.receiver.is_a?(Prism::SelfNode)
9
+ call_without_self = @file_source.byteslice(
10
+ node.message_loc.start_offset,
11
+ node.location.start_offset + node.location.length - node.message_loc.start_offset
12
+ )
13
+
14
+ add_mutation(
15
+ offset: node.location.start_offset,
16
+ length: node.location.length,
17
+ replacement: call_without_self,
18
+ node: node
19
+ )
20
+ end
21
+
22
+ super
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution
4
+ module Mutator
5
+ module Operator
6
+ class RegexpMutation < Base
7
+ NEVER_MATCH = 'a\A'
8
+
9
+ def visit_regular_expression_node(node)
10
+ add_mutation(
11
+ offset: node.content_loc.start_offset,
12
+ length: node.content_loc.length,
13
+ replacement: NEVER_MATCH,
14
+ node: node
15
+ )
16
+
17
+ super
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -25,7 +25,12 @@ module Evilution
25
25
  Operator::ReturnValueRemoval,
26
26
  Operator::CollectionReplacement,
27
27
  Operator::MethodCallRemoval,
28
- Operator::ArgumentRemoval
28
+ Operator::ArgumentRemoval,
29
+ Operator::BlockRemoval,
30
+ Operator::ConditionalFlip,
31
+ Operator::RangeReplacement,
32
+ Operator::RegexpMutation,
33
+ Operator::ReceiverReplacement
29
34
  ].each { |op| registry.register(op) }
30
35
  registry
31
36
  end
@@ -16,6 +16,7 @@ require_relative "git/changed_files"
16
16
  require_relative "result/mutation_result"
17
17
  require_relative "result/summary"
18
18
  require_relative "baseline"
19
+ require_relative "cache"
19
20
  require_relative "parallel/pool"
20
21
 
21
22
  module Evilution
@@ -27,6 +28,7 @@ module Evilution
27
28
  @parser = AST::Parser.new
28
29
  @registry = Mutator::Registry.default
29
30
  @isolator = build_isolator
31
+ @cache = config.incremental? ? Cache.new : nil
30
32
  end
31
33
 
32
34
  def call
@@ -54,7 +56,7 @@ module Evilution
54
56
 
55
57
  private
56
58
 
57
- attr_reader :parser, :registry, :isolator
59
+ attr_reader :parser, :registry, :isolator, :cache
58
60
 
59
61
  def parse_subjects
60
62
  files = resolve_target_files
@@ -68,7 +70,11 @@ module Evilution
68
70
  end
69
71
 
70
72
  def filter_by_target(subjects)
71
- matched = subjects.select { |s| s.name == config.target }
73
+ matched = if config.target.include?("#")
74
+ subjects.select { |s| s.name == config.target }
75
+ else
76
+ subjects.select { |s| s.name.start_with?("#{config.target}#") }
77
+ end
72
78
  raise Error, "no method found matching '#{config.target}'" if matched.empty?
73
79
 
74
80
  matched
@@ -125,12 +131,10 @@ module Evilution
125
131
  truncated = false
126
132
 
127
133
  mutations.each_with_index do |mutation, index|
128
- test_command = ->(m) { integration.call(m) }
129
- result = isolator.call(
130
- mutation: mutation,
131
- test_command: test_command,
132
- timeout: config.timeout
133
- )
134
+ result = execute_or_fetch(mutation) do
135
+ test_command = ->(m) { integration.call(m) }
136
+ isolator.call(mutation: mutation, test_command: test_command, timeout: config.timeout)
137
+ end
134
138
  mutation.strip_sources!
135
139
  result = neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
136
140
  results << result
@@ -157,20 +161,34 @@ module Evilution
157
161
  mutations.each_slice(config.jobs) do |batch|
158
162
  break if state[:truncated]
159
163
 
160
- compact_results = pool.map(batch) do |mutation|
161
- test_command = ->(m) { integration.call(m) }
162
- result = worker_isolator.call(mutation: mutation, test_command: test_command, timeout: config.timeout)
163
- compact_result(result)
164
- end
165
-
166
- batch.each(&:strip_sources!)
167
- batch_results = rebuild_results(batch, compact_results)
164
+ batch_results = run_parallel_batch(batch, pool, worker_isolator, integration)
168
165
  process_batch(batch_results, baseline_result, spec_resolver, state)
169
166
  end
170
167
 
171
168
  [state[:results], state[:truncated]]
172
169
  end
173
170
 
171
+ def run_parallel_batch(batch, pool, worker_isolator, integration)
172
+ uncached_indices, cached_results = partition_cached(batch)
173
+ worker_results = run_uncached_workers(batch, uncached_indices, pool, worker_isolator, integration)
174
+ compact_results = merge_parallel_results(batch, uncached_indices, cached_results, worker_results)
175
+ batch.each(&:strip_sources!)
176
+ batch_results = rebuild_results(batch, compact_results)
177
+ batch_results.each { |r| store_cached_result(r.mutation, r) }
178
+ batch_results
179
+ end
180
+
181
+ def run_uncached_workers(batch, uncached_indices, pool, worker_isolator, integration)
182
+ return [] if uncached_indices.empty?
183
+
184
+ uncached = uncached_indices.map { |i| batch[i] }
185
+ pool.map(uncached) do |mutation|
186
+ test_command = ->(m) { integration.call(m) }
187
+ result = worker_isolator.call(mutation: mutation, test_command: test_command, timeout: config.timeout)
188
+ compact_result(result)
189
+ end
190
+ end
191
+
174
192
  def process_batch(batch_results, baseline_result, spec_resolver, state)
175
193
  batch_results.each do |result|
176
194
  result = neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
@@ -334,5 +352,63 @@ module Evilution
334
352
  Reporter::CLI.new
335
353
  end
336
354
  end
355
+
356
+ def partition_cached(batch)
357
+ uncached_indices = []
358
+ cached_results = {}
359
+
360
+ batch.each_with_index do |mutation, i|
361
+ cached = fetch_cached_result(mutation)
362
+ if cached
363
+ cached_results[i] = compact_result(cached)
364
+ else
365
+ uncached_indices << i
366
+ end
367
+ end
368
+
369
+ [uncached_indices, cached_results]
370
+ end
371
+
372
+ def merge_parallel_results(batch, uncached_indices, cached_results, worker_results)
373
+ result_map = cached_results.dup
374
+ uncached_indices.each_with_index { |batch_idx, worker_idx| result_map[batch_idx] = worker_results[worker_idx] }
375
+ batch.each_index.map { |i| result_map[i] }
376
+ end
377
+
378
+ def execute_or_fetch(mutation)
379
+ cached = fetch_cached_result(mutation)
380
+ return cached if cached
381
+
382
+ result = yield
383
+ store_cached_result(mutation, result)
384
+ result
385
+ end
386
+
387
+ def fetch_cached_result(mutation)
388
+ return nil unless cache
389
+
390
+ data = cache.fetch(mutation)
391
+ return nil unless data
392
+ return nil unless %i[killed timeout].include?(data[:status])
393
+
394
+ Result::MutationResult.new(
395
+ mutation: mutation,
396
+ status: data[:status],
397
+ duration: data[:duration],
398
+ killing_test: data[:killing_test],
399
+ test_command: data[:test_command]
400
+ )
401
+ end
402
+
403
+ def store_cached_result(mutation, result)
404
+ return unless cache
405
+ return unless result.killed? || result.timeout?
406
+
407
+ cache.store(mutation,
408
+ status: result.status,
409
+ duration: result.duration,
410
+ killing_test: result.killing_test,
411
+ test_command: result.test_command)
412
+ end
337
413
  end
338
414
  end
@@ -28,13 +28,35 @@ module Evilution
28
28
  base = source_path.sub(/\.rb\z/, "_spec.rb")
29
29
  prefix = STRIPPABLE_PREFIXES.find { |p| source_path.start_with?(p) }
30
30
 
31
- if prefix
32
- stripped = "spec/#{base.delete_prefix(prefix)}"
33
- kept = "spec/#{base}"
34
- [stripped, kept]
35
- else
36
- ["spec/#{base}"]
31
+ candidates = if prefix
32
+ stripped = base.delete_prefix(prefix)
33
+ ["spec/#{stripped}", "spec/#{base}"]
34
+ else
35
+ ["spec/#{base}"]
36
+ end
37
+
38
+ fallbacks = candidates.flat_map { |c| parent_fallback_candidates(c) }.uniq
39
+ candidates + fallbacks
40
+ end
41
+
42
+ def parent_fallback_candidates(spec_path)
43
+ parts = spec_path.split("/")
44
+ # parts: ["spec", "foo", "bar_spec.rb"] — need at least 3 parts for fallback
45
+ return [] if parts.length < 3
46
+
47
+ candidates = []
48
+ # Remove filename, then progressively remove directories
49
+ dir_parts = parts[1..-2] # ["models", "game"]
50
+ (dir_parts.length - 1).downto(0) do |i|
51
+ file = "#{dir_parts[i]}_spec.rb"
52
+ if i.zero?
53
+ candidates << "spec/#{file}"
54
+ else
55
+ parent = dir_parts[0...i].join("/")
56
+ candidates << "spec/#{parent}/#{file}"
57
+ end
37
58
  end
59
+ candidates
38
60
  end
39
61
  end
40
62
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Evilution
4
- VERSION = "0.7.0"
4
+ VERSION = "0.9.0"
5
5
  end
data/lib/evilution.rb CHANGED
@@ -28,6 +28,11 @@ require_relative "evilution/mutator/operator/return_value_removal"
28
28
  require_relative "evilution/mutator/operator/collection_replacement"
29
29
  require_relative "evilution/mutator/operator/method_call_removal"
30
30
  require_relative "evilution/mutator/operator/argument_removal"
31
+ require_relative "evilution/mutator/operator/block_removal"
32
+ require_relative "evilution/mutator/operator/conditional_flip"
33
+ require_relative "evilution/mutator/operator/range_replacement"
34
+ require_relative "evilution/mutator/operator/regexp_mutation"
35
+ require_relative "evilution/mutator/operator/receiver_replacement"
31
36
  require_relative "evilution/mutator/registry"
32
37
  require_relative "evilution/isolation/fork"
33
38
  require_relative "evilution/isolation/in_process"
@@ -46,6 +51,7 @@ require_relative "evilution/coverage/collector"
46
51
  require_relative "evilution/coverage/test_map"
47
52
  require_relative "evilution/spec_resolver"
48
53
  require_relative "evilution/baseline"
54
+ require_relative "evilution/cache"
49
55
  require_relative "evilution/cli"
50
56
  require_relative "evilution/runner"
51
57
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: evilution
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Denis Kiselev
@@ -81,6 +81,7 @@ files:
81
81
  - lib/evilution/ast/parser.rb
82
82
  - lib/evilution/ast/source_surgeon.rb
83
83
  - lib/evilution/baseline.rb
84
+ - lib/evilution/cache.rb
84
85
  - lib/evilution/cli.rb
85
86
  - lib/evilution/config.rb
86
87
  - lib/evilution/coverage/collector.rb
@@ -101,11 +102,13 @@ files:
101
102
  - lib/evilution/mutator/operator/argument_removal.rb
102
103
  - lib/evilution/mutator/operator/arithmetic_replacement.rb
103
104
  - lib/evilution/mutator/operator/array_literal.rb
105
+ - lib/evilution/mutator/operator/block_removal.rb
104
106
  - lib/evilution/mutator/operator/boolean_literal_replacement.rb
105
107
  - lib/evilution/mutator/operator/boolean_operator_replacement.rb
106
108
  - lib/evilution/mutator/operator/collection_replacement.rb
107
109
  - lib/evilution/mutator/operator/comparison_replacement.rb
108
110
  - lib/evilution/mutator/operator/conditional_branch.rb
111
+ - lib/evilution/mutator/operator/conditional_flip.rb
109
112
  - lib/evilution/mutator/operator/conditional_negation.rb
110
113
  - lib/evilution/mutator/operator/float_literal.rb
111
114
  - lib/evilution/mutator/operator/hash_literal.rb
@@ -114,6 +117,9 @@ files:
114
117
  - lib/evilution/mutator/operator/method_call_removal.rb
115
118
  - lib/evilution/mutator/operator/negation_insertion.rb
116
119
  - lib/evilution/mutator/operator/nil_replacement.rb
120
+ - lib/evilution/mutator/operator/range_replacement.rb
121
+ - lib/evilution/mutator/operator/receiver_replacement.rb
122
+ - lib/evilution/mutator/operator/regexp_mutation.rb
117
123
  - lib/evilution/mutator/operator/return_value_removal.rb
118
124
  - lib/evilution/mutator/operator/statement_deletion.rb
119
125
  - lib/evilution/mutator/operator/string_literal.rb