mutineer 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +475 -0
  3. data/README.md +224 -28
  4. data/lib/mutineer/changed_lines.rb +24 -1
  5. data/lib/mutineer/cli.rb +117 -20
  6. data/lib/mutineer/config.rb +64 -13
  7. data/lib/mutineer/coverage_map.rb +533 -100
  8. data/lib/mutineer/daemon_backend.rb +51 -33
  9. data/lib/mutineer/daemon_client.rb +212 -22
  10. data/lib/mutineer/daemon_server.rb +74 -27
  11. data/lib/mutineer/external_backend.rb +2 -2
  12. data/lib/mutineer/file_swap.rb +1 -1
  13. data/lib/mutineer/isolation.rb +92 -6
  14. data/lib/mutineer/job_plan.rb +352 -0
  15. data/lib/mutineer/kill_channel.rb +284 -0
  16. data/lib/mutineer/kill_matrix.rb +88 -0
  17. data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
  18. data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
  19. data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +14 -68
  20. data/lib/mutineer/minitest_integration.rb +75 -11
  21. data/lib/mutineer/mutant_id.rb +5 -4
  22. data/lib/mutineer/mutators/array_literal.rb +0 -7
  23. data/lib/mutineer/mutators/base.rb +63 -4
  24. data/lib/mutineer/mutators/chain_link.rb +0 -7
  25. data/lib/mutineer/mutators/collection_method.rb +1 -1
  26. data/lib/mutineer/mutators/condition_forcing.rb +0 -7
  27. data/lib/mutineer/mutators/operand_removal.rb +0 -7
  28. data/lib/mutineer/mutators/operator_assignment.rb +0 -7
  29. data/lib/mutineer/mutators/return_nil.rb +5 -11
  30. data/lib/mutineer/mutators/string_literal.rb +1 -1
  31. data/lib/mutineer/orphan_guard.rb +28 -0
  32. data/lib/mutineer/pairing.rb +222 -15
  33. data/lib/mutineer/parser.rb +12 -3
  34. data/lib/mutineer/project.rb +344 -21
  35. data/lib/mutineer/rails_worker_db.rb +83 -9
  36. data/lib/mutineer/reporter.rb +306 -53
  37. data/lib/mutineer/result.rb +73 -5
  38. data/lib/mutineer/runner.rb +123 -242
  39. data/lib/mutineer/statement_lines.rb +183 -0
  40. data/lib/mutineer/subject.rb +6 -2
  41. data/lib/mutineer/test_runners/minitest.rb +4 -2
  42. data/lib/mutineer/test_runners/rspec.rb +282 -3
  43. data/lib/mutineer/test_runners.rb +5 -3
  44. data/lib/mutineer/version.rb +1 -1
  45. data/lib/mutineer/worker_pool.rb +4 -2
  46. data/lib/mutineer.rb +2 -0
  47. metadata +10 -3
data/lib/mutineer/cli.rb CHANGED
@@ -9,7 +9,9 @@ require_relative "project"
9
9
  require_relative "pairing"
10
10
  require_relative "changed_lines"
11
11
  require_relative "runner"
12
+ require_relative "job_plan"
12
13
  require_relative "reporter"
14
+ require_relative "kill_matrix"
13
15
  require_relative "baseline"
14
16
  require_relative "mutator_registry"
15
17
 
@@ -28,11 +30,12 @@ module Mutineer
28
30
  Usage: mutineer [options] <command> [args]
29
31
 
30
32
  Commands:
31
- run [options] <source...> --test <test...> Mutate, run, and report
33
+ run [options] <source...> --test <test> [--test <test>...]
34
+ Mutate, run, and report
32
35
  run --dry-run [options] <source...> Print candidate mutations only
33
36
 
34
37
  Run options:
35
- --test FILE Test file covering the sources (repeatable)
38
+ --test FILE Test file covering the sources (one per flag; repeat it)
36
39
  --operators LIST Comma-separated operator names (default: Tier 1 set)
37
40
  --threshold FLOAT Fail (exit 1) when score < FLOAT (default: 0 = off)
38
41
  --baseline FILE Fail (exit 1) on NEW survivors / score drop vs a prior
@@ -41,12 +44,22 @@ module Mutineer
41
44
  --only NAME Restrict to one fully-qualified subject
42
45
  --since REF Only mutate lines changed since git REF (e.g. origin/main)
43
46
  --no-since Disable diff scoping (a typed no beats a .mutineer.yml since:)
