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,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../fork_server"
4
+
5
+ module Henitai
6
+ class ExecutionEngine
7
+ # Opt-in fork-server execution (`mutation.fork_server`, ADR-15) for the
8
+ # engine's linear path. While the block runs, @fork_server_runner (when a
9
+ # server booted) runs the mutants; it is nil when the flag is off, the
10
+ # integration cannot boot once, or the server failed to boot, and the
11
+ # integration then boots per mutant.
12
+ module ForkServerScope
13
+ private
14
+
15
+ def with_fork_server(mutants, integration, config)
16
+ @fork_server_runner = start_fork_server(mutants, integration, config)
17
+ yield
18
+ ensure
19
+ @fork_server_runner&.stop
20
+ @fork_server_runner = nil
21
+ end
22
+
23
+ def start_fork_server(mutants, integration, config)
24
+ return unless fork_server_requested?(config)
25
+ return warn_fork_server_unsupported unless fork_server_supported?(integration)
26
+
27
+ test_files = mutants.flat_map { |mutant| prioritized_tests_for(mutant, integration, config) }.uniq
28
+ return if test_files.empty?
29
+
30
+ ForkServer.start(integration:, mutants:, test_files:, log_path: fork_server_log_path(config),
31
+ fallback_timeout: ->(files) { resolved_timeout(files, config) })
32
+ end
33
+
34
+ def fork_server_requested?(config)
35
+ config.respond_to?(:fork_server) && config.fork_server ? true : false
36
+ end
37
+
38
+ def fork_server_supported?(integration)
39
+ integration.respond_to?(:fork_server_supported?) && integration.fork_server_supported?
40
+ end
41
+
42
+ def warn_fork_server_unsupported
43
+ warn("henitai: mutation.fork_server is not supported for this integration; using per-mutant boot")
44
+ nil
45
+ end
46
+
47
+ # The scheduler learns of served processes' exits through the server.
48
+ def parallel_runtime_options
49
+ @fork_server_runner&.active? ? { runtime: @fork_server_runner.runtime } : {}
50
+ end
51
+
52
+ def fork_server_log_path(config)
53
+ File.join(config.reports_dir, "mutation-logs", "fork-server.log")
54
+ end
55
+ end
56
+ end
57
+ end
@@ -2,11 +2,13 @@
2
2
 
3
3
  require_relative "process_worker_runner"
4
4
  require_relative "execution_engine/env_scope"
5
+ require_relative "execution_engine/fork_server_scope"
5
6
 
6
7
  module Henitai
7
8
  # Runs pending mutants through the selected integration.
8
9
  class ExecutionEngine
9
10
  include EnvScope
11
+ include ForkServerScope
10
12
 
11
13
  def run(mutants, integration, config, progress_reporter: nil)
12
14
  with_reports_dir(config) do
@@ -23,17 +25,36 @@ module Henitai
23
25
  private
24
26
 
25
27
  def execute(mutants, integration, config, progress_reporter)
26
- @flaky_retry_count = 0
27
28
  pending_mutants = Array(mutants).select(&:pending?)
28
29
  mutex = Mutex.new
29
- if parallel_execution?(config, pending_mutants)
30
- run_parallel(pending_mutants, integration, config, progress_reporter)
31
- else
32
- run_linear(pending_mutants, integration, config, progress_reporter, mutex)
30
+ @flaky_tests = flaky_tests_for(config)
31
+ @retrier = FlakyMutantRetrier.new(flaky_tests: @flaky_tests, max_retries: max_flaky_retries(config))
32
+ run_pending(pending_mutants, integration, config, progress_reporter, mutex)
33
+ warn_flaky_mutants
34
+ verify_controls(pending_mutants, integration, config)
35
+ mutants
36
+ end
37
+
38
+ def run_pending(pending_mutants, integration, config, progress_reporter, mutex)
39
+ with_fork_server(pending_mutants, integration, config) do
40
+ if parallel_execution?(config, pending_mutants)
41
+ run_parallel(pending_mutants, integration, config, progress_reporter)
42
+ else
43
+ run_linear(pending_mutants, integration, config, progress_reporter, mutex)
44
+ @flaky_retry_count = @retrier.retried_count
45
+ end
33
46
  end
47
+ end
34
48
 
