henitai 0.1.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 +7 -0
- data/CHANGELOG.md +19 -0
- data/LICENSE +21 -0
- data/README.md +182 -0
- data/assets/schema/henitai.schema.json +123 -0
- data/exe/henitai +6 -0
- data/lib/henitai/arid_node_filter.rb +97 -0
- data/lib/henitai/cli.rb +341 -0
- data/lib/henitai/configuration.rb +132 -0
- data/lib/henitai/configuration_validator.rb +293 -0
- data/lib/henitai/coverage_bootstrapper.rb +75 -0
- data/lib/henitai/coverage_formatter.rb +112 -0
- data/lib/henitai/equivalence_detector.rb +85 -0
- data/lib/henitai/execution_engine.rb +174 -0
- data/lib/henitai/git_diff_analyzer.rb +82 -0
- data/lib/henitai/integration.rb +417 -0
- data/lib/henitai/mutant/activator.rb +234 -0
- data/lib/henitai/mutant.rb +68 -0
- data/lib/henitai/mutant_generator.rb +158 -0
- data/lib/henitai/mutant_history_store.rb +279 -0
- data/lib/henitai/operator.rb +96 -0
- data/lib/henitai/operators/arithmetic_operator.rb +46 -0
- data/lib/henitai/operators/array_declaration.rb +52 -0
- data/lib/henitai/operators/assignment_expression.rb +78 -0
- data/lib/henitai/operators/block_statement.rb +31 -0
- data/lib/henitai/operators/boolean_literal.rb +70 -0
- data/lib/henitai/operators/conditional_expression.rb +184 -0
- data/lib/henitai/operators/equality_operator.rb +41 -0
- data/lib/henitai/operators/hash_literal.rb +66 -0
- data/lib/henitai/operators/logical_operator.rb +84 -0
- data/lib/henitai/operators/method_expression.rb +56 -0
- data/lib/henitai/operators/pattern_match.rb +66 -0
- data/lib/henitai/operators/range_literal.rb +40 -0
- data/lib/henitai/operators/return_value.rb +105 -0
- data/lib/henitai/operators/safe_navigation.rb +34 -0
- data/lib/henitai/operators/string_literal.rb +64 -0
- data/lib/henitai/operators.rb +25 -0
- data/lib/henitai/parser_current.rb +7 -0
- data/lib/henitai/reporter.rb +432 -0
- data/lib/henitai/result.rb +170 -0
- data/lib/henitai/runner.rb +183 -0
- data/lib/henitai/sampling_strategy.rb +33 -0
- data/lib/henitai/scenario_execution_result.rb +71 -0
- data/lib/henitai/source_parser.rb +41 -0
- data/lib/henitai/static_filter.rb +186 -0
- data/lib/henitai/stillborn_filter.rb +34 -0
- data/lib/henitai/subject.rb +71 -0
- data/lib/henitai/subject_resolver.rb +232 -0
- data/lib/henitai/syntax_validator.rb +16 -0
- data/lib/henitai/test_prioritizer.rb +55 -0
- data/lib/henitai/unparse_helper.rb +24 -0
- data/lib/henitai/version.rb +5 -0
- data/lib/henitai/warning_silencer.rb +16 -0
- data/lib/henitai.rb +51 -0
- data/sig/configuration_validator.rbs +29 -0
- data/sig/henitai.rbs +594 -0
- data/sig/unparser.rbs +3 -0
- metadata +153 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Orchestrates the full mutation testing pipeline.
|
|
5
|
+
#
|
|
6
|
+
# Pipeline phases (Phase-Gate model):
|
|
7
|
+
#
|
|
8
|
+
# Gate 1 — Subject selection
|
|
9
|
+
# Resolve source files from includes, apply --since filter (incremental),
|
|
10
|
+
# build Subject list from AST.
|
|
11
|
+
#
|
|
12
|
+
# Gate 2 — Mutant generation
|
|
13
|
+
# Apply operators to each Subject's AST. Filter arid (non-productive)
|
|
14
|
+
# nodes via ignore_patterns. Produces the initial mutant list.
|
|
15
|
+
#
|
|
16
|
+
# Gate 3 — Static filtering
|
|
17
|
+
# Remove ignored mutants (pattern matches), compile-time errors.
|
|
18
|
+
# Apply per-test coverage data: mark :no_coverage for uncovered mutants.
|
|
19
|
+
#
|
|
20
|
+
# Gate 4 — Mutant execution
|
|
21
|
+
# Run surviving mutants in isolated child processes (fork isolation).
|
|
22
|
+
# Each child process loads the test suite with the mutated method
|
|
23
|
+
# injected via Module#define_method. Collect kill/survive/timeout results.
|
|
24
|
+
#
|
|
25
|
+
# Gate 5 — Reporting
|
|
26
|
+
# Write results to configured reporters (terminal, html, json, dashboard).
|
|
27
|
+
#
|
|
28
|
+
class Runner
|
|
29
|
+
attr_reader :config, :result
|
|
30
|
+
|
|
31
|
+
def initialize(config: Configuration.load, subjects: nil, since: nil)
|
|
32
|
+
@config = config
|
|
33
|
+
@subjects = subjects
|
|
34
|
+
@since = since
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Entry point — runs the full pipeline and returns a Result.
|
|
38
|
+
# @return [Result]
|
|
39
|
+
def run
|
|
40
|
+
started_at = Time.now
|
|
41
|
+
source_files = self.source_files
|
|
42
|
+
bootstrap_coverage(source_files)
|
|
43
|
+
subjects = resolve_subjects(source_files)
|
|
44
|
+
mutants = generate_mutants(subjects)
|
|
45
|
+
mutants = filter_mutants(mutants)
|
|
46
|
+
mutants = execute_mutants(mutants)
|
|
47
|
+
finished_at = Time.now
|
|
48
|
+
|
|
49
|
+
build_result(mutants, started_at, finished_at)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def resolve_subjects(source_files = self.source_files)
|
|
55
|
+
subjects = subject_resolver.resolve_from_files(source_files)
|
|
56
|
+
return subjects if pattern_subjects.empty?
|
|
57
|
+
|
|
58
|
+
selected_subjects = pattern_subjects.flat_map do |pattern|
|
|
59
|
+
subject_resolver.apply_pattern(subjects, pattern.expression)
|
|
60
|
+
end
|
|
61
|
+
unique_subjects(selected_subjects)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def generate_mutants(subjects)
|
|
65
|
+
mutant_generator.generate(subjects, operators, config:)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def filter_mutants(mutants)
|
|
69
|
+
static_filter.apply(mutants, config)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def execute_mutants(mutants)
|
|
73
|
+
execution_engine.run(
|
|
74
|
+
mutants,
|
|
75
|
+
integration,
|
|
76
|
+
config,
|
|
77
|
+
progress_reporter: progress_reporter
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def report(result)
|
|
82
|
+
Reporter.run_all(names: config.reporters, result:, config:)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def persist_history(result, recorded_at)
|
|
86
|
+
history_store.record(
|
|
87
|
+
result,
|
|
88
|
+
version: Henitai::VERSION,
|
|
89
|
+
recorded_at:
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def build_result(mutants, started_at, finished_at)
|
|
94
|
+
@result = Result.new(
|
|
95
|
+
mutants:,
|
|
96
|
+
started_at:,
|
|
97
|
+
finished_at:
|
|
98
|
+
)
|
|
99
|
+
persist_history(@result, finished_at)
|
|
100
|
+
report(@result)
|
|
101
|
+
@result
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def subject_resolver
|
|
105
|
+
@subject_resolver ||= SubjectResolver.new
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def git_diff_analyzer
|
|
109
|
+
@git_diff_analyzer ||= GitDiffAnalyzer.new
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def mutant_generator
|
|
113
|
+
@mutant_generator ||= MutantGenerator.new
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def static_filter
|
|
117
|
+
@static_filter ||= StaticFilter.new
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def execution_engine
|
|
121
|
+
@execution_engine ||= ExecutionEngine.new
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def coverage_bootstrapper
|
|
125
|
+
@coverage_bootstrapper ||= CoverageBootstrapper.new
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def bootstrap_coverage(source_files)
|
|
129
|
+
coverage_bootstrapper.ensure!(source_files:, config:, integration:)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def integration
|
|
133
|
+
@integration ||= Integration.for(config.integration).new
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def operators
|
|
137
|
+
@operators ||= Operator.for_set(config.operators)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def progress_reporter
|
|
141
|
+
return nil unless Array(config.reporters).map(&:to_s).include?("terminal")
|
|
142
|
+
|
|
143
|
+
Reporter::Terminal.new(config:)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def history_store
|
|
147
|
+
@history_store ||= MutantHistoryStore.new(
|
|
148
|
+
path: File.join(config.reports_dir, Henitai::HISTORY_STORE_FILENAME)
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def source_files
|
|
153
|
+
@source_files ||= begin
|
|
154
|
+
included_files = Array(config.includes).flat_map do |include_path|
|
|
155
|
+
Dir.glob(File.join(include_path, "**", "*.rb"))
|
|
156
|
+
end.uniq
|
|
157
|
+
|
|
158
|
+
if @since
|
|
159
|
+
changed_files = git_diff_analyzer.changed_files(from: @since, to: "HEAD")
|
|
160
|
+
changed_file_set = changed_files.map { |path| normalize_path(path) }
|
|
161
|
+
|
|
162
|
+
included_files.select do |path|
|
|
163
|
+
changed_file_set.include?(normalize_path(path))
|
|
164
|
+
end
|
|
165
|
+
else
|
|
166
|
+
included_files
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def pattern_subjects
|
|
172
|
+
Array(@subjects)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def unique_subjects(subjects)
|
|
176
|
+
subjects.uniq { |subject| [subject.expression, subject.source_file] }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def normalize_path(path)
|
|
180
|
+
File.expand_path(path)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Samples mutants in a strategy-aware, deterministic way.
|
|
5
|
+
class SamplingStrategy
|
|
6
|
+
def sample(mutants, ratio:, strategy: :stratified)
|
|
7
|
+
strategy = strategy.to_sym if strategy.respond_to?(:to_sym)
|
|
8
|
+
|
|
9
|
+
case strategy
|
|
10
|
+
when :stratified
|
|
11
|
+
stratified_sample(Array(mutants), ratio:)
|
|
12
|
+
else
|
|
13
|
+
raise ArgumentError, "Unsupported sampling strategy: #{strategy}"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def stratified_sample(mutants, ratio:)
|
|
20
|
+
return [] if ratio.to_f <= 0.0
|
|
21
|
+
|
|
22
|
+
mutants.group_by { |mutant| mutant.subject.expression }.flat_map do |_subject, group|
|
|
23
|
+
group.take(sample_count(group.size, ratio))
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def sample_count(size, ratio)
|
|
28
|
+
count = (size * ratio).ceil
|
|
29
|
+
count = 1 if count < 1
|
|
30
|
+
[count, size].min
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Captures the result of one baseline or mutant test run.
|
|
5
|
+
class ScenarioExecutionResult
|
|
6
|
+
attr_reader :status, :stdout, :stderr, :exit_status, :log_path
|
|
7
|
+
|
|
8
|
+
def initialize(status:, stdout:, stderr:, log_path:, exit_status: nil)
|
|
9
|
+
@status = status
|
|
10
|
+
@stdout = stdout.to_s
|
|
11
|
+
@stderr = stderr.to_s
|
|
12
|
+
@log_path = log_path
|
|
13
|
+
@exit_status = exit_status
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def survived?
|
|
17
|
+
status == :survived
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def killed?
|
|
21
|
+
status == :killed
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def timeout?
|
|
25
|
+
status == :timeout
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def ==(other)
|
|
29
|
+
return status == other.status if other.respond_to?(:status)
|
|
30
|
+
return status == other if other.is_a?(Symbol)
|
|
31
|
+
|
|
32
|
+
super
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def log_text
|
|
36
|
+
@log_text ||= if File.exist?(log_path)
|
|
37
|
+
File.read(log_path)
|
|
38
|
+
else
|
|
39
|
+
combined_output
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def combined_output
|
|
44
|
+
[
|
|
45
|
+
(stdout.empty? ? nil : stream_section("stdout", stdout)),
|
|
46
|
+
(stderr.empty? ? nil : stream_section("stderr", stderr))
|
|
47
|
+
].compact.join("\n")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def tail(lines = 12)
|
|
51
|
+
log_text.lines.last(lines).join
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def should_show_logs?(all_logs: false)
|
|
55
|
+
all_logs || timeout?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def failure_tail(all_logs: false, lines: 12)
|
|
59
|
+
return combined_output if all_logs
|
|
60
|
+
return "" unless should_show_logs?(all_logs:)
|
|
61
|
+
|
|
62
|
+
tail(lines)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def stream_section(name, content)
|
|
68
|
+
"#{name}:\n#{content}"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "parser/source/buffer"
|
|
4
|
+
require "prism"
|
|
5
|
+
|
|
6
|
+
module Henitai
|
|
7
|
+
# Parses Ruby source into parser-compatible AST nodes using Prism.
|
|
8
|
+
#
|
|
9
|
+
# The parser translation layer keeps the `Parser::AST::Node` shape that the
|
|
10
|
+
# mutation pipeline and Unparser already expect, while delegating syntax
|
|
11
|
+
# support to Prism.
|
|
12
|
+
class SourceParser
|
|
13
|
+
DEFAULT_PATH = "(string)"
|
|
14
|
+
|
|
15
|
+
def self.parse(source, path: DEFAULT_PATH)
|
|
16
|
+
new.parse(source, path:)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.parse_file(path)
|
|
20
|
+
new.parse_file(path)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def parse(source, path: DEFAULT_PATH)
|
|
24
|
+
Prism::Translation::ParserCurrent.new.parse(source_buffer(source, path))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def parse_file(path)
|
|
28
|
+
# Ruby's file encoding rules apply here. Projects that use explicit source
|
|
29
|
+
# encoding comments can be handled by a future encoding-aware option.
|
|
30
|
+
parse(File.read(path), path:)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def source_buffer(source, path)
|
|
36
|
+
Parser::Source::Buffer.new(path).tap do |buffer|
|
|
37
|
+
buffer.source = source
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
# Applies static, pre-execution filtering to generated mutants.
|
|
7
|
+
class StaticFilter
|
|
8
|
+
DEFAULT_COVERAGE_REPORT_PATH = "coverage/.resultset.json"
|
|
9
|
+
DEFAULT_PER_TEST_COVERAGE_REPORT_PATH = "coverage/henitai_per_test.json"
|
|
10
|
+
|
|
11
|
+
# This method is the gate-level filter orchestrator.
|
|
12
|
+
def apply(mutants, config)
|
|
13
|
+
coverage_lines = coverage_lines_for(config)
|
|
14
|
+
coverage_report_present = coverage_report_present?(config)
|
|
15
|
+
|
|
16
|
+
Array(mutants).each do |mutant|
|
|
17
|
+
next if ignored_mutant?(mutant, config)
|
|
18
|
+
|
|
19
|
+
mark_equivalent_mutant(mutant)
|
|
20
|
+
mark_no_coverage_mutant(
|
|
21
|
+
mutant,
|
|
22
|
+
coverage_report_present:,
|
|
23
|
+
coverage_lines:
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
mutants
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def coverage_lines_for(config)
|
|
31
|
+
coverage_report_path = coverage_report_path(config)
|
|
32
|
+
per_test_coverage_report_path = per_test_coverage_report_path(config)
|
|
33
|
+
|
|
34
|
+
coverage_lines = coverage_lines_by_file(coverage_report_path)
|
|
35
|
+
return coverage_lines unless coverage_lines.empty?
|
|
36
|
+
|
|
37
|
+
coverage_lines_from_test_lines(
|
|
38
|
+
test_lines_by_file(per_test_coverage_report_path)
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def coverage_lines_by_file(path = DEFAULT_COVERAGE_REPORT_PATH)
|
|
43
|
+
return {} unless File.exist?(path)
|
|
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)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def test_lines_by_file(path = DEFAULT_PER_TEST_COVERAGE_REPORT_PATH)
|
|
56
|
+
return {} unless File.exist?(path)
|
|
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
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def ignored?(mutant, config)
|
|
69
|
+
source = source_for(mutant)
|
|
70
|
+
return false unless source
|
|
71
|
+
|
|
72
|
+
compiled_ignore_patterns(config).any? do |pattern|
|
|
73
|
+
pattern.match?(source)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def ignored_mutant?(mutant, config)
|
|
78
|
+
return false unless ignored?(mutant, config)
|
|
79
|
+
|
|
80
|
+
mutant.status = :ignored
|
|
81
|
+
true
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def mark_equivalent_mutant(mutant)
|
|
85
|
+
return unless mutant.pending?
|
|
86
|
+
|
|
87
|
+
equivalence_detector.analyze(mutant)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def mark_no_coverage_mutant(mutant, coverage_report_present:, coverage_lines:)
|
|
91
|
+
return unless coverage_report_present
|
|
92
|
+
return unless mutant.pending?
|
|
93
|
+
return if covered?(mutant, coverage_lines)
|
|
94
|
+
|
|
95
|
+
mutant.status = :no_coverage
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def covered?(mutant, coverage_lines)
|
|
99
|
+
file = normalize_path(mutant.location[:file])
|
|
100
|
+
start_line = mutant.location[:start_line]
|
|
101
|
+
|
|
102
|
+
Array(coverage_lines[file]).include?(start_line)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def source_for(mutant)
|
|
106
|
+
original_node = mutant.original_node
|
|
107
|
+
location = original_node&.location
|
|
108
|
+
expression = location&.expression
|
|
109
|
+
expression&.source
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def compiled_ignore_patterns(config)
|
|
113
|
+
patterns = Array(config&.ignore_patterns).dup.freeze
|
|
114
|
+
@compiled_ignore_patterns ||= {}
|
|
115
|
+
@compiled_ignore_patterns[patterns] ||= patterns.map { |pattern| Regexp.new(pattern) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def covered_lines(file_coverage)
|
|
119
|
+
Array(file_coverage["lines"]).each_with_index.filter_map do |count, index|
|
|
120
|
+
index + 1 if count.to_i.positive?
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def normalize_test_coverage(coverage)
|
|
125
|
+
case coverage
|
|
126
|
+
when Hash
|
|
127
|
+
coverage.transform_values do |lines|
|
|
128
|
+
Array(lines).grep(Integer).uniq.sort
|
|
129
|
+
end
|
|
130
|
+
else
|
|
131
|
+
Array(coverage).grep(Integer).uniq.sort
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def coverage_lines_from_test_lines(test_lines)
|
|
136
|
+
coverage = Hash.new { |hash, key| hash[key] = [] }
|
|
137
|
+
|
|
138
|
+
test_lines.each_value do |source_coverage|
|
|
139
|
+
next unless source_coverage.is_a?(Hash)
|
|
140
|
+
|
|
141
|
+
source_coverage.each do |source_file, lines|
|
|
142
|
+
coverage[normalize_path(source_file)].concat(Array(lines).grep(Integer))
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
coverage.transform_values(&:uniq).transform_values(&:sort)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def normalize_path(path)
|
|
150
|
+
File.expand_path(path)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def equivalence_detector
|
|
154
|
+
@equivalence_detector ||= EquivalenceDetector.new
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def coverage_report_path(config)
|
|
158
|
+
File.join(coverage_dir_for(config), ".resultset.json")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def coverage_report_present?(config)
|
|
162
|
+
coverage_report_path = coverage_report_path(config)
|
|
163
|
+
per_test_coverage_report_path = per_test_coverage_report_path(config)
|
|
164
|
+
|
|
165
|
+
File.exist?(coverage_report_path) || File.exist?(per_test_coverage_report_path)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def per_test_coverage_report_path(config)
|
|
169
|
+
reports_dir = reports_dir_for(config)
|
|
170
|
+
File.join(reports_dir, File.basename(DEFAULT_PER_TEST_COVERAGE_REPORT_PATH))
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def coverage_dir_for(config)
|
|
174
|
+
return "coverage" unless config.respond_to?(:reports_dir)
|
|
175
|
+
return "coverage" if config.reports_dir.nil? || config.reports_dir.empty?
|
|
176
|
+
|
|
177
|
+
File.join(config.reports_dir, "coverage")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def reports_dir_for(config)
|
|
181
|
+
return "coverage" unless config.respond_to?(:reports_dir)
|
|
182
|
+
|
|
183
|
+
config.reports_dir || "coverage"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "unparser"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
# Suppresses mutants that do not produce syntactically valid Ruby.
|
|
7
|
+
class StillbornFilter
|
|
8
|
+
def suppressed?(mutant)
|
|
9
|
+
source = render(mutant)
|
|
10
|
+
return true unless source
|
|
11
|
+
|
|
12
|
+
RubyVM::InstructionSequence.compile(wrapped_source(source))
|
|
13
|
+
false
|
|
14
|
+
rescue SyntaxError
|
|
15
|
+
true
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def render(mutant)
|
|
21
|
+
Unparser.unparse(mutant.mutated_node)
|
|
22
|
+
rescue StandardError
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def wrapped_source(source)
|
|
27
|
+
<<~RUBY
|
|
28
|
+
def __henitai_stillborn__
|
|
29
|
+
#{source}
|
|
30
|
+
end
|
|
31
|
+
RUBY
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Represents an addressable unit of source code to be mutated.
|
|
5
|
+
#
|
|
6
|
+
# Subjects are expressed using a compact syntax:
|
|
7
|
+
# Foo::Bar#instance_method — specific instance method
|
|
8
|
+
# Foo::Bar.class_method — specific class method
|
|
9
|
+
# Foo::Bar* — all methods on Foo::Bar
|
|
10
|
+
# Foo* — all methods in the Foo namespace
|
|
11
|
+
#
|
|
12
|
+
# The Subject is resolved from the AST before mutation begins.
|
|
13
|
+
# Test selection uses longest-prefix matching against example group
|
|
14
|
+
# descriptions in the test suite.
|
|
15
|
+
class Subject
|
|
16
|
+
attr_reader :namespace, :method_name, :method_type, :source_file,
|
|
17
|
+
:source_range, :ast_node
|
|
18
|
+
|
|
19
|
+
# @param expression [String] subject expression, e.g. "Foo::Bar#method"
|
|
20
|
+
def self.parse(expression)
|
|
21
|
+
new(expression:)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param namespace [String] fully-qualified module/class name
|
|
25
|
+
# @param method_name [String] method name (nil for wildcard subjects)
|
|
26
|
+
# @param method_type [Symbol] :instance or :class
|
|
27
|
+
# @param source_location [Hash] file/range metadata for the subject source
|
|
28
|
+
# The explicit keyword API matches the public call sites and keeps the
|
|
29
|
+
# metadata fields discoverable without a single options hash.
|
|
30
|
+
# rubocop:disable Metrics/ParameterLists
|
|
31
|
+
def initialize(expression: nil, namespace: nil, method_name: nil,
|
|
32
|
+
method_type: :instance, source_location: nil, ast_node: nil)
|
|
33
|
+
if expression
|
|
34
|
+
parse_expression(expression)
|
|
35
|
+
else
|
|
36
|
+
@namespace = namespace
|
|
37
|
+
@method_name = method_name
|
|
38
|
+
@method_type = method_type
|
|
39
|
+
end
|
|
40
|
+
@source_file = source_location&.fetch(:file, nil)
|
|
41
|
+
@source_range = source_location&.fetch(:range, nil)
|
|
42
|
+
@ast_node = ast_node
|
|
43
|
+
end
|
|
44
|
+
# rubocop:enable Metrics/ParameterLists
|
|
45
|
+
|
|
46
|
+
# Full addressable expression, e.g. "Foo::Bar#method"
|
|
47
|
+
def expression
|
|
48
|
+
sep = @method_type == :class ? "." : "#"
|
|
49
|
+
@method_name ? "#{@namespace}#{sep}#{@method_name}" : "#{@namespace}*"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def wildcard?
|
|
53
|
+
@method_name.nil?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def parse_expression(expr)
|
|
59
|
+
if (m = expr.match(/\A(.+?)([#.])(\w+)\z/))
|
|
60
|
+
@namespace = m[1]
|
|
61
|
+
@method_type = m[2] == "." ? :class : :instance
|
|
62
|
+
@method_name = m[3]
|
|
63
|
+
else
|
|
64
|
+
# Wildcard: "Foo*" or "Foo::Bar*"
|
|
65
|
+
@namespace = expr.delete_suffix("*")
|
|
66
|
+
@method_name = nil
|
|
67
|
+
@method_type = :instance
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|