rigortype 0.1.11 → 0.1.13

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/check_rules.rb +96 -3
  3. data/lib/rigor/analysis/erb_template_detector.rb +38 -0
  4. data/lib/rigor/analysis/runner.rb +6 -1
  5. data/lib/rigor/analysis/worker_session.rb +6 -1
  6. data/lib/rigor/cli/plugins_command.rb +308 -0
  7. data/lib/rigor/cli/plugins_renderer.rb +173 -0
  8. data/lib/rigor/cli/skill_command.rb +170 -0
  9. data/lib/rigor/cli.rb +37 -1
  10. data/lib/rigor/configuration/severity_profile.rb +3 -0
  11. data/lib/rigor/inference/block_parameter_binder.rb +35 -0
  12. data/lib/rigor/inference/expression_typer.rb +69 -30
  13. data/lib/rigor/inference/indexed_narrowing.rb +187 -0
  14. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
  15. data/lib/rigor/inference/method_dispatcher.rb +23 -0
  16. data/lib/rigor/inference/mutation_widening.rb +285 -0
  17. data/lib/rigor/inference/narrowing.rb +72 -4
  18. data/lib/rigor/inference/scope_indexer.rb +409 -12
  19. data/lib/rigor/inference/statement_evaluator.rb +256 -4
  20. data/lib/rigor/scope.rb +195 -4
  21. data/lib/rigor/version.rb +1 -1
  22. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +22 -1
  23. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +94 -6
  24. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +11 -1
  25. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +7 -1
  26. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +135 -11
  27. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +94 -43
  28. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +138 -35
  29. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +17 -3
  30. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +10 -0
  31. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +13 -3
  32. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +6 -2
  33. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +83 -7
  34. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +4 -1
  35. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +16 -1
  36. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +81 -5
  37. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +11 -3
  38. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +194 -5
  39. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
  40. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
  41. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
  42. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +64 -3
  43. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1107 -59
  44. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +81 -4
  45. data/sig/rigor/scope.rbs +23 -0
  46. data/skills/rigor-baseline-reduce/SKILL.md +100 -0
  47. data/skills/rigor-baseline-reduce/references/01-classify.md +107 -0
  48. data/skills/rigor-baseline-reduce/references/02-fix-or-suppress.md +133 -0
  49. data/skills/rigor-plugin-author/SKILL.md +95 -0
  50. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +195 -0
  51. data/skills/rigor-plugin-author/references/02-walker-and-types.md +155 -0
  52. data/skills/rigor-plugin-author/references/03-test-and-ship.md +163 -0
  53. data/skills/rigor-project-init/SKILL.md +129 -0
  54. data/skills/rigor-project-init/references/01-detect.md +101 -0
  55. data/skills/rigor-project-init/references/02-configure.md +185 -0
  56. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +168 -0
  57. data/skills/rigor-project-init/references/04-sig-uplift.md +171 -0
  58. metadata +22 -1
@@ -15,34 +15,126 @@ module Rigor
15
15
 
16
16
  # Built-in Rails helpers we don't want to flag as
17
17
  # unknown. The plugin's HelperTable describes
18
- # user-declared routes; Rails ships built-in helpers
19
- # (`url_for`, `polymorphic_path`, ) the plugin
20
- # deliberately ignores.
18
+ # user-declared routes; Rails (and a small set of
19
+ # widely-used asset gems) ship built-in helpers
20
+ # (`url_for`, `polymorphic_path`, vite_ruby's
21
+ # `vite_asset_path`, …) the plugin deliberately ignores.
21
22
  BUILTIN_PASSTHROUGH = %w[
22
23
  url_for_path url_for_url
23
24
  polymorphic_path polymorphic_url
25
+ vite_asset_path vite_asset_url
26
+ expose_path expose_url
27
+ asset_path asset_url
28
+ image_path image_url
29
+ javascript_path javascript_url
30
+ stylesheet_path stylesheet_url
31
+ font_path font_url
32
+ video_path video_url
33
+ audio_path audio_url
24
34
  ].freeze
