henitai 0.2.1 → 0.3.1

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +106 -1
  3. data/README.md +125 -3
  4. data/assets/schema/henitai.schema.json +35 -0
  5. data/lib/henitai/available_cpu_count.rb +16 -23
  6. data/lib/henitai/canonical_report_merger.rb +107 -0
  7. data/lib/henitai/canonical_report_writer.rb +22 -0
  8. data/lib/henitai/checkpoint_reporter.rb +79 -0
  9. data/lib/henitai/cli/clean_command.rb +14 -8
  10. data/lib/henitai/cli/command_support.rb +2 -1
  11. data/lib/henitai/cli/operator_command.rb +2 -1
  12. data/lib/henitai/cli/options.rb +21 -57
  13. data/lib/henitai/cli/run_command.rb +36 -3
  14. data/lib/henitai/cli/run_options.rb +108 -0
  15. data/lib/henitai/cli.rb +12 -4
  16. data/lib/henitai/composite_progress_reporter.rb +42 -0
  17. data/lib/henitai/configuration.rb +41 -9
  18. data/lib/henitai/configuration_validator/rules.rb +18 -0
  19. data/lib/henitai/configuration_validator/scalars.rb +46 -0
  20. data/lib/henitai/configuration_validator.rb +8 -1
  21. data/lib/henitai/coverage_bootstrapper.rb +54 -5
  22. data/lib/henitai/coverage_formatter.rb +5 -1
  23. data/lib/henitai/coverage_report_reader.rb +28 -5
  24. data/lib/henitai/execution_engine/env_scope.rb +67 -0
  25. data/lib/henitai/execution_engine.rb +111 -50
  26. data/lib/henitai/generated_artifacts.rb +58 -0
  27. data/lib/henitai/incremental_filter.rb +100 -0
  28. data/lib/henitai/integration/child_debug_support.rb +6 -2
  29. data/lib/henitai/integration/coverage_suppression.rb +9 -0
  30. data/lib/henitai/integration/minitest.rb +11 -43
  31. data/lib/henitai/integration/minitest_load_path.rb +14 -0
  32. data/lib/henitai/integration/minitest_suite_command.rb +17 -0
  33. data/lib/henitai/integration/minitest_test_runner.rb +39 -0
  34. data/lib/henitai/integration/rails_environment_preloader.rb +14 -0
  35. data/lib/henitai/integration/rspec_child_runner.rb +3 -3
  36. data/lib/henitai/integration/rspec_test_selection.rb +3 -0
  37. data/lib/henitai/integration/scenario_log_support.rb +67 -20
  38. data/lib/henitai/minitest_coverage_reporter.rb +4 -1
  39. data/lib/henitai/mutant/activator.rb +14 -0
  40. data/lib/henitai/mutant.rb +13 -6
  41. data/lib/henitai/mutant_history_store/sql.rb +21 -3
  42. data/lib/henitai/mutant_history_store/verdict_cache.rb +84 -0
  43. data/lib/henitai/mutant_history_store.rb +62 -12
  44. data/lib/henitai/mutant_identity.rb +33 -2
  45. data/lib/henitai/mutation_skip_directives.rb +227 -0
  46. data/lib/henitai/operator.rb +3 -1
  47. data/lib/henitai/operators/equality_identity_operator.rb +51 -0
  48. data/lib/henitai/operators/equality_operator.rb +8 -3
  49. data/lib/henitai/operators.rb +1 -0
  50. data/lib/henitai/per_test_coverage.rb +81 -0
  51. data/lib/henitai/per_test_coverage_collector.rb +20 -5
  52. data/lib/henitai/per_test_coverage_selector.rb +11 -33
  53. data/lib/henitai/reporter/dashboard_metadata_provider.rb +113 -0
  54. data/lib/henitai/reporter.rb +248 -117
  55. data/lib/henitai/reports_directory_lock.rb +76 -0
  56. data/lib/henitai/result.rb +75 -10
  57. data/lib/henitai/runner.rb +102 -43
  58. data/lib/henitai/scenario_execution_result.rb +12 -0
  59. data/lib/henitai/slot_scheduler/draining.rb +21 -15
  60. data/lib/henitai/slot_scheduler.rb +73 -15
  61. data/lib/henitai/static_filter.rb +16 -6
  62. data/lib/henitai/survivor_rerun_strategy.rb +1 -1
  63. data/lib/henitai/survivor_test_filter.rb +1 -1
  64. data/lib/henitai/test_prioritizer.rb +28 -2
  65. data/lib/henitai/timeout_calibrator.rb +44 -0
  66. data/lib/henitai/verdict_fingerprint.rb +155 -0
  67. data/lib/henitai/version.rb +1 -1
  68. data/lib/henitai.rb +14 -1
  69. data/sig/henitai.rbs +235 -25
  70. metadata +22 -3
  71. data/lib/henitai/parallel_execution_runner.rb +0 -153
