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
@@ -18,21 +18,31 @@ module Rigor
18
18
  freeze
19
19
  end
20
20
 
21
+ # Entries are keyed by the de-rooted constant path (`"User"`, `"Admin::User"` — never `"::User"`;
22
+ # see {AttachmentDiscoverer}), while a QUERY may legitimately arrive rooted: a `Nominal` receiver
23
+ # for `::User` renders its class name as `"::User"`. The root marker is dropped here, once, so no
24
+ # caller needs an `attachments_for(name) || attachments_for("::#{name}")` retry (#621).
21
25
  def attachments_for(class_name)
22
- entries[class_name.to_s]
26
+ entries[class_name.to_s.delete_prefix("::")]
23
27
  end
24
28
 
25
29
  def class_names = entries.keys
26
30
 
27
31
  def empty? = entries.empty?
28
32
 
33
+ # Rows that name the SAME class are UNIONed, not replaced. A model is routinely declared more than
34
+ # once — `app/models/user.rb` holds the real class and a concern or an engine's file reopens it —
35
+ # and a reopen ADDS attachments rather than replacing the class, so keeping only the last row in the
36
+ # glob dropped the earlier declaration's `has_one_attached` and left `user.avatar` untyped. A
37
+ # redeclared attachment NAME resolves to the later row, as it does at load time.
29
38
  def self.build(rows:)
30
39
  entries = rows.each_with_object({}) do |row, acc|
31
40
  class_name = row.fetch(:class_name)
32
- attachments = Array(row[:attachments]).map(&:freeze).freeze
33
- acc[class_name] = attachments
41
+ by_name = acc[class_name] || {}
42
+ Array(row[:attachments]).each { |attachment| by_name[attachment[:name]] = attachment.freeze }
43
+ acc[class_name] = by_name
34
44
  end
35
- new(entries.freeze)
45
+ new(entries.transform_values { |by_name| by_name.values.freeze }.freeze)
36
46
  end
37
47
  end
38
48
  end
@@ -37,7 +37,10 @@ module Rigor
37
37
  class Activestorage < Rigor::Plugin::Base
38
38
  manifest(
39
39
  id: "activestorage",
40
- version: "0.1.0",
40
+ # Bumped 2026-09-02 (#621) — attachment owners are keyed by their de-rooted name and a reopened
41
+ # class's attachments are UNIONed rather than clobbered, so a cached 0.1.0 index can be missing the
42
+ # attachments the merge restores.
43
+ version: "0.2.0",
41
44
  description: "Types ActiveStorage attachment macros (has_one_attached / has_many_attached) on AR models.",
42
45
  config_schema: {
43
46
  "model_search_paths" => { kind: :array, default: ["app/models"] }
@@ -91,8 +94,8 @@ module Rigor
91
94
  receiver_type = scope.type_of(call_node.receiver)
92
95
  next nil unless receiver_type.is_a?(Rigor::Type::Nominal)
93
96
 
94
- attachments = index.attachments_for(receiver_type.class_name) ||
95
- index.attachments_for("::#{receiver_type.class_name}")
97
+ # `AttachmentIndex#attachments_for` de-roots the query itself (#621) — no retry arm here.
98
+ attachments = index.attachments_for(receiver_type.class_name)
96
99
  next nil if attachments.nil?
97
100
 
98
101
  attachment = attachments.find { |a| a[:name] == call_node.name.to_s }
@@ -53,7 +53,7 @@ module Rigor
53
53
  module_function
54
54
 
55
55
  def attributions
56
- clock_rows + zone_rows + current_rows + notification_rows + [in_time_zone_row]
56
+ clock_rows + zone_rows + current_rows + notification_rows + in_time_zone_rows
57
57
  end
58
58
 
59
59
  def clock_rows
@@ -90,19 +90,36 @@ module Rigor
90
90
  row(TIME, :zone=, ["global.write"], singleton: true,
91
91
  why: "rewrites the process/fiber's time zone for everything downstream"),
92
92
  row(TIME, :use_zone, ["global.write"], singleton: true,
93
- why: "swaps the zone around a block; the block's own origins join by containment")
93
+ why: "swaps the zone around a block; the block's own origins join by containment"),
94
+ # Issue #658 declares these three in the RBS bundle, so they get their attributions here for
95
+ # the same reason `DateTime#in_time_zone` did: the impurity is invisible at the signature.
96
+ row(TIME, :zone_default, ZONE_READ, singleton: true,
97
+ why: "the `attr_accessor` behind `Time.zone`'s fallback — the same mutable process state"),
98
+ row(TIME, :zone_default=, ["global.write"], singleton: true,
99
+ why: "rewrites the fallback zone for every fiber that has not set its own"),
100
+ row(TIME, :days_in_month, CLOCK, singleton: true,
101
+ why: "`year` defaults to `current.year`, so an omitted second argument reads the clock"),
102
+ row(TIME, :days_in_year, CLOCK, singleton: true, why: "the same default, through `days_in_month`")
94
103
  ]
