mutineer 1.3.0 → 1.5.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +317 -0
  3. data/README.md +200 -28
  4. data/lib/mutineer/changed_lines.rb +57 -2
  5. data/lib/mutineer/cli.rb +142 -92
  6. data/lib/mutineer/config.rb +215 -56
  7. data/lib/mutineer/coverage_map.rb +161 -14
  8. data/lib/mutineer/daemon_backend.rb +3 -0
  9. data/lib/mutineer/daemon_client.rb +170 -13
  10. data/lib/mutineer/daemon_server.rb +2 -1
  11. data/lib/mutineer/external_backend.rb +2 -2
  12. data/lib/mutineer/isolation.rb +91 -6
  13. data/lib/mutineer/kill_channel.rb +284 -0
  14. data/lib/mutineer/kill_matrix.rb +88 -0
  15. data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
  16. data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
  17. data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +14 -68
  18. data/lib/mutineer/minitest_integration.rb +27 -10
  19. data/lib/mutineer/mutant_id.rb +5 -4
  20. data/lib/mutineer/mutator_registry.rb +12 -3
  21. data/lib/mutineer/mutators/array_literal.rb +0 -7
  22. data/lib/mutineer/mutators/base.rb +63 -4
  23. data/lib/mutineer/mutators/chain_link.rb +0 -7
  24. data/lib/mutineer/mutators/collection_method.rb +1 -1
  25. data/lib/mutineer/mutators/condition_forcing.rb +134 -0
  26. data/lib/mutineer/mutators/operand_removal.rb +0 -7
  27. data/lib/mutineer/mutators/operator_assignment.rb +113 -0
  28. data/lib/mutineer/mutators/return_nil.rb +5 -11
  29. data/lib/mutineer/mutators/string_literal.rb +1 -1
  30. data/lib/mutineer/pairing.rb +222 -15
  31. data/lib/mutineer/parser.rb +12 -3
  32. data/lib/mutineer/project.rb +7 -0
  33. data/lib/mutineer/rails_worker_db.rb +1 -1
  34. data/lib/mutineer/reporter.rb +288 -52
  35. data/lib/mutineer/result.rb +37 -1
  36. data/lib/mutineer/runner.rb +133 -16
  37. data/lib/mutineer/statement_lines.rb +143 -0
  38. data/lib/mutineer/test_runners/minitest.rb +4 -2
  39. data/lib/mutineer/test_runners/rspec.rb +243 -3
  40. data/lib/mutineer/test_runners.rb +5 -3
  41. data/lib/mutineer/version.rb +1 -1
  42. data/lib/mutineer/worker_pool.rb +4 -2
  43. data/lib/mutineer.rb +3 -0
  44. metadata +10 -3
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Mutineer
6
+ module Mutators
7
+ # Operator-assignment mutator (Tier-2).
8
+ #
9
+ # Swaps the operator of a compound assignment, one mutation per
10
+ # occurrence: `t += x` -> `t -= x`. Prism parses `t += x` as its own
11
+ # node, not as a CallNode, so the arithmetic mutator never sees it.
12
+ #
13
+ # The mapping is the same as for arithmetic. Other compound operators
14
+ # (`||=`, `&&=`, `|=`, `<<=` and the rest) are not changed.
15
+ class OperatorAssignment < Base
16
+ # Token replacements for compound arithmetic operators.
17
+ REPLACEMENTS = {
18
+ :+ => "-=", :- => "+=", :* => "/=", :/ => "*=", :% => "*=", :** => "*="
19
+ }.freeze
20
+
21
+ # Visits `t += x`.
22
+ #
23
+ # @param node [Prism::LocalVariableOperatorWriteNode] node to inspect.
24
+ # @return [void]
25
+ def visit_local_variable_operator_write_node(node)
26
+ emit(node)
27
+ super # the value can hold its own compound assignment
28
+ end
29
+
30
+ # Visits `@t += x`.
31
+ #
32
+ # @param node [Prism::InstanceVariableOperatorWriteNode] node to inspect.
33
+ # @return [void]
34
+ def visit_instance_variable_operator_write_node(node)
35
+ emit(node)
36
+ super
37
+ end
38
+
39
+ # Visits `@@t += x`.
40
+ #
41
+ # @param node [Prism::ClassVariableOperatorWriteNode] node to inspect.
42
+ # @return [void]
43
+ def visit_class_variable_operator_write_node(node)
44
+ emit(node)
45
+ super
46
+ end
47
+
48
+ # Visits `$t += x`.
49
+ #
50
+ # @param node [Prism::GlobalVariableOperatorWriteNode] node to inspect.
51
+ # @return [void]
52
+ def visit_global_variable_operator_write_node(node)
53
+ emit(node)
54
+ super
55
+ end
56
+
57
+ # Visits `T += x`.
58
+ #
59
+ # @param node [Prism::ConstantOperatorWriteNode] node to inspect.
60
+ # @return [void]
61
+ def visit_constant_operator_write_node(node)
62
+ emit(node)
63
+ super
64
+ end
65
+
66
+ # Visits `A::T += x` and `::T += x`.
67
+ #
68
+ # @param node [Prism::ConstantPathOperatorWriteNode] node to inspect.
69
+ # @return [void]
70
+ def visit_constant_path_operator_write_node(node)
71
+ emit(node)
72
+ super
73
+ end
74
+
75
+ # Visits `a.b += x` and `a&.b += x`.
76
+ #
77
+ # @param node [Prism::CallOperatorWriteNode] node to inspect.
78
+ # @return [void]
79
+ def visit_call_operator_write_node(node)
80
+ emit(node)
81
+ super
82
+ end
83
+
84
+ # Visits `a[i] += x`.
85
+ #
86
+ # @param node [Prism::IndexOperatorWriteNode] node to inspect.
87
+ # @return [void]
88
+ def visit_index_operator_write_node(node)
89
+ emit(node)
90
+ super
91
+ end
92
+
93
+ private
94
+
95
+ # Emits a mutation when the node's operator has a replacement.
96
+ #
97
+ # @param node [Prism::Node] a node with `binary_operator` and `binary_operator_loc`.
98
+ # @return [void]
99
+ def emit(node)
100
+ replacement = REPLACEMENTS[node.binary_operator]
101
+ return unless replacement
102
+
103
+ loc = node.binary_operator_loc
104
+ @mutations << Mutation.new(
105
+ start_offset: loc.start_offset,
106
+ end_offset: loc.end_offset,
107
+ replacement: replacement,
108
+ operator: :operator_assignment
109
+ )
110
+ end
111
+ end
112
+ end
113
+ end
@@ -16,11 +16,12 @@ module Mutineer
16
16
  def mutations_for(subject, source)
