branchproof 0.11.1 → 0.12.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/README.md +440 -29
  4. data/doc/Branchproof/CLI.md +1 -1
  5. data/doc/Branchproof/ChangedCoverage.md +19 -0
  6. data/doc/Branchproof/ChangedCoveragePolicy.md +24 -0
  7. data/doc/Branchproof/ChangedDecisionMap.md +20 -0
  8. data/doc/Branchproof/ChangedScope.md +18 -0
  9. data/doc/Branchproof/Collation.md +23 -0
  10. data/doc/Branchproof/CollationManifest.md +29 -0
  11. data/doc/Branchproof/CoverageSummary.md +20 -0
  12. data/doc/Branchproof/Doctor/TerminalRenderer.md +15 -0
  13. data/doc/Branchproof/Doctor.md +34 -0
  14. data/doc/Branchproof/FallbackInstrumentation.md +8 -0
  15. data/doc/Branchproof/FallbackRuntime.md +12 -0
  16. data/doc/Branchproof/FallbackSyntax.md +13 -0
  17. data/doc/Branchproof/GitChanges.md +19 -0
  18. data/doc/Branchproof/GithubReport.md +25 -0
  19. data/doc/Branchproof/HtmlReport.md +22 -0
  20. data/doc/Branchproof/Instrumenter.md +1 -1
  21. data/doc/Branchproof/MinitestAdapter.md +4 -0
  22. data/doc/Branchproof/RakeTask.md +121 -0
  23. data/doc/Branchproof/Report.md +41 -3
  24. data/doc/Branchproof/ReportSelection.md +10 -1
  25. data/doc/Branchproof/Runtime.md +4 -1
  26. data/doc/Branchproof/Source.md +1 -1
  27. data/doc/Branchproof/SummaryRanking/Decision.md +86 -0
  28. data/doc/Branchproof/SummaryRanking/SourceFile.md +43 -0
  29. data/doc/Branchproof/SummaryRanking.md +36 -0
  30. data/doc/Branchproof/SummaryReport.md +42 -0
  31. data/doc/Branchproof/Worker.md +1 -1
  32. data/doc/Branchproof.md +21 -1
  33. data/doc/CHANGELOG.md +82 -0
  34. data/doc/README.md +440 -29
  35. data/lib/branchproof/analyzer.rb +2 -52
  36. data/lib/branchproof/changed_coverage.rb +177 -0
  37. data/lib/branchproof/changed_decision_map.rb +359 -0
  38. data/lib/branchproof/changed_scope.rb +93 -0
  39. data/lib/branchproof/cli.rb +242 -21
  40. data/lib/branchproof/collation.rb +525 -0
  41. data/lib/branchproof/collation_manifest.rb +129 -0
  42. data/lib/branchproof/comparison.rb +11 -3
  43. data/lib/branchproof/comparison_report.rb +2 -0
  44. data/lib/branchproof/configuration.rb +12 -2
  45. data/lib/branchproof/coverage_policy.rb +122 -0
  46. data/lib/branchproof/coverage_summary.rb +94 -0
  47. data/lib/branchproof/doctor.rb +241 -0
  48. data/lib/branchproof/evidence.rb +5 -1
  49. data/lib/branchproof/exception_instrumentation.rb +4 -3
  50. data/lib/branchproof/exception_syntax.rb +15 -1
  51. data/lib/branchproof/fallback_instrumentation.rb +25 -0
  52. data/lib/branchproof/fallback_runtime.rb +14 -0
  53. data/lib/branchproof/fallback_syntax.rb +90 -0
  54. data/lib/branchproof/focused_report.rb +81 -22
  55. data/lib/branchproof/git_changes.rb +188 -0
  56. data/lib/branchproof/github_report.rb +292 -0
  57. data/lib/branchproof/html_report.rb +412 -0
  58. data/lib/branchproof/instrumenter.rb +2 -0
  59. data/lib/branchproof/minimizer.rb +4 -2
  60. data/lib/branchproof/minitest_adapter.rb +45 -5
  61. data/lib/branchproof/rails_support.rb +2 -2
  62. data/lib/branchproof/rake_task.rb +186 -0
  63. data/lib/branchproof/report.rb +383 -71
  64. data/lib/branchproof/report_selection.rb +26 -5
  65. data/lib/branchproof/runtime.rb +2 -0
  66. data/lib/branchproof/saved_report.rb +323 -5
  67. data/lib/branchproof/source.rb +15 -3
  68. data/lib/branchproof/summary_ranking.rb +165 -0
  69. data/lib/branchproof/summary_report.rb +241 -0
  70. data/lib/branchproof/version.rb +1 -1
  71. data/lib/branchproof/worker.rb +14 -9
  72. data/lib/branchproof.rb +12 -0
  73. data/llms.txt +21 -1
  74. data/sig/branchproof.rbs +206 -5
  75. metadata +39 -23
