mutato 0.1.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 (70) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +13 -0
  3. data/LICENSE +21 -0
  4. data/README.md +218 -0
  5. data/exe/mutato +21 -0
  6. data/lib/mutato/abort.rb +12 -0
  7. data/lib/mutato/annotations.rb +65 -0
  8. data/lib/mutato/arid.rb +104 -0
  9. data/lib/mutato/baseline.rb +51 -0
  10. data/lib/mutato/boot.rb +99 -0
  11. data/lib/mutato/campaign.rb +52 -0
  12. data/lib/mutato/change.rb +49 -0
  13. data/lib/mutato/child.rb +135 -0
  14. data/lib/mutato/cli.rb +138 -0
  15. data/lib/mutato/clock.rb +21 -0
  16. data/lib/mutato/config.rb +39 -0
  17. data/lib/mutato/console.rb +33 -0
  18. data/lib/mutato/coverage_listener/tally.rb +26 -0
  19. data/lib/mutato/coverage_listener.rb +46 -0
  20. data/lib/mutato/covering.rb +45 -0
  21. data/lib/mutato/diff.rb +42 -0
  22. data/lib/mutato/emitter.rb +46 -0
  23. data/lib/mutato/equivalence.rb +30 -0
  24. data/lib/mutato/experience.rb +11 -0
  25. data/lib/mutato/flags.rb +58 -0
  26. data/lib/mutato/generation.rb +36 -0
  27. data/lib/mutato/generator.rb +10 -0
  28. data/lib/mutato/genres/conditions.rb +70 -0
  29. data/lib/mutato/genres/elements.rb +49 -0
  30. data/lib/mutato/genres/genre.rb +23 -0
  31. data/lib/mutato/genres/operators.rb +81 -0
  32. data/lib/mutato/genres/statements.rb +122 -0
  33. data/lib/mutato/genres/swaps.rb +58 -0
  34. data/lib/mutato/genres/values.rb +41 -0
  35. data/lib/mutato/genres.rb +53 -0
  36. data/lib/mutato/group_hooks.rb +45 -0
  37. data/lib/mutato/hits.rb +18 -0
  38. data/lib/mutato/installer/header.rb +40 -0
  39. data/lib/mutato/installer.rb +133 -0
  40. data/lib/mutato/measure.rb +37 -0
  41. data/lib/mutato/mode/control.rb +43 -0
  42. data/lib/mutato/mode/run.rb +33 -0
  43. data/lib/mutato/mode.rb +26 -0
  44. data/lib/mutato/mutation.rb +85 -0
  45. data/lib/mutato/nodes.rb +150 -0
  46. data/lib/mutato/options.rb +33 -0
  47. data/lib/mutato/output.rb +76 -0
  48. data/lib/mutato/privates.rb +46 -0
  49. data/lib/mutato/report.rb +36 -0
  50. data/lib/mutato/rspec_adapter/progress.rb +20 -0
  51. data/lib/mutato/rspec_adapter/result.rb +15 -0
  52. data/lib/mutato/rspec_adapter.rb +129 -0
  53. data/lib/mutato/run.rb +44 -0
  54. data/lib/mutato/selection.rb +100 -0
  55. data/lib/mutato/session.rb +53 -0
  56. data/lib/mutato/skips.rb +54 -0
  57. data/lib/mutato/soft_timeout.rb +8 -0
  58. data/lib/mutato/subject.rb +21 -0
  59. data/lib/mutato/subjects/scope.rb +44 -0
  60. data/lib/mutato/subjects.rb +67 -0
  61. data/lib/mutato/suite.rb +6 -0
  62. data/lib/mutato/trial.rb +93 -0
  63. data/lib/mutato/usage.rb +41 -0
  64. data/lib/mutato/version.rb +6 -0
  65. data/lib/mutato/visitor/cursor.rb +28 -0
  66. data/lib/mutato/visitor/walk.rb +17 -0
  67. data/lib/mutato/visitor.rb +88 -0
  68. data/lib/mutato/watchdog.rb +54 -0
  69. data/lib/mutato.rb +55 -0
  70. metadata +128 -0
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Mutato
6
+ module Flags
7
+ TABLE = [
8
+ [%w[-h --help], :help, TrueClass],
9
+ [%w[-v --version], :version, TrueClass],
10
+ ["--spec ARGS", :spec, String],
11
+ ["--config FILE", :config, String],
12
+ ["--diff FILE", :diff, String],
13
+ ["--format NAME", :format, String],
14
+ ["--limit N", :limit, Integer],
15
+ ["--sample N", :sample, Integer],
16
+ ["--genre LIST", :genres, Array],
17
+ ["--only IDS", :only, Array],
18
+ ["--out DIR", :out, String],
19
+ ["--timeout-min S", :timeout_min, Float]
20
+ ].freeze
21
+ DEFAULTS = { spec: "spec", out: "mutato.out", timeout_min: 10.0 }.freeze
22
+ GENRES = %w[statement condition relational arithmetic swap-method value element].freeze
23
+ private_constant :TABLE, :DEFAULTS, :GENRES
24
+
25
+ module_function
26
+
27
+ def parse(argv)
28
+ values = Options.members.to_h { |member| [member, DEFAULTS[member]] }
29
+ parser(values).parse!(argv)
30
+ check(values)
31
+ Options.new(**values)
32
+ end
33
+
34
+ # Values OptionParser takes but mutato cannot use, reported the way it reports its own.
35
+ def check(values)
36
+ check_genres(values[:genres].to_a)
37
+ negative = %i[limit sample].find { |key| values[key]&.negative? }
38
+ raise OptionParser::InvalidArgument, "--#{negative} #{values[negative]}" if negative
39
+ end
40
+
41
+ def check_genres(genres)
42
+ unknown = genres - GENRES
43
+ wrong = "--genre #{unknown.join(",")} (genres: #{GENRES.join(", ")})"
44
+ raise OptionParser::InvalidArgument, wrong if unknown.any?
45
+ end
46
+
47
+ def parser(values)
48
+ parser = OptionParser.new
49
+ TABLE.each { |flag| define(parser, values, flag) }
50
+ parser
51
+ end
52
+
53
+ def define(parser, values, flag)
54
+ switches, key, type = flag
55
+ parser.on(*switches, type) { |value| values[key] = value }
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Mutato
6
+ class Generation
7
+ def initialize(file, source)
8
+ @file = file
9
+ @source = source
10
+ @tree = Prism.parse(source).value
11
+ @emitter = Emitter.new(file, source)
12
+ end
13
+
14
+ def generator
15
+ subjects = found
16
+ kept, dropped = mutate(subjects).partition { |mutation| parses?(mutation) }
17
+ Generator.new(file: @file, source: @source, subjects:, mutations: kept, dropped:)
18
+ end
19
+
20
+ private
21
+
22
+ def found
23
+ usage = Usage.new(@tree)
24
+ Subjects.find(@file, @tree).map { |subject| usage.demoted(subject) }
25
+ end
26
+
27
+ def mutate(subjects)
28
+ subjects.reject(&:skip).each { |subject| Visitor.new(subject, @emitter).run }
29
+ @emitter.mutations
30
+ end
31
+
32
+ def parses?(mutation)
33
+ Prism.parse(mutation.mutated_source(@source)).errors.empty?
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutato
4
+ Generator = Data.define(:file, :source, :subjects, :mutations, :dropped) do
5
+ def self.read(file)
6
+ Generation.new(file, File.read(file)).generator
7
+ end
8
+ end
9
+ public_constant :Generator
10
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "genre"
5
+
6
+ module Mutato
7
+ module Genres
8
+ class Conditions < Genre
9
+ CONNECTORS = { Prism::AndNode => %w[|| or], Prism::OrNode => %w[&& and] }.freeze
10
+ LITERALS = [Prism::TrueNode, Prism::FalseNode].freeze
11
+ private_constant :CONNECTORS, :LITERALS
12
+
13
+ # `warn(...) unless ok`: a compound of arid parts is arid, children included.
14
+ def branch(node)
15
+ return :stop if Arid.arid_node?(node)
16
+
17
+ replace(node.predicate, literals(node))
18
+ spare_guard(node)
19
+ end
20
+
21
+ # The literal that skips the body: `while false`, `until true`.
22
+ def loop(node)
23
+ replace(node.predicate, node.is_a?(Prism::WhileNode) ? %w[false] : %w[true])
24
+ end
25
+
26
+ def connector(node)
27
+ operator = node.operator_loc
28
+ spelled = operator.slice
29
+ replacement = CONNECTORS.fetch(node.class)[spelled.match?(/\A\w/) ? 1 : 0]
30
+ emit(
31
+ :condition,
32
+ Change.whole(
33
+ node,
34
+ replacement,
35
+ "replace `#{spelled}` with `#{replacement}`"
36
+ ).at(operator)
37
+ )
38
+ end
39
+
40
+ private
41
+
42
+ def replace(predicate, literals)
43
+ return if predicate.nil? || Nodes.one_of?(predicate, LITERALS)
44
+
45
+ shown = Nodes.compact(predicate.slice)
46
+ literals.each do |literal|
47
+ emit(
48
+ :condition,
49
+ Change.whole(predicate, literal, "replace condition `#{shown}` with #{literal}")
50
+ )
51
+ end
52
+ end
53
+
54
+ # The disabling literal repeats a deletion, or for a memo guard only skips a cache.
55
+ def literals(node)
56
+ disabled = visitor.walk.deleted.include?(node) || Nodes.memo_guard?(node)
57
+ return %w[true false] unless Nodes.else_less?(node) && disabled
58
+
59
+ node.is_a?(Prism::IfNode) ? %w[true] : %w[false]
60
+ end
61
+
62
+ # Deleting a guard's lone statement repeats a literal or the whole statement's deletion.
63
+ def spare_guard(node)
64
+ return unless Nodes.else_less?(node) && Nodes.single_statement(node)
65
+
66
+ visitor.walk.spared << node.statements
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "genre"
5
+
6
+ module Mutato
7
+ module Genres
8
+ # Newlines stay with a dropped element, so line numbers hold.
9
+ class Elements < Genre
10
+ MAX = 10
11
+ private_constant :MAX
12
+
13
+ # A lone element has nothing to drop it from; only its trailing comma would go.
14
+ def mutate(node)
15
+ elements = node.elements
16
+ return unless (2..MAX).cover?(elements.size)
17
+
18
+ elements.each_index do |index|
19
+ drop(elements, index) unless Nodes.nil_valued?(elements[index])
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def drop(elements, index)
26
+ from, to = range(elements, index)
27
+ element = elements[index]
28
+ newlines = "\n" * source.byteslice(from...to).count("\n")
29
+ change = Change.whole(element, newlines, "drop `#{Nodes.compact(element.slice)}`")
30
+ emit(:element, change.covering([from, to - from]))
31
+ end
32
+
33
+ # The last element goes from the previous one's end, trailing comma included.
34
+ def range(elements, index)
35
+ location = elements[index].location
36
+ following = elements[index + 1]
37
+ return [location.start_offset, following.location.start_offset] if following
38
+
39
+ stop = location.end_offset
40
+ [elements[index - 1].location.end_offset, stop + trailing_comma(stop).bytesize]
41
+ end
42
+
43
+ # Only a comma that ends the list: one before `&block` must stay.
44
+ def trailing_comma(offset)
45
+ source.byteslice(offset, 200).to_s.b[/\A\s*,(?=(?:\s|#[^\n]*)*[\]})])/].to_s
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutato
4
+ module Genres
5
+ class Genre
6
+ def initialize(visitor)
7
+ @visitor = visitor
8
+ end
9
+
10
+ private
11
+
12
+ attr_reader :visitor
13
+
14
+ def emit(genre, change)
15
+ visitor.emit(genre, change)
16
+ end
17
+
18
+ def source
19
+ visitor.source
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "genre"
5
+
6
+ module Mutato
7
+ module Genres
8
+ class Operators < Genre
9
+ RELATIONAL = {
10
+ "<" => %w[<= !=],
11
+ "<=" => %w[< ==],
12
+ ">" => %w[>= !=],
13
+ ">=" => %w[> ==],
14
+ "==" => %w[!=],
15
+ "!=" => %w[==]
16
+ }.freeze
17
+ # What a comparison gives when both sides are equal.
18
+ ALWAYS = { "<" => "false", "<=" => "true", ">" => "false", ">=" => "true" }.freeze
19
+ ARITHMETIC = { "+" => "-", "-" => "+", "*" => "/", "/" => "*" }.freeze
20
+ NON_COMMUTATIVE = %w[- / % **].freeze
21
+ private_constant :RELATIONAL, :ALWAYS, :ARITHMETIC, :NON_COMMUTATIVE
22
+
23
+ # A length is never negative, so `size > 0` is `size != 0`.
24
+ def self.neighbours(call, name)
25
+ same = name == ">" && Nodes.count_against_zero?(call)
26
+ RELATIONAL.fetch(name).reject { |operator| same && operator == "!=" }
27
+ end
28
+
29
+ def mutate(call)
30
+ name = call.name.to_s
31
+ relational(call, name) if RELATIONAL.key?(name)
32
+ arithmetic = ARITHMETIC[name]
33
+ operator(call, arithmetic, :arithmetic) if arithmetic
34
+ swap_operands(call) if NON_COMMUTATIVE.include?(name)
35
+ end
36
+
37
+ def compound(node)
38
+ operator = node.binary_operator.to_s
39
+ replacement = ARITHMETIC[operator]
40
+ return unless replacement
41
+
42
+ change = Change.whole(
43
+ node,
44
+ "#{replacement}=",
45
+ "replace `#{operator}=` with `#{replacement}=`"
46
+ )
47
+ emit(:arithmetic, change.at(node.binary_operator_loc))
48
+ end
49
+
50
+ private
51
+
52
+ def relational(call, name)
53
+ Operators.neighbours(call, name).each { |operator| operator(call, operator, :relational) }
54
+ literal = ALWAYS[name]
55
+ always(call, literal) if literal
56
+ end
57
+
58
+ def operator(call, replacement, genre)
59
+ change = Change.whole(call, replacement, "replace `#{call.name}` with `#{replacement}`")
60
+ emit(genre, change.at(call.message_loc))
61
+ end
62
+
63
+ def always(call, literal)
64
+ emit(
65
+ :relational,
66
+ Change.whole(call, literal, "replace `#{Nodes.compact(call.slice)}` with #{literal}")
67
+ )
68
+ end
69
+
70
+ def swap_operands(call)
71
+ name = call.name
72
+ lhs = call.receiver.slice
73
+ rhs = Nodes.sole_argument(call).slice
74
+ emit(
75
+ :arithmetic,
76
+ Change.whole(call, "(#{rhs}) #{name} (#{lhs})", "swap operands of `#{name}`")
77
+ )
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "genre"
5
+
6
+ module Mutato
7
+ module Genres
8
+ class Statements < Genre
9
+ LOOPS = [
10
+ Prism::WhileNode,
11
+ Prism::UntilNode,
12
+ Prism::ForNode,
13
+ Prism::BlockNode,
14
+ Prism::LambdaNode
15
+ ].freeze
16
+ # `n += 1` and `n ||= x` read n as well.
17
+ READS = [
18
+ Prism::LocalVariableReadNode,
19
+ Prism::LocalVariableOperatorWriteNode,
20
+ Prism::LocalVariableOrWriteNode,
21
+ Prism::LocalVariableAndWriteNode
22
+ ].freeze
23
+ EFFECTS = [
24
+ Prism::YieldNode,
25
+ Prism::SuperNode,
26
+ Prism::ForwardingSuperNode,
27
+ Prism::InstanceVariableWriteNode,
28
+ Prism::ClassVariableWriteNode,
29
+ Prism::GlobalVariableWriteNode
30
+ ].freeze
31
+ private_constant :LOOPS, :READS, :EFFECTS
32
+
33
+ # Computing it has no effect: literals, reads, and operators between them.
34
+ def self.pure?(value)
35
+ Nodes.each_node(value).all? do |node|
36
+ node.is_a?(Prism::CallNode) ? Nodes.binary_operator?(node) : !Nodes.one_of?(node, EFFECTS)
37
+ end
38
+ end
39
+
40
+ def self.around?(node, offset)
41
+ span = node.location
42
+ Nodes.one_of?(node, LOOPS) && (span.start_offset...span.end_offset).cover?(offset)
43
+ end
44
+
45
+ # Deleting these changes nothing a test can see.
46
+ def self.keep?(statement)
47
+ return true if Arid.arid_node?(statement) || Nodes.one_of?(statement, Nodes::LITERALS)
48
+
49
+ statement.is_a?(Prism::DefNode) || Nodes.memo_guard?(statement)
50
+ end
51
+
52
+ def mutate(node)
53
+ spared = visitor.walk.spared
54
+ return if spared.include?(node)
55
+
56
+ statements = node.body
57
+ statements.each do |statement|
58
+ consider(statement, statements.last) unless spared.include?(statement)
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def consider(statement, valued)
65
+ return visitor.walk.dead << statement if dead_store?(statement, valued)
66
+
67
+ delete(statement) unless Statements.keep?(statement)
68
+ end
69
+
70
+ def delete(statement)
71
+ visitor.walk.deleted << statement
72
+ change = Change.whole(
73
+ statement,
74
+ "nil",
75
+ "delete statement `#{Nodes.compact(statement.slice)}`"
76
+ )
77
+ visitor.with_statement(statement) do
78
+ emit(:statement, change.covering(Nodes.extent(statement, source)))
79
+ end
80
+ end
81
+
82
+ # A local store ending its list is the list's value; of ivars, only initialize's `@x = nil`.
83
+ def dead_store?(statement, valued)
84
+ case statement
85
+ when Prism::LocalVariableWriteNode then !statement.equal?(valued) && dead_local?(statement)
86
+ when Prism::InstanceVariableWriteNode then initial_nil?(statement)
87
+ when Prism::CallNode then Nodes.nil_store?(statement)
88
+ else false
89
+ end
90
+ end
91
+
92
+ def dead_local?(write)
93
+ unread_local?(write) && Statements.pure?(write.value)
94
+ end
95
+
96
+ def initial_nil?(write)
97
+ visitor.subject.name == :initialize && write.value.is_a?(Prism::NilNode)
98
+ end
99
+
100
+ # In a loop or block, a read earlier in the body sees the write on the next pass.
101
+ def unread_local?(write)
102
+ from = outermost_loop_start(write) || write.location.end_offset
103
+ reads_of(write.name).none? { |read| (from..).cover?(read.location.start_offset) }
104
+ end
105
+
106
+ def outermost_loop_start(write)
107
+ offset = write.location.start_offset
108
+ nodes = Nodes.each_node(visitor.subject.node)
109
+ starts = nodes.filter_map do |node|
110
+ node.location.start_offset if Statements.around?(node, offset)
111
+ end
112
+ starts.min
113
+ end
114
+
115
+ def reads_of(name)
116
+ Nodes.each_node(visitor.subject.node).select do |node|
117
+ Nodes.one_of?(node, READS) && node.name == name
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "genre"
5
+
6
+ module Mutato
7
+ module Genres
8
+ class Swaps < Genre
9
+ SWAPS = {
10
+ max: %i[min],
11
+ min: %i[max],
12
+ first: %i[last],
13
+ last: %i[first],
14
+ round: %i[floor ceil],
15
+ floor: %i[round ceil],
16
+ ceil: %i[round floor],
17
+ truncate: %i[round],
18
+ any?: %i[all? none?],
19
+ all?: %i[any?],
20
+ none?: %i[any?],
21
+ empty?: %i[any?]
22
+ }.freeze
23
+ FINDERS = %i[first last].freeze
24
+ private_constant :SWAPS, :FINDERS
25
+
26
+ # `first(id: x)` is a finder, and a receiverless `max` the method's own: not Array calls.
27
+ def self.swappable?(call)
28
+ return false if call.receiver.nil?
29
+
30
+ !(FINDERS.include?(call.name) && (call.arguments || call.block))
31
+ end
32
+
33
+ def mutate(call)
34
+ name = call.name
35
+ negation(call) if name == :! && call.receiver
36
+ replacements = SWAPS[name]
37
+ methods(call, replacements) if replacements && Swaps.swappable?(call)
38
+ end
39
+
40
+ private
41
+
42
+ def negation(call)
43
+ emit(
44
+ :condition,
45
+ Change.whole(call, call.receiver.slice, "drop `!` from `#{Nodes.compact(call.slice)}`")
46
+ )
47
+ end
48
+
49
+ def methods(call, replacements)
50
+ name = call.name
51
+ replacements.each do |replacement|
52
+ change = Change.whole(call, replacement.to_s, "replace `.#{name}` with `.#{replacement}`")
53
+ emit(:"swap-method", change.at(call.message_loc))
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "genre"
5
+
6
+ module Mutato
7
+ module Genres
8
+ class Values < Genre
9
+ GUESSES = {
10
+ Prism::ArrayNode => "[]",
11
+ Prism::HashNode => "{}",
12
+ Prism::StringNode => '""',
13
+ Prism::InterpolatedStringNode => '""',
14
+ Prism::IntegerNode => "0",
15
+ Prism::TrueNode => "true",
16
+ Prism::FalseNode => "true"
17
+ }.freeze
18
+ private_constant :GUESSES
19
+
20
+ def mutate(body)
21
+ # A def with a rescue: the statements are what the method returns.
22
+ body = body.statements if body.is_a?(Prism::BeginNode)
23
+ return unless body.is_a?(Prism::StatementsNode)
24
+
25
+ guesses(body.body.last).each do |value|
26
+ description = "replace body of #{visitor.subject} with #{value}"
27
+ emit(:value, Change.whole(body, value, description).covering(Nodes.extent(body, source)))
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def guesses(last)
34
+ return %w[true false] if visitor.subject.name.to_s.end_with?("?")
35
+
36
+ guessed = GUESSES.filter_map { |klass, value| value if last.is_a?(klass) }
37
+ ["nil", *guessed].uniq
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "genres/conditions"
5
+ require_relative "genres/elements"
6
+ require_relative "genres/operators"
7
+ require_relative "genres/statements"
8
+ require_relative "genres/swaps"
9
+ require_relative "genres/values"
10
+
11
+ module Mutato
12
+ module Genres
13
+ COLLECTIONS = [Prism::ArrayNode, Prism::HashNode, Prism::KeywordHashNode].freeze
14
+ COMPOUNDS = [
15
+ Prism::LocalVariableOperatorWriteNode,
16
+ Prism::InstanceVariableOperatorWriteNode,
17
+ Prism::ClassVariableOperatorWriteNode,
18
+ Prism::GlobalVariableOperatorWriteNode,
19
+ Prism::IndexOperatorWriteNode,
20
+ Prism::CallOperatorWriteNode,
21
+ Prism::ConstantOperatorWriteNode,
22
+ Prism::ConstantPathOperatorWriteNode
23
+ ].freeze
24
+ private_constant :COLLECTIONS, :COMPOUNDS
25
+
26
+ module_function
27
+
28
+ # :stop leaves the node's children alone.
29
+ def mutate(visitor, node)
30
+ case node
31
+ when Prism::IfNode, Prism::UnlessNode then Conditions.new(visitor).branch(node)
32
+ when Prism::WhileNode, Prism::UntilNode then Conditions.new(visitor).loop(node)
33
+ when Prism::AndNode, Prism::OrNode then Conditions.new(visitor).connector(node)
34
+ when Prism::CallNode then call(visitor, node)
35
+ else other(visitor, node)
36
+ end
37
+ end
38
+
39
+ def other(visitor, node)
40
+ return Elements.new(visitor).mutate(node) if Nodes.one_of?(node, COLLECTIONS)
41
+
42
+ Operators.new(visitor).compound(node) if Nodes.one_of?(node, COMPOUNDS)
43
+ end
44
+
45
+ # Nothing inside a logger call, or a loop that only logs, is mutated either.
46
+ def call(visitor, call)
47
+ return :stop if Arid.arid_node?(call)
48
+
49
+ Operators.new(visitor).mutate(call) if Nodes.binary_operator?(call)
50
+ Swaps.new(visitor).mutate(call)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutato
4
+ # Lines a before(:all) hook hits count for every example of its group.
5
+ class GroupHooks
6
+ def self.assign(ids, line_map)
7
+ yield.each { |key| line_map[key].concat(ids) }
8
+ end
9
+
10
+ def self.all_ids
11
+ RSpec.world.example_groups.flat_map(&:descendant_filtered_examples).map(&:id)
12
+ end
13
+
14
+ def initialize
15
+ @pending = false
16
+ @suite = true
17
+ @ids = {}
18
+ end
19
+
20
+ # At the first group: what before(:suite) ran, which every example runs after.
21
+ def credit_suite(line_map, &)
22
+ return unless @suite
23
+
24
+ @suite = false
25
+ GroupHooks.assign(GroupHooks.all_ids, line_map, &)
26
+ end
27
+
28
+ def expect_hooks
29
+ @pending = true
30
+ end
31
+
32
+ def credit(group, line_map, &)
33
+ return unless @pending
34
+
35
+ @pending = false
36
+ GroupHooks.assign(served(group), line_map, &)
37
+ end
38
+
39
+ private
40
+
41
+ def served(group)
42
+ @ids[group] ||= group.parent_groups.flat_map(&:descendant_filtered_examples).map(&:id).uniq
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutato
4
+ # Method start lines too, when coverage counts methods: an endless def gets no line hit.
5
+ module Hits
6
+ module_function
7
+
8
+ def keys(path, data)
9
+ lines = data.fetch(:lines).each_with_index.filter_map do |count, index|
10
+ "#{path}:#{index + 1}" if count&.positive?
11
+ end
12
+ methods = data.fetch(:methods, {}).filter_map do |(_, _, line), count|
13
+ "#{path}:#{line}" if count.positive?
14
+ end
15
+ lines + methods
16
+ end
17
+ end
18
+ end