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
@@ -63,19 +63,31 @@ syntax-highlighted for a tty — through
63
63
  colorizer; `--no-color` (and the `NO_COLOR` environment
64
64
  variable) disable the colour.
65
65
 
66
- ## `rigor type-of` — one position
66
+ ## `rigor type-of` — exact positions or a whole line
67
67
 
68
- When you only need one expression's type — typically while
69
- chasing down why a diagnostic did or did not fire — query a
70
- single position:
68
+ When you need a few expression types — typically while chasing
69
+ down why a diagnostic did or did not fire — query the exact
70
+ positions together so Rigor loads the project and each source
71
+ file once:
71
72
 
72
73
  ```sh
73
- rigor type-of lib/example.rb:12:8
74
+ rigor type-of lib/example.rb:12:8 lib/example.rb:12:14
74
75
  ```
75
76
 
76
- `--format=json` emits a machine-readable result for tooling.
77
- This is the same query the editor integration answers on
78
- hover.
77
+ Leave off the column to avoid counting it by hand. Rigor prints
78
+ a table of the first 40 expressions starting on the line,
79
+ outermost first at each 1-based column, and marks a truncated
80
+ table:
81
+
82
+ ```sh
83
+ rigor type-of lib/example.rb:12
84
+ ```
85
+
86
+ `--format=json` emits a machine-readable result for tooling: one
87
+ result stays a flat object, while several results use a `results`
88
+ array. Line queries add `line_enumerations` metadata with the
89
+ shown and total expression counts. An exact position is the same
90
+ query the editor integration answers on hover.
79
91
 
80
92
  ## `rigor trace` — watch the inference happen
81
93
 
@@ -49,6 +49,10 @@ each plugin's options — but the families today are:
49
49
  `rigor-factorybot`.
50
50
  - **dry-rb** — `rigor-dry-types`, `rigor-dry-schema`,
51
51
  `rigor-dry-struct`, `rigor-dry-validation`.
52
+ - **FFI** — `rigor-ffi` and sub-plugins (`rigor-ffi-rzmq`,
53
+ `rigor-rbnacl`, `rigor-sassc`, `rigor-ethon`). For plugin authors,
54
+ `rigor-ffi` provides the `ffi_binding_recognizer` class DSL on
55
+ `Rigor::Plugin::Base` to recognize custom binding definitions.
52
56
  - **Other ecosystems** — `rigor-sinatra`, `rigor-hanami`,
53
57
  `rigor-devise`, `rigor-pundit`, `rigor-sidekiq`,
54
58
  `rigor-graphql`, `rigor-statesman`, `rigor-sorbet`,
@@ -56,6 +56,43 @@ plugins:
56
56
  view_search_paths: ["app/views"] # default
57
57
  ```
58
58
 
59
+ ## What it types
60
+
61
+ Inside a controller, `params`, `request`, `session`, `flash` and
62
+ `cookies` type as their Action Pack classes, and so do the chains
63
+ built on them:
64
+
65
+ ```ruby
66
+ request.post? # bool — and so do get? / put? / patch? / delete? /
67
+ # head? / options? / trace? / link? / unlink? /
68
+ # xhr? / xml_http_request? / ssl? / local? / form_data?
69
+ flash.now # ActionDispatch::Flash::FlashNow
70
+ flash.keep # ActionDispatch::Flash::FlashHash
71
+ flash[:notice] = "hi" # "hi" — an assignment is its right-hand side
72
+ ```
73
+
74
+ Rigor ships **no signature** for these Action Pack classes, on
75
+ purpose: the receiver becomes concrete (so `rigor coverage
76
+ --protection` counts the site) while the method surface stays
77
+ lenient, so `request.headers`, `flash.now[:alert] = x` and anything
78
+ else the framework adds resolve without a diagnostic. A partial
79
+ signature would be worse than none — every member it omitted would
80
+ become a false `call.undefined-method`.
81
+
82
+ The predicates are typed `bool` — the union of `true` and `false` —
83
+ which is both the real contract (every one of them is an `==`,
84
+ `match?` or `include?` in Rails or Rack) and the reason they are safe
85
+ to type at all: a condition that folds needs to prove *one* constant,
86
+ and a union of both never does. `return unless request.post?` and
87
+ `mode = request.get? ? :a : :b` read exactly as they did before.
88
+
89
+ `request.format` is **not** typed. That inertness argument is
90
+ narrower than it looks — it holds for a union of the two boolean
91
+ constants, not for a union of ordinary classes, which is nil-free and
92
+ so *can* fold a condition — and `Mime::NullType`, the value `format`
93
+ returns when there is no format, answers `nil?` with `true` while
94
+ being a real object. Typing it needs a nil-aware answer.
95
+
59
96
  ## Limitations
60
97
 
61
98
  - **Implicit-self helpers only.** `*_path` / `*_url` calls with an
@@ -69,6 +106,30 @@ plugins:
69
106
  knows what `rigor-rails-routes` published, and `permit`
70
107
  validation only what `rigor-activerecord` published — enabling
71
108
  those producers widens what this plugin can check.
109
+ - **`params[:key]` stays untyped.** Inside a controller, `params`
110
+ types as `ActionController::Parameters`, and so does the result
111
+ of every builder method that always returns one — `require`,
112
+ `permit`, `permit!`, `expect`, `slice`, `slice!`, `except`,
113
+ `without`, `extract!`, `merge`, `merge!`, `reverse_merge`,
114
+ `reverse_merge!`, `with_defaults`, `with_defaults!`, `compact`,
115
+ `compact_blank`, `deep_dup` — so a chain built from them keeps a
116
+ concrete receiver throughout. A subscript read is deliberately
117
+ left untyped: `params[:missing]` is `nil` at runtime, and a type
118
+ that says otherwise would let the flow rules fold live
119
+ conditions (`if params[:q]`, `url.nil?`) to a constant and report
120
+ working code. Methods whose result depends on the call — `dig`,
121
+ `fetch`, `compact!`, and the block-less `select` / `reject` /
122
+ `transform_keys` / `transform_values` — are untyped for the same
123
+ reason.
124
+ - **`flash[:key]` and `session[:key]` stay untyped too**, for that
125
+ same reason and measured the same way. Both are leaf reads that
126
+ return whatever was stored — or `nil` for a key that is not set.
127
+ A non-nil type folds `mode = flash[:notice] ? … : …` to one arm and
128
+ reports the live guard after it; a nullable one puts
129
+ `call.possible-nil-receiver` on `note = flash[:notice];
130
+ note.upcase`. Writing through them is unaffected: `flash[:k] = v`
131
+ is `v` because that is what an assignment expression means in Ruby,
132
+ with no rule needed.
72
133
 
73
134
  ## Plugin internals
74
135
 
@@ -30,7 +30,8 @@ errors_demo.rb:24: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
 
@@ -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