@@ -18,20 +18,28 @@ module Branchproof
18
18
  VIEWS = Report::VIEWS.to_h { |view| [view.to_s.tr("_", "-"), view] }
19
19
  .merge("decision_tables" => :decision_tables).freeze
20
20
 
21
- def initialize(stdout:, stderr:)
21
+ def initialize(stdout:, stderr:, env: ENV)
22
22
  @stdout = stdout
23
23
  @stderr = stderr
24
+ @env = env
24
25
  end
25
26
 
26
27
  def call(argv)
27
28
  argv = Array(argv)
28
- return help if [["--help"], ["help"], ["analyze", "--help"]].include?(argv)
29
+ return help if [["--help"], ["help"], ["analyze", "--help"], ["collate", "--help"]].include?(argv)
30
+ return doctor_command(argv.drop(1)) if argv.first == "doctor"
31
+ return collate_command(argv.drop(1)) if argv.first == "collate"
29
32
  return offline(argv) if %w[report compare].include?(argv.first)
33
+ return usage_error("mutation testing is not supported yet; use doctor, analyze, report, compare, or collate") if argv.first == "mutate"
30
34
 
31
35
  options = parse(argv)
32
- return usage_error("expected analyze, report, or compare; use branchproof --help") unless options
36
+ return usage_error("expected doctor, analyze, report, compare, or collate; use branchproof --help") unless options
33
37
 
34
38
  inventory = build_inventory(options)
39
+ changed_scope = if options[:changed_since]
40
+ ChangedScope.new(root: options[:project][:root], ref: options[:changed_since])
41
+ .call(inventory: inventory)
42
+ end
35
43
  evidence = empty_evidence(inventory, options)
36
44
  baseline = if run_worker?(options)
37
45
  run_worker(options, inventory, evidence)
@@ -78,8 +86,10 @@ module Branchproof
78
86
  analysis: analysis, minima: minima, baseline: baseline, diagnostics: diagnostics,
79
87
  level: options[:level], missing_only: options[:missing_only], view: options[:view],
80
88
  run_metadata: run_metadata(options, baseline), minimum: options[:minimum],
81
- focus: options[:focus], top: options[:top])
89
+ focus: options[:focus], top: options[:top], changed_scope: changed_scope,
90
+ minimum_changed: options[:minimum_changed])
82
91
  output_report(report, options)
92
+ write_step_summary(report, options)
83
93
  report.exit_code
84
94
  rescue ArgumentError => e
85
95
  usage_error(e.message)
@@ -94,34 +104,100 @@ module Branchproof
94
104
  def help
95
105
  @stdout.write(<<~HELP)
96
106
  Usage:
107
+ branchproof doctor [SOURCE_GLOB ...] [--test TEST_GLOB] [--project auto|ruby|rails] [--framework auto|minitest|rspec]
108
+ [--config PATH|--no-config] [--format terminal|json]
97
109
  branchproof analyze [SOURCE_GLOB ...] [--test TEST_GLOB] [--project auto|ruby|rails] [--framework auto|minitest|rspec]
98
- [--view decisions|conditions|tests|decision-tables] [--level 1|2|3] [--missing-only] [--minimum CRITERION=THRESHOLD]
110
+ [--view decisions|conditions|tests|decision-tables|summary] [--level 1|2|3] [--missing-only] [--minimum CRITERION=THRESHOLD] [--minimum-changed CRITERION=THRESHOLD]
111
+ [--changed-since REF]
99
112
  [--focus PATH[:LINE]] [--top N]
100
- [--format terminal|json] [--output PATH] [--limits PATH] [--config PATH|--no-config]
113
+ [--format terminal|json|github] [--output PATH] [--limits PATH] [--config PATH|--no-config]
101
114
  [--no-reachability] [-- RUNNER_ARGS]
