henitai 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +106 -1
  3. data/README.md +125 -3
  4. data/assets/schema/henitai.schema.json +35 -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 +14 -8
  10. data/lib/henitai/cli/command_support.rb +2 -1
  11. data/lib/henitai/cli/operator_command.rb +2 -1
  12. data/lib/henitai/cli/options.rb +21 -57
  13. data/lib/henitai/cli/run_command.rb +36 -3
  14. data/lib/henitai/cli/run_options.rb +108 -0
  15. data/lib/henitai/cli.rb +12 -4
  16. data/lib/henitai/composite_progress_reporter.rb +42 -0
  17. data/lib/henitai/configuration.rb +41 -9
  18. data/lib/henitai/configuration_validator/rules.rb +18 -0
  19. data/lib/henitai/configuration_validator/scalars.rb +46 -0
  20. data/lib/henitai/configuration_validator.rb +8 -1
  21. data/lib/henitai/coverage_bootstrapper.rb +54 -5
  22. data/lib/henitai/coverage_formatter.rb +5 -1
  23. data/lib/henitai/coverage_report_reader.rb +28 -5
  24. data/lib/henitai/execution_engine/env_scope.rb +67 -0
  25. data/lib/henitai/execution_engine.rb +111 -50
  26. data/lib/henitai/generated_artifacts.rb +58 -0
  27. data/lib/henitai/incremental_filter.rb +100 -0
  28. data/lib/henitai/integration/child_debug_support.rb +6 -2
  29. data/lib/henitai/integration/coverage_suppression.rb +9 -0
  30. data/lib/henitai/integration/minitest.rb +11 -43
  31. data/lib/henitai/integration/minitest_load_path.rb +14 -0
  32. data/lib/henitai/integration/minitest_suite_command.rb +17 -0
  33. data/lib/henitai/integration/minitest_test_runner.rb +39 -0
  34. data/lib/henitai/integration/rails_environment_preloader.rb +14 -0
  35. data/lib/henitai/integration/rspec_child_runner.rb +3 -3
  36. data/lib/henitai/integration/rspec_test_selection.rb +3 -0
  37. data/lib/henitai/integration/scenario_log_support.rb +67 -20
  38. data/lib/henitai/minitest_coverage_reporter.rb +4 -1
  39. data/lib/henitai/mutant/activator.rb +14 -0
  40. data/lib/henitai/mutant.rb +13 -6
  41. data/lib/henitai/mutant_history_store/sql.rb +21 -3
  42. data/lib/henitai/mutant_history_store/verdict_cache.rb +84 -0
  43. data/lib/henitai/mutant_history_store.rb +62 -12
  44. data/lib/henitai/mutant_identity.rb +33 -2
  45. data/lib/henitai/mutation_skip_directives.rb +227 -0
  46. data/lib/henitai/operator.rb +3 -1
  47. data/lib/henitai/operators/equality_identity_operator.rb +51 -0
  48. data/lib/henitai/operators/equality_operator.rb +8 -3
  49. data/lib/henitai/operators.rb +1 -0
  50. data/lib/henitai/per_test_coverage.rb +81 -0
  51. data/lib/henitai/per_test_coverage_collector.rb +20 -5
  52. data/lib/henitai/per_test_coverage_selector.rb +11 -33
  53. data/lib/henitai/reporter/dashboard_metadata_provider.rb +113 -0
  54. data/lib/henitai/reporter.rb +248 -117
  55. data/lib/henitai/reports_directory_lock.rb +76 -0
  56. data/lib/henitai/result.rb +75 -10
  57. data/lib/henitai/runner.rb +102 -43
  58. data/lib/henitai/scenario_execution_result.rb +12 -0
  59. data/lib/henitai/slot_scheduler/draining.rb +21 -15
  60. data/lib/henitai/slot_scheduler.rb +73 -15
  61. data/lib/henitai/static_filter.rb +16 -6
  62. data/lib/henitai/survivor_rerun_strategy.rb +1 -1
  63. data/lib/henitai/survivor_test_filter.rb +1 -1
  64. data/lib/henitai/test_prioritizer.rb +28 -2
  65. data/lib/henitai/timeout_calibrator.rb +44 -0
  66. data/lib/henitai/verdict_fingerprint.rb +155 -0
  67. data/lib/henitai/version.rb +1 -1
  68. data/lib/henitai.rb +14 -1
  69. data/sig/henitai.rbs +235 -25
  70. metadata +22 -3
  71. data/lib/henitai/parallel_execution_runner.rb +0 -153
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+
7
+ module Henitai
8
+ # Coordinates exclusive access to a reports directory across processes.
9
+ class ReportsDirectoryLock
10
+ LOCK_FILENAME = ".henitai-run.lock"
11
+
12
+ def initialize(reports_dir:)
13
+ @reports_dir = File.expand_path(reports_dir)
14
+ end
15
+
16
+ def synchronize
17
+ FileUtils.mkdir_p(@reports_dir)
18
+ File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |file|
19
+ acquire(file)
20
+ write_owner(file)
21
+ yield
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def lock_path
28
+ File.join(@reports_dir, LOCK_FILENAME)
29
+ end
30
+
31
+ def acquire(file)
32
+ return if file.flock(File::LOCK_EX | File::LOCK_NB)
33
+
34
+ raise ConcurrentRunError, contention_message(owner_pid(file))
35
+ end
36
+
37
+ def owner_pid(file)
38
+ file.rewind
39
+ metadata = JSON.parse(file.read)
40
+ pid = metadata["pid"] if metadata.is_a?(Hash)
41
+ pid.is_a?(Integer) ? pid : "unknown"
42
+ rescue JSON::ParserError, IOError, SystemCallError
43
+ "unknown"
44
+ end
45
+
46
+ def contention_message(pid)
47
+ message = "another henitai run is active in #{@reports_dir} " \
48
+ "(pid #{pid}); use a separate reports_dir or wait"
49
+ return message unless dead_owner?(pid)
50
+
51
+ "#{message}. The recorded owner pid #{pid} is not running — an " \
52
+ "orphaned child may still hold the lock " \
53
+ "(check: lsof #{lock_path})"
54
+ end
55
+
56
+ # True only when the recorded owner pid provably no longer exists. EPERM
57
+ # means the process is alive but owned by someone else — treated as alive.
58
+ def dead_owner?(pid)
59
+ return false unless pid.is_a?(Integer)
60
+
61
+ Process.kill(0, pid)
62
+ false
63
+ rescue Errno::ESRCH
64
+ true
65
+ rescue StandardError
66
+ false
67
+ end
68
+
69
+ def write_owner(file)
70
+ file.rewind
71
+ file.truncate(0)
72
+ file.write(JSON.generate(pid: Process.pid, started_at: Time.now.iso8601))
73
+ file.flush
74
+ end
75
+ end
76
+ end
@@ -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
@@ -15,18 +16,24 @@ module Henitai
15
16
  DEFAULT_THRESHOLDS = { high: 80, low: 60 }.freeze
