henitai 0.1.2 → 0.1.3

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -1
  3. data/README.md +3 -1
  4. data/assets/schema/henitai.schema.json +0 -4
  5. data/lib/henitai/arid_node_filter.rb +3 -0
  6. data/lib/henitai/available_cpu_count.rb +79 -0
  7. data/lib/henitai/cli.rb +7 -4
  8. data/lib/henitai/configuration.rb +3 -5
  9. data/lib/henitai/configuration_validator.rb +1 -11
  10. data/lib/henitai/coverage_bootstrapper.rb +112 -7
  11. data/lib/henitai/coverage_report_reader.rb +67 -0
  12. data/lib/henitai/eager_load.rb +11 -0
  13. data/lib/henitai/equivalence_detector.rb +60 -1
  14. data/lib/henitai/execution_engine.rb +34 -22
  15. data/lib/henitai/integration/rspec_process_runner.rb +58 -0
  16. data/lib/henitai/integration.rb +192 -90
  17. data/lib/henitai/mutant.rb +3 -1
  18. data/lib/henitai/mutant_generator.rb +25 -48
  19. data/lib/henitai/operator.rb +6 -1
  20. data/lib/henitai/operators/assignment_expression.rb +7 -23
  21. data/lib/henitai/operators/conditional_expression.rb +1 -7
  22. data/lib/henitai/operators/method_chain_unwrap.rb +41 -0
  23. data/lib/henitai/operators/regex_mutator.rb +89 -0
  24. data/lib/henitai/operators/unary_operator.rb +36 -0
  25. data/lib/henitai/operators/update_operator.rb +70 -0
  26. data/lib/henitai/operators.rb +4 -0
  27. data/lib/henitai/parallel_execution_runner.rb +135 -0
  28. data/lib/henitai/per_test_coverage_selector.rb +60 -0
  29. data/lib/henitai/result.rb +16 -4
  30. data/lib/henitai/runner.rb +75 -11
  31. data/lib/henitai/source_parser.rb +12 -1
  32. data/lib/henitai/static_filter.rb +20 -41
  33. data/lib/henitai/version.rb +1 -1
  34. data/lib/henitai.rb +3 -0
  35. data/sig/henitai.rbs +59 -10
  36. metadata +16 -3
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "etc"
3
+ require_relative "parallel_execution_runner"
4
4
 
5
5
  module Henitai
6
6
  # Runs pending mutants through the selected integration.
@@ -12,7 +12,7 @@ module Henitai
12
12
  pending_mutants = Array(mutants).select(&:pending?)
13
13
  mutex = Mutex.new
14
14
  if parallel_execution?(config, pending_mutants)
15
- run_parallel(pending_mutants, integration, config, progress_reporter, mutex)
15
+ run_parallel(pending_mutants, integration, config, progress_reporter)
16
16
  else
17
17
  run_linear(pending_mutants, integration, config, progress_reporter, mutex)
18
18
  end
@@ -31,7 +31,12 @@ module Henitai
31
31
 
32
32
  def worker_count(config)
33
33
  configured_jobs = config.respond_to?(:jobs) ? config.jobs : nil
34
- configured_jobs || Etc.nprocessors
34
+ return configured_jobs if configured_jobs
35
+
36
+ # The fallback stays conservative for now; the execution policy still
37
+ # defaults to a single worker even though AvailableCpuCount exists as a
38
+ # future policy hook.
39
+ 1
35
40
  end
36
41
 
37
42
  def run_linear(mutants, integration, config, progress_reporter, mutex)
@@ -40,24 +45,29 @@ module Henitai
40
45
  end
41
46
  end
42
47
 
43
- def run_parallel(mutants, integration, config, progress_reporter, mutex)
44
- queue = Queue.new
45
- mutants.each { |mutant| queue << mutant }
48
+ def run_parallel(mutants, integration, config, progress_reporter)
49
+ ParallelExecutionRunner.new(
50
+ worker_count: worker_count(config)
51
+ ).run(
52
+ mutants,
53
+ integration,
54
+ config,
55
+ progress_reporter,
56
+ stdin_pipe: pipe_stdin?,
57
+ process_mutant: method(:process_mutant)
58
+ )
59
+ end
46
60
 
47
- Array.new(worker_count(config)) do
48
- Thread.new do
49
- loop do
50
- mutant = queue.pop(true)
51
- process_mutant(mutant, integration, config, progress_reporter, mutex)
52
- rescue ThreadError
53
- break
54
- end
55
- end
56
- end.each(&:join)
61
+ def pipe_stdin?
62
+ $stdin.stat.pipe?
63
+ rescue Errno::EBADF
64
+ false
57
65
  end
