rigortype 0.3.5 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (266) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/data/builtins/ruby_core/date.yml +6 -3
  4. data/data/builtins/ruby_core/enumerable.yml +1 -0
  5. data/data/builtins/ruby_core/exception.yml +2 -1
  6. data/data/builtins/ruby_core/file.yml +28 -14
  7. data/data/builtins/ruby_core/hash.yml +5 -2
  8. data/data/builtins/ruby_core/io.yml +33 -16
  9. data/data/builtins/ruby_core/random.yml +2 -1
  10. data/data/builtins/ruby_core/re.yml +6 -3
  11. data/data/builtins/ruby_core/struct.yml +2 -0
  12. data/data/builtins/ruby_core/time.yml +2 -1
  13. data/data/gem_overlay/activesupport/core_ext.rbs +442 -11
  14. data/docs/handbook/02-everyday-types.md +1 -1
  15. data/docs/handbook/04-tuples-and-shapes.md +1 -1
  16. data/docs/handbook/08-understanding-errors.md +1 -1
  17. data/docs/handbook/09-plugins.md +2 -2
  18. data/docs/handbook/10-sorbet.md +1 -1
  19. data/docs/handbook/README.md +2 -2
  20. data/docs/handbook/appendix-go.md +1 -1
  21. data/docs/handbook/appendix-java-csharp.md +2 -2
  22. data/docs/handbook/appendix-mypy.md +1 -1
  23. data/docs/handbook/appendix-protocols-and-structural-typing.md +2 -2
  24. data/docs/handbook/appendix-rust.md +1 -1
  25. data/docs/handbook/appendix-type-theory.md +2 -2
  26. data/docs/handbook/appendix-typescript.md +4 -4
  27. data/docs/manual/02-cli-reference.md +29 -16
  28. data/docs/manual/03-configuration.md +1 -1
  29. data/docs/manual/04-diagnostics.md +12 -0
  30. data/docs/manual/05-inspecting-types.md +20 -8
  31. data/docs/manual/07-plugins.md +4 -0
  32. data/docs/manual/08-skills.md +1 -1
  33. data/docs/manual/11-ci.md +1 -1
  34. data/docs/manual/12-caching.md +1 -1
  35. data/docs/manual/15-type-protection-coverage.md +1 -1
  36. data/docs/manual/18-removing-dead-code.md +13 -4
  37. data/docs/manual/19-effect-labels.md +19 -12
  38. data/docs/manual/README.md +2 -2
  39. data/docs/manual/plugins/rigor-actionmailer.md +4 -4
  40. data/docs/manual/plugins/rigor-actionpack.md +61 -0
  41. data/docs/manual/plugins/rigor-activejob.md +3 -3
  42. data/docs/manual/plugins/rigor-activerecord.md +78 -5
  43. data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
  44. data/docs/manual/plugins/rigor-rails-i18n.md +5 -5
  45. data/docs/manual/plugins/rigor-rspec.md +6 -2
  46. data/docs/manual/plugins/rigor-sidekiq.md +22 -0
  47. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
  48. data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
  49. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
  50. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
  51. data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
  52. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
  53. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
  54. data/lib/rigor/analysis/check_rules.rb +383 -48
  55. data/lib/rigor/analysis/crash_signature.rb +116 -0
  56. data/lib/rigor/analysis/dependency_recorder.rb +18 -3
  57. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  58. data/lib/rigor/analysis/diagnostic.rb +17 -6
  59. data/lib/rigor/analysis/effects_cache_probe.rb +132 -0
  60. data/lib/rigor/analysis/incremental.rb +28 -0
  61. data/lib/rigor/analysis/incremental_session.rb +44 -9
  62. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
  63. data/lib/rigor/analysis/reachability/graph.rb +44 -16
  64. data/lib/rigor/analysis/reachability/plugin_roots.rb +11 -8
  65. data/lib/rigor/analysis/reachability/scan.rb +27 -7
  66. data/lib/rigor/analysis/reachability/scan_cache.rb +130 -0
  67. data/lib/rigor/analysis/result.rb +32 -0
  68. data/lib/rigor/analysis/run_cache_key.rb +53 -1
  69. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +93 -1
  70. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +14 -8
  71. data/lib/rigor/analysis/runner/pool_coordinator.rb +34 -0
  72. data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
  73. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -2
  74. data/lib/rigor/analysis/runner.rb +298 -38
  75. data/lib/rigor/analysis/worker_session.rb +10 -1
  76. data/lib/rigor/bleeding_edge.rb +7 -6
  77. data/lib/rigor/builtins/hkt_builtins.rb +45 -6
  78. data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
  79. data/lib/rigor/builtins/static_return_refinements.rb +20 -1
  80. data/lib/rigor/cache/descriptor.rb +42 -2
  81. data/lib/rigor/cache/file_digest.rb +20 -2
  82. data/lib/rigor/cache/incremental_snapshot.rb +40 -3
  83. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +31 -8
  84. data/lib/rigor/cache/store.rb +10 -1
  85. data/lib/rigor/cli/check_command.rb +67 -48
  86. data/lib/rigor/cli/coverage_command.rb +28 -24
  87. data/lib/rigor/cli/coverage_mutation.rb +13 -17
  88. data/lib/rigor/cli/coverage_scan.rb +47 -6
  89. data/lib/rigor/cli/doc_links.rb +100 -0
  90. data/lib/rigor/cli/docs_command.rb +32 -2
  91. data/lib/rigor/cli/effects_command.rb +27 -2
  92. data/lib/rigor/cli/effects_diff_renderer.rb +82 -12
  93. data/lib/rigor/cli/effects_explain_renderer.rb +6 -3
  94. data/lib/rigor/cli/effects_snapshot_command.rb +52 -7
  95. data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
  96. data/lib/rigor/cli/fused_protection_report.rb +23 -2
  97. data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
  98. data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
  99. data/lib/rigor/cli/mutation_protection_report.rb +31 -2
  100. data/lib/rigor/cli/sig_gen_command.rb +18 -0
  101. data/lib/rigor/cli/type_of_command.rb +188 -41
  102. data/lib/rigor/cli/type_of_renderer.rb +72 -3
  103. data/lib/rigor/cli/type_scan_command.rb +10 -1
  104. data/lib/rigor/cli/unused_command.rb +65 -16
  105. data/lib/rigor/cli.rb +22 -9
  106. data/lib/rigor/configuration.rb +7 -4
  107. data/lib/rigor/effects/definition_lines.rb +100 -0
  108. data/lib/rigor/effects/envelope_check.rb +18 -1
  109. data/lib/rigor/effects/liskov_check.rb +17 -8
  110. data/lib/rigor/effects/plugin_facts.rb +1 -1
  111. data/lib/rigor/effects/scanner.rb +2 -3
  112. data/lib/rigor/effects/signature_sources.rb +13 -2
  113. data/lib/rigor/effects/snapshot.rb +53 -21
  114. data/lib/rigor/effects/snapshot_diff.rb +26 -3
  115. data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
  116. data/lib/rigor/environment/lockfile_resolver.rb +15 -5
  117. data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
  118. data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
  119. data/lib/rigor/environment/rbs_loader.rb +525 -46
  120. data/lib/rigor/environment.rb +38 -15
  121. data/lib/rigor/inference/acceptance.rb +172 -20
  122. data/lib/rigor/inference/captured_locals.rb +62 -0
  123. data/lib/rigor/inference/content_join.rb +347 -0
  124. data/lib/rigor/inference/def_handle.rb +15 -3
  125. data/lib/rigor/inference/def_node_resolver.rb +62 -3
  126. data/lib/rigor/inference/expression_typer.rb +1020 -141
  127. data/lib/rigor/inference/fork_map.rb +6 -1
  128. data/lib/rigor/inference/hkt_reducer.rb +12 -1
  129. data/lib/rigor/inference/hkt_registry.rb +46 -0
  130. data/lib/rigor/inference/hkt_sugar_translator.rb +93 -0
  131. data/lib/rigor/inference/index_write_widening.rb +48 -0
  132. data/lib/rigor/inference/indexed_narrowing.rb +14 -0
  133. data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
  134. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
  135. data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
  136. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
  137. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
  138. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
  139. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
  140. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
  141. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
  142. data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
  143. data/lib/rigor/inference/method_dispatcher.rb +155 -14
  144. data/lib/rigor/inference/method_parameter_binder.rb +3 -3
  145. data/lib/rigor/inference/mutation_widening.rb +244 -174
  146. data/lib/rigor/inference/narrowing.rb +331 -27
  147. data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
  148. data/lib/rigor/inference/pre_eval_constants.rb +15 -0
  149. data/lib/rigor/inference/precision_scanner.rb +5 -2
  150. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  151. data/lib/rigor/inference/rbs_type_translator.rb +89 -39
  152. data/lib/rigor/inference/scope_indexer.rb +1493 -140
  153. data/lib/rigor/inference/statement_evaluator.rb +276 -91
  154. data/lib/rigor/inference/struct_fold_safety.rb +186 -27
  155. data/lib/rigor/inference/synthetic_method_scanner.rb +7 -0
  156. data/lib/rigor/inference/version_guard.rb +229 -0
  157. data/lib/rigor/language_server/selection_range_provider.rb +1 -1
  158. data/lib/rigor/plugin/base.rb +3 -2
  159. data/lib/rigor/plugin/inflector.rb +14 -5
  160. data/lib/rigor/plugin/io_boundary.rb +107 -4
  161. data/lib/rigor/plugin/loader.rb +14 -0
  162. data/lib/rigor/protection/analysis_guard.rb +60 -0
  163. data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
  164. data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
  165. data/lib/rigor/protection/discovery_seed.rb +1 -1
  166. data/lib/rigor/protection/measurement_integrity.rb +36 -0
  167. data/lib/rigor/protection/mutation_scanner.rb +61 -6
  168. data/lib/rigor/protection/mutator.rb +121 -18
  169. data/lib/rigor/rbs_extended/envelope_scanner.rb +9 -0
  170. data/lib/rigor/reflection.rb +182 -30
  171. data/lib/rigor/scope/discovery_index.rb +39 -1
  172. data/lib/rigor/scope.rb +385 -4
  173. data/lib/rigor/sig_gen/classification.rb +12 -1
  174. data/lib/rigor/sig_gen/generator.rb +254 -5
  175. data/lib/rigor/sig_gen/renderer.rb +1 -6
  176. data/lib/rigor/sig_gen/writer.rb +3 -0
  177. data/lib/rigor/source/constant_path.rb +79 -2
  178. data/lib/rigor/triage/catalogue.rb +1 -1
  179. data/lib/rigor/type/combinator.rb +10 -0
  180. data/lib/rigor/type/maybe.rb +47 -0
  181. data/lib/rigor/type/refined.rb +1 -2
  182. data/lib/rigor/type/result.rb +53 -0
  183. data/lib/rigor/type.rb +2 -0
  184. data/lib/rigor/version.rb +1 -1
  185. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
  186. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
  187. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
  188. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
  189. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
  190. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
  191. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
  192. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
  193. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
  194. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
  195. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
  196. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
  197. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
  198. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
  199. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
  200. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
  201. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
  202. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
  203. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
  204. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
  205. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
  206. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
  207. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
  208. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
  209. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
  210. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
  211. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
  212. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
  213. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
  214. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
  215. data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
  216. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
  217. data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
  218. data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
  219. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
  220. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
  221. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
  222. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
  223. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
  224. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
  225. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
  226. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
  227. data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
  228. data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
  229. data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
  230. data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
  231. data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
  232. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
  233. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
  234. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  235. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
  236. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
  237. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
  238. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
  239. data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
  240. data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
  241. data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
  242. data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
  243. data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
  244. data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
  245. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
  246. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
  247. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
  248. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
  249. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
  250. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
  251. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
  252. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
  253. data/sig/rigor/analysis/baseline.rbs +1 -1
  254. data/sig/rigor/environment.rbs +3 -0
  255. data/sig/rigor/inference.rbs +4 -1
  256. data/sig/rigor/plugin/io_boundary.rbs +2 -0
  257. data/sig/rigor/reflection.rbs +3 -1
  258. data/sig/rigor/scope.rbs +25 -1
  259. data/sig/rigor/type.rbs +95 -1
  260. data/sig/rigor.rbs +16 -0
  261. data/skills/rigor-ci-setup/SKILL.md +2 -2
  262. data/skills/rigor-editor-setup/SKILL.md +2 -2
  263. data/skills/rigor-mcp-setup/SKILL.md +2 -2
  264. data/skills/rigor-monkeypatch-resolve/SKILL.md +1 -1
  265. data/skills/rigor-plugin-review/SKILL.md +3 -3
  266. metadata +73 -3
