active_mutator 0.7.0 → 0.7.1

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: cf0b7bed1f6b35d6ab688affd01cb2ccc362944019ae5c18bff2e1205cccb53e
4
- data.tar.gz: 9b4d7cac2fc3f8e3a6d737db4aa7f3b8f3f46c00baa275669fecbc5752e50910
3
+ metadata.gz: 66b813b08ab7b9c3ba792c6472ced651e2f4af2b3b530f5a07c56e938570b21c
4
+ data.tar.gz: 62bf64d3c76e28648300ccbf437a0334865467890f740b44205c5b417ff3229c
5
5
  SHA512:
6
- metadata.gz: f2ed986856e22c4881936ae20f66bac4ecdc57b31239b5786942be6c2128516db64542be9185da5f9e8bc06ad1acd893d9e231602e1cf553ad7c74d60af9ef61
7
- data.tar.gz: 8f8bd75ed8572b8e1eee883644b412bc9e718eeb0b85f35e22d8f27b6f937113c505bccadca869fd33546895bbf68126a30b9931fc755c3df8d71e33b8168bb0
6
+ metadata.gz: 0736a4d6182dc5ecbcd14c2243371471f88e96033e459922f7244ce595abe1b17b65a47f3ff9c26fa1d29c8608434cd1c0198b0d5c5c58cefa54d35af9c2ea51
7
+ data.tar.gz: 5ea60c1a68d289dcd101857b96a4abd4d807e976f741a45ed788eeff86157b383a0ea8095fe9806ff2623aeaf70b986764fa41166656bd864aa189014cfcc03c
data/README.md CHANGED
@@ -90,6 +90,11 @@ coverage map. The map is cached in `.active_mutator/` and refreshed
90
90
  incrementally after that (see
91
91
  [`docs/guides/how-it-works.md`](docs/guides/how-it-works.md)).
92
92
  Then each mutant runs in its own fork against only its covering examples.
93
+ Top-level groups run the group that last killed a sibling mutant first,
94
+ then the subject's convention spec, then the cheapest remaining groups by
95
+ baseline time. A kill ends the fork sooner. Specs that depend on
96
+ another top-level group having run first can behave differently; see
97
+ [how it works](docs/guides/how-it-works.md).
93
98
 
94
99
  ### Reading the output
95
100
 
@@ -0,0 +1,22 @@
1
+ module ActiveMutator
2
+ # RSpec example ids look like "./spec/a_spec.rb[1:2:3]": the file, then the
3
+ # scoped path of the example. The first number is the top-level group, and a
4
+ # shared example adds a second bracket. Anything else parses to nil, so a bad
5
+ # id is never mistaken for a group.
6
+ module ExampleId
7
+ PATTERN = /\A(?<file>.+?)(?<scope>\[(?<top>\d+)(?::\d+)*\](?:\[\d+(?::\d+)*\])*)\z/
8
+
9
+ # [absolute file, top-level group number], or nil. ./spec/a_spec.rb,
10
+ # spec/a_spec.rb, and its absolute path all name the same file.
11
+ def self.group_key(id)
12
+ match = PATTERN.match(id) if id.is_a?(String)
13
+ [File.expand_path(match[:file]), match[:top]] if match
14
+ end
15
+
16
+ # The id with its file made absolute, for comparing spellings; or nil.
17
+ def self.normalize(id)
18
+ match = PATTERN.match(id) if id.is_a?(String)
19
+ "#{File.expand_path(match[:file])}#{match[:scope]}" if match
20
+ end
21
+ end
22
+ end
@@ -308,16 +308,29 @@ module ActiveMutator
308
308
  end
309
309
 
310
310
  # Single source of truth for lane/timeout/variable derivation, shared by
311
- # phase-1 planning and phase-2 escalation so the two never drift.
311
+ # phase-1 planning and phase-2 escalation so the two never drift. Lane and
312
+ # budget come from the whole selection; only the order of the ids changes.
312
313
  def build_work_item(mutation, example_ids, map)
313
314
  lane = example_ids.any? { |id| serial_example?(id) } ? :serial : :parallel
314
315
  variable = map.time_for(example_ids) * @config.timeout_factor
315
316
  boot_extra = lane == :serial ? @config.browser_boot_seconds : 0.0
316
317
  timeout = variable + @config.timeout_floor + boot_extra
