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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +104 -1
- data/README.md +11 -1
- data/assets/schema/henitai.schema.json +1 -1
- data/lib/henitai/cli/operator_command.rb +2 -1
- data/lib/henitai/cli/run_options.rb +1 -1
- data/lib/henitai/cli.rb +1 -1
- data/lib/henitai/configuration.rb +9 -2
- data/lib/henitai/configuration_validator.rb +1 -1
- data/lib/henitai/dirty_source_detector.rb +53 -0
- data/lib/henitai/equivalence_detector/operand_predicates.rb +49 -0
- data/lib/henitai/equivalence_detector.rb +6 -23
- data/lib/henitai/excluded_test_filter.rb +47 -0
- data/lib/henitai/execution_engine.rb +5 -11
- data/lib/henitai/inherited_fd_registry.rb +66 -0
- data/lib/henitai/integration/base.rb +7 -2
- data/lib/henitai/integration/child_bootstrap.rb +27 -0
- data/lib/henitai/integration/child_debug_log.rb +135 -0
- data/lib/henitai/integration/child_runtime_control.rb +6 -18
- data/lib/henitai/integration/loaded_features.rb +38 -0
- data/lib/henitai/integration/mutant_run_support.rb +5 -5
- data/lib/henitai/integration/rspec_child_runner.rb +16 -15
- data/lib/henitai/integration/rspec_process_runner.rb +7 -2
- data/lib/henitai/integration.rb +10 -7
- data/lib/henitai/mutation_skip_directives.rb +7 -1
- data/lib/henitai/operator.rb +12 -2
- data/lib/henitai/operators/hash_key_type.rb +50 -0
- data/lib/henitai/operators/hash_literal.rb +19 -20
- data/lib/henitai/operators/return_value.rb +1 -1
- data/lib/henitai/operators.rb +1 -0
- data/lib/henitai/orphan_watchdog.rb +93 -0
- data/lib/henitai/process_liveness.rb +41 -0
- data/lib/henitai/reports_directory_lock.rb +12 -11
- data/lib/henitai/result.rb +30 -3
- data/lib/henitai/runner.rb +41 -123
- data/lib/henitai/runner_dependencies.rb +75 -0
- data/lib/henitai/slot_scheduler/drain_verdict.rb +29 -0
- data/lib/henitai/slot_scheduler/draining.rb +7 -17
- data/lib/henitai/slot_scheduler/retry_policy.rb +21 -0
- data/lib/henitai/slot_scheduler/slot_deadline.rb +37 -0
- data/lib/henitai/slot_scheduler/slot_table.rb +75 -0
- data/lib/henitai/slot_scheduler/test_file_selection.rb +40 -0
- data/lib/henitai/slot_scheduler.rb +68 -80
- data/lib/henitai/source_file_selection.rb +76 -0
- data/lib/henitai/subject_selection.rb +33 -0
- data/lib/henitai/survivor_rerun_strategy.rb +7 -19
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +8 -0
- data/sig/henitai.rbs +94 -38
- metadata +32 -9
- 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
|
|
24
|
+
return unless child_debug_log.enabled?
|
|
22
25
|
|
|
23
|
-
|
|
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") {
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
66
|
+
child_debug_log.mutant_meta(mutant)
|
|
67
|
+
child_debug_log.activation_start(mutant.id)
|
|
68
68
|
activation_result = Mutant::Activator.activate!(mutant)
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
12
|
+
log = child_debug_log
|
|
13
|
+
log.write("[henitai-debug-child] build_rspec_runner_start")
|
|
13
14
|
runner = build_rspec_runner
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
+
log.write("[henitai-debug-child] runner_run_system_exit status=#{e.status.inspect}")
|
|
22
23
|
raise
|
|
23
24
|
ensure
|
|
24
|
-
|
|
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
|
-
|
|
36
|
+
child_debug_log.write("[henitai-debug-child] trap_interrupt_start")
|
|
36
37
|
::RSpec::Core::Runner.__send__(:trap_interrupt)
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
55
|
+
child_debug_log.write("[henitai-debug-child] run_specs_start")
|
|
55
56
|
result = runner.send(:run_specs, ::RSpec.world.ordered_example_groups)
|
|
56
|
-
|
|
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.
|
|
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
|
-
|
|
93
|
+
ChildBootstrap.after_fork!(parent_pid:)
|
|
89
94
|
ENV["HENITAI_MUTANT_ID"] = mutant.id
|
|
90
95
|
Process.exit(
|
|
91
96
|
integration.run_in_child(
|
data/lib/henitai/integration.rb
CHANGED
|
@@ -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/
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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.
|
data/lib/henitai/operator.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
42
|
+
next unless pair.type == :pair
|
|
37
43
|
|
|
38
44
|
build_mutant(
|
|
39
45
|
subject:,
|
|
40
46
|
original_node: node,
|
|
41
|
-
mutated_node:
|
|
42
|
-
description: "
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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.
|
|
68
|
+
body.children.reverse_each.find { |child| child.is_a?(Parser::AST::Node) }
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
# rubocop:disable Lint/BooleanSymbol
|
data/lib/henitai/operators.rb
CHANGED
|
@@ -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
|