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,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Mutato
|
|
6
|
+
class Installer
|
|
7
|
+
# What a mutant needs from its file: the frozen-string comment and top-level `using`.
|
|
8
|
+
module Header
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# The code's second line is the def's; the header goes above the first.
|
|
12
|
+
def evaluate(code, subject)
|
|
13
|
+
path = File.expand_path(subject.file)
|
|
14
|
+
head = of(path)
|
|
15
|
+
source = head + code
|
|
16
|
+
TOPLEVEL_BINDING.eval(source, path, subject.node.location.start_line - 1 - head.count("\n"))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def of(path)
|
|
20
|
+
result = Prism.parse_file(path)
|
|
21
|
+
"#{frozen_line(result)}#{usings(result)}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def frozen_line(result)
|
|
25
|
+
frozen = result.magic_comments.any? { |comment| Header.frozen?(comment) }
|
|
26
|
+
"# frozen_string_literal: true\n" if frozen
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Ruby accepts dashes and any case in the key and the value.
|
|
30
|
+
def frozen?(comment)
|
|
31
|
+
comment.key.tr("-", "_").casecmp?("frozen_string_literal") && comment.value.casecmp?("true")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def usings(result)
|
|
35
|
+
calls = result.value.statements.body.select { |node| Nodes.using?(node) }
|
|
36
|
+
calls.sum("") { |node| "#{node.slice}; " }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "installer/header"
|
|
4
|
+
|
|
5
|
+
module Mutato
|
|
6
|
+
# Wrapped in the original nesting, so constant lookup in the mutant still works.
|
|
7
|
+
class Installer
|
|
8
|
+
class Unviable < StandardError
|
|
9
|
+
end
|
|
10
|
+
public_constant :Unviable
|
|
11
|
+
|
|
12
|
+
# Whatever stops the mutated method from loading makes the mutant unviable.
|
|
13
|
+
def self.install(subject, def_source)
|
|
14
|
+
quietly { new(subject).install(def_source) }
|
|
15
|
+
rescue ScriptError, StandardError => error
|
|
16
|
+
raise Unviable, "#{error.class}: #{error.message.lines.first.to_s.strip}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The method as it is now, to tell if the tests replaced the installed one.
|
|
20
|
+
def self.current(subject)
|
|
21
|
+
new(subject).current
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.methods_of(owner)
|
|
25
|
+
owner.instance_methods(false) + owner.private_instance_methods(false)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Redefining warns under -w, which a suite failing on warnings turns into unviable mutants.
|
|
29
|
+
def self.quietly
|
|
30
|
+
verbose = $VERBOSE
|
|
31
|
+
$VERBOSE = nil
|
|
32
|
+
yield
|
|
33
|
+
ensure
|
|
34
|
+
$VERBOSE = verbose
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(subject)
|
|
38
|
+
@subject = subject
|
|
39
|
+
@name = subject.name
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def install(def_source)
|
|
43
|
+
owner = loaded_owner
|
|
44
|
+
visibility = visibility_of(owner)
|
|
45
|
+
twins = twins_of(owner)
|
|
46
|
+
Header.evaluate(wrap(def_source, visibility), @subject)
|
|
47
|
+
restore(owner, visibility, twins)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def current
|
|
51
|
+
owner = resolve_owner
|
|
52
|
+
owner.instance_method(@name) if defines?(owner)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def wrap(def_source, visibility)
|
|
56
|
+
"#{openers.join(" ")}\n#{prefix(visibility)}#{def_source}\n#{" end" * @subject.chain.size}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def openers
|
|
62
|
+
@subject.chain.map { |kind, name| kind == :sclass ? "class << self;" : "#{kind} #{name};" }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# `private def` keeps a method_added hook from seeing a public def.
|
|
66
|
+
def prefix(visibility)
|
|
67
|
+
visibility == :public || @subject.singleton ? "" : "#{visibility} "
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A class a framework loads on first use is loaded from the file.
|
|
71
|
+
def loaded_owner
|
|
72
|
+
name = @subject.owner_name
|
|
73
|
+
require File.expand_path(@subject.file) unless name.empty? || Object.const_defined?(name)
|
|
74
|
+
resolve_owner
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Aliases of the old body, and module_function's singleton copy, must follow.
|
|
78
|
+
def twins_of(owner)
|
|
79
|
+
return [] unless defines?(owner)
|
|
80
|
+
|
|
81
|
+
target = owner.instance_method(@name)
|
|
82
|
+
Installer.methods_of(owner).select do |name|
|
|
83
|
+
name != @name && owner.instance_method(name) == target
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def defines?(owner)
|
|
88
|
+
owner.method_defined?(@name) || owner.private_method_defined?(@name)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The visibility methods are private, hence __send__.
|
|
92
|
+
def restore(owner, visibility, twins)
|
|
93
|
+
twins.each { |name| owner.alias_method(name, @name) }
|
|
94
|
+
owner.__send__(:module_function, @name) if visibility == :private && module_function?(owner)
|
|
95
|
+
owner.__send__(visibility, @name) if @subject.singleton && visibility != :public
|
|
96
|
+
owner.instance_method(@name)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def module_function?(owner)
|
|
100
|
+
owner.instance_of?(Module) && owner.singleton_methods(false).include?(@name)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def resolve_owner
|
|
104
|
+
receiver = @subject.receiver
|
|
105
|
+
owner = receiver ? lexical(receiver) : nesting_owner
|
|
106
|
+
return owner if !@subject.singleton || @subject.chain.last == [:sclass, nil]
|
|
107
|
+
|
|
108
|
+
owner.singleton_class
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def nesting_owner
|
|
112
|
+
@subject.chain.reduce(Object) do |mod, (kind, name)|
|
|
113
|
+
kind == :sclass ? mod.singleton_class : mod.const_get(name)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A constant as the code naming it sees it: from the innermost nesting outward.
|
|
118
|
+
def lexical(name)
|
|
119
|
+
nestings = @subject.chain.filter_map(&:last).reduce([Object]) do |found, part|
|
|
120
|
+
found << found.last.const_get(part)
|
|
121
|
+
end
|
|
122
|
+
nestings.reverse.find { |mod| mod.const_defined?(name) }
|
|
123
|
+
.const_get(name)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def visibility_of(owner)
|
|
127
|
+
return :private if owner.private_method_defined?(@name, false)
|
|
128
|
+
return :protected if owner.protected_method_defined?(@name, false)
|
|
129
|
+
|
|
130
|
+
:public
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutato
|
|
4
|
+
Measure = Data.define(:status, :line_map, :durations, :failed, :seen, :hooks) do
|
|
5
|
+
# A set: an array here would take a minute on a large suite.
|
|
6
|
+
def self.judged(ids, judges)
|
|
7
|
+
ids.select { |id| judges.include?(id) }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def summary(seconds)
|
|
11
|
+
format(
|
|
12
|
+
"baseline: status %<status>d, %<seconds>.2fs, %<lines>d covered lines, %<tests>d tests",
|
|
13
|
+
status:,
|
|
14
|
+
seconds:,
|
|
15
|
+
lines: line_map.size,
|
|
16
|
+
tests: durations.size
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def all_failed?
|
|
21
|
+
durations.size == failed.size
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Only tests that ran and passed can judge a mutant; a group hook may credit others.
|
|
25
|
+
def baseline(**rest)
|
|
26
|
+
judges = (durations.keys - failed).to_set
|
|
27
|
+
Baseline.new(
|
|
28
|
+
line_map: line_map.transform_values { |ids| Measure.judged(ids, judges) },
|
|
29
|
+
durations:,
|
|
30
|
+
order: durations.each_key.with_index.to_h,
|
|
31
|
+
hooks:,
|
|
32
|
+
**rest
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
public_constant :Measure
|
|
37
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutato
|
|
4
|
+
module Mode
|
|
5
|
+
# A control that exercised nothing proved nothing: exit 3.
|
|
6
|
+
module Control
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def todo(selection)
|
|
10
|
+
selection.controls
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def exit_code(outcomes)
|
|
14
|
+
return 1 if Mode.failed_controls(outcomes).any?
|
|
15
|
+
|
|
16
|
+
outcomes.any? { |outcome| Mode.exercised?(outcome) } ? 0 : 3
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def summary(outcomes)
|
|
20
|
+
bad = Mode.failed_controls(outcomes)
|
|
21
|
+
return verdict(outcomes) if bad.empty?
|
|
22
|
+
|
|
23
|
+
[
|
|
24
|
+
"control FAILED:",
|
|
25
|
+
*bad.map do |outcome|
|
|
26
|
+
" #{outcome.fetch(:outcome)} #{outcome.fetch(:label)}"
|
|
27
|
+
end
|
|
28
|
+
]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def verdict(outcomes)
|
|
32
|
+
exercised = outcomes.select { |outcome| Mode.exercised?(outcome) }
|
|
33
|
+
if exercised.empty?
|
|
34
|
+
return ["control proved nothing: no reinstalled method was exercised by a test"]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
["control OK: #{exercised.size} of #{outcomes.size} reinstalled methods passed their tests"]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def annotate(_outcomes, _console, _format); end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutato
|
|
4
|
+
module Mode
|
|
5
|
+
module Run
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def todo(selection)
|
|
9
|
+
selection.mutations
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def exit_code(outcomes)
|
|
13
|
+
Mode.missed(outcomes).any? ? 2 : 0
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def summary(outcomes)
|
|
17
|
+
lines = Mode.missed(outcomes).map do |outcome|
|
|
18
|
+
" #{outcome.fetch(:id)} #{outcome.fetch(:label)}#{flaky_note(outcome)}"
|
|
19
|
+
end
|
|
20
|
+
lines.empty? ? [] : ["\nMISSED:", *lines]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def flaky_note(outcome)
|
|
24
|
+
excluded = outcome.fetch(:flaky, [])
|
|
25
|
+
" (#{excluded.size} flaky tests excluded)" if excluded.any?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def annotate(outcomes, console, format)
|
|
29
|
+
Annotations.new(console).show(outcomes, format)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/mutato/mode.rb
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "mode/control"
|
|
4
|
+
require_relative "mode/run"
|
|
5
|
+
|
|
6
|
+
module Mutato
|
|
7
|
+
module Mode
|
|
8
|
+
MISSED = %i[missed unjudged].freeze
|
|
9
|
+
PASSING_CONTROL = %i[missed uncovered load-time unloaded].freeze
|
|
10
|
+
private_constant :MISSED, :PASSING_CONTROL
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def missed(outcomes)
|
|
15
|
+
outcomes.select { |outcome| MISSED.include?(outcome.fetch(:outcome)) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def exercised?(outcome)
|
|
19
|
+
outcome.fetch(:examples).positive?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def failed_controls(outcomes)
|
|
23
|
+
outcomes.reject { |outcome| PASSING_CONTROL.include?(outcome.fetch(:outcome)) }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutato
|
|
4
|
+
Mutation = Data.define(
|
|
5
|
+
:id,
|
|
6
|
+
:file,
|
|
7
|
+
:subject,
|
|
8
|
+
:genre,
|
|
9
|
+
:line,
|
|
10
|
+
:col,
|
|
11
|
+
:start_line,
|
|
12
|
+
:end_line,
|
|
13
|
+
:outer_lines,
|
|
14
|
+
:offset,
|
|
15
|
+
:bytes,
|
|
16
|
+
:original,
|
|
17
|
+
:replacement,
|
|
18
|
+
:description
|
|
19
|
+
) do
|
|
20
|
+
# The span starts below the def line, which runs at load; a one-liner's is empty: uncovered.
|
|
21
|
+
def self.control(subject)
|
|
22
|
+
location = subject.node.location
|
|
23
|
+
first = location.start_line
|
|
24
|
+
new(
|
|
25
|
+
id: "control-#{subject.qualified.tr("/", "_")}",
|
|
26
|
+
file: subject.file,
|
|
27
|
+
subject:,
|
|
28
|
+
genre: :control,
|
|
29
|
+
line: first,
|
|
30
|
+
col: 1,
|
|
31
|
+
start_line: first + 1,
|
|
32
|
+
end_line: location.end_line,
|
|
33
|
+
outer_lines: nil,
|
|
34
|
+
offset: location.start_offset,
|
|
35
|
+
bytes: 0,
|
|
36
|
+
original: "",
|
|
37
|
+
replacement: "",
|
|
38
|
+
description: "reinstall #{subject} unchanged"
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def label
|
|
43
|
+
"#{file}:#{line}:#{col}: [#{genre}] #{description}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
alias_method :to_s, :label
|
|
47
|
+
|
|
48
|
+
def listing
|
|
49
|
+
"#{id} #{label} (#{subject})"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def span
|
|
53
|
+
start_line..end_line
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def record(ids)
|
|
57
|
+
{
|
|
58
|
+
id:,
|
|
59
|
+
label:,
|
|
60
|
+
genre:,
|
|
61
|
+
file:,
|
|
62
|
+
line:,
|
|
63
|
+
col:,
|
|
64
|
+
description:,
|
|
65
|
+
examples: ids.size,
|
|
66
|
+
ids:,
|
|
67
|
+
seconds: 0.0
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def apply(source)
|
|
72
|
+
mutated = source.b
|
|
73
|
+
mutated[offset, bytes] = replacement.b
|
|
74
|
+
mutated.force_encoding(source.encoding)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def mutated_source(file_source)
|
|
78
|
+
mutated = apply(file_source)
|
|
79
|
+
delta = replacement.bytesize - bytes
|
|
80
|
+
location = subject.node.location
|
|
81
|
+
mutated.b[location.start_offset, location.length + delta].force_encoding(file_source.encoding)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
public_constant :Mutation
|
|
85
|
+
end
|
data/lib/mutato/nodes.rb
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Mutato
|
|
6
|
+
module Nodes
|
|
7
|
+
BLOCKS = [Prism::BlockNode, Prism::LambdaNode].freeze
|
|
8
|
+
CONDITIONALS = [Prism::IfNode, Prism::UnlessNode].freeze
|
|
9
|
+
HEREDOC_CAPABLE = [
|
|
10
|
+
Prism::StringNode,
|
|
11
|
+
Prism::InterpolatedStringNode,
|
|
12
|
+
Prism::XStringNode,
|
|
13
|
+
Prism::InterpolatedXStringNode
|
|
14
|
+
].freeze
|
|
15
|
+
LITERALS = [
|
|
16
|
+
Prism::StringNode,
|
|
17
|
+
Prism::SymbolNode,
|
|
18
|
+
Prism::IntegerNode,
|
|
19
|
+
Prism::FloatNode,
|
|
20
|
+
Prism::NilNode,
|
|
21
|
+
Prism::TrueNode,
|
|
22
|
+
Prism::FalseNode
|
|
23
|
+
].freeze
|
|
24
|
+
MEMO_GUARD = /\Areturn (@\w+) if \1\z/
|
|
25
|
+
public_constant :BLOCKS, :CONDITIONALS, :LITERALS
|
|
26
|
+
private_constant :HEREDOC_CAPABLE, :MEMO_GUARD
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# Named, not anonymous: Ruby 3.3.0 rejects `&` forwarded from inside a block.
|
|
31
|
+
def each_node(node, &block)
|
|
32
|
+
return enum_for(:each_node, node) unless block
|
|
33
|
+
|
|
34
|
+
yield(node)
|
|
35
|
+
node.compact_child_nodes.each { |child| each_node(child, &block) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def using?(node)
|
|
39
|
+
node.is_a?(Prism::CallNode) && node.name == :using && node.receiver.nil?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def one_of?(node, classes)
|
|
43
|
+
classes.any? { |klass| node.is_a?(klass) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The statements of a def body, looking through the begin a rescue adds.
|
|
47
|
+
def statements_of(body)
|
|
48
|
+
body = body.statements if body.is_a?(Prism::BeginNode)
|
|
49
|
+
body.body if body.is_a?(Prism::StatementsNode)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def else_less?(node)
|
|
53
|
+
node.is_a?(Prism::IfNode) ? node.subsequent.nil? : node.else_clause.nil?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def constant_receiver(definition)
|
|
57
|
+
receiver = definition.receiver
|
|
58
|
+
receiver.slice if receiver.is_a?(Prism::ConstantReadNode)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def singleton_receiver?(definition)
|
|
62
|
+
receiver = definition.receiver
|
|
63
|
+
receiver.is_a?(Prism::SelfNode) || receiver.is_a?(Prism::ConstantReadNode)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def foreign_receiver?(definition)
|
|
67
|
+
receiver = definition.receiver
|
|
68
|
+
receiver && !singleton_receiver?(definition)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Lines inside blocks may run later than the method itself (define_method, Thread.new).
|
|
72
|
+
def block_lines(definition)
|
|
73
|
+
blocks = each_node(definition).select { |inner| one_of?(inner, BLOCKS) }
|
|
74
|
+
blocks.flat_map do |block|
|
|
75
|
+
location = block.location
|
|
76
|
+
((location.start_line + 1)..location.end_line).to_a
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# `x.size > 0`: a comparison a count can never fail.
|
|
81
|
+
def count_against_zero?(call)
|
|
82
|
+
receiver = call.receiver
|
|
83
|
+
argument = sole_argument(call)
|
|
84
|
+
receiver.is_a?(Prism::CallNode) && %i[length size count].include?(receiver.name) &&
|
|
85
|
+
argument.is_a?(Prism::IntegerNode) && argument.value.zero?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# `initialize` and setters: their caller never sees the value they return.
|
|
89
|
+
def value_dropped?(name)
|
|
90
|
+
name == :initialize || name.to_s.match?(/\A\w+=\z/)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# `return @x if @x`: deleting it, or making it never return, only skips a cache.
|
|
94
|
+
def memo_guard?(statement)
|
|
95
|
+
statement.slice.match?(MEMO_GUARD)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def nil_store?(call)
|
|
99
|
+
call.name == :[]= && arguments_of(call).last.is_a?(Prism::NilNode)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def nil_valued?(element)
|
|
103
|
+
value = element.is_a?(Prism::AssocNode) ? element.value : element
|
|
104
|
+
value.is_a?(Prism::NilNode) || value.is_a?(Prism::FalseNode)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def single_statement(conditional)
|
|
108
|
+
return unless conditional.statements&.body in [only]
|
|
109
|
+
|
|
110
|
+
only
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def arguments_of(call)
|
|
114
|
+
call.arguments&.arguments || []
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def sole_argument(call)
|
|
118
|
+
argument, = arguments_of(call)
|
|
119
|
+
argument
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# `a + b`, `x < 1`: one receiver, one argument, no parentheses, the name spelled out.
|
|
123
|
+
def binary_operator?(call)
|
|
124
|
+
message = call.message_loc
|
|
125
|
+
call.receiver && message && call.opening_loc.nil? &&
|
|
126
|
+
arguments_of(call).size == 1 && message.slice == call.name.to_s
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# A heredoc's body lies below its statement and goes with it; the terminator's newline stays.
|
|
130
|
+
def extent(node, source)
|
|
131
|
+
location = node.location
|
|
132
|
+
start = location.start_offset
|
|
133
|
+
stop = [location.end_offset, *heredoc_ends(node)].max
|
|
134
|
+
stop -= 1 if source.getbyte(stop - 1) == "\n".ord
|
|
135
|
+
[start, stop - start]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def heredoc_ends(node)
|
|
139
|
+
heredocs = each_node(node).select do |inner|
|
|
140
|
+
one_of?(inner, HEREDOC_CAPABLE) && inner.heredoc?
|
|
141
|
+
end
|
|
142
|
+
heredocs.map { |heredoc| heredoc.closing_loc.end_offset }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def compact(text)
|
|
146
|
+
joined = text.gsub(/\s*\\\n\s*/, " ").gsub(/\s+/, " ")
|
|
147
|
+
joined.sub(/\A(.{57}).{4,}\z/, "\\1...")
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module Mutato
|
|
6
|
+
Options = Data.define(
|
|
7
|
+
:help,
|
|
8
|
+
:version,
|
|
9
|
+
:spec,
|
|
10
|
+
:config,
|
|
11
|
+
:diff,
|
|
12
|
+
:format,
|
|
13
|
+
:limit,
|
|
14
|
+
:sample,
|
|
15
|
+
:genres,
|
|
16
|
+
:only,
|
|
17
|
+
:out,
|
|
18
|
+
:timeout_min
|
|
19
|
+
) do
|
|
20
|
+
def self.parse(argv)
|
|
21
|
+
Flags.parse(argv)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def spec_args
|
|
25
|
+
Shellwords.split(spec)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def genre?(genre)
|
|
29
|
+
genres.nil? || genres.include?(genre.to_s)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
public_constant :Options
|
|
33
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Mutato
|
|
7
|
+
class Output
|
|
8
|
+
OUTCOMES = %w[
|
|
9
|
+
caught
|
|
10
|
+
missed
|
|
11
|
+
timeout
|
|
12
|
+
unjudged
|
|
13
|
+
equivalent
|
|
14
|
+
overwritten
|
|
15
|
+
unviable
|
|
16
|
+
crashed
|
|
17
|
+
uncovered
|
|
18
|
+
load-time
|
|
19
|
+
unloaded
|
|
20
|
+
].freeze
|
|
21
|
+
LOG_CAP = 1_000_000
|
|
22
|
+
private_constant :OUTCOMES, :LOG_CAP
|
|
23
|
+
|
|
24
|
+
# Logs hold whatever the application printed; keep the last megabyte.
|
|
25
|
+
def self.cap(log)
|
|
26
|
+
size = File.size?(log)
|
|
27
|
+
return unless size && size > LOG_CAP
|
|
28
|
+
|
|
29
|
+
File.binwrite(log, "mutato: log truncated to its last #{LOG_CAP} bytes\n#{tail(log)}")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Only the tail is read: a runaway mutant's log can be gigabytes.
|
|
33
|
+
def self.tail(log)
|
|
34
|
+
File.open(log, "rb") do |file|
|
|
35
|
+
file.seek(-LOG_CAP, IO::SEEK_END)
|
|
36
|
+
file.read
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.listed(outcomes, name)
|
|
41
|
+
chosen = outcomes.select { |outcome| outcome.fetch(:outcome).to_s == name }
|
|
42
|
+
chosen.map { |outcome| "#{outcome.fetch(:id)} #{outcome.fetch(:label)}" }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A run starts outcomes.jsonl afresh, as it rewrites the other lists.
|
|
46
|
+
def initialize(dir)
|
|
47
|
+
@dir = dir
|
|
48
|
+
FileUtils.mkdir_p(File.join(dir, "logs"))
|
|
49
|
+
File.write(File.join(dir, "outcomes.jsonl"), "")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def log_path(id)
|
|
53
|
+
File.join(@dir, "logs", "#{id}.log")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def append(outcome)
|
|
57
|
+
File.write(File.join(@dir, "outcomes.jsonl"), "#{JSON.generate(outcome)}\n", mode: "a")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def write(outcomes, flaky)
|
|
61
|
+
File.write(File.join(@dir, "outcomes.json"), JSON.pretty_generate(outcomes))
|
|
62
|
+
OUTCOMES.each { |name| write_list("#{name}.txt", Output.listed(outcomes, name)) }
|
|
63
|
+
write_list("flaky.txt", flaky)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def write_list(name, lines)
|
|
69
|
+
File.write(
|
|
70
|
+
File.join(@dir, name),
|
|
71
|
+
lines.map { |line| "#{line}\n" }
|
|
72
|
+
.join
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Mutato
|
|
6
|
+
# The private method names of one statement list: `private def x`, `private :x`, and the
|
|
7
|
+
# defs after a bare `private`, until `public` or `protected`.
|
|
8
|
+
module Privates
|
|
9
|
+
VISIBILITY = %i[private public protected].freeze
|
|
10
|
+
private_constant :VISIBILITY
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def names(statements)
|
|
15
|
+
hidden = false
|
|
16
|
+
statements.body.each_with_object([]) do |statement, names|
|
|
17
|
+
hidden = section(statement, hidden)
|
|
18
|
+
names.concat(listed(statement))
|
|
19
|
+
names << statement.name if hidden && statement.is_a?(Prism::DefNode)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def section(statement, hidden)
|
|
24
|
+
return hidden unless visibility?(statement) && Nodes.arguments_of(statement).empty?
|
|
25
|
+
|
|
26
|
+
statement.name == :private
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def listed(statement)
|
|
30
|
+
return [] unless visibility?(statement) && statement.name == :private
|
|
31
|
+
|
|
32
|
+
Nodes.arguments_of(statement).filter_map { |argument| Privates.name_of(argument) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def name_of(argument)
|
|
36
|
+
return argument.name if argument.is_a?(Prism::DefNode)
|
|
37
|
+
|
|
38
|
+
argument.unescaped.to_sym if Nodes.one_of?(argument, [Prism::SymbolNode, Prism::StringNode])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def visibility?(statement)
|
|
42
|
+
statement.is_a?(Prism::CallNode) && statement.receiver.nil? &&
|
|
43
|
+
VISIBILITY.include?(statement.name)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|