active_mutator 0.4.1 → 0.5.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: 35694bf66189f60b88d9761217beb3cd025e74998e0ed1a2eed69a4a002f4b04
4
- data.tar.gz: e4dc5eb475d915653cf317379fa565aefacbcbe0d0f5115bd750dbb8860c74c7
3
+ metadata.gz: 45e58d5b8880a6716b7a78ee4011f0e5529600b3daa222ad39854723d08bd9d3
4
+ data.tar.gz: d9c9067771e0596e3241d8a0bb408c3c9f041e98eac33f08066b1118feedb5b8
5
5
  SHA512:
6
- metadata.gz: 8e8d6d31ef9767b5ae869b753090e8237f94442a82271fcec9aaa126ea02a2392f8c884bb978339c2cdf30210fde64948040cd226d2d0491eca3617db783e5bd
7
- data.tar.gz: d4ac2c77866af4cfb2369ff7a1a8be527d2dddacbd494d28310710ba92936d2321f6b6993168e7e1aaf8dd7059a2ae91818f90c8815b2cfb95447a11ae67f800
6
+ metadata.gz: 2e6881dce81b23302b40220a9b593b290b22b08eb5f6c4426da96e2f5557e369cd635525f32aa357078c6ae10f289c05d216b025992ce23ff607841b42b71b1b
7
+ data.tar.gz: 25fbb644044f506fcc4663ed26be6898187a4205a91778fdfc049240ecf7c1bb54a52f81b38e2ab9f59f75c00f0bbcd5d93b8ad6bc0d7c7a149d90cdc4f69cf4
data/README.md CHANGED
@@ -58,7 +58,7 @@ changed and nothing noticed. A survivor is not a hypothetical. It is the
58
58
  exact line, the exact before and after diff, and proof that no assertion
59
59
  depends on the difference.
60
60
 
61
- Mutation score is `(killed + timeout) / (killed + timeout + survived)`.
61
+ Mutation score is `(killed + timeout) / (killed + timeout + survived + error)`.
62
62
  100% is usually not the right target. Some mutants are behaviorally
63
63
  *equivalent* to the original and can never be killed by any test. That is
64
64
  why active_mutator has a committed acceptance ledger. It lets you close
@@ -121,17 +121,20 @@ Each character on the progress line is one mutant, printed as it finishes:
121
121
  |---|---|---|
122
122
  | `.` | `killed` | a covering test failed. Good, the mutant is dead |
123
123
  | `S` | `survived` | every covering test passed. This is a test gap |
124
- | `T` | `timeout` | ran past its time budget. Counted as detected (likely an infinite loop) |
125
- | `E` | `error` | the worker crashed, or the mutated code raised outside a test assertion |
124
+ | `T` | `timeout` | ran past its time budget. Counts as detected (the mutant likely made a loop never end), and the summary lists each one with elapsed vs budget so a tight budget is visible |
125
+ | `E` | `error` | the worker crashed, or the mutated code raised outside a test assertion. Not detected: counts against the score and fails the run |
126
126
  | `U` | `uncovered` | no test executes the mutated line at all. This is coverage debt, worse than a survivor |
127
127
  | `A` | `accepted` | matches a known-equivalent entry in the acceptance ledger. Excluded from the score |
128
128
 