@@ -18,11 +18,11 @@ plugins:
18
18
  ## What it checks
19
19
 
20
20
  ```text
21
- demo.rb:20:1: info: `User.find` returns User (table: `users`) [plugin.activerecord.model-call]
22
- demo.rb:23:1: info: `User.where` (:admin) on table `users` [plugin.activerecord.model-call]
21
+ demo.rb:18:1: info: `User.find` returns User (table: `users`) [plugin.activerecord.model-call]
22
+ demo.rb:21:1: info: `User.where` (:admin) on table `users` [plugin.activerecord.model-call]
23
23
 
24
- errors_demo.rb:13:1: error: `User.where(emial: ...)` references unknown column `emial` on table `users` (did you mean `:email`?) [plugin.activerecord.unknown-column]
25
- errors_demo.rb:25:1: error: `User.find` expects at least 1 argument, got 0 [plugin.activerecord.wrong-arity]
24
+ errors_demo.rb:12:1: error: `User.where(emial: ...)` references unknown column `emial` on table `users` (did you mean `:email`?) [plugin.activerecord.unknown-column]
25
+ errors_demo.rb:24:1: error: `User.find` expects at least 1 argument, got 0 [plugin.activerecord.wrong-arity]
26
26
  ```
27
27
 
28
28
  | Diagnostic | Severity | Rule |
