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
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ class Mutant
5
+ # Resolves the object a re-evaluated +def+ must be defined on, and loads
6
+ # the subject's source file when its namespace is still undefined.
7
+ class LexicalTarget
8
+ # Raised when a subject's namespace is still undefined after its source
9
+ # file was loaded, so there is no class to reopen.
10
+ class SubjectNotLoadedError < StandardError; end
11
+
12
+ def self.resolve(subject, activation)
13
+ new.resolve(subject, activation)
14
+ end
15
+
16
+ def self.load_target(subject)
17
+ new.load_target(subject)
18
+ end
19
+
20
+ # The object the re-evaluated +def+ defines its method on, as Ruby
21
+ # resolves it: the openers are evaluated with +self+ -- or the receiver
22
+ # of +def Other.name+ -- as their body. The subject's namespace is only
23
+ # the resolver's static reading of the source, which cannot tell
24
+ # whether +Other+ inside +module Outer+ is +Outer::Other+ or +::Other+.
25
+ # The scope, owner and singleton flag come from the activation, so a
26
+ # replayed recipe does not need the AST. The subject's file is loaded
27
+ # first when needed (see +ensure_loaded+), so that evaluating the openers
28
+ # reopens the loaded class instead of creating an empty one.
29
+ def resolve(subject, activation)
30
+ scope = activation.lexical_scope
31
+ ensure_loaded(subject, scope)
32
+ source = "#{scope.join('; ')}\n#{activation.owner}\n#{Array.new(scope.size, 'end').join('; ')}"
33
+ owner = TOPLEVEL_BINDING.eval(source, activation.file, activation.line)
34
+ activation.singleton ? owner.singleton_class : owner
35
+ end
36
+
37
+ def load_target(subject)
38
+ Object.const_get(subject.namespace)
39
+ rescue NameError
40
+ load_source_file(subject)
41
+ Object.const_get(subject.namespace)
42
+ end
43
+
44
+ private
45
+
46
+ # Evaluating the openers must reopen existing constants, never create
47
+ # them: a missing class would become an empty phantom that swallows the
48
+ # mutant. The scope is therefore checked without evaluation first. If it
49
+ # does not resolve, the subject's file is loaded and checked again. If it
50
+ # still does not resolve, activation fails instead of creating anything.
51
+ def ensure_loaded(subject, scope)
52
+ return load_toplevel_source(subject) if scope.empty?
53
+ return if LexicalScopeCheck.resolves?(scope)
54
+
55
+ load_subject_source(subject)
56
+ return if LexicalScopeCheck.resolves?(scope)
57
+
58
+ raise SubjectNotLoadedError,
59
+ "cannot activate #{subject.expression}: its source did not define #{subject.namespace}"
60
+ end
61
+
62
+ # Without openers no phantom class can be created; the file is loaded
63
+ # only when the subject's namespace constant is still missing. A file
64
+ # that fails to load fails the activation instead of crashing the child.
65
+ def load_toplevel_source(subject)
66
+ load_target(subject)
67
+ rescue NameError
68
+ nil
69
+ rescue StandardError, ScriptError => e
70
+ raise SubjectNotLoadedError, "cannot activate #{subject.namespace}: its source failed to load (#{e.message})"
71
+ end
72
+
73
+ def load_subject_source(subject)
74
+ load_source_file(subject)
75
+ rescue StandardError, ScriptError => e
76
+ raise SubjectNotLoadedError, "cannot activate #{subject.namespace}: its source failed to load (#{e.message})"
77
+ end
78
+
79
+ def load_source_file(subject)
80
+ source_file = subject.source_file || source_file_from_ast(subject)
81
+ return unless source_file && File.file?(source_file)
82
+
83
+ Thread.current[:henitai_filter_const_warnings] = true
84
+ load(source_file)
85
+ loaded_feature = File.expand_path(source_file)
86
+ $LOADED_FEATURES << loaded_feature unless $LOADED_FEATURES.include?(loaded_feature)
87
+ ensure
88
+ Thread.current[:henitai_filter_const_warnings] = false
89
+ end
90
+
91
+ def source_file_from_ast(subject)
92
+ ast_node = subject.ast_node
93
+ return unless ast_node
94
+
95
+ location = ast_node.location
96
+ return unless location
97
+
98
+ expression = location.expression
99
+ return unless expression
100
+
101
+ expression.source_buffer.name
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "unparser"
4
+
5
+ module Henitai
6
+ class Mutant
7
+ # Renders the source text spliced in place of a mutant's original node.
8
+ #
9
+ # The text replaces only the original node's range, so it must parse as
10
+ # one expression wherever it lands. An operator expression such as
11
+ # `a || b` does not: spliced into `a && b && c` it reads as
12
+ # `a || (b && c)`. Operator expressions are therefore parenthesised;
13
+ # parentheses never change an expression's meaning, missing ones can. An
14
+ # unmutated control keeps the original text.
15
+ class Replacement
16
+ OPERATOR_METHODS = %i[
17
+ + - * / % ** == != < > <= >= <=> === =~ !~ << >> & | ^ ! ~ -@ +@
18
+ ].freeze
19
+
20
+ def self.render(mutant, original_range)
21
+ new(mutant, original_range).render
22
+ end
23
+
24
+ def initialize(mutant, original_range)
25
+ @mutant = mutant
26
+ @original_range = original_range
27
+ end
28
+
29
+ def render
30
+ return @original_range.source if @mutant.mutated_node.equal?(@mutant.original_node)
31
+
32
+ text = unparse(@mutant.mutated_node)
33
+ operator_expression?(@mutant.mutated_node) ? "(#{text})" : text
34
+ end
35
+
36
+ private
37
+
38
+ def unparse(node)
39
+ Unparser.unparse(node)
40
+ rescue StandardError => e
41
+ raise Unparser::UnsupportedNodeError, e.message
42
+ end
43
+
44
+ def operator_expression?(node)
45
+ return false unless node.is_a?(Parser::AST::Node)
46
+ return true if %i[and or].include?(node.type)
47
+
48
+ %i[send csend].include?(node.type) && OPERATOR_METHODS.include?(node.children[1])
49
+ end
50
+ end
51
+ end
52
+ end
@@ -21,6 +21,11 @@ module Henitai
21
21
 
