henitai 0.1.2 → 0.1.3

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -1
  3. data/README.md +3 -1
  4. data/assets/schema/henitai.schema.json +0 -4
  5. data/lib/henitai/arid_node_filter.rb +3 -0
  6. data/lib/henitai/available_cpu_count.rb +79 -0
  7. data/lib/henitai/cli.rb +7 -4
  8. data/lib/henitai/configuration.rb +3 -5
  9. data/lib/henitai/configuration_validator.rb +1 -11
  10. data/lib/henitai/coverage_bootstrapper.rb +112 -7
  11. data/lib/henitai/coverage_report_reader.rb +67 -0
  12. data/lib/henitai/eager_load.rb +11 -0
  13. data/lib/henitai/equivalence_detector.rb +60 -1
  14. data/lib/henitai/execution_engine.rb +34 -22
  15. data/lib/henitai/integration/rspec_process_runner.rb +58 -0
  16. data/lib/henitai/integration.rb +192 -90
  17. data/lib/henitai/mutant.rb +3 -1
  18. data/lib/henitai/mutant_generator.rb +25 -48
  19. data/lib/henitai/operator.rb +6 -1
  20. data/lib/henitai/operators/assignment_expression.rb +7 -23
  21. data/lib/henitai/operators/conditional_expression.rb +1 -7
  22. data/lib/henitai/operators/method_chain_unwrap.rb +41 -0
  23. data/lib/henitai/operators/regex_mutator.rb +89 -0
  24. data/lib/henitai/operators/unary_operator.rb +36 -0
  25. data/lib/henitai/operators/update_operator.rb +70 -0
  26. data/lib/henitai/operators.rb +4 -0
  27. data/lib/henitai/parallel_execution_runner.rb +135 -0
  28. data/lib/henitai/per_test_coverage_selector.rb +60 -0
  29. data/lib/henitai/result.rb +16 -4
  30. data/lib/henitai/runner.rb +75 -11
  31. data/lib/henitai/source_parser.rb +12 -1
  32. data/lib/henitai/static_filter.rb +20 -41
  33. data/lib/henitai/version.rb +1 -1
  34. data/lib/henitai.rb +3 -0
  35. data/sig/henitai.rbs +59 -10
  36. metadata +16 -3
@@ -4,13 +4,14 @@ require_relative "../parser_current"
4
4
 
5
5
  module Henitai
6
6
  module Operators
7
- # Mutates compound assignments and reduces ||= to a plain assignment.
7
+ # Reduces ||= to a plain assignment, removing the memoization guard.
8
+ #
9
+ # Arithmetic compound assignments (+=, -=, *=, /=) are covered by
10
+ # UpdateOperator, which also handles the logical pair swap (||= ↔ &&=).
11
+ # AssignmentExpression is intentionally scoped to or_asgn reduction only
12
+ # to avoid emitting duplicate mutants in the full operator set.
8
13
  class AssignmentExpression < Henitai::Operator
9
- NODE_TYPES = %i[op_asgn or_asgn].freeze
10
- OPERATOR_MAP = {
11
- :+ => :-,
12
- :- => :+
13
- }.freeze
14
+ NODE_TYPES = %i[or_asgn].freeze
14
15
 
15
16
  def self.node_types
16
17
  NODE_TYPES
@@ -18,8 +19,6 @@ module Henitai
18
19
 
19
20
  def mutate(node, subject:)
20
21
  case node.type
21
- when :op_asgn
22
- mutate_compound_assignment(node, subject:)
23
22
  when :or_asgn
24
23
  # Memoization-style ||= is usually filtered earlier by AridNodeFilter.
25
24
  mutate_coalesce_assignment(node, subject:)
@@ -30,21 +29,6 @@ module Henitai
30
29
 
31
30
  private
32
31
 
