rigortype 0.3.6 → 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 (213) 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/manual/02-cli-reference.md +16 -7
  15. data/docs/manual/05-inspecting-types.md +20 -8
  16. data/docs/manual/07-plugins.md +4 -0
  17. data/docs/manual/plugins/rigor-actionpack.md +61 -0
  18. data/docs/manual/plugins/rigor-activerecord.md +74 -1
  19. data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
  20. data/docs/manual/plugins/rigor-sidekiq.md +22 -0
  21. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
  22. data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
  23. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
  24. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
  25. data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
  26. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
  27. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
  28. data/lib/rigor/analysis/check_rules.rb +383 -48
  29. data/lib/rigor/analysis/crash_signature.rb +116 -0
  30. data/lib/rigor/analysis/dependency_recorder.rb +18 -3
  31. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  32. data/lib/rigor/analysis/incremental.rb +28 -0
  33. data/lib/rigor/analysis/incremental_session.rb +44 -9
  34. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
  35. data/lib/rigor/analysis/reachability/graph.rb +14 -5
  36. data/lib/rigor/analysis/reachability/scan.rb +7 -3
  37. data/lib/rigor/analysis/result.rb +32 -0
  38. data/lib/rigor/analysis/run_cache_key.rb +41 -1
  39. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +93 -1
  40. data/lib/rigor/analysis/runner/pool_coordinator.rb +34 -0
  41. data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
  42. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -2
  43. data/lib/rigor/analysis/runner.rb +147 -10
  44. data/lib/rigor/analysis/worker_session.rb +10 -1
  45. data/lib/rigor/bleeding_edge.rb +7 -6
  46. data/lib/rigor/builtins/hkt_builtins.rb +45 -6
  47. data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
  48. data/lib/rigor/builtins/static_return_refinements.rb +20 -1
  49. data/lib/rigor/cache/descriptor.rb +42 -2
  50. data/lib/rigor/cache/incremental_snapshot.rb +40 -3
  51. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +31 -8
  52. data/lib/rigor/cache/store.rb +10 -1
  53. data/lib/rigor/cli/coverage_command.rb +23 -18
  54. data/lib/rigor/cli/coverage_mutation.rb +13 -17
  55. data/lib/rigor/cli/coverage_scan.rb +47 -6
  56. data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
  57. data/lib/rigor/cli/fused_protection_report.rb +23 -2
  58. data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
  59. data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
  60. data/lib/rigor/cli/mutation_protection_report.rb +31 -2
  61. data/lib/rigor/cli/sig_gen_command.rb +18 -0
  62. data/lib/rigor/cli/type_of_command.rb +188 -41
  63. data/lib/rigor/cli/type_of_renderer.rb +72 -3
  64. data/lib/rigor/cli/type_scan_command.rb +10 -1
  65. data/lib/rigor/cli.rb +1 -1
  66. data/lib/rigor/configuration.rb +7 -4
  67. data/lib/rigor/effects/plugin_facts.rb +1 -1
  68. data/lib/rigor/effects/scanner.rb +2 -3
  69. data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
  70. data/lib/rigor/environment/lockfile_resolver.rb +15 -5
  71. data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
  72. data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
  73. data/lib/rigor/environment/rbs_loader.rb +525 -46
  74. data/lib/rigor/environment.rb +38 -15
  75. data/lib/rigor/inference/acceptance.rb +172 -20
  76. data/lib/rigor/inference/captured_locals.rb +62 -0
  77. data/lib/rigor/inference/content_join.rb +347 -0
  78. data/lib/rigor/inference/def_handle.rb +15 -3
  79. data/lib/rigor/inference/def_node_resolver.rb +62 -3
  80. data/lib/rigor/inference/expression_typer.rb +1020 -141
  81. data/lib/rigor/inference/fork_map.rb +6 -1
  82. data/lib/rigor/inference/hkt_reducer.rb +12 -1
  83. data/lib/rigor/inference/hkt_registry.rb +46 -0
  84. data/lib/rigor/inference/hkt_sugar_translator.rb +93 -0
  85. data/lib/rigor/inference/index_write_widening.rb +48 -0
  86. data/lib/rigor/inference/indexed_narrowing.rb +14 -0
  87. data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
  88. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
  89. data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
  90. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
  91. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
  92. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
  93. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
  94. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
  95. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
  96. data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
  97. data/lib/rigor/inference/method_dispatcher.rb +155 -14
  98. data/lib/rigor/inference/method_parameter_binder.rb +3 -3
  99. data/lib/rigor/inference/mutation_widening.rb +244 -174
  100. data/lib/rigor/inference/narrowing.rb +331 -27
  101. data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
  102. data/lib/rigor/inference/pre_eval_constants.rb +15 -0
  103. data/lib/rigor/inference/precision_scanner.rb +5 -2
  104. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  105. data/lib/rigor/inference/rbs_type_translator.rb +89 -39
  106. data/lib/rigor/inference/scope_indexer.rb +1493 -140
  107. data/lib/rigor/inference/statement_evaluator.rb +276 -91
  108. data/lib/rigor/inference/struct_fold_safety.rb +186 -27
  109. data/lib/rigor/inference/version_guard.rb +229 -0
  110. data/lib/rigor/language_server/selection_range_provider.rb +1 -1
  111. data/lib/rigor/plugin/base.rb +3 -2
  112. data/lib/rigor/plugin/inflector.rb +14 -5
  113. data/lib/rigor/plugin/io_boundary.rb +107 -4
  114. data/lib/rigor/plugin/loader.rb +14 -0
  115. data/lib/rigor/protection/analysis_guard.rb +60 -0
  116. data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
  117. data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
  118. data/lib/rigor/protection/discovery_seed.rb +1 -1
  119. data/lib/rigor/protection/measurement_integrity.rb +36 -0
  120. data/lib/rigor/protection/mutation_scanner.rb +61 -6
  121. data/lib/rigor/protection/mutator.rb +121 -18
  122. data/lib/rigor/reflection.rb +182 -30
  123. data/lib/rigor/scope/discovery_index.rb +39 -1
  124. data/lib/rigor/scope.rb +385 -4
  125. data/lib/rigor/sig_gen/classification.rb +12 -1
  126. data/lib/rigor/sig_gen/generator.rb +254 -5
  127. data/lib/rigor/sig_gen/renderer.rb +1 -6
  128. data/lib/rigor/sig_gen/writer.rb +3 -0
  129. data/lib/rigor/source/constant_path.rb +79 -2
  130. data/lib/rigor/triage/catalogue.rb +1 -1
  131. data/lib/rigor/type/combinator.rb +10 -0
  132. data/lib/rigor/type/maybe.rb +47 -0
  133. data/lib/rigor/type/refined.rb +1 -2
  134. data/lib/rigor/type/result.rb +53 -0
  135. data/lib/rigor/type.rb +2 -0
  136. data/lib/rigor/version.rb +1 -1
  137. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
  138. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
  139. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
  140. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
  141. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
  142. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
  143. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
  144. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
  145. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
  146. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
  147. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
  148. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
  149. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
  150. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
  151. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
  152. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
  153. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
  154. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
  155. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
  156. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
  157. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
  158. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
  159. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
  160. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
  161. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
  162. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
  163. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
  164. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
  165. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
  166. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
  167. data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
  168. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
  169. data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
  170. data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
  171. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
  172. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
  173. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
  174. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
  175. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
  176. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
  177. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
  178. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
  179. data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
  180. data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
  181. data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
  182. data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
  183. data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
  184. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
  185. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
  186. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  187. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
  188. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
  189. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
  190. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
  191. data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
  192. data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
  193. data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
  194. data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
  195. data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
  196. data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
  197. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
  198. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
  199. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
  200. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
  201. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
  202. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
  203. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
  204. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
  205. data/sig/rigor/analysis/baseline.rbs +1 -1
  206. data/sig/rigor/environment.rbs +3 -0
  207. data/sig/rigor/inference.rbs +4 -1
  208. data/sig/rigor/plugin/io_boundary.rbs +2 -0
  209. data/sig/rigor/reflection.rbs +3 -1
  210. data/sig/rigor/scope.rbs +25 -1
  211. data/sig/rigor/type.rbs +95 -1
  212. data/sig/rigor.rbs +16 -0
  213. metadata +69 -3