22
22
  autoload :Activator, "henitai/mutant/activator"
23
23
  autoload :ParameterSource, "henitai/mutant/parameter_source"
24
+ autoload :Activation, "henitai/mutant/activation"
25
+ autoload :LexicalScopeCheck, "henitai/mutant/lexical_scope_check"
26
+ autoload :LexicalTarget, "henitai/mutant/lexical_target"
27
+ autoload :DefInjection, "henitai/mutant/def_injection"
28
+ autoload :Replacement, "henitai/mutant/replacement"
24
29
 
25
30
  # Status-Vokabular folgt dem Stryker mutation-testing-report-schema.
26
31
  # :equivalent ist ein Henitai-interner Status (wird im JSON als "Ignored" serialisiert,
@@ -40,9 +45,12 @@ module Henitai
40
45
 
41
46
  attr_reader :id, :subject, :operator, :original_node, :mutated_node,
42
47
  :mutation_type, :description, :location,
43
- :precomputed_stable_id, :precomputed_activation_source
48
+ :precomputed_stable_id, :precomputed_activation, :precomputed_replacement
44
49
  attr_accessor :status, :killing_test, :duration, :covered_by, :tests_completed,
45
- :ignore_reason, :from_cache
50
+ :ignore_reason, :status_reason, :from_cache
51
+ # Set by MutantGenerator only for mutants whose identity would otherwise
52
+ # collide with another one (see MutantIdentity).
53
+ attr_accessor :identity_discriminator
46
54
 
47
55
  # @param subject [Subject] the subject being mutated
48
56
  # @param operator [Symbol] operator name, e.g. :ArithmeticOperator
@@ -51,7 +59,8 @@ module Henitai
51
59
  # @param location [Hash] { file:, start_line:, end_line:, start_col:, end_col: }
52
60
  # rubocop:disable Metrics/ParameterLists
53
61
  def initialize(subject:, operator:, nodes:, description:, location:,
54
- precomputed_stable_id: nil, precomputed_activation_source: nil)
62
+ precomputed_stable_id: nil, precomputed_activation: nil,
63
+ precomputed_replacement: nil)
55
64
  @id = SecureRandom.uuid
