active_mutator 0.1.1 → 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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +156 -13
  3. data/lib/active_mutator/accepted_ledger.rb +22 -7
  4. data/lib/active_mutator/baseline_delta.rb +78 -1
  5. data/lib/active_mutator/class_shape.rb +47 -0
  6. data/lib/active_mutator/cli.rb +17 -4
  7. data/lib/active_mutator/closure_reload.rb +202 -0
  8. data/lib/active_mutator/config.rb +3 -1
  9. data/lib/active_mutator/config_file.rb +92 -0
  10. data/lib/active_mutator/defined_constants.rb +48 -0
  11. data/lib/active_mutator/edit.rb +8 -2
  12. data/lib/active_mutator/engine.rb +121 -7
  13. data/lib/active_mutator/inserter.rb +6 -3
  14. data/lib/active_mutator/operators/base.rb +2 -1
  15. data/lib/active_mutator/operators/call_swap.rb +16 -0
  16. data/lib/active_mutator/operators/literal.rb +14 -2
  17. data/lib/active_mutator/reporter/github.rb +36 -0
  18. data/lib/active_mutator/reporter/json.rb +1 -0
  19. data/lib/active_mutator/reporter/operator_stats.rb +20 -0
  20. data/lib/active_mutator/reporter/stryker_json.rb +128 -0
  21. data/lib/active_mutator/reporter/terminal.rb +24 -1
  22. data/lib/active_mutator/result.rb +1 -1
  23. data/lib/active_mutator/runner.rb +239 -19
  24. data/lib/active_mutator/scheduler.rb +41 -5
  25. data/lib/active_mutator/source_location.rb +21 -0
  26. data/lib/active_mutator/subject.rb +13 -3
  27. data/lib/active_mutator/subject_finder.rb +89 -9
  28. data/lib/active_mutator/subject_matcher.rb +23 -0
  29. data/lib/active_mutator/timeout_calibrator.rb +75 -0
  30. data/lib/active_mutator/version.rb +1 -1
  31. data/lib/active_mutator/work_item.rb +8 -1
  32. data/lib/active_mutator/worker.rb +53 -8
  33. data/lib/active_mutator.rb +10 -0
  34. metadata +20 -5
