henitai 0.2.1 → 0.3.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 +80 -1
- data/README.md +123 -2
- data/assets/schema/henitai.schema.json +35 -0
- data/lib/henitai/available_cpu_count.rb +16 -23
- data/lib/henitai/canonical_report_merger.rb +107 -0
- data/lib/henitai/canonical_report_writer.rb +22 -0
- data/lib/henitai/checkpoint_reporter.rb +79 -0
- data/lib/henitai/cli/clean_command.rb +14 -8
- data/lib/henitai/cli/command_support.rb +2 -1
- data/lib/henitai/cli/operator_command.rb +2 -1
- data/lib/henitai/cli/options.rb +21 -57
- data/lib/henitai/cli/run_command.rb +36 -3
- data/lib/henitai/cli/run_options.rb +108 -0
- data/lib/henitai/cli.rb +12 -4
- data/lib/henitai/composite_progress_reporter.rb +42 -0
- data/lib/henitai/configuration.rb +41 -9
- data/lib/henitai/configuration_validator/rules.rb +18 -0
- data/lib/henitai/configuration_validator/scalars.rb +46 -0
- data/lib/henitai/configuration_validator.rb +8 -1
- data/lib/henitai/coverage_bootstrapper.rb +54 -5
- data/lib/henitai/coverage_formatter.rb +5 -1
- data/lib/henitai/coverage_report_reader.rb +28 -5
- data/lib/henitai/execution_engine/env_scope.rb +67 -0
- data/lib/henitai/execution_engine.rb +111 -50
- data/lib/henitai/generated_artifacts.rb +58 -0
- data/lib/henitai/incremental_filter.rb +100 -0
- data/lib/henitai/integration/child_debug_support.rb +6 -2
- data/lib/henitai/integration/coverage_suppression.rb +9 -0
- data/lib/henitai/integration/minitest.rb +11 -43
- data/lib/henitai/integration/minitest_load_path.rb +14 -0
- data/lib/henitai/integration/minitest_suite_command.rb +17 -0
- data/lib/henitai/integration/minitest_test_runner.rb +39 -0
- data/lib/henitai/integration/rails_environment_preloader.rb +14 -0
- data/lib/henitai/integration/rspec_child_runner.rb +3 -3
- data/lib/henitai/integration/rspec_test_selection.rb +3 -0
- data/lib/henitai/integration/scenario_log_support.rb +64 -20
- data/lib/henitai/minitest_coverage_reporter.rb +4 -1
- data/lib/henitai/mutant/activator.rb +14 -0
- data/lib/henitai/mutant.rb +13 -6
- data/lib/henitai/mutant_history_store/sql.rb +21 -3
- data/lib/henitai/mutant_history_store/verdict_cache.rb +84 -0
- data/lib/henitai/mutant_history_store.rb +62 -12
- data/lib/henitai/mutant_identity.rb +33 -2
- data/lib/henitai/mutation_skip_directives.rb +227 -0
- data/lib/henitai/operator.rb +3 -1
- data/lib/henitai/operators/equality_identity_operator.rb +51 -0
- data/lib/henitai/operators/equality_operator.rb +8 -3
- data/lib/henitai/operators.rb +1 -0
- data/lib/henitai/per_test_coverage.rb +66 -0
- data/lib/henitai/per_test_coverage_collector.rb +20 -5
- data/lib/henitai/per_test_coverage_selector.rb +11 -33
- data/lib/henitai/reporter/dashboard_metadata_provider.rb +113 -0
- data/lib/henitai/reporter.rb +238 -117
- data/lib/henitai/reports_directory_lock.rb +76 -0
- data/lib/henitai/result.rb +72 -9
- data/lib/henitai/runner.rb +82 -40
- data/lib/henitai/scenario_execution_result.rb +12 -0
- data/lib/henitai/slot_scheduler/draining.rb +21 -15
- data/lib/henitai/slot_scheduler.rb +73 -15
- data/lib/henitai/static_filter.rb +16 -6
- data/lib/henitai/survivor_rerun_strategy.rb +1 -1
- data/lib/henitai/survivor_test_filter.rb +1 -1
- data/lib/henitai/test_prioritizer.rb +28 -2
- data/lib/henitai/timeout_calibrator.rb +44 -0
- data/lib/henitai/verdict_fingerprint.rb +155 -0
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +14 -1
- data/sig/henitai.rbs +230 -25
- metadata +22 -3
- data/lib/henitai/parallel_execution_runner.rb +0 -153
|
@@ -4,6 +4,10 @@ require "fileutils"
|
|
|
4
4
|
# Guarded to avoid a circular require: integration.rb requires this file at its
|
|
5
5
|
# tail, by which point Base is already defined, so the require is skipped.
|
|
6
6
|
require_relative "../integration" unless defined?(Henitai::Integration::Base)
|
|
7
|
+
require_relative "minitest_load_path"
|
|
8
|
+
require_relative "minitest_suite_command"
|
|
9
|
+
require_relative "minitest_test_runner"
|
|
10
|
+
require_relative "rails_environment_preloader"
|
|
7
11
|
|
|
8
12
|
module Henitai
|
|
9
13
|
module Integration
|
|
@@ -26,18 +30,18 @@ module Henitai
|
|
|
26
30
|
end
|
|
27
31
|
|
|
28
32
|
def run_mutant(mutant:, test_files:, timeout:)
|
|
29
|
-
|
|
33
|
+
MinitestLoadPath.ensure!
|
|
30
34
|
super
|
|
31
35
|
end
|
|
32
36
|
|
|
33
37
|
def spawn_mutant(mutant:, test_files:)
|
|
34
|
-
|
|
38
|
+
MinitestLoadPath.ensure!
|
|
35
39
|
super
|
|
36
40
|
end
|
|
37
41
|
|
|
38
42
|
def run_in_child(mutant:, test_files:, log_paths:)
|
|
39
43
|
ENV["RAILS_ENV"] = "test" unless ENV["RAILS_ENV"] == "test"
|
|
40
|
-
|
|
44
|
+
RailsEnvironmentPreloader.new.call
|
|
41
45
|
super
|
|
42
46
|
end
|
|
43
47
|
|
|
@@ -50,50 +54,14 @@ module Henitai
|
|
|
50
54
|
cleanup_suite_process(pid, wait_result)
|
|
51
55
|
end
|
|
52
56
|
|
|
53
|
-
private
|
|
54
|
-
|
|
55
57
|
def suite_command(test_files)
|
|
56
|
-
|
|
57
|
-
"-r", "henitai/minitest_simplecov",
|
|
58
|
-
"-r", "henitai/minitest_coverage_hook",
|
|
59
|
-
"-e", "ARGV.each { |f| require File.expand_path(f) }",
|
|
60
|
-
*test_files]
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def run_tests(test_files)
|
|
64
|
-
suppress_simplecov!
|
|
65
|
-
suppress_minitest_autorun!
|
|
66
|
-
test_files.each { |file| require File.expand_path(file) }
|
|
67
|
-
# @type var empty_args: Array[String]
|
|
68
|
-
empty_args = []
|
|
69
|
-
status = ::Minitest.run(empty_args)
|
|
70
|
-
return status if status.is_a?(Integer)
|
|
71
|
-
|
|
72
|
-
status == true ? 0 : 1
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def preload_environment
|
|
76
|
-
env_file = File.expand_path("config/environment.rb")
|
|
77
|
-
require env_file if File.exist?(env_file)
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def setup_load_path
|
|
81
|
-
test_dir = File.expand_path("test")
|
|
82
|
-
$LOAD_PATH.unshift(test_dir) unless $LOAD_PATH.include?(test_dir)
|
|
58
|
+
MinitestSuiteCommand.new.build(test_files)
|
|
83
59
|
end
|
|
84
60
|
|
|
85
|
-
|
|
86
|
-
require "minitest"
|
|
87
|
-
singleton_class = ::Minitest.singleton_class
|
|
88
|
-
suppressor = @minitest_autorun_suppressor ||= Module.new.tap do |mod|
|
|
89
|
-
mod.define_method(:autorun) { nil }
|
|
90
|
-
end
|
|
91
|
-
return if singleton_class.ancestors.include?(suppressor)
|
|
61
|
+
private
|
|
92
62
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
rescue LoadError, NameError
|
|
96
|
-
nil
|
|
63
|
+
def run_tests(test_files)
|
|
64
|
+
MinitestTestRunner.new.call(test_files)
|
|
97
65
|
end
|
|
98
66
|
|
|
99
67
|
def subprocess_env
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
module Integration
|
|
5
|
+
# Ensures the current project's test/ directory is on $LOAD_PATH, for
|
|
6
|
+
# Minitest#run_mutant/#spawn_mutant. Idempotent: safe to call repeatedly.
|
|
7
|
+
module MinitestLoadPath
|
|
8
|
+
def self.ensure!
|
|
9
|
+
test_dir = File.expand_path("test")
|
|
10
|
+
$LOAD_PATH.unshift(test_dir) unless $LOAD_PATH.include?(test_dir)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
module Integration
|
|
5
|
+
# Builds the argv used to run Minitest test files in an external Ruby
|
|
6
|
+
# process, for Minitest#spawn_suite_process's baseline coverage bootstrap.
|
|
7
|
+
class MinitestSuiteCommand
|
|
8
|
+
def build(test_files)
|
|
9
|
+
["bundle", "exec", "ruby", "-I", "test",
|
|
10
|
+
"-r", "henitai/minitest_simplecov",
|
|
11
|
+
"-r", "henitai/minitest_coverage_hook",
|
|
12
|
+
"-e", "ARGV.each { |f| require File.expand_path(f) }",
|
|
13
|
+
*test_files]
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "coverage_suppression"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
module Integration
|
|
7
|
+
# Runs Minitest test files in-process and normalizes the result to an
|
|
8
|
+
# exit code, for Minitest#run_tests during a single mutant activation.
|
|
9
|
+
class MinitestTestRunner
|
|
10
|
+
def call(test_files)
|
|
11
|
+
CoverageRuntimeSuppressors.suppress_simplecov!
|
|
12
|
+
suppress_autorun!
|
|
13
|
+
test_files.each { |file| require File.expand_path(file) }
|
|
14
|
+
# @type var empty_args: Array[String]
|
|
15
|
+
empty_args = []
|
|
16
|
+
status = ::Minitest.run(empty_args)
|
|
17
|
+
return status if status.is_a?(Integer)
|
|
18
|
+
|
|
19
|
+
status == true ? 0 : 1
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def suppress_autorun!
|
|
25
|
+
require "minitest"
|
|
26
|
+
singleton_class = ::Minitest.singleton_class
|
|
27
|
+
suppressor = @autorun_suppressor ||= Module.new.tap do |mod|
|
|
28
|
+
mod.define_method(:autorun) { nil }
|
|
29
|
+
end
|
|
30
|
+
return if singleton_class.ancestors.include?(suppressor)
|
|
31
|
+
|
|
32
|
+
singleton_class.prepend(suppressor)
|
|
33
|
+
nil
|
|
34
|
+
rescue LoadError, NameError
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
module Integration
|
|
5
|
+
# Loads a Rails app's config/environment.rb if present, for
|
|
6
|
+
# Minitest#run_in_child. A no-op outside a Rails project.
|
|
7
|
+
class RailsEnvironmentPreloader
|
|
8
|
+
def call
|
|
9
|
+
env_file = File.expand_path("config/environment.rb")
|
|
10
|
+
require env_file if File.exist?(env_file)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -42,17 +42,17 @@ module Henitai
|
|
|
42
42
|
|
|
43
43
|
def load_rspec_spec_files(test_files)
|
|
44
44
|
debug_child_puts("[henitai-debug-child] load_spec_files_start")
|
|
45
|
-
::RSpec.
|
|
45
|
+
::RSpec.configuration.files_to_run = test_files.map do |file|
|
|
46
46
|
File.expand_path(file)
|
|
47
47
|
end
|
|
48
|
-
::RSpec.
|
|
48
|
+
::RSpec.configuration.load_spec_files
|
|
49
49
|
debug_child_example_count("after_load")
|
|
50
50
|
debug_child_puts("[henitai-debug-child] load_spec_files_return")
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
def run_rspec_specs(runner)
|
|
54
54
|
debug_child_puts("[henitai-debug-child] run_specs_start")
|
|
55
|
-
result = runner.send(:run_specs, ::RSpec.
|
|
55
|
+
result = runner.send(:run_specs, ::RSpec.world.ordered_example_groups)
|
|
56
56
|
debug_child_puts("[henitai-debug-child] run_specs_return result=#{result.inspect}")
|
|
57
57
|
result
|
|
58
58
|
end
|
|
@@ -18,7 +18,10 @@ module Henitai
|
|
|
18
18
|
def select_tests(subject)
|
|
19
19
|
matches = spec_files.select do |path|
|
|
20
20
|
content = File.read(path)
|
|
21
|
+
# rubocop:disable Style/ArrayIntersect -- content is a String (substring
|
|
22
|
+
# search), not a collection; `intersect?` would raise a TypeError.
|
|
21
23
|
selection_patterns(subject).any? { |pattern| content.include?(pattern) }
|
|
24
|
+
# rubocop:enable Style/ArrayIntersect
|
|
22
25
|
rescue StandardError
|
|
23
26
|
false
|
|
24
27
|
end
|
|
@@ -7,6 +7,12 @@ module Henitai
|
|
|
7
7
|
# Shared helpers for capturing stdout/stderr from child test processes and
|
|
8
8
|
# for reading and combining the captured log files afterwards.
|
|
9
9
|
class ScenarioLogSupport
|
|
10
|
+
# Env var carrying the per-stream capture cap (bytes) into forked
|
|
11
|
+
# children. Set by the execution engine from Configuration#max_log_bytes.
|
|
12
|
+
MAX_LOG_BYTES_ENV = "HENITAI_MAX_LOG_BYTES"
|
|
13
|
+
DEFAULT_MAX_LOG_BYTES = 5_000_000
|
|
14
|
+
DRAIN_CHUNK_BYTES = 65_536
|
|
15
|
+
|
|
10
16
|
def capture_child_output(log_paths)
|
|
11
17
|
output_files = open_child_output(log_paths)
|
|
12
18
|
yield
|
|
@@ -26,10 +32,20 @@ module Henitai
|
|
|
26
32
|
end
|
|
27
33
|
end
|
|
28
34
|
|
|
35
|
+
# Captures via a pipe drained by a background thread that writes to the
|
|
36
|
+
# log file only up to +max_log_bytes+ and then discards the overflow.
|
|
37
|
+
# A runaway mutant (e.g. one that recurses henitai into itself and spews
|
|
38
|
+
# backtraces) can otherwise fill hundreds of MB per child; draining past
|
|
39
|
+
# the cap keeps the writer from blocking on a full pipe.
|
|
29
40
|
def open_child_output(log_paths)
|
|
30
41
|
FileUtils.mkdir_p(File.dirname(log_paths[:log_path]))
|
|
31
|
-
|
|
32
|
-
|
|
42
|
+
cap = max_log_bytes
|
|
43
|
+
output_files = {
|
|
44
|
+
original_stdout: stdout_stream.dup,
|
|
45
|
+
original_stderr: stderr_stream.dup,
|
|
46
|
+
stdout: open_capped_stream(log_paths[:stdout_path], cap),
|
|
47
|
+
stderr: open_capped_stream(log_paths[:stderr_path], cap)
|
|
48
|
+
}
|
|
33
49
|
redirect_child_output(output_files)
|
|
34
50
|
output_files
|
|
35
51
|
end
|
|
@@ -38,26 +54,54 @@ module Henitai
|
|
|
38
54
|
return unless output_files
|
|
39
55
|
|
|
40
56
|
restore_child_output(output_files)
|
|
41
|
-
|
|
57
|
+
finish_capped_stream(output_files[:stdout])
|
|
58
|
+
finish_capped_stream(output_files[:stderr])
|
|
59
|
+
output_files[:original_stdout]&.close
|
|
60
|
+
output_files[:original_stderr]&.close
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# One capture channel: a pipe whose read end is drained (capped) into the
|
|
64
|
+
# log file by a dedicated thread. Returns the pieces close needs.
|
|
65
|
+
def open_capped_stream(path, cap)
|
|
66
|
+
file = File.new(path, "w")
|
|
67
|
+
file.sync = true
|
|
68
|
+
reader, writer = IO.pipe
|
|
69
|
+
thread = Thread.new { drain_capped(reader, file, cap) }
|
|
70
|
+
{ file:, reader:, writer:, thread: }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def drain_capped(reader, file, cap)
|
|
74
|
+
written = 0
|
|
75
|
+
truncated = false
|
|
76
|
+
loop do
|
|
77
|
+
chunk = reader.readpartial(DRAIN_CHUNK_BYTES)
|
|
78
|
+
next unless written < cap
|
|
79
|
+
|
|
80
|
+
slice = chunk.byteslice(0, cap - written)
|
|
81
|
+
file.write(slice)
|
|
82
|
+
written += slice.bytesize
|
|
83
|
+
next unless written >= cap && !truncated
|
|
84
|
+
|
|
85
|
+
file.write("\n[henitai] output truncated at #{cap} bytes\n")
|
|
86
|
+
truncated = true
|
|
87
|
+
end
|
|
88
|
+
rescue IOError
|
|
89
|
+
# EOFError (a subclass) on writer close, or a closed pipe: draining done.
|
|
90
|
+
nil
|
|
42
91
|
end
|
|
43
92
|
|
|
44
|
-
def
|
|
45
|
-
|
|
46
|
-
original_stdout: stdout_stream.dup,
|
|
47
|
-
original_stderr: stderr_stream.dup,
|
|
48
|
-
stdout_file: File.new(log_paths[:stdout_path], "w"),
|
|
49
|
-
stderr_file: File.new(log_paths[:stderr_path], "w")
|
|
50
|
-
}
|
|
51
|
-
end
|
|
93
|
+
def finish_capped_stream(stream)
|
|
94
|
+
return unless stream
|
|
52
95
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
96
|
+
stream[:writer].close
|
|
97
|
+
stream[:thread].join
|
|
98
|
+
stream[:reader].close
|
|
99
|
+
stream[:file].close
|
|
56
100
|
end
|
|
57
101
|
|
|
58
102
|
def redirect_child_output(output_files)
|
|
59
|
-
reopen_child_output_stream(stdout_stream, output_files[:
|
|
60
|
-
reopen_child_output_stream(stderr_stream, output_files[:
|
|
103
|
+
reopen_child_output_stream(stdout_stream, output_files[:stdout][:writer])
|
|
104
|
+
reopen_child_output_stream(stderr_stream, output_files[:stderr][:writer])
|
|
61
105
|
$stdout = stdout_stream
|
|
62
106
|
$stderr = stderr_stream
|
|
63
107
|
end
|
|
@@ -73,10 +117,10 @@ module Henitai
|
|
|
73
117
|
stream.reopen(original_stream) if original_stream
|
|
74
118
|
end
|
|
75
119
|
|
|
76
|
-
def
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
120
|
+
def max_log_bytes
|
|
121
|
+
raw = ENV.fetch(MAX_LOG_BYTES_ENV, nil)
|
|
122
|
+
value = raw.to_i
|
|
123
|
+
value.positive? ? value : DEFAULT_MAX_LOG_BYTES
|
|
80
124
|
end
|
|
81
125
|
|
|
82
126
|
def read_log_file(path)
|
|
@@ -57,15 +57,27 @@ module Henitai
|
|
|
57
57
|
subject.method_type == :class ? target.singleton_class : target
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
+
# The trailing module_function re-copy handles module_function subjects:
|
|
61
|
+
# module_function copies the (private) instance method onto the module's
|
|
62
|
+
# singleton, and define_method only replaces the instance side — without
|
|
63
|
+
# the re-copy, callers of Module.method keep running the original code
|
|
64
|
+
# and every mutant in such modules falsely survives. The guard checks
|
|
65
|
+
# the pre-injection shape (private instance + singleton method on a
|
|
66
|
+
# non-Class module) so a class-level method that merely shares an
|
|
67
|
+
# instance method's name is never clobbered.
|
|
60
68
|
def method_source(mutant)
|
|
61
69
|
method_name = mutant.subject.method_name
|
|
62
70
|
parameters = parameter_source(mutant)
|
|
63
71
|
replacement = body_source(mutant)
|
|
64
72
|
|
|
65
73
|
<<~RUBY
|
|
74
|
+
__henitai_module_function__ = !is_a?(Class) &&
|
|
75
|
+
private_method_defined?(:#{method_name}) &&
|
|
76
|
+
singleton_class.method_defined?(:#{method_name})
|
|
66
77
|
define_method(:#{method_name}) do |#{parameters}|
|
|
67
78
|
#{replacement}
|
|
68
79
|
end
|
|
80
|
+
module_function :#{method_name} if __henitai_module_function__
|
|
69
81
|
RUBY
|
|
70
82
|
end
|
|
71
83
|
|
|
@@ -164,6 +176,8 @@ module Henitai
|
|
|
164
176
|
|
|
165
177
|
Thread.current[:henitai_filter_const_warnings] = true
|
|
166
178
|
load(source_file)
|
|
179
|
+
loaded_feature = File.expand_path(source_file)
|
|
180
|
+
$LOADED_FEATURES << loaded_feature unless $LOADED_FEATURES.include?(loaded_feature)
|
|
167
181
|
ensure
|
|
168
182
|
Thread.current[:henitai_filter_const_warnings] = false
|
|
169
183
|
end
|
data/lib/henitai/mutant.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
4
|
require_relative "mutant_identity"
|
|
5
|
+
require_relative "unparse_helper"
|
|
5
6
|
|
|
6
7
|
module Henitai
|
|
7
8
|
# Represents a single syntactic mutation applied to a Subject.
|
|
@@ -16,6 +17,8 @@ module Henitai
|
|
|
16
17
|
# :pending, :killed, :survived, :timeout, :compile_error, :runtime_error,
|
|
17
18
|
# :ignored, :no_coverage
|
|
18
19
|
class Mutant
|
|
20
|
+
include UnparseHelper
|
|
21
|
+
|
|
19
22
|
autoload :Activator, "henitai/mutant/activator"
|
|
20
23
|
autoload :ParameterSource, "henitai/mutant/parameter_source"
|
|
21
24
|
|
|
@@ -38,7 +41,8 @@ module Henitai
|
|
|
38
41
|
attr_reader :id, :subject, :operator, :original_node, :mutated_node,
|
|
39
42
|
:mutation_type, :description, :location,
|
|
40
43
|
:precomputed_stable_id, :precomputed_activation_source
|
|
41
|
-
attr_accessor :status, :killing_test, :duration, :covered_by, :tests_completed
|
|
44
|
+
attr_accessor :status, :killing_test, :duration, :covered_by, :tests_completed,
|
|
45
|
+
:ignore_reason, :from_cache
|
|
42
46
|
|
|
43
47
|
# @param subject [Subject] the subject being mutated
|
|
44
48
|
# @param operator [Symbol] operator name, e.g. :ArithmeticOperator
|
|
@@ -57,11 +61,9 @@ module Henitai
|
|
|
57
61
|
@location = location
|
|
58
62
|
@precomputed_stable_id = precomputed_stable_id
|
|
59
63
|
@precomputed_activation_source = precomputed_activation_source
|
|
60
|
-
@status
|
|
61
|
-
@killing_test
|
|
62
|
-
@
|
|
63
|
-
@covered_by = nil
|
|
64
|
-
@tests_completed = nil
|
|
64
|
+
@status = :pending
|
|
65
|
+
@killing_test = @duration = @covered_by = @tests_completed = @ignore_reason = nil
|
|
66
|
+
@from_cache = false
|
|
65
67
|
end
|
|
66
68
|
# rubocop:enable Metrics/ParameterLists
|
|
67
69
|
|
|
@@ -69,7 +71,12 @@ module Henitai
|
|
|
69
71
|
@stable_id ||= @precomputed_stable_id || MutantIdentity.stable_id(self)
|
|
70
72
|
end
|
|
71
73
|
|
|
74
|
+
# Unparsing is expensive and the value is needed by both the JSON and
|
|
75
|
+
# the terminal reporter — compute once per mutant.
|
|
76
|
+
def mutated_source = @mutated_source ||= safe_unparse(mutated_node)
|
|
77
|
+
|
|
72
78
|
def killed? = @status == :killed
|
|
79
|
+
def from_cache? = @from_cache == true
|
|
73
80
|
def survived? = @status == :survived
|
|
74
81
|
def pending? = @status == :pending
|
|
75
82
|
def ignored? = @status == :ignored
|
|
@@ -49,6 +49,14 @@ module Henitai
|
|
|
49
49
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
50
50
|
SQL
|
|
51
51
|
|
|
52
|
+
# Nullable verdict-cache columns added after the initial release; see
|
|
53
|
+
# MIGRATION_COLUMNS. Old rows keep NULL there, which the incremental
|
|
54
|
+
# filter treats as never-reusable — the correct conservative behavior.
|
|
55
|
+
MIGRATION_COLUMNS = {
|
|
56
|
+
"subject_source_hash" => "TEXT",
|
|
57
|
+
"covered_tests_fingerprint" => "TEXT"
|
|
58
|
+
}.freeze
|
|
59
|
+
|
|
52
60
|
UPSERT_MUTANT = <<~SQL
|
|
53
61
|
INSERT INTO mutants (
|
|
54
62
|
mutant_id,
|
|
@@ -58,14 +66,24 @@ module Henitai
|
|
|
58
66
|
last_seen_at,
|
|
59
67
|
current_status,
|
|
60
68
|
status_history,
|
|
61
|
-
days_alive
|
|
62
|
-
|
|
69
|
+
days_alive,
|
|
70
|
+
subject_source_hash,
|
|
71
|
+
covered_tests_fingerprint
|
|
72
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
63
73
|
ON CONFLICT(mutant_id) DO UPDATE SET
|
|
64
74
|
last_seen_version = excluded.last_seen_version,
|
|
65
75
|
last_seen_at = excluded.last_seen_at,
|
|
66
76
|
current_status = excluded.current_status,
|
|
67
77
|
status_history = excluded.status_history,
|
|
68
|
-
days_alive = excluded.days_alive
|
|
78
|
+
days_alive = excluded.days_alive,
|
|
79
|
+
subject_source_hash = excluded.subject_source_hash,
|
|
80
|
+
covered_tests_fingerprint = excluded.covered_tests_fingerprint
|
|
81
|
+
SQL
|
|
82
|
+
|
|
83
|
+
VERDICT_LOOKUP = <<~SQL
|
|
84
|
+
SELECT current_status, subject_source_hash, covered_tests_fingerprint
|
|
85
|
+
FROM mutants
|
|
86
|
+
WHERE mutant_id = ?
|
|
69
87
|
SQL
|
|
70
88
|
end
|
|
71
89
|
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../verdict_fingerprint"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
class MutantHistoryStore
|
|
7
|
+
# Verdict-cache column handling for the incremental (`--incremental`)
|
|
8
|
+
# reuse feature. Mixed into {MutantHistoryStore}.
|
|
9
|
+
module VerdictCache
|
|
10
|
+
# The reusable verdicts: hashes are persisted for killed and survived
|
|
11
|
+
# mutants only; every other status stays NULL and always re-executes.
|
|
12
|
+
CACHEABLE_STATUSES = %w[killed survived].freeze
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
# Cache-hit mutants (from_cache) were never executed this run, so their
|
|
17
|
+
# covered tests are unknown — carry the stored fingerprints forward
|
|
18
|
+
# instead of recomputing them from empty data (which would wipe them
|
|
19
|
+
# and silently disable reuse after one cached run).
|
|
20
|
+
def verdict_cache_bindings(mutant, existing_row)
|
|
21
|
+
status = mutant.status.to_s
|
|
22
|
+
return [nil, nil] unless CACHEABLE_STATUSES.include?(status)
|
|
23
|
+
return existing_cache_bindings(existing_row) if from_cache?(mutant)
|
|
24
|
+
return killed_cache_bindings(mutant) if status == "killed"
|
|
25
|
+
|
|
26
|
+
survived_cache_bindings(mutant, existing_row)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def killed_cache_bindings(mutant)
|
|
30
|
+
[
|
|
31
|
+
VerdictFingerprint.subject_source_hash(mutant),
|
|
32
|
+
VerdictFingerprint.tests_fingerprint(covered_tests_for(mutant))
|
|
33
|
+
]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Survived rows fingerprint the full-map intersection set — the same
|
|
37
|
+
# set the incremental filter recomputes live — never Mutant#covered_by,
|
|
38
|
+
# which reflects selector fallback and test_excludes and would
|
|
39
|
+
# permanently mismatch the live set. When the fingerprint cannot be
|
|
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.
|
|
44
|
+
def survived_cache_bindings(mutant, existing_row)
|
|
45
|
+
bindings = [
|
|
46
|
+
VerdictFingerprint.subject_source_hash(mutant),
|
|
47
|
+
survived_tests_fingerprint(mutant)
|
|
48
|
+
]
|
|
49
|
+
return bindings unless bindings.any?(&:nil?)
|
|
50
|
+
|
|
51
|
+
existing_cache_bindings(existing_row)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def survived_tests_fingerprint(mutant)
|
|
55
|
+
return nil unless per_test_coverage
|
|
56
|
+
|
|
57
|
+
VerdictFingerprint.survivor_tests_fingerprint(
|
|
58
|
+
per_test_coverage.tests_covering(mutant),
|
|
59
|
+
dependency_sha: recording_dependency_sha
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
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
|
+
def existing_cache_bindings(existing_row)
|
|
70
|
+
return [nil, nil] unless existing_row
|
|
71
|
+
|
|
72
|
+
[existing_row["subject_source_hash"], existing_row["covered_tests_fingerprint"]]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def from_cache?(mutant)
|
|
76
|
+
mutant.respond_to?(:from_cache?) && mutant.from_cache?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def covered_tests_for(mutant)
|
|
80
|
+
mutant.respond_to?(:covered_by) ? mutant.covered_by : nil
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|