56
65
  @subject = subject
57
66
  @operator = operator
@@ -60,10 +69,9 @@ module Henitai
60
69
  @description = description
61
70
  @location = location
62
71
  @precomputed_stable_id = precomputed_stable_id
63
- @precomputed_activation_source = precomputed_activation_source
64
- @status = :pending
65
- @killing_test = @duration = @covered_by = @tests_completed = @ignore_reason = nil
66
- @from_cache = false
72
+ @precomputed_activation = precomputed_activation
73
+ @precomputed_replacement = precomputed_replacement
74
+ reset_outcome
67
75
  end
68
76
  # rubocop:enable Metrics/ParameterLists
69
77
 
@@ -73,7 +81,9 @@ module Henitai
73
81
 
74
82
  # Unparsing is expensive and the value is needed by both the JSON and
75
83
  # the terminal reporter — compute once per mutant.
76
- def mutated_source = @mutated_source ||= safe_unparse(mutated_node)
84
+ def mutated_source
85
+ @mutated_source ||= @precomputed_replacement || safe_unparse(mutated_node)
86
+ end
77
87
 
78
88
  def killed? = @status == :killed
79
89
  def from_cache? = @from_cache == true
@@ -85,5 +95,14 @@ module Henitai
85
95
  def to_s
86
96
  "#{operator}@#{location[:file]}:#{location[:start_line]} — #{description}"
87
97
  end
98
+
99
+ private
100
+
101
+ def reset_outcome
102
+ @status = :pending
103
+ @killing_test = @duration = @covered_by = @tests_completed = nil
104
+ @ignore_reason = @status_reason = nil
105
+ @from_cache = false
106
+ end
88
107
  end
89
108
  end
@@ -6,7 +6,7 @@ require_relative "source_parser"
6
6
  module Henitai
7
7
  # Traverses a subject's AST and asks operators to build mutants.
8
8
  class MutantGenerator
9
- def generate(subjects, operators, config: nil)
9
+ def generate(subjects, operators, config: nil, sample: true)
10
10
  normalized_operators = normalize_operators(operators)
11
11
  arid_node_filter = AridNodeFilter.new
12
12
  syntax_validator = SyntaxValidator.new
@@ -22,7 +22,12 @@ module Henitai
22
22
  )
23
23
  end
24
24
 
25
- sample_mutants(mutants, config:, sampling_strategy:)
25
+ # Deduplicate programs before sampling: unsampled generation (survivor reruns) must keep the same
26
+ # representative of a duplicate program as a sampled run, or a reported survivor's stable id would
27
+ # not be found again. Sampling is stratified by operator within each subject, using a seeded
28
+ # per-mutant order, and returns kept mutants in input order.
29
+ unique = distinct_programs(distinct_identities(mutants))
30
+ sample ? sample_mutants(unique, config:, sampling_strategy:) : unique
26
31
  end
27
32
 
28
33
  private
@@ -71,14 +76,12 @@ module Henitai
71
76
  def walk(node, parent: nil)
72
77
  return unless node.is_a?(Parser::AST::Node)
73
78
 
74
- # Str children of a non-heredoc dstr are raw text segments embedded
75
- # inside a quoted interpolated string. They have no surrounding quotes
76
- # in the source, so replacing them via source-fragment substitution
77
- # would insert a quoted literal into the raw-text position and produce
78
- # a SyntaxError when the mutant is activated.
79
- # Heredoc dstr children are exempt: the heredoc body is plain text, so
80
- # inserting "" there stays valid Ruby.
81
- return if embedded_non_heredoc_dstr_str?(node, parent)
79
+ # A str node inside an interpolated string, heredoc, symbol, command,
80
+ # regexp or word array is raw text without quotes of its own.
81
+ # Replacing it would insert a quoted literal into the text -- a
82
+ # different mutation than reported, or a SyntaxError -- so it is not
83
+ # a StringLiteral site; its container is mutated instead.
84
+ return if raw_text_segment?(node, parent) || shorthand_value?(node, parent)
82
85
 
83
86
  apply_operators(node) if node_within_subject_range?(node)
84
87
  node.children.each do |child|
@@ -86,18 +89,29 @@ module Henitai
86
89
  end
87
90
  end
88
91
 
