active_mutator 0.1.0 → 0.2.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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +160 -63
  3. data/lib/active_mutator/accepted_ledger.rb +23 -8
  4. data/lib/active_mutator/atomic_file.rb +1 -1
  5. data/lib/active_mutator/baseline.rb +13 -4
  6. data/lib/active_mutator/baseline_delta.rb +67 -1
  7. data/lib/active_mutator/baseline_hooks.rb +2 -2
  8. data/lib/active_mutator/cli.rb +16 -4
  9. data/lib/active_mutator/config.rb +2 -1
  10. data/lib/active_mutator/config_file.rb +89 -0
  11. data/lib/active_mutator/coverage_map.rb +1 -1
  12. data/lib/active_mutator/defined_constants.rb +48 -0
  13. data/lib/active_mutator/edit.rb +8 -2
  14. data/lib/active_mutator/engine.rb +15 -2
  15. data/lib/active_mutator/fingerprint.rb +1 -1
  16. data/lib/active_mutator/inserter.rb +6 -3
  17. data/lib/active_mutator/operators/base.rb +2 -1
  18. data/lib/active_mutator/operators/call_swap.rb +16 -0
  19. data/lib/active_mutator/operators/literal.rb +14 -2
  20. data/lib/active_mutator/reporter/github.rb +36 -0
  21. data/lib/active_mutator/reporter/json.rb +1 -0
  22. data/lib/active_mutator/reporter/operator_stats.rb +20 -0
  23. data/lib/active_mutator/reporter/stryker_json.rb +117 -0
  24. data/lib/active_mutator/reporter/terminal.rb +11 -0
  25. data/lib/active_mutator/runner.rb +121 -17
  26. data/lib/active_mutator/scheduler.rb +64 -7
  27. data/lib/active_mutator/source_location.rb +21 -0
  28. data/lib/active_mutator/subject.rb +7 -1
  29. data/lib/active_mutator/subject_finder.rb +46 -7
  30. data/lib/active_mutator/subject_matcher.rb +23 -0
  31. data/lib/active_mutator/timeout_calibrator.rb +75 -0
  32. data/lib/active_mutator/version.rb +1 -1
  33. data/lib/active_mutator/work_item.rb +8 -1
  34. data/lib/active_mutator/worker.rb +5 -2
  35. data/lib/active_mutator.rb +8 -0
  36. metadata +13 -5
@@ -1,3 +1,5 @@
1
+ require "json"
2
+
1
3
  module ActiveMutator
2
4
  class Runner
3
5
  def initialize(config, reporter: nil)
@@ -7,24 +9,34 @@ module ActiveMutator
7
9
 
8
10
  def call
9
11
  ENV["ACTIVE_MUTATOR"] = "1"
12
+ load_operators
10
13
  preload!
11
14
  preload_spec_helper!
12
15
  map = Baseline.new(root: @config.root).coverage_map(force: @config.force_baseline)
16
+ @reporter.coverage_map = map if @reporter.respond_to?(:coverage_map=)
13
17
  subjects = discover_subjects
14
18
  analyses = subjects.map { |s| Engine.new.analyze(s) }
15
19
  mutations = analyses.flat_map(&:mutations)
20
+ mutations = mutations.first(@config.max_mutants) if @config.max_mutants
16
21
  invalid_count = analyses.sum(&:invalid_count)
17
22
 
18
23
  fingerprints = Fingerprint.for_mutations(mutations, root: @config.root)
19
24
  ledger = AcceptedLedger.load(@config.root)
20
- warn_stale(ledger, fingerprints.values)
25
+ scanned_files = prune_scope(subjects)
26
+ warn_stale(ledger, fingerprints.values, scanned_files)
21
27
 
22
28
  items, pre_results = plan_work(mutations, map, ledger: ledger, fingerprints: fingerprints)
29
+ return debug_plan(items, pre_results) if @config.debug_plan
30
+
23
31
  pre_results.each { |r| @reporter.on_result(r) }
