rails-doctor 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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +11 -0
  3. data/LICENSE +21 -0
  4. data/README.md +148 -0
  5. data/docs/adapter-architecture.md +21 -0
  6. data/docs/agent-handoff.md +22 -0
  7. data/docs/architecture.md +21 -0
  8. data/docs/cli-reference.md +48 -0
  9. data/docs/config-reference.md +50 -0
  10. data/docs/github-actions.md +36 -0
  11. data/docs/monetization.md +14 -0
  12. data/docs/output-schema.md +57 -0
  13. data/docs/scoring-model.md +23 -0
  14. data/examples/github-actions/rails-doctor.yml +40 -0
  15. data/examples/report.html +704 -0
  16. data/examples/report.json +971 -0
  17. data/examples/report.md +261 -0
  18. data/examples/report.txt +45 -0
  19. data/exe/rails-doctor +8 -0
  20. data/lib/rails_doctor/adapters/base.rb +109 -0
  21. data/lib/rails_doctor/adapters/brakeman.rb +47 -0
  22. data/lib/rails_doctor/adapters/bundler_audit.rb +54 -0
  23. data/lib/rails_doctor/adapters/dependency_freshness.rb +51 -0
  24. data/lib/rails_doctor/adapters/flay.rb +41 -0
  25. data/lib/rails_doctor/adapters/flog.rb +41 -0
  26. data/lib/rails_doctor/adapters/reek.rb +39 -0
  27. data/lib/rails_doctor/adapters/rubocop.rb +40 -0
  28. data/lib/rails_doctor/adapters/strong_migrations.rb +52 -0
  29. data/lib/rails_doctor/adapters/test_coverage.rb +400 -0
  30. data/lib/rails_doctor/adapters/test_runner.rb +79 -0
  31. data/lib/rails_doctor/adapters/zeitwerk.rb +42 -0
  32. data/lib/rails_doctor/agent/handoff.rb +159 -0
  33. data/lib/rails_doctor/checks/rails_checks.rb +371 -0
  34. data/lib/rails_doctor/cli.rb +232 -0
  35. data/lib/rails_doctor/command_runner.rb +55 -0
  36. data/lib/rails_doctor/config.rb +161 -0
  37. data/lib/rails_doctor/init/runner.rb +191 -0
  38. data/lib/rails_doctor/models.rb +280 -0
  39. data/lib/rails_doctor/project.rb +95 -0
  40. data/lib/rails_doctor/reporters/html.rb +400 -0
  41. data/lib/rails_doctor/reporters/json.rb +18 -0
  42. data/lib/rails_doctor/reporters/markdown.rb +132 -0
  43. data/lib/rails_doctor/reporters/terminal.rb +101 -0
  44. data/lib/rails_doctor/scanner.rb +173 -0
  45. data/lib/rails_doctor/scorer.rb +74 -0
  46. data/lib/rails_doctor/version.rb +5 -0
  47. data/lib/rails_doctor.rb +15 -0
  48. data/site/assets/cli-output.png +0 -0
  49. data/site/assets/report-preview.png +0 -0
  50. data/site/index.html +294 -0
  51. metadata +167 -0
