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
@@ -6,12 +6,14 @@ require_relative "railties/effects"
6
6
 
7
7
  module Rigor
8
8
  module Plugin
9
- # rigor-railties — the Rails **framework core** as an effect vocabulary.
9
+ # rigor-railties — the Rails **framework core**: its effect vocabulary, and the type of the four
10
+ # `Rails.` singleton readers.
10
11
  #
11
- # Unlike its siblings this plugin runs no analysis: it emits no diagnostic, types no receiver and
12
- # declares no producer. What it contributes is the part of ADR-103's Rails layer that belongs to no
13
- # single component gem`Rails.cache`, `Rails.logger`, `Rails.env`, the application configuration and
14
- # the credentials — plus the `rails` entry-point preset that `effects.snapshot.reach:` adopts by name.
12
+ # It emits no diagnostic and declares no producer. What it contributes is the part of ADR-103's Rails
13
+ # layer that belongs to no single component gem `Rails.cache`, `Rails.logger`, `Rails.env`, the
14
+ # application configuration and the credentials plus the `rails` entry-point preset that
15
+ # `effects.snapshot.reach:` adopts by name, plus the lenient nominals those same readers return
16
+ # ({RAILS_SINGLETON_READER_TYPES}).
15
17
  #
16
18
  # ## Why it is its own plugin
17
19
  #
@@ -38,15 +40,23 @@ module Rigor
38
40
  #
39
41
  # ## Cost when effects are off
40
42
  #
41
- # None beyond the load. The manifest's effect fields are frozen arrays; nothing reads them unless the
42
- # project has an `effects:` block ({Rigor::Plugin::Registry#effect_contributions} is lazy), and the
43
- # plugin implements no per-call contribution path at all, so `ContributionIndex` never consults it.
43
+ # The manifest's effect fields are frozen arrays; nothing reads them unless the project has an
44
+ # `effects:` block ({Rigor::Plugin::Registry#effect_contributions} is lazy). The reader typing costs one
45
+ # `ContributionIndex` name-gate probe per dispatch of a method actually named `logger` / `cache` /
46
+ # `configuration` / `application` (ADR-52 WD1 compiles the `methods:` gate into the registry), and the
47
+ # block declines on the first check for anything whose receiver is not the `Rails` constant. The reader
48
+ # gate ({#project_defined_reader?}) runs only after that syntactic gate passes, so it is confined to
49
+ # `Rails.`-shaped sites. It runs identically for both spellings — bare `Rails` and root-qualified
50
+ # `::Rails` both resolve through `Scope#type_of` ({#resolved_rails_constant_name}), which the engine's
51
+ # own rooted constant resolution (#614) answers correctly on its own.
44
52
  class Railties < Rigor::Plugin::Base
45
53
  manifest(
46
54
  id: "railties",
47
- version: "0.1.0",
48
- description: "The Rails framework-core effect vocabulary: cache, logger, environment, " \
49
- "configuration, credentials, and the `rails` entry-point preset.",
55
+ # Bumped 2026-09-01 (#534 item 2) — the four `Rails.` singleton readers now return lenient
56
+ # nominals instead of `Dynamic[top]`.
57
+ version: "0.2.0",
58
+ description: "The Rails framework-core effect vocabulary and singleton-reader types: cache, " \
59
+ "logger, environment, configuration, credentials, and the `rails` entry-point preset.",
50
60
  # ADR-103 WD2 — rigor-railties models Rails itself, so it opens the `rails.*` root. Granted only
51
61
  # because the engine bundles this plugin ({Rigor::Plugin::FirstParty}); the same declaration from a
52
62
  # third-party gem would open `railties.*` and earn a warning.
@@ -55,6 +65,141 @@ module Rigor
55
65
  effect_attributions: Effects.attributions,
56
66
  effect_entry_points: Effects.entry_points
57
67
  )
