mutant 0.15.0 → 0.16.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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/VERSION +1 -1
  3. data/bin/mutant +0 -7
  4. data/lib/mutant/cli/command/root.rb +1 -1
  5. data/lib/mutant/cli/command/session.rb +281 -0
  6. data/lib/mutant/cli/command.rb +16 -2
  7. data/lib/mutant/config.rb +1 -1
  8. data/lib/mutant/expression/method.rb +0 -2
  9. data/lib/mutant/expression/methods.rb +0 -2
  10. data/lib/mutant/expression/namespace.rb +0 -2
  11. data/lib/mutant/isolation/fork.rb +2 -6
  12. data/lib/mutant/isolation.rb +31 -0
  13. data/lib/mutant/matcher/null.rb +1 -1
  14. data/lib/mutant/mutation/runner/sink.rb +23 -10
  15. data/lib/mutant/mutation/runner.rb +1 -0
  16. data/lib/mutant/mutation.rb +3 -20
  17. data/lib/mutant/mutator/node/literal/integer.rb +61 -0
  18. data/lib/mutant/parallel/connection.rb +0 -2
  19. data/lib/mutant/parallel/driver.rb +0 -2
  20. data/lib/mutant/reporter/cli/printer/alive_results.rb +27 -0
  21. data/lib/mutant/reporter/cli/printer/env_result.rb +53 -1
  22. data/lib/mutant/reporter/cli/printer/subject_result.rb +103 -5
  23. data/lib/mutant/reporter/cli/printer.rb +24 -1
  24. data/lib/mutant/repository/diff.rb +1 -2
  25. data/lib/mutant/result/exception.rb +29 -0
  26. data/lib/mutant/result/json_writer.rb +43 -0
  27. data/lib/mutant/result/process_status.rb +37 -0
  28. data/lib/mutant/result/session.rb +63 -0
  29. data/lib/mutant/result/test.rb +30 -0
  30. data/lib/mutant/result.rb +201 -96
  31. data/lib/mutant/segment/recorder.rb +0 -2
  32. data/lib/mutant/timer.rb +3 -1
  33. data/lib/mutant/transform/json.rb +68 -0
  34. data/lib/mutant/transform.rb +33 -25
  35. data/lib/mutant/version.rb +8 -14
  36. data/lib/mutant/world.rb +6 -0
  37. data/lib/mutant/zombifier.rb +0 -2
  38. data/lib/mutant.rb +13 -4
  39. metadata +33 -7
  40. data/lib/mutant/reporter/cli/printer/coverage_result.rb +0 -19
  41. data/lib/mutant/reporter/cli/printer/mutation_result.rb +0 -84
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutant
4
+ class Reporter
5
+ class CLI
6
+ class Printer
7
+ # Shared logic for printing alive mutation results
8
+ module AliveResults
9
+ ALIVE_EXPLANATION = <<~'MESSAGE'
10
+ Uncovered mutations detected, exiting nonzero!
11
+ Alive mutations require one of two actions:
12
+ A) Keep the mutated code: Your tests specify the correct semantics,
13
+ and the original code is redundant. Accept the mutation.
14
+ B) Add a missing test: The original code is correct, but the tests
15
+ do not verify the behavior the mutation removed.
16
+ MESSAGE
17
+
18
+ def print_alive_results(failed_subject_results)
19
+ failed_subject_results.each do |subject_result|
20
+ SubjectResult.call(display_config:, output:, object: subject_result)
21
+ end
22
+ end
23
+ end # AliveResults
24
+ end # Printer
25
+ end # CLI
26
+ end # Reporter
27
+ end # Mutant
@@ -8,13 +8,65 @@ module Mutant
8
8
  class EnvResult < self
9
9
  delegate(:failed_subject_results)
10
10
 
11
+ SEPARATOR = '-----------------------'
12
+ MORE_MESSAGE = '(%d more alive mutation(s), use `mutant session subject %s` to see all details)'
13
+ STATS_FORMAT = 'tests: %d, runtime: %.2fs, killtime: %.2fs'
14
+ NO_DIFF_MESSAGE = 'BUG: No diff generated, please report circumstances to https://github.com/mbj/mutant'
15
+
16
+ private_constant(*constants(false))
17
+
11
18
  # Run printer
12
19
  #
13
20
  # @return [undefined]
14
21
  def run
15
- visit_collection(SubjectResult, failed_subject_results)
22
+ unless failed_subject_results.empty?
23
+ puts(AliveResults::ALIVE_EXPLANATION)
24
+ failed_subject_results.each(&method(:print_subject_summary))
25
+ end
16
26
  visit(EnvProgress, object)
17
27
  end
