rigortype 0.3.6 → 0.3.8

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 (223) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  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 +409 -11
  14. data/docs/handbook/11-sig-gen.md +24 -14
  15. data/docs/manual/02-cli-reference.md +23 -7
  16. data/docs/manual/04-diagnostics.md +1 -1
  17. data/docs/manual/05-inspecting-types.md +20 -8
  18. data/docs/manual/07-plugins.md +4 -0
  19. data/docs/manual/plugins/rigor-actionpack.md +61 -0
  20. data/docs/manual/plugins/rigor-activerecord.md +74 -1
  21. data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
  22. data/docs/manual/plugins/rigor-sidekiq.md +22 -0
  23. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
  24. data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
  25. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
  26. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
  27. data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
  28. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
  29. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
  30. data/lib/rigor/analysis/check_rules.rb +383 -48
  31. data/lib/rigor/analysis/crash_signature.rb +188 -0
  32. data/lib/rigor/analysis/dependency_recorder.rb +18 -3
  33. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  34. data/lib/rigor/analysis/incremental.rb +28 -0
  35. data/lib/rigor/analysis/incremental_session.rb +63 -16
  36. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
  37. data/lib/rigor/analysis/reachability/graph.rb +14 -5
  38. data/lib/rigor/analysis/reachability/scan.rb +7 -3
  39. data/lib/rigor/analysis/result.rb +32 -0
  40. data/lib/rigor/analysis/run_cache_key.rb +41 -1
  41. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +233 -16
  42. data/lib/rigor/analysis/runner/pool_coordinator.rb +266 -47
  43. data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
  44. data/lib/rigor/analysis/runner/run_snapshots.rb +12 -2
  45. data/lib/rigor/analysis/runner.rb +212 -16
  46. data/lib/rigor/analysis/worker_session.rb +37 -3
  47. data/lib/rigor/bleeding_edge.rb +7 -6
  48. data/lib/rigor/builtins/hkt_builtins.rb +45 -6
  49. data/lib/rigor/builtins/imported_refinements.rb +11 -9
  50. data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
  51. data/lib/rigor/builtins/static_return_refinements.rb +20 -1
  52. data/lib/rigor/cache/annotation_location.rb +72 -0
  53. data/lib/rigor/cache/descriptor.rb +42 -2
  54. data/lib/rigor/cache/incremental_snapshot.rb +40 -3
  55. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +70 -8
  56. data/lib/rigor/cache/store.rb +17 -1
  57. data/lib/rigor/cli/coverage_command.rb +23 -18
  58. data/lib/rigor/cli/coverage_mutation.rb +13 -17
  59. data/lib/rigor/cli/coverage_scan.rb +47 -6
  60. data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
  61. data/lib/rigor/cli/fused_protection_report.rb +23 -2
  62. data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
  63. data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
  64. data/lib/rigor/cli/mutation_protection_report.rb +31 -2
  65. data/lib/rigor/cli/sig_gen_command.rb +47 -0
  66. data/lib/rigor/cli/type_of_command.rb +188 -41
  67. data/lib/rigor/cli/type_of_renderer.rb +72 -3
  68. data/lib/rigor/cli/type_scan_command.rb +10 -1
  69. data/lib/rigor/cli.rb +1 -1
  70. data/lib/rigor/configuration.rb +7 -4
  71. data/lib/rigor/effects/plugin_facts.rb +1 -1
  72. data/lib/rigor/effects/scanner.rb +2 -3
  73. data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
  74. data/lib/rigor/environment/failure_slot.rb +28 -0
  75. data/lib/rigor/environment/lockfile_resolver.rb +15 -5
  76. data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
  77. data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
  78. data/lib/rigor/environment/rbs_loader.rb +743 -93
  79. data/lib/rigor/environment.rb +125 -25
  80. data/lib/rigor/inference/acceptance.rb +172 -20
  81. data/lib/rigor/inference/captured_locals.rb +62 -0
  82. data/lib/rigor/inference/content_join.rb +347 -0
  83. data/lib/rigor/inference/def_handle.rb +15 -3
  84. data/lib/rigor/inference/def_node_resolver.rb +62 -3
  85. data/lib/rigor/inference/expression_typer.rb +1020 -141
  86. data/lib/rigor/inference/fork_map.rb +6 -1
  87. data/lib/rigor/inference/hkt_reducer.rb +12 -1
  88. data/lib/rigor/inference/hkt_registry.rb +61 -3
  89. data/lib/rigor/inference/hkt_sugar_translator.rb +87 -0
  90. data/lib/rigor/inference/index_write_widening.rb +48 -0
  91. data/lib/rigor/inference/indexed_narrowing.rb +14 -0
  92. data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
  93. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
  94. data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
  95. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
  96. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
  97. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
  98. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
  99. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
  100. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
  101. data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
  102. data/lib/rigor/inference/method_dispatcher.rb +155 -14
  103. data/lib/rigor/inference/method_parameter_binder.rb +3 -3
  104. data/lib/rigor/inference/mutation_widening.rb +244 -174
  105. data/lib/rigor/inference/narrowing.rb +331 -27
  106. data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
  107. data/lib/rigor/inference/pre_eval_constants.rb +15 -0
  108. data/lib/rigor/inference/precision_scanner.rb +5 -2
  109. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  110. data/lib/rigor/inference/rbs_type_translator.rb +89 -39
  111. data/lib/rigor/inference/scope_indexer.rb +1493 -140
  112. data/lib/rigor/inference/statement_evaluator.rb +276 -91
  113. data/lib/rigor/inference/struct_fold_safety.rb +186 -27
  114. data/lib/rigor/inference/version_guard.rb +229 -0
  115. data/lib/rigor/language_server/selection_range_provider.rb +1 -1
  116. data/lib/rigor/plugin/base.rb +3 -2
  117. data/lib/rigor/plugin/inflector.rb +14 -5
  118. data/lib/rigor/plugin/io_boundary.rb +107 -4
  119. data/lib/rigor/plugin/loader.rb +14 -0
  120. data/lib/rigor/plugin/registry.rb +25 -2
  121. data/lib/rigor/protection/analysis_guard.rb +60 -0
  122. data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
  123. data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
  124. data/lib/rigor/protection/discovery_seed.rb +1 -1
  125. data/lib/rigor/protection/measurement_integrity.rb +36 -0
  126. data/lib/rigor/protection/mutation_scanner.rb +61 -6
  127. data/lib/rigor/protection/mutator.rb +121 -18
  128. data/lib/rigor/rbs_extended/envelope_scanner.rb +8 -7
  129. data/lib/rigor/rbs_extended/hkt_directives.rb +16 -1
  130. data/lib/rigor/rbs_extended/reporter.rb +93 -13
  131. data/lib/rigor/rbs_extended.rb +6 -1
  132. data/lib/rigor/reflection.rb +182 -30
  133. data/lib/rigor/scope/discovery_index.rb +39 -1
  134. data/lib/rigor/scope.rb +385 -4
  135. data/lib/rigor/sig_gen/classification.rb +12 -1
  136. data/lib/rigor/sig_gen/generator.rb +320 -85
  137. data/lib/rigor/sig_gen/renderer.rb +14 -12
  138. data/lib/rigor/sig_gen/writer.rb +3 -0
  139. data/lib/rigor/source/constant_path.rb +79 -2
  140. data/lib/rigor/triage/catalogue.rb +1 -1
  141. data/lib/rigor/type/combinator.rb +10 -0
  142. data/lib/rigor/type/maybe.rb +47 -0
  143. data/lib/rigor/type/refined.rb +1 -2
  144. data/lib/rigor/type/result.rb +53 -0
  145. data/lib/rigor/type.rb +2 -0
  146. data/lib/rigor/version.rb +1 -1
  147. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
  148. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
  149. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
  150. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
  151. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
  152. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
  154. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
  157. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
  158. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
  159. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
  160. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
  161. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
  162. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
  163. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
  164. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
  165. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
  166. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
  167. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
  168. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
  169. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
  170. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
  171. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
  172. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
  173. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
  174. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
  175. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
  176. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
  177. data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
  178. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
  179. data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
  180. data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
  181. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
  182. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
  183. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
  184. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
  185. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
  186. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
  187. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
  188. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
  189. data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
  190. data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
  191. data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
  192. data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
  193. data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
  194. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
  195. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
  196. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  197. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
  198. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
  199. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
  200. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
  201. data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
  202. data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
  203. data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
  204. data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
  205. data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
  206. data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
  207. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
  208. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
  209. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
  210. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
  211. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
  212. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
  213. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
  214. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
  215. data/sig/rigor/analysis/baseline.rbs +1 -1
  216. data/sig/rigor/environment.rbs +9 -0
  217. data/sig/rigor/inference.rbs +4 -1
  218. data/sig/rigor/plugin/io_boundary.rbs +2 -0
  219. data/sig/rigor/reflection.rbs +3 -1
  220. data/sig/rigor/scope.rbs +25 -1
  221. data/sig/rigor/type.rbs +95 -1
  222. data/sig/rigor.rbs +16 -0
  223. metadata +71 -3
