mutineer 1.4.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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +250 -0
  3. data/README.md +198 -27
  4. data/lib/mutineer/changed_lines.rb +24 -1
  5. data/lib/mutineer/cli.rb +112 -16
  6. data/lib/mutineer/config.rb +64 -13
  7. data/lib/mutineer/coverage_map.rb +161 -14
  8. data/lib/mutineer/daemon_backend.rb +3 -0
  9. data/lib/mutineer/daemon_client.rb +170 -13
  10. data/lib/mutineer/daemon_server.rb +2 -1
  11. data/lib/mutineer/external_backend.rb +2 -2
  12. data/lib/mutineer/isolation.rb +91 -6
  13. data/lib/mutineer/kill_channel.rb +284 -0
  14. data/lib/mutineer/kill_matrix.rb +88 -0
  15. data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
  16. data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
  17. data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +14 -68
  18. data/lib/mutineer/minitest_integration.rb +27 -10
  19. data/lib/mutineer/mutant_id.rb +5 -4
  20. data/lib/mutineer/mutators/array_literal.rb +0 -7
  21. data/lib/mutineer/mutators/base.rb +63 -4
  22. data/lib/mutineer/mutators/chain_link.rb +0 -7
  23. data/lib/mutineer/mutators/collection_method.rb +1 -1
  24. data/lib/mutineer/mutators/condition_forcing.rb +0 -7
  25. data/lib/mutineer/mutators/operand_removal.rb +0 -7
  26. data/lib/mutineer/mutators/operator_assignment.rb +0 -7
  27. data/lib/mutineer/mutators/return_nil.rb +5 -11
  28. data/lib/mutineer/mutators/string_literal.rb +1 -1
  29. data/lib/mutineer/pairing.rb +222 -15
  30. data/lib/mutineer/parser.rb +12 -3
  31. data/lib/mutineer/project.rb +7 -0
  32. data/lib/mutineer/rails_worker_db.rb +1 -1
  33. data/lib/mutineer/reporter.rb +288 -52
  34. data/lib/mutineer/result.rb +37 -1
  35. data/lib/mutineer/runner.rb +133 -16
  36. data/lib/mutineer/statement_lines.rb +143 -0
  37. data/lib/mutineer/test_runners/minitest.rb +4 -2
  38. data/lib/mutineer/test_runners/rspec.rb +243 -3
  39. data/lib/mutineer/test_runners.rb +5 -3
  40. data/lib/mutineer/version.rb +1 -1
  41. data/lib/mutineer/worker_pool.rb +4 -2
  42. data/lib/mutineer.rb +1 -0
  43. metadata +8 -3
@@ -59,6 +59,8 @@ module Mutineer
59
59
  # A file that is not in the index has no usable diff, so `new_file_diff`
60
60
  # supplies one that marks every line new. That includes a file the ref still
61
61
  # has, deleted and then written again: git prints only a deletion hunk for it.
62
+ # A Git-ignored file outside the index contributes no changed lines, with a
63
+ # warning, so the skip does not look like a file with no changes.
62
64
  # Paths are literal, so a name such as `file[1].rb` is not read as a glob.
63
65
  #
64
66
  # A failure is warned, never silent: an empty result means "no changed
@@ -73,7 +75,13 @@ module Mutineer
73
75
  out, _err, status = Open3.capture3(
74
76
  "git", "--literal-pathspecs", "-C", project_root, "diff", "--unified=0", ref, "--", abs_file
75
77
  )
76
- return(indexed?(abs_file, project_root) ? out : new_file_diff(abs_file)) if status.success?
78
+ if status.success?
79
+ return out if indexed?(abs_file, project_root)
80
+ return new_file_diff(abs_file) unless ignored?(abs_file, project_root)
81
+
82
+ warn "[mutineer] #{abs_file} is ignored by Git; its lines will not be mutated (--since)"
83
+ return ""
84
+ end
77
85
 
78
86
  warn "[mutineer] git diff failed for #{abs_file}; its lines will not be mutated (--since)"
79
87
  ""
@@ -95,6 +103,21 @@ module Mutineer
95
103
  known.success?
96
104
  end
97
105
 