@@ -0,0 +1,280 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module RailsDoctor
7
+ SEVERITIES = %w[info low medium high critical].freeze
8
+ SEVERITY_WEIGHTS = {
9
+ "info" => 0,
10
+ "low" => 1,
11
+ "medium" => 3,
12
+ "high" => 7,
13
+ "critical" => 15
14
+ }.freeze
15
+
16
+ Finding = Struct.new(
17
+ :id,
18
+ :severity,
19
+ :category,
20
+ :tool,
21
+ :file,
22
+ :line,
23
+ :confidence,
24
+ :message,
25
+ :recommendation,
26
+ :agent_instruction,
27
+ :suggested_commands,
28
+ :metadata,
29
+ keyword_init: true
30
+ ) do
31
+ def initialize(**kwargs)
32
+ super
33
+ self.id ||= self.class.generate_id(kwargs)
34
+ self.severity ||= "medium"
35
+ self.confidence ||= "medium"
36
+ self.suggested_commands ||= []
37
+ self.metadata ||= {}
38
+ end
39
+
40
+ def to_h
41
+ {
42
+ id: id,
43
+ severity: severity,
44
+ category: category,
45
+ tool: tool,
46
+ file: file,
47
+ line: line,
48
+ confidence: confidence,
49
+ message: message,
50
+ recommendation: recommendation,
51
+ agent_instruction: agent_instruction,
52
+ suggested_commands: suggested_commands,
53
+ metadata: metadata
54
+ }.compact
55
+ end
56
+
57
+ def self.generate_id(attrs)
58
+ seed = [attrs[:tool], attrs[:category], attrs[:file], attrs[:line], attrs[:message]].join(":")
59
+ "rd-#{seed.hash.abs.to_s(36)}"
60
+ end
61
+ end
62
+
63
+ ToolRun = Struct.new(
64
+ :name,
65
+ :available,
66
+ :skipped,
67
+ :command,
68
+ :exit_status,
69
+ :duration_ms,
70
+ :stdout,
71
+ :stderr,
72
+ :skip_reason,
73
+ :metadata,
74
+ keyword_init: true
75
+ ) do
76
+ def initialize(**kwargs)
77
+ super
78
+ self.available = true if available.nil?
79
+ self.skipped = false if skipped.nil?
80
+ self.metadata ||= {}
81
+ end
82
+
83
+ def to_h(include_raw: false)
84
+ hash = {
85
+ name: name,
86
+ available: available,
87
+ skipped: skipped,
88
+ command: command,
89
+ exit_status: exit_status,
90
+ duration_ms: duration_ms,
91
+ skip_reason: skip_reason,
92
+ metadata: metadata
93
+ }.compact
94
+ if include_raw
95
+ hash[:stdout] = stdout
96
+ hash[:stderr] = stderr
97
+ end
98
+ hash
99
+ end
100
+ end
101
+
102
+ Score = Struct.new(
103
+ :overall,
104
+ :changed_files,
105
+ :confidence,
106
+ :penalties,
107
+ :top_score_movers,
108
+ keyword_init: true
109
+ ) do
110
+ def to_h
111
+ {
112
+ overall: overall,
113
+ changed_files: changed_files,
114
+ confidence: confidence,
115
+ penalties: penalties,
116
+ top_score_movers: top_score_movers
117
+ }
118
+ end
119
+ end
120
+
121
+ Hotspot = Struct.new(
122
+ :file,
123
+ :score,
124
+ :finding_count,
125
+ :churn,
126
+ :changed,
127
+ :categories,
128
+ :summary,
129
+ keyword_init: true
130
+ ) do
131
+ def to_h
132
+ {
133
+ file: file,
134
+ score: score,
135
+ finding_count: finding_count,
136
+ churn: churn,
137
+ changed: changed,
138
+ categories: categories,
139
+ summary: summary
140
+ }
141
+ end
142
+ end
143
+
144
+ Coverage = Struct.new(
145
+ :available,
146
+ :status,
147
+ :source,
148
+ :report_path,
149
+ :line_percent,
150
+ :branch_percent,
151
+ :covered_lines,
152
+ :missed_lines,
153
+ :total_lines,
154
+ :covered_branches,
155
+ :missed_branches,
156
+ :total_branches,
157
+ :thresholds,
158
+ :top_files,
159
+ :low_file_count,
160
+ :changed_files_below_threshold,
161
+ :metadata,
162
+ keyword_init: true
163
+ ) do
164
+ def initialize(**kwargs)
165
+ super
166
+ self.available = false if available.nil?
167
+ self.thresholds ||= {}
168
+ self.top_files ||= []
169
+ self.changed_files_below_threshold ||= []
170
+ self.metadata ||= {}
171
+ end
172
+
173
+ def summary
174
+ {
175
+ available: available,
176
+ status: status,
177
+ source: source,
178
+ line_percent: line_percent,
179
+ branch_percent: branch_percent,
180
+ thresholds: thresholds,
181
+ low_file_count: low_file_count || top_files.count { |file| file[:below_threshold] },
182
+ changed_file_low_count: changed_files_below_threshold.size
183
+ }.compact
184
+ end
185
+
186
+ def to_h
187
+ {
188
+ available: available,
189
+ status: status,
190
+ source: source,
191
+ report_path: report_path,
192
+ line_percent: line_percent,
193
+ branch_percent: branch_percent,
194
+ covered_lines: covered_lines,
195
+ missed_lines: missed_lines,
196
+ total_lines: total_lines,
197
+ covered_branches: covered_branches,
198
+ missed_branches: missed_branches,
199
+ total_branches: total_branches,
200
+ thresholds: thresholds,
201
+ top_files: top_files,
202
+ low_file_count: low_file_count,
203
+ changed_files_below_threshold: changed_files_below_threshold,
204
+ metadata: metadata
205
+ }.compact
206
+ end
207
+ end
208
+
209
+ ScanResult = Struct.new(
210
+ :started_at,
211
+ :finished_at,
212
+ :project_root,
213
+ :profile,
214
+ :metadata,
215
+ :findings,
216
+ :tool_runs,
217
+ :skipped_tools,
218
+ :score,
219
+ :hotspots,
220
+ :coverage,
221
+ :raw_outputs,
222
+ keyword_init: true
223
+ ) do
224
+ def initialize(**kwargs)
225
+ super
226
+ self.started_at ||= Time.now
227
+ self.metadata ||= {}
228
+ self.findings ||= []
229
+ self.tool_runs ||= []
230
+ self.skipped_tools ||= []
231
+ self.hotspots ||= []
232
+ self.raw_outputs ||= {}
233
+ end
234
+
235
+ def finish!
236
+ self.finished_at = Time.now
237
+ self
238
+ end
239
+
240
+ def duration_ms
241
+ return nil unless started_at && finished_at
242
+
243
+ ((finished_at - started_at) * 1000).round
244
+ end
245
+
246
+ def summary
247
+ counts = findings.each_with_object(Hash.new(0)) { |finding, memo| memo[finding.severity] += 1 }
248
+ SEVERITIES.each { |severity| counts[severity] ||= 0 }
249
+
250
+ {
251
+ profile: profile,
252
+ duration_ms: duration_ms,
253
+ finding_count: findings.size,
254
+ severity_counts: counts,
255
+ skipped_tools: skipped_tools.map(&:to_h),
256
+ score: score&.to_h,
257
+ coverage: coverage&.summary
258
+ }
259
+ end
260
+
261
+ def to_h(include_raw: false)
262
+ {
263
+ schema_version: "1.1",
264
+ generated_at: (finished_at || Time.now).iso8601,
265
+ project_root: project_root,
266
+ profile: profile,
267
+ metadata: metadata,
268
+ summary: summary,
269
+ coverage: coverage&.to_h,
270
+ findings: findings.map(&:to_h),
271
+ hotspots: hotspots.map(&:to_h),
272
+ tool_runs: tool_runs.map { |run| run.to_h(include_raw: include_raw) }
273
+ }
274
+ end
275
+
276
+ def to_json(*args)
277
+ JSON.pretty_generate(to_h, *args)
278
+ end
279
+ end
280
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module RailsDoctor
6
+ class Project
7
+ attr_reader :root, :runner
8
+
9
+ def initialize(root:, runner: nil)
10
+ @root = File.expand_path(root)
11
+ @runner = runner || CommandRunner.new(project_root: @root)
12
+ end
13
+
14
+ def rails_app?
15
+ File.exist?(join("config/application.rb")) || File.exist?(join("bin/rails")) || gem_declared?("rails")
16
+ end
17
+
18
+ def gem_declared?(name)
19
+ [join("Gemfile"), join("Gemfile.lock")].any? do |path|
20
+ next false unless File.exist?(path)
21
+
22
+ content = File.read(path)
23
+ content.include?(%("#{name}")) ||
24
+ content.include?(%('#{name}')) ||
25
+ content.match?(/^\s{4}#{Regexp.escape(name)}\s/) ||
26
+ content.match?(/^\s{2}#{Regexp.escape(name)}\s/)
27
+ end
28
+ end
29
+
30
+ def gem_version(name)
31
+ lockfile = join("Gemfile.lock")
32
+ return nil unless File.exist?(lockfile)
33
+
34
+ escaped = Regexp.escape(name)
35
+ File.read(lockfile)[/^\s{4}#{escaped} \(([^)]+)\)/, 1]
36
+ end
37
+
38
+ def rails_version
39
+ gem_version("rails")
40
+ end
41
+
42
+ def command_available?(name)
43
+ runner.executable?(name) || File.executable?(join("bin/#{name}"))
44
+ end
45
+
46
+ def current_branch
47
+ result = runner.run("git rev-parse --abbrev-ref HEAD", timeout_seconds: 5)
48
+ return nil unless result.exit_status == 0
49
+
50
+ result.stdout.strip
51
+ end
52
+
53
+ def dirty_worktree?
54
+ result = runner.run("git status --porcelain", timeout_seconds: 5)
55
+ result.exit_status == 0 && !result.stdout.strip.empty?
56
+ end
57
+
58
+ def changed_files(base_ref: nil)
59
+ if base_ref && !base_ref.to_s.strip.empty?
60
+ result = runner.run("git diff --name-only #{base_ref.shellescape}...HEAD", timeout_seconds: 20)
61
+ return result.stdout.lines.map(&:strip).uniq.reject(&:empty?) if result.exit_status == 0
62
+ end
63
+
64
+ names = []
65
+ ["git diff --name-only", "git diff --cached --name-only"].each do |command|
66
+ result = runner.run(command, timeout_seconds: 10)
67
+ names.concat(result.stdout.lines.map(&:strip)) if result.exit_status == 0
68
+ end
69
+ names.uniq.reject(&:empty?)
70
+ end
71
+
72
+ def churn(window_days:)
73
+ since = (Time.now - (window_days.to_i * 86_400)).strftime("%Y-%m-%d")
74
+ command = "git log --since=#{since} --name-only --pretty=format:"
75
+ result = runner.run(command, timeout_seconds: 20)
76
+ return {} unless result.exit_status == 0
77
+
78
+ result.stdout.lines.map(&:strip).reject(&:empty?).each_with_object(Hash.new(0)) do |file, counts|
79
+ counts[file] += 1
80
+ end
81
+ end
82
+
83
+ def files(pattern)
84
+ Dir.glob(join(pattern)).select { |path| File.file?(path) }
85
+ end
86
+
87
+ def relative(path)
88
+ path.to_s.delete_prefix("#{root}/")
89
+ end
90
+
91
+ def join(*parts)
92
+ File.join(root, *parts)
93
+ end
94
+ end
95
+ end