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.
@@ -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 = [] # [namespace, name] from `module_function :a` / `module_function def` (#98)
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); namespaces compare
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.each { |s| s.singleton = true if named.include?([s.namespace.join("::"), s.name]) }
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
- else
93
- namespace = @namespace_stack.join("::")
132
+ elsif !@anonymous_block
94
133
  args.each do |arg|
95
- @module_function_names << [namespace, arg.value.to_sym] if arg.is_a?(Prism::SymbolNode)
96
- @module_function_names << [namespace, arg.name] if arg.is_a?(Prism::DefNode)
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
- saved_stack = @namespace_stack
152
- saved_lexical = @lexical_stack
153
- saved_active = @module_function_active
154
- name = extract_constant_name(path)
155
- root = root_anchored?(path)
156
- @namespace_stack = root ? [name] : saved_stack + [name]
157
- @lexical_stack = saved_lexical + [root ? "::#{name}" : name]
158
- @module_function_active = false
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
- @namespace_stack = saved_stack
162
- @lexical_stack = saved_lexical
163
- @module_function_active = saved_active
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. Loads
99
- # the schema into the worker database when a schema path is given
100
- # (idempotent: schema.rb runs with `force: true`), covering a fresh worker
101
- # file.
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
- ActiveRecord::Base.establish_connection(worker_db_config(worker))
110
- load_schema(schema_path) if schema_path
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
  #
@@ -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.6"
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, "ignored" => @agg.ignored_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 `baseline.new_survivors`.
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"