106
+ # Tells whether Git ignores the file (`.gitignore`, `.git/info/exclude` or
107
+ # the global excludes file).
108
+ #
109
+ # @param abs_file [String] absolute path of the file.
110
+ # @param project_root [String] repository root for `git -C`.
111
+ # @return [Boolean] true when Git ignores the file. False when Git cannot
112
+ # tell, such as for a path beyond a symlink or inside a submodule (exit
113
+ # 128): that file is scored in full, as a file Git does not ignore is.
114
+ def ignored?(abs_file, project_root)
115
+ _out, _err, status = Open3.capture3(
116
+ "git", "-C", project_root, "check-ignore", "--quiet", "--", abs_file
117
+ )
118
+ status.success?
119
+ end
120
+
98
121
  # Returns a diff that marks a file as entirely new. It returns `""` when
99
122
  # the file is empty or cannot be read (after a warning). A file outside the
100
123
  # index has every line new; read as "unchanged", `--since` would score
data/lib/mutineer/cli.rb CHANGED
@@ -10,6 +10,7 @@ require_relative "pairing"
10
10
  require_relative "changed_lines"
11
11
  require_relative "runner"
12
12
  require_relative "reporter"
13
+ require_relative "kill_matrix"
13
14
  require_relative "baseline"
14
15
  require_relative "mutator_registry"
15
16
 
@@ -28,11 +29,12 @@ module Mutineer
28
29
  Usage: mutineer [options] <command> [args]
29
30
 
30
31
  Commands:
31
- run [options] <source...> --test <test...> Mutate, run, and report
32
+ run [options] <source...> --test <test> [--test <test>...]
33
+ Mutate, run, and report
32
34
  run --dry-run [options] <source...> Print candidate mutations only
33
35
 
34
36
  Run options:
35
- --test FILE Test file covering the sources (repeatable)
37
+ --test FILE Test file covering the sources (one per flag; repeat it)
36
38
  --operators LIST Comma-separated operator names (default: Tier 1 set)
37
39
  --threshold FLOAT Fail (exit 1) when score < FLOAT (default: 0 = off)
38
40
  --baseline FILE Fail (exit 1) on NEW survivors / score drop vs a prior
@@ -41,12 +43,22 @@ module Mutineer
41
43
  --only NAME Restrict to one fully-qualified subject
42
44
  --since REF Only mutate lines changed since git REF (e.g. origin/main)
43
45
  --no-since Disable diff scoping (a typed no beats a .mutineer.yml since:)
44
- --jobs N Parallel worker count (default: processor count)
46
+ --jobs N Parallel worker count (default: processor count);
47
+ --test-command, --fail-fast, or --rails
48
+ without --daemon forces 1
45
49
  --strategy NAME reload (whole-file) or redefine (surgical); default: reload
50
+ --timeout SECONDS Per-mutant time limit for in-process runs (default: 10;
51
+ not with --daemon or --test-command); a mutant over it
52
+ is a timeout, excluded from the score
53
+ --capture-timeout SECONDS Time limit for each coverage-capture subprocess
54
+ and the clean check (default: 120; not with --test-command)
55
+ --cache-dir DIR Directory for the coverage cache (default: .mutineer;
56
+ not with --test-command)
46
57
  --framework NAME minitest or rspec (default: auto-detect from --test names)
47
58
  --boot FILE Require FILE once in the parent to boot the app env, then
48
59
  fork per mutant (Rails apps; requires --test)
49
- --rails Sugar for --boot config/environment --strategy redefine
60
+ --rails Boot config/environment; without --daemon, defaults to
61
+ redefine and serial execution
50
62
  --test-command CMD Run the target suite in the app's own runtime as a
51
63
  subprocess (for apps on Ruby < 3.4). CMD must contain
52
64
  %{files}. Scrubs Mutineer Ruby PATH pins; set RAILS_ENV
@@ -57,7 +69,13 @@ module Mutineer
57
69
  --format human|json|html Report format (default: human)
58
70
  --output FILE Write the report to FILE instead of stdout
59
71
  --dry-run List mutations without executing
