henitai 0.1.8 → 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 +86 -1
- 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 +403 -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 +205 -9
- metadata +23 -2
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
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
# Reads a Stryker-compatible mutation report and extracts survivor data.
|
|
7
|
+
#
|
|
8
|
+
# Returns a +Report+ value object carrying:
|
|
9
|
+
# - +survivor_ids+ — stable IDs of survived mutants
|
|
10
|
+
# - +coverage_map+ — stableId → [test_files] from prior coveredBy data
|
|
11
|
+
# - +git_sha+ — git HEAD at the time the report was written (may be nil)
|
|
12
|
+
#
|
|
13
|
+
# Scope validation is intentionally shallow: checks schemaVersion presence
|
|
14
|
+
# and at least one file path overlap with config.includes.
|
|
15
|
+
class SurvivorLoader
|
|
16
|
+
# Value object returned by #load.
|
|
17
|
+
Report = Struct.new(:survivor_ids, :coverage_map, :git_sha)
|
|
18
|
+
|
|
19
|
+
class FileNotFoundError < StandardError; end
|
|
20
|
+
class InvalidReportError < StandardError; end
|
|
21
|
+
class ScopeMismatchError < StandardError; end
|
|
22
|
+
|
|
23
|
+
# @param path [String] path to a Stryker-compatible JSON report
|
|
24
|
+
# @param include_paths [Array<String>] from config.includes; used for scope validation
|
|
25
|
+
def initialize(path, include_paths: [])
|
|
26
|
+
@path = path
|
|
27
|
+
@include_paths = include_paths
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [Report]
|
|
31
|
+
def load
|
|
32
|
+
raw = read_file
|
|
33
|
+
report = parse_json(raw)
|
|
34
|
+
validate_scope(report)
|
|
35
|
+
build_report(report)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def build_report(report)
|
|
41
|
+
entries = known_entries(report)
|
|
42
|
+
Report.new(
|
|
43
|
+
survivor_ids: extract_survivor_ids(entries),
|
|
44
|
+
coverage_map: extract_coverage_map(entries),
|
|
45
|
+
git_sha: report["gitSha"]
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns mutant entries that have a stableId, warning about those that don't.
|
|
50
|
+
def known_entries(report)
|
|
51
|
+
all_mutants(report).select do |entry|
|
|
52
|
+
if entry["stableId"]
|
|
53
|
+
true
|
|
54
|
+
else
|
|
55
|
+
warn "henitai: survivor report entry missing stableId — skipping"
|
|
56
|
+
false
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def extract_survivor_ids(entries)
|
|
62
|
+
entries.filter_map { |e| e["stableId"] if e["status"] == "Survived" }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def extract_coverage_map(entries)
|
|
66
|
+
entries.each_with_object({}) do |entry, map|
|
|
67
|
+
covered = Array(entry["coveredBy"]).compact
|
|
68
|
+
map[entry["stableId"]] = covered unless covered.empty?
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def read_file
|
|
73
|
+
File.read(@path)
|
|
74
|
+
rescue Errno::ENOENT
|
|
75
|
+
raise FileNotFoundError, "Survivor report not found: #{@path}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def parse_json(raw)
|
|
79
|
+
JSON.parse(raw)
|
|
80
|
+
rescue JSON::ParserError => e
|
|
81
|
+
raise InvalidReportError, "Invalid JSON in survivor report #{@path}: #{e.message}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def validate_scope(report)
|
|
85
|
+
validate_schema_version!(report)
|
|
86
|
+
return if @include_paths.empty?
|
|
87
|
+
|
|
88
|
+
report_files = normalized_report_files(report)
|
|
89
|
+
include_dirs_raw = normalized_include_dirs_raw
|
|
90
|
+
include_dirs_abs = normalized_include_dirs_abs(include_dirs_raw)
|
|
91
|
+
|
|
92
|
+
return if any_report_file_overlaps?(report_files, include_dirs_raw, include_dirs_abs)
|
|
93
|
+
|
|
94
|
+
raise ScopeMismatchError,
|
|
95
|
+
"Survivor report #{@path} has no file overlap with configured includes — " \
|
|
96
|
+
"did you pass a report from a different project?"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def validate_schema_version!(report)
|
|
100
|
+
return if report.key?("schemaVersion")
|
|
101
|
+
|
|
102
|
+
raise InvalidReportError,
|
|
103
|
+
"Survivor report #{@path} is missing schemaVersion — is this a Henitai report?"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def normalized_report_files(report)
|
|
107
|
+
(report.fetch("files", {}) || {}).keys.map { |p| strip_trailing_slash(p.to_s) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def normalized_include_dirs_raw
|
|
111
|
+
@include_paths.map { |p| strip_trailing_slash(p.to_s) }.uniq
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def normalized_include_dirs_abs(dirs_raw)
|
|
115
|
+
dirs_raw.map { |p| File.expand_path(p) }.uniq
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def strip_trailing_slash(path)
|
|
119
|
+
path.sub(%r{/\z}, "")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def any_report_file_overlaps?(report_files, include_dirs_raw, include_dirs_abs)
|
|
123
|
+
report_files.any? do |file|
|
|
124
|
+
include_dirs_raw.any? { |inc| path_prefix_match?(file, inc) } ||
|
|
125
|
+
include_dirs_abs.any? { |inc_abs| path_prefix_match?(File.expand_path(file), inc_abs) }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def path_prefix_match?(path, dir)
|
|
130
|
+
return false if path.empty? || dir.empty?
|
|
131
|
+
|
|
132
|
+
path == dir || path.start_with?(dir + File::SEPARATOR)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def all_mutants(report)
|
|
136
|
+
files = report.fetch("files", {}) || {}
|
|
137
|
+
files.values.compact.flat_map { |file_data| file_data.fetch("mutants", []) }
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Filters a mutant list to those that match a set of prior survivor stable IDs.
|
|
5
|
+
#
|
|
6
|
+
# After calling #select, #unmatched_ids reports which survivor IDs had no
|
|
7
|
+
# corresponding mutant in the current generation. A high unmatched ratio
|
|
8
|
+
# indicates that the source has drifted and a full run is recommended.
|
|
9
|
+
class SurvivorSelector
|
|
10
|
+
DRIFT_THRESHOLD = 0.5
|
|
11
|
+
class SelectionError < StandardError; end
|
|
12
|
+
|
|
13
|
+
def initialize(survivor_ids:)
|
|
14
|
+
@survivor_ids = survivor_ids.to_set
|
|
15
|
+
@unmatched_ids = nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def select(mutants)
|
|
19
|
+
current_index = mutants.to_h { |m| [m.stable_id, m] }
|
|
20
|
+
matched_ids, @unmatched_ids = @survivor_ids.partition { |id| current_index.key?(id) }
|
|
21
|
+
matched_ids.filter_map { |id| current_index[id] }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def unmatched_ids
|
|
25
|
+
raise SelectionError, "Call #select before accessing #unmatched_ids" if @unmatched_ids.nil?
|
|
26
|
+
|
|
27
|
+
@unmatched_ids
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def drift_warning?
|
|
31
|
+
return false if @survivor_ids.empty?
|
|
32
|
+
|
|
33
|
+
unmatched_ids.size.to_f / @survivor_ids.size > DRIFT_THRESHOLD
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Splits a matched survivor set into stable and pending subsets by consulting
|
|
5
|
+
# a git diff against the covering tests from the prior report.
|
|
6
|
+
#
|
|
7
|
+
# A survivor is **stable** (can skip re-execution) when:
|
|
8
|
+
# - it has covering test data in the prior report, AND
|
|
9
|
+
# - none of those test files appear in the diff between the prior run's
|
|
10
|
+
# git_sha and the current HEAD.
|
|
11
|
+
#
|
|
12
|
+
# A survivor is **pending** (must execute) when:
|
|
13
|
+
# - git_sha is nil (no anchor → conservative), OR
|
|
14
|
+
# - its coveredBy data is absent or empty, OR
|
|
15
|
+
# - at least one covering test file changed.
|
|
16
|
+
#
|
|
17
|
+
# On any git error the filter conservatively treats all survivors as pending.
|
|
18
|
+
class SurvivorTestFilter
|
|
19
|
+
# @param coverage_map [Hash<String, Array<String>>] stableId → [test_files]
|
|
20
|
+
# @param git_sha [String, nil] git SHA from the prior report
|
|
21
|
+
# @param dirty_source_files [Boolean] true when the current worktree has
|
|
22
|
+
# dirty source files and the survivor shortcut must be disabled
|
|
23
|
+
# @param worktree_changed_files [Array<String>] dirty tracked/untracked files
|
|
24
|
+
# @param diff_analyzer [GitDiffAnalyzer]
|
|
25
|
+
def initialize(
|
|
26
|
+
coverage_map:,
|
|
27
|
+
git_sha:,
|
|
28
|
+
dirty_source_files: false,
|
|
29
|
+
worktree_changed_files: [],
|
|
30
|
+
diff_analyzer: GitDiffAnalyzer.new
|
|
31
|
+
)
|
|
32
|
+
@coverage_map = coverage_map
|
|
33
|
+
@git_sha = git_sha
|
|
34
|
+
@dirty_source_files = dirty_source_files
|
|
35
|
+
@worktree_changed_files = Array(worktree_changed_files)
|
|
36
|
+
@diff_analyzer = diff_analyzer
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param mutants [Array<Mutant>]
|
|
40
|
+
# @return [Hash<Symbol, Array<Mutant>>] { stable: [...], pending: [...] }
|
|
41
|
+
def apply(mutants)
|
|
42
|
+
return { stable: [], pending: mutants } if @dirty_source_files
|
|
43
|
+
return { stable: [], pending: mutants } if @git_sha.nil?
|
|
44
|
+
|
|
45
|
+
changed = changed_test_files
|
|
46
|
+
return { stable: [], pending: mutants } if changed.nil?
|
|
47
|
+
|
|
48
|
+
mutants.each_with_object({ stable: [], pending: [] }) do |mutant, result|
|
|
49
|
+
bucket = stable_survivor?(mutant, changed) ? :stable : :pending
|
|
50
|
+
result[bucket] << mutant
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def stable_survivor?(mutant, changed)
|
|
57
|
+
covering = @coverage_map[mutant.stable_id]
|
|
58
|
+
return false if covering.nil? || covering.empty?
|
|
59
|
+
|
|
60
|
+
covering.none? { |test_file| changed.include?(test_file) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Returns a Set of changed test file paths, or nil on any git error
|
|
64
|
+
# (nil triggers conservative fallback in #apply — all survivors pending).
|
|
65
|
+
def changed_test_files
|
|
66
|
+
committed = @diff_analyzer.changed_files(from: @git_sha, to: "HEAD")
|
|
67
|
+
(committed + @worktree_changed_files).to_set
|
|
68
|
+
rescue StandardError
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/henitai/version.rb
CHANGED