mutation_tester 1.6.0 → 1.7.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 +6 -0
- data/Gemfile.lock +1 -1
- data/docs/mutation-types.md +21 -0
- data/lib/mutation_tester/mutator.rb +114 -6
- data/lib/mutation_tester/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 69175984fb4d2e1fd2859a58bf8ef935c0866743bdcb3e712b4f0c7c5b4b3853
|
|
4
|
+
data.tar.gz: c0b7f471d708e0e9102de7fd73fb2cbd1b476f983bef5b61f2c6bb0656072cb6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e804c052438a3e3f4e7e8dae1d0b9eee24bd169df0cfc7ffb81db4bc63adba70420c67d5d83539a4431db91c25d355bf2dc1a58229b1d6fa3f81358650a7bbd9
|
|
7
|
+
data.tar.gz: 241a515a2b594221a48af68a001660b5df09453ab985271eca43b14f72e673ad5ef24934e5c16f89b1395081addfba64280c7ffcacd125121545ab747b0db390
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.7.0] - 2026-09-19
|
|
4
|
+
|
|
5
|
+
- Behavior change: two new default operators generate additional mutants, so the mutation score of a file can drop after the upgrade without any change to your code, and a run that sat just above `minimum_score` can start failing. Only files that contain ranges or calls with two or more hash pairs are affected. The new survivors point at options and bounds no test checks; a mutant that cannot be killed (see below) is excluded with `# mutation_tester:disable` on its line. There is no new configuration, no new reported type and no JSON schema change: both operators belong to existing families and are switched off together with them (`argument`, `comparison`). Mutant ids shift in affected files.
|
|
6
|
+
- Added removal of a single hash pair from a call argument, reported as `argument` (`Remove pair <key> from <method>`): keyword options (`validates :role, presence: true, inclusion: ROLES` yields one mutant without `presence:` and one without `inclusion:`), braced hashes, and option hashes nested as a pair value (`uniqueness: { scope: :account_id, case_sensitive: false }`). Until now the only mutants of such a call removed or replaced the whole option list, which usually fails while the class loads, so every test of the file killed them and none could tell tests apart in the kill matrix. A single removed option leaves the code loadable, so only the test that checks that option kills it. The mutant is reported on the line of the removed pair, so each pair of a multi-line call can be annotated separately. Left alone: a lone brace-free keyword that ends the call (that is the existing last-argument removal), hashes carrying a double splat (`**opts` or an anonymous `**`), hash literals that are not call arguments, and any pair whose removal would cut into a heredoc. A pair that only repeats the callee's own default (`async: false` when `async` already defaults to `false`) yields a mutant no test can kill, the same limitation the last-argument removal has.
|
|
7
|
+
- Added the range boundary swap, reported as `comparison` (`Change .. to ...` and back): the range form of `<=` versus `<` on the upper bound, killed only by a test that uses the bound itself (the last element, the last character kept by `text[0...limit]`). Endless ranges (`1..`, `1..nil`, `text[1..]`) and ranges ending at `Float::INFINITY` are skipped because both forms hold the same values there; flip-flops are untouched.
|
|
8
|
+
|
|
3
9
|
## [1.6.0] - 2026-09-19
|
|
4
10
|
|
|
5
11
|
- Added an opt-in kill matrix (`--kill-matrix`, `config.kill_matrix = true`) that records which tests kill each mutant, so the JSON report can be used to find redundant tests and not only missing ones. In this mode every mutant runs the full test file (no stop at the first failing test and no RSpec test selection), for RSpec and Minitest on all three runners, serial and parallel. The JSON report gains three additive fields, so `schema_version` stays at 1: `kill_matrix: true`, `tests[]` (every test of the baseline run with `id`, `name`, `line` and `status`) and `mutations[].killed_by` (the sorted ids of every test that failed under the mutant). An empty `killed_by` on a `killed` or `timeout` mutant means the killers are unknown (every timeout, and a mutated file that no longer loads), so a test must never be called redundant because of such a mutant. `--kill-matrix` together with `--fail-fast` is a usage error (exit code `2`), and a baseline that records no test aborts the run instead of reporting an empty matrix. The README has a new "Finding redundant tests" section with `jq` recipes, and `examples/github_actions/redundant_tests.yml` ships a scheduled audit workflow.
|
data/Gemfile.lock
CHANGED
data/docs/mutation-types.md
CHANGED
|
@@ -36,6 +36,12 @@ mutating them mostly yields always-killed `NoMethodError` garbage rather than a
|
|
|
36
36
|
- `==` → `!=`, `>`, `<`
|
|
37
37
|
- `!=` → `==`, `>`, `<`
|
|
38
38
|
- `<=>` → `==`
|
|
39
|
+
- Range boundary: `a..b` → `a...b` and `a...b` → `a..b`, the range form of `<=` versus `<` on the upper bound. Only
|
|
40
|
+
a test that uses the upper bound itself (the last element, the last character kept by `text[0...limit]`) kills it.
|
|
41
|
+
An endless range (`1..`, `1..nil`, `text[1..]`) and a range ending at `Float::INFINITY` are left alone, because both
|
|
42
|
+
forms hold the same values there; beginless ranges (`..5`) are mutated, and flip-flops are not ranges and are
|
|
43
|
+
untouched. Reported with `type: comparison`, `original` and `mutated` being the two operators (e.g.
|
|
44
|
+
`Change .. to ...`). Numeric literal bounds still get their own `number` mutants.
|
|
39
45
|
|
|
40
46
|
## Strict Equality Mutations (opt-in)
|
|
41
47
|
|
|
@@ -130,6 +136,21 @@ mutating them mostly yields always-killed `NoMethodError` garbage rather than a
|
|
|
130
136
|
unless the argument is already the `nil` literal. A test that never verifies the effect of an argument (say, an
|
|
131
137
|
exception message asserted only by class, or a constructor field no spec reads) lets these mutants survive even when
|
|
132
138
|
the argument is not a literal.
|
|
139
|
+
- Removes one pair of a hash passed as a call argument, each pair in its own mutant: keyword options
|
|
140
|
+
(`validates :role, presence: true, inclusion: ROLES` → `validates :role, inclusion: ROLES` and
|
|
141
|
+
`validates :role, presence: true`), braced hashes, and option hashes nested as a pair value
|
|
142
|
+
(`uniqueness: { scope: :account_id, case_sensitive: false }` → `uniqueness: { case_sensitive: false }`). Unlike
|
|
143
|
+
removing or nil-ing the whole argument, the call usually still loads and runs, so only a test that checks that one
|
|
144
|
+
option kills the mutant. A braced hash with a single pair becomes `{}`; a lone brace-free keyword that ends the call
|
|
145
|
+
(`m(a, k: 1)`) is left to the last-argument removal, which produces the same code, and is removed here only when a
|
|
146
|
+
block pass follows it (`m(a, k: 1, &blk)` → `m(a, &blk)`). Hashes that carry a double splat (`**opts` or an anonymous
|
|
147
|
+
`**`) and hash literals that are not call arguments (`PRICES = { ... }`) are not touched, and a pair is kept when
|
|
148
|
+
removing it would cut into a heredoc (the pair opens one, or a heredoc body lies between the pair and its neighbor).
|
|
149
|
+
Reported with the description `Remove pair <key> from <method>` on the line of the removed pair, so in a multi-line
|
|
150
|
+
call each pair can be annotated on its own line; when the removed text spans a line break, `mutated_line` is the
|
|
151
|
+
marker `(pair removed)` and `mutated` still holds the whole call after the mutation. As with last-argument removal, a
|
|
152
|
+
pair that only repeats the callee's own default (`notify(user, async: false)` when `async` already defaults to
|
|
153
|
+
`false`) yields a mutant no test can kill; annotate the line of that pair with `# mutation_tester:disable`.
|
|
133
154
|
- Exclusions: operator sends (`+`, `==`, `[]`, `[]=`, `<<`, setters, ...), require-like calls (`require`,
|
|
134
155
|
`require_relative`, `load`, `autoload`), block-pass arguments (`&blk`), splats (`*args`), double splats (`**opts`),
|
|
135
156
|
and safe-navigation calls are not mutated by this family. A candidate whose code would no longer parse is dropped at
|
|
@@ -41,6 +41,10 @@ module MutationTester
|
|
|
41
41
|
|
|
42
42
|
NON_MUTABLE_ARGUMENT_TYPES = %i[block_pass splat kwsplat].freeze
|
|
43
43
|
|
|
44
|
+
HASH_NODE_TYPES = %i[hash kwargs].freeze
|
|
45
|
+
|
|
46
|
+
RANGE_OPERATOR_SWAPS = { irange: '...', erange: '..' }.freeze
|
|
47
|
+
|
|
44
48
|
DISABLE_ANNOTATION = /mutation_tester:disable\b/.freeze
|
|
45
49
|
|
|
46
50
|
def self.disabled_lines(content)
|
|
@@ -71,6 +75,7 @@ module MutationTester
|
|
|
71
75
|
@disabled_lines = self.class.disabled_lines(@original_content)
|
|
72
76
|
@in_memory_safe_context = false
|
|
73
77
|
@class_body_block_depth = 0
|
|
78
|
+
@heredoc_ranges = heredoc_ranges(ast)
|
|
74
79
|
mutations = collect_mutations(ast)
|
|
75
80
|
mutations = filter_mutations(mutations)
|
|
76
81
|
report_skipped(mutations.size) if @skipped_count.positive?
|
|
@@ -144,6 +149,8 @@ module MutationTester
|
|
|
144
149
|
mutations += mutate_logical_and(ast) if enabled?(:logical)
|
|
145
150
|
when :or
|
|
146
151
|
mutations += mutate_logical_or(ast) if enabled?(:logical)
|
|
152
|
+
when :irange, :erange
|
|
153
|
+
mutations += mutate_range_node(ast) if enabled?(:comparison)
|
|
147
154
|
end
|
|
148
155
|
|
|
149
156
|
mutations.each do |mutation|
|
|
@@ -282,7 +289,9 @@ module MutationTester
|
|
|
282
289
|
return [] unless PLAIN_METHOD_NAME.match?(method_name.to_s)
|
|
283
290
|
return [] if REQUIRE_METHODS.include?(method_name)
|
|
284
291
|
|
|
285
|
-
build_last_argument_removal(node, arguments) +
|
|
292
|
+
build_last_argument_removal(node, arguments) +
|
|
293
|
+
build_argument_nil_mutations(node, arguments) +
|
|
294
|
+
build_pair_removal_mutations(node, arguments)
|
|
286
295
|
rescue => e
|
|
287
296
|
warn_skipped(:argument, node, e)
|
|
288
297
|
[]
|
|
@@ -290,7 +299,7 @@ module MutationTester
|
|
|
290
299
|
|
|
291
300
|
def mutable_argument?(argument)
|
|
292
301
|
return false if NON_MUTABLE_ARGUMENT_TYPES.include?(argument.type)
|
|
293
|
-
return false if
|
|
302
|
+
return false if HASH_NODE_TYPES.include?(argument.type) &&
|
|
294
303
|
argument.children.any? { |child| child.is_a?(Parser::AST::Node) && child.type == :kwsplat }
|
|
295
304
|
|
|
296
305
|
true
|
|
@@ -328,7 +337,77 @@ module MutationTester
|
|
|
328
337
|
end
|
|
329
338
|
end
|
|
330
339
|
|
|
331
|
-
def
|
|
340
|
+
def build_pair_removal_mutations(node, arguments)
|
|
341
|
+
arguments.flat_map do |argument|
|
|
342
|
+
next [] unless HASH_NODE_TYPES.include?(argument.type)
|
|
343
|
+
|
|
344
|
+
build_hash_pair_removals(node, argument, arguments)
|
|
345
|
+
end
|
|
346
|
+
rescue => e
|
|
347
|
+
warn_skipped(:argument, node, e)
|
|
348
|
+
[]
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def build_hash_pair_removals(node, hash, arguments)
|
|
352
|
+
pairs = hash.children
|
|
353
|
+
return [] unless pairs.all? { |child| child.type == :pair }
|
|
354
|
+
|
|
355
|
+
nested = pairs.flat_map do |pair|
|
|
356
|
+
value = pair.children[1]
|
|
357
|
+
value.type == :hash ? build_hash_pair_removals(node, value, arguments) : []
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
pairs.each_index.filter_map { |index| build_pair_removal(node, hash, index, arguments) } + nested
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def build_pair_removal(node, hash, index, arguments)
|
|
364
|
+
range_begin, range_end = pair_removal_range(hash, index, arguments)
|
|
365
|
+
return nil unless range_begin
|
|
366
|
+
return nil if overlaps_heredoc?(range_begin, range_end)
|
|
367
|
+
|
|
368
|
+
pair = hash.children[index]
|
|
369
|
+
key = pair.children[0].loc.expression
|
|
370
|
+
mutation = build_argument_mutation(
|
|
371
|
+
node, range_begin, range_end, '',
|
|
372
|
+
"Remove pair #{source_slice(key.begin_pos, key.end_pos)} from #{node.children[1]}",
|
|
373
|
+
line: pair.loc.line
|
|
374
|
+
)
|
|
375
|
+
mutation[:mutated_line] = '(pair removed)' if mutation && source_slice(range_begin, range_end).include?("\n")
|
|
376
|
+
mutation
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def pair_removal_range(hash, index, arguments)
|
|
380
|
+
pairs = hash.children
|
|
381
|
+
pair = pairs[index].loc.expression
|
|
382
|
+
|
|
383
|
+
if pairs.size > 1 && index < pairs.size - 1
|
|
384
|
+
[pair.begin_pos, pairs[index + 1].loc.expression.begin_pos]
|
|
385
|
+
elsif pairs.size > 1
|
|
386
|
+
[pairs[index - 1].loc.expression.end_pos, pair.end_pos]
|
|
387
|
+
elsif hash.loc.begin
|
|
388
|
+
[hash.loc.begin.end_pos, hash.loc.end.begin_pos]
|
|
389
|
+
else
|
|
390
|
+
following = arguments[arguments.index { |argument| argument.equal?(hash) } + 1]
|
|
391
|
+
following && [pair.begin_pos, following.loc.expression.begin_pos]
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def heredoc_ranges(node, ranges = [])
|
|
396
|
+
return ranges unless node.is_a?(Parser::AST::Node)
|
|
397
|
+
|
|
398
|
+
if heredoc_node?(node)
|
|
399
|
+
ranges << [node.loc.expression.begin_pos, node.loc.expression.end_pos]
|
|
400
|
+
ranges << [node.loc.heredoc_body.begin_pos, node.loc.heredoc_end.end_pos]
|
|
401
|
+
end
|
|
402
|
+
node.children.each { |child| heredoc_ranges(child, ranges) }
|
|
403
|
+
ranges
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def overlaps_heredoc?(range_begin, range_end)
|
|
407
|
+
@heredoc_ranges.any? { |heredoc_begin, heredoc_end| range_begin < heredoc_end && heredoc_begin < range_end }
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def build_argument_mutation(node, range_begin, range_end, replacement, description, line: node.loc.line)
|
|
332
411
|
mutated_code = splice_source(range_begin, range_end, replacement)
|
|
333
412
|
return nil if mutated_code == @original_content
|
|
334
413
|
return nil unless parses_cleanly?(mutated_code)
|
|
@@ -338,12 +417,12 @@ module MutationTester
|
|
|
338
417
|
{
|
|
339
418
|
id: next_mutation_id,
|
|
340
419
|
type: :argument,
|
|
341
|
-
line:
|
|
420
|
+
line: line,
|
|
342
421
|
original: source_slice(call.begin_pos, call.end_pos),
|
|
343
422
|
mutated: mutated_code[call.begin_pos...(call.end_pos + offset)],
|
|
344
423
|
code: mutated_code,
|
|
345
|
-
source_line: extract_source_line(
|
|
346
|
-
mutated_line: extract_mutated_line(mutated_code,
|
|
424
|
+
source_line: extract_source_line(line),
|
|
425
|
+
mutated_line: extract_mutated_line(mutated_code, line),
|
|
347
426
|
description: description
|
|
348
427
|
}
|
|
349
428
|
end
|
|
@@ -670,6 +749,35 @@ module MutationTester
|
|
|
670
749
|
[]
|
|
671
750
|
end
|
|
672
751
|
|
|
752
|
+
def mutate_range_node(node)
|
|
753
|
+
upper_bound = node.children[1]
|
|
754
|
+
return [] if upper_bound.nil? || upper_bound.type == :nil || infinity_constant?(upper_bound)
|
|
755
|
+
|
|
756
|
+
operator = node.loc.operator
|
|
757
|
+
replacement = RANGE_OPERATOR_SWAPS.fetch(node.type)
|
|
758
|
+
mutated_code = splice_source(operator.begin_pos, operator.end_pos, replacement)
|
|
759
|
+
return [] unless parses_cleanly?(mutated_code)
|
|
760
|
+
|
|
761
|
+
[{
|
|
762
|
+
id: next_mutation_id,
|
|
763
|
+
type: :comparison,
|
|
764
|
+
line: operator.line,
|
|
765
|
+
original: operator.source,
|
|
766
|
+
mutated: replacement,
|
|
767
|
+
code: mutated_code,
|
|
768
|
+
source_line: extract_source_line(operator.line),
|
|
769
|
+
mutated_line: extract_mutated_line(mutated_code, operator.line),
|
|
770
|
+
description: "Change #{operator.source} to #{replacement}"
|
|
771
|
+
}]
|
|
772
|
+
rescue => e
|
|
773
|
+
warn_skipped(:comparison, node, e)
|
|
774
|
+
[]
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
def infinity_constant?(node)
|
|
778
|
+
node.type == :const && node.children[1] == :INFINITY
|
|
779
|
+
end
|
|
780
|
+
|
|
673
781
|
def parses_cleanly?(code)
|
|
674
782
|
buffer = Parser::Source::Buffer.new('(mutant-candidate)')
|
|
675
783
|
buffer.source = code
|