@@ -2,6 +2,11 @@
2
2
 
3
3
  module Henitai
4
4
  # Narrows candidate test files using the per-test coverage report.
5
+ #
6
+ # The intersection check itself lives in {PerTestCoverage}; this class only
7
+ # adds the selection policy: when no candidate provably covers the mutant
8
+ # (or the map / location is unavailable), it falls back to all candidates —
9
+ # over-selection costs time, never correctness.
5
10
  class PerTestCoverageSelector
6
11
  def initialize(coverage_report_reader: CoverageReportReader.new)
7
12
  @coverage_report_reader = coverage_report_reader
@@ -10,49 +15,22 @@ module Henitai
10
15
  def filter(tests, mutant, reports_dir:)
11
16
  candidates = Array(tests)
12
17
  return candidates if candidates.empty?
13
- return candidates unless location_available?(mutant)
14
- return candidates unless per_test_coverage_available?(reports_dir)
15
18
 
19
+ coverage = per_test_coverage(reports_dir)
16
20
  covered_tests = candidates.select do |test|
17
- covers_mutant?(test, mutant, reports_dir)
21
+ coverage.covers?(test, mutant)
18
22
  end
19
23
  covered_tests.empty? ? candidates : covered_tests
20
24
  end
21
25
 
22
26
  private
23
27
 
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
28
  def per_test_coverage(reports_dir)
47
29
  @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?
30
+ @per_test_coverage[reports_dir] ||= PerTestCoverage.new(
31
+ reports_dir:,
32
+ coverage_report_reader:
33
+ )
56
34
  end
57
35
 
58
36
  attr_reader :coverage_report_reader
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "uri"
5
+
6
+ module Henitai
7
+ module Reporter
8
+ # Resolves the Stryker Dashboard project/version/api-key coordinates for
9
+ # {Dashboard}. Isolated behind injectable +env+/+git_executor+ seams so
10
+ # callers (and specs) never have to touch real ENV or shell out to git.
11
+ class DashboardMetadataProvider
12
+ def initialize(dashboard_config:, env: ENV, git_executor: Open3)
13
+ @dashboard_config = dashboard_config || {}
14
+ @env = env
15
+ @git_executor = git_executor
16
+ end
17
+
18
+ def project
19
+ @project ||= dashboard_config[:project] || project_from_git_remote
20
+ end
21
+
22
+ def version
23
+ @version ||= env_version || git_branch_name
24
+ end
25
+
26
+ def api_key
27
+ env.fetch("STRYKER_DASHBOARD_API_KEY", nil)
28
+ end
29
+
30
+ class << self
31
+ def project_from_git_url(url)
32
+ normalized = normalize_git_url(url)
33
+ return nil if normalized.nil?
34
+
35
+ return project_from_uri_url(normalized) if normalized.include?("://")
36
+ return project_from_ssh_url(normalized) if normalized.include?("@")
37
+
38
+ normalized
39
+ rescue URI::InvalidURIError
40
+ nil
41
+ end
42
+
43
+ def normalize_git_url(url)
44
+ return nil if url.nil? || url.strip.empty?
45
+
46
+ url.strip.sub(/\.git\z/, "")
47
+ end
48
+
49
+ def project_from_uri_url(normalized)
50
+ uri = URI.parse(normalized)
51
+ path = uri.path.to_s.sub(%r{^/}, "")
52
+ [uri.host, path].compact.reject(&:empty?).join("/")
53
+ end
54
+
55
+ def project_from_ssh_url(normalized)
56
+ _, host_and_path = normalized.split("@", 2)
57
+ return nil if host_and_path.nil?
58
+
59
+ host, path = host_and_path.split(":", 2)
60
+ return nil unless host && path
61
+
62
+ "#{host}/#{path}"
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ attr_reader :dashboard_config, :env, :git_executor
69
+
70
+ def env_version
71
+ ref_name = env.fetch("GITHUB_REF_NAME", nil)
72
+ return ref_name unless blank?(ref_name)
73
+
74
+ ref = env.fetch("GITHUB_REF", nil)
75
+ return ref_without_prefix(ref) unless ref.nil? || blank?(ref)
76
+
77
+ env.fetch("GITHUB_SHA", nil)
78
+ end
79
+
80
+ def ref_without_prefix(ref)
81
+ return nil if blank?(ref)
82
+
83
+ ref.to_s.sub(%r{^refs/(heads|tags|pull)/}, "")
84
+ end
85
+
86
+ def project_from_git_remote
87
+ self.class.project_from_git_url(git_remote_url)
88
+ end
89
+
90
+ def git_remote_url
91
+ stdout, status = git_executor.capture2("git", "remote", "get-url", "origin")
92
+ return stdout.strip if status.success?
93
+
94
+ nil
95
+ rescue Errno::ENOENT
96
+ nil
97
+ end
98
+
99
+ def git_branch_name
100
+ stdout, status = git_executor.capture2("git", "rev-parse", "--abbrev-ref", "HEAD")
101
+ return stdout.strip if status.success? && !stdout.strip.empty?
102
+
103
+ nil
104
+ rescue Errno::ENOENT
105
+ nil
106
+ end
107
+
108
+ def blank?(value)
109
+ value.nil? || value.strip.empty?
110
+ end
111
+ end
112
+ end
113
+ end
@@ -6,6 +6,7 @@ require "net/http"
6
6
  require "open3"
