active_mutator 0.2.0 → 0.3.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: 5729b9385c24e575708a5227a3838ae28e09dd2ddf3e40e1883e9e74f7399275
4
- data.tar.gz: 69b41b3304c8c008f54f6c20547ff9283d26a278eea706dc7cf923466dde1b5c
3
+ metadata.gz: '08222e2342f45fd4c9150cd9201383231f1add08c5c5582bccf74f4350c87181'
4
+ data.tar.gz: ae88865c407500e7b7fcc0cb9a0ab72fe90aa2860136f9bd523203a1baae9c35
5
5
  SHA512:
6
- metadata.gz: 3e0dce87a35314cb96e68e7051e3dfa8bbc329b58a3c41beef1201c7c5e8a3205d58a28593bf99382dfd1f9a9fd52859b44def8e2ee5d57dd0879e9dfcadec37
7
- data.tar.gz: 5ec63925341ef8356801ee171e83af5b6323feb0f15838b1430421c9e5aaf73017d0e5ebaafb653039917a97144933e3572c2d182ba0544de27fbe60a0d7d6ca
6
+ metadata.gz: c0ed008b82b47bf0e70e74bd51330eafaeb02f6c7241e5a19e2a75395293db1ccf02e3f13f3810abf2da1972f5b50b678e35bae4efb946b139aac22e0faccfd0
7
+ data.tar.gz: dd02830ae6b56cdc687cf90dc5131d1409298ac8e5d03217a57f92a044aca4dee6731043498e1f021d9eee4ef064af8cab80fb048263046dfe28fe719bef0077
data/README.md CHANGED
@@ -263,6 +263,7 @@ survivors show inline on the PR diff. Pairs with the CI recipe:
263
263
  | `--[no-]adaptive-timeout` | on | scale timeout budgets from observed worker wall times (median utilization, grow-only, clamped 1x–4x; `--timeout-factor`/`--timeout-floor` set the starting budget) |
264
264
  | `--require FILE` | none | preload files (repeatable) |
265
265
  | `--operator FILE` | none | load a custom operator file before analysis (repeatable) |
266
+ | `--[no-]class-level` | on | mutate class-level code (macros, constants, DSL/scope lambdas) via class-body subjects |
266
267
  | `--fail-at SCORE` | none (strict) | exit 0 if score >= SCORE even with survivors (opt-in relaxation for gradual adoption; 0 = report-only) |
267
268
 
268
269
  `--debug-plan` prints the planned mutant list as one JSON document
@@ -287,7 +288,10 @@ lists; the first `--serial-pattern` replaces them). Recognized keys:
287
288
  `requires`, `operators` (custom operator files, loaded before analysis; see
288
289
  [Custom operators](docs/guides/custom-operators.md)),
289
290
  `preload_helper` (a path, or `false` to skip preload),
290
- `adaptive_timeout` (`true`/`false`).
291
+ `adaptive_timeout` (`true`/`false`),
292
+ `class_level` (`true`/`false`, default `true` — mutate class-level code),
293
+ `class_level_closure_cap` (integer, default `10` — max constants a
294
+ class-body mutant may reload before it is `skipped`).
291
295
  Unknown keys and wrong types are errors, not silent no-ops.
292
296
 
293
297
  ```yaml
@@ -300,19 +304,66 @@ serial_patterns:
300
304
  fail_at: 90 # legacy suite: gate on score instead of zero-survivors
301
305
  ```
302
306
 
