henitai 0.2.1 → 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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/README.md +123 -2
  4. data/assets/schema/henitai.schema.json +35 -0
  5. data/lib/henitai/available_cpu_count.rb +16 -23
  6. data/lib/henitai/canonical_report_merger.rb +107 -0
  7. data/lib/henitai/canonical_report_writer.rb +22 -0
  8. data/lib/henitai/checkpoint_reporter.rb +79 -0
  9. data/lib/henitai/cli/clean_command.rb +14 -8
  10. data/lib/henitai/cli/command_support.rb +2 -1
  11. data/lib/henitai/cli/operator_command.rb +2 -1
  12. data/lib/henitai/cli/options.rb +21 -57
  13. data/lib/henitai/cli/run_command.rb +36 -3
  14. data/lib/henitai/cli/run_options.rb +108 -0
  15. data/lib/henitai/cli.rb +12 -4
  16. data/lib/henitai/composite_progress_reporter.rb +42 -0
  17. data/lib/henitai/configuration.rb +41 -9
  18. data/lib/henitai/configuration_validator/rules.rb +18 -0
  19. data/lib/henitai/configuration_validator/scalars.rb +46 -0
  20. data/lib/henitai/configuration_validator.rb +8 -1
  21. data/lib/henitai/coverage_bootstrapper.rb +54 -5
  22. data/lib/henitai/coverage_formatter.rb +5 -1
  23. data/lib/henitai/coverage_report_reader.rb +28 -5
  24. data/lib/henitai/execution_engine/env_scope.rb +67 -0
  25. data/lib/henitai/execution_engine.rb +111 -50
  26. data/lib/henitai/generated_artifacts.rb +58 -0
  27. data/lib/henitai/incremental_filter.rb +100 -0
  28. data/lib/henitai/integration/child_debug_support.rb +6 -2
  29. data/lib/henitai/integration/coverage_suppression.rb +9 -0
  30. data/lib/henitai/integration/minitest.rb +11 -43
  31. data/lib/henitai/integration/minitest_load_path.rb +14 -0
  32. data/lib/henitai/integration/minitest_suite_command.rb +17 -0
  33. data/lib/henitai/integration/minitest_test_runner.rb +39 -0
  34. data/lib/henitai/integration/rails_environment_preloader.rb +14 -0
  35. data/lib/henitai/integration/rspec_child_runner.rb +3 -3
  36. data/lib/henitai/integration/rspec_test_selection.rb +3 -0
  37. data/lib/henitai/integration/scenario_log_support.rb +64 -20
  38. data/lib/henitai/minitest_coverage_reporter.rb +4 -1
  39. data/lib/henitai/mutant/activator.rb +14 -0
  40. data/lib/henitai/mutant.rb +13 -6
  41. data/lib/henitai/mutant_history_store/sql.rb +21 -3
  42. data/lib/henitai/mutant_history_store/verdict_cache.rb +84 -0
  43. data/lib/henitai/mutant_history_store.rb +62 -12
  44. data/lib/henitai/mutant_identity.rb +33 -2
  45. data/lib/henitai/mutation_skip_directives.rb +227 -0
  46. data/lib/henitai/operator.rb +3 -1
  47. data/lib/henitai/operators/equality_identity_operator.rb +51 -0
  48. data/lib/henitai/operators/equality_operator.rb +8 -3
  49. data/lib/henitai/operators.rb +1 -0
  50. data/lib/henitai/per_test_coverage.rb +66 -0
  51. data/lib/henitai/per_test_coverage_collector.rb +20 -5
  52. data/lib/henitai/per_test_coverage_selector.rb +11 -33
  53. data/lib/henitai/reporter/dashboard_metadata_provider.rb +113 -0
  54. data/lib/henitai/reporter.rb +238 -117
  55. data/lib/henitai/reports_directory_lock.rb +76 -0
  56. data/lib/henitai/result.rb +72 -9
  57. data/lib/henitai/runner.rb +82 -40
  58. data/lib/henitai/scenario_execution_result.rb +12 -0
  59. data/lib/henitai/slot_scheduler/draining.rb +21 -15
  60. data/lib/henitai/slot_scheduler.rb +73 -15
  61. data/lib/henitai/static_filter.rb +16 -6
  62. data/lib/henitai/survivor_rerun_strategy.rb +1 -1
  63. data/lib/henitai/survivor_test_filter.rb +1 -1
  64. data/lib/henitai/test_prioritizer.rb +28 -2
  65. data/lib/henitai/timeout_calibrator.rb +44 -0
  66. data/lib/henitai/verdict_fingerprint.rb +155 -0
  67. data/lib/henitai/version.rb +1 -1
  68. data/lib/henitai.rb +14 -1
  69. data/sig/henitai.rbs +230 -25
  70. metadata +22 -3
  71. data/lib/henitai/parallel_execution_runner.rb +0 -153
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "coverage_report_reader"
4
+ require_relative "mutation_skip_directives"
4
5
 
