mutaruby 0.1.0 → 0.1.1
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 +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +13 -4
- data/lib/mutaruby/cli.rb +75 -67
- data/lib/mutaruby/command.rb +40 -31
- data/lib/mutaruby/error.rb +6 -0
- data/lib/mutaruby/generator.rb +59 -74
- data/lib/mutaruby/ignore_file.rb +26 -30
- data/lib/mutaruby/mutation.rb +16 -13
- data/lib/mutaruby/parsing.rb +15 -19
- data/lib/mutaruby/reporters.rb +32 -27
- data/lib/mutaruby/runner.rb +67 -72
- data/lib/mutaruby/sandbox.rb +18 -16
- data/lib/mutaruby/selection.rb +21 -30
- data/lib/mutaruby/version.rb +1 -1
- data/lib/mutaruby.rb +0 -15
- metadata +2 -1
data/lib/mutaruby/generator.rb
CHANGED
|
@@ -26,7 +26,7 @@ module Mutaruby
|
|
|
26
26
|
'loop-condition-false' => 'while c: c -> false',
|
|
27
27
|
'literal-int' => 'n -> n + 1, n - 1, 0, 1 (each distinct value once)',
|
|
28
28
|
'literal-float' => 'x -> 0.0, x + 1.0',
|
|
29
|
-
'literal-string' => '"abc" -> "", "" -> "mutaruby"',
|
|
29
|
+
'literal-string' => '"abc" -> "", "" -> "mutaruby" (also single-quoted and %q/%Q strings)',
|
|
30
30
|
'literal-symbol' => ':sym -> :__mutaruby__',
|
|
31
31
|
'literal-boolean' => 'true <-> false',
|
|
32
32
|
'literal-nil' => 'nil -> true',
|
|
@@ -62,23 +62,26 @@ module Mutaruby
|
|
|
62
62
|
DECLARATIONS = %i[require require_relative load autoload attr_reader attr_writer attr_accessor include extend prepend
|
|
63
63
|
private public protected module_function private_constant public_constant
|
|
64
64
|
private_class_method public_class_method define_method alias_method raise fail].freeze
|
|
65
|
-
|
|
65
|
+
# Bare visibility calls (`private` on its own line) are never deleted: that only changes which methods are public.
|
|
66
|
+
VISIBILITY_CALLS = %i[private public protected module_function].map { |v| Parser::AST::Node.new(:send, [nil, v]) }.freeze
|
|
66
67
|
# Definitions are not deleted as statements: deleting a whole class or method is caught by any test that touches
|
|
67
68
|
# it, and the interesting mutants are inside (method-body-nil and the operators on the body).
|
|
68
69
|
STRUCTURAL = %i[module class sclass def defs].freeze
|
|
69
70
|
PREDICATE_SKIP = %i[block_given? frozen? respond_to? respond_to_missing? nil?].freeze
|
|
70
71
|
DISABLE_MARK = 'mutaruby:disable'
|
|
72
|
+
MAX_ARGS = 6 # argument-removal skips calls with more arguments than this
|
|
71
73
|
|
|
72
74
|
attr_reader :errors
|
|
73
75
|
|
|
74
76
|
# path: file path for messages; rel: path relative to the project root (stored on the mutants).
|
|
77
|
+
# operators: nil (all) or a list of operator ids.
|
|
75
78
|
def initialize(source, path:, rel: path, operators: nil)
|
|
76
79
|
@src = source
|
|
77
80
|
@path = path
|
|
78
81
|
@rel = rel
|
|
79
|
-
@operators = operators
|
|
82
|
+
@operators = operators
|
|
80
83
|
@valid_encoding = source.valid_encoding?
|
|
81
|
-
@lines = @valid_encoding ? source.
|
|
84
|
+
@lines = @valid_encoding ? source.lines.map(&:strip) : []
|
|
82
85
|
@occurrence = occurrence_index(@lines)
|
|
83
86
|
@errors = []
|
|
84
87
|
end
|
|
@@ -97,20 +100,19 @@ module Mutaruby
|
|
|
97
100
|
ast = Parsing.parse(@path, @src)
|
|
98
101
|
@out = []
|
|
99
102
|
@seen = {}
|
|
100
|
-
@scope = []
|
|
101
|
-
@meth = []
|
|
102
|
-
|
|
103
|
-
walk(ast, nil) if ast
|
|
103
|
+
@scope = [] # namespace names ("Foo", "Bar")
|
|
104
|
+
@meth = [] # enclosing method names, qualified; @singleton: inside `class << self`
|
|
105
|
+
walk(ast, nil)
|
|
104
106
|
@out.each_with_index { |m, i| m.id = i }
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
@errors << "#{@path}: cannot parse (#{e.message}); skipped"
|
|
107
|
+
rescue Parser::SyntaxError => e
|
|
108
|
+
@errors << "#{@path}: cannot parse (#{e}); skipped"
|
|
108
109
|
[]
|
|
109
110
|
end
|
|
110
111
|
|
|
112
|
+
# lineno => 1-based index of that line among the identical (stripped) lines of the file.
|
|
111
113
|
def occurrence_index(lines)
|
|
112
114
|
seen = Hash.new(0)
|
|
113
|
-
lines.each_with_index.to_h { |l, i| [i + 1, seen[l
|
|
115
|
+
lines.each_with_index.to_h { |l, i| [i + 1, seen[l] += 1] }
|
|
114
116
|
end
|
|
115
117
|
|
|
116
118
|
def enabled?(op)
|
|
@@ -125,7 +127,7 @@ module Mutaruby
|
|
|
125
127
|
return if orig == repl
|
|
126
128
|
|
|
127
129
|
lineno = range.line
|
|
128
|
-
line = @lines[lineno - 1]
|
|
130
|
+
line = @lines[lineno - 1]
|
|
129
131
|
return if line.include?(DISABLE_MARK)
|
|
130
132
|
|
|
131
133
|
text = Splicer.splice(@src, range.begin_pos, range.end_pos, orig, repl)
|
|
@@ -134,7 +136,7 @@ module Mutaruby
|
|
|
134
136
|
@seen[text] = true
|
|
135
137
|
return unless Parsing.valid?(@path, text)
|
|
136
138
|
|
|
137
|
-
@out << Mutation.new(file: @rel, operator: op, orig: orig, repl: repl, lineno: lineno, line_text: line
|
|
139
|
+
@out << Mutation.new(file: @rel, operator: op, orig: orig, repl: repl, lineno: lineno, line_text: line,
|
|
138
140
|
occurrence: @occurrence[lineno], method: @meth.last, text: text)
|
|
139
141
|
end
|
|
140
142
|
|
|
@@ -151,31 +153,34 @@ module Mutaruby
|
|
|
151
153
|
|
|
152
154
|
case node.type
|
|
153
155
|
when :module, :class
|
|
154
|
-
visit(node, parent)
|
|
155
156
|
@scope.push(src_of(node.children[0]))
|
|
156
|
-
|
|
157
|
+
walk_children(node)
|
|
157
158
|
@scope.pop
|
|
158
159
|
when :sclass
|
|
159
|
-
|
|
160
|
-
@singleton
|
|
161
|
-
|
|
162
|
-
@singleton
|
|
160
|
+
outer = @singleton
|
|
161
|
+
@singleton = true
|
|
162
|
+
walk_children(node)
|
|
163
|
+
@singleton = outer
|
|
163
164
|
when :def, :defs
|
|
164
165
|
@meth.push(qualified(node))
|
|
165
166
|
visit(node, parent)
|
|
166
|
-
|
|
167
|
+
walk_children(node)
|
|
167
168
|
@meth.pop
|
|
168
169
|
else
|
|
169
170
|
visit(node, parent)
|
|
170
|
-
|
|
171
|
+
walk_children(node)
|
|
171
172
|
end
|
|
172
173
|
end
|
|
173
174
|
|
|
175
|
+
def walk_children(node)
|
|
176
|
+
node.children.each { |c| walk(c, node) }
|
|
177
|
+
end
|
|
178
|
+
|
|
174
179
|
def qualified(node)
|
|
175
180
|
ns = @scope.join('::')
|
|
176
181
|
if node.type == :defs
|
|
177
182
|
"#{ns.empty? ? src_of(node.children[0]) : ns}.#{node.children[1]}"
|
|
178
|
-
elsif @singleton
|
|
183
|
+
elsif @singleton
|
|
179
184
|
"#{ns}.#{node.children[0]}"
|
|
180
185
|
else
|
|
181
186
|
ns.empty? ? node.children[0].to_s : "#{ns}##{node.children[0]}"
|
|
@@ -218,44 +223,31 @@ module Mutaruby
|
|
|
218
223
|
|
|
219
224
|
def visit_send(n, parent)
|
|
220
225
|
recv, meth, *args = *n
|
|
221
|
-
sel = n.loc.selector
|
|
222
226
|
SWAPS.each do |op, table|
|
|
223
|
-
add(
|
|
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')
|
|
227
|
+
add(n.loc.selector, table[meth].to_s, op) if table.key?(meth) && args.size == 1
|
|
229
228
|
end
|
|
230
|
-
add(n.loc.
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
name = meth.to_s
|
|
236
|
-
if name.end_with?('?') && !PREDICATE_SKIP.include?(meth)
|
|
229
|
+
add(n.loc.expression, src_of(recv), 'negation-removal') if meth == :!
|
|
230
|
+
add(n.loc.expression, src_of(recv), 'unary-minus-removal') if meth == :-@
|
|
231
|
+
add(n.loc.dot, '.', 'safe-navigation') if n.type == :csend
|
|
232
|
+
add(n.loc.expression, 'nil', 'raise-removal') if recv.nil? && %i[raise fail].include?(meth)
|
|
233
|
+
if meth.end_with?('?') && !PREDICATE_SKIP.include?(meth)
|
|
237
234
|
add(n.loc.expression, 'true', 'predicate-true')
|
|
238
235
|
add(n.loc.expression, 'false', 'predicate-false')
|
|
239
236
|
end
|
|
240
|
-
plain =
|
|
237
|
+
plain = meth.match?(/\A[a-z_]\w*[?!]?\z/) # a named method, not an operator or a writer
|
|
241
238
|
block_call = parent&.type == :block && parent.children[0].equal?(n)
|
|
242
|
-
if recv && plain &&
|
|
239
|
+
if recv && plain && !block_call
|
|
243
240
|
add(n.loc.expression, src_of(recv), 'call-to-receiver')
|
|
244
|
-
first
|
|
245
|
-
add(n.loc.expression, src_of(first), 'call-to-argument') if first && !%i[block_pass splat kwsplat hash].include?(first.type)
|
|
241
|
+
add(n.loc.expression, src_of(args.first), 'call-to-argument') if args.any?
|
|
246
242
|
end
|
|
247
|
-
remove_arguments(
|
|
243
|
+
remove_arguments(args) if plain && !DECLARATIONS.include?(meth) && args.size.between?(1, MAX_ARGS)
|
|
248
244
|
end
|
|
249
245
|
|
|
250
|
-
def remove_arguments(
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
first = args.first.loc.expression
|
|
254
|
-
last = args.last.loc.expression
|
|
255
|
-
range = first.join(last)
|
|
246
|
+
def remove_arguments(args)
|
|
247
|
+
range = args.first.loc.expression.join(args.last.loc.expression)
|
|
256
248
|
args.each_index do |i|
|
|
257
|
-
rest = args.
|
|
258
|
-
add(range, rest.join(', '), 'argument-removal')
|
|
249
|
+
rest = args.reject.with_index { |_, j| j == i }
|
|
250
|
+
add(range, rest.map { |a| src_of(a) }.join(', '), 'argument-removal')
|
|
259
251
|
end
|
|
260
252
|
end
|
|
261
253
|
|
|
@@ -269,53 +261,47 @@ module Mutaruby
|
|
|
269
261
|
end
|
|
270
262
|
|
|
271
263
|
def visit_if(n)
|
|
272
|
-
cond = n.children
|
|
273
|
-
return unless cond.is_a?(Parser::AST::Node)
|
|
274
|
-
|
|
264
|
+
cond = n.children.first
|
|
275
265
|
add(cond.loc.expression, "!(#{src_of(cond)})", 'condition-negate')
|
|
276
266
|
add(cond.loc.expression, 'true', 'condition-true')
|
|
277
267
|
add(cond.loc.expression, 'false', 'condition-false')
|
|
278
268
|
end
|
|
279
269
|
|
|
280
270
|
def visit_loop(n)
|
|
281
|
-
cond = n.children
|
|
271
|
+
cond = n.children.first
|
|
282
272
|
add(cond.loc.expression, "!(#{src_of(cond)})", 'loop-condition-negate')
|
|
283
273
|
add(cond.loc.expression, 'false', 'loop-condition-false') if %i[while while_post].include?(n.type)
|
|
284
274
|
end
|
|
285
275
|
|
|
286
276
|
def visit_int(n)
|
|
287
|
-
v = n.children
|
|
288
|
-
[v + 1, v - 1, 0, 1].
|
|
277
|
+
v = n.children.first
|
|
278
|
+
[v + 1, v - 1, 0, 1].each { |x| add(n.loc.expression, x.to_s, 'literal-int') }
|
|
289
279
|
end
|
|
290
280
|
|
|
291
281
|
def visit_float(n)
|
|
292
|
-
v = n.children
|
|
282
|
+
v = n.children.first
|
|
293
283
|
add(n.loc.expression, '0.0', 'literal-float') unless v.zero?
|
|
294
284
|
add(n.loc.expression, (v + 1.0).to_s, 'literal-float') if v.finite?
|
|
295
285
|
end
|
|
296
286
|
|
|
287
|
+
# Strings with both delimiters in the source: '...', "...", %q(...), %Q[...]. Not heredocs, not the parts of an
|
|
288
|
+
# interpolated string, not %w elements, not ?c character literals.
|
|
297
289
|
def visit_str(n, parent)
|
|
298
|
-
return if parent && %i[dstr xstr dsym regexp].include?(parent.type)
|
|
299
290
|
return if declaration?(parent)
|
|
300
291
|
|
|
301
|
-
|
|
302
|
-
|
|
292
|
+
open = n.loc.begin if n.loc.respond_to?(:begin)
|
|
293
|
+
close = n.loc.end if open
|
|
294
|
+
return unless close
|
|
303
295
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
else
|
|
307
|
-
add(n.loc.expression, "#{q}#{q}", 'literal-string')
|
|
308
|
-
end
|
|
296
|
+
fill = n.children.first.empty? ? 'mutaruby' : ''
|
|
297
|
+
add(n.loc.expression, "#{open.source}#{fill}#{close.source}", 'literal-string')
|
|
309
298
|
end
|
|
310
299
|
|
|
311
300
|
def visit_body(n)
|
|
312
|
-
|
|
313
|
-
return unless kids.size > 1
|
|
301
|
+
return unless n.children.size > 1
|
|
314
302
|
|
|
315
|
-
|
|
316
|
-
next
|
|
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
|
|
303
|
+
n.children.each do |k|
|
|
304
|
+
next if STRUCTURAL.include?(k.type) || VISIBILITY_CALLS.include?(k)
|
|
319
305
|
|
|
320
306
|
add(k.loc.expression, 'nil', 'statement-delete')
|
|
321
307
|
end
|
|
@@ -323,11 +309,10 @@ module Mutaruby
|
|
|
323
309
|
|
|
324
310
|
def visit_def(n)
|
|
325
311
|
body = n.children.last
|
|
326
|
-
return unless body
|
|
312
|
+
return unless body
|
|
327
313
|
|
|
328
314
|
add(body.loc.expression, 'nil', 'method-body-nil')
|
|
329
|
-
|
|
330
|
-
return unless name.to_s.end_with?('?')
|
|
315
|
+
return unless n.children[n.type == :def ? 0 : 1].end_with?('?')
|
|
331
316
|
|
|
332
317
|
add(body.loc.expression, 'true', 'method-body-bool')
|
|
333
318
|
add(body.loc.expression, 'false', 'method-body-bool')
|
data/lib/mutaruby/ignore_file.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'yaml'
|
|
4
|
+
require_relative 'generator'
|
|
4
5
|
|
|
5
6
|
module Mutaruby
|
|
6
7
|
# The ignore file lists equivalent mutants: mutants that cannot be killed because they do not change behaviour.
|
|
@@ -31,13 +32,20 @@ module Mutaruby
|
|
|
31
32
|
end
|
|
32
33
|
|
|
33
34
|
KEYS = %w[file operator line occurrence method mutation lineno reason].freeze
|
|
35
|
+
REQUIRED = %w[file operator line reason].freeze
|
|
36
|
+
STRINGS = %w[file operator reason method mutation].freeze
|
|
34
37
|
|
|
35
38
|
attr_reader :entries, :path
|
|
36
39
|
|
|
37
40
|
def self.load(path)
|
|
38
|
-
new(YAML.safe_load(File.read(path)
|
|
41
|
+
new(YAML.safe_load(File.read(path)), path: path)
|
|
39
42
|
rescue Psych::SyntaxError => e
|
|
40
|
-
raise Invalid, "#{path}: not valid YAML (#{e
|
|
43
|
+
raise Invalid, "#{path}: not valid YAML (#{e})"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# How many lines of `source` read `text` once stripped.
|
|
47
|
+
def self.count_lines(source, text)
|
|
48
|
+
source.each_line.count { |l| l.strip == text }
|
|
41
49
|
end
|
|
42
50
|
|
|
43
51
|
def initialize(data, path: '(ignore file)')
|
|
@@ -51,21 +59,19 @@ module Mutaruby
|
|
|
51
59
|
|
|
52
60
|
# Static validation against the current sources: { rel_path => source }. Returns error strings.
|
|
53
61
|
def validate(sources)
|
|
54
|
-
|
|
55
|
-
@entries.each do |e|
|
|
62
|
+
@entries.filter_map do |e|
|
|
56
63
|
src = sources[e.file]
|
|
57
64
|
next unless src # file not part of this run
|
|
58
65
|
|
|
59
|
-
count =
|
|
66
|
+
count = self.class.count_lines(src, e.line)
|
|
60
67
|
if count.zero?
|
|
61
|
-
|
|
68
|
+
"STALE (the line is no longer in the file; re-review or remove the entry): #{e.label}"
|
|
62
69
|
elsif e.occurrence.nil? && count > 1
|
|
63
|
-
|
|
70
|
+
"AMBIGUOUS (the line occurs #{count} times; add `occurrence`): #{e.label}"
|
|
64
71
|
elsif e.occurrence && e.occurrence > count
|
|
65
|
-
|
|
72
|
+
"STALE (occurrence #{e.occurrence} but the line now occurs #{count} time(s)): #{e.label}"
|
|
66
73
|
end
|
|
67
74
|
end
|
|
68
|
-
errs
|
|
69
75
|
end
|
|
70
76
|
|
|
71
77
|
# The entry covering this mutant, or nil.
|
|
@@ -74,7 +80,7 @@ module Mutaruby
|
|
|
74
80
|
e.file == mutation.file && e.operator == mutation.operator && e.line == mutation.line_text &&
|
|
75
81
|
(e.occurrence || 1) == mutation.occurrence &&
|
|
76
82
|
(e.method.nil? || e.method == mutation.method) &&
|
|
77
|
-
(e.mutation.nil? ||
|
|
83
|
+
(e.mutation.nil? || e.mutation == mutation.change)
|
|
78
84
|
end
|
|
79
85
|
end
|
|
80
86
|
|
|
@@ -88,44 +94,34 @@ module Mutaruby
|
|
|
88
94
|
def self.stub_for(mutations, sources)
|
|
89
95
|
mutations.map do |m|
|
|
90
96
|
h = { 'file' => m.file, 'operator' => m.operator, 'line' => m.line_text }
|
|
91
|
-
|
|
92
|
-
h['occurrence'] = m.occurrence if count > 1
|
|
97
|
+
h['occurrence'] = m.occurrence if count_lines(sources[m.file], m.line_text) > 1
|
|
93
98
|
h['method'] = m.method if m.method
|
|
94
|
-
h
|
|
95
|
-
h['lineno'] = m.lineno
|
|
96
|
-
h['reason'] = ''
|
|
97
|
-
h
|
|
99
|
+
h.merge('mutation' => m.change, 'lineno' => m.lineno, 'reason' => '')
|
|
98
100
|
end.to_yaml
|
|
99
101
|
end
|
|
100
102
|
|
|
101
103
|
private
|
|
102
104
|
|
|
103
|
-
def normalize(s)
|
|
104
|
-
s.to_s.gsub(/\s+/, ' ').strip
|
|
105
|
-
end
|
|
106
|
-
|
|
107
105
|
def parse_entry(raw, i)
|
|
108
106
|
where = "#{@path}: entry ##{i + 1}"
|
|
109
107
|
raise Invalid, "#{where}: expected a mapping" unless raw.is_a?(Hash)
|
|
110
108
|
|
|
111
|
-
unknown = raw.keys
|
|
109
|
+
unknown = raw.keys - KEYS
|
|
112
110
|
raise Invalid, "#{where}: unknown key(s) #{unknown.join(', ')}" if unknown.any?
|
|
113
111
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
raise Invalid, "#{where}: `#{k}`
|
|
112
|
+
REQUIRED.each { |k| raise Invalid, "#{where}: `#{k}` is required" if raw[k].to_s.strip.empty? }
|
|
113
|
+
STRINGS.each do |k|
|
|
114
|
+
raise Invalid, "#{where}: `#{k}` must be a string" unless raw[k].nil? || raw[k].is_a?(String)
|
|
117
115
|
end
|
|
118
116
|
%w[occurrence lineno].each do |k|
|
|
119
117
|
v = raw[k]
|
|
120
118
|
raise Invalid, "#{where}: `#{k}` must be a positive integer" unless v.nil? || (v.is_a?(Integer) && v.positive?)
|
|
121
119
|
end
|
|
122
|
-
unless Generator::OPERATORS.key?(raw['operator']
|
|
123
|
-
raise Invalid, "#{where}: unknown operator #{raw['operator'].inspect}"
|
|
124
|
-
end
|
|
120
|
+
raise Invalid, "#{where}: unknown operator #{raw['operator'].inspect}" unless Generator::OPERATORS.key?(raw['operator'])
|
|
125
121
|
|
|
126
|
-
Entry.new(file: raw['file'].
|
|
127
|
-
occurrence: raw['occurrence'], method: raw['method']
|
|
128
|
-
lineno: raw['lineno'], reason: raw['reason'].
|
|
122
|
+
Entry.new(file: raw['file'].delete_prefix('./'), operator: raw['operator'], line: raw['line'].to_s.strip,
|
|
123
|
+
occurrence: raw['occurrence'], method: raw['method'], mutation: raw['mutation'] && Mutation.squeeze(raw['mutation']),
|
|
124
|
+
lineno: raw['lineno'], reason: raw['reason'].strip, index: i)
|
|
129
125
|
end
|
|
130
126
|
end
|
|
131
127
|
end
|
data/lib/mutaruby/mutation.rb
CHANGED
|
@@ -13,9 +13,17 @@ module Mutaruby
|
|
|
13
13
|
# text the complete mutated file
|
|
14
14
|
Mutation = Struct.new(:id, :file, :operator, :orig, :repl, :lineno, :line_text, :occurrence, :method, :text,
|
|
15
15
|
keyword_init: true) do
|
|
16
|
-
|
|
16
|
+
LABEL_MAX = 80
|
|
17
|
+
|
|
18
|
+
# Whitespace runs collapsed to one space, ends stripped.
|
|
19
|
+
def self.squeeze(text)
|
|
20
|
+
text.gsub(/\s+/, ' ').strip
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# One-line form of a source slice, cut to LABEL_MAX characters.
|
|
24
|
+
def short(str)
|
|
17
25
|
s = str.gsub(/\s+/, ' ')
|
|
18
|
-
s.length >
|
|
26
|
+
s.length > LABEL_MAX ? "#{s[0, LABEL_MAX - 3]}..." : s
|
|
19
27
|
end
|
|
20
28
|
|
|
21
29
|
# Human label: "comparison: > => >="
|
|
@@ -23,26 +31,21 @@ module Mutaruby
|
|
|
23
31
|
"#{operator}: #{short(orig)} => #{short(repl)}"
|
|
24
32
|
end
|
|
25
33
|
|
|
26
|
-
#
|
|
34
|
+
# "orig => repl", squeezed; the optional `mutation` field of an ignore entry.
|
|
27
35
|
def change
|
|
28
|
-
"#{orig
|
|
36
|
+
Mutation.squeeze("#{orig} => #{repl}")
|
|
29
37
|
end
|
|
30
38
|
|
|
31
39
|
def location
|
|
32
40
|
"#{file}:#{lineno}"
|
|
33
41
|
end
|
|
34
42
|
|
|
35
|
-
# The
|
|
43
|
+
# The replaced lines and their replacement, as a minimal unified-style diff.
|
|
36
44
|
def diff(original_source)
|
|
37
|
-
lines = original_source.lines
|
|
38
|
-
mlines = text.lines
|
|
39
45
|
i = lineno - 1
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
+
old = original_source.lines[i, orig.count("\n") + 1].map { |l| "- #{l.chomp}" }
|
|
47
|
+
new = text.lines[i, repl.count("\n") + 1].map { |l| "+ #{l.chomp}" }
|
|
48
|
+
(old + new).join("\n")
|
|
46
49
|
end
|
|
47
50
|
|
|
48
51
|
def to_h
|
data/lib/mutaruby/parsing.rb
CHANGED
|
@@ -1,40 +1,36 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'parser'
|
|
4
|
+
require_relative 'error'
|
|
4
5
|
|
|
5
6
|
module Mutaruby
|
|
6
7
|
# Picks the newest `parser` grammar that is not newer than the running Ruby. `parser/current` prints a warning on
|
|
7
8
|
# Rubies it does not know yet; choosing the class ourselves keeps the output clean and the behaviour explicit.
|
|
8
9
|
module Parsing
|
|
10
|
+
OLDEST = 31 # Ruby 3.1
|
|
11
|
+
|
|
9
12
|
class << self
|
|
10
13
|
def parser_class
|
|
11
|
-
@parser_class ||=
|
|
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
|
|
14
|
+
@parser_class ||= grammar_for(RUBY_VERSION)
|
|
26
15
|
end
|
|
27
16
|
|
|
28
|
-
|
|
29
|
-
|
|
17
|
+
# The parser class for a Ruby version string ("3.3.5" -> Parser::Ruby33), falling back to older grammars.
|
|
18
|
+
def grammar_for(version)
|
|
19
|
+
major, minor = version.split('.').map(&:to_i)
|
|
20
|
+
((major * 10) + minor).downto(OLDEST) do |v|
|
|
21
|
+
require "parser/ruby#{v}"
|
|
22
|
+
return Parser.const_get("Ruby#{v}")
|
|
23
|
+
rescue LoadError
|
|
24
|
+
# this `parser` release has no grammar for v; try the next older one
|
|
25
|
+
end
|
|
26
|
+
raise Error, "no parser grammar available for Ruby #{version}"
|
|
30
27
|
end
|
|
31
28
|
|
|
32
29
|
# Parse `source`; returns the AST or raises Parser::SyntaxError.
|
|
33
30
|
def parse(path, source)
|
|
34
31
|
p = parser_class.new
|
|
35
32
|
p.diagnostics.all_errors_are_fatal = true
|
|
36
|
-
p.
|
|
37
|
-
p.parse(buffer(path, source))
|
|
33
|
+
p.parse(Parser::Source::Buffer.new(path, source: source))
|
|
38
34
|
end
|
|
39
35
|
|
|
40
36
|
def valid?(path, source)
|
data/lib/mutaruby/reporters.rb
CHANGED
|
@@ -22,7 +22,7 @@ module Mutaruby
|
|
|
22
22
|
io.puts
|
|
23
23
|
io.puts " #{m.location}#{m.method ? " in #{m.method}" : ''} [##{m.id}]"
|
|
24
24
|
io.puts " #{m.label}"
|
|
25
|
-
m.diff(report.sources[m.file]).
|
|
25
|
+
io.puts m.diff(report.sources[m.file]).gsub(/^/, ' ')
|
|
26
26
|
end
|
|
27
27
|
io.puts
|
|
28
28
|
end
|
|
@@ -53,11 +53,11 @@ module Mutaruby
|
|
|
53
53
|
score: report.score&.round(4), complete: report.complete,
|
|
54
54
|
counts: { mutants: report.outcomes.size, killed: report.killed, timeout: report.count(:timeout),
|
|
55
55
|
survived: report.count(:survived), equivalent: report.count(:equivalent) },
|
|
56
|
-
files: report.files, baseline_seconds: report.baseline_seconds
|
|
56
|
+
files: report.files, baseline_seconds: secs(report.baseline_seconds), timeout: report.timeout,
|
|
57
57
|
mutants: report.outcomes.map do |o|
|
|
58
|
-
o.mutation.to_h.merge(status: o.status
|
|
58
|
+
o.mutation.to_h.merge(status: o.status, seconds: secs(o.seconds), ignore_reason: o.ignore&.reason)
|
|
59
59
|
end,
|
|
60
|
-
stale_ignores: report.stale.map
|
|
60
|
+
stale_ignores: report.stale.map { |e| e.to_h.compact.except(:index) },
|
|
61
61
|
warnings: report.warnings
|
|
62
62
|
}
|
|
63
63
|
end
|
|
@@ -68,36 +68,41 @@ module Mutaruby
|
|
|
68
68
|
|
|
69
69
|
# JUnit-style XML: one testcase per mutant. A survivor is a failure, an equivalent mutant is skipped.
|
|
70
70
|
def junit(report, io)
|
|
71
|
-
|
|
71
|
+
n = report.outcomes.size
|
|
72
72
|
failures = report.count(:survived)
|
|
73
|
+
t = secs(report.seconds)
|
|
73
74
|
io.puts '<?xml version="1.0" encoding="UTF-8"?>'
|
|
74
|
-
io.puts %(<testsuites name="mutaruby" tests="#{
|
|
75
|
-
io.puts %( <testsuite name="mutaruby" tests="#{
|
|
76
|
-
%(skipped="#{report.count(:equivalent)}" time="#{
|
|
77
|
-
io.puts %( <properties><property name="score" value="#{
|
|
75
|
+
io.puts %(<testsuites name="mutaruby" tests="#{n}" failures="#{failures}" time="#{t}">)
|
|
76
|
+
io.puts %( <testsuite name="mutaruby" tests="#{n}" failures="#{failures}" errors="0" ) +
|
|
77
|
+
%(skipped="#{report.count(:equivalent)}" time="#{t}">)
|
|
78
|
+
io.puts %( <properties><property name="score" value="#{score_text(report)}"/></properties>)
|
|
78
79
|
report.outcomes.each do |o|
|
|
79
80
|
m = o.mutation
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
io.puts '>'
|
|
85
|
-
io.puts %( <failure message="#{x.call("survived: #{m.label}")}" type="survived">#{x.call(m.diff(report.sources[m.file]))}</failure>)
|
|
86
|
-
io.puts ' </testcase>'
|
|
87
|
-
when :equivalent
|
|
88
|
-
io.puts '>'
|
|
89
|
-
io.puts %( <skipped message="#{x.call("equivalent: #{o.ignore.reason}")}"/>)
|
|
90
|
-
io.puts ' </testcase>'
|
|
91
|
-
when :timeout
|
|
92
|
-
io.puts '>'
|
|
93
|
-
io.puts ' <system-out>killed by timeout</system-out>'
|
|
94
|
-
io.puts ' </testcase>'
|
|
95
|
-
else
|
|
96
|
-
io.puts '/>'
|
|
97
|
-
end
|
|
81
|
+
head = %( <testcase classname="#{esc(m.file)}" name="#{esc("##{m.id} #{m.location} #{m.label}")}" ) +
|
|
82
|
+
%(time="#{secs(o.seconds)}")
|
|
83
|
+
detail = junit_detail(o, report)
|
|
84
|
+
io.puts(detail ? "#{head}>\n #{detail}\n </testcase>" : "#{head}/>")
|
|
98
85
|
end
|
|
99
86
|
io.puts ' </testsuite>'
|
|
100
87
|
io.puts '</testsuites>'
|
|
101
88
|
end
|
|
89
|
+
|
|
90
|
+
def junit_detail(outcome, report)
|
|
91
|
+
m = outcome.mutation
|
|
92
|
+
case outcome.status
|
|
93
|
+
when :survived
|
|
94
|
+
%(<failure message="#{esc("survived: #{m.label}")}" type="survived">#{esc(m.diff(report.sources[m.file]))}</failure>)
|
|
95
|
+
when :equivalent then %(<skipped message="#{esc("equivalent: #{outcome.ignore.reason}")}"/>)
|
|
96
|
+
when :timeout then '<system-out>killed by timeout</system-out>'
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def esc(text)
|
|
101
|
+
CGI.escapeHTML(text)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def secs(seconds)
|
|
105
|
+
seconds.round(3)
|
|
106
|
+
end
|
|
102
107
|
end
|
|
103
108
|
end
|