henitai 0.1.10 → 0.2.1
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 +94 -1
- data/README.md +33 -7
- data/assets/schema/henitai.schema.json +6 -0
- data/lib/henitai/cli/clean_command.rb +48 -0
- data/lib/henitai/cli/command_support.rb +51 -0
- data/lib/henitai/cli/init_command.rb +64 -0
- data/lib/henitai/cli/operator_command.rb +95 -0
- data/lib/henitai/cli/options.rb +120 -0
- data/lib/henitai/cli/run_command.rb +103 -0
- data/lib/henitai/cli.rb +17 -327
- data/lib/henitai/configuration.rb +26 -12
- data/lib/henitai/configuration_validator/rules.rb +143 -0
- data/lib/henitai/configuration_validator/scalars.rb +123 -0
- data/lib/henitai/configuration_validator.rb +12 -239
- data/lib/henitai/coverage_bootstrapper.rb +24 -24
- data/lib/henitai/eager_load.rb +36 -5
- data/lib/henitai/execution_engine.rb +6 -11
- data/lib/henitai/git_diff_analyzer.rb +34 -0
- data/lib/henitai/integration/base.rb +171 -0
- data/lib/henitai/integration/child_debug_support.rb +115 -0
- data/lib/henitai/integration/child_runtime_control.rb +50 -0
- data/lib/henitai/integration/coverage_suppression.rb +43 -0
- data/lib/henitai/integration/minitest.rb +133 -0
- data/lib/henitai/integration/mutant_run_support.rb +77 -0
- data/lib/henitai/integration/rspec_child_runner.rb +61 -0
- data/lib/henitai/integration/rspec_process_runner.rb +66 -13
- data/lib/henitai/integration/rspec_test_selection.rb +135 -0
- data/lib/henitai/integration/scenario_log_support.rb +116 -0
- data/lib/henitai/integration.rb +43 -519
- data/lib/henitai/mutant/activator.rb +13 -79
- data/lib/henitai/mutant/parameter_source.rb +98 -0
- data/lib/henitai/mutant.rb +14 -2
- data/lib/henitai/mutant_generator.rb +21 -2
- data/lib/henitai/mutant_history_store/sql.rb +72 -0
- data/lib/henitai/mutant_history_store.rb +12 -91
- data/lib/henitai/mutant_identity.rb +34 -0
- data/lib/henitai/parallel_execution_runner.rb +29 -11
- data/lib/henitai/per_test_coverage_collector.rb +3 -1
- data/lib/henitai/process_wakeup.rb +49 -0
- data/lib/henitai/process_worker_runner.rb +148 -0
- data/lib/henitai/reporter.rb +96 -11
- data/lib/henitai/result.rb +49 -16
- data/lib/henitai/runner.rb +96 -30
- data/lib/henitai/scenario_execution_result.rb +16 -3
- data/lib/henitai/slot_scheduler/draining.rb +140 -0
- data/lib/henitai/slot_scheduler/process_control.rb +43 -0
- data/lib/henitai/slot_scheduler.rb +214 -0
- data/lib/henitai/static_filter.rb +10 -3
- data/lib/henitai/survivor_activation_cache.rb +81 -0
- data/lib/henitai/survivor_loader.rb +140 -0
- data/lib/henitai/survivor_rerun_strategy.rb +195 -0
- data/lib/henitai/survivor_selector.rb +36 -0
- data/lib/henitai/survivor_test_filter.rb +72 -0
- data/lib/henitai/unparse_helper.rb +5 -2
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +10 -0
- data/sig/configuration_validator.rbs +46 -22
- data/sig/henitai.rbs +329 -53
- metadata +46 -2
|
@@ -2,15 +2,68 @@
|
|
|
2
2
|
|
|
3
3
|
module Henitai
|
|
4
4
|
module Integration
|
|
5
|
+
# Tracks real OS child pids for scheduler observability.
|
|
6
|
+
# Gated on HENITAI_DEBUG_SCHEDULER=1. Thread-safe.
|
|
7
|
+
module SchedulerDiagnostics
|
|
8
|
+
@mutex = Mutex.new
|
|
9
|
+
@intervals = []
|
|
10
|
+
@live_count = 0
|
|
11
|
+
@max_concurrent = 0
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def enabled?
|
|
15
|
+
ENV["HENITAI_DEBUG_SCHEDULER"] == "1"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def child_started(pid)
|
|
19
|
+
return unless enabled?
|
|
20
|
+
|
|
21
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
22
|
+
@mutex.synchronize do
|
|
23
|
+
@live_count += 1
|
|
24
|
+
@max_concurrent = [@max_concurrent, @live_count].max
|
|
25
|
+
@intervals << { pid: pid, started_at: started_at, ended_at: nil }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def child_ended(pid)
|
|
30
|
+
return if pid.nil? || !enabled?
|
|
31
|
+
|
|
32
|
+
ended_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
@live_count -= 1
|
|
35
|
+
entry = @intervals.rfind { |i| i[:pid] == pid && i[:ended_at].nil? }
|
|
36
|
+
entry[:ended_at] = ended_at if entry
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def summary
|
|
41
|
+
@mutex.synchronize { { max_concurrent: @max_concurrent, intervals: @intervals.dup } }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def reset!
|
|
45
|
+
@mutex.synchronize do
|
|
46
|
+
@intervals = []
|
|
47
|
+
@live_count = 0
|
|
48
|
+
@max_concurrent = 0
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Captures the PID and log paths for a spawned mutant child process.
|
|
55
|
+
ChildHandle = Struct.new(:pid, :log_paths)
|
|
56
|
+
|
|
5
57
|
# Runs RSpec child and suite processes on behalf of the integration.
|
|
6
58
|
class RspecProcessRunner
|
|
7
59
|
def run_mutant(integration, mutant:, test_files:, timeout:)
|
|
8
|
-
|
|
9
|
-
pid
|
|
10
|
-
wait_result = integration.wait_with_timeout(pid, timeout)
|
|
11
|
-
integration.build_result(wait_result, log_paths)
|
|
60
|
+
handle = integration.spawn_mutant(mutant:, test_files:)
|
|
61
|
+
SchedulerDiagnostics.child_started(handle.pid)
|
|
62
|
+
wait_result = integration.wait_with_timeout(handle.pid, timeout)
|
|
63
|
+
integration.build_result(wait_result, handle.log_paths)
|
|
12
64
|
ensure
|
|
13
|
-
|
|
65
|
+
SchedulerDiagnostics.child_ended(handle&.pid)
|
|
66
|
+
finalize_mutant_run(integration, handle&.pid, wait_result)
|
|
14
67
|
end
|
|
15
68
|
|
|
16
69
|
def run_suite(integration, test_files, timeout:)
|
|
@@ -27,10 +80,11 @@ module Henitai
|
|
|
27
80
|
end
|
|
28
81
|
end
|
|
29
82
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
83
|
+
# Called from Integration::Rspec#spawn_mutant (and Minitest#spawn_mutant).
|
|
84
|
+
# Forks a child, sets process group, activates the mutant, runs tests.
|
|
85
|
+
# Returns a ChildHandle with the forked pid and log_paths.
|
|
86
|
+
def spawn_mutant(integration, mutant:, test_files:, log_paths:)
|
|
87
|
+
pid = Process.fork do
|
|
34
88
|
Process.setpgid(0, 0)
|
|
35
89
|
ENV["HENITAI_MUTANT_ID"] = mutant.id
|
|
36
90
|
Process.exit(
|
|
@@ -41,18 +95,17 @@ module Henitai
|
|
|
41
95
|
)
|
|
42
96
|
)
|
|
43
97
|
end
|
|
98
|
+
ChildHandle.new(pid:, log_paths:)
|
|
44
99
|
end
|
|
45
100
|
|
|
101
|
+
private
|
|
102
|
+
|
|
46
103
|
def finalize_mutant_run(integration, pid, wait_result)
|
|
47
104
|
return unless pid
|
|
48
105
|
|
|
49
106
|
integration.cleanup_process_group(pid) unless wait_result == :timeout
|
|
50
107
|
integration.reap_child(pid) if wait_result.nil?
|
|
51
108
|
end
|
|
52
|
-
|
|
53
|
-
def mutant_log_name(mutant)
|
|
54
|
-
"mutant-#{mutant.id}"
|
|
55
|
-
end
|
|
56
109
|
end
|
|
57
110
|
end
|
|
58
111
|
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
module Integration
|
|
5
|
+
# Spec-file discovery and subject-to-spec matching for the RSpec
|
|
6
|
+
# integration. Selection uses longest-prefix matching against the subject
|
|
7
|
+
# expression and namespace, with a transitive require-based fallback.
|
|
8
|
+
module RspecTestSelection
|
|
9
|
+
REQUIRE_DIRECTIVE_PATTERN = /
|
|
10
|
+
\A\s*
|
|
11
|
+
(require|require_relative)
|
|
12
|
+
\s*
|
|
13
|
+
(?:\(\s*)?
|
|
14
|
+
["']([^"']+)["']
|
|
15
|
+
\s*\)?
|
|
16
|
+
/x
|
|
17
|
+
|
|
18
|
+
def select_tests(subject)
|
|
19
|
+
matches = spec_files.select do |path|
|
|
20
|
+
content = File.read(path)
|
|
21
|
+
selection_patterns(subject).any? { |pattern| content.include?(pattern) }
|
|
22
|
+
rescue StandardError
|
|
23
|
+
false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
return matches unless matches.empty?
|
|
27
|
+
|
|
28
|
+
fallback_spec_files(subject)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def spec_files
|
|
34
|
+
@spec_files ||= begin
|
|
35
|
+
paths = Dir.glob("spec/**/*_spec.rb")
|
|
36
|
+
paths - excluded_spec_files
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def fallback_spec_files(subject)
|
|
41
|
+
return [] unless subject.source_file
|
|
42
|
+
|
|
43
|
+
matches = spec_files.select do |path|
|
|
44
|
+
requires_source_file_transitively?(path, subject.source_file)
|
|
45
|
+
rescue StandardError
|
|
46
|
+
false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
matches.empty? ? spec_files : matches
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def excluded_spec_files
|
|
53
|
+
@excluded_spec_files ||= rspec_exclude_patterns.flat_map { |pattern| Dir.glob(pattern) }.uniq
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def rspec_exclude_patterns
|
|
57
|
+
rspec_config_lines.filter_map do |line|
|
|
58
|
+
line[/\A--exclude-pattern\s+(.+)\z/, 1]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def rspec_config_lines
|
|
63
|
+
return [] unless File.exist?(rspec_config_path)
|
|
64
|
+
|
|
65
|
+
File.readlines(rspec_config_path, chomp: true).map(&:strip)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def rspec_config_path
|
|
69
|
+
".rspec"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def selection_patterns(subject)
|
|
73
|
+
[
|
|
74
|
+
subject.expression,
|
|
75
|
+
subject.namespace
|
|
76
|
+
].compact.uniq.sort_by(&:length).reverse
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def requires_source_file?(spec_file, source_file)
|
|
80
|
+
content = File.read(spec_file)
|
|
81
|
+
basename = File.basename(source_file, ".rb")
|
|
82
|
+
content.include?(basename) || content.include?(source_file)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def requires_source_file_transitively?(spec_file, source_file, visited = [])
|
|
86
|
+
normalized_spec_file = File.expand_path(spec_file)
|
|
87
|
+
return false if visited.include?(normalized_spec_file)
|
|
88
|
+
|
|
89
|
+
visited << normalized_spec_file
|
|
90
|
+
return true if requires_source_file?(spec_file, source_file)
|
|
91
|
+
|
|
92
|
+
required_files(spec_file).any? do |required_file|
|
|
93
|
+
requires_source_file_transitively?(required_file, source_file, visited)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def required_files(spec_file)
|
|
98
|
+
File.read(spec_file).lines.filter_map do |line|
|
|
99
|
+
match = line.match(REQUIRE_DIRECTIVE_PATTERN)
|
|
100
|
+
next unless match
|
|
101
|
+
|
|
102
|
+
resolve_required_file(spec_file, match[1].to_s, match[2].to_s)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def resolve_required_file(spec_file, method_name, required_path)
|
|
107
|
+
candidates =
|
|
108
|
+
if method_name == "require_relative"
|
|
109
|
+
relative_candidates(spec_file, required_path)
|
|
110
|
+
else
|
|
111
|
+
require_candidates(spec_file, required_path)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
candidates.find { |candidate| File.file?(candidate) }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def relative_candidates(spec_file, required_path)
|
|
118
|
+
expand_candidates(File.dirname(spec_file), required_path)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def require_candidates(spec_file, required_path)
|
|
122
|
+
([File.dirname(spec_file), Dir.pwd] + $LOAD_PATH).flat_map do |base_path|
|
|
123
|
+
expand_candidates(base_path, required_path)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def expand_candidates(base_path, required_path)
|
|
128
|
+
[
|
|
129
|
+
File.expand_path(required_path, base_path),
|
|
130
|
+
File.expand_path("#{required_path}.rb", base_path)
|
|
131
|
+
].uniq
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
module Integration
|
|
7
|
+
# Shared helpers for capturing stdout/stderr from child test processes and
|
|
8
|
+
# for reading and combining the captured log files afterwards.
|
|
9
|
+
class ScenarioLogSupport
|
|
10
|
+
def capture_child_output(log_paths)
|
|
11
|
+
output_files = open_child_output(log_paths)
|
|
12
|
+
yield
|
|
13
|
+
ensure
|
|
14
|
+
close_child_output(output_files)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def with_coverage_dir(mutant_id)
|
|
18
|
+
original_coverage_dir = ENV.fetch("HENITAI_COVERAGE_DIR", nil)
|
|
19
|
+
ENV["HENITAI_COVERAGE_DIR"] = mutation_coverage_dir(mutant_id)
|
|
20
|
+
yield
|
|
21
|
+
ensure
|
|
22
|
+
if original_coverage_dir.nil?
|
|
23
|
+
ENV.delete("HENITAI_COVERAGE_DIR")
|
|
24
|
+
else
|
|
25
|
+
ENV["HENITAI_COVERAGE_DIR"] = original_coverage_dir
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def open_child_output(log_paths)
|
|
30
|
+
FileUtils.mkdir_p(File.dirname(log_paths[:log_path]))
|
|
31
|
+
output_files = build_child_output_files(log_paths)
|
|
32
|
+
sync_child_output_files(output_files)
|
|
33
|
+
redirect_child_output(output_files)
|
|
34
|
+
output_files
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def close_child_output(output_files)
|
|
38
|
+
return unless output_files
|
|
39
|
+
|
|
40
|
+
restore_child_output(output_files)
|
|
41
|
+
close_child_output_files(output_files)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def build_child_output_files(log_paths)
|
|
45
|
+
{
|
|
46
|
+
original_stdout: stdout_stream.dup,
|
|
47
|
+
original_stderr: stderr_stream.dup,
|
|
48
|
+
stdout_file: File.new(log_paths[:stdout_path], "w"),
|
|
49
|
+
stderr_file: File.new(log_paths[:stderr_path], "w")
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def sync_child_output_files(output_files)
|
|
54
|
+
output_files[:stdout_file].sync = true
|
|
55
|
+
output_files[:stderr_file].sync = true
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def redirect_child_output(output_files)
|
|
59
|
+
reopen_child_output_stream(stdout_stream, output_files[:stdout_file])
|
|
60
|
+
reopen_child_output_stream(stderr_stream, output_files[:stderr_file])
|
|
61
|
+
$stdout = stdout_stream
|
|
62
|
+
$stderr = stderr_stream
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def restore_child_output(output_files)
|
|
66
|
+
reopen_child_output_stream(stdout_stream, output_files[:original_stdout])
|
|
67
|
+
reopen_child_output_stream(stderr_stream, output_files[:original_stderr])
|
|
68
|
+
$stdout = stdout_stream
|
|
69
|
+
$stderr = stderr_stream
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def reopen_child_output_stream(stream, original_stream)
|
|
73
|
+
stream.reopen(original_stream) if original_stream
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def close_child_output_files(output_files)
|
|
77
|
+
%i[stdout_file stderr_file original_stdout original_stderr].each do |key|
|
|
78
|
+
output_files[key]&.close
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def read_log_file(path)
|
|
83
|
+
return "" unless File.exist?(path)
|
|
84
|
+
|
|
85
|
+
File.read(path)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def write_combined_log(path, stdout, stderr)
|
|
89
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
90
|
+
File.write(path, combined_log(stdout, stderr))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def combined_log(stdout, stderr)
|
|
94
|
+
[
|
|
95
|
+
(stdout.empty? ? nil : "stdout:\n#{stdout}"),
|
|
96
|
+
(stderr.empty? ? nil : "stderr:\n#{stderr}")
|
|
97
|
+
].compact.join("\n")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def mutation_coverage_dir(mutant_id)
|
|
103
|
+
reports_dir = ENV.fetch("HENITAI_REPORTS_DIR", "reports")
|
|
104
|
+
File.join(reports_dir, "mutation-coverage", mutant_id.to_s)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def stdout_stream
|
|
108
|
+
@stdout_stream ||= IO.for_fd(1)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def stderr_stream
|
|
112
|
+
@stderr_stream ||= IO.for_fd(2)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|