rail_verdict 1.0.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 (110) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/NOTICE +14 -0
  4. data/README.md +192 -0
  5. data/exe/railverdict +6 -0
  6. data/exe/railverdict-minitest-reporter.rb +90 -0
  7. data/lib/rail_verdict/analyzers/_shared.rb +54 -0
  8. data/lib/rail_verdict/analyzers/bundler_audit.rb +186 -0
  9. data/lib/rail_verdict/analyzers/minitest.rb +312 -0
  10. data/lib/rail_verdict/analyzers/rspec.rb +274 -0
  11. data/lib/rail_verdict/analyzers/rubocop.rb +289 -0
  12. data/lib/rail_verdict/analyzers/simplecov.rb +178 -0
  13. data/lib/rail_verdict/baseline.rb +151 -0
  14. data/lib/rail_verdict/check.rb +415 -0
  15. data/lib/rail_verdict/cli.rb +471 -0
  16. data/lib/rail_verdict/comparison.rb +216 -0
  17. data/lib/rail_verdict/configuration.rb +165 -0
  18. data/lib/rail_verdict/contracts/analyzer_result.rb +138 -0
  19. data/lib/rail_verdict/contracts/finding.rb +154 -0
  20. data/lib/rail_verdict/contracts/gate_result.rb +171 -0
  21. data/lib/rail_verdict/coverage/changed_line_evaluator.rb +70 -0
  22. data/lib/rail_verdict/doctor.rb +132 -0
  23. data/lib/rail_verdict/errors.rb +17 -0
  24. data/lib/rail_verdict/findings_command.rb +24 -0
  25. data/lib/rail_verdict/fingerprint.rb +72 -0
  26. data/lib/rail_verdict/git/command.rb +48 -0
  27. data/lib/rail_verdict/git/context.rb +361 -0
  28. data/lib/rail_verdict/git/diff_parser.rb +200 -0
  29. data/lib/rail_verdict/git/errors.rb +16 -0
  30. data/lib/rail_verdict/init.rb +38 -0
  31. data/lib/rail_verdict/intelligence/ai_analysis.rb +82 -0
  32. data/lib/rail_verdict/intelligence/ai_failure.rb +44 -0
  33. data/lib/rail_verdict/intelligence/budget.rb +36 -0
  34. data/lib/rail_verdict/intelligence/cache.rb +74 -0
  35. data/lib/rail_verdict/intelligence/context_builder.rb +92 -0
  36. data/lib/rail_verdict/intelligence/manifest.rb +70 -0
  37. data/lib/rail_verdict/intelligence/orchestrator.rb +77 -0
  38. data/lib/rail_verdict/intelligence/prompt.rb +28 -0
  39. data/lib/rail_verdict/intelligence/provider.rb +14 -0
  40. data/lib/rail_verdict/intelligence/providers/fake_provider.rb +31 -0
  41. data/lib/rail_verdict/intelligence/providers/openai_compat.rb +119 -0
  42. data/lib/rail_verdict/intelligence/redactor.rb +51 -0
  43. data/lib/rail_verdict/intelligence/secret_detector.rb +45 -0
  44. data/lib/rail_verdict/intelligence/source_reader.rb +49 -0
  45. data/lib/rail_verdict/intelligence.rb +11 -0
  46. data/lib/rail_verdict/mcp/cache.rb +233 -0
  47. data/lib/rail_verdict/mcp/repository_root.rb +31 -0
  48. data/lib/rail_verdict/mcp/serializers.rb +98 -0
  49. data/lib/rail_verdict/mcp/server.rb +110 -0
  50. data/lib/rail_verdict/mcp/tools/build_repair_packet.rb +85 -0
  51. data/lib/rail_verdict/mcp/tools/explain.rb +100 -0
  52. data/lib/rail_verdict/mcp/tools/get_finding.rb +139 -0
  53. data/lib/rail_verdict/mcp/tools/investigate.rb +107 -0
  54. data/lib/rail_verdict/mcp/tools/list_findings.rb +119 -0
  55. data/lib/rail_verdict/mcp/tools/verify.rb +137 -0
  56. data/lib/rail_verdict/mcp/tools/verify_repair.rb +105 -0
  57. data/lib/rail_verdict/mcp/validators.rb +81 -0
  58. data/lib/rail_verdict/mcp.rb +7 -0
  59. data/lib/rail_verdict/process_runner.rb +292 -0
  60. data/lib/rail_verdict/rails_context/classifier.rb +57 -0
  61. data/lib/rail_verdict/rails_context/confidence.rb +13 -0
  62. data/lib/rail_verdict/rails_context/constant_inferencer.rb +56 -0
  63. data/lib/rail_verdict/rails_context/context.rb +115 -0
  64. data/lib/rail_verdict/rails_context/detector.rb +141 -0
  65. data/lib/rail_verdict/rails_context/limits.rb +17 -0
  66. data/lib/rail_verdict/rails_context/resolvers/association_extractor.rb +48 -0
  67. data/lib/rail_verdict/rails_context/resolvers/policy_resolver.rb +45 -0
  68. data/lib/rail_verdict/rails_context/resolvers/route_resolver.rb +40 -0
  69. data/lib/rail_verdict/rails_context/resolvers/route_scanner.rb +55 -0
  70. data/lib/rail_verdict/rails_context/resolvers/schema_resolver.rb +90 -0
  71. data/lib/rail_verdict/rails_context/resolvers/test_candidates.rb +75 -0
  72. data/lib/rail_verdict/rails_context/resolvers/view_resolver.rb +45 -0
  73. data/lib/rail_verdict/rails_context/schema_parser.rb +50 -0
  74. data/lib/rail_verdict/rails_context.rb +21 -0
  75. data/lib/rail_verdict/repair/boundary.rb +36 -0
  76. data/lib/rail_verdict/repair/command.rb +89 -0
  77. data/lib/rail_verdict/repair/constraints.rb +31 -0
  78. data/lib/rail_verdict/repair/context_assembler.rb +301 -0
  79. data/lib/rail_verdict/repair/diff_context.rb +51 -0
  80. data/lib/rail_verdict/repair/packet.rb +158 -0
  81. data/lib/rail_verdict/repair/prompt_renderer.rb +27 -0
  82. data/lib/rail_verdict/repair/verification_plan.rb +45 -0
  83. data/lib/rail_verdict/repair/verifier.rb +84 -0
  84. data/lib/rail_verdict/reporters/console.rb +107 -0
  85. data/lib/rail_verdict/reporters/github_annotations.rb +104 -0
  86. data/lib/rail_verdict/reporters/json_reporter.rb +19 -0
  87. data/lib/rail_verdict/reporters/sarif.rb +138 -0
  88. data/lib/rail_verdict/run_context.rb +99 -0
  89. data/lib/rail_verdict/schema_validator.rb +85 -0
  90. data/lib/rail_verdict/strict_yaml.rb +134 -0
  91. data/lib/rail_verdict/verification/policy.rb +359 -0
  92. data/lib/rail_verdict/version.rb +5 -0
  93. data/lib/rail_verdict/waiver.rb +67 -0
  94. data/lib/rail_verdict/waiver_store.rb +59 -0
  95. data/lib/rail_verdict.rb +64 -0
  96. data/schemas/ai-analysis-v1.schema.json +54 -0
  97. data/schemas/baseline-v1.schema.json +108 -0
  98. data/schemas/configuration-v1.1.schema.json +150 -0
  99. data/schemas/configuration-v1.2.schema.json +182 -0
  100. data/schemas/configuration-v1.3.schema.json +198 -0
  101. data/schemas/configuration-v1.4.schema.json +146 -0
  102. data/schemas/configuration-v1.schema.json +72 -0
  103. data/schemas/coverage-v1.schema.json +51 -0
  104. data/schemas/finding-v1.schema.json +131 -0
  105. data/schemas/minitest-reporter-v1.schema.json +113 -0
  106. data/schemas/repair-packet-v1.schema.json +364 -0
  107. data/schemas/result-v1.schema.json +378 -0
  108. data/schemas/waiver-v1.schema.json +45 -0
  109. data/schemas/waivers-v1.schema.json +67 -0
  110. metadata +192 -0
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module RailVerdict
6
+ module Doctor
7
+ Outcome = Struct.new(:report, :exit_code, keyword_init: true)
8
+
9
+ module_function
10
+
11
+ def execute(repository_root:, config_path:, runner: ProcessRunner, rubocop_command_resolver: nil)
12
+ root = File.realpath(repository_root)
13
+ resolved_config = resolve_config_path(root, config_path)
14
+ configuration = Configuration.load(resolved_config)
15
+ probes = {}
16
+ configuration.analyzers.each do |name, selection|
17
+ next unless selection.fetch("enabled")
18
+
19
+ adapter = build_adapter(name, rubocop_command_resolver)
20
+ next unless adapter
21
+
22
+ probes[name] = adapter.probe(root, runner: runner)
23
+ end
24
+ analyzer_versions = probes.transform_values(&:version)
25
+ context = RunContext.build(
26
+ repository_root: root,
27
+ configuration: configuration,
28
+ analyzer_versions: analyzer_versions,
29
+ revision_resolver: ->(_root) { nil }
30
+ )
31
+ analyzers_report = {}
32
+ configuration.analyzers.each do |name, selection|
33
+ probe = probes[name]
34
+ analyzers_report[name] = {
35
+ "enabled" => selection.fetch("enabled"),
36
+ "required" => selection.fetch("required"),
37
+ "status" => probe&.status || "disabled",
38
+ "version" => probe&.version
39
+ }
40
+ end
41
+ hints = {}
42
+ configuration.analyzers.each do |name, sel|
43
+ probe = probes[name]
44
+ status = probe&.status || "disabled"
45
+ next if %w[succeeded disabled].include?(status)
46
+
47
+ hints[name] = doctor_hint(name, probe)
48
+ end
49
+ baseline_hint = nil
50
+ if configuration.mode == "no_new_debt"
51
+ baseline_path = Baseline.resolve_path(repository_root: root, configuration: configuration)
52
+ baseline_hint = File.file?(baseline_path) ? nil : "baseline missing for no_new_debt; run `railverdict baseline create`"
53
+ end
54
+ report = {
55
+ "doctor" => "1.0",
56
+ "ruby_version" => context.ruby_version,
57
+ "target_ruby_version" => context.target_ruby_version,
58
+ "rails_version" => context.rails_version,
59
+ "configuration" => {
60
+ "path" => relative_path(resolved_config, root),
61
+ "valid" => true,
62
+ "mode" => configuration.mode,
63
+ "digest" => configuration.digest
64
+ },
65
+ "analyzers" => analyzers_report,
66
+ "hints" => hints,
67
+ "baseline_hint" => baseline_hint
68
+ }.compact
69
+ Outcome.new(report: report, exit_code: 0)
70
+ rescue ConfigurationError => error
71
+ Outcome.new(
72
+ report: {
73
+ "doctor" => "1.0",
74
+ "configuration" => { "valid" => false, "error" => error.message },
75
+ "analyzers" => {}
76
+ },
77
+ exit_code: 2
78
+ )
79
+ rescue RailVerdict::Error, SystemCallError => error
80
+ Outcome.new(
81
+ report: { "doctor" => "1.0", "configuration" => { "valid" => false, "error" => error.message }, "analyzers" => {} },
82
+ exit_code: 2
83
+ )
84
+ end
85
+
86
+ def resolve_config_path(root, config_path)
87
+ path = config_path.to_s
88
+ Pathname.new(path).absolute? ? path : File.expand_path(path, root)
89
+ end
90
+ private_class_method :resolve_config_path
91
+
92
+ def build_adapter(name, rubocop_command_resolver)
93
+ case name
94
+ when "rubocop"
95
+ RailVerdict::Analyzers::RuboCop.new(command_resolver: rubocop_command_resolver)
96
+ when "minitest"
97
+ RailVerdict::Analyzers::Minitest.new
98
+ when "rspec"
99
+ RailVerdict::Analyzers::RSpec.new
100
+ when "simplecov"
101
+ RailVerdict::Analyzers::SimpleCov.new
102
+ when "bundler_audit"
103
+ RailVerdict::Analyzers::BundlerAudit.new
104
+ end
105
+ end
106
+ private_class_method :build_adapter
107
+
108
+ def doctor_hint(name, probe)
109
+ msg = probe&.message.to_s
110
+ case name
111
+ when "rspec"
112
+ "Add `rspec` to the target application's bundle and run bundle install. (#{msg})"
113
+ when "minitest"
114
+ "Add `minitest` to the target bundle or ensure tests load. (#{msg})"
115
+ when "rubocop"
116
+ "Add `rubocop` to the target bundle and run bundle install. (#{msg})"
117
+ when "simplecov"
118
+ "Generate coverage at coverage/coverage.json (SimpleCov JSON formatter) before check. (#{msg})"
119
+ when "bundler_audit"
120
+ "Add `bundler-audit` to the bundle; run `bundle exec bundler-audit update` separately. (#{msg})"
121
+ else
122
+ msg
123
+ end
124
+ end
125
+ private_class_method :doctor_hint
126
+
127
+ def relative_path(path, root)
128
+ Pathname.new(path).relative_path_from(Pathname.new(root)).to_s
129
+ end
130
+ private_class_method :relative_path
131
+ end
132
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailVerdict
4
+ class Error < StandardError; end
5
+
6
+ class UsageError < Error; end
7
+
8
+ class ConfigurationError < Error
9
+ attr_reader :source_path, :property_path
10
+
11
+ def initialize(message, source_path: nil, property_path: nil)
12
+ super(message)
13
+ @source_path = source_path
14
+ @property_path = property_path
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailVerdict
4
+ module FindingsCommand
5
+ module_function
6
+
7
+ def document(outcome)
8
+ {
9
+ "schema_version" => "1.0",
10
+ "findings" => outcome.findings.sort_by(&:sort_key).map(&:to_schema_h)
11
+ }
12
+ end
13
+
14
+ def console(outcome)
15
+ return "No findings.\n" if outcome.findings.empty?
16
+
17
+ outcome.findings.sort_by(&:sort_key).map do |finding|
18
+ location = finding.location
19
+ line = location["start_line"] ? ":#{location['start_line']}" : ""
20
+ "#{location.fetch('path')}#{line} [#{finding.severity}] #{finding.analyzer}/#{finding.rule_id}: #{finding.message}"
21
+ end.join("\n") + "\n"
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module RailVerdict
7
+ module Fingerprint
8
+ VERSION = 1
9
+ ALGORITHM = "sha256"
10
+ PAYLOAD_SCHEMA = "https://railverdict.dev/fingerprint-payload/v1"
11
+ LEGACY_PAYLOAD_SCHEMA = "https://railverdict.dev/fingerprint-payload/v0.1"
12
+ FINGERPRINT_PATTERN = /\Asha256:[0-9a-f]{64}\z/
13
+
14
+ class IncompatibleError < RailVerdict::Error; end
15
+
16
+ module_function
17
+
18
+ def canonical_payload(analyzer:, rule_id:, path:, message:)
19
+ analyzer = normalize_field(analyzer, "analyzer")
20
+ rule_id = normalize_field(rule_id, "rule_id")
21
+ path = normalize_path(path)
22
+ message = normalize_message(message)
23
+ {
24
+ "payload_schema" => PAYLOAD_SCHEMA,
25
+ "fingerprint_version" => VERSION,
26
+ "algorithm" => ALGORITHM,
27
+ "analyzer" => analyzer,
28
+ "rule_id" => rule_id,
29
+ "path" => path,
30
+ "message" => message
31
+ }
32
+ end
33
+
34
+ def canonical_json(analyzer:, rule_id:, path:, message:)
35
+ payload = canonical_payload(analyzer: analyzer, rule_id: rule_id, path: path, message: message)
36
+ sorted = payload.keys.sort.to_h { |key| [key, payload.fetch(key)] }
37
+ JSON.generate(sorted)
38
+ end
39
+
40
+ def hexdigest(analyzer:, rule_id:, path:, message:)
41
+ "sha256:#{Digest::SHA256.hexdigest(canonical_json(analyzer: analyzer, rule_id: rule_id, path: path, message: message))}"
42
+ end
43
+
44
+ def valid?(fingerprint)
45
+ fingerprint.is_a?(String) && fingerprint.match?(FINGERPRINT_PATTERN)
46
+ end
47
+
48
+ def normalize_field(value, _name)
49
+ value.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?").strip
50
+ end
51
+ private_class_method :normalize_field
52
+
53
+ def normalize_path(path)
54
+ raw = path.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?").strip
55
+ raw = raw.delete_prefix("./")
56
+ raw = raw.delete_prefix("/")
57
+ raw = raw.unicode_normalize(:nfc) if raw.respond_to?(:unicode_normalize)
58
+ raw
59
+ end
60
+ private_class_method :normalize_path
61
+
62
+ def normalize_message(message)
63
+ raw = message.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?").strip
64
+ raw = raw.gsub(/\s+/, " ")
65
+ raw = raw.unicode_normalize(:nfc) if raw.respond_to?(:unicode_normalize)
66
+ raw = raw[0, 4096] if raw.bytesize > 4096
67
+ raw = raw.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")
68
+ raw.bytesize > 4096 ? raw.byteslice(0, 4096).encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?") : raw
69
+ end
70
+ private_class_method :normalize_message
71
+ end
72
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailVerdict
4
+ module Git
5
+ module Command
6
+ module_function
7
+
8
+ GIT_EXECUTABLE = "git"
9
+
10
+ def run(argv, chdir:, timeout_seconds: 5.0, max_stdout_bytes: 524288, max_stderr_bytes: 65536, runner: ProcessRunner)
11
+ argv.each do |element|
12
+ raise ArgumentError, "git argv elements must be strings" unless element.is_a?(String)
13
+ raise ArgumentError, "git argv elements cannot contain NUL bytes" if element.include?("\0")
14
+ end
15
+ runner.run(GIT_EXECUTABLE, argv, chdir: chdir, timeout_seconds: timeout_seconds, max_stdout_bytes: max_stdout_bytes, max_stderr_bytes: max_stderr_bytes)
16
+ end
17
+
18
+ def interpret(result, chdir:)
19
+ case result.status
20
+ when :spawn_failed
21
+ raise Git::Unavailable, "git is not available: #{result.detail}"
22
+ when :timed_out
23
+ raise Git::Timeout, "git operation timed out"
24
+ when :truncated
25
+ raise Git::Truncated, "git output was truncated"
26
+ when :signaled
27
+ raise Git::Error, "git terminated by signal #{result.signal}"
28
+ when :exited
29
+ result
30
+ else
31
+ raise Git::Error, "unexpected git result: #{result.status}"
32
+ end
33
+ if result.stdout_truncated || result.stderr_truncated
34
+ raise Git::Truncated, "git output was truncated"
35
+ end
36
+ result
37
+ end
38
+
39
+ def require_success(result, context_message: "git command failed")
40
+ return if result.exit_code == 0
41
+
42
+ stderr = result.stderr.to_s.strip
43
+ suffix = stderr.empty? ? "" : " (#{stderr[0, 1024]})"
44
+ raise Git::Error, "#{context_message}: exit #{result.exit_code}#{suffix}"
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,361 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "pathname"
5
+
6
+ module RailVerdict
7
+ module Git
8
+ class ChangedFile
9
+ attr_reader :status, :path, :old_path, :new_path, :score
10
+ attr_accessor :binary
11
+
12
+ def initialize(status:, path:, old_path:, new_path:, score:, binary: false)
13
+ @status = status
14
+ @path = path
15
+ @old_path = old_path
16
+ @new_path = new_path
17
+ @score = score
18
+ @binary = binary
19
+ freeze
20
+ end
21
+
22
+ def to_h
23
+ { "status" => status.to_s, "path" => path, "old_path" => old_path, "new_path" => new_path, "score" => score, "binary" => binary }
24
+ end
25
+ end
26
+
27
+ class Context
28
+ attr_reader :repository_root, :head, :base, :merge_base, :changed_files, :changed_line_set, :binary_paths, :conflicted_paths
29
+
30
+ def initialize(repository_root:, head:, base:, merge_base:, changed_files:, changed_line_set:, binary_paths:, conflicted_paths:)
31
+ @repository_root = repository_root.dup.freeze
32
+ @head = head.dup.freeze
33
+ @base = base.dup.freeze
34
+ @merge_base = merge_base.dup.freeze
35
+ @changed_files = changed_files.map(&:freeze).freeze
36
+ @changed_line_set = deep_freeze_line_set(changed_line_set)
37
+ @binary_paths = binary_paths.dup.freeze
38
+ @conflicted_paths = conflicted_paths.dup.freeze
39
+ freeze
40
+ end
41
+
42
+ def to_h
43
+ {
44
+ "repository_root" => repository_root,
45
+ "head" => head,
46
+ "base" => base,
47
+ "merge_base" => merge_base,
48
+ "changed_files" => changed_files.map(&:to_h),
49
+ "changed_line_set" => changed_line_set,
50
+ "binary_paths" => binary_paths.sort,
51
+ "conflicted_paths" => conflicted_paths.sort
52
+ }
53
+ end
54
+
55
+ def rename_map
56
+ map = {}
57
+ changed_files.each do |file|
58
+ next unless file.status == :renamed
59
+
60
+ map[file.new_path] = file.old_path if file.new_path && file.old_path
61
+ end
62
+ map.freeze
63
+ end
64
+
65
+ def self.build(repository_root:, base_override:, configuration:, runner: ProcessRunner)
66
+ root = File.realpath(repository_root)
67
+ raise Git::NotARepository, "repository root does not exist: #{repository_root}" unless File.directory?(root)
68
+
69
+ git_root = resolve_git_root(root, runner: runner)
70
+ unless Pathname.new(git_root) == Pathname.new(root) || root.start_with?(git_root + File::SEPARATOR) || git_root == root
71
+ git_root = root if File.directory?(File.join(root, ".git"))
72
+ end
73
+ unless Dir.exist?(File.join(git_root, ".git")) || File.file?(File.join(git_root, ".git"))
74
+ toplevel = git_toplevel(root, runner: runner)
75
+ if toplevel && File.directory?(toplevel)
76
+ git_root = toplevel
77
+ end
78
+ end
79
+ repository_root = git_root
80
+
81
+ head = resolve_head(repository_root, runner: runner)
82
+ base_raw = resolve_base_raw(base_override, configuration)
83
+ if base_raw.nil? || base_raw.strip.empty?
84
+ raise Git::BaseUnresolvable, "changed scope requires an explicit base: supply --base <revision> or set git.base in .railverdict.yml"
85
+ end
86
+ base = resolve_base_commit(base_raw.strip, repository_root, runner: runner)
87
+ merge_base = resolve_merge_base(base, head, repository_root, runner: runner)
88
+ changed_files, binary_set = resolve_changed_files(merge_base, head, repository_root, runner: runner)
89
+ changed_line_set = resolve_changed_line_set(merge_base, head, changed_files, repository_root, runner: runner)
90
+ conflicted = resolve_conflicts(repository_root, runner: runner)
91
+ binary_paths = (binary_set.to_a + changed_line_set.select { |_, v| v.nil? }.keys).uniq.sort.freeze
92
+ filtered_line_set = changed_line_set.reject { |_, v| v.nil? }
93
+
94
+ new(
95
+ repository_root: repository_root,
96
+ head: head,
97
+ base: base,
98
+ merge_base: merge_base,
99
+ changed_files: changed_files,
100
+ changed_line_set: filtered_line_set,
101
+ binary_paths: binary_paths,
102
+ conflicted_paths: conflicted
103
+ )
104
+ end
105
+
106
+ def self.resolve_git_root(root, runner:)
107
+ result = Command.run(["rev-parse", "--show-toplevel"], chdir: root, runner: runner)
108
+ interpreted = begin
109
+ Command.interpret(result, chdir: root)
110
+ rescue Git::Error
111
+ return root
112
+ end
113
+ return root unless interpreted.exit_code == 0
114
+
115
+ toplevel = interpreted.stdout.to_s.strip
116
+ toplevel = toplevel.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?").strip
117
+ toplevel = toplevel.unicode_normalize(:nfc) if toplevel.respond_to?(:unicode_normalize)
118
+ toplevel.empty? ? root : File.realpath(toplevel)
119
+ rescue Errno::ENOENT, Errno::EACCES
120
+ root
121
+ end
122
+ private_class_method :resolve_git_root
123
+
124
+ def self.git_toplevel(root, runner:)
125
+ result = Command.run(["rev-parse", "--show-toplevel"], chdir: root, runner: runner)
126
+ interpreted = begin
127
+ Command.interpret(result, chdir: root)
128
+ rescue Git::Error
129
+ return nil
130
+ end
131
+ return nil unless interpreted.exit_code == 0
132
+
133
+ out = interpreted.stdout.to_s.strip
134
+ out.empty? ? nil : out
135
+ end
136
+ private_class_method :git_toplevel
137
+
138
+ def self.resolve_head(root, runner:)
139
+ result = Command.run(["rev-parse", "HEAD"], chdir: root, runner: runner)
140
+ interpreted = Command.interpret(result, chdir: root)
141
+ unless interpreted.exit_code == 0
142
+ raise Git::Error, "cannot resolve HEAD: #{interpreted.stderr.to_s.strip[0, 512]}"
143
+ end
144
+ sha = interpreted.stdout.to_s.strip
145
+ unless sha.match?(/\A[0-9a-f]{7,64}\z/)
146
+ raise Git::Error, "HEAD is not a valid commit SHA: #{sha.inspect}"
147
+ end
148
+ full = resolve_full_sha(sha, root, runner: runner)
149
+ full || sha
150
+ end
151
+ private_class_method :resolve_head
152
+
153
+ def self.resolve_full_sha(sha, root, runner:)
154
+ result = Command.run(["rev-parse", sha], chdir: root, runner: runner)
155
+ interpreted = begin
156
+ Command.interpret(result, chdir: root)
157
+ rescue Git::Error
158
+ return nil
159
+ end
160
+ return nil unless interpreted.exit_code == 0
161
+
162
+ out = interpreted.stdout.to_s.strip
163
+ out.match?(/\A[0-9a-f]{40,64}\z/) ? out : nil
164
+ end
165
+ private_class_method :resolve_full_sha
166
+
167
+ def self.resolve_base_raw(base_override, configuration)
168
+ if base_override && !base_override.to_s.strip.empty?
169
+ return base_override.to_s.strip
170
+ end
171
+ if configuration.respond_to?(:git_base) && configuration.git_base && !configuration.git_base.to_s.strip.empty?
172
+ return configuration.git_base.to_s.strip
173
+ end
174
+ nil
175
+ end
176
+ private_class_method :resolve_base_raw
177
+
178
+ def self.resolve_base_commit(base_raw, root, runner:)
179
+ result = Command.run(["rev-parse", "--verify", "#{base_raw}^{commit}"], chdir: root, runner: runner)
180
+ interpreted = begin
181
+ Command.interpret(result, chdir: root)
182
+ rescue Git::Unavailable, Git::Timeout, Git::Truncated => error
183
+ raise error
184
+ rescue Git::Error
185
+ raise Git::BaseUnresolvable, "base revision is not resolvable: #{base_raw.inspect}"
186
+ end
187
+ unless interpreted.exit_code == 0
188
+ message = interpreted.stderr.to_s.strip
189
+ if message.include?("not a valid object name") || message.include?("needed a single revision")
190
+ raise Git::BaseUnresolvable, "base revision is not resolvable: #{base_raw.inspect}"
191
+ end
192
+ raise Git::BaseUnresolvable, "base revision is not resolvable: #{base_raw.inspect} (#{message[0, 512]})"
193
+ end
194
+ sha = interpreted.stdout.to_s.strip
195
+ unless sha.match?(/\A[0-9a-f]{7,64}\z/)
196
+ raise Git::BaseUnresolvable, "base did not resolve to a commit: #{base_raw.inspect}"
197
+ end
198
+ sha
199
+ end
200
+ private_class_method :resolve_base_commit
201
+
202
+ def self.resolve_merge_base(base, head, root, runner:)
203
+ result = Command.run(["merge-base", base, head], chdir: root, runner: runner)
204
+ interpreted = begin
205
+ Command.interpret(result, chdir: root)
206
+ rescue Git::Unavailable, Git::Timeout, Git::Truncated => error
207
+ raise error
208
+ rescue Git::Error => error
209
+ raise Git::MergeBaseFailure, "cannot resolve merge-base for #{base.inspect} and #{head.inspect}: #{error.message}"
210
+ end
211
+ unless interpreted.exit_code == 0
212
+ stderr = interpreted.stderr.to_s.strip
213
+ if stderr.include?("not a valid object name") || stderr.include?("no merge base")
214
+ raise Git::MergeBaseFailure, "cannot resolve merge-base: history is incomplete or base is unrelated (#{stderr[0, 512]})"
215
+ end
216
+ raise Git::MergeBaseFailure, "cannot resolve merge-base: #{stderr[0, 512]}"
217
+ end
218
+ sha = interpreted.stdout.to_s.strip
219
+ unless sha.match?(/\A[0-9a-f]{7,64}\z/)
220
+ raise Git::MergeBaseFailure, "merge-base did not resolve to a commit"
221
+ end
222
+ sha
223
+ end
224
+ private_class_method :resolve_merge_base
225
+
226
+ def self.resolve_changed_files(merge_base, head, root, runner:)
227
+ name_status_raw = run_diff_or_raise(["diff", "--name-status", "-z", "--find-renames", "--diff-filter=ADMR", merge_base, head], root, runner: runner, context: "name-status")
228
+ files = DiffParser.parse_name_status_nul(name_status_raw)
229
+
230
+ numstat_raw = run_diff_or_raise(["diff", "--numstat", "-z", "--find-renames", merge_base, head], root, runner: runner, context: "numstat")
231
+ numstat = DiffParser.parse_numstat_rename_nul(numstat_raw)
232
+
233
+ binary_set = Set.new
234
+ enriched = files.map do |file|
235
+ key = file.path || file.old_path
236
+ info = numstat[key] if key
237
+ is_binary = !!(info && info[:binary])
238
+ binary_set.add(key) if is_binary
239
+ ChangedFile.new(status: file.status, path: file.path, old_path: file.old_path, new_path: file.new_path, score: file.score, binary: is_binary)
240
+ end
241
+
242
+ changed = enriched.reject { |file| file.status == :deleted }.sort_by { |file| file.path.to_s }
243
+ [changed, binary_set]
244
+ end
245
+ private_class_method :resolve_changed_files
246
+
247
+ def self.run_diff_or_raise(argv, root, runner:, context:)
248
+ result = Command.run(argv, chdir: root, timeout_seconds: 10.0, max_stdout_bytes: 4 * 1024 * 1024, runner: runner)
249
+ interpreted = Command.interpret(result, chdir: root)
250
+ unless interpreted.exit_code == 0
251
+ raise Git::DiffFailure, "git diff #{context} failed: #{interpreted.stderr.to_s.strip[0, 1024]}"
252
+ end
253
+ interpreted.stdout.dup.force_encoding(Encoding::BINARY)
254
+ end
255
+ private_class_method :run_diff_or_raise
256
+
257
+ def self.resolve_changed_line_set(merge_base, head, changed_files, root, runner:)
258
+ return {} if changed_files.empty?
259
+
260
+ raw = begin
261
+ run_diff_or_raise(["diff", "-U0", "--no-color", "-z", "--find-renames", merge_base, head], root, runner: runner, context: "unified")
262
+ rescue Git::DiffFailure
263
+ ""
264
+ end
265
+ raw = raw.dup.force_encoding(Encoding::BINARY) if raw
266
+ binary_paths = changed_files.select { |file| file.binary }.map { |file| file.path }.compact
267
+ binary_set = Set.new(binary_paths)
268
+ segments = DiffParser.split_unified_by_nul(raw || "")
269
+ line_set = {}
270
+ segments.each do |path, diff_text|
271
+ normalized = normalize_path_for_lines(path.to_s)
272
+ next if binary_set.include?(normalized)
273
+
274
+ exists = changed_files.any? { |file| file.path == normalized }
275
+ next unless exists || changed_files.any? { |file| file.new_path == normalized }
276
+
277
+ text = diff_text.to_s
278
+ next if text.empty?
279
+
280
+ lines = extract_added_lines(text)
281
+ line_set[normalized] = lines.sort.freeze unless lines.empty?
282
+ end
283
+ line_set.transform_values(&:freeze).freeze
284
+ end
285
+ private_class_method :resolve_changed_line_set
286
+
287
+ def self.extract_added_lines(diff_text)
288
+ lines = []
289
+ new_line = nil
290
+ diff_text.each_line do |raw_line|
291
+ if raw_line.start_with?("@@ ")
292
+ match = raw_line.match(/\A@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/)
293
+ new_line = match ? match[1].to_i : nil
294
+ next
295
+ end
296
+ next if new_line.nil?
297
+ next if raw_line.start_with?("diff --git") || raw_line.start_with?("index ") || raw_line.start_with?("--- ") || raw_line.start_with?("+++ ")
298
+
299
+ if raw_line.start_with?("+")
300
+ next if raw_line.start_with?("+++ ")
301
+
302
+ lines << new_line
303
+ new_line += 1
304
+ elsif raw_line.start_with?("-")
305
+ next
306
+ elsif raw_line.start_with?(" ") || raw_line.start_with?("\\")
307
+ new_line += 1 if raw_line.start_with?(" ")
308
+ end
309
+ end
310
+ lines
311
+ end
312
+ private_class_method :extract_added_lines
313
+
314
+ def self.normalize_path_for_lines(path)
315
+ raw = path.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?").strip
316
+ raw = raw.delete_prefix("./")
317
+ raw = raw.delete_prefix("/")
318
+ raw = raw.unicode_normalize(:nfc) if raw.respond_to?(:unicode_normalize)
319
+ raw
320
+ end
321
+ private_class_method :normalize_path_for_lines
322
+
323
+ def self.resolve_conflicts(root, runner:)
324
+ result = Command.run(["ls-files", "-u", "-z"], chdir: root, runner: runner)
325
+ interpreted = begin
326
+ Command.interpret(result, chdir: root)
327
+ rescue Git::Error
328
+ return [].freeze
329
+ end
330
+ return [].freeze unless interpreted.exit_code == 0
331
+
332
+ raw = interpreted.stdout.to_s
333
+ return [].freeze if raw.strip.empty?
334
+
335
+ parts = raw.split("\0")
336
+ paths = Set.new
337
+ parts.each do |entry|
338
+ next if entry.strip.empty?
339
+
340
+ tab_index = entry.index("\t")
341
+ path = tab_index ? entry[(tab_index + 1)..] : entry
342
+ next if path.nil? || path.strip.empty?
343
+
344
+ paths.add(normalize_path_for_lines(path))
345
+ end
346
+ paths.to_a.sort.freeze
347
+ end
348
+ private_class_method :resolve_conflicts
349
+
350
+ private
351
+
352
+ def deep_freeze_line_set(line_set)
353
+ frozen = {}
354
+ line_set.each do |path, lines|
355
+ frozen[path.dup.freeze] = lines.sort.freeze
356
+ end
357
+ frozen.freeze
358
+ end
359
+ end
360
+ end
361
+ end