henitai 0.1.10 → 0.2.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.
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "stringio"
5
+ require_relative "process_wakeup"
4
6
  require_relative "integration/rspec_process_runner"
5
7
 
6
8
  module Henitai
@@ -108,6 +110,204 @@ module Henitai
108
110
  end
109
111
  end
110
112
 
113
+ # Shared debug helpers for child-run diagnostics.
114
+ # Debug helpers are intentionally grouped here so the child-run diagnostics
115
+ # stay isolated from the main integration flow.
116
+ # rubocop:disable Metrics/ModuleLength
117
+ module ChildDebugSupport
118
+ private
119
+
120
+ def run_rspec_runner(test_files)
121
+ debug_child_puts("[henitai-debug-child] build_rspec_runner_start")
122
+ runner = build_rspec_runner
123
+ debug_child_puts("[henitai-debug-child] build_rspec_runner_return")
124
+ debug_child_puts("[henitai-debug-child] configure_rspec_runner_start")
125
+ configure_rspec_runner(runner)
126
+ debug_child_puts("[henitai-debug-child] configure_rspec_runner_return")
127
+ load_rspec_spec_files(test_files)
128
+ run_rspec_specs(runner)
129
+ rescue SystemExit => e
130
+ debug_child_puts("[henitai-debug-child] runner_run_system_exit status=#{e.status.inspect}")
131
+ raise
132
+ ensure
133
+ debug_child_puts("[henitai-debug-child] runner_run_ensure")
134
+ end
135
+
136
+ def build_rspec_runner
137
+ # @type var empty_args: Array[String]
138
+ empty_args = []
139
+ configuration_options = ::RSpec::Core.const_get(:ConfigurationOptions).new(empty_args)
140
+ ::RSpec::Core::Runner.__send__(:new, configuration_options)
141
+ end
142
+
143
+ def configure_rspec_runner(runner)
144
+ debug_child_puts("[henitai-debug-child] trap_interrupt_start")
145
+ ::RSpec::Core::Runner.__send__(:trap_interrupt)
146
+ debug_child_puts("[henitai-debug-child] trap_interrupt_return")
147
+ debug_child_puts("[henitai-debug-child] runner_configure_start")
148
+ runner.send(:configure, $stderr, $stdout)
149
+ debug_child_puts("[henitai-debug-child] runner_configure_return")
150
+ end
151
+
152
+ def load_rspec_spec_files(test_files)
153
+ debug_child_puts("[henitai-debug-child] load_spec_files_start")
154
+ ::RSpec.__send__(:configuration).files_to_run = test_files.map do |file|
155
+ File.expand_path(file)
156
+ end
157
+ ::RSpec.__send__(:configuration).load_spec_files
158
+ debug_child_example_count("after_load")
159
+ debug_child_puts("[henitai-debug-child] load_spec_files_return")
160
+ end
161
+
162
+ def run_rspec_specs(runner)
163
+ debug_child_puts("[henitai-debug-child] run_specs_start")
164
+ result = runner.send(:run_specs, ::RSpec.__send__(:world).ordered_example_groups)
165
+ debug_child_puts("[henitai-debug-child] run_specs_return result=#{result.inspect}")
166
+ result
167
+ end
168
+
169
+ def debug_child? = ENV["HENITAI_DEBUG_CHILD"] == "1"
170
+
171
+ def debug_child_puts(message)
172
+ $stdout.puts(message)
173
+ $stdout.flush
174
+ end
175
+
176
+ def debug_child_rspec_trace(test_files:, rspec_options:, rspec_argv:)
177
+ return unless debug_child?
178
+
179
+ files_exist = test_files.map { |f| [f, File.exist?(f)] }.inspect
180
+ loaded_features = loaded_feature_map(test_files).inspect # steep:ignore Ruby::NoMethod
181
+
182
+ debug_child_puts(
183
+ "[henitai-debug-child] cwd=#{Dir.pwd}\n" \
184
+ "[henitai-debug-child] files_exist=#{files_exist}\n" \
185
+ "[henitai-debug-child] loaded_features_check=#{loaded_features}\n" \
186
+ "[henitai-debug-child] test_files=#{test_files.inspect}\n" \
187
+ "[henitai-debug-child] rspec_options=#{rspec_options.inspect}\n" \
188
+ "[henitai-debug-child] rspec_argv=#{rspec_argv.inspect}"
189
+ )
190
+ end
191
+
192
+ def debug_child_rspec_exit(status)
193
+ return unless debug_child?
194
+
195
+ debug_child_puts("[henitai-debug-child] RSpec result=#{status.inspect}")
196
+ end
197
+
198
+ def suppress_simplecov!
199
+ CoverageRuntimeSuppressors.suppress_simplecov!
200
+ end
201
+
202
+ def suppress_coverage!
203
+ CoverageRuntimeSuppressors.suppress_coverage!
204
+ end
205
+
206
+ def debug_child_example_count(stage) # steep:ignore Ruby::UndeclaredMethodDefinition
207
+ return unless debug_child?
208
+
209
+ count = rspec_world_example_count
210
+ debug_child_puts(
211
+ "[henitai-debug-child] rspec_world_example_count_#{stage}=#{count.inspect}"
212
+ )
213
+ end
214
+
215
+ def debug_child_activation_start(mutant_id)
216
+ return unless debug_child?
217
+
218
+ debug_child_puts("[henitai-debug-child] activate_start mutant=#{mutant_id}")
219
+ end
220
+
221
+ def debug_child_activation_end(activation_result, test_files:)
222
+ return unless debug_child?
223
+
224
+ debug_child_puts(
225
+ "[henitai-debug-child] activate_end result=#{activation_result.inspect}\n" \
226
+ "[henitai-debug-child] run_tests_start test_files=#{test_files.inspect}"
227
+ )
228
+ end
229
+
230
+ def debug_child_mutant_meta(mutant)
231
+ stable_id = mutant.respond_to?(:stable_id) ? mutant.stable_id : nil
232
+ operator = mutant.respond_to?(:operator) ? mutant.operator : nil
233
+ has_subject_expression =
234
+ mutant.respond_to?(:subject) && mutant.subject.respond_to?(:expression)
235
+ subject_expression = has_subject_expression ? mutant.subject.expression : nil
236
+ location = mutant.respond_to?(:location) ? mutant.location.inspect : nil
237
+
238
+ debug_child_puts(
239
+ "[henitai-debug-child] mutant_meta stableId=#{stable_id}\n" \
240
+ "[henitai-debug-child] mutant_meta operator=#{operator}\n" \
241
+ "[henitai-debug-child] mutant_meta subject=#{subject_expression}\n" \
242
+ "[henitai-debug-child] mutant_meta location=#{location}\n"
243
+ )
244
+ end
245
+
246
+ def debug_child_activation_check
247
+ location = begin
248
+ Henitai::Runner.instance_method(:resolve_subjects).source_location&.join(":")
249
+ rescue StandardError
250
+ nil
251
+ end
252
+
253
+ debug_child_puts(
254
+ "[henitai-debug-child] activation_check resolve_subjects_location=#{location}\n"
255
+ )
256
+ end
257
+
258
+ def loaded_feature_map(test_files) = test_files.map { |file| [file, loaded_feature?(file)] } # steep:ignore Ruby::UndeclaredMethodDefinition
259
+
260
+ def loaded_feature?(file) # steep:ignore Ruby::UndeclaredMethodDefinition
261
+ expanded = File.expand_path(file)
262
+ candidates = [expanded, "#{expanded}.rb", file, "#{file}.rb"].uniq
263
+ $LOADED_FEATURES.any? do |feature|
264
+ normalized = begin
265
+ File.expand_path(feature)
266
+ rescue StandardError
267
+ feature
268
+ end
269
+ candidates.include?(feature) || candidates.include?(normalized)
270
+ end
271
+ end
272
+
273
+ def rspec_world_example_count # steep:ignore Ruby::UndeclaredMethodDefinition
274
+ world = ::RSpec.__send__(:world)
275
+ world.example_count
276
+ rescue StandardError
277
+ nil
278
+ end
279
+
280
+ def debug_child_timeout_dump(pid)
281
+ return unless debug_child?
282
+
283
+ debug_child_puts("[henitai-debug-child] timeout_signal_sent pid=#{pid}")
284
+ Process.kill(:USR1, pid)
285
+ pause(0.2)
286
+ rescue Errno::ESRCH
287
+ nil
288
+ end
289
+
290
+ def install_debug_timeout_trap
291
+ Signal.trap("USR1") { debug_child_thread_dump("timeout") }
292
+ end
293
+
294
+ def debug_child_thread_dump(reason)
295
+ return unless debug_child?
296
+
297
+ debug_child_puts("[henitai-debug-child] thread_dump reason=#{reason}")
298
+ Thread.list.each_with_index do |thread, index|
299
+ debug_child_puts(
300
+ "[henitai-debug-child] thread index=#{index} id=#{thread.object_id} " \
301
+ "status=#{thread.status.inspect}"
302
+ )
303
+ Array(thread.backtrace).each do |line|
304
+ debug_child_puts("[henitai-debug-child] #{line}")
305
+ end
306
+ end
307
+ end
308
+ end
309
+ # rubocop:enable Metrics/ModuleLength
310
+
111
311
  # Integration adapter for RSpec.
