henitai 0.3.1 → 0.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +104 -1
  3. data/README.md +11 -1
  4. data/assets/schema/henitai.schema.json +1 -1
  5. data/lib/henitai/cli/operator_command.rb +2 -1
  6. data/lib/henitai/cli/run_options.rb +1 -1
  7. data/lib/henitai/cli.rb +1 -1
  8. data/lib/henitai/configuration.rb +9 -2
  9. data/lib/henitai/configuration_validator.rb +1 -1
  10. data/lib/henitai/dirty_source_detector.rb +53 -0
  11. data/lib/henitai/equivalence_detector/operand_predicates.rb +49 -0
  12. data/lib/henitai/equivalence_detector.rb +6 -23
  13. data/lib/henitai/excluded_test_filter.rb +47 -0
  14. data/lib/henitai/execution_engine.rb +5 -11
  15. data/lib/henitai/inherited_fd_registry.rb +66 -0
  16. data/lib/henitai/integration/base.rb +7 -2
  17. data/lib/henitai/integration/child_bootstrap.rb +27 -0
  18. data/lib/henitai/integration/child_debug_log.rb +135 -0
  19. data/lib/henitai/integration/child_runtime_control.rb +6 -18
  20. data/lib/henitai/integration/loaded_features.rb +38 -0
  21. data/lib/henitai/integration/mutant_run_support.rb +5 -5
  22. data/lib/henitai/integration/rspec_child_runner.rb +16 -15
  23. data/lib/henitai/integration/rspec_process_runner.rb +7 -2
  24. data/lib/henitai/integration.rb +10 -7
  25. data/lib/henitai/mutation_skip_directives.rb +7 -1
  26. data/lib/henitai/operator.rb +12 -2
  27. data/lib/henitai/operators/hash_key_type.rb +50 -0
  28. data/lib/henitai/operators/hash_literal.rb +19 -20
  29. data/lib/henitai/operators/return_value.rb +1 -1
  30. data/lib/henitai/operators.rb +1 -0
  31. data/lib/henitai/orphan_watchdog.rb +93 -0
  32. data/lib/henitai/process_liveness.rb +41 -0
  33. data/lib/henitai/reports_directory_lock.rb +12 -11
  34. data/lib/henitai/result.rb +30 -3
  35. data/lib/henitai/runner.rb +41 -123
  36. data/lib/henitai/runner_dependencies.rb +75 -0
  37. data/lib/henitai/slot_scheduler/drain_verdict.rb +29 -0
  38. data/lib/henitai/slot_scheduler/draining.rb +7 -17
  39. data/lib/henitai/slot_scheduler/retry_policy.rb +21 -0
  40. data/lib/henitai/slot_scheduler/slot_deadline.rb +37 -0
  41. data/lib/henitai/slot_scheduler/slot_table.rb +75 -0
  42. data/lib/henitai/slot_scheduler/test_file_selection.rb +40 -0
  43. data/lib/henitai/slot_scheduler.rb +68 -80
  44. data/lib/henitai/source_file_selection.rb +76 -0
  45. data/lib/henitai/subject_selection.rb +33 -0
  46. data/lib/henitai/survivor_rerun_strategy.rb +7 -19
  47. data/lib/henitai/version.rb +1 -1
  48. data/lib/henitai.rb +8 -0
  49. data/sig/henitai.rbs +94 -38
  50. metadata +32 -9
  51. data/lib/henitai/integration/child_debug_support.rb +0 -119
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "loaded_features"
4
+
5
+ module Henitai
6
+ module Integration
7
+ # Diagnostics writer for the mutant child process, gated on
8
+ # `HENITAI_DEBUG_CHILD=1`.
9
+ #
10
+ # Every method gates on {#enabled?} itself rather than trusting call sites,
11
+ # so no unguarded caller can leak debug lines into every child log by
12
+ # default.
13
+ class ChildDebugLog
14
+ PREFIX = "[henitai-debug-child]"
15
+
16
+ def initialize(io: nil, loaded_features: LoadedFeatures.new)
17
+ @io = io
18
+ @loaded_features = loaded_features
19
+ end
20
+
21
+ def enabled? = ENV["HENITAI_DEBUG_CHILD"] == "1"
22
+
23
+ def write(message)
24
+ return unless enabled?
25
+
26
+ io.puts(message)
27
+ io.flush
28
+ end
29
+
30
+ def rspec_trace(test_files:, rspec_options:, rspec_argv:)
31
+ return unless enabled?
32
+
33
+ write(
34
+ "#{PREFIX} cwd=#{Dir.pwd}\n" \
35
+ "#{PREFIX} files_exist=#{test_files.map { |file| [file, File.exist?(file)] }.inspect}\n" \
36
+ "#{PREFIX} loaded_features_check=#{@loaded_features.map(test_files).inspect}\n" \
37
+ "#{PREFIX} test_files=#{test_files.inspect}\n" \
38
+ "#{PREFIX} rspec_options=#{rspec_options.inspect}\n" \
39
+ "#{PREFIX} rspec_argv=#{rspec_argv.inspect}"
40
+ )
41
+ end
42
+
43
+ def rspec_exit(status)
44
+ write("#{PREFIX} RSpec result=#{status.inspect}")
45
+ end
46
+
47
+ def example_count(stage)
48
+ return unless enabled?
49
+
50
+ write("#{PREFIX} rspec_world_example_count_#{stage}=#{rspec_world_example_count.inspect}")
51
+ end
52
+
53
+ def activation_start(mutant_id)
54
+ write("#{PREFIX} activate_start mutant=#{mutant_id}")
55
+ end
56
+
57
+ def activation_end(activation_result, test_files:)
58
+ write(
59
+ "#{PREFIX} activate_end result=#{activation_result.inspect}\n" \
60
+ "#{PREFIX} run_tests_start test_files=#{test_files.inspect}"
61
+ )
62
+ end
63
+
64
+ def mutant_meta(mutant)
65
+ return unless enabled?
66
+
67
+ write(
68
+ "#{PREFIX} mutant_meta stableId=#{value_of(mutant, :stable_id)}\n" \
69
+ "#{PREFIX} mutant_meta operator=#{value_of(mutant, :operator)}\n" \
70
+ "#{PREFIX} mutant_meta subject=#{subject_expression_of(mutant)}\n" \
71
+ "#{PREFIX} mutant_meta location=#{location_of(mutant)}\n"
72
+ )
73
+ end
74
+
75
+ # Reports where Runner#resolve_subjects was loaded from, which is how a
76
+ # child that activated a mutant on Henitai's own source is identified.
77
+ def activation_check
78
+ return unless enabled?
79
+
80
+ location = begin
81
+ Henitai::Runner.instance_method(:resolve_subjects).source_location&.join(":") # henitai:disable
82
+ rescue StandardError
83
+ nil
84
+ end
85
+
86
+ write("#{PREFIX} activation_check resolve_subjects_location=#{location}\n")
87
+ end
88
+
89
+ def timeout_signal_sent(pid)
90
+ write("#{PREFIX} timeout_signal_sent pid=#{pid}")
91
+ end
92
+
93
+ def thread_dump(reason)
94
+ return unless enabled?
95
+
96
+ write("#{PREFIX} thread_dump reason=#{reason}")
97
+ Thread.list.each_with_index { |thread, index| dump_thread(thread, index) }
98
+ end
99
+
100
+ def rspec_world_example_count
101
+ ::RSpec.world.example_count
102
+ rescue StandardError
103
+ nil
104
+ end
105
+
106
+ private
107
+
108
+ def dump_thread(thread, index)
109
+ write(
110
+ "#{PREFIX} thread index=#{index} id=#{thread.object_id} " \
111
+ "status=#{thread.status.inspect}"
112
+ )
113
+ Array(thread.backtrace).each { |line| write("#{PREFIX} #{line}") }
114
+ end
115
+
116
+ def value_of(mutant, name) = mutant.respond_to?(name) ? mutant.public_send(name) : nil
117
+
118
+ # Deliberately `.inspect` inside the ternary, not on the result: a mutant
119
+ # without #location must render as empty, not as the string "nil".
120
+ def location_of(mutant) = mutant.respond_to?(:location) ? mutant.location.inspect : nil
121
+
122
+ def subject_expression_of(mutant)
123
+ return nil unless mutant.respond_to?(:subject) && mutant.subject.respond_to?(:expression)
124
+
125
+ mutant.subject.expression
126
+ end
127
+
128
+ # Resolved per call, never memoized: ScenarioLogSupport#capture_child_output
129
+ # reassigns $stdout inside the child *after* this object is built, so a
130
+ # captured-at-construction stream would send every debug line to the
131
+ # parent's terminal instead of the child's log file.
132
+ def io = @io || $stdout
133
+ end
134
+ end
135
+ end
@@ -17,10 +17,13 @@ module Henitai
17
17
  CoverageRuntimeSuppressors.suppress_coverage!