44
- --jobs N Parallel worker count (default: processor count)
47
+ --jobs N Parallel worker count (default: processor count);
48
+ --test-command, --fail-fast, or --rails
49
+ without --daemon forces 1
45
50
  --strategy NAME reload (whole-file) or redefine (surgical); default: reload
51
+ --timeout SECONDS Per-mutant time limit for in-process runs (default: 10;
52
+ not with --daemon or --test-command); a mutant over it
53
+ is a timeout, excluded from the score
54
+ --capture-timeout SECONDS Time limit for each coverage-capture subprocess
55
+ and the clean check (default: 120; not with --test-command)
56
+ --cache-dir DIR Directory for the coverage cache (default: .mutineer;
57
+ not with --test-command)
46
58
  --framework NAME minitest or rspec (default: auto-detect from --test names)
47
59
  --boot FILE Require FILE once in the parent to boot the app env, then
48
60
  fork per mutant (Rails apps; requires --test)
49
- --rails Sugar for --boot config/environment --strategy redefine
61
+ --rails Boot config/environment; without --daemon, defaults to
62
+ redefine and serial execution
50
63
  --test-command CMD Run the target suite in the app's own runtime as a
51
64
  subprocess (for apps on Ruby < 3.4). CMD must contain
52
65
  %{files}. Scrubs Mutineer Ruby PATH pins; set RAILS_ENV
@@ -57,7 +70,13 @@ module Mutineer
57
70
  --format human|json|html Report format (default: human)
58
71
  --output FILE Write the report to FILE instead of stdout
59
72
  --dry-run List mutations without executing
60
- --fail-fast Stop at the first surviving mutant
73
+ --fail-fast Stop at the first surviving mutant (--no-fail-fast beats a
74
+ .mutineer.yml fail_fast:)
75
+ --matrix Run every covering test for each mutant and report blind and
76
+ redundant tests; the JSON report also lists each mutant's
77
+ killers (in-process only; not with --daemon, --test-command,
78
+ --fail-fast or --dry-run; --no-matrix beats a .mutineer.yml
79
+ matrix:)
61
80
  --verbose Surface the real error when a fork capture fails (alias: --debug)
62
81
 
63
82
  Options:
@@ -88,7 +107,10 @@ module Mutineer
88
107
  end
89
108
  o.on("--list-operators") { show_operators = true }
90
109
  o.on("--dry-run") { opts[:dry_run] = true }
91
- o.on("--fail-fast") { opts[:fail_fast] = true }
110
+ # A typed --no-* beats the .mutineer.yml key, so a file key that
111
+ # conflicts with another flag can be turned off for one run.
112
+ o.on("--[no-]fail-fast") { |on| opts[:fail_fast] = on }
113
+ o.on("--[no-]matrix") { |on| opts[:matrix] = on }
92
114
  o.on("--only NAME") { |v| opts[:only] = v }
93
115
  o.on("--since REF") { |v| opts[:since] = Config.parse(:since, v) }
94
116
  # A typed "no" must beat a .mutineer.yml `since:` key: the key is present
@@ -100,6 +122,9 @@ module Mutineer
100
122
  end
101
123
  o.on("--threshold FLOAT") { |v| opts[:threshold] = Config.parse(:threshold, v) }
102
124
  o.on("--jobs N") { |v| opts[:jobs] = Config.parse(:jobs, v) }
125
+ o.on("--timeout SECONDS") { |v| opts[:timeout] = Config.parse(:timeout, v) }
126
+ o.on("--capture-timeout SECONDS") { |v| opts[:capture_timeout] = Config.parse(:capture_timeout, v) }
127
+ o.on("--cache-dir DIR") { |v| opts[:cache_dir] = Config.parse(:cache_dir, v) }
103
128
  o.on("--strategy STRAT") { |v| opts[:strategy] = Config.parse(:strategy, v) }
104
129
  o.on("--framework NAME") { |v| opts[:framework] = Config.parse(:framework, v) }
105
130
  o.on("--boot FILE") { |v| opts[:boot] = v }
@@ -138,7 +163,7 @@ module Mutineer
138
163
 
139
164
  begin
140
165
  file_path = Config.find_file
141
- file_hash = file_path ? Config.from_file(file_path) : {}
166
+ file_hash = file_path ? Config.from_file(file_path, defer_operators: opts.key?(:operators)) : {}
142
167
  config = Config.resolve(opts, file_hash)
143
168
  rescue Mutineer::ConfigError => e
