rigortype 0.3.5 → 0.3.7

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 (266) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/data/builtins/ruby_core/date.yml +6 -3
  4. data/data/builtins/ruby_core/enumerable.yml +1 -0
  5. data/data/builtins/ruby_core/exception.yml +2 -1
  6. data/data/builtins/ruby_core/file.yml +28 -14
  7. data/data/builtins/ruby_core/hash.yml +5 -2
  8. data/data/builtins/ruby_core/io.yml +33 -16
  9. data/data/builtins/ruby_core/random.yml +2 -1
  10. data/data/builtins/ruby_core/re.yml +6 -3
  11. data/data/builtins/ruby_core/struct.yml +2 -0
  12. data/data/builtins/ruby_core/time.yml +2 -1
  13. data/data/gem_overlay/activesupport/core_ext.rbs +442 -11
  14. data/docs/handbook/02-everyday-types.md +1 -1
  15. data/docs/handbook/04-tuples-and-shapes.md +1 -1
  16. data/docs/handbook/08-understanding-errors.md +1 -1
  17. data/docs/handbook/09-plugins.md +2 -2
  18. data/docs/handbook/10-sorbet.md +1 -1
  19. data/docs/handbook/README.md +2 -2
  20. data/docs/handbook/appendix-go.md +1 -1
  21. data/docs/handbook/appendix-java-csharp.md +2 -2
  22. data/docs/handbook/appendix-mypy.md +1 -1
  23. data/docs/handbook/appendix-protocols-and-structural-typing.md +2 -2
  24. data/docs/handbook/appendix-rust.md +1 -1
  25. data/docs/handbook/appendix-type-theory.md +2 -2
  26. data/docs/handbook/appendix-typescript.md +4 -4
  27. data/docs/manual/02-cli-reference.md +29 -16
  28. data/docs/manual/03-configuration.md +1 -1
  29. data/docs/manual/04-diagnostics.md +12 -0
  30. data/docs/manual/05-inspecting-types.md +20 -8
  31. data/docs/manual/07-plugins.md +4 -0
  32. data/docs/manual/08-skills.md +1 -1
  33. data/docs/manual/11-ci.md +1 -1
  34. data/docs/manual/12-caching.md +1 -1
  35. data/docs/manual/15-type-protection-coverage.md +1 -1
  36. data/docs/manual/18-removing-dead-code.md +13 -4
  37. data/docs/manual/19-effect-labels.md +19 -12
  38. data/docs/manual/README.md +2 -2
  39. data/docs/manual/plugins/rigor-actionmailer.md +4 -4
  40. data/docs/manual/plugins/rigor-actionpack.md +61 -0
  41. data/docs/manual/plugins/rigor-activejob.md +3 -3
  42. data/docs/manual/plugins/rigor-activerecord.md +78 -5
  43. data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
  44. data/docs/manual/plugins/rigor-rails-i18n.md +5 -5
  45. data/docs/manual/plugins/rigor-rspec.md +6 -2
  46. data/docs/manual/plugins/rigor-sidekiq.md +22 -0
  47. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
  48. data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
  49. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
  50. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
  51. data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
  52. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
  53. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
  54. data/lib/rigor/analysis/check_rules.rb +383 -48
  55. data/lib/rigor/analysis/crash_signature.rb +116 -0
  56. data/lib/rigor/analysis/dependency_recorder.rb +18 -3
  57. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  58. data/lib/rigor/analysis/diagnostic.rb +17 -6
  59. data/lib/rigor/analysis/effects_cache_probe.rb +132 -0
  60. data/lib/rigor/analysis/incremental.rb +28 -0
  61. data/lib/rigor/analysis/incremental_session.rb +44 -9
  62. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
  63. data/lib/rigor/analysis/reachability/graph.rb +44 -16
  64. data/lib/rigor/analysis/reachability/plugin_roots.rb +11 -8
  65. data/lib/rigor/analysis/reachability/scan.rb +27 -7
  66. data/lib/rigor/analysis/reachability/scan_cache.rb +130 -0
  67. data/lib/rigor/analysis/result.rb +32 -0
  68. data/lib/rigor/analysis/run_cache_key.rb +53 -1
  69. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +93 -1
  70. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +14 -8
  71. data/lib/rigor/analysis/runner/pool_coordinator.rb +34 -0
  72. data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
  73. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -2
  74. data/lib/rigor/analysis/runner.rb +298 -38
  75. data/lib/rigor/analysis/worker_session.rb +10 -1
  76. data/lib/rigor/bleeding_edge.rb +7 -6
  77. data/lib/rigor/builtins/hkt_builtins.rb +45 -6
  78. data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
  79. data/lib/rigor/builtins/static_return_refinements.rb +20 -1
  80. data/lib/rigor/cache/descriptor.rb +42 -2
  81. data/lib/rigor/cache/file_digest.rb +20 -2
  82. data/lib/rigor/cache/incremental_snapshot.rb +40 -3
  83. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +31 -8
  84. data/lib/rigor/cache/store.rb +10 -1
  85. data/lib/rigor/cli/check_command.rb +67 -48
  86. data/lib/rigor/cli/coverage_command.rb +28 -24
  87. data/lib/rigor/cli/coverage_mutation.rb +13 -17
  88. data/lib/rigor/cli/coverage_scan.rb +47 -6
  89. data/lib/rigor/cli/doc_links.rb +100 -0
  90. data/lib/rigor/cli/docs_command.rb +32 -2
  91. data/lib/rigor/cli/effects_command.rb +27 -2
  92. data/lib/rigor/cli/effects_diff_renderer.rb +82 -12
  93. data/lib/rigor/cli/effects_explain_renderer.rb +6 -3
  94. data/lib/rigor/cli/effects_snapshot_command.rb +52 -7
  95. data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
  96. data/lib/rigor/cli/fused_protection_report.rb +23 -2
  97. data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
  98. data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
  99. data/lib/rigor/cli/mutation_protection_report.rb +31 -2
  100. data/lib/rigor/cli/sig_gen_command.rb +18 -0
  101. data/lib/rigor/cli/type_of_command.rb +188 -41
  102. data/lib/rigor/cli/type_of_renderer.rb +72 -3
  103. data/lib/rigor/cli/type_scan_command.rb +10 -1
  104. data/lib/rigor/cli/unused_command.rb +65 -16
  105. data/lib/rigor/cli.rb +22 -9
  106. data/lib/rigor/configuration.rb +7 -4
  107. data/lib/rigor/effects/definition_lines.rb +100 -0
  108. data/lib/rigor/effects/envelope_check.rb +18 -1
  109. data/lib/rigor/effects/liskov_check.rb +17 -8
  110. data/lib/rigor/effects/plugin_facts.rb +1 -1
  111. data/lib/rigor/effects/scanner.rb +2 -3
  112. data/lib/rigor/effects/signature_sources.rb +13 -2
  113. data/lib/rigor/effects/snapshot.rb +53 -21
  114. data/lib/rigor/effects/snapshot_diff.rb +26 -3
  115. data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
  116. data/lib/rigor/environment/lockfile_resolver.rb +15 -5
  117. data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
  118. data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
  119. data/lib/rigor/environment/rbs_loader.rb +525 -46
  120. data/lib/rigor/environment.rb +38 -15
  121. data/lib/rigor/inference/acceptance.rb +172 -20
  122. data/lib/rigor/inference/captured_locals.rb +62 -0
  123. data/lib/rigor/inference/content_join.rb +347 -0
  124. data/lib/rigor/inference/def_handle.rb +15 -3
  125. data/lib/rigor/inference/def_node_resolver.rb +62 -3
  126. data/lib/rigor/inference/expression_typer.rb +1020 -141
  127. data/lib/rigor/inference/fork_map.rb +6 -1
  128. data/lib/rigor/inference/hkt_reducer.rb +12 -1
  129. data/lib/rigor/inference/hkt_registry.rb +46 -0
  130. data/lib/rigor/inference/hkt_sugar_translator.rb +93 -0
  131. data/lib/rigor/inference/index_write_widening.rb +48 -0
  132. data/lib/rigor/inference/indexed_narrowing.rb +14 -0
  133. data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
  134. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
  135. data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
  136. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
  137. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
  138. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
  139. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
  140. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
  141. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
  142. data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
  143. data/lib/rigor/inference/method_dispatcher.rb +155 -14
  144. data/lib/rigor/inference/method_parameter_binder.rb +3 -3
  145. data/lib/rigor/inference/mutation_widening.rb +244 -174
  146. data/lib/rigor/inference/narrowing.rb +331 -27
  147. data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
  148. data/lib/rigor/inference/pre_eval_constants.rb +15 -0
  149. data/lib/rigor/inference/precision_scanner.rb +5 -2
  150. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  151. data/lib/rigor/inference/rbs_type_translator.rb +89 -39
  152. data/lib/rigor/inference/scope_indexer.rb +1493 -140
  153. data/lib/rigor/inference/statement_evaluator.rb +276 -91
  154. data/lib/rigor/inference/struct_fold_safety.rb +186 -27
  155. data/lib/rigor/inference/synthetic_method_scanner.rb +7 -0
  156. data/lib/rigor/inference/version_guard.rb +229 -0
  157. data/lib/rigor/language_server/selection_range_provider.rb +1 -1
  158. data/lib/rigor/plugin/base.rb +3 -2
  159. data/lib/rigor/plugin/inflector.rb +14 -5
  160. data/lib/rigor/plugin/io_boundary.rb +107 -4
  161. data/lib/rigor/plugin/loader.rb +14 -0
  162. data/lib/rigor/protection/analysis_guard.rb +60 -0
  163. data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
  164. data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
  165. data/lib/rigor/protection/discovery_seed.rb +1 -1
  166. data/lib/rigor/protection/measurement_integrity.rb +36 -0
  167. data/lib/rigor/protection/mutation_scanner.rb +61 -6
  168. data/lib/rigor/protection/mutator.rb +121 -18
  169. data/lib/rigor/rbs_extended/envelope_scanner.rb +9 -0
  170. data/lib/rigor/reflection.rb +182 -30
  171. data/lib/rigor/scope/discovery_index.rb +39 -1
  172. data/lib/rigor/scope.rb +385 -4
  173. data/lib/rigor/sig_gen/classification.rb +12 -1
  174. data/lib/rigor/sig_gen/generator.rb +254 -5
  175. data/lib/rigor/sig_gen/renderer.rb +1 -6
  176. data/lib/rigor/sig_gen/writer.rb +3 -0
  177. data/lib/rigor/source/constant_path.rb +79 -2
  178. data/lib/rigor/triage/catalogue.rb +1 -1
  179. data/lib/rigor/type/combinator.rb +10 -0
  180. data/lib/rigor/type/maybe.rb +47 -0
  181. data/lib/rigor/type/refined.rb +1 -2
  182. data/lib/rigor/type/result.rb +53 -0
  183. data/lib/rigor/type.rb +2 -0
  184. data/lib/rigor/version.rb +1 -1
  185. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
  186. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
  187. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
  188. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
  189. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
  190. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
  191. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
  192. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
  193. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
  194. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
  195. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
  196. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
  197. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
  198. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
  199. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
  200. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
  201. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
  202. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
  203. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
  204. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
  205. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
  206. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
  207. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
  208. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
  209. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
  210. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
  211. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
  212. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
  213. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
  214. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
  215. data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
  216. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
  217. data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
  218. data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
  219. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
  220. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
  221. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
  222. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
  223. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
  224. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
  225. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
  226. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
  227. data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
  228. data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
  229. data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
  230. data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
  231. data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
  232. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
  233. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
  234. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  235. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
  236. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
  237. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
  238. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
  239. data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
  240. data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
  241. data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
  242. data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
  243. data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
  244. data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
  245. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
  246. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
  247. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
  248. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
  249. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
  250. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
  251. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
  252. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
  253. data/sig/rigor/analysis/baseline.rbs +1 -1
  254. data/sig/rigor/environment.rbs +3 -0
  255. data/sig/rigor/inference.rbs +4 -1
  256. data/sig/rigor/plugin/io_boundary.rbs +2 -0
  257. data/sig/rigor/reflection.rbs +3 -1
  258. data/sig/rigor/scope.rbs +25 -1
  259. data/sig/rigor/type.rbs +95 -1
  260. data/sig/rigor.rbs +16 -0
  261. data/skills/rigor-ci-setup/SKILL.md +2 -2
  262. data/skills/rigor-editor-setup/SKILL.md +2 -2
  263. data/skills/rigor-mcp-setup/SKILL.md +2 -2
  264. data/skills/rigor-monkeypatch-resolve/SKILL.md +1 -1
  265. data/skills/rigor-plugin-review/SKILL.md +3 -3
  266. metadata +73 -3
