henitai 0.1.7 → 0.1.10
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 +34 -1
- data/lib/henitai/coverage_formatter.rb +6 -95
- data/lib/henitai/eager_load.rb +1 -1
- data/lib/henitai/equivalence_detector.rb +55 -1
- data/lib/henitai/integration.rb +41 -2
- data/lib/henitai/minitest_coverage_hook.rb +17 -0
- data/lib/henitai/minitest_coverage_reporter.rb +36 -0
- data/lib/henitai/per_test_coverage_collector.rb +119 -0
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +2 -0
- data/sig/henitai.rbs +46 -8
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a993f2073367de6b25c8fe922a2089f57103d79790c1e9cd95d2ba6b573b3050
|
|
4
|
+
data.tar.gz: d6956c7b23f1ec7cd97773cdd249a2c12b17f75e080cb98092d34049a763c607
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e7a1925ad77b77ac4ed802b3419b15988a436098488a2dfb0ee11f8f930da024bc05a686606ae4e86ba6fd9f36ef4aed19ef8b1e46bef31e143d25ee4dc54ded
|
|
7
|
+
data.tar.gz: f095d945bd5f9313a202db7a6ce3936a69233b47d62b92ec353950857c27d58b75dc8084164383d66558ef2a076c62e2fd53e68621d8b9d95546c6b1114faa73
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.1.10] - 2026-04-16
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- RuboCop `RSpec/MultipleExpectations` offenses in `coverage_formatter_spec` and
|
|
14
|
+
`per_test_coverage_collector_spec` resolved by splitting each two-assertion
|
|
15
|
+
example into focused single-expectation examples
|
|
16
|
+
|
|
17
|
+
## [0.1.9] - 2026-04-16
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- SimpleCov is now suppressed during Minitest mutant child runs: `SimpleCov.start`
|
|
21
|
+
is turned into a no-op before test files are required, eliminating the
|
|
22
|
+
"Stopped processing SimpleCov as a previous error has been detected" warning
|
|
23
|
+
and avoiding unnecessary coverage instrumentation overhead in every mutant fork
|
|
24
|
+
|
|
25
|
+
## [0.1.8] - 2026-04-16
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
- Minitest integration now supports per-test coverage: a `MinitestCoverageReporter`
|
|
29
|
+
snapshots `Coverage.peek_result` after each test and writes `henitai_per_test.json`,
|
|
30
|
+
enabling targeted mutation runs that only execute the tests covering the mutated lines —
|
|
31
|
+
the same efficiency that was previously available only to RSpec projects
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
- RSpec integration now respects `--exclude-pattern` entries in `.rspec` so excluded
|
|
35
|
+
spec files are not passed to the runner during mutation runs
|
|
36
|
+
- Per-test source file filter corrected to check only the project-relative path prefix
|
|
37
|
+
rather than scanning the full absolute path, preventing false exclusions when the
|
|
38
|
+
project lives under a directory whose path contains `/spec/` or `/test/`
|
|
39
|
+
|
|
10
40
|
## [0.1.7] - 2026-04-14
|
|
11
41
|
|
|
12
42
|
### Added
|
|
@@ -181,7 +211,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
181
211
|
- CLI critical path: `henitai run` now executes the full pipeline, supports `--since`, returns CI-friendly exit codes, and `henitai version` prints `Henitai::VERSION`
|
|
182
212
|
- RSpec per-test coverage output: `henitai/coverage_formatter` now writes `coverage/henitai_per_test.json`
|
|
183
213
|
|
|
184
|
-
[Unreleased]: https://github.com/martinotten/henitai/compare/v0.1.
|
|
214
|
+
[Unreleased]: https://github.com/martinotten/henitai/compare/v0.1.10...HEAD
|
|
215
|
+
[0.1.10]: https://github.com/martinotten/henitai/compare/v0.1.9...v0.1.10
|
|
216
|
+
[0.1.9]: https://github.com/martinotten/henitai/compare/v0.1.8...v0.1.9
|
|
217
|
+
[0.1.8]: https://github.com/martinotten/henitai/compare/v0.1.7...v0.1.8
|
|
185
218
|
[0.1.7]: https://github.com/martinotten/henitai/compare/v0.1.6...v0.1.7
|
|
186
219
|
[0.1.6]: https://github.com/martinotten/henitai/compare/v0.1.5...v0.1.6
|
|
187
220
|
[0.1.5]: https://github.com/martinotten/henitai/compare/v0.1.4...v0.1.5
|
|
@@ -1,112 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
4
|
-
require "fileutils"
|
|
5
|
-
require "json"
|
|
3
|
+
require "henitai/per_test_coverage_collector"
|
|
6
4
|
|
|
7
5
|
module Henitai
|
|
8
6
|
# Collects per-test coverage data for static filtering heuristics.
|
|
9
7
|
class CoverageFormatter
|
|
10
|
-
REPORT_DIR_ENV =
|
|
11
|
-
REPORT_FILE_NAME =
|
|
8
|
+
REPORT_DIR_ENV = PerTestCoverageCollector::REPORT_DIR_ENV
|
|
9
|
+
REPORT_FILE_NAME = PerTestCoverageCollector::REPORT_FILE_NAME
|
|
12
10
|
|
|
13
11
|
def initialize(_output)
|
|
14
|
-
@
|
|
15
|
-
hash[test_file] = Hash.new { |nested, source_file| nested[source_file] = [] }
|
|
16
|
-
end
|
|
17
|
-
@previous_snapshot = {}
|
|
18
|
-
@warned_missing_coverage = false
|
|
12
|
+
@collector = PerTestCoverageCollector.new
|
|
19
13
|
end
|
|
20
14
|
|
|
21
15
|
def example_finished(notification)
|
|
22
|
-
|
|
23
|
-
return warn_missing_coverage unless snapshot
|
|
24
|
-
|
|
25
|
-
test_file = notification.example.metadata[:file_path]
|
|
26
|
-
new_lines(snapshot).each do |source_file, lines|
|
|
27
|
-
@coverage_by_test[test_file][source_file].concat(lines)
|
|
28
|
-
@coverage_by_test[test_file][source_file].uniq!
|
|
29
|
-
@coverage_by_test[test_file][source_file].sort!
|
|
30
|
-
end
|
|
31
|
-
@previous_snapshot = snapshot
|
|
16
|
+
@collector.record_test(notification.example.metadata[:file_path])
|
|
32
17
|
end
|
|
33
18
|
|
|
34
19
|
def dump_summary(_summary)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
FileUtils.mkdir_p(File.dirname(report_path))
|
|
38
|
-
File.write(report_path, JSON.pretty_generate(serializable_report))
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
private
|
|
42
|
-
|
|
43
|
-
def report_path
|
|
44
|
-
File.join(reports_dir, REPORT_FILE_NAME)
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def reports_dir
|
|
48
|
-
ENV.fetch(REPORT_DIR_ENV, "coverage")
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def current_snapshot
|
|
52
|
-
Coverage.peek_result
|
|
53
|
-
rescue StandardError
|
|
54
|
-
nil
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def warn_missing_coverage
|
|
58
|
-
return if @warned_missing_coverage
|
|
59
|
-
|
|
60
|
-
warn "Per-test coverage unavailable; skipping coverage formatter output"
|
|
61
|
-
@warned_missing_coverage = true
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def new_lines(snapshot)
|
|
65
|
-
snapshot.each_with_object({}) do |(source_file, file_coverage), result|
|
|
66
|
-
next unless source_file?(source_file)
|
|
67
|
-
|
|
68
|
-
lines = new_line_numbers(
|
|
69
|
-
file_coverage,
|
|
70
|
-
previous_line_counts(source_file)
|
|
71
|
-
)
|
|
72
|
-
result[source_file] = lines unless lines.empty?
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def new_line_numbers(file_coverage, previous_counts)
|
|
77
|
-
line_counts_for(file_coverage).each_with_index.filter_map do |count, index|
|
|
78
|
-
next unless count.to_i.positive?
|
|
79
|
-
next if previous_counts.fetch(index, 0).to_i.positive?
|
|
80
|
-
|
|
81
|
-
index + 1
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def previous_line_counts(source_file)
|
|
86
|
-
line_counts_for(@previous_snapshot.fetch(source_file, []))
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
def line_counts_for(file_coverage)
|
|
90
|
-
case file_coverage
|
|
91
|
-
when Hash
|
|
92
|
-
Array(file_coverage[:lines] || file_coverage["lines"])
|
|
93
|
-
else
|
|
94
|
-
Array(file_coverage)
|
|
95
|
-
end
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def source_file?(path)
|
|
99
|
-
expanded = File.expand_path(path)
|
|
100
|
-
expanded.start_with?(Dir.pwd) &&
|
|
101
|
-
!expanded.include?("#{File::SEPARATOR}spec#{File::SEPARATOR}")
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
def serializable_report
|
|
105
|
-
@coverage_by_test.transform_values do |source_map|
|
|
106
|
-
source_map.to_h do |source_file, lines|
|
|
107
|
-
[File.expand_path(source_file), lines.uniq.sort]
|
|
108
|
-
end
|
|
109
|
-
end
|
|
20
|
+
@collector.write_report
|
|
110
21
|
end
|
|
111
22
|
end
|
|
112
23
|
end
|
data/lib/henitai/eager_load.rb
CHANGED
|
@@ -4,7 +4,7 @@ require "henitai"
|
|
|
4
4
|
|
|
5
5
|
# Force all autoloaded constants to load so mutation testing tools
|
|
6
6
|
# (e.g. mutant) can discover subjects via ObjectSpace.
|
|
7
|
-
SIDE_EFFECT_FILES = %w[minitest_simplecov.rb rspec_coverage_formatter.rb].freeze
|
|
7
|
+
SIDE_EFFECT_FILES = %w[minitest_simplecov.rb minitest_coverage_hook.rb rspec_coverage_formatter.rb].freeze
|
|
8
8
|
|
|
9
9
|
Dir[File.join(__dir__, "**/*.rb")].each do |f|
|
|
10
10
|
require f unless SIDE_EFFECT_FILES.any? { |name| f.end_with?(name) }
|
|
@@ -21,7 +21,8 @@ module Henitai
|
|
|
21
21
|
def equivalent_mutation?(mutant)
|
|
22
22
|
equivalent_arithmetic_mutation?(mutant) ||
|
|
23
23
|
equivalent_logical_mutation?(mutant) ||
|
|
24
|
-
equivalent_singleton_equality_mutation?(mutant)
|
|
24
|
+
equivalent_singleton_equality_mutation?(mutant) ||
|
|
25
|
+
equivalent_string_eql_mutation?(mutant)
|
|
25
26
|
end
|
|
26
27
|
|
|
27
28
|
def equivalent_arithmetic_mutation?(mutant)
|
|
@@ -162,6 +163,59 @@ module Henitai
|
|
|
162
163
|
equality_operators?(original.children[1], mutated.children[1])
|
|
163
164
|
end
|
|
164
165
|
|
|
166
|
+
# Detects `lhs == rhs` mutated to `lhs.eql?(rhs)` (or the reverse) when at
|
|
167
|
+
# least one operand is a string literal.
|
|
168
|
+
#
|
|
169
|
+
# String#eql? is documented to compare both type and value. Since String#==
|
|
170
|
+
# also compares type and value (it returns false for any non-String argument
|
|
171
|
+
# without invoking the other object's #==), the two methods are equivalent
|
|
172
|
+
# for all possible inputs whenever at least one operand is statically known
|
|
173
|
+
# to be a String — proven here by the presence of a :str literal on the
|
|
174
|
+
# receiver or the argument side.
|
|
175
|
+
#
|
|
176
|
+
# When no operand is a string literal we conservatively leave the mutant
|
|
177
|
+
# pending: the receiver could be any object whose custom #== diverges from
|
|
178
|
+
# its #eql?.
|
|
179
|
+
def equivalent_string_eql_mutation?(mutant)
|
|
180
|
+
original = mutant.original_node
|
|
181
|
+
mutated = mutant.mutated_node
|
|
182
|
+
|
|
183
|
+
string_eql_send?(original) && string_eql_send?(mutated) &&
|
|
184
|
+
same_receiver?(original, mutated) &&
|
|
185
|
+
string_eql_operators?(original.children[1], mutated.children[1]) &&
|
|
186
|
+
same_rhs?(original, mutated) &&
|
|
187
|
+
string_operand?(original)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def string_eql_send?(node)
|
|
191
|
+
node.is_a?(Parser::AST::Node) &&
|
|
192
|
+
node.type == :send &&
|
|
193
|
+
node.children.size == 3 &&
|
|
194
|
+
string_eql_operator?(node.children[1])
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def string_eql_operator?(operator)
|
|
198
|
+
%i[== eql?].include?(operator)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def string_eql_operators?(op_a, op_b)
|
|
202
|
+
string_eql_operator?(op_a) && string_eql_operator?(op_b) && op_a != op_b
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def same_rhs?(original, mutated)
|
|
206
|
+
same_node?(original.children[2], mutated.children[2])
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Returns true when at least one operand is a string literal, giving static
|
|
210
|
+
# proof that the comparison is string-typed on at least one side.
|
|
211
|
+
def string_operand?(node)
|
|
212
|
+
string_literal?(node.children[0]) || string_literal?(node.children[2])
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def string_literal?(node)
|
|
216
|
+
node.is_a?(Parser::AST::Node) && node.type == :str
|
|
217
|
+
end
|
|
218
|
+
|
|
165
219
|
def singleton_rhs_match?(original, mutated)
|
|
166
220
|
rhs = original.children[2]
|
|
167
221
|
singleton_literal?(rhs) && same_node?(rhs, mutated.children[2])
|
data/lib/henitai/integration.rb
CHANGED
|
@@ -326,7 +326,8 @@ module Henitai
|
|
|
326
326
|
end
|
|
327
327
|
|
|
328
328
|
def spec_files
|
|
329
|
-
Dir.glob("spec/**/*_spec.rb")
|
|
329
|
+
paths = Dir.glob("spec/**/*_spec.rb")
|
|
330
|
+
paths - excluded_spec_files
|
|
330
331
|
end
|
|
331
332
|
|
|
332
333
|
def fallback_spec_files(subject)
|
|
@@ -341,6 +342,26 @@ module Henitai
|
|
|
341
342
|
matches.empty? ? spec_files : matches
|
|
342
343
|
end
|
|
343
344
|
|
|
345
|
+
def excluded_spec_files
|
|
346
|
+
rspec_exclude_patterns.flat_map { |pattern| Dir.glob(pattern) }.uniq
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def rspec_exclude_patterns
|
|
350
|
+
rspec_config_lines.filter_map do |line|
|
|
351
|
+
line[/\A--exclude-pattern\s+(.+)\z/, 1]
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def rspec_config_lines
|
|
356
|
+
return [] unless File.exist?(rspec_config_path)
|
|
357
|
+
|
|
358
|
+
File.readlines(rspec_config_path, chomp: true).map(&:strip)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def rspec_config_path
|
|
362
|
+
".rspec"
|
|
363
|
+
end
|
|
364
|
+
|
|
344
365
|
def selection_patterns(subject)
|
|
345
366
|
[
|
|
346
367
|
subject.expression,
|
|
@@ -470,6 +491,12 @@ module Henitai
|
|
|
470
491
|
end
|
|
471
492
|
end
|
|
472
493
|
|
|
494
|
+
# Prepended onto SimpleCov's singleton class to turn start into a no-op
|
|
495
|
+
# during mutant child runs. Using prepend avoids "method redefined" warnings.
|
|
496
|
+
module SimpleCovStartSuppressor
|
|
497
|
+
def start(*_args) = nil
|
|
498
|
+
end
|
|
499
|
+
|
|
473
500
|
# Minitest integration adapter.
|
|
474
501
|
#
|
|
475
502
|
# Coverage formatter injection remains implemented in the RSpec child
|
|
@@ -477,7 +504,7 @@ module Henitai
|
|
|
477
504
|
# coverage collection is not yet wired into this path.
|
|
478
505
|
class Minitest < Rspec
|
|
479
506
|
def per_test_coverage_supported?
|
|
480
|
-
|
|
507
|
+
true
|
|
481
508
|
end
|
|
482
509
|
|
|
483
510
|
def run_mutant(mutant:, test_files:, timeout:)
|
|
@@ -505,11 +532,13 @@ module Henitai
|
|
|
505
532
|
def suite_command(test_files)
|
|
506
533
|
["bundle", "exec", "ruby", "-I", "test",
|
|
507
534
|
"-r", "henitai/minitest_simplecov",
|
|
535
|
+
"-r", "henitai/minitest_coverage_hook",
|
|
508
536
|
"-e", "ARGV.each { |f| require File.expand_path(f) }",
|
|
509
537
|
*test_files]
|
|
510
538
|
end
|
|
511
539
|
|
|
512
540
|
def run_tests(test_files)
|
|
541
|
+
suppress_simplecov!
|
|
513
542
|
test_files.each { |file| require File.expand_path(file) }
|
|
514
543
|
# @type var empty_args: Array[String]
|
|
515
544
|
empty_args = []
|
|
@@ -529,6 +558,16 @@ module Henitai
|
|
|
529
558
|
$LOAD_PATH.unshift(test_dir) unless $LOAD_PATH.include?(test_dir)
|
|
530
559
|
end
|
|
531
560
|
|
|
561
|
+
def suppress_simplecov!
|
|
562
|
+
require "simplecov"
|
|
563
|
+
sc = Object.const_get(:SimpleCov) # steep:ignore Ruby::UnknownConstant
|
|
564
|
+
return if sc.singleton_class.ancestors.include?(SimpleCovStartSuppressor)
|
|
565
|
+
|
|
566
|
+
sc.singleton_class.prepend(SimpleCovStartSuppressor)
|
|
567
|
+
rescue LoadError, NameError
|
|
568
|
+
nil
|
|
569
|
+
end
|
|
570
|
+
|
|
532
571
|
def subprocess_env
|
|
533
572
|
env = super
|
|
534
573
|
env["RAILS_ENV"] = "test" unless ENV["RAILS_ENV"] == "test"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Injected by henitai into the Minitest baseline subprocess to collect
|
|
4
|
+
# per-test line coverage. Must be required after henitai/minitest_simplecov
|
|
5
|
+
# so that Coverage is already running when the reporter takes snapshots.
|
|
6
|
+
|
|
7
|
+
require "minitest"
|
|
8
|
+
require "henitai/minitest_coverage_reporter"
|
|
9
|
+
|
|
10
|
+
Minitest.extensions << "henitai_coverage"
|
|
11
|
+
|
|
12
|
+
# Henitai per-test coverage plugin for Minitest.
|
|
13
|
+
module Minitest
|
|
14
|
+
def self.plugin_henitai_coverage_init(_options)
|
|
15
|
+
reporter.reporters << Henitai::MinitestCoverageReporter.new
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "minitest"
|
|
4
|
+
require "henitai/per_test_coverage_collector"
|
|
5
|
+
|
|
6
|
+
module Henitai
|
|
7
|
+
# Minitest reporter that collects per-test line coverage deltas.
|
|
8
|
+
#
|
|
9
|
+
# Added to Minitest's reporter chain by the henitai_coverage plugin
|
|
10
|
+
# (see minitest_coverage_hook.rb). Delegates accumulation and serialisation
|
|
11
|
+
# to PerTestCoverageCollector so the JSON output format is identical to the
|
|
12
|
+
# RSpec integration.
|
|
13
|
+
class MinitestCoverageReporter < Minitest::Reporter
|
|
14
|
+
def initialize(io = $stdout, options = {})
|
|
15
|
+
super
|
|
16
|
+
@collector = PerTestCoverageCollector.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def record(result)
|
|
20
|
+
super
|
|
21
|
+
@collector.record_test(relative_to_pwd(result.source_location.first))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def report
|
|
25
|
+
super
|
|
26
|
+
@collector.write_report
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def relative_to_pwd(path)
|
|
32
|
+
prefix = "#{Dir.pwd}#{File::SEPARATOR}"
|
|
33
|
+
path.start_with?(prefix) ? path.sub(prefix, "") : path
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "coverage"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Henitai
|
|
8
|
+
# Accumulates per-test line coverage deltas across test examples.
|
|
9
|
+
#
|
|
10
|
+
# Framework-agnostic core used by both the RSpec and Minitest adapters.
|
|
11
|
+
# Callers invoke record_test after each test completes and write_report
|
|
12
|
+
# once the full suite has finished.
|
|
13
|
+
class PerTestCoverageCollector
|
|
14
|
+
REPORT_DIR_ENV = "HENITAI_REPORTS_DIR"
|
|
15
|
+
REPORT_FILE_NAME = "henitai_per_test.json"
|
|
16
|
+
|
|
17
|
+
def initialize
|
|
18
|
+
@coverage_by_test = Hash.new do |hash, test_file|
|
|
19
|
+
hash[test_file] = Hash.new { |nested, source_file| nested[source_file] = [] }
|
|
20
|
+
end
|
|
21
|
+
@previous_snapshot = {}
|
|
22
|
+
@warned_missing_coverage = false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def record_test(test_file)
|
|
26
|
+
snapshot = current_snapshot
|
|
27
|
+
return warn_missing_coverage unless snapshot
|
|
28
|
+
|
|
29
|
+
new_lines(snapshot).each do |source_file, lines|
|
|
30
|
+
@coverage_by_test[test_file][source_file].concat(lines)
|
|
31
|
+
@coverage_by_test[test_file][source_file].uniq!
|
|
32
|
+
@coverage_by_test[test_file][source_file].sort!
|
|
33
|
+
end
|
|
34
|
+
@previous_snapshot = snapshot
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def write_report
|
|
38
|
+
return if @coverage_by_test.empty?
|
|
39
|
+
|
|
40
|
+
FileUtils.mkdir_p(File.dirname(report_path))
|
|
41
|
+
File.write(report_path, JSON.pretty_generate(serializable_report))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def report_path
|
|
47
|
+
File.join(reports_dir, REPORT_FILE_NAME)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def reports_dir
|
|
51
|
+
ENV.fetch(REPORT_DIR_ENV, "coverage")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def current_snapshot
|
|
55
|
+
Coverage.peek_result
|
|
56
|
+
rescue StandardError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def warn_missing_coverage
|
|
61
|
+
return if @warned_missing_coverage
|
|
62
|
+
|
|
63
|
+
warn "Per-test coverage unavailable; skipping coverage formatter output"
|
|
64
|
+
@warned_missing_coverage = true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def new_lines(snapshot)
|
|
68
|
+
snapshot.each_with_object({}) do |(source_file, file_coverage), result|
|
|
69
|
+
next unless source_file?(source_file)
|
|
70
|
+
|
|
71
|
+
lines = new_line_numbers(
|
|
72
|
+
file_coverage,
|
|
73
|
+
previous_line_counts(source_file)
|
|
74
|
+
)
|
|
75
|
+
result[source_file] = lines unless lines.empty?
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def new_line_numbers(file_coverage, previous_counts)
|
|
80
|
+
line_counts_for(file_coverage).each_with_index.filter_map do |count, index|
|
|
81
|
+
next unless count.to_i.positive?
|
|
82
|
+
next if previous_counts.fetch(index, 0).to_i.positive?
|
|
83
|
+
|
|
84
|
+
index + 1
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def previous_line_counts(source_file)
|
|
89
|
+
line_counts_for(@previous_snapshot.fetch(source_file, []))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def line_counts_for(file_coverage)
|
|
93
|
+
case file_coverage
|
|
94
|
+
when Hash
|
|
95
|
+
Array(file_coverage[:lines] || file_coverage["lines"])
|
|
96
|
+
else
|
|
97
|
+
Array(file_coverage)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def source_file?(path)
|
|
102
|
+
expanded = File.expand_path(path)
|
|
103
|
+
prefix = "#{Dir.pwd}#{File::SEPARATOR}"
|
|
104
|
+
return false unless expanded.start_with?(prefix)
|
|
105
|
+
|
|
106
|
+
relative = expanded.sub(prefix, "")
|
|
107
|
+
!relative.start_with?("spec#{File::SEPARATOR}") &&
|
|
108
|
+
!relative.start_with?("test#{File::SEPARATOR}")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def serializable_report
|
|
112
|
+
@coverage_by_test.transform_values do |source_map|
|
|
113
|
+
source_map.to_h do |source_file, lines|
|
|
114
|
+
[File.expand_path(source_file), lines.uniq.sort]
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
data/lib/henitai/version.rb
CHANGED
data/lib/henitai.rb
CHANGED
|
@@ -41,6 +41,8 @@ module Henitai
|
|
|
41
41
|
autoload :StillbornFilter, "henitai/stillborn_filter"
|
|
42
42
|
autoload :ScenarioExecutionResult, "henitai/scenario_execution_result"
|
|
43
43
|
autoload :CoverageFormatter, "henitai/coverage_formatter"
|
|
44
|
+
autoload :MinitestCoverageReporter, "henitai/minitest_coverage_reporter"
|
|
45
|
+
autoload :PerTestCoverageCollector, "henitai/per_test_coverage_collector"
|
|
44
46
|
autoload :SyntaxValidator, "henitai/syntax_validator"
|
|
45
47
|
autoload :SamplingStrategy, "henitai/sampling_strategy"
|
|
46
48
|
autoload :TestPrioritizer, "henitai/test_prioritizer"
|
data/sig/henitai.rbs
CHANGED
|
@@ -39,6 +39,15 @@ end
|
|
|
39
39
|
|
|
40
40
|
module ::Minitest
|
|
41
41
|
def self.run: (Array[String]) -> bool
|
|
42
|
+
def self.extensions: () -> Array[String]
|
|
43
|
+
def self.reporter: () -> untyped
|
|
44
|
+
def self.plugin_henitai_coverage_init: (untyped) -> void
|
|
45
|
+
|
|
46
|
+
class Reporter
|
|
47
|
+
def initialize: (?IO, ?Hash[untyped, untyped]) -> void
|
|
48
|
+
def record: (untyped) -> void
|
|
49
|
+
def report: () -> void
|
|
50
|
+
end
|
|
42
51
|
end
|
|
43
52
|
|
|
44
53
|
module ::RSpec::Core::Formatters
|
|
@@ -177,25 +186,45 @@ module Henitai
|
|
|
177
186
|
def valid?: (Mutant) -> bool
|
|
178
187
|
end
|
|
179
188
|
|
|
180
|
-
class
|
|
189
|
+
class PerTestCoverageCollector
|
|
181
190
|
REPORT_DIR_ENV: String
|
|
182
191
|
REPORT_FILE_NAME: String
|
|
183
192
|
|
|
184
|
-
def initialize: (
|
|
185
|
-
def
|
|
186
|
-
def
|
|
193
|
+
def initialize: () -> void
|
|
194
|
+
def record_test: (String) -> void
|
|
195
|
+
def write_report: () -> void
|
|
187
196
|
|
|
188
197
|
private
|
|
189
198
|
|
|
190
199
|
def report_path: () -> String
|
|
191
200
|
def reports_dir: () -> String
|
|
192
201
|
def current_snapshot: () -> untyped
|
|
193
|
-
def
|
|
194
|
-
def
|
|
195
|
-
def
|
|
202
|
+
def warn_missing_coverage: () -> void
|
|
203
|
+
def new_lines: (untyped) -> Hash[String, Array[Integer]]
|
|
204
|
+
def new_line_numbers: (untyped, untyped) -> Array[Integer]
|
|
205
|
+
def previous_line_counts: (String) -> Array[untyped]
|
|
196
206
|
def line_counts_for: (untyped) -> Array[untyped]
|
|
197
207
|
def source_file?: (String) -> bool
|
|
198
|
-
def serializable_report: () ->
|
|
208
|
+
def serializable_report: () -> Hash[String, Hash[String, Array[Integer]]]
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
class CoverageFormatter
|
|
212
|
+
REPORT_DIR_ENV: String
|
|
213
|
+
REPORT_FILE_NAME: String
|
|
214
|
+
|
|
215
|
+
def initialize: (untyped) -> void
|
|
216
|
+
def example_finished: (untyped) -> void
|
|
217
|
+
def dump_summary: (untyped) -> void
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
class MinitestCoverageReporter < ::Minitest::Reporter
|
|
221
|
+
def initialize: (?IO, ?Hash[untyped, untyped]) -> void
|
|
222
|
+
def record: (untyped) -> void
|
|
223
|
+
def report: () -> void
|
|
224
|
+
|
|
225
|
+
private
|
|
226
|
+
|
|
227
|
+
def relative_to_pwd: (String) -> String
|
|
199
228
|
end
|
|
200
229
|
|
|
201
230
|
class SamplingStrategy
|
|
@@ -301,6 +330,10 @@ module Henitai
|
|
|
301
330
|
def with_subprocess_env: () { () -> untyped } -> untyped
|
|
302
331
|
def spec_files: () -> Array[String]
|
|
303
332
|
def fallback_spec_files: (Subject) -> Array[String]
|
|
333
|
+
def excluded_spec_files: () -> Array[String]
|
|
334
|
+
def rspec_exclude_patterns: () -> Array[String]
|
|
335
|
+
def rspec_config_lines: () -> Array[String]
|
|
336
|
+
def rspec_config_path: () -> String
|
|
304
337
|
def selection_patterns: (Subject) -> Array[String]
|
|
305
338
|
def requires_source_file?: (String, String) -> bool
|
|
306
339
|
def requires_source_file_transitively?: (String, String, ?Array[String]) -> bool
|
|
@@ -311,6 +344,10 @@ module Henitai
|
|
|
311
344
|
def expand_candidates: (String, String) -> Array[String]
|
|
312
345
|
end
|
|
313
346
|
|
|
347
|
+
module SimpleCovStartSuppressor
|
|
348
|
+
def start: (*untyped) -> nil
|
|
349
|
+
end
|
|
350
|
+
|
|
314
351
|
class Minitest < Rspec
|
|
315
352
|
def run_mutant: (mutant: Mutant, test_files: Array[String], timeout: Float) -> ScenarioExecutionResult
|
|
316
353
|
def run_suite: (Array[String], ?timeout: Float) -> ScenarioExecutionResult
|
|
@@ -321,6 +358,7 @@ module Henitai
|
|
|
321
358
|
def run_tests: (Array[String]) -> Integer
|
|
322
359
|
def preload_environment: () -> void
|
|
323
360
|
def setup_load_path: () -> void
|
|
361
|
+
def suppress_simplecov!: () -> void
|
|
324
362
|
def subprocess_env: () -> Hash[String, String]
|
|
325
363
|
def cleanup_suite_process: (Integer?, untyped) -> void
|
|
326
364
|
def spec_files: () -> Array[String]
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: henitai
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.10
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Martin Otten
|
|
@@ -101,6 +101,8 @@ files:
|
|
|
101
101
|
- lib/henitai/git_diff_analyzer.rb
|
|
102
102
|
- lib/henitai/integration.rb
|
|
103
103
|
- lib/henitai/integration/rspec_process_runner.rb
|
|
104
|
+
- lib/henitai/minitest_coverage_hook.rb
|
|
105
|
+
- lib/henitai/minitest_coverage_reporter.rb
|
|
104
106
|
- lib/henitai/minitest_simplecov.rb
|
|
105
107
|
- lib/henitai/mutant.rb
|
|
106
108
|
- lib/henitai/mutant/activator.rb
|
|
@@ -129,6 +131,7 @@ files:
|
|
|
129
131
|
- lib/henitai/operators/update_operator.rb
|
|
130
132
|
- lib/henitai/parallel_execution_runner.rb
|
|
131
133
|
- lib/henitai/parser_current.rb
|
|
134
|
+
- lib/henitai/per_test_coverage_collector.rb
|
|
132
135
|
- lib/henitai/per_test_coverage_selector.rb
|
|
133
136
|
- lib/henitai/reporter.rb
|
|
134
137
|
- lib/henitai/result.rb
|
|
@@ -157,7 +160,7 @@ metadata:
|
|
|
157
160
|
changelog_uri: https://github.com/martinotten/henitai/blob/main/CHANGELOG.md
|
|
158
161
|
documentation_uri: https://github.com/martinotten/henitai/blob/main/README.md
|
|
159
162
|
homepage_uri: https://github.com/martinotten/henitai
|
|
160
|
-
source_code_uri: https://github.com/martinotten/henitai/tree/v0.1.
|
|
163
|
+
source_code_uri: https://github.com/martinotten/henitai/tree/v0.1.10
|
|
161
164
|
rubygems_mfa_required: 'true'
|
|
162
165
|
rdoc_options: []
|
|
163
166
|
require_paths:
|