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.
@@ -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