henitai 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +222 -1
  3. data/README.md +100 -13
  4. data/assets/schema/henitai.schema.json +20 -0
  5. data/lib/henitai/arid_node_filter.rb +4 -19
  6. data/lib/henitai/bootstrap_error_message.rb +20 -0
  7. data/lib/henitai/canonical_report_merger.rb +23 -2
  8. data/lib/henitai/cli/run_command.rb +25 -7
  9. data/lib/henitai/cli/run_options.rb +1 -1
  10. data/lib/henitai/cli.rb +4 -1
  11. data/lib/henitai/configuration.rb +4 -1
  12. data/lib/henitai/configuration_validator/rules.rb +8 -0
  13. data/lib/henitai/configuration_validator/scalars.rb +9 -0
  14. data/lib/henitai/configuration_validator.rb +2 -1
  15. data/lib/henitai/constant_path.rb +48 -0
  16. data/lib/henitai/control_run.rb +73 -0
  17. data/lib/henitai/coverage_bootstrapper.rb +36 -10
  18. data/lib/henitai/coverage_formatter.rb +26 -3
  19. data/lib/henitai/coverage_report_reader.rb +21 -0
  20. data/lib/henitai/equivalence_detector/operand_predicates.rb +16 -0
  21. data/lib/henitai/equivalence_detector.rb +46 -38
  22. data/lib/henitai/example_summary.rb +24 -0
  23. data/lib/henitai/execution_engine/fork_server_scope.rb +57 -0
  24. data/lib/henitai/execution_engine.rb +75 -60
  25. data/lib/henitai/fidelity_check.rb +173 -0
  26. data/lib/henitai/flaky_detection_baseline.rb +61 -0
  27. data/lib/henitai/flaky_mutant_retrier.rb +43 -0
  28. data/lib/henitai/flaky_retry_decision.rb +23 -0
  29. data/lib/henitai/flaky_test_registry.rb +63 -0
  30. data/lib/henitai/fork_server/client.rb +231 -0
  31. data/lib/henitai/fork_server/group_killer.rb +48 -0
  32. data/lib/henitai/fork_server/group_terminator.rb +47 -0
  33. data/lib/henitai/fork_server/launcher.rb +38 -0
  34. data/lib/henitai/fork_server/mutant_runner.rb +141 -0
  35. data/lib/henitai/fork_server/protocol.rb +73 -0
  36. data/lib/henitai/fork_server/reaper.rb +24 -0
  37. data/lib/henitai/fork_server/runtime.rb +109 -0
  38. data/lib/henitai/fork_server/server.rb +108 -0
  39. data/lib/henitai/fork_server/server_process.rb +73 -0
  40. data/lib/henitai/fork_server/status.rb +52 -0
  41. data/lib/henitai/fork_server.rb +43 -0
  42. data/lib/henitai/generated_artifacts.rb +3 -0
  43. data/lib/henitai/git_diff_analyzer.rb +28 -11
  44. data/lib/henitai/incremental_filter.rb +20 -10
  45. data/lib/henitai/integration/base.rb +72 -5
  46. data/lib/henitai/integration/capped_log_sink.rb +34 -0
  47. data/lib/henitai/integration/child_bootstrap.rb +7 -0
  48. data/lib/henitai/integration/child_report_store.rb +36 -0
  49. data/lib/henitai/integration/minitest.rb +7 -5
  50. data/lib/henitai/integration/minitest_autorun.rb +20 -0
  51. data/lib/henitai/integration/minitest_outcome_reporter.rb +28 -0
  52. data/lib/henitai/integration/minitest_preload.rb +30 -0
  53. data/lib/henitai/integration/minitest_runnable_filter.rb +61 -0
  54. data/lib/henitai/integration/minitest_test_runner.rb +32 -15
  55. data/lib/henitai/integration/mutant_run_support.rb +73 -13
  56. data/lib/henitai/integration/rspec_child_runner.rb +44 -9
  57. data/lib/henitai/integration/rspec_preload.rb +35 -0
  58. data/lib/henitai/integration/rspec_process_runner.rb +4 -1
  59. data/lib/henitai/integration/scenario_log_support.rb +31 -18
  60. data/lib/henitai/integration.rb +16 -3
  61. data/lib/henitai/minitest_coverage_reporter.rb +22 -6
  62. data/lib/henitai/minitest_result_file.rb +62 -0
  63. data/lib/henitai/mutant/activation.rb +32 -0
  64. data/lib/henitai/mutant/activator.rb +75 -55
  65. data/lib/henitai/mutant/def_injection.rb +136 -0
  66. data/lib/henitai/mutant/lexical_scope_check.rb +120 -0
  67. data/lib/henitai/mutant/lexical_target.rb +105 -0
  68. data/lib/henitai/mutant/replacement.rb +52 -0
  69. data/lib/henitai/mutant.rb +27 -8
  70. data/lib/henitai/mutant_generator.rb +62 -18
  71. data/lib/henitai/mutant_history_store/verdict_cache.rb +12 -11
  72. data/lib/henitai/mutant_history_store.rb +14 -4
  73. data/lib/henitai/mutant_identity.rb +5 -1
  74. data/lib/henitai/mutant_verdict.rb +71 -0
  75. data/lib/henitai/operators/array_declaration.rb +4 -0
  76. data/lib/henitai/operators/assignment_expression.rb +2 -0
  77. data/lib/henitai/operators/conditional_expression.rb +21 -1
  78. data/lib/henitai/operators/hash_literal.rb +7 -4
  79. data/lib/henitai/operators/method_expression.rb +14 -3
  80. data/lib/henitai/operators/regex_metacharacters.rb +89 -0
  81. data/lib/henitai/operators/regex_mutator.rb +23 -28
  82. data/lib/henitai/operators/return_value.rb +1 -1
  83. data/lib/henitai/operators.rb +1 -0
  84. data/lib/henitai/per_test_coverage.rb +11 -7
  85. data/lib/henitai/per_test_coverage_collector.rb +30 -31
  86. data/lib/henitai/per_test_coverage_delta.rb +60 -0
  87. data/lib/henitai/per_test_coverage_selector.rb +26 -13
  88. data/lib/henitai/process_wakeup.rb +8 -3
  89. data/lib/henitai/process_worker_runner.rb +19 -5
  90. data/lib/henitai/reporter.rb +15 -8
  91. data/lib/henitai/result.rb +32 -16
  92. data/lib/henitai/reuse_context.rb +50 -0
  93. data/lib/henitai/rspec_coverage_formatter.rb +4 -0
  94. data/lib/henitai/rspec_outcomes.rb +72 -0
  95. data/lib/henitai/runner.rb +58 -19
  96. data/lib/henitai/runner_dependencies.rb +22 -1
  97. data/lib/henitai/sampling_strategy.rb +36 -6
  98. data/lib/henitai/scenario_execution_result.rb +28 -5
  99. data/lib/henitai/signature_line.rb +21 -0
  100. data/lib/henitai/slot_scheduler/draining.rb +12 -2
  101. data/lib/henitai/slot_scheduler/process_control.rb +33 -0
  102. data/lib/henitai/slot_scheduler/retry_policy.rb +8 -6
  103. data/lib/henitai/slot_scheduler.rb +47 -8
  104. data/lib/henitai/source_file_selection.rb +1 -1
  105. data/lib/henitai/source_parser.rb +29 -9
  106. data/lib/henitai/static_filter.rb +45 -14
  107. data/lib/henitai/stillborn_filter.rb +70 -2
  108. data/lib/henitai/subject.rb +8 -3
  109. data/lib/henitai/subject_resolver.rb +65 -31
  110. data/lib/henitai/suite_boot_clock.rb +48 -0
  111. data/lib/henitai/survivor_activation_cache.rb +18 -11
  112. data/lib/henitai/survivor_rerun_strategy.rb +47 -43
  113. data/lib/henitai/test_file_outcomes.rb +79 -0
  114. data/lib/henitai/timeout_calibrator.rb +31 -9
  115. data/lib/henitai/timeout_ceiling.rb +37 -0
  116. data/lib/henitai/verdict_fingerprint.rb +29 -30
  117. data/lib/henitai/version.rb +1 -1
  118. data/lib/henitai.rb +17 -1
  119. data/sig/configuration_validator.rbs +1 -0
  120. data/sig/henitai.rbs +225 -45
  121. metadata +46 -3
  122. data/lib/henitai/survivor_test_filter.rb +0 -72