@@ -61,8 +61,15 @@ module Rigor
61
61
  # DomainBlocksController` resolves as `Admin::DomainBlocksController` (matching the
62
62
  # `ControllerDiscoverer`), so render paths and filter-chain validation on nested controllers are
63
63
  # correct.
64
- version: "0.9.0",
65
- description: "Validates Action Pack route-helper calls and filter chains inside controllers, and types the request-context readers (`params` / `session` / `request`).",
64
+ # Bumped 2026-09-02 (#534 "same lane") — the request predicates and the FlashHash chain join the
65
+ # typed surface. `0.9.0`'s successor is `1.0.0`, not `0.10.0`: AGENTS.md's single-digit rule binds
66
+ # recursively at every position, and it applies here because a manifest version is a cache key
67
+ # rather than a gem release (so the no-autonomous-bump rule does not).
68
+ # Bumped 2026-09-02 (#621) — a reopened controller's `def`s are UNIONed rather than clobbered by
69
+ # the later file in the glob, so a cached 1.0.0 index can be missing the filter targets the merge
70
+ # restores.
71
+ version: "1.1.0",
72
+ description: "Validates Action Pack route-helper calls and filter chains inside controllers, and types the request-context readers (`params` / `session` / `request` / `flash`) and their chains.",
66
73
  config_schema: {
67
74
  "controller_search_paths" => { kind: :array, default: ["app/controllers"] },
68
75
  "view_search_paths" => { kind: :array, default: ["app/views"] }
@@ -203,7 +210,93 @@ module Rigor
203
210
  # method on a Parameters value stays engine-lenient (no `undefined-method`), and this types the
204
211
  # container only, never a caller's argument (ADR-5). The GitLab strong-params survey (108 leaked
205
212
  # `.permit` sites) is the demand.
206
- STRONG_PARAMS_CHAIN_METHODS = %i[require permit permit!].freeze
213
+ # Issue #534 — `expect` (Rails 8's require+permit) and `slice` joined 2026-09-01, from the corpus
214
+ # sweep's pair counts (63 and 38 sites on mastodon). Both share `require`'s safety shape: they
215
+ # never return nil (`expect` raises like `require`; `slice` always returns a Parameters), so the
216
+ # non-nil lenient nominal cannot fold a flow rule wrong.
217
+ #
218
+ # ## The admission rule for this table
219
+ #
220
+ # A method belongs here **iff its Rails implementation returns an `ActionController::Parameters`
221
+ # on every path** — either a fresh `new_instance_with_inherited_permitted_status(...)` or `self`.
222
+ # That is a stronger bar than "usually returns Parameters", and it is the bar that keeps the
223
+ # entry FP-safe, because the RBS-less nominal buys leniency on the *method surface* only:
224
+ #
225
+ # - Method-surface rules DO decline on it. `call.undefined-method` bails at
226
+ # `Rigor::Reflection.rbs_class_known?` (lib/rigor/analysis/check_rules.rb:720) because Rigor
227
+ # ships no RBS for Parameters, and the argument / arity rules gate on the same predicate
228
+ # (:1151, :2126). So `params.slice(:a).whatever_rails_adds` never fires, and a scalar-shaped
229
+ # use of a Parameters-typed value (`.to_i`, `.strip`, string interpolation) resolves lenient
230
+ # -to-Dynamic rather than to a diagnostic.
231
+ # - **Flow rules do NOT.** Truthiness folding, ternary arm selection and `.nil?` folding key on
232
+ # the type being *nil-free*, not on whether Rigor knows its methods. A non-nil nominal standing
233
+ # in for a value that is nil at runtime is a wrong precise type, and the folds it licenses are
234
+ # real diagnostics on correct code. This is why the table admits only never-nil methods, and
235
+ # why `#[]` is excluded below.
236
+ #
237
+ # Return contracts verified against rails v8.1.0.beta1
238
+ # (`actionpack/lib/action_controller/metal/strong_parameters.rb`). Deliberately excluded even
239
+ # though they are Parameters-shaped: `compact!` (`self if @parameters.compact!` — returns nil
240
+ # when nothing changed, :1038); `select` / `reject` / `filter` / `transform_keys` /
241
+ # `transform_values` (`to_enum(...)` without a block, so the answer depends on the call's block
242
+ # argument, :1004/:1018/:934/:917); `select!` / `keep_if`, whose doc comment ("returns nil if no
243
+ # changes were made") contradicts its body (`self`, :1010) — a contradiction that could resolve
244
+ # either way on a future Rails, and the demand does not justify betting on it; `dig` / `fetch` /
245
+ # `to_h` / `to_unsafe_h`, which return a caller value, nil, or a HashWithIndifferentAccess.
246
+ # `reject!` / `delete_if` / `compact_blank!` DO return `self` on every path (:1024/:1028/:1050)
247
+ # and are admissible under the same argument — deferred only to keep this change at the
248
+ # reviewed set; admit them with the next batch.
249
+ #
250
+ # ## `Parameters#[]`: rejected 2026-09-01, adopted 2026-09-05 (issue #534 item 1, #574)
251
+ #
252
+ # `#[]` is the single largest named-receiver pair on both survey apps (redmine 581, mastodon
253
+ # ~475), so it is the entry with by far the most to gain — and both typings of it were measured
254
+ # on a fixture controller of ordinary Rails idioms before being rejected:
255
+ #
256
+ # - **`#[] -> Parameters` (non-nil).** Types the whole chain (`params[:a][:b][:c]` resolves), but
257
+ # the non-nil nominal is a *wrong* precise type at a missing key, and the flow rules act on it.
258
+ # Every `params[:k]` condition becomes always-truthy, so a ternary over one folds to its true
259
+ # arm: `mode = params[:full] ? :full : :short` types `mode` as `:full`, and the live guard
260
+ # `return if mode == :short` draws `flow.always-truthy-condition` — a diagnostic on a branch
261
+ # the program really takes. The same fold proves `if url.nil?` unreachable and types its body
262
+ # `bot`. This reproduces the withdrawal measured on five working redmine/mastodon controllers.
263
+ # (A shape like `x = params[:flag] ? nil : "a"; x.upcase` also changes answer, but it is a true
264
+ # positive both before and after — `String | nil` genuinely can be nil — so it is evidence of
265
+ # the fold, not of an FP. The two shapes above are the ones that are silent today and wrong
266
+ # under this typing, which is why they are what the spec pins.)
267
+ # - **`#[] -> Parameters | nil`.** Fixes every fold above (no ternary collapse, no `bot` branch)
268
+ # and still carries the chain — `params[:user][:name]` types `Parameters?` and
269
+ # `params[:user].permit(:name)` types `Parameters`, because the dispatcher strips the nil arm
270
+ # for the receiver gate. Idiomatic *chained* reads stay silent too: `call.possible-nil-receiver`
271
+ # restricts itself to `Prism::LocalVariableReadNode` receivers (`CheckRules#nil_receiver_diagnostic`'s local-read restriction), so
272
+ # `params[:q].strip` cannot fire. What it does fire on is the assigned form — `q = params[:q];
273
+ # q.strip` — at **error** severity, once per unguarded use. Guarded forms (`return if q.nil?`,
274
+ # `if q`, `q&.strip`, `|| ""`) all narrow correctly and stay silent, so this is not a broken
275
+ # rule; it is the rule working, on a shape working Rails controllers use constantly. Adopting
276
+ # it would put an error on correct code in exchange for a coverage metric.
277
+ #
278
+ # The first trade is still refused: `-> Parameters` buys protection-coverage with false positives,
279
+ # which inverts the project's ordering of those two goods (AGENTS.md: "false positives outrank
280
+ # worst-case static reading").
281
+ #
282
+ # The SECOND is no longer a trade. The `possible-nil-receiver` firing above was never this typing
283
+ # being wrong — it was the rules-level vacuity #574 describes, and the paragraph that stood here
284
+ # named the precondition exactly: "a receiver-position nullable the flow rules read as unknown
285
+ # rather than as nil". #574's witness gate is that change. `ActionController::Parameters` ships no
286
+ # RBS and has no project source, so the gate now reads it as UNKNOWN rather than as a full witness,
287
+ # the nil-arm has nothing confirming it, and the rule declines. `q = params[:q]; q.strip` is silent
288
+ # on the shape that rejected this typing, measured through the real CLI rather than argued.
289
+ #
290
+ # The control specs stay, and they still bite: they pin the HAZARD (no truthiness fold, no `bot`
291
+ # branch, no possible-nil firing), not the Dynamic answer, so they go red if either typing's
292
+ # hazard ever returns — and they now carry a positive assertion that `params[:k]` types
293
+ # `Parameters?`, so a silent regression to `Dynamic` or to the non-nil nominal fails them too.
294
+ STRONG_PARAMS_CHAIN_METHODS = %i[
295
+ require permit permit! expect slice
296
+ except without extract! slice!
297
+ merge merge! reverse_merge reverse_merge! with_defaults with_defaults!
298
+ compact compact_blank deep_dup
299
+ ].freeze
207
300
 
208
301
  dynamic_return receivers: [REQUEST_CONTEXT_READER_TYPES[:params]], methods: STRONG_PARAMS_CHAIN_METHODS do |call_node, _scope|
209
302
  next nil unless call_node.is_a?(Prism::CallNode)
@@ -211,6 +304,150 @@ module Rigor
211
304
  Rigor::Type::Combinator.nominal_of(REQUEST_CONTEXT_READER_TYPES[:params])
212
305
  end
213
306
 
307
+ # Issue #534 item 1, reopened by #574. `#[]` is the single largest named-receiver pair on both survey
308
+ # apps (redmine 581, mastodon ~475). The honest contract is `Parameters | nil` — a missing key really
309
+ # returns nil — and the block comment above records why that typing was REJECTED in 2026-09-01: it put
310
+ # `call.possible-nil-receiver` at ERROR severity on `q = params[:q]; q.strip`, a shape working Rails
311
+ # controllers use constantly.
312
+ #
313
+ # That firing was a rules-level vacuity, not this typing being wrong, and #574 fixed it: the
314
+ # possible-nil witness gate counted a named-but-RBS-less arm as answerable, so a plugin-minted
315
+ # nominal with no RBS and no project source stood in as a full witness for a question nothing could
316
+ # answer. With the gate requiring a KNOWABLE arm, the nil-arm has no confirmed witness and the rule
317
+ # declines — which is the FP-first ordering, and what the comment above named as the precondition
318
+ # ("a receiver-position nullable the flow rules read as unknown rather than as nil").
319
+ #
320
+ # The non-nil `-> Parameters` typing stays rejected on its own merits, unchanged: it makes every
321
+ # `params[:k]` condition always-truthy and folds a ternary over one to its true arm.
322
+ dynamic_return receivers: [REQUEST_CONTEXT_READER_TYPES[:params]], methods: %i[[]] do |call_node, _scope|
323
+ next nil unless call_node.is_a?(Prism::CallNode)
324
+
325
+ Rigor::Type::Combinator.union(
326
+ Rigor::Type::Combinator.nominal_of(REQUEST_CONTEXT_READER_TYPES[:params]),
327
+ Rigor::Type::Combinator.constant_of(nil)
328
+ )
329
+ end
330
+
331
+ # Phase 5c (2026-09-02, issue #534 "same lane") — the rest of the request-context surface the
332
+ # corpus sweep measured after the #578 / #585 batch: `request.post?` 28 and `request.xhr?` 27 on
333
+ # the two apps, `flash.now` inside the redmine 147 / mastodon 29 flash cluster. Each is typed from
334
+ # its Rails/Rack source contract, verified at v8.0.2 / rack v3.1.8.
335
+ #
336
+ # ## The predicates return a real `bool`, and a real `bool` does not fold
337
+ #
338
+ # Every method in this table is a zero-argument predicate whose body is a genuine boolean
339
+ # expression — `request_method == POST` and its nine siblings in `Rack::Request::Helpers`,
340
+ # `/XMLHttpRequest/i.match?(...)` for `xhr?` / `xml_http_request?`, `scheme == "https" || scheme ==
341
+ # "wss"` for `ssl?`, two `LOCALHOST.match?` conjuncts for `local?`, and
342
+ # `FORM_DATA_MEDIA_TYPES.include?(media_type)` for `form_data?`. None can return a non-boolean, so
343
+ # `true | false` is the exact type rather than an approximation.
344
+ #
345
+ # `bool` is a UNION of the two constants, and that is what makes it FP-safe where a nominal would
346
+ # not be: `flow.always-truthy-condition` fires when the flow proves the condition folds to ONE
347
+ # constant, and a union of both never does. Measured on the shapes controllers actually write —
348
+ # `return unless request.post?`, `if request.xhr? … else … end`, `mode = request.get? ? :a : :b`
349
+ # followed by a live `mode == :a` guard, `request.post? && x`, `!request.get?` — the answer is zero
350
+ # diagnostics, unchanged from the `Dynamic[top]` baseline. This is the opposite of the
351
+ # `Parameters#[]` case (#578): there the candidate type was a non-nil *nominal* standing in for a
352
+ # value that is nil at runtime, and the fold it licensed was wrong.
353
+ #
354
+ # The distinction the predicates turn on is *inertness*, not truthfulness, and it is narrower than
355
+ # it looks: a two-constant union is inert because neither arm can be eliminated, while ANY nil-free
356
+ # type — nominal or union of nominals — is not. `request.format` was withdrawn from this batch on
357
+ # exactly that point (see below).
358
+ REQUEST_PREDICATE_METHODS = %i[
359
+ get? post? put? patch? delete? head? options? trace? link? unlink?
360
+ xhr? xml_http_request? ssl? local? form_data?
361
+ ].freeze
362
+
363
+ dynamic_return receivers: [REQUEST_CONTEXT_READER_TYPES[:request]], methods: REQUEST_PREDICATE_METHODS do |call_node, _scope|
364
+ next nil unless call_node.is_a?(Prism::CallNode)
365
+ next nil unless call_node.arguments.nil?
366
+ next nil unless call_node.block.nil?
367
+
368
+ Rigor::Type::Combinator.union(
369
+ Rigor::Type::Combinator.constant_of(true),
370
+ Rigor::Type::Combinator.constant_of(false)
371
+ )
372
+ end
373
+
374
+ # The FlashHash methods whose Rails body returns something this plugin can name on every path.
375
+ #
376
+ # - `now` is `@now ||= FlashNow.new(self)` — a memoised, never-nil `ActionDispatch::Flash::FlashNow`.
377
+ # It is the carrier behind `flash.now[:alert] = "…"`, the second-most-written flash idiom.
378
+ # - `keep` and `discard` are `k ? self[k] : self`, so they are admissible ONLY in their
379
+ # zero-argument form, where the answer is `self` — a FlashHash. Given a key they return a leaf
380
+ # value or nil, which is the hazard below. The rule reads the arity syntactically, so the two
381
+ # spellings get different answers from the same table row.
382
+ #
383
+ # Both nominals are RBS-less, hence lenient, for the reason `REQUEST_CONTEXT_READER_TYPES`
384
+ # documents.
385
+ FLASH_CHAIN_TYPES = {
386
+ now: "ActionDispatch::Flash::FlashNow",
387
+ keep: REQUEST_CONTEXT_READER_TYPES[:flash],
388
+ discard: REQUEST_CONTEXT_READER_TYPES[:flash]
389
+ }.freeze
390
+
391
+ # `keep` / `discard` answer `self` only when called with no key.
392
+ FLASH_SELF_RETURNING_METHODS = %i[keep discard].freeze
393
+
394
+ dynamic_return receivers: [REQUEST_CONTEXT_READER_TYPES[:flash]], methods: FLASH_CHAIN_TYPES.keys do |call_node, _scope|
395
+ next nil unless call_node.is_a?(Prism::CallNode)
396
+ next nil unless call_node.arguments.nil?
397
+ next nil unless call_node.block.nil?
398
+
399
+ class_name = FLASH_CHAIN_TYPES[call_node.name]
400
+ next nil if class_name.nil?
401
+
402
+ Rigor::Type::Combinator.nominal_of(class_name)
403
+ end
404
+
405
+ # ## What deliberately does NOT land here (issue #534, adjudicated 2026-09-02)
406
+ #
407
+ # **`request.format` was withdrawn from this batch and is filed as its own issue.** It was written
408
+ # as `Mime::Type | Mime::NullType` — a union read straight off `formats.first ||
409
+ # Mime::NullType.instance` — and review found the reasoning behind it wrong on the point that
410
+ # matters. A two-class union of nominals is NOT flow-inert the way `true | false` is: neither Mime
411
+ # arm is falsey, so the whole union is nil-free, and `mode = request.format ? :f : :n; return "a" if
412
+ # mode == :n` draws `flow.always-truthy-condition`. That fold is truthful at runtime — `format`
413
+ # really never returns nil — but truthfulness is not the bar this plugin has been holding, and the
414
+ # fold was never adjudicated against the shapes controllers write.
415
+ #
416
+ # `Mime::NullType` is what makes it more than a paperwork problem. It is a nil-MASQUERADING object:
417
+ # `mime_type.rb` (v8.0.2) gives it an explicit `def nil?; true; end`, so a nil-free union is the
418
+ # wrong model of it, and mastodon's `account_controller_concern.rb` really does branch on
419
+ # `request.format.nil?`. Measured today, `.nil?` on the union does not fold — but that is a
420
+ # consequence of the arms being RBS-less, not of anything this table asserts, and nothing pinned it.
421
+ # Typing `format` needs a nil-aware answer and its own adjudication, not a row here.
422
+ #
423
+ # **`FlashHash#[]=` and `Session#[]=` need no rule, and adding one could only do harm.** Two
424
+ # independent measurements say so. First, Ruby's assignment-expression semantics make the value of
425
+ # `flash[:notice] = msg` the RHS whatever `[]=` returns, and Rigor already models that: on a
426
+ # controller fixture with no plugin rule at all, `flash[:notice] = "hi"` types `"hi"` and
427
+ # `session[:user_id] = 1` types `1`. Second, `coverage --protection` scores a fixture of five such
428
+ # writes at **6/6 (100%)** — the lens measures *receiver* concreteness, and `flash` / `session` are
429
+ # already concrete nominals, so there is no coverage to win either. The corpus's "`FlashHash#[]=`
430
+ # 127 / 29" is a *named-receiver pair* count (a dispatch whose method does not resolve), which no
431
+ # return-type contribution can move. A rule here could only agree with the answer already given, or
432
+ # contradict it.
433
+ #
434
+ # **`FlashHash#[]` and `Session#[]` stay untyped, and both candidate arms were run.** Both are leaf
435
+ # reads — `@flashes[k.to_s]` and the session delegate's `[]` — returning whatever was stored, or
436
+ # nil for a key that is not set, so this is `Parameters#[]` (#578) again and it measures the same:
437
+ #
438
+ # - **non-nil `String`**: `mode = flash[:notice] ? :flash : :plain` folds to `:flash`, and the live
439
+ # `mode == :plain` guard draws `flow.always-truthy-condition` — a diagnostic on a branch the
440
+ # program takes.
441
+ # - **`String | nil`**: no folds, but `note = flash[:notice]; note.upcase` fires
442
+ # `call.possible-nil-receiver` at ERROR severity — the assigned-then-used shape controllers write
443
+ # constantly. (`uid.to_i` is silent because `NilClass` has `to_i`, which makes the noise
444
+ # unpredictable rather than absent.)
445
+ #
446
+ # One false positive each, on ordinary controller code, in exchange for a coverage number. Session
447
+ # is if anything worse than Parameters: `session[:user_id]` is *the* nil-checked read in Rails, so
448
+ # the fold lands on the guard that matters most. Reopening either needs the same rules-level change
449
+ # #574 tracks, not a plugin table row. The specs pin both arms as executable controls.
450
+
214
451
  private
215
452
 
216
453
  # True when the current `self` is a controller — the enclosing class is one the discoverer
@@ -225,7 +462,8 @@ module Rigor
225
462
  return false if name.nil?
226
463
 
227
464
  index = producer_value(:controller_index)
228
- return true if index && (index.find(name) || index.find("::#{name}"))
465
+ # `ControllerIndex#find` de-roots the query itself (#621) no retry arm here.
466
+ return true if index&.find(name)
229
467
 
230
468
  name.end_with?("Controller")
231
469
  end
@@ -35,7 +35,9 @@ module Rigor
35
35
  class_name = constant_receiver_name(call_node.receiver)
36
36
  return [] if class_name.nil?
37
37
 
38
- entry = job_index.find(class_name) || job_index.find("::#{class_name}")
38
+ # `JobIndex#find` de-roots the query itself (#621) a `::WelcomeJob.perform_later` receiver and a
39
+ # plain `WelcomeJob.perform_later` reach the same entry with no retry arm here.
40
+ entry = job_index.find(class_name)
39
41
  return [] if entry.nil?
40
42
 
41
43
  violations = [info_violation(call_node, entry)]
@@ -135,7 +135,7 @@ module Rigor
135
135
  # per-environment split has no single transport, and guessing one would be the wrong kind of
136
136
  # precision.
137
137
  def detect_adapter(io_boundary, root)
138
- found = config_files(root).filter_map do |path|
138
+ found = config_files(io_boundary, root).filter_map do |path|
139
139
  io_boundary.read_file(path).force_encoding("UTF-8")[SETTING, 1]
140
140
  rescue StandardError
141
141
  nil
@@ -143,10 +143,14 @@ module Rigor
143
143
  found.length == 1 ? found.first : nil
144
144
  end
145
145
 
146
- def config_files(root)
146
+ # ADR-45 WD1b (#613) — `config/application.rb` is probed at a fixed path, so its ABSENCE is what
147
+ # shapes "no adapter" on a project that has none yet; the probe goes through the boundary to record
148
+ # that. The globbed `config/environments/*.rb` entries exist by enumeration and only pick up the
149
+ # (immediately superseded) presence row.
150
+ def config_files(io_boundary, root)
147
151
  application = File.join(root, CONFIG_PATHS.first)
148
152
  environments = Dir.glob(File.join(root, CONFIG_PATHS.last, "*.rb"))
149
- ([application] + environments).select { |path| File.file?(path) }
153
+ ([application] + environments).select { |path| io_boundary.file?(path) }
150
154
  rescue StandardError
151
155
  []
152
156
  end
@@ -26,26 +26,61 @@ module Rigor
26
26
  def initialize(io_boundary:, search_paths:, base_classes:)
27
27
  @io_boundary = io_boundary
28
28
  @search_paths = search_paths
29
- @base_classes = base_classes.to_set
29
+ # De-rooted for the same reason superclass names are ({#visit_class}): the match is by exact
30
+ # spelling, so a configured `job_base_classes: ["::ApplicationJob"]` would otherwise never match
31
+ # anything a declaration can render.
32
+ @base_classes = base_classes.to_set { |name| strip_root(name.to_s) }
30
33
  end
31
34
 
32
35
  # @return [JobIndex]
33
36
  def discover
34
- entries = []
37
+ candidates = []
35
38
  ruby_files_under(@search_paths).each do |path|
36
39
  contents = read_safely(path)
37
40
  next if contents.nil?
38
41
 
39
42
  tree = Prism.parse(contents).value
40
43
  walk_for_jobs(tree, []) do |class_name, perform_def|
41
- entries << build_entry(class_name, perform_def)
44
+ candidates << [class_name, perform_def]
42
45
  end
43
46
  end
44
- JobIndex.new(entries)
47
+ JobIndex.new(merge_redeclarations(candidates))
45
48
  end
46
49
 
47
50
  private
48
51
 
52
+ # Collapses the candidates that declare the SAME constant into one entry, so {JobIndex} — which
53
+ # keys its rows by `class_name` — receives at most one per job.
54
+ #
55
+ # A job is routinely declared more than once: `app/jobs/welcome_job.rb` holds the real class and a
56
+ # second file reopens it (`class ::WelcomeJob`, `class WelcomeJob`) to add a method. Taking the last
57
+ # candidate dropped the real `#perform` envelope whenever the reopen sorted later in the glob — the
58
+ # reopen carries no `#perform`, so the job silently became any-arity and every `perform_later` went
59
+ # unchecked. The rows are merged instead:
60
+ #
61
+ # - The declarations that actually spell `def perform` decide the envelope; a reopen that does not
62
+ # contributes nothing rather than erasing it.
63
+ # - When two declarations BOTH spell it with different shapes, the envelope WIDENS (min of the mins,
64
+ # max of the maxes) and the required keywords intersect. The glob order is not the load order, so
65
+ # pinning either shape would surface `wrong-arity` on a call the definition Ruby actually loads
66
+ # accepts — ADR-5: a missed narrow case costs precision, a wrong one costs correct code.
67
+ def merge_redeclarations(candidates)
68
+ candidates.group_by(&:first).map do |class_name, rows|
69
+ declared = rows.filter_map { |(_, perform_def)| perform_def }
70
+ entries = declared.empty? ? [build_entry(class_name, nil)] : declared.map { |d| build_entry(class_name, d) }
71
+ entries.reduce { |base, addition| widen(base, addition) }
72
+ end
73
+ end
74
+
75
+ # The arity envelope that accepts every call either declaration accepts. See {#merge_redeclarations}.
76
+ def widen(base, addition)
77
+ base.with(
78
+ min_arity: [base.min_arity, addition.min_arity].min,
79
+ max_arity: [base.max_arity, addition.max_arity].max,
80
+ keyword_required: base.keyword_required & addition.keyword_required
81
+ )
82
+ end
83
+
49
84
  def read_safely(path)
50
85
  @io_boundary.read_file(path)
51
86
  rescue Plugin::AccessDeniedError, Errno::ENOENT
@@ -55,7 +90,8 @@ module Rigor
55
90
  def ruby_files_under(roots)
56
91
  roots.flat_map do |root|
57
92
  absolute = File.expand_path(root)
58
- 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)
59
95
 
