mutineer 1.5.0 → 1.6.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 +4 -4
- data/CHANGELOG.md +225 -0
- data/README.md +28 -3
- data/lib/mutineer/cli.rb +5 -4
- data/lib/mutineer/coverage_map.rb +372 -86
- data/lib/mutineer/daemon_backend.rb +48 -33
- data/lib/mutineer/daemon_client.rb +42 -9
- data/lib/mutineer/daemon_server.rb +73 -27
- data/lib/mutineer/file_swap.rb +1 -1
- data/lib/mutineer/isolation.rb +1 -0
- data/lib/mutineer/job_plan.rb +352 -0
- data/lib/mutineer/minitest_integration.rb +48 -1
- data/lib/mutineer/orphan_guard.rb +28 -0
- data/lib/mutineer/project.rb +337 -21
- data/lib/mutineer/rails_worker_db.rb +82 -8
- data/lib/mutineer/reporter.rb +20 -3
- data/lib/mutineer/result.rb +36 -4
- data/lib/mutineer/runner.rb +48 -284
- data/lib/mutineer/statement_lines.rb +40 -0
- data/lib/mutineer/subject.rb +6 -2
- data/lib/mutineer/test_runners/rspec.rb +39 -0
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer.rb +1 -0
- metadata +3 -1
data/lib/mutineer/project.rb
CHANGED
|
@@ -28,6 +28,31 @@ module Mutineer
|
|
|
28
28
|
# Walks an AST, maintaining a namespace stack, emitting Subjects.
|
|
29
29
|
# Nested inside Project to signal its private role.
|
|
30
30
|
class SubjectVisitor < Prism::Visitor
|
|
31
|
+
# Calls whose block is the body of the class they build: receiver name => method.
|
|
32
|
+
CLASS_BUILDERS = { "Data" => :define, "Struct" => :new, "Class" => :new, "Module" => :new }.freeze
|
|
33
|
+
|
|
34
|
+
# A constant name, or a path of them; Ruby allows Unicode in both.
|
|
35
|
+
CONSTANT_PATH = /\A[[:upper:]][[:word:]]*(?:::[[:upper:]][[:word:]]*)*\z/
|
|
36
|
+
|
|
37
|
+
# A name that reads like a constant name but may not be one (#216). It is
|
|
38
|
+
# the singleton class name of a `class << self` opened inside a def or a
|
|
39
|
+
# block that does not build a class, or built on a written path or
|
|
40
|
+
# `#<anonymous>` (`#<Class:Foo::X>` from `class Foo::X` inside `class << self`);
|
|
41
|
+
# or the `X` of `module self::X` or `self::X = ...` in a def or a block. The segment carries that mark (see
|
|
42
|
+
# {#singleton_name}, {#with_namespace}, {#assigned_owner}), and `scope`, the
|
|
43
|
+
# def or block whose `self` the name is under, or a new object when unknown.
|
|
44
|
+
class WrittenName < String
|
|
45
|
+
# @return [Object] the def or block node whose `self` the name is under.
|
|
46
|
+
attr_reader :scope
|
|
47
|
+
|
|
48
|
+
# @param name [String] the name as built.
|
|
49
|
+
# @param scope [Object] the def or block node whose `self` the name is under.
|
|
50
|
+
def initialize(name, scope = Object.new)
|
|
51
|
+
super(name)
|
|
52
|
+
@scope = scope
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
31
56
|
attr_reader :subjects
|
|
32
57
|
|
|
33
58
|
# Builds a subject visitor.
|
|
@@ -40,22 +65,33 @@ module Mutineer
|
|
|
40
65
|
@subjects = []
|
|
41
66
|
@singleton_depth = 0
|
|
42
67
|
@module_function_active = false # bareword `module_function` seen in this module body
|
|
43
|
-
@module_function_names = [] # [
|
|
68
|
+
@module_function_names = [] # [module key, name] from `module_function :a` / `module_function def` (#98)
|
|
69
|
+
@subject_keys = [] # the module key of each subject, by index
|
|
70
|
+
@body = Object.new # identifies the class/module body or builder block being visited
|
|
71
|
+
@block_owner = nil
|
|
72
|
+
@block_namespace = nil
|
|
73
|
+
@owner_unknown = false
|
|
74
|
+
@assigned = nil
|
|
75
|
+
@singleton_cref = nil # the singleton class name, in a builder block inside `class << self`
|
|
76
|
+
@namespace_unknown = false # the namespace is under a singleton class (#208) or a block's `self` (#229)
|
|
77
|
+
@anonymous_block = false # inside a builder block not assigned to a constant
|
|
78
|
+
@self_unknown = false # inside a def or a block that is not a builder, so `self` is not the namespace
|
|
79
|
+
@self_scope = nil # the def or block whose `self` the code runs with
|
|
80
|
+
@singleton_uncertain = false # the open `class << self` was inside a def or a non-builder block
|
|
44
81
|
super()
|
|
45
82
|
end
|
|
46
83
|
|
|
47
84
|
# Promote `module_function :name` / `module_function def name` subjects to
|
|
48
85
|
# singleton after the full walk — the naming call may appear before or after
|
|
49
86
|
# the def, so it can't be decided at visit_def_node time (#20). Only methods
|
|
50
|
-
# of the module that made the call are promoted (#98)
|
|
51
|
-
# joined, since `module A::B` and nested `module A; module B` differ as arrays.
|
|
87
|
+
# of the module that made the call are promoted (#98), matched by {#module_key}.
|
|
52
88
|
#
|
|
53
89
|
# @return [void]
|
|
54
90
|
def promote_module_functions!
|
|
55
91
|
return if @module_function_names.empty?
|
|
56
92
|
|
|
57
93
|
named = @module_function_names.to_set
|
|
58
|
-
@subjects.
|
|
94
|
+
@subjects.each_with_index { |s, i| s.singleton = true if named.include?([@subject_keys[i], s.name]) }
|
|
59
95
|
end
|
|
60
96
|
|
|
61
97
|
# Visits class nodes and tracks namespace nesting.
|
|
@@ -77,7 +113,11 @@ module Mutineer
|
|
|
77
113
|
# Track `module_function` so its methods are recorded as singletons (#20) —
|
|
78
114
|
# the called form is the singleton method on the module object. Bareword
|
|
79
115
|
# `module_function` flips all SUBSEQUENT defs in this body; the argument
|
|
80
|
-
# forms (`:sym`, `def`) name methods promoted after the walk.
|
|
116
|
+
# forms (`:sym`, `def`) name methods promoted after the walk. A builder
|
|
117
|
+
# block's defs belong to the class it builds; one not assigned to a
|
|
118
|
+
# constant has no name, so its subjects are marked `owner_unknown`, and a
|
|
119
|
+
# `module_function :name` in it promotes nothing. Elsewhere it promotes the
|
|
120
|
+
# methods of the same module, matched by {#module_key}.
|
|
81
121
|
#
|
|
82
122
|
# @param node [Prism::CallNode] call node.
|
|
83
123
|
# @return [void]
|
|
@@ -89,16 +129,65 @@ module Mutineer
|
|
|
89
129
|
args = node.arguments&.arguments || []
|
|
90
130
|
if args.empty?
|
|
91
131
|
@module_function_active = true
|
|
92
|
-
|
|
93
|
-
namespace = @namespace_stack.join("::")
|
|
132
|
+
elsif !@anonymous_block
|
|
94
133
|
args.each do |arg|
|
|
95
|
-
@module_function_names << [
|
|
96
|
-
@module_function_names << [
|
|
134
|
+
@module_function_names << [module_key, arg.value.to_sym] if arg.is_a?(Prism::SymbolNode)
|
|
135
|
+
@module_function_names << [module_key, arg.name] if arg.is_a?(Prism::DefNode)
|
|
97
136
|
end
|
|
98
137
|
end
|
|
99
138
|
end
|
|
139
|
+
return super unless builds_class?(node)
|
|
140
|
+
|
|
141
|
+
anonymous = !@assigned&.first.equal?(node)
|
|
142
|
+
with_block_owner(anonymous ? [nil, @namespace_stack, true] : @assigned.last, anonymous) do
|
|
143
|
+
@self_scope = node.block
|
|
144
|
+
super
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# A block that does not build a class may run with any `self` (`class_eval`,
|
|
149
|
+
# `instance_eval`, a callback), so a `class << self` in it names a singleton
|
|
150
|
+
# class Mutineer cannot be sure of (#216). A lambda is treated the same.
|
|
151
|
+
#
|
|
152
|
+
# @param node [Prism::BlockNode, Prism::LambdaNode] block or lambda node.
|
|
153
|
+
# @return [void]
|
|
154
|
+
def visit_block_node(node)
|
|
155
|
+
return super if node.equal?(@self_scope) # a builder block
|
|
156
|
+
|
|
157
|
+
saved_self = [@self_unknown, @self_scope]
|
|
158
|
+
@self_unknown = true
|
|
159
|
+
@self_scope = node
|
|
100
160
|
super
|
|
161
|
+
@self_unknown, @self_scope = saved_self
|
|
162
|
+
end
|
|
163
|
+
alias visit_lambda_node visit_block_node
|
|
164
|
+
|
|
165
|
+
# Names the class a builder block assigned to this constant builds (see {#builds_class?}).
|
|
166
|
+
#
|
|
167
|
+
# `=`, `||=` and `&&=` store the class, so each names it; `+=` stores what `+`
|
|
168
|
+
# returns, so its builder is left unnamed.
|
|
169
|
+
#
|
|
170
|
+
# @param node [Prism::ConstantWriteNode, Prism::ConstantPathWriteNode, Prism::ConstantOrWriteNode,
|
|
171
|
+
# Prism::ConstantAndWriteNode, Prism::ConstantPathOrWriteNode, Prism::ConstantPathAndWriteNode]
|
|
172
|
+
# constant assignment.
|
|
173
|
+
# @return [void]
|
|
174
|
+
def visit_constant_write_node(node)
|
|
175
|
+
call = assigned_value(node.value)
|
|
176
|
+
return super unless builds_class?(call)
|
|
177
|
+
|
|
178
|
+
saved = @assigned
|
|
179
|
+
@assigned = [call, assigned_owner(node)]
|
|
180
|
+
begin
|
|
181
|
+
super
|
|
182
|
+
ensure
|
|
183
|
+
@assigned = saved
|
|
184
|
+
end
|
|
101
185
|
end
|
|
186
|
+
alias visit_constant_path_write_node visit_constant_write_node
|
|
187
|
+
alias visit_constant_or_write_node visit_constant_write_node
|
|
188
|
+
alias visit_constant_and_write_node visit_constant_write_node
|
|
189
|
+
alias visit_constant_path_or_write_node visit_constant_write_node
|
|
190
|
+
alias visit_constant_path_and_write_node visit_constant_write_node
|
|
102
191
|
|
|
103
192
|
# Methods inside `class << self` are class methods of the enclosing
|
|
104
193
|
# namespace, but their def nodes have no receiver — track the singleton
|
|
@@ -113,8 +202,11 @@ module Mutineer
|
|
|
113
202
|
|
|
114
203
|
@singleton_depth += 1
|
|
115
204
|
saved_active = @module_function_active
|
|
205
|
+
saved_uncertain = @singleton_uncertain
|
|
206
|
+
@singleton_uncertain ||= @self_unknown # `self` here may be another object (#216)
|
|
116
207
|
super
|
|
117
208
|
@module_function_active = saved_active # a visibility call in here is not the module body's
|
|
209
|
+
@singleton_uncertain = saved_uncertain
|
|
118
210
|
@singleton_depth -= 1
|
|
119
211
|
end
|
|
120
212
|
|
|
@@ -125,42 +217,266 @@ module Mutineer
|
|
|
125
217
|
def visit_def_node(node)
|
|
126
218
|
@subjects << Subject.new(
|
|
127
219
|
file: @file,
|
|
128
|
-
namespace: @namespace_stack.dup,
|
|
220
|
+
namespace: (@block_namespace || @namespace_stack).dup,
|
|
129
221
|
lexical: @lexical_stack.dup,
|
|
222
|
+
block_owner: @block_owner,
|
|
223
|
+
owner_unknown: @owner_unknown,
|
|
130
224
|
name: node.name,
|
|
131
225
|
singleton: !node.receiver.nil? || @singleton_depth.positive? || @module_function_active,
|
|
132
226
|
def_node: node
|
|
133
227
|
)
|
|
228
|
+
@subject_keys << module_key
|
|
134
229
|
saved_active = @module_function_active
|
|
230
|
+
saved_self = [@self_unknown, @self_scope]
|
|
231
|
+
@self_unknown = true # `self` in a method body is the receiver it is called on
|
|
232
|
+
@self_scope = node
|
|
135
233
|
super
|
|
136
234
|
@module_function_active = saved_active # a visibility call in a method body runs only when it is called
|
|
235
|
+
@self_unknown, @self_scope = saved_self
|
|
137
236
|
end
|
|
138
237
|
|
|
139
238
|
private
|
|
140
239
|
|
|
240
|
+
# The module a `module_function` call and a def are matched by: its joined
|
|
241
|
+
# namespace, so a module reopened later in the file matches (#216), and
|
|
242
|
+
# `module A::B` matches nested `module A; module B`. An unknown owner that an
|
|
243
|
+
# anonymous builder block owns, or whose name has no {WrittenName} segment
|
|
244
|
+
# and is not built only from constant names (see {#named_segment?}), may be
|
|
245
|
+
# shared by another module, so it is matched only within this body (#208).
|
|
246
|
+
# Otherwise a name with a {WrittenName}
|
|
247
|
+
# segment is matched by that segment's scope and text, so openings under one
|
|
248
|
+
# `self` match each other, but not those under another, whether the owner is
|
|
249
|
+
# known or not (#229).
|
|
250
|
+
#
|
|
251
|
+
# @return [String, Object]
|
|
252
|
+
def module_key
|
|
253
|
+
namespace = @block_namespace || @namespace_stack
|
|
254
|
+
written = written?(namespace)
|
|
255
|
+
return @body if @owner_unknown && (@anonymous_block || !written && !namespace.all? { |s| named_segment?(s) })
|
|
256
|
+
return namespace.join("::") unless written
|
|
257
|
+
|
|
258
|
+
namespace.map { |s| s.is_a?(WrittenName) ? "#{s.scope.object_id}:#{s}" : s }.join("::")
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# True when any segment of the namespace is a {WrittenName}.
|
|
262
|
+
#
|
|
263
|
+
# @param namespace [Array<String>] namespace segments.
|
|
264
|
+
# @return [Boolean]
|
|
265
|
+
def written?(namespace)
|
|
266
|
+
namespace.any?(WrittenName)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# True when a namespace segment is a constant name, or a singleton class
|
|
270
|
+
# (`#<Class:App::M>`) of constant names. A written path or `#<anonymous>` is
|
|
271
|
+
# not, nor is a singleton class name built on one ({WrittenName}).
|
|
272
|
+
#
|
|
273
|
+
# @param segment [String] namespace segment.
|
|
274
|
+
# @return [Boolean]
|
|
275
|
+
def named_segment?(segment)
|
|
276
|
+
!segment.is_a?(WrittenName) && segment.gsub(/#<Class:|>/, "").match?(CONSTANT_PATH) &&
|
|
277
|
+
(segment.start_with?("#<Class:") || !segment.include?("::"))
|
|
278
|
+
end
|
|
279
|
+
|
|
141
280
|
# Runs the block with `path` pushed as the current namespace. A
|
|
142
281
|
# root-anchored path (`module ::X` / `class ::X`) names the top-level X,
|
|
143
282
|
# not X nested in the enclosing scope, so the namespace restarts there.
|
|
144
283
|
# Bareword `module_function` state does not cross a class or module
|
|
145
284
|
# boundary: each body starts without it, and the outer state returns after.
|
|
285
|
+
# A class or module `X` opened inside `class << self`, even within a builder
|
|
286
|
+
# block there, is a constant of the singleton class, which has no constant
|
|
287
|
+
# path. It and everything nested in it is named under `#<Class:...>` with
|
|
288
|
+
# its owner unknown (#208), and its body defines instance methods again.
|
|
289
|
+
# A compact `Foo::X` or a top-level `::X` there is named as written, and
|
|
290
|
+
# its owner is unknown too: redefine reopens the lexical chain without the
|
|
291
|
+
# singleton class, so constants the body looks up through it would not resolve.
|
|
292
|
+
# A `self::X` in any block is under the block's `self`, not the namespace
|
|
293
|
+
# around the block (#229). In a builder block that is the built class, so
|
|
294
|
+
# it is named under the builder's constant; in any other block (`class_eval`,
|
|
295
|
+
# a callback) `self` may be any object, so it is named under the innermost
|
|
296
|
+
# enclosing builder's constant, or else the enclosing namespace. Either way
|
|
297
|
+
# the last segment is a {WrittenName}, and it and everything nested in it
|
|
298
|
+
# has its owner unknown. Redefine reopens the lexical chain (`module Outer;
|
|
299
|
+
# module X`), which never reaches the block's `self`; reopening X through
|
|
300
|
+
# its constant with `class_eval`, as for `self::X = ...` in a builder block,
|
|
301
|
+
# would drop X from `Module.nesting`, so constants its body looks up would not resolve.
|
|
146
302
|
#
|
|
147
303
|
# @param path [Prism::Node] the class/module constant path.
|
|
148
304
|
# @yield the class or module body visit.
|
|
149
305
|
# @return [void]
|
|
150
306
|
def with_namespace(path)
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
307
|
+
preserving_scope do
|
|
308
|
+
name = extract_constant_name(path)
|
|
309
|
+
root = root_anchored?(path)
|
|
310
|
+
in_singleton = !@singleton_cref.nil? || @singleton_depth.positive?
|
|
311
|
+
self_in_block = !in_singleton && !root && self_rooted?(path) && (!@block_namespace.nil? || @self_unknown)
|
|
312
|
+
@namespace_stack =
|
|
313
|
+
if root then [name]
|
|
314
|
+
elsif in_singleton then path.is_a?(Prism::ConstantPathNode) ? [path.slice] : [singleton_name, name]
|
|
315
|
+
elsif self_in_block then (@block_namespace || @namespace_stack) + [WrittenName.new(name, @self_scope)]
|
|
316
|
+
else @namespace_stack + [name]
|
|
317
|
+
end
|
|
318
|
+
@lexical_stack += [root ? "::#{name}" : name]
|
|
319
|
+
@module_function_active = false
|
|
320
|
+
@block_owner = @block_namespace = nil
|
|
321
|
+
# redefine reopens the lexical chain, which does not pass through the block's `self`
|
|
322
|
+
@namespace_unknown = @owner_unknown = in_singleton || self_in_block || @namespace_unknown
|
|
323
|
+
@singleton_depth = 0
|
|
324
|
+
@singleton_cref = nil
|
|
325
|
+
@anonymous_block = @self_unknown = @singleton_uncertain = false
|
|
326
|
+
@body = Object.new
|
|
327
|
+
yield
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Visitor state a class/module body or builder block sets for what it contains.
|
|
332
|
+
SCOPE_STATE = %i[@namespace_stack @lexical_stack @module_function_active @block_owner @block_namespace
|
|
333
|
+
@owner_unknown @singleton_depth @singleton_cref @namespace_unknown @anonymous_block @body
|
|
334
|
+
@self_unknown @self_scope @singleton_uncertain].freeze
|
|
335
|
+
|
|
336
|
+
# Runs the block and then restores every {SCOPE_STATE} variable, so a
|
|
337
|
+
# nested body's state never leaks out of it.
|
|
338
|
+
#
|
|
339
|
+
# @yield the nested visit.
|
|
340
|
+
# @return [void]
|
|
341
|
+
def preserving_scope
|
|
342
|
+
saved = SCOPE_STATE.map { |name| instance_variable_get(name) }
|
|
159
343
|
yield
|
|
160
344
|
ensure
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
345
|
+
SCOPE_STATE.zip(saved) { |name, value| instance_variable_set(name, value) }
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Resolves the constant an assignment writes the way Ruby does. `X` is in the
|
|
349
|
+
# current namespace, `::X` and a path at the top level start from Object, and
|
|
350
|
+
# `self::X` is under the current class (the built class inside a builder
|
|
351
|
+
# block). In a block that does not build a class, `self` may be any object,
|
|
352
|
+
# so `self::X` there has its owner unknown (#229). Any other path is
|
|
353
|
+
# looked up at run time, so its owner is unknown
|
|
354
|
+
# and the subject is named as written. Lexically inside `class << self`,
|
|
355
|
+
# even within a builder block there, the constant belongs to the singleton
|
|
356
|
+
# class, which has no constant path, so its owner is unknown too, as is
|
|
357
|
+
# `X` or `self::X` in a class or module opened there (#208). A `::X` there
|
|
358
|
+
# is named `X`, still unknown (see {#with_namespace}).
|
|
359
|
+
#
|
|
360
|
+
# @param node [Prism::Node] constant assignment.
|
|
361
|
+
# @return [Array(String, Array<String>, Boolean)] owner, namespace, unknown.
|
|
362
|
+
def assigned_owner(node)
|
|
363
|
+
if @singleton_cref || @singleton_depth.positive?
|
|
364
|
+
written = node.respond_to?(:target) ? node.target.slice : node.name.to_s
|
|
365
|
+
return [nil, written.start_with?("::") ? [written.delete_prefix("::")] : [singleton_name, written], true]
|
|
366
|
+
end
|
|
367
|
+
unless node.respond_to?(:target)
|
|
368
|
+
namespace = @namespace_stack + [node.name.to_s]
|
|
369
|
+
return @namespace_unknown ? [nil, namespace, true] : named_owner(namespace)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
names = []
|
|
373
|
+
path = node.target
|
|
374
|
+
while path.is_a?(Prism::ConstantPathNode)
|
|
375
|
+
names.unshift(path.name.to_s)
|
|
376
|
+
path = path.parent
|
|
377
|
+
end
|
|
378
|
+
# see {#with_namespace}; in a builder block too, so a later `module self::X` there matches it
|
|
379
|
+
if path.is_a?(Prism::SelfNode) && (@self_unknown || @block_namespace)
|
|
380
|
+
names[0] = WrittenName.new(names[0], @self_scope)
|
|
381
|
+
end
|
|
382
|
+
if path.is_a?(Prism::SelfNode) && (@self_unknown || @namespace_unknown && !@anonymous_block)
|
|
383
|
+
return [nil, (@block_namespace || @namespace_stack) + names, true]
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
base =
|
|
387
|
+
case path
|
|
388
|
+
when nil then []
|
|
389
|
+
when Prism::SelfNode then @block_namespace || @namespace_stack unless @owner_unknown
|
|
390
|
+
when Prism::ConstantReadNode then [path.name.to_s] if @namespace_stack.empty?
|
|
391
|
+
end
|
|
392
|
+
return [nil, [node.target.slice], true] unless base
|
|
393
|
+
|
|
394
|
+
@namespace_unknown ? [nil, base + names, true] : named_owner(base + names)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# Names the singleton class that owns the constants written here: `class << self`
|
|
398
|
+
# opens that of the current class (the built class inside a builder block),
|
|
399
|
+
# and a builder block inside `class << self` keeps the enclosing one. A block
|
|
400
|
+
# not assigned to a constant builds a class with no name, written `#<anonymous>`.
|
|
401
|
+
# Each nested `class << self` opens the singleton class of the one around it.
|
|
402
|
+
# A name built on a written path or `#<anonymous>`, or opened where `self` may
|
|
403
|
+
# be another object (in a def or a non-builder block), is a {WrittenName} (see {#module_key}).
|
|
404
|
+
#
|
|
405
|
+
# @return [String] e.g. `#<Class:App>`, or `#<Class:#<Class:App>>` two deep.
|
|
406
|
+
def singleton_name
|
|
407
|
+
return @singleton_cref unless @singleton_depth.positive?
|
|
408
|
+
|
|
409
|
+
base = @anonymous_block ? @namespace_stack + ["#<anonymous>"] : @block_namespace || @namespace_stack
|
|
410
|
+
name = @singleton_depth.times.reduce(base.join("::")) { |inner, _| "#<Class:#{inner}>" }
|
|
411
|
+
named = !@singleton_uncertain && (@owner_unknown ? base.all? { |s| named_segment?(s) } : !written?(base))
|
|
412
|
+
named ? name : WrittenName.new(name)
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# The owner for a resolved namespace, root-anchored so the redefine
|
|
416
|
+
# wrapper loads onto that constant from any nesting.
|
|
417
|
+
#
|
|
418
|
+
# @param namespace [Array<String>] resolved namespace.
|
|
419
|
+
# @return [Array(String, Array<String>, Boolean)]
|
|
420
|
+
def named_owner(namespace)
|
|
421
|
+
["::#{namespace.join("::")}", namespace, false]
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# The value an assignment stores: the last statement inside parentheses or a
|
|
425
|
+
# `begin` without `rescue`, which Ruby returns from them.
|
|
426
|
+
#
|
|
427
|
+
# @param node [Prism::Node] assigned expression.
|
|
428
|
+
# @return [Prism::Node, nil]
|
|
429
|
+
def assigned_value(node)
|
|
430
|
+
loop do
|
|
431
|
+
body =
|
|
432
|
+
case node
|
|
433
|
+
when Prism::ParenthesesNode then node.body
|
|
434
|
+
when Prism::BeginNode then node.statements unless node.rescue_clause
|
|
435
|
+
end
|
|
436
|
+
return node unless body
|
|
437
|
+
|
|
438
|
+
node = body.is_a?(Prism::StatementsNode) ? body.body.last : body
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# True when the node is `Data.define`, `Struct.new`, `Class.new` or `Module.new` with a block.
|
|
443
|
+
#
|
|
444
|
+
# @param node [Prism::Node, nil] node.
|
|
445
|
+
# @return [Boolean]
|
|
446
|
+
def builds_class?(node)
|
|
447
|
+
node.is_a?(Prism::CallNode) && node.block.is_a?(Prism::BlockNode) &&
|
|
448
|
+
CLASS_BUILDERS[node.receiver&.slice&.delete_prefix("::")] == node.name
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Runs the block with the defs it visits owned by the class a builder block builds.
|
|
452
|
+
# The block body defines instance methods of that class, even inside `class << self`,
|
|
453
|
+
# but its constants still land where the enclosing `class << self` puts them.
|
|
454
|
+
#
|
|
455
|
+
# @param owner [Array(String, Array<String>, Boolean)] the owner as written, its namespace,
|
|
456
|
+
# and whether that name is unknown.
|
|
457
|
+
# @param anonymous [Boolean] true when the block is not assigned to a constant.
|
|
458
|
+
# @yield the builder call visit.
|
|
459
|
+
# @return [void]
|
|
460
|
+
def with_block_owner(owner, anonymous)
|
|
461
|
+
preserving_scope do
|
|
462
|
+
@singleton_cref = singleton_name if @singleton_depth.positive?
|
|
463
|
+
@block_owner, @block_namespace, @owner_unknown = owner
|
|
464
|
+
@anonymous_block = anonymous
|
|
465
|
+
@self_unknown = @singleton_uncertain = false
|
|
466
|
+
@module_function_active = false
|
|
467
|
+
@singleton_depth = 0
|
|
468
|
+
@body = Object.new
|
|
469
|
+
yield
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# True when a constant path starts with `self` (e.g. `self::X`).
|
|
474
|
+
#
|
|
475
|
+
# @param node [Prism::Node] constant path node.
|
|
476
|
+
# @return [Boolean]
|
|
477
|
+
def self_rooted?(node)
|
|
478
|
+
node = node.parent while node.is_a?(Prism::ConstantPathNode)
|
|
479
|
+
node.is_a?(Prism::SelfNode)
|
|
164
480
|
end
|
|
165
481
|
|
|
166
482
|
# True when a constant path starts with `::` (e.g. `::X` or `::A::B`).
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
3
5
|
module Mutineer
|
|
4
6
|
# Per-worker database isolation for the daemon path.
|
|
5
7
|
#
|
|
@@ -14,7 +16,9 @@ module Mutineer
|
|
|
14
16
|
# forks cannot clobber each other's transactional fixtures. {after_fork} runs
|
|
15
17
|
# inside a freshly-forked child and points that child's connection at the
|
|
16
18
|
# worker's database BEFORE any test loads; transactional fixtures then
|
|
17
|
-
# repopulate that isolated database per test.
|
|
19
|
+
# repopulate that isolated database per test. On a slot's first use the worker
|
|
20
|
+
# database starts as a copy of the base test database, so it holds what the
|
|
21
|
+
# booted parent wrote, as the in-process backend sees it.
|
|
18
22
|
#
|
|
19
23
|
# Scope: SQLite adapter only (per-worker file, hermetic). Postgres per-worker
|
|
20
24
|
# DBs (`CREATE DATABASE <db>-<worker>`) are not implemented yet; a non-SQLite
|
|
@@ -95,22 +99,92 @@ module Mutineer
|
|
|
95
99
|
|
|
96
100
|
# Child-side (after fork): route this process's ActiveRecord at the worker's
|
|
97
101
|
# own database and confirm it is reachable, so a routing failure reads as
|
|
98
|
-
# `error` (via the daemon's child rescue) rather than a false verdict.
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
+
# `error` (via the daemon's child rescue) rather than a false verdict.
|
|
103
|
+
#
|
|
104
|
+
# With `seed: true` (the slot's first use) the worker database first becomes
|
|
105
|
+
# a copy of the base test database, schema and rows, so rows the daemon
|
|
106
|
+
# parent wrote while it booted (initializers, `--require` files) are there,
|
|
107
|
+
# as they are for the in-process backend (#222). The schema is then loaded
|
|
108
|
+
# only when the copy differs from `schema.rb` ({schema_current?}: schema
|
|
109
|
+
# version or stored `schema_sha1`, as in a stale or empty base database):
|
|
110
|
+
# `schema.rb` runs with `force: true`, which drops the copied rows of the
|
|
111
|
+
# tables it defines.
|
|
102
112
|
#
|
|
103
113
|
# @param worker [Integer] the worker slot index.
|
|
104
114
|
# @param schema_path [String, nil] absolute path to `db/schema.rb`, or nil to skip.
|
|
115
|
+
# @param seed [Boolean] copy the base database into the worker database first.
|
|
105
116
|
# @return [void]
|
|
106
|
-
def self.after_fork(worker, schema_path = nil)
|
|
117
|
+
def self.after_fork(worker, schema_path = nil, seed: false)
|
|
107
118
|
return unless available?
|
|
108
119
|
|
|
109
|
-
|
|
110
|
-
|
|
120
|
+
config = worker_db_config(worker)
|
|
121
|
+
base = ActiveRecord::Base.connection.select_value("SELECT file FROM pragma_database_list WHERE name = 'main'") if seed
|
|
122
|
+
ActiveRecord::Base.establish_connection(config)
|
|
123
|
+
seed_from(base) if seed
|
|
124
|
+
load_schema(schema_path) if schema_path && !schema_current?(schema_path)
|
|
111
125
|
verify_connection!
|
|
112
126
|
end
|
|
113
127
|
|
|
128
|
+
# Copy the base test database file into the worker database this fork is
|
|
129
|
+
# now connected to, with the SQLite online backup API: one consistent
|
|
130
|
+
# snapshot of the committed data (WAL included) that replaces whatever the
|
|
131
|
+
# worker file held (an earlier run, or a copy a timeout interrupted). Both
|
|
132
|
+
# ends are files SQLite itself opened, so no path rules are re-derived here.
|
|
133
|
+
#
|
|
134
|
+
# @param base_file [String] the base database file, from `pragma_database_list`.
|
|
135
|
+
# @return [void]
|
|
136
|
+
def self.seed_from(base_file)
|
|
137
|
+
codes = SQLite3::Constants::ErrorCode
|
|
138
|
+
source = SQLite3::Database.new(base_file, readonly: true)
|
|
139
|
+
source.busy_timeout = 5000
|
|
140
|
+
backup = SQLite3::Backup.new(ActiveRecord::Base.connection.raw_connection, "main", source, "main")
|
|
141
|
+
# Another worker's process can hold a lock on the base file for a moment;
|
|
142
|
+
# BUSY/LOCKED steps are retried for up to 5 seconds, then the copy fails
|
|
143
|
+
# with a message that names the database (scored `error`).
|
|
144
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
|
|
145
|
+
until (status = backup.step(-1)) == codes::DONE
|
|
146
|
+
retry_ok = [codes::BUSY, codes::LOCKED].include?(status) && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
|
|
147
|
+
raise "copying #{base_file} into the worker database failed (SQLite code #{status})" unless retry_ok
|
|
148
|
+
|
|
149
|
+
sleep 0.01
|
|
150
|
+
end
|
|
151
|
+
ensure
|
|
152
|
+
backup&.finish
|
|
153
|
+
source&.close
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# True when the current database already holds the schema that `schema.rb`
|
|
157
|
+
# declares: its newest `schema_migrations` row matches the declared version,
|
|
158
|
+
# and the `schema_sha1` Rails stores in `ar_internal_metadata` matches the
|
|
159
|
+
# file, which catches an edited schema with the same version. No stored
|
|
160
|
+
# checksum counts as out of date, as in Rails' own `schema_up_to_date?`.
|
|
161
|
+
#
|
|
162
|
+
# @param schema_path [String] absolute path to `db/schema.rb`.
|
|
163
|
+
# @return [Boolean]
|
|
164
|
+
def self.schema_current?(schema_path)
|
|
165
|
+
text = File.read(schema_path)
|
|
166
|
+
version = schema_file_version(text)
|
|
167
|
+
conn = ActiveRecord::Base.connection
|
|
168
|
+
base = ActiveRecord::Base
|
|
169
|
+
migrations = "#{base.table_name_prefix}#{base.schema_migrations_table_name}#{base.table_name_suffix}"
|
|
170
|
+
metadata = "#{base.table_name_prefix}#{base.internal_metadata_table_name}#{base.table_name_suffix}"
|
|
171
|
+
return false unless version && conn.table_exists?(migrations)
|
|
172
|
+
return false unless conn.select_values("SELECT version FROM #{conn.quote_table_name(migrations)}").map(&:to_i).max == version
|
|
173
|
+
return false unless conn.table_exists?(metadata)
|
|
174
|
+
|
|
175
|
+
conn.select_value("SELECT value FROM #{conn.quote_table_name(metadata)} WHERE key = 'schema_sha1'") ==
|
|
176
|
+
Digest::SHA1.hexdigest(text)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# The version a `schema.rb` declares in `define(version: ...)`, or nil. Pure
|
|
180
|
+
# string parse (no AR) so it is unit-testable in the zero-dep suite.
|
|
181
|
+
#
|
|
182
|
+
# @param text [String] the `schema.rb` source.
|
|
183
|
+
# @return [Integer, nil]
|
|
184
|
+
def self.schema_file_version(text)
|
|
185
|
+
text[/define\(version:\s*([\d_]+)/, 1]&.delete("_")&.to_i
|
|
186
|
+
end
|
|
187
|
+
|
|
114
188
|
# Load a Rails `schema.rb` into the current connection with output silenced
|
|
115
189
|
# (fork child stdout is already File::NULL; this is belt-and-braces).
|
|
116
190
|
#
|
data/lib/mutineer/reporter.rb
CHANGED
|
@@ -26,7 +26,7 @@ module Mutineer
|
|
|
26
26
|
BROKEN_FLOOR = 1
|
|
27
27
|
|
|
28
28
|
# The JSON report's `schema_version` (see docs/json-schema.md).
|
|
29
|
-
SCHEMA_VERSION = "1.
|
|
29
|
+
SCHEMA_VERSION = "1.7"
|
|
30
30
|
|
|
31
31
|
# The warning both matrix renderers give under the redundant tests.
|
|
32
32
|
MATRIX_REDUNDANT_NOTE = "Delete redundant tests one at a time: two of them can be the only killers of one mutant."
|
|
@@ -167,6 +167,8 @@ module Mutineer
|
|
|
167
167
|
total: @agg.total, killed: killed, survived: survived,
|
|
168
168
|
no_coverage: @agg.no_coverage_count,
|
|
169
169
|
uncapturable: @agg.uncapturable_count,
|
|
170
|
+
unplaceable: @agg.unplaceable_count,
|
|
171
|
+
ran_at_load: @agg.ran_at_load_count,
|
|
170
172
|
skipped_invalid: @agg.skipped_invalid_count,
|
|
171
173
|
errored: @agg.errored_count, timeout: @agg.timeout_count,
|
|
172
174
|
ignored: @agg.ignored_count,
|
|
@@ -194,6 +196,12 @@ module Mutineer
|
|
|
194
196
|
# Same shape as no_coverage; additive key.
|
|
195
197
|
uncapturable: @agg.results.select(&:uncapturable?).map { |r| mutant_json(r) }
|
|
196
198
|
.sort_by { |h| [h[:file], h[:line], h[:operator], h[:id].to_s] },
|
|
199
|
+
# Additive (1.7): owner-unknown mutants redefine did not run. Not in no_verdict.
|
|
200
|
+
unplaceable: @agg.results.select(&:unplaceable?).map { |r| mutant_json(r) }
|
|
201
|
+
.sort_by { |h| [h[:file], h[:line], h[:operator], h[:id].to_s] },
|
|
202
|
+
# Additive (1.7, #187): mutants on a line that ran at load. Not in no_verdict.
|
|
203
|
+
ran_at_load: @agg.results.select(&:ran_at_load?).map { |r| mutant_json(r) }
|
|
204
|
+
.sort_by { |h| [h[:file], h[:line], h[:operator], h[:id].to_s] },
|
|
197
205
|
# Every mutant that was attempted and produced no verdict, whatever the
|
|
198
206
|
# reason — the set the --threshold completeness gate counts. Named for the
|
|
199
207
|
# condition rather than one status, because summary.errored means :error
|
|
@@ -287,7 +295,8 @@ module Mutineer
|
|
|
287
295
|
counts = {
|
|
288
296
|
"total" => @agg.total, "killed" => @agg.killed_count,
|
|
289
297
|
"survived" => @agg.survived_count, "no_coverage" => @agg.no_coverage_count,
|
|
290
|
-
"uncapturable" => @agg.uncapturable_count, "
|
|
298
|
+
"uncapturable" => @agg.uncapturable_count, "unplaceable" => @agg.unplaceable_count,
|
|
299
|
+
"ran_at_load" => @agg.ran_at_load_count, "ignored" => @agg.ignored_count,
|
|
291
300
|
"skipped" => @agg.skipped_invalid_count,
|
|
292
301
|
"errored" => @agg.errored_count, "timeout" => @agg.timeout_count
|
|
293
302
|
}
|
|
@@ -623,7 +632,8 @@ module Mutineer
|
|
|
623
632
|
end
|
|
624
633
|
|
|
625
634
|
# The fields that name one mutant, for the lists that point at mutants:
|
|
626
|
-
# `no_coverage`, `uncapturable`, `ignored` and
|
|
635
|
+
# `no_coverage`, `uncapturable`, `unplaceable`, `ran_at_load`, `ignored` and
|
|
636
|
+
# `baseline.new_survivors`.
|
|
627
637
|
def mutant_json(result)
|
|
628
638
|
m = result.mutation
|
|
629
639
|
file = result.subject.file
|
|
@@ -690,6 +700,12 @@ module Mutineer
|
|
|
690
700
|
out.puts format("Timeout: %-6d (over the per-mutant time limit)", @agg.timeout_count)
|
|
691
701
|
# A broken harness, not a coverage gap: report it distinctly from No coverage.
|
|
692
702
|
out.puts format("Uncapturable: %-6d (tests failed to run)", @agg.uncapturable_count)
|
|
703
|
+
# Not broken: redefine has no named class to load these onto; reload runs them.
|
|
704
|
+
out.puts format("Unplaceable: %-6d (class cannot be named statically; --strategy reload runs these)",
|
|
705
|
+
@agg.unplaceable_count)
|
|
706
|
+
# Not a verdict: the line ran before the mutant was applied (#187).
|
|
707
|
+
out.puts format("Ran at load: %-6d (ran while the app or class loaded; mutineer cannot re-run that; " \
|
|
708
|
+
"verify with --test-command)", @agg.ran_at_load_count)
|
|
693
709
|
# Equivalent mutants the user suppressed; excluded from the denominator.
|
|
694
710
|
out.puts format("Ignored: %-6d (equivalent, suppressed)", @agg.ignored_count)
|
|
695
711
|
end
|
|
@@ -702,6 +718,7 @@ module Mutineer
|
|
|
702
718
|
def score_line(out, err)
|
|
703
719
|
score = @agg.mutation_score
|
|
704
720
|
excluded = "#{@agg.no_coverage_count} no-coverage, #{@agg.uncapturable_count} uncapturable, " \
|
|
721
|
+
"#{@agg.unplaceable_count} unplaceable, #{@agg.ran_at_load_count} ran at load, " \
|
|
705
722
|
"#{@agg.skipped_invalid_count} skipped, " \
|
|
706
723
|
"#{@agg.errored_count} errored, #{@agg.timeout_count} timeout, " \
|
|
707
724
|
"#{@agg.ignored_count} ignored excluded"
|