25
35
 
26
36
  Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
27
37
 
28
38
  module_function
29
39
 
40
+ # RSpec / Minitest DSL methods that DECLARE a memoized
41
+ # local — `let(:foo) { ... }`, `subject(:foo) { ... }`,
42
+ # plus the bang and let_it_be variants. The first
43
+ # positional Symbol argument is the local name. A bare
44
+ # `subject { ... }` (no arg) defines `:subject` itself.
45
+ SHADOWING_DSL = %i[
46
+ let let! let_it_be let_it_be!
47
+ subject subject!
48
+ ].freeze
49
+
50
+ # Paths under which `unknown-helper` is suppressed. Route
51
+ # helpers are not customarily resolved through these
52
+ # directories, so a bare `shared_inbox_url` call inside
53
+ # `app/models/account.rb` (the call-site is the
54
+ # `accounts.shared_inbox_url` AR column accessor — same
55
+ # name as a hypothetical route helper) or a `default_url`
56
+ # inside `lib/paperclip/url_generator_extensions.rb` (the
57
+ # call-site is `Paperclip::UrlGenerator#default_url`, a
58
+ # gem-side instance method) would be a false positive.
59
+ # The core engine's `call.undefined-method` still catches
60
+ # a genuinely unreachable receiver. Cheap path-prefix
61
+ # check; no AR-column / gem-include analysis required.
62
+ SKIP_UNKNOWN_HELPER_PATHS = [
63
+ %r{(?:\A|/)app/models/},
64
+ %r{(?:\A|/)app/services/},
65
+ %r{(?:\A|/)app/workers/},
66
+ %r{(?:\A|/)app/finders/},
67
+ %r{(?:\A|/)app/policies/},
68
+ %r{(?:\A|/)app/validators/},
69
+ %r{(?:\A|/)app/uploaders/},
70
+ %r{(?:\A|/)lib/},
71
+ %r{(?:\A|/)db/},
72
+ %r{(?:\A|/)config/}
73
+ ].freeze
74
+
30
75
  # @param path [String] file being analysed
31
76
  # @param root [Prism::Node]
32
77
  # @param helper_table [HelperTable]
33
78
  # @return [Array<Diagnostic>]
34
79
  def diagnose(path:, root:, helper_table:)
80
+ suppress_unknown = SKIP_UNKNOWN_HELPER_PATHS.any? { |re| path.match?(re) }
81
+ # Same SKIP set silences `wrong-arity` too. A call
82
+ # like `group_path` inside `app/services/groups/nested_
83
+ # create_service.rb` is almost certainly the service's
84
+ # own instance method (`attr_accessor :group_path`) —
85
+ # both the unknown-helper check and the arity check
86
+ # against a registered route helper would FP.
35
87
  diagnostics = []
88
+ # Pre-walk the file to collect every name that
89
+ # shadows a route helper at call time: `let(:foo)`,
90
+ # `subject(:foo)`, `def foo`, and explicit local
91
+ # assignments (`foo_url = "..."`). At the call site
92
+ # `foo` then resolves to the shadowing local, not to
93
+ # the registered route helper — firing `unknown-helper`
94
+ # / `wrong-arity` against the helper would be a false
95
+ # positive against canonical RSpec idioms (Mastodon
96
+ # has 200+ such patterns in `spec/`).
97
+ shadowing = collect_shadowing_names(root)
98
+
36
99
  walk(root) do |call_node|
37
100
  name = call_node.name.to_s
38
101
  next unless name.end_with?("_path") || name.end_with?("_url")
39
102
  next if BUILTIN_PASSTHROUGH.include?(name)
103
+ next if shadowing.include?(name)
40
104
 
41
105
  entry = helper_table.find(name)
42
106
  if entry
