henitai 0.1.10 → 0.2.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 +68 -0
- data/README.md +18 -4
- data/lib/henitai/cli.rb +81 -3
- data/lib/henitai/configuration.rb +24 -11
- data/lib/henitai/coverage_bootstrapper.rb +24 -24
- data/lib/henitai/execution_engine.rb +3 -9
- data/lib/henitai/git_diff_analyzer.rb +34 -0
- data/lib/henitai/integration/rspec_process_runner.rb +66 -13
- data/lib/henitai/integration.rb +386 -38
- data/lib/henitai/mutant/activator.rb +14 -2
- data/lib/henitai/mutant.rb +13 -2
- data/lib/henitai/mutant_generator.rb +21 -2
- data/lib/henitai/mutant_history_store.rb +7 -22
- data/lib/henitai/mutant_identity.rb +34 -0
- data/lib/henitai/parallel_execution_runner.rb +29 -11
- data/lib/henitai/process_wakeup.rb +49 -0
- data/lib/henitai/process_worker_runner.rb +434 -0
- data/lib/henitai/reporter.rb +76 -3
- data/lib/henitai/result.rb +39 -8
- data/lib/henitai/runner.rb +203 -14
- data/lib/henitai/scenario_execution_result.rb +16 -3
- data/lib/henitai/static_filter.rb +10 -3
- data/lib/henitai/survivor_activation_cache.rb +81 -0
- data/lib/henitai/survivor_loader.rb +140 -0
- data/lib/henitai/survivor_selector.rb +36 -0
- data/lib/henitai/survivor_test_filter.rb +72 -0
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +8 -0
- data/sig/henitai.rbs +200 -9
- metadata +22 -1
data/lib/henitai/reporter.rb
CHANGED
|
@@ -90,6 +90,14 @@ module Henitai
|
|
|
90
90
|
end
|
|
91
91
|
|
|
92
92
|
def summary_lines(result)
|
|
93
|
+
if result.respond_to?(:partial_rerun?) && result.partial_rerun?
|
|
94
|
+
partial_summary_lines(result)
|
|
95
|
+
else
|
|
96
|
+
full_summary_lines(result)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def full_summary_lines(result)
|
|
93
101
|
[
|
|
94
102
|
"Mutation testing summary",
|
|
95
103
|
score_line(result),
|
|
@@ -101,6 +109,28 @@ module Henitai
|
|
|
101
109
|
]
|
|
102
110
|
end
|
|
103
111
|
|
|
112
|
+
def partial_summary_lines(result)
|
|
113
|
+
lines = [
|
|
114
|
+
"Partial survivor rerun",
|
|
115
|
+
format_row("Survived", count_status(result, :survived)),
|
|
116
|
+
format_row("Duration", format_duration(result.duration))
|
|
117
|
+
]
|
|
118
|
+
append_survivor_stats(lines, result)
|
|
119
|
+
lines
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def append_survivor_stats(lines, result)
|
|
123
|
+
return unless result.respond_to?(:survivor_stats)
|
|
124
|
+
|
|
125
|
+
stats = result.survivor_stats # : Hash[Symbol, untyped]?
|
|
126
|
+
return unless stats
|
|
127
|
+
|
|
128
|
+
lines << format_row("Matched", stats.fetch(:matched))
|
|
129
|
+
lines << format_row("Skipped", stats.fetch(:skipped_count, 0))
|
|
130
|
+
lines << format_row("Unmatched", stats.fetch(:unmatched_count))
|
|
131
|
+
lines << format_row("Drift warning", stats.fetch(:drift_warning) ? "yes" : "no")
|
|
132
|
+
end
|
|
133
|
+
|
|
104
134
|
def survived_detail_lines(result)
|
|
105
135
|
survivors = result.mutants.select(&:survived?)
|
|
106
136
|
return [] if survivors.empty?
|
|
@@ -212,14 +242,57 @@ module Henitai
|
|
|
212
242
|
# JSON reporter.
|
|
213
243
|
class Json < Base
|
|
214
244
|
def report(result)
|
|
215
|
-
|
|
216
|
-
|
|
245
|
+
schema = result.to_stryker_schema
|
|
246
|
+
write_canonical(schema)
|
|
247
|
+
write_session_snapshot(schema)
|
|
248
|
+
write_activation_recipes(result)
|
|
217
249
|
write_history_report
|
|
218
250
|
end
|
|
219
251
|
|
|
220
252
|
private
|
|
221
253
|
|
|
222
|
-
def
|
|
254
|
+
def write_canonical(schema)
|
|
255
|
+
FileUtils.mkdir_p(File.dirname(canonical_path))
|
|
256
|
+
File.write(canonical_path, JSON.pretty_generate(schema))
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def write_session_snapshot(schema)
|
|
260
|
+
session_id = schema[:sessionId]
|
|
261
|
+
return unless session_id
|
|
262
|
+
|
|
263
|
+
path = session_snapshot_path(session_id)
|
|
264
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
265
|
+
File.write(path, JSON.pretty_generate(schema))
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def write_activation_recipes(result)
|
|
269
|
+
session_id = result.session_id if result.respond_to?(:session_id)
|
|
270
|
+
return unless session_id
|
|
271
|
+
|
|
272
|
+
survived = survived_mutants_for(result)
|
|
273
|
+
return if survived.empty?
|
|
274
|
+
|
|
275
|
+
recipes = SurvivorActivationCache.compute(survived)
|
|
276
|
+
return if recipes.empty?
|
|
277
|
+
|
|
278
|
+
SurvivorActivationCache.write(session_recipe_path(session_id), recipes)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def survived_mutants_for(result)
|
|
282
|
+
return [] unless result.respond_to?(:mutants)
|
|
283
|
+
|
|
284
|
+
result.mutants.select(&:survived?)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def session_snapshot_path(session_id)
|
|
288
|
+
File.join(config.reports_dir, "sessions", session_id, "mutation-report.json")
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def session_recipe_path(session_id)
|
|
292
|
+
File.join(config.reports_dir, "sessions", session_id, SurvivorActivationCache::FILENAME)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def canonical_path
|
|
223
296
|
File.join(config.reports_dir, "mutation-report.json")
|
|
224
297
|
end
|
|
225
298
|
|
data/lib/henitai/result.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "securerandom"
|
|
3
4
|
require_relative "unparse_helper"
|
|
4
5
|
|
|
5
6
|
module Henitai
|
|
@@ -13,14 +14,25 @@ module Henitai
|
|
|
13
14
|
SCHEMA_VERSION = "1.0"
|
|
14
15
|
DEFAULT_THRESHOLDS = { high: 80, low: 60 }.freeze
|
|
15
16
|
|
|
16
|
-
attr_reader :mutants, :started_at, :finished_at, :thresholds
|
|
17
|
+
attr_reader :mutants, :started_at, :finished_at, :thresholds, :survivor_stats,
|
|
18
|
+
:session_id, :git_sha
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
@
|
|
20
|
+
# rubocop:disable Metrics/ParameterLists
|
|
21
|
+
def initialize(mutants:, started_at:, finished_at:, thresholds: nil,
|
|
22
|
+
partial_rerun: false, survivor_stats: nil,
|
|
23
|
+
session_id: SecureRandom.uuid, git_sha: nil)
|
|
24
|
+
@mutants = mutants
|
|
25
|
+
@started_at = started_at
|
|
26
|
+
@finished_at = finished_at
|
|
27
|
+
@thresholds = DEFAULT_THRESHOLDS.merge(thresholds || {})
|
|
28
|
+
@partial_rerun = partial_rerun
|
|
29
|
+
@survivor_stats = survivor_stats
|
|
30
|
+
@session_id = session_id
|
|
31
|
+
@git_sha = git_sha
|
|
23
32
|
end
|
|
33
|
+
# rubocop:enable Metrics/ParameterLists
|
|
34
|
+
|
|
35
|
+
def partial_rerun? = @partial_rerun
|
|
24
36
|
|
|
25
37
|
# @return [Integer] number of killed mutants
|
|
26
38
|
def killed = mutants.count(&:killed?)
|
|
@@ -88,14 +100,32 @@ module Henitai
|
|
|
88
100
|
# Serialise to Stryker mutation-testing-report-schema JSON (schema 1.0).
|
|
89
101
|
# @return [Hash]
|
|
90
102
|
def to_stryker_schema
|
|
91
|
-
|
|
103
|
+
schema = base_schema
|
|
104
|
+
sha = @git_sha
|
|
105
|
+
schema[:gitSha] = sha if sha
|
|
106
|
+
return schema unless partial_rerun?
|
|
107
|
+
|
|
108
|
+
schema[:partialRerun] = true
|
|
109
|
+
schema[:unmatchedSurvivorIds] = unmatched_survivor_ids
|
|
110
|
+
schema
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def base_schema
|
|
116
|
+
{ # : Hash[Symbol, untyped]
|
|
92
117
|
schemaVersion: SCHEMA_VERSION,
|
|
118
|
+
sessionId: @session_id,
|
|
93
119
|
thresholds: thresholds,
|
|
94
120
|
files: build_files_section
|
|
95
121
|
}
|
|
96
122
|
end
|
|
97
123
|
|
|
98
|
-
|
|
124
|
+
def unmatched_survivor_ids
|
|
125
|
+
return survivor_stats.fetch(:unmatched_ids) if survivor_stats
|
|
126
|
+
|
|
127
|
+
[] # : Array[String]
|
|
128
|
+
end
|
|
99
129
|
|
|
100
130
|
def build_files_section
|
|
101
131
|
mutants.group_by { |m| m.location[:file] }.transform_values do |file_mutants|
|
|
@@ -115,6 +145,7 @@ module Henitai
|
|
|
115
145
|
def mutant_to_schema(mutant)
|
|
116
146
|
{
|
|
117
147
|
id: mutant.id,
|
|
148
|
+
stableId: mutant.stable_id,
|
|
118
149
|
mutatorName: mutant.operator,
|
|
119
150
|
replacement: replacement_for(mutant),
|
|
120
151
|
location: location_for(mutant),
|
data/lib/henitai/runner.rb
CHANGED
|
@@ -25,28 +25,39 @@ 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
|
|
28
29
|
class Runner
|
|
29
30
|
attr_reader :config, :result
|
|
30
31
|
|
|
31
|
-
def initialize(config: Configuration.load, subjects: nil, since: nil)
|
|
32
|
-
@config
|
|
33
|
-
@subjects
|
|
34
|
-
@since
|
|
32
|
+
def initialize(config: Configuration.load, subjects: nil, since: nil, survivors_from: nil)
|
|
33
|
+
@config = config
|
|
34
|
+
@subjects = subjects
|
|
35
|
+
@since = since
|
|
36
|
+
@survivors_from = survivors_from
|
|
35
37
|
end
|
|
36
38
|
|
|
37
39
|
# Entry point — runs the full pipeline and returns a Result.
|
|
38
40
|
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
41
|
+
# Fast path (recipe rerun): when +--survivors-from+ is given and an
|
|
42
|
+
# +activation-recipes.json+ file exists beside the report with entries for
|
|
43
|
+
# all survivor IDs, stub Mutants are built from the recipes and the full
|
|
44
|
+
# source-parse / mutant-generation pipeline is skipped entirely.
|
|
45
|
+
#
|
|
46
|
+
# Normal path: Coverage bootstrap (Gate 0) runs in a background thread so
|
|
47
|
+
# that Gate 1 (subject resolution) and Gate 2 (mutant generation) proceed
|
|
48
|
+
# concurrently. The thread is joined before Gate 3 (static filtering).
|
|
43
49
|
#
|
|
44
50
|
# @return [Result]
|
|
45
51
|
def run
|
|
46
52
|
started_at = Time.now
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
53
|
+
|
|
54
|
+
mutants = if survivor_rerun? && (fast_mutants = try_recipe_run)
|
|
55
|
+
execute_mutants(fast_mutants)
|
|
56
|
+
else
|
|
57
|
+
source_files = self.source_files
|
|
58
|
+
subjects = resolve_subjects(source_files)
|
|
59
|
+
execute_mutants(mutants_for(subjects, source_files))
|
|
60
|
+
end
|
|
50
61
|
|
|
51
62
|
build_result(mutants, started_at, Time.now)
|
|
52
63
|
end
|
|
@@ -75,8 +86,8 @@ module Henitai
|
|
|
75
86
|
bootstrap_thread = bootstrap_mutants(source_files)
|
|
76
87
|
mutants = generate_mutants(subjects)
|
|
77
88
|
bootstrap_thread.value
|
|
78
|
-
|
|
79
|
-
|
|
89
|
+
filtered = filter_mutants(mutants)
|
|
90
|
+
apply_survivor_selection(filtered)
|
|
80
91
|
end
|
|
81
92
|
|
|
82
93
|
def bootstrap_mutants(source_files)
|
|
@@ -109,13 +120,24 @@ module Henitai
|
|
|
109
120
|
mutants:,
|
|
110
121
|
started_at:,
|
|
111
122
|
finished_at:,
|
|
112
|
-
thresholds: result_thresholds
|
|
123
|
+
thresholds: result_thresholds,
|
|
124
|
+
partial_rerun: survivor_rerun?,
|
|
125
|
+
survivor_stats: @survivor_stats,
|
|
126
|
+
git_sha: safe_head_sha
|
|
113
127
|
)
|
|
114
128
|
persist_history(@result, finished_at)
|
|
115
129
|
report(@result)
|
|
116
130
|
@result
|
|
117
131
|
end
|
|
118
132
|
|
|
133
|
+
def safe_head_sha
|
|
134
|
+
git_diff_analyzer.head_sha
|
|
135
|
+
rescue StandardError
|
|
136
|
+
# `head_sha` rescues Errno::ENOENT. This extra rescue is defensive for
|
|
137
|
+
# unexpected Open3/git runtime errors; conservative fallback is `nil`.
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
119
141
|
def bootstrap_coverage(source_files, test_files = nil)
|
|
120
142
|
coverage_bootstrapper.ensure!(source_files:, config:, integration:, test_files:)
|
|
121
143
|
end
|
|
@@ -200,5 +222,172 @@ module Henitai
|
|
|
200
222
|
|
|
201
223
|
config.thresholds
|
|
202
224
|
end
|
|
225
|
+
|
|
226
|
+
def survivor_rerun?
|
|
227
|
+
!@survivors_from.nil?
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def apply_survivor_selection(mutants)
|
|
231
|
+
return mutants unless survivor_rerun?
|
|
232
|
+
|
|
233
|
+
dirty_worktree_files = dirty_worktree_changed_files
|
|
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
|
+
end
|
|
246
|
+
|
|
247
|
+
# Attempts to run survivors directly from pre-computed activation recipes,
|
|
248
|
+
# bypassing source parsing and mutant generation entirely.
|
|
249
|
+
# Returns the mutant array on success, or nil if recipes are unavailable.
|
|
250
|
+
def try_recipe_run
|
|
251
|
+
dirty_worktree_files = dirty_worktree_changed_files
|
|
252
|
+
loaded = load_survivor_report
|
|
253
|
+
return nil unless recipe_fast_path_safe?(loaded, dirty_worktree_files)
|
|
254
|
+
|
|
255
|
+
run_from_recipes(loaded, dirty_worktree_files)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def load_survivor_report
|
|
259
|
+
SurvivorLoader.new(@survivors_from, include_paths: Array(config.includes)).load
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def run_from_recipes(loaded, dirty_worktree_files)
|
|
263
|
+
recipes = load_activation_recipes(loaded.survivor_ids)
|
|
264
|
+
return nil if recipes.nil?
|
|
265
|
+
|
|
266
|
+
selector, stubs = recipe_selector_and_stubs(loaded.survivor_ids, recipes)
|
|
267
|
+
split = test_filter(
|
|
268
|
+
loaded,
|
|
269
|
+
dirty_source_files: dirty_source_files?(dirty_worktree_files, git_sha: loaded.git_sha)
|
|
270
|
+
).apply(stubs)
|
|
271
|
+
finalize_survivor_split(selector, stubs, split)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def recipe_fast_path_safe?(loaded, dirty_worktree_files)
|
|
275
|
+
!dirty_source_files?(dirty_worktree_files, git_sha: loaded.git_sha)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Builds stub Mutants from recipes and a SurvivorSelector primed with the
|
|
279
|
+
# survivor ID set. The selector is given a synthetic #select call so that
|
|
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]
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Returns the recipe hash if the file exists and covers every survivor ID;
|
|
289
|
+
# otherwise returns nil to trigger the normal generation path.
|
|
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
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def build_stub_mutant(stable_id, recipe)
|
|
300
|
+
mutant = Mutant.new(
|
|
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
|
|
312
|
+
|
|
313
|
+
def stub_subject_from_recipe(recipe)
|
|
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
|
+
)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def recipe_location(loc)
|
|
323
|
+
return {} unless loc.is_a?(Hash)
|
|
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
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def finalize_survivor_split(selector, selected, split)
|
|
335
|
+
split[:stable].each { |m| m.status = :survived }
|
|
336
|
+
warn_survivor_drift(selector) if selector.drift_warning?
|
|
337
|
+
@survivor_stats = build_survivor_stats(selector, selected, split)
|
|
338
|
+
split[:stable] + split[:pending]
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def test_filter(loaded, dirty_source_files: false)
|
|
342
|
+
SurvivorTestFilter.new(
|
|
343
|
+
coverage_map: loaded.coverage_map,
|
|
344
|
+
git_sha: loaded.git_sha,
|
|
345
|
+
dirty_source_files:,
|
|
346
|
+
worktree_changed_files: Array(dirty_worktree_changed_files),
|
|
347
|
+
diff_analyzer: git_diff_analyzer
|
|
348
|
+
)
|
|
349
|
+
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
|
|
203
391
|
end
|
|
392
|
+
# rubocop:enable Metrics/ClassLength
|
|
204
393
|
end
|
|
@@ -7,7 +7,7 @@ module Henitai
|
|
|
7
7
|
|
|
8
8
|
def self.build(wait_result:, stdout:, stderr:, log_path:)
|
|
9
9
|
new(
|
|
10
|
-
status: status_for(wait_result),
|
|
10
|
+
status: status_for(wait_result, stdout:, stderr:),
|
|
11
11
|
stdout: stdout,
|
|
12
12
|
stderr: stderr,
|
|
13
13
|
log_path: log_path,
|
|
@@ -62,7 +62,7 @@ module Henitai
|
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
def should_show_logs?(all_logs: nil)
|
|
65
|
-
all_logs || timeout?
|
|
65
|
+
all_logs || timeout? || status == :compile_error
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
def failure_tail(all_logs: nil, lines: 12)
|
|
@@ -77,8 +77,10 @@ module Henitai
|
|
|
77
77
|
class << self
|
|
78
78
|
private
|
|
79
79
|
|
|
80
|
-
def status_for(wait_result)
|
|
80
|
+
def status_for(wait_result, stdout:, stderr:)
|
|
81
81
|
return :timeout if wait_result == :timeout
|
|
82
|
+
return :compile_error if no_examples_found?(stdout, stderr)
|
|
83
|
+
return :compile_error if zero_examples_failure?(wait_result, stdout, stderr)
|
|
82
84
|
return :compile_error if exit_status_for(wait_result) == 2
|
|
83
85
|
return :survived if wait_result.respond_to?(:success?) && wait_result.success?
|
|
84
86
|
|
|
@@ -91,6 +93,17 @@ module Henitai
|
|
|
91
93
|
|
|
92
94
|
wait_result.exitstatus
|
|
93
95
|
end
|
|
96
|
+
|
|
97
|
+
def no_examples_found?(stdout, stderr)
|
|
98
|
+
stdout.to_s.include?("No examples found.") || stderr.to_s.include?("No examples found.")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def zero_examples_failure?(wait_result, stdout, stderr)
|
|
102
|
+
return false unless exit_status_for(wait_result) == 1
|
|
103
|
+
|
|
104
|
+
output = [stdout.to_s, stderr.to_s].join("\n")
|
|
105
|
+
output.include?("0 examples, 0 failures")
|
|
106
|
+
end
|
|
94
107
|
end
|
|
95
108
|
|
|
96
109
|
def stream_section(name, content)
|
|
@@ -37,11 +37,18 @@ module Henitai
|
|
|
37
37
|
|
|
38
38
|
coverage_lines = coverage_lines_by_file(coverage_report_path)
|
|
39
39
|
coverage_lines = merge_method_coverage(coverage_lines, coverage_report_path)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
coverage_lines_from_test_lines(
|
|
40
|
+
per_test_lines = coverage_lines_from_test_lines(
|
|
43
41
|
test_lines_by_file(per_test_coverage_report_path)
|
|
44
42
|
)
|
|
43
|
+
|
|
44
|
+
return per_test_lines if coverage_lines.empty?
|
|
45
|
+
|
|
46
|
+
# Merge per-test coverage into the standard coverage map.
|
|
47
|
+
# Standard coverage may be incomplete when child processes fork before
|
|
48
|
+
# all files are loaded; per-test coverage widens the result set.
|
|
49
|
+
per_test_lines.each_with_object(coverage_lines) do |(file, lines), merged|
|
|
50
|
+
merged[file] = ((merged[file] || []) + lines).uniq.sort
|
|
51
|
+
end
|
|
45
52
|
end
|
|
46
53
|
|
|
47
54
|
def coverage_lines_by_file(path = DEFAULT_COVERAGE_REPORT_PATH)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Henitai
|
|
7
|
+
# Stores and retrieves pre-computed +define_method+ activation sources for
|
|
8
|
+
# survived mutants, enabling survivor reruns to skip the full mutant-generation
|
|
9
|
+
# pipeline.
|
|
10
|
+
#
|
|
11
|
+
# The cache artifact (+activation-recipes.json+) is written alongside the
|
|
12
|
+
# session snapshot in +reports/sessions/<session_id>/+. When a survivor rerun
|
|
13
|
+
# finds this file next to the report it was given, it can build stub Mutant
|
|
14
|
+
# objects directly and execute them without re-parsing source files.
|
|
15
|
+
#
|
|
16
|
+
# A recipe entry encodes everything needed to activate and re-report a mutant:
|
|
17
|
+
# the +define_method+ source, subject coordinates, operator, description,
|
|
18
|
+
# location, and the coveredBy test list.
|
|
19
|
+
class SurvivorActivationCache
|
|
20
|
+
FILENAME = "activation-recipes.json"
|
|
21
|
+
|
|
22
|
+
# Build a recipe hash for each survived mutant that has a computable
|
|
23
|
+
# activation source.
|
|
24
|
+
#
|
|
25
|
+
# @param survived_mutants [Array<Mutant>]
|
|
26
|
+
# @return [Hash<String, Hash>] stableId → recipe
|
|
27
|
+
def self.compute(survived_mutants)
|
|
28
|
+
survived_mutants.each_with_object({}) do |mutant, cache|
|
|
29
|
+
source = Mutant::Activator.activation_source_for(mutant)
|
|
30
|
+
next unless source
|
|
31
|
+
|
|
32
|
+
cache[mutant.stable_id] = build_recipe(mutant, source)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param path [String] path to +activation-recipes.json+
|
|
37
|
+
# @return [Hash, nil] nil when the file is absent or unparseable
|
|
38
|
+
def self.load(path)
|
|
39
|
+
return nil unless File.exist?(path)
|
|
40
|
+
|
|
41
|
+
JSON.parse(File.read(path))
|
|
42
|
+
rescue JSON::ParserError
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @param path [String]
|
|
47
|
+
# @param recipes [Hash<String, Hash>]
|
|
48
|
+
def self.write(path, recipes)
|
|
49
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
50
|
+
File.write(path, JSON.pretty_generate(recipes))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class << self
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def build_recipe(mutant, activation_source)
|
|
57
|
+
{
|
|
58
|
+
"activationSource" => activation_source,
|
|
59
|
+
"namespace" => mutant.subject.namespace,
|
|
60
|
+
"methodName" => mutant.subject.method_name,
|
|
61
|
+
"methodType" => mutant.subject.method_type.to_s,
|
|
62
|
+
"sourceFile" => mutant.subject.source_file,
|
|
63
|
+
"operator" => mutant.operator,
|
|
64
|
+
"description" => mutant.description,
|
|
65
|
+
"location" => serialize_location(mutant.location),
|
|
66
|
+
"coveredBy" => Array(mutant.covered_by).compact
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def serialize_location(location)
|
|
71
|
+
{
|
|
72
|
+
"file" => location[:file],
|
|
73
|
+
"startLine" => location[:start_line],
|
|
74
|
+
"endLine" => location[:end_line],
|
|
75
|
+
"startCol" => location[:start_col],
|
|
76
|
+
"endCol" => location[:end_col]
|
|
77
|
+
}.compact
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|