17
17
  @source = source
18
18
  @mutations = []
19
- body = subject.def_node.body
20
- if body
21
- body.accept(self) # rule 1 (explicit return nodes in this body)
22
- final_expression_nil(body) # rule 2 (method's final expression)
19
+ @body = subject.def_node.body
20
+ if @body
21
+ @body.accept(self) # rule 1 (explicit return nodes in this body)
22
+ final_expression_nil(@body) # rule 2 (method's final expression)
23
23
  end
24
+ drop_dangling_heredocs
24
25
  @mutations
25
26
  end
26
27
 
@@ -39,13 +40,6 @@ module Mutineer
39
40
  super
40
41
  end
41
42
 
42
- # Nested method definitions are discovered as their own subjects; do not
43
- # recurse into them (prevents double-counting their statements).
44
- #
45
- # @param node [Prism::DefNode] nested definition node.
46
- # @return [void]
47
- def visit_def_node(node); end
48
-
49
43
  private
50
44
 
51
45
  # Mutates a method's final expression to nil when eligible.
@@ -15,7 +15,7 @@ module Mutineer
15
15
  # @param node [Prism::StringNode] node to inspect.
16
16
  # @return [void]
17
17
  def visit_string_node(node)
18
- # ponytail: only plain "..." / '...' quotes. opening is nil for
18
+ # Only plain "..." / '...' quotes. opening is nil for
19
19
  # interpolation parts and %w[] elements; heredocs/%-literals use a
20
20
  # different opening token. Skipping them keeps mutants re-parseable.