24
- scheduler = Scheduler.new(jobs: @config.jobs, on_result: @reporter.method(:on_result))
32
+ calibrators = if @config.adaptive_timeout
33
+ { parallel: TimeoutCalibrator.new, serial: TimeoutCalibrator.new }
34
+ end
35
+ scheduler = Scheduler.new(jobs: @config.jobs, on_result: @reporter.method(:on_result),
36
+ calibrators: calibrators)
25
37
  results = scheduler.run(items) + pre_results
26
38
 
27
- accept_survivors!(ledger, results, fingerprints) if @config.accept_survivors
39
+ accept_survivors!(ledger, results, fingerprints, scanned_files) if @config.accept_survivors
28
40
 
29
41
  @reporter.summary(results, invalid_count: invalid_count)
30
42
  exit_code(results)
@@ -39,27 +51,60 @@ module ActiveMutator
39
51
  pre_results << Result.new(mutation: mutation, status: :accepted, details: nil)
40
52
  next
41
53
  end
42
- example_ids = map.examples_for(mutation.subject.file, mutation.lines)
54
+ example_ids = map.examples_for(mutation.subject.file, coverage_lines(mutation))
43
55
  if example_ids.empty?
44
56
  pre_results << Result.new(mutation: mutation, status: :uncovered, details: nil)
45
57
  else
46
58
  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)
59
+ variable = map.time_for(example_ids) * @config.timeout_factor
60
+ boot_extra = lane == :serial ? @config.browser_boot_seconds : 0.0
61
+ timeout = variable + @config.timeout_floor + boot_extra
62
+ items << WorkItem.new(mutation: mutation, example_ids: example_ids,
63
+ timeout: timeout, lane: lane, variable: variable)
50
64
  end
51
65
  end
52
66
  [items, pre_results]
53
67
  end
54
68
 
55
69
  def exit_code(results)
56
- results.any? { |r| r.status == :survived } ? 1 : 0
70
+ survived = results.count { |r| r.status == :survived }
71
+ return 0 if survived.zero?
72
+ return 1 unless @config.fail_at
73
+
74
+ detected = results.count { |r| %i[killed timeout].include?(r.status) }
75
+ score = detected * 100.0 / (detected + survived)
76
+ score >= @config.fail_at ? 0 : 1
57
77
  end
58
78
 
59
79
  private
60
80
 
81
+ # Custom operators must exist in the PARENT before Engine analysis:
82
+ # subclassing Operators::Base self-registers, and forks inherit the
83
+ # loaded class. `requires` can't serve — those load inside the fork's
84
+ # setup, after mutations are already planned.
85
+ def load_operators
86
+ @config.operator_paths.each do |f|
87
+ require File.expand_path(f, @config.root)
88
+ rescue LoadError, SyntaxError => e
89
+ raise Error, "operator file not loadable: #{f}: #{e.message}"
90
+ end
91
+ end
92
+
93
+ # Line coverage attributes multi-line expressions to their statement anchor
94
+ # line (version-dependently), so a sub-expression mutant's own lines may
95
+ # carry no coverage at all. Look up the whole subject instead: a mutant must
96
+ # run against every example covering any line of its method.
97
+ def coverage_lines(mutation)
98
+ mutation.lines.to_a | mutation.subject.line_range.to_a
99
+ end
100
+
61
101
  def build_reporter
62
- @config.format == :json ? Reporter::Json.new : Reporter::Terminal.new
102
+ case @config.format
103
+ when :json then Reporter::Json.new
104
+ when :stryker_json then Reporter::StrykerJson.new(root: @config.root)
105
+ when :github then Reporter::Github.new(root: @config.root)
106
+ else Reporter::Terminal.new
107
+ end
63
108
  end
64
109
 
65
110
  def preload!
@@ -77,9 +122,14 @@ module ActiveMutator
77
122
  def discover_subjects