@@ -36,7 +36,11 @@ module Rigor
36
36
  def initialize(io_boundary:, search_paths:, base_classes:)
37
37
  @io_boundary = io_boundary
38
38
  @search_paths = search_paths
39
- @base_classes = base_classes.to_set
39
+ # De-rooted for the same reason superclass names are ({#visit_class}): the match is by exact
40
+ # spelling, so a channel written `< ::ApplicationCable::Channel` — or a configured
41
+ # `channel_base_classes: ["::ApplicationCable::Channel"]` — would otherwise never match, and the
42
+ # channel would go undiscovered and its `broadcast_to` call sites report `unknown-channel`.
43
+ @base_classes = base_classes.to_set { |name| strip_root(name.to_s) }
40
44
  end
41
45
 
42
46
  # @return [ChannelIndex]
@@ -51,11 +55,39 @@ module Rigor
51
55
  entries << build_entry(class_name, path, body)
52
56
  end
53
57
  end
54
- ChannelIndex.new(entries)
58
+ ChannelIndex.new(merge_redeclarations(entries))
55
59
  end
56
60
 
57
61
  private
58
62
 
63
+ # Collapses the entries that declare the SAME constant into one, so {ChannelIndex} — which keys its
64
+ # by-name view by `class_name` — receives at most one row per channel.
65
+ #
66
+ # A channel is routinely declared more than once: the real class in `app/channels/chat_channel.rb`
67
+ # and a second file reopening it (`class ::ChatChannel`, `class ChatChannel`) to add an action.
68
+ # Ruby's own semantics are ADDITIVE, so the rows are UNIONed: action methods and literal stream
69
+ # names union, and `dynamic_streams` ORs — any dynamic registration anywhere in the class means no
70
+ # literal name can be proven missing, which is the false-positive-safe reading. Taking the last row
71
+ # instead dropped the earlier declaration's actions and streams whenever it sorted later in the glob.
72
+ # `file_path` stays the earlier declaration's; nothing reads it by name.
73
+ def merge_redeclarations(entries)
74
+ return entries if entries.length < 2
75
+
76
+ entries.each_with_object({}) do |entry, acc|
77
+ name = entry.class_name
78
+ acc[name] = acc.key?(name) ? merged_entry(acc[name], entry) : entry
79
+ end.values
80
+ end
81
+
82
+ # `base` is the earlier declaration, `addition` the later one. See {#merge_redeclarations}.
83
+ def merged_entry(base, addition)
84
+ base.with(
85
+ action_methods: (base.action_methods | addition.action_methods).freeze,
86
+ stream_names: (base.stream_names | addition.stream_names).freeze,
87
+ dynamic_streams: base.dynamic_streams || addition.dynamic_streams
88
+ )
89
+ end
90
+
59
91
  def read_safely(path)