144
169
  # The lib layer raises instead of killing the host; the CLI maps a
@@ -150,8 +175,14 @@ module Mutineer
150
175
  case argv.first
151
176
  when "run"
152
177
  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!.
178
+ tests_as_sources = argv[1..].grep(%r{(\A|/)(test|spec)/.*_(test|spec)\.rb\z})
179
+ if config.explicit?(:tests) && tests_as_sources.any?
180
+ warn "mutineer: #{tests_as_sources.join(', ')} looks like a test file, not a source. " \
181
+ "--test takes one file; repeat it for each test file (--test a_test.rb --test b_test.rb)"
182
+ exit 2
183
+ end
184
+ # A directory source expands to its **/*.rb files; a file inside the
185
+ # project becomes its root-relative path (#104). Test inference (when --test is omitted) happens in validate!.
155
186
  config.sources = Pairing.expand_sources(argv[1..], project_root: config.project_root)
156
187
  run(config)
157
188
  else
@@ -224,6 +255,8 @@ module Mutineer
224
255
  # @param config [Mutineer::Config] run configuration.
225
256
  # @return [void]
226
257
  def self.validate!(config)
258
+ # First, so a conflict is reported before another check rewrites the config.
259
+ validate_matrix!(config) if config.matrix
227
260
  validate_test_command!(config) if config.test_command
228
261
 
229
262
  validate_since!(config) if config.since
@@ -277,6 +310,7 @@ module Mutineer
277
310
  "(surgical redefine needs a shared VM; the subprocess has its own)"
278
311
  exit 2
279
312
  end
313
+ warn_unused_settings(config, "--test-command", %i[timeout capture_timeout cache_dir])
280
314
  return unless config.jobs > 1
281
315
 
282
316
  warn "[mutineer] --test-command runs serially (no per-worker DB isolation yet); forcing --jobs 1."
@@ -305,6 +339,7 @@ module Mutineer
305
339
  "(rspec is not implemented on the daemon path yet)"
306
340
  exit 2
307
341
  end
342
+ warn_unused_settings(config, "--daemon", %i[timeout])
308
343
  return if config.strategy == "reload"
309
344
 
310
345
  # --rails defaults strategy to redefine; daemon always whole-file loads.
@@ -313,6 +348,66 @@ module Mutineer
313
348
  config.strategy = "reload"
314
349
  end
315
350
 
351
+ # Where each setting a backend may ignore applies, for the warning.
352
+ UNUSED_SETTING_SCOPE = {
353
+ timeout: "in-process runs only",
354
+ capture_timeout: "in-process and --daemon coverage capture",
355
+ cache_dir: "the in-process and --daemon coverage cache"
356
+ }.freeze
357
+
358
+ # A backend that never reads a setting the user set would silently do
359
+ # nothing with it, so say so. `--test-command` builds no coverage map and
360
+ # has its own runtime, so it reads none of {UNUSED_SETTING_SCOPE}; the
361
+ # daemon reads the capture limit and the cache directory but keeps its own
362
+ # per-mutant limit.
363
+ #
364
+ # @api private
365
+ # @param config [Mutineer::Config] run configuration.
366
+ # @param backend [String] the backend's flag.
367
+ # @param keys [Array<Symbol>] the settings that backend ignores.
368
+ # @return [void]
369
+ def self.warn_unused_settings(config, backend, keys)
370
+ keys.each do |key|
371
+ next unless config.explicit?(key)
372
+
373
+ applies = UNUSED_SETTING_SCOPE.fetch(key)
374
+ warn "[mutineer] #{config.origin(key)} has no effect with #{backend} (it applies to #{applies}); ignoring it."
375
+ end
376
+ end
377
+
378
+ # --matrix runs on the in-process backend and needs every mutant's whole
379
+ # covering run, so a backend that cannot name the failing test, or a run that
380
+ # stops early, is a usage error (exit 2), never a quietly partial matrix.
381
+ #
382
+ # @api private
383
+ # @param config [Mutineer::Config] run configuration.
384
+ # @return [void]
385
+ def self.validate_matrix!(config)
386
+ conflict, reason =
387
+ if config.dry_run
388
+ [:dry_run, "a dry run runs no tests, so it has no matrix"]
389
+ elsif config.daemon
390
+ [:daemon, "the kill matrix runs on the in-process backend only"]
391
+ elsif config.test_command
392
+ [:test_command, "the external suite reports pass or fail, not which test failed"]
393
+ elsif config.fail_fast
394
+ [:fail_fast, "a fail-fast run is partial, so blind and redundant tests would be wrong"]
395
+ end
396
+ return unless conflict
397
+
398
+ # Say how to turn off whichever side came from the file, for this run.
399
+ hint =
400
+ if config.origin(:matrix) != "--matrix"
401
+ "; pass --no-matrix to run without it"
402
+ elsif conflict == :fail_fast && config.origin(:fail_fast) != "--fail-fast"
403
+ "; pass --no-fail-fast to run without it"
404
+ else
405
+ ""
406
+ end
407
+ warn "mutineer: #{config.origin(:matrix)} cannot be combined with #{config.origin(conflict)} (#{reason}#{hint})"
408
+ exit 2
409
+ end
410
+
316
411
  # --since needs a real git repo and a resolvable ref; either failure is a