78
123
  paths = @config.paths.empty? ? default_paths : @config.paths
79
124
  subjects = paths
80
- .flat_map { |p| Dir[File.join(@config.root, p, "**", "*.rb")] }
125
+ .flat_map { |p| expand_path_arg(p) }
126
+ .uniq
127
+ .reject { |file| excluded?(file) }
81
128
  .sort.flat_map { |file| SubjectFinder.call(file) }
82
- subjects = subjects.select { |s| s.name == @config.subject_filter } if @config.subject_filter
129
+ if @config.subject_filter
130
+ matcher = SubjectMatcher.new(@config.subject_filter)
131
+ subjects = subjects.select { |s| matcher.match?(s.name) }
132
+ end
83
133
  if @config.since
84
134
  filter = SinceFilter.new(ref: @config.since, root: @config.root)
85
135
  subjects = subjects.select { |s| filter.cover?(s) }
@@ -87,6 +137,34 @@ module ActiveMutator
87
137
  subjects
88
138
  end
89
139
 
140
+ # Positional args may be files or directories. Anything else is an error:
141
+ # a mistyped path that silently matched nothing produced a false green
142
+ # (0 subjects, exit 0) — see #23.
143
+ def expand_path_arg(path)
144
+ full = File.expand_path(path, @config.root)
145
+ if File.file?(full)
146
+ raise Error, "not a Ruby file: #{path}" unless full.end_with?(".rb")
147
+
148
+ [full]
149
+ elsif Dir.exist?(full)
150
+ Dir[File.join(full, "**", "*.rb")]
151
+ else
152
+ raise Error, "no such file or directory: #{path}"
153
+ end
154
+ end
155
+
156
+ def excluded?(file)
157
+ flags = File::FNM_PATHNAME | File::FNM_EXTGLOB
158
+ relative = file.delete_prefix(@config.root.chomp("/") + "/")
159
+ @config.exclude.any? do |pattern|
160
+ # Gitignore-like ergonomics: "lib/gen", "lib/gen/" and "lib/gen/**"
161
+ # all exclude the whole subtree, not just direct children.
162
+ dir = pattern.sub(%r{(/\*\*)?/?\z}, "")
163
+ File.fnmatch?(pattern, relative, flags) ||
164
+ File.fnmatch?("#{dir}/**/*", relative, flags)
165
+ end
166
+ end
167
+
90
168
  def default_paths
91
169
  %w[app lib].select { |p| Dir.exist?(File.join(@config.root, p)) }
92
170
  end
@@ -121,22 +199,48 @@ module ActiveMutator
121
199
 
122
200
  # A preloaded helper commonly starts SimpleCov. Its at_exit would fire in
123
201
  # THIS parent process at the end of the mutation run, clobbering the
124
- # project's real coverage data and minimum_coverage would exit(1) for a
202
+ # project's real coverage data, and minimum_coverage would exit(1) for a
125
203
  # bogus reason. Neutralize it.
126
204
  def disarm_simplecov
127
205
  SimpleCov.at_exit {} if defined?(SimpleCov)
128
206
  end
129
207
 
130
- def accept_survivors!(ledger, results, fingerprints)
208
+ # Only a run with no subject-level narrowing has fully scanned a file;
209
+ # anything narrower must not prune (or warn about) out-of-scope entries.
210
+ # MAINTENANCE: any future flag that narrows the mutant set below "every
211
+ # subject in the scanned files" MUST be added to this nil-trigger list,
212
+ # or scoped accept runs will clobber out-of-scope ledger entries (#24).
213
+ def prune_scope(subjects)
214
+ return nil if @config.subject_filter || @config.since || @config.max_mutants
215
+
216
+ subjects.map { |s| s.file.delete_prefix("#{@config.root}/") }.uniq
217
+ end
218
+
219
+ def accept_survivors!(ledger, results, fingerprints, scanned_files)
131
220
  survivors = results.select { |r| r.status == :survived }.map { |r| fingerprints[r.mutation] }