60
92
  @io_boundary.read_file(path)
61
93
  rescue Plugin::AccessDeniedError, Errno::ENOENT
@@ -65,7 +97,8 @@ module Rigor
65
97
  def ruby_files_under(roots)
66
98
  roots.flat_map do |root|
67
99
  absolute = File.expand_path(root)
68
- next [] unless File.directory?(absolute)
100
+ # ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
101
+ next [] unless @io_boundary.directory?(absolute)
69
102
 
70
103
  Dir.glob(File.join(absolute, "**", "*.rb"))
71
104
  end
@@ -86,22 +119,37 @@ module Rigor
86
119
  class_local_name = constant_path_name(node.constant_path)
87
120
  return if class_local_name.nil?
88
121
 
89
- full_name = (lexical_path + [class_local_name]).join("::")
90
- superclass = constant_path_name(node.superclass) if node.superclass
122
+ full_name = declared_constant_name(class_local_name, lexical_path)
123
+ superclass = strip_root(constant_path_name(node.superclass)) if node.superclass
91
124
  yield full_name, node.body if superclass && @base_classes.include?(superclass)
92
125
 
93
- inner_path = lexical_path + [class_local_name]
94
- walk_for_channels(node.body, inner_path, &) if node.body
126
+ walk_for_channels(node.body, [full_name], &) if node.body
95
127
  end