112
312
  #
113
313
  # This class exists as the stable public entry point for the RSpec
@@ -127,6 +327,8 @@ module Henitai
127
327
 
128
328
  # Base class for all integrations.
129
329
  class Base
330
+ include ChildDebugSupport
331
+
130
332
  # @param subject [Subject]
131
333
  # @return [Array<String>] paths to test files that cover this subject
132
334
  def select_tests(subject)
@@ -148,26 +350,33 @@ module Henitai
148
350
  raise NotImplementedError
149
351
  end
150
352
 
353
+ # Fork a child process for the mutant without waiting for it to finish.
354
+ # Returns a ChildHandle carrying the OS pid and log file paths.
355
+ # The caller is responsible for waiting and cleanup.
356
+ #
357
+ # @param mutant [Mutant]
358
+ # @param test_files [Array<String>]
359
+ # @return [RspecProcessRunner::ChildHandle]
360
+ def spawn_mutant(mutant:, test_files:)
361
+ raise NotImplementedError
362
+ end
363
+
151
364
  def per_test_coverage_supported?
152
365
  false
153
366
  end
154
367
 
155
368
  def wait_with_timeout(pid, timeout)
156
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
157
-
158
- loop do
159
- wait_result = Process.wait(pid, Process::WNOHANG)
160
- return Process.last_status if wait_result
369
+ wakeup = Henitai.const_get(:ProcessWakeup).new.install
370
+ return Process.last_status if wait_nonblocking(pid)
161
371
 