@@ -14,8 +14,18 @@ module Rigor
14
14
  # * {.qualified_name_or_nil} is STRICT — a dynamic base anywhere in the chain yields `nil`, so a caller
15
15
  # that statically names constants can treat the path as opaque rather than guessing.
16
16
  #
17
- # A leading `::` (absolute root, `::Foo`) renders as `"Foo"` under both policies. A node that is neither
18
- # a `ConstantReadNode` nor a `ConstantPathNode` yields `nil` under both.
17
+ # A leading `::` (absolute root, `::Foo`) renders as `"Foo"` under both policies: the discovered-constant
18
+ # tables are keyed by un-rooted names, so a rendered `"::Foo"` would miss every one of them. The root
19
+ # marker is therefore carried OUT OF BAND by {.rooted?}, which a caller performing Ruby's lexical constant
20
+ # lookup MUST consult — `::Foo` names the top-level `Foo` and never a lexically nearer shadow
21
+ # ([#614](https://github.com/rigortype/rigor/issues/614)). A node that is neither a `ConstantReadNode`
22
+ # nor a `ConstantPathNode` yields `nil` under both policies.
23
+ #
24
+ # A `class` / `module` HEADER is a constant path too, and it has to consult the same marker — which is why
25
+ # {.declaration_prefix} and {.pushed_nesting} live here rather than in each of the two dozen walks that
26
+ # used to append the rendered name to their own prefix. `::` re-anchors a header at the top level exactly
27
+ # as it re-anchors a reference; reading only the rendered name filed `class ::Foo` inside `module MyApp`
28
+ # under `MyApp::Foo` ([#708](https://github.com/rigortype/rigor/issues/708)).
19
29
  module ConstantPath