303
- ## Known limits (v1.1)
304
-
305
- Method bodies only (no class-macro/constant mutation). RSpec only.
306
- Plain heredoc bodies ARE mutated (emptied); interpolated heredocs are
307
- skipped. `class << self` bodies are mutated as singleton subjects
308
- (`class << obj` and top-level `class << self` are skipped). Nested defs
309
- mutate as part of the enclosing method's body they get no subject of
310
- their own (a directly-inserted mutant would be reverted whenever the
311
- outer method re-runs the `def`). The incremental baseline recovers the residual blind spot —
312
- constant-reference detection handles the common case since 0.2, and a few
313
- residual cases (pure indirection, partially-covering files, leaf-only or
314
- wrapper-only references, `class ::Foo`, `Data.define`/`Struct.new` value
315
- objects) are caught by nightly `--force-baseline`.
307
+ ## Class-level mutation
308
+
309
+ Class-level code macros (`validates`, `scope`, `has_many`), constants,
310
+ and DSL/scope lambdas IS mutated. Each Zeitwerk-shaped file gets a
311
+ `… (class body)` subject alongside its method subjects, and the same
312
+ operator set runs over its class-level statements. Because re-running a
313
+ macro *accumulates* rather than replaces (calling `validates` twice adds a
314
+ second validator), a class-body mutant can't be inserted with `class_eval`
315
+ the way a `def` mutant is. Instead active_mutator removes the target
316
+ constant and re-evaluates the whole mutated file, reloading anything
317
+ attached to it (includers, subclasses, extenders) in dependency order. See
318
+ [`docs/guides/how-it-works.md`](docs/guides/how-it-works.md) for the full
319
+ closure-reload pipeline.
320
+
321
+ Disable it with `--no-class-level` (or `class_level: false` in the config
322
+ file). A class-body mutant whose closure can't be reloaded faithfully — the
323
+ closure exceeds `class_level_closure_cap` (default `10`), the constant was
324
+ reopened elsewhere, or an attacher is anonymous/native — is reported
325
+ `skipped` (progress char `-`): listed but **not counted in the score**,
326
+ because a mutant we can't insert faithfully must not be called survived or
327
+ killed.
328
+
329
+ ## Known limits
330
+
331
+ Method bodies **and** Zeitwerk-shaped class bodies are mutated; the
332
+ remaining limits are:
333
+
334
+ - **Class-body mutation requires a Zeitwerk-shaped file** — exactly one
335
+ top-level class/module per file. Multi-constant files and core-class
336
+ monkey-patches/reopens are not class-body-mutated (issue #32). Their
337
+ method bodies still are.
338
+ - **Most code inside blocks is not mutated.** `ActiveSupport::Concern` DSL
339
+ blocks (`included`/`prepended`/`class_methods do … end`) ARE mutated — their
340
+ bodies re-run as class-level code in the includer (issue #31). Every other
341
+ block (`has_many :x do … end` and any `do … end`/`{ … }` body) is pruned to
342
+ avoid false survivors from mutating code whose run-time context is unknown.
343
+ - **Constants captured by value go stale.** A reference that holds the
344
+ target *by value* rather than by ancestry — an alias (`ALIAS = SomeClass`),
345
+ a registry the class was pushed into, a memoized instance, a class
346
+ variable captured at load — keeps pointing at the pre-reload object after
347
+ the closure reload. Such stale references can produce false survivors.
348
+ - **Whole-file re-eval re-runs class-body side effects.** The reload
349
+ re-evaluates the target and every attacher's class body, so non-idempotent
350
+ load-time side effects (global self-registration, descendant tracking) run
351
+ twice — which can double or mask a count a spec asserts on.
352
+ - **`refine`-based modules are not discovered or reloaded.** Refinements
353
+ are anonymous and don't appear in normal `ancestors`.
354
+ - **RSpec only.** Test selection, worker setup, and the world-group filter
355
+ are all RSpec-API-shaped.
356
+ - **Method-body scope details:** plain heredoc bodies ARE mutated (emptied);
357
+ interpolated heredocs are skipped. `class << self` bodies are mutated as
358
+ singleton subjects (`class << obj` and top-level `class << self` are
359
+ skipped). Nested defs mutate as part of the enclosing method's body — they
360
+ get no subject of their own (a directly-inserted mutant would be reverted
361
+ whenever the outer method re-runs the `def`).
362
+ - **The incremental baseline's residual blind spot:** constant-reference
363
+ detection handles the common case since 0.2; a few residual cases (pure
364
+ indirection, partially-covering files, leaf-only or wrapper-only
365
+ references, `class ::Foo`, `Data.define`/`Struct.new` value objects) are
366
+ caught by nightly `--force-baseline`.
316
367
 
317
368
  ## Guides
318
369
 
@@ -84,21 +84,13 @@ module ActiveMutator
84
84
  abs = File.join(root, rel)
85
85
  return [] unless File.exist?(abs)
86
86
 
87
- constants = DefinedConstants.in_source(File.read(abs))
88
- return [] if constants.empty?
87
+ pattern = constant_reference_pattern(File.read(abs))
88
+ return [] unless pattern
89
89
 
90
90
  all_specs = spec_contents.keys
91
91
 
92
92
  covering_specs = coverage_map.examples_covering_file(abs)
93
93
  .map { |id| spec_file_of(id) }.to_a.uniq
94
- # Escaping is required: dynamic-namespace class definitions (e.g.
95
- # `class (a)::Baz`, `class foo.bar::Baz`) make constant_path.slice carry
96
- # regex metachars. Unescaped, "(a)::Baz" would match the literal text
97
- # "a::Baz" — a false candidate.
98
- # TODO(#11, Task 10 residual gap): a top-level `class ::Foo` yields the
99
- # slice "::Foo", and /\b::Foo\b/ can never match (no word boundary
100
- # before ":"), so such files are silently unscanned.
101
- pattern = /\b(?:#{constants.map { |c| Regexp.escape(c) }.join("|")})\b/
102
94
  candidates = all_specs.filter_map do |spec_abs|
103
95
  spec_rel = spec_abs.delete_prefix(root).delete_prefix("/")
104
96
  next if covering_specs.include?(spec_rel)
@@ -119,5 +111,24 @@ module ActiveMutator
119
111
  def self.spec_file_of(example_id)
120
112
  example_id.sub(%r{\A\./}, "").sub(/\[.*\]\z/, "")
121
113
  end
114
+
115
+ # Regexp matching any textual reference to a constant DEFINED in `source`,
116
+ # or nil when the source defines none. Shared by newly_covering_candidates
117
+ # and Runner's phase-2 escalation so the escaping/word-boundary rules live
118
+ # in one place.
119
+ #
120
+ # Escaping is required: dynamic-namespace class definitions (e.g.
121
+ # `class (a)::Baz`, `class foo.bar::Baz`) make constant_path.slice carry
122
+ # regex metachars. Unescaped, "(a)::Baz" would match the literal text
123
+ # "a::Baz" — a false candidate.
124
+ # TODO(#11, Task 10 residual gap): a top-level `class ::Foo` yields the
125
+ # slice "::Foo", and /\b::Foo\b/ can never match (no word boundary before
126
+ # ":"), so such files are silently unscanned.
127
+ def self.constant_reference_pattern(source)
128
+ constants = DefinedConstants.in_source(source)
129
+ return nil if constants.empty?
130
+
131
+ /\b(?:#{constants.map { |c| Regexp.escape(c) }.join("|")})\b/
132
+ end
122
133
  end
123
134
  end
@@ -0,0 +1,47 @@
1
+ module ActiveMutator
2
+ # Structural predicates over a parsed file that the class-level machinery
3
+ # must apply IDENTICALLY in more than one place. Centralized so the gates
4
+ # cannot drift:
5
+ # - SubjectFinder decides which files get a class-body subject.
6
+ # - ClosureReload decides which dependent files are safe to remove_const
7
+ # and re-eval.
8
+ # Both need the same "is this file a single reloadable constant?" rule, and
9
+ # both need the same "does this class-body statement belong to another
10
+ # subject?" rule.
11
+ # `extend self`, not `module_function`: module_function copies each method
12
+ # onto the singleton at definition time, so a def-level mutant (which
13
+ # redefines the INSTANCE method in the fork) would never reach the singleton
14
+ # copy that callers invoke — an untestable false survivor. `extend self`
15
+ # keeps ONE method object, dispatched to via the singleton's ancestry.
16
+ module ClassShape
17
+ extend self
18
+
19
+ # Zeitwerk-shaped: the file defines exactly one top-level constant, so
20
+ # remove_const + whole-file re-eval reinstates precisely that constant
21
+ # (issue #32). A file with more than one would re-run macros on / reassign
22
+ # the constants that were NOT removed (accumulation and "already
23
+ # initialized constant" bugs).
24
+ #
25
+ # Counts every top-level constant-DEFINING form, not just `class`/`module`
26
+ # blocks: `Adapter = Class.new`, `Point = Struct.new(...)`,
27
+ # `Config = Data.define(...)`, and plain `CONST = ...` are ConstantWriteNodes
28
+ # (or ConstantPathWriteNodes) that a class/module-only count missed, letting
29
+ # a two-constant file slip through and get reassigned on re-eval.
30
+ def single_top_level_constant?(program)
31
+ program.statements.body.count { |s| defines_constant?(s) } == 1
32
+ end
33
+
34
+ def defines_constant?(node)
35
+ node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode) ||
36
+ node.is_a?(Prism::ConstantWriteNode) || node.is_a?(Prism::ConstantPathWriteNode)
37
+ end
38
+
39
+ # A class-body statement that is owned by a DIFFERENT subject — its own
40
+ # def, or a nested class/module/singleton-class that gets its own subjects.
41
+ # The class-body walk must neither collect edits for it nor delete it.
42
+ def owned_by_other_subject?(node)
43
+ node.is_a?(Prism::DefNode) || node.is_a?(Prism::ClassNode) ||
44
+ node.is_a?(Prism::ModuleNode) || node.is_a?(Prism::SingletonClassNode)
45
+ end
46
+ end
47
+ end
@@ -24,7 +24,7 @@ module ActiveMutator
24
24
  preload_helper: nil, serial_patterns: ["spec/system/", "spec/features/"],
25
25
  browser_boot_seconds: 15.0, accept_survivors: false, exclude: [],
26
26
  max_mutants: nil, debug_plan: false, fail_at: nil, adaptive_timeout: true,
27
- operator_paths: []
27
+ operators: [], class_level: true, class_level_closure_cap: 10
28
28
  }
29
29
  options.merge!(ConfigFile.load(Dir.pwd))
30
30
  paths = OptionParser.new do |o|
@@ -35,7 +35,8 @@ module ActiveMutator
35
35
  o.on("--jobs N", Integer, "Concurrent workers (default: half the CPU count)") { |v| options[:jobs] = v }
36
36
  o.on("--format FMT", ConfigFile::FORMATS, "Output format") { |v| options[:format] = v.tr("-", "_").to_sym }
37
37
  o.on("--require FILE", "File to require before mutating (repeatable; adds to config-file requires)") { |v| options[:requires] << v }
38
- o.on("--operator FILE", "Ruby file defining a custom operator, loaded before analysis (repeatable)") { |v| options[:operator_paths] << v }
38
+ o.on("--operator FILE", "Ruby file defining a custom operator, loaded before analysis (repeatable)") { |v| options[:operators] << v }
39
+ o.on("--[no-]class-level", "Mutate class-level code: macros, constants, DSL lambdas (default: on)") { |v| options[:class_level] = v }
39
40
  o.on("--force-baseline", "Ignore cached coverage map") { options[:force_baseline] = true }
40
41
  o.on("--timeout-factor F", Float, "Timeout = baseline time * F + floor") { |v| options[:timeout_factor] = v }
41
42
  o.on("--timeout-floor S", Float, "Minimum timeout seconds") { |v| options[:timeout_floor] = v }
@@ -0,0 +1,202 @@
1
+ module ActiveMutator
2
+ # Fork-side insertion for class-body mutants. A def mutant can be
3
+ # class_eval'd over the live constant; class-level code cannot (re-running
4
+ # `validates` ADDS a validator, it doesn't replace one). So: remove the
5
+ # constant and re-eval the whole mutated file. Anything already attached to
6
+ # the OLD object — classes that include the module, subclasses, extend
7
+ # sites — would go stale, so they are removed and re-evaled too (pristine
8
+ # sources), dependency-first. The fork dies after the run; nothing is
9
+ # restored.
10
+ #
11
+ # Every guard failure raises Skip; the Worker reports the mutant as
12
+ # `skipped` with the reason. Skipping is honest: a mutant we cannot insert
13
+ # faithfully must not be counted as survived OR killed.
14
+ #
15
+ # Known limitations (inherent to remove-and-reload; accepted trade-offs):
16
+ # - References that hold the target BY VALUE rather than by ancestry are
17
+ # not discovered and keep pointing at the pre-remove_const object:
18
+ # aliases (`ALIAS = MyClass`), arrays/registries the target was pushed
19
+ # into, memoized instances, class variables captured at load time.
20
+ # - `refine`-based modules are anonymous and do not appear in normal
21
+ # `ancestors`, so refinements of the target are not discovered/reloaded.
22
+ # - Re-evaling the target AND each attacher re-runs their class-body side
23
+ # effects. Non-idempotent load-time effects (self-registration into a
24
+ # global registry, DescendantsTracker-style hooks) therefore run twice; a
25
+ # spec asserting such a count can see it doubled (false kill) or masked
26
+ # (false survival).
27
+ # - The re-eval order pins the target first (every attacher depends on it)
28
+ # then sorts the rest by instance-ancestor depth. An `extend`
29
+ # relationship BETWEEN two non-target attachers can still re-eval out of
30
+ # order (rare); a full topological sort is deliberately not attempted.
31
+ class ClosureReload
32
+ Skip = Class.new(StandardError)
33
+
34
+ # The MUTATED target source could not be re-evaled — the mutation broke the
35
+ # class so it no longer loads. Every covering spec loads the class, so the
36
+ # suite would fail: the Worker maps this to a kill, not an error.
37
+ MutantLoadError = Class.new(StandardError)
38
+
39
+ DEFAULT_CAP = 10
40
+
41
+ class << self
42
+ # Assigned by Runner from config before scheduling; forks inherit it.
43
+ attr_writer :cap
44
+
45
+ def cap = @cap || DEFAULT_CAP
46
+ end
47
+
48
+ def initialize(subject, mutated_source)
49
+ @subject = subject
50
+ @mutated_source = mutated_source
51
+ end
52
+
53
+ def call(cap: self.class.cap)
54
+ target = resolve_target
55
+ closure = compute_closure(target)
56
+ if closure.size > cap
57
+ raise Skip, "reload closure (#{closure.size} constants) exceeds cap (#{cap})"
58
+ end
59
+
60
+ # Re-eval must load dependencies before dependents. Every closure member
61
+ # carries the target directly (single-pass discovery invariant), so the
62
+ # target must be re-eval'd first — pin it to the front. `ancestors.size`
63
+ # can't do this alone: an `extend` puts the target in the extender's
64
+ # SINGLETON ancestry, so a module that extends the target has an instance
65
+ # ancestry SMALLER than the target's and would sort before it (NameError
66
+ # on re-eval). For the remaining attachers, ascending instance-ancestor
67
+ # depth is a valid order among include/subclass relationships (a
68
+ # superclass before its subclass, an included module before its
69
+ # includer); depth is captured while the constants are still live, since
70
+ # it can't be read after remove_const.
71
+ rest = closure.reject { |m| m.equal?(target) }
72
+ ordered = [target, *rest.sort_by { |m| m.ancestors.size }]
73
+ sources = ordered.map { |mod| [mod.name, source_for(mod)] }
74
+ sources.each { |name, _| remove_constant(name) }
75
+ sources.each_with_index do |(_, (file, src)), idx|
76
+ eval(src, TOPLEVEL_BINDING, file, 1) # rubocop:disable Security/Eval
77
+ rescue ScriptError, StandardError => e
78
+ # idx.zero? is the MUTATED target, which by construction depends on
79
+ # nothing else in the closure (every other member carries the target,
80
+ # not vice versa). Its source failing to load is therefore the
81
+ # mutation's own doing — a kill, not a tool error.
82
+ #
83
+ # A PRISTINE dependent (idx > 0) failing means the ancestry-depth order
84
+ # couldn't satisfy a cross-attacher reference (see the ordering note
85
+ # above): we can't faithfully reinstate the closure, so this is an
86
+ # honest Skip, not a survived/killed verdict and not a bare error.
87
+ raise MutantLoadError, e.message if idx.zero?
88
+
89
+ raise Skip, "reload re-eval failed (#{e.message}); closure could not be reinstated in dependency order"
90
+ end
91
+ nil
92
+ end
93
+
94
+ private
95
+
96
+ def resolve_target
97
+ scope = @subject.constant_scope
98
+ target = begin
99
+ Object.const_get(scope)
100
+ rescue NameError
101
+ raise Skip, "constant #{scope} not loaded"
102
+ end
103
+ file, = Object.const_source_location(scope)
104
+ unless file && File.identical?(file, @subject.file)
105
+ raise Skip, "#{scope} defined at #{file || "?"}, not #{@subject.file} (reopened constant)"
106
+ end
107
+ target
108
+ end
109
+
110
+ # Single discovery pass. Ruby's `ancestors` is transitive, so one scan for
111
+ # everything carrying the target already yields every transitive attacher:
112
+ # an attacher-of-an-attacher (a subclass of an includer, an includer of an
113
+ # includer) carries the target directly too. No BFS/dedup is needed — the
114
+ # re-eval order is imposed later by pinning the target first and sorting the
115
+ # rest by ancestry depth (see #call), not by discovery order and not by a
116
+ # full topological sort (deliberately not attempted; see class docstring).
117
+ def compute_closure(target)
118
+ [target, *attachers(target)]
119
+ end
120
+
121
+ # Everything stale after removing `mod`: includers and subclasses carry
122
+ # it in `ancestors`; extend-sites carry it in their singleton class's
123
+ # ancestors, so singleton classes map back through attached_object. `.uniq`
124
+ # collapses a member found both ways (a class that both includes and
125
+ # extends the target).
126
+ def attachers(mod)
127
+ ObjectSpace.each_object(Module).filter_map do |m|
128
+ next if m.equal?(mod)
129
+ next unless carries?(m, mod)
130
+
131
+ if m.singleton_class?
132
+ m = m.attached_object
133
+ unless m.is_a?(Module)
134
+ raise Skip, "an object instance is extended with #{mod.name || mod.inspect}; not reloadable"
135
+ end
136
+ end
137
+
138
+ m
139
+ end.uniq
140
+ end
141
+
142
+ # ObjectSpace hands back every Module in the VM, including ones we cannot
143
+ # introspect — e.g. a module whose #ancestors is overridden to raise. A
144
+ # module we cannot read is not a reloadable dependency of the target, so
145
+ # treat it as unrelated rather than aborting the whole scan. Trade-off: a
146
+ # genuine attacher whose #ancestors raises would be silently dropped
147
+ # (acceptable — pathological).
148
+ #
149
+ # The rescue is deliberately broader than StandardError: #ancestors on a
150
+ # foreign object can raise Exception-level errors that are NOT StandardError
151
+ # — the canonical case is an expired RSpec verifying double
152
+ # (ExpiredTestDoubleError < MockExpectationError < Exception) left in
153
+ # ObjectSpace by another spec. Control-flow errors (a signal such as an
154
+ # interrupt, or an explicit exit) are re-raised so a run stays
155
+ # interruptible; everything else means "not a dependency, skip it".
156
+ def carries?(mod, target)
157
+ mod.ancestors.include?(target)
158
+ rescue Exception => e # rubocop:disable Lint/RescueException
159
+ raise if e.is_a?(SignalException) || e.is_a?(SystemExit)
160
+
161
+ false
162
+ end
163
+
164
+ def source_for(mod)
165
+ name = mod.name
166
+ raise Skip, "anonymous #{mod.is_a?(Class) ? "class" : "module"} in reload closure" unless name
167
+
168
+ return [@subject.file, @mutated_source] if name == @subject.constant_scope
169
+
170
+ file, = Object.const_source_location(name)
171
+ raise Skip, "#{name}: no source file (native or dynamically defined)" unless file && File.exist?(file)
172
+
173
+ src = File.read(file)
174
+ unless single_constant_file?(src)
175
+ raise Skip, "#{name}: #{file} defines multiple top-level constants; not reloadable"
176
+ end
177
+
178
+ [file, src]
179
+ end
180
+
181
+ # Same Zeitwerk-shape rule the SubjectFinder gate applies to the target
182
+ # file: re-evaling a multi-constant file would re-run macros on constants
183
+ # that were NOT removed (accumulation bugs). Shared with SubjectFinder.
184
+ def single_constant_file?(source)
185
+ result = Prism.parse(source)
186
+ return false unless result.success?
187
+
188
+ ClassShape.single_top_level_constant?(result.value)
189
+ end
190
+
191
+ def remove_constant(name)
192
+ parts = name.split("::")
193
+ leaf = parts.pop
194
+ parent = parts.empty? ? Object : Object.const_get(parts.join("::"))
195
+ parent.send(:remove_const, leaf)
196
+ rescue NameError
197
+ # Either a parent namespace earlier in the closure was already removed
198
+ # (taking this nested constant with it), or the leaf is already gone.
199
+ # Nothing to remove — the re-eval pass reinstates it from its own file.
200
+ end
201
+ end
202
+ end
@@ -5,5 +5,6 @@ module ActiveMutator
5
5
  :timeout_factor, :timeout_floor, :force_baseline, :root,
6
6
  :preload_helper, :serial_patterns, :browser_boot_seconds,
7
7
  :accept_survivors, :exclude, :max_mutants, :debug_plan,
8
- :fail_at, :adaptive_timeout, :operator_paths)
8
+ :fail_at, :adaptive_timeout, :operators,
9
+ :class_level, :class_level_closure_cap)
9
10
  end