162
- if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
163
- final_wait_result = Process.wait(pid, Process::WNOHANG)
164
- return Process.last_status if final_wait_result
372
+ wakeup.wait(timeout)
373
+ wakeup.drain
374
+ return Process.last_status if wait_nonblocking(pid)
375
+ return Process.last_status if wait_nonblocking(pid)
165
376
 
166
- return handle_timeout(pid)
167
- end
168
-
169
- pause(0.01)
170
- end
377
+ handle_timeout(pid)
378
+ ensure
379
+ wakeup&.close
171
380
  end
172
381
 
173
382
  def reap_child(pid)
@@ -177,13 +386,22 @@ module Henitai
177
386
  end
178
387
 
179
388
  def cleanup_process_group(pid)
389
+ grace_period = 2.0
390
+ wakeup = Henitai.const_get(:ProcessWakeup).new.install
180
391
  Process.kill(:SIGTERM, -pid)
181
- pause(2.0)
392
+ return if wait_nonblocking(pid)
393
+
394
+ wakeup.wait(grace_period)
395
+ wakeup.drain
396
+ return if wait_nonblocking(pid)
397
+
182
398
  Process.kill(:SIGKILL, -pid)
183
399
  rescue Errno::EPERM
184
400
  cleanup_child_process(pid)
185
401
  rescue Errno::ESRCH
186
402
  nil
403
+ ensure
404
+ wakeup&.close
187
405
  end
188
406
 
189
407
  private
@@ -194,6 +412,7 @@ module Henitai
194
412
 
195
413
  def handle_timeout(pid)
196
414
  begin
415
+ debug_child_timeout_dump(pid)
197
416
  cleanup_process_group(pid)
198
417
  ensure
199
418
  reap_child(pid)
