active_mutator 0.2.0 → 0.4.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.
@@ -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
@@ -3,7 +3,9 @@ require "etc"
3
3
  module ActiveMutator
4
4
  Config = Data.define(:paths, :since, :subject_filter, :jobs, :format, :requires,
5
5
  :timeout_factor, :timeout_floor, :force_baseline, :root,
6
- :preload_helper, :serial_patterns, :browser_boot_seconds,
6
+ :preload_helper, :serial_patterns, :spec_paths,
7
+ :browser_boot_seconds,
7
8
  :accept_survivors, :exclude, :max_mutants, :debug_plan,
8
- :fail_at, :adaptive_timeout, :operator_paths)
9
+ :fail_at, :adaptive_timeout, :operators,
10
+ :class_level, :class_level_closure_cap)
9
11
  end
@@ -19,15 +19,15 @@ module ActiveMutator
19
19
  "fail_at" => :score,
20
20
  "exclude" => :string_list,
21
21
  "serial_patterns" => :string_list,
22
+ "spec_paths" => :nonempty_string_list,
22
23
  "requires" => :string_list,
23
24
  "operators" => :string_list,
24
25
  "preload_helper" => :preload_helper,
25
- "adaptive_timeout" => :boolean
26
+ "adaptive_timeout" => :boolean,
27
+ "class_level" => :boolean,
28
+ "class_level_closure_cap" => :positive_integer
26
29
  }.freeze
27
30
 
28
- # YAML keys that don't match their Config member name.
29
- RENAMES = { "operators" => :operator_paths }.freeze
30
-
31
31
  def self.load(root)
32
32
  path = File.join(root, FILENAME)
33
33
  return {} unless File.exist?(path)
@@ -40,7 +40,7 @@ module ActiveMutator
40
40
  validator = KEYS[key]
41
41
  raise Error, "#{FILENAME}: unknown config key: #{key}" unless validator
42
42
 
43
- [RENAMES.fetch(key, key.to_sym), coerce(key, validator, value)]
43
+ [key.to_sym, coerce(key, validator, value)]
44
44
  end
45
45
  end
46
46
 
@@ -55,6 +55,10 @@ module ActiveMutator
55
55
  when :integer
56
56
  raise Error, "#{FILENAME}: #{key} must be an integer" unless value.is_a?(Integer)
57
57
  value
58
+ when :positive_integer
59
+ raise Error, "#{FILENAME}: #{key} must be an integer" unless value.is_a?(Integer)
60
+ raise Error, "#{FILENAME}: #{key} must be >= 1" unless value >= 1
61
+ value
58
62
  when :number
59
63
  raise Error, "#{FILENAME}: #{key} must be a number" unless value.is_a?(Numeric)
60
64
  value.to_f
@@ -72,6 +76,12 @@ module ActiveMutator
72
76
  raise Error, "#{FILENAME}: #{key} must be a list of strings"
73
77
  end
74
78
  value
79
+ when :nonempty_string_list
80
+ unless value.is_a?(Array) && value.all?(String)
81
+ raise Error, "#{FILENAME}: #{key} must be a list of strings"
82
+ end
83
+ raise Error, "#{FILENAME}: #{key} must not be empty" if value.empty?
84
+ value
75
85
  when :boolean
76
86
  unless [true, false].include?(value)
77
87
  raise Error, "#{FILENAME}: #{key} must be true or false"
@@ -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