henitai 0.2.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +105 -1
- data/README.md +138 -5
- data/assets/schema/henitai.schema.json +41 -0
- data/lib/henitai/available_cpu_count.rb +16 -23
- data/lib/henitai/canonical_report_merger.rb +107 -0
- data/lib/henitai/canonical_report_writer.rb +22 -0
- data/lib/henitai/checkpoint_reporter.rb +79 -0
- data/lib/henitai/cli/clean_command.rb +54 -0
- data/lib/henitai/cli/command_support.rb +52 -0
- data/lib/henitai/cli/init_command.rb +64 -0
- data/lib/henitai/cli/operator_command.rb +96 -0
- data/lib/henitai/cli/options.rb +84 -0
- data/lib/henitai/cli/run_command.rb +136 -0
- data/lib/henitai/cli/run_options.rb +108 -0
- data/lib/henitai/cli.rb +27 -407
- data/lib/henitai/composite_progress_reporter.rb +42 -0
- data/lib/henitai/configuration.rb +42 -9
- data/lib/henitai/configuration_validator/rules.rb +161 -0
- data/lib/henitai/configuration_validator/scalars.rb +169 -0
- data/lib/henitai/configuration_validator.rb +20 -240
- data/lib/henitai/coverage_bootstrapper.rb +54 -5
- data/lib/henitai/coverage_formatter.rb +5 -1
- data/lib/henitai/coverage_report_reader.rb +28 -5
- data/lib/henitai/eager_load.rb +36 -5
- data/lib/henitai/execution_engine/env_scope.rb +67 -0
- data/lib/henitai/execution_engine.rb +115 -53
- data/lib/henitai/generated_artifacts.rb +58 -0
- data/lib/henitai/incremental_filter.rb +100 -0
- data/lib/henitai/integration/base.rb +171 -0
- data/lib/henitai/integration/child_debug_support.rb +119 -0
- data/lib/henitai/integration/child_runtime_control.rb +50 -0
- data/lib/henitai/integration/coverage_suppression.rb +52 -0
- data/lib/henitai/integration/minitest.rb +101 -0
- data/lib/henitai/integration/minitest_load_path.rb +14 -0
- data/lib/henitai/integration/minitest_suite_command.rb +17 -0
- data/lib/henitai/integration/minitest_test_runner.rb +39 -0
- data/lib/henitai/integration/mutant_run_support.rb +77 -0
- data/lib/henitai/integration/rails_environment_preloader.rb +14 -0
- data/lib/henitai/integration/rspec_child_runner.rb +61 -0
- data/lib/henitai/integration/rspec_test_selection.rb +138 -0
- data/lib/henitai/integration/scenario_log_support.rb +160 -0
- data/lib/henitai/integration.rb +22 -846
- data/lib/henitai/minitest_coverage_reporter.rb +4 -1
- data/lib/henitai/mutant/activator.rb +15 -79
- data/lib/henitai/mutant/parameter_source.rb +98 -0
- data/lib/henitai/mutant.rb +14 -6
- data/lib/henitai/mutant_history_store/sql.rb +90 -0
- data/lib/henitai/mutant_history_store/verdict_cache.rb +84 -0
- data/lib/henitai/mutant_history_store.rb +67 -81
- data/lib/henitai/mutant_identity.rb +33 -2
- data/lib/henitai/mutation_skip_directives.rb +227 -0
- data/lib/henitai/operator.rb +3 -1
- data/lib/henitai/operators/equality_identity_operator.rb +51 -0
- data/lib/henitai/operators/equality_operator.rb +8 -3
- data/lib/henitai/operators.rb +1 -0
- data/lib/henitai/per_test_coverage.rb +66 -0
- data/lib/henitai/per_test_coverage_collector.rb +23 -6
- data/lib/henitai/per_test_coverage_selector.rb +11 -33
- data/lib/henitai/process_worker_runner.rb +48 -334
- data/lib/henitai/reporter/dashboard_metadata_provider.rb +113 -0
- data/lib/henitai/reporter.rb +258 -125
- data/lib/henitai/reports_directory_lock.rb +76 -0
- data/lib/henitai/result.rb +88 -23
- data/lib/henitai/runner.rb +137 -218
- data/lib/henitai/scenario_execution_result.rb +12 -0
- data/lib/henitai/slot_scheduler/draining.rb +146 -0
- data/lib/henitai/slot_scheduler/process_control.rb +43 -0
- data/lib/henitai/slot_scheduler.rb +272 -0
- data/lib/henitai/static_filter.rb +16 -6
- data/lib/henitai/survivor_rerun_strategy.rb +195 -0
- data/lib/henitai/survivor_test_filter.rb +1 -1
- data/lib/henitai/test_prioritizer.rb +28 -2
- data/lib/henitai/timeout_calibrator.rb +44 -0
- data/lib/henitai/unparse_helper.rb +5 -2
- data/lib/henitai/verdict_fingerprint.rb +155 -0
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +16 -1
- data/sig/configuration_validator.rbs +46 -22
- data/sig/henitai.rbs +386 -96
- metadata +45 -3
- data/lib/henitai/parallel_execution_runner.rb +0 -153
data/lib/henitai/reporter.rb
CHANGED
|
@@ -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.
|
|
@@ -22,22 +23,29 @@ module Henitai
|
|
|
22
23
|
# @param names [Array<String>] reporter names from configuration
|
|
23
24
|
# @param result [Result]
|
|
24
25
|
# @param config [Configuration]
|
|
25
|
-
|
|
26
|
+
# @param history_store [MutantHistoryStore, nil] persistence store the JSON
|
|
27
|
+
# reporter reads trend data from; supplied by the composition root so the
|
|
28
|
+
# reporter does not build infrastructure itself.
|
|
29
|
+
def self.run_all(names:, result:, config:, history_store: nil)
|
|
26
30
|
names.each do |name|
|
|
27
|
-
reporter_class(name).new(config:).report(result)
|
|
31
|
+
reporter_class(name).new(config:, history_store:).report(result)
|
|
28
32
|
end
|
|
29
33
|
end
|
|
30
34
|
|
|
31
35
|
def self.reporter_class(name)
|
|
32
36
|
const_get(name.capitalize)
|
|
33
37
|
rescue NameError
|
|
34
|
-
raise ArgumentError, "Unknown reporter: #{name}. Valid reporters: terminal, json, html, dashboard"
|
|
38
|
+
raise ArgumentError, "Unknown reporter: #{name}. Valid reporters: terminal, json, html, dashboard, github"
|
|
35
39
|
end
|
|
36
40
|
|
|
37
41
|
# Base class for all reporters.
|
|
38
42
|
class Base
|
|
39
|
-
|
|
43
|
+
# @param history_store [MutantHistoryStore, nil] accepted by every
|
|
44
|
+
# reporter for a uniform factory signature; only the JSON reporter uses
|
|
45
|
+
# it. Ignored elsewhere.
|
|
46
|
+
def initialize(config:, history_store: nil)
|
|
40
47
|
@config = config
|
|
48
|
+
@history_store = history_store
|
|
41
49
|
end
|
|
42
50
|
|
|
43
51
|
# @param result [Result]
|
|
@@ -47,7 +55,22 @@ module Henitai
|
|
|
47
55
|
|
|
48
56
|
private
|
|
49
57
|
|
|
50
|
-
attr_reader :config
|
|
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
|
|
51
74
|
end
|
|
52
75
|
|
|
53
76
|
# Terminal reporter.
|
|
@@ -61,6 +84,11 @@ module Henitai
|
|
|
61
84
|
ignored: "I"
|
|
62
85
|
}.freeze
|
|
63
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
|
+
|
|
64
92
|
def report(result)
|
|
65
93
|
puts report_lines(result)
|
|
66
94
|
end
|
|
@@ -81,6 +109,8 @@ module Henitai
|
|
|
81
109
|
|
|
82
110
|
private
|
|
83
111
|
|
|
112
|
+
attr_reader :color_enabled
|
|
113
|
+
|
|
84
114
|
def report_lines(result)
|
|
85
115
|
lines = summary_lines(result)
|
|
86
116
|
detail_lines = survived_detail_lines(result)
|
|
@@ -105,8 +135,42 @@ module Henitai
|
|
|
105
135
|
format_row("Survived", count_status(result, :survived)),
|
|
106
136
|
format_row("Timeout", count_status(result, :timeout)),
|
|
107
137
|
format_row("No coverage", count_status(result, :no_coverage)),
|
|
108
|
-
format_row("Duration", format_duration(result.duration))
|
|
109
|
-
|
|
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
|
+
)
|
|
110
174
|
end
|
|
111
175
|
|
|
112
176
|
def partial_summary_lines(result)
|
|
@@ -160,19 +224,43 @@ module Henitai
|
|
|
160
224
|
end
|
|
161
225
|
|
|
162
226
|
def mutated_line(mutant)
|
|
163
|
-
format("+ %s",
|
|
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)
|
|
164
238
|
end
|
|
165
239
|
|
|
166
240
|
# Like safe_unparse but makes invisible characters visible in terminal
|
|
167
241
|
# output. For string literal nodes the inner value is shown via #inspect
|
|
168
|
-
# so that e.g. "" vs " " vs "\n" are unambiguous.
|
|
169
|
-
#
|
|
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.
|
|
170
245
|
def display_unparse(node)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
176
264
|
end
|
|
177
265
|
|
|
178
266
|
def score_line(result)
|
|
@@ -219,7 +307,7 @@ module Henitai
|
|
|
219
307
|
end
|
|
220
308
|
|
|
221
309
|
def colorize(text, color)
|
|
222
|
-
return text
|
|
310
|
+
return text unless color_enabled
|
|
223
311
|
|
|
224
312
|
"\e[#{color}m#{text}\e[0m"
|
|
225
313
|
end
|
|
@@ -243,7 +331,7 @@ module Henitai
|
|
|
243
331
|
class Json < Base
|
|
244
332
|
def report(result)
|
|
245
333
|
schema = result.to_stryker_schema
|
|
246
|
-
write_canonical(schema)
|
|
334
|
+
write_canonical(schema, authoritative: authoritative?(result))
|
|
247
335
|
write_session_snapshot(schema)
|
|
248
336
|
write_activation_recipes(result)
|
|
249
337
|
write_history_report
|
|
@@ -251,9 +339,8 @@ module Henitai
|
|
|
251
339
|
|
|
252
340
|
private
|
|
253
341
|
|
|
254
|
-
def write_canonical(schema)
|
|
255
|
-
|
|
256
|
-
File.write(canonical_path, JSON.pretty_generate(schema))
|
|
342
|
+
def write_canonical(schema, authoritative:)
|
|
343
|
+
CanonicalReportWriter.write(schema, path: canonical_path, authoritative:)
|
|
257
344
|
end
|
|
258
345
|
|
|
259
346
|
def write_session_snapshot(schema)
|
|
@@ -292,17 +379,18 @@ module Henitai
|
|
|
292
379
|
File.join(config.reports_dir, "sessions", session_id, SurvivorActivationCache::FILENAME)
|
|
293
380
|
end
|
|
294
381
|
|
|
295
|
-
def canonical_path
|
|
296
|
-
File.join(config.reports_dir, "mutation-report.json")
|
|
297
|
-
end
|
|
298
|
-
|
|
299
382
|
def write_history_report
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
return unless File.exist?(path)
|
|
383
|
+
store = history_store || default_history_store
|
|
384
|
+
return unless File.exist?(store.path)
|
|
303
385
|
|
|
304
386
|
FileUtils.mkdir_p(File.dirname(history_report_path))
|
|
305
|
-
File.write(history_report_path, JSON.pretty_generate(
|
|
387
|
+
File.write(history_report_path, JSON.pretty_generate(store.trend_report))
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def default_history_store
|
|
391
|
+
MutantHistoryStore.new(
|
|
392
|
+
path: File.join(config.reports_dir, Henitai::HISTORY_STORE_FILENAME)
|
|
393
|
+
)
|
|
306
394
|
end
|
|
307
395
|
|
|
308
396
|
def history_report_path
|
|
@@ -313,8 +401,12 @@ module Henitai
|
|
|
313
401
|
# HTML reporter.
|
|
314
402
|
class Html < Base
|
|
315
403
|
def report(result)
|
|
404
|
+
report_schema(schema_for(result))
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def report_schema(schema)
|
|
316
408
|
FileUtils.mkdir_p(File.dirname(report_path))
|
|
317
|
-
File.write(report_path, html_document(
|
|
409
|
+
File.write(report_path, html_document(schema))
|
|
318
410
|
end
|
|
319
411
|
|
|
320
412
|
private
|
|
@@ -323,7 +415,7 @@ module Henitai
|
|
|
323
415
|
File.join(config.reports_dir, "mutation-report.html")
|
|
324
416
|
end
|
|
325
417
|
|
|
326
|
-
def html_document(
|
|
418
|
+
def html_document(schema)
|
|
327
419
|
<<~HTML
|
|
328
420
|
<!DOCTYPE html>
|
|
329
421
|
<html lang="en">
|
|
@@ -335,7 +427,7 @@ module Henitai
|
|
|
335
427
|
<body>
|
|
336
428
|
<mutation-test-report-app titlePostfix="Henitai"></mutation-test-report-app>
|
|
337
429
|
<script src="https://www.unpkg.com/mutation-testing-elements"></script>
|
|
338
|
-
<script type="application/json" id="henitai-report-data">#{escaped_report_json(
|
|
430
|
+
<script type="application/json" id="henitai-report-data">#{escaped_report_json(schema)}</script>
|
|
339
431
|
<script>
|
|
340
432
|
const report = JSON.parse(
|
|
341
433
|
document.getElementById("henitai-report-data").textContent
|
|
@@ -347,19 +439,138 @@ module Henitai
|
|
|
347
439
|
HTML
|
|
348
440
|
end
|
|
349
441
|
|
|
350
|
-
def
|
|
351
|
-
|
|
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)
|
|
352
451
|
.gsub("&", "\\u0026")
|
|
353
452
|
.gsub("<", "\\u003c")
|
|
354
453
|
.gsub(">", "\\u003e")
|
|
355
454
|
end
|
|
356
455
|
end
|
|
357
456
|
|
|
358
|
-
#
|
|
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.
|
|
359
565
|
class Dashboard < Base
|
|
360
566
|
DEFAULT_BASE_URL = "https://dashboard.stryker-mutator.io"
|
|
361
567
|
HTTP_TIMEOUT_SECONDS = 30
|
|
362
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
|
+
|
|
363
574
|
def report(result)
|
|
364
575
|
return unless ready?
|
|
365
576
|
|
|
@@ -368,8 +579,20 @@ module Henitai
|
|
|
368
579
|
send_request(uri, request)
|
|
369
580
|
end
|
|
370
581
|
|
|
582
|
+
class << self
|
|
583
|
+
def project_from_git_url(url)
|
|
584
|
+
DashboardMetadataProvider.project_from_git_url(url)
|
|
585
|
+
end
|
|
586
|
+
end
|
|
587
|
+
|
|
371
588
|
private
|
|
372
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
|
+
|
|
373
596
|
def ready?
|
|
374
597
|
!project.nil? && !version.nil? && !api_key.nil?
|
|
375
598
|
end
|
|
@@ -401,7 +624,7 @@ module Henitai
|
|
|
401
624
|
def dashboard_uri
|
|
402
625
|
uri = URI.parse(base_url)
|
|
403
626
|
# @type var segments: Array[String]
|
|
404
|
-
base_path = uri.path.to_s.chomp("/")
|
|
627
|
+
base_path = uri.path.to_s.chomp("/").delete_prefix("/")
|
|
405
628
|
segments = []
|
|
406
629
|
segments << base_path unless base_path.empty?
|
|
407
630
|
segments += ["api", "reports", project_path, encoded_version]
|
|
@@ -415,38 +638,6 @@ module Henitai
|
|
|
415
638
|
config.dashboard[:base_url] || DEFAULT_BASE_URL
|
|
416
639
|
end
|
|
417
640
|
|
|
418
|
-
def project
|
|
419
|
-
@project ||= config.dashboard[:project] || project_from_git_remote
|
|
420
|
-
end
|
|
421
|
-
|
|
422
|
-
def version
|
|
423
|
-
@version ||= env_version || git_branch_name
|
|
424
|
-
end
|
|
425
|
-
|
|
426
|
-
def env_version
|
|
427
|
-
ref_name = ENV.fetch("GITHUB_REF_NAME", nil)
|
|
428
|
-
return ref_name unless blank?(ref_name)
|
|
429
|
-
|
|
430
|
-
ref = ENV.fetch("GITHUB_REF", nil)
|
|
431
|
-
return ref_without_prefix(ref) unless ref.nil? || blank?(ref)
|
|
432
|
-
|
|
433
|
-
ENV.fetch("GITHUB_SHA", nil)
|
|
434
|
-
end
|
|
435
|
-
|
|
436
|
-
def ref_without_prefix(ref)
|
|
437
|
-
return nil if blank?(ref)
|
|
438
|
-
|
|
439
|
-
ref.to_s.sub(%r{^refs/(heads|tags|pull)/}, "")
|
|
440
|
-
end
|
|
441
|
-
|
|
442
|
-
def project_from_git_remote
|
|
443
|
-
self.class.project_from_git_url(git_remote_url)
|
|
444
|
-
end
|
|
445
|
-
|
|
446
|
-
def api_key
|
|
447
|
-
ENV.fetch("STRYKER_DASHBOARD_API_KEY", nil)
|
|
448
|
-
end
|
|
449
|
-
|
|
450
641
|
def project_path
|
|
451
642
|
project.to_s.split("/").map { |segment| URI.encode_www_form_component(segment) }.join("/")
|
|
452
643
|
end
|
|
@@ -454,64 +645,6 @@ module Henitai
|
|
|
454
645
|
def encoded_version
|
|
455
646
|
URI.encode_www_form_component(version.to_s)
|
|
456
647
|
end
|
|
457
|
-
|
|
458
|
-
def blank?(value)
|
|
459
|
-
value.nil? || value.strip.empty?
|
|
460
|
-
end
|
|
461
|
-
|
|
462
|
-
def git_remote_url
|
|
463
|
-
stdout, status = Open3.capture2("git", "remote", "get-url", "origin")
|
|
464
|
-
return stdout.strip if status.success?
|
|
465
|
-
|
|
466
|
-
nil
|
|
467
|
-
rescue Errno::ENOENT
|
|
468
|
-
nil
|
|
469
|
-
end
|
|
470
|
-
|
|
471
|
-
def git_branch_name
|
|
472
|
-
stdout, status = Open3.capture2("git", "rev-parse", "--abbrev-ref", "HEAD")
|
|
473
|
-
return stdout.strip if status.success? && !stdout.strip.empty?
|
|
474
|
-
|
|
475
|
-
nil
|
|
476
|
-
rescue Errno::ENOENT
|
|
477
|
-
nil
|
|
478
|
-
end
|
|
479
|
-
|
|
480
|
-
class << self
|
|
481
|
-
def project_from_git_url(url)
|
|
482
|
-
normalized = normalize_git_url(url)
|
|
483
|
-
return nil if normalized.nil?
|
|
484
|
-
|
|
485
|
-
return project_from_uri_url(normalized) if normalized.include?("://")
|
|
486
|
-
return project_from_ssh_url(normalized) if normalized.include?("@")
|
|
487
|
-
|
|
488
|
-
normalized
|
|
489
|
-
rescue URI::InvalidURIError
|
|
490
|
-
nil
|
|
491
|
-
end
|
|
492
|
-
|
|
493
|
-
def normalize_git_url(url)
|
|
494
|
-
return nil if url.nil? || url.strip.empty?
|
|
495
|
-
|
|
496
|
-
url.strip.sub(/\.git\z/, "")
|
|
497
|
-
end
|
|
498
|
-
|
|
499
|
-
def project_from_uri_url(normalized)
|
|
500
|
-
uri = URI.parse(normalized)
|
|
501
|
-
path = uri.path.to_s.sub(%r{^/}, "")
|
|
502
|
-
[uri.host, path].compact.reject(&:empty?).join("/")
|
|
503
|
-
end
|
|
504
|
-
|
|
505
|
-
def project_from_ssh_url(normalized)
|
|
506
|
-
_, host_and_path = normalized.split("@", 2)
|
|
507
|
-
return nil if host_and_path.nil?
|
|
508
|
-
|
|
509
|
-
host, path = host_and_path.split(":", 2)
|
|
510
|
-
return nil unless host && path
|
|
511
|
-
|
|
512
|
-
"#{host}/#{path}"
|
|
513
|
-
end
|
|
514
|
-
end
|
|
515
648
|
end
|
|
516
649
|
end
|
|
517
650
|
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Henitai
|
|
8
|
+
# Coordinates exclusive access to a reports directory across processes.
|
|
9
|
+
class ReportsDirectoryLock
|
|
10
|
+
LOCK_FILENAME = ".henitai-run.lock"
|
|
11
|
+
|
|
12
|
+
def initialize(reports_dir:)
|
|
13
|
+
@reports_dir = File.expand_path(reports_dir)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def synchronize
|
|
17
|
+
FileUtils.mkdir_p(@reports_dir)
|
|
18
|
+
File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |file|
|
|
19
|
+
acquire(file)
|
|
20
|
+
write_owner(file)
|
|
21
|
+
yield
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def lock_path
|
|
28
|
+
File.join(@reports_dir, LOCK_FILENAME)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def acquire(file)
|
|
32
|
+
return if file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
33
|
+
|
|
34
|
+
raise ConcurrentRunError, contention_message(owner_pid(file))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def owner_pid(file)
|
|
38
|
+
file.rewind
|
|
39
|
+
metadata = JSON.parse(file.read)
|
|
40
|
+
pid = metadata["pid"] if metadata.is_a?(Hash)
|
|
41
|
+
pid.is_a?(Integer) ? pid : "unknown"
|
|
42
|
+
rescue JSON::ParserError, IOError, SystemCallError
|
|
43
|
+
"unknown"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def contention_message(pid)
|
|
47
|
+
message = "another henitai run is active in #{@reports_dir} " \
|
|
48
|
+
"(pid #{pid}); use a separate reports_dir or wait"
|
|
49
|
+
return message unless dead_owner?(pid)
|
|
50
|
+
|
|
51
|
+
"#{message}. The recorded owner pid #{pid} is not running — an " \
|
|
52
|
+
"orphaned child may still hold the lock " \
|
|
53
|
+
"(check: lsof #{lock_path})"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# True only when the recorded owner pid provably no longer exists. EPERM
|
|
57
|
+
# means the process is alive but owned by someone else — treated as alive.
|
|
58
|
+
def dead_owner?(pid)
|
|
59
|
+
return false unless pid.is_a?(Integer)
|
|
60
|
+
|
|
61
|
+
Process.kill(0, pid)
|
|
62
|
+
false
|
|
63
|
+
rescue Errno::ESRCH
|
|
64
|
+
true
|
|
65
|
+
rescue StandardError
|
|
66
|
+
false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def write_owner(file)
|
|
70
|
+
file.rewind
|
|
71
|
+
file.truncate(0)
|
|
72
|
+
file.write(JSON.generate(pid: Process.pid, started_at: Time.now.iso8601))
|
|
73
|
+
file.flush
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|