rigortype 0.1.9 → 0.1.11

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 (158) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/rigor/analysis/baseline.rb +51 -15
  4. data/lib/rigor/analysis/runner.rb +67 -9
  5. data/lib/rigor/analysis/worker_session.rb +13 -4
  6. data/lib/rigor/cache/rbs_descriptor.rb +21 -2
  7. data/lib/rigor/cache/rbs_environment.rb +2 -1
  8. data/lib/rigor/cli/annotate_command.rb +57 -7
  9. data/lib/rigor/cli/baseline_command.rb +4 -3
  10. data/lib/rigor/cli/coverage_command.rb +126 -0
  11. data/lib/rigor/cli/coverage_renderer.rb +162 -0
  12. data/lib/rigor/cli/coverage_report.rb +75 -0
  13. data/lib/rigor/cli/mcp_command.rb +70 -0
  14. data/lib/rigor/cli.rb +88 -5
  15. data/lib/rigor/environment/rbs_loader.rb +46 -5
  16. data/lib/rigor/environment/reporters.rb +3 -2
  17. data/lib/rigor/environment.rb +159 -4
  18. data/lib/rigor/inference/def_return_typer.rb +98 -0
  19. data/lib/rigor/inference/expression_typer.rb +143 -12
  20. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +5 -0
  21. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
  22. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +115 -7
  23. data/lib/rigor/inference/precision_scanner.rb +131 -0
  24. data/lib/rigor/inference/statement_evaluator.rb +26 -2
  25. data/lib/rigor/mcp/loop.rb +43 -0
  26. data/lib/rigor/mcp/server.rb +263 -0
  27. data/lib/rigor/mcp.rb +16 -0
  28. data/lib/rigor/plugin/base.rb +28 -5
  29. data/lib/rigor/plugin/manifest.rb +33 -5
  30. data/lib/rigor/plugin/registry.rb +21 -0
  31. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
  32. data/lib/rigor/sig_gen/generator.rb +150 -75
  33. data/lib/rigor/type/combinator.rb +57 -0
  34. data/lib/rigor/version.rb +1 -1
  35. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  36. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  37. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  38. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  39. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  40. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +178 -0
  41. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
  42. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
  43. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
  44. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  45. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
  46. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
  47. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
  48. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -0
  49. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  50. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  51. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  52. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  53. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  54. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  55. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +273 -0
  56. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  57. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  58. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  59. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +240 -0
  60. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
  61. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -0
  62. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  63. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  64. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  66. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  67. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  68. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  69. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +34 -0
  70. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  71. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +463 -0
  72. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  73. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  74. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  75. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  76. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  77. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  78. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  79. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  80. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  81. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  82. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  83. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  84. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  85. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  86. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  87. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  88. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  89. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  90. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  91. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  92. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  93. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  94. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  95. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  96. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  97. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  98. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  99. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  100. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  101. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  102. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  103. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  104. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  105. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  106. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  107. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +277 -0
  108. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  109. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  110. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +167 -0
  111. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  112. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +161 -0
  113. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
  114. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
  115. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -0
  116. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  117. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  118. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  119. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  120. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  121. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  122. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  123. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  124. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  125. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  126. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  127. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  128. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  129. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  130. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  131. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  132. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  133. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  134. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  135. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  136. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  137. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  138. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  139. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  140. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  141. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  142. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  143. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  144. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  145. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  146. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  147. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  148. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  149. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  150. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  151. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  152. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  153. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  154. data/sig/rigor/analysis/baseline.rbs +39 -0
  155. data/sig/rigor/environment.rbs +3 -2
  156. data/sig/rigor/type.rbs +4 -0
  157. data/sig/rigor.rbs +2 -0
  158. metadata +180 -1
