rigortype 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/check_rules.rb +96 -3
  3. data/lib/rigor/analysis/erb_template_detector.rb +38 -0
  4. data/lib/rigor/analysis/runner.rb +6 -1
  5. data/lib/rigor/analysis/worker_session.rb +6 -1
  6. data/lib/rigor/cli/plugins_command.rb +308 -0
  7. data/lib/rigor/cli/plugins_renderer.rb +173 -0
  8. data/lib/rigor/cli/skill_command.rb +170 -0
  9. data/lib/rigor/cli.rb +37 -1
  10. data/lib/rigor/configuration/severity_profile.rb +3 -0
  11. data/lib/rigor/inference/block_parameter_binder.rb +35 -0
  12. data/lib/rigor/inference/expression_typer.rb +69 -30
  13. data/lib/rigor/inference/indexed_narrowing.rb +187 -0
  14. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
  15. data/lib/rigor/inference/method_dispatcher.rb +23 -0
  16. data/lib/rigor/inference/mutation_widening.rb +285 -0
  17. data/lib/rigor/inference/narrowing.rb +72 -4
  18. data/lib/rigor/inference/scope_indexer.rb +409 -12
  19. data/lib/rigor/inference/statement_evaluator.rb +256 -4
  20. data/lib/rigor/scope.rb +195 -4
  21. data/lib/rigor/version.rb +1 -1
  22. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +22 -1
  23. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +94 -6
  24. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +11 -1
  25. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +7 -1
  26. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +135 -11
  27. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +94 -43
  28. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +138 -35
  29. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +17 -3
  30. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +10 -0
  31. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +13 -3
  32. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +6 -2
  33. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +83 -7
  34. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +4 -1
  35. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +16 -1
  36. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +81 -5
  37. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +11 -3
  38. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +194 -5
  39. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
  40. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
  41. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
  42. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +64 -3
  43. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1107 -59
  44. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +81 -4
  45. data/sig/rigor/scope.rbs +23 -0
  46. data/skills/rigor-baseline-reduce/SKILL.md +100 -0
  47. data/skills/rigor-baseline-reduce/references/01-classify.md +107 -0
  48. data/skills/rigor-baseline-reduce/references/02-fix-or-suppress.md +133 -0
  49. data/skills/rigor-plugin-author/SKILL.md +95 -0
  50. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +195 -0
  51. data/skills/rigor-plugin-author/references/02-walker-and-types.md +155 -0
  52. data/skills/rigor-plugin-author/references/03-test-and-ship.md +163 -0
  53. data/skills/rigor-project-init/SKILL.md +129 -0
  54. data/skills/rigor-project-init/references/01-detect.md +101 -0
  55. data/skills/rigor-project-init/references/02-configure.md +185 -0
  56. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +168 -0
  57. data/skills/rigor-project-init/references/04-sig-uplift.md +171 -0
  58. metadata +22 -1
@@ -65,15 +65,27 @@ module Rigor
65
65
 
66
66
  # @return [MailerIndex]
67
67
  def discover
68
- entries = []
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
- entries << build_class_entry(class_name, path, def_nodes)
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
- yield full_name, def_nodes
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
- version: "0.1.0",
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 = first_class_node(root)
106
+ class_node, enclosing = first_class_node_with_namespace(root)
107
107
  return [] if class_node.nil?
108
108
 
109
- class_name = qualified_name_for(class_node.constant_path)
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
- return nil unless node.is_a?(Prism::Node)
183
- return node if node.is_a?(Prism::ClassNode)
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 = first_class_node(child)
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 = first_class_node(root)
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 = qualified_name_for(class_node.constant_path)
376
+ class_name = qualified_name_with_enclosing(class_node.constant_path, enclosing)
324
377
  return [] if class_name.nil?
325
378
 
326
- controller_path = controller_path_for(class_name)
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
- # immediate parent.
15
+ # ancestor chain.
16
16
  #
17
- # Limitations (per the Phase 2 design):
17
+ # Two declaration shapes are recognised and qualified
18
+ # the same way:
18
19
  #
19
- # - Single-class-per-file is the assumption — the walker
20
- # records the first top-level class node it encounters
21
- # per file. Files with multiple classes (rare in
22
- # `app/controllers/` outside of nested namespaces) only
23
- # contribute their first class.
24
- # - One level of inheritance only. `class FooController <
25
- # ApplicationController` records `FooController`'s
26
- # methods + parent_class_name `"ApplicationController"`,
27
- # and the index resolves the inherited methods at lookup
28
- # time. Two-level chains (`AdminController <
29
- # AdminBaseController < ApplicationController`) are not
30
- # walked transitively in Phase 2; `AdminController`'s
31
- # inherited methods are limited to what
32
- # `AdminBaseController` directly defines, not what
33
- # `AdminBaseController` inherits.
34
- # - Modules / `concerning :Auth` blocks are not walked.
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
- locate_classes_and_modules(parse_result.value).each do |declaration_node|
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
- # Recursive top-level descent. Returns every `ClassNode`
75
- # and `ModuleNode` reachable through nested `module` /
76
- # `class` blocks. Pre-fix only the **first** ClassNode
77
- # was harvested, which meant controller files that
78
- # define multiple classes lost coverage AND concern
79
- # modules under `app/controllers/concerns/` were ignored
80
- # entirely. The latter was the dominant Mastodon /
81
- # Redmine FP: `before_action :require_account_signature!`
82
- # references a method defined in a concern module that
83
- # the harvester never visited.
84
- def locate_classes_and_modules(node, into = [])
85
- return into unless node.is_a?(Prism::Node)
86
-
87
- into << node if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
88
- node.compact_child_nodes.each do |child|
89
- locate_classes_and_modules(child, into)
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
- def build_entry(declaration_node)
95
- name = qualified_name_for(declaration_node.constant_path)
96
- parent_name = if declaration_node.is_a?(Prism::ClassNode) && declaration_node.superclass
97
- qualified_name_for(declaration_node.superclass)
98
- end
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