60
96
  Dir.glob(File.join(absolute, "**", "*.rb"))
61
97
  end
@@ -76,26 +112,41 @@ module Rigor
76
112
  class_local_name = constant_path_name(node.constant_path)
77
113
  return if class_local_name.nil?
78
114
 
79
- full_name = (lexical_path + [class_local_name]).join("::")
80
- superclass = constant_path_name(node.superclass) if node.superclass
115
+ full_name = declared_constant_name(class_local_name, lexical_path)
116
+ superclass = strip_root(constant_path_name(node.superclass)) if node.superclass
81
117
  if superclass && @base_classes.include?(superclass)
82
118
  perform_def = lookup_perform_def(node.body)
83
119
  yield full_name, perform_def
84
120
  end
85
121
 
86
- inner_path = lexical_path + [class_local_name]
87
- walk_for_jobs(node.body, inner_path, &) if node.body
122
+ walk_for_jobs(node.body, [full_name], &) if node.body
88
123
  end
89
124
 
90
125
  def visit_module(node, lexical_path, &)
91
126
  module_local_name = constant_path_name(node.constant_path)
92
127
  return if module_local_name.nil?
93
128
 
94
- inner_path = lexical_path + [module_local_name]
129
+ inner_path = [declared_constant_name(module_local_name, lexical_path)]
95
130
  walk_for_jobs(node.body, inner_path, &) if node.body