7
7
  require "uri"
8
8
  require_relative "unparse_helper"
9
+ require_relative "reporter/dashboard_metadata_provider"
9
10
 
10
11
  module Henitai
11
12
  # Namespace for result reporters.
@@ -34,7 +35,7 @@ module Henitai
34
35
  def self.reporter_class(name)
35
36
  const_get(name.capitalize)
36
37
  rescue NameError
37
- raise ArgumentError, "Unknown reporter: #{name}. Valid reporters: terminal, json, html, dashboard"
38
+ raise ArgumentError, "Unknown reporter: #{name}. Valid reporters: terminal, json, html, dashboard, github"
38
39
  end
39
40
 
40
41
  # Base class for all reporters.
@@ -55,6 +56,21 @@ module Henitai
55
56
  private
56
57
 
57
58
  attr_reader :config, :history_store
59
+
60
+ # Authoritative (full) runs fully replace the canonical report;
61
+ # scoped/partial runs merge into it (CanonicalReportMerger) so
62
+ # findings for files outside this run's scope aren't lost.
63
+ def authoritative?(result)
64
+ return true unless result.respond_to?(:authoritative?)
65
+
66
+ result.authoritative?
67
+ end
68
+
69
+ # The merge source of truth is always the JSON canonical report, even
70
+ # for the HTML reporter, which embeds the same merged schema.
71
+ def canonical_path
72
+ File.join(config.reports_dir, "mutation-report.json")
73
+ end
58
74
  end
59
75
 
60
76
  # Terminal reporter.
@@ -68,6 +84,11 @@ module Henitai
68
84
  ignored: "I"
69
85
  }.freeze
70
86
 
87
+ def initialize(config:, history_store: nil, color_enabled: !ENV.key?("NO_COLOR"))
88
+ super(config:, history_store:)
89
+ @color_enabled = color_enabled
90
+ end
91
+
71
92
  def report(result)
72
93
  puts report_lines(result)
73
94
  end
@@ -88,6 +109,8 @@ module Henitai
88
109
 
89
110
  private
90
111
 
112
+ attr_reader :color_enabled
113
+
91
114
  def report_lines(result)
92
115
  lines = summary_lines(result)
93
116
  detail_lines = survived_detail_lines(result)
@@ -112,8 +135,52 @@ module Henitai
112
135
  format_row("Survived", count_status(result, :survived)),
113
136
  format_row("Timeout", count_status(result, :timeout)),
114
137
  format_row("No coverage", count_status(result, :no_coverage)),