16
17
 
17
18
  attr_reader :mutants, :started_at, :finished_at, :thresholds, :survivor_stats,
18
- :session_id, :git_sha
19
+ :session_id, :git_sha, :since
19
20
 
20
21
  # @param source_provider [#call] maps a file path to its source string.
21
22
  # Injected so the domain object performs no disk IO; the caller (which
22
23
  # already knows the file paths) supplies the contents. The default
23
24
  # provider returns "" for every file — callers that need real source in
24
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.
25
31
  # rubocop:disable Metrics/ParameterLists
26
32
  def initialize(mutants:, started_at:, finished_at:, thresholds: nil,
27
33
  partial_rerun: false, survivor_stats: nil,
28
34
  session_id: SecureRandom.uuid, git_sha: nil,
29
- source_provider: ->(_file) { "" })
35
+ source_provider: ->(_file) { "" }, authoritative: true,
36
+ since: nil)
30
37
  @mutants = mutants
31
38
  @started_at = started_at
32
39
  @finished_at = finished_at
@@ -36,10 +43,13 @@ module Henitai
36
43
  @session_id = session_id
37
44
  @git_sha = git_sha
38
45
  @source_provider = source_provider
46
+ @authoritative = authoritative
47
+ @since = since
39
48
  end
40
49
  # rubocop:enable Metrics/ParameterLists