@@ -5,8 +5,11 @@ require_relative "../parser_current"
5
5
  module Henitai
6
6
  module Operators
7
7
  # Replaces generic method call results with nil.
8
+ #
9
+ # A call with a block is replaced together with its block: replacing only
10
+ # the call would leave +nil { ... }+ behind, which does not parse.
8
11
  class MethodExpression < Henitai::Operator
9
- NODE_TYPES = [:send].freeze
12
+ NODE_TYPES = %i[send block numblock].freeze
10
13
  EXCLUDED_METHODS = %i[
11
14
  +
12
15
  -
@@ -31,9 +34,10 @@ module Henitai
31
34
  end
32
35
 
33
36
  def mutate(node, subject:)
34
- return [] unless node.type == :send
37
+ call = called_method(node)
38
+ return [] unless call&.type == :send
35
39
 
36
- _receiver, method_name, *_arguments = node.children
40
+ _receiver, method_name, *_arguments = call.children
37
41
  return [] if excluded_method?(method_name)
38
42
 
39
43
  [
@@ -48,6 +52,13 @@ module Henitai
48
52
 
49
53
  private
50
54
 
55
+ def called_method(node)
56
+ case node.type
57
+ when :send then node
58
+ when :block, :numblock then node.children.first
59
+ end
60
+ end
61
+
51
62
  def excluded_method?(method_name)
52
63
  method_name.to_s.end_with?("=") || EXCLUDED_METHODS.include?(method_name)
53
64
  end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ module Operators
5
+ # Locates the regexp metacharacters that RegexMutator edits.
6
+ #
7
+ # An escaped character and the contents of a character class are literal
8
+ # text: the +^+ in +[^0-9]+ negates the class and is no anchor, and the
9
+ # +++ in +[+-]+ or +\++ is no quantifier. The scan therefore skips
10
+ # escapes and tracks character-class nesting, including POSIX brackets
11
+ # such as +[[:alpha:]]+.
12
+ class RegexMetacharacters
13
+ ANCHORS = %w[^ $].freeze
14
+ QUANTIFIERS = %w[+ * ?].freeze
15
+ GROUPING = %w[( |].freeze
16
+
17
+ def initialize(source)
18
+ @source = source
19
+ @found = Hash.new { |hash, key| hash[key] = [] }
20
+ scan
21
+ end
22
+
23
+ # Indices of the +char+ quantifier ("+" or "*") applied to an atom.
24
+ def quantifiers(char)
25
+ @found[[:quantifier, char]].dup
26
+ end
27
+
28
+ # Indices of the +char+ anchor ("^" or "$").
29
+ def anchors(char)
30
+ @found[[:anchor, char]].dup
31
+ end
32
+
33
+ # Indices of the opening brackets of top-level, non-negated classes.
34
+ def class_openers
35
+ @found[:class_opener].dup
36
+ end
37
+
38
+ private
39
+
40
+ def scan
41
+ @index = 0
42
+ @depth = 0
43
+ @previous = nil
44
+ step while @index < @source.length
45
+ end
46
+
47
+ def step
48
+ char = @source[@index]
49
+ if char == "\\"
50
+ @previous = :atom
51
+ @index += 2
52
+ else
53
+ @depth.positive? ? within_class(char) : top_level(char)
54
+ @index += 1
55
+ end
56
+ end
57
+
58
+ def within_class(char)
59
+ @depth += 1 if char == "["
60
+ @depth -= 1 if char == "]"
61
+ @previous = :atom if @depth.zero?
62
+ end
63
+
64
+ def top_level(char)
65
+ record(char)
66
+ @previous = kind_of_token(char)
67
+ @depth = 1 if char == "["
68
+ end
69
+
70
+ def record(char)
71
+ if ANCHORS.include?(char)
72
+ @found[[:anchor, char]] << @index
73
+ elsif char == "["
74
+ @found[:class_opener] << @index unless @source[@index + 1] == "^"
75
+ elsif QUANTIFIERS.include?(char) && @previous == :atom
76
+ @found[[:quantifier, char]] << @index
77
+ end
78
+ end
79
+
80
+ def kind_of_token(char)
81
+ return :anchor if ANCHORS.include?(char)
82
+ return :quantifier if QUANTIFIERS.include?(char) || char == "}"
83
+ return :group if GROUPING.include?(char)
84
+
85
+ :atom
86
+ end
87
+ end
88
+ end
89
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../parser_current"
4
+ require_relative "regex_metacharacters"
4
5
 
5
6
  module Henitai
6
7
  module Operators
@@ -8,7 +9,10 @@ module Henitai
8
9
  # and character-class negation.
9
10
  #
10
11
  # Each applicable transformation yields a separate mutant. Invalid
11
- # results (unparseable regex) are discarded before emission.
12
+ # results (unparseable regex) are discarded before emission. Only static,
13
+ # single-segment literals are mutated: an interpolated or multi-line
14
+ # regexp is split into several parts, and rewriting one part alone would
15
+ # drop or misread the others.
12
16
  # Anchor removal and character-class negation are intentionally one-way:
13
17
  # they reduce noisy patterns rather than mirroring a full edit matrix.
14
18
  class RegexMutator < Henitai::Operator
@@ -30,11 +34,11 @@ module Henitai
30
34
  private
31
35
 
32
36
  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
37
+ *parts, opts_node = node.children
38
+ return unless opts_node.is_a?(Parser::AST::Node) && opts_node.type == :regopt
39
+ return unless parts.size == 1 && parts.first.is_a?(Parser::AST::Node) && parts.first.type == :str
36
40
 
37
- [str_child.children[0], opts_node]
41
+ [parts.first.children[0], opts_node]
38
42
  end
39
43
 
40
44
  def build_regex_mutant(node, opts_node, new_source, original, description, subject:) # rubocop:disable Metrics/ParameterLists
@@ -47,35 +51,26 @@ module Henitai
47
51
  end
48
52
 
49
53
  def transformations(source)
54
+ found = RegexMetacharacters.new(source)
50
55
  [
51
- *quantifier_swaps(source),
52
- *anchor_removals(source),
53
- *char_class_negations(source)
56
+ [edit(source, found.quantifiers("+"), "*"), "replaced + quantifier with *"],
57
+ [edit(source, found.quantifiers("*"), "+"), "replaced * quantifier with +"],
58
+ # Anchors are removed, never added, and character classes are only
59
+ # negated: the reverse edits would just recreate an input.
60
+ [edit(source, found.anchors("^").first(1), ""), "removed ^ anchor"],
61
+ [edit(source, found.anchors("$").last(1), ""), "removed $ anchor"],
62
+ [insert_after(source, found.class_openers, "^"), "negated character class"]
54
63
  ]
55
64
  end
56
65
 
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
- ]
66
+ # Replaces the single characters at +indices+, from the last to the
67
+ # first so earlier indices stay valid.
68
+ def edit(source, indices, text)
69
+ indices.sort.reverse.each_with_object(source.dup) { |index, result| result[index] = text }
73
70
  end
74
71
 
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"]]
72
+ def insert_after(source, indices, text)
73
+ indices.sort.reverse.each_with_object(source.dup) { |index, result| result.insert(index + 1, text) }
79
74
  end
80
75
 
81
76
  def valid_regex?(source)
@@ -47,7 +47,7 @@ module Henitai
47
47
  end
48
48
 
49
49
  def mutate_implicit_return(node, subject:)
50
- return [] unless node == final_expression_node(subject&.ast_node)
50
+ return [] unless node.equal?(final_expression_node(subject&.ast_node))
51
51
 
52
52
  replacement_nodes(node).map do |replacement|
53
53
  build_mutant(
@@ -24,6 +24,7 @@ module Henitai
24
24
  autoload :MethodExpression, "henitai/operators/method_expression"
25
25
  autoload :AssignmentExpression, "henitai/operators/assignment_expression"
26
26
  autoload :MethodChainUnwrap, "henitai/operators/method_chain_unwrap"
27
+ autoload :RegexMetacharacters, "henitai/operators/regex_metacharacters"
27
28
  autoload :RegexMutator, "henitai/operators/regex_mutator"
28
29
  autoload :UnaryOperator, "henitai/operators/unary_operator"
29
30
  autoload :UpdateOperator, "henitai/operators/update_operator"
@@ -35,12 +35,7 @@ module Henitai
35
35
  # @return [Array<String>] sorted; empty when the test is unknown or the
36
36
  # map is unavailable.
37
37
  def source_files_covered_by(test_file)
38
- wanted = File.expand_path(test_file.to_s)
39
- map.select { |test, _| File.expand_path(test) == wanted }
40
- .values
41
- .flat_map(&:keys)
42
- .uniq
43
- .sort
38
+ Array(map_by_absolute_test_path[File.expand_path(test_file.to_s)]&.keys).sort
44
39
  end
45
40
 
46
41
  # True when the given test file's recorded coverage intersects the
@@ -63,11 +58,20 @@ module Henitai
63
58
  mutant.location[:end_line]
64
59
  end
65
60
 
61
+ # RSpec records test files as "./spec/x_spec.rb" while candidates arrive
62
+ # as "spec/x_spec.rb"; both resolve to the same absolute key.
66
63
  def coverage_lines_for(test, mutant)
67
- source_map = map[test.to_s] || {}
64
+ source_map = map_by_absolute_test_path[File.expand_path(test.to_s)] || {}
68
65
  Array(source_map[File.expand_path(mutant.location[:file])]).uniq
69
66
  end
70
67
 
68
+ def map_by_absolute_test_path
69
+ @map_by_absolute_test_path ||= map.each_with_object({}) do |(test, source_map), index|
70
+ merged = index[File.expand_path(test)] ||= {}
71
+ source_map.each { |file, lines| merged[file] = Array(merged[file]) + Array(lines) }
72
+ end
73
+ end
74
+
71
75
  def mutant_lines(mutant)
72
76
  (mutant.location[:start_line]..mutant.location[:end_line]).to_a
73
77
  end
@@ -3,9 +3,12 @@
3
3
  require "coverage"
4
4
  require "fileutils"
5
5
  require "json"
6
+ require_relative "per_test_coverage_delta"
7
+ require_relative "suite_boot_clock"
6
8
 
7
9
  module Henitai
8
10
  # Accumulates per-test line coverage deltas across test examples.
11
+ # {PerTestCoverageDelta} decides which lines a single test executed.
9
12
  #
10
13
  # Framework-agnostic core used by both the RSpec and Minitest adapters.
11
14
  # Callers invoke record_test after each test completes and write_report
@@ -13,16 +16,25 @@ module Henitai
13
16
  class PerTestCoverageCollector
14
17
  REPORT_DIR_ENV = "HENITAI_REPORTS_DIR"
15
18
  REPORT_FILE_NAME = "henitai_per_test.json"
19
+ BOOT_FILE_NAME = "henitai_suite_boot.json"
16
20
 
17
- def initialize
21
+ # @param boot_clock [SuiteBootClock] measures suite boot time
22
+ def initialize(boot_clock: SuiteBootClock.from_env(ENV))
23
+ @boot_clock = boot_clock
18
24
  @coverage_by_test = Hash.new do |hash, test_file|
19
25
  hash[test_file] = Hash.new { |nested, source_file| nested[source_file] = [] }
20
26
  end
21
27
  @duration_by_test = Hash.new(0.0)
22
28
  @previous_snapshot = {}
29
+ @delta = PerTestCoverageDelta.new
23
30
  @warned_missing_coverage = false
24
31
  end
25
32
 
33
+ # Called when the first test of the suite starts; ends the boot phase.
34
+ def test_started
35
+ @boot_clock.mark_first_test_started
36
+ end
37
+
26
38
  def record_test(test_file, duration: nil)
27
39
  snapshot = current_snapshot
28
40
  return warn_missing_coverage unless snapshot
@@ -36,19 +48,34 @@ module Henitai
36
48
  @previous_snapshot = snapshot
37
49
  end
38
50
 
51
+ # Advances past a test whose result cannot be attributed to a file, so its
52
+ # coverage is not credited to the next test.
53
+ def skip_test
54
+ @previous_snapshot = current_snapshot || @previous_snapshot
55
+ end
56
+
39
57
  def write_report
40
58
  return if @coverage_by_test.empty?
41
59
 
42
- FileUtils.mkdir_p(File.dirname(report_path))
60
+ FileUtils.mkdir_p(reports_dir)
43
61
  File.write(report_path, JSON.pretty_generate(serializable_report))
62
+ write_boot_report if @boot_clock.measured?
44
63
  end
45
64
 
46
65
  private
47
66
 
67
+ def write_boot_report
68
+ File.write(boot_path, JSON.pretty_generate("boot_seconds" => @boot_clock.boot_seconds))
69
+ end
70
+
48
71
  def report_path
49
72
  File.join(reports_dir, REPORT_FILE_NAME)
50
73
  end
51
74
 
75
+ def boot_path
76
+ File.join(reports_dir, BOOT_FILE_NAME)
77
+ end
78
+
52
79
  def reports_dir
53
80
  ENV.fetch(REPORT_DIR_ENV, "coverage")
54
81
  end
@@ -81,39 +108,11 @@ module Henitai
81
108
  snapshot.each_with_object({}) do |(source_file, file_coverage), result|
82
109
  next unless source_file?(source_file)
83
110
 
84
- lines = new_line_numbers(
85
- file_coverage,
86
- previous_line_counts(source_file)
87
- )
111
+ lines = @delta.executed_lines(file_coverage, @previous_snapshot.fetch(source_file, []))
88
112
  result[source_file] = lines unless lines.empty?
89
113
  end
90
114
  end
91
115
 
92
- # Count-delta attribution: a line belongs to every test whose run
93
- # incremented its cumulative count, not just the first toucher. This keeps
94
- # the per-test map a complete over-approximation of reachability — the
95
- # soundness precondition for survivor verdict reuse (ADR-11).
96
- def new_line_numbers(file_coverage, previous_counts)
97
- line_counts_for(file_coverage).each_with_index.filter_map do |count, index|
98
- next unless count.to_i > previous_counts.fetch(index, 0).to_i
99
-
100
- index + 1
101
- end
102
- end
103
-
104
- def previous_line_counts(source_file)
105
- line_counts_for(@previous_snapshot.fetch(source_file, []))
106
- end
107
-
108
- def line_counts_for(file_coverage)
109
- case file_coverage
110
- when Hash
111
- Array(file_coverage[:lines] || file_coverage["lines"])
112
- else
113
- Array(file_coverage)
114
- end
115
- end
116
-
117
116
  def source_file?(path)
118
117
  expanded = File.expand_path(path)
119
118
  prefix = "#{Dir.pwd}#{File::SEPARATOR}"
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Computes which source lines a test executed in one file, from two
5
+ # cumulative +Coverage.peek_result+ entries (before and after the test).
6
+ #
7
+ # Line coverage alone misses endless methods (+def x = expr+): calling
8
+ # one never increments any line count, so their body lines would only be
9
+ # attributed to the test that loaded the file. When method coverage is
10
+ # available, every called method also contributes its +def+ line and the
11
+ # lines inside its range that line coverage does not track.
12
+ class PerTestCoverageDelta
13
+ def executed_lines(current, previous)
14
+ (counted_lines(current, previous) | called_method_lines(current, previous)).sort
15
+ end
16
+
17
+ private
18
+
19
+ # Count-delta attribution: a line belongs to every test whose run
20
+ # incremented its cumulative count, not just the first toucher. This keeps
21
+ # the per-test map a complete over-approximation of reachability — the
22
+ # soundness precondition for survivor verdict reuse (ADR-11).
23
+ def counted_lines(current, previous)
24
+ previous_counts = line_counts(previous)
25
+ line_counts(current).each_with_index.filter_map do |count, index|
26
+ index + 1 if count.to_i > previous_counts.fetch(index, 0).to_i
27
+ end
28
+ end
29
+
30
+ def called_method_lines(current, previous)
31
+ counts = line_counts(current)
32
+ previous_calls = method_counts(previous)
33
+ method_counts(current).flat_map do |key, calls|
34
+ next [] unless calls.to_i > previous_calls.fetch(key, 0).to_i
35
+
36
+ untracked_method_lines(key, counts)
37
+ end
38
+ end
39
+
40
+ def untracked_method_lines(key, counts)
41
+ first_line = key[2]
42
+ last_line = key[4]
43
+ (first_line..last_line).select do |line|
44
+ line == first_line || counts[line - 1].nil?
45
+ end
46
+ end
47
+
48
+ def line_counts(file_coverage)
49
+ return Array(file_coverage) unless file_coverage.is_a?(Hash)
50
+
51
+ Array(file_coverage[:lines] || file_coverage["lines"])
52
+ end
53
+
54
+ def method_counts(file_coverage)
55
+ return {} unless file_coverage.is_a?(Hash)
56
+
57
+ file_coverage[:methods] || file_coverage["methods"] || {}
58
+ end
59
+ end
60
+ end
@@ -1,30 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Henitai
4
- # Narrows candidate test files using the per-test coverage report.
4
+ # Selects the test files to run for a mutant, coverage first.
5
5
  #
6
- # The intersection check itself lives in {PerTestCoverage}; this class only
7
- # adds the selection policy: when no candidate provably covers the mutant
8
- # (or the map / location is unavailable), it falls back to all candidates —
9
- # over-selection costs time, never correctness.
6
+ # The per-test coverage map is the primary source: every integration test
7
+ # file that provably reaches the mutant's lines is selected, whether or not
8
+ # its name matches the subject. Name matching ({fallback}) is only consulted
9
+ # when the map cannot decide — no map, no location, no covering test in the
10
+ # map, or no covering test left in the integration. Over-selection costs
11
+ # time; a missed covering test would produce a false survivor. Mutants on a
12
+ # method's def line additionally take the name-matched tests, because that
13
+ # line's coverage is only a load-time record.
14
+ #
15
+ # `test_files` and `fallback` are lambdas so that expensive name matching
16
+ # and file discovery run only when they are needed.
10
17
  class PerTestCoverageSelector
11
18
  def initialize(coverage_report_reader: CoverageReportReader.new)
12
19
  @coverage_report_reader = coverage_report_reader
13
20
  end
14
21
 
15
- def filter(tests, mutant, reports_dir:)
16
- candidates = Array(tests)
17
- return candidates if candidates.empty?
22
+ def select(mutant, reports_dir:, test_files:, fallback:)
23
+ covering = per_test_coverage(reports_dir).tests_covering(mutant)
24
+ return fallback.call if covering.empty?
18
25
 
19
- coverage = per_test_coverage(reports_dir)
20
- covered_tests = candidates.select do |test|
21
- coverage.covers?(test, mutant)
22
- end
23
- covered_tests.empty? ? candidates : covered_tests
26
+ covered = covered_integration_tests(test_files.call, covering)
27
+ return fallback.call if covered.empty?
28
+
29
+ SignatureLine.on?(mutant) ? covered | fallback.call : covered
24
30
  end
25
31
 
26
32
  private
27
33
 
34
+ def covered_integration_tests(integration_tests, covering)
35
+ covering_paths = covering.map { |test| File.expand_path(test) }
36
+ Array(integration_tests).select do |test|
37
+ covering_paths.include?(File.expand_path(test))
38
+ end
39
+ end
40
+
28
41
  def per_test_coverage(reports_dir)
29
42
  @per_test_coverage ||= {}
30
43
  @per_test_coverage[reports_dir] ||= PerTestCoverage.new(
@@ -13,10 +13,15 @@ module Henitai
13
13
  self
14
14
  end
15
15
 
16
+ # Also wake on these sockets; +provider+ is asked for them on every wait,
17
+ # so a connection that was lost can drop out of the set.
18
+ def watch(&provider)
19
+ @watched = provider
20
+ self
21
+ end
22
+
16
23
  def wait(timeout)
17
- # rubocop:disable Lint/IncompatibleIoSelectWithFiberScheduler
18
- IO.select([reader], nil, nil, timeout)
19
- # rubocop:enable Lint/IncompatibleIoSelectWithFiberScheduler
24
+ IO.select([reader, *@watched&.call], nil, nil, timeout)
20
25
  rescue Errno::EINTR
21
26
  nil
22
27
  end
@@ -27,6 +27,10 @@ module Henitai
27
27
  Process.wait(pid)
28
28
  end
29
29
 
30
+ def pause(seconds)
31
+ Kernel.sleep(seconds)
32
+ end
33
+
30
34
  def trap(signal, handler = nil, &block)
31
35
  Kernel.trap(signal, handler || block)
32
36
  end
@@ -35,10 +39,11 @@ module Henitai
35
39
  # Loop primitives the {SlotScheduler} reads back from its host.
36
40
  attr_reader :worker_count, :runtime, :wakeup
37
41
 
38
- def initialize(worker_count:, runtime: Runtime.new, wakeup: nil)
42
+ def initialize(worker_count:, runtime: Runtime.new, wakeup: nil, flaky_tests: nil)
39
43
  @worker_count = worker_count
40
44
  @runtime = runtime
41
45
  @wakeup = wakeup
46
+ @flaky_tests = flaky_tests
42
47
  @shutdown_requested = false
43
48
  end
44
49
 
@@ -85,18 +90,21 @@ module Henitai
85
90
  def prepare_run(mutants, integration, config, progress_reporter, options)
86
91
  @shutdown_requested = false
87
92
  @wakeup = Henitai::ProcessWakeup.new.install if @wakeup.nil?
93
+ @wakeup.watch { @runtime.wakeup_ios } if @runtime.respond_to?(:wakeup_ios)
88
94
  @scheduler = SlotScheduler.new(
89
95
  integration: integration,
90
96
  config: config,
91
97
  progress_reporter: progress_reporter,
92
98
  options: options,
93
- host: self
99
+ host: self,
100
+ flaky_tests: @flaky_tests
94
101
  )
95
102
  @scheduler.enqueue(mutants)
96
103
  end
97
104
 
98
105
  def event_loop
99
106
  saved_traps = install_signal_traps
107
+ @scheduler.fill_idle_slots
100
108
  loop do
101
109
  break if @scheduler.done?
102
110
 
@@ -108,7 +116,6 @@ module Henitai
108
116
  end
109
117
 
110
118
  def process_cycle
111
- @scheduler.fill_idle_slots unless @shutdown_requested
112
119
  @scheduler.reap_all_completed_children
113
120
  @scheduler.check_timeouts
114
121
  @scheduler.fill_idle_slots unless @shutdown_requested
@@ -129,14 +136,21 @@ module Henitai
129
136
  end
130
137
 
131
138
  def wait_for_next_event
132
- @wakeup&.wait(@scheduler.next_event_timeout)
139
+ @wakeup&.wait(next_wait_timeout)
133
140
  @wakeup&.drain
134
141
  end
135
142
 
143
+ # An event the runtime already read off its socket cannot wake the select.
144
+ def next_wait_timeout
145
+ return 0 if @runtime.respond_to?(:pending_events?) && @runtime.pending_events?
146
+
147
+ @scheduler.next_event_timeout
148
+ end
149
+
136
150
  def install_signal_traps
137
151
  saved = {}
138
152
  %w[INT TERM HUP].each do |sig|
139
- saved[sig] = @runtime.trap(sig) { @shutdown_requested = true }
153
+ saved[sig] = @runtime.trap(sig) { request_shutdown }
140
154
  end
141
155
  saved
142
156
  end
@@ -71,6 +71,15 @@ module Henitai
71
71
  def canonical_path
72
72
  File.join(config.reports_dir, "mutation-report.json")
73
73
  end
74
+
75
+ # Scoped runs upload the merged canonical report so that they do not
76
+ # replace the full report with a partial one.
77
+ def merged_schema_for(result)
78
+ schema = result.to_stryker_schema
79
+ return schema if authoritative?(result)
80
+
81
+ CanonicalReportMerger.merge(schema, canonical_path, prune_missing: true)
82
+ end
74
83
  end
75
84
 
76
85
  # Terminal reporter.
@@ -395,6 +404,8 @@ module Henitai
395
404
 
396
405
  FileUtils.mkdir_p(File.dirname(history_report_path))
397
406
  File.write(history_report_path, JSON.pretty_generate(store.trend_report))
407
+ rescue StandardError => e
408
+ warn "henitai: could not export mutation history (#{e.class}: #{e.message})"
398
409
  end
399
410
 
400
411
  def default_history_store
@@ -450,10 +461,7 @@ module Henitai
450
461
  end
451
462
 
452
463
  def schema_for(result)
453
- schema = result.to_stryker_schema
454
- return schema if authoritative?(result)
455
-
456
- CanonicalReportMerger.merge(schema, canonical_path, prune_missing: true)
464
+ merged_schema_for(result)
457
465
  end
458
466
 
459
467
  def escaped_report_json(schema)
@@ -515,9 +523,8 @@ module Henitai
515
523
  end
516
524
 
517
525
  def ignore_reason_for(mutant)
518
- return nil unless mutant.respond_to?(:ignore_reason)
519
-
520
- reason = mutant.ignore_reason
526
+ reason = mutant.status_reason if mutant.respond_to?(:status_reason)
527
+ reason ||= mutant.ignore_reason if mutant.respond_to?(:ignore_reason)
521
528
  reason ? "(#{reason})" : nil
522
529
  end
523
530
 
@@ -609,7 +616,7 @@ module Henitai
609
616
 
610
617
  def build_request(result, uri)
611
618
  request = Net::HTTP::Put.new(uri.request_uri, request_headers)
612
- request.body = JSON.generate(result.to_stryker_schema)
619
+ request.body = JSON.generate(merged_schema_for(result))
613
620
  request
614
621
  end
615
622