mutineer 1.0.2 → 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 +126 -0
- data/README.md +5 -2
- data/lib/mutineer/child_stdout.rb +35 -0
- data/lib/mutineer/coverage_map.rb +115 -56
- data/lib/mutineer/daemon_server.rb +2 -1
- data/lib/mutineer/isolation.rb +9 -3
- data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +199 -0
- data/lib/mutineer/minitest_integration.rb +22 -8
- data/lib/mutineer/mutator_registry.rb +24 -4
- data/lib/mutineer/mutators/array_literal.rb +51 -0
- data/lib/mutineer/mutators/base.rb +15 -0
- data/lib/mutineer/mutators/chain_link.rb +139 -0
- data/lib/mutineer/mutators/negation_removal.rb +44 -0
- data/lib/mutineer/mutators/operand_removal.rb +65 -0
- data/lib/mutineer/mutators/range_literal.rb +54 -0
- data/lib/mutineer/mutators/safe_navigation.rb +76 -0
- data/lib/mutineer/pairing.rb +10 -5
- data/lib/mutineer/runner.rb +28 -7
- data/lib/mutineer/test_runners/minitest.rb +5 -1
- data/lib/mutineer/test_runners/rspec.rb +9 -11
- data/lib/mutineer/test_runners.rb +4 -2
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer.rb +6 -0
- metadata +9 -15
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Mutineer
|
|
6
|
+
module Mutators
|
|
7
|
+
# Safe-navigation mutator (Tier-2).
|
|
8
|
+
#
|
|
9
|
+
# Replaces `&.` with `.`, one mutation per occurrence. The mutant raises
|
|
10
|
+
# NoMethodError on a nil receiver, so it survives when no test passes nil.
|
|
11
|
+
class SafeNavigation < Base
|
|
12
|
+
# Visits call nodes and emits safe-navigation mutations.
|
|
13
|
+
#
|
|
14
|
+
# @param node [Prism::CallNode] call node to inspect.
|
|
15
|
+
# @return [void]
|
|
16
|
+
def visit_call_node(node)
|
|
17
|
+
emit(node)
|
|
18
|
+
super # chained calls (a&.b&.c) each get their own mutation
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Visits `a&.b += 1`, which Prism parses as its own node, not a CallNode.
|
|
22
|
+
#
|
|
23
|
+
# @param node [Prism::CallOperatorWriteNode] node to inspect.
|
|
24
|
+
# @return [void]
|
|
25
|
+
def visit_call_operator_write_node(node)
|
|
26
|
+
emit(node)
|
|
27
|
+
super
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Visits `a&.b ||= 1`.
|
|
31
|
+
#
|
|
32
|
+
# @param node [Prism::CallOrWriteNode] node to inspect.
|
|
33
|
+
# @return [void]
|
|
34
|
+
def visit_call_or_write_node(node)
|
|
35
|
+
emit(node)
|
|
36
|
+
super
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Visits `a&.b &&= 1`.
|
|
40
|
+
#
|
|
41
|
+
# @param node [Prism::CallAndWriteNode] node to inspect.
|
|
42
|
+
# @return [void]
|
|
43
|
+
def visit_call_and_write_node(node)
|
|
44
|
+
emit(node)
|
|
45
|
+
super
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Visits a call used as an assignment target, as in `for a&.b in list`.
|
|
49
|
+
#
|
|
50
|
+
# @param node [Prism::CallTargetNode] node to inspect.
|
|
51
|
+
# @return [void]
|
|
52
|
+
def visit_call_target_node(node)
|
|
53
|
+
emit(node)
|
|
54
|
+
super
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# Emits a mutation when the node's call operator is `&.`.
|
|
60
|
+
#
|
|
61
|
+
# @param node [Prism::Node] a node with `safe_navigation?` and `call_operator_loc`.
|
|
62
|
+
# @return [void]
|
|
63
|
+
def emit(node)
|
|
64
|
+
return unless node.safe_navigation?
|
|
65
|
+
|
|
66
|
+
loc = node.call_operator_loc
|
|
67
|
+
@mutations << Mutation.new(
|
|
68
|
+
start_offset: loc.start_offset,
|
|
69
|
+
end_offset: loc.end_offset,
|
|
70
|
+
replacement: ".",
|
|
71
|
+
operator: :safe_navigation
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
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
|
|
@@ -434,7 +454,8 @@ module Mutineer
|
|
|
434
454
|
else
|
|
435
455
|
Isolation.apply_whole_file(mutated, source_file)
|
|
436
456
|
end
|
|
437
|
-
|
|
457
|
+
# One failing test already kills the mutant, so the child stops there.
|
|
458
|
+
TestRunners.for(framework).run(abs_tests, stop_at_first_failure: true)
|
|
438
459
|
end
|
|
439
460
|
end
|
|
440
461
|
|
|
@@ -9,8 +9,12 @@ module Mutineer
|
|
|
9
9
|
# Runs the given Minitest files.
|
|
10
10
|
#
|
|
11
11
|
# @param test_files [String, Array<String>] one file or many files.
|
|
12
|
+
# @param stop_at_first_failure [Boolean] when true, the run ends at the
|
|
13
|
+
# first failing test.
|
|
12
14
|
# @return [Integer] 0 on success, 1 on failure.
|
|
13
|
-
def self.run(test_files
|
|
15
|
+
def self.run(test_files, stop_at_first_failure: false)
|
|
16
|
+
MinitestIntegration.run(test_files, stop_at_first_failure: stop_at_first_failure)
|
|
17
|
+
end
|
|
14
18
|
end
|
|
15
19
|
end
|
|
16
20
|
end
|
|
@@ -16,24 +16,22 @@ module Mutineer
|
|
|
16
16
|
# Runs the given RSpec files.
|
|
17
17
|
#
|
|
18
18
|
# @param spec_files [String, Array<String>] one file or many files.
|
|
19
|
+
# @param stop_at_first_failure [Boolean] when true, the run ends at the
|
|
20
|
+
# first failing example (RSpec `--fail-fast`).
|
|
19
21
|
# @return [Integer] 0 on success, 1 on failure.
|
|
20
|
-
def self.run(spec_files)
|
|
22
|
+
def self.run(spec_files, stop_at_first_failure: false)
|
|
21
23
|
require_rspec!
|
|
22
24
|
|
|
23
25
|
::RSpec::Core::Runner.disable_autorun!
|
|
24
26
|
::RSpec.reset
|
|
25
27
|
|
|
28
|
+
# The sink takes RSpec's own formatter output. Spec output is not
|
|
29
|
+
# silenced here: the fork boundary that calls this method has already
|
|
30
|
+
# pointed stdout at File::NULL (see ChildStdout).
|
|
26
31
|
sink = StringIO.new
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
$stderr = sink
|
|
31
|
-
begin
|
|
32
|
-
status = ::RSpec::Core::Runner.run(["--no-color", *Array(spec_files)], sink, sink)
|
|
33
|
-
ensure
|
|
34
|
-
$stdout = orig_out
|
|
35
|
-
$stderr = orig_err
|
|
36
|
-
end
|
|
32
|
+
args = ["--no-color"]
|
|
33
|
+
args << "--fail-fast" if stop_at_first_failure
|
|
34
|
+
status = ::RSpec::Core::Runner.run([*args, *Array(spec_files)], sink, sink)
|
|
37
35
|
|
|
38
36
|
status.zero? ? 0 : 1
|
|
39
37
|
end
|
|
@@ -6,8 +6,10 @@ require_relative "test_runners/rspec"
|
|
|
6
6
|
module Mutineer
|
|
7
7
|
# Picks the test-framework runner.
|
|
8
8
|
#
|
|
9
|
-
# Each runner responds to `.run(files) -> 0/1`
|
|
10
|
-
# failure) and is called only inside a forked child.
|
|
9
|
+
# Each runner responds to `.run(files, stop_at_first_failure: false) -> 0/1`
|
|
10
|
+
# (0 = all passed, 1 = any failure) and is called only inside a forked child.
|
|
11
|
+
# Only the mutant path passes `stop_at_first_failure: true`. The fork boundary
|
|
12
|
+
# silences stdout (see ChildStdout); the runners do not.
|
|
11
13
|
module TestRunners
|
|
12
14
|
# Returns the runner module for a framework name.
|
|
13
15
|
#
|
data/lib/mutineer/version.rb
CHANGED
data/lib/mutineer.rb
CHANGED
|
@@ -25,6 +25,12 @@ require_relative "mutineer/mutators/condition_negation"
|
|
|
25
25
|
require_relative "mutineer/mutators/string_literal"
|
|
26
26
|
require_relative "mutineer/mutators/regex_literal"
|
|
27
27
|
require_relative "mutineer/mutators/collection_method"
|
|
28
|
+
require_relative "mutineer/mutators/safe_navigation"
|
|
29
|
+
require_relative "mutineer/mutators/range_literal"
|
|
30
|
+
require_relative "mutineer/mutators/negation_removal"
|
|
31
|
+
require_relative "mutineer/mutators/chain_link"
|
|
32
|
+
require_relative "mutineer/mutators/operand_removal"
|
|
33
|
+
require_relative "mutineer/mutators/array_literal"
|
|
28
34
|
require_relative "mutineer/mutator_registry"
|
|
29
35
|
require_relative "mutineer/worker_pool"
|
|
30
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.0
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Teren
|
|
@@ -9,20 +9,6 @@ bindir: bin
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
-
- !ruby/object:Gem::Dependency
|
|
13
|
-
name: minitest
|
|
14
|
-
requirement: !ruby/object:Gem::Requirement
|
|
15
|
-
requirements:
|
|
16
|
-
- - "~>"
|
|
17
|
-
- !ruby/object:Gem::Version
|
|
18
|
-
version: '5.0'
|
|
19
|
-
type: :development
|
|
20
|
-
prerelease: false
|
|
21
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
-
requirements:
|
|
23
|
-
- - "~>"
|
|
24
|
-
- !ruby/object:Gem::Version
|
|
25
|
-
version: '5.0'
|
|
26
12
|
- !ruby/object:Gem::Dependency
|
|
27
13
|
name: rake
|
|
28
14
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -68,6 +54,7 @@ files:
|
|
|
68
54
|
- lib/mutineer.rb
|
|
69
55
|
- lib/mutineer/baseline.rb
|
|
70
56
|
- lib/mutineer/changed_lines.rb
|
|
57
|
+
- lib/mutineer/child_stdout.rb
|
|
71
58
|
- lib/mutineer/cli.rb
|
|
72
59
|
- lib/mutineer/config.rb
|
|
73
60
|
- lib/mutineer/coverage_map.rb
|
|
@@ -78,19 +65,26 @@ files:
|
|
|
78
65
|
- lib/mutineer/file_swap.rb
|
|
79
66
|
- lib/mutineer/isolation.rb
|
|
80
67
|
- lib/mutineer/minitest_integration.rb
|
|
68
|
+
- lib/mutineer/minitest_integration/stop_at_first_failure.rb
|
|
81
69
|
- lib/mutineer/mutant_id.rb
|
|
82
70
|
- lib/mutineer/mutation.rb
|
|
83
71
|
- lib/mutineer/mutator_registry.rb
|
|
84
72
|
- lib/mutineer/mutators/arithmetic.rb
|
|
73
|
+
- lib/mutineer/mutators/array_literal.rb
|
|
85
74
|
- lib/mutineer/mutators/base.rb
|
|
86
75
|
- lib/mutineer/mutators/boolean_connector.rb
|
|
87
76
|
- lib/mutineer/mutators/boolean_literal.rb
|
|
77
|
+
- lib/mutineer/mutators/chain_link.rb
|
|
88
78
|
- lib/mutineer/mutators/collection_method.rb
|
|
89
79
|
- lib/mutineer/mutators/comparison.rb
|
|
90
80
|
- lib/mutineer/mutators/condition_negation.rb
|
|
91
81
|
- lib/mutineer/mutators/literal_mutation.rb
|
|
82
|
+
- lib/mutineer/mutators/negation_removal.rb
|
|
83
|
+
- lib/mutineer/mutators/operand_removal.rb
|
|
84
|
+
- lib/mutineer/mutators/range_literal.rb
|
|
92
85
|
- lib/mutineer/mutators/regex_literal.rb
|
|
93
86
|
- lib/mutineer/mutators/return_nil.rb
|
|
87
|
+
- lib/mutineer/mutators/safe_navigation.rb
|
|
94
88
|
- lib/mutineer/mutators/statement_removal.rb
|
|
95
89
|
- lib/mutineer/mutators/string_literal.rb
|
|
96
90
|
- lib/mutineer/pairing.rb
|