317
- WorkItem.new(mutation: mutation, example_ids: example_ids,
318
+ WorkItem.new(mutation: mutation, example_ids: prioritize(mutation, example_ids, map),
318
319
  timeout: timeout, lane: lane, variable: variable)
319
320
  end
320
321
 
322
+ # Run order for the covering set: the subject's convention spec first, then
323
+ # the cheapest top-level groups (summing only the selected examples), so a
324
+ # killing spec is found before a slow one. Ties keep sorted-selection order.
325
+ # Ids inside a group keep their order, and malformed ids stay their own group.
326
+ def prioritize(mutation, example_ids, map)
327
+ convention = convention_spec_rels(mutation.subject.file)
328
+ groups = example_ids.group_by { |id| ExampleId.group_key(id) || id }.values
329
+ groups.each_with_index.sort_by do |ids, position|
330
+ [convention.include?(BaselineDelta.spec_file_of(ids.first)) ? 0 : 1, map.time_for(ids), position]
331
+ end.flat_map(&:first)
332
+ end
333
+
321
334
  # Spec files that textually match `pattern` (a constant-reference pattern
322
335
  # for the subject's file, built via BaselineDelta.constant_reference_pattern
323
336
  # so the escaping/word-boundary rules stay shared), minus everything phase 1
@@ -29,6 +29,7 @@ module ActiveMutator
29
29
  def run(items)
30
30
  running = {}
31
31
  results = []
32
+ @killers = {} # Subject => [file, top-level group] of its latest killer
32
33
  @abort.deferred do
33
34
  # Browser-covered mutants each boot Chrome + an app server; running
34
35
  # them concurrently melts CPUs and manufactures false timeouts.
@@ -92,6 +93,7 @@ module ActiveMutator
92
93
  STDERR_TAIL_LINES = 20
93
94
 
94
95
  def spawn(item, running)
96
+ item = prioritized(item)
95
97
  calibrator = calibrator_for(item)
96
98
  budget = calibrator ? calibrator.budget_for(item) : item.timeout
97
99
  log_scale(calibrator, item.lane)
@@ -162,6 +164,29 @@ module ActiveMutator
162
164
  end
163
165
  end
164
166
 
167
+ # A sibling mutant of the same subject (the plan is built per mutant, so
168
+ # the covering set is the same or close) most likely dies to the same
169
+ # spec: run the group that killed the last one first. The set is unchanged.
170
+ def prioritized(item)
171
+ key = @killers[item.mutation.subject]
172
+ return item unless key
173
+
174
+ first, rest = item.example_ids.partition { |id| ExampleId.group_key(id) == key }
175
+ item.with(example_ids: first + rest)
176
+ end
177
+
178
+ # Only a kill that names one of the examples it was given counts. Anything
179
+ # else (missing, malformed, foreign, or on another status) leaves the last
180
+ # killer alone.
181
+ def learn_killer(item, status, killer)
182
+ return unless status == :killed
183
+
184
+ selected = item.example_ids.filter_map { |id| ExampleId.normalize(id) }
185
+ return unless selected.include?(ExampleId.normalize(killer))
186
+
187
+ @killers[item.mutation.subject] = ExampleId.group_key(killer)
188
+ end
189
+
165
190
  def finish(entry)
166
191
  seconds = now - entry[:started]
167
192
  report_line, stats_line = entry[:payload].lines.map(&:strip).reject(&:empty?)
@@ -179,6 +204,8 @@ module ActiveMutator
179
204
  Result.new(mutation: entry[:item].mutation, status: :error,
180
205
  details: "worker emitted unparseable payload", seconds: seconds)
181
206
  else
207
+ # Learned only once the whole payload (report and stats) has parsed.
208
+ learn_killer(entry[:item], status, data["killer"]) if reported
182
209
  Result.new(mutation: entry[:item].mutation, status: status, details: details,
183
210
  seconds: seconds, peak_rss_kb: peak)
184
211
  end
@@ -1,3 +1,3 @@
1
1
  module ActiveMutator
2
- VERSION = "0.7.0"
2
+ VERSION = "0.7.1"
3
3
  end
@@ -1,6 +1,8 @@
1
1
  module ActiveMutator
2
2
  # lane: :parallel (default pool) | :serial (browser-covered, one at a time)
3
3
  # timeout: static total budget (variable + fixed), kept for --debug-plan and compat
4
+ # example_ids: the covering set in run priority. A worker loads the specs
5
+ # sorted but runs top-level groups in this order.
4
6
  # variable: the baseline-estimate-derived part (estimate * timeout_factor) — the
5
7
  # only part the TimeoutCalibrator scales
6
8
  WorkItem = Data.define(:mutation, :example_ids, :timeout, :lane, :variable) do
@@ -40,9 +40,20 @@ module ActiveMutator
40
40
  end
41
41
 
42
42
  def run
43
+ # The mutant lands in THIS process, so a mutant of the id parser or of
44
+ # these methods could rewrite the very code that selects the groups and
45
+ # reports the verdict, before the specs that kill it have run. Bind the
46
+ # originals first. A Worker built later (by a spec) sees the mutant.
47
+ @emit = method(:emit)
48
+ @covering_groups = method(:covering_groups)
49
+ @killer = method(:killer)
50
+ @normalize = ExampleId.method(:normalize)
51
+ @group_keys = @example_ids.filter_map { |id| ExampleId.group_key(id) }
43
52
  require "rspec/core"
44
53
  devnull = File.open(File::NULL, "w")
45
- runner = RSpec::Core::Runner.new(RSpec::Core::ConfigurationOptions.new(@example_ids))
54
+ # Sorted so spec files load in the same order whatever the run priority:
55
+ # load order decides constant definitions and shared setup.
56
+ runner = RSpec::Core::Runner.new(RSpec::Core::ConfigurationOptions.new(@example_ids.sort))
46
57
  if @mutation.subject.reload?
47
58
  require @mutation.subject.file # no-op if already loaded; guarantees the constant exists
48
59
  insert_mutation # BEFORE setup: groups bind described_class to the mutated object
@@ -56,15 +67,20 @@ module ActiveMutator
56
67
  # is pure waste inside the fork.
57
68
  RSpec.configuration.fail_fast = 1
58
69
  after_fork_hygiene
59
- code = runner.run_specs(covering_groups)
60
- emit(code.zero? ? "survived" : "killed")
70
+ failed_before = failed_examples.dup
71
+ code = runner.run_specs(@covering_groups.call)
72
+ if code.zero?
73
+ @emit.call("survived")
74
+ else
75
+ @emit.call("killed", killer: @killer.call(failed_examples - failed_before))
76
+ end
61
77
  rescue ClosureReload::Skip => e
62
- emit("skipped", details: e.message)
78
+ @emit.call("skipped", details: e.message)
63
79
  rescue ClosureReload::MutantLoadError => e
64
80
  # The mutation made the class unloadable; a real suite would fail on it.
65
- emit("killed", details: "mutated class failed to load: #{e.message}")
81
+ @emit.call("killed", details: "mutated class failed to load: #{e.message}")
66
82
  rescue StandardError, ScriptError => e
67
- emit("error", details: "#{e.class}: #{e.message}")
83
+ @emit.call("error", details: "#{e.class}: #{e.message}")
68
84
  end
69
85
 
70
86
  private
@@ -88,8 +104,10 @@ module ActiveMutator
88
104
  end
89
105
  end
90
106
 
91
- def emit(status, details: nil)
92
- @writer.puts(JSON.generate("status" => status, "details" => details))
107
+ def emit(status, details: nil, killer: nil)
108
+ report = { "status" => status, "details" => details }
109
+ report["killer"] = killer if killer
110
+ @writer.puts(JSON.generate(report))
93
111
  @writer.flush if @writer.respond_to?(:flush)
94
112
  end
95
113
 
@@ -97,14 +115,28 @@ module ActiveMutator
97
115
  # top-level groups evaluated while the PARENT preloaded the spec helper
98
116
  # (spec/support files with RSpec.describe at load time are common). Those
99
117
  # leak into the fork; running them would report their failures as false
100
- # kills. Run only groups that belong to the covering spec files.
118
+ # kills. Run only groups that belong to the covering spec files, in the
119
+ # priority order of @example_ids; groups it does not name keep host order.
101
120
  def covering_groups
102
- covering = @example_ids
103
- .map { |id| File.expand_path(id[/\A(.+?)\[/, 1]) }
104
- .to_set
105
- RSpec.world.ordered_example_groups.select do |group|
121
+ covering = @group_keys.map(&:first).to_set
122
+ rank = {}
123
+ @group_keys.each { |key| rank[key] ||= rank.size }
124
+ groups = RSpec.world.ordered_example_groups.each_with_index.select do |group, _|
106
125
  covering.include?(group.metadata[:absolute_file_path])
107
126
  end
127
+ groups.sort_by do |group, host_index|
128
+ key = [group.metadata[:absolute_file_path], group.metadata[:scoped_id]]
129
+ [rank.fetch(key, rank.size), host_index]
130
+ end.map(&:first)
131
+ end
132
+
133
+ def failed_examples = RSpec.configuration.reporter.failed_examples
134
+
135
+ # The first fresh failure that is one of the examples this mutant ran
136
+ # against, spelled as RSpec reports it; nil when there is none.
137
+ def killer(failures)
138
+ selected = @example_ids.filter_map { |id| @normalize.call(id) }.to_set
139
+ failures.map(&:id).find { |id| selected.include?(@normalize.call(id)) }
108
140
  end
109
141
  end
110
142
  end
@@ -39,6 +39,7 @@ require_relative "active_mutator/inserter"
39
39
  require_relative "active_mutator/closure_reload"
40
40
  require_relative "active_mutator/memory_probe"
41
41
  require_relative "active_mutator/sampler"
42
+ require_relative "active_mutator/example_id"
42
43
  require_relative "active_mutator/worker"
43
44
  require_relative "active_mutator/result"
44
45
  require_relative "active_mutator/work_item"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_mutator
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel John
@@ -89,6 +89,7 @@ files:
89
89
  - lib/active_mutator/edit.rb
90
90
  - lib/active_mutator/engine.rb
91
91
  - lib/active_mutator/events.rb
92
+ - lib/active_mutator/example_id.rb
92
93
  - lib/active_mutator/fingerprint.rb
93
94
  - lib/active_mutator/inserter.rb
94
95
  - lib/active_mutator/memory_ceiling.rb
@@ -144,7 +145,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
144
145
  - !ruby/object:Gem::Version
145
146
  version: '0'
146
147
  requirements: []
147
- rubygems_version: 4.0.19
148
+ rubygems_version: 3.6.7
148
149
  specification_version: 4
149
150
  summary: Mutation testing for Ruby, built on Prism
150
151
  test_files: []