18
18
  end
19
19
 
20
+ # Signalling, not logging: the thread dump itself is emitted by the
21
+ # child's USR1 handler, so this only asks for it and gives the child a
22
+ # moment to write before the caller tears the process group down.
20
23
  def debug_child_timeout_dump(pid)
21
- return unless debug_child?
24
+ return unless child_debug_log.enabled?
22
25
 
23
- debug_child_puts("[henitai-debug-child] timeout_signal_sent pid=#{pid}")
26
+ child_debug_log.timeout_signal_sent(pid)
24
27
  Process.kill(:USR1, pid)
25
28
  pause(0.2)
26
29
  rescue Errno::ESRCH
@@ -28,22 +31,7 @@ module Henitai
28
31
  end
29
32
 
30
33
  def install_debug_timeout_trap
31
- Signal.trap("USR1") { debug_child_thread_dump("timeout") }
32
- end
33
-
34
- def debug_child_thread_dump(reason)
35
- return unless debug_child?
36
-
37
- debug_child_puts("[henitai-debug-child] thread_dump reason=#{reason}")
38
- Thread.list.each_with_index do |thread, index|
39
- debug_child_puts(
40
- "[henitai-debug-child] thread index=#{index} id=#{thread.object_id} " \
41
- "status=#{thread.status.inspect}"
42
- )
43
- Array(thread.backtrace).each do |line|
44
- debug_child_puts("[henitai-debug-child] #{line}")
45
- end
46
- end
34
+ Signal.trap("USR1") { child_debug_log.thread_dump("timeout") }
47
35
  end
