henitai 0.1.8 → 0.2.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.
@@ -25,28 +25,39 @@ 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
28
29
  class Runner
29
30
  attr_reader :config, :result
30
31
 
31
- def initialize(config: Configuration.load, subjects: nil, since: nil)
32
- @config = config
33
- @subjects = subjects
34
- @since = since
32
+ def initialize(config: Configuration.load, subjects: nil, since: nil, survivors_from: nil)
33
+ @config = config
34
+ @subjects = subjects
35
+ @since = since
36
+ @survivors_from = survivors_from
35
37
  end
36
38
 
37
39
  # Entry point — runs the full pipeline and returns a Result.
38
40
  #
39
- # Coverage bootstrap (Gate 0) runs in a background thread so that Gate 1
40
- # (subject resolution) and Gate 2 (mutant generation) proceed concurrently.
41
- # The thread is joined before Gate 3 (static filtering), which is the first
42
- # phase that requires coverage data.
41
+ # Fast path (recipe rerun): when +--survivors-from+ is given and an
42
+ # +activation-recipes.json+ file exists beside the report with entries for
43
+ # all survivor IDs, stub Mutants are built from the recipes and the full
44
+ # source-parse / mutant-generation pipeline is skipped entirely.
45
+ #
46
+ # Normal path: Coverage bootstrap (Gate 0) runs in a background thread so
47
+ # that Gate 1 (subject resolution) and Gate 2 (mutant generation) proceed
48
+ # concurrently. The thread is joined before Gate 3 (static filtering).
43
49
  #
44
50
  # @return [Result]
45
51
  def run
46
52
  started_at = Time.now
47
- source_files = self.source_files
48
- subjects = resolve_subjects(source_files)
49
- mutants = execute_mutants(mutants_for(subjects, source_files))
53
+
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
50
61
 
51
62
  build_result(mutants, started_at, Time.now)
52
63
  end
@@ -75,8 +86,8 @@ module Henitai
75
86
  bootstrap_thread = bootstrap_mutants(source_files)
76
87
  mutants = generate_mutants(subjects)
77
88
  bootstrap_thread.value
78
-
79
- filter_mutants(mutants)
89
+ filtered = filter_mutants(mutants)
90
+ apply_survivor_selection(filtered)
80
91
  end
81
92
 
82
93
  def bootstrap_mutants(source_files)
@@ -109,13 +120,24 @@ module Henitai
109
120
  mutants:,
110
121
  started_at:,
111
122
  finished_at:,
112
- thresholds: result_thresholds
123
+ thresholds: result_thresholds,
124
+ partial_rerun: survivor_rerun?,
125
+ survivor_stats: @survivor_stats,
126
+ git_sha: safe_head_sha
113
127
  )
114
128
  persist_history(@result, finished_at)
115
129
  report(@result)
116
130
  @result
117
131
  end
118
132
 
133
+ def safe_head_sha
134
+ git_diff_analyzer.head_sha
135
+ rescue StandardError
136
+ # `head_sha` rescues Errno::ENOENT. This extra rescue is defensive for
137
+ # unexpected Open3/git runtime errors; conservative fallback is `nil`.
138
+ nil
139
+ end
140
+
119
141
  def bootstrap_coverage(source_files, test_files = nil)
120
142
  coverage_bootstrapper.ensure!(source_files:, config:, integration:, test_files:)
121
143
  end
@@ -200,5 +222,172 @@ module Henitai
200
222
 
201
223
  config.thresholds
202
224
  end