89
- def embedded_non_heredoc_dstr_str?(node, parent)
90
- node.type == :str &&
91
- parent&.type == :dstr &&
92
- !heredoc_node?(parent)
92
+ RAW_TEXT_CONTAINERS = %i[dstr dsym xstr regexp].freeze
93
+ private_constant :RAW_TEXT_CONTAINERS
94
+
95
+ def raw_text_segment?(node, parent)
96
+ return false unless node.type == :str && parent
97
+
98
+ RAW_TEXT_CONTAINERS.include?(parent.type) || word_array?(parent)
93
99
  end
94
100
 
95
- def heredoc_node?(node)
96
- node.location.respond_to?(:heredoc_body) && node.location.heredoc_body
101
+ # The value of +build(config:)+ is an implicit call that shares its
102
+ # source range with the key; replacing it would rewrite the key.
103
+ def shorthand_value?(node, parent)
104
+ return false unless parent&.type == :pair && node.equal?(parent.children[1])
105
+
106
+ node.location.expression == parent.children[0].location.expression
107
+ end
108
+
109
+ def word_array?(node)
110
+ node.type == :array && node.location.begin&.source.to_s.start_with?("%")
97
111
  end
98
112
 
99
113
  def apply_operators(node)
100
- return if @arid_node_filter.suppressed?(node, @config)
114
+ return if @arid_node_filter.suppressed?(node)
101
115
 
102
116
  @operators_by_node_type[node.type].each do |operator|
103
117
  operator.mutate(node, subject: @subject).each do |mutant|
@@ -132,6 +146,35 @@ module Henitai
132
146
  end
133
147
  end
134
148
 
149
+ # Mutants sharing an identity are told apart by where their original node
150
+ # ends; those that also end at the same place are the same mutation
151
+ # twice, and only the first is kept.
152
+ def distinct_identities(mutants)
153
+ mutants.group_by { |mutant| MutantIdentity.stable_id(mutant) }.each_value do |group|
154
+ group.each { |mutant| mutant.identity_discriminator = node_end(mutant) } if group.size > 1
155
+ end
156
+ mutants.uniq { |mutant| MutantIdentity.stable_id(mutant) }
157
+ end
158
+
159
+ # Different operators can produce the same program -- BooleanLiteral's
160
+ # `return false` for `return true if x` is also ReturnValue's. Running it
161
+ # twice would count one result twice; the first mutant is kept. Mutants
162
+ # without a computable activation are left to the later checks.
163
+ def distinct_programs(mutants)
164
+ seen = {}
165
+ mutants.select do |mutant|
166
+ program = Mutant::Activator.activation_source_for(mutant)
167
+ key = [mutant.subject, program]
168
+ program.nil? || (!seen.key?(key) && (seen[key] = true))
169
+ end
170
+ end
171
+
172
+ def node_end(mutant)
173
+ range = mutant.original_node&.location&.expression
174
+ start = mutant.subject.source_range&.begin
175
+ range && start ? "#{range.last_line - start}:#{range.last_column}" : range&.end_pos.to_s
176
+ end
177
+
135
178
  def sample_mutants(mutants, config:, sampling_strategy:)
136
179
  sampling = config&.sampling
137
180
  return mutants unless sampling
@@ -140,7 +183,8 @@ module Henitai
140
183
  sampling_strategy.sample(
141
184
  mutants,
142
185
  ratio: sampling[:ratio],
143
- strategy: sampling[:strategy] || :stratified
186
+ strategy: sampling[:strategy] || :stratified,
187
+ seed: sampling[:seed] || SamplingStrategy::DEFAULT_SEED
144
188
  )
145
189
  end
146
190
 
@@ -29,7 +29,7 @@ module Henitai
29
29
  def killed_cache_bindings(mutant)
30
30
  [
31
31
  VerdictFingerprint.subject_source_hash(mutant),
32
- VerdictFingerprint.tests_fingerprint(covered_tests_for(mutant))
32
+ VerdictFingerprint.tests_fingerprint(covered_tests_for(mutant), context_sha: reuse_context&.fingerprint)
33
33
  ]
34
34
  end
35
35
 
@@ -38,34 +38,35 @@ module Henitai
38
38
  # which reflects selector fallback and test_excludes and would