102
- branchproof report SNAPSHOT [--view decisions|conditions|tests|decision-tables]
103
- [--level 1|2|3] [--missing-only] [--minimum CRITERION=THRESHOLD] [--focus PATH[:LINE]] [--top N]
104
- [--format terminal|json] [--output PATH]
115
+ branchproof report SNAPSHOT [--view decisions|conditions|tests|decision-tables|summary]
116
+ [--level 1|2|3] [--missing-only] [--minimum CRITERION=THRESHOLD] [--minimum-changed CRITERION=THRESHOLD] [--focus PATH[:LINE]] [--top N]
117
+ [--format terminal|json|github|html] [--output PATH]
105
118
  branchproof compare BEFORE AFTER [--format terminal|json] [--output PATH] [--fail-on-regression]
119
+ branchproof collate REPORT... [--manifest PATH] [--format terminal|json|github|html] [--output PATH]
106
120
  mcdc accepts the same commands as a compatibility alias.
107
121
  JSON always contains full evidence; --view requires terminal output.
122
+ github prints Actions annotations for the summary ranking and appends Markdown to $GITHUB_STEP_SUMMARY.
123
+ HTML report output is self-contained and offline; it uses an integrated layout, so --view is unavailable.
108
124
  decision_tables is also accepted as an alias for the decision-tables view.
109
125
  --no-reachability keeps every generated decision-table rule as a coverage obligation.
126
+ --changed-since REF captures tracked changes from a commit to the current worktree; --minimum-changed requires this scope and gates its coverage separately.
110
127
  HELP
111
128
  0
112
129
  end
113
130
 
131
+ def doctor_command(args)
132
+ return help if args == ["--help"]
133
+
134
+ format = doctor_json_requested?(args) ? :json : :terminal
135
+ begin
136
+ analyze_args = doctor_analyze_args(args)
137
+ options = parse(analyze_args, doctor: true)
138
+ source_files = select_source_files(options).select { |path| File.file?(path) }
139
+ test_files = options[:tests].select { |path| File.file?(path) }
140
+ document = Doctor.new(options: options).document(source_files: source_files, test_files: test_files)
141
+ format = options[:format]
142
+ rescue ArgumentError, SystemCallError, IOError => e
143
+ document = Doctor.error(e.message)
144
+ end
145
+ output_doctor(document, format)
146
+ document[:status] == "ready" ? 0 : 2
147
+ end
148
+
149
+ def doctor_json_requested?(args)
150
+ args.each_cons(2).any? { |flag, format| flag == "--format" && format == "json" }
151
+ end
152
+
153
+ def doctor_analyze_args(args)
154
+ allowed_with_value = %w[--test --project --framework --config --format]
155
+ allowed_without_value = ["--no-config"]
156
+ analyze = ["analyze"]
157
+ until args.empty?
158
+ token = args.shift
159
+ if allowed_with_value.include?(token)
160
+ value = args.shift
161
+ raise ArgumentError, "#{token} requires a value" if value.to_s.empty? || value.start_with?("-")
162
+ if token == "--format" && !%w[terminal json].include?(value)
163
+ raise ArgumentError, "format must be terminal or json"
164
+ end
165
+
166
+ analyze.push(token, value)
167
+ elsif allowed_without_value.include?(token)
168
+ analyze << token
169
+ elsif token.start_with?("-")
170
+ raise ArgumentError, "#{token} is not supported by doctor"
171
+ else
172
+ analyze << token
173
+ end
174
+ end
175
+ analyze
176
+ end
177
+
178
+ def output_doctor(document, format)
179
+ if format == :json
180
+ @stdout.write(JSON.generate(normalize(document)))
181
+ @stdout.write("\n")
182
+ else
183
+ @stdout.write(Doctor.terminal(document))
184
+ end
185
+ end
186
+
114
187
  def parse_view(view)
115
188
  resolved = VIEWS[view.to_s]
116
- raise ArgumentError, "view must be decisions, conditions, tests, or decision-tables" unless resolved
189
+ raise ArgumentError, "view must be decisions, conditions, tests, decision-tables, or summary" unless resolved
117
190
 
118
191
  resolved
119
192
  end
120
193
 
121
194
  def validate_view!(options)
122
- return unless options[:explicit_view] && options[:format] == :json
195
+ return unless options[:explicit_view]
196
+ raise ArgumentError, "HTML uses an integrated layout; --view is unavailable" if options[:format] == :html
197
+ raise ArgumentError, "--view requires terminal format; JSON contains full evidence" if options[:format] == :json
198
+ return unless options[:format] == :github
123
199
 
124
- raise ArgumentError, "--view requires terminal format; JSON contains full evidence"
200
+ raise ArgumentError, "--view requires terminal format; GitHub output uses the summary ranking"
125
201
  end
