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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -1
  3. data/README.md +33 -7
  4. data/assets/schema/henitai.schema.json +6 -0
  5. data/lib/henitai/cli/clean_command.rb +48 -0
  6. data/lib/henitai/cli/command_support.rb +51 -0
  7. data/lib/henitai/cli/init_command.rb +64 -0
  8. data/lib/henitai/cli/operator_command.rb +95 -0
  9. data/lib/henitai/cli/options.rb +120 -0
  10. data/lib/henitai/cli/run_command.rb +103 -0
  11. data/lib/henitai/cli.rb +17 -327
  12. data/lib/henitai/configuration.rb +26 -12
  13. data/lib/henitai/configuration_validator/rules.rb +143 -0
  14. data/lib/henitai/configuration_validator/scalars.rb +123 -0
  15. data/lib/henitai/configuration_validator.rb +12 -239
  16. data/lib/henitai/coverage_bootstrapper.rb +24 -24
  17. data/lib/henitai/eager_load.rb +36 -5
  18. data/lib/henitai/execution_engine.rb +6 -11
  19. data/lib/henitai/git_diff_analyzer.rb +34 -0
  20. data/lib/henitai/integration/base.rb +171 -0
  21. data/lib/henitai/integration/child_debug_support.rb +115 -0
  22. data/lib/henitai/integration/child_runtime_control.rb +50 -0
  23. data/lib/henitai/integration/coverage_suppression.rb +43 -0
  24. data/lib/henitai/integration/minitest.rb +133 -0
  25. data/lib/henitai/integration/mutant_run_support.rb +77 -0
  26. data/lib/henitai/integration/rspec_child_runner.rb +61 -0
  27. data/lib/henitai/integration/rspec_process_runner.rb +66 -13
  28. data/lib/henitai/integration/rspec_test_selection.rb +135 -0
  29. data/lib/henitai/integration/scenario_log_support.rb +116 -0
  30. data/lib/henitai/integration.rb +43 -519
  31. data/lib/henitai/mutant/activator.rb +13 -79
  32. data/lib/henitai/mutant/parameter_source.rb +98 -0
  33. data/lib/henitai/mutant.rb +14 -2
  34. data/lib/henitai/mutant_generator.rb +21 -2
  35. data/lib/henitai/mutant_history_store/sql.rb +72 -0
  36. data/lib/henitai/mutant_history_store.rb +12 -91
  37. data/lib/henitai/mutant_identity.rb +34 -0
  38. data/lib/henitai/parallel_execution_runner.rb +29 -11
  39. data/lib/henitai/per_test_coverage_collector.rb +3 -1
  40. data/lib/henitai/process_wakeup.rb +49 -0
  41. data/lib/henitai/process_worker_runner.rb +148 -0
  42. data/lib/henitai/reporter.rb +96 -11
  43. data/lib/henitai/result.rb +49 -16
  44. data/lib/henitai/runner.rb +96 -30
  45. data/lib/henitai/scenario_execution_result.rb +16 -3
  46. data/lib/henitai/slot_scheduler/draining.rb +140 -0
  47. data/lib/henitai/slot_scheduler/process_control.rb +43 -0
  48. data/lib/henitai/slot_scheduler.rb +214 -0
  49. data/lib/henitai/static_filter.rb +10 -3
  50. data/lib/henitai/survivor_activation_cache.rb +81 -0
  51. data/lib/henitai/survivor_loader.rb +140 -0
  52. data/lib/henitai/survivor_rerun_strategy.rb +195 -0
  53. data/lib/henitai/survivor_selector.rb +36 -0
  54. data/lib/henitai/survivor_test_filter.rb +72 -0
  55. data/lib/henitai/unparse_helper.rb +5 -2
  56. data/lib/henitai/version.rb +1 -1
  57. data/lib/henitai.rb +10 -0
  58. data/sig/configuration_validator.rbs +46 -22
  59. data/sig/henitai.rbs +329 -53
  60. metadata +46 -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
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Filters a mutant list to those that match a set of prior survivor stable IDs.
5
+ #
6
+ # After calling #select, #unmatched_ids reports which survivor IDs had no
7
+ # corresponding mutant in the current generation. A high unmatched ratio
8
+ # indicates that the source has drifted and a full run is recommended.
9
+ class SurvivorSelector
10
+ DRIFT_THRESHOLD = 0.5
11
+ class SelectionError < StandardError; end
12
+
13
+ def initialize(survivor_ids:)
14
+ @survivor_ids = survivor_ids.to_set
15
+ @unmatched_ids = nil
16
+ end
17
+
18
+ def select(mutants)
19
+ current_index = mutants.to_h { |m| [m.stable_id, m] }
20
+ matched_ids, @unmatched_ids = @survivor_ids.partition { |id| current_index.key?(id) }
21
+ matched_ids.filter_map { |id| current_index[id] }
22
+ end
23
+
24
+ def unmatched_ids
25
+ raise SelectionError, "Call #select before accessing #unmatched_ids" if @unmatched_ids.nil?
26
+
27
+ @unmatched_ids
28
+ end
29
+
30
+ def drift_warning?
31
+ return false if @survivor_ids.empty?
32
+
33
+ unmatched_ids.size.to_f / @survivor_ids.size > DRIFT_THRESHOLD
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Splits a matched survivor set into stable and pending subsets by consulting
5
+ # a git diff against the covering tests from the prior report.
6
+ #
7
+ # A survivor is **stable** (can skip re-execution) when:
8
+ # - it has covering test data in the prior report, AND
9
+ # - none of those test files appear in the diff between the prior run's
10
+ # git_sha and the current HEAD.
11
+ #
12
+ # A survivor is **pending** (must execute) when:
13
+ # - git_sha is nil (no anchor → conservative), OR
14
+ # - its coveredBy data is absent or empty, OR
15
+ # - at least one covering test file changed.
16
+ #
17
+ # On any git error the filter conservatively treats all survivors as pending.
18
+ class SurvivorTestFilter
19
+ # @param coverage_map [Hash<String, Array<String>>] stableId → [test_files]
20
+ # @param git_sha [String, nil] git SHA from the prior report
21
+ # @param dirty_source_files [Boolean] true when the current worktree has
22
+ # dirty source files and the survivor shortcut must be disabled
23
+ # @param worktree_changed_files [Array<String>] dirty tracked/untracked files
24
+ # @param diff_analyzer [GitDiffAnalyzer]
25
+ def initialize(
26
+ coverage_map:,
27
+ git_sha:,
28
+ dirty_source_files: false,
29
+ worktree_changed_files: [],
30
+ diff_analyzer: GitDiffAnalyzer.new
31
+ )
32
+ @coverage_map = coverage_map
33
+ @git_sha = git_sha
34
+ @dirty_source_files = dirty_source_files
35
+ @worktree_changed_files = Array(worktree_changed_files)
36
+ @diff_analyzer = diff_analyzer
37
+ end
38
+
39
+ # @param mutants [Array<Mutant>]
40
+ # @return [Hash<Symbol, Array<Mutant>>] { stable: [...], pending: [...] }
41
+ def apply(mutants)
42
+ return { stable: [], pending: mutants } if @dirty_source_files
43
+ return { stable: [], pending: mutants } if @git_sha.nil?
44
+
45
+ changed = changed_test_files
46
+ return { stable: [], pending: mutants } if changed.nil?
47
+
48
+ mutants.each_with_object({ stable: [], pending: [] }) do |mutant, result|
49
+ bucket = stable_survivor?(mutant, changed) ? :stable : :pending
50
+ result[bucket] << mutant
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def stable_survivor?(mutant, changed)
57
+ covering = @coverage_map[mutant.stable_id]
58
+ return false if covering.nil? || covering.empty?
59
+
60
+ covering.none? { |test_file| changed.include?(test_file) }
61
+ end
62
+
63
+ # Returns a Set of changed test file paths, or nil on any git error
64
+ # (nil triggers conservative fallback in #apply — all survivors pending).
65
+ def changed_test_files
66
+ committed = @diff_analyzer.changed_files(from: @git_sha, to: "HEAD")
67
+ (committed + @worktree_changed_files).to_set
68
+ rescue StandardError
69
+ nil
70
+ end
71
+ end
72
+ end
@@ -9,8 +9,11 @@ module Henitai
9
9
 
