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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +105 -1
  3. data/README.md +138 -5
  4. data/assets/schema/henitai.schema.json +41 -0
  5. data/lib/henitai/available_cpu_count.rb +16 -23
  6. data/lib/henitai/canonical_report_merger.rb +107 -0
  7. data/lib/henitai/canonical_report_writer.rb +22 -0
  8. data/lib/henitai/checkpoint_reporter.rb +79 -0
  9. data/lib/henitai/cli/clean_command.rb +54 -0
  10. data/lib/henitai/cli/command_support.rb +52 -0
  11. data/lib/henitai/cli/init_command.rb +64 -0
  12. data/lib/henitai/cli/operator_command.rb +96 -0
  13. data/lib/henitai/cli/options.rb +84 -0
  14. data/lib/henitai/cli/run_command.rb +136 -0
  15. data/lib/henitai/cli/run_options.rb +108 -0
  16. data/lib/henitai/cli.rb +27 -407
  17. data/lib/henitai/composite_progress_reporter.rb +42 -0
  18. data/lib/henitai/configuration.rb +42 -9
  19. data/lib/henitai/configuration_validator/rules.rb +161 -0
  20. data/lib/henitai/configuration_validator/scalars.rb +169 -0
  21. data/lib/henitai/configuration_validator.rb +20 -240
  22. data/lib/henitai/coverage_bootstrapper.rb +54 -5
  23. data/lib/henitai/coverage_formatter.rb +5 -1
  24. data/lib/henitai/coverage_report_reader.rb +28 -5
  25. data/lib/henitai/eager_load.rb +36 -5
  26. data/lib/henitai/execution_engine/env_scope.rb +67 -0
  27. data/lib/henitai/execution_engine.rb +115 -53
  28. data/lib/henitai/generated_artifacts.rb +58 -0
  29. data/lib/henitai/incremental_filter.rb +100 -0
  30. data/lib/henitai/integration/base.rb +171 -0
  31. data/lib/henitai/integration/child_debug_support.rb +119 -0
  32. data/lib/henitai/integration/child_runtime_control.rb +50 -0
  33. data/lib/henitai/integration/coverage_suppression.rb +52 -0
  34. data/lib/henitai/integration/minitest.rb +101 -0
  35. data/lib/henitai/integration/minitest_load_path.rb +14 -0
  36. data/lib/henitai/integration/minitest_suite_command.rb +17 -0
  37. data/lib/henitai/integration/minitest_test_runner.rb +39 -0
  38. data/lib/henitai/integration/mutant_run_support.rb +77 -0
  39. data/lib/henitai/integration/rails_environment_preloader.rb +14 -0
  40. data/lib/henitai/integration/rspec_child_runner.rb +61 -0
  41. data/lib/henitai/integration/rspec_test_selection.rb +138 -0
  42. data/lib/henitai/integration/scenario_log_support.rb +160 -0
  43. data/lib/henitai/integration.rb +22 -846
  44. data/lib/henitai/minitest_coverage_reporter.rb +4 -1
  45. data/lib/henitai/mutant/activator.rb +15 -79
  46. data/lib/henitai/mutant/parameter_source.rb +98 -0
  47. data/lib/henitai/mutant.rb +14 -6
  48. data/lib/henitai/mutant_history_store/sql.rb +90 -0
  49. data/lib/henitai/mutant_history_store/verdict_cache.rb +84 -0
  50. data/lib/henitai/mutant_history_store.rb +67 -81
  51. data/lib/henitai/mutant_identity.rb +33 -2
  52. data/lib/henitai/mutation_skip_directives.rb +227 -0
  53. data/lib/henitai/operator.rb +3 -1
  54. data/lib/henitai/operators/equality_identity_operator.rb +51 -0
  55. data/lib/henitai/operators/equality_operator.rb +8 -3
  56. data/lib/henitai/operators.rb +1 -0
  57. data/lib/henitai/per_test_coverage.rb +66 -0
  58. data/lib/henitai/per_test_coverage_collector.rb +23 -6
  59. data/lib/henitai/per_test_coverage_selector.rb +11 -33
  60. data/lib/henitai/process_worker_runner.rb +48 -334
  61. data/lib/henitai/reporter/dashboard_metadata_provider.rb +113 -0
  62. data/lib/henitai/reporter.rb +258 -125
  63. data/lib/henitai/reports_directory_lock.rb +76 -0
  64. data/lib/henitai/result.rb +88 -23
  65. data/lib/henitai/runner.rb +137 -218
  66. data/lib/henitai/scenario_execution_result.rb +12 -0
  67. data/lib/henitai/slot_scheduler/draining.rb +146 -0
  68. data/lib/henitai/slot_scheduler/process_control.rb +43 -0
  69. data/lib/henitai/slot_scheduler.rb +272 -0
  70. data/lib/henitai/static_filter.rb +16 -6
  71. data/lib/henitai/survivor_rerun_strategy.rb +195 -0
  72. data/lib/henitai/survivor_test_filter.rb +1 -1
  73. data/lib/henitai/test_prioritizer.rb +28 -2
  74. data/lib/henitai/timeout_calibrator.rb +44 -0
  75. data/lib/henitai/unparse_helper.rb +5 -2
  76. data/lib/henitai/verdict_fingerprint.rb +155 -0
  77. data/lib/henitai/version.rb +1 -1
  78. data/lib/henitai.rb +16 -1
  79. data/sig/configuration_validator.rbs +46 -22
  80. data/sig/henitai.rbs +386 -96
  81. metadata +45 -3
  82. data/lib/henitai/parallel_execution_runner.rb +0 -153
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "securerandom"
4
+ require_relative "mutant_identity"
4
5
  require_relative "unparse_helper"
