active_mutator 0.4.1 → 0.6.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: 60c123ba92745825d5041728a14144e2cad95ba814d54fcb72bba40e1d5c0089
4
+ data.tar.gz: b7f96c322c455aa01d4ef19e04aa017f61fecb82347b1da7e315cfdc4316708e
5
5
  SHA512:
6
- metadata.gz: 8e8d6d31ef9767b5ae869b753090e8237f94442a82271fcec9aaa126ea02a2392f8c884bb978339c2cdf30210fde64948040cd226d2d0491eca3617db783e5bd
7
- data.tar.gz: d4ac2c77866af4cfb2369ff7a1a8be527d2dddacbd494d28310710ba92936d2321f6b6993168e7e1aaf8dd7059a2ae91818f90c8815b2cfb95447a11ae67f800
6
+ metadata.gz: 134766964d548d79002d0364046d6a46cb9a3b3d14b334c7d7ee67b3581865c31f10184bd7666d59f5732122dbc74f8c8ffdd7ee14664cd610c38ba64675fc31
7
+ data.tar.gz: 22bd7813d2a1d061a045a3bc3c7cc2d21c31b3d34f6c8b26e0f2433921b131c124f0f652e3367657bfcc26e987dd7416763c33e05d3e074cfa688703047ec8e2
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,25 @@ 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`, `empty_plan`) is
135
+ independent of the `--fail-at` gate. A `--since` or `--subject` run that
136
+ plans zero mutants skips the baseline, prints the usual count block with
137
+ every status at `0` and no `Mutation score:` line (JSON: `score` is `null`,
138
+ `exit_reason` is `empty_plan`), then warns with the cause and exits `1`
139
+ unless `--allow-empty` is given. The count block is printed on every run,
140
+ whatever the exit code, so log-scraping never has to handle a missing block.
141
+ `--allow-empty` forgives the empty plan only when the `--since` diff touched
142
+ no candidate source file; see [Empty plans in CI](#empty-plans-in-ci).
135
143
 
136
144
  When survivors exist, the summary also prints a per-operator table showing
137
145
  how often each operator's mutants survive, to help spot likely-equivalent
@@ -198,7 +206,7 @@ score is below the threshold). Mistyped positional paths (a file that
198
206
  doesn't exist, or a non-`.rb` file) are an error (exit 2) instead of a
199
207
  vacuous green run.
200
208
 
201
- Score = (killed + timeout) / (killed + timeout + survived).
209
+ Score = (killed + timeout) / (killed + timeout + survived + error).
202
210
 
203
211
  ## The dev loop
204
212
 
@@ -242,6 +250,29 @@ survivors show inline on the PR diff. Pairs with the CI recipe:
242
250
  residual blind spot — constant-reference detection handles the common
243
251
  newly-covering-example case since 0.2)
244
252
 
253
+ ### Empty plans in CI
254
+
255
+ A `--since` PR run that plans zero mutants skips the baseline, prints the
256
+ zero-count block, and exits `1`. Pass `--allow-empty` to let it decide for
257
+ itself whether that emptiness is fine:
258
+
259
+ - **Exit 0** when the diff contains no candidate file. A candidate is a
260
+ changed or untracked `.rb` file inside the scanned paths (positional args
261
+ or the default `app`/`lib`), not under a spec path, and not matching an
262
+ `--exclude` pattern. Docs-only, spec-only, and excluded-path PRs pass.
263
+ - **Exit 0** with `--no-class-level` when every changed line in the
264
+ candidate files falls inside class-body code that flag dropped. The
265
+ warning names `--no-class-level` as the reason.
266
+ - **Exit 1** otherwise. A candidate source file changed but produced no
267
+ mutants, and the warning lists the file(s). A comment-only edit in `lib/`
268
+ counts as a real change here.
269
+ - With `--subject` and no `--since` there is no diff to judge, so
270
+ `--allow-empty` exits `0` unconditionally.
271
+
272
+ `--changed` follows the same rule, and an untracked candidate `.rb` file
273
+ counts as a candidate. This replaces the repo-side log-scraping script some
274
+ projects used to tell a docs-only PR from a broken `--since` range.
275
+
245
276
  ## Flags
246
277
 
247
278
  | Flag | Default | Meaning |
@@ -253,6 +284,7 @@ survivors show inline on the PR diff. Pairs with the CI recipe:
253
284
  | `--exclude PAT` | none | skip files matching glob during subject discovery (repeatable, gitignore-like) |
254
285
  | `--max-mutants N` | none | deterministic sample of the first N mutants (quick smoke run on huge scopes; accepted/uncovered mutants count against N) |
255
286
  | `--debug-plan` | off | print planned mutants as JSON and exit without running |
287
+ | `--allow-empty` | off | forgive an empty `--since`/`--subject` plan, but only when the `--since` diff touched no candidate source file (default: warn and exit 1) |
256
288
  | `--format terminal\|json\|stryker-json\|github` | terminal | report format |
257
289
  | `--accept-survivors` | off | record survivors to the acceptance ledger |
258
290
  | `--force-baseline` | off | ignore cached coverage map |
@@ -401,6 +433,19 @@ change. Also run `bundle exec active_mutator --changed` on your own diff
401
433
  before sending a change that touches `lib/`. This is a good idea for the
402
434
  same reason you'd want it run on any other codebase.
403
435
 
436
+ If a run dies with `baseline suite failed` and a `LoadError` mentioning
437
+ `bundler-2.x/lib/gems/bundler-2.x/exe/bundle`, your Ruby manager (seen with
438
+ mise) breaks nested `bundle exec`: the baseline shells out to `bundle exec
439
+ rspec`, and bundler's exported `RUBYLIB` makes the inner binstub resolve the
440
+ wrong path. Skip the outer bundler instead:
441
+
442
+ ```sh
443
+ ruby -Ilib exe/active_mutator lib --since origin/main # same as the CI mutation job
444
+ ```
445
+
446
+ The `:e2e` specs nest `bundle exec` on their own inside the fixture project,
447
+ so on such a machine they fail either way; rely on the CI `e2e` job for those.
448
+
404
449
  ## License
405
450
 
406
451
  [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,9 @@ 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",
70
+ "Exit 0 when --since/--subject plan no mutants and the --since diff touched no mutable " \
71
+ "source file (default: exit 1)") { options[:allow_empty] = true }
69
72
  o.on("--fail-at SCORE", Float, "Exit 0 if mutation score >= SCORE even with survivors (default: any survivor fails)") do |v|
70
73
  raise OptionParser::InvalidArgument, "--fail-at must be within 0..100" unless (0..100).cover?(v)
71
74
  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
@@ -12,8 +12,8 @@ module ActiveMutator
12
12
 
13
13
  def on_result(result) = @terminal.on_result(result)
14
14
 
15
- def summary(results, invalid_count:)
16
- @terminal.summary(results, invalid_count: invalid_count)
15
+ def summary(results, invalid_count:, empty_plan: false)
16
+ @terminal.summary(results, invalid_count: invalid_count, empty_plan: empty_plan)
17
17
  results.select { |r| r.status == :survived }.each { |r| annotate(r) }
18
18
  end
19
19
 
@@ -9,20 +9,30 @@ module ActiveMutator
9
9
 
10
10
  def on_result(result); end
11
11
 
12
- def summary(results, invalid_count:)
13
- counts = results.group_by(&:status).transform_values(&:size)
12
+ # An empty plan has no score (null) and its own exit_reason, so a CI
13
+ # consumer can tell "nothing to mutate" from "everything was killed" (#45).
14
+ def summary(results, invalid_count:, empty_plan: false)
15
+ counts = Terminal.counts(results)
14
16
  @out.puts JSON.pretty_generate(
15
- "score" => Terminal.score(counts),
17
+ "score" => empty_plan ? nil : Terminal.score(counts),
16
18
  "counts" => counts.transform_keys(&:to_s),
17
19
  "invalid" => invalid_count,
18
20
  "operators" => OperatorStats.call(results),
19
21
  "results" => results.map { |r| serialize(r) },
20
- "exit_reason" => counts.fetch(:survived, 0).positive? ? "unaccepted_survivors" : "clean"
22
+ "exit_reason" => exit_reason(counts, empty_plan)
21
23
  )
22
24
  end
23
25
 
24
26
  private
25
27
 
28
+ def exit_reason(counts, empty_plan)
29
+ return "empty_plan" if empty_plan
30
+ return "unaccepted_survivors" if counts[:survived].positive?
31
+ return "worker_errors" if counts[:error].positive?
32
+
33
+ "clean"
34
+ end
35
+
26
36
  def serialize(result)
27
37
  m = result.mutation
28
38
  {
@@ -1,4 +1,5 @@
1
1
  require "json"
2
+ require "fileutils"
2
3
 
3
4
  module ActiveMutator
4
5
  module Reporter
@@ -31,9 +32,13 @@ module ActiveMutator
31
32
  @out.print(Terminal::CHARS.fetch(result.status))
32
33
  end
33
34
 
34
- def summary(results, invalid_count:)
35
+ # A zero-mutant report is valid schema output, so an empty plan writes the
36
+ # same file with no files/mutants; the flag is accepted for contract parity.
37
+ def summary(results, invalid_count:, empty_plan: false)
35
38
  report = build_report(results, invalid_count)
36
39
  path = File.join(@root, REPORT_PATH)
40
+ # An empty plan skips the baseline, which used to create this dir.
41
+ FileUtils.mkdir_p(File.dirname(path))
37
42
  AtomicFile.write(path, JSON.pretty_generate(report))
38
43
  @out.puts "", "", "Stryker report written to #{REPORT_PATH}"
39
44
  end
@@ -12,16 +12,24 @@ module ActiveMutator
12
12
  @out.print(CHARS.fetch(result.status))
13
13
  end
14
14
 
15
- def summary(results, invalid_count:)
16
- counts = results.group_by(&:status).transform_values(&:size)
15
+ # Every status key is present (zero when absent) so the block has the same
16
+ # shape on every run, including an empty plan.
17
+ def self.counts(results)
18
+ tallies = results.group_by(&:status).transform_values(&:size)
19
+ CHARS.keys.to_h { |status| [status, tallies.fetch(status, 0)] }
20
+ end
21
+
22
+ # `empty_plan: true` means a --since/--subject scope planned nothing: the
23
+ # count block still prints, but there is no score to report (#45).
24
+ def summary(results, invalid_count:, empty_plan: false)
25
+ counts = self.class.counts(results)
17
26
  @out.puts "", ""
18
- CHARS.each_key do |status|
19
- @out.puts "#{status}: #{counts.fetch(status, 0)}"
20
- end
27
+ counts.each { |status, count| @out.puts "#{status}: #{count}" }
21
28
  @out.puts "invalid (discarded): #{invalid_count}"
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?
29
+ @out.puts format("Mutation score: %.1f%%", score(counts) * 100) unless empty_plan
30
+ print_group("Surviving mutants:", results.select { |r| r.status == :survived })
31
+ print_group("Errored mutants (not detected):", results.select { |r| r.status == :error })
32
+ print_group("Timed-out mutants (counted as detected):", results.select { |r| r.status == :timeout })
25
33
  skipped = results.select { |r| r.status == :skipped }
26
34
  print_skipped(skipped) unless skipped.empty?
27
35
  stats = OperatorStats.call(results)
@@ -29,9 +37,12 @@ module ActiveMutator
29
37
  print_operator_stats(noisy) unless noisy.empty?
30
38
  end
31
39
 
40
+ # A timeout is a detection (the mutant changed behavior enough to hang,
41
+ # the same convention as Stryker and PIT). An error is a non-verdict:
42
+ # scoring it as a pass let a broken worker read as 100%.
32
43
  def self.score(counts)
33
44
  detected = counts.fetch(:killed, 0) + counts.fetch(:timeout, 0)
34
- denominator = detected + counts.fetch(:survived, 0)
45
+ denominator = detected + counts.fetch(:survived, 0) + counts.fetch(:error, 0)
35
46
  return 1.0 if denominator.zero?
36
47
 
37
48
  detected.to_f / denominator
@@ -49,9 +60,11 @@ module ActiveMutator
49
60
  end
50
61
  end
51
62
 
52
- def print_survivors(survivors)
53
- @out.puts "", "Surviving mutants:"
54
- survivors.each do |result|
63
+ def print_group(title, group)
64
+ return if group.empty?
65
+
66
+ @out.puts "", title
67
+ group.each do |result|
55
68
  m = result.mutation
56
69
  @out.puts "", " #{m.subject.name} (#{m.subject.file}:#{m.line})"
57
70
  @out.puts " #{m.description}"
@@ -2,6 +2,13 @@ require "json"
2
2
 
3
3
  module ActiveMutator
4
4
  class Runner
5
+ # What discovery saw, beyond the final subject list. `scanned_files` are
6
+ # root-relative source files after path expansion and excludes, minus
7
+ # spec_paths; `since_candidates` are the --since diff's files among them;
8
+ # `since_matched_all` are the since-covered subjects before --no-class-level
9
+ # drops class bodies. The last two feed the --allow-empty verdict (#46).
10
+ Discovery = Data.define(:subjects, :scanned_files, :since_candidates, :since_matched_all)
11
+
5
12
  def initialize(config, reporter: nil)
6
13
  @config = config
7
14
  @reporter = reporter || build_reporter
@@ -13,14 +20,24 @@ module ActiveMutator
13
20
  ClosureReload.cap = @config.class_level_closure_cap
14
21
  preload!
15
22
  preload_spec_helper!
16
- map = Baseline.new(root: @config.root, spec_paths: @config.spec_paths)
17
- .coverage_map(force: @config.force_baseline)
18
- @reporter.coverage_map = map if @reporter.respond_to?(:coverage_map=)
19
- subjects = discover_subjects
23
+ discovery = discover
24
+ subjects = discovery.subjects
20
25
  analyses = subjects.map { |s| Engine.new.analyze(s) }
21
26
  mutations = analyses.flat_map(&:mutations)
22
27
  mutations = mutations.first(@config.max_mutants) if @config.max_mutants
23
28
  invalid_count = analyses.sum(&:invalid_count)
29
+ # Decide emptiness before the baseline: a scoped run that plans nothing
30
+ # has no use for a coverage map, and building one spawns the whole spec
31
+ # suite (#47).
32
+ if mutations.empty? && (@config.since || @config.subject_filter)
33
+ return debug_plan([], []) if @config.debug_plan
34
+
35
+ return empty_plan_exit(invalid_count, discovery)
36
+ end
37
+
38
+ map = Baseline.new(root: @config.root, spec_paths: @config.spec_paths)
39
+ .coverage_map(force: @config.force_baseline)
40
+ @reporter.coverage_map = map if @reporter.respond_to?(:coverage_map=)
24
41
 
25
42
  fingerprints = Fingerprint.for_mutations(mutations, root: @config.root)
26
43
  ledger = AcceptedLedger.load(@config.root)
@@ -119,7 +136,7 @@ module ActiveMutator
119
136
  extra = items[r.mutation].example_ids.map { |id| BaselineDelta.spec_file_of(id) }.uniq.size
120
137
  replacement.with(details: "escalated (+#{extra} spec files)")
121
138
  else
122
- # A timeout/error/skip in phase 2 did NOT prove a kill the mutant
139
+ # A timeout/error/skip in phase 2 did NOT prove a kill; the mutant
123
140
  # already survived phase 1, so keep that verdict rather than letting
124
141
  # an inconclusive escalation inflate the score (a :timeout counts as
125
142
  # detected in exit_code/score).
@@ -129,17 +146,66 @@ module ActiveMutator
129
146
  end
130
147
 
131
148
  def exit_code(results)
132
- survived = results.count { |r| r.status == :survived }
133
- return 0 if survived.zero?
149
+ undetected = results.count { |r| %i[survived error].include?(r.status) }
150
+ return 0 if undetected.zero?
134
151
  return 1 unless @config.fail_at
135
152
 
136
153
  detected = results.count { |r| %i[killed timeout].include?(r.status) }
137
- score = detected * 100.0 / (detected + survived)
154
+ score = detected * 100.0 / (detected + undetected)
138
155
  score >= @config.fail_at ? 0 : 1
139
156
  end
140
157
 
141
158
  private
142
159
 
160
+ # A scoped run that plans nothing must not report "100%" and pass --fail-at:
161
+ # the usual cause is a --since range or --subject filter that matched no
162
+ # mutable code, or class-body code dropped by --no-class-level (#23 covers
163
+ # the zero-subject case for explicit paths).
164
+ def empty_plan_exit(invalid_count, discovery)
165
+ @reporter.summary([], invalid_count: invalid_count, empty_plan: true)
166
+ causes = []
167
+ causes << "--since #{@config.since} matched no mutable code" if @config.since
168
+ causes << "--subject #{@config.subject_filter} matched no subjects" if @config.subject_filter
169
+ causes << "--no-class-level excludes class-body code" unless @config.class_level
170
+ warn "active_mutator: no mutants planned (#{causes.join("; ")})"
171
+ unless @config.allow_empty
172
+ warn "active_mutator: exiting 1; pass --allow-empty if an empty plan is expected"
173
+ return 1
174
+ end
175
+
176
+ allow_empty_exit(discovery)
177
+ end
178
+
179
+ # --allow-empty forgives an empty plan only when the --since diff touched no
180
+ # candidate source file (docs-only, spec-only, excluded paths). A changed
181
+ # candidate that planned nothing is the case worth failing on, unless the
182
+ # only code it touched is class-body code that --no-class-level dropped.
183
+ # --subject alone has no diff to judge, so it stays an unconditional 0.
184
+ def allow_empty_exit(discovery)
185
+ return 0 unless @config.since
186
+
187
+ candidates = discovery.since_candidates
188
+ return 0 if candidates.empty?
189
+
190
+ if !@config.class_level && class_body_only?(discovery.since_matched_all, candidates)
191
+ warn "active_mutator: forgiving empty plan: changed lines are class-body code and --no-class-level is set"
192
+ return 0
193
+ end
194
+
195
+ warn "active_mutator: exiting 1; --allow-empty forgives an empty plan only when no candidate " \
196
+ "source file changed. Changed: #{candidates.join(", ")}"
197
+ 1
198
+ end
199
+
200
+ # Every matched subject is a class body AND every candidate file has one:
201
+ # a candidate that matched nothing (comment-only edit, deletion) must not
202
+ # hide behind a class-body change in a different file. Candidates are
203
+ # non-empty here, so no matches at all fails the second test on its own.
204
+ def class_body_only?(subjects, candidates)
205
+ matched_files = subjects.map { |s| relative(s.file) }
206
+ subjects.all?(&:class_body?) && candidates.all? { |file| matched_files.include?(file) }
207
+ end
208
+
143
209
  # Single source of truth for lane/timeout/variable derivation, shared by
144
210
  # phase-1 planning and phase-2 escalation so the two never drift.
145
211
  def build_work_item(mutation, example_ids, map)
@@ -233,23 +299,38 @@ module ActiveMutator
233
299
  end
234
300
  end
235
301
 
236
- def discover_subjects
302
+ def discover
237
303
  paths = @config.paths.empty? ? default_paths : @config.paths
238
- subjects = paths
304
+ files = paths
239
305
  .flat_map { |p| expand_path_arg(p) }
240
306
  .uniq
241
307
  .reject { |file| excluded?(file) }
242
- .sort.flat_map { |file| SubjectFinder.call(file) }
243
- subjects = subjects.reject(&:class_body?) unless @config.class_level
308
+ .sort
309
+ scanned_files = files.map { |f| relative(f) }.reject { |rel| under_spec_paths?(rel) }
310
+ subjects = files.flat_map { |file| SubjectFinder.call(file) }
244
311
  if @config.subject_filter
245
312
  matcher = SubjectMatcher.new(@config.subject_filter)
246
313
  subjects = subjects.select { |s| matcher.match?(s.name) }
247
314
  end
315
+ since_candidates = []
248
316
  if @config.since
249
317
  filter = SinceFilter.new(ref: @config.since, root: @config.root)
250
318
  subjects = subjects.select { |s| filter.cover?(s) }
319
+ since_candidates = filter.changed_files & scanned_files
251
320
  end
252
- subjects
321
+ # Class bodies drop out LAST so since_matched_all still knows about them.
322
+ since_matched_all = subjects
323
+ subjects = subjects.reject(&:class_body?) unless @config.class_level
324
+ Discovery.new(subjects: subjects, scanned_files: scanned_files,
325
+ since_candidates: since_candidates, since_matched_all: since_matched_all)
326
+ end
327
+
328
+ def relative(file) = file.delete_prefix(@config.root.chomp("/") + "/")
329
+
330
+ # Scanned entries are files, so only the "inside this directory" test
331
+ # matters; the trailing slash keeps `spec` from swallowing `spec_tools/`.
332
+ def under_spec_paths?(rel)
333
+ @config.spec_paths.any? { |sp| rel.start_with?("#{sp.chomp("/")}/") }
253
334
  end
254
335
 
255
336
  # Positional args may be files or directories. Anything else is an error:
@@ -329,7 +410,7 @@ module ActiveMutator
329
410
  # MAINTENANCE: any future flag that narrows the mutant set below "every
330
411
  # subject in the scanned files" MUST be added to this nil-trigger list,
331
412
  # or scoped accept runs will clobber out-of-scope ledger entries (#24).
332
- # --no-class-level drops every class_body subject (discover_subjects), so a
413
+ # --no-class-level drops every class_body subject (discover), so a
333
414
  # file's class-body fingerprint is absent even though the file is scanned;
334
415
  # without this guard its accepted ledger entry looks stale and gets pruned.
335
416
  def prune_scope(subjects)
@@ -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
@@ -20,6 +20,15 @@ module ActiveMutator
20
20
  changed.reject { |_, lines| lines.empty? }
21
21
  end
22
22
 
23
+ # Every file the diff touched on the new side, including deletion-only
24
+ # files that `parse` drops (they add no lines, so nothing to cover, but
25
+ # the file still changed and must count as a --since candidate).
26
+ def self.touched_files(diff_text)
27
+ diff_text.each_line.filter_map do |line|
28
+ line.delete_prefix("+++ b/").strip if line.start_with?("+++ b/")
29
+ end.uniq
30
+ end
31
+
23
32
  def initialize(ref:, root:)
24
33
  @root = root
25
34
  diff = IO.popen(
@@ -28,15 +37,22 @@ module ActiveMutator
28
37
  raise Error, "git diff #{ref} failed" unless $?.success?
29
38
 
30
39
  @changed = self.class.parse(diff)
40
+ @touched = self.class.touched_files(diff)
31
41
  untracked = IO.popen(
32
42
  ["git", "-C", root, "ls-files", "--others", "--exclude-standard", "--", "*.rb"], &:read
33
43
  )
34
44
  # Untracked files are invisible to `git diff` but are agentic TDD's most
35
45
  # common case (brand-new file + spec). Whole-file sentinel: every line
36
46
  # counts as changed.
37
- untracked.each_line { |l| @changed[l.strip] = :all unless l.strip.empty? }
47
+ untracked.split("\n").each { |l| @changed[l] = :all }
48
+ @touched |= @changed.keys
38
49
  end
39
50
 
51
+ # Root-relative paths of every .rb file the diff touched (tracked files,
52
+ # including deletion-only ones, plus untracked files). Pure accessor: no
53
+ # further git calls.
54
+ def changed_files = @touched
55
+
40
56
  def cover?(subject)
41
57
  lines = @changed[subject.file.delete_prefix("#{@root}/")]
42
58
  return false unless lines
@@ -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.6.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.6.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: []