@@ -0,0 +1,20 @@
1
+ module ActiveMutator
2
+ module Reporter
3
+ # Per-operator noise signal (issue #20): equivalent_rate =
4
+ # survived / (killed + survived). Every :survived result is covered by
5
+ # construction (uncovered mutants never reach the scheduler), so this is
6
+ # the covered-survivor rate. It deliberately conflates true equivalents
7
+ # with weak assertions — it is an aggregate signal, not a score.
8
+ module OperatorStats
9
+ def self.call(results)
10
+ results.group_by { |r| r.mutation.edit.operator }.to_h do |operator, group|
11
+ killed = group.count { |r| r.status == :killed }
12
+ survived = group.count { |r| r.status == :survived }
13
+ denominator = killed + survived
14
+ rate = denominator.zero? ? 0.0 : (survived.to_f / denominator).round(3)
15
+ [operator, { "killed" => killed, "survived" => survived, "equivalent_rate" => rate }]
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,128 @@
1
+ require "json"
2
+
3
+ module ActiveMutator
4
+ module Reporter
5
+ # mutation-testing-report-schema v2 (the Stryker ecosystem format).
6
+ # Load the written file in https://microsoft.github.io/mutation-testing-elements/
7
+ # for the interactive per-file mutant viewer.
8
+ #
9
+ # Schema constraints honored here: 1-based positive line/column, integer
10
+ # thresholds, tool-specific data only under config.active_mutator.
11
+ # Invalid mutants are discarded before results exist, so they appear as a
12
+ # count in the extras, never as CompileError mutants.
13
+ class StrykerJson
14
+ SCHEMA_URL = "https://git.io/mutation-testing-schema"
15
+ STATUS = { killed: "Killed", survived: "Survived", timeout: "Timeout",
16
+ error: "RuntimeError", uncovered: "NoCoverage", accepted: "Ignored",
17
+ skipped: "Ignored" }.freeze
18
+ ACCEPTED_REASON = "Accepted as equivalent in #{AcceptedLedger::FILENAME}".freeze
19
+ REPORT_PATH = File.join(".active_mutator", "mutation-report.json")
20
+
21
+ # Injected by Runner once the baseline map exists; nil in unit tests.
22
+ attr_writer :coverage_map
23
+
24
+ def initialize(root:, out: $stdout)
25
+ @root = root
26
+ @out = out
27
+ @coverage_map = nil
28
+ end
29
+
30
+ def on_result(result)
31
+ @out.print(Terminal::CHARS.fetch(result.status))
32
+ end
33
+
34
+ def summary(results, invalid_count:)
35
+ report = build_report(results, invalid_count)
36
+ path = File.join(@root, REPORT_PATH)
37
+ AtomicFile.write(path, JSON.pretty_generate(report))
38
+ @out.puts "", "", "Stryker report written to #{REPORT_PATH}"
39
+ end
40
+
41
+ private
42
+
43
+ def build_report(results, invalid_count)
44
+ mutants_by_file = results.group_by { |r| r.mutation.subject.file }
45
+ report = {
46
+ "$schema" => SCHEMA_URL,
47
+ "schemaVersion" => "2",
48
+ "thresholds" => { "high" => 80, "low" => 60 },
49
+ "projectRoot" => @root,
50
+ "config" => { "active_mutator" => { "invalid_discarded" => invalid_count,
51
+ "version" => VERSION } },
52
+ "files" => mutants_by_file.to_h { |file, rs| [relative(file), file_entry(file, rs)] }
53
+ }
54
+ tests = referenced_examples(results)
55
+ report["testFiles"] = test_files(tests) unless tests.empty?
56
+ report
57
+ end
58
+
59
+ def file_entry(file, results)
60
+ source = File.read(file)
61
+ { "language" => "ruby", "source" => source,
62
+ "mutants" => results.map { |r| mutant(r, source) } }
63
+ end
64
+
65
+ def mutant(result, source)
66
+ loc = SourceLocation.locate(source, result.mutation.edit.range)
67
+ entry = {
68
+ "id" => next_id,
69
+ "mutatorName" => result.mutation.edit.operator,
70
+ "location" => { "start" => stringify(loc[:start]), "end" => stringify(loc[:end]) },
71
+ "status" => STATUS.fetch(result.status),
72
+ "replacement" => result.mutation.edit.replacement,
73
+ "description" => result.mutation.description
74
+ }
75
+ reason = status_reason(result)
76
+ entry["statusReason"] = reason if reason
77
+ covered = covered_by(result)
78
+ entry["coveredBy"] = covered if covered
79
+ entry
80
+ end
81
+
82
+ def status_reason(result)
83
+ return ACCEPTED_REASON if result.status == :accepted
84
+
85
+ result.details&.to_s
86
+ end
87
+
88
+ def covered_by(result)
89
+ return nil unless @coverage_map
90
+
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)
102
+ end
103
+
104
+ def referenced_examples(results)
105
+ results.flat_map { |r| covered_by(r) || [] }.uniq.sort
106
+ end
107
+
108
+ # Group example ids by spec path (the id up to the trailing "[...]")
109
+ # so the viewer's test panel resolves coveredBy references.
110
+ def test_files(example_ids)
111
+ example_ids
112
+ .group_by { |id| id.sub(%r{\A\./}, "").sub(/\[.*\]\z/, "") }
113
+ .transform_values do |ids|
114
+ { "tests" => ids.map { |id| { "id" => id, "name" => id } } }
115
+ end
116
+ end
117
+
118
+ def next_id
119
+ @next_id = (@next_id || -1) + 1
120
+ @next_id.to_s
121
+ end
122
+
123
+ def stringify(position) = { "line" => position[:line], "column" => position[:column] }
124
+
125
+ def relative(file) = file.delete_prefix(@root.chomp("/") + "/")
126
+ end
127
+ end
128
+ end
@@ -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,11 @@ 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?
27
+ stats = OperatorStats.call(results)
28
+ noisy = stats.select { |_, s| s["survived"].positive? }
29
+ print_operator_stats(noisy) unless noisy.empty?
24
30
  end
25
31
 
26
32
  def self.score(counts)
@@ -35,6 +41,14 @@ module ActiveMutator
35
41
 
