mutineer 1.4.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +475 -0
  3. data/README.md +224 -28
  4. data/lib/mutineer/changed_lines.rb +24 -1
  5. data/lib/mutineer/cli.rb +117 -20
  6. data/lib/mutineer/config.rb +64 -13
  7. data/lib/mutineer/coverage_map.rb +533 -100
  8. data/lib/mutineer/daemon_backend.rb +51 -33
  9. data/lib/mutineer/daemon_client.rb +212 -22
  10. data/lib/mutineer/daemon_server.rb +74 -27
  11. data/lib/mutineer/external_backend.rb +2 -2
  12. data/lib/mutineer/file_swap.rb +1 -1
  13. data/lib/mutineer/isolation.rb +92 -6
  14. data/lib/mutineer/job_plan.rb +352 -0
  15. data/lib/mutineer/kill_channel.rb +284 -0
  16. data/lib/mutineer/kill_matrix.rb +88 -0
  17. data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
  18. data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
  19. data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +14 -68
  20. data/lib/mutineer/minitest_integration.rb +75 -11
  21. data/lib/mutineer/mutant_id.rb +5 -4
  22. data/lib/mutineer/mutators/array_literal.rb +0 -7
  23. data/lib/mutineer/mutators/base.rb +63 -4
  24. data/lib/mutineer/mutators/chain_link.rb +0 -7
  25. data/lib/mutineer/mutators/collection_method.rb +1 -1
  26. data/lib/mutineer/mutators/condition_forcing.rb +0 -7
  27. data/lib/mutineer/mutators/operand_removal.rb +0 -7
  28. data/lib/mutineer/mutators/operator_assignment.rb +0 -7
  29. data/lib/mutineer/mutators/return_nil.rb +5 -11
  30. data/lib/mutineer/mutators/string_literal.rb +1 -1
  31. data/lib/mutineer/orphan_guard.rb +28 -0
  32. data/lib/mutineer/pairing.rb +222 -15
  33. data/lib/mutineer/parser.rb +12 -3
  34. data/lib/mutineer/project.rb +344 -21
  35. data/lib/mutineer/rails_worker_db.rb +83 -9
  36. data/lib/mutineer/reporter.rb +306 -53
  37. data/lib/mutineer/result.rb +73 -5
  38. data/lib/mutineer/runner.rb +123 -242
  39. data/lib/mutineer/statement_lines.rb +183 -0
  40. data/lib/mutineer/subject.rb +6 -2
  41. data/lib/mutineer/test_runners/minitest.rb +4 -2
  42. data/lib/mutineer/test_runners/rspec.rb +282 -3
  43. data/lib/mutineer/test_runners.rb +5 -3
  44. data/lib/mutineer/version.rb +1 -1
  45. data/lib/mutineer/worker_pool.rb +4 -2
  46. data/lib/mutineer.rb +2 -0
  47. metadata +10 -3
@@ -3,15 +3,18 @@
3
3
  require "json"
4
4
  require "tempfile"
5
5
  require_relative "child_stdout"
6
+ require_relative "orphan_guard"
6
7
 
7
8
  module Mutineer
8
9
  # App-side daemon (persistent worker).
9
10
  #
10
11
  # Runs UNDER THE APP'S OWN BUNDLE/RUBY (the tool's DaemonClient spawns it via
11
12
  # `bundle exec ruby`). It boots the app ONCE, then serves per-mutant test-run
12
- # requests over stdin/stdout as newline-delimited JSON. For each request it
13
- # FORKS a child that loads the mutated source text the tool sent, runs the
14
- # covering tests, and exits with a status the parent decodes into a verdict.
13
+ # requests over stdin/stdout as newline-delimited JSON (the protocol keeps a
14
+ # private copy of the original stdout; the app's own stdout goes to stderr).
15
+ # For each request it FORKS a child that loads the mutated source text the
16
+ # tool sent, runs the covering tests, and exits with a status the parent
17
+ # decodes into a verdict.
15
18
  #
16
19
  # HARD CONSTRAINT: this file must be loadable WITHOUT Prism or the rest of
17
20
  # mutineer. The app's Ruby may be < 3.4 (no stdlib Prism) and its bundle has no