68
+
69
+ # Issue #534 item 2 — the four `Rails.` singleton readers, measured on the 2026-09-01 corpus sweep as
70
+ # 261 opaque sites on mastodon alone (`configuration` 108, `cache` 81, `logger` 50, `application` 22).
71
+ # Every one of them typed `Dynamic[top]`, so the whole `Rails.cache.fetch(...)` /
72
+ # `Rails.application.config...` surface was unprotected.
73
+ #
74
+ # Each value is a **lenient nominal**: a real Rails class name for which Rigor ships NO RBS. That is
75
+ # the same construction rigor-actionpack's `REQUEST_CONTEXT_READER_TYPES` uses, and it is chosen for
76
+ # the same reason — the site becomes a *concrete* receiver (`coverage --protection` counts it, the
77
+ # dispatch resolves against a named class) while the method surface stays engine-lenient, because
78
+ # `call.undefined-method` and the argument / arity rules all bail at
79
+ # `Rigor::Reflection.rbs_class_known?`. Shipping a partial RBS for any of these would invert that:
80
+ # a declared class drops every member its RBS omits, and `Rails.logger.tagged { }` becomes a false
81
+ # positive.
82
+ #
83
+ # ## Why these names, and why not `::Logger`
84
+ #
85
+ # - `logger` → **`ActiveSupport::BroadcastLogger`**, not `::Logger`. `::Logger` is the name the issue
86
+ # suggested and it is the one name here that must NOT be used: it is a stdlib class, so Rigor knows
87
+ # its RBS, so the leniency argument above evaporates and every ActiveSupport extension of the logger
88
+ # — `tagged`, `silence`, `broadcast_to`, `local_level=` — becomes `call.undefined-method` on working
89
+ # Rails code. Measured, not assumed (spec: "does not fire undefined-method on the ActiveSupport
90
+ # logger surface"). `BroadcastLogger` is what `Rails.logger` actually returns on Rails 7.1+, which
91
+ # is every Rails still supported; on 7.0 and earlier it was an `ActiveSupport::Logger`, and since
92
+ # both names are RBS-less the difference is invisible to every rule — the accurate modern name is
93
+ # preferred only so a project that later authors its own `sig/` for it gets the right class.
94
+ # - `cache` → `ActiveSupport::Cache::Store`, the declared return of `Rails.cache` and the base class
95
+ # of every store implementation.
96
+ # - `application` → `Rails::Application`, the singleton the railtie builds.
97
+ # - `configuration` → `Rails::Application::Configuration`. This is the entry whose *leniency* matters
98
+ # most rather than its accuracy: `config.x_service_url` works through `method_missing` into
99
+ # `@custom`, so no RBS could ever be complete for it, and the RBS-less nominal is the only answer
100
+ # that types the receiver without claiming to know the members.
101
+ #
102
+ # ## The nil question
103
+ #
104
+ # All four are non-nil nominals, and a non-nil type licenses the flow rules to fold `if Rails.logger`
105
+ # to always-truthy (the hazard that kept `Parameters#[]` out of rigor-actionpack's table, #578). The
106
+ # difference is that here the non-nil claim is TRUE: inside a booted application — the only state in
107
+ # which `app/` code runs — the railtie initializers have assigned all four, and `Rails.logger` is nil
108
+ # only in a bare `require "rails"` process. The paired specs pin both halves: no `undefined-method` on
109
+ # the extension surface, and no fold on a `Rails.cache.fetch { }` guard shape.
110
+ RAILS_SINGLETON_READER_TYPES = {
111
+ logger: "ActiveSupport::BroadcastLogger",
112
+ cache: "ActiveSupport::Cache::Store",
113
+ configuration: "Rails::Application::Configuration",
114
+ application: "Rails::Application"
115
+ }.freeze
116
+
117
+ # `logger` / `cache` / `configuration` / `application` are ordinary method names that any project may
118
+ # define on its own objects, so the receiver gate is syntactic and exact: the literal `Rails` constant,
119
+ # bare or root-qualified (`::Rails`). A `receivers:` gate cannot express it — the `Rails` constant is
120
+ # unresolved in a project with no Rails RBS, so the receiver type is `Dynamic` and
121
+ # `dynamic_return_receiver_class_name` yields nil. The one thing the type side CAN see is the opposite
122
+ # case — the project defining this very reader on its own `Rails` — and that is the second gate
123
+ # ({#project_defined_reader?}).
124
+ dynamic_return methods: RAILS_SINGLETON_READER_TYPES.keys do |call_node, scope|
125
+ next nil unless call_node.is_a?(Prism::CallNode)
126
+ next nil unless call_node.arguments.nil? # `Rails.logger`, not `Rails.logger(x)`
127
+ next nil unless call_node.block.nil?
128
+ next nil unless rails_constant_receiver?(call_node.receiver)
129
+ next nil if project_defined_reader?(call_node, scope)
130
+
131
+ class_name = RAILS_SINGLETON_READER_TYPES[call_node.name]
132
+ next nil if class_name.nil?
133
+
134
+ Rigor::Type::Combinator.nominal_of(class_name)
135
+ end
136
+
137
+ # ADR-88 WD1 — the reader types are a static table and the effect rows are built from the manifest, so
138
+ # the plugin holds no state of its own that a fingerprint would have to observe, and a project that
139
+ # enables it stays incremental-capable. The one project-derived input is the {#project_defined_reader?}
140
+ # gate's `discovered_methods` read, and that does NOT need to ride this sentinel: it goes through
141
+ # `Rigor::Reflection.discovered_method?`, which records the ADR-46 negative dependency on a miss, so a
142
+ # warm `--incremental` re-check invalidates the gated sites the same way the engine's own cross-file
143
+ # method reads are invalidated — per consumer and per symbol, not by refusing the snapshot wholesale.
144
+ def incremental_state_fingerprint
145
+ "static-rails-readers"
146
+ end
147
+
148
+ private
149
+
150
+ # True for the `Rails` constant written as a receiver — `Rails.logger` (`ConstantReadNode`) and
151
+ # `::Rails.logger` (a root-qualified `ConstantPathNode`). A nested `Foo::Rails` or `::Foo::Rails` is
152
+ # deliberately NOT matched: it is a different constant, and matching it would type someone else's
153
+ # `logger`. `Source::ConstantPath.qualified_name_or_nil` already renders both accepted spellings down
154
+ # to the bare `"Rails"` string, and any multi-segment path down to its full dotted name, so the single
155
+ # equality check below is the whole gate (#626).
156
+ def rails_constant_receiver?(receiver)
157
+ Rigor::Source::ConstantPath.qualified_name_or_nil(receiver) == "Rails"
158
+ end
159
+
160
+ # #588 — the syntactic gate cannot tell the framework's `Rails` from a `Rails` the project declares
161
+ # itself, and when the project declares `def self.logger` on it, retyping the site to
162
+ # `BroadcastLogger` buries the project's own answer for its own constant. Listing this plugin beside
163
+ # such a definition is a self-contradictory configuration (activation is the `plugins:` list alone —
164
+ # no lockfile gate notices the framework is absent), but the project's definition is the honest type.
165
+ #
166
+ # The gate is deliberately the READER, not the constant. "The project has a `Rails` constant" is far
167
+ # too wide: a real Rails app reopens `module Rails` to hang an initializer or an engine extension off
168
+ # it (mastodon's `lib/rails/engine_extensions.rb`), and a compact `class Rails::HealthController`
169
+ # synthesizes the namespace on its own (#528) — in both shapes NOTHING answers `Rails.logger` but the
170
+ # framework, so declining there would only drop the site back to `Dynamic[top]` and give up #534's
171
+ # 261 mastodon sites for nothing. Declining only when the project defines the very method being
172
+ # called keeps the reopen typed and still yields the constant to whoever owns it.
173
+ #
174
+ # Diagnostics-silent either way when the project's definition exists (both nominals are RBS-less), so
175
+ # this half is precision-honesty rather than a false-positive fix; the reader-not-constant narrowing
176
+ # is what keeps it from being a precision regression.
177
+ def project_defined_reader?(call_node, scope)
178
+ owner = resolved_rails_constant_name(call_node.receiver, scope)
179
+ return false if owner.nil?
180
+
181
+ Rigor::Reflection.discovered_method?(owner, call_node.name, kind: :singleton, scope: scope)
182
+ end
183
+
184
+ # The constant name this receiver ACTUALLY resolves to, or nil when it resolves to nothing the
185
+ # project declares (the ordinary case: the framework's `Rails` is unresolved without a Rails RBS, so
186
+ # it types `Dynamic` and no name comes back).
187
+ #
188
+ # Both spellings go through `Scope#type_of` — Ruby's lexical walk, `Module.nesting` innermost-first,
189
+ # then project ancestors, then top level ({Rigor::Reflection.resolve_constant_type}) — which is the
190
+ # engine's single implementation and not a second one here. A root-qualified `::Rails` used to be
191
+ # answered directly, bypassing the engine: `Source::ConstantPath.qualified_name_or_nil` renders a
192
+ # parent-less `ConstantPathNode` as the bare `"Rails"`, and before #614 `Scope#type_of` had no way to
193
+ # tell that path was rooted, so it walked LEXICALLY and answered `MyApp::Rails` for a `::Rails` written
194
+ # inside `module MyApp`. #614 taught the engine to carry rootedness alongside the name
195
+ # (`Reflection.resolve_constant_type(name, scope:, rooted:)`), so `::Rails` now resolves to the
196
+ # top-level constant through the very same call, and this method no longer needs its own copy (#626).
197
+ def resolved_rails_constant_name(receiver, scope)
198
+ return nil if scope.nil?
199
+
200
+ receiver_type = scope.type_of(receiver)
201
+ receiver_type.class_name if receiver_type.is_a?(Rigor::Type::Singleton)
202
+ end
58
203
  end
