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,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutato
4
+ class Report
5
+ def initialize(outcomes, mode, console)
6
+ @outcomes = outcomes
7
+ @mode = mode
8
+ @console = console
9
+ end
10
+
11
+ def show(started, flaky)
12
+ @console.say(
13
+ "\nresults: #{counts}",
14
+ format("%<wall>.0fs wall%<mean>s", wall: Clock.since(started), mean:)
15
+ )
16
+ @console.say("#{flaky.size} flaky tests fail with and without a mutation") if flaky.any?
17
+ @console.say(*@mode.summary(@outcomes))
18
+ end
19
+
20
+ private
21
+
22
+ def counts
23
+ groups = @outcomes.group_by { |outcome| outcome.fetch(:outcome) }
24
+ groups.map { |name, list| "#{name} #{list.size}" }
25
+ .join(", ")
26
+ end
27
+
28
+ def mean
29
+ seconds = @outcomes.map { |outcome| outcome.fetch(:seconds) }
30
+ .select(&:positive?)
31
+ return "" if seconds.empty?
32
+
33
+ format(", mean %<seconds>.2fs per executed mutant", seconds: seconds.sum / seconds.size)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutato
4
+ module RSpecAdapter
5
+ class Progress
6
+ attr_reader :ran, :current
7
+
8
+ def initialize
9
+ @ran = []
10
+ @current = nil
11
+ end
12
+
13
+ def example_started(notification)
14
+ @current = notification.example.id
15
+ @ran << @current
16
+ end
17
+ end
18
+ private_constant :Progress
19
+ end
20
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutato
4
+ module RSpecAdapter
5
+ Result = Data.define(:status, :outside, :failing, :ran) do
6
+ def outcome
7
+ return { outcome: :caught, detail: "error outside examples", ran: } if outside
8
+ return { outcome: :missed, ran: } if status.zero?
9
+
10
+ { outcome: :caught, failing:, ran: }
11
+ end
12
+ end
13
+ public_constant :Result
14
+ end
15
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "coverage"
4
+ require_relative "rspec_adapter/progress"
5
+ require_relative "rspec_adapter/result"
6
+
7
+ module Mutato
8
+ # RSpec is one per process, so this is a module, not a class.
9
+ module RSpecAdapter
10
+ module_function
11
+
12
+ def boot(args)
13
+ # Cached iseqs cannot carry coverage.
14
+ ENV["DISABLE_BOOTSNAP_COMPILE_CACHE"] ||= "1"
15
+ start_coverage
16
+ # Here, not at load: the project's bundle provides rspec.
17
+ require "rspec/core"
18
+ setup(RSpec::Core::Runner.new(RSpec::Core::ConfigurationOptions.new(args)))
19
+ end
20
+
21
+ # Before spec_helper, so files load instrumented; lines only, as method counters slow calls.
22
+ def start_coverage
23
+ Coverage.start(lines: true) unless Coverage.running?
24
+ end
25
+
26
+ def setup(runner)
27
+ @runner = runner
28
+ runner.setup($stderr, $stdout)
29
+ RSpec.configuration.backtrace_exclusion_patterns << %r{/mutato/}
30
+ end
31
+
32
+ def locations
33
+ RSpec.world.all_examples.to_h { |example| [example.id, example.metadata.fetch(:file_path)] }
34
+ end
35
+
36
+ # A SyntaxError sets rspec_is_quitting, any other load error wants_to_quit.
37
+ def load_failed?
38
+ world = RSpec.world
39
+ world.rspec_is_quitting || world.wants_to_quit
40
+ end
41
+
42
+ def current
43
+ @progress&.current
44
+ end
45
+
46
+ def stop
47
+ RSpec.world.wants_to_quit = true
48
+ end
49
+
50
+ # Child only.
51
+ def baseline(prefixes)
52
+ listener = CoverageListener.new(prefixes)
53
+ RSpec.configuration.reporter.register_listener(listener, *CoverageListener::EVENTS)
54
+ quiet(fail_fast: false)
55
+ Coverage.result(stop: false, clear: true)
56
+ Measure.new(status: run_all, **listener.tally.to_h)
57
+ end
58
+
59
+ # Child only. Stops at the first failure.
60
+ def run(ids)
61
+ prepare(ids)
62
+ Result.new(
63
+ status: run_all,
64
+ outside: RSpec.world.non_example_failure,
65
+ failing: first_failure,
66
+ ran: @progress.ran
67
+ )
68
+ end
69
+
70
+ def prepare(ids)
71
+ quiet(fail_fast: 1)
72
+ order(ids.each_with_index.to_h)
73
+ filter(ids.to_set)
74
+ watch
75
+ end
76
+
77
+ def watch
78
+ @progress = Progress.new
79
+ RSpec.configuration.reporter.register_listener(@progress, :example_started)
80
+ end
81
+
82
+ def run_all
83
+ @runner.run_specs(RSpec.world.ordered_example_groups)
84
+ end
85
+
86
+ def first_failure
87
+ RSpec.configuration.reporter.failed_examples.first&.id
88
+ end
89
+
90
+ # No status file, a fresh clock, and fail_fast forced: --fail-fast in .rspec is forced too.
91
+ def quiet(fail_fast:)
92
+ configuration = RSpec.configuration
93
+ configuration.example_status_persistence_file_path = nil
94
+ configuration.start_time = Time.now
95
+ configuration.force(fail_fast:)
96
+ end
97
+
98
+ def order(rank)
99
+ RSpec.configuration.register_ordering(:global) { |items| ordered(items, rank) }
100
+ end
101
+
102
+ def ordered(items, rank)
103
+ items.sort_by { |item| rank_of(item, rank) }
104
+ end
105
+
106
+ def rank_of(item, rank)
107
+ return rank.fetch(item.id, Float::INFINITY) if item.is_a?(RSpec::Core::Example)
108
+
109
+ ranks = item.descendant_filtered_examples.map { |example| rank.fetch(example.id, Float::INFINITY) }
110
+ ranks.min || Float::INFINITY
111
+ end
112
+
113
+ def filter(wanted)
114
+ filtered_examples.each_value { |examples| keep(examples, wanted) }
115
+ end
116
+
117
+ # Filtered examples are computed on demand, group by group.
118
+ def filtered_examples
119
+ world = RSpec.world
120
+ filtered = world.filtered_examples
121
+ world.example_groups.flat_map(&:descendants).each { |group| filtered[group] }
122
+ filtered
123
+ end
124
+
125
+ def keep(examples, wanted)
126
+ examples.select! { |example| wanted.include?(example.id) }
127
+ end
128
+ end
129
+ end
data/lib/mutato/run.rb ADDED
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutato
4
+ # Without a mutation, it confirms a kill.
5
+ class Run
6
+ def initialize(suite, mutation)
7
+ @suite = suite
8
+ @mutation = mutation
9
+ @installed = nil
10
+ end
11
+
12
+ def go(ids, budget, log:)
13
+ Mutato.config.run_hooks(:before_mutant)
14
+ result = Child.run(timeout: budget + [budget, 5.0].max, log:) { in_child(ids, budget) }
15
+ Output.cap(log)
16
+ result
17
+ end
18
+
19
+ private
20
+
21
+ def in_child(ids, budget)
22
+ install
23
+ checked(Watchdog.new(@suite.adapter, budget).run(ids))
24
+ rescue Installer::Unviable => error
25
+ { outcome: :unviable, detail: error.message }
26
+ end
27
+
28
+ def install
29
+ return unless @mutation
30
+
31
+ @installed = Installer.install(
32
+ @mutation.subject,
33
+ @mutation.mutated_source(File.read(@mutation.file))
34
+ )
35
+ end
36
+
37
+ def checked(verdict)
38
+ return verdict if verdict.fetch(:outcome) == :timeout || !@mutation
39
+ return verdict if Installer.current(@mutation.subject) == @installed
40
+
41
+ { outcome: :overwritten, detail: "the tests redefined the method (code reloader?)" }
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutato
4
+ class Selection
5
+ attr_reader :paths, :options
6
+
7
+ def self.by_id(todo, ids)
8
+ index = todo.to_h { |mutation| [mutation.id, mutation] }
9
+ ids.map { |id| index.fetch(id) }
10
+ end
11
+
12
+ # Under the current directory, the first directory of the path; elsewhere, the path itself.
13
+ def self.root_of(path)
14
+ here = Dir.pwd
15
+ inside = path.delete_prefix("#{here}/")
16
+ inside == path ? path : File.join(here, inside.split("/").first)
17
+ end
18
+
19
+ def initialize(paths, options)
20
+ @paths = paths
21
+ @options = options
22
+ @generators = nil
23
+ @diff = nil
24
+ end
25
+
26
+ def files
27
+ @paths.flat_map { |path| File.directory?(path) ? Dir["#{path}/**/*.rb"].sort : [path] }
28
+ end
29
+
30
+ def generators
31
+ @generators ||= files.map { |file| Generator.read(file) }
32
+ end
33
+
34
+ def subjects
35
+ generators.flat_map(&:subjects)
36
+ end
37
+
38
+ def mutations
39
+ limit = @options.limit
40
+ limit ? narrowed.first(limit) : narrowed
41
+ end
42
+
43
+ def controls
44
+ subjects.reject(&:skip).map { |subject| Mutation.control(subject) }
45
+ end
46
+
47
+ def unknown(todo)
48
+ @options.only.to_a - todo.map(&:id)
49
+ end
50
+
51
+ # The same random subset every run.
52
+ def chosen(todo)
53
+ only = @options.only
54
+ sample = @options.sample
55
+ todo = Selection.by_id(todo, only) if only
56
+ sample ? todo.sample(sample, random: Random.new(1)) : todo
57
+ end
58
+
59
+ def prefixes
60
+ roots = @paths.map { |path| Selection.root_of(File.expand_path(path)) }
61
+ roots.uniq
62
+ end
63
+
64
+ def summary(todo)
65
+ skipped, kept = subjects.partition(&:skip)
66
+ [
67
+ "#{todo.size} mutants over #{files.size} files, #{kept.size} methods",
68
+ *without_methods,
69
+ *dropped,
70
+ *skipped.map(&:skipped_line)
71
+ ]
72
+ end
73
+
74
+ private
75
+
76
+ def narrowed
77
+ all = generators.flat_map(&:mutations).select { |mutation| @options.genre?(mutation.genre) }
78
+ @options.diff ? all.select { |mutation| diff.touches?(mutation.file, mutation.span) } : all
79
+ end
80
+
81
+ def diff
82
+ @diff ||= Diff.read(@options.diff)
83
+ end
84
+
85
+ def without_methods
86
+ empty = generators.select { |generator| generator.subjects.empty? }
87
+ return if empty.none?
88
+
89
+ ["#{empty.size} files without methods (code in blocks or at class level is not mutated)"]
90
+ end
91
+
92
+ def dropped
93
+ dropped = generators.flat_map(&:dropped)
94
+ return if dropped.empty?
95
+
96
+ examples = dropped.first(3).map(&:label).join("; ")
97
+ ["#{dropped.size} mutants dropped as unparsable, e.g. #{examples}"]
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutato
4
+ class Session
5
+ def initialize(mode, selection, console)
6
+ @mode = mode
7
+ @selection = selection
8
+ @console = console
9
+ end
10
+
11
+ def call
12
+ todo = chosen
13
+ @console.say(*@selection.summary(todo))
14
+ nothing if todo.empty?
15
+ try(todo, Boot.new(@selection, @console).suite)
16
+ end
17
+
18
+ private
19
+
20
+ def chosen
21
+ todo = @mode.todo(@selection)
22
+ unknown = @selection.unknown(todo)
23
+ @console.die("no such mutant: #{unknown.join(", ")}") if unknown.any?
24
+ @selection.chosen(todo)
25
+ end
26
+
27
+ def nothing
28
+ @console.die(
29
+ "nothing to mutate under #{@selection.paths.join(" ")}",
30
+ code: @selection.options.diff ? 0 : 3
31
+ )
32
+ end
33
+
34
+ def try(todo, suite)
35
+ started = Clock.now
36
+ campaign = Campaign.new(suite, @console)
37
+ outcomes = campaign.try(todo) { |partial| conclude(partial, campaign, started) }
38
+ finish(conclude(outcomes, campaign, started))
39
+ end
40
+
41
+ def conclude(outcomes, campaign, started)
42
+ flaky = campaign.experience.flaky
43
+ Report.new(outcomes, @mode, @console).show(started, flaky)
44
+ campaign.suite.output.write(outcomes, flaky)
45
+ outcomes
46
+ end
47
+
48
+ def finish(outcomes)
49
+ @mode.annotate(outcomes, @console, @selection.options.format)
50
+ @mode.exit_code(outcomes)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Mutato
6
+ module Skips
7
+ ARID_DEFS = %i[to_s inspect].freeze
8
+ STUB = /NotImplementedError|NoMethodError|abstract|overrid|subclass/i
9
+ JUMPS = [Prism::ReturnNode, Prism::NextNode].freeze
10
+ private_constant :ARID_DEFS, :STUB, :JUMPS
11
+
12
+ module_function
13
+
14
+ def reason(subject, scope)
15
+ node = subject.node
16
+ return "explicit receiver" if Nodes.foreign_receiver?(node) || scope.foreign
17
+ return "defined inside a block" if scope.in_block
18
+ return "arid method name" if !subject.singleton && ARID_DEFS.include?(subject.name)
19
+
20
+ helper_reason(node) || Mutato.config.skip_reason(subject.qualified)
21
+ end
22
+
23
+ def helper_reason(node)
24
+ return "logging helper" if Arid.helper_name?(node.name)
25
+
26
+ statements = Nodes.statements_of(node.body)
27
+ body_reason(statements) if statements
28
+ end
29
+
30
+ def body_reason(statements)
31
+ return "template stub" if (statements in [only]) && stub?(only)
32
+
33
+ "logging only" if logging_only?(statements)
34
+ end
35
+
36
+ # `return unless x` in a method that otherwise only logs decides whether to log.
37
+ def logging_only?(statements)
38
+ arid = statements.map { |statement| Arid.arid_node?(statement) }
39
+ arid.any? && statements.zip(arid).all? { |statement, logs| logs || guard?(statement) }
40
+ end
41
+
42
+ def stub?(statement)
43
+ statement.is_a?(Prism::CallNode) && statement.name == :raise && statement.slice.match?(STUB)
44
+ end
45
+
46
+ def guard?(statement)
47
+ conditional = Nodes.one_of?(statement, Nodes::CONDITIONALS)
48
+ return false unless conditional && Nodes.else_less?(statement)
49
+
50
+ only = Nodes.single_statement(statement)
51
+ Nodes.one_of?(only, JUMPS) && only.arguments.nil?
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutato
4
+ # Not a StandardError: a worker loop's `rescue => e` would swallow it.
5
+ class SoftTimeout < Exception
6
+ end
7
+ public_constant :SoftTimeout
8
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutato
4
+ Subject = Data.define(:file, :name, :chain, :node, :singleton, :receiver, :skip, :block_lines) do
5
+ # The constant that owns the method, `App::Calc`; empty at the top level.
6
+ def owner_name
7
+ receiver || chain.filter_map(&:last).join("::")
8
+ end
9
+
10
+ def qualified
11
+ "#{owner_name}#{singleton ? "." : "#"}#{name}"
12
+ end
13
+
14
+ alias_method :to_s, :qualified
15
+
16
+ def skipped_line
17
+ "skipped #{qualified}: #{skip} #{file}:#{node.location.start_line}"
18
+ end
19
+ end
20
+ public_constant :Subject
21
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Mutato
6
+ class Subjects
7
+ # foreign: inside `class << obj` for an obj other than self.
8
+ Scope = Data.define(:chain, :in_block, :foreign) do
9
+ def self.top
10
+ new(chain: [], in_block: false, foreign: false)
11
+ end
12
+
13
+ def into(node)
14
+ case node
15
+ when Prism::ModuleNode, Prism::ClassNode then nest(
16
+ KINDS.fetch(node.class),
17
+ node.constant_path.slice
18
+ )
19
+ when Prism::SingletonClassNode then singleton(node)
20
+ else Nodes.one_of?(node, Nodes::BLOCKS) ? with(in_block: true) : self
21
+ end
22
+ end
23
+
24
+ def singleton(node)
25
+ node.expression.is_a?(Prism::SelfNode) ? nest(:sclass, nil) : with(foreign: true)
26
+ end
27
+
28
+ def nest(kind, name)
29
+ with(chain: chain + [[kind, name]])
30
+ end
31
+
32
+ def singleton_class?
33
+ chain.last == [:sclass, nil]
34
+ end
35
+
36
+ # Whether `name` is the enclosing class, as `More` in `def More.x` inside `class More`.
37
+ def enclosing?(name)
38
+ _, enclosing = chain.last
39
+ enclosing.to_s.split("::").last == name
40
+ end
41
+ end
42
+ private_constant :Scope
43
+ end
44
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "subjects/scope"
5
+
6
+ module Mutato
7
+ class Subjects
8
+ KINDS = { Prism::ModuleNode => :module, Prism::ClassNode => :class }.freeze
9
+ DEFINERS = {
10
+ %w[Data define] => :class,
11
+ %w[Struct new] => :class,
12
+ %w[Class new] => :class,
13
+ %w[Module new] => :module
14
+ }.freeze
15
+ private_constant :KINDS, :DEFINERS
16
+
17
+ # `Point = Struct.new(:x) do ... end`: the block is the body of the class the constant names.
18
+ def self.constant_body(node)
19
+ call = node.value if node.is_a?(Prism::ConstantWriteNode)
20
+ block = call.block if call.is_a?(Prism::CallNode)
21
+ return unless block.is_a?(Prism::BlockNode)
22
+
23
+ kind = DEFINERS[[call.receiver&.slice, call.name.to_s]]
24
+ [kind, node.name.to_s, block.body] if kind
25
+ end
26
+
27
+ def self.find(file, tree)
28
+ finder = new(file)
29
+ finder.walk(tree, Scope.top)
30
+ finder.found
31
+ end
32
+
33
+ attr_reader :found
34
+
35
+ def initialize(file)
36
+ @file = file
37
+ @found = []
38
+ end
39
+
40
+ def walk(node, scope)
41
+ return @found << define(node, scope) if node.is_a?(Prism::DefNode)
42
+
43
+ kind, name, body = Subjects.constant_body(node)
44
+ return walk(body, scope.nest(kind, name)) if body
45
+
46
+ node.compact_child_nodes.each { |child| walk(child, scope.into(node)) }
47
+ end
48
+
49
+ private
50
+
51
+ # `def More.x` inside `class More` is `def self.x`.
52
+ def define(node, scope)
53
+ receiver = Nodes.constant_receiver(node)
54
+ subject = Subject.new(
55
+ file: @file,
56
+ name: node.name,
57
+ chain: scope.chain,
58
+ node:,
59
+ receiver: (receiver unless scope.enclosing?(receiver)),
60
+ singleton: scope.singleton_class? || Nodes.singleton_receiver?(node),
61
+ skip: nil,
62
+ block_lines: []
63
+ )
64
+ subject.with(skip: Skips.reason(subject, scope), block_lines: Nodes.block_lines(node))
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutato
4
+ Suite = Data.define(:adapter, :baseline, :options, :output)
5
+ public_constant :Suite
6
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutato
4
+ class Trial
5
+ KILLS = %i[caught crashed].freeze
6
+ private_constant :KILLS
7
+
8
+ # A child that died before any example ran names no tests: the whole sequence stands.
9
+ def self.sequence_of(result, remaining)
10
+ ran = result.fetch(:ran, [])
11
+ ran.empty? ? remaining : ran
12
+ end
13
+
14
+ def self.blame(result, sequence)
15
+ return if result.fetch(:outcome) == :missed
16
+
17
+ result[:failing] || result[:running] || sequence.last
18
+ end
19
+
20
+ def initialize(mutation, suite, experience)
21
+ @mutation = mutation
22
+ @suite = suite
23
+ @experience = experience
24
+ end
25
+
26
+ def judge
27
+ covering = Covering.new(@mutation, @suite.baseline)
28
+ ids = covering.ids
29
+ record = @mutation.record(ids)
30
+ return record.merge(outcome: covering.absence) if ids.empty?
31
+
32
+ record.merge(tried(ids))
33
+ end
34
+
35
+ private
36
+
37
+ def tried(ids)
38
+ return { outcome: :equivalent } if Equivalence.equivalent?(@mutation)
39
+
40
+ timed(ids)
41
+ end
42
+
43
+ def timed(ids)
44
+ budget = @suite.baseline.budget(ids, @suite.options.timeout_min)
45
+ started = Clock.now
46
+ result = verdict(ids - flaky, budget)
47
+ result.merge(seconds: Clock.since(started), budget:, flaky: ids & flaky)
48
+ end
49
+
50
+ def flaky
51
+ @experience.flaky.to_a
52
+ end
53
+
54
+ # A kill counts once the same tests pass unmutated; a test failing both ways is flaky.
55
+ def verdict(remaining, budget)
56
+ return { outcome: :unjudged } if remaining.empty?
57
+
58
+ result = Run.new(@suite, @mutation).go(remaining, budget, log:)
59
+ return result unless KILLS.include?(result.fetch(:outcome))
60
+
61
+ confirm(result, remaining, budget)
62
+ end
63
+
64
+ def confirm(result, remaining, budget)
65
+ sequence = Trial.sequence_of(result, remaining)
66
+ culprit = culprit_of(sequence, budget)
67
+ return result.merge(outcome: :caught) unless culprit
68
+
69
+ @experience.flaky << culprit
70
+ verdict(remaining - flaky, budget)
71
+ end
72
+
73
+ # The test the unmutated run failed or hung on; cached, as many kills share a sequence.
74
+ def culprit_of(sequence, budget)
75
+ culprits = @experience.culprits
76
+ return culprits[sequence] if culprits.key?(sequence)
77
+
78
+ result = unmutated(sequence, budget)
79
+ culprits[sequence] = Trial.blame(result, sequence)
80
+ end
81
+
82
+ def unmutated(sequence, budget)
83
+ result = Run.new(@suite, nil).go(sequence, budget, log: "#{log}.confirm")
84
+ return result unless result.fetch(:outcome) == :timeout
85
+
86
+ Run.new(@suite, nil).go(sequence, budget * 2, log: "#{log}.confirm")
87
+ end
88
+
89
+ def log
90
+ @suite.output.log_path(@mutation.id)
91
+ end
92
+ end
93
+ end