evilution 0.9.0 → 0.10.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: bd6e8e6df2bd5ec7096d3d8ddf35106323f25fab3a7e0aa0ad215aab2081a43d
4
- data.tar.gz: 3178e1d157672ad425758e9122b7c151e899ea561af9a00b0e40d6f899b97bbd
3
+ metadata.gz: a4753225c2b9795143ecd0e37a355a2503e2a467ee9f79d1be82841141102a3d
4
+ data.tar.gz: 760527bdb4c437124ed4ec26e35d72aff297bdc519636cb04b12eee408e5a4e4
5
5
  SHA512:
6
- metadata.gz: 178f85a6accb6c0e5a844e2b3b749909b9ee1f68a7e660fe88a79eeb8624cd6a6b7112443fc35ac9a0d1401477fd986b51d4b7055be654443f1f5e0da2626cf1
7
- data.tar.gz: 0f5f209a4073f8de346baf2252f318dbefd40609eb163e2cd17b8339c311cfe2b520e53c23ac9d97de009a3788af7f0618a3d870afc76aeb6185374441c0275d
6
+ metadata.gz: bc9b9d5d7922ecb4f6dd7fd7a9dd62dcf292f8c5ba1b8d6b71b01d3850a2565afaecd04d30fc18bdef9f8d586cf5559ce97ac7188b5c0e9e99d3d5bbfb4c91e4
7
+ data.tar.gz: c8ec937012b8e910c61c42b650150de6144f59e49a731e903d59973858bca71e8fbd63a29988acb749f3e6ecfaa0ecc749d907195f81449bf6c3aa68a3872fe4
@@ -1 +1 @@
1
- 1773937412
1
+ 1774024848
data/.beads/issues.jsonl CHANGED
@@ -99,7 +99,7 @@
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
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"}]}
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":"closed","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-20T23:14:12.811639741+07:00","closed_at":"2026-03-20T23:14:12.811639741+07:00","close_reason":"Implemented SendMutation operator with 17 method family replacements (flat_map/map, public_send/send, gsub/sub, detect/find, collect/map, each_with_object/inject, reverse_each/each, length/size, values_at/fetch_values). 19 specs passing.","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
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
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
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"}]}
@@ -109,7 +109,7 @@
109
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
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
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"}]}
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":"closed","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-20T23:43:12.098455761+07:00","closed_at":"2026-03-20T23:43:12.098455761+07:00","close_reason":"Implemented ArgumentNilSubstitution operator. Replaces each argument with nil one at a time, works for single-arg calls too (unlike ArgumentRemoval). Skips splat, keyword, block, and forwarding args. 12 specs passing, 705 total.","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"}]}
113
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"}
114
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"}
115
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"}
@@ -123,8 +123,8 @@
123
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
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
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"}
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"}
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"}
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":"closed","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-20T23:58:29.045246978+07:00","closed_at":"2026-03-20T23:58:29.045246978+07:00","close_reason":"Implemented HTML reporter with visual mutation map. Self-contained HTML with inline CSS (dark theme), per-file mutation maps with color-coded lines (green=killed, red=survived, yellow=timeout), expandable survived mutation details with diffs and suggestions. Writes to evilution-report.html. 17 reporter specs, 724 total passing."}
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":"closed","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-21T00:22:25.796367972+07:00","closed_at":"2026-03-21T00:22:25.796367972+07:00","close_reason":"Implemented equivalent mutation detection with 4 heuristics: NoopSource (identical source), MethodBodyNil (empty/nil method bodies), AliasSwap (detect/find, length/size, collect/map), DeadCode (unreachable code after return/raise). Integrated into runner pipeline, reporters (CLI/JSON/HTML), and MCP tool. Equivalents excluded from score denominator. 100% mutation score (211/211). 753 total specs passing."}
128
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"}
129
129
  {"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"}
130
130
  {"id":"EV-70","title":"Epic: Fix memory leaks and add memory observability","description":"Evilution processes consume up to 17.2 GB each due to memory leaks. This epic covers: (1) identifying and fixing root causes — double forking, upfront mutation array, source string retention in results, Marshal round-trips; (2) adding runtime memory instrumentation behind --verbose; (3) adding memory budget specs to catch regressions in CI.","status":"closed","priority":1,"issue_type":"epic","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-18T18:51:39.345459861+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-19T14:34:58.765114612+07:00","closed_at":"2026-03-19T14:34:58.765114612+07:00","close_reason":"All 14 sub-issues complete. Memory leaks fixed, observability added, rake memory:check in place for regression detection.","external_ref":"gh-124","labels":["v0.7.0"]}
@@ -144,5 +144,6 @@
144
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
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
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"}
147
+ {"id":"EV-73","title":"Clean up deprecated CLI switches and config options","description":"Remove long-deprecated --diff, --no-coverage flags and coverage config option. GH #176.","status":"closed","priority":2,"issue_type":"task","owner":"denis.kiselyov@gmail.com","created_at":"2026-03-21T00:37:09.266487789+07:00","created_by":"Denis Kiselev","updated_at":"2026-03-21T00:49:24.827479374+07:00","closed_at":"2026-03-21T00:49:24.827479374+07:00","close_reason":"Removed deprecated --diff, --no-coverage CLI flags; removed diff_base, coverage, diff? from Config; removed filter_by_diff from Runner; deleted diff/parser, diff/file_filter, coverage/collector, coverage/test_map and their specs; cleaned up requires. 712 specs passing.","external_ref":"gh-176"}
147
148
  {"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."}