@@ -22,12 +22,11 @@ module ActiveMutator
22
22
  "requires" => :string_list,
23
23
  "operators" => :string_list,
24
24
  "preload_helper" => :preload_helper,
25
- "adaptive_timeout" => :boolean
25
+ "adaptive_timeout" => :boolean,
26
+ "class_level" => :boolean,
27
+ "class_level_closure_cap" => :positive_integer
26
28
  }.freeze
27
29
 
28
- # YAML keys that don't match their Config member name.
29
- RENAMES = { "operators" => :operator_paths }.freeze
30
-
31
30
  def self.load(root)
32
31
  path = File.join(root, FILENAME)
33
32
  return {} unless File.exist?(path)
@@ -40,7 +39,7 @@ module ActiveMutator
40
39
  validator = KEYS[key]
41
40
  raise Error, "#{FILENAME}: unknown config key: #{key}" unless validator
42
41
 
43
- [RENAMES.fetch(key, key.to_sym), coerce(key, validator, value)]
42
+ [key.to_sym, coerce(key, validator, value)]
44
43
  end
45
44
  end
46
45
 
@@ -55,6 +54,10 @@ module ActiveMutator
55
54
  when :integer
56
55
  raise Error, "#{FILENAME}: #{key} must be an integer" unless value.is_a?(Integer)
