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,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "optparse"
5
+
6
+ require_relative "version"
7
+ module RailsDoctor
8
+ Error = Class.new(StandardError) unless const_defined?(:Error)
9
+ end
10
+
11
+ require_relative "models"
12
+ require_relative "config"
13
+ require_relative "scanner"
14
+ require_relative "project"
15
+ require_relative "command_runner"
16
+ require_relative "reporters/terminal"
17
+ require_relative "reporters/json"
18
+ require_relative "reporters/markdown"
19
+ require_relative "reporters/html"
20
+ require_relative "init/runner"
21
+ require_relative "agent/handoff"
22
+
23
+ module RailsDoctor
24
+ class CLI
25
+ FORMATS = %w[terminal json markdown html].freeze
26
+
27
+ def initialize(argv, stdout: $stdout, stderr: $stderr, env: ENV)
28
+ @argv = argv.dup
29
+ @stdout = stdout
30
+ @stderr = stderr
31
+ @env = env
32
+ end
33
+
34
+ def run
35
+ command = @argv.first&.start_with?("-") ? "scan" : (@argv.shift || "scan")
36
+ case command
37
+ when "scan" then run_scan
38
+ when "init" then run_init
39
+ when "agent" then run_agent
40
+ when "validate-config" then run_validate_config
41
+ when "version", "--version", "-v" then @stdout.puts VERSION; 0
42
+ when "help", "--help", "-h" then @stdout.puts(help); 0
43
+ else
44
+ @stderr.puts "Unknown command #{command.inspect}"
45
+ @stderr.puts help
46
+ 1
47
+ end
48
+ rescue Error => error
49
+ @stderr.puts "rails-doctor: #{error.message}"
50
+ 1
51
+ end
52
+
53
+ private
54
+
55
+ def run_scan
56
+ options = scan_options
57
+ config = Config.load(project_root: Dir.pwd, path: options[:config])
58
+ result = Scanner.new(project_root: Dir.pwd, config: config, env: @env).run(
59
+ profile: options[:profile],
60
+ changed_only: options[:changed_only],
61
+ base_ref: options[:base_ref]
62
+ )
63
+ output = render(result, options[:format], include_raw: options[:include_raw])
64
+ write_or_print(output, options[:output], format: options[:format])
65
+ threshold_exit(result, options)
66
+ end
67
+
68
+ def run_init
69
+ options = init_options
70
+ config = Config.load(project_root: Dir.pwd)
71
+ runner = CommandRunner.new(project_root: Dir.pwd, env: @env)
72
+ project = Project.new(root: Dir.pwd, runner: runner)
73
+ output = Init::Runner.new(project: project, config: config, runner: runner, options: options).run
74
+ @stdout.write(output)
75
+ 0
76
+ end
77
+
78
+ def run_agent
79
+ agent_name = @argv.shift || raise(Error, "agent name required")
80
+ options = agent_options
81
+ config = Config.load(project_root: Dir.pwd, path: options[:config])
82
+ runner = CommandRunner.new(project_root: Dir.pwd, env: @env)
83
+ project = Project.new(root: Dir.pwd, runner: runner)
84
+ result = Scanner.new(project_root: Dir.pwd, config: config, env: @env).run(
85
+ profile: options[:profile],
86
+ changed_only: options[:changed_only],
87
+ base_ref: options[:base_ref]
88
+ )
89
+ options[:changed_files] = result.metadata[:changed_files]
90
+ output = Agent::Handoff.new(
91
+ agent_name: agent_name,
92
+ project: project,
93
+ config: config,
94
+ runner: runner,
95
+ options: options
96
+ ).run(result)
97
+ @stdout.write(output)
98
+ 0
99
+ end
100
+
101
+ def run_validate_config
102
+ options = {}
103
+ OptionParser.new do |parser|
104
+ parser.on("--config PATH") { |value| options[:config] = value }
105
+ end.parse!(@argv)
106
+ Config.load(project_root: Dir.pwd, path: options[:config])
107
+ @stdout.puts "Rails Doctor config is valid."
108
+ 0
109
+ end
110
+
111
+ def scan_options
112
+ options = {
113
+ profile: "recommended",
114
+ format: "terminal",
115
+ changed_only: false,
116
+ include_raw: false
117
+ }
118
+ OptionParser.new do |parser|
119
+ parser.banner = "Usage: rails-doctor [scan] [options]"
120
+ parser.on("--profile NAME") { |value| options[:profile] = value }
121
+ parser.on("--format FORMAT") { |value| options[:format] = validate_format(value) }
122
+ parser.on("--output PATH") { |value| options[:output] = value }
123
+ parser.on("--config PATH") { |value| options[:config] = value }
124
+ parser.on("--changed-only") { options[:changed_only] = true }
125
+ parser.on("--base REF") { |value| options[:base_ref] = value }
126
+ parser.on("--include-raw") { options[:include_raw] = true }
127
+ parser.on("--fail-on SEVERITY") { |value| options[:fail_on] = value }
128
+ parser.on("--min-score SCORE", Integer) { |value| options[:min_score] = value }
129
+ end.parse!(@argv)
130
+ options
131
+ end
132
+
133
+ def init_options
134
+ options = {
135
+ profile: "recommended",
136
+ dry_run: false,
137
+ yes: false,
138
+ install: false,
139
+ ci: false
140
+ }
141
+ OptionParser.new do |parser|
142
+ parser.banner = "Usage: rails-doctor init [options]"
143
+ parser.on("--profile NAME") { |value| options[:profile] = value }
144
+ parser.on("--dry-run") { options[:dry_run] = true }
145
+ parser.on("--yes") { options[:yes] = true }
146
+ parser.on("--install") { options[:install] = true }
147
+ parser.on("--ci") { options[:ci] = true }
148
+ parser.on("--test-command COMMAND") { |value| options[:test_command] = value }
149
+ end.parse!(@argv)
150
+ options
151
+ end
152
+
153
+ def agent_options
154
+ options = {
155
+ profile: "recommended",
156
+ apply: false,
157
+ allow_dirty: false,
158
+ max_findings: 10,
159
+ changed_only: false
160
+ }
161
+ OptionParser.new do |parser|
162
+ parser.banner = "Usage: rails-doctor agent AGENT [options]"
163
+ parser.on("--profile NAME") { |value| options[:profile] = value }
164
+ parser.on("--config PATH") { |value| options[:config] = value }
165
+ parser.on("--severity SEVERITY") { |value| options[:severity] = value }
166
+ parser.on("--max-findings N", Integer) { |value| options[:max_findings] = value }
167
+ parser.on("--changed-only") { options[:changed_only] = true }
168
+ parser.on("--base REF") { |value| options[:base_ref] = value }
169
+ parser.on("--apply") { options[:apply] = true }
170
+ parser.on("--allow-dirty") { options[:allow_dirty] = true }
171
+ end.parse!(@argv)
172
+ options
173
+ end
174
+
175
+ def render(result, format, include_raw: false)
176
+ case format
177
+ when "terminal" then Reporters::Terminal.new(result).render
178
+ when "json" then Reporters::Json.new(result, include_raw: include_raw).render
179
+ when "markdown" then Reporters::Markdown.new(result).render
180
+ when "html" then Reporters::Html.new(result).render
181
+ else raise Error, "Unknown format #{format.inspect}"
182
+ end
183
+ end
184
+
185
+ def write_or_print(output, path, format:)
186
+ if path
187
+ FileUtils.mkdir_p(File.dirname(path))
188
+ File.write(path, output)
189
+ @stdout.puts "Wrote #{format} report to #{path}"
190
+ else
191
+ @stdout.write(output)
192
+ end
193
+ end
194
+
195
+ def threshold_exit(result, options)
196
+ if options[:fail_on]
197
+ threshold = SEVERITY_WEIGHTS.fetch(options[:fail_on]) { raise Error, "Unknown severity #{options[:fail_on]}" }
198
+ return 2 if result.findings.any? { |finding| SEVERITY_WEIGHTS.fetch(finding.severity, 0) >= threshold }
199
+ end
200
+
201
+ if options[:min_score] && result.score&.overall.to_i < options[:min_score]
202
+ return 2
203
+ end
204
+
205
+ 0
206
+ end
207
+
208
+ def validate_format(value)
209
+ raise Error, "Unknown format #{value.inspect}. Use #{FORMATS.join(", ")}." unless FORMATS.include?(value)
210
+
211
+ value
212
+ end
213
+
214
+ def help
215
+ <<~HELP
216
+ Rails Doctor #{VERSION}
217
+
218
+ Usage:
219
+ rails-doctor [scan] [--profile recommended] [--format terminal|json|markdown|html] [--base origin/main]
220
+ rails-doctor init [--dry-run] [--yes] [--install] [--ci]
221
+ rails-doctor agent codex [--severity high] [--apply]
222
+ rails-doctor validate-config
223
+
224
+ Scan profiles:
225
+ fast static/local only, no tests, no network
226
+ recommended core static checks and configured local coverage
227
+ ci static checks, tests, runtime warnings, artifacts
228
+ deep ci plus deep quality and dependency freshness
229
+ HELP
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "shellwords"
5
+ require "timeout"
6
+
7
+ module RailsDoctor
8
+ CommandResult = Struct.new(:command, :stdout, :stderr, :exit_status, :duration_ms, keyword_init: true)
9
+
10
+ class CommandRunner
11
+ attr_reader :project_root, :env
12
+
13
+ def initialize(project_root:, env: ENV)
14
+ @project_root = File.expand_path(project_root)
15
+ @env = env
16
+ end
17
+
18
+ def run(command, timeout_seconds: 120)
19
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
20
+ stdout = +""
21
+ stderr = +""
22
+ status_code = nil
23
+
24
+ begin
25
+ Timeout.timeout(timeout_seconds) do
26
+ stdout, stderr, status = Open3.capture3(env.to_h, command, chdir: project_root)
27
+ status_code = status.exitstatus
28
+ end
29
+ rescue Timeout::Error
30
+ stderr = "Command timed out after #{timeout_seconds}s"
31
+ status_code = 124
32
+ rescue Errno::ENOENT => error
33
+ stderr = error.message
34
+ status_code = 127
35
+ end
36
+
37
+ finished = Process.clock_gettime(Process::CLOCK_MONOTONIC)
38
+ CommandResult.new(
39
+ command: command,
40
+ stdout: stdout,
41
+ stderr: stderr,
42
+ exit_status: status_code,
43
+ duration_ms: ((finished - started) * 1000).round
44
+ )
45
+ end
46
+
47
+ def executable?(name)
48
+ path = env.fetch("PATH", "").split(File::PATH_SEPARATOR)
49
+ path.any? do |dir|
50
+ candidate = File.join(dir, name)
51
+ File.file?(candidate) && File.executable?(candidate)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module RailsDoctor
6
+ class Config
7
+ DEFAULT_FILE = ".rails-doctor.yml"
8
+
9
+ DEFAULTS = {
10
+ "version" => 1,
11
+ "profiles" => {
12
+ "fast" => {
13
+ "adapters" => %w[rubocop brakeman zeitwerk rails_checks],
14
+ "run_tests" => false,
15
+ "network" => false,
16
+ "deep_quality" => false
17
+ },
18
+ "recommended" => {
19
+ "adapters" => %w[rubocop brakeman bundler_audit zeitwerk reek strong_migrations rails_checks],
20
+ "run_tests" => false,
21
+ "network" => false,
22
+ "deep_quality" => false
23
+ },
24
+ "ci" => {
25
+ "adapters" => %w[rubocop brakeman bundler_audit zeitwerk reek strong_migrations rails_checks test_runner test_coverage],
26
+ "run_tests" => true,
27
+ "network" => false,
28
+ "deep_quality" => false
29
+ },
30
+ "deep" => {
31
+ "adapters" => %w[rubocop brakeman bundler_audit zeitwerk reek strong_migrations rails_checks test_runner test_coverage flog flay dependency_freshness],
32
+ "run_tests" => true,
33
+ "network" => true,
34
+ "deep_quality" => true
35
+ }
36
+ },
37
+ "commands" => {
38
+ "rubocop" => "bundle exec rubocop --format json",
39
+ "brakeman" => "bundle exec brakeman --format json --quiet",
40
+ "bundler_audit" => "bundle exec bundle-audit check --format json",
41
+ "zeitwerk" => "bundle exec rails zeitwerk:check",
42
+ "reek" => "bundle exec reek --format json",
43
+ "flog" => "bundle exec flog app lib",
44
+ "flay" => "bundle exec flay app lib",
45
+ "dependency_freshness" => "bundle outdated --parseable",
46
+ "test" => nil
47
+ },
48
+ "reports" => {
49
+ "default_format" => "terminal",
50
+ "output_dir" => "tmp/rails-doctor",
51
+ "include_raw_output" => true
52
+ },
53
+ "coverage" => {
54
+ "enabled" => true,
55
+ "source" => "simplecov",
56
+ "result_path" => "coverage/.resultset.json",
57
+ "include" => [
58
+ "app/**/*.rb",
59
+ "lib/**/*.rb"
60
+ ],
61
+ "max_files" => 10
62
+ },
63
+ "thresholds" => {
64
+ "fail_on" => nil,
65
+ "min_score" => nil,
66
+ "coverage" => {
67
+ "line" => 90.0,
68
+ "file_line" => 80.0,
69
+ "branch" => nil
70
+ },
71
+ "large_file_lines" => {
72
+ "model" => 250,
73
+ "controller" => 220,
74
+ "job" => 160,
75
+ "mailer" => 160,
76
+ "view" => 180
77
+ },
78
+ "todo_density_per_100_lines" => 2.0,
79
+ "flog_high_score" => 25.0
80
+ },
81
+ "git" => {
82
+ "churn_window_days" => 90,
83
+ "base_ref" => nil
84
+ },
85
+ "agents" => {
86
+ "codex" => {
87
+ "command" => "codex exec",
88
+ "apply_requires_clean_worktree" => true
89
+ },
90
+ "claude-code" => {
91
+ "command" => "claude",
92
+ "apply_requires_clean_worktree" => true
93
+ },
94
+ "cursor" => {
95
+ "command" => "cursor-agent",
96
+ "apply_requires_clean_worktree" => true
97
+ }
98
+ }
99
+ }.freeze
100
+
101
+ attr_reader :project_root, :path, :data
102
+
103
+ def initialize(project_root:, path: nil, data: nil)
104
+ @project_root = File.expand_path(project_root)
105
+ @path = path || File.join(@project_root, DEFAULT_FILE)
106
+ @data = deep_merge(DEFAULTS, data || load_file(@path))
107
+ end
108
+
109
+ def self.load(project_root:, path: nil)
110
+ new(project_root: project_root, path: path)
111
+ end
112
+
113
+ def profile(name)
114
+ data.fetch("profiles").fetch(name) do
115
+ raise Error, "Unknown profile #{name.inspect}. Available profiles: #{data.fetch("profiles").keys.join(", ")}"
116
+ end
117
+ end
118
+
119
+ def adapters_for(profile_name)
120
+ profile(profile_name).fetch("adapters")
121
+ end
122
+
123
+ def command(name)
124
+ data.fetch("commands", {})[name.to_s]
125
+ end
126
+
127
+ def threshold(key)
128
+ data.fetch("thresholds", {})[key.to_s]
129
+ end
130
+
131
+ def agent(name)
132
+ data.fetch("agents", {})[name.to_s]
133
+ end
134
+
135
+ def report_output_dir
136
+ File.join(project_root, data.fetch("reports").fetch("output_dir"))
137
+ end
138
+
139
+ def to_yaml
140
+ data.to_yaml
141
+ end
142
+
143
+ private
144
+
145
+ def load_file(file)
146
+ return {} unless file && File.exist?(file)
147
+
148
+ YAML.safe_load(File.read(file), aliases: true) || {}
149
+ end
150
+
151
+ def deep_merge(left, right)
152
+ left.merge(right) do |_key, old_value, new_value|
153
+ if old_value.is_a?(Hash) && new_value.is_a?(Hash)
154
+ deep_merge(old_value, new_value)
155
+ else
156
+ new_value
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "yaml"
5
+
6
+ module RailsDoctor
7
+ module Init
8
+ class Runner
9
+ RECOMMENDED_GEMS = {
10
+ "rubocop" => "development,test",
11
+ "rubocop-rails" => "development,test",
12
+ "brakeman" => "development,test",
13
+ "bundler-audit" => "development,test",
14
+ "reek" => "development,test",
15
+ "strong_migrations" => "development,test",
16
+ "prosopite" => "development,test",
17
+ "simplecov" => "development,test",
18
+ "flog" => "development,test",
19
+ "flay" => "development,test"
20
+ }.freeze
21
+
22
+ attr_reader :project, :config, :runner, :options
23
+
24
+ def initialize(project:, config:, runner:, options:)
25
+ @project = project
26
+ @config = config
27
+ @runner = runner
28
+ @options = options
29
+ end
30
+
31
+ def run
32
+ changes = planned_changes
33
+ lines = []
34
+ lines << "Rails Doctor init"
35
+ lines << "Profile: #{options.fetch(:profile, "recommended")}"
36
+ lines << ""
37
+ lines << "Planned file changes"
38
+ changes.each do |change|
39
+ lines << "- #{change.fetch(:path)} (#{change.fetch(:action)})"
40
+ end
41
+ lines << ""
42
+
43
+ missing = missing_gems
44
+ if missing.any?
45
+ lines << "Missing recommended gems"
46
+ missing.each { |gem| lines << "- #{gem} (#{RECOMMENDED_GEMS.fetch(gem)})" }
47
+ lines << ""
48
+ end
49
+
50
+ if options[:dry_run]
51
+ lines << "Dry run only. No files were changed."
52
+ return lines.join("\n") + "\n"
53
+ end
54
+
55
+ if options[:yes] || confirm?("Apply Rails Doctor configuration? [y/N] ")
56
+ changes.each { |change| write_change(change) }
57
+ lines << "Wrote Rails Doctor configuration."
58
+ else
59
+ lines << "Skipped file changes."
60
+ end
61
+
62
+ if missing.any? && should_install_missing?
63
+ install_missing(missing, lines)
64
+ elsif missing.any?
65
+ lines << "Skipped gem installation. Run rails-doctor init --install to install missing tools."
66
+ end
67
+
68
+ lines.join("\n") + "\n"
69
+ end
70
+
71
+ private
72
+
73
+ def planned_changes
74
+ changes = [
75
+ {
76
+ path: Config::DEFAULT_FILE,
77
+ action: File.exist?(project.join(Config::DEFAULT_FILE)) ? "update" : "create",
78
+ content: generated_config
79
+ }
80
+ ]
81
+
82
+ if options[:ci]
83
+ changes << {
84
+ path: ".github/workflows/rails-doctor.yml",
85
+ action: File.exist?(project.join(".github/workflows/rails-doctor.yml")) ? "update" : "create",
86
+ content: github_workflow
87
+ }
88
+ end
89
+
90
+ changes
91
+ end
92
+
93
+ def generated_config
94
+ data = Config::DEFAULTS.dup
95
+ data = deep_dup(data)
96
+ data["commands"]["test"] = options[:test_command] || detect_test_command
97
+ data["reports"]["output_dir"] = "tmp/rails-doctor"
98
+ data.to_yaml
99
+ end
100
+
101
+ def github_workflow
102
+ <<~YAML
103
+ name: Rails Doctor
104
+
105
+ on:
106
+ pull_request:
107
+ workflow_dispatch:
108
+
109
+ permissions:
110
+ contents: read
111
+ pull-requests: write
112
+
113
+ jobs:
114
+ rails-doctor:
115
+ runs-on: ubuntu-latest
116
+ strategy:
117
+ fail-fast: false
118
+ matrix:
119
+ ruby: ["3.2", "3.3", "3.4"]
120
+ steps:
121
+ - uses: actions/checkout@v4
122
+ with:
123
+ fetch-depth: 0
124
+ - uses: ruby/setup-ruby@v1
125
+ with:
126
+ ruby-version: ${{ matrix.ruby }}
127
+ bundler-cache: true
128
+ - run: bundle exec rails-doctor --profile ci --base origin/${{ github.base_ref || 'main' }} --format markdown --output tmp/rails-doctor/summary.md
129
+ - run: bundle exec rails-doctor --profile ci --base origin/${{ github.base_ref || 'main' }} --format json --output tmp/rails-doctor/report.json
130
+ - run: bundle exec rails-doctor --profile ci --base origin/${{ github.base_ref || 'main' }} --format html --output tmp/rails-doctor/report.html
131
+ - name: Rails Doctor summary
132
+ run: cat tmp/rails-doctor/summary.md >> "$GITHUB_STEP_SUMMARY"
133
+ - name: Optional PR comment
134
+ if: github.event_name == 'pull_request' && vars.RAILS_DOCTOR_PR_COMMENT == 'true'
135
+ run: gh pr comment "$PR_URL" --body-file tmp/rails-doctor/summary.md
136
+ env:
137
+ GH_TOKEN: ${{ github.token }}
138
+ PR_URL: ${{ github.event.pull_request.html_url }}
139
+ - uses: actions/upload-artifact@v4
140
+ with:
141
+ name: rails-doctor-${{ matrix.ruby }}
142
+ path: tmp/rails-doctor
143
+ YAML
144
+ end
145
+
146
+ def write_change(change)
147
+ path = project.join(change.fetch(:path))
148
+ FileUtils.mkdir_p(File.dirname(path))
149
+ File.write(path, change.fetch(:content))
150
+ end
151
+
152
+ def missing_gems
153
+ RECOMMENDED_GEMS.keys.reject { |gem| project.gem_declared?(gem) }
154
+ end
155
+
156
+ def should_install_missing?
157
+ return true if options[:install]
158
+ return false if options[:yes]
159
+
160
+ confirm?("Install missing recommended gems with bundle add? [y/N] ")
161
+ end
162
+
163
+ def install_missing(missing, lines)
164
+ missing.group_by { |gem| RECOMMENDED_GEMS.fetch(gem) }.each do |group, gems|
165
+ command = "bundle add #{gems.join(" ")} --group=#{group}"
166
+ result = runner.run(command, timeout_seconds: 300)
167
+ lines << "Ran #{command}: exit #{result.exit_status}"
168
+ lines << result.stderr.strip unless result.stderr.to_s.strip.empty?
169
+ end
170
+ end
171
+
172
+ def detect_test_command
173
+ return "bundle exec rspec" if File.exist?(project.join("spec"))
174
+ return "bin/rails test" if File.exist?(project.join("test")) || File.exist?(project.join("bin/rails"))
175
+
176
+ nil
177
+ end
178
+
179
+ def confirm?(prompt)
180
+ return false unless $stdin.tty?
181
+
182
+ print prompt
183
+ $stdin.gets.to_s.strip.downcase.start_with?("y")
184
+ end
185
+
186
+ def deep_dup(value)
187
+ Marshal.load(Marshal.dump(value))
188
+ end
189
+ end
190
+ end
191
+ end