henitai 0.2.1 → 0.3.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/README.md +123 -2
  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 +64 -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 +66 -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 +238 -117
  55. data/lib/henitai/reports_directory_lock.rb +76 -0
  56. data/lib/henitai/result.rb +72 -9
  57. data/lib/henitai/runner.rb +82 -40
  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 +230 -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,42 @@ 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
+ ].compact
142
+ end
143
+
144
+ def reused_verdicts_line(result)
145
+ reused = reused_mutants(result)
146
+ return nil if reused.empty?
147
+
148
+ format(
149
+ "%<reused>d of %<total>d verdicts reused from history (%<killed>d killed, %<survived>d survived)",
150
+ reused: reused.size,
151
+ total: result.mutants.size,
152
+ killed: reused.count { |mutant| mutant.status == :killed },
153
+ survived: reused.count { |mutant| mutant.status == :survived }
154
+ )
155
+ end
156
+
157
+ def reused_mutants(result)
158
+ result.mutants.select { |mutant| mutant.respond_to?(:from_cache?) && mutant.from_cache? }
159
+ end
160
+
161
+ # Cached verdicts make the combined MS/MSI partly synthetic; the
162
+ # executed-only pair keeps this run's own signal visible.
163
+ def executed_only_score_line(result)
164
+ return nil unless result.respond_to?(:executed_scoring_summary)
165
+
166
+ summary = result.executed_scoring_summary # : Hash[Symbol, untyped]?
167
+ return nil unless summary
168
+
169
+ format(
170
+ "Executed-only MS %<score>s | MSI %<indicator>s",
171
+ score: format_percent(summary[:mutation_score]),
172
+ indicator: format_percent(summary[:mutation_score_indicator])
173
+ )
117
174
  end
118
175
 
119
176
  def partial_summary_lines(result)
@@ -167,19 +224,43 @@ module Henitai
167
224
  end
168
225
 
169
226
  def mutated_line(mutant)
170
- format("+ %s", display_unparse(mutant.mutated_node))
227
+ format("+ %s", display_mutated_source(mutant))
228
+ end
229
+
230
+ # Prefer the mutant's memoized unparse (shared with the JSON reporter);
231
+ # string literals keep the visible-escape rendering below.
232
+ def display_mutated_source(mutant)
233
+ node = mutant.mutated_node
234
+ return node.children.first.inspect if str_node?(node)
235
+ return mutant.mutated_source if mutant.respond_to?(:mutated_source)
236
+
237
+ display_unparse(node)
171
238
  end
172
239
 
173
240
  # Like safe_unparse but makes invisible characters visible in terminal
174
241
  # 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.
242
+ # so that e.g. "" vs " " vs "\n" are unambiguous. Nodes parsed from real
243
+ # source render their original source slice — unparsing is expensive and
244
+ # this path runs once per survivor; synthetic nodes unparse as before.
177
245
  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
246
+ return node.children.first.inspect if str_node?(node)
247
+
248
+ source_slice(node) || safe_unparse(node)
249
+ end
250
+
251
+ def str_node?(node)
252
+ node.respond_to?(:type) && node.respond_to?(:children) && node.type == :str
253
+ end
254
+
255
+ # Single-line original source of a parsed node; nil for synthetic nodes
256
+ # or multi-line slices (those fall back to normalized unparsing).
257
+ def source_slice(node)
258
+ return nil unless node.respond_to?(:location)
259
+
260
+ source = node.location&.expression&.source
261
+ source unless source.nil? || source.include?("\n")
262
+ rescue StandardError
263
+ nil
183
264
  end
184
265
 
185
266
  def score_line(result)
@@ -226,7 +307,7 @@ module Henitai
226
307
  end
227
308
 
228
309
  def colorize(text, color)
229
- return text if ENV.key?("NO_COLOR")
310
+ return text unless color_enabled
230
311
 
231
312
  "\e[#{color}m#{text}\e[0m"
232
313
  end
@@ -250,7 +331,7 @@ module Henitai
250
331
  class Json < Base
251
332
  def report(result)
252
333
  schema = result.to_stryker_schema
253
- write_canonical(schema)
334
+ write_canonical(schema, authoritative: authoritative?(result))
254
335
  write_session_snapshot(schema)
255
336
  write_activation_recipes(result)
256
337
  write_history_report
@@ -258,9 +339,8 @@ module Henitai
258
339
 
259
340
  private
260
341
 
261
- def write_canonical(schema)
262
- FileUtils.mkdir_p(File.dirname(canonical_path))
263
- File.write(canonical_path, JSON.pretty_generate(schema))
342
+ def write_canonical(schema, authoritative:)
343
+ CanonicalReportWriter.write(schema, path: canonical_path, authoritative:)
264
344
  end