58
66
 
59
67
  def process_mutant(mutant, integration, config, progress_reporter, mutex)
60
68
  test_files = prioritized_tests_for(mutant, integration, config)
69
+ mutant.covered_by = test_files if mutant.respond_to?(:covered_by=)
70
+ mutant.tests_completed = test_files.size if mutant.respond_to?(:tests_completed=)
61
71
  scenario_result = run_with_flaky_retry(mutant, integration, config, test_files, mutex)
62
72
  mutant.status = scenario_status(scenario_result)
63
73
 
@@ -69,16 +79,18 @@ module Henitai
69
79
  end
70
80
 
71
81
  def prioritized_tests_for(mutant, integration, config)
72
- test_prioritizer.sort(
73
- integration.select_tests(mutant.subject),
82
+ tests = integration.select_tests(mutant.subject)
83
+ tests = per_test_coverage_selector.filter(
84
+ tests,
74
85
  mutant,
75
- test_history(config)
86
+ reports_dir: config.reports_dir
76
87
  )
88
+ test_prioritizer.sort(tests, mutant, test_history(config))
77
89
  end
78
90
 
79
- def test_prioritizer
80
- @test_prioritizer ||= TestPrioritizer.new
81
- end
91
+ def test_prioritizer = @test_prioritizer ||= TestPrioritizer.new
92
+
93
+ def per_test_coverage_selector = @per_test_coverage_selector ||= PerTestCoverageSelector.new
82
94
 
83
95
  def test_history(config)
84
96
  return {} unless config.respond_to?(:history)
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ module Integration
5
+ # Runs RSpec child and suite processes on behalf of the integration.
6
+ class RspecProcessRunner
7
+ def run_mutant(integration, mutant:, test_files:, timeout:)
8
+ log_paths = integration.scenario_log_paths(mutant_log_name(mutant))
9
+ pid = fork_mutant_process(integration, mutant, test_files, log_paths)
10
+ wait_result = integration.wait_with_timeout(pid, timeout)
11
+ integration.build_result(wait_result, log_paths)
12
+ ensure
13
+ finalize_mutant_run(integration, pid, wait_result)
14
+ end
15
+
16
+ def run_suite(integration, test_files, timeout:)
17
+ log_paths = integration.scenario_log_paths("baseline")
18
+ wait_result = nil
19
+ FileUtils.mkdir_p(File.dirname(log_paths[:stdout_path]))
20
+ pid = integration.spawn_suite_process(test_files, log_paths)
21
+ wait_result = integration.wait_with_timeout(pid, timeout)
22
+ integration.build_result(wait_result, log_paths)
23
+ ensure
24
+ if pid
25
+ integration.cleanup_process_group(pid) unless wait_result == :timeout
26
+ integration.reap_child(pid) if wait_result.nil?
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def fork_mutant_process(integration, mutant, test_files, log_paths)
33
+ Process.fork do
34
+ Process.setpgid(0, 0)
35
+ ENV["HENITAI_MUTANT_ID"] = mutant.id
36
+ Process.exit(
37
+ integration.run_in_child(
38
+ mutant: mutant,
39
+ test_files: test_files,
40
+ log_paths: log_paths
41
+ )
42
+ )
43
+ end
44
+ end
45
+
46
+ def finalize_mutant_run(integration, pid, wait_result)
47
+ return unless pid
48
+
49
+ integration.cleanup_process_group(pid) unless wait_result == :timeout
50
+ integration.reap_child(pid) if wait_result.nil?
51
+ end
52
+
53
+ def mutant_log_name(mutant)
54
+ "mutant-#{mutant.id}"
55
+ end
56
+ end
57
+ end
58
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "minitest"
5
+ require_relative "integration/rspec_process_runner"
5
6
 
6
7
  module Henitai
7
8
  # Namespace for test-framework integrations.
@@ -148,11 +149,99 @@ module Henitai
148
149
  raise NotImplementedError
149
150
  end
150
151
 
152
+ def per_test_coverage_supported?
153
+ false
154
+ end
155
+
156
+ def wait_with_timeout(pid, timeout)
157
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
158
+
159
+ loop do
160
+ wait_result = Process.wait(pid, Process::WNOHANG)
161
+ return Process.last_status if wait_result
162
+
163
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
164
+ final_wait_result = Process.wait(pid, Process::WNOHANG)
165
+ return Process.last_status if final_wait_result
166
+
167
+ return handle_timeout(pid)
168
+ end
169
+
170
+ pause(0.01)
171
+ end
172
+ end
173
+
174
+ def reap_child(pid)
175
+ Process.wait(pid)
176
+ rescue Errno::ECHILD, Errno::ESRCH
177
+ nil
178
+ end
179
+
180
+ def cleanup_process_group(pid)
181
+ Process.kill(:SIGTERM, -pid)
182
+ pause(2.0)
183
+ Process.kill(:SIGKILL, -pid)
184
+ rescue Errno::EPERM
185
+ cleanup_child_process(pid)
186
+ rescue Errno::ESRCH
187
+ nil
188
+ end
189
+
151
190
  private
152
191
 
153
192
  def pause(seconds)
154
193
  sleep(seconds)
155
194
  end
195
+
196
+ def handle_timeout(pid)
197
+ begin
198
+ cleanup_process_group(pid)
199
+ ensure
200
+ reap_child(pid)
201
+ end
202
+ :timeout
203
+ end
204
+
205
+ def cleanup_child_process(pid)
206
+ Process.kill(:SIGTERM, pid)
207
+ pause(2.0)
208
+ Process.kill(:SIGKILL, pid)
209
+ rescue Errno::EPERM, Errno::ESRCH
210
+ nil
211
+ end
212
+
213
+ def rspec_options
214
+ []
215
+ end
216
+
217
+ def subprocess_env
218
+ { "PARALLEL_WORKERS" => "1" }
219
+ end
220
+
221
+ def scenario_log_support
222
+ @scenario_log_support ||= ScenarioLogSupport.new
223
+ end
224
+
225
+ def with_subprocess_env
226
+ original_env = {} # : Hash[String, String?]
227
+ subprocess_env.each do |key, value|
228
+ original_env[key] = ENV.fetch(key, nil)
229
+ ENV[key] = value
230
+ end
231
+ yield
232
+ ensure
233
+ restore_subprocess_env(original_env)
234
+ end
235
+
236
+ def restore_subprocess_env(original_env)
237
+ original_env.each do |key, value|
238
+ if value.nil?
239
+ ENV.delete(key)
240
+ else
241
+ ENV[key] = value
242
+ end
243
+ end
244
+ end
156
245
  end
157
246
 
158
247
  # RSpec integration adapter.
@@ -185,65 +274,25 @@ module Henitai
185
274
  end
186
275
 
187
276
  def run_mutant(mutant:, test_files:, timeout:)
188
- log_paths = scenario_log_paths("mutant-#{mutant.id}")
189
- pid = Process.fork do
190
- ENV["HENITAI_MUTANT_ID"] = mutant.id
191
- Process.exit(run_in_child(mutant:, test_files:, log_paths:))
192
- end
193
-
194
- build_result(wait_with_timeout(pid, timeout), log_paths)
277
+ RspecProcessRunner.new.run_mutant(self, mutant:, test_files:, timeout:)
195
278
  end
196
279
 
197
- def run_suite(test_files, timeout: DEFAULT_SUITE_TIMEOUT)
198
- log_paths = scenario_log_paths("baseline")
199
- FileUtils.mkdir_p(File.dirname(log_paths[:stdout_path]))
200
- pid = File.open(log_paths[:stdout_path], "w") do |stdout_file|
201
- File.open(log_paths[:stderr_path], "w") do |stderr_file|
202
- Process.spawn(*suite_command(test_files), out: stdout_file, err: stderr_file)
203
- end
204
- end
205
- build_result(wait_with_timeout(pid, timeout), log_paths)
280
+ def per_test_coverage_supported?
281
+ true
206
282
  end
207
283
 
208
- private
209
-
210
- def run_in_child(mutant:, test_files:, log_paths:)
211
- Thread.report_on_exception = false
212
- scenario_log_support.with_coverage_dir(mutant.id) do
213
- scenario_log_support.capture_child_output(log_paths) do
214
- return 2 if Mutant::Activator.activate!(mutant) == :compile_error
215
-
216
- run_tests(test_files)
217
- end
218
- end
284
+ def run_suite(test_files, timeout: DEFAULT_SUITE_TIMEOUT)
285
+ RspecProcessRunner.new.run_suite(self, test_files, timeout:)
219
286
  end
220
287
 
221
288
  def suite_command(test_files)
222
- ["bundle", "exec", "rspec", *test_files]
223
- end
224
-
225
- def wait_with_timeout(pid, timeout)
226
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
227
-
228
- loop do
229
- return Process.last_status if Process.wait(pid, Process::WNOHANG)
230
- return handle_timeout(pid) if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
231
-
232
- pause(0.01)
233
- end
234
- end
235
-
236
- def handle_timeout(pid)
237
- begin
238
- Process.kill(:SIGTERM, pid)
239
- pause(2.0)
240
- Process.kill(:SIGKILL, pid)
241
- rescue Errno::ESRCH
242
- # The child may exit after SIGTERM but before SIGKILL.
243
- ensure
244
- reap_child(pid)
245
- end
246
- :timeout
289
+ [
290
+ "bundle", "exec", "ruby",
291
+ "-r", "henitai/rspec_coverage_formatter",
292
+ "-S", "rspec", *test_files,
293
+ "--format", "progress",
294
+ "--format", "Henitai::CoverageFormatter"
295
+ ]
247
296
  end
248
297
 
249
298
  def run_tests(test_files)
@@ -254,32 +303,6 @@ module Henitai
254
303
  status == true ? 0 : 1
255
304
  end
256
305
 
257
- def rspec_options
258
- ["--require", "henitai/rspec_coverage_formatter"]
259
- end
260
-
261
- def scenario_log_support
262
- @scenario_log_support ||= ScenarioLogSupport.new
263
- end
264
-
265
- def read_log_file(path)
266
- return "" unless File.exist?(path)
267
-
268
- File.read(path)
269
- end
270
-
271
- def write_combined_log(path, stdout, stderr)
272
- FileUtils.mkdir_p(File.dirname(path))
273
- File.write(path, combined_log(stdout, stderr))
274
- end
275
-
276
- def combined_log(stdout, stderr)
277
- [
278
- (stdout.empty? ? nil : "stdout:\n#{stdout}"),
279
- (stderr.empty? ? nil : "stderr:\n#{stderr}")
280
- ].compact.join("\n")
281
- end
282
-
283
306
  def scenario_log_paths(name)
284
307
  reports_dir = ENV.fetch("HENITAI_REPORTS_DIR", "reports")
285
308
  log_dir = File.join(reports_dir, "mutation-logs")
@@ -398,10 +421,70 @@ module Henitai
398
421
  ].uniq