126
202
 
127
203
  def offline(argv)
@@ -141,27 +217,111 @@ module Branchproof
141
217
  if options[:level] > 1 && !document["analysis"]
142
218
  raise ArgumentError, "saved report has no analysis; use --level 1"
143
219
  end
144
- if options[:missing_only] && (options[:format] != :terminal || options[:level] == 1)
145
- raise ArgumentError, "--missing-only requires terminal format and level 2 or 3"
220
+ if options[:missing_only] && (!%i[terminal html].include?(options[:format]) || options[:level] == 1)
221
+ raise ArgumentError, "--missing-only requires terminal or HTML format and level 2 or 3"
146
222
  end
147
223
 
148
224
  report = Report.from_document(document: document, level: options[:level], view: options[:view],
149
225
  missing_only: options[:missing_only], minimum: options[:minimum_overrides],
226
+ minimum_changed: options[:minimum_changed_overrides],
150
227
  focus: options[:focus], top: options[:top])
151
228
  output_report(report, options)
229
+ write_step_summary(report, options)
152
230
  report.exit_code
153
231
  end
154
232
  end
155
233
 
234
+ def collate_command(args)
235
+ options, paths = parse_collate(args)
236
+ manifest = if options[:manifest]
237
+ require_relative "collation_manifest"
238
+ CollationManifest.new(path: options[:manifest], supplied_paths: paths)
239
+ end
240
+ protected_paths = paths.map { File.expand_path(_1) }
241
+ if manifest
242
+ protected_paths.concat(manifest.declared_paths)
243
+ protected_paths << File.expand_path(options[:manifest])
244
+ end
245
+ reject_collation_output_collision!(protected_paths, options[:output]) if options[:output]
246
+
247
+ supplied = if manifest
248
+ manifest.inputs
249
+ else
250
+ paths.map { |path| { id: nil, path: File.expand_path(path) } }
251
+ end
252
+ inputs = supplied.map do |input|
253
+ input.merge(document: SavedReport.read(input.fetch(:path)))
254
+ end
255
+ document = Collation.new(inputs: inputs, expected_shards: manifest&.expected_ids).call
256
+ report = Report.from_document(document: document, level: document.dig("run_metadata", "requested_level"))
257
+ output_report(report, options)
258
+ write_step_summary(report, options)
259
+ report.exit_code
260
+ end
261
+
262
+ def parse_collate(args)
263
+ options = { format: :json }
264
+ paths = []
265
+ seen = {}
266
+ until args.empty?
267
+ token = args.shift
268
+ case token
269
+ when "--format"
270
+ argument = collate_option_value(args, seen, token)
271
+ raise ArgumentError, "format must be terminal, json, github, or html" unless %w[terminal json github html].include?(argument)
272
+
273
+ options[:format] = argument.to_sym
274
+ when "--output", "--manifest"
275
+ argument = collate_option_value(args, seen, token)
276
+ options[token == "--output" ? :output : :manifest] = argument
277
+ else
278
+ raise ArgumentError, "unknown option: #{token}" if token.start_with?("-")
279
+
280
+ paths << token
281
+ end
282
+ end
283
+ raise ArgumentError, "collate requires at least one saved report" if paths.empty?
284
+
285
+ [options, paths]
286
+ end
287
+
288
+ def collate_option_value(args, seen, token)
289
+ raise ArgumentError, "#{token} may be specified only once" if seen[token]
290
+
291
+ seen[token] = true
292
+ argument = args.shift
293
+ raise ArgumentError, "#{token} requires a value" if argument.to_s.empty? || argument.start_with?("-")
294
+
295
+ argument
296
+ end
297
+
298
+ def reject_collation_output_collision!(protected_paths, output)
299
+ require_relative "collation_manifest"
300
+ output_path = File.expand_path(output)
301
+ output_identity = CollationManifest.path_identity(output_path)
302
+ collision = protected_paths.any? do |path|
303
+ File.expand_path(path) == output_path ||
304
+ CollationManifest.path_identity(path) == output_identity ||
305
+ (File.exist?(path) && File.exist?(output_path) && File.identical?(path, output_path))
306
+ end
307
+ raise ArgumentError, "output must not overwrite an input, declared report, or manifest" if collision
308
+ end
309
+
156
310
  def parse_offline(command, args)
157
311
  options = { format: :terminal, view: :decisions, missing_only: false, minimum_overrides: {} }
158
312
  paths = []