265
345
 
266
346
  def write_session_snapshot(schema)
@@ -299,10 +379,6 @@ module Henitai
299
379
  File.join(config.reports_dir, "sessions", session_id, SurvivorActivationCache::FILENAME)
300
380
  end
301
381
 
302
- def canonical_path
303
- File.join(config.reports_dir, "mutation-report.json")
304
- end
305
-
306
382
  def write_history_report
307
383
  store = history_store || default_history_store
308
384
  return unless File.exist?(store.path)
@@ -325,8 +401,12 @@ module Henitai
325
401
  # HTML reporter.
326
402
  class Html < Base
327
403
  def report(result)
404
+ report_schema(schema_for(result))
405
+ end
406
+
407
+ def report_schema(schema)
328
408
  FileUtils.mkdir_p(File.dirname(report_path))
329
- File.write(report_path, html_document(result))
409
+ File.write(report_path, html_document(schema))
330
410
  end
331
411
 
332
412
  private
@@ -335,7 +415,7 @@ module Henitai
335
415
  File.join(config.reports_dir, "mutation-report.html")
336
416
  end
337
417
 
338
- def html_document(result)
418
+ def html_document(schema)
339
419
  <<~HTML
340
420
  <!DOCTYPE html>
341
421
  <html lang="en">
@@ -347,7 +427,7 @@ module Henitai
347
427
  <body>
348
428
  <mutation-test-report-app titlePostfix="Henitai"></mutation-test-report-app>
349
429
  <script src="https://www.unpkg.com/mutation-testing-elements"></script>
350
- <script type="application/json" id="henitai-report-data">#{escaped_report_json(result)}</script>
430
+ <script type="application/json" id="henitai-report-data">#{escaped_report_json(schema)}</script>
351
431
  <script>