96
131
  end
97
132
 
98
- # Renders `Foo::Bar` / `::Foo::Bar` as a String.
133
+ # The full constant name a `class` / `module` declaration defines, given the rendered local name
134
+ # and the enclosing lexical path. A ROOTED local name (`class ::WelcomeJob`) names the top-level
135
+ # constant whatever the nesting, so the lexical path is dropped and the `::` with it; otherwise the
136
+ # name is appended to the path (`class WelcomeJob` inside `module Admin` → `Admin::WelcomeJob`).
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
+ # `::WelcomeJob` → `WelcomeJob`; a name without the root marker is returned unchanged (nil stays nil).
144
+ def strip_root(name)
145
+ name&.delete_prefix("::")
146
+ end
147
+
148
+ # Renders `Foo::Bar` / `::Foo::Bar` as a String, KEEPING the leading `::` of a rooted path —
149
+ # {#declared_constant_name} reads it as "reset the lexical path" before the marker is dropped.
99
150
  def constant_path_name(node)
100
151
  return nil if node.nil?
101
152
 
@@ -29,19 +29,27 @@ module Rigor
29
29
 
30
30
  attr_reader :entries
31
31
 
32
+ # `entries` is expected UNIQUE by `class_name`: the by-name Hash is keyed by it, so a second row for
33
+ # a job would replace the first outright. {JobDiscoverer#merge_redeclarations} is the single home of
34
+ # that guarantee — a reopened class arrives as one merged row, never as two.
32
35
  def initialize(entries)
