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.
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
- opts = {} # symbol => value, the CLI-provided Config fields
82
- explicit = Set.new # precedence keys the user typed
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; explicit << :fail_fast }
98
- o.on("--only NAME") { |v| opts[:only] = v; explicit << :only }
99
- o.on("--since REF") { |v| opts[:since] = v; explicit << :since }
100
- # A typed "no" must beat a .mutineer.yml `since:` key (CLI-over-config
101
- # precedence): marking :since explicit with a nil value blocks the fill.
102
- o.on("--no-since") { opts[:since] = nil; explicit << :since }
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") { |v| opts[:operators] = v.split(",").map(&:strip); explicit << :operators }
105
- o.on("--threshold FLOAT") do |v|
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("--jobs N") { |v| opts[:jobs] = v; explicit << :jobs }
115
- o.on("--strategy STRAT") { |v| opts[:strategy] = v; explicit << :strategy }
116
- o.on("--framework NAME") { |v| opts[:framework] = v; explicit << :framework }
117
- o.on("--boot FILE") { |v| opts[:boot] = v; explicit << :boot }
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 also a .mutineer.yml key, so mark it explicit when typed
124
- # (CLI wins over the file). --baseline-epsilon is CLI-only.
125
- o.on("--baseline FILE") { |v| opts[:baseline] = v; explicit << :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; explicit << :test_command }
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; explicit << :daemon }
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, explicit)
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, explicit)
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, explicit = Set.new)
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, explicit)
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, explicit = Set.new)
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, explicit) unless config.dry_run
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, explicit)
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.include?(:framework)
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)
@@ -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 :require_paths field.
37
- KNOWN_KEYS = %w[operators jobs threshold only require boot rails since framework verbose ignore baseline fail_fast test_command daemon].freeze
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
- def initialize(**kwargs)
40
- super
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
- out[field_for(ks)] = coerce(ks, value, name)
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
- # Apply precedence: start from the CLI-provided values, then fill in a
107
- # config-file value only for keys the user did NOT type on the command line.
108
- # `explicit` is a Set of field symbols the CLI saw with a value; a Set (not
109
- # nil-sentinels) is used because some valid values are zero/false.
110
- def self.resolve(cli_opts, file_hash, explicit)
111
- merged = cli_opts.dup
112
- file_hash.each { |k, v| merged[k] = v unless explicit.include?(k) }
113
- config = new(**merged)
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.include?(:strategy)
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 neither CLI nor config file set it
133
- # (explicit value, from either source, is already on config.framework and
134
- # always wins). Default minitest unless the test files clearly look RSpec.
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
- # Coerces a config value to its target type.
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 known_key [String] config key.
161
- # @param value [Object] raw YAML value.
162
- # @param file_name [String] config file name for warnings.
163
- # @return [Object] coerced value.
164
- def self.coerce(known_key, value, file_name)
165
- case known_key
166
- when "operators" then filter_operators(Array(value).map(&:to_s), file_name)
167
- when "jobs" then value.to_i
168
- when "threshold"
169
- f = Float(value, exception: false)
170
- if f.nil?
171
- raise ConfigError, "#{file_name}: threshold must be a number between 0 and 100 " \
172
- "(got: #{value.inspect})"
173
- end
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 "require" then Array(value).map(&:to_s)
176
- when "boot" then value.to_s
177
- when "framework" then value.to_s
178
- when "rails" then value == true || value.to_s == "true"
179
- when "daemon" then value == true || value.to_s == "true"
180
- when "verbose" then value == true || value.to_s == "true"
181
- when "fail_fast" then value == true || value.to_s == "true"
182
- when "ignore" then Array(value).map(&:to_s)
183
- when "baseline" then value.to_s
184
- when "test_command" then value.to_s
185
- when "since"
186
- # false / empty normalize to nil ("no scoping"), so every consumer's
187
- # nil-check (runner scoping, the report's scoped marker) agrees. A
188
- # false left raw would skip scoping but still mark the report scoped.
189
- value == false || value.to_s.empty? ? nil : value.to_s
190
- else value
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