352
432
  const report = JSON.parse(
353
433
  document.getElementById("henitai-report-data").textContent
@@ -359,19 +439,138 @@ module Henitai
359
439
  HTML
360
440
  end
361
441
 
362
- def escaped_report_json(result)
363
- JSON.pretty_generate(result.to_stryker_schema)
442
+ def schema_for(result)
443
+ schema = result.to_stryker_schema
444
+ return schema if authoritative?(result)
445
+
446
+ CanonicalReportMerger.merge(schema, canonical_path, prune_missing: true)
447
+ end
448
+
449
+ def escaped_report_json(schema)
450
+ JSON.pretty_generate(schema)
364
451
  .gsub("&", "\\u0026")
365
452
  .gsub("<", "\\u003c")
366
453
  .gsub(">", "\\u003e")
367
454
  end
368
455
  end
369
456
 
370
- # Dashboard reporter.
457
+ # Dry-run listing: prints the post-filter mutant set (Gates 0–3) without
458
+ # any execution results. Used internally by the runner for `--dry-run`;
459
+ # not part of the `reporters:` configuration surface.
460
+ class DryRun < Base
461
+ def initialize(config:, history_store: nil, io: $stdout)
462
+ super(config:, history_store:)
463
+ @io = io
464
+ end
465
+
466
+ def report(result)
467
+ io.puts(listing_lines(result.mutants))
468
+ end
469
+
470
+ private
471
+
472
+ attr_reader :io
473
+
474
+ def listing_lines(mutants)
475
+ header = ["Dry run: #{mutants.size} mutants (no mutants executed)"]
476
+ return header + ["", summary_line(mutants)] if mutants.empty?
477
+
478
+ header + grouped_lines(mutants) + ["", summary_line(mutants)]
479
+ end
480
+
481
+ def grouped_lines(mutants)
482
+ mutants.group_by { |mutant| subject_label(mutant) }.flat_map do |label, subject_mutants|
483
+ ["", label] + subject_mutants.map { |mutant| mutant_line(mutant) }
484
+ end
485
+ end
486
+
487
+ def subject_label(mutant)
488
+ subject = mutant.subject
489
+ return subject.expression if subject.respond_to?(:expression) && subject.expression
490
+
491
+ mutant.location.fetch(:file, "(unknown subject)")
492
+ end
493
+
494
+ def mutant_line(mutant)
495
+ line = format(
496
+ " %<operator>s — %<description>s %<file>s:%<line>d [%<status>s]",
497
+ operator: mutant.operator,
498
+ description: mutant.description,
499
+ file: mutant.location.fetch(:file),
500
+ line: mutant.location.fetch(:start_line),
501
+ status: mutant.status
502
+ )
503
+ reason = ignore_reason_for(mutant)
504
+ reason ? "#{line} #{reason}" : line
505
+ end
506
+
507
+ def ignore_reason_for(mutant)
508
+ return nil unless mutant.respond_to?(:ignore_reason)
509
+
510
+ reason = mutant.ignore_reason
511
+ reason ? "(#{reason})" : nil
512
+ end
513
+
514
+ def summary_line(mutants)
515
+ counts = mutants.group_by(&:status).transform_values(&:size)
516
+ summary = counts.map { |status, count| "#{status} #{count}" }.join(" | ")
517
+ "Summary: #{summary.empty? ? 'no mutants' : summary}"
518
+ end
519
+ end
520
+
521
+ # GitHub Actions annotation reporter. Prints one `::warning` workflow
522
+ # command per survived mutant so survivors show up inline on the PR diff.
523
+ # Killed/ignored/no-coverage/timeout mutants stay silent.
524
+ class Github < Base
525
+ def initialize(config:, history_store: nil, io: $stdout)
526
+ super(config:, history_store:)
527
+ @io = io
528
+ end
529
+
530
+ def report(result)
531
+ result.mutants.select(&:survived?).each do |mutant|
532
+ io.puts(annotation_line(mutant))
533
+ end
534
+ end
535
+
536
+ private
537
+
538
+ attr_reader :io
539
+
540
+ def annotation_line(mutant)
541
+ format(
542
+ "::warning file=%<file>s,line=%<line>d::%<message>s",
543
+ file: relative_path(mutant.location.fetch(:file)),
544
+ line: mutant.location.fetch(:start_line),
545
+ message: escape_message("Survived mutant: #{mutant.operator} — #{mutant.description}")
546
+ )
547
+ end
548
+
549
+ def relative_path(file)
550
+ pathname = Pathname.new(file)
551
+ return file unless pathname.absolute?
552
+
553
+ pathname.relative_path_from(Dir.pwd).to_s
554
+ end
555
+
556
+ # GitHub workflow-command message payload escaping: only `%`, LF and CR
557
+ # are significant; everything else is parsed literally.
558
+ def escape_message(message)
559
+ message.gsub("%", "%25").gsub("\n", "%0A").gsub("\r", "%0D")
560
+ end
561
+ end
562
+
563
+ # Dashboard reporter. Delegates project/version/api-key resolution to
564
+ # {DashboardMetadataProvider} so it never touches real ENV or git itself.
371
565
  class Dashboard < Base
372
566
  DEFAULT_BASE_URL = "https://dashboard.stryker-mutator.io"
373
567
  HTTP_TIMEOUT_SECONDS = 30
374
568
 
569
+ def initialize(config:, history_store: nil, metadata_provider: nil)
570
+ super(config:, history_store:)
571
+ @metadata_provider = metadata_provider || DashboardMetadataProvider.new(dashboard_config: config.dashboard)
572
+ end
573
+
375
574
  def report(result)
376
575
  return unless ready?
377
576
 
@@ -380,8 +579,20 @@ module Henitai
380
579
  send_request(uri, request)
381
580
  end
382
581
 
582
+ class << self
583
+ def project_from_git_url(url)
584
+ DashboardMetadataProvider.project_from_git_url(url)
585
+ end
586
+ end
587
+
383
588
  private
384
589
 
590
+ attr_reader :metadata_provider
591
+
592
+ def project = metadata_provider.project
593
+ def version = metadata_provider.version
594
+ def api_key = metadata_provider.api_key
595
+
385
596
  def ready?
386
597
  !project.nil? && !version.nil? && !api_key.nil?
387
598
  end
@@ -413,7 +624,7 @@ module Henitai
413
624
  def dashboard_uri
414
625
  uri = URI.parse(base_url)
415
626
  # @type var segments: Array[String]
416
- base_path = uri.path.to_s.chomp("/")
627
+ base_path = uri.path.to_s.chomp("/").delete_prefix("/")
417
628
  segments = []
418
629
  segments << base_path unless base_path.empty?
419
630
  segments += ["api", "reports", project_path, encoded_version]
@@ -427,38 +638,6 @@ module Henitai
427
638
  config.dashboard[:base_url] || DEFAULT_BASE_URL
428
639
  end
429
640
 
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
641
  def project_path
463
642
  project.to_s.split("/").map { |segment| URI.encode_www_form_component(segment) }.join("/")
464
643
  end
@@ -466,64 +645,6 @@ module Henitai
466
645
  def encoded_version
467
646
  URI.encode_www_form_component(version.to_s)
468
647
  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
648
  end
528
649
  end
529
650
  end