henitai 0.2.0 → 0.3.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 +105 -1
- data/README.md +138 -5
- data/assets/schema/henitai.schema.json +41 -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 +54 -0
- data/lib/henitai/cli/command_support.rb +52 -0
- data/lib/henitai/cli/init_command.rb +64 -0
- data/lib/henitai/cli/operator_command.rb +96 -0
- data/lib/henitai/cli/options.rb +84 -0
- data/lib/henitai/cli/run_command.rb +136 -0
- data/lib/henitai/cli/run_options.rb +108 -0
- data/lib/henitai/cli.rb +27 -407
- data/lib/henitai/composite_progress_reporter.rb +42 -0
- data/lib/henitai/configuration.rb +42 -9
- data/lib/henitai/configuration_validator/rules.rb +161 -0
- data/lib/henitai/configuration_validator/scalars.rb +169 -0
- data/lib/henitai/configuration_validator.rb +20 -240
- 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/eager_load.rb +36 -5
- data/lib/henitai/execution_engine/env_scope.rb +67 -0
- data/lib/henitai/execution_engine.rb +115 -53
- data/lib/henitai/generated_artifacts.rb +58 -0
- data/lib/henitai/incremental_filter.rb +100 -0
- data/lib/henitai/integration/base.rb +171 -0
- data/lib/henitai/integration/child_debug_support.rb +119 -0
- data/lib/henitai/integration/child_runtime_control.rb +50 -0
- data/lib/henitai/integration/coverage_suppression.rb +52 -0
- data/lib/henitai/integration/minitest.rb +101 -0
- 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/mutant_run_support.rb +77 -0
- data/lib/henitai/integration/rails_environment_preloader.rb +14 -0
- data/lib/henitai/integration/rspec_child_runner.rb +61 -0
- data/lib/henitai/integration/rspec_test_selection.rb +138 -0
- data/lib/henitai/integration/scenario_log_support.rb +160 -0
- data/lib/henitai/integration.rb +22 -846
- data/lib/henitai/minitest_coverage_reporter.rb +4 -1
- data/lib/henitai/mutant/activator.rb +15 -79
- data/lib/henitai/mutant/parameter_source.rb +98 -0
- data/lib/henitai/mutant.rb +14 -6
- data/lib/henitai/mutant_history_store/sql.rb +90 -0
- data/lib/henitai/mutant_history_store/verdict_cache.rb +84 -0
- data/lib/henitai/mutant_history_store.rb +67 -81
- 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 +66 -0
- data/lib/henitai/per_test_coverage_collector.rb +23 -6
- data/lib/henitai/per_test_coverage_selector.rb +11 -33
- data/lib/henitai/process_worker_runner.rb +48 -334
- data/lib/henitai/reporter/dashboard_metadata_provider.rb +113 -0
- data/lib/henitai/reporter.rb +258 -125
- data/lib/henitai/reports_directory_lock.rb +76 -0
- data/lib/henitai/result.rb +88 -23
- data/lib/henitai/runner.rb +137 -218
- data/lib/henitai/scenario_execution_result.rb +12 -0
- data/lib/henitai/slot_scheduler/draining.rb +146 -0
- data/lib/henitai/slot_scheduler/process_control.rb +43 -0
- data/lib/henitai/slot_scheduler.rb +272 -0
- data/lib/henitai/static_filter.rb +16 -6
- data/lib/henitai/survivor_rerun_strategy.rb +195 -0
- 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/unparse_helper.rb +5 -2
- data/lib/henitai/verdict_fingerprint.rb +155 -0
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +16 -1
- data/sig/configuration_validator.rbs +46 -22
- data/sig/henitai.rbs +386 -96
- metadata +45 -3
- data/lib/henitai/parallel_execution_runner.rb +0 -153
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Survivor-rerun fast path for {Runner}.
|
|
5
|
+
#
|
|
6
|
+
# When +--survivors-from+ is given, this collaborator loads the prior report
|
|
7
|
+
# and either:
|
|
8
|
+
#
|
|
9
|
+
# * builds stub Mutants directly from +activation-recipes.json+ (the recipe
|
|
10
|
+
# fast path, bypassing source parsing and mutant generation), or
|
|
11
|
+
# * filters a freshly generated mutant list down to the prior survivors.
|
|
12
|
+
#
|
|
13
|
+
# In both cases it records {#survivor_stats} (matched/unmatched counts and the
|
|
14
|
+
# drift warning) for the Runner to attach to its Result.
|
|
15
|
+
class SurvivorRerunStrategy
|
|
16
|
+
attr_reader :survivor_stats
|
|
17
|
+
|
|
18
|
+
def initialize(survivors_from:, config:, git_diff_analyzer:)
|
|
19
|
+
@survivors_from = survivors_from
|
|
20
|
+
@config = config
|
|
21
|
+
@git_diff_analyzer = git_diff_analyzer
|
|
22
|
+
@survivor_stats = nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def active?
|
|
26
|
+
!@survivors_from.nil?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Attempts to run survivors directly from pre-computed activation recipes,
|
|
30
|
+
# bypassing source parsing and mutant generation entirely.
|
|
31
|
+
# Returns the mutant array on success, or nil if recipes are unavailable.
|
|
32
|
+
def try_recipe_run
|
|
33
|
+
dirty_worktree_files = dirty_worktree_changed_files
|
|
34
|
+
loaded = load_survivor_report
|
|
35
|
+
return nil unless recipe_fast_path_safe?(loaded, dirty_worktree_files)
|
|
36
|
+
|
|
37
|
+
run_from_recipes(loaded, dirty_worktree_files)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def apply_selection(mutants)
|
|
41
|
+
dirty_worktree_files = dirty_worktree_changed_files
|
|
42
|
+
loaded = load_survivor_report
|
|
43
|
+
selector = SurvivorSelector.new(survivor_ids: loaded.survivor_ids)
|
|
44
|
+
selected = selector.select(mutants)
|
|
45
|
+
finalize_survivor_split(
|
|
46
|
+
selector,
|
|
47
|
+
selected,
|
|
48
|
+
test_filter(
|
|
49
|
+
loaded,
|
|
50
|
+
dirty_source_files: dirty_source_files?(dirty_worktree_files, git_sha: loaded.git_sha)
|
|
51
|
+
).apply(selected)
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def load_survivor_report
|
|
58
|
+
SurvivorLoader.new(@survivors_from, include_paths: Array(@config.includes)).load
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def run_from_recipes(loaded, dirty_worktree_files)
|
|
62
|
+
recipes = load_activation_recipes(loaded.survivor_ids)
|
|
63
|
+
return nil if recipes.nil?
|
|
64
|
+
|
|
65
|
+
selector, stubs = recipe_selector_and_stubs(loaded.survivor_ids, recipes)
|
|
66
|
+
split = test_filter(
|
|
67
|
+
loaded,
|
|
68
|
+
dirty_source_files: dirty_source_files?(dirty_worktree_files, git_sha: loaded.git_sha)
|
|
69
|
+
).apply(stubs)
|
|
70
|
+
finalize_survivor_split(selector, stubs, split)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def recipe_fast_path_safe?(loaded, dirty_worktree_files)
|
|
74
|
+
!dirty_source_files?(dirty_worktree_files, git_sha: loaded.git_sha)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Builds stub Mutants from recipes and a SurvivorSelector primed with the
|
|
78
|
+
# survivor ID set. The selector is given a synthetic #select call so that
|
|
79
|
+
# #drift_warning? / #unmatched_ids are available (all IDs will be matched).
|
|
80
|
+
def recipe_selector_and_stubs(survivor_ids, recipes)
|
|
81
|
+
stubs = survivor_ids.map { |id| build_stub_mutant(id, recipes[id]) }
|
|
82
|
+
selector = SurvivorSelector.new(survivor_ids:)
|
|
83
|
+
selector.select(stubs)
|
|
84
|
+
[selector, stubs]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Returns the recipe hash if the file exists and covers every survivor ID;
|
|
88
|
+
# otherwise returns nil to trigger the normal generation path.
|
|
89
|
+
def load_activation_recipes(survivor_ids)
|
|
90
|
+
path = File.join(File.dirname(@survivors_from), SurvivorActivationCache::FILENAME)
|
|
91
|
+
recipes = SurvivorActivationCache.load(path)
|
|
92
|
+
return nil if recipes.nil?
|
|
93
|
+
return nil unless survivor_ids.all? { |id| recipes.key?(id) }
|
|
94
|
+
|
|
95
|
+
recipes
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def build_stub_mutant(stable_id, recipe)
|
|
99
|
+
mutant = Mutant.new(
|
|
100
|
+
subject: stub_subject_from_recipe(recipe),
|
|
101
|
+
operator: recipe.fetch("operator"),
|
|
102
|
+
nodes: { original: nil, mutated: nil },
|
|
103
|
+
description: recipe.fetch("description"),
|
|
104
|
+
location: recipe_location(recipe["location"]),
|
|
105
|
+
precomputed_stable_id: stable_id,
|
|
106
|
+
precomputed_activation_source: recipe.fetch("activationSource")
|
|
107
|
+
)
|
|
108
|
+
mutant.covered_by = recipe["coveredBy"]
|
|
109
|
+
mutant
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def stub_subject_from_recipe(recipe)
|
|
113
|
+
Subject.new(
|
|
114
|
+
namespace: recipe["namespace"],
|
|
115
|
+
method_name: recipe["methodName"],
|
|
116
|
+
method_type: (recipe["methodType"] || "instance").to_sym,
|
|
117
|
+
source_location: { file: recipe["sourceFile"], range: nil }
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def recipe_location(loc)
|
|
122
|
+
return {} unless loc.is_a?(Hash)
|
|
123
|
+
|
|
124
|
+
{
|
|
125
|
+
file: loc["file"],
|
|
126
|
+
start_line: loc["startLine"],
|
|
127
|
+
end_line: loc["endLine"],
|
|
128
|
+
start_col: loc["startCol"],
|
|
129
|
+
end_col: loc["endCol"]
|
|
130
|
+
}.compact
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def finalize_survivor_split(selector, selected, split)
|
|
134
|
+
split[:stable].each { |m| m.status = :survived }
|
|
135
|
+
warn_survivor_drift(selector) if selector.drift_warning?
|
|
136
|
+
@survivor_stats = build_survivor_stats(selector, selected, split)
|
|
137
|
+
split[:stable] + split[:pending]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def test_filter(loaded, dirty_source_files:)
|
|
141
|
+
SurvivorTestFilter.new(
|
|
142
|
+
coverage_map: loaded.coverage_map,
|
|
143
|
+
git_sha: loaded.git_sha,
|
|
144
|
+
dirty_source_files:,
|
|
145
|
+
worktree_changed_files: Array(dirty_worktree_changed_files),
|
|
146
|
+
diff_analyzer: @git_diff_analyzer
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def dirty_worktree_changed_files
|
|
151
|
+
@dirty_worktree_changed_files ||= @git_diff_analyzer.working_tree_changed_files
|
|
152
|
+
rescue StandardError
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def dirty_source_files?(dirty_worktree_files, git_sha: nil)
|
|
157
|
+
return true if dirty_worktree_files.nil?
|
|
158
|
+
|
|
159
|
+
all_changed = dirty_worktree_files + committed_changed_files(git_sha)
|
|
160
|
+
include_roots = Array(@config.includes).map { |path| normalize_path(path) }
|
|
161
|
+
all_changed.any? { |path| in_include_root?(normalize_path(path), include_roots) }
|
|
162
|
+
rescue StandardError
|
|
163
|
+
true
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def committed_changed_files(git_sha)
|
|
167
|
+
return [] unless git_sha
|
|
168
|
+
|
|
169
|
+
@git_diff_analyzer.changed_files(from: git_sha, to: "HEAD")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def in_include_root?(path, include_roots)
|
|
173
|
+
include_roots.any? { |root| path == root || path.start_with?("#{root}/") }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def normalize_path(path)
|
|
177
|
+
File.expand_path(path)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def warn_survivor_drift(selector)
|
|
181
|
+
warn "henitai: WARNING: #{selector.unmatched_ids.size} prior survivors " \
|
|
182
|
+
"could not be matched; the source may have drifted - consider a full run"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def build_survivor_stats(selector, selected, split)
|
|
186
|
+
{
|
|
187
|
+
matched: selected.size,
|
|
188
|
+
unmatched_count: selector.unmatched_ids.size,
|
|
189
|
+
unmatched_ids: selector.unmatched_ids,
|
|
190
|
+
skipped_count: split[:stable].size,
|
|
191
|
+
drift_warning: selector.drift_warning?
|
|
192
|
+
}
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -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
|
|
@@ -9,8 +9,11 @@ module Henitai
|
|
|
9
9
|
|
|
10
10
|
def safe_unparse(node)
|
|
11
11
|
Unparser.unparse(node)
|
|
12
|
-
rescue
|
|
13
|
-
|
|
12
|
+
rescue Unparser::UnknownNodeError, Unparser::InvalidNodeError,
|
|
13
|
+
Unparser::UnsupportedNodeError, EncodingError
|
|
14
|
+
# Unparser does not support all AST node types, and some mutated string
|
|
15
|
+
# segments carry incompatible encodings; fall back gracefully for those.
|
|
16
|
+
# Other failures (e.g. NoMethodError) are bugs and must surface.
|
|
14
17
|
fallback_source(node)
|
|
15
18
|
end
|
|
16
19
|
|
|
@@ -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,19 +35,29 @@ 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"
|
|
46
59
|
autoload :SurvivorActivationCache, "henitai/survivor_activation_cache"
|
|
60
|
+
autoload :SurvivorRerunStrategy, "henitai/survivor_rerun_strategy"
|
|
47
61
|
autoload :ScenarioExecutionResult, "henitai/scenario_execution_result"
|
|
48
62
|
autoload :CoverageFormatter, "henitai/coverage_formatter"
|
|
49
63
|
autoload :MinitestCoverageReporter, "henitai/minitest_coverage_reporter"
|
|
@@ -51,9 +65,10 @@ module Henitai
|
|
|
51
65
|
autoload :SyntaxValidator, "henitai/syntax_validator"
|
|
52
66
|
autoload :SamplingStrategy, "henitai/sampling_strategy"
|
|
53
67
|
autoload :TestPrioritizer, "henitai/test_prioritizer"
|
|
68
|
+
autoload :TimeoutCalibrator, "henitai/timeout_calibrator"
|
|
54
69
|
autoload :ExecutionEngine, "henitai/execution_engine"
|
|
55
|
-
autoload :ParallelExecutionRunner, "henitai/parallel_execution_runner"
|
|
56
70
|
autoload :ProcessWorkerRunner, "henitai/process_worker_runner"
|
|
71
|
+
autoload :SlotScheduler, "henitai/slot_scheduler"
|
|
57
72
|
autoload :ProcessWakeup, "henitai/process_wakeup"
|
|
58
73
|
autoload :Runner, "henitai/runner"
|
|
59
74
|
autoload :Reporter, "henitai/reporter"
|
|
@@ -1,29 +1,53 @@
|
|
|
1
1
|
module Henitai
|
|
2
2
|
module ConfigurationValidator
|
|
3
|
+
VALID_TOP_LEVEL_KEYS: Array[Symbol]
|
|
4
|
+
VALID_MUTATION_KEYS: Array[Symbol]
|
|
5
|
+
VALID_SAMPLING_KEYS: Array[Symbol]
|
|
6
|
+
VALID_COVERAGE_CRITERIA_KEYS: Array[Symbol]
|
|
7
|
+
VALID_THRESHOLDS_KEYS: Array[Symbol]
|
|
8
|
+
VALID_DASHBOARD_KEYS: Array[Symbol]
|
|
9
|
+
VALID_INTEGRATION_KEYS: Array[Symbol]
|
|
10
|
+
VALID_OPERATORS: Array[Symbol]
|
|
11
|
+
VALIDATION_STEPS: Array[Symbol]
|
|
12
|
+
|
|
3
13
|
def self.validate!: (Hash[Symbol, untyped]) -> void
|
|
14
|
+
def self.warn: (String) -> void
|
|
4
15
|
|
|
5
|
-
|
|
16
|
+
module Rules
|
|
17
|
+
def self?.validate_top_level_keys: (Hash[Symbol, untyped]) -> void
|
|
18
|
+
def self?.validate_integration: (Hash[Symbol, untyped]) -> void
|
|
19
|
+
def self?.validate_includes: (Hash[Symbol, untyped]) -> void
|
|
20
|
+
def self?.validate_excludes: (Hash[Symbol, untyped]) -> void
|
|
21
|
+
def self?.validate_jobs: (Hash[Symbol, untyped]) -> void
|
|
22
|
+
def self?.validate_reporters: (Hash[Symbol, untyped]) -> void
|
|
23
|
+
def self?.validate_reports_dir: (Hash[Symbol, untyped]) -> void
|
|
24
|
+
def self?.validate_all_logs: (Hash[Symbol, untyped]) -> void
|
|
25
|
+
def self?.validate_dashboard: (Hash[Symbol, untyped]) -> void
|
|
26
|
+
def self?.validate_mutation: (Hash[Symbol, untyped]) -> void
|
|
27
|
+
def self?.validate_mutation_limits: (Hash[Symbol, untyped]) -> void
|
|
28
|
+
def self?.validate_mutation_filters: (Hash[Symbol, untyped]) -> void
|
|
29
|
+
def self?.validate_coverage_criteria: (Hash[Symbol, untyped]) -> void
|
|
30
|
+
def self?.validate_thresholds: (Hash[Symbol, untyped]) -> void
|
|
31
|
+
def self?.validate_sampling: (untyped) -> void
|
|
32
|
+
def self?.warn_unknown_keys: (Hash[Symbol, untyped], Array[Symbol], ?String?) -> void
|
|
33
|
+
def self?.key_path: (String?, Symbol) -> String
|
|
34
|
+
def self?.ensure_hash!: (untyped, String) -> void
|
|
35
|
+
def self?.configuration_error: (String) -> void
|
|
36
|
+
end
|
|
6
37
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def self.validate_optional_string: (untyped, String) -> void
|
|
22
|
-
def self.validate_string_array: (untyped, String) -> void
|
|
23
|
-
def self.warn_unknown_keys: (Hash[Symbol, untyped], Array[Symbol], ?String) -> void
|
|
24
|
-
def self.key_path: (?String, Symbol) -> String
|
|
25
|
-
def self.ensure_hash!: (untyped, String) -> void
|
|
26
|
-
def self.describe_array_type: (untyped) -> String
|
|
27
|
-
def self.configuration_error: (String) -> void
|
|
38
|
+
module Scalars
|
|
39
|
+
def self?.validate_operator: (untyped) -> void
|
|
40
|
+
def self?.validate_timeout: (untyped) -> void
|
|
41
|
+
def self?.validate_threshold: (untyped, String) -> void
|
|
42
|
+
def self?.validate_boolean: (untyped, String) -> void
|
|
43
|
+
def self?.validate_optional_string: (untyped, String) -> void
|
|
44
|
+
def self?.validate_string_array: (untyped, String) -> void
|
|
45
|
+
def self?.validate_ignore_patterns: (untyped) -> void
|
|
46
|
+
def self?.validate_max_flaky_retries: (untyped) -> void
|
|
47
|
+
def self?.validate_sampling_ratio: (untyped) -> void
|
|
48
|
+
def self?.validate_sampling_strategy: (untyped) -> void
|
|
49
|
+
def self?.validate_sampling_completeness: (Hash[Symbol, untyped]) -> void
|
|
50
|
+
def self?.describe_array_type: (untyped) -> String
|
|
51
|
+
end
|
|
28
52
|
end
|
|
29
53
|
end
|