rigortype 0.1.15 → 0.1.16

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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/exe/rigor +19 -0
  4. data/lib/rigor/analysis/check_rules.rb +25 -1
  5. data/lib/rigor/analysis/diagnostic.rb +40 -0
  6. data/lib/rigor/analysis/runner.rb +61 -2
  7. data/lib/rigor/analysis/worker_session.rb +3 -2
  8. data/lib/rigor/cache/descriptor.rb +6 -2
  9. data/lib/rigor/cli/plugins_command.rb +51 -4
  10. data/lib/rigor/cli/plugins_renderer.rb +86 -1
  11. data/lib/rigor/cli.rb +135 -5
  12. data/lib/rigor/environment/rbs_loader.rb +259 -1
  13. data/lib/rigor/environment.rb +8 -2
  14. data/lib/rigor/inference/budget_trace.rb +137 -0
  15. data/lib/rigor/inference/expression_typer.rb +9 -2
  16. data/lib/rigor/inference/hkt_reducer.rb +2 -0
  17. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -6
  18. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +81 -14
  19. data/lib/rigor/inference/method_dispatcher.rb +57 -10
  20. data/lib/rigor/inference/precision_scanner.rb +60 -1
  21. data/lib/rigor/inference/scope_indexer.rb +127 -8
  22. data/lib/rigor/inference/statement_evaluator.rb +13 -8
  23. data/lib/rigor/inference/synthetic_method_index.rb +23 -4
  24. data/lib/rigor/inference/synthetic_method_scanner.rb +148 -14
  25. data/lib/rigor/plugin/additional_initializer.rb +108 -0
  26. data/lib/rigor/plugin/base.rb +321 -2
  27. data/lib/rigor/plugin/box.rb +64 -0
  28. data/lib/rigor/plugin/inflector.rb +121 -0
  29. data/lib/rigor/plugin/isolation.rb +191 -0
  30. data/lib/rigor/plugin/macro/nested_class_template.rb +140 -0
  31. data/lib/rigor/plugin/macro.rb +1 -0
  32. data/lib/rigor/plugin/manifest.rb +120 -23
  33. data/lib/rigor/plugin/node_context.rb +62 -0
  34. data/lib/rigor/plugin/registry.rb +10 -0
  35. data/lib/rigor/plugin.rb +3 -0
  36. data/lib/rigor/sig_gen/generator.rb +2 -3
  37. data/lib/rigor/sig_gen/observation_collector.rb +2 -2
  38. data/lib/rigor/source/literals.rb +118 -0
  39. data/lib/rigor/source/node_walker.rb +26 -0
  40. data/lib/rigor/source.rb +1 -0
  41. data/lib/rigor/type/combinator.rb +6 -1
  42. data/lib/rigor/type/union.rb +65 -1
  43. data/lib/rigor/version.rb +1 -1
  44. data/lib/rigor.rb +1 -0
  45. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +31 -53
  46. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +21 -23
  47. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +38 -59
  48. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +7 -13
  49. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +22 -33
  50. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +298 -413
  51. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +69 -71
  52. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +24 -34
  53. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +18 -16
  54. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +4 -46
  55. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  56. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +1 -1
  57. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +17 -12
  58. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -8
  59. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +2 -7
  60. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +2 -6
  61. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +4 -3
  62. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  63. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +40 -45
  64. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +7 -17
  65. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +20 -42
  66. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +7 -4
  67. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +4 -8
  68. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +188 -0
  69. data/plugins/rigor-mangrove/lib/rigor-mangrove.rb +3 -0
  70. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +4 -0
  71. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +24 -8
  72. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +31 -48
  73. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +21 -23
  74. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +54 -82
  75. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +25 -25
  76. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +63 -147
  77. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -17
  78. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +23 -114
  79. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +36 -31
  80. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  81. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +6 -3
  82. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +4 -2
  83. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +13 -12
  84. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +28 -40
  85. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +44 -47
  86. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +11 -10
  87. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +45 -87
  88. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +11 -12
  89. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +29 -42
  90. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +20 -19
  91. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +73 -0
  92. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +43 -1
  93. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +21 -29
  94. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +36 -96
  95. data/sig/rigor/plugin/access_denied_error.rbs +3 -1
  96. data/sig/rigor/plugin/base.rbs +58 -3
  97. data/sig/rigor/plugin/io_boundary.rbs +3 -0
  98. data/sig/rigor/plugin/manifest.rbs +31 -1
  99. data/sig/rigor/source.rbs +12 -0
  100. data/sig/rigor.rbs +5 -0
  101. data/skills/rigor-plugin-author/SKILL.md +13 -9
  102. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +6 -5
  103. data/skills/rigor-plugin-author/references/02-walker-and-types.md +159 -75
  104. data/skills/rigor-plugin-author/references/03-test-and-ship.md +3 -3
  105. metadata +52 -2
  106. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +0 -114
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "prism"
4
+ require "rigor/source/literals"
4
5
 
