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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +13 -0
- data/LICENSE +21 -0
- data/README.md +218 -0
- data/exe/mutato +21 -0
- data/lib/mutato/abort.rb +12 -0
- data/lib/mutato/annotations.rb +65 -0
- data/lib/mutato/arid.rb +104 -0
- data/lib/mutato/baseline.rb +51 -0
- data/lib/mutato/boot.rb +99 -0
- data/lib/mutato/campaign.rb +52 -0
- data/lib/mutato/change.rb +49 -0
- data/lib/mutato/child.rb +135 -0
- data/lib/mutato/cli.rb +138 -0
- data/lib/mutato/clock.rb +21 -0
- data/lib/mutato/config.rb +39 -0
- data/lib/mutato/console.rb +33 -0
- data/lib/mutato/coverage_listener/tally.rb +26 -0
- data/lib/mutato/coverage_listener.rb +46 -0
- data/lib/mutato/covering.rb +45 -0
- data/lib/mutato/diff.rb +42 -0
- data/lib/mutato/emitter.rb +46 -0
- data/lib/mutato/equivalence.rb +30 -0
- data/lib/mutato/experience.rb +11 -0
- data/lib/mutato/flags.rb +58 -0
- data/lib/mutato/generation.rb +36 -0
- data/lib/mutato/generator.rb +10 -0
- data/lib/mutato/genres/conditions.rb +70 -0
- data/lib/mutato/genres/elements.rb +49 -0
- data/lib/mutato/genres/genre.rb +23 -0
- data/lib/mutato/genres/operators.rb +81 -0
- data/lib/mutato/genres/statements.rb +122 -0
- data/lib/mutato/genres/swaps.rb +58 -0
- data/lib/mutato/genres/values.rb +41 -0
- data/lib/mutato/genres.rb +53 -0
- data/lib/mutato/group_hooks.rb +45 -0
- data/lib/mutato/hits.rb +18 -0
- data/lib/mutato/installer/header.rb +40 -0
- data/lib/mutato/installer.rb +133 -0
- data/lib/mutato/measure.rb +37 -0
- data/lib/mutato/mode/control.rb +43 -0
- data/lib/mutato/mode/run.rb +33 -0
- data/lib/mutato/mode.rb +26 -0
- data/lib/mutato/mutation.rb +85 -0
- data/lib/mutato/nodes.rb +150 -0
- data/lib/mutato/options.rb +33 -0
- data/lib/mutato/output.rb +76 -0
- data/lib/mutato/privates.rb +46 -0
- data/lib/mutato/report.rb +36 -0
- data/lib/mutato/rspec_adapter/progress.rb +20 -0
- data/lib/mutato/rspec_adapter/result.rb +15 -0
- data/lib/mutato/rspec_adapter.rb +129 -0
- data/lib/mutato/run.rb +44 -0
- data/lib/mutato/selection.rb +100 -0
- data/lib/mutato/session.rb +53 -0
- data/lib/mutato/skips.rb +54 -0
- data/lib/mutato/soft_timeout.rb +8 -0
- data/lib/mutato/subject.rb +21 -0
- data/lib/mutato/subjects/scope.rb +44 -0
- data/lib/mutato/subjects.rb +67 -0
- data/lib/mutato/suite.rb +6 -0
- data/lib/mutato/trial.rb +93 -0
- data/lib/mutato/usage.rb +41 -0
- data/lib/mutato/version.rb +6 -0
- data/lib/mutato/visitor/cursor.rb +28 -0
- data/lib/mutato/visitor/walk.rb +17 -0
- data/lib/mutato/visitor.rb +88 -0
- data/lib/mutato/watchdog.rb +54 -0
- data/lib/mutato.rb +55 -0
- 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
|
data/lib/mutato/skips.rb
ADDED
|
@@ -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,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
|
data/lib/mutato/suite.rb
ADDED
data/lib/mutato/trial.rb
ADDED
|
@@ -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
|