115
- format_row("Duration", format_duration(result.duration))
116
- ]
138
+ format_row("Duration", format_duration(result.duration)),
139
+ reused_verdicts_line(result),
140
+ executed_only_score_line(result),
141
+ empty_since_scope_line(result)
142
+ ].compact
143
+ end
144
+
145
+ # A zero-mutant summary with every counter at 0 and n/a scores is
146
+ # baffling without its cause; name it when the run was scoped by --since.
147
+ def empty_since_scope_line(result)
148
+ return nil unless result.mutants.empty?
149
+ return nil unless result.respond_to?(:since) && result.since
150
+
151
+ "No mutants: no configured source files changed since #{result.since}."
152
+ end
153
+
154
+ def reused_verdicts_line(result)
155
+ reused = reused_mutants(result)
156
+ return nil if reused.empty?
157
+
158
+ format(
159
+ "%<reused>d of %<total>d verdicts reused from history (%<killed>d killed, %<survived>d survived)",
160
+ reused: reused.size,
161
+ total: result.mutants.size,
162
+ killed: reused.count { |mutant| mutant.status == :killed },
163
+ survived: reused.count { |mutant| mutant.status == :survived }
164
+ )
165
+ end
166
+
167
+ def reused_mutants(result)
168
+ result.mutants.select { |mutant| mutant.respond_to?(:from_cache?) && mutant.from_cache? }
169
+ end
170
+
171
+ # Cached verdicts make the combined MS/MSI partly synthetic; the
172
+ # executed-only pair keeps this run's own signal visible.
173
+ def executed_only_score_line(result)
174
+ return nil unless result.respond_to?(:executed_scoring_summary)
175
+
176
+ summary = result.executed_scoring_summary # : Hash[Symbol, untyped]?
177
+ return nil unless summary
178
+
179
+ format(
180
+ "Executed-only MS %<score>s | MSI %<indicator>s",
181
+ score: format_percent(summary[:mutation_score]),
182
+ indicator: format_percent(summary[:mutation_score_indicator])
183
+ )
117
184
  end
118
185
 
119
186
  def partial_summary_lines(result)
@@ -167,19 +234,43 @@ module Henitai
167
234
  end
168
235
 
169
236
  def mutated_line(mutant)
170
- format("+ %s", display_unparse(mutant.mutated_node))
237
+ format("+ %s", display_mutated_source(mutant))
238
+ end
239
+
240
+ # Prefer the mutant's memoized unparse (shared with the JSON reporter);
241
+ # string literals keep the visible-escape rendering below.
242
+ def display_mutated_source(mutant)
243
+ node = mutant.mutated_node
244
+ return node.children.first.inspect if str_node?(node)
245
+ return mutant.mutated_source if mutant.respond_to?(:mutated_source)
246
+
247
+ display_unparse(node)
171
248
  end
172
249
 
173
250
  # Like safe_unparse but makes invisible characters visible in terminal
174
251
  # output. For string literal nodes the inner value is shown via #inspect
175
- # so that e.g. "" vs " " vs "\n" are unambiguous. Other nodes unparse
176
- # normally.
252
+ # so that e.g. "" vs " " vs "\n" are unambiguous. Nodes parsed from real
253
+ # source render their original source slice — unparsing is expensive and
254
+ # this path runs once per survivor; synthetic nodes unparse as before.
177
255
  def display_unparse(node)
178
- if node.respond_to?(:type) && node.respond_to?(:children) && node.type == :str
179
- node.children.first.inspect
180
- else
181
- safe_unparse(node)
182
- end
256
+ return node.children.first.inspect if str_node?(node)
257
+
258
+ source_slice(node) || safe_unparse(node)
259
+ end
260
+
261
+ def str_node?(node)
262
+ node.respond_to?(:type) && node.respond_to?(:children) && node.type == :str
263
+ end
264
+
265
+ # Single-line original source of a parsed node; nil for synthetic nodes
266
+ # or multi-line slices (those fall back to normalized unparsing).
267
+ def source_slice(node)
268
+ return nil unless node.respond_to?(:location)
269
+
270
+ source = node.location&.expression&.source
271
+ source unless source.nil? || source.include?("\n")
272
+ rescue StandardError
273
+ nil
183
274
  end
184
275
 
185
276
  def score_line(result)
@@ -226,7 +317,7 @@ module Henitai
226
317
  end
227
318
 
228
319
  def colorize(text, color)
