henitai 0.2.0 → 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 +105 -1
- data/README.md +138 -5
- data/assets/schema/henitai.schema.json +41 -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 +54 -0
- data/lib/henitai/cli/command_support.rb +52 -0
- data/lib/henitai/cli/init_command.rb +64 -0
- data/lib/henitai/cli/operator_command.rb +96 -0
- data/lib/henitai/cli/options.rb +84 -0
- data/lib/henitai/cli/run_command.rb +136 -0
- data/lib/henitai/cli/run_options.rb +108 -0
- data/lib/henitai/cli.rb +27 -407
- data/lib/henitai/composite_progress_reporter.rb +42 -0
- data/lib/henitai/configuration.rb +42 -9
- data/lib/henitai/configuration_validator/rules.rb +161 -0
- data/lib/henitai/configuration_validator/scalars.rb +169 -0
- data/lib/henitai/configuration_validator.rb +20 -240
- 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/eager_load.rb +36 -5
- data/lib/henitai/execution_engine/env_scope.rb +67 -0
- data/lib/henitai/execution_engine.rb +115 -53
- data/lib/henitai/generated_artifacts.rb +58 -0
- data/lib/henitai/incremental_filter.rb +100 -0
- data/lib/henitai/integration/base.rb +171 -0
- data/lib/henitai/integration/child_debug_support.rb +119 -0
- data/lib/henitai/integration/child_runtime_control.rb +50 -0
- data/lib/henitai/integration/coverage_suppression.rb +52 -0
- data/lib/henitai/integration/minitest.rb +101 -0
- 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/mutant_run_support.rb +77 -0
- data/lib/henitai/integration/rails_environment_preloader.rb +14 -0
- data/lib/henitai/integration/rspec_child_runner.rb +61 -0
- data/lib/henitai/integration/rspec_test_selection.rb +138 -0
- data/lib/henitai/integration/scenario_log_support.rb +160 -0
- data/lib/henitai/integration.rb +22 -846
- data/lib/henitai/minitest_coverage_reporter.rb +4 -1
- data/lib/henitai/mutant/activator.rb +15 -79
- data/lib/henitai/mutant/parameter_source.rb +98 -0
- data/lib/henitai/mutant.rb +14 -6
- data/lib/henitai/mutant_history_store/sql.rb +90 -0
- data/lib/henitai/mutant_history_store/verdict_cache.rb +84 -0
- data/lib/henitai/mutant_history_store.rb +67 -81
- 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 +23 -6
- data/lib/henitai/per_test_coverage_selector.rb +11 -33
- data/lib/henitai/process_worker_runner.rb +48 -334
- data/lib/henitai/reporter/dashboard_metadata_provider.rb +113 -0
- data/lib/henitai/reporter.rb +258 -125
- data/lib/henitai/reports_directory_lock.rb +76 -0
- data/lib/henitai/result.rb +88 -23
- data/lib/henitai/runner.rb +137 -218
- data/lib/henitai/scenario_execution_result.rb +12 -0
- data/lib/henitai/slot_scheduler/draining.rb +146 -0
- data/lib/henitai/slot_scheduler/process_control.rb +43 -0
- data/lib/henitai/slot_scheduler.rb +272 -0
- data/lib/henitai/static_filter.rb +16 -6
- data/lib/henitai/survivor_rerun_strategy.rb +195 -0
- 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/unparse_helper.rb +5 -2
- data/lib/henitai/verdict_fingerprint.rb +155 -0
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +16 -1
- data/sig/configuration_validator.rbs +46 -22
- data/sig/henitai.rbs +386 -96
- metadata +45 -3
- data/lib/henitai/parallel_execution_runner.rb +0 -153
data/lib/henitai/result.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
|
+
require_relative "mutant_identity"
|
|
4
5
|
require_relative "unparse_helper"
|
|
5
6
|
|
|
6
7
|
module Henitai
|
|
@@ -17,22 +18,36 @@ module Henitai
|
|
|
17
18
|
attr_reader :mutants, :started_at, :finished_at, :thresholds, :survivor_stats,
|
|
18
19
|
:session_id, :git_sha
|
|
19
20
|
|
|
21
|
+
# @param source_provider [#call] maps a file path to its source string.
|
|
22
|
+
# Injected so the domain object performs no disk IO; the caller (which
|
|
23
|
+
# already knows the file paths) supplies the contents. The default
|
|
24
|
+
# provider returns "" for every file — callers that need real source in
|
|
25
|
+
# the schema (e.g. the runner) inject a provider primed with file content.
|
|
26
|
+
# @param authoritative [Boolean] whether this run covers the full
|
|
27
|
+
# configured mutation scope. Authoritative runs fully replace the
|
|
28
|
+
# canonical report; non-authoritative (subject-scoped, --since,
|
|
29
|
+
# --survivors-from) runs are merged into it instead — see
|
|
30
|
+
# CanonicalReportMerger.
|
|
20
31
|
# rubocop:disable Metrics/ParameterLists
|
|
21
32
|
def initialize(mutants:, started_at:, finished_at:, thresholds: nil,
|
|
22
33
|
partial_rerun: false, survivor_stats: nil,
|
|
23
|
-
session_id: SecureRandom.uuid, git_sha: nil
|
|
24
|
-
|
|
25
|
-
@
|
|
26
|
-
@
|
|
27
|
-
@
|
|
28
|
-
@
|
|
29
|
-
@
|
|
30
|
-
@
|
|
31
|
-
@
|
|
34
|
+
session_id: SecureRandom.uuid, git_sha: nil,
|
|
35
|
+
source_provider: ->(_file) { "" }, authoritative: true)
|
|
36
|
+
@mutants = mutants
|
|
37
|
+
@started_at = started_at
|
|
38
|
+
@finished_at = finished_at
|
|
39
|
+
@thresholds = DEFAULT_THRESHOLDS.merge(thresholds || {})
|
|
40
|
+
@partial_rerun = partial_rerun
|
|
41
|
+
@survivor_stats = survivor_stats
|
|
42
|
+
@session_id = session_id
|
|
43
|
+
@git_sha = git_sha
|
|
44
|
+
@source_provider = source_provider
|
|
45
|
+
@authoritative = authoritative
|
|
32
46
|
end
|
|
33
47
|
# rubocop:enable Metrics/ParameterLists
|
|
34
48
|
|
|
35
49
|
def partial_rerun? = @partial_rerun
|
|
50
|
+
def authoritative? = @authoritative
|
|
36
51
|
|
|
37
52
|
# @return [Integer] number of killed mutants
|
|
38
53
|
def killed = mutants.count(&:killed?)
|
|
@@ -46,7 +61,7 @@ module Henitai
|
|
|
46
61
|
# Detected = killed + timeout + runtime_error (alle Zustände die einen Fehler beweisen)
|
|
47
62
|
# @return [Integer]
|
|
48
63
|
def detected
|
|
49
|
-
mutants
|
|
64
|
+
detected_in(mutants)
|
|
50
65
|
end
|
|
51
66
|
|
|
52
67
|
# Mutation Score (MS) — Architektur-Formel aus Abschnitt 6.1:
|
|
@@ -59,11 +74,7 @@ module Henitai
|
|
|
59
74
|
#
|
|
60
75
|
# @return [Float, nil] 0.0–100.0, nil wenn kein valider Mutant vorhanden
|
|
61
76
|
def mutation_score
|
|
62
|
-
|
|
63
|
-
valid = mutants.reject { |m| excluded.include?(m.status) }
|
|
64
|
-
return nil if valid.empty?
|
|
65
|
-
|
|
66
|
-
((detected.to_f / valid.count) * 100.0).round(2).to_f
|
|
77
|
+
mutation_score_for(mutants)
|
|
67
78
|
end
|
|
68
79
|
|
|
69
80
|
# Mutation Score Indicator (MSI) — naive Berechnung ohne Äquivalenz-Bereinigung:
|
|
@@ -75,9 +86,23 @@ module Henitai
|
|
|
75
86
|
#
|
|
76
87
|
# @return [Float, nil]
|
|
77
88
|
def mutation_score_indicator
|
|
78
|
-
|
|
89
|
+
mutation_score_indicator_for(mutants)
|
|
90
|
+
end
|
|
79
91
|
|
|
80
|
-
|
|
92
|
+
# Scores over only the mutants actually executed this run — verdicts
|
|
93
|
+
# reused from the history store (`--incremental`) excluded. nil when
|
|
94
|
+
# nothing was reused: the combined scores already tell the whole story,
|
|
95
|
+
# and reporters use nil to suppress the extra line.
|
|
96
|
+
#
|
|
97
|
+
# @return [Hash, nil] { mutation_score:, mutation_score_indicator: }
|
|
98
|
+
def executed_scoring_summary
|
|
99
|
+
executed = mutants.reject { |m| m.respond_to?(:from_cache?) && m.from_cache? }
|
|
100
|
+
return nil if executed.size == mutants.size
|
|
101
|
+
|
|
102
|
+
{
|
|
103
|
+
mutation_score: mutation_score_for(executed),
|
|
104
|
+
mutation_score_indicator: mutation_score_indicator_for(executed)
|
|
105
|
+
}
|
|
81
106
|
end
|
|
82
107
|
|
|
83
108
|
# Compact public summary for reporters.
|
|
@@ -112,6 +137,24 @@ module Henitai
|
|
|
112
137
|
|
|
113
138
|
private
|
|
114
139
|
|
|
140
|
+
def detected_in(list)
|
|
141
|
+
list.count { |m| %i[killed timeout runtime_error].include?(m.status) }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def mutation_score_for(list)
|
|
145
|
+
excluded = %i[ignored no_coverage compile_error equivalent]
|
|
146
|
+
valid = list.reject { |m| excluded.include?(m.status) }
|
|
147
|
+
return nil if valid.empty?
|
|
148
|
+
|
|
149
|
+
((detected_in(list).to_f / valid.count) * 100.0).round(2).to_f
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def mutation_score_indicator_for(list)
|
|
153
|
+
return nil if list.empty?
|
|
154
|
+
|
|
155
|
+
((list.count(&:killed?).to_f / list.count) * 100.0).round(2).to_f
|
|
156
|
+
end
|
|
157
|
+
|
|
115
158
|
def base_schema
|
|
116
159
|
{ # : Hash[Symbol, untyped]
|
|
117
160
|
schemaVersion: SCHEMA_VERSION,
|
|
@@ -129,14 +172,9 @@ module Henitai
|
|
|
129
172
|
|
|
130
173
|
def build_files_section
|
|
131
174
|
mutants.group_by { |m| m.location[:file] }.transform_values do |file_mutants|
|
|
132
|
-
source = begin
|
|
133
|
-
File.read(file_mutants.first.location[:file])
|
|
134
|
-
rescue StandardError
|
|
135
|
-
""
|
|
136
|
-
end
|
|
137
175
|
{
|
|
138
176
|
language: "ruby",
|
|
139
|
-
source
|
|
177
|
+
source: @source_provider.call(file_mutants.first.location[:file]),
|
|
140
178
|
mutants: file_mutants.map { |m| mutant_to_schema(m) }
|
|
141
179
|
}
|
|
142
180
|
end
|
|
@@ -146,10 +184,13 @@ module Henitai
|
|
|
146
184
|
{
|
|
147
185
|
id: mutant.id,
|
|
148
186
|
stableId: mutant.stable_id,
|
|
187
|
+
legacyStableId: legacy_stable_id_for(mutant),
|
|
149
188
|
mutatorName: mutant.operator,
|
|
150
189
|
replacement: replacement_for(mutant),
|
|
151
190
|
location: location_for(mutant),
|
|
152
191
|
status: stryker_status(mutant.status),
|
|
192
|
+
statusReason: status_reason_for(mutant),
|
|
193
|
+
fromCache: from_cache_for(mutant),
|
|
153
194
|
description: mutant.description,
|
|
154
195
|
duration: duration_for(mutant)
|
|
155
196
|
}.compact.merge(coverage_schema(mutant))
|
|
@@ -166,6 +207,8 @@ module Henitai
|
|
|
166
207
|
end
|
|
167
208
|
|
|
168
209
|
def replacement_for(mutant)
|
|
210
|
+
return mutant.mutated_source if mutant.respond_to?(:mutated_source)
|
|
211
|
+
|
|
169
212
|
safe_unparse(mutant.mutated_node)
|
|
170
213
|
end
|
|
171
214
|
|
|
@@ -188,6 +231,28 @@ module Henitai
|
|
|
188
231
|
mutant.duration&.then { |d| (d * 1000).round }
|
|
189
232
|
end
|
|
190
233
|
|
|
234
|
+
# Serialized as the schema's statusReason so directive reasons show up
|
|
235
|
+
# next to the Ignored status in the HTML report.
|
|
236
|
+
def status_reason_for(mutant)
|
|
237
|
+
return nil unless mutant.respond_to?(:ignore_reason)
|
|
238
|
+
|
|
239
|
+
mutant.ignore_reason
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Vendored extension field (like stableId): marks verdicts reused from
|
|
243
|
+
# the history store so reports stay honest about what actually executed.
|
|
244
|
+
def from_cache_for(mutant)
|
|
245
|
+
return nil unless mutant.respond_to?(:from_cache?)
|
|
246
|
+
|
|
247
|
+
mutant.from_cache? || nil
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def legacy_stable_id_for(mutant)
|
|
251
|
+
return if mutant.respond_to?(:precomputed_stable_id) && mutant.precomputed_stable_id
|
|
252
|
+
|
|
253
|
+
MutantIdentity.legacy_stable_id(mutant)
|
|
254
|
+
end
|
|
255
|
+
|
|
191
256
|
def equivalence_uncertainty
|
|
192
257
|
return nil if mutation_score.nil?
|
|
193
258
|
|
data/lib/henitai/runner.rb
CHANGED
|
@@ -25,15 +25,19 @@ module Henitai
|
|
|
25
25
|
# Gate 5 — Reporting
|
|
26
26
|
# Write results to configured reporters (terminal, html, json, dashboard).
|
|
27
27
|
#
|
|
28
|
-
# rubocop:disable Metrics/ClassLength
|
|
29
28
|
class Runner
|
|
30
29
|
attr_reader :config, :result
|
|
31
30
|
|
|
32
|
-
|
|
31
|
+
# @param mode [Hash] execution-mode flags: +dry_run:+ stops before Gate 4,
|
|
32
|
+
# +incremental:+ reuses still-valid Killed verdicts from history.
|
|
33
|
+
def initialize(config: Configuration.load, subjects: nil, since: nil, survivors_from: nil,
|
|
34
|
+
mode: {})
|
|
33
35
|
@config = config
|
|
34
36
|
@subjects = subjects
|
|
35
37
|
@since = since
|
|
36
38
|
@survivors_from = survivors_from
|
|
39
|
+
@dry_run = mode.fetch(:dry_run, false)
|
|
40
|
+
@incremental = mode.fetch(:incremental, false)
|
|
37
41
|
end
|
|
38
42
|
|
|
39
43
|
# Entry point — runs the full pipeline and returns a Result.
|
|
@@ -49,21 +53,40 @@ module Henitai
|
|
|
49
53
|
#
|
|
50
54
|
# @return [Result]
|
|
51
55
|
def run
|
|
52
|
-
|
|
56
|
+
ReportsDirectoryLock.new(reports_dir: config.reports_dir).synchronize do
|
|
57
|
+
started_at = Time.now
|
|
53
58
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
else
|
|
57
|
-
source_files = self.source_files
|
|
58
|
-
subjects = resolve_subjects(source_files)
|
|
59
|
-
execute_mutants(mutants_for(subjects, source_files))
|
|
60
|
-
end
|
|
59
|
+
mutants = pipeline_mutants
|
|
60
|
+
return dry_run_result(mutants, started_at, Time.now) if @dry_run
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
build_result(execute_mutants(mutants), started_at, Time.now)
|
|
63
|
+
end
|
|
63
64
|
end
|
|
64
65
|
|
|
65
66
|
private
|
|
66
67
|
|
|
68
|
+
# Gates 0–3 only: coverage bootstrap, subject resolution, generation and
|
|
69
|
+
# static/skip/arid/stillborn filtering — everything short of execution.
|
|
70
|
+
def pipeline_mutants
|
|
71
|
+
if survivor_rerun? && (fast_mutants = survivor_strategy.try_recipe_run)
|
|
72
|
+
return fast_mutants
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
source_files = self.source_files
|
|
76
|
+
subjects = resolve_subjects(source_files)
|
|
77
|
+
mutants_for(subjects, source_files)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Dry run stops before Gate 4: prints the post-filter listing and returns
|
|
81
|
+
# a Result without executing mutants, persisting history or running the
|
|
82
|
+
# configured reporters. Gate 0 and lock coordination may still write under
|
|
83
|
+
# reports_dir.
|
|
84
|
+
def dry_run_result(mutants, started_at, finished_at)
|
|
85
|
+
@result = build_result_object(mutants, started_at, finished_at)
|
|
86
|
+
Reporter::DryRun.new(config:).report(@result)
|
|
87
|
+
@result
|
|
88
|
+
end
|
|
89
|
+
|
|
67
90
|
def resolve_subjects(source_files = self.source_files)
|
|
68
91
|
subjects = subject_resolver.resolve_from_files(source_files)
|
|
69
92
|
return subjects if pattern_subjects.empty?
|
|
@@ -86,14 +109,27 @@ module Henitai
|
|
|
86
109
|
bootstrap_thread = bootstrap_mutants(source_files)
|
|
87
110
|
mutants = generate_mutants(subjects)
|
|
88
111
|
bootstrap_thread.value
|
|
89
|
-
filtered = filter_mutants(mutants)
|
|
90
|
-
|
|
112
|
+
filtered = apply_incremental_filter(filter_mutants(mutants))
|
|
113
|
+
return filtered unless survivor_rerun?
|
|
114
|
+
|
|
115
|
+
survivor_strategy.apply_selection(filtered)
|
|
91
116
|
end
|
|
92
117
|
|
|
93
|
-
|
|
94
|
-
|
|
118
|
+
# Opt-in verdict reuse (`--incremental`): still-valid Killed and Survived
|
|
119
|
+
# verdicts from the history store are marked with their stored status +
|
|
120
|
+
# from_cache before execution. The filter is only ever constructed when
|
|
121
|
+
# the flag is set; it runs after the coverage bootstrap join in
|
|
122
|
+
# mutants_for, so the live per-test map it reads is never mid-write.
|
|
123
|
+
def apply_incremental_filter(mutants)
|
|
124
|
+
return mutants unless @incremental
|
|
125
|
+
|
|
126
|
+
IncrementalFilter.new(history_store:, per_test_coverage:,
|
|
127
|
+
dependency_fingerprint: VerdictFingerprint.dependency_fingerprint)
|
|
128
|
+
.apply(mutants)
|
|
95
129
|
end
|
|
96
130
|
|
|
131
|
+
def bootstrap_mutants(source_files) = Thread.new { bootstrap_coverage(source_files) }
|
|
132
|
+
|
|
97
133
|
def execute_mutants(mutants)
|
|
98
134
|
execution_engine.run(
|
|
99
135
|
mutants,
|
|
@@ -104,7 +140,7 @@ module Henitai
|
|
|
104
140
|
end
|
|
105
141
|
|
|
106
142
|
def report(result)
|
|
107
|
-
Reporter.run_all(names: config.reporters, result:, config:)
|
|
143
|
+
Reporter.run_all(names: config.reporters, result:, config:, history_store:)
|
|
108
144
|
end
|
|
109
145
|
|
|
110
146
|
def persist_history(result, recorded_at)
|
|
@@ -116,18 +152,38 @@ module Henitai
|
|
|
116
152
|
end
|
|
117
153
|
|
|
118
154
|
def build_result(mutants, started_at, finished_at)
|
|
119
|
-
@result =
|
|
155
|
+
@result = build_result_object(mutants, started_at, finished_at)
|
|
156
|
+
persist_history(@result, finished_at)
|
|
157
|
+
report(@result)
|
|
158
|
+
@result
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def build_result_object(mutants, started_at, finished_at)
|
|
162
|
+
Result.new(
|
|
120
163
|
mutants:,
|
|
121
164
|
started_at:,
|
|
122
165
|
finished_at:,
|
|
123
166
|
thresholds: result_thresholds,
|
|
124
167
|
partial_rerun: survivor_rerun?,
|
|
125
|
-
survivor_stats:
|
|
126
|
-
git_sha: safe_head_sha
|
|
168
|
+
survivor_stats: survivor_strategy.survivor_stats,
|
|
169
|
+
git_sha: safe_head_sha,
|
|
170
|
+
source_provider: source_provider,
|
|
171
|
+
authoritative: full_run?
|
|
127
172
|
)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Reads each source file once and caches it, so Result consumes source
|
|
176
|
+
# content while performing no disk IO of its own. Returns "" for files that
|
|
177
|
+
# cannot be read (e.g. recipe stubs with synthetic locations).
|
|
178
|
+
def source_provider
|
|
179
|
+
cache = {} # : Hash[String, String]
|
|
180
|
+
lambda do |file|
|
|
181
|
+
cache[file] ||= begin
|
|
182
|
+
File.read(file)
|
|
183
|
+
rescue StandardError
|
|
184
|
+
""
|
|
185
|
+
end
|
|
186
|
+
end
|
|
131
187
|
end
|
|
132
188
|
|
|
133
189
|
def safe_head_sha
|
|
@@ -142,29 +198,17 @@ module Henitai
|
|
|
142
198
|
coverage_bootstrapper.ensure!(source_files:, config:, integration:, test_files:)
|
|
143
199
|
end
|
|
144
200
|
|
|
145
|
-
def subject_resolver
|
|
146
|
-
@subject_resolver ||= SubjectResolver.new
|
|
147
|
-
end
|
|
201
|
+
def subject_resolver = @subject_resolver ||= SubjectResolver.new
|
|
148
202
|
|
|
149
|
-
def git_diff_analyzer
|
|
150
|
-
@git_diff_analyzer ||= GitDiffAnalyzer.new
|
|
151
|
-
end
|
|
203
|
+
def git_diff_analyzer = @git_diff_analyzer ||= GitDiffAnalyzer.new
|
|
152
204
|
|
|
153
|
-
def mutant_generator
|
|
154
|
-
@mutant_generator ||= MutantGenerator.new
|
|
155
|
-
end
|
|
205
|
+
def mutant_generator = @mutant_generator ||= MutantGenerator.new
|
|
156
206
|
|
|
157
|
-
def static_filter
|
|
158
|
-
@static_filter ||= StaticFilter.new
|
|
159
|
-
end
|
|
207
|
+
def static_filter = @static_filter ||= StaticFilter.new
|
|
160
208
|
|
|
161
|
-
def execution_engine
|
|
162
|
-
@execution_engine ||= ExecutionEngine.new
|
|
163
|
-
end
|
|
209
|
+
def execution_engine = @execution_engine ||= ExecutionEngine.new
|
|
164
210
|
|
|
165
|
-
def coverage_bootstrapper
|
|
166
|
-
@coverage_bootstrapper ||= CoverageBootstrapper.new
|
|
167
|
-
end
|
|
211
|
+
def coverage_bootstrapper = @coverage_bootstrapper ||= CoverageBootstrapper.new
|
|
168
212
|
|
|
169
213
|
def integration
|
|
170
214
|
@integration ||= Integration.for(config.integration).new
|
|
@@ -174,220 +218,95 @@ module Henitai
|
|
|
174
218
|
@operators ||= Operator.for_set(config.operators)
|
|
175
219
|
end
|
|
176
220
|
|
|
221
|
+
# Fans progress out to the terminal reporter (when enabled) and the
|
|
222
|
+
# checkpoint writer (when enabled and a file report is configured), so a
|
|
223
|
+
# long run persists partial results incrementally.
|
|
177
224
|
def progress_reporter
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
Reporter::Terminal.new(config:)
|
|
225
|
+
CompositeProgressReporter.for(config:, source_provider:, full_run: full_run?)
|
|
181
226
|
end
|
|
182
227
|
|
|
183
228
|
def history_store
|
|
184
229
|
@history_store ||= MutantHistoryStore.new(
|
|
185
|
-
path: File.join(config.reports_dir, Henitai::HISTORY_STORE_FILENAME)
|
|
230
|
+
path: File.join(config.reports_dir, Henitai::HISTORY_STORE_FILENAME), per_test_coverage:
|
|
186
231
|
)
|
|
187
232
|
end
|
|
188
233
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if @since
|
|
196
|
-
changed_files = git_diff_analyzer.changed_files(from: @since, to: "HEAD")
|
|
197
|
-
changed_file_set = changed_files.map { |path| normalize_path(path) }
|
|
198
|
-
|
|
199
|
-
included_files.select do |path|
|
|
200
|
-
changed_file_set.include?(normalize_path(path))
|
|
201
|
-
end
|
|
202
|
-
else
|
|
203
|
-
included_files
|
|
204
|
-
end
|
|
205
|
-
end
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
def pattern_subjects
|
|
209
|
-
Array(@subjects)
|
|
210
|
-
end
|
|
211
|
-
|
|
212
|
-
def unique_subjects(subjects)
|
|
213
|
-
subjects.uniq { |subject| [subject.expression, subject.source_file] }
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
def normalize_path(path)
|
|
217
|
-
File.expand_path(path)
|
|
218
|
-
end
|
|
219
|
-
|
|
220
|
-
def result_thresholds
|
|
221
|
-
return nil unless config.respond_to?(:thresholds)
|
|
222
|
-
|
|
223
|
-
config.thresholds
|
|
234
|
+
# One shared live view of the per-test coverage map: the incremental
|
|
235
|
+
# filter proves survivor reuse against it and the history store records
|
|
236
|
+
# the same intersection set — one implementation, one snapshot.
|
|
237
|
+
def per_test_coverage
|
|
238
|
+
@per_test_coverage ||= PerTestCoverage.new(reports_dir: config.reports_dir)
|
|
224
239
|
end
|
|
225
240
|
|
|
226
|
-
def
|
|
227
|
-
|
|
241
|
+
def source_files
|
|
242
|
+
@source_files ||= filter_changed(reject_excluded(included_source_files))
|
|
228
243
|
end
|
|
229
244
|
|
|
230
|
-
def
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
loaded = SurvivorLoader.new(@survivors_from, include_paths: Array(config.includes)).load
|
|
235
|
-
selector = SurvivorSelector.new(survivor_ids: loaded.survivor_ids)
|
|
236
|
-
selected = selector.select(mutants)
|
|
237
|
-
finalize_survivor_split(
|
|
238
|
-
selector,
|
|
239
|
-
selected,
|
|
240
|
-
test_filter(
|
|
241
|
-
loaded,
|
|
242
|
-
dirty_source_files: dirty_source_files?(dirty_worktree_files, git_sha: loaded.git_sha)
|
|
243
|
-
).apply(selected)
|
|
244
|
-
)
|
|
245
|
+
def included_source_files
|
|
246
|
+
Array(config.includes).flat_map do |include_path|
|
|
247
|
+
Dir.glob(File.join(include_path, "**", "*.rb"))
|
|
248
|
+
end.uniq
|
|
245
249
|
end
|
|
246
250
|
|
|
247
|
-
#
|
|
248
|
-
#
|
|
249
|
-
#
|
|
250
|
-
def
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
return nil unless recipe_fast_path_safe?(loaded, dirty_worktree_files)
|
|
251
|
+
# Drops files matched by any `excludes:` glob (e.g. standalone entry points
|
|
252
|
+
# that cannot be mutation-tested in-process). Excludes apply regardless of
|
|
253
|
+
# the --since filter.
|
|
254
|
+
def reject_excluded(files)
|
|
255
|
+
excluded = excluded_source_files
|
|
256
|
+
return files if excluded.empty?
|
|
254
257
|
|
|
255
|
-
|
|
258
|
+
files.reject { |path| excluded.include?(normalize_path(path)) }
|
|
256
259
|
end
|
|
257
260
|
|
|
258
|
-
def
|
|
259
|
-
|
|
261
|
+
def excluded_source_files
|
|
262
|
+
Array(config.excludes)
|
|
263
|
+
.flat_map { |pattern| Dir.glob(pattern) }
|
|
264
|
+
.map { |path| normalize_path(path) }
|
|
260
265
|
end
|
|
261
266
|
|
|
262
|
-
def
|
|
263
|
-
|
|
264
|
-
return nil if recipes.nil?
|
|
267
|
+
def filter_changed(files)
|
|
268
|
+
return files unless @since
|
|
265
269
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
).apply(stubs)
|
|
271
|
-
finalize_survivor_split(selector, stubs, split)
|
|
270
|
+
changed_file_set = git_diff_analyzer
|
|
271
|
+
.changed_files(from: @since, to: "HEAD")
|
|
272
|
+
.map { |path| normalize_path(path) }
|
|
273
|
+
files.select { |path| changed_file_set.include?(normalize_path(path)) }
|
|
272
274
|
end
|
|
273
275
|
|
|
274
|
-
def
|
|
275
|
-
|
|
276
|
+
def pattern_subjects
|
|
277
|
+
Array(@subjects)
|
|
276
278
|
end
|
|
277
279
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
# #drift_warning? / #unmatched_ids are available (all IDs will be matched).
|
|
281
|
-
def recipe_selector_and_stubs(survivor_ids, recipes)
|
|
282
|
-
stubs = survivor_ids.map { |id| build_stub_mutant(id, recipes[id]) }
|
|
283
|
-
selector = SurvivorSelector.new(survivor_ids:)
|
|
284
|
-
selector.select(stubs)
|
|
285
|
-
[selector, stubs]
|
|
280
|
+
def unique_subjects(subjects)
|
|
281
|
+
subjects.uniq { |subject| [subject.expression, subject.source_file] }
|
|
286
282
|
end
|
|
287
283
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
def load_activation_recipes(survivor_ids)
|
|
291
|
-
path = File.join(File.dirname(@survivors_from), SurvivorActivationCache::FILENAME)
|
|
292
|
-
recipes = SurvivorActivationCache.load(path)
|
|
293
|
-
return nil if recipes.nil?
|
|
294
|
-
return nil unless survivor_ids.all? { |id| recipes.key?(id) }
|
|
295
|
-
|
|
296
|
-
recipes
|
|
284
|
+
def normalize_path(path)
|
|
285
|
+
File.expand_path(path)
|
|
297
286
|
end
|
|
298
287
|
|
|
299
|
-
def
|
|
300
|
-
|
|
301
|
-
subject: stub_subject_from_recipe(recipe),
|
|
302
|
-
operator: recipe.fetch("operator"),
|
|
303
|
-
nodes: { original: nil, mutated: nil },
|
|
304
|
-
description: recipe.fetch("description"),
|
|
305
|
-
location: recipe_location(recipe["location"]),
|
|
306
|
-
precomputed_stable_id: stable_id,
|
|
307
|
-
precomputed_activation_source: recipe.fetch("activationSource")
|
|
308
|
-
)
|
|
309
|
-
mutant.covered_by = recipe["coveredBy"]
|
|
310
|
-
mutant
|
|
311
|
-
end
|
|
288
|
+
def result_thresholds
|
|
289
|
+
return nil unless config.respond_to?(:thresholds)
|
|
312
290
|
|
|
313
|
-
|
|
314
|
-
Subject.new(
|
|
315
|
-
namespace: recipe["namespace"],
|
|
316
|
-
method_name: recipe["methodName"],
|
|
317
|
-
method_type: (recipe["methodType"] || "instance").to_sym,
|
|
318
|
-
source_location: { file: recipe["sourceFile"], range: nil }
|
|
319
|
-
)
|
|
291
|
+
config.thresholds
|
|
320
292
|
end
|
|
321
293
|
|
|
322
|
-
def
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
{
|
|
326
|
-
file: loc["file"],
|
|
327
|
-
start_line: loc["startLine"],
|
|
328
|
-
end_line: loc["endLine"],
|
|
329
|
-
start_col: loc["startCol"],
|
|
330
|
-
end_col: loc["endCol"]
|
|
331
|
-
}.compact
|
|
294
|
+
def survivor_rerun?
|
|
295
|
+
!@survivors_from.nil?
|
|
332
296
|
end
|
|
333
297
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
split[:stable] + split[:pending]
|
|
298
|
+
# Mutation-scope full run, controlling Result#authoritative? — distinct
|
|
299
|
+
# from the per-test-coverage plan's test-suite-scope "full run".
|
|
300
|
+
def full_run?
|
|
301
|
+
pattern_subjects.empty? && @since.nil? && !survivor_rerun?
|
|
339
302
|
end
|
|
340
303
|
|
|
341
|
-
def
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
worktree_changed_files: Array(dirty_worktree_changed_files),
|
|
347
|
-
diff_analyzer: git_diff_analyzer
|
|
304
|
+
def survivor_strategy
|
|
305
|
+
@survivor_strategy ||= SurvivorRerunStrategy.new(
|
|
306
|
+
survivors_from: @survivors_from,
|
|
307
|
+
config:,
|
|
308
|
+
git_diff_analyzer:
|
|
348
309
|
)
|
|
349
310
|
end
|
|
350
|
-
|
|
351
|
-
def dirty_worktree_changed_files
|
|
352
|
-
@dirty_worktree_changed_files ||= git_diff_analyzer.working_tree_changed_files
|
|
353
|
-
rescue StandardError
|
|
354
|
-
nil
|
|
355
|
-
end
|
|
356
|
-
|
|
357
|
-
def dirty_source_files?(dirty_worktree_files, git_sha: nil)
|
|
358
|
-
return true if dirty_worktree_files.nil?
|
|
359
|
-
|
|
360
|
-
all_changed = dirty_worktree_files + committed_changed_files(git_sha)
|
|
361
|
-
include_roots = Array(config.includes).map { |path| normalize_path(path) }
|
|
362
|
-
all_changed.any? { |path| in_include_root?(normalize_path(path), include_roots) }
|
|
363
|
-
rescue StandardError
|
|
364
|
-
true
|
|
365
|
-
end
|
|
366
|
-
|
|
367
|
-
def committed_changed_files(git_sha)
|
|
368
|
-
return [] unless git_sha
|
|
369
|
-
|
|
370
|
-
git_diff_analyzer.changed_files(from: git_sha, to: "HEAD")
|
|
371
|
-
end
|
|
372
|
-
|
|
373
|
-
def in_include_root?(path, include_roots)
|
|
374
|
-
include_roots.any? { |root| path == root || path.start_with?("#{root}/") }
|
|
375
|
-
end
|
|
376
|
-
|
|
377
|
-
def warn_survivor_drift(selector)
|
|
378
|
-
warn "henitai: WARNING: #{selector.unmatched_ids.size} prior survivors " \
|
|
379
|
-
"could not be matched; the source may have drifted - consider a full run"
|
|
380
|
-
end
|
|
381
|
-
|
|
382
|
-
def build_survivor_stats(selector, selected, split)
|
|
383
|
-
{
|
|
384
|
-
matched: selected.size,
|
|
385
|
-
unmatched_count: selector.unmatched_ids.size,
|
|
386
|
-
unmatched_ids: selector.unmatched_ids,
|
|
387
|
-
skipped_count: split[:stable].size,
|
|
388
|
-
drift_warning: selector.drift_warning?
|
|
389
|
-
}
|
|
390
|
-
end
|
|
391
311
|
end
|
|
392
|
-
# rubocop:enable Metrics/ClassLength
|
|
393
312
|
end
|
|
@@ -23,6 +23,18 @@ module Henitai
|
|
|
23
23
|
@exit_status = exit_status
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
# Frees the captured child output once it has been consumed (e.g. by the
|
|
27
|
+
# progress reporter). The scheduler retains one result per mutant for the
|
|
28
|
+
# whole run; without this a runaway mutant's multi-hundred-MB output would
|
|
29
|
+
# stay resident until the run ends. Status/log_path/exit_status are kept so
|
|
30
|
+
# the result stays usable for scoring and log lookups.
|
|
31
|
+
def release_output!
|
|
32
|
+
@stdout = ""
|
|
33
|
+
@stderr = ""
|
|
34
|
+
@log_text = nil
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
26
38
|
def survived?
|
|
27
39
|
status == :survived
|
|
28
40
|
end
|