399
422
  end
400
423
 
401
- def reap_child(pid)
402
- Process.wait(pid)
403
- rescue Errno::ECHILD, Errno::ESRCH
404
- nil
424
+ def spawn_suite_process(test_files, log_paths)
425
+ File.open(log_paths[:stdout_path], "w") do |stdout_file|
426
+ File.open(log_paths[:stderr_path], "w") do |stderr_file|
427
+ Process.spawn(
428
+ subprocess_env,
429
+ *suite_command(test_files),
430
+ out: stdout_file,
431
+ err: stderr_file,
432
+ pgroup: true
433
+ )
434
+ end
435
+ end
436
+ end
437
+
438
+ def run_in_child(mutant:, test_files:, log_paths:)
439
+ Thread.report_on_exception = false
440
+ with_subprocess_env do
441
+ scenario_log_support.with_coverage_dir(mutant.id) do
442
+ scenario_log_support.capture_child_output(log_paths) do
443
+ return 2 if Mutant::Activator.activate!(mutant) == :compile_error
444
+
445
+ run_tests(test_files)
446
+ end
447
+ end
448
+ end
449
+ end
450
+
451
+ def read_log_file(path)
452
+ return "" unless File.exist?(path)
453
+
454
+ File.read(path)
455
+ end
456
+
457
+ def write_combined_log(path, stdout, stderr)
458
+ FileUtils.mkdir_p(File.dirname(path))
459
+ File.write(path, combined_log(stdout, stderr))
460
+ end
461
+
462
+ def combined_log(stdout, stderr)
463
+ [
464
+ (stdout.empty? ? nil : "stdout:\n#{stdout}"),
465
+ (stderr.empty? ? nil : "stderr:\n#{stderr}")
466
+ ].compact.join("\n")
467
+ end
468
+ end
469
+
470
+ # Stores the child-process log helpers shared by the integration specs.
471
+ class ScenarioLogSupport
472
+ def read_log_file(path)
473
+ return "" unless File.exist?(path)
474
+
475
+ File.read(path)
476
+ end
477
+
478
+ def write_combined_log(path, stdout, stderr)
479
+ FileUtils.mkdir_p(File.dirname(path))
480
+ File.write(path, combined_log(stdout, stderr))
481
+ end
482
+
483
+ def combined_log(stdout, stderr)
484
+ [
485
+ (stdout.empty? ? nil : "stdout:\n#{stdout}"),
486
+ (stderr.empty? ? nil : "stderr:\n#{stderr}")
487
+ ].compact.join("\n")
405
488
  end
