rigortype 0.1.11 → 0.1.12
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/lib/rigor/analysis/erb_template_detector.rb +38 -0
- data/lib/rigor/analysis/runner.rb +6 -1
- data/lib/rigor/analysis/worker_session.rb +6 -1
- data/lib/rigor/cli/plugins_command.rb +308 -0
- data/lib/rigor/cli/plugins_renderer.rb +173 -0
- data/lib/rigor/cli.rb +28 -0
- data/lib/rigor/inference/block_parameter_binder.rb +35 -0
- data/lib/rigor/inference/expression_typer.rb +69 -30
- data/lib/rigor/inference/indexed_narrowing.rb +187 -0
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
- data/lib/rigor/inference/method_dispatcher.rb +23 -0
- data/lib/rigor/inference/mutation_widening.rb +285 -0
- data/lib/rigor/inference/narrowing.rb +72 -4
- data/lib/rigor/inference/scope_indexer.rb +409 -12
- data/lib/rigor/inference/statement_evaluator.rb +256 -4
- data/lib/rigor/scope.rb +181 -4
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +22 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +94 -6
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +11 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +7 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +135 -11
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +94 -43
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +138 -35
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +17 -3
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +10 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +13 -3
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +6 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +83 -7
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +4 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +16 -1
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +81 -5
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +11 -3
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +194 -5
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +64 -3
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1107 -59
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +81 -4
- data/sig/rigor/scope.rbs +22 -0
- metadata +9 -1
|
@@ -65,15 +65,27 @@ module Rigor
|
|
|
65
65
|
|
|
66
66
|
# @return [MailerIndex]
|
|
67
67
|
def discover
|
|
68
|
-
|
|
68
|
+
# Two-pass: first collect every module's defs (for
|
|
69
|
+
# the include-following step), then build per-class
|
|
70
|
+
# entries that pull in actions from include'd modules.
|
|
71
|
+
# GitLab's `Notify` mailer derives every action from
|
|
72
|
+
# `Emails::*` concerns under `app/mailers/emails/`.
|
|
73
|
+
module_actions = {} # module_fqn => Hash<Symbol, ActionEntry>
|
|
74
|
+
class_visits = [] # collected (class_name, path, def_nodes, includes)
|
|
75
|
+
|
|
69
76
|
ruby_files_under(@search_paths).each do |path|
|
|
70
77
|
contents = read_safely(path)
|
|
71
78
|
next if contents.nil?
|
|
72
79
|
|
|
73
80
|
tree = Prism.parse(contents).value
|
|
74
|
-
walk_for_mailers(tree, []) do |class_name, def_nodes|
|
|
75
|
-
|
|
81
|
+
walk_for_mailers(tree, []) do |class_name, def_nodes, includes|
|
|
82
|
+
class_visits << [class_name, path, def_nodes, includes]
|
|
76
83
|
end
|
|
84
|
+
collect_module_actions(tree, [], module_actions)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
entries = class_visits.map do |class_name, path, def_nodes, includes|
|
|
88
|
+
build_class_entry(class_name, path, def_nodes, includes, module_actions)
|
|
77
89
|
end
|
|
78
90
|
MailerIndex.new(entries)
|
|
79
91
|
end
|
|
@@ -114,13 +126,62 @@ module Rigor
|
|
|
114
126
|
superclass = constant_path_name(node.superclass) if node.superclass
|
|
115
127
|
if superclass && @base_classes.include?(superclass)
|
|
116
128
|
def_nodes = collect_action_defs(node.body)
|
|
117
|
-
|
|
129
|
+
includes = collect_includes(node.body)
|
|
130
|
+
yield full_name, def_nodes, includes
|
|
118
131
|
end
|
|
119
132
|
|
|
120
133
|
inner_path = lexical_path + [class_local_name]
|
|
121
134
|
walk_for_mailers(node.body, inner_path, &) if node.body
|
|
122
135
|
end
|
|
123
136
|
|
|
137
|
+
# Collects qualified-constant names passed to `include
|
|
138
|
+
# X` calls inside the class body. Used to look up
|
|
139
|
+
# concern-module action definitions (GitLab's
|
|
140
|
+
# `Notify` mailer derives every action from
|
|
141
|
+
# `Emails::Issues`, `Emails::MergeRequests`, etc.).
|
|
142
|
+
def collect_includes(body)
|
|
143
|
+
return [] if body.nil?
|
|
144
|
+
|
|
145
|
+
names = []
|
|
146
|
+
body.compact_child_nodes.each do |node|
|
|
147
|
+
next unless node.is_a?(Prism::CallNode) && node.receiver.nil? && node.name == :include
|
|
148
|
+
|
|
149
|
+
(node.arguments&.arguments || []).each do |arg|
|
|
150
|
+
name = constant_path_name(arg)
|
|
151
|
+
names << name.delete_prefix("::") if name
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
names
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Walks the AST collecting every module's instance-side
|
|
158
|
+
# def nodes by fully-qualified module name. The same
|
|
159
|
+
# `collect_action_defs` filter applies (private /
|
|
160
|
+
# `_`-prefixed / callback-target methods skipped).
|
|
161
|
+
def collect_module_actions(node, lexical_path, accumulator)
|
|
162
|
+
return if node.nil?
|
|
163
|
+
|
|
164
|
+
case node
|
|
165
|
+
when Prism::ModuleNode
|
|
166
|
+
local_name = constant_path_name(node.constant_path)
|
|
167
|
+
return if local_name.nil?
|
|
168
|
+
|
|
169
|
+
full_name = (lexical_path + [local_name.delete_prefix("::")]).join("::")
|
|
170
|
+
if node.body
|
|
171
|
+
def_nodes = collect_action_defs(node.body)
|
|
172
|
+
entries = def_nodes.to_h { |def_node| [def_node.name, build_action_entry(def_node)] }
|
|
173
|
+
accumulator[full_name] = entries unless entries.empty?
|
|
174
|
+
collect_module_actions(node.body, lexical_path + [local_name.delete_prefix("::")], accumulator)
|
|
175
|
+
end
|
|
176
|
+
when Prism::ClassNode
|
|
177
|
+
local_name = constant_path_name(node.constant_path)
|
|
178
|
+
inner = local_name ? lexical_path + [local_name.delete_prefix("::")] : lexical_path
|
|
179
|
+
collect_module_actions(node.body, inner, accumulator) if node.body
|
|
180
|
+
else
|
|
181
|
+
node.compact_child_nodes.each { |child| collect_module_actions(child, lexical_path, accumulator) }
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
124
185
|
def visit_module(node, lexical_path, &)
|
|
125
186
|
module_local_name = constant_path_name(node.constant_path)
|
|
126
187
|
return if module_local_name.nil?
|
|
@@ -233,19 +294,46 @@ module Rigor
|
|
|
233
294
|
end
|
|
234
295
|
end
|
|
235
296
|
|
|
236
|
-
def build_class_entry(class_name, file_path, def_nodes)
|
|
297
|
+
def build_class_entry(class_name, file_path, def_nodes, includes = [], module_actions = {})
|
|
237
298
|
actions = def_nodes.to_h do |def_node|
|
|
238
299
|
entry = build_action_entry(def_node)
|
|
239
300
|
[entry.method_name, entry]
|
|
240
301
|
end
|
|
241
302
|
|
|
303
|
+
# Merge in actions from include'd modules. The
|
|
304
|
+
# discoverer pre-collected every module's defs as
|
|
305
|
+
# `module_actions` keyed by fully-qualified module
|
|
306
|
+
# name. We resolve each include against that map —
|
|
307
|
+
# tries the full include name first, then walks down
|
|
308
|
+
# the class's lexical chain looking for a nested
|
|
309
|
+
# match (e.g. `Emails::Issues` inside `class Notify`
|
|
310
|
+
# at top-level resolves to top-level `Emails::Issues`).
|
|
311
|
+
# Includes we cannot resolve are silently skipped;
|
|
312
|
+
# the per-mailer `unresolved_includes?` predicate
|
|
313
|
+
# below (consumed by the analyzer) downgrades
|
|
314
|
+
# `unknown-action` to silence when any include is
|
|
315
|
+
# unresolved.
|
|
316
|
+
unresolved_includes = []
|
|
317
|
+
includes.each do |include_name|
|
|
318
|
+
inc_actions = module_actions[include_name]
|
|
319
|
+
if inc_actions.nil?
|
|
320
|
+
unresolved_includes << include_name
|
|
321
|
+
next
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
inc_actions.each do |method_name, entry|
|
|
325
|
+
actions[method_name] ||= entry
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
242
329
|
missing_views = actions.keys.reject { |action| view_exists?(class_name, action) }
|
|
243
330
|
|
|
244
331
|
MailerIndex::ClassEntry.new(
|
|
245
332
|
class_name: class_name,
|
|
246
333
|
file_path: file_path,
|
|
247
334
|
actions: actions,
|
|
248
|
-
missing_views: missing_views
|
|
335
|
+
missing_views: missing_views,
|
|
336
|
+
unresolved_includes: unresolved_includes.freeze
|
|
249
337
|
)
|
|
250
338
|
end
|
|
251
339
|
|
|
@@ -28,10 +28,20 @@ module Rigor
|
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
ClassEntry = Data.define(:class_name, :file_path, :actions, :missing_views) do
|
|
31
|
+
ClassEntry = Data.define(:class_name, :file_path, :actions, :missing_views, :unresolved_includes) do
|
|
32
32
|
def find_action(method_name)
|
|
33
33
|
actions[method_name.to_sym]
|
|
34
34
|
end
|
|
35
|
+
|
|
36
|
+
# True when the mailer `include`s a module whose
|
|
37
|
+
# source we couldn't index (typically a gem-shipped
|
|
38
|
+
# concern that defines additional mailer actions).
|
|
39
|
+
# Analyzer downgrades `unknown-action` to silence in
|
|
40
|
+
# this case — the unresolved module may legitimately
|
|
41
|
+
# provide the action.
|
|
42
|
+
def unresolved_includes?
|
|
43
|
+
!unresolved_includes.empty?
|
|
44
|
+
end
|
|
35
45
|
end
|
|
36
46
|
|
|
37
47
|
attr_reader :entries
|
|
@@ -50,7 +50,13 @@ module Rigor
|
|
|
50
50
|
class Actionmailer < Rigor::Plugin::Base
|
|
51
51
|
manifest(
|
|
52
52
|
id: "actionmailer",
|
|
53
|
-
|
|
53
|
+
# Bumped 2026-05-28 — extended RESERVED_CLASS_METHODS to
|
|
54
|
+
# include `respond_to?` / `public_send` / `send` /
|
|
55
|
+
# `__send__` / `method` and friends so dynamic-dispatch
|
|
56
|
+
# idioms (`Mailer.respond_to?(action)` /
|
|
57
|
+
# `Mailer.public_send(action)`) stop firing
|
|
58
|
+
# `unknown-action` against the Ruby reflection method.
|
|
59
|
+
version: "0.4.0",
|
|
54
60
|
description: "Validates ActionMailer call shape and view template existence.",
|
|
55
61
|
config_schema: {
|
|
56
62
|
"mailer_search_paths" => :array,
|
|
@@ -103,10 +103,10 @@ module Rigor
|
|
|
103
103
|
# method. Files that don't contain a known controller
|
|
104
104
|
# contribute no diagnostics.
|
|
105
105
|
def diagnose_filters(path:, root:, controller_index:)
|
|
106
|
-
class_node =
|
|
106
|
+
class_node, enclosing = first_class_node_with_namespace(root)
|
|
107
107
|
return [] if class_node.nil?
|
|
108
108
|
|
|
109
|
-
class_name =
|
|
109
|
+
class_name = qualified_name_with_enclosing(class_node.constant_path, enclosing)
|
|
110
110
|
return [] if class_name.nil?
|
|
111
111
|
return [] unless controller_index.known?(class_name)
|
|
112
112
|
|
|
@@ -179,14 +179,67 @@ module Rigor
|
|
|
179
179
|
end
|
|
180
180
|
|
|
181
181
|
def first_class_node(node)
|
|
182
|
-
|
|
183
|
-
|
|
182
|
+
class_node, = first_class_node_with_namespace(node)
|
|
183
|
+
class_node
|
|
184
|
+
end
|
|
184
185
|
|
|
186
|
+
# Returns `[class_node, enclosing_namespace_array]` for
|
|
187
|
+
# the first `ClassNode` reachable from `node`. The
|
|
188
|
+
# namespace chain accumulates every enclosing
|
|
189
|
+
# `ModuleNode` / `ClassNode` qualifier, so the
|
|
190
|
+
# nested-module declaration shape
|
|
191
|
+
#
|
|
192
|
+
# module Admin
|
|
193
|
+
# class DomainBlocksController < BaseController
|
|
194
|
+
# end
|
|
195
|
+
# end
|
|
196
|
+
#
|
|
197
|
+
# is recovered as the qualified name
|
|
198
|
+
# `Admin::DomainBlocksController` — the same name the
|
|
199
|
+
# `ControllerDiscoverer` registers under (see the
|
|
200
|
+
# nested-module qualification fix on
|
|
201
|
+
# `ControllerDiscoverer#walk_declarations`). Without
|
|
202
|
+
# this, the analyzer used the bare inner-class name
|
|
203
|
+
# for index lookups + `controller_path_for`, silently
|
|
204
|
+
# skipping filter validation and pointing render
|
|
205
|
+
# template paths at the wrong directory (Mastodon's
|
|
206
|
+
# `admin/domain_blocks` rendered as bare
|
|
207
|
+
# `domain_blocks`).
|
|
208
|
+
def first_class_node_with_namespace(node, namespace = [])
|
|
209
|
+
return [nil, namespace] unless node.is_a?(Prism::Node)
|
|
210
|
+
return [node, namespace] if node.is_a?(Prism::ClassNode)
|
|
211
|
+
|
|
212
|
+
inner_namespace = if node.is_a?(Prism::ModuleNode)
|
|
213
|
+
namespace + namespace_segments_for(node)
|
|
214
|
+
else
|
|
215
|
+
namespace
|
|
216
|
+
end
|
|
185
217
|
node.compact_child_nodes.each do |child|
|
|
186
|
-
found =
|
|
187
|
-
return found if found
|
|
218
|
+
found, found_namespace = first_class_node_with_namespace(child, inner_namespace)
|
|
219
|
+
return [found, found_namespace] if found
|
|
188
220
|
end
|
|
189
|
-
nil
|
|
221
|
+
[nil, namespace]
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def namespace_segments_for(declaration_node)
|
|
225
|
+
path = qualified_name_for(declaration_node.constant_path)
|
|
226
|
+
path ? path.split("::") : []
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Resolves a class-name AST node against an enclosing
|
|
230
|
+
# namespace chain. A `ConstantPathNode` (e.g.
|
|
231
|
+
# `class Admin::Foo`) is already absolute and ignores
|
|
232
|
+
# the chain; a `ConstantReadNode` (bare `class Foo`
|
|
233
|
+
# inside `module Admin`) is qualified against it.
|
|
234
|
+
def qualified_name_with_enclosing(node, enclosing)
|
|
235
|
+
return nil unless node.is_a?(Prism::Node)
|
|
236
|
+
|
|
237
|
+
local = qualified_name_for(node)
|
|
238
|
+
return nil if local.nil?
|
|
239
|
+
return local if node.is_a?(Prism::ConstantPathNode) && !node.parent.nil?
|
|
240
|
+
return local if enclosing.empty?
|
|
241
|
+
|
|
242
|
+
"#{enclosing.join('::')}::#{local}"
|
|
190
243
|
end
|
|
191
244
|
|
|
192
245
|
def qualified_name_for(node)
|
|
@@ -316,19 +369,90 @@ module Rigor
|
|
|
316
369
|
# validates explicit renders only, since the implicit
|
|
317
370
|
# path would false-positive on `redirect_to` / `head`
|
|
318
371
|
# / early returns.
|
|
319
|
-
def diagnose_renders(path:, root:, view_search_roots:)
|
|
320
|
-
class_node =
|
|
372
|
+
def diagnose_renders(path:, root:, view_search_roots:, controller_index: nil)
|
|
373
|
+
class_node, enclosing = first_class_node_with_namespace(root)
|
|
321
374
|
return [] if class_node.nil?
|
|
322
375
|
|
|
323
|
-
class_name =
|
|
376
|
+
class_name = qualified_name_with_enclosing(class_node.constant_path, enclosing)
|
|
324
377
|
return [] if class_name.nil?
|
|
325
378
|
|
|
326
|
-
|
|
379
|
+
# Prefer the file-path-derived controller path when the
|
|
380
|
+
# source lives under `app/controllers/` — Rails autoload
|
|
381
|
+
# is the runtime authority on `Admin::Users::RolesController`
|
|
382
|
+
# vs `Users::RolesController` (a declaration like
|
|
383
|
+
# `module Admin; class Users::RolesController` resolves
|
|
384
|
+
# to whichever `Users` constant Ruby finds first, and
|
|
385
|
+
# the file path is the disambiguator Rails picks). Fall
|
|
386
|
+
# back to the AST-derived class name for files outside
|
|
387
|
+
# `app/controllers/` (libraries, test fixtures).
|
|
388
|
+
controller_path = controller_path_from_file(path) || controller_path_for(class_name)
|
|
327
389
|
return [] if controller_path.nil?
|
|
328
390
|
|
|
391
|
+
# Render checks silence when the controller (or its
|
|
392
|
+
# ancestor chain) inherits from a gem-shipped parent
|
|
393
|
+
# (Devise::ConfirmationsController,
|
|
394
|
+
# Doorkeeper::ApplicationsController, …). The gem
|
|
395
|
+
# ships its own views; the local subclass calling
|
|
396
|
+
# `render :show` resolves through the gem's view path,
|
|
397
|
+
# which our static analyser doesn't know about.
|
|
398
|
+
if controller_index&.known?(class_name) &&
|
|
399
|
+
controller_index.unresolved_include?(class_name)
|
|
400
|
+
return []
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Abstract base controllers: a `*BaseController` (`Admin::
|
|
404
|
+
# BaseController`, `Settings::Preferences::BaseController`,
|
|
405
|
+
# …) typically has no view of its own; its `render :show`
|
|
406
|
+
# bodies are resolved by Rails against the calling
|
|
407
|
+
# subclass's controller path at request time, NOT the
|
|
408
|
+
# base's. Same for parent controllers whose view
|
|
409
|
+
# directory exists but contains only subdirectories
|
|
410
|
+
# (Mastodon's `Admin::SettingsController` whose
|
|
411
|
+
# `app/views/admin/settings/` holds about/, appearance/,
|
|
412
|
+
# … but no top-level templates). Skip render checks for
|
|
413
|
+
# these — the diagnostic would be a false positive
|
|
414
|
+
# against intentional Rails abstract-base layouts.
|
|
415
|
+
return [] if abstract_base_controller?(class_name, controller_path, view_search_roots)
|
|
416
|
+
|
|
329
417
|
collect_render_diagnostics(path, class_node.body, controller_path, view_search_roots)
|
|
330
418
|
end
|
|
331
419
|
|
|
420
|
+
# Convert `<root>/app/controllers/admin/users/roles_controller.rb`
|
|
421
|
+
# to `admin/users/roles`. Returns nil if the path is not
|
|
422
|
+
# under an `app/controllers/` segment.
|
|
423
|
+
def controller_path_from_file(path)
|
|
424
|
+
match = path.match(%r{(?:\A|/)app/controllers/(.+)_controller\.rb\z})
|
|
425
|
+
match&.[](1)
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# An abstract base controller — its `render` bodies don't
|
|
429
|
+
# validate against its own controller_path because Rails
|
|
430
|
+
# resolves at request time against the actual subclass.
|
|
431
|
+
# Two conservative heuristics:
|
|
432
|
+
# (1) The class name ends with `BaseController`. Strong
|
|
433
|
+
# Rails-convention signal (Settings::Preferences::
|
|
434
|
+
# BaseController, Admin::BaseController, …).
|
|
435
|
+
# (2) The controller's view directory exists but contains
|
|
436
|
+
# ONLY subdirectories (no template files at its
|
|
437
|
+
# top). Mastodon's Admin::SettingsController whose
|
|
438
|
+
# app/views/admin/settings/ holds only about/,
|
|
439
|
+
# appearance/, … — the parent of nested controllers.
|
|
440
|
+
# Deliberately NOT triggered by "no view directory at
|
|
441
|
+
# all" because that fires the diagnostic we DO want for
|
|
442
|
+
# genuinely-missing views (the typo / forgot-to-create
|
|
443
|
+
# case).
|
|
444
|
+
def abstract_base_controller?(class_name, controller_path, view_search_roots)
|
|
445
|
+
return true if class_name.end_with?("BaseController")
|
|
446
|
+
|
|
447
|
+
view_search_roots.any? do |root|
|
|
448
|
+
dir = File.join(root, controller_path)
|
|
449
|
+
next false unless File.directory?(dir)
|
|
450
|
+
|
|
451
|
+
entries = Dir.children(dir)
|
|
452
|
+
entries.any? && entries.all? { |e| File.directory?(File.join(dir, e)) }
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
|
|
332
456
|
def collect_render_diagnostics(path, body, controller_path, view_search_roots)
|
|
333
457
|
diagnostics = []
|
|
334
458
|
walk_render_calls(body) do |call_node|
|
|
@@ -12,26 +12,39 @@ module Rigor
|
|
|
12
12
|
# parent_class_name)` triples. Used by Phase 2 (filter
|
|
13
13
|
# chains) to validate that `before_action :name`
|
|
14
14
|
# references a method defined on the controller or its
|
|
15
|
-
#
|
|
15
|
+
# ancestor chain.
|
|
16
16
|
#
|
|
17
|
-
#
|
|
17
|
+
# Two declaration shapes are recognised and qualified
|
|
18
|
+
# the same way:
|
|
18
19
|
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
20
|
+
# class Admin::AccountsController < BaseController
|
|
21
|
+
# # → registered as "Admin::AccountsController"
|
|
22
|
+
#
|
|
23
|
+
# module Admin
|
|
24
|
+
# class AccountsController < BaseController
|
|
25
|
+
# # → ALSO registered as "Admin::AccountsController"
|
|
26
|
+
# end
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# Pre-fix the nested form ignored the enclosing `module
|
|
30
|
+
# Admin` and registered the inner class as bare
|
|
31
|
+
# `"AccountsController"`, overwriting the top-level
|
|
32
|
+
# `app/controllers/accounts_controller.rb` entry. Mastodon
|
|
33
|
+
# has both shapes for the same logical name (top-level
|
|
34
|
+
# `AccountsController` + `Admin::AccountsController` +
|
|
35
|
+
# `Api::V1::AccountsController`) and the overwrite caused
|
|
36
|
+
# the wrong inheritance / method set to flow downstream,
|
|
37
|
+
# producing dozens of false `unknown-filter-method`
|
|
38
|
+
# diagnostics.
|
|
39
|
+
#
|
|
40
|
+
# Multi-level inheritance is also walked: `Admin::AccountsController
|
|
41
|
+
# < BaseController < ApplicationController` resolves
|
|
42
|
+
# methods from all three. Cycle-safe via a visited set.
|
|
43
|
+
# The parent-name lookup is **lexically scoped** — a bare
|
|
44
|
+
# `BaseController` reference inside `module Admin` first
|
|
45
|
+
# tries `Admin::BaseController`, then falls through to
|
|
46
|
+
# top-level `BaseController`, matching Ruby's constant-
|
|
47
|
+
# resolution semantics.
|
|
35
48
|
class ControllerDiscoverer
|
|
36
49
|
def initialize(io_boundary:, search_paths:)
|
|
37
50
|
@io_boundary = io_boundary
|
|
@@ -63,49 +76,87 @@ module Rigor
|
|
|
63
76
|
parse_result = Prism.parse(contents)
|
|
64
77
|
return unless parse_result.errors.empty?
|
|
65
78
|
|
|
66
|
-
|
|
67
|
-
entry = build_entry(declaration_node)
|
|
79
|
+
walk_declarations(parse_result.value, namespace: []) do |declaration_node, enclosing|
|
|
80
|
+
entry = build_entry(declaration_node, enclosing)
|
|
68
81
|
entries[entry.class_name] = entry if entry.class_name
|
|
69
82
|
end
|
|
70
83
|
rescue Plugin::AccessDeniedError, Errno::ENOENT
|
|
71
84
|
nil
|
|
72
85
|
end
|
|
73
86
|
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
# Walks every `ClassNode` / `ModuleNode` in the AST,
|
|
88
|
+
# yielding `[declaration_node, enclosing_namespace_array]`
|
|
89
|
+
# for each. `enclosing` is the chain of module / class
|
|
90
|
+
# qualifiers OUTSIDE the yielded declaration (e.g. for
|
|
91
|
+
# `module Admin; class AccountsController; end; end`
|
|
92
|
+
# the inner class yields with enclosing
|
|
93
|
+
# `["Admin"]`). The yielded declaration's own segment
|
|
94
|
+
# is added to the chain for its body's recursion so
|
|
95
|
+
# constants nested two levels deep qualify correctly.
|
|
96
|
+
def walk_declarations(node, namespace:, &)
|
|
97
|
+
return unless node.is_a?(Prism::Node)
|
|
98
|
+
|
|
99
|
+
if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
|
|
100
|
+
yield node, namespace
|
|
101
|
+
inner_namespace = namespace + namespace_segments_for(node)
|
|
102
|
+
return if node.body.nil?
|
|
103
|
+
|
|
104
|
+
walk_declarations(node.body, namespace: inner_namespace, &)
|
|
105
|
+
else
|
|
106
|
+
node.compact_child_nodes.each do |child|
|
|
107
|
+
walk_declarations(child, namespace: namespace, &)
|
|
108
|
+
end
|
|
90
109
|
end
|
|
91
|
-
into
|
|
92
110
|
end
|
|
93
111
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
112
|
+
# The segments contributed by a declaration to the
|
|
113
|
+
# qualifier chain of its body. For `class A::B` this is
|
|
114
|
+
# `["A", "B"]`; for `module Outer` it is `["Outer"]`.
|
|
115
|
+
# Returns `[]` for an anonymous declaration the walker
|
|
116
|
+
# cannot qualify.
|
|
117
|
+
def namespace_segments_for(declaration_node)
|
|
118
|
+
path = qualified_name_for(declaration_node.constant_path)
|
|
119
|
+
path ? path.split("::") : []
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def build_entry(declaration_node, enclosing)
|
|
123
|
+
name = qualified_name_with_enclosing(declaration_node.constant_path, enclosing)
|
|
124
|
+
parent_name = parent_name_for(declaration_node)
|
|
99
125
|
methods = collect_def_names(declaration_node.body)
|
|
100
126
|
includes = collect_include_targets(declaration_node.body)
|
|
101
127
|
ControllerIndex::Entry.new(
|
|
102
128
|
class_name: name,
|
|
103
129
|
defined_methods: methods.freeze,
|
|
104
130
|
parent_class_name: parent_name,
|
|
105
|
-
included_module_names: includes.freeze
|
|
131
|
+
included_module_names: includes.freeze,
|
|
132
|
+
enclosing_namespace: enclosing.dup.freeze
|
|
106
133
|
)
|
|
107
134
|
end
|
|
108
135
|
|
|
136
|
+
# Resolves the declared name against the enclosing
|
|
137
|
+
# namespace chain. A `ConstantPathNode` (e.g.
|
|
138
|
+
# `Admin::Foo` in `class Admin::Foo`) is already
|
|
139
|
+
# absolute and ignores the enclosing chain. A
|
|
140
|
+
# `ConstantReadNode` (bare `Foo` in `module Admin;
|
|
141
|
+
# class Foo`) is qualified against the chain so the
|
|
142
|
+
# name becomes `Admin::Foo`.
|
|
143
|
+
def qualified_name_with_enclosing(node, enclosing)
|
|
144
|
+
return nil unless node.is_a?(Prism::Node)
|
|
145
|
+
|
|
146
|
+
local = qualified_name_for(node)
|
|
147
|
+
return nil if local.nil?
|
|
148
|
+
return local if node.is_a?(Prism::ConstantPathNode) && !node.parent.nil?
|
|
149
|
+
return local if enclosing.empty?
|
|
150
|
+
|
|
151
|
+
"#{enclosing.join('::')}::#{local}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def parent_name_for(declaration_node)
|
|
155
|
+
return nil unless declaration_node.is_a?(Prism::ClassNode) && declaration_node.superclass
|
|
156
|
+
|
|
157
|
+
qualified_name_for(declaration_node.superclass)
|
|
158
|
+
end
|
|
159
|
+
|
|
109
160
|
def collect_def_names(node, accumulator = [])
|
|
110
161
|
return accumulator unless node.is_a?(Prism::Node)
|
|
111
162
|
|