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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/.migration-hint-ts +1 -1
  3. data/.beads/issues.jsonl +47 -46
  4. data/CHANGELOG.md +48 -0
  5. data/README.md +143 -50
  6. data/docs/ast_pattern_syntax.md +210 -0
  7. data/lib/evilution/ast/pattern/filter.rb +25 -0
  8. data/lib/evilution/ast/pattern/matcher.rb +107 -0
  9. data/lib/evilution/ast/pattern/parser.rb +185 -0
  10. data/lib/evilution/ast/pattern.rb +4 -0
  11. data/lib/evilution/ast/sorbet_sig_detector.rb +52 -0
  12. data/lib/evilution/cli.rb +400 -24
  13. data/lib/evilution/config.rb +43 -2
  14. data/lib/evilution/disable_comment.rb +90 -0
  15. data/lib/evilution/hooks/loader.rb +35 -0
  16. data/lib/evilution/hooks/registry.rb +60 -0
  17. data/lib/evilution/hooks.rb +58 -0
  18. data/lib/evilution/integration/base.rb +4 -0
  19. data/lib/evilution/integration/rspec.rb +6 -2
  20. data/lib/evilution/isolation/fork.rb +5 -0
  21. data/lib/evilution/mcp/session_diff_tool.rb +5 -35
  22. data/lib/evilution/mutator/base.rb +4 -1
  23. data/lib/evilution/mutator/operator/collection_return.rb +33 -0
  24. data/lib/evilution/mutator/operator/defined_check.rb +16 -0
  25. data/lib/evilution/mutator/operator/index_assignment_removal.rb +18 -0
  26. data/lib/evilution/mutator/operator/index_to_dig.rb +58 -0
  27. data/lib/evilution/mutator/operator/index_to_fetch.rb +30 -0
  28. data/lib/evilution/mutator/operator/keyword_argument.rb +91 -0
  29. data/lib/evilution/mutator/operator/mixin_removal.rb +2 -1
  30. data/lib/evilution/mutator/operator/multiple_assignment.rb +47 -0
  31. data/lib/evilution/mutator/operator/pattern_matching_alternative.rb +46 -0
  32. data/lib/evilution/mutator/operator/pattern_matching_array.rb +97 -0
  33. data/lib/evilution/mutator/operator/pattern_matching_guard.rb +44 -0
  34. data/lib/evilution/mutator/operator/regex_capture.rb +43 -0
  35. data/lib/evilution/mutator/operator/scalar_return.rb +37 -0
  36. data/lib/evilution/mutator/operator/splat_operator.rb +46 -0
  37. data/lib/evilution/mutator/operator/superclass_removal.rb +2 -1
  38. data/lib/evilution/mutator/operator/yield_statement.rb +51 -0
  39. data/lib/evilution/mutator/registry.rb +17 -3
  40. data/lib/evilution/parallel/pool.rb +7 -51
  41. data/lib/evilution/parallel/work_queue.rb +224 -0
  42. data/lib/evilution/reporter/cli.rb +22 -1
  43. data/lib/evilution/reporter/html.rb +76 -3
  44. data/lib/evilution/reporter/json.rb +23 -2
  45. data/lib/evilution/reporter/suggestion.rb +115 -1
  46. data/lib/evilution/result/summary.rb +20 -2
  47. data/lib/evilution/runner.rb +133 -13
  48. data/lib/evilution/session/diff.rb +85 -0
  49. data/lib/evilution/session/store.rb +5 -2
  50. data/lib/evilution/version.rb +1 -1
  51. data/lib/evilution.rb +23 -0
  52. metadata +28 -2
data/README.md CHANGED
@@ -29,27 +29,42 @@ evilution [command] [options] [files...]
29
29
 
30
30
  ### Commands