@@ -202,21 +421,32 @@ module Henitai
202
421
  end
203
422
 
204
423
  def cleanup_child_process(pid)
424
+ grace_period = 2.0
425
+ wakeup = Henitai.const_get(:ProcessWakeup).new.install
205
426
  Process.kill(:SIGTERM, pid)
206
- pause(2.0)
427
+ return if wait_nonblocking(pid)
428
+
429
+ wakeup.wait(grace_period)
430
+ wakeup.drain
431
+ return if wait_nonblocking(pid)
432
+
207
433
  Process.kill(:SIGKILL, pid)
208
434
  rescue Errno::EPERM, Errno::ESRCH
209
435
  nil
210
- end
211
-
212
- def rspec_options
213
- []
436
+ ensure
437
+ wakeup&.close
214
438
  end
215
439
 
216
440
  def subprocess_env
217
441
  { "PARALLEL_WORKERS" => "1" }
218
442
  end
219
443
 
444
+ def wait_nonblocking(pid)
445
+ Process.wait(pid, Process::WNOHANG)
446
+ rescue Errno::ECHILD, Errno::ESRCH
447
+ nil
448
+ end
449
+
220
450
  def scenario_log_support
221
451
  @scenario_log_support ||= ScenarioLogSupport.new
222
452
  end
@@ -241,6 +471,42 @@ module Henitai
241
471
  end
242
472
  end
243
473
  end
474
+
475
+ def with_non_interactive_stdin
476
+ original_stdin = $stdin
477
+ $stdin = StringIO.new
478
+ yield
479
+ ensure
480
+ $stdin = original_stdin
481
+ end
482
+
483
+ def run_tests(test_files)
484
+ require "rspec/core"
485
+ ::RSpec.__send__(:configuration).fail_if_no_examples = true
486
+ debug_child_rspec_trace(test_files:, rspec_options: [], rspec_argv: test_files)
487
+ debug_child_example_count("before_run") # steep:ignore Ruby::NoMethod
488
+ debug_child_puts("[henitai-debug-child] runner_run_start")
489
+ status = run_rspec_runner(test_files)
490
+ debug_child_puts("[henitai-debug-child] runner_run_return status=#{status.inspect}")
491
+ debug_child_example_count("after_run") # steep:ignore Ruby::NoMethod
492
+ debug_child_rspec_exit(status)
493
+ return status if status.is_a?(Integer)
494
+
495
+ status == true ? 0 : 1
496
+ end
497
+
498
+ def run_child_activation_and_tests(mutant:, test_files:, log_paths:)
499
+ scenario_log_support.with_coverage_dir(mutant.id) do
500
+ scenario_log_support.capture_child_output(log_paths) do
501
+ debug_child_mutant_meta(mutant) if debug_child?
502
+ debug_child_activation_start(mutant.id)
503
+ activation_result = Mutant::Activator.activate!(mutant)
504
+ debug_child_activation_check if debug_child?
505
+ debug_child_activation_end(activation_result, test_files:)
506
+ activation_result == :compile_error ? 2 : run_tests(test_files)
507
+ end
508
+ end
509
+ end
244
510
  end
245
511
 
246
512
  # RSpec integration adapter.
@@ -268,8 +534,11 @@ module Henitai
268
534
  fallback_spec_files(subject)
269
535
  end
270
536
 
271
- def test_files
272
- spec_files
537
+ def test_files = spec_files
538
+
539
+ def spawn_mutant(mutant:, test_files:)
540
+ log_paths = scenario_log_paths(mutant_log_name(mutant))
541
+ RspecProcessRunner.new.spawn_mutant(self, mutant:, test_files:, log_paths:)
273
542
  end
274
543
 
275
544
  def run_mutant(mutant:, test_files:, timeout:)
@@ -288,18 +557,30 @@ module Henitai
288
557
  [
289
558
  "bundle", "exec", "ruby",
290
559
  "-r", "henitai/rspec_coverage_formatter",
291
- "-S", "rspec", *test_files,
292
- "--format", "progress",
293
- "--format", "Henitai::CoverageFormatter"
560
+ "-e", rspec_suite_runner_script,
561
+ *test_files
294
562
  ]
295
563
  end
296
564
 