28
+
29
+ private
30
+
31
+ def print_subject_summary(subject_result)
32
+ uncovered = subject_result.uncovered_results
33
+
34
+ if uncovered.any? { |coverage_result| critical?(coverage_result.mutation_result) }
35
+ SubjectResult.call(output:, object: subject_result)
36
+ return
37
+ end
38
+
39
+ print_subject_line(subject_result)
40
+ print_mutation_diff(uncovered.first.mutation_result)
41
+
42
+ remaining = uncovered.length - 1
43
+
44
+ return unless remaining.positive?
45
+
46
+ puts(MORE_MESSAGE % [remaining, subject_result.expression_syntax])
47
+ end
48
+
49
+ def print_subject_line(subject_result)
50
+ status(subject_result.identification)
51
+ puts(STATS_FORMAT % [subject_result.tests.length, subject_result.runtime, subject_result.killtime])
52
+ end
53
+
54
+ def critical?(mutation_result)
55
+ !mutation_result.mutation_type.eql?('evil')
56
+ end
57
+
58
+ def print_mutation_diff(mutation_result)
59
+ puts(mutation_result.mutation_identification)
60
+ puts(SEPARATOR)
61
+ diff = mutation_result.mutation_diff
62
+
63
+ if diff
64
+ output.write(color? ? colorize_diff(diff) : diff)
65
+ else
66
+ puts(NO_DIFF_MESSAGE)
67
+ end
68
+ puts(SEPARATOR)
69
+ end
18
70
  end # EnvResult
19
71
  end # Printer
20
72
  end # CLI
@@ -5,19 +5,117 @@ module Mutant
5
5
  class CLI
6
6
  class Printer
7
7
  # Subject result printer
8
+ #
9
+ # Renders subject identification, tests, and all uncovered mutations
10
+ # inline so that subject context (source, node) is available when
11
+ # printing mutation details.
8
12
  class SubjectResult < self
9
13
 
10
- delegate :subject, :uncovered_results, :tests
14
+ delegate :uncovered_results, :tests
15
+
16
+ MAP = {
17
+ 'evil' => :evil_details,
18
+ 'neutral' => :neutral_details,
19
+ 'noop' => :noop_details
20
+ }.freeze
21
+
22
+ NEUTRAL_MESSAGE = <<~'MESSAGE'
23
+ --- Neutral failure ---
24
+ Original code was inserted unmutated. And the test did NOT PASS.
25
+ Your tests do not pass initially or you found a bug in mutant / unparser.
26
+ Subject AST:
27
+ %s
28
+ Unparsed Source:
29
+ %s
30
+ MESSAGE
31
+
32
+ NO_DIFF_MESSAGE = <<~'MESSAGE'
33
+ --- Internal failure ---
34
+ BUG: A generated mutation did not result in exactly one diff hunk!
35
+ This is an invariant violation by the mutation generation engine.
36
+ Please report a reproduction to https://github.com/mbj/mutant
37
+ Original unparsed source:
38
+ %s
39
+ Original AST:
40
+ %s
41
+ Mutated unparsed source:
42
+ %s
43
+ Mutated AST:
44
+ %s
45
+ MESSAGE
46
+
47
+ NOOP_MESSAGE = <<~'MESSAGE'
48
+ ---- Noop failure -----
49
+ No code was inserted. And the test did NOT PASS.
50
+ This is typically a problem of your specs not passing unmutated.
51
+ MESSAGE
52
+
53
+ SEPARATOR = '-----------------------'
54
+ STATS_FORMAT = 'tests: %d, runtime: %.2fs, killtime: %.2fs'
55
+
56
+ private_constant(*constants(false))
11
57
 
12
58
  # Run report printer
13
59
  #
14
60
  # @return [undefined]
15
61
  def run
16
- status(subject.identification)
17
- tests.each do |test|
18
- puts("- #{test.identification}")
62
+ status(object.identification)
63
+ puts(STATS_FORMAT % [tests.length, object.runtime, object.killtime])
64
+ uncovered_results.each do |coverage_result|
65
+ print_mutation_result(coverage_result.mutation_result)
66
+ end
67
+ print_selected_tests
68
+ end
69
+
70
+ private
71
+
72
+ def print_selected_tests
73
+ if tests.empty?
74
+ puts('no selected tests')
75
+ else
76
+ puts("selected tests (#{tests.length}):")
77
+ tests.each do |test|
78
+ puts("- #{test.identification}")
79
+ end
80
+ end
81
+ end
82
+
83
+ def print_mutation_result(mutation_result)
84
+ puts(mutation_result.mutation_identification)
85
+ puts(SEPARATOR)
86
+ visit(IsolationResult, mutation_result.isolation_result) if show_isolation_logs?(mutation_result)
87
+ __send__(MAP.fetch(mutation_result.mutation_type), mutation_result)
88
+ puts(SEPARATOR)
89
+ end
90
+
91
+ def show_isolation_logs?(mutation_result)
92
+ display_config.isolation_logs || !mutation_result.mutation_type.eql?('evil')
93
+ end
94
+
95
+ # rubocop:disable Metrics/MethodLength
96
+ def evil_details(mutation_result)
97
+ diff = mutation_result.mutation_diff
98
+
99
+ if diff
100
+ output.write(color? ? colorize_diff(diff) : diff)
101
+ else
102
+ info(
103
+ NO_DIFF_MESSAGE,
104
+ object.source,
105
+ object.node.inspect,
106
+ mutation_result.mutation_source,
107
+ mutation_result.mutation_node.inspect
108
+ )
19
109
  end
