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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +222 -1
- data/README.md +100 -13
- data/assets/schema/henitai.schema.json +20 -0
- data/lib/henitai/arid_node_filter.rb +4 -19
- data/lib/henitai/bootstrap_error_message.rb +20 -0
- data/lib/henitai/canonical_report_merger.rb +23 -2
- data/lib/henitai/cli/run_command.rb +25 -7
- data/lib/henitai/cli/run_options.rb +1 -1
- data/lib/henitai/cli.rb +4 -1
- data/lib/henitai/configuration.rb +4 -1
- data/lib/henitai/configuration_validator/rules.rb +8 -0
- data/lib/henitai/configuration_validator/scalars.rb +9 -0
- data/lib/henitai/configuration_validator.rb +2 -1
- data/lib/henitai/constant_path.rb +48 -0
- data/lib/henitai/control_run.rb +73 -0
- data/lib/henitai/coverage_bootstrapper.rb +36 -10
- data/lib/henitai/coverage_formatter.rb +26 -3
- data/lib/henitai/coverage_report_reader.rb +21 -0
- data/lib/henitai/equivalence_detector/operand_predicates.rb +16 -0
- data/lib/henitai/equivalence_detector.rb +46 -38
- data/lib/henitai/example_summary.rb +24 -0
- data/lib/henitai/execution_engine/fork_server_scope.rb +57 -0
- data/lib/henitai/execution_engine.rb +75 -60
- data/lib/henitai/fidelity_check.rb +173 -0
- data/lib/henitai/flaky_detection_baseline.rb +61 -0
- data/lib/henitai/flaky_mutant_retrier.rb +43 -0
- data/lib/henitai/flaky_retry_decision.rb +23 -0
- data/lib/henitai/flaky_test_registry.rb +63 -0
- data/lib/henitai/fork_server/client.rb +231 -0
- data/lib/henitai/fork_server/group_killer.rb +48 -0
- data/lib/henitai/fork_server/group_terminator.rb +47 -0
- data/lib/henitai/fork_server/launcher.rb +38 -0
- data/lib/henitai/fork_server/mutant_runner.rb +141 -0
- data/lib/henitai/fork_server/protocol.rb +73 -0
- data/lib/henitai/fork_server/reaper.rb +24 -0
- data/lib/henitai/fork_server/runtime.rb +109 -0
- data/lib/henitai/fork_server/server.rb +108 -0
- data/lib/henitai/fork_server/server_process.rb +73 -0
- data/lib/henitai/fork_server/status.rb +52 -0
- data/lib/henitai/fork_server.rb +43 -0
- data/lib/henitai/generated_artifacts.rb +3 -0
- data/lib/henitai/git_diff_analyzer.rb +28 -11
- data/lib/henitai/incremental_filter.rb +20 -10
- data/lib/henitai/integration/base.rb +72 -5
- data/lib/henitai/integration/capped_log_sink.rb +34 -0
- data/lib/henitai/integration/child_bootstrap.rb +7 -0
- data/lib/henitai/integration/child_report_store.rb +36 -0
- data/lib/henitai/integration/minitest.rb +7 -5
- data/lib/henitai/integration/minitest_autorun.rb +20 -0
- data/lib/henitai/integration/minitest_outcome_reporter.rb +28 -0
- data/lib/henitai/integration/minitest_preload.rb +30 -0
- data/lib/henitai/integration/minitest_runnable_filter.rb +61 -0
- data/lib/henitai/integration/minitest_test_runner.rb +32 -15
- data/lib/henitai/integration/mutant_run_support.rb +73 -13
- data/lib/henitai/integration/rspec_child_runner.rb +44 -9
- data/lib/henitai/integration/rspec_preload.rb +35 -0
- data/lib/henitai/integration/rspec_process_runner.rb +4 -1
- data/lib/henitai/integration/scenario_log_support.rb +31 -18
- data/lib/henitai/integration.rb +16 -3
- data/lib/henitai/minitest_coverage_reporter.rb +22 -6
- data/lib/henitai/minitest_result_file.rb +62 -0
- data/lib/henitai/mutant/activation.rb +32 -0
- data/lib/henitai/mutant/activator.rb +75 -55
- data/lib/henitai/mutant/def_injection.rb +136 -0
- data/lib/henitai/mutant/lexical_scope_check.rb +120 -0
- data/lib/henitai/mutant/lexical_target.rb +105 -0
- data/lib/henitai/mutant/replacement.rb +52 -0
- data/lib/henitai/mutant.rb +27 -8
- data/lib/henitai/mutant_generator.rb +62 -18
- data/lib/henitai/mutant_history_store/verdict_cache.rb +12 -11
- data/lib/henitai/mutant_history_store.rb +14 -4
- data/lib/henitai/mutant_identity.rb +5 -1
- data/lib/henitai/mutant_verdict.rb +71 -0
- data/lib/henitai/operators/array_declaration.rb +4 -0
- data/lib/henitai/operators/assignment_expression.rb +2 -0
- data/lib/henitai/operators/conditional_expression.rb +21 -1
- data/lib/henitai/operators/hash_literal.rb +7 -4
- data/lib/henitai/operators/method_expression.rb +14 -3
- data/lib/henitai/operators/regex_metacharacters.rb +89 -0
- data/lib/henitai/operators/regex_mutator.rb +23 -28
- data/lib/henitai/operators/return_value.rb +1 -1
- data/lib/henitai/operators.rb +1 -0
- data/lib/henitai/per_test_coverage.rb +11 -7
- data/lib/henitai/per_test_coverage_collector.rb +30 -31
- data/lib/henitai/per_test_coverage_delta.rb +60 -0
- data/lib/henitai/per_test_coverage_selector.rb +26 -13
- data/lib/henitai/process_wakeup.rb +8 -3
- data/lib/henitai/process_worker_runner.rb +19 -5
- data/lib/henitai/reporter.rb +15 -8
- data/lib/henitai/result.rb +32 -16
- data/lib/henitai/reuse_context.rb +50 -0
- data/lib/henitai/rspec_coverage_formatter.rb +4 -0
- data/lib/henitai/rspec_outcomes.rb +72 -0
- data/lib/henitai/runner.rb +58 -19
- data/lib/henitai/runner_dependencies.rb +22 -1
- data/lib/henitai/sampling_strategy.rb +36 -6
- data/lib/henitai/scenario_execution_result.rb +28 -5
- data/lib/henitai/signature_line.rb +21 -0
- data/lib/henitai/slot_scheduler/draining.rb +12 -2
- data/lib/henitai/slot_scheduler/process_control.rb +33 -0
- data/lib/henitai/slot_scheduler/retry_policy.rb +8 -6
- data/lib/henitai/slot_scheduler.rb +47 -8
- data/lib/henitai/source_file_selection.rb +1 -1
- data/lib/henitai/source_parser.rb +29 -9
- data/lib/henitai/static_filter.rb +45 -14
- data/lib/henitai/stillborn_filter.rb +70 -2
- data/lib/henitai/subject.rb +8 -3
- data/lib/henitai/subject_resolver.rb +65 -31
- data/lib/henitai/suite_boot_clock.rb +48 -0
- data/lib/henitai/survivor_activation_cache.rb +18 -11
- data/lib/henitai/survivor_rerun_strategy.rb +47 -43
- data/lib/henitai/test_file_outcomes.rb +79 -0
- data/lib/henitai/timeout_calibrator.rb +31 -9
- data/lib/henitai/timeout_ceiling.rb +37 -0
- data/lib/henitai/verdict_fingerprint.rb +29 -30
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +17 -1
- data/sig/configuration_validator.rbs +1 -0
- data/sig/henitai.rbs +225 -45
- metadata +46 -3
- 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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
|
217
|
-
return if
|
|
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: %<
|
|
224
|
-
flaky
|
|
225
|
-
|
|
226
|
-
|
|
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
|