33
- def mutate_compound_assignment(node, subject:)
34
- left, operator, right = node.children
35
- replacement = OPERATOR_MAP[operator]
36
- return [] unless replacement
37
-
38
- [
39
- build_mutant(
40
- subject:,
41
- original_node: node,
42
- mutated_node: Parser::AST::Node.new(:op_asgn, [left, replacement, right]),
43
- description: "replaced #{operator} with #{replacement}"
44
- )
45
- ]
46
- end
47
-
48
32
  def mutate_coalesce_assignment(node, subject:)
49
33
  left, right = node.children
50
34
  mutated_node = assignment_node(left, right)
@@ -74,13 +74,7 @@ module Henitai
74
74
  end
75
75
 
76
76
  def case_children(children)
77
- return [[], nil] if children.empty?
78
-
79
- if children.last&.type == :when
80
- [children, nil]
81
- else
82
- [children[0...-1], children.last]
83
- end
77
+ [children[0...-1], children.last]
84
78
  end
85
79
 
86
80
  def condition_variants(node, subject:, condition:)
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../parser_current"
4
+
5
+ module Henitai
6
+ module Operators
7
+ # Removes individual links from a method chain by replacing the outer
8
+ # send node with its receiver.
9
+ #
10
+ # Only fires when the immediate receiver is itself a :send node, which
11
+ # naturally excludes block-receiver chains (list.select { }.count) and
12
+ # standalone calls.
13
+ #
14
+ # Example: array.uniq.sort.first
15
+ # → array.uniq.sort (removed .first)
16
+ # → array.uniq (removed .sort)
17
+ # → array (removed .uniq) — via the :uniq node
18
+ class MethodChainUnwrap < Henitai::Operator
19
+ NODE_TYPES = %i[send].freeze
20
+
21
+ def self.node_types
22
+ NODE_TYPES
23
+ end
24
+
25
+ def mutate(node, subject:)
26
+ receiver = node.children[0]
27
+ return [] unless receiver.is_a?(Parser::AST::Node) && receiver.type == :send
28
+
29
+ method_name = node.children[1]
30
+ [
31
+ build_mutant(
32
+ subject:,
33
+ original_node: node,
34
+ mutated_node: receiver,
35
+ description: "removed .#{method_name} from chain"
36
+ )
37
+ ]
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../parser_current"
4
+
5
+ module Henitai
6
+ module Operators
7
+ # Mutates regular expression literals by altering quantifiers, anchors,
8
+ # and character-class negation.
9
+ #
10
+ # Each applicable transformation yields a separate mutant. Invalid
11
+ # results (unparseable regex) are discarded before emission.
12
+ # Anchor removal and character-class negation are intentionally one-way:
13
+ # they reduce noisy patterns rather than mirroring a full edit matrix.
14
+ class RegexMutator < Henitai::Operator
15
+ NODE_TYPES = %i[regexp].freeze
16
+
17
+ def self.node_types
18
+ NODE_TYPES
19
+ end
20
+
21
+ def mutate(node, subject:)
22
+ source, opts_node = extract_parts(node)
23
+ return [] unless source
24
+
25
+ transformations(source).filter_map do |new_source, description|
26
+ build_regex_mutant(node, opts_node, new_source, source, description, subject:)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def extract_parts(node)
33
+ str_child = node.children.find { |c| c.is_a?(Parser::AST::Node) && c.type == :str }
34
+ opts_node = node.children.find { |c| c.is_a?(Parser::AST::Node) && c.type == :regopt }
35
+ return nil unless str_child
36
+
37
+ [str_child.children[0], opts_node]
38
+ end
39
+
40
+ def build_regex_mutant(node, opts_node, new_source, original, description, subject:) # rubocop:disable Metrics/ParameterLists
41
+ return if new_source == original
42
+ return unless valid_regex?(new_source)
43
+
44
+ children = [Parser::AST::Node.new(:str, [new_source]), opts_node]
45
+ mutated = Parser::AST::Node.new(:regexp, children)
46
+ build_mutant(subject:, original_node: node, mutated_node: mutated, description:)
47
+ end
48
+
49
+ def transformations(source)
50
+ [
51
+ *quantifier_swaps(source),
52
+ *anchor_removals(source),
53
+ *char_class_negations(source)
54
+ ]
55
+ end
56
+
57
+ def quantifier_swaps(source)
58
+ # Quantifier swaps are symmetric. The other transforms are intentionally
59
+ # one-way because they model reductions instead of reversible edits.
60
+ [
61
+ [source.gsub(/(?<=[^*+?\\])\+/, "*"), "replaced + quantifier with *"],
62
+ [source.gsub(/(?<=[^*+?\\])\*/, "+"), "replaced * quantifier with +"]
63
+ ]
64
+ end
65
+
66
+ def anchor_removals(source)
67
+ # Anchors are removed, not added back. The mutation is deliberately
68
+ # asymmetric because "restoring" anchors would just recreate the input.
69
+ [
70
+ [source.sub("^", ""), "removed ^ anchor"],
71
+ [source.sub(/\$$/, ""), "removed $ anchor"]
72
+ ]
73
+ end
74
+
75
+ def char_class_negations(source)
76
+ # Negation only flips a positive class into a negated one. We do not
77
+ # emit the reverse mutation because that would mirror the same edit.
78
+ [[source.gsub(/\[(?!\^)/, "[^"), "negated character class"]]
79
+ end
80
+
81
+ def valid_regex?(source)
82
+ Regexp.new(source)
83
+ true
84
+ rescue RegexpError
85
+ false
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../parser_current"
4
+
5
+ module Henitai
6
+ module Operators
7
+ # Removes unary prefix operators by replacing the send node with its receiver.
8
+ #
9
+ # Covers :-@ (unary minus) and :~ (bitwise NOT).
10
+ # Unary negation (!) is intentionally excluded — BooleanLiteral owns that.
11
+ class UnaryOperator < Henitai::Operator
12
+ NODE_TYPES = %i[send].freeze
13
+ UNARY_METHODS = %i[-@ ~].freeze
14
+
15
+ def self.node_types
16
+ NODE_TYPES
17
+ end
18
+
19
+ def mutate(node, subject:)
20
+ receiver, method_name, *arguments = node.children
21
+ return [] unless UNARY_METHODS.include?(method_name)
22
+ return [] unless arguments.empty?
23
+ return [] unless receiver
24
+
25
+ [
26
+ build_mutant(
27
+ subject:,
28
+ original_node: node,
29
+ mutated_node: receiver,
30
+ description: "removed unary #{method_name.to_s.delete('@')}"
31
+ )
32
+ ]
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../parser_current"
4
+
5
+ module Henitai
6
+ module Operators
7
+ # Swaps compound assignment operators with their inverses.
8
+ #
9
+ # Covers arithmetic pairs (+=/-=, *=/=) via :op_asgn and
10
+ # logical pairs (||=/&&=) via :or_asgn/:and_asgn. Exponent and modulo
11
+ # compound assignments are intentionally excluded: they are not part of the
12
+ # supported swap matrix and are already covered by other operator families
13
+ # when appropriate.
14
+ class UpdateOperator < Henitai::Operator
15
+ NODE_TYPES = %i[op_asgn or_asgn and_asgn].freeze
16
+ ARITHMETIC_SWAPS = {
17
+ :+ => :-,
18
+ :- => :+,
19
+ :* => :/,
20
+ :/ => :*
21
+ }.freeze
22
+
23
+ def self.node_types
24
+ NODE_TYPES
25
+ end
26
+
27
+ def mutate(node, subject:)
28
+ case node.type
29
+ when :op_asgn
30
+ mutate_arithmetic(node, subject:)
31
+ when :or_asgn
32
+ mutate_logical(node, subject:, from: "||=", to: :and_asgn, to_op: "&&=")
33
+ when :and_asgn
34
+ mutate_logical(node, subject:, from: "&&=", to: :or_asgn, to_op: "||=")
35
+ else
36
+ []
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def mutate_arithmetic(node, subject:)
43
+ target, operator, value = node.children
44
+ replacement = ARITHMETIC_SWAPS[operator]
45
+ return [] unless replacement
46
+
47
+ [
48
+ build_mutant(
49
+ subject:,
50
+ original_node: node,
51
+ mutated_node: Parser::AST::Node.new(:op_asgn, [target, replacement, value]),
52
+ description: "replaced #{operator}= with #{replacement}="
53
+ )
54
+ ]
55
+ end
56
+
57
+ def mutate_logical(node, subject:, from:, to:, to_op:)
58
+ target, value = node.children
59
+ [
60
+ build_mutant(
61
+ subject:,
62
+ original_node: node,
63
+ mutated_node: Parser::AST::Node.new(to, [target, value]),
64
+ description: "replaced #{from} with #{to_op}"
65
+ )
66
+ ]
67
+ end
68
+ end
69
+ end
70
+ end
@@ -21,5 +21,9 @@ module Henitai
21
21
  autoload :BlockStatement, "henitai/operators/block_statement"
22
22
  autoload :MethodExpression, "henitai/operators/method_expression"
23
23
  autoload :AssignmentExpression, "henitai/operators/assignment_expression"
24
+ autoload :MethodChainUnwrap, "henitai/operators/method_chain_unwrap"
25
+ autoload :RegexMutator, "henitai/operators/regex_mutator"
26
+ autoload :UnaryOperator, "henitai/operators/unary_operator"
27
+ autoload :UpdateOperator, "henitai/operators/update_operator"
24
28
  end
25
29
  end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Runs pending mutants across worker threads with signal and stdin handling.
5
+ class ParallelExecutionRunner
6
+ ParallelExecutionContext = Struct.new(
7
+ :queue, :integration, :config, :progress_reporter,
8
+ :mutex, :state, :old_handlers, :stdin_watcher
9
+ )
10
+
11
+ def initialize(worker_count:)
12
+ @worker_count = worker_count
13
+ end
14
+
15
+ def run(mutants, integration, config, progress_reporter, options = {})
16
+ context = build_parallel_context(
17
+ mutants,
18
+ integration,
19
+ config,
20
+ progress_reporter
21
+ )
22
+ execute_parallel_execution(
23
+ context,
24
+ stdin_pipe: options.fetch(:stdin_pipe, false),
25
+ process_mutant: options.fetch(:process_mutant)
26
+ )
27
+ end
28
+
29
+ def execute_parallel_execution(context, stdin_pipe:, process_mutant:)
30
+ install_parallel_signal_traps(context)
31
+ start_parallel_stdin_watcher(context, stdin_pipe)
32
+ parallel_workers(context, process_mutant).each(&:join)
33
+ ensure
34
+ stop_parallel_stdin_watcher(context)
35
+ restore_parallel_signal_traps(context)
36
+ raise context.state[:error] if context&.state&.fetch(:error, nil)
37
+ raise Interrupt if context&.state&.fetch(:stopping, false)
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :worker_count
43
+
44
+ def build_parallel_queue(mutants)
45
+ Queue.new.tap { |queue| mutants.each { |mutant| queue << mutant } }
46
+ end
47
+
48
+ def build_parallel_context(mutants, integration, config, progress_reporter)
49
+ ParallelExecutionContext.new(
50
+ build_parallel_queue(mutants),
51
+ integration,
52
+ config,
53
+ progress_reporter,
54
+ Mutex.new,
55
+ { stopping: false }
56
+ )
57
+ end
58
+
59
+ def install_parallel_signal_traps(context)
60
+ context.old_handlers = {
61
+ int: trap(:INT) { stop_parallel_execution(context) },
62
+ term: trap(:TERM) { stop_parallel_execution(context) },
63
+ hup: trap(:HUP) { stop_parallel_execution(context) }
64
+ }
65
+ end
66
+
67
+ def stop_parallel_execution(context)
68
+ context.state[:stopping] = true
69
+ context.queue.clear
70
+ end
71
+
72
+ def start_parallel_stdin_watcher(context, stdin_pipe)
73
+ return unless stdin_pipe
74
+ # CI runners expose stdin as a non-interactive pipe, so EOF there should
75
+ # not be treated as a user disconnect.
76
+ return if ci_environment?
77
+
78
+ context.stdin_watcher = Thread.new do
79
+ $stdin.read
80
+ stop_parallel_execution(context)
81
+ rescue IOError, Errno::EBADF
82
+ nil
83
+ end
84
+ end
85
+
86
+ def parallel_workers(context, process_mutant)
87
+ Array.new(worker_count) { Thread.new { process_parallel_worker(context, process_mutant) } }
88
+ end
89
+
90
+ def process_parallel_worker(context, process_mutant)
91
+ loop do
92
+ break if context.state[:stopping]
93
+
94
+ process_mutant.call(
95
+ context.queue.pop(true),
96
+ context.integration,
97
+ context.config,
98
+ context.progress_reporter,
99
+ context.mutex
100
+ )
101
+ rescue ThreadError
102
+ break
103
+ rescue StandardError => e
104
+ record_parallel_error(context, e)
105
+ break
106
+ end
107
+ end
108
+
109
+ def stop_parallel_stdin_watcher(context)
110
+ context&.stdin_watcher&.kill
111
+ end
112
+
113
+ def restore_parallel_signal_traps(context)
114
+ handlers = context&.old_handlers
115
+ return unless handlers
116
+
117
+ trap(:INT, handlers[:int] || "DEFAULT")
118
+ trap(:TERM, handlers[:term] || "DEFAULT")
119
+ trap(:HUP, handlers[:hup] || "DEFAULT")
120
+ end
121
+
122
+ def record_parallel_error(context, error)
123
+ context.mutex.synchronize do
124
+ context.state[:error] ||= error
125
+ context.state[:stopping] = true
126
+ context.queue.clear
127
+ end
128
+ end
129
+
130
+ def ci_environment?
131
+ value = ENV.fetch("CI", nil)
132
+ value && !%w[0 false].include?(value.downcase)
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Narrows candidate test files using the per-test coverage report.
5
+ class PerTestCoverageSelector
6
+ def initialize(coverage_report_reader: CoverageReportReader.new)
7
+ @coverage_report_reader = coverage_report_reader
8
+ end
9
+
10
+ def filter(tests, mutant, reports_dir:)
11
+ candidates = Array(tests)
12
+ return candidates if candidates.empty?
13
+ return candidates unless location_available?(mutant)
14
+ return candidates unless per_test_coverage_available?(reports_dir)
15
+
16
+ covered_tests = candidates.select do |test|
17
+ covers_mutant?(test, mutant, reports_dir)
18
+ end
19
+ covered_tests.empty? ? candidates : covered_tests
20
+ end
21
+
22
+ private
23
+
24
+ def location_available?(mutant)
25
+ mutant.respond_to?(:location) &&
26
+ mutant.location.is_a?(Hash) &&
27
+ mutant.location[:file] &&
28
+ mutant.location[:start_line] &&
29
+ mutant.location[:end_line]
30
+ end
31
+
32
+ def covers_mutant?(test, mutant, reports_dir)
33
+ covered_lines = coverage_lines_for(test, mutant, reports_dir)
34
+ mutant_lines(mutant).any? { |line| covered_lines.include?(line) }
35
+ end
36
+
37
+ def coverage_lines_for(test, mutant, reports_dir)
38
+ source_map = per_test_coverage(reports_dir)[test.to_s] || {}
39
+ Array(source_map[File.expand_path(mutant.location[:file])]).uniq
40
+ end
41
+
42
+ def mutant_lines(mutant)
43
+ (mutant.location[:start_line]..mutant.location[:end_line]).to_a
44
+ end
45
+
46
+ def per_test_coverage(reports_dir)
47
+ @per_test_coverage ||= {}
48
+ @per_test_coverage[reports_dir] ||= begin
49
+ path = File.join(reports_dir, "henitai_per_test.json")
50
+ coverage_report_reader.test_lines_by_file(path)
51
+ end
52
+ end
53
+
54
+ def per_test_coverage_available?(reports_dir)
55
+ !per_test_coverage(reports_dir).empty?
56
+ end
57
+
58
+ attr_reader :coverage_report_reader
59
+ end
60
+ end
@@ -11,13 +11,15 @@ module Henitai
11
11
  include UnparseHelper
12
12
 
13
13
  SCHEMA_VERSION = "1.0"
14
+ DEFAULT_THRESHOLDS = { high: 80, low: 60 }.freeze
14
15
 
15
- attr_reader :mutants, :started_at, :finished_at
16
+ attr_reader :mutants, :started_at, :finished_at, :thresholds
16
17
 
17
- def initialize(mutants:, started_at:, finished_at:)
18
+ def initialize(mutants:, started_at:, finished_at:, thresholds: nil)
18
19
  @mutants = mutants
19
20
  @started_at = started_at
20
21
  @finished_at = finished_at
22
+ @thresholds = DEFAULT_THRESHOLDS.merge(thresholds || {})
21
23
  end
22
24
 
23
25
  # @return [Integer] number of killed mutants
@@ -88,7 +90,7 @@ module Henitai
88
90
  def to_stryker_schema
89
91
  {
90
92
  schemaVersion: SCHEMA_VERSION,
91
- thresholds: { high: 80, low: 60 },
93
+ thresholds: thresholds,
92
94
  files: build_files_section
93
95
  }
94
96
  end
@@ -119,7 +121,17 @@ module Henitai
119
121
  status: stryker_status(mutant.status),
120
122
  description: mutant.description,
121
123
  duration: duration_for(mutant)
122
- }.compact
124
+ }.compact.merge(coverage_schema(mutant))
125
+ end
126
+
127
+ def coverage_schema(mutant)
128
+ covered_by = Array(mutant.covered_by).compact
129
+ return {} if covered_by.empty?
130
+
131
+ {
132
+ coveredBy: covered_by,
133
+ testsCompleted: mutant.tests_completed || covered_by.size
134
+ }
123
135
  end
