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.
- 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 +3 -2
- data/lib/rail_verdict/analyzers/minitest.rb +92 -43
- data/lib/rail_verdict/analyzers/rspec.rb +151 -44
- data/lib/rail_verdict/analyzers/rubocop.rb +2 -1
- data/lib/rail_verdict/analyzers/simplecov.rb +71 -75
- data/lib/rail_verdict/change_intelligence.rb +274 -0
- data/lib/rail_verdict/change_surfaces.rb +232 -0
- data/lib/rail_verdict/check.rb +84 -15
- data/lib/rail_verdict/cli.rb +640 -5
- 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 +19 -6
- data/lib/rail_verdict/engineering_policy.rb +394 -0
- data/lib/rail_verdict/findings_command.rb +7 -1
- data/lib/rail_verdict/handoff.rb +125 -0
- data/lib/rail_verdict/mcp/cache.rb +10 -0
- data/lib/rail_verdict/mcp/server.rb +15 -1
- data/lib/rail_verdict/mcp/tools/create_handoff.rb +115 -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/inspect_handoff.rb +72 -0
- data/lib/rail_verdict/mcp/tools/verify_handoff.rb +117 -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 +274 -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 +39 -2
- data/lib/rail_verdict/test_selection.rb +186 -0
- data/lib/rail_verdict/verification_environment.rb +20 -51
- 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 +13 -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/verification-handoff-v1.schema.json +69 -0
- data/schemas/workflow-receipt-v1.schema.json +47 -0
- 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
|
data/lib/rail_verdict/mcp.rb
CHANGED
|
@@ -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.
|
|
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, "
|
|
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] }.
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|