406
489
  end
407
490
 
@@ -424,13 +507,11 @@ module Henitai
424
507
 
425
508
  def run_suite(test_files, timeout: DEFAULT_SUITE_TIMEOUT)
426
509
  log_paths = scenario_log_paths("baseline")
427
- FileUtils.mkdir_p(File.dirname(log_paths[:stdout_path]))
428
- pid = File.open(log_paths[:stdout_path], "w") do |stdout_file|
429
- File.open(log_paths[:stderr_path], "w") do |stderr_file|
430
- Process.spawn(subprocess_env, *suite_command(test_files), out: stdout_file, err: stderr_file)
431
- end
432
- end
433
- build_result(wait_with_timeout(pid, timeout), log_paths)
510
+ pid = spawn_suite_process(test_files, log_paths)
511
+ wait_result = wait_with_timeout(pid, timeout)
512
+ build_result(wait_result, log_paths)
513
+ ensure
514
+ cleanup_suite_process(pid, wait_result)
434
515
  end
435
516
 
436
517
  private
@@ -463,12 +544,33 @@ module Henitai
463
544
  end
464
545
 
465
546
  def subprocess_env
466
- env = {} # : Hash[String, String]
547
+ env = super
467
548
  env["RAILS_ENV"] = "test" unless ENV["RAILS_ENV"] == "test"