124
136
 
125
137
  def replacement_for(mutant)
@@ -35,18 +35,24 @@ module Henitai
35
35
  end
36
36
 
37
37
  # Entry point — runs the full pipeline and returns a Result.
38
+ #
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.
43
+ #
44
+ # For targeted runs (`subjects:` provided), the bootstrap is further scoped
45
+ # to the spec files that cover the requested subjects rather than the full
46
+ # suite, reducing the baseline run time proportionally.
47
+ #
38
48
  # @return [Result]
39
49
  def run
40
50
  started_at = Time.now
41
51
  source_files = self.source_files
42
- bootstrap_coverage(source_files)
43
52
  subjects = resolve_subjects(source_files)
44
- mutants = generate_mutants(subjects)
45
- mutants = filter_mutants(mutants)
46
- mutants = execute_mutants(mutants)
47
- finished_at = Time.now
53
+ mutants = execute_mutants(mutants_for(subjects, source_files))
48
54
 
49
- build_result(mutants, started_at, finished_at)
55
+ build_result(mutants, started_at, Time.now)
50
56
  end
51
57
 
52
58
  private
@@ -69,6 +75,29 @@ module Henitai
69
75
  static_filter.apply(mutants, config)
70
76
  end
71
77
 
78
+ def mutants_for(subjects, source_files)
79
+ bootstrap_thread = bootstrap_mutants(source_files, subjects)
80
+ mutants = generate_mutants(subjects)
81
+ bootstrap_thread.value
82
+
83
+ filtered_mutants = filter_mutants(mutants)
84
+ return filtered_mutants unless targeted_run?
85
+
86
+ refresh_coverage_for_targeted_run(filtered_mutants, source_files)
87
+ end
88
+
89
+ def refresh_coverage_for_targeted_run(mutants, source_files)
90
+ return mutants unless retry_full_bootstrap?(mutants)
91
+
92
+ bootstrap_coverage(source_files)
93
+ filter_mutants(mutants)
94
+ end
95
+
96
+ def bootstrap_mutants(source_files, subjects)
97
+ scoped_tests = scoped_bootstrap_test_files(subjects)
98
+ Thread.new { bootstrap_coverage(source_files, scoped_tests) }
99
+ end
100
+
72
101
  def execute_mutants(mutants)
