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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +19 -0
  3. data/LICENSE +21 -0
  4. data/README.md +182 -0
  5. data/assets/schema/henitai.schema.json +123 -0
  6. data/exe/henitai +6 -0
  7. data/lib/henitai/arid_node_filter.rb +97 -0
  8. data/lib/henitai/cli.rb +341 -0
  9. data/lib/henitai/configuration.rb +132 -0
  10. data/lib/henitai/configuration_validator.rb +293 -0
  11. data/lib/henitai/coverage_bootstrapper.rb +75 -0
  12. data/lib/henitai/coverage_formatter.rb +112 -0
  13. data/lib/henitai/equivalence_detector.rb +85 -0
  14. data/lib/henitai/execution_engine.rb +174 -0
  15. data/lib/henitai/git_diff_analyzer.rb +82 -0
  16. data/lib/henitai/integration.rb +417 -0
  17. data/lib/henitai/mutant/activator.rb +234 -0
  18. data/lib/henitai/mutant.rb +68 -0
  19. data/lib/henitai/mutant_generator.rb +158 -0
  20. data/lib/henitai/mutant_history_store.rb +279 -0
  21. data/lib/henitai/operator.rb +96 -0
  22. data/lib/henitai/operators/arithmetic_operator.rb +46 -0
  23. data/lib/henitai/operators/array_declaration.rb +52 -0
  24. data/lib/henitai/operators/assignment_expression.rb +78 -0
  25. data/lib/henitai/operators/block_statement.rb +31 -0
  26. data/lib/henitai/operators/boolean_literal.rb +70 -0
  27. data/lib/henitai/operators/conditional_expression.rb +184 -0
  28. data/lib/henitai/operators/equality_operator.rb +41 -0
  29. data/lib/henitai/operators/hash_literal.rb +66 -0
  30. data/lib/henitai/operators/logical_operator.rb +84 -0
  31. data/lib/henitai/operators/method_expression.rb +56 -0
  32. data/lib/henitai/operators/pattern_match.rb +66 -0
  33. data/lib/henitai/operators/range_literal.rb +40 -0
  34. data/lib/henitai/operators/return_value.rb +105 -0
  35. data/lib/henitai/operators/safe_navigation.rb +34 -0
  36. data/lib/henitai/operators/string_literal.rb +64 -0
  37. data/lib/henitai/operators.rb +25 -0
  38. data/lib/henitai/parser_current.rb +7 -0
  39. data/lib/henitai/reporter.rb +432 -0
  40. data/lib/henitai/result.rb +170 -0
  41. data/lib/henitai/runner.rb +183 -0
  42. data/lib/henitai/sampling_strategy.rb +33 -0
  43. data/lib/henitai/scenario_execution_result.rb +71 -0
  44. data/lib/henitai/source_parser.rb +41 -0
  45. data/lib/henitai/static_filter.rb +186 -0
  46. data/lib/henitai/stillborn_filter.rb +34 -0
  47. data/lib/henitai/subject.rb +71 -0
  48. data/lib/henitai/subject_resolver.rb +232 -0
  49. data/lib/henitai/syntax_validator.rb +16 -0
  50. data/lib/henitai/test_prioritizer.rb +55 -0
  51. data/lib/henitai/unparse_helper.rb +24 -0
  52. data/lib/henitai/version.rb +5 -0
  53. data/lib/henitai/warning_silencer.rb +16 -0
  54. data/lib/henitai.rb +51 -0
  55. data/sig/configuration_validator.rbs +29 -0
  56. data/sig/henitai.rbs +594 -0
  57. data/sig/unparser.rbs +3 -0
  58. 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "warning_silencer"
4
+
5
+ Henitai::WarningSilencer.silence do
6
+ require "parser/current"
7
+ 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