5
6
  module Henitai
6
7
  # Applies static, pre-execution filtering to generated mutants.
@@ -8,8 +9,10 @@ module Henitai
8
9
  DEFAULT_COVERAGE_REPORT_PATH = CoverageReportReader::DEFAULT_COVERAGE_REPORT_PATH
9
10
  DEFAULT_PER_TEST_COVERAGE_REPORT_PATH = CoverageReportReader::DEFAULT_PER_TEST_COVERAGE_REPORT_PATH
10
11
 
11
- def initialize(coverage_report_reader: CoverageReportReader.new)
12
+ def initialize(coverage_report_reader: CoverageReportReader.new,
13
+ skip_directives: MutationSkipDirectives.new)
12
14
  @coverage_report_reader = coverage_report_reader
15
+ @skip_directives = skip_directives
13
16
  end
14
17
 
15
18
  # This method is the gate-level filter orchestrator.
@@ -18,7 +21,7 @@ module Henitai
18
21
  coverage_report_present = coverage_report_present?(config)
19
22
 
20
23
  Array(mutants).each do |mutant|
21
- next if ignored_mutant?(mutant, config)
24
+ next if ignored_mutant?(mutant, config) || skip_directive_mutant?(mutant)
22
25
 
23
26
  mark_equivalent_mutant(mutant)
