mutineer 0.2.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4020e2cd0e53ac4f406998d2c251d26abee9f75c4e6ceef365f2ffe565be50ab
4
+ data.tar.gz: f42dbc71077fc802b56f878e27885568f3d128f244875fb678dede5f6940f385
5
+ SHA512:
6
+ metadata.gz: dc24bb60419b7d701a85a09c70495c9d9d7ce2cd2d5b5a10621cb03a940276c3526dc650ee4deb7f93529e09b67b872777c2483419979d5740a83aa9ac9bfd26
7
+ data.tar.gz: ff59317a64ab0e3478c7dde381ac9752b56c83920d1a63bcf6528ac826f286d787b2d560c1946e897790bd47325ac2209d22b537e3baa146b90f2bedcd2f0c72
data/CHANGELOG.md ADDED
@@ -0,0 +1,42 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is based on
4
+ [Keep a Changelog](https://keepachangelog.com/), and this project adheres to
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [0.2.0] - 2026-06-28
8
+
9
+ ### Added
10
+ - **Boot mode for Rails (and any app needing its environment booted)** —
11
+ `--rails` boots `config/environment` once in the parent and forks per mutant
12
+ (children inherit the booted app), defaults the strategy to `redefine`, and
13
+ reconnects ActiveRecord in each fork for DB fork-safety. `--boot FILE` boots a
14
+ custom entry point. Boot mode requires `--test` files and runs them for every
15
+ mutant (coverage-guided selection in boot mode is future work). `.mutineer.yml`
16
+ accepts `boot:` and `rails:`.
17
+ - GitHub Actions CI (test suite + gem build on Ruby 3.4, ubuntu + macos).
18
+
19
+ ### Changed
20
+ - `--strategy` values are now `reload` / `redefine` (canonical); `7a` / `7b`
21
+ remain accepted as deprecated aliases.
22
+
23
+ ## [0.1.0] - 2026-06-28
24
+
25
+ ### Added
26
+ - Initial release of Mutineer — a clean-room, Prism-based mutation-testing tool
27
+ for Ruby with zero runtime dependencies (Ruby ≥ 3.4).
28
+ - Mutation operators: arithmetic, comparison, boolean-connector, boolean-literal,
29
+ statement-removal (Tier 1, default); return-nil, literal-mutation,
30
+ condition-negation (Tier 2, opt-in via `--operators`).
31
+ - Coverage-guided test selection with a digest-keyed, auto-invalidating cache.
32
+ - Fork-isolated, parallel execution (`--jobs`) with per-mutant timeouts.
33
+ - Two application strategies: `reload` (whole-file, default) and `redefine`
34
+ (surgical), verified to agree on namespaced multi-statement methods. (`7a`/`7b`
35
+ accepted as deprecated aliases.)
36
+ - `run`, `--dry-run`, `--threshold`, `--only`, `--operators`, `--strategy`,
37
+ `--format human|json`, `--output`, `--list-operators`.
38
+ - `.mutineer.yml` configuration (CLI > config > default precedence).
39
+ - Byte-correct source handling for multibyte (UTF-8) sources.
40
+
41
+ [0.2.0]: https://github.com/davidteren/mutineer/releases/tag/v0.2.0
42
+ [0.1.0]: https://github.com/davidteren/mutineer/releases/tag/v0.1.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 David Teren
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # Mutineer
2
+
3
+ A clean-room mutation-testing tool for Ruby. Mutineer mutates your source one
4
+ change at a time, runs your Minitest suite against each mutant, and reports the
5
+ ones your tests failed to catch — the gaps where your suite isn't actually
6
+ testing anything.
7
+
8
+ - **Prism + stdlib only** — zero runtime dependencies (Ruby ≥ 3.4).
9
+ - **One mutation per mutant**, validity-checked by re-parsing.
10
+ - **Fork-isolated**, parallel execution (Linux + macOS).
11
+ - **Coverage-guided** — each mutant runs only the test files that cover its line.
12
+
13
+ 📖 **[mutineer.github.io →](https://davidteren.github.io/mutineer/)** — overview, operators, and usage.
14
+
15
+ ## Install
16
+
17
+ ```sh
18
+ gem install mutineer
19
+ ```
20
+
21
+ Or in a Gemfile:
22
+
23
+ ```ruby
24
+ gem "mutineer", group: :test
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ```sh
30
+ mutineer run <source...> --test <test...> [options]
31
+ ```
32
+
33
+ Mutate `lib/calculator.rb`, checking it against its test, and fail CI if the
34
+ mutation score drops below 90%:
35
+
36
+ ```sh
37
+ mutineer run lib/calculator.rb --test test/calculator_test.rb --threshold 90
38
+ ```
39
+
40
+ ### Options
41
+
42
+ | Flag | Meaning |
43
+ |------|---------|
44
+ | `--test FILE` | Test file covering the sources (repeatable) |
45
+ | `--operators LIST` | Comma-separated operator names (default: the Tier-1 set) |
46
+ | `--threshold FLOAT` | Exit 1 when the score is below FLOAT (default: 0 = off) |
47
+ | `--only NAME` | Restrict to one fully-qualified subject, e.g. `Calculator#add` |
48
+ | `--jobs N` | Parallel worker count (default: processor count) |
49
+ | `--strategy NAME` | Mutation application: `reload` whole-file (default) or `redefine` surgical (`7a`/`7b` accepted as deprecated aliases) |
50
+ | `--format human\|json` | Report format (default: human) |
51
+ | `--output FILE` | Write the report to FILE instead of stdout |
52
+ | `--dry-run` | List candidate mutations without executing |
53
+ | `--list-operators` | List available operators (default vs optional) and exit |
54
+ | `--version`, `--help` | Print version / usage and exit |
55
+
56
+ ### Exit codes
57
+
58
+ | Code | Meaning |
59
+ |------|---------|
60
+ | `0` | Score ≥ threshold (or no threshold set) |
61
+ | `1` | Survivors below threshold, or a runtime error |
62
+ | `2` | Usage / invalid-flag error |
63
+
64
+ ### Operators
65
+
66
+ Run `mutineer --list-operators` to see them. Default (Tier 1): `arithmetic`,
67
+ `comparison`, `boolean_connector`, `boolean_literal`, `statement_removal`.
68
+ Available but off by default (Tier 2, enable via `--operators`): `return_nil`,
69
+ `literal_mutation`, `condition_negation`.
70
+
71
+ ## Rails apps
72
+
73
+ Rails code needs its environment booted before the suite runs, so point Mutineer
74
+ at your app with `--rails` and run it inside the project's bundle:
75
+
76
+ ```sh
77
+ RAILS_ENV=test bundle exec mutineer run \
78
+ app/models/order.rb --test test/models/order_test.rb --rails
79
+ ```
80
+
81
+ `--rails` boots `config/environment` once in the parent process (every mutant
82
+ then forks and inherits it), defaults `--strategy` to `redefine` (surgical — it
83
+ avoids reloading files into the app tree), and reconnects ActiveRecord in each
84
+ fork so the database connection is fork-safe. Use `--boot FILE` to boot a
85
+ different entry point. Boot mode requires at least one `--test` file and runs
86
+ those tests for every mutant (coverage-guided selection is not yet available in
87
+ boot mode).
88
+
89
+ Add Mutineer to your Gemfile's test group:
90
+
91
+ ```ruby
92
+ gem "mutineer", group: :test, require: false
93
+ ```
94
+
95
+ ## Configuration
96
+
97
+ Mutineer reads an optional `.mutineer.yml` from the project root (nearest one,
98
+ walking up). CLI flags override config; config overrides defaults.
99
+
100
+ Sources are positional CLI arguments and test files come from `--test`; the
101
+ config file accepts these keys: `operators`, `threshold`, `jobs`, `only`,
102
+ `require` (extra files to load before mutating), and `boot`/`rails`.
103
+
104
+ ```yaml
105
+ # .mutineer.yml
106
+ operators: [arithmetic, comparison, boolean_connector, boolean_literal, statement_removal]
107
+ threshold: 90
108
+ jobs: 4
109
+ require:
110
+ - config/environment
111
+ ```
112
+
113
+ Coverage results are cached in `.mutineer/coverage.json` (digest-keyed; rebuilt
114
+ automatically when sources change). Add `.mutineer/` to your `.gitignore`.
115
+
116
+ ## License
117
+
118
+ MIT — see [LICENSE](LICENSE).
data/bin/mutineer ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/mutineer"
5
+
6
+ Mutineer::CLI.start(ARGV)
@@ -0,0 +1,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "set"
5
+ require_relative "version"
6
+ require_relative "config"
7
+ require_relative "parser"
8
+ require_relative "project"
9
+ require_relative "runner"
10
+ require_relative "reporter"
11
+ require_relative "mutator_registry"
12
+
13
+ module Mutineer
14
+ # Command-line entry point. `start` is the single public method called by
15
+ # bin/mutineer; it parses argv, acts, and exits with a pinned code.
16
+ #
17
+ # Exit codes (taxonomy consistent across M1–M5):
18
+ # 0 success / requested output (--version, --help, score >= threshold)
19
+ # 1 survivors below threshold, or a runtime error
20
+ # 2 usage / flag error (unknown subcommand, invalid flag, unknown operator,
21
+ # out-of-range threshold)
22
+ class CLI
23
+ BANNER = <<~USAGE
24
+ Usage: mutineer [options] <command> [args]
25
+
26
+ Commands:
27
+ run [options] <source...> --test <test...> Mutate, run, and report
28
+ run --dry-run [options] <source...> Print candidate mutations only
29
+
30
+ Run options:
31
+ --test FILE Test file covering the sources (repeatable)
32
+ --operators LIST Comma-separated operator names (default: Tier 1 set)
33
+ --threshold FLOAT Fail (exit 1) when score < FLOAT (default: 0 = off)
34
+ --only NAME Restrict to one fully-qualified subject
35
+ --jobs N Parallel worker count (default: processor count)
36
+ --strategy NAME reload (whole-file) or redefine (surgical); default: reload
37
+ --boot FILE Require FILE once in the parent to boot the app env, then
38
+ fork per mutant (Rails apps; requires --test, no coverage)
39
+ --rails Sugar for --boot config/environment --strategy redefine
40
+ --format human|json Report format (default: human)
41
+ --output FILE Write the report to FILE instead of stdout
42
+ --dry-run List mutations without executing
43
+
44
+ Options:
45
+ --list-operators List available operators (default vs optional) and exit
46
+ --version Print version and exit
47
+ --help Print this help and exit
48
+ USAGE
49
+
50
+ # Field symbols whose config-file value is suppressed when the flag is typed.
51
+ PRECEDENCE_FLAGS = %i[operators jobs threshold only].freeze
52
+
53
+ # Deprecated internal strategy names, mapped to their canonical equivalents.
54
+ STRATEGY_ALIASES = { "7a" => "reload", "7b" => "redefine" }.freeze
55
+
56
+ def self.start(argv)
57
+ opts = {} # symbol => value, the CLI-provided Config fields
58
+ explicit = Set.new # precedence keys the user typed (KTD3)
59
+ show_operators = false
60
+
61
+ parser = OptionParser.new do |o|
62
+ o.banner = BANNER
63
+ o.on("--version") do
64
+ puts Mutineer::VERSION
65
+ exit 0
66
+ end
67
+ o.on("--help") do
68
+ puts BANNER
69
+ exit 0
70
+ end
71
+ o.on("--list-operators") { show_operators = true }
72
+ o.on("--dry-run") { opts[:dry_run] = true }
73
+ o.on("--only NAME") { |v| opts[:only] = v; explicit << :only }
74
+ o.on("--test FILE") { |v| (opts[:tests] ||= []) << v }
75
+ o.on("--operators LIST") { |v| opts[:operators] = v.split(",").map(&:strip); explicit << :operators }
76
+ o.on("--threshold FLOAT") { |v| opts[:threshold] = v.to_f; explicit << :threshold }
77
+ o.on("--jobs N") { |v| opts[:jobs] = v; explicit << :jobs }
78
+ o.on("--strategy STRAT") { |v| opts[:strategy] = v; explicit << :strategy }
79
+ o.on("--boot FILE") { |v| opts[:boot] = v; explicit << :boot }
80
+ o.on("--rails") { opts[:rails] = true }
81
+ o.on("--format FORMAT") { |v| opts[:format] = v }
82
+ o.on("--output FILE") { |v| opts[:output] = v }
83
+ end
84
+
85
+ begin
86
+ parser.parse!(argv)
87
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
88
+ warn "mutineer: #{e.message}"
89
+ exit 2
90
+ end
91
+
92
+ if show_operators
93
+ list_operators
94
+ exit 0
95
+ end
96
+
97
+ if argv.empty?
98
+ puts BANNER
99
+ exit 0
100
+ end
101
+
102
+ begin
103
+ file_path = Config.find_file
104
+ file_hash = file_path ? Config.from_file(file_path) : {}
105
+ config = Config.resolve(opts, file_hash, explicit)
106
+ rescue Mutineer::ConfigError => e
107
+ # R8: the lib layer raises instead of killing the host; the CLI maps a
108
+ # config (usage) error to exit 2.
109
+ warn "mutineer: #{e.message}"
110
+ exit 2
111
+ end
112
+
113
+ case argv.first
114
+ when "run"
115
+ config.sources = argv[1..]
116
+ run(config)
117
+ else
118
+ warn "mutineer: unknown command '#{argv.first}'"
119
+ exit 2
120
+ end
121
+ end
122
+
123
+ def self.list_operators
124
+ MutatorRegistry::ALL.each_key do |name|
125
+ state = MutatorRegistry.default?(name) ? "default" : "disabled"
126
+ puts format("%-20s tier %d %-9s %s",
127
+ name, MutatorRegistry.tier(name), state, MutatorRegistry::DESCRIPTIONS[name])
128
+ end
129
+ end
130
+
131
+ def self.run(config)
132
+ if config.sources.empty?
133
+ warn "mutineer: run requires at least one source file"
134
+ exit 2
135
+ end
136
+ validate!(config)
137
+
138
+ config.dry_run ? dry_run(config) : execute(config)
139
+ rescue ArgumentError => e
140
+ # Unknown --operators value surfaces here; no backtrace reaches the user.
141
+ warn "mutineer: #{e.message}"
142
+ exit 2
143
+ rescue SystemCallError => e
144
+ # R5: a missing/unreadable path reaches here as Errno::ENOENT etc. — a plain
145
+ # message and usage exit, never a raw backtrace.
146
+ warn "mutineer: #{e.message}"
147
+ exit 2
148
+ rescue SyntaxError => e
149
+ # A syntactically invalid source file surfaces when `require`d; report it
150
+ # cleanly rather than dumping a backtrace.
151
+ warn "mutineer: cannot load source: #{e.message}"
152
+ exit 1
153
+ rescue Mutineer::ParseError => e
154
+ warn "mutineer: error reading: #{e.message}"
155
+ exit 1
156
+ end
157
+
158
+ # Flag validation: every flag/usage failure exits 2 (C7), consistent with the
159
+ # taxonomy above — CI can tell "mistyped flag" from "tests too weak."
160
+ def self.validate!(config)
161
+ unless (0.0..100.0).cover?(config.threshold)
162
+ warn "mutineer: --threshold must be between 0 and 100"
163
+ exit 2
164
+ end
165
+
166
+ jobs = Integer(config.jobs.to_s, exception: false)
167
+ if jobs.nil? || jobs < 1
168
+ warn "mutineer: --jobs requires a positive integer (got: #{config.jobs})"
169
+ exit 2
170
+ end
171
+ config.jobs = jobs
172
+
173
+ unless %w[human json].include?(config.format)
174
+ warn %(mutineer: unknown format "#{config.format}". Expected: human, json)
175
+ exit 2
176
+ end
177
+
178
+ # Canonical strategies are reload|redefine; 7a/7b are accepted as deprecated
179
+ # aliases. Normalize to canonical so the rest of the pipeline sees one name.
180
+ config.strategy = STRATEGY_ALIASES.fetch(config.strategy, config.strategy)
181
+ unless %w[reload redefine].include?(config.strategy)
182
+ warn %(mutineer: unknown strategy "#{config.strategy}". Expected: reload, redefine)
183
+ exit 2
184
+ end
185
+
186
+ # Boot mode does no coverage selection — every mutant runs the given tests —
187
+ # so at least one --test file is mandatory (there is nothing to select from).
188
+ if config.boot && config.tests.empty?
189
+ warn "mutineer: --boot/--rails requires at least one --test file"
190
+ exit 2
191
+ end
192
+
193
+ preflight_output!(config.output) if config.output
194
+ validate_paths!(config)
195
+ end
196
+
197
+ # R5: validate path existence up front so a typo is a clean usage error (exit
198
+ # 2), not an Errno::ENOENT backtrace from deep in the run. Flag checks run
199
+ # first so a bad flag still reports the flag, not the missing file.
200
+ def self.validate_paths!(config)
201
+ missing = (config.sources + config.tests)
202
+ .reject { |p| File.exist?(File.expand_path(p, config.project_root)) }
203
+ return if missing.empty?
204
+
205
+ warn "mutineer: no such file: #{missing.join(', ')}"
206
+ exit 2
207
+ end
208
+
209
+ def self.preflight_output!(path)
210
+ dir = File.dirname(File.expand_path(path))
211
+ return if File.directory?(dir) && File.writable?(dir)
212
+
213
+ reason = File.directory?(dir) ? "directory is not writable" : "no such directory"
214
+ warn "mutineer: cannot write to #{path}: #{reason}"
215
+ exit 2
216
+ end
217
+
218
+ def self.execute(config)
219
+ if config.tests.empty?
220
+ warn "mutineer: run requires at least one --test file (or use --dry-run)"
221
+ exit 2
222
+ end
223
+
224
+ aggregate, source_map = Runner.execute(config)
225
+ reporter = Reporter.new(aggregate, source_map)
226
+ reporter.report(out: $stdout, err: $stderr, threshold: config.threshold,
227
+ format: config.format, output: config.output)
228
+ exit reporter.exit_code(threshold: config.threshold)
229
+ end
230
+
231
+ def self.dry_run(config)
232
+ operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
233
+ sources = {}
234
+ per_operator = Hash.new(0)
235
+ skipped = 0
236
+
237
+ Project.discover(config.sources, only: config.only).each do |subject|
238
+ source = (sources[subject.file] ||= Parser.parse_file(subject.file).source.source)
239
+ operator_classes.each do |klass|
240
+ klass.new.mutations_for(subject, source).each do |mutation|
241
+ unless mutation.valid?(source)
242
+ skipped += 1
243
+ next
244
+ end
245
+ per_operator[mutation.operator] += 1
246
+ original = source.byteslice(mutation.start_offset...mutation.end_offset)
247
+ line = source.byteslice(0, mutation.start_offset).count("\n") + 1
248
+ puts "[#{mutation.operator}] #{subject.qualified_name} " \
249
+ "#{subject.file}:#{line} `#{original}` -> `#{mutation.replacement}`"
250
+ end
251
+ end
252
+ end
253
+
254
+ total = per_operator.values.sum
255
+ breakdown = per_operator.map { |op, n| "#{op}: #{n}" }.join(", ")
256
+ summary = breakdown.empty? ? "" : "#{breakdown} — "
257
+ puts "#{summary}#{total} mutations (dry run, not executed); #{skipped} skipped (invalid)"
258
+ exit 0
259
+ end
260
+ end
261
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+ require "yaml"
5
+
6
+ module Mutineer
7
+ # Raised by the config layer instead of calling exit/abort — a data class must
8
+ # never kill the host process (R8). The CLI rescues this and maps it to exit 2.
9
+ class ConfigError < StandardError; end
10
+
11
+ # Plain run configuration, populated by the CLI (or directly by the
12
+ # integration test). `operators` nil means "all default operators";
13
+ # `threshold` 0.0 means the CI gate is off (spec §10).
14
+ #
15
+ # M5 adds: jobs (parallel workers), format (human|json), output (report file),
16
+ # strategy (reload|redefine), require_paths (extra files to load). Config loading and
17
+ # the CLI > file > default precedence merge live here (KTD3/KTD4).
18
+ #
19
+ # Boot mode adds: boot (a file to require ONCE in the parent so the app env —
20
+ # e.g. Rails — is booted before forking; sources are then NOT manually required)
21
+ # and rails (sugar: defaults boot to config/environment and strategy to redefine,
22
+ # and reconnects ActiveRecord per fork).
23
+ Config = Struct.new(
24
+ :sources, :tests, :operators, :threshold, :only, :dry_run,
25
+ :cache_dir, :project_root, :load_paths,
26
+ :jobs, :format, :output, :strategy, :require_paths,
27
+ :boot, :rails,
28
+ keyword_init: true
29
+ ) do
30
+ CONFIG_FILE = ".mutineer.yml"
31
+ # Keys accepted in .mutineer.yml (R7). `require` maps to the :require_paths field.
32
+ KNOWN_KEYS = %w[operators jobs threshold only require boot rails].freeze
33
+
34
+ def initialize(**kwargs)
35
+ super
36
+ self.sources ||= []
37
+ self.tests ||= []
38
+ self.threshold ||= 0.0
39
+ self.dry_run ||= false
40
+ self.cache_dir ||= ".mutineer"
41
+ self.project_root ||= Dir.pwd
42
+ self.load_paths ||= ["lib"]
43
+ self.jobs ||= Etc.nprocessors
44
+ self.format ||= "human"
45
+ self.strategy ||= "reload"
46
+ self.require_paths ||= []
47
+ self.rails = false if rails.nil?
48
+ end
49
+
50
+ # Walk from `start` toward `home`, returning the first .mutineer.yml path found
51
+ # or nil. Checks `home` itself, then stops; if `start` is above `home`
52
+ # (e.g. /tmp), the walk continues to the filesystem root (KTD4). Pure
53
+ # discovery — reads no file content.
54
+ def self.find_file(start = Dir.pwd, home = File.expand_path("~"))
55
+ dir = File.expand_path(start)
56
+ loop do
57
+ candidate = File.join(dir, CONFIG_FILE)
58
+ return candidate if File.file?(candidate)
59
+ break if dir == home
60
+
61
+ parent = File.dirname(dir)
62
+ break if parent == dir # filesystem root
63
+
64
+ dir = parent
65
+ end
66
+ nil
67
+ end
68
+
69
+ # Parse a .mutineer.yml into a symbol-keyed hash of recognized keys. Unknown
70
+ # keys / unknown operator names emit a one-line stderr warning and are ignored
71
+ # (R7). A YAML syntax error raises ConfigError (R7a/R8) — never a silent
72
+ # fallback to defaults, and never an exit from the lib layer.
73
+ def self.from_file(path)
74
+ raw = YAML.safe_load(File.read(path)) || {}
75
+ name = File.basename(path)
76
+ unless raw.is_a?(Hash)
77
+ warn "mutineer: #{name} ignored: expected a YAML mapping of keys to values"
78
+ return {}
79
+ end
80
+
81
+ out = {}
82
+ raw.each do |key, value|
83
+ ks = key.to_s
84
+ unless KNOWN_KEYS.include?(ks)
85
+ warn "mutineer: unknown config key #{ks.inspect} in #{name} " \
86
+ "(known: #{KNOWN_KEYS.join(', ')}); ignored"
87
+ next
88
+ end
89
+ out[field_for(ks)] = coerce(ks, value, name)
90
+ end
91
+ out
92
+ rescue Psych::SyntaxError => e
93
+ raise ConfigError, "#{File.basename(path)} parse error: #{e.message}"
94
+ end
95
+
96
+ # Apply precedence (KTD3): start from the CLI-provided values, then fill in a
97
+ # config-file value only for keys the user did NOT type on the command line.
98
+ # `explicit` is a Set of field symbols the CLI saw with a value; a Set (not
99
+ # nil-sentinels) is used because some valid values are zero/false.
100
+ def self.resolve(cli_opts, file_hash, explicit)
101
+ merged = cli_opts.dup
102
+ file_hash.each { |k, v| merged[k] = v unless explicit.include?(k) }
103
+ config = new(**merged)
104
+
105
+ # --rails sugar: boot config/environment and prefer the surgical (redefine)
106
+ # strategy, which avoids writing tempfiles into the app tree and Zeitwerk
107
+ # reload hazards. An explicit --strategy always wins.
108
+ if config.rails
109
+ config.boot ||= "config/environment"
110
+ config.strategy = "redefine" unless explicit.include?(:strategy)
111
+ end
112
+ config
113
+ end
114
+
115
+ def self.field_for(known_key)
116
+ known_key == "require" ? :require_paths : known_key.to_sym
117
+ end
118
+
119
+ def self.coerce(known_key, value, file_name)
120
+ case known_key
121
+ when "operators" then filter_operators(Array(value).map(&:to_s), file_name)
122
+ when "jobs" then value.to_i
123
+ when "threshold" then value.to_f
124
+ when "require" then Array(value).map(&:to_s)
125
+ when "boot" then value.to_s
126
+ when "rails" then value == true || value.to_s == "true"
127
+ else value
128
+ end
129
+ end
130
+
131
+ # Drop (with a warning) operator names the registry doesn't know (R7).
132
+ # Referenced lazily so config.rb carries no load-order dependency on the
133
+ # registry; by the time a config is parsed at runtime, it is loaded.
134
+ def self.filter_operators(names, file_name)
135
+ known = MutatorRegistry::ALL.keys
136
+ names.select do |n|
137
+ next true if known.include?(n)
138
+
139
+ warn "mutineer: unknown operator #{n.inspect} in #{file_name} " \
140
+ "(known: #{known.join(', ')}); ignored"
141
+ false
142
+ end
143
+ end
144
+ end
145
+ end