159
313
  until args.empty?
160
314
  token = args.shift
161
315
  case token
316
+ when "--changed-since"
317
+ raise ArgumentError, "--changed-since is available only for analyze"
162
318
  when "--format"
163
319
  format = args.shift
164
- raise ArgumentError, "format must be terminal or json" unless %w[terminal json].include?(format)
320
+ if command == "compare"
321
+ raise ArgumentError, "format must be terminal or json" unless %w[terminal json].include?(format)
322
+ else
323
+ raise ArgumentError, "format must be terminal, json, github, or html" unless %w[terminal json github html].include?(format)
324
+ end
165
325
 
166
326
  options[:format] = format.to_sym
167
327
  when "--output"
@@ -171,7 +331,7 @@ module Branchproof
171
331
  raise ArgumentError, "--fail-on-regression requires compare" unless command == "compare"
172
332
 
173
333
  options[:fail_on_regression] = true
174
- when "--view", "--level", "--missing-only", "--minimum", "--focus", "--top"
334
+ when "--view", "--level", "--missing-only", "--minimum", "--minimum-changed", "--focus", "--top"
175
335
  raise ArgumentError, "#{token} requires report" unless command == "report"
176
336
 
177
337
  case token
@@ -183,6 +343,8 @@ module Branchproof
183
343
  raise ArgumentError, "level must be 1, 2, or 3" unless (1..3).cover?(options[:level])
184
344
  when "--minimum"
185
345
  add_minimum_override!(options, args.shift)
346
+ when "--minimum-changed"
347
+ add_minimum_changed_override!(options, args.shift)
186
348
  when "--focus"
187
349
  options[:focus] = args.shift
188
350
  raise ArgumentError, "--focus requires PATH or PATH:LINE" if options[:focus].nil? || options[:focus].start_with?("-")
@@ -256,7 +418,7 @@ module Branchproof
256
418
  path.to_s.empty? ? path.to_s : relative_path(path, root)
257
419
  end
258
420
 
259
- def parse(argv)
421
+ def parse(argv, doctor: false)
260
422
  return nil if argv.empty? || argv.first != "analyze"
261
423
 
262
424
  args = argv.drop(1)
@@ -267,7 +429,7 @@ module Branchproof
267
429
  runner_args: runner_args, project: nil, missing_only: false, view: :decisions,
268
430
  reachability: true, project_mode: "auto", framework: "auto", explicit_tests: false,
269
431
  explicit_project: false, explicit_framework: false, explicit_sources: false,
270
- config_path: nil, config_disabled: false, minimum_overrides: {} }
432
+ config_path: nil, config_disabled: false, minimum_overrides: {}, minimum_changed_overrides: {} }
271
433
  until args.empty?
272
434
  token = args.shift
273
435
  case token
@@ -278,6 +440,13 @@ module Branchproof
278
440
  options[:missing_only] = true
279
441
  when "--minimum"
280
442
  add_minimum_override!(options, args.shift)
443
+ when "--minimum-changed"
444
+ add_minimum_changed_override!(options, args.shift)
445
+ when "--changed-since"
446
+ ref = args.shift
447
+ raise ArgumentError, "--changed-since requires a non-option REF" if ref.to_s.empty? || ref.start_with?("-")
448
+
449
+ options[:changed_since] = ref
281
450
  when "--focus"
282
451
  options[:focus] = args.shift
283
452
  raise ArgumentError, "--focus requires PATH or PATH:LINE" if options[:focus].nil? || options[:focus].start_with?("-")
@@ -293,7 +462,7 @@ module Branchproof
293
462
  options[:level] = level
294
463
  when "--format"
295
464
  format = args.shift.to_s
296
- raise ArgumentError, "format must be terminal or json" unless %w[terminal json].include?(format)
465
+ raise ArgumentError, "format must be terminal, json, or github" unless %w[terminal json github].include?(format)
297
466
 
298
467
  options[:format] = format.to_sym
299
468
  when "--output"
@@ -355,10 +524,16 @@ module Branchproof
355
524
  end
356
525
  options[:exclude] = Array(configuration[:exclude]).dup
357
526
  options[:minimum] = configuration.fetch(:minimum, {}).dup.merge(options[:minimum_overrides])
527
+ options[:minimum_changed] = configuration.fetch(:minimum_changed, {}).dup.merge(options[:minimum_changed_overrides])
358
528
  else
359
529
  options[:exclude] = []
360
530
  options[:minimum] = options[:minimum_overrides].dup
