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,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailVerdict
4
+ module Reporters
5
+ module Console
6
+ module_function
7
+
8
+ def render(result)
9
+ lines = ["RailVerdict #{RailVerdict::VERSION}", "", "Analyzers"]
10
+ result.analyzer_results.each do |analyzer|
11
+ version = analyzer.tool_version ? " (#{analyzer.tool_version})" : ""
12
+ lines << format(
13
+ " %s: %s%s, %d findings",
14
+ analyzer.analyzer,
15
+ analyzer.execution_status,
16
+ version,
17
+ analyzer.finding_ids.length
18
+ )
19
+ end
20
+ lines << " none" if result.analyzer_results.empty?
21
+
22
+ lines << ""
23
+ lines << "Findings"
24
+ if result.findings.empty?
25
+ lines << " none"
26
+ else
27
+ result.findings.each do |finding|
28
+ lines << format(
29
+ " %s [%s] %s %s",
30
+ finding.fetch("severity"),
31
+ finding.fetch("state"),
32
+ finding.fetch("id"),
33
+ finding.fetch("blocking") ? "blocking" : "advisory"
34
+ )
35
+ end
36
+ end
37
+
38
+ unless result.operational_failures.empty?
39
+ lines << ""
40
+ lines << "Operational failures"
41
+ result.operational_failures.each do |failure|
42
+ analyzer = failure["analyzer"] ? "#{failure['analyzer']}: " : ""
43
+ lines << " [#{failure.fetch('code')}] #{analyzer}#{sanitize(failure.fetch('message'))}"
44
+ end
45
+ end
46
+
47
+ if result.baseline
48
+ baseline = result.baseline
49
+ if baseline["loaded"]
50
+ lines << ""
51
+ lines << "Baseline: loaded #{baseline['path']} (v#{baseline['schema_version']}, fp v#{baseline['fingerprint_version']})"
52
+ else
53
+ lines << ""
54
+ lines << "Baseline: none (#{baseline['path']})"
55
+ end
56
+ end
57
+
58
+ if result.comparison
59
+ counts = result.comparison["counts"] || {}
60
+ lines << ""
61
+ lines << format("Comparison: Introduced: %d Existing: %d Resolved: %d Changed: %d Moved: %d Waived: %d",
62
+ counts["introduced"] || 0, counts["existing"] || 0, counts["resolved"] || 0,
63
+ counts["changed"] || 0, counts["moved"] || 0, counts["waived"] || 0)
64
+ end
65
+
66
+ if result.git
67
+ git = result.git
68
+ if git["error"]
69
+ lines << ""
70
+ lines << "Git: error #{sanitize(git.fetch('error'))}: #{sanitize(git.fetch('error_message', ''))}"
71
+ else
72
+ changed_count = (git["changed_files"] || []).length
73
+ lines << ""
74
+ lines << format("Git: head %s base %s merge-base %s (%d changed files)",
75
+ sanitize(git["head"].to_s[0, 7]), sanitize(git["base"].to_s[0, 7]), sanitize(git["merge_base"].to_s[0, 7]), changed_count)
76
+ lines << "Scope: changed" if git["head"] && git["merge_base"]
77
+ end
78
+ end
79
+
80
+ if result.rails_context
81
+ rc = result.rails_context
82
+ entries = rc["entries"] || []
83
+ detected = rc["detected"] || {}
84
+ lines << ""
85
+ lines << format("Rails context: %d entries (%s, %s)",
86
+ entries.length,
87
+ sanitize(detected["rails_version"] || "no rails"),
88
+ sanitize(rc["scope"] || "unknown"))
89
+ end
90
+
91
+ lines << ""
92
+ lines << "Gate: #{result.gate}"
93
+ lines << "Policy: #{result.policy_status}"
94
+ lines << "Reasons"
95
+ result.decision_reasons.each do |reason|
96
+ lines << " - #{reason.fetch('code')}: #{sanitize(reason.fetch('message'))}"
97
+ end
98
+ lines.join("\n") + "\n"
99
+ end
100
+
101
+ def sanitize(value)
102
+ value.to_s.gsub(/[[:cntrl:]]/) { |character| character == "\n" ? " " : "?" }
103
+ end
104
+ private_class_method :sanitize
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailVerdict
4
+ module Reporters
5
+ module GitHubAnnotations
6
+ module_function
7
+
8
+ def render(result, findings: nil)
9
+ findings_list = findings || result.findings
10
+ lines = []
11
+ findings_list.sort_by { |finding| [finding["path"] || finding["location"]&.dig("path") || "", finding["start_line"] || finding["location"]&.dig("start_line") || 0, finding["id"] || ""] }.each do |finding|
12
+ path = extract_path(finding)
13
+ line = extract_line(finding)
14
+ col = extract_column(finding)
15
+ message = finding["message"] || finding[:message] || finding["id"] || ""
16
+ rule_id = finding["rule_id"] || finding[:rule_id] || finding["id"] || "unknown"
17
+ analyzer = finding["analyzer"] || finding[:analyzer] || "unknown"
18
+ severity = finding["severity"] || finding[:severity] || "info"
19
+ blocking = finding["blocking"]
20
+ level = annotation_level(severity, blocking, result.gate)
21
+ file_part = "file=#{escape_property(path)}"
22
+ line_part = line ? ",line=#{line}" : ""
23
+ col_part = col ? ",col=#{col}" : ""
24
+ title = escape_data("#{analyzer}:#{rule_id}")
25
+ msg = escape_data("#{message} [#{finding['state'] || finding[:state] || 'observed'}]")
26
+ lines << "::#{level} #{file_part}#{line_part}#{col_part},title=#{title}::#{msg}"
27
+ end
28
+ lines.join("\n") + (lines.empty? ? "" : "\n")
29
+ end
30
+
31
+ def render_summary_markdown(result, findings: nil)
32
+ findings_list = findings || result.findings
33
+ header = "| File | Rule | Message | Severity | State | Blocking |\n| --- | --- | --- | --- | --- | --- |\n"
34
+ rows = findings_list.sort_by { |finding| [finding["path"] || finding["location"]&.dig("path") || "", finding["id"] || ""] }.map do |finding|
35
+ path = finding["path"] || finding["location"]&.dig("path") || finding[:path] || "unknown"
36
+ rule = finding["rule_id"] || finding[:rule_id] || finding["id"] || ""
37
+ msg = (finding["message"] || finding[:message] || "").to_s.gsub("|", "\\|").gsub("\n", " ")[0, 200]
38
+ severity = finding["severity"] || finding[:severity] || ""
39
+ state = finding["state"] || finding[:state] || ""
40
+ blocking = finding["blocking"] ? "yes" : "no"
41
+ "| #{path} | #{rule} | #{msg} | #{severity} | #{state} | #{blocking} |"
42
+ end
43
+ summary = "# RailVerdict #{result.gate} (#{result.policy_status})\n\n"
44
+ summary += findings_list.empty? ? "No findings.\n" : header + rows.join("\n") + "\n"
45
+ summary += "\nReasons:\n"
46
+ result.decision_reasons.each do |reason|
47
+ summary += "- #{reason.fetch('code')}: #{reason.fetch('message')}\n"
48
+ end
49
+ summary
50
+ end
51
+
52
+ def annotation_level(severity, blocking, gate)
53
+ return "error" if blocking == true
54
+ return "error" if gate == "FAIL"
55
+
56
+ case severity.to_s
57
+ when "critical", "high"
58
+ "error"
59
+ when "medium"
60
+ "warning"
61
+ when "low", "info"
62
+ "notice"
63
+ else
64
+ "notice"
65
+ end
66
+ end
67
+ private_class_method :annotation_level
68
+
69
+ def extract_path(finding)
70
+ loc = finding["location"] || finding[:location]
71
+ return finding["path"] || finding[:path] || "unknown" if !loc.is_a?(Hash)
72
+
73
+ loc["path"] || loc[:path] || finding["path"] || "unknown"
74
+ end
75
+ private_class_method :extract_path
76
+
77
+ def extract_line(finding)
78
+ loc = finding["location"] || finding[:location]
79
+ return finding["start_line"] || finding[:start_line] if !loc.is_a?(Hash) || loc.nil?
80
+
81
+ loc["start_line"] || loc[:start_line]
82
+ end
83
+ private_class_method :extract_line
84
+
85
+ def extract_column(finding)
86
+ loc = finding["location"] || finding[:location]
87
+ return finding["start_column"] || finding[:start_column] if !loc.is_a?(Hash) || loc.nil?
88
+
89
+ loc["start_column"] || loc[:start_column]
90
+ end
91
+ private_class_method :extract_column
92
+
93
+ def escape_property(value)
94
+ value.to_s.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A").gsub(":", "%3A").gsub(",", "%2C")
95
+ end
96
+ private_class_method :escape_property
97
+
98
+ def escape_data(value)
99
+ value.to_s.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
100
+ end
101
+ private_class_method :escape_data
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RailVerdict
6
+ module Reporters
7
+ module JsonReporter
8
+ module_function
9
+
10
+ def render(result)
11
+ document = result.to_schema_h
12
+ errors = SchemaValidator.validate_result(document)
13
+ raise RailVerdict::Error, "result-v1 validation failed: #{errors.join('; ')}" unless errors.empty?
14
+
15
+ JSON.generate(document) + "\n"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RailVerdict
6
+ module Reporters
7
+ module Sarif
8
+ SEVERITY_LEVEL = {
9
+ "critical" => "error",
10
+ "high" => "error",
11
+ "medium" => "warning",
12
+ "low" => "note",
13
+ "info" => "note"
14
+ }.freeze
15
+
16
+ module_function
17
+
18
+ def render(result, findings: nil)
19
+ all_findings = findings || result.findings
20
+ rules = dedup_rules(all_findings)
21
+ results = all_findings.map { |finding| finding_to_result(finding) }.sort_by { |entry| [entry["ruleId"].to_s, entry.dig("locations", 0, "physicalLocation", "artifactLocation", "uri").to_s, entry.dig("locations", 0, "physicalLocation", "region", "startLine").to_i] }
22
+
23
+ {
24
+ "version" => "2.1.0",
25
+ "$schema" => "https://json.schemastore.org/sarif-2.1.0.json",
26
+ "runs" => [
27
+ {
28
+ "tool" => {
29
+ "driver" => {
30
+ "name" => "RailVerdict",
31
+ "version" => RailVerdict::VERSION,
32
+ "informationUri" => "https://railverdict.dev",
33
+ "rules" => rules
34
+ }
35
+ },
36
+ "results" => results,
37
+ "invocations" => [
38
+ {
39
+ "executionSuccessful" => result.completion_status == "complete",
40
+ "toolExecutionNotifications" => result.decision_reasons.map { |reason| { "message" => { "text" => reason.fetch("message") }, "level" => "note", "descriptor" => { "id" => reason.fetch("code") } } }
41
+ }
42
+ ]
43
+ }
44
+ ]
45
+ }
46
+ end
47
+
48
+ def render_json(result, findings: nil)
49
+ JSON.generate(render(result, findings: findings)) + "\n"
50
+ end
51
+
52
+ def finding_to_result(finding)
53
+ severity = finding["severity"] || finding[:severity] || "info"
54
+ level = SEVERITY_LEVEL.fetch(severity.to_s, "note")
55
+ fingerprint = finding["fingerprint"] || finding[:fingerprint]
56
+ path = extract_path(finding)
57
+ start_line = extract_start_line(finding)
58
+ message = finding["message"] || finding[:message] || finding["id"] || finding[:id] || ""
59
+
60
+ location = {
61
+ "physicalLocation" => {
62
+ "artifactLocation" => { "uri" => uri_encode(path) }
63
+ }
64
+ }
65
+ if start_line
66
+ location["physicalLocation"]["region"] = { "startLine" => start_line.to_i }
67
+ col = extract_start_column(finding)
68
+ location["physicalLocation"]["region"]["startColumn"] = col.to_i if col
69
+ end
70
+
71
+ result = {
72
+ "ruleId" => finding["rule_id"] || finding[:rule_id] || finding["id"] || finding[:id] || "unknown",
73
+ "level" => level,
74
+ "message" => { "text" => message.to_s },
75
+ "locations" => [location]
76
+ }
77
+ if fingerprint
78
+ result["partialFingerprints"] = { "primaryLocationLineHash" => fingerprint.to_s }
79
+ result["fingerprints"] = { "primaryLocationLineHash" => fingerprint.to_s }
80
+ end
81
+ result["properties"] = { "severity" => severity.to_s, "state" => (finding["state"] || finding[:state] || "observed").to_s } if finding["state"] || finding[:state]
82
+ result
83
+ end
84
+ private_class_method :finding_to_result
85
+
86
+ def extract_path(finding)
87
+ loc = finding["location"] || finding[:location]
88
+ return finding["path"] || finding[:path] || "unknown" unless loc.is_a?(Hash)
89
+
90
+ loc["path"] || loc[:path] || finding["path"] || "unknown"
91
+ end
92
+ private_class_method :extract_path
93
+
94
+ def extract_start_line(finding)
95
+ loc = finding["location"] || finding[:location]
96
+ return nil unless loc.is_a?(Hash)
97
+
98
+ loc["start_line"] || loc[:start_line]
99
+ end
100
+ private_class_method :extract_start_line
101
+
102
+ def extract_start_column(finding)
103
+ loc = finding["location"] || finding[:location]
104
+ return nil unless loc.is_a?(Hash)
105
+
106
+ loc["start_column"] || loc[:start_column]
107
+ end
108
+ private_class_method :extract_start_column
109
+
110
+ def dedup_rules(findings)
111
+ seen = {}
112
+ findings.each do |finding|
113
+ rule_id = finding["rule_id"] || finding[:rule_id] || finding["id"] || "unknown"
114
+ analyzer = finding["analyzer"] || finding[:analyzer] || "unknown"
115
+ key = "#{analyzer}:#{rule_id}"
116
+ next if seen.key?(key)
117
+
118
+ seen[key] = {
119
+ "id" => rule_id.to_s,
120
+ "name" => rule_id.to_s,
121
+ "shortDescription" => { "text" => "#{analyzer}/#{rule_id}" },
122
+ "fullDescription" => { "text" => "Analyzer #{analyzer} rule #{rule_id}" },
123
+ "properties" => { "analyzer" => analyzer.to_s }
124
+ }
125
+ end
126
+ seen.values.sort_by { |rule| rule["id"].to_s }
127
+ end
128
+ private_class_method :dedup_rules
129
+
130
+ def uri_encode(path)
131
+ str = path.to_s.dup.force_encoding(Encoding::UTF_8).scrub("?")
132
+ str = str.gsub("\\", "/")
133
+ str.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")
134
+ end
135
+ private_class_method :uri_encode
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailVerdict
4
+ class RunContext
5
+ DETERMINISTIC_INPUTS = {
6
+ "locale" => "C.UTF-8",
7
+ "timezone" => "UTC",
8
+ "ordering" => "canonical-v1"
9
+ }.freeze
10
+
11
+ GEMFILE_LOCK_RAILS = /^\s+rails \((\d+\.\d+\.\d+(?:\.\d+)*)\)/
12
+ GEMFILE_LOCK_RUBY = /^\s+ruby (\d+\.\d+\.\d+)/
13
+
14
+ attr_reader :repository_root, :revision, :ruby_version, :target_ruby_version,
15
+ :rails_version, :analyzers, :analyzer_versions, :configuration_mode,
16
+ :configuration_digest, :deterministic_inputs, :git_context
17
+
18
+ def self.build(repository_root:, configuration:, analyzer_versions:, revision_resolver: nil, git_context: nil)
19
+ root =
20
+ begin
21
+ File.realpath(repository_root)
22
+ rescue Errno::ENOENT, Errno::EACCES
23
+ raise RailVerdict::Error, "repository root does not exist: #{repository_root}"
24
+ end
25
+ raise RailVerdict::Error, "repository root is not a directory: #{repository_root}" unless File.directory?(root)
26
+
27
+ revision = revision_resolver ? revision_resolver.call(root) : nil
28
+ lockfile_text = read_lockfile(root)
29
+
30
+ new(
31
+ repository_root: root,
32
+ revision: revision,
33
+ ruby_version: RUBY_VERSION,
34
+ target_ruby_version: lockfile_text && lockfile_text[GEMFILE_LOCK_RUBY, 1],
35
+ rails_version: lockfile_text && lockfile_text[GEMFILE_LOCK_RAILS, 1],
36
+ analyzers: configuration.analyzers,
37
+ analyzer_versions: analyzer_versions,
38
+ configuration_mode: configuration.mode,
39
+ configuration_digest: configuration.digest,
40
+ deterministic_inputs: DETERMINISTIC_INPUTS,
41
+ git_context: git_context
42
+ )
43
+ end
44
+
45
+ def self.read_lockfile(root)
46
+ path = File.join(root, "Gemfile.lock")
47
+ return nil unless File.file?(path)
48
+
49
+ text = File.binread(path).force_encoding(Encoding::UTF_8)
50
+ return nil unless text.valid_encoding?
51
+
52
+ text
53
+ end
54
+
55
+ private_class_method :read_lockfile
56
+
57
+ def initialize(repository_root:, revision:, ruby_version:, target_ruby_version:, rails_version:,
58
+ analyzers:, analyzer_versions:, configuration_mode:, configuration_digest:,
59
+ deterministic_inputs:, git_context: nil)
60
+ @repository_root = repository_root.dup.freeze
61
+ @revision = revision && revision.dup.freeze
62
+ @ruby_version = ruby_version.dup.freeze
63
+ @target_ruby_version = target_ruby_version && target_ruby_version.dup.freeze
64
+ @rails_version = rails_version && rails_version.dup.freeze
65
+ @analyzers = deep_freeze(analyzers)
66
+ @analyzer_versions = deep_freeze(analyzer_versions)
67
+ @configuration_mode = configuration_mode.dup.freeze
68
+ @configuration_digest = configuration_digest.dup.freeze
69
+ @deterministic_inputs = deep_freeze(deterministic_inputs.dup)
70
+ @git_context = git_context
71
+ freeze
72
+ end
73
+
74
+ def changed?
75
+ !@git_context.nil?
76
+ end
77
+
78
+ private
79
+
80
+ def deep_freeze(value)
81
+ case value
82
+ when Hash
83
+ value.each do |key, child|
84
+ key.freeze if key.is_a?(String)
85
+ deep_freeze(child)
86
+ end
87
+ value.freeze
88
+ when Array
89
+ value.each { |child| deep_freeze(child) }
90
+ value.freeze
91
+ when String
92
+ value.freeze
93
+ else
94
+ value
95
+ end
96
+ value
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "json_schemer"
5
+
6
+ module RailVerdict
7
+ module SchemaValidator
8
+ CONFIGURATION_SCHEMA = "configuration-v1.schema.json"
9
+ CONFIGURATION_V11_SCHEMA = "configuration-v1.1.schema.json"
10
+ CONFIGURATION_V12_SCHEMA = "configuration-v1.2.schema.json"
11
+ CONFIGURATION_V13_SCHEMA = "configuration-v1.3.schema.json"
12
+ CONFIGURATION_V14_SCHEMA = "configuration-v1.4.schema.json"
13
+ AI_ANALYSIS_SCHEMA = "ai-analysis-v1.schema.json"
14
+ FINDING_SCHEMA = "finding-v1.schema.json"
15
+ RESULT_SCHEMA = "result-v1.schema.json"
16
+ BASELINE_SCHEMA = "baseline-v1.schema.json"
17
+ WAIVER_SCHEMA = "waiver-v1.schema.json"
18
+ WAIVERS_SCHEMA = "waivers-v1.schema.json"
19
+ REPAIR_PACKET_SCHEMA = "repair-packet-v1.schema.json"
20
+
21
+ def self.schema_dir
22
+ File.expand_path("../../schemas", __dir__)
23
+ end
24
+
25
+ def self.validate_configuration(data)
26
+ schema_name = if data.is_a?(Hash) && data["version"] == 1.4
27
+ CONFIGURATION_V14_SCHEMA
28
+ elsif data.is_a?(Hash) && data["version"] == 1.3
29
+ CONFIGURATION_V13_SCHEMA
30
+ elsif data.is_a?(Hash) && data["version"] == 1.2
31
+ CONFIGURATION_V12_SCHEMA
32
+ elsif data.is_a?(Hash) && data["version"] == 1.1
33
+ CONFIGURATION_V11_SCHEMA
34
+ else
35
+ CONFIGURATION_SCHEMA
36
+ end
37
+ validate(data, schema_name)
38
+ end
39
+
40
+ def self.validate_ai_analysis(data)
41
+ validate(data, AI_ANALYSIS_SCHEMA)
42
+ end
43
+
44
+ def self.validate_finding(data)
45
+ validate(data, FINDING_SCHEMA)
46
+ end
47
+
48
+ def self.validate_result(data)
49
+ validate(data, RESULT_SCHEMA)
50
+ end
51
+
52
+ def self.validate_baseline(data)
53
+ validate(data, BASELINE_SCHEMA)
54
+ end
55
+
56
+ def self.validate_waiver(data)
57
+ validate(data, WAIVER_SCHEMA)
58
+ end
59
+
60
+ def self.validate_waivers(data)
61
+ validate(data, WAIVERS_SCHEMA)
62
+ end
63
+
64
+ def self.validate_repair_packet(data)
65
+ validate(data, REPAIR_PACKET_SCHEMA)
66
+ end
67
+
68
+ def self.validate(data, schema_name)
69
+ schema = load_schema(schema_name)
70
+ JSONSchemer.schema(schema).validate(data).map { |error| format_error(error) }
71
+ end
72
+
73
+ def self.load_schema(schema_name)
74
+ JSON.parse(File.read(File.join(schema_dir, schema_name)))
75
+ end
76
+
77
+ def self.format_error(error)
78
+ pointer = error["data_pointer"].to_s
79
+ location = pointer.empty? ? "$" : "$#{pointer.gsub('/', '.')}"
80
+ "#{location}: #{error["error"]}"
81
+ end
82
+
83
+ private_class_method :load_schema, :format_error
84
+ end
85
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "psych"
4
+
5
+ module RailVerdict
6
+ module StrictYaml
7
+ class Error < RailVerdict::Error
8
+ attr_reader :property_path
9
+
10
+ def initialize(message, property_path: nil)
11
+ super(message)
12
+ @property_path = property_path
13
+ end
14
+ end
15
+
16
+ PLAIN = Psych::Nodes::Scalar::PLAIN
17
+ INTEGER = /\A[-+]?[0-9]+\z/
18
+ FLOAT = /\A[-+]?[0-9]*\.[0-9]+(?:[eE][-+]?[0-9]+)?\z/
19
+ FLOAT_EXPONENT = /\A[-+]?[0-9]+[eE][-+]?[0-9]+\z/
20
+
21
+ def self.parse(text, source_path)
22
+ document =
23
+ begin
24
+ Psych.parse(text)
25
+ rescue Psych::SyntaxError => error
26
+ raise Error.new(
27
+ "#{source_path}: YAML syntax error: #{error.message.lines.first.to_s.strip}",
28
+ property_path: "$"
29
+ )
30
+ end
31
+
32
+ materialize(document&.root, "$")
33
+ end
34
+
35
+ def self.materialize(node, path)
36
+ case node
37
+ when nil
38
+ nil
39
+ when Psych::Nodes::Mapping
40
+ materialize_mapping(node, path)
41
+ when Psych::Nodes::Sequence
42
+ materialize_sequence(node, path)
43
+ when Psych::Nodes::Scalar
44
+ materialize_scalar(node, path)
45
+ when Psych::Nodes::Alias
46
+ raise Error.new("#{path}: aliases are not permitted", property_path: path)
47
+ else
48
+ raise Error.new("#{path}: unsupported YAML node", property_path: path)
49
+ end
50
+ end
51
+
52
+ def self.materialize_mapping(node, path)
53
+ reject_tag(node, path)
54
+ reject_anchor(node, path)
55
+ children = node.children || []
56
+ result = {}
57
+ seen = {}
58
+ children.each_slice(2) do |key_node, value_node|
59
+ raise Error.new("#{path}: mapping entries require a key and a value", property_path: path) if value_node.nil?
60
+
61
+ key = mapping_key(key_node, path)
62
+ if seen.key?(key)
63
+ raise Error.new("#{path}.#{key}: duplicate key #{key.inspect}", property_path: "#{path}.#{key}")
64
+ end
65
+
66
+ seen[key] = true
67
+ result[key] = materialize(value_node, "#{path}.#{key}")
68
+ end
69
+ result
70
+ end
71
+
72
+ def self.materialize_sequence(node, path)
73
+ reject_tag(node, path)
74
+ reject_anchor(node, path)
75
+ (node.children || []).each_with_index.map do |child, index|
76
+ materialize(child, "#{path}[#{index}]")
77
+ end
78
+ end
79
+
80
+ def self.materialize_scalar(node, path)
81
+ reject_tag(node, path)
82
+ reject_anchor(node, path)
83
+ return node.value.dup.force_encoding(Encoding::UTF_8) unless node.style == PLAIN
84
+
85
+ resolve_plain_scalar(node.value)
86
+ end
87
+
88
+ def self.mapping_key(node, path)
89
+ case node
90
+ when Psych::Nodes::Scalar
91
+ node.value.to_s
92
+ when Psych::Nodes::Alias
93
+ raise Error.new("#{path}: aliases are not permitted in mapping keys", property_path: path)
94
+ else
95
+ raise Error.new("#{path}: mapping keys must be scalars", property_path: path)
96
+ end
97
+ end
98
+
99
+ def self.resolve_plain_scalar(value)
100
+ case value
101
+ when "", "~", "null", "Null", "NULL"
102
+ nil
103
+ when "true", "True", "TRUE"
104
+ true
105
+ when "false", "False", "FALSE"
106
+ false
107
+ when INTEGER
108
+ Integer(value)
109
+ when FLOAT, FLOAT_EXPONENT
110
+ Float(value)
111
+ else
112
+ value.dup
113
+ end
114
+ end
115
+
116
+ def self.reject_tag(node, path)
117
+ tag = node.tag
118
+ return if tag.nil? || tag.empty?
119
+
120
+ raise Error.new("#{path}: explicit YAML tags are not permitted", property_path: path)
121
+ end
122
+
123
+ def self.reject_anchor(node, path)
124
+ anchor = node.respond_to?(:anchor) ? node.anchor : nil
125
+ return if anchor.nil? || anchor.empty?
126
+
127
+ raise Error.new("#{path}: anchors are not permitted", property_path: path)
128
+ end
129
+
130
+ private_class_method :materialize, :materialize_mapping, :materialize_sequence,
131
+ :materialize_scalar, :mapping_key, :resolve_plain_scalar,
132
+ :reject_tag, :reject_anchor
133
+ end
134
+ end