@@ -30,7 +30,8 @@ errors_demo.rb:25:1: error: `User.find` expects at least 1 argument, got 0 [plug
30
30
  | Recognised `Model.find` / `Model.find_by` / `Model.where` call | `:info` | `plugin.activerecord.model-call` |
31
31
  | `Model.find_by(unknown: ...)` / `Model.where(unknown: ...)` | `:error` | `plugin.activerecord.unknown-column` |
32
32
  | `Model.find` with 0 args | `:error` | `plugin.activerecord.wrong-arity` |
33
- | No schema source (`db/schema.rb` or `db/structure.sql`) readable | `:warning` | `plugin.activerecord.load-error` |
33
+ | No schema source (`db/schema.rb` or `db/structure.sql`) present — reduced mode | `:info` | `plugin.activerecord.load-error` |
34
+ | A schema source that exists but cannot be read or parsed | `:warning` | `plugin.activerecord.load-error` |
34
35
 
35
36
  Did-you-mean suggestions use `DidYouMean` fuzzy matching against
36
37
  the resolved table's column names.
@@ -75,18 +76,90 @@ Chained query methods keep the element type, and iteration
75
76
  scope invoked on a typed relation (`User.where(...).published`)
76
77
  never surfaces a false `call.undefined-method`.
77
78
 
79
+ `User.table_name` types as `String`, and as the exact string
80
+ only when your source says the name: a literal
81
+ `self.table_name = "people"` on the class or on an STI ancestor,
82
+ with nothing in that chain computing the name at runtime (a
83
+ `def self.table_name`, a `class << self` version of it, or an
84
+ interpolated assignment all count as computing it). Every other
85
+ name — anything the plugin derived by pluralizing the class name —
86
+ stays plain `String`.
87
+
88
+ That includes names that look confirmed. A `users` table in your
89
+ schema is not evidence that it is `User`'s table: with a
90
+ `self.table_name_prefix` on the base class, `User` really reads
91
+ `app_users`, and a `users` table belonging to some other model
92
+ would "confirm" the wrong guess. A wrong exact string is worse
93
+ than an honest `String` — code comparing `User.table_name` would
94
+ quietly take the wrong branch — so the plugin pins only what you
95
+ wrote down. `User.quoted_table_name` is always `String`; the
96
+ quoting is up to the database adapter.
97
+
98
+ A model declared inside a Ruby module or class (`Blog::Post`)
99
+ resolves its table the way Rails does for the cases below: the
100
+ namespace is dropped, not flattened into the name, so `Blog::Post`
101
+ reads `posts`, not `blog_posts`. A `table_name_prefix` /
102
+ `table_name_suffix` the enclosing namespace declares as a literal
103
+ (`def self.table_name_prefix = "blog_"`, `class << self` with the
104
+ same, or `mattr_accessor :table_name_prefix, default: "blog_"`) is
105
+ applied on top, so the same model reads `blog_posts` once `Blog`
106
+ sets that. `mattr_writer` does not count — it defines no reader, so
107
+ Rails never actually reads the value back, and neither does the
108
+ plugin.
109
+
110
+ `Blog::Post.table_name` still reads as the plain demodulized name
111
+ (`posts`) when `Blog`'s prefix/suffix is declared in a shape the
112
+ plugin cannot read as a literal (a computed value, two disagreeing
113
+ declarations) — but that string is informational only in this case.
114
+ The plugin does not trust it enough to look up columns against it:
115
+ guessing a bare name is the guess most likely to hit an unrelated
116
+ REAL table in a namespaced app, and a wrong corroboration is worse
117
+ than none, so `Blog::Post`'s column, alias and association checks
118
+ stand down entirely rather than run against a table that might not
119
+ be the real one.
120
+
78
121
  ## Limitations
79
122
 
80
123
  - **Direct-superclass match only.** `class Admin < User` where
81
124
  `User < ApplicationRecord` is not discovered. Either add `User`
82
125
  to `model_base_classes`, or list every concrete model
83
126
  explicitly.
127
+ - **A model nested inside ANOTHER non-abstract model class stands down rather
128
+ than guesses.** `Post::Comment` where `Post < ApplicationRecord` and `Post`
129
+ is not abstract hits a different Rails naming rule entirely — the parent's own
130
+ table name is spliced into the middle of the child's, not a
131
+ prefix/suffix — so the plugin recognises the shape and stands
132
+ `Comment`'s column / alias / association checks down instead of
133
+ computing (or guessing at) the real name. (A model nested inside
134
+ an *abstract* parent class, such as `Base::Comment` where `Base` declares
135
+ `self.abstract_class = true` or `primary_abstract_class`, correctly resolves
136
+ its plain demodulized table name with full column checks.)
137
+ - **External `table_name_prefix` / `table_name_suffix` declarations and
138
+ engines.** Declarations outside `model_search_paths` (e.g. in `lib/` or an
139
+ engine's `isolate_namespace`) are detected across the project and cause
140
+ affected models to safely stand down with an empty column set, rather than
141
+ guessing an incorrect table name. Within `model_search_paths`, model-level
142
+ and base-class `table_name_prefix` declarations (literal or computed) are
143
+ resolved directly.
84
144
  - **PostgreSQL `db/structure.sql` fallback.** When `db/schema.rb` is
85
145
  absent, the plugin parses `db/structure.sql` (the `schema_format =
86
146
  :sql` dump) for the same column/type table. It reads PostgreSQL DDL
87
147
  only; a column whose SQL type has no Ruby mapping (a custom enum,
88
148
  `tsvector`, `ltree`) degrades to `Object` (never dropped), and
89
149
  non-`public`-schema partition tables are skipped.
150
+ - **No committed schema — reduced mode.** A project that ships raw
151
+ migrations and gitignores `db/schema.rb` (the DB-agnostic Rails
152
+ pattern) still gets table names, finders, scopes and associations:
153
+ those are read from your model source, not from the schema. Only
154
+ the column-dependent half stands down — column readers stay
155
+ untyped and `where(col:)` keys are not validated, exactly as they
156
+ are for a table the schema does not describe. The plugin says so
157
+ once per run at `:info`. Committing a schema dump (or pointing
158
+ `schema_file` / `structure_sql_file` at one) turns the column half
159
+ back on from the next cold run — a warm cache keeps serving the
160
+ reduced index until it is invalidated, so use `rigor check
161
+ --no-cache` (or `make cache-clean`) if you want to see the change
162
+ immediately.
90
163
  - **Column reads, not setters.** The plugin types instance-side
91
164
  column *reads* (`user.name`, `user.admin?`) and singular
92
165
  associations, but not the `name=` setter or the dirty-tracking
@@ -44,7 +44,9 @@ Roughly the top ~40 selectors plus their close neighbours, across:
44
44
  `#constantize`, `#pluralize`, …), filters (`#squish`, `#truncate`),
45
45
  `#html_safe`, `#starts_with?` / `#ends_with?`, conversions.
46
46
  - **Time / Date / DateTime** — `.current`, `.zone`, `#yesterday`,
47
- `#tomorrow`, `#beginning_of_*` / `#end_of_*`, `#ago`, `#since`.
47
+ `#tomorrow`, `#beginning_of_*` / `#end_of_*`, `#ago`, `#since`. `Time`
48
+ additionally carries its **whole** Rails instance surface (see below);
49
+ `Date` and `DateTime` carry the same subset they always did.
48
50
  - **Array** — `.wrap`, `#to_sentence`, `#in_groups_of`, `#second` …
49
51
  `#fifth`, `#compact_blank`, `#exclude?`.
50
52
  - **Hash** — `#symbolize_keys` / `#stringify_keys` (+ deep / bang),
@@ -57,25 +59,125 @@ Roughly the top ~40 selectors plus their close neighbours, across:
57
59
  Time.current # without the bundle: call.undefined-method Time.current
58
60
  ```
59
61
 
62
+ ## Durations are typed
63
+
64
+ `1.day`, `5.minutes`, `2.5.hours` and every other multiplier type as
65
+ `ActiveSupport::Duration`, and the arithmetic around them keeps its
66
+ meaning:
67
+
68
+ ```ruby
69
+ 1.day # ActiveSupport::Duration
70
+ Time.current - 30.minutes # Time
71
+ 2 * 1.day # ActiveSupport::Duration
72
+ 1.day + 1.hour # ActiveSupport::Duration
73
+ Date.today - 1.week # Date | Time
74
+ ```
75
+
76
+ `Date ± duration` is a union because that is what Rails does: a
77
+ date-part duration gives you back a `Date`, a sub-day one gives you a
78
+ `Time`.
79
+
80
+ Rigor ships a **partial** signature for `ActiveSupport::Duration`: the
81
+ reader surface — `#to_i` / `#in_seconds`, `#to_f`, `#in_minutes` /
82
+ `#in_hours` / `#in_days` / `#in_weeks` / `#in_months` / `#in_years`,
83
+ `#iso8601`, `#parts` — is typed, so `3.hours.in_minutes` is `Float` and
84
+ `1.day.to_i * 2` is `Integer`. `#ago` / `#until` / `#before` / `#since`
85
+ / `#from_now` / `#after` are NOT part of that surface — they default
86
+ to `Time.current`, and typing them was blocked on Rails' `Time`
87
+ instance extensions being declared first, which the section below now
88
+ does; the multipliers themselves are tracked separately. Every
89
+ other member — the arithmetic operators above aside, `==`, and
90
+ anything else Duration forwards through `method_missing` — resolves
91
+ without a diagnostic too, while the site still counts as a concrete
92
+ receiver for `rigor coverage --protection`. Naming
93
+ `ActiveSupport::Duration` at all would normally be the wrong move — a
94
+ partial signature on a class whose real surface forwards to
95
+ `method_missing` turns every omitted member into a false
96
+ `call.undefined-method` — so the plugin lists it under
97
+ `open_receivers:`, the same exemption `rigor-activerecord` gives
98
+ `ActiveRecord::Relation`.
99
+
100
+ The multiplier only fires on a receiver Rigor has proven numeric, so
101
+ `created_at.day`, `Date.today.year` and your own object's `#days` keep
102
+ the answers they always had.
103
+
104
+ ## The Rails `Time` instance surface is declared, not sampled
105
+
106
+ `Time` is a core Ruby class, so RBS knows it fully and it is **closed**:
107
+ a name the signatures do not declare is reported
108
+ `call.undefined-method`. That makes an omission on `Time` just as much
109
+ of a false positive as a wrong return type, with no gradual middle, so
110
+ this bundle declares the surface ActiveSupport adds by audit against the
111
+ gem's own sources rather than by a "top selectors" sample.
112
+
113
+ ```ruby
114
+ Time.current.to_fs(:db) # String
115
+ Time.current.formatted_offset # String
116
+ Time.current.past? # bool
117
+ Time.current.at_beginning_of_hour # Time
118
+ Time.current.days_ago(3).all_week # Range[Time]
119
+ Time.current.in_time_zone("Hawaii") # untyped (ActiveSupport::TimeWithZone)
120
+ Time.current.definitely_not_here # still call.undefined-method
121
+ ```
122
+
123
+ That is the predicates (`#past?`, `#future?`, `#today?`, `#on_weekend?`,
124
+ …), the whole `#days_ago` / `#months_since` / `#next_occurring` family,
125
+ the quarter and `at_`-prefixed spellings, the `#all_week` / `#all_month`
126
+ / `#all_quarter` / `#all_year` ranges, `#to_fs` / `#to_formatted_s` /
127
+ `#formatted_offset` / `#rfc3339`, `#in_time_zone`, and the `Time.`
128
+ singletons `.days_in_month`, `.days_in_year`, `.rfc3339`, `.use_zone`,
129
+ `.find_zone` / `.find_zone!` and `.zone_default`.
130
+
131
+ Where a return cannot honestly be named it is widened rather than
132
+ guessed: `#in_time_zone` answers an `ActiveSupport::TimeWithZone`, which
133
+ this bundle does not model, so it reads `untyped`.
134
+
135
+ What is left out is twelve names, measured against a real
136
+ `require "active_support/all"`: ten instance and two singleton, every one
137
+ an `alias_method` artefact of ActiveSupport's own `+` / `-` / `<=>` /
138
+ `eql?` / `Time.at` overrides — the `plus_with{,out}_duration`,
139
+ `minus_with{,out}_duration`, `minus_with{,out}_coercion`,
140
+ `compare_with{,out}_coercion`, `eql_with{,out}_coercion` and
141
+ `Time.at_with{,out}_coercion` pairs. They are public at runtime and
142
+ `:nodoc:` in the source, and nothing outside ActiveSupport calls them;
143
+ code that does will see them reported.
144
+
145
+ `Date` and `DateTime` are extended by the same ActiveSupport modules and
146
+ do **not** carry this yet — `Date.current.past?` still reports.
147
+
60
148
  ## No diagnostics, no config
61
149
 
62
- The plugin is RBS-only — it emits no diagnostics and has no
63
- configuration knobs. It contributes its signatures unconditionally when
64
- listed under `plugins:`.
150
+ The plugin emits no diagnostics and has no configuration knobs. It
151
+ contributes its signatures and the Duration typing above —
152
+ unconditionally when listed under `plugins:`.
65
153
 
66
154
  ## Limitations
67
155
 
68
- - **Conservative return types.** `Integer#days` really returns
69
- `ActiveSupport::Duration`, but the bundle types it `untyped` because
70
- the analysis environment usually lacks the Duration class the goal
71
- is to silence undefined-method, not to give precise returns. Likewise
72
- `#html_safe` is typed `String` (not `SafeBuffer`) and `#try` / `#try!`
73
- return `untyped`.
156
+ - **Conservative return types.** `#html_safe` is typed `String` (not
157
+ `SafeBuffer`) and `#try` / `#try!` return `untyped` — the goal for
158
+ those is to silence undefined-method, not to give precise returns.
159
+ (The Duration multipliers are one exception: they are declared
160
+ `untyped` in the bundle and then typed by the plugin instead not
161
+ because the bundle can't name `ActiveSupport::Duration` (it does,
162
+ described above), but because moving the multiplier return itself
163
+ into RBS to match hasn't happened yet. `ActiveSupport::Duration`'s
164
+ own reader surface is the other exception, described above.)
165
+ - **`duration / x` is not typed.** `1.day / 2` is a Duration but
166
+ `1.day / 1.hour` is a plain `24`; the answer depends on the operand,
167
+ so Rigor declines rather than guessing.
168
+ - **`duration + Time` is not typed either.** `30.minutes + Time.now`
169
+ raises at runtime — `Duration#+` cannot coerce a Time, and `-`, `*`,
170
+ and a `Date` or `DateTime` on the right fail the same way — so Rigor
171
+ claims nothing for it. `Time.now + 30.minutes` is the form that has a
172
+ value, and it is typed `Time`.
74
173
  - **Project-private monkey-patches are not covered** — only real
