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,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../parser_current"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
module Operators
|
|
7
|
+
# Replaces string literals with neutral alternatives.
|
|
8
|
+
class StringLiteral < Henitai::Operator
|
|
9
|
+
NODE_TYPES = %i[str dstr].freeze
|
|
10
|
+
REPLACEMENTS = ["", "Henitai was here"].freeze
|
|
11
|
+
|
|
12
|
+
def self.node_types
|
|
13
|
+
NODE_TYPES
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def mutate(node, subject:)
|
|
17
|
+
case node.type
|
|
18
|
+
when :str
|
|
19
|
+
mutate_plain_string(node, subject:)
|
|
20
|
+
when :dstr
|
|
21
|
+
mutate_interpolated_string(node, subject:)
|
|
22
|
+
else
|
|
23
|
+
[]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def mutate_plain_string(node, subject:)
|
|
30
|
+
REPLACEMENTS.map do |replacement|
|
|
31
|
+
build_mutant(
|
|
32
|
+
subject:,
|
|
33
|
+
original_node: node,
|
|
34
|
+
mutated_node: Parser::AST::Node.new(:str, [replacement]),
|
|
35
|
+
description: "replaced string with #{replacement.inspect}"
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def mutate_interpolated_string(node, subject:)
|
|
41
|
+
replacement = static_string(node)
|
|
42
|
+
|
|
43
|
+
[
|
|
44
|
+
build_mutant(
|
|
45
|
+
subject:,
|
|
46
|
+
original_node: node,
|
|
47
|
+
mutated_node: Parser::AST::Node.new(:str, [replacement]),
|
|
48
|
+
description: "removed interpolation from string"
|
|
49
|
+
)
|
|
50
|
+
]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def static_string(node)
|
|
54
|
+
# Fully interpolated strings collapse to an empty string, which still
|
|
55
|
+
# gives us a valid neutral mutation target.
|
|
56
|
+
node.children.each_with_object(+"") do |child, string|
|
|
57
|
+
next unless child.type == :str
|
|
58
|
+
|
|
59
|
+
string << child.children.first.to_s
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Namespace for concrete mutation operators.
|
|
5
|
+
#
|
|
6
|
+
# Concrete operator classes are autoloaded so the registry stays lightweight
|
|
7
|
+
# until a specific operator is referenced.
|
|
8
|
+
module Operators
|
|
9
|
+
autoload :ArithmeticOperator, "henitai/operators/arithmetic_operator"
|
|
10
|
+
autoload :EqualityOperator, "henitai/operators/equality_operator"
|
|
11
|
+
autoload :LogicalOperator, "henitai/operators/logical_operator"
|
|
12
|
+
autoload :BooleanLiteral, "henitai/operators/boolean_literal"
|
|
13
|
+
autoload :ConditionalExpression, "henitai/operators/conditional_expression"
|
|
14
|
+
autoload :StringLiteral, "henitai/operators/string_literal"
|
|
15
|
+
autoload :ReturnValue, "henitai/operators/return_value"
|
|
16
|
+
autoload :ArrayDeclaration, "henitai/operators/array_declaration"
|
|
17
|
+
autoload :HashLiteral, "henitai/operators/hash_literal"
|
|
18
|
+
autoload :RangeLiteral, "henitai/operators/range_literal"
|
|
19
|
+
autoload :SafeNavigation, "henitai/operators/safe_navigation"
|
|
20
|
+
autoload :PatternMatch, "henitai/operators/pattern_match"
|
|
21
|
+
autoload :BlockStatement, "henitai/operators/block_statement"
|
|
22
|
+
autoload :MethodExpression, "henitai/operators/method_expression"
|
|
23
|
+
autoload :AssignmentExpression, "henitai/operators/assignment_expression"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "open3"
|
|
7
|
+
require "uri"
|
|
8
|
+
require_relative "unparse_helper"
|
|
9
|
+
|
|
10
|
+
module Henitai
|
|
11
|
+
# Namespace for result reporters.
|
|
12
|
+
#
|
|
13
|
+
# Each reporter receives a Result object and writes output in its specific
|
|
14
|
+
# format. Reporters are selected via `reporters:` in .henitai.yml.
|
|
15
|
+
#
|
|
16
|
+
# Built-in reporters:
|
|
17
|
+
# terminal — coloured summary table to STDOUT
|
|
18
|
+
# json — mutation-testing-report-schema JSON file
|
|
19
|
+
# html — self-contained HTML using mutation-testing-elements web component
|
|
20
|
+
# dashboard — POST to Stryker Dashboard REST API
|
|
21
|
+
module Reporter
|
|
22
|
+
# @param names [Array<String>] reporter names from configuration
|
|
23
|
+
# @param result [Result]
|
|
24
|
+
# @param config [Configuration]
|
|
25
|
+
def self.run_all(names:, result:, config:)
|
|
26
|
+
names.each do |name|
|
|
27
|
+
reporter_class(name).new(config:).report(result)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.reporter_class(name)
|
|
32
|
+
const_get(name.capitalize)
|
|
33
|
+
rescue NameError
|
|
34
|
+
raise ArgumentError, "Unknown reporter: #{name}. Valid reporters: terminal, json, html, dashboard"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Base class for all reporters.
|
|
38
|
+
class Base
|
|
39
|
+
def initialize(config:)
|
|
40
|
+
@config = config
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param result [Result]
|
|
44
|
+
def report(result)
|
|
45
|
+
raise NotImplementedError, "#{self.class}#report must be implemented"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
attr_reader :config
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Terminal reporter.
|
|
54
|
+
class Terminal < Base
|
|
55
|
+
include UnparseHelper
|
|
56
|
+
|
|
57
|
+
PROGRESS_GLYPHS = {
|
|
58
|
+
killed: "·",
|
|
59
|
+
survived: "S",
|
|
60
|
+
timeout: "T",
|
|
61
|
+
ignored: "I"
|
|
62
|
+
}.freeze
|
|
63
|
+
|
|
64
|
+
def report(result)
|
|
65
|
+
puts report_lines(result)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def progress(mutant, scenario_result: nil)
|
|
69
|
+
glyph = PROGRESS_GLYPHS[mutant.status]
|
|
70
|
+
return unless glyph
|
|
71
|
+
|
|
72
|
+
print(glyph)
|
|
73
|
+
return flush unless should_show_logs?(scenario_result)
|
|
74
|
+
|
|
75
|
+
output = scenario_output(scenario_result)
|
|
76
|
+
print("\n")
|
|
77
|
+
print("log: #{scenario_result.log_path}\n")
|
|
78
|
+
print(output) unless output.empty?
|
|
79
|
+
$stdout.flush
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def report_lines(result)
|
|
85
|
+
lines = summary_lines(result)
|
|
86
|
+
detail_lines = survived_detail_lines(result)
|
|
87
|
+
return lines if detail_lines.empty?
|
|
88
|
+
|
|
89
|
+
lines + [""] + detail_lines
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def summary_lines(result)
|
|
93
|
+
[
|
|
94
|
+
"Mutation testing summary",
|
|
95
|
+
score_line(result),
|
|
96
|
+
format_row("Killed", count_status(result, :killed)),
|
|
97
|
+
format_row("Survived", count_status(result, :survived)),
|
|
98
|
+
format_row("Timeout", count_status(result, :timeout)),
|
|
99
|
+
format_row("No coverage", count_status(result, :no_coverage)),
|
|
100
|
+
format_row("Duration", format_duration(result.duration))
|
|
101
|
+
]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def survived_detail_lines(result)
|
|
105
|
+
survivors = result.mutants.select(&:survived?)
|
|
106
|
+
return [] if survivors.empty?
|
|
107
|
+
|
|
108
|
+
["Survived mutants"] + survivors.flat_map { |mutant| survived_mutant_lines(mutant) }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def survived_mutant_lines(mutant)
|
|
112
|
+
[
|
|
113
|
+
survived_mutant_header(mutant),
|
|
114
|
+
original_line(mutant),
|
|
115
|
+
mutated_line(mutant)
|
|
116
|
+
]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def survived_mutant_header(mutant)
|
|
120
|
+
format(
|
|
121
|
+
"%<file>s:%<line>d %<operator>s",
|
|
122
|
+
file: mutant.location.fetch(:file),
|
|
123
|
+
line: mutant.location.fetch(:start_line),
|
|
124
|
+
operator: mutant.operator
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def original_line(mutant)
|
|
129
|
+
format("- %s", safe_unparse(mutant.original_node))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def mutated_line(mutant)
|
|
133
|
+
format("+ %s", safe_unparse(mutant.mutated_node))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def score_line(result)
|
|
137
|
+
summary = result.scoring_summary
|
|
138
|
+
line = [
|
|
139
|
+
format("MS %s", format_percent(summary[:mutation_score])),
|
|
140
|
+
format("MSI %s", format_percent(summary[:mutation_score_indicator])),
|
|
141
|
+
format(
|
|
142
|
+
"Equivalence uncertainty %s",
|
|
143
|
+
summary[:equivalence_uncertainty] || "n/a"
|
|
144
|
+
)
|
|
145
|
+
].join(" | ")
|
|
146
|
+
color = score_color(summary[:mutation_score])
|
|
147
|
+
color ? colorize(line, color) : line
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def format_row(label, value)
|
|
151
|
+
format("%<label>-12s %<value>s", label:, value:)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def count_status(result, status)
|
|
155
|
+
result.mutants.count { |mutant| mutant.status == status }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def format_duration(duration)
|
|
159
|
+
format("%.2fs", duration)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def format_percent(value)
|
|
163
|
+
value.nil? ? "n/a" : format("%.2f%%", value)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def score_color(score)
|
|
167
|
+
return nil if score.nil?
|
|
168
|
+
|
|
169
|
+
thresholds = config.thresholds || {}
|
|
170
|
+
high = thresholds.fetch(:high, 80)
|
|
171
|
+
low = thresholds.fetch(:low, 60)
|
|
172
|
+
|
|
173
|
+
return "32" if score >= high
|
|
174
|
+
return "33" if score >= low
|
|
175
|
+
|
|
176
|
+
"31"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def colorize(text, color)
|
|
180
|
+
return text if ENV.key?("NO_COLOR")
|
|
181
|
+
|
|
182
|
+
"\e[#{color}m#{text}\e[0m"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def should_show_logs?(scenario_result)
|
|
186
|
+
return false unless scenario_result.respond_to?(:failure_tail)
|
|
187
|
+
|
|
188
|
+
scenario_result.should_show_logs?(all_logs: config.all_logs)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def scenario_output(scenario_result)
|
|
192
|
+
scenario_result.failure_tail(all_logs: config.all_logs)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def flush
|
|
196
|
+
$stdout.flush
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# JSON reporter.
|
|
201
|
+
class Json < Base
|
|
202
|
+
def report(result)
|
|
203
|
+
FileUtils.mkdir_p(File.dirname(report_path))
|
|
204
|
+
File.write(report_path, JSON.pretty_generate(result.to_stryker_schema))
|
|
205
|
+
write_history_report
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
private
|
|
209
|
+
|
|
210
|
+
def report_path
|
|
211
|
+
File.join(config.reports_dir, "mutation-report.json")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def write_history_report
|
|
215
|
+
path = File.join(config.reports_dir, Henitai::HISTORY_STORE_FILENAME)
|
|
216
|
+
history_store = MutantHistoryStore.new(path:)
|
|
217
|
+
return unless File.exist?(path)
|
|
218
|
+
|
|
219
|
+
FileUtils.mkdir_p(File.dirname(history_report_path))
|
|
220
|
+
File.write(history_report_path, JSON.pretty_generate(history_store.trend_report))
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def history_report_path
|
|
224
|
+
File.join(config.reports_dir, "mutation-history.json")
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# HTML reporter.
|
|
229
|
+
class Html < Base
|
|
230
|
+
def report(result)
|
|
231
|
+
FileUtils.mkdir_p(File.dirname(report_path))
|
|
232
|
+
File.write(report_path, html_document(result))
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
private
|
|
236
|
+
|
|
237
|
+
def report_path
|
|
238
|
+
File.join(config.reports_dir, "mutation-report.html")
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def html_document(result)
|
|
242
|
+
<<~HTML
|
|
243
|
+
<!DOCTYPE html>
|
|
244
|
+
<html lang="en">
|
|
245
|
+
<head>
|
|
246
|
+
<meta charset="utf-8">
|
|
247
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
248
|
+
<title>Henitai mutation report</title>
|
|
249
|
+
</head>
|
|
250
|
+
<body>
|
|
251
|
+
<mutation-test-report-app titlePostfix="Henitai"></mutation-test-report-app>
|
|
252
|
+
<script src="https://www.unpkg.com/mutation-testing-elements"></script>
|
|
253
|
+
<script type="application/json" id="henitai-report-data">#{escaped_report_json(result)}</script>
|
|
254
|
+
<script>
|
|
255
|
+
const report = JSON.parse(
|
|
256
|
+
document.getElementById("henitai-report-data").textContent
|
|
257
|
+
);
|
|
258
|
+
document.querySelector("mutation-test-report-app").report = report;
|
|
259
|
+
</script>
|
|
260
|
+
</body>
|
|
261
|
+
</html>
|
|
262
|
+
HTML
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def escaped_report_json(result)
|
|
266
|
+
JSON.pretty_generate(result.to_stryker_schema)
|
|
267
|
+
.gsub("&", "\\u0026")
|
|
268
|
+
.gsub("<", "\\u003c")
|
|
269
|
+
.gsub(">", "\\u003e")
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Dashboard reporter.
|
|
274
|
+
class Dashboard < Base
|
|
275
|
+
DEFAULT_BASE_URL = "https://dashboard.stryker-mutator.io"
|
|
276
|
+
HTTP_TIMEOUT_SECONDS = 30
|
|
277
|
+
|
|
278
|
+
def report(result)
|
|
279
|
+
return unless ready?
|
|
280
|
+
|
|
281
|
+
uri = dashboard_uri
|
|
282
|
+
request = build_request(result, uri)
|
|
283
|
+
send_request(uri, request)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
private
|
|
287
|
+
|
|
288
|
+
def ready?
|
|
289
|
+
!project.nil? && !version.nil? && !api_key.nil?
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def build_request(result, uri)
|
|
293
|
+
request = Net::HTTP::Put.new(uri.request_uri, request_headers)
|
|
294
|
+
request.body = JSON.generate(result.to_stryker_schema)
|
|
295
|
+
request
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def request_headers
|
|
299
|
+
{
|
|
300
|
+
"Content-Type" => "application/json",
|
|
301
|
+
"X-Api-Key" => api_key.to_s
|
|
302
|
+
}
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def send_request(uri, request)
|
|
306
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
307
|
+
http.open_timeout = HTTP_TIMEOUT_SECONDS
|
|
308
|
+
http.read_timeout = HTTP_TIMEOUT_SECONDS
|
|
309
|
+
http.request(request)
|
|
310
|
+
end
|
|
311
|
+
rescue StandardError => e
|
|
312
|
+
warn("Dashboard reporter upload failed: #{e.message}")
|
|
313
|
+
nil
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def dashboard_uri
|
|
317
|
+
uri = URI.parse(base_url)
|
|
318
|
+
# @type var segments: Array[String]
|
|
319
|
+
base_path = uri.path.to_s.chomp("/")
|
|
320
|
+
segments = []
|
|
321
|
+
segments << base_path unless base_path.empty?
|
|
322
|
+
segments += ["api", "reports", project_path, encoded_version]
|
|
323
|
+
uri.path = "/#{segments.join('/')}"
|
|
324
|
+
uri
|
|
325
|
+
rescue URI::InvalidURIError
|
|
326
|
+
URI.parse(DEFAULT_BASE_URL)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def base_url
|
|
330
|
+
config.dashboard[:base_url] || DEFAULT_BASE_URL
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def project
|
|
334
|
+
@project ||= config.dashboard[:project] || project_from_git_remote
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def version
|
|
338
|
+
@version ||= env_version || git_branch_name
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def env_version
|
|
342
|
+
ref_name = ENV.fetch("GITHUB_REF_NAME", nil)
|
|
343
|
+
return ref_name unless blank?(ref_name)
|
|
344
|
+
|
|
345
|
+
ref = ENV.fetch("GITHUB_REF", nil)
|
|
346
|
+
return ref_without_prefix(ref) unless ref.nil? || blank?(ref)
|
|
347
|
+
|
|
348
|
+
ENV.fetch("GITHUB_SHA", nil)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def ref_without_prefix(ref)
|
|
352
|
+
return nil if blank?(ref)
|
|
353
|
+
|
|
354
|
+
ref.to_s.sub(%r{^refs/(heads|tags|pull)/}, "")
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def project_from_git_remote
|
|
358
|
+
self.class.project_from_git_url(git_remote_url)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def api_key
|
|
362
|
+
ENV.fetch("STRYKER_DASHBOARD_API_KEY", nil)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def project_path
|
|
366
|
+
project.to_s.split("/").map { |segment| URI.encode_www_form_component(segment) }.join("/")
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def encoded_version
|
|
370
|
+
URI.encode_www_form_component(version.to_s)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def blank?(value)
|
|
374
|
+
value.nil? || value.strip.empty?
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def git_remote_url
|
|
378
|
+
stdout, status = Open3.capture2("git", "remote", "get-url", "origin")
|
|
379
|
+
return stdout.strip if status.success?
|
|
380
|
+
|
|
381
|
+
nil
|
|
382
|
+
rescue Errno::ENOENT
|
|
383
|
+
nil
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def git_branch_name
|
|
387
|
+
stdout, status = Open3.capture2("git", "rev-parse", "--abbrev-ref", "HEAD")
|
|
388
|
+
return stdout.strip if status.success? && !stdout.strip.empty?
|
|
389
|
+
|
|
390
|
+
nil
|
|
391
|
+
rescue Errno::ENOENT
|
|
392
|
+
nil
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
class << self
|
|
396
|
+
def project_from_git_url(url)
|
|
397
|
+
normalized = normalize_git_url(url)
|
|
398
|
+
return nil if normalized.nil?
|
|
399
|
+
|
|
400
|
+
return project_from_uri_url(normalized) if normalized.include?("://")
|
|
401
|
+
return project_from_ssh_url(normalized) if normalized.include?("@")
|
|
402
|
+
|
|
403
|
+
normalized
|
|
404
|
+
rescue URI::InvalidURIError
|
|
405
|
+
nil
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def normalize_git_url(url)
|
|
409
|
+
return nil if url.nil? || url.strip.empty?
|
|
410
|
+
|
|
411
|
+
url.strip.sub(/\.git\z/, "")
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def project_from_uri_url(normalized)
|
|
415
|
+
uri = URI.parse(normalized)
|
|
416
|
+
path = uri.path.to_s.sub(%r{^/}, "")
|
|
417
|
+
[uri.host, path].compact.reject(&:empty?).join("/")
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def project_from_ssh_url(normalized)
|
|
421
|
+
_, host_and_path = normalized.split("@", 2)
|
|
422
|
+
return nil if host_and_path.nil?
|
|
423
|
+
|
|
424
|
+
host, path = host_and_path.split(":", 2)
|
|
425
|
+
return nil unless host && path
|
|
426
|
+
|
|
427
|
+
"#{host}/#{path}"
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "unparse_helper"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
# Aggregates the outcome of a complete mutation testing run.
|
|
7
|
+
#
|
|
8
|
+
# Provides metrics and the serialised Stryker mutation-testing-report-schema
|
|
9
|
+
# JSON payload. The schema version follows stryker-mutator/mutation-testing-elements.
|
|
10
|
+
class Result
|
|
11
|
+
include UnparseHelper
|
|
12
|
+
|
|
13
|
+
SCHEMA_VERSION = "1.0"
|
|
14
|
+
|
|
15
|
+
attr_reader :mutants, :started_at, :finished_at
|
|
16
|
+
|
|
17
|
+
def initialize(mutants:, started_at:, finished_at:)
|
|
18
|
+
@mutants = mutants
|
|
19
|
+
@started_at = started_at
|
|
20
|
+
@finished_at = finished_at
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Integer] number of killed mutants
|
|
24
|
+
def killed = mutants.count(&:killed?)
|
|
25
|
+
|
|
26
|
+
# @return [Integer] number of survived mutants
|
|
27
|
+
def survived = mutants.count(&:survived?)
|
|
28
|
+
|
|
29
|
+
# @return [Integer] number of confirmed equivalent mutants (excluded from MS)
|
|
30
|
+
def equivalent = mutants.count(&:equivalent?)
|
|
31
|
+
|
|
32
|
+
# Detected = killed + timeout + runtime_error (alle Zustände die einen Fehler beweisen)
|
|
33
|
+
# @return [Integer]
|
|
34
|
+
def detected
|
|
35
|
+
mutants.count { |m| %i[killed timeout runtime_error].include?(m.status) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Mutation Score (MS) — Architektur-Formel aus Abschnitt 6.1:
|
|
39
|
+
#
|
|
40
|
+
# MS = detected / (total − ignored − no_coverage − compile_error − equivalent)
|
|
41
|
+
#
|
|
42
|
+
# Confirmed equivalent mutants werden aus BEIDEN Seiten der Gleichung entfernt:
|
|
43
|
+
# Sie sind weder im Zähler (nicht detektierbar) noch im Nenner (nicht testbar).
|
|
44
|
+
# Das ist der entscheidende Unterschied zum MSI.
|
|
45
|
+
#
|
|
46
|
+
# @return [Float, nil] 0.0–100.0, nil wenn kein valider Mutant vorhanden
|
|
47
|
+
def mutation_score
|
|
48
|
+
excluded = %i[ignored no_coverage compile_error equivalent]
|
|
49
|
+
valid = mutants.reject { |m| excluded.include?(m.status) }
|
|
50
|
+
return nil if valid.empty?
|
|
51
|
+
|
|
52
|
+
((detected.to_f / valid.count) * 100.0).round(2).to_f
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Mutation Score Indicator (MSI) — naive Berechnung ohne Äquivalenz-Bereinigung:
|
|
56
|
+
#
|
|
57
|
+
# MSI = killed / all_mutants
|
|
58
|
+
#
|
|
59
|
+
# MSI ist immer ≤ MS. Der Unterschied kommuniziert die Äquivalenz-Unsicherheit.
|
|
60
|
+
# Beide Werte MÜSSEN im Report zusammen ausgewiesen werden (Anti-Pattern: nur MS).
|
|
61
|
+
#
|
|
62
|
+
# @return [Float, nil]
|
|
63
|
+
def mutation_score_indicator
|
|
64
|
+
return nil if mutants.empty?
|
|
65
|
+
|
|
66
|
+
((killed.to_f / mutants.count) * 100.0).round(2).to_f
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Compact public summary for reporters.
|
|
70
|
+
# The uncertainty note is intentionally qualitative: equivalent mutants are
|
|
71
|
+
# a known gray area, so the terminal report should communicate that
|
|
72
|
+
# uncertainty instead of pretending to be precise.
|
|
73
|
+
def scoring_summary
|
|
74
|
+
{
|
|
75
|
+
mutation_score: mutation_score,
|
|
76
|
+
mutation_score_indicator: mutation_score_indicator,
|
|
77
|
+
equivalence_uncertainty: equivalence_uncertainty
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @return [Float] duration in seconds
|
|
82
|
+
def duration
|
|
83
|
+
finished_at - started_at
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Serialise to Stryker mutation-testing-report-schema JSON (schema 1.0).
|
|
87
|
+
# @return [Hash]
|
|
88
|
+
def to_stryker_schema
|
|
89
|
+
{
|
|
90
|
+
schemaVersion: SCHEMA_VERSION,
|
|
91
|
+
thresholds: { high: 80, low: 60 },
|
|
92
|
+
files: build_files_section
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def build_files_section
|
|
99
|
+
mutants.group_by { |m| m.location[:file] }.transform_values do |file_mutants|
|
|
100
|
+
source = begin
|
|
101
|
+
File.read(file_mutants.first.location[:file])
|
|
102
|
+
rescue StandardError
|
|
103
|
+
""
|
|
104
|
+
end
|
|
105
|
+
{
|
|
106
|
+
language: "ruby",
|
|
107
|
+
source:,
|
|
108
|
+
mutants: file_mutants.map { |m| mutant_to_schema(m) }
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def mutant_to_schema(mutant)
|
|
114
|
+
{
|
|
115
|
+
id: mutant.id,
|
|
116
|
+
mutatorName: mutant.operator,
|
|
117
|
+
replacement: replacement_for(mutant),
|
|
118
|
+
location: location_for(mutant),
|
|
119
|
+
status: stryker_status(mutant.status),
|
|
120
|
+
description: mutant.description,
|
|
121
|
+
duration: duration_for(mutant)
|
|
122
|
+
}.compact
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def replacement_for(mutant)
|
|
126
|
+
safe_unparse(mutant.mutated_node)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def location_for(mutant)
|
|
130
|
+
{
|
|
131
|
+
start: line_column(mutant, :start),
|
|
132
|
+
end: line_column(mutant, :end)
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def line_column(mutant, prefix)
|
|
137
|
+
# Stryker schema columns are 1-based; Parser locations are 0-based.
|
|
138
|
+
{
|
|
139
|
+
line: mutant.location.fetch(:"#{prefix}_line"),
|
|
140
|
+
column: mutant.location.fetch(:"#{prefix}_col") + 1
|
|
141
|
+
}
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def duration_for(mutant)
|
|
145
|
+
mutant.duration&.then { |d| (d * 1000).round }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def equivalence_uncertainty
|
|
149
|
+
return nil if mutation_score.nil?
|
|
150
|
+
|
|
151
|
+
"~10-15% of live mutants"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def stryker_status(status)
|
|
155
|
+
# :equivalent wird als "Ignored" serialisiert — das Stryker-Schema kennt keinen
|
|
156
|
+
# Equivalent-Status. Die interne Unterscheidung (für MS vs. MSI) bleibt im Result-Objekt.
|
|
157
|
+
{
|
|
158
|
+
killed: "Killed",
|
|
159
|
+
survived: "Survived",
|
|
160
|
+
timeout: "Timeout",
|
|
161
|
+
no_coverage: "NoCoverage",
|
|
162
|
+
ignored: "Ignored",
|
|
163
|
+
equivalent: "Ignored",
|
|
164
|
+
compile_error: "CompileError",
|
|
165
|
+
runtime_error: "RuntimeError",
|
|
166
|
+
pending: "Pending"
|
|
167
|
+
}.fetch(status, "Pending")
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|