henitai 0.1.2 → 0.1.4

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +105 -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 +128 -10
  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 +195 -110
  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/string_literal.rb +2 -1
  25. data/lib/henitai/operators/unary_operator.rb +36 -0
  26. data/lib/henitai/operators/update_operator.rb +70 -0
  27. data/lib/henitai/operators.rb +4 -0
  28. data/lib/henitai/parallel_execution_runner.rb +135 -0
  29. data/lib/henitai/per_test_coverage_selector.rb +60 -0
  30. data/lib/henitai/reporter.rb +14 -2
  31. data/lib/henitai/result.rb +16 -4
  32. data/lib/henitai/runner.rb +75 -11
  33. data/lib/henitai/scenario_execution_result.rb +31 -2
  34. data/lib/henitai/source_parser.rb +12 -1
  35. data/lib/henitai/static_filter.rb +20 -41
  36. data/lib/henitai/version.rb +1 -1
  37. data/lib/henitai.rb +3 -0
  38. data/sig/henitai.rbs +66 -10
  39. metadata +17 -4
@@ -34,7 +34,8 @@ module Henitai
34
34
  end
35
35
 
36
36
  def generate_for_subject(subject, operators, config:, arid_node_filter:, syntax_validator:)
37
- return [] unless subject.source_file && subject.source_range
37
+ source_node = source_node_for(subject)
38
+ return [] unless source_node
38
39
 
39
40
  visitor = SubjectVisitor.new(
40
41
  subject,
@@ -43,11 +44,8 @@ module Henitai
43
44
  arid_node_filter:,
44
45
  syntax_validator:
45
46
  )
46
- visitor.process(SourceParser.parse_file(subject.source_file))
47
- prune_mutants_per_line(
48
- visitor.mutants,
49
- max_mutants_per_line: config&.max_mutants_per_line || 1
50
- )
47
+ visitor.process(source_node)
48
+ visitor.mutants
51
49
  end
52
50
 
53
51
  # Depth-first pre-order AST visitor for a single subject.
@@ -60,13 +58,8 @@ module Henitai
60
58
  @mutants = []
61
59
  @arid_node_filter = arid_node_filter
62
60
  @syntax_validator = syntax_validator
63
- @operators_by_node_type = operators.each_with_object(
64
- Hash.new { |hash, key| hash[key] = [] }
65
- ) do |operator, map|
66
- operator.class.node_types.each do |node_type|
67
- map[node_type] << operator
68
- end
69
- end
61
+ initialize_subject_range(subject)
62
+ @operators_by_node_type = index_operators(operators)
70
63
  end
71
64
 
72
65
  def process(node)
@@ -95,27 +88,28 @@ module Henitai
95
88
  end
96
89
 
97
90
  def node_within_subject_range?(node)
91
+ return true unless @subject_range_begin
92
+
98
93
  location = node.location&.expression
99
- return true unless location && @subject.source_range
94
+ return true unless location
100
95
 
101
- node_range = location.line..location.last_line
102
- ranges_overlap?(node_range, @subject.source_range)
96
+ location.line <= @subject_range_end && @subject_range_begin <= location.last_line
103
97
  end
104
98
 
105
- def ranges_overlap?(left, right)
106
- left.begin <= right.end && right.begin <= left.end
107
- end
108
- end
99
+ def initialize_subject_range(subject)
100
+ subject_range = subject.source_range
101
+ return unless subject_range
109
102
 
110
- def prune_mutants_per_line(mutants, max_mutants_per_line:)
111
- grouped = mutants.each_with_object({}) do |mutant, selected|
112
- key = line_key(mutant)
113
- selected[key] ||= []
114
- selected[key] << mutant
103
+ @subject_range_begin = subject_range.begin
104
+ @subject_range_end = subject_range.end
115
105
  end
116
106
 