129
129
  `invalid` mutants (edits that don't even re-parse as valid Ruby) are
130
130
  discarded before scheduling and reported as a count only. Exit code is `1`
131
- if unaccepted survivors exist (or, with `--fail-at`, if the score is below
132
- the threshold), `0` otherwise, including when there are only `uncovered`,
133
- `accepted`, or `error` results. The JSON report's `exit_reason` field
134
- reflects survivor presence, independent of the `--fail-at` gate.
131
+ if unaccepted survivors or errors exist (or, with `--fail-at`, if the score
132
+ is below the threshold), `0` otherwise, including when there are only
133
+ `uncovered` or `accepted` results. The JSON report's `exit_reason` field
134
+ (`unaccepted_survivors`, `worker_errors`, `clean`) is independent of the
135
+ `--fail-at` gate. A `--since`
136
+ or `--subject` run that plans zero mutants prints no score; it warns with the
137
+ cause and exits `1` unless `--allow-empty` is given.
135
138
 
136
139
  When survivors exist, the summary also prints a per-operator table showing
137
140
  how often each operator's mutants survive, to help spot likely-equivalent
@@ -198,7 +201,7 @@ score is below the threshold). Mistyped positional paths (a file that
198
201
  doesn't exist, or a non-`.rb` file) are an error (exit 2) instead of a
199
202
  vacuous green run.
200
203
 
201
- Score = (killed + timeout) / (killed + timeout + survived).
204
+ Score = (killed + timeout) / (killed + timeout + survived + error).
202
205
 
203
206
  ## The dev loop
204
207
 
@@ -253,6 +256,7 @@ survivors show inline on the PR diff. Pairs with the CI recipe:
253
256
  | `--exclude PAT` | none | skip files matching glob during subject discovery (repeatable, gitignore-like) |
254
257
  | `--max-mutants N` | none | deterministic sample of the first N mutants (quick smoke run on huge scopes; accepted/uncovered mutants count against N) |
255
258
  | `--debug-plan` | off | print planned mutants as JSON and exit without running |
259
+ | `--allow-empty` | off | exit 0 when `--since`/`--subject` plan no mutants (default: warn and exit 1) |
256
260
  | `--format terminal\|json\|stryker-json\|github` | terminal | report format |
257
261
  | `--accept-survivors` | off | record survivors to the acceptance ledger |
258
262
  | `--force-baseline` | off | ignore cached coverage map |
@@ -401,6 +405,19 @@ change. Also run `bundle exec active_mutator --changed` on your own diff
401
405
  before sending a change that touches `lib/`. This is a good idea for the
402
406
  same reason you'd want it run on any other codebase.
403
407
 
408
+ If a run dies with `baseline suite failed` and a `LoadError` mentioning
409
+ `bundler-2.x/lib/gems/bundler-2.x/exe/bundle`, your Ruby manager (seen with
410
+ mise) breaks nested `bundle exec`: the baseline shells out to `bundle exec
411
+ rspec`, and bundler's exported `RUBYLIB` makes the inner binstub resolve the
412
+ wrong path. Skip the outer bundler instead:
413
+
414
+ ```sh
415
+ ruby -Ilib exe/active_mutator lib --since origin/main # same as the CI mutation job
416
+ ```
417
+
418
+ The `:e2e` specs nest `bundle exec` on their own inside the fixture project,
419
+ so on such a machine they fail either way; rely on the CI `e2e` job for those.
420
+
404
421
  ## License
405
422
 
406
423
  [MIT](LICENSE).
@@ -64,8 +64,13 @@ module ActiveMutator
64
64
  end
65
65
  end
66
66
 
67
+ # An example id passed alongside its own file makes RSpec run ONLY the
68
+ # listed ids from that file, so a changed spec file's new examples would
69
+ # never be recorded. A whole-file rerun subsumes the ids.
70
+ rerun_spec_files = rerun_spec_files.uniq.sort
71
+ rerun_example_ids = rerun_example_ids.reject { |id| rerun_spec_files.include?(spec_file_of(id)) }
67
72
  Delta.new(full: false,
68
- rerun_spec_files: rerun_spec_files.uniq.sort,
73
+ rerun_spec_files: rerun_spec_files,
69
74
  rerun_example_ids: rerun_example_ids.uniq.sort,
70
75
  drop_example_ids: drop_example_ids.uniq.sort,
71
76
  drop_source_files: drop_source_files.uniq.sort)
@@ -43,5 +43,24 @@ module ActiveMutator
43
43
  node.is_a?(Prism::DefNode) || node.is_a?(Prism::ClassNode) ||
44
44
  node.is_a?(Prism::ModuleNode) || node.is_a?(Prism::SingletonClassNode)
45
45
  end
46
+
47
+ # ActiveSupport::Concern DSL calls whose block body re-runs as class-level
48
+ # code in the includer (`included`, `prepended`) or is module_eval'd into
49
+ # the concern's ClassMethods module (`class_methods`). SubjectFinder gives
50
+ # the defs inside their own subjects; Engine's class-body walk owns the rest.
51
+ CONCERN_BLOCK_CALLS = %i[included prepended class_methods].freeze
52
+
53
+ def concern_dsl_block?(node)
54
+ node.is_a?(Prism::CallNode) && node.receiver.nil? &&
55
+ CONCERN_BLOCK_CALLS.include?(node.name) && node.block.is_a?(Prism::BlockNode)
56
+ end
57
+
58
+ # Where the search for concern-block defs stops: a def below one of these
59
+ # is not on the concern's scope, so it gets no subject (same limit as
60
+ # SubjectFinder#visit_block_node) and stays with the class-body walk.
61
+ def concern_def_boundary?(node)
62
+ node.is_a?(Prism::BlockNode) || node.is_a?(Prism::ClassNode) ||
63
+ node.is_a?(Prism::ModuleNode) || node.is_a?(Prism::SingletonClassNode)
64
+ end
46
65
  end
47
66
  end
@@ -32,7 +32,7 @@ module ActiveMutator
32
32
  spec_paths: ["spec"],
33
33
  browser_boot_seconds: 15.0, accept_survivors: false, exclude: [],
34
34
  max_mutants: nil, debug_plan: false, fail_at: nil, adaptive_timeout: true,
35
- operators: [], class_level: true, class_level_closure_cap: 10
35
+ operators: [], class_level: true, class_level_closure_cap: 10, allow_empty: false
36
36
  }