317
412
  # usage error (exit 2) so CI sees "bad invocation," not "tests too weak."
318
413
  #
@@ -354,9 +449,10 @@ module Mutineer
354
449
  end
355
450
 
356
451
  # 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
452
+ # (explicit --test wins). Each source with at least one inferred test on
453
+ # disk joins the run; a source with none is dropped with a one-line stderr
454
+ # warning and the run continues with the rest. Split files for one source
455
+ # are all kept (#87). If every source is dropped: in boot mode the
360
456
  # dedicated --boot/--rails-requires-test check reports it; otherwise exit 2
361
457
  # with a usage message. The framework is re-detected from the inferred set
362
458
  # unless it was set explicitly (a spec-only project loads/reports as rspec).
@@ -368,14 +464,14 @@ module Mutineer
368
464
  return unless config.tests.empty?
369
465
 
370
466
  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
467
+ tests = Pairing.infer_tests(s, project_root: config.project_root, prefer: config.framework)
468
+ [s, tests] unless tests.empty?
373
469
  end
374
470
  (config.sources - paired.map(&:first)).each do |s|
375
471
  warn "[mutineer] no test found by convention for #{s}; skipping"
376
472
  end
377
473
  config.sources = paired.map(&:first)
378
- config.tests = paired.map(&:last).uniq
474
+ config.tests = paired.flat_map(&:last).uniq
379
475
  config.framework = Config.detect_framework(config.tests) unless config.explicit?(:framework)
380
476
 
381
477
  return unless config.sources.empty?
@@ -426,7 +522,8 @@ module Mutineer
426
522
 
427
523
  aggregate, source_map, extras = Runner.execute(config)
428
524
  warn_legacy_ignore_matches(extras[:legacy_ignore_matches])
429
- reporter = Reporter.new(aggregate, source_map)
525
+ matrix = KillMatrix.new(aggregate.results) if config.matrix
526
+ reporter = Reporter.new(aggregate, source_map, matrix: matrix)
430
527
 
431
528
  # Diff the current run against the baseline (preflighted above) by the
432
529
  # stable survivor id. The delta is rendered inline (human section / additive
@@ -543,20 +640,20 @@ module Mutineer
543
640
  "it runs this mutineer version or later."
544
641
  end
545
642
 
546
- # Runs dry-run mode. Reuses Runner.collect_jobs (+ filter_since) so the
643
+ # Runs dry-run mode. Reuses JobPlan.collect_jobs (+ filter_since) so the
547
644
  # candidate list cannot drift from a real run's job selection.
548
645
  #
549
646
  # @param config [Mutineer::Config] run configuration.
550
647
  # @return [void]
551
648
  def self.dry_run(config)
552
649
  operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
553
- jobs, ignored_results, source_map, extras = Runner.collect_jobs(config, operator_classes)
650
+ jobs, ignored_results, source_map, extras = JobPlan.collect_jobs(config, operator_classes)
554
651
  warn_legacy_ignore_matches(extras[:legacy_ignore_matches])
555
652
  # Narrow jobs and ignored the same way so the summary matches the printed list.
556
653
  if config.since
557
- jobs = Runner.filter_since(jobs, source_map, config)
654
+ jobs = JobPlan.filter_since(jobs, source_map, config)
558
655
  ignored_jobs = ignored_results.map { |r| [r.subject, r.mutation, r.id] }
559
- ignored = Runner.filter_since(ignored_jobs, source_map, config).size
656
+ ignored = JobPlan.filter_since(ignored_jobs, source_map, config).size
560
657
  else
561
658
  ignored = ignored_results.size
562
659
  end
@@ -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