468
549
  env["PARALLEL_WORKERS"] = "1"
469
550
  env
470
551
  end
471
552
 
553
+ def spawn_suite_process(test_files, log_paths)
554
+ FileUtils.mkdir_p(File.dirname(log_paths[:stdout_path]))
555
+ File.open(log_paths[:stdout_path], "w") do |stdout_file|
556
+ File.open(log_paths[:stderr_path], "w") do |stderr_file|
557
+ Process.spawn(
558
+ subprocess_env,
559
+ *suite_command(test_files),
560
+ out: stdout_file,
561
+ err: stderr_file
562
+ )
563
+ end
564
+ end
565
+ end
566
+
567
+ def cleanup_suite_process(pid, wait_result)
568
+ return unless pid
569
+
570
+ cleanup_child_process(pid)
571
+ reap_child(pid) if wait_result.nil?
572
+ end
573
+
472
574
  def spec_files
473
575
  (Dir.glob("test/**/*_test.rb") + Dir.glob("test/**/*_spec.rb"))
474
576
  .reject { |f| f.start_with?("test/system/") }
@@ -35,7 +35,7 @@ module Henitai
35
35
 
36
36
  attr_reader :id, :subject, :operator, :original_node, :mutated_node,
37
37
  :mutation_type, :description, :location
38
- attr_accessor :status, :killing_test, :duration
38
+ attr_accessor :status, :killing_test, :duration, :covered_by, :tests_completed
39
39
 
40
40
  # @param subject [Subject] the subject being mutated
41
41
  # @param operator [Symbol] operator name, e.g. :ArithmeticOperator
@@ -53,6 +53,8 @@ module Henitai
53
53
  @status = :pending
54
54
  @killing_test = nil
55
55
  @duration = nil
56
+ @covered_by = nil
57
+ @tests_completed = nil
56
58
  end
57
59
 
58
60
  def killed? = @status == :killed
@@ -34,7 +34,8 @@ module Henitai
34
34
  end
35
35
 
36
36
  def generate_for_subject(subject, operators, config:, arid_node_filter:, syntax_validator:)
37
- return [] unless subject.source_file && subject.source_range
37
+ source_node = source_node_for(subject)
38
+ return [] unless source_node
38
39
 
39
40
  visitor = SubjectVisitor.new(
40
41
  subject,
@@ -43,11 +44,8 @@ module Henitai
43
44
  arid_node_filter:,
44
45
  syntax_validator:
45
46
  )
46
- visitor.process(SourceParser.parse_file(subject.source_file))
47
- prune_mutants_per_line(
48
- visitor.mutants,
49
- max_mutants_per_line: config&.max_mutants_per_line || 1
50
- )
47
+ visitor.process(source_node)
48
+ visitor.mutants
51
49
  end
52
50
 
53
51
  # Depth-first pre-order AST visitor for a single subject.
