mutineer 1.1.0 → 1.2.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: 1ca70f2affdf60ddda53d3d38e23ddff4ba6cae27cd789501c7fe3060975f146
4
- data.tar.gz: 94a9968fe182655e6d566e9edfc43f1f2e15b050c7690f8905e7be9f7d710b61
3
+ metadata.gz: 2b48ca68bd77d82bd83917312fe4bc2377954403e0a63c99702c9059b9dde07b
4
+ data.tar.gz: 0c11eaa74d07db731902b2fe70313f71b17eded8a59f10e5652b86985c11641c
5
5
  SHA512:
6
- metadata.gz: 8da96d70db7af241cda4ca44ecbe84522b0c0b68427a46c05e3178723c2eada7d957677c8b2cc7d2853d9593cba546c194f342862058dffdba55d03e8ef27021
7
- data.tar.gz: 50481e9ca34d07530878f6ff0f25f192ab63c3200376a1a1ea5665341955a00c6bf2f9e7a4429d6cd8fd8d2119c4316c4c993741f219897f57ab493ca6c428b1
6
+ metadata.gz: 23505720f25f34b80bde51fb0a900c16a40295966ec8bd14713d22eca03ea0058a69d83b3f701672adee6a20b10f342f09d8b561d974f2a8f23d12a82ba22e46
7
+ data.tar.gz: cb2b659574bf1454ef265e3723b2c906485589696316cb63d9fb81d7696e04867e9fec73ee36b8d67063c7d941f4165fd35c922cc47cbc19466ec839ff8cbc2c
data/CHANGELOG.md CHANGED
@@ -6,6 +6,54 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.2.0] - 2026-09-28
10
+
11
+ ### Added
12
+ - **Operand-removal operator** (Tier-2, opt-in via `--operators`):
13
+ `operand_removal` replaces `a && b` with `(a)` and with `(b)`, and does the
14
+ same for `||`, `and` and `or`. The mutant survives when no test needs the
15
+ operand that the mutant removes. The operator never keeps a jump operand
16
+ (`return`, `break`, `next`, `redo`, `retry`) alone, because a jump does not
17
+ parse in a value context. It never removes an operand that holds a heredoc,
18
+ because the heredoc body stays behind as code. It skips nested method
19
+ definitions, because mutineer mutates each one as its own method.
20
+ - **Array-literal operator** (Tier-2, opt-in via `--operators`):
21
+ `array_literal` replaces a non-empty array literal, such as `[a, b]` or
22
+ `%i[a b]`, with `[]`. The mutant survives when no test checks the contents
23
+ of the array. The operator skips an implicit array (`x = 1, 2`), an array
24
+ that holds a heredoc, and nested method definitions.
25
+ - **Sources also pair with Minitest's `test/**/test_*.rb` files** — after the
26
+ `_test.rb` forms, so existing projects pair as before. `lib/helper.rb` does
27
+ not pair with the `test/test_helper.rb` support file. A failed capture of a
28
+ `test_<name>.rb` file now marks `<name>.rb` uncapturable, as `<name>_test.rb`
29
+ does (#120). Without `framework:` set, a source with `spec/<name>_spec.rb`
30
+ and `test/test_<name>.rb` but no `<name>_test.rb` now pairs with the
31
+ Minitest file, as the "Minitest first" order says.
32
+
33
+ ### Fixed
34
+ - **`reload` loads the mutant by an absolute path** — a relative source path
35
+ gave the mutant relative backtrace paths, so code that checks its own frames
36
+ by absolute path failed for every mutant, a false kill (#123).
37
+ - **`require "test_helper"` works without `RUBYOPT`** — a standalone run puts
38
+ `lib`, then each test file's `test_helper.rb` directory, on the load path,
39
+ as boot mode and `rake test` do. A run where no test records coverage because
40
+ captures failed now exits 1 instead of reporting N/A (#119).
41
+ - **A disable-line marker warns about an operator it does not know** — a
42
+ reason written without `--` became part of the operator name, so the marker
43
+ suppressed nothing and said nothing (#124). A marker followed only by spaces
44
+ or commas, such as `disable-line -- why`, now disables the whole line.
45
+ - **Coverage capture and the clean check run each source once** — they read
46
+ sources with `load`, so a test's own `require` ran them again: a `Struct`
47
+ superclass raised `superclass mismatch`, and load-time code ran twice (#122).
48
+ A mutant of such a class still errors under `--strategy reload`, which loads
49
+ the mutated file again; `--strategy redefine` runs it.
50
+ Code that guards itself to run once (`unless defined?(X)`) can now show as
51
+ covered, so its mutants run where they were `no_coverage` before.
52
+ - **A red unmutated suite now shows why it failed** — in a standalone run, the
53
+ Minitest summary or RSpec output of the failing test, with its failure
54
+ message, goes to stderr before the "not green" error. A passing run prints
55
+ nothing extra. Boot mode (`--rails`, `--boot`) is unchanged (#121).
56
+
9
57
  ## [1.1.0] - 2026-09-28
10
58
 
11
59
  ### Added
@@ -498,6 +546,7 @@ Rails hardening + CI batch (issues #8–#13), all verified Rails-free.
498
546
  - `.mutineer.yml` configuration (CLI > config > default precedence).
499
547
  - Byte-correct source handling for multibyte (UTF-8) sources.
500
548
 
549
+ [1.2.0]: https://github.com/davidteren/mutineer/releases/tag/v1.2.0
501
550
  [1.1.0]: https://github.com/davidteren/mutineer/releases/tag/v1.1.0
502
551
  [1.0.2]: https://github.com/davidteren/mutineer/releases/tag/v1.0.2
503
552
  [1.0.1]: https://github.com/davidteren/mutineer/releases/tag/v1.0.1
data/README.md CHANGED
@@ -84,7 +84,8 @@ Run `mutineer --list-operators` to see them. Default (Tier 1): `arithmetic`,
84
84
  `comparison`, `boolean_connector`, `boolean_literal`, `statement_removal`.
85
85
  Available but off by default (Tier 2, enable via `--operators`): `return_nil`,
86
86
  `literal_mutation`, `condition_negation`, `string_literal`, `regex`,
87
- `collection_method`, `safe_navigation`, `range`, `negation_removal`, `chain_link`.
87
+ `collection_method`, `safe_navigation`, `range`, `negation_removal`, `chain_link`,
88
+ `operand_removal`, `array_literal`.
88
89
 
89
90
  ## Rails apps
90
91
 
@@ -212,7 +213,7 @@ Tradeoffs — this path is correct but not free:
212
213
  Some mutants are equivalent (behaviour-identical) and survive forever — keeping a
213
214
  file off 100%. Suppress them so the score and `--threshold` gate stay meaningful:
214
215
 
215
- - **Inline:** `some_line # mutineer:disable-line` (or scope it: `# mutineer:disable-line comparison`).
216
+ - **Inline:** `some_line # mutineer:disable-line` (or scope it: `# mutineer:disable-line comparison`). Put a reason after `--`: `# mutineer:disable-line comparison -- the test checks only 20`.
216
217
  - **Config:** a `.mutineer.yml` `ignore:` list of stable mutant ids. Each survivor's
217
218
  `id` is printed in the JSON report, so copy it straight into `ignore:`.
218
219
 
@@ -96,7 +96,7 @@ module Mutineer
96
96
  # Is this source file's empty coverage the result of an *errored* capture
97
97
  # rather than a genuine coverage gap? True iff some capture failed this run
98
98
  # AND this file got zero coverage from any successful capture AND a failed
99
- # test file maps to it by the standard _test/_spec naming convention. Derived
99
+ # test file maps to it by the _test/_spec/test_ naming convention. Derived
100
100
  # purely from already-persisted state (@map keys + @failed_test_files); no
101
101
  # rerun, no new cached field, no digest change.
102
102
  #
@@ -142,10 +142,17 @@ module Mutineer
142
142
  @map.keys.map { |k| k.rpartition(":").first }.to_set
143
143
  end
144
144
 
145
- # Basenames of failed test files with a trailing _test/_spec (and .rb) stripped,
146
- # i.e. the source basenames they would have covered by convention.
145
+ # Basenames of the sources that failed test files pair with by convention:
146
+ # a trailing _test/_spec is stripped first, as pairing tries that form first.
147
147
  def failed_test_targets
148
- @failed_test_files.map { |t| File.basename(t, ".rb").sub(/_(test|spec)\z/, "") }.to_set
148
+ @failed_test_files.map do |t|
149
+ name = File.basename(t, ".rb")
150
+ case name
151
+ when /_(test|spec)\z/ then name.sub(/_(test|spec)\z/, "")
152
+ when "test_helper" then name # Minitest's support file pairs with no source
153
+ else name.delete_prefix("test_")
154
+ end
155
+ end.to_set
149
156
  end
150
157
 
151
158
  # Shared cache dance for both build paths: hit the digest-keyed cache, else
@@ -577,11 +584,17 @@ module Mutineer
577
584
  loads = Array(test_paths).map { |t| "load #{absolute(t).inspect}" }.join("\n")
578
585
  <<~RUBY
579
586
  require "minitest"
587
+ require "stringio"
580
588
  def Minitest.autorun; end
589
+ _report = StringIO.new
590
+ Minitest.define_singleton_method(:plugin_mutineer_report_init) { |options| reporter << Minitest::SummaryReporter.new(_report, options) }
591
+ Minitest.extensions << "mutineer_report"
581
592
  $LOAD_PATH.unshift(*#{abs_load_paths.inspect})
582
- #{abs_source_paths.inspect}.each { |f| load f }
593
+ #{abs_source_paths.inspect}.each { |f| require f }
583
594
  #{loads}
584
- exit(Minitest.run([]) ? 0 : 1)
595
+ _passed = Minitest.run([])
596
+ $stderr.write(_report.string) unless _passed
597
+ exit(_passed ? 0 : 1)
585
598
  RUBY
586
599
  end
587
600
 
@@ -601,9 +614,10 @@ module Mutineer
601
614
  end
602
615
  RSpec::Core::Runner.disable_autorun!
603
616
  $LOAD_PATH.unshift(*#{abs_load_paths.inspect})
604
- #{abs_source_paths.inspect}.each { |f| load f }
617
+ #{abs_source_paths.inspect}.each { |f| require f }
605
618
  _sink = StringIO.new
606
619
  status = RSpec::Core::Runner.run(["--no-color", #{specs}], _sink, _sink)
620
+ $stderr.write(_sink.string) unless status.zero?
607
621
  exit(status.zero? ? 0 : 1)
608
622
  RUBY
609
623
  end
@@ -628,12 +642,17 @@ module Mutineer
628
642
  require "coverage"
629
643
  require "json"
630
644
  require "minitest"
645
+ require "stringio"
631
646
  def Minitest.autorun; end
647
+ _report = StringIO.new
648
+ Minitest.define_singleton_method(:plugin_mutineer_report_init) { |options| reporter << Minitest::SummaryReporter.new(_report, options) }
649
+ Minitest.extensions << "mutineer_report"
632
650
  Coverage.start(lines: true)
633
651
  $LOAD_PATH.unshift(*#{abs_load_paths.inspect})
634
- #{abs_source_paths.inspect}.each { |f| load f }
652
+ #{abs_source_paths.inspect}.each { |f| require f }
635
653
  load #{absolute(test_path).inspect}
636
654
  _passed = Minitest.run([])
655
+ $stderr.write(_report.string) unless _passed
637
656
  _result.puts JSON.generate("passed" => _passed == true, "coverage" => Coverage.result,
638
657
  "loaded_files" => #{loaded_files_expression})
639
658
  _result.close
@@ -641,7 +660,7 @@ module Mutineer
641
660
  end
642
661
 
643
662
  # Same coverage-JSON contract as the minitest path, but driven by RSpec:
644
- # require rspec/core lazily, load the sources under Coverage, then run the
663
+ # require rspec/core lazily, require the sources under Coverage, then run the
645
664
  # one spec via RSpec::Core::Runner. The JSON goes to the result channel (see
646
665
  # {#spawn_script}), so spec output cannot corrupt it. A missing rspec makes
647
666
  # the script exit non-zero -> capture() records a skipped (incomplete-map)
@@ -661,9 +680,10 @@ module Mutineer
661
680
  RSpec::Core::Runner.disable_autorun!
662
681
  Coverage.start(lines: true)
663
682
  $LOAD_PATH.unshift(*#{abs_load_paths.inspect})
664
- #{abs_source_paths.inspect}.each { |f| load f }
683
+ #{abs_source_paths.inspect}.each { |f| require f }
665
684
  _sink = StringIO.new
666
685
  _status = RSpec::Core::Runner.run(["--no-color", #{absolute(test_path).inspect}], _sink, _sink)
686
+ $stderr.write(_sink.string) unless _status.zero?
667
687
  _result.puts JSON.generate("passed" => _status.zero?, "coverage" => Coverage.result,
668
688
  "loaded_files" => #{loaded_files_expression})
669
689
  _result.close
@@ -99,7 +99,7 @@ module Mutineer
99
99
  # @param source_file [String] original source file path.
100
100
  # @return [Object] whatever `load` returns.
101
101
  def self.apply_whole_file(mutated, source_file)
102
- Tempfile.create(["mutineer_mutant", ".rb"], File.dirname(source_file)) do |f|
102
+ Tempfile.create(["mutineer_mutant", ".rb"], File.dirname(File.expand_path(source_file))) do |f|
103
103
  f.write(mutated)
104
104
  f.flush
105
105
  load f.path
@@ -15,6 +15,8 @@ require_relative "mutators/safe_navigation"
15
15
  require_relative "mutators/range_literal"
16
16
  require_relative "mutators/negation_removal"
17
17
  require_relative "mutators/chain_link"
18
+ require_relative "mutators/operand_removal"
19
+ require_relative "mutators/array_literal"
18
20
 
19
21
  module Mutineer
20
22
  # Maps operator names to operator classes.
@@ -41,7 +43,9 @@ module Mutineer
41
43
  "safe_navigation" => Mutators::SafeNavigation,
42
44
  "range" => Mutators::RangeLiteral,
43
45
  "negation_removal" => Mutators::NegationRemoval,
44
- "chain_link" => Mutators::ChainLink
46
+ "chain_link" => Mutators::ChainLink,
47
+ "operand_removal" => Mutators::OperandRemoval,
48
+ "array_literal" => Mutators::ArrayLiteral
45
49
  }.freeze
46
50
 
47
51
  # The default Tier-1 operator set.
@@ -49,7 +53,7 @@ module Mutineer
49
53
  # Tier-2 operators that remain opt-in.
50
54
  TIER2_NAMES = %w[return_nil literal_mutation condition_negation string_literal regex collection_method
51
55
  safe_navigation range negation_removal
52
- chain_link].freeze
56
+ chain_link operand_removal array_literal].freeze
53
57
 
54
58
  # Short human-readable descriptions for each operator.
55
59
  DESCRIPTIONS = {
@@ -67,7 +71,9 @@ module Mutineer
67
71
  "safe_navigation" => "&. -> .",
68
72
  "range" => ".. <-> ...",
69
73
  "negation_removal" => "!x, not x -> x",
70
- "chain_link" => "drop one call from a chain: a.b.c -> a.c"
74
+ "chain_link" => "drop one call from a chain: a.b.c -> a.c",
75
+ "operand_removal" => "a && b -> a, b",
76
+ "array_literal" => "[a, b] -> []"
71
77
  }.freeze
72
78
 
73
79
  # Resolves operator names to classes.
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Mutineer
6
+ module Mutators
7
+ # Array-literal mutator (Tier-2).
8
+ #
9
+ # Replaces a non-empty array literal with an empty one, one mutation per
10
+ # literal: `[a, b]` and `%i[a b]` become `[]`. The mutant survives when no
11
+ # test checks the contents of the array.
12
+ class ArrayLiteral < Base
13
+ # Visits array nodes and emits array-literal mutations.
14
+ #
15
+ # @param node [Prism::ArrayNode] array node to inspect.
16
+ # @return [void]
17
+ def visit_array_node(node)
18
+ emit(node)
19
+ super # nested arrays each get their own mutation
20
+ end
21
+
22
+ # Skips a nested method definition. The project finds it as a subject of
23
+ # its own, so a visit here counts its arrays twice.
24
+ #
25
+ # @param node [Prism::DefNode] nested definition node.
26
+ # @return [void]
27
+ def visit_def_node(node); end
28
+
29
+ private
30
+
31
+ # Emits a mutation for a non-empty array with brackets.
32
+ #
33
+ # The operator skips an implicit array (`x = 1, 2`), because it has no
34
+ # brackets. It also skips an array that holds a heredoc, because the
35
+ # heredoc body stays behind as code.
36
+ #
37
+ # @param node [Prism::ArrayNode] array node to inspect.
38
+ # @return [void]
39
+ def emit(node)
40
+ return if node.opening_loc.nil? || node.elements.empty? || heredoc?(node)
41
+
42
+ @mutations << Mutation.new(
43
+ start_offset: node.location.start_offset,
44
+ end_offset: node.location.end_offset,
45
+ replacement: "[]",
46
+ operator: :array_literal
47
+ )
48
+ end
49
+ end
50
+ end
51
+ end
@@ -26,6 +26,21 @@ module Mutineer
26
26
  subject.def_node.body&.accept(self)
27
27
  @mutations
28
28
  end
29
+
30
+ private
31
+
32
+ # Returns whether a node is, or contains, a heredoc.
33
+ #
34
+ # A heredoc's body lies outside its node's byte range. A mutation that
35
+ # deletes the node leaves the body behind as code, so the mutant
36
+ # always raises.
37
+ #
38
+ # @param node [Prism::Node] node to inspect.
39
+ # @return [Boolean] true when a heredoc is inside the node.
40
+ def heredoc?(node)
41
+ (node.respond_to?(:heredoc?) && node.heredoc?) ||
42
+ node.compact_child_nodes.any? { |child| heredoc?(child) }
43
+ end
29
44
  end
30
45
  end
31
46
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Mutineer
6
+ module Mutators
7
+ # Operand-removal mutator (Tier-2).
8
+ #
9
+ # Replaces a boolean expression with one of its operands, two mutations
10
+ # per `&&`, `||`, `and` or `or`: `a && b` becomes `(a)` and `(b)`. The
11
+ # parentheses keep the precedence of `and` and `or`. A surviving mutant
12
+ # shows that no test needs the operand that was removed.
13
+ class OperandRemoval < Base
14
+ # Node types for jumps. A jump in a value context does not parse, so
15
+ # the operator never keeps a jump alone.
16
+ JUMPS = [Prism::ReturnNode, Prism::BreakNode, Prism::NextNode, Prism::RedoNode, Prism::RetryNode].freeze
17
+
18
+ # Visits `and` nodes.
19
+ #
20
+ # @param node [Prism::AndNode] node to inspect.
21
+ # @return [void]
22
+ def visit_and_node(node)
23
+ emit(node)
24
+ super # nested connectors (a && b && c) each get their own mutations
25
+ end
26
+
27
+ # Visits `or` nodes.
28
+ #
29
+ # @param node [Prism::OrNode] node to inspect.
30
+ # @return [void]
31
+ def visit_or_node(node)
32
+ emit(node)
33
+ super
34
+ end
35
+
36
+ # Skips a nested method definition. The project finds it as a subject of
37
+ # its own, so a visit here counts its connectors twice.
38
+ #
39
+ # @param node [Prism::DefNode] nested definition node.
40
+ # @return [void]
41
+ def visit_def_node(node); end
42
+
43
+ private
44
+
45
+ # Emits one mutation that keeps the left operand, then one that keeps
46
+ # the right operand. The order is fixed, because the two mutants share
47
+ # a token and their occurrence ordinals tell them apart.
48
+ #
49
+ # @param node [Prism::AndNode, Prism::OrNode] connector node.
50
+ # @return [void]
51
+ def emit(node)
52
+ [[node.left, node.right], [node.right, node.left]].each do |kept, removed|
53
+ next if JUMPS.any? { |type| kept.is_a?(type) } || heredoc?(removed)
54
+
55
+ @mutations << Mutation.new(
56
+ start_offset: node.location.start_offset,
57
+ end_offset: node.location.end_offset,
58
+ replacement: "(#{kept.slice})",
59
+ operator: :operand_removal
60
+ )
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -5,9 +5,9 @@ module Mutineer
5
5
  # no Rails, no class loading, no process. Two jobs:
6
6
  # * expand_sources — a directory argument becomes its sorted **/*.rb files.
7
7
  # * infer_test — a source's test file by convention (app/ and lib/
8
- # sources map to test/.../_test.rb or spec/.../_spec.rb),
9
- # preserving namespaced subdirectories. First EXISTING
10
- # candidate wins.
8
+ # sources map to test/.../_test.rb, test/.../test_*.rb
9
+ # or spec/.../_spec.rb), preserving namespaced
10
+ # subdirectories. First EXISTING candidate wins.
11
11
  #
12
12
  # Independently unit-testable: every method is pure in/out over the
13
13
  # filesystem, so the pairing contract is exercised with plain fixtures, no
@@ -68,8 +68,8 @@ module Mutineer
68
68
  end
69
69
  end
70
70
 
71
- # Ordered candidate test paths. lib/ sources also get test/lib/... and
72
- # spec/lib/... (Rails apps put lib tests under either layout).
71
+ # Ordered candidate test paths: _test.rb, then Minitest's test_*.rb, then
72
+ # _spec.rb. lib/ sources also get the test/lib/... and spec/lib/... layouts.
73
73
  #
74
74
  # @param base [String] logical source path without extension.
75
75
  # @param lib [Boolean] whether the source originated from lib/.
@@ -78,6 +78,11 @@ module Mutineer
78
78
  def candidates(base, lib, prefer)
79
79
  minitest = ["test/#{base}_test.rb"]
80
80
  minitest << "test/lib/#{base}_test.rb" if lib
81
+ unless File.basename(base) == "helper" # test/test_helper.rb is Minitest's support file, not a test
82
+ prefixed = base.sub(%r{[^/]+\z}) { |name| "test_#{name}" }
83
+ minitest << "test/#{prefixed}.rb"
84
+ minitest << "test/lib/#{prefixed}.rb" if lib
85
+ end
81
86
  rspec = ["spec/#{base}_spec.rb"]
82
87
  rspec << "spec/lib/#{base}_spec.rb" if lib
83
88
  prefer == "rspec" ? rspec + minitest : minitest + rspec
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pathname"
3
4
  require_relative "parser"
4
5
  require_relative "project"
5
6
  require_relative "result"
@@ -93,10 +94,16 @@ module Mutineer
93
94
  verbose: config.verbose
94
95
  ).build_via_fork(after_fork: (config.rails ? -> { reconnect_active_record } : nil))
95
96
  else
97
+ # As in boot mode, and with lib first as `rake test` does.
98
+ test_roots = test_load_roots(config.tests.map { |t| File.expand_path(t, config.project_root) })
99
+ libs = config.load_paths.map { |p| File.expand_path(p, config.project_root) }
100
+ $LOAD_PATH.unshift(*(libs + test_roots).uniq.reject { |d| $LOAD_PATH.include?(d) })
101
+ # Relative, so the cache digest does not depend on the checkout path.
102
+ rel_roots = test_roots.map { |d| Pathname(d).relative_path_from(File.expand_path(config.project_root)).to_s }
96
103
  coverage_map = CoverageMap.new(
97
104
  source_paths: config.sources, test_paths: config.tests,
98
105
  cache_dir: config.cache_dir, project_root: config.project_root,
99
- load_paths: config.load_paths, framework: config.framework
106
+ load_paths: config.load_paths + rel_roots, framework: config.framework
100
107
  ).build_or_load
101
108
  end
102
109
  abort_if_unclean!(coverage_map)
@@ -157,7 +164,7 @@ module Mutineer
157
164
  ignored_results = []
158
165
  Project.discover(config.sources, only: config.only).each do |subject|
159
166
  source = (source_map[subject.file] ||= File.read(subject.file))
160
- disabled = (disabled_map[subject.file] ||= suppress_map(source))
167
+ disabled = (disabled_map[subject.file] ||= suppress_map(source, subject.file))
161
168
  mutations = operator_classes.flat_map { |klass| klass.new.mutations_for(subject, source) }
162
169
  ids = MutantId.for_subject(subject, source, mutations)
163
170
  mutations.each_with_index do |mutation, i|
@@ -253,8 +260,13 @@ module Mutineer
253
260
  #
254
261
  # @param coverage_map [Mutineer::CoverageMap] the built or loaded map.
255
262
  # @return [void]
256
- # @raise [Mutineer::SmokeCheckError] when any captured test failed clean.
263
+ # @raise [Mutineer::SmokeCheckError] when any captured test failed clean,
264
+ # or when no test recorded coverage and a capture failed.
257
265
  def self.abort_if_unclean!(coverage_map)
266
+ if coverage_map.map.empty? && coverage_map.failed_test_files.any?
267
+ raise SmokeCheckError, "no test recorded coverage, and capture failed for #{coverage_map.failed_test_files.join(', ')}"
268
+ end
269
+
258
270
  files = coverage_map.failed_clean_tests
259
271
  return if files.empty?
260
272
 
@@ -299,13 +311,21 @@ module Mutineer
299
311
  # sits on the same physical line as the code it silences). A bare marker
300
312
  # disables every operator on that line; `disable-line a, b` only the listed
301
313
  # operators. Block-form disable/enable ranges are intentionally not supported.
302
- def self.suppress_map(source)
314
+ def self.suppress_map(source, file)
303
315
  map = {}
304
316
  source.each_line.with_index(1) do |text, line|
305
317
  next unless (m = text.match(/#\s*mutineer:disable-line(?:\s+([\w,\s]+))?/))
306
318
 
307
- ops = m[1]
308
- map[line] = ops ? ops.split(",").map { |o| o.strip.to_sym }.reject(&:empty?).to_set : :all
319
+ ops = m[1]&.split(",")&.map(&:strip)&.reject(&:empty?)
320
+ # Only spaces or commas after the marker (e.g. `disable-line -- why`)
321
+ # is a bare marker, not an empty list that silences nothing.
322
+ ops = nil if ops&.empty?
323
+ unknown = ops.to_a.reject { |o| MutatorRegistry::ALL.key?(o) }
324
+ unknown.each do |o|
325
+ warn "mutineer: unknown operator #{o.inspect} in #{file}:#{line} " \
326
+ "(known: #{MutatorRegistry::ALL.keys.join(', ')}); write a reason after --"
327
+ end
328
+ map[line] = ops ? ops.map(&:to_sym).to_set : :all
309
329
  end
310
330
  map
311
331
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Mutineer
4
4
  # Current Mutineer release version.
5
- VERSION = "1.1.0"
5
+ VERSION = "1.2.0"
6
6
  end
data/lib/mutineer.rb CHANGED
@@ -29,6 +29,8 @@ require_relative "mutineer/mutators/safe_navigation"
29
29
  require_relative "mutineer/mutators/range_literal"
30
30
  require_relative "mutineer/mutators/negation_removal"
31
31
  require_relative "mutineer/mutators/chain_link"
32
+ require_relative "mutineer/mutators/operand_removal"
33
+ require_relative "mutineer/mutators/array_literal"
32
34
  require_relative "mutineer/mutator_registry"
33
35
  require_relative "mutineer/worker_pool"
34
36
  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.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Teren
@@ -70,6 +70,7 @@ files:
70
70
  - lib/mutineer/mutation.rb
71
71
  - lib/mutineer/mutator_registry.rb
72
72
  - lib/mutineer/mutators/arithmetic.rb
73
+ - lib/mutineer/mutators/array_literal.rb
73
74
  - lib/mutineer/mutators/base.rb
74
75
  - lib/mutineer/mutators/boolean_connector.rb
75
76
  - lib/mutineer/mutators/boolean_literal.rb
@@ -79,6 +80,7 @@ files:
79
80
  - lib/mutineer/mutators/condition_negation.rb
80
81
  - lib/mutineer/mutators/literal_mutation.rb
81
82
  - lib/mutineer/mutators/negation_removal.rb
83
+ - lib/mutineer/mutators/operand_removal.rb
82
84
  - lib/mutineer/mutators/range_literal.rb
83
85
  - lib/mutineer/mutators/regex_literal.rb
84
86
  - lib/mutineer/mutators/return_nil.rb