mutineer 1.2.0 → 1.4.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 +133 -0
- data/README.md +53 -4
- data/lib/mutineer/baseline.rb +74 -11
- data/lib/mutineer/changed_lines.rb +34 -2
- data/lib/mutineer/cli.rb +102 -81
- data/lib/mutineer/config.rb +157 -49
- data/lib/mutineer/coverage_map.rb +7 -30
- data/lib/mutineer/daemon_backend.rb +5 -4
- data/lib/mutineer/isolation.rb +8 -3
- data/lib/mutineer/mutant_id.rb +72 -13
- data/lib/mutineer/mutator_registry.rb +12 -3
- data/lib/mutineer/mutators/condition_forcing.rb +141 -0
- data/lib/mutineer/mutators/operator_assignment.rb +120 -0
- data/lib/mutineer/project.rb +47 -18
- data/lib/mutineer/project_path.rb +57 -0
- data/lib/mutineer/reporter.rb +18 -5
- data/lib/mutineer/result.rb +2 -2
- data/lib/mutineer/runner.rb +64 -20
- data/lib/mutineer/subject.rb +13 -2
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer.rb +2 -0
- metadata +4 -1
data/lib/mutineer/cli.rb
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "optparse"
|
|
4
|
-
require "set"
|
|
5
4
|
require "open3"
|
|
6
5
|
require_relative "version"
|
|
7
6
|
require_relative "config"
|
|
@@ -67,19 +66,14 @@ module Mutineer
|
|
|
67
66
|
--help Print this help and exit
|
|
68
67
|
USAGE
|
|
69
68
|
|
|
70
|
-
# Field symbols whose config-file value is suppressed when the flag is typed.
|
|
71
|
-
PRECEDENCE_FLAGS = %i[operators jobs threshold only].freeze
|
|
72
|
-
|
|
73
|
-
# Deprecated internal strategy names, mapped to their canonical equivalents.
|
|
74
|
-
STRATEGY_ALIASES = { "7a" => "reload", "7b" => "redefine" }.freeze
|
|
75
|
-
|
|
76
69
|
# Parses arguments, executes the command, and exits.
|
|
77
70
|
#
|
|
78
71
|
# @param argv [Array<String>] raw command-line arguments.
|
|
79
72
|
# @return [void]
|
|
80
73
|
def self.start(argv)
|
|
81
|
-
|
|
82
|
-
|
|
74
|
+
# The CLI layer holds only the fields the user typed, so Config.resolve
|
|
75
|
+
# can tell a typed `false`/`nil` from an absent flag by whether the key exists.
|
|
76
|
+
opts = {}
|
|
83
77
|
show_operators = false
|
|
84
78
|
|
|
85
79
|
parser = OptionParser.new do |o|
|
|
@@ -94,47 +88,40 @@ module Mutineer
|
|
|
94
88
|
end
|
|
95
89
|
o.on("--list-operators") { show_operators = true }
|
|
96
90
|
o.on("--dry-run") { opts[:dry_run] = true }
|
|
97
|
-
o.on("--fail-fast") { opts[:fail_fast] = true
|
|
98
|
-
o.on("--only NAME") { |v| opts[:only] = v
|
|
99
|
-
o.on("--since REF") { |v| opts[:since] =
|
|
100
|
-
# A typed "no" must beat a .mutineer.yml `since:` key
|
|
101
|
-
#
|
|
102
|
-
o.on("--no-since") { opts[:since] = nil
|
|
91
|
+
o.on("--fail-fast") { opts[:fail_fast] = true }
|
|
92
|
+
o.on("--only NAME") { |v| opts[:only] = v }
|
|
93
|
+
o.on("--since REF") { |v| opts[:since] = Config.parse(:since, v) }
|
|
94
|
+
# A typed "no" must beat a .mutineer.yml `since:` key: the key is present
|
|
95
|
+
# with a nil value, and nil is a value.
|
|
96
|
+
o.on("--no-since") { opts[:since] = nil }
|
|
103
97
|
o.on("--test FILE") { |v| (opts[:tests] ||= []) << v }
|
|
104
|
-
o.on("--operators LIST")
|
|
105
|
-
|
|
106
|
-
f = Float(v, exception: false)
|
|
107
|
-
if f.nil?
|
|
108
|
-
warn "mutineer: --threshold requires a number between 0 and 100 (got: #{v.inspect})"
|
|
109
|
-
exit 2
|
|
110
|
-
end
|
|
111
|
-
opts[:threshold] = f
|
|
112
|
-
explicit << :threshold
|
|
98
|
+
o.on("--operators LIST") do |v|
|
|
99
|
+
opts[:operators] = Config.parse(:operators, v.split(",").map(&:strip))
|
|
113
100
|
end
|
|
114
|
-
o.on("--
|
|
115
|
-
o.on("--
|
|
116
|
-
o.on("--
|
|
117
|
-
o.on("--
|
|
101
|
+
o.on("--threshold FLOAT") { |v| opts[:threshold] = Config.parse(:threshold, v) }
|
|
102
|
+
o.on("--jobs N") { |v| opts[:jobs] = Config.parse(:jobs, v) }
|
|
103
|
+
o.on("--strategy STRAT") { |v| opts[:strategy] = Config.parse(:strategy, v) }
|
|
104
|
+
o.on("--framework NAME") { |v| opts[:framework] = Config.parse(:framework, v) }
|
|
105
|
+
o.on("--boot FILE") { |v| opts[:boot] = v }
|
|
118
106
|
o.on("--rails") { opts[:rails] = true }
|
|
119
107
|
o.on("--verbose") { opts[:verbose] = true }
|
|
120
108
|
o.on("--debug") { opts[:verbose] = true } # alias of --verbose
|
|
121
|
-
o.on("--format FORMAT") { |v| opts[:format] = v }
|
|
109
|
+
o.on("--format FORMAT") { |v| opts[:format] = Config.parse(:format, v) }
|
|
122
110
|
o.on("--output FILE") { |v| opts[:output] = v }
|
|
123
|
-
# --baseline is
|
|
124
|
-
|
|
125
|
-
o.on("--baseline
|
|
126
|
-
o.on("--baseline-epsilon FLOAT") { |v| opts[:baseline_epsilon] = v.to_f }
|
|
111
|
+
# --baseline-epsilon is CLI-only.
|
|
112
|
+
o.on("--baseline FILE") { |v| opts[:baseline] = v }
|
|
113
|
+
o.on("--baseline-epsilon FLOAT") { |v| opts[:baseline_epsilon] = Config.parse(:baseline_epsilon, v) }
|
|
127
114
|
# Run the target suite as a subprocess in the app's OWN runtime so
|
|
128
115
|
# mutineer (Ruby >= 3.4) can mutation-test apps pinned to an older Ruby.
|
|
129
|
-
o.on("--test-command CMD") { |v| opts[:test_command] = v
|
|
116
|
+
o.on("--test-command CMD") { |v| opts[:test_command] = v }
|
|
130
117
|
# Boot the app ONCE in a persistent daemon and fork per mutant, with
|
|
131
118
|
# per-worker DB isolation so --jobs N is safe under Rails.
|
|
132
|
-
o.on("--daemon") { opts[:daemon] = true
|
|
119
|
+
o.on("--daemon") { opts[:daemon] = true }
|
|
133
120
|
end
|
|
134
121
|
|
|
135
122
|
begin
|
|
136
123
|
parser.parse!(argv)
|
|
137
|
-
rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
|
|
124
|
+
rescue OptionParser::InvalidOption, OptionParser::MissingArgument, Mutineer::ConfigError => e
|
|
138
125
|
warn "mutineer: #{e.message}"
|
|
139
126
|
exit 2
|
|
140
127
|
end
|
|
@@ -152,7 +139,7 @@ module Mutineer
|
|
|
152
139
|
begin
|
|
153
140
|
file_path = Config.find_file
|
|
154
141
|
file_hash = file_path ? Config.from_file(file_path) : {}
|
|
155
|
-
config = Config.resolve(opts, file_hash
|
|
142
|
+
config = Config.resolve(opts, file_hash)
|
|
156
143
|
rescue Mutineer::ConfigError => e
|
|
157
144
|
# The lib layer raises instead of killing the host; the CLI maps a
|
|
158
145
|
# config (usage) error to exit 2.
|
|
@@ -162,10 +149,11 @@ module Mutineer
|
|
|
162
149
|
|
|
163
150
|
case argv.first
|
|
164
151
|
when "run"
|
|
152
|
+
warn_config_root_mismatch(file_path, config.project_root) if file_path
|
|
165
153
|
# A directory source expands to its **/*.rb files; literal files pass
|
|
166
154
|
# through. Test inference (when --test is omitted) happens in validate!.
|
|
167
155
|
config.sources = Pairing.expand_sources(argv[1..], project_root: config.project_root)
|
|
168
|
-
run(config
|
|
156
|
+
run(config)
|
|
169
157
|
else
|
|
170
158
|
warn "mutineer: unknown command '#{argv.first}'"
|
|
171
159
|
exit 2
|
|
@@ -186,14 +174,13 @@ module Mutineer
|
|
|
186
174
|
# Runs the requested command after validation.
|
|
187
175
|
#
|
|
188
176
|
# @param config [Mutineer::Config] run configuration.
|
|
189
|
-
# @param explicit [Set<Symbol>] explicit CLI fields.
|
|
190
177
|
# @return [void]
|
|
191
|
-
def self.run(config
|
|
178
|
+
def self.run(config)
|
|
192
179
|
if config.sources.empty?
|
|
193
180
|
warn "mutineer: run requires at least one source file"
|
|
194
181
|
exit 2
|
|
195
182
|
end
|
|
196
|
-
validate!(config
|
|
183
|
+
validate!(config)
|
|
197
184
|
|
|
198
185
|
config.dry_run ? dry_run(config) : execute(config)
|
|
199
186
|
rescue ArgumentError => e
|
|
@@ -235,39 +222,8 @@ module Mutineer
|
|
|
235
222
|
#
|
|
236
223
|
# @api private
|
|
237
224
|
# @param config [Mutineer::Config] run configuration.
|
|
238
|
-
# @param explicit [Set<Symbol>] explicit CLI fields.
|
|
239
225
|
# @return [void]
|
|
240
|
-
def self.validate!(config
|
|
241
|
-
unless (0.0..100.0).cover?(config.threshold)
|
|
242
|
-
warn "mutineer: --threshold must be between 0 and 100"
|
|
243
|
-
exit 2
|
|
244
|
-
end
|
|
245
|
-
|
|
246
|
-
jobs = Integer(config.jobs.to_s, exception: false)
|
|
247
|
-
if jobs.nil? || jobs < 1
|
|
248
|
-
warn "mutineer: --jobs requires a positive integer (got: #{config.jobs})"
|
|
249
|
-
exit 2
|
|
250
|
-
end
|
|
251
|
-
config.jobs = jobs
|
|
252
|
-
|
|
253
|
-
unless %w[human json html].include?(config.format)
|
|
254
|
-
warn %(mutineer: unknown format "#{config.format}". Expected: human, json, html)
|
|
255
|
-
exit 2
|
|
256
|
-
end
|
|
257
|
-
|
|
258
|
-
# Canonical strategies are reload|redefine; 7a/7b are accepted as deprecated
|
|
259
|
-
# aliases. Normalize to canonical so the rest of the pipeline sees one name.
|
|
260
|
-
config.strategy = STRATEGY_ALIASES.fetch(config.strategy, config.strategy)
|
|
261
|
-
unless %w[reload redefine].include?(config.strategy)
|
|
262
|
-
warn %(mutineer: unknown strategy "#{config.strategy}". Expected: reload, redefine)
|
|
263
|
-
exit 2
|
|
264
|
-
end
|
|
265
|
-
|
|
266
|
-
unless %w[minitest rspec].include?(config.framework)
|
|
267
|
-
warn %(mutineer: unknown framework "#{config.framework}". Expected: minitest, rspec)
|
|
268
|
-
exit 2
|
|
269
|
-
end
|
|
270
|
-
|
|
226
|
+
def self.validate!(config)
|
|
271
227
|
validate_test_command!(config) if config.test_command
|
|
272
228
|
|
|
273
229
|
validate_since!(config) if config.since
|
|
@@ -276,7 +232,7 @@ module Mutineer
|
|
|
276
232
|
|
|
277
233
|
# When --test is omitted, infer each source's test by convention. Autopair
|
|
278
234
|
# also re-detects framework from inferred tests when --framework was not set.
|
|
279
|
-
autopair!(config
|
|
235
|
+
autopair!(config) unless config.dry_run
|
|
280
236
|
|
|
281
237
|
# Daemon validation runs AFTER autopair so auto-inferred *_spec.rb tests
|
|
282
238
|
# cannot bypass the RSpec rejection (framework would still be minitest if we
|
|
@@ -407,9 +363,8 @@ module Mutineer
|
|
|
407
363
|
#
|
|
408
364
|
# @api private
|
|
409
365
|
# @param config [Mutineer::Config] run configuration.
|
|
410
|
-
# @param explicit [Set<Symbol>] explicit CLI fields.
|
|
411
366
|
# @return [void]
|
|
412
|
-
def self.autopair!(config
|
|
367
|
+
def self.autopair!(config)
|
|
413
368
|
return unless config.tests.empty?
|
|
414
369
|
|
|
415
370
|
paired = config.sources.filter_map do |s|
|
|
@@ -421,7 +376,7 @@ module Mutineer
|
|
|
421
376
|
end
|
|
422
377
|
config.sources = paired.map(&:first)
|
|
423
378
|
config.tests = paired.map(&:last).uniq
|
|
424
|
-
config.framework = Config.detect_framework(config.tests) unless explicit
|
|
379
|
+
config.framework = Config.detect_framework(config.tests) unless config.explicit?(:framework)
|
|
425
380
|
|
|
426
381
|
return unless config.sources.empty?
|
|
427
382
|
return if config.boot # let the --boot/--rails-requires-test check report it
|
|
@@ -469,7 +424,8 @@ module Mutineer
|
|
|
469
424
|
exit 2
|
|
470
425
|
end
|
|
471
426
|
|
|
472
|
-
aggregate, source_map = Runner.execute(config)
|
|
427
|
+
aggregate, source_map, extras = Runner.execute(config)
|
|
428
|
+
warn_legacy_ignore_matches(extras[:legacy_ignore_matches])
|
|
473
429
|
reporter = Reporter.new(aggregate, source_map)
|
|
474
430
|
|
|
475
431
|
# Diff the current run against the baseline (preflighted above) by the
|
|
@@ -479,12 +435,18 @@ module Mutineer
|
|
|
479
435
|
# so only the new-survivor half of the gate applies (see Baseline#diff).
|
|
480
436
|
delta = if config.baseline
|
|
481
437
|
Baseline.load(config.baseline).diff(aggregate, epsilon: config.baseline_epsilon,
|
|
482
|
-
scoped: !config.since.nil
|
|
438
|
+
scoped: !config.since.nil?,
|
|
439
|
+
id_map: extras[:id_map],
|
|
440
|
+
project_root: config.project_root)
|
|
483
441
|
end
|
|
442
|
+
warn_legacy_baseline if delta&.legacy_matches&.positive?
|
|
484
443
|
|
|
444
|
+
# ignore counts old-format entries (one warning each), not the ids they matched.
|
|
445
|
+
legacy_id_matches = { ignore: extras[:legacy_ignore_matches].size,
|
|
446
|
+
baseline: delta ? delta.legacy_matches : 0 }
|
|
485
447
|
reporter.report(out: $stdout, err: $stderr, threshold: config.threshold,
|
|
486
448
|
format: config.format, output: config.output, baseline: delta,
|
|
487
|
-
scoped: !config.since.nil
|
|
449
|
+
scoped: !config.since.nil?, legacy_id_matches: legacy_id_matches)
|
|
488
450
|
|
|
489
451
|
# Warn (stderr, so it never pollutes json/html) that an external run's score
|
|
490
452
|
# is not comparable to an in-process run: no coverage narrowing (uncovered
|
|
@@ -523,6 +485,64 @@ module Mutineer
|
|
|
523
485
|
"enable with --operators <list>."
|
|
524
486
|
end
|
|
525
487
|
|
|
488
|
+
# Warns once when the loaded .mutineer.yml sits outside the run directory
|
|
489
|
+
# (#126). Mutant ids hash each file's path relative to the run directory,
|
|
490
|
+
# but the config is found by walking up, so a run from a subdirectory loads
|
|
491
|
+
# the same ignore list while its ids no longer match. A config in the home
|
|
492
|
+
# directory is a personal default, not a project root, so it never warns.
|
|
493
|
+
#
|
|
494
|
+
# @param file_path [String] the .mutineer.yml that was loaded.
|
|
495
|
+
# @param project_root [String] the run directory ids are relative to.
|
|
496
|
+
# @return [void]
|
|
497
|
+
def self.warn_config_root_mismatch(file_path, project_root)
|
|
498
|
+
config_dir = ProjectPath.root_real(File.dirname(file_path))
|
|
499
|
+
return if config_dir == ProjectPath.root_real(project_root)
|
|
500
|
+
return if config_dir == ProjectPath.root_real(Dir.home)
|
|
501
|
+
|
|
502
|
+
warn "[mutineer] loaded #{file_path}, but mutant ids are relative to the run directory " \
|
|
503
|
+
"#{project_root}, not to #{config_dir}. Ignore ids and baselines written from " \
|
|
504
|
+
"#{config_dir} will not match this run. Run mutineer from #{config_dir}."
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# Warns once per old-format `ignore:` entry (#126), naming each new id it
|
|
508
|
+
# matched with that mutant's file and subject. mutineer cannot tell a full
|
|
509
|
+
# run from a narrowed one, so the text always says the list covers only this
|
|
510
|
+
# run's mutants. An entry that matched more than one distinct mutant (in
|
|
511
|
+
# other files, or same-named methods in one file) over-matched: the old id
|
|
512
|
+
# could not tell them apart, so replacing it with every new id would keep
|
|
513
|
+
# suppressing the mutants it hid by accident.
|
|
514
|
+
#
|
|
515
|
+
# @param matches [Hash{String => Array<Hash{Symbol => String}>}] old-format
|
|
516
|
+
# entry => one `{id:, file:, subject:}` hash per matched mutant.
|
|
517
|
+
# @return [void]
|
|
518
|
+
def self.warn_legacy_ignore_matches(matches)
|
|
519
|
+
matches.each do |old, hits|
|
|
520
|
+
listed = hits.map { |h| "#{h[:id]} (#{h[:file]}, #{h[:subject]})" }.join(", ")
|
|
521
|
+
advice = if hits.map { |h| h[:id] }.uniq.size > 1
|
|
522
|
+
"#{old} over-matched: the old format could not tell these mutants apart. " \
|
|
523
|
+
"Replace #{old} and keep only the ids for the mutant you meant to ignore, not all of them."
|
|
524
|
+
else
|
|
525
|
+
"Replace #{old} with the new ids in your ignore list."
|
|
526
|
+
end
|
|
527
|
+
warn "[mutineer] ignore entry #{old} uses the old id format, which did not include the " \
|
|
528
|
+
"file path. It matched these new ids: #{listed}. This list covers only mutants in " \
|
|
529
|
+
"this run's sources and operators; a run over every source gives the complete " \
|
|
530
|
+
"replacement. #{advice}"
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
# Warns once that the --baseline file stores old-format ids (#126), so the
|
|
535
|
+
# diff fell back to matching on them. Called only when a survivor matched
|
|
536
|
+
# through an old id alone.
|
|
537
|
+
#
|
|
538
|
+
# @return [void]
|
|
539
|
+
def self.warn_legacy_baseline
|
|
540
|
+
warn "[mutineer] the baseline uses the old id format, which did not include the file " \
|
|
541
|
+
"path, so survivors were matched on their old ids and files. Regenerate the baseline " \
|
|
542
|
+
"(run with --format json and save the output), but only after every gate that reads " \
|
|
543
|
+
"it runs this mutineer version or later."
|
|
544
|
+
end
|
|
545
|
+
|
|
526
546
|
# Runs dry-run mode. Reuses Runner.collect_jobs (+ filter_since) so the
|
|
527
547
|
# candidate list cannot drift from a real run's job selection.
|
|
528
548
|
#
|
|
@@ -530,7 +550,8 @@ module Mutineer
|
|
|
530
550
|
# @return [void]
|
|
531
551
|
def self.dry_run(config)
|
|
532
552
|
operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
|
|
533
|
-
jobs, ignored_results, source_map = Runner.collect_jobs(config, operator_classes)
|
|
553
|
+
jobs, ignored_results, source_map, extras = Runner.collect_jobs(config, operator_classes)
|
|
554
|
+
warn_legacy_ignore_matches(extras[:legacy_ignore_matches])
|
|
534
555
|
# Narrow jobs and ignored the same way so the summary matches the printed list.
|
|
535
556
|
if config.since
|
|
536
557
|
jobs = Runner.filter_since(jobs, source_map, config)
|
data/lib/mutineer/config.rb
CHANGED
|
@@ -8,13 +8,55 @@ 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: :test_command, type: :string, yaml_key: "test_command", flag: "--test-command"),
|
|
43
|
+
ConfigOption.new(field: :daemon, type: :bool, yaml_key: "daemon", flag: "--daemon"),
|
|
44
|
+
ConfigOption.new(field: :format, type: :enum, flag: "--format", values: %w[human json html]),
|
|
45
|
+
ConfigOption.new(field: :strategy, type: :enum, flag: "--strategy", values: %w[reload redefine],
|
|
46
|
+
aliases: STRATEGY_ALIASES),
|
|
47
|
+
ConfigOption.new(field: :output, type: :string, flag: "--output"),
|
|
48
|
+
ConfigOption.new(field: :baseline_epsilon, type: :nonneg_float, flag: "--baseline-epsilon"),
|
|
49
|
+
ConfigOption.new(field: :dry_run, type: :bool, flag: "--dry-run")
|
|
50
|
+
].freeze
|
|
51
|
+
|
|
11
52
|
# Plain run configuration, populated by the CLI (or directly by the
|
|
12
53
|
# integration test). `operators` nil means "all default operators";
|
|
13
54
|
# `threshold` 0.0 means the CI gate is off (spec §10).
|
|
14
55
|
#
|
|
15
56
|
# Also holds: jobs (parallel workers), format (human|json), output (report
|
|
16
57
|
# file), strategy (reload|redefine), require_paths (extra files to load).
|
|
17
|
-
# Config loading and the CLI > file > default precedence merge live here
|
|
58
|
+
# Config loading and the CLI > file > default precedence merge live here; each
|
|
59
|
+
# layer holds only the keys the user wrote, and Config#explicit? reports them.
|
|
18
60
|
#
|
|
19
61
|
# Boot mode adds: boot (a file to require ONCE in the parent so the app env,
|
|
20
62
|
# e.g. Rails, is booted before forking; sources are then NOT manually required)
|
|
@@ -33,11 +75,15 @@ module Mutineer
|
|
|
33
75
|
) do
|
|
34
76
|
# Config file name.
|
|
35
77
|
CONFIG_FILE = ".mutineer.yml"
|
|
36
|
-
# Keys accepted in .mutineer.yml. `require` maps to the
|
|
37
|
-
|
|
78
|
+
# Keys accepted in .mutineer.yml, derived from the schema. `require` maps to the
|
|
79
|
+
# :require_paths field.
|
|
80
|
+
KNOWN_KEYS = CONFIG_OPTIONS.filter_map(&:yaml_key).freeze
|
|
38
81
|
|
|
39
|
-
|
|
40
|
-
|
|
82
|
+
# @param explicit [Array<Symbol>] fields the user wrote (CLI or file). Derived
|
|
83
|
+
# values fill only the others; a programmatic Config.new writes none.
|
|
84
|
+
def initialize(explicit: [], **kwargs)
|
|
85
|
+
super(**kwargs)
|
|
86
|
+
@explicit = explicit.to_a.dup.freeze
|
|
41
87
|
self.sources ||= []
|
|
42
88
|
self.tests ||= []
|
|
43
89
|
self.threshold ||= 0.0
|
|
@@ -57,6 +103,17 @@ module Mutineer
|
|
|
57
103
|
self.daemon = false if daemon.nil?
|
|
58
104
|
end
|
|
59
105
|
|
|
106
|
+
# True when the user wrote `key`, on the command line or in the config
|
|
107
|
+
# file, whatever the value (`false` and `nil` count). The answer comes from
|
|
108
|
+
# which keys the layers held, so a new option needs no bookkeeping to be
|
|
109
|
+
# covered.
|
|
110
|
+
#
|
|
111
|
+
# @param key [Symbol] Config field name.
|
|
112
|
+
# @return [Boolean]
|
|
113
|
+
def explicit?(key)
|
|
114
|
+
@explicit.include?(key)
|
|
115
|
+
end
|
|
116
|
+
|
|
60
117
|
# Walk from `start` toward `home`, returning the first .mutineer.yml path found
|
|
61
118
|
# or nil. Checks `home` itself, then stops; if `start` is above `home`
|
|
62
119
|
# (e.g. /tmp), the walk continues to the filesystem root. Pure discovery;
|
|
@@ -96,28 +153,34 @@ module Mutineer
|
|
|
96
153
|
"(known: #{KNOWN_KEYS.join(', ')}); ignored"
|
|
97
154
|
next
|
|
98
155
|
end
|
|
99
|
-
|
|
156
|
+
field = field_for(ks)
|
|
157
|
+
parsed = parse(field, value, file: name)
|
|
158
|
+
parsed = filter_operators(parsed, name) if field == :operators
|
|
159
|
+
out[field] = parsed
|
|
100
160
|
end
|
|
101
161
|
out
|
|
102
162
|
rescue Psych::SyntaxError => e
|
|
103
163
|
raise ConfigError, "#{File.basename(path)} parse error: #{e.message}"
|
|
104
164
|
end
|
|
105
165
|
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
166
|
+
# Merges the two user layers, CLI over file, then derives what neither wrote.
|
|
167
|
+
# A layer is a Hash of only the keys the user set, so precedence is `merge`
|
|
168
|
+
# and "did the user write this" is whether the key exists. Nothing tracks
|
|
169
|
+
# provenance by hand, and `false`/`nil` are ordinary values.
|
|
170
|
+
#
|
|
171
|
+
# @param cli_opts [Hash{Symbol => Object}] parsed command-line fields.
|
|
172
|
+
# @param file_hash [Hash{Symbol => Object}] parsed .mutineer.yml fields.
|
|
173
|
+
# @return [Mutineer::Config]
|
|
174
|
+
def self.resolve(cli_opts, file_hash)
|
|
175
|
+
user = file_hash.merge(cli_opts)
|
|
176
|
+
config = new(**user, explicit: user.keys)
|
|
114
177
|
|
|
115
178
|
# --rails sugar: boot config/environment. Prefer redefine only for the
|
|
116
179
|
# in-process path (daemon is whole-file reload only). In-process --rails
|
|
117
180
|
# shares one test database, so force serial unless --daemon.
|
|
118
181
|
if config.rails
|
|
119
182
|
config.boot ||= "config/environment"
|
|
120
|
-
unless config.daemon || explicit
|
|
183
|
+
unless config.daemon || config.explicit?(:strategy)
|
|
121
184
|
config.strategy = "redefine"
|
|
122
185
|
end
|
|
123
186
|
unless config.daemon
|
|
@@ -129,9 +192,9 @@ module Mutineer
|
|
|
129
192
|
end
|
|
130
193
|
end
|
|
131
194
|
|
|
132
|
-
# Auto-detect the framework only when
|
|
133
|
-
#
|
|
134
|
-
#
|
|
195
|
+
# Auto-detect the framework only when the user wrote none: a value from
|
|
196
|
+
# either layer is already on config.framework and always wins. Default
|
|
197
|
+
# minitest unless the test files clearly look RSpec.
|
|
135
198
|
config.framework ||= detect_framework(config.tests)
|
|
136
199
|
config
|
|
137
200
|
end
|
|
@@ -155,42 +218,87 @@ module Mutineer
|
|
|
155
218
|
known_key == "require" ? :require_paths : known_key.to_sym
|
|
156
219
|
end
|
|
157
220
|
|
|
158
|
-
#
|
|
221
|
+
# Parses one raw value into the typed value for `field`. A value that does
|
|
222
|
+
# not fit its type raises ConfigError naming where it came from, so the CLI
|
|
223
|
+
# and .mutineer.yml report the same mistake in the same way. `nil` and
|
|
224
|
+
# `false` are valid results for some fields (`since: false` means "no scoping").
|
|
159
225
|
#
|
|
160
|
-
# @param
|
|
161
|
-
# @param value [Object] raw YAML value.
|
|
162
|
-
# @param
|
|
163
|
-
#
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
226
|
+
# @param field [Symbol] Config field name (a row of the option schema).
|
|
227
|
+
# @param value [Object] raw CLI string or YAML value.
|
|
228
|
+
# @param file [String, nil] config file name when the value came from it;
|
|
229
|
+
# nil when it came from the command line.
|
|
230
|
+
# @return [Object] the typed value.
|
|
231
|
+
# @raise [Mutineer::ConfigError] when the value does not fit the field's type.
|
|
232
|
+
def self.parse(field, value, file: nil)
|
|
233
|
+
opt = CONFIG_OPTIONS.find { |o| o.field == field } or raise ArgumentError, "unknown option #{field.inspect}"
|
|
234
|
+
origin = file ? "#{file}: #{opt.yaml_key}" : opt.flag
|
|
235
|
+
got = "(got: #{value.inspect})"
|
|
236
|
+
case opt.type
|
|
237
|
+
when :positive_int
|
|
238
|
+
n = value.is_a?(Integer) ? value : (value.to_i if value.is_a?(String) && value.match?(/\A\d+\z/))
|
|
239
|
+
raise ConfigError, "#{origin} must be a positive integer, digits only #{got}" if n.nil? || n < 1
|
|
240
|
+
|
|
241
|
+
n
|
|
242
|
+
when :percent
|
|
243
|
+
f = finite_float(value)
|
|
244
|
+
raise ConfigError, "#{origin} must be a number between 0 and 100 #{got}" unless f && (0.0..100.0).cover?(f)
|
|
245
|
+
|
|
246
|
+
f
|
|
247
|
+
when :nonneg_float
|
|
248
|
+
f = finite_float(value)
|
|
249
|
+
raise ConfigError, "#{origin} must be a finite number, 0 or greater #{got}" unless f && f >= 0.0
|
|
250
|
+
|
|
174
251
|
f
|
|
175
|
-
when
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
when
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
#
|
|
189
|
-
|
|
190
|
-
|
|
252
|
+
when :bool
|
|
253
|
+
return value if [true, false].include?(value)
|
|
254
|
+
return value == "true" if %w[true false].include?(value)
|
|
255
|
+
|
|
256
|
+
raise ConfigError, "#{origin} must be true or false #{got}"
|
|
257
|
+
when :enum
|
|
258
|
+
name = opt.aliases&.fetch(value, nil) || value
|
|
259
|
+
return name if opt.values.include?(name)
|
|
260
|
+
|
|
261
|
+
prefix = file ? "#{file}: " : ""
|
|
262
|
+
raise ConfigError, "#{prefix}unknown #{field} #{value.to_s.inspect}. Expected: #{opt.values.join(', ')}"
|
|
263
|
+
when :string_list then Array(value).map(&:to_s)
|
|
264
|
+
when :string
|
|
265
|
+
# A key written with no value (`baseline:`) parses as nil. Keeping nil
|
|
266
|
+
# would switch the feature off without a word; main failed here, so the
|
|
267
|
+
# typo stays loud. An absent key never reaches parse, so it stays unset.
|
|
268
|
+
# `only: false` must not become the subject name "false" either: it
|
|
269
|
+
# matches nothing, so the run has no mutants and still exits 0.
|
|
270
|
+
raise ConfigError, "#{origin} must be a string #{got}" if [nil, true, false].include?(value)
|
|
271
|
+
|
|
272
|
+
value.to_s
|
|
273
|
+
when :since
|
|
274
|
+
# `false` is the one way to say "no scoping" in the file. It becomes nil
|
|
275
|
+
# so every consumer's nil-check (runner scoping, the report's scoped
|
|
276
|
+
# marker) agrees; a false left raw would skip scoping but still mark
|
|
277
|
+
# the report scoped. A blank value is an error, not "no scoping": a
|
|
278
|
+
# `since: "$REF"` whose variable is unset must not turn scoping off.
|
|
279
|
+
return nil if value == false
|
|
280
|
+
raise ConfigError, "#{origin} must be a git ref, not blank #{got}" if value.to_s.strip.empty?
|
|
281
|
+
|
|
282
|
+
value.to_s
|
|
191
283
|
end
|
|
192
284
|
end
|
|
193
285
|
|
|
286
|
+
# Reads a finite Float from a number or a plain decimal string. Rejects booleans,
|
|
287
|
+
# NaN and Infinity: `Float(true)` is an error, but a YAML `.nan` is a Float.
|
|
288
|
+
# A string must be digits with an optional fraction, the same digits-only rule
|
|
289
|
+
# as `jobs`: `Float()` alone would also read `0x10`, `1_0`, `+2` and `1e2`.
|
|
290
|
+
#
|
|
291
|
+
# @api private
|
|
292
|
+
# @param value [Object] raw value.
|
|
293
|
+
# @return [Float, nil] the number, or nil when it is not a finite number.
|
|
294
|
+
def self.finite_float(value)
|
|
295
|
+
f = case value
|
|
296
|
+
when Integer, Float then value.to_f
|
|
297
|
+
when String then Float(value) if value.match?(/\A\d+(\.\d+)?\z/)
|
|
298
|
+
end
|
|
299
|
+
f if f&.finite?
|
|
300
|
+
end
|
|
301
|
+
|
|
194
302
|
# Drop (with a warning) operator names the registry does not know.
|
|
195
303
|
# Referenced lazily so config.rb carries no load-order dependency on the
|
|
196
304
|
# registry; by the time a config is parsed at runtime, it is loaded.
|
|
@@ -9,6 +9,7 @@ require "set"
|
|
|
9
9
|
require_relative "minitest_integration"
|
|
10
10
|
require_relative "test_runners"
|
|
11
11
|
require_relative "child_stdout"
|
|
12
|
+
require_relative "project_path"
|
|
12
13
|
|
|
13
14
|
module Mutineer
|
|
14
15
|
# Maps `(source_file, line) -> [test_files]` so each mutant runs only against
|
|
@@ -722,11 +723,7 @@ module Mutineer
|
|
|
722
723
|
#
|
|
723
724
|
# @api private
|
|
724
725
|
# @return [String] realpath of the project root when it exists.
|
|
725
|
-
def project_root_real
|
|
726
|
-
File.realpath(File.expand_path(@project_root))
|
|
727
|
-
rescue Errno::ENOENT
|
|
728
|
-
File.expand_path(@project_root)
|
|
729
|
-
end
|
|
726
|
+
def project_root_real = ProjectPath.root_real(@project_root)
|
|
730
727
|
|
|
731
728
|
# Project-local `.rb` files loaded in this process at capture time.
|
|
732
729
|
#
|
|
@@ -907,38 +904,18 @@ module Mutineer
|
|
|
907
904
|
# @return [Array<String>] absolute load paths.
|
|
908
905
|
def abs_load_paths = @load_paths.map { |p| absolute(p) }
|
|
909
906
|
|
|
910
|
-
# Relativizes a path against the project root.
|
|
907
|
+
# Relativizes a path against the project root (see {ProjectPath.relative}).
|
|
911
908
|
#
|
|
912
909
|
# @api private
|
|
913
910
|
# @param path [String] path to relativize.
|
|
914
|
-
# @return [String] relative path.
|
|
915
|
-
def relativize(path)
|
|
916
|
-
abs = path.start_with?("/") ? path : absolute(path)
|
|
917
|
-
abs = realpath_if_exists(abs)
|
|
918
|
-
root = project_root_real
|
|
919
|
-
prefix = root.end_with?("/") ? root : "#{root}/"
|
|
920
|
-
return abs unless abs.start_with?(prefix)
|
|
921
|
-
|
|
922
|
-
abs.delete_prefix(prefix)
|
|
923
|
-
end
|
|
911
|
+
# @return [String] relative path, or an absolute path when outside the root.
|
|
912
|
+
def relativize(path) = ProjectPath.relative(path, @project_root)
|
|
924
913
|
|
|
925
|
-
# Expands a path relative to the project root.
|
|
914
|
+
# Expands a path relative to the project root (see {ProjectPath.absolute}).
|
|
926
915
|
#
|
|
927
916
|
# @api private
|
|
928
917
|
# @param path [String] path to expand.
|
|
929
918
|
# @return [String] absolute path.
|
|
930
|
-
def absolute(path)
|
|
931
|
-
raw = File.absolute_path?(path) ? path : File.expand_path(path, @project_root)
|
|
932
|
-
realpath_if_exists(raw)
|
|
933
|
-
end
|
|
934
|
-
|
|
935
|
-
# Real path when the file exists, otherwise `path` unchanged.
|
|
936
|
-
#
|
|
937
|
-
# @api private
|
|
938
|
-
# @param path [String] absolute or relative path.
|
|
939
|
-
# @return [String]
|
|
940
|
-
def realpath_if_exists(path)
|
|
941
|
-
File.exist?(path) ? File.realpath(path) : path
|
|
942
|
-
end
|
|
919
|
+
def absolute(path) = ProjectPath.absolute(path, @project_root)
|
|
943
920
|
end
|
|
944
921
|
end
|