48
36
  end
49
37
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ module Integration
5
+ # Answers whether a test file has already been required, by matching it
6
+ # against `$LOADED_FEATURES`.
7
+ #
8
+ # Both sides need normalizing: `$LOADED_FEATURES` holds absolute paths for
9
+ # required files but callers hand over repository-relative test paths, and
10
+ # either side may or may not carry the `.rb` suffix. A feature string that
11
+ # cannot be expanded (invalid encoding, for instance) falls back to its raw
12
+ # form rather than aborting the whole check — this runs inside a mutant
13
+ # child whose only job is diagnostics.
14
+ class LoadedFeatures
15
+ def include?(file)
16
+ candidates = candidates_for(file)
17
+ $LOADED_FEATURES.any? do |feature|
18
+ candidates.include?(feature) || candidates.include?(normalize(feature))
19
+ end
20
+ end
21
+
22
+ def map(files) = files.map { |file| [file, include?(file)] }
23
+
24
+ private
25
+
26
+ def candidates_for(file)
27
+ expanded = File.expand_path(file)
28
+ [expanded, "#{expanded}.rb", file, "#{file}.rb"].uniq
29
+ end
30
+
31
+ def normalize(feature)
32
+ File.expand_path(feature)
33
+ rescue StandardError
34
+ feature
35
+ end
36
+ end
37
+ end
38
+ end
@@ -47,7 +47,7 @@ module Henitai
47
47
  with_subprocess_env do
48
48
  suppress_simplecov!
49
49
  suppress_coverage!
50
- install_debug_timeout_trap if debug_child?
50
+ install_debug_timeout_trap if child_debug_log.enabled?
51
51
  with_non_interactive_stdin do
52
52
  run_child_activation_and_tests(mutant:, test_files:, log_paths:)
53
53
  end
