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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +105 -1
  3. data/README.md +138 -5
  4. data/assets/schema/henitai.schema.json +41 -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 +54 -0
  10. data/lib/henitai/cli/command_support.rb +52 -0
  11. data/lib/henitai/cli/init_command.rb +64 -0
  12. data/lib/henitai/cli/operator_command.rb +96 -0
  13. data/lib/henitai/cli/options.rb +84 -0
  14. data/lib/henitai/cli/run_command.rb +136 -0
  15. data/lib/henitai/cli/run_options.rb +108 -0
  16. data/lib/henitai/cli.rb +27 -407
  17. data/lib/henitai/composite_progress_reporter.rb +42 -0
  18. data/lib/henitai/configuration.rb +42 -9
  19. data/lib/henitai/configuration_validator/rules.rb +161 -0
  20. data/lib/henitai/configuration_validator/scalars.rb +169 -0
  21. data/lib/henitai/configuration_validator.rb +20 -240
  22. data/lib/henitai/coverage_bootstrapper.rb +54 -5
  23. data/lib/henitai/coverage_formatter.rb +5 -1
  24. data/lib/henitai/coverage_report_reader.rb +28 -5
  25. data/lib/henitai/eager_load.rb +36 -5
  26. data/lib/henitai/execution_engine/env_scope.rb +67 -0
  27. data/lib/henitai/execution_engine.rb +115 -53
  28. data/lib/henitai/generated_artifacts.rb +58 -0
  29. data/lib/henitai/incremental_filter.rb +100 -0
  30. data/lib/henitai/integration/base.rb +171 -0
  31. data/lib/henitai/integration/child_debug_support.rb +119 -0
  32. data/lib/henitai/integration/child_runtime_control.rb +50 -0
  33. data/lib/henitai/integration/coverage_suppression.rb +52 -0
  34. data/lib/henitai/integration/minitest.rb +101 -0
  35. data/lib/henitai/integration/minitest_load_path.rb +14 -0
  36. data/lib/henitai/integration/minitest_suite_command.rb +17 -0
  37. data/lib/henitai/integration/minitest_test_runner.rb +39 -0
  38. data/lib/henitai/integration/mutant_run_support.rb +77 -0
  39. data/lib/henitai/integration/rails_environment_preloader.rb +14 -0
  40. data/lib/henitai/integration/rspec_child_runner.rb +61 -0
  41. data/lib/henitai/integration/rspec_test_selection.rb +138 -0
  42. data/lib/henitai/integration/scenario_log_support.rb +160 -0
  43. data/lib/henitai/integration.rb +22 -846
  44. data/lib/henitai/minitest_coverage_reporter.rb +4 -1
  45. data/lib/henitai/mutant/activator.rb +15 -79
  46. data/lib/henitai/mutant/parameter_source.rb +98 -0
  47. data/lib/henitai/mutant.rb +14 -6
  48. data/lib/henitai/mutant_history_store/sql.rb +90 -0
  49. data/lib/henitai/mutant_history_store/verdict_cache.rb +84 -0
  50. data/lib/henitai/mutant_history_store.rb +67 -81
  51. data/lib/henitai/mutant_identity.rb +33 -2
  52. data/lib/henitai/mutation_skip_directives.rb +227 -0
  53. data/lib/henitai/operator.rb +3 -1
  54. data/lib/henitai/operators/equality_identity_operator.rb +51 -0
  55. data/lib/henitai/operators/equality_operator.rb +8 -3
  56. data/lib/henitai/operators.rb +1 -0
  57. data/lib/henitai/per_test_coverage.rb +66 -0
  58. data/lib/henitai/per_test_coverage_collector.rb +23 -6
  59. data/lib/henitai/per_test_coverage_selector.rb +11 -33
  60. data/lib/henitai/process_worker_runner.rb +48 -334
  61. data/lib/henitai/reporter/dashboard_metadata_provider.rb +113 -0
  62. data/lib/henitai/reporter.rb +258 -125
  63. data/lib/henitai/reports_directory_lock.rb +76 -0
  64. data/lib/henitai/result.rb +88 -23
  65. data/lib/henitai/runner.rb +137 -218
  66. data/lib/henitai/scenario_execution_result.rb +12 -0
  67. data/lib/henitai/slot_scheduler/draining.rb +146 -0
  68. data/lib/henitai/slot_scheduler/process_control.rb +43 -0
  69. data/lib/henitai/slot_scheduler.rb +272 -0
  70. data/lib/henitai/static_filter.rb +16 -6
  71. data/lib/henitai/survivor_rerun_strategy.rb +195 -0
  72. data/lib/henitai/survivor_test_filter.rb +1 -1
  73. data/lib/henitai/test_prioritizer.rb +28 -2
  74. data/lib/henitai/timeout_calibrator.rb +44 -0
  75. data/lib/henitai/unparse_helper.rb +5 -2
  76. data/lib/henitai/verdict_fingerprint.rb +155 -0
  77. data/lib/henitai/version.rb +1 -1
  78. data/lib/henitai.rb +16 -1
  79. data/sig/configuration_validator.rbs +46 -22
  80. data/sig/henitai.rbs +386 -96
  81. metadata +45 -3
  82. data/lib/henitai/parallel_execution_runner.rb +0 -153
@@ -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
- def self.run_all(names:, result:, config:)
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
- def initialize(config:)
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", 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)
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. Other nodes unparse
169
- # 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.
170
245
  def display_unparse(node)
171
- if node.respond_to?(:type) && node.respond_to?(:children) && node.type == :str
172
- node.children.first.inspect
173
- else
174
- safe_unparse(node)
175
- 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
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 if ENV.key?("NO_COLOR")
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
- FileUtils.mkdir_p(File.dirname(canonical_path))
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
- path = File.join(config.reports_dir, Henitai::HISTORY_STORE_FILENAME)
301
- history_store = MutantHistoryStore.new(path:)
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(history_store.trend_report))
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(result))
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(result)
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(result)}</script>
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 escaped_report_json(result)
351
- 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)
352
451
  .gsub("&", "\\u0026")
353
452
  .gsub("<", "\\u003c")
354
453
  .gsub(">", "\\u003e")
355
454
  end
356
455
  end
357
456
 
358
- # 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.
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