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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b33e53b5e67f12380151690883608e8bc76ab3915d4771baa6d5293e5cedd94c
4
- data.tar.gz: 24fda20aa66c67f0a828c44fd3cd064a586c8ea40f6c9fe2f8263518ef6318be
3
+ metadata.gz: 69175984fb4d2e1fd2859a58bf8ef935c0866743bdcb3e712b4f0c7c5b4b3853
4
+ data.tar.gz: c0b7f471d708e0e9102de7fd73fb2cbd1b476f983bef5b61f2c6bb0656072cb6
5
5
  SHA512:
6
- metadata.gz: 76f242526719139f3e78ba2a32e145671db00486f7a017d75011330c6c390c8b12e91ff142e779270d2f05711da36970747831cf1af9aadd06735f604010cfa8
7
- data.tar.gz: 58d9d64c6344dfcee881455109b9638e9e380233a7e3a4be826879e14f8b4ca21de201f79978b9eb07fbcd0376a30b9e9b71cc440bb1c033fc92804062c6770a
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
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- mutation_tester (1.6.0)
4
+ mutation_tester (1.7.0)
5
5
  parallel (~> 1.20)
6
6
  parser (~> 3.3)
7
7
  rainbow (~> 3.0)
@@ -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) + build_argument_nil_mutations(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 %i[kwargs hash].include?(argument.type) &&
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 build_argument_mutation(node, range_begin, range_end, replacement, description)
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: node.loc.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(node.loc.line),
346
- mutated_line: extract_mutated_line(mutated_code, node.loc.line),
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
@@ -1,3 +1,3 @@
1
1
  module MutationTester
2
- VERSION = '1.6.0'.freeze
2
+ VERSION = '1.7.0'.freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mutation_tester
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.0
4
+ version: 1.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kamil Dzierbicki