henitai 0.2.0 → 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 +26 -1
- data/README.md +15 -3
- 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 +16 -404
- data/lib/henitai/configuration.rb +2 -1
- 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/eager_load.rb +36 -5
- data/lib/henitai/execution_engine.rb +4 -3
- 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_test_selection.rb +135 -0
- data/lib/henitai/integration/scenario_log_support.rb +116 -0
- data/lib/henitai/integration.rb +22 -846
- data/lib/henitai/mutant/activator.rb +1 -79
- data/lib/henitai/mutant/parameter_source.rb +98 -0
- data/lib/henitai/mutant.rb +1 -0
- data/lib/henitai/mutant_history_store/sql.rb +72 -0
- data/lib/henitai/mutant_history_store.rb +5 -69
- data/lib/henitai/per_test_coverage_collector.rb +3 -1
- data/lib/henitai/process_worker_runner.rb +48 -334
- data/lib/henitai/reporter.rb +20 -8
- data/lib/henitai/result.rb +17 -15
- data/lib/henitai/runner.rb +59 -182
- 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/survivor_rerun_strategy.rb +195 -0
- data/lib/henitai/unparse_helper.rb +5 -2
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +2 -0
- data/sig/configuration_validator.rbs +46 -22
- data/sig/henitai.rbs +158 -73
- metadata +25 -2
|
@@ -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: false)
|
|
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
|
|
@@ -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
|
|
data/lib/henitai/version.rb
CHANGED
data/lib/henitai.rb
CHANGED
|
@@ -44,6 +44,7 @@ module Henitai
|
|
|
44
44
|
autoload :SurvivorSelector, "henitai/survivor_selector"
|
|
45
45
|
autoload :SurvivorTestFilter, "henitai/survivor_test_filter"
|
|
46
46
|
autoload :SurvivorActivationCache, "henitai/survivor_activation_cache"
|
|
47
|
+
autoload :SurvivorRerunStrategy, "henitai/survivor_rerun_strategy"
|
|
47
48
|
autoload :ScenarioExecutionResult, "henitai/scenario_execution_result"
|
|
48
49
|
autoload :CoverageFormatter, "henitai/coverage_formatter"
|
|
49
50
|
autoload :MinitestCoverageReporter, "henitai/minitest_coverage_reporter"
|
|
@@ -54,6 +55,7 @@ module Henitai
|
|
|
54
55
|
autoload :ExecutionEngine, "henitai/execution_engine"
|
|
55
56
|
autoload :ParallelExecutionRunner, "henitai/parallel_execution_runner"
|
|
56
57
|
autoload :ProcessWorkerRunner, "henitai/process_worker_runner"
|
|
58
|
+
autoload :SlotScheduler, "henitai/slot_scheduler"
|
|
57
59
|
autoload :ProcessWakeup, "henitai/process_wakeup"
|
|
58
60
|
autoload :Runner, "henitai/runner"
|
|
59
61
|
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
|