36
42
  def score(counts) = self.class.score(counts)
37
43
 
44
+ def print_operator_stats(stats)
45
+ @out.puts "", "Equivalent-rate by operator (survived / (killed + survived)):"
46
+ stats.sort_by { |_, s| -s["equivalent_rate"] }.each do |operator, s|
47
+ @out.puts format(" %-24s %5.1f%% (%d survived / %d killed)",
48
+ operator, s["equivalent_rate"] * 100, s["survived"], s["killed"])
49
+ end
50
+ end
51
+
38
52
  def print_survivors(survivors)
39
53
  @out.puts "", "Surviving mutants:"
40
54
  survivors.each do |result|
@@ -43,6 +57,15 @@ module ActiveMutator
43
57
  @out.puts " #{m.description}"
44
58
  @out.puts " - #{m.original_snippet}"
45
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}"
46
69
  end
47
70
  end
48
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
@@ -1,3 +1,5 @@
1
+ require "json"
2
+
1
3
  module ActiveMutator
2
4
  class Runner
3
5
  def initialize(config, reporter: nil)
@@ -7,30 +9,45 @@ module ActiveMutator
7
9
 
8
10
  def call
9
11
  ENV["ACTIVE_MUTATOR"] = "1"
12
+ load_operators
13
+ ClosureReload.cap = @config.class_level_closure_cap
10
14
  preload!
11
15
  preload_spec_helper!
12
16
  map = Baseline.new(root: @config.root).coverage_map(force: @config.force_baseline)
17
+ @reporter.coverage_map = map if @reporter.respond_to?(:coverage_map=)
13
18
  subjects = discover_subjects
14
19
  analyses = subjects.map { |s| Engine.new.analyze(s) }
15
20
  mutations = analyses.flat_map(&:mutations)
21
+ mutations = mutations.first(@config.max_mutants) if @config.max_mutants
16
22
  invalid_count = analyses.sum(&:invalid_count)
17
23
 
18
24
  fingerprints = Fingerprint.for_mutations(mutations, root: @config.root)
19
25
  ledger = AcceptedLedger.load(@config.root)
20
- warn_stale(ledger, fingerprints.values)
26
+ scanned_files = prune_scope(subjects)
27
+ warn_stale(ledger, fingerprints.values, scanned_files)
28
+
29
+ items, pre_results, phase1_ids = plan_work(mutations, map, ledger: ledger, fingerprints: fingerprints)
30
+ return debug_plan(items, pre_results) if @config.debug_plan
21
31
 
22
- items, pre_results = plan_work(mutations, map, ledger: ledger, fingerprints: fingerprints)
23
32
  pre_results.each { |r| @reporter.on_result(r) }
24
- scheduler = Scheduler.new(jobs: @config.jobs, on_result: @reporter.method(:on_result))
33
+ calibrators = if @config.adaptive_timeout
34
+ { parallel: TimeoutCalibrator.new, serial: TimeoutCalibrator.new }
35
+ end
36
+ scheduler = Scheduler.new(jobs: @config.jobs, on_result: @reporter.method(:on_result),
37
+ calibrators: calibrators)
25
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)
26
41
 
27
- accept_survivors!(ledger, results, fingerprints) if @config.accept_survivors
42
+ accept_survivors!(ledger, results, fingerprints, scanned_files) if @config.accept_survivors
28
43
 
29
44
  @reporter.summary(results, invalid_count: invalid_count)
30
45
  exit_code(results)
31
46
  end
32
47
 
33
- # 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.
34
51
  def plan_work(mutations, map, ledger: nil, fingerprints: {})
35
52
  items = []
36
53
  pre_results = []
@@ -39,27 +56,167 @@ module ActiveMutator
39
56
  pre_results << Result.new(mutation: mutation, status: :accepted, details: nil)
40
57
  next
41
58
  end
42
- example_ids = map.examples_for(mutation.subject.file, mutation.lines)
59
+ example_ids = examples_for_mutation(mutation, map)
43
60
  if example_ids.empty?
44
61
  pre_results << Result.new(mutation: mutation, status: :uncovered, details: nil)
45
62
  else