21
21
  if %w[" '].include?(node.opening)
@@ -1,13 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "project_path"
4
+
3
5
  module Mutineer
4
6
  # Source -> test pairing by path convention (#11). Pure stdlib path logic:
5
7
  # no Rails, no class loading, no process. Two jobs:
6
- # * expand_sources — a directory argument becomes its sorted **/*.rb files.
7
- # * infer_test — a source's test file by convention (app/ and lib/
8
- # sources map to test/.../_test.rb, test/.../test_*.rb
9
- # or spec/.../_spec.rb), preserving namespaced
10
- # subdirectories. First EXISTING candidate wins.
8
+ # * expand_sources — a directory argument becomes its sorted **/*.rb files;
9
+ # a file inside the root becomes its root-relative path.
10
+ # * infer_tests — a source's test files by convention (app/ and lib/
11
+ # sources map to test/.../_test.rb, test/.../<name>_*_test.rb,
12
+ # test/.../test_*.rb or spec/.../_spec.rb), preserving
13
+ # namespaced subdirectories. The first EXISTING exact
14
+ # candidate stays first. Split Minitest files join a
15
+ # Minitest match. A matched spec is left as that one file.
11
16
  #
12
17
  # Independently unit-testable: every method is pure in/out over the
13
18
  # filesystem, so the pairing contract is exercised with plain fixtures, no
@@ -17,37 +22,88 @@ module Mutineer
17
22
 
18
23
  # Expand each positional source: a directory -> its sorted **/*.rb files
19
24
  # (relative to project_root); a file (or glob, or anything non-directory)
20
- # -> itself. Flattened, deduped, order-stable.
25
+ # -> its path relative to project_root, so `./lib/x.rb` and an absolute
26
+ # path pair like `lib/x.rb` (#104). A missing file, or one outside the
27
+ # root, stays as typed. Flattened, deduped, order-stable.
21
28
  #
22
29
  # @param args [Array<String>] source paths or directories.
23
30
  # @param project_root [String] repository root for relative expansion.
24
31
  # @return [Array<String>] flattened, deduped source file list.
25
32
  def expand_sources(args, project_root:)
26
33
  root = File.expand_path(project_root)
34
+ # The root as typed and as its real path (macOS `/var` vs `/private/var`),
35
+ # resolved once for every file below.
36
+ roots = [root, ProjectPath.root_real(root)].uniq
27
37
  Array(args).flat_map do |arg|
28
38
  abs = File.expand_path(arg, root)
29
39
  if File.directory?(abs)
30
- Dir.glob(File.join(abs, "**", "*.rb")).sort.map { |f| f.delete_prefix("#{root}/") }
40
+ Dir.glob(File.join(abs, "**", "*.rb")).sort.map { |f| existing_relative(f, roots) || f }
31
41
  else
32
- [arg]
42
+ # `..` resolves as text, as FileSwap and the daemon already read it.
43
+ [(existing_relative(abs, roots) if File.exist?(abs)) || arg]
33
44
  end
34
45
  end.uniq
35
46
  end
36
47
 
37
- # The first EXISTING candidate test path for a source (relative to
38
- # project_root), or nil. `prefer` is the resolved framework ("minitest" |
39
- # "rspec"): its candidates are tried first, the other framework's as
40
- # fallback, so a minitest default still finds a spec and vice-versa.
48
+ # An existing file's root-relative path, or nil outside the root. Tries the
49
+ # path as given, then with its directory resolved, so a path typed through
50
+ # a symlinked parent (or macOS `/var` under a `/private/var` root) matches.
51
+ # The file's own name is kept, so a symlinked file is not renamed.
52
+ #
53
+ # @param abs [String] expanded absolute path of an existing file.
54
+ # @param roots [Array<String>] the project root, expanded and real.
55
+ # @return [String, nil] root-relative path, or nil outside the root.
56
+ def existing_relative(abs, roots)
57
+ root_relative(abs, roots) ||
58
+ root_relative(File.join(File.realpath(File.dirname(abs)), File.basename(abs)), roots)
59
+ end
60
+
61
+ # `abs` relative to the first of `roots` it is under, or nil. Text only: a
62
+ # symlinked source keeps its own name (realpath would rename it to its
63
+ # target and pair the wrong test). `roots` holds the root's real path too,
64
+ # so the macOS `/var` vs `/private/var` alias still matches.
65
+ #
66
+ # @param abs [String] expanded absolute path.
67
+ # @param roots [Array<String>] the project root, expanded and real.
68
+ # @return [String, nil] root-relative path, or nil outside the root.
69
+ def root_relative(abs, roots)
70
+ roots.each do |r|
71
+ prefix = r.end_with?("/") ? r : "#{r}/"
72
+ return abs.delete_prefix(prefix) if abs.start_with?(prefix)
73
+ end
74
+ nil
75
+ end
76
+
77
+ # The first test path {#infer_tests} would run for a source, or nil.
78
+ # `prefer` is the resolved framework ("minitest" | "rspec"): its candidates
79
+ # are tried first, the other framework's as fallback, so a minitest default
80
+ # still finds a spec and vice-versa.
41
81
  #
42
82
  # @param source_rel [String] relative source path.
43
83
  # @param project_root [String] repository root for existence checks.
44
84
  # @param prefer [String] preferred framework name.
45
85
  # @return [String, nil] existing test path or nil.
46
86
  def infer_test(source_rel, project_root:, prefer: "minitest")
87
+ infer_tests(source_rel, project_root: project_root, prefer: prefer).first
88
+ end
89
+
90
+ # Every test file convention pairs with a source. The first existing exact
91
+ # candidate from {#candidates} stays first. Each `<basename>_*_test.rb` in
92
+ # the mirrored test directory is added after it (#87). A spec that the exact
93
+ # rules already found is left alone: split Minitest names do not join it
94
+ # and do not replace it.
95
+ #
96
+ # @param source_rel [String] relative source path.
97
+ # @param project_root [String] repository root for existence checks.
98
+ # @param prefer [String] preferred framework name.
99
+ # @return [Array<String>] existing test paths, exact candidate first.
100
+ def infer_tests(source_rel, project_root:, prefer: "minitest")
47
101
  base, lib = logical_path(source_rel)
48
- candidates(base, lib, prefer).find do |rel|
49
- File.exist?(File.expand_path(rel, project_root))
50
- end
102
+ root = File.expand_path(project_root)
103
+ exact = candidates(base, lib, prefer).find { |rel| File.exist?(File.expand_path(rel, root)) }
104
+ return [exact] if exact&.end_with?("_spec.rb")
105
+
106
+ ([exact] + split_test_files(base, lib, root)).compact.uniq
51
107
  end
52
108
 
53
109
  # Strip the source root to a logical path (no ".rb") and flag lib/
@@ -87,5 +143,156 @@ module Mutineer
87
143
  rspec << "spec/lib/#{base}_spec.rb" if lib
88
144
  prefer == "rspec" ? rspec + minitest : minitest + rspec
89
145
  end
146
+
147
+ # `<basename>_*_test.rb` files in each mirrored test directory (#87). The
148
+ # exact `<basename>_test.rb` name is not included. `lib/` sources also look
149
+ # under `test/lib/`. Names are matched as text, not as a glob, so a basename
150
+ # that contains `*` stays literal. `test/` comes before `test/lib/`, and
151
+ # each directory is sorted.
152
+ #
153
+ # @param base [String] logical source path without extension.
154
+ # @param lib [Boolean] whether the source originated from lib/.
155
+ # @param root [String] expanded project root.
156
+ # @return [Array<String>] relative split-test paths.
157
+ def split_test_files(base, lib, root)
158
+ name = File.basename(base)
159
+ dirs = [mirror_dir("test", base)]
160
+ dirs << mirror_dir("test/lib", base) if lib
161
+ dirs.flat_map { |dir| split_tests_in(root, dir, name, base) }
162
+ end
163
+
164
+ # Relative test directory for a logical source path. A source with no
165
+ # subdirectory (`calc`) maps to `prefix` itself, not `prefix/.`.
166
+ #
167
+ # @param prefix [String] `test` or `test/lib`.
168
+ # @param base [String] logical source path without extension.
169
+ # @return [String] relative directory.
170
+ def mirror_dir(prefix, base)
171
+ dir = File.dirname(base)
172
+ dir == "." ? prefix : File.join(prefix, dir)
173
+ end
174
+
175
+ # Split test files directly inside `dir_rel` (not in subdirectories).
176
+ #
177
+ # @param root [String] expanded project root.
178
+ # @param dir_rel [String] relative directory.
179
+ # @param name [String] source basename without extension.
180
+ # @param base [String] logical source path without extension.
181
+ # @return [Array<String>] sorted relative paths.
182
+ def split_tests_in(root, dir_rel, name, base)
183
+ root_abs = File.expand_path(root)
184
+ dir_abs = File.expand_path(dir_rel, root_abs)
185
+ return [] unless inside_project?(dir_abs, root_abs)
186
+ return [] unless File.directory?(dir_abs)
187
+
188
+ Dir.children(dir_abs).filter_map do |entry|
189
+ next unless split_entry?(entry, name)
190
+ next unless File.file?(File.join(dir_abs, entry))
191
+ next if claimed_by_longer_source?(root, base, entry, dir_rel)
192
+
193
+ File.join(dir_rel, entry)
194
+ end.sort
195
+ end
196
+
197
+ # True when `dir_abs` is the project root or a directory inside it.
198
+ # A source path such as `../../evil.rb` must not list tests outside the project.
199
+ #
200
+ # @param dir_abs [String] expanded directory.
201
+ # @param root_abs [String] expanded project root.
202
+ # @return [Boolean]
203
+ def inside_project?(dir_abs, root_abs)
204
+ prefix = root_abs == File::SEPARATOR ? File::SEPARATOR : "#{root_abs}#{File::SEPARATOR}"
205
+ dir_abs == root_abs || dir_abs.start_with?(prefix)
206
+ end
207
+
208
+ # True when `entry` is `<name>_<piece>_test.rb` and not the exact
209
+ # `<name>_test.rb` file.
210
+ #
211
+ # @param entry [String] test file basename.
212
+ # @param name [String] source basename without extension.
213
+ # @return [Boolean]
214
+ def split_entry?(entry, name)
215
+ return false unless entry.end_with?("_test.rb")
216
+
217
+ rest = entry.delete_prefix("#{name}_")
218
+ rest != entry && rest.match?(/\A.+_test\.rb\z/)
219
+ end
220
+
221
+ # True when a longer source file on disk owns this split test. `user.rb`
222
+ # does not take `user_session_test.rb` when `user_session.rb` exists in the
223
+ # same directory, under `app/`, or under `lib/`. An intermediate file
224
+ # counts: `bar_upsert.rb` owns `bar_upsert_guards_test.rb`, so `bar.rb`
225
+ # does not.
226
+ #
227
+ # @param root [String] expanded project root.
228
+ # @param base [String] logical source path without extension.
229
+ # @param entry [String] test file basename.
230
+ # @param scanned_dir [String, nil] relative test directory being scanned.
231
+ # When set, a longer source claims the file only if that source searches
232
+ # this directory. An `app/` source does not take a `test/lib/` file.
233
+ # @return [Boolean]
234
+ def claimed_by_longer_source?(root, base, entry, scanned_dir = nil)
235
+ name = File.basename(base)
236
+ stem = entry.sub(/_test\.rb\z/, "")
237
+ return false unless stem.start_with?("#{name}_")
238
+
239
+ rest = stem.delete_prefix("#{name}_")
240
+ return false if rest.empty?
241
+
242
+ dir = File.dirname(base)
243
+ parts = rest.split("_", -1)
244
+ (1..parts.length).any? do |i|
245
+ longer = "#{name}_#{parts.first(i).join("_")}"
246
+ next false unless longer_owns_entry?(entry, longer)
247
+
248
+ longer_source_exists?(root, dir, longer, scanned_dir)
249
+ end
250
+ end
251
+
252
+ # True when `entry` is the exact test, or a split test with a non-empty
253
+ # piece, for `stem`. `user_session__test.rb` is neither for
254
+ # `user_session`, so that source does not take it from `user.rb`.
255
+ #
256
+ # @param entry [String] test file basename.
257
+ # @param stem [String] longer source basename without extension.
258
+ # @return [Boolean]
259
+ def longer_owns_entry?(entry, stem)
260
+ entry == "#{stem}_test.rb" || split_entry?(entry, stem)
261
+ end
262
+
263
+ # True when a longer source file exists beside this logical path.
264
+ # The logical directory itself is checked, so `src/user_session.rb` owns
265
+ # `test/src/user_session_test.rb`. `app/` and `lib/` are checked too, so an
266
+ # `app/` source still yields the name to a `lib/` sibling and the reverse.
267
+ #
268
+ # @param root [String] expanded project root.
269
+ # @param dir [String] logical directory, or `.` when the source has none.
270
+ # @param stem [String] longer source basename without extension.
271
+ # @param scanned_dir [String, nil] relative test directory being scanned.
272
+ # @return [Boolean]
273
+ def longer_source_exists?(root, dir, stem, scanned_dir = nil)
274
+ file = "#{stem}.rb"
275
+ folders = [dir == "." ? nil : dir]
276
+ %w[app lib].each { |prefix| folders << (dir == "." ? prefix : File.join(prefix, dir)) }
277
+ folders.uniq.any? do |folder|
278
+ rel = folder ? File.join(folder, file) : file
279
+ next false unless File.file?(File.join(root, rel))
280
+ next false if scanned_dir && !source_searches_dir?(rel, scanned_dir)
281
+
282
+ true
283
+ end
284
+ end
285
+
286
+ # True when pairing this source looks in `scanned_dir`.
287
+ #
288
+ # @param source_rel [String] project-relative source path.
289
+ # @param scanned_dir [String] relative test directory.
290
+ # @return [Boolean]
291
+ def source_searches_dir?(source_rel, scanned_dir)
292
+ base, lib = logical_path(source_rel)
293
+ dirs = [mirror_dir("test", base)]
294
+ dirs << mirror_dir("test/lib", base) if lib
295
+ dirs.include?(scanned_dir)
296
+ end
90
297
  end
91
298
  end
@@ -10,9 +10,9 @@ module Mutineer
10
10
 
11
11
  # Thin boundary around Prism.
12
12
  #
13
- # Both methods return a Prism::ParseResult so all callers use result.value,
14
- # result.source.source (raw bytes), and result.errors uniformly. No wrapping
15
- # struct.
13
+ # The parse methods return a Prism::ParseResult so all callers use
14
+ # result.value, result.source.source (raw bytes), and result.errors
15
+ # uniformly. No wrapping struct.
16
16
  class Parser
17
17
  # Parses a file with Prism.
18
18
  #
@@ -32,5 +32,14 @@ module Mutineer
32
32
  def self.parse_string(source)
33
33
  Prism.parse(source)
34
34
  end
35
+
36
+ # The comments of source text, without building its syntax tree: a few
37
+ # times cheaper than {.parse_string} for a caller that needs only these.
38
+ #
39
+ # @param source [String] source text.
40
+ # @return [Array<Prism::Comment>] its comments, in source order.
41
+ def self.comments(source)
42
+ Prism.parse_comments(source)
43
+ end
35
44
  end
36
45
  end
@@ -82,6 +82,9 @@ module Mutineer
82
82
  # @param node [Prism::CallNode] call node.
83
83
  # @return [void]
84
84
  def visit_call_node(node)
85
+ if %i[public private protected].include?(node.name) && node.receiver.nil? && node.arguments.nil?
86
+ @module_function_active = false # without arguments, Ruby goes back to instance methods
87
+ end
85
88
  if node.name == :module_function && node.receiver.nil?
86
89
  args = node.arguments&.arguments || []
87
90
  if args.empty?
@@ -109,7 +112,9 @@ module Mutineer
109
112
  return unless node.expression.is_a?(Prism::SelfNode)
110
113
 
111
114
  @singleton_depth += 1
115
+ saved_active = @module_function_active
112
116
  super
117
+ @module_function_active = saved_active # a visibility call in here is not the module body's
113
118
  @singleton_depth -= 1
114
119
  end
115
120
 
@@ -126,7 +131,9 @@ module Mutineer
126
131
  singleton: !node.receiver.nil? || @singleton_depth.positive? || @module_function_active,
127
132
  def_node: node
128
133
  )
134
+ saved_active = @module_function_active
129
135
  super
136
+ @module_function_active = saved_active # a visibility call in a method body runs only when it is called
130
137
  end
131
138
 
132
139
  private
@@ -65,7 +65,7 @@ module Mutineer
65
65
  unless adapter.start_with?("sqlite")
66
66
  raise NotImplementedError,
67
67
  "worker-DB isolation currently provisions SQLite only (got adapter #{adapter.inspect}); " \
68
- "Postgres per-worker provisioning is not yet supported. Use a SQLite test DB, or drop --jobs."
68
+ "Postgres per-worker provisioning is not yet supported. Use a SQLite test DB with --daemon, or drop --daemon to run serially."
69
69
  end
70
70
 
71
71
  per_worker_config(hash, worker)