297
- def run_tests(test_files)
298
- require "rspec/core"
299
- status = RSpec::Core::Runner.run(test_files + rspec_options)
300
- return status if status.is_a?(Integer)
565
+ def rspec_suite_runner_script
566
+ <<~RUBY
567
+ require "rspec/core"
301
568
 
302
- status == true ? 0 : 1
569
+ test_files = ARGV.map { |file| File.expand_path(file) }
570
+ config = RSpec.configuration
571
+ options = RSpec::Core::ConfigurationOptions.new(
572
+ ["--format", "progress", "--format", "Henitai::CoverageFormatter"]
573
+ )
574
+ runner = RSpec::Core::Runner.send(:new, options)
575
+
576
+ RSpec::Core::Runner.send(:trap_interrupt)
577
+ runner.send(:configure, $stderr, $stdout)
578
+ config.files_to_run = test_files
579
+ config.load_spec_files
580
+
581
+ status = runner.send(:run_specs, RSpec.world.ordered_example_groups)
582
+ exit(status.is_a?(Integer) ? status : (status == true ? 0 : 1))
583
+ RUBY
303
584
  end
304
585
 
305
586
  def scenario_log_paths(name)
@@ -326,8 +607,10 @@ module Henitai
326
607
  end
327
608
 
328
609
  def spec_files
329
- paths = Dir.glob("spec/**/*_spec.rb")
330
- paths - excluded_spec_files
610
+ @spec_files ||= begin
611
+ paths = Dir.glob("spec/**/*_spec.rb")
612
+ paths - excluded_spec_files
613
+ end
331
614
  end
332
615
 
333
616
  def fallback_spec_files(subject)
@@ -343,7 +626,7 @@ module Henitai
343
626
  end
344
627
 
345
628
  def excluded_spec_files
346
- rspec_exclude_patterns.flat_map { |pattern| Dir.glob(pattern) }.uniq
629
+ @excluded_spec_files ||= rspec_exclude_patterns.flat_map { |pattern| Dir.glob(pattern) }.uniq
347
630
  end
348
631
 
349
632
  def rspec_exclude_patterns
@@ -441,16 +724,19 @@ module Henitai
441
724
  def run_in_child(mutant:, test_files:, log_paths:)
442
725
  Thread.report_on_exception = false
443
726
  with_subprocess_env do
444
- scenario_log_support.with_coverage_dir(mutant.id) do
445
- scenario_log_support.capture_child_output(log_paths) do
446
- return 2 if Mutant::Activator.activate!(mutant) == :compile_error
447
-
448
- run_tests(test_files)
449
- end
727
+ suppress_simplecov!
728
+ suppress_coverage!
729
+ install_debug_timeout_trap if debug_child?
730
+ with_non_interactive_stdin do
731
+ run_child_activation_and_tests(mutant:, test_files:, log_paths:)
450
732
  end
451
733
  end
452
734
  end
453
735
 
736
+ def mutant_log_name(mutant)
737
+ "mutant-#{mutant.id}"
738
+ end
739
+
454
740
  def read_log_file(path)
455
741
  return "" unless File.exist?(path)
456
742
 
@@ -497,6 +783,38 @@ module Henitai
497
783
  def start(*_args) = nil
498
784
  end
499
785
 
786
+ # Suppresses expensive and irrelevant coverage startup/teardown during
787
+ # mutant child runs. Coverage artifacts are only required during the
788
+ # dedicated bootstrap phase.
789
+ module CoverageRuntimeSuppressors
790
+ def self.suppress_simplecov!
791
+ require "simplecov"
792
+ sc = Object.const_get(:SimpleCov) # steep:ignore Ruby::UnknownConstant
793
+ sc.external_at_exit = true if sc.respond_to?(:external_at_exit=)
794
+ return if sc.singleton_class.ancestors.include?(SimpleCovStartSuppressor)
795
+
796
+ sc.singleton_class.prepend(SimpleCovStartSuppressor)
797
+ rescue LoadError, NameError
798
+ nil
799
+ end
800
+
801
+ def self.suppress_coverage!
802
+ require "coverage"
803
+ cov = Object.const_get(:Coverage) # steep:ignore Ruby::UnknownConstant
804
+ return if cov.singleton_class.ancestors.include?(CoverageStartSuppressor)
805
+
806
+ cov.singleton_class.prepend(CoverageStartSuppressor)
807
+ rescue LoadError, NameError
808
+ nil
809
+ end
810
+ end
811
+
812
+ # Prepended onto the coverage gem's Coverage singleton to turn start
813
+ # into a no-op during mutant child runs.
814
+ module CoverageStartSuppressor
815
+ def start(*_args) = nil
816
+ end
817
+
500
818
  # Minitest integration adapter.