132
221
  return if survivors.empty?
133
222
 
134
- ledger.accept!(survivors, fingerprints.values)
223
+ ledger.accept!(survivors, fingerprints.values, scanned_files: scanned_files)
135
224
  end
136
225
 
137
- def warn_stale(ledger, all_fingerprints)
138
- ledger.stale_entries(all_fingerprints).each do |entry|
139
- warn "active_mutator: stale accepted fingerprint (no matching mutant): #{entry.subject} #{entry.description}"
226
+ def debug_plan(items, pre_results)
227
+ plan = items.map do |i|
228
+ { "subject" => i.mutation.subject.name, "description" => i.mutation.description,
229
+ "file" => i.mutation.subject.file, "line" => i.mutation.line,
230
+ "lane" => i.lane.to_s, "timeout" => i.timeout.round(2),
231
+ "examples" => i.example_ids.size }
232
+ end
233
+ skipped = pre_results.group_by { |r| r.status.to_s }.transform_values(&:size)
234
+ puts JSON.pretty_generate("planned" => plan, "pre_resolved" => skipped)
235
+ 0
236
+ end
237
+
238
+ def warn_stale(ledger, all_fingerprints, scanned_files)
239
+ ledger.stale_entries(all_fingerprints, scanned_files: scanned_files).each do |entry|
240
+ warn "active_mutator: stale accepted fingerprint (no matching mutant): #{entry.subject}, #{entry.description}"
241
+ end
242
+ ledger.missing_file_entries(@config.root).each do |entry|
243
+ warn "active_mutator: accepted fingerprint references missing file: #{entry.file} (#{entry.subject})"
140
244
  end
141
245
  end
142
246
  end
@@ -5,10 +5,16 @@ module ActiveMutator
5
5
  # Parent enforces per-item deadlines with SIGKILL (worker-side timeouts
6
6
  # cannot interrupt all infinite loops).
7
7
  class Scheduler
8
- def initialize(jobs:, worker: Worker.method(:run), on_result: nil)
8
+ OrphanedError = Class.new(Error)
9
+
10
+ def initialize(jobs:, worker: Worker.method(:run), on_result: nil,
11
+ calibrators: nil, orphaned: -> { Process.ppid == 1 })
9
12
  @jobs = jobs
10
13
  @worker = worker
11
14
  @on_result = on_result
15
+ @calibrators = calibrators
16
+ @orphaned = orphaned
17
+ @last_logged_scale = {} # lane => last scale logged for that lane
12
18
  end
13
19
 
14
20
  def run(items)
@@ -32,6 +38,7 @@ module ActiveMutator
32
38
  queue = items.dup
33
39
  results = []
34
40
  until queue.empty? && running.empty?
41
+ abort_if_orphaned!(running)
35
42
  spawn(queue.shift, running) while running.size < width && !queue.empty?
36
43
  reap(running, results)
37
44
  sleep 0.02 unless running.empty?
@@ -39,6 +46,22 @@ module ActiveMutator
39
46
  results
40
47
  end
41
48
 
49
+ # SIGKILL on the parent (or a closed terminal, or CI teardown) cannot be
50
+ # trapped, so a killed run would otherwise keep forking through the whole
51
+ # queue with nobody supervising it. Orphaned processes get reparented to
52
+ # init/launchd (ppid 1); when that happens, stop everything and bail.
53
+ def abort_if_orphaned!(running)
54
+ return unless @orphaned.call
55
+
56
+ running.each_key do |pid|
57
+ kill(pid)
58
+ rescue StandardError
59
+ nil
60
+ end
61
+ running.clear
62
+ raise OrphanedError, "parent process died; aborting mutation run"
63
+ end
64
+
42
65
  def spawn(item, running)
43
66
  reader, writer = IO.pipe
44
67
  pid = fork do
@@ -51,7 +74,12 @@ module ActiveMutator
51
74
  Process.exit!(0)