60
- --fail-fast Stop at the first surviving mutant
72
+ --fail-fast Stop at the first surviving mutant (--no-fail-fast beats a
73
+ .mutineer.yml fail_fast:)
74
+ --matrix Run every covering test for each mutant and report blind and
75
+ redundant tests; the JSON report also lists each mutant's
76
+ killers (in-process only; not with --daemon, --test-command,
77
+ --fail-fast or --dry-run; --no-matrix beats a .mutineer.yml
78
+ matrix:)
61
79
  --verbose Surface the real error when a fork capture fails (alias: --debug)
62
80
 
63
81
  Options:
@@ -88,7 +106,10 @@ module Mutineer
88
106
  end
89
107
  o.on("--list-operators") { show_operators = true }
90
108
  o.on("--dry-run") { opts[:dry_run] = true }
91
- o.on("--fail-fast") { opts[:fail_fast] = true }
109
+ # A typed --no-* beats the .mutineer.yml key, so a file key that
110
+ # conflicts with another flag can be turned off for one run.
111
+ o.on("--[no-]fail-fast") { |on| opts[:fail_fast] = on }
112
+ o.on("--[no-]matrix") { |on| opts[:matrix] = on }
92
113
  o.on("--only NAME") { |v| opts[:only] = v }
93
114
  o.on("--since REF") { |v| opts[:since] = Config.parse(:since, v) }
94
115
  # A typed "no" must beat a .mutineer.yml `since:` key: the key is present
@@ -100,6 +121,9 @@ module Mutineer
100
121
  end
101
122
  o.on("--threshold FLOAT") { |v| opts[:threshold] = Config.parse(:threshold, v) }
102
123
  o.on("--jobs N") { |v| opts[:jobs] = Config.parse(:jobs, v) }
124
+ o.on("--timeout SECONDS") { |v| opts[:timeout] = Config.parse(:timeout, v) }
125
+ o.on("--capture-timeout SECONDS") { |v| opts[:capture_timeout] = Config.parse(:capture_timeout, v) }
126
+ o.on("--cache-dir DIR") { |v| opts[:cache_dir] = Config.parse(:cache_dir, v) }
103
127
  o.on("--strategy STRAT") { |v| opts[:strategy] = Config.parse(:strategy, v) }
104
128
  o.on("--framework NAME") { |v| opts[:framework] = Config.parse(:framework, v) }
105
129
  o.on("--boot FILE") { |v| opts[:boot] = v }
@@ -138,7 +162,7 @@ module Mutineer
138
162
 
139
163
  begin
140
164
  file_path = Config.find_file
141
- file_hash = file_path ? Config.from_file(file_path) : {}
165
+ file_hash = file_path ? Config.from_file(file_path, defer_operators: opts.key?(:operators)) : {}
142
166
  config = Config.resolve(opts, file_hash)
143
167
  rescue Mutineer::ConfigError => e
144
168
  # The lib layer raises instead of killing the host; the CLI maps a
@@ -150,8 +174,14 @@ module Mutineer
150
174
  case argv.first
151
175
  when "run"
152
176
  warn_config_root_mismatch(file_path, config.project_root) if file_path
153
- # A directory source expands to its **/*.rb files; literal files pass
154
- # through. Test inference (when --test is omitted) happens in validate!.
177
+ tests_as_sources = argv[1..].grep(%r{(\A|/)(test|spec)/.*_(test|spec)\.rb\z})
178
+ if config.explicit?(:tests) && tests_as_sources.any?
179
+ warn "mutineer: #{tests_as_sources.join(', ')} looks like a test file, not a source. " \
180
+ "--test takes one file; repeat it for each test file (--test a_test.rb --test b_test.rb)"
181
+ exit 2
182
+ end
183
+ # A directory source expands to its **/*.rb files; a file inside the
184
+ # project becomes its root-relative path (#104). Test inference (when --test is omitted) happens in validate!.
155
185
  config.sources = Pairing.expand_sources(argv[1..], project_root: config.project_root)
156
186
  run(config)
157
187
  else
@@ -224,6 +254,8 @@ module Mutineer
224
254
  # @param config [Mutineer::Config] run configuration.
225
255
  # @return [void]
226
256
  def self.validate!(config)
257
+ # First, so a conflict is reported before another check rewrites the config.
258
+ validate_matrix!(config) if config.matrix
227
259
  validate_test_command!(config) if config.test_command
228
260
 
229
261
  validate_since!(config) if config.since