95
104
  end
96
105
 
97
- # `DateTime#in_time_zone` — a gap the #388 `%a{pure}` sweep over `sig/active_support/core_ext.rbs`
106
+ # `#in_time_zone` — a gap the #388 `%a{pure}` sweep over `sig/active_support/core_ext.rbs`
98
107
  # surfaced: `in_time_zone(zone = ::Time.zone)` reads `Time.zone` through its own default argument
99
108
  # whenever the caller doesn't pass one explicitly, so it cannot be annotated `%a{pure}` there. It
100
109
  # belongs here rather than in the RBS because the label is `global.read` alone (not the
101
110
  # `nondet.time` + `global.read` pair `CLOCK` carries) — `in_time_zone` converts an already-fixed
102
111
  # instant into a zone, it does not read the clock itself.
103
- def in_time_zone_row
104
- row("DateTime", :in_time_zone, ZONE_READ,
105
- why: "the default argument reads `Time.zone` when the caller doesn't name one explicitly")
112
+ #
113
+ # `DateAndTime::Zones` is included into `Time` as well as `Date` and `DateTime`, so all three take
114
+ # the identical row: #658 declared the `Time` spelling in the RBS bundle and #670 the `Date` one,
115
+ # which is what earns `Date` its row here — the label is a fact about the module, not about the
116
+ # receiver, and the three only ever differed in which spellings the bundle had got round to
117
+ # declaring.
118
+ def in_time_zone_rows
119
+ [TIME, DATE, DATETIME].map do |receiver|
120
+ row(receiver, :in_time_zone, ZONE_READ,
121
+ why: "the default argument reads `Time.zone` when the caller doesn't name one explicitly")
122
+ end
106
123
  end
107
124
 
108
125
  def current_rows
@@ -6,11 +6,31 @@ require_relative "activesupport_core_ext/effects"
6
6
 
7
7
  module Rigor
8
8
  module Plugin