59
204
 
60
205
  Rigor::Plugin.register(Railties)
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor"
4
+ require "rigor-ffi"
5
+
6
+ module Rigor
7
+ module Plugin
8
+ class RbNaCl < Base
9
+ manifest(
10
+ id: "rbnacl",
11
+ version: "0.1.0",
12
+ description: "Rigor type support for RbNaCl libsodium bindings"
13
+ )
14
+
15
+ # Issue 2 & 5: use robust positional binding extractor and preserve module context
16
+ ffi_binding_recognizer :sodium_function do |node, module_name|
17
+ next [] unless node.is_a?(Prism::CallNode) && node.name == :sodium_function
18
+
19
+ receiver = module_name && !module_name.empty? ? module_name : "RbNaCl"
20
+ fact = FFI::Analyzer.extract_function_binding(
21
+ node,
22
+ method_name: :sodium_function,
23
+ module_name: receiver,
24
+ default_return: :int
25
+ )
26
+ fact ? [fact] : []
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rigor/plugin/rbnacl"
@@ -0,0 +1,22 @@
1
+ module RbNaCl
2
+ class SecretBox
3
+ def initialize: (String key) -> void
4
+ def encrypt: (String nonce, String message) -> String
5
+ def decrypt: (String nonce, String ciphertext) -> String
6
+ end
7
+
8
+ module Signatures
9
+ module Ed25519
10
+ class SigningKey
11
+ def initialize: (String seed) -> void
12
+ def sign: (String message) -> String
13
+ def verify_key: () -> VerifyKey
14
+ end
15
+
16
+ class VerifyKey
17
+ def initialize: (String key) -> void
18
+ def verify: (String signature, String message) -> bool
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/plugin"
4
+ require "rigor-ffi"
5
+
6
+ module Rigor
7
+ module Plugin
8
+ class SassC < Base
9
+ manifest(
10
+ id: "sassc",
11
+ version: "0.1.0",
12
+ description: "Models SassC native bindings, nominal pointer typedefs, and struct unions.",
13
+ signature_paths: ["sig"]
14
+ )
15
+
16
+ # Recognizer for sass prefix-stripped attach_function calls
17
+ ffi_binding_recognizer :sassc_function do |node, module_name|
18
+ next [] unless node.is_a?(Prism::CallNode) && node.name == :attach_function
19
+
20
+ args = node.arguments&.arguments || []
21
+ next [] if args.empty?
22
+
23
+ ruby_name = FFI::Analyzer.extract_symbol(args[0])
24
+ next [] if ruby_name.nil?
25
+
26
+ mod = module_name && !module_name.empty? ? module_name : "SassC::Native"
27
+
28
+ # If ruby_name starts with sass_, also register stripped version
29
+ c_name = ruby_name
30
+ stripped_name = ruby_name.to_s.sub(/^sass_/, "").to_sym
31
+
32
+ fact = FFI::Analyzer.extract_attach_function(node, module_name: mod)
33
+ next [] if fact.nil?
34
+
35
+ if stripped_name == ruby_name
36
+ [fact]
37
+ else
38
+ [
39
+ fact,
40
+ FFI::AttachFunctionFact.new(
41
+ ruby_name: stripped_name,
42
+ c_name: c_name,
43
+ arg_types: fact.arg_types,
44
+ return_type: fact.return_type,
45
+ node: node,
46
+ receiver_name: mod
47
+ )
48
+ ]
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ Rigor::Plugin.register(Rigor::Plugin::SassC)
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rigor/plugin/sassc"
@@ -0,0 +1,24 @@
1
+ module SassC
2
+ class Engine
3
+ def initialize: (String template, ?Hash[Symbol, untyped] options) -> void
4
+ def render: () -> String
5
+ end
6
+
7
+ module Native
8
+ class SassDataContextPtr < FFI::Pointer
9
+ end
10
+
11
+ class SassOptionsPtr < FFI::Pointer
12
+ end
13
+
14
+ class SassContextPtr < FFI::Pointer
15
+ end
16
+
17
+ def self.make_data_context: (String src) -> SassDataContextPtr
18
+ def self.compile_data_context: (SassDataContextPtr dctx) -> Integer
19
+ def self.data_context_get_options: (SassDataContextPtr dctx) -> SassOptionsPtr
20
+ def self.data_context_get_context: (SassDataContextPtr dctx) -> SassContextPtr
21
+ def self.context_get_output_string: (SassContextPtr ctx) -> String
22
+ def self.delete_data_context: (SassDataContextPtr dctx) -> void
23
+ end
24
+ end
@@ -30,8 +30,8 @@ module Rigor
30
30
  # have_db_column(:col)