@@ -277,6 +309,7 @@ module Mutineer
277
309
  "(surgical redefine needs a shared VM; the subprocess has its own)"
278
310
  exit 2
279
311
  end
312
+ warn_unused_settings(config, "--test-command", %i[timeout capture_timeout cache_dir])
280
313
  return unless config.jobs > 1
281
314
 
282
315
  warn "[mutineer] --test-command runs serially (no per-worker DB isolation yet); forcing --jobs 1."
@@ -305,6 +338,7 @@ module Mutineer
305
338
  "(rspec is not implemented on the daemon path yet)"
306
339
  exit 2
307
340
  end
341
+ warn_unused_settings(config, "--daemon", %i[timeout])
308
342
  return if config.strategy == "reload"
309
343
 
310
344
  # --rails defaults strategy to redefine; daemon always whole-file loads.
@@ -313,6 +347,66 @@ module Mutineer
313
347
  config.strategy = "reload"
314
348
  end
315
349
 
350
+ # Where each setting a backend may ignore applies, for the warning.
351
+ UNUSED_SETTING_SCOPE = {
352
+ timeout: "in-process runs only",
353
+ capture_timeout: "in-process and --daemon coverage capture",
354
+ cache_dir: "the in-process and --daemon coverage cache"
355
+ }.freeze
356
+
357
+ # A backend that never reads a setting the user set would silently do
358
+ # nothing with it, so say so. `--test-command` builds no coverage map and
359
+ # has its own runtime, so it reads none of {UNUSED_SETTING_SCOPE}; the
360
+ # daemon reads the capture limit and the cache directory but keeps its own
361
+ # per-mutant limit.
362
+ #
363
+ # @api private
364
+ # @param config [Mutineer::Config] run configuration.
365
+ # @param backend [String] the backend's flag.
366
+ # @param keys [Array<Symbol>] the settings that backend ignores.
367
+ # @return [void]
368
+ def self.warn_unused_settings(config, backend, keys)
369
+ keys.each do |key|
370
+ next unless config.explicit?(key)
371
+
372
+ applies = UNUSED_SETTING_SCOPE.fetch(key)
373
+ warn "[mutineer] #{config.origin(key)} has no effect with #{backend} (it applies to #{applies}); ignoring it."
374
+ end
375
+ end
376
+
377
+ # --matrix runs on the in-process backend and needs every mutant's whole
378
+ # covering run, so a backend that cannot name the failing test, or a run that
379
+ # stops early, is a usage error (exit 2), never a quietly partial matrix.
380
+ #
381
+ # @api private
382
+ # @param config [Mutineer::Config] run configuration.
383
+ # @return [void]
384
+ def self.validate_matrix!(config)
385
+ conflict, reason =
386
+ if config.dry_run
387
+ [:dry_run, "a dry run runs no tests, so it has no matrix"]
388
+ elsif config.daemon
389
+ [:daemon, "the kill matrix runs on the in-process backend only"]
390
+ elsif config.test_command
391
+ [:test_command, "the external suite reports pass or fail, not which test failed"]
392
+ elsif config.fail_fast
393
+ [:fail_fast, "a fail-fast run is partial, so blind and redundant tests would be wrong"]
394
+ end
395
+ return unless conflict
396
+
397
+ # Say how to turn off whichever side came from the file, for this run.
398
+ hint =
399
+ if config.origin(:matrix) != "--matrix"
400
+ "; pass --no-matrix to run without it"
401
+ elsif conflict == :fail_fast && config.origin(:fail_fast) != "--fail-fast"
402
+ "; pass --no-fail-fast to run without it"
403
+ else
404
+ ""
405
+ end
406
+ warn "mutineer: #{config.origin(:matrix)} cannot be combined with #{config.origin(conflict)} (#{reason}#{hint})"
407
+ exit 2
408
+ end
409
+
316
410
  # --since needs a real git repo and a resolvable ref; either failure is a
317
411
  # usage error (exit 2) so CI sees "bad invocation," not "tests too weak."
318
412
  #
@@ -354,9 +448,10 @@ module Mutineer
354
448
  end
355
449
 
356
450
  # Auto-pair sources to tests by path convention when no --test was given
