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,261 @@
1
+ # Rails Doctor Report
2
+
3
+ - Profile: `deep`
4
+ - Overall score: `0/100`
5
+ - Changed-files score: `100/100`
6
+ - Confidence: `100%`
7
+ - Coverage: `48.00% lines`
8
+ - Findings: `24`
9
+ - Duration: `600ms`
10
+
11
+ ## Coverage
12
+
13
+ - Line coverage: `48.00%`
14
+ - Line threshold: `90.00%`
15
+ - Covered lines: `12/25`
16
+ - Branch coverage: `50.00%`
17
+
18
+ Low-coverage files:
19
+ - `app/controllers/posts_controller.rb`: 22.22% lines (2/9)
20
+ - `app/models/post.rb`: 44.44% lines (4/9)
21
+
22
+ ## Severity Breakdown
23
+
24
+ - `medium`: 10
25
+ - `critical`: 1
26
+ - `high`: 10
27
+ - `low`: 3
28
+
29
+ ## Skipped Tools
30
+
31
+ No tools were skipped.
32
+
33
+ ## Top Findings
34
+
35
+ ### CRITICAL: SQL Injection: Possible SQL injection
36
+
37
+ - Tool: `brakeman`
38
+ - Category: `security`
39
+ - Location: `app/models/post.rb:8`
40
+ - Confidence: `high`
41
+
42
+ Review Brakeman guidance: https://brakemanscanner.org/docs/warning_types/sql_injection/
43
+
44
+ **Agent instruction:** Fix this security finding with the smallest behavior-preserving change. Prefer framework-safe APIs and add regression tests.
45
+
46
+ ### HIGH: rack: Example vulnerability
47
+
48
+ - Tool: `bundler_audit`
49
+ - Category: `dependency-security`
50
+ - Location: `Gemfile.lock`
51
+ - Confidence: `high`
52
+
53
+ Update rack to a patched version and rerun Bundler Audit.
54
+
55
+ **Agent instruction:** Update the vulnerable gem conservatively, refresh the lockfile, and run the test suite.
56
+
57
+ ### HIGH: Prosopite: N+1 queries detected for Post => [:user]
58
+
59
+ - Tool: `test_runner`
60
+ - Category: `runtime-n-plus-one`
61
+ - Confidence: `medium`
62
+
63
+ Fix the N+1 query by eager loading or adjusting the query path exercised by tests.
64
+
65
+ **Agent instruction:** Use includes/preload/eager_load or query restructuring. Verify with the same test command.
66
+
67
+ ### HIGH: posts.user_id has no index
68
+
69
+ - Tool: `rails_checks`
70
+ - Category: `database-integrity`
71
+ - Location: `db/schema.rb`
72
+ - Confidence: `high`
73
+
74
+ Add an index for the foreign key column to avoid slow association lookups.
75
+
76
+ **Agent instruction:** Create a migration that adds an index on posts.user_id. For PostgreSQL production apps, prefer a concurrent index path compatible with strong_migrations.
77
+
78
+ ### HIGH: users.email has a Rails uniqueness validation without a unique database index
79
+
80
+ - Tool: `rails_checks`
81
+ - Category: `database-integrity`
82
+ - Location: `app/models/user.rb`
83
+ - Confidence: `medium`
84
+
85
+ Back uniqueness validations with a unique index to prevent race-condition duplicates.
86
+
87
+ **Agent instruction:** Add a unique index migration for users.email, handle existing duplicate data if necessary, and rerun tests.
88
+
89
+ ### HIGH: Routes reference missing ghosts_controller.rb
90
+
91
+ - Tool: `rails_checks`
92
+ - Category: `routing`
93
+ - Location: `config/routes.rb`
94
+ - Confidence: `high`
95
+
96
+ Create the controller or remove/rename the route.
97
+
98
+ **Agent instruction:** Align routes with real controller names. Prefer removing stale routes over creating empty controllers.
99
+
100
+ ### HIGH: Route points to missing posts#create
101
+
102
+ - Tool: `rails_checks`
103
+ - Category: `routing`
104
+ - Location: `app/controllers/posts_controller.rb`
105
+ - Confidence: `high`
106
+
107
+ Implement the action or update/remove the route.
108
+
109
+ **Agent instruction:** Do not add an empty action. Determine the intended route behavior, then implement or remove the stale route.
110
+
111
+ ### HIGH: Route points to missing posts#destroy
112
+
113
+ - Tool: `rails_checks`
114
+ - Category: `routing`
115
+ - Location: `app/controllers/posts_controller.rb`
116
+ - Confidence: `high`
117
+
118
+ Implement the action or update/remove the route.
119
+
120
+ **Agent instruction:** Do not add an empty action. Determine the intended route behavior, then implement or remove the stale route.
121
+
122
+ ### HIGH: Route points to missing posts#edit
123
+
124
+ - Tool: `rails_checks`
125
+ - Category: `routing`
126
+ - Location: `app/controllers/posts_controller.rb`
127
+ - Confidence: `high`
128
+
129
+ Implement the action or update/remove the route.
130
+
131
+ **Agent instruction:** Do not add an empty action. Determine the intended route behavior, then implement or remove the stale route.
132
+
133
+ ### HIGH: Route points to missing posts#new
134
+
135
+ - Tool: `rails_checks`
136
+ - Category: `routing`
137
+ - Location: `app/controllers/posts_controller.rb`
138
+ - Confidence: `high`
139
+
140
+ Implement the action or update/remove the route.
141
+
142
+ **Agent instruction:** Do not add an empty action. Determine the intended route behavior, then implement or remove the stale route.
143
+
144
+ ### HIGH: Route points to missing posts#update
145
+
146
+ - Tool: `rails_checks`
147
+ - Category: `routing`
148
+ - Location: `app/controllers/posts_controller.rb`
149
+ - Confidence: `high`
150
+
151
+ Implement the action or update/remove the route.
152
+
153
+ **Agent instruction:** Do not add an empty action. Determine the intended route behavior, then implement or remove the stale route.
154
+
155
+ ### MEDIUM: DEPRECATION WARNING: old API is deprecated
156
+
157
+ - Tool: `test_runner`
158
+ - Category: `deprecation`
159
+ - Confidence: `medium`
160
+
161
+ Resolve deprecation warnings before framework or gem upgrades make them failures.
162
+
163
+ **Agent instruction:** Update the deprecated API usage and add a regression test when behavior could change.
164
+
165
+ ### MEDIUM: Line coverage 48.00% is below the 90.00% threshold
166
+
167
+ - Tool: `test_coverage`
168
+ - Category: `test-coverage`
169
+ - Confidence: `high`
170
+
171
+ Add tests for uncovered application code, starting with the lowest-coverage files.
172
+
173
+ **Agent instruction:** Prioritize behavior tests for uncovered app/lib code. Use the coverage metadata to start with files below the configured threshold.
174
+
175
+ ### MEDIUM: app/controllers/posts_controller.rb line coverage 22.22% is below the 80.00% per-file threshold
176
+
177
+ - Tool: `test_coverage`
178
+ - Category: `test-coverage`
179
+ - Location: `app/controllers/posts_controller.rb`
180
+ - Confidence: `high`
181
+
182
+ Add focused tests that exercise the uncovered behavior in this file.
183
+
184
+ **Agent instruction:** Add or update tests for this file before expanding the implementation. Prefer behavior-level tests that cover the missing branches or lines.
185
+
186
+ ### MEDIUM: app/models/post.rb line coverage 44.44% is below the 80.00% per-file threshold
187
+
188
+ - Tool: `test_coverage`
189
+ - Category: `test-coverage`
190
+ - Location: `app/models/post.rb`
191
+ - Confidence: `high`
192
+
193
+ Add focused tests that exercise the uncovered behavior in this file.
194
+
195
+ **Agent instruction:** Add or update tests for this file before expanding the implementation. Prefer behavior-level tests that cover the missing branches or lines.
196
+
197
+ ### MEDIUM: High complexity score 32.5 for Post#publish!
198
+
199
+ - Tool: `flog`
200
+ - Category: `complexity`
201
+ - Location: `app/models/post.rb:4`
202
+ - Confidence: `medium`
203
+
204
+ Extract simpler methods or objects around the complex branch.
205
+
206
+ **Agent instruction:** Reduce complexity with behavior-preserving extraction. Do not combine this with unrelated cleanup.
207
+
208
+ ### MEDIUM: Similar code group 1 across app/models/post.rb:4, app/models/user.rb:2
209
+
210
+ - Tool: `flay`
211
+ - Category: `duplication`
212
+ - Location: `app/models/post.rb:4`
213
+ - Confidence: `medium`
214
+
215
+ Review whether this duplication is intentional. Extract shared behavior only if the abstraction is clear.
216
+
217
+ **Agent instruction:** Do not blindly abstract. Compare the duplicated code paths, preserve semantics, and add tests if extracting shared code.
218
+
219
+ ### MEDIUM: 1 TODO/FIXME/HACK marker in 10 lines
220
+
221
+ - Tool: `rails_checks`
222
+ - Category: `technical-debt`
223
+ - Location: `app/models/post.rb`
224
+ - Confidence: `medium`
225
+
226
+ Convert stale markers into tracked work or resolve them while the context is fresh.
227
+
228
+ **Agent instruction:** Do not delete markers without addressing or preserving the underlying work item. Prefer resolving changed-file markers.
229
+
230
+ ### MEDIUM: posts#show has no matching template or explicit response
231
+
232
+ - Tool: `rails_checks`
233
+ - Category: `routing`
234
+ - Location: `app/controllers/posts_controller.rb`
235
+ - Confidence: `medium`
236
+
237
+ Add a template or explicit render/redirect/head response.
238
+
239
+ **Agent instruction:** Inspect the action intent. Add the missing view or explicit response and cover the route with a request/controller test.
240
+
241
+ ### MEDIUM: TooManyStatements: has the smell of too many statements
242
+
243
+ - Tool: `reek`
244
+ - Category: `code-smell`
245
+ - Location: `app/models/post.rb:4`
246
+ - Confidence: `high`
247
+
248
+ Refactor the local smell without broad behavior changes.
249
+
250
+ **Agent instruction:** Refactor only the affected method/class. Preserve public behavior and add or run tests around the changed code.
251
+
252
+
253
+ ## Hotspots
254
+
255
+ - `app/controllers/posts_controller.rb`: score 42, 8 findings, churn 0, changed=false
256
+ - `app/models/post.rb`: score 33, 7 findings, churn 0, changed=false
257
+ - `Gemfile.lock`: score 8, 2 findings, churn 0, changed=false
258
+ - `db/schema.rb`: score 7, 1 findings, churn 0, changed=false
259
+ - `app/models/user.rb`: score 7, 1 findings, churn 0, changed=false
260
+ - `config/routes.rb`: score 7, 1 findings, churn 0, changed=false
261
+ - `config/initializers/strong_migrations.rb`: score 1, 1 findings, churn 0, changed=false
@@ -0,0 +1,45 @@
1
+ Rails Doctor
2
+ ============
3
+ Profile: deep
4
+ Score: 0/100 overall, 100/100 changed files
5
+ Confidence: 100%
6
+ Coverage: 48.00% lines (threshold 90.00%)
7
+ Findings: critical=1, high=10, medium=10, low=3, info=0
8
+ Duration: 600ms
9
+
10
+ Low coverage files
11
+ - app/controllers/posts_controller.rb: 22.22% lines
12
+ - app/models/post.rb: 44.44% lines
13
+
14
+ Top fixes
15
+ - [critical] SQL Injection: Possible SQL injection
16
+ app/models/post.rb:8
17
+ Review Brakeman guidance: https://brakemanscanner.org/docs/warning_types/sql_injection/
18
+ - [high] rack: Example vulnerability
19
+ Gemfile.lock
20
+ Update rack to a patched version and rerun Bundler Audit.
21
+ - [high] Prosopite: N+1 queries detected for Post => [:user]
22
+ 2
23
+ Fix the N+1 query by eager loading or adjusting the query path exercised by tests.
24
+ - [high] posts.user_id has no index
25
+ db/schema.rb
26
+ Add an index for the foreign key column to avoid slow association lookups.
27
+ - [high] users.email has a Rails uniqueness validation without a unique database index
28
+ app/models/user.rb
29
+ Back uniqueness validations with a unique index to prevent race-condition duplicates.
30
+ - [high] Routes reference missing ghosts_controller.rb
31
+ config/routes.rb
32
+ Create the controller or remove/rename the route.
33
+ - [high] Route points to missing posts#create
34
+ app/controllers/posts_controller.rb
35
+ Implement the action or update/remove the route.
36
+ - [high] Route points to missing posts#destroy
37
+ app/controllers/posts_controller.rb
38
+ Implement the action or update/remove the route.
39
+
40
+ Hotspots
41
+ - app/controllers/posts_controller.rb: score 42, 8 findings, churn 0
42
+ - app/models/post.rb: score 33, 7 findings, churn 0
43
+ - Gemfile.lock: score 8, 2 findings, churn 0
44
+ - db/schema.rb: score 7, 1 findings, churn 0
45
+ - app/models/user.rb: score 7, 1 findings, churn 0
data/exe/rails-doctor ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+
6
+ require "rails_doctor/cli"
7
+
8
+ exit RailsDoctor::CLI.new(ARGV).run
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RailsDoctor
6
+ module Adapters
7
+ class Base
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
+ self.class::NAME
20
+ end
21
+
22
+ def command
23
+ config.command(name)
24
+ end
25
+
26
+ def available?
27
+ return false if command.to_s.strip.empty?
28
+
29
+ declared_gems.any? { |gem| project.gem_declared?(gem) } || executable_names.any? { |exe| project.command_available?(exe) }
30
+ end
31
+
32
+ def unavailable_reason
33
+ "#{name} is not installed or available in this project."
34
+ end
35
+
36
+ def install_guidance
37
+ "Run rails-doctor init to add #{declared_gems.first || name} to the appropriate development/test group."
38
+ end
39
+
40
+ def run
41
+ command_result = runner.run(command, timeout_seconds: timeout_seconds)
42
+ {
43
+ tool_run: ToolRun.new(
44
+ name: name,
45
+ available: true,
46
+ skipped: false,
47
+ command: command,
48
+ exit_status: command_result.exit_status,
49
+ duration_ms: command_result.duration_ms,
50
+ stdout: command_result.stdout,
51
+ stderr: command_result.stderr,
52
+ metadata: metadata(command_result)
53
+ ),
54
+ findings: parse(command_result)
55
+ }
56
+ end
57
+
58
+ private
59
+
60
+ def declared_gems
61
+ []
62
+ end
63
+
64
+ def executable_names
65
+ [name]
66
+ end
67
+
68
+ def timeout_seconds
69
+ 120
70
+ end
71
+
72
+ def metadata(_command_result)
73
+ {}
74
+ end
75
+
76
+ def parse_json(output)
77
+ JSON.parse(output.to_s)
78
+ rescue JSON::ParserError
79
+ nil
80
+ end
81
+
82
+ def command_failed_finding(command_result, severity: "medium")
83
+ return nil if command_result.exit_status == 0
84
+
85
+ output = [command_result.stderr, command_result.stdout].compact.join("\n").strip
86
+ Finding.new(
87
+ severity: severity,
88
+ category: "tool-execution",
89
+ tool: name,
90
+ confidence: "high",
91
+ message: "#{name} exited with status #{command_result.exit_status}",
92
+ recommendation: "Run #{command.inspect} directly to inspect the underlying tool failure.",
93
+ agent_instruction: "Do not blindly edit app code. First determine whether #{name} failed because of configuration, missing dependencies, or a real project issue.",
94
+ metadata: { output_excerpt: output[0, 1_000] }
95
+ )
96
+ end
97
+
98
+ def severity_from_tool(value, default: "medium")
99
+ case value.to_s.downcase
100
+ when "fatal", "error", "critical" then "critical"
101
+ when "high" then "high"
102
+ when "warning", "warn", "medium" then "medium"
103
+ when "refactor", "convention", "low", "info" then "low"
104
+ else default
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDoctor
4
+ module Adapters
5
+ class Brakeman < Base
6
+ NAME = "brakeman"
7
+
8
+ private
9
+
10
+ def declared_gems
11
+ ["brakeman"]
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("warnings", []).map do |warning|
19
+ confidence = warning.fetch("confidence", "Medium").downcase
20
+ Finding.new(
21
+ severity: brakeman_severity(warning),
22
+ category: "security",
23
+ tool: name,
24
+ file: warning["file"],
25
+ line: warning["line"],
26
+ confidence: confidence,
27
+ message: "#{warning["warning_type"]}: #{warning["message"]}",
28
+ recommendation: warning["link"] ? "Review Brakeman guidance: #{warning["link"]}" : "Review and fix the security warning.",
29
+ agent_instruction: "Fix this security finding with the smallest behavior-preserving change. Prefer framework-safe APIs and add regression tests.",
30
+ metadata: { code: warning["code"], fingerprint: warning["fingerprint"] }.compact
31
+ )
32
+ end
33
+ end
34
+
35
+ def brakeman_severity(warning)
36
+ type = warning["warning_type"].to_s.downcase
37
+ return "critical" if type.match?(/sql|command|mass assignment|deserial/i)
38
+
39
+ case warning["confidence"].to_s.downcase
40
+ when "high" then "high"
41
+ when "weak" then "low"
42
+ else "medium"
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDoctor
4
+ module Adapters
5
+ class BundlerAudit < Base
6
+ NAME = "bundler_audit"
7
+
8
+ private
9
+
10
+ def declared_gems
11
+ ["bundler-audit"]
12
+ end
13
+
14
+ def executable_names
15
+ ["bundle-audit"]
16
+ end
17
+
18
+ def parse(command_result)
19
+ json = parse_json(command_result.stdout)
20
+ return [command_failed_finding(command_result, severity: "high")].compact unless json
21
+
22
+ results = json["results"] || json["vulnerabilities"] || []
23
+ results.map do |entry|
24
+ advisory = entry["advisory"] || entry
25
+ gem = entry["gem"] || {}
26
+ gem_name = gem["name"] || entry["name"] || advisory["gem"]
27
+ title = advisory["title"] || advisory["description"] || "Vulnerable dependency"
28
+ Finding.new(
29
+ severity: advisory_severity(advisory),
30
+ category: "dependency-security",
31
+ tool: name,
32
+ file: "Gemfile.lock",
33
+ confidence: "high",
34
+ message: "#{gem_name}: #{title}",
35
+ recommendation: "Update #{gem_name} to a patched version and rerun Bundler Audit.",
36
+ agent_instruction: "Update the vulnerable gem conservatively, refresh the lockfile, and run the test suite.",
37
+ suggested_commands: ["bundle update #{gem_name}", "bundle exec bundle-audit check"],
38
+ metadata: { advisory: advisory["id"] || advisory["cve"], url: advisory["url"] }.compact
39
+ )
40
+ end
41
+ end
42
+
43
+ def advisory_severity(advisory)
44
+ criticality = advisory["criticality"].to_s.downcase
45
+ return "critical" if criticality == "critical"
46
+ return "high" if criticality == "high"
47
+ return "medium" if criticality == "medium"
48
+
49
+ score = advisory["cvss_v3"] || advisory["cvss"]
50
+ score.to_f >= 9 ? "critical" : score.to_f >= 7 ? "high" : "medium"
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDoctor
4
+ module Adapters
5
+ class DependencyFreshness < Base
6
+ NAME = "dependency_freshness"
7
+
8
+ def available?
9
+ File.exist?(project.join("Gemfile.lock")) && project.command_available?("bundle")
10
+ end
11
+
12
+ def install_guidance
13
+ "Install Bundler and ensure Gemfile.lock is present before running dependency freshness checks."
14
+ end
15
+
16
+ private
17
+
18
+ def executable_names
19
+ ["bundle"]
20
+ end
21
+
22
+ def timeout_seconds
23
+ 180
24
+ end
25
+
26
+ def parse(command_result)
27
+ return [] if command_result.exit_status == 0 && command_result.stdout.strip.empty?
28
+
29
+ lines = command_result.stdout.lines.map(&:strip).reject(&:empty?)
30
+ lines.each_with_object([]) do |line, findings|
31
+ next unless line.include?(" ")
32
+
33
+ gem_name = line.split(/[ (]/).first
34
+ next if gem_name.to_s.empty?
35
+
36
+ findings << Finding.new(
37
+ severity: "low",
38
+ category: "dependency-freshness",
39
+ tool: name,
40
+ file: "Gemfile.lock",
41
+ confidence: "medium",
42
+ message: "#{gem_name} appears to be outdated",
43
+ recommendation: "Review the update risk and update in a separate dependency-focused change.",
44
+ agent_instruction: "Do not batch this with feature work. Update #{gem_name} conservatively and run the full test suite.",
45
+ suggested_commands: ["bundle update #{gem_name}"]
46
+ )
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDoctor
4
+ module Adapters
5
+ class Flay < Base
6
+ NAME = "flay"
7
+
8
+ private
9
+
10
+ def declared_gems
11
+ ["flay"]
12
+ end
13
+
14
+ def parse(command_result)
15
+ output = command_result.stdout.to_s
16
+ failed = command_failed_finding(command_result, severity: "medium")
17
+ return [failed].compact if failed && output.strip.empty?
18
+
19
+ groups = output.split(/Similar code found in/).drop(1)
20
+ groups.map.with_index(1) do |group, index|
21
+ files = group.scan(/([A-Za-z0-9_\/.-]+\.rb):(\d+)/).map { |file, line| "#{file}:#{line}" }
22
+ next if files.empty?
23
+
24
+ file, line_no = files.first.split(":")
25
+ Finding.new(
26
+ severity: "medium",
27
+ category: "duplication",
28
+ tool: name,
29
+ file: file,
30
+ line: line_no.to_i,
31
+ confidence: "medium",
32
+ message: "Similar code group #{index} across #{files.uniq.first(4).join(", ")}",
33
+ recommendation: "Review whether this duplication is intentional. Extract shared behavior only if the abstraction is clear.",
34
+ agent_instruction: "Do not blindly abstract. Compare the duplicated code paths, preserve semantics, and add tests if extracting shared code.",
35
+ metadata: { locations: files.uniq }
36
+ )
37
+ end.compact
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDoctor
4
+ module Adapters
5
+ class Flog < Base
6
+ NAME = "flog"
7
+
8
+ private
9
+
10
+ def declared_gems
11
+ ["flog"]
12
+ end
13
+
14
+ def parse(command_result)
15
+ failed = command_failed_finding(command_result, severity: "medium")
16
+ return [failed].compact if failed && command_result.stdout.strip.empty?
17
+
18
+ threshold = config.threshold("flog_high_score").to_f
19
+ command_result.stdout.lines.each_with_object([]) do |line, findings|
20
+ next unless line =~ /^\s*(\d+(?:\.\d+)?)\s+(.+?)(?:\s+([A-Za-z0-9_\/.-]+\.rb):(\d+))?\s*$/
21
+
22
+ score = Regexp.last_match(1).to_f
23
+ next if score < threshold
24
+
25
+ findings << Finding.new(
26
+ severity: score >= threshold * 2 ? "high" : "medium",
27
+ category: "complexity",
28
+ tool: name,
29
+ file: Regexp.last_match(3),
30
+ line: Regexp.last_match(4)&.to_i,
31
+ confidence: "medium",
32
+ message: "High complexity score #{score.round(1)} for #{Regexp.last_match(2).strip}",
33
+ recommendation: "Extract simpler methods or objects around the complex branch.",
34
+ agent_instruction: "Reduce complexity with behavior-preserving extraction. Do not combine this with unrelated cleanup.",
35
+ metadata: { flog_score: score }
36
+ )
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end