20
30
  module_function
21
31
 
@@ -53,6 +63,73 @@ module Rigor
53
63
  "#{parent_name}::#{node.name}"
54
64
  end
55
65
  end
66
+
67
+ # True when the reference is written with a leading `::` — `::Foo`, `::Foo::Bar`. Prism spells the
68
+ # root as a `ConstantPathNode` with a nil `parent`, so the answer lives at the LEFTMOST segment of the
69
+ # chain and the walk has to reach it: `::Foo::Bar` is a path node whose parent is itself a path node.
70
+ # A `ConstantReadNode` is a bare name and is never rooted; a dynamic base (`expr::Bar`) is not a root
71
+ # either, so the recursion stops at any non-constant parent.
72
+ def rooted?(node)
73
+ return false unless node.is_a?(Prism::ConstantPathNode)
74
+
75
+ parent = node.parent
76
+ return true if parent.nil?
77
+
78
+ rooted?(parent)
79
+ end
80
+
81
+ # The qualified prefix a `class` / `module` header contributes to the body it opens, given the prefix of
82
+ # the body the header is WRITTEN in. `class Bar` inside `module Outer` is `Outer::Bar`; the compact
83
+ # `class Outer::Bar` written at the top level is the same name through one keyword instead of two.
84
+ #
85
+ # A ROOTED header RESETS the prefix. `class ::Rooted::Bar` names `Rooted::Bar` wherever it is written —
86
+ # Ruby's `::` re-anchors the header at the top level exactly as it does a reference — so the enclosing
87
+ # prefix is DROPPED rather than kept. Keeping it filed the class under `Outer::Rooted::Bar`, a name no
88
+ # caller writes, and the single-segment `class ::Foo` inside `module MyApp` became `MyApp::Foo`: not
89
+ # merely mis-keyed but undiscoverable, so a call on `Foo` typed opaque and reported nothing at all
90
+ # ([#708](https://github.com/rigortype/rigor/issues/708), [#638](https://github.com/rigortype/rigor/issues/638)).
91
+ #
92
+ # nil when the header names no constant — the same refusal {.qualified_name} makes, propagated so a
93
+ # caller keeps its own prefix rather than qualifying under a name it could not render.
94
+ def declaration_prefix(outer_prefix, constant_path)
95
+ name = qualified_name(constant_path)
96
+ return nil if name.nil?
97
+
98
+ rooted?(constant_path) ? [name] : outer_prefix + [name]
99
+ end
100
+
101
+ # Ruby's `Module.nesting` inside the body a `class` / `module` header opens, given the nesting of the
102
+ # body the header is written in. ONE entry per declaration keyword, qualified against the entry already
103
+ # on top, so a compact `class A::B` contributes the single `"A::B"` where `module A; class B` contributes
104
+ # two — the whole difference between the two spellings, and unrecoverable from the `"A::B"` they share
105
+ # afterwards ([#652](https://github.com/rigortype/rigor/issues/652)). This is the ONLY thing that pushes
106
+ # an entry: a `def`, a block, and a `class << expr` body each inherit the chain unchanged, because none
107
+ # of them pushes a cref in Ruby.
108
+ #
109
+ # A ROOTED header pushes its name UNQUALIFIED and leaves the enclosing entries beneath it, which is the
110
+ # one place this differs from {.declaration_prefix}: Ruby's nesting inside `class ::Rooted::Bar` written
111
+ # in `module Outer` is `[Rooted::Bar, Outer]`, so the class's own name resets while `Outer` stays on the
112
+ # ladder as a live rung ([#708](https://github.com/rigortype/rigor/issues/708)).
113
+ #
114
+ # The header is rendered by the LENIENT {.qualified_name}, which is total over every header Ruby parses —
115
+ # `class` and `module` require a constant path, so the only dynamic base the grammar admits is `self`, and
116
+ # `class self::Thing` inside `module Outer` renders `"Thing"` and records `["Outer::Thing", "Outer"]`,
117
+ # which is what Ruby's nesting is there. So there is no unnameable-header case to refuse, and the `nil`
118
+ # guards below are reachable only for a non-constant node (which a `ClassNode` / `ModuleNode` header
119
+ # never is) and for the nil chain a caller may thread in.
120
+ #
121
+ # Both nesting walks call THIS function — `Inference::StatementEvaluator`'s declaration walk and
122
+ # `Inference::ScopeIndexer`'s def-node walk — so a callee re-walk answers what the body's own walk
123
+ # answers by construction rather than by two mirrored implementations staying in step.
124
+ def pushed_nesting(nesting, constant_path)
125
+ return nil if nesting.nil?
126
+
127
+ name = qualified_name(constant_path)
128
+ return nil if name.nil?
129
+
130
+ outer = rooted?(constant_path) ? nil : nesting.first
131
+ [outer ? "#{outer}::#{name}" : name, *nesting].freeze
132
+ end
56
133
  end
