rail_verdict 1.3.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 (61) 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 +3 -2
  5. data/lib/rail_verdict/analyzers/minitest.rb +92 -43
  6. data/lib/rail_verdict/analyzers/rspec.rb +151 -44
  7. data/lib/rail_verdict/analyzers/rubocop.rb +2 -1
  8. data/lib/rail_verdict/analyzers/simplecov.rb +71 -75
  9. data/lib/rail_verdict/change_intelligence.rb +274 -0
  10. data/lib/rail_verdict/change_surfaces.rb +232 -0
  11. data/lib/rail_verdict/check.rb +84 -15
  12. data/lib/rail_verdict/cli.rb +640 -5
  13. data/lib/rail_verdict/comparison.rb +18 -2
  14. data/lib/rail_verdict/configuration.rb +12 -2
  15. data/lib/rail_verdict/contracts/analyzer_result.rb +1 -1
  16. data/lib/rail_verdict/contracts/finding.rb +6 -0
  17. data/lib/rail_verdict/doctor.rb +19 -6
  18. data/lib/rail_verdict/engineering_policy.rb +394 -0
  19. data/lib/rail_verdict/findings_command.rb +7 -1
  20. data/lib/rail_verdict/handoff.rb +125 -0
  21. data/lib/rail_verdict/mcp/cache.rb +10 -0
  22. data/lib/rail_verdict/mcp/server.rb +15 -1
  23. data/lib/rail_verdict/mcp/tools/create_handoff.rb +115 -0
  24. data/lib/rail_verdict/mcp/tools/create_workflow_receipt.rb +135 -0
  25. data/lib/rail_verdict/mcp/tools/get_engineering_policy.rb +117 -0
  26. data/lib/rail_verdict/mcp/tools/get_pr_intelligence.rb +1 -1
  27. data/lib/rail_verdict/mcp/tools/get_review_packet.rb +116 -0
  28. data/lib/rail_verdict/mcp/tools/inspect_handoff.rb +72 -0
  29. data/lib/rail_verdict/mcp/tools/verify_handoff.rb +117 -0
  30. data/lib/rail_verdict/mcp/tools/verify_review_observation.rb +120 -0
  31. data/lib/rail_verdict/mcp.rb +12 -0
  32. data/lib/rail_verdict/pr_intelligence.rb +39 -1
  33. data/lib/rail_verdict/rails_context/resolvers/test_candidates.rb +38 -4
  34. data/lib/rail_verdict/repair/verification_plan.rb +33 -1
  35. data/lib/rail_verdict/reporters/console.rb +40 -0
  36. data/lib/rail_verdict/reporters/engineering_policy.rb +50 -0
  37. data/lib/rail_verdict/reporters/github_annotations.rb +6 -1
  38. data/lib/rail_verdict/reporters/pr_intelligence.rb +54 -5
  39. data/lib/rail_verdict/reporters/review.rb +73 -0
  40. data/lib/rail_verdict/reporters/sarif.rb +6 -1
  41. data/lib/rail_verdict/reuse.rb +274 -0
  42. data/lib/rail_verdict/review_observation.rb +95 -0
  43. data/lib/rail_verdict/review_packet.rb +159 -0
  44. data/lib/rail_verdict/schema_validator.rb +39 -2
  45. data/lib/rail_verdict/test_selection.rb +186 -0
  46. data/lib/rail_verdict/verification_environment.rb +20 -51
  47. data/lib/rail_verdict/verification_plan.rb +97 -0
  48. data/lib/rail_verdict/version.rb +1 -1
  49. data/lib/rail_verdict/workflow_receipt.rb +77 -0
  50. data/lib/rail_verdict.rb +13 -0
  51. data/schemas/configuration-v1.5.schema.json +2 -1
  52. data/schemas/configuration-v1.6.schema.json +177 -0
  53. data/schemas/configuration-v1.7.schema.json +499 -0
  54. data/schemas/engineering-policy-v1.schema.json +107 -0
  55. data/schemas/pr-intelligence-v1.1.schema.json +407 -0
  56. data/schemas/repair-packet-v1.schema.json +42 -0
  57. data/schemas/review-observation-v1.schema.json +68 -0
  58. data/schemas/review-packet-v1.schema.json +175 -0
  59. data/schemas/verification-handoff-v1.schema.json +69 -0
  60. data/schemas/workflow-receipt-v1.schema.json +47 -0
  61. metadata +29 -1
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailVerdict
4
+ module MCP
5
+ module Tools
6
+ class InspectHandoff
7
+ def initialize(server:)
8
+ @server = server
9
+ end
10
+
11
+ def tool_name
12
+ "inspect_handoff"
13
+ end
14
+
15
+ def tool_title
16
+ "Inspect verification handoff"
17
+ end
18
+
19
+ def tool_description
20
+ "Parse and validate a Verification Handoff. Bounded, deterministic, tamper-detected via handoff_id. Does not execute analyzers."
21
+ end
22
+
23
+ def tool_input_schema
24
+ {
25
+ type: "object",
26
+ properties: {
27
+ handoff: { type: "object", description: "Handoff document to inspect" },
28
+ handoff_json: { type: "string", description: "Handoff JSON string (alternative to handoff object)" }
29
+ },
30
+ additionalProperties: false
31
+ }
32
+ end
33
+
34
+ def tool_output_schema
35
+ {
36
+ type: "object",
37
+ properties: {
38
+ valid: { type: "boolean" },
39
+ handoff: { type: ["object", "null"] },
40
+ error: { type: "string" },
41
+ handoff_id: { type: "string" }
42
+ },
43
+ required: %w[valid]
44
+ }
45
+ end
46
+
47
+ def tool_annotations
48
+ { read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false, title: tool_title }
49
+ end
50
+
51
+ def call(handoff: nil, handoff_json: nil, **_rest)
52
+ text = if handoff_json && !handoff_json.to_s.strip.empty?
53
+ handoff_json.to_s
54
+ elsif handoff
55
+ JSON.generate(handoff)
56
+ else
57
+ return Serializers.error_response("handoff or handoff_json is required", code: "invalid_arguments")
58
+ end
59
+ return Serializers.error_response("handoff too large", code: "handoff_too_large") if text.bytesize > Handoff::MAX_DOCUMENT_BYTES
60
+ doc, err = Handoff.parse(text)
61
+ if doc
62
+ Serializers.tool_response({ "valid" => true, "handoff" => Serializers.scrub(doc), "handoff_id" => doc["handoff_id"] }, error: false)
63
+ else
64
+ Serializers.tool_response({ "valid" => false, "error" => err.to_s, "handoff" => nil }, error: false)
65
+ end
66
+ rescue StandardError => e
67
+ Serializers.error_response("inspect_handoff failed: #{e.message}", code: "internal_error")
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailVerdict
4
+ module MCP
5
+ module Tools
6
+ class VerifyHandoff
7
+ def initialize(server:)
8
+ @server = server
9
+ end
10
+
11
+ def tool_name
12
+ "verify_handoff"
13
+ end
14
+
15
+ def tool_title
16
+ "Verify handoff reuse"
17
+ end
18
+
19
+ def tool_description
20
+ "Evaluate whether a Verification Handoff is REUSABLE under current repository state/environment. Re-observes state/environment independently, never trusts Handoff. Returns REUSABLE or VERIFICATION_REQUIRED with reasons. Does not execute analyzers."
21
+ end
22
+
23
+ def tool_input_schema
24
+ {
25
+ type: "object",
26
+ properties: {
27
+ handoff: { type: "object", description: "Handoff document" },
28
+ handoff_json: { type: "string", description: "Handoff JSON string" },
29
+ config_path: { type: "string" },
30
+ baseline_path: { type: "string" },
31
+ waiver_path: { type: "string" }
32
+ },
33
+ additionalProperties: false
34
+ }
35
+ end
36
+
37
+ def tool_output_schema
38
+ {
39
+ type: "object",
40
+ properties: {
41
+ handoff_valid: { type: "boolean" },
42
+ receipt_fresh: { type: "boolean" },
43
+ decision: { type: "string", enum: %w[REUSABLE VERIFICATION_REQUIRED INVALID UNAVAILABLE] },
44
+ reasons: { type: "array" },
45
+ handoff_id: { type: "string" },
46
+ receipt_id: { type: "string" }
47
+ },
48
+ required: %w[decision]
49
+ }
50
+ end
51
+
52
+ def tool_annotations
53
+ { read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false, title: tool_title }
54
+ end
55
+
56
+ def call(handoff: nil, handoff_json: nil, config_path: nil, baseline_path: nil, waiver_path: nil, **_rest)
57
+ text = if handoff_json && !handoff_json.to_s.strip.empty?
58
+ handoff_json.to_s
59
+ elsif handoff
60
+ JSON.generate(handoff)
61
+ else
62
+ return Serializers.error_response("handoff or handoff_json required", code: "invalid_arguments")
63
+ end
64
+ return Serializers.error_response("handoff too large", code: "handoff_too_large") if text.bytesize > Handoff::MAX_DOCUMENT_BYTES
65
+ doc, err = Handoff.parse(text)
66
+ unless doc
67
+ return Serializers.tool_response({ "handoff_valid" => false, "receipt_fresh" => false, "decision" => Reuse::INVALID, "reasons" => [err.to_s] }, error: false)
68
+ end
69
+
70
+ root = @server.repository_root
71
+ effective_paths = Check.effective_input_paths(root: File.realpath(root), config_path: resolve_config_path(config_path), baseline_path_override: resolve_contained_path(baseline_path, "baseline_path"), waiver_path_override: resolve_contained_path(waiver_path, "waiver_path"))
72
+ before_state = RepositoryState.capture(repository_root: root, configuration_paths: effective_paths)
73
+ current_env_obj = VerificationEnvironment.capture(repository_root: root)
74
+ current_env = {
75
+ "ruby_engine" => current_env_obj.ruby_engine,
76
+ "ruby_version" => current_env_obj.ruby_version,
77
+ "railverdict_version" => current_env_obj.railverdict_version,
78
+ "analyzer_versions" => current_env_obj.analyzer_versions
79
+ }
80
+ config = Configuration.load(effective_paths[:config]) rescue nil
81
+ required = config ? config.analyzers.select { |_, s| s["enabled"] && s["required"] }.keys.map(&:to_s) : []
82
+ contract = { "required_analyzers" => required, "verification_mode" => doc.dig("source_scope", "verification_mode"), "changed_base" => doc.dig("source_scope", "changed_base") }
83
+ result = Reuse.evaluate(handoff_document: doc, current_repository_state: before_state, current_environment: current_env, current_contract: contract)
84
+ after_state = RepositoryState.capture(repository_root: root, configuration_paths: effective_paths)
85
+ if before_state && after_state && before_state.digest != after_state.digest
86
+ result = Reuse::Result.new(decision: Reuse::VERIFICATION_REQUIRED, reasons: result.reasons + ["reuse_toctou"], handoff_valid: result.handoff_valid, receipt_fresh: result.receipt_fresh)
87
+ end
88
+ Serializers.tool_response({ "handoff_valid" => result.handoff_valid, "receipt_fresh" => result.receipt_fresh, "decision" => result.decision, "reasons" => result.reasons, "handoff_id" => doc["handoff_id"], "receipt_id" => doc.dig("receipt", "receipt_id") }, error: false)
89
+ rescue ArgumentError => e
90
+ Serializers.error_response(e.message, code: "invalid_arguments")
91
+ rescue StandardError => e
92
+ Serializers.error_response("verify_handoff failed: #{e.message}", code: "internal_error")
93
+ end
94
+
95
+ private
96
+
97
+ def resolve_contained_path(raw_path, field_name)
98
+ return nil if raw_path.nil? || raw_path.to_s.strip.empty?
99
+ raw = raw_path.to_s.strip
100
+ raise ArgumentError, "#{field_name} contains NUL byte" if raw.include?("\u0000")
101
+ expanded = File.expand_path(raw, @server.repository_root)
102
+ raise ArgumentError, "#{field_name} escapes repository root" unless RepositoryRoot.contained?(@server.repository_root, expanded)
103
+ expanded
104
+ end
105
+
106
+ def resolve_config_path(config_path)
107
+ return File.join(@server.repository_root, ".railverdict.yml") if config_path.nil? || config_path.to_s.strip.empty?
108
+ raw = config_path.to_s.strip
109
+ raise ArgumentError, "config_path contains NUL byte" if raw.include?("\u0000")
110
+ expanded = File.expand_path(raw, @server.repository_root)
111
+ raise ArgumentError, "config_path escapes repository root" unless RepositoryRoot.contained?(@server.repository_root, expanded)
112
+ expanded
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../serializers"
4
+
5
+ module RailVerdict
6
+ module MCP
7
+ module Tools
8
+ # Validate an outside observation against freshly re-observed state.
9
+ # Gate-neutral by construction: proves reference to a state, never
10
+ # correctness of the conclusion, and alters nothing.
11
+ class VerifyReviewObservation
12
+ MAX_OBSERVATION_BYTES = 256 * 1024
13
+
14
+ def initialize(server:)
15
+ @server = server
16
+ end
17
+
18
+ def tool_name
19
+ "verify_review_observation"
20
+ end
21
+
22
+ def tool_title
23
+ "Verify review observation"
24
+ end
25
+
26
+ def tool_description
27
+ "Validate a review observation document against the current repository state, configuration, and policy. Returns valid_bound, stale, untrusted, invalid, or unavailable. Validation never changes any gate, requirement, finding, receipt, or handoff."
28
+ end
29
+
30
+ def tool_input_schema
31
+ {
32
+ type: "object",
33
+ properties: {
34
+ observation: { type: "object" }
35
+ },
36
+ required: %w[observation],
37
+ additionalProperties: false
38
+ }
39
+ end
40
+
41
+ def tool_output_schema
42
+ {
43
+ type: "object",
44
+ properties: {
45
+ status: { type: "string", enum: %w[valid_bound stale untrusted invalid unavailable] },
46
+ observation_id: { type: ["string", "null"] },
47
+ code: { type: "string" },
48
+ message: { type: "string" }
49
+ },
50
+ required: %w[status]
51
+ }
52
+ end
53
+
54
+ def tool_annotations
55
+ { read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false, title: tool_title }
56
+ end
57
+
58
+ def call(observation: nil, **_rest)
59
+ unless observation.is_a?(Hash)
60
+ return Serializers.tool_response(
61
+ { "status" => "invalid", "code" => "observation_malformed",
62
+ "message" => "observation must be an object" }, error: false)
63
+ end
64
+ begin
65
+ normalized = JSON.parse(JSON.generate(observation))
66
+ rescue StandardError
67
+ return Serializers.tool_response(
68
+ { "status" => "invalid", "code" => "observation_malformed",
69
+ "message" => "observation is not JSON-serializable" }, error: false)
70
+ end
71
+ if JSON.generate(normalized).bytesize > MAX_OBSERVATION_BYTES
72
+ return Serializers.tool_response(
73
+ { "status" => "invalid", "code" => "observation_too_large",
74
+ "message" => "observation exceeds 256 KiB" }, error: false)
75
+ end
76
+ observation = normalized
77
+
78
+ root = @server.repository_root
79
+ outcome = begin
80
+ @server.cache.fetch_outcome
81
+ rescue StandardError
82
+ nil
83
+ end
84
+ configuration = outcome&.configuration
85
+ if configuration.nil?
86
+ begin
87
+ configuration = RailVerdict::Configuration.load(File.join(File.realpath(root), ".railverdict.yml"))
88
+ rescue StandardError
89
+ configuration = nil
90
+ end
91
+ end
92
+ if configuration.nil?
93
+ return Serializers.tool_response(
94
+ { "status" => "unavailable", "code" => "observation_configuration_unavailable",
95
+ "message" => "observation binding requires a readable configuration file" }, error: false)
96
+ end
97
+
98
+ effective_paths = begin
99
+ RailVerdict::Check.effective_input_paths(root: File.realpath(root), config_path: File.join(File.realpath(root), ".railverdict.yml"))
100
+ rescue StandardError
101
+ nil
102
+ end
103
+ current_state = RailVerdict::RepositoryState.capture(repository_root: root, configuration_paths: effective_paths)
104
+ components = current_state.projection&.fetch("components", nil)
105
+ head = components.is_a?(Hash) ? components["head"] : nil
106
+ current = {
107
+ "head" => head,
108
+ "configuration_digest" => configuration.digest,
109
+ "policy_digest" => RailVerdict::EngineeringPolicy.policy_digest(
110
+ RailVerdict::EngineeringPolicy.effective_policy(configuration))
111
+ }
112
+ verdict = RailVerdict::ReviewObservation.validate(observation: observation, current: current)
113
+ Serializers.tool_response(Serializers.scrub(verdict), error: false)
114
+ rescue StandardError => e
115
+ Serializers.error_response("verify_review_observation failed: #{e.message}", code: "internal_error")
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -5,3 +5,15 @@ require_relative "mcp/validators"
5
5
  require_relative "mcp/serializers"
