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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/NOTICE +14 -0
- data/README.md +192 -0
- data/exe/railverdict +6 -0
- data/exe/railverdict-minitest-reporter.rb +90 -0
- data/lib/rail_verdict/analyzers/_shared.rb +54 -0
- data/lib/rail_verdict/analyzers/bundler_audit.rb +186 -0
- data/lib/rail_verdict/analyzers/minitest.rb +312 -0
- data/lib/rail_verdict/analyzers/rspec.rb +274 -0
- data/lib/rail_verdict/analyzers/rubocop.rb +289 -0
- data/lib/rail_verdict/analyzers/simplecov.rb +178 -0
- data/lib/rail_verdict/baseline.rb +151 -0
- data/lib/rail_verdict/check.rb +415 -0
- data/lib/rail_verdict/cli.rb +471 -0
- data/lib/rail_verdict/comparison.rb +216 -0
- data/lib/rail_verdict/configuration.rb +165 -0
- data/lib/rail_verdict/contracts/analyzer_result.rb +138 -0
- data/lib/rail_verdict/contracts/finding.rb +154 -0
- data/lib/rail_verdict/contracts/gate_result.rb +171 -0
- data/lib/rail_verdict/coverage/changed_line_evaluator.rb +70 -0
- data/lib/rail_verdict/doctor.rb +132 -0
- data/lib/rail_verdict/errors.rb +17 -0
- data/lib/rail_verdict/findings_command.rb +24 -0
- data/lib/rail_verdict/fingerprint.rb +72 -0
- data/lib/rail_verdict/git/command.rb +48 -0
- data/lib/rail_verdict/git/context.rb +361 -0
- data/lib/rail_verdict/git/diff_parser.rb +200 -0
- data/lib/rail_verdict/git/errors.rb +16 -0
- data/lib/rail_verdict/init.rb +38 -0
- data/lib/rail_verdict/intelligence/ai_analysis.rb +82 -0
- data/lib/rail_verdict/intelligence/ai_failure.rb +44 -0
- data/lib/rail_verdict/intelligence/budget.rb +36 -0
- data/lib/rail_verdict/intelligence/cache.rb +74 -0
- data/lib/rail_verdict/intelligence/context_builder.rb +92 -0
- data/lib/rail_verdict/intelligence/manifest.rb +70 -0
- data/lib/rail_verdict/intelligence/orchestrator.rb +77 -0
- data/lib/rail_verdict/intelligence/prompt.rb +28 -0
- data/lib/rail_verdict/intelligence/provider.rb +14 -0
- data/lib/rail_verdict/intelligence/providers/fake_provider.rb +31 -0
- data/lib/rail_verdict/intelligence/providers/openai_compat.rb +119 -0
- data/lib/rail_verdict/intelligence/redactor.rb +51 -0
- data/lib/rail_verdict/intelligence/secret_detector.rb +45 -0
- data/lib/rail_verdict/intelligence/source_reader.rb +49 -0
- data/lib/rail_verdict/intelligence.rb +11 -0
- data/lib/rail_verdict/mcp/cache.rb +233 -0
- data/lib/rail_verdict/mcp/repository_root.rb +31 -0
- data/lib/rail_verdict/mcp/serializers.rb +98 -0
- data/lib/rail_verdict/mcp/server.rb +110 -0
- data/lib/rail_verdict/mcp/tools/build_repair_packet.rb +85 -0
- data/lib/rail_verdict/mcp/tools/explain.rb +100 -0
- data/lib/rail_verdict/mcp/tools/get_finding.rb +139 -0
- data/lib/rail_verdict/mcp/tools/investigate.rb +107 -0
- data/lib/rail_verdict/mcp/tools/list_findings.rb +119 -0
- data/lib/rail_verdict/mcp/tools/verify.rb +137 -0
- data/lib/rail_verdict/mcp/tools/verify_repair.rb +105 -0
- data/lib/rail_verdict/mcp/validators.rb +81 -0
- data/lib/rail_verdict/mcp.rb +7 -0
- data/lib/rail_verdict/process_runner.rb +292 -0
- data/lib/rail_verdict/rails_context/classifier.rb +57 -0
- data/lib/rail_verdict/rails_context/confidence.rb +13 -0
- data/lib/rail_verdict/rails_context/constant_inferencer.rb +56 -0
- data/lib/rail_verdict/rails_context/context.rb +115 -0
- data/lib/rail_verdict/rails_context/detector.rb +141 -0
- data/lib/rail_verdict/rails_context/limits.rb +17 -0
- data/lib/rail_verdict/rails_context/resolvers/association_extractor.rb +48 -0
- data/lib/rail_verdict/rails_context/resolvers/policy_resolver.rb +45 -0
- data/lib/rail_verdict/rails_context/resolvers/route_resolver.rb +40 -0
- data/lib/rail_verdict/rails_context/resolvers/route_scanner.rb +55 -0
- data/lib/rail_verdict/rails_context/resolvers/schema_resolver.rb +90 -0
- data/lib/rail_verdict/rails_context/resolvers/test_candidates.rb +75 -0
- data/lib/rail_verdict/rails_context/resolvers/view_resolver.rb +45 -0
- data/lib/rail_verdict/rails_context/schema_parser.rb +50 -0
- data/lib/rail_verdict/rails_context.rb +21 -0
- data/lib/rail_verdict/repair/boundary.rb +36 -0
- data/lib/rail_verdict/repair/command.rb +89 -0
- data/lib/rail_verdict/repair/constraints.rb +31 -0
- data/lib/rail_verdict/repair/context_assembler.rb +301 -0
- data/lib/rail_verdict/repair/diff_context.rb +51 -0
- data/lib/rail_verdict/repair/packet.rb +158 -0
- data/lib/rail_verdict/repair/prompt_renderer.rb +27 -0
- data/lib/rail_verdict/repair/verification_plan.rb +45 -0
- data/lib/rail_verdict/repair/verifier.rb +84 -0
- data/lib/rail_verdict/reporters/console.rb +107 -0
- data/lib/rail_verdict/reporters/github_annotations.rb +104 -0
- data/lib/rail_verdict/reporters/json_reporter.rb +19 -0
- data/lib/rail_verdict/reporters/sarif.rb +138 -0
- data/lib/rail_verdict/run_context.rb +99 -0
- data/lib/rail_verdict/schema_validator.rb +85 -0
- data/lib/rail_verdict/strict_yaml.rb +134 -0
- data/lib/rail_verdict/verification/policy.rb +359 -0
- data/lib/rail_verdict/version.rb +5 -0
- data/lib/rail_verdict/waiver.rb +67 -0
- data/lib/rail_verdict/waiver_store.rb +59 -0
- data/lib/rail_verdict.rb +64 -0
- data/schemas/ai-analysis-v1.schema.json +54 -0
- data/schemas/baseline-v1.schema.json +108 -0
- data/schemas/configuration-v1.1.schema.json +150 -0
- data/schemas/configuration-v1.2.schema.json +182 -0
- data/schemas/configuration-v1.3.schema.json +198 -0
- data/schemas/configuration-v1.4.schema.json +146 -0
- data/schemas/configuration-v1.schema.json +72 -0
- data/schemas/coverage-v1.schema.json +51 -0
- data/schemas/finding-v1.schema.json +131 -0
- data/schemas/minitest-reporter-v1.schema.json +113 -0
- data/schemas/repair-packet-v1.schema.json +364 -0
- data/schemas/result-v1.schema.json +378 -0
- data/schemas/waiver-v1.schema.json +45 -0
- data/schemas/waivers-v1.schema.json +67 -0
- metadata +192 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailVerdict
|
|
4
|
+
module Git
|
|
5
|
+
module DiffParser
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def parse_name_status_nul(raw)
|
|
9
|
+
return [] if raw.nil? || raw.empty?
|
|
10
|
+
|
|
11
|
+
parts = raw.split("\0")
|
|
12
|
+
parts.pop if parts.last == ""
|
|
13
|
+
files = []
|
|
14
|
+
index = 0
|
|
15
|
+
while index < parts.length
|
|
16
|
+
entry = parts[index]
|
|
17
|
+
break if entry.nil? || entry.empty?
|
|
18
|
+
|
|
19
|
+
status_char = entry[0]
|
|
20
|
+
case status_char
|
|
21
|
+
when "R", "C"
|
|
22
|
+
old_path = parts[index + 1]
|
|
23
|
+
new_path = parts[index + 2]
|
|
24
|
+
if old_path.nil? || new_path.nil?
|
|
25
|
+
raise Git::Error, "malformed git name-status output (rename requires two paths)"
|
|
26
|
+
end
|
|
27
|
+
score = entry[1..].to_i
|
|
28
|
+
files << ChangedFile.new(status: status_char == "R" ? :renamed : :copied, path: normalize_path(new_path), old_path: normalize_path(old_path), new_path: normalize_path(new_path), score: score, binary: false)
|
|
29
|
+
index += 3
|
|
30
|
+
when "A"
|
|
31
|
+
path = parts[index + 1]
|
|
32
|
+
raise Git::Error, "malformed git name-status output (added requires path)" if path.nil?
|
|
33
|
+
|
|
34
|
+
files << ChangedFile.new(status: :added, path: normalize_path(path), old_path: nil, new_path: normalize_path(path), score: nil, binary: false)
|
|
35
|
+
index += 2
|
|
36
|
+
when "D"
|
|
37
|
+
path = parts[index + 1]
|
|
38
|
+
raise Git::Error, "malformed git name-status output (deleted requires path)" if path.nil?
|
|
39
|
+
|
|
40
|
+
files << ChangedFile.new(status: :deleted, path: nil, old_path: normalize_path(path), new_path: nil, score: nil, binary: false)
|
|
41
|
+
index += 2
|
|
42
|
+
when "M", "T"
|
|
43
|
+
path = parts[index + 1]
|
|
44
|
+
raise Git::Error, "malformed git name-status output (modified requires path)" if path.nil?
|
|
45
|
+
|
|
46
|
+
mapped = status_char == "T" ? :typechange : :modified
|
|
47
|
+
files << ChangedFile.new(status: mapped, path: normalize_path(path), old_path: normalize_path(path), new_path: normalize_path(path), score: nil, binary: false)
|
|
48
|
+
index += 2
|
|
49
|
+
else
|
|
50
|
+
raise Git::Error, "unexpected git name-status entry: #{entry.inspect}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
files.sort_by { |file| (file.path || file.old_path || "").to_s }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def parse_numstat_nul(raw)
|
|
57
|
+
return {} if raw.nil? || raw.empty?
|
|
58
|
+
|
|
59
|
+
parts = raw.split("\0")
|
|
60
|
+
parts.pop if parts.last == ""
|
|
61
|
+
result = {}
|
|
62
|
+
parts.each do |entry|
|
|
63
|
+
next if entry.empty?
|
|
64
|
+
|
|
65
|
+
fields = entry.split("\t")
|
|
66
|
+
next unless fields.length == 3
|
|
67
|
+
|
|
68
|
+
added, deleted, path = fields
|
|
69
|
+
binary = added == "-" || deleted == "-"
|
|
70
|
+
result[normalize_path(path)] = { added: binary ? nil : added.to_i, deleted: binary ? nil : deleted.to_i, binary: binary }
|
|
71
|
+
end
|
|
72
|
+
result
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def parse_numstat_rename_nul(raw)
|
|
76
|
+
return {} if raw.nil? || raw.empty?
|
|
77
|
+
|
|
78
|
+
parts = raw.split("\0")
|
|
79
|
+
parts.pop if parts.last == ""
|
|
80
|
+
result = {}
|
|
81
|
+
parts.each do |entry|
|
|
82
|
+
next if entry.empty?
|
|
83
|
+
|
|
84
|
+
fields = entry.split("\t")
|
|
85
|
+
next unless fields.length >= 3
|
|
86
|
+
|
|
87
|
+
added, deleted = fields[0], fields[1]
|
|
88
|
+
binary = added == "-" || deleted == "-"
|
|
89
|
+
path = fields.length == 4 ? fields[3] : fields[2]
|
|
90
|
+
key = normalize_path(path)
|
|
91
|
+
result[key] = { added: binary ? nil : added.to_i, deleted: binary ? nil : deleted.to_i, binary: binary }
|
|
92
|
+
end
|
|
93
|
+
result
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def parse_changed_lines_unified(raw, changed_paths)
|
|
97
|
+
return {} if raw.nil? || raw.empty?
|
|
98
|
+
|
|
99
|
+
return {} if changed_paths.nil? || changed_paths.empty?
|
|
100
|
+
|
|
101
|
+
binary_set = Set.new
|
|
102
|
+
changed_paths.each do |file|
|
|
103
|
+
binary_set.add(file[:path]) if file[:binary]
|
|
104
|
+
binary_set.add(file[:new_path]) if file[:binary] && file[:new_path]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
segments = split_unified_by_nul(raw)
|
|
108
|
+
line_set = {}
|
|
109
|
+
segments.each do |segment|
|
|
110
|
+
path, diff_text = segment
|
|
111
|
+
normalized = normalize_path(path.to_s)
|
|
112
|
+
next if binary_set.include?(normalized)
|
|
113
|
+
next if diff_text.nil? || diff_text.empty?
|
|
114
|
+
|
|
115
|
+
lines = parse_unified_hunks(diff_text)
|
|
116
|
+
line_set[normalized] = lines.sort.freeze unless lines.empty?
|
|
117
|
+
end
|
|
118
|
+
line_set
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def split_unified_by_nul(raw)
|
|
122
|
+
raw_text = raw.dup.force_encoding(Encoding::UTF_8).scrub("?")
|
|
123
|
+
if raw_text.include?("\0")
|
|
124
|
+
parts = raw_text.split("\0")
|
|
125
|
+
parts.pop if parts.last == ""
|
|
126
|
+
segments = []
|
|
127
|
+
parts.each_slice(2) do |path, diff_text|
|
|
128
|
+
next if path.nil?
|
|
129
|
+
|
|
130
|
+
segments << [path, diff_text || ""]
|
|
131
|
+
end
|
|
132
|
+
segments
|
|
133
|
+
else
|
|
134
|
+
split_unified_without_nul(raw_text)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def split_unified_without_nul(raw_text)
|
|
139
|
+
segments = []
|
|
140
|
+
current_path = nil
|
|
141
|
+
current_lines = []
|
|
142
|
+
raw_text.each_line do |line|
|
|
143
|
+
if line.start_with?("diff --git ")
|
|
144
|
+
if current_path
|
|
145
|
+
segments << [current_path, current_lines.join]
|
|
146
|
+
end
|
|
147
|
+
match = line.match(%r{\Adiff --git a/.+ b/(.+)\n?\z})
|
|
148
|
+
current_path = match ? match[1].strip : "unknown"
|
|
149
|
+
current_lines = []
|
|
150
|
+
elsif current_path
|
|
151
|
+
current_lines << line
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
segments << [current_path, current_lines.join] if current_path
|
|
155
|
+
segments
|
|
156
|
+
end
|
|
157
|
+
private_class_method :split_unified_without_nul
|
|
158
|
+
|
|
159
|
+
def parse_unified_hunks(diff_text)
|
|
160
|
+
lines = []
|
|
161
|
+
new_line = nil
|
|
162
|
+
diff_text.each_line do |raw_line|
|
|
163
|
+
if raw_line.start_with?("@@ ")
|
|
164
|
+
match = raw_line.match(/\A@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/)
|
|
165
|
+
new_line = match ? match[1].to_i : nil
|
|
166
|
+
next
|
|
167
|
+
end
|
|
168
|
+
next if new_line.nil?
|
|
169
|
+
next if raw_line.start_with?("diff --git") || raw_line.start_with?("index ") || raw_line.start_with?("--- ") || raw_line.start_with?("+++ ")
|
|
170
|
+
|
|
171
|
+
if raw_line.start_with?("+")
|
|
172
|
+
next if raw_line.start_with?("+++ ")
|
|
173
|
+
|
|
174
|
+
lines << new_line
|
|
175
|
+
new_line += 1
|
|
176
|
+
elsif raw_line.start_with?("-")
|
|
177
|
+
next
|
|
178
|
+
elsif raw_line.start_with?(" ") || raw_line.start_with?("\\")
|
|
179
|
+
new_line += 1 if raw_line.start_with?(" ")
|
|
180
|
+
elsif raw_line.strip.empty?
|
|
181
|
+
next
|
|
182
|
+
else
|
|
183
|
+
next
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
lines
|
|
187
|
+
end
|
|
188
|
+
private_class_method :parse_unified_hunks
|
|
189
|
+
|
|
190
|
+
def normalize_path(path)
|
|
191
|
+
raw = path.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?").strip
|
|
192
|
+
raw = raw.delete_prefix("./")
|
|
193
|
+
raw = raw.delete_prefix("/")
|
|
194
|
+
raw = raw.unicode_normalize(:nfc) if raw.respond_to?(:unicode_normalize)
|
|
195
|
+
raw
|
|
196
|
+
end
|
|
197
|
+
private_class_method :normalize_path
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailVerdict
|
|
4
|
+
module Git
|
|
5
|
+
class Error < RailVerdict::Error; end
|
|
6
|
+
|
|
7
|
+
class NotARepository < Error; end
|
|
8
|
+
class Unavailable < Error; end
|
|
9
|
+
class InvalidRevision < Error; end
|
|
10
|
+
class BaseUnresolvable < Error; end
|
|
11
|
+
class MergeBaseFailure < Error; end
|
|
12
|
+
class DiffFailure < Error; end
|
|
13
|
+
class Timeout < Error; end
|
|
14
|
+
class Truncated < Error; end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module RailVerdict
|
|
6
|
+
module Init
|
|
7
|
+
CONFIGURATION = <<~YAML
|
|
8
|
+
version: 1
|
|
9
|
+
mode: no_new_debt
|
|
10
|
+
analyzers:
|
|
11
|
+
rubocop:
|
|
12
|
+
enabled: true
|
|
13
|
+
required: true
|
|
14
|
+
YAML
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def write(root:, config_path:, force: false)
|
|
19
|
+
path = Pathname.new(config_path.to_s)
|
|
20
|
+
path = Pathname.new(File.expand_path(path.to_s, root)) unless path.absolute?
|
|
21
|
+
if path.exist? && !force
|
|
22
|
+
return [2, "configuration already exists: #{relative_path(path.to_s, root)}; use --force to overwrite"]
|
|
23
|
+
end
|
|
24
|
+
parent = path.dirname
|
|
25
|
+
return [2, "configuration directory does not exist: #{relative_path(parent.to_s, root)}"] unless parent.directory?
|
|
26
|
+
|
|
27
|
+
File.write(path, CONFIGURATION)
|
|
28
|
+
[0, "initialized #{relative_path(path.to_s, root)}"]
|
|
29
|
+
rescue SystemCallError => error
|
|
30
|
+
[2, "could not write configuration: #{error.message}"]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def relative_path(path, root)
|
|
34
|
+
Pathname.new(path).relative_path_from(Pathname.new(root)).to_s
|
|
35
|
+
end
|
|
36
|
+
private_class_method :relative_path
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailVerdict
|
|
4
|
+
module Intelligence
|
|
5
|
+
class AIAnalysis
|
|
6
|
+
SCHEMA_VERSION = "1.0"
|
|
7
|
+
ASSESSMENTS = %w[likely_cause needs_investigation uncertain].freeze
|
|
8
|
+
CONFIDENCES = %w[low medium high].freeze
|
|
9
|
+
|
|
10
|
+
attr_reader :finding_id, :fingerprint, :assessment, :confidence, :summary,
|
|
11
|
+
:root_cause, :suggested_fix, :recommended_tests, :evidence_notes, :provenance
|
|
12
|
+
|
|
13
|
+
def initialize(finding_id:, fingerprint:, assessment:, confidence:, summary:, provenance:,
|
|
14
|
+
root_cause: nil, suggested_fix: nil, recommended_tests: [], evidence_notes: [])
|
|
15
|
+
@finding_id = finding_id.dup.freeze
|
|
16
|
+
@fingerprint = fingerprint.dup.freeze
|
|
17
|
+
@assessment = assessment.dup.freeze
|
|
18
|
+
@confidence = confidence.dup.freeze
|
|
19
|
+
@summary = summary.dup.freeze
|
|
20
|
+
@root_cause = root_cause&.dup&.freeze
|
|
21
|
+
@suggested_fix = suggested_fix&.dup&.freeze
|
|
22
|
+
@recommended_tests = (recommended_tests || []).map { |v| v.dup.freeze }.freeze
|
|
23
|
+
@evidence_notes = (evidence_notes || []).map { |v| v.dup.freeze }.freeze
|
|
24
|
+
@provenance = deep_freeze(provenance.dup)
|
|
25
|
+
validate!
|
|
26
|
+
freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_h
|
|
30
|
+
h = {
|
|
31
|
+
"schema_version" => SCHEMA_VERSION,
|
|
32
|
+
"finding_id" => finding_id,
|
|
33
|
+
"fingerprint" => fingerprint,
|
|
34
|
+
"assessment" => assessment,
|
|
35
|
+
"confidence" => confidence,
|
|
36
|
+
"summary" => summary,
|
|
37
|
+
"provenance" => provenance
|
|
38
|
+
}
|
|
39
|
+
h["root_cause"] = root_cause if root_cause
|
|
40
|
+
h["suggested_fix"] = suggested_fix if suggested_fix
|
|
41
|
+
h["recommended_tests"] = recommended_tests unless recommended_tests.empty?
|
|
42
|
+
h["evidence_notes"] = evidence_notes unless evidence_notes.empty?
|
|
43
|
+
h
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.from_hash(hash)
|
|
47
|
+
errors = SchemaValidator.validate_ai_analysis(hash)
|
|
48
|
+
raise ArgumentError, errors.join("; ") unless errors.empty?
|
|
49
|
+
|
|
50
|
+
new(
|
|
51
|
+
finding_id: hash.fetch("finding_id"),
|
|
52
|
+
fingerprint: hash.fetch("fingerprint"),
|
|
53
|
+
assessment: hash.fetch("assessment"),
|
|
54
|
+
confidence: hash.fetch("confidence"),
|
|
55
|
+
summary: hash.fetch("summary"),
|
|
56
|
+
root_cause: hash["root_cause"],
|
|
57
|
+
suggested_fix: hash["suggested_fix"],
|
|
58
|
+
recommended_tests: hash["recommended_tests"] || [],
|
|
59
|
+
evidence_notes: hash["evidence_notes"] || [],
|
|
60
|
+
provenance: hash.fetch("provenance")
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def validate!
|
|
67
|
+
raise ArgumentError, "assessment must be one of #{ASSESSMENTS.join(', ')}" unless ASSESSMENTS.include?(assessment)
|
|
68
|
+
raise ArgumentError, "confidence must be one of #{CONFIDENCES.join(', ')}" unless CONFIDENCES.include?(confidence)
|
|
69
|
+
raise ArgumentError, "fingerprint invalid" unless fingerprint.match?(/\Asha256:[0-9a-f]{64}\z/)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def deep_freeze(value)
|
|
73
|
+
case value
|
|
74
|
+
when Hash then value.each { |k, v| k.freeze if k.is_a?(String); deep_freeze(v) }; value.freeze
|
|
75
|
+
when Array then value.each { |v| deep_freeze(v) }; value.freeze
|
|
76
|
+
when String then value.freeze
|
|
77
|
+
else value
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailVerdict
|
|
4
|
+
module Intelligence
|
|
5
|
+
class AIFailure
|
|
6
|
+
CODES = %w[
|
|
7
|
+
disabled
|
|
8
|
+
provider_unavailable
|
|
9
|
+
authentication_failed
|
|
10
|
+
rate_limited
|
|
11
|
+
timed_out
|
|
12
|
+
budget_exhausted
|
|
13
|
+
context_rejected
|
|
14
|
+
secret_detected
|
|
15
|
+
response_invalid
|
|
16
|
+
schema_invalid
|
|
17
|
+
context_too_large
|
|
18
|
+
manifest_invalid
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
attr_reader :code, :message
|
|
22
|
+
|
|
23
|
+
def initialize(code:, message:)
|
|
24
|
+
raise ArgumentError, "invalid code" unless CODES.include?(code.to_s)
|
|
25
|
+
|
|
26
|
+
@code = code.to_s.freeze
|
|
27
|
+
@message = sanitize(message.to_s).freeze
|
|
28
|
+
freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_h
|
|
32
|
+
{ "code" => code, "message" => message }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def sanitize(text)
|
|
38
|
+
cleaned = text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")
|
|
39
|
+
cleaned = cleaned.gsub(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/, "?")
|
|
40
|
+
cleaned.strip[0, 1024]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailVerdict
|
|
4
|
+
module Intelligence
|
|
5
|
+
class Budget
|
|
6
|
+
DEFAULTS = {
|
|
7
|
+
max_findings: 3,
|
|
8
|
+
max_requests: 3,
|
|
9
|
+
max_context_bytes: 64 * 1024
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :max_findings, :max_requests, :max_context_bytes
|
|
13
|
+
|
|
14
|
+
def initialize(config_budgets = {})
|
|
15
|
+
@max_findings = (config_budgets["max_findings"] || DEFAULTS[:max_findings]).to_i
|
|
16
|
+
@max_requests = (config_budgets["max_requests"] || DEFAULTS[:max_requests]).to_i
|
|
17
|
+
@max_context_bytes = (config_budgets["max_context_bytes"] || DEFAULTS[:max_context_bytes]).to_i
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def enforce!(manifest)
|
|
21
|
+
if manifest.total_bytes > max_context_bytes
|
|
22
|
+
raise AIFailure.new(code: "budget_exhausted", message: "context exceeds max_context_bytes #{max_context_bytes}")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.select_findings(findings, limit:)
|
|
27
|
+
severity_rank = { "critical" => 0, "high" => 1, "medium" => 2, "low" => 3, "info" => 4 }
|
|
28
|
+
findings.sort_by do |f|
|
|
29
|
+
blocking = f.respond_to?(:state) ? (f.state == "introduced" ? 0 : 1) : 1
|
|
30
|
+
sev = severity_rank[f.severity] || 5
|
|
31
|
+
[blocking, sev, f.sort_key]
|
|
32
|
+
end.first(limit)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
|
|
8
|
+
module RailVerdict
|
|
9
|
+
module Intelligence
|
|
10
|
+
class Cache
|
|
11
|
+
MAX_TOTAL_BYTES = 10 * 1024 * 1024
|
|
12
|
+
|
|
13
|
+
def initialize(root:, enabled: false)
|
|
14
|
+
@root = root.to_s
|
|
15
|
+
@enabled = enabled
|
|
16
|
+
@dir = File.join(@root, ".railverdict", "cache", "ai")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def enabled?
|
|
20
|
+
@enabled
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def key_for(fingerprint:, context_hash:, provider:, model:, prompt_version:, schema_version:)
|
|
24
|
+
Digest::SHA256.hexdigest([fingerprint, context_hash, provider, model, prompt_version, schema_version].join("|"))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def read(key)
|
|
28
|
+
return nil unless @enabled
|
|
29
|
+
|
|
30
|
+
path = File.join(@dir, "#{key}.json")
|
|
31
|
+
return nil unless File.file?(path)
|
|
32
|
+
|
|
33
|
+
data = JSON.parse(File.read(path))
|
|
34
|
+
AIAnalysis.from_hash(data)
|
|
35
|
+
rescue StandardError
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def write(key, analysis)
|
|
40
|
+
return unless @enabled
|
|
41
|
+
|
|
42
|
+
FileUtils.mkdir_p(@dir)
|
|
43
|
+
path = File.join(@dir, "#{key}.json")
|
|
44
|
+
tmp = Tempfile.new(["ai-cache", ".json"], @dir)
|
|
45
|
+
begin
|
|
46
|
+
tmp.write(JSON.generate(analysis.to_h))
|
|
47
|
+
tmp.flush
|
|
48
|
+
tmp.fsync
|
|
49
|
+
File.chmod(0o600, tmp.path)
|
|
50
|
+
File.rename(tmp.path, path)
|
|
51
|
+
File.chmod(0o600, path) rescue nil
|
|
52
|
+
enforce_size_limit
|
|
53
|
+
ensure
|
|
54
|
+
tmp.close rescue nil
|
|
55
|
+
File.unlink(tmp.path) rescue nil
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def enforce_size_limit
|
|
62
|
+
files = Dir.glob(File.join(@dir, "*.json")).map { |p| [p, File.size(p)] }.sort_by { |_, s| s }
|
|
63
|
+
total = files.sum { |_, s| s }
|
|
64
|
+
files.each do |path, _|
|
|
65
|
+
break if total <= MAX_TOTAL_BYTES
|
|
66
|
+
|
|
67
|
+
size = File.size(path) rescue 0
|
|
68
|
+
File.unlink(path) rescue nil
|
|
69
|
+
total -= size
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailVerdict
|
|
4
|
+
module Intelligence
|
|
5
|
+
module ContextBuilder
|
|
6
|
+
MAX_FILES = 3
|
|
7
|
+
MAX_CONTEXT_BYTES = 64 * 1024
|
|
8
|
+
|
|
9
|
+
def self.build(outcome:, finding_ref:, repository_root: nil)
|
|
10
|
+
root = repository_root || outcome.context&.repository_root || Dir.pwd
|
|
11
|
+
findings = outcome.findings || []
|
|
12
|
+
target = findings.find { |f| f.id == finding_ref || f.fingerprint == finding_ref }
|
|
13
|
+
raise ArgumentError, "finding not found: #{finding_ref}" unless target
|
|
14
|
+
|
|
15
|
+
location = target.location || {}
|
|
16
|
+
path = location["path"] || location[:path]
|
|
17
|
+
line = location["start_line"] || location[:start_line]
|
|
18
|
+
|
|
19
|
+
snippets = []
|
|
20
|
+
if path
|
|
21
|
+
snippet = SourceReader.read_snippet(repository_root: root, path: path, target_line: line)
|
|
22
|
+
snippets << snippet if snippet
|
|
23
|
+
end
|
|
24
|
+
snippets = snippets.first(MAX_FILES)
|
|
25
|
+
|
|
26
|
+
rails_facts = extract_rails_facts(outcome, path)
|
|
27
|
+
git_slice = extract_git_slice(outcome)
|
|
28
|
+
comparison_slice = extract_comparison(outcome)
|
|
29
|
+
policy_reason = outcome.result&.decision_reasons&.first&.fetch("message", nil) rescue nil
|
|
30
|
+
|
|
31
|
+
manifest = Manifest.new(
|
|
32
|
+
finding_id: target.id,
|
|
33
|
+
fingerprint: target.fingerprint,
|
|
34
|
+
finding: target.to_schema_h,
|
|
35
|
+
snippets: snippets.map { |s| { "path" => s[:path], "content" => sanitize(s[:content]), "truncated" => s[:truncated] } },
|
|
36
|
+
rails_facts: rails_facts,
|
|
37
|
+
git_slice: git_slice,
|
|
38
|
+
comparison_slice: comparison_slice,
|
|
39
|
+
policy_reason: policy_reason
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if manifest.total_bytes > MAX_CONTEXT_BYTES
|
|
43
|
+
trimmed = Manifest.new(
|
|
44
|
+
finding_id: manifest.finding_id,
|
|
45
|
+
fingerprint: manifest.fingerprint,
|
|
46
|
+
finding: manifest.finding,
|
|
47
|
+
snippets: manifest.snippets.first(1).map { |s| s.merge("content" => s["content"].byteslice(0, 16 * 1024).to_s) },
|
|
48
|
+
rails_facts: rails_facts.is_a?(Array) ? rails_facts.first(1) : rails_facts,
|
|
49
|
+
git_slice: git_slice,
|
|
50
|
+
comparison_slice: comparison_slice,
|
|
51
|
+
policy_reason: policy_reason
|
|
52
|
+
)
|
|
53
|
+
return trimmed
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
manifest
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.extract_rails_facts(outcome, path)
|
|
60
|
+
rc = outcome.result&.instance_variable_get(:@rails_context) rescue nil
|
|
61
|
+
rc ||= outcome.result&.to_schema_h&.fetch("rails_context", nil) rescue nil
|
|
62
|
+
return [] unless rc
|
|
63
|
+
|
|
64
|
+
entries = rc["entries"] || rc[:entries] || []
|
|
65
|
+
return entries unless path
|
|
66
|
+
|
|
67
|
+
entries.select { |e| (e["source_path"] || e[:source_path]) == path }.first(1)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.extract_git_slice(outcome)
|
|
71
|
+
git = outcome.result&.instance_variable_get(:@git) rescue nil
|
|
72
|
+
return {} unless git
|
|
73
|
+
|
|
74
|
+
{ "head" => git["head"], "base" => git["base"], "merge_base" => git["merge_base"] }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.extract_comparison(outcome)
|
|
78
|
+
comp = outcome.result&.instance_variable_get(:@comparison) rescue nil
|
|
79
|
+
return {} unless comp
|
|
80
|
+
|
|
81
|
+
{ "counts" => comp["counts"] }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.sanitize(text)
|
|
85
|
+
t = text.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")
|
|
86
|
+
t = t.gsub(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/, "?")
|
|
87
|
+
t
|
|
88
|
+
end
|
|
89
|
+
private_class_method :sanitize
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module RailVerdict
|
|
7
|
+
module Intelligence
|
|
8
|
+
class Manifest
|
|
9
|
+
attr_reader :finding_id, :fingerprint, :finding, :snippets, :rails_facts,
|
|
10
|
+
:git_slice, :comparison_slice, :policy_reason
|
|
11
|
+
|
|
12
|
+
def initialize(finding_id:, fingerprint:, finding:, snippets:, rails_facts:, git_slice:, comparison_slice:, policy_reason:)
|
|
13
|
+
@finding_id = finding_id.dup.freeze
|
|
14
|
+
@fingerprint = fingerprint.dup.freeze
|
|
15
|
+
@finding = deep_freeze(finding.dup)
|
|
16
|
+
@snippets = deep_freeze(snippets.map(&:dup))
|
|
17
|
+
@rails_facts = deep_freeze(rails_facts)
|
|
18
|
+
@git_slice = deep_freeze(git_slice)
|
|
19
|
+
@comparison_slice = deep_freeze(comparison_slice)
|
|
20
|
+
@policy_reason = policy_reason&.dup&.freeze
|
|
21
|
+
freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_h
|
|
25
|
+
{
|
|
26
|
+
finding_id: finding_id,
|
|
27
|
+
fingerprint: fingerprint,
|
|
28
|
+
finding: finding,
|
|
29
|
+
snippets: snippets,
|
|
30
|
+
rails_facts: rails_facts,
|
|
31
|
+
git_slice: git_slice,
|
|
32
|
+
comparison_slice: comparison_slice,
|
|
33
|
+
policy_reason: policy_reason
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def to_json_hash
|
|
38
|
+
{
|
|
39
|
+
"finding_id" => finding_id,
|
|
40
|
+
"fingerprint" => fingerprint,
|
|
41
|
+
"finding" => finding,
|
|
42
|
+
"snippets" => snippets,
|
|
43
|
+
"rails_facts" => rails_facts,
|
|
44
|
+
"git_slice" => git_slice,
|
|
45
|
+
"comparison_slice" => comparison_slice,
|
|
46
|
+
"policy_reason" => policy_reason
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def context_hash
|
|
51
|
+
Digest::SHA256.hexdigest(JSON.generate(to_json_hash.sort.to_h))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def total_bytes
|
|
55
|
+
JSON.generate(to_json_hash).bytesize
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def deep_freeze(value)
|
|
61
|
+
case value
|
|
62
|
+
when Hash then value.each { |k, v| k.freeze if k.is_a?(String); deep_freeze(v) }; value.freeze
|
|
63
|
+
when Array then value.each { |v| deep_freeze(v) }; value.freeze
|
|
64
|
+
when String then value.freeze
|
|
65
|
+
else value
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|