evilution 0.16.1 → 0.18.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/.migration-hint-ts +1 -1
- data/.beads/issues.jsonl +47 -46
- data/CHANGELOG.md +48 -0
- data/README.md +143 -50
- data/docs/ast_pattern_syntax.md +210 -0
- data/lib/evilution/ast/pattern/filter.rb +25 -0
- data/lib/evilution/ast/pattern/matcher.rb +107 -0
- data/lib/evilution/ast/pattern/parser.rb +185 -0
- data/lib/evilution/ast/pattern.rb +4 -0
- data/lib/evilution/ast/sorbet_sig_detector.rb +52 -0
- data/lib/evilution/cli.rb +400 -24
- data/lib/evilution/config.rb +43 -2
- data/lib/evilution/disable_comment.rb +90 -0
- data/lib/evilution/hooks/loader.rb +35 -0
- data/lib/evilution/hooks/registry.rb +60 -0
- data/lib/evilution/hooks.rb +58 -0
- data/lib/evilution/integration/base.rb +4 -0
- data/lib/evilution/integration/rspec.rb +6 -2
- data/lib/evilution/isolation/fork.rb +5 -0
- data/lib/evilution/mcp/session_diff_tool.rb +5 -35
- data/lib/evilution/mutator/base.rb +4 -1
- data/lib/evilution/mutator/operator/collection_return.rb +33 -0
- data/lib/evilution/mutator/operator/defined_check.rb +16 -0
- data/lib/evilution/mutator/operator/index_assignment_removal.rb +18 -0
- data/lib/evilution/mutator/operator/index_to_dig.rb +58 -0
- data/lib/evilution/mutator/operator/index_to_fetch.rb +30 -0
- data/lib/evilution/mutator/operator/keyword_argument.rb +91 -0
- data/lib/evilution/mutator/operator/mixin_removal.rb +2 -1
- data/lib/evilution/mutator/operator/multiple_assignment.rb +47 -0
- data/lib/evilution/mutator/operator/pattern_matching_alternative.rb +46 -0
- data/lib/evilution/mutator/operator/pattern_matching_array.rb +97 -0
- data/lib/evilution/mutator/operator/pattern_matching_guard.rb +44 -0
- data/lib/evilution/mutator/operator/regex_capture.rb +43 -0
- data/lib/evilution/mutator/operator/scalar_return.rb +37 -0
- data/lib/evilution/mutator/operator/splat_operator.rb +46 -0
- data/lib/evilution/mutator/operator/superclass_removal.rb +2 -1
- data/lib/evilution/mutator/operator/yield_statement.rb +51 -0
- data/lib/evilution/mutator/registry.rb +17 -3
- data/lib/evilution/parallel/pool.rb +7 -51
- data/lib/evilution/parallel/work_queue.rb +224 -0
- data/lib/evilution/reporter/cli.rb +22 -1
- data/lib/evilution/reporter/html.rb +76 -3
- data/lib/evilution/reporter/json.rb +23 -2
- data/lib/evilution/reporter/suggestion.rb +115 -1
- data/lib/evilution/result/summary.rb +20 -2
- data/lib/evilution/runner.rb +133 -13
- data/lib/evilution/session/diff.rb +85 -0
- data/lib/evilution/session/store.rb +5 -2
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +23 -0
- metadata +28 -2
data/README.md
CHANGED
|
@@ -29,27 +29,42 @@ evilution [command] [options] [files...]
|
|
|
29
29
|
|
|
30
30
|
### Commands
|
|
31
31
|
|
|
32
|
-
| Command
|
|
33
|
-
|
|
34
|
-
| `run`
|
|
35
|
-
| `init`
|
|
36
|
-
| `version`
|
|
32
|
+
| Command | Description | Default |
|
|
33
|
+
|----------------------|----------------------------------------------------|---------|
|
|
34
|
+
| `run` | Execute mutation testing against files | Yes |
|
|
35
|
+
| `init` | Generate `.evilution.yml` config file | |
|
|
36
|
+
| `version` | Print version string | |
|
|
37
|
+
| `subjects [files]` | List mutation subjects with locations and counts | |
|
|
38
|
+
| `tests list [files]` | List spec files mapped to source files | |
|
|
39
|
+
| `session list` | List saved session results | |
|
|
40
|
+
| `session show FILE` | Display detailed session results | |
|
|
41
|
+
| `session diff A B` | Compare two sessions (fixed/new/persistent) | |
|
|
42
|
+
| `session gc --older-than D` | Garbage-collect sessions older than D (e.g. 30d) | |
|
|
43
|
+
| `util mutation` | Preview mutations for a file or inline code | |
|
|
44
|
+
| `environment show` | Display runtime environment and settings | |
|
|
37
45
|
|
|
38
46
|
### Options (for `run` command)
|
|
39
47
|
|
|
40
|
-
| Flag
|
|
41
|
-
|
|
42
|
-
| `-t`, `--timeout N`
|
|
43
|
-
| `-f`, `--format FORMAT`
|
|
44
|
-
| `--target
|
|
45
|
-
| `--min-score FLOAT`
|
|
46
|
-
| `--spec FILES`
|
|
47
|
-
| `-j`, `--jobs N`
|
|
48
|
-
| `--no-baseline`
|
|
49
|
-
| `--fail-fast [N]`
|
|
50
|
-
| `-v`, `--verbose`
|
|
51
|
-
| `--suggest-tests`
|
|
52
|
-
| `-q`, `--quiet`
|
|
48
|
+
| Flag | Type | Default | Description |
|
|
49
|
+
|------------------------------|---------|--------------|---------------------------------------------------|
|
|
50
|
+
| `-t`, `--timeout N` | Integer | 30 | Per-mutation timeout in seconds. |
|
|
51
|
+
| `-f`, `--format FORMAT` | String | `text` | Output format: `text`, `json`, or `html`. |
|
|
52
|
+
| `--target EXPR` | String | _(none)_ | Only mutate matching methods. Supports method name (`Foo::Bar#calculate`), class (`Foo`), namespace wildcards (`Foo::Bar*`), method-type selectors (`Foo#`, `Foo.`), descendants (`descendants:Foo`), and source globs (`source:lib/**/*.rb`). |
|
|
53
|
+
| `--min-score FLOAT` | Float | 0.0 | Minimum mutation score (0.0–1.0) to pass. |
|
|
54
|
+
| `--spec FILES` | Array | _(none)_ | Spec files to run (comma-separated). Defaults to `spec/`. |
|
|
55
|
+
| `-j`, `--jobs N` | Integer | 1 | Number of parallel workers. Uses demand-driven work distribution with pipe-based IPC. |
|
|
56
|
+
| `--no-baseline` | Boolean | _(enabled)_ | Skip baseline test suite check. By default, a baseline run detects pre-existing failures and marks those mutations as `neutral`. |
|
|
57
|
+
| `--fail-fast [N]` | Integer | _(none)_ | Stop after N surviving mutants (default 1 if no value given). |
|
|
58
|
+
| `-v`, `--verbose` | Boolean | false | Verbose output with RSS memory and GC stats per phase and per mutation. |
|
|
59
|
+
| `--suggest-tests` | Boolean | false | Generate concrete RSpec test code in suggestions instead of static descriptions. |
|
|
60
|
+
| `-q`, `--quiet` | Boolean | false | Suppress output. |
|
|
61
|
+
| `--stdin` | Boolean | false | Read target file paths from stdin (one per line). |
|
|
62
|
+
| `--incremental` | Boolean | false | Cache killed/timeout results; skip unchanged mutations on re-runs. |
|
|
63
|
+
| `--save-session` | Boolean | false | Persist results as timestamped JSON under `.evilution/results/`. |
|
|
64
|
+
| `--no-progress` | Boolean | _(enabled)_ | Disable the TTY progress bar. |
|
|
65
|
+
| `--show-disabled` | Boolean | false | Report mutations skipped by `# evilution:disable` comments. |
|
|
66
|
+
| `--baseline-session PATH` | String | _(none)_ | Saved session file for HTML report comparison. |
|
|
67
|
+
| `-e CODE`, `--eval CODE` | String | _(none)_ | Inline Ruby code for `util mutation` command. |
|
|
53
68
|
|
|
54
69
|
### Exit Codes
|
|
55
70
|
|
|
@@ -66,15 +81,43 @@ Generate default config: `bundle exec evilution init`
|
|
|
66
81
|
Creates `.evilution.yml`:
|
|
67
82
|
|
|
68
83
|
```yaml
|
|
69
|
-
# timeout:
|
|
70
|
-
# format: text
|
|
71
|
-
# min_score: 0.0
|
|
72
|
-
# integration: rspec
|
|
73
|
-
# suggest_tests: false
|
|
84
|
+
# timeout: 30 # seconds per mutation
|
|
85
|
+
# format: text # text | json | html
|
|
86
|
+
# min_score: 0.0 # 0.0–1.0
|
|
87
|
+
# integration: rspec # test framework
|
|
88
|
+
# suggest_tests: false # concrete RSpec test code in suggestions
|
|
89
|
+
# save_session: false # persist results under .evilution/results/
|
|
90
|
+
# show_disabled: false # report mutations skipped by disable comments
|
|
91
|
+
# baseline_session: null # path to session file for HTML comparison
|
|
92
|
+
# ignore_patterns: [] # AST patterns to exclude (see docs/ast_pattern_syntax.md)
|
|
93
|
+
# progress: true # TTY progress bar
|
|
74
94
|
```
|
|
75
95
|
|
|
76
96
|
**Precedence**: CLI flags override `.evilution.yml` values.
|
|
77
97
|
|
|
98
|
+
## Disable Comments
|
|
99
|
+
|
|
100
|
+
Suppress mutations on specific code with inline comments:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
# Disable a single line
|
|
104
|
+
log(message) # evilution:disable
|
|
105
|
+
|
|
106
|
+
# Disable an entire method (place comment immediately before def)
|
|
107
|
+
# evilution:disable
|
|
108
|
+
def infrastructure_method
|
|
109
|
+
# no mutations generated for this method body
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Disable a region
|
|
113
|
+
# evilution:disable
|
|
114
|
+
setup_logging
|
|
115
|
+
configure_metrics
|
|
116
|
+
# evilution:enable
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Use `--show-disabled` to see which mutations were skipped.
|
|
120
|
+
|
|
78
121
|
## JSON Output Schema
|
|
79
122
|
|
|
80
123
|
Use `--format json` for machine-readable output. Schema:
|
|
@@ -112,30 +155,72 @@ Use `--format json` for machine-readable output. Schema:
|
|
|
112
155
|
|
|
113
156
|
**Key metric**: `summary.score` — the mutation score. Higher is better. 1.0 means all mutations were caught.
|
|
114
157
|
|
|
115
|
-
## Mutation Operators (
|
|
158
|
+
## Mutation Operators (60 total)
|
|
116
159
|
|
|
117
160
|
Each operator name is stable and appears in JSON output under `survived[].operator`.
|
|
118
161
|
|
|
119
|
-
| Operator
|
|
120
|
-
|
|
121
|
-
| `arithmetic_replacement`
|
|
122
|
-
| `comparison_replacement`
|
|
123
|
-
| `boolean_operator_replacement` | Swap `&&` / `\|\|`
|
|
124
|
-
| `boolean_literal_replacement`
|
|
125
|
-
| `nil_replacement`
|
|
126
|
-
| `integer_literal`
|
|
127
|
-
| `float_literal`
|
|
128
|
-
| `string_literal`
|
|
129
|
-
| `array_literal`
|
|
130
|
-
| `hash_literal`
|
|
131
|
-
| `symbol_literal`
|
|
132
|
-
| `conditional_negation`
|
|
133
|
-
| `conditional_branch`
|
|
134
|
-
| `
|
|
135
|
-
| `
|
|
136
|
-
| `
|
|
137
|
-
| `
|
|
138
|
-
| `
|
|
162
|
+
| Operator | What it does | Example |
|
|
163
|
+
|---|---|---|
|
|
164
|
+
| `arithmetic_replacement` | Swap arithmetic operators | `a + b` -> `a - b` |
|
|
165
|
+
| `comparison_replacement` | Swap comparison operators | `a >= b` -> `a > b` |
|
|
166
|
+
| `boolean_operator_replacement` | Swap `&&` / `\|\|` | `a && b` -> `a \|\| b` |
|
|
167
|
+
| `boolean_literal_replacement` | Flip boolean literals | `true` -> `false` |
|
|
168
|
+
| `nil_replacement` | Replace `nil` with `true`, `false`, `0`, `""` | `nil` -> `true` |
|
|
169
|
+
| `integer_literal` | Boundary-value integer mutations | `n` -> `0`, `1`, `n+1`, `n-1` |
|
|
170
|
+
| `float_literal` | Boundary-value float mutations | `f` -> `0.0`, `1.0` |
|
|
171
|
+
| `string_literal` | Empty the string | `"str"` -> `""` |
|
|
172
|
+
| `array_literal` | Empty the array | `[a, b]` -> `[]` |
|
|
173
|
+
| `hash_literal` | Empty the hash | `{k: v}` -> `{}` |
|
|
174
|
+
| `symbol_literal` | Replace with sentinel symbol | `:foo` -> `:__evilution_mutated__` |
|
|
175
|
+
| `conditional_negation` | Replace condition with `true`/`false` | `if cond` -> `if true` |
|
|
176
|
+
| `conditional_branch` | Remove if/else branch | Deletes branch body |
|
|
177
|
+
| `conditional_flip` | Flip `if` to `unless` and vice versa | `if cond` -> `unless cond` |
|
|
178
|
+
| `statement_deletion` | Remove statements from method bodies | Deletes a statement |
|
|
179
|
+
| `method_body_replacement` | Replace entire method body with `nil` | Method body -> `nil` |
|
|
180
|
+
| `negation_insertion` | Negate predicate methods | `x.empty?` -> `!x.empty?` |
|
|
181
|
+
| `return_value_removal` | Strip return values | `return x` -> `return` |
|
|
182
|
+
| `collection_replacement` | Swap collection methods | `map` -> `each`, `select` <-> `reject` |
|
|
183
|
+
| `collection_return` | Replace collection return values | `return [1]` -> `return []` |
|
|
184
|
+
| `scalar_return` | Replace scalar return values | `return 42` -> `return 0` |
|
|
185
|
+
| `method_call_removal` | Remove method calls, keep receiver | `obj.foo(x)` -> `obj` |
|
|
186
|
+
| `argument_removal` | Remove individual arguments | `foo(a, b)` -> `foo(b)` |
|
|
187
|
+
| `argument_nil_substitution` | Replace arguments with `nil` | `foo(a, b)` -> `foo(nil, b)` |
|
|
188
|
+
| `keyword_argument` | Remove keyword defaults/params | `def foo(bar: 42)` -> `def foo(bar:)` |
|
|
189
|
+
| `multiple_assignment` | Remove targets or swap order | `a, b = 1, 2` -> `b, a = 1, 2` |
|
|
190
|
+
| `block_removal` | Remove blocks from method calls | `items.map { \|x\| x * 2 }` -> `items.map` |
|
|
191
|
+
| `range_replacement` | Swap inclusive/exclusive ranges | `1..10` -> `1...10` |
|
|
192
|
+
| `regexp_mutation` | Replace regexp with always/never matching | `/pat/` -> `/a\A/` |
|
|
193
|
+
| `receiver_replacement` | Drop explicit `self` receiver | `self.foo` -> `foo` |
|
|
194
|
+
| `send_mutation` | Swap semantically related methods | `detect` -> `find`, `map` -> `flat_map` |
|
|
195
|
+
| `compound_assignment` | Swap compound assignment operators | `+=` -> `-=`, `&&=` -> `\|\|=` |
|
|
196
|
+
| `local_variable_assignment` | Replace variable assignment with `nil` | `x = expr` -> `x = nil` |
|
|
197
|
+
| `instance_variable_write` | Replace ivar assignment with `nil` | `@x = expr` -> `@x = nil` |
|
|
198
|
+
| `class_variable_write` | Replace cvar assignment with `nil` | `@@x = expr` -> `@@x = nil` |
|
|
199
|
+
| `global_variable_write` | Replace gvar assignment with `nil` | `$x = expr` -> `$x = nil` |
|
|
200
|
+
| `mixin_removal` | Remove include/extend/prepend | `include Foo` -> removed |
|
|
201
|
+
| `superclass_removal` | Remove class inheritance | `class Foo < Bar` -> `class Foo` |
|
|
202
|
+
| `rescue_removal` | Remove rescue clauses | Deletes rescue block |
|
|
203
|
+
| `rescue_body_replacement` | Replace rescue body with `nil` | Rescue body -> `nil` |
|
|
204
|
+
| `inline_rescue` | Remove inline rescue fallback | `expr rescue val` -> `expr` |
|
|
205
|
+
| `ensure_removal` | Remove ensure blocks | Deletes ensure block |
|
|
206
|
+
| `break_statement` | Remove break statements | `break` -> removed |
|
|
207
|
+
| `next_statement` | Remove next statements | `next` -> removed |
|
|
208
|
+
| `redo_statement` | Remove redo statements | `redo` -> removed |
|
|
209
|
+
| `bang_method` | Swap bang with non-bang methods | `sort!` -> `sort` |
|
|
210
|
+
| `bitwise_replacement` | Swap bitwise operators | `a & b` -> `a \| b` |
|
|
211
|
+
| `bitwise_complement` | Remove or swap `~` | `~x` -> `x`, `~x` -> `-x` |
|
|
212
|
+
| `zsuper_removal` | Replace implicit `super` with `nil` | `super` -> `nil` |
|
|
213
|
+
| `explicit_super_mutation` | Mutate explicit super arguments | `super(a, b)` -> `super` |
|
|
214
|
+
| `index_to_fetch` | Replace `[]` with `.fetch()` | `h[k]` -> `h.fetch(k)` |
|
|
215
|
+
| `index_to_dig` | Replace `[]` chains with `.dig()` | `h[a][b]` -> `h.dig(a, b)` |
|
|
216
|
+
| `index_assignment_removal` | Remove `[]=` assignments | `h[k] = v` -> removed |
|
|
217
|
+
| `pattern_matching_guard` | Remove/negate pattern guards | `in x if cond` -> `in x` |
|
|
218
|
+
| `pattern_matching_alternative` | Remove/reorder alternatives | `pat1 \| pat2` -> `pat1` |
|
|
219
|
+
| `pattern_matching_array` | Remove/wildcard array elements | `[a, b]` -> `[a, _]` |
|
|
220
|
+
| `yield_statement` | Remove yield or its arguments | `yield(x)` -> `yield` |
|
|
221
|
+
| `splat_operator` | Remove splat/double-splat | `foo(*args)` -> `foo(args)` |
|
|
222
|
+
| `defined_check` | Replace `defined?` with `true` | `defined?(x)` -> `true` |
|
|
223
|
+
| `regex_capture` | Swap or nil-ify capture refs | `$1` -> `$2`, `$1` -> `nil` |
|
|
139
224
|
|
|
140
225
|
## MCP Server (AI Agent Integration)
|
|
141
226
|
|
|
@@ -160,7 +245,14 @@ Create a `.mcp.json` file in your project root:
|
|
|
160
245
|
|
|
161
246
|
If using Bundler, set the command to `bundle` and args to `["exec", "evilution", "mcp"]`.
|
|
162
247
|
|
|
163
|
-
The server exposes
|
|
248
|
+
The server exposes the following tools:
|
|
249
|
+
|
|
250
|
+
| Tool | Description |
|
|
251
|
+
|---|---|
|
|
252
|
+
| `evilution-mutate` | Run mutation testing on target files with structured JSON results |
|
|
253
|
+
| `evilution-session-list` | Browse saved session history |
|
|
254
|
+
| `evilution-session-show` | Display detailed session results |
|
|
255
|
+
| `evilution-session-diff` | Compare two sessions (fixed/new/persistent survivors, score delta) |
|
|
164
256
|
|
|
165
257
|
### Verbosity Control
|
|
166
258
|
|
|
@@ -274,11 +366,12 @@ Tests 4 paths (InProcess isolation, Fork isolation, mutation generation + stripp
|
|
|
274
366
|
|
|
275
367
|
1. **Parse** — Prism parses Ruby files into ASTs with exact byte offsets
|
|
276
368
|
2. **Extract** — Methods are identified as mutation subjects
|
|
277
|
-
3. **
|
|
278
|
-
4. **
|
|
279
|
-
5. **
|
|
280
|
-
6. **
|
|
281
|
-
7. **
|
|
369
|
+
3. **Filter** — Disable comments, Sorbet `sig` blocks, and AST ignore patterns exclude mutations before execution
|
|
370
|
+
4. **Mutate** — 60 operators produce text replacements at precise byte offsets (source-level surgery, no AST unparsing)
|
|
371
|
+
5. **Isolate** — Default isolation is in-process; `--isolation fork` uses forked child processes. Parallel mode (`--jobs N`) always uses in-process isolation inside pool workers to avoid double forking
|
|
372
|
+
6. **Test** — RSpec executes against the mutated source
|
|
373
|
+
7. **Collect** — Source strings and AST nodes are released after use to minimize memory retention
|
|
374
|
+
8. **Report** — Results aggregated into text, JSON, or HTML, including efficiency metrics and peak memory usage
|
|
282
375
|
|
|
283
376
|
## Repository
|
|
284
377
|
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# AST Pattern Language Syntax
|
|
2
|
+
|
|
3
|
+
Design document for the `ignore_patterns` configuration in Evilution.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The AST pattern language allows precise, semantic exclusion of mutations. Instead of
|
|
8
|
+
file/line targeting, patterns match Prism AST node structures so that mutations on
|
|
9
|
+
logging, debugging, or infrastructure code can be suppressed declaratively.
|
|
10
|
+
|
|
11
|
+
Patterns are specified in `.evilution.yml` under the `ignore_patterns` key:
|
|
12
|
+
|
|
13
|
+
```yaml
|
|
14
|
+
ignore_patterns:
|
|
15
|
+
- "call{name=log}"
|
|
16
|
+
- "call{receiver=call{name=logger}}"
|
|
17
|
+
- "call{name=puts|warn|pp}"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Syntax
|
|
21
|
+
|
|
22
|
+
### Node Type Matching
|
|
23
|
+
|
|
24
|
+
A pattern starts with a **node type** name. Node types are lowercased, underscore-separated
|
|
25
|
+
names derived from Prism node classes with the `Node` suffix stripped:
|
|
26
|
+
|
|
27
|
+
| Pattern type | Prism class |
|
|
28
|
+
|-----------------|---------------------------|
|
|
29
|
+
| `call` | `Prism::CallNode` |
|
|
30
|
+
| `string` | `Prism::StringNode` |
|
|
31
|
+
| `integer` | `Prism::IntegerNode` |
|
|
32
|
+
| `if` | `Prism::IfNode` |
|
|
33
|
+
| `def` | `Prism::DefNode` |
|
|
34
|
+
| `constant_read` | `Prism::ConstantReadNode` |
|
|
35
|
+
|
|
36
|
+
A bare node type matches any node of that type:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
call # matches any CallNode
|
|
40
|
+
def # matches any DefNode
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Attribute Matching
|
|
44
|
+
|
|
45
|
+
Curly braces after the node type specify **attribute constraints**. Attributes
|
|
46
|
+
correspond to Prism node accessor methods:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
call{name=log} # CallNode where name == :log
|
|
50
|
+
def{name=initialize} # DefNode where name == :initialize
|
|
51
|
+
constant_read{name=Logger} # ConstantReadNode where name == :Logger
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Multiple attributes are separated by commas (AND logic):
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
call{name=info, receiver=call{name=logger}}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Attribute Values
|
|
61
|
+
|
|
62
|
+
**Symbol/string values** are unquoted. The value is matched against the node attribute
|
|
63
|
+
after calling `.to_s` on both sides:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
call{name=log} # matches when node.name.to_s == "log"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Alternatives** use `|` (OR logic) within a single attribute value:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
call{name=debug|info|warn|error|fatal} # matches any log level
|
|
73
|
+
call{name=puts|p|pp|print} # matches common debug output
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**Wildcard** `*` matches any value (useful for requiring an attribute exists):
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
call{receiver=*} # matches any call with a receiver (not bare function calls)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Nested Patterns
|
|
83
|
+
|
|
84
|
+
Attribute values can be **nested patterns**, enabling structural matching:
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
call{receiver=call{name=logger}}
|
|
88
|
+
# Matches: logger.info("msg"), logger.debug(data)
|
|
89
|
+
# Does NOT match: info("msg"), foo.info("msg")
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Nesting can go arbitrarily deep:
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
call{receiver=call{receiver=constant_read{name=Rails}, name=logger}}
|
|
96
|
+
# Matches: Rails.logger.info("msg")
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Wildcards
|
|
100
|
+
|
|
101
|
+
The `_` pattern matches **any single node**:
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
call{receiver=_} # any call with any receiver
|
|
105
|
+
call{name=log, receiver=_} # .log() called on anything
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The `**` pattern matches **any subtree** (zero or more levels):
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
call{receiver=**, name=log}
|
|
112
|
+
# Matches: log(), foo.log(), foo.bar.log(), a.b.c.log()
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Negation
|
|
116
|
+
|
|
117
|
+
Prefix `!` negates a pattern or value:
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
call{name=!log} # any call except log
|
|
121
|
+
call{receiver=!call{name=logger}} # calls whose receiver is not logger
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## YAML Configuration
|
|
125
|
+
|
|
126
|
+
```yaml
|
|
127
|
+
ignore_patterns:
|
|
128
|
+
# Suppress mutations on all logging calls
|
|
129
|
+
- "call{name=debug|info|warn|error|fatal, receiver=call{name=logger}}"
|
|
130
|
+
|
|
131
|
+
# Suppress mutations on debug output
|
|
132
|
+
- "call{name=puts|p|pp|print}"
|
|
133
|
+
|
|
134
|
+
# Suppress mutations on Rails logger at any depth
|
|
135
|
+
- "call{receiver=call{receiver=constant_read{name=Rails}, name=logger}}"
|
|
136
|
+
|
|
137
|
+
# Suppress mutations inside any method named `to_s`
|
|
138
|
+
- "def{name=to_s}"
|
|
139
|
+
|
|
140
|
+
# Suppress mutations on constant `ENV` reads
|
|
141
|
+
- "call{receiver=constant_read{name=ENV}}"
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Pattern Matching Semantics
|
|
145
|
+
|
|
146
|
+
1. Each pattern is tested against the **mutation's AST node** and its **ancestors**.
|
|
147
|
+
2. A mutation is ignored if **any** pattern in `ignore_patterns` matches.
|
|
148
|
+
3. For `def` patterns, the match is against the enclosing method node, not the
|
|
149
|
+
mutated node itself. This allows ignoring all mutations within a method.
|
|
150
|
+
4. Attribute matching is **exact** (after `.to_s` coercion) unless alternatives (`|`)
|
|
151
|
+
or wildcards (`*`, `_`, `**`) are used.
|
|
152
|
+
5. Unspecified attributes are unconstrained (implicit wildcard).
|
|
153
|
+
|
|
154
|
+
## Grammar (EBNF)
|
|
155
|
+
|
|
156
|
+
```ebnf
|
|
157
|
+
pattern = node_type [ "{" attributes "}" ]
|
|
158
|
+
node_type = identifier | "_" | "**"
|
|
159
|
+
attributes = attribute { "," attribute }
|
|
160
|
+
attribute = identifier "=" value
|
|
161
|
+
value = "!" value
|
|
162
|
+
| nested_pattern
|
|
163
|
+
| alternatives
|
|
164
|
+
| "*"
|
|
165
|
+
nested_pattern = node_type "{" attributes "}"
|
|
166
|
+
alternatives = atom { "|" atom }
|
|
167
|
+
atom = identifier | "*"
|
|
168
|
+
identifier = [a-zA-Z_] [a-zA-Z0-9_]*
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
A bare `identifier` without `{` is parsed as a scalar value (matched via `.to_s`).
|
|
172
|
+
An `identifier` followed by `{` is parsed as a nested pattern.
|
|
173
|
+
|
|
174
|
+
## Examples
|
|
175
|
+
|
|
176
|
+
| Pattern | Matches | Does NOT match |
|
|
177
|
+
|---------|---------|----------------|
|
|
178
|
+
| `call` | Any method call | Literals, assignments |
|
|
179
|
+
| `call{name=log}` | `log()`, `x.log()` | `logger()`, `x.debug()` |
|
|
180
|
+
| `call{name=debug\|info}` | `debug()`, `x.info()` | `warn()`, `error()` |
|
|
181
|
+
| `call{receiver=call{name=logger}}` | `logger.info()` | `info()`, `foo.info()` |
|
|
182
|
+
| `call{receiver=_}` | `x.foo()`, `obj.bar()` | `foo()` (no receiver) |
|
|
183
|
+
| `call{receiver=**}` | `foo()`, `x.foo()`, `a.b.foo()` | *(matches all)* |
|
|
184
|
+
| `def{name=to_s}` | `def to_s; ... end` | `def to_str; ... end` |
|
|
185
|
+
| `call{name=!log}` | `debug()`, `info()` | `log()` |
|
|
186
|
+
|
|
187
|
+
## Design Decisions
|
|
188
|
+
|
|
189
|
+
1. **Prism-native naming**: Node types map directly to Prism class names (lowercased,
|
|
190
|
+
`Node` suffix stripped). No abstraction layer — users who inspect AST output can
|
|
191
|
+
write patterns immediately.
|
|
192
|
+
|
|
193
|
+
2. **Unquoted values**: Attribute values don't require quotes. This keeps YAML clean
|
|
194
|
+
and avoids escaping issues. The tradeoff is that values cannot contain `{`, `}`,
|
|
195
|
+
`,`, `=`, `|`, or `!` — these are reserved syntax characters.
|
|
196
|
+
|
|
197
|
+
3. **Implicit wildcards for unspecified attributes**: `call{name=log}` matches
|
|
198
|
+
regardless of receiver, arguments, etc. Only specified attributes constrain the
|
|
199
|
+
match.
|
|
200
|
+
|
|
201
|
+
4. **OR within attributes, AND across attributes**: `name=a|b` is OR;
|
|
202
|
+
`name=a, receiver=x` is AND. This covers the common cases without needing
|
|
203
|
+
explicit boolean operators.
|
|
204
|
+
|
|
205
|
+
5. **`**` for deep matching**: Inspired by glob syntax. Enables "match .log() at any
|
|
206
|
+
call depth" without enumerating receiver chains.
|
|
207
|
+
|
|
208
|
+
6. **No regex support**: Exact match + alternatives covers the practical use cases.
|
|
209
|
+
Regex would complicate parsing and make patterns harder to read. Can be added later
|
|
210
|
+
if needed.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "parser"
|
|
4
|
+
|
|
5
|
+
class Evilution::AST::Pattern::Filter
|
|
6
|
+
attr_reader :skipped_count
|
|
7
|
+
|
|
8
|
+
def initialize(patterns)
|
|
9
|
+
@matchers = patterns.map { |p| Evilution::AST::Pattern::Parser.new(p).parse }
|
|
10
|
+
@skipped_count = 0
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def skip?(node)
|
|
14
|
+
if @matchers.any? { |m| m.match?(node) }
|
|
15
|
+
@skipped_count += 1
|
|
16
|
+
true
|
|
17
|
+
else
|
|
18
|
+
false
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def reset_count!
|
|
23
|
+
@skipped_count = 0
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../pattern"
|
|
4
|
+
|
|
5
|
+
module Evilution::AST::Pattern
|
|
6
|
+
class NodeMatcher
|
|
7
|
+
attr_reader :node_type, :attributes
|
|
8
|
+
|
|
9
|
+
def initialize(node_type, attributes)
|
|
10
|
+
@node_type = node_type
|
|
11
|
+
@prism_class = resolve_prism_class(node_type)
|
|
12
|
+
@attributes = attributes
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def match?(node)
|
|
16
|
+
return false unless node.is_a?(@prism_class)
|
|
17
|
+
|
|
18
|
+
@attributes.all? { |attr_name, value_matcher| match_attribute?(node, attr_name, value_matcher) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def resolve_prism_class(type_name)
|
|
24
|
+
class_name = "#{type_name.split("_").map(&:capitalize).join}Node"
|
|
25
|
+
Prism.const_get(class_name)
|
|
26
|
+
rescue NameError
|
|
27
|
+
raise Evilution::ConfigError, "unknown AST node type: #{type_name}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def match_attribute?(node, attr_name, value_matcher)
|
|
31
|
+
return false unless node.respond_to?(attr_name)
|
|
32
|
+
|
|
33
|
+
attr_value = node.public_send(attr_name)
|
|
34
|
+
|
|
35
|
+
if value_matcher.respond_to?(:match?)
|
|
36
|
+
value_matcher.match?(attr_value)
|
|
37
|
+
else
|
|
38
|
+
value_matcher.match_value?(attr_value)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class AnyNodeMatcher
|
|
44
|
+
def match?(node)
|
|
45
|
+
!node.nil?
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class DeepWildcardMatcher
|
|
50
|
+
def match?(_node)
|
|
51
|
+
true
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
class ValueMatcher
|
|
56
|
+
def initialize(value)
|
|
57
|
+
@value = value
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def match_value?(actual)
|
|
61
|
+
actual.to_s == @value
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def match?(node)
|
|
65
|
+
match_value?(node)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
class AlternativesMatcher
|
|
70
|
+
def initialize(values)
|
|
71
|
+
@values = values
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def match_value?(actual)
|
|
75
|
+
actual_str = actual.to_s
|
|
76
|
+
@values.any? { |v| actual_str == v }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def match?(node)
|
|
80
|
+
match_value?(node)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
class WildcardValueMatcher
|
|
85
|
+
def match_value?(_actual)
|
|
86
|
+
true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def match?(node)
|
|
90
|
+
!node.nil?
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
class NegationMatcher
|
|
95
|
+
def initialize(inner)
|
|
96
|
+
@inner = inner
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def match_value?(actual)
|
|
100
|
+
!@inner.match_value?(actual)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def match?(node)
|
|
104
|
+
!@inner.match?(node)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|