57
134
  end
58
135
  end
@@ -327,7 +327,7 @@ module Rigor
327
327
  parser == :toplevel ? d.method_name : parse_undefined_method(d)&.fetch(:method)
328
328
  end
329
329
  names.tally.sort_by { |method, count| [-count, method] }
330
- .first(limit).map { |method, count| "#{method}×#{count}" }.join(" ")
330
+ .first(limit).map { |method, count| "#{method}×#{count}" }.join(" ")
331
331
  end
332
332
 
333
333
  def rule_of(diag)
@@ -369,6 +369,16 @@ module Rigor
369
369
  StructInstance.new(members, class_name)
370
370
  end
371
371
 
372
+ # ADR-3 amendment (ADR-20 slice 4) — generic Result[T, E] carrier constructor.
373
+ def result_of(ok_type, err_type)
374
+ Result.new(ok_type, err_type)
375
+ end
376
+
377
+ # ADR-3 amendment (ADR-20 slice 4) — generic Maybe[T] carrier constructor.
378
+ def maybe_of(value_type)
379
+ Maybe.new(value_type)
380
+ end
381
+
372
382
  # Normalized union. Flattens nested Unions, deduplicates structurally equal members, drops Bot, and
373
383
  # collapses 0/1-member results.
374
384
  def union(*types)
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../trinary"
4
+ require_relative "../value_semantics"
5
+ require_relative "acceptance_router"
6
+ require_relative "plain_lattice"
7
+
8
+ module Rigor
9
+ module Type
10
+ # A generic Maybe carrier representing an optional value that either contains a value of type
11
+ # `value_type` or nothing.
12
+ #
13
+ # Models `Maybe[T]` (e.g. `Dry::Monads::Maybe[T]`).
14
+ #
15
+ # Subtyping is covariant: `Maybe[T1] <: Maybe[T2]` iff `T1 <: T2`.
16
+ class Maybe
17
+ attr_reader :value_type
18
+
19
+ def initialize(value_type)
20
+ raise ArgumentError, "value_type must not be nil" if value_type.nil?
21
+
22
+ @value_type = value_type
23
+ freeze
24
+ end
25
+
26
+ def describe(verbosity = :short)
27
+ "Maybe[#{value_type.describe(verbosity)}]"
28
+ end
29
+
30
+ def erase_to_rbs
31
+ "::Dry::Monads::Maybe[#{value_type.erase_to_rbs}]"
32
+ end
33
+
34
+ include Rigor::Type::PlainLattice
35
+
36
+ include Rigor::Type::AcceptanceRouter
37
+
38
+ include Rigor::ValueSemantics
39
+
40
+ value_fields :value_type
41
+
42
+ def inspect
43
+ "#<Rigor::Type::Maybe #{describe(:short)}>"
44
+ end
45
+ end
46
+ end
47
+ end
@@ -77,14 +77,13 @@ module Rigor
77
77
  # Recognises a Ruby value against this carrier's predicate. The trinary return is intentional:
78
78
  # `true` / `false` when the predicate registry decides, `nil` when the predicate is unknown to the
79
79
  # registry, so callers (today {Inference::Acceptance}) can fall through to gradual-mode `:maybe`.
80
- # rubocop:disable Style/ReturnNilInPredicateMethodDefinition
80
+ # rubocop:disable-next Style/ReturnNilInPredicateMethodDefinition
81
81
  def matches?(value)
82
82
  recogniser = PREDICATES[predicate_id]
83
83
  return nil if recogniser.nil?
84
84
 
85
85
  !!recogniser.call(value)
86
86
  end
87
- # rubocop:enable Style/ReturnNilInPredicateMethodDefinition
88
87
 
89
88
  # `predicate_id => recogniser` table. The recogniser is called with a Ruby value (typically the
90
89
  # inner `value` of a `Constant`) and returns truthy when the value satisfies the predicate. The
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../trinary"
4
+ require_relative "../value_semantics"
5
+ require_relative "acceptance_router"
6
+ require_relative "plain_lattice"
7
+
8
+ module Rigor
9
+ module Type
10
+ # A generic Result carrier representing a computation that either succeeded with a value of type
11
+ # `ok_type` or failed with an error of type `err_type`.
12
+ #
13
+ # Models `Result[T, E]` (e.g. `Dry::Monads::Result[T, E]`).
14
+ #
15
+ # Subtyping is covariant in both positions: `Result[T1, E1] <: Result[T2, E2]` iff `T1 <: T2` and
16
+ # `E1 <: E2`.
17
+ class Result
18
+ attr_reader :ok_type, :err_type
19
+
20
+ alias success_type ok_type
21
+ alias failure_type err_type
22
+
23
+ def initialize(ok_type, err_type)
24
+ raise ArgumentError, "ok_type must not be nil" if ok_type.nil?
25
+ raise ArgumentError, "err_type must not be nil" if err_type.nil?
26
+
27
+ @ok_type = ok_type
28
+ @err_type = err_type
29
+ freeze
30
+ end
31
+
32
+ def describe(verbosity = :short)
33
+ "Result[#{ok_type.describe(verbosity)}, #{err_type.describe(verbosity)}]"
34
+ end
35
+
36
+ def erase_to_rbs
37
+ "::Dry::Monads::Result[#{ok_type.erase_to_rbs}, #{err_type.erase_to_rbs}]"
38
+ end
39
+
40
+ include Rigor::Type::PlainLattice
41
+
42
+ include Rigor::Type::AcceptanceRouter
43
+
44
+ include Rigor::ValueSemantics
45
+
46
+ value_fields :ok_type, :err_type
47
+
48
+ def inspect
49
+ "#<Rigor::Type::Result #{describe(:short)}>"
50
+ end
51
+ end
52
+ end
53
+ end
data/lib/rigor/type.rb CHANGED
@@ -28,6 +28,8 @@ require_relative "type/difference"
28
28
  require_relative "type/refined"
29
29
  require_relative "type/intersection"
30
30
  require_relative "type/app"
31
+ require_relative "type/result"
32
+ require_relative "type/maybe"
31
33
  require_relative "type/bound_method"
32
34
  require_relative "type/accepts_result"
33
35
  require_relative "type/combinator"
data/lib/rigor/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rigor
4
- VERSION = "0.3.6"
4
+ VERSION = "0.3.8"
5
5
  end
@@ -56,7 +56,9 @@ module Rigor
56
56
  # not ActionCable — pass through silently to avoid flagging unrelated `broadcast_to` methods.
57
57
  return [] unless class_name.end_with?("Channel")
58
58
 
59
- entry = channel_index.find(class_name) || channel_index.find("::#{class_name}")
59
+ # `ChannelIndex#find` de-roots the query itself (#621) a `::ChatChannel.broadcast_to` receiver
60
+ # and a plain `ChatChannel.broadcast_to` reach the same entry with no retry arm here.
61
+ entry = channel_index.find(class_name)
60
62
  return [unknown_channel_violation(class_name, channel_index)] if entry.nil?
61
63
 
62
64
  [broadcast_target_info(entry)]
@@ -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