357
- # (explicit --test wins). Each source with an inferred test on disk joins the
358
- # run; a source with none is dropped with a one-line stderr warning and the
359
- # run continues with the rest. If every source is dropped: in boot mode the
451
+ # (explicit --test wins). Each source with at least one inferred test on
452
+ # disk joins the run; a source with none is dropped with a one-line stderr
453
+ # warning and the run continues with the rest. Split files for one source
454
+ # are all kept (#87). If every source is dropped: in boot mode the
360
455
  # dedicated --boot/--rails-requires-test check reports it; otherwise exit 2
361
456
  # with a usage message. The framework is re-detected from the inferred set
362
457
  # unless it was set explicitly (a spec-only project loads/reports as rspec).
@@ -368,14 +463,14 @@ module Mutineer
368
463
  return unless config.tests.empty?
369
464
 
370
465
  paired = config.sources.filter_map do |s|
371
- t = Pairing.infer_test(s, project_root: config.project_root, prefer: config.framework)
372
- [s, t] if t
466
+ tests = Pairing.infer_tests(s, project_root: config.project_root, prefer: config.framework)
467
+ [s, tests] unless tests.empty?
373
468
  end
374
469
  (config.sources - paired.map(&:first)).each do |s|
375
470
  warn "[mutineer] no test found by convention for #{s}; skipping"
376
471
  end
377
472
  config.sources = paired.map(&:first)
378
- config.tests = paired.map(&:last).uniq
473
+ config.tests = paired.flat_map(&:last).uniq
379
474
  config.framework = Config.detect_framework(config.tests) unless config.explicit?(:framework)
380
475
 
381
476
  return unless config.sources.empty?
@@ -426,7 +521,8 @@ module Mutineer
426
521
 
427
522
  aggregate, source_map, extras = Runner.execute(config)
428
523
  warn_legacy_ignore_matches(extras[:legacy_ignore_matches])
429
- reporter = Reporter.new(aggregate, source_map)
524
+ matrix = KillMatrix.new(aggregate.results) if config.matrix
525
+ reporter = Reporter.new(aggregate, source_map, matrix: matrix)
430
526
 
431
527
  # Diff the current run against the baseline (preflighted above) by the
432
528
  # stable survivor id. The delta is rendered inline (human section / additive
@@ -39,8 +39,13 @@ module Mutineer
39
39
  ConfigOption.new(field: :ignore, type: :string_list, yaml_key: "ignore"),
40
40
  ConfigOption.new(field: :baseline, type: :string, yaml_key: "baseline", flag: "--baseline"),
41
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"),
42
43
  ConfigOption.new(field: :test_command, type: :string, yaml_key: "test_command", flag: "--test-command"),
43
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"),
44
49
  ConfigOption.new(field: :format, type: :enum, flag: "--format", values: %w[human json html]),
45
50
  ConfigOption.new(field: :strategy, type: :enum, flag: "--strategy", values: %w[reload redefine],
46
51
  aliases: STRATEGY_ALIASES),
@@ -58,10 +63,13 @@ module Mutineer
58
63
  # Config loading and the CLI > file > default precedence merge live here; each
59
64
  # layer holds only the keys the user wrote, and Config#explicit? reports them.
60
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.
68
+ #
61
69
  # Boot mode adds: boot (a file to require ONCE in the parent so the app env,
62
70
  # e.g. Rails, is booted before forking; sources are then NOT manually required)
63
- # and rails (sugar: defaults boot to config/environment and strategy to redefine,
64
- # 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).
65
73
  Config = Struct.new(
66
74
  :sources, :tests, :operators, :threshold, :only, :dry_run,
67
75
  :cache_dir, :project_root, :load_paths,
@@ -70,7 +78,7 @@ module Mutineer
70
78
  # :daemon is user-facing (--daemon flag + KNOWN_KEYS + boolean coerce).
71
79
  # :daemon_timeout stays programmatic (set by tests/Runner; no flag yet).
72
80
  :baseline, :baseline_epsilon, :fail_fast, :test_command,
73
- :daemon, :daemon_timeout,
81
+ :daemon, :daemon_timeout, :timeout, :capture_timeout, :matrix,
74
82
  keyword_init: true
75
83
  ) do
76
84
  # Config file name.
@@ -81,9 +89,12 @@ module Mutineer
81
89
 
