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 +4 -4
- data/CHANGELOG.md +67 -0
- data/README.md +2 -1
- data/lib/mutineer/changed_lines.rb +34 -2
- data/lib/mutineer/cli.rb +31 -77
- data/lib/mutineer/config.rb +157 -49
- data/lib/mutineer/mutator_registry.rb +12 -3
- data/lib/mutineer/mutators/condition_forcing.rb +141 -0
- data/lib/mutineer/mutators/operator_assignment.rb +120 -0
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e18649829a9226ea7a5f654ef38602eabff4005ebf8a516fca4402460a7aac09
|
|
4
|
+
data.tar.gz: 9e3c166e5e2e513278aaf210f271ed0f070cd335d0de588318cb8aa71f6b6a06
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
82
|
-
|
|
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
|
|
98
|
-
o.on("--only NAME") { |v| opts[:only] = v
|
|
99
|
-
o.on("--since REF") { |v| opts[:since] =
|
|
100
|
-
# A typed "no" must beat a .mutineer.yml `since:` key
|
|
101
|
-
#
|
|
102
|
-
o.on("--no-since") { opts[:since] = nil
|
|
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")
|
|
105
|
-
|
|
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("--
|
|
115
|
-
o.on("--
|
|
116
|
-
o.on("--
|
|
117
|
-
o.on("--
|
|
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
|
|
124
|
-
|
|
125
|
-
o.on("--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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
data/lib/mutineer/config.rb
CHANGED
|
@@ -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
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
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
|
|
133
|
-
#
|
|
134
|
-
#
|
|
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
|
-
#
|
|
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
|
|
161
|
-
# @param value [Object] raw YAML value.
|
|
162
|
-
# @param
|
|
163
|
-
#
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
when
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
#
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
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
|
data/lib/mutineer/version.rb
CHANGED
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.
|
|
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
|