mutineer 1.3.0 → 1.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e7b8a623ac7db818d33df11a7ca32e26a67db7f156061027f018debeb17ced2f
4
- data.tar.gz: 5957fb6e57cb5a5149822818f773f3e8d3da271bce7bb0cfc4d0a8640764aa40
3
+ metadata.gz: e18649829a9226ea7a5f654ef38602eabff4005ebf8a516fca4402460a7aac09
4
+ data.tar.gz: 9e3c166e5e2e513278aaf210f271ed0f070cd335d0de588318cb8aa71f6b6a06
5
5
  SHA512:
6
- metadata.gz: 1de2a131774b7b587abbc8ca91a57da333688cb2fdb937defa002d661426137decf8b86fbde1a2d176a5525f8cb7d9a8b6a2bebc353960729704b3e8d2473213
7
- data.tar.gz: '094d062c4c8458fda7061a575681f13022131b2b9078e5731bcbbfa300ac67b86e53691e4d9e104cc06e50832b66bd3dd32433c1bdf420e74b75a7b7748b4916'
6
+ metadata.gz: 2e10e1214d0b55a4c93c7fa13d4c3b39503d4e2b1e367a995e2d813e98bd55ce2c2774b429bd690f2ae55019a39c7ee539e3ef60ea51bf25e2bc61c602ab72b6
7
+ data.tar.gz: 146768f9f599d41af9e6549f65ad7658cdd55e3d57a6f35fa6d094cfef6be73b036bb3dc8ebe8616d03cf358cdaf78e5d8a3b40a996c7a4d18a06898b9454824
data/CHANGELOG.md CHANGED
@@ -6,6 +6,72 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.4.0] - 2026-09-30
10
+
11
+ ### Added
12
+ - **Condition-forcing operators** (Tier-2, opt-in via `--operators`):
13
+ `condition_true` and `condition_false` replace an `if`/`elsif`/`unless`,
14
+ ternary, modifier or `case`/`in` guard condition with `(true)` or `(false)`, so
15
+ its branch always runs or never runs. A surviving mutant means no selected
16
+ test detected the forced condition. A literal condition, also in
17
+ parentheses, makes no mutant: forcing it changes nothing or repeats the
18
+ `boolean_literal` flip. A condition that holds a heredoc makes no mutant.
19
+ A condition that assigns a variable keeps its code, and only its value
20
+ is forced (`(m = x; true)`), so later code still sees the variable.
21
+ The never-runs side of an else-less conditional can
22
+ be the same program as the `nil` that `statement_removal` or `return_nil`
23
+ puts in its place; it is still made, so these mutants and their ids do not
24
+ depend on which other operators run.
25
+ - **Operator-assignment operator** (Tier-2, opt-in via `--operators`):
26
+ `operator_assignment` swaps the operator of a compound assignment:
27
+ `+=` <-> `-=`, `*=` <-> `/=`, and `%=` and `**=` -> `*=`. It covers local,
28
+ instance, class and global variables, constants, calls (`a.b += 1`) and
29
+ index calls (`a[i] += 1`). The `arithmetic` operator never sees these
30
+ forms, because Prism does not parse them as calls. The operator does not
31
+ change `||=`, `&&=`, or the bitwise and shift forms (`|=`, `<<=`).
32
+
33
+ ### Fixed
34
+ - **A typed `--rails`, `--verbose` or `--debug` beats `rails: false` and
35
+ `verbose: false` in `.mutineer.yml`** (#103). The flag was dropped, so the
36
+ run went on without Rails boot or verbose output. The config layers now keep
37
+ only the keys the user wrote, and "did the user write this" comes from those
38
+ keys, not from a hand-kept list.
39
+ - **`framework: rspec` in `.mutineer.yml` survives test pairing** (#103).
40
+ Pairing re-detected the framework from the test file names, so an RSpec
41
+ suite in `test/calc_test.rb` ran under Minitest.
42
+ - **A bad number is an error, not a rounded value** (#105). `--jobs 1.9` and
43
+ `--baseline-epsilon abc` no longer become `1` and `0.0`. One option schema
44
+ parses each value once, where it enters, for both the command line and
45
+ `.mutineer.yml`.
46
+ - **`--since` keeps a source file that git does not track yet** (#156). Before,
47
+ such a file counted as unchanged. The run scored no mutants, and a positive
48
+ `--threshold` still exited 0. The file is now new in full, so all its
49
+ mutants run.
50
+
51
+ ### Changed
52
+ - **These now exit 2 with a message that names the option**: `jobs: 1.9` or
53
+ `jobs: true` in `.mutineer.yml` (before: `1`, or a crash); a bad, negative
54
+ or non-finite `--baseline-epsilon` (before: `0.0`); a boolean key in
55
+ `.mutineer.yml` that is not `true` or `false`, such as the string `"yes"`
56
+ (before: `false`).
57
+ - **`--jobs` takes plain digits only.** `0x2`, `+2`, `" 2"` and `1_0` exit 2.
58
+ Before, `Integer()` read them as 2, 2, 2 and 10. `--threshold` and
59
+ `--baseline-epsilon` take a plain decimal, such as `2` or `2.5`, from a
60
+ string. `0x10`, `+2`, `1_0` and `1e2` exit 2. Before, `Float()` read them
61
+ as 16.0, 2.0, 10.0 and 100.0.
62
+ - **A string option that gets `true`, `false` or no value exits 2**, such as
63
+ `only: false` or `baseline:` in `.mutineer.yml`. A blank `since` also exits 2,
64
+ in the file and on the command line, so an unset shell variable does not
65
+ turn scoping off. `since: false` still means no scoping.
66
+ - **Config file errors start with the file and key**, for example
67
+ `.mutineer.yml: threshold must be a number between 0 and 100`, where they
68
+ started with `--threshold`.
69
+ - **The docs site is built in CI** — a Pages workflow runs `rake site:build`
70
+ and deploys the result, so the YARD HTML under `/api/`, `llms-full.txt`,
71
+ `json-schema.html` and `sitemap.xml` are no longer committed. CI checks
72
+ that the site builds, in place of `rake yard:pages:check`. The site keeps
73
+ its URLs (#153).
74
+
9
75
  ## [1.3.0] - 2026-09-29
10
76
 
11
77
  ### Changed
@@ -611,6 +677,7 @@ Rails hardening + CI batch (issues #8–#13), all verified Rails-free.
611
677
  - `.mutineer.yml` configuration (CLI > config > default precedence).
612
678
  - Byte-correct source handling for multibyte (UTF-8) sources.
613
679
 
680
+ [1.4.0]: https://github.com/davidteren/mutineer/releases/tag/v1.4.0
614
681
  [1.3.0]: https://github.com/davidteren/mutineer/releases/tag/v1.3.0
615
682
  [1.2.0]: https://github.com/davidteren/mutineer/releases/tag/v1.2.0
616
683
  [1.1.0]: https://github.com/davidteren/mutineer/releases/tag/v1.1.0
data/README.md CHANGED
@@ -85,7 +85,8 @@ Run `mutineer --list-operators` to see them. Default (Tier 1): `arithmetic`,
85
85
  Available but off by default (Tier 2, enable via `--operators`): `return_nil`,
86
86
  `literal_mutation`, `condition_negation`, `string_literal`, `regex`,
87
87
  `collection_method`, `safe_navigation`, `range`, `negation_removal`, `chain_link`,
88
- `operand_removal`, `array_literal`.
88
+ `operand_removal`, `array_literal`, `condition_true`,
89
+ `condition_false`, `operator_assignment`.
89
90
 
90
91
  ## Rails apps
91
92
 
@@ -56,6 +56,10 @@ module Mutineer
56
56
  end
57
57
 
58
58
  # Returns the stdout of `git -C <root> diff --unified=0 <ref> -- <file>`.
59
+ # A file that is not in the index has no usable diff, so `new_file_diff`
60
+ # supplies one that marks every line new. That includes a file the ref still
61
+ # has, deleted and then written again: git prints only a deletion hunk for it.
62
+ # Paths are literal, so a name such as `file[1].rb` is not read as a glob.
59
63
  #
60
64
  # A failure is warned, never silent: an empty result means "no changed
61
65
  # lines", which under `--since` removes every mutant for the file — a green
@@ -67,9 +71,9 @@ module Mutineer
67
71
  # @return [String] diff text, or `""` on failure (after a stderr warning).
68
72
  def git_diff(ref, abs_file, project_root)
69
73
  out, _err, status = Open3.capture3(
70
- "git", "-C", project_root, "diff", "--unified=0", ref, "--", abs_file
74
+ "git", "--literal-pathspecs", "-C", project_root, "diff", "--unified=0", ref, "--", abs_file
71
75
  )
72
- return out if status.success?
76
+ return(indexed?(abs_file, project_root) ? out : new_file_diff(abs_file)) if status.success?
73
77
 
74
78
  warn "[mutineer] git diff failed for #{abs_file}; its lines will not be mutated (--since)"
75
79
  ""
@@ -77,5 +81,33 @@ module Mutineer
77
81
  warn "[mutineer] git diff failed for #{abs_file} (#{e.class}); its lines will not be mutated (--since)"
78
82
  ""
79
83
  end
84
+
85
+ # Tells whether the index holds the file. Only then is git's own diff the
86
+ # truth; the diff text cannot tell, because a mode-only diff must stay empty.
87
+ #
88
+ # @param abs_file [String] absolute path of the file being diffed.
89
+ # @param project_root [String] repository root for `git -C`.
90
+ # @return [Boolean] true when the index holds the file.
91
+ def indexed?(abs_file, project_root)
92
+ _out, _err, known = Open3.capture3(
93
+ "git", "--literal-pathspecs", "-C", project_root, "ls-files", "--error-unmatch", "--", abs_file
94
+ )
95
+ known.success?
96
+ end
97
+
98
+ # Returns a diff that marks a file as entirely new. It returns `""` when
99
+ # the file is empty or cannot be read (after a warning). A file outside the
100
+ # index has every line new; read as "unchanged", `--since` would score
101
+ # nothing and a positive threshold would still exit 0.
102
+ #
103
+ # @param abs_file [String] absolute path of the file being diffed.
104
+ # @return [String] a one-hunk diff header, or `""`.
105
+ def new_file_diff(abs_file)
106
+ count = File.foreach(abs_file).count
107
+ count.zero? ? "" : "@@ -0,0 +1,#{count} @@\n"
108
+ rescue SystemCallError => e
109
+ warn "[mutineer] cannot read #{abs_file} (#{e.class}); its lines will not be mutated (--since)"
110
+ ""
111
+ end
80
112
  end
81
113
  end
data/lib/mutineer/cli.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "optparse"
4
- require "set"
5
4
  require "open3"
6
5
  require_relative "version"
7
6
  require_relative "config"
@@ -67,19 +66,14 @@ module Mutineer
67
66
  --help Print this help and exit
68
67
  USAGE
69
68
 
70
- # Field symbols whose config-file value is suppressed when the flag is typed.
71
- PRECEDENCE_FLAGS = %i[operators jobs threshold only].freeze
72
-
73
- # Deprecated internal strategy names, mapped to their canonical equivalents.
74
- STRATEGY_ALIASES = { "7a" => "reload", "7b" => "redefine" }.freeze
75
-
76
69
  # Parses arguments, executes the command, and exits.
77
70
  #
78
71
  # @param argv [Array<String>] raw command-line arguments.
79
72
  # @return [void]
80
73
  def self.start(argv)
81
- opts = {} # symbol => value, the CLI-provided Config fields
82
- explicit = Set.new # precedence keys the user typed
74
+ # The CLI layer holds only the fields the user typed, so Config.resolve
75
+ # can tell a typed `false`/`nil` from an absent flag by whether the key exists.
76
+ opts = {}
83
77
  show_operators = false
84
78
 
85
79
  parser = OptionParser.new do |o|
@@ -94,47 +88,40 @@ module Mutineer
94
88
  end
95
89
  o.on("--list-operators") { show_operators = true }
96
90
  o.on("--dry-run") { opts[:dry_run] = true }
97
- o.on("--fail-fast") { opts[:fail_fast] = true; explicit << :fail_fast }
98
- o.on("--only NAME") { |v| opts[:only] = v; explicit << :only }
99
- o.on("--since REF") { |v| opts[:since] = v; explicit << :since }
100
- # A typed "no" must beat a .mutineer.yml `since:` key (CLI-over-config
101
- # precedence): marking :since explicit with a nil value blocks the fill.
102
- o.on("--no-since") { opts[:since] = nil; explicit << :since }
91
+ o.on("--fail-fast") { opts[:fail_fast] = true }
92
+ o.on("--only NAME") { |v| opts[:only] = v }
93
+ o.on("--since REF") { |v| opts[:since] = Config.parse(:since, v) }
94
+ # A typed "no" must beat a .mutineer.yml `since:` key: the key is present
95
+ # with a nil value, and nil is a value.
96
+ o.on("--no-since") { opts[:since] = nil }
103
97
  o.on("--test FILE") { |v| (opts[:tests] ||= []) << v }
104
- o.on("--operators LIST") { |v| opts[:operators] = v.split(",").map(&:strip); explicit << :operators }
105
- o.on("--threshold FLOAT") do |v|
106
- f = Float(v, exception: false)
107
- if f.nil?
108
- warn "mutineer: --threshold requires a number between 0 and 100 (got: #{v.inspect})"
109
- exit 2
110
- end
111
- opts[:threshold] = f
112
- explicit << :threshold
98
+ o.on("--operators LIST") do |v|
99
+ opts[:operators] = Config.parse(:operators, v.split(",").map(&:strip))
113
100
  end
114
- o.on("--jobs N") { |v| opts[:jobs] = v; explicit << :jobs }
115
- o.on("--strategy STRAT") { |v| opts[:strategy] = v; explicit << :strategy }
116
- o.on("--framework NAME") { |v| opts[:framework] = v; explicit << :framework }
117
- o.on("--boot FILE") { |v| opts[:boot] = v; explicit << :boot }
101
+ o.on("--threshold FLOAT") { |v| opts[:threshold] = Config.parse(:threshold, v) }
102
+ o.on("--jobs N") { |v| opts[:jobs] = Config.parse(:jobs, v) }
103
+ o.on("--strategy STRAT") { |v| opts[:strategy] = Config.parse(:strategy, v) }
104
+ o.on("--framework NAME") { |v| opts[:framework] = Config.parse(:framework, v) }
105
+ o.on("--boot FILE") { |v| opts[:boot] = v }
118
106
  o.on("--rails") { opts[:rails] = true }
119
107
  o.on("--verbose") { opts[:verbose] = true }
120
108
  o.on("--debug") { opts[:verbose] = true } # alias of --verbose
121
- o.on("--format FORMAT") { |v| opts[:format] = v }
109
+ o.on("--format FORMAT") { |v| opts[:format] = Config.parse(:format, v) }
122
110
  o.on("--output FILE") { |v| opts[:output] = v }
123
- # --baseline is also a .mutineer.yml key, so mark it explicit when typed
124
- # (CLI wins over the file). --baseline-epsilon is CLI-only.
125
- o.on("--baseline FILE") { |v| opts[:baseline] = v; explicit << :baseline }
126
- o.on("--baseline-epsilon FLOAT") { |v| opts[:baseline_epsilon] = v.to_f }
111
+ # --baseline-epsilon is CLI-only.
112
+ o.on("--baseline FILE") { |v| opts[:baseline] = v }
113
+ o.on("--baseline-epsilon FLOAT") { |v| opts[:baseline_epsilon] = Config.parse(:baseline_epsilon, v) }
127
114
  # Run the target suite as a subprocess in the app's OWN runtime so
128
115
  # mutineer (Ruby >= 3.4) can mutation-test apps pinned to an older Ruby.
129
- o.on("--test-command CMD") { |v| opts[:test_command] = v; explicit << :test_command }
116
+ o.on("--test-command CMD") { |v| opts[:test_command] = v }
130
117
  # Boot the app ONCE in a persistent daemon and fork per mutant, with
131
118
  # per-worker DB isolation so --jobs N is safe under Rails.
132
- o.on("--daemon") { opts[:daemon] = true; explicit << :daemon }
119
+ o.on("--daemon") { opts[:daemon] = true }
133
120
  end
134
121
 
135
122
  begin
136
123
  parser.parse!(argv)
137
- rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
124
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument, Mutineer::ConfigError => e
138
125
  warn "mutineer: #{e.message}"
139
126
  exit 2
140
127
  end
@@ -152,7 +139,7 @@ module Mutineer
152
139
  begin
153
140
  file_path = Config.find_file
154
141
  file_hash = file_path ? Config.from_file(file_path) : {}
155
- config = Config.resolve(opts, file_hash, explicit)
142
+ config = Config.resolve(opts, file_hash)
156
143
  rescue Mutineer::ConfigError => e
157
144
  # The lib layer raises instead of killing the host; the CLI maps a
158
145
  # config (usage) error to exit 2.
@@ -166,7 +153,7 @@ module Mutineer
166
153
  # A directory source expands to its **/*.rb files; literal files pass
167
154
  # through. Test inference (when --test is omitted) happens in validate!.
168
155
  config.sources = Pairing.expand_sources(argv[1..], project_root: config.project_root)
169
- run(config, explicit)
156
+ run(config)
170
157
  else
171
158
  warn "mutineer: unknown command '#{argv.first}'"
172
159
  exit 2
@@ -187,14 +174,13 @@ module Mutineer
187
174
  # Runs the requested command after validation.
188
175
  #
189
176
  # @param config [Mutineer::Config] run configuration.
190
- # @param explicit [Set<Symbol>] explicit CLI fields.
191
177
  # @return [void]
192
- def self.run(config, explicit = Set.new)
178
+ def self.run(config)
193
179
  if config.sources.empty?
194
180
  warn "mutineer: run requires at least one source file"
195
181
  exit 2
196
182
  end
197
- validate!(config, explicit)
183
+ validate!(config)
198
184
 
199
185
  config.dry_run ? dry_run(config) : execute(config)
200
186
  rescue ArgumentError => e
@@ -236,39 +222,8 @@ module Mutineer
236
222
  #
237
223
  # @api private
238
224
  # @param config [Mutineer::Config] run configuration.
239
- # @param explicit [Set<Symbol>] explicit CLI fields.
240
225
  # @return [void]
241
- def self.validate!(config, explicit = Set.new)
242
- unless (0.0..100.0).cover?(config.threshold)
243
- warn "mutineer: --threshold must be between 0 and 100"
244
- exit 2
245
- end
246
-
247
- jobs = Integer(config.jobs.to_s, exception: false)
248
- if jobs.nil? || jobs < 1
249
- warn "mutineer: --jobs requires a positive integer (got: #{config.jobs})"
250
- exit 2
251
- end
252
- config.jobs = jobs
253
-
254
- unless %w[human json html].include?(config.format)
255
- warn %(mutineer: unknown format "#{config.format}". Expected: human, json, html)
256
- exit 2
257
- end
258
-
259
- # Canonical strategies are reload|redefine; 7a/7b are accepted as deprecated
260
- # aliases. Normalize to canonical so the rest of the pipeline sees one name.
261
- config.strategy = STRATEGY_ALIASES.fetch(config.strategy, config.strategy)
262
- unless %w[reload redefine].include?(config.strategy)
263
- warn %(mutineer: unknown strategy "#{config.strategy}". Expected: reload, redefine)
264
- exit 2
265
- end
266
-
267
- unless %w[minitest rspec].include?(config.framework)
268
- warn %(mutineer: unknown framework "#{config.framework}". Expected: minitest, rspec)
269
- exit 2
270
- end
271
-
226
+ def self.validate!(config)
272
227
  validate_test_command!(config) if config.test_command
273
228
 
274
229
  validate_since!(config) if config.since
@@ -277,7 +232,7 @@ module Mutineer
277
232
 
278
233
  # When --test is omitted, infer each source's test by convention. Autopair
279
234
  # also re-detects framework from inferred tests when --framework was not set.
280
- autopair!(config, explicit) unless config.dry_run
235
+ autopair!(config) unless config.dry_run
281
236
 
282
237
  # Daemon validation runs AFTER autopair so auto-inferred *_spec.rb tests
283
238
  # cannot bypass the RSpec rejection (framework would still be minitest if we
@@ -408,9 +363,8 @@ module Mutineer
408
363
  #
409
364
  # @api private
410
365
  # @param config [Mutineer::Config] run configuration.
411
- # @param explicit [Set<Symbol>] explicit CLI fields.
412
366
  # @return [void]
413
- def self.autopair!(config, explicit)
367
+ def self.autopair!(config)
414
368
  return unless config.tests.empty?
415
369
 
416
370
  paired = config.sources.filter_map do |s|
@@ -422,7 +376,7 @@ module Mutineer
422
376
  end
423
377
  config.sources = paired.map(&:first)
424
378
  config.tests = paired.map(&:last).uniq
425
- config.framework = Config.detect_framework(config.tests) unless explicit.include?(:framework)
379
+ config.framework = Config.detect_framework(config.tests) unless config.explicit?(:framework)
426
380
 
427
381
  return unless config.sources.empty?
428
382
  return if config.boot # let the --boot/--rails-requires-test check report it
@@ -8,13 +8,55 @@ module Mutineer
8
8
  # never kill the host process. The CLI rescues this and maps it to exit 2.
9
9
  class ConfigError < StandardError; end
10
10
 
11
+ # One row of the option schema: how a Config field is parsed and where the
12
+ # user can set it. `yaml_key` is nil for CLI-only options; `flag` is nil for
13
+ # YAML-only ones. `values`/`aliases` apply to the :enum type.
14
+ #
15
+ # @api private
16
+ ConfigOption = Struct.new(:field, :type, :yaml_key, :flag, :values, :aliases, keyword_init: true)
17
+
18
+ # Deprecated internal strategy names, mapped to their canonical equivalents.
19
+ STRATEGY_ALIASES = { "7a" => "reload", "7b" => "redefine" }.freeze
20
+
21
+ # The option schema. Config.parse is the only place that turns a raw value
22
+ # (a CLI string or a YAML scalar) into a typed one, so a value is checked once,
23
+ # at the boundary, whichever layer wrote it. Row order fixes the order of
24
+ # KNOWN_KEYS in warnings.
25
+ #
26
+ # @api private
27
+ CONFIG_OPTIONS = [
28
+ ConfigOption.new(field: :operators, type: :string_list, yaml_key: "operators", flag: "--operators"),
29
+ ConfigOption.new(field: :jobs, type: :positive_int, yaml_key: "jobs", flag: "--jobs"),
30
+ ConfigOption.new(field: :threshold, type: :percent, yaml_key: "threshold", flag: "--threshold"),
31
+ ConfigOption.new(field: :only, type: :string, yaml_key: "only", flag: "--only"),
32
+ ConfigOption.new(field: :require_paths, type: :string_list, yaml_key: "require"),
33
+ ConfigOption.new(field: :boot, type: :string, yaml_key: "boot", flag: "--boot"),
34
+ ConfigOption.new(field: :rails, type: :bool, yaml_key: "rails", flag: "--rails"),
35
+ ConfigOption.new(field: :since, type: :since, yaml_key: "since", flag: "--since"),
36
+ ConfigOption.new(field: :framework, type: :enum, yaml_key: "framework", flag: "--framework",
37
+ values: %w[minitest rspec]),
38
+ ConfigOption.new(field: :verbose, type: :bool, yaml_key: "verbose", flag: "--verbose"),
39
+ ConfigOption.new(field: :ignore, type: :string_list, yaml_key: "ignore"),
40
+ ConfigOption.new(field: :baseline, type: :string, yaml_key: "baseline", flag: "--baseline"),
41
+ ConfigOption.new(field: :fail_fast, type: :bool, yaml_key: "fail_fast", flag: "--fail-fast"),
42
+ ConfigOption.new(field: :test_command, type: :string, yaml_key: "test_command", flag: "--test-command"),
43
+ ConfigOption.new(field: :daemon, type: :bool, yaml_key: "daemon", flag: "--daemon"),
44
+ ConfigOption.new(field: :format, type: :enum, flag: "--format", values: %w[human json html]),
45
+ ConfigOption.new(field: :strategy, type: :enum, flag: "--strategy", values: %w[reload redefine],
46
+ aliases: STRATEGY_ALIASES),
47
+ ConfigOption.new(field: :output, type: :string, flag: "--output"),
48
+ ConfigOption.new(field: :baseline_epsilon, type: :nonneg_float, flag: "--baseline-epsilon"),
49
+ ConfigOption.new(field: :dry_run, type: :bool, flag: "--dry-run")
50
+ ].freeze
51
+
11
52
  # Plain run configuration, populated by the CLI (or directly by the
12
53
  # integration test). `operators` nil means "all default operators";
13
54
  # `threshold` 0.0 means the CI gate is off (spec §10).
14
55
  #
15
56
  # Also holds: jobs (parallel workers), format (human|json), output (report
16
57
  # file), strategy (reload|redefine), require_paths (extra files to load).
17
- # Config loading and the CLI > file > default precedence merge live here.
58
+ # Config loading and the CLI > file > default precedence merge live here; each
59
+ # layer holds only the keys the user wrote, and Config#explicit? reports them.
18
60
  #
19
61
  # Boot mode adds: boot (a file to require ONCE in the parent so the app env,
20
62
  # e.g. Rails, is booted before forking; sources are then NOT manually required)
@@ -33,11 +75,15 @@ module Mutineer
33
75
  ) do
34
76
  # Config file name.
35
77
  CONFIG_FILE = ".mutineer.yml"
36
- # Keys accepted in .mutineer.yml. `require` maps to the :require_paths field.
37
- KNOWN_KEYS = %w[operators jobs threshold only require boot rails since framework verbose ignore baseline fail_fast test_command daemon].freeze
78
+ # Keys accepted in .mutineer.yml, derived from the schema. `require` maps to the
79
+ # :require_paths field.
80
+ KNOWN_KEYS = CONFIG_OPTIONS.filter_map(&:yaml_key).freeze
38
81
 
39
- def initialize(**kwargs)
40
- super
82
+ # @param explicit [Array<Symbol>] fields the user wrote (CLI or file). Derived
83
+ # values fill only the others; a programmatic Config.new writes none.
84
+ def initialize(explicit: [], **kwargs)
85
+ super(**kwargs)
86
+ @explicit = explicit.to_a.dup.freeze
41
87
  self.sources ||= []
42
88
  self.tests ||= []
43
89
  self.threshold ||= 0.0
@@ -57,6 +103,17 @@ module Mutineer
57
103
  self.daemon = false if daemon.nil?
58
104
  end
59
105
 
106
+ # True when the user wrote `key`, on the command line or in the config
107
+ # file, whatever the value (`false` and `nil` count). The answer comes from
108
+ # which keys the layers held, so a new option needs no bookkeeping to be
109
+ # covered.
110
+ #
111
+ # @param key [Symbol] Config field name.
112
+ # @return [Boolean]
113
+ def explicit?(key)
114
+ @explicit.include?(key)
115
+ end
116
+
60
117
  # Walk from `start` toward `home`, returning the first .mutineer.yml path found
61
118
  # or nil. Checks `home` itself, then stops; if `start` is above `home`
62
119
  # (e.g. /tmp), the walk continues to the filesystem root. Pure discovery;
@@ -96,28 +153,34 @@ module Mutineer
96
153
  "(known: #{KNOWN_KEYS.join(', ')}); ignored"
97
154
  next
98
155
  end
99
- out[field_for(ks)] = coerce(ks, value, name)
156
+ field = field_for(ks)
157
+ parsed = parse(field, value, file: name)
158
+ parsed = filter_operators(parsed, name) if field == :operators
159
+ out[field] = parsed
100
160
  end
101
161
  out
102
162
  rescue Psych::SyntaxError => e
103
163
  raise ConfigError, "#{File.basename(path)} parse error: #{e.message}"
104
164
  end
105
165
 
106
- # Apply precedence: start from the CLI-provided values, then fill in a
107
- # config-file value only for keys the user did NOT type on the command line.
108
- # `explicit` is a Set of field symbols the CLI saw with a value; a Set (not
109
- # nil-sentinels) is used because some valid values are zero/false.
110
- def self.resolve(cli_opts, file_hash, explicit)
111
- merged = cli_opts.dup
112
- file_hash.each { |k, v| merged[k] = v unless explicit.include?(k) }
113
- config = new(**merged)
166
+ # Merges the two user layers, CLI over file, then derives what neither wrote.
167
+ # A layer is a Hash of only the keys the user set, so precedence is `merge`
168
+ # and "did the user write this" is whether the key exists. Nothing tracks
169
+ # provenance by hand, and `false`/`nil` are ordinary values.
170
+ #
171
+ # @param cli_opts [Hash{Symbol => Object}] parsed command-line fields.
172
+ # @param file_hash [Hash{Symbol => Object}] parsed .mutineer.yml fields.
173
+ # @return [Mutineer::Config]
174
+ def self.resolve(cli_opts, file_hash)
175
+ user = file_hash.merge(cli_opts)
176
+ config = new(**user, explicit: user.keys)
114
177
 
115
178
  # --rails sugar: boot config/environment. Prefer redefine only for the
116
179
  # in-process path (daemon is whole-file reload only). In-process --rails
117
180
  # shares one test database, so force serial unless --daemon.
118
181
  if config.rails
119
182
  config.boot ||= "config/environment"
120
- unless config.daemon || explicit.include?(:strategy)
183
+ unless config.daemon || config.explicit?(:strategy)
121
184
  config.strategy = "redefine"
122
185
  end
123
186
  unless config.daemon
@@ -129,9 +192,9 @@ module Mutineer
129
192
  end
130
193
  end
131
194
 
132
- # Auto-detect the framework only when neither CLI nor config file set it
133
- # (explicit value, from either source, is already on config.framework and
134
- # always wins). Default minitest unless the test files clearly look RSpec.
195
+ # Auto-detect the framework only when the user wrote none: a value from
196
+ # either layer is already on config.framework and always wins. Default
197
+ # minitest unless the test files clearly look RSpec.
135
198
  config.framework ||= detect_framework(config.tests)
136
199
  config
137
200
  end
@@ -155,42 +218,87 @@ module Mutineer
155
218
  known_key == "require" ? :require_paths : known_key.to_sym
156
219
  end
157
220
 
158
- # Coerces a config value to its target type.
221
+ # Parses one raw value into the typed value for `field`. A value that does
222
+ # not fit its type raises ConfigError naming where it came from, so the CLI
223
+ # and .mutineer.yml report the same mistake in the same way. `nil` and
224
+ # `false` are valid results for some fields (`since: false` means "no scoping").
159
225
  #
160
- # @param known_key [String] config key.
161
- # @param value [Object] raw YAML value.
162
- # @param file_name [String] config file name for warnings.
163
- # @return [Object] coerced value.
164
- def self.coerce(known_key, value, file_name)
165
- case known_key
166
- when "operators" then filter_operators(Array(value).map(&:to_s), file_name)
167
- when "jobs" then value.to_i
168
- when "threshold"
169
- f = Float(value, exception: false)
170
- if f.nil?
171
- raise ConfigError, "#{file_name}: threshold must be a number between 0 and 100 " \
172
- "(got: #{value.inspect})"
173
- end
226
+ # @param field [Symbol] Config field name (a row of the option schema).
227
+ # @param value [Object] raw CLI string or YAML value.
228
+ # @param file [String, nil] config file name when the value came from it;
229
+ # nil when it came from the command line.
230
+ # @return [Object] the typed value.
231
+ # @raise [Mutineer::ConfigError] when the value does not fit the field's type.
232
+ def self.parse(field, value, file: nil)
233
+ opt = CONFIG_OPTIONS.find { |o| o.field == field } or raise ArgumentError, "unknown option #{field.inspect}"
234
+ origin = file ? "#{file}: #{opt.yaml_key}" : opt.flag
235
+ got = "(got: #{value.inspect})"
236
+ case opt.type
237
+ when :positive_int
238
+ n = value.is_a?(Integer) ? value : (value.to_i if value.is_a?(String) && value.match?(/\A\d+\z/))
239
+ raise ConfigError, "#{origin} must be a positive integer, digits only #{got}" if n.nil? || n < 1
240
+
241
+ n
242
+ when :percent
243
+ f = finite_float(value)
244
+ raise ConfigError, "#{origin} must be a number between 0 and 100 #{got}" unless f && (0.0..100.0).cover?(f)
245
+
246
+ f
247
+ when :nonneg_float
248
+ f = finite_float(value)
249
+ raise ConfigError, "#{origin} must be a finite number, 0 or greater #{got}" unless f && f >= 0.0
250
+
174
251
  f
175
- when "require" then Array(value).map(&:to_s)
176
- when "boot" then value.to_s
177
- when "framework" then value.to_s
178
- when "rails" then value == true || value.to_s == "true"
179
- when "daemon" then value == true || value.to_s == "true"
180
- when "verbose" then value == true || value.to_s == "true"
181
- when "fail_fast" then value == true || value.to_s == "true"
182
- when "ignore" then Array(value).map(&:to_s)
183
- when "baseline" then value.to_s
184
- when "test_command" then value.to_s
185
- when "since"
186
- # false / empty normalize to nil ("no scoping"), so every consumer's
187
- # nil-check (runner scoping, the report's scoped marker) agrees. A
188
- # false left raw would skip scoping but still mark the report scoped.
189
- value == false || value.to_s.empty? ? nil : value.to_s
190
- else value
252
+ when :bool
253
+ return value if [true, false].include?(value)
254
+ return value == "true" if %w[true false].include?(value)
255
+
256
+ raise ConfigError, "#{origin} must be true or false #{got}"
257
+ when :enum
258
+ name = opt.aliases&.fetch(value, nil) || value
259
+ return name if opt.values.include?(name)
260
+
261
+ prefix = file ? "#{file}: " : ""
262
+ raise ConfigError, "#{prefix}unknown #{field} #{value.to_s.inspect}. Expected: #{opt.values.join(', ')}"
263
+ when :string_list then Array(value).map(&:to_s)
264
+ when :string
265
+ # A key written with no value (`baseline:`) parses as nil. Keeping nil
266
+ # would switch the feature off without a word; main failed here, so the
267
+ # typo stays loud. An absent key never reaches parse, so it stays unset.
268
+ # `only: false` must not become the subject name "false" either: it
269
+ # matches nothing, so the run has no mutants and still exits 0.
270
+ raise ConfigError, "#{origin} must be a string #{got}" if [nil, true, false].include?(value)
271
+
272
+ value.to_s
273
+ when :since
274
+ # `false` is the one way to say "no scoping" in the file. It becomes nil
275
+ # so every consumer's nil-check (runner scoping, the report's scoped
276
+ # marker) agrees; a false left raw would skip scoping but still mark
277
+ # the report scoped. A blank value is an error, not "no scoping": a
278
+ # `since: "$REF"` whose variable is unset must not turn scoping off.
279
+ return nil if value == false
280
+ raise ConfigError, "#{origin} must be a git ref, not blank #{got}" if value.to_s.strip.empty?
281
+
282
+ value.to_s
191
283
  end
192
284
  end
193
285
 
286
+ # Reads a finite Float from a number or a plain decimal string. Rejects booleans,
287
+ # NaN and Infinity: `Float(true)` is an error, but a YAML `.nan` is a Float.
288
+ # A string must be digits with an optional fraction, the same digits-only rule
289
+ # as `jobs`: `Float()` alone would also read `0x10`, `1_0`, `+2` and `1e2`.
290
+ #
291
+ # @api private
292
+ # @param value [Object] raw value.
293
+ # @return [Float, nil] the number, or nil when it is not a finite number.
294
+ def self.finite_float(value)
295
+ f = case value
296
+ when Integer, Float then value.to_f
297
+ when String then Float(value) if value.match?(/\A\d+(\.\d+)?\z/)
298
+ end
299
+ f if f&.finite?
300
+ end
301
+
194
302
  # Drop (with a warning) operator names the registry does not know.
195
303
  # Referenced lazily so config.rb carries no load-order dependency on the
196
304
  # registry; by the time a config is parsed at runtime, it is loaded.
@@ -17,6 +17,8 @@ require_relative "mutators/negation_removal"
17
17
  require_relative "mutators/chain_link"
18
18
  require_relative "mutators/operand_removal"
19
19
  require_relative "mutators/array_literal"
20
+ require_relative "mutators/condition_forcing"
21
+ require_relative "mutators/operator_assignment"
20
22
 
21
23
  module Mutineer
22
24
  # Maps operator names to operator classes.
@@ -45,7 +47,10 @@ module Mutineer
45
47
  "negation_removal" => Mutators::NegationRemoval,
46
48
  "chain_link" => Mutators::ChainLink,
47
49
  "operand_removal" => Mutators::OperandRemoval,
48
- "array_literal" => Mutators::ArrayLiteral
50
+ "array_literal" => Mutators::ArrayLiteral,
51
+ "condition_true" => Mutators::ConditionTrue,
52
+ "condition_false" => Mutators::ConditionFalse,
53
+ "operator_assignment" => Mutators::OperatorAssignment
49
54
  }.freeze
50
55
 
51
56
  # The default Tier-1 operator set.
@@ -53,7 +58,8 @@ module Mutineer
53
58
  # Tier-2 operators that remain opt-in.
54
59
  TIER2_NAMES = %w[return_nil literal_mutation condition_negation string_literal regex collection_method
55
60
  safe_navigation range negation_removal
56
- chain_link operand_removal array_literal].freeze
61
+ chain_link operand_removal array_literal
62
+ condition_true condition_false operator_assignment].freeze
57
63
 
58
64
  # Short human-readable descriptions for each operator.
59
65
  DESCRIPTIONS = {
@@ -73,7 +79,10 @@ module Mutineer
73
79
  "negation_removal" => "!x, not x -> x",
74
80
  "chain_link" => "drop one call from a chain: a.b.c -> a.c",
75
81
  "operand_removal" => "a && b -> a, b",
76
- "array_literal" => "[a, b] -> []"
82
+ "array_literal" => "[a, b] -> []",
83
+ "condition_true" => "if/elsif/unless/ternary/modifier/case-in guard condition -> true",
84
+ "condition_false" => "if/elsif/unless/ternary/modifier/case-in guard condition -> false",
85
+ "operator_assignment" => "+= <-> -=, *= <-> /=, %= -> *=, **= -> *= (not ||=, &&=)"
77
86
  }.freeze
78
87
 
79
88
  # Resolves operator names to classes.
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Mutineer
6
+ module Mutators
7
+ # Shared base of the condition-forcing mutators (Tier-2).
8
+ #
9
+ # Replaces the condition of an `if`, `elsif`, `unless`, ternary, modifier
10
+ # guard (`x if y`) or `case`/`in` guard with a literal, so its branch always
11
+ # runs or never runs. {ConditionTrue} forces `true` and {ConditionFalse}
12
+ # forces `false`; one mutation per condition each.
13
+ #
14
+ # A condition that is already `true`, `false` or `nil`, even in
15
+ # parentheses, is left alone: forcing it changes nothing or repeats the
16
+ # `boolean_literal` flip. This holds even when `boolean_literal` does not run.
17
+ # A condition that holds a heredoc is left alone too: the heredoc body lies
18
+ # outside the condition, so it would stay behind as code. A condition that
19
+ # assigns a variable keeps its code and only its value is forced.
20
+ #
21
+ # The never-runs side of an else-less conditional can be the same program
22
+ # as the `nil` that `statement_removal` or `return_nil` puts in place of the
23
+ # whole conditional. It is still made: dropping it would make the mutants
24
+ # of one operator depend on which other operators run, and on what the user
25
+ # suppressed for them. The two then get the same verdict.
26
+ #
27
+ # `condition_negation` wraps the condition in `!( ... )`, which neither
28
+ # forced value is.
29
+ class ConditionForcing < Base
30
+ # Condition nodes that `boolean_literal` already mutates.
31
+ LITERALS = [Prism::TrueNode, Prism::FalseNode, Prism::NilNode].freeze
32
+
33
+ # Visits if nodes: `if`, `elsif`, a ternary, a modifier `if` and an
34
+ # `in ... if` guard.
35
+ #
36
+ # @param node [Prism::IfNode] node to inspect.
37
+ # @return [void]
38
+ def visit_if_node(node)
39
+ force(node)
40
+ super
41
+ end
42
+
43
+ # Visits unless nodes: `unless`, a modifier `unless` and an
44
+ # `in ... unless` guard.
45
+ #
46
+ # @param node [Prism::UnlessNode] node to inspect.
47
+ # @return [void]
48
+ def visit_unless_node(node)
49
+ force(node)
50
+ super
51
+ end
52
+
53
+ # Nested method definitions are discovered as their own subjects; do not
54
+ # recurse into them (prevents double-counting their conditions).
55
+ #
56
+ # @param node [Prism::DefNode] nested definition node.
57
+ # @return [void]
58
+ def visit_def_node(node); end
59
+
60
+ private
61
+
62
+ # Emits the forced condition unless the condition is a literal or holds a
63
+ # heredoc, whose body would stay behind as code.
64
+ #
65
+ # @param node [Prism::IfNode, Prism::UnlessNode] the conditional.
66
+ # @return [void]
67
+ def force(node)
68
+ predicate = node.predicate
69
+ return if LITERALS.include?(unwrap(predicate).class) || heredoc?(predicate)
70
+
71
+ loc = predicate.location
72
+ @mutations << Mutation.new(
73
+ start_offset: loc.start_offset,
74
+ end_offset: loc.end_offset,
75
+ replacement: replacement(predicate),
76
+ operator: self.class::OPERATOR
77
+ )
78
+ end
79
+
80
+ # Returns the forced value for the condition, always in parentheses, so it
81
+ # cannot fuse with a keyword or a `?` next to it: `x if@a` becomes
82
+ # `x if(true)` and `@a?1:2` becomes `(true)?1:2`. A condition that assigns
83
+ # a variable keeps its code, so later reads still see the variable:
84
+ # `(m = x; true)`.
85
+ #
86
+ # @param predicate [Prism::Node] the condition.
87
+ # @return [String] the replacement source.
88
+ def replacement(predicate)
89
+ value = self.class::VALUE.to_s
90
+ writes?(predicate) ? "(#{predicate.slice}; #{value})" : "(#{value})"
91
+ end
92
+
93
+ # Returns whether the node writes a variable or constant anywhere inside
94
+ # it: a local, instance, class or global variable, or a constant, in a
95
+ # plain, compound, multiple or pattern write, or a named regex capture.
96
+ #
97
+ # @param node [Prism::Node] the node to inspect.
98
+ # @return [Boolean] true when a write or target node is inside.
99
+ def writes?(node)
100
+ return true if node.type.name.end_with?("_write_node", "_target_node")
101
+
102
+ node.compact_child_nodes.any? { |child| writes?(child) }
103
+ end
104
+
105
+ # Returns the node inside parentheses that hold exactly one expression,
106
+ # so `(true)` and `((true))` read as `true`.
107
+ #
108
+ # @param node [Prism::Node] the condition.
109
+ # @return [Prism::Node] the innermost wrapped node, or `node` itself.
110
+ def unwrap(node)
111
+ body = node.body if node.is_a?(Prism::ParenthesesNode)
112
+ body = body.body.first if body.is_a?(Prism::StatementsNode) && body.body.size == 1
113
+ body && !body.is_a?(Prism::StatementsNode) ? unwrap(body) : node
114
+ end
115
+ end
116
+
117
+ # Condition-forcing mutator: the condition becomes `true` (Tier-2).
118
+ #
119
+ # An `if` branch always runs; an `unless` branch never runs. The mutant
120
+ # survives when no test runs the code with the condition false (for an
121
+ # `if`) or true (for an `unless`) and checks what changes.
122
+ class ConditionTrue < ConditionForcing
123
+ # The literal the condition is replaced with.
124
+ VALUE = true
125
+ # The operator name mutations carry.
126
+ OPERATOR = :condition_true
127
+ end
128
+
129
+ # Condition-forcing mutator: the condition becomes `false` (Tier-2).
130
+ #
131
+ # An `if` branch never runs; an `unless` branch always runs. The mutant
132
+ # survives when no test needs the branch the condition guards (for an
133
+ # `if`) or skips it (for an `unless`).
134
+ class ConditionFalse < ConditionForcing
135
+ # The literal the condition is replaced with.
136
+ VALUE = false
137
+ # The operator name mutations carry.
138
+ OPERATOR = :condition_false
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Mutineer
6
+ module Mutators
7
+ # Operator-assignment mutator (Tier-2).
8
+ #
9
+ # Swaps the operator of a compound assignment, one mutation per
10
+ # occurrence: `t += x` -> `t -= x`. Prism parses `t += x` as its own
11
+ # node, not as a CallNode, so the arithmetic mutator never sees it.
12
+ #
13
+ # The mapping is the same as for arithmetic. Other compound operators
14
+ # (`||=`, `&&=`, `|=`, `<<=` and the rest) are not changed.
15
+ class OperatorAssignment < Base
16
+ # Token replacements for compound arithmetic operators.
17
+ REPLACEMENTS = {
18
+ :+ => "-=", :- => "+=", :* => "/=", :/ => "*=", :% => "*=", :** => "*="
19
+ }.freeze
20
+
21
+ # Visits `t += x`.
22
+ #
23
+ # @param node [Prism::LocalVariableOperatorWriteNode] node to inspect.
24
+ # @return [void]
25
+ def visit_local_variable_operator_write_node(node)
26
+ emit(node)
27
+ super # the value can hold its own compound assignment
28
+ end
29
+
30
+ # Visits `@t += x`.
31
+ #
32
+ # @param node [Prism::InstanceVariableOperatorWriteNode] node to inspect.
33
+ # @return [void]
34
+ def visit_instance_variable_operator_write_node(node)
35
+ emit(node)
36
+ super
37
+ end
38
+
39
+ # Visits `@@t += x`.
40
+ #
41
+ # @param node [Prism::ClassVariableOperatorWriteNode] node to inspect.
42
+ # @return [void]
43
+ def visit_class_variable_operator_write_node(node)
44
+ emit(node)
45
+ super
46
+ end
47
+
48
+ # Visits `$t += x`.
49
+ #
50
+ # @param node [Prism::GlobalVariableOperatorWriteNode] node to inspect.
51
+ # @return [void]
52
+ def visit_global_variable_operator_write_node(node)
53
+ emit(node)
54
+ super
55
+ end
56
+
57
+ # Visits `T += x`.
58
+ #
59
+ # @param node [Prism::ConstantOperatorWriteNode] node to inspect.
60
+ # @return [void]
61
+ def visit_constant_operator_write_node(node)
62
+ emit(node)
63
+ super
64
+ end
65
+
66
+ # Visits `A::T += x` and `::T += x`.
67
+ #
68
+ # @param node [Prism::ConstantPathOperatorWriteNode] node to inspect.
69
+ # @return [void]
70
+ def visit_constant_path_operator_write_node(node)
71
+ emit(node)
72
+ super
73
+ end
74
+
75
+ # Visits `a.b += x` and `a&.b += x`.
76
+ #
77
+ # @param node [Prism::CallOperatorWriteNode] node to inspect.
78
+ # @return [void]
79
+ def visit_call_operator_write_node(node)
80
+ emit(node)
81
+ super
82
+ end
83
+
84
+ # Visits `a[i] += x`.
85
+ #
86
+ # @param node [Prism::IndexOperatorWriteNode] node to inspect.
87
+ # @return [void]
88
+ def visit_index_operator_write_node(node)
89
+ emit(node)
90
+ super
91
+ end
92
+
93
+ # Skips a nested method definition. The project finds it as a subject of
94
+ # its own, so a visit here counts its compound assignments twice.
95
+ #
96
+ # @param node [Prism::DefNode] nested definition node.
97
+ # @return [void]
98
+ def visit_def_node(node); end
99
+
100
+ private
101
+
102
+ # Emits a mutation when the node's operator has a replacement.
103
+ #
104
+ # @param node [Prism::Node] a node with `binary_operator` and `binary_operator_loc`.
105
+ # @return [void]
106
+ def emit(node)
107
+ replacement = REPLACEMENTS[node.binary_operator]
108
+ return unless replacement
109
+
110
+ loc = node.binary_operator_loc
111
+ @mutations << Mutation.new(
112
+ start_offset: loc.start_offset,
113
+ end_offset: loc.end_offset,
114
+ replacement: replacement,
115
+ operator: :operator_assignment
116
+ )
117
+ end
118
+ end
119
+ end
120
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Mutineer
4
4
  # Current Mutineer release version.
5
- VERSION = "1.3.0"
5
+ VERSION = "1.4.0"
6
6
  end
data/lib/mutineer.rb CHANGED
@@ -31,6 +31,8 @@ require_relative "mutineer/mutators/negation_removal"
31
31
  require_relative "mutineer/mutators/chain_link"
32
32
  require_relative "mutineer/mutators/operand_removal"
33
33
  require_relative "mutineer/mutators/array_literal"
34
+ require_relative "mutineer/mutators/condition_forcing"
35
+ require_relative "mutineer/mutators/operator_assignment"
34
36
  require_relative "mutineer/mutator_registry"
35
37
  require_relative "mutineer/worker_pool"
36
38
  require_relative "mutineer/progress"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mutineer
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Teren
@@ -77,10 +77,12 @@ files:
77
77
  - lib/mutineer/mutators/chain_link.rb
78
78
  - lib/mutineer/mutators/collection_method.rb
79
79
  - lib/mutineer/mutators/comparison.rb
80
+ - lib/mutineer/mutators/condition_forcing.rb
80
81
  - lib/mutineer/mutators/condition_negation.rb
81
82
  - lib/mutineer/mutators/literal_mutation.rb
82
83
  - lib/mutineer/mutators/negation_removal.rb
83
84
  - lib/mutineer/mutators/operand_removal.rb
85
+ - lib/mutineer/mutators/operator_assignment.rb
84
86
  - lib/mutineer/mutators/range_literal.rb
85
87
  - lib/mutineer/mutators/regex_literal.rb
86
88
  - lib/mutineer/mutators/return_nil.rb