@@ -63,11 +63,11 @@ module Henitai
63
63
  def run_child_activation_and_tests(mutant:, test_files:, log_paths:)
64
64
  scenario_log_support.with_coverage_dir(mutant.id) do
65
65
  scenario_log_support.capture_child_output(log_paths) do
66
- debug_child_mutant_meta(mutant) if debug_child?
67
- debug_child_activation_start(mutant.id)
66
+ child_debug_log.mutant_meta(mutant)
67
+ child_debug_log.activation_start(mutant.id)
68
68
  activation_result = Mutant::Activator.activate!(mutant)
69
- debug_child_activation_check if debug_child?
70
- debug_child_activation_end(activation_result, test_files:)
69
+ child_debug_log.activation_check
70
+ child_debug_log.activation_end(activation_result, test_files:)
71
71
  activation_result == :compile_error ? 2 : run_tests(test_files)
72
72
  end
73
73
  end
@@ -9,19 +9,20 @@ module Henitai
9
9
  private
10
10
 
11
11
  def run_rspec_runner(test_files)
12
- debug_child_puts("[henitai-debug-child] build_rspec_runner_start")
12
+ log = child_debug_log
13
+ log.write("[henitai-debug-child] build_rspec_runner_start")
13
14
  runner = build_rspec_runner
14
- debug_child_puts("[henitai-debug-child] build_rspec_runner_return")
15
- debug_child_puts("[henitai-debug-child] configure_rspec_runner_start")
15
+ log.write("[henitai-debug-child] build_rspec_runner_return")
16
+ log.write("[henitai-debug-child] configure_rspec_runner_start")
16
17
  configure_rspec_runner(runner)
17
- debug_child_puts("[henitai-debug-child] configure_rspec_runner_return")
18
+ log.write("[henitai-debug-child] configure_rspec_runner_return")
18
19
  load_rspec_spec_files(test_files)
19
20
  run_rspec_specs(runner)
20
21
  rescue SystemExit => e
21
- debug_child_puts("[henitai-debug-child] runner_run_system_exit status=#{e.status.inspect}")
22
+ log.write("[henitai-debug-child] runner_run_system_exit status=#{e.status.inspect}")
22
23
  raise
23
24
  ensure
24
- debug_child_puts("[henitai-debug-child] runner_run_ensure")
25
+ child_debug_log.write("[henitai-debug-child] runner_run_ensure")
25
26
  end
26
27
 
27
28
  def build_rspec_runner
@@ -32,28 +33,28 @@ module Henitai
32
33
  end
33
34
 
34
35
  def configure_rspec_runner(runner)
35
- debug_child_puts("[henitai-debug-child] trap_interrupt_start")
36
+ child_debug_log.write("[henitai-debug-child] trap_interrupt_start")
36
37
  ::RSpec::Core::Runner.__send__(:trap_interrupt)
37
- debug_child_puts("[henitai-debug-child] trap_interrupt_return")
38
- debug_child_puts("[henitai-debug-child] runner_configure_start")
38
+ child_debug_log.write("[henitai-debug-child] trap_interrupt_return")
39
+ child_debug_log.write("[henitai-debug-child] runner_configure_start")
39
40
  runner.send(:configure, $stderr, $stdout)
40
- debug_child_puts("[henitai-debug-child] runner_configure_return")
41
+ child_debug_log.write("[henitai-debug-child] runner_configure_return")
41
42
  end
42
43
 
43
44
  def load_rspec_spec_files(test_files)
44
- debug_child_puts("[henitai-debug-child] load_spec_files_start")
45
+ child_debug_log.write("[henitai-debug-child] load_spec_files_start")
45
46
  ::RSpec.configuration.files_to_run = test_files.map do |file|
46
47
  File.expand_path(file)
47
48
  end
48
49
  ::RSpec.configuration.load_spec_files
49
- debug_child_example_count("after_load")
50
- debug_child_puts("[henitai-debug-child] load_spec_files_return")
50
+ child_debug_log.example_count("after_load")
51
+ child_debug_log.write("[henitai-debug-child] load_spec_files_return")
51
52
  end
52
53
 
53
54
  def run_rspec_specs(runner)