31
31
  # have_db_index(:col)
32
32
  #
33
- # All look up `:col` against the model's columns (`Entry#column?`). Unknown columns fire
34
- # `unknown-column`.
33
+ # All look up `:col` against the model's `columns:` (the published `:model_index` fact's flat
34
+ # `Array<String>` per model — see `Analyzer#column_names`). Unknown columns fire `unknown-column`.
35
35
  #
36
36
  # ### Association matchers
37
37
  #
@@ -74,7 +74,10 @@ module Rigor
74
74
 
75
75
  # @param path [String]
76
76
  # @param root [Prism::Node]
77
- # @param model_index [Object, nil] the `:model_index` fact value. When nil the analyzer falls silent.
77
+ # @param model_index [Hash{String => Hash}, nil] the `:model_index` fact value the flat
78
+ # `class_name => { table:, columns:, associations:, ... }` Hash `rigor-activerecord` publishes
79
+ # (ADR-9's "the value is data, not objects" contract; see `Activerecord#index_to_published_hash`).
80
+ # When nil the analyzer falls silent.
78
81
  # @return [Array<Diagnostic>]
79
82
  # The matcher violations for a single call node (0..1), or `[]` when it is not a shoulda matcher,
80
83
  # sits outside any `describe <ModelConst>` block, or the model is unknown. ADR-37: the engine owns
