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,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "shellwords"
6
+ require "time"
7
+
8
+ module RailsDoctor
9
+ module Agent
10
+ class Handoff
11
+ attr_reader :agent_name, :project, :config, :runner, :options
12
+
13
+ def initialize(agent_name:, project:, config:, runner:, options:)
14
+ @agent_name = agent_name
15
+ @project = project
16
+ @config = config
17
+ @runner = runner
18
+ @options = options
19
+ end
20
+
21
+ def run(scan_result)
22
+ agent_config = config.agent(agent_name)
23
+ raise Error, "Unknown agent #{agent_name.inspect}" unless agent_config
24
+
25
+ findings = filtered_findings(scan_result.findings)
26
+ brief = build_brief(scan_result, findings)
27
+ brief_path = write_brief(brief)
28
+
29
+ output = +"Wrote agent brief: #{brief_path}\n"
30
+ output << "Findings included: #{findings.size}\n"
31
+
32
+ if options[:apply]
33
+ ensure_clean_worktree!(agent_config)
34
+ command = "#{agent_config.fetch("command")} #{Shellwords.escape(brief_path)}"
35
+ command_result = runner.run(command, timeout_seconds: 900)
36
+ audit_path = write_audit(scan_result, findings, brief_path, command, command_result)
37
+ output << "Invoked #{agent_name}: exit #{command_result.exit_status}\n"
38
+ output << "Audit trail: #{audit_path}\n"
39
+ output << command_result.stdout unless command_result.stdout.to_s.empty?
40
+ output << command_result.stderr unless command_result.stderr.to_s.empty?
41
+ else
42
+ output << "No agent was invoked. Re-run with --apply to execute #{agent_name}.\n"
43
+ end
44
+
45
+ output
46
+ end
47
+
48
+ private
49
+
50
+ def filtered_findings(findings)
51
+ filtered = findings
52
+ if options[:severity]
53
+ threshold = SEVERITY_WEIGHTS.fetch(options[:severity], 0)
54
+ filtered = filtered.select { |finding| SEVERITY_WEIGHTS.fetch(finding.severity, 0) >= threshold }
55
+ end
56
+ filtered = filtered.select { |finding| Array(options[:changed_files]).include?(finding.file) } if options[:changed_only]
57
+ filtered.first(options.fetch(:max_findings, 10))
58
+ end
59
+
60
+ def build_brief(scan_result, findings)
61
+ lines = []
62
+ lines << "# Rails Doctor Agent Brief"
63
+ lines << ""
64
+ lines << "You are repairing findings from Rails Doctor. Make minimal, behavior-preserving changes. Do not commit automatically."
65
+ lines << ""
66
+ lines << "Project: #{scan_result.project_root}"
67
+ lines << "Profile: #{scan_result.profile}"
68
+ lines << "Overall score: #{scan_result.score&.overall}"
69
+ lines << "Changed-files score: #{scan_result.score&.changed_files}"
70
+ lines << coverage_brief(scan_result)
71
+ lines << ""
72
+ lines << "## Findings"
73
+ findings.each do |finding|
74
+ lines << ""
75
+ lines << "### #{finding.severity.upcase}: #{finding.message}"
76
+ lines << "- ID: #{finding.id}"
77
+ lines << "- Tool: #{finding.tool}"
78
+ lines << "- Category: #{finding.category}"
79
+ lines << "- Location: #{[finding.file, finding.line].compact.join(":")}" if finding.file
80
+ lines << "- Recommendation: #{finding.recommendation}"
81
+ lines << "- Agent instruction: #{finding.agent_instruction}"
82
+ lines << "- Suggested commands: #{finding.suggested_commands.join(", ")}" if finding.suggested_commands.any?
83
+ end
84
+ lines << ""
85
+ lines << "After changes, run Rails Doctor again and the relevant test command."
86
+ lines.join("\n")
87
+ end
88
+
89
+ def coverage_brief(scan_result)
90
+ coverage = scan_result.coverage
91
+ return "Coverage: not captured" unless coverage
92
+ return "Coverage: #{coverage.status} at #{coverage.report_path}" unless coverage.available
93
+
94
+ lines = []
95
+ lines << "Coverage: #{format_percent(coverage.line_percent)} lines"
96
+ low_files = low_coverage_files(coverage)
97
+ if low_files.any?
98
+ lines << ""
99
+ lines << "## Coverage"
100
+ low_files.first(10).each do |file|
101
+ lines << "- #{file.fetch(:file)}: #{format_percent(file.fetch(:line_percent))} lines (#{file.fetch(:covered_lines)}/#{file.fetch(:total_lines)})"
102
+ end
103
+ end
104
+ lines.join("\n")
105
+ end
106
+
107
+ def low_coverage_files(coverage)
108
+ low_files = coverage.top_files.select { |file| file[:below_threshold] }
109
+ (coverage.changed_files_below_threshold + low_files).uniq { |file| file.fetch(:file) }
110
+ end
111
+
112
+ def format_percent(value)
113
+ return "n/a" if value.nil?
114
+
115
+ format("%.2f%%", value)
116
+ end
117
+
118
+ def write_brief(content)
119
+ dir = project.join(".rails-doctor/agent-briefs")
120
+ FileUtils.mkdir_p(dir)
121
+ path = File.join(dir, "#{timestamp}-#{agent_name}.md")
122
+ File.write(path, content)
123
+ path
124
+ end
125
+
126
+ def write_audit(scan_result, findings, brief_path, command, command_result)
127
+ dir = project.join(".rails-doctor/agent-runs")
128
+ FileUtils.mkdir_p(dir)
129
+ path = File.join(dir, "#{timestamp}-#{agent_name}.json")
130
+ payload = {
131
+ generated_at: Time.now.iso8601,
132
+ agent: agent_name,
133
+ command: command,
134
+ exit_status: command_result.exit_status,
135
+ brief_path: brief_path,
136
+ finding_ids: findings.map(&:id),
137
+ score: scan_result.score&.to_h,
138
+ stdout: command_result.stdout,
139
+ stderr: command_result.stderr,
140
+ changed_files_after: project.changed_files
141
+ }
142
+ File.write(path, JSON.pretty_generate(payload))
143
+ path
144
+ end
145
+
146
+ def ensure_clean_worktree!(agent_config)
147
+ return if options[:allow_dirty]
148
+ return unless agent_config.fetch("apply_requires_clean_worktree", true)
149
+ return unless project.dirty_worktree?
150
+
151
+ raise Error, "Refusing to invoke #{agent_name} on a dirty worktree. Commit/stash changes or pass --allow-dirty."
152
+ end
153
+
154
+ def timestamp
155
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,371 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDoctor
4
+ module Checks
5
+ class RailsChecks
6
+ NAME = "rails_checks"
7
+
8
+ attr_reader :project, :config, :runner, :profile, :changed_files
9
+
10
+ def initialize(project:, config:, runner:, profile:, changed_files: [])
11
+ @project = project
12
+ @config = config
13
+ @runner = runner
14
+ @profile = profile
15
+ @changed_files = changed_files
16
+ end
17
+
18
+ def name
19
+ NAME
20
+ end
21
+
22
+ def available?
23
+ project.rails_app?
24
+ end
25
+
26
+ def unavailable_reason
27
+ "This does not look like a Rails app."
28
+ end
29
+
30
+ def install_guidance
31
+ "Run Rails Doctor from the root of a Rails 7.1+ application."
32
+ end
33
+
34
+ def run
35
+ findings = []
36
+ findings.concat(missing_foreign_key_indexes)
37
+ findings.concat(missing_unique_indexes)
38
+ findings.concat(route_action_view_findings)
39
+ findings.concat(large_artifact_findings)
40
+ findings.concat(todo_density_findings)
41
+ findings.concat(missing_test_counterparts)
42
+ findings.concat(coverage_gap_findings)
43
+
44
+ {
45
+ tool_run: ToolRun.new(
46
+ name: name,
47
+ available: true,
48
+ skipped: false,
49
+ exit_status: 0,
50
+ metadata: { checks: %w[indexes uniqueness routes views size todos tests coverage-gaps] }
51
+ ),
52
+ findings: findings
53
+ }
54
+ end
55
+
56
+ private
57
+
58
+ def schema
59
+ @schema ||= begin
60
+ path = project.join("db/schema.rb")
61
+ File.exist?(path) ? File.read(path) : ""
62
+ end
63
+ end
64
+
65
+ def parsed_schema
66
+ @parsed_schema ||= begin
67
+ tables = Hash.new { |hash, key| hash[key] = { columns: [], indexes: [] } }
68
+
69
+ schema.scan(/create_table\s+"([^"]+)".*?do \|t\|(.*?)^\s*end/m).each do |table, block|
70
+ block.scan(/t\.(?:bigint|integer|string|uuid)\s+"([^"]+)"/).each do |column|
71
+ tables[table][:columns] << column.first
72
+ end
73
+ end
74
+
75
+ schema.scan(/add_index\s+"([^"]+)",\s+\[([^\]]+)\](.*)$/).each do |table, columns, options|
76
+ tables[table][:indexes] << {
77
+ columns: columns.scan(/"([^"]+)"/).flatten,
78
+ unique: options.include?("unique: true")
79
+ }
80
+ end
81
+
82
+ tables
83
+ end
84
+ end
85
+
86
+ def missing_foreign_key_indexes
87
+ parsed_schema.flat_map do |table, data|
88
+ data[:columns].grep(/_id\z/).each_with_object([]) do |column, findings|
89
+ next if indexed?(table, [column])
90
+
91
+ findings << Finding.new(
92
+ severity: "high",
93
+ category: "database-integrity",
94
+ tool: name,
95
+ file: "db/schema.rb",
96
+ confidence: "high",
97
+ message: "#{table}.#{column} has no index",
98
+ recommendation: "Add an index for the foreign key column to avoid slow association lookups.",
99
+ agent_instruction: "Create a migration that adds an index on #{table}.#{column}. For PostgreSQL production apps, prefer a concurrent index path compatible with strong_migrations.",
100
+ suggested_commands: ["bin/rails generate migration AddIndexTo#{camelize(table)}#{camelize(column)}"]
101
+ )
102
+ end
103
+ end
104
+ end
105
+
106
+ def missing_unique_indexes
107
+ project.files("app/models/**/*.rb").flat_map do |path|
108
+ relative = project.relative(path)
109
+ table = table_for_model_path(path)
110
+ next [] unless table
111
+
112
+ File.read(path).scan(/validates\s+:([a-zA-Z_]\w*).*?uniqueness:\s*(?:true|\{)/m).each_with_object([]) do |column_match, findings|
113
+ column = column_match.first
114
+ next if unique_indexed?(table, [column])
115
+
116
+ findings << Finding.new(
117
+ severity: "high",
118
+ category: "database-integrity",
119
+ tool: name,
120
+ file: relative,
121
+ confidence: "medium",
122
+ message: "#{table}.#{column} has a Rails uniqueness validation without a unique database index",
123
+ recommendation: "Back uniqueness validations with a unique index to prevent race-condition duplicates.",
124
+ agent_instruction: "Add a unique index migration for #{table}.#{column}, handle existing duplicate data if necessary, and rerun tests.",
125
+ suggested_commands: ["bin/rails generate migration AddUniqueIndexTo#{camelize(table)}#{camelize(column)}"]
126
+ )
127
+ end
128
+ end
129
+ end
130
+
131
+ def route_action_view_findings
132
+ route_map = routes
133
+ findings = []
134
+
135
+ route_map.each do |controller, actions|
136
+ controller_file = project.join("app/controllers/#{controller}_controller.rb")
137
+ unless File.exist?(controller_file)
138
+ findings << Finding.new(
139
+ severity: "high",
140
+ category: "routing",
141
+ tool: name,
142
+ file: "config/routes.rb",
143
+ confidence: "high",
144
+ message: "Routes reference missing #{controller}_controller.rb",
145
+ recommendation: "Create the controller or remove/rename the route.",
146
+ agent_instruction: "Align routes with real controller names. Prefer removing stale routes over creating empty controllers."
147
+ )
148
+ next
149
+ end
150
+
151
+ controller_source = File.read(controller_file)
152
+ defined_actions = controller_source.scan(/^\s*def\s+([a-zA-Z_]\w*[!?=]?)/).flatten
153
+ actions.each do |action|
154
+ unless defined_actions.include?(action)
155
+ findings << Finding.new(
156
+ severity: "high",
157
+ category: "routing",
158
+ tool: name,
159
+ file: project.relative(controller_file),
160
+ confidence: "high",
161
+ message: "Route points to missing #{controller}##{action}",
162
+ recommendation: "Implement the action or update/remove the route.",
163
+ agent_instruction: "Do not add an empty action. Determine the intended route behavior, then implement or remove the stale route."
164
+ )
165
+ next
166
+ end
167
+
168
+ next if explicit_response?(controller_source, action)
169
+ next if template_exists?(controller, action)
170
+
171
+ findings << Finding.new(
172
+ severity: "medium",
173
+ category: "routing",
174
+ tool: name,
175
+ file: project.relative(controller_file),
176
+ confidence: "medium",
177
+ message: "#{controller}##{action} has no matching template or explicit response",
178
+ recommendation: "Add a template or explicit render/redirect/head response.",
179
+ agent_instruction: "Inspect the action intent. Add the missing view or explicit response and cover the route with a request/controller test."
180
+ )
181
+ end
182
+
183
+ (defined_actions - actions).each do |action|
184
+ next if %w[initialize].include?(action)
185
+
186
+ findings << Finding.new(
187
+ severity: "low",
188
+ category: "dead-code",
189
+ tool: name,
190
+ file: project.relative(controller_file),
191
+ confidence: "low",
192
+ message: "#{controller}##{action} is not referenced by simple route analysis",
193
+ recommendation: "Review whether this action is reached by custom routing or can be removed.",
194
+ agent_instruction: "Do not remove this action automatically. First search routes, tests, links, and callers for dynamic usage."
195
+ )
196
+ end
197
+ end
198
+
199
+ findings
200
+ end
201
+
202
+ def large_artifact_findings
203
+ thresholds = config.threshold("large_file_lines") || {}
204
+ patterns = {
205
+ "model" => "app/models/**/*.rb",
206
+ "controller" => "app/controllers/**/*_controller.rb",
207
+ "job" => "app/jobs/**/*.rb",
208
+ "mailer" => "app/mailers/**/*.rb",
209
+ "view" => "app/views/**/*"
210
+ }
211
+
212
+ patterns.flat_map do |kind, pattern|
213
+ threshold = thresholds.fetch(kind, 200).to_i
214
+ project.files(pattern).each_with_object([]) do |path, findings|
215
+ next if File.directory?(path)
216
+
217
+ lines = File.readlines(path).size
218
+ next if lines <= threshold
219
+
220
+ findings << Finding.new(
221
+ severity: "medium",
222
+ category: "maintainability-hotspot",
223
+ tool: name,
224
+ file: project.relative(path),
225
+ confidence: "medium",
226
+ message: "#{kind} file has #{lines} lines, above the #{threshold}-line threshold",
227
+ recommendation: "Treat this as a refactor hotspot, especially if the file is frequently changed.",
228
+ agent_instruction: "Avoid expanding this file further. If changing it, prefer extracting one cohesive behavior with tests.",
229
+ metadata: { lines: lines, threshold: threshold, artifact_type: kind }
230
+ )
231
+ end
232
+ end
233
+ end
234
+
235
+ def todo_density_findings
236
+ threshold = config.threshold("todo_density_per_100_lines").to_f
237
+ files = project.files("{app,lib,config}/**/*.{rb,erb,haml,slim}")
238
+
239
+ files.each_with_object([]) do |path, findings|
240
+ lines = File.readlines(path)
241
+ todo_count = lines.count { |line| line.match?(/\b(TODO|FIXME|HACK)\b/i) }
242
+ next if todo_count.zero?
243
+
244
+ density = todo_count / [lines.size, 1].max.to_f * 100
245
+ next if density < threshold && !project.changed_files.include?(project.relative(path))
246
+
247
+ findings << Finding.new(
248
+ severity: density >= threshold ? "medium" : "low",
249
+ category: "technical-debt",
250
+ tool: name,
251
+ file: project.relative(path),
252
+ confidence: "medium",
253
+ message: "#{todo_count} TODO/FIXME/HACK marker#{todo_count == 1 ? "" : "s"} in #{lines.size} lines",
254
+ recommendation: "Convert stale markers into tracked work or resolve them while the context is fresh.",
255
+ agent_instruction: "Do not delete markers without addressing or preserving the underlying work item. Prefer resolving changed-file markers."
256
+ )
257
+ end
258
+ end
259
+
260
+ def missing_test_counterparts
261
+ changed = changed_files
262
+ return [] if changed.empty?
263
+
264
+ changed.grep(%r{\Aapp/(models|controllers|jobs|mailers)/.+\.rb\z}).each_with_object([]) do |file, findings|
265
+ expected = expected_test_paths(file)
266
+ next if expected.any? { |path| File.exist?(project.join(path)) }
267
+
268
+ findings << Finding.new(
269
+ severity: "medium",
270
+ category: "test-coverage",
271
+ tool: name,
272
+ file: file,
273
+ confidence: "medium",
274
+ message: "Changed app file has no obvious test/spec counterpart",
275
+ recommendation: "Add or update a nearby test for this changed Rails artifact.",
276
+ agent_instruction: "Create the missing test/spec counterpart or update an existing integration/request test that covers this behavior.",
277
+ metadata: { expected_paths: expected }
278
+ )
279
+ end
280
+ end
281
+
282
+ def coverage_gap_findings
283
+ findings = []
284
+ unless project.gem_declared?("brakeman")
285
+ findings << coverage_gap("security", "Brakeman is unavailable, so Rails security coverage is incomplete.", "brakeman")
286
+ end
287
+ unless project.gem_declared?("reek")
288
+ findings << coverage_gap("code-smell", "Reek is unavailable, so code smell coverage is incomplete.", "reek")
289
+ end
290
+ unless project.gem_declared?("strong_migrations")
291
+ findings << coverage_gap("migration-safety", "Strong Migrations is unavailable, so migration safety coverage is incomplete.", "strong_migrations")
292
+ end
293
+ findings
294
+ end
295
+
296
+ def coverage_gap(_category, message, missing_tool)
297
+ Finding.new(
298
+ severity: "info",
299
+ category: "coverage-gap",
300
+ tool: name,
301
+ confidence: "high",
302
+ message: message,
303
+ recommendation: "Run rails-doctor init to add #{missing_tool} coverage.",
304
+ agent_instruction: "Do not modify application code for this finding. Install/configure #{missing_tool} if the project wants this coverage."
305
+ )
306
+ end
307
+
308
+ def indexed?(table, columns)
309
+ parsed_schema.fetch(table, { indexes: [] })[:indexes].any? { |index| index[:columns] == columns }
310
+ end
311
+
312
+ def unique_indexed?(table, columns)
313
+ parsed_schema.fetch(table, { indexes: [] })[:indexes].any? { |index| index[:columns] == columns && index[:unique] }
314
+ end
315
+
316
+ def table_for_model_path(path)
317
+ basename = File.basename(path, ".rb")
318
+ pluralize(basename)
319
+ end
320
+
321
+ def routes
322
+ path = project.join("config/routes.rb")
323
+ return {} unless File.exist?(path)
324
+
325
+ source = File.read(path)
326
+ route_map = Hash.new { |hash, key| hash[key] = [] }
327
+
328
+ source.scan(/to:\s*["']([a-zA-Z0-9_\/]+)#([a-zA-Z_]\w*)["']/).each do |controller, action|
329
+ route_map[controller] << action
330
+ end
331
+
332
+ source.scan(/resources\s+:([a-zA-Z_]\w*)/).each do |resource|
333
+ controller = resource.first
334
+ route_map[controller].concat(%w[index show new create edit update destroy])
335
+ end
336
+
337
+ route_map.transform_values { |actions| actions.uniq.sort }
338
+ end
339
+
340
+ def explicit_response?(source, action)
341
+ body = source[/^\s*def\s+#{Regexp.escape(action)}\b(.*?)^\s*end/m, 1].to_s
342
+ body.match?(/\b(render|redirect_to|head|respond_to|send_data|send_file)\b/)
343
+ end
344
+
345
+ def template_exists?(controller, action)
346
+ Dir.glob(project.join("app/views/#{controller}/#{action}.*")).any?
347
+ end
348
+
349
+ def expected_test_paths(file)
350
+ suffix = file.delete_prefix("app/").sub(/\.rb\z/, "")
351
+ [
352
+ "test/#{suffix}_test.rb",
353
+ "spec/#{suffix}_spec.rb",
354
+ "spec/requests/#{File.basename(file, ".rb")}_spec.rb",
355
+ "test/integration/#{File.basename(file, ".rb")}_test.rb"
356
+ ]
357
+ end
358
+
359
+ def camelize(value)
360
+ value.split("_").map(&:capitalize).join
361
+ end
362
+
363
+ def pluralize(value)
364
+ return value.sub(/y\z/, "ies") if value.end_with?("y")
365
+ return value if value.end_with?("s")
366
+
367
+ "#{value}s"
368
+ end
369
+ end
370
+ end
371
+ end