@@ -21,7 +24,7 @@ module Mutineer
21
24
  # tool-side; the daemon only `load`s text.
22
25
  #
23
26
  # Protocol (one JSON object per line, both directions):
24
- # boot in : {"cmd":"boot","project_root":"...","boot":"config/environment",
27
+ # boot in : {"cmd":"boot","project_root":"...","boot":"config/environment","require_paths":[...],
25
28
  # "load_paths":["test"],"framework":"minitest","rails":true,"schema":"db/schema.rb"}
26
29
  # ready out: {"ready":true,"ruby":"3.3.6"} (or {"ready":false,"error":"..."} then exit)
27
30
  # run in : {"id":N,"worker":I,"payload":{"code":"<ruby>","source_file":"app/models/order.rb"},
@@ -49,10 +52,14 @@ module Mutineer
49
52
  # Serve the protocol on the given IO pair (defaults to stdio). Returns on quit.
50
53
  #
51
54
  # @param input [IO] request stream.
52
- # @param output [IO] verdict stream.
55
+ # @param output [IO, nil] verdict stream. nil (the default) reserves the
56
+ # process's original stdout for the protocol and points fd 1 at stderr.
53
57
  # @param errio [IO] diagnostics stream (never the IPC channel).
54
58
  # @return [void]
55
- def run(input: $stdin, output: $stdout, errio: $stderr)
59
+ def run(input: $stdin, output: nil, errio: $stderr)
60
+ # Only a stream `run` reserved itself is the daemon's to close in a fork.
61
+ @protocol = output ? nil : reserve_protocol_output
62
+ output ||= @protocol
56
63
  @errio = errio
57
64
  @output = output
58
65
  boot_line = input.gets
@@ -92,6 +99,21 @@ module Mutineer
92
99
 
93
100
  private
94
101
 
102
+ # Move the protocol channel off fd 1 before the app boots. The app may
103
+ # print through `puts`, `STDOUT`, or a raw fd 1 write (a logger, a
104
+ # subprocess). A line like that ahead of the ready message would break
105
+ # the client's JSON read. The protocol keeps a private dup of the
106
+ # original stdout. Fd 1 then points at stderr, so app output stays
107
+ # visible in the tool's diagnostics.
108
+ #
109
+ # @return [IO] the reserved protocol stream.
110
+ def reserve_protocol_output
111
+ protocol = STDOUT.dup
112
+ STDOUT.reopen(STDERR)
113
+ $stdout = STDOUT
114
+ protocol
115
+ end
116
+
95
117
  # BOOT ONCE. chdir + require the app's boot file so the whole app is loaded
96
118
  # and inherited by every fork. Never requires mutineer.
97
119
  def boot!(cfg)
@@ -105,13 +127,16 @@ module Mutineer
105
127
  # instrumented. The map build (build_via_fork) forks this booted parent.
106
128
  if cfg["coverage"]
107
129
  require "coverage"
108
- Coverage.start(lines: true)
130
+ Coverage.start(lines: true, methods: true)
109
131
  end
110
132
  # Clear any mutant tempfile a prior SIGKILLed timeout child orphaned in a
111
133
  # source dir BEFORE the app boots. Zeitwerk would otherwise choke on the
112
134
  # tempfile's non-constant name during autoload setup.
113
135
  sweep_temps
114
136
  require File.expand_path(cfg["boot"]) if cfg["boot"]
137
+ # The --require files, after the boot as in-process (Runner.execute), so
138
+ # the coverage peek sees what they ran at load.
139
+ Array(cfg["require_paths"]).each { |f| require File.expand_path(f) }
115
140
  setup_worker_db(cfg) if cfg["rails"]
116
141
  rescue Exception => e # rubocop:disable Lint/RescueException
117
142
  # Boot failed (bad boot path, app error). Tell the client and exit so it
@@ -131,15 +156,15 @@ module Mutineer
131
156
  @worker_db = RailsWorkerDb.available? ? RailsWorkerDb : nil
132
157
  schema = cfg["schema"] && File.expand_path(cfg["schema"])
133
158
  @schema_path = schema if schema && File.exist?(schema)