6
6
  require_relative "mcp/cache"
7
7
  require_relative "mcp/server"
8
+ require_relative "mcp/tools/verify"
9
+ require_relative "mcp/tools/list_findings"
10
+ require_relative "mcp/tools/get_finding"
11
+ require_relative "mcp/tools/build_repair_packet"
12
+ require_relative "mcp/tools/verify_repair"
13
+ require_relative "mcp/tools/explain"
14
+ require_relative "mcp/tools/investigate"
15
+ require_relative "mcp/tools/get_verification_receipt"
16
+ require_relative "mcp/tools/get_pr_intelligence"
17
+ require_relative "mcp/tools/create_handoff"
18
+ require_relative "mcp/tools/inspect_handoff"
19
+ require_relative "mcp/tools/verify_handoff"
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require_relative "change_surfaces"
5
+ require_relative "change_intelligence"
4
6
 
5
7
  module RailVerdict
6
8
  module PRIntelligence
7
- SCHEMA_VERSION = "1.0"
9
+ SCHEMA_VERSION = "1.1"
10
+ PREVIOUS_SCHEMA_VERSION = "1.0"
8
11
  MAX_SIGNAL_EVIDENCE = 20
9
12
  SIGNALS = {
10
13
  "database_change" => lambda { |path| path == "db/schema.rb" || path == "db/structure.sql" || path.start_with?("db/migrate/") },
@@ -23,10 +26,25 @@ module RailVerdict
23
26
 
24
27
  module_function
25
28
 
29
+ # Canonical change intelligence model. v1.0 keys are unchanged; v1.1
30
+ # appends surfaces, review signals/risk, verification scope,
31
+ # missing evidence, and review focus. Read-only over the outcome:
32
+ # GateResult authority is never touched here.
26
33
  def document(outcome)
27
34
  result = outcome.result
28
35
  git_context = outcome.context&.git_context
29
36
  git = result.git || {}
37
+ paths = ChangeIntelligence.changed_paths(git_context)
38
+ available = !git_context.nil?
39
+ review_config = outcome.respond_to?(:configuration) ? outcome.configuration&.review_config : nil
40
+ review_config = {} unless review_config.is_a?(Hash)
41
+ surfaces = ChangeSurfaces.detect(paths, available: available)
42
+ project_areas = ChangeSurfaces.project_sensitive(
43
+ paths, review_config["sensitive_paths"], available: available
44
+ )
45
+ change_signals = review_signals(surfaces, project_areas)
46
+ scope = verification_scope(outcome, git_context)
47
+ missing = ChangeIntelligence.missing_evidence(outcome, surfaces, scope)
30
48
 
31
49
  document = {
32
50
  "schema_version" => SCHEMA_VERSION,
@@ -34,6 +52,13 @@ module RailVerdict
34
52
  "gate_result" => gate_result(result),
35
53
  "change" => change(git_context),
36
54
  "signals" => signals(git_context),
55
+ "surfaces" => surfaces,
56
+ "project_sensitive_areas" => project_areas,
57
+ "review_signals" => change_signals,
58
+ "review_risk" => ChangeIntelligence.review_risk(change_signals, review_config["risk"]),
59
+ "verification_scope" => scope,
60
+ "missing_evidence" => missing,
61
+ "review_focus" => ChangeIntelligence.review_focus(surfaces, project_areas, available ? paths : []),
37
62
  "quality_delta" => quality_delta(result),
38
63
  "analyzer_evidence" => analyzer_evidence(result),
39
64
  "test_intelligence" => test_intelligence(result),
@@ -74,6 +99,19 @@ module RailVerdict
74
99
  end
75
100
  end
76
101
 
102
+ def review_signals(surfaces, project_areas)
103
+ ChangeIntelligence.review_signals(surfaces, project_areas)
104
+ end
105
+ private_class_method :review_signals
106
+
107
+ def verification_scope(outcome, git_context)
108
+ context = outcome.context
109
+ root = context.respond_to?(:repository_root) ? context.repository_root : nil
110
+ ChangeIntelligence.verification_scope(outcome.result, repository_root: root, git_context: git_context)
111
+ end
112
+ private_class_method :verification_scope
113
+
114
+
77
115
  def provenance(outcome, git_context, git)
78
116
  context = outcome.context
79
117
  {
@@ -5,7 +5,7 @@ module RailVerdict
5
5
  module Resolvers
6
6
  module TestCandidates
7
7
  def self.for(repository_root:, kind:, path:)
8
- basename = File.basename(path, ".rb")
8
+ basename = File.basename(path, ".*")
9
9
  dir = File.dirname(path)
10
10
  candidates = []
11
11
 
@@ -20,6 +20,7 @@ module RailVerdict
20
20
  candidates << candidate_path(repository_root, "test", "controllers", relative, basename)
21
21
  candidates << candidate_path(repository_root, "spec", "requests", relative, clean)
22
22
  candidates << candidate_path(repository_root, "spec", "controllers", relative, basename)
23
+ candidates << candidate_path(repository_root, "spec", "requests", relative, "#{clean}_spec")
23
24
  when "job"
24
25
  relative = dir.delete_prefix("app/jobs").delete_prefix("/")
25
26
  candidates << candidate_path(repository_root, "test", "jobs", relative, basename)
@@ -36,19 +37,52 @@ module RailVerdict
36
37
  relative = dir.delete_prefix("app/services").delete_prefix("/")
37
38
  candidates << candidate_path(repository_root, "test", "services", relative, basename)
38
39
  candidates << candidate_path(repository_root, "spec", "services", relative, basename)
40
+ when "policy"
41
+ clean = basename.delete_suffix("_policy")
42
+ relative = dir.delete_prefix("app/policies").delete_prefix("/")
43
+ candidates << candidate_path(repository_root, "test", "policies", relative, basename)
44
+ candidates << candidate_path(repository_root, "spec", "policies", relative, basename)
45
+ candidates << candidate_path(repository_root, "spec", "policies", relative, clean)
46
+ when "component"
47
+ clean = basename.delete_suffix("_component")
48
+ relative = dir.delete_prefix("app/components").delete_prefix("/")
49
+ candidates << candidate_path(repository_root, "test", "components", relative, basename)
50
+ candidates << candidate_path(repository_root, "spec", "components", relative, basename)
51
+ candidates << candidate_path(repository_root, "spec", "components", relative, clean)
52
+ when "view"
53
+ relative = dir.delete_prefix("app/views").delete_prefix("/")
54
+ controller_dir = relative.split("/").first
55
+ candidates << candidate_path(repository_root, "spec", "views", relative, basename)
56
+ candidates << candidate_path(repository_root, "spec", "system", controller_dir || "", "#{controller_dir}_spec") if controller_dir
57
+ candidates << candidate_path(repository_root, "spec", "requests", controller_dir || "", "#{controller_dir}_spec") if controller_dir
58
+ when "lib"
59
+ relative = dir.delete_prefix("lib").delete_prefix("/")
60
+ candidates << candidate_path(repository_root, "spec", "lib", relative, basename)
61
+ candidates << candidate_path(repository_root, "spec", "", relative, basename)
62
+ candidates << candidate_path(repository_root, "test", "lib", relative, basename)
63
+ candidates << candidate_path(repository_root, "test", "", relative, basename)
64
+ when "spec"
65
+ if path.end_with?("_spec.rb") && safe_exists?(repository_root, File.join(repository_root, path))
66
+ candidates << { path: path, exists: true }
67
+ end
68
+ when "test"
69
+ if path.end_with?("_test.rb") && safe_exists?(repository_root, File.join(repository_root, path))
70
+ candidates << { path: path, exists: true }
71
+ end
39
72
  else
40
73
  return []
41
74
  end
42
75
 
43
- candidates.compact.select { |entry| entry[:exists] }.first(2).map do |entry|
76
+ candidates.compact.select { |entry| entry[:exists] }.map do |entry|
44
77
  { "path" => entry[:path], "relationship" => "related_test", "confidence" => "conventional", "provenance" => "file_exists:#{entry[:path]}" }
45
78
  end
46
79
  end
47
80
 
48
81
  def self.candidate_path(root, framework, kind_dir, relative, basename)
49
82
  rel = relative.empty? ? "" : "#{relative}/"
50
- filename = framework == "test" ? "#{basename}_test.rb" : "#{basename}_spec.rb"
51
- repo_relative = "#{framework}/#{kind_dir}/#{rel}#{filename}".gsub(%r{//+}, "/")
83
+ clean_base = basename.delete_suffix("_spec").delete_suffix("_test")
84
+ filename = framework == "test" ? "#{clean_base}_test.rb" : "#{clean_base}_spec.rb"
85
+ repo_relative = "#{framework}/#{kind_dir}/#{rel}#{filename}".gsub(%r{//+}, "/").delete_prefix("/")
52
86
  full = File.join(root, repo_relative)
53
87
  exists = safe_exists?(root, full)
54
88
  return nil unless exists
@@ -6,7 +6,39 @@ module RailVerdict
6
6
  def self.build(outcome:, repository_root: nil, base_revision: nil)
7
7
  required = [required_check(base_revision: base_revision)]
8
8
  suggested = suggested_commands(outcome)
9
- { "required" => required, "suggested" => suggested }
9
+ plan = { "required" => required, "suggested" => suggested }
10
+ observed = observed_execution(outcome)
11
+ plan["execution"] = observed[:execution] unless observed[:execution].nil?
12
+ plan["requirements"] = observed[:requirements] unless observed[:requirements].nil?
13
+ plan["reasons"] = observed[:reasons] unless observed[:reasons].nil?
14
+ plan
15
+ end
16
+
17
+ # Observed facts about the verification that produced this packet: which
18
+ # analyzers executed, per-framework test scope, and policy requirement
19
+ # statuses. Never a prediction; re-running `required` re-plans via the
20
+ # canonical engine. Best-effort: absent on outcomes that cannot project.
21
+ def self.observed_execution(outcome)
22
+ document = PRIntelligence.document(outcome)
23
+ policy = EngineeringPolicy.evaluate(outcome: outcome, pr_document: document)
24
+ scope = document["verification_scope"].is_a?(Hash) ? document["verification_scope"] : {}
25
+ frameworks = scope["frameworks"].is_a?(Hash) ? scope["frameworks"] : {}
26
+ evidence = Array(document["analyzer_evidence"])
27
+ {
28
+ execution: {
29
+ "analyzers_executed" => evidence.select { |entry| entry["evidence_status"] == "complete" }
30
+ .map { |entry| entry["analyzer"].to_s }.sort,
31
+ "test_scope" => frameworks.to_h do |name, entry|
32
+ [name.to_s, { "executed" => entry["executed"] == true, "scope" => entry["scope"].to_s }]
33
+ end
34
+ },
35
+ requirements: Array(policy["requirements"]).map do |entry|
36
+ { "id" => entry["id"], "status" => entry["status"] }
37
+ end.sort_by { |entry| entry["id"] },
38
+ reasons: Array(policy["reason_codes"]).sort
39
+ }
40
+ rescue RailVerdict::Error, ArgumentError, NoMethodError
41
+ {}
10
42
  end
11
43
 
12
44
  def self.required_check(base_revision: nil)
@@ -88,6 +88,8 @@ module RailVerdict
88
88
  sanitize(rc["scope"] || "unknown"))
89
89
  end
90
90
 
91
+ append_change_intelligence(lines, result)
92
+
91
93
  lines << ""
92
94
  lines << "Gate: #{result.gate}"
93
95
  lines << "Policy: #{result.policy_status}"
@@ -102,6 +104,44 @@ module RailVerdict
102
104
  value.to_s.gsub(/[[:cntrl:]]/) { |character| character == "\n" ? " " : "?" }
103
105
  end
104
106
  private_class_method :sanitize
107
+
108
+ # Compact reviewer summary for changed-scope runs. Path-derived only;
109
+ # verification scope reflects executed analyzer evidence.
110
+ def append_change_intelligence(lines, result)
111
+ git = result.git
112
+ return unless git.is_a?(Hash) && git["error"].nil?
113
+
114
+ paths = Array(git["changed_files"]).map { |file| file["path"] }.compact
115
+ surfaces = RailVerdict::ChangeSurfaces.detect(paths, available: true)
116
+ signals = RailVerdict::ChangeIntelligence.review_signals(surfaces, [])
117
+ risk = RailVerdict::ChangeIntelligence.review_risk(signals, {})
118
+ lines << ""
119
+ lines << "Review risk: #{risk.fetch('level')}"
120
+ lines << " reasons: #{risk.fetch('reasons').join(', ')}" unless risk.fetch("reasons").empty?
121
+ sensitive = surfaces.select { |_, entry| entry["changed"] && entry["sensitive"] }
122
+ lines << "Sensitive surfaces: #{sensitive.empty? ? 'none' : sensitive.map { |id, _| sanitize(RailVerdict::ChangeSurfaces::SURFACES[id]['label']) }.join(', ')}"
123
+ scopes = executed_test_scopes(result)
124
+ lines << "Tests: #{scopes.empty? ? 'no test evidence' : scopes.join('; ')}"
125
+ focus = RailVerdict::ChangeIntelligence.review_focus(surfaces, [], paths)
126
+ unless focus.empty?
127
+ lines << "Reviewer focus:"
128
+ focus.first(3).each { |item| lines << " #{item.fetch('rank')}. #{sanitize(item.fetch('title'))}" }
129
+ end
130
+ nil
131
+ end
132
+ private_class_method :append_change_intelligence
133
+
134
+
135
+ def executed_test_scopes(result)
136
+ result.analyzer_results.filter_map do |analyzer|
137
+ next unless %w[rspec minitest].include?(analyzer.analyzer)
138
+
139
+ summary = analyzer.evidence_summary
140
+ scope = summary.is_a?(Hash) ? summary["test_scope"] : nil
141
+ "#{analyzer.analyzer} #{scope || analyzer.execution_status}"
142
+ end
143
+ end
144
+ private_class_method :executed_test_scopes
105
145
  end
106
146
  end
107
147
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailVerdict
4
+ module Reporters
5
+ # Bounded console projection of an engineering-policy-v1 envelope.
6
+ # Presentation only: requirement identity lives in the JSON document.
7
+ module EngineeringPolicy
8
+ MAX_OBSERVED = 5
9
+
10
+ module_function
11
+
12
+ def render(document)
13
+ lines = []
14
+ lines << "Engineering policy: #{document['decision']} (gate #{document['gate']})"
15
+ lines << "Policy: #{short(document['policy_digest'])} Config: #{short(document['configuration_digest'])}"
16
+ requirements = document["requirements"] || []
17
+ if requirements.empty?
18
+ lines << "Requirements: none configured; decision mirrors the deterministic gate."
19
+ else
20
+ lines << "Requirements:"
21
+ requirements.each do |entry|
22
+ lines << " [#{entry['status']}] #{entry['id']} — #{entry['trigger']}"
23
+ observed = Array(entry["observed_evidence"])
24
+ unless observed.empty?
25
+ head = observed.first(MAX_OBSERVED)
26
+ head.each { |line| lines << " #{truncate(line.to_s)}" }
27
+ extra = observed.length - head.length
28
+ lines << " (+#{extra} more)" if extra.positive?
29
+ end
30
+ lines << " recovery: #{entry['recovery_action']}" if entry["recovery_action"]
31
+ end
32
+ end
33
+ drift = document["drift"]
34
+ lines << "Policy drift: #{drift['status']} (#{drift['code']})" if drift.is_a?(Hash)
35
+ lines << "Reason codes: #{Array(document['reason_codes']).join(', ')}"
36
+ "#{lines.join("\n")}\n"
37
+ end
38
+
39
+ def short(digest)
40
+ digest.is_a?(String) ? digest[0, 12] : "unknown"
41
+ end
42
+ private_class_method :short
43
+
44
+ def truncate(text)
45
+ text.length > 160 ? "#{text[0, 157]}..." : text
46
+ end
47
+ private_class_method :truncate
48
+ end
49
+ end
50
+ end
@@ -21,7 +21,12 @@ module RailVerdict
21
21
  file_part = "file=#{escape_property(path)}"
22
22
  line_part = line ? ",line=#{line}" : ""
23
23
  col_part = col ? ",col=#{col}" : ""
24
- title = escape_data("#{analyzer}:#{rule_id}")
24
+ title_rule = if rule_id.to_s.start_with?("#{analyzer}:") || rule_id.to_s.start_with?("#{analyzer}/")
25
+ rule_id.to_s
26
+ else
27
+ "#{analyzer}:#{rule_id}"
28
+ end
29
+ title = escape_data(title_rule)
25
30
  msg = escape_data("#{message} [#{finding['state'] || finding[:state] || 'observed'}]")
26
31
  lines << "::#{level} #{file_part}#{line_part}#{col_part},title=#{title}::#{msg}"
27
32
  end