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.
@@ -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
@@ -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
@@ -434,7 +454,8 @@ module Mutineer
434
454
  else
435
455
  Isolation.apply_whole_file(mutated, source_file)
436
456
  end
437
- TestRunners.for(framework).run(abs_tests)
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) = MinitestIntegration.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
- orig_out = $stdout
28
- orig_err = $stderr
29
- $stdout = sink
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` (0 = all passed, 1 = any
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
  #
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Mutineer
4
4
  # Current Mutineer release version.
5
- VERSION = "1.0.2"
5
+ VERSION = "1.2.0"
6
6
  end
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.2
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