134
- # Schema is loaded once per worker slot on first use (not every mutant fork).
135
- @schema_ready = {}
159
+ # Each worker slot is seeded once, on first use (not every mutant fork).
160
+ @slot_ready = {}
136
161
  rescue LoadError => e
137
162
  @errio.puts("[daemon] worker-DB routing unavailable: #{e.message}")
138
163
  @worker_db = nil
139
164
  end
140
165
 
141
166
  # Build the coverage map app-side (Coverage was started at boot) and return
142
- # it as `{map, failed_test_files}` for the tool to select covering tests.
167
+ # it as `{map, failed_test_files, load_lines, load_methods}` for the tool to select covering tests.
143
168
  # Capture forks route to worker 0's DB (isolated, serial). On any failure
144
169
  # return an empty map + an error string. The tool then falls back to the
145
170
  # full test set rather than mis-scoring everything as no_coverage.
@@ -149,41 +174,61 @@ module Mutineer
149
174
  cmap = CoverageMap.new(
150
175
  source_paths: Array(@cfg["sources"]), test_paths: Array(@cfg["tests"]),
151
176
  load_paths: Array(@cfg["load_paths"]), project_root: root,
152
- boot_path: @cfg["boot"], framework: @framework, cache_dir: File.join(root, ".mutineer")
177
+ boot_path: @cfg["boot"], require_paths: Array(@cfg["require_paths"]),
178
+ framework: @framework, cache_dir: @cfg["cache_dir"] || File.join(root, ".mutineer"),
179
+ capture_timeout: @cfg["capture_timeout"] || CoverageMap::DEFAULT_CAPTURE_TIMEOUT
153
180
  ).build_via_fork(after_fork: coverage_after_fork)
181
+ # Nothing reads Coverage after the map, so a mutant forked from this
182
+ # daemon does not pay for it (#228).
183
+ Coverage.suspend if Coverage.running?
154
184
  { "map" => cmap.map, "failed_test_files" => cmap.failed_test_files,
155
- "failed_clean_tests" => cmap.failed_clean_tests }
185
+ "failed_clean_tests" => cmap.failed_clean_tests, "load_lines" => cmap.load_lines.to_a,
186
+ "load_methods" => cmap.load_methods.to_a }
156
187
  rescue Exception => e # rubocop:disable Lint/RescueException
157
188
  @errio.puts("[daemon] coverage build failed: #{e.class}: #{e.message}")
158
189
  { "map" => {}, "failed_test_files" => [], "error" => "#{e.class}: #{e.message}" }
159
190
  end
160
191
 
161
- # Fork-safety hook for coverage capture: route each capture fork to worker
162
- # 0's isolated DB (captures run serially, so one worker is enough). Nil when
163
- # the app has no worker-DB adapter (non-Rails). Capture then runs as before.
192
+ # Fork-safety hook for coverage capture. Each capture fork drops its copy
193
+ # of the protocol channel (see close_protocol) and, when the app has a
194
+ # worker-DB adapter, routes to a fresh copy of the base DB in worker 0's
195
+ # slot (captures run serially, so one worker is enough).
164
196
  def coverage_after_fork
165
- return nil unless @worker_db
166
-
197
+ worker_db = @worker_db
167
198
  schema = @schema_path
168
- -> { @worker_db.after_fork(0, schema) }
199
+ lambda do
200
+ close_protocol
201
+ worker_db&.after_fork(0, schema, seed: true)
202
+ end
203
+ end
204
+
205
+ # A forked child never answers on the protocol channel. Closing its
206
+ # inherited copy lets a daemon crash read as EOF on the client at once,
207
+ # not only after a slow or hung child exits. A caller-supplied output
208
+ # (for example STDOUT) is left open.
209
+ def close_protocol
210
+ @protocol&.close rescue nil # rubocop:disable Style/RescueModifier
169
211
  end
170
212
 
171
213
  # Fork a child to run one mutant in isolation; decode its exit into a verdict.
172
214
  def run_mutant(req)
173
215
  timeout = req.fetch("timeout", 30)
174
216
  worker = req.fetch("worker", 0)