@@ -84,7 +87,7 @@ module Rigor
84
87
  #
85
88
  # @param matcher_call [Prism::Node]
86
89
  # @param ancestors [Array<Prism::Node>]
87
- # @param model_index [Object, nil]
90
+ # @param model_index [Hash{String => Hash}, nil]
88
91
  # @return [Array<Violation>]
89
92
  def violations_for(matcher_call:, ancestors:, model_index:)
90
93
  return [] if model_index.nil?
@@ -93,7 +96,10 @@ module Rigor
93
96
  anchor = anchor_for(ancestors)
94
97
  return [] if anchor.nil?
95
98
 
96
- entry = model_index.find(anchor)
99
+ # Keyed Hash lookup — NOT `ModelIndex#find`. The published fact is a plain Hash (`class_name =>
100
+ # entry Hash`), never the `ModelIndex` object `rigor-activerecord` builds internally (issue #573:
101
+ # `Hash#find(anchor)` with no block returns an Enumerator, not the entry).
102
+ entry = model_index[anchor]
97
103
  return [] if entry.nil?
98
104
 
99
105
  violation = diagnostic_for(matcher_call, anchor, entry)
@@ -137,6 +143,9 @@ module Rigor
137
143
  render_constant_path(arg)
138
144
  end
139
145
 