39
39
  # permanently mismatch the live set. When the fingerprint cannot be
40
40
  # recomputed (no per-test map, recipe stubs without a source range),
41
- # the previously stored bindings are kept: the filter re-validates
42
- # everything live before reuse, so a carried-forward fingerprint can
43
- # never validate a changed state.
41
+ # the previously stored bindings are kept, but only from a survived
42
+ # row of the same subject source: a killed row's or an edited method's
43
+ # fingerprints would vouch for a state this survivor never ran in.
44
44
  def survived_cache_bindings(mutant, existing_row)
45
45
  bindings = [
46
46
  VerdictFingerprint.subject_source_hash(mutant),
47
47
  survived_tests_fingerprint(mutant)
48
48
  ]
49
49
  return bindings unless bindings.any?(&:nil?)
50
+ return [nil, nil] unless carry_survived_bindings?(bindings.first, existing_row)
50
51
 
51
52
  existing_cache_bindings(existing_row)
52
53
  end
53
54
 
55
+ def carry_survived_bindings?(source_hash, existing_row)
56
+ return false unless existing_row && existing_row["current_status"] == "survived"
57
+
58
+ source_hash.nil? || source_hash == existing_row["subject_source_hash"]
59
+ end
60
+
54
61
  def survived_tests_fingerprint(mutant)
55
62
  return nil unless per_test_coverage
56
63
 
57
64
  VerdictFingerprint.survivor_tests_fingerprint(
58
65
  per_test_coverage.tests_covering(mutant),
59
- dependency_sha: recording_dependency_sha
66
+ context_sha: reuse_context&.fingerprint
60
67
  )
61
68
  end
62
69
 
63
- # One dependency sha per record call set, computed lazily so runs
64
- # without survivors never touch the dependency files.
65
- def recording_dependency_sha
66
- @recording_dependency_sha ||= VerdictFingerprint.dependency_fingerprint
67
- end
68
-
69
70
  def existing_cache_bindings(existing_row)
70
71
  return [nil, nil] unless existing_row
71
72
 
@@ -17,9 +17,12 @@ module Henitai
17
17
  # @param per_test_coverage [PerTestCoverage, nil] live per-test coverage
18
18
  # view used to record the full-map intersection set for survived
19
19
  # verdicts; without it survived rows stay NULL (never reusable).
20
- def initialize(path:, per_test_coverage: nil)
20
+ # @param reuse_context [ReuseContext, nil] fingerprint recorded with every
21
+ # cached verdict; without it no verdict is cached (never reusable).
22
+ def initialize(path:, per_test_coverage: nil, reuse_context: nil)
21
23
  @path = path
22
24
  @per_test_coverage = per_test_coverage
25
+ @reuse_context = reuse_context
23
26
  end
24
27
 
25
28
  attr_reader :path
@@ -30,7 +33,7 @@ module Henitai
30
33
  with_database do |db|
31
34
  ensure_schema(db)
32
35
  db.transaction do
33
- insert_run(db, result, version, recorded_at) unless partial_rerun?(result)
36
+ insert_run(db, result, version, recorded_at) if full_run?(result)
34
37
  Array(result.mutants).each do |mutant|
35
38
  upsert_mutant(db, mutant, version, recorded_at)
36
39
  end
@@ -72,7 +75,7 @@ module Henitai
72
75
 
73
76
  private
74
77
 
75
- attr_reader :per_test_coverage
78
+ attr_reader :per_test_coverage, :reuse_context
76
79
 
77
80
  def verdict_from_row(row)
78
81
  return nil unless row && VerdictCache::CACHEABLE_STATUSES.include?(row["current_status"])
@@ -85,6 +88,11 @@ module Henitai
85
88
  }
86
89
  end
87
90
 
91
+ def full_run?(result)
92
+ authoritative = !result.respond_to?(:authoritative?) || result.authoritative?
93
+ authoritative && !partial_rerun?(result)
94
+ end
95
+
88
96
  def partial_rerun?(result)
89
97
  result.respond_to?(:partial_rerun?) && result.partial_rerun?
90
98
  end
@@ -133,8 +141,10 @@ module Henitai
133
141
  end
134
142
  end
135
143
 