20
- visit_collection(CoverageResult, uncovered_results)
110
+ end
111
+ # rubocop:enable Metrics/MethodLength
112
+
113
+ def noop_details(_mutation_result)
114
+ info(NOOP_MESSAGE)
115
+ end
116
+
117
+ def neutral_details(mutation_result)
118
+ info(NEUTRAL_MESSAGE, object.node.inspect, mutation_result.mutation_source)
21
119
  end
22
120
 
23
121
  end # SubjectResult
@@ -5,13 +5,25 @@ module Mutant
5
5
  class CLI
6
6
  # CLI runner status printer base class
7
7
  class Printer
8
+ # Printer display options
9
+ class DisplayConfig
10
+ include Anima.new(:isolation_logs)
11
+
12
+ DEFAULT = new(isolation_logs: false)
13
+ VERBOSE = new(isolation_logs: true)
14
+ end
15
+
8
16
  include(
9
17
  AbstractType,
10
18
  Adamantium,
11
- Anima.new(:output, :object),
19
+ Anima.new(:display_config, :output, :object),
12
20
  Procto
13
21
  )
14
22
 
23
+ def self.call(output:, object:, display_config: DisplayConfig::DEFAULT)
24
+ super
25
+ end
26
+
15
27
  private_class_method :new
16
28
 
17
29
  def call = run
@@ -82,6 +94,17 @@ module Mutant
82
94
  end
83
95
 
84
96
  alias_method :color?, :tty?
97
+
98
+ def colorize_diff(raw_diff)
99
+ raw_diff.lines.map do |line|
100
+ case line[0]
101
+ when '+' then Unparser::Color::GREEN.format(line)
102
+ when '-' then Unparser::Color::RED.format(line)
103
+ else
104
+ line
105
+ end
106
+ end.join
107
+ end
85
108
  end # Printer
86
109
  end # CLI
87
110
  end # Reporter
@@ -60,14 +60,13 @@ module Mutant
60
60
  end
61
61
 
62
62
  # rubocop:disable Metrics/MethodLength
63
- # mutant:disable (3.2 specific mutation)
64
63
  def parse_line(root, line)
65
64
  match = FORMAT.match(line)
66
65
 
67
66
  if match