146
+ # The anchor is the constant path WITHOUT a leading `::` — `describe ::User` anchors on `"User"`,
147
+ # the same spelling `describe User` produces — because the `:model_index` fact is keyed by the
148
+ # de-rooted class name however the model was declared (#583; `Activerecord#index_to_published_hash`).
140
149
  def render_constant_path(node)
141
150
  case node
142
151
  when Prism::ConstantReadNode then node.name.to_s
@@ -148,7 +157,7 @@ module Rigor
148
157
  current = current.parent
149
158
  end
150
159
  case current
151
- when nil then "::#{parts.join('::')}"
160
+ when nil then parts.join("::")
152
161
  when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
153
162
  end
154
163
  end
@@ -170,19 +179,30 @@ module Rigor
170
179
  end
171
180
  end
172
181
 
182
+ # `entry` is the published fact's per-model Hash: `{ table:, columns:, associations:, ... }`.
183
+ # `columns:` is a flat `Array<String>` (`Activerecord#index_to_published_hash`); `associations:` is
184
+ # `Array<Hash>` with String `:name`, Symbol `:kind` (`:singular` | `:collection`), `:nullable`,
185
+ # `:polymorphic`, and a `:target` that is a String except on polymorphic rows, where it is nil —
186
+ # the row shape `ModelDiscoverer#build_association_row` publishes. Only `:name` and `:kind` are
187
+ # read here.
188
+ def column_names(entry) = entry[:columns] || []
189
+ def association_rows(entry) = entry[:associations] || []
190
+ def find_association(entry, name) = association_rows(entry).find { |a| a[:name] == name.to_s }
191
+
173
192
  def column_violation(matcher_call, anchor, entry, column_name)
174
- return nil if entry.column?(column_name)
193
+ return nil if column_names(entry).include?(column_name.to_s)
175
194
 
176
195
  Violation.new(
177
196
  rule: "unknown-column",
178
197
  message: "#{matcher_call.name}(:#{column_name}) — no column `#{column_name}` on " \
179
- "#{anchor} (columns: #{entry.column_names.sort.join(', ')})"
198
+ "#{anchor} (columns: #{column_names(entry).sort.join(', ')})"
180
199
  )
181
200
  end
182
201
 
183
202
  def association_violation(matcher_call, anchor, entry, assoc_name, expected_kind:)
184
- if entry.association?(assoc_name)
185
- actual = entry.association(assoc_name)[:kind]
203
+ association = find_association(entry, assoc_name)
204
+ if association
205
+ actual = association[:kind]
186
206
  return nil if actual == expected_kind
187
207
 
188
208
  Violation.new(
@@ -191,10 +211,11 @@ module Rigor
191
211
  "a #{actual} association; #{matcher_call.name} expects #{expected_kind}"
192
212
  )
193
213
  else
214
+ known = association_rows(entry).map { |a| a[:name] }
194
215
  Violation.new(
195
216
  rule: "unknown-association",
196
217
  message: "#{matcher_call.name}(:#{assoc_name}) — no association `#{assoc_name}` on " \
197
- "#{anchor} (associations: #{entry.association_names.sort.join(', ')})"
218
+ "#{anchor} (associations: #{known.sort.join(', ')})"
198
219
  )
199
220
  end
200
221
  end
@@ -44,7 +44,9 @@ module Rigor
44
44
  class_name = constant_receiver_name(call_node.receiver)
45
45
  return [] if class_name.nil?
46
46
 
47
- entry = worker_index.find(class_name) || worker_index.find("::#{class_name}")
47
+ # `WorkerIndex#find` de-roots the query itself (#621) a `::WelcomeWorker.perform_async` receiver
48
+ # and a plain `WelcomeWorker.perform_async` reach the same entry with no retry arm here.
49
+ entry = worker_index.find(class_name)
48
50
  return [] if entry.nil?
