rails_code_auditor 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 83cd23ea4027538587a1814cbf2a3472bd818fe0cb15bf492a3ab54b565c2e63
4
+ data.tar.gz: 40438e254bed8d07966559ac52df8a3063f427167fbc236a8eac0fba6a591c1d
5
+ SHA512:
6
+ metadata.gz: e1cdd901ff82b3f2c2d5f0be65175a03b534a6d3a401c34a9c85861401ea2cf416162a1045fe34b2e8cf0ef79a3a220a1c1e9f87b81fff05d06b973735974227
7
+ data.tar.gz: 721d41c939a3e00321508a4c36033221e58b6f67a6fd1f0ee9df090e1ae289fafe0b65c94e0f07b7e20c6b3c4bad375463b763f00d440c7a96c29af529e6cde2
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "rails_code_auditor"
4
+ RailsCodeAuditor.run(ARGV)
@@ -0,0 +1,150 @@
1
+ require "fileutils"
2
+
3
+ module RailsCodeAuditor
4
+ class Analyzer
5
+ REPORT_FOLDER = "report"
6
+
7
+ def self.ruby_version
8
+ Gem::Version.new(RUBY_VERSION)
9
+ end
10
+
11
+ def self.rails_version
12
+ defined?(Rails) ? Gem::Version.new(Rails.version) : nil
13
+ end
14
+
15
+ def self.run_cmd(command, raw: false)
16
+ puts "Running: #{command}"
17
+ output = `#{command}`
18
+ if output.empty?
19
+ nil
20
+ else
21
+ begin
22
+ raw ? output : JSON.parse(output)
23
+ rescue StandardError
24
+ output
25
+ end
26
+ end
27
+ end
28
+
29
+ def self.ensure_report_folder
30
+ FileUtils.mkdir_p(REPORT_FOLDER)
31
+ end
32
+
33
+ def self.write_html_report(tool_name, content)
34
+ path = File.join(REPORT_FOLDER, "#{tool_name}.html")
35
+ File.open(path, "w") do |f|
36
+ f.puts "<html><head><title>#{tool_name.capitalize} Report</title></head><body><pre>"
37
+ f.puts "<h1>#{tool_name.capitalize} Report</h1>"
38
+ f.puts content
39
+ f.puts "</pre></body></html>"
40
+ end
41
+ path
42
+ end
43
+
44
+ def self.generate_brakeman_html
45
+ run_cmd("brakeman -o #{REPORT_FOLDER}/brakeman.html", raw: true)
46
+ "#{REPORT_FOLDER}/brakeman.html"
47
+ end
48
+
49
+ def self.generate_rails_best_practices_html
50
+ run_cmd("rails_best_practices -f html --output-file #{REPORT_FOLDER}/rails_best_practices.html", raw: true)
51
+ "#{REPORT_FOLDER}/rails_best_practices.html"
52
+ end
53
+
54
+ def self.generate_rubycritic_html
55
+ run_cmd("rubycritic --no-browser --path #{REPORT_FOLDER}/rubycritic", raw: true)
56
+ "#{REPORT_FOLDER}/rubycritic/overview.html"
57
+ end
58
+
59
+ def self.generate_reek_html
60
+ run_cmd("reek --format html > report/reek.html", raw: true)
61
+ "#{REPORT_FOLDER}/reek.html"
62
+ end
63
+
64
+ def self.run_all
65
+ ensure_report_folder
66
+
67
+ results = {}
68
+
69
+ results[:brakeman] = {
70
+ json: run_cmd("brakeman -f json --no-exit-on-error"),
71
+ html_path: run_cmd("brakeman -o #{REPORT_FOLDER}/brakeman.html", raw: true)
72
+ }
73
+
74
+ results[:bundler_audit] = {
75
+ json: run_cmd("bundle audit check --verbose"),
76
+ html_path: write_html_report("bundler_audit", run_cmd("bundle audit check --verbose"))
77
+ }
78
+
79
+ results[:rubocop] = if ruby_version >= Gem::Version.new("2.7")
80
+ {
81
+ json: run_cmd("rubocop --format json"),
82
+ html_path: write_html_report("rubocop", run_cmd("rubocop --format simple"))
83
+ }
84
+ else
85
+ { skipped: true, reason: "Rubocop requires Ruby >= 2.7" }
86
+ end
87
+
88
+ results[:rails_best_practices] = {
89
+ json: run_cmd("rails_best_practices --format json"),
90
+ html_path: run_cmd("rails_best_practices -f html --output-file #{REPORT_FOLDER}/rails_best_practices.html",
91
+ raw: true)
92
+ }
93
+
94
+ results[:flay] = {
95
+ text: run_cmd("flay --mass 50 ."),
96
+ html_path: write_html_report("flay", run_cmd("flay --mass 50 ."))
97
+ }
98
+
99
+ results[:flog] = {
100
+ text: run_cmd("flog ."),
101
+ html_path: write_html_report("flog", run_cmd("flog ."))
102
+ }
103
+
104
+ results[:license_finder] = if ruby_version >= Gem::Version.new("2.7")
105
+ {
106
+ json: run_cmd("license_finder --format json"),
107
+ html_path: write_html_report("license_finder",
108
+ run_cmd("license_finder --format text"))
109
+ }
110
+ else
111
+ { skipped: true, reason: "LicenseFinder requires Ruby >= 2.7" }
112
+ end
113
+
114
+ results[:reek] = {
115
+ json: run_cmd("reek --format json"),
116
+ html_path: run_cmd("reek --format html > #{REPORT_FOLDER}/reek.html", raw: true)
117
+ }
118
+
119
+ begin
120
+ Timeout.timeout(300) do
121
+ results[:rubycritic] = if ruby_version >= Gem::Version.new("2.7")
122
+ {
123
+ json: run_cmd("rubycritic --format json"),
124
+ html_path: run_cmd("rubycritic --no-browser --path #{REPORT_FOLDER}/rubycritic",
125
+ raw: true)
126
+ }
127
+ else
128
+ { skipped: true, reason: "RubyCritic requires Ruby >= 2.7" }
129
+ end
130
+ end
131
+ rescue Timeout::Error
132
+ results[:rubycritic] = { error: "RubyCritic timed out after 5 minutes" }
133
+ end
134
+
135
+ results[:fasterer] = {
136
+ text: run_cmd("fasterer ."),
137
+ html_path: write_html_report("fasterer", run_cmd("fasterer ."))
138
+ }
139
+
140
+ # Optional: tools only if Rails >= 5
141
+ if rails_version && rails_version >= Gem::Version.new("5.0")
142
+ results[:grover] = {
143
+ html_path: write_html_report("grover", "Grover logic here (if you use it)")
144
+ }
145
+ end
146
+
147
+ results
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,80 @@
1
+ require "gruff"
2
+ require 'active_support/core_ext/hash/indifferent_access'
3
+
4
+ module RailsCodeAuditor
5
+ class Grapher
6
+ REPORT_PATH = "./report".freeze
7
+
8
+ def self.generate(results)
9
+ results = results.with_indifferent_access
10
+ Dir.mkdir(REPORT_PATH) unless Dir.exist?(REPORT_PATH)
11
+
12
+ graphs = []
13
+
14
+ summary = {
15
+ "Security" => results[:security],
16
+ "Code Quality" => results[:code_quality],
17
+ "Dependencies" => results[:dependencies],
18
+ "Test Coverage" => results[:test_coverage],
19
+ "Overall" => results[:overall]
20
+ }.compact
21
+
22
+ graphs << graph_bar("Audit Scores", summary)
23
+
24
+ summary.each do |label, data|
25
+ graphs << graph_pie(label, data[:score])
26
+ end
27
+
28
+ graphs
29
+ end
30
+
31
+ def self.graph_bar(title, metrics)
32
+ g = Gruff::Bar.new
33
+ g.title = title
34
+
35
+ labels = {}
36
+ metrics.each_with_index do |(label, data), index|
37
+ score = data[:score] || 0
38
+ labels[index] = label
39
+ g.data(label, [score], bar_color(score))
40
+ end
41
+
42
+ g.labels = labels
43
+
44
+ file_name = "#{title.downcase.gsub(" ", "_")}.png"
45
+ path = File.join(REPORT_PATH, file_name)
46
+ g.write(path)
47
+
48
+ puts "Generated graph at #{path}"
49
+
50
+ { title: title, path: path }
51
+ end
52
+
53
+ def self.graph_pie(title, score)
54
+ score = score || 0
55
+ remaining = 100 - score
56
+
57
+ g = Gruff::Pie.new
58
+ g.title = title
59
+ g.data("Score", score, bar_color(score))
60
+ g.data("Remaining", remaining, "#dddddd")
61
+
62
+ file_name = "#{title.downcase.gsub(" ", "_")}_pie.png"
63
+ path = File.join(REPORT_PATH, file_name)
64
+ g.write(path)
65
+
66
+ puts "Generated pie chart: #{path}"
67
+
68
+ { title: title, path: path }
69
+ end
70
+
71
+ def self.bar_color(score)
72
+ case score
73
+ when 0..49 then '#e74c3c' # red
74
+ when 50..74 then '#f1c40f' # yellow
75
+ when 75..89 then '#3498db' # blue
76
+ else '#2ecc71' # green
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,62 @@
1
+ require "fileutils"
2
+ require "grover"
3
+
4
+ module RailsCodeAuditor
5
+ class HtmlToPdfConverter
6
+ INPUT_PATHS = ["report", "report/rubycritic"]
7
+ OUTPUT_PATH = "report/pdf"
8
+
9
+ def self.puppeteer_installed?
10
+ system("npx puppeteer --version > /dev/null 2>&1")
11
+ end
12
+
13
+ def self.convert_all
14
+ unless puppeteer_installed?
15
+ puts "[!] Puppeteer is not installed. Please run: yarn add puppeteer"
16
+ return []
17
+ end
18
+
19
+ FileUtils.mkdir_p(OUTPUT_PATH)
20
+ pdf_paths = []
21
+
22
+ html_files = INPUT_PATHS.flat_map { |path| Dir["#{path}/*.html"] }
23
+
24
+ if html_files.empty?
25
+ puts "[!] No HTML files found in #{INPUT_PATHS.join(', ')}"
26
+ return []
27
+ end
28
+
29
+ html_files.each do |html_path|
30
+ begin
31
+ relative_name = html_path.sub(%r{^.*?report/}, "").gsub("/", "_")
32
+ pdf_filename = relative_name.sub(/\.html$/, ".pdf")
33
+ pdf_output_path = File.join(OUTPUT_PATH, pdf_filename)
34
+
35
+ html_content = File.read(html_path)
36
+
37
+ grover = Grover.new(
38
+ html_content,
39
+ print_background: true,
40
+ prefer_css_page_size: true,
41
+ wait_until: 'networkidle0',
42
+ format: 'A4',
43
+ margin: { top: '1cm', bottom: '1cm' }
44
+ )
45
+
46
+ File.write(pdf_output_path, grover.to_pdf)
47
+ pdf_paths << pdf_output_path
48
+ puts "[✓] PDF generated: #{pdf_output_path}"
49
+
50
+ rescue Grover::DependencyError => e
51
+ puts "[!] Puppeteer is required but not available. Skipping: #{html_path}"
52
+ next
53
+ rescue => e
54
+ puts "[!] Failed to convert #{html_path}: #{e.class} - #{e.message}"
55
+ next
56
+ end
57
+ end
58
+
59
+ pdf_paths
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,71 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "uri"
4
+
5
+ module RailsCodeAuditor
6
+ class LlmClient
7
+ def self.sanitize_results(results)
8
+ results.transform_values do |tool|
9
+ tool.dup.tap do |entry|
10
+ if entry.is_a?(Hash) && entry[:details].is_a?(String)
11
+ entry[:details] = entry[:details].slice(0, 500) # Trim details to avoid LLM overload
12
+ entry[:details].gsub!(/\e\[[\d;]*m/, '') # Remove ANSI color codes
13
+ end
14
+ end
15
+ end
16
+ end
17
+ def self.score_with_llm(json_results)
18
+ sanitized = sanitize_results(json_results)
19
+ puts "[*] Scoring with LLM (LLaMA3)..."
20
+
21
+ prompt = <<~PROMPT
22
+ Analyze the following Rails code audit summary and return a JSON object like:
23
+ {
24
+ "security": 75,
25
+ "code_quality": 80,
26
+ "test_coverage": 60,
27
+ "dependencies": 90,
28
+ "overall": 76
29
+ }
30
+
31
+ Only return the raw JSON, nothing else.
32
+
33
+ Input:
34
+ #{JSON.pretty_generate(sanitized)}
35
+ PROMPT
36
+
37
+ uri = URI("http://localhost:11434/api/generate")
38
+ body = {
39
+ model: "llama3",
40
+ prompt: prompt,
41
+ stream: false
42
+ }
43
+
44
+ response = Net::HTTP.post(uri, body.to_json, "Content-Type" => "application/json")
45
+ response_body = JSON.parse(response.body)
46
+
47
+ raw_output = response_body["response"].strip
48
+ # Try to extract JSON from any surrounding text
49
+ json_match = raw_output.match(/\{.*\}/m)
50
+
51
+ if json_match
52
+ parsed_scores = JSON.parse(json_match[0])
53
+ scored_results = parsed_scores.transform_values do |score|
54
+ remark = case score
55
+ when 90..100 then "Excellent"
56
+ when 75..89 then "Good"
57
+ when 60..74 then "Average"
58
+ else "Needs Improvement"
59
+ end
60
+ { score: score, remark: remark }
61
+ end
62
+ scored_results
63
+ else
64
+ raise "Response did not contain valid JSON"
65
+ end
66
+ rescue => e
67
+ puts "[!] LLM scoring failed: #{e.message}"
68
+ nil
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,80 @@
1
+ require "prawn"
2
+ require "prawn/table"
3
+ require "fileutils"
4
+ require "combine_pdf"
5
+ require "active_support/core_ext/string/inflections"
6
+
7
+ module RailsCodeAuditor
8
+ class PdfGenerator
9
+ OUTPUT_PATH = "code_audit_report.pdf"
10
+ TEMP_PRWAN_PDF = "tmp/main_audit.pdf"
11
+
12
+ def self.generate(results, scores, graphs, html_pdf_paths = [])
13
+ FileUtils.mkdir_p("tmp")
14
+ FileUtils.mkdir_p("report/pdf")
15
+
16
+ # Generate main Prawn PDF
17
+ Prawn::Document.generate(TEMP_PRWAN_PDF) do |pdf|
18
+ pdf.text "Rails Code Audit Report", size: 24, style: :bold, align: :center
19
+ pdf.move_down 20
20
+
21
+ # Summary Scores Table
22
+ pdf.text "Audit Summary Scores", size: 16, style: :bold
23
+ pdf.move_down 10
24
+ summary_data = [["Metric", "Score (0-100)", "Remarks"]]
25
+ scores.each do |metric, value|
26
+ summary_data << [metric.to_s.humanize, value[:score], value[:remark]]
27
+ end
28
+ pdf.table(summary_data, header: true, width: pdf.bounds.width)
29
+ pdf.move_down 20
30
+
31
+ # Detailed Audit Results
32
+ pdf.text "Detailed Audit Results", size: 16, style: :bold
33
+ pdf.move_down 10
34
+ results.each do |check_name, result|
35
+ pdf.text check_name.to_s.humanize, size: 12, style: :bold
36
+ pdf.text "Status: #{result[:status]}"
37
+ pdf.text "Details: #{result[:details]}"
38
+ pdf.move_down 10
39
+ end
40
+
41
+ # Graphs (optional)
42
+ if graphs && graphs.any?
43
+ pdf.start_new_page
44
+ pdf.text "Visual Graphs", size: 16, style: :bold
45
+ pdf.move_down 10
46
+
47
+ graphs.each do |graph|
48
+ if File.exist?(graph[:path])
49
+ pdf.text graph[:title], size: 12, style: :bold
50
+ pdf.image graph[:path], fit: [500, 300]
51
+ pdf.move_down 20
52
+ else
53
+ puts "[!] Graph file missing: #{graph[:path]}"
54
+ end
55
+ end
56
+ end
57
+
58
+ pdf.number_pages "Page <page> of <total>", at: [pdf.bounds.right - 150, 0]
59
+ end
60
+
61
+ puts "[✓] Main audit PDF saved to #{TEMP_PRWAN_PDF}"
62
+
63
+ # Merge all PDFs (main + html converted)
64
+ combined_pdf = CombinePDF.new
65
+ combined_pdf << CombinePDF.load(TEMP_PRWAN_PDF)
66
+
67
+ html_pdf_paths.each do |pdf_path|
68
+ if File.exist?(pdf_path)
69
+ puts "[+] Merging #{pdf_path}"
70
+ combined_pdf << CombinePDF.load(pdf_path)
71
+ else
72
+ puts "[!] Skipped missing HTML-generated PDF: #{pdf_path}"
73
+ end
74
+ end
75
+
76
+ combined_pdf.save(OUTPUT_PATH)
77
+ puts "[✓] Final merged PDF saved to #{OUTPUT_PATH}"
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,175 @@
1
+ module RailsCodeAuditor
2
+ class ReportGenerator
3
+ def self.normalize(results)
4
+ {
5
+ brakeman: summarize_or_skip(:brakeman, results) { |res| summarize_brakeman(res[:json]) },
6
+ bundler_audit: summarize_or_skip(:bundler_audit, results) { |res| summarize_bundler(res[:json]) },
7
+ rubocop: summarize_or_skip(:rubocop, results) { |res| summarize_rubocop(res[:json]) },
8
+ rails_best_practices: summarize_or_skip(:rails_best_practices, results) do |res|
9
+ summarize_rails_best_practices(res[:json])
10
+ end,
11
+ flay: summarize_or_skip(:flay, results) { |res| summarize_text_tool("Flay", res[:text]) },
12
+ flog: summarize_or_skip(:flog, results) { |res| summarize_text_tool("Flog", res[:text]) },
13
+ license_finder: summarize_or_skip(:license_finder, results) { |res| summarize_license_finder(res[:json]) },
14
+ reek: summarize_or_skip(:reek, results) { |res| summarize_reek(res[:json]) },
15
+ rubycritic: summarize_or_skip(:rubycritic, results) { |res| summarize_rubycritic(res[:json]) },
16
+ fasterer: summarize_or_skip(:fasterer, results) { |res| summarize_fasterer(res[:text]) }
17
+ }
18
+ end
19
+
20
+ def self.summarize_or_skip(tool, results)
21
+ if results[tool]&.dig(:skipped)
22
+ {
23
+ status: "Skipped",
24
+ details: results[tool][:reason] || "Tool not available in this environment"
25
+ }
26
+ elsif results[tool].nil?
27
+ {
28
+ status: "Not Run",
29
+ details: "No data available for #{tool}"
30
+ }
31
+ else
32
+ yield(results[tool])
33
+ end
34
+ end
35
+
36
+ def self.summarize_brakeman(raw)
37
+ json = parse_json_input(raw, label: "Brakeman")
38
+ warnings = json["warnings"] || []
39
+ summary = warnings.map { |w| "#{w["warning_type"]}: #{w["message"]} in #{w["file"]}" }.join("\n")
40
+ {
41
+ status: "#{warnings.size} security warning#{"s" unless warnings.size == 1}",
42
+ details: summary
43
+ }
44
+ end
45
+
46
+ def self.parse_json_input(input, label: "JSON")
47
+ case input
48
+ when String
49
+ begin
50
+ JSON.parse(input)
51
+ rescue JSON::ParserError => e
52
+ warn "❌ Failed to parse #{label} string: #{e.message}"
53
+ {}
54
+ end
55
+ when Hash
56
+ input
57
+ else
58
+ warn "❌ Unsupported #{label} input type: #{input.class}"
59
+ {}
60
+ end
61
+ end
62
+
63
+ def self.summarize_bundler(raw)
64
+ json = begin
65
+ JSON.parse(raw)
66
+ rescue StandardError
67
+ {}
68
+ end
69
+ vulns = json["advisories"] || []
70
+ details = vulns.map { |v| "#{v["gem"]}: #{v["title"]}" }.join("\n")
71
+ {
72
+ status: "#{vulns.size} vulnerability#{"ies" unless vulns.size == 1}",
73
+ details: details
74
+ }
75
+ end
76
+
77
+ def self.summarize_rubocop(raw)
78
+ json = begin
79
+ JSON.parse(raw)
80
+ rescue StandardError
81
+ {}
82
+ end
83
+ offenses = json["files"]&.flat_map { |f| f["offenses"] } || []
84
+ details = offenses.map { |o| "#{o["cop_name"]}: #{o["message"]}" }.join("\n")
85
+ {
86
+ status: "#{offenses.size} code offense#{"s" unless offenses.size == 1}",
87
+ details: details
88
+ }
89
+ end
90
+
91
+ def self.summarize_rails_best_practices(raw)
92
+ issues = begin
93
+ JSON.parse(raw)
94
+ rescue StandardError
95
+ []
96
+ end
97
+
98
+ status = "#{issues.size} issue#{"s" unless issues.size == 1}"
99
+ grouped = issues.group_by { |issue| issue["message"] }
100
+
101
+ details = grouped.map do |message, group|
102
+ "#{message} (#{group.size}x)"
103
+ end.join("\n")
104
+
105
+ {
106
+ status: status,
107
+ details: details
108
+ }
109
+ end
110
+
111
+ def self.summarize_text_tool(name, raw)
112
+ lines = raw ? raw.split("\n").reject(&:empty?) : []
113
+ {
114
+ status: "#{lines.size} issue#{"s" unless lines.size == 1}",
115
+ details: lines.first(10).join("\n") + (lines.size > 10 ? "\n..." : "")
116
+ }
117
+ end
118
+
119
+ def self.summarize_license_finder(raw)
120
+ json = begin
121
+ JSON.parse(raw)
122
+ rescue StandardError
123
+ []
124
+ end
125
+ problematic = json.select { |pkg| pkg["approved"] == false }
126
+ details = problematic.map { |p| "#{p["name"]} - #{p["licenses"].join(", ")}" }.join("\n")
127
+ {
128
+ status: "#{problematic.size} unapproved license#{"s" unless problematic.size == 1}",
129
+ details: details
130
+ }
131
+ end
132
+
133
+ def self.summarize_reek(raw)
134
+ parsed = raw.is_a?(String) ? JSON.parse(raw, symbolize_names: true) : raw
135
+
136
+ puts "JSON array but got #{parsed.class}" unless parsed.is_a?(Array)
137
+
138
+ total_smells = parsed.size
139
+ sample_smells = parsed.first(10)
140
+
141
+ details = sample_smells.map do |smell|
142
+ "#{smell["source"]} [#{smell["lines"].join(", ")}]: #{smell["message"]} (#{smell["smell_type"]})"
143
+ end
144
+
145
+ {
146
+ status: "#{total_smells} smell#{"s" unless total_smells == 1}",
147
+ details: details.join("\n") + (total_smells > 10 ? "\n..." : "")
148
+ }
149
+ end
150
+
151
+ def self.summarize_rubycritic(raw)
152
+ lines = raw.to_s.split("\n").map(&:strip).reject(&:empty?)
153
+
154
+ # Extract score
155
+ score_line = lines.find { |line| line.match?(/^Score:\s+\d+(\.\d+)?$/) }
156
+ score = score_line&.match(/Score:\s+([\d.]+)/)&.captures&.first
157
+
158
+ # Extract letter-grade issues (lines that start with a grade followed by a dash)
159
+ issues = lines.select { |line| line.match?(/^\b[FABCDE]\b\s+-\s+/) }
160
+
161
+ {
162
+ status: score ? "Score: #{score}" : "No score found",
163
+ details: issues.first(10).join("\n") + (issues.size > 10 ? "\n..." : "")
164
+ }
165
+ end
166
+
167
+ def self.summarize_fasterer(raw)
168
+ suggestions = raw.lines.select { |line| line.include?(":") }
169
+ {
170
+ status: "#{suggestions.size} performance suggestion#{"s" unless suggestions.size == 1}",
171
+ details: suggestions.join("\n")
172
+ }
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,129 @@
1
+ module RailsCodeAuditor
2
+ class Scorer
3
+ def self.score(results)
4
+ scores = {
5
+ security: {
6
+ score: security_score(results),
7
+ remark: remark_for(security_score(results))
8
+ },
9
+ code_quality: {
10
+ score: code_quality_score(results),
11
+ remark: remark_for(code_quality_score(results))
12
+ },
13
+ dependencies: {
14
+ score: dependency_score(results),
15
+ remark: remark_for(dependency_score(results))
16
+ },
17
+ test_coverage: {
18
+ score: test_coverage_score(results),
19
+ remark: remark_for(test_coverage_score(results))
20
+ }
21
+ }
22
+
23
+ overall = overall_score(scores)
24
+ scores[:overall] = {
25
+ score: overall,
26
+ remark: remark_for(overall)
27
+ }
28
+
29
+ scores
30
+ end
31
+
32
+ def self.remark_for(score)
33
+ case score
34
+ when 90..100 then "Excellent"
35
+ when 75..89 then "Good"
36
+ when 60..74 then "Fair"
37
+ else "Needs Improvement"
38
+ end
39
+ end
40
+
41
+ def self.security_score(results)
42
+ tool_scores = [
43
+ extract_issue_count(results.dig(:brakeman, :status)),
44
+ extract_issue_count(results.dig(:bundler_audit, :status))
45
+ ].compact
46
+
47
+ total = tool_scores.sum
48
+ active_tools = tool_scores.size
49
+ calculate_score(total, active_tools)
50
+ end
51
+
52
+ def self.code_quality_score(results)
53
+ issue_counts = [
54
+ extract_issue_count(results.dig(:rubocop, :status)),
55
+ extract_issue_count(results.dig(:rails_best_practices, :status)),
56
+ extract_issue_count(results.dig(:reek, :status)),
57
+ extract_issue_count(results.dig(:flay, :status)),
58
+ extract_issue_count(results.dig(:flog, :status)),
59
+ extract_issue_count(results.dig(:fasterer, :status))
60
+ ].compact
61
+
62
+ total_issues = issue_counts.sum
63
+ active_tool_count = issue_counts.size
64
+ issue_score = calculate_score(total_issues, active_tool_count)
65
+
66
+ # Handle RubyCritic separately
67
+ rubycritic_score = extract_rubycritic_score(results.dig(:rubycritic, :status))
68
+
69
+ if rubycritic_score
70
+ ((issue_score + rubycritic_score) / 2.0).round
71
+ else
72
+ issue_score
73
+ end
74
+ end
75
+
76
+ def self.extract_rubycritic_score(status)
77
+ return nil unless status.is_a?(String)
78
+ return nil if status.downcase.include?("skipped") || status.downcase.include?("not run")
79
+
80
+ return unless match = status.match(/Score:\s*([0-9.]+)/)
81
+
82
+ match[1].to_f.round
83
+ end
84
+
85
+ def self.dependency_score(results)
86
+ count = extract_issue_count(results.dig(:license_finder, :status))
87
+ return 100 if count.nil? # Tool skipped
88
+
89
+ calculate_score(count, 1)
90
+ end
91
+
92
+ def self.test_coverage_score(results)
93
+ status = results.dig(:simplecov, :status)
94
+ return 100 if !status.is_a?(String) || status.downcase.include?("skipped") || status.downcase.include?("not run")
95
+
96
+ if status.match(/Coverage:\s*([\d.]+)/)
97
+ ::Regexp.last_match(1).to_f.round
98
+ else
99
+ 0
100
+ end
101
+ end
102
+
103
+ def self.extract_issue_count(status)
104
+ return nil unless status.is_a?(String)
105
+ return nil if status.downcase.include?("skipped") || status.downcase.include?("not run")
106
+
107
+ if match = status.match(/(\d+)/)
108
+ match[1].to_i
109
+ else
110
+ 0
111
+ end
112
+ end
113
+
114
+ def self.calculate_score(issue_count, active_tool_count)
115
+ return 100 if issue_count == 0
116
+ return 0 if active_tool_count == 0
117
+
118
+ score = 100 - (issue_count.to_f / (active_tool_count * 10)) * 10
119
+ [[score.round, 0].max, 100].min
120
+ end
121
+
122
+ def self.overall_score(scores_hash)
123
+ category_scores = scores_hash.values.map { |v| v[:score] }.compact
124
+ return 0 if category_scores.empty?
125
+
126
+ (category_scores.sum / category_scores.size.to_f).round
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,82 @@
1
+ require "json"
2
+ require "bundler"
3
+
4
+ module RailsCodeAuditor
5
+ class SimpleCovRunner
6
+ def self.run
7
+ setup_file = ".simplecov_setup.rb"
8
+
9
+ unless simplecov_installed_in_project?
10
+ return {
11
+ status: "Skipped",
12
+ success: false,
13
+ error: "simplecov gem not found in the target Rails app",
14
+ details: "simplecov gem not found in the target Rails app"
15
+ }
16
+ end
17
+
18
+ File.write(setup_file, <<~RUBY)
19
+ require 'simplecov'
20
+
21
+ SimpleCov.start 'rails' do
22
+ enable_coverage :branch
23
+ add_filter '/test/'
24
+ add_filter '/spec/'
25
+ end
26
+
27
+ SimpleCov.command_name ENV.fetch("SIMPLECOV_COMMAND_NAME", "Rails Tests")
28
+ puts "[SimpleCov] started"
29
+ RUBY
30
+
31
+ test_cmd =
32
+ if Dir.exist?("spec")
33
+ "SIMPLECOV_COMMAND_NAME='RSpec Tests' bundle exec ruby -r./#{setup_file} -S rspec"
34
+ elsif Dir.exist?("test")
35
+ "SIMPLECOV_COMMAND_NAME='Rails Tests' bundle exec ruby -r./#{setup_file} -S rails test"
36
+ else
37
+ return { status: "Coverage: 0%", error: "No test directory found", success: false,
38
+ details: "No test directory found" }
39
+ end
40
+
41
+ success = system(test_cmd)
42
+
43
+ coverage_path = "coverage/.last_run.json"
44
+ if File.exist?(coverage_path)
45
+ json = JSON.parse(File.read(coverage_path))
46
+ percent = json["result"]["covered_percent"] || json["result"]["line"]
47
+ if percent
48
+ percent = percent.round(2)
49
+ {
50
+ status: "Coverage: #{percent}%",
51
+ success: success,
52
+ raw_result: json,
53
+ details: json
54
+ }
55
+ else
56
+ {
57
+ status: "Coverage: N/A",
58
+ success: false,
59
+ error: "Coverage percentage not found in report",
60
+ details: json
61
+ }
62
+ end
63
+ else
64
+ {
65
+ status: "Coverage: 0%",
66
+ success: success,
67
+ error: "Coverage file not generated",
68
+ details: "Coverage file not generated"
69
+ }
70
+ end
71
+ ensure
72
+ File.delete(setup_file) if setup_file && File.exist?(setup_file)
73
+ end
74
+
75
+ def self.simplecov_installed_in_project?
76
+ spec = Bundler.locked_gems.specs.find { |s| s.name == "simplecov" }
77
+ !spec.nil?
78
+ rescue StandardError
79
+ false
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsCodeAuditor
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,31 @@
1
+ require "rails_code_auditor/analyzer"
2
+ require "rails_code_auditor/report_generator"
3
+ require "rails_code_auditor/pdf_generator"
4
+ require "rails_code_auditor/grapher"
5
+ require "rails_code_auditor/scorer"
6
+ require "rails_code_auditor/llm_client"
7
+ require "rails_code_auditor/simplecov_runner"
8
+
9
+ rails_version = defined?(Rails) ? Gem::Version.new(Rails::VERSION::STRING) : nil
10
+ USE_GROVER = rails_version.nil? || rails_version >= Gem::Version.new("5.0")
11
+
12
+ require "rails_code_auditor/html_to_pdf_converter" if USE_GROVER
13
+
14
+ module RailsCodeAuditor
15
+ def self.run(args)
16
+ puts "[*] Running Rails Code Auditor..."
17
+
18
+ raw_results = Analyzer.run_all
19
+ results = ReportGenerator.normalize(raw_results)
20
+ results[:simplecov] = SimpleCovRunner.run
21
+ scores = if args.include?("--use-llm")
22
+ LlmClient.score_with_llm(results) || Scorer.score(results)
23
+ else
24
+ Scorer.score(results)
25
+ end
26
+ graphs = Grapher.generate(scores)
27
+ html_pdf_paths = USE_GROVER ? HtmlToPdfConverter.convert_all : []
28
+ PdfGenerator.generate(results, scores, graphs, html_pdf_paths)
29
+ puts "[✓] Audit complete. PDF report generated."
30
+ end
31
+ end
metadata ADDED
@@ -0,0 +1,270 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails_code_auditor
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - sivamanikandan
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 2025-07-24 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: brakeman
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '6.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '6.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: bundler-audit
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.9'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.9'
40
+ - !ruby/object:Gem::Dependency
41
+ name: combine_pdf
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: fasterer
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.7'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.7'
68
+ - !ruby/object:Gem::Dependency
69
+ name: flay
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: 2.13.3
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: 2.13.3
82
+ - !ruby/object:Gem::Dependency
83
+ name: flog
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '4.8'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '4.8'
96
+ - !ruby/object:Gem::Dependency
97
+ name: gruff
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '0.21'
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '0.21'
110
+ - !ruby/object:Gem::Dependency
111
+ name: prawn
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '2.4'
117
+ type: :runtime
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '2.4'
124
+ - !ruby/object:Gem::Dependency
125
+ name: prawn-table
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - "~>"
129
+ - !ruby/object:Gem::Version
130
+ version: 0.2.2
131
+ type: :runtime
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - "~>"
136
+ - !ruby/object:Gem::Version
137
+ version: 0.2.2
138
+ - !ruby/object:Gem::Dependency
139
+ name: rails_best_practices
140
+ requirement: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - "~>"
143
+ - !ruby/object:Gem::Version
144
+ version: '1.22'
145
+ type: :runtime
146
+ prerelease: false
147
+ version_requirements: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - "~>"
150
+ - !ruby/object:Gem::Version
151
+ version: '1.22'
152
+ - !ruby/object:Gem::Dependency
153
+ name: simplecov
154
+ requirement: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - "~>"
157
+ - !ruby/object:Gem::Version
158
+ version: '0.22'
159
+ type: :runtime
160
+ prerelease: false
161
+ version_requirements: !ruby/object:Gem::Requirement
162
+ requirements:
163
+ - - "~>"
164
+ - !ruby/object:Gem::Version
165
+ version: '0.22'
166
+ - !ruby/object:Gem::Dependency
167
+ name: license_finder
168
+ requirement: !ruby/object:Gem::Requirement
169
+ requirements:
170
+ - - "~>"
171
+ - !ruby/object:Gem::Version
172
+ version: '7.0'
173
+ type: :runtime
174
+ prerelease: false
175
+ version_requirements: !ruby/object:Gem::Requirement
176
+ requirements:
177
+ - - "~>"
178
+ - !ruby/object:Gem::Version
179
+ version: '7.0'
180
+ - !ruby/object:Gem::Dependency
181
+ name: rubocop
182
+ requirement: !ruby/object:Gem::Requirement
183
+ requirements:
184
+ - - "~>"
185
+ - !ruby/object:Gem::Version
186
+ version: '1.60'
187
+ type: :runtime
188
+ prerelease: false
189
+ version_requirements: !ruby/object:Gem::Requirement
190
+ requirements:
191
+ - - "~>"
192
+ - !ruby/object:Gem::Version
193
+ version: '1.60'
194
+ - !ruby/object:Gem::Dependency
195
+ name: rubycritic
196
+ requirement: !ruby/object:Gem::Requirement
197
+ requirements:
198
+ - - "~>"
199
+ - !ruby/object:Gem::Version
200
+ version: 4.9.2
201
+ type: :runtime
202
+ prerelease: false
203
+ version_requirements: !ruby/object:Gem::Requirement
204
+ requirements:
205
+ - - "~>"
206
+ - !ruby/object:Gem::Version
207
+ version: 4.9.2
208
+ - !ruby/object:Gem::Dependency
209
+ name: grover
210
+ requirement: !ruby/object:Gem::Requirement
211
+ requirements:
212
+ - - ">="
213
+ - !ruby/object:Gem::Version
214
+ version: '0'
215
+ type: :runtime
216
+ prerelease: false
217
+ version_requirements: !ruby/object:Gem::Requirement
218
+ requirements:
219
+ - - ">="
220
+ - !ruby/object:Gem::Version
221
+ version: '0'
222
+ description: rails_code_auditor is a developer-friendly Ruby gem that automates the
223
+ process of auditing your Rails codebase. It runs a suite of essential tools—including
224
+ Brakeman, Bundler Audit, RuboCop, Rails Best Practices, Flay, Flog, and License
225
+ Finder—and consolidates all outputs into a single readable report.
226
+ email:
227
+ - sivamanikandan@railsfactory.org
228
+ executables:
229
+ - rails_code_auditor
230
+ extensions: []
231
+ extra_rdoc_files: []
232
+ files:
233
+ - exe/rails_code_auditor
234
+ - lib/rails_code_auditor.rb
235
+ - lib/rails_code_auditor/analyzer.rb
236
+ - lib/rails_code_auditor/grapher.rb
237
+ - lib/rails_code_auditor/html_to_pdf_converter.rb
238
+ - lib/rails_code_auditor/llm_client.rb
239
+ - lib/rails_code_auditor/pdf_generator.rb
240
+ - lib/rails_code_auditor/report_generator.rb
241
+ - lib/rails_code_auditor/scorer.rb
242
+ - lib/rails_code_auditor/simplecov_runner.rb
243
+ - lib/rails_code_auditor/version.rb
244
+ homepage: https://github.com/railsfactory-sivamanikandan/rails_code_auditor
245
+ licenses:
246
+ - MIT
247
+ metadata:
248
+ allowed_push_host: https://rubygems.org
249
+ homepage_uri: https://github.com/railsfactory-sivamanikandan/rails_code_auditor
250
+ source_code_uri: https://github.com/railsfactory-sivamanikandan/rails_code_auditor
251
+ changelog_uri: https://github.com/railsfactory-sivamanikandan/rails_code_auditor/CHANGELOG.md
252
+ rdoc_options: []
253
+ require_paths:
254
+ - lib
255
+ required_ruby_version: !ruby/object:Gem::Requirement
256
+ requirements:
257
+ - - ">="
258
+ - !ruby/object:Gem::Version
259
+ version: 3.1.0
260
+ required_rubygems_version: !ruby/object:Gem::Requirement
261
+ requirements:
262
+ - - ">="
263
+ - !ruby/object:Gem::Version
264
+ version: '0'
265
+ requirements: []
266
+ rubygems_version: 3.6.2
267
+ specification_version: 4
268
+ summary: Easily generate consolidated security and code quality reports for Ruby on
269
+ Rails applications with a single command.
270
+ test_files: []