rails-doctor 0.2.0 → 0.3
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 +4 -4
- data/CHANGELOG.md +6 -0
- data/examples/github-actions/rails-doctor.yml +7 -0
- data/lib/rails_doctor/checks/rails_checks.rb +192 -7
- data/lib/rails_doctor/init/runner.rb +14 -1
- data/lib/rails_doctor/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ed3ab8bad2609f892947c536cb38286fd793a80954c3d319fa420a5fbe60363d
|
|
4
|
+
data.tar.gz: 5c56138a1015010e73aef14fee3dd8711db17fa65ad859d1278de14fe2770bcf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a27e13091f164d1cf8381f2ff0a812425bc10aae66929f91e1c582f08381c247ae48348a98a265d4c25a9a9d746d6d63cf17913c070048362883c6d060e9d494
|
|
7
|
+
data.tar.gz: 0c5f55e894a9da56d9c13ceb31026c73034b44228d7cde6a167b4b58307d2ead1c72880b6bbd44d4d4a1d06d1c332a4efa3022fbfa545664dc420f8ac118eab5
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3
|
|
4
|
+
|
|
5
|
+
- Reduced Rails route-check false positives for `resources` `only:`/`except:` options, namespace and `scope module:` blocks, inherited Devise controller actions, and private controller helper methods.
|
|
6
|
+
- Made `rails-doctor init --install` run Bundler through the active Ruby executable so rbenv/asdf/shimmed environments use the same Ruby context as Rails Doctor.
|
|
7
|
+
- Added npm install steps to generated GitHub Actions workflows when a `package-lock.json` is present, improving Rails app support for npm-managed frontend assets.
|
|
8
|
+
|
|
3
9
|
## 0.2.0
|
|
4
10
|
|
|
5
11
|
- Improved Rails schema parsing for inline indexes, single-column indexes, scoped uniqueness validations, partial unique indexes, and string-backed foreign keys.
|
|
@@ -23,6 +23,13 @@ jobs:
|
|
|
23
23
|
with:
|
|
24
24
|
ruby-version: ${{ matrix.ruby }}
|
|
25
25
|
bundler-cache: true
|
|
26
|
+
- uses: actions/setup-node@v4
|
|
27
|
+
if: ${{ hashFiles('package-lock.json') != '' }}
|
|
28
|
+
with:
|
|
29
|
+
node-version: "22"
|
|
30
|
+
cache: npm
|
|
31
|
+
- run: npm ci
|
|
32
|
+
if: ${{ hashFiles('package-lock.json') != '' }}
|
|
26
33
|
- run: bundle exec rails-doctor --profile ci --base origin/${{ github.base_ref || 'main' }} --format markdown --output tmp/rails-doctor/summary.md
|
|
27
34
|
- run: bundle exec rails-doctor --profile ci --base origin/${{ github.base_ref || 'main' }} --format json --output tmp/rails-doctor/report.json
|
|
28
35
|
- run: bundle exec rails-doctor --profile ci --base origin/${{ github.base_ref || 'main' }} --format html --output tmp/rails-doctor/report.html
|
|
@@ -1,9 +1,28 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
begin
|
|
4
|
+
require "active_support/inflector"
|
|
5
|
+
rescue LoadError
|
|
6
|
+
nil
|
|
7
|
+
end
|
|
8
|
+
|
|
3
9
|
module RailsDoctor
|
|
4
10
|
module Checks
|
|
5
11
|
class RailsChecks
|
|
6
12
|
NAME = "rails_checks"
|
|
13
|
+
RESTFUL_ACTIONS = %w[index show new create edit update destroy].freeze
|
|
14
|
+
SINGULAR_RESTFUL_ACTIONS = %w[show new create edit update destroy].freeze
|
|
15
|
+
DEVISE_INHERITED_ACTIONS = {
|
|
16
|
+
"Devise::ConfirmationsController" => %w[new create show],
|
|
17
|
+
"Devise::OmniauthCallbacksController" => %w[failure passthru],
|
|
18
|
+
"Devise::PasswordsController" => %w[new create edit update],
|
|
19
|
+
"Devise::RegistrationsController" => %w[cancel create destroy edit new update],
|
|
20
|
+
"Devise::SessionsController" => %w[create destroy new],
|
|
21
|
+
"Devise::UnlocksController" => %w[new create show]
|
|
22
|
+
}.freeze
|
|
23
|
+
FALLBACK_IRREGULAR_PLURALS = {
|
|
24
|
+
"person" => "people"
|
|
25
|
+
}.freeze
|
|
7
26
|
|
|
8
27
|
attr_reader :project, :config, :runner, :profile, :changed_files
|
|
9
28
|
|
|
@@ -153,9 +172,11 @@ module RailsDoctor
|
|
|
153
172
|
end
|
|
154
173
|
|
|
155
174
|
controller_source = File.read(controller_file)
|
|
156
|
-
defined_actions = controller_source
|
|
175
|
+
defined_actions = controller_actions(controller_source)
|
|
157
176
|
actions.each do |action|
|
|
158
177
|
unless defined_actions.include?(action)
|
|
178
|
+
next if inherited_route_action?(controller_source, action)
|
|
179
|
+
|
|
159
180
|
findings << Finding.new(
|
|
160
181
|
severity: "high",
|
|
161
182
|
category: "routing",
|
|
@@ -377,19 +398,180 @@ module RailsDoctor
|
|
|
377
398
|
|
|
378
399
|
source = File.read(path)
|
|
379
400
|
route_map = Hash.new { |hash, key| hash[key] = [] }
|
|
401
|
+
block_stack = []
|
|
380
402
|
|
|
381
|
-
source
|
|
382
|
-
|
|
383
|
-
|
|
403
|
+
route_lines(source).each do |line|
|
|
404
|
+
next if line.empty? || line.start_with?("#")
|
|
405
|
+
|
|
406
|
+
if line.match?(/\Aend\b/)
|
|
407
|
+
block_stack.pop
|
|
408
|
+
next
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
module_stack = block_stack.filter_map { |frame| frame[:module] }
|
|
412
|
+
add_explicit_routes(route_map, line, module_stack)
|
|
413
|
+
add_resource_routes(route_map, line, module_stack)
|
|
384
414
|
|
|
385
|
-
|
|
386
|
-
controller = resource.first
|
|
387
|
-
route_map[controller].concat(%w[index show new create edit update destroy])
|
|
415
|
+
block_stack << route_block_frame(line) if route_block_opens?(line)
|
|
388
416
|
end
|
|
389
417
|
|
|
390
418
|
route_map.transform_values { |actions| actions.uniq.sort }
|
|
391
419
|
end
|
|
392
420
|
|
|
421
|
+
def route_lines(source)
|
|
422
|
+
lines = []
|
|
423
|
+
buffer = nil
|
|
424
|
+
|
|
425
|
+
source.each_line do |raw_line|
|
|
426
|
+
line = raw_line.strip
|
|
427
|
+
|
|
428
|
+
if buffer
|
|
429
|
+
buffer = "#{buffer} #{line}"
|
|
430
|
+
if route_statement_complete?(buffer)
|
|
431
|
+
lines << buffer
|
|
432
|
+
buffer = nil
|
|
433
|
+
end
|
|
434
|
+
elsif route_statement_start?(line) && !route_statement_complete?(line)
|
|
435
|
+
buffer = line
|
|
436
|
+
else
|
|
437
|
+
lines << line
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
lines << buffer if buffer
|
|
442
|
+
lines
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def route_statement_start?(line)
|
|
446
|
+
line.match?(/\A(?:get|post|put|patch|delete|match|root|namespace|scope|resource|resources)\b/)
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def route_statement_complete?(line)
|
|
450
|
+
return false if line.end_with?(",")
|
|
451
|
+
|
|
452
|
+
bracket_balanced?(line, "[", "]") &&
|
|
453
|
+
bracket_balanced?(line, "{", "}") &&
|
|
454
|
+
bracket_balanced?(line, "(", ")")
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def bracket_balanced?(line, left, right)
|
|
458
|
+
line.count(left) == line.count(right)
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def route_block_opens?(line)
|
|
462
|
+
line.match?(/\bdo\b/) || line.match?(/\A(?:if|unless|case|begin)\b/)
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def add_explicit_routes(route_map, line, module_stack)
|
|
466
|
+
line.scan(/to:\s*["'](\/?)([a-zA-Z0-9_\/]+)#([a-zA-Z_]\w*)["']/).each do |absolute, controller, action|
|
|
467
|
+
modules = absolute == "/" ? [] : route_modules(line, module_stack)
|
|
468
|
+
route_map[controller_with_modules(controller, modules)] << action
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def add_resource_routes(route_map, line, module_stack)
|
|
473
|
+
line.scan(/\b(resource|resources)\s+:([a-zA-Z_]\w*)(.*)$/).each do |kind, resource, options|
|
|
474
|
+
singular = kind == "resource"
|
|
475
|
+
controller = route_option_value(options, "controller") || (singular ? pluralize(resource) : resource)
|
|
476
|
+
absolute = controller.start_with?("/")
|
|
477
|
+
modules = absolute ? [] : route_modules(options, module_stack)
|
|
478
|
+
route_map[controller_with_modules(controller.delete_prefix("/"), modules)].concat(resource_actions(options, singular: singular))
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def route_block_frame(line)
|
|
483
|
+
{ module: route_namespace_module(line) || route_scope_module(line) }.compact
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def route_namespace_module(line)
|
|
487
|
+
match = line.match(/\bnamespace\s+(?::([a-zA-Z_]\w*)|["']([^"']+)["'])/)
|
|
488
|
+
match && (match[1] || match[2])
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def route_scope_module(line)
|
|
492
|
+
route_option_value(line, "module") if line.match?(/\bscope\b/)
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def route_modules(options, module_stack)
|
|
496
|
+
module_stack + [route_option_value(options, "module")].compact
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def controller_with_modules(controller, modules)
|
|
500
|
+
prefix = modules.reject(&:empty?).join("/")
|
|
501
|
+
return controller if prefix.empty?
|
|
502
|
+
return controller if controller == prefix || controller.start_with?("#{prefix}/")
|
|
503
|
+
|
|
504
|
+
"#{prefix}/#{controller}"
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def resource_actions(options, singular:)
|
|
508
|
+
actions = singular ? SINGULAR_RESTFUL_ACTIONS.dup : RESTFUL_ACTIONS.dup
|
|
509
|
+
only = route_action_option(options, "only")
|
|
510
|
+
except = route_action_option(options, "except")
|
|
511
|
+
actions &= only if only
|
|
512
|
+
actions -= except if except
|
|
513
|
+
actions
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def route_action_option(options, key)
|
|
517
|
+
percent_list = /%[iIwW](?:\[[^\]]*\]|\([^\)]*\)|\{[^\}]*\}|<[^>]*>)/
|
|
518
|
+
match = options.match(/\b#{Regexp.escape(key)}:\s*(\[[^\]]*\]|#{percent_list}|:[a-zA-Z_]\w*|["'][^"']+["'])/)
|
|
519
|
+
return unless match
|
|
520
|
+
|
|
521
|
+
extract_route_actions(match[1])
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def extract_route_actions(value)
|
|
525
|
+
if value.start_with?("%i", "%I", "%w", "%W")
|
|
526
|
+
value[/\A%[iIwW].(.*).\z/, 1].to_s.split(/\s+/)
|
|
527
|
+
elsif value.start_with?("[")
|
|
528
|
+
value.scan(/:([a-zA-Z_]\w*)/).flatten + value.scan(/["']([a-zA-Z_]\w*)["']/).flatten
|
|
529
|
+
else
|
|
530
|
+
[value.delete_prefix(":").delete_prefix("\"").delete_suffix("\"").delete_prefix("'").delete_suffix("'")]
|
|
531
|
+
end.uniq
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
def route_option_value(options, key)
|
|
535
|
+
match = options.match(/\b#{Regexp.escape(key)}:\s*(?::([a-zA-Z_]\w*)|["']([^"']+)["'])/)
|
|
536
|
+
match && (match[1] || match[2])
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def controller_actions(source)
|
|
540
|
+
visibility = :public
|
|
541
|
+
actions = []
|
|
542
|
+
all_methods = []
|
|
543
|
+
non_public_methods = []
|
|
544
|
+
public_methods = []
|
|
545
|
+
|
|
546
|
+
source.each_line do |line|
|
|
547
|
+
if line.match?(/^\s*(private|protected)\s*(?:#.*)?$/)
|
|
548
|
+
visibility = :non_public
|
|
549
|
+
elsif line.match?(/^\s*public\s*(?:#.*)?$/)
|
|
550
|
+
visibility = :public
|
|
551
|
+
elsif (match = line.match(/^\s*(?:private|protected)\s+(.+)/))
|
|
552
|
+
non_public_methods.concat(visibility_method_names(match[1]))
|
|
553
|
+
elsif (match = line.match(/^\s*public\s+(.+)/))
|
|
554
|
+
public_methods.concat(visibility_method_names(match[1]))
|
|
555
|
+
elsif visibility == :public && (match = line.match(/^\s*def\s+([a-zA-Z_]\w*[!?=]?)/))
|
|
556
|
+
all_methods << match[1]
|
|
557
|
+
actions << match[1]
|
|
558
|
+
elsif (match = line.match(/^\s*def\s+([a-zA-Z_]\w*[!?=]?)/))
|
|
559
|
+
all_methods << match[1]
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
((actions - non_public_methods) + (all_methods & public_methods)).uniq
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
def visibility_method_names(source)
|
|
567
|
+
source.scan(/:([a-zA-Z_]\w*[!?=]?)/).flatten + source.scan(/["']([a-zA-Z_]\w*[!?=]?)["']/).flatten
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def inherited_route_action?(source, action)
|
|
571
|
+
superclass = source[/<\s*(Devise::[A-Za-z0-9_:]+Controller)/, 1]
|
|
572
|
+
DEVISE_INHERITED_ACTIONS.fetch(superclass, []).include?(action)
|
|
573
|
+
end
|
|
574
|
+
|
|
393
575
|
def explicit_response?(source, action)
|
|
394
576
|
body = source[/^\s*def\s+#{Regexp.escape(action)}\b(.*?)^\s*end/m, 1].to_s
|
|
395
577
|
body.match?(/\b(render|redirect_to|head|respond_to|send_data|send_file)\b/)
|
|
@@ -414,7 +596,10 @@ module RailsDoctor
|
|
|
414
596
|
end
|
|
415
597
|
|
|
416
598
|
def pluralize(value)
|
|
599
|
+
return ActiveSupport::Inflector.pluralize(value) if defined?(ActiveSupport::Inflector)
|
|
600
|
+
return FALLBACK_IRREGULAR_PLURALS.fetch(value) if FALLBACK_IRREGULAR_PLURALS.key?(value)
|
|
417
601
|
return value.sub(/y\z/, "ies") if value.end_with?("y")
|
|
602
|
+
return "#{value}es" if value.match?(/(?:s|x|z|ch|sh)\z/)
|
|
418
603
|
return value if value.end_with?("s")
|
|
419
604
|
|
|
420
605
|
"#{value}s"
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
require "shellwords"
|
|
4
6
|
require "yaml"
|
|
5
7
|
|
|
6
8
|
module RailsDoctor
|
|
@@ -133,6 +135,13 @@ module RailsDoctor
|
|
|
133
135
|
with:
|
|
134
136
|
ruby-version: ${{ matrix.ruby }}
|
|
135
137
|
bundler-cache: true
|
|
138
|
+
- uses: actions/setup-node@v4
|
|
139
|
+
if: ${{ hashFiles('package-lock.json') != '' }}
|
|
140
|
+
with:
|
|
141
|
+
node-version: "22"
|
|
142
|
+
cache: npm
|
|
143
|
+
- run: npm ci
|
|
144
|
+
if: ${{ hashFiles('package-lock.json') != '' }}
|
|
136
145
|
- run: bundle exec rails-doctor --profile ci --base origin/${{ github.base_ref || 'main' }} --format markdown --output tmp/rails-doctor/summary.md
|
|
137
146
|
- run: bundle exec rails-doctor --profile ci --base origin/${{ github.base_ref || 'main' }} --format json --output tmp/rails-doctor/report.json
|
|
138
147
|
- run: bundle exec rails-doctor --profile ci --base origin/${{ github.base_ref || 'main' }} --format html --output tmp/rails-doctor/report.html
|
|
@@ -180,10 +189,14 @@ module RailsDoctor
|
|
|
180
189
|
def install_command(gems, group: nil)
|
|
181
190
|
grouped = group ? { group => gems } : gems.group_by { |gem| RECOMMENDED_GEMS.fetch(gem) }
|
|
182
191
|
grouped.map do |target_group, target_gems|
|
|
183
|
-
"
|
|
192
|
+
"#{bundle_command} add #{target_gems.join(" ")} --group=#{target_group}"
|
|
184
193
|
end.join(" && ")
|
|
185
194
|
end
|
|
186
195
|
|
|
196
|
+
def bundle_command
|
|
197
|
+
"#{Shellwords.escape(RbConfig.ruby)} -S bundle"
|
|
198
|
+
end
|
|
199
|
+
|
|
187
200
|
def deep_profile?
|
|
188
201
|
options.fetch(:profile, "recommended").to_s == "deep"
|
|
189
202
|
end
|
data/lib/rails_doctor/version.rb
CHANGED