49
51
 
50
52
  violations = [info_violation(call_node, entry)]
@@ -68,7 +68,10 @@ module Rigor
68
68
 
69
69
  def load_document(path)
70
70
  absolute = File.expand_path(path.to_s)
71
- return nil unless File.file?(absolute)
71
+ # ADR-45 WD1b (#613) — the probe goes through the boundary so "there is no `config/sidekiq.yml`"
72
+ # is a recorded dependency; `File.file?` here left the warm run serving the no-schedule answer
73
+ # after the file appeared.
74
+ return nil unless @io_boundary.file?(absolute)
72
75
 
73
76
  contents = read_safely(absolute)
74
77
  contents && parse_safely(contents)
@@ -27,26 +27,60 @@ module Rigor
27
27
  def initialize(io_boundary:, search_paths:, marker_modules:)
28
28
  @io_boundary = io_boundary
29
29
  @search_paths = search_paths
30
- @marker_modules = marker_modules.to_set
30
+ # De-rooted so the marker match is by exact spelling on both sides: a worker written
31
+ # `include ::Sidekiq::Job` — and a configured `worker_marker_modules: ["::Sidekiq::Job"]` —
32
+ # would otherwise never match anything the other side can render.
33
+ @marker_modules = marker_modules.to_set { |name| strip_root(name.to_s) }
31
34
  end
32
35
 
33
36
  # @return [WorkerIndex]
34
37
  def discover
35
- entries = []
38
+ candidates = []
36
39
  ruby_files_under(@search_paths).each do |path|
37
40
  contents = read_safely(path)
38
41
  next if contents.nil?
39
42
 
40
43
  tree = Prism.parse(contents).value
41
44
  walk_for_workers(tree, []) do |class_name, perform_def|
42
- entries << build_entry(class_name, perform_def)
45
+ candidates << [class_name, perform_def]
43
46
  end
44
47
  end
45
- WorkerIndex.new(entries)
48
+ WorkerIndex.new(merge_redeclarations(candidates))
46
49
  end
47
50
 
48
51
  private
49
52
 
53
+ # Collapses the candidates that declare the SAME constant into one entry, so {WorkerIndex} — which
54
+ # keys its rows by `class_name` — receives at most one per worker.
55
+ #
56
+ # A worker is routinely declared more than once: the real class in `app/workers/welcome_worker.rb`
57
+ # and a second file reopening it (`class ::WelcomeWorker`, `class WelcomeWorker`) to add a method.
58
+ # Taking the last candidate dropped the real `#perform` envelope whenever the reopen sorted later in
59
+ # the glob — the reopen carries no `#perform`, so the worker silently became any-arity and every
60
+ # `perform_async` went unchecked. The rows are merged instead:
61
+ #
62
+ # - The declarations that actually spell `def perform` decide the envelope; a reopen that does not
63
+ # contributes nothing rather than erasing it.
64
+ # - When two declarations BOTH spell it with different shapes, the envelope WIDENS (min of the mins,
65
+ # max of the maxes). The glob order is not the load order, so pinning either shape would surface
66
+ # `wrong-arity` on a call the definition Ruby actually loads accepts — ADR-5: a missed narrow case
67
+ # costs precision, a wrong one costs correct code.
68
+ def merge_redeclarations(candidates)
69
+ candidates.group_by(&:first).map do |class_name, rows|
70
+ declared = rows.filter_map { |(_, perform_def)| perform_def }
71
+ entries = declared.empty? ? [build_entry(class_name, nil)] : declared.map { |d| build_entry(class_name, d) }
72
+ entries.reduce { |base, addition| widen(base, addition) }
73
+ end
74
+ end
75
+
76
+ # The arity envelope that accepts every call either declaration accepts. See {#merge_redeclarations}.
77
+ def widen(base, addition)
78
+ base.with(
79
+ min_arity: [base.min_arity, addition.min_arity].min,
80
+ max_arity: [base.max_arity, addition.max_arity].max
81
+ )
82
+ end
83
+
50
84
  def read_safely(path)
51
85
  @io_boundary.read_file(path)
52
86
  rescue Plugin::AccessDeniedError, Errno::ENOENT