@@ -0,0 +1,490 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "helper_table"
6
+
7
+ module Rigor
8
+ module Plugin
9
+ class RailsRoutes < Rigor::Plugin::Base
10
+ # Statically interprets `config/routes.rb`'s DSL via
11
+ # Prism — never executes the file. The interpreter is
12
+ # deliberately narrow; it covers the subset documented
13
+ # in the plugin's README and degrades silently on
14
+ # constructs it doesn't recognise.
15
+ #
16
+ # Recognised DSL surface (per the Rails-plugins
17
+ # roadmap):
18
+ #
19
+ # - `Rails.application.routes.draw do ... end` (entry
20
+ # block; the body is interpreted)
21
+ # - `resources :name [, only: [...] | except: [...]]`
22
+ # - `resource :name`
23
+ # - `get/post/patch/put/delete "path", to:, as:`
24
+ # - `root to: "..."` / `root "..."`
25
+ # - One level of `namespace :foo do ... end`
26
+ # - One level of nested `resources` (`resources :users
27
+ # do; resources :posts; end`)
28
+ # - `member do ... end` / `collection do ... end`
29
+ # inside `resources`
30
+ #
31
+ # Out of scope for v0.1.0 (silent skips):
32
+ #
33
+ # - `scope :path:` / `scope :module:` / `scope :as:`
34
+ # - Constraints (`constraints: { id: /\d+/ }`)
35
+ # - `mount` / engine routes
36
+ # - `direct(:name) { |obj| ... }`
37
+ # - Format restrictions
38
+ module RoutesParser
39
+ # Standard resource actions Rails generates by default.
40
+ DEFAULT_RESOURCE_ACTIONS = %i[index show new create edit update destroy].freeze
41
+ # Default actions for `resource` (singular) — no index,
42
+ # no `:id` segment.
43
+ DEFAULT_SINGULAR_ACTIONS = %i[show new create edit update destroy].freeze
44
+
45
+ # Helper-name conventions per action. `:show` and
46
+ # `:update` / `:destroy` share the singular-form
47
+ # helper (Rails dedupes).
48
+ ACTION_HTTP_METHODS = {
49
+ index: :get,
50
+ show: :get,
51
+ new: :get,
52
+ create: :post,
53
+ edit: :get,
54
+ update: :patch, # also :put
55
+ destroy: :delete
56
+ }.freeze
57
+
58
+ module_function
59
+
60
+ # @param contents [String] raw `config/routes.rb` source
61
+ # @return [HelperTable]
62
+ def parse(contents)
63
+ parse_result = Prism.parse(contents)
64
+ return HelperTable.new([]) unless parse_result.errors.empty?
65
+
66
+ context = Context.new
67
+ interpret(parse_result.value, context)
68
+
69
+ # Each helper has both `_path` and `_url` forms.
70
+ paired = context.entries.flat_map do |entry|
71
+ [
72
+ entry,
73
+ HelperTable::Entry.new(
74
+ name: entry.name.sub(/_path\z/, "_url"),
75
+ arity: entry.arity,
76
+ path: entry.path,
77
+ http_method: entry.http_method,
78
+ action: entry.action
79
+ )
80
+ ]
81
+ end
82
+ HelperTable.new(paired)
83
+ end
84
+
85
+ # Per-parse mutable accumulator. Tracks the current
86
+ # nesting prefix (namespaces + parent resource) and the
87
+ # entries collected so far.
88
+ class Context
89
+ attr_reader :entries
90
+
91
+ def initialize
92
+ @entries = []
93
+ # Stack of prefix segments. Each entry is one of:
94
+ # - `{ kind: :namespace, name: "admin" }`
95
+ # - `{ kind: :scope, parent: "user", arity_segments: [":user_id"] }`
96
+ @stack = []
97
+ end
98
+
99
+ def push_namespace(name)
100
+ @stack.push(kind: :namespace, name: name.to_s)
101
+ yield
102
+ ensure
103
+ @stack.pop
104
+ end
105
+
106
+ def push_resource(parent_name)
107
+ singular = singularize(parent_name.to_s)
108
+ @stack.push(kind: :scope, parent: singular, arity_segments: [":#{singular}_id"])
109
+ yield
110
+ ensure
111
+ @stack.pop
112
+ end
113
+
114
+ # Helper-name prefix from namespaces (`admin_`,
115
+ # `admin_users_`, …).
116
+ def helper_prefix
117
+ segments = @stack.filter_map { |frame| frame_helper_segment(frame) }
118
+ segments.map { |segment| "#{segment}_" }.join
119
+ end
120
+
121
+ # Path prefix — including the parent's `:user_id`
122
+ # segments for nested resources and the namespace
123
+ # path prefix.
124
+ def path_prefix
125
+ parts = @stack.flat_map { |frame| frame_path_segments(frame) }
126
+ parts.join
127
+ end
128
+
129
+ # Number of dynamic segments (`:user_id`-style)
130
+ # captured by the parent scope chain. Used to
131
+ # compute helper arity for nested resources.
132
+ def parent_segment_count
133
+ @stack.count { |frame| frame[:kind] == :scope }
134
+ end
135
+
136
+ private
137
+
138
+ def frame_helper_segment(frame)
139
+ case frame[:kind]
140
+ when :namespace then frame[:name]
141
+ when :scope then frame[:parent]
142
+ end
143
+ end
144
+
145
+ def frame_path_segments(frame)
146
+ case frame[:kind]
147
+ when :namespace then ["/#{frame[:name]}"]
148
+ when :scope then ["/#{pluralize(frame[:parent])}/:#{frame[:parent]}_id"]
149
+ else []
150
+ end
151
+ end
152
+
153
+ # Tiny English inflector. Sufficient for the standard
154
+ # `posts` ↔ `post`, `users` ↔ `user` rename Rails
155
+ # generates by default; users with custom
156
+ # inflections need to author RBS by hand for the
157
+ # affected helpers (out of scope for v0.1.0).
158
+ #
159
+ # The canonical English uncountable noun set from
160
+ # ActiveSupport::Inflector::Inflections (Rails 8.x).
161
+ # `singularize("news")` returns `"news"` rather than
162
+ # `"new"`. Pre-fix the parser stripped the trailing
163
+ # 's' from `news`, so `resources :news` registered
164
+ # `new_path` / `news_path` / `new_news_path` (broken
165
+ # — Rails actually generates `news_path` for both
166
+ # index and show, with the show form taking `:id`).
167
+ # Redmine hit this 81× across `news_path(id)` calls.
168
+ UNCOUNTABLE = %w[
169
+ equipment information rice money species series fish
170
+ sheep jeans police news media settings
171
+ ].to_set.freeze
172
+ private_constant :UNCOUNTABLE
173
+
174
+ def singularize(word)
175
+ return word if UNCOUNTABLE.include?(word)
176
+ return "#{word.chomp('ies')}y" if word.end_with?("ies") && word.length > 3
177
+ return word.chomp("es") if word.end_with?("ses") || word.end_with?("xes")
178
+ return word.chomp("s") if word.end_with?("s")
179
+
180
+ word
181
+ end
182
+
183
+ def pluralize(word)
184
+ return word if UNCOUNTABLE.include?(word)
185
+ return word if word.end_with?("s")
186
+ return "#{word.chomp('y')}ies" if word.end_with?("y") && word.length > 1
187
+
188
+ "#{word}s"
189
+ end
190
+ end
191
+
192
+ def interpret(node, context)
193
+ return unless node.is_a?(Prism::Node)
194
+
195
+ case node
196
+ when Prism::CallNode
197
+ interpret_call(node, context)
198
+ else
199
+ node.compact_child_nodes.each { |child| interpret(child, context) }
200
+ end
201
+ end
202
+
203
+ def interpret_call(node, context)
204
+ case node.name
205
+ when :draw
206
+ # `Rails.application.routes.draw do ... end` —
207
+ # interpret the block body.
208
+ interpret_block_body(node, context)
209
+ when :namespace
210
+ handle_namespace(node, context)
211
+ when :resources
212
+ handle_resources(node, context)
213
+ when :resource
214
+ handle_resource(node, context)
215
+ when :root
216
+ handle_root(node, context)
217
+ when :get, :post, :patch, :put, :delete
218
+ handle_explicit_route(node, context)
219
+ when :member, :collection
220
+ # Inside a `resources` block, `member do ... end`
221
+ # / `collection do ... end` introduces extra
222
+ # routes. Interpreted only when we have a parent
223
+ # scope (otherwise the call is meaningless).
224
+ handle_member_or_collection(node, context)
225
+ else
226
+ interpret_block_body(node, context)
227
+ end
228
+ end
229
+
230
+ def interpret_block_body(node, context)
231
+ body = node.block&.body
232
+ return if body.nil?
233
+
234
+ body.compact_child_nodes.each { |child| interpret(child, context) }
235
+ end
236
+
237
+ def handle_namespace(node, context)
238
+ name = symbol_argument(node, 0)
239
+ return interpret_block_body(node, context) if name.nil?
240
+
241
+ context.push_namespace(name) { interpret_block_body(node, context) }
242
+ end
243
+
244
+ def handle_resources(node, context)
245
+ name = symbol_argument(node, 0)
246
+ return interpret_block_body(node, context) if name.nil?
247
+
248
+ actions = restrict_actions(node, DEFAULT_RESOURCE_ACTIONS)
249
+ base_arity = context.parent_segment_count
250
+
251
+ register_resourceful_helpers(name, actions, base_arity, context, plural: true)
252
+
253
+ context.push_resource(name) do
254
+ interpret_block_body(node, context)
255
+ end
256
+ end
257
+
258
+ def handle_resource(node, context)
259
+ name = symbol_argument(node, 0)
260
+ return interpret_block_body(node, context) if name.nil?
261
+
262
+ actions = restrict_actions(node, DEFAULT_SINGULAR_ACTIONS)
263
+ base_arity = context.parent_segment_count
264
+
265
+ # Singular resource — no `:id` segment, no `:index`
266
+ # / pluralised helper. The "show" helper is
267
+ # `<name>_path` (singular).
268
+ register_resourceful_helpers(name, actions, base_arity, context, plural: false)
269
+
270
+ # Nested `resources :things` inside `resource :profile`
271
+ # is rare; we still descend so the inner declarations
272
+ # collect their own helpers.
273
+ interpret_block_body(node, context)
274
+ end
275
+
276
+ def handle_root(node, context)
277
+ # `root to: "..."` / `root "..."` — single helper
278
+ # `root_path`, arity 0, GET. Real-world Rails apps also
279
+ # use `root :to => 'welcome#index', :as => 'home'` (the
280
+ # canonical Redmine idiom across 230+ call sites), which
281
+ # registers an additional `home_path` / `home_url` alias
282
+ # for the same path. Mastodon and Solidus also use the
283
+ # `as:` form occasionally for analytics-friendly URL
284
+ # naming.
285
+ path = context.path_prefix.empty? ? "/" : context.path_prefix
286
+ context.entries << HelperTable::Entry.new(
287
+ name: "#{context.helper_prefix}root_path",
288
+ arity: 0, path: path, http_method: :get, action: :root
289
+ )
290
+
291
+ alias_name = keyword_symbol(node, :as)
292
+ return if alias_name.nil?
293
+
294
+ context.entries << HelperTable::Entry.new(
295
+ name: "#{context.helper_prefix}#{alias_name}_path",
296
+ arity: 0, path: path, http_method: :get, action: :root
297
+ )
298
+ end
299
+
300
+ def handle_explicit_route(node, context)
301
+ # `get "/about", to: "static#about", as: :about`
302
+ path = string_argument(node, 0)
303
+ as_name = keyword_symbol(node, :as)
304
+ return if as_name.nil? && path.nil?
305
+
306
+ # When `as:` is omitted, Rails generates a helper
307
+ # name from the path. For our static analysis
308
+ # we only register helpers when we can name them
309
+ # confidently — i.e. when `as:` is present.
310
+ return if as_name.nil?
311
+
312
+ name = "#{context.helper_prefix}#{as_name}_path"
313
+ arity = context.parent_segment_count + count_path_placeholders(path)
314
+ context.entries << HelperTable::Entry.new(
315
+ name: name, arity: arity,
316
+ path: "#{context.path_prefix}#{path || ''}",
317
+ http_method: node.name, action: :custom
318
+ )
319
+ end
320
+
321
+ def handle_member_or_collection(node, context)
322
+ # Only meaningful when we're inside a `resources` /
323
+ # `resource` block. The Context's stack tells us.
324
+ return unless context.parent_segment_count.positive? || in_singular_resource?(context)
325
+
326
+ # The Context doesn't currently distinguish
327
+ # "inside resources" from "inside resource" — for
328
+ # v0.1.0 we treat both the same way and let the
329
+ # explicit `as:` in member/collection do the
330
+ # naming work.
331
+ interpret_block_body(node, context)
332
+ end
333
+
334
+ def in_singular_resource?(*)
335
+ # Slice 1 doesn't model the singular-resource frame
336
+ # separately; placeholder so member / collection
337
+ # blocks at least descend.
338
+ true
339
+ end
340
+
341
+ # Generate the standard helpers for a resource(s).
342
+ # `plural: true` for `resources :users`, `false` for
343
+ # `resource :profile`.
344
+ def register_resourceful_helpers(name, actions, base_arity, context, plural:)
345
+ singular = singularize_word(name.to_s)
346
+ plural_form = plural ? name.to_s : singular # `resource :foo` uses singular path
347
+ path_base = "#{context.path_prefix}/#{plural_form}"
348
+
349
+ actions.each do |action|
350
+ entry = entry_for_action(
351
+ action,
352
+ name: name, singular: singular, base_arity: base_arity,
353
+ path_base: path_base, helper_prefix: context.helper_prefix, plural: plural
354
+ )
355
+ context.entries << entry if entry
356
+ end
357
+ end
358
+
359
+ # `:create` / `:update` / `:destroy` don't generate
360
+ # `*_path` helpers separate from the show / index
361
+ # helper Rails reuses for their forms; the case
362
+ # statement returns nil for those and the caller
363
+ # skips them.
364
+ def entry_for_action(action, name:, singular:, base_arity:, path_base:, helper_prefix:, plural:)
365
+ case action
366
+ when :index then index_entry(plural, helper_prefix, name, base_arity, path_base)
367
+ when :show then show_entry(plural, helper_prefix, singular, base_arity, path_base)
368
+ when :new
369
+ HelperTable::Entry.new(
370
+ name: "#{helper_prefix}new_#{singular}_path",
371
+ arity: base_arity, path: "#{path_base}/new",
372
+ http_method: :get, action: :new
373
+ )
374
+ when :edit then edit_entry(plural, helper_prefix, singular, base_arity, path_base)
375
+ end
376
+ end
377
+
378
+ def index_entry(plural, helper_prefix, name, base_arity, path_base)
379
+ return nil unless plural
380
+
381
+ HelperTable::Entry.new(
382
+ name: "#{helper_prefix}#{name}_path",
383
+ arity: base_arity, path: path_base,
384
+ http_method: :get, action: :index
385
+ )
386
+ end
387
+
388
+ def show_entry(plural, helper_prefix, singular, base_arity, path_base)
389
+ show_path = plural ? "#{path_base}/:id" : path_base
390
+ show_arity = plural ? base_arity + 1 : base_arity
391
+ HelperTable::Entry.new(
392
+ name: "#{helper_prefix}#{singular}_path",
393
+ arity: show_arity, path: show_path,
394
+ http_method: :get, action: :show
395
+ )
396
+ end
397
+
398
+ def edit_entry(plural, helper_prefix, singular, base_arity, path_base)
399
+ edit_path = plural ? "#{path_base}/:id/edit" : "#{path_base}/edit"
400
+ edit_arity = plural ? base_arity + 1 : base_arity
401
+ HelperTable::Entry.new(
402
+ name: "#{helper_prefix}edit_#{singular}_path",
403
+ arity: edit_arity, path: edit_path,
404
+ http_method: :get, action: :edit
405
+ )
406
+ end
407
+
408
+ def restrict_actions(node, default)
409
+ options = options_hash(node)
410
+ # `resources :foo, only: :show` is the same as
411
+ # `only: [:show]` in Rails; `options_hash` preserves the
412
+ # Symbol shape from the source, so coerce here.
413
+ if (only = options[:only])
414
+ Array(only) & default
415
+ elsif (except = options[:except])
416
+ default - Array(except)
417
+ else
418
+ default
419
+ end
420
+ end
421
+
422
+ def options_hash(node)
423
+ args = node.arguments&.arguments || []
424
+ last = args.last
425
+ return {} unless last.is_a?(Prism::KeywordHashNode)
426
+
427
+ last.elements.each_with_object({}) do |element, into|
428
+ next unless element.is_a?(Prism::AssocNode)
429
+ next unless element.key.is_a?(Prism::SymbolNode)
430
+
431
+ value = symbol_array(element.value) || symbol_value(element.value) || string_value(element.value)
432
+ into[element.key.unescaped.to_sym] = value
433
+ end
434
+ end
435
+
436
+ def symbol_argument(node, index)
437
+ arg = (node.arguments&.arguments || [])[index]
438
+ symbol_value(arg)
439
+ end
440
+
441
+ def string_argument(node, index)
442
+ arg = (node.arguments&.arguments || [])[index]
443
+ string_value(arg)
444
+ end
445
+
446
+ def keyword_symbol(node, key)
447
+ options_hash(node)[key]
448
+ end
449
+
450
+ def symbol_value(node)
451
+ node.is_a?(Prism::SymbolNode) ? node.unescaped.to_sym : nil
452
+ end
453
+
454
+ def string_value(node)
455
+ node.is_a?(Prism::StringNode) ? node.unescaped : nil
456
+ end
457
+
458
+ def symbol_array(node)
459
+ return nil unless node.is_a?(Prism::ArrayNode)
460
+
461
+ values = node.elements.map { |e| symbol_value(e) }
462
+ values.all? ? values : nil
463
+ end
464
+
465
+ def count_path_placeholders(path)
466
+ return 0 if path.nil?
467
+
468
+ path.scan(/:[a-z_][a-z0-9_]*/).size
469
+ end
470
+
471
+ # Shared with `Context::Inflector#singularize` — kept in
472
+ # sync until one of the two call sites can adopt the
473
+ # other.
474
+ UNCOUNTABLE = %w[
475
+ equipment information rice money species series fish
476
+ sheep jeans police news media settings
477
+ ].to_set.freeze
478
+
479
+ def singularize_word(word)
480
+ return word if UNCOUNTABLE.include?(word)
481
+ return "#{word.chomp('ies')}y" if word.end_with?("ies") && word.length > 3
482
+ return word.chomp("es") if word.end_with?("ses") || word.end_with?("xes")
483
+ return word.chomp("s") if word.end_with?("s")
484
+
485
+ word
486
+ end
487
+ end
488
+ end
489
+ end
490
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/plugin"
4
+
5
+ require_relative "rails_routes/helper_table"
6
+ require_relative "rails_routes/routes_parser"
7
+ require_relative "rails_routes/analyzer"
8
+
9
+ module Rigor
10
+ module Plugin
11
+ # rigor-rails-routes — validates Rails route-helper calls
12
+ # (`users_path`, `edit_user_path(@user)`, …) against the
13
+ # project's `config/routes.rb`.
14
+ #
15
+ # Tier 1A of the [Rails plugins roadmap](../../../../docs/design/20260508-rails-plugins-roadmap.md).
16
+ # Statically interprets the routes DSL via Prism — no
17
+ # `rails` runtime dependency. Recognised v0.1.0 surface:
18
+ #
19
+ # - `Rails.application.routes.draw do ... end`
20
+ # - `resources :name [, only: [...] | except: [...]]`
21
+ # - `resource :name`
22
+ # - `get/post/patch/put/delete "/path", to:, as:`
23
+ # - `root to: "..."` / `root "..."`
24
+ # - One level of `namespace :foo do ... end`
25
+ # - One level of nested `resources`
26
+ #
27
+ # The plugin publishes its parsed `:helper_table` through
28
+ # the ADR-9 cross-plugin fact store so future
29
+ # `rigor-actionpack` Phase 4 can consume it for
30
+ # route-helper validation in controller code.
31
+ #
32
+ # ## Configuration
33
+ #
34
+ # plugins:
35
+ # - gem: rigor-rails-routes
36
+ # config:
37
+ # routes_file: config/routes.rb # default; optional
38
+ #
39
+ # ## Limitations (v0.1.0)
40
+ #
41
+ # - `scope :path:` / `scope :module:` / `scope :as:` are
42
+ # not interpreted — helpers nested inside these
43
+ # constructs are silently skipped.
44
+ # - Constraints / format restrictions / mountable
45
+ # engines are out of scope.
46
+ # - The English inflector is intentionally tiny: it
47
+ # handles `posts` ↔ `post`, `users` ↔ `user`,
48
+ # `categories` ↔ `category`, `boxes` ↔ `box`. Custom
49
+ # inflections (`fish` ↔ `fish`, `child` ↔ `children`)
50
+ # are out of scope; users who need them ship a hand-
51
+ # written RBS for the affected helper.
52
+ class RailsRoutes < Rigor::Plugin::Base
53
+ manifest(
54
+ id: "rails-routes",
55
+ version: "0.1.0",
56
+ description: "Validates Rails route-helper calls against `config/routes.rb`.",
57
+ config_schema: {
58
+ "routes_file" => :string
59
+ },
60
+ produces: [:helper_table]
61
+ )
62
+
63
+ DEFAULT_ROUTES_FILE = "config/routes.rb"
64
+
65
+ # Cached producer — reads `config/routes.rb` through
66
+ # the trusted `IoBoundary` and parses through
67
+ # {RoutesParser}. The descriptor's auto-collected
68
+ # `FileEntry` digest invalidates the cache on routes-
69
+ # file edits.
70
+ producer :helper_table do |_params|
71
+ contents = io_boundary.read_file(@routes_file)
72
+ RoutesParser.parse(contents)
73
+ end
74
+
75
+ def init(_services)
76
+ @routes_file = config.fetch("routes_file", DEFAULT_ROUTES_FILE)
77
+ @helper_table = nil
78
+ @load_error = nil
79
+ end
80
+
81
+ # Publishes the parsed table to the cross-plugin fact
82
+ # store so future Tier 2 plugins (rigor-actionpack
83
+ # Phase 4) can read it via `services.fact_store.read`.
84
+ def prepare(services)
85
+ table = helper_table_or_nil
86
+ return if table.nil?
87
+
88
+ services.fact_store.publish(
89
+ plugin_id: manifest.id,
90
+ name: :helper_table,
91
+ value: table.to_h
92
+ )
93
+ end
94
+
95
+ def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
96
+ table = helper_table_or_nil
97
+ if table.nil? && @load_error
98
+ return [] if @load_error_emitted
99
+
100
+ @load_error_emitted = true
101
+ return [load_error_diagnostic(path)]
102
+ end
103
+ return [] if table.nil? || table.empty?
104
+
105
+ Analyzer.diagnose(path: path, root: root, helper_table: table)
106
+ .map { |diag| build_diagnostic(diag) }
107
+ end
108
+
109
+ private
110
+
111
+ # The load-error path used to emit the same warning on
112
+ # every analyzed file in the project. On large monorepos
113
+ # (Mastodon: 1,302 files; Solidus: ~1,000 files) and on
114
+ # legacy projects without a top-level `config/routes.rb`,
115
+ # this multiplied a single root cause into 1,000+
116
+ # identical diagnostics. The error is project-global —
117
+ # report it once per run.
118
+
119
+ def helper_table_or_nil
120
+ return @helper_table if @helper_table
121
+
122
+ # Read first so the IoBoundary's FileEntry digest
123
+ # captures into the descriptor before `cache_for`
124
+ # snapshots it (the same pattern documented in
125
+ # rigor-routes / rigor-activerecord).
126
+ io_boundary.read_file(@routes_file)
127
+ @helper_table = cache_for(:helper_table, params: {}).call
128
+ rescue Plugin::AccessDeniedError => e
129
+ @load_error = "rigor-rails-routes: #{e.message}"
130
+ nil
131
+ rescue Errno::ENOENT
132
+ @load_error = "rigor-rails-routes: routes file `#{@routes_file}` not found; route checks skipped"
133
+ nil
134
+ rescue StandardError => e
135
+ @load_error = "rigor-rails-routes: failed to parse `#{@routes_file}`: #{e.class}: #{e.message}"
136
+ nil
137
+ end
138
+
139
+ def load_error_diagnostic(path)
140
+ Rigor::Analysis::Diagnostic.new(
141
+ path: path, line: 1, column: 1,
142
+ message: @load_error,
143
+ severity: :warning,
144
+ rule: "load-error"
145
+ )
146
+ end
147
+
148
+ def build_diagnostic(diag)
149
+ Rigor::Analysis::Diagnostic.new(
150
+ path: diag.path, line: diag.line, column: diag.column,
151
+ message: diag.message, severity: diag.severity, rule: diag.rule
152
+ )
153
+ end
154
+ end
155
+
156
+ Rigor::Plugin.register(RailsRoutes)
157
+ end
158
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rigor/plugin/rails_routes"