225
+
226
+ def survivor_rerun?
227
+ !@survivors_from.nil?
228
+ end
229
+
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
+ end
246
+
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)
254
+
255
+ run_from_recipes(loaded, dirty_worktree_files)
256
+ end
257
+
258
+ def load_survivor_report
259
+ SurvivorLoader.new(@survivors_from, include_paths: Array(config.includes)).load
260
+ end
261
+
262
+ def run_from_recipes(loaded, dirty_worktree_files)
263
+ recipes = load_activation_recipes(loaded.survivor_ids)
264
+ return nil if recipes.nil?
265
+
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)
272
+ end
273
+
274
+ def recipe_fast_path_safe?(loaded, dirty_worktree_files)
275
+ !dirty_source_files?(dirty_worktree_files, git_sha: loaded.git_sha)
276
+ end
277
+
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]
286
+ end
287
+
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
297
+ end
298
+
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
312
+
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
+ )
320
+ end
321
+
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
332
+ end
333
+
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]
339
+ end
340
+
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
348
+ )
349
+ 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
203
391
  end
392
+ # rubocop:enable Metrics/ClassLength
204
393
  end
@@ -7,7 +7,7 @@ module Henitai
7
7
 
8
8
  def self.build(wait_result:, stdout:, stderr:, log_path:)
