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,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module RailVerdict
6
+ class Configuration
7
+ DEFAULT_FILENAME = ".railverdict.yml"
8
+ CONFIGURATION_VERSION = 1.4
9
+ SUPPORTED_VERSIONS = [1, 1.1, 1.2, 1.3, 1.4].freeze
10
+ UTF8_BOM = "\xEF\xBB\xBF".b
11
+
12
+ attr_reader :version, :mode, :analyzers, :source_path, :digest
13
+
14
+ def self.load(path)
15
+ source_path = path.to_s
16
+ unless File.file?(source_path)
17
+ raise ConfigurationError.new(
18
+ "configuration file not found: #{source_path}",
19
+ source_path: source_path
20
+ )
21
+ end
22
+
23
+ bytes = File.binread(source_path)
24
+ text = decode_utf8(bytes, source_path)
25
+ data = load_strict_yaml(text, source_path)
26
+ validate_schema(data, source_path)
27
+ new(data, source_path, bytes)
28
+ end
29
+
30
+ def self.decode_utf8(bytes, source_path)
31
+ if bytes.start_with?(UTF8_BOM)
32
+ raise ConfigurationError.new(
33
+ "#{source_path}: configuration must be UTF-8 without a byte order mark",
34
+ source_path: source_path
35
+ )
36
+ end
37
+
38
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
39
+ unless text.valid_encoding?
40
+ raise ConfigurationError.new(
41
+ "#{source_path}: configuration must be valid UTF-8",
42
+ source_path: source_path
43
+ )
44
+ end
45
+
46
+ text
47
+ end
48
+
49
+ def self.load_strict_yaml(text, source_path)
50
+ StrictYaml.parse(text, source_path)
51
+ rescue StrictYaml::Error => error
52
+ raise ConfigurationError.new(
53
+ "#{error.message}",
54
+ source_path: source_path,
55
+ property_path: error.property_path
56
+ )
57
+ end
58
+
59
+ def self.validate_schema(data, source_path)
60
+ unless data.is_a?(Hash)
61
+ raise ConfigurationError.new(
62
+ "#{source_path}: $: configuration must be a mapping",
63
+ source_path: source_path,
64
+ property_path: "$"
65
+ )
66
+ end
67
+
68
+ errors = SchemaValidator.validate_configuration(data)
69
+ return if errors.empty?
70
+
71
+ raise ConfigurationError.new(
72
+ "invalid configuration #{source_path}: #{errors.join('; ')}",
73
+ source_path: source_path,
74
+ property_path: errors.first.to_s.split(":").first
75
+ )
76
+ end
77
+
78
+ def initialize(data, source_path, bytes)
79
+ @version = data.fetch("version")
80
+ @mode = data.fetch("mode").freeze
81
+ @analyzers = deep_freeze(data.fetch("analyzers"))
82
+ @raw_data = deep_freeze(data.dup)
83
+ @source_path = source_path.dup.freeze
84
+ @digest = Digest::SHA256.hexdigest(bytes).freeze
85
+ freeze
86
+ end
87
+
88
+ def analyzer_selection(name)
89
+ @analyzers.fetch(name)
90
+ end
91
+
92
+ def analyzer_enabled?(name)
93
+ selection = @analyzers[name]
94
+ return false if selection.nil?
95
+
96
+ selection.fetch("enabled")
97
+ end
98
+
99
+ def analyzer_required?(name)
100
+ @analyzers.fetch(name).fetch("required")
101
+ end
102
+
103
+ def analyzer_present?(name)
104
+ @analyzers.key?(name)
105
+ end
106
+
107
+ def baseline_path
108
+ @raw_data&.dig("baseline", "path")
109
+ end
110
+
111
+ def waivers_path
112
+ @raw_data&.dig("waivers", "path")
113
+ end
114
+
115
+ def git_base
116
+ @raw_data&.dig("git", "base")
117
+ end
118
+
119
+ def ai_config
120
+ @raw_data&.dig("ai")
121
+ end
122
+
123
+ def ai_enabled?
124
+ ai_config&.fetch("enabled", false) == true
125
+ end
126
+
127
+ def ai_mode
128
+ ai_config&.fetch("mode", "off")
129
+ end
130
+
131
+ def ai_remote_enabled?
132
+ ai_enabled? && ai_config&.dig("remote", "enabled") == true
133
+ end
134
+
135
+ def ai_remote_trust
136
+ ai_config&.dig("remote", "trust") || "redacted"
137
+ end
138
+
139
+ def ai_budgets
140
+ ai_config&.fetch("budgets", {}) || {}
141
+ end
142
+
143
+ def ai_cache_enabled?
144
+ ai_config&.dig("cache", "enabled") == true
145
+ end
146
+
147
+ private
148
+
149
+ def deep_freeze(value)
150
+ case value
151
+ when Hash
152
+ value.each { |key, child| deep_freeze(child); key.freeze }
153
+ value.freeze
154
+ when Array
155
+ value.each { |child| deep_freeze(child) }
156
+ value.freeze
157
+ when String
158
+ value.freeze
159
+ else
160
+ value
161
+ end
162
+ value
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailVerdict
4
+ class AnalyzerResult
5
+ EXECUTION_STATUSES = %w[
6
+ succeeded unavailable unsupported timed_out signaled
7
+ failed parse_failed truncated malformed
8
+ ].freeze
9
+ FAILURE_CODES = (EXECUTION_STATUSES - ["succeeded"]).freeze
10
+ EVIDENCE_STATUSES = %w[complete incomplete].freeze
11
+
12
+ attr_reader :analyzer, :invocation, :tool_version, :execution_status,
13
+ :evidence_status, :finding_ids, :failure, :evidence_summary
14
+
15
+ def initialize(analyzer:, invocation:, execution_status:, finding_ids:, tool_version: nil, failure: nil, evidence_summary: nil)
16
+ @analyzer = require_nonempty_string(analyzer, "analyzer", 256).freeze
17
+ @invocation = validate_invocation(invocation)
18
+ @execution_status = validate_execution_status(execution_status)
19
+ @evidence_status = execution_status == "succeeded" ? "complete" : "incomplete"
20
+ @tool_version = tool_version && require_nonempty_string(tool_version, "tool_version", 128).freeze
21
+ @finding_ids = validate_finding_ids(finding_ids)
22
+ @failure = validate_failure(failure)
23
+ @evidence_summary = validate_evidence_summary(evidence_summary)
24
+ enforce_coupling
25
+ freeze
26
+ end
27
+
28
+ def complete?
29
+ evidence_status == "complete"
30
+ end
31
+
32
+ def to_schema_h
33
+ result = {}
34
+ result["analyzer"] = analyzer
35
+ result["tool_version"] = tool_version if tool_version
36
+ result["invocation"] = invocation
37
+ result["execution_status"] = execution_status
38
+ result["evidence_status"] = evidence_status
39
+ result["finding_ids"] = finding_ids
40
+ result["failure"] = failure if failure
41
+ result["evidence_summary"] = evidence_summary if evidence_summary
42
+ result
43
+ end
44
+
45
+ private
46
+
47
+ def enforce_coupling
48
+ if execution_status == "succeeded"
49
+ raise ArgumentError, "succeeded results cannot carry failure metadata" unless failure.nil?
50
+ else
51
+ raise ArgumentError, "non-succeeded results require failure metadata" if failure.nil?
52
+ unless failure["code"] == execution_status
53
+ raise ArgumentError, "failure code must match execution status"
54
+ end
55
+ end
56
+ end
57
+
58
+ def validate_invocation(invocation)
59
+ raise ArgumentError, "invocation must be a Hash" unless invocation.is_a?(Hash)
60
+
61
+ executable = invocation["executable"]
62
+ argv = invocation["argv"]
63
+ unless executable.is_a?(String) && !executable.empty? && executable.bytesize <= 1024
64
+ raise ArgumentError, "invocation.executable must be a non-empty string"
65
+ end
66
+ unless argv.is_a?(Array) && argv.all? { |element| element.is_a?(String) && element.bytesize <= 4096 }
67
+ raise ArgumentError, "invocation.argv must be an array of strings"
68
+ end
69
+
70
+ { "executable" => executable.dup.freeze, "argv" => argv.map(&:dup).map(&:freeze).freeze }.freeze
71
+ end
72
+
73
+ def validate_execution_status(value)
74
+ unless EXECUTION_STATUSES.include?(value)
75
+ raise ArgumentError, "execution_status must be one of #{EXECUTION_STATUSES.join(', ')}"
76
+ end
77
+
78
+ value
79
+ end
80
+
81
+ def validate_finding_ids(finding_ids)
82
+ unless finding_ids.is_a?(Array) &&
83
+ finding_ids.all? { |id| id.is_a?(String) && !id.empty? && id.bytesize <= 256 }
84
+ raise ArgumentError, "finding_ids must be an array of non-empty strings"
85
+ end
86
+
87
+ finding_ids.map { |id| id.dup.freeze }.freeze
88
+ end
89
+
90
+ def validate_failure(failure)
91
+ return nil if failure.nil?
92
+
93
+ raise ArgumentError, "failure must be a Hash" unless failure.is_a?(Hash)
94
+
95
+ code = failure["code"]
96
+ message = failure["message"]
97
+ unless FAILURE_CODES.include?(code)
98
+ raise ArgumentError, "failure.code must be one of #{FAILURE_CODES.join(', ')}"
99
+ end
100
+ unless message.is_a?(String) && !message.empty? && message.bytesize <= 4096
101
+ raise ArgumentError, "failure.message must be a non-empty string"
102
+ end
103
+
104
+ { "code" => code.freeze, "message" => message.dup.freeze }.freeze
105
+ end
106
+
107
+ def validate_evidence_summary(value)
108
+ return nil if value.nil?
109
+
110
+ raise ArgumentError, "evidence_summary must be a Hash" unless value.is_a?(Hash)
111
+ raise ArgumentError, "evidence_summary is too large" if value.keys.length > 30
112
+
113
+ value.each do |key, entry|
114
+ raise ArgumentError, "evidence_summary keys must be non-empty strings" unless key.is_a?(String) && !key.empty? && key.bytesize <= 128
115
+ if entry.is_a?(Hash) || entry.is_a?(Array)
116
+ raise ArgumentError, "evidence_summary values must be primitives" unless key.start_with?("changed_") || key == "files"
117
+ else
118
+ unless entry.is_a?(Integer) || entry.is_a?(Float) || entry.is_a?(String) || entry.nil? || entry == true || entry == false
119
+ raise ArgumentError, "evidence_summary values must be primitives"
120
+ end
121
+ if entry.is_a?(String) && entry.bytesize > 4096
122
+ raise ArgumentError, "evidence_summary string values too large"
123
+ end
124
+ end
125
+ end
126
+
127
+ value.dup.freeze
128
+ end
129
+
130
+ def require_nonempty_string(value, name, max_length)
131
+ unless value.is_a?(String) && !value.empty? && value.bytesize <= max_length
132
+ raise ArgumentError, "#{name} must be a non-empty string of at most #{max_length} bytes"
133
+ end
134
+
135
+ value
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module RailVerdict
7
+ class Finding
8
+ SCHEMA_VERSION = "1.0"
9
+ FINGERPRINT_PAYLOAD_SCHEMA = "https://railverdict.dev/fingerprint-payload/v1"
10
+ LEGACY_FINGERPRINT_PAYLOAD_SCHEMA = "https://railverdict.dev/fingerprint-payload/v0.1"
11
+ FINGERPRINT_PATTERN = /\Asha256:[0-9a-f]{64}\z/
12
+ LOCATION_PATH_PATTERN = %r{
13
+ \A
14
+ (?!/)
15
+ (?!.*\\)
16
+ (?!.*//)
17
+ (?!(?:\.{1,2})(?:/|\z))
18
+ (?!.* / (?:\.{1,2}) (?:/|\z))
19
+ [^\u0000-\u001f/]+
20
+ (?:/[^\u0000-\u001f/]+)*
21
+ \z
22
+ }x
23
+
24
+ ORIGINS = %w[deterministic runtime ai custom].freeze
25
+ SEVERITIES = %w[info low medium high critical].freeze
26
+ CONFIDENCES = %w[low medium high].freeze
27
+ STATES = %w[observed introduced existing resolved changed moved suppressed waived].freeze
28
+
29
+ attr_reader :id, :fingerprint, :origin, :analyzer, :rule_id, :category, :severity,
30
+ :confidence, :state, :evidence_ref, :location, :message
31
+
32
+ def self.fingerprint_for(analyzer:, rule_id:, path:, message:)
33
+ Fingerprint.hexdigest(analyzer: analyzer, rule_id: rule_id, path: path, message: message)
34
+ end
35
+
36
+ def self.id_for(fingerprint)
37
+ hex = fingerprint.delete_prefix("sha256:")
38
+ "rv:#{hex.slice(0, 20)}"
39
+ end
40
+
41
+ def initialize(fingerprint:, origin:, analyzer:, rule_id:, category:, severity:, confidence:,
42
+ state:, evidence_ref:, location:, message:)
43
+ @fingerprint = require_match(fingerprint, FINGERPRINT_PATTERN, "fingerprint")
44
+ @origin = require_member(origin, ORIGINS, "origin")
45
+ @analyzer = require_nonempty(analyzer, "analyzer", 256)
46
+ @rule_id = require_nonempty(rule_id, "rule_id", 256)
47
+ @category = require_nonempty(category, "category", 256)
48
+ @severity = require_member(severity, SEVERITIES, "severity")
49
+ @confidence = require_member(confidence, CONFIDENCES, "confidence")
50
+ @state = require_member(state, STATES, "state")
51
+ @evidence_ref = require_nonempty(evidence_ref, "evidence_ref", 512)
52
+ @location = validate_location(location)
53
+ @message = require_nonempty(message, "message", 4096)
54
+ @id = Finding.id_for(@fingerprint).freeze
55
+ @location.freeze
56
+ @analyzer.freeze
57
+ @rule_id.freeze
58
+ @category.freeze
59
+ @evidence_ref.freeze
60
+ @message.freeze
61
+ freeze
62
+ end
63
+
64
+ def schema_version
65
+ SCHEMA_VERSION
66
+ end
67
+
68
+ def sort_key
69
+ [
70
+ location.fetch("path").to_s,
71
+ location["start_line"] || -1,
72
+ location["end_line"] || -1,
73
+ analyzer,
74
+ rule_id,
75
+ fingerprint,
76
+ id
77
+ ]
78
+ end
79
+
80
+ def to_schema_h
81
+ {
82
+ "schema_version" => SCHEMA_VERSION,
83
+ "id" => id,
84
+ "fingerprint" => fingerprint,
85
+ "origin" => origin,
86
+ "analyzer" => analyzer,
87
+ "rule_id" => rule_id,
88
+ "category" => category,
89
+ "severity" => severity,
90
+ "confidence" => confidence,
91
+ "state" => state,
92
+ "evidence_ref" => evidence_ref,
93
+ "location" => location,
94
+ "message" => message
95
+ }
96
+ end
97
+
98
+ private
99
+
100
+ def require_match(value, pattern, name)
101
+ raise ArgumentError, "#{name} is invalid" unless value.is_a?(String) && value.match?(pattern)
102
+
103
+ value.frozen? ? value : value.dup.freeze
104
+ end
105
+
106
+ def require_member(value, allowed, name)
107
+ raise ArgumentError, "#{name} must be one of #{allowed.join(', ')}" unless allowed.include?(value)
108
+
109
+ value
110
+ end
111
+
112
+ def require_nonempty(value, name, max_length)
113
+ unless value.is_a?(String) && value.valid_encoding? && !value.empty? && value.bytesize <= max_length
114
+ raise ArgumentError, "#{name} must be a non-empty string of at most #{max_length} bytes"
115
+ end
116
+
117
+ value
118
+ end
119
+
120
+ def validate_location(location)
121
+ raise ArgumentError, "location must be a Hash" unless location.is_a?(Hash)
122
+
123
+ unknown = location.keys - %w[path start_line end_line]
124
+ raise ArgumentError, "location contains unknown fields: #{unknown.join(', ')}" unless unknown.empty?
125
+
126
+ path = location.fetch("path") do
127
+ raise ArgumentError, "location requires a path"
128
+ end
129
+ unless path.is_a?(String) && path.valid_encoding? && path.match?(LOCATION_PATH_PATTERN)
130
+ raise ArgumentError, "location.path must be a clean repository-relative path"
131
+ end
132
+
133
+ result = { "path" => path.dup.freeze }
134
+ start_line = location["start_line"]
135
+ end_line = location["end_line"]
136
+ result["start_line"] = validate_line(start_line, "start_line") unless start_line.nil?
137
+ result["end_line"] = validate_line(end_line, "end_line") unless end_line.nil?
138
+
139
+ if !start_line.nil? && !end_line.nil? && end_line < start_line
140
+ raise ArgumentError, "location end_line cannot precede start_line"
141
+ end
142
+
143
+ result
144
+ end
145
+
146
+ def validate_line(value, name)
147
+ unless value.is_a?(Integer) && value >= 1
148
+ raise ArgumentError, "location #{name} must be an integer >= 1"
149
+ end
150
+
151
+ value
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailVerdict
4
+ class GateResult
5
+ SCHEMA_VERSION = "1.0"
6
+ COMPLETION_STATUSES = %w[complete incomplete interrupted].freeze
7
+ GATES = %w[PASS WARN FAIL INCOMPLETE].freeze
8
+ POLICY_STATUSES = %w[pass warn fail not_evaluated].freeze
9
+ FAILURE_CODES = %w[
10
+ unavailable unsupported timed_out signaled failed parse_failed truncated
11
+ malformed configuration interrupted incomplete_evidence
12
+ ].freeze
13
+
14
+ attr_reader :completion_status, :gate, :policy_status, :findings,
15
+ :analyzer_results, :operational_failures, :decision_reasons
16
+
17
+ def initialize(completion_status:, gate:, policy_status:, findings:, analyzer_results:,
18
+ operational_failures:, decision_reasons:, baseline: nil, comparison: nil, git: nil, rails_context: nil)
19
+ @completion_status = require_member(completion_status, COMPLETION_STATUSES, "completion_status")
20
+ @gate = require_member(gate, GATES, "gate")
21
+ @policy_status = require_member(policy_status, POLICY_STATUSES, "policy_status")
22
+ @findings = validate_findings(findings)
23
+ @analyzer_results = validate_analyzer_results(analyzer_results)
24
+ @operational_failures = validate_operational_failures(operational_failures)
25
+ @decision_reasons = validate_decision_reasons(decision_reasons)
26
+ @baseline = baseline ? baseline.dup.freeze : nil
27
+ @comparison = comparison ? comparison.dup.freeze : nil
28
+ @git = git ? deep_freeze_git(git) : nil
29
+ @rails_context = rails_context ? deep_freeze_git(rails_context) : nil
30
+ enforce_state_coupling
31
+ freeze
32
+ end
33
+
34
+ def baseline
35
+ @baseline
36
+ end
37
+
38
+ def comparison
39
+ @comparison
40
+ end
41
+
42
+ def git
43
+ @git
44
+ end
45
+
46
+ def rails_context
47
+ @rails_context
48
+ end
49
+
50
+ def complete?
51
+ completion_status == "complete"
52
+ end
53
+
54
+ def incomplete?
55
+ completion_status != "complete"
56
+ end
57
+
58
+ def to_schema_h
59
+ result = {
60
+ "schema_version" => SCHEMA_VERSION,
61
+ "completion_status" => completion_status,
62
+ "gate" => gate,
63
+ "policy_status" => policy_status,
64
+ "findings" => findings,
65
+ "analyzer_results" => analyzer_results.map(&:to_schema_h),
66
+ "operational_failures" => operational_failures,
67
+ "decision_reasons" => decision_reasons
68
+ }
69
+ result["baseline"] = @baseline if @baseline
70
+ result["comparison"] = @comparison if @comparison
71
+ result["git"] = @git if @git
72
+ result["rails_context"] = @rails_context if @rails_context
73
+ result
74
+ end
75
+
76
+ def deep_freeze_git(value)
77
+ case value
78
+ when Hash
79
+ value.each do |key, child|
80
+ key.freeze if key.is_a?(String)
81
+ deep_freeze_git(child)
82
+ end
83
+ value.freeze
84
+ when Array
85
+ value.each { |child| deep_freeze_git(child) }
86
+ value.freeze
87
+ when String
88
+ value.freeze
89
+ else
90
+ value
91
+ end
92
+ value
93
+ end
94
+ private :deep_freeze_git
95
+
96
+ private
97
+
98
+ def require_member(value, allowed, name)
99
+ raise ArgumentError, "#{name} must be one of #{allowed.join(', ')}" unless allowed.include?(value)
100
+
101
+ value
102
+ end
103
+
104
+ def enforce_state_coupling
105
+ if completion_status == "complete"
106
+ unless %w[PASS WARN FAIL].include?(gate) && %w[pass warn fail].include?(policy_status)
107
+ raise ArgumentError, "complete result must contain a completed gate and policy status"
108
+ end
109
+ elsif gate != "INCOMPLETE" || policy_status != "not_evaluated" || operational_failures.empty?
110
+ raise ArgumentError, "incomplete result must be INCOMPLETE, not_evaluated, and explain a failure"
111
+ end
112
+ end
113
+
114
+ def validate_findings(findings)
115
+ unless findings.is_a?(Array)
116
+ raise ArgumentError, "findings must be an array"
117
+ end
118
+
119
+ findings.map do |finding|
120
+ raise ArgumentError, "finding summary must be a Hash" unless finding.is_a?(Hash)
121
+
122
+ expected = %w[id fingerprint severity state blocking]
123
+ raise ArgumentError, "finding summary contains unknown fields" unless finding.keys == expected
124
+ raise ArgumentError, "finding summary id is invalid" unless finding["id"].is_a?(String) && !finding["id"].empty?
125
+ raise ArgumentError, "finding summary fingerprint is invalid" unless finding["fingerprint"].match?(/\Asha256:[0-9a-f]{64}\z/)
126
+ raise ArgumentError, "finding summary severity is invalid" unless Finding::SEVERITIES.include?(finding["severity"])
127
+ raise ArgumentError, "finding summary state is invalid" unless Finding::STATES.include?(finding["state"])
128
+ raise ArgumentError, "finding summary blocking must be boolean" unless [true, false].include?(finding["blocking"])
129
+
130
+ finding.dup.freeze
131
+ end.freeze
132
+ end
133
+
134
+ def validate_analyzer_results(results)
135
+ raise ArgumentError, "analyzer_results must be an array" unless results.is_a?(Array)
136
+ raise ArgumentError, "analyzer_results must contain AnalyzerResult values" unless results.all? { |result| result.is_a?(AnalyzerResult) }
137
+
138
+ results.sort_by(&:analyzer).freeze
139
+ end
140
+
141
+ def validate_operational_failures(failures)
142
+ raise ArgumentError, "operational_failures must be an array" unless failures.is_a?(Array)
143
+
144
+ failures.map do |failure|
145
+ raise ArgumentError, "operational failure must be a Hash" unless failure.is_a?(Hash)
146
+ allowed = %w[code analyzer message]
147
+ raise ArgumentError, "operational failure contains unknown fields" unless (failure.keys - allowed).empty?
148
+ raise ArgumentError, "operational failure has invalid code" unless FAILURE_CODES.include?(failure["code"])
149
+ raise ArgumentError, "operational failure requires a message" unless failure["message"].is_a?(String) && !failure["message"].empty?
150
+ if failure.key?("analyzer") && !failure["analyzer"].is_a?(String)
151
+ raise ArgumentError, "operational failure analyzer must be a string"
152
+ end
153
+
154
+ failure.dup.freeze
155
+ end.freeze
156
+ end
157
+
158
+ def validate_decision_reasons(reasons)
159
+ raise ArgumentError, "decision_reasons must be an array" unless reasons.is_a?(Array)
160
+
161
+ reasons.map do |reason|
162
+ raise ArgumentError, "decision reason must be a Hash" unless reason.is_a?(Hash)
163
+ raise ArgumentError, "decision reason fields are invalid" unless reason.keys == %w[code message]
164
+ raise ArgumentError, "decision reason code is invalid" unless reason["code"].is_a?(String) && !reason["code"].empty?
165
+ raise ArgumentError, "decision reason message is invalid" unless reason["message"].is_a?(String) && !reason["message"].empty?
166
+
167
+ reason.dup.freeze
168
+ end.freeze
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailVerdict
4
+ module Coverage
5
+ module ChangedLineEvaluator
6
+ module_function
7
+
8
+ def evaluate(coverage_document:, line_set:)
9
+ raise ArgumentError, "coverage_document must be a Hash" unless coverage_document.is_a?(Hash)
10
+ raise ArgumentError, "line_set must be a Hash" unless line_set.is_a?(Hash)
11
+
12
+ files = coverage_document["files"]
13
+ raise ArgumentError, "coverage document has no files array" unless files.is_a?(Array)
14
+
15
+ coverage_map = {}
16
+ files.each do |file|
17
+ filename = file["filename"]
18
+ coverage = file.dig("coverage", "lines")
19
+ next unless filename.is_a?(String) && coverage.is_a?(Array)
20
+
21
+ coverage_map[filename] = coverage
22
+ end
23
+
24
+ executable = 0
25
+ covered = 0
26
+ missing = []
27
+
28
+ line_set.each do |path, lines|
29
+ raise ArgumentError, "line_set path must be a string" unless path.is_a?(String)
30
+ raise ArgumentError, "line_set lines must be an array" unless lines.is_a?(Array)
31
+
32
+ coverage = coverage_map[path]
33
+ lines.each do |line_number|
34
+ raise ArgumentError, "line number must be an integer >= 1" unless line_number.is_a?(Integer) && line_number >= 1
35
+
36
+ if coverage.nil? || line_number > coverage.length
37
+ executable += 1
38
+ missing << [path, line_number]
39
+ next
40
+ end
41
+
42
+ hit = coverage[line_number - 1]
43
+ if hit.nil?
44
+ next
45
+ end
46
+
47
+ executable += 1
48
+ if hit.to_i > 0
49
+ covered += 1
50
+ else
51
+ missing << [path, line_number]
52
+ end
53
+ end
54
+ end
55
+
56
+ missing = missing.sort
57
+
58
+ percent = executable == 0 ? 100.0 : ((covered.to_f / executable) * 100).round(2)
59
+
60
+ {
61
+ "covered_lines" => covered,
62
+ "executable_lines" => executable,
63
+ "missing_lines" => missing,
64
+ "percent" => percent,
65
+ "paths" => line_set.keys.sort
66
+ }.freeze
67
+ end
68
+ end
69
+ end
70
+ end