175
- # Load schema until the first killed/survived fork for this worker slot.
176
- schema_for_fork = (@worker_db && @schema_path && !@schema_ready[worker]) ? @schema_path : nil
217
+ # Seed the slot's DB until the first killed/survived fork for this worker slot.
218
+ first_use = @worker_db && !@slot_ready[worker]
219
+ daemon = Process.pid
177
220
  pid = fork do
178
221
  # New process group so a per-fork timeout can SIGKILL the whole subtree,
179
- # and silence the child's stdout so test output never corrupts the IPC pipe.
222
+ # and silence the child's stdout so test output stays out of the diagnostics.
180
223
  Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
224
+ OrphanGuard.start(daemon)
181
225
  code =
182
226
  begin
183
227
  ChildStdout.silence
228
+ close_protocol
184
229
  # Route THIS fork at its own worker database before any test loads.
185
230
  # A routing failure raises here and is scored `error`, never a false verdict.
186
- @worker_db&.after_fork(worker, schema_for_fork)
231
+ @worker_db&.after_fork(worker, first_use ? @schema_path : nil, seed: first_use)
187
232
  apply_payload(req["payload"])
188
233
  run_tests(Array(req["tests"]))
189
234
  rescue Exception => e # rubocop:disable Lint/RescueException
@@ -193,10 +238,10 @@ module Mutineer
193
238
  exit!(code)
194
239
  end
195
240
  verdict = wait_verdict(pid, timeout)
196
- # Mark ready only when the child finished cleanly after schema load
197
- # (killed/survived). Timeout can interrupt mid-load_schema; error is a
198
- # routing failure. Both leave the slot unready so the next fork reloads.
199
- @schema_ready[worker] = true if schema_for_fork && %w[killed survived].include?(verdict)
241
+ # Mark ready only when the child finished cleanly after seeding
242
+ # (killed/survived). Timeout can interrupt mid-copy; error is a routing
243
+ # failure. Both leave the slot unready so the next fork seeds again.
244
+ @slot_ready[worker] = true if first_use && %w[killed survived].include?(verdict)
200
245
  # A SIGKILLed timeout child skipped its Tempfile unlink. Sweep the orphan
201
246
  # so it cannot outlive the run or trip Zeitwerk on a later fork.
202
247
  sweep_temps if verdict == "timeout"
@@ -204,7 +249,7 @@ module Mutineer
204
249
  end
205
250
 
206
251
  # Remove orphaned mutant tempfiles from the source dirs (parent-side; the
207
- # SIGKILL path cannot run the child's ensure). Mirrors Runner.sweep_orphans.
252
+ # SIGKILL path cannot run the child's ensure). Mirrors JobPlan.sweep_orphans.
208
253
  def sweep_temps
209
254
  @source_dirs.to_a.each do |dir|
210
255
  Dir.glob(File.join(dir, "mutineer_daemon*.rb")).each do |f|
@@ -218,6 +263,8 @@ module Mutineer
218
263
  # pulls in Prism which is forbidden app-side). NOTE: this is the 3rd copy of
219
264
  # the waitpid2(WNOHANG)+deadline+pgroup-SIGKILL+decode discipline. A fix to
220
265
  # the kill/reap/decode logic must be applied to all three in lockstep.
266
+ # CoverageMap#await_child applies the same deadline and group kill to
267
+ # coverage capture.
221
268
  # SIGKILL the child's process group past the deadline; a signalled child
222
269
  # (nil exitstatus) is `error`.
223
270
  def wait_verdict(pid, timeout)
@@ -52,11 +52,11 @@ module Mutineer
52
52
  ].freeze
53
53
 
54
54
  # Turn a command template into an argv array (no shell → no eval, no
55
- # injection). The `%{files}` token expands IN PLACE to N separate argv
55
+ # injection). The <tt>%{files}</tt> token expands IN PLACE to N separate argv
56
56
  # elements — one per path, unescaped — so a path containing a space stays a
57
57
  # single argument. It is not a space-joined string.
58
58
  #
59
- # @param command [String] the --test-command template (contains %{files}).
59
+ # @param command [String] the --test-command template (contains <tt>%{files}</tt>).
60
60
  # @param files [Array<String>] test file paths to substitute.
61
61
  # @return [Array<String>] argv.
62
62
  def self.build_argv(command, files)
