henitai 0.1.1 → 0.1.3
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 +91 -1
- data/README.md +3 -1
- data/assets/schema/henitai.schema.json +0 -4
- data/lib/henitai/arid_node_filter.rb +3 -0
- data/lib/henitai/available_cpu_count.rb +79 -0
- data/lib/henitai/cli.rb +7 -4
- data/lib/henitai/configuration.rb +3 -5
- data/lib/henitai/configuration_validator.rb +1 -11
- data/lib/henitai/coverage_bootstrapper.rb +112 -8
- data/lib/henitai/coverage_formatter.rb +4 -4
- data/lib/henitai/coverage_report_reader.rb +67 -0
- data/lib/henitai/eager_load.rb +11 -0
- data/lib/henitai/equivalence_detector.rb +60 -1
- data/lib/henitai/execution_engine.rb +34 -22
- data/lib/henitai/integration/rspec_process_runner.rb +58 -0
- data/lib/henitai/integration.rb +222 -100
- data/lib/henitai/minitest_simplecov.rb +3 -0
- data/lib/henitai/mutant/activator.rb +78 -42
- data/lib/henitai/mutant.rb +3 -1
- data/lib/henitai/mutant_generator.rb +25 -48
- data/lib/henitai/operator.rb +6 -1
- data/lib/henitai/operators/assignment_expression.rb +7 -23
- data/lib/henitai/operators/conditional_expression.rb +1 -7
- data/lib/henitai/operators/method_chain_unwrap.rb +41 -0
- data/lib/henitai/operators/regex_mutator.rb +89 -0
- data/lib/henitai/operators/unary_operator.rb +36 -0
- data/lib/henitai/operators/update_operator.rb +70 -0
- data/lib/henitai/operators.rb +4 -0
- data/lib/henitai/parallel_execution_runner.rb +135 -0
- data/lib/henitai/per_test_coverage_selector.rb +60 -0
- data/lib/henitai/result.rb +16 -4
- data/lib/henitai/rspec_coverage_formatter.rb +10 -0
- data/lib/henitai/runner.rb +75 -11
- data/lib/henitai/source_parser.rb +12 -1
- data/lib/henitai/static_filter.rb +53 -38
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +3 -0
- data/sig/henitai.rbs +65 -11
- metadata +18 -4
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Runs pending mutants across worker threads with signal and stdin handling.
|
|
5
|
+
class ParallelExecutionRunner
|
|
6
|
+
ParallelExecutionContext = Struct.new(
|
|
7
|
+
:queue, :integration, :config, :progress_reporter,
|
|
8
|
+
:mutex, :state, :old_handlers, :stdin_watcher
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
def initialize(worker_count:)
|
|
12
|
+
@worker_count = worker_count
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run(mutants, integration, config, progress_reporter, options = {})
|
|
16
|
+
context = build_parallel_context(
|
|
17
|
+
mutants,
|
|
18
|
+
integration,
|
|
19
|
+
config,
|
|
20
|
+
progress_reporter
|
|
21
|
+
)
|
|
22
|
+
execute_parallel_execution(
|
|
23
|
+
context,
|
|
24
|
+
stdin_pipe: options.fetch(:stdin_pipe, false),
|
|
25
|
+
process_mutant: options.fetch(:process_mutant)
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def execute_parallel_execution(context, stdin_pipe:, process_mutant:)
|
|
30
|
+
install_parallel_signal_traps(context)
|
|
31
|
+
start_parallel_stdin_watcher(context, stdin_pipe)
|
|
32
|
+
parallel_workers(context, process_mutant).each(&:join)
|
|
33
|
+
ensure
|
|
34
|
+
stop_parallel_stdin_watcher(context)
|
|
35
|
+
restore_parallel_signal_traps(context)
|
|
36
|
+
raise context.state[:error] if context&.state&.fetch(:error, nil)
|
|
37
|
+
raise Interrupt if context&.state&.fetch(:stopping, false)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
attr_reader :worker_count
|
|
43
|
+
|
|
44
|
+
def build_parallel_queue(mutants)
|
|
45
|
+
Queue.new.tap { |queue| mutants.each { |mutant| queue << mutant } }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def build_parallel_context(mutants, integration, config, progress_reporter)
|
|
49
|
+
ParallelExecutionContext.new(
|
|
50
|
+
build_parallel_queue(mutants),
|
|
51
|
+
integration,
|
|
52
|
+
config,
|
|
53
|
+
progress_reporter,
|
|
54
|
+
Mutex.new,
|
|
55
|
+
{ stopping: false }
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def install_parallel_signal_traps(context)
|
|
60
|
+
context.old_handlers = {
|
|
61
|
+
int: trap(:INT) { stop_parallel_execution(context) },
|
|
62
|
+
term: trap(:TERM) { stop_parallel_execution(context) },
|
|
63
|
+
hup: trap(:HUP) { stop_parallel_execution(context) }
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def stop_parallel_execution(context)
|
|
68
|
+
context.state[:stopping] = true
|
|
69
|
+
context.queue.clear
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def start_parallel_stdin_watcher(context, stdin_pipe)
|
|
73
|
+
return unless stdin_pipe
|
|
74
|
+
# CI runners expose stdin as a non-interactive pipe, so EOF there should
|
|
75
|
+
# not be treated as a user disconnect.
|
|
76
|
+
return if ci_environment?
|
|
77
|
+
|
|
78
|
+
context.stdin_watcher = Thread.new do
|
|
79
|
+
$stdin.read
|
|
80
|
+
stop_parallel_execution(context)
|
|
81
|
+
rescue IOError, Errno::EBADF
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def parallel_workers(context, process_mutant)
|
|
87
|
+
Array.new(worker_count) { Thread.new { process_parallel_worker(context, process_mutant) } }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def process_parallel_worker(context, process_mutant)
|
|
91
|
+
loop do
|
|
92
|
+
break if context.state[:stopping]
|
|
93
|
+
|
|
94
|
+
process_mutant.call(
|
|
95
|
+
context.queue.pop(true),
|
|
96
|
+
context.integration,
|
|
97
|
+
context.config,
|
|
98
|
+
context.progress_reporter,
|
|
99
|
+
context.mutex
|
|
100
|
+
)
|
|
101
|
+
rescue ThreadError
|
|
102
|
+
break
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
record_parallel_error(context, e)
|
|
105
|
+
break
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def stop_parallel_stdin_watcher(context)
|
|
110
|
+
context&.stdin_watcher&.kill
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def restore_parallel_signal_traps(context)
|
|
114
|
+
handlers = context&.old_handlers
|
|
115
|
+
return unless handlers
|
|
116
|
+
|
|
117
|
+
trap(:INT, handlers[:int] || "DEFAULT")
|
|
118
|
+
trap(:TERM, handlers[:term] || "DEFAULT")
|
|
119
|
+
trap(:HUP, handlers[:hup] || "DEFAULT")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def record_parallel_error(context, error)
|
|
123
|
+
context.mutex.synchronize do
|
|
124
|
+
context.state[:error] ||= error
|
|
125
|
+
context.state[:stopping] = true
|
|
126
|
+
context.queue.clear
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def ci_environment?
|
|
131
|
+
value = ENV.fetch("CI", nil)
|
|
132
|
+
value && !%w[0 false].include?(value.downcase)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Narrows candidate test files using the per-test coverage report.
|
|
5
|
+
class PerTestCoverageSelector
|
|
6
|
+
def initialize(coverage_report_reader: CoverageReportReader.new)
|
|
7
|
+
@coverage_report_reader = coverage_report_reader
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def filter(tests, mutant, reports_dir:)
|
|
11
|
+
candidates = Array(tests)
|
|
12
|
+
return candidates if candidates.empty?
|
|
13
|
+
return candidates unless location_available?(mutant)
|
|
14
|
+
return candidates unless per_test_coverage_available?(reports_dir)
|
|
15
|
+
|
|
16
|
+
covered_tests = candidates.select do |test|
|
|
17
|
+
covers_mutant?(test, mutant, reports_dir)
|
|
18
|
+
end
|
|
19
|
+
covered_tests.empty? ? candidates : covered_tests
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def location_available?(mutant)
|
|
25
|
+
mutant.respond_to?(:location) &&
|
|
26
|
+
mutant.location.is_a?(Hash) &&
|
|
27
|
+
mutant.location[:file] &&
|
|
28
|
+
mutant.location[:start_line] &&
|
|
29
|
+
mutant.location[:end_line]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def covers_mutant?(test, mutant, reports_dir)
|
|
33
|
+
covered_lines = coverage_lines_for(test, mutant, reports_dir)
|
|
34
|
+
mutant_lines(mutant).any? { |line| covered_lines.include?(line) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def coverage_lines_for(test, mutant, reports_dir)
|
|
38
|
+
source_map = per_test_coverage(reports_dir)[test.to_s] || {}
|
|
39
|
+
Array(source_map[File.expand_path(mutant.location[:file])]).uniq
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def mutant_lines(mutant)
|
|
43
|
+
(mutant.location[:start_line]..mutant.location[:end_line]).to_a
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def per_test_coverage(reports_dir)
|
|
47
|
+
@per_test_coverage ||= {}
|
|
48
|
+
@per_test_coverage[reports_dir] ||= begin
|
|
49
|
+
path = File.join(reports_dir, "henitai_per_test.json")
|
|
50
|
+
coverage_report_reader.test_lines_by_file(path)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def per_test_coverage_available?(reports_dir)
|
|
55
|
+
!per_test_coverage(reports_dir).empty?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
attr_reader :coverage_report_reader
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/henitai/result.rb
CHANGED
|
@@ -11,13 +11,15 @@ module Henitai
|
|
|
11
11
|
include UnparseHelper
|
|
12
12
|
|
|
13
13
|
SCHEMA_VERSION = "1.0"
|
|
14
|
+
DEFAULT_THRESHOLDS = { high: 80, low: 60 }.freeze
|
|
14
15
|
|
|
15
|
-
attr_reader :mutants, :started_at, :finished_at
|
|
16
|
+
attr_reader :mutants, :started_at, :finished_at, :thresholds
|
|
16
17
|
|
|
17
|
-
def initialize(mutants:, started_at:, finished_at:)
|
|
18
|
+
def initialize(mutants:, started_at:, finished_at:, thresholds: nil)
|
|
18
19
|
@mutants = mutants
|
|
19
20
|
@started_at = started_at
|
|
20
21
|
@finished_at = finished_at
|
|
22
|
+
@thresholds = DEFAULT_THRESHOLDS.merge(thresholds || {})
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
# @return [Integer] number of killed mutants
|
|
@@ -88,7 +90,7 @@ module Henitai
|
|
|
88
90
|
def to_stryker_schema
|
|
89
91
|
{
|
|
90
92
|
schemaVersion: SCHEMA_VERSION,
|
|
91
|
-
thresholds:
|
|
93
|
+
thresholds: thresholds,
|
|
92
94
|
files: build_files_section
|
|
93
95
|
}
|
|
94
96
|
end
|
|
@@ -119,7 +121,17 @@ module Henitai
|
|
|
119
121
|
status: stryker_status(mutant.status),
|
|
120
122
|
description: mutant.description,
|
|
121
123
|
duration: duration_for(mutant)
|
|
122
|
-
}.compact
|
|
124
|
+
}.compact.merge(coverage_schema(mutant))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def coverage_schema(mutant)
|
|
128
|
+
covered_by = Array(mutant.covered_by).compact
|
|
129
|
+
return {} if covered_by.empty?
|
|
130
|
+
|
|
131
|
+
{
|
|
132
|
+
coveredBy: covered_by,
|
|
133
|
+
testsCompleted: mutant.tests_completed || covered_by.size
|
|
134
|
+
}
|
|
123
135
|
end
|
|
124
136
|
|
|
125
137
|
def replacement_for(mutant)
|
data/lib/henitai/runner.rb
CHANGED
|
@@ -35,18 +35,24 @@ module Henitai
|
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
# Entry point — runs the full pipeline and returns a Result.
|
|
38
|
+
#
|
|
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.
|
|
43
|
+
#
|
|
44
|
+
# For targeted runs (`subjects:` provided), the bootstrap is further scoped
|
|
45
|
+
# to the spec files that cover the requested subjects rather than the full
|
|
46
|
+
# suite, reducing the baseline run time proportionally.
|
|
47
|
+
#
|
|
38
48
|
# @return [Result]
|
|
39
49
|
def run
|
|
40
50
|
started_at = Time.now
|
|
41
51
|
source_files = self.source_files
|
|
42
|
-
bootstrap_coverage(source_files)
|
|
43
52
|
subjects = resolve_subjects(source_files)
|
|
44
|
-
mutants =
|
|
45
|
-
mutants = filter_mutants(mutants)
|
|
46
|
-
mutants = execute_mutants(mutants)
|
|
47
|
-
finished_at = Time.now
|
|
53
|
+
mutants = execute_mutants(mutants_for(subjects, source_files))
|
|
48
54
|
|
|
49
|
-
build_result(mutants, started_at,
|
|
55
|
+
build_result(mutants, started_at, Time.now)
|
|
50
56
|
end
|
|
51
57
|
|
|
52
58
|
private
|
|
@@ -69,6 +75,29 @@ module Henitai
|
|
|
69
75
|
static_filter.apply(mutants, config)
|
|
70
76
|
end
|
|
71
77
|
|
|
78
|
+
def mutants_for(subjects, source_files)
|
|
79
|
+
bootstrap_thread = bootstrap_mutants(source_files, subjects)
|
|
80
|
+
mutants = generate_mutants(subjects)
|
|
81
|
+
bootstrap_thread.value
|
|
82
|
+
|
|
83
|
+
filtered_mutants = filter_mutants(mutants)
|
|
84
|
+
return filtered_mutants unless targeted_run?
|
|
85
|
+
|
|
86
|
+
refresh_coverage_for_targeted_run(filtered_mutants, source_files)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def refresh_coverage_for_targeted_run(mutants, source_files)
|
|
90
|
+
return mutants unless retry_full_bootstrap?(mutants)
|
|
91
|
+
|
|
92
|
+
bootstrap_coverage(source_files)
|
|
93
|
+
filter_mutants(mutants)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def bootstrap_mutants(source_files, subjects)
|
|
97
|
+
scoped_tests = scoped_bootstrap_test_files(subjects)
|
|
98
|
+
Thread.new { bootstrap_coverage(source_files, scoped_tests) }
|
|
99
|
+
end
|
|
100
|
+
|
|
72
101
|
def execute_mutants(mutants)
|
|
73
102
|
execution_engine.run(
|
|
74
103
|
mutants,
|
|
@@ -94,13 +123,33 @@ module Henitai
|
|
|
94
123
|
@result = Result.new(
|
|
95
124
|
mutants:,
|
|
96
125
|
started_at:,
|
|
97
|
-
finished_at
|
|
126
|
+
finished_at:,
|
|
127
|
+
thresholds: result_thresholds
|
|
98
128
|
)
|
|
99
129
|
persist_history(@result, finished_at)
|
|
100
130
|
report(@result)
|
|
101
131
|
@result
|
|
102
132
|
end
|
|
103
133
|
|
|
134
|
+
# Returns the spec files to use for the coverage bootstrap.
|
|
135
|
+
#
|
|
136
|
+
# For full runs (no subject pattern given), returns nil so the bootstrapper
|
|
137
|
+
# falls back to the integration's full test-file list.
|
|
138
|
+
#
|
|
139
|
+
# For targeted runs, returns the union of test files selected for each
|
|
140
|
+
# resolved subject. Falls back to nil (all tests) if the selection is empty,
|
|
141
|
+
# so the bootstrapper always has a non-empty file list.
|
|
142
|
+
def scoped_bootstrap_test_files(subjects)
|
|
143
|
+
return nil if pattern_subjects.empty?
|
|
144
|
+
|
|
145
|
+
files = subjects.flat_map { |subject| integration.select_tests(subject) }.uniq
|
|
146
|
+
files.empty? ? nil : files
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def bootstrap_coverage(source_files, test_files = nil)
|
|
150
|
+
coverage_bootstrapper.ensure!(source_files:, config:, integration:, test_files:)
|
|
151
|
+
end
|
|
152
|
+
|
|
104
153
|
def subject_resolver
|
|
105
154
|
@subject_resolver ||= SubjectResolver.new
|
|
106
155
|
end
|
|
@@ -125,10 +174,6 @@ module Henitai
|
|
|
125
174
|
@coverage_bootstrapper ||= CoverageBootstrapper.new
|
|
126
175
|
end
|
|
127
176
|
|
|
128
|
-
def bootstrap_coverage(source_files)
|
|
129
|
-
coverage_bootstrapper.ensure!(source_files:, config:, integration:)
|
|
130
|
-
end
|
|
131
|
-
|
|
132
177
|
def integration
|
|
133
178
|
@integration ||= Integration.for(config.integration).new
|
|
134
179
|
end
|
|
@@ -172,6 +217,19 @@ module Henitai
|
|
|
172
217
|
Array(@subjects)
|
|
173
218
|
end
|
|
174
219
|
|
|
220
|
+
def targeted_run?
|
|
221
|
+
!pattern_subjects.empty?
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def retry_full_bootstrap?(mutants)
|
|
225
|
+
executable_mutants = Array(mutants).reject do |mutant|
|
|
226
|
+
%i[ignored compile_error equivalent].include?(mutant.status)
|
|
227
|
+
end
|
|
228
|
+
return false if executable_mutants.empty?
|
|
229
|
+
|
|
230
|
+
executable_mutants.all? { |mutant| mutant.status == :no_coverage }
|
|
231
|
+
end
|
|
232
|
+
|
|
175
233
|
def unique_subjects(subjects)
|
|
176
234
|
subjects.uniq { |subject| [subject.expression, subject.source_file] }
|
|
177
235
|
end
|
|
@@ -179,5 +237,11 @@ module Henitai
|
|
|
179
237
|
def normalize_path(path)
|
|
180
238
|
File.expand_path(path)
|
|
181
239
|
end
|
|
240
|
+
|
|
241
|
+
def result_thresholds
|
|
242
|
+
return nil unless config.respond_to?(:thresholds)
|
|
243
|
+
|
|
244
|
+
config.thresholds
|
|
245
|
+
end
|
|
182
246
|
end
|
|
183
247
|
end
|
|
@@ -12,12 +12,23 @@ module Henitai
|
|
|
12
12
|
class SourceParser
|
|
13
13
|
DEFAULT_PATH = "(string)"
|
|
14
14
|
|
|
15
|
+
@cache = {}
|
|
16
|
+
|
|
15
17
|
def self.parse(source, path: DEFAULT_PATH)
|
|
16
18
|
new.parse(source, path:)
|
|
17
19
|
end
|
|
18
20
|
|
|
21
|
+
# Returns the parsed AST for +path+, re-using a cached result when the
|
|
22
|
+
# file's mtime has not changed. This avoids parsing the same file twice
|
|
23
|
+
# across pipeline phases (e.g. SubjectResolver then MutantGenerator).
|
|
19
24
|
def self.parse_file(path)
|
|
20
|
-
|
|
25
|
+
key = [path, File.mtime(path)]
|
|
26
|
+
@cache[key] ||= new.parse_file(path)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Clears the parse cache. Intended for test isolation.
|
|
30
|
+
def self.clear_cache!
|
|
31
|
+
@cache.clear
|
|
21
32
|
end
|
|
22
33
|
|
|
23
34
|
def parse(source, path: DEFAULT_PATH)
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative "coverage_report_reader"
|
|
4
4
|
|
|
5
5
|
module Henitai
|
|
6
6
|
# Applies static, pre-execution filtering to generated mutants.
|
|
7
7
|
class StaticFilter
|
|
8
|
-
DEFAULT_COVERAGE_REPORT_PATH =
|
|
9
|
-
DEFAULT_PER_TEST_COVERAGE_REPORT_PATH =
|
|
8
|
+
DEFAULT_COVERAGE_REPORT_PATH = CoverageReportReader::DEFAULT_COVERAGE_REPORT_PATH
|
|
9
|
+
DEFAULT_PER_TEST_COVERAGE_REPORT_PATH = CoverageReportReader::DEFAULT_PER_TEST_COVERAGE_REPORT_PATH
|
|
10
|
+
|
|
11
|
+
def initialize(coverage_report_reader: CoverageReportReader.new)
|
|
12
|
+
@coverage_report_reader = coverage_report_reader
|
|
13
|
+
end
|
|
10
14
|
|
|
11
15
|
# This method is the gate-level filter orchestrator.
|
|
12
16
|
def apply(mutants, config)
|
|
@@ -32,6 +36,7 @@ module Henitai
|
|
|
32
36
|
per_test_coverage_report_path = per_test_coverage_report_path(config)
|
|
33
37
|
|
|
34
38
|
coverage_lines = coverage_lines_by_file(coverage_report_path)
|
|
39
|
+
coverage_lines = merge_method_coverage(coverage_lines, coverage_report_path)
|
|
35
40
|
return coverage_lines unless coverage_lines.empty?
|
|
36
41
|
|
|
37
42
|
coverage_lines_from_test_lines(
|
|
@@ -40,31 +45,17 @@ module Henitai
|
|
|
40
45
|
end
|
|
41
46
|
|
|
42
47
|
def coverage_lines_by_file(path = DEFAULT_COVERAGE_REPORT_PATH)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
coverage = Hash.new { |hash, key| hash[key] = [] }
|
|
46
|
-
JSON.parse(File.read(path)).each_value do |result|
|
|
47
|
-
result.fetch("coverage", {}).each do |file, file_coverage|
|
|
48
|
-
coverage[normalize_path(file)].concat(covered_lines(file_coverage))
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
coverage.transform_values(&:uniq).transform_values(&:sort)
|
|
48
|
+
coverage_report_reader.coverage_lines_by_file(path)
|
|
53
49
|
end
|
|
54
50
|
|
|
55
51
|
def test_lines_by_file(path = DEFAULT_PER_TEST_COVERAGE_REPORT_PATH)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
parsed = JSON.parse(File.read(path))
|
|
59
|
-
return {} unless parsed.is_a?(Hash)
|
|
60
|
-
|
|
61
|
-
parsed.transform_values do |coverage|
|
|
62
|
-
normalize_test_coverage(coverage)
|
|
63
|
-
end
|
|
52
|
+
coverage_report_reader.test_lines_by_file(path)
|
|
64
53
|
end
|
|
65
54
|
|
|
66
55
|
private
|
|
67
56
|
|
|
57
|
+
attr_reader :coverage_report_reader
|
|
58
|
+
|
|
68
59
|
def ignored?(mutant, config)
|
|
69
60
|
source = source_for(mutant)
|
|
70
61
|
return false unless source
|
|
@@ -97,9 +88,10 @@ module Henitai
|
|
|
97
88
|
|
|
98
89
|
def covered?(mutant, coverage_lines)
|
|
99
90
|
file = normalize_path(mutant.location[:file])
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
91
|
+
covered = Array(coverage_lines[file])
|
|
92
|
+
(mutant.location[:start_line]..mutant.location[:end_line]).any? do |line|
|
|
93
|
+
covered.include?(line)
|
|
94
|
+
end
|
|
103
95
|
end
|
|
104
96
|
|
|
105
97
|
def source_for(mutant)
|
|
@@ -115,23 +107,40 @@ module Henitai
|
|
|
115
107
|
@compiled_ignore_patterns[patterns] ||= patterns.map { |pattern| Regexp.new(pattern) }
|
|
116
108
|
end
|
|
117
109
|
|
|
118
|
-
def
|
|
119
|
-
|
|
120
|
-
|
|
110
|
+
def merge_method_coverage(coverage_lines, path)
|
|
111
|
+
return coverage_lines unless File.exist?(path)
|
|
112
|
+
|
|
113
|
+
JSON.parse(File.read(path)).each_value do |suite|
|
|
114
|
+
suite.fetch("coverage", {}).each do |file, file_coverage|
|
|
115
|
+
merge_file_method_coverage(coverage_lines, file, file_coverage)
|
|
116
|
+
end
|
|
121
117
|
end
|
|
118
|
+
|
|
119
|
+
coverage_lines.transform_values(&:sort)
|
|
122
120
|
end
|
|
123
121
|
|
|
124
|
-
def
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
122
|
+
def merge_file_method_coverage(coverage_lines, file, file_coverage)
|
|
123
|
+
methods = file_coverage["methods"]
|
|
124
|
+
return unless methods.is_a?(Hash)
|
|
125
|
+
|
|
126
|
+
normalized = normalize_path(file)
|
|
127
|
+
methods.each do |key, count|
|
|
128
|
+
next unless count.to_i.positive?
|
|
129
|
+
|
|
130
|
+
range = method_line_range(key)
|
|
131
|
+
next unless range
|
|
132
|
+
|
|
133
|
+
coverage_lines[normalized] = Array(coverage_lines[normalized]) | range.to_a
|
|
132
134
|
end
|
|
133
135
|
end
|
|
134
136
|
|
|
137
|
+
def method_line_range(key)
|
|
138
|
+
m = key.match(/(\d+), \d+, (\d+), \d+\]\z/)
|
|
139
|
+
return unless m
|
|
140
|
+
|
|
141
|
+
(m.captures.first.to_i..m.captures.last.to_i)
|
|
142
|
+
end
|
|
143
|
+
|
|
135
144
|
def coverage_lines_from_test_lines(test_lines)
|
|
136
145
|
coverage = Hash.new { |hash, key| hash[key] = [] }
|
|
137
146
|
|
|
@@ -147,10 +156,16 @@ module Henitai
|
|
|
147
156
|
end
|
|
148
157
|
|
|
149
158
|
def normalize_path(path)
|
|
159
|
+
@normalize_path_cache ||= {}
|
|
160
|
+
return @normalize_path_cache[path] if @normalize_path_cache.key?(path)
|
|
161
|
+
|
|
150
162
|
expanded = File.expand_path(path)
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
163
|
+
resolved = begin
|
|
164
|
+
File.realpath(expanded)
|
|
165
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
166
|
+
expanded
|
|
167
|
+
end
|
|
168
|
+
@normalize_path_cache[path] = resolved
|
|
154
169
|
end
|
|
155
170
|
|
|
156
171
|
def equivalence_detector
|
data/lib/henitai/version.rb
CHANGED
data/lib/henitai.rb
CHANGED
|
@@ -22,6 +22,8 @@ module Henitai
|
|
|
22
22
|
|
|
23
23
|
autoload :Configuration, "henitai/configuration"
|
|
24
24
|
autoload :CoverageBootstrapper, "henitai/coverage_bootstrapper"
|
|
25
|
+
autoload :CoverageReportReader, "henitai/coverage_report_reader"
|
|
26
|
+
autoload :PerTestCoverageSelector, "henitai/per_test_coverage_selector"
|
|
25
27
|
autoload :Subject, "henitai/subject"
|
|
26
28
|
autoload :Mutant, "henitai/mutant"
|
|
27
29
|
autoload :Operator, "henitai/operator"
|
|
@@ -33,6 +35,7 @@ module Henitai
|
|
|
33
35
|
autoload :MutantGenerator, "henitai/mutant_generator"
|
|
34
36
|
autoload :MutantHistoryStore, "henitai/mutant_history_store"
|
|
35
37
|
autoload :AridNodeFilter, "henitai/arid_node_filter"
|
|
38
|
+
autoload :AvailableCpuCount, "henitai/available_cpu_count"
|
|
36
39
|
autoload :EquivalenceDetector, "henitai/equivalence_detector"
|
|
37
40
|
autoload :StaticFilter, "henitai/static_filter"
|
|
38
41
|
autoload :StillbornFilter, "henitai/stillborn_filter"
|