54
- debug_child_puts("[henitai-debug-child] run_specs_start")
55
+ child_debug_log.write("[henitai-debug-child] run_specs_start")
55
56
  result = runner.send(:run_specs, ::RSpec.world.ordered_example_groups)
56
- debug_child_puts("[henitai-debug-child] run_specs_return result=#{result.inspect}")
57
+ child_debug_log.write("[henitai-debug-child] run_specs_return result=#{result.inspect}")
57
58
  result
58
59
  end
59
60
  end
@@ -32,7 +32,7 @@ module Henitai
32
32
  ended_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
33
33
  @mutex.synchronize do
34
34
  @live_count -= 1
35
- entry = @intervals.rfind { |i| i[:pid] == pid && i[:ended_at].nil? }
35
+ entry = @intervals.reverse_each.find { |i| i[:pid] == pid && i[:ended_at].nil? }
36
36
  entry[:ended_at] = ended_at if entry
37
37
  end
38
38
  end
@@ -84,8 +84,13 @@ module Henitai
84
84
  # Forks a child, sets process group, activates the mutant, runs tests.
85
85
  # Returns a ChildHandle with the forked pid and log_paths.
86
86
  def spawn_mutant(integration, mutant:, test_files:, log_paths:)
87
+ # Captured here, in the parent. Reading Process.ppid inside the child
88
+ # would race the very death the watchdog looks for: a parent dying
89
+ # between fork and that read leaves the child with ppid 1 as its
90
+ # baseline, so it would never consider itself orphaned.
91
+ parent_pid = Process.pid
87
92
  pid = Process.fork do
88
- Process.setpgid(0, 0)
93
+ ChildBootstrap.after_fork!(parent_pid:)
89
94
  ENV["HENITAI_MUTANT_ID"] = mutant.id
90
95
  Process.exit(
91
96
  integration.run_in_child(
@@ -3,10 +3,12 @@
3
3
  require "fileutils"
4
4
  require "stringio"
5
5
  require_relative "process_wakeup"
6
+ require_relative "integration/child_bootstrap"
6
7
  require_relative "integration/rspec_process_runner"
7
8
  require_relative "integration/scenario_log_support"
8
9
  require_relative "integration/coverage_suppression"
9
- require_relative "integration/child_debug_support"
10
+ require_relative "integration/child_debug_log"
11
+ require_relative "integration/loaded_features"
10
12
  require_relative "integration/base"
11
13
  require_relative "integration/mutant_run_support"
12
14
  require_relative "integration/rspec_child_runner"
@@ -111,13 +113,14 @@ module Henitai
111
113
  def run_tests(test_files)
112
114
  require "rspec/core"
113
115
  ::RSpec.__send__(:configuration).fail_if_no_examples = true
114
- debug_child_rspec_trace(test_files:, rspec_options: [], rspec_argv: test_files)
115
- debug_child_example_count("before_run") # steep:ignore Ruby::NoMethod
116
- debug_child_puts("[henitai-debug-child] runner_run_start")
116
+ log = child_debug_log
117
+ log.rspec_trace(test_files:, rspec_options: [], rspec_argv: test_files)
118
+ log.example_count("before_run")
119
+ log.write("[henitai-debug-child] runner_run_start")
117
120
  status = run_rspec_runner(test_files)
118
- debug_child_puts("[henitai-debug-child] runner_run_return status=#{status.inspect}")
119
- debug_child_example_count("after_run") # steep:ignore Ruby::NoMethod
120
- debug_child_rspec_exit(status)
121
+ log.write("[henitai-debug-child] runner_run_return status=#{status.inspect}")
122
+ log.example_count("after_run")
123
+ log.rspec_exit(status)
121
124
  return status if status.is_a?(Integer)
122
125
 
123
126
  status == true ? 0 : 1
@@ -25,7 +25,13 @@ module Henitai
25
25
  # Matching mutants are reported as ignored by {StaticFilter}, not dropped.
26
26
  class MutationSkipDirectives
27
27
  DIRECTIVE = /\A#\s*henitai:disable(?<kind>-start|-end)?(?<rest>[:\s].*)?\z/
28
- VALID_OPERATOR_NAMES = Operator::FULL_SET
28
+
29
+ # The whitelist is the operator *registry*, not the configured set: the
30
+ # widest set names every operator henitai knows. Narrowing it to the
31
+ # configured set would reject a directive for a registered operator the
32
+ # current run happens not to enable — hard-set names above all, which is
33
+ # precisely where the escape hatch is needed (ADR-12).
34
+ VALID_OPERATOR_NAMES = Operator::HARD_SET
29
35
 
30
36
  # A parsed directive: +operators+ is nil (all) or a Set of canonical
31
37
  # operator names; +reason+ is optional free text shown in reports.
@@ -44,13 +44,23 @@ module Henitai
44
44
  AssignmentExpression
45
45
  UnaryOperator
46
46
  UpdateOperator
47
+ ]).freeze
48
+
49
+ # Usually-unkillable operators on top of full: mutations whose survival
50
+ # rarely indicates a test gap (framework key normalization, the
51
+ # ==/eql?/equal? pairing). Opt in when hunting the last survivors
52
+ # (ADR-12).
53
+ HARD_SET = (FULL_SET + %w[
47
54
  EqualityIdentityOperator
55
+ HashKeyType
48
56
  ]).freeze