46
- lane = example_ids.any? { |id| serial_example?(id) } ? :serial : :parallel
47
- timeout = map.time_for(example_ids) * @config.timeout_factor + @config.timeout_floor
48
- timeout += @config.browser_boot_seconds if lane == :serial
49
- items << WorkItem.new(mutation: mutation, example_ids: example_ids, timeout: timeout, lane: lane)
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
50
126
  end
51
127
  end
52
- [items, pre_results]
53
128
  end
54
129
 
55
130
  def exit_code(results)
56
- results.any? { |r| r.status == :survived } ? 1 : 0
131
+ survived = results.count { |r| r.status == :survived }
132
+ return 0 if survived.zero?
133
+ return 1 unless @config.fail_at
134
+
135
+ detected = results.count { |r| %i[killed timeout].include?(r.status) }
136
+ score = detected * 100.0 / (detected + survived)
137
+ score >= @config.fail_at ? 0 : 1
57
138
  end
58
139
 
59
140
  private
60
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
+
175
+ # Custom operators must exist in the PARENT before Engine analysis:
176
+ # subclassing Operators::Base self-registers, and forks inherit the
177
+ # loaded class. `requires` can't serve — those load inside the fork's
178
+ # setup, after mutations are already planned.
179
+ def load_operators
180
+ @config.operators.each do |f|
181
+ require File.expand_path(f, @config.root)
182
+ rescue LoadError, SyntaxError => e
183
+ raise Error, "operator file not loadable: #{f}: #{e.message}"
184
+ end
185
+ end
186
+
187
+ # Line coverage attributes multi-line expressions to their statement anchor
188
+ # line (version-dependently), so a sub-expression mutant's own lines may
189
+ # carry no coverage at all. Look up the whole subject instead: a mutant must
190
+ # run against every example covering any line of its method.
191
+ def coverage_lines(mutation)
192
+ mutation.lines.to_a | mutation.subject.line_range.to_a
193
+ end
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
+
61
213
  def build_reporter
62
- @config.format == :json ? Reporter::Json.new : Reporter::Terminal.new
214
+ case @config.format
215
+ when :json then Reporter::Json.new
216
+ when :stryker_json then Reporter::StrykerJson.new(root: @config.root)
217
+ when :github then Reporter::Github.new(root: @config.root)
218
+ else Reporter::Terminal.new
219
+ end
63
220
  end
64
221
 
65
222
  def preload!
@@ -77,9 +234,15 @@ module ActiveMutator
77
234
  def discover_subjects
78
235
  paths = @config.paths.empty? ? default_paths : @config.paths
79
236
  subjects = paths
80
- .flat_map { |p| Dir[File.join(@config.root, p, "**", "*.rb")] }
237
+ .flat_map { |p| expand_path_arg(p) }
238
+ .uniq
239
+ .reject { |file| excluded?(file) }
81
240
  .sort.flat_map { |file| SubjectFinder.call(file) }
82
- subjects = subjects.select { |s| s.name == @config.subject_filter } if @config.subject_filter
241
+ subjects = subjects.reject(&:class_body?) unless @config.class_level
242
+ if @config.subject_filter
243
+ matcher = SubjectMatcher.new(@config.subject_filter)
244
+ subjects = subjects.select { |s| matcher.match?(s.name) }
245
+ end
83
246
  if @config.since
84
247
  filter = SinceFilter.new(ref: @config.since, root: @config.root)
85
248
  subjects = subjects.select { |s| filter.cover?(s) }
@@ -87,6 +250,34 @@ module ActiveMutator
87
250
  subjects
88
251
  end
89
252
 
253
+ # Positional args may be files or directories. Anything else is an error:
254
+ # a mistyped path that silently matched nothing produced a false green
255
+ # (0 subjects, exit 0) — see #23.
256
+ def expand_path_arg(path)
257
+ full = File.expand_path(path, @config.root)
258
+ if File.file?(full)
259
+ raise Error, "not a Ruby file: #{path}" unless full.end_with?(".rb")
260
+
261
+ [full]
262
+ elsif Dir.exist?(full)
263
+ Dir[File.join(full, "**", "*.rb")]
264
+ else
265
+ raise Error, "no such file or directory: #{path}"
266
+ end
267
+ end
268
+
269
+ def excluded?(file)
270
+ flags = File::FNM_PATHNAME | File::FNM_EXTGLOB
271
+ relative = file.delete_prefix(@config.root.chomp("/") + "/")
272
+ @config.exclude.any? do |pattern|
273
+ # Gitignore-like ergonomics: "lib/gen", "lib/gen/" and "lib/gen/**"
274
+ # all exclude the whole subtree, not just direct children.
275
+ dir = pattern.sub(%r{(/\*\*)?/?\z}, "")
276
+ File.fnmatch?(pattern, relative, flags) ||
277
+ File.fnmatch?("#{dir}/**/*", relative, flags)
278
+ end
279
+ end
280
+
90
281
  def default_paths