41
50
 
42
51
  def partial_rerun? = @partial_rerun
52
+ def authoritative? = @authoritative
43
53
 
44
54
  # @return [Integer] number of killed mutants
45
55
  def killed = mutants.count(&:killed?)
@@ -53,7 +63,7 @@ module Henitai
53
63
  # Detected = killed + timeout + runtime_error (alle Zustände die einen Fehler beweisen)
54
64
  # @return [Integer]
55
65
  def detected
56
- mutants.count { |m| %i[killed timeout runtime_error].include?(m.status) }
66
+ detected_in(mutants)
57
67
  end
58
68
 
59
69
  # Mutation Score (MS) — Architektur-Formel aus Abschnitt 6.1:
@@ -66,11 +76,7 @@ module Henitai
66
76
  #
67
77
  # @return [Float, nil] 0.0–100.0, nil wenn kein valider Mutant vorhanden
68
78
  def mutation_score
69
- excluded = %i[ignored no_coverage compile_error equivalent]
70
- valid = mutants.reject { |m| excluded.include?(m.status) }
71
- return nil if valid.empty?
72
-
73
- ((detected.to_f / valid.count) * 100.0).round(2).to_f
79
+ mutation_score_for(mutants)
74
80
  end
75
81
 
76
82
  # Mutation Score Indicator (MSI) — naive Berechnung ohne Äquivalenz-Bereinigung:
@@ -82,9 +88,23 @@ module Henitai
82
88
  #
83
89
  # @return [Float, nil]
84
90
  def mutation_score_indicator
85
- return nil if mutants.empty?
91
+ mutation_score_indicator_for(mutants)
92
+ end
86
93
 
87
- ((killed.to_f / mutants.count) * 100.0).round(2).to_f
94
+ # Scores over only the mutants actually executed this run — verdicts
95
+ # reused from the history store (`--incremental`) excluded. nil when
96
+ # nothing was reused: the combined scores already tell the whole story,
97
+ # and reporters use nil to suppress the extra line.
98
+ #
99
+ # @return [Hash, nil] { mutation_score:, mutation_score_indicator: }
100
+ def executed_scoring_summary
101
+ executed = mutants.reject { |m| m.respond_to?(:from_cache?) && m.from_cache? }
102
+ return nil if executed.size == mutants.size
103
+
104
+ {
105
+ mutation_score: mutation_score_for(executed),
106
+ mutation_score_indicator: mutation_score_indicator_for(executed)
107
+ }
88
108
  end
89
109
 
90
110
  # Compact public summary for reporters.
@@ -119,6 +139,24 @@ module Henitai
119
139
 
120
140
  private
121
141
 
142
+ def detected_in(list)
143
+ list.count { |m| %i[killed timeout runtime_error].include?(m.status) }
144
+ end
145
+
146
+ def mutation_score_for(list)
147
+ excluded = %i[ignored no_coverage compile_error equivalent]
148
+ valid = list.reject { |m| excluded.include?(m.status) }
149
+ return nil if valid.empty?
150
+
151
+ ((detected_in(list).to_f / valid.count) * 100.0).round(2).to_f
152
+ end
153
+
154
+ def mutation_score_indicator_for(list)
155
+ return nil if list.empty?
156
+
157
+ ((list.count(&:killed?).to_f / list.count) * 100.0).round(2).to_f
158
+ end
159
+
122
160
  def base_schema
