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,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