75
174
  ActiveSupport extensions. For your own core-class patches see the
76
175
  `pre_eval:` mechanism ([ADR-17](../../adr/17-monkey-patch-pre-evaluation.md)).
77
- - **Top ~40 selectors, not exhaustive.** ActiveSupport ships hundreds of
78
- extensions; this covers the head of the real-world distribution.
176
+ - **Top ~40 selectors, not exhaustive** except on `Time`, where the
177
+ closed-core-class argument above makes a sample unsound and the audit
178
+ is exhaustive but for the twelve `:nodoc:` alias-chain artefacts named
179
+ there. Elsewhere ActiveSupport ships hundreds of extensions and this
180
+ covers the head of the real-world distribution.
79
181
 
80
182
  ## Plugin internals
81
183
 
@@ -19,11 +19,11 @@ Against a locale catalogue, every statically-resolvable call site
19
19
  is validated:
20
20
 
21
21
  ```text
22
- demo.rb:14:1: info: `t('users.welcome')` resolves in en, ja
23
- errors_demo.rb:12:1: error: missing translation key `users.welcom` in any locale (did you mean `users.welcome`?)
24
- errors_demo.rb:16:1: error: `t('users.welcome')` expects interpolation `name`, got (none)
25
- errors_demo.rb:20:1: warning: `t('users.welcome')` does not use interpolation `extra` (known placeholders: `name`)
26
- errors_demo.rb:25:1: warning: `t('errors.messages.blank')` is missing from locale(s) ja
22
+ demo.rb:12:1: info: `t('users.welcome')` resolves in en, ja [plugin.rails-i18n.translation-call]
23
+ errors_demo.rb:10:1: error: missing translation key `users.welcom` in any locale (did you mean `users.welcome`?) [plugin.rails-i18n.unknown-key]
24
+ errors_demo.rb:14:1: error: `t('users.welcome')` expects interpolation `name`, got (none) [plugin.rails-i18n.wrong-interpolation]
25
+ errors_demo.rb:18:1: warning: `t('users.welcome')` does not use interpolation `extra` (known placeholders: `name`) [plugin.rails-i18n.extra-interpolation]
26
+ errors_demo.rb:23:1: warning: `t('errors.messages.blank')` is missing from locale(s) ja [plugin.rails-i18n.missing-locale]
27
27
  ```