9
- # ADR-25 — a pure RBS-bundle plugin. It ships NO analyzer code: no `diagnostics_for_file`, no
10
- # `dynamic_return`. Its whole contribution is the manifest's `signature_paths: ["sig"]`, which
11
- # declares the bundled ActiveSupport `core_ext` RBS directory. `Plugin::Loader` resolves that
12
- # directory against this gem's root and `Environment.for_project` merges it into the RBS environment,
13
- # so the ActiveSupport core-extension selectors (`3.days`, `"x".squish`, `Time.current`, …) resolve.
9
+ # ADR-25 — very nearly a pure RBS-bundle plugin. It emits no diagnostic and declares no producer; its
10
+ # main contribution is the manifest's `signature_paths: ["sig"]`, which declares the bundled
11
+ # ActiveSupport `core_ext` RBS directory. `Plugin::Loader` resolves that directory against this gem's
12
+ # root and `Environment.for_project` merges it into the RBS environment, so the ActiveSupport
13
+ # core-extension selectors (`3.days`, `"x".squish`, `Time.current`, …) resolve.
14
+ #
15
+ # The one exception is the {DURATION_MULTIPLIERS} pair of `dynamic_return` rules, which give `1.day` /
16
+ # `5.minutes` an `ActiveSupport::Duration` nominal and keep the arithmetic around it honest. They
17
+ # cannot live in the RBS bundle; see the comment on that constant for why.
18
+ #
19
+ # Issue #632 — `sig/active_support/core_ext.rbs` now ALSO declares `ActiveSupport::Duration` itself
20
+ # (the reader surface: `ago`/`until`/`since`/`from_now`/`before`/`after`, `to_i`/`in_seconds`, `to_f`,
21
+ # `in_minutes`/`in_hours`/`in_days`/`in_weeks`/`in_months`/`in_years`, `iso8601`, `parts`), which is
22
+ # what lets `30.minutes.ago.iso8601` / `1.day.to_i * 2` resolve past the first reader. That is new: the
23
+ # multiplier fix above went out of its way NOT to name the class (naming it makes it RBS-known, and a
24
+ # known class with an incomplete signature turns every member the declaration omits into a false
25
+ # `call.undefined-method` — Duration forwards everything else to the wrapped numeric via
26
+ # `method_missing`). Declaring it now is safe because `ActiveSupport::Duration` is protected TWICE:
27
+ # this manifest's own `open_receivers:` (ADR-26, below — the mechanism `rigor-activerecord` uses for
28
+ # `ActiveRecord::Relation`) covers the plugin-loaded path, and
29
+ # `Rigor::Analysis::CheckRules::GEM_OVERLAY_OPEN_RECEIVERS` covers the auto-applied
30
+ # `data/gem_overlay/activesupport/core_ext.rbs` twin (issue #449's overlay-has-no-manifest gap — see that
31
+ # constant's comment). Either alone would keep `call.undefined-method` from ever firing against a
32
+ # Duration receiver; both exist because a plugin manifest cannot protect a receiver an unrelated,
33
+ # manifest-less RBS file also declares.
14
34
  #
15
35
  # Activate it like any plugin — no path, no vendoring:
16
36
  #
@@ -22,11 +42,20 @@ module Rigor
22
42
  class ActivesupportCoreExt < Rigor::Plugin::Base
23
43
  manifest(
24
44
  id: "activesupport-core-ext",
25
- # Bumped 2026-05-28 — added Date#midnight / at_midnight / beginning_of_day / end_of_day (Rails
26
- # aliases that return Time, not Date).
27
- version: "0.2.0",
28
- description: "RBS bundle for the most-frequently-flagged ActiveSupport core_ext extensions.",
45
+ # Bumped 2026-09-02 (#632) `ActiveSupport::Duration`'s reader surface (`ago`/`to_i`/`iso8601`/…)
46
+ # is now declared, open_receivers-protected; see the class comment above.
47
+ version: "0.4.0",
48
+ description: "RBS bundle for the most-frequently-flagged ActiveSupport core_ext extensions, " \
49
+ "plus the `ActiveSupport::Duration` type of the numeric time multipliers and its " \
50
+ "reader surface.",
29
51
  signature_paths: ["sig"],
52
+ # ADR-26 — `ActiveSupport::Duration` forwards any method its own class doesn't define to the
53
+ # wrapped numeric via `method_missing` (audited against ActiveSupport 8.1.3.1's
54
+ # `lib/active_support/duration.rb`), so `sig/`'s necessarily-partial declaration of it must not let
55
+ # `call.undefined-method` fire against it. Distinct from the RECEIVER-side FP the multiplier
56
+ # `dynamic_return` gate guards (`Time#day` vs `Duration#day`, in the class comment above) — this is
57
+ # the class's OWN unenumerable member set.
58
+ open_receivers: ["ActiveSupport::Duration"],
30
59
  # ADR-103 WD10 (#387) — the IMPURE half of ActiveSupport: the clock, the notification bus and
31
60
  # `CurrentAttributes`. The `%a{pure}` sweep over the predicate surface is issue #388 and lands in
32
61
  # `sig/`, not here. See {Effects}.
@@ -34,6 +63,232 @@ module Rigor
34
63
  effect_labels: %w[rails.current.read rails.current.write],
35
64
  effect_attributions: Effects.attributions
36
65
  )
