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.
Files changed (110) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/NOTICE +14 -0
  4. data/README.md +192 -0
  5. data/exe/railverdict +6 -0
  6. data/exe/railverdict-minitest-reporter.rb +90 -0
  7. data/lib/rail_verdict/analyzers/_shared.rb +54 -0
  8. data/lib/rail_verdict/analyzers/bundler_audit.rb +186 -0
  9. data/lib/rail_verdict/analyzers/minitest.rb +312 -0
  10. data/lib/rail_verdict/analyzers/rspec.rb +274 -0
  11. data/lib/rail_verdict/analyzers/rubocop.rb +289 -0
  12. data/lib/rail_verdict/analyzers/simplecov.rb +178 -0
  13. data/lib/rail_verdict/baseline.rb +151 -0
  14. data/lib/rail_verdict/check.rb +415 -0
  15. data/lib/rail_verdict/cli.rb +471 -0
  16. data/lib/rail_verdict/comparison.rb +216 -0
  17. data/lib/rail_verdict/configuration.rb +165 -0
  18. data/lib/rail_verdict/contracts/analyzer_result.rb +138 -0
  19. data/lib/rail_verdict/contracts/finding.rb +154 -0
  20. data/lib/rail_verdict/contracts/gate_result.rb +171 -0
  21. data/lib/rail_verdict/coverage/changed_line_evaluator.rb +70 -0
  22. data/lib/rail_verdict/doctor.rb +132 -0
  23. data/lib/rail_verdict/errors.rb +17 -0
  24. data/lib/rail_verdict/findings_command.rb +24 -0
  25. data/lib/rail_verdict/fingerprint.rb +72 -0
  26. data/lib/rail_verdict/git/command.rb +48 -0
  27. data/lib/rail_verdict/git/context.rb +361 -0
  28. data/lib/rail_verdict/git/diff_parser.rb +200 -0
  29. data/lib/rail_verdict/git/errors.rb +16 -0
  30. data/lib/rail_verdict/init.rb +38 -0
  31. data/lib/rail_verdict/intelligence/ai_analysis.rb +82 -0
  32. data/lib/rail_verdict/intelligence/ai_failure.rb +44 -0
  33. data/lib/rail_verdict/intelligence/budget.rb +36 -0
  34. data/lib/rail_verdict/intelligence/cache.rb +74 -0
  35. data/lib/rail_verdict/intelligence/context_builder.rb +92 -0
  36. data/lib/rail_verdict/intelligence/manifest.rb +70 -0
  37. data/lib/rail_verdict/intelligence/orchestrator.rb +77 -0
  38. data/lib/rail_verdict/intelligence/prompt.rb +28 -0
  39. data/lib/rail_verdict/intelligence/provider.rb +14 -0
  40. data/lib/rail_verdict/intelligence/providers/fake_provider.rb +31 -0
  41. data/lib/rail_verdict/intelligence/providers/openai_compat.rb +119 -0
  42. data/lib/rail_verdict/intelligence/redactor.rb +51 -0
  43. data/lib/rail_verdict/intelligence/secret_detector.rb +45 -0
  44. data/lib/rail_verdict/intelligence/source_reader.rb +49 -0
  45. data/lib/rail_verdict/intelligence.rb +11 -0
  46. data/lib/rail_verdict/mcp/cache.rb +233 -0
  47. data/lib/rail_verdict/mcp/repository_root.rb +31 -0
  48. data/lib/rail_verdict/mcp/serializers.rb +98 -0
  49. data/lib/rail_verdict/mcp/server.rb +110 -0
  50. data/lib/rail_verdict/mcp/tools/build_repair_packet.rb +85 -0
  51. data/lib/rail_verdict/mcp/tools/explain.rb +100 -0
  52. data/lib/rail_verdict/mcp/tools/get_finding.rb +139 -0
  53. data/lib/rail_verdict/mcp/tools/investigate.rb +107 -0
  54. data/lib/rail_verdict/mcp/tools/list_findings.rb +119 -0
  55. data/lib/rail_verdict/mcp/tools/verify.rb +137 -0
  56. data/lib/rail_verdict/mcp/tools/verify_repair.rb +105 -0
  57. data/lib/rail_verdict/mcp/validators.rb +81 -0
  58. data/lib/rail_verdict/mcp.rb +7 -0
  59. data/lib/rail_verdict/process_runner.rb +292 -0
  60. data/lib/rail_verdict/rails_context/classifier.rb +57 -0
  61. data/lib/rail_verdict/rails_context/confidence.rb +13 -0
  62. data/lib/rail_verdict/rails_context/constant_inferencer.rb +56 -0
  63. data/lib/rail_verdict/rails_context/context.rb +115 -0
  64. data/lib/rail_verdict/rails_context/detector.rb +141 -0
  65. data/lib/rail_verdict/rails_context/limits.rb +17 -0
  66. data/lib/rail_verdict/rails_context/resolvers/association_extractor.rb +48 -0
  67. data/lib/rail_verdict/rails_context/resolvers/policy_resolver.rb +45 -0
  68. data/lib/rail_verdict/rails_context/resolvers/route_resolver.rb +40 -0
  69. data/lib/rail_verdict/rails_context/resolvers/route_scanner.rb +55 -0
  70. data/lib/rail_verdict/rails_context/resolvers/schema_resolver.rb +90 -0
  71. data/lib/rail_verdict/rails_context/resolvers/test_candidates.rb +75 -0
  72. data/lib/rail_verdict/rails_context/resolvers/view_resolver.rb +45 -0
  73. data/lib/rail_verdict/rails_context/schema_parser.rb +50 -0
  74. data/lib/rail_verdict/rails_context.rb +21 -0
  75. data/lib/rail_verdict/repair/boundary.rb +36 -0
  76. data/lib/rail_verdict/repair/command.rb +89 -0
  77. data/lib/rail_verdict/repair/constraints.rb +31 -0
  78. data/lib/rail_verdict/repair/context_assembler.rb +301 -0
  79. data/lib/rail_verdict/repair/diff_context.rb +51 -0
  80. data/lib/rail_verdict/repair/packet.rb +158 -0
  81. data/lib/rail_verdict/repair/prompt_renderer.rb +27 -0
  82. data/lib/rail_verdict/repair/verification_plan.rb +45 -0
  83. data/lib/rail_verdict/repair/verifier.rb +84 -0
  84. data/lib/rail_verdict/reporters/console.rb +107 -0
  85. data/lib/rail_verdict/reporters/github_annotations.rb +104 -0
  86. data/lib/rail_verdict/reporters/json_reporter.rb +19 -0
  87. data/lib/rail_verdict/reporters/sarif.rb +138 -0
  88. data/lib/rail_verdict/run_context.rb +99 -0
  89. data/lib/rail_verdict/schema_validator.rb +85 -0
  90. data/lib/rail_verdict/strict_yaml.rb +134 -0
  91. data/lib/rail_verdict/verification/policy.rb +359 -0
  92. data/lib/rail_verdict/version.rb +5 -0
  93. data/lib/rail_verdict/waiver.rb +67 -0
  94. data/lib/rail_verdict/waiver_store.rb +59 -0
  95. data/lib/rail_verdict.rb +64 -0
  96. data/schemas/ai-analysis-v1.schema.json +54 -0
  97. data/schemas/baseline-v1.schema.json +108 -0
  98. data/schemas/configuration-v1.1.schema.json +150 -0
  99. data/schemas/configuration-v1.2.schema.json +182 -0
  100. data/schemas/configuration-v1.3.schema.json +198 -0
  101. data/schemas/configuration-v1.4.schema.json +146 -0
  102. data/schemas/configuration-v1.schema.json +72 -0
  103. data/schemas/coverage-v1.schema.json +51 -0
  104. data/schemas/finding-v1.schema.json +131 -0
  105. data/schemas/minitest-reporter-v1.schema.json +113 -0
  106. data/schemas/repair-packet-v1.schema.json +364 -0
  107. data/schemas/result-v1.schema.json +378 -0
  108. data/schemas/waiver-v1.schema.json +45 -0
  109. data/schemas/waivers-v1.schema.json +67 -0
  110. 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