24
27
  mark_no_coverage_mutant(
@@ -61,7 +64,7 @@ module Henitai
61
64
 
62
65
  private
63
66
 
64
- attr_reader :coverage_report_reader
67
+ attr_reader :coverage_report_reader, :skip_directives
65
68
 
66
69
  def ignored?(mutant, config)
67
70
  source = source_for(mutant)
@@ -79,6 +82,15 @@ module Henitai
79
82
  true
80
83
  end
81
84
 
85
+ def skip_directive_mutant?(mutant)
86
+ directive = skip_directives.directive_for(mutant)
87
+ return false unless directive
88
+
89
+ mutant.status = :ignored
90
+ mutant.ignore_reason = directive.reason if mutant.respond_to?(:ignore_reason=)
91
+ true
92
+ end
93
+
82
94
  def mark_equivalent_mutant(mutant)
83
95
  return unless mutant.pending?
84
96
 
@@ -96,9 +108,7 @@ module Henitai
96
108
  def covered?(mutant, coverage_lines)
97
109
  file = normalize_path(mutant.location[:file])
98
110
  covered = Array(coverage_lines[file])
99
- (mutant.location[:start_line]..mutant.location[:end_line]).any? do |line|
100
- covered.include?(line)
101
- end
111
+ covered.any? { |line| (mutant.location[:start_line]..mutant.location[:end_line]).cover?(line) }
102
112
  end
103
113
 
104
114
  def source_for(mutant)
@@ -137,7 +137,7 @@ module Henitai
137
137
  split[:stable] + split[:pending]
138
138
  end
139
139
 
140
- def test_filter(loaded, dirty_source_files: false)
140
+ def test_filter(loaded, dirty_source_files:)
141
141
  SurvivorTestFilter.new(
142
142
  coverage_map: loaded.coverage_map,
143
143
  git_sha: loaded.git_sha,
@@ -57,7 +57,7 @@ module Henitai
57
57
  covering = @coverage_map[mutant.stable_id]
58
58
  return false if covering.nil? || covering.empty?
59
59
 
60
- covering.none? { |test_file| changed.include?(test_file) }
60
+ !changed.intersect?(covering)
61
61
  end
62
62
 
63
63
  # Returns a Set of changed test file paths, or nil on any git error
@@ -2,15 +2,41 @@
2
2
 
3
3
  module Henitai
4
4
  # Orders test files so previously effective tests run first.
5
+ #
6
+ # Primary key: kill-history count (a test that killed neighbouring mutants
7
+ # probably kills this one). Tiebreaker: measured per-test-file runtime,
8
+ # ascending, so the first kill costs as little wall-clock as possible.
9
+ # Untimed tests sort after timed ones by original inventory order; with no
10
+ # timing source at all the ordering is exactly the historical behavior.
5
11
  class TestPrioritizer
12
+ # @param timing_source [#call, nil] returns a Hash of test file path to
13
+ # wall-clock seconds; resolved lazily on first sort.
14
+ def initialize(timing_source: nil)
15
+ @timing_source = timing_source
16
+ end
17
+
6
18
  def sort(tests, _mutant, history)
7
19
  Array(tests).each_with_index.sort_by do |test, index|
8
- [-history_count(history, test), index]
20
+ [-history_count(history, test), runtime(test), index]
9
21
  end.map(&:first)
10
22
  end
11
23
 
12
24
  private
13
25
 
26
+ def runtime(test)
27
+ durations = timing_durations
28
+ history_key_candidates(test).each do |key|
29
+ value = durations[key]
30
+ return value.to_f unless value.nil?
31
+ end
32
+
33
+ Float::INFINITY
34
+ end
35
+
36
+ def timing_durations
37
+ @timing_durations ||= @timing_source&.call || {}
38
+ end
39
+
14
40
  def history_count(history, test)
15
41
  return 0 unless history.respond_to?(:fetch)
16
42
 
@@ -45,7 +71,7 @@ module Henitai
45
71
 
46
72
  def relative_history_key(path)
47
73
  pathname = Pathname.new(path)
48
- return unless pathname.absolute?
74
+ return path unless pathname.absolute?
49
75
 
50
76
  pathname.relative_path_from(Pathname.pwd).to_s
51
77
  rescue StandardError
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Derives a per-mutant timeout from measured per-test-file durations.
5
+ #
6
+ # The calibrated value is `multiplier × sum(durations of the selected test
7
+ # files)`, clamped to a floor so a near-instant baseline doesn't flag normal
8
+ # jitter as a timeout. Returns nil — caller falls back to the static
9
+ # default — when any selected test lacks timing data, so a partially
10
+ # measured run is treated as uncalibratable rather than under-estimated.
11
+ class TimeoutCalibrator
12
+ FLOOR_SECONDS = 2.0
13
+
14
+ # @param timing_source [#call] returns a Hash of test file path to
15
+ # wall-clock seconds; resolved lazily on first use.
16
+ # @param multiplier [Numeric]
17
+ def initialize(timing_source:, multiplier:)
18
+ @timing_source = timing_source
19
+ @multiplier = multiplier
20
+ end
21
+
22
+ # @param test_files [Array<String>]
23
+ # @return [Float, nil]
24
+ def timeout_for(test_files)
25
+ files = Array(test_files)
26
+ return nil if files.empty? || durations.empty?
27
+
28
+ baselines = files.map { |file| durations[normalize(file)] }
29
+ return nil if baselines.any?(&:nil?)
30
+
31
+ [@multiplier * baselines.sum, FLOOR_SECONDS].max
32
+ end
33
+
34
+ private
35
+
36
+ def durations
37
+ @durations ||= (@timing_source.call || {}).transform_keys { |key| normalize(key) }
38
+ end
39
+
40
+ def normalize(path)
41
+ File.expand_path(path.to_s)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "find"
5
+ require "json"
6
+
7
+ require_relative "generated_artifacts"
8
+
9
+ module Henitai
10
+ # Content fingerprints that decide whether a stored verdict is still
11
+ # trustworthy: the subject's source (method body) and the covering test
12
+ # files must both be byte-identical to what was recorded. Survived verdicts
13
+ # additionally record the full-map covering-set membership plus a run-level
14
+ # dependency fingerprint (ADR-11). Any read failure yields nil / a mismatch
15
+ # — conservative, never reuse on doubt.
16
+ module VerdictFingerprint
17
+ # Files that influence test behavior but never appear in any covering
18
+ # set: helpers, support code, fixtures/factories, lockfile and tool
19
+ # config. One combined hash over all of them gates survivor reuse.
20
+ DEPENDENCY_GLOBS = %w[
21
+ spec/spec_helper.rb
22
+ spec/rails_helper.rb
23
+ spec/support/**/*
24
+ spec/fixtures/**/*
25
+ spec/factories/**/*
26
+ test/test_helper.rb
27
+ test/support/**/*
28
+ test/fixtures/**/*
29
+ test/factories/**/*
30
+ Gemfile.lock
31
+ .henitai.yml
32
+ .rspec
33
+ ].freeze
34
+
35
+ module_function
36
+
37
+ # SHA256 of the subject's source lines. nil when the subject has no
38
+ # source range or the file cannot be read.
39
+ def subject_source_hash(mutant)
40
+ subject = mutant.subject
41
+ return nil unless subject.respond_to?(:source_file) && subject.respond_to?(:source_range)
42
+
43
+ range = subject.source_range
44
+ return nil unless range
45
+
46
+ lines = File.readlines(subject.source_file)
47
+ Digest::SHA256.hexdigest(lines[(range.begin - 1)..(range.end - 1)].to_a.join)
48
+ rescue SystemCallError, IOError
49
+ nil
50
+ end
51
+
52
+ # JSON fingerprint of the covering test files: their sorted paths plus a
53
+ # combined content hash. nil when no tests are known or any is unreadable.
54
+ def tests_fingerprint(test_files)
55
+ paths = Array(test_files).map(&:to_s).sort
56
+ return nil if paths.empty?
57
+
58
+ sha = combined_content_sha(paths)
59
+ return nil unless sha
60
+
61
+ JSON.generate("paths" => paths, "sha" => sha)
62
+ end
63
+
64
+ # True when every recorded test file still exists with identical content.
65
+ def tests_fingerprint_current?(fingerprint_json)
66
+ return false if fingerprint_json.nil?
67
+
68
+ fingerprint = JSON.parse(fingerprint_json)
69
+ combined_content_sha(fingerprint.fetch("paths")) == fingerprint.fetch("sha")
70
+ rescue StandardError
71
+ false
72
+ end
73
+
74
+ # Existing dependency files resolved against +root+, sorted. Shared by
75
+ # the fingerprint below and by the coverage freshness watch — a stale
76
+ # per-test map after a dependency edit would let the test selector omit
77
+ # a newly covering test. Uses Find with pruning instead of Dir.glob so
78
+ # generated subtrees (thousands of mutation-log files under fixture
79
+ # projects) are never walked at all.
80
+ def dependency_files(root = Dir.pwd)
81
+ files = DEPENDENCY_GLOBS.flat_map do |pattern|
82
+ base = File.join(root, pattern)
83
+ pattern.include?("*") ? pruned_tree_files(File.dirname(base.sub("/**/*", "/x"))) : [base]
84
+ end
85
+ files.select { |path| File.file?(path) }.uniq.sort
86
+ end
87
+
88
+ def pruned_tree_files(dir)
89
+ return [] unless File.directory?(dir)
90
+
91
+ collected = [] # : Array[String]
92
+ Find.find(dir) do |path|
93
+ Find.prune if File.directory?(path) && GeneratedArtifacts.generated_dir?(path)
94
+ collected << path if File.file?(path)
95
+ end
96
+ collected
97
+ end
98
+
99
+ # Run-level combined SHA over the dependency file set. Missing files are
100
+ # simply excluded (not an error); a read failure yields nil — no reuse.
101
+ def dependency_fingerprint(root = Dir.pwd) = combined_content_sha(dependency_files(root))
102
+
103
+ # Fingerprint recorded for a Survived verdict: the sorted full-map
104
+ # intersection set (paths + combined content sha) plus the run-level
105
+ # dependency sha. nil when any input is unavailable — the verdict is
106
+ # then never reusable.
107
+ def survivor_tests_fingerprint(test_files, dependency_sha:)
108
+ return nil if dependency_sha.nil?
109
+
110
+ paths = Array(test_files).map(&:to_s).sort
111
+ return nil if paths.empty?
112
+
113
+ sha = combined_content_sha(paths)
114
+ return nil unless sha
115
+
116
+ JSON.generate("paths" => paths, "sha" => sha, "dependencies" => dependency_sha)
117
+ end
118
+
119
+ # True when the recorded covering set still equals the live one in
120
+ # membership AND content, and the dependency fingerprint is unchanged.
121
+ # Any parse/read failure or missing field resolves to stale.
122
+ def survivor_fingerprint_current?(fingerprint_json, live_paths:, dependency_sha:)
123
+ return false if fingerprint_json.nil? || dependency_sha.nil?
124
+
125
+ survivor_fingerprint_matches?(JSON.parse(fingerprint_json), live_paths, dependency_sha)
126
+ rescue StandardError
127
+ false
128
+ end
129
+
130
+ def survivor_fingerprint_matches?(fingerprint, live_paths, dependency_sha)
131
+ recorded_paths = fingerprint.fetch("paths")
132
+ return false if recorded_paths.empty?
133
+
134
+ recorded_paths == Array(live_paths).map(&:to_s).sort &&
135
+ fingerprint.fetch("dependencies") == dependency_sha &&
136
+ combined_content_sha(recorded_paths) == fingerprint.fetch("sha")
137
+ end
138
+
139
+ def combined_content_sha(paths)
140
+ digest = Digest::SHA256.new
141
+ paths.each do |path|
142
+ content = File.read(path)
143
+ digest.update(path.bytesize.to_s)
144
+ digest.update(":")
145
+ digest.update(path)
146
+ digest.update(content.bytesize.to_s)
147
+ digest.update(":")
148
+ digest.update(content)
149
+ end
150
+ digest.hexdigest
151
+ rescue StandardError
152
+ nil
153
+ end
154
+ end
155
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Henitai
4
- VERSION = "0.2.1"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/henitai.rb CHANGED
@@ -20,9 +20,13 @@ module Henitai
20
20
  # Raised when coverage data cannot be bootstrapped or validated.
21
21
  class CoverageError < StandardError; end
22
22
 
23
+ # Raised when another process is using the configured reports directory.
24
+ class ConcurrentRunError < StandardError; end
25
+
23
26
  autoload :Configuration, "henitai/configuration"
24
27
  autoload :CoverageBootstrapper, "henitai/coverage_bootstrapper"
25
28
  autoload :CoverageReportReader, "henitai/coverage_report_reader"
29
+ autoload :PerTestCoverage, "henitai/per_test_coverage"
26
30
  autoload :PerTestCoverageSelector, "henitai/per_test_coverage_selector"
27
31
  autoload :Subject, "henitai/subject"
28
32
  autoload :Mutant, "henitai/mutant"
@@ -31,15 +35,24 @@ module Henitai
31
35
  autoload :Operators, "henitai/operators"
32
36
  autoload :SourceParser, "henitai/source_parser"
33
37
  autoload :SubjectResolver, "henitai/subject_resolver"
38
+ autoload :GeneratedArtifacts, "henitai/generated_artifacts"
34
39
  autoload :GitDiffAnalyzer, "henitai/git_diff_analyzer"
35
40
  autoload :GitDiffError, "henitai/git_diff_analyzer"
36
41
  autoload :MutantGenerator, "henitai/mutant_generator"
37
42
  autoload :MutantHistoryStore, "henitai/mutant_history_store"
43
+ autoload :MutationSkipDirectives, "henitai/mutation_skip_directives"
38
44
  autoload :AridNodeFilter, "henitai/arid_node_filter"
39
45
  autoload :AvailableCpuCount, "henitai/available_cpu_count"
40
46
  autoload :EquivalenceDetector, "henitai/equivalence_detector"
41
47
  autoload :StaticFilter, "henitai/static_filter"
48
+ autoload :IncrementalFilter, "henitai/incremental_filter"
49
+ autoload :VerdictFingerprint, "henitai/verdict_fingerprint"
42
50
  autoload :StillbornFilter, "henitai/stillborn_filter"
51
+ autoload :CanonicalReportMerger, "henitai/canonical_report_merger"
52
+ autoload :CanonicalReportWriter, "henitai/canonical_report_writer"
53
+ autoload :CheckpointReporter, "henitai/checkpoint_reporter"
54
+ autoload :CompositeProgressReporter, "henitai/composite_progress_reporter"
55
+ autoload :ReportsDirectoryLock, "henitai/reports_directory_lock"
43
56
  autoload :SurvivorLoader, "henitai/survivor_loader"
44
57
  autoload :SurvivorSelector, "henitai/survivor_selector"
45
58
  autoload :SurvivorTestFilter, "henitai/survivor_test_filter"
@@ -52,8 +65,8 @@ module Henitai
52
65
  autoload :SyntaxValidator, "henitai/syntax_validator"
53
66
  autoload :SamplingStrategy, "henitai/sampling_strategy"
54
67
  autoload :TestPrioritizer, "henitai/test_prioritizer"
68
+ autoload :TimeoutCalibrator, "henitai/timeout_calibrator"
55
69
  autoload :ExecutionEngine, "henitai/execution_engine"
56
- autoload :ParallelExecutionRunner, "henitai/parallel_execution_runner"
57
70
  autoload :ProcessWorkerRunner, "henitai/process_worker_runner"
58
71
  autoload :SlotScheduler, "henitai/slot_scheduler"
59
72
  autoload :ProcessWakeup, "henitai/process_wakeup"