96
128
 
97
129
  def visit_module(node, lexical_path, &)
98
130
  module_local_name = constant_path_name(node.constant_path)
99
131
  return if module_local_name.nil?
100
132
 
101
- inner_path = lexical_path + [module_local_name]
133
+ inner_path = [declared_constant_name(module_local_name, lexical_path)]
102
134
  walk_for_channels(node.body, inner_path, &) if node.body
103
135
  end
104
136
 
137
+ # The full constant name a `class` / `module` declaration defines, given the rendered local name
138
+ # and the enclosing lexical path. A ROOTED local name (`class ::ChatChannel`) names the top-level
139
+ # constant whatever the nesting, so the lexical path is dropped and the `::` with it; otherwise the
140
+ # name is appended to the path (`class ChatChannel` inside `module Admin` → `Admin::ChatChannel`).
141
+ def declared_constant_name(local_name, lexical_path)
142
+ return strip_root(local_name) if local_name.start_with?("::")
143
+
144
+ (lexical_path + [local_name]).join("::")
145
+ end
146
+
147
+ # `::ChatChannel` → `ChatChannel`; a name without the root marker is returned unchanged (nil stays
148
+ # nil).
149
+ def strip_root(name)
150
+ name&.delete_prefix("::")
151
+ end
152
+
105
153
  def build_entry(class_name, path, body)
106
154
  actions = []
107
155
  (body&.compact_child_nodes || []).each do |node|
@@ -154,6 +202,8 @@ module Rigor
154
202
  true
155
203
  end
156
204
 
205
+ # Renders a constant-path node as a String, KEEPING the leading `::` of a rooted path —
206
+ # {#declared_constant_name} reads it as "reset the lexical path" before the marker is dropped.
157
207
  def constant_path_name(node)
158
208
  return nil if node.nil?
159
209
 
@@ -26,19 +26,27 @@ module Rigor
26
26
 
27
27
  attr_reader :entries
28
28
 
29
+ # `entries` is expected UNIQUE by `class_name`: the by-name Hash is keyed by it, so a second row for
30
+ # a channel would replace the first outright. {ChannelDiscoverer#merge_redeclarations} is the single
31
+ # home of that guarantee — a reopened class arrives as one merged row, never as two.
29
32
  def initialize(entries)
30
33
  @entries = entries.freeze
31
34
  @by_name = entries.to_h { |entry| [entry.class_name, entry] }.freeze
32
35
  freeze
33
36
  end
34
37
 
38
+ # Entries are keyed by the de-rooted constant path (`"ChatChannel"`, `"Admin::ChatChannel"` — never
39
+ # `"::ChatChannel"`; see {ChannelDiscoverer}), while a QUERY may legitimately arrive rooted:
40
+ # `::ChatChannel.broadcast_to(...)` renders its receiver as `"::ChatChannel"`. The root marker is
41
+ # dropped here, once, so no caller needs a `find(name) || find("::#{name}")` retry (#621).
42
+ #
35
43
  # @return [Entry, nil]
36
44
  def find(class_name)
37
- @by_name[class_name.to_s]
45
+ @by_name[strip_leading_namespace(class_name.to_s)]
38
46
  end
39
47
 
40
48
  def known?(class_name)
41
- @by_name.key?(class_name.to_s)
49
+ @by_name.key?(strip_leading_namespace(class_name.to_s))
42
50
  end
43
51
 
44
52
  def empty?
@@ -64,6 +72,11 @@ module Rigor
64
72
  def any_dynamic_streams?
65
73
  @entries.any?(&:dynamic_streams)
66
74
  end
75
+
76
+ private
77
+
78
+ # `::ChatChannel` → `ChatChannel`. The query-side half of the key contract — see {#find}.
79
+ def strip_leading_namespace(name) = name.delete_prefix("::")
67
80
  end
68
81
  end
69
82
  end
@@ -65,7 +65,10 @@ module Rigor
65
65
  class Actioncable < Rigor::Plugin::Base
