rail_verdict 1.0.1 → 1.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 62a7032233c6c05db93caede4d2fad10181a04aff006780106e92b474fe18195
4
- data.tar.gz: 5a87a7f930f2bf519561404be6fbcec92c41c2596216beaf06ab0aa7a84fd01c
3
+ metadata.gz: 808405ac6448a462283966840c5b9074afff5cd43f0c2c3b477d9951e7dfe0a8
4
+ data.tar.gz: 2f3aa97c547c6db980c284ca4081bc09230d4e13f63369cced690f60d18ccddd
5
5
  SHA512:
6
- metadata.gz: cbac4c960c4c1392d957ba96dc63776b7406a6da4f7f596735ea3635294dab6f99e94a5a99f022437bff51396354f8ba59ff4cab9f9abc7a5ce52ca0e60a4cf2
7
- data.tar.gz: f83c03803134e56beb991545bb2dd1ddc8f43bb5beb821c2540161af623c34005389ccd04d4589944b738996442f30ab82be237a1ba9d832318760ca99d8a139
6
+ metadata.gz: a70e6b7d070e6d4990b0b7991af2332c572e0ad2c9438b0ada3afb7e095729285b5b5714c8be8be512e1c7d8907e089bec08d5e4b62524a401719d215bc2eb28
7
+ data.tar.gz: 6881881853b9073e8add4c6946d1bdb0da79928a57b6d27a057e9b1646dcbe9e960a9f646c9fb9f62743f5ba3c0d61f93fc2a1fa572b529e8071268f97b69f0b
data/README.md CHANGED
@@ -182,6 +182,9 @@ railverdict check
182
182
 
183
183
  # 5. Inspect normalized findings
184
184
  railverdict findings
185
+
186
+ # 6. Summarize one pull-request change from a single verification run
187
+ railverdict pr --base origin/main
185
188
  ```
186
189
 
187
190
  ### Example: A Passing Gate
@@ -277,6 +280,53 @@ railverdict check --changed --base HEAD~1
277
280
  railverdict check --changed --base origin/main --format json
278
281
  ```
279
282
 
283
+ ### PR Intelligence
284
+
285
+ `railverdict pr --base origin/main` produces a deterministic, versioned summary
286
+ of the change, quality delta, objective Rails path signals, analyzer evidence,
287
+ test metrics, and coverage available from that same verification run. Use
288
+ `--format json` for machine consumers. Signals are deterministic attention
289
+ indicators, not risk probabilities; `GateResult` and policy remain the only
290
+ verification authority.
291
+
292
+ Example console output:
293
+
294
+ ```
295
+ RailVerdict PR Intelligence
296
+
297
+ Gate: PASS
298
+ Completion: complete
299
+ Revision: 3f4a2c1d9e00 (base 8a7b6c5d4e33)
300
+
301
+ Change
302
+ 4 files
303
+ +38 / -7
304
+ added 1 modified 2 deleted 0 renamed 1
305
+
306
+ Quality Delta
307
+ introduced 0 resolved 2 existing 8
308
+
309
+ Signals
310
+ Database YES
311
+ Authorization YES
312
+ Routes NO
313
+ Dependencies NO
314
+ Configuration NO
315
+ Tests YES
316
+
317
+ Evidence
318
+ rubocop: succeeded
319
+ rspec: succeeded
320
+ ```
321
+
322
+ The JSON contract is [`pr-intelligence-v1.schema.json`](schemas/pr-intelligence-v1.schema.json).
323
+ It includes `head`, `base`, `merge_base`, and configuration-digest provenance,
324
+ plus a stable projection of the canonical `gate_result` without checkout-local
325
+ paths. Without a compatible baseline, `quality_delta.available` is
326
+ `false` with `reason: "baseline_not_available"`; it does not emit fake zeroes.
327
+ Invalid Git bases and incomplete required evidence remain `INCOMPLETE` with
328
+ exit code `2`.
329
+
280
330
  ### Key Capabilities
281
331
 
282
332
  - **Merge-Base Resolution:** Computes the exact `merge-base` between `HEAD` and the target branch;
@@ -20,6 +20,7 @@ module RailVerdict
20
20
  init Write the default .railverdict.yml configuration
21
21
  doctor Report configuration and analyzer observations
22
22
  check Run verification and print the gate result
23
+ pr Summarize one changed-scope verification for review
23
24
  baseline create Deferred boundary; Phase 3 owns baseline writes
24
25
  findings Print normalized findings from the evidence run
25
26
  explain Explain a finding with optional AI
@@ -53,6 +54,8 @@ module RailVerdict
53
54
  command_doctor(argv.drop(1))
54
55
  when "check"
55
56
  command_check(argv.drop(1))
57
+ when "pr"
58
+ command_pr(argv.drop(1))
56
59
  when "baseline"