501
819
  #
502
820
  # Coverage formatter injection remains implemented in the RSpec child
@@ -512,6 +830,11 @@ module Henitai
512
830
  super
513
831
  end
514
832
 
833
+ def spawn_mutant(mutant:, test_files:)
834
+ setup_load_path
835
+ super
836
+ end
837
+
515
838
  def run_in_child(mutant:, test_files:, log_paths:)
516
839
  ENV["RAILS_ENV"] = "test" unless ENV["RAILS_ENV"] == "test"
517
840
  preload_environment
@@ -539,6 +862,7 @@ module Henitai
539
862
 
540
863
  def run_tests(test_files)
541
864
  suppress_simplecov!
865
+ suppress_minitest_autorun!
542
866
  test_files.each { |file| require File.expand_path(file) }
543
867
  # @type var empty_args: Array[String]
544
868
  empty_args = []
@@ -558,6 +882,20 @@ module Henitai
558
882
  $LOAD_PATH.unshift(test_dir) unless $LOAD_PATH.include?(test_dir)
559
883
  end
560
884
 
885
+ def suppress_minitest_autorun!
886
+ require "minitest"
887
+ singleton_class = ::Minitest.singleton_class
888
+ suppressor = @minitest_autorun_suppressor ||= Module.new.tap do |mod|
889
+ mod.define_method(:autorun) { nil }
890
+ end
891
+ return if singleton_class.ancestors.include?(suppressor)
892
+
893
+ singleton_class.prepend(suppressor)
894
+ nil
895
+ rescue LoadError, NameError
896
+ nil
897
+ end
898
+
561
899
  def suppress_simplecov!
562
900
  require "simplecov"
563
901
  sc = Object.const_get(:SimpleCov) # steep:ignore Ruby::UnknownConstant
@@ -568,6 +906,16 @@ module Henitai
568
906
  nil
569
907
  end
570
908
 
909
+ def suppress_coverage!
910
+ require "coverage"
911
+ cov = Object.const_get(:Coverage) # steep:ignore Ruby::UnknownConstant
912
+ return if cov.singleton_class.ancestors.include?(CoverageStartSuppressor)
913
+
914
+ cov.singleton_class.prepend(CoverageStartSuppressor)
915
+ rescue LoadError, NameError
916
+ nil
917
+ end
918
+
571
919
  def subprocess_env
572
920
  env = super
573
921
  env["RAILS_ENV"] = "test" unless ENV["RAILS_ENV"] == "test"
@@ -6,6 +6,7 @@ require "unparser"
6
6
  module Henitai
7
7
  class Mutant
8
8
  # Activates a mutant inside the forked child process.
9
+ # rubocop:disable Metrics/ClassLength
9
10
  class Activator
10
11
  # Filters "already initialized constant" C-level warnings that fire when
11
12
  # a source file is loaded into a process that already has the constant
@@ -38,16 +39,26 @@ module Henitai
38
39
  new.activate!(mutant)
39
40
  end
40
41
 
42
+ # Returns the +define_method+ source string for +mutant+ without
43
+ # actually evaluating it. Used to pre-compute activation recipes.
44
+ # Returns nil if the source cannot be computed (e.g. unsupported AST node).
45
+ def self.activation_source_for(mutant)
46
+ new.send(:method_source, mutant)
47
+ rescue StandardError
48
+ nil
49
+ end
50
+
41
51
  def activate!(mutant)
42
52
  subject = mutant.subject
43
53
  raise ArgumentError, "Cannot activate wildcard subjects" if subject.method_name.nil?
44
54
 
55
+ source = mutant.precomputed_activation_source || method_source(mutant)
45
56
  target = target_for(subject)
46
57
  Henitai::WarningSilencer.silence do
