mutineer 1.7.0 → 1.8.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 +432 -0
- data/README.md +157 -747
- data/lib/mutineer/baseline.rb +1 -1
- data/lib/mutineer/changed_lines.rb +2 -1
- data/lib/mutineer/cli.rb +119 -27
- data/lib/mutineer/concern_blocks.rb +168 -5
- data/lib/mutineer/config.rb +6 -0
- data/lib/mutineer/coverage_map.rb +92 -17
- data/lib/mutineer/daemon_backend.rb +75 -32
- data/lib/mutineer/daemon_client.rb +50 -17
- data/lib/mutineer/daemon_server.rb +92 -40
- data/lib/mutineer/external_backend.rb +150 -17
- data/lib/mutineer/isolation.rb +67 -25
- data/lib/mutineer/job_plan.rb +117 -33
- data/lib/mutineer/kill_channel.rb +24 -14
- data/lib/mutineer/migrate.rb +10 -8
- data/lib/mutineer/minitest_integration/in_test_database_error.rb +184 -0
- data/lib/mutineer/minitest_integration/kill_recorder.rb +25 -17
- data/lib/mutineer/minitest_integration.rb +44 -14
- data/lib/mutineer/mutators/array_literal.rb +4 -2
- data/lib/mutineer/mutators/base.rb +22 -0
- data/lib/mutineer/mutators/call_to_receiver.rb +20 -7
- data/lib/mutineer/mutators/chain_link.rb +6 -6
- data/lib/mutineer/mutators/condition_forcing.rb +6 -3
- data/lib/mutineer/mutators/operand_removal.rb +4 -2
- data/lib/mutineer/mutators/range_literal.rb +105 -4
- data/lib/mutineer/mutators/return_nil.rb +1 -0
- data/lib/mutineer/orphan_guard.rb +18 -6
- data/lib/mutineer/pairing.rb +62 -1
- data/lib/mutineer/project.rb +226 -12
- data/lib/mutineer/rails_worker_db.rb +200 -16
- data/lib/mutineer/redaction.rb +112 -11
- data/lib/mutineer/reporter.rb +109 -21
- data/lib/mutineer/result.rb +33 -5
- data/lib/mutineer/runner.rb +126 -18
- data/lib/mutineer/statement_lines.rb +14 -8
- data/lib/mutineer/subject.rb +13 -5
- data/lib/mutineer/test_runners/minitest.rb +2 -1
- data/lib/mutineer/test_runners.rb +4 -2
- data/lib/mutineer/triage.rb +1 -1
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer/worker_pool.rb +2 -1
- data/skills/mutineer/SKILL.md +84 -0
- metadata +5 -1
data/lib/mutineer/pairing.rb
CHANGED
|
@@ -4,7 +4,7 @@ require_relative "project_path"
|
|
|
4
4
|
|
|
5
5
|
module Mutineer
|
|
6
6
|
# Source -> test pairing by path convention (#11). Pure stdlib path logic:
|
|
7
|
-
# no Rails, no class loading, no process.
|
|
7
|
+
# no Rails, no class loading, no process. Three jobs:
|
|
8
8
|
# * expand_sources — a directory argument becomes its sorted **/*.rb files;
|
|
9
9
|
# a file inside the root becomes its root-relative path.
|
|
10
10
|
# * infer_tests — a source's test files by convention (app/ and lib/
|
|
@@ -13,6 +13,9 @@ module Mutineer
|
|
|
13
13
|
# namespaced subdirectories. The first EXISTING exact
|
|
14
14
|
# candidate stays first. Split Minitest files join a
|
|
15
15
|
# Minitest match. A matched spec is left as that one file.
|
|
16
|
+
# * test_given_as_source? — whether a positional argument is a test file
|
|
17
|
+
# by these same names, given as a source by mistake
|
|
18
|
+
# after `--test` (#293).
|
|
16
19
|
#
|
|
17
20
|
# Independently unit-testable: every method is pure in/out over the
|
|
18
21
|
# filesystem, so the pairing contract is exercised with plain fixtures, no
|
|
@@ -144,6 +147,64 @@ module Mutineer
|
|
|
144
147
|
prefer == "rspec" ? rspec + minitest : minitest + rspec
|
|
145
148
|
end
|
|
146
149
|
|
|
150
|
+
# Basenames that pair as tests in {candidates}: `*_test.rb`, `*_spec.rb`
|
|
151
|
+
# and Minitest's `test_*.rb`, but not its `test_helper.rb` support file.
|
|
152
|
+
TEST_NAME = /\A(?!test_helper\.rb\z)(?:.+_(?:test|spec)|test_.+)\.rb\z/
|
|
153
|
+
|
|
154
|
+
# Whether the `run` guard rejects a positional argument as a test file
|
|
155
|
+
# given as a source (#293). Segments are read from the path relative to
|
|
156
|
+
# the root, so a `test/` folder above the project never counts. A
|
|
157
|
+
# `*_test.rb` / `*_spec.rb` path is flagged when it has a `test/` or
|
|
158
|
+
# `spec/` segment (the guard's original rule), or when it sits in the same
|
|
159
|
+
# directory as a given test, which catches root-level tests. A
|
|
160
|
+
# `test_*.rb` path is flagged only under the
|
|
161
|
+
# project's top-level `test/` or `spec/`, because real sources use the
|
|
162
|
+
# prefix (`lib/mutineer/test_runners.rb`, `lib/foo/test/test_app.rb`). A
|
|
163
|
+
# source whose own test (`<stem>_test.rb`, `test_<stem>.rb`,
|
|
164
|
+
# `<stem>_spec.rb`, or a split `<stem>_*_test.rb` not claimed by a longer
|
|
165
|
+
# source on disk) sits beside it in the test set is not flagged. Paths
|
|
166
|
+
# compare relative to the root, as in
|
|
167
|
+
# {expand_sources} (the root's real path counts too, for macOS `/var` vs
|
|
168
|
+
# `/private/var`), so `./a_test.rb` and `a_test.rb` match. No file content
|
|
169
|
+
# is read.
|
|
170
|
+
#
|
|
171
|
+
# @param path [String] positional argument as typed.
|
|
172
|
+
# @param project_root [String] repository root for relative expansion.
|
|
173
|
+
# @param tests [Array<String>] the explicit test files.
|
|
174
|
+
# @return [Boolean] true when the argument looks like a misplaced test.
|
|
175
|
+
def test_given_as_source?(path, project_root:, tests:)
|
|
176
|
+
name = File.basename(path)
|
|
177
|
+
return false unless name.match?(TEST_NAME)
|
|
178
|
+
|
|
179
|
+
root = File.expand_path(project_root)
|
|
180
|
+
roots = [root, ProjectPath.root_real(root)].uniq
|
|
181
|
+
relative = lambda do |p|
|
|
182
|
+
abs = File.expand_path(p, root)
|
|
183
|
+
root_relative(abs, roots) || abs
|
|
184
|
+
end
|
|
185
|
+
rel = relative.call(path)
|
|
186
|
+
# Outside the root, read segments from a relative path as typed
|
|
187
|
+
# (`../test/b_test.rb`) and not from an absolute one, so a `test/`
|
|
188
|
+
# folder above the root never counts.
|
|
189
|
+
segments = root_relative(File.expand_path(path, root), roots)
|
|
190
|
+
segments ||= path.start_with?(File::SEPARATOR) ? "" : path
|
|
191
|
+
return segments.match?(%r{\A(test|spec)/}) unless name.match?(/_(test|spec)\.rb\z/)
|
|
192
|
+
return true if segments.match?(%r{(\A|/)(test|spec)/})
|
|
193
|
+
|
|
194
|
+
dir = File.dirname(rel)
|
|
195
|
+
beside = Array(tests).map(&relative)
|
|
196
|
+
.select { |t| File.dirname(t) == dir }
|
|
197
|
+
.map { |t| File.basename(t) }
|
|
198
|
+
stem = File.basename(name, ".rb")
|
|
199
|
+
own = ["test_#{stem}.rb", "#{stem}_spec.rb"]
|
|
200
|
+
base = rel.delete_suffix(".rb")
|
|
201
|
+
return false if beside.any? do |t|
|
|
202
|
+
own.include?(t) || (longer_owns_entry?(t, stem) && !claimed_by_longer_source?(root, base, t))
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
beside.any?
|
|
206
|
+
end
|
|
207
|
+
|
|
147
208
|
# `<basename>_*_test.rb` files in each mirrored test directory (#87). The
|
|
148
209
|
# exact `<basename>_test.rb` name is not included. `lib/` sources also look
|
|
149
210
|
# under `test/lib/`. Names are matched as text, not as a glob, so a basename
|
data/lib/mutineer/project.rb
CHANGED
|
@@ -17,14 +17,151 @@ module Mutineer
|
|
|
17
17
|
def self.discover(paths, only: nil)
|
|
18
18
|
subjects = Array(paths).flat_map do |path|
|
|
19
19
|
result = Parser.parse_file(path)
|
|
20
|
-
visitor = SubjectVisitor.new(path)
|
|
20
|
+
visitor = SubjectVisitor.new(path, plain_path_calls(result.value))
|
|
21
21
|
visitor.visit(result.value)
|
|
22
22
|
visitor.promote_module_functions!
|
|
23
|
+
exclude_ambiguous_hook_defs!(visitor.subjects, result.value)
|
|
23
24
|
visitor.subjects
|
|
24
25
|
end
|
|
25
26
|
only ? subjects.select { |s| s.qualified_name == only } : subjects
|
|
26
27
|
end
|
|
27
28
|
|
|
29
|
+
# Keeps hook defs unplaceable when the raw AST can define another method
|
|
30
|
+
# with the same name and source line. Count all defs, including bodies that
|
|
31
|
+
# SubjectVisitor does not select, and Ruby's explicit method creators (see
|
|
32
|
+
# {.created_method_locations}). Indirect callable bodies may come from any
|
|
33
|
+
# block or lambda in this file, including a stored hook block. An unknown
|
|
34
|
+
# creator name blocks only those possible source lines. Instance and
|
|
35
|
+
# singleton definitions can both reach singleton-class hosts, so their
|
|
36
|
+
# shared name and line are ambiguous too. Run before `only` so selecting
|
|
37
|
+
# one subject cannot hide a collision.
|
|
38
|
+
#
|
|
39
|
+
# @param subjects [Array<Mutineer::Subject>] subjects from one source file.
|
|
40
|
+
# @param program [Prism::ProgramNode] the complete parsed file.
|
|
41
|
+
# @return [void]
|
|
42
|
+
def self.exclude_ambiguous_hook_defs!(subjects, program)
|
|
43
|
+
return unless subjects.any?(&:reload_hook_call)
|
|
44
|
+
|
|
45
|
+
counts = Hash.new(0)
|
|
46
|
+
body_lines = Set.new
|
|
47
|
+
indirect_names = Set.new
|
|
48
|
+
nodes = [program]
|
|
49
|
+
while (node = nodes.pop)
|
|
50
|
+
nodes.concat(node.compact_child_nodes)
|
|
51
|
+
if node.is_a?(Prism::LambdaNode) || node.is_a?(Prism::BlockNode)
|
|
52
|
+
body_lines << node.location.start_line
|
|
53
|
+
end
|
|
54
|
+
location = if node.is_a?(Prism::DefNode)
|
|
55
|
+
[node.location.start_line, [node.name]]
|
|
56
|
+
elsif node.is_a?(Prism::CallNode)
|
|
57
|
+
created_method_locations(node)
|
|
58
|
+
end
|
|
59
|
+
next unless location
|
|
60
|
+
|
|
61
|
+
line, names = location
|
|
62
|
+
if line
|
|
63
|
+
names.each { |name| counts[[line, name]] += 1 }
|
|
64
|
+
else
|
|
65
|
+
names.each { |name| indirect_names << name }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
indirect_names.each { |name| body_lines.each { |line| counts[[line, name]] += 1 } }
|
|
69
|
+
subjects.each do |subject|
|
|
70
|
+
line = subject.def_node.location.start_line
|
|
71
|
+
subject.reload_hook_call = nil if counts[[line, subject.name]] > 1 || counts[[line, nil]].positive?
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
private_class_method :exclude_ambiguous_hook_defs!
|
|
75
|
+
|
|
76
|
+
# The source line and names Ruby assigns to explicit method creators. A
|
|
77
|
+
# block-defined method starts at the block opening and a literal lambda at
|
|
78
|
+
# its arrow. A positional callable takes precedence over a supplied block.
|
|
79
|
+
# Other positional bodies and block passes have an indirect source: the
|
|
80
|
+
# caller checks possible block/lambda lines instead of the call line.
|
|
81
|
+
# An attribute method starts at the message token, not its receiver line.
|
|
82
|
+
# Literal symbol and string names are exact. A dynamic name, splat, or attr
|
|
83
|
+
# writer flag may name any method on that line, represented by nil.
|
|
84
|
+
#
|
|
85
|
+
# @param node [Prism::CallNode] call to inspect.
|
|
86
|
+
# @return [Array, nil] source line and names (a nil line means an indirect
|
|
87
|
+
# callable body), or nil for a call outside this built-in family.
|
|
88
|
+
def self.created_method_locations(node)
|
|
89
|
+
arguments = node.arguments&.arguments || []
|
|
90
|
+
case node.name
|
|
91
|
+
when :define_method, :define_singleton_method
|
|
92
|
+
body = arguments[1] || node.block
|
|
93
|
+
body = body.expression if body.is_a?(Prism::BlockArgumentNode)
|
|
94
|
+
line = body.location.start_line if body.is_a?(Prism::BlockNode) || body.is_a?(Prism::LambdaNode)
|
|
95
|
+
return [line, [literal_method_name(arguments.first)]]
|
|
96
|
+
when :attr_reader, :attr_writer, :attr_accessor, :attr
|
|
97
|
+
mode = { attr_reader: :reader, attr_writer: :writer, attr_accessor: :both, attr: :reader }.fetch(node.name)
|
|
98
|
+
if node.name == :attr && arguments.size == 2 &&
|
|
99
|
+
(arguments.last.is_a?(Prism::TrueNode) || arguments.last.is_a?(Prism::FalseNode))
|
|
100
|
+
mode = arguments.last.is_a?(Prism::TrueNode) ? :both : :reader
|
|
101
|
+
arguments = arguments.first(1)
|
|
102
|
+
end
|
|
103
|
+
names = arguments.flat_map do |argument|
|
|
104
|
+
name = literal_method_name(argument)
|
|
105
|
+
next [nil] unless name
|
|
106
|
+
|
|
107
|
+
case mode
|
|
108
|
+
when :reader then [name]
|
|
109
|
+
when :writer then [:"#{name}="]
|
|
110
|
+
else [name, :"#{name}="]
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
[node.message_loc.start_line, names]
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
private_class_method :created_method_locations
|
|
117
|
+
|
|
118
|
+
# A literal method name, or nil when it depends on a runtime value.
|
|
119
|
+
#
|
|
120
|
+
# @param node [Prism::Node, nil] name argument.
|
|
121
|
+
# @return [Symbol, nil]
|
|
122
|
+
def self.literal_method_name(node)
|
|
123
|
+
node.unescaped.to_sym if node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
|
|
124
|
+
end
|
|
125
|
+
private_class_method :literal_method_name
|
|
126
|
+
|
|
127
|
+
# The call statements that run each time the file loads, in order: each one
|
|
128
|
+
# sits in the file's top-level statements or in a `module` or `class` body
|
|
129
|
+
# reached only through such statements. A call under any `if`, `unless`,
|
|
130
|
+
# `case`, `&&`, `rescue` or loop, an assignment, an argument, a def or a block
|
|
131
|
+
# is not on this path. A file with a `return` outside any def or block may
|
|
132
|
+
# stop before a call, so it has none (#297).
|
|
133
|
+
#
|
|
134
|
+
# @param program [Prism::ProgramNode] the parsed file.
|
|
135
|
+
# @return [Set<Prism::CallNode>] compared by identity.
|
|
136
|
+
def self.plain_path_calls(program)
|
|
137
|
+
calls = Set.new.compare_by_identity
|
|
138
|
+
return calls if top_level_return?(program)
|
|
139
|
+
|
|
140
|
+
bodies = [program.statements]
|
|
141
|
+
while (body = bodies.pop)
|
|
142
|
+
body.body.each do |node|
|
|
143
|
+
calls << node if node.is_a?(Prism::CallNode)
|
|
144
|
+
bodies << node.body if (node.is_a?(Prism::ModuleNode) || node.is_a?(Prism::ClassNode)) &&
|
|
145
|
+
node.body.is_a?(Prism::StatementsNode)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
calls
|
|
149
|
+
end
|
|
150
|
+
private_class_method :plain_path_calls
|
|
151
|
+
|
|
152
|
+
# True when the node holds a `return` that is not inside a def, a block or a
|
|
153
|
+
# lambda, so loading the file can stop early.
|
|
154
|
+
#
|
|
155
|
+
# @param node [Prism::Node] node to search.
|
|
156
|
+
# @return [Boolean]
|
|
157
|
+
def self.top_level_return?(node)
|
|
158
|
+
return true if node.is_a?(Prism::ReturnNode)
|
|
159
|
+
return false if node.is_a?(Prism::DefNode) || node.is_a?(Prism::BlockNode) || node.is_a?(Prism::LambdaNode)
|
|
160
|
+
|
|
161
|
+
node.compact_child_nodes.any? { |child| top_level_return?(child) }
|
|
162
|
+
end
|
|
163
|
+
private_class_method :top_level_return?
|
|
164
|
+
|
|
28
165
|
# Walks an AST, maintaining a namespace stack, emitting Subjects.
|
|
29
166
|
# Nested inside Project to signal its private role.
|
|
30
167
|
class SubjectVisitor < Prism::Visitor
|
|
@@ -34,6 +171,9 @@ module Mutineer
|
|
|
34
171
|
# ActiveSupport::Concern calls whose block another module or class runs (see {#concern_block}).
|
|
35
172
|
CONCERN_BLOCKS = %i[class_methods included prepended].freeze
|
|
36
173
|
|
|
174
|
+
# Calls whose first argument names a method they reach (see {#placeable_hook}).
|
|
175
|
+
NAME_TAKERS = %i[method instance_method send public_send __send__].freeze
|
|
176
|
+
|
|
37
177
|
# A constant name, or a path of them; Ruby allows Unicode in both.
|
|
38
178
|
CONSTANT_PATH = /\A[[:upper:]][[:word:]]*(?:::[[:upper:]][[:word:]]*)*\z/
|
|
39
179
|
|
|
@@ -61,8 +201,11 @@ module Mutineer
|
|
|
61
201
|
# Builds a subject visitor.
|
|
62
202
|
#
|
|
63
203
|
# @param file [String] source file path being visited.
|
|
64
|
-
|
|
204
|
+
# @param plain_calls [Set<Prism::CallNode>] the file's call statements on a
|
|
205
|
+
# plain path (see {Project.plain_path_calls}); only these hook calls can be placed.
|
|
206
|
+
def initialize(file, plain_calls = Set.new)
|
|
65
207
|
@file = file
|
|
208
|
+
@plain_calls = plain_calls
|
|
66
209
|
@namespace_stack = []
|
|
67
210
|
@lexical_stack = [] # class/module names as written, `::X` kept (#145)
|
|
68
211
|
@subjects = []
|
|
@@ -83,6 +226,9 @@ module Mutineer
|
|
|
83
226
|
@singleton_uncertain = false # the open `class << self` was inside a def or a non-builder block
|
|
84
227
|
@concern_modules = Set.new # the {#module_key} of each module that ran `extend ActiveSupport::Concern`
|
|
85
228
|
@hook_block = false # inside a concern's `included` or `prepended` block (see {#hook_block?})
|
|
229
|
+
@enclosing_subject = nil # the subject of the def being visited, for a nested def (#290)
|
|
230
|
+
@hook_call = nil # that block's call node, when reload can place its defs (see {#placeable_hook})
|
|
231
|
+
@hook_uses = Set.new # the names that block calls outside its def bodies
|
|
86
232
|
super()
|
|
87
233
|
end
|
|
88
234
|
|
|
@@ -96,7 +242,12 @@ module Mutineer
|
|
|
96
242
|
return if @module_function_names.empty?
|
|
97
243
|
|
|
98
244
|
named = @module_function_names.to_set
|
|
99
|
-
@subjects.each_with_index
|
|
245
|
+
@subjects.each_with_index do |s, i|
|
|
246
|
+
next unless named.include?([@subject_keys[i], s.name])
|
|
247
|
+
|
|
248
|
+
s.singleton = true
|
|
249
|
+
s.reload_hook_call = nil # a module function has a second, private copy no hook def placement makes
|
|
250
|
+
end
|
|
100
251
|
end
|
|
101
252
|
|
|
102
253
|
# Visits class nodes and tracks namespace nesting.
|
|
@@ -156,6 +307,7 @@ module Mutineer
|
|
|
156
307
|
hook = hook_block?(node)
|
|
157
308
|
return preserving_scope do
|
|
158
309
|
@hook_block ||= hook
|
|
310
|
+
@hook_call, @hook_uses = placeable_hook(node) if hook
|
|
159
311
|
@owner_unknown = true
|
|
160
312
|
super
|
|
161
313
|
end
|
|
@@ -244,25 +396,29 @@ module Mutineer
|
|
|
244
396
|
# @param node [Prism::DefNode] method definition node.
|
|
245
397
|
# @return [void]
|
|
246
398
|
def visit_def_node(node)
|
|
247
|
-
|
|
399
|
+
subject = Subject.new(
|
|
248
400
|
file: @file,
|
|
249
401
|
namespace: (@block_namespace || @namespace_stack).dup,
|
|
250
402
|
lexical: @lexical_stack.dup,
|
|
251
403
|
block_owner: @block_owner,
|
|
252
404
|
owner_unknown: @owner_unknown,
|
|
253
405
|
hook_block: @hook_block,
|
|
406
|
+
reload_hook_call: reload_hook_call_for(node),
|
|
254
407
|
name: node.name,
|
|
255
408
|
singleton: !node.receiver.nil? || @singleton_depth.positive? || @module_function_active,
|
|
256
|
-
def_node: node
|
|
409
|
+
def_node: node,
|
|
410
|
+
enclosing_subject: @enclosing_subject
|
|
257
411
|
)
|
|
412
|
+
@subjects << subject
|
|
258
413
|
@subject_keys << module_key
|
|
259
414
|
saved_active = @module_function_active
|
|
260
|
-
saved_self = [@self_unknown, @self_scope]
|
|
415
|
+
saved_self = [@self_unknown, @self_scope, @enclosing_subject]
|
|
261
416
|
@self_unknown = true # `self` in a method body is the receiver it is called on
|
|
262
417
|
@self_scope = node
|
|
418
|
+
@enclosing_subject = subject
|
|
263
419
|
super
|
|
264
420
|
@module_function_active = saved_active # a visibility call in a method body runs only when it is called
|
|
265
|
-
@self_unknown, @self_scope = saved_self
|
|
421
|
+
@self_unknown, @self_scope, @enclosing_subject = saved_self
|
|
266
422
|
end
|
|
267
423
|
|
|
268
424
|
private
|
|
@@ -347,7 +503,7 @@ module Mutineer
|
|
|
347
503
|
end
|
|
348
504
|
@lexical_stack += [root ? "::#{name}" : name]
|
|
349
505
|
@module_function_active = false
|
|
350
|
-
@block_owner = @block_namespace = nil
|
|
506
|
+
@block_owner = @block_namespace = @hook_call = nil
|
|
351
507
|
# redefine reopens the lexical chain, which does not pass through the block's `self`
|
|
352
508
|
@namespace_unknown = @owner_unknown = in_singleton || self_in_block || @namespace_unknown
|
|
353
509
|
@singleton_depth = 0
|
|
@@ -361,7 +517,7 @@ module Mutineer
|
|
|
361
517
|
# Visitor state a class/module body or builder block sets for what it contains.
|
|
362
518
|
SCOPE_STATE = %i[@namespace_stack @lexical_stack @module_function_active @block_owner @block_namespace
|
|
363
519
|
@owner_unknown @singleton_depth @singleton_cref @namespace_unknown @anonymous_block @body
|
|
364
|
-
@self_unknown @self_scope @singleton_uncertain @hook_block].freeze
|
|
520
|
+
@self_unknown @self_scope @singleton_uncertain @hook_block @hook_call @hook_uses].freeze
|
|
365
521
|
|
|
366
522
|
# Runs the block and then restores every {SCOPE_STATE} variable, so a
|
|
367
523
|
# nested body's state never leaks out of it.
|
|
@@ -512,9 +668,10 @@ module Mutineer
|
|
|
512
668
|
# ActiveSupport::Concern` earlier in this file (see {#concern_block}).
|
|
513
669
|
# ActiveSupport runs the block only when a class first includes or prepends
|
|
514
670
|
# the concern, so loading the file again stores the new block but never
|
|
515
|
-
# runs it in a class that already has the concern (#256).
|
|
516
|
-
#
|
|
517
|
-
#
|
|
671
|
+
# runs it in a class that already has the concern (#256). Reload can still
|
|
672
|
+
# place a def that sits directly in such a block (see {#placeable_hook} and
|
|
673
|
+
# {#reload_hook_call_for}, #297); anything else the block defines, a nested
|
|
674
|
+
# class or a builder block included, stays as the original wrote it.
|
|
518
675
|
#
|
|
519
676
|
# @param node [Prism::CallNode] a call {#concern_block} sorted as a concern block.
|
|
520
677
|
# @return [Boolean]
|
|
@@ -522,6 +679,63 @@ module Mutineer
|
|
|
522
679
|
%i[included prepended].include?(node.name) && @concern_modules.include?(module_key)
|
|
523
680
|
end
|
|
524
681
|
|
|
682
|
+
# The hook call and the names its block calls outside its def bodies, when
|
|
683
|
+
# reload can place the block's defs (#297), else nil and an empty set. The
|
|
684
|
+
# call must be on a plain path ({Project.plain_path_calls}), so the statement
|
|
685
|
+
# the mutant file adds before it runs exactly when the call runs. An alias in
|
|
686
|
+
# the block keeps the original body, so a block with any `alias` or
|
|
687
|
+
# `alias_method` places nothing. The names come from bare calls, `self.`
|
|
688
|
+
# calls and the name given to `method`, `instance_method`, `send`,
|
|
689
|
+
# `public_send` or `__send__`, in every nested block and lambda too:
|
|
690
|
+
# discovery cannot tell a block that runs at once from one that runs
|
|
691
|
+
# later. A name such a call computes at runtime places nothing.
|
|
692
|
+
#
|
|
693
|
+
# @param node [Prism::CallNode] the hook call.
|
|
694
|
+
# @return [Array(Prism::CallNode, Set<Symbol>), Array(nil, Set<Symbol>)]
|
|
695
|
+
def placeable_hook(node)
|
|
696
|
+
uses = Set.new
|
|
697
|
+
return [nil, uses] unless @plain_calls.include?(node)
|
|
698
|
+
|
|
699
|
+
nodes = [node.block.body].compact
|
|
700
|
+
while (current = nodes.pop)
|
|
701
|
+
next if current.is_a?(Prism::DefNode)
|
|
702
|
+
return [nil, Set.new] if current.is_a?(Prism::AliasMethodNode)
|
|
703
|
+
|
|
704
|
+
if current.is_a?(Prism::CallNode) && (current.receiver.nil? || current.receiver.is_a?(Prism::SelfNode))
|
|
705
|
+
return [nil, Set.new] if current.name == :alias_method
|
|
706
|
+
|
|
707
|
+
uses << current.name
|
|
708
|
+
if NAME_TAKERS.include?(current.name) && (name_arg = current.arguments&.arguments&.first)
|
|
709
|
+
# A runtime name may be any def in the block.
|
|
710
|
+
return [nil, Set.new] unless name_arg.is_a?(Prism::SymbolNode) || name_arg.is_a?(Prism::StringNode)
|
|
711
|
+
|
|
712
|
+
uses << name_arg.unescaped.to_sym
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
nodes.concat(current.compact_child_nodes)
|
|
716
|
+
end
|
|
717
|
+
[node, uses]
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
# The hook call of the block this def sits in directly, when reload can
|
|
721
|
+
# place the def (#297), else nil. The def must be in the block itself, or
|
|
722
|
+
# in its `class << self` without a receiver: not in a def, a nested class
|
|
723
|
+
# or module, or another block. `module_function` gives the def a second,
|
|
724
|
+
# private copy, and a name the block itself uses kept the original's
|
|
725
|
+
# result, so neither is placeable. An explicit receiver other than `self`
|
|
726
|
+
# can be bound only inside the hook, which the extracted def does not keep.
|
|
727
|
+
#
|
|
728
|
+
# @param node [Prism::DefNode] the def being recorded.
|
|
729
|
+
# @return [Prism::CallNode, nil]
|
|
730
|
+
def reload_hook_call_for(node)
|
|
731
|
+
return unless @hook_call && @self_scope.equal?(@hook_call.block) && !@module_function_active
|
|
732
|
+
return if node.receiver && !node.receiver.is_a?(Prism::SelfNode)
|
|
733
|
+
return if @singleton_depth > 1 || (@singleton_depth == 1 && node.receiver)
|
|
734
|
+
return if @hook_uses.include?(node.name)
|
|
735
|
+
|
|
736
|
+
@hook_call
|
|
737
|
+
end
|
|
738
|
+
|
|
525
739
|
# Runs the block with the defs it visits owned by the class a builder block builds.
|
|
526
740
|
# The block body defines instance methods of that class, even inside `class << self`,
|
|
527
741
|
# but its constants still land where the enclosing `class << self` puts them.
|