@@ -56,7 +90,8 @@ module Rigor
56
90
  def ruby_files_under(roots)
57
91
  roots.flat_map do |root|
58
92
  absolute = File.expand_path(root)
59
- next [] unless File.directory?(absolute)
93
+ # ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
94
+ next [] unless @io_boundary.directory?(absolute)
60
95
 
61
96
  Dir.glob(File.join(absolute, "**", "*.rb"))
62
97
  end
@@ -77,24 +112,40 @@ module Rigor
77
112
  class_local_name = constant_path_name(node.constant_path)
78
113
  return if class_local_name.nil?
79
114
 
80
- full_name = (lexical_path + [class_local_name]).join("::")
115
+ full_name = declared_constant_name(class_local_name, lexical_path)
81
116
  if includes_marker_module?(node.body)
82
117
  perform_def = lookup_perform_def(node.body)
83
118
  yield full_name, perform_def
84
119
  end
85
120
 
86
- inner_path = lexical_path + [class_local_name]
87
- walk_for_workers(node.body, inner_path, &) if node.body
121
+ walk_for_workers(node.body, [full_name], &) if node.body
88
122
  end
89
123
 
90
124
  def visit_module(node, lexical_path, &)
91
125
  module_local_name = constant_path_name(node.constant_path)
92
126
  return if module_local_name.nil?
93
127
 
94
- inner_path = lexical_path + [module_local_name]
128
+ inner_path = [declared_constant_name(module_local_name, lexical_path)]
95
129
  walk_for_workers(node.body, inner_path, &) if node.body
96
130
  end
97
131
 
132
+ # The full constant name a `class` / `module` declaration defines, given the rendered local name
133
+ # and the enclosing lexical path. A ROOTED local name (`class ::WelcomeWorker`) names the top-level
134
+ # constant whatever the nesting, so the lexical path is dropped and the `::` with it; otherwise the
135
+ # name is appended to the path (`class WelcomeWorker` inside `module Admin` →
136
+ # `Admin::WelcomeWorker`).
137
+ def declared_constant_name(local_name, lexical_path)
138
+ return strip_root(local_name) if local_name.start_with?("::")
139
+
140
+ (lexical_path + [local_name]).join("::")
141
+ end
142
+
143
+ # `::WelcomeWorker` → `WelcomeWorker`; a name without the root marker is returned unchanged (nil
144
+ # stays nil).
145
+ def strip_root(name)
146
+ name&.delete_prefix("::")
147
+ end
148
+
98
149
  # Returns true if the class body contains a top-level `include <Module>` call where `<Module>`
99
150
  # matches one of the configured marker modules.
100
151
  def includes_marker_module?(body)
@@ -106,11 +157,13 @@ module Rigor
106
157
  next false unless node.receiver.nil?
107
158
 
108
159
  arg = node.arguments&.arguments&.first
109
- module_name = constant_path_name(arg)
160
+ module_name = strip_root(constant_path_name(arg))
110
161
  module_name && @marker_modules.include?(module_name)
111
162
  end
112
163
  end
113
164
 
165
+ # Renders a constant-path node as a String, KEEPING the leading `::` of a rooted path —
166
+ # {#declared_constant_name} reads it as "reset the lexical path" before the marker is dropped.
114
167
  def constant_path_name(node)
115
168
  return nil if node.nil?
116
169
 
@@ -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 worker would replace the first outright. {WorkerDiscoverer#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 (`"WelcomeWorker"`, `"Admin::WelcomeWorker"` —
39
+ # never `"::WelcomeWorker"`; see {WorkerDiscoverer}), while a QUERY may legitimately arrive rooted:
40
+ # `::WelcomeWorker.perform_async(1)` renders its receiver as `"::WelcomeWorker"`. 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?
@@ -52,6 +60,11 @@ module Rigor
52
60
  def names
53
61
  @by_name.keys
54
62
  end
63
+
64
+ private
65
+
66
+ # `::WelcomeWorker` → `WelcomeWorker`. The query-side half of the key contract — see {#find}.
67
+ def strip_leading_namespace(name) = name.delete_prefix("::")
55
68
  end
56
69
  end
57
70
  end