28
28
 
29
29
  1. **Key existence** — a key absent from every locale is flagged,
@@ -29,9 +29,13 @@ RSpec.describe "User" do
29
29
  end
30
30
  ```
31
31
 
32
+ The snippet above is condensed for reading; the output below is what
33
+ Rigor actually prints for the plugin's own demo
34
+ (`plugins/rigor-rspec/demo/`), so the line numbers are that file's:
35
+
32
36
  ```text
33
- spec/user_spec.rb:5:3: warning: duplicate `let(:user)` in this scope (first declared at line 4); the last declaration wins at runtime
34
- spec/user_spec.rb:7:3: error: `let(:tags)` references its own name `tags` — this will infinite-loop at runtime
37
+ spec/errors_spec.rb:23:3: warning: duplicate `let(:user)` in this scope (first declared at line 22); the last declaration wins at runtime [plugin.rspec.duplicate-let]
38
+ spec/errors_spec.rb:27:3: error: `let(:tags)` references its own name `tags` — this will infinite-loop at runtime [plugin.rspec.self-reference]
35
39
  ```
36
40
 
37
41
  1. **Duplicate `let` / `subject` declarations** within the same
@@ -42,6 +42,28 @@ arguments.
42
42
  | `plugin.sidekiq.missing-schedule` | error | `perform_in()` / `perform_at()` called with zero arguments (the schedule is required even when `#perform` takes none) |
