evilution 1.0.0 → 1.1.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 +4 -4
- data/.beads/interactions.jsonl +21 -0
- data/CHANGELOG.md +26 -0
- data/README.md +11 -5
- data/docs/architecture.md +1 -1
- data/lib/evilution/integration/loading/redefinition_recovery.rb +1 -1
- data/lib/evilution/mcp/complete_result_server.rb +41 -0
- data/lib/evilution/mcp/server.rb +2 -1
- data/lib/evilution/mutator/base.rb +14 -2
- data/lib/evilution/mutator/operator/boolean_expression_to_nil.rb +22 -0
- data/lib/evilution/mutator/operator/boolean_operand_promotion.rb +31 -0
- data/lib/evilution/mutator/operator/case_in.rb +64 -0
- data/lib/evilution/mutator/operator/case_when.rb +72 -1
- data/lib/evilution/mutator/operator/conditional_branch.rb +20 -7
- data/lib/evilution/mutator/operator/if_branch_swap.rb +48 -0
- data/lib/evilution/mutator/operator/loop_body_to_raise.rb +68 -0
- data/lib/evilution/mutator/operator/pattern_predicate.rb +28 -0
- data/lib/evilution/mutator/primitives.rb +52 -0
- data/lib/evilution/mutator/registry.rb +6 -0
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +6 -0
- data/scripts/compare_targeting +4 -2
- metadata +10 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 71f26de1d4437f62a01f5d5784a3a67ffdc914418ae952cb279de392ec401ecb
|
|
4
|
+
data.tar.gz: 92d18647ec19836335c5eaa3156ce6da7db1857b285b66f49efc2ed66de49ed0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0174eb2e10182274ed8cbda806172a8d4956caeff7af844c409daa6f3de711b709a450be89cf4dc3a08b36e7df84ca0c0473a66d21909d8752a83bce4309aab2
|
|
7
|
+
data.tar.gz: 8bf2f8cfce0633f537186cd08aaf6abd66c06869d56308647f0be34352d6d94c06912b8c1c0e8128ca19b45acc60536b5a6f35028996ce25ca2a0501bdc1b508
|
data/.beads/interactions.jsonl
CHANGED
|
@@ -448,3 +448,24 @@
|
|
|
448
448
|
{"id":"int-616f39e1","kind":"field_change","created_at":"2026-07-15T13:33:53.68007233Z","actor":"Denis Kiselev","issue_id":"EV-y194","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
|
|
449
449
|
{"id":"int-abcaeb3a","kind":"field_change","created_at":"2026-07-15T13:33:54.363899883Z","actor":"Denis Kiselev","issue_id":"EV-j2kz","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
|
|
450
450
|
{"id":"int-b8027311","kind":"field_change","created_at":"2026-07-15T13:34:47.737536249Z","actor":"Denis Kiselev","issue_id":"EV-w07i","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}}
|
|
451
|
+
{"id":"int-e7b1f137","kind":"field_change","created_at":"2026-08-14T05:36:05.821384373Z","actor":"Denis Kiselev","issue_id":"EV-4ild","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
|
|
452
|
+
{"id":"int-c003e7fa","kind":"field_change","created_at":"2026-08-22T04:23:28.006710286Z","actor":"Denis Kiselev","issue_id":"EV-170m.1","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
|
|
453
|
+
{"id":"int-f912a48c","kind":"field_change","created_at":"2026-08-22T15:26:32.589708305Z","actor":"Denis Kiselev","issue_id":"EV-170m.1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
|
|
454
|
+
{"id":"int-d74d2ac8","kind":"field_change","created_at":"2026-08-22T17:21:54.118527965Z","actor":"Denis Kiselev","issue_id":"EV-170m.2","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
|
|
455
|
+
{"id":"int-d694451a","kind":"field_change","created_at":"2026-08-22T17:21:59.468788556Z","actor":"Denis Kiselev","issue_id":"EV-170m.3","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
|
|
456
|
+
{"id":"int-9cd4dc49","kind":"field_change","created_at":"2026-08-22T17:22:05.341826256Z","actor":"Denis Kiselev","issue_id":"EV-170m.5","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
|
|
457
|
+
{"id":"int-9a64fd8d","kind":"field_change","created_at":"2026-08-23T05:54:32.093591437Z","actor":"Denis Kiselev","issue_id":"EV-170m.14","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
|
|
458
|
+
{"id":"int-990ddf2a","kind":"field_change","created_at":"2026-08-23T05:54:35.35056636Z","actor":"Denis Kiselev","issue_id":"EV-170m.15","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
|
|
459
|
+
{"id":"int-aa07e9f8","kind":"field_change","created_at":"2026-08-23T05:54:38.655416932Z","actor":"Denis Kiselev","issue_id":"EV-170m.16","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
|
|
460
|
+
{"id":"int-e7ba9d2f","kind":"field_change","created_at":"2026-08-23T05:54:41.957571538Z","actor":"Denis Kiselev","issue_id":"EV-170m.10","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
|
|
461
|
+
{"id":"int-c4cf46e8","kind":"field_change","created_at":"2026-08-23T05:54:43.220109392Z","actor":"Denis Kiselev","issue_id":"EV-170m.11","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
|
|
462
|
+
{"id":"int-46ac995b","kind":"field_change","created_at":"2026-08-23T05:54:46.601240327Z","actor":"Denis Kiselev","issue_id":"EV-170m.12","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
|
|
463
|
+
{"id":"int-ee1da7ed","kind":"field_change","created_at":"2026-08-23T05:54:58.551793942Z","actor":"Denis Kiselev","issue_id":"EV-170m.8","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
|
|
464
|
+
{"id":"int-a2659f70","kind":"field_change","created_at":"2026-08-23T06:17:48.940199465Z","actor":"Denis Kiselev","issue_id":"EV-zfh6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
|
|
465
|
+
{"id":"int-3ef2119d","kind":"field_change","created_at":"2026-08-23T06:22:32.612716343Z","actor":"Denis Kiselev","issue_id":"EV-9mrs","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
|
|
466
|
+
{"id":"int-4f24cfc1","kind":"field_change","created_at":"2026-08-23T06:22:36.754337802Z","actor":"Denis Kiselev","issue_id":"EV-170m.4","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}}
|
|
467
|
+
{"id":"int-db1a15c4","kind":"field_change","created_at":"2026-08-23T06:22:40.226188047Z","actor":"Denis Kiselev","issue_id":"EV-170m.6","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}}
|
|
468
|
+
{"id":"int-ee6c5593","kind":"field_change","created_at":"2026-08-23T06:22:43.665223358Z","actor":"Denis Kiselev","issue_id":"EV-170m.7","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}}
|
|
469
|
+
{"id":"int-a9687ce8","kind":"field_change","created_at":"2026-08-23T06:22:47.016572566Z","actor":"Denis Kiselev","issue_id":"EV-170m.9","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}}
|
|
470
|
+
{"id":"int-6979f453","kind":"field_change","created_at":"2026-08-23T06:22:50.346314707Z","actor":"Denis Kiselev","issue_id":"EV-170m.13","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}}
|
|
471
|
+
{"id":"int-c0c4ce61","kind":"field_change","created_at":"2026-08-23T06:22:53.658409885Z","actor":"Denis Kiselev","issue_id":"EV-170m.17","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}}
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
Versioning policy: see [docs/versioning.md](docs/versioning.md).
|
|
4
4
|
|
|
5
|
+
## [1.1.0] - 2026-08-23
|
|
6
|
+
|
|
7
|
+
Control-flow and pattern-matching operator expansion: the `default` profile grows from 74 to 80 operators, and three existing operators gain mutations they were silently missing. Adding operators to `default` is a MINOR change under [docs/versioning.md](docs/versioning.md), and mutation scores will move — every new operator produces mutants your suite has never been measured against. Pin the gem version and the operator profile if you need a stable score across runs.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Six new mutation operators for control flow and pattern matching (`default` profile: 74 -> 80)** — closing the value-level and branch-level gaps in an operator set that previously mutated control-flow nodes only at the keyword level (epic EV-170m, GH #1418):
|
|
12
|
+
- **`boolean_operand_promotion`** — drops one side of a compound boolean: `a && b` becomes `a` and `b`, and the same for `||` and the `and` / `or` keyword forms. Kills tests that only ever exercise one side of a condition, which `boolean_operator_replacement` cannot: it swaps the operator but always keeps both operands (EV-170m.2, PR #1565, GH #1431)
|
|
13
|
+
- **`boolean_expression_to_nil`** — replaces a whole compound boolean with `nil`, targeting conditions that are run for their side effects rather than their value (EV-170m.3, PR #1566, GH #1432)
|
|
14
|
+
- **`if_branch_swap`** — replaces the if-branch with the else body and drops the else: `if c; x; else; y; end` becomes `if c; y; end`. Both outcomes of the condition change, which is out of reach of `conditional_negation` (pins the predicate to one branch) and `conditional_branch` (blanks one body to `nil`) (EV-170m.5, PR #1567, GH #1434)
|
|
15
|
+
- **`loop_body_to_raise`** — replaces a `while` / `until` body with a bare `raise`, so a survivor means no test ever enters the loop. The raise ends the loop on its first iteration, so the mutant cannot spin (EV-170m.8, PR #1569, GH #1437)
|
|
16
|
+
- **`case_in`** — the first operator to visit Ruby's pattern-matching grammar (`Prism::CaseMatchNode`): drops one `in` clause from a `case/in`, or drops its `else`. Input that used to match then falls through to a later arm, to the `else`, or — with neither — raises `NoMatchingPatternError` (EV-170m.14 / EV-170m.15, PRs #1573 / #1574, GH #1443 / #1444)
|
|
17
|
+
- **`pattern_predicate`** — one-line pattern match to `false`: `x in Integer` becomes `false`. The mutant differs from the original only on inputs the pattern actually matches, so a survivor means no test ever feeds it a matching value (EV-170m.16, PR #1575, GH #1445)
|
|
18
|
+
- **`case_when` covers two more shapes** — an empty `when` arm now gets a `raise` inserted, and a multi-condition arm is shortened one value at a time (`when a, b` becomes `when a` and `when b`). Neither was previously reachable: dropping an empty arm is indistinguishable from falling through to a missing `else`, and whole-arm removal cannot tell which of several listed values a test exercises (EV-170m.11 / EV-170m.12, PRs #1571 / #1572, GH #1440 / #1441)
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **`conditional_branch` ignored `unless` entirely** — the operator defined only `visit_if_node`, so neither `unless c; x; end` nor `unless c; x; else; y; end` received any branch-body mutation, while the equivalent `if` forms received one per branch. Prism gives `unless` its own node type and names the else slot `else_clause` rather than `IfNode`'s `subsequent`; both forms now share the branch handling, so `unless` bodies — including the modifier form — get the same branch-to-`nil` mutants (EV-9mrs, PR #1578, GH #1568)
|
|
23
|
+
- **MCP list responses omitted the mandatory `resultType` field** — protocol revision 2026-07-28 (SEP-2322) makes `resultType` mandatory on list results, and the `mcp` gem echoes back that revision while still omitting the field. Strict clients therefore rejected the whole list and saw a server exposing no tools at all. `Evilution::MCP::CompleteResultServer` now marks every list result `"complete"` (PR #1417)
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- **Post-form loops (`begin ... end while c`) verified across the loop operators** — `loop_body_to_raise` reaches through the `BeginNode` that Prism reports as the loop's statements, so the wrapper, and with it the guaranteed first iteration, survives the mutation; editing the outer span would have rewritten the loop as `raise while c`, which tests the condition first. `loop_flip` and `begin_unwrap` gained the post-form coverage they never had, including the fact that unwrapping such a loop legitimately drops its run-once guarantee. A sweep of every operator over post-form loops found no mutation that fails to parse (EV-170m.10, PR #1570, GH #1439)
|
|
28
|
+
- **Shared operator primitives (`Mutator::Primitives`)** — `mutate_to_nil` and `promote_child`, built on `Base#add_mutation(skip_unparseable: true)`. Operators built on them skip rather than emit when the result would not parse in its surrounding context, so a promotion that cannot parse no longer reaches the `unparseable` bucket that the point operators still populate (EV-170m.1, PR #1564, GH #1430)
|
|
29
|
+
- **Dependency bumps** — `mcp` 0.24.0 -> 1.2.0 (PRs #1411, #1413, #1414, #1563), CI Ruby versions and gem dependencies (PR #1415), `ruby/setup-ruby` 1.318.0 -> 1.321.0 (PRs #1409, #1412), `actions/checkout` 7.0.0 -> 7.0.1 (PR #1410), `rubygems/release-gem` 1.4.0 -> 1.4.1 (PR #1562)
|
|
30
|
+
|
|
5
31
|
## [1.0.0] - 2026-07-15
|
|
6
32
|
|
|
7
33
|
First stable release. From `1.0.0` onward evilution follows [Semantic Versioning](https://semver.org): the public contract — CLI commands and flags, `.evilution.yml` configuration keys, session JSON files, the MCP tool schemas, and process exit codes — is frozen and covered by the SemVer guarantees and deprecation cycle in [docs/versioning.md](docs/versioning.md). The `1.0.0` milestone is the culmination of the readiness work that shipped across the `0.31`–`0.35` line (config and session-JSON schema versioning, MCP tool-contract stabilization, the CLI flag deprecation sweep, real-world Rails validation, the parallel/isolation stress suite, and running evilution against its own suite to a mutation-score target).
|
data/README.md
CHANGED
|
@@ -157,7 +157,7 @@ Every command, subcommand, and flag listed in this section is part of evilution'
|
|
|
157
157
|
|
|
158
158
|
Two profiles ship out of the box:
|
|
159
159
|
|
|
160
|
-
- **`default`** — the
|
|
160
|
+
- **`default`** — the 80 stable operators registered in `Mutator::Registry.default`. Suitable for everyday CI runs; balances coverage signal against survivor noise.
|
|
161
161
|
- **`strict`** — adds extra truthiness mutators on top of `default`. Currently `PredicateToNil` (replaces every `x.predicate?` call with `nil` to surface tests that only assert truthiness rather than exact return values). Use for pre-merge audits where you want maximum sensitivity at the cost of more survivors.
|
|
162
162
|
|
|
163
163
|
Set via `--profile=strict`, the `--strict` shortcut, or `profile: strict` in `.evilution.yml`.
|
|
@@ -390,7 +390,7 @@ Compatibility policy for the `1.x` gem line:
|
|
|
390
390
|
|
|
391
391
|
Unresolved mutations indicate a missing test mapping — the file has no corresponding test file that the resolver could find (for example, an RSpec `_spec.rb` file or a Minitest `_test.rb` file, depending on configuration). The resolver searches the `lib/`-mirrored path, common non-mirrored buckets (`spec/unit`, `spec/lib`, `test/unit`, `test/lib`), and the flat `test_`-prefixed Minitest/Test::Unit convention (`test/test_connection_pool_timed_stack.rb`), so a high unresolved rate usually means a genuinely missing or unconventionally-placed test; a run that leaves many mutations unresolved prints an unresolved-rate warning with a best-guess spec path per source file. They are reported separately so you can act on them (add a test, adjust test naming, pass `--spec`, or opt in to the full-suite fallback) without inflating the error count.
|
|
392
392
|
|
|
393
|
-
## Mutation Operators (
|
|
393
|
+
## Mutation Operators (80 total)
|
|
394
394
|
|
|
395
395
|
Each operator name is stable and appears in JSON output under `survived[].operator`.
|
|
396
396
|
|
|
@@ -399,6 +399,8 @@ Each operator name is stable and appears in JSON output under `survived[].operat
|
|
|
399
399
|
| `arithmetic_replacement` | Swap arithmetic operators | `a + b` -> `a - b` |
|
|
400
400
|
| `comparison_replacement` | Swap comparison operators | `a >= b` -> `a > b` |
|
|
401
401
|
| `boolean_operator_replacement` | Swap `&&` / `\|\|` | `a && b` -> `a \|\| b` |
|
|
402
|
+
| `boolean_operand_promotion` | Drop one side of `&&` / `\|\|` | `a && b` -> `a`, `b` |
|
|
403
|
+
| `boolean_expression_to_nil` | Replace a whole compound boolean with `nil` | `a && b` -> `nil` |
|
|
402
404
|
| `boolean_literal_replacement` | Flip boolean literals | `true` -> `false` |
|
|
403
405
|
| `nil_replacement` | Replace `nil` with `true`, `false`, `0`, `""` | `nil` -> `true` |
|
|
404
406
|
| `integer_literal` | Boundary-value integer mutations | `n` -> `0`, `1`, `n+1`, `n-1` |
|
|
@@ -408,7 +410,8 @@ Each operator name is stable and appears in JSON output under `survived[].operat
|
|
|
408
410
|
| `hash_literal` | Empty the hash | `{k: v}` -> `{}` |
|
|
409
411
|
| `symbol_literal` | Replace with sentinel symbol | `:foo` -> `:__evilution_mutated__` |
|
|
410
412
|
| `conditional_negation` | Replace condition with `true`/`false` | `if cond` -> `if true` |
|
|
411
|
-
| `conditional_branch` | Remove if/else branch | Deletes branch body |
|
|
413
|
+
| `conditional_branch` | Remove if/unless/else branch | Deletes branch body |
|
|
414
|
+
| `if_branch_swap` | Replace the if-branch with the else body, drop the else | `if c; x; else; y; end` -> `if c; y; end` |
|
|
412
415
|
| `conditional_flip` | Flip `if` to `unless` and vice versa | `if cond` -> `unless cond` |
|
|
413
416
|
| `statement_deletion` | Remove statements from method bodies | Deletes a statement |
|
|
414
417
|
| `method_body_replacement` | Replace entire method body | Method body -> `nil`, `self`, `super` |
|
|
@@ -460,9 +463,12 @@ Each operator name is stable and appears in JSON output under `survived[].operat
|
|
|
460
463
|
| `defined_check` | Replace `defined?` with `true` | `defined?(x)` -> `true` |
|
|
461
464
|
| `regex_capture` | Swap or nil-ify capture refs | `$1` -> `$2`, `$1` -> `nil` |
|
|
462
465
|
| `loop_flip` | Swap while/until loops | `while cond` -> `until cond` |
|
|
466
|
+
| `loop_body_to_raise` | Replace a loop body with `raise` | `while c; body; end` -> `while c; raise; end` |
|
|
463
467
|
| `string_interpolation` | Replace interpolation content with nil | `"hello #{name}"` -> `"hello #{nil}"` |
|
|
464
468
|
| `retry_removal` | Remove retry statements | `retry` -> `nil` |
|
|
465
|
-
| `case_when` | Remove/replace case/when branches | Remove `when` branch, body -> `nil`, remove `else` |
|
|
469
|
+
| `case_when` | Remove/replace case/when branches | Remove `when` branch, drop one condition from `when a, b`, body -> `nil`, empty body -> `raise`, remove `else` |
|
|
470
|
+
| `case_in` | Drop one `in` clause from a `case/in`, or its `else` | `case x; in Integer; …; in String; …; end` -> drops an arm; `else` -> `NoMatchingPatternError` |
|
|
471
|
+
| `pattern_predicate` | One-line pattern match -> `false` | `x in Integer` -> `false` |
|
|
466
472
|
| `predicate_replacement` | Replace predicate calls with booleans | `x.empty?` -> `true`, `x.empty?` -> `false` |
|
|
467
473
|
| `equality_to_identity` | Replace equality with identity check | `a == b` -> `a.equal?(b)` |
|
|
468
474
|
| `lambda_body` | Replace lambda body with nil | `-> { expr }` -> `-> { nil }` |
|
|
@@ -810,7 +816,7 @@ points — see [docs/architecture.md](docs/architecture.md).
|
|
|
810
816
|
1. **Parse** — Prism parses Ruby files into ASTs with exact byte offsets
|
|
811
817
|
2. **Extract** — Methods are identified as mutation subjects
|
|
812
818
|
3. **Filter** — Disable comments, Sorbet `sig` blocks, and AST ignore patterns exclude mutations before execution
|
|
813
|
-
4. **Mutate** —
|
|
819
|
+
4. **Mutate** — 80 operators produce text replacements at precise byte offsets (source-level surgery, no AST unparsing); heredoc literal text is skipped by default. Identical byte-mutations from different operators are deduplicated by `(file_path, mutated_source)` so the count is not inflated by overlap
|
|
814
820
|
5. **Isolate** — Mutations are applied to temporary file copies (never modifying originals); load-path redirection ensures `require` resolves the mutated copy. Default isolation is in-process for plain Ruby projects (no gemspec) and fork for Rails projects and packaged gems (auto-detected); `--isolation fork` forces forked child processes. Both sequential and parallel (`--jobs N`) modes respect the configured isolation strategy
|
|
815
821
|
6. **Test** — The configured test framework (RSpec, Minitest, or Test::Unit) executes against the mutated source
|
|
816
822
|
7. **Collect** — Source strings and AST nodes are released after use to minimize memory retention
|
data/docs/architecture.md
CHANGED
|
@@ -48,7 +48,7 @@ Everything lives under `lib/evilution/`.
|
|
|
48
48
|
| `Config`, `Config::*` | Merge `.evilution.yml` + CLI flags + env, validate, freeze. | `config.rb`, `config/sources.rb`, `config/validators/*` |
|
|
49
49
|
| `Runner`, `Runner::*` | Orchestrate the whole run. Each stage is its own collaborator. | `runner.rb`, `runner/*` |
|
|
50
50
|
| `AST`, `Subject` | Prism parse, find method subjects, source surgery, pattern matching, heredoc spans. | `ast/parser.rb`, `ast/source_surgeon.rb`, `subject.rb` |
|
|
51
|
-
| `Mutator`, `Mutator::Operator::*` |
|
|
51
|
+
| `Mutator`, `Mutator::Operator::*` | 80 operators (default profile) that emit byte-edits; registry + profiles. | `mutator/base.rb`, `mutator/registry.rb`, `mutator/operator/*` |
|
|
52
52
|
| `Mutation` | An immutable mutation record (original/mutated sources, slice, location, parse status). | `mutation.rb` |
|
|
53
53
|
| `SpecResolver`, `SpecSelector` | Map a source file to its covering spec files (layout heuristics + explicit mappings). | `spec_resolver.rb`, `spec_selector.rb` |
|
|
54
54
|
| `Isolation::{Fork,InProcess}`, `ProcessSupervisor` | Run one mutation's tests in isolation; process-group lifecycle, sandboxing, TERM/KILL ladder. | `isolation/fork.rb`, `process_supervisor.rb` |
|
|
@@ -65,7 +65,7 @@ class Evilution::Integration::Loading::RedefinitionRecovery
|
|
|
65
65
|
msg = error.message
|
|
66
66
|
# `msg` is a String and patterns are substrings, so `include?` is a
|
|
67
67
|
# substring test — `intersect?` would compare array elements, not text.
|
|
68
|
-
IDEMPOTENCY_PATTERNS.any? {
|
|
68
|
+
IDEMPOTENCY_PATTERNS.any? { msg.include?(_1) }
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
def superclass_mismatch?(error)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
|
|
5
|
+
require_relative "../mcp"
|
|
6
|
+
|
|
7
|
+
# MCP protocol revision 2026-07-28 (SEP-2322) makes `resultType` mandatory on list results: a
|
|
8
|
+
# client may only treat a missing field as "complete" for servers that negotiated an earlier
|
|
9
|
+
# revision. The mcp gem echoes back any client-offered revision it supports — 2026-07-28
|
|
10
|
+
# included — yet its list handlers still omit the field, so strict clients reject the whole
|
|
11
|
+
# list and the server appears to expose no tools at all.
|
|
12
|
+
#
|
|
13
|
+
# Every list result this server produces is final (there is no `input_required` round trip in
|
|
14
|
+
# the evilution tool surface), so the field is always `"complete"`. The value is written as a
|
|
15
|
+
# literal rather than `MCP::ResultType::COMPLETE` because the gem dependency allows versions
|
|
16
|
+
# that predate that constant. Older revisions ignore the extra key.
|
|
17
|
+
class Evilution::MCP::CompleteResultServer < MCP::Server
|
|
18
|
+
RESULT_TYPE_COMPLETE = "complete"
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def list_tools(request)
|
|
23
|
+
mark_complete(super)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def list_prompts(request)
|
|
27
|
+
mark_complete(super)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def list_resources(request)
|
|
31
|
+
mark_complete(super)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def list_resource_templates(request)
|
|
35
|
+
mark_complete(super)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def mark_complete(result)
|
|
39
|
+
result.merge(resultType: RESULT_TYPE_COMPLETE)
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/evilution/mcp/server.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "mcp"
|
|
4
4
|
require_relative "../version"
|
|
5
|
+
require_relative "complete_result_server"
|
|
5
6
|
require_relative "mutate_tool"
|
|
6
7
|
require_relative "session_tool"
|
|
7
8
|
require_relative "info_tool"
|
|
@@ -10,7 +11,7 @@ require_relative "../mcp"
|
|
|
10
11
|
|
|
11
12
|
class Evilution::MCP::Server
|
|
12
13
|
def self.build
|
|
13
|
-
::MCP::
|
|
14
|
+
Evilution::MCP::CompleteResultServer.new(
|
|
14
15
|
name: "evilution",
|
|
15
16
|
version: Evilution::VERSION,
|
|
16
17
|
tools: [Evilution::MCP::MutateTool, Evilution::MCP::SessionTool, Evilution::MCP::InfoTool]
|
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
require "prism"
|
|
4
4
|
|
|
5
5
|
require_relative "../mutator"
|
|
6
|
+
require_relative "primitives"
|
|
6
7
|
require_relative "../integration/loading/body_call_neutralizer"
|
|
7
8
|
require_relative "../ast/heredoc_span"
|
|
8
9
|
|
|
9
10
|
class Evilution::Mutator::Base < Prism::Visitor
|
|
11
|
+
include Evilution::Mutator::Primitives
|
|
12
|
+
|
|
10
13
|
AffectedSlices = Data.define(:original, :mutated)
|
|
11
14
|
private_constant :AffectedSlices
|
|
12
15
|
|
|
@@ -41,7 +44,12 @@ class Evilution::Mutator::Base < Prism::Visitor
|
|
|
41
44
|
|
|
42
45
|
private
|
|
43
46
|
|
|
44
|
-
|
|
47
|
+
# `skip_unparseable` makes the mutation vanish rather than be recorded when
|
|
48
|
+
# the surgery does not parse. Off by default: the point operators rely on
|
|
49
|
+
# unparseable bytes reaching the reporter as their own bucket. The shared
|
|
50
|
+
# primitives opt in, since a nil-ification or promotion that does not parse
|
|
51
|
+
# carries no signal.
|
|
52
|
+
def add_mutation(offset:, length:, replacement:, node:, skip_unparseable: false)
|
|
45
53
|
return if @filter && @filter.skip?(node)
|
|
46
54
|
|
|
47
55
|
# When the byte range opens a heredoc but stops at its inline anchor,
|
|
@@ -67,6 +75,8 @@ class Evilution::Mutator::Base < Prism::Visitor
|
|
|
67
75
|
surgery = Evilution::AST::SourceSurgeon.apply(
|
|
68
76
|
@file_source, offset: offset, length: length, replacement: replacement
|
|
69
77
|
)
|
|
78
|
+
return if skip_unparseable && surgery.unparseable?
|
|
79
|
+
|
|
70
80
|
slices = slice_affected_lines(
|
|
71
81
|
mutated_source: surgery.source,
|
|
72
82
|
offset: offset,
|
|
@@ -74,7 +84,9 @@ class Evilution::Mutator::Base < Prism::Visitor
|
|
|
74
84
|
replacement_bytesize: replacement.bytesize
|
|
75
85
|
)
|
|
76
86
|
|
|
77
|
-
|
|
87
|
+
mutation = build_mutation_record(node, surgery, slices)
|
|
88
|
+
@mutations << mutation
|
|
89
|
+
mutation
|
|
78
90
|
end
|
|
79
91
|
|
|
80
92
|
def build_mutation_record(node, surgery, slices)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Replace a whole compound boolean with `nil`: `a && b` becomes `nil`, and
|
|
6
|
+
# the same for `||` and the `and` / `or` keyword forms.
|
|
7
|
+
#
|
|
8
|
+
# Where BooleanOperandPromotion drops one side, this drops both. The
|
|
9
|
+
# survivor it targets is a condition whose value is never asserted — code
|
|
10
|
+
# that runs the expression for its side effects, or a test that only checks
|
|
11
|
+
# the branch was taken rather than what the branch was given.
|
|
12
|
+
class Evilution::Mutator::Operator::BooleanExpressionToNil < Evilution::Mutator::Base
|
|
13
|
+
def visit_and_node(node)
|
|
14
|
+
mutate_to_nil(node)
|
|
15
|
+
super
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def visit_or_node(node)
|
|
19
|
+
mutate_to_nil(node)
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Drop one side of a compound boolean: `a && b` becomes `a` and `b`, and the
|
|
6
|
+
# same for `||` (both the symbol and the `and` / `or` keyword forms).
|
|
7
|
+
#
|
|
8
|
+
# BooleanOperatorReplacement swaps the operator but always keeps both
|
|
9
|
+
# operands, so a test that only ever exercises one side of a condition
|
|
10
|
+
# survives it. Promoting an operand kills exactly those tests.
|
|
11
|
+
class Evilution::Mutator::Operator::BooleanOperandPromotion < Evilution::Mutator::Base
|
|
12
|
+
def visit_and_node(node)
|
|
13
|
+
promote_operands(node)
|
|
14
|
+
super
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def visit_or_node(node)
|
|
18
|
+
promote_operands(node)
|
|
19
|
+
super
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# Nested expressions need no special handling: `a && b && c` is an outer
|
|
25
|
+
# AndNode over an inner one, so the outer visit yields `a && b` and `c`
|
|
26
|
+
# while `super` descends and the inner visit yields `a && c` and `b`.
|
|
27
|
+
def promote_operands(node)
|
|
28
|
+
promote_child(node, node.left)
|
|
29
|
+
promote_child(node, node.right)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Drop one `in` clause from a `case/in`, leaving the rest of the arms in
|
|
6
|
+
# place.
|
|
7
|
+
#
|
|
8
|
+
# Input that used to match the removed pattern now falls through to a later
|
|
9
|
+
# arm, to the `else`, or -- with neither -- raises NoMatchingPatternError. A
|
|
10
|
+
# survivor means the suite never exercises that arm, so its pattern could be
|
|
11
|
+
# deleted outright without any test noticing.
|
|
12
|
+
#
|
|
13
|
+
# Pattern matching had no visitor at all before this operator: the case/in
|
|
14
|
+
# grammar is a CaseMatchNode of InNodes, unrelated to the CaseNode/WhenNode
|
|
15
|
+
# pair CaseWhen handles.
|
|
16
|
+
class Evilution::Mutator::Operator::CaseIn < Evilution::Mutator::Base
|
|
17
|
+
def visit_case_match_node(node)
|
|
18
|
+
remove_in_clauses(node)
|
|
19
|
+
remove_else_branch(node)
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# A case/in needs at least one arm to parse, so a lone clause stays put.
|
|
26
|
+
def remove_in_clauses(node)
|
|
27
|
+
return if node.conditions.length < 2
|
|
28
|
+
|
|
29
|
+
node.conditions.each do |in_node|
|
|
30
|
+
location = in_node.location
|
|
31
|
+
|
|
32
|
+
add_mutation(
|
|
33
|
+
offset: location.start_offset,
|
|
34
|
+
length: location.length,
|
|
35
|
+
replacement: "",
|
|
36
|
+
node: in_node
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Without an else, an unmatched value raises NoMatchingPatternError rather
|
|
42
|
+
# than falling through, so a survivor means nothing in the suite reaches the
|
|
43
|
+
# fallback.
|
|
44
|
+
#
|
|
45
|
+
# An empty else body is worth removing here, which is where this parts ways
|
|
46
|
+
# with CaseWhen: a case/when yields nil whether its else is empty or absent,
|
|
47
|
+
# but an empty case/in else yields nil while an absent one raises. Prism
|
|
48
|
+
# reports no statements for that shape, so the edit covers the keyword alone.
|
|
49
|
+
def remove_else_branch(node)
|
|
50
|
+
else_clause = node.else_clause
|
|
51
|
+
return if else_clause.nil?
|
|
52
|
+
|
|
53
|
+
keyword_location = else_clause.else_keyword_loc
|
|
54
|
+
statements = else_clause.statements
|
|
55
|
+
end_offset = statements.nil? ? keyword_location.end_offset : statements.location.end_offset
|
|
56
|
+
|
|
57
|
+
add_mutation(
|
|
58
|
+
offset: keyword_location.start_offset,
|
|
59
|
+
length: end_offset - keyword_location.start_offset,
|
|
60
|
+
replacement: "",
|
|
61
|
+
node: else_clause
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -5,7 +5,9 @@ require_relative "../operator"
|
|
|
5
5
|
class Evilution::Mutator::Operator::CaseWhen < Evilution::Mutator::Base
|
|
6
6
|
def visit_case_node(node)
|
|
7
7
|
remove_when_branches(node)
|
|
8
|
+
remove_when_conditions(node)
|
|
8
9
|
replace_when_bodies(node)
|
|
10
|
+
raise_in_empty_when_bodies(node)
|
|
9
11
|
remove_else_branch(node)
|
|
10
12
|
|
|
11
13
|
super
|
|
@@ -26,9 +28,42 @@ class Evilution::Mutator::Operator::CaseWhen < Evilution::Mutator::Base
|
|
|
26
28
|
end
|
|
27
29
|
end
|
|
28
30
|
|
|
31
|
+
# `when a, b` matches on either value, so dropping the whole arm cannot tell
|
|
32
|
+
# which of them a test actually exercises. Shortening the list one value at a
|
|
33
|
+
# time can. WhenNode#conditions holds the values; the enclosing CaseNode's
|
|
34
|
+
# own #conditions holds the arms.
|
|
35
|
+
def remove_when_conditions(node)
|
|
36
|
+
node.conditions.each do |when_node|
|
|
37
|
+
values = when_node.conditions
|
|
38
|
+
next if values.length < 2
|
|
39
|
+
|
|
40
|
+
values.each_index do |index|
|
|
41
|
+
offset, length = condition_removal_span(values, index)
|
|
42
|
+
|
|
43
|
+
add_mutation(offset: offset, length: length, replacement: "", node: when_node)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Each value has to take its separating comma with it, or the arm is left
|
|
49
|
+
# with a dangling one. The first value owns the comma that follows it; every
|
|
50
|
+
# later value owns the comma that precedes it. Taking whole byte ranges
|
|
51
|
+
# between value boundaries also sweeps up any newline the list wraps on.
|
|
52
|
+
def condition_removal_span(values, index)
|
|
53
|
+
if index.zero?
|
|
54
|
+
start_offset = values[0].location.start_offset
|
|
55
|
+
end_offset = values[1].location.start_offset
|
|
56
|
+
else
|
|
57
|
+
start_offset = values[index - 1].location.end_offset
|
|
58
|
+
end_offset = values[index].location.end_offset
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
[start_offset, end_offset - start_offset]
|
|
62
|
+
end
|
|
63
|
+
|
|
29
64
|
def replace_when_bodies(node)
|
|
30
65
|
node.conditions.each do |when_node|
|
|
31
|
-
next if
|
|
66
|
+
next if empty_body?(when_node)
|
|
32
67
|
|
|
33
68
|
add_mutation(
|
|
34
69
|
offset: when_node.statements.location.start_offset,
|
|
@@ -39,6 +74,42 @@ class Evilution::Mutator::Operator::CaseWhen < Evilution::Mutator::Base
|
|
|
39
74
|
end
|
|
40
75
|
end
|
|
41
76
|
|
|
77
|
+
# An empty arm is a deliberate no-op, so there is no body to blank out --
|
|
78
|
+
# dropping the arm entirely is indistinguishable from falling through to a
|
|
79
|
+
# missing else. Inserting a raise is what proves the arm was selected.
|
|
80
|
+
def raise_in_empty_when_bodies(node)
|
|
81
|
+
node.conditions.each do |when_node|
|
|
82
|
+
next unless empty_body?(when_node)
|
|
83
|
+
|
|
84
|
+
indent = " " * (indentation_of(when_node.keyword_loc.start_offset) + 2)
|
|
85
|
+
|
|
86
|
+
add_mutation(
|
|
87
|
+
offset: body_insert_offset(when_node),
|
|
88
|
+
length: 0,
|
|
89
|
+
replacement: "\n#{indent}raise",
|
|
90
|
+
node: when_node
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def empty_body?(when_node)
|
|
96
|
+
statements = when_node.statements
|
|
97
|
+
statements.nil? || statements.body.empty?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# `when 1, 2` puts the body after the last condition; `when 1 then` after the
|
|
101
|
+
# keyword. Prism reports a nil then_keyword_loc when the form omits it.
|
|
102
|
+
def body_insert_offset(when_node)
|
|
103
|
+
then_keyword_loc = when_node.then_keyword_loc
|
|
104
|
+
location = then_keyword_loc.nil? ? when_node.conditions.last.location : then_keyword_loc
|
|
105
|
+
|
|
106
|
+
location.start_offset + location.length
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def indentation_of(offset)
|
|
110
|
+
offset - line_start_byte(@file_source, offset)
|
|
111
|
+
end
|
|
112
|
+
|
|
42
113
|
def remove_else_branch(node)
|
|
43
114
|
else_clause = node.else_clause
|
|
44
115
|
return if else_clause.nil? || else_clause.statements.nil?
|
|
@@ -4,21 +4,34 @@ require_relative "../operator"
|
|
|
4
4
|
|
|
5
5
|
class Evilution::Mutator::Operator::ConditionalBranch < Evilution::Mutator::Base
|
|
6
6
|
def visit_if_node(node)
|
|
7
|
-
|
|
7
|
+
blank_branches(node, node.subsequent)
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
super
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Prism gives `unless` its own node type, and names the else slot
|
|
13
|
+
# `else_clause` rather than IfNode's `subsequent`. Everything below that is
|
|
14
|
+
# the same shape, so both forms share the branch handling.
|
|
15
|
+
def visit_unless_node(node)
|
|
16
|
+
blank_branches(node, node.else_clause)
|
|
11
17
|
|
|
12
18
|
super
|
|
13
19
|
end
|
|
14
20
|
|
|
15
21
|
private
|
|
16
22
|
|
|
17
|
-
def
|
|
18
|
-
return
|
|
19
|
-
|
|
23
|
+
def blank_branches(node, else_clause)
|
|
24
|
+
return if node.statements.nil?
|
|
25
|
+
|
|
26
|
+
add_nil_mutation(node.statements, node)
|
|
27
|
+
add_nil_mutation_to_else(else_clause, node)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def add_nil_mutation_to_else(else_clause, node)
|
|
31
|
+
return unless else_clause.is_a?(Prism::ElseNode)
|
|
32
|
+
return if else_clause.statements.nil?
|
|
20
33
|
|
|
21
|
-
add_nil_mutation(
|
|
34
|
+
add_nil_mutation(else_clause.statements, node)
|
|
22
35
|
end
|
|
23
36
|
|
|
24
37
|
def add_nil_mutation(statements, node)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Replace the if-branch with the else body and drop the else:
|
|
6
|
+
# `if c; x; else; y; end` becomes `if c; y; end`.
|
|
7
|
+
#
|
|
8
|
+
# The condition still runs and still selects, but both of its outcomes
|
|
9
|
+
# change: truthy now yields the else body, falsy now yields nil. That is out
|
|
10
|
+
# of reach of the existing conditional operators — ConditionalNegation
|
|
11
|
+
# pins the predicate to one branch, ConditionalBranch blanks a body to nil —
|
|
12
|
+
# so this survives only when the suite never distinguishes the two branch
|
|
13
|
+
# values from each other.
|
|
14
|
+
class Evilution::Mutator::Operator::IfBranchSwap < Evilution::Mutator::Base
|
|
15
|
+
def visit_if_node(node)
|
|
16
|
+
swap_branches(node)
|
|
17
|
+
super
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# The edit spans from the start of the if-branch to the end of the else
|
|
23
|
+
# body, which swallows the `else` keyword along with the original branch.
|
|
24
|
+
# It deliberately stops short of ElseNode#location, which runs on to the
|
|
25
|
+
# closing `end` the mutation needs to keep.
|
|
26
|
+
#
|
|
27
|
+
# `subsequent` is an IfNode rather than an ElseNode on an `elsif`, and
|
|
28
|
+
# nil on a bare `if`; neither has an else body to promote. A ternary does
|
|
29
|
+
# reach the edit, but `c ? y` does not parse, so add_mutation drops it.
|
|
30
|
+
def swap_branches(node)
|
|
31
|
+
else_node = node.subsequent
|
|
32
|
+
return unless else_node.is_a?(Prism::ElseNode)
|
|
33
|
+
|
|
34
|
+
then_statements = node.statements
|
|
35
|
+
else_statements = else_node.statements
|
|
36
|
+
return if then_statements.nil? || else_statements.nil?
|
|
37
|
+
|
|
38
|
+
offset = then_statements.location.start_offset
|
|
39
|
+
|
|
40
|
+
add_mutation(
|
|
41
|
+
offset: offset,
|
|
42
|
+
length: else_statements.location.end_offset - offset,
|
|
43
|
+
replacement: source_of(else_statements),
|
|
44
|
+
node: node,
|
|
45
|
+
skip_unparseable: true
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Replace a loop body with a bare `raise`: `while c; body; end` becomes
|
|
6
|
+
# `while c; raise; end`.
|
|
7
|
+
#
|
|
8
|
+
# A survivor means no test ever enters the loop, so the body is either dead
|
|
9
|
+
# or reached only by paths the suite never exercises. The raise terminates
|
|
10
|
+
# the loop on its first iteration, which is what makes this safe where
|
|
11
|
+
# body-to-nil is not: nil-ing a body that advances the predicate spins until
|
|
12
|
+
# the per-mutation timeout (EV-170m.7).
|
|
13
|
+
class Evilution::Mutator::Operator::LoopBodyToRaise < Evilution::Mutator::Base
|
|
14
|
+
def visit_while_node(node)
|
|
15
|
+
replace_body_with_raise(node)
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def visit_until_node(node)
|
|
20
|
+
replace_body_with_raise(node)
|
|
21
|
+
super
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def replace_body_with_raise(node)
|
|
27
|
+
statements = body_statements(node)
|
|
28
|
+
return if statements.nil?
|
|
29
|
+
return if bare_raise?(statements)
|
|
30
|
+
|
|
31
|
+
location = statements.location
|
|
32
|
+
|
|
33
|
+
add_mutation(
|
|
34
|
+
offset: location.start_offset,
|
|
35
|
+
length: location.length,
|
|
36
|
+
replacement: "raise",
|
|
37
|
+
node: node
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A post-form loop (`begin ... end while c`) hangs its body off a BeginNode,
|
|
42
|
+
# and that node's own span is what WhileNode#statements reports. Editing the
|
|
43
|
+
# outer span would rewrite the loop as `raise while c`, which checks the
|
|
44
|
+
# predicate first and so no longer runs the body unconditionally -- exactly
|
|
45
|
+
# the property this mutation exists to probe. Reach through to the inner
|
|
46
|
+
# statements instead and leave `begin ... end while c` standing.
|
|
47
|
+
#
|
|
48
|
+
# begin_modifier? is set only for that post-form, where the grammar
|
|
49
|
+
# guarantees the single wrapped statement is the BeginNode.
|
|
50
|
+
def body_statements(node)
|
|
51
|
+
statements = node.statements
|
|
52
|
+
return statements unless node.begin_modifier?
|
|
53
|
+
|
|
54
|
+
statements.body.first.statements
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A body that already raises unconditionally would mutate to itself.
|
|
58
|
+
def bare_raise?(statements)
|
|
59
|
+
body = statements.body
|
|
60
|
+
return false unless body.length == 1
|
|
61
|
+
|
|
62
|
+
only = body.first
|
|
63
|
+
only.is_a?(Prism::CallNode) &&
|
|
64
|
+
only.name == :raise &&
|
|
65
|
+
only.arguments.nil? &&
|
|
66
|
+
only.receiver.nil?
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../operator"
|
|
4
|
+
|
|
5
|
+
# Replace a one-line pattern match with `false`: `x in Integer` becomes
|
|
6
|
+
# `false`.
|
|
7
|
+
#
|
|
8
|
+
# The mutant differs from the original only on inputs the pattern actually
|
|
9
|
+
# matches, so a survivor means no test ever feeds it a matching value -- the
|
|
10
|
+
# true path of the predicate is unexercised.
|
|
11
|
+
#
|
|
12
|
+
# Only Prism::MatchPredicateNode is mutated. Its sibling MatchRequiredNode
|
|
13
|
+
# (`x => Integer`) raises NoMatchingPatternError rather than returning a
|
|
14
|
+
# boolean, so `false` would not stand in for it.
|
|
15
|
+
class Evilution::Mutator::Operator::PatternPredicate < Evilution::Mutator::Base
|
|
16
|
+
def visit_match_predicate_node(node)
|
|
17
|
+
location = node.location
|
|
18
|
+
|
|
19
|
+
add_mutation(
|
|
20
|
+
offset: location.start_offset,
|
|
21
|
+
length: location.length,
|
|
22
|
+
replacement: "false",
|
|
23
|
+
node: node
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
super
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../mutator"
|
|
4
|
+
|
|
5
|
+
# Mutation shapes shared across the operator families: replacing an
|
|
6
|
+
# expression with `nil`, and replacing an expression with the source of one
|
|
7
|
+
# of its children. Both are built on Base#add_mutation, so the
|
|
8
|
+
# equivalent-mutant filter and the heredoc-span guards still apply.
|
|
9
|
+
#
|
|
10
|
+
# Unlike a bare add_mutation call, these skip rather than emit when the
|
|
11
|
+
# result would not parse in its surrounding context. A promotion that does
|
|
12
|
+
# not parse is noise rather than signal, so it never reaches the
|
|
13
|
+
# `unparseable` bucket the point operators still populate.
|
|
14
|
+
module Evilution::Mutator::Primitives
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
# Replace `target`'s byte span with `nil`, attributing the mutation to
|
|
18
|
+
# `node`. `target` defaults to `node`; pass an inner node to nil out a body
|
|
19
|
+
# while keeping the reported location on the enclosing construct.
|
|
20
|
+
def mutate_to_nil(node, target: node)
|
|
21
|
+
replace_span(node: node, target: target, replacement: "nil")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Replace `target`'s byte span with `child`'s source, verbatim. `child` may
|
|
25
|
+
# be nil — Prism leaves optional slots (a call's receiver, an if's else)
|
|
26
|
+
# empty — in which case there is nothing to promote.
|
|
27
|
+
def promote_child(node, child, target: node)
|
|
28
|
+
return nil if child.nil?
|
|
29
|
+
|
|
30
|
+
replace_span(node: node, target: target, replacement: source_of(child))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def replace_span(node:, target:, replacement:)
|
|
34
|
+
return nil if target.nil?
|
|
35
|
+
|
|
36
|
+
location = target.location
|
|
37
|
+
return nil if replacement == byteslice_source(location.start_offset, location.length)
|
|
38
|
+
|
|
39
|
+
add_mutation(
|
|
40
|
+
offset: location.start_offset,
|
|
41
|
+
length: location.length,
|
|
42
|
+
replacement: replacement,
|
|
43
|
+
node: node,
|
|
44
|
+
skip_unparseable: true
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def source_of(child)
|
|
49
|
+
location = child.location
|
|
50
|
+
byteslice_source(location.start_offset, location.length)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -29,6 +29,8 @@ class Evilution::Mutator::Registry
|
|
|
29
29
|
Evilution::Mutator::Operator::ComparisonReplacement,
|
|
30
30
|
Evilution::Mutator::Operator::ArithmeticReplacement,
|
|
31
31
|
Evilution::Mutator::Operator::BooleanOperatorReplacement,
|
|
32
|
+
Evilution::Mutator::Operator::BooleanOperandPromotion,
|
|
33
|
+
Evilution::Mutator::Operator::BooleanExpressionToNil,
|
|
32
34
|
Evilution::Mutator::Operator::BooleanLiteralReplacement,
|
|
33
35
|
Evilution::Mutator::Operator::NilReplacement,
|
|
34
36
|
Evilution::Mutator::Operator::IntegerLiteral,
|
|
@@ -39,6 +41,7 @@ class Evilution::Mutator::Registry
|
|
|
39
41
|
Evilution::Mutator::Operator::SymbolLiteral,
|
|
40
42
|
Evilution::Mutator::Operator::ConditionalNegation,
|
|
41
43
|
Evilution::Mutator::Operator::ConditionalBranch,
|
|
44
|
+
Evilution::Mutator::Operator::IfBranchSwap,
|
|
42
45
|
Evilution::Mutator::Operator::LastExpressionRemoval,
|
|
43
46
|
Evilution::Mutator::Operator::StatementDeletion,
|
|
44
47
|
Evilution::Mutator::Operator::MethodBodyReplacement,
|
|
@@ -92,9 +95,12 @@ class Evilution::Mutator::Registry
|
|
|
92
95
|
Evilution::Mutator::Operator::DefinedCheck,
|
|
93
96
|
Evilution::Mutator::Operator::RegexCapture,
|
|
94
97
|
Evilution::Mutator::Operator::LoopFlip,
|
|
98
|
+
Evilution::Mutator::Operator::LoopBodyToRaise,
|
|
95
99
|
Evilution::Mutator::Operator::StringInterpolation,
|
|
96
100
|
Evilution::Mutator::Operator::RetryRemoval,
|
|
97
101
|
Evilution::Mutator::Operator::CaseWhen,
|
|
102
|
+
Evilution::Mutator::Operator::CaseIn,
|
|
103
|
+
Evilution::Mutator::Operator::PatternPredicate,
|
|
98
104
|
Evilution::Mutator::Operator::PredicateReplacement,
|
|
99
105
|
Evilution::Mutator::Operator::EqualityToIdentity,
|
|
100
106
|
Evilution::Mutator::Operator::LambdaBody,
|
data/lib/evilution/version.rb
CHANGED
data/lib/evilution.rb
CHANGED
|
@@ -27,11 +27,14 @@ require_relative "evilution/mutator/operator/integer_literal"
|
|
|
27
27
|
require_relative "evilution/mutator/operator/float_literal"
|
|
28
28
|
require_relative "evilution/mutator/operator/nil_replacement"
|
|
29
29
|
require_relative "evilution/mutator/operator/boolean_operator_replacement"
|
|
30
|
+
require_relative "evilution/mutator/operator/boolean_operand_promotion"
|
|
31
|
+
require_relative "evilution/mutator/operator/boolean_expression_to_nil"
|
|
30
32
|
require_relative "evilution/mutator/operator/arithmetic_replacement"
|
|
31
33
|
require_relative "evilution/mutator/operator/string_literal"
|
|
32
34
|
require_relative "evilution/mutator/operator/array_literal"
|
|
33
35
|
require_relative "evilution/mutator/operator/hash_literal"
|
|
34
36
|
require_relative "evilution/mutator/operator/conditional_branch"
|
|
37
|
+
require_relative "evilution/mutator/operator/if_branch_swap"
|
|
35
38
|
require_relative "evilution/mutator/operator/symbol_literal"
|
|
36
39
|
require_relative "evilution/mutator/operator/conditional_negation"
|
|
37
40
|
require_relative "evilution/mutator/operator/negation_insertion"
|
|
@@ -85,9 +88,12 @@ require_relative "evilution/mutator/operator/splat_operator"
|
|
|
85
88
|
require_relative "evilution/mutator/operator/defined_check"
|
|
86
89
|
require_relative "evilution/mutator/operator/regex_capture"
|
|
87
90
|
require_relative "evilution/mutator/operator/loop_flip"
|
|
91
|
+
require_relative "evilution/mutator/operator/loop_body_to_raise"
|
|
88
92
|
require_relative "evilution/mutator/operator/string_interpolation"
|
|
89
93
|
require_relative "evilution/mutator/operator/retry_removal"
|
|
90
94
|
require_relative "evilution/mutator/operator/case_when"
|
|
95
|
+
require_relative "evilution/mutator/operator/case_in"
|
|
96
|
+
require_relative "evilution/mutator/operator/pattern_predicate"
|
|
91
97
|
require_relative "evilution/mutator/operator/predicate_replacement"
|
|
92
98
|
require_relative "evilution/mutator/operator/predicate_to_nil"
|
|
93
99
|
require_relative "evilution/mutator/operator/equality_to_identity"
|
data/scripts/compare_targeting
CHANGED
|
@@ -128,8 +128,10 @@ module CompareTargeting
|
|
|
128
128
|
def lost_kills(mode = "coverage")
|
|
129
129
|
full = @modes.fetch("full_file")
|
|
130
130
|
other = @modes.fetch(mode)
|
|
131
|
-
full
|
|
132
|
-
|
|
131
|
+
full
|
|
132
|
+
.keys
|
|
133
|
+
.select { |key| full.status(key) == "killed" && other.status(key) != "killed" }
|
|
134
|
+
.map { |key| full.label(key) }
|
|
133
135
|
end
|
|
134
136
|
|
|
135
137
|
def to_row(repo)
|
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: 1.
|
|
4
|
+
version: 1.1.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-
|
|
11
|
+
date: 2026-08-23 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: diff-lcs
|
|
@@ -273,6 +273,7 @@ files:
|
|
|
273
273
|
- lib/evilution/load_path.rb
|
|
274
274
|
- lib/evilution/load_path/subpath_resolver.rb
|
|
275
275
|
- lib/evilution/mcp.rb
|
|
276
|
+
- lib/evilution/mcp/complete_result_server.rb
|
|
276
277
|
- lib/evilution/mcp/info_tool.rb
|
|
277
278
|
- lib/evilution/mcp/info_tool/actions.rb
|
|
278
279
|
- lib/evilution/mcp/info_tool/actions/base.rb
|
|
@@ -313,9 +314,12 @@ files:
|
|
|
313
314
|
- lib/evilution/mutator/operator/block_param_removal.rb
|
|
314
315
|
- lib/evilution/mutator/operator/block_pass_removal.rb
|
|
315
316
|
- lib/evilution/mutator/operator/block_removal.rb
|
|
317
|
+
- lib/evilution/mutator/operator/boolean_expression_to_nil.rb
|
|
316
318
|
- lib/evilution/mutator/operator/boolean_literal_replacement.rb
|
|
319
|
+
- lib/evilution/mutator/operator/boolean_operand_promotion.rb
|
|
317
320
|
- lib/evilution/mutator/operator/boolean_operator_replacement.rb
|
|
318
321
|
- lib/evilution/mutator/operator/break_statement.rb
|
|
322
|
+
- lib/evilution/mutator/operator/case_in.rb
|
|
319
323
|
- lib/evilution/mutator/operator/case_when.rb
|
|
320
324
|
- lib/evilution/mutator/operator/class_variable_write.rb
|
|
321
325
|
- lib/evilution/mutator/operator/collection_replacement.rb
|
|
@@ -332,6 +336,7 @@ files:
|
|
|
332
336
|
- lib/evilution/mutator/operator/float_literal.rb
|
|
333
337
|
- lib/evilution/mutator/operator/global_variable_write.rb
|
|
334
338
|
- lib/evilution/mutator/operator/hash_literal.rb
|
|
339
|
+
- lib/evilution/mutator/operator/if_branch_swap.rb
|
|
335
340
|
- lib/evilution/mutator/operator/index_assignment_removal.rb
|
|
336
341
|
- lib/evilution/mutator/operator/index_to_at.rb
|
|
337
342
|
- lib/evilution/mutator/operator/index_to_dig.rb
|
|
@@ -343,6 +348,7 @@ files:
|
|
|
343
348
|
- lib/evilution/mutator/operator/lambda_body.rb
|
|
344
349
|
- lib/evilution/mutator/operator/last_expression_removal.rb
|
|
345
350
|
- lib/evilution/mutator/operator/local_variable_assignment.rb
|
|
351
|
+
- lib/evilution/mutator/operator/loop_body_to_raise.rb
|
|
346
352
|
- lib/evilution/mutator/operator/loop_flip.rb
|
|
347
353
|
- lib/evilution/mutator/operator/method_body_replacement.rb
|
|
348
354
|
- lib/evilution/mutator/operator/method_call_removal.rb
|
|
@@ -354,6 +360,7 @@ files:
|
|
|
354
360
|
- lib/evilution/mutator/operator/pattern_matching_alternative.rb
|
|
355
361
|
- lib/evilution/mutator/operator/pattern_matching_array.rb
|
|
356
362
|
- lib/evilution/mutator/operator/pattern_matching_guard.rb
|
|
363
|
+
- lib/evilution/mutator/operator/pattern_predicate.rb
|
|
357
364
|
- lib/evilution/mutator/operator/predicate_replacement.rb
|
|
358
365
|
- lib/evilution/mutator/operator/predicate_to_nil.rb
|
|
359
366
|
- lib/evilution/mutator/operator/range_replacement.rb
|
|
@@ -376,6 +383,7 @@ files:
|
|
|
376
383
|
- lib/evilution/mutator/operator/symbol_literal.rb
|
|
377
384
|
- lib/evilution/mutator/operator/yield_statement.rb
|
|
378
385
|
- lib/evilution/mutator/operator/zsuper_removal.rb
|
|
386
|
+
- lib/evilution/mutator/primitives.rb
|
|
379
387
|
- lib/evilution/mutator/registry.rb
|
|
380
388
|
- lib/evilution/parallel.rb
|
|
381
389
|
- lib/evilution/parallel/pool.rb
|