57
60
  command_baseline(argv.drop(1))
58
61
  when "findings"
@@ -144,6 +147,38 @@ module RailVerdict
144
147
  exit_code_for(outcome.result, interrupted: interrupted)
145
148
  end
146
149
 
150
+ def command_pr(argv)
151
+ options = { config: DEFAULT_CONFIG_PATH, format: "console", base: nil, baseline: nil, waiver: nil }
152
+ parser = OptionParser.new do |opts|
153
+ opts.banner = "Usage: railverdict pr [--config PATH] [--format console|json] [--base REV] [--baseline PATH] [--waiver PATH]"
154
+ opts.on("--config PATH", String) { |value| options[:config] = value }
155
+ opts.on("--format FORMAT", String) { |value| options[:format] = value }
156
+ opts.on("--base REV", String) { |value| options[:base] = value }
157
+ opts.on("--baseline PATH", String) { |value| options[:baseline] = value }
158
+ opts.on("--waiver PATH", String) { |value| options[:waiver] = value }
159
+ end
160
+ parse!(parser, argv)
161
+ unless %w[console json].include?(options[:format])
162
+ raise RailVerdict::UsageError, "invalid --format #{options[:format].inspect}; expected console or json"
163
+ end
164
+
165
+ outcome, interrupted = execute_check(options.merge(changed: true))
166
+ document = PRIntelligence.document(outcome)
167
+ if options[:format] == "json"
168
+ @stdout.write(JSON.generate(document) + "\n")
169
+ else
170
+ @stdout.write(Reporters::PRIntelligence.render(document))
171
+ end
172
+ exit_code_for(outcome.result, interrupted: interrupted)
173
+ rescue RailVerdict::UsageError => error
174
+ @stderr.puts "railverdict pr: #{error.message}"
175
+ @stderr.puts USAGE
176
+ EXIT_NO_GATE
177
+ rescue RailVerdict::Error => error
178
+ @stderr.puts "railverdict pr: #{error.message}"
179
+ EXIT_NO_GATE
180
+ end
181
+
147
182
  def command_baseline(argv)
148
183
  subcommand = argv.first
149
184
  raise RailVerdict::UsageError, "unknown baseline subcommand: #{subcommand.inspect}; only `baseline create` exists" unless subcommand == "create"
@@ -6,21 +6,32 @@ require "pathname"
6
6
  module RailVerdict
7
7
  module Git
8
8
  class ChangedFile
9
- attr_reader :status, :path, :old_path, :new_path, :score
9
+ attr_reader :status, :path, :old_path, :new_path, :score, :lines_added, :lines_removed
10
10
  attr_accessor :binary
11
11
 
12
- def initialize(status:, path:, old_path:, new_path:, score:, binary: false)
12
+ def initialize(status:, path:, old_path:, new_path:, score:, binary: false, lines_added: nil, lines_removed: nil)
13
13
  @status = status
14
14
  @path = path
15
15
  @old_path = old_path
16
16
  @new_path = new_path
17
17
  @score = score
18
18
  @binary = binary
19
+ @lines_added = lines_added
20
+ @lines_removed = lines_removed
19
21
  freeze
20
22
  end
21
23
 
22
24
  def to_h
23
- { "status" => status.to_s, "path" => path, "old_path" => old_path, "new_path" => new_path, "score" => score, "binary" => binary }
25
+ {
26
+ "status" => status.to_s,
27
+ "path" => path,
28
+ "old_path" => old_path,
29
+ "new_path" => new_path,
30
+ "score" => score,
31
+ "binary" => binary,
32
+ "lines_added" => lines_added,
33
+ "lines_removed" => lines_removed
34
+ }
24
35
  end
25
36
  end
26
37
 
@@ -236,11 +247,19 @@ module RailVerdict
236
247
  info = numstat[key] if key
237
248
  is_binary = !!(info && info[:binary])
238
249
  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)
250
+ ChangedFile.new(
251
+ status: file.status,
252
+ path: file.path,
253
+ old_path: file.old_path,
254
+ new_path: file.new_path,
255
+ score: file.score,
256
+ binary: is_binary,
257
+ lines_added: info && info[:added],
258
+ lines_removed: info && info[:deleted]
259
+ )
240
260
  end
241
261
 
242
- changed = enriched.reject { |file| file.status == :deleted }.sort_by { |file| file.path.to_s }
243
- [changed, binary_set]
262
+ [enriched.sort_by { |file| (file.path || file.old_path).to_s }, binary_set]
244
263
  end
245
264
  private_class_method :resolve_changed_files
246
265
 
@@ -62,7 +62,7 @@ module RailVerdict
62
62
  parts.each do |entry|
63
63
  next if entry.empty?
64
64
 
65
- fields = entry.split("\t")
65
+ fields = entry.split("\t", 3)
66
66
  next unless fields.length == 3
