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
|
@@ -43,7 +43,8 @@ module Henitai
|
|
|
43
43
|
survivors_from: resolved_survivors_from,
|
|
44
44
|
mode: {
|
|
45
45
|
dry_run: options.fetch(:dry_run, false),
|
|
46
|
-
incremental: options.fetch(:incremental, false) && !options.fetch(:force, false)
|
|
46
|
+
incremental: options.fetch(:incremental, false) && !options.fetch(:force, false),
|
|
47
|
+
force: options.fetch(:force, false)
|
|
47
48
|
}
|
|
48
49
|
)
|
|
49
50
|
runner.run
|
|
@@ -64,7 +65,7 @@ module Henitai
|
|
|
64
65
|
# being a snapshot path; this keeps activation-recipes lookup correct.
|
|
65
66
|
return survivors_from if File.basename(parent_dir) == "sessions"
|
|
66
67
|
|
|
67
|
-
session_id =
|
|
68
|
+
session_id = single_session_id_from_report(survivors_from)
|
|
68
69
|
return survivors_from if session_id.nil?
|
|
69
70
|
|
|
70
71
|
snapshot_path = survivors_snapshot_path(report_dir, session_id)
|
|
@@ -85,9 +86,16 @@ module Henitai
|
|
|
85
86
|
File.join(report_dir, "sessions", session_id, "mutation-report.json")
|
|
86
87
|
end
|
|
87
88
|
|
|
88
|
-
|
|
89
|
+
# The report's sessionId only names a snapshot holding every survivor when
|
|
90
|
+
# each file entry was produced by that very session. A merged canonical
|
|
91
|
+
# report (or a legacy one without per-file provenance) is used as is.
|
|
92
|
+
def single_session_id_from_report(path)
|
|
89
93
|
parsed = JSON.parse(File.read(path))
|
|
90
|
-
parsed["sessionId"]
|
|
94
|
+
session_id = parsed["sessionId"]
|
|
95
|
+
files = parsed["files"]
|
|
96
|
+
return nil unless files.is_a?(Hash) && files.each_value.all? { |f| f["sessionId"] == session_id }
|
|
97
|
+
|
|
98
|
+
session_id
|
|
91
99
|
rescue JSON::ParserError, Errno::ENOENT
|
|
92
100
|
nil
|
|
93
101
|
end
|
|
@@ -104,6 +112,18 @@ module Henitai
|
|
|
104
112
|
strict_status || threshold_status_for(result, config)
|
|
105
113
|
end
|
|
106
114
|
|
|
115
|
+
# An empty score denominator passes only when there was nothing to score
|
|
116
|
+
# (no changed code, everything ignored). When harness errors emptied it,
|
|
117
|
+
# nothing was tested, and the run must not pass its threshold.
|
|
118
|
+
def empty_score_status(result)
|
|
119
|
+
harness_errors = result.mutants.count { |mutant| mutant.status == :compile_error }
|
|
120
|
+
return 0 if harness_errors.zero?
|
|
121
|
+
|
|
122
|
+
warn "henitai: no mutant could be scored: #{harness_errors} harness error(s) " \
|
|
123
|
+
"(CompileError, see statusReason in the report)"
|
|
124
|
+
1
|
|
125
|
+
end
|
|
126
|
+
|
|
107
127
|
# Expanded, opt-in exit codes (precedence: timeout > runtime/compile
|
|
108
128
|
# error > threshold miss). The timeout code is informational and
|
|
109
129
|
# independent of coverage_criteria.timeout: a run can pass its threshold
|
|
@@ -118,9 +138,7 @@ module Henitai
|
|
|
118
138
|
|
|
119
139
|
def threshold_status_for(result, config)
|
|
120
140
|
score = result.mutation_score
|
|
121
|
-
|
|
122
|
-
# code) cannot fail a threshold — treat it as success.
|
|
123
|
-
return 0 if score.nil?
|
|
141
|
+
return empty_score_status(result) if score.nil?
|
|
124
142
|
|
|
125
143
|
score.to_i >= config.thresholds.fetch(:low, 60) ? 0 : 1
|
|
126
144
|
end
|
|
@@ -79,7 +79,7 @@ module Henitai
|
|
|
79
79
|
def add_force_option(opts, options)
|
|
80
80
|
opts.on(
|
|
81
81
|
"--force",
|
|
82
|
-
"Bypass verdict reuse and execute every mutant
|
|
82
|
+
"Bypass verdict reuse (--incremental, --survivors-from) and execute every mutant"
|
|
83
83
|
) do
|
|
84
84
|
options[:force] = true
|
|
85
85
|
end
|
data/lib/henitai/cli.rb
CHANGED
|
@@ -37,7 +37,10 @@ module Henitai
|
|
|
37
37
|
%w[coverage .resultset.json],
|
|
38
38
|
%w[coverage .last_run.json],
|
|
39
39
|
["henitai_per_test.json"],
|
|
40
|
-
[
|
|
40
|
+
[PerTestCoverageCollector::BOOT_FILE_NAME],
|
|
41
|
+
[CoverageBootstrapper::DEPENDENCY_MANIFEST_FILE],
|
|
42
|
+
[FlakyTestRegistry::FILE_NAME],
|
|
43
|
+
[TestFileOutcomes::REPORT_FILE_NAME]
|
|
41
44
|
].freeze
|
|
42
45
|
|
|
43
46
|
# Whole directories of per-mutant scratch artifacts, removed recursively.
|
|
@@ -43,7 +43,7 @@ module Henitai
|
|
|
43
43
|
|
|
44
44
|
attr_reader :integration, :includes, :excludes, :test_excludes, :operators, :timeout,
|
|
45
45
|
:timeout_multiplier, :ignore_patterns, :sampling, :jobs,
|
|
46
|
-
:max_flaky_retries, :max_log_bytes, :max_timeout,
|
|
46
|
+
:max_flaky_retries, :max_log_bytes, :max_timeout, :control_runs, :flaky_detection, :fork_server,
|
|
47
47
|
:coverage_criteria, :thresholds,
|
|
48
48
|
:reporters, :reports_dir,
|
|
49
49
|
:checkpoint_enabled, :checkpoint_every, :checkpoint_interval,
|
|
@@ -124,6 +124,9 @@ module Henitai
|
|
|
124
124
|
@max_flaky_retries = mutation.fetch(:max_flaky_retries, DEFAULT_MAX_FLAKY_RETRIES)
|
|
125
125
|
@max_log_bytes = mutation[:max_log_bytes] || DEFAULT_MAX_LOG_BYTES
|
|
126
126
|
@max_timeout = mutation[:max_timeout] || DEFAULT_MAX_TIMEOUT
|
|
127
|
+
@control_runs = mutation.fetch(:control_runs, true)
|
|
128
|
+
@flaky_detection = mutation.fetch(:flaky_detection, false)
|
|
129
|
+
@fork_server = mutation.fetch(:fork_server, false)
|
|
127
130
|
end
|
|
128
131
|
|
|
129
132
|
def apply_reports_defaults(raw)
|
|
@@ -100,6 +100,13 @@ module Henitai
|
|
|
100
100
|
Scalars.validate_max_flaky_retries(value[:max_flaky_retries])
|
|
101
101
|
Scalars.validate_max_log_bytes(value[:max_log_bytes])
|
|
102
102
|
Scalars.validate_max_timeout(value[:max_timeout])
|
|
103
|
+
validate_mutation_flags(value)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def validate_mutation_flags(value)
|
|
107
|
+
%i[control_runs flaky_detection fork_server].each do |key|
|
|
108
|
+
Scalars.validate_boolean(value[key], "mutation.#{key}") unless value[key].nil?
|
|
109
|
+
end
|
|
103
110
|
end
|
|
104
111
|
|
|
105
112
|
def validate_mutation_filters(value)
|
|
@@ -133,6 +140,7 @@ module Henitai
|
|
|
133
140
|
Scalars.validate_sampling_completeness(value)
|
|
134
141
|
Scalars.validate_sampling_ratio(value[:ratio])
|
|
135
142
|
Scalars.validate_sampling_strategy(value[:strategy])
|
|
143
|
+
Scalars.validate_sampling_seed(value[:seed])
|
|
136
144
|
end
|
|
137
145
|
|
|
138
146
|
def warn_unknown_keys(raw, allowed_keys, path = nil)
|
|
@@ -150,6 +150,15 @@ module Henitai
|
|
|
150
150
|
)
|
|
151
151
|
end
|
|
152
152
|
|
|
153
|
+
def validate_sampling_seed(value)
|
|
154
|
+
return if value.nil?
|
|
155
|
+
return if value.is_a?(Integer) && value >= 0
|
|
156
|
+
|
|
157
|
+
Rules.configuration_error(
|
|
158
|
+
"Invalid configuration value for mutation.sampling.seed: expected Integer >= 0, got #{value.inspect}"
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
153
162
|
def validate_sampling_completeness(value)
|
|
154
163
|
return if value.key?(:ratio) && value.key?(:strategy)
|
|
155
164
|
|
|
@@ -26,9 +26,10 @@ module Henitai
|
|
|
26
26
|
].freeze
|
|
27
27
|
VALID_MUTATION_KEYS = %i[
|
|
28
28
|
operators timeout timeout_multiplier ignore_patterns max_flaky_retries max_log_bytes max_timeout sampling
|
|
29
|
+
control_runs flaky_detection fork_server
|
|
29
30
|
].freeze
|
|
30
31
|
VALID_REPORTS_KEYS = %i[checkpoint checkpoint_every checkpoint_interval].freeze
|
|
31
|
-
VALID_SAMPLING_KEYS = %i[ratio strategy].freeze
|
|
32
|
+
VALID_SAMPLING_KEYS = %i[ratio strategy seed].freeze
|
|
32
33
|
VALID_COVERAGE_CRITERIA_KEYS = %i[test_result timeout process_abort].freeze
|
|
33
34
|
VALID_THRESHOLDS_KEYS = %i[high low].freeze
|
|
34
35
|
VALID_DASHBOARD_KEYS = %i[project base_url].freeze
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Names constants as the source writes them and attributes them to the
|
|
5
|
+
# namespace they belong to, as far as the source alone can tell.
|
|
6
|
+
module ConstantPath
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# The constant path of a +const+ or +casgn+ node as written;
|
|
10
|
+
# root-qualified paths keep their leading +::+. nil for other nodes.
|
|
11
|
+
def of(node)
|
|
12
|
+
return unless node.respond_to?(:type)
|
|
13
|
+
|
|
14
|
+
case node.type
|
|
15
|
+
when :const, :casgn
|
|
16
|
+
parent = of(node.children[0])
|
|
17
|
+
own = node.children[1].to_s
|
|
18
|
+
parent.nil? ? own : "#{parent}::#{own}"
|
|
19
|
+
when :cbase
|
|
20
|
+
""
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# +name+ inside +namespace+. A root-qualified name (+::Top+) ignores the
|
|
25
|
+
# enclosing namespace.
|
|
26
|
+
def qualify(namespace, name)
|
|
27
|
+
return name.delete_prefix("::") if name&.start_with?("::")
|
|
28
|
+
return name if namespace.nil? || namespace.empty?
|
|
29
|
+
return namespace if name.nil? || name.empty?
|
|
30
|
+
|
|
31
|
+
"#{namespace}::#{name}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The object a singleton method or singleton class is defined on: the
|
|
35
|
+
# enclosing namespace for +self+, the named constant otherwise, nil for
|
|
36
|
+
# any other object. A relative constant is attributed to the innermost
|
|
37
|
+
# lexical candidate, which Ruby also tries first. The source alone cannot
|
|
38
|
+
# tell whether +Other+ inside +module Outer+ is +Outer::Other+ or
|
|
39
|
+
# +::Other+, so this name can be off; activation resolves the real object
|
|
40
|
+
# at runtime (Mutant::Activator).
|
|
41
|
+
def owner(receiver, namespace)
|
|
42
|
+
case receiver.type
|
|
43
|
+
when :self then namespace
|
|
44
|
+
when :const then qualify(namespace, of(receiver))
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Verifies, after execution, that the activation path itself does not break
|
|
5
|
+
# a subject's tests.
|
|
6
|
+
#
|
|
7
|
+
# For every subject with a detected verdict (Killed, Timeout, RuntimeError)
|
|
8
|
+
# the unmutated method is injected through the same activation and fork
|
|
9
|
+
# path, against the same test files. The tests must pass. If they do not,
|
|
10
|
+
# injecting the method is what made them fail -- a lost constant scope,
|
|
11
|
+
# `super` or `yield` inside `define_method`, a rebuilt parameter list -- so
|
|
12
|
+
# none of that subject's detections can be credited to its mutations. They
|
|
13
|
+
# are reclassified as CompileError with the control's outcome as reason.
|
|
14
|
+
#
|
|
15
|
+
# Verdicts reused from history are left alone: they were not produced by
|
|
16
|
+
# this run's activation.
|
|
17
|
+
class ControlRun
|
|
18
|
+
DETECTED = %i[killed timeout runtime_error].freeze
|
|
19
|
+
|
|
20
|
+
# @param timeout_for [#call] test files -> timeout in seconds
|
|
21
|
+
def initialize(integration:, timeout_for:)
|
|
22
|
+
@integration = integration
|
|
23
|
+
@timeout_for = timeout_for
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [Hash] verified:, failed: subjects; reclassified: mutants
|
|
27
|
+
def verify(mutants)
|
|
28
|
+
stats = { verified: 0, failed: 0, reclassified: 0 }
|
|
29
|
+
detected_groups(mutants).each_value do |detected|
|
|
30
|
+
stats[:verified] += 1
|
|
31
|
+
result = run_control(detected.first)
|
|
32
|
+
next if result.status == :survived
|
|
33
|
+
|
|
34
|
+
stats[:failed] += 1
|
|
35
|
+
stats[:reclassified] += reclassify(detected, result)
|
|
36
|
+
end
|
|
37
|
+
stats
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def detected_groups(mutants)
|
|
43
|
+
Array(mutants)
|
|
44
|
+
.select { |mutant| DETECTED.include?(mutant.status) && !mutant.from_cache? && mutant.original_node }
|
|
45
|
+
.group_by { |mutant| [mutant.subject, test_files(mutant)] }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def run_control(mutant)
|
|
49
|
+
files = test_files(mutant)
|
|
50
|
+
@integration.run_mutant(mutant: control_for(mutant), test_files: files, timeout: @timeout_for.call(files))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def control_for(mutant)
|
|
54
|
+
node = mutant.original_node
|
|
55
|
+
Mutant.new(subject: mutant.subject, operator: "Control", nodes: { original: node, mutated: node },
|
|
56
|
+
description: "unmutated control", location: mutant.location)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def reclassify(detected, result)
|
|
60
|
+
reason = "control run failed: the unmutated method fails its tests when injected " \
|
|
61
|
+
"(#{[result.status, result.status_reason].compact.join(': ')})"
|
|
62
|
+
detected.each do |mutant|
|
|
63
|
+
mutant.status = :compile_error
|
|
64
|
+
mutant.status_reason = reason
|
|
65
|
+
end
|
|
66
|
+
detected.size
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def test_files(mutant)
|
|
70
|
+
Array(mutant.covered_by).sort
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -45,7 +45,9 @@ module Henitai
|
|
|
45
45
|
# but irrelevant report (e.g. from a different working directory) must
|
|
46
46
|
# still trigger a re-bootstrap rather than silently proceeding with no
|
|
47
47
|
# usable coverage.
|
|
48
|
-
|
|
48
|
+
if flaky_detection?(config)
|
|
49
|
+
run_flaky_baseline(integration, config, resolved_test_files)
|
|
50
|
+
elsif !coverage_ready?(source_files, config, integration, resolved_test_files)
|
|
49
51
|
bootstrap_coverage(integration, config, resolved_test_files)
|
|
50
52
|
end
|
|
51
53
|
|
|
@@ -69,7 +71,25 @@ module Henitai
|
|
|
69
71
|
def coverage_ready?(source_files, config, integration, test_files)
|
|
70
72
|
coverage_fresh?(source_files, config, test_files) &&
|
|
71
73
|
coverage_available?(source_files, config) &&
|
|
72
|
-
per_test_coverage_ready?(source_files, config, integration, test_files)
|
|
74
|
+
per_test_coverage_ready?(source_files, config, integration, test_files) &&
|
|
75
|
+
boot_seconds_ready?(config, integration)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Timeout calibration reads the boot time the per-test collector writes
|
|
79
|
+
# next to the coverage artifacts, so a missing boot file means the
|
|
80
|
+
# baseline must run again to measure it.
|
|
81
|
+
def boot_seconds_ready?(config, integration)
|
|
82
|
+
return true unless calibration_enabled?(config) && per_test_coverage_supported?(integration)
|
|
83
|
+
|
|
84
|
+
File.exist?(boot_seconds_report_path(config))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def calibration_enabled?(config)
|
|
88
|
+
config.respond_to?(:timeout_configured?) && !config.timeout_configured?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def boot_seconds_report_path(config)
|
|
92
|
+
File.join(reports_dir(config), PerTestCoverageCollector::BOOT_FILE_NAME)
|
|
73
93
|
end
|
|
74
94
|
|
|
75
95
|
def covered_source_files(source_files, coverage_lines)
|
|
@@ -135,19 +155,25 @@ module Henitai
|
|
|
135
155
|
return
|
|
136
156
|
end
|
|
137
157
|
|
|
138
|
-
raise CoverageError,
|
|
158
|
+
raise CoverageError, BootstrapErrorMessage.build(result)
|
|
139
159
|
end
|
|
140
160
|
end
|
|
141
161
|
end
|
|
142
162
|
|
|
143
|
-
|
|
144
|
-
|
|
163
|
+
# Flaky detection needs two complete baseline runs, so the existing
|
|
164
|
+
# coverage is never reused in this mode.
|
|
165
|
+
def flaky_detection?(config)
|
|
166
|
+
config.respond_to?(:flaky_detection) && config.flaky_detection
|
|
167
|
+
end
|
|
145
168
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
169
|
+
def run_flaky_baseline(integration, config, test_files)
|
|
170
|
+
with_reports_dir(config) do
|
|
171
|
+
with_coverage_dir(config) do
|
|
172
|
+
FlakyDetectionBaseline.new(reports_dir: reports_dir(config))
|
|
173
|
+
.run(integration:, test_files:)
|
|
174
|
+
record_dependency_manifest(config)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
151
177
|
end
|
|
152
178
|
|
|
153
179
|
def with_coverage_dir(config)
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "henitai/per_test_coverage_collector"
|
|
4
|
+
require "henitai/test_file_outcomes"
|
|
5
|
+
require "henitai/rspec_outcomes"
|
|
4
6
|
|
|
5
7
|
module Henitai
|
|
6
8
|
# Collects per-test coverage data for static filtering heuristics.
|
|
@@ -8,8 +10,14 @@ module Henitai
|
|
|
8
10
|
REPORT_DIR_ENV = PerTestCoverageCollector::REPORT_DIR_ENV
|
|
9
11
|
REPORT_FILE_NAME = PerTestCoverageCollector::REPORT_FILE_NAME
|
|
10
12
|
|
|
11
|
-
def initialize(_output)
|
|
12
|
-
@collector = PerTestCoverageCollector.new
|
|
13
|
+
def initialize(_output, boot_clock: SuiteBootClock.from_env(ENV))
|
|
14
|
+
@collector = PerTestCoverageCollector.new(boot_clock:)
|
|
15
|
+
@outcomes = TestFileOutcomes.new
|
|
16
|
+
@outcome_listener = RspecOutcomes::Listener.new(@outcomes)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def example_started(_notification)
|
|
20
|
+
@collector.test_started
|
|
13
21
|
end
|
|
14
22
|
|
|
15
23
|
def example_finished(notification)
|
|
@@ -18,10 +26,25 @@ module Henitai
|
|
|
18
26
|
example.metadata[:file_path],
|
|
19
27
|
duration: example.execution_result.run_time
|
|
20
28
|
)
|
|
29
|
+
@outcome_listener.example_finished(notification)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def example_group_started(notification)
|
|
33
|
+
@outcome_listener.example_group_started(notification)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def example_group_finished(notification)
|
|
37
|
+
@outcome_listener.example_group_finished(notification)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def message(notification)
|
|
41
|
+
@outcome_listener.message(notification)
|
|
21
42
|
end
|
|
22
43
|
|
|
23
|
-
def dump_summary(
|
|
44
|
+
def dump_summary(summary)
|
|
45
|
+
@outcome_listener.dump_summary(summary)
|
|
24
46
|
@collector.write_report
|
|
47
|
+
@outcomes.write(TestFileOutcomes.default_report_path)
|
|
25
48
|
end
|
|
26
49
|
end
|
|
27
50
|
end
|
|
@@ -7,6 +7,7 @@ module Henitai
|
|
|
7
7
|
class CoverageReportReader
|
|
8
8
|
DEFAULT_COVERAGE_REPORT_PATH = "coverage/.resultset.json"
|
|
9
9
|
DEFAULT_PER_TEST_COVERAGE_REPORT_PATH = "coverage/henitai_per_test.json"
|
|
10
|
+
DEFAULT_SUITE_BOOT_REPORT_PATH = "coverage/henitai_suite_boot.json"
|
|
10
11
|
|
|
11
12
|
def coverage_lines_by_file(path = DEFAULT_COVERAGE_REPORT_PATH)
|
|
12
13
|
return {} unless File.exist?(path)
|
|
@@ -37,6 +38,26 @@ module Henitai
|
|
|
37
38
|
end.to_h
|
|
38
39
|
end
|
|
39
40
|
|
|
41
|
+
# Suite boot seconds (loading helpers, spec files and before(:suite)).
|
|
42
|
+
# Missing or malformed reports yield 0.0, the value for "unknown".
|
|
43
|
+
def boot_seconds(path = DEFAULT_SUITE_BOOT_REPORT_PATH)
|
|
44
|
+
return 0.0 unless File.exist?(path)
|
|
45
|
+
|
|
46
|
+
parsed = JSON.parse(File.read(path))
|
|
47
|
+
parsed.is_a?(Hash) ? usable_boot_seconds(parsed["boot_seconds"]) : 0.0
|
|
48
|
+
rescue JSON::ParserError, ArgumentError, TypeError
|
|
49
|
+
0.0
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Only a finite, non-negative number is a usable measurement; anything
|
|
53
|
+
# else is treated as unknown.
|
|
54
|
+
def usable_boot_seconds(value)
|
|
55
|
+
return 0.0 unless value.is_a?(Numeric)
|
|
56
|
+
|
|
57
|
+
seconds = Float(value)
|
|
58
|
+
seconds.finite? && seconds >= 0 ? seconds : 0.0
|
|
59
|
+
end
|
|
60
|
+
|
|
40
61
|
private
|
|
41
62
|
|
|
42
63
|
def per_test_report(path)
|
|
@@ -27,8 +27,24 @@ module Henitai
|
|
|
27
27
|
# Multiplicative identity: `x * 1`, `x / 1` and `x ** 1` all reduce to `x`.
|
|
28
28
|
def one_operand?(node) = numeric_operand?(node, 1)
|
|
29
29
|
|
|
30
|
+
# The identities above only hold when the receiver is a number. For an
|
|
31
|
+
# unknown receiver `"ab" * 1` is `"ab"` while `"ab" / 1` raises, and a
|
|
32
|
+
# custom object may define its own operators. Negative zero is excluded
|
|
33
|
+
# because `-0.0 + 0` is `0.0` while `-0.0 - 0` is `-0.0`.
|
|
34
|
+
def numeric_receiver?(node)
|
|
35
|
+
return false unless node.is_a?(Parser::AST::Node)
|
|
36
|
+
|
|
37
|
+
case node.type
|
|
38
|
+
when :int then true
|
|
39
|
+
when :float then !negative_zero?(node.children.first)
|
|
40
|
+
else false
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
30
44
|
private
|
|
31
45
|
|
|
46
|
+
def negative_zero?(value) = value.zero? && (1.0 / value).negative?
|
|
47
|
+
|
|
32
48
|
# Reads the right-hand operand of a binary send. Guards against a
|
|
33
49
|
# malformed node whose operand is a bare Ruby value rather than an AST
|
|
34
50
|
# node -- that shape does not occur in parsed source, but the detector
|
|
@@ -6,9 +6,25 @@ require_relative "equivalence_detector/operand_predicates"
|
|
|
6
6
|
module Henitai
|
|
7
7
|
# Detects obvious equivalent mutants before execution.
|
|
8
8
|
#
|
|
9
|
-
# The detector is intentionally conservative: it only marks
|
|
10
|
-
# equivalent when the AST shape and the
|
|
11
|
-
#
|
|
9
|
+
# The detector is intentionally conservative: it only marks a mutation as
|
|
10
|
+
# equivalent when the AST shape and the literal operands prove the
|
|
11
|
+
# equivalence for every runtime value. Each rule below states the argument
|
|
12
|
+
# that makes it sound; a rule that cannot state one is not implemented.
|
|
13
|
+
#
|
|
14
|
+
# Rules:
|
|
15
|
+
# * Additive identity, `n + 0` <-> `n - 0`, when `n` is an Integer literal
|
|
16
|
+
# or a non-negative-zero Float literal. Both return `n`.
|
|
17
|
+
# * Multiplicative identity, `n * 1` <-> `n / 1` <-> `n ** 1`, under the
|
|
18
|
+
# same receiver condition. Both return `n`.
|
|
19
|
+
# * Left-literal logical identity, `false || x` -> `x` and `true && x` -> `x`.
|
|
20
|
+
# `false || x` is `x` for every `x`, and `true && x` is `x` for every `x`.
|
|
21
|
+
# The right-literal forms are unsound: `nil || false` is `false` and
|
|
22
|
+
# `5 && true` is `true`.
|
|
23
|
+
# * Singleton equality, `s == v` <-> `s.equal?(v)` when `s` and `v` are
|
|
24
|
+
# Symbol, nil, true or false literals. These values are unique instances,
|
|
25
|
+
# so `==` reduces to identity.
|
|
26
|
+
# * String equality, `"a" == "b"` <-> `"a".eql?("b")` when both operands are
|
|
27
|
+
# String literals. Both compare type and value for two Strings.
|
|
12
28
|
class EquivalenceDetector
|
|
13
29
|
def analyze(mutant)
|
|
14
30
|
return mutant unless equivalent_mutation?(mutant)
|
|
@@ -48,14 +64,16 @@ module Henitai
|
|
|
48
64
|
additive_operator?(original.children[1]) &&
|
|
49
65
|
additive_operator?(mutated.children[1]) &&
|
|
50
66
|
zero_operand?(original) &&
|
|
51
|
-
zero_operand?(mutated)
|
|
67
|
+
zero_operand?(mutated) &&
|
|
68
|
+
numeric_receiver?(original)
|
|
52
69
|
end
|
|
53
70
|
|
|
54
71
|
def multiplicative_equivalent?(original, mutated)
|
|
55
72
|
multiplicative_operator?(original.children[1]) &&
|
|
56
73
|
multiplicative_operator?(mutated.children[1]) &&
|
|
57
74
|
one_operand?(original) &&
|
|
58
|
-
one_operand?(mutated)
|
|
75
|
+
one_operand?(mutated) &&
|
|
76
|
+
numeric_receiver?(original)
|
|
59
77
|
end
|
|
60
78
|
|
|
61
79
|
def equivalent_logical_mutation?(mutant)
|
|
@@ -71,28 +89,23 @@ module Henitai
|
|
|
71
89
|
end
|
|
72
90
|
|
|
73
91
|
def logical_identity_equivalent?(original, mutated)
|
|
74
|
-
|
|
75
|
-
when :or
|
|
92
|
+
if original.type == :or
|
|
76
93
|
false_identity_equivalent?(original, mutated)
|
|
77
|
-
when :and
|
|
78
|
-
true_identity_equivalent?(original, mutated)
|
|
79
94
|
else
|
|
80
|
-
|
|
95
|
+
true_identity_equivalent?(original, mutated)
|
|
81
96
|
end
|
|
82
97
|
end
|
|
83
98
|
|
|
99
|
+
# `false || x` is `x` for every `x`. The right-literal form `x || false` is
|
|
100
|
+
# not: `nil || false` is `false`, not `nil`.
|
|
84
101
|
def false_identity_equivalent?(original, mutated)
|
|
85
|
-
|
|
86
|
-
return true if false_operand?(original.children[1]) && same_node?(mutated, original.children[0])
|
|
87
|
-
|
|
88
|
-
false
|
|
102
|
+
false_operand?(original.children[0]) && same_node?(mutated, original.children[1])
|
|
89
103
|
end
|
|
90
104
|
|
|
105
|
+
# `true && x` is `x` for every `x`. The right-literal form `x && true` is
|
|
106
|
+
# not: `5 && true` is `true`, not `5`.
|
|
91
107
|
def true_identity_equivalent?(original, mutated)
|
|
92
|
-
|
|
93
|
-
return true if true_operand?(original.children[1]) && same_node?(mutated, original.children[0])
|
|
94
|
-
|
|
95
|
-
false
|
|
108
|
+
true_operand?(original.children[0]) && same_node?(mutated, original.children[1])
|
|
96
109
|
end
|
|
97
110
|
|
|
98
111
|
def false_operand?(node)
|
|
@@ -121,6 +134,8 @@ module Henitai
|
|
|
121
134
|
|
|
122
135
|
def one_operand?(node) = operand_predicates.one_operand?(node)
|
|
123
136
|
|
|
137
|
+
def numeric_receiver?(node) = operand_predicates.numeric_receiver?(node.children[0])
|
|
138
|
+
|
|
124
139
|
def operand_predicates = @operand_predicates ||= OperandPredicates.new
|
|
125
140
|
|
|
126
141
|
# Detects `lhs == <singleton>` mutated to `lhs.equal?(<singleton>)` (or the
|
|
@@ -146,28 +161,27 @@ module Henitai
|
|
|
146
161
|
equality_operators?(original.children[1], mutated.children[1])
|
|
147
162
|
end
|
|
148
163
|
|
|
149
|
-
# Detects `lhs == rhs` mutated to `lhs.eql?(rhs)` (or the reverse) when
|
|
150
|
-
#
|
|
164
|
+
# Detects `lhs == rhs` mutated to `lhs.eql?(rhs)` (or the reverse) when both
|
|
165
|
+
# operands are string literals.
|
|
151
166
|
#
|
|
152
|
-
# String
|
|
153
|
-
#
|
|
154
|
-
#
|
|
155
|
-
#
|
|
156
|
-
# to be a String — proven here by the presence of a :str literal on the
|
|
157
|
-
# receiver or the argument side.
|
|
158
|
-
#
|
|
159
|
-
# When no operand is a string literal we conservatively leave the mutant
|
|
160
|
-
# pending: the receiver could be any object whose custom #== diverges from
|
|
161
|
-
# its #eql?.
|
|
167
|
+
# With two String literals, neither `==` nor `eql?` can reach a custom
|
|
168
|
+
# method, so both compare type and value and return the same result. A
|
|
169
|
+
# single string literal is not enough: an unknown receiver can override
|
|
170
|
+
# `==`, and `"a" == obj` calls `obj == "a"` when obj responds to `to_str`.
|
|
162
171
|
def equivalent_string_eql_mutation?(mutant)
|
|
163
172
|
original = mutant.original_node
|
|
164
173
|
mutated = mutant.mutated_node
|
|
165
174
|
|
|
175
|
+
string_eql_swap?(original, mutated) &&
|
|
176
|
+
string_literal?(original.children[0]) &&
|
|
177
|
+
string_literal?(original.children[2])
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def string_eql_swap?(original, mutated)
|
|
166
181
|
string_eql_send?(original) && string_eql_send?(mutated) &&
|
|
167
182
|
same_receiver?(original, mutated) &&
|
|
168
183
|
string_eql_operators?(original.children[1], mutated.children[1]) &&
|
|
169
|
-
same_rhs?(original, mutated)
|
|
170
|
-
string_operand?(original)
|
|
184
|
+
same_rhs?(original, mutated)
|
|
171
185
|
end
|
|
172
186
|
|
|
173
187
|
def string_eql_send?(node)
|
|
@@ -189,12 +203,6 @@ module Henitai
|
|
|
189
203
|
same_node?(original.children[2], mutated.children[2])
|
|
190
204
|
end
|
|
191
205
|
|
|
192
|
-
# Returns true when at least one operand is a string literal, giving static
|
|
193
|
-
# proof that the comparison is string-typed on at least one side.
|
|
194
|
-
def string_operand?(node)
|
|
195
|
-
string_literal?(node.children[0]) || string_literal?(node.children[2])
|
|
196
|
-
end
|
|
197
|
-
|
|
198
206
|
def string_literal?(node)
|
|
199
207
|
node.is_a?(Parser::AST::Node) && node.type == :str
|
|
200
208
|
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Reads whether a test run's output says that no example ran.
|
|
5
|
+
#
|
|
6
|
+
# Both texts are matched at the start of a line only, and only the last
|
|
7
|
+
# summary line counts: failure output may quote either text, indented, from
|
|
8
|
+
# the test source, and the tests themselves may print summaries of nested
|
|
9
|
+
# runs before the run's own.
|
|
10
|
+
module ExampleSummary
|
|
11
|
+
NO_EXAMPLES_FOUND = /^No examples found\.$/
|
|
12
|
+
SUMMARY = /^(\d+) (?:examples?, \d+ failures?|runs?, \d+ assertions?)/
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def none_ran?(output)
|
|
17
|
+
text = output.to_s
|
|
18
|
+
summaries = text.scan(SUMMARY)
|
|
19
|
+
return NO_EXAMPLES_FOUND.match?(text) if summaries.empty?
|
|
20
|
+
|
|
21
|
+
summaries.last == ["0"]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|