91
282
  %w[app lib].select { |p| Dir.exist?(File.join(@config.root, p)) }
92
283
  end
@@ -127,17 +318,46 @@ module ActiveMutator
127
318
  SimpleCov.at_exit {} if defined?(SimpleCov)
128
319
  end
129
320
 
130
- def accept_survivors!(ledger, results, fingerprints)
321
+ # Only a run with no subject-level narrowing has fully scanned a file;
322
+ # anything narrower must not prune (or warn about) out-of-scope entries.
323
+ # MAINTENANCE: any future flag that narrows the mutant set below "every
324
+ # subject in the scanned files" MUST be added to this nil-trigger list,
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.
329
+ def prune_scope(subjects)
330
+ return nil if @config.subject_filter || @config.since || @config.max_mutants || !@config.class_level
331
+
332
+ subjects.map { |s| s.file.delete_prefix("#{@config.root}/") }.uniq
333
+ end
334
+
335
+ def accept_survivors!(ledger, results, fingerprints, scanned_files)
131
336
  survivors = results.select { |r| r.status == :survived }.map { |r| fingerprints[r.mutation] }
132
337
  return if survivors.empty?
133
338
 
134
- ledger.accept!(survivors, fingerprints.values)
339
+ ledger.accept!(survivors, fingerprints.values, scanned_files: scanned_files)
135
340
  end
136
341
 
137
- def warn_stale(ledger, all_fingerprints)
138
- ledger.stale_entries(all_fingerprints).each do |entry|
342
+ def debug_plan(items, pre_results)
343
+ plan = items.map do |i|
344
+ { "subject" => i.mutation.subject.name, "description" => i.mutation.description,
345
+ "file" => i.mutation.subject.file, "line" => i.mutation.line,
346
+ "lane" => i.lane.to_s, "timeout" => i.timeout.round(2),
347
+ "examples" => i.example_ids.size }
348
+ end
349
+ skipped = pre_results.group_by { |r| r.status.to_s }.transform_values(&:size)
350
+ puts JSON.pretty_generate("planned" => plan, "pre_resolved" => skipped)
351
+ 0
352
+ end
353
+
354
+ def warn_stale(ledger, all_fingerprints, scanned_files)
355
+ ledger.stale_entries(all_fingerprints, scanned_files: scanned_files).each do |entry|
139
356
  warn "active_mutator: stale accepted fingerprint (no matching mutant): #{entry.subject}, #{entry.description}"
140
357
  end
358
+ ledger.missing_file_entries(@config.root).each do |entry|
359
+ warn "active_mutator: accepted fingerprint references missing file: #{entry.file} (#{entry.subject})"
360
+ end
141
361
  end
142
362
  end
143
363
  end
@@ -8,11 +8,13 @@ module ActiveMutator
8
8
  OrphanedError = Class.new(Error)
9
9
 
10
10
  def initialize(jobs:, worker: Worker.method(:run), on_result: nil,
11
- orphaned: -> { Process.ppid == 1 })
11
+ calibrators: nil, orphaned: -> { Process.ppid == 1 })
12
12
  @jobs = jobs
13
13
  @worker = worker
14
14
  @on_result = on_result
15
+ @calibrators = calibrators
15
16
  @orphaned = orphaned
17
+ @last_logged_scale = {} # lane => last scale logged for that lane
16
18
  end
17
19
 
18
20
  def run(items)
@@ -72,7 +74,12 @@ module ActiveMutator
72
74
  Process.exit!(0)
73
75
  end
74
76
  writer.close