5
6
  module Rigor
6
7
  module Plugin
@@ -11,8 +12,6 @@ module Rigor
11
12
  # recognised helpers and error diagnostics for typos /
12
13
  # arity mismatches.
13
14
  module Analyzer
14
- DID_YOU_MEAN_DISTANCE = 3
15
-
16
15
  # Built-in Rails helpers we don't want to flag as
17
16
  # unknown. The plugin's HelperTable describes
18
17
  # user-declared routes; Rails (and a small set of
@@ -33,7 +32,10 @@ module Rigor
33
32
  audio_path audio_url
34
33
  ].freeze
35
34
 
36
- Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
35
+ # One route-helper observation. Carries no path/location the
36
+ # caller (the `node_rule` block) positions it via
37
+ # `Plugin::Base#diagnostic`.
38
+ Violation = Struct.new(:rule, :severity, :message, keyword_init: true)
37
39
 
38
40
  module_function
39
41
 
@@ -76,70 +78,45 @@ module Rigor
76
78
  # @param root [Prism::Node]
77
79
  # @param helper_table [HelperTable]
78
80
  # @return [Array<Diagnostic>]
79
- def diagnose(path:, root:, helper_table:)
81
+ # The route-helper violations for a single call node (0..2),
82
+ # or `[]` when the node is not an implicit `*_path` / `*_url`
83
+ # helper call, is shadowed by a same-file binding, or is in a
84
+ # suppressed directory. ADR-37: the engine owns the walk; the
85
+ # same-file `shadowing` set is built once as the node-rule file
86
+ # context.
87
+ #
88
+ # @param call_node [Prism::Node]
89
+ # @param helper_table [HelperTable]
90
+ # @param shadowing [Set<String>]
91
+ # @param path [String]
92
+ # @return [Array<Violation>]
93
+ def violations_for(call_node:, helper_table:, shadowing:, path:)
94
+ return [] unless call_node.is_a?(Prism::CallNode) && implicit_helper_call?(call_node)
95
+
96
+ name = call_node.name.to_s
97
+ return [] if BUILTIN_PASSTHROUGH.include?(name)
98
+ return [] if shadowing.include?(name)
99
+
100
+ # The SKIP set silences both `unknown-helper` and `wrong-arity`:
101
+ # a `group_path` inside `app/services/...` is more likely the
102
+ # file's own method (an `attr_accessor`) than a route helper.
80
103
  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.
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
-
99
- walk(root) do |call_node|
100
- name = call_node.name.to_s
101
- next unless name.end_with?("_path") || name.end_with?("_url")
102
- next if BUILTIN_PASSTHROUGH.include?(name)
103
- next if shadowing.include?(name)
104
104
 