82
90
  # @param explicit [Array<Symbol>] fields the user wrote (CLI or file). Derived
83
91
  # values fill only the others; a programmatic Config.new writes none.
84
- def initialize(explicit: [], **kwargs)
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)
85
95
  super(**kwargs)
86
96
  @explicit = explicit.to_a.dup.freeze
97
+ @from_file = from_file.to_a.dup.freeze
87
98
  self.sources ||= []
88
99
  self.tests ||= []
89
100
  self.threshold ||= 0.0
@@ -101,6 +112,7 @@ module Mutineer
101
112
  self.baseline_epsilon ||= 0.0
102
113
  self.fail_fast = false if fail_fast.nil?
103
114
  self.daemon = false if daemon.nil?
115
+ self.matrix = false if matrix.nil?
104
116
  end
105
117
 
106
118
  # True when the user wrote `key`, on the command line or in the config
@@ -114,6 +126,17 @@ module Mutineer
114
126
  @explicit.include?(key)
115
127
  end
116
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)
138
+ end
139
+
117
140
  # Walk from `start` toward `home`, returning the first .mutineer.yml path found
118
141
  # or nil. Checks `home` itself, then stops; if `start` is above `home`
119
142
  # (e.g. /tmp), the walk continues to the filesystem root. Pure discovery;
@@ -134,10 +157,18 @@ module Mutineer
134
157
  end
135
158
 
136
159
  # Parse a .mutineer.yml into a symbol-keyed hash of recognized keys. Unknown
137
- # keys / unknown operator names emit a one-line stderr warning and are ignored.
138
- # A YAML syntax error raises ConfigError: never a silent fallback to defaults,
139
- # and never an exit from the lib layer.
140
- def self.from_file(path)
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)
141
172
  raw = YAML.safe_load(File.read(path)) || {}
142
173
  name = File.basename(path)
143
174
  unless raw.is_a?(Hash)
@@ -154,8 +185,13 @@ module Mutineer
154
185
  next
155
186
  end
156
187
  field = field_for(ks)
157
- parsed = parse(field, value, file: name)
158
- parsed = filter_operators(parsed, name) if field == :operators
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
159
195
  out[field] = parsed
160
196
  end
161
197
  out
@@ -173,7 +209,7 @@ module Mutineer
173
209
  # @return [Mutineer::Config]
174
210
  def self.resolve(cli_opts, file_hash)
175
211
  user = file_hash.merge(cli_opts)
176
- config = new(**user, explicit: user.keys)
212
+ config = new(**user, explicit: user.keys, from_file: file_hash.keys - cli_opts.keys)
177
213
 
178
214
  # --rails sugar: boot config/environment. Prefer redefine only for the
179
215
  # in-process path (daemon is whole-file reload only). In-process --rails
@@ -227,9 +263,12 @@ module Mutineer
227
263
  # @param value [Object] raw CLI string or YAML value.
228
264
  # @param file [String, nil] config file name when the value came from it;
229
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.
230
269
  # @return [Object] the typed value.
231
270
  # @raise [Mutineer::ConfigError] when the value does not fit the field's type.
232
- def self.parse(field, value, file: nil)
271
+ def self.parse(field, value, file: nil, defer_operators: false)
233
272
  opt = CONFIG_OPTIONS.find { |o| o.field == field } or raise ArgumentError, "unknown option #{field.inspect}"
234
273
  origin = file ? "#{file}: #{opt.yaml_key}" : opt.flag
235
274
  got = "(got: #{value.inspect})"
@@ -260,7 +299,16 @@ module Mutineer
260
299
 
261
300
  prefix = file ? "#{file}: " : ""
262
301
  raise ConfigError, "#{prefix}unknown #{field} #{value.to_s.inspect}. Expected: #{opt.values.join(', ')}"
263
- when :string_list then Array(value).map(&:to_s)
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
264
312
  when :string
265
313
  # A key written with no value (`baseline:`) parses as nil. Keeping nil
266
314
  # would switch the feature off without a word; main failed here, so the
@@ -268,6 +316,9 @@ module Mutineer
268
316
  # `only: false` must not become the subject name "false" either: it
269
317
  # matches nothing, so the run has no mutants and still exits 0.
270
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?
271
322
 
272
323
  value.to_s
273
324
  when :since