107
+ # When the file is in a `SKIP_UNKNOWN_HELPER_PATHS`
108
+ # directory (models / services / workers / lib /
109
+ # …), don't emit the info or arity diagnostic
110
+ # against a registered helper either — the call
111
+ # is more likely the file's own instance method
112
+ # (e.g. `group_path` as an `attr_accessor`) that
113
+ # happens to share a name with a registered
114
+ # route helper. The unknown-helper suppression
115
+ # already silences the inverse case.
116
+ next if suppress_unknown
117
+
43
118
  diagnostics << info_diagnostic(path, call_node, entry)
44
119
  arity_diagnostic = arity_check(path, call_node, entry, helper_table)
45
120
  diagnostics << arity_diagnostic if arity_diagnostic
121
+ elsif helper_table.recognised?(name)
122
+ # Custom helper (discovered via
123
+ # `app/helpers/**/*.rb`) or a dynamic-provider
124
+ # Devise OmniAuth helper. We do NOT have an
125
+ # arity / path to validate — the helper is just
126
+ # known-to-exist. Skip the arity / info
127
+ # diagnostic; the absence of an `unknown-helper`
128
+ # error is the user-visible outcome.
129
+ next
130
+ elsif suppress_unknown
131
+ # File is in a directory where route helpers are
132
+ # not customarily resolved (models / lib / db /
133
+ # config). Skip `unknown-helper` to avoid false
134
+ # positives on AR column accessors / gem-side
135
+ # instance methods that happen to end in `_url` /
136
+ # `_path`.
137
+ next
46
138
  else
47
139
  diagnostics << unknown_helper_diagnostic(path, call_node, name, helper_table)
48
140
  end
@@ -50,6 +142,52 @@ module Rigor
50
142
  diagnostics
51
143
  end
52
144
 
145
+ # Walks the AST once and returns the Set of names that
146
+ # shadow a route helper for this file. Includes:
147
+ #
148
+ # - `def name` declarations (any level — the
149
+ # per-method-scope visibility model Ruby uses means a
150
+ # local `def` shadows the helper at every call site
151
+ # reachable from where it is defined; we approximate
152
+ # "reachable" with "anywhere in the same file").
153
+ # - RSpec `let` / `let!` / `let_it_be` / `let_it_be!` /
154
+ # `subject` / `subject!` declarations.
155
+ # - Local assignments at any scope
156
+ # (`foo_url = "..."`).
157
+ def collect_shadowing_names(root)
158
+ names = Set.new
159
+ walk_for_shadowing(root, names)
160
+ names
161
+ end
162
+
163
+ def walk_for_shadowing(node, names)
164
+ return unless node.is_a?(Prism::Node)
165
+
166
+ case node
167
+ when Prism::DefNode
168
+ names << node.name.to_s if node.receiver.nil?
169
+ when Prism::LocalVariableWriteNode
170
+ names << node.name.to_s
171
+ when Prism::CallNode
172
+ record_let_like_name(node, names)
173
+ end
174
+
175
+ node.compact_child_nodes.each { |child| walk_for_shadowing(child, names) }
176
+ end
177
+
178
+ def record_let_like_name(call_node, names)
179
+ return unless call_node.receiver.nil?
180
+ return unless SHADOWING_DSL.include?(call_node.name)
181
+
182
+ arg = call_node.arguments&.arguments&.first
183
+ if arg.is_a?(Prism::SymbolNode)
184
+ names << arg.unescaped
185
+ elsif call_node.name == :subject && call_node.arguments.nil?
186
+ # Bare `subject { ... }` defines `:subject` itself.
187
+ names << "subject"
188
+ end
189
+ end
190
+
53
191
  def walk(node, &)
54
192
  return unless node.is_a?(Prism::Node)
55
193
 
@@ -66,6 +204,25 @@ module Rigor
66
204
  node.receiver.nil? && (node.name.to_s.end_with?("_path") || node.name.to_s.end_with?("_url"))
67
205
  end
68
206
 
