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,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDoctor
|
|
4
|
+
module Adapters
|
|
5
|
+
class Reek < Base
|
|
6
|
+
NAME = "reek"
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def declared_gems
|
|
11
|
+
["reek"]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def parse(command_result)
|
|
15
|
+
json = parse_json(command_result.stdout)
|
|
16
|
+
return [command_failed_finding(command_result, severity: "medium")].compact unless json
|
|
17
|
+
|
|
18
|
+
smells = json.is_a?(Hash) ? json.fetch("smells", []) : json
|
|
19
|
+
smells.map do |smell|
|
|
20
|
+
file = smell["source"] || smell["file"]
|
|
21
|
+
lines = smell["lines"] || [smell["line"]]
|
|
22
|
+
smell_type = smell["smell_type"] || smell["type"] || "Code smell"
|
|
23
|
+
Finding.new(
|
|
24
|
+
severity: "medium",
|
|
25
|
+
category: "code-smell",
|
|
26
|
+
tool: name,
|
|
27
|
+
file: file,
|
|
28
|
+
line: Array(lines).compact.first,
|
|
29
|
+
confidence: "high",
|
|
30
|
+
message: "#{smell_type}: #{smell["message"]}",
|
|
31
|
+
recommendation: "Refactor the local smell without broad behavior changes.",
|
|
32
|
+
agent_instruction: "Refactor only the affected method/class. Preserve public behavior and add or run tests around the changed code.",
|
|
33
|
+
metadata: { context: smell["context"], smell_type: smell_type }.compact
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDoctor
|
|
4
|
+
module Adapters
|
|
5
|
+
class Rubocop < Base
|
|
6
|
+
NAME = "rubocop"
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def declared_gems
|
|
11
|
+
%w[rubocop rubocop-rails]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def parse(command_result)
|
|
15
|
+
json = parse_json(command_result.stdout)
|
|
16
|
+
return [command_failed_finding(command_result, severity: "high")].compact unless json
|
|
17
|
+
|
|
18
|
+
json.fetch("files", []).flat_map do |file|
|
|
19
|
+
path = file.fetch("path")
|
|
20
|
+
file.fetch("offenses", []).map do |offense|
|
|
21
|
+
location = offense.fetch("location", {})
|
|
22
|
+
cop_name = offense["cop_name"] || offense["cop"]
|
|
23
|
+
Finding.new(
|
|
24
|
+
severity: severity_from_tool(offense["severity"], default: "low"),
|
|
25
|
+
category: "lint",
|
|
26
|
+
tool: name,
|
|
27
|
+
file: project.relative(path),
|
|
28
|
+
line: location["line"],
|
|
29
|
+
confidence: "high",
|
|
30
|
+
message: "#{cop_name}: #{offense["message"]}",
|
|
31
|
+
recommendation: "Fix the RuboCop offense or document why this cop should be configured differently.",
|
|
32
|
+
agent_instruction: "Apply a minimal change that satisfies #{cop_name}. Preserve behavior and run the relevant tests.",
|
|
33
|
+
suggested_commands: ["bundle exec rubocop #{project.relative(path)}"]
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDoctor
|
|
4
|
+
module Adapters
|
|
5
|
+
class StrongMigrations < Base
|
|
6
|
+
NAME = "strong_migrations"
|
|
7
|
+
|
|
8
|
+
def available?
|
|
9
|
+
project.gem_declared?("strong_migrations")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def install_guidance
|
|
13
|
+
"Add gem \"strong_migrations\" to development/test and run rails-doctor init for migration safety coverage."
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
{
|
|
18
|
+
tool_run: ToolRun.new(
|
|
19
|
+
name: name,
|
|
20
|
+
available: true,
|
|
21
|
+
skipped: false,
|
|
22
|
+
exit_status: 0,
|
|
23
|
+
metadata: {
|
|
24
|
+
coverage: "strong_migrations gem detected",
|
|
25
|
+
initializer_present: File.exist?(project.join("config/initializers/strong_migrations.rb"))
|
|
26
|
+
}
|
|
27
|
+
),
|
|
28
|
+
findings: initializer_findings
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def initializer_findings
|
|
35
|
+
return [] if File.exist?(project.join("config/initializers/strong_migrations.rb"))
|
|
36
|
+
|
|
37
|
+
[
|
|
38
|
+
Finding.new(
|
|
39
|
+
severity: "low",
|
|
40
|
+
category: "migration-safety",
|
|
41
|
+
tool: name,
|
|
42
|
+
file: "config/initializers/strong_migrations.rb",
|
|
43
|
+
confidence: "medium",
|
|
44
|
+
message: "strong_migrations is installed but no initializer was found",
|
|
45
|
+
recommendation: "Generate or review the Strong Migrations initializer so project-specific safety settings are explicit.",
|
|
46
|
+
agent_instruction: "Add the standard Strong Migrations initializer only after checking project database adapter and deployment practices."
|
|
47
|
+
)
|
|
48
|
+
]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDoctor
|
|
4
|
+
module Adapters
|
|
5
|
+
class TestCoverage < Base
|
|
6
|
+
NAME = "test_coverage"
|
|
7
|
+
DEFAULT_LINE_THRESHOLD = 90.0
|
|
8
|
+
DEFAULT_FILE_LINE_THRESHOLD = 80.0
|
|
9
|
+
InvalidCoverageReport = Class.new(StandardError)
|
|
10
|
+
|
|
11
|
+
def available?
|
|
12
|
+
true
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def install_guidance
|
|
16
|
+
"Add gem \"simplecov\" to development/test and require it before the test framework boots."
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def run
|
|
20
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
21
|
+
coverage, findings, stderr, exit_status = coverage_result
|
|
22
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
|
|
23
|
+
|
|
24
|
+
{
|
|
25
|
+
tool_run: ToolRun.new(
|
|
26
|
+
name: name,
|
|
27
|
+
available: true,
|
|
28
|
+
skipped: false,
|
|
29
|
+
exit_status: exit_status,
|
|
30
|
+
duration_ms: duration_ms,
|
|
31
|
+
stderr: stderr,
|
|
32
|
+
metadata: { coverage: coverage.to_h }
|
|
33
|
+
),
|
|
34
|
+
findings: findings,
|
|
35
|
+
coverage: coverage
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def coverage_result
|
|
42
|
+
if disabled?
|
|
43
|
+
coverage = coverage_payload(status: "disabled")
|
|
44
|
+
return [coverage, [], nil, 0]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
unless File.exist?(result_path)
|
|
48
|
+
coverage = coverage_payload(status: "missing")
|
|
49
|
+
return [coverage, [missing_report_finding(coverage)], nil, 0]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
payload = JSON.parse(File.read(result_path))
|
|
53
|
+
validate_payload!(payload)
|
|
54
|
+
coverage = build_coverage(payload)
|
|
55
|
+
return [coverage, [empty_report_finding(coverage)], nil, 0] if coverage.status == "empty"
|
|
56
|
+
|
|
57
|
+
[coverage, coverage_findings(coverage), nil, 0]
|
|
58
|
+
rescue JSON::ParserError, SystemCallError, InvalidCoverageReport => error
|
|
59
|
+
coverage = coverage_payload(status: "invalid", metadata: { error: "#{error.class}: #{error.message}" })
|
|
60
|
+
[coverage, [invalid_report_finding(coverage, error)], error.message, 1]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def build_coverage(payload)
|
|
64
|
+
files = merged_files(payload)
|
|
65
|
+
file_metrics = files.each_with_object([]) do |(file, data), metrics|
|
|
66
|
+
metric = file_coverage(file, data)
|
|
67
|
+
metrics << metric if metric
|
|
68
|
+
end
|
|
69
|
+
if file_metrics.empty?
|
|
70
|
+
return coverage_payload(
|
|
71
|
+
available: false,
|
|
72
|
+
status: "empty",
|
|
73
|
+
metadata: { include: include_patterns }
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
covered_lines = file_metrics.sum { |file| file.fetch(:covered_lines) }
|
|
78
|
+
missed_lines = file_metrics.sum { |file| file.fetch(:missed_lines) }
|
|
79
|
+
total_lines = covered_lines + missed_lines
|
|
80
|
+
covered_branches = file_metrics.sum { |file| file.fetch(:covered_branches, 0).to_i }
|
|
81
|
+
missed_branches = file_metrics.sum { |file| file.fetch(:missed_branches, 0).to_i }
|
|
82
|
+
total_branches = covered_branches + missed_branches
|
|
83
|
+
line_percent = percent(covered_lines, total_lines)
|
|
84
|
+
branch_percent = total_branches.positive? ? percent(covered_branches, total_branches) : nil
|
|
85
|
+
low_files = file_metrics.select { |file| file[:below_threshold] }.sort_by { |file| [file.fetch(:line_percent), file.fetch(:file)] }
|
|
86
|
+
top_files = file_metrics.sort_by { |file| [file.fetch(:line_percent), file.fetch(:file)] }.first(max_files)
|
|
87
|
+
changed_low_files = file_metrics.select do |file|
|
|
88
|
+
changed_files.include?(file.fetch(:file)) && file.fetch(:line_percent) < file_line_threshold
|
|
89
|
+
end.sort_by { |file| [file.fetch(:line_percent), file.fetch(:file)] }
|
|
90
|
+
|
|
91
|
+
coverage_payload(
|
|
92
|
+
available: true,
|
|
93
|
+
status: coverage_status(line_percent: line_percent, branch_percent: branch_percent, low_file_count: low_files.size),
|
|
94
|
+
line_percent: line_percent,
|
|
95
|
+
branch_percent: branch_percent,
|
|
96
|
+
covered_lines: covered_lines,
|
|
97
|
+
missed_lines: missed_lines,
|
|
98
|
+
total_lines: total_lines,
|
|
99
|
+
covered_branches: total_branches.positive? ? covered_branches : nil,
|
|
100
|
+
missed_branches: total_branches.positive? ? missed_branches : nil,
|
|
101
|
+
total_branches: total_branches.positive? ? total_branches : nil,
|
|
102
|
+
top_files: top_files,
|
|
103
|
+
low_file_count: low_files.size,
|
|
104
|
+
changed_files_below_threshold: changed_low_files
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def coverage_findings(coverage)
|
|
109
|
+
findings = []
|
|
110
|
+
if coverage.line_percent && coverage.line_percent < line_threshold
|
|
111
|
+
findings << Finding.new(
|
|
112
|
+
severity: "medium",
|
|
113
|
+
category: "test-coverage",
|
|
114
|
+
tool: name,
|
|
115
|
+
confidence: "high",
|
|
116
|
+
message: "Line coverage #{format_percent(coverage.line_percent)} is below the #{format_percent(line_threshold)} threshold",
|
|
117
|
+
recommendation: "Add tests for uncovered application code, starting with the lowest-coverage files.",
|
|
118
|
+
agent_instruction: "Prioritize behavior tests for uncovered app/lib code. Use the coverage metadata to start with files below the configured threshold.",
|
|
119
|
+
metadata: {
|
|
120
|
+
line_percent: coverage.line_percent,
|
|
121
|
+
threshold: line_threshold,
|
|
122
|
+
low_files: coverage.top_files.select { |file| file[:below_threshold] }
|
|
123
|
+
}
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
if branch_threshold && coverage.branch_percent.nil?
|
|
128
|
+
findings << Finding.new(
|
|
129
|
+
severity: "medium",
|
|
130
|
+
category: "test-coverage",
|
|
131
|
+
tool: name,
|
|
132
|
+
confidence: "high",
|
|
133
|
+
message: "Branch coverage is unavailable but the #{format_percent(branch_threshold)} branch threshold is configured",
|
|
134
|
+
recommendation: "Enable SimpleCov branch coverage or remove the branch coverage threshold.",
|
|
135
|
+
agent_instruction: "Check SimpleCov branch coverage setup before editing application code.",
|
|
136
|
+
metadata: { threshold: branch_threshold }
|
|
137
|
+
)
|
|
138
|
+
elsif branch_threshold && coverage.branch_percent < branch_threshold
|
|
139
|
+
findings << Finding.new(
|
|
140
|
+
severity: "medium",
|
|
141
|
+
category: "test-coverage",
|
|
142
|
+
tool: name,
|
|
143
|
+
confidence: "high",
|
|
144
|
+
message: "Branch coverage #{format_percent(coverage.branch_percent)} is below the #{format_percent(branch_threshold)} threshold",
|
|
145
|
+
recommendation: "Add tests for uncovered branches or lower the configured branch threshold intentionally.",
|
|
146
|
+
agent_instruction: "Prioritize tests that exercise missing branches before changing implementation behavior.",
|
|
147
|
+
metadata: {
|
|
148
|
+
branch_percent: coverage.branch_percent,
|
|
149
|
+
threshold: branch_threshold
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
low_file_findings(coverage).each do |file|
|
|
155
|
+
findings << Finding.new(
|
|
156
|
+
severity: "medium",
|
|
157
|
+
category: "test-coverage",
|
|
158
|
+
tool: name,
|
|
159
|
+
file: file.fetch(:file),
|
|
160
|
+
confidence: "high",
|
|
161
|
+
message: "#{file.fetch(:file)} line coverage #{format_percent(file.fetch(:line_percent))} is below the #{format_percent(file_line_threshold)} per-file threshold",
|
|
162
|
+
recommendation: "Add focused tests that exercise the uncovered behavior in this file.",
|
|
163
|
+
agent_instruction: "Add or update tests for this file before expanding the implementation. Prefer behavior-level tests that cover the missing branches or lines.",
|
|
164
|
+
metadata: file.merge(threshold: file_line_threshold)
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
findings
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def low_file_findings(coverage)
|
|
172
|
+
low_files = coverage.top_files.select { |file| file[:below_threshold] }
|
|
173
|
+
changed_low = coverage.changed_files_below_threshold
|
|
174
|
+
(changed_low + low_files).uniq { |file| file.fetch(:file) }.first(max_files)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def merged_files(payload)
|
|
178
|
+
payload.each_with_object({}) do |(_suite, result), files|
|
|
179
|
+
coverage = result.fetch("coverage")
|
|
180
|
+
coverage.each do |path, raw_data|
|
|
181
|
+
relative = normalize_path(path)
|
|
182
|
+
next unless relative && included?(relative)
|
|
183
|
+
|
|
184
|
+
files[relative] ||= { lines: [], branches: Hash.new(0) }
|
|
185
|
+
merge_lines(files[relative][:lines], lines_for(raw_data))
|
|
186
|
+
branch_counts_for(raw_data).each do |branch_id, count|
|
|
187
|
+
files[relative][:branches][branch_id] += count
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def validate_payload!(payload)
|
|
194
|
+
raise InvalidCoverageReport, "expected top-level result set object" unless payload.is_a?(Hash)
|
|
195
|
+
|
|
196
|
+
payload.each do |suite, result|
|
|
197
|
+
raise InvalidCoverageReport, "expected #{suite} suite to be an object" unless result.is_a?(Hash)
|
|
198
|
+
|
|
199
|
+
coverage = result["coverage"]
|
|
200
|
+
raise InvalidCoverageReport, "expected #{suite} suite coverage to be an object" unless coverage.is_a?(Hash)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def merge_lines(existing, incoming)
|
|
205
|
+
max = [existing.size, incoming.size].max
|
|
206
|
+
max.times do |index|
|
|
207
|
+
next if incoming[index].nil?
|
|
208
|
+
|
|
209
|
+
existing[index] = existing[index].nil? ? incoming[index].to_i : existing[index].to_i + incoming[index].to_i
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def file_coverage(file, data)
|
|
214
|
+
lines = data.fetch(:lines)
|
|
215
|
+
total_lines = lines.count { |count| !count.nil? }
|
|
216
|
+
return nil if total_lines.zero?
|
|
217
|
+
|
|
218
|
+
covered_lines = lines.count { |count| count.to_i.positive? }
|
|
219
|
+
missed_lines = total_lines - covered_lines
|
|
220
|
+
branches = data.fetch(:branches)
|
|
221
|
+
total_branches = branches.size
|
|
222
|
+
covered_branches = branches.values.count(&:positive?)
|
|
223
|
+
missed_branches = total_branches - covered_branches
|
|
224
|
+
line_percent = percent(covered_lines, total_lines)
|
|
225
|
+
|
|
226
|
+
{
|
|
227
|
+
file: file,
|
|
228
|
+
line_percent: line_percent,
|
|
229
|
+
covered_lines: covered_lines,
|
|
230
|
+
missed_lines: missed_lines,
|
|
231
|
+
total_lines: total_lines,
|
|
232
|
+
branch_percent: total_branches.positive? ? percent(covered_branches, total_branches) : nil,
|
|
233
|
+
covered_branches: total_branches.positive? ? covered_branches : nil,
|
|
234
|
+
missed_branches: total_branches.positive? ? missed_branches : nil,
|
|
235
|
+
total_branches: total_branches.positive? ? total_branches : nil,
|
|
236
|
+
below_threshold: line_percent < file_line_threshold
|
|
237
|
+
}.compact
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def lines_for(raw_data)
|
|
241
|
+
case raw_data
|
|
242
|
+
when Array then raw_data
|
|
243
|
+
when Hash then raw_data.fetch("lines", [])
|
|
244
|
+
else []
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def branch_counts_for(raw_data)
|
|
249
|
+
return [] unless raw_data.is_a?(Hash)
|
|
250
|
+
|
|
251
|
+
branches = raw_data.fetch("branches", {})
|
|
252
|
+
branches.flat_map do |branch_key, outcomes|
|
|
253
|
+
case outcomes
|
|
254
|
+
when Hash
|
|
255
|
+
outcomes.map { |outcome_key, count| ["#{branch_key}/#{outcome_key}", count.to_i] }
|
|
256
|
+
else
|
|
257
|
+
[[branch_key, outcomes.to_i]]
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def coverage_status(line_percent:, branch_percent:, low_file_count:)
|
|
263
|
+
return "empty" if line_percent.nil?
|
|
264
|
+
return "below_threshold" if line_percent < line_threshold
|
|
265
|
+
return "below_threshold" if branch_threshold && branch_percent.nil?
|
|
266
|
+
return "below_threshold" if branch_threshold && branch_percent < branch_threshold
|
|
267
|
+
return "below_threshold" if low_file_count.positive?
|
|
268
|
+
|
|
269
|
+
"ok"
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def missing_report_finding(coverage)
|
|
273
|
+
Finding.new(
|
|
274
|
+
severity: "info",
|
|
275
|
+
category: "coverage-gap",
|
|
276
|
+
tool: name,
|
|
277
|
+
confidence: "high",
|
|
278
|
+
message: "SimpleCov coverage report not found at #{coverage.report_path}",
|
|
279
|
+
recommendation: "Add simplecov to development/test and require it before the test framework boots.",
|
|
280
|
+
agent_instruction: "Do not modify application code for this finding. Configure SimpleCov in the test helper or CI if this project wants coverage metrics.",
|
|
281
|
+
suggested_commands: ["bundle add simplecov --group=development,test"],
|
|
282
|
+
metadata: { report_path: coverage.report_path }
|
|
283
|
+
)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def empty_report_finding(coverage)
|
|
287
|
+
Finding.new(
|
|
288
|
+
severity: "info",
|
|
289
|
+
category: "coverage-gap",
|
|
290
|
+
tool: name,
|
|
291
|
+
confidence: "high",
|
|
292
|
+
message: "SimpleCov coverage report has no app/lib files matching #{include_patterns.join(", ")}",
|
|
293
|
+
recommendation: "Check SimpleCov filters and Rails Doctor coverage.include settings.",
|
|
294
|
+
agent_instruction: "Inspect coverage filters and result paths before changing application code.",
|
|
295
|
+
metadata: coverage.metadata
|
|
296
|
+
)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def invalid_report_finding(coverage, error)
|
|
300
|
+
Finding.new(
|
|
301
|
+
severity: "high",
|
|
302
|
+
category: "tool-execution",
|
|
303
|
+
tool: name,
|
|
304
|
+
confidence: "high",
|
|
305
|
+
message: "Could not read SimpleCov coverage report at #{coverage.report_path}: #{error.class}",
|
|
306
|
+
recommendation: "Regenerate coverage with SimpleCov or fix coverage.result_path.",
|
|
307
|
+
agent_instruction: "Do not edit application code until coverage reporting is valid. Check SimpleCov setup and rerun the configured test command.",
|
|
308
|
+
metadata: { report_path: coverage.report_path, error: error.message }
|
|
309
|
+
)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def coverage_payload(attributes = {})
|
|
313
|
+
Coverage.new(**{
|
|
314
|
+
available: false,
|
|
315
|
+
status: nil,
|
|
316
|
+
source: source,
|
|
317
|
+
report_path: relative_result_path,
|
|
318
|
+
thresholds: coverage_thresholds
|
|
319
|
+
}.merge(attributes))
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def disabled?
|
|
323
|
+
coverage_config.fetch("enabled", true) == false
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def result_path
|
|
327
|
+
project.join(coverage_config.fetch("result_path", "coverage/.resultset.json"))
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def relative_result_path
|
|
331
|
+
coverage_config.fetch("result_path", "coverage/.resultset.json")
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def source
|
|
335
|
+
coverage_config.fetch("source", "simplecov")
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def include_patterns
|
|
339
|
+
Array(coverage_config.fetch("include", ["app/**/*.rb", "lib/**/*.rb"]))
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def max_files
|
|
343
|
+
[coverage_config.fetch("max_files", 10).to_i, 0].max
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def coverage_config
|
|
347
|
+
@coverage_config ||= config.data.fetch("coverage", {})
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def coverage_thresholds
|
|
351
|
+
@coverage_thresholds ||= (config.threshold("coverage") || {}).transform_keys(&:to_sym)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def line_threshold
|
|
355
|
+
threshold(:line, DEFAULT_LINE_THRESHOLD)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def file_line_threshold
|
|
359
|
+
threshold(:file_line, DEFAULT_FILE_LINE_THRESHOLD)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def threshold(key, default)
|
|
363
|
+
value = coverage_thresholds[key]
|
|
364
|
+
value.nil? || value.to_s.strip.empty? ? default : value.to_f
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def branch_threshold
|
|
368
|
+
value = coverage_thresholds[:branch]
|
|
369
|
+
return nil if value.nil? || value.to_s.strip.empty?
|
|
370
|
+
|
|
371
|
+
value.to_f
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def normalize_path(path)
|
|
375
|
+
raw_path = path.to_s
|
|
376
|
+
expanded = File.expand_path(raw_path, project.root)
|
|
377
|
+
return nil unless expanded.start_with?("#{project.root}/")
|
|
378
|
+
|
|
379
|
+
relative = project.relative(expanded)
|
|
380
|
+
return nil unless File.file?(project.join(relative))
|
|
381
|
+
|
|
382
|
+
relative
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def included?(relative)
|
|
386
|
+
include_patterns.any? { |pattern| File.fnmatch?(pattern, relative, File::FNM_PATHNAME) }
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def percent(covered, total)
|
|
390
|
+
return 0.0 unless total.positive?
|
|
391
|
+
|
|
392
|
+
(covered.to_f / total * 100).round(2)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def format_percent(value)
|
|
396
|
+
format("%.2f%%", value)
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDoctor
|
|
4
|
+
module Adapters
|
|
5
|
+
class TestRunner < Base
|
|
6
|
+
NAME = "test_runner"
|
|
7
|
+
|
|
8
|
+
def command
|
|
9
|
+
config.command("test")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def available?
|
|
13
|
+
!command.to_s.strip.empty?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def unavailable_reason
|
|
17
|
+
"No test command is configured in .rails-doctor.yml."
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def install_guidance
|
|
21
|
+
"Run rails-doctor init and choose the project test command, for example bin/rails test or bundle exec rspec."
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def timeout_seconds
|
|
27
|
+
600
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def parse(command_result)
|
|
31
|
+
output = [command_result.stdout, command_result.stderr].join("\n")
|
|
32
|
+
findings = []
|
|
33
|
+
|
|
34
|
+
if command_result.exit_status != 0
|
|
35
|
+
findings << Finding.new(
|
|
36
|
+
severity: "critical",
|
|
37
|
+
category: "tests",
|
|
38
|
+
tool: name,
|
|
39
|
+
confidence: "high",
|
|
40
|
+
message: "Configured test command failed",
|
|
41
|
+
recommendation: "Fix failing tests before merging. The health score treats failing tests as release-blocking.",
|
|
42
|
+
agent_instruction: "Inspect the test failure output, fix the smallest cause, and rerun the configured test command.",
|
|
43
|
+
suggested_commands: [command],
|
|
44
|
+
metadata: { output_excerpt: output[0, 2_000] }
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
output.lines.each_with_index do |line, index|
|
|
49
|
+
if line.match?(/DEPRECATION WARNING|deprecated/i)
|
|
50
|
+
findings << Finding.new(
|
|
51
|
+
severity: "medium",
|
|
52
|
+
category: "deprecation",
|
|
53
|
+
tool: name,
|
|
54
|
+
line: index + 1,
|
|
55
|
+
confidence: "medium",
|
|
56
|
+
message: line.strip,
|
|
57
|
+
recommendation: "Resolve deprecation warnings before framework or gem upgrades make them failures.",
|
|
58
|
+
agent_instruction: "Update the deprecated API usage and add a regression test when behavior could change."
|
|
59
|
+
)
|
|
60
|
+
elsif line.match?(/Bullet|Prosopite|N\+1/i)
|
|
61
|
+
findings << Finding.new(
|
|
62
|
+
severity: "high",
|
|
63
|
+
category: "runtime-n-plus-one",
|
|
64
|
+
tool: name,
|
|
65
|
+
line: index + 1,
|
|
66
|
+
confidence: "medium",
|
|
67
|
+
message: line.strip,
|
|
68
|
+
recommendation: "Fix the N+1 query by eager loading or adjusting the query path exercised by tests.",
|
|
69
|
+
agent_instruction: "Use includes/preload/eager_load or query restructuring. Verify with the same test command.",
|
|
70
|
+
suggested_commands: [command]
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
findings
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDoctor
|
|
4
|
+
module Adapters
|
|
5
|
+
class Zeitwerk < Base
|
|
6
|
+
NAME = "zeitwerk"
|
|
7
|
+
|
|
8
|
+
def available?
|
|
9
|
+
project.rails_app? && (project.command_available?("rails") || File.exist?(project.join("bin/rails")) || project.gem_declared?("rails"))
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def install_guidance
|
|
13
|
+
"Zeitwerk checks require a Rails app. Ensure Rails is installed and bin/rails is available."
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def executable_names
|
|
19
|
+
["rails"]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def parse(command_result)
|
|
23
|
+
return [] if command_result.exit_status == 0
|
|
24
|
+
|
|
25
|
+
output = [command_result.stdout, command_result.stderr].join("\n").strip
|
|
26
|
+
[
|
|
27
|
+
Finding.new(
|
|
28
|
+
severity: "critical",
|
|
29
|
+
category: "autoloading",
|
|
30
|
+
tool: name,
|
|
31
|
+
confidence: "high",
|
|
32
|
+
message: "Zeitwerk autoloading check failed",
|
|
33
|
+
recommendation: "Fix constant naming, file paths, or autoload configuration until rails zeitwerk:check passes.",
|
|
34
|
+
agent_instruction: "Align file names, module names, and constant definitions with Rails autoloading conventions. Rerun rails zeitwerk:check.",
|
|
35
|
+
suggested_commands: ["bundle exec rails zeitwerk:check"],
|
|
36
|
+
metadata: { output_excerpt: output[0, 1_500] }
|
|
37
|
+
)
|
|
38
|
+
]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|