37
37
  options.merge!(ConfigFile.load(Dir.pwd))
38
38
  paths = OptionParser.new do |o|
@@ -66,6 +66,7 @@ module ActiveMutator
66
66
  o.on("--exclude PAT", "Skip files matching glob, relative to root (repeatable)") { |v| options[:exclude] << v }
67
67
  o.on("--max-mutants N", Integer, "Deterministically sample the first N mutants") { |v| options[:max_mutants] = v }
68
68
  o.on("--debug-plan", "Print the planned mutant list as JSON and exit") { options[:debug_plan] = true }
69
+ o.on("--allow-empty", "Exit 0 when --since/--subject plan no mutants (default: exit 1)") { options[:allow_empty] = true }
69
70
  o.on("--fail-at SCORE", Float, "Exit 0 if mutation score >= SCORE even with survivors (default: any survivor fails)") do |v|
70
71
  raise OptionParser::InvalidArgument, "--fail-at must be within 0..100" unless (0..100).cover?(v)
71
72
  options[:fail_at] = v
@@ -7,5 +7,5 @@ module ActiveMutator
7
7
  :browser_boot_seconds,
8
8
  :accept_survivors, :exclude, :max_mutants, :debug_plan,
9
9
  :fail_at, :adaptive_timeout, :operators,
10
- :class_level, :class_level_closure_cap)
10
+ :class_level, :class_level_closure_cap, :allow_empty)
11
11
  end
@@ -25,7 +25,8 @@ module ActiveMutator
25
25
  "preload_helper" => :preload_helper,
26
26
  "adaptive_timeout" => :boolean,
27
27
  "class_level" => :boolean,
28
- "class_level_closure_cap" => :positive_integer
28
+ "class_level_closure_cap" => :positive_integer,
29
+ "allow_empty" => :boolean
29
30
  }.freeze
30
31
 
31
32
  def self.load(root)
@@ -77,14 +77,7 @@ module ActiveMutator
77
77
 
78
78
  def owned_statement?(node) = ClassShape.owned_by_other_subject?(node)
79
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
80
+ def concern_dsl_block?(node) = ClassShape.concern_dsl_block?(node)
88
81
 
89
82
  # No nil guard needed (unlike #walk): the entry node is the class body's
90
83
  # StatementsNode, guaranteed present for a class-body subject, and
@@ -95,20 +88,33 @@ module ActiveMutator
95
88
  elsif node.is_a?(Prism::BlockNode)
96
89
  # Pruned: a block's run-time context is unknown (see collect comment).
97
90
  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)
91
+ # The concern call node itself is not yielded: the whole-block deletion
92
+ # edit already comes from the enclosing StatementsNode, and no operator
93
+ # targets a bare receiverless call.
94
+ concern_walk(node.block.body, owned, &blk)
106
95
  else
107
96
  yield node
108
97
  node.compact_child_nodes.each { |child| class_walk(child, owned, &blk) }
109
98
  end
110
99
  end
111
100
 
101
+ # Inside a concern block, defs are subjects of their own (SubjectFinder
102
+ # emits them; mirrors ClassShape.concern_dsl_block?), so they are owned
103
+ # here. Everything else mutates like the def-level #walk, including the
104
+ # interior of nested blocks, exactly as before defs got their own subjects.
105
+ def concern_walk(node, owned, &blk)
106
+ return if node.nil?
107
+
108
+ if node.is_a?(Prism::DefNode)
109
+ owned << (node.location.start_offset...node.location.end_offset)
110
+ elsif ClassShape.concern_def_boundary?(node)
111
+ walk(node, &blk)
112
+ else
113
+ yield node
114
+ node.compact_child_nodes.each { |child| concern_walk(child, owned, &blk) }
115
+ end
116
+ end
117
+
112
118
  # The mutant is the whole file. The def-shaped fields are filled with the