105
- entry = helper_table.find(name)
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
-
118
- diagnostics << info_diagnostic(path, call_node, entry)
119
- arity_diagnostic = arity_check(path, call_node, entry, helper_table)
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
138
- else
139
- diagnostics << unknown_helper_diagnostic(path, call_node, name, helper_table)
140
- end
105
+ entry = helper_table.find(name)
106
+ if entry
107
+ return [] if suppress_unknown
108
+
109
+ violations = [info_violation(entry)]
110
+ arity = arity_violation(call_node, entry, helper_table, path)
111
+ violations << arity if arity
112
+ violations
113
+ elsif helper_table.recognised?(name) || suppress_unknown
114
+ # Recognised custom / dynamic helper (no arity to check), or
115
+ # a directory where helpers aren't customarily resolved.
116
+ []
117
+ else
118
+ [unknown_helper_violation(name, helper_table)]
141
119
  end
142
- diagnostics
143
120
  end
144
121
 
145
122
  # Walks the AST once and returns the Set of names that
@@ -156,23 +133,17 @@ module Rigor
156
133
  # (`foo_url = "..."`).
157
134
  def collect_shadowing_names(root)
158
135
  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)
136
+ Source::NodeWalker.each(root) do |node|
137
+ case node
138
+ when Prism::DefNode
139
+ names << node.name.to_s if node.receiver.nil?
140
+ when Prism::LocalVariableWriteNode
141
+ names << node.name.to_s
142
+ when Prism::CallNode
143
+ record_let_like_name(node, names)
144
+ end
173
145
  end
174
-
175
- node.compact_child_nodes.each { |child| walk_for_shadowing(child, names) }
146
+ names
176
147
  end
177
148
 
178
149
  def record_let_like_name(call_node, names)
@@ -180,21 +151,15 @@ module Rigor
180
151
  return unless SHADOWING_DSL.include?(call_node.name)
181
152
 
182
153
  arg = call_node.arguments&.arguments&.first
183
- if arg.is_a?(Prism::SymbolNode)
184
- names << arg.unescaped
154
+ name = Rigor::Source::Literals.symbol_name(arg)
155
+ if name
156
+ names << name
185
157
  elsif call_node.name == :subject && call_node.arguments.nil?
186
158
  # Bare `subject { ... }` defines `:subject` itself.
187
159
  names << "subject"
188
160
  end
189
161
  end
190
162
 
191
- def walk(node, &)
192
- return unless node.is_a?(Prism::Node)
193
-
194
- yield node if node.is_a?(Prism::CallNode) && implicit_helper_call?(node)
195
- node.compact_child_nodes.each { |child| walk(child, &) }
196
- end
197
-
198
163
  # `*_path` / `*_url` calls without an explicit
199
164
  # receiver. Calls like `obj.users_path` or
200
165
  # `Foo::users_path` are NOT route-helper invocations
@@ -223,20 +188,16 @@ module Rigor
223
188
  IMPLICIT_FILL_PATHS.any? { |re| path.match?(re) }
224
189
  end
225
190
 
226
- def info_diagnostic(path, call_node, entry)
227
- location = call_node.location
191
+ def info_violation(entry)
228
192
  method_label = entry.http_method ? entry.http_method.to_s.upcase : "*"