67
67
 
68
68
  added, deleted, path = fields
@@ -78,15 +78,27 @@ module RailVerdict
78
78
  parts = raw.split("\0")
79
79
  parts.pop if parts.last == ""
80
80
  result = {}
81
- parts.each do |entry|
81
+ index = 0
82
+ while index < parts.length
83
+ entry = parts[index]
84
+ index += 1
82
85
  next if entry.empty?
83
86
 
84
- fields = entry.split("\t")
85
- next unless fields.length >= 3
87
+ fields = entry.split("\t", 3)
88
+ next unless fields.length >= 2
86
89
 
87
90
  added, deleted = fields[0], fields[1]
88
91
  binary = added == "-" || deleted == "-"
89
- path = fields.length == 4 ? fields[3] : fields[2]
92
+ if fields.length >= 3 && !fields[2].empty?
93
+ path = fields[2]
94
+ else
95
+ old_path = parts[index]
96
+ new_path = parts[index + 1]
97
+ break if old_path.nil? || new_path.nil?
98
+
99
+ path = new_path
100
+ index += 2
101
+ end
90
102
  key = normalize_path(path)
91
103
  result[key] = { added: binary ? nil : added.to_i, deleted: binary ? nil : deleted.to_i, binary: binary }
92
104
  end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RailVerdict