49
57
 
50
- # @param set [Symbol] :light or :full
58
+ SETS = { light: LIGHT_SET, full: FULL_SET, hard: HARD_SET }.freeze
59
+
60
+ # @param set [Symbol] :light, :full or :hard
51
61
  # @return [Array<Operator>] operator instances for the given set
52
62
  def self.for_set(set)
53
- names = set.to_sym == :full ? FULL_SET : LIGHT_SET
63
+ names = SETS.fetch(set.to_sym, LIGHT_SET)
54
64
  names.map { |name| Henitai::Operators.const_get(name).new }
55
65
  end
56
66
 
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ module Operators
5
+ # Mutates symbol hash keys into string keys, one pair at a time
6
+ # (`{ a: 1 }` -> `{ "a" => 1 }`). Symbol/string key confusion is a real
7
+ # defect class, but frameworks that normalize keys (e.g. ActiveRecord's
8
+ # `order`/`where`) make these mutants frequently unkillable — hence the
9
+ # hard set, not full (ADR-12).
10
+ class HashKeyType < Henitai::Operator
11
+ NODE_TYPES = [:hash].freeze
12
+
13
+ def self.node_types
14
+ NODE_TYPES
15
+ end
16
+
17
+ def mutate(node, subject:)
18
+ node.children.each_with_index.filter_map do |pair, index|
19
+ next unless symbol_key_pair?(pair)
20
+
21
+ build_mutant(
22
+ subject:,
23
+ original_node: node,
24
+ mutated_node: mutated_hash(node, index),
25
+ description: "replaced symbol key with string key"
26
+ )
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def symbol_key_pair?(node)
33
+ node.type == :pair && node.children.first&.type == :sym
34
+ end
35
+
36
+ def mutated_hash(node, index)
37
+ pairs = node.children.each_with_index.map do |pair, pair_index|
38
+ pair_index == index ? stringified_pair(pair) : pair
39
+ end
40
+ Parser::AST::Node.new(:hash, pairs)
41
+ end
42
+
43
+ def stringified_pair(pair)
44
+ key, value = pair.children
45
+ string_key = Parser::AST::Node.new(:str, [key.children.first.to_s])
46
+ Parser::AST::Node.new(:pair, [string_key, value])
47
+ end
48
+ end
49
+ end
50
+ end
@@ -4,7 +4,10 @@ require_relative "../parser_current"
4
4
 
5
5
  module Henitai
6
6
  module Operators
7
- # Reduces hash literals by emptying them or mutating symbol keys.
7
+ # Reduces hash literals: empties the whole hash and removes one pair at a
8
+ # time. Symbol-key -> string-key mutation lives in {HashKeyType} (hard
9
+ # set) because framework key normalization makes it frequently unkillable
10
+ # (ADR-12).
8
11
  class HashLiteral < Henitai::Operator
9
12
  NODE_TYPES = [:hash].freeze