52
75
  end
53
76
  writer.close
54
- 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 }
55
83
  end
56
84
 
57
85
  def reap(running, results)
@@ -59,7 +87,11 @@ module ActiveMutator
59
87
  done, _status = Process.waitpid2(pid, Process::WNOHANG)
60
88
  if done
61
89
  running.delete(pid)
62
- 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
63
95
  elsif now > entry[:deadline]
64
96
  kill(pid)
65
97
  running.delete(pid)
@@ -73,8 +105,16 @@ module ActiveMutator
73
105
  payload = entry[:reader].read.to_s
74
106
  entry[:reader].close
75
107
  data = payload.empty? ? nil : JSON.parse(payload)
76
- status = data ? data.fetch("status").to_sym : :error
77
- 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
78
118
  report(Result.new(mutation: entry[:item].mutation, status: status, details: details))
79
119
  end
80
120
 
@@ -86,7 +126,7 @@ module ActiveMutator
86
126
  def kill(pid)
87
127
  Process.kill("KILL", -pid) # negative pid = whole process group
88
128
  rescue Errno::ESRCH, Errno::EPERM
89
- # Group not established yet (setpgid race) or already gone direct kill.
129
+ # Group not established yet (setpgid race) or already gone: direct kill.
90
130
  begin
91
131
  Process.kill("KILL", pid)
92
132
  rescue Errno::ESRCH
@@ -100,7 +140,7 @@ module ActiveMutator
100
140
  end
101
141
  end
102
142
 
103
- # Returns {sig => previous_handler} so #run can restore on exit
143
+ # Returns {sig => previous_handler} so #run can restore on exit,
104
144
  # otherwise our traps permanently replace the host's (e.g. RSpec's Ctrl-C).
105
145
  def install_signal_handlers(running)
106
146
  %w[INT TERM].to_h do |sig|
@@ -120,6 +160,23 @@ module ActiveMutator
120
160
  previous.each { |sig, handler| trap(sig, handler || "DEFAULT") }
121
161
  end
122
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
+
123
180
  def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
124
181
  end
125
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,13 @@
1
1
  module ActiveMutator
2
2
  # A mutable unit: one method definition.
3
3
  # byte_range/line_range cover the whole `def ... end`.
4
- Subject = Data.define(:name, :file, :byte_range, :line_range, :constant_scope, :kind) do
4
+ # sclass: def lives inside `class << self` — its source slice is `def foo`,
5
+ # so Inserter must target the singleton class, not the constant itself.
6
+ Subject = Data.define(:name, :file, :byte_range, :line_range, :constant_scope, :kind, :sclass) do
7
+ def initialize(name:, file:, byte_range:, line_range:, constant_scope:, kind:, sclass: false)
8
+ super
9
+ end
10
+
5
11
  def singleton? = kind == :singleton
6
12
  end
7
13
  end
@@ -1,36 +1,73 @@
1
+ require "set"
2
+
1
3
  module ActiveMutator
2
4
  class SubjectFinder < Prism::Visitor
5
+ SKIP_MARKER = /#\s*active_mutator:\s*skip\b/
6
+
3
7
  def self.call(file)
4
8
  result = Prism.parse(File.read(file))
5
9
  return [] unless result.success?
6
10
 
7
- finder = new(file)
11
+ skip_lines = result.comments
12
+ .select { |c| c.slice.match?(SKIP_MARKER) }
13
+ .to_set { |c| c.location.start_line }
14
+ finder = new(file, skip_lines: skip_lines)
8
15
  finder.visit(result.value)
9
16
  finder.subjects
10
17
  end
11
18
 
12
19
  attr_reader :subjects
13
20
 
14
- def initialize(file)
21
+ def initialize(file, skip_lines: Set.new)
15
22
  @file = file
23
+ @skip_lines = skip_lines
16
24
  @stack = []
17
25
  @subjects = []