75
- running[pid] = { reader: reader, item: item, deadline: now + item.timeout }
77
+ calibrator = calibrator_for(item)
78
+ budget = calibrator ? calibrator.budget_for(item) : item.timeout
79
+ log_scale(calibrator, item.lane)
80
+ started = now
81
+ running[pid] = { reader: reader, item: item, started: started,
82
+ budget: budget, deadline: started + budget }
76
83
  end
77
84
 
78
85
  def reap(running, results)
@@ -80,7 +87,11 @@ module ActiveMutator
80
87
  done, _status = Process.waitpid2(pid, Process::WNOHANG)
81
88
  if done
82
89
  running.delete(pid)
83
- results << finish(entry)
90
+ result = finish(entry)
91
+ if result.status == :killed
92
+ calibrator_for(entry[:item])&.record(now - entry[:started], entry[:budget])
93
+ end
94
+ results << result
84
95
  elsif now > entry[:deadline]
85
96
  kill(pid)
86
97
  running.delete(pid)
@@ -94,8 +105,16 @@ module ActiveMutator
94
105
  payload = entry[:reader].read.to_s
95
106
  entry[:reader].close
96
107
  data = payload.empty? ? nil : JSON.parse(payload)
97
- status = data ? data.fetch("status").to_sym : :error
98
- details = data ? data["details"] : "worker exited without reporting"
108
+ # A self-mutation of Worker#emit can produce well-formed JSON without a
109
+ # "status" key (or with a non-Hash root); treat any unusable payload as
110
+ # a worker error instead of crashing the whole run.
111
+ reported = data.is_a?(Hash) && data.key?("status")
112
+ status = reported ? data["status"].to_sym : :error
113
+ details = reported ? data["details"] : "worker exited without reporting"
114
+ rescue JSON::ParserError
115
+ report(Result.new(mutation: entry[:item].mutation, status: :error,
116
+ details: "worker emitted unparseable payload"))
117
+ else
99
118
  report(Result.new(mutation: entry[:item].mutation, status: status, details: details))
100
119
  end
101
120
 
@@ -141,6 +160,23 @@ module ActiveMutator
141
160
  previous.each { |sig, handler| trap(sig, handler || "DEFAULT") }
142
161
  end
143
162
 
163
+ def calibrator_for(item)
164
+ @calibrators && @calibrators[item.lane]
165
+ end
166
+
167
+ # Effective budgets are otherwise invisible (--debug-plan shows static
168
+ # ones by design). One stderr line per scale CHANGE per lane, not per
169
+ # spawn — the two lanes calibrate independently, so the lane is named.
170
+ def log_scale(calibrator, lane)
171
+ return unless calibrator&.warmed?
172
+
173
+ scale = calibrator.scale.round(2)
174
+ return if scale == @last_logged_scale[lane]
175
+
176
+ @last_logged_scale[lane] = scale
177
+ warn "active_mutator: adaptive timeout scale (#{lane}): #{scale}"
178
+ end
179
+
144
180
  def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
145
181
  end
146
182
  end
@@ -0,0 +1,21 @@
1
+ module ActiveMutator
2
+ # 1-based line/column (never 0 — the Stryker schema rejects 0) for an
3
+ # exclusive byte range within a source string.
4
+ module SourceLocation
5
+ def self.locate(source, byte_range)
6
+ {
7
+ start: position(source, byte_range.begin),
8
+ end: position(source, byte_range.end)
9
+ }
10
+ end
11
+
12
+ def self.position(source, offset)
13
+ prefix = source.byteslice(0, offset)
14
+ last_newline = prefix.rindex("\n")
15
+ {
16
+ line: prefix.count("\n") + 1,
17
+ column: offset - (last_newline ? last_newline + 1 : 0) + 1
18
+ }
19
+ end
20
+ end
21
+ end
@@ -1,7 +1,17 @@
1
1
  module ActiveMutator
2
- # A mutable unit: one method definition.
3
- # byte_range/line_range cover the whole `def ... end`.
4
- Subject = Data.define(:name, :file, :byte_range, :line_range, :constant_scope, :kind) do
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).
6
+ # sclass: def lives inside `class << self` — its source slice is `def foo`,
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)
10
+ super
11
+ end
12
+
5
13
  def singleton? = kind == :singleton
14
+
15
+ def class_body? = kind == :class_body
6
16
  end
7
17
  end