207
+ # Paths where the implicit-params-fill pattern is
208
+ # idiomatic — controllers / mailers / views routinely
209
+ # call `*_path` / `*_url` helpers with fewer args than
210
+ # the route's static placeholder count because Rails
211
+ # fills `:foo_id` segments from `request.params` at
212
+ # runtime (controllers / views) or from the call's
213
+ # polymorphic-friendly receiver (mailers).
214
+ IMPLICIT_FILL_PATHS = [
215
+ %r{(?:\A|/)app/controllers/},
216
+ %r{(?:\A|/)app/mailers/},
217
+ %r{(?:\A|/)app/views/},
218
+ %r{(?:\A|/)app/components/},
219
+ %r{(?:\A|/)app/helpers/}
220
+ ].freeze
221
+
222
+ def implicit_fill_path?(path)
223
+ IMPLICIT_FILL_PATHS.any? { |re| path.match?(re) }
224
+ end
225
+
69
226
  def info_diagnostic(path, call_node, entry)
70
227
  location = call_node.location
71
228
  method_label = entry.http_method ? entry.http_method.to_s.upcase : "*"
@@ -80,7 +237,8 @@ module Rigor
80
237
  end
81
238
 
82
239
  def arity_check(path, call_node, entry, helper_table)
83
- actual = (call_node.arguments&.arguments || []).size
240
+ args = call_node.arguments&.arguments || []
241
+ actual = args.size
84
242
  # Uncountable nouns (`news` / `series` / `media`) cause
85
243
  # Rails to register two entries under the same helper
86
244
  # name — `news_path` accepts both arity 0 (index) and
@@ -88,7 +246,38 @@ module Rigor
88
246
  # accepts_arity? checks the full set.
89
247
  return nil if helper_table.accepts_arity?(entry.name, actual)
90
248
 
91
- arities = helper_table.acceptable_arities(entry.name).sort
249
+ # Rails accepts a kwargs-only call shape that supplies
250
+ # route segments by name:
251
+ # short_account_status_url(account_username: u, id: i)
252
+ # for the route `/@:account_username/:id` (arity 2).
253
+ # Our positional-arg count is 1 (the KeywordHashNode),
254
+ # so the strict check rejects — but Rails would
255
+ # resolve every segment from the hash. When the call
256
+ # has a trailing KeywordHashNode AND the positional
257
+ # count (excluding it) is `<= expected_arity`, accept
258
+ # — the kwargs may carry the missing segments.
259
+ if args.last.is_a?(Prism::KeywordHashNode)
260
+ positional = actual - 1
261
+ return nil if helper_table.acceptable_arities(entry.name).any? { |exp| positional <= exp }
262
+ end
263
+
264
+ # Underflow tolerance for controllers / mailers /
265
+ # views. A `redirect_to namespace_project_milestones_path`
266
+ # inside `Projects::MilestonesController` legitimately
267
+ # passes 0 args — Rails fills the missing `:namespace_id`
268
+ # / `:project_id` segments from `request.params` at
269
+ # runtime. Mailers reach helpers polymorphically too
270
+ # (`project_commit_url(commit)` resolves project from
271
+ # commit.project). Silence when `actual < min_arity`
272
+ # AND the call site is in a controller / mailer / view
273
+ # directory (where the implicit-params-fill pattern is
274
+ # idiomatic). Overflow (`actual > max_arity`) still
275
+ # fires — that's almost always a typo.
276
+ arities_set = helper_table.acceptable_arities(entry.name)
277
+ min_arity = arities_set.min
278
+ return nil if actual < min_arity && implicit_fill_path?(path)
279
+
280
+ arities = arities_set.sort
92
281
  expected = arities.length == 1 ? arities.first.to_s : "#{arities.first}..#{arities.last}"
93
282
  location = call_node.location
