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.
@@ -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
- FileUtils.mkdir_p(File.dirname(report_path))
216
- File.write(report_path, JSON.pretty_generate(result.to_stryker_schema))
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 report_path
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
 
@@ -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
- def initialize(mutants:, started_at:, finished_at:, thresholds: nil)
19
- @mutants = mutants
20
- @started_at = started_at
21
- @finished_at = finished_at
22
- @thresholds = DEFAULT_THRESHOLDS.merge(thresholds || {})
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
- private
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),
@@ -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 = config
33
- @subjects = subjects
34
- @since = 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
- # Coverage bootstrap (Gate 0) runs in a background thread so that Gate 1
40
- # (subject resolution) and Gate 2 (mutant generation) proceed concurrently.
41
- # The thread is joined before Gate 3 (static filtering), which is the first
42
- # phase that requires coverage data.
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
- source_files = self.source_files
48
- subjects = resolve_subjects(source_files)
49
- mutants = execute_mutants(mutants_for(subjects, source_files))
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
- filter_mutants(mutants)
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
- return coverage_lines unless coverage_lines.empty?
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