229
- Diagnostic.new(
230
- path: path,
231
- line: location.start_line,
232
- column: location.start_column + 1,
193
+ Violation.new(
233
194
  severity: :info,
234
195
  rule: "helper",
235
196
  message: "`#{entry.name}` → #{method_label} #{entry.path}"
236
197
  )
237
198
  end
238
199
 
239
- def arity_check(path, call_node, entry, helper_table)
200
+ def arity_violation(call_node, entry, helper_table, path)
240
201
  args = call_node.arguments&.arguments || []
241
202
  actual = args.size
242
203
  # Uncountable nouns (`news` / `series` / `media`) cause
@@ -279,71 +240,26 @@ module Rigor
279
240
 
280
241
  arities = arities_set.sort
281
242
  expected = arities.length == 1 ? arities.first.to_s : "#{arities.first}..#{arities.last}"
282
- location = call_node.location
283
- Diagnostic.new(
284
- path: path,
285
- line: location.start_line,
286
- column: location.start_column + 1,
243
+ Violation.new(
287
244
  severity: :error,
288
245
  rule: "wrong-arity",
289
246
  message: "`#{entry.name}` expects #{expected} argument(s), got #{actual}"
290
247
  )
291
248
  end
292
249
 
293
- def unknown_helper_diagnostic(path, call_node, name, helper_table)
294
- location = call_node.location
295
- suggestion = did_you_mean(name, helper_table.names)
250
+ def unknown_helper_violation(name, helper_table)
251
+ # ADR-39 / boilerplate 0c — the shared DidYouMean-backed suggester
252
+ # replaces the hand-rolled Levenshtein this module used to carry.
253
+ suggestion = Rigor::Plugin::Base.suggest(name, helper_table.names)
296
254
  message = "no route helper `#{name}`"
297
255
  message += " (did you mean `#{suggestion}`?)" if suggestion
298
256
 
299
- Diagnostic.new(
300
- path: path,
301
- line: location.start_line,
302
- column: location.start_column + 1,
257
+ Violation.new(
303
258
  severity: :error,
304
259
  rule: "unknown-helper",
305
260
  message: message
306
261
  )
307
262
  end
308
-
309
- # Levenshtein-style nearest neighbour. Returns the
310
- # closest known helper within {DID_YOU_MEAN_DISTANCE}
311
- # edits, or nil.
312
- def did_you_mean(name, candidates)
313
- best = nil
314
- best_distance = DID_YOU_MEAN_DISTANCE + 1
315
- candidates.each do |candidate|
316
- d = levenshtein(name, candidate)
317
- if d < best_distance
318
- best = candidate
319
- best_distance = d
320
- end
321
- end
322
- best
323
- end
324
-
325
- # Standard iterative Levenshtein. Lifted from
326
- # rigor-routes' equivalent helper for parity.
327
- def levenshtein(left, right)
328
- return right.length if left.empty?
329
- return left.length if right.empty?
330
-
331
- rows = Array.new(left.length + 1) { Array.new(right.length + 1, 0) }
332
- (0..left.length).each { |i| rows[i][0] = i }
333
- (0..right.length).each { |j| rows[0][j] = j }
334
-
335
- (1..left.length).each do |i|
336
- (1..right.length).each do |j|
337
- cost = left[i - 1] == right[j - 1] ? 0 : 1
338
- rows[i][j] = [
339
- rows[i - 1][j] + 1,
340
- rows[i][j - 1] + 1,
341
- rows[i - 1][j - 1] + cost
342
- ].min
343
- end
344
- end
345
- rows[left.length][right.length]
346
- end
347
263
  end
348
264
  end
349
265
  end
@@ -198,24 +198,11 @@ module Rigor
198
198
  OMNIAUTH_SUFFIXES
199
199
  end
200
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
-
201
+ # ADR-39 delegates to the shared inflector (real
202
+ # `ActiveSupport::Inflector` when available), replacing the tiny
203
+ # AS-replica this module used to duplicate from `RoutesParser`.
210
204
  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
205
+ Rigor::Plugin::Inflector.singularize(word)
219
206
  end
220
207
 
221
208
  # Approximate path generation. We don't honour the
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "prism"
4
+ require "rigor/source/literals"
4
5
 
5
6
  require_relative "helper_table"
6
7
 
@@ -407,83 +408,24 @@ module Rigor
407
408
  end
408
409
  end
409
410
 
410
- # Tiny English inflector. Sufficient for the standard
411
- # `posts` ↔ `post`, `users` `user` rename Rails
412
- # generates by default; users with custom
413
- # inflections need to author RBS by hand for the
414
- # affected helpers (out of scope for v0.1.0).
415
- #
416
- # The canonical English uncountable noun set from
417
- # ActiveSupport::Inflector::Inflections (Rails 8.x).
418
- # `singularize("news")` returns `"news"` rather than
419
- # `"new"`. Pre-fix the parser stripped the trailing
420
- # 's' from `news`, so `resources :news` registered
421
- # `new_path` / `news_path` / `new_news_path` (broken
422
- # — Rails actually generates `news_path` for both
423
- # index and show, with the show form taking `:id`).
424
- # Redmine hit this 81× across `news_path(id)` calls.
425
- UNCOUNTABLE = %w[
426
- equipment information rice money species series fish
427
- sheep jeans police news settings
428
- ].to_set.freeze
429
- private_constant :UNCOUNTABLE
430
-
431
- # Latin / Greek irregular plurals Rails ships in its
432
- # default inflector. `media` → `medium` is the
433
- # dominant Rails-app case (Mastodon's `resources
434
- # :media, only: [:show]` generates `medium_path(id)`,
435
- # not `media_path`). Pre-fix `media` was in
436
- # UNCOUNTABLE which produced `media_path` for both
437
- # index and show — incorrect.
438
- IRREGULAR_SINGULARS = {
439
- "media" => "medium",
440
- "data" => "datum",
441
- "criteria" => "criterion",
442
- "phenomena" => "phenomenon"
443
- }.freeze
444
- private_constant :IRREGULAR_SINGULARS
445
-
411
+ # ADR-39: inflection delegates to the shared
412
+ # `Rigor::Plugin::Inflector`, which calls the real
413
+ # `ActiveSupport::Inflector` (the authority Rails itself uses to
414
+ # generate helper names) when available, falling back to a
415
+ # built-in approximation otherwise. This replaces the hand-tuned
416
+ # AS-replica these methods used to carry — every special case
417
+ # they accreted (`news` uncountable, `media`→`medium`,
418
+ # `databases`→`database`, `custom_css` ss-preservation,
419
+ # `async_refresh`→`async_refreshes`) was reverse-engineered
420
+ # `ActiveSupport::Inflector` behaviour, so the real inflector
421
+ # produces the same result for those and the correct one for the
422
+ # words the replica never covered.
446
423
  def singularize(word)
447
- return IRREGULAR_SINGULARS[word] if IRREGULAR_SINGULARS.key?(word)
448
- return word if UNCOUNTABLE.include?(word)
449
- return "#{word.chomp('ies')}y" if word.end_with?("ies") && word.length > 3
450
- # Rails ships specific `*es$` → `*` rules for
451
- # `(alias|status)es` and `(bus)es` (these have
452
- # singular endings that the generic-chomp rules
453
- # would mis-singularise to `aliase` / `statuse` /
454
- # `buse`).
455
- return word.chomp("es") if /(?:alias|status|bus)es\z/.match?(word)
456
- # Rails only chomps `es` after `x|ch|ss|sh|z` for
457
- # the generic case (`inflect.singular(/(x|ch|ss|sh)
458
- # es$/i, '\1')`). A generic `ses` suffix removed
459
- # `es` and broke `databases` → `databas` (correct:
460
- # `database`). Restrict to the explicit suffix set.
461
- return word.chomp("es") if word.end_with?("ches", "shes", "sses", "xes", "zes")
462
- # Words ending in `ss` are their own singular —
463
- # Rails' default inflector ships
464
- # `inflect.singular(/(ss)$/i, '\1')` that preserves
465
- # the double-s. Mastodon's `resources :custom_css`
466
- # was singularising to `custom_cs` and producing a
467
- # bogus `custom_cs_path(id)`.
468
- return word if word.end_with?("ss")
469
- return word.chomp("s") if word.end_with?("s")
470
-
471
- word
424
+ Rigor::Plugin::Inflector.singularize(word)
472
425
  end
473
426
 
474
427
  def pluralize(word)
475
- return IRREGULAR_SINGULARS.key(word) if IRREGULAR_SINGULARS.value?(word)
476
- return word if UNCOUNTABLE.include?(word)
477
- return word if word.end_with?("s") && !word.end_with?("ss")
478
- return "#{word.chomp('y')}ies" if word.end_with?("y") && word.length > 1
479
- # Words ending in s/sh/ch/x/z take "es" in plural,
480
- # matching Rails' default inflector. Without this
481
- # `async_refresh` (singular) pluralised to
482
- # `async_refreshs`, mismatching the actual
483
- # `/async_refreshes` URL.
484
- return "#{word}es" if word.end_with?("ss", "sh", "ch", "x", "z")
485
-
486
- "#{word}s"
428
+ Rigor::Plugin::Inflector.pluralize(word)
487
429
  end
488
430
  end
489
431
 
@@ -586,11 +528,7 @@ module Rigor
586
528
  # auto-pairing in `parse` adds `name_url`). Only handled
587
529
  # for literal Symbol or String first-arg.
588
530
  def handle_direct(node, context)
589
- first = node.arguments&.arguments&.first
590
- name = case first
591
- when Prism::SymbolNode then first.unescaped
592
- when Prism::StringNode then first.unescaped
593
- end
531
+ name = Rigor::Source::Literals.symbol_or_string_name(node.arguments&.arguments&.first)
594
532
  return if name.nil? || name.empty?
595
533
 
596
534
  block = node.block
@@ -798,7 +736,8 @@ module Rigor
798
736
  next if child.receiver
799
737
 
800
738
  (child.arguments&.arguments || []).each do |arg|
801
- skips << arg.unescaped.to_sym if arg.is_a?(Prism::SymbolNode)
739
+ sym = Rigor::Source::Literals.symbol(arg)
740
+ skips << sym if sym
802
741
  end
803
742
  end
804
743
  skips
@@ -1495,42 +1434,12 @@ module Rigor
1495
1434
  path.scan(/\([^)]*\)/).sum { |group| group.scan(/[:*][a-z_][a-z0-9_]*/).size }
1496
1435
  end
1497
1436
 
1498
- # Shared with `Context::Inflector#singularize` kept in
1499
- # sync until one of the two call sites can adopt the
1500
- # other.
1501
- UNCOUNTABLE = %w[
1502
- equipment information rice money species series fish
1503
- sheep jeans police news settings
1504
- ].to_set.freeze
1505
-
1506
- # Same `IRREGULAR_SINGULARS` map as `Context#singularize`.
1507
- IRREGULAR_SINGULARS = {
1508
- "media" => "medium",
1509
- "data" => "datum",
1510
- "criteria" => "criterion",
1511
- "phenomena" => "phenomenon"
1512
- }.freeze
1513
-
1437
+ # ADR-39 delegates to the shared inflector (real
1438
+ # `ActiveSupport::Inflector` when available). Previously a
1439
+ # hand-tuned AS-replica kept in sync with `Context#singularize`;
1440
+ # both now share the one authoritative implementation.
1514
1441
  def singularize_word(word)
1515
- return IRREGULAR_SINGULARS[word] if IRREGULAR_SINGULARS.key?(word)
1516
- return word if UNCOUNTABLE.include?(word)
1517
- return "#{word.chomp('ies')}y" if word.end_with?("ies") && word.length > 3
1518
- # Rails-shipped `(alias|status|bus)es$` → `$1`
1519
- # specifics — without these the generic chomp rules
1520
- # mis-singularise `statuses` → `statuse` and
1521
- # `aliases` → `aliase`.
1522
- return word.chomp("es") if /(?:alias|status|bus)es\z/.match?(word)
1523
- # Match the Rails default-inflector rule:
1524
- # `(x|ch|ss|sh)es$` → strip "es". A generic `ses`
1525
- # suffix would over-strip (`databases` → `databas`).
1526
- return word.chomp("es") if word.end_with?("ches", "shes", "sses", "xes", "zes")
1527
- # Preserve trailing `ss` — Rails ships
1528
- # `inflect.singular(/(ss)$/i, '\1')` so `custom_css`
1529
- # singularises as `custom_css`, not `custom_cs`.
1530
- return word if word.end_with?("ss")
1531
- return word.chomp("s") if word.end_with?("s")
1532
-
1533
- word
1442
+ Rigor::Plugin::Inflector.singularize(word)
1534
1443
  end
1535
1444
  end
1536
1445
  end
@@ -66,29 +66,26 @@ module Rigor
66
66
  version: "0.27.0",
67
67
  description: "Validates Rails route-helper calls against `config/routes.rb`.",
68
68
  config_schema: {
69
- "routes_file" => :string,
70
- "helper_paths" => :array
69
+ "routes_file" => { kind: :string, default: "config/routes.rb" },
70
+ # `helper_paths` the directories `HelperDiscoverer` walks
71
+ # for project-defined `*_path` / `*_url` methods. Default to
72
+ # the whole `app/` tree — the suffix filter inside the
73
+ # discoverer keeps the registered set tight, and real-world
74
+ # Rails apps routinely keep URL builders under
75
+ # `app/controllers` (private `def page_url`, `def callback_url`
76
+ # shapes), `app/lib` (Mastodon's
77
+ # `TranslationService::DeepL#base_url`), `app/services`
78
+ # (`SoftwareUpdateCheckService#api_url`), `app/serializers`,
79
+ # `app/presenters`, `app/decorators`, not only `app/helpers/`.
80
+ # Walking the whole tree is the honest answer to "does this
81
+ # `_path` / `_url` name exist anywhere in the project?"; the
82
+ # cost is a one-time Prism parse per file at startup, which is
83
+ # bounded.
84
+ "helper_paths" => { kind: :array, default: ["app"] }
71
85
  },
72
86
  produces: [:helper_table]
73
87
  )
74
88
 
75
- DEFAULT_ROUTES_FILE = "config/routes.rb"
76
-
77
- # The directories `HelperDiscoverer` walks for project-
78
- # defined `*_path` / `*_url` methods. Default to the whole
79
- # `app/` tree — the suffix filter inside the discoverer
80
- # keeps the registered set tight, and real-world Rails
81
- # apps routinely keep URL builders under `app/controllers`
82
- # (private `def page_url`, `def callback_url` shapes),
83
- # `app/lib` (Mastodon's `TranslationService::DeepL#base_url`),
84
- # `app/services` (`SoftwareUpdateCheckService#api_url`),
85
- # `app/serializers`, `app/presenters`, `app/decorators`,
86
- # not only `app/helpers/`. Walking the whole tree is the
87
- # honest answer to "does this `_path` / `_url` name exist
88
- # anywhere in the project?"; the cost is a one-time Prism
89
- # parse per file at startup, which is bounded.
90
- DEFAULT_HELPER_PATHS = ["app"].freeze
91
-
92
89
  # Cached producer — reads `config/routes.rb` through
93
90
  # the trusted `IoBoundary` and parses through
94
91
  # {RoutesParser}. The descriptor's auto-collected
@@ -110,8 +107,8 @@ module Rigor
110
107
  end
111
108
 
112
109
  def init(_services)
113
- @routes_file = config.fetch("routes_file", DEFAULT_ROUTES_FILE)
114
- @helper_paths = Array(config.fetch("helper_paths", DEFAULT_HELPER_PATHS)).map(&:to_s)
110
+ @routes_file = config.fetch("routes_file")
111
+ @helper_paths = Array(config.fetch("helper_paths")).map(&:to_s)
115
112
  @helper_table = nil
116
113
  @load_error = nil
117
114
  end
@@ -165,6 +162,10 @@ module Rigor
165
162
  )