35
- warn_flaky_mutants(pending_mutants.size)
36
- mutants
49
+ def verify_controls(mutants, integration, config)
50
+ return unless config.respond_to?(:control_runs) && config.control_runs
51
+
52
+ timeout_for = ->(test_files) { resolved_timeout(test_files, config) }
53
+ stats = ControlRun.new(integration:, timeout_for:).verify(mutants)
54
+ return if stats[:failed].zero?
55
+
56
+ warn format("henitai: control runs failed for %<failed>d of %<verified>d subjects; " \
57
+ "%<reclassified>d detected verdicts reclassified as CompileError", **stats)
37
58
  end
38
59
 
39
60
  def parallel_execution?(config, mutants)
@@ -57,10 +78,14 @@ module Henitai
57
78
  end
58
79
 
59
80
  def run_parallel(mutants, integration, config, progress_reporter)
60
- runner = ProcessWorkerRunner.new(worker_count: worker_count(config))
81
+ runner = ProcessWorkerRunner.new(
82
+ worker_count: worker_count(config),
83
+ flaky_tests: @flaky_tests,
84
+ **parallel_runtime_options
85
+ )
61
86
  results = runner.run(
62
87
  mutants,
63
- integration,
88
+ @fork_server_runner || integration,
64
89
  config,
65
90
  progress_reporter,
66
91
  test_file_resolver: ->(mutant) { prioritized_tests_for(mutant, integration, config) },
@@ -75,8 +100,12 @@ module Henitai
75
100
  record_test_files(mutant, test_files)
76
101
  return record_no_coverage(mutant, progress_reporter, mutex) if test_files.empty?
77
102
 
78
- scenario_result = run_with_flaky_retry(mutant, integration, config, test_files, mutex)
103
+ scenario_result = @retrier.run(
104
+ mutant: mutant, integration: @fork_server_runner || integration, test_files: test_files,
105
+ timeout: resolved_timeout(test_files, config)
106
+ )
79
107
  mutant.status = scenario_status(scenario_result)
108
+ ScenarioExecutionResult.copy_reason(scenario_result, mutant)
80
109
 
81
110
  report_progress(mutant, scenario_result, progress_reporter, mutex)
82
111
  end
@@ -92,19 +121,15 @@ module Henitai
92
121
  end
93
122
 
94
123
  def report_progress(mutant, scenario_result, progress_reporter, mutex)
95
- if mutex
96
- mutex.synchronize { progress_reporter&.progress(mutant, scenario_result:) }
97
- else
98
- progress_reporter&.progress(mutant, scenario_result:)
99
- end
124
+ mutex.synchronize { progress_reporter&.progress(mutant, scenario_result:) }
100
125
  end
101
126
 
102
127
  def prioritized_tests_for(mutant, integration, config)
103
- tests = reject_excluded_tests(integration.select_tests(mutant.subject), config)
104
- tests = per_test_coverage_selector.filter(
105
- tests,
128
+ tests = per_test_coverage_selector.select(
106
129
  mutant,
107
- reports_dir: config.reports_dir
130
+ reports_dir: config.reports_dir,
131
+ test_files: -> { reject_excluded_tests(integration.test_files, config) },
132
+ fallback: -> { reject_excluded_tests(integration.select_tests(mutant.subject), config) }
108
133
  )
109
134
  test_prioritizer(config).sort(tests, mutant, test_history(config))
110
135
  end
@@ -133,11 +158,21 @@ module Henitai
133
158
  def resolved_timeout(test_files, config)
134
159
  return config.timeout unless calibration_enabled?(config)
135
160
 
136
- calibrated = timeout_calibrator(config).timeout_for(test_files)
137
- return [calibrated, max_timeout(config)].min if calibrated
161
+ calibrator = timeout_calibrator(config)
162
+ calibrated = calibrator.timeout_for(test_files)
163
+ unless calibrated
164
+ warn_timeout_fallback_once
165
+ return Configuration::DEFAULT_TIMEOUT
166
+ end
138
167
 
139
- warn_timeout_fallback_once
140
- Configuration::DEFAULT_TIMEOUT
168
+ timeout_ceiling(config).cap(calibrated, calibrator.baseline_for(test_files))
169
+ end
170
+
171
+ def timeout_ceiling(config)
172
+ @timeout_ceiling ||= TimeoutCeiling.new(
173
+ ceiling: max_timeout(config),
174
+ warner: ->(message) { warn(message) }
175
+ )
141
176
  end
142
177
 
143
178
  # Ceiling on the auto-calibrated timeout so a runaway mutant is killed in
@@ -153,13 +188,21 @@ module Henitai
153
188
  config.respond_to?(:timeout_configured?) && !config.timeout_configured?
154
189
  end
155
190
 
191
+ # Boot stays in the timeout even with a fork server: served mutants still
192
+ # run the suite's before(:suite) hooks, which the boot measurement includes.
156
193
  def timeout_calibrator(config)
157
194
  @timeout_calibrator ||= TimeoutCalibrator.new(
158
195
  timing_source: timing_source(config),
159
- multiplier: config.timeout_multiplier
196
+ multiplier: config.timeout_multiplier,
197
+ boot_seconds: -> { boot_seconds(config) }
160
198
  )
161
199
  end
162
200
 
201
+ def boot_seconds(config)
202
+ path = File.join(config.reports_dir, PerTestCoverageCollector::BOOT_FILE_NAME)
203
+ CoverageReportReader.new.boot_seconds(path)
204
+ end
205
+
163
206
  def warn_timeout_fallback_once
164
207
  return if @warned_timeout_fallback
165
208
 
@@ -178,34 +221,9 @@ module Henitai
178
221
  config.history || {}
179
222
  end
180
223
 
181
- # Retry logic is kept in one place to preserve the status transition flow.
182
- # The retry budget is configurable because repeated survivors can multiply
183
- # runtime on real CI workloads.
184
- # rubocop:disable Metrics/MethodLength
185
- def run_with_flaky_retry(mutant, integration, config, test_files, mutex)
186
- timeout = resolved_timeout(test_files, config)
187
- scenario_result = integration.run_mutant(
188
- mutant:,
189
- test_files:,
190
- timeout: timeout
191
- )
192
- return scenario_result unless scenario_status(scenario_result) == :survived
193
-
194
- retries = 0
195
- max_flaky_retries(config).times do
196
- retries += 1
197
- scenario_result = integration.run_mutant(
198
- mutant:,
199
- test_files:,
200
- timeout: timeout
201
- )
202
- break unless scenario_status(scenario_result) == :survived
203
- end
204
-
205
- mutex.synchronize { @flaky_retry_count += 1 } if retries.positive?
206
- scenario_result
224
+ def flaky_tests_for(config)
225
+ FlakyTestRegistry.for_config(config)
207
226
  end
208
- # rubocop:enable Metrics/MethodLength
209
227
 
210
228
  def scenario_status(result)
211
229
  return result if result.is_a?(Symbol)
@@ -213,17 +231,14 @@ module Henitai
213
231
  result.status
214
232
  end
215
233
 
216
- def warn_flaky_mutants(total_mutants)
217
- return if total_mutants.zero?
218
-
219
- flaky_ratio = @flaky_retry_count.to_f / total_mutants
220
- return unless flaky_ratio > 0.05
234
+ def warn_flaky_mutants
235
+ return if @flaky_retry_count.to_i.zero?
221
236
 
222
237
  warn format(
223
- "Flaky-test mitigation: %<flaky>d/%<total>d mutants required retries (%<ratio>.2f%%)",
224
- flaky: @flaky_retry_count,
225
- total: total_mutants,
226
- ratio: flaky_ratio * 100.0
238
+ "Flaky-test mitigation: %<count>d killed mutant(s) were re-checked because their killing tests " \
239
+ "are flaky (%<files>s)",
240
+ count: @flaky_retry_count,
241
+ files: @flaky_tests.all.join(", ")
227
242
  )
228
243
  end
229
244
 
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "parser_current"
5
+ require_relative "source_parser"
6
+
7
+ module Henitai
8
+ # Verifies, before a mutant runs, that its activation source differs from
9
+ # the original method by exactly the reported mutation and nothing else.
10
+ #
11
+ # The checks run in order:
12
+ #
13
+ # 1. Site: the mutated node must lie inside the method body that the
14
+ # activation replaces.
15
+ # 2. Syntax: the activation source must be valid Ruby in context. Prism's
16
+ # error list is used because the parser translation layer recovers from
17
+ # errors silently.
18
+ # 3. Structure: the activated method body, re-parsed, must equal the
19
+ # original body with the original node replaced by the mutated node.
20
+ # Both sides are normalised first: parentheses around one expression
21
+ # are dropped, an interpolation-free +dstr+ is the same value as one
22
+ # +str+, nested statement sequences flatten, an empty kwargs node is no
23
+ # argument, and +__FILE__+ compares as an absolute path.
24
+ #
25
+ # A violation is a harness defect, not a test verdict: the mutant must not
26
+ # run, and the reason must be reported.
27
+ class FidelityCheck
28
+ # @return [String, nil] the reason the mutant is unfaithful, or nil
29
+ def violation(mutant)
30
+ return unless checkable?(mutant)
31
+
32
+ activation = Mutant::Activator.activation_for(mutant)
33
+ return "activation source could not be built" unless activation
34
+
35
+ whole_def = activation.mode == :toplevel
36
+ expected = expected_tree(mutant, whole_def)
37
+ return "mutation site is outside the executed method body" unless expected
38
+
39
+ syntax_violation(activation.source) || structure_violation(mutant, expected, activation, whole_def)
40
+ rescue StandardError => e
41
+ "fidelity could not be verified (#{e.class}: #{e.message})"
42
+ end
43
+
44
+ private
45
+
46
+ def checkable?(mutant)
47
+ return false unless mutant.original_node
48
+
49
+ subject = mutant.subject
50
+ !subject.method_name.nil? && !subject.ast_node.nil?
51
+ end
52
+
53
+ def syntax_violation(source)
54
+ error = Prism.parse(source).errors.first
55
+ "activation source is not valid Ruby: #{error.message}" if error
56
+ end
57
+
58
+ def structure_violation(mutant, expected, activation, whole_def)
59
+ source = activation.source
60
+ tree = SourceParser.parse(source, path: activation.file || Mutant::Activator::EVAL_FILE,
61
+ line: activation.line || 1)
62
+ actual = normalize(activated_tree(tree, mutant.subject.method_name, whole_def))
63
+ expected = normalize(expected)
64
+ "activated code differs from the reported mutation" unless actual == expected
65
+ end
66
+
67
+ # A re-evaluated def (ADR-13) is compared as a whole, parameters
68
+ # included; the define_method fallback only by body.
69
+ def expected_tree(mutant, whole_def)
70
+ definition = mutant.subject.ast_node
71
+ scope = whole_def ? definition : method_body(definition)
72
+ replaced, found = substitute(scope, mutant.original_node, mutant.mutated_node)
73
+ replaced if found
74
+ end
75
+
76
+ def method_body(node)
77
+ case node.type
78
+ when :defs then node.children[3]
79
+ when :def, :block then node.children[2]
80
+ end
81
+ end
82
+
83
+ def substitute(node, target, replacement)
84
+ return [replacement, true] if node.equal?(target)
85
+ return [node, false] unless node.is_a?(Parser::AST::Node)
86
+
87
+ found = false
88
+ children = node.children.map do |child|
89
+ child, child_found = substitute(child, target, replacement)
90
+ found ||= child_found
91
+ child
92
+ end
93
+ [found ? node.updated(nil, children) : node, found]
94
+ end
95
+
96
+ # The method as activated: a re-evaluated +def+ (ADR-13) or the
97
+ # +define_method+ block of the fallback.
98
+ def activated_tree(tree, method_name, whole_def)
99
+ name = method_name.to_sym
100
+ definition = find_node(tree) do |node|
101
+ activated_def?(node, name) || (node.type == :block && define_method_call?(node.children[0], name))
102
+ end
103
+ whole_def ? definition : definition && method_body(definition)
104
+ end
105
+
106
+ def activated_def?(node, name)
107
+ (node.type == :def && node.children[0] == name) || (node.type == :defs && node.children[1] == name)
108
+ end
109
+
110
+ def define_method_call?(call, method_name)
111
+ call.type == :send && call.children[0].nil? && call.children[1] == :define_method &&
112
+ call.children[2]&.type == :sym && call.children[2].children[0] == method_name
113
+ end
114
+
115
+ def find_node(node, &)
116
+ return unless node.is_a?(Parser::AST::Node)
117
+ return node if yield(node)
118
+
119
+ node.children.each do |child|
120
+ found = find_node(child, &)
121
+ return found if found
122
+ end
123
+ nil
124
+ end
125
+
126
+ def normalize(node)
127
+ return node unless node.is_a?(Parser::AST::Node)
128
+ return normalize(node.children[0]) if parenthesised?(node)
129
+ return node.updated(:str, [literal_text(node)]) if literal_dstr?(node)
130
+ return node.updated(nil, [File.expand_path(node.children[0])]) if file_keyword?(node)
131
+
132
+ node.updated(nil, normalized_children(node))
133
+ end
134
+
135
+ # `foo(a, )` -- left behind when all keyword arguments are removed --
136
+ # calls `foo(a)`, so an empty kwargs node is no argument at all.
137
+ def normalized_children(node)
138
+ children = node.children.map { |child| normalize(child) }.reject { |child| empty_kwargs?(child) }
139
+ node.type == :begin ? children.flat_map { |child| statements(child) } : children
140
+ end
141
+
142
+ # Parentheses around one expression carry no meaning once the tree
143
+ # encodes precedence; Mutant::Replacement adds them around operators.
144
+ def parenthesised?(node)
145
+ node.type == :begin && node.children.size == 1
146
+ end
147
+
148
+ def empty_kwargs?(node)
149
+ node.is_a?(Parser::AST::Node) && node.type == :kwargs && node.children.empty?
150
+ end
151
+
152
+ # A statement sequence spliced into another one parses as one flat
153
+ # sequence.
154
+ def statements(node)
155
+ node.is_a?(Parser::AST::Node) && node.type == :begin ? node.children : [node]
156
+ end
157
+
158
+ def file_keyword?(node)
159
+ node.type == :str && node.location&.expression&.source == "__FILE__"
160
+ end
161
+
162
+ # An interpolation-free heredoc parses into a +dstr+ of line segments.
163
+ def literal_dstr?(node)
164
+ node.type == :dstr && node.children.all? { |child| child.type == :str || literal_dstr?(child) }
165
+ end
166
+
167
+ def literal_text(node)
168
+ return node.children[0] if node.type == :str
169
+
170
+ node.children.map { |child| literal_text(child) }.join
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Henitai
6
+ # Runs the suite twice and persists the test files whose outcome differed.
7
+ #
8
+ # Only used when `mutation.flaky_detection` is enabled. Each run records
9
+ # per-file outcomes via the integration's coverage hooks; the files that
10
+ # disagree between the two runs are written to `henitai_flaky_tests.json`.
11
+ class FlakyDetectionBaseline
12
+ def initialize(reports_dir:)
13
+ @reports_dir = reports_dir
14
+ end
15
+
16
+ def run(integration:, test_files:)
17
+ FileUtils.mkdir_p(@reports_dir)
18
+ first, first_result = run_once(integration, test_files)
19
+ second, second_result = run_once(integration, test_files)
20
+ registry = FlakyTestRegistry.from_outcomes(first.outcomes, second.outcomes)
21
+ ensure_usable_suite(registry, [[first, first_result], [second, second_result]])
22
+ registry.write(File.join(@reports_dir, FlakyTestRegistry::FILE_NAME))
23
+ warn_flaky(registry)
24
+ registry
25
+ end
26
+
27
+ private
28
+
29
+ def run_once(integration, test_files)
30
+ outcome_path = TestFileOutcomes.report_path(@reports_dir)
31
+ FileUtils.rm_f(outcome_path)
32
+ result = integration.run_suite(test_files)
33
+ raise CoverageError, "could not record suite outcomes" unless File.exist?(outcome_path)
34
+
35
+ [TestFileOutcomes.load(outcome_path), result]
36
+ end
37
+
38
+ # A failing run is only acceptable as evidence of flakiness: the suite
39
+ # must pass at least once and every file that failed in a failing run must
40
+ # be a flaky file. A run that failed without a file to blame (a load error,
41
+ # a suite-level hook) is never explained.
42
+ def ensure_usable_suite(registry, runs)
43
+ failing = runs.reject { |_outcomes, result| result.survived? }
44
+ return if failing.empty?
45
+ return if failing.size < runs.size && failing.all? { |outcomes, _result| explained?(registry, outcomes) }
46
+
47
+ raise CoverageError, BootstrapErrorMessage.build(failing.first.last)
48
+ end
49
+
50
+ def explained?(registry, outcomes)
51
+ failed = outcomes.failed_files
52
+ !outcomes.unattributed_failures? && !failed.empty? && failed.all? { |file| registry.flaky?(file) }
53
+ end
54
+
55
+ def warn_flaky(registry)
56
+ return if registry.empty?
57
+
58
+ warn("Flaky tests detected (#{registry.all.size}): #{registry.all.join(', ')}")
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Re-runs a mutant whose kill is attributable only to known-flaky test files.
5
+ #
6
+ # Survivors are never retried: a retry could only turn a survivor into a
7
+ # kill, which would inflate the score. A kill is retried only when every
8
+ # killing test file is in the flaky set, up to `max_retries` times.
9
+ class FlakyMutantRetrier
10
+ attr_reader :retried_count
11
+
12
+ def initialize(flaky_tests:, max_retries:)
13
+ @decision = FlakyRetryDecision.new(flaky_tests: flaky_tests, max_retries: max_retries)
14
+ @retried_count = 0
15
+ @mutex = Mutex.new
16
+ end
17
+
18
+ def run(mutant:, integration:, test_files:, timeout:)
19
+ result = integration.run_mutant(mutant: mutant, test_files: test_files, timeout: timeout)
20
+ attempts = 0
21
+ while @decision.retry?(**decision_input(result, attempts))
22
+ attempts += 1
23
+ result = integration.run_mutant(mutant: mutant, test_files: test_files, timeout: timeout)
24
+ end
25
+ @mutex.synchronize { @retried_count += 1 } if attempts.positive?
26
+ result
27
+ end
28
+
29
+ private
30
+
31
+ def decision_input(result, attempts)
32
+ {
33
+ status: status_of(result),
34
+ killing_tests: result.respond_to?(:killing_tests) ? result.killing_tests : [],
35
+ attempts: attempts
36
+ }
37
+ end
38
+
39
+ def status_of(result)
40
+ result.is_a?(Symbol) ? result : result.status
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Decides whether a mutant verdict earns another attempt.
5
+ #
6
+ # Survivors are never retried: flakiness can only turn a survivor into a
7
+ # kill, which would inflate the score. A kill is retried only when every test
8
+ # that killed the mutant is a known flaky test, because flakiness can fake a
9
+ # kill but not a survivor. Without a flaky set nothing is retried.
10
+ class FlakyRetryDecision
11
+ def initialize(flaky_tests:, max_retries:)
12
+ @flaky_tests = flaky_tests
13
+ @max_retries = max_retries.to_i
14
+ end
15
+
16
+ def retry?(status:, killing_tests:, attempts:)
17
+ status == :killed &&
18
+ attempts < @max_retries &&
19
+ killing_tests.any? &&
20
+ killing_tests.all? { |file| @flaky_tests.flaky?(file) }
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Henitai
6
+ # The set of test files whose outcome differed between two baseline runs.
7
+ #
8
+ # Produced by comparing per-file pass/fail maps from two runs of the same
9
+ # suite, and persisted as `henitai_flaky_tests.json` so that later runs can
10
+ # tell a kill caused by a flaky test from one caused by a stable test.
11
+ class FlakyTestRegistry
12
+ FILE_NAME = "henitai_flaky_tests.json"
13
+
14
+ # A file is flaky only when both runs ran it and disagreed. A file seen in
15
+ # one run only has no second observation, so it is never called flaky;
16
+ # FlakyDetectionBaseline rejects a failing run that such a file leaves
17
+ # unexplained.
18
+ def self.from_outcomes(first, second)
19
+ shared = first.keys & second.keys
20
+ flaky = shared.reject { |file| first[file] == second[file] }
21
+ new(flaky)
22
+ end
23
+
24
+ def self.load(path)
25
+ return new([]) unless File.file?(path)
26
+
27
+ new(Array(JSON.parse(File.read(path))["flaky_tests"]))
28
+ end
29
+
30
+ def self.for_config(config)
31
+ return new([]) unless config.respond_to?(:flaky_detection) && config.flaky_detection
32
+
33
+ for_reports_dir(config.respond_to?(:reports_dir) ? config.reports_dir : nil)
34
+ end
35
+
36
+ def self.for_reports_dir(dir)
37
+ return new([]) if dir.nil?
38
+
39
+ load(File.join(dir, FILE_NAME))
40
+ end
41
+
42
+ def initialize(files)
43
+ @files = files.map { |file| File.expand_path(file) }.uniq.sort.freeze
44
+ @lookup = @files.to_set.freeze
45
+ end
46
+
47
+ def all
48
+ @files
49
+ end
50
+
51
+ def flaky?(path)
52
+ @lookup.include?(File.expand_path(path))
53
+ end
54
+
55
+ def empty?
56
+ @files.empty?
57
+ end
58
+
59
+ def write(path)
60
+ File.write(path, JSON.pretty_generate("flaky_tests" => @files))
61
+ end
62
+ end
63
+ end