10
10
  def safe_unparse(node)
11
11
  Unparser.unparse(node)
12
- rescue StandardError
13
- # Unparser does not support all AST node types, so fall back gracefully.
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Henitai
4
- VERSION = "0.1.10"
4
+ VERSION = "0.2.1"
5
5
  end
data/lib/henitai.rb CHANGED
@@ -26,6 +26,7 @@ module Henitai
26
26
  autoload :PerTestCoverageSelector, "henitai/per_test_coverage_selector"
27
27
  autoload :Subject, "henitai/subject"
28
28
  autoload :Mutant, "henitai/mutant"
29
+ autoload :MutantIdentity, "henitai/mutant_identity"
29
30
  autoload :Operator, "henitai/operator"
30
31
  autoload :Operators, "henitai/operators"
31
32
  autoload :SourceParser, "henitai/source_parser"
@@ -39,6 +40,11 @@ module Henitai
39
40
  autoload :EquivalenceDetector, "henitai/equivalence_detector"
40
41
  autoload :StaticFilter, "henitai/static_filter"
41
42
  autoload :StillbornFilter, "henitai/stillborn_filter"
43
+ autoload :SurvivorLoader, "henitai/survivor_loader"
44
+ autoload :SurvivorSelector, "henitai/survivor_selector"
45
+ autoload :SurvivorTestFilter, "henitai/survivor_test_filter"
46
+ autoload :SurvivorActivationCache, "henitai/survivor_activation_cache"
47
+ autoload :SurvivorRerunStrategy, "henitai/survivor_rerun_strategy"
42
48
  autoload :ScenarioExecutionResult, "henitai/scenario_execution_result"