57
56
  value
57
+ when :positive_integer
58
+ raise Error, "#{FILENAME}: #{key} must be an integer" unless value.is_a?(Integer)
59
+ raise Error, "#{FILENAME}: #{key} must be >= 1" unless value >= 1
60
+ value
58
61
  when :number
59
62
  raise Error, "#{FILENAME}: #{key} must be a number" unless value.is_a?(Numeric)
60
63
  value.to_f
@@ -8,7 +8,9 @@ module ActiveMutator
8
8
  result = Prism.parse(source)
9
9
  raise Error, "#{subject.file} no longer parses" unless result.success?
10
10
 
11
- def_node = find_def(result.value, subject.byte_range.begin)
11
+ return analyze_class_body(subject, source, result) if subject.class_body?
12
+
13
+ def_node = find_node(result.value, subject.byte_range.begin, Prism::DefNode)
12
14
  raise Error, "subject not found: #{subject.name}" unless def_node
13
15
 
14
16
  invalid = 0
@@ -22,16 +24,115 @@ module ActiveMutator
22
24
 
23
25
  private
24
26
 
25
- def find_def(node, start_offset)
26
- return node if node.is_a?(Prism::DefNode) && node.location.start_offset == start_offset
27
+ def analyze_class_body(subject, source, result)
28
+ class_node = find_node(result.value, subject.byte_range.begin, Prism::ClassNode, Prism::ModuleNode)
29
+ raise Error, "subject not found: #{subject.name}" unless class_node
30
+
31
+ invalid = 0
32
+ mutations = collect_class_body_edits(class_node).filter_map do |edit|
33
+ mutation, valid = build_class_body_mutation(subject, source, edit)
34
+ invalid += 1 unless valid
35
+ mutation
36
+ end
37
+ Analysis.new(mutations: mutations, invalid_count: invalid)
38
+ end
39
+
40
+ # Depth-first search for the node of one of `types` whose byte range starts
41
+ # at `start_offset`. Def-level and class-body subject location share this;
42
+ # only the node type(s) differ (DefNode vs Class/ModuleNode).
43
+ def find_node(node, start_offset, *types)
44
+ return node if types.any? { |t| node.is_a?(t) } && node.location.start_offset == start_offset
27
45
 
28
46
  node.compact_child_nodes.each do |child|
29
- found = find_def(child, start_offset)
47
+ found = find_node(child, start_offset, *types)
30
48
  return found if found
31
49
  end
32
50
  nil
33
51
  end
34
52
 