10
13
 
@@ -16,7 +19,7 @@ module Henitai
16
19
  return [] if node.children.empty?
17
20
 
18
21
  mutants = [empty_hash_mutant(node, subject:)]
19
- mutants.concat(symbol_key_mutants(node, subject:))
22
+ mutants.concat(pair_removal_mutants(node, subject:))
20
23
  mutants
21
24
  end
22
25
 
@@ -31,35 +34,31 @@ module Henitai
31
34
  )
32
35
  end
33
36
 
34
- def symbol_key_mutants(node, subject:)
37
+ # Removing the only entry would duplicate the empty-hash mutant.
38
+ def pair_removal_mutants(node, subject:)
39
+ return [] if node.children.size < 2
40
+
35
41
  node.children.each_with_index.filter_map do |pair, index|
36
- next unless symbol_key_pair?(pair)
42
+ next unless pair.type == :pair
37
43
 
38
44
  build_mutant(
39
45
  subject:,
40
46
  original_node: node,
41
- mutated_node: mutated_hash(node, index),
42
- description: "replaced symbol key with string key"
47
+ mutated_node: hash_without_pair(node, index),
48
+ description: "removed hash pair #{pair_key_label(pair)}"
43
49
  )
44
50
  end
45
51
  end
46
52
 
47
- def symbol_key_pair?(node)
48
- node.type == :pair && node.children.first&.type == :sym
49
- end
50
-
51
- def mutated_hash(node, pair_index)
52
- mutated_pairs = node.children.each_with_index.map do |pair, index|
53
- index == pair_index ? mutated_pair(pair) : pair
54
- end
55
-
56
- Parser::AST::Node.new(:hash, mutated_pairs)
53
+ def hash_without_pair(node, pair_index)
54
+ remaining = node.children.reject.with_index { |_pair, index| index == pair_index }
55
+ Parser::AST::Node.new(:hash, remaining)
57
56
  end
58
57
 
59
- def mutated_pair(pair)
60
- key, value = pair.children
61
- mutated_key = Parser::AST::Node.new(:str, [key.children.first.to_s])
62
- Parser::AST::Node.new(:pair, [mutated_key, value])
58
+ # Pair keys are always AST nodes (sym/str/…); their first child is the
59
+ # literal value used purely as a human-readable label.
60
+ def pair_key_label(pair)
61
+ pair.children.first.children.first
63
62
  end
64
63
  end
65
64
  end
@@ -65,7 +65,7 @@ module Henitai
65
65
  body = method_node.children.last
66
66
  return body unless body&.type == :begin
67
67
 
68
- body.children.rfind { |child| child.is_a?(Parser::AST::Node) }
68
+ body.children.reverse_each.find { |child| child.is_a?(Parser::AST::Node) }
69
69
  end
70
70
 
71
71
  # rubocop:disable Lint/BooleanSymbol
@@ -16,6 +16,7 @@ module Henitai
16
16
  autoload :ReturnValue, "henitai/operators/return_value"
17
17
  autoload :ArrayDeclaration, "henitai/operators/array_declaration"
18
18
  autoload :HashLiteral, "henitai/operators/hash_literal"
19
+ autoload :HashKeyType, "henitai/operators/hash_key_type"
19
20
  autoload :RangeLiteral, "henitai/operators/range_literal"
20
21
  autoload :SafeNavigation, "henitai/operators/safe_navigation"
