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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE +21 -0
- data/README.md +148 -0
- data/docs/adapter-architecture.md +21 -0
- data/docs/agent-handoff.md +22 -0
- data/docs/architecture.md +21 -0
- data/docs/cli-reference.md +48 -0
- data/docs/config-reference.md +50 -0
- data/docs/github-actions.md +36 -0
- data/docs/monetization.md +14 -0
- data/docs/output-schema.md +57 -0
- data/docs/scoring-model.md +23 -0
- data/examples/github-actions/rails-doctor.yml +40 -0
- data/examples/report.html +704 -0
- data/examples/report.json +971 -0
- data/examples/report.md +261 -0
- data/examples/report.txt +45 -0
- data/exe/rails-doctor +8 -0
- data/lib/rails_doctor/adapters/base.rb +109 -0
- data/lib/rails_doctor/adapters/brakeman.rb +47 -0
- data/lib/rails_doctor/adapters/bundler_audit.rb +54 -0
- data/lib/rails_doctor/adapters/dependency_freshness.rb +51 -0
- data/lib/rails_doctor/adapters/flay.rb +41 -0
- data/lib/rails_doctor/adapters/flog.rb +41 -0
- data/lib/rails_doctor/adapters/reek.rb +39 -0
- data/lib/rails_doctor/adapters/rubocop.rb +40 -0
- data/lib/rails_doctor/adapters/strong_migrations.rb +52 -0
- data/lib/rails_doctor/adapters/test_coverage.rb +400 -0
- data/lib/rails_doctor/adapters/test_runner.rb +79 -0
- data/lib/rails_doctor/adapters/zeitwerk.rb +42 -0
- data/lib/rails_doctor/agent/handoff.rb +159 -0
- data/lib/rails_doctor/checks/rails_checks.rb +371 -0
- data/lib/rails_doctor/cli.rb +232 -0
- data/lib/rails_doctor/command_runner.rb +55 -0
- data/lib/rails_doctor/config.rb +161 -0
- data/lib/rails_doctor/init/runner.rb +191 -0
- data/lib/rails_doctor/models.rb +280 -0
- data/lib/rails_doctor/project.rb +95 -0
- data/lib/rails_doctor/reporters/html.rb +400 -0
- data/lib/rails_doctor/reporters/json.rb +18 -0
- data/lib/rails_doctor/reporters/markdown.rb +132 -0
- data/lib/rails_doctor/reporters/terminal.rb +101 -0
- data/lib/rails_doctor/scanner.rb +173 -0
- data/lib/rails_doctor/scorer.rb +74 -0
- data/lib/rails_doctor/version.rb +5 -0
- data/lib/rails_doctor.rb +15 -0
- data/site/assets/cli-output.png +0 -0
- data/site/assets/report-preview.png +0 -0
- data/site/index.html +294 -0
- 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
|