26
+ @sclass_depth = 0
18
27
  super()
19
28
  end
20
29
 
30
+ # Classes/modules declared inside `class << self` hang their constant on
31
+ # the SINGLETON class, so a lexically-joined scope like "Foo::Bar" is not
32
+ # reachable via Object.const_get — Inserter would crash. Skipped entirely.
21
33
  def visit_class_node(node)
34
+ return if @sclass_depth.positive?
35
+
22
36
  with_scope(node.constant_path.slice) { super }
23
37
  end
24
38
 
25
39
  def visit_module_node(node)
40
+ return if @sclass_depth.positive?
41
+
26
42
  with_scope(node.constant_path.slice) { super }
27
43
  end
28
44
 
29
- # `class << self` bodies are a documented v1 limit: not visited.
30
- def visit_singleton_class_node(node); end
45
+ # `class << self` inside a constant scope: defs there are singleton
46
+ # methods of the enclosing constant. `class << obj` and a top-level
47
+ # `class << self` (no constant to hang the method on) stay skipped.
48
+ def visit_singleton_class_node(node)
49
+ return unless node.expression.is_a?(Prism::SelfNode) && !@stack.empty?
50
+
51
+ @sclass_depth += 1
52
+ begin
53
+ super
54
+ ensure
55
+ @sclass_depth -= 1
56
+ end
57
+ end
58
+
59
+ # Defs inside blocks (`Data.define do ... end`, `class_eval do ... end`)
60
+ # do not live on the enclosing constant scope, so Inserter would redefine
61
+ # them on the wrong constant and every mutant would falsely survive.
62
+ # Same v1 limit as `class << self`: not visited. Note this also hides
63
+ # classes/modules defined inside blocks (accepted v1 limit).
64
+ def visit_block_node(node); end
31
65
 
32
66
  def visit_def_node(node)
33
- singleton = node.receiver.is_a?(Prism::SelfNode)
67
+ return if @skip_lines.include?(node.location.start_line - 1)
68
+
69
+ sclass = @sclass_depth.positive?
70
+ singleton = sclass || node.receiver.is_a?(Prism::SelfNode)
34
71
  scope = @stack.empty? ? nil : @stack.join("::")
35
72
  loc = node.location
36
73
  @subjects << Subject.new(
@@ -39,9 +76,11 @@ module ActiveMutator
39
76
  byte_range: loc.start_offset...loc.end_offset,
40
77
  line_range: loc.start_line..loc.end_line,
41
78
  constant_scope: scope,
42
- kind: singleton ? :singleton : :instance
79
+ kind: singleton ? :singleton : :instance,
80
+ sclass: sclass
43
81
  )
44
- # No `super`: nested defs are out of scope for v1.
82
+ # No `super`: nested defs get no subject of their own -- their bodies
83
+ # are mutated via the OUTER def (Engine#walk descends into them).
45
84
  end
46
85
 
47
86
  private