6
+ module PRIntelligence
7
+ SCHEMA_VERSION = "1.0"
8
+ MAX_SIGNAL_EVIDENCE = 20
9
+ SIGNALS = {
10
+ "database_change" => lambda { |path| path == "db/schema.rb" || path == "db/structure.sql" || path.start_with?("db/migrate/") },
11
+ "routes_change" => lambda { |path| path == "config/routes.rb" || path.start_with?("config/routes/") },
12
+ "dependency_change" => lambda { |path| %w[Gemfile Gemfile.lock].include?(path) },
13
+ "configuration_change" => lambda { |path| path == "config" || path.start_with?("config/") },
14
+ "authorization_change" => lambda { |path| path.start_with?("app/policies/") },
15
+ "tests_change" => lambda { |path| path.start_with?("spec/") || path.start_with?("test/") }
16
+ }.freeze
17
+ STATUS_KEYS = %w[added modified deleted renamed].freeze
18
+ TEST_KEYS = %w[tests_total assertions failures errors skips duration_seconds seed runner].freeze
19
+ GATE_RESULT_KEYS = %w[
20
+ schema_version completion_status gate policy_status findings
21
+ operational_failures decision_reasons
22
+ ].freeze
23
+
24
+ module_function
25
+
26
+ def document(outcome)
27
+ result = outcome.result
28
+ git_context = outcome.context&.git_context
29
+ git = result.git || {}
30
+
31
+ document = {
32
+ "schema_version" => SCHEMA_VERSION,
33
+ "provenance" => provenance(outcome, git_context, git),
34
+ "gate_result" => gate_result(result),
35
+ "change" => change(git_context),
36
+ "signals" => signals(git_context),
37
+ "quality_delta" => quality_delta(result),
38
+ "analyzer_evidence" => analyzer_evidence(result),
39
+ "test_intelligence" => test_intelligence(result),
40
+ "coverage" => coverage(result)
41
+ }
42
+ errors = SchemaValidator.validate_pr_intelligence(document)
43
+ raise RailVerdict::Error, "pr-intelligence-v1 validation failed: #{errors.join('; ')}" unless errors.empty?
44
+
45
+ document
46
+ end
47
+
48
+ def render_json(outcome)
49
+ JSON.generate(document(outcome)) + "\n"
50
+ end
51
+
52
+ def provenance(outcome, git_context, git)
53
+ context = outcome.context
54
+ {
55
+ "head" => git_context&.head || git["head"],
56
+ "base" => git_context&.base || git["base"],
57
+ "merge_base" => git_context&.merge_base || git["merge_base"],
58
+ "configuration_digest" => context&.configuration_digest
59
+ }
60
+ end
61
+ private_class_method :provenance
62
+
63
+ def gate_result(result)
64
+ source = result.to_schema_h
65
+ GATE_RESULT_KEYS.to_h { |key| [key, source.fetch(key)] }
66
+ end
67
+ private_class_method :gate_result
68
+
69
+ def change(git_context)
70
+ return { "available" => false, "reason" => "git_scope_unavailable" } unless git_context
71
+
72
+ files = git_context.changed_files
73
+ lines_added = line_total(files, :lines_added)
74
+ lines_removed = line_total(files, :lines_removed)
75
+ status_counts = STATUS_KEYS.to_h { |status| [status, files.count { |file| file.status.to_s == status }] }
76
+ {
77
+ "available" => true,
78
+ "files_changed" => files.length,
79
+ "lines_added" => lines_added,
80
+ "lines_removed" => lines_removed,
81
+ "status_counts" => status_counts
82
+ }
83
+ end
84
+ private_class_method :change
85
+
86
+ def line_total(files, attribute)
87
+ values = files.map { |file| file.public_send(attribute) }
88
+ return 0 if values.empty?
89
+ return nil unless values.all? { |value| value.is_a?(Integer) && value >= 0 }
90
+
91
+ values.sum
92
+ end
93
+ private_class_method :line_total
94
+
95
+ def signals(git_context)
96
+ paths = if git_context
97
+ git_context.changed_files.flat_map { |file| [file.path, file.old_path, file.new_path] }.compact.uniq.sort
98
+ else
99
+ []
100
+ end
101
+ available = !git_context.nil?
102
+
103
+ SIGNALS.to_h do |name, matcher|
104
+ evidence = available ? paths.select { |path| matcher.call(path) } : []
105
+ evidence_head = evidence.first(MAX_SIGNAL_EVIDENCE)
106
+ entry = {
107
+ "available" => available,
108
+ "present" => !evidence.empty?,
109
+ "evidence" => evidence_head,
110
+ "additional_evidence_count" => [evidence.length - evidence_head.length, 0].max
111
+ }
112
+ entry["reason"] = "git_scope_unavailable" unless available
113
+ [name, entry]
114
+ end
115
+ end
116
+ private_class_method :signals
117
+
118
+ def quality_delta(result)
119
+ comparison = result.comparison
120
+ baseline = result.baseline
121
+ unless baseline && baseline["loaded"] == true && comparison.is_a?(Hash)
122
+ return { "available" => false, "reason" => baseline && baseline["compatible"] == false ? "baseline_incompatible" : "baseline_not_available" }
123
+ end
124
+
125
+ counts = comparison.fetch("counts", {})
126
+ {
127
+ "available" => true,
128
+ "introduced" => counts.fetch("introduced", 0),
129
+ "existing" => counts.fetch("existing", 0),
130
+ "resolved" => counts.fetch("resolved", 0),
131
+ "changed" => counts.fetch("changed", 0),
132
+ "moved" => counts.fetch("moved", 0),
133
+ "waived" => counts.fetch("waived", 0),
134
+ "orphaned_waivers" => Array(comparison["orphaned_waivers"]).length
135
+ }
136
+ end
137
+ private_class_method :quality_delta
138
+
139
+ def analyzer_evidence(result)
140
+ result.analyzer_results.sort_by(&:analyzer).map do |analyzer|
141
+ entry = {
142
+ "analyzer" => analyzer.analyzer,
143
+ "execution_status" => analyzer.execution_status,
144
+ "evidence_status" => analyzer.evidence_status
145
+ }
146
+ entry["tool_version"] = analyzer.tool_version if analyzer.tool_version
147
+ entry
148
+ end
149
+ end
150
+ private_class_method :analyzer_evidence
151
+
152
+ def test_intelligence(result)
153
+ analyzers = {}
154
+ result.analyzer_results.sort_by(&:analyzer).each do |analyzer|
155
+ next unless %w[minitest rspec].include?(analyzer.analyzer)
156
+ next unless analyzer.execution_status == "succeeded" && analyzer.evidence_summary.is_a?(Hash)
157
+
158
+ summary = analyzer.evidence_summary
159
+ analyzers[analyzer.analyzer] = TEST_KEYS.each_with_object({}) do |key, values|
160
+ values[key] = summary[key] if summary.key?(key)
161
+ end
162
+ end
163
+ return { "available" => false, "reason" => "test_metrics_unavailable" } if analyzers.empty?
164
+
165
+ { "available" => true, "analyzers" => analyzers }
166
+ end
167
+ private_class_method :test_intelligence
168
+
169
+ def coverage(result)
170
+ analyzer = result.analyzer_results.find { |item| item.analyzer == "simplecov" }
171
+ summary = analyzer&.evidence_summary
172
+ unless analyzer&.execution_status == "succeeded" && summary.is_a?(Hash)
173
+ return { "available" => false, "reason" => "coverage_evidence_unavailable" }
174
+ end
175
+
176
+ output = { "available" => true }
177
+ output["global_percent"] = summary["percent"] if summary["percent"].is_a?(Numeric)
178
+ changed = summary["changed_line_coverage"]
179
+ if changed.is_a?(Hash) && changed["percent"].is_a?(Numeric)
180
+ output["changed_lines_percent"] = changed["percent"]
181
+ output["changed_lines_covered"] = changed["covered_lines"] if changed["covered_lines"].is_a?(Integer)
182
+ output["changed_lines_executable"] = changed["executable_lines"] if changed["executable_lines"].is_a?(Integer)
183
+ end
184
+ output
185
+ end
186
+ private_class_method :coverage
187
+ end
188
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailVerdict
4
+ module Reporters
5
+ module PRIntelligence
6
+ SIGNAL_LABELS = {
7
+ "database_change" => "Database",
8
+ "authorization_change" => "Authorization",
9
+ "routes_change" => "Routes",
10
+ "dependency_change" => "Dependencies",
11
+ "configuration_change" => "Configuration",
12
+ "tests_change" => "Tests"
13
+ }.freeze
14
+
15
+ module_function
16
+
17
+ def render(document)
18
+ gate_result = document.fetch("gate_result")
19
+ lines = ["RailVerdict PR Intelligence", "", "Gate: #{gate_result.fetch('gate')}", "Completion: #{gate_result.fetch('completion_status')}"]
20
+ provenance = document.fetch("provenance")
21
+ lines << "Revision: #{short(provenance['head'])} (base #{short(provenance['base'])})"
22
+
23
+ change = document.fetch("change")
24
+ lines << ""
25
+ lines << "Change"
26
+ if change["available"]
27
+ lines << " #{change.fetch('files_changed')} files"
28
+ lines << " +#{change['lines_added'] || '?'} / -#{change['lines_removed'] || '?'}"
29
+ counts = change.fetch("status_counts")
30
+ lines << " added #{counts['added']} modified #{counts['modified']} deleted #{counts['deleted']} renamed #{counts['renamed']}"
31
+ else
32
+ lines << " unavailable (#{change.fetch('reason')})"
33
+ end
34
+
35
+ delta = document.fetch("quality_delta")
36
+ lines << ""
37
+ lines << "Quality Delta"
38
+ if delta["available"]
39
+ lines << " introduced #{delta['introduced']} resolved #{delta['resolved']} existing #{delta['existing']}"
40
+ lines << " changed #{delta['changed']} moved #{delta['moved']} waived #{delta['waived']} orphaned waivers #{delta['orphaned_waivers']}"
41
+ else
42
+ lines << " unavailable (#{delta.fetch('reason')})"
43
+ end
44
+
45
+ lines << ""
46
+ lines << "Signals"
47
+ document.fetch("signals").each do |name, signal|
48
+ label = SIGNAL_LABELS.fetch(name, name)
49
+ value = signal["available"] ? (signal["present"] ? "YES" : "NO") : "N/A"
50
+ lines << format(" %-14s %s", label, value)
51
+ end
52
+
53
+ lines << ""
54
+ lines << "Evidence"
55
+ evidence = document.fetch("analyzer_evidence")
56
+ if evidence.empty?
57
+ lines << " none"
58
+ else
59
+ evidence.each { |entry| lines << " #{entry.fetch('analyzer')}: #{entry.fetch('execution_status')}" }
60
+ end
61
+
62
+ tests = document.fetch("test_intelligence")
63
+ lines << ""
64
+ lines << "Tests: #{tests['available'] ? tests.fetch('analyzers').map { |name, summary| "#{name} #{summary['tests_total']} total, #{summary['failures']} failures" }.join('; ') : tests.fetch('reason')}"
65
+ coverage = document.fetch("coverage")
66
+ coverage_text = if coverage["available"]
67
+ parts = []
68
+ parts << "global #{coverage['global_percent']}%" if coverage.key?("global_percent")
69
+ parts << "changed #{coverage['changed_lines_percent']}%" if coverage.key?("changed_lines_percent")
70
+ parts.join(", ")
71
+ else
72
+ coverage.fetch("reason")
73
+ end
74
+ lines << "Coverage: #{coverage_text}"
75
+ lines.join("\n") + "\n"
76
+ end
77
+
78
+ def short(value)
79
+ value ? value.to_s[0, 12] : "unknown"
80
+ end
81
+ private_class_method :short
82
+ end
83
+ end
84
+ end
@@ -18,6 +18,7 @@ module RailVerdict
18
18
  WAIVER_SCHEMA = "waiver-v1.schema.json"
