evilution 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 41c28afbfc162902708394c367eb87150663ac64ab7b2228ee72ab7bb3b0549b
4
- data.tar.gz: 269ec61ea7f4d9f083247b022ba7e331bfce85ebc62d75151cf0b14d6769a8f9
3
+ metadata.gz: 3b561b61697995a22463ea15828357375b1ce9868ad6ee87f3f331336d26b890
4
+ data.tar.gz: 4a0cb24bfe2e9e9478ab5d752f05ea4ea69582e324e736241f1fe6b1e5e581cd
5
5
  SHA512:
6
- metadata.gz: 11f61c28eca30a943b6632066b8992860e2bf67fb1a617e8cf0ac6ccfcbbce33d48baec4452a57127aacaeacf2f834f6d01350be091a5d2bbd279df226e5cbc0
7
- data.tar.gz: 6d3c6f07b8ddda520966ef2bc9616752fa693499fff18e0542e3059a1f1ff16ae9a56f9b68fe96eea6b90cb41981e5f87bc540238a2fd2e29f166f629cf1259f
6
+ metadata.gz: 416aaea92d50fc3a969c350fd36d47e5b8b625ba4b1c64b66f208731cfaf1250714b8bc8e494bbc1586922357886dd96026f31e9f96b7e2cd849e43f402de3e3
7
+ data.tar.gz: e81fc9c5edfa9e25b4a7077f2776c2256bf5211bc5d88b53cc604ef735d64366ee3b3a294a480f49d3107c7634ff98459f6f46e0a8ede3248cec2b72007f940e
@@ -230,3 +230,8 @@
230
230
  {"id":"int-94972aae","kind":"field_change","created_at":"2026-04-20T16:13:12.727090894Z","actor":"Denis Kiselev","issue_id":"EV-0fgx","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #813 (commit 262b9ae). Integration spec spec/evilution/compare/integration_spec.rb + e2e fixtures landed. 15/15 examples pass."}}
231
231
  {"id":"int-4d639e4e","kind":"field_change","created_at":"2026-04-20T16:57:42.95949164Z","actor":"Denis Kiselev","issue_id":"EV-ynlo","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #816. SourceAstCache content-keyed LRU + ExampleFilter integration + BaselineRunner wiring."}}