9
9
  new(
10
- status: status_for(wait_result),
10
+ status: status_for(wait_result, stdout:, stderr:),
11
11
  stdout: stdout,
12
12
  stderr: stderr,
13
13
  log_path: log_path,
@@ -62,7 +62,7 @@ module Henitai
62
62
  end
63
63
 
64
64
  def should_show_logs?(all_logs: nil)
65
- all_logs || timeout?
65
+ all_logs || timeout? || status == :compile_error
66
66
  end
67
67
 
68
68
  def failure_tail(all_logs: nil, lines: 12)
@@ -77,8 +77,10 @@ module Henitai
77
77
  class << self
78
78
  private
79
79
 
80
- def status_for(wait_result)
80
+ def status_for(wait_result, stdout:, stderr:)
81
81
  return :timeout if wait_result == :timeout
82
+ return :compile_error if no_examples_found?(stdout, stderr)
83
+ return :compile_error if zero_examples_failure?(wait_result, stdout, stderr)
82
84
  return :compile_error if exit_status_for(wait_result) == 2
83
85
  return :survived if wait_result.respond_to?(:success?) && wait_result.success?
84
86
 
@@ -91,6 +93,17 @@ module Henitai
91
93
 
92
94
  wait_result.exitstatus
93
95
  end
96
+
97
+ def no_examples_found?(stdout, stderr)
98
+ stdout.to_s.include?("No examples found.") || stderr.to_s.include?("No examples found.")
99
+ end
100
+
101
+ def zero_examples_failure?(wait_result, stdout, stderr)
102
+ return false unless exit_status_for(wait_result) == 1
103
+
104
+ output = [stdout.to_s, stderr.to_s].join("\n")
105
+ output.include?("0 examples, 0 failures")
106
+ end
94
107
  end
95
108
 
96
109
  def stream_section(name, content)
@@ -37,11 +37,18 @@ module Henitai
37
37
 
38
38
  coverage_lines = coverage_lines_by_file(coverage_report_path)
39
39
  coverage_lines = merge_method_coverage(coverage_lines, coverage_report_path)
40
- return coverage_lines unless coverage_lines.empty?
41
-
42
- coverage_lines_from_test_lines(
40
+ per_test_lines = coverage_lines_from_test_lines(
43
41
  test_lines_by_file(per_test_coverage_report_path)
44
42
  )
43
+
44
+ return per_test_lines if coverage_lines.empty?
45
+
46
+ # Merge per-test coverage into the standard coverage map.
47
+ # Standard coverage may be incomplete when child processes fork before
48
+ # all files are loaded; per-test coverage widens the result set.
49
+ per_test_lines.each_with_object(coverage_lines) do |(file, lines), merged|
50
+ merged[file] = ((merged[file] || []) + lines).uniq.sort
51
+ end
45
52
  end
46
53
 
47
54
  def coverage_lines_by_file(path = DEFAULT_COVERAGE_REPORT_PATH)
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Henitai
7
+ # Stores and retrieves pre-computed +define_method+ activation sources for
8
+ # survived mutants, enabling survivor reruns to skip the full mutant-generation
9
+ # pipeline.
10
+ #
11
+ # The cache artifact (+activation-recipes.json+) is written alongside the
12
+ # session snapshot in +reports/sessions/<session_id>/+. When a survivor rerun
13
+ # finds this file next to the report it was given, it can build stub Mutant
14
+ # objects directly and execute them without re-parsing source files.
15
+ #
16
+ # A recipe entry encodes everything needed to activate and re-report a mutant:
17
+ # the +define_method+ source, subject coordinates, operator, description,
18
+ # location, and the coveredBy test list.
19
+ class SurvivorActivationCache
20
+ FILENAME = "activation-recipes.json"
21
+
22
+ # Build a recipe hash for each survived mutant that has a computable
23
+ # activation source.
24
+ #
25
+ # @param survived_mutants [Array<Mutant>]
26
+ # @return [Hash<String, Hash>] stableId → recipe
27
+ def self.compute(survived_mutants)
28
+ survived_mutants.each_with_object({}) do |mutant, cache|
29
+ source = Mutant::Activator.activation_source_for(mutant)
30
+ next unless source
31
+
32
+ cache[mutant.stable_id] = build_recipe(mutant, source)
33
+ end
34
+ end
35
+
36
+ # @param path [String] path to +activation-recipes.json+
37
+ # @return [Hash, nil] nil when the file is absent or unparseable
38
+ def self.load(path)
39
+ return nil unless File.exist?(path)
40
+
41
+ JSON.parse(File.read(path))
42
+ rescue JSON::ParserError
43
+ nil
44
+ end
45
+
46
+ # @param path [String]
47
+ # @param recipes [Hash<String, Hash>]
48
+ def self.write(path, recipes)
49
+ FileUtils.mkdir_p(File.dirname(path))
50
+ File.write(path, JSON.pretty_generate(recipes))
51
+ end
52
+
53
+ class << self
54
+ private
55
+
56
+ def build_recipe(mutant, activation_source)
57
+ {
58
+ "activationSource" => activation_source,
59
+ "namespace" => mutant.subject.namespace,
60
+ "methodName" => mutant.subject.method_name,
61
+ "methodType" => mutant.subject.method_type.to_s,
62
+ "sourceFile" => mutant.subject.source_file,
63
+ "operator" => mutant.operator,
64
+ "description" => mutant.description,
65
+ "location" => serialize_location(mutant.location),
66
+ "coveredBy" => Array(mutant.covered_by).compact
67
+ }
68
+ end
69
+
70
+ def serialize_location(location)
71
+ {
72
+ "file" => location[:file],
73
+ "startLine" => location[:start_line],
74
+ "endLine" => location[:end_line],
75
+ "startCol" => location[:start_col],
76
+ "endCol" => location[:end_col]
77
+ }.compact
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Henitai
6
+ # Reads a Stryker-compatible mutation report and extracts survivor data.
7
+ #
8
+ # Returns a +Report+ value object carrying:
9
+ # - +survivor_ids+ — stable IDs of survived mutants
10
+ # - +coverage_map+ — stableId → [test_files] from prior coveredBy data
11
+ # - +git_sha+ — git HEAD at the time the report was written (may be nil)
12
+ #
13
+ # Scope validation is intentionally shallow: checks schemaVersion presence
14
+ # and at least one file path overlap with config.includes.
15
+ class SurvivorLoader
16
+ # Value object returned by #load.
17
+ Report = Struct.new(:survivor_ids, :coverage_map, :git_sha)
18
+
19
+ class FileNotFoundError < StandardError; end
20
+ class InvalidReportError < StandardError; end
21
+ class ScopeMismatchError < StandardError; end
22
+
23
+ # @param path [String] path to a Stryker-compatible JSON report
24
+ # @param include_paths [Array<String>] from config.includes; used for scope validation
25
+ def initialize(path, include_paths: [])
26
+ @path = path
27
+ @include_paths = include_paths
28
+ end
29
+
30
+ # @return [Report]
31
+ def load
32
+ raw = read_file
33
+ report = parse_json(raw)
34
+ validate_scope(report)
35
+ build_report(report)
36
+ end
37
+
38
+ private
39
+
40
+ def build_report(report)
41
+ entries = known_entries(report)
42
+ Report.new(
43
+ survivor_ids: extract_survivor_ids(entries),
44
+ coverage_map: extract_coverage_map(entries),
45
+ git_sha: report["gitSha"]
46
+ )
47
+ end
48
+
49
+ # Returns mutant entries that have a stableId, warning about those that don't.
50
+ def known_entries(report)
51
+ all_mutants(report).select do |entry|
52
+ if entry["stableId"]
53
+ true
54
+ else
55
+ warn "henitai: survivor report entry missing stableId — skipping"
56
+ false
57
+ end
58
+ end
59
+ end
60
+
61
+ def extract_survivor_ids(entries)
62
+ entries.filter_map { |e| e["stableId"] if e["status"] == "Survived" }
63
+ end
64
+
65
+ def extract_coverage_map(entries)
66
+ entries.each_with_object({}) do |entry, map|
67
+ covered = Array(entry["coveredBy"]).compact
68
+ map[entry["stableId"]] = covered unless covered.empty?
69
+ end
70
+ end
71
+
72
+ def read_file
73
+ File.read(@path)
74
+ rescue Errno::ENOENT
75
+ raise FileNotFoundError, "Survivor report not found: #{@path}"
76
+ end
77
+
78
+ def parse_json(raw)
79
+ JSON.parse(raw)
80
+ rescue JSON::ParserError => e
81
+ raise InvalidReportError, "Invalid JSON in survivor report #{@path}: #{e.message}"
82
+ end
83
+
84
+ def validate_scope(report)
85
+ validate_schema_version!(report)
86
+ return if @include_paths.empty?
87
+
88
+ report_files = normalized_report_files(report)
89
+ include_dirs_raw = normalized_include_dirs_raw
90
+ include_dirs_abs = normalized_include_dirs_abs(include_dirs_raw)
91
+
92
+ return if any_report_file_overlaps?(report_files, include_dirs_raw, include_dirs_abs)
93
+
94
+ raise ScopeMismatchError,
95
+ "Survivor report #{@path} has no file overlap with configured includes — " \
96
+ "did you pass a report from a different project?"
97
+ end
98
+
99
+ def validate_schema_version!(report)
100
+ return if report.key?("schemaVersion")
101
+
102
+ raise InvalidReportError,
103
+ "Survivor report #{@path} is missing schemaVersion — is this a Henitai report?"
104
+ end
105
+
106
+ def normalized_report_files(report)
107
+ (report.fetch("files", {}) || {}).keys.map { |p| strip_trailing_slash(p.to_s) }
108
+ end
109
+
110
+ def normalized_include_dirs_raw
111
+ @include_paths.map { |p| strip_trailing_slash(p.to_s) }.uniq
112
+ end
113
+
114
+ def normalized_include_dirs_abs(dirs_raw)
115
+ dirs_raw.map { |p| File.expand_path(p) }.uniq
116
+ end
117
+
118
+ def strip_trailing_slash(path)
119
+ path.sub(%r{/\z}, "")
120
+ end
121
+
122
+ def any_report_file_overlaps?(report_files, include_dirs_raw, include_dirs_abs)
123
+ report_files.any? do |file|
124
+ include_dirs_raw.any? { |inc| path_prefix_match?(file, inc) } ||
125
+ include_dirs_abs.any? { |inc_abs| path_prefix_match?(File.expand_path(file), inc_abs) }
126
+ end
127
+ end
128
+
129
+ def path_prefix_match?(path, dir)
130
+ return false if path.empty? || dir.empty?
131
+
132
+ path == dir || path.start_with?(dir + File::SEPARATOR)
133
+ end
134
+
135
+ def all_mutants(report)
136
+ files = report.fetch("files", {}) || {}
137
+ files.values.compact.flat_map { |file_data| file_data.fetch("mutants", []) }
138
+ end
139
+ end
140
+ 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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Henitai
4
- VERSION = "0.1.8"
4
+ VERSION = "0.2.0"
5
5
  end