148
149
  {"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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.10.0] - 2026-03-21
4
+
5
+ ### Added
6
+
7
+ - **SendMutation operator** — new mutation operator that replaces method calls with semantically related alternatives (e.g. `detect` ↔ `find`, `map` ↔ `flat_map`, `length` ↔ `size`, `gsub` ↔ `sub`, `send` ↔ `public_send`, and more); 17 replacement pairs covering common Ruby method families
8
+ - **ArgumentNilSubstitution operator** — new mutation operator that replaces each positional argument with `nil` one at a time (e.g. `foo(a, b)` → `foo(nil, b)`, `foo(a, nil)`); skips splat, keyword, block, and forwarding arguments
9
+ - **HTML report** (`--format html`) — self-contained HTML mutation report with dark theme, color-coded mutation map, survived mutation diffs with suggestions, and score badge; written to `evilution-report.html`
10
+ - **Equivalent mutation detection** — automatically identifies mutations that produce semantically identical behavior using four heuristics: noop source (identical before/after), method body nil (empty/nil methods), alias swap (detect↔find, length↔size, collect↔map), and dead code (unreachable statements after return/raise); equivalent mutations are excluded from the mutation score denominator
11
+ - **MCP tool equivalent trimming** — diffs are stripped from equivalent mutation entries in MCP responses alongside killed and neutral entries
12
+
13
+ ### Removed
14
+
15
+ - **`--diff` CLI flag** — deprecated since v0.2.0; use line-range targeting instead (e.g. `evilution run lib/foo.rb:15-30`)
16
+ - **`--no-coverage` CLI flag** — deprecated since v0.2.0; had no effect
17
+ - **`diff_base` and `coverage` config keys** — no longer recognized in `.evilution.yml`; config file warnings removed
18
+ - **`Diff::Parser` and `Diff::FileFilter` modules** — dead code removed along with specs
19
+ - **`Coverage::Collector` and `Coverage::TestMap` modules** — dead code removed along with specs
20
+
3
21
  ## [0.9.0] - 2026-03-19
4
22
 
5
23
  ### Added
data/lib/evilution/cli.rb CHANGED
@@ -106,22 +106,13 @@ module Evilution
106
106
  def add_core_options(opts)
107
107
  opts.on("-j", "--jobs N", Integer, "Number of parallel workers (default: 1)") { |n| @options[:jobs] = n }
108
108
  opts.on("-t", "--timeout N", Integer, "Per-mutation timeout in seconds") { |n| @options[:timeout] = n }
109
- opts.on("-f", "--format FORMAT", "Output format: text, json") { |f| @options[:format] = f.to_sym }
110
- opts.on("--diff BASE", "DEPRECATED: Use line-range targeting instead") do |b|
111
- warn("Warning: --diff is deprecated and will be removed in a future version. " \
112
- "Use line-range targeting instead: evilution run lib/foo.rb:15-30")
113
- @options[:diff_base] = b
114
- end
109
+ opts.on("-f", "--format FORMAT", "Output format: text, json, html") { |f| @options[:format] = f.to_sym }
115
110
  end
116
111
 
117
112
  def add_filter_options(opts)
118
113
  opts.on("--min-score FLOAT", Float, "Minimum mutation score to pass") { |s| @options[:min_score] = s }
119
114
  opts.on("--spec FILES", Array, "Spec files to run (comma-separated)") { |f| @options[:spec_files] = f }
120
115
  opts.on("--target METHOD", "Only mutate the named method (e.g. Foo::Bar#calculate)") { |m| @options[:target] = m }
121
- opts.on("--no-coverage", "DEPRECATED: Has no effect and will be removed in a future version") do
122
- warn("Warning: --no-coverage is deprecated, currently has no effect, and will be removed in a future version.")
123
- @options[:coverage] = false
124
- end
125
116
  end
126
117
 
127
118
  def add_flag_options(opts)
@@ -9,11 +9,9 @@ module Evilution
9
9
  DEFAULTS = {
10
10
  timeout: 30,
11
11
  format: :text,
12
- diff_base: nil,
13
12
  target: nil,
14
13
  min_score: 0.0,
15
14
  integration: :rspec,
16
- coverage: true,
17
15
  verbose: false,
18
16
  quiet: false,
19
17
  jobs: 1,
@@ -25,14 +23,13 @@ module Evilution
25
23
  spec_files: []
26
24
  }.freeze
27
25
 
28
- attr_reader :target_files, :timeout, :format, :diff_base,
29
- :target, :min_score, :integration, :coverage, :verbose, :quiet,
26
+ attr_reader :target_files, :timeout, :format,
27
+ :target, :min_score, :integration, :verbose, :quiet,
30
28
  :jobs, :fail_fast, :baseline, :isolation, :incremental, :line_ranges, :spec_files
31
29
 
32
30
  def initialize(**options)
33
31
  file_options = options.delete(:skip_config_file) ? {} : load_config_file
34
32
  merged = DEFAULTS.merge(file_options).merge(options)
35
- warn_removed_options(merged, file_options)
36
33
  assign_attributes(merged)
37
34
  freeze
38
35
  end
@@ -45,8 +42,8 @@ module Evilution
45
42
  format == :text
46
43
  end
47
44
 
48
- def diff?
49
- !diff_base.nil?
45
+ def html?
46
+ format == :html
50
47
  end
51
48
 
52
49
  def line_ranges?
@@ -107,9 +104,6 @@ module Evilution
107
104
 
108
105
  # Stop after N surviving mutants (default: disabled)
109
106
  # fail_fast: 1
110
-
111
- # DEPRECATED: Coverage filtering is deprecated and will be removed
112
- # coverage: true
113
107
  YAML
114
108
  end
115
109
 
@@ -130,11 +124,9 @@ module Evilution
130
124
  @target_files = Array(merged[:target_files])
131
125
  @timeout = merged[:timeout]
132
126
  @format = merged[:format].to_sym
133
- @diff_base = merged[:diff_base]
134
127
  @target = merged[:target]
135
128
  @min_score = merged[:min_score].to_f
136
129
  @integration = merged[:integration].to_sym
137
- @coverage = merged[:coverage]
138
130
  @verbose = merged[:verbose]
139
131
  @quiet = merged[:quiet]
140
132
  @jobs = validate_jobs(merged[:jobs])
@@ -166,18 +158,6 @@ module Evilution
166
158
  raise ConfigError, "jobs must be a positive integer, got #{value.inspect}"
167
159
  end
168
160
 
169
- def warn_removed_options(_merged, file_options)
170
- if file_options.key?(:coverage)
171
- warn("Warning: 'coverage' in config file is deprecated and ignored. " \
172
- "This option will be removed in a future version.")
173
- end
174
-
175
- return unless file_options[:diff_base]
176
-
177
- warn("Warning: 'diff_base' in config file is deprecated and will be removed in a future version. " \
178
- "Use line-range targeting instead: evilution run lib/foo.rb:15-30")
179
- end
180
-
181
161
  def load_config_file
182
162
  self.class.file_options
183
163
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "heuristic/noop_source"
4
+ require_relative "heuristic/method_body_nil"
5
+ require_relative "heuristic/alias_swap"
6
+ require_relative "heuristic/dead_code"
7
+
8
+ module Evilution
9
+ module Equivalent
10
+ class Detector
11
+ def initialize(heuristics: nil)
12
+ @heuristics = heuristics || default_heuristics
13
+ end
14
+
15
+ def call(mutations)
16
+ equivalent = []
17
+ remaining = []
18
+
19
+ mutations.each do |mutation|
20
+ if @heuristics.any? { |h| h.match?(mutation) }
21
+ equivalent << mutation
22
+ else
23
+ remaining << mutation
24
+ end
25
+ end
26
+
27
+ [equivalent, remaining]
28
+ end
29
+
30
+ private
31
+
32
+ def default_heuristics
33
+ [
34
+ Heuristic::NoopSource.new,
35
+ Heuristic::MethodBodyNil.new,
36
+ Heuristic::AliasSwap.new,
37
+ Heuristic::DeadCode.new
38
+ ]
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution
4
+ module Equivalent
5
+ module Heuristic
6
+ class AliasSwap
7
+ ALIAS_PAIRS = Set[
8
+ Set[:detect, :find],
9
+ Set[:length, :size],
10
+ Set[:collect, :map]
11
+ ].freeze
12
+
13
+ def match?(mutation)
14
+ return false unless mutation.operator_name == "send_mutation"
15
+
16
+ diff = mutation.diff
17
+ removed = extract_method(diff, "- ")
18
+ added = extract_method(diff, "+ ")
19
+ return false unless removed && added
20
+
21
+ pair = Set[removed.to_sym, added.to_sym]
22
+ ALIAS_PAIRS.include?(pair)
23
+ end
24
+
25
+ private
26
+
27
+ def extract_method(diff, prefix)
28
+ line = diff.split("\n").find { |l| l.start_with?(prefix) }
29
+ return nil unless line
30
+
31
+ match = line.match(/\.(\w+)(?:[\s(]|$)/)
32
+ match && match[1]
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution
4
+ module Equivalent
5
+ module Heuristic
6
+ class DeadCode
7
+ def match?(mutation)
8
+ return false unless mutation.operator_name == "statement_deletion"
9
+
10
+ node = mutation.subject.node
11
+ return false unless node
12
+
13
+ body = node.body
14
+ return false unless body.is_a?(Prism::StatementsNode)
15
+
16
+ statements = body.body
17
+ unreachable_lines = find_unreachable_lines(statements)
18
+ unreachable_lines.include?(mutation.line)
19
+ end
20
+
21
+ private
22
+
23
+ def find_unreachable_lines(statements)
24
+ lines = Set.new
25
+ found_unconditional_return = false
26
+
27
+ statements.each do |stmt|
28
+ if found_unconditional_return
29
+ collect_lines(stmt, lines)
30
+ elsif unconditional_return?(stmt)
31
+ found_unconditional_return = true
32
+ end
33
+ end
34
+
35
+ lines
36
+ end
37
+
38
+ def unconditional_return?(node)
39
+ node.is_a?(Prism::ReturnNode) ||
40
+ (node.is_a?(Prism::CallNode) && node.name == :raise)
41
+ end
42
+
43
+ def collect_lines(node, lines)
44
+ start_line = node.location.start_line
45
+ end_line = node.location.end_line
46
+ (start_line..end_line).each { |l| lines.add(l) }
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution
4
+ module Equivalent
5
+ module Heuristic
6
+ class MethodBodyNil
7
+ def match?(mutation)
8
+ return false unless mutation.operator_name == "method_body_replacement"
9
+
10
+ node = mutation.subject.node
11
+ return false unless node
12
+
13
+ body = node.body
14
+ return true if body.nil? || body.is_a?(Prism::NilNode)
15
+
16
+ return body.body.first.is_a?(Prism::NilNode) if body.is_a?(Prism::StatementsNode) && body.body.length == 1
17
+
18
+ false
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution
4
+ module Equivalent
5
+ module Heuristic
6
+ class NoopSource
7
+ def match?(mutation)
8
+ mutation.original_source == mutation.mutated_source
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -103,6 +103,7 @@ module Evilution
103
103
  data = ::JSON.parse(json_string)
104
104
  strip_diffs(data, "killed")
105
105
  strip_diffs(data, "neutral")
106
+ strip_diffs(data, "equivalent")
106
107
  ::JSON.generate(data)
107
108
  end
108
109
 
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution
4
+ module Mutator
5
+ module Operator
6
+ class ArgumentNilSubstitution < Base
7
+ SKIP_TYPES = [
8
+ Prism::SplatNode,
9
+ Prism::KeywordHashNode,
10
+ Prism::BlockArgumentNode,
11
+ Prism::ForwardingArgumentsNode
12
+ ].freeze
13
+
14
+ def visit_call_node(node)
15
+ args = node.arguments&.arguments
16
+
17
+ if args && args.length >= 1 && positional_only?(args)
18
+ args.each_index do |i|
19
+ parts = args.each_with_index.map { |a, j| j == i ? "nil" : a.slice }
20
+ replacement = parts.join(", ")
21
+
22
+ add_mutation(
23
+ offset: node.arguments.location.start_offset,
24
+ length: node.arguments.location.length,
25
+ replacement: replacement,
26
+ node: node
27
+ )
28
+ end
29
+ end
30
+
31
+ super
32
+ end
33
+
34
+ private
35
+
36
+ def positional_only?(args)
37
+ args.none? { |arg| SKIP_TYPES.any? { |type| arg.is_a?(type) } }
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution
4
+ module Mutator
5
+ module Operator
6
+ class SendMutation < Base
7
+ REPLACEMENTS = {
8
+ flat_map: [:map],
9
+ map: [:flat_map],
10
+ collect: [:map],
11
+ public_send: [:send],
12
+ send: [:public_send],
13
+ gsub: [:sub],
14
+ sub: [:gsub],
15
+ detect: [:find],
16
+ find: [:detect],
17
+ each_with_object: [:inject],
18
+ inject: [:each_with_object],
19
+ reverse_each: [:each],
20
+ each: [:reverse_each],
21
+ length: [:size],
22
+ size: [:length],
23
+ values_at: [:fetch_values],
24
+ fetch_values: [:values_at]
25
+ }.freeze
26
+
27
+ def visit_call_node(node)
28
+ replacements = REPLACEMENTS[node.name]
29
+ return super unless replacements
30
+ return super unless node.receiver
31
+
32
+ loc = node.message_loc
33
+ return super unless loc
34
+
35
+ replacements.each do |replacement|
36
+ add_mutation(
37
+ offset: loc.start_offset,
38
+ length: loc.length,
39
+ replacement: replacement.to_s,
40
+ node: node
41
+ )
42
+ end
43
+
44
+ super
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -30,7 +30,9 @@ module Evilution
30
30
  Operator::ConditionalFlip,
31
31
  Operator::RangeReplacement,
32
32
  Operator::RegexpMutation,
33
- Operator::ReceiverReplacement
33
+ Operator::ReceiverReplacement,
34
+ Operator::SendMutation,
35
+ Operator::ArgumentNilSubstitution
34
36
  ].each { |op| registry.register(op) }
35
37
  registry
36
38
  end
@@ -17,6 +17,7 @@ module Evilution
17
17
  lines << peak_memory_line(peak) if peak
18
18
  append_survived(lines, summary)
19
19
  append_neutral(lines, summary)
20
+ append_equivalent(lines, summary)
20
21
  lines << ""
21
22
  lines << "[TRUNCATED] Stopped early due to --fail-fast" if summary.truncated?
22
23
  lines << result_line(summary)
@@ -42,6 +43,14 @@ module Evilution
42
43
  summary.neutral_results.each { |result| lines << format_neutral(result) }
43
44
  end
44
45
 
46
+ def append_equivalent(lines, summary)
47
+ return unless summary.equivalent_results.any?
48
+
49
+ lines << ""
50
+ lines << "Equivalent mutations (provably identical behavior):"
51
+ summary.equivalent_results.each { |result| lines << format_neutral(result) }
52
+ end
53
+
45
54
  def header
46
55
  "Evilution v#{Evilution::VERSION} — Mutation Testing Results"
47
56
  end
@@ -50,11 +59,12 @@ module Evilution
50
59
  parts = "Mutations: #{summary.total} total, #{summary.killed} killed, " \
51
60
  "#{summary.survived} survived, #{summary.timed_out} timed out"
52
61
  parts += ", #{summary.neutral} neutral" if summary.neutral.positive?
62
+ parts += ", #{summary.equivalent} equivalent" if summary.equivalent.positive?
53
63
  parts
54
64
  end
55
65
 
56
66
  def score_line(summary)
57
- denominator = summary.total - summary.errors - summary.neutral
67
+ denominator = summary.total - summary.errors - summary.neutral - summary.equivalent
58
68
  score_pct = format_pct(summary.score)
59
69
  "Score: #{score_pct} (#{summary.killed}/#{denominator})"
60
70
  end
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require_relative "suggestion"
5
+
6
+ module Evilution
7
+ module Reporter
8
+ class HTML # rubocop:disable Metrics/ClassLength
9
+ def initialize
10
+ @suggestion = Suggestion.new
11
+ end
12
+
13
+ def call(summary)
14
+ files = group_by_file(summary.results)
15
+ build_html(summary, files)
16
+ end
17
+
18
+ private
19
+
20
+ def group_by_file(results)
21
+ grouped = {}
22
+ results.each do |result|
23
+ path = result.mutation.file_path
24
+ grouped[path] ||= []
25
+ grouped[path] << result
26
+ end
27
+ grouped.sort_by { |path, _| path }.to_h
28
+ end
29
+
30
+ def build_html(summary, files)
31
+ <<~HTML
32
+ <!DOCTYPE html>
33
+ <html lang="en">
34
+ <head>
35
+ <meta charset="UTF-8">
36
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
37
+ <title>Evilution Mutation Report</title>
38
+ #{stylesheet}
39
+ </head>
40
+ <body>
41
+ #{build_header(summary)}
42
+ #{build_summary_cards(summary)}
43
+ #{build_truncation_notice(summary)}
44
+ #{build_file_sections(files)}
45
+ #{build_footer}
46
+ </body>
47
+ </html>
48
+ HTML
49
+ end
50
+
51
+ def build_header(summary)
52
+ score_pct = format("%.2f%%", summary.score * 100)
53
+ score_class = score_css_class(summary.score)
54
+ <<~HTML
55
+ <header>
56
+ <h1>Evilution <span class="version">v#{h(Evilution::VERSION)}</span></h1>
57
+ <div class="score-badge #{score_class}">#{score_pct}</div>
58
+ </header>
59
+ HTML
60
+ end
61
+
62
+ def build_summary_cards(summary)
63
+ peak = summary.peak_memory_mb
64
+ peak_html = if peak
65
+ peak_val = format("%.1f", peak)
66
+ "<div class=\"card\"><span class=\"card-value\">#{peak_val} MB</span>" \
67
+ "<span class=\"card-label\">Peak Memory</span></div>"
68
+ else
69
+ ""
70
+ end
71
+ <<~HTML
72
+ <section class="summary-cards">
73
+ <div class="card"><span class="card-value">#{summary.total}</span><span class="card-label">Total</span></div>
74
+ <div class="card card-killed"><span class="card-value">#{summary.killed}</span><span class="card-label">Killed</span></div>
75
+ <div class="card card-survived"><span class="card-value">#{summary.survived}</span><span class="card-label">Survived</span></div>
76
+ <div class="card"><span class="card-value">#{summary.timed_out}</span><span class="card-label">Timed Out</span></div>
77
+ <div class="card"><span class="card-value">#{summary.errors}</span><span class="card-label">Errors</span></div>
78
+ <div class="card"><span class="card-value">#{summary.neutral}</span><span class="card-label">Neutral</span></div>
79
+ <div class="card"><span class="card-value">#{summary.equivalent}</span><span class="card-label">Equivalent</span></div>
80
+ <div class="card"><span class="card-value">#{format("%.2f", summary.duration)}s</span><span class="card-label">Duration</span></div>
81
+ #{peak_html}
82
+ </section>
83
+ HTML
84
+ end
85
+
86
+ def build_truncation_notice(summary)
87
+ return "" unless summary.truncated?
88
+
89
+ '<div class="truncation-notice">Truncated: Stopped early due to --fail-fast</div>'
90
+ end
91
+
92
+ def build_file_sections(files)
93
+ return '<p class="empty">No mutations generated.</p>' if files.empty?
94
+
95
+ files.map { |path, results| build_file_section(path, results) }.join("\n")
96
+ end
97
+
98
+ def build_file_section(path, results)
99
+ killed_count = results.count(&:killed?)
100
+ survived_count = results.count(&:survived?)
101
+ total = results.length
102
+ survived = results.select(&:survived?)
103
+ map_html = build_mutation_map(results)
104
+
105
+ <<~HTML
106
+ <section class="file-section">
107
+ <h2 class="file-header">
108
+ <span class="file-path">#{h(path)}</span>
109
+ <span class="file-stats">#{killed_count} killed / #{survived_count} survived / #{total} total</span>
110
+ </h2>
111
+ <div class="mutation-map">#{map_html}</div>
112
+ #{build_survived_details(survived)}
113
+ </section>
114
+ HTML
115
+ end
116
+
117
+ def build_mutation_map(results)
118
+ results
119
+ .sort_by { |r| r.mutation.line }
120
+ .map { |r| build_map_entry(r) }
121
+ .join("\n")
122
+ end
123
+
124
+ def build_map_entry(result)
125
+ mutation = result.mutation
126
+ status = result.status.to_s
127
+ <<~HTML.chomp
128
+ <div class="map-line #{status}">
129
+ <span class="line-number">line #{mutation.line}</span>
130
+ <span class="operator">#{h(mutation.operator_name)}</span>
131
+ <span class="status-badge #{status}">#{status}</span>
132
+ </div>
133
+ HTML
134
+ end
135
+
136
+ def build_survived_details(survived)
137
+ return "" if survived.empty?
138
+
139
+ entries = survived.map { |r| build_survived_entry(r) }.join("\n")
140
+ <<~HTML
141
+ <div class="survived-details">
142
+ <h3>Survived Mutations</h3>
143
+ #{entries}
144
+ </div>
145
+ HTML
146
+ end
147
+
148
+ def build_survived_entry(result)
149
+ mutation = result.mutation
150
+ suggestion_text = @suggestion.suggestion_for(mutation)
151
+ diff_html = format_diff(mutation.diff)
152
+ <<~HTML
153
+ <div class="survived-entry">
154
+ <div class="survived-header">
155
+ <span class="operator">#{h(mutation.operator_name)}</span>
156
+ <span class="location">#{h(mutation.file_path)}:#{mutation.line}</span>
157
+ </div>
158
+ <pre class="diff">#{diff_html}</pre>
159
+ <div class="suggestion">#{h(suggestion_text)}</div>
160
+ </div>
161
+ HTML
162
+ end
163
+
164
+ def format_diff(diff)
165
+ diff.split("\n").map do |line|
166
+ escaped = h(line)
167
+ css_class = if line.start_with?("- ")
168
+ "diff-removed"
169
+ elsif line.start_with?("+ ")
170
+ "diff-added"
171
+ else
172
+ ""
173
+ end
174
+ %(<span class="#{css_class}">#{escaped}</span>)
175
+ end.join("\n")
176
+ end
177
+
178
+ def build_footer
179
+ "<footer>Generated by Evilution v#{h(Evilution::VERSION)}</footer>"
180
+ end
181
+
182
+ def score_css_class(score)
183
+ if score >= 0.8
184
+ "score-high"
185
+ elsif score >= 0.5
186
+ "score-medium"
187
+ else
188
+ "score-low"
189
+ end
190
+ end
191
+
192
+ def h(text)
193
+ CGI.escapeHTML(text.to_s)
194
+ end
195
+
196
+ def stylesheet
197
+ <<~HTML
198
+ <style>
199
+ * { margin: 0; padding: 0; box-sizing: border-box; }
200
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0d1117; color: #c9d1d9; padding: 2rem; max-width: 1200px; margin: 0 auto; }
201
+ header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; }
202
+ h1 { font-size: 1.5rem; color: #f0f6fc; }
203
+ .version { color: #8b949e; font-weight: normal; font-size: 0.9rem; }
204
+ .score-badge { font-size: 1.8rem; font-weight: bold; padding: 0.3rem 1rem; border-radius: 8px; }
205
+ .score-high { background: #1a4731; color: #3fb950; }
206
+ .score-medium { background: #4a3a10; color: #d29922; }
207
+ .score-low { background: #4a1a1a; color: #f85149; }
208
+ .summary-cards { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 2rem; }
209
+ .card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1rem 1.2rem; text-align: center; min-width: 100px; }
210
+ .card-value { display: block; font-size: 1.4rem; font-weight: bold; color: #f0f6fc; }
211
+ .card-label { display: block; font-size: 0.75rem; color: #8b949e; margin-top: 0.2rem; text-transform: uppercase; }
212
+ .card-killed { border-color: #238636; }
213
+ .card-survived { border-color: #da3633; }
214
+ .truncation-notice { background: #4a3a10; border: 1px solid #d29922; color: #d29922; padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 2rem; }
215
+ .file-section { background: #161b22; border: 1px solid #30363d; border-radius: 8px; margin-bottom: 1.5rem; overflow: hidden; }
216
+ .file-header { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; background: #1c2129; border-bottom: 1px solid #30363d; font-size: 0.9rem; }
217
+ .file-path { color: #58a6ff; font-family: monospace; }
218
+ .file-stats { color: #8b949e; font-size: 0.8rem; font-weight: normal; }
219
+ .mutation-map { padding: 0.5rem 1rem; }
220
+ .map-line { display: flex; align-items: center; gap: 0.75rem; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.85rem; font-family: monospace; }
221
+ .map-line.killed { color: #3fb950; }
222
+ .map-line.survived { color: #f85149; }
223
+ .map-line.timeout { color: #d29922; }
224
+ .map-line.error { color: #f85149; }
225
+ .map-line.neutral { color: #8b949e; }
226
+ .map-line.equivalent { color: #8b949e; }
227
+ .line-number { min-width: 60px; color: #8b949e; }
228
+ .operator { flex: 1; }
229
+ .status-badge { font-size: 0.7rem; padding: 0.1rem 0.5rem; border-radius: 10px; text-transform: uppercase; font-weight: bold; }
230
+ .status-badge.killed { background: #1a4731; }
231
+ .status-badge.survived { background: #4a1a1a; }
232
+ .status-badge.timeout { background: #4a3a10; }
233
+ .status-badge.neutral { background: #21262d; }
234
+ .status-badge.equivalent { background: #21262d; }
235
+ .survived-details { border-top: 1px solid #30363d; padding: 1rem; }
236
+ .survived-details h3 { color: #f85149; font-size: 0.9rem; margin-bottom: 0.75rem; }
237
+ .survived-entry { background: #1c1a1a; border: 1px solid #4a1a1a; border-radius: 6px; padding: 0.75rem; margin-bottom: 0.75rem; }
238
+ .survived-header { display: flex; justify-content: space-between; margin-bottom: 0.5rem; font-size: 0.85rem; }
239
+ .survived-header .operator { color: #f85149; font-weight: bold; }
240
+ .survived-header .location { color: #8b949e; font-family: monospace; }
241
+ .diff { background: #0d1117; border-radius: 4px; padding: 0.5rem 0.75rem; font-size: 0.8rem; overflow-x: auto; line-height: 1.5; }
242
+ .diff-removed { color: #f85149; display: block; }
243
+ .diff-added { color: #3fb950; display: block; }
244
+ .suggestion { color: #d29922; font-size: 0.8rem; margin-top: 0.5rem; font-style: italic; }
245
+ .empty { color: #8b949e; text-align: center; padding: 2rem; }
246
+ footer { text-align: center; color: #484f58; font-size: 0.75rem; margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #21262d; }
247
+ </style>
248
+ HTML
249
+ end
250
+ end
251
+ end
252
+ end
@@ -17,6 +17,7 @@ module Evilution
17
17
 
18
18
  private
19
19
 
20
+ # rubocop:disable Metrics/PerceivedComplexity
20
21
  def build_report(summary)
21
22
  {
22
23
  version: Evilution::VERSION,
@@ -26,9 +27,11 @@ module Evilution
26
27
  killed: summary.killed_results.map { |r| build_mutation_detail(r) },
27
28
  neutral: summary.neutral_results.map { |r| build_mutation_detail(r) },
28
29
  timed_out: summary.results.select(&:timeout?).map { |r| build_mutation_detail(r) },
29
- errors: summary.results.select(&:error?).map { |r| build_mutation_detail(r) }
30
+ errors: summary.results.select(&:error?).map { |r| build_mutation_detail(r) },
31
+ equivalent: summary.equivalent_results.map { |r| build_mutation_detail(r) }
30
32
  }
31
33
  end
34
+ # rubocop:enable Metrics/PerceivedComplexity
32
35
 
33
36
  def build_summary(summary)
34
37
  data = {
@@ -38,6 +41,7 @@ module Evilution
38
41
  timed_out: summary.timed_out,
39
42
  errors: summary.errors,
40
43
  neutral: summary.neutral,
44
+ equivalent: summary.equivalent,
41
45
  score: summary.score.round(4),
42
46
  duration: summary.duration.round(4)
43
47
  }
@@ -3,7 +3,7 @@
3
3
  module Evilution
4
4
  module Result
5
5
  class MutationResult
6
- STATUSES = %i[killed survived timeout error neutral].freeze
6
+ STATUSES = %i[killed survived timeout error neutral equivalent].freeze
7
7
 
8
8
  attr_reader :mutation, :status, :duration, :killing_test, :test_command,
9
9
  :child_rss_kb, :memory_delta_kb
@@ -41,6 +41,10 @@ module Evilution
41
41
  def neutral?
42
42
  status == :neutral
43
43
  end
44
+
45
+ def equivalent?
46
+ status == :equivalent
47
+ end
44
48
  end
45
49
  end
46
50
  end
@@ -40,8 +40,12 @@ module Evilution
40
40
  results.count(&:neutral?)
41
41
  end
42
42
 
43
+ def equivalent
44
+ results.count(&:equivalent?)
45
+ end
46
+
43
47
  def score
44
- denominator = total - errors - neutral
48
+ denominator = total - errors - neutral - equivalent
45
49
  return 0.0 if denominator.zero?
46
50
 
47
51
  killed.to_f / denominator
@@ -63,6 +67,10 @@ module Evilution
63
67
  results.select(&:neutral?)
64
68
  end
65
69
 
70
+ def equivalent_results
71
+ results.select(&:equivalent?)
72
+ end
73
+
66
74
  def peak_memory_mb
67
75
  max_rss = nil
68
76
  results.each do |result|
@@ -9,9 +9,9 @@ require_relative "isolation/in_process"
9
9
  require_relative "integration/rspec"
10
10
  require_relative "reporter/json"
11
11
  require_relative "reporter/cli"
12
+ require_relative "reporter/html"
12
13
  require_relative "reporter/suggestion"
13
- require_relative "diff/parser"
14
- require_relative "diff/file_filter"
14
+ require_relative "equivalent/detector"
15
15
  require_relative "git/changed_files"
16
16
  require_relative "result/mutation_result"
17
17
  require_relative "result/summary"
@@ -37,13 +37,18 @@ module Evilution
37
37
  subjects = parse_subjects
38
38
  subjects = filter_by_target(subjects) if config.target?
39
39
  subjects = filter_by_line_ranges(subjects) if config.line_ranges?
40
- subjects = filter_by_diff(subjects) if config.diff?
41
40
  log_memory("after parse_subjects", "#{subjects.length} subjects")
42
41
 
43
42
  baseline_result = run_baseline(subjects)
44
43
 
45
44
  mutations = generate_mutations(subjects)
45
+ equivalent_mutations, mutations = filter_equivalent(mutations)
46
+ release_subject_nodes(subjects)
46
47
  results, truncated = run_mutations(mutations, baseline_result)
48
+ results += equivalent_mutations.map do |m|
49
+ m.strip_sources!
50
+ equivalent_result(m)
51
+ end
47
52
  log_memory("after run_mutations", "#{results.length} results")
48
53
 
49
54
  duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
@@ -91,20 +96,24 @@ module Evilution
91
96
  end
92
97
  end
93
98
 
94
- def filter_by_diff(subjects)
95
- diff_parser = Diff::Parser.new
96
- changed_ranges = diff_parser.parse(config.diff_base)
97
- Diff::FileFilter.new.filter(subjects, changed_ranges)
98
- end
99
-
100
99
  def generate_mutations(subjects)
101
100
  subjects.flat_map do |subject|
102
- mutations = registry.mutations_for(subject)
103
- subject.release_node!
104
- mutations
101
+ registry.mutations_for(subject)
105
102
  end
106
103
  end
107
104
 
105
+ def filter_equivalent(mutations)
106
+ Equivalent::Detector.new.call(mutations)
107
+ end
108
+
109
+ def release_subject_nodes(subjects)
110
+ subjects.each(&:release_node!)
111
+ end
112
+
113
+ def equivalent_result(mutation)
114
+ Result::MutationResult.new(mutation: mutation, status: :equivalent, duration: 0.0)
115
+ end
116
+
108
117
  def run_baseline(subjects)
109
118
  return nil unless config.baseline? && subjects.any?
110
119
 
@@ -281,7 +290,15 @@ module Evilution
281
290
  return unless reporter
282
291
 
283
292
  output = reporter.call(summary)
284
- $stdout.puts(output) unless config.quiet
293
+ return if config.quiet
294
+
295
+ if config.html?
296
+ path = "evilution-report.html"
297
+ File.write(path, output)
298
+ warn "HTML report written to #{path}"
299
+ else
300
+ $stdout.puts(output)
301
+ end
285
302
  end
286
303
 
287
304
  def log_baseline_start
@@ -350,6 +367,8 @@ module Evilution
350
367
  Reporter::JSON.new
351
368
  when :text
352
369
  Reporter::CLI.new
370
+ when :html
371
+ Reporter::HTML.new
353
372
  end
354
373
  end
355
374
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Evilution
4
- VERSION = "0.9.0"
4
+ VERSION = "0.10.0"
5
5
  end
data/lib/evilution.rb CHANGED
@@ -33,12 +33,13 @@ require_relative "evilution/mutator/operator/conditional_flip"
33
33
  require_relative "evilution/mutator/operator/range_replacement"
34
34
  require_relative "evilution/mutator/operator/regexp_mutation"
35
35
  require_relative "evilution/mutator/operator/receiver_replacement"
36
+ require_relative "evilution/mutator/operator/send_mutation"
37
+ require_relative "evilution/mutator/operator/argument_nil_substitution"
36
38
  require_relative "evilution/mutator/registry"
39
+ require_relative "evilution/equivalent/detector"
37
40
  require_relative "evilution/isolation/fork"
38
41
  require_relative "evilution/isolation/in_process"
39
42
  require_relative "evilution/parallel/pool"
40
- require_relative "evilution/diff/parser"
41
- require_relative "evilution/diff/file_filter"
42
43
  require_relative "evilution/git/changed_files"
43
44
  require_relative "evilution/integration/base"
44
45
  require_relative "evilution/integration/rspec"
@@ -46,9 +47,8 @@ require_relative "evilution/result/mutation_result"
46
47
  require_relative "evilution/result/summary"
47
48
  require_relative "evilution/reporter/json"
48
49
  require_relative "evilution/reporter/cli"
50
+ require_relative "evilution/reporter/html"
49
51
  require_relative "evilution/reporter/suggestion"
50
- require_relative "evilution/coverage/collector"
51
- require_relative "evilution/coverage/test_map"
52
52
  require_relative "evilution/spec_resolver"
53
53
  require_relative "evilution/baseline"
54
54
  require_relative "evilution/cache"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: evilution
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Denis Kiselev
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-03-19 00:00:00.000000000 Z
11
+ date: 2026-03-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: diff-lcs
@@ -84,10 +84,11 @@ files:
84
84
  - lib/evilution/cache.rb
85
85
  - lib/evilution/cli.rb
86
86
  - lib/evilution/config.rb
87
- - lib/evilution/coverage/collector.rb
88
- - lib/evilution/coverage/test_map.rb
89
- - lib/evilution/diff/file_filter.rb
90
- - lib/evilution/diff/parser.rb
87
+ - lib/evilution/equivalent/detector.rb
88
+ - lib/evilution/equivalent/heuristic/alias_swap.rb
89
+ - lib/evilution/equivalent/heuristic/dead_code.rb
90
+ - lib/evilution/equivalent/heuristic/method_body_nil.rb
91
+ - lib/evilution/equivalent/heuristic/noop_source.rb
91
92
  - lib/evilution/git/changed_files.rb
92
93
  - lib/evilution/integration/base.rb
93
94
  - lib/evilution/integration/rspec.rb
@@ -99,6 +100,7 @@ files:
99
100
  - lib/evilution/memory/leak_check.rb
100
101
  - lib/evilution/mutation.rb
101
102
  - lib/evilution/mutator/base.rb
103
+ - lib/evilution/mutator/operator/argument_nil_substitution.rb
102
104
  - lib/evilution/mutator/operator/argument_removal.rb
103
105
  - lib/evilution/mutator/operator/arithmetic_replacement.rb
104
106
  - lib/evilution/mutator/operator/array_literal.rb
@@ -121,12 +123,14 @@ files:
121
123
  - lib/evilution/mutator/operator/receiver_replacement.rb
122
124
  - lib/evilution/mutator/operator/regexp_mutation.rb
123
125
  - lib/evilution/mutator/operator/return_value_removal.rb
126
+ - lib/evilution/mutator/operator/send_mutation.rb
124
127
  - lib/evilution/mutator/operator/statement_deletion.rb
125
128
  - lib/evilution/mutator/operator/string_literal.rb
126
129
  - lib/evilution/mutator/operator/symbol_literal.rb
127
130
  - lib/evilution/mutator/registry.rb
128
131
  - lib/evilution/parallel/pool.rb
129
132
  - lib/evilution/reporter/cli.rb
133
+ - lib/evilution/reporter/html.rb
130
134
  - lib/evilution/reporter/json.rb
131
135
  - lib/evilution/reporter/suggestion.rb
132
136
  - lib/evilution/result/mutation_result.rb
@@ -1,47 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "coverage"
4
- require "stringio"
5
-
6
- module Evilution
7
- module Coverage
8
- class Collector
9
- def call(test_files:)
10
- read_io, write_io = IO.pipe
11
-
12
- pid = ::Process.fork do
13
- read_io.close
14
- result = collect_coverage(test_files)
15
- Marshal.dump(result, write_io)
16
- write_io.close
17
- exit!(0)
18
- end
19
-
20
- write_io.close
21
- data = read_io.read
22
- read_io.close
23
- ::Process.wait(pid)
24
-
25
- Marshal.load(data) # rubocop:disable Security/MarshalLoad
26
- ensure
27
- read_io&.close
28
- write_io&.close
29
- end
30
-
31
- private
32
-
33
- def collect_coverage(test_files)
34
- ::Coverage.start
35
- return ::Coverage.result if test_files.empty?
36
-
37
- require "rspec/core"
38
- ::RSpec.reset
39
- ::RSpec::Core::Runner.run(
40
- ["--format", "progress", "--no-color", "--order", "defined", *test_files],
41
- StringIO.new, StringIO.new
42
- )
43
- ::Coverage.result
44
- end
45
- end
46
- end
47
- end
@@ -1,25 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Evilution
4
- module Coverage
5
- class TestMap
6
- def initialize(coverage_data)
7
- @coverage_data = coverage_data
8
- end
9
-
10
- # Returns true if the given source line was executed during tests.
11
- # file_path should be an absolute path matching coverage data keys.
12
- # line is 1-based (editor line numbers).
13
- def covered?(file_path, line)
14
- line_data = @coverage_data[file_path]
15
- return false unless line_data
16
-
17
- index = line - 1
18
- return false if index.negative? || index >= line_data.length
19
-
20
- count = line_data[index]
21
- !count.nil? && count.positive?
22
- end
23
- end
24
- end
25
- end
@@ -1,29 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Evilution
4
- module Diff
5
- class FileFilter
6
- # Filters subjects to only those whose methods overlap with changed lines.
7
- #
8
- # @param subjects [Array<Subject>] All extracted subjects
9
- # @param changed_ranges [Array<Hash>] Output from Diff::Parser#parse
10
- # @return [Array<Subject>] Subjects overlapping with changes
11
- def filter(subjects, changed_ranges)
12
- lookup = build_lookup(changed_ranges)
13
-
14
- subjects.select do |subject|
15
- ranges = lookup[subject.file_path]
16
- next false unless ranges
17
-
18
- ranges.any? { |range| range.cover?(subject.line_number) }
19
- end
20
- end
21
-
22
- private
23
-
24
- def build_lookup(changed_ranges)
25
- changed_ranges.to_h { [_1[:file], _1[:lines]] }
26
- end
27
- end
28
- end
29
- end
@@ -1,47 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Evilution
4
- module Diff
5
- class Parser
6
- # Parses git diff output to extract changed file paths and line ranges.
7
- #
8
- # @param diff_base [String] Git ref to diff against (e.g., "HEAD~1", "main")
9
- # @return [Array<Hash>] Array of { file: String, lines: Array<Range> }
10
- def parse(diff_base)
11
- output = run_git_diff(diff_base)
12
- parse_diff_output(output)
13
- end
14
-
15
- private
16
-
17
- def run_git_diff(diff_base)
18
- `git diff --unified=0 #{diff_base}..HEAD -- '*.rb' 2>/dev/null`
19
- end
20
-
21
- def parse_diff_output(output)
22
- result = {}
23
- current_file = nil
24
-
25
- output.each_line do |line|
26
- case line
27
- when %r{^diff --git a/.+ b/(.+)$}
28
- current_file = Regexp.last_match(1)
29
- when /^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,(\d+))?\s+@@/
30
- next unless current_file
31
-
32
- start_line = Regexp.last_match(1).to_i
33
- count = (Regexp.last_match(2) || "1").to_i
34
-
35
- next if count.zero? # Pure deletion, no new lines
36
-
37
- end_line = start_line + count - 1
38
- result[current_file] ||= []
39
- result[current_file] << (start_line..end_line)
40
- end
41
- end
42
-
43
- result.map { |file, lines| { file: file, lines: lines } }
44
- end
45
- end
46
- end
47
- end