53
+ # Class-level code only: defs, nested class/modules and `class << self`
54
+ # bodies are owned by other subjects. Lambdas (scope bodies, if: procs) ARE
55
+ # descended, as are the ActiveSupport::Concern DSL blocks (`included`,
56
+ # `prepended`, `class_methods`) whose bodies re-run as class-level code in
57
+ # the includer (issue #31). Every OTHER block (association extensions,
58
+ # custom DSLs that run in an unknown context) stays pruned — mutating those
59
+ # risks false survivors. Edits that would delete a whole owned statement
60
+ # (StatementDeletion sees the enclosing StatementsNode) are discarded.
61
+ # Owned ranges are collected recursively while walking, not just from the
62
+ # class body's direct children: a def can nest inside class-level control
63
+ # flow (`if`/`unless`/`begin`), and deleting it there is equally out of
64
+ # scope.
65
+ def collect_class_body_edits(class_node)
66
+ owned = []
67
+ edits = []
68
+ class_walk(class_node.body, owned) do |node|
69
+ @operators.each do |op|
70
+ edits.concat(op.edits(node))
71
+ rescue StandardError => e
72
+ raise Error, "operator #{op.class.name} failed on #{node.class.name}: #{e.message}"
73
+ end
74
+ end
75
+ edits.reject { |e| owned.include?(e.range) }
76
+ end
77
+
78
+ def owned_statement?(node) = ClassShape.owned_by_other_subject?(node)
79
+
80
+ # ActiveSupport::Concern DSL calls whose block body re-runs as class-level
81
+ # code in the includer, so it is in scope for class-body mutation.
82
+ CONCERN_BLOCK_CALLS = %i[included prepended class_methods].freeze
83
+
84
+ def concern_dsl_block?(node)
85
+ node.is_a?(Prism::CallNode) && node.receiver.nil? &&
86
+ CONCERN_BLOCK_CALLS.include?(node.name) && node.block.is_a?(Prism::BlockNode)
87
+ end
88
+
89
+ # No nil guard needed (unlike #walk): the entry node is the class body's
90
+ # StatementsNode, guaranteed present for a class-body subject, and
91
+ # compact_child_nodes never yields nil.
92
+ def class_walk(node, owned, &blk)
93
+ if owned_statement?(node)
94
+ owned << (node.location.start_offset...node.location.end_offset)
95
+ elsif node.is_a?(Prism::BlockNode)
96
+ # Pruned: a block's run-time context is unknown (see collect comment).
97
+ elsif concern_dsl_block?(node)
98
+ # Inside a concern block the statements have no subject of their own, so
99
+ # mutate everything (including nested def bodies) exactly like the
100
+ # def-level #walk — do NOT recurse via class_walk (it would prune the
101
+ # block) and do NOT mark the interior defs owned. The concern call node
102
+ # itself is not yielded: the whole-block deletion edit already comes
103
+ # from the enclosing StatementsNode, and no operator targets a bare
104
+ # receiverless call.
105
+ walk(node.block.body, &blk)
106
+ else
107
+ yield node
108
+ node.compact_child_nodes.each { |child| class_walk(child, owned, &blk) }
109
+ end
110
+ end
111
+
112
+ # The mutant is the whole file. The def-shaped fields are filled with the
113
+ # file source so the Mutation shape stays uniform; Worker routes
114
+ # class-body mutants through ClosureReload (whole-file re-eval), never
115
+ # through Inserter's class_eval.
116
+ def build_class_body_mutation(subject, source, edit)
117
+ original = source.byteslice(edit.range)
118
+ return [nil, true] if edit.replacement == original # no-op guard
119
+
120
+ mutated = Splicer.apply(source, [edit])
121
+ parsed = Prism.parse(mutated)
122
+ return [nil, false] unless parsed.success?
123
+ return [nil, false] unless find_node(parsed.value, subject.byte_range.begin, Prism::ClassNode, Prism::ModuleNode)
124
+
125
+ [Mutation.new(
126
+ subject: subject,
127
+ edit: edit,
128
+ original_snippet: original,
129
+ line: source.byteslice(0, edit.range.begin).count("\n") + 1,
130
+ mutated_file_source: mutated,
131
+ mutated_def_source: mutated,
132
+ mutated_def_line: 1
133
+ ), true]
134
+ end
135
+
35
136
  def collect_edits(def_node)
36
137
  edits = []
37
138
  walk(def_node.body) do |node|
@@ -71,7 +172,7 @@ module ActiveMutator
71
172
  parsed = Prism.parse(mutated)
72
173
  return [nil, false] unless parsed.success?
73
174
 
74
- new_def = find_def(parsed.value, subject.byte_range.begin)
175
+ new_def = find_node(parsed.value, subject.byte_range.begin, Prism::DefNode)
75
176
  return [nil, false] unless new_def
76
177
 