123
161
  { # : Hash[Symbol, untyped]
124
162
  schemaVersion: SCHEMA_VERSION,
@@ -148,10 +186,13 @@ module Henitai
148
186
  {
149
187
  id: mutant.id,
150
188
  stableId: mutant.stable_id,
189
+ legacyStableId: legacy_stable_id_for(mutant),
151
190
  mutatorName: mutant.operator,
152
191
  replacement: replacement_for(mutant),
153
192
  location: location_for(mutant),
154
193
  status: stryker_status(mutant.status),
194
+ statusReason: status_reason_for(mutant),
195
+ fromCache: from_cache_for(mutant),
155
196
  description: mutant.description,
156
197
  duration: duration_for(mutant)
157
198
  }.compact.merge(coverage_schema(mutant))
@@ -168,6 +209,8 @@ module Henitai
168
209
  end
169
210
 
170
211
  def replacement_for(mutant)
212
+ return mutant.mutated_source if mutant.respond_to?(:mutated_source)
213
+
171
214
  safe_unparse(mutant.mutated_node)
172
215
  end
173
216
 
@@ -190,6 +233,28 @@ module Henitai
190
233
  mutant.duration&.then { |d| (d * 1000).round }
191
234
  end
192
235
 
236
+ # Serialized as the schema's statusReason so directive reasons show up
237
+ # next to the Ignored status in the HTML report.
238
+ def status_reason_for(mutant)
239
+ return nil unless mutant.respond_to?(:ignore_reason)
240
+
241
+ mutant.ignore_reason
242
+ end
243
+
244
+ # Vendored extension field (like stableId): marks verdicts reused from
245
+ # the history store so reports stay honest about what actually executed.
246
+ def from_cache_for(mutant)
247
+ return nil unless mutant.respond_to?(:from_cache?)
248
+
249
+ mutant.from_cache? || nil
250
+ end
251
+
252
+ def legacy_stable_id_for(mutant)
253
+ return if mutant.respond_to?(:precomputed_stable_id) && mutant.precomputed_stable_id
254
+
255
+ MutantIdentity.legacy_stable_id(mutant)
256
+ end
257
+
193
258
  def equivalence_uncertainty
194
259
  return nil if mutation_score.nil?
195
260
 
@@ -28,11 +28,16 @@ module Henitai
28
28
  class Runner
29
29
  attr_reader :config, :result
30
30
 
31
- 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: {})
32
35
  @config = config
33
36
  @subjects = subjects
34
37
  @since = since
35
38
  @survivors_from = survivors_from
39
+ @dry_run = mode.fetch(:dry_run, false)
40
+ @incremental = mode.fetch(:incremental, false)
36
41
  end
37
42
 
38
43
  # Entry point — runs the full pipeline and returns a Result.
@@ -48,21 +53,40 @@ module Henitai
48
53
  #
49
54
  # @return [Result]
50
55
  def run
51
- started_at = Time.now
56
+ ReportsDirectoryLock.new(reports_dir: config.reports_dir).synchronize do
57
+ started_at = Time.now
52
58
 
53
- mutants = if survivor_rerun? && (fast_mutants = survivor_strategy.try_recipe_run)
54
- execute_mutants(fast_mutants)
55
- else
56
- source_files = self.source_files
57
- subjects = resolve_subjects(source_files)
58
- execute_mutants(mutants_for(subjects, source_files))
59
- end
59
+ mutants = pipeline_mutants
60
+ return dry_run_result(mutants, started_at, Time.now) if @dry_run
60
61
 
61
- build_result(mutants, started_at, Time.now)
62
+ build_result(execute_mutants(mutants), started_at, Time.now)
63
+ end
62
64
  end
63
65
 
64
66
  private
65
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
+
66
90
  def resolve_subjects(source_files = self.source_files)
67
91
  subjects = subject_resolver.resolve_from_files(source_files)
68
92
  return subjects if pattern_subjects.empty?
@@ -85,16 +109,27 @@ module Henitai
85
109
  bootstrap_thread = bootstrap_mutants(source_files)
86
110
  mutants = generate_mutants(subjects)
87
111
  bootstrap_thread.value
88
- filtered = filter_mutants(mutants)
112
+ filtered = apply_incremental_filter(filter_mutants(mutants))
89
113
  return filtered unless survivor_rerun?
90
114
 
91
115
  survivor_strategy.apply_selection(filtered)
92
116
  end
93
117
 
94
- def bootstrap_mutants(source_files)
95
- 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)
96
129
  end
97
130
 
131
+ def bootstrap_mutants(source_files) = Thread.new { bootstrap_coverage(source_files) }
132
+
98
133
  def execute_mutants(mutants)