5
6
 
6
7
  module Henitai
@@ -17,22 +18,36 @@ module Henitai
17
18
  attr_reader :mutants, :started_at, :finished_at, :thresholds, :survivor_stats,
18
19
  :session_id, :git_sha
19
20
 
21
+ # @param source_provider [#call] maps a file path to its source string.
22
+ # Injected so the domain object performs no disk IO; the caller (which
23
+ # already knows the file paths) supplies the contents. The default
24
+ # provider returns "" for every file — callers that need real source in
25
+ # the schema (e.g. the runner) inject a provider primed with file content.
26
+ # @param authoritative [Boolean] whether this run covers the full
27
+ # configured mutation scope. Authoritative runs fully replace the
28
+ # canonical report; non-authoritative (subject-scoped, --since,
29
+ # --survivors-from) runs are merged into it instead — see
30
+ # CanonicalReportMerger.
20
31
  # rubocop:disable Metrics/ParameterLists
21
32
  def initialize(mutants:, started_at:, finished_at:, thresholds: nil,
22
33
  partial_rerun: false, survivor_stats: nil,
23
- session_id: SecureRandom.uuid, git_sha: nil)
24
- @mutants = mutants
25
- @started_at = started_at
26
- @finished_at = finished_at
27
- @thresholds = DEFAULT_THRESHOLDS.merge(thresholds || {})
28
- @partial_rerun = partial_rerun
29
- @survivor_stats = survivor_stats
30
- @session_id = session_id
31
- @git_sha = git_sha
34
+ session_id: SecureRandom.uuid, git_sha: nil,
35
+ source_provider: ->(_file) { "" }, authoritative: true)
36
+ @mutants = mutants
37
+ @started_at = started_at
38
+ @finished_at = finished_at
39
+ @thresholds = DEFAULT_THRESHOLDS.merge(thresholds || {})
40
+ @partial_rerun = partial_rerun
41
+ @survivor_stats = survivor_stats
42
+ @session_id = session_id
43
+ @git_sha = git_sha
44
+ @source_provider = source_provider
45
+ @authoritative = authoritative
32
46
  end
33
47
  # rubocop:enable Metrics/ParameterLists
34
48
 
35
49
  def partial_rerun? = @partial_rerun
50
+ def authoritative? = @authoritative
36
51
 
37
52
  # @return [Integer] number of killed mutants
38
53
  def killed = mutants.count(&:killed?)
@@ -46,7 +61,7 @@ module Henitai
46
61
  # Detected = killed + timeout + runtime_error (alle Zustände die einen Fehler beweisen)
47
62
  # @return [Integer]
48
63
  def detected
49
- mutants.count { |m| %i[killed timeout runtime_error].include?(m.status) }
64
+ detected_in(mutants)
50
65
  end
51
66
 
52
67
  # Mutation Score (MS) — Architektur-Formel aus Abschnitt 6.1:
@@ -59,11 +74,7 @@ module Henitai
59
74
  #
60
75
  # @return [Float, nil] 0.0–100.0, nil wenn kein valider Mutant vorhanden
61
76
  def mutation_score
62
- excluded = %i[ignored no_coverage compile_error equivalent]
63
- valid = mutants.reject { |m| excluded.include?(m.status) }
64
- return nil if valid.empty?
65
-
66
- ((detected.to_f / valid.count) * 100.0).round(2).to_f
77
+ mutation_score_for(mutants)
67
78
  end
68
79
 
69
80
  # Mutation Score Indicator (MSI) — naive Berechnung ohne Äquivalenz-Bereinigung:
@@ -75,9 +86,23 @@ module Henitai
75
86
  #
76
87
  # @return [Float, nil]
77
88
  def mutation_score_indicator
78
- return nil if mutants.empty?
89
+ mutation_score_indicator_for(mutants)
90
+ end
79
91
 
80
- ((killed.to_f / mutants.count) * 100.0).round(2).to_f
92
+ # Scores over only the mutants actually executed this run — verdicts
93
+ # reused from the history store (`--incremental`) excluded. nil when
94
+ # nothing was reused: the combined scores already tell the whole story,
95
+ # and reporters use nil to suppress the extra line.
96
+ #
97
+ # @return [Hash, nil] { mutation_score:, mutation_score_indicator: }
98
+ def executed_scoring_summary
99
+ executed = mutants.reject { |m| m.respond_to?(:from_cache?) && m.from_cache? }
100
+ return nil if executed.size == mutants.size
101
+
102
+ {
103
+ mutation_score: mutation_score_for(executed),
104
+ mutation_score_indicator: mutation_score_indicator_for(executed)
105
+ }
81
106
  end
82
107
 
83
108
  # Compact public summary for reporters.
@@ -112,6 +137,24 @@ module Henitai
112
137
 
113
138
  private
114
139
 
140
+ def detected_in(list)
141
+ list.count { |m| %i[killed timeout runtime_error].include?(m.status) }
142
+ end
143
+
144
+ def mutation_score_for(list)
145
+ excluded = %i[ignored no_coverage compile_error equivalent]
146
+ valid = list.reject { |m| excluded.include?(m.status) }
147
+ return nil if valid.empty?
148
+
149
+ ((detected_in(list).to_f / valid.count) * 100.0).round(2).to_f
150
+ end
151
+
152
+ def mutation_score_indicator_for(list)
153
+ return nil if list.empty?
154
+
155
+ ((list.count(&:killed?).to_f / list.count) * 100.0).round(2).to_f
156
+ end
157
+
115
158
  def base_schema