31
31
 
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 | |
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 | Type | Default | Description |
41
- |-------------------------|---------|--------------|---------------------------------------------------|
42
- | `-t`, `--timeout N` | Integer | 10 | Per-mutation timeout in seconds. |
43
- | `-f`, `--format FORMAT` | String | `text` | Output format: `text` or `json`. |
44
- | `--target METHOD` | String | _(none)_ | Only mutate the named method (e.g. `Foo::Bar#calculate`). |
45
- | `--min-score FLOAT` | Float | 0.0 | Minimum mutation score (0.0–1.0) to pass. |
46
- | `--spec FILES` | Array | _(none)_ | Spec files to run (comma-separated). Defaults to `spec/`. |
47
- | `-j`, `--jobs N` | Integer | 1 | Number of parallel workers. Pool forks per batch; mutations run in-process inside workers. |
48
- | `--no-baseline` | Boolean | _(enabled)_ | Skip baseline test suite check. By default, a baseline run detects pre-existing failures and marks those mutations as `neutral`. |
49
- | `--fail-fast [N]` | Integer | _(none)_ | Stop after N surviving mutants (default 1 if no value given). |
50
- | `-v`, `--verbose` | Boolean | false | Verbose output with RSS memory and GC stats per phase and per mutation. |
51
- | `--suggest-tests` | Boolean | false | Generate concrete RSpec test code in suggestions instead of static descriptions. |
52
- | `-q`, `--quiet` | Boolean | false | Suppress output. |
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: 10 # seconds per mutation
70
- # format: text # text | json
71
- # min_score: 0.0 # 0.0–1.0
72
- # integration: rspec # test framework
73
- # suggest_tests: false # concrete RSpec test code in suggestions
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 (18 total)
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 | What it does | Example |
120
- |---------------------------|-------------------------------------------|------------------------------------|
121
- | `arithmetic_replacement` | Swap arithmetic operators | `a + b` -> `a - b` |
122
- | `comparison_replacement` | Swap comparison operators | `a >= b` -> `a > b` |
123
- | `boolean_operator_replacement` | Swap `&&` / `\|\|` | `a && b` -> `a \|\| b` |
124
- | `boolean_literal_replacement` | Flip boolean literals | `true` -> `false` |
125
- | `nil_replacement` | Replace expression with `nil` | `expr` -> `nil` |
126
- | `integer_literal` | Boundary-value integer mutations | `n` -> `0`, `1`, `n+1`, `n-1` |
127
- | `float_literal` | Boundary-value float mutations | `f` -> `0.0`, `1.0` |
128
- | `string_literal` | Empty the string | `"str"` -> `""` |
129
- | `array_literal` | Empty the array | `[a, b]` -> `[]` |
130
- | `hash_literal` | Empty the hash | `{k: v}` -> `{}` |
131
- | `symbol_literal` | Replace with sentinel symbol | `:foo` -> `:__evilution_mutated__` |
132
- | `conditional_negation` | Replace condition with `true`/`false` | `if cond` -> `if true` |
133
- | `conditional_branch` | Remove if/else branch | Deletes branch body |
134
- | `statement_deletion` | Remove statements from method bodies | Deletes a statement |
135
- | `method_body_replacement` | Replace entire method body with `nil` | Method body -> `nil` |
136
- | `negation_insertion` | Negate predicate methods | `x.empty?` -> `!x.empty?` |
137
- | `return_value_removal` | Strip return values | `return x` -> `return` |
138
- | `collection_replacement` | Swap collection methods | `map` -> `each`, `select` <-> `reject` |
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 an `evilution-mutate` tool that accepts target files, method targets, spec overrides, parallelism, and timeout options — returning structured JSON results directly to the agent.
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. **Mutate** — Operators produce text replacements at precise byte offsets (source-level surgery, no AST unparsing)
278
- 4. **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
279
- 5. **Test** — RSpec executes against the mutated source
280
- 6. **Collect** — Source strings and AST nodes are released after use to minimize memory retention
281
- 7. **Report** — Results aggregated into text or JSON, including peak memory usage
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