mutineer 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +317 -0
- data/README.md +200 -28
- data/lib/mutineer/changed_lines.rb +57 -2
- data/lib/mutineer/cli.rb +142 -92
- data/lib/mutineer/config.rb +215 -56
- data/lib/mutineer/coverage_map.rb +161 -14
- data/lib/mutineer/daemon_backend.rb +3 -0
- data/lib/mutineer/daemon_client.rb +170 -13
- data/lib/mutineer/daemon_server.rb +2 -1
- data/lib/mutineer/external_backend.rb +2 -2
- data/lib/mutineer/isolation.rb +91 -6
- data/lib/mutineer/kill_channel.rb +284 -0
- data/lib/mutineer/kill_matrix.rb +88 -0
- data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
- data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
- data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +14 -68
- data/lib/mutineer/minitest_integration.rb +27 -10
- data/lib/mutineer/mutant_id.rb +5 -4
- data/lib/mutineer/mutator_registry.rb +12 -3
- data/lib/mutineer/mutators/array_literal.rb +0 -7
- data/lib/mutineer/mutators/base.rb +63 -4
- data/lib/mutineer/mutators/chain_link.rb +0 -7
- data/lib/mutineer/mutators/collection_method.rb +1 -1
- data/lib/mutineer/mutators/condition_forcing.rb +134 -0
- data/lib/mutineer/mutators/operand_removal.rb +0 -7
- data/lib/mutineer/mutators/operator_assignment.rb +113 -0
- data/lib/mutineer/mutators/return_nil.rb +5 -11
- data/lib/mutineer/mutators/string_literal.rb +1 -1
- data/lib/mutineer/pairing.rb +222 -15
- data/lib/mutineer/parser.rb +12 -3
- data/lib/mutineer/project.rb +7 -0
- data/lib/mutineer/rails_worker_db.rb +1 -1
- data/lib/mutineer/reporter.rb +288 -52
- data/lib/mutineer/result.rb +37 -1
- data/lib/mutineer/runner.rb +133 -16
- data/lib/mutineer/statement_lines.rb +143 -0
- data/lib/mutineer/test_runners/minitest.rb +4 -2
- data/lib/mutineer/test_runners/rspec.rb +243 -3
- data/lib/mutineer/test_runners.rb +5 -3
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer/worker_pool.rb +4 -2
- data/lib/mutineer.rb +3 -0
- metadata +10 -3
data/lib/mutineer/config.rb
CHANGED
|
@@ -8,18 +8,68 @@ module Mutineer
|
|
|
8
8
|
# never kill the host process. The CLI rescues this and maps it to exit 2.
|
|
9
9
|
class ConfigError < StandardError; end
|
|
10
10
|
|
|
11
|
+
# One row of the option schema: how a Config field is parsed and where the
|
|
12
|
+
# user can set it. `yaml_key` is nil for CLI-only options; `flag` is nil for
|
|
13
|
+
# YAML-only ones. `values`/`aliases` apply to the :enum type.
|
|
14
|
+
#
|
|
15
|
+
# @api private
|
|
16
|
+
ConfigOption = Struct.new(:field, :type, :yaml_key, :flag, :values, :aliases, keyword_init: true)
|
|
17
|
+
|
|
18
|
+
# Deprecated internal strategy names, mapped to their canonical equivalents.
|
|
19
|
+
STRATEGY_ALIASES = { "7a" => "reload", "7b" => "redefine" }.freeze
|
|
20
|
+
|
|
21
|
+
# The option schema. Config.parse is the only place that turns a raw value
|
|
22
|
+
# (a CLI string or a YAML scalar) into a typed one, so a value is checked once,
|
|
23
|
+
# at the boundary, whichever layer wrote it. Row order fixes the order of
|
|
24
|
+
# KNOWN_KEYS in warnings.
|
|
25
|
+
#
|
|
26
|
+
# @api private
|
|
27
|
+
CONFIG_OPTIONS = [
|
|
28
|
+
ConfigOption.new(field: :operators, type: :string_list, yaml_key: "operators", flag: "--operators"),
|
|
29
|
+
ConfigOption.new(field: :jobs, type: :positive_int, yaml_key: "jobs", flag: "--jobs"),
|
|
30
|
+
ConfigOption.new(field: :threshold, type: :percent, yaml_key: "threshold", flag: "--threshold"),
|
|
31
|
+
ConfigOption.new(field: :only, type: :string, yaml_key: "only", flag: "--only"),
|
|
32
|
+
ConfigOption.new(field: :require_paths, type: :string_list, yaml_key: "require"),
|
|
33
|
+
ConfigOption.new(field: :boot, type: :string, yaml_key: "boot", flag: "--boot"),
|
|
34
|
+
ConfigOption.new(field: :rails, type: :bool, yaml_key: "rails", flag: "--rails"),
|
|
35
|
+
ConfigOption.new(field: :since, type: :since, yaml_key: "since", flag: "--since"),
|
|
36
|
+
ConfigOption.new(field: :framework, type: :enum, yaml_key: "framework", flag: "--framework",
|
|
37
|
+
values: %w[minitest rspec]),
|
|
38
|
+
ConfigOption.new(field: :verbose, type: :bool, yaml_key: "verbose", flag: "--verbose"),
|
|
39
|
+
ConfigOption.new(field: :ignore, type: :string_list, yaml_key: "ignore"),
|
|
40
|
+
ConfigOption.new(field: :baseline, type: :string, yaml_key: "baseline", flag: "--baseline"),
|
|
41
|
+
ConfigOption.new(field: :fail_fast, type: :bool, yaml_key: "fail_fast", flag: "--fail-fast"),
|
|
42
|
+
ConfigOption.new(field: :matrix, type: :bool, yaml_key: "matrix", flag: "--matrix"),
|
|
43
|
+
ConfigOption.new(field: :test_command, type: :string, yaml_key: "test_command", flag: "--test-command"),
|
|
44
|
+
ConfigOption.new(field: :daemon, type: :bool, yaml_key: "daemon", flag: "--daemon"),
|
|
45
|
+
ConfigOption.new(field: :timeout, type: :positive_int, yaml_key: "timeout", flag: "--timeout"),
|
|
46
|
+
ConfigOption.new(field: :capture_timeout, type: :positive_int, yaml_key: "capture_timeout",
|
|
47
|
+
flag: "--capture-timeout"),
|
|
48
|
+
ConfigOption.new(field: :cache_dir, type: :string, yaml_key: "cache_dir", flag: "--cache-dir"),
|
|
49
|
+
ConfigOption.new(field: :format, type: :enum, flag: "--format", values: %w[human json html]),
|
|
50
|
+
ConfigOption.new(field: :strategy, type: :enum, flag: "--strategy", values: %w[reload redefine],
|
|
51
|
+
aliases: STRATEGY_ALIASES),
|
|
52
|
+
ConfigOption.new(field: :output, type: :string, flag: "--output"),
|
|
53
|
+
ConfigOption.new(field: :baseline_epsilon, type: :nonneg_float, flag: "--baseline-epsilon"),
|
|
54
|
+
ConfigOption.new(field: :dry_run, type: :bool, flag: "--dry-run")
|
|
55
|
+
].freeze
|
|
56
|
+
|
|
11
57
|
# Plain run configuration, populated by the CLI (or directly by the
|
|
12
58
|
# integration test). `operators` nil means "all default operators";
|
|
13
59
|
# `threshold` 0.0 means the CI gate is off (spec §10).
|
|
14
60
|
#
|
|
15
61
|
# Also holds: jobs (parallel workers), format (human|json), output (report
|
|
16
62
|
# file), strategy (reload|redefine), require_paths (extra files to load).
|
|
17
|
-
# Config loading and the CLI > file > default precedence merge live here
|
|
63
|
+
# Config loading and the CLI > file > default precedence merge live here; each
|
|
64
|
+
# layer holds only the keys the user wrote, and Config#explicit? reports them.
|
|
65
|
+
#
|
|
66
|
+
# `matrix` (--matrix) runs every covering test for each mutant and reports
|
|
67
|
+
# which tests kill it (see KillMatrix); it never changes a verdict.
|
|
18
68
|
#
|
|
19
69
|
# Boot mode adds: boot (a file to require ONCE in the parent so the app env,
|
|
20
70
|
# e.g. Rails, is booted before forking; sources are then NOT manually required)
|
|
21
|
-
# and rails (sugar: defaults boot to config/environment
|
|
22
|
-
# and reconnects ActiveRecord per fork).
|
|
71
|
+
# and rails (sugar: defaults boot to config/environment, prefers redefine without
|
|
72
|
+
# a daemon, and reconnects ActiveRecord per fork).
|
|
23
73
|
Config = Struct.new(
|
|
24
74
|
:sources, :tests, :operators, :threshold, :only, :dry_run,
|
|
25
75
|
:cache_dir, :project_root, :load_paths,
|
|
@@ -28,16 +78,23 @@ module Mutineer
|
|
|
28
78
|
# :daemon is user-facing (--daemon flag + KNOWN_KEYS + boolean coerce).
|
|
29
79
|
# :daemon_timeout stays programmatic (set by tests/Runner; no flag yet).
|
|
30
80
|
:baseline, :baseline_epsilon, :fail_fast, :test_command,
|
|
31
|
-
:daemon, :daemon_timeout,
|
|
81
|
+
:daemon, :daemon_timeout, :timeout, :capture_timeout, :matrix,
|
|
32
82
|
keyword_init: true
|
|
33
83
|
) do
|
|
34
84
|
# Config file name.
|
|
35
85
|
CONFIG_FILE = ".mutineer.yml"
|
|
36
|
-
# Keys accepted in .mutineer.yml. `require` maps to the
|
|
37
|
-
|
|
86
|
+
# Keys accepted in .mutineer.yml, derived from the schema. `require` maps to the
|
|
87
|
+
# :require_paths field.
|
|
88
|
+
KNOWN_KEYS = CONFIG_OPTIONS.filter_map(&:yaml_key).freeze
|
|
38
89
|
|
|
39
|
-
|
|
40
|
-
|
|
90
|
+
# @param explicit [Array<Symbol>] fields the user wrote (CLI or file). Derived
|
|
91
|
+
# values fill only the others; a programmatic Config.new writes none.
|
|
92
|
+
# @param from_file [Array<Symbol>] the explicit fields whose value came from
|
|
93
|
+
# the config file (the command line did not override them).
|
|
94
|
+
def initialize(explicit: [], from_file: [], **kwargs)
|
|
95
|
+
super(**kwargs)
|
|
96
|
+
@explicit = explicit.to_a.dup.freeze
|
|
97
|
+
@from_file = from_file.to_a.dup.freeze
|
|
41
98
|
self.sources ||= []
|
|
42
99
|
self.tests ||= []
|
|
43
100
|
self.threshold ||= 0.0
|
|
@@ -55,6 +112,29 @@ module Mutineer
|
|
|
55
112
|
self.baseline_epsilon ||= 0.0
|
|
56
113
|
self.fail_fast = false if fail_fast.nil?
|
|
57
114
|
self.daemon = false if daemon.nil?
|
|
115
|
+
self.matrix = false if matrix.nil?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# True when the user wrote `key`, on the command line or in the config
|
|
119
|
+
# file, whatever the value (`false` and `nil` count). The answer comes from
|
|
120
|
+
# which keys the layers held, so a new option needs no bookkeeping to be
|
|
121
|
+
# covered.
|
|
122
|
+
#
|
|
123
|
+
# @param key [Symbol] Config field name.
|
|
124
|
+
# @return [Boolean]
|
|
125
|
+
def explicit?(key)
|
|
126
|
+
@explicit.include?(key)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Where the user set `key`, for messages: the config-file key (as
|
|
130
|
+
# `name in .mutineer.yml`) when its value came from the file, else the
|
|
131
|
+
# command-line flag.
|
|
132
|
+
#
|
|
133
|
+
# @param key [Symbol] Config field name (a row of the option schema).
|
|
134
|
+
# @return [String] e.g. `"--fail-fast"` or `"fail_fast in .mutineer.yml"`.
|
|
135
|
+
def origin(key)
|
|
136
|
+
opt = CONFIG_OPTIONS.find { |o| o.field == key } or raise ArgumentError, "unknown option #{key.inspect}"
|
|
137
|
+
@from_file.include?(key) && opt.yaml_key ? "#{opt.yaml_key} in #{CONFIG_FILE}" : (opt.flag || opt.yaml_key)
|
|
58
138
|
end
|
|
59
139
|
|
|
60
140
|
# Walk from `start` toward `home`, returning the first .mutineer.yml path found
|
|
@@ -77,10 +157,18 @@ module Mutineer
|
|
|
77
157
|
end
|
|
78
158
|
|
|
79
159
|
# Parse a .mutineer.yml into a symbol-keyed hash of recognized keys. Unknown
|
|
80
|
-
# keys
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
|
|
160
|
+
# keys emit a one-line stderr warning and are ignored. Unknown operator names
|
|
161
|
+
# warn and are dropped. If that leaves no names, the file is an error: an
|
|
162
|
+
# empty operator list would run nothing and exit 0. Pass
|
|
163
|
+
# +defer_operators: true+ only when the command line replaces that list, so
|
|
164
|
+
# a blank or all-unknown file list does not block +--operators+. A YAML
|
|
165
|
+
# syntax error raises ConfigError: never a silent fallback to defaults, and
|
|
166
|
+
# never an exit from the lib layer.
|
|
167
|
+
#
|
|
168
|
+
# @param path [String] config file path.
|
|
169
|
+
# @param defer_operators [Boolean] keep an empty operator list for a CLI override.
|
|
170
|
+
# @return [Hash{Symbol => Object}]
|
|
171
|
+
def self.from_file(path, defer_operators: false)
|
|
84
172
|
raw = YAML.safe_load(File.read(path)) || {}
|
|
85
173
|
name = File.basename(path)
|
|
86
174
|
unless raw.is_a?(Hash)
|
|
@@ -96,28 +184,39 @@ module Mutineer
|
|
|
96
184
|
"(known: #{KNOWN_KEYS.join(', ')}); ignored"
|
|
97
185
|
next
|
|
98
186
|
end
|
|
99
|
-
|
|
187
|
+
field = field_for(ks)
|
|
188
|
+
parsed = parse(field, value, file: name, defer_operators: defer_operators)
|
|
189
|
+
if field == :operators
|
|
190
|
+
parsed = filter_operators(parsed, name)
|
|
191
|
+
if parsed.empty? && !defer_operators
|
|
192
|
+
raise ConfigError, "#{name}: operators must name at least one known operator"
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
out[field] = parsed
|
|
100
196
|
end
|
|
101
197
|
out
|
|
102
198
|
rescue Psych::SyntaxError => e
|
|
103
199
|
raise ConfigError, "#{File.basename(path)} parse error: #{e.message}"
|
|
104
200
|
end
|
|
105
201
|
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
202
|
+
# Merges the two user layers, CLI over file, then derives what neither wrote.
|
|
203
|
+
# A layer is a Hash of only the keys the user set, so precedence is `merge`
|
|
204
|
+
# and "did the user write this" is whether the key exists. Nothing tracks
|
|
205
|
+
# provenance by hand, and `false`/`nil` are ordinary values.
|
|
206
|
+
#
|
|
207
|
+
# @param cli_opts [Hash{Symbol => Object}] parsed command-line fields.
|
|
208
|
+
# @param file_hash [Hash{Symbol => Object}] parsed .mutineer.yml fields.
|
|
209
|
+
# @return [Mutineer::Config]
|
|
210
|
+
def self.resolve(cli_opts, file_hash)
|
|
211
|
+
user = file_hash.merge(cli_opts)
|
|
212
|
+
config = new(**user, explicit: user.keys, from_file: file_hash.keys - cli_opts.keys)
|
|
114
213
|
|
|
115
214
|
# --rails sugar: boot config/environment. Prefer redefine only for the
|
|
116
215
|
# in-process path (daemon is whole-file reload only). In-process --rails
|
|
117
216
|
# shares one test database, so force serial unless --daemon.
|
|
118
217
|
if config.rails
|
|
119
218
|
config.boot ||= "config/environment"
|
|
120
|
-
unless config.daemon || explicit
|
|
219
|
+
unless config.daemon || config.explicit?(:strategy)
|
|
121
220
|
config.strategy = "redefine"
|
|
122
221
|
end
|
|
123
222
|
unless config.daemon
|
|
@@ -129,9 +228,9 @@ module Mutineer
|
|
|
129
228
|
end
|
|
130
229
|
end
|
|
131
230
|
|
|
132
|
-
# Auto-detect the framework only when
|
|
133
|
-
#
|
|
134
|
-
#
|
|
231
|
+
# Auto-detect the framework only when the user wrote none: a value from
|
|
232
|
+
# either layer is already on config.framework and always wins. Default
|
|
233
|
+
# minitest unless the test files clearly look RSpec.
|
|
135
234
|
config.framework ||= detect_framework(config.tests)
|
|
136
235
|
config
|
|
137
236
|
end
|
|
@@ -155,42 +254,102 @@ module Mutineer
|
|
|
155
254
|
known_key == "require" ? :require_paths : known_key.to_sym
|
|
156
255
|
end
|
|
157
256
|
|
|
158
|
-
#
|
|
257
|
+
# Parses one raw value into the typed value for `field`. A value that does
|
|
258
|
+
# not fit its type raises ConfigError naming where it came from, so the CLI
|
|
259
|
+
# and .mutineer.yml report the same mistake in the same way. `nil` and
|
|
260
|
+
# `false` are valid results for some fields (`since: false` means "no scoping").
|
|
159
261
|
#
|
|
160
|
-
# @param
|
|
161
|
-
# @param value [Object] raw YAML value.
|
|
162
|
-
# @param
|
|
163
|
-
#
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
262
|
+
# @param field [Symbol] Config field name (a row of the option schema).
|
|
263
|
+
# @param value [Object] raw CLI string or YAML value.
|
|
264
|
+
# @param file [String, nil] config file name when the value came from it;
|
|
265
|
+
# nil when it came from the command line.
|
|
266
|
+
# @param defer_operators [Boolean] when true, a blank operator list is
|
|
267
|
+
# returned instead of raising, so +--operators+ can replace it. The flag
|
|
268
|
+
# itself still rejects a blank list.
|
|
269
|
+
# @return [Object] the typed value.
|
|
270
|
+
# @raise [Mutineer::ConfigError] when the value does not fit the field's type.
|
|
271
|
+
def self.parse(field, value, file: nil, defer_operators: false)
|
|
272
|
+
opt = CONFIG_OPTIONS.find { |o| o.field == field } or raise ArgumentError, "unknown option #{field.inspect}"
|
|
273
|
+
origin = file ? "#{file}: #{opt.yaml_key}" : opt.flag
|
|
274
|
+
got = "(got: #{value.inspect})"
|
|
275
|
+
case opt.type
|
|
276
|
+
when :positive_int
|
|
277
|
+
n = value.is_a?(Integer) ? value : (value.to_i if value.is_a?(String) && value.match?(/\A\d+\z/))
|
|
278
|
+
raise ConfigError, "#{origin} must be a positive integer, digits only #{got}" if n.nil? || n < 1
|
|
279
|
+
|
|
280
|
+
n
|
|
281
|
+
when :percent
|
|
282
|
+
f = finite_float(value)
|
|
283
|
+
raise ConfigError, "#{origin} must be a number between 0 and 100 #{got}" unless f && (0.0..100.0).cover?(f)
|
|
284
|
+
|
|
174
285
|
f
|
|
175
|
-
when
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
when
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
when
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
286
|
+
when :nonneg_float
|
|
287
|
+
f = finite_float(value)
|
|
288
|
+
raise ConfigError, "#{origin} must be a finite number, 0 or greater #{got}" unless f && f >= 0.0
|
|
289
|
+
|
|
290
|
+
f
|
|
291
|
+
when :bool
|
|
292
|
+
return value if [true, false].include?(value)
|
|
293
|
+
return value == "true" if %w[true false].include?(value)
|
|
294
|
+
|
|
295
|
+
raise ConfigError, "#{origin} must be true or false #{got}"
|
|
296
|
+
when :enum
|
|
297
|
+
name = opt.aliases&.fetch(value, nil) || value
|
|
298
|
+
return name if opt.values.include?(name)
|
|
299
|
+
|
|
300
|
+
prefix = file ? "#{file}: " : ""
|
|
301
|
+
raise ConfigError, "#{prefix}unknown #{field} #{value.to_s.inspect}. Expected: #{opt.values.join(', ')}"
|
|
302
|
+
when :string_list
|
|
303
|
+
items = Array(value).map(&:to_s)
|
|
304
|
+
# Only `operators` treats [] as "run these" rather than "use the
|
|
305
|
+
# default". A blank key then makes no mutants and exits 0. An empty
|
|
306
|
+
# `require` or `ignore` matches the default, so those stay valid.
|
|
307
|
+
if field == :operators && !defer_operators && ([nil, true, false].include?(value) || items.empty? || items.all? { |item| item.strip.empty? })
|
|
308
|
+
raise ConfigError, "#{origin} must name at least one operator, not blank #{got}"
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
items
|
|
312
|
+
when :string
|
|
313
|
+
# A key written with no value (`baseline:`) parses as nil. Keeping nil
|
|
314
|
+
# would switch the feature off without a word; main failed here, so the
|
|
315
|
+
# typo stays loud. An absent key never reaches parse, so it stays unset.
|
|
316
|
+
# `only: false` must not become the subject name "false" either: it
|
|
317
|
+
# matches nothing, so the run has no mutants and still exits 0.
|
|
318
|
+
raise ConfigError, "#{origin} must be a string #{got}" if [nil, true, false].include?(value)
|
|
319
|
+
# A blank cache directory (an unset CI variable) would put coverage.json
|
|
320
|
+
# in the project root, so it is an error, not the default.
|
|
321
|
+
raise ConfigError, "#{origin} must be a directory, not blank #{got}" if field == :cache_dir && value.to_s.strip.empty?
|
|
322
|
+
|
|
323
|
+
value.to_s
|
|
324
|
+
when :since
|
|
325
|
+
# `false` is the one way to say "no scoping" in the file. It becomes nil
|
|
326
|
+
# so every consumer's nil-check (runner scoping, the report's scoped
|
|
327
|
+
# marker) agrees; a false left raw would skip scoping but still mark
|
|
328
|
+
# the report scoped. A blank value is an error, not "no scoping": a
|
|
329
|
+
# `since: "$REF"` whose variable is unset must not turn scoping off.
|
|
330
|
+
return nil if value == false
|
|
331
|
+
raise ConfigError, "#{origin} must be a git ref, not blank #{got}" if value.to_s.strip.empty?
|
|
332
|
+
|
|
333
|
+
value.to_s
|
|
191
334
|
end
|
|
192
335
|
end
|
|
193
336
|
|
|
337
|
+
# Reads a finite Float from a number or a plain decimal string. Rejects booleans,
|
|
338
|
+
# NaN and Infinity: `Float(true)` is an error, but a YAML `.nan` is a Float.
|
|
339
|
+
# A string must be digits with an optional fraction, the same digits-only rule
|
|
340
|
+
# as `jobs`: `Float()` alone would also read `0x10`, `1_0`, `+2` and `1e2`.
|
|
341
|
+
#
|
|
342
|
+
# @api private
|
|
343
|
+
# @param value [Object] raw value.
|
|
344
|
+
# @return [Float, nil] the number, or nil when it is not a finite number.
|
|
345
|
+
def self.finite_float(value)
|
|
346
|
+
f = case value
|
|
347
|
+
when Integer, Float then value.to_f
|
|
348
|
+
when String then Float(value) if value.match?(/\A\d+(\.\d+)?\z/)
|
|
349
|
+
end
|
|
350
|
+
f if f&.finite?
|
|
351
|
+
end
|
|
352
|
+
|
|
194
353
|
# Drop (with a warning) operator names the registry does not know.
|
|
195
354
|
# Referenced lazily so config.rb carries no load-order dependency on the
|
|
196
355
|
# registry; by the time a config is parsed at runtime, it is loaded.
|
|
@@ -10,6 +10,7 @@ require_relative "minitest_integration"
|
|
|
10
10
|
require_relative "test_runners"
|
|
11
11
|
require_relative "child_stdout"
|
|
12
12
|
require_relative "project_path"
|
|
13
|
+
require_relative "pairing"
|
|
13
14
|
|
|
14
15
|
module Mutineer
|
|
15
16
|
# Maps `(source_file, line) -> [test_files]` so each mutant runs only against
|
|
@@ -98,8 +99,11 @@ module Mutineer
|
|
|
98
99
|
# rather than a genuine coverage gap? True iff some capture failed this run
|
|
99
100
|
# AND this file got zero coverage from any successful capture AND a failed
|
|
100
101
|
# test file maps to it by the _test/_spec/test_ naming convention. Derived
|
|
101
|
-
#
|
|
102
|
-
#
|
|
102
|
+
# from already-persisted state (@map keys + @failed_test_files). A split
|
|
103
|
+
# name checks the longer source files that existed when that check first
|
|
104
|
+
# ran for this map. Adding or deleting one of those files changes the
|
|
105
|
+
# coverage digest, so the next run captures again instead of retargeting
|
|
106
|
+
# a stored failure.
|
|
103
107
|
#
|
|
104
108
|
# File-level, convention-based attribution. A line covered only by a failed
|
|
105
109
|
# test in an otherwise-covered file stays no_coverage (condition 2), and a
|
|
@@ -112,7 +116,7 @@ module Mutineer
|
|
|
112
116
|
rel = relativize(absolute(file))
|
|
113
117
|
return false if covered_source_files.include?(rel)
|
|
114
118
|
|
|
115
|
-
|
|
119
|
+
failed_test_blames?(rel)
|
|
116
120
|
end
|
|
117
121
|
|
|
118
122
|
# Per-method taint. A mutant on a line whose enclosing method got zero
|
|
@@ -131,7 +135,7 @@ module Mutineer
|
|
|
131
135
|
return false if @failed_test_files.empty?
|
|
132
136
|
|
|
133
137
|
rel = relativize(absolute(file))
|
|
134
|
-
return false unless
|
|
138
|
+
return false unless failed_test_blames?(rel)
|
|
135
139
|
|
|
136
140
|
line_range.none? { |ln| @map.key?("#{rel}:#{ln}") }
|
|
137
141
|
end
|
|
@@ -143,17 +147,119 @@ module Mutineer
|
|
|
143
147
|
@map.keys.map { |k| k.rpartition(":").first }.to_set
|
|
144
148
|
end
|
|
145
149
|
|
|
146
|
-
#
|
|
147
|
-
#
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
150
|
+
# True when a failed test file pairs with `source_rel` by convention.
|
|
151
|
+
# Exact `_test` / `_spec` / `test_` names match by basename, as before.
|
|
152
|
+
# A split `<name>_*_test.rb` matches only in the mirrored test directory,
|
|
153
|
+
# and only when no longer source file owns that name (#87). The answer
|
|
154
|
+
# is kept for this map, so a later delete does not move the failure.
|
|
155
|
+
#
|
|
156
|
+
# @param source_rel [String] project-relative source path.
|
|
157
|
+
# @return [Boolean]
|
|
158
|
+
def failed_test_blames?(source_rel)
|
|
159
|
+
@blame_for ||= {}
|
|
160
|
+
return @blame_for[source_rel] if @blame_for.key?(source_rel)
|
|
161
|
+
|
|
162
|
+
@blame_for[source_rel] = exact_failed_test?(source_rel) || split_failed_test?(source_rel)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# True when a failed exact `_test` / `_spec` / `test_` file pairs with
|
|
166
|
+
# this source. An `app/` or `lib/` source only accepts a `test/` or
|
|
167
|
+
# `spec/` file from its mirrored directory, so
|
|
168
|
+
# `test/foo/bar_upsert_test.rb` does not taint `app/other/bar_upsert.rb`.
|
|
169
|
+
# Any other source still matches by basename. A fixture under
|
|
170
|
+
# `test/fixtures/` and an explicit test both name their source that way.
|
|
171
|
+
#
|
|
172
|
+
# @param source_rel [String] project-relative source path.
|
|
173
|
+
# @return [Boolean]
|
|
174
|
+
def exact_failed_test?(source_rel)
|
|
175
|
+
name = File.basename(source_rel, ".rb")
|
|
176
|
+
base, lib = Pairing.logical_path(source_rel)
|
|
177
|
+
@failed_test_files.any? do |test_path|
|
|
178
|
+
next false unless failed_test_target_name(test_path) == name
|
|
179
|
+
|
|
180
|
+
rel = relativize(test_path)
|
|
181
|
+
dir = File.dirname(rel)
|
|
182
|
+
if app_or_lib_source?(source_rel) && conventional_test_dir?(dir)
|
|
183
|
+
mirrored_test_dir?(rel, base, lib) || spec_mirror?(rel, base, lib)
|
|
184
|
+
else
|
|
185
|
+
true
|
|
155
186
|
end
|
|
156
|
-
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# True when pairing gives this source a mirrored test directory.
|
|
191
|
+
#
|
|
192
|
+
# @param source_rel [String] project-relative source path.
|
|
193
|
+
# @return [Boolean]
|
|
194
|
+
def app_or_lib_source?(source_rel)
|
|
195
|
+
source_rel.start_with?("app/", "lib/")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Basename a failed test file would pair with, before the directory check.
|
|
199
|
+
#
|
|
200
|
+
# @param test_path [String]
|
|
201
|
+
# @return [String]
|
|
202
|
+
def failed_test_target_name(test_path)
|
|
203
|
+
name = File.basename(test_path, ".rb")
|
|
204
|
+
case name
|
|
205
|
+
when /_(test|spec)\z/ then name.sub(/_(test|spec)\z/, "")
|
|
206
|
+
when "test_helper" then name
|
|
207
|
+
else name.delete_prefix("test_")
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# True when `dir` is `test`, `spec`, or a directory under one of them.
|
|
212
|
+
#
|
|
213
|
+
# @param dir [String] project-relative directory.
|
|
214
|
+
# @return [Boolean]
|
|
215
|
+
def conventional_test_dir?(dir)
|
|
216
|
+
dir == "test" || dir == "spec" || dir.start_with?("test/", "spec/")
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# True when a failed split test file pairs with this source. Directory and
|
|
220
|
+
# the longer-source check match {Pairing}, so a test outside the mirror, or
|
|
221
|
+
# one owned by `user_session.rb`, does not taint `user.rb`.
|
|
222
|
+
#
|
|
223
|
+
# @param source_rel [String] project-relative source path.
|
|
224
|
+
# @return [Boolean]
|
|
225
|
+
def split_failed_test?(source_rel)
|
|
226
|
+
base, lib = Pairing.logical_path(source_rel)
|
|
227
|
+
name = File.basename(base)
|
|
228
|
+
@failed_test_files.any? do |t|
|
|
229
|
+
test_rel = relativize(t)
|
|
230
|
+
entry = File.basename(test_rel)
|
|
231
|
+
next false unless Pairing.split_entry?(entry, name)
|
|
232
|
+
next false unless mirrored_test_dir?(test_rel, base, lib)
|
|
233
|
+
next false if Pairing.claimed_by_longer_source?(@project_root, base, entry, File.dirname(test_rel))
|
|
234
|
+
|
|
235
|
+
true
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# True when `test_rel` sits in a mirrored test directory for `base`.
|
|
240
|
+
#
|
|
241
|
+
# @param test_rel [String] project-relative test path.
|
|
242
|
+
# @param base [String] logical source path without extension.
|
|
243
|
+
# @param lib [Boolean] whether the source originated from lib/.
|
|
244
|
+
# @return [Boolean]
|
|
245
|
+
def mirrored_test_dir?(test_rel, base, lib)
|
|
246
|
+
dirs = [Pairing.mirror_dir("test", base)]
|
|
247
|
+
dirs << Pairing.mirror_dir("test/lib", base) if lib
|
|
248
|
+
dirs.include?(File.dirname(test_rel))
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# True when `test_rel` sits in a mirrored spec directory for `base`.
|
|
252
|
+
# Split Minitest names stay in {#mirrored_test_dir?}. An exact `_spec`
|
|
253
|
+
# file uses this directory.
|
|
254
|
+
#
|
|
255
|
+
# @param test_rel [String] project-relative test path.
|
|
256
|
+
# @param base [String] logical source path without extension.
|
|
257
|
+
# @param lib [Boolean] whether the source originated from lib/.
|
|
258
|
+
# @return [Boolean]
|
|
259
|
+
def spec_mirror?(test_rel, base, lib)
|
|
260
|
+
dirs = [Pairing.mirror_dir("spec", base)]
|
|
261
|
+
dirs << Pairing.mirror_dir("spec/lib", base) if lib
|
|
262
|
+
dirs.include?(File.dirname(test_rel))
|
|
157
263
|
end
|
|
158
264
|
|
|
159
265
|
# Shared cache dance for both build paths: hit the digest-keyed cache, else
|
|
@@ -809,11 +915,52 @@ module Mutineer
|
|
|
809
915
|
digest_group(d, "source", @source_paths)
|
|
810
916
|
digest_group(d, "test", @test_paths)
|
|
811
917
|
digest_group(d, "boot", [boot_digest_path]) if @boot_path
|
|
918
|
+
ownership_paths.each do |rel|
|
|
919
|
+
d.update("owner\0")
|
|
920
|
+
d.update(rel)
|
|
921
|
+
d.update("\0")
|
|
922
|
+
end
|
|
812
923
|
@load_paths.sort.each { |lp| d.update("loadpath\0#{lp}\0") }
|
|
813
924
|
d.update("framework\0#{@framework}\0")
|
|
814
925
|
d.hexdigest
|
|
815
926
|
end
|
|
816
927
|
|
|
928
|
+
# Longer source files that can take a split test from a configured source.
|
|
929
|
+
# Only their presence is digested. A content edit of a sibling does not
|
|
930
|
+
# change pairing, and it does not rebuild coverage. Each directory is
|
|
931
|
+
# listed once, even when many sources share it.
|
|
932
|
+
#
|
|
933
|
+
# @return [Array<String>] project-relative paths, sorted.
|
|
934
|
+
def ownership_paths
|
|
935
|
+
root = File.expand_path(@project_root)
|
|
936
|
+
listings = {}
|
|
937
|
+
paths = @source_paths.flat_map do |source|
|
|
938
|
+
rel = relativize(absolute(source))
|
|
939
|
+
next [] if rel.start_with?("/")
|
|
940
|
+
|
|
941
|
+
base, = Pairing.logical_path(rel)
|
|
942
|
+
name = File.basename(base)
|
|
943
|
+
dir = File.dirname(base)
|
|
944
|
+
folders = [dir == "." ? nil : dir]
|
|
945
|
+
%w[app lib].each { |prefix| folders << (dir == "." ? prefix : File.join(prefix, dir)) }
|
|
946
|
+
folders.uniq.flat_map do |folder|
|
|
947
|
+
abs = folder ? File.join(root, folder) : root
|
|
948
|
+
entries = listings.fetch(abs) do
|
|
949
|
+
listings[abs] = File.directory?(abs) ? Dir.children(abs) : []
|
|
950
|
+
end
|
|
951
|
+
entries.filter_map do |entry|
|
|
952
|
+
next unless entry.end_with?(".rb")
|
|
953
|
+
|
|
954
|
+
stem = entry.delete_suffix(".rb")
|
|
955
|
+
next unless stem.start_with?("#{name}_") && stem.length > name.length
|
|
956
|
+
|
|
957
|
+
folder ? File.join(folder, entry) : entry
|
|
958
|
+
end
|
|
959
|
+
end
|
|
960
|
+
end
|
|
961
|
+
paths.uniq.sort
|
|
962
|
+
end
|
|
963
|
+
|
|
817
964
|
# boot_path is a require-style path (e.g. "config/environment", no extension);
|
|
818
965
|
# resolve it to the real file for reading, appending ".rb" when needed.
|
|
819
966
|
def boot_digest_path
|
|
@@ -279,6 +279,7 @@ module Mutineer
|
|
|
279
279
|
project_root: config.project_root,
|
|
280
280
|
boot: File.expand_path(config.boot || "config/environment", config.project_root),
|
|
281
281
|
load_paths: Runner.test_load_roots(abs_tests),
|
|
282
|
+
cache_dir: File.expand_path(config.cache_dir, config.project_root),
|
|
282
283
|
source_dirs: Runner.source_dirs(config), # so the daemon can sweep orphan mutant temps
|
|
283
284
|
framework: config.framework,
|
|
284
285
|
rails: config.rails,
|
|
@@ -290,6 +291,8 @@ module Mutineer
|
|
|
290
291
|
# instrumentation/memory across every mutant fork). `sources`/`tests` are the
|
|
291
292
|
# map-build inputs.
|
|
292
293
|
coverage: coverage,
|
|
294
|
+
# The map-building daemon's capture limit (--capture-timeout); nil = default.
|
|
295
|
+
capture_timeout: config.capture_timeout,
|
|
293
296
|
sources: config.sources.map { |s| File.expand_path(s, config.project_root) },
|
|
294
297
|
tests: abs_tests
|
|
295
298
|
}
|