229
- return text if ENV.key?("NO_COLOR")
320
+ return text unless color_enabled
230
321
 
231
322
  "\e[#{color}m#{text}\e[0m"
232
323
  end
@@ -250,7 +341,7 @@ module Henitai
250
341
  class Json < Base
251
342
  def report(result)
252
343
  schema = result.to_stryker_schema
253
- write_canonical(schema)
344
+ write_canonical(schema, authoritative: authoritative?(result))
254
345
  write_session_snapshot(schema)
255
346
  write_activation_recipes(result)
256
347
  write_history_report
@@ -258,9 +349,8 @@ module Henitai
258
349
 
259
350
  private
260
351
 
261
- def write_canonical(schema)
262
- FileUtils.mkdir_p(File.dirname(canonical_path))
263
- File.write(canonical_path, JSON.pretty_generate(schema))
352
+ def write_canonical(schema, authoritative:)
353
+ CanonicalReportWriter.write(schema, path: canonical_path, authoritative:)
264
354
  end
265
355
 
266
356
  def write_session_snapshot(schema)
@@ -299,10 +389,6 @@ module Henitai
299
389
  File.join(config.reports_dir, "sessions", session_id, SurvivorActivationCache::FILENAME)
300
390
  end
301
391
 
302
- def canonical_path
303
- File.join(config.reports_dir, "mutation-report.json")
304
- end
305
-
306
392
  def write_history_report
307
393
  store = history_store || default_history_store
308
394
  return unless File.exist?(store.path)
@@ -325,8 +411,12 @@ module Henitai
325
411
  # HTML reporter.
326
412
  class Html < Base
327
413
  def report(result)
414
+ report_schema(schema_for(result))
415
+ end
416
+
417
+ def report_schema(schema)
328
418
  FileUtils.mkdir_p(File.dirname(report_path))
329
- File.write(report_path, html_document(result))
419
+ File.write(report_path, html_document(schema))
330
420
  end
331
421
 
332
422
  private
@@ -335,7 +425,7 @@ module Henitai
335
425
  File.join(config.reports_dir, "mutation-report.html")
336
426
  end
337
427
 
338
- def html_document(result)
428
+ def html_document(schema)
339
429
  <<~HTML
340
430
  <!DOCTYPE html>
341
431
  <html lang="en">
@@ -347,7 +437,7 @@ module Henitai
347
437
  <body>
348
438
  <mutation-test-report-app titlePostfix="Henitai"></mutation-test-report-app>
349
439
  <script src="https://www.unpkg.com/mutation-testing-elements"></script>
350
- <script type="application/json" id="henitai-report-data">#{escaped_report_json(result)}</script>
440
+ <script type="application/json" id="henitai-report-data">#{escaped_report_json(schema)}</script>
351
441
  <script>