43
49
  autoload :CoverageFormatter, "henitai/coverage_formatter"
44
50
  autoload :MinitestCoverageReporter, "henitai/minitest_coverage_reporter"
@@ -47,6 +53,10 @@ module Henitai
47
53
  autoload :SamplingStrategy, "henitai/sampling_strategy"
48
54
  autoload :TestPrioritizer, "henitai/test_prioritizer"
49
55
  autoload :ExecutionEngine, "henitai/execution_engine"
56
+ autoload :ParallelExecutionRunner, "henitai/parallel_execution_runner"
57
+ autoload :ProcessWorkerRunner, "henitai/process_worker_runner"
58
+ autoload :SlotScheduler, "henitai/slot_scheduler"
59
+ autoload :ProcessWakeup, "henitai/process_wakeup"
50
60
  autoload :Runner, "henitai/runner"
51
61
  autoload :Reporter, "henitai/reporter"
52
62
  autoload :Integration, "henitai/integration"
@@ -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
- private
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
- def self.validate_top_level_keys: (Hash[Symbol, untyped]) -> void
8
- def self.validate_integration: (Hash[Symbol, untyped]) -> void
9
- def self.validate_includes: (Hash[Symbol, untyped]) -> void
10
- def self.validate_jobs: (Hash[Symbol, untyped]) -> void
11
- def self.validate_reporters: (Hash[Symbol, untyped]) -> void
12
- def self.validate_all_logs: (Hash[Symbol, untyped]) -> void
13
- def self.validate_dashboard: (Hash[Symbol, untyped]) -> void
14
- def self.validate_mutation: (Hash[Symbol, untyped]) -> void
15
- def self.validate_coverage_criteria: (Hash[Symbol, untyped]) -> void
16
- def self.validate_thresholds: (Hash[Symbol, untyped]) -> void
17
- def self.validate_operator: (untyped) -> void
18
- def self.validate_timeout: (untyped) -> void
19
- def self.validate_threshold: (untyped, String) -> void
20
- def self.validate_boolean: (untyped, String) -> void
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