mutaruby 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 +30 -0
- data/LICENSE +21 -0
- data/README.md +398 -0
- data/exe/mutaruby +6 -0
- data/lib/mutaruby/cli.rb +159 -0
- data/lib/mutaruby/command.rb +101 -0
- data/lib/mutaruby/generator.rb +336 -0
- data/lib/mutaruby/ignore_file.rb +131 -0
- data/lib/mutaruby/mutation.rb +53 -0
- data/lib/mutaruby/parsing.rb +48 -0
- data/lib/mutaruby/reporters.rb +103 -0
- data/lib/mutaruby/runner.rb +181 -0
- data/lib/mutaruby/sandbox.rb +82 -0
- data/lib/mutaruby/selection.rb +107 -0
- data/lib/mutaruby/splicer.rb +27 -0
- data/lib/mutaruby/version.rb +5 -0
- data/lib/mutaruby.rb +19 -0
- metadata +84 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tempfile'
|
|
4
|
+
|
|
5
|
+
module Mutaruby
|
|
6
|
+
# Runs the user's test command once, in a fresh process (a new shell and everything it starts), inside a sandbox.
|
|
7
|
+
# Exit status 0 means the tests passed. A run that exceeds the timeout is killed together with its whole process group.
|
|
8
|
+
class Command
|
|
9
|
+
Result = Struct.new(:status, :exitstatus, :seconds, :output, keyword_init: true) do
|
|
10
|
+
def passed? = status == :passed
|
|
11
|
+
def timeout? = status == :timeout
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
OUTPUT_TAIL = 4000
|
|
15
|
+
|
|
16
|
+
def initialize(command, root:, env: {})
|
|
17
|
+
@command = command
|
|
18
|
+
@root = File.expand_path(root)
|
|
19
|
+
@env = env
|
|
20
|
+
@live = {}
|
|
21
|
+
@lock = Mutex.new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Kills every run still in progress (used when mutaruby itself is interrupted: the runs live in their own process
|
|
25
|
+
# groups, so a Ctrl-C in the terminal does not reach them).
|
|
26
|
+
def kill_all
|
|
27
|
+
pids = @lock.synchronize { @live.keys }
|
|
28
|
+
pids.each { |pid| kill_group(pid) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# dir: sandbox project dir. timeout: seconds (nil = none). capture: keep an output tail (for the baseline report).
|
|
32
|
+
def run(dir, timeout: nil, env: {}, capture: false)
|
|
33
|
+
log = capture ? Tempfile.new('mutaruby-out') : nil
|
|
34
|
+
out = log ? log.path : File::NULL
|
|
35
|
+
started = monotonic
|
|
36
|
+
pid = Process.spawn(child_env(dir).merge(@env).merge(env), '/bin/sh', '-c', @command,
|
|
37
|
+
chdir: dir, pgroup: true, in: File::NULL, out: out, err: out)
|
|
38
|
+
@lock.synchronize { @live[pid] = true }
|
|
39
|
+
waiter = Process.detach(pid)
|
|
40
|
+
timed_out = waiter.join(timeout).nil? if timeout
|
|
41
|
+
if timed_out
|
|
42
|
+
kill_group(pid)
|
|
43
|
+
waiter.join
|
|
44
|
+
end
|
|
45
|
+
st = waiter.value
|
|
46
|
+
@lock.synchronize { @live.delete(pid) }
|
|
47
|
+
status = if timed_out then :timeout
|
|
48
|
+
elsif st.success? then :passed
|
|
49
|
+
else :failed
|
|
50
|
+
end
|
|
51
|
+
Result.new(status: status, exitstatus: st.exitstatus, seconds: monotonic - started,
|
|
52
|
+
output: log ? tail(log.path) : nil)
|
|
53
|
+
ensure
|
|
54
|
+
log&.close!
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# Bundler exports the ORIGINAL Gemfile path to child processes. Left as is, `bundle exec` in the sandbox would
|
|
60
|
+
# resolve the Gemfile (and a `gemspec` line's lib/ directory) from the user's checkout and test the unmutated code.
|
|
61
|
+
def child_env(dir)
|
|
62
|
+
# The per-mutant variables are always set or cleared explicitly, so a mutaruby run nested inside another one's
|
|
63
|
+
# test command does not inherit the outer run's mutant id.
|
|
64
|
+
env = { 'MUTARUBY' => '1', 'MUTARUBY_MUTANT_ID' => nil, 'MUTARUBY_FILE' => nil }
|
|
65
|
+
%w[BUNDLE_GEMFILE BUNDLE_APP_CONFIG].each do |k|
|
|
66
|
+
v = ENV.fetch(k, nil)
|
|
67
|
+
next unless v
|
|
68
|
+
|
|
69
|
+
full = File.expand_path(v)
|
|
70
|
+
env[k] = full.start_with?("#{@root}/") ? File.join(dir, full.delete_prefix("#{@root}/")) : v
|
|
71
|
+
end
|
|
72
|
+
env
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def kill_group(pid)
|
|
76
|
+
Process.kill('TERM', -pid)
|
|
77
|
+
deadline = monotonic + 2
|
|
78
|
+
sleep 0.05 while monotonic < deadline && alive?(pid)
|
|
79
|
+
Process.kill('KILL', -pid)
|
|
80
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def alive?(pid)
|
|
85
|
+
Process.kill(0, -pid)
|
|
86
|
+
true
|
|
87
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
88
|
+
false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def tail(path)
|
|
92
|
+
s = File.binread(path)
|
|
93
|
+
s = s[-OUTPUT_TAIL..] if s.bytesize > OUTPUT_TAIL
|
|
94
|
+
s.force_encoding(Encoding::UTF_8).scrub
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def monotonic
|
|
98
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'parsing'
|
|
4
|
+
require_relative 'mutation'
|
|
5
|
+
require_relative 'splicer'
|
|
6
|
+
|
|
7
|
+
module Mutaruby
|
|
8
|
+
# Walks a file's AST (the `parser` gem) and produces every mutant of it. Each mutant is ONE splice of the original
|
|
9
|
+
# source at the character offsets of an AST node (or of one of its tokens), so comments, formatting and the rest of
|
|
10
|
+
# the file are untouched. Mutants that no longer parse, and mutants whose text equals an earlier one, are dropped.
|
|
11
|
+
class Generator
|
|
12
|
+
# Operator id => one-line description. The README documents each with an example.
|
|
13
|
+
OPERATORS = {
|
|
14
|
+
'arithmetic' => 'swap + - * / % ** (a + b -> a - b, a * b -> a / b, a % b -> a * b, a ** b -> a * b)',
|
|
15
|
+
'comparison' => 'boundary / equality swaps (== <-> !=, < -> <=, <= -> <, > -> >=, >= -> >, <=> -> ==, === -> !=, =~ <-> !~)',
|
|
16
|
+
'bitwise' => 'swap & | ^ << >> (a & b -> a | b, a << b -> a >> b)',
|
|
17
|
+
'logical-swap' => '&& <-> ||, and <-> or',
|
|
18
|
+
'logical-drop-left' => 'a && b -> b, a || b -> b',
|
|
19
|
+
'logical-drop-right' => 'a && b -> a, a || b -> a',
|
|
20
|
+
'negation-removal' => '!x -> x, not x -> x',
|
|
21
|
+
'unary-minus-removal' => '-x -> x',
|
|
22
|
+
'condition-negate' => 'if c / unless c / c ? a : b: c -> !(c)',
|
|
23
|
+
'condition-true' => 'if c: c -> true',
|
|
24
|
+
'condition-false' => 'if c: c -> false',
|
|
25
|
+
'loop-condition-negate' => 'while c / until c: c -> !(c)',
|
|
26
|
+
'loop-condition-false' => 'while c: c -> false',
|
|
27
|
+
'literal-int' => 'n -> n + 1, n - 1, 0, 1 (each distinct value once)',
|
|
28
|
+
'literal-float' => 'x -> 0.0, x + 1.0',
|
|
29
|
+
'literal-string' => '"abc" -> "", "" -> "mutaruby"',
|
|
30
|
+
'literal-symbol' => ':sym -> :__mutaruby__',
|
|
31
|
+
'literal-boolean' => 'true <-> false',
|
|
32
|
+
'literal-nil' => 'nil -> true',
|
|
33
|
+
'literal-array' => '[a, b] -> []',
|
|
34
|
+
'literal-hash' => '{ k => v } -> {}',
|
|
35
|
+
'range-swap' => 'a..b <-> a...b',
|
|
36
|
+
'return-nil' => 'return x -> return nil',
|
|
37
|
+
'method-body-nil' => 'def m; body; end -> def m; nil; end',
|
|
38
|
+
'method-body-bool' => 'def m?; body; end -> true / false (predicate methods only)',
|
|
39
|
+
'statement-delete' => 'one statement of a multi-statement body -> nil',
|
|
40
|
+
'argument-removal' => 'f(a, b) -> f(b), f(a) (one argument removed at a time)',
|
|
41
|
+
'call-to-receiver' => 'recv.m(args) -> recv',
|
|
42
|
+
'call-to-argument' => 'recv.m(a, ...) -> a',
|
|
43
|
+
'predicate-true' => 'x.m? -> true',
|
|
44
|
+
'predicate-false' => 'x.m? -> false',
|
|
45
|
+
'safe-navigation' => 'a&.m -> a.m',
|
|
46
|
+
'raise-removal' => 'raise X -> nil (also fail)',
|
|
47
|
+
'op-assign' => 'swap compound assignment (+= <-> -=, *= <-> /=, |= <-> &=, <<= <-> >>=)',
|
|
48
|
+
'or-assign' => 'a ||= b -> a = b',
|
|
49
|
+
'and-assign' => 'a &&= b -> a = b'
|
|
50
|
+
}.freeze
|
|
51
|
+
|
|
52
|
+
SWAPS = {
|
|
53
|
+
'arithmetic' => { :+ => :-, :- => :+, :* => :/, :/ => :*, :% => :*, :** => :* },
|
|
54
|
+
'comparison' => { :== => :!=, :!= => :==, :< => :<=, :<= => :<, :> => :>=, :>= => :>, :<=> => :==,
|
|
55
|
+
:=== => :!=, :=~ => :!~, :!~ => :=~ },
|
|
56
|
+
'bitwise' => { :& => :|, :| => :&, :^ => :&, :<< => :>>, :>> => :<< }
|
|
57
|
+
}.freeze
|
|
58
|
+
OP_ASSIGN = { :+ => :-, :- => :+, :* => :/, :/ => :*, :| => :&, :& => :|, :<< => :>>, :>> => :<< }.freeze
|
|
59
|
+
|
|
60
|
+
# Calls that are declarations, not computations: their arguments are never removed and their string arguments are
|
|
61
|
+
# never emptied (a mutant of `require 'json'` that requires "" is noise, not a test gap).
|
|
62
|
+
DECLARATIONS = %i[require require_relative load autoload attr_reader attr_writer attr_accessor include extend prepend
|
|
63
|
+
private public protected module_function private_constant public_constant
|
|
64
|
+
private_class_method public_class_method define_method alias_method raise fail].freeze
|
|
65
|
+
VISIBILITY = %i[private public protected module_function].freeze
|
|
66
|
+
# Definitions are not deleted as statements: deleting a whole class or method is caught by any test that touches
|
|
67
|
+
# it, and the interesting mutants are inside (method-body-nil and the operators on the body).
|
|
68
|
+
STRUCTURAL = %i[module class sclass def defs].freeze
|
|
69
|
+
PREDICATE_SKIP = %i[block_given? frozen? respond_to? respond_to_missing? nil?].freeze
|
|
70
|
+
DISABLE_MARK = 'mutaruby:disable'
|
|
71
|
+
|
|
72
|
+
attr_reader :errors
|
|
73
|
+
|
|
74
|
+
# path: file path for messages; rel: path relative to the project root (stored on the mutants).
|
|
75
|
+
def initialize(source, path:, rel: path, operators: nil)
|
|
76
|
+
@src = source
|
|
77
|
+
@path = path
|
|
78
|
+
@rel = rel
|
|
79
|
+
@operators = operators # nil = all, else a Set of ids
|
|
80
|
+
@valid_encoding = source.valid_encoding?
|
|
81
|
+
@lines = @valid_encoding ? source.split("\n", -1) : []
|
|
82
|
+
@occurrence = occurrence_index(@lines)
|
|
83
|
+
@errors = []
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def mutations
|
|
87
|
+
@mutations ||= build
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def build
|
|
93
|
+
unless @valid_encoding
|
|
94
|
+
@errors << "#{@path}: cannot parse (not valid #{@src.encoding}); skipped"
|
|
95
|
+
return []
|
|
96
|
+
end
|
|
97
|
+
ast = Parsing.parse(@path, @src)
|
|
98
|
+
@out = []
|
|
99
|
+
@seen = {}
|
|
100
|
+
@scope = [] # namespace names ("Foo", "Bar")
|
|
101
|
+
@meth = [] # enclosing method names, qualified
|
|
102
|
+
@singleton = 0
|
|
103
|
+
walk(ast, nil) if ast
|
|
104
|
+
@out.each_with_index { |m, i| m.id = i }
|
|
105
|
+
@out
|
|
106
|
+
rescue Parser::SyntaxError, EncodingError, ArgumentError => e
|
|
107
|
+
@errors << "#{@path}: cannot parse (#{e.message}); skipped"
|
|
108
|
+
[]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def occurrence_index(lines)
|
|
112
|
+
seen = Hash.new(0)
|
|
113
|
+
lines.each_with_index.to_h { |l, i| [i + 1, seen[l.strip] += 1] }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def enabled?(op)
|
|
117
|
+
@operators.nil? || @operators.include?(op)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Splices `repl` over `range` (a Parser::Source::Range) and records the mutant if it is new and still parses.
|
|
121
|
+
def add(range, repl, op)
|
|
122
|
+
return unless enabled?(op)
|
|
123
|
+
|
|
124
|
+
orig = range.source
|
|
125
|
+
return if orig == repl
|
|
126
|
+
|
|
127
|
+
lineno = range.line
|
|
128
|
+
line = @lines[lineno - 1].to_s
|
|
129
|
+
return if line.include?(DISABLE_MARK)
|
|
130
|
+
|
|
131
|
+
text = Splicer.splice(@src, range.begin_pos, range.end_pos, orig, repl)
|
|
132
|
+
return if @seen[text]
|
|
133
|
+
|
|
134
|
+
@seen[text] = true
|
|
135
|
+
return unless Parsing.valid?(@path, text)
|
|
136
|
+
|
|
137
|
+
@out << Mutation.new(file: @rel, operator: op, orig: orig, repl: repl, lineno: lineno, line_text: line.strip,
|
|
138
|
+
occurrence: @occurrence[lineno], method: @meth.last, text: text)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def declaration?(node)
|
|
142
|
+
node&.type == :send && DECLARATIONS.include?(node.children[1])
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def src_of(node)
|
|
146
|
+
node.loc.expression.source
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def walk(node, parent)
|
|
150
|
+
return unless node.is_a?(Parser::AST::Node)
|
|
151
|
+
|
|
152
|
+
case node.type
|
|
153
|
+
when :module, :class
|
|
154
|
+
visit(node, parent)
|
|
155
|
+
@scope.push(src_of(node.children[0]))
|
|
156
|
+
node.children.each { |c| walk(c, node) }
|
|
157
|
+
@scope.pop
|
|
158
|
+
when :sclass
|
|
159
|
+
visit(node, parent)
|
|
160
|
+
@singleton += 1
|
|
161
|
+
node.children.each { |c| walk(c, node) }
|
|
162
|
+
@singleton -= 1
|
|
163
|
+
when :def, :defs
|
|
164
|
+
@meth.push(qualified(node))
|
|
165
|
+
visit(node, parent)
|
|
166
|
+
node.children.each { |c| walk(c, node) }
|
|
167
|
+
@meth.pop
|
|
168
|
+
else
|
|
169
|
+
visit(node, parent)
|
|
170
|
+
node.children.each { |c| walk(c, node) }
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def qualified(node)
|
|
175
|
+
ns = @scope.join('::')
|
|
176
|
+
if node.type == :defs
|
|
177
|
+
"#{ns.empty? ? src_of(node.children[0]) : ns}.#{node.children[1]}"
|
|
178
|
+
elsif @singleton.positive?
|
|
179
|
+
"#{ns}.#{node.children[0]}"
|
|
180
|
+
else
|
|
181
|
+
ns.empty? ? node.children[0].to_s : "#{ns}##{node.children[0]}"
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def visit(n, parent)
|
|
186
|
+
case n.type
|
|
187
|
+
when :send, :csend then visit_send(n, parent)
|
|
188
|
+
when :and, :or then visit_logical(n)
|
|
189
|
+
when :if then visit_if(n)
|
|
190
|
+
when :while, :until, :while_post, :until_post then visit_loop(n)
|
|
191
|
+
when :int then visit_int(n)
|
|
192
|
+
when :float then visit_float(n)
|
|
193
|
+
when :str then visit_str(n, parent)
|
|
194
|
+
when :sym
|
|
195
|
+
add(n.loc.expression, ':__mutaruby__', 'literal-symbol') if n.loc.expression.source.match?(/\A:[A-Za-z_]\w*[?!=]?\z/) &&
|
|
196
|
+
!declaration?(parent)
|
|
197
|
+
when :true then add(n.loc.expression, 'false', 'literal-boolean')
|
|
198
|
+
when :false then add(n.loc.expression, 'true', 'literal-boolean')
|
|
199
|
+
when :nil then add(n.loc.expression, 'true', 'literal-nil')
|
|
200
|
+
when :array
|
|
201
|
+
add(n.loc.expression, '[]', 'literal-array') if n.children.any? && n.loc.begin&.source == '['
|
|
202
|
+
when :hash
|
|
203
|
+
add(n.loc.expression, '{}', 'literal-hash') if n.children.any? && n.loc.begin&.source == '{'
|
|
204
|
+
when :irange, :erange
|
|
205
|
+
# an endless range (a..) means the same with either operator
|
|
206
|
+
add(n.loc.operator, n.type == :irange ? '...' : '..', 'range-swap') unless n.children[1].nil?
|
|
207
|
+
when :return
|
|
208
|
+
add(n.loc.expression, 'return nil', 'return-nil') if n.children.any?
|
|
209
|
+
when :op_asgn
|
|
210
|
+
sw = OP_ASSIGN[n.children[1]]
|
|
211
|
+
add(n.loc.operator, "#{sw}=", 'op-assign') if sw
|
|
212
|
+
when :or_asgn then add(n.loc.operator, '=', 'or-assign')
|
|
213
|
+
when :and_asgn then add(n.loc.operator, '=', 'and-assign')
|
|
214
|
+
when :begin, :kwbegin then visit_body(n)
|
|
215
|
+
when :def, :defs then visit_def(n)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def visit_send(n, parent)
|
|
220
|
+
recv, meth, *args = *n
|
|
221
|
+
sel = n.loc.selector
|
|
222
|
+
SWAPS.each do |op, table|
|
|
223
|
+
add(sel, table[meth].to_s, op) if table.key?(meth) && recv && args.size == 1 && sel
|
|
224
|
+
end
|
|
225
|
+
if meth == :! && recv
|
|
226
|
+
add(n.loc.expression, src_of(recv), 'negation-removal')
|
|
227
|
+
elsif meth == :-@ && recv
|
|
228
|
+
add(n.loc.expression, src_of(recv), 'unary-minus-removal')
|
|
229
|
+
end
|
|
230
|
+
add(n.loc.dot, '.', 'safe-navigation') if n.type == :csend && n.loc.dot
|
|
231
|
+
if recv.nil? && %i[raise fail].include?(meth)
|
|
232
|
+
add(n.loc.expression, 'nil', 'raise-removal')
|
|
233
|
+
return
|
|
234
|
+
end
|
|
235
|
+
name = meth.to_s
|
|
236
|
+
if name.end_with?('?') && !PREDICATE_SKIP.include?(meth)
|
|
237
|
+
add(n.loc.expression, 'true', 'predicate-true')
|
|
238
|
+
add(n.loc.expression, 'false', 'predicate-false')
|
|
239
|
+
end
|
|
240
|
+
plain = name.match?(/\A[a-z_]\w*[?!]?\z/) # a named method, not an operator or a writer
|
|
241
|
+
block_call = parent&.type == :block && parent.children[0].equal?(n)
|
|
242
|
+
if recv && plain && sel && !block_call
|
|
243
|
+
add(n.loc.expression, src_of(recv), 'call-to-receiver')
|
|
244
|
+
first = args.first
|
|
245
|
+
add(n.loc.expression, src_of(first), 'call-to-argument') if first && !%i[block_pass splat kwsplat hash].include?(first.type)
|
|
246
|
+
end
|
|
247
|
+
remove_arguments(n, args) if plain && !DECLARATIONS.include?(meth) && args.any? && parent&.type != :masgn
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def remove_arguments(n, args)
|
|
251
|
+
return if args.size > 6
|
|
252
|
+
|
|
253
|
+
first = args.first.loc.expression
|
|
254
|
+
last = args.last.loc.expression
|
|
255
|
+
range = first.join(last)
|
|
256
|
+
args.each_index do |i|
|
|
257
|
+
rest = args.each_with_index.reject { |_, j| j == i }.map { |a, _| src_of(a) }
|
|
258
|
+
add(range, rest.join(', '), 'argument-removal')
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def visit_logical(n)
|
|
263
|
+
l, r = *n
|
|
264
|
+
kw = n.loc.operator.source
|
|
265
|
+
repl = { '&&' => '||', '||' => '&&', 'and' => 'or', 'or' => 'and' }.fetch(kw)
|
|
266
|
+
add(n.loc.operator, repl, 'logical-swap')
|
|
267
|
+
add(n.loc.expression, src_of(l), 'logical-drop-right')
|
|
268
|
+
add(n.loc.expression, src_of(r), 'logical-drop-left')
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def visit_if(n)
|
|
272
|
+
cond = n.children[0]
|
|
273
|
+
return unless cond.is_a?(Parser::AST::Node)
|
|
274
|
+
|
|
275
|
+
add(cond.loc.expression, "!(#{src_of(cond)})", 'condition-negate')
|
|
276
|
+
add(cond.loc.expression, 'true', 'condition-true')
|
|
277
|
+
add(cond.loc.expression, 'false', 'condition-false')
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def visit_loop(n)
|
|
281
|
+
cond = n.children[0]
|
|
282
|
+
add(cond.loc.expression, "!(#{src_of(cond)})", 'loop-condition-negate')
|
|
283
|
+
add(cond.loc.expression, 'false', 'loop-condition-false') if %i[while while_post].include?(n.type)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def visit_int(n)
|
|
287
|
+
v = n.children[0]
|
|
288
|
+
[v + 1, v - 1, 0, 1].uniq.reject { |x| x == v }.each { |x| add(n.loc.expression, x.to_s, 'literal-int') }
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def visit_float(n)
|
|
292
|
+
v = n.children[0]
|
|
293
|
+
add(n.loc.expression, '0.0', 'literal-float') unless v.zero?
|
|
294
|
+
add(n.loc.expression, (v + 1.0).to_s, 'literal-float') if v.finite?
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def visit_str(n, parent)
|
|
298
|
+
return if parent && %i[dstr xstr dsym regexp].include?(parent.type)
|
|
299
|
+
return if declaration?(parent)
|
|
300
|
+
|
|
301
|
+
q = n.loc.respond_to?(:begin) && n.loc.begin ? n.loc.begin.source : nil
|
|
302
|
+
return unless ['"', "'"].include?(q)
|
|
303
|
+
|
|
304
|
+
if n.children[0].empty?
|
|
305
|
+
add(n.loc.expression, "#{q}mutaruby#{q}", 'literal-string')
|
|
306
|
+
else
|
|
307
|
+
add(n.loc.expression, "#{q}#{q}", 'literal-string')
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def visit_body(n)
|
|
312
|
+
kids = n.children
|
|
313
|
+
return unless kids.size > 1
|
|
314
|
+
|
|
315
|
+
kids.each do |k|
|
|
316
|
+
next unless k.is_a?(Parser::AST::Node)
|
|
317
|
+
next if STRUCTURAL.include?(k.type)
|
|
318
|
+
next if k.type == :send && k.children[0].nil? && VISIBILITY.include?(k.children[1]) && k.children.size == 2
|
|
319
|
+
|
|
320
|
+
add(k.loc.expression, 'nil', 'statement-delete')
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def visit_def(n)
|
|
325
|
+
body = n.children.last
|
|
326
|
+
return unless body.is_a?(Parser::AST::Node)
|
|
327
|
+
|
|
328
|
+
add(body.loc.expression, 'nil', 'method-body-nil')
|
|
329
|
+
name = n.type == :def ? n.children[0] : n.children[1]
|
|
330
|
+
return unless name.to_s.end_with?('?')
|
|
331
|
+
|
|
332
|
+
add(body.loc.expression, 'true', 'method-body-bool')
|
|
333
|
+
add(body.loc.expression, 'false', 'method-body-bool')
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module Mutaruby
|
|
6
|
+
# The ignore file lists equivalent mutants: mutants that cannot be killed because they do not change behaviour.
|
|
7
|
+
#
|
|
8
|
+
# An entry names one mutation SITE, not a line number:
|
|
9
|
+
#
|
|
10
|
+
# - file: lib/calc.rb # relative to the project root
|
|
11
|
+
# operator: comparison # operator id
|
|
12
|
+
# line: "return 0 if n <= 0" # the source line, whitespace-trimmed
|
|
13
|
+
# occurrence: 2 # Nth line with exactly that text in the file (required if it is not unique)
|
|
14
|
+
# method: "Calc#clamp" # optional: enclosing method
|
|
15
|
+
# mutation: "<= => <" # optional: narrows the entry to one change at the site
|
|
16
|
+
# lineno: 41 # optional hint for humans; never used for matching (it drifts)
|
|
17
|
+
# reason: "n is never negative here" # REQUIRED
|
|
18
|
+
#
|
|
19
|
+
# Line numbers drift whenever code above them changes, so they are a hint only. The key is the text of the line plus
|
|
20
|
+
# its occurrence among identical lines, plus the enclosing method: editing other code does not invalidate it, and
|
|
21
|
+
# editing the line itself does (the entry becomes STALE and is reported, so it gets re-reviewed).
|
|
22
|
+
class IgnoreFile
|
|
23
|
+
class Invalid < StandardError; end
|
|
24
|
+
|
|
25
|
+
Entry = Struct.new(:file, :operator, :line, :occurrence, :method, :mutation, :lineno, :reason, :index,
|
|
26
|
+
keyword_init: true) do
|
|
27
|
+
def label
|
|
28
|
+
"##{index + 1} #{file}#{lineno ? ":#{lineno}" : ''} #{operator} #{line.inspect}" \
|
|
29
|
+
"#{occurrence ? " (occurrence #{occurrence})" : ''}#{method ? " in #{method}" : ''}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
KEYS = %w[file operator line occurrence method mutation lineno reason].freeze
|
|
34
|
+
|
|
35
|
+
attr_reader :entries, :path
|
|
36
|
+
|
|
37
|
+
def self.load(path)
|
|
38
|
+
new(YAML.safe_load(File.read(path), permitted_classes: [], aliases: false), path: path)
|
|
39
|
+
rescue Psych::SyntaxError => e
|
|
40
|
+
raise Invalid, "#{path}: not valid YAML (#{e.message})"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def initialize(data, path: '(ignore file)')
|
|
44
|
+
@path = path
|
|
45
|
+
data = data['ignore'] if data.is_a?(Hash) && data.key?('ignore')
|
|
46
|
+
data ||= []
|
|
47
|
+
raise Invalid, "#{path}: expected a list of entries" unless data.is_a?(Array)
|
|
48
|
+
|
|
49
|
+
@entries = data.each_with_index.map { |raw, i| parse_entry(raw, i) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Static validation against the current sources: { rel_path => source }. Returns error strings.
|
|
53
|
+
def validate(sources)
|
|
54
|
+
errs = []
|
|
55
|
+
@entries.each do |e|
|
|
56
|
+
src = sources[e.file]
|
|
57
|
+
next unless src # file not part of this run
|
|
58
|
+
|
|
59
|
+
count = src.split("\n", -1).count { |l| l.strip == e.line }
|
|
60
|
+
if count.zero?
|
|
61
|
+
errs << "STALE (the line is no longer in the file; re-review or remove the entry): #{e.label}"
|
|
62
|
+
elsif e.occurrence.nil? && count > 1
|
|
63
|
+
errs << "AMBIGUOUS (the line occurs #{count} times; add `occurrence`): #{e.label}"
|
|
64
|
+
elsif e.occurrence && e.occurrence > count
|
|
65
|
+
errs << "STALE (occurrence #{e.occurrence} but the line now occurs #{count} time(s)): #{e.label}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
errs
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The entry covering this mutant, or nil.
|
|
72
|
+
def match(mutation)
|
|
73
|
+
@entries.find do |e|
|
|
74
|
+
e.file == mutation.file && e.operator == mutation.operator && e.line == mutation.line_text &&
|
|
75
|
+
(e.occurrence || 1) == mutation.occurrence &&
|
|
76
|
+
(e.method.nil? || e.method == mutation.method) &&
|
|
77
|
+
(e.mutation.nil? || normalize(e.mutation) == mutation.change)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Entries for files in `complete_files` that matched no surviving mutant: the mutant is now killed, or the code
|
|
82
|
+
# changed. Either way the entry must be removed or re-reviewed.
|
|
83
|
+
def stale(matched_entries, complete_files)
|
|
84
|
+
@entries.reject { |e| matched_entries.include?(e) }.select { |e| complete_files.include?(e.file) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# YAML stubs for the given survivors (reason left empty on purpose: the file is rejected until it is filled in).
|
|
88
|
+
def self.stub_for(mutations, sources)
|
|
89
|
+
mutations.map do |m|
|
|
90
|
+
h = { 'file' => m.file, 'operator' => m.operator, 'line' => m.line_text }
|
|
91
|
+
count = sources[m.file].to_s.split("\n", -1).count { |l| l.strip == m.line_text }
|
|
92
|
+
h['occurrence'] = m.occurrence if count > 1
|
|
93
|
+
h['method'] = m.method if m.method
|
|
94
|
+
h['mutation'] = m.change
|
|
95
|
+
h['lineno'] = m.lineno
|
|
96
|
+
h['reason'] = ''
|
|
97
|
+
h
|
|
98
|
+
end.to_yaml
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def normalize(s)
|
|
104
|
+
s.to_s.gsub(/\s+/, ' ').strip
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def parse_entry(raw, i)
|
|
108
|
+
where = "#{@path}: entry ##{i + 1}"
|
|
109
|
+
raise Invalid, "#{where}: expected a mapping" unless raw.is_a?(Hash)
|
|
110
|
+
|
|
111
|
+
unknown = raw.keys.map(&:to_s) - KEYS
|
|
112
|
+
raise Invalid, "#{where}: unknown key(s) #{unknown.join(', ')}" if unknown.any?
|
|
113
|
+
|
|
114
|
+
%w[file operator line reason].each do |k|
|
|
115
|
+
v = raw[k]
|
|
116
|
+
raise Invalid, "#{where}: `#{k}` is required" if v.nil? || v.to_s.strip.empty?
|
|
117
|
+
end
|
|
118
|
+
%w[occurrence lineno].each do |k|
|
|
119
|
+
v = raw[k]
|
|
120
|
+
raise Invalid, "#{where}: `#{k}` must be a positive integer" unless v.nil? || (v.is_a?(Integer) && v.positive?)
|
|
121
|
+
end
|
|
122
|
+
unless Generator::OPERATORS.key?(raw['operator'].to_s)
|
|
123
|
+
raise Invalid, "#{where}: unknown operator #{raw['operator'].inspect}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
Entry.new(file: raw['file'].to_s.sub(%r{\A\./}, ''), operator: raw['operator'].to_s, line: raw['line'].to_s.strip,
|
|
127
|
+
occurrence: raw['occurrence'], method: raw['method']&.to_s, mutation: raw['mutation']&.to_s,
|
|
128
|
+
lineno: raw['lineno'], reason: raw['reason'].to_s.strip, index: i)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutaruby
|
|
4
|
+
# One mutant: a single source edit of one file.
|
|
5
|
+
#
|
|
6
|
+
# file path of the mutated file, relative to the project root
|
|
7
|
+
# operator operator id (see OPERATORS / README)
|
|
8
|
+
# orig, repl the replaced source slice and its replacement
|
|
9
|
+
# lineno 1-based line where the replaced slice starts
|
|
10
|
+
# line_text that line, stripped (part of the site key)
|
|
11
|
+
# occurrence 1-based index of line_text among the identical (stripped) lines of the file (part of the site key)
|
|
12
|
+
# method enclosing method, qualified: "Foo::Bar#baz" (instance), "Foo::Bar.baz" (singleton), nil at top level
|
|
13
|
+
# text the complete mutated file
|
|
14
|
+
Mutation = Struct.new(:id, :file, :operator, :orig, :repl, :lineno, :line_text, :occurrence, :method, :text,
|
|
15
|
+
keyword_init: true) do
|
|
16
|
+
def short(str, max = 80)
|
|
17
|
+
s = str.gsub(/\s+/, ' ')
|
|
18
|
+
s.length > max ? "#{s[0, max - 3]}..." : s
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Human label: "comparison: > => >="
|
|
22
|
+
def label
|
|
23
|
+
"#{operator}: #{short(orig)} => #{short(repl)}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Normalised "orig => repl" (whitespace collapsed); the optional `mutation` field of an ignore entry.
|
|
27
|
+
def change
|
|
28
|
+
"#{orig.gsub(/\s+/, ' ').strip} => #{repl.gsub(/\s+/, ' ').strip}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def location
|
|
32
|
+
"#{file}:#{lineno}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The mutated region with one line of context, as a minimal unified-style diff.
|
|
36
|
+
def diff(original_source)
|
|
37
|
+
lines = original_source.lines
|
|
38
|
+
mlines = text.lines
|
|
39
|
+
i = lineno - 1
|
|
40
|
+
orig_n = orig.count("\n") + 1
|
|
41
|
+
repl_n = repl.count("\n") + 1
|
|
42
|
+
out = []
|
|
43
|
+
(lines[i, orig_n] || []).each { |l| out << "- #{l.chomp}" }
|
|
44
|
+
(mlines[i, repl_n] || []).each { |l| out << "+ #{l.chomp}" }
|
|
45
|
+
out.join("\n")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def to_h
|
|
49
|
+
{ id: id, file: file, lineno: lineno, operator: operator, method: method, line_text: line_text,
|
|
50
|
+
occurrence: occurrence, orig: orig, repl: repl, change: change, label: label }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'parser'
|
|
4
|
+
|
|
5
|
+
module Mutaruby
|
|
6
|
+
# Picks the newest `parser` grammar that is not newer than the running Ruby. `parser/current` prints a warning on
|
|
7
|
+
# Rubies it does not know yet; choosing the class ourselves keeps the output clean and the behaviour explicit.
|
|
8
|
+
module Parsing
|
|
9
|
+
class << self
|
|
10
|
+
def parser_class
|
|
11
|
+
@parser_class ||= begin
|
|
12
|
+
major, minor = RUBY_VERSION.split('.').map(&:to_i)
|
|
13
|
+
wanted = (major * 10) + minor
|
|
14
|
+
found = nil
|
|
15
|
+
wanted.downto(31) do |v|
|
|
16
|
+
begin
|
|
17
|
+
require "parser/ruby#{v}"
|
|
18
|
+
found = Parser.const_get("Ruby#{v}")
|
|
19
|
+
break
|
|
20
|
+
rescue LoadError, NameError
|
|
21
|
+
next
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
found || raise(Error, "no parser grammar available for Ruby #{RUBY_VERSION}")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def buffer(path, source)
|
|
29
|
+
Parser::Source::Buffer.new(path, source: source)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Parse `source`; returns the AST or raises Parser::SyntaxError.
|
|
33
|
+
def parse(path, source)
|
|
34
|
+
p = parser_class.new
|
|
35
|
+
p.diagnostics.all_errors_are_fatal = true
|
|
36
|
+
p.diagnostics.ignore_warnings = true
|
|
37
|
+
p.parse(buffer(path, source))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def valid?(path, source)
|
|
41
|
+
parse(path, source)
|
|
42
|
+
true
|
|
43
|
+
rescue Parser::SyntaxError, EncodingError, ArgumentError
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|