144
+ # Recipe stubs carry a precomputed id that cannot be recomputed from
145
+ # their (absent) AST nodes.
136
146
  def stable_mutant_id(mutant)
137
- MutantIdentity.stable_id(mutant)
147
+ mutant.respond_to?(:stable_id) ? mutant.stable_id : MutantIdentity.stable_id(mutant)
138
148
  end
139
149
 
140
150
  def mutation_history_entry(mutant, version, recorded_at)
@@ -19,8 +19,12 @@ module Henitai
19
19
  digest(legacy_identity_components(mutant))
20
20
  end
21
21
 
22
+ # The discriminator is present only for mutants that would otherwise
23
+ # share an identity -- nested nodes starting at the same position, such
24
+ # as the calls of `x.a.b.c` -- so every other identity stays unchanged.
22
25
  def self.identity_components(mutant)
23
- legacy_identity_components(mutant) + [site_offset(mutant)]
26
+ discriminator = mutant.respond_to?(:identity_discriminator) && mutant.identity_discriminator
27
+ legacy_identity_components(mutant) + [site_offset(mutant)] + [discriminator].compact
24
28
  end
25
29
  private_class_method :identity_components
26
30
 
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Classifies one mutant run from the child's own report and the way the
5
+ # child process ended.
6
+ #
7
+ # The child reports what it got to: a failed activation, a finished test run
8
+ # with the exit code the tests returned, or a SystemExit raised while the
9
+ # tests ran. Only a finished test run yields a test verdict (Killed or
10
+ # Survived); everything else is a harness or runtime status that stays out of
11
+ # the detected count. In particular a child that died without a report, or
12
+ # was killed by a signal, is a RuntimeError, never a kill.
13
+ class MutantVerdict
14
+ def initialize(wait_result:, report:, stdout:, stderr:)
15
+ @wait_result = wait_result
16
+ @report = report
17
+ @output = "#{stdout}\n#{stderr}"
18
+ end
19
+
20
+ # @return [Array(Symbol, String|nil)] status and reason
21
+ def to_a
22
+ return [:timeout, nil] if @wait_result == :timeout
23
+ return [:runtime_error, "child terminated by signal #{signal_name}"] if signaled?
24
+ return [:runtime_error, "child exited with status #{exit_status} without reporting a result"] unless @report
25
+
26
+ reported_verdict
27
+ end
28
+
29
+ # @return [Array<String>] expanded paths of the test files that failed
30
+ # in the child, or an empty list when the child did not report them or
31
+ # also saw a failure it could not attribute to a file (which makes the
32
+ # kill genuine, never one a flaky file alone may have caused)
33
+ def killing_tests
34
+ return [] if @report.nil? || @report["unattributed_failures"]
35
+
36
+ Array(@report["failed_tests"]).map { |path| File.expand_path(path) }
37
+ end
38
+
39
+ private
40
+
41
+ def reported_verdict
42
+ case @report["outcome"]
43
+ when "activation_failed" then [:compile_error, "activation failed: #{@report['reason']}"]
44
+ when "environment_failed"
45
+ [:compile_error, "the test files failed to load, before the mutant was activated"]
46
+ when "system_exit"
47
+ [:runtime_error, "the test run ended early with SystemExit (status #{@report['status']})"]
48
+ when "tests_finished" then test_verdict
49
+ else [:runtime_error, "child reported an unknown outcome: #{@report['outcome']}"]
50
+ end
51
+ end
52
+
53
+ def test_verdict
54
+ return [:compile_error, "no examples ran"] if ExampleSummary.none_ran?(@output)
55
+
56
+ @report["exit_code"].to_i.zero? ? [:survived, nil] : [:killed, nil]
57
+ end
58
+
59
+ def signaled?
60
+ @wait_result.respond_to?(:signaled?) && @wait_result.signaled?
61
+ end
62
+
63
+ def signal_name
64
+ "SIG#{Signal.signame(@wait_result.termsig)}"
65
+ end
66
+
67
+ def exit_status
68
+ @wait_result.respond_to?(:exitstatus) ? @wait_result.exitstatus : "unknown"
69
+ end
70
+ end
71
+ end
@@ -12,7 +12,11 @@ module Henitai
12
12
  NODE_TYPES
13
13
  end
14
14
 