43
43
  | `plugin.sidekiq.load-error` | warning | worker discovery failed (parse/read error) — once per file |
44
44
 
45
+ ## What it types
46
+
47
+ `perform_async` / `perform_in` / `perform_at` on a **discovered**
48
+ worker return the job id, so the value you assign is a `String`:
49
+
50
+ ```ruby
51
+ jid = WelcomeEmailWorker.perform_async(123)
52
+ jid.upcase # String — resolved, and checked
53
+ OtherThing.perform_async # untouched: not a discovered worker
54
+ ```
55
+
56
+ `perform_inline` is not typed — it runs the job in-process and returns
57
+ whatever your `#perform` returns.
58
+
59
+ The type is plain `String`, not `String?`, even though
60
+ `Sidekiq::Client#push` returns nil when a client middleware halts the
61
+ chain. That path needs a middleware in your own app that returns false
62
+ from `#call` — rare, deliberate, and code that already knows to check —
63
+ whereas typing it nullable puts a `call.possible-nil-receiver` error on
64
+ the ordinary `jid = W.perform_async(id); jid.length`. Rigor takes the
65
+ answer that is silent on the common code.
66
+
45
67
  ## Configuration
46
68
 
47
69
  ```yaml
@@ -5,6 +5,7 @@ require "prism"
5
5
  require_relative "../../source/node_children"
6
6
  require_relative "../../inference/optimistic_origin"
7
7
  require_relative "inferred_param_guard"
8
+ require_relative "published_constant_guard"
8
9
 
9
10
  module Rigor
10
11
  module Analysis
@@ -119,6 +120,17 @@ module Rigor
119
120
  predicate_type = scope.type_of(predicate)
120
121
  return unless predicate_type.is_a?(Type::Constant)
121
122
 
123
+ # Issue #644 — a predicate whose constancy rests on a value constant declared in ANOTHER file is
124
+ # not a logic error the reader's author can see: `if MODE == :production` folds only because the
125
+ # project-wide table published `MODE`, and a configuration constant read in ten files would put
126
+ # this warning in all ten. The value stays published (dispatch and argument typing keep it); only
127
+ # the firing is withheld, which is the direction the carrier discipline allows.
128
+ #
129
+ # LAST of the gates on purpose. It is the only one that can resolve a def and walk its body, and a
130
+ # predicate that does not fold to a `Type::Constant` was never going to fire — so the fold check
131
+ # above is what keeps the hop off every ordinary `if` in a project that publishes anything at all.
132
+ return if PublishedConstantGuard.rooted?(predicate, scope)
133
+
122
134
  polarity = predicate_type.value.nil? || predicate_type.value == false ? :falsey : :truthy
123
135
  @results << Result.new(node: predicate, polarity: polarity)
124
136
  end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../../inference/version_guard"
6
+ require_relative "../../source/node_children"
7
+
8
+ module Rigor
9
+ module Analysis
10
+ module CheckRules
11
+ # ADR-47 WD5 — drops the diagnostics that land inside the **dead arm of a decidable version guard**
12
+ # (issue #627).
13
+ #
14
+ # {Inference::StatementEvaluator} already skips evaluating that arm, so its writes never join into the
15
+ # post-`if` scope. Skipping the evaluation is not by itself enough to silence the arm, though: the
16
+ # rule walk visits every node of the file whether or not the evaluator typed it, so a call whose
17
+ # receiver is typeable on its own — a constant, a literal — still reports. `mail/lib/mail/yaml.rb`'s
18
+ #
19
+ # ::YAML.safe_load(yaml, permitted_classes) # the Psych < 3.1 positional form
20
+ #
21
+ # is exactly that shape: honest against the Ruby 4 Psych signature, and unreachable on the Ruby the
22
+ # user is checking with. This is the second half of the fix — the arm stops producing diagnostics.
23
+ #
24
+ # The dead arms are found by re-asking {Inference::VersionGuard}, which is a pure function of the AST,
25
+ # so this filter and the evaluator's arm elision cannot disagree about which arm is dead.
26
+ #
27
+ # Applied to the type / flow rules only, and BEFORE `suppression.*` joins the list: a malformed
28
+ # `# rigor:disable` marker inside a dead arm is still a real authoring error — the marker's own
29
+ # well-formedness does not depend on whether the code around it runs.
30
+ module DeadVersionGuardArms
31
+ module_function
32
+
33
+ # @param diagnostics [Array<Rigor::Analysis::Diagnostic>]
34
+ # @param root [Prism::Node]
35
+ # @return [Array<Rigor::Analysis::Diagnostic>]
36
+ def filter(diagnostics, root)
37
+ # The scan is a whole-file walk, so it is paid only when there is something to drop. A file with
38
+ # no diagnostics — the overwhelming majority — never walks.
39
+ return diagnostics if diagnostics.empty?
40
+
41
+ arms = scan(root)
42
+ return diagnostics if arms.empty?
43
+
44
+ diagnostics.reject { |diagnostic| arms.any? { |arm| covers?(arm, diagnostic) } }
45
+ end
46
+
47
+ # @param root [Prism::Node]
48
+ # @return [Array<Prism::Location>] the source ranges of every dead version-guard arm
49
+ def scan(root)
50
+ arms = []
51
+ collect(root, arms)
52
+ arms
53
+ end
54
+
55
+ def collect(node, arms)
56
+ return unless node.is_a?(Prism::Node)
57
+
58
+ dead = dead_arm(node)
59
+ arms << dead.location if dead
60
+ # Nothing inside a dead arm can produce a surviving diagnostic, so the walk does not descend into
61
+ # it — a nested guard there would only add a range already covered.
62
+ node.rigor_each_child { |child| collect(child, arms) unless dead && child.equal?(dead) }
63
+ end
64
+ private_class_method :collect
65
+
66
+ # The arm that cannot run, or nil when the guard is undecidable (both arms live — the pre-existing
67
+ # behaviour) or the dead arm is absent (`foo if RUBY_VERSION >= "3.1"` has no `else`).
68
+ def dead_arm(node)
69
+ case node
70
+ when Prism::IfNode
71
+ case Inference::VersionGuard.verdict(node.predicate)
72
+ when :truthy then node.subsequent
73
+ when :falsey then node.statements
74
+ end
75
+ when Prism::UnlessNode
76
+ # `unless` runs its body on the FALSEY edge, so the arms are swapped.
77
+ case Inference::VersionGuard.verdict(node.predicate)
78
+ when :truthy then node.statements
79
+ when :falsey then node.else_clause
80
+ end
81
+ end
82
+ end
83
+ private_class_method :dead_arm
84
+
85
+ # Prism columns are 0-based and `Diagnostic#column` is 1-based; the location's end is exclusive.
86
+ # Compared as `[line, column]` pairs rather than by line alone so a one-line guard
87
+ # (`RUBY_VERSION >= "3.1" ? a(1) : a(1, 2)`) drops only the dead half.
88
+ def covers?(location, diagnostic)
89
+ position = [diagnostic.line, diagnostic.column - 1]
90
+ return false if (position <=> [location.start_line, location.start_column]).negative?
91
+
92
+ (position <=> [location.end_line, location.end_column]).negative?
93
+ end
94
+ private_class_method :covers?
95
+ end
96
+ end
97
+ end
98
+ end
@@ -69,9 +69,9 @@ module Rigor
69
69
 
70
70
  case node
71
71
  when Prism::ClassNode, Prism::ModuleNode
72
- name = Source::ConstantPath.qualified_name(node.constant_path)
73
- if name
74
- walk(node.body, qualified_prefix + [name]) if node.body
72
+ child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
73
+ if child_prefix
74
+ walk(node.body, child_prefix) if node.body
75
75
  return
76
76
  end
77
77
  when Prism::DefNode