116
159
  { # : Hash[Symbol, untyped]
117
160
  schemaVersion: SCHEMA_VERSION,
@@ -129,14 +172,9 @@ module Henitai
129
172
 
130
173
  def build_files_section
131
174
  mutants.group_by { |m| m.location[:file] }.transform_values do |file_mutants|
132
- source = begin
133
- File.read(file_mutants.first.location[:file])
134
- rescue StandardError
135
- ""
136
- end
137
175
  {
138
176
  language: "ruby",
139
- source:,
177
+ source: @source_provider.call(file_mutants.first.location[:file]),
140
178
  mutants: file_mutants.map { |m| mutant_to_schema(m) }
141
179
  }
142
180
  end
@@ -146,10 +184,13 @@ module Henitai
146
184
  {
147
185
  id: mutant.id,
148
186
  stableId: mutant.stable_id,
187
+ legacyStableId: legacy_stable_id_for(mutant),
149
188
  mutatorName: mutant.operator,
150
189
  replacement: replacement_for(mutant),
151
190
  location: location_for(mutant),
152
191
  status: stryker_status(mutant.status),
192
+ statusReason: status_reason_for(mutant),
193
+ fromCache: from_cache_for(mutant),
153
194
  description: mutant.description,
154
195
  duration: duration_for(mutant)
155
196
  }.compact.merge(coverage_schema(mutant))
@@ -166,6 +207,8 @@ module Henitai
166
207
  end
167
208
 
168
209
  def replacement_for(mutant)
210
+ return mutant.mutated_source if mutant.respond_to?(:mutated_source)
211
+
169
212
  safe_unparse(mutant.mutated_node)
170
213
  end
171
214
 
@@ -188,6 +231,28 @@ module Henitai
188
231
  mutant.duration&.then { |d| (d * 1000).round }
189
232
  end
190
233
 
234
+ # Serialized as the schema's statusReason so directive reasons show up
235
+ # next to the Ignored status in the HTML report.
236
+ def status_reason_for(mutant)
237
+ return nil unless mutant.respond_to?(:ignore_reason)
238
+
239
+ mutant.ignore_reason
240
+ end
241
+
242
+ # Vendored extension field (like stableId): marks verdicts reused from
243
+ # the history store so reports stay honest about what actually executed.
244
+ def from_cache_for(mutant)
245
+ return nil unless mutant.respond_to?(:from_cache?)
246
+
247
+ mutant.from_cache? || nil
248
+ end
249
+
250
+ def legacy_stable_id_for(mutant)
251
+ return if mutant.respond_to?(:precomputed_stable_id) && mutant.precomputed_stable_id
252
+
253
+ MutantIdentity.legacy_stable_id(mutant)
254
+ end
255
+
191
256
  def equivalence_uncertainty
192
257
  return nil if mutation_score.nil?
193
258
 
@@ -25,15 +25,19 @@ module Henitai
25
25
  # Gate 5 — Reporting
26
26
  # Write results to configured reporters (terminal, html, json, dashboard).
27
27
  #
28
- # rubocop:disable Metrics/ClassLength
29
28
  class Runner
30
29
  attr_reader :config, :result
31
30
 
32
- def initialize(config: Configuration.load, subjects: nil, since: nil, survivors_from: nil)
31
+ # @param mode [Hash] execution-mode flags: +dry_run:+ stops before Gate 4,
32
+ # +incremental:+ reuses still-valid Killed verdicts from history.
33
+ def initialize(config: Configuration.load, subjects: nil, since: nil, survivors_from: nil,
34
+ mode: {})
33
35
  @config = config
34
36
  @subjects = subjects
35
37
  @since = since
36
38
  @survivors_from = survivors_from
39
+ @dry_run = mode.fetch(:dry_run, false)
40
+ @incremental = mode.fetch(:incremental, false)
37
41
  end
38
42
 
39
43
  # Entry point — runs the full pipeline and returns a Result.
@@ -49,21 +53,40 @@ module Henitai
49
53
  #
50
54
  # @return [Result]
51
55
  def run
52
- started_at = Time.now
56
+ ReportsDirectoryLock.new(reports_dir: config.reports_dir).synchronize do
57
+ started_at = Time.now
53
58
 
54
- mutants = if survivor_rerun? && (fast_mutants = try_recipe_run)
55
- execute_mutants(fast_mutants)
56
- else
57
- source_files = self.source_files
58
- subjects = resolve_subjects(source_files)
59
- execute_mutants(mutants_for(subjects, source_files))
60
- end
59
+ mutants = pipeline_mutants
60
+ return dry_run_result(mutants, started_at, Time.now) if @dry_run
61
61
 
62
- build_result(mutants, started_at, Time.now)
62
+ build_result(execute_mutants(mutants), started_at, Time.now)
63
+ end
63
64
  end
64
65
 
65
66
  private
66
67
 
68
+ # Gates 0–3 only: coverage bootstrap, subject resolution, generation and
69
+ # static/skip/arid/stillborn filtering — everything short of execution.
70
+ def pipeline_mutants
71
+ if survivor_rerun? && (fast_mutants = survivor_strategy.try_recipe_run)
72
+ return fast_mutants
73
+ end
74
+
75
+ source_files = self.source_files
76
+ subjects = resolve_subjects(source_files)
77
+ mutants_for(subjects, source_files)
78
+ end
79
+
80
+ # Dry run stops before Gate 4: prints the post-filter listing and returns
81
+ # a Result without executing mutants, persisting history or running the
82
+ # configured reporters. Gate 0 and lock coordination may still write under
83
+ # reports_dir.
84
+ def dry_run_result(mutants, started_at, finished_at)
85
+ @result = build_result_object(mutants, started_at, finished_at)
86
+ Reporter::DryRun.new(config:).report(@result)
87
+ @result
88
+ end
89
+
67
90
  def resolve_subjects(source_files = self.source_files)
68
91
  subjects = subject_resolver.resolve_from_files(source_files)
69
92
  return subjects if pattern_subjects.empty?
@@ -86,14 +109,27 @@ module Henitai
86
109
  bootstrap_thread = bootstrap_mutants(source_files)
87
110
  mutants = generate_mutants(subjects)
88
111
  bootstrap_thread.value
89
- filtered = filter_mutants(mutants)
90
- apply_survivor_selection(filtered)
112
+ filtered = apply_incremental_filter(filter_mutants(mutants))
113
+ return filtered unless survivor_rerun?
114
+
115
+ survivor_strategy.apply_selection(filtered)
91
116
  end
92
117
 
93
- def bootstrap_mutants(source_files)
94
- Thread.new { bootstrap_coverage(source_files) }
118
+ # Opt-in verdict reuse (`--incremental`): still-valid Killed and Survived
119
+ # verdicts from the history store are marked with their stored status +
120
+ # from_cache before execution. The filter is only ever constructed when
121
+ # the flag is set; it runs after the coverage bootstrap join in
122
+ # mutants_for, so the live per-test map it reads is never mid-write.
123
+ def apply_incremental_filter(mutants)
124
+ return mutants unless @incremental
125
+
126
+ IncrementalFilter.new(history_store:, per_test_coverage:,
127
+ dependency_fingerprint: VerdictFingerprint.dependency_fingerprint)
128
+ .apply(mutants)
95
129
  end
96
130
 
131
+ def bootstrap_mutants(source_files) = Thread.new { bootstrap_coverage(source_files) }
132
+
97
133
  def execute_mutants(mutants)
98
134
  execution_engine.run(
99
135
  mutants,
@@ -104,7 +140,7 @@ module Henitai
104
140
  end
105
141
 
106
142
  def report(result)
107
- Reporter.run_all(names: config.reporters, result:, config:)
143
+ Reporter.run_all(names: config.reporters, result:, config:, history_store:)
108
144
  end
109
145
 
110
146
  def persist_history(result, recorded_at)
@@ -116,18 +152,38 @@ module Henitai
116
152
  end
117
153
 
118
154
  def build_result(mutants, started_at, finished_at)
119
- @result = Result.new(
155
+ @result = build_result_object(mutants, started_at, finished_at)
156
+ persist_history(@result, finished_at)
157
+ report(@result)
158
+ @result
159
+ end
160
+
161
+ def build_result_object(mutants, started_at, finished_at)
162
+ Result.new(
120
163
  mutants:,
121
164
  started_at:,
122
165
  finished_at:,
123
166
  thresholds: result_thresholds,
124
167
  partial_rerun: survivor_rerun?,
125
- survivor_stats: @survivor_stats,
126
- git_sha: safe_head_sha
168
+ survivor_stats: survivor_strategy.survivor_stats,
169
+ git_sha: safe_head_sha,
170
+ source_provider: source_provider,
171
+ authoritative: full_run?
127
172
  )
128
- persist_history(@result, finished_at)
129
- report(@result)
130
- @result
173
+ end
174
+
175
+ # Reads each source file once and caches it, so Result consumes source
176
+ # content while performing no disk IO of its own. Returns "" for files that
177
+ # cannot be read (e.g. recipe stubs with synthetic locations).
178
+ def source_provider
179
+ cache = {} # : Hash[String, String]
180
+ lambda do |file|
181
+ cache[file] ||= begin
182
+ File.read(file)
183
+ rescue StandardError
184
+ ""
185
+ end
186
+ end
131
187
  end
132
188
 
133
189
  def safe_head_sha
@@ -142,29 +198,17 @@ module Henitai
142
198
  coverage_bootstrapper.ensure!(source_files:, config:, integration:, test_files:)
143
199
  end
144
200
 
145
- def subject_resolver
146
- @subject_resolver ||= SubjectResolver.new
147
- end
201
+ def subject_resolver = @subject_resolver ||= SubjectResolver.new
148
202
 
149
- def git_diff_analyzer
150
- @git_diff_analyzer ||= GitDiffAnalyzer.new
151
- end
203
+ def git_diff_analyzer = @git_diff_analyzer ||= GitDiffAnalyzer.new
152
204
 
153
- def mutant_generator
154
- @mutant_generator ||= MutantGenerator.new
155
- end
205
+ def mutant_generator = @mutant_generator ||= MutantGenerator.new
156
206
 
157
- def static_filter
158
- @static_filter ||= StaticFilter.new
159
- end
207
+ def static_filter = @static_filter ||= StaticFilter.new
160
208
 
161
- def execution_engine
162
- @execution_engine ||= ExecutionEngine.new
163
- end
209
+ def execution_engine = @execution_engine ||= ExecutionEngine.new
164
210
 
165
- def coverage_bootstrapper
166
- @coverage_bootstrapper ||= CoverageBootstrapper.new
167
- end
211
+ def coverage_bootstrapper = @coverage_bootstrapper ||= CoverageBootstrapper.new
168
212
 
169
213
  def integration
170
214
  @integration ||= Integration.for(config.integration).new
@@ -174,220 +218,95 @@ module Henitai
174
218
  @operators ||= Operator.for_set(config.operators)
175
219
  end
176
220
 
221
+ # Fans progress out to the terminal reporter (when enabled) and the
222
+ # checkpoint writer (when enabled and a file report is configured), so a
223
+ # long run persists partial results incrementally.
177
224
  def progress_reporter
178
- return nil unless Array(config.reporters).map(&:to_s).include?("terminal")
179
-
180
- Reporter::Terminal.new(config:)
225
+ CompositeProgressReporter.for(config:, source_provider:, full_run: full_run?)
181
226
  end
182
227
 
183
228
  def history_store
184
229
  @history_store ||= MutantHistoryStore.new(
185
- path: File.join(config.reports_dir, Henitai::HISTORY_STORE_FILENAME)
230
+ path: File.join(config.reports_dir, Henitai::HISTORY_STORE_FILENAME), per_test_coverage:
186
231
  )
187
232
  end
188
233
 
189
- def source_files
190
- @source_files ||= begin
191
- included_files = Array(config.includes).flat_map do |include_path|
192
- Dir.glob(File.join(include_path, "**", "*.rb"))
193
- end.uniq
194
-
195
- if @since
196
- changed_files = git_diff_analyzer.changed_files(from: @since, to: "HEAD")
197
- changed_file_set = changed_files.map { |path| normalize_path(path) }
198
-
199
- included_files.select do |path|
200
- changed_file_set.include?(normalize_path(path))
201
- end
202
- else
203
- included_files
204
- end
205
- end
206
- end
207
-
208
- def pattern_subjects
209
- Array(@subjects)
210
- end
211
-
212
- def unique_subjects(subjects)
213
- subjects.uniq { |subject| [subject.expression, subject.source_file] }
214
- end
215
-
216
- def normalize_path(path)
217
- File.expand_path(path)
218
- end
219
-
220
- def result_thresholds
221
- return nil unless config.respond_to?(:thresholds)
222
-
223
- config.thresholds
234
+ # One shared live view of the per-test coverage map: the incremental
235
+ # filter proves survivor reuse against it and the history store records
236
+ # the same intersection set — one implementation, one snapshot.
237
+ def per_test_coverage
238
+ @per_test_coverage ||= PerTestCoverage.new(reports_dir: config.reports_dir)
224
239
  end
225
240
 
226
- def survivor_rerun?
227
- !@survivors_from.nil?
241
+ def source_files
242
+ @source_files ||= filter_changed(reject_excluded(included_source_files))
228
243
  end
229
244
 
230
- def apply_survivor_selection(mutants)
231
- return mutants unless survivor_rerun?
232
-
233
- dirty_worktree_files = dirty_worktree_changed_files
234
- loaded = SurvivorLoader.new(@survivors_from, include_paths: Array(config.includes)).load
235
- selector = SurvivorSelector.new(survivor_ids: loaded.survivor_ids)
236
- selected = selector.select(mutants)
237
- finalize_survivor_split(
238
- selector,
239
- selected,
240
- test_filter(
241
- loaded,
242
- dirty_source_files: dirty_source_files?(dirty_worktree_files, git_sha: loaded.git_sha)
243
- ).apply(selected)
244
- )
245
+ def included_source_files
246
+ Array(config.includes).flat_map do |include_path|
247
+ Dir.glob(File.join(include_path, "**", "*.rb"))
248
+ end.uniq
245
249
  end
246
250
 
247
- # Attempts to run survivors directly from pre-computed activation recipes,
248
- # bypassing source parsing and mutant generation entirely.
249
- # Returns the mutant array on success, or nil if recipes are unavailable.
250
- def try_recipe_run
251
- dirty_worktree_files = dirty_worktree_changed_files
252
- loaded = load_survivor_report
253
- return nil unless recipe_fast_path_safe?(loaded, dirty_worktree_files)
251
+ # Drops files matched by any `excludes:` glob (e.g. standalone entry points
252
+ # that cannot be mutation-tested in-process). Excludes apply regardless of
253
+ # the --since filter.
254
+ def reject_excluded(files)
255
+ excluded = excluded_source_files
256
+ return files if excluded.empty?
254
257
 
255
- run_from_recipes(loaded, dirty_worktree_files)
258
+ files.reject { |path| excluded.include?(normalize_path(path)) }
256
259
  end
257
260
 
258
- def load_survivor_report
259
- SurvivorLoader.new(@survivors_from, include_paths: Array(config.includes)).load
261
+ def excluded_source_files
262
+ Array(config.excludes)
263
+ .flat_map { |pattern| Dir.glob(pattern) }
264
+ .map { |path| normalize_path(path) }
260
265
  end
261
266
 
262
- def run_from_recipes(loaded, dirty_worktree_files)
263
- recipes = load_activation_recipes(loaded.survivor_ids)
264
- return nil if recipes.nil?
267
+ def filter_changed(files)
268
+ return files unless @since
265
269
 
266
- selector, stubs = recipe_selector_and_stubs(loaded.survivor_ids, recipes)
267
- split = test_filter(
268
- loaded,
269
- dirty_source_files: dirty_source_files?(dirty_worktree_files, git_sha: loaded.git_sha)
270
- ).apply(stubs)
271
- finalize_survivor_split(selector, stubs, split)
270
+ changed_file_set = git_diff_analyzer
271
+ .changed_files(from: @since, to: "HEAD")
272
+ .map { |path| normalize_path(path) }
273
+ files.select { |path| changed_file_set.include?(normalize_path(path)) }
272
274
  end
273
275
 
274
- def recipe_fast_path_safe?(loaded, dirty_worktree_files)
275
- !dirty_source_files?(dirty_worktree_files, git_sha: loaded.git_sha)
276
+ def pattern_subjects
277
+ Array(@subjects)
276
278
  end
277
279
 
278
- # Builds stub Mutants from recipes and a SurvivorSelector primed with the
279
- # survivor ID set. The selector is given a synthetic #select call so that
280
- # #drift_warning? / #unmatched_ids are available (all IDs will be matched).
281
- def recipe_selector_and_stubs(survivor_ids, recipes)
282
- stubs = survivor_ids.map { |id| build_stub_mutant(id, recipes[id]) }
283
- selector = SurvivorSelector.new(survivor_ids:)
284
- selector.select(stubs)
285
- [selector, stubs]
280
+ def unique_subjects(subjects)
281
+ subjects.uniq { |subject| [subject.expression, subject.source_file] }
286
282
  end
287
283
 
288
- # Returns the recipe hash if the file exists and covers every survivor ID;
289
- # otherwise returns nil to trigger the normal generation path.
290
- def load_activation_recipes(survivor_ids)
291
- path = File.join(File.dirname(@survivors_from), SurvivorActivationCache::FILENAME)
292
- recipes = SurvivorActivationCache.load(path)
293
- return nil if recipes.nil?
294
- return nil unless survivor_ids.all? { |id| recipes.key?(id) }
295
-
296
- recipes
284
+ def normalize_path(path)
285
+ File.expand_path(path)
297
286
  end
298
287
 
299
- def build_stub_mutant(stable_id, recipe)
300
- mutant = Mutant.new(
301
- subject: stub_subject_from_recipe(recipe),
302
- operator: recipe.fetch("operator"),
303
- nodes: { original: nil, mutated: nil },
304
- description: recipe.fetch("description"),
305
- location: recipe_location(recipe["location"]),
306
- precomputed_stable_id: stable_id,
307
- precomputed_activation_source: recipe.fetch("activationSource")
308
- )
309
- mutant.covered_by = recipe["coveredBy"]
310
- mutant
311
- end
288
+ def result_thresholds
289
+ return nil unless config.respond_to?(:thresholds)
312
290
 
313
- def stub_subject_from_recipe(recipe)
314
- Subject.new(
315
- namespace: recipe["namespace"],
316
- method_name: recipe["methodName"],
317
- method_type: (recipe["methodType"] || "instance").to_sym,
318
- source_location: { file: recipe["sourceFile"], range: nil }
319
- )
291
+ config.thresholds
320
292
  end
321
293
 
322
- def recipe_location(loc)
323
- return {} unless loc.is_a?(Hash)
324
-
325
- {
326
- file: loc["file"],
327
- start_line: loc["startLine"],
328
- end_line: loc["endLine"],
329
- start_col: loc["startCol"],
330
- end_col: loc["endCol"]
331
- }.compact
294
+ def survivor_rerun?
295
+ !@survivors_from.nil?
332
296
  end
333
297
 
334
- def finalize_survivor_split(selector, selected, split)
335
- split[:stable].each { |m| m.status = :survived }
336
- warn_survivor_drift(selector) if selector.drift_warning?
337
- @survivor_stats = build_survivor_stats(selector, selected, split)
338
- split[:stable] + split[:pending]
298
+ # Mutation-scope full run, controlling Result#authoritative? — distinct
299
+ # from the per-test-coverage plan's test-suite-scope "full run".
300
+ def full_run?
301
+ pattern_subjects.empty? && @since.nil? && !survivor_rerun?
339
302
  end
340
303
 
341
- def test_filter(loaded, dirty_source_files: false)
342
- SurvivorTestFilter.new(
343
- coverage_map: loaded.coverage_map,
344
- git_sha: loaded.git_sha,
345
- dirty_source_files:,
346
- worktree_changed_files: Array(dirty_worktree_changed_files),
347
- diff_analyzer: git_diff_analyzer
304
+ def survivor_strategy
305
+ @survivor_strategy ||= SurvivorRerunStrategy.new(
306
+ survivors_from: @survivors_from,
307
+ config:,
308
+ git_diff_analyzer:
348
309
  )
349
310
  end
350
-
351
- def dirty_worktree_changed_files
352
- @dirty_worktree_changed_files ||= git_diff_analyzer.working_tree_changed_files
353
- rescue StandardError
354
- nil
355
- end
356
-
357
- def dirty_source_files?(dirty_worktree_files, git_sha: nil)
358
- return true if dirty_worktree_files.nil?
359
-
360
- all_changed = dirty_worktree_files + committed_changed_files(git_sha)
361
- include_roots = Array(config.includes).map { |path| normalize_path(path) }
362
- all_changed.any? { |path| in_include_root?(normalize_path(path), include_roots) }
363
- rescue StandardError
364
- true
365
- end
366
-
367
- def committed_changed_files(git_sha)
368
- return [] unless git_sha
369
-
370
- git_diff_analyzer.changed_files(from: git_sha, to: "HEAD")
371
- end
372
-
373
- def in_include_root?(path, include_roots)
374
- include_roots.any? { |root| path == root || path.start_with?("#{root}/") }
375
- end
376
-
377
- def warn_survivor_drift(selector)
378
- warn "henitai: WARNING: #{selector.unmatched_ids.size} prior survivors " \
379
- "could not be matched; the source may have drifted - consider a full run"
380
- end
381
-
382
- def build_survivor_stats(selector, selected, split)
383
- {
384
- matched: selected.size,
385
- unmatched_count: selector.unmatched_ids.size,
386
- unmatched_ids: selector.unmatched_ids,
387
- skipped_count: split[:stable].size,
388
- drift_warning: selector.drift_warning?
389
- }
390
- end
391
311
  end
392
- # rubocop:enable Metrics/ClassLength
393
312
  end
@@ -23,6 +23,18 @@ module Henitai
23
23
  @exit_status = exit_status
24
24
  end
25
25
 
26
+ # Frees the captured child output once it has been consumed (e.g. by the
27
+ # progress reporter). The scheduler retains one result per mutant for the
28
+ # whole run; without this a runaway mutant's multi-hundred-MB output would
29
+ # stay resident until the run ends. Status/log_path/exit_status are kept so
30
+ # the result stays usable for scoring and log lookups.
31
+ def release_output!
32
+ @stdout = ""
33
+ @stderr = ""
34
+ @log_text = nil
35
+ self
36
+ end
37
+
26
38
  def survived?
27
39
  status == :survived
28
40
  end