rail_verdict 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/NOTICE +14 -0
- data/README.md +192 -0
- data/exe/railverdict +6 -0
- data/exe/railverdict-minitest-reporter.rb +90 -0
- data/lib/rail_verdict/analyzers/_shared.rb +54 -0
- data/lib/rail_verdict/analyzers/bundler_audit.rb +186 -0
- data/lib/rail_verdict/analyzers/minitest.rb +312 -0
- data/lib/rail_verdict/analyzers/rspec.rb +274 -0
- data/lib/rail_verdict/analyzers/rubocop.rb +289 -0
- data/lib/rail_verdict/analyzers/simplecov.rb +178 -0
- data/lib/rail_verdict/baseline.rb +151 -0
- data/lib/rail_verdict/check.rb +415 -0
- data/lib/rail_verdict/cli.rb +471 -0
- data/lib/rail_verdict/comparison.rb +216 -0
- data/lib/rail_verdict/configuration.rb +165 -0
- data/lib/rail_verdict/contracts/analyzer_result.rb +138 -0
- data/lib/rail_verdict/contracts/finding.rb +154 -0
- data/lib/rail_verdict/contracts/gate_result.rb +171 -0
- data/lib/rail_verdict/coverage/changed_line_evaluator.rb +70 -0
- data/lib/rail_verdict/doctor.rb +132 -0
- data/lib/rail_verdict/errors.rb +17 -0
- data/lib/rail_verdict/findings_command.rb +24 -0
- data/lib/rail_verdict/fingerprint.rb +72 -0
- data/lib/rail_verdict/git/command.rb +48 -0
- data/lib/rail_verdict/git/context.rb +361 -0
- data/lib/rail_verdict/git/diff_parser.rb +200 -0
- data/lib/rail_verdict/git/errors.rb +16 -0
- data/lib/rail_verdict/init.rb +38 -0
- data/lib/rail_verdict/intelligence/ai_analysis.rb +82 -0
- data/lib/rail_verdict/intelligence/ai_failure.rb +44 -0
- data/lib/rail_verdict/intelligence/budget.rb +36 -0
- data/lib/rail_verdict/intelligence/cache.rb +74 -0
- data/lib/rail_verdict/intelligence/context_builder.rb +92 -0
- data/lib/rail_verdict/intelligence/manifest.rb +70 -0
- data/lib/rail_verdict/intelligence/orchestrator.rb +77 -0
- data/lib/rail_verdict/intelligence/prompt.rb +28 -0
- data/lib/rail_verdict/intelligence/provider.rb +14 -0
- data/lib/rail_verdict/intelligence/providers/fake_provider.rb +31 -0
- data/lib/rail_verdict/intelligence/providers/openai_compat.rb +119 -0
- data/lib/rail_verdict/intelligence/redactor.rb +51 -0
- data/lib/rail_verdict/intelligence/secret_detector.rb +45 -0
- data/lib/rail_verdict/intelligence/source_reader.rb +49 -0
- data/lib/rail_verdict/intelligence.rb +11 -0
- data/lib/rail_verdict/mcp/cache.rb +233 -0
- data/lib/rail_verdict/mcp/repository_root.rb +31 -0
- data/lib/rail_verdict/mcp/serializers.rb +98 -0
- data/lib/rail_verdict/mcp/server.rb +110 -0
- data/lib/rail_verdict/mcp/tools/build_repair_packet.rb +85 -0
- data/lib/rail_verdict/mcp/tools/explain.rb +100 -0
- data/lib/rail_verdict/mcp/tools/get_finding.rb +139 -0
- data/lib/rail_verdict/mcp/tools/investigate.rb +107 -0
- data/lib/rail_verdict/mcp/tools/list_findings.rb +119 -0
- data/lib/rail_verdict/mcp/tools/verify.rb +137 -0
- data/lib/rail_verdict/mcp/tools/verify_repair.rb +105 -0
- data/lib/rail_verdict/mcp/validators.rb +81 -0
- data/lib/rail_verdict/mcp.rb +7 -0
- data/lib/rail_verdict/process_runner.rb +292 -0
- data/lib/rail_verdict/rails_context/classifier.rb +57 -0
- data/lib/rail_verdict/rails_context/confidence.rb +13 -0
- data/lib/rail_verdict/rails_context/constant_inferencer.rb +56 -0
- data/lib/rail_verdict/rails_context/context.rb +115 -0
- data/lib/rail_verdict/rails_context/detector.rb +141 -0
- data/lib/rail_verdict/rails_context/limits.rb +17 -0
- data/lib/rail_verdict/rails_context/resolvers/association_extractor.rb +48 -0
- data/lib/rail_verdict/rails_context/resolvers/policy_resolver.rb +45 -0
- data/lib/rail_verdict/rails_context/resolvers/route_resolver.rb +40 -0
- data/lib/rail_verdict/rails_context/resolvers/route_scanner.rb +55 -0
- data/lib/rail_verdict/rails_context/resolvers/schema_resolver.rb +90 -0
- data/lib/rail_verdict/rails_context/resolvers/test_candidates.rb +75 -0
- data/lib/rail_verdict/rails_context/resolvers/view_resolver.rb +45 -0
- data/lib/rail_verdict/rails_context/schema_parser.rb +50 -0
- data/lib/rail_verdict/rails_context.rb +21 -0
- data/lib/rail_verdict/repair/boundary.rb +36 -0
- data/lib/rail_verdict/repair/command.rb +89 -0
- data/lib/rail_verdict/repair/constraints.rb +31 -0
- data/lib/rail_verdict/repair/context_assembler.rb +301 -0
- data/lib/rail_verdict/repair/diff_context.rb +51 -0
- data/lib/rail_verdict/repair/packet.rb +158 -0
- data/lib/rail_verdict/repair/prompt_renderer.rb +27 -0
- data/lib/rail_verdict/repair/verification_plan.rb +45 -0
- data/lib/rail_verdict/repair/verifier.rb +84 -0
- data/lib/rail_verdict/reporters/console.rb +107 -0
- data/lib/rail_verdict/reporters/github_annotations.rb +104 -0
- data/lib/rail_verdict/reporters/json_reporter.rb +19 -0
- data/lib/rail_verdict/reporters/sarif.rb +138 -0
- data/lib/rail_verdict/run_context.rb +99 -0
- data/lib/rail_verdict/schema_validator.rb +85 -0
- data/lib/rail_verdict/strict_yaml.rb +134 -0
- data/lib/rail_verdict/verification/policy.rb +359 -0
- data/lib/rail_verdict/version.rb +5 -0
- data/lib/rail_verdict/waiver.rb +67 -0
- data/lib/rail_verdict/waiver_store.rb +59 -0
- data/lib/rail_verdict.rb +64 -0
- data/schemas/ai-analysis-v1.schema.json +54 -0
- data/schemas/baseline-v1.schema.json +108 -0
- data/schemas/configuration-v1.1.schema.json +150 -0
- data/schemas/configuration-v1.2.schema.json +182 -0
- data/schemas/configuration-v1.3.schema.json +198 -0
- data/schemas/configuration-v1.4.schema.json +146 -0
- data/schemas/configuration-v1.schema.json +72 -0
- data/schemas/coverage-v1.schema.json +51 -0
- data/schemas/finding-v1.schema.json +131 -0
- data/schemas/minitest-reporter-v1.schema.json +113 -0
- data/schemas/repair-packet-v1.schema.json +364 -0
- data/schemas/result-v1.schema.json +378 -0
- data/schemas/waiver-v1.schema.json +45 -0
- data/schemas/waivers-v1.schema.json +67 -0
- metadata +192 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailVerdict
|
|
4
|
+
module RailsContext
|
|
5
|
+
module Resolvers
|
|
6
|
+
module AssociationExtractor
|
|
7
|
+
MACROS = %w[belongs_to has_one has_many has_and_belongs_to_many].freeze
|
|
8
|
+
|
|
9
|
+
def self.extract(repository_root:, path:)
|
|
10
|
+
full = File.join(repository_root, path)
|
|
11
|
+
return [] unless File.file?(full)
|
|
12
|
+
return [] if File.size(full) > Limits::MAX_FILE_BYTES rescue nil
|
|
13
|
+
|
|
14
|
+
text = File.binread(full)
|
|
15
|
+
return [] if text.bytesize > Limits::MAX_FILE_BYTES
|
|
16
|
+
|
|
17
|
+
text.force_encoding(Encoding::UTF_8)
|
|
18
|
+
return [] unless text.valid_encoding?
|
|
19
|
+
return [] if text.include?("\x00")
|
|
20
|
+
|
|
21
|
+
results = []
|
|
22
|
+
text.each_line do |line|
|
|
23
|
+
stripped = line.strip
|
|
24
|
+
next if stripped.start_with?("#")
|
|
25
|
+
|
|
26
|
+
MACROS.each do |macro|
|
|
27
|
+
match = stripped.match(/\A#{Regexp.escape(macro)}\s+[:"]?([a-z_][a-z0-9_]*)["']?/)
|
|
28
|
+
next unless match
|
|
29
|
+
|
|
30
|
+
after = stripped[match[0].length..].to_s.strip
|
|
31
|
+
next if after.start_with?("->") || after.include?("proc") || after.include?("lambda")
|
|
32
|
+
|
|
33
|
+
name = match[1].to_s
|
|
34
|
+
next if name.empty?
|
|
35
|
+
|
|
36
|
+
results << { "name" => name, "macro" => macro, "confidence" => "exact", "provenance" => "literal:#{macro} :#{name}" }
|
|
37
|
+
break if results.length >= Limits::MAX_ASSOCIATIONS
|
|
38
|
+
end
|
|
39
|
+
break if results.length >= Limits::MAX_ASSOCIATIONS
|
|
40
|
+
end
|
|
41
|
+
results
|
|
42
|
+
rescue StandardError
|
|
43
|
+
[]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailVerdict
|
|
4
|
+
module RailsContext
|
|
5
|
+
module Resolvers
|
|
6
|
+
module PolicyResolver
|
|
7
|
+
def self.for(repository_root:, kind:, path:, constant:)
|
|
8
|
+
return [] unless %w[model controller].include?(kind)
|
|
9
|
+
return [] if constant.nil? || constant.empty?
|
|
10
|
+
|
|
11
|
+
base_name = constant.split("::").last.to_s
|
|
12
|
+
snake = underscore(base_name)
|
|
13
|
+
dir_parts = constant.split("::")[0...-1].map { |part| underscore(part) }
|
|
14
|
+
relative_dir = dir_parts.empty? ? "" : "#{dir_parts.join('/')}/"
|
|
15
|
+
repo_relative = "app/policies/#{relative_dir}#{snake}_policy.rb"
|
|
16
|
+
full = File.join(repository_root, repo_relative)
|
|
17
|
+
return [] unless safe_exists?(repository_root, full)
|
|
18
|
+
|
|
19
|
+
[{ "path" => repo_relative, "relationship" => "policy", "confidence" => "conventional", "provenance" => "file_exists:#{repo_relative}" }]
|
|
20
|
+
rescue StandardError
|
|
21
|
+
[]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.underscore(camel)
|
|
25
|
+
word = camel.to_s.dup
|
|
26
|
+
word.gsub!(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
|
|
27
|
+
word.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
|
|
28
|
+
word.downcase
|
|
29
|
+
end
|
|
30
|
+
private_class_method :underscore
|
|
31
|
+
|
|
32
|
+
def self.safe_exists?(root, full)
|
|
33
|
+
return false unless File.file?(full)
|
|
34
|
+
|
|
35
|
+
real = File.realpath(full) rescue full
|
|
36
|
+
real_root = File.realpath(root) rescue root
|
|
37
|
+
real.start_with?(real_root)
|
|
38
|
+
rescue StandardError
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
private_class_method :safe_exists?
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailVerdict
|
|
4
|
+
module RailsContext
|
|
5
|
+
module Resolvers
|
|
6
|
+
module RouteResolver
|
|
7
|
+
def self.for(repository_root:, kind:, constant:)
|
|
8
|
+
routes_path = File.join(repository_root, "config/routes.rb")
|
|
9
|
+
return [] unless File.file?(routes_path)
|
|
10
|
+
|
|
11
|
+
real = File.realpath(routes_path) rescue routes_path
|
|
12
|
+
real_root = File.realpath(repository_root) rescue repository_root
|
|
13
|
+
return [] unless real.start_with?(real_root)
|
|
14
|
+
|
|
15
|
+
base = [{ "path" => "config/routes.rb", "relationship" => "routes", "confidence" => "exact", "provenance" => "file_exists:config/routes.rb" }]
|
|
16
|
+
|
|
17
|
+
return base unless kind == "controller" && constant
|
|
18
|
+
|
|
19
|
+
controller_key = underscore(constant.delete_suffix("Controller"))
|
|
20
|
+
scan = RouteScanner.scan(repository_root: repository_root)
|
|
21
|
+
matching = scan[:declarations].select { |decl| decl["controller"] == controller_key || decl["controller"] == controller_key.split("/").last }
|
|
22
|
+
|
|
23
|
+
matching.first(Limits::MAX_RELATED).map do |decl|
|
|
24
|
+
{ "path" => "config/routes.rb", "relationship" => "route_declaration:#{decl['controller']}", "confidence" => "exact", "provenance" => "literal:#{decl['declaration'][0, 120]}" }
|
|
25
|
+
end.then { |extra| (base + extra).first(Limits::MAX_RELATED) }
|
|
26
|
+
rescue StandardError
|
|
27
|
+
[]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.underscore(camel)
|
|
31
|
+
word = camel.to_s.dup
|
|
32
|
+
word.gsub!(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
|
|
33
|
+
word.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
|
|
34
|
+
word.downcase
|
|
35
|
+
end
|
|
36
|
+
private_class_method :underscore
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailVerdict
|
|
4
|
+
module RailsContext
|
|
5
|
+
module Resolvers
|
|
6
|
+
module RouteScanner
|
|
7
|
+
def self.scan(repository_root:)
|
|
8
|
+
path = File.join(repository_root, "config/routes.rb")
|
|
9
|
+
return { declarations: [], unresolved: false } unless File.file?(path)
|
|
10
|
+
return { declarations: [], unresolved: false } if File.size(path) > Limits::ROUTES_MAX_BYTES rescue nil
|
|
11
|
+
|
|
12
|
+
text = File.binread(path)
|
|
13
|
+
return { declarations: [], unresolved: false } if text.bytesize > Limits::ROUTES_MAX_BYTES
|
|
14
|
+
|
|
15
|
+
text.force_encoding(Encoding::UTF_8)
|
|
16
|
+
return { declarations: [], unresolved: false } unless text.valid_encoding?
|
|
17
|
+
return { declarations: [], unresolved: true } if text.include?("\x00")
|
|
18
|
+
|
|
19
|
+
declarations = []
|
|
20
|
+
unresolved = false
|
|
21
|
+
|
|
22
|
+
text.each_line do |line|
|
|
23
|
+
stripped = line.strip
|
|
24
|
+
next if stripped.empty? || stripped.start_with?("#")
|
|
25
|
+
|
|
26
|
+
if stripped.match?(/\b(draw|mount|concern|constraints)\b/)
|
|
27
|
+
unresolved = true
|
|
28
|
+
next
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
if (match = stripped.match(/\bresources\s*:\s*(\w+)/))
|
|
32
|
+
declarations << { "controller" => match[1], "declaration" => stripped, "confidence" => "exact" }
|
|
33
|
+
next
|
|
34
|
+
end
|
|
35
|
+
if (match = stripped.match(/\bresource\s*:\s*(\w+)/))
|
|
36
|
+
declarations << { "controller" => match[1], "declaration" => stripped, "confidence" => "exact" }
|
|
37
|
+
next
|
|
38
|
+
end
|
|
39
|
+
if (match = stripped.match(/\b(?:get|post|put|patch|delete)\s+['\"][^'\"]*['\"]\s*,\s*to:\s*['\"](\w+)#/))
|
|
40
|
+
declarations << { "controller" => match[1], "declaration" => stripped, "confidence" => "exact" }
|
|
41
|
+
next
|
|
42
|
+
end
|
|
43
|
+
if stripped.match?(/\bresources\b/) && stripped.include?("do")
|
|
44
|
+
unresolved = true
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
{ declarations: declarations.first(Limits::MAX_RELATED), unresolved: unresolved }
|
|
49
|
+
rescue StandardError
|
|
50
|
+
{ declarations: [], unresolved: false }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailVerdict
|
|
4
|
+
module RailsContext
|
|
5
|
+
module Resolvers
|
|
6
|
+
module SchemaResolver
|
|
7
|
+
def self.for(repository_root:, kind:, constant:, path:)
|
|
8
|
+
return [] unless kind == "model"
|
|
9
|
+
return [] if constant.nil? || constant.empty?
|
|
10
|
+
|
|
11
|
+
table = inferred_table(constant)
|
|
12
|
+
custom = custom_table_name(repository_root, path)
|
|
13
|
+
if custom
|
|
14
|
+
if custom[:literal]
|
|
15
|
+
return [{ "path" => "db/schema.rb", "relationship" => "schema_table:#{custom[:table]}", "confidence" => "exact", "provenance" => "literal:self.table_name=\"#{custom[:table]}\"" }]
|
|
16
|
+
else
|
|
17
|
+
return [{ "path" => "db/schema.rb", "relationship" => "schema_table", "confidence" => "unresolved", "provenance" => "dynamic_table_name" }]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
schema = SchemaParser.parse(repository_root: repository_root)
|
|
22
|
+
has_structure_sql = File.file?(File.join(repository_root, "db/structure.sql"))
|
|
23
|
+
|
|
24
|
+
if schema.key?(table)
|
|
25
|
+
return [{ "path" => "db/schema.rb", "relationship" => "schema_table:#{table}", "confidence" => "conventional", "provenance" => "schema_table:#{table}" }]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
if has_structure_sql
|
|
29
|
+
return [{ "path" => "db/structure.sql", "relationship" => "schema_table", "confidence" => "unresolved", "provenance" => "structure_sql_not_parsed" }]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
if table
|
|
33
|
+
return [{ "path" => "db/schema.rb", "relationship" => "schema_table:#{table}", "confidence" => "inferred", "provenance" => "inferred_table:#{table}" }]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
[]
|
|
37
|
+
rescue StandardError
|
|
38
|
+
[]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.inferred_table(constant)
|
|
42
|
+
base = constant.split("::").last.to_s
|
|
43
|
+
snake = underscore(base)
|
|
44
|
+
naive_pluralize(snake)
|
|
45
|
+
end
|
|
46
|
+
private_class_method :inferred_table
|
|
47
|
+
|
|
48
|
+
def self.custom_table_name(root, path)
|
|
49
|
+
full = File.join(root, path)
|
|
50
|
+
return nil unless File.file?(full)
|
|
51
|
+
return nil if File.size(full) > Limits::MAX_FILE_BYTES rescue nil
|
|
52
|
+
|
|
53
|
+
text = File.binread(full)
|
|
54
|
+
return nil if text.bytesize > Limits::MAX_FILE_BYTES
|
|
55
|
+
|
|
56
|
+
text.force_encoding(Encoding::UTF_8)
|
|
57
|
+
return nil unless text.valid_encoding?
|
|
58
|
+
return nil if text.include?("\x00")
|
|
59
|
+
|
|
60
|
+
literal = text.match(/self\.table_name\s*=\s*["']([^"']+)["']/)
|
|
61
|
+
return { literal: true, table: literal[1] } if literal
|
|
62
|
+
|
|
63
|
+
dynamic = text.match(/self\.table_name\s*=/)
|
|
64
|
+
return { literal: false, table: nil } if dynamic
|
|
65
|
+
|
|
66
|
+
nil
|
|
67
|
+
rescue StandardError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
private_class_method :custom_table_name
|
|
71
|
+
|
|
72
|
+
def self.underscore(camel)
|
|
73
|
+
word = camel.to_s.dup
|
|
74
|
+
word.gsub!(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
|
|
75
|
+
word.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
|
|
76
|
+
word.downcase
|
|
77
|
+
end
|
|
78
|
+
private_class_method :underscore
|
|
79
|
+
|
|
80
|
+
def self.naive_pluralize(word)
|
|
81
|
+
return "#{word}s" if word.empty?
|
|
82
|
+
return "#{word}es" if word.end_with?("s", "x", "z", "ch", "sh")
|
|
83
|
+
|
|
84
|
+
"#{word}s"
|
|
85
|
+
end
|
|
86
|
+
private_class_method :naive_pluralize
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailVerdict
|
|
4
|
+
module RailsContext
|
|
5
|
+
module Resolvers
|
|
6
|
+
module TestCandidates
|
|
7
|
+
def self.for(repository_root:, kind:, path:)
|
|
8
|
+
basename = File.basename(path, ".rb")
|
|
9
|
+
dir = File.dirname(path)
|
|
10
|
+
candidates = []
|
|
11
|
+
|
|
12
|
+
case kind
|
|
13
|
+
when "model"
|
|
14
|
+
relative = dir.delete_prefix("app/models").delete_prefix("/")
|
|
15
|
+
candidates << candidate_path(repository_root, "test", "models", relative, basename)
|
|
16
|
+
candidates << candidate_path(repository_root, "spec", "models", relative, basename)
|
|
17
|
+
when "controller"
|
|
18
|
+
clean = basename.delete_suffix("_controller")
|
|
19
|
+
relative = dir.delete_prefix("app/controllers").delete_prefix("/")
|
|
20
|
+
candidates << candidate_path(repository_root, "test", "controllers", relative, basename)
|
|
21
|
+
candidates << candidate_path(repository_root, "spec", "requests", relative, clean)
|
|
22
|
+
candidates << candidate_path(repository_root, "spec", "controllers", relative, basename)
|
|
23
|
+
when "job"
|
|
24
|
+
relative = dir.delete_prefix("app/jobs").delete_prefix("/")
|
|
25
|
+
candidates << candidate_path(repository_root, "test", "jobs", relative, basename)
|
|
26
|
+
candidates << candidate_path(repository_root, "spec", "jobs", relative, basename)
|
|
27
|
+
when "mailer"
|
|
28
|
+
relative = dir.delete_prefix("app/mailers").delete_prefix("/")
|
|
29
|
+
candidates << candidate_path(repository_root, "test", "mailers", relative, basename)
|
|
30
|
+
candidates << candidate_path(repository_root, "spec", "mailers", relative, basename)
|
|
31
|
+
when "helper"
|
|
32
|
+
relative = dir.delete_prefix("app/helpers").delete_prefix("/")
|
|
33
|
+
candidates << candidate_path(repository_root, "test", "helpers", relative, basename)
|
|
34
|
+
candidates << candidate_path(repository_root, "spec", "helpers", relative, basename)
|
|
35
|
+
when "service"
|
|
36
|
+
relative = dir.delete_prefix("app/services").delete_prefix("/")
|
|
37
|
+
candidates << candidate_path(repository_root, "test", "services", relative, basename)
|
|
38
|
+
candidates << candidate_path(repository_root, "spec", "services", relative, basename)
|
|
39
|
+
else
|
|
40
|
+
return []
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
candidates.compact.select { |entry| entry[:exists] }.first(2).map do |entry|
|
|
44
|
+
{ "path" => entry[:path], "relationship" => "related_test", "confidence" => "conventional", "provenance" => "file_exists:#{entry[:path]}" }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.candidate_path(root, framework, kind_dir, relative, basename)
|
|
49
|
+
rel = relative.empty? ? "" : "#{relative}/"
|
|
50
|
+
filename = framework == "test" ? "#{basename}_test.rb" : "#{basename}_spec.rb"
|
|
51
|
+
repo_relative = "#{framework}/#{kind_dir}/#{rel}#{filename}".gsub(%r{//+}, "/")
|
|
52
|
+
full = File.join(root, repo_relative)
|
|
53
|
+
exists = safe_exists?(root, full)
|
|
54
|
+
return nil unless exists
|
|
55
|
+
|
|
56
|
+
{ path: repo_relative, exists: true }
|
|
57
|
+
rescue StandardError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
private_class_method :candidate_path
|
|
61
|
+
|
|
62
|
+
def self.safe_exists?(root, full)
|
|
63
|
+
return false unless File.file?(full)
|
|
64
|
+
|
|
65
|
+
real = File.realpath(full) rescue full
|
|
66
|
+
real_root = File.realpath(root) rescue root
|
|
67
|
+
real.start_with?(real_root)
|
|
68
|
+
rescue StandardError
|
|
69
|
+
false
|
|
70
|
+
end
|
|
71
|
+
private_class_method :safe_exists?
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailVerdict
|
|
4
|
+
module RailsContext
|
|
5
|
+
module Resolvers
|
|
6
|
+
module ViewResolver
|
|
7
|
+
ALLOWED_EXTENSIONS = %w[.html.erb .turbo_stream.erb .json.jbuilder .html.haml .erb .jbuilder].freeze
|
|
8
|
+
|
|
9
|
+
def self.for(repository_root:, kind:, path:, constant:)
|
|
10
|
+
return [] unless kind == "controller"
|
|
11
|
+
return [] if constant.nil? || constant.empty?
|
|
12
|
+
|
|
13
|
+
base = constant.delete_suffix("Controller")
|
|
14
|
+
parts = base.split("::").map { |part| underscore(part) }
|
|
15
|
+
view_dir = File.join(repository_root, "app/views", *parts)
|
|
16
|
+
return [] unless File.directory?(view_dir)
|
|
17
|
+
|
|
18
|
+
real = File.realpath(view_dir) rescue view_dir
|
|
19
|
+
real_root = File.realpath(repository_root) rescue repository_root
|
|
20
|
+
return [] unless real.start_with?(real_root)
|
|
21
|
+
|
|
22
|
+
entries = Dir.children(view_dir).sort.first(Limits::MAX_RELATED)
|
|
23
|
+
entries.filter_map do |name|
|
|
24
|
+
full = File.join(view_dir, name)
|
|
25
|
+
next unless File.file?(full)
|
|
26
|
+
next unless ALLOWED_EXTENSIONS.any? { |ext| name.end_with?(ext) }
|
|
27
|
+
|
|
28
|
+
repo_relative = "app/views/#{parts.join('/')}/#{name}"
|
|
29
|
+
{ "path" => repo_relative, "relationship" => "view", "confidence" => "conventional", "provenance" => "dir_exists:app/views/#{parts.join('/')}" }
|
|
30
|
+
end
|
|
31
|
+
rescue StandardError
|
|
32
|
+
[]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.underscore(camel)
|
|
36
|
+
word = camel.to_s.dup
|
|
37
|
+
word.gsub!(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
|
|
38
|
+
word.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
|
|
39
|
+
word.downcase
|
|
40
|
+
end
|
|
41
|
+
private_class_method :underscore
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailVerdict
|
|
4
|
+
module RailsContext
|
|
5
|
+
module SchemaParser
|
|
6
|
+
def self.parse(repository_root:)
|
|
7
|
+
path = File.join(repository_root, "db/schema.rb")
|
|
8
|
+
return {} unless File.file?(path)
|
|
9
|
+
return {} if File.size(path) > Limits::MAX_FILE_BYTES rescue nil
|
|
10
|
+
|
|
11
|
+
text = File.binread(path)
|
|
12
|
+
return {} if text.bytesize > Limits::MAX_FILE_BYTES
|
|
13
|
+
|
|
14
|
+
text.force_encoding(Encoding::UTF_8)
|
|
15
|
+
return {} unless text.valid_encoding?
|
|
16
|
+
|
|
17
|
+
tables = {}
|
|
18
|
+
text.scan(/create_table\s+["']([^"']+)["']/) do |match|
|
|
19
|
+
table = match[0].to_s.strip
|
|
20
|
+
next if table.empty?
|
|
21
|
+
next if tables.key?(table)
|
|
22
|
+
|
|
23
|
+
break if tables.length >= Limits::MAX_SCHEMA_TABLES
|
|
24
|
+
|
|
25
|
+
snippet = extract_table_snippet(text, table)
|
|
26
|
+
tables[table] = snippet
|
|
27
|
+
end
|
|
28
|
+
tables
|
|
29
|
+
rescue StandardError
|
|
30
|
+
{}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.extract_table_snippet(text, table)
|
|
34
|
+
pattern = /create_table\s+["']#{Regexp.escape(table)}["'].*?\n(.*?)end/m
|
|
35
|
+
match = text.match(pattern)
|
|
36
|
+
return { "columns" => [] } unless match
|
|
37
|
+
|
|
38
|
+
block_text = match[1].to_s
|
|
39
|
+
columns = []
|
|
40
|
+
block_text.scan(/t\.\w+\s+["']([^"']+)["']/) do |col_match|
|
|
41
|
+
break if columns.length >= Limits::MAX_COLUMNS_PER_TABLE
|
|
42
|
+
|
|
43
|
+
columns << { "name" => col_match[0], "type" => "unknown" }
|
|
44
|
+
end
|
|
45
|
+
{ "columns" => columns }
|
|
46
|
+
end
|
|
47
|
+
private_class_method :extract_table_snippet
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rails_context/limits"
|
|
4
|
+
require_relative "rails_context/confidence"
|
|
5
|
+
require_relative "rails_context/classifier"
|
|
6
|
+
require_relative "rails_context/constant_inferencer"
|
|
7
|
+
require_relative "rails_context/detector"
|
|
8
|
+
require_relative "rails_context/schema_parser"
|
|
9
|
+
require_relative "rails_context/resolvers/test_candidates"
|
|
10
|
+
require_relative "rails_context/resolvers/policy_resolver"
|
|
11
|
+
require_relative "rails_context/resolvers/view_resolver"
|
|
12
|
+
require_relative "rails_context/resolvers/route_scanner"
|
|
13
|
+
require_relative "rails_context/resolvers/route_resolver"
|
|
14
|
+
require_relative "rails_context/resolvers/schema_resolver"
|
|
15
|
+
require_relative "rails_context/resolvers/association_extractor"
|
|
16
|
+
require_relative "rails_context/context"
|
|
17
|
+
|
|
18
|
+
module RailVerdict
|
|
19
|
+
module RailsContext
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module RailVerdict
|
|
6
|
+
module Repair
|
|
7
|
+
module Boundary
|
|
8
|
+
def self.snapshot(repository_root:, configuration:, outcome:)
|
|
9
|
+
config_digest = configuration&.digest
|
|
10
|
+
baseline_path = configuration ? Baseline.resolve_path(repository_root: repository_root, configuration: configuration) : nil
|
|
11
|
+
waiver_path = configuration ? WaiverStore.resolve_path(repository_root: repository_root, configuration: configuration) : nil
|
|
12
|
+
baseline_digest = file_digest(baseline_path)
|
|
13
|
+
waivers_digest = file_digest(waiver_path)
|
|
14
|
+
source_revision = outcome&.context&.revision
|
|
15
|
+
base_revision = outcome&.context&.git_context&.base rescue nil
|
|
16
|
+
base_revision ||= outcome&.result&.git&.fetch("base", nil) rescue nil
|
|
17
|
+
{
|
|
18
|
+
"configuration_digest" => config_digest || Digest::SHA256.hexdigest(""),
|
|
19
|
+
"baseline_digest" => baseline_digest,
|
|
20
|
+
"waivers_digest" => waivers_digest,
|
|
21
|
+
"base_revision" => base_revision,
|
|
22
|
+
"source_revision" => source_revision
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.file_digest(path)
|
|
27
|
+
return nil unless path && File.file?(path)
|
|
28
|
+
|
|
29
|
+
Digest::SHA256.hexdigest(File.binread(path))
|
|
30
|
+
rescue StandardError
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
private_class_method :file_digest
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
|
|
7
|
+
module RailVerdict
|
|
8
|
+
module Repair
|
|
9
|
+
class Command
|
|
10
|
+
def self.execute(repository_root:, config_path:, finding_ref:, format: "console", output_path: nil, changed: false, base: nil, baseline_override: nil, waiver_override: nil, stdout: $stdout, stderr: $stderr)
|
|
11
|
+
outcome, interrupted = run_check(repository_root: repository_root, config_path: config_path, changed: changed, base: base, baseline_override: baseline_override, waiver_override: waiver_override)
|
|
12
|
+
return [130, nil] if interrupted || outcome.result.completion_status == "interrupted"
|
|
13
|
+
|
|
14
|
+
begin
|
|
15
|
+
packet = ContextAssembler.build(outcome: outcome, finding_ref: finding_ref, repository_root: repository_root)
|
|
16
|
+
rescue ContextAssembler::StaleFindingError => e
|
|
17
|
+
stderr.puts "railverdict repair: #{e.message}"
|
|
18
|
+
return [2, nil]
|
|
19
|
+
rescue ArgumentError => e
|
|
20
|
+
stderr.puts "railverdict repair: #{e.message}"
|
|
21
|
+
return [2, nil]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
hash = packet.to_h
|
|
25
|
+
|
|
26
|
+
if output_path
|
|
27
|
+
write_atomic(output_path, hash, stderr)
|
|
28
|
+
return [2, nil] unless File.file?(output_path)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
if format == "json"
|
|
32
|
+
stdout.write(JSON.generate(hash) + "\n")
|
|
33
|
+
else
|
|
34
|
+
stdout.write(render_console(hash))
|
|
35
|
+
end
|
|
36
|
+
[0, packet]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.run_check(repository_root:, config_path:, changed:, base:, baseline_override:, waiver_override:)
|
|
40
|
+
interrupted = false
|
|
41
|
+
previous = Signal.trap("INT") do
|
|
42
|
+
interrupted = true
|
|
43
|
+
RailVerdict::ProcessRunner.registry.terminate_all
|
|
44
|
+
end
|
|
45
|
+
opts = { repository_root: repository_root, config_path: config_path, interrupted: -> { interrupted } }
|
|
46
|
+
opts[:changed] = changed if changed
|
|
47
|
+
opts[:base] = base if base
|
|
48
|
+
opts[:baseline_path_override] = baseline_override if baseline_override
|
|
49
|
+
opts[:waiver_path_override] = waiver_override if waiver_override
|
|
50
|
+
outcome = Check.execute(**opts)
|
|
51
|
+
[outcome, interrupted]
|
|
52
|
+
ensure
|
|
53
|
+
Signal.trap("INT", previous) if previous
|
|
54
|
+
end
|
|
55
|
+
private_class_method :run_check
|
|
56
|
+
|
|
57
|
+
def self.write_atomic(path, hash, stderr)
|
|
58
|
+
dir = File.dirname(File.expand_path(path))
|
|
59
|
+
FileUtils.mkdir_p(dir)
|
|
60
|
+
tmp = "#{path}.tmp.#{Process.pid}.#{SecureRandom.hex(4)}"
|
|
61
|
+
File.write(tmp, JSON.generate(hash) + "\n")
|
|
62
|
+
File.chmod(0o600, tmp) rescue nil
|
|
63
|
+
File.rename(tmp, path)
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
stderr.puts "railverdict repair: failed to write #{path}: #{e.message}"
|
|
66
|
+
ensure
|
|
67
|
+
File.unlink(tmp) rescue nil
|
|
68
|
+
end
|
|
69
|
+
private_class_method :write_atomic
|
|
70
|
+
|
|
71
|
+
def self.render_console(hash)
|
|
72
|
+
lines = []
|
|
73
|
+
lines << "RepairPacket #{hash['packet_id']}"
|
|
74
|
+
lines << "Target: #{hash.dig('target','finding','id')} #{hash.dig('target','finding','location','path')}:#{hash.dig('target','finding','location','start_line')}"
|
|
75
|
+
lines << "Severity: #{hash.dig('target','severity')} Blocking: #{hash.dig('target','blocking')}"
|
|
76
|
+
lines << "Gate: #{hash.dig('verification','gate')} Policy: #{hash.dig('verification','mode')}"
|
|
77
|
+
lines << "Reason: #{hash.dig('verification','decision_reasons',0,'message')}"
|
|
78
|
+
plan = hash.dig("verification_plan","required",0,"display") || "bundle exec railverdict check"
|
|
79
|
+
lines << "Verify: #{plan}"
|
|
80
|
+
lines << "Constraints: do not update baseline/waiver/policy; require verification"
|
|
81
|
+
if hash["ai_analysis"]
|
|
82
|
+
lines << "AI: #{hash.dig('ai_analysis','summary')&.slice(0, 80)}"
|
|
83
|
+
end
|
|
84
|
+
lines.join("\n") + "\n"
|
|
85
|
+
end
|
|
86
|
+
private_class_method :render_console
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailVerdict
|
|
4
|
+
module Repair
|
|
5
|
+
module Constraints
|
|
6
|
+
DEFAULT = {
|
|
7
|
+
"forbid_baseline_update" => true,
|
|
8
|
+
"forbid_waiver_creation" => true,
|
|
9
|
+
"forbid_policy_relaxation" => true,
|
|
10
|
+
"require_verification" => true
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
INSTRUCTIONS = [
|
|
14
|
+
"Do not modify baseline files (.railverdict-baseline.json).",
|
|
15
|
+
"Do not create waiver files (.railverdict-waivers.json).",
|
|
16
|
+
"Do not weaken verification policy (mode strict/no_new_debt/advisory).",
|
|
17
|
+
"Do not disable analyzers or make required analyzers optional.",
|
|
18
|
+
"Preserve unrelated behavior; fix root cause, do not suppress the rule.",
|
|
19
|
+
"Run required verification after modification."
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
def self.default
|
|
23
|
+
DEFAULT.dup
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.instructions
|
|
27
|
+
INSTRUCTIONS.dup
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|