15
+ # A list without brackets (rescue classes, multiple return values,
16
+ # multiple assignment) is not an array literal and has no empty form.
15
17
  def mutate(node, subject:)
18
+ return [] unless node.location.begin
19
+
16
20
  if node.children.empty?
17
21
  [empty_array_mutant(subject:, node:)]
18
22
  else
@@ -51,6 +51,8 @@ module Henitai
51
51
  when :casgn
52
52
  namespace, name = left.children
53
53
  Parser::AST::Node.new(:casgn, [namespace, name, right])
54
+ when :indexasgn
55
+ left.updated(nil, [*left.children, right])
54
56
  when :send
55
57
  assignment_name = left.children[1] == :[] ? :[]= : :"#{left.children[1]}="
56
58
  receiver, _method_name, *arguments = left.children
@@ -28,6 +28,8 @@ module Henitai
28
28
  private
29
29
 
30
30
  def mutate_if(node, subject:)
31
+ return elsif_condition_mutants(node, subject:) if elsif?(node)
32
+
31
33
  condition, then_branch, else_branch = node.children
32
34
  mutations = condition_variants(node, subject:, condition:)
33
35
 
@@ -38,6 +40,22 @@ module Henitai
38
40
  mutations
39
41
  end
40
42
 
43
+ # An elsif's source range starts at `elsif` and has no `end`, so a
44
+ # re-rendered `if ... end` cannot take its place. Only its condition is
45
+ # mutated, in place; removing a branch is what "replaced condition with
46
+ # false" already does.
47
+ def elsif_condition_mutants(node, subject:)
48
+ condition = node.children.first
49
+ [[true_node, "replaced condition with true"], [false_node, "replaced condition with false"],
50
+ [negate(condition), "negated condition"]].map do |replacement, description|
51
+ build_mutant(subject:, original_node: condition, mutated_node: replacement, description:)
52
+ end
53
+ end
54
+
55
+ def elsif?(node)
56
+ node.location.respond_to?(:keyword) && node.location.keyword&.source == "elsif"
57
+ end
58
+
41
59
  def mutate_case(node, subject:)
42
60
  condition = node.children.first
43
61
  when_nodes, else_branch = case_children(node.children.drop(1))
@@ -154,8 +172,10 @@ module Henitai
154
172
  Parser::AST::Node.new(node.type, children)
155
173
  end
156
174
 
175
+ # The parentheses are part of the mutation: `!a > b` would negate only
176
+ # `a` and compare the boolean, which is not the reported mutant.
157
177
  def negate(node)
158
- Parser::AST::Node.new(:send, [node, :!])
178
+ Parser::AST::Node.new(:send, [Parser::AST::Node.new(:begin, [node]), :!])
159
179
  end
160
180
 
161
181
  # rubocop:disable Lint/BooleanSymbol
@@ -9,7 +9,10 @@ module Henitai
9
9
  # set) because framework key normalization makes it frequently unkillable
10
10
  # (ADR-12).
11
11
  class HashLiteral < Henitai::Operator
12
- NODE_TYPES = [:hash].freeze
12
+ # Keyword arguments (kwargs) are mutated like hash literals; the mutant
13
+ # keeps the node type, so a removed keyword never turns the remaining
14
+ # ones into a positional hash.
15
+ NODE_TYPES = %i[hash kwargs].freeze
13
16
 
14
17
  def self.node_types
15
18
  NODE_TYPES
@@ -29,8 +32,8 @@ module Henitai
29
32
  build_mutant(
30
33
  subject:,
31
34
  original_node: node,
32
- mutated_node: Parser::AST::Node.new(:hash, []),
33
- description: "replaced hash with empty hash"
35
+ mutated_node: node.updated(nil, []),
36
+ description: node.type == :kwargs ? "removed keyword arguments" : "replaced hash with empty hash"
34
37
  )
35
38
  end
36
39
 
@@ -52,7 +55,7 @@ module Henitai
52
55
 
53
56
  def hash_without_pair(node, pair_index)
54
57
  remaining = node.children.reject.with_index { |_pair, index| index == pair_index }
55
- Parser::AST::Node.new(:hash, remaining)
58
+ node.updated(nil, remaining)
56
59
  end
57
60
 
58
61
  # Pair keys are always AST nodes (sym/str/…); their first child is the