21
22
  autoload :PatternMatch, "henitai/operators/pattern_match"
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Makes a forked mutant child exit when its parent dies.
5
+ #
6
+ # Children `setpgid(0, 0)` into their own process group, and all parent-side
7
+ # cleanup (timeout kills, graceful drain, signal traps) only runs while the
8
+ # parent's event loop is alive. If the parent is SIGKILLed, OOM-killed, or
9
+ # crashes, its children receive no signal at all: they reparent to init and
10
+ # keep running, each holding a full Ruby and test-framework image. Runs have
11
+ # been observed leaving a dozen such orphans behind, several gigabytes in
12
+ # total.
13
+ #
14
+ # A child cannot be told to die by a parent that is already gone, so it has
15
+ # to notice by itself. This is a poll: portable, and cheap enough at a
16
+ # multi-second interval that it costs nothing next to running a test suite.
17
+ # PDEATHSIG (Linux) and kqueue NOTE_EXIT (macOS) would be prompter but are
18
+ # platform-specific; they can be layered on later behind the same interface.
19
+ class OrphanWatchdog
20
+ DEFAULT_INTERVAL = 1.5
21
+ ENV_ENABLED = "HENITAI_CHILD_WATCHDOG"
22
+ ENV_INTERVAL = "HENITAI_CHILD_WATCHDOG_INTERVAL"
23
+
24
+ # Exit code used when the watchdog fires. 2 classifies as :compile_error
25
+ # (see ScenarioExecutionResult.status_for), which surfaces in reports and
26
+ # logs. Codes at 3 and above classify as :killed, which would let a
27
+ # false positive silently inflate the mutation score -- so a visibly wrong
28
+ # verdict is preferred to an invisibly wrong one. In the true-positive case
29
+ # the parent is dead and nothing classifies this child at all.
30
+ ORPHAN_EXIT_CODE = 2
31
+
32
+ # Captured at load time, for the same reason as ProcessLiveness::KILL: the
33
+ # mutant child runs the host project's own suite, and a spec there stubbing
34
+ # `Process.ppid` would otherwise make this child believe it had been
35
+ # reparented and exit itself.
36
+ PPID = Process.method(:ppid)
37
+
38
+ # Opt-OUT, deliberately inverted relative to HENITAI_DEBUG_CHILD's opt-in:
39
+ # a watchdog that defaulted to off would never protect the runs it exists
40
+ # for. Set HENITAI_CHILD_WATCHDOG=0 to disable.
41
+ def self.enabled?(env = ENV)
42
+ env[ENV_ENABLED] != "0"
43
+ end
44
+
45
+ def self.poll_interval(env = ENV)
46
+ seconds = Float(env[ENV_INTERVAL], exception: false)
47
+ seconds&.positive? ? seconds : DEFAULT_INTERVAL
48
+ end
49
+
50
+ # Starts the watchdog in a background thread. Call in the child, right
51
+ # after fork, with the pid captured in the parent beforehand.
52
+ #
53
+ # @return [Thread, nil] nil when disabled
54
+ def self.start(parent_pid:, env: ENV)
55
+ return nil unless enabled?(env)
56
+
57
+ watchdog = new(parent_pid:, interval: poll_interval(env))
58
+ Thread.new { watchdog.run }
59
+ end
60
+
61
+ # Every collaborator is injectable so the decision logic can be specced
62
+ # without forking a process or waiting on a real clock.
63
+ # rubocop:disable Metrics/ParameterLists
64
+ def initialize(parent_pid:, interval: DEFAULT_INTERVAL, liveness: ProcessLiveness,
65
+ ppid: PPID, on_orphan: nil, sleeper: nil)
66
+ @parent_pid = parent_pid
67
+ @interval = interval
68
+ @liveness = liveness
69
+ @ppid = ppid
70
+ @on_orphan = on_orphan || -> { Kernel.exit!(ORPHAN_EXIT_CODE) }
71
+ @sleeper = sleeper || ->(seconds) { Kernel.sleep(seconds) }
72
+ end
73
+ # rubocop:enable Metrics/ParameterLists
74
+
75
+ # Two arms, because neither alone is sufficient. A changed ppid is
76
+ # definitive -- we have been reparented, and no pid reuse can fake that --
77
+ # but it stays equal while the parent lingers as a zombie, which the
78
+ # liveness probe catches.
79
+ def orphaned?
80
+ @ppid.call != @parent_pid || !@liveness.alive?(@parent_pid)
81
+ end
82
+
83
+ # Polls until orphaned, then hands over to the orphan handler -- which by
84
+ # default calls exit! and so never returns. Checks before sleeping, so a
85
+ # child forked from an already-dead parent dies immediately rather than
86
+ # after one interval.
87
+ def run
88
+ @sleeper.call(@interval) until orphaned?
89
+
90
+ @on_orphan.call
91
+ end
92
+ end
93
+ end