66
+
67
+ # Issue #534 item 3 — `core_ext/numeric/time`'s Duration multipliers. The 2026-09-01 corpus sweep
68
+ # measured ~265 mastodon sites on these, every one of them `Dynamic[top]` with cause
69
+ # `explicit_untyped`: the RBS bundle declares them `() -> untyped` so that `1.day` merely *resolves*,
70
+ # and the value it produces then carries nothing.
71
+ #
72
+ # ## Why this is a `dynamic_return` and not an RBS return type
73
+ #
74
+ # The obvious fix — changing `core_ext.rbs`'s `def day: () -> untyped` to
75
+ # `() -> ActiveSupport::Duration` — was not available when this rule was written (#534): RBS could
76
+ # not name a class the bundle did not declare without making it RBS-KNOWN, and a known class with an
77
+ # incomplete signature was worse than no class at all — `call.undefined-method` would stop declining
78
+ # at `Rigor::Reflection.rbs_class_known?`, so every member the declaration omitted would become a
79
+ # false positive on working code. Issue #632 changed that premise: `sig/active_support/core_ext.rbs`
80
+ # now declares `ActiveSupport::Duration`'s reader surface, protected by `open_receivers:` above
81
+ # rather than by staying unnamed, so the fix IS available now — #660 tracks moving the multiplier
82
+ # return itself into RBS to match. This rule stays today's answer because that move hasn't happened
83
+ # yet, not because RBS still cannot express it. `Duration`'s real surface is `method_missing`-forwarded
84
+ # to the wrapped numeric plus `ago` / `since` / `from_now` / `until` / `before` / `after` / `in_*` /
85
+ # `iso8601` and every `Numeric` operator, so "omits a member" is guaranteed. The contribution tier sits ABOVE
86
+ # `RbsDispatch` (`MethodDispatcher#resolve`), so declaring the multiplier in RBS and typing it here
87
+ # is not a contradiction: the RBS declaration is what makes `1.day` resolve at all, and this supplies
88
+ # the answer the declaration deliberately withholds.
89
+ #
90
+ # ## The receiver gate is the FP boundary
91
+ #
92
+ # `day`, `month`, `year`, `hour`, `minute`, `second` and `week` are all real methods on `Time` and
93
+ # `Date` that return an `Integer`, so a name-only rule would silently retype `created_at.day` from
94
+ # `Integer` to a Duration — a wrong precise type on a hot core method. The gate therefore admits only
95
+ # a receiver Rigor has actually proven numeric, including the folded literal carriers (`Constant[1]`,
96
+ # `IntegerRange`) which is what `1.day` is at the call site, and declines on everything else,
97
+ # `Dynamic` included. Declining on `Dynamic` costs a few sites on untyped receivers and is what keeps
98
+ # a `record.days` on some project's own object untouched.
99
+ DURATION_MULTIPLIERS = %i[
100
+ second seconds minute minutes hour hours day days week weeks
101
+ fortnight fortnights month months year years
102
+ ].freeze
103
+
104
+ # The nominal every multiplier returns. It is a *lenient* nominal: the site becomes a concrete
105
+ # receiver that `coverage --protection` counts, and `call.undefined-method` never fires against it
106
+ # (ADR-26 `open_receivers:` below) regardless of what its RBS does or doesn't enumerate. Issue #632
107
+ # gave `ActiveSupport::Duration` an actual (partial) RBS declaration — `sig/active_support/
108
+ # core_ext.rbs`'s reader surface, `to_i` / `in_seconds` / `to_f` / the `in_minutes` family / `iso8601`
109
+ # / `parts` — so THOSE now resolve precisely instead of to `Dynamic`. `ago` / `until` / `before` /
110
+ # `since` / `from_now` / `after` stay undeclared on purpose (issue #659, blocked on #658: typing them
111
+ # needs the Rails `Time` instance surface first) and the rest of Duration's real API — the arithmetic
112
+ # operators, `==`, anything `method_missing` forwards to the wrapped numeric — was simply never in
113
+ # scope. Every one of those still resolves lenient-to-`Dynamic` rather than to a diagnostic.
114
+ DURATION_NOMINAL = "ActiveSupport::Duration"
115
+
116
+ # The receiver class names the multipliers are real methods on. `Numeric` is included for a receiver
117
+ # typed at the abstract class (a `Numeric` parameter); `Rational` / `BigDecimal` are not, because the
118
+ # RBS bundle does not declare the multipliers on them, so `2r.days` does not resolve in the first
119
+ # place and typing its result would be a claim about a call Rigor cannot see.
120
+ DURATION_RECEIVER_CLASSES = %w[Integer Float Numeric].freeze
121
+
122
+ dynamic_return methods: DURATION_MULTIPLIERS do |call_node, scope|
123
+ next nil unless call_node.is_a?(Prism::CallNode)
124
+ next nil unless call_node.receiver # `day` with no receiver is somebody's own method
125
+ next nil unless call_node.arguments.nil?
126
+ next nil unless call_node.block.nil?
127
+ next nil unless numeric_receiver?(scope&.type_of(call_node.receiver))
128
+
129
+ Rigor::Type::Combinator.nominal_of(DURATION_NOMINAL)
130
+ end
131
+
132
+ # ## Duration arithmetic — a correction, not a feature
133
+ #
134
+ # Typing the multiplier alone REGRESSES three shapes, measured on a fixture before this rule existed.
135
+ # A Duration is an ordinary argument to the core operators, and the overload selector has to choose
136
+ # among `Time#-`'s `(Time) -> Float` / `(Numeric) -> Time` with an argument whose class it has no RBS
137
+ # for. Given `Dynamic[top]` — the pre-#534 answer — it widened to the union of the returns and stayed
138
+ # lenient; given a *named* class it commits, and it commits wrong:
139
+ #
140
+ # Time.now - 30.minutes before: Dynamic[Float | Time] after (no rule): Float
141
+ # 2 * 1.day before: Dynamic[BigDecimal | …] after (no rule): Integer
142
+ # 3 + 1.day before: Dynamic[BigDecimal | …] after (no rule): Integer
143
+ # Date.today - 1.week before: Dynamic[Date | Rational] after (no rule): Rational
144
+ #
145
+ # Each of those is a wrong precise type, and each turns the next line into a false positive:
146
+ # `(Time.current - 30.minutes).beginning_of_day` became `undefined method 'beginning_of_day' for
147
+ # Float`, `(2 * 1.day).ago` became one for Integer, `(Date.today - 1.week).year` one for Rational
148
+ # (5/5 measured). `Time.current - 1.day` is ordinary Rails, so shipping the multipliers without this
149
+ # rule would trade ~265 typed sites for diagnostics on working code — the trade AGENTS.md forbids.
150
+ # The rule restores the runtime answer at each of those sites:
151
+ #
152
+ # - `Time`/`DateTime` ± Duration → the receiver's own class. Rails' `plus_with_duration` /
153
+ # `minus_with_duration` return `other.since(self)` / `other.until(self)`, i.e. the receiver kind.
154
+ # - `Date` ± Duration → `Date | Time`, and the union is the honest answer rather than a hedge:
155
+ # `Duration#since` returns a Date for a date-part duration and a Time for a sub-day one, so
156
+ # `Date.today + 1.day` really is a Date and `Date.today + 1.hour` really is a Time. Rigor's union
157
+ # receiver declines a method that only one arm has (measured: `.hour` on `Date | Time` is silent),
158
+ # so the union costs nothing in false positives while keeping both arms named.
159
+ # - Numeric ± Duration, and Numeric * Duration → Duration. `Duration#coerce` wraps the numeric in a
160
+ # `Duration::Scalar`, so `2 * 1.day` is a two-day Duration, not an Integer.
161
+ # - Duration ± Duration and Duration * Numeric → Duration. Not a correction (the RBS-less receiver
162
+ # was already lenient) but the same contract, and it is what carries a `1.day + 1.hour` chain.
163
+ #
164
+ # `/` is deliberately absent: `1.day / 2` is a Duration but `1.day / 1.hour` is a plain `24`, and the
165
+ # answer depends on the operand kind in a way this table would have to guess at. So is
166
+ # `Duration * Duration` — not a quantity Rails promises anything about. So is a Duration RECEIVER
167
+ # with a `Time` / `DateTime` / `Date` argument (#588): `30.minutes + Time.now` raises, under every
168
+ # operator — the table types values, and a crashing expression has none.
169
+
170
+ # Receiver class → what `receiver <op> duration` is. `Time` / `DateTime` keep their own kind; `Date`
171
+ # widens to the two kinds `Duration#since` can produce; a numeric or another Duration is a Duration.
172
+ DURATION_ARITHMETIC_SELF_KINDS = %w[Time DateTime].freeze
173
+ DURATION_ARITHMETIC_NUMERIC_KINDS = %w[Integer Float Numeric].freeze
174
+ DURATION_ARITHMETIC_DATE_KINDS = %w[Date Time].freeze
175
+
176
+ # The operand kinds whose arithmetic with a Duration is itself a Duration — on BOTH sides of the
177
+ # operator. See {#duration_valued_pair?} for why the argument side is checked too.
178
+ DURATION_VALUED_OPERAND_KINDS = %i[duration numeric].freeze
179
+
180
+ dynamic_return methods: %i[+ - *] do |call_node, scope|
181
+ next nil unless call_node.is_a?(Prism::CallNode)
182
+
183
+ argument = duration_arithmetic_argument(call_node)
184
+ next nil if argument.nil?
185
+
186
+ # The ARGUMENT is checked before the receiver: it is the cheaper of the two on the overwhelmingly
187
+ # common shapes (`i + 1`, `n * 2`), and a call with no Duration operand must cost as little as
188
+ # possible — this rule is consulted on every `+` / `-` / `*` in the project. The early exit is
189
+ # what makes the ordering worth anything: an argument that is no Duration operand at all
190
+ # (`"a" + b`, `list + other`) declines here and never types its receiver on this rule's behalf.
191
+ argument_kind = duration_operand_kind(scope&.type_of(argument))
192
+ next nil if argument_kind.nil?
193
+
194
+ receiver_kind = duration_operand_kind(scope&.type_of(call_node.receiver))
195
+ next nil if receiver_kind.nil?
196
+
197
+ duration_arithmetic_result(call_node.name, receiver_kind, argument_kind)
198
+ end
199
+
200
+ # ADR-88 WD1 — both tables are static and the RBS bundle ships with the gem; nothing here reads a
201
+ # project file, so a project enabling this plugin stays incremental-capable.
202
+ def incremental_state_fingerprint
203
+ "static-duration-multipliers"
204
+ end
205
+
206
+ private
207
+
208
+ # True only for a receiver Rigor has actually proven numeric. `Constant` covers the literal form the
209
+ # idiom is written in (`1.day`); `IntegerRange` covers a refined integer; `Nominal` covers a value
210
+ # typed at the class. Everything else — `Dynamic`, a user nominal, a union — declines.
211
+ def numeric_receiver?(type)
212
+ case type
213
+ when Rigor::Type::Constant then type.value.is_a?(Integer) || type.value.is_a?(Float)
214
+ when Rigor::Type::IntegerRange then true
215
+ when Rigor::Type::Nominal then DURATION_RECEIVER_CLASSES.include?(type.class_name)
216
+ else false
217
+ end
218
+ end
219
+
220
+ # The single positional argument of a binary operator call, or nil for every other shape. Purely
221
+ # syntactic, so it runs before any type is computed.
222
+ def duration_arithmetic_argument(call_node)
223
+ arguments = call_node.arguments&.arguments
224
+ return nil unless arguments&.size == 1
225
+
226
+ argument = arguments.first
227
+ return nil if argument.is_a?(Prism::SplatNode) || argument.is_a?(Prism::KeywordHashNode)
228
+
229
+ argument
230
+ end
231
+
232
+ # The operand's role in Duration arithmetic — `:duration`, `:numeric`, or its own class name for the
233
+ # date/time kinds — or nil for everything else, which is what declines the rule. Note the exact
234
+ # class-name match on the date/time kinds: a `Time` subclass is not retyped as its parent.
235
+ def duration_operand_kind(type)
236
+ case type
237
+ when Rigor::Type::Constant
238
+ :numeric if type.value.is_a?(Integer) || type.value.is_a?(Float)
239
+ when Rigor::Type::IntegerRange then :numeric
240
+ when Rigor::Type::Nominal then duration_operand_kind_for_class(type.class_name)
241
+ end
242
+ end
243
+
244
+ def duration_operand_kind_for_class(class_name)
245
+ return :duration if class_name == DURATION_NOMINAL
246
+ return :numeric if DURATION_ARITHMETIC_NUMERIC_KINDS.include?(class_name)
247
+ return :date if class_name == "Date"
248
+
249
+ class_name if DURATION_ARITHMETIC_SELF_KINDS.include?(class_name)
250
+ end
251
+
252
+ # The runtime answer for `receiver <op> argument`, or nil when the pair is not one this rule speaks
253
+ # for. At least one operand must be a Duration — otherwise the call is ordinary arithmetic and keeps
254
+ # whatever answer it always had (`1 + 1`, `Time - Time`, `"a" + "b"`).
255
+ def duration_arithmetic_result(operator, receiver_kind, argument_kind)
256
+ return nil unless receiver_kind == :duration || argument_kind == :duration
257
+
258
+ if DURATION_ARITHMETIC_SELF_KINDS.include?(receiver_kind)
259
+ # `Time`/`DateTime` ± Duration → the receiver's own kind. Multiplication is not defined.
260
+ return Rigor::Type::Combinator.nominal_of(receiver_kind) if operator != :*
261
+ elsif receiver_kind == :date
262
+ # `Date` ± Duration → whichever kind the duration's parts make of it.
263
+ return date_arithmetic_union if operator != :*
264
+ elsif duration_valued_pair?(operator, receiver_kind, argument_kind)
265
+ # Duration ± Duration, Duration ± numeric, numeric ± Duration, and the `*` forms of each: a
266
+ # Duration, via `Duration#coerce`'s `Duration::Scalar` wrapper.
267
+ return Rigor::Type::Combinator.nominal_of(DURATION_NOMINAL)
268
+ end
269
+ nil
270
+ end
271
+
272
+ # The pairs whose value is a Duration: a Duration or numeric on BOTH sides, minus `Duration *
273
+ # Duration` (not a quantity Rails promises). The argument side of the check is #588 — a Duration
274
+ # receiver with a `Time` / `DateTime` / `Date` argument is an expression that RAISES:
275
+ # `30.minutes + Time.now` adds the Time to the seconds part and `Integer#+` cannot coerce it
276
+ # (TypeError), `30.minutes - Time.now` sends `-@` to the Time (NoMethodError), and `*` is a
277
+ # TypeError from `Duration#calculate` — 7/7 shapes measured on ActiveSupport 8.1. Claiming
278
+ # `Duration` there would type crashing code; declining leaves the RBS-less receiver lenient, which
279
+ # is the honest answer for an expression that has no value.
280
+ def duration_valued_pair?(operator, receiver_kind, argument_kind)
281
+ return false unless DURATION_VALUED_OPERAND_KINDS.include?(receiver_kind)
282
+ return false unless DURATION_VALUED_OPERAND_KINDS.include?(argument_kind)
283
+
284
+ !(operator == :* && receiver_kind == :duration && argument_kind == :duration)
285
+ end
286
+
287
+ def date_arithmetic_union
288
+ Rigor::Type::Combinator.union(
289
+ *DURATION_ARITHMETIC_DATE_KINDS.map { |name| Rigor::Type::Combinator.nominal_of(name) }
290
+ )
291
+ end
37
292
  end
38
293
 
39
294
  Rigor::Plugin.register(ActivesupportCoreExt)