19
19
  WAIVERS_SCHEMA = "waivers-v1.schema.json"
20
20
  REPAIR_PACKET_SCHEMA = "repair-packet-v1.schema.json"
21
+ PR_INTELLIGENCE_SCHEMA = "pr-intelligence-v1.schema.json"
21
22
 
22
23
  def self.schema_dir
23
24
  File.expand_path("../../schemas", __dir__)
@@ -68,6 +69,10 @@ module RailVerdict
68
69
  validate(data, REPAIR_PACKET_SCHEMA)
69
70
  end
70
71
 
72
+ def self.validate_pr_intelligence(data)
73
+ validate(data, PR_INTELLIGENCE_SCHEMA)
74
+ end
75
+
71
76
  def self.validate(data, schema_name)
72
77
  schema = load_schema(schema_name)
73
78
  JSONSchemer.schema(schema).validate(data).map { |error| format_error(error) }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailVerdict
4
- VERSION = "1.0.1"
4
+ VERSION = "1.1.0"
5
5
  end
data/lib/rail_verdict.rb CHANGED
@@ -36,6 +36,8 @@ require_relative "rail_verdict/reporters/console"
36
36
  require_relative "rail_verdict/reporters/json_reporter"
37
37
  require_relative "rail_verdict/reporters/sarif"