232
232
  {"id":"int-c15ed3e8","kind":"field_change","created_at":"2026-04-20T17:49:38.782150309Z","actor":"Denis Kiselev","issue_id":"EV-toid","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
233
+ {"id":"int-842a1d3a","kind":"field_change","created_at":"2026-04-21T05:01:55.401885343Z","actor":"Denis Kiselev","issue_id":"EV-h8pw","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
234
+ {"id":"int-c92539a9","kind":"field_change","created_at":"2026-04-21T05:04:50.605824487Z","actor":"Denis Kiselev","issue_id":"EV-vkq9","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
235
+ {"id":"int-2abceed3","kind":"field_change","created_at":"2026-04-24T05:18:13.096916772Z","actor":"Denis Kiselev","issue_id":"EV-kjac","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
236
+ {"id":"int-03bc2f7f","kind":"field_change","created_at":"2026-04-24T05:40:10.83959826Z","actor":"Denis Kiselev","issue_id":"EV-hklf","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
237
+ {"id":"int-2781044c","kind":"field_change","created_at":"2026-04-24T05:40:11.975030418Z","actor":"Denis Kiselev","issue_id":"EV-cpku","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Folded into EV-hklf — error rename 'no method found' → 'no subject matched' shipped in PR #872."}}
@@ -66,17 +66,30 @@ Evilution
66
66
  ::AST::SourceSurgeon # Text-level mutation at byte offsets
67
67
  ::Mutator::Base # Abstract operator base (Prism::Visitor)
68
68
  ::Mutator::Registry # Node type → operator mapping
69
- ::Mutator::Operator::* # 18 concrete mutation operators
69
+ ::Mutator::Operator::* # 72 concrete mutation operators
70
70
  ::Isolation::Fork # Fork + pipe per mutation
71
+ ::Integration::Base # Template-method orchestrator; delegates mutation apply to Loading::MutationApplier
71
72
  ::Integration::RSpec # RSpec programmatic test runner
73
+ ::Integration::Minitest # Minitest programmatic test runner
74
+ ::Integration::Loading::MutationApplier # Composes the mutation-apply pipeline
75
+ ::Integration::Loading::SyntaxValidator # Prism parse check
76
+ ::Integration::Loading::SourceEvaluator # eval w/ TOPLEVEL_BINDING + absolute path
77
+ ::Integration::Loading::ConstantPinner # const_get top-level constants to defeat Zeitwerk re-autoload
78
+ ::Integration::Loading::RedefinitionRecovery # Strip constants + retry on "already defined"
79
+ ::Integration::Loading::ConcernStateCleaner # Clear AS::Concern @_included_block / @_prepended_block
80
+ ::AST::ConstantNames # Prism walk → fully-qualified class/module names
81
+ ::LoadPath::SubpathResolver # Shortest $LOAD_PATH-relative path for a file
72
82
  ::Coverage::Collector # Ruby Coverage module wrapper
73
83
  ::Coverage::TestMap # Source line → test file mapping
84
+ ::Compare::Categorizer # Fixed / new / persistent / flaky / reintroduced bucketing
85
+ ::Compare::Normalizer # Canonicalize mutation records for cross-run compare
74
86
  ::Diff::Parser # Git diff output parser
75
87
  ::Diff::FileFilter # Filter subjects to changed code
76
88
  ::Result::MutationResult # Single mutation outcome
77
89
  ::Result::Summary # Aggregated results
78
90
  ::Reporter::JSON # Structured output for AI agents
79
91
  ::Reporter::CLI # Human-readable terminal output
92
+ ::Reporter::HTML # Section-based HTML report
80
93
  ::Reporter::Suggestion # Actionable fix hint generator
81
94
  ```
82
95
 
@@ -0,0 +1,55 @@
1
+ ---
2
+ name: create-issue
3
+ description: Create a beads issue and a matching GitHub issue, cross-linked via external-ref and description
4
+ argument-hint: "--title <title> --description <desc> --type <type> --priority <0-4>"
5
+ user-invocable: true
6
+ disable-model-invocation: true
7
+ ---
8
+
9
+ # Create Cross-Linked Issue (Beads + GitHub)
10
+
11
+ Create a beads issue and a matching GitHub issue in Port-Royal/vanilla-mafia, with each linking to the other.
12
+
13
+ ## Arguments
14
+
15
+ All arguments are passed as `$ARGUMENTS`. Parse them as bd-style flags:
16
+
17
+ - `--title` (required): Issue title
18
+ - `--description` (required): Why this issue exists and what needs to be done
19
+ - `--type` (required): `bug`, `feature`, or `task`
20
+ - `--priority` (required): 0-4 (0=critical, 4=backlog)
21
+ - `--parent` (optional): Parent beads issue ID for sub-tasks
22
+
23
+ Example:
24
+ ```
25
+ /create-issue --title "Fix login redirect" --description "After login, users are redirected to /dashboard instead of the page they came from" --type bug --priority 1
26
+ ```
27
+
28
+ ## Steps
29
+
30
+ 1. **Create the GitHub issue first** (to get the GH number):
31
+ ```
32
+ gh issue create --repo Port-Royal/vanilla-mafia --title "<title>" --body "<description>"
33
+ ```
34
+ Extract the issue number from the output.
35
+
36
+ 2. **Create the beads issue** with `--external-ref gh-<number>`:
37
+ ```
38
+ bd create --title "<title>" --description "<description>\n\nGH: #<gh-number>" --type <type> --priority <priority> --external-ref gh-<number>
39
+ ```
40
+ Extract the beads issue ID from the output.
41
+
42
+ 3. **Update the GitHub issue body** to include the beads ID:
43
+ ```
44
+ gh api repos/Port-Royal/vanilla-mafia/issues/<number> --method PATCH -f body="<description>
45
+
46
+ Beads: <beads-id>"
47
+ ```
48
+ Use `gh api` (not `gh issue edit`) to avoid the Projects Classic GraphQL error.
49
+
50
+ 4. **If `--parent` was provided**, set the parent on the beads issue:
51
+ ```
52
+ bd update <beads-id> --parent <parent-id>
53
+ ```
54
+
55
+ 5. **Display the result**: show both IDs in format `<beads-id> (GH #<number>)`.
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.26.0] - 2026-04-24
4
+
5
+ ### Removed
6
+
7
+ - **Deprecated MCP session tools removed** — `evilution-session-list`, `evilution-session-show`, `evilution-session-diff` shims (deprecated since #637 after consolidation into `evilution-session`) deleted (#686, PR #851)
8
+
9
+ ### Fixed
10
+
11
+ - **`preload` silently ignored under `:in_process` isolation** — `Runner::IsolationResolver#perform_preload` gated the preload on `resolve_isolation == :fork`, so non-Rails projects (auto-resolving to `:in_process`) silently skipped `preload:` from `.evilution.yml` or `--preload`. Preload now runs for `:in_process` too (#868, PR #871)
12
+ - **`--target ClassName` silently narrowed to git-changed files** — when only a class/method target was given with no file scope, `Runner::SubjectPipeline#target_files` fell back to `Git::ChangedFiles`, producing a misleading `no method found matching 'X'` error when the class file was not in the working-tree diff. Target files now resolve from the configured source when a method/class target is given without explicit file scope (#869, PR #872)
13
+
14
+ ### Changed
15
+
16
+ - **Internal `Evilution::Compare` refactor** — `lib/evilution/compare.rb` split into one class per file under `lib/evilution/compare/` (`Categorizer`, `Detector`, `Fingerprint`, `InvalidInput`, `Normalizer`, `Record`); `rubocop:disable Style/OneClassPerFile` removed (#825, PR #852)
17
+ - **Internal `Evilution::Integration::Base` refactor** — decomposed into focused collaborators under `Evilution::Integration::Loading::*` (`SyntaxValidator`, `SourceEvaluator`, `ConstantPinner`, `RedefinitionRecovery`, `ConcernStateCleaner`, `MutationApplier`) plus shared helpers `Evilution::AST::ConstantNames` and `Evilution::LoadPath::SubpathResolver`. `Integration::Base` now delegates mutation application to an injectable `MutationApplier`; no user-visible behavior change (#845, PR #873)
18
+
3
19
  ## [0.25.0] - 2026-04-21
4
20
 
5
21
  ### Added
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../ast"
5
+
6
+ # Walks a Prism AST and returns every class/module constant declared, nested
7
+ # names rendered fully-qualified (e.g. "Foo::Bar"). Order is source order:
8
+ # outer declarations precede their nested children.
9
+ class Evilution::AST::ConstantNames
10
+ def call(source)
11
+ result = Prism.parse(source)
12
+ return [] if result.failure?
13
+
14
+ collect(result.value)
15
+ end
16
+
17
+ private
18
+
19
+ def collect(node, nesting = [])
20
+ names = []
21
+ case node
22
+ when Prism::ModuleNode, Prism::ClassNode
23
+ const = node.constant_path.full_name
24
+ qualified = nesting.any? && !const.include?("::") ? "#{nesting.join("::")}::#{const}" : const
25
+ names << qualified
26
+ names.concat(collect(node.body, nesting + [const])) if node.body
27
+ when Prism::ProgramNode
28
+ names.concat(collect(node.statements, nesting)) if node.statements
29
+ when Prism::StatementsNode
30
+ node.body.each { |child| names.concat(collect(child, nesting)) }
31
+ end
32
+ names
33
+ end
34
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../compare"
4
+
5
+ class Evilution::Compare::InvalidInput < StandardError
6
+ attr_reader :index
7
+
8
+ def initialize(message, index: nil)
9
+ super(message)
10
+ @index = index
11
+ end
12
+ end
@@ -1,15 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # rubocop:disable Style/OneClassPerFile
4
3
  module Evilution::Compare
5
4
  end
6
5
 
7
- class Evilution::Compare::InvalidInput < StandardError
8
- attr_reader :index
9
-
10
- def initialize(message, index: nil)
11
- super(message)
12
- @index = index
13
- end
14
- end
15
- # rubocop:enable Style/OneClassPerFile
6
+ require_relative "compare/invalid_input"
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "prism"
4
3
  require_relative "../integration"
4
+ require_relative "loading/mutation_applier"
5
5
 
6
6
  class Evilution::Integration::Base
7
7
  def self.baseline_runner
@@ -12,14 +12,15 @@ class Evilution::Integration::Base
12
12
  raise NotImplementedError, "#{name}.baseline_options must be implemented"
13
13
  end
14
14
 
15
- def initialize(hooks: nil)
15
+ def initialize(hooks: nil, mutation_applier: Evilution::Integration::Loading::MutationApplier.new)
16
16
  @hooks = hooks
17
+ @mutation_applier = mutation_applier
17
18
  end
18
19
 
19
20
  def call(mutation)
20
21
  ensure_framework_loaded
21
22
  fire_hook(:mutation_insert_pre, mutation: mutation, file_path: mutation.file_path)
22
- load_error = apply_mutation(mutation)
23
+ load_error = @mutation_applier.call(mutation)
23
24
  return load_error if load_error
24
25
 
25
26
  fire_hook(:mutation_insert_post, mutation: mutation, file_path: mutation.file_path)
@@ -47,156 +48,4 @@ class Evilution::Integration::Base
47
48
  def fire_hook(event, **payload)
48
49
  @hooks.fire(event, **payload) if @hooks
49
50
  end
50
-
51
- def apply_mutation(mutation)
52
- prism_error = validate_mutated_syntax(mutation.mutated_source)
53
- return prism_error if prism_error
54
-
55
- pin_autoloaded_constants(mutation.original_source)
56
- clear_concern_state(mutation.file_path)
57
- with_redefinition_recovery(mutation.original_source) do
58
- eval_mutated_source(mutation)
59
- end
60
- nil
61
- rescue SyntaxError => e
62
- {
63
- passed: false,
64
- error: "syntax error in mutated source: #{e.message}",
65
- error_class: e.class.name,
66
- error_backtrace: Array(e.backtrace).first(5)
67
- }
68
- rescue ScriptError, StandardError => e
69
- {
70
- passed: false,
71
- error: "#{e.class}: #{e.message}",
72
- error_class: e.class.name,
73
- error_backtrace: Array(e.backtrace).first(5)
74
- }
75
- end
76
-
77
- def validate_mutated_syntax(source)
78
- return nil if Prism.parse(source).success?
79
-
80
- {
81
- passed: false,
82
- error: "mutated source has syntax errors",
83
- error_class: "SyntaxError",
84
- error_backtrace: []
85
- }
86
- end
87
-
88
- # Evaluate the mutated source with __FILE__ set to the original path so
89
- # that `require_relative` and `__dir__` resolve against the real source
90
- # tree, where sibling files actually exist.
91
- def eval_mutated_source(mutation)
92
- absolute = File.expand_path(mutation.file_path)
93
- # rubocop:disable Security/Eval
94
- eval(mutation.mutated_source, TOPLEVEL_BINDING, absolute, 1)
95
- # rubocop:enable Security/Eval
96
- end
97
-
98
- def with_redefinition_recovery(original_source)
99
- yield
100
- rescue ArgumentError => e
101
- raise unless redefinition_conflict?(e)
102
-
103
- remove_defined_constants(original_source)
104
- yield
105
- end
106
-
107
- def redefinition_conflict?(error)
108
- error.message.include?("already defined")
109
- end
110
-
111
- def pin_autoloaded_constants(source)
112
- collect_constant_names(Prism.parse(source).value).each do |name|
113
- Object.const_get(name) if Object.const_defined?(name, false)
114
- rescue NameError # :nodoc:
115
- nil
116
- end
117
- end
118
-
119
- def collect_constant_names(node, nesting = [])
120
- names = []
121
- case node
122
- when Prism::ModuleNode, Prism::ClassNode
123
- const = node.constant_path.full_name
124
- qualified = nesting.any? && !const.include?("::") ? "#{nesting.join("::")}::#{const}" : const
125
- names << qualified
126
- names.concat(collect_constant_names(node.body, nesting + [const])) if node.body
127
- when Prism::ProgramNode
128
- names.concat(collect_constant_names(node.statements, nesting)) if node.statements
129
- when Prism::StatementsNode
130
- node.body.each { |child| names.concat(collect_constant_names(child, nesting)) }
131
- end
132
- names
133
- end
134
-
135
- def remove_defined_constants(source)
136
- collect_constant_names(Prism.parse(source).value).reverse_each do |name|
137
- parent_name, _, local_name = name.rpartition("::")
138
- parent = resolve_loaded_constant_parent(parent_name)
139
- next unless parent
140
- next unless parent.const_defined?(local_name, false)
141
- next if parent.autoload?(local_name)
142
-
143
- parent.send(:remove_const, local_name.to_sym)
144
- end
145
- end
146
-
147
- def resolve_loaded_constant_parent(parent_name)
148
- return Object if parent_name.empty?
149
-
150
- parent_name.split("::").reduce(Object) do |mod, part|
151
- return nil unless mod.const_defined?(part, false)
152
- return nil if mod.autoload?(part)
153
-
154
- resolved = mod.const_get(part, false)
155
- return nil unless resolved.is_a?(Module)
156
-
157
- resolved
158
- end
159
- end
160
-
161
- def clear_concern_state(file_path)
162
- return unless defined?(ActiveSupport::Concern)
163
-
164
- absolute = File.expand_path(file_path)
165
- subpath = resolve_require_subpath(file_path)
166
-
167
- ObjectSpace.each_object(Module) do |mod|
168
- next unless mod.singleton_class.ancestors.include?(ActiveSupport::Concern)
169
-
170
- %i[@_included_block @_prepended_block].each do |ivar|
171
- next unless mod.instance_variable_defined?(ivar)
172
-
173
- block = mod.instance_variable_get(ivar)
174
- block_file = block.source_location&.first
175
- next unless block_file
176
-
177
- expanded = File.expand_path(block_file)
178
- mod.remove_instance_variable(ivar) if source_matches?(expanded, absolute, subpath)
179
- end
180
- end
181
- end
182
-
183
- def source_matches?(block_path, absolute, subpath)
184
- block_path == absolute || (subpath && block_path.end_with?("/#{subpath}"))
185
- end
186
-
187
- def resolve_require_subpath(file_path)
188
- absolute = File.expand_path(file_path)
189
- best_subpath = nil
190
-
191
- $LOAD_PATH.each do |entry|
192
- dir = File.expand_path(entry)
193
- prefix = dir.end_with?("/") ? dir : "#{dir}/"
194
- next unless absolute.start_with?(prefix)
195
-
196
- candidate = absolute.delete_prefix(prefix)
197
- best_subpath = candidate if best_subpath.nil? || candidate.length < best_subpath.length
198
- end
199
-
200
- best_subpath
201
- end
202
51
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../loading"
4
+ require_relative "../../load_path/subpath_resolver"
5
+
6
+ # Re-evaluating an `ActiveSupport::Concern` module raises
7
+ # "MultipleIncludedBlocks" because AS::Concern records the block source
8
+ # location on the first include/prepend call. Before a re-eval we clear the
9
+ # `@_included_block` / `@_prepended_block` ivar on modules whose block came
10
+ # from the file we're about to re-eval.
11
+ class Evilution::Integration::Loading::ConcernStateCleaner
12
+ IVARS = %i[@_included_block @_prepended_block].freeze
13
+
14
+ def initialize(subpath_resolver: Evilution::LoadPath::SubpathResolver.new)
15
+ @subpath_resolver = subpath_resolver
16
+ end
17
+
18
+ def call(file_path)
19
+ return unless defined?(ActiveSupport::Concern)
20
+
21
+ absolute = File.expand_path(file_path)
22
+ subpath = @subpath_resolver.call(file_path)
23
+
24
+ ObjectSpace.each_object(Module) do |mod|
25
+ next unless mod.singleton_class.ancestors.include?(ActiveSupport::Concern)
26
+
27
+ clear_concern_ivars(mod, absolute, subpath)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def clear_concern_ivars(mod, absolute, subpath)
34
+ IVARS.each do |ivar|
35
+ next unless mod.instance_variable_defined?(ivar)
36
+
37
+ block = mod.instance_variable_get(ivar)
38
+ block_file = block.source_location&.first
39
+ next unless block_file
40
+
41
+ expanded = File.expand_path(block_file)
42
+ mod.remove_instance_variable(ivar) if source_matches?(expanded, absolute, subpath)
43
+ end
44
+ end
45
+
46
+ def source_matches?(block_path, absolute, subpath)
47
+ block_path == absolute || (subpath && block_path.end_with?("/#{subpath}"))
48
+ end
49
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../loading"
4
+ require_relative "../../ast/constant_names"
5
+
6
+ # Defeat Zeitwerk's re-autoload hook when we re-eval a file in place. Walking
7
+ # the source AST for top-level class/module names and calling `const_get` on
8
+ # each tells Zeitwerk "this constant is loaded" so our re-eval does not lose
9
+ # state (e.g. `@_included_block`) to a follow-up autoload.
10
+ class Evilution::Integration::Loading::ConstantPinner
11
+ def initialize(constant_names: Evilution::AST::ConstantNames.new)
12
+ @constant_names = constant_names
13
+ end
14
+
15
+ def call(source)
16
+ names = @constant_names.call(source)
17
+ names.each do |name|
18
+ Object.const_get(name) if Object.const_defined?(name, false)
19
+ rescue NameError # :nodoc:
20
+ nil
21
+ end
22
+ names
23
+ end
24
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../loading"
4
+ require_relative "syntax_validator"
5
+ require_relative "constant_pinner"
6
+ require_relative "concern_state_cleaner"
7
+ require_relative "source_evaluator"
8
+ require_relative "redefinition_recovery"
9
+
10
+ # Composes the load-time pipeline that applies a mutation's new source to the
11
+ # running VM: syntax-validate -> pin top-level constants (beats Zeitwerk) ->
12
+ # clear AS::Concern state -> eval inside a redefinition-recovery wrapper.
13
+ # Returns nil on success or a failure-shaped hash on any error.
14
+ class Evilution::Integration::Loading::MutationApplier
15
+ def initialize(syntax_validator: Evilution::Integration::Loading::SyntaxValidator.new,
16
+ constant_pinner: Evilution::Integration::Loading::ConstantPinner.new,
17
+ concern_state_cleaner: Evilution::Integration::Loading::ConcernStateCleaner.new,
18
+ source_evaluator: Evilution::Integration::Loading::SourceEvaluator.new,
19
+ redefinition_recovery: Evilution::Integration::Loading::RedefinitionRecovery.new)
20
+ @syntax_validator = syntax_validator
21
+ @constant_pinner = constant_pinner
22
+ @concern_state_cleaner = concern_state_cleaner
23
+ @source_evaluator = source_evaluator
24
+ @redefinition_recovery = redefinition_recovery
25
+ end
26
+
27
+ def call(mutation)
28
+ syntax_error = @syntax_validator.call(mutation.mutated_source)
29
+ return syntax_error if syntax_error
30
+
31
+ @constant_pinner.call(mutation.original_source)
32
+ @concern_state_cleaner.call(mutation.file_path)
33
+ @redefinition_recovery.call(mutation.original_source) do
34
+ @source_evaluator.call(mutation.mutated_source, mutation.file_path)
35
+ end
36
+ nil
37
+ rescue SyntaxError => e
38
+ {
39
+ passed: false,
40
+ error: "syntax error in mutated source: #{e.message}",
41
+ error_class: e.class.name,
42
+ error_backtrace: Array(e.backtrace).first(5)
43
+ }
44
+ rescue ScriptError, StandardError => e
45
+ {
46
+ passed: false,
47
+ error: "#{e.class}: #{e.message}",
48
+ error_class: e.class.name,
49
+ error_backtrace: Array(e.backtrace).first(5)
50
+ }
51
+ end
52
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../loading"
4
+ require_relative "../../ast/constant_names"
5
+
6
+ # Some DSLs (Rails 8 enum, define_method guards) raise ArgumentError on
7
+ # re-declaration. On such a conflict we strip constants declared in the source
8
+ # and retry the load once against a fresh namespace.
9
+ class Evilution::Integration::Loading::RedefinitionRecovery
10
+ def initialize(constant_names: Evilution::AST::ConstantNames.new)
11
+ @constant_names = constant_names
12
+ end
13
+
14
+ def call(source, &block)
15
+ block.call
16
+ rescue ArgumentError => e
17
+ raise unless redefinition_conflict?(e)
18
+
19
+ remove_defined_constants(source)
20
+ block.call
21
+ end
22
+
23
+ private
24
+
25
+ def redefinition_conflict?(error)
26
+ error.message.include?("already defined")
27
+ end
28
+
29
+ def remove_defined_constants(source)
30
+ @constant_names.call(source).reverse_each do |name|
31
+ parent_name, _, local_name = name.rpartition("::")
32
+ parent = resolve_loaded_constant_parent(parent_name)
33
+ next unless parent
34
+ next unless parent.const_defined?(local_name, false)
35
+ next if parent.autoload?(local_name)
36
+
37
+ parent.send(:remove_const, local_name.to_sym)
38
+ end
39
+ end
40
+
41
+ def resolve_loaded_constant_parent(parent_name)
42
+ return Object if parent_name.empty?
43
+
44
+ parent_name.split("::").reduce(Object) do |mod, part|
45
+ return nil unless mod.const_defined?(part, false)
46
+ return nil if mod.autoload?(part)
47
+
48
+ resolved = mod.const_get(part, false)
49
+ return nil unless resolved.is_a?(Module)
50
+
51
+ resolved
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../loading"
4
+
5
+ # Evaluate source with __FILE__ set to the absolute original path so that
6
+ # `require_relative` and `__dir__` resolve against the real source tree, where
7
+ # sibling files actually exist.
8
+ class Evilution::Integration::Loading::SourceEvaluator
9
+ def call(source, file_path)
10
+ absolute = File.expand_path(file_path)
11
+ # rubocop:disable Security/Eval
12
+ eval(source, TOPLEVEL_BINDING, absolute, 1)
13
+ # rubocop:enable Security/Eval
14
+ end
15
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../loading"
5
+
6
+ class Evilution::Integration::Loading::SyntaxValidator
7
+ ERROR_MESSAGE = "mutated source has syntax errors"
8
+
9
+ def call(source)
10
+ return nil if Prism.parse(source).success?
11
+
12
+ {
13
+ passed: false,
14
+ error: ERROR_MESSAGE,
15
+ error_class: "SyntaxError",
16
+ error_backtrace: []
17
+ }
18
+ end
19
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../integration"
4
+
5
+ module Evilution::Integration::Loading
6
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../load_path"
4
+
5
+ # Given an absolute (or expandable) file path, returns the shortest path
6
+ # relative to any `$LOAD_PATH` entry the file lives under, or nil if the file
7
+ # is outside every entry. The shortest match wins because a deeper LOAD_PATH
8
+ # entry yields a shorter subpath that better matches `require` resolution.
9
+ class Evilution::LoadPath::SubpathResolver
10
+ def call(file_path)
11
+ absolute = File.expand_path(file_path)
12
+ best_subpath = nil
13
+
14
+ $LOAD_PATH.each do |entry|
15
+ dir = File.expand_path(entry)
16
+ prefix = dir.end_with?("/") ? dir : "#{dir}/"
17
+ next unless absolute.start_with?(prefix)
18
+
19
+ candidate = absolute.delete_prefix(prefix)
20
+ best_subpath = candidate if best_subpath.nil? || candidate.length < best_subpath.length
21
+ end
22
+
23
+ best_subpath
24
+ end
25
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution::LoadPath
4
+ end
@@ -30,10 +30,10 @@ class Evilution::Runner::IsolationResolver
30
30
 
31
31
  def perform_preload
32
32
  return if config.preload == false
33
- return unless resolve_isolation == :fork
34
33
 
35
34
  path = resolve_preload_path
36
35
  return unless path
36
+ return unless should_preload?
37
37
 
38
38
  prepare_load_path_for_preload
39
39
  require File.expand_path(path)
@@ -48,6 +48,17 @@ class Evilution::Runner::IsolationResolver
48
48
 
49
49
  attr_reader :config, :hooks
50
50
 
51
+ # Under :fork, allow preloading — caller resolves whether a path exists (an
52
+ # explicit --preload / preload: value, or an auto-detected rails_helper) and
53
+ # bails early when none does. Under :in_process, only allow preloading when
54
+ # the user explicitly asked via --preload or preload: in YAML — don't
55
+ # auto-load spec/rails_helper.rb for a user who opted out of fork.
56
+ def should_preload?
57
+ return true if resolve_isolation == :fork
58
+
59
+ config.preload.is_a?(String)
60
+ end
61
+
51
62
  def target_files
52
63
  @target_files ||= @target_files_callback.call
53
64
  end
@@ -20,13 +20,7 @@ class Evilution::Runner::SubjectPipeline
20
20
  end
21
21
 
22
22
  def target_files
23
- @target_files ||= if source_glob_target?
24
- resolve_source_glob
25
- elsif !config.target_files.empty?
26
- config.target_files
27
- else
28
- Evilution::Git::ChangedFiles.new.call
29
- end
23
+ @target_files ||= resolve_target_files
30
24
  end
31
25
 
32
26
  private
@@ -88,11 +82,27 @@ class Evilution::Runner::SubjectPipeline
88
82
 
89
83
  def filter_by_target(subjects)
90
84
  matched = subjects.select(&target_matcher)
91
- raise Evilution::Error, "no method found matching '#{config.target}'" if matched.empty?
85
+ raise Evilution::Error, build_no_match_error if matched.empty?
92
86
 
93
87
  matched
94
88
  end
95
89
 
90
+ def resolve_target_files
91
+ return resolve_source_glob if source_glob_target?
92
+ return config.target_files unless config.target_files.empty?
93
+
94
+ @used_git_fallback = true
95
+ Evilution::Git::ChangedFiles.new.call
96
+ end
97
+
98
+ def build_no_match_error
99
+ base = "no subject matched '#{config.target}'"
100
+ return base unless @used_git_fallback
101
+
102
+ "#{base}; scanned git-changed files only. Pass file paths or " \
103
+ "--target source:<glob> to scan the full codebase."
104
+ end
105
+
96
106
  def target_matcher
97
107
  target = config.target
98
108
  if target.end_with?("*")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Evilution
4
- VERSION = "0.25.0"
4
+ VERSION = "0.26.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: evilution
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.25.0
4
+ version: 0.26.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-04-20 00:00:00.000000000 Z
11
+ date: 2026-04-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: diff-lcs
@@ -72,6 +72,7 @@ files:
72
72
  - ".claude/prompts/devops.md"
73
73
  - ".claude/prompts/tests.md"
74
74
  - ".claude/settings.json"
75
+ - ".claude/skills/create-issue/SKILL.md"
75
76
  - CHANGELOG.md
76
77
  - CODE_OF_CONDUCT.md
77
78
  - LICENSE.txt
@@ -88,6 +89,7 @@ files:
88
89
  - exe/evilution
89
90
  - lib/evilution.rb
90
91
  - lib/evilution/ast.rb
92
+ - lib/evilution/ast/constant_names.rb
91
93
  - lib/evilution/ast/inheritance_scanner.rb
92
94
  - lib/evilution/ast/parser.rb
93
95
  - lib/evilution/ast/pattern.rb
@@ -135,6 +137,7 @@ files:
135
137
  - lib/evilution/compare/categorizer.rb
136
138
  - lib/evilution/compare/detector.rb
137
139
  - lib/evilution/compare/fingerprint.rb
140
+ - lib/evilution/compare/invalid_input.rb
138
141
  - lib/evilution/compare/normalizer.rb
139
142
  - lib/evilution/compare/record.rb
140
143
  - lib/evilution/config.rb
@@ -158,12 +161,21 @@ files:
158
161
  - lib/evilution/integration.rb
159
162
  - lib/evilution/integration/base.rb
160
163
  - lib/evilution/integration/crash_detector.rb
164
+ - lib/evilution/integration/loading.rb
165
+ - lib/evilution/integration/loading/concern_state_cleaner.rb
166
+ - lib/evilution/integration/loading/constant_pinner.rb
167
+ - lib/evilution/integration/loading/mutation_applier.rb
168
+ - lib/evilution/integration/loading/redefinition_recovery.rb
169
+ - lib/evilution/integration/loading/source_evaluator.rb
170
+ - lib/evilution/integration/loading/syntax_validator.rb
161
171
  - lib/evilution/integration/minitest.rb
162
172
  - lib/evilution/integration/minitest_crash_detector.rb
163
173
  - lib/evilution/integration/rspec.rb
164
174
  - lib/evilution/isolation.rb
165
175
  - lib/evilution/isolation/fork.rb
166
176
  - lib/evilution/isolation/in_process.rb
177
+ - lib/evilution/load_path.rb
178
+ - lib/evilution/load_path/subpath_resolver.rb
167
179
  - lib/evilution/mcp.rb
168
180
  - lib/evilution/mcp/info_tool.rb
169
181
  - lib/evilution/mcp/mutate_tool.rb
@@ -174,9 +186,6 @@ files:
174
186
  - lib/evilution/mcp/mutate_tool/report_trimmer.rb
175
187
  - lib/evilution/mcp/mutate_tool/survived_enricher.rb
176
188
  - lib/evilution/mcp/server.rb
177
- - lib/evilution/mcp/session_diff_tool.rb
178
- - lib/evilution/mcp/session_list_tool.rb
179
- - lib/evilution/mcp/session_show_tool.rb
180
189
  - lib/evilution/mcp/session_tool.rb
181
190
  - lib/evilution/memory.rb
182
191
  - lib/evilution/memory/leak_check.rb
@@ -1,63 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "mcp"
5
- require_relative "../session/store"
6
- require_relative "../session/diff"
7
-
8
- require_relative "../mcp"
9
-
10
- # @deprecated Superseded by {Evilution::MCP::SessionTool} (action: "diff") as of 0.22.8.
11
- # No longer registered with the MCP server; retained only for direct Ruby callers.
12
- # Will be removed entirely — tracked by EV-h8pw / GH #686.
13
- class Evilution::MCP::SessionDiffTool < MCP::Tool
14
- tool_name "evilution-session-diff"
15
- description "DEPRECATED: use evilution-session with action: 'diff'. " \
16
- "Compare two mutation testing sessions and return the diff. " \
17
- "Shows new regressions, fixed mutations, and persistent survivors."
18
- input_schema(
19
- properties: {
20
- base: {
21
- type: "string",
22
- description: "Path to the base (older) session JSON file"
23
- },
24
- head: {
25
- type: "string",
26
- description: "Path to the head (newer) session JSON file"
27
- }
28
- }
29
- )
30
-
31
- class << self
32
- # rubocop:disable Lint/UnusedMethodArgument
33
- def call(server_context:, base: nil, head: nil)
34
- return error_response("config_error", "base is required") unless base
35
- return error_response("config_error", "head is required") unless head
36
-
37
- store = Evilution::Session::Store.new
38
- base_data = store.load(base)
39
- head_data = store.load(head)
40
-
41
- diff = Evilution::Session::Diff.new
42
- result = diff.call(base_data, head_data)
43
-
44
- ::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(result.to_h) }])
45
- rescue Evilution::Error => e
46
- error_response("not_found", e.message)
47
- rescue ::JSON::ParserError => e
48
- error_response("parse_error", e.message)
49
- rescue SystemCallError => e
50
- error_response("runtime_error", e.message)
51
- end
52
- # rubocop:enable Lint/UnusedMethodArgument
53
-
54
- private
55
-
56
- def error_response(type, message)
57
- ::MCP::Tool::Response.new(
58
- [{ type: "text", text: ::JSON.generate({ error: { type: type, message: message } }) }],
59
- error: true
60
- )
61
- end
62
- end
63
- end
@@ -1,50 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "mcp"
5
- require_relative "../session/store"
6
-
7
- require_relative "../mcp"
8
-
9
- # @deprecated Superseded by {Evilution::MCP::SessionTool} (action: "list") as of 0.22.8.
10
- # No longer registered with the MCP server; retained only for direct Ruby callers.
11
- # Will be removed entirely — tracked by EV-h8pw / GH #686.
12
- class Evilution::MCP::SessionListTool < MCP::Tool
13
- tool_name "evilution-session-list"
14
- description "DEPRECATED: use evilution-session with action: 'list'. " \
15
- "List past mutation testing sessions with summary statistics. " \
16
- "Returns sessions in reverse chronological order."
17
- input_schema(
18
- properties: {
19
- results_dir: {
20
- type: "string",
21
- description: "Session results directory (default: .evilution/results)"
22
- },
23
- limit: {
24
- type: "integer",
25
- description: "Return only the N most recent sessions"
26
- }
27
- }
28
- )
29
-
30
- class << self
31
- # rubocop:disable Lint/UnusedMethodArgument
32
- def call(server_context:, results_dir: nil, limit: nil)
33
- store_opts = {}
34
- store_opts[:results_dir] = results_dir if results_dir
35
- store = Evilution::Session::Store.new(**store_opts)
36
- entries = store.list
37
- entries = entries.first(limit) if limit
38
-
39
- payload = entries.map { |e| stringify_keys(e) }
40
- ::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(payload) }])
41
- end
42
- # rubocop:enable Lint/UnusedMethodArgument
43
-
44
- private
45
-
46
- def stringify_keys(hash)
47
- hash.transform_keys(&:to_s)
48
- end
49
- end
50
- end
@@ -1,57 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "mcp"
5
- require_relative "../session/store"
6
-
7
- require_relative "../mcp"
8
-
9
- # @deprecated Superseded by {Evilution::MCP::SessionTool} (action: "show") as of 0.22.8.
10
- # No longer registered with the MCP server; retained only for direct Ruby callers.
11
- # Will be removed entirely — tracked by EV-h8pw / GH #686.
12
- class Evilution::MCP::SessionShowTool < MCP::Tool
13
- tool_name "evilution-session-show"
14
- description "DEPRECATED: use evilution-session with action: 'show'. " \
15
- "Show full details of a past mutation testing session, " \
16
- "including survived mutations with diffs."
17
- input_schema(
18
- properties: {
19
- path: {
20
- type: "string",
21
- description: "Path to the session JSON file (as returned by evilution-session-list)"
22
- }
23
- }
24
- )
25
-
26
- class << self
27
- # rubocop:disable Lint/UnusedMethodArgument
28
- def call(server_context:, path: nil)
29
- unless path
30
- return ::MCP::Tool::Response.new(
31
- [{ type: "text", text: ::JSON.generate({ error: { type: "config_error", message: "path is required" } }) }],
32
- error: true
33
- )
34
- end
35
-
36
- store = Evilution::Session::Store.new
37
- data = store.load(path)
38
- ::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(data) }])
39
- rescue Evilution::Error => e
40
- ::MCP::Tool::Response.new(
41
- [{ type: "text", text: ::JSON.generate({ error: { type: "not_found", message: e.message } }) }],
42
- error: true
43
- )
44
- rescue ::JSON::ParserError => e
45
- ::MCP::Tool::Response.new(
46
- [{ type: "text", text: ::JSON.generate({ error: { type: "parse_error", message: e.message } }) }],
47
- error: true
48
- )
49
- rescue SystemCallError => e
50
- ::MCP::Tool::Response.new(
51
- [{ type: "text", text: ::JSON.generate({ error: { type: "runtime_error", message: e.message } }) }],
52
- error: true
53
- )
54
- end
55
- # rubocop:enable Lint/UnusedMethodArgument
56
- end
57
- end