Class: Mutineer::Config

Inherits:
Struct
  • Object
show all
Defined in:
lib/mutineer/config.rb

Overview

Plain run configuration, populated by the CLI (or directly by the integration test). operators nil means "all default operators"; threshold 0.0 means the CI gate is off (spec ยง10).

Also holds: jobs (parallel workers), format (human|json), output (report file), strategy (reload|redefine), require_paths (extra files to load). Config loading and the CLI > file > default precedence merge live here; each layer holds only the keys the user wrote, and Config#explicit? reports them.

Boot mode adds: boot (a file to require ONCE in the parent so the app env, e.g. Rails, is booted before forking; sources are then NOT manually required) and rails (sugar: defaults boot to config/environment, prefers redefine without a daemon, and reconnects ActiveRecord per fork).

Constant Summary collapse

CONFIG_FILE =

Config file name.

".mutineer.yml"
KNOWN_KEYS =

Keys accepted in .mutineer.yml, derived from the schema. require maps to the :require_paths field.

CONFIG_OPTIONS.filter_map(&:yaml_key).freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(explicit: [], **kwargs) ⇒ Config

Returns a new instance of Config.

Parameters:

  • explicit (Array<Symbol>) (defaults to: []) —

    fields the user wrote (CLI or file). Derived values fill only the others; a programmatic Config.new writes none.



84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/mutineer/config.rb', line 84

def initialize(explicit: [], **kwargs)
  super(**kwargs)
  @explicit = explicit.to_a.dup.freeze
  self.sources       ||= []
  self.tests         ||= []
  self.threshold     ||= 0.0
  self.dry_run       ||= false
  self.cache_dir     ||= ".mutineer"
  self.project_root  ||= Dir.pwd
  self.load_paths    ||= ["lib"]
  self.jobs          ||= Etc.nprocessors
  self.format        ||= "human"
  self.strategy      ||= "reload"
  self.require_paths ||= []
  self.rails         = false if rails.nil?
  self.verbose       = false if verbose.nil?
  self.ignore        ||= []
  self.baseline_epsilon ||= 0.0
  self.fail_fast     = false if fail_fast.nil?
  self.daemon        = false if daemon.nil?
end

Instance Attribute Details

#baseline ⇒ Object

Returns the value of attribute baseline

Returns:

  • (Object) —

    the current value of baseline



65
66
67
# File 'lib/mutineer/config.rb', line 65

def baseline
  @baseline
end

#baseline_epsilon ⇒ Object

Returns the value of attribute baseline_epsilon

Returns:

  • (Object) —

    the current value of baseline_epsilon



65
66
67
# File 'lib/mutineer/config.rb', line 65

def baseline_epsilon
  @baseline_epsilon
end

#boot ⇒ Object

Returns the value of attribute boot

Returns:

  • (Object) —

    the current value of boot



65
66
67
# File 'lib/mutineer/config.rb', line 65

def boot
  @boot
end

#cache_dir ⇒ Object

Returns the value of attribute cache_dir

Returns:

  • (Object) —

    the current value of cache_dir



65
66
67
# File 'lib/mutineer/config.rb', line 65

def cache_dir
  @cache_dir
end

#daemon ⇒ Object

Returns the value of attribute daemon

Returns:

  • (Object) —

    the current value of daemon



65
66
67
# File 'lib/mutineer/config.rb', line 65

def daemon
  @daemon
end

#daemon_timeout ⇒ Object

Returns the value of attribute daemon_timeout

Returns:

  • (Object) —

    the current value of daemon_timeout



65
66
67
# File 'lib/mutineer/config.rb', line 65

def daemon_timeout
  @daemon_timeout
end

#dry_run ⇒ Object

Returns the value of attribute dry_run

Returns:

  • (Object) —

    the current value of dry_run



65
66
67
# File 'lib/mutineer/config.rb', line 65

def dry_run
  @dry_run
end

#fail_fast ⇒ Object

Returns the value of attribute fail_fast

Returns:

  • (Object) —

    the current value of fail_fast



65
66
67
# File 'lib/mutineer/config.rb', line 65

def fail_fast
  @fail_fast
end

#format ⇒ Object

Returns the value of attribute format

Returns:

  • (Object) —

    the current value of format



65
66
67
# File 'lib/mutineer/config.rb', line 65

def format
  @format
end

#framework ⇒ Object