94
283
  Diagnostic.new(
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ class RailsRoutes < Rigor::Plugin::Base
6
+ # Generates the catalogue of route helpers a `devise_for
7
+ # :resources` call adds to a Rails app's routing table.
8
+ # The set is fixed and well-documented (the
9
+ # [Devise wiki](https://github.com/heartcombo/devise/wiki/How-To:-Add-routes-helpers)
10
+ # enumerates every generated helper); the generator
11
+ # mirrors it.
12
+ #
13
+ # Used by `RoutesParser` when it sees a top-level
14
+ # `devise_for :name` (or `devise_for :name, skip:
15
+ # [...]`) call. Each generated helper is materialised as
16
+ # a `HelperTable::Entry` so the analyzer's
17
+ # `unknown-helper` rule recognises them and the arity
18
+ # check accepts the canonical signatures (Devise helpers
19
+ # take zero positional args plus an optional trailing
20
+ # options hash, which `HelperTable#accepts_arity?`
21
+ # already handles via the `arity + 1` rule).
22
+ #
23
+ # Scope:
24
+ #
25
+ # - Recognises the standard six controllers
26
+ # (`:sessions`, `:passwords`, `:confirmations`,
27
+ # `:unlocks`, `:registrations`, `:omniauth_callbacks`).
28
+ # - Honours `skip: [...]` to suppress controllers the
29
+ # project disables.
30
+ # - Honours `path: "..."` to remap the URL path (does
31
+ # NOT remap the helper-name prefix — Devise uses the
32
+ # resource name for the helper, not the path).
33
+ # - Does NOT yet honour `as: "..."` overrides, `class_name:`,
34
+ # `controllers: { ... }` (these are rare; expand on
35
+ # demand).
36
+ # - `omniauth_callbacks` helpers are dynamic — the
37
+ # provider name is supplied at call time
38
+ # (`user_facebook_omniauth_authorize_path`) and Devise
39
+ # declares them from the configured providers, which
40
+ # live in an initializer this static parser does not
41
+ # read. We register the SHAPE-FAMILY suffix matchers
42
+ # `_omniauth_authorize_path` / `_omniauth_callback_path`
43
+ # via a separate hook (see `OMNIAUTH_HELPER_PATTERNS`);
44
+ # these are NOT in the table but consulted by the
45
+ # `Analyzer.allowed_dynamic_pattern?` check.
46
+ module DeviseRoutes
47
+ # The standard Devise controllers and the helper
48
+ # actions each generates. Keys are the controller
49
+ # name; values are arrays of `[helper_prefix,
50
+ # http_method, action]` triples — `helper_prefix` is
51
+ # the part BEFORE the resource-name segment (Rails
52
+ # produces `<prefix>_<resource>_<suffix>_path`; for
53
+ # the bare `<resource>_session_path` shape the prefix
54
+ # is empty).
55
+ CONTROLLER_HELPERS = {
56
+ sessions: [
57
+ ["new", "session", :get, :new],
58
+ [nil, "session", :post, :create],
59
+ ["destroy", "session", :delete, :destroy]
60
+ ],
61
+ passwords: [
62
+ ["new", "password", :get, :new],
63
+ ["edit", "password", :get, :edit],
64
+ [nil, "password", :get, :show]
65
+ ],
66
+ confirmations: [
67
+ ["new", "confirmation", :get, :new],
68
+ [nil, "confirmation", :get, :show]
69
+ ],
70
+ unlocks: [
71
+ ["new", "unlock", :get, :new],
72
+ [nil, "unlock", :get, :show]
73
+ ],
74
+ registrations: [
75
+ ["cancel", "registration", :get, :show],
76
+ ["new", "registration", :get, :new],
77
+ ["edit", "registration", :get, :edit],
78
+ [nil, "registration", :get, :show]
79
+ ]
80
+ }.freeze
81
+
82
+ # Dynamic-provider helper patterns. `_omniauth_authorize_path`
83
+ # and `_omniauth_callback_path` are generated per
84
+ # configured provider (Facebook, GitHub, …) by an
85
+ # initializer this parser does not read. We treat any
86
+ # `<resource>_<provider>_omniauth_(authorize|callback)_(path|url)`
87
+ # call as recognised when the resource is one the
88
+ # project declared via `devise_for`.
89
+ OMNIAUTH_SUFFIXES = %w[
90
+ _omniauth_authorize_path _omniauth_authorize_url
91
+ _omniauth_callback_path _omniauth_callback_url
92
+ ].freeze
93
+
94
+ module_function
95
+
96
+ # @param resource [String, Symbol] the `devise_for`
97
+ # argument — typically `:users`. Used as both the
98
+ # helper-name segment (`new_user_session_path`) and
99
+ # to derive the resource path.
100
+ # @param skip [Array<Symbol>] controllers the project
101
+ # disables via `devise_for :users, skip: [:registrations]`.
102
+ # @return [Array<HelperTable::Entry>] one entry per
103
+ # generated `_path` helper. The caller is expected to
104
+ # pair `_url` variants the same way `RoutesParser` does
105
+ # for other entries.
106
+ def generate(resource:, skip: [])
107
+ resource_segment = singularize(resource.to_s)
108
+ skip_set = skip.to_set(&:to_sym)
109
+ entries = []
110
+
111
+ CONTROLLER_HELPERS.each do |controller, actions|
112
+ next if skip_set.include?(controller)
113
+
114
+ actions.each do |(prefix, suffix, http_method, action)|
115
+ helper = if prefix
116
+ "#{prefix}_#{resource_segment}_#{suffix}_path"
117
+ else
118
+ "#{resource_segment}_#{suffix}_path"
119
+ end
120
+ path = devise_path_for(resource_segment, controller, action)
121
+ entries << HelperTable::Entry.new(
122
+ name: helper,
123
+ arity: 0,
124
+ path: path,
125
+ http_method: http_method,
126
+ action: action
127
+ )
128
+ end
129
+ end
130
+
131
+ # Devise also ships *scoped* helpers
132
+ # (`Devise::Controllers::UrlHelpers`) that DROP the
133
+ # resource segment and take the scope as a positional
134
+ # argument: `new_password_path(resource_name)` is the
135
+ # bare form of `new_user_password_path`. Used inside
136
+ # `Auth::PasswordsController < Devise::PasswordsController`
137
+ # (Mastodon's idiom). Arity 1 (the scope), and a
138
+ # trailing options-hash bumps it via the existing
139
+ # `arity + 1` rule.
140
+ entries.concat(scoped_helpers(skip_set))
141
+
142
+ # `omniauth_authorize_path(scope, provider)` and
143
+ # `omniauth_callback_path(scope, provider)` are
144
+ # `Devise::Controllers::UrlHelpers` scoped helpers
145
+ # that delegate to the OmniAuth gem. Arity 2.
146
+ unless skip_set.include?(:omniauth_callbacks)
147
+ %w[omniauth_authorize_path omniauth_callback_path].each do |name|
148
+ entries << HelperTable::Entry.new(
149
+ name: name,
150
+ arity: 2,
151
+ path: "/users/auth/:provider",
152
+ http_method: :get,
153
+ action: :show
154
+ )
155
+ end
156
+ end
157
+
158
+ entries
159
+ end
160
+
161
+ # Scoped helpers Devise exposes inside its controllers.
162
+ # The arity-1 family — caller supplies the resource
163
+ # scope (`:user` etc.) as the first positional arg.
164
+ # Names mirror the qualified `<scope>_<name>_path`
165
+ # entries above, minus the scope segment.
166
+ SCOPED_HELPERS = {
167
+ sessions: %w[new_session_path session_path destroy_session_path],
168
+ passwords: %w[new_password_path edit_password_path password_path],
169
+ confirmations: %w[new_confirmation_path confirmation_path],
170
+ unlocks: %w[new_unlock_path unlock_path],
171
+ registrations: %w[cancel_registration_path new_registration_path edit_registration_path registration_path]
172
+ }.freeze
173
+ private_constant :SCOPED_HELPERS
174
+
175
+ def scoped_helpers(skip_set)
176
+ SCOPED_HELPERS.flat_map do |controller, names|
177
+ next [] if skip_set.include?(controller)
178
+
179
+ names.map do |name|
180
+ HelperTable::Entry.new(
181
+ name: name,
182
+ arity: 1,
183
+ path: "/<scope>",
184
+ http_method: :get,
185
+ action: :show
186
+ )
187
+ end
188
+ end
189
+ end
190
+
191
+ # Returns the set of OmniAuth pattern suffixes the
192
+ # analyzer accepts for a given resource. The analyzer
193
+ # consults this set (via `HelperTable#allows_omniauth?`)
194
+ # when a `*_path` / `*_url` call's name does not match
195
+ # any registered entry and its prefix matches a Devise
196
+ # resource.
197
+ def omniauth_suffixes
198
+ OMNIAUTH_SUFFIXES
199
+ end
200
+
201
+ # The same tiny singularizer used by `RoutesParser`.
202
+ # We duplicate the catalogue here so this module does
203
+ # not need to reach into `Context`'s private state.
204
+ UNCOUNTABLE = %w[
205
+ equipment information rice money species series fish
206
+ sheep jeans police news media settings
207
+ ].to_set.freeze
208
+ private_constant :UNCOUNTABLE
209
+
210
+ def singularize(word)
211
+ return word if UNCOUNTABLE.include?(word)
212
+ return "#{word.chomp('ies')}y" if word.end_with?("ies") && word.length > 3
213
+ return word.chomp("es") if word.end_with?("ses") || word.end_with?("xes")
214
+ # `ss` preserved — Rails default inflector behaviour.
215
+ return word if word.end_with?("ss")
216
+ return word.chomp("s") if word.end_with?("s")
217
+
218
+ word
219
+ end
220
+
221
+ # Approximate path generation. We don't honour the
222
+ # `path:` Devise option here because the helper NAME
223
+ # is what the analyzer cares about; the `path` field is
224
+ # informational and surfaces only in the `:helper` info
225
+ # diagnostic.
226
+ def devise_path_for(resource_segment, controller, action)
227
+ plural = "#{resource_segment}s"
228
+ base = "/#{plural}"
229
+ case controller
230
+ when :sessions then session_path_for(action)
231
+ when :registrations then registration_path_for(action, base)
232
+ else "#{base}/#{controller}#{action_suffix(action)}"
233
+ end
234
+ end
235
+
236
+ def session_path_for(action)
237
+ case action
238
+ when :new then "/users/sign_in"
239
+ when :create then "/users/sign_in"
240
+ when :destroy then "/users/sign_out"
241
+ else "/users/sign_in"
242
+ end
243
+ end
244
+
245
+ def registration_path_for(action, base)
246
+ case action
247
+ when :new then "#{base}/sign_up"
248
+ when :edit then "#{base}/edit"
249
+ when :show then base
250
+ else base
251
+ end
252
+ end
253
+
254
+ def action_suffix(action)
255
+ case action
256
+ when :new then "/new"
257
+ when :edit then "/edit"
258
+ else ""
259
+ end
260
+ end
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ class RailsRoutes < Rigor::Plugin::Base
6
+ # Generates the catalogue of OAuth route helpers a
7
+ # `use_doorkeeper do ... end` block adds to a Rails app's
8
+ # routing table. The set is the Doorkeeper gem's
9
+ # documented default — see Doorkeeper's
10
+ # [Routing](https://github.com/doorkeeper-gem/doorkeeper/wiki/Customizing-routes)
11
+ # documentation; this module mirrors the canonical
12
+ # helper names.
13
+ #
14
+ # Used by `RoutesParser` when it sees a `use_doorkeeper`
15
+ # call. Each generated helper is materialised as a
16
+ # `HelperTable::Entry` so the analyzer's
17
+ # `unknown-helper` rule recognises them. Arity for the
18
+ # `oauth_application_path(:id)` / `oauth_authorized_application_path(:id)`
19
+ # forms is 1 (the `:id`); all other Doorkeeper helpers
20
+ # are arity 0.
21
+ #
22
+ # Scope:
23
+ #
24
+ # - Recognises the standard helper set: authorization,
25
+ # token, revoke, introspect, token_info, userinfo,
26
+ # applications (resources), authorized_applications.
27
+ # - Honours `skip_controllers <names>` inside the block
28
+ # to omit the named controller's helpers.
29
+ # - Does NOT yet honour `controllers ...:` remappings —
30
+ # those change WHICH class serves the route but not
31
+ # the helper NAMES, so omitting them is sound.
32
+ # - Does NOT yet honour custom `as:` / `at:` on
33
+ # `use_doorkeeper` itself.
34
+ module DoorkeeperRoutes
35
+ # Per-controller helper catalogue. Keys are the
36
+ # `skip_controllers :<key>` names; values are the
37
+ # entries (name + arity + path + http_method).
38
+ # Both `_path` and `_url` variants are paired by
39
+ # `RoutesParser` after registration.
40
+ CONTROLLER_HELPERS = {
41
+ authorizations: [
42
+ ["oauth_authorization_path", 0, "/oauth/authorize", :get, :show],
43
+ ["oauth_authorization_url", 0, "/oauth/authorize", :get, :show]
44
+ ],
45
+ tokens: [
46
+ ["oauth_token_path", 0, "/oauth/token", :post, :create],
47
+ ["oauth_token_url", 0, "/oauth/token", :post, :create],
48
+ ["oauth_revoke_path", 0, "/oauth/revoke", :post, :create],
49
+ ["oauth_revoke_url", 0, "/oauth/revoke", :post, :create],
50
+ ["oauth_introspect_path", 0, "/oauth/introspect", :post, :create],
51
+ ["oauth_introspect_url", 0, "/oauth/introspect", :post, :create]
52
+ ],
53
+ token_info: [
54
+ ["oauth_token_info_path", 0, "/oauth/token/info", :get, :show],
55
+ ["oauth_token_info_url", 0, "/oauth/token/info", :get, :show]
56
+ ],
57
+ userinfo: [
58
+ ["oauth_userinfo_path", 0, "/oauth/userinfo", :get, :show],
59
+ ["oauth_userinfo_url", 0, "/oauth/userinfo", :get, :show]
60
+ ],
61
+ applications: [
62
+ ["oauth_applications_path", 0, "/oauth/applications", :get, :index],
63
+ ["oauth_applications_url", 0, "/oauth/applications", :get, :index],
64
+ ["new_oauth_application_path", 0, "/oauth/applications/new", :get, :new],
65
+ ["new_oauth_application_url", 0, "/oauth/applications/new", :get, :new],
66
+ ["edit_oauth_application_path", 1, "/oauth/applications/:id/edit", :get, :edit],
67
+ ["edit_oauth_application_url", 1, "/oauth/applications/:id/edit", :get, :edit],
68
+ ["oauth_application_path", 1, "/oauth/applications/:id", :get, :show],
69
+ ["oauth_application_url", 1, "/oauth/applications/:id", :get, :show]
70
+ ],
71
+ authorized_applications: [
72
+ ["oauth_authorized_applications_path", 0, "/oauth/authorized_applications", :get, :index],
73
+ ["oauth_authorized_applications_url", 0, "/oauth/authorized_applications", :get, :index],
74
+ ["oauth_authorized_application_path", 1, "/oauth/authorized_applications/:id", :delete, :destroy],
75
+ ["oauth_authorized_application_url", 1, "/oauth/authorized_applications/:id", :delete, :destroy]
76
+ ]
77
+ }.freeze
78
+
79
+ module_function
80
+
81
+ # @param skip [Array<Symbol>] controller names the
82
+ # project omits via `skip_controllers :name, ...`.
83
+ # @return [Array<HelperTable::Entry>] flattened entries.
84
+ def generate(skip: [])
85
+ skip_set = skip.to_set(&:to_sym)
86
+ CONTROLLER_HELPERS.flat_map do |controller, rows|
87
+ next [] if skip_set.include?(controller)
88
+
89
+ rows.map do |name, arity, path, http_method, action|
90
+ HelperTable::Entry.new(
91
+ name: name, arity: arity, path: path,
92
+ http_method: http_method, action: action
93
+ )
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end