@@ -21,7 +21,7 @@ module Mutineer
21
21
  # which makes leaving the file mutated the one genuinely dangerous failure mode.
22
22
  #
23
23
  # Defense in depth, mirroring the tempfile-orphan discipline
24
- # (`Runner.sweep_orphans`, `isolation.rb` tempfiles):
24
+ # (`JobPlan.sweep_orphans`, `isolation.rb` tempfiles):
25
25
  # - exclusive OS ownership (flock) is acquired before swap or recovery;
26
26
  # - the original bytes are held in memory AND written to a sibling backup;
27
27
  # - `ensure` restores from memory around every mutant;
@@ -4,6 +4,7 @@ require "tempfile"
4
4
  require_relative "result"
5
5
  require_relative "parser"
6
6
  require_relative "child_stdout"
7
+ require_relative "kill_channel"
7
8
 
8
9
  module Mutineer
9
10
  # Fork-based isolation for running one mutant. The block runs in a child
@@ -23,6 +24,9 @@ module Mutineer
23
24
  class Isolation
24
25
  DEFAULT_TIMEOUT = 10 # seconds
25
26
 
27
+ # Seconds between the parent's checks on a running child.
28
+ POLL_INTERVAL = 0.005
29
+
26
30
  # Runs the block in a forked child. The block's return value (an Integer
27
31
  # exit code) or any explicit `exit` is honoured; an unhandled exception
28
32
  # becomes exit 2 with the cause written to STDERR.
@@ -30,18 +34,30 @@ module Mutineer
30
34
  # The child silences its stdout (see {ChildStdout.silence}) before the
31
35
  # block runs, so test output never reaches the user. Stderr stays open.
32
36
  #
37
+ # With `channel: true` (a `--matrix` run) the block gets the write end of a
38
+ # pipe for {KillChannel} lines. The parent reads it while it waits, so a
39
+ # child with more than a pipe buffer to say never blocks, and attaches the
40
+ # lines to the Result as {Kills}. The verdict is the one a run without
41
+ # `--matrix` gives (see {.finish}).
42
+ #
33
43
  # @param timeout [Integer] timeout in seconds.
44
+ # @param channel [Boolean] open a {KillChannel} pipe for the block.
45
+ # @yieldparam channel [IO, nil] the pipe's write end, or nil without `channel`.
34
46
  # @yieldreturn [Integer] child exit status.
35
47
  # @return [Mutineer::Result] result from the child process.
36
- def self.run(timeout: DEFAULT_TIMEOUT)
48
+ def self.run(timeout: DEFAULT_TIMEOUT, channel: false)
49
+ rd, wr = IO.pipe if channel
50
+ wr&.sync = true
37
51
  pid = fork do
52
+ rd&.close
38
53
  # Own process group so a timeout kill can reap grandchildren (match
39
54
  # daemon/external backends). Best-effort: if setpgid fails, kill the pid.
40
55
  Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
41
56
  code = 0
42
57
  begin
43
58
  ChildStdout.silence
44
- result = yield
59
+ # A block that takes no channel (a zero-arity lambda) still runs.
60
+ result = channel ? yield(wr) : yield
45
61
  code = result.is_a?(Integer) ? result : 0
46
62
  rescue SystemExit => e
47
63
  code = e.status
@@ -57,13 +73,17 @@ module Mutineer
57
73
  exit!(code)
58
74
  end
59
75
 
76
+ wr&.close
77
+ buffer = +"" if rd
78
+ reading = !rd.nil?
79
+
60
80
  # Single-threaded deadline poll: we are the ONLY caller of waitpid on this
61
81
  # pid, so we never reap-then-kill. SIGKILL the process group only after
62
82
  # WNOHANG shows the child is still alive past the deadline.
63
83
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
64
84
  loop do
65
85
  reaped, status = Process.waitpid2(pid, Process::WNOHANG)
66
- return decode(status) if reaped
86
+ return finish(decode(status), rd, buffer, finished: true) if reaped
67
87
 
68
88
  if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
69
89
  begin
@@ -74,14 +94,79 @@ module Mutineer
74
94
  begin
75
95
  _reaped, status = Process.waitpid2(pid)
76
96
  # Child may have finished cleanly between WNOHANG and kill; honor it.