@@ -0,0 +1,23 @@
1
+ module ActiveMutator
2
+ # Tiny subject-expression grammar for --subject:
3
+ # Foo::Bar#baz exact Foo::Bar all methods of the constant
4
+ # Foo::Bar* namespace Foo::Bar#* instance-only Foo::Bar.* singleton-only
5
+ class SubjectMatcher
6
+ def initialize(expression)
7
+ @regexp = compile(expression)
8
+ end
9
+
10
+ def match?(name) = @regexp.match?(name)
11
+
12
+ private
13
+
14
+ def compile(expr)
15
+ case expr
16
+ when /\A(.+)([#.])\*\z/ then /\A#{Regexp.escape($1)}#{Regexp.escape($2)}[^#.]+\z/
17
+ when /\A(.+)\*\z/ then /\A#{Regexp.escape($1)}/
18
+ when /[#.]/ then /\A#{Regexp.escape(expr)}\z/
19
+ else /\A#{Regexp.escape(expr)}[#.][^#.]+\z/
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,75 @@
1
+ module ActiveMutator
2
+ # Adaptive timeout budgets (#9). Static budgets derive from baseline times
3
+ # measured warm and unloaded; under parallel load they misclassify
4
+ # slow-but-honest kills as timeouts. The Scheduler feeds this with the
5
+ # observed wall time of every KILLED fork (errors exit artificially fast,
6
+ # survivors run their whole covering set — both would bias the median;
7
+ # timed-out forks have no known wall time at all). One instance per lane:
8
+ # parallel and serial run under different load regimes.
9
+ #
10
+ # Utilization is elapsed / the EFFECTIVE budget the fork ran under (the
11
+ # value budget_for returned at its spawn), not the static item.timeout —
12
+ # otherwise a pinned-high scale could never observe recovery (ratchet).
13
+ # Once WARMUP observations exist, remaining budgets' variable part is
14
+ # scaled by the clamped median utilization. The fixed part (timeout_floor
15
+ # + browser boot) is never scaled: fork boot cost does not shrink because
16
+ # examples run fast.
17
+ #
18
+ # The scale is grow-only (MIN_SCALE = 1.0): budgets may only extend beyond
19
+ # the static value, never fall below it. Downscaling has no recovery signal
20
+ # because timeouts are CENSORED samples — the scheduler skips them (they have
21
+ # no known wall time), so only killed forks feed the window. A low median
22
+ # utilization would shrink the budget, causing a legitimately slow kill to be
23
+ # reaped as a timeout; that censored kill never enters the window, so nothing
24
+ # ever pushes utilization back up. The shrink self-sustains — an asymmetric
25
+ # ratchet. Growing is safe (a too-large budget still records a real kill and
26
+ # relaxes); shrinking is not, so we forbid it.
27
+ class TimeoutCalibrator
28
+ WARMUP = 5
29
+ # A real sliding window, not full-run history: a 500-mutant run's early
30
+ # load regime must age out completely instead of anchoring the median
31
+ # forever. 30 samples is enough for a stable median and small enough to
32
+ # track load changes within a few dozen finishes.
33
+ WINDOW = 30
34
+ TARGET_UTILIZATION = 0.25
35
+ MIN_SCALE = 1.0
36
+ MAX_SCALE = 4.0
37
+
38
+ def initialize
39
+ @utilizations = []
40
+ end
41
+
42
+ def record(elapsed_seconds, budget)
43
+ return unless budget.positive?
44
+
45
+ @utilizations << elapsed_seconds / budget
46
+ @utilizations.shift while @utilizations.size > WINDOW
47
+ end
48
+
49
+ def warmed? = @utilizations.size >= WARMUP
50
+
51
+ def budget_for(item)
52
+ return item.timeout unless warmed?
53
+
54
+ fixed = item.timeout - item.variable
55
+ item.variable * scale + fixed
56
+ end
57
+
58
+ def scale
59
+ # An empty window has no median; a caller invoking scale without warmed?
60
+ # (median of [] is nil) would otherwise raise. Neutral scale = no change.
61
+ return 1.0 if @utilizations.empty?
62
+
63
+ s = median(@utilizations) / TARGET_UTILIZATION
64
+ s.clamp(MIN_SCALE, MAX_SCALE)
65
+ end
66
+
67
+ private
68
+
69
+ def median(values)
70
+ sorted = values.sort
71
+ mid = sorted.size / 2
72
+ sorted.size.odd? ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2.0
73
+ end
74
+ end
75
+ end
@@ -1,3 +1,3 @@
1
1
  module ActiveMutator
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
@@ -1,4 +1,11 @@
1
1
  module ActiveMutator
2
2
  # lane: :parallel (default pool) | :serial (browser-covered, one at a time)
3
- WorkItem = Data.define(:mutation, :example_ids, :timeout, :lane)
3
+ # timeout: static total budget (variable + fixed), kept for --debug-plan and compat
4
+ # variable: the baseline-estimate-derived part (estimate * timeout_factor) — the
5
+ # only part the TimeoutCalibrator scales
6
+ WorkItem = Data.define(:mutation, :example_ids, :timeout, :lane, :variable) do
7
+ def initialize(mutation:, example_ids:, timeout:, lane:, variable: 0.0)
8
+ super
9
+ end
10
+ end
4
11
  end
@@ -3,7 +3,7 @@ require "set"
3
3
 
4
4
  module ActiveMutator
5
5
  # Runs INSIDE a fork. Order is critical: RSpec's setup phase loads the spec
6
- # files, whose spec_helper/rails_helper loads the application only THEN
6
+ # files, whose spec_helper/rails_helper loads the application. Only THEN
7
7
  # can the mutation be inserted over the loaded original. Insert-first would
8
8
  # NameError on any project not preloaded in the parent (all non-Rails
9
9
  # projects), and loading app code after insertion would silently restore
@@ -24,6 +24,9 @@ module ActiveMutator
24
24
  devnull = File.open(File::NULL, "w")
25
25
  runner = RSpec::Core::Runner.new(RSpec::Core::ConfigurationOptions.new(@example_ids))
26
26
  runner.setup(devnull, devnull) # loads spec files -> loads the app
27
+ # One failure kills the mutant; running the rest of the covering set
28
+ # is pure waste inside the fork.
29
+ RSpec.configuration.fail_fast = 1
27
30
  Inserter.new.insert(@mutation) # now the target constant exists
28
31
  after_fork_hygiene
29
32
  code = runner.run_specs(covering_groups)
@@ -47,7 +50,7 @@ module ActiveMutator
47
50
  @writer.flush if @writer.respond_to?(:flush)
48
51
  end
49
52
 
50
- # RSpec.world holds every group registered in the process including any
53
+ # RSpec.world holds every group registered in the process, including any
51
54
  # top-level groups evaluated while the PARENT preloaded the spec helper
52
55
  # (spec/support files with RSpec.describe at load time are common). Those
53
56
  # leak into the fork; running them would report their failures as false
@@ -11,6 +11,7 @@ require_relative "active_mutator/edit"
11
11
  require_relative "active_mutator/splicer"
12
12
  require_relative "active_mutator/subject"
13
13
  require_relative "active_mutator/subject_finder"
14
+ require_relative "active_mutator/subject_matcher"
14
15
  require_relative "active_mutator/operators/base"
15
16
  require_relative "active_mutator/operators/conditional_boundary"
16
17
  require_relative "active_mutator/operators/condition_forcing"
@@ -27,16 +28,23 @@ require_relative "active_mutator/atomic_file"
27
28
  require_relative "active_mutator/coverage_map"
28
29
  require_relative "active_mutator/baseline"
29
30
  require_relative "active_mutator/baseline_delta"
31
+ require_relative "active_mutator/defined_constants"
30
32
  require_relative "active_mutator/inserter"
31
33
  require_relative "active_mutator/worker"
32
34
  require_relative "active_mutator/result"
33
35
  require_relative "active_mutator/work_item"
36
+ require_relative "active_mutator/timeout_calibrator"
34
37
  require_relative "active_mutator/scheduler"
38
+ require_relative "active_mutator/reporter/operator_stats"
35
39
  require_relative "active_mutator/reporter/terminal"
40
+ require_relative "active_mutator/reporter/github"
36
41
  require_relative "active_mutator/reporter/json"
42
+ require_relative "active_mutator/source_location"
37
43
  require_relative "active_mutator/since_filter"
38
44
  require_relative "active_mutator/fingerprint"
39
45
  require_relative "active_mutator/accepted_ledger"
46
+ require_relative "active_mutator/reporter/stryker_json"
40
47
  require_relative "active_mutator/config"
41
48
  require_relative "active_mutator/runner"
49
+ require_relative "active_mutator/config_file"
42
50
  require_relative "active_mutator/cli"