99
134
  execution_engine.run(
100
135
  mutants,
@@ -117,7 +152,14 @@ module Henitai
117
152
  end
118
153
 
119
154
  def build_result(mutants, started_at, finished_at)
120
- @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(
121
163
  mutants:,
122
164
  started_at:,
123
165
  finished_at:,
@@ -125,11 +167,10 @@ module Henitai
125
167
  partial_rerun: survivor_rerun?,
126
168
  survivor_stats: survivor_strategy.survivor_stats,
127
169
  git_sha: safe_head_sha,
128
- source_provider: source_provider
170
+ source_provider: source_provider,
171
+ authoritative: full_run?,
172
+ since: @since
129
173
  )
130
- persist_history(@result, finished_at)
131
- report(@result)
132
- @result
133
174
  end
134
175
 
135
176
  # Reads each source file once and caches it, so Result consumes source
@@ -158,29 +199,17 @@ module Henitai
158
199
  coverage_bootstrapper.ensure!(source_files:, config:, integration:, test_files:)
159
200
  end
160
201
 
161
- def subject_resolver
162
- @subject_resolver ||= SubjectResolver.new
163
- end
202
+ def subject_resolver = @subject_resolver ||= SubjectResolver.new
164
203
 
165
- def git_diff_analyzer
166
- @git_diff_analyzer ||= GitDiffAnalyzer.new
167
- end
204
+ def git_diff_analyzer = @git_diff_analyzer ||= GitDiffAnalyzer.new
168
205
 
169
- def mutant_generator
170
- @mutant_generator ||= MutantGenerator.new
171
- end
206
+ def mutant_generator = @mutant_generator ||= MutantGenerator.new
172
207
 
173
- def static_filter
174
- @static_filter ||= StaticFilter.new
175
- end
208
+ def static_filter = @static_filter ||= StaticFilter.new
176
209
 
177
- def execution_engine
178
- @execution_engine ||= ExecutionEngine.new
179
- end
210
+ def execution_engine = @execution_engine ||= ExecutionEngine.new
180
211
 
181
- def coverage_bootstrapper
182
- @coverage_bootstrapper ||= CoverageBootstrapper.new
183
- end
212
+ def coverage_bootstrapper = @coverage_bootstrapper ||= CoverageBootstrapper.new
184
213
 
185
214
  def integration
186
215
  @integration ||= Integration.for(config.integration).new
@@ -190,18 +219,26 @@ module Henitai
190
219
  @operators ||= Operator.for_set(config.operators)
191
220
  end
192
221
 
222
+ # Fans progress out to the terminal reporter (when enabled) and the
223
+ # checkpoint writer (when enabled and a file report is configured), so a
224
+ # long run persists partial results incrementally.
193
225
  def progress_reporter
194
- return nil unless Array(config.reporters).map(&:to_s).include?("terminal")
195
-
196
- Reporter::Terminal.new(config:)
226
+ CompositeProgressReporter.for(config:, source_provider:, full_run: full_run?)
197
227
  end
198
228
 
199
229
  def history_store
200
230
  @history_store ||= MutantHistoryStore.new(
201
- path: File.join(config.reports_dir, Henitai::HISTORY_STORE_FILENAME)
231
+ path: File.join(config.reports_dir, Henitai::HISTORY_STORE_FILENAME), per_test_coverage:
202
232
  )
203
233
  end
204
234
 
235
+ # One shared live view of the per-test coverage map: the incremental
236
+ # filter proves survivor reuse against it and the history store records
237
+ # the same intersection set — one implementation, one snapshot.
238
+ def per_test_coverage
239
+ @per_test_coverage ||= PerTestCoverage.new(reports_dir: config.reports_dir)
240
+ end
241
+
205
242
  def source_files
206
243
  @source_files ||= filter_changed(reject_excluded(included_source_files))
207
244
  end
@@ -231,12 +268,28 @@ module Henitai
231
268
  def filter_changed(files)
232
269
  return files unless @since
233
270
 
234
- changed_file_set = git_diff_analyzer
235
- .changed_files(from: @since, to: "HEAD")
236
- .map { |path| normalize_path(path) }
271
+ changed_file_set = changed_paths_since.map { |path| normalize_path(path) }
272
+ changed_file_set += covered_sources_for_changed_tests(changed_file_set)
237
273
  files.select { |path| changed_file_set.include?(normalize_path(path)) }
238
274
  end
239
275
 
276
+ # Committed changes since the ref plus the current working tree (tracked
277
+ # dirty and untracked files): the working tree is what gets tested, so it
278
+ # is always part of "changed since REF".
279
+ def changed_paths_since
280
+ git_diff_analyzer.changed_files(from: @since, to: "HEAD") +
281
+ git_diff_analyzer.working_tree_changed_files
282
+ end
283
+
284
+ # Changed test files select the source files they cover, so an edited
285
+ # test re-tests the subjects it can kill. Uses the per-test map from the
286
+ # previous run — Gate 1 runs before this run's bootstrap finishes.
287
+ def covered_sources_for_changed_tests(changed_paths)
288
+ changed_paths
289
+ .flat_map { |path| per_test_coverage.source_files_covered_by(path) }
290
+ .map { |path| normalize_path(path) }
291
+ end
292
+
240
293
  def pattern_subjects
241
294
  Array(@subjects)
242
295
  end
@@ -259,6 +312,12 @@ module Henitai
259
312
  !@survivors_from.nil?
260
313
  end
261
314
 
315
+ # Mutation-scope full run, controlling Result#authoritative? — distinct
316
+ # from the per-test-coverage plan's test-suite-scope "full run".
317
+ def full_run?
318
+ pattern_subjects.empty? && @since.nil? && !survivor_rerun?
319
+ end
320
+
262
321
  def survivor_strategy
263
322
  @survivor_strategy ||= SurvivorRerunStrategy.new(
264
323
  survivors_from: @survivors_from,
@@ -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
@@ -106,24 +106,30 @@ module Henitai
106
106
  # parent signal was sent. Once SIGTERM has been dispatched, the forced
107
107
  # outcome is authoritative — a child handling SIGTERM and exiting 0 must
108
108
  # not be misclassified as :survived.
109
- def reap_and_remove_draining(draining) # rubocop:disable Metrics/AbcSize
110
- draining.each_value do |slot|
111
- # One last WNOHANG before blocking: catches processes that exited
112
- # between SIGKILL and here.
113
- _, final_status = wnohang_reap(slot.pid)
114
- reap_pid(slot.pid) unless final_status
109
+ def reap_and_remove_draining(draining)
110
+ draining.each_value { |slot| reap_and_finalize_slot(slot) }
111
+ end
115
112
 
116
- pid_to_slot.delete(slot.pid)
117
- slots.delete(slot.slot_id)
118
- Integration::SchedulerDiagnostics.child_ended(slot.pid)
113
+ # One last WNOHANG before blocking: catches processes that exited
114
+ # between SIGKILL and here.
115
+ def reap_and_finalize_slot(slot)
116
+ _, final_status = wnohang_reap(slot.pid)
117
+ reap_pid(slot.pid) unless final_status
119
118
 
120
- next if slot.forced_outcome == :interrupted
119
+ pid_to_slot.delete(slot.pid)
120
+ slots.delete(slot.slot_id)
121
+ Integration::SchedulerDiagnostics.child_ended(slot.pid)
121
122
 
122
- result = build_drain_result(slot, final_status)
123
- slot.mutant.status = result.status
124
- results << result
125
- progress_reporter&.progress(slot.mutant, scenario_result: result)
126
- end
123
+ return if slot.forced_outcome == :interrupted
124
+
125
+ record_drain_result(slot, final_status)
126
+ end
127
+
128
+ def record_drain_result(slot, final_status)
129
+ result = build_drain_result(slot, final_status)
130
+ slot.mutant.status = result.status
131
+ results << result
132
+ progress_reporter&.progress(slot.mutant, scenario_result: result)
127
133
  end
128
134
 
129
135
  # Choose result: use real exit status only if observed before any parent