531
+ options[:minimum_changed] = options[:minimum_changed_overrides].dup
532
+ end
533
+ if !doctor && !options[:minimum_changed].empty? && !options[:changed_since]
534
+ raise ArgumentError, "--minimum-changed requires --changed-since REF"
361
535
  end
536
+
362
537
  options[:project] = Project.new(root: root, mode: options[:project_mode], framework: options[:framework]).to_h
363
538
  validate_view!(options)
364
539
  validate_selection!(options)
@@ -393,6 +568,20 @@ module Branchproof
393
568
  raise ArgumentError, "minimum threshold must be a finite number from 0 to 100"
394
569
  end
395
570
 
571
+ def add_minimum_changed_override!(options, argument)
572
+ match = argument.to_s.match(/\A([a-z_]+)=([0-9]+(?:\.[0-9]+)?)\z/)
573
+ raise ArgumentError, "minimum-changed must be CRITERION=THRESHOLD" unless match
574
+
575
+ criterion = match[1]
576
+ threshold = match[2].include?(".") ? Float(match[2]) : Integer(match[2], 10)
577
+ normalized = CoveragePolicy.normalize(criterion => threshold)
578
+ criterion = normalized.keys.first
579
+ options[:minimum_changed_overrides] ||= {}
580
+ raise ArgumentError, "duplicate coverage criterion: #{criterion}" if options[:minimum_changed_overrides].key?(criterion)
581
+
582
+ options[:minimum_changed_overrides][criterion] = normalized.fetch(criterion)
583
+ end
584
+
396
585
  def validate_selection!(options)
397
586
  return unless options[:focus] || options[:top]
398
587
  raise ArgumentError, "focus and top filters are terminal-only" if options[:format] == :json
@@ -401,6 +590,12 @@ module Branchproof
401
590
  end
402
591
 
403
592
  def build_inventory(options)
593
+ root = options[:project][:root]
594
+ selected = select_source_files(options)
595
+ Source.new(root: root, limits: options[:limits]).inventory(paths: selected)
596
+ end
597
+
598
+ def select_source_files(options)
404
599
  root = options[:project][:root]
405
600
  test_paths = options[:tests].filter_map do |path|
406
601
  File.realpath(path)
@@ -426,7 +621,7 @@ module Branchproof
426
621
  end
427
622
  options[:excluded_files] = excluded
428
623
  options[:selected_source_files] = selected
429
- Source.new(root: root, limits: options[:limits]).inventory(paths: selected)
624
+ selected
430
625
  end
431
626
 
432
627
  def empty_evidence(inventory, options)
@@ -500,6 +695,28 @@ module Branchproof
500
695
  File.unlink(temporary) if created && temporary && File.file?(temporary)
501
696
  end
502
697
 
698
+ # GitHub Actions reads Markdown appended to this file into the job summary.
699
+ def write_step_summary(report, options)
700
+ path = @env["GITHUB_STEP_SUMMARY"].to_s
701
+ return unless options[:format] == :github && !path.empty?
702
+
703
+ File.open(path, "a") { |file| file.write(report.step_summary) }
704
+ rescue SystemCallError, IOError => e
705
+ @stderr.write("branchproof: could not write GITHUB_STEP_SUMMARY: #{e.message}\n")
706
+ end
707
+
708
+ # Annotation paths must be relative to the repository (GITHUB_WORKSPACE),
709
+ # but report paths are relative to the project root (the current directory).
710
+ def github_path_prefix
711
+ workspace = @env["GITHUB_WORKSPACE"].to_s
712
+ return nil if workspace.empty?
713
+
714
+ relative = Pathname.new(File.realpath(Dir.pwd)).relative_path_from(Pathname.new(File.realpath(workspace))).to_s
715
+ relative == "." || relative.start_with?("..") ? nil : relative
716
+ rescue SystemCallError, ArgumentError
717
+ nil
718
+ end
719
+
503
720
  def run_worker?(options)
504
721
  !options[:tests].empty? || options[:project][:framework].to_s == "rspec"
505
722
  end
@@ -515,7 +732,11 @@ module Branchproof
515
732
 
516
733
  def report_string(report, format)
517
734
  buffer = StringIO.new
518
- report.write(io: buffer, format: format)
735
+ if format == :github
736
+ report.write(io: buffer, format: format, path_prefix: github_path_prefix)
737
+ else
738
+ report.write(io: buffer, format: format)
739
+ end
519
740
  buffer.string
520
741
  end
521
742