47
- target.class_eval(method_source(mutant), __FILE__, __LINE__ + 1)
58
+ target.class_eval(source, __FILE__, __LINE__ + 1)
48
59
  nil
49
60
  end
50
- rescue Unparser::UnsupportedNodeError
61
+ rescue Unparser::UnsupportedNodeError, SyntaxError
51
62
  :compile_error
52
63
  end
53
64
 
@@ -266,5 +277,6 @@ module Henitai
266
277
  raise Unparser::UnsupportedNodeError, e.message
267
278
  end
268
279
  end
280
+ # rubocop:enable Metrics/ClassLength
269
281
  end
270
282
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "securerandom"
4
+ require_relative "mutant_identity"
4
5
 
5
6
  module Henitai
6
7
  # Represents a single syntactic mutation applied to a Subject.
@@ -34,7 +35,8 @@ module Henitai
34
35
  ].freeze
35
36
 
36
37
  attr_reader :id, :subject, :operator, :original_node, :mutated_node,
37
- :mutation_type, :description, :location
38
+ :mutation_type, :description, :location,
39
+ :precomputed_stable_id, :precomputed_activation_source
38
40
  attr_accessor :status, :killing_test, :duration, :covered_by, :tests_completed
39
41
 
40
42
  # @param subject [Subject] the subject being mutated
@@ -42,7 +44,9 @@ module Henitai
42
44
  # @param nodes [Hash] AST nodes with :original and :mutated entries
43
45
  # @param description [String] human-readable description of the mutation
44
46
  # @param location [Hash] { file:, start_line:, end_line:, start_col:, end_col: }
45
- def initialize(subject:, operator:, nodes:, description:, location:)
47
+ # rubocop:disable Metrics/ParameterLists
48
+ def initialize(subject:, operator:, nodes:, description:, location:,
49
+ precomputed_stable_id: nil, precomputed_activation_source: nil)
46
50
  @id = SecureRandom.uuid
47
51
  @subject = subject
48
52
  @operator = operator
@@ -50,12 +54,19 @@ module Henitai
50
54
  @mutated_node = nodes.fetch(:mutated)
51
55
  @description = description
52
56
  @location = location
57
+ @precomputed_stable_id = precomputed_stable_id
58
+ @precomputed_activation_source = precomputed_activation_source
53
59
  @status = :pending
54
60
  @killing_test = nil
55
61
  @duration = nil
56
62
  @covered_by = nil
57
63
  @tests_completed = nil
58
64
  end
65
+ # rubocop:enable Metrics/ParameterLists
66
+
67
+ def stable_id
68
+ @stable_id ||= @precomputed_stable_id || MutantIdentity.stable_id(self)
69
+ end
59
70
 
60
71
  def killed? = @status == :killed
61
72
  def survived? = @status == :survived
@@ -68,15 +68,34 @@ module Henitai
68
68
 
69
69
  private
70
70
 
71
- def walk(node)
71
+ def walk(node, parent: nil)
72
72
  return unless node.is_a?(Parser::AST::Node)
73
73
 
74
+ # Str children of a non-heredoc dstr are raw text segments embedded
75
+ # inside a quoted interpolated string. They have no surrounding quotes
76
+ # in the source, so replacing them via source-fragment substitution
77
+ # would insert a quoted literal into the raw-text position and produce
78
+ # a SyntaxError when the mutant is activated.
79
+ # Heredoc dstr children are exempt: the heredoc body is plain text, so
80
+ # inserting "" there stays valid Ruby.
81
+ return if embedded_non_heredoc_dstr_str?(node, parent)
82
+
74
83
  apply_operators(node) if node_within_subject_range?(node)
75
84
  node.children.each do |child|
76
- walk(child)
85
+ walk(child, parent: node)
77
86
  end
78
87
  end
79
88
 
89
+ def embedded_non_heredoc_dstr_str?(node, parent)
90
+ node.type == :str &&
91
+ parent&.type == :dstr &&
92
+ !heredoc_node?(parent)
93
+ end
94
+
95
+ def heredoc_node?(node)
96
+ node.location.respond_to?(:heredoc_body) && node.location.heredoc_body
97
+ end
98
+
80
99
  def apply_operators(node)
81
100
  return if @arid_node_filter.suppressed?(node, @config)
82
101