77
- return decode(status) if status && status.exited? && !status.signaled?
97
+ return finish(decode(status), rd, buffer, finished: true) if status && status.exited? && !status.signaled?
78
98
  rescue Errno::ECHILD
79
99
  # already reaped
80
100
  end
81
- return Result.timeout
101
+ return finish(Result.timeout, rd, buffer, finished: false)
102
+ end
103
+ if reading
104
+ IO.select([rd], nil, nil, POLL_INTERVAL)
105
+ reading = drain(rd, buffer)
106
+ else
107
+ sleep POLL_INTERVAL
82
108
  end
83
- sleep 0.005
84
109
  end
110
+ ensure
111
+ rd&.close
112
+ # Closed after the fork in the normal path; still open if fork raised.
113
+ wr.close if wr && !wr.closed?
114
+ end
115
+
116
+ # Reads what the channel holds now, without blocking.
117
+ #
118
+ # @api private
119
+ # @param rd [IO] the channel's read end.
120
+ # @param buffer [String] bytes read so far; appended to.
121
+ # @return [Boolean] false once the channel is at end of file.
122
+ def self.drain(rd, buffer)
123
+ loop do
124
+ chunk = rd.read_nonblock(65_536, exception: false)
125
+ return true if chunk == :wait_readable
126
+ return false if chunk.nil?
127
+
128
+ buffer << chunk
129
+ end
130
+ end
131
+
132
+ # The Result to return. Without a channel it is `result` itself. With one,
133
+ # the rest of the channel is read, and the Result carries the {Kills} it
134
+ # names.
135
+ #
136
+ # The verdict matches a run without `--matrix`, which skips everything after
137
+ # the first failing serial test except the code around it (see
138
+ # {KillChannel}). So when a serial test has named a kill and the child then
139
+ # ended inside a region that run would skip (an exit, a crash or the
140
+ # timeout in a later test, class or group), the mutant is `killed`: the
141
+ # plain run never reached that point. An end anywhere else keeps the exit
142
+ # status, because the plain run reaches that code too: the rest of the
143
+ # failing test's class wrapper or group hooks, or the suite hooks. A kill in
144
+ # the parallel tests of a Minitest run also keeps it, because the stop
145
+ # cannot skip tests already queued.
146
+ #
147
+ # The row is complete only when the child ended before the timeout, sent a
148
+ # valid stream with both `start` and `end`, lost no line, and its kills
149
+ # agree with the verdict (a killed mutant names a killer; a survivor names
150
+ # none). An invalid stream promotes nothing, and its tests are dropped: an
151
+ # out-of-order line means the parent cannot tell which outcomes are real.
152
+ #
153
+ # @api private
154
+ # @param result [Mutineer::Result] the verdict from the exit status or the timeout.
155
+ # @param rd [IO, nil] the channel's read end.
156
+ # @param buffer [String, nil] bytes read so far.
157
+ # @param finished [Boolean] the child ended before the timeout.
158
+ # @return [Mutineer::Result]
159
+ def self.finish(result, rd, buffer, finished:)
160
+ return result unless rd
161
+
162
+ drain(rd, buffer)
163
+ report = KillChannel.parse(buffer)
164
+ return result.with(kills: Kills.new(killed_by: [], ran: [], complete: false)) if report.invalid
165
+
166
+ result = Result.killed if report.started && report.serial_kill && report.skipping.positive?
167
+ agrees = result.killed? ? report.killed.any? : result.survived? && report.killed.empty?
168
+ complete = finished && report.started && report.finished && report.lost.zero? && agrees
169
+ result.with(kills: Kills.new(killed_by: report.killed, ran: report.ran, complete: complete))
85
170
  end
86
171
 
87
172
  # Strategy 7a (default): write the whole mutated file and `load` it, which
@@ -149,6 +234,7 @@ module Mutineer
149
234
  else
150
235
  mutated_def
151
236
  end
237
+ inner = "#{subject.block_owner}.class_eval do\n#{inner}\nend" if subject.block_owner
152
238
  wrapped = "#{prefix}#{inner}#{"\nend" * keywords.size}"
153
239
 
154
240
  # A snippet that fails to reparse must NOT silently fall through to