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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f6d4db14cadca4a355a495d14a6e005e9d34dd7c4b9e338c686f23614e66c02b
4
- data.tar.gz: 47fa1a18bcdb0f011e371aebfbb18d75cd8b24e6c6a08f91e2f7ea74359b47a7
3
+ metadata.gz: ed3ab8bad2609f892947c536cb38286fd793a80954c3d319fa420a5fbe60363d
4
+ data.tar.gz: 5c56138a1015010e73aef14fee3dd8711db17fa65ad859d1278de14fe2770bcf
5
5
  SHA512:
6
- metadata.gz: 4acb580364cf3d04eeef44162668ada9642264c918f8a3918d14c8fad47708e1c0003ade9e419b25c9804701cf48cdc47efbc78c403d67c9745569c147224cfd
7
- data.tar.gz: 431f3efe7bb6d6a1e82ecdc2993f85776f79ffe8ebb3175e88bf1fd54728366dff9ba778b87d0b8fa965a3ce6a6c6ee1a66aacca0ba3a86b9b1308ea597abbc9
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.scan(/^\s*def\s+([a-zA-Z_]\w*[!?=]?)/).flatten
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.scan(/to:\s*["']([a-zA-Z0-9_\/]+)#([a-zA-Z_]\w*)["']/).each do |controller, action|
382
- route_map[controller] << action
383
- end
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
- source.scan(/resources\s+:([a-zA-Z_]\w*)/).each do |resource|
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
- "bundle add #{target_gems.join(" ")} --group=#{target_group}"
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsDoctor
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-doctor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: '0.3'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Josh Saint Jacque