68
67
  Either::Right.new(
69
68
  Path.new(
70
- path: root.join(Util.one(match.captures)),
69
+ path: root.join(Util.one(match)),
71
70
  to:,
72
71
  world:
73
72
  )
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutant
4
+ module Result
5
+ # Serializable exception data
6
+ class Exception
7
+ include Anima.new(
8
+ :backtrace,
9
+ :message,
10
+ :original_class
11
+ )
12
+
13
+ # Build from a Ruby exception
14
+ #
15
+ # @param [::Exception] exception
16
+ #
17
+ # @return [Exception]
18
+ def self.from_exception(exception)
19
+ new(
20
+ backtrace: exception.backtrace,
21
+ message: exception.message,
22
+ original_class: exception.class.name
23
+ )
24
+ end
25
+
26
+ JSON = Transform::JSON.for_anima(self)
27
+ end # Exception
28
+ end # Result
29
+ end # Mutant
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutant
4
+ module Result
5
+ # Write result JSON to .mutant/results/
6
+ class JSONWriter
7
+ include Anima.new(:env, :result)
8
+
9
+ RESULTS_DIR = '.mutant/results'
10
+
11
+ # Write result JSON file
12
+ #
13
+ # @return [Pathname]
14
+ def call
15
+ dir = env.world.pathname.new(RESULTS_DIR)
16
+ dir.mkpath
17
+
18
+ path = dir.join("#{SESSION_ID}.json")
19
+ path.write(json)
20
+
21
+ path
22
+ end
23
+
24
+ private
25
+
26
+ def json
27
+ JSON.generate(Session::JSON.dump(session).from_right)
28
+ end
29
+
30
+ def session
31
+ Session.new(
32
+ killtime: result.killtime,
33
+ mutant_version: VERSION,
34
+ pid: env.world.process.pid,
35
+ ruby_version: RUBY_VERSION,
36
+ runtime: result.runtime,
37
+ session_id: SESSION_ID,
38
+ subject_results: result.subject_results
39
+ )
40
+ end
41
+ end # JSONWriter
42
+ end # Result
43
+ end # Mutant
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutant
4
+ module Result
5
+ # Serializable process status
6
+ #
7
+ # Replaces Process::Status in the result tree with a
8
+ # round-trippable value object.
9
+ class ProcessStatus
10
+ include Anima.new(:exitstatus)
11
+
12
+ # Build from a Process::Status object
13
+ #
14
+ # @param [Process::Status] process_status
15
+ #
16
+ # @return [ProcessStatus]
17
+ def self.from_process_status(process_status)
18
+ new(exitstatus: process_status.exitstatus)
19
+ end
20
+
21
+ # Stable inspect without memory address for use in user-facing output
22
+ #
23
+ # @return [String]
24
+ def inspect
25
+ "#<#{self.class.name} exitstatus=#{exitstatus}>"
26
+ end
27
+
28
+ # Test for successful exit
29
+ #
30
+ # @return [Boolean]
31
+ def success?
32
+ exitstatus.equal?(0)
33
+ end
34
+ JSON = Transform::JSON.for_anima(self)
35
+ end # ProcessStatus
36
+ end # Result
37
+ end # Mutant
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutant
4
+ module Result
5
+ # Top-level result object containing session metadata and subject results
6
+ class Session
7
+ # Extract timestamp from UUIDv7 session_id
8
+ module Timestamp
9
+ # @return [Time]
10
+ def timestamp
11
+ ms = session_id.delete('-')[0, 12].to_i(16)
12
+ Time.at(ms / 1000.0).utc
13
+ end
14
+ end
15
+
16
+ include Timestamp, Anima.new(
17
+ :killtime,
18
+ :mutant_version,
19
+ :pid,
20
+ :ruby_version,
21
+ :runtime,
22
+ :session_id,
23
+ :subject_results
24
+ )
25
+
26
+ dump = Transform::Success.new(
27
+ block: lambda do |object|
28
+ {
29
+ 'killtime' => object.killtime,
30
+ 'mutant_version' => object.mutant_version,
31
+ 'pid' => object.pid,
32
+ 'ruby_version' => object.ruby_version,
33
+ 'runtime' => object.runtime,
34
+ 'session_id' => object.session_id,
35
+ 'subject_results' => object.subject_results.map { |subject_result| Subject::JSON.dump(subject_result).from_right }
36
+ }
37
+ end
38
+ )
39
+
40
+ load = Transform::Sequence.new(
41
+ steps: [
42
+ Transform::Hash.new(
43
+ required: [
44
+ Transform::Hash::Key.new(value: 'killtime', transform: Transform::FLOAT),
45
+ Transform::Hash::Key.new(value: 'mutant_version', transform: Transform::STRING),
46
+ Transform::Hash::Key.new(value: 'pid', transform: Transform::INTEGER),
47
+ Transform::Hash::Key.new(value: 'ruby_version', transform: Transform::STRING),
48
+ Transform::Hash::Key.new(value: 'runtime', transform: Transform::FLOAT),
49
+ Transform::Hash::Key.new(value: 'session_id', transform: Transform::STRING),
50
+ Transform::Hash::Key.new(value: 'subject_results', transform: Transform::Array.new(transform: Subject::JSON.load_transform))
51
+ ],
52
+ optional: []
53
+ ),
54
+ Transform::Hash::Symbolize.new,
55
+ Transform::Success.new(block: method(:new).to_proc)
56
+ ]
57
+ )
58
+
59
+ JSON = Transform::JSON.new(dump_transform: dump, load_transform: load)
60
+
61
+ end # Session
62
+ end # Result
63
+ end # Mutant
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutant
4
+ module Result
5
+ # Test result
6
+ class Test
7
+ include Anima.new(:job_index, :passed, :runtime, :output)
8
+
9
+ alias_method :success?, :passed
10
+
11
+ class VoidValue < self
12
+ include Singleton
13
+
14
+ # Initialize object
15
+ #
16
+ # @return [undefined]
17
+ def initialize
18
+ super(
19
+ job_index: nil,
20
+ output: '',
21
+ passed: false,
22
+ runtime: 0.0
23
+ )
24
+ end
25
+ end # VoidValue
26
+
27
+ JSON = Transform::JSON.for_anima(self)
28
+ end # Test
29
+ end # Result
30
+ end # Mutant