73
102
  execution_engine.run(
74
103
  mutants,
@@ -94,13 +123,33 @@ module Henitai
94
123
  @result = Result.new(
95
124
  mutants:,
96
125
  started_at:,
97
- finished_at:
126
+ finished_at:,
127
+ thresholds: result_thresholds
98
128
  )
99
129
  persist_history(@result, finished_at)
100
130
  report(@result)
101
131
  @result
102
132
  end
103
133
 
134
+ # Returns the spec files to use for the coverage bootstrap.
135
+ #
136
+ # For full runs (no subject pattern given), returns nil so the bootstrapper
137
+ # falls back to the integration's full test-file list.
138
+ #
139
+ # For targeted runs, returns the union of test files selected for each
140
+ # resolved subject. Falls back to nil (all tests) if the selection is empty,
141
+ # so the bootstrapper always has a non-empty file list.
142
+ def scoped_bootstrap_test_files(subjects)
143
+ return nil if pattern_subjects.empty?
144
+
145
+ files = subjects.flat_map { |subject| integration.select_tests(subject) }.uniq
146
+ files.empty? ? nil : files
147
+ end
148
+
149
+ def bootstrap_coverage(source_files, test_files = nil)
150
+ coverage_bootstrapper.ensure!(source_files:, config:, integration:, test_files:)
151
+ end
152
+
104
153
  def subject_resolver
105
154
  @subject_resolver ||= SubjectResolver.new
106
155
  end
@@ -125,10 +174,6 @@ module Henitai
125
174
  @coverage_bootstrapper ||= CoverageBootstrapper.new
126
175
  end
127
176
 
128
- def bootstrap_coverage(source_files)
129
- coverage_bootstrapper.ensure!(source_files:, config:, integration:)
130
- end
131
-
132
177
  def integration
133
178
  @integration ||= Integration.for(config.integration).new
134
179
  end
@@ -172,6 +217,19 @@ module Henitai
172
217
  Array(@subjects)
173
218
  end
174
219
 
220
+ def targeted_run?
221
+ !pattern_subjects.empty?
222
+ end
223
+
224
+ def retry_full_bootstrap?(mutants)
225
+ executable_mutants = Array(mutants).reject do |mutant|
226
+ %i[ignored compile_error equivalent].include?(mutant.status)
227
+ end
228
+ return false if executable_mutants.empty?
229
+
230
+ executable_mutants.all? { |mutant| mutant.status == :no_coverage }
231
+ end
232
+
175
233
  def unique_subjects(subjects)
176
234
  subjects.uniq { |subject| [subject.expression, subject.source_file] }
177
235
  end
@@ -179,5 +237,11 @@ module Henitai
179
237
  def normalize_path(path)
180
238
  File.expand_path(path)
181
239
  end
240
+
241
+ def result_thresholds
242
+ return nil unless config.respond_to?(:thresholds)
243
+
244
+ config.thresholds
245
+ end
182
246
  end
183
247
  end