Returns the value of attribute framework

Returns:

  • (Object) —

    the current value of framework



65
66
67
# File 'lib/mutineer/config.rb', line 65

def framework
  @framework
end

#ignore ⇒ Object

Returns the value of attribute ignore

Returns:

  • (Object) —

    the current value of ignore



65
66
67
# File 'lib/mutineer/config.rb', line 65

def ignore
  @ignore
end

#jobs ⇒ Object

Returns the value of attribute jobs

Returns:

  • (Object) —

    the current value of jobs



65
66
67
# File 'lib/mutineer/config.rb', line 65

def jobs
  @jobs
end

#load_paths ⇒ Object

Returns the value of attribute load_paths

Returns:

  • (Object) —

    the current value of load_paths



65
66
67
# File 'lib/mutineer/config.rb', line 65

def load_paths
  @load_paths
end

#only ⇒ Object

Returns the value of attribute only

Returns:

  • (Object) —

    the current value of only



65
66
67
# File 'lib/mutineer/config.rb', line 65

def only
  @only
end

#operators ⇒ Object

Returns the value of attribute operators

Returns:

  • (Object) —

    the current value of operators



65
66
67
# File 'lib/mutineer/config.rb', line 65

def operators
  @operators
end

#output ⇒ Object

Returns the value of attribute output

Returns:

  • (Object) —

    the current value of output



65
66
67
# File 'lib/mutineer/config.rb', line 65

def output
  @output
end

#project_root ⇒ Object

Returns the value of attribute project_root

Returns:

  • (Object) —

    the current value of project_root



65
66
67
# File 'lib/mutineer/config.rb', line 65

def project_root
  @project_root
end

#rails ⇒ Object

Returns the value of attribute rails

Returns:

  • (Object) —

    the current value of rails



65
66
67
# File 'lib/mutineer/config.rb', line 65

def rails
  @rails
end

#require_paths ⇒ Object

Returns the value of attribute require_paths

Returns:

  • (Object) —

    the current value of require_paths



65
66
67
# File 'lib/mutineer/config.rb', line 65

def require_paths
  @require_paths
end

#since ⇒ Object

Returns the value of attribute since

Returns:

  • (Object) —

    the current value of since



65
66
67
# File 'lib/mutineer/config.rb', line 65

def since
  @since
end

#sources ⇒ Object

Returns the value of attribute sources

Returns:

  • (Object) —

    the current value of sources



65
66
67
# File 'lib/mutineer/config.rb', line 65

def sources
  @sources
end

#strategy ⇒ Object

Returns the value of attribute strategy

Returns:

  • (Object) —

    the current value of strategy



65
66
67
# File 'lib/mutineer/config.rb', line 65

def strategy
  @strategy
end

#test_command ⇒ Object

Returns the value of attribute test_command

Returns:

  • (Object) —

    the current value of test_command



65
66
67
# File 'lib/mutineer/config.rb', line 65

def test_command
  @test_command
end

#tests ⇒ Object

Returns the value of attribute tests

Returns:

  • (Object) —

    the current value of tests



65
66
67
# File 'lib/mutineer/config.rb', line 65

def tests
  @tests
end

#threshold ⇒ Object

Returns the value of attribute threshold

Returns:

  • (Object) —

    the current value of threshold



65
66
67
# File 'lib/mutineer/config.rb', line 65

def threshold
  @threshold
end

#verbose ⇒ Object

Returns the value of attribute verbose

Returns:

  • (Object) —

    the current value of verbose



65
66
67
# File 'lib/mutineer/config.rb', line 65

def verbose
  @verbose
end

Class Method Details

.detect_framework(tests) ⇒ String

Pick rspec when a MAJORITY of the given test files end with _spec.rb; otherwise minitest. Empty/ambiguous -> minitest (the safe default).

Parameters:

  • tests (Array<String>) —

    test file paths.

Returns:

  • (String) —

    "rspec" or "minitest".



207
208
209
210
211
# File 'lib/mutineer/config.rb', line 207

def self.detect_framework(tests)
  tests = Array(tests)
  specs = tests.count { |t| t.to_s.end_with?("_spec.rb") }
  specs > tests.length / 2.0 ? "rspec" : "minitest"
end

.field_for(known_key) ⇒ Symbol

Maps a config key to its Struct field.

Parameters:

  • known_key (String) —

    config key.