38
38
  require_relative "rail_verdict/reporters/github_annotations"
39
+ require_relative "rail_verdict/reporters/pr_intelligence"
40
+ require_relative "rail_verdict/pr_intelligence"
39
41
  require_relative "rail_verdict/cli"
40
42
  require_relative "rail_verdict/intelligence"
41
43
  require_relative "rail_verdict/intelligence/source_reader"
@@ -0,0 +1,285 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://railverdict.dev/schemas/pr-intelligence/v1.schema.json",
4
+ "title": "RailVerdict PR Intelligence v1",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": [
8
+ "schema_version",
9
+ "provenance",
10
+ "gate_result",
11
+ "change",
12
+ "signals",
13
+ "quality_delta",
14
+ "analyzer_evidence",
15
+ "test_intelligence",
16
+ "coverage"
17
+ ],
18
+ "properties": {
19
+ "schema_version": { "const": "1.0" },
20
+ "provenance": { "$ref": "#/$defs/provenance" },
21
+ "gate_result": { "$ref": "#/$defs/gate_result" },
22
+ "change": { "$ref": "#/$defs/change" },
23
+ "signals": {
24
+ "type": "object",
25
+ "additionalProperties": false,
26
+ "required": [
27
+ "database_change",
28
+ "routes_change",
29
+ "dependency_change",
30
+ "configuration_change",
31
+ "authorization_change",
32
+ "tests_change"
33
+ ],
34
+ "properties": {
35
+ "database_change": { "$ref": "#/$defs/signal" },
36
+ "routes_change": { "$ref": "#/$defs/signal" },
37
+ "dependency_change": { "$ref": "#/$defs/signal" },
38
+ "configuration_change": { "$ref": "#/$defs/signal" },
39
+ "authorization_change": { "$ref": "#/$defs/signal" },
40
+ "tests_change": { "$ref": "#/$defs/signal" }
41
+ }
42
+ },
43
+ "quality_delta": { "$ref": "#/$defs/quality_delta" },
44
+ "analyzer_evidence": {
45
+ "type": "array",
46
+ "items": { "$ref": "#/$defs/analyzer_evidence" }
47
+ },
48
+ "test_intelligence": { "$ref": "#/$defs/test_intelligence" },
49
+ "coverage": { "$ref": "#/$defs/coverage" }
50
+ },
51
+ "$defs": {
52
+ "nullable_string": { "type": ["string", "null"] },
53
+ "provenance": {
54
+ "type": "object",
55
+ "additionalProperties": false,
56
+ "required": ["head", "base", "merge_base", "configuration_digest"],
57
+ "properties": {
58
+ "head": { "$ref": "#/$defs/nullable_string" },
59
+ "base": { "$ref": "#/$defs/nullable_string" },
60
+ "merge_base": { "$ref": "#/$defs/nullable_string" },
61
+ "configuration_digest": { "$ref": "#/$defs/nullable_string" }
62
+ }
63
+ },
64
+ "change": {
65
+ "type": "object",
66
+ "additionalProperties": false,
67
+ "required": ["available"],
68
+ "properties": {
69
+ "available": { "type": "boolean" },
70
+ "reason": { "type": "string" },
71
+ "files_changed": { "type": "integer", "minimum": 0 },
72
+ "lines_added": { "type": ["integer", "null"], "minimum": 0 },
73
+ "lines_removed": { "type": ["integer", "null"], "minimum": 0 },
74
+ "status_counts": {
75
+ "type": "object",
76
+ "additionalProperties": false,
77
+ "required": ["added", "modified", "deleted", "renamed"],
78
+ "properties": {
79
+ "added": { "type": "integer", "minimum": 0 },
80
+ "modified": { "type": "integer", "minimum": 0 },
81
+ "deleted": { "type": "integer", "minimum": 0 },
82
+ "renamed": { "type": "integer", "minimum": 0 }
83
+ }
84
+ }
85
+ },
86
+ "allOf": [
87
+ {
88
+ "if": { "properties": { "available": { "const": true } } },
89
+ "then": { "required": ["files_changed", "lines_added", "lines_removed", "status_counts"] }
90
+ },
91
+ {
92
+ "if": { "properties": { "available": { "const": false } } },
93
+ "then": { "required": ["reason"] }
94
+ }
95
+ ]
96
+ },
97
+ "signal": {
98
+ "type": "object",
99
+ "additionalProperties": false,
100
+ "required": ["available", "present", "evidence", "additional_evidence_count"],
101
+ "properties": {
102
+ "available": { "type": "boolean" },
103
+ "present": { "type": "boolean" },
104
+ "evidence": {
105
+ "type": "array",
106
+ "maxItems": 20,
107
+ "items": { "type": "string", "minLength": 1 }
108
+ },
109
+ "additional_evidence_count": { "type": "integer", "minimum": 0 },
110
+ "reason": { "type": "string" }
111
+ }
112
+ },
113
+ "quality_delta": {
114
+ "type": "object",
115
+ "additionalProperties": false,
116
+ "required": ["available"],
117
+ "properties": {
118
+ "available": { "type": "boolean" },
119
+ "reason": { "type": "string" },
120
+ "introduced": { "type": "integer", "minimum": 0 },
121
+ "existing": { "type": "integer", "minimum": 0 },
122
+ "resolved": { "type": "integer", "minimum": 0 },
123
+ "changed": { "type": "integer", "minimum": 0 },
124
+ "moved": { "type": "integer", "minimum": 0 },
125
+ "waived": { "type": "integer", "minimum": 0 },
126
+ "orphaned_waivers": { "type": "integer", "minimum": 0 }
127
+ },
128
+ "allOf": [
129
+ {
130
+ "if": { "properties": { "available": { "const": true } } },
131
+ "then": { "required": ["introduced", "existing", "resolved", "changed", "moved", "waived", "orphaned_waivers"] }
132
+ },
133
+ {
134
+ "if": { "properties": { "available": { "const": false } } },
135
+ "then": { "required": ["reason"] }
136
+ }
137
+ ]
138
+ },
139
+ "analyzer_evidence": {
140
+ "type": "object",
141
+ "additionalProperties": false,
142
+ "required": ["analyzer", "execution_status", "evidence_status"],
143
+ "properties": {
144
+ "analyzer": { "type": "string", "minLength": 1 },
145
+ "tool_version": { "type": "string", "minLength": 1 },
146
+ "execution_status": { "type": "string", "minLength": 1 },
147
+ "evidence_status": { "enum": ["complete", "incomplete"] }
148
+ }
149
+ },
150
+ "gate_result": {
151
+ "type": "object",
152
+ "additionalProperties": false,
153
+ "required": [
154
+ "schema_version",
155
+ "completion_status",
156
+ "gate",
157
+ "policy_status",
158
+ "findings",
159
+ "operational_failures",
160
+ "decision_reasons"
161
+ ],
162
+ "properties": {
163
+ "schema_version": { "const": "1.0" },
164
+ "completion_status": { "enum": ["complete", "incomplete", "interrupted"] },
165
+ "gate": { "enum": ["PASS", "WARN", "FAIL", "INCOMPLETE"] },
166
+ "policy_status": { "enum": ["pass", "warn", "fail", "not_evaluated"] },
167
+ "findings": { "type": "array", "items": { "$ref": "#/$defs/finding_summary" } },
168
+ "operational_failures": { "type": "array", "items": { "$ref": "#/$defs/operational_failure" } },
169
+ "decision_reasons": { "type": "array", "items": { "$ref": "#/$defs/decision_reason" } }
170
+ },
171
+ "allOf": [
172
+ {
173
+ "if": { "properties": { "completion_status": { "const": "complete" } } },
174
+ "then": {
175
+ "properties": {
176
+ "gate": { "enum": ["PASS", "WARN", "FAIL"] },
177
+ "policy_status": { "enum": ["pass", "warn", "fail"] }
178
+ }
179
+ }
180
+ },
181
+ {
182
+ "if": { "properties": { "completion_status": { "enum": ["incomplete", "interrupted"] } } },
183
+ "then": {
184
+ "properties": {
185
+ "gate": { "const": "INCOMPLETE" },
186
+ "policy_status": { "const": "not_evaluated" },
187
+ "operational_failures": { "minItems": 1 }
188
+ }
189
+ }
190
+ }
191
+ ]
192
+ },
193
+ "finding_summary": {
194
+ "type": "object",
195
+ "additionalProperties": false,
196
+ "required": ["id", "fingerprint", "severity", "state", "blocking"],
197
+ "properties": {
198
+ "id": { "type": "string", "minLength": 1, "maxLength": 256 },
199
+ "fingerprint": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" },
200
+ "severity": { "enum": ["info", "low", "medium", "high", "critical"] },
201
+ "state": { "enum": ["observed", "introduced", "existing", "resolved", "changed", "moved", "suppressed", "waived"] },
202
+ "blocking": { "type": "boolean" }
203
+ }
204
+ },
205
+ "operational_failure": {
206
+ "type": "object",
207
+ "additionalProperties": false,
208
+ "required": ["code", "message"],
209
+ "properties": {
210
+ "code": { "enum": ["unavailable", "unsupported", "timed_out", "signaled", "failed", "parse_failed", "truncated", "malformed", "configuration", "interrupted", "incomplete_evidence"] },
211
+ "analyzer": { "type": "string", "minLength": 1, "maxLength": 256 },
212
+ "message": { "type": "string", "minLength": 1, "maxLength": 4096 }
213
+ }
214
+ },
215
+ "decision_reason": {
216
+ "type": "object",
217
+ "additionalProperties": false,
218
+ "required": ["code", "message"],
219
+ "properties": {
220
+ "code": { "type": "string", "minLength": 1, "maxLength": 128 },
221
+ "message": { "type": "string", "minLength": 1, "maxLength": 4096 }
222
+ }
223
+ },
224
+ "test_intelligence": {
225
+ "type": "object",
226
+ "additionalProperties": false,
227
+ "required": ["available"],
228
+ "properties": {
229
+ "available": { "type": "boolean" },
230
+ "reason": { "type": "string", "minLength": 1 },
231
+ "analyzers": {
232
+ "type": "object",
233
+ "additionalProperties": false,
234
+ "properties": {
235
+ "minitest": { "$ref": "#/$defs/test_summary" },
236
+ "rspec": { "$ref": "#/$defs/test_summary" }
237
+ }
238
+ }
239
+ },
240
+ "allOf": [
241
+ {
242
+ "if": { "properties": { "available": { "const": true } } },
243
+ "then": { "required": ["analyzers"] }
244
+ },
245
+ {
246
+ "if": { "properties": { "available": { "const": false } } },
247
+ "then": { "required": ["reason"] }
248
+ }
249
+ ]
250
+ },
251
+ "test_summary": {
252
+ "type": "object",
253
+ "additionalProperties": false,
254
+ "properties": {
255
+ "tests_total": { "type": "integer", "minimum": 0 },
256
+ "assertions": { "type": "integer", "minimum": 0 },
257
+ "failures": { "type": "integer", "minimum": 0 },
258
+ "errors": { "type": "integer", "minimum": 0 },
259
+ "skips": { "type": "integer", "minimum": 0 },
260
+ "duration_seconds": { "type": "number", "minimum": 0 },
261
+ "seed": { "type": ["integer", "null"] },
262
+ "runner": { "type": "string", "minLength": 1, "maxLength": 128 }
263
+ }
264
+ },
265
+ "coverage": {
266
+ "type": "object",
267
+ "additionalProperties": false,
268
+ "required": ["available"],
269
+ "properties": {
270
+ "available": { "type": "boolean" },
271
+ "reason": { "type": "string", "minLength": 1 },
272
+ "global_percent": { "type": "number", "minimum": 0, "maximum": 100 },
273
+ "changed_lines_percent": { "type": "number", "minimum": 0, "maximum": 100 },
274
+ "changed_lines_covered": { "type": "integer", "minimum": 0 },
275
+ "changed_lines_executable": { "type": "integer", "minimum": 0 }
276
+ },
277
+ "allOf": [
278
+ {
279
+ "if": { "properties": { "available": { "const": false } } },
280
+ "then": { "required": ["reason"] }
281
+ }
282
+ ]
283
+ }
284
+ }
285
+ }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rail_verdict
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Pedro Dalben
@@ -113,6 +113,7 @@ files:
113
113
  - lib/rail_verdict/mcp/tools/verify.rb
