mutineer 1.5.0 → 1.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.
@@ -0,0 +1,352 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "set"
5
+ require_relative "parser"
6
+ require_relative "project"
7
+ require_relative "result"
8
+ require_relative "statement_lines"
9
+ require_relative "changed_lines"
10
+ require_relative "mutator_registry"
11
+ require_relative "mutant_id"
12
+ require_relative "project_path"
13
+ require_relative "external_backend"
14
+
15
+ module Mutineer
16
+ # The job vocabulary every backend shares: which mutants run, which are
17
+ # ignored, which lines `--since` keeps, which tests cover a mutant, and the
18
+ # source and test directories a run touches. {Runner}, {DaemonBackend} and
19
+ # {CLI} each require this file, so job selection cannot drift between them
20
+ # (score parity), and the require graph has no cycles (#75). Never require
21
+ # runner.rb, daemon_backend.rb or cli.rb from here: each requires this file.
22
+ module JobPlan
23
+ # Collect every (subject, mutation, id) up front so a backend can run them.
24
+ # A mutant the user marked known-equivalent (inline disable-line comment or
25
+ # .mutineer.yml ignore id) is classified :ignored here and NEVER run. It is
26
+ # removed from the killed+survived denominator so a strong file reaches 100%.
27
+ # The id is computed per subject (occurrence needs the full list), keyed on
28
+ # the file path relative to config.project_root, and carried on every job so
29
+ # the parent can reattach it after the run. Shared by the in-process,
30
+ # external, and daemon backends so job selection can never drift.
31
+ #
32
+ # Each mutant also gets its old-format id ({MutantId.legacy_for}), so an ignore
33
+ # entry stored before ids carried the path still suppresses it. Prints nothing:
34
+ # the extras hash returns, as data, `legacy_ignore_matches` (each old-format
35
+ # ignore entry that matched a mutant through its old-format id => one
36
+ # `{id:, file:, subject:}` hash per matched mutant, in collection order: its
37
+ # new id, its project-relative file and its subject's qualified name;
38
+ # recorded even when a new id is also listed, since the old entry still
39
+ # over-matches other files) and `id_map` (every new id => its old-format id).
40
+ #
41
+ # Subjects sharing a qualified name in one file (two owner-less `def index`
42
+ # in two DSL blocks) get a per-file ordinal in discovery order, so their ids
43
+ # differ; the first one's ordinal is 0 and leaves its id unchanged.
44
+ #
45
+ # @param config [Mutineer::Config] run configuration.
46
+ # @param operator_classes [Array<Class>] resolved operators.
47
+ # @return [Array(Array, Array<Result>, Hash<String,String>, Hash{Symbol => Hash})]
48
+ # jobs, ignored, source_map, and extras (`:legacy_ignore_matches`, `:id_map`).
49
+ def self.collect_jobs(config, operator_classes)
50
+ source_map = {}
51
+ disabled_map = {}
52
+ id_paths = {}
53
+ # [file, qualified_name] => { declaration offset => ordinal }: keyed by the
54
+ # declaration, so the same file discovered twice (two path spellings) reuses
55
+ # its ordinal instead of minting a second id for the same mutant.
56
+ name_decls = Hash.new { |h, k| h[k] = {} }
57
+ ignore_set = config.ignore.to_set
58
+ jobs = []
59
+ ignored_results = []
60
+ legacy_ignore_matches = {}
61
+ id_map = {}
62
+ Project.discover(config.sources, only: config.only).each do |subject|
63
+ source = (source_map[subject.file] ||= File.read(subject.file))
64
+ disabled = (disabled_map[subject.file] ||= suppress_map(source, subject.file))
65
+ mutations = operator_classes.flat_map { |klass| klass.new.mutations_for(subject, source) }
66
+ id_path = (id_paths[subject.file] ||= ProjectPath.relative(subject.file, config.project_root))
67
+ decls = name_decls[[id_path, subject.qualified_name]]
68
+ ordinal = (decls[subject.def_node.location.start_offset] ||= decls.size)
69
+ ids = MutantId.for_subject(subject, source, mutations, path: id_path, subject_ordinal: ordinal)
70
+ legacy_ids = MutantId.legacy_for_subject(subject, source, mutations)
71
+ lines = mutations.map { |m| source.byteslice(0, m.start_offset).count("\n") + 1 }
72
+ keys = result_keys(mutations, source, lines)
73
+ # A repeat of an earlier edit on the same line is dropped (#159),
74
+ # separately among the run and the ignored mutants, so an ignored copy
75
+ # never hides a copy that should run. A dropped copy records nothing.
76
+ seen = { run: Set.new, ignored: Set.new }
77
+ mutations.each_with_index do |mutation, i|
78
+ id = ids[i]
79
+ legacy = legacy_ids[i]
80
+ ignored = suppressed?(mutation.operator, lines[i], [id, legacy], disabled, ignore_set)
81
+ next unless seen[ignored ? :ignored : :run].add?(keys[i])
82
+
83
+ id_map[id] = legacy
84
+ # An old entry still over-matches other files even when the new id is
85
+ # listed too, so every old-entry match is reported for migration.
86
+ if ignore_set.include?(legacy)
87
+ (legacy_ignore_matches[legacy] ||= []) << { id: id, file: id_path, subject: subject.qualified_name }
88
+ end
89
+ if ignored
90
+ ignored_results << Result.ignored.with(subject: subject, mutation: mutation, id: id)
91
+ else
92
+ jobs << [subject, mutation, id]
93
+ end
94
+ end
95
+ end
96
+ [jobs, ignored_results, source_map, { legacy_ignore_matches: legacy_ignore_matches, id_map: id_map }]
97
+ end
98
+
99
+ # One key per mutation; two mutations share a key exactly when they are
100
+ # the same operator on the same line and give the same mutated source (one
101
+ # edit, #159). The caller drops a repeat after ids are assigned. Only a
102
+ # group of same-operator, same-line mutations can repeat, so only those
103
+ # build a key from their text: the span from the group's earliest start to
104
+ # its latest end, digested. Every other mutation gets a key of its own.
105
+ #
106
+ # @param mutations [Array<Mutineer::Mutation>] one subject's mutations.
107
+ # @param source [String] the full, unmutated source.
108
+ # @param lines [Array<Integer>] each mutation's line.
109
+ # @return [Array<Object>] one key per mutation, in order.
110
+ def self.result_keys(mutations, source, lines)
111
+ keys = Array.new(mutations.size) { |i| i }
112
+ mutations.each_index.group_by { |i| [mutations[i].operator, lines[i]] }.each_value do |group|
113
+ next if group.size < 2
114
+
115
+ from = group.map { |i| mutations[i].start_offset }.min
116
+ to = group.map { |i| mutations[i].end_offset }.max
117
+ group.each do |i|
118
+ m = mutations[i]
119
+ span = "#{source.byteslice(from...m.start_offset)}#{m.replacement}#{source.byteslice(m.end_offset...to)}"
120
+ keys[i] = [m.operator, lines[i], Digest::SHA256.digest(span)]
121
+ end
122
+ end
123
+ keys
124
+ end
125
+
126
+ # Aborts the run when coverage capture saw a red unmutated suite. Scoring
127
+ # those results would treat existing assertion failures as killed mutants.
128
+ #
129
+ # @param coverage_map [Mutineer::CoverageMap] the built or loaded map.
130
+ # @return [void]
131
+ # @raise [Mutineer::SmokeCheckError] when any captured test failed clean,
132
+ # or when no test recorded coverage and a capture failed.
133
+ def self.abort_if_unclean!(coverage_map)
134
+ if coverage_map.map.empty? && coverage_map.failed_test_files.any?
135
+ raise SmokeCheckError, "no test recorded coverage, and capture failed for #{coverage_map.failed_test_files.join(', ')}"
136
+ end
137
+
138
+ files = coverage_map.failed_clean_tests
139
+ return if files.empty?
140
+
141
+ raise SmokeCheckError,
142
+ "the unmutated suite is not green (#{files.join(', ')}) — " \
143
+ "#{ExternalBackend.generic_env_hint}."
144
+ end
145
+
146
+ # Coverage-based test selection, shared by the in-process ({Runner.run}) and daemon
147
+ # paths so both narrow identically (score parity). Returns
148
+ # `[:run, abs_test_paths]` when some test covers the mutant's line, in the
149
+ # order of {CoverageMap#order_tests} (paired files, then cheapest), or
150
+ # `[:verdict, Result]` (no_coverage / uncapturable) when none do. A line
151
+ # Ruby does not count (a later line of a multi-line statement) uses the
152
+ # tests that ran the statement that holds it ({StatementLines}), unless the
153
+ # mutant sits in code of that statement that runs only sometimes.
154
+ #
155
+ # An empty selection is `:ran_at_load` when the mutant's line ran while the
156
+ # app booted or its class loaded ({ran_at_load?}): no test ran it, but the
157
+ # load did, so it is not a coverage gap.
158
+ #
159
+ # An empty selection is `:uncapturable` (not `:no_coverage`) when the
160
+ # mutant's enclosing method body got coverage from no *successful* capture but
161
+ # a sibling test failed to capture: the coverage was lost, not absent. Both are
162
+ # excluded from the score denominator, so this distinction is reporting-only
163
+ # and never changes the daemon-vs-in-process score.
164
+ #
165
+ # @param source_file [String] the mutated source file path.
166
+ # @param mutation [Mutineer::Mutation] the mutation (for its line offset).
167
+ # @param subject [Mutineer::Subject, nil] the subject (for its method body range).
168
+ # @param source [String] the original source text.
169
+ # @param coverage_map [Mutineer::CoverageMap] the built/loaded coverage map.
170
+ # @return [Array(Symbol, Object)] `[:run, Array<String>]` or `[:verdict, Result]`.
171
+ def self.coverage_selection(source_file, mutation, subject, source, coverage_map)
172
+ line = source.byteslice(0, mutation.start_offset).count("\n") + 1
173
+ chosen = coverage_map.tests_for(source_file, line)
174
+ if chosen.empty? && subject
175
+ # A multi-line statement has a count on one of its lines only.
176
+ lines = StatementLines.for(subject.def_node, source, mutation.start_offset)
177
+ chosen = lines.flat_map { |l| coverage_map.tests_for(source_file, l) }.uniq
178
+ end
179
+ if chosen.empty?
180
+ # Method BODY range, not the whole def: the def/end lines are "covered" at
181
+ # class-load even when the body never runs (body_loc is the statements' span).
182
+ loc = subject&.body_loc
183
+ range = loc ? (loc.start_line..loc.end_line) : (line..line)
184
+ return [:verdict, Result.uncapturable] if coverage_map.method_uncapturable?(source_file, range)
185
+ return [:verdict, Result.ran_at_load] if ran_at_load?(source_file, mutation, subject, source, coverage_map)
186
+
187
+ return [:verdict, Result.no_coverage]
188
+ end
189
+
190
+ [:run, coverage_map.order_tests(source_file, chosen).map { |t| File.expand_path(t, coverage_map.project_root) }]
191
+ end
192
+
193
+ # True when the mutant's line ran while the app booted or its class loaded,
194
+ # so a test can check a value the original code computed before the mutant
195
+ # was applied. Shared by {coverage_selection}, {Runner.run} and the daemon backend.
196
+ #
197
+ # Only the lines of the statement that holds the mutant count
198
+ # ({StatementLines}), and only inside the method body. So code that runs
199
+ # only sometimes (`x if c`, a ternary branch) never counts: its line can
200
+ # count at load without it. The `def` line never counts: Ruby counts it
201
+ # when the method is defined.
202
+ #
203
+ # A one-line or endless def keeps its body on the `def` line, so it uses
204
+ # the method's own call count at load instead (#209), for code that runs
205
+ # each time the method does ({StatementLines.runs_with_method?}).
206
+ #
207
+ # @param source_file [String] the mutated source file path.
208
+ # @param mutation [Mutineer::Mutation] the mutation.
209
+ # @param subject [Mutineer::Subject, nil] the subject (for its method body range).
210
+ # @param source [String] the original source text.
211
+ # @param coverage_map [Mutineer::CoverageMap, nil] the coverage map.
212
+ # @return [Boolean]
213
+ def self.ran_at_load?(source_file, mutation, subject, source, coverage_map)
214
+ loc = subject&.body_loc
215
+ return false unless loc && coverage_map
216
+
217
+ def_loc = subject.def_node.location
218
+ def_line = def_loc.start_line
219
+ if loc.end_line == def_line
220
+ return coverage_map.method_ran_at_load?(source_file, def_line, def_loc.start_column) &&
221
+ StatementLines.runs_with_method?(subject.def_node, mutation.start_offset)
222
+ end
223
+
224
+ body = (loc.start_line..loc.end_line)
225
+ lines = StatementLines.for(subject.def_node, source, mutation.start_offset)
226
+ lines.any? { |l| l != def_line && body.cover?(l) && coverage_map.ran_at_load?(source_file, l) }
227
+ end
228
+
229
+ # A survivor whose line ran at load becomes `ran_at_load` ({ran_at_load?});
230
+ # any other result comes back unchanged. A `--matrix` row is kept.
231
+ #
232
+ # @param result [Mutineer::Result] the mutant's verdict.
233
+ # @param source_file [String] the mutated source file path.
234
+ # @param mutation [Mutineer::Mutation] the mutation.
235
+ # @param subject [Mutineer::Subject, nil] the subject.
236
+ # @param source [String] the original source text.
237
+ # @param coverage_map [Mutineer::CoverageMap, nil] the coverage map.
238
+ # @return [Mutineer::Result]
239
+ def self.load_verdict(result, source_file, mutation, subject, source, coverage_map)
240
+ return result unless result.survived? && ran_at_load?(source_file, mutation, subject, source, coverage_map)
241
+
242
+ result.with(status: :ran_at_load)
243
+ end
244
+
245
+ # Map each line number to :all or a set of operator symbols, using
246
+ # inline `# mutineer:disable-line [ops]` markers (RuboCop semantics: the marker
247
+ # sits on the same physical line as the code it silences). A bare marker
248
+ # disables every operator on that line; `disable-line a, b` only the listed
249
+ # operators. Block-form disable/enable ranges are intentionally not supported.
250
+ # Only a real `#` comment counts: Prism lists the comments, so the marker
251
+ # text inside a string, heredoc or regex silences nothing (#158).
252
+ def self.suppress_map(source, file)
253
+ map = {}
254
+ Parser.comments(source).grep(Prism::InlineComment).each do |comment|
255
+ line = comment.location.start_line
256
+ next unless (m = comment.slice.match(/#\s*mutineer:disable-line(?:\s+([\w,\s]+))?/))
257
+
258
+ ops = m[1]&.split(",")&.map(&:strip)&.reject(&:empty?)
259
+ # Only spaces or commas after the marker (e.g. `disable-line -- why`)
260
+ # is a bare marker, not an empty list that silences nothing.
261
+ ops = nil if ops&.empty?
262
+ unknown = ops.to_a.reject { |o| MutatorRegistry::ALL.key?(o) }
263
+ unknown.each do |o|
264
+ warn "mutineer: unknown operator #{o.inspect} in #{file}:#{line} " \
265
+ "(known: #{MutatorRegistry::ALL.keys.join(', ')}); write a reason after --"
266
+ end
267
+ map[line] = ops ? ops.map(&:to_sym).to_set : :all
268
+ end
269
+ map
270
+ end
271
+
272
+ # True when this mutant is suppressed: its line bears a disable-line marker
273
+ # (bare, or scoped to its operator), OR its new or old-format id is in the
274
+ # config ignore list. Checked at job-build time so a suppressed mutant is
275
+ # never forked.
276
+ #
277
+ # @param ids [Array<String>, String] the mutant's new id and its old-format
278
+ # id, or a single id (the pre-#126 call shape).
279
+ def self.suppressed?(operator, line, ids, disabled, ignore_set)
280
+ return true if Array(ids).any? { |id| ignore_set.include?(id) }
281
+
282
+ case (entry = disabled[line])
283
+ when :all then true
284
+ when Set then entry.include?(operator)
285
+ else false
286
+ end
287
+ end
288
+
289
+ # --since: keep only jobs whose mutation lands on a line changed since the git
290
+ # ref. Composes with coverage selection (it only narrows the job list; each
291
+ # surviving mutant still goes through Runner.run's coverage check). A file with
292
+ # no changed lines (absent from the diff) contributes no jobs. Line is computed
293
+ # exactly as Runner.run does, from the already-read source in source_map.
294
+ def self.filter_since(jobs, source_map, config)
295
+ changed = ChangedLines.for(ref: config.since, files: config.sources,
296
+ project_root: config.project_root)
297
+ jobs.select do |subject, mutation|
298
+ source = source_map[subject.file]
299
+ line = source.byteslice(0, mutation.start_offset).count("\n") + 1
300
+ abs = File.expand_path(subject.file, config.project_root)
301
+ changed.fetch(abs, []).include?(line)
302
+ end
303
+ end
304
+
305
+ # For each test file, the directory to add to $LOAD_PATH so its
306
+ # `require "test_helper"` (or spec_helper) resolves: the nearest ancestor
307
+ # holding that helper, plus the file's own dir as a fallback.
308
+ def self.test_load_roots(test_files)
309
+ test_files.flat_map do |f|
310
+ dir = File.dirname(f)
311
+ root = nil
312
+ loop do
313
+ if File.exist?(File.join(dir, "test_helper.rb")) || File.exist?(File.join(dir, "spec_helper.rb"))
314
+ root = dir
315
+ break
316
+ end
317
+ parent = File.dirname(dir)
318
+ break if parent == dir
319
+
320
+ dir = parent
321
+ end
322
+ [root, File.dirname(f)].compact
323
+ end.uniq
324
+ end
325
+
326
+ # The unique absolute directories holding the sources. Sweep target for both
327
+ # orphan mechanisms (in-process mutant tempfiles and external backup files),
328
+ # and shipped to the daemon via {DaemonBackend.boot_config} so it can sweep too.
329
+ # Shared so the path-expansion rule cannot drift between the paths.
330
+ #
331
+ # @param config [Mutineer::Config] run configuration.
332
+ # @return [Array<String>] unique absolute source directories.
333
+ def self.source_dirs(config)
334
+ config.sources.map { |f| File.dirname(File.expand_path(f, config.project_root)) }.uniq
335
+ end
336
+
337
+ # Removes stale mutant tempfiles from the given directories. The daemon writes a
338
+ # differently-named temp, so {DaemonBackend} passes its glob when it has to sweep
339
+ # tool-side (nothing boots on an empty run, so the daemon's own sweep never runs).
340
+ #
341
+ # @param dirs [Array<String>] directories to sweep.
342
+ # @param glob [String] filename pattern to remove.
343
+ # @return [void]
344
+ def self.sweep_orphans(dirs, glob = "mutineer_mutant*.rb")
345
+ dirs.each do |dir|
346
+ Dir.glob(File.join(dir, glob)).each do |f|
347
+ File.unlink(f) rescue nil # rubocop:disable Style/RescueModifier
348
+ end
349
+ end
350
+ end
351
+ end
352
+ end
@@ -62,7 +62,12 @@ module Mutineer
62
62
  # Drop runnables inherited from the parent suite (this is the child's
63
63
  # private copy — the parent is unaffected) so only the target test runs.
64
64
  Minitest::Runnable.reset
65
- Array(test_files).each { |f| load f }
65
+ # Each test class's file position: the file that first defined it.
66
+ rank = {}
67
+ Array(test_files).each_with_index do |f, i|
68
+ load f
69
+ Minitest::Runnable.runnables.each { |klass| rank[klass] ||= i }
70
+ end
66
71
 
67
72
  armed =
68
73
  if record_to
@@ -73,6 +78,7 @@ module Mutineer
73
78
  # Pin the seed only when a hook is armed; an unknown Minitest shape gets
74
79
  # the normal full, randomly ordered run.
75
80
  args = armed && !ENV["SEED"] ? ["--seed", STOP_AT_FIRST_FAILURE_SEED.to_s] : []
81
+ keep_file_order(rank) if armed
76
82
  # No silencing here: the fork boundary that calls this method has already
77
83
  # pointed stdout at File::NULL (see ChildStdout).
78
84
  passed = Minitest.run(args)
@@ -86,5 +92,46 @@ module Mutineer
86
92
  StopAtFirstFailure.disarm!
87
93
  KillRecorder.disarm!
88
94
  end
95
+
96
+ # Runs the test classes in the order of their files (#203). Minitest
97
+ # shuffles the classes with the seed, then runs the serial classes before
98
+ # the parallel (`parallelize_me!`) ones. A stable sort by file position
99
+ # after that shuffle puts the files in the given order and keeps the
100
+ # seeded order within one file. The serial-then-parallel split is
101
+ # unchanged. Only the child's own class list gets {FileOrder}.
102
+ #
103
+ # @api private
104
+ # @param rank [Hash{Class => Integer}] each class's file position.
105
+ # @return [void]
106
+ def self.keep_file_order(rank)
107
+ FileOrder.rank = rank
108
+ ::Minitest::Runnable.runnables.extend(FileOrder)
109
+ end
110
+
111
+ # Extends the list of test classes: its `shuffle` keeps the file order.
112
+ module FileOrder
113
+ class << self
114
+ # Each test class's file position.
115
+ #
116
+ # @return [Hash{Class => Integer}, nil]
117
+ attr_accessor :rank
118
+ end
119
+
120
+ # The seeded shuffle, then a stable sort by file position.
121
+ #
122
+ # @return [Array<Class>]
123
+ def shuffle(...)
124
+ rank = FileOrder.rank || {}
125
+ super.sort_by.with_index { |klass, i| [rank.fetch(klass, rank.size), i] }
126
+ end
127
+
128
+ # Minitest 5.15 and older drop empty classes before the shuffle, so the
129
+ # filtered list keeps the file order too.
130
+ #
131
+ # @return [Array<Class>]
132
+ def reject(...)
133
+ super.extend(FileOrder)
134
+ end
135
+ end
89
136
  end
90
137
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutineer
4
+ # Ends a forked child's process group once the process that forked it is
5
+ # gone (#101). A mutant or capture child leads its own process group, so a
6
+ # parent that dies, or is killed by a client that gave up on it, does not
7
+ # take the child's tests with it; without this, a hung test would run on and
8
+ # keep its worker database open. Needs no other part of Mutineer, so the
9
+ # app-side daemon can load it.
10
+ module OrphanGuard
11
+ # Seconds between checks. A forked child gets no signal on parent death.
12
+ POLL = 0.5
13
+
14
+ # Starts the watchdog in the current (child) process.
15
+ #
16
+ # @param parent [Integer] the parent's pid, read in the parent before
17
+ # `fork`, so a parent that dies before this call is still noticed.
18
+ # @return [Thread]
19
+ def self.start(parent)
20
+ Thread.new do
21
+ sleep(POLL) while Process.ppid == parent
22
+ # Group 0 only when this child leads its own group: a failed setpgid
23
+ # leaves it in the parent's group, which must not be killed.
24
+ Process.kill(:KILL, Process.getpgrp == Process.pid ? 0 : Process.pid)
25
+ end
26
+ end
27
+ end
28
+ end