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 +4 -4
- data/CHANGELOG.md +49 -0
- data/README.md +3 -2
- data/lib/mutineer/coverage_map.rb +30 -10
- data/lib/mutineer/isolation.rb +1 -1
- data/lib/mutineer/mutator_registry.rb +9 -3
- data/lib/mutineer/mutators/array_literal.rb +51 -0
- data/lib/mutineer/mutators/base.rb +15 -0
- data/lib/mutineer/mutators/operand_removal.rb +65 -0
- data/lib/mutineer/pairing.rb +10 -5
- data/lib/mutineer/runner.rb +26 -6
- 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: 2b48ca68bd77d82bd83917312fe4bc2377954403e0a63c99702c9059b9dde07b
|
|
4
|
+
data.tar.gz: 0c11eaa74d07db731902b2fe70313f71b17eded8a59f10e5652b86985c11641c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
146
|
-
#
|
|
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
|
|
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|
|
|
593
|
+
#{abs_source_paths.inspect}.each { |f| require f }
|
|
583
594
|
#{loads}
|
|
584
|
-
|
|
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|
|
|
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|
|
|
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,
|
|
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|
|
|
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
|
data/lib/mutineer/isolation.rb
CHANGED
|
@@ -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
|
data/lib/mutineer/pairing.rb
CHANGED
|
@@ -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
|
|
9
|
-
#
|
|
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.
|
|
72
|
-
#
|
|
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
|
data/lib/mutineer/runner.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/mutineer/version.rb
CHANGED
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.
|
|
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
|