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,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutato
4
+ # `shown` is where the mutant is reported; offset and bytes are what it replaces.
5
+ Change = Data.define(:genre, :node, :shown, :offset, :bytes, :replacement, :description) do
6
+ def self.whole(node, replacement, description)
7
+ location = node.location
8
+ new(
9
+ genre: nil,
10
+ node:,
11
+ shown: location,
12
+ offset: location.start_offset,
13
+ bytes: location.length,
14
+ replacement:,
15
+ description:
16
+ )
17
+ end
18
+
19
+ def at(location)
20
+ with(shown: location, offset: location.start_offset, bytes: location.length)
21
+ end
22
+
23
+ def covering(extent)
24
+ offset, bytes = extent
25
+ with(offset:, bytes:)
26
+ end
27
+
28
+ def original(source)
29
+ source.byteslice(offset, bytes)
30
+ end
31
+
32
+ # Coverage marks a statement's first line only: continuation lines go by their statement.
33
+ def where(cursor)
34
+ span = (cursor.statement || node).location
35
+ {
36
+ line: shown.start_line,
37
+ col: shown.start_column + 1,
38
+ start_line: span.start_line,
39
+ end_line: span.end_line,
40
+ outer_lines: cursor.outer_lines(node)
41
+ }
42
+ end
43
+
44
+ def what(source)
45
+ { genre:, offset:, bytes:, original: original(source), replacement:, description: }
46
+ end
47
+ end
48
+ public_constant :Change
49
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutato
4
+ # `exit!` throughout: a child must not run the parent's at_exit handlers.
5
+ class Child
6
+ CHUNK = 65_536
7
+ private_constant :CHUNK
8
+
9
+ def self.run(timeout: nil, log: nil, &)
10
+ new(timeout, log).run(&)
11
+ end
12
+
13
+ # Whatever the block raises, the parent gets a result.
14
+ def self.attempt
15
+ yield
16
+ rescue SoftTimeout
17
+ { outcome: :timeout }
18
+ rescue Exception => error
19
+ {
20
+ outcome: :crashed,
21
+ detail: "#{error.class}: #{error.message}",
22
+ backtrace: Array(error.backtrace).first(5)
23
+ }
24
+ end
25
+
26
+ # A signal death (segfault, SIGTERM) counts against the mutant until confirmed.
27
+ def self.verdict(data, status)
28
+ return { outcome: :caught, failing: nil, signal: status.termsig } if status.signaled?
29
+ return { outcome: :crashed, detail: "exit status #{status.exitstatus}" } if data.empty?
30
+
31
+ Marshal.load(data)
32
+ end
33
+
34
+ def initialize(timeout, log)
35
+ @timeout = timeout
36
+ @log = log
37
+ @reader = nil
38
+ @pid = nil
39
+ end
40
+
41
+ # Named, not anonymous: Ruby 3.3.0 rejects `&` forwarded from inside a block.
42
+ def run(&block)
43
+ Mutato.config.run_hooks(:before_fork)
44
+ # A project may set default_internal; Marshal's bytes must not be transcoded.
45
+ @reader, writer = IO.pipe(binmode: true)
46
+ @pid = fork { work(writer, &block) }
47
+ collect(writer)
48
+ ensure
49
+ sweep
50
+ end
51
+
52
+ private
53
+
54
+ # A process group of its own: what the tests start and leave running dies with it.
55
+ def work(writer, &)
56
+ Process.setpgid(0, 0)
57
+ @reader.close
58
+ settle
59
+ deliver(writer, Child.attempt(&))
60
+ end
61
+
62
+ # Not the parent's INT/TERM handlers: a mutant that signals itself must end only the child.
63
+ def settle
64
+ %w[INT TERM].each { |signal| trap(signal, "DEFAULT") }
65
+ Mutato.config.run_hooks(:after_fork)
66
+ redirect
67
+ end
68
+
69
+ def redirect
70
+ STDIN.reopen(File::NULL)
71
+ log_output if @log
72
+ end
73
+
74
+ # The streams, not $stdout: a project's replacement object still writes to them.
75
+ def log_output
76
+ STDOUT.reopen(@log, "w")
77
+ STDERR.reopen(STDOUT)
78
+ STDOUT.sync = true
79
+ $stdout = STDOUT
80
+ $stderr = STDERR
81
+ end
82
+
83
+ def deliver(writer, result)
84
+ writer.write(Marshal.dump(result))
85
+ rescue SystemCallError
86
+ # The parent is gone, and there is no one to tell; still no at_exit handlers.
87
+ ensure
88
+ writer.close
89
+ STDOUT.flush
90
+ exit! 0
91
+ end
92
+
93
+ def collect(writer)
94
+ writer.close
95
+ data = +""
96
+ status = read_until_exit(data, @timeout && (Clock.now + @timeout))
97
+ status ? Child.verdict(data, status) : killed
98
+ end
99
+
100
+ # To the child's exit, not EOF: a process the tests fork holds the pipe. nil past the deadline.
101
+ def read_until_exit(data, deadline)
102
+ loop do
103
+ status = exited
104
+ drain(data)
105
+ return status if status || Clock.left(deadline)&.zero?
106
+
107
+ @reader.wait_readable(0.05)
108
+ end
109
+ end
110
+
111
+ def exited
112
+ Process.wait2(@pid, Process::WNOHANG)&.last
113
+ end
114
+
115
+ def drain(data)
116
+ while (chunk = @reader.read_nonblock(CHUNK, exception: false)).is_a?(String)
117
+ data << chunk
118
+ end
119
+ end
120
+
121
+ def killed
122
+ Process.kill("KILL", @pid)
123
+ Process.wait(@pid)
124
+ File.write(@log, "\nmutato: killed after #{@timeout.round(1)} s\n", mode: "a") if @log
125
+ { outcome: :timeout }
126
+ end
127
+
128
+ def sweep
129
+ @reader&.close
130
+ Process.kill("KILL", -@pid) if @pid
131
+ rescue Errno::ESRCH, Errno::EPERM
132
+ nil
133
+ end
134
+ end
135
+ end
data/lib/mutato/cli.rb ADDED
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "abort"
4
+ require_relative "annotations"
5
+ require_relative "baseline"
6
+ require_relative "boot"
7
+ require_relative "campaign"
8
+ require_relative "child"
9
+ require_relative "clock"
10
+ require_relative "console"
11
+ require_relative "coverage_listener"
12
+ require_relative "covering"
13
+ require_relative "diff"
14
+ require_relative "equivalence"
15
+ require_relative "experience"
16
+ require_relative "flags"
17
+ require_relative "group_hooks"
18
+ require_relative "hits"
19
+ require_relative "measure"
20
+ require_relative "mode"
21
+ require_relative "options"
22
+ require_relative "output"
23
+ require_relative "report"
24
+ require_relative "rspec_adapter"
25
+ require_relative "run"
26
+ require_relative "selection"
27
+ require_relative "session"
28
+ require_relative "soft_timeout"
29
+ require_relative "suite"
30
+ require_relative "trial"
31
+ require_relative "watchdog"
32
+
33
+ module Mutato
34
+ class CLI
35
+ USAGE = <<~USAGE
36
+ usage: mutato list|run|control PATH... [options]
37
+ list the mutants that would be tried, without running anything
38
+ run baseline, then every mutant; survivors are the output
39
+ control reinstall every method unchanged; all must come out missed
40
+ options:
41
+ -h, --help -v, --version this text, the version
42
+ --spec ARGS rspec arguments: paths, --tag, ... (default spec)
43
+ --config FILE hooks and skips (default .mutato.rb)
44
+ --diff FILE|- only mutate lines a unified diff adds or changes
45
+ --format github|plain survivors as annotations or path:line:col lines
46
+ --limit N --sample N --genre a,b --only ID,ID
47
+ --out DIR (default mutato.out) --timeout-min SECONDS (default 10)
48
+ USAGE
49
+ MODES = { "run" => Mode::Run, "control" => Mode::Control }.freeze
50
+ COMMANDS = ["list", *MODES.keys].freeze
51
+ private_constant :USAGE, :MODES, :COMMANDS
52
+
53
+ # Returns the exit code; an Abort from anywhere, flag parsing included, ends here.
54
+ def self.call(argv, console: Console.new)
55
+ new(argv, console:).call
56
+ rescue Abort => error
57
+ console.say(error.message)
58
+ error.code
59
+ ensure
60
+ console.flush
61
+ end
62
+
63
+ def initialize(argv, console:)
64
+ @console = console
65
+ # Loaded test code may rewrite ARGV.
66
+ argv = argv.dup
67
+ @options = parse(argv)
68
+ @command = argv.shift
69
+ @paths = argv.freeze
70
+ end
71
+
72
+ def call
73
+ return show(info) if info
74
+
75
+ prepare
76
+ dispatch(Selection.new(@paths, @options))
77
+ end
78
+
79
+ private
80
+
81
+ def info
82
+ return USAGE if @options.help
83
+
84
+ "mutato #{VERSION}" if @options.version
85
+ end
86
+
87
+ def prepare
88
+ check_paths
89
+ load_config
90
+ ARGV.clear
91
+ end
92
+
93
+ # OptionParser reports a bad flag by raising; the user gets one line.
94
+ def parse(argv)
95
+ Options.parse(argv)
96
+ rescue OptionParser::ParseError => error
97
+ @console.die("#{error.message}; mutato --help lists the options")
98
+ end
99
+
100
+ def show(text)
101
+ @console.out(text)
102
+ 0
103
+ end
104
+
105
+ def check_paths
106
+ @console.die(USAGE) unless COMMANDS.include?(@command) && @paths.any?
107
+ missing = @paths.reject { |path| File.exist?(path) }
108
+ @console.die("no such path: #{missing.join(", ")}") if missing.any?
109
+ end
110
+
111
+ def load_config
112
+ named = @options.config
113
+ config = named || ".mutato.rb"
114
+ exists = File.exist?(config)
115
+ @console.die("no such config: #{config}") if named && !exists
116
+ load config if exists
117
+ end
118
+
119
+ def dispatch(selection)
120
+ return list(selection) if @command == "list"
121
+
122
+ Session.new(MODES.fetch(@command), selection, @console).call
123
+ end
124
+
125
+ def list(selection)
126
+ mutations = selection.chosen(selection.mutations)
127
+ print_listing(mutations)
128
+ @console.say(*selection.summary(mutations))
129
+ 0
130
+ end
131
+
132
+ # On stdout, and out before the summary follows on stderr.
133
+ def print_listing(mutations)
134
+ mutations.each { |mutation| @console.out(mutation.listing) }
135
+ @console.flush
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutato
4
+ # Seconds from a clock the system never sets back.
5
+ module Clock
6
+ module_function
7
+
8
+ def now
9
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
10
+ end
11
+
12
+ def since(started)
13
+ now - started
14
+ end
15
+
16
+ # Seconds until the deadline, never negative; nil without one.
17
+ def left(deadline)
18
+ [deadline - now, 0].max if deadline
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutato
4
+ class Config
5
+ Skip = Data.define(:pattern, :reason)
6
+ private_constant :Skip
7
+
8
+ attr_reader :hooks
9
+
10
+ def initialize
11
+ @hooks = Hash.new { |hash, name| hash[name] = [] }
12
+ @skips = []
13
+ @arid = Set.new
14
+ end
15
+
16
+ def run_hooks(name)
17
+ @hooks.fetch(name, []).each(&:call)
18
+ end
19
+
20
+ # A String names one method exactly; a Regexp matches the qualified name.
21
+ def skip(pattern, reason)
22
+ pattern = /\A#{Regexp.escape(pattern)}\z/ if pattern.is_a?(String)
23
+ @skips << Skip.new(pattern:, reason:)
24
+ end
25
+
26
+ def skip_reason(qualified)
27
+ @skips.find { |skip| skip.pattern.match?(qualified) }
28
+ &.reason
29
+ end
30
+
31
+ def arid(*names)
32
+ @arid.merge(names.map(&:to_s))
33
+ end
34
+
35
+ def arid?(name)
36
+ @arid.include?(name.to_s)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "abort"
4
+
5
+ module Mutato
6
+ # The real streams, duped at start: the project under test may replace $stdout.
7
+ class Console
8
+ def initialize(out: $stdout.dup, err: $stderr.dup)
9
+ @out = out
10
+ @err = err
11
+ @err.sync = true
12
+ end
13
+
14
+ # Not Kernel#warn: RUBYOPT=-W0 silences that.
15
+ def say(*lines)
16
+ lines.each { |line| @err.puts(line) }
17
+ end
18
+
19
+ def out(line)
20
+ @out.puts(line)
21
+ end
22
+
23
+ # CLI.call reports it and returns the code.
24
+ def die(message, code: 1)
25
+ raise Abort.new(message, code:)
26
+ end
27
+
28
+ # Stderr is unbuffered already.
29
+ def flush
30
+ @out.flush
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutato
4
+ class CoverageListener
5
+ Tally = Struct.new(:line_map, :durations, :failed, :seen, :began) do
6
+ def self.fresh
7
+ new(Hash.new { |hash, key| hash[key] = [] }, {}, [], Set.new, Clock.now)
8
+ end
9
+
10
+ def finish(example, seconds, hits)
11
+ id = example.id
12
+ durations[id] = seconds
13
+ failed << id if example.execution_result.status == :failed
14
+ hits.each { |key| line_map[key] << id }
15
+ end
16
+
17
+ # Read right after the run: its time less the tests' is the hooks'. A Hash with a
18
+ # default block cannot be marshalled.
19
+ def to_h
20
+ hooks = [Clock.since(began) - durations.values.sum, 0].max
21
+ super.except(:began).merge(line_map: line_map.to_a.to_h, hooks:)
22
+ end
23
+ end
24
+ public_constant :Tally
25
+ end
26
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "coverage"
4
+ require_relative "coverage_listener/tally"
5
+
6
+ module Mutato
7
+ # Each read clears the counters, so one read per example gives exactly its lines.
8
+ class CoverageListener
9
+ EVENTS = %i[example_group_started example_started example_finished].freeze
10
+ public_constant :EVENTS
11
+
12
+ attr_reader :tally
13
+
14
+ def initialize(prefixes)
15
+ @prefixes = prefixes
16
+ @tally = Tally.fresh
17
+ @hooks = GroupHooks.new
18
+ @started = Clock.now
19
+ end
20
+
21
+ def example_group_started(_notification)
22
+ @hooks.credit_suite(@tally.line_map) { read_hits }
23
+ @hooks.expect_hooks
24
+ end
25
+
26
+ def example_started(notification)
27
+ @started = Clock.now
28
+ @hooks.credit(notification.example.example_group, @tally.line_map) { read_hits }
29
+ end
30
+
31
+ def example_finished(notification)
32
+ @tally.finish(notification.example, Clock.since(@started), read_hits)
33
+ end
34
+
35
+ private
36
+
37
+ def read_hits
38
+ Coverage.result(stop: false, clear: true).flat_map do |path, data|
39
+ next [] unless path.start_with?(*@prefixes)
40
+
41
+ @tally.seen << path
42
+ Hits.keys(path, data)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutato
4
+ class Covering
5
+ def initialize(mutation, baseline)
6
+ @mutation = mutation
7
+ @baseline = baseline
8
+ @path = File.expand_path(mutation.file)
9
+ end
10
+
11
+ def ids
12
+ ids = stored? ? [] : @baseline.covering(@path, lines)
13
+ @baseline.prioritize(ids, @mutation.file)
14
+ end
15
+
16
+ def absence
17
+ return :unloaded unless @baseline.loaded?(@path)
18
+ # An endless def gets no line event when called.
19
+ return :uncovered if @mutation.subject.node.equal_loc
20
+
21
+ if @baseline.booted?(@path, stored? ? holding : lines)
22
+ :"load-time"
23
+ else
24
+ :uncovered
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ # Blocks run later than their method: outside one, skip its lines; inside, use its statement.
31
+ def lines
32
+ span = @mutation.span.to_a
33
+ @mutation.outer_lines ? span : span - @mutation.subject.block_lines
34
+ end
35
+
36
+ # All its lines outside blocks: a multi-line literal's line event is on its first element.
37
+ def holding
38
+ @mutation.outer_lines.to_a - @mutation.subject.block_lines
39
+ end
40
+
41
+ def stored?
42
+ @mutation.outer_lines && @baseline.covering(@path, holding).empty?
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutato
4
+ class Diff
5
+ def self.read(option)
6
+ new(option == "-" ? $stdin.read : File.read(option))
7
+ end
8
+
9
+ # `+++ b/lib/a.rb`, maybe a tab and timestamp after (diff -u, hg); c/ i/ o/ w/ from git.
10
+ def self.path(row)
11
+ row.delete_prefix("+++ ").partition("\t").first.strip.sub(%r{\A[abciow]/}, "")
12
+ end
13
+
14
+ def initialize(text)
15
+ @lines = Hash.new { |hash, file| hash[file] = [] }
16
+ @file = nil
17
+ @line = 0
18
+ # Only the paths matter; a stray byte elsewhere must not stop the reading.
19
+ text.dup.force_encoding(Encoding::UTF_8).scrub.each_line { |row| take(row) }
20
+ end
21
+
22
+ # Both paths from the current directory: `lib`, `./lib` and an absolute path agree.
23
+ def touches?(file, range)
24
+ @lines.fetch(File.expand_path(file), []).any? { |line| range.cover?(line) }
25
+ end
26
+
27
+ private
28
+
29
+ def take(row)
30
+ if row.start_with?("+++ ") then @file = File.expand_path(Diff.path(row))
31
+ elsif (hunk = row.match(/\A@@ -\d+(?:,\d+)? \+(\d+)/)) then @line = Integer(hunk[1], 10)
32
+ elsif row.start_with?("+") then added
33
+ elsif row.start_with?(" ") then @line += 1
34
+ end
35
+ end
36
+
37
+ def added
38
+ @lines[@file] << @line
39
+ @line += 1
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Mutato
6
+ class Emitter
7
+ attr_reader :source, :mutations
8
+
9
+ def initialize(file, source)
10
+ @file = file
11
+ @source = source
12
+ @seen = Set.new
13
+ @mutations = []
14
+ end
15
+
16
+ def add(change, subject, cursor)
17
+ replacement = change.replacement
18
+ return if change.original(@source) == replacement
19
+ return unless @seen.add?([change.offset, change.bytes, replacement])
20
+
21
+ @mutations << Mutation.new(
22
+ id: id_for(change, subject),
23
+ file: @file,
24
+ subject:,
25
+ **change.where(cursor),
26
+ **change.what(@source)
27
+ )
28
+ end
29
+
30
+ private
31
+
32
+ # The offset within the def: distinct for two `+` on a line, stable when other code moves.
33
+ def id_for(change, subject)
34
+ within = change.offset - subject.node.location.start_offset
35
+ parts = [
36
+ @file,
37
+ subject.qualified,
38
+ change.genre,
39
+ within,
40
+ change.original(@source),
41
+ change.replacement
42
+ ]
43
+ Digest::SHA1.hexdigest(parts.join("\0"))[0, 12]
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutato
4
+ module Equivalence
5
+ # BasicObject's is empty; Struct's, given nothing, sets members to the nil they hold.
6
+ EMPTY = [BasicObject.instance_method(:initialize), Struct.instance_method(:initialize)].freeze
7
+ private_constant :EMPTY
8
+
9
+ module_function
10
+
11
+ # In a module, where `super` goes depends on the class that includes it.
12
+ def equivalent?(mutation)
13
+ subject = mutation.subject
14
+ empty_super?(mutation) && loaded?(subject) &&
15
+ EMPTY.include?(Installer.current(subject)&.super_method)
16
+ end
17
+
18
+ # A class only the tests load is unknown here; its mutant runs, and the child loads it.
19
+ def loaded?(subject)
20
+ name = subject.owner_name
21
+ name.empty? || Object.const_defined?(name)
22
+ end
23
+
24
+ # A bare `super` with parameters passes them on.
25
+ def empty_super?(mutation)
26
+ text = mutation.original
27
+ text == "super()" || (text == "super" && mutation.subject.node.parameters.nil?)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutato
4
+ # culprits: per test sequence, the test that failed it unmutated, or nil when it passed.
5
+ Experience = Struct.new(:flaky, :culprits) do
6
+ def self.fresh
7
+ new(Set.new, {})
8
+ end
9
+ end
10
+ public_constant :Experience
11
+ end