77
178
  [Mutation.new(
@@ -13,7 +13,8 @@ module ActiveMutator
13
13
  class StrykerJson
14
14
  SCHEMA_URL = "https://git.io/mutation-testing-schema"
15
15
  STATUS = { killed: "Killed", survived: "Survived", timeout: "Timeout",
16
- error: "RuntimeError", uncovered: "NoCoverage", accepted: "Ignored" }.freeze
16
+ error: "RuntimeError", uncovered: "NoCoverage", accepted: "Ignored",
17
+ skipped: "Ignored" }.freeze
17
18
  ACCEPTED_REASON = "Accepted as equivalent in #{AcceptedLedger::FILENAME}".freeze
18
19
  REPORT_PATH = File.join(".active_mutator", "mutation-report.json")
19
20
 
@@ -87,7 +88,17 @@ module ActiveMutator
87
88
  def covered_by(result)
88
89
  return nil unless @coverage_map
89
90
 
90
- @coverage_map.examples_for(result.mutation.subject.file, result.mutation.lines)
91
+ subject = result.mutation.subject
92
+ # Class-body lines execute at load time, so per-line coverage never
93
+ # attributes examples to them (see Runner#examples_for_mutation). Mirror
94
+ # the scheduling substitution — every example that loaded the file — so
95
+ # the viewer shows real test linkage instead of an empty coveredBy.
96
+ if subject.class_body?
97
+ examples = @coverage_map.examples_covering_file(subject.file)
98
+ return examples.empty? ? nil : examples.sort
99
+ end
100
+
101
+ @coverage_map.examples_for(subject.file, result.mutation.lines)
91
102
  end
92
103
 
93
104
  def referenced_examples(results)
@@ -1,7 +1,8 @@
1
1
  module ActiveMutator
2
2
  module Reporter
3
3
  class Terminal
4
- CHARS = { killed: ".", survived: "S", timeout: "T", error: "E", uncovered: "U", accepted: "A" }.freeze
4
+ CHARS = { killed: ".", survived: "S", timeout: "T", error: "E", uncovered: "U", accepted: "A",
5
+ skipped: "-" }.freeze
5
6
 
6
7
  def initialize(out: $stdout)
7
8
  @out = out
@@ -21,6 +22,8 @@ module ActiveMutator
21
22
  @out.puts format("Mutation score: %.1f%%", score(counts) * 100)
22
23
  survivors = results.select { |r| r.status == :survived }
23
24
  print_survivors(survivors) unless survivors.empty?
25
+ skipped = results.select { |r| r.status == :skipped }
26
+ print_skipped(skipped) unless skipped.empty?
24
27
  stats = OperatorStats.call(results)
25
28
  noisy = stats.select { |_, s| s["survived"].positive? }
26
29
  print_operator_stats(noisy) unless noisy.empty?
@@ -54,6 +57,15 @@ module ActiveMutator
54
57
  @out.puts " #{m.description}"
55
58
  @out.puts " - #{m.original_snippet}"
56
59
  @out.puts " + #{m.edit.replacement}"
60
+ @out.puts " (#{result.details})" if result.details
61
+ end
62
+ end
63
+
64
+ def print_skipped(skipped)
65
+ @out.puts "", "Skipped mutants (not counted in the score):"
66
+ skipped.each do |result|
67
+ m = result.mutation
68
+ @out.puts " #{m.subject.name} (#{m.subject.file}:#{m.line}): #{result.details}"
57
69
  end
58
70
  end
59
71
  end
@@ -1,5 +1,5 @@
1
1
  module ActiveMutator
2
- # status: :killed | :survived | :timeout | :error | :uncovered | :accepted
2
+ # status: :killed | :survived | :timeout | :error | :uncovered | :accepted | :skipped
3
3
  Result = Data.define(:mutation, :status, :details) do
4
4
  def detected? = %i[killed timeout].include?(status)
5
5
  end
@@ -10,6 +10,7 @@ module ActiveMutator
10
10
  def call
11
11
  ENV["ACTIVE_MUTATOR"] = "1"
12
12
  load_operators
13
+ ClosureReload.cap = @config.class_level_closure_cap
13
14
  preload!
14
15
  preload_spec_helper!
15
16
  map = Baseline.new(root: @config.root).coverage_map(force: @config.force_baseline)
@@ -25,7 +26,7 @@ module ActiveMutator
25
26
  scanned_files = prune_scope(subjects)
26
27
  warn_stale(ledger, fingerprints.values, scanned_files)
27
28
 
28
- items, pre_results = plan_work(mutations, map, ledger: ledger, fingerprints: fingerprints)
29
+ items, pre_results, phase1_ids = plan_work(mutations, map, ledger: ledger, fingerprints: fingerprints)
29
30
  return debug_plan(items, pre_results) if @config.debug_plan
30
31
 
31
32
  pre_results.each { |r| @reporter.on_result(r) }
@@ -35,6 +36,8 @@ module ActiveMutator
35
36
  scheduler = Scheduler.new(jobs: @config.jobs, on_result: @reporter.method(:on_result),
36
37
  calibrators: calibrators)
37
38
  results = scheduler.run(items) + pre_results
39
+ # Phase 2 runs on its own scheduler (built lazily inside), so pass nil.
40
+ results = escalate_class_body_survivors(results, nil, map, phase1_ids: phase1_ids)
38
41
 
39
42
  accept_survivors!(ledger, results, fingerprints, scanned_files) if @config.accept_survivors
40
43
 
@@ -42,7 +45,9 @@ module ActiveMutator
42
45
  exit_code(results)
43
46
  end
44
47
 
45
- # Returns [work_items, pre_results]. Public for unit testing.
48
+ # Returns [work_items, pre_results, phase1_ids]. phase1_ids maps each
49
+ # planned mutation to the example ids it was scheduled against, so phase 2
50
+ # escalation can subtract what was already run. Public for unit testing.
46
51
  def plan_work(mutations, map, ledger: nil, fingerprints: {})
47
52
  items = []
48
53
  pre_results = []
@@ -51,19 +56,75 @@ module ActiveMutator
51
56
  pre_results << Result.new(mutation: mutation, status: :accepted, details: nil)
52
57
  next
53
58
  end
54
- example_ids = map.examples_for(mutation.subject.file, coverage_lines(mutation))
59
+ example_ids = examples_for_mutation(mutation, map)
55
60
  if example_ids.empty?
56
61
  pre_results << Result.new(mutation: mutation, status: :uncovered, details: nil)
57
62
  else
58
- lane = example_ids.any? { |id| serial_example?(id) } ? :serial : :parallel
59
- variable = map.time_for(example_ids) * @config.timeout_factor
60
- boot_extra = lane == :serial ? @config.browser_boot_seconds : 0.0
61
- timeout = variable + @config.timeout_floor + boot_extra
62
- items << WorkItem.new(mutation: mutation, example_ids: example_ids,
63
- timeout: timeout, lane: lane, variable: variable)
63
+ items << build_work_item(mutation, example_ids, map)
64
+ end
65
+ end
66
+ phase1_ids = items.to_h { |i| [i.mutation, i.example_ids] }
67
+ [items, pre_results, phase1_ids]
68
+ end
69
+
70
+ # Phase 2 of the class-body kill pipeline (public for unit testing).
71
+ # A class-body survivor is only DECLARED after every spec file that
72
+ # references the constant has had its shot: re-enqueue against the
73
+ # referencing files phase 1 didn't run, and take the escalated verdict.
74
+ #
75
+ # `scheduler` is injectable for unit tests; in the normal run it is nil and
76
+ # a dedicated escalation scheduler is built lazily (only when there is
77
+ # phase-2 work) with NO on_result — escalation is a refinement pass, and
78
+ # reporting through the live callback would print a second status char for a
79
+ # mutant already streamed in phase 1. The final summary reflects the
80
+ # escalated verdicts regardless.
81
+ def escalate_class_body_survivors(results, scheduler, map, phase1_ids:)
82
+ candidates = results.select { |r| r.status == :survived && r.mutation.subject.class_body? }
83
+ # Perf gate: skip reading the whole spec suite into memory in the common
84
+ # case of no class-body survivors. (Deleting this line is a behavioral
85
+ # no-op — the later `items.empty?` return still guards correctness — so
86
+ # its mutant is a known equivalent.)
87
+ return results if candidates.empty?
88
+
89
+ spec_contents = Dir[File.join(@config.root, "spec/**/*_spec.rb")].to_h { |f| [f, File.read(f)] }
90
+ patterns = {} # subject file => constant-reference pattern (parsed once per file)
91
+ items = {}
92
+ candidates.each do |r|
93
+ file = r.mutation.subject.file
94
+ pattern = patterns.fetch(file) do
95
+ patterns[file] = BaselineDelta.constant_reference_pattern(File.read(file))
96
+ end
97
+ next unless pattern
98
+
99
+ ids = escalation_examples(map, spec_contents, phase1_ids.fetch(r.mutation, []), pattern)
100
+ next if ids.empty?
101
+
102
+ items[r.mutation] = build_work_item(r.mutation, ids, map)
103
+ end
104
+ return results if items.empty?
105
+
106
+ scheduler ||= Scheduler.new(jobs: @config.jobs)
107
+ escalated = scheduler.run(items.values).to_h { |res| [res.mutation, res] }
108
+ results.map do |r|
109
+ # A replacement only ever exists for a survived candidate (items is
110
+ # built solely from those), so no redundant status re-check is needed.
111
+ replacement = escalated[r.mutation]
112
+ next r unless replacement
113
+
114
+ case replacement.status
115
+ when :killed
116
+ replacement
117
+ when :survived
118
+ extra = items[r.mutation].example_ids.map { |id| BaselineDelta.spec_file_of(id) }.uniq.size
119
+ replacement.with(details: "escalated (+#{extra} spec files)")
120
+ else
121
+ # A timeout/error/skip in phase 2 did NOT prove a kill — the mutant
122
+ # already survived phase 1, so keep that verdict rather than letting
123
+ # an inconclusive escalation inflate the score (a :timeout counts as
124
+ # detected in exit_code/score).
125
+ r
64
126
  end
65
127
  end
66
- [items, pre_results]
67
128
  end
68
129
 
69
130
  def exit_code(results)
@@ -78,12 +139,45 @@ module ActiveMutator
78
139
 
79
140
  private
80
141
 
142
+ # Single source of truth for lane/timeout/variable derivation, shared by
143
+ # phase-1 planning and phase-2 escalation so the two never drift.
144
+ def build_work_item(mutation, example_ids, map)
145
+ lane = example_ids.any? { |id| serial_example?(id) } ? :serial : :parallel
146
+ variable = map.time_for(example_ids) * @config.timeout_factor
147
+ boot_extra = lane == :serial ? @config.browser_boot_seconds : 0.0
148
+ timeout = variable + @config.timeout_floor + boot_extra
149
+ WorkItem.new(mutation: mutation, example_ids: example_ids,
150
+ timeout: timeout, lane: lane, variable: variable)
151
+ end
152
+
153
+ # Spec files that textually match `pattern` (a constant-reference pattern
154
+ # for the subject's file, built via BaselineDelta.constant_reference_pattern
155
+ # so the escaping/word-boundary rules stay shared), minus everything phase 1
156
+ # already ran; returned as example ids.
157
+ #
158
+ # Two deliberate choices: (a) matching is TEXTUAL, so a constant named in a
159
+ # comment or string still counts — intentional, since the worst case is a
160
+ # wasted run and the verdict stays correct; (b) unlike
161
+ # BaselineDelta.newly_covering_candidates there is intentionally NO fan-out
162
+ # ceiling here — a class-body survivor gets every referencing spec its shot
163
+ # before being declared.
164
+ def escalation_examples(map, spec_contents, phase1_example_ids, pattern)
165
+ phase1_files = phase1_example_ids.map { |id| BaselineDelta.spec_file_of(id) }.uniq
166
+ spec_contents.filter_map do |abs, content|
167
+ rel = abs.delete_prefix(@config.root.chomp("/") + "/")
168
+ next if phase1_files.include?(rel)
169
+ next unless content.match?(pattern)
170
+
171
+ map.examples_for_spec_file(rel)
172
+ end.flatten.uniq.sort
173
+ end
174
+
81
175
  # Custom operators must exist in the PARENT before Engine analysis:
82
176
  # subclassing Operators::Base self-registers, and forks inherit the
83
177
  # loaded class. `requires` can't serve — those load inside the fork's
84
178
  # setup, after mutations are already planned.
85
179
  def load_operators
86
- @config.operator_paths.each do |f|
180
+ @config.operators.each do |f|
87
181
  require File.expand_path(f, @config.root)
88
182
  rescue LoadError, SyntaxError => e
89
183
  raise Error, "operator file not loadable: #{f}: #{e.message}"
@@ -98,6 +192,24 @@ module ActiveMutator
98
192
  mutation.lines.to_a | mutation.subject.line_range.to_a
99
193
  end
100
194
 
195
+ # Class-body lines execute at load time, so line coverage never
196
+ # attributes examples to them. Substitute: every example that covers ANY
197
+ # line of the file (it must have loaded the class), plus the convention
198
+ # spec file's examples. Phase 2 (escalation) widens further before a
199
+ # survivor is declared.
200
+ def examples_for_mutation(mutation, map)
201
+ return map.examples_for(mutation.subject.file, coverage_lines(mutation)) unless mutation.subject.class_body?
202
+
203
+ (map.examples_covering_file(mutation.subject.file) |
204
+ map.examples_for_spec_file(convention_spec_rel(mutation.subject.file))).sort
205
+ end
206
+
207
+ def convention_spec_rel(file)
208
+ rel = file.delete_prefix(@config.root.chomp("/") + "/").delete_suffix(".rb")
209
+ rest = rel.sub(%r{\A[^/]+/}, "")
210
+ "spec/#{rest}_spec.rb"
211
+ end
212
+
101
213
  def build_reporter
102
214
  case @config.format
103
215
  when :json then Reporter::Json.new
@@ -126,6 +238,7 @@ module ActiveMutator
126
238
  .uniq
127
239
  .reject { |file| excluded?(file) }
128
240
  .sort.flat_map { |file| SubjectFinder.call(file) }
241
+ subjects = subjects.reject(&:class_body?) unless @config.class_level
129
242
  if @config.subject_filter
130
243
  matcher = SubjectMatcher.new(@config.subject_filter)
131
244
  subjects = subjects.select { |s| matcher.match?(s.name) }
@@ -210,8 +323,11 @@ module ActiveMutator
210
323
  # MAINTENANCE: any future flag that narrows the mutant set below "every
211
324
  # subject in the scanned files" MUST be added to this nil-trigger list,
212
325
  # or scoped accept runs will clobber out-of-scope ledger entries (#24).
326
+ # --no-class-level drops every class_body subject (discover_subjects), so a
327
+ # file's class-body fingerprint is absent even though the file is scanned;
328
+ # without this guard its accepted ledger entry looks stale and gets pruned.
213
329
  def prune_scope(subjects)
214
- return nil if @config.subject_filter || @config.since || @config.max_mutants
330
+ return nil if @config.subject_filter || @config.since || @config.max_mutants || !@config.class_level
215
331
 
216
332
  subjects.map { |s| s.file.delete_prefix("#{@config.root}/") }.uniq
217
333
  end
@@ -1,6 +1,8 @@
1
1
  module ActiveMutator
2
- # A mutable unit: one method definition.
3
- # byte_range/line_range cover the whole `def ... end`.
2
+ # A mutable unit. kind :instance/:singleton = one method definition
3
+ # (byte_range/line_range cover the whole `def ... end`). kind :class_body =
4
+ # the class-level code of one class/module (byte_range covers the whole
5
+ # class/module node; Engine only mutates non-def body statements).
4
6
  # sclass: def lives inside `class << self` — its source slice is `def foo`,
5
7
  # so Inserter must target the singleton class, not the constant itself.
6
8
  Subject = Data.define(:name, :file, :byte_range, :line_range, :constant_scope, :kind, :sclass) do
@@ -9,5 +11,7 @@ module ActiveMutator
9
11
  end
10
12
 
11
13
  def singleton? = kind == :singleton
14
+
15
+ def class_body? = kind == :class_body
12
16
  end
13
17
  end
@@ -11,16 +11,25 @@ module ActiveMutator
11
11
  skip_lines = result.comments
12
12
  .select { |c| c.slice.match?(SKIP_MARKER) }
13
13
  .to_set { |c| c.location.start_line }
14
- finder = new(file, skip_lines: skip_lines)
14
+ finder = new(file, skip_lines: skip_lines,
15
+ class_level: zeitwerk_shaped?(result.value))
15
16
  finder.visit(result.value)
16
17
  finder.subjects
17
18
  end
18
19
 
20
+ # Class-body subjects only for Zeitwerk-shaped files: exactly one
21
+ # top-level constant. Multi-constant files and core-class reopens have no
22
+ # safe remove_const + re-eval story (issue #32). Shared with ClosureReload.
23
+ def self.zeitwerk_shaped?(program)
24
+ ClassShape.single_top_level_constant?(program)
25
+ end
26
+
19
27
  attr_reader :subjects
20
28
 
21
- def initialize(file, skip_lines: Set.new)
29
+ def initialize(file, skip_lines: Set.new, class_level: true)
22
30
  @file = file
23
31
  @skip_lines = skip_lines
32
+ @class_level = class_level
24
33
  @stack = []
25
34
  @subjects = []
26
35
  @sclass_depth = 0
@@ -33,13 +42,19 @@ module ActiveMutator
33
42
  def visit_class_node(node)
34
43
  return if @sclass_depth.positive?
35
44
 
36
- with_scope(node.constant_path.slice) { super }
45
+ with_scope(node.constant_path.slice) do
46
+ add_class_body_subject(node)
47
+ super
48
+ end
37
49
  end
38
50
 
39
51
  def visit_module_node(node)
40
52
  return if @sclass_depth.positive?
41
53
 
42
- with_scope(node.constant_path.slice) { super }
54
+ with_scope(node.constant_path.slice) do
55
+ add_class_body_subject(node)
56
+ super
57
+ end
43
58
  end
44
59
 
45
60
  # `class << self` inside a constant scope: defs there are singleton
@@ -85,6 +100,32 @@ module ActiveMutator
85
100
 
86
101
  private
87
102
 
103
+ # One subject for the class-level code of this class/module. Only if the
104
+ # body has at least one statement the class-body walk can mutate: defs
105
+ # and nested class/modules are owned by other subjects.
106
+ def add_class_body_subject(node)
107
+ return unless @class_level
108
+ return if @skip_lines.include?(node.location.start_line - 1)
109
+
110
+ body = node.body
111
+ return unless body.is_a?(Prism::StatementsNode)
112
+ return if body.body.all? { |s| owned_by_other_subject?(s) }
113
+
114
+ scope = @stack.join("::")
115
+ loc = node.location
116
+ @subjects << Subject.new(
117
+ name: "#{scope} (class body)",
118
+ file: @file,
119
+ byte_range: loc.start_offset...loc.end_offset,
120
+ line_range: loc.start_line..loc.end_line,
121
+ constant_scope: scope,
122
+ kind: :class_body,
123
+ sclass: false
124
+ )
125
+ end
126
+
127
+ def owned_by_other_subject?(node) = ClassShape.owned_by_other_subject?(node)
128
+
88
129
  def with_scope(name)
89
130
  @stack.push(name)
90
131
  yield
@@ -1,3 +1,3 @@
1
1
  module ActiveMutator
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.0"
3
3
  end
@@ -2,12 +2,32 @@ require "json"
2
2
  require "set"
3
3
 
4
4
  module ActiveMutator
5
- # Runs INSIDE a fork. Order is critical: RSpec's setup phase loads the spec
6
- # files, whose spec_helper/rails_helper loads the application. Only THEN
7
- # can the mutation be inserted over the loaded original. Insert-first would
8
- # NameError on any project not preloaded in the parent (all non-Rails
9
- # projects), and loading app code after insertion would silently restore
10
- # the original method.
5
+ # Runs INSIDE a fork. Insertion order relative to RSpec's setup (which loads
6
+ # the spec files, and with them the app) DIFFERS by mutant kind:
7
+ #
8
+ # Class-body: insert BEFORE setup. `RSpec.describe SomeClass` binds
9
+ # `metadata[:described_class]` to the constant AT LOAD TIME, and a class-body
10
+ # mutant reloads the constant to a NEW object via ClosureReload; a group
11
+ # loaded first would keep the pre-mutation object and falsely survive. So we
12
+ # require the subject file, reload, THEN let setup load the groups — every
13
+ # group binds to the mutated object.
14
+ #
15
+ # Def: insert AFTER setup. A def mutant class_evals the live method in
16
+ # place, so it must be the LAST thing to touch that method. Inserting before
17
+ # setup let a file loaded during spec-load (a concern/decorator/monkeypatch
18
+ # that reopens the class but isn't transitively required by the subject
19
+ # file) silently redefine the method back to the original, reporting a false
20
+ # survivor. Loading everything first, then inserting, closes that window.
21
+ # Requiring the subject after setup also means spec_helper's load-time setup
22
+ # runs first, so a subject that depends on it still loads in non-preloaded
23
+ # projects.
24
+ #
25
+ # The explicit `require` of the subject file guarantees the target constant
26
+ # exists before insertion regardless of preload: preloaded projects
27
+ # (Rails/Zeitwerk, or a preloaded spec helper) already have it in
28
+ # $LOADED_FEATURES so it's a no-op, while non-preloaded projects (plain
29
+ # gems whose spec files require the lib themselves, or --no-preload-helper)
30
+ # get it loaded rather than relying on spec-load to define it.
11
31
  class Worker
12
32
  def self.run(mutation, example_ids, writer)
13
33
  new(mutation, example_ids, writer).run
@@ -23,20 +43,42 @@ module ActiveMutator
23
43
  require "rspec/core"
24
44
  devnull = File.open(File::NULL, "w")
25
45
  runner = RSpec::Core::Runner.new(RSpec::Core::ConfigurationOptions.new(@example_ids))
26
- runner.setup(devnull, devnull) # loads spec files -> loads the app
46
+ if @mutation.subject.class_body?
47
+ require @mutation.subject.file # no-op if already loaded; guarantees the constant exists
48
+ insert_mutation # BEFORE setup: groups bind described_class to the mutated object
49
+ runner.setup(devnull, devnull) # loads spec files
50
+ else
51
+ runner.setup(devnull, devnull) # loads spec files -> the app, in dependency order
52
+ require @mutation.subject.file # no-op if already loaded; guarantees the constant exists
53
+ insert_mutation # AFTER load: nothing left can redefine the method back
54
+ end
27
55
  # One failure kills the mutant; running the rest of the covering set
28
56
  # is pure waste inside the fork.
29
57
  RSpec.configuration.fail_fast = 1
30
- Inserter.new.insert(@mutation) # now the target constant exists
31
58
  after_fork_hygiene
32
59
  code = runner.run_specs(covering_groups)
33
60
  emit(code.zero? ? "survived" : "killed")
61
+ rescue ClosureReload::Skip => e
62
+ emit("skipped", details: e.message)
63
+ rescue ClosureReload::MutantLoadError => e
64
+ # The mutation made the class unloadable; a real suite would fail on it.
65
+ emit("killed", details: "mutated class failed to load: #{e.message}")
34
66
  rescue StandardError, ScriptError => e
35
67
  emit("error", details: "#{e.class}: #{e.message}")
36
68
  end
37
69
 
38
70
  private
39
71
 
72
+ # Def mutants class_eval over the live constant; class-body mutants
73
+ # cannot (macros accumulate) and go through whole-file closure reload.
74
+ def insert_mutation
75
+ if @mutation.subject.class_body?
76
+ ClosureReload.new(@mutation.subject, @mutation.mutated_file_source).call
77
+ else
78
+ Inserter.new.insert(@mutation)
79
+ end
80
+ end
81
+
40
82
  def after_fork_hygiene
41
83
  srand
42
84
  if defined?(ActiveRecord::Base)
@@ -10,6 +10,7 @@ end
10
10
  require_relative "active_mutator/edit"
11
11
  require_relative "active_mutator/splicer"
12
12
  require_relative "active_mutator/subject"
13
+ require_relative "active_mutator/class_shape"
13
14
  require_relative "active_mutator/subject_finder"
14
15
  require_relative "active_mutator/subject_matcher"
15
16
  require_relative "active_mutator/operators/base"
@@ -30,6 +31,7 @@ require_relative "active_mutator/baseline"
30
31
  require_relative "active_mutator/baseline_delta"
31
32
  require_relative "active_mutator/defined_constants"
32
33
  require_relative "active_mutator/inserter"
34
+ require_relative "active_mutator/closure_reload"
33
35
  require_relative "active_mutator/worker"
34
36
  require_relative "active_mutator/result"
35
37
  require_relative "active_mutator/work_item"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_mutator
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel John
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-20 00:00:00.000000000 Z
11
+ date: 2026-07-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: prism
@@ -17,6 +17,9 @@ dependencies:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
19
  version: '0.30'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '2'
20
23
  type: :runtime
21
24
  prerelease: false
22
25
  version_requirements: !ruby/object:Gem::Requirement
@@ -24,18 +27,21 @@ dependencies:
24
27
  - - ">="
25
28
  - !ruby/object:Gem::Version
26
29
  version: '0.30'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '2'
27
33
  - !ruby/object:Gem::Dependency
28
34
  name: rspec-core
29
35
  requirement: !ruby/object:Gem::Requirement
30
36
  requirements:
31
- - - ">="
37
+ - - "~>"
32
38
  - !ruby/object:Gem::Version
33
39
  version: '3.12'
34
40
  type: :runtime
35
41
  prerelease: false
36
42
  version_requirements: !ruby/object:Gem::Requirement
37
43
  requirements:
38
- - - ">="
44
+ - - "~>"
39
45
  - !ruby/object:Gem::Version
40
46
  version: '3.12'
41
47
  - !ruby/object:Gem::Dependency
@@ -72,7 +78,9 @@ files:
72
78
  - lib/active_mutator/baseline.rb
73
79
  - lib/active_mutator/baseline_delta.rb
74
80
  - lib/active_mutator/baseline_hooks.rb
81
+ - lib/active_mutator/class_shape.rb
75
82
  - lib/active_mutator/cli.rb
83
+ - lib/active_mutator/closure_reload.rb
76
84
  - lib/active_mutator/config.rb
77
85
  - lib/active_mutator/config_file.rb
78
86
  - lib/active_mutator/coverage_map.rb
@@ -114,7 +122,6 @@ licenses:
114
122
  - MIT
115
123
  metadata:
116
124
  rubygems_mfa_required: 'true'
117
- homepage_uri: https://github.com/drj613/active_mutator
118
125
  source_code_uri: https://github.com/drj613/active_mutator
119
126
  changelog_uri: https://github.com/drj613/active_mutator/blob/main/CHANGELOG.md
120
127
  bug_tracker_uri: https://github.com/drj613/active_mutator/issues