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.
- checksums.yaml +4 -4
- data/README.md +4 -2
- data/exe/rigor +19 -0
- data/lib/rigor/analysis/check_rules.rb +25 -1
- data/lib/rigor/analysis/diagnostic.rb +40 -0
- data/lib/rigor/analysis/runner.rb +61 -2
- data/lib/rigor/analysis/worker_session.rb +3 -2
- data/lib/rigor/cache/descriptor.rb +6 -2
- data/lib/rigor/cli/plugins_command.rb +51 -4
- data/lib/rigor/cli/plugins_renderer.rb +86 -1
- data/lib/rigor/cli.rb +135 -5
- data/lib/rigor/environment/rbs_loader.rb +259 -1
- data/lib/rigor/environment.rb +8 -2
- data/lib/rigor/inference/budget_trace.rb +137 -0
- data/lib/rigor/inference/expression_typer.rb +9 -2
- data/lib/rigor/inference/hkt_reducer.rb +2 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -6
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +81 -14
- data/lib/rigor/inference/method_dispatcher.rb +57 -10
- data/lib/rigor/inference/precision_scanner.rb +60 -1
- data/lib/rigor/inference/scope_indexer.rb +127 -8
- data/lib/rigor/inference/statement_evaluator.rb +13 -8
- data/lib/rigor/inference/synthetic_method_index.rb +23 -4
- data/lib/rigor/inference/synthetic_method_scanner.rb +148 -14
- data/lib/rigor/plugin/additional_initializer.rb +108 -0
- data/lib/rigor/plugin/base.rb +321 -2
- data/lib/rigor/plugin/box.rb +64 -0
- data/lib/rigor/plugin/inflector.rb +121 -0
- data/lib/rigor/plugin/isolation.rb +191 -0
- data/lib/rigor/plugin/macro/nested_class_template.rb +140 -0
- data/lib/rigor/plugin/macro.rb +1 -0
- data/lib/rigor/plugin/manifest.rb +120 -23
- data/lib/rigor/plugin/node_context.rb +62 -0
- data/lib/rigor/plugin/registry.rb +10 -0
- data/lib/rigor/plugin.rb +3 -0
- data/lib/rigor/sig_gen/generator.rb +2 -3
- data/lib/rigor/sig_gen/observation_collector.rb +2 -2
- data/lib/rigor/source/literals.rb +118 -0
- data/lib/rigor/source/node_walker.rb +26 -0
- data/lib/rigor/source.rb +1 -0
- data/lib/rigor/type/combinator.rb +6 -1
- data/lib/rigor/type/union.rb +65 -1
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +1 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +31 -53
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +21 -23
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +38 -59
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +7 -13
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +22 -33
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +298 -413
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +69 -71
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +24 -34
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +18 -16
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +4 -46
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +1 -1
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +17 -12
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -8
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +2 -7
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +2 -6
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +4 -3
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +40 -45
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +7 -17
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +20 -42
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +7 -4
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +4 -8
- data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +188 -0
- data/plugins/rigor-mangrove/lib/rigor-mangrove.rb +3 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +4 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +24 -8
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +31 -48
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +21 -23
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +54 -82
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +25 -25
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +63 -147
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -17
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +23 -114
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +36 -31
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +6 -3
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +4 -2
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +13 -12
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +28 -40
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +44 -47
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +11 -10
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +45 -87
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +11 -12
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +29 -42
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +20 -19
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +73 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +43 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +21 -29
- data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +36 -96
- data/sig/rigor/plugin/access_denied_error.rbs +3 -1
- data/sig/rigor/plugin/base.rbs +58 -3
- data/sig/rigor/plugin/io_boundary.rbs +3 -0
- data/sig/rigor/plugin/manifest.rbs +31 -1
- data/sig/rigor/source.rbs +12 -0
- data/sig/rigor.rbs +5 -0
- data/skills/rigor-plugin-author/SKILL.md +13 -9
- data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +6 -5
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +159 -75
- data/skills/rigor-plugin-author/references/03-test-and-ship.md +3 -3
- metadata +52 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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
|
|
227
|
-
location = call_node.location
|
|
191
|
+
def info_violation(entry)
|
|
228
192
|
method_label = entry.http_method ? entry.http_method.to_s.upcase : "*"
|
|
229
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
202
|
-
#
|
|
203
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
411
|
-
# `
|
|
412
|
-
#
|
|
413
|
-
#
|
|
414
|
-
#
|
|
415
|
-
#
|
|
416
|
-
#
|
|
417
|
-
#
|
|
418
|
-
# `
|
|
419
|
-
# `
|
|
420
|
-
#
|
|
421
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
1499
|
-
#
|
|
1500
|
-
#
|
|
1501
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
114
|
-
@helper_paths = Array(config.fetch("helper_paths"
|
|
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
|
-
|
|
179
|
-
|
|
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)
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
161
|
+
name = Rigor::Source::Literals.symbol(first_arg)
|
|
162
|
+
return nil if name.nil?
|
|
161
163
|
|
|
162
164
|
Declaration.new(
|
|
163
|
-
name:
|
|
165
|
+
name: name,
|
|
164
166
|
kind: call_node.name,
|
|
165
167
|
location: call_node.location,
|
|
166
168
|
block_node: call_node.block
|