33
36
  @entries = entries.freeze
34
37
  @by_name = entries.to_h { |entry| [entry.class_name, entry] }.freeze
35
38
  freeze
36
39
  end
37
40
 
41
+ # Entries are keyed by the de-rooted constant path (`"WelcomeJob"`, `"Admin::WelcomeJob"` — never
42
+ # `"::WelcomeJob"`; see {JobDiscoverer}), while a QUERY may legitimately arrive rooted:
43
+ # `::WelcomeJob.perform_later(1)` renders its receiver as `"::WelcomeJob"`. The root marker is
44
+ # dropped here, once, so no caller needs a `find(name) || find("::#{name}")` retry (#621).
45
+ #
38
46
  # @return [Entry, nil]
39
47
  def find(class_name)
40
- @by_name[class_name.to_s]
48
+ @by_name[strip_leading_namespace(class_name.to_s)]
41
49
  end
42
50
 
43
51
  def known?(class_name)
44
- @by_name.key?(class_name.to_s)
52
+ @by_name.key?(strip_leading_namespace(class_name.to_s))
45
53
  end
46
54
 
47
55
  def empty?
@@ -55,6 +63,11 @@ module Rigor
55
63
  def names
56
64
  @by_name.keys
57
65
  end
66
+
67
+ private
68
+
69
+ # `::WelcomeJob` → `WelcomeJob`. The query-side half of the key contract — see {#find}.
70
+ def strip_leading_namespace(name) = name.delete_prefix("::")
58
71
  end