114
114
  - lib/rail_verdict/mcp/tools/verify_repair.rb
115
115
  - lib/rail_verdict/mcp/validators.rb
116
+ - lib/rail_verdict/pr_intelligence.rb
116
117
  - lib/rail_verdict/process_runner.rb
117
118
  - lib/rail_verdict/rails_context.rb
118
119
  - lib/rail_verdict/rails_context/classifier.rb
@@ -141,6 +142,7 @@ files:
141
142
  - lib/rail_verdict/reporters/console.rb
142
143
  - lib/rail_verdict/reporters/github_annotations.rb
143
144
  - lib/rail_verdict/reporters/json_reporter.rb
145
+ - lib/rail_verdict/reporters/pr_intelligence.rb
144
146
  - lib/rail_verdict/reporters/sarif.rb
145
147
  - lib/rail_verdict/run_context.rb
146
148
  - lib/rail_verdict/schema_validator.rb
@@ -160,6 +162,7 @@ files:
160
162
  - schemas/coverage-v1.schema.json
161
163
  - schemas/finding-v1.schema.json
162
164
  - schemas/minitest-reporter-v1.schema.json
165
+ - schemas/pr-intelligence-v1.schema.json
163
166
  - schemas/repair-packet-v1.schema.json
164
167
  - schemas/result-v1.schema.json
165
168
  - schemas/waiver-v1.schema.json