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,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