117
- grouped.values.flat_map do |mutants_for_line|
118
- mutants_for_line.sort_by { |mutant| mutant_priority_key(mutant) }.take(max_mutants_per_line)
107
+ def index_operators(operators)
108
+ operators.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |operator, map|
109
+ operator.class.node_types.each do |node_type|
110
+ map[node_type] << operator
111
+ end
112
+ end
119
113
  end
120
114
  end
121
115
 
@@ -131,28 +125,11 @@ module Henitai
131
125
  )
132
126
  end
133
127
 
134
- def line_key(mutant)
135
- [
136
- mutant.location[:file],
137
- mutant.location[:start_line]
138
- ]
139
- end
140
-
141
- def mutant_priority_key(mutant)
142
- [
143
- operator_priority(mutant.operator),
144
- mutant.location[:start_col] || 0,
145
- mutant.description
146
- ]
147
- end
148
-
149
- def operator_priority(operator_name)
150
- operator_priority_map.fetch(operator_name, operator_priority_map.length)
151
- end
128
+ def source_node_for(subject)
129
+ return subject.ast_node if subject.ast_node
130
+ return nil unless subject.source_file && subject.source_range
152
131
 
153
- def operator_priority_map
154
- # The constant order defines signal priority for per-line pruning.
155
- @operator_priority_map ||= Operator::FULL_SET.each_with_index.to_h
132
+ SourceParser.new.parse_file(subject.source_file)
156
133
  end
157
134
  end
158
135
  end
@@ -13,7 +13,8 @@ module Henitai
13
13
  #
14
14
  # Additional operators (full set):
15
15
  # ArrayDeclaration, HashLiteral, RangeLiteral, SafeNavigation,
16
- # PatternMatch, BlockStatement, MethodExpression, AssignmentExpression
16
+ # PatternMatch, BlockStatement, MethodExpression, AssignmentExpression,
17
+ # UnaryOperator, UpdateOperator, RegexMutator, MethodChainUnwrap
17
18
  #
18
19
  # Each operator subclass must implement:
19
20
  # - .node_types → Array<Symbol> AST node types this operator handles
@@ -32,12 +33,16 @@ module Henitai
32
33
  FULL_SET = (LIGHT_SET + %w[
33
34
  ArrayDeclaration
34
35
  HashLiteral
36
+ MethodChainUnwrap
35
37
  RangeLiteral
38
+ RegexMutator
36
39
  SafeNavigation
37
40
  PatternMatch
38
41
  BlockStatement
39
42
  MethodExpression
40
43
  AssignmentExpression
44
+ UnaryOperator
45
+ UpdateOperator
41
46
  ]).freeze
42
47
 
43
48
  # @param set [Symbol] :light or :full
@@ -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
@@ -27,7 +27,8 @@ module Henitai
27
27
  private
28
28
 
29
29
  def mutate_plain_string(node, subject:)
30
- REPLACEMENTS.map do |replacement|
30
+ original_value = node.children.first
31
+ REPLACEMENTS.reject { |r| r == original_value }.map do |replacement|
31
32
  build_mutant(
32
33
  subject:,
33
34
  original_node: node,
@@ -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
@@ -126,11 +126,23 @@ module Henitai
126
126
  end
127
127
 
128
128
  def original_line(mutant)
129
- format("- %s", safe_unparse(mutant.original_node))
129
+ format("- %s", display_unparse(mutant.original_node))
130
130
  end
131
131
 
132
132
  def mutated_line(mutant)
133
- format("+ %s", safe_unparse(mutant.mutated_node))
133
+ format("+ %s", display_unparse(mutant.mutated_node))
134
+ end
135
+
136
+ # Like safe_unparse but makes invisible characters visible in terminal
137
+ # output. For string literal nodes the inner value is shown via #inspect
138
+ # so that e.g. "" vs " " vs "\n" are unambiguous. Other nodes unparse
139
+ # normally.
140
+ def display_unparse(node)
141
+ if node.respond_to?(:type) && node.respond_to?(:children) && node.type == :str
142
+ node.children.first.inspect
143
+ else
144
+ safe_unparse(node)
145
+ end
134
146
  end
135
147
 
136
148
  def score_line(result)