rail_verdict 1.4.0 → 1.8.2
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 +4 -4
- data/README.md +2 -1
- data/lib/rail_verdict/analyzers/brakeman.rb +309 -0
- data/lib/rail_verdict/analyzers/bundler_audit.rb +1 -1
- data/lib/rail_verdict/analyzers/minitest.rb +45 -9
- data/lib/rail_verdict/analyzers/rspec.rb +35 -6
- data/lib/rail_verdict/change_intelligence.rb +274 -0
- data/lib/rail_verdict/change_surfaces.rb +232 -0
- data/lib/rail_verdict/check.rb +83 -14
- data/lib/rail_verdict/cli.rb +294 -6
- data/lib/rail_verdict/comparison.rb +18 -2
- data/lib/rail_verdict/configuration.rb +12 -2
- data/lib/rail_verdict/contracts/analyzer_result.rb +1 -1
- data/lib/rail_verdict/contracts/finding.rb +6 -0
- data/lib/rail_verdict/doctor.rb +17 -5
- data/lib/rail_verdict/engineering_policy.rb +394 -0
- data/lib/rail_verdict/findings_command.rb +7 -1
- data/lib/rail_verdict/handoff.rb +4 -1
- data/lib/rail_verdict/mcp/server.rb +8 -0
- data/lib/rail_verdict/mcp/tools/create_workflow_receipt.rb +135 -0
- data/lib/rail_verdict/mcp/tools/get_engineering_policy.rb +117 -0
- data/lib/rail_verdict/mcp/tools/get_pr_intelligence.rb +1 -1
- data/lib/rail_verdict/mcp/tools/get_review_packet.rb +116 -0
- data/lib/rail_verdict/mcp/tools/verify_review_observation.rb +120 -0
- data/lib/rail_verdict/mcp.rb +12 -0
- data/lib/rail_verdict/pr_intelligence.rb +39 -1
- data/lib/rail_verdict/rails_context/resolvers/test_candidates.rb +38 -4
- data/lib/rail_verdict/repair/verification_plan.rb +33 -1
- data/lib/rail_verdict/reporters/console.rb +40 -0
- data/lib/rail_verdict/reporters/engineering_policy.rb +50 -0
- data/lib/rail_verdict/reporters/github_annotations.rb +6 -1
- data/lib/rail_verdict/reporters/pr_intelligence.rb +54 -5
- data/lib/rail_verdict/reporters/review.rb +73 -0
- data/lib/rail_verdict/reporters/sarif.rb +6 -1
- data/lib/rail_verdict/reuse.rb +106 -0
- data/lib/rail_verdict/review_observation.rb +95 -0
- data/lib/rail_verdict/review_packet.rb +159 -0
- data/lib/rail_verdict/schema_validator.rb +34 -2
- data/lib/rail_verdict/test_selection.rb +186 -0
- data/lib/rail_verdict/verification_environment.rb +16 -7
- data/lib/rail_verdict/verification_plan.rb +97 -0
- data/lib/rail_verdict/version.rb +1 -1
- data/lib/rail_verdict/workflow_receipt.rb +77 -0
- data/lib/rail_verdict.rb +11 -0
- data/schemas/configuration-v1.5.schema.json +2 -1
- data/schemas/configuration-v1.6.schema.json +177 -0
- data/schemas/configuration-v1.7.schema.json +499 -0
- data/schemas/engineering-policy-v1.schema.json +107 -0
- data/schemas/pr-intelligence-v1.1.schema.json +407 -0
- data/schemas/repair-packet-v1.schema.json +42 -0
- data/schemas/review-observation-v1.schema.json +68 -0
- data/schemas/review-packet-v1.schema.json +175 -0
- data/schemas/workflow-receipt-v1.schema.json +47 -0
- metadata +23 -1
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "change_surfaces"
|
|
4
|
+
|
|
5
|
+
module RailVerdict
|
|
6
|
+
# Deterministic change intelligence assembly.
|
|
7
|
+
#
|
|
8
|
+
# Read-only projection over an executed verification Outcome plus its
|
|
9
|
+
# detected surfaces. NEVER influences GateResult: every method here is a
|
|
10
|
+
# pure function of already-decided verification evidence.
|
|
11
|
+
#
|
|
12
|
+
# Confidence vocabulary: surfaces carry their own detection tier
|
|
13
|
+
# ("detected" / "inferred" / "mixed"); anything that cannot be established
|
|
14
|
+
# deterministically is reported under missing_evidence as "unknown" — never
|
|
15
|
+
# as a fact.
|
|
16
|
+
module ChangeIntelligence
|
|
17
|
+
RISK_ORDER = { "low" => 0, "medium" => 1, "high" => 2, "critical" => 3 }.freeze
|
|
18
|
+
|
|
19
|
+
SIGNAL_CODES = {
|
|
20
|
+
"security" => "security_config_changed",
|
|
21
|
+
"authorization" => "authorization_surface_changed",
|
|
22
|
+
"authentication" => "authentication_surface_changed",
|
|
23
|
+
"migration" => "migration_added",
|
|
24
|
+
"database" => "database_surface_changed",
|
|
25
|
+
"dependencies" => "dependency_changed",
|
|
26
|
+
"public_api" => "public_api_changed",
|
|
27
|
+
"routes" => "routes_changed",
|
|
28
|
+
"shared_infrastructure" => "shared_infrastructure_changed",
|
|
29
|
+
"configuration" => "configuration_changed"
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
# Priority order for Review Focus (trust-critical first).
|
|
33
|
+
FOCUS_ORDER = %w[
|
|
34
|
+
security authorization authentication migration database dependencies
|
|
35
|
+
public_api routes shared_infrastructure configuration controllers models
|
|
36
|
+
background_jobs mailers views storage initializers test_infrastructure
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
def changed_paths(git_context)
|
|
42
|
+
return nil unless git_context
|
|
43
|
+
|
|
44
|
+
git_context.changed_files.flat_map { |file| [file.path, file.old_path, file.new_path] }.compact.uniq.sort
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Array of { code, surface?, project_area?, detail, paths, sensitive }.
|
|
48
|
+
def review_signals(surfaces, project_areas)
|
|
49
|
+
signals = []
|
|
50
|
+
SIGNAL_CODES.each do |surface_id, code|
|
|
51
|
+
entry = surfaces[surface_id]
|
|
52
|
+
next unless entry && entry["available"] && entry["changed"]
|
|
53
|
+
|
|
54
|
+
signals << {
|
|
55
|
+
"code" => code,
|
|
56
|
+
"surface" => surface_id,
|
|
57
|
+
"detail" => "#{ChangeSurfaces::SURFACES[surface_id]['label']} surface changed",
|
|
58
|
+
"paths" => entry["evidence"],
|
|
59
|
+
"additional_evidence_count" => entry["additional_evidence_count"],
|
|
60
|
+
"sensitive" => entry["sensitive"],
|
|
61
|
+
"detection" => entry["detection"]
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
Array(project_areas).each do |area|
|
|
65
|
+
next unless area["available"] && area["changed"]
|
|
66
|
+
|
|
67
|
+
signals << {
|
|
68
|
+
"code" => "project_sensitive_path_changed:#{area['name']}",
|
|
69
|
+
"project_area" => area["name"],
|
|
70
|
+
"detail" => "Project-defined sensitive area '#{area['name']}' changed",
|
|
71
|
+
"paths" => area["evidence"],
|
|
72
|
+
"additional_evidence_count" => area["additional_evidence_count"],
|
|
73
|
+
"sensitive" => true,
|
|
74
|
+
"detection" => "detected"
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
signals
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# { level, reasons, configured } — max-severity aggregation, explainable.
|
|
81
|
+
def review_risk(signals, risk_overrides)
|
|
82
|
+
overrides = risk_overrides.is_a?(Hash) ? risk_overrides : {}
|
|
83
|
+
reasons = []
|
|
84
|
+
level = "low"
|
|
85
|
+
signals.each do |signal|
|
|
86
|
+
base = if signal["project_area"]
|
|
87
|
+
"high"
|
|
88
|
+
else
|
|
89
|
+
ChangeSurfaces::SURFACES.dig(signal["surface"], "default_risk")
|
|
90
|
+
end
|
|
91
|
+
next if base.nil?
|
|
92
|
+
|
|
93
|
+
mapped = overrides.fetch(signal["surface"] || signal["code"], base).to_s
|
|
94
|
+
mapped = base unless RISK_ORDER.key?(mapped)
|
|
95
|
+
reasons << signal["code"]
|
|
96
|
+
level = mapped if RISK_ORDER[mapped] > RISK_ORDER[level]
|
|
97
|
+
end
|
|
98
|
+
{ "level" => level.upcase, "reasons" => reasons.uniq.sort,
|
|
99
|
+
"configured" => !overrides.empty? }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# { available, reason?, frameworks: { rspec|minitest => {...} } }.
|
|
103
|
+
# Executed analyzer evidence wins; when an analyzer did not execute,
|
|
104
|
+
# the deterministic TestSelection recompute is reported as the selection
|
|
105
|
+
# basis with executed: false.
|
|
106
|
+
def verification_scope(result, repository_root:, git_context:)
|
|
107
|
+
frameworks = {}
|
|
108
|
+
%w[rspec minitest].each do |name|
|
|
109
|
+
frameworks[name] = framework_scope(result, name,
|
|
110
|
+
repository_root: repository_root, git_context: git_context)
|
|
111
|
+
end
|
|
112
|
+
if frameworks.values.all? { |entry| entry["scope"] == "unknown" }
|
|
113
|
+
{ "available" => false, "reason" => "test_scope_unavailable", "frameworks" => frameworks }
|
|
114
|
+
else
|
|
115
|
+
{ "available" => true, "frameworks" => frameworks }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def framework_scope(result, name, repository_root:, git_context:)
|
|
120
|
+
analyzer = result.analyzer_results.find { |item| item.analyzer == name }
|
|
121
|
+
summary = analyzer&.evidence_summary
|
|
122
|
+
if analyzer && analyzer.execution_status == "succeeded" && summary.is_a?(Hash)
|
|
123
|
+
entry = {
|
|
124
|
+
"available" => true, "executed" => true,
|
|
125
|
+
"execution_status" => analyzer.execution_status,
|
|
126
|
+
"scope" => summary["test_scope"] || "full"
|
|
127
|
+
}
|
|
128
|
+
entry["selected_files"] = Array(summary["target_files"]).compact.sort if summary["target_files"]
|
|
129
|
+
entry["fallback_reason"] = summary["fallback_reason"] if summary["fallback_reason"]
|
|
130
|
+
return entry
|
|
131
|
+
end
|
|
132
|
+
return { "available" => false, "executed" => false, "scope" => "unknown",
|
|
133
|
+
"reason" => analyzer ? "analyzer_#{analyzer.execution_status}" : "analyzer_not_configured" } unless git_context
|
|
134
|
+
|
|
135
|
+
begin
|
|
136
|
+
selection = TestSelection.resolve(repository_root: repository_root,
|
|
137
|
+
git_context: git_context, framework: name.to_sym)
|
|
138
|
+
entry = { "available" => true, "executed" => false,
|
|
139
|
+
"execution_status" => analyzer ? analyzer.execution_status : "not_executed",
|
|
140
|
+
"scope" => selection.scope }
|
|
141
|
+
entry["selected_files"] = selection.selected_files if selection.scope == "targeted"
|
|
142
|
+
entry["fallback_reason"] = selection.fallback_reason if selection.fallback_reason
|
|
143
|
+
entry
|
|
144
|
+
rescue StandardError
|
|
145
|
+
{ "available" => false, "executed" => false, "scope" => "unknown",
|
|
146
|
+
"reason" => "selection_unresolvable" }
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
private_class_method :framework_scope
|
|
150
|
+
|
|
151
|
+
# Array of { code, detail, reason }. Facts about gaps, never guesses.
|
|
152
|
+
def missing_evidence(outcome, surfaces, verification_scope)
|
|
153
|
+
result = outcome.result
|
|
154
|
+
gaps = []
|
|
155
|
+
auth = surfaces["authorization"]
|
|
156
|
+
if auth && auth["available"] && auth["changed"]
|
|
157
|
+
gaps << {
|
|
158
|
+
"code" => "authorization_changed_verification_not_established",
|
|
159
|
+
"detail" => "Authorization surface changed; no deterministic authorization-to-test mapping exists",
|
|
160
|
+
"reason" => "no_deterministic_relationship_established",
|
|
161
|
+
"confidence" => "unknown"
|
|
162
|
+
}
|
|
163
|
+
end
|
|
164
|
+
verification_scope.fetch("frameworks", {}).each do |name, entry|
|
|
165
|
+
reason = entry["fallback_reason"].to_s
|
|
166
|
+
next unless reason.start_with?("unmapped_source_change:", "unrecognized_source_file:")
|
|
167
|
+
|
|
168
|
+
outcome_text = entry["executed"] ? "full suite ran instead" : "full suite required (#{name} not executed)"
|
|
169
|
+
gaps << {
|
|
170
|
+
"code" => "targeted_test_mapping_ambiguous",
|
|
171
|
+
"detail" => "#{name}: #{reason} — #{outcome_text}",
|
|
172
|
+
"reason" => reason,
|
|
173
|
+
"confidence" => "unknown"
|
|
174
|
+
}
|
|
175
|
+
end
|
|
176
|
+
configuration = outcome.respond_to?(:configuration) ? outcome.configuration : nil
|
|
177
|
+
configuration = nil unless configuration.respond_to?(:analyzer_required?)
|
|
178
|
+
if configuration
|
|
179
|
+
required_incomplete = result.analyzer_results.select do |analyzer|
|
|
180
|
+
begin
|
|
181
|
+
configuration.analyzer_required?(analyzer.analyzer)
|
|
182
|
+
rescue StandardError
|
|
183
|
+
false
|
|
184
|
+
end && analyzer.execution_status != "succeeded"
|
|
185
|
+
end
|
|
186
|
+
required_incomplete.each do |analyzer|
|
|
187
|
+
gaps << {
|
|
188
|
+
"code" => "required_analyzer_incomplete",
|
|
189
|
+
"detail" => "Required analyzer '#{analyzer.analyzer}' did not succeed (#{analyzer.execution_status})",
|
|
190
|
+
"reason" => analyzer.failure ? analyzer.failure["code"].to_s : analyzer.execution_status,
|
|
191
|
+
"confidence" => "detected"
|
|
192
|
+
}
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
coverage = result.analyzer_results.find { |item| item.analyzer == "simplecov" }
|
|
196
|
+
if coverage.nil? || coverage.execution_status != "succeeded"
|
|
197
|
+
gaps << {
|
|
198
|
+
"code" => "coverage_evidence_unavailable",
|
|
199
|
+
"detail" => "Changed-lines coverage could not be established",
|
|
200
|
+
"reason" => coverage ? "simplecov_#{coverage.execution_status}" : "simplecov_not_configured",
|
|
201
|
+
"confidence" => "unknown"
|
|
202
|
+
}
|
|
203
|
+
end
|
|
204
|
+
unless result.baseline && result.baseline["loaded"] == true
|
|
205
|
+
gaps << {
|
|
206
|
+
"code" => "baseline_unavailable",
|
|
207
|
+
"detail" => "No quality delta: baseline not loaded, so introduced/resolved cannot be distinguished from existing",
|
|
208
|
+
"reason" => result.baseline && result.baseline["compatible"] == false ? "baseline_incompatible" : "baseline_not_available",
|
|
209
|
+
"confidence" => "detected"
|
|
210
|
+
}
|
|
211
|
+
end
|
|
212
|
+
gaps << {
|
|
213
|
+
"code" => "git_scope_unavailable",
|
|
214
|
+
"detail" => "Changed scope could not be established; surfaces, signals, and focus are unavailable",
|
|
215
|
+
"reason" => "git_scope_unavailable",
|
|
216
|
+
"confidence" => "detected"
|
|
217
|
+
} if outcome.context&.git_context.nil? && result.git.nil?
|
|
218
|
+
gaps
|
|
219
|
+
end
|
|
220
|
+
# Ordered { rank, surface, title, reason, paths, additional_evidence_count }.
|
|
221
|
+
# Non-sensitive items whose evidence is fully covered by higher-ranked
|
|
222
|
+
# items are skipped to reduce the review search space; sensitive items
|
|
223
|
+
# and project areas are always shown. Deterministic: FOCUS_ORDER decides.
|
|
224
|
+
def review_focus(surfaces, project_areas, changed_paths = [])
|
|
225
|
+
items = []
|
|
226
|
+
covered = {}
|
|
227
|
+
FOCUS_ORDER.each do |surface_id|
|
|
228
|
+
entry = surfaces[surface_id]
|
|
229
|
+
next unless entry && entry["available"] && entry["changed"]
|
|
230
|
+
|
|
231
|
+
sensitive = ChangeSurfaces::SURFACES[surface_id]["default_risk"] != nil
|
|
232
|
+
paths = entry["evidence"].first(10)
|
|
233
|
+
unless sensitive
|
|
234
|
+
next if !paths.empty? && paths.all? { |path| covered.key?(path) }
|
|
235
|
+
end
|
|
236
|
+
paths.each { |path| covered[path] = true }
|
|
237
|
+
items << {
|
|
238
|
+
"surface" => surface_id,
|
|
239
|
+
"title" => ChangeSurfaces::SURFACES[surface_id]["label"],
|
|
240
|
+
"reason" => "#{ChangeSurfaces::SURFACES[surface_id]['label']} surface changed",
|
|
241
|
+
"paths" => paths,
|
|
242
|
+
"additional_evidence_count" => entry["additional_evidence_count"] + [entry["evidence"].length - 10, 0].max,
|
|
243
|
+
"detection" => entry["detection"]
|
|
244
|
+
}
|
|
245
|
+
end
|
|
246
|
+
Array(project_areas).each do |area|
|
|
247
|
+
next unless area["available"] && area["changed"]
|
|
248
|
+
|
|
249
|
+
items << {
|
|
250
|
+
"surface" => "project:#{area['name']}",
|
|
251
|
+
"title" => "Sensitive area: #{area['name']}",
|
|
252
|
+
"reason" => "Project-defined sensitive area '#{area['name']}' changed",
|
|
253
|
+
"paths" => Array(area["evidence"]).first(10),
|
|
254
|
+
"additional_evidence_count" => area["additional_evidence_count"],
|
|
255
|
+
"detection" => "detected"
|
|
256
|
+
}
|
|
257
|
+
Array(area["evidence"]).each { |path| covered[path] = true }
|
|
258
|
+
end
|
|
259
|
+
unmapped = Array(changed_paths).map(&:to_s).uniq.sort.reject { |path| covered.key?(path) }
|
|
260
|
+
unless unmapped.empty?
|
|
261
|
+
head = unmapped.first(10)
|
|
262
|
+
items << {
|
|
263
|
+
"surface" => "unmapped",
|
|
264
|
+
"title" => "Unmapped changed files (#{unmapped.length})",
|
|
265
|
+
"reason" => "Changed files matching no Rails surface or project area; review directly",
|
|
266
|
+
"paths" => head,
|
|
267
|
+
"additional_evidence_count" => unmapped.length - head.length,
|
|
268
|
+
"detection" => "detected"
|
|
269
|
+
}
|
|
270
|
+
end
|
|
271
|
+
items.each_with_index.map { |item, index| item.merge("rank" => index + 1) }
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailVerdict
|
|
4
|
+
# Deterministic Rails-aware change surface detection.
|
|
5
|
+
#
|
|
6
|
+
# Every surface is a pure function of changed file paths. Each reported
|
|
7
|
+
# surface carries its evidence paths; nothing is ever asserted without
|
|
8
|
+
# evidence. Detection tier is explicit:
|
|
9
|
+
# - "detected": strong Rails path convention (reliable).
|
|
10
|
+
# - "inferred": filename heuristic, documented as such (see AUTHENTICATION,
|
|
11
|
+
# PUBLIC_API, and the name-pattern tier of SECURITY).
|
|
12
|
+
module ChangeSurfaces
|
|
13
|
+
MAX_EVIDENCE = 20
|
|
14
|
+
|
|
15
|
+
# id => { label, detection, matchers: [lambdas], default_risk }
|
|
16
|
+
# default_risk nil means the surface is not sensitive by default.
|
|
17
|
+
SURFACES = {
|
|
18
|
+
"security" => {
|
|
19
|
+
"label" => "Security",
|
|
20
|
+
"detection" => "mixed",
|
|
21
|
+
"default_risk" => "critical",
|
|
22
|
+
"matchers" => [
|
|
23
|
+
lambda { |path| path.start_with?("config/credentials/") || path == "config/credentials.yml.enc" },
|
|
24
|
+
lambda { |path| path == "config/master.key" || path == "config/credentials.key" },
|
|
25
|
+
lambda { |path| path.start_with?("config/initializers/") && path.match?(/secret|credential|csp|secure_headers|ssl|force_ssl/i) }
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
"authorization" => {
|
|
29
|
+
"label" => "Authorization",
|
|
30
|
+
"detection" => "detected",
|
|
31
|
+
"default_risk" => "high",
|
|
32
|
+
"matchers" => [
|
|
33
|
+
lambda { |path| path.start_with?("app/policies/") },
|
|
34
|
+
lambda { |path| path == "app/models/ability.rb" || path.end_with?("/ability.rb") },
|
|
35
|
+
lambda { |path| path.include?("/abilities/") && path.end_with?(".rb") }
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
"authentication" => {
|
|
39
|
+
"label" => "Authentication",
|
|
40
|
+
"detection" => "inferred",
|
|
41
|
+
"default_risk" => "high",
|
|
42
|
+
"matchers" => [
|
|
43
|
+
lambda { |path| path == "config/initializers/devise.rb" },
|
|
44
|
+
lambda { |path| path.match?(%r{\Aapp/controllers/.*sessions?_controller\.rb\z}) },
|
|
45
|
+
lambda { |path| path.match?(%r{\Aapp/controllers/.*(passwords|registrations|unlocks|confirmations|omniauth_callbacks)_controller\.rb\z}) },
|
|
46
|
+
lambda { |path| path.start_with?("app/models/") && File.basename(path).match?(/session|token|password/i) }
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
"migration" => {
|
|
50
|
+
"label" => "Migration",
|
|
51
|
+
"detection" => "detected",
|
|
52
|
+
"default_risk" => "high",
|
|
53
|
+
"matchers" => [lambda { |path| path.start_with?("db/migrate/") }]
|
|
54
|
+
},
|
|
55
|
+
"database" => {
|
|
56
|
+
"label" => "Database",
|
|
57
|
+
"detection" => "detected",
|
|
58
|
+
"default_risk" => "medium",
|
|
59
|
+
"matchers" => [
|
|
60
|
+
lambda { |path| path == "db/schema.rb" || path == "db/structure.sql" },
|
|
61
|
+
lambda { |path| path.start_with?("app/models/") }
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
"dependencies" => {
|
|
65
|
+
"label" => "Dependencies",
|
|
66
|
+
"detection" => "detected",
|
|
67
|
+
"default_risk" => "medium",
|
|
68
|
+
"matchers" => [
|
|
69
|
+
lambda { |path| %w[Gemfile Gemfile.lock].include?(path) },
|
|
70
|
+
lambda { |path| path.end_with?(".gemspec") }
|
|
71
|
+
]
|
|
72
|
+
},
|
|
73
|
+
"public_api" => {
|
|
74
|
+
"label" => "Public API",
|
|
75
|
+
"detection" => "inferred",
|
|
76
|
+
"default_risk" => "medium",
|
|
77
|
+
"matchers" => [
|
|
78
|
+
lambda { |path| path.start_with?("app/controllers/api/") },
|
|
79
|
+
lambda { |path| path.match?(%r{\Aapp/controllers/v\d+/.+\.rb\z}) },
|
|
80
|
+
lambda { |path| path.start_with?("app/views/api/") }
|
|
81
|
+
]
|
|
82
|
+
},
|
|
83
|
+
"routes" => {
|
|
84
|
+
"label" => "Routes",
|
|
85
|
+
"detection" => "detected",
|
|
86
|
+
"default_risk" => "medium",
|
|
87
|
+
"matchers" => [
|
|
88
|
+
lambda { |path| path == "config/routes.rb" || path.start_with?("config/routes/") }
|
|
89
|
+
]
|
|
90
|
+
},
|
|
91
|
+
"background_jobs" => {
|
|
92
|
+
"label" => "Background jobs",
|
|
93
|
+
"detection" => "detected",
|
|
94
|
+
"default_risk" => nil,
|
|
95
|
+
"matchers" => [
|
|
96
|
+
lambda { |path| path.start_with?("app/jobs/") || path.start_with?("app/workers/") },
|
|
97
|
+
lambda { |path| path.match?(%r{\Aconfig/(sidekiq.*|initializers/sidekiq.*)\z}) }
|
|
98
|
+
]
|
|
99
|
+
},
|
|
100
|
+
"controllers" => {
|
|
101
|
+
"label" => "Controllers",
|
|
102
|
+
"detection" => "detected",
|
|
103
|
+
"default_risk" => nil,
|
|
104
|
+
"matchers" => [lambda { |path| path.start_with?("app/controllers/") }]
|
|
105
|
+
},
|
|
106
|
+
"models" => {
|
|
107
|
+
"label" => "Models",
|
|
108
|
+
"detection" => "detected",
|
|
109
|
+
"default_risk" => nil,
|
|
110
|
+
"matchers" => [lambda { |path| path.start_with?("app/models/") }]
|
|
111
|
+
},
|
|
112
|
+
"views" => {
|
|
113
|
+
"label" => "Views",
|
|
114
|
+
"detection" => "detected",
|
|
115
|
+
"default_risk" => nil,
|
|
116
|
+
"matchers" => [lambda { |path| path.start_with?("app/views/") }]
|
|
117
|
+
},
|
|
118
|
+
"mailers" => {
|
|
119
|
+
"label" => "Mailers",
|
|
120
|
+
"detection" => "detected",
|
|
121
|
+
"default_risk" => nil,
|
|
122
|
+
"matchers" => [lambda { |path| path.start_with?("app/mailers/") }]
|
|
123
|
+
},
|
|
124
|
+
"storage" => {
|
|
125
|
+
"label" => "Storage",
|
|
126
|
+
"detection" => "detected",
|
|
127
|
+
"default_risk" => nil,
|
|
128
|
+
"matchers" => [
|
|
129
|
+
lambda { |path| path.start_with?("storage/") },
|
|
130
|
+
lambda { |path| path.match?(%r{\Aconfig/storage.*\.yml\z}) }
|
|
131
|
+
]
|
|
132
|
+
},
|
|
133
|
+
"initializers" => {
|
|
134
|
+
"label" => "Initializers",
|
|
135
|
+
"detection" => "detected",
|
|
136
|
+
"default_risk" => nil,
|
|
137
|
+
"matchers" => [lambda { |path| path.start_with?("config/initializers/") }]
|
|
138
|
+
},
|
|
139
|
+
"configuration" => {
|
|
140
|
+
"label" => "Configuration",
|
|
141
|
+
"detection" => "detected",
|
|
142
|
+
"default_risk" => "medium",
|
|
143
|
+
"matchers" => [lambda { |path| path == "config" || path.start_with?("config/") }]
|
|
144
|
+
},
|
|
145
|
+
"test_infrastructure" => {
|
|
146
|
+
"label" => "Test infrastructure",
|
|
147
|
+
"detection" => "detected",
|
|
148
|
+
"default_risk" => nil,
|
|
149
|
+
"matchers" => [
|
|
150
|
+
lambda { |path| %w[spec/spec_helper.rb spec/rails_helper.rb test/test_helper.rb].include?(path) },
|
|
151
|
+
lambda { |path| path.start_with?("spec/support/") || path.start_with?("test/support/") }
|
|
152
|
+
]
|
|
153
|
+
},
|
|
154
|
+
"shared_infrastructure" => {
|
|
155
|
+
"label" => "Shared infrastructure",
|
|
156
|
+
"detection" => "detected",
|
|
157
|
+
"default_risk" => "medium",
|
|
158
|
+
"matchers" => [
|
|
159
|
+
lambda { |path| %w[app/models/application_record.rb app/controllers/application_controller.rb app/jobs/application_job.rb app/mailers/application_mailer.rb app/helpers/application_helper.rb].include?(path) },
|
|
160
|
+
lambda { |path| %w[config/application.rb config/environment.rb config/boot.rb].include?(path) },
|
|
161
|
+
lambda { |path| path.start_with?("config/environments/") }
|
|
162
|
+
]
|
|
163
|
+
}
|
|
164
|
+
}.freeze
|
|
165
|
+
|
|
166
|
+
RISK_LEVELS = %w[low medium high critical].freeze
|
|
167
|
+
|
|
168
|
+
module_function
|
|
169
|
+
|
|
170
|
+
# Returns { id => { available, changed, evidence, additional_evidence_count,
|
|
171
|
+
# detection, sensitive, reason? } }.
|
|
172
|
+
def detect(changed_paths, available:)
|
|
173
|
+
paths = Array(changed_paths).compact.uniq.sort
|
|
174
|
+
SURFACES.to_h do |id, spec|
|
|
175
|
+
if !available
|
|
176
|
+
[id, { "available" => false, "changed" => false, "evidence" => [],
|
|
177
|
+
"additional_evidence_count" => 0, "detection" => spec["detection"],
|
|
178
|
+
"sensitive" => false, "reason" => "git_scope_unavailable" }]
|
|
179
|
+
else
|
|
180
|
+
evidence = paths.select { |path| spec["matchers"].any? { |m| safe_match(m, path) } }
|
|
181
|
+
head = evidence.first(MAX_EVIDENCE)
|
|
182
|
+
[id, { "available" => true, "changed" => !evidence.empty?,
|
|
183
|
+
"evidence" => head,
|
|
184
|
+
"additional_evidence_count" => [evidence.length - head.length, 0].max,
|
|
185
|
+
"detection" => spec["detection"],
|
|
186
|
+
"sensitive" => !spec["default_risk"].nil? && !evidence.empty? }]
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Project-defined sensitive areas from configuration review.sensitive_paths:
|
|
192
|
+
# { name => [glob, ...] }. Returns array of
|
|
193
|
+
# { name, changed, evidence, additional_evidence_count }.
|
|
194
|
+
def project_sensitive(changed_paths, sensitive_config, available:)
|
|
195
|
+
config = sensitive_config.is_a?(Hash) ? sensitive_config : {}
|
|
196
|
+
paths = Array(changed_paths).compact.uniq.sort
|
|
197
|
+
config.filter_map do |name, patterns|
|
|
198
|
+
name = name.to_s
|
|
199
|
+
next if name.empty? || !patterns.is_a?(Array)
|
|
200
|
+
|
|
201
|
+
if !available
|
|
202
|
+
{ "name" => name, "available" => false, "changed" => false,
|
|
203
|
+
"evidence" => [], "additional_evidence_count" => 0,
|
|
204
|
+
"reason" => "git_scope_unavailable" }
|
|
205
|
+
else
|
|
206
|
+
evidence = paths.select do |path|
|
|
207
|
+
patterns.any? { |pattern| glob_match(pattern.to_s, path) }
|
|
208
|
+
end
|
|
209
|
+
head = evidence.first(MAX_EVIDENCE)
|
|
210
|
+
{ "name" => name, "available" => true, "changed" => !evidence.empty?,
|
|
211
|
+
"evidence" => head,
|
|
212
|
+
"additional_evidence_count" => [evidence.length - head.length, 0].max }
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def glob_match(pattern, path)
|
|
218
|
+
return false if pattern.empty?
|
|
219
|
+
|
|
220
|
+
File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_EXTGLOB)
|
|
221
|
+
rescue StandardError
|
|
222
|
+
false
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def safe_match(matcher, path)
|
|
226
|
+
matcher.call(path) == true
|
|
227
|
+
rescue StandardError
|
|
228
|
+
false
|
|
229
|
+
end
|
|
230
|
+
private_class_method :safe_match
|
|
231
|
+
end
|
|
232
|
+
end
|
data/lib/rail_verdict/check.rb
CHANGED
|
@@ -13,7 +13,8 @@ module RailVerdict
|
|
|
13
13
|
"minitest" => RailVerdict::Analyzers::Minitest,
|
|
14
14
|
"rspec" => RailVerdict::Analyzers::RSpec,
|
|
15
15
|
"simplecov" => RailVerdict::Analyzers::SimpleCov,
|
|
16
|
-
"bundler_audit" => RailVerdict::Analyzers::BundlerAudit
|
|
16
|
+
"bundler_audit" => RailVerdict::Analyzers::BundlerAudit,
|
|
17
|
+
"brakeman" => RailVerdict::Analyzers::Brakeman
|
|
17
18
|
}.freeze
|
|
18
19
|
|
|
19
20
|
def self.registry
|
|
@@ -86,7 +87,7 @@ module RailVerdict
|
|
|
86
87
|
|
|
87
88
|
module_function
|
|
88
89
|
|
|
89
|
-
def execute(repository_root:, config_path:, runner: ProcessRunner, rubocop_command_resolver: nil, analyzer_timeout_seconds: 30.0, interrupted: nil, baseline_path_override: nil, waiver_path_override: nil, clock: Time.now.utc, changed: false, base: nil)
|
|
90
|
+
def execute(repository_root:, config_path:, runner: ProcessRunner, rubocop_command_resolver: nil, analyzer_timeout_seconds: 30.0, interrupted: nil, baseline_path_override: nil, waiver_path_override: nil, clock: Time.now.utc, changed: false, base: nil, handoff_document: nil)
|
|
90
91
|
root = File.realpath(repository_root)
|
|
91
92
|
resolved_config = resolve_config_path(root, config_path)
|
|
92
93
|
configuration = Configuration.load(resolved_config)
|
|
@@ -141,11 +142,45 @@ module RailVerdict
|
|
|
141
142
|
)
|
|
142
143
|
return interrupted_outcome(context: context, configuration: configuration) if interrupted&.call
|
|
143
144
|
|
|
145
|
+
input_paths = effective_input_paths(
|
|
146
|
+
root: root,
|
|
147
|
+
config_path: config_path,
|
|
148
|
+
baseline_path_override: baseline_path_override,
|
|
149
|
+
waiver_path_override: waiver_path_override
|
|
150
|
+
)
|
|
151
|
+
current_repo_state = capture_guard_state(root, input_paths)
|
|
152
|
+
current_env = {
|
|
153
|
+
"ruby_engine" => defined?(RUBY_ENGINE) ? RUBY_ENGINE : "ruby",
|
|
154
|
+
"ruby_version" => RUBY_VERSION,
|
|
155
|
+
"railverdict_version" => RailVerdict::VERSION,
|
|
156
|
+
"analyzer_versions" => analyzer_versions
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
plan = VerificationPlan.build(
|
|
160
|
+
repository_root: root,
|
|
161
|
+
configuration: configuration,
|
|
162
|
+
probes: probes,
|
|
163
|
+
git_context: git_context,
|
|
164
|
+
handoff_document: handoff_document,
|
|
165
|
+
current_repository_state: current_repo_state,
|
|
166
|
+
current_environment: current_env,
|
|
167
|
+
changed: changed
|
|
168
|
+
)
|
|
169
|
+
|
|
144
170
|
analyzer_results = []
|
|
145
171
|
findings = []
|
|
146
172
|
configuration.analyzers.each do |name, selection|
|
|
147
173
|
next unless selection.fetch("enabled")
|
|
148
174
|
|
|
175
|
+
name = name.to_s
|
|
176
|
+
if plan.analyzers_to_reuse.include?(name)
|
|
177
|
+
reused_ar = plan.reused_results[name]
|
|
178
|
+
reused_f = plan.reused_findings[name] || []
|
|
179
|
+
analyzer_results << reused_ar
|
|
180
|
+
findings.concat(reused_f)
|
|
181
|
+
next
|
|
182
|
+
end
|
|
183
|
+
|
|
149
184
|
adapter_class = REGISTRY[name]
|
|
150
185
|
unless adapter_class
|
|
151
186
|
analyzer_results << AnalyzerResult.new(
|
|
@@ -162,13 +197,37 @@ module RailVerdict
|
|
|
162
197
|
probe = probes[name]
|
|
163
198
|
timeout_seconds = resolve_timeout_seconds(configuration, name, analyzer_timeout_seconds)
|
|
164
199
|
begin
|
|
165
|
-
analyzer_result, analyzer_findings =
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
200
|
+
analyzer_result, analyzer_findings = if name == "rspec"
|
|
201
|
+
adapter.run(
|
|
202
|
+
root,
|
|
203
|
+
runner: runner,
|
|
204
|
+
probe_result: probe,
|
|
205
|
+
timeout_seconds: timeout_seconds,
|
|
206
|
+
configuration: configuration,
|
|
207
|
+
target_files: plan.target_files["rspec"],
|
|
208
|
+
test_scope: plan.test_scope,
|
|
209
|
+
fallback_reason: plan.fallback_reasons["rspec"]
|
|
210
|
+
)
|
|
211
|
+
elsif name == "minitest"
|
|
212
|
+
adapter.run(
|
|
213
|
+
root,
|
|
214
|
+
runner: runner,
|
|
215
|
+
probe_result: probe,
|
|
216
|
+
timeout_seconds: timeout_seconds,
|
|
217
|
+
configuration: configuration,
|
|
218
|
+
target_files: plan.target_files["minitest"],
|
|
219
|
+
test_scope: plan.test_scope,
|
|
220
|
+
fallback_reason: plan.fallback_reasons["minitest"]
|
|
221
|
+
)
|
|
222
|
+
else
|
|
223
|
+
adapter.run(
|
|
224
|
+
root,
|
|
225
|
+
runner: runner,
|
|
226
|
+
probe_result: probe,
|
|
227
|
+
timeout_seconds: timeout_seconds,
|
|
228
|
+
configuration: configuration
|
|
229
|
+
)
|
|
230
|
+
end
|
|
172
231
|
rescue StandardError => error
|
|
173
232
|
# Guarded boundary: analyzer normalization must never bypass GateResult
|
|
174
233
|
message = RailVerdict::Analyzers::Shared.bounded_message("#{error.class}: #{error.message}")
|
|
@@ -393,18 +452,28 @@ module RailVerdict
|
|
|
393
452
|
end
|
|
394
453
|
private_class_method :interrupted_outcome
|
|
395
454
|
|
|
396
|
-
def build_adapter(name,
|
|
455
|
+
def build_adapter(name, command_resolver)
|
|
456
|
+
resolver = if command_resolver.is_a?(Hash)
|
|
457
|
+
command_resolver[name] || command_resolver[name.to_sym]
|
|
458
|
+
elsif name == "rubocop"
|
|
459
|
+
command_resolver
|
|
460
|
+
else
|
|
461
|
+
nil
|
|
462
|
+
end
|
|
463
|
+
|
|
397
464
|
case name
|
|
398
465
|
when "rubocop"
|
|
399
|
-
RailVerdict::Analyzers::RuboCop.new(command_resolver:
|
|
466
|
+
RailVerdict::Analyzers::RuboCop.new(command_resolver: resolver)
|
|
400
467
|
when "minitest"
|
|
401
|
-
RailVerdict::Analyzers::Minitest.new
|
|
468
|
+
RailVerdict::Analyzers::Minitest.new(command_resolver: resolver)
|
|
402
469
|
when "rspec"
|
|
403
|
-
RailVerdict::Analyzers::RSpec.new
|
|
470
|
+
RailVerdict::Analyzers::RSpec.new(command_resolver: resolver)
|
|
404
471
|
when "simplecov"
|
|
405
472
|
RailVerdict::Analyzers::SimpleCov.new
|
|
406
473
|
when "bundler_audit"
|
|
407
|
-
RailVerdict::Analyzers::BundlerAudit.new
|
|
474
|
+
RailVerdict::Analyzers::BundlerAudit.new(command_resolver: resolver)
|
|
475
|
+
when "brakeman"
|
|
476
|
+
RailVerdict::Analyzers::Brakeman.new(command_resolver: resolver)
|
|
408
477
|
end
|
|
409
478
|
end
|
|
410
479
|
private_class_method :build_adapter
|