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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +475 -0
- data/README.md +224 -28
- data/lib/mutineer/changed_lines.rb +24 -1
- data/lib/mutineer/cli.rb +117 -20
- data/lib/mutineer/config.rb +64 -13
- data/lib/mutineer/coverage_map.rb +533 -100
- data/lib/mutineer/daemon_backend.rb +51 -33
- data/lib/mutineer/daemon_client.rb +212 -22
- data/lib/mutineer/daemon_server.rb +74 -27
- data/lib/mutineer/external_backend.rb +2 -2
- data/lib/mutineer/file_swap.rb +1 -1
- data/lib/mutineer/isolation.rb +92 -6
- data/lib/mutineer/job_plan.rb +352 -0
- data/lib/mutineer/kill_channel.rb +284 -0
- data/lib/mutineer/kill_matrix.rb +88 -0
- data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
- data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
- data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +14 -68
- data/lib/mutineer/minitest_integration.rb +75 -11
- data/lib/mutineer/mutant_id.rb +5 -4
- data/lib/mutineer/mutators/array_literal.rb +0 -7
- data/lib/mutineer/mutators/base.rb +63 -4
- data/lib/mutineer/mutators/chain_link.rb +0 -7
- data/lib/mutineer/mutators/collection_method.rb +1 -1
- data/lib/mutineer/mutators/condition_forcing.rb +0 -7
- data/lib/mutineer/mutators/operand_removal.rb +0 -7
- data/lib/mutineer/mutators/operator_assignment.rb +0 -7
- data/lib/mutineer/mutators/return_nil.rb +5 -11
- data/lib/mutineer/mutators/string_literal.rb +1 -1
- data/lib/mutineer/orphan_guard.rb +28 -0
- data/lib/mutineer/pairing.rb +222 -15
- data/lib/mutineer/parser.rb +12 -3
- data/lib/mutineer/project.rb +344 -21
- data/lib/mutineer/rails_worker_db.rb +83 -9
- data/lib/mutineer/reporter.rb +306 -53
- data/lib/mutineer/result.rb +73 -5
- data/lib/mutineer/runner.rb +123 -242
- data/lib/mutineer/statement_lines.rb +183 -0
- data/lib/mutineer/subject.rb +6 -2
- data/lib/mutineer/test_runners/minitest.rb +4 -2
- data/lib/mutineer/test_runners/rspec.rb +282 -3
- data/lib/mutineer/test_runners.rb +5 -3
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer/worker_pool.rb +4 -2
- data/lib/mutineer.rb +2 -0
- 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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|
|
358
|
-
# run; a source with none is dropped with a one-line stderr
|
|
359
|
-
# run continues with the rest.
|
|
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
|
-
|
|
372
|
-
[s,
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
656
|
+
ignored = JobPlan.filter_since(ignored_jobs, source_map, config).size
|
|
560
657
|
else
|
|
561
658
|
ignored = ignored_results.size
|
|
562
659
|
end
|
data/lib/mutineer/config.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
|
|
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
|
-
|
|
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
|
|
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
|