henitai 0.2.1 → 0.3.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 +106 -1
- data/README.md +125 -3
- data/assets/schema/henitai.schema.json +35 -0
- data/lib/henitai/available_cpu_count.rb +16 -23
- data/lib/henitai/canonical_report_merger.rb +107 -0
- data/lib/henitai/canonical_report_writer.rb +22 -0
- data/lib/henitai/checkpoint_reporter.rb +79 -0
- data/lib/henitai/cli/clean_command.rb +14 -8
- data/lib/henitai/cli/command_support.rb +2 -1
- data/lib/henitai/cli/operator_command.rb +2 -1
- data/lib/henitai/cli/options.rb +21 -57
- data/lib/henitai/cli/run_command.rb +36 -3
- data/lib/henitai/cli/run_options.rb +108 -0
- data/lib/henitai/cli.rb +12 -4
- data/lib/henitai/composite_progress_reporter.rb +42 -0
- data/lib/henitai/configuration.rb +41 -9
- data/lib/henitai/configuration_validator/rules.rb +18 -0
- data/lib/henitai/configuration_validator/scalars.rb +46 -0
- data/lib/henitai/configuration_validator.rb +8 -1
- data/lib/henitai/coverage_bootstrapper.rb +54 -5
- data/lib/henitai/coverage_formatter.rb +5 -1
- data/lib/henitai/coverage_report_reader.rb +28 -5
- data/lib/henitai/execution_engine/env_scope.rb +67 -0
- data/lib/henitai/execution_engine.rb +111 -50
- data/lib/henitai/generated_artifacts.rb +58 -0
- data/lib/henitai/incremental_filter.rb +100 -0
- data/lib/henitai/integration/child_debug_support.rb +6 -2
- data/lib/henitai/integration/coverage_suppression.rb +9 -0
- data/lib/henitai/integration/minitest.rb +11 -43
- data/lib/henitai/integration/minitest_load_path.rb +14 -0
- data/lib/henitai/integration/minitest_suite_command.rb +17 -0
- data/lib/henitai/integration/minitest_test_runner.rb +39 -0
- data/lib/henitai/integration/rails_environment_preloader.rb +14 -0
- data/lib/henitai/integration/rspec_child_runner.rb +3 -3
- data/lib/henitai/integration/rspec_test_selection.rb +3 -0
- data/lib/henitai/integration/scenario_log_support.rb +67 -20
- data/lib/henitai/minitest_coverage_reporter.rb +4 -1
- data/lib/henitai/mutant/activator.rb +14 -0
- data/lib/henitai/mutant.rb +13 -6
- data/lib/henitai/mutant_history_store/sql.rb +21 -3
- data/lib/henitai/mutant_history_store/verdict_cache.rb +84 -0
- data/lib/henitai/mutant_history_store.rb +62 -12
- data/lib/henitai/mutant_identity.rb +33 -2
- data/lib/henitai/mutation_skip_directives.rb +227 -0
- data/lib/henitai/operator.rb +3 -1
- data/lib/henitai/operators/equality_identity_operator.rb +51 -0
- data/lib/henitai/operators/equality_operator.rb +8 -3
- data/lib/henitai/operators.rb +1 -0
- data/lib/henitai/per_test_coverage.rb +81 -0
- data/lib/henitai/per_test_coverage_collector.rb +20 -5
- data/lib/henitai/per_test_coverage_selector.rb +11 -33
- data/lib/henitai/reporter/dashboard_metadata_provider.rb +113 -0
- data/lib/henitai/reporter.rb +248 -117
- data/lib/henitai/reports_directory_lock.rb +76 -0
- data/lib/henitai/result.rb +75 -10
- data/lib/henitai/runner.rb +102 -43
- data/lib/henitai/scenario_execution_result.rb +12 -0
- data/lib/henitai/slot_scheduler/draining.rb +21 -15
- data/lib/henitai/slot_scheduler.rb +73 -15
- data/lib/henitai/static_filter.rb +16 -6
- data/lib/henitai/survivor_rerun_strategy.rb +1 -1
- data/lib/henitai/survivor_test_filter.rb +1 -1
- data/lib/henitai/test_prioritizer.rb +28 -2
- data/lib/henitai/timeout_calibrator.rb +44 -0
- data/lib/henitai/verdict_fingerprint.rb +155 -0
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +14 -1
- data/sig/henitai.rbs +235 -25
- metadata +22 -3
- data/lib/henitai/parallel_execution_runner.rb +0 -153
|
@@ -23,11 +23,17 @@ module Henitai
|
|
|
23
23
|
|
|
24
24
|
PROCESS_DRAIN_WINDOW = 0.2
|
|
25
25
|
|
|
26
|
+
# Environment variable exposing a stable worker-slot index (0..jobs-1) to
|
|
27
|
+
# each forked child so test suites can isolate shared resources per slot
|
|
28
|
+
# (e.g. "myapp_test_#{ENV['HENITAI_WORKER_SLOT']}"). A flaky-retry respawn
|
|
29
|
+
# keeps the original attempt's value.
|
|
30
|
+
WORKER_SLOT_ENV = "HENITAI_WORKER_SLOT"
|
|
31
|
+
|
|
26
32
|
# Tracks one in-flight mutant child process.
|
|
27
33
|
Slot = Struct.new(
|
|
28
34
|
:slot_id, :mutant, :pid, :started_at_monotonic, :timeout,
|
|
29
35
|
:log_paths, :retry_count, :draining, :term_sent_at_monotonic,
|
|
30
|
-
:forced_outcome
|
|
36
|
+
:forced_outcome, :worker_index
|
|
31
37
|
)
|
|
32
38
|
|
|
33
39
|
# @return [Integer] mutants that required at least one retry during the run.
|
|
@@ -98,28 +104,48 @@ module Henitai
|
|
|
98
104
|
test_files = resolve_test_files(mutant)
|
|
99
105
|
mutant.covered_by = test_files if mutant.respond_to?(:covered_by=)
|
|
100
106
|
mutant.tests_completed = test_files.size if mutant.respond_to?(:tests_completed=)
|
|
101
|
-
|
|
102
|
-
|
|
107
|
+
return record_no_coverage(mutant) if resolved_selection_empty?(test_files)
|
|
108
|
+
|
|
109
|
+
worker_index = next_free_worker_index
|
|
110
|
+
with_worker_slot(worker_index) do
|
|
111
|
+
handle = integration.spawn_mutant(mutant: mutant, test_files: test_files)
|
|
112
|
+
register_slot(handle, mutant, worker_index, slot_timeout(mutant, test_files))
|
|
113
|
+
end
|
|
103
114
|
rescue StandardError => e
|
|
104
115
|
record_spawn_failure(mutant, e)
|
|
105
116
|
end
|
|
106
117
|
|
|
107
|
-
def register_slot(handle, mutant)
|
|
118
|
+
def register_slot(handle, mutant, worker_index, timeout)
|
|
108
119
|
slot_id = next_slot_id!
|
|
109
|
-
slot = build_slot(slot_id, mutant, handle)
|
|
120
|
+
slot = build_slot(slot_id, mutant, handle, worker_index, timeout)
|
|
110
121
|
slots[slot_id] = slot
|
|
111
122
|
pid_to_slot[handle.pid] = slot_id
|
|
112
123
|
Integration::SchedulerDiagnostics.child_started(handle.pid)
|
|
113
124
|
end
|
|
114
125
|
|
|
115
|
-
def build_slot(slot_id, mutant, handle)
|
|
126
|
+
def build_slot(slot_id, mutant, handle, worker_index, timeout)
|
|
116
127
|
Slot.new(
|
|
117
128
|
slot_id, mutant, handle.pid,
|
|
118
129
|
monotonic_time,
|
|
119
|
-
|
|
130
|
+
timeout, handle.log_paths, 0, false, nil, nil,
|
|
131
|
+
worker_index
|
|
120
132
|
)
|
|
121
133
|
end
|
|
122
134
|
|
|
135
|
+
def slot_timeout(mutant, test_files)
|
|
136
|
+
resolver = options[:timeout_resolver]
|
|
137
|
+
resolver ? resolver.call(mutant, test_files) : config.timeout
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Smallest index in 0...worker_count not held by a live slot, so
|
|
141
|
+
# concurrently-running children always see distinct values and freed
|
|
142
|
+
# indices are reused. Slot ids themselves grow monotonically and are
|
|
143
|
+
# unsuitable as a resource token.
|
|
144
|
+
def next_free_worker_index
|
|
145
|
+
used = slots.each_value.map(&:worker_index)
|
|
146
|
+
(0...worker_count).find { |index| !used.include?(index) } || used.size
|
|
147
|
+
end
|
|
148
|
+
|
|
123
149
|
def complete_slot(pid, wait_result)
|
|
124
150
|
slot_id = pid_to_slot.delete(pid)
|
|
125
151
|
return unless slot_id
|
|
@@ -140,6 +166,7 @@ module Henitai
|
|
|
140
166
|
slot.mutant.status = result.status
|
|
141
167
|
results << result
|
|
142
168
|
progress_reporter&.progress(slot.mutant, scenario_result: result)
|
|
169
|
+
result.release_output! if result.respond_to?(:release_output!)
|
|
143
170
|
end
|
|
144
171
|
end
|
|
145
172
|
|
|
@@ -147,22 +174,32 @@ module Henitai
|
|
|
147
174
|
!shutdown? && result.survived? && slot.retry_count < config.max_flaky_retries.to_i
|
|
148
175
|
end
|
|
149
176
|
|
|
150
|
-
def retry_slot(slot)
|
|
177
|
+
def retry_slot(slot)
|
|
178
|
+
test_files = resolve_test_files(slot.mutant)
|
|
179
|
+
with_worker_slot(slot.worker_index) do
|
|
180
|
+
handle = integration.spawn_mutant(mutant: slot.mutant, test_files: test_files)
|
|
181
|
+
finish_retry(slot, handle)
|
|
182
|
+
end
|
|
183
|
+
rescue StandardError => e
|
|
184
|
+
slots.delete(slot.slot_id)
|
|
185
|
+
record_spawn_failure(slot.mutant, e)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def finish_retry(slot, handle)
|
|
151
189
|
@flaky_retry_count += 1 if slot.retry_count.zero?
|
|
152
190
|
slot.retry_count += 1
|
|
153
|
-
|
|
154
|
-
handle =
|
|
191
|
+
reset_slot_for_retry(slot, handle)
|
|
192
|
+
pid_to_slot[handle.pid] = slot.slot_id
|
|
193
|
+
Integration::SchedulerDiagnostics.child_started(handle.pid)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def reset_slot_for_retry(slot, handle)
|
|
155
197
|
slot.pid = handle.pid
|
|
156
198
|
slot.log_paths = handle.log_paths
|
|
157
199
|
slot.started_at_monotonic = monotonic_time
|
|
158
200
|
slot.draining = false
|
|
159
201
|
slot.term_sent_at_monotonic = nil
|
|
160
202
|
slot.forced_outcome = nil
|
|
161
|
-
pid_to_slot[handle.pid] = slot.slot_id
|
|
162
|
-
Integration::SchedulerDiagnostics.child_started(handle.pid)
|
|
163
|
-
rescue StandardError => e
|
|
164
|
-
slots.delete(slot.slot_id)
|
|
165
|
-
record_spawn_failure(slot.mutant, e)
|
|
166
203
|
end
|
|
167
204
|
|
|
168
205
|
def record_spawn_failure(mutant, error)
|
|
@@ -178,6 +215,15 @@ module Henitai
|
|
|
178
215
|
progress_reporter&.progress(mutant, scenario_result: result)
|
|
179
216
|
end
|
|
180
217
|
|
|
218
|
+
def record_no_coverage(mutant)
|
|
219
|
+
mutant.status = :no_coverage
|
|
220
|
+
progress_reporter&.progress(mutant, scenario_result: nil)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def resolved_selection_empty?(test_files)
|
|
224
|
+
options.key?(:test_file_resolver) && test_files.empty?
|
|
225
|
+
end
|
|
226
|
+
|
|
181
227
|
def remaining_slot_timeout(slot, now)
|
|
182
228
|
# Invariant: drain_draining_slots runs (and removes draining slots) before
|
|
183
229
|
# the event wait, so next_event_timeout never observes a draining slot
|
|
@@ -210,5 +256,17 @@ module Henitai
|
|
|
210
256
|
@next_slot_id += 1
|
|
211
257
|
id
|
|
212
258
|
end
|
|
259
|
+
|
|
260
|
+
def with_worker_slot(worker_index)
|
|
261
|
+
previous = ENV.fetch(WORKER_SLOT_ENV, nil)
|
|
262
|
+
ENV[WORKER_SLOT_ENV] = worker_index.to_s
|
|
263
|
+
yield
|
|
264
|
+
ensure
|
|
265
|
+
if previous.nil?
|
|
266
|
+
ENV.delete(WORKER_SLOT_ENV)
|
|
267
|
+
else
|
|
268
|
+
ENV[WORKER_SLOT_ENV] = previous
|
|
269
|
+
end
|
|
270
|
+
end
|
|
213
271
|
end
|
|
214
272
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "coverage_report_reader"
|
|
4
|
+
require_relative "mutation_skip_directives"
|
|
4
5
|
|
|
5
6
|
module Henitai
|
|
6
7
|
# Applies static, pre-execution filtering to generated mutants.
|
|
@@ -8,8 +9,10 @@ module Henitai
|
|
|
8
9
|
DEFAULT_COVERAGE_REPORT_PATH = CoverageReportReader::DEFAULT_COVERAGE_REPORT_PATH
|
|
9
10
|
DEFAULT_PER_TEST_COVERAGE_REPORT_PATH = CoverageReportReader::DEFAULT_PER_TEST_COVERAGE_REPORT_PATH
|
|
10
11
|
|
|
11
|
-
def initialize(coverage_report_reader: CoverageReportReader.new
|
|
12
|
+
def initialize(coverage_report_reader: CoverageReportReader.new,
|
|
13
|
+
skip_directives: MutationSkipDirectives.new)
|
|
12
14
|
@coverage_report_reader = coverage_report_reader
|
|
15
|
+
@skip_directives = skip_directives
|
|
13
16
|
end
|
|
14
17
|
|
|
15
18
|
# This method is the gate-level filter orchestrator.
|
|
@@ -18,7 +21,7 @@ module Henitai
|
|
|
18
21
|
coverage_report_present = coverage_report_present?(config)
|
|
19
22
|
|
|
20
23
|
Array(mutants).each do |mutant|
|
|
21
|
-
next if ignored_mutant?(mutant, config)
|
|
24
|
+
next if ignored_mutant?(mutant, config) || skip_directive_mutant?(mutant)
|
|
22
25
|
|
|
23
26
|
mark_equivalent_mutant(mutant)
|
|
24
27
|
mark_no_coverage_mutant(
|
|
@@ -61,7 +64,7 @@ module Henitai
|
|
|
61
64
|
|
|
62
65
|
private
|
|
63
66
|
|
|
64
|
-
attr_reader :coverage_report_reader
|
|
67
|
+
attr_reader :coverage_report_reader, :skip_directives
|
|
65
68
|
|
|
66
69
|
def ignored?(mutant, config)
|
|
67
70
|
source = source_for(mutant)
|
|
@@ -79,6 +82,15 @@ module Henitai
|
|
|
79
82
|
true
|
|
80
83
|
end
|
|
81
84
|
|
|
85
|
+
def skip_directive_mutant?(mutant)
|
|
86
|
+
directive = skip_directives.directive_for(mutant)
|
|
87
|
+
return false unless directive
|
|
88
|
+
|
|
89
|
+
mutant.status = :ignored
|
|
90
|
+
mutant.ignore_reason = directive.reason if mutant.respond_to?(:ignore_reason=)
|
|
91
|
+
true
|
|
92
|
+
end
|
|
93
|
+
|
|
82
94
|
def mark_equivalent_mutant(mutant)
|
|
83
95
|
return unless mutant.pending?
|
|
84
96
|
|
|
@@ -96,9 +108,7 @@ module Henitai
|
|
|
96
108
|
def covered?(mutant, coverage_lines)
|
|
97
109
|
file = normalize_path(mutant.location[:file])
|
|
98
110
|
covered = Array(coverage_lines[file])
|
|
99
|
-
(mutant.location[:start_line]..mutant.location[:end_line]).
|
|
100
|
-
covered.include?(line)
|
|
101
|
-
end
|
|
111
|
+
covered.any? { |line| (mutant.location[:start_line]..mutant.location[:end_line]).cover?(line) }
|
|
102
112
|
end
|
|
103
113
|
|
|
104
114
|
def source_for(mutant)
|
|
@@ -137,7 +137,7 @@ module Henitai
|
|
|
137
137
|
split[:stable] + split[:pending]
|
|
138
138
|
end
|
|
139
139
|
|
|
140
|
-
def test_filter(loaded, dirty_source_files:
|
|
140
|
+
def test_filter(loaded, dirty_source_files:)
|
|
141
141
|
SurvivorTestFilter.new(
|
|
142
142
|
coverage_map: loaded.coverage_map,
|
|
143
143
|
git_sha: loaded.git_sha,
|
|
@@ -57,7 +57,7 @@ module Henitai
|
|
|
57
57
|
covering = @coverage_map[mutant.stable_id]
|
|
58
58
|
return false if covering.nil? || covering.empty?
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
!changed.intersect?(covering)
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
# Returns a Set of changed test file paths, or nil on any git error
|
|
@@ -2,15 +2,41 @@
|
|
|
2
2
|
|
|
3
3
|
module Henitai
|
|
4
4
|
# Orders test files so previously effective tests run first.
|
|
5
|
+
#
|
|
6
|
+
# Primary key: kill-history count (a test that killed neighbouring mutants
|
|
7
|
+
# probably kills this one). Tiebreaker: measured per-test-file runtime,
|
|
8
|
+
# ascending, so the first kill costs as little wall-clock as possible.
|
|
9
|
+
# Untimed tests sort after timed ones by original inventory order; with no
|
|
10
|
+
# timing source at all the ordering is exactly the historical behavior.
|
|
5
11
|
class TestPrioritizer
|
|
12
|
+
# @param timing_source [#call, nil] returns a Hash of test file path to
|
|
13
|
+
# wall-clock seconds; resolved lazily on first sort.
|
|
14
|
+
def initialize(timing_source: nil)
|
|
15
|
+
@timing_source = timing_source
|
|
16
|
+
end
|
|
17
|
+
|
|
6
18
|
def sort(tests, _mutant, history)
|
|
7
19
|
Array(tests).each_with_index.sort_by do |test, index|
|
|
8
|
-
[-history_count(history, test), index]
|
|
20
|
+
[-history_count(history, test), runtime(test), index]
|
|
9
21
|
end.map(&:first)
|
|
10
22
|
end
|
|
11
23
|
|
|
12
24
|
private
|
|
13
25
|
|
|
26
|
+
def runtime(test)
|
|
27
|
+
durations = timing_durations
|
|
28
|
+
history_key_candidates(test).each do |key|
|
|
29
|
+
value = durations[key]
|
|
30
|
+
return value.to_f unless value.nil?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
Float::INFINITY
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def timing_durations
|
|
37
|
+
@timing_durations ||= @timing_source&.call || {}
|
|
38
|
+
end
|
|
39
|
+
|
|
14
40
|
def history_count(history, test)
|
|
15
41
|
return 0 unless history.respond_to?(:fetch)
|
|
16
42
|
|
|
@@ -45,7 +71,7 @@ module Henitai
|
|
|
45
71
|
|
|
46
72
|
def relative_history_key(path)
|
|
47
73
|
pathname = Pathname.new(path)
|
|
48
|
-
return unless pathname.absolute?
|
|
74
|
+
return path unless pathname.absolute?
|
|
49
75
|
|
|
50
76
|
pathname.relative_path_from(Pathname.pwd).to_s
|
|
51
77
|
rescue StandardError
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Derives a per-mutant timeout from measured per-test-file durations.
|
|
5
|
+
#
|
|
6
|
+
# The calibrated value is `multiplier × sum(durations of the selected test
|
|
7
|
+
# files)`, clamped to a floor so a near-instant baseline doesn't flag normal
|
|
8
|
+
# jitter as a timeout. Returns nil — caller falls back to the static
|
|
9
|
+
# default — when any selected test lacks timing data, so a partially
|
|
10
|
+
# measured run is treated as uncalibratable rather than under-estimated.
|
|
11
|
+
class TimeoutCalibrator
|
|
12
|
+
FLOOR_SECONDS = 2.0
|
|
13
|
+
|
|
14
|
+
# @param timing_source [#call] returns a Hash of test file path to
|
|
15
|
+
# wall-clock seconds; resolved lazily on first use.
|
|
16
|
+
# @param multiplier [Numeric]
|
|
17
|
+
def initialize(timing_source:, multiplier:)
|
|
18
|
+
@timing_source = timing_source
|
|
19
|
+
@multiplier = multiplier
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param test_files [Array<String>]
|
|
23
|
+
# @return [Float, nil]
|
|
24
|
+
def timeout_for(test_files)
|
|
25
|
+
files = Array(test_files)
|
|
26
|
+
return nil if files.empty? || durations.empty?
|
|
27
|
+
|
|
28
|
+
baselines = files.map { |file| durations[normalize(file)] }
|
|
29
|
+
return nil if baselines.any?(&:nil?)
|
|
30
|
+
|
|
31
|
+
[@multiplier * baselines.sum, FLOOR_SECONDS].max
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def durations
|
|
37
|
+
@durations ||= (@timing_source.call || {}).transform_keys { |key| normalize(key) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def normalize(path)
|
|
41
|
+
File.expand_path(path.to_s)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "find"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
require_relative "generated_artifacts"
|
|
8
|
+
|
|
9
|
+
module Henitai
|
|
10
|
+
# Content fingerprints that decide whether a stored verdict is still
|
|
11
|
+
# trustworthy: the subject's source (method body) and the covering test
|
|
12
|
+
# files must both be byte-identical to what was recorded. Survived verdicts
|
|
13
|
+
# additionally record the full-map covering-set membership plus a run-level
|
|
14
|
+
# dependency fingerprint (ADR-11). Any read failure yields nil / a mismatch
|
|
15
|
+
# — conservative, never reuse on doubt.
|
|
16
|
+
module VerdictFingerprint
|
|
17
|
+
# Files that influence test behavior but never appear in any covering
|
|
18
|
+
# set: helpers, support code, fixtures/factories, lockfile and tool
|
|
19
|
+
# config. One combined hash over all of them gates survivor reuse.
|
|
20
|
+
DEPENDENCY_GLOBS = %w[
|
|
21
|
+
spec/spec_helper.rb
|
|
22
|
+
spec/rails_helper.rb
|
|
23
|
+
spec/support/**/*
|
|
24
|
+
spec/fixtures/**/*
|
|
25
|
+
spec/factories/**/*
|
|
26
|
+
test/test_helper.rb
|
|
27
|
+
test/support/**/*
|
|
28
|
+
test/fixtures/**/*
|
|
29
|
+
test/factories/**/*
|
|
30
|
+
Gemfile.lock
|
|
31
|
+
.henitai.yml
|
|
32
|
+
.rspec
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
# SHA256 of the subject's source lines. nil when the subject has no
|
|
38
|
+
# source range or the file cannot be read.
|
|
39
|
+
def subject_source_hash(mutant)
|
|
40
|
+
subject = mutant.subject
|
|
41
|
+
return nil unless subject.respond_to?(:source_file) && subject.respond_to?(:source_range)
|
|
42
|
+
|
|
43
|
+
range = subject.source_range
|
|
44
|
+
return nil unless range
|
|
45
|
+
|
|
46
|
+
lines = File.readlines(subject.source_file)
|
|
47
|
+
Digest::SHA256.hexdigest(lines[(range.begin - 1)..(range.end - 1)].to_a.join)
|
|
48
|
+
rescue SystemCallError, IOError
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# JSON fingerprint of the covering test files: their sorted paths plus a
|
|
53
|
+
# combined content hash. nil when no tests are known or any is unreadable.
|
|
54
|
+
def tests_fingerprint(test_files)
|
|
55
|
+
paths = Array(test_files).map(&:to_s).sort
|
|
56
|
+
return nil if paths.empty?
|
|
57
|
+
|
|
58
|
+
sha = combined_content_sha(paths)
|
|
59
|
+
return nil unless sha
|
|
60
|
+
|
|
61
|
+
JSON.generate("paths" => paths, "sha" => sha)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# True when every recorded test file still exists with identical content.
|
|
65
|
+
def tests_fingerprint_current?(fingerprint_json)
|
|
66
|
+
return false if fingerprint_json.nil?
|
|
67
|
+
|
|
68
|
+
fingerprint = JSON.parse(fingerprint_json)
|
|
69
|
+
combined_content_sha(fingerprint.fetch("paths")) == fingerprint.fetch("sha")
|
|
70
|
+
rescue StandardError
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Existing dependency files resolved against +root+, sorted. Shared by
|
|
75
|
+
# the fingerprint below and by the coverage freshness watch — a stale
|
|
76
|
+
# per-test map after a dependency edit would let the test selector omit
|
|
77
|
+
# a newly covering test. Uses Find with pruning instead of Dir.glob so
|
|
78
|
+
# generated subtrees (thousands of mutation-log files under fixture
|
|
79
|
+
# projects) are never walked at all.
|
|
80
|
+
def dependency_files(root = Dir.pwd)
|
|
81
|
+
files = DEPENDENCY_GLOBS.flat_map do |pattern|
|
|
82
|
+
base = File.join(root, pattern)
|
|
83
|
+
pattern.include?("*") ? pruned_tree_files(File.dirname(base.sub("/**/*", "/x"))) : [base]
|
|
84
|
+
end
|
|
85
|
+
files.select { |path| File.file?(path) }.uniq.sort
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def pruned_tree_files(dir)
|
|
89
|
+
return [] unless File.directory?(dir)
|
|
90
|
+
|
|
91
|
+
collected = [] # : Array[String]
|
|
92
|
+
Find.find(dir) do |path|
|
|
93
|
+
Find.prune if File.directory?(path) && GeneratedArtifacts.generated_dir?(path)
|
|
94
|
+
collected << path if File.file?(path)
|
|
95
|
+
end
|
|
96
|
+
collected
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Run-level combined SHA over the dependency file set. Missing files are
|
|
100
|
+
# simply excluded (not an error); a read failure yields nil — no reuse.
|
|
101
|
+
def dependency_fingerprint(root = Dir.pwd) = combined_content_sha(dependency_files(root))
|
|
102
|
+
|
|
103
|
+
# Fingerprint recorded for a Survived verdict: the sorted full-map
|
|
104
|
+
# intersection set (paths + combined content sha) plus the run-level
|
|
105
|
+
# dependency sha. nil when any input is unavailable — the verdict is
|
|
106
|
+
# then never reusable.
|
|
107
|
+
def survivor_tests_fingerprint(test_files, dependency_sha:)
|
|
108
|
+
return nil if dependency_sha.nil?
|
|
109
|
+
|
|
110
|
+
paths = Array(test_files).map(&:to_s).sort
|
|
111
|
+
return nil if paths.empty?
|
|
112
|
+
|
|
113
|
+
sha = combined_content_sha(paths)
|
|
114
|
+
return nil unless sha
|
|
115
|
+
|
|
116
|
+
JSON.generate("paths" => paths, "sha" => sha, "dependencies" => dependency_sha)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# True when the recorded covering set still equals the live one in
|
|
120
|
+
# membership AND content, and the dependency fingerprint is unchanged.
|
|
121
|
+
# Any parse/read failure or missing field resolves to stale.
|
|
122
|
+
def survivor_fingerprint_current?(fingerprint_json, live_paths:, dependency_sha:)
|
|
123
|
+
return false if fingerprint_json.nil? || dependency_sha.nil?
|
|
124
|
+
|
|
125
|
+
survivor_fingerprint_matches?(JSON.parse(fingerprint_json), live_paths, dependency_sha)
|
|
126
|
+
rescue StandardError
|
|
127
|
+
false
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def survivor_fingerprint_matches?(fingerprint, live_paths, dependency_sha)
|
|
131
|
+
recorded_paths = fingerprint.fetch("paths")
|
|
132
|
+
return false if recorded_paths.empty?
|
|
133
|
+
|
|
134
|
+
recorded_paths == Array(live_paths).map(&:to_s).sort &&
|
|
135
|
+
fingerprint.fetch("dependencies") == dependency_sha &&
|
|
136
|
+
combined_content_sha(recorded_paths) == fingerprint.fetch("sha")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def combined_content_sha(paths)
|
|
140
|
+
digest = Digest::SHA256.new
|
|
141
|
+
paths.each do |path|
|
|
142
|
+
content = File.read(path)
|
|
143
|
+
digest.update(path.bytesize.to_s)
|
|
144
|
+
digest.update(":")
|
|
145
|
+
digest.update(path)
|
|
146
|
+
digest.update(content.bytesize.to_s)
|
|
147
|
+
digest.update(":")
|
|
148
|
+
digest.update(content)
|
|
149
|
+
end
|
|
150
|
+
digest.hexdigest
|
|
151
|
+
rescue StandardError
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
data/lib/henitai/version.rb
CHANGED
data/lib/henitai.rb
CHANGED
|
@@ -20,9 +20,13 @@ module Henitai
|
|
|
20
20
|
# Raised when coverage data cannot be bootstrapped or validated.
|
|
21
21
|
class CoverageError < StandardError; end
|
|
22
22
|
|
|
23
|
+
# Raised when another process is using the configured reports directory.
|
|
24
|
+
class ConcurrentRunError < StandardError; end
|
|
25
|
+
|
|
23
26
|
autoload :Configuration, "henitai/configuration"
|
|
24
27
|
autoload :CoverageBootstrapper, "henitai/coverage_bootstrapper"
|
|
25
28
|
autoload :CoverageReportReader, "henitai/coverage_report_reader"
|
|
29
|
+
autoload :PerTestCoverage, "henitai/per_test_coverage"
|
|
26
30
|
autoload :PerTestCoverageSelector, "henitai/per_test_coverage_selector"
|
|
27
31
|
autoload :Subject, "henitai/subject"
|
|
28
32
|
autoload :Mutant, "henitai/mutant"
|
|
@@ -31,15 +35,24 @@ module Henitai
|
|
|
31
35
|
autoload :Operators, "henitai/operators"
|
|
32
36
|
autoload :SourceParser, "henitai/source_parser"
|
|
33
37
|
autoload :SubjectResolver, "henitai/subject_resolver"
|
|
38
|
+
autoload :GeneratedArtifacts, "henitai/generated_artifacts"
|
|
34
39
|
autoload :GitDiffAnalyzer, "henitai/git_diff_analyzer"
|
|
35
40
|
autoload :GitDiffError, "henitai/git_diff_analyzer"
|
|
36
41
|
autoload :MutantGenerator, "henitai/mutant_generator"
|
|
37
42
|
autoload :MutantHistoryStore, "henitai/mutant_history_store"
|
|
43
|
+
autoload :MutationSkipDirectives, "henitai/mutation_skip_directives"
|
|
38
44
|
autoload :AridNodeFilter, "henitai/arid_node_filter"
|
|
39
45
|
autoload :AvailableCpuCount, "henitai/available_cpu_count"
|
|
40
46
|
autoload :EquivalenceDetector, "henitai/equivalence_detector"
|
|
41
47
|
autoload :StaticFilter, "henitai/static_filter"
|
|
48
|
+
autoload :IncrementalFilter, "henitai/incremental_filter"
|
|
49
|
+
autoload :VerdictFingerprint, "henitai/verdict_fingerprint"
|
|
42
50
|
autoload :StillbornFilter, "henitai/stillborn_filter"
|
|
51
|
+
autoload :CanonicalReportMerger, "henitai/canonical_report_merger"
|
|
52
|
+
autoload :CanonicalReportWriter, "henitai/canonical_report_writer"
|
|
53
|
+
autoload :CheckpointReporter, "henitai/checkpoint_reporter"
|
|
54
|
+
autoload :CompositeProgressReporter, "henitai/composite_progress_reporter"
|
|
55
|
+
autoload :ReportsDirectoryLock, "henitai/reports_directory_lock"
|
|
43
56
|
autoload :SurvivorLoader, "henitai/survivor_loader"
|
|
44
57
|
autoload :SurvivorSelector, "henitai/survivor_selector"
|
|
45
58
|
autoload :SurvivorTestFilter, "henitai/survivor_test_filter"
|
|
@@ -52,8 +65,8 @@ module Henitai
|
|
|
52
65
|
autoload :SyntaxValidator, "henitai/syntax_validator"
|
|
53
66
|
autoload :SamplingStrategy, "henitai/sampling_strategy"
|
|
54
67
|
autoload :TestPrioritizer, "henitai/test_prioritizer"
|
|
68
|
+
autoload :TimeoutCalibrator, "henitai/timeout_calibrator"
|
|
55
69
|
autoload :ExecutionEngine, "henitai/execution_engine"
|
|
56
|
-
autoload :ParallelExecutionRunner, "henitai/parallel_execution_runner"
|
|
57
70
|
autoload :ProcessWorkerRunner, "henitai/process_worker_runner"
|
|
58
71
|
autoload :SlotScheduler, "henitai/slot_scheduler"
|
|
59
72
|
autoload :ProcessWakeup, "henitai/process_wakeup"
|