66
66
  manifest(
67
67
  id: "actioncable",
68
- version: "0.1.0",
68
+ # Bumped 2026-09-02 (#621) — channel keys and base-class names are de-rooted at the producer and a
69
+ # reopened class's actions and streams are UNIONed rather than clobbered, so a cached 0.1.0 index
70
+ # can be missing a whole channel (`< ::ApplicationCable::Channel`) or half its stream names.
71
+ version: "0.2.0",
69
72
  description: "Validates ActionCable broadcast call shape against discovered channels.",
70
73
  config_schema: {
71
74
  "channel_search_paths" => { kind: :array, default: ["app/channels"] },
@@ -53,7 +53,9 @@ module Rigor
53
53
  return [] if class_name.nil?
54
54
  return [] if RESERVED_CLASS_METHODS.include?(call_node.name)
55
55
 
56
- class_entry = mailer_index.find(class_name) || mailer_index.find("::#{class_name}")
56
+ # `MailerIndex#find` de-roots the query itself (#621) a `::UserMailer.welcome` receiver and a
57
+ # plain `UserMailer.welcome` reach the same entry with no retry arm here.
58
+ class_entry = mailer_index.find(class_name)
57
59
  return [] if class_entry.nil?
58
60
 
59
61
  action_entry = class_entry.find_action(call_node.name)
@@ -46,7 +46,10 @@ module Rigor
46
46
  def initialize(io_boundary:, search_paths:, base_classes:, views_root: DEFAULT_VIEWS_ROOT)
47
47
  @io_boundary = io_boundary
48
48
  @search_paths = search_paths
49
- @base_classes = base_classes.to_set
49
+ # De-rooted for the same reason superclass names are ({#visit_class}): the match is by exact
50
+ # spelling, so a configured `mailer_base_classes: ["::ApplicationMailer"]` would otherwise never
51
+ # match anything a declaration can render.
52
+ @base_classes = base_classes.to_set { |name| strip_root(name.to_s) }
50
53
  @views_root = views_root
51
54
  end
52
55
 
@@ -86,7 +89,8 @@ module Rigor
86
89
  def ruby_files_under(roots)
87
90
  roots.flat_map do |root|
88
91
  absolute = File.expand_path(root)
89
- next [] unless File.directory?(absolute)
92
+ # ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
93
+ next [] unless @io_boundary.directory?(absolute)
90
94
 
91
95
  Dir.glob(File.join(absolute, "**", "*.rb"))
92
96
  end
@@ -107,16 +111,15 @@ module Rigor
107
111
  class_local_name = constant_path_name(node.constant_path)
108
112
  return if class_local_name.nil?
109
113
 
110
- full_name = (lexical_path + [class_local_name]).join("::")
111
- superclass = constant_path_name(node.superclass) if node.superclass
114
+ full_name = declared_constant_name(class_local_name, lexical_path)
115
+ superclass = strip_root(constant_path_name(node.superclass)) if node.superclass
112
116
  if superclass && @base_classes.include?(superclass)
113
117
  def_nodes = collect_action_defs(node.body)
114
118
  includes = collect_includes(node.body)
115
119
  yield full_name, def_nodes, includes
116
120
  end
117
121
 
118
- inner_path = lexical_path + [class_local_name]
119
- walk_for_mailers(node.body, inner_path, &) if node.body
122
+ walk_for_mailers(node.body, [full_name], &) if node.body
120
123
  end
121
124
 
122
125
  # Collects qualified-constant names passed to `include X` calls inside the class body. Used to
@@ -130,8 +133,8 @@ module Rigor
130
133
  next unless node.is_a?(Prism::CallNode) && node.receiver.nil? && node.name == :include
131
134
 
132
135
  (node.arguments&.arguments || []).each do |arg|
133
- name = constant_path_name(arg)
134
- names << name.delete_prefix("::") if name
136
+ name = strip_root(constant_path_name(arg))
137
+ names << name if name
135
138
  end
136
139
  end
137
140
  names
@@ -148,16 +151,19 @@ module Rigor
148
151
  local_name = constant_path_name(node.constant_path)
149
152
  return if local_name.nil?
150
153
 
151
- full_name = (lexical_path + [local_name.delete_prefix("::")]).join("::")
154
+ full_name = declared_constant_name(local_name, lexical_path)
152
155
  if node.body
153
156
  def_nodes = collect_action_defs(node.body)
154
157
  entries = def_nodes.to_h { |def_node| [def_node.name, build_action_entry(def_node)] }
155
- accumulator[full_name] = entries unless entries.empty?
156
- collect_module_actions(node.body, lexical_path + [local_name.delete_prefix("::")], accumulator)
158
+ # UNIONed, never assigned: a concern module reopened in a second file adds actions rather
159
+ # than replacing the ones the first declaration spelled (same rule as {MailerIndex}'s
160
+ # per-class merge).
161
+ accumulator[full_name] = (accumulator[full_name] || {}).merge(entries) unless entries.empty?
162
+ collect_module_actions(node.body, [full_name], accumulator)
157
163
  end
158
164
  when Prism::ClassNode
159
165
  local_name = constant_path_name(node.constant_path)
160
- inner = local_name ? lexical_path + [local_name.delete_prefix("::")] : lexical_path
166
+ inner = local_name ? [declared_constant_name(local_name, lexical_path)] : lexical_path
161
167
  collect_module_actions(node.body, inner, accumulator) if node.body
162
168
  else
163
169
  node.rigor_each_child { |child| collect_module_actions(child, lexical_path, accumulator) }
@@ -168,10 +174,27 @@ module Rigor
168
174
  module_local_name = constant_path_name(node.constant_path)
169
175
  return if module_local_name.nil?
170
176
 
171
- inner_path = lexical_path + [module_local_name]
177
+ inner_path = [declared_constant_name(module_local_name, lexical_path)]
172
178
  walk_for_mailers(node.body, inner_path, &) if node.body
173
179
  end
174
180
 
181
+ # The full constant name a `class` / `module` declaration defines, given the rendered local name
182
+ # and the enclosing lexical path. A ROOTED local name (`class ::UserMailer`) names the top-level
183
+ # constant whatever the nesting, so the lexical path is dropped and the `::` with it; otherwise the
184
+ # name is appended to the path (`class UserMailer` inside `module Admin` → `Admin::UserMailer`).
185
+ def declared_constant_name(local_name, lexical_path)
186
+ return strip_root(local_name) if local_name.start_with?("::")
187
+
188
+ (lexical_path + [local_name]).join("::")
189
+ end
190
+
191
+ # `::UserMailer` → `UserMailer`; a name without the root marker is returned unchanged (nil stays nil).
192
+ def strip_root(name)
193
+ name&.delete_prefix("::")
194
+ end
195
+
196
+ # Renders a constant-path node as a String, KEEPING the leading `::` of a rooted path —
197
+ # {#declared_constant_name} reads it as "reset the lexical path" before the marker is dropped.
175
198
  def constant_path_name(node)
176
199
  return nil if node.nil?
177
200
 
@@ -334,7 +357,9 @@ module Rigor
334
357
  # ADR-39: the real ActiveSupport::Inflector resolves the mailer's view directory
335
358
  # (`Foo::BarMailer` → `foo/bar_mailer`), so a divergence from Rails' real underscore can't
336
359
  # point the missing-view check at the wrong directory.
337
- underscore_path = Rigor::Plugin::Inflector.underscore(class_name.delete_prefix("::"))
360
+ # `class_name` is already de-rooted ({#declared_constant_name}), so no root marker can reach the
361
+ # inflection.
362
+ underscore_path = Rigor::Plugin::Inflector.underscore(class_name)
338
363
  mailer_dir = File.join(views_root_absolute, underscore_path)
339
364
 
340
365
  VIEW_FORMATS.any? do |format|
@@ -40,19 +40,32 @@ module Rigor
40
40
 
41
41
  attr_reader :entries
42
42
 
43
+ # `entries` holds one row PER DECLARATION — a mailer declared in two files contributes two — because
44
+ # {#find_by_file} anchors the `missing-view` diagnostics on the file each action's `def` lives in.
45
+ # The by-name view a call site consults is the UNION of a class's declarations ({.merge_entry}): a
46
+ # reopen ADDS actions rather than replacing the class, so keeping the last row alone reported
47
+ # `unknown-action` on every action the other declaration spelled (#621).
43
48
  def initialize(entries)
44
49
  @entries = entries.freeze
45
- @by_name = entries.to_h { |entry| [entry.class_name, entry] }.freeze
50
+ @by_name = entries.each_with_object({}) do |entry, acc|
51
+ name = entry.class_name
52
+ acc[name] = acc.key?(name) ? self.class.merge_entry(acc[name], entry) : entry
53
+ end.freeze
46
54
  freeze
47
55
  end
48
56
 
57
+ # Entries are keyed by the de-rooted constant path (`"UserMailer"`, `"Admin::UserMailer"` — never
58
+ # `"::UserMailer"`; see {MailerDiscoverer}), while a QUERY may legitimately arrive rooted:
59
+ # `::UserMailer.welcome(u)` renders its receiver as `"::UserMailer"`. The root marker is dropped
60
+ # here, once, so no caller needs a `find(name) || find("::#{name}")` retry (#621).
61
+ #
49
62
  # @return [ClassEntry, nil]
50
63
  def find(class_name)
51
- @by_name[class_name.to_s]
64
+ @by_name[strip_leading_namespace(class_name.to_s)]
52
65
  end
53
66
 
54
67
  def known?(class_name)
55
- @by_name.key?(class_name.to_s)
68
+ @by_name.key?(strip_leading_namespace(class_name.to_s))
56
69
  end
57
70
 
58
71
  # @param file_path [String] absolute path of a mailer file (canonicalised — see plugin entry's
@@ -73,6 +86,26 @@ module Rigor
73
86
  def names
74
87
  @by_name.keys
75
88
  end
89
+
90
+ # Unions two declarations of the SAME mailer class. `base` is the earlier row, `addition` the later
91
+ # one; Ruby's own semantics are additive, so nothing either declaration spelled is lost. Actions are
92
+ # name-keyed (a redefinition in the later file wins, as it does at load time), `missing_views` and
93
+ # `unresolved_includes` union, and `file_path` stays the earlier declaration's — the merged row is
94
+ # only ever reached by name, never by {#find_by_file}, which reads the per-declaration `entries`.
95
+ def self.merge_entry(base, addition)
96
+ base.with(
97
+ actions: base.actions.merge(addition.actions),
98
+ missing_views: (base.missing_views | addition.missing_views).freeze,
99
+ unresolved_includes: (base.unresolved_includes | addition.unresolved_includes).freeze
100
+ )
101
+ end
102
+
103
+ # `::UserMailer` → `UserMailer`. The query-side half of the key contract — see {#find}.
104
+ def self.strip_leading_namespace(name) = name.delete_prefix("::")
105
+
106
+ private
107
+
108
+ def strip_leading_namespace(name) = self.class.strip_leading_namespace(name)
76
109
  end
77
110
  end
78
111
  end
@@ -47,7 +47,11 @@ module Rigor
47
47
  # `send` / `__send__` / `method` and friends so dynamic-dispatch idioms
48
48
  # (`Mailer.respond_to?(action)` / `Mailer.public_send(action)`) stop firing `unknown-action`
49
49
  # against the Ruby reflection method.
50
- version: "0.4.0",
50
+ #
51
+ # Bumped 2026-09-02 (#621) — mailer keys are de-rooted at the producer and a reopened class's
52
+ # actions are UNIONed rather than clobbered, so a cached 0.4.0 index can be missing actions this
53
+ # version knows about.
54
+ version: "0.5.0",
51
55
  description: "Validates ActionMailer call shape and view template existence.",
52
56
  config_schema: {
53
57
  "mailer_search_paths" => { kind: :array, default: ["app/mailers"] },
@@ -120,7 +124,7 @@ module Rigor
120
124
  severity: :warning,
121
125
  rule: "missing-view",
122
126
  message: "`#{class_entry.class_name}##{action_name}` has no view template " \
123
- "under `#{@views_root}/#{Rigor::Plugin::Inflector.underscore(class_entry.class_name.delete_prefix('::'))}/`"
127
+ "under `#{@views_root}/#{Rigor::Plugin::Inflector.underscore(class_entry.class_name)}/`"
124
128
  )
125
129
  end
126
130
  end
@@ -247,26 +247,42 @@ module Rigor
247
247
  return nil if class_index.nil?
248
248
 
249
249
  class_node = ancestors[class_index]
250
+ # A ROOTED enclosing module (`module ::Admin`) resets the qualifier chain, exactly as it does in
251
+ # {ControllerDiscoverer} — the two sides must render the same key or the lookup silently misses
252
+ # (#621).
250
253
  enclosing = ancestors[0...class_index]
251
254
  .grep(Prism::ModuleNode)
252
- .flat_map { |n| namespace_segments_for(n) }
255
+ .each_with_object([]) do |module_node, chain|
256
+ chain.clear if rooted_declaration?(module_node)
257
+ chain.concat(namespace_segments_for(module_node))
258
+ end
253
259
  qualified_name_with_enclosing(class_node.constant_path, enclosing)
254
260
  end
255
261
 
262
+ # True when the declaration's constant path is explicitly rooted (`class ::Foo`, `class ::Foo::Bar`)
263
+ # — the leftmost segment is a `ConstantPathNode` with no parent. Mirrors
264
+ # {ControllerDiscoverer#rooted_declaration?}.
265
+ def rooted_declaration?(declaration_node)
266
+ node = declaration_node.constant_path
267
+ node = node.parent while node.is_a?(Prism::ConstantPathNode) && node.parent
268
+ node.is_a?(Prism::ConstantPathNode)
269
+ end
270
+
256
271
  def namespace_segments_for(declaration_node)
257
272
  path = qualified_name_for(declaration_node.constant_path)
258
273
  path ? path.split("::") : []
259
274
  end
260
275
 
261
- # Resolves a class-name AST node against an enclosing namespace chain. A `ConstantPathNode`
262
- # (e.g. `class Admin::Foo`) is already absolute and ignores the chain; a `ConstantReadNode`
263
- # (bare `class Foo` inside `module Admin`) is qualified against it.
276
+ # Resolves a class-name AST node against an enclosing namespace chain. A `ConstantPathNode` is
277
+ # already absolute and ignores the chain either because it is qualified (`class Admin::Foo`) or
278
+ # because it is ROOTED (`class ::Foo`), which names the top-level constant whatever the nesting
279
+ # (#621). A `ConstantReadNode` (bare `class Foo` inside `module Admin`) is qualified against it.
264
280
  def qualified_name_with_enclosing(node, enclosing)
265
281
  return nil unless node.is_a?(Prism::Node)
266
282
 
267
283
  local = qualified_name_for(node)
268
284
  return nil if local.nil?
269
- return local if node.is_a?(Prism::ConstantPathNode) && !node.parent.nil?
285
+ return local if node.is_a?(Prism::ConstantPathNode)
270
286
  return local if enclosing.empty?
271
287
 
272
288
  "#{enclosing.join('::')}::#{local}"
@@ -56,7 +56,8 @@ module Rigor
56
56
  def ruby_files_under(roots)
57
57
  roots.flat_map do |root|
58
58
  absolute = File.expand_path(root)
59
- next [] unless File.directory?(absolute)
59
+ # ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
60
+ next [] unless @io_boundary.directory?(absolute)
60
61
 
61
62
  Dir.glob(File.join(absolute, "**", "*.rb"))
62
63
  end
@@ -69,12 +70,34 @@ module Rigor
69
70
 
70
71
  walk_declarations(parse_result.value, namespace: []) do |declaration_node, enclosing|
71
72
  entry = build_entry(declaration_node, enclosing)
72
- entries[entry.class_name] = entry if entry.class_name
73
+ next unless entry.class_name
74
+
75
+ existing = entries[entry.class_name]
76
+ entries[entry.class_name] = existing ? merge_entry(existing, entry) : entry
73
77
  end
74
78
  rescue Plugin::AccessDeniedError, Errno::ENOENT
75
79
  nil
76
80
  end
77
81
 
82
+ # Unions two declarations of the SAME constant. A controller or concern is routinely declared more
83
+ # than once — the real class in `app/controllers/accounts_controller.rb` and a second file reopening
84
+ # it (`class ::AccountsController`, `class AccountsController`) to add an action or a filter target.
85
+ # Ruby's own semantics are ADDITIVE, so nothing either declaration spelled is lost. Assigning the
86
+ # later entry instead dropped the earlier one's `def`s, and `before_action :a_method_the_first_file
87
+ # _defines` then surfaced a false `unknown-filter-method` on correct code (#621).
88
+ #
89
+ # `parent_class_name` takes the first non-nil — a reopen normally omits `< Parent`, and two
90
+ # declarations that both spell one name the same class. `enclosing_namespace` stays the earlier
91
+ # declaration's: it is the lexical scope the merged include / parent names resolve against, and an
92
+ # unresolvable name degrades to {ControllerIndex#unresolved_include?}, which silences the rule.
93
+ def merge_entry(base, addition)
94
+ base.with(
95
+ defined_methods: (base.defined_methods | addition.defined_methods).freeze,
96
+ parent_class_name: base.parent_class_name || addition.parent_class_name,
97
+ included_module_names: (base.included_module_names | addition.included_module_names).freeze
98
+ )
99
+ end
100
+
78
101
  # Walks every `ClassNode` / `ModuleNode` in the AST, yielding `[declaration_node,
79
102
  # enclosing_namespace_array]` for each. `enclosing` is the chain of module / class qualifiers
80
103
  # OUTSIDE the yielded declaration (e.g. for `module Admin; class AccountsController; end; end`
@@ -86,7 +109,10 @@ module Rigor
86
109
 
87
110
  if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
88
111
  yield node, namespace
89
- inner_namespace = namespace + namespace_segments_for(node)
112
+ # A ROOTED declaration (`class ::Foo`) names the top-level constant whatever its nesting, so it
113
+ # RESETS the qualifier chain its body sees rather than extending the enclosing one (#621).
114
+ inner_namespace = rooted_declaration?(node) ? [] : namespace
115
+ inner_namespace += namespace_segments_for(node)
90
116
  return if node.body.nil?
91
117
 
92
118
  walk_declarations(node.body, namespace: inner_namespace, &)
@@ -119,21 +145,34 @@ module Rigor
119
145
  )
120
146
  end
121
147
 
122
- # Resolves the declared name against the enclosing namespace chain. A `ConstantPathNode` (e.g.
123
- # `Admin::Foo` in `class Admin::Foo`) is already absolute and ignores the enclosing chain. A
124
- # `ConstantReadNode` (bare `Foo` in `module Admin; class Foo`) is qualified against the chain so
125
- # the name becomes `Admin::Foo`.
148
+ # Resolves the declared name against the enclosing namespace chain. A `ConstantPathNode` is already
149
+ # absolute and ignores the enclosing chain — either because it is qualified (`Admin::Foo` in
150
+ # `class Admin::Foo`) or because it is ROOTED (`::Foo`), which names the top-level constant
151
+ # whatever the nesting (#621). A `ConstantReadNode` (bare `Foo` in `module Admin; class Foo`) is
152
+ # qualified against the chain so the name becomes `Admin::Foo`.
153
+ #
154
+ # {#qualified_name_for} already renders a rooted path WITHOUT its leading `::`, so the entry key is
155
+ # the plain spelling every consumer anchors on.
126
156
  def qualified_name_with_enclosing(node, enclosing)
127
157
  return nil unless node.is_a?(Prism::Node)
128
158
 
129
159
  local = qualified_name_for(node)
130
160
  return nil if local.nil?
131
- return local if node.is_a?(Prism::ConstantPathNode) && !node.parent.nil?
161
+ return local if node.is_a?(Prism::ConstantPathNode)
132
162
  return local if enclosing.empty?
133
163
 
134
164
  "#{enclosing.join('::')}::#{local}"
135
165
  end
136
166
 
167
+ # True when the declaration's constant path is explicitly rooted (`class ::Foo`,
168
+ # `class ::Foo::Bar`) — the leftmost segment is a `ConstantPathNode` with no parent. The analyzer
169
+ # carries the same predicate, because both sides must agree on the key a call site looks up.
170
+ def rooted_declaration?(declaration_node)
171
+ node = declaration_node.constant_path
172
+ node = node.parent while node.is_a?(Prism::ConstantPathNode) && node.parent
173
+ node.is_a?(Prism::ConstantPathNode)
174
+ end
175
+
137
176
  def parent_name_for(declaration_node)
138
177
  return nil unless declaration_node.is_a?(Prism::ClassNode) && declaration_node.superclass
139
178
 
@@ -38,9 +38,14 @@ module Rigor
38
38
  freeze
39
39
  end
40
40
 
41
+ # Entries are keyed by the de-rooted constant path (`"AccountsController"`,
42
+ # `"Admin::AccountsController"` — never `"::AccountsController"`; see {ControllerDiscoverer}),
43
+ # while a QUERY may legitimately arrive rooted. The root marker is dropped at every lookup entry
44
+ # point, once, so no caller needs a `find(name) || find("::#{name}")` retry (#621).
45
+ #
41
46
  # @return [Entry, nil]
42
47
  def find(class_name)
43
- @entries[class_name]
48
+ @entries[strip_leading_namespace(class_name)]
44
49
  end
45
50
 
46
51
  # Resolves the **effective** method set for a controller, including methods contributed by
@@ -56,7 +61,7 @@ module Rigor
56
61
  seen_classes = {}
57
62
  seen_modules = {}
58
63
  methods = []
59
- current = class_name
64
+ current = strip_leading_namespace(class_name)
60
65
  while current && !seen_classes[current]
61
66
  seen_classes[current] = true
62
67
  entry = @entries[current]
@@ -87,6 +92,7 @@ module Rigor
87
92
  # may legitimately contribute the filter (either directly, or via its own ancestor chain which
88
93
  # the static analyzer cannot follow), and there's no way to verify.
89
94
  def unresolved_include?(class_name)
95
+ class_name = strip_leading_namespace(class_name)
90
96
  entry = @entries[class_name]
91
97
  return false if entry.nil?
92
98
 
@@ -132,7 +138,7 @@ module Rigor
132
138
  end
133
139
 
134
140
  def known?(class_name)
135
- @entries.key?(class_name)
141
+ @entries.key?(strip_leading_namespace(class_name))
136
142
  end
137
143
 
138
144
  def class_names
@@ -141,6 +147,10 @@ module Rigor
141
147
 
142
148
  private
143
149
 
150
+ # `::AccountsController` → `AccountsController`. The query-side half of the key contract — see
151
+ # {#find}.
152
+ def strip_leading_namespace(name) = name&.delete_prefix("::")
153
+
144
154
  def collect_methods(name, seen, into)
145
155
  return if name.nil?
146
156