Returns:

  • (Symbol) —

    struct field name.



217
218
219
# File 'lib/mutineer/config.rb', line 217

def self.field_for(known_key)
  known_key == "require" ? :require_paths : known_key.to_sym
end

.filter_operators(names, file_name) ⇒ Array<String>

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Drop (with a warning) operator names the registry does not know. Referenced lazily so config.rb carries no load-order dependency on the registry; by the time a config is parsed at runtime, it is loaded.

Parameters:

  • names (Array<String>) —

    operator names.

  • file_name (String) —

    config file name for warnings.

Returns:

  • (Array<String>) —

    known operator names.



310
311
312
313
314
315
316
317
318
319
# File 'lib/mutineer/config.rb', line 310

def self.filter_operators(names, file_name)
  known = MutatorRegistry::ALL.keys
  names.select do |n|
    next true if known.include?(n)

    warn "mutineer: unknown operator #{n.inspect} in #{file_name} " \
         "(known: #{known.join(', ')}); ignored"
    false
  end
end

.find_file(start = Dir.pwd, home = File.expand_path("~")) ⇒ Object

Walk from start toward home, returning the first .mutineer.yml path found or nil. Checks home itself, then stops; if start is above home (e.g. /tmp), the walk continues to the filesystem root. Pure discovery; reads no file content.



121
122
123
124
125
126
127
128
129
130
131
132
133
134
# File 'lib/mutineer/config.rb', line 121

def self.find_file(start = Dir.pwd, home = File.expand_path("~"))
  dir = File.expand_path(start)
  loop do
    candidate = File.join(dir, CONFIG_FILE)
    return candidate if File.file?(candidate)
    break if dir == home

    parent = File.dirname(dir)
    break if parent == dir # filesystem root

    dir = parent
  end
  nil
end

.finite_float(value) ⇒ Float?

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Reads a finite Float from a number or a plain decimal string. Rejects booleans, NaN and Infinity: Float(true) is an error, but a YAML .nan is a Float. A string must be digits with an optional fraction, the same digits-only rule as jobs: Float() alone would also read 0x10, 1_0, +2 and 1e2.

Parameters:

  • value (Object) —

    raw value.

Returns:

  • (Float, nil) —

    the number, or nil when it is not a finite number.



294
295
296
297
298
299
300
# File 'lib/mutineer/config.rb', line 294

def self.finite_float(value)
  f = case value
      when Integer, Float then value.to_f
      when String then Float(value) if value.match?(/\A\d+(\.\d+)?\z/)
      end
  f if f&.finite?
end

.from_file(path) ⇒ Object

Parse a .mutineer.yml into a symbol-keyed hash of recognized keys. Unknown keys / unknown operator names emit a one-line stderr warning and are ignored. A YAML syntax error raises ConfigError: never a silent fallback to defaults, and never an exit from the lib layer.



140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/mutineer/config.rb', line 140

def self.from_file(path)
  raw = YAML.safe_load(File.read(path)) || {}
  name = File.basename(path)
  unless raw.is_a?(Hash)
    warn "mutineer: #{name} ignored: expected a YAML mapping of keys to values"
    return {}
  end

  out = {}
  raw.each do |key, value|
    ks = key.to_s
    unless KNOWN_KEYS.include?(ks)
      warn "mutineer: unknown config key #{ks.inspect} in #{name} " \
           "(known: #{KNOWN_KEYS.join(', ')}); ignored"
      next
    end
    field = field_for(ks)
    parsed = parse(field, value, file: name)
    parsed = filter_operators(parsed, name) if field == :operators
    out[field] = parsed
  end
  out
rescue Psych::SyntaxError => e
  raise ConfigError, "#{File.basename(path)} parse error: #{e.message}"
end

.parse(field, value, file: nil) ⇒ Object

Parses one raw value into the typed value for field. A value that does not fit its type raises ConfigError naming where it came from, so the CLI and .mutineer.yml report the same mistake in the same way. nil and false are valid results for some fields (since: false means "no scoping").

Parameters:

  • field (Symbol) —

    Config field name (a row of the option schema).

  • value (Object) —

    raw CLI string or YAML value.

  • file (String, nil) (defaults to: nil) —

    config file name when the value came from it; nil when it came from the command line.

Returns:

  • (Object) —

    the typed value.

Raises:



232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
# File 'lib/mutineer/config.rb', line 232