166
163
  end
167
164
 
165
+ # File-level only: the once-per-run load-error emission. Per-call
166
+ # helper validation runs over the engine-owned walk via the
167
+ # node_rule below (ADR-37), with the same-file `shadowing` set
168
+ # built once as the node-rule file context.
168
169
  def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
169
170
  table = helper_table_or_nil
170
171
  if table.nil? && @load_error
@@ -173,10 +174,21 @@ module Rigor
173
174
  @load_error_emitted = true
174
175
  return [load_error_diagnostic(path)]
175
176
  end
176
- return [] if table.nil? || table.empty?
177
177
 
178
- Analyzer.diagnose(path: path, root: root, helper_table: table)
179
- .map { |diag| build_diagnostic(diag) }
178
+ []
179
+ end
180
+
181
+ node_file_context do |root, _scope|
182
+ Analyzer.collect_shadowing_names(root)
183
+ end
184
+
185
+ node_rule Prism::CallNode do |node, _scope, path, shadowing|
186
+ table = helper_table_or_nil
187
+ next [] if table.nil? || table.empty?
188
+
189
+ Analyzer.violations_for(call_node: node, helper_table: table, shadowing: shadowing, path: path).map do |violation|
190
+ diagnostic(node, path: path, message: violation.message, severity: violation.severity, rule: violation.rule)
191
+ end
180
192
  end