@@ -60,13 +58,8 @@ module Henitai
60
58
  @mutants = []
61
59
  @arid_node_filter = arid_node_filter
62
60
  @syntax_validator = syntax_validator
63
- @operators_by_node_type = operators.each_with_object(
64
- Hash.new { |hash, key| hash[key] = [] }
65
- ) do |operator, map|
66
- operator.class.node_types.each do |node_type|
67
- map[node_type] << operator
68
- end
69
- end
61
+ initialize_subject_range(subject)
62
+ @operators_by_node_type = index_operators(operators)
70
63
  end
71
64
 
72
65
  def process(node)
@@ -95,27 +88,28 @@ module Henitai
95
88
  end
96
89
 
97
90
  def node_within_subject_range?(node)
91
+ return true unless @subject_range_begin
92
+
98
93
  location = node.location&.expression
99
- return true unless location && @subject.source_range
94
+ return true unless location
100
95
 
101
- node_range = location.line..location.last_line
102
- ranges_overlap?(node_range, @subject.source_range)
96
+ location.line <= @subject_range_end && @subject_range_begin <= location.last_line
103
97
  end
104
98
 
105
- def ranges_overlap?(left, right)
106
- left.begin <= right.end && right.begin <= left.end
107
- end
108
- end
99
+ def initialize_subject_range(subject)
100
+ subject_range = subject.source_range
101
+ return unless subject_range
109
102
 
110
- def prune_mutants_per_line(mutants, max_mutants_per_line:)
111
- grouped = mutants.each_with_object({}) do |mutant, selected|
112
- key = line_key(mutant)
113
- selected[key] ||= []
114
- selected[key] << mutant
103
+ @subject_range_begin = subject_range.begin
104
+ @subject_range_end = subject_range.end
115
105
  end
116
106
 
117
- grouped.values.flat_map do |mutants_for_line|
118
- mutants_for_line.sort_by { |mutant| mutant_priority_key(mutant) }.take(max_mutants_per_line)
107
+ def index_operators(operators)
108
+ operators.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |operator, map|
109
+ operator.class.node_types.each do |node_type|
110
+ map[node_type] << operator
111
+ end
112
+ end
119
113
  end
120
114
  end
121
115
 
@@ -131,28 +125,11 @@ module Henitai
131
125
  )
132
126
  end
133
127
 
134
- def line_key(mutant)
135
- [
136
- mutant.location[:file],
137
- mutant.location[:start_line]
138
- ]
139
- end
140
-
141
- def mutant_priority_key(mutant)
142
- [
143
- operator_priority(mutant.operator),
144
- mutant.location[:start_col] || 0,
145
- mutant.description
146
- ]
147
- end
148
-
149
- def operator_priority(operator_name)
150
- operator_priority_map.fetch(operator_name, operator_priority_map.length)
151
- end
128
+ def source_node_for(subject)
129
+ return subject.ast_node if subject.ast_node
130
+ return nil unless subject.source_file && subject.source_range
152
131
 
153
- def operator_priority_map
154
- # The constant order defines signal priority for per-line pruning.
155
- @operator_priority_map ||= Operator::FULL_SET.each_with_index.to_h
132
+ SourceParser.new.parse_file(subject.source_file)
156
133
  end
157
134
  end
158
135
  end
@@ -13,7 +13,8 @@ module Henitai
13
13
  #
14
14
  # Additional operators (full set):
15
15
  # ArrayDeclaration, HashLiteral, RangeLiteral, SafeNavigation,
16
- # PatternMatch, BlockStatement, MethodExpression, AssignmentExpression
16
+ # PatternMatch, BlockStatement, MethodExpression, AssignmentExpression,
17
+ # UnaryOperator, UpdateOperator, RegexMutator, MethodChainUnwrap
17
18
  #
18
19
  # Each operator subclass must implement:
19
20
  # - .node_types → Array<Symbol> AST node types this operator handles
@@ -32,12 +33,16 @@ module Henitai
32
33
  FULL_SET = (LIGHT_SET + %w[
33
34
  ArrayDeclaration
34
35
  HashLiteral
36
+ MethodChainUnwrap
35
37
  RangeLiteral
38
+ RegexMutator
36
39
  SafeNavigation
37
40
  PatternMatch
38
41
  BlockStatement
39
42
  MethodExpression
40
43
  AssignmentExpression
44
+ UnaryOperator
45
+ UpdateOperator
41
46
  ]).freeze
42
47
 
43
48
  # @param set [Symbol] :light or :full