def self.parse(field, value, file: nil)
  opt = CONFIG_OPTIONS.find { |o| o.field == field } or raise ArgumentError, "unknown option #{field.inspect}"
  origin = file ? "#{file}: #{opt.yaml_key}" : opt.flag
  got = "(got: #{value.inspect})"
  case opt.type
  when :positive_int
    n = value.is_a?(Integer) ? value : (value.to_i if value.is_a?(String) && value.match?(/\A\d+\z/))
    raise ConfigError, "#{origin} must be a positive integer, digits only #{got}" if n.nil? || n < 1

    n
  when :percent
    f = finite_float(value)
    raise ConfigError, "#{origin} must be a number between 0 and 100 #{got}" unless f && (0.0..100.0).cover?(f)

    f
  when :nonneg_float
    f = finite_float(value)
    raise ConfigError, "#{origin} must be a finite number, 0 or greater #{got}" unless f && f >= 0.0

    f
  when :bool
    return value if [true, false].include?(value)
    return value == "true" if %w[true false].include?(value)

    raise ConfigError, "#{origin} must be true or false #{got}"
  when :enum
    name = opt.aliases&.fetch(value, nil) || value
    return name if opt.values.include?(name)

    prefix = file ? "#{file}: " : ""
    raise ConfigError, "#{prefix}unknown #{field} #{value.to_s.inspect}. Expected: #{opt.values.join(', ')}"
  when :string_list then Array(value).map(&:to_s)
  when :string
    # A key written with no value (`baseline:`) parses as nil. Keeping nil
    # would switch the feature off without a word; main failed here, so the
    # typo stays loud. An absent key never reaches parse, so it stays unset.
    # `only: false` must not become the subject name "false" either: it
    # matches nothing, so the run has no mutants and still exits 0.
    raise ConfigError, "#{origin} must be a string #{got}" if [nil, true, false].include?(value)

    value.to_s
  when :since
    # `false` is the one way to say "no scoping" in the file. It becomes nil
    # so every consumer's nil-check (runner scoping, the report's scoped
    # marker) agrees; a false left raw would skip scoping but still mark
    # the report scoped. A blank value is an error, not "no scoping": a
    # `since: "$REF"` whose variable is unset must not turn scoping off.
    return nil if value == false
    raise ConfigError, "#{origin} must be a git ref, not blank #{got}" if value.to_s.strip.empty?

    value.to_s
  end
end

.resolve(cli_opts, file_hash) ⇒ Mutineer::Config

Merges the two user layers, CLI over file, then derives what neither wrote. A layer is a Hash of only the keys the user set, so precedence is merge and "did the user write this" is whether the key exists. Nothing tracks provenance by hand, and false/nil are ordinary values.

Parameters:

  • cli_opts (Hash{Symbol => Object}) —

    parsed command-line fields.

  • file_hash (Hash{Symbol => Object}) —

    parsed .mutineer.yml fields.

Returns:



174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
# File 'lib/mutineer/config.rb', line 174

def self.resolve(cli_opts, file_hash)
  user = file_hash.merge(cli_opts)
  config = new(**user, explicit: user.keys)

  # --rails sugar: boot config/environment. Prefer redefine only for the
  # in-process path (daemon is whole-file reload only). In-process --rails
  # shares one test database, so force serial unless --daemon.
  if config.rails
    config.boot ||= "config/environment"
    unless config.daemon || config.explicit?(:strategy)
      config.strategy = "redefine"
    end
    unless config.daemon
      if config.jobs.to_i > 1
        warn "[mutineer] --rails without --daemon runs serially (shared test DB); " \
             "forcing --jobs 1. Use --daemon for safe --jobs N."
      end
      config.jobs = 1
    end
  end

  # Auto-detect the framework only when the user wrote none: a value from
  # either layer is already on config.framework and always wins. Default
  # minitest unless the test files clearly look RSpec.
  config.framework ||= detect_framework(config.tests)
  config
end

Instance Method Details

#explicit?(key) ⇒ Boolean

True when the user wrote key, on the command line or in the config file, whatever the value (false and nil count). The answer comes from which keys the layers held, so a new option needs no bookkeeping to be covered.

Parameters:

  • key (Symbol) —

    Config field name.

Returns:

  • (Boolean)


113
114
115
# File 'lib/mutineer/config.rb', line 113

def explicit?(key)
  @explicit.include?(key)
end