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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -1
  3. data/lib/rail_verdict/analyzers/brakeman.rb +309 -0
  4. data/lib/rail_verdict/analyzers/bundler_audit.rb +1 -1
  5. data/lib/rail_verdict/analyzers/minitest.rb +45 -9
  6. data/lib/rail_verdict/analyzers/rspec.rb +35 -6
  7. data/lib/rail_verdict/change_intelligence.rb +274 -0
  8. data/lib/rail_verdict/change_surfaces.rb +232 -0
  9. data/lib/rail_verdict/check.rb +83 -14
  10. data/lib/rail_verdict/cli.rb +294 -6
  11. data/lib/rail_verdict/comparison.rb +18 -2
  12. data/lib/rail_verdict/configuration.rb +12 -2
  13. data/lib/rail_verdict/contracts/analyzer_result.rb +1 -1
  14. data/lib/rail_verdict/contracts/finding.rb +6 -0
  15. data/lib/rail_verdict/doctor.rb +17 -5
  16. data/lib/rail_verdict/engineering_policy.rb +394 -0
  17. data/lib/rail_verdict/findings_command.rb +7 -1
  18. data/lib/rail_verdict/handoff.rb +4 -1
  19. data/lib/rail_verdict/mcp/server.rb +8 -0
  20. data/lib/rail_verdict/mcp/tools/create_workflow_receipt.rb +135 -0
  21. data/lib/rail_verdict/mcp/tools/get_engineering_policy.rb +117 -0
  22. data/lib/rail_verdict/mcp/tools/get_pr_intelligence.rb +1 -1
  23. data/lib/rail_verdict/mcp/tools/get_review_packet.rb +116 -0
  24. data/lib/rail_verdict/mcp/tools/verify_review_observation.rb +120 -0
  25. data/lib/rail_verdict/mcp.rb +12 -0
  26. data/lib/rail_verdict/pr_intelligence.rb +39 -1
  27. data/lib/rail_verdict/rails_context/resolvers/test_candidates.rb +38 -4
  28. data/lib/rail_verdict/repair/verification_plan.rb +33 -1
  29. data/lib/rail_verdict/reporters/console.rb +40 -0
  30. data/lib/rail_verdict/reporters/engineering_policy.rb +50 -0
  31. data/lib/rail_verdict/reporters/github_annotations.rb +6 -1
  32. data/lib/rail_verdict/reporters/pr_intelligence.rb +54 -5
  33. data/lib/rail_verdict/reporters/review.rb +73 -0
  34. data/lib/rail_verdict/reporters/sarif.rb +6 -1
  35. data/lib/rail_verdict/reuse.rb +106 -0
  36. data/lib/rail_verdict/review_observation.rb +95 -0
  37. data/lib/rail_verdict/review_packet.rb +159 -0
  38. data/lib/rail_verdict/schema_validator.rb +34 -2
  39. data/lib/rail_verdict/test_selection.rb +186 -0
  40. data/lib/rail_verdict/verification_environment.rb +16 -7
  41. data/lib/rail_verdict/verification_plan.rb +97 -0
  42. data/lib/rail_verdict/version.rb +1 -1
  43. data/lib/rail_verdict/workflow_receipt.rb +77 -0
  44. data/lib/rail_verdict.rb +11 -0
  45. data/schemas/configuration-v1.5.schema.json +2 -1
  46. data/schemas/configuration-v1.6.schema.json +177 -0
  47. data/schemas/configuration-v1.7.schema.json +499 -0
  48. data/schemas/engineering-policy-v1.schema.json +107 -0
  49. data/schemas/pr-intelligence-v1.1.schema.json +407 -0
  50. data/schemas/repair-packet-v1.schema.json +42 -0
  51. data/schemas/review-observation-v1.schema.json +68 -0
  52. data/schemas/review-packet-v1.schema.json +175 -0
  53. data/schemas/workflow-receipt-v1.schema.json +47 -0
  54. 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
@@ -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 = adapter.run(
166
- root,
167
- runner: runner,
168
- probe_result: probe,
169
- timeout_seconds: timeout_seconds,
170
- configuration: configuration
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, rubocop_command_resolver)
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: rubocop_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