113
119
  # file source so the Mutation shape stays uniform; Worker routes
114
120
  # class-body mutants through ClosureReload (whole-file re-eval), never
@@ -17,12 +17,19 @@ module ActiveMutator
17
17
  "invalid" => invalid_count,
18
18
  "operators" => OperatorStats.call(results),
19
19
  "results" => results.map { |r| serialize(r) },
20
- "exit_reason" => counts.fetch(:survived, 0).positive? ? "unaccepted_survivors" : "clean"
20
+ "exit_reason" => exit_reason(counts)
21
21
  )
22
22
  end
23
23
 
24
24
  private
25
25
 
26
+ def exit_reason(counts)
27
+ return "unaccepted_survivors" if counts.fetch(:survived, 0).positive?
28
+ return "worker_errors" if counts.fetch(:error, 0).positive?
29
+
30
+ "clean"
31
+ end
32
+
26
33
  def serialize(result)
27
34
  m = result.mutation
28
35
  {
@@ -20,8 +20,9 @@ module ActiveMutator
20
20
  end
21
21
  @out.puts "invalid (discarded): #{invalid_count}"
22
22
  @out.puts format("Mutation score: %.1f%%", score(counts) * 100)
23
- survivors = results.select { |r| r.status == :survived }
24
- print_survivors(survivors) unless survivors.empty?
23
+ print_group("Surviving mutants:", results.select { |r| r.status == :survived })
24
+ print_group("Errored mutants (not detected):", results.select { |r| r.status == :error })
25
+ print_group("Timed-out mutants (counted as detected):", results.select { |r| r.status == :timeout })
25
26
  skipped = results.select { |r| r.status == :skipped }
26
27
  print_skipped(skipped) unless skipped.empty?
27
28
  stats = OperatorStats.call(results)
@@ -29,9 +30,12 @@ module ActiveMutator
29
30
  print_operator_stats(noisy) unless noisy.empty?
30
31
  end
31
32
 
33
+ # A timeout is a detection (the mutant changed behavior enough to hang,
34
+ # the same convention as Stryker and PIT). An error is a non-verdict:
35
+ # scoring it as a pass let a broken worker read as 100%.
32
36
  def self.score(counts)
33
37
  detected = counts.fetch(:killed, 0) + counts.fetch(:timeout, 0)
34
- denominator = detected + counts.fetch(:survived, 0)
38
+ denominator = detected + counts.fetch(:survived, 0) + counts.fetch(:error, 0)
35
39
  return 1.0 if denominator.zero?
36
40
 
37
41
  detected.to_f / denominator
@@ -49,9 +53,11 @@ module ActiveMutator
49
53
  end
50
54
  end
51
55
 
52
- def print_survivors(survivors)
53
- @out.puts "", "Surviving mutants:"
54
- survivors.each do |result|
56
+ def print_group(title, group)
57
+ return if group.empty?
58
+
59
+ @out.puts "", title
60
+ group.each do |result|
55
61
  m = result.mutation
56
62
  @out.puts "", " #{m.subject.name} (#{m.subject.file}:#{m.line})"
57
63
  @out.puts " #{m.description}"
@@ -29,6 +29,7 @@ module ActiveMutator
29
29
 
30
30
  items, pre_results, phase1_ids = plan_work(mutations, map, ledger: ledger, fingerprints: fingerprints)
31
31
  return debug_plan(items, pre_results) if @config.debug_plan
32
+ return empty_plan_exit if mutations.empty? && (@config.since || @config.subject_filter)
32
33
 
33
34
  pre_results.each { |r| @reporter.on_result(r) }
34
35
  calibrators = if @config.adaptive_timeout
@@ -119,7 +120,7 @@ module ActiveMutator
119
120
  extra = items[r.mutation].example_ids.map { |id| BaselineDelta.spec_file_of(id) }.uniq.size
120
121
  replacement.with(details: "escalated (+#{extra} spec files)")
121
122
  else
122
- # A timeout/error/skip in phase 2 did NOT prove a kill the mutant
123
+ # A timeout/error/skip in phase 2 did NOT prove a kill; the mutant
123
124
  # already survived phase 1, so keep that verdict rather than letting
124
125
  # an inconclusive escalation inflate the score (a :timeout counts as
125
126
  # detected in exit_code/score).
@@ -129,17 +130,33 @@ module ActiveMutator
129
130
  end
130
131
 
131
132
  def exit_code(results)
132
- survived = results.count { |r| r.status == :survived }
133
- return 0 if survived.zero?
133
+ undetected = results.count { |r| %i[survived error].include?(r.status) }
134
+ return 0 if undetected.zero?
134
135
  return 1 unless @config.fail_at
135
136
 
136
137
  detected = results.count { |r| %i[killed timeout].include?(r.status) }
137
- score = detected * 100.0 / (detected + survived)
138
+ score = detected * 100.0 / (detected + undetected)
138
139
  score >= @config.fail_at ? 0 : 1
139
140
  end
140
141
 
141
142
  private
142
143
 
144
+ # A scoped run that plans nothing must not report "100%" and pass --fail-at:
145
+ # the usual cause is a --since range or --subject filter that matched no
146
+ # mutable code, or class-body code dropped by --no-class-level (#23 covers
147
+ # the zero-subject case for explicit paths).
148
+ def empty_plan_exit
149
+ causes = []
150
+ causes << "--since #{@config.since} matched no mutable code" if @config.since
151
+ causes << "--subject #{@config.subject_filter} matched no subjects" if @config.subject_filter
152
+ causes << "--no-class-level excludes class-body code" unless @config.class_level
153
+ warn "active_mutator: no mutants planned (#{causes.join("; ")})"
154
+ return 0 if @config.allow_empty
155
+
156
+ warn "active_mutator: exiting 1; pass --allow-empty if an empty plan is expected"
157
+ 1
158
+ end
159
+
143
160
  # Single source of truth for lane/timeout/variable derivation, shared by
144
161
  # phase-1 planning and phase-2 escalation so the two never drift.
145
162
  def build_work_item(mutation, example_ids, map)
@@ -1,4 +1,5 @@
1
1
  require "json"
2
+ require "tempfile"
2
3
 
3
4
  module ActiveMutator
4
5
  # Fork pool: one fork per WorkItem, capped at `jobs` concurrent forks.
@@ -62,13 +63,20 @@ module ActiveMutator
62
63
  raise OrphanedError, "parent process died; aborting mutation run"
63
64
  end
64
65
 
66
+ STDERR_TAIL_LINES = 20
67
+
65
68
  def spawn(item, running)
66
69
  reader, writer = IO.pipe
70
+ stderr_file = Tempfile.new("active_mutator-worker")
67
71
  pid = fork do
68
72
  reader.close
69
73
  Process.setpgid(0, 0) # own process group: deadline kill reaps grandchildren too
70
74
  $stdout.reopen(File::NULL) # app code that prints must not corrupt parent's report
71
- $stderr.reopen(File::NULL) # ditto for warnings (RSpec/app noise interleaves with reports)
75
+ $stderr.reopen(stderr_file.path, "w") # kept for the crash report, never shown otherwise
76
+ # After fork(), libpq's GSS encryption negotiation touches Apple
77
+ # frameworks and segfaults the child on macOS; disabling it is
78
+ # harmless everywhere else.
79
+ ENV["PGGSSENCMODE"] ||= "disable"
72
80
  @worker.call(item.mutation, item.example_ids, writer)
73
81
  writer.close
74
82
  Process.exit!(0)
@@ -78,7 +86,7 @@ module ActiveMutator
78
86
  budget = calibrator ? calibrator.budget_for(item) : item.timeout
79
87
  log_scale(calibrator, item.lane)
80
88
  started = now
81
- running[pid] = { reader: reader, item: item, started: started,
89
+ running[pid] = { reader: reader, item: item, started: started, stderr_file: stderr_file,
82
90
  budget: budget, deadline: started + budget }
83
91
  end
84
92
 
@@ -96,7 +104,9 @@ module ActiveMutator
96
104
  kill(pid)
97
105
  running.delete(pid)
98
106
  entry[:reader].close
99
- results << report(Result.new(mutation: entry[:item].mutation, status: :timeout, details: nil))
107
+ entry[:stderr_file].close!
108
+ details = format("timed out after %.1fs (budget %.1fs)", now - entry[:started], entry[:budget])
109
+ results << report(Result.new(mutation: entry[:item].mutation, status: :timeout, details: details))
100
110
  end
101
111
  end
102
112
  end
@@ -104,13 +114,14 @@ module ActiveMutator
104
114
  def finish(entry)
105
115
  payload = entry[:reader].read.to_s
106
116
  entry[:reader].close
117
+ stderr_tail = stderr_tail(entry[:stderr_file])
107
118
  data = payload.empty? ? nil : JSON.parse(payload)
108
119
  # A self-mutation of Worker#emit can produce well-formed JSON without a
109
120
  # "status" key (or with a non-Hash root); treat any unusable payload as
110
121
  # a worker error instead of crashing the whole run.
111
122
  reported = data.is_a?(Hash) && data.key?("status")
112
123
  status = reported ? data["status"].to_sym : :error
113
- details = reported ? data["details"] : "worker exited without reporting"
124
+ details = reported ? data["details"] : unreported_details(stderr_tail)
114
125
  rescue JSON::ParserError
115
126
  report(Result.new(mutation: entry[:item].mutation, status: :error,
116
127
  details: "worker emitted unparseable payload"))
@@ -123,6 +134,20 @@ module ActiveMutator
123
134
  result
124
135
  end
125
136
 
137
+ # The child wrote through its own descriptor (reopened by path), so this
138
+ # handle is still at offset 0: no rewind needed.
139
+ def stderr_tail(file)
140
+ file.read.to_s.lines.last(STDERR_TAIL_LINES).join.strip
141
+ ensure
142
+ file.close!
143
+ end
144
+
145
+ def unreported_details(stderr_tail)
146
+ return "worker exited without reporting" if stderr_tail.empty?
147
+
148
+ "worker exited without reporting; stderr tail:\n#{stderr_tail}"
149
+ end
150
+
126
151
  def kill(pid)
127
152
  Process.kill("KILL", -pid) # negative pid = whole process group
128
153
  rescue Errno::ESRCH, Errno::EPERM
@@ -5,13 +5,18 @@ module ActiveMutator
5
5
  # class/module node; Engine only mutates non-def body statements).
6
6
  # sclass: def lives inside `class << self` — its source slice is `def foo`,
7
7
  # so Inserter must target the singleton class, not the constant itself.
8
- Subject = Data.define(:name, :file, :byte_range, :line_range, :constant_scope, :kind, :sclass) do
9
- def initialize(name:, file:, byte_range:, line_range:, constant_scope:, kind:, sclass: false)
8
+ # reload: def lives inside a concern's `included`/`prepended` block, so it
9
+ # lands on the includer, not on the constant; the only faithful insertion is
10
+ # the same whole-file closure reload a class-body mutant gets.
11
+ Subject = Data.define(:name, :file, :byte_range, :line_range, :constant_scope, :kind, :sclass, :reload) do
12
+ def initialize(name:, file:, byte_range:, line_range:, constant_scope:, kind:, sclass: false, reload: false)
10
13
  super
11
14
  end
12
15
 
13
16
  def singleton? = kind == :singleton
14
17
 
15
18
  def class_body? = kind == :class_body
19
+
20
+ def reload? = class_body? || reload
16
21
  end
17
22
  end
@@ -79,6 +79,31 @@ module ActiveMutator
79
79
  def visit_block_node(node); end
80
80
 
81
81
  def visit_def_node(node)
82
+ add_def_subject(node)
83
+ # No `super`: nested defs get no subject of their own -- their bodies
84
+ # are mutated via the OUTER def (Engine#walk descends into them).
85
+ end
86
+
87
+ # ActiveSupport::Concern blocks are the exception to visit_block_node.
88
+ # `class_methods do` is module_eval'd into `Scope::ClassMethods`, so its
89
+ # defs are plain subjects on that constant, exactly as if written in
90
+ # `module ClassMethods`. `included`/`prepended` defs land on the includer,
91
+ # which Inserter cannot target; they are reload subjects (whole-file
92
+ # closure reload), which needs the same Zeitwerk-shape gate as class-body
93
+ # subjects.
94
+ def visit_call_node(node)
95
+ return super unless @sclass_depth.zero? && !@stack.empty? && ClassShape.concern_dsl_block?(node)
96
+
97
+ if node.name == :class_methods
98
+ with_scope("ClassMethods") { each_concern_def(node.block.body) { |d| add_def_subject(d) } }
99
+ elsif @class_level
100
+ each_concern_def(node.block.body) { |d| add_def_subject(d, reload: true) }
101
+ end
102
+ end
103
+
104
+ private
105
+
106
+ def add_def_subject(node, reload: false)
82
107
  return if @skip_lines.include?(node.location.start_line - 1)
83
108
 
84
109
  sclass = @sclass_depth.positive?
@@ -92,13 +117,20 @@ module ActiveMutator
92
117
  line_range: loc.start_line..loc.end_line,
93
118
  constant_scope: scope,
94
119
  kind: singleton ? :singleton : :instance,
95
- sclass: sclass
120
+ sclass: sclass,
121
+ reload: reload
96
122
  )
97
- # No `super`: nested defs get no subject of their own -- their bodies
98
- # are mutated via the OUTER def (Engine#walk descends into them).
99
123
  end
100
124
 
101
- private
125
+ def each_concern_def(node, &blk)
126
+ return if node.nil?
127
+
128
+ if node.is_a?(Prism::DefNode)
129
+ yield node
130
+ elsif !ClassShape.concern_def_boundary?(node)
131
+ node.compact_child_nodes.each { |child| each_concern_def(child, &blk) }
132
+ end
133
+ end
102
134
 
103
135
  # One subject for the class-level code of this class/module. Only if the
104
136
  # body has at least one statement the class-body walk can mutate: defs
@@ -1,3 +1,3 @@
1
1
  module ActiveMutator
2
- VERSION = "0.4.1"
2
+ VERSION = "0.5.0"
3
3
  end
@@ -43,7 +43,7 @@ module ActiveMutator
43
43
  require "rspec/core"
44
44
  devnull = File.open(File::NULL, "w")
45
45
  runner = RSpec::Core::Runner.new(RSpec::Core::ConfigurationOptions.new(@example_ids))
46
- if @mutation.subject.class_body?
46
+ if @mutation.subject.reload?
47
47
  require @mutation.subject.file # no-op if already loaded; guarantees the constant exists
48
48
  insert_mutation # BEFORE setup: groups bind described_class to the mutated object
49
49
  runner.setup(devnull, devnull) # loads spec files
@@ -70,9 +70,10 @@ module ActiveMutator
70
70
  private
71
71
 
72
72
  # Def mutants class_eval over the live constant; class-body mutants
73
- # cannot (macros accumulate) and go through whole-file closure reload.
73
+ # cannot (macros accumulate) and go through whole-file closure reload, as
74
+ # do defs inside `included`/`prepended` blocks (they live on the includer).
74
75
  def insert_mutation
75
- if @mutation.subject.class_body?
76
+ if @mutation.subject.reload?
76
77
  ClosureReload.new(@mutation.subject, @mutation.mutated_file_source).call
77
78
  else
78
79
  Inserter.new.insert(@mutation)
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_mutator
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel John
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2026-08-31 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: prism
@@ -62,7 +61,6 @@ description: Mutation testing for Ruby and Rails. Uses Prism-based source-span m
62
61
  (no unparser), coverage-mapped test selection, and a fork-per-mutant kill pipeline.
63
62
  Scopes to changed methods for a fast dev loop, or to a diff for CI. Includes an
64
63
  incremental coverage baseline and a committed acceptance ledger for equivalent mutants.
65
- email:
66
64
  executables:
67
65
  - active_mutator
68
66
  extensions: []
@@ -125,7 +123,6 @@ metadata:
125
123
  source_code_uri: https://github.com/drj613/active_mutator
126
124
  changelog_uri: https://github.com/drj613/active_mutator/blob/main/CHANGELOG.md
127
125
  bug_tracker_uri: https://github.com/drj613/active_mutator/issues
128
- post_install_message:
129
126
  rdoc_options: []
130
127
  require_paths:
131
128
  - lib
@@ -140,8 +137,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
140
137
  - !ruby/object:Gem::Version
141
138
  version: '0'
142
139
  requirements: []
143
- rubygems_version: 3.5.16
144
- signing_key:
140
+ rubygems_version: 4.0.19
145
141
  specification_version: 4
146
142
  summary: Mutation testing for Ruby, built on Prism
147
143
  test_files: []