352
442
  const report = JSON.parse(
353
443
  document.getElementById("henitai-report-data").textContent
@@ -359,19 +449,138 @@ module Henitai
359
449
  HTML
360
450
  end
361
451
 
362
- def escaped_report_json(result)
363
- JSON.pretty_generate(result.to_stryker_schema)
452
+ def schema_for(result)
453
+ schema = result.to_stryker_schema
454
+ return schema if authoritative?(result)
455
+
456
+ CanonicalReportMerger.merge(schema, canonical_path, prune_missing: true)
457
+ end
458
+
459
+ def escaped_report_json(schema)
460
+ JSON.pretty_generate(schema)
364
461
  .gsub("&", "\\u0026")
365
462
  .gsub("<", "\\u003c")
366
463
  .gsub(">", "\\u003e")
367
464
  end
368
465
  end
369
466
 
370
- # Dashboard reporter.
467
+ # Dry-run listing: prints the post-filter mutant set (Gates 0–3) without
468
+ # any execution results. Used internally by the runner for `--dry-run`;
469
+ # not part of the `reporters:` configuration surface.
470
+ class DryRun < Base
471
+ def initialize(config:, history_store: nil, io: $stdout)
472
+ super(config:, history_store:)
473
+ @io = io
474
+ end
475
+
476
+ def report(result)
477
+ io.puts(listing_lines(result.mutants))
478
+ end
479
+
480
+ private
481
+
482
+ attr_reader :io
483
+
484
+ def listing_lines(mutants)
485
+ header = ["Dry run: #{mutants.size} mutants (no mutants executed)"]
486
+ return header + ["", summary_line(mutants)] if mutants.empty?
487
+
488
+ header + grouped_lines(mutants) + ["", summary_line(mutants)]
489
+ end
490
+
491
+ def grouped_lines(mutants)
492
+ mutants.group_by { |mutant| subject_label(mutant) }.flat_map do |label, subject_mutants|
493
+ ["", label] + subject_mutants.map { |mutant| mutant_line(mutant) }
494
+ end
495
+ end
496
+
497
+ def subject_label(mutant)
498
+ subject = mutant.subject
499
+ return subject.expression if subject.respond_to?(:expression) && subject.expression
500
+
501
+ mutant.location.fetch(:file, "(unknown subject)")
502
+ end
503
+
504
+ def mutant_line(mutant)
505
+ line = format(
506
+ " %<operator>s — %<description>s %<file>s:%<line>d [%<status>s]",
507
+ operator: mutant.operator,
508
+ description: mutant.description,
509
+ file: mutant.location.fetch(:file),
510
+ line: mutant.location.fetch(:start_line),
511
+ status: mutant.status
512
+ )
513
+ reason = ignore_reason_for(mutant)
514
+ reason ? "#{line} #{reason}" : line
515
+ end
516
+
517
+ def ignore_reason_for(mutant)
518
+ return nil unless mutant.respond_to?(:ignore_reason)
519
+
520
+ reason = mutant.ignore_reason
521
+ reason ? "(#{reason})" : nil
522
+ end
523
+
524
+ def summary_line(mutants)
525
+ counts = mutants.group_by(&:status).transform_values(&:size)
526
+ summary = counts.map { |status, count| "#{status} #{count}" }.join(" | ")
527
+ "Summary: #{summary.empty? ? 'no mutants' : summary}"
528
+ end
529
+ end
530
+
531
+ # GitHub Actions annotation reporter. Prints one `::warning` workflow
532
+ # command per survived mutant so survivors show up inline on the PR diff.
533
+ # Killed/ignored/no-coverage/timeout mutants stay silent.
534
+ class Github < Base
535
+ def initialize(config:, history_store: nil, io: $stdout)
536
+ super(config:, history_store:)
537
+ @io = io
538
+ end
539
+
540
+ def report(result)
541
+ result.mutants.select(&:survived?).each do |mutant|
542
+ io.puts(annotation_line(mutant))
543
+ end
544
+ end
545
+
546
+ private
547
+
548
+ attr_reader :io
549
+
550
+ def annotation_line(mutant)
551
+ format(
552
+ "::warning file=%<file>s,line=%<line>d::%<message>s",
553
+ file: relative_path(mutant.location.fetch(:file)),
554
+ line: mutant.location.fetch(:start_line),
555
+ message: escape_message("Survived mutant: #{mutant.operator} — #{mutant.description}")
556
+ )
557
+ end
558
+
559
+ def relative_path(file)
560
+ pathname = Pathname.new(file)
561
+ return file unless pathname.absolute?
562
+
563
+ pathname.relative_path_from(Dir.pwd).to_s
564
+ end
565
+
566
+ # GitHub workflow-command message payload escaping: only `%`, LF and CR
567
+ # are significant; everything else is parsed literally.
568
+ def escape_message(message)
569
+ message.gsub("%", "%25").gsub("\n", "%0A").gsub("\r", "%0D")
570
+ end
571
+ end
572
+
573
+ # Dashboard reporter. Delegates project/version/api-key resolution to
574
+ # {DashboardMetadataProvider} so it never touches real ENV or git itself.
371
575
  class Dashboard < Base
372
576
  DEFAULT_BASE_URL = "https://dashboard.stryker-mutator.io"
373
577
  HTTP_TIMEOUT_SECONDS = 30
374
578
 
579
+ def initialize(config:, history_store: nil, metadata_provider: nil)
580
+ super(config:, history_store:)
581
+ @metadata_provider = metadata_provider || DashboardMetadataProvider.new(dashboard_config: config.dashboard)
582
+ end
583
+
375
584
  def report(result)
376
585
  return unless ready?
377
586
 
@@ -380,8 +589,20 @@ module Henitai
380
589
  send_request(uri, request)
381
590
  end
382
591
 
592
+ class << self
593
+ def project_from_git_url(url)
594
+ DashboardMetadataProvider.project_from_git_url(url)
595
+ end
596
+ end
597
+
383
598
  private
384
599
 
600
+ attr_reader :metadata_provider
601
+
602
+ def project = metadata_provider.project
603
+ def version = metadata_provider.version
604
+ def api_key = metadata_provider.api_key
605
+
385
606
  def ready?
386
607
  !project.nil? && !version.nil? && !api_key.nil?
387
608
  end
@@ -413,7 +634,7 @@ module Henitai
413
634
  def dashboard_uri
414
635
  uri = URI.parse(base_url)
415
636
  # @type var segments: Array[String]
416
- base_path = uri.path.to_s.chomp("/")
637
+ base_path = uri.path.to_s.chomp("/").delete_prefix("/")
417
638
  segments = []
418
639
  segments << base_path unless base_path.empty?
419
640
  segments += ["api", "reports", project_path, encoded_version]
@@ -427,38 +648,6 @@ module Henitai
427
648
  config.dashboard[:base_url] || DEFAULT_BASE_URL
428
649
  end
429
650
 
430
- def project
431
- @project ||= config.dashboard[:project] || project_from_git_remote
432
- end
433
-
434
- def version
435
- @version ||= env_version || git_branch_name
436
- end
437
-
438
- def env_version
439
- ref_name = ENV.fetch("GITHUB_REF_NAME", nil)
440
- return ref_name unless blank?(ref_name)
441
-
442
- ref = ENV.fetch("GITHUB_REF", nil)
443
- return ref_without_prefix(ref) unless ref.nil? || blank?(ref)
444
-
445
- ENV.fetch("GITHUB_SHA", nil)
446
- end
447
-
448
- def ref_without_prefix(ref)
449
- return nil if blank?(ref)
450
-
451
- ref.to_s.sub(%r{^refs/(heads|tags|pull)/}, "")
452
- end
453
-
454
- def project_from_git_remote
455
- self.class.project_from_git_url(git_remote_url)
456
- end
457
-
458
- def api_key
459
- ENV.fetch("STRYKER_DASHBOARD_API_KEY", nil)
460
- end
461
-
462
651
  def project_path
463
652
  project.to_s.split("/").map { |segment| URI.encode_www_form_component(segment) }.join("/")
464
653
  end
@@ -466,64 +655,6 @@ module Henitai
466
655
  def encoded_version
467
656
  URI.encode_www_form_component(version.to_s)
468
657
  end
469
-
470
- def blank?(value)
471
- value.nil? || value.strip.empty?
472
- end
473
-
474
- def git_remote_url
475
- stdout, status = Open3.capture2("git", "remote", "get-url", "origin")
476
- return stdout.strip if status.success?
477
-
478
- nil
479
- rescue Errno::ENOENT
480
- nil
481
- end
482
-
483
- def git_branch_name
484
- stdout, status = Open3.capture2("git", "rev-parse", "--abbrev-ref", "HEAD")
485
- return stdout.strip if status.success? && !stdout.strip.empty?
486
-
487
- nil
488
- rescue Errno::ENOENT
489
- nil
490
- end
491
-
492
- class << self
493
- def project_from_git_url(url)
494
- normalized = normalize_git_url(url)
495
- return nil if normalized.nil?
496
-
497
- return project_from_uri_url(normalized) if normalized.include?("://")
498
- return project_from_ssh_url(normalized) if normalized.include?("@")
499
-
500
- normalized
501
- rescue URI::InvalidURIError
502
- nil
503
- end
504
-
505
- def normalize_git_url(url)
506
- return nil if url.nil? || url.strip.empty?
507
-
508
- url.strip.sub(/\.git\z/, "")
509
- end
510
-
511
- def project_from_uri_url(normalized)
512
- uri = URI.parse(normalized)
513
- path = uri.path.to_s.sub(%r{^/}, "")
514
- [uri.host, path].compact.reject(&:empty?).join("/")
515
- end
516
-
517
- def project_from_ssh_url(normalized)
518
- _, host_and_path = normalized.split("@", 2)
519
- return nil if host_and_path.nil?
520
-
521
- host, path = host_and_path.split(":", 2)
522
- return nil unless host && path
523
-
524
- "#{host}/#{path}"
525
- end
526
- end
527
658
  end
528
659
  end
529
660
  end