181
193
 
182
194
  private
@@ -221,13 +233,6 @@ module Rigor
221
233
  rule: "load-error"
222
234
  )
223
235
  end
224
-
225
- def build_diagnostic(diag)
226
- Rigor::Analysis::Diagnostic.new(
227
- path: diag.path, line: diag.line, column: diag.column,
228
- message: diag.message, severity: diag.severity, rule: diag.rule
229
- )
230
- end
231
236
  end
232
237
 
233
238
  Rigor::Plugin.register(RailsRoutes)
@@ -138,7 +138,6 @@ module Rigor
138
138
  id: base.id,
139
139
  version: base.version,
140
140
  description: base.description,
141
- protocols: base.protocols,
142
141
  config_schema: base.config_schema,
143
142
  produces: base.produces,
144
143
  consumes: base.consumes,
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "prism"
4
+ require "rigor/source/literals"
4
5
 
5
6
  require_relative "scope_walker"
6
7
 
@@ -82,7 +83,10 @@ module Rigor
82
83
  if describe_call?(node)
83
84
  record = build_record(node, anchor)
84
85
  accumulator << record
85
- if node.block&.body
86
+ # `describe_call?` already guarantees a `Prism::CallNode`; the
87
+ # explicit `is_a?` re-states it so the analyzer narrows `node`
88
+ # from `Prism::Node` and resolves `#block` (a CallNode method).
89
+ if node.is_a?(Prism::CallNode) && node.block&.body
86
90
  collect(node.block.body, anchor: record.describe_const || anchor, accumulator: accumulator)
87
91
  end
88
92
  return
@@ -190,9 +194,8 @@ module Rigor
190
194
 
191
195
  return nil
192
196
  end
193
- return nil unless first_arg.is_a?(Prism::SymbolNode)
194
197
 
195
- first_arg.unescaped.to_sym
198
+ Rigor::Source::Literals.symbol(first_arg)
196
199
  end
197
200
  end
198
201
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "prism"
4
+ require "rigor/source/literals"
4
5
 
5
6
  module Rigor
6
7
  module Plugin
@@ -157,10 +158,11 @@ module Rigor
157
158
  block_node: call_node.block
158
159
  )
159
160
  end
160
- return nil unless first_arg.is_a?(Prism::SymbolNode)
161
+ name = Rigor::Source::Literals.symbol(first_arg)
162
+ return nil if name.nil?
161
163
 
162
164
  Declaration.new(
163
- name: first_arg.unescaped.to_sym,
165
+ name: name,
164
166
  kind: call_node.name,
165
167
  location: call_node.location,
166
168
  block_node: call_node.block