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