59
72
  end
60
73
  end
@@ -69,7 +69,9 @@ module Rigor
69
69
 
70
70
  def load_document(path)
71
71
  absolute = File.expand_path(path.to_s)
72
- return nil unless File.file?(absolute)
72
+ # ADR-45 WD1b (#613) — probing through the boundary records the missing `config/recurring.yml`
73
+ # as a dependency, so adding one invalidates the warm run instead of being served past.
74
+ return nil unless @io_boundary.file?(absolute)
73
75
 
74
76
  contents = read_safely(absolute)
75
77
  contents && parse_safely(contents)
@@ -39,7 +39,11 @@ module Rigor
39
39
  id: "activejob",
40
40
  # Bumped — publishes `:reachability_roots` for `rigor unused` (ADR-102 WD3): the jobs Solid Queue's
41
41
  # recurring schedule runs by name, which no `perform_later` call site writes down.
42
- version: "0.2.0",
42
+ #
43
+ # Bumped 2026-09-02 (#621) — job keys are de-rooted at the producer and a reopened class's
44
+ # `#perform` envelope is merged rather than clobbered, so a cached 0.2.0 index can carry the
45
+ # any-arity row the merge replaces (and miss a schedule's reachability root under a rooted name).
46
+ version: "0.3.0",
43
47
  description: "Validates ActiveJob `Job.perform_later` argument arity.",
44
48
  config_schema: {
45
49
  "job_search_paths" => { kind: :array, default: ["app/jobs"] },
@@ -59,8 +59,8 @@ module Rigor
59
59
  model_name = constant_receiver_name(node.receiver)
60
60
  return if model_name.nil?
61
61
 
62
- entry = @model_index.find(model_name) ||
63
- @model_index.find("::#{model_name}")
62
+ # A rooted receiver (`::User.where(...)`) renders as `"::User"`; `ModelIndex#find` normalises it.
63
+ entry = @model_index.find(model_name)
64
64
  return if entry.nil?
65
65
 
66
66
  case node.name