rigortype 0.3.2 → 0.3.4

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 (179) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/array.yml +11 -24
  4. data/data/builtins/ruby_core/enumerable.yml +4 -8
  5. data/data/builtins/ruby_core/exception.yml +6 -9
  6. data/data/builtins/ruby_core/hash.yml +13 -20
  7. data/data/builtins/ruby_core/io.yml +6 -9
  8. data/data/builtins/ruby_core/numeric.yml +2 -3
  9. data/data/builtins/ruby_core/proc.yml +4 -8
  10. data/data/builtins/ruby_core/re.yml +6 -9
  11. data/data/builtins/ruby_core/set.yml +2 -3
  12. data/data/builtins/ruby_core/string.yml +4 -7
  13. data/data/builtins/ruby_core/struct.yml +0 -6
  14. data/data/builtins/ruby_core/time.yml +3 -6
  15. data/data/effects/core.yml +843 -0
  16. data/data/effects/registry.yml +67 -0
  17. data/docs/handbook/07-rbs-and-extended.md +106 -5
  18. data/docs/manual/02-cli-reference.md +311 -1
  19. data/docs/manual/03-configuration.md +84 -1
  20. data/docs/manual/04-diagnostics.md +14 -2
  21. data/docs/manual/16-rbs-extended-annotations.md +77 -0
  22. data/docs/manual/18-removing-dead-code.md +326 -0
  23. data/docs/manual/README.md +5 -0
  24. data/docs/manual/plugins/rigor-actionmailer.md +5 -0
  25. data/docs/manual/plugins/rigor-activejob.md +47 -0
  26. data/docs/manual/plugins/rigor-factorybot.md +16 -0
  27. data/docs/manual/plugins/rigor-pundit.md +26 -0
  28. data/docs/manual/plugins/rigor-rails-routes.md +41 -2
  29. data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
  30. data/docs/manual/plugins/rigor-rspec.md +8 -0
  31. data/docs/manual/plugins/rigor-sidekiq.md +54 -0
  32. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +8 -0
  33. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
  34. data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
  35. data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
  36. data/lib/rigor/analysis/check_rules.rb +62 -22
  37. data/lib/rigor/analysis/incremental_session.rb +140 -6
  38. data/lib/rigor/analysis/reachability/graph.rb +244 -0
  39. data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
  40. data/lib/rigor/analysis/reachability/project_files.rb +58 -0
  41. data/lib/rigor/analysis/reachability/scan.rb +241 -0
  42. data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
  43. data/lib/rigor/analysis/rule_catalog.rb +163 -1
  44. data/lib/rigor/analysis/run_cache_key.rb +16 -0
  45. data/lib/rigor/analysis/runner/declaration_position.rb +54 -0
  46. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
  47. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
  48. data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
  49. data/lib/rigor/analysis/runner/pool_coordinator.rb +68 -10
  50. data/lib/rigor/analysis/runner.rb +420 -10
  51. data/lib/rigor/analysis/severity_stamp.rb +2 -1
  52. data/lib/rigor/analysis/worker_session.rb +58 -2
  53. data/lib/rigor/bleeding_edge.rb +22 -0
  54. data/lib/rigor/cache/incremental_snapshot.rb +24 -5
  55. data/lib/rigor/cli/check_command.rb +13 -1
  56. data/lib/rigor/cli/check_runner_factory.rb +4 -1
  57. data/lib/rigor/cli/effects_command.rb +131 -0
  58. data/lib/rigor/cli/effects_diff_renderer.rb +123 -0
  59. data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
  60. data/lib/rigor/cli/effects_renderer.rb +54 -0
  61. data/lib/rigor/cli/effects_report.rb +53 -0
  62. data/lib/rigor/cli/effects_snapshot_command.rb +256 -0
  63. data/lib/rigor/cli/unused_command.rb +288 -0
  64. data/lib/rigor/cli.rb +16 -0
  65. data/lib/rigor/configuration/severity_profile.rb +23 -0
  66. data/lib/rigor/configuration.rb +296 -14
  67. data/lib/rigor/effects/attribution.rb +76 -0
  68. data/lib/rigor/effects/catalog.rb +275 -0
  69. data/lib/rigor/effects/collector.rb +182 -0
  70. data/lib/rigor/effects/config_envelopes.rb +185 -0
  71. data/lib/rigor/effects/discharge.rb +69 -0
  72. data/lib/rigor/effects/effect_table.rb +92 -0
  73. data/lib/rigor/effects/entry_points.rb +93 -0
  74. data/lib/rigor/effects/envelope.rb +86 -0
  75. data/lib/rigor/effects/envelope_check.rb +172 -0
  76. data/lib/rigor/effects/envelope_index.rb +157 -0
  77. data/lib/rigor/effects/file_collection.rb +160 -0
  78. data/lib/rigor/effects/framework_units.rb +167 -0
  79. data/lib/rigor/effects/identity.rb +104 -0
  80. data/lib/rigor/effects/label.rb +77 -0
  81. data/lib/rigor/effects/label_intent.rb +73 -0
  82. data/lib/rigor/effects/label_set.rb +136 -0
  83. data/lib/rigor/effects/liskov_check.rb +167 -0
  84. data/lib/rigor/effects/local_ownership.rb +132 -0
  85. data/lib/rigor/effects/method_key.rb +40 -0
  86. data/lib/rigor/effects/mutation_classifier.rb +92 -0
  87. data/lib/rigor/effects/narrowing.rb +202 -0
  88. data/lib/rigor/effects/origin.rb +66 -0
  89. data/lib/rigor/effects/path_finder.rb +89 -0
  90. data/lib/rigor/effects/plugin_facts.rb +335 -0
  91. data/lib/rigor/effects/propagator.rb +275 -0
  92. data/lib/rigor/effects/registry.rb +193 -0
  93. data/lib/rigor/effects/scanner.rb +290 -0
  94. data/lib/rigor/effects/signature_sources.rb +74 -0
  95. data/lib/rigor/effects/snapshot.rb +380 -0
  96. data/lib/rigor/effects/snapshot_diff.rb +265 -0
  97. data/lib/rigor/effects/summary.rb +154 -0
  98. data/lib/rigor/effects/taint_cause.rb +38 -0
  99. data/lib/rigor/effects/unit_scan.rb +572 -0
  100. data/lib/rigor/effects/unknown_label_check.rb +86 -0
  101. data/lib/rigor/effects/unknown_label_report.rb +59 -0
  102. data/lib/rigor/effects.rb +47 -0
  103. data/lib/rigor/environment/rbs_loader.rb +34 -0
  104. data/lib/rigor/environment.rb +8 -1
  105. data/lib/rigor/flow_contribution/element.rb +1 -0
  106. data/lib/rigor/flow_contribution/merge_result.rb +5 -3
  107. data/lib/rigor/flow_contribution/merger.rb +16 -1
  108. data/lib/rigor/flow_contribution.rb +20 -4
  109. data/lib/rigor/inference/anonymous_meta_class.rb +81 -0
  110. data/lib/rigor/inference/expression_typer.rb +74 -17
  111. data/lib/rigor/inference/fork_map.rb +45 -11
  112. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +147 -17
  113. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +97 -2
  114. data/lib/rigor/inference/method_dispatcher.rb +41 -10
  115. data/lib/rigor/inference/optimistic_origin.rb +68 -3
  116. data/lib/rigor/inference/pre_eval_constants.rb +189 -0
  117. data/lib/rigor/inference/scope_indexer.rb +134 -31
  118. data/lib/rigor/inference/singleton_object_constant.rb +63 -0
  119. data/lib/rigor/inference/statement_evaluator.rb +25 -12
  120. data/lib/rigor/plugin/base.rb +24 -0
  121. data/lib/rigor/plugin/effect_attribution.rb +208 -0
  122. data/lib/rigor/plugin/effect_edge.rb +101 -0
  123. data/lib/rigor/plugin/effect_entry_points.rb +51 -0
  124. data/lib/rigor/plugin/first_party.rb +57 -0
  125. data/lib/rigor/plugin/isolation.rb +4 -1
  126. data/lib/rigor/plugin/manifest.rb +103 -3
  127. data/lib/rigor/plugin/node_rule_walk.rb +6 -0
  128. data/lib/rigor/plugin/registry.rb +48 -0
  129. data/lib/rigor/plugin.rb +1 -0
  130. data/lib/rigor/protection/closure_kill_oracle.rb +20 -1
  131. data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
  132. data/lib/rigor/rbs_extended.rb +168 -0
  133. data/lib/rigor/reflection.rb +131 -30
  134. data/lib/rigor/scope.rb +62 -2
  135. data/lib/rigor/source/node_walker.rb +12 -0
  136. data/lib/rigor/type/anonymous_class_name.rb +40 -0
  137. data/lib/rigor/type/nominal.rb +5 -2
  138. data/lib/rigor/type/singleton.rb +4 -1
  139. data/lib/rigor/version.rb +1 -1
  140. data/lib/rigor.rb +1 -0
  141. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
  142. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
  143. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
  144. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
  145. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
  146. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
  147. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
  148. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
  149. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
  150. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
  151. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
  152. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
  153. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
  154. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
  155. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
  156. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
  157. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +385 -28
  158. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
  159. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
  160. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
  161. data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
  162. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
  163. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
  164. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
  165. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
  166. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
  167. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
  168. data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
  169. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
  170. data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
  171. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
  172. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +56 -3
  173. data/sig/rigor/analysis/fact_store.rbs +1 -0
  174. data/sig/rigor/inference.rbs +2 -0
  175. data/sig/rigor/rbs_extended.rbs +6 -0
  176. data/sig/rigor/scope.rbs +4 -0
  177. data/sig/rigor.rbs +21 -1
  178. data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
  179. metadata +83 -1
@@ -18,6 +18,57 @@
18
18
  # Not exhaustive. ActiveSupport ships many more extensions across
19
19
  # Hash / Array / String / Object / Date / Time / Integer / Module
20
20
  # than this file covers; PRs welcome to extend.
21
+ #
22
+ # ---------------------------------------------------------------
23
+ # Purity (ADR-103 WD10; docs/type-specification/effect-labels.md; issue #388)
24
+ # ---------------------------------------------------------------
25
+ #
26
+ # `%a{pure}` below is audited one by one against the vendored ActiveSupport
27
+ # source (8.1.3.1), not assumed from the method name. Two recurring
28
+ # judgment calls, decided once here rather than repeated per method:
29
+ #
30
+ # - **Inflection rules are treated as pure.** `camelize` / `underscore` /
31
+ # `pluralize` / `singularize` / `humanize` / `titleize` / `tableize` /
32
+ # `classify` / `dasherize` / `demodulize` / `deconstantize` /
33
+ # `foreign_key` / `upcase_first` all read `ActiveSupport::Inflector`'s
34
+ # per-locale rule table (`inflections(locale)`) — process-global, but
35
+ # configured once at boot and never touched by request-handling code.
36
+ # Rigor's own ADR-90 (the standalone inflector) already treats
37
+ # inflections as pure functions of the rule set; this file inherits
38
+ # that assumption rather than re-litigating it. `parameterize` is the
39
+ # one exception in this family: it calls `I18n.transliterate`, which
40
+ # reads `I18n.locale` when no explicit `locale:` is given — a stronger,
41
+ # request-scoped global read the ADR-90 assumption does not cover, so
42
+ # it is skipped below.
43
+ # - **A method that dispatches to a name on its OWN receiver's declared
44
+ # type is fine; a method that dispatches to a name looked up on an
45
+ # ARGUMENT or ELEMENT of unknown type is not**, because the override is
46
+ # invisible to this audit (the `try` problem). `in?` / `presence_in`
47
+ # are the one deliberate exception: they call `#include?` / `#cover?`
48
+ # on the argument, which is near-universally a pure predicate, and the
49
+ # design note calls them out by name. `to_param` / `to_query` (Hash) and
50
+ # `to_fs(:db)` (Array, which calls `#id` on each element) are the
51
+ # converse case and are left unannotated.
52
+ #
53
+ # The clock, `Time.zone`, `ActiveSupport::Notifications` and
54
+ # `ActiveSupport::CurrentAttributes` are covered by this plugin's
55
+ # `effect_attributions:` manifest (`lib/rigor/plugin/activesupport_core_ext/effects.rb`,
56
+ # issue #387) rather than by an RBS annotation here — see that file for
57
+ # the impure half of this bundle. `DateTime#in_time_zone` was a gap in
58
+ # that sweep (it reads `Time.zone` via its default argument) and is
59
+ # closed there in this same change.
60
+ #
61
+ # Five `Date` singleton methods this file declares — `self.end_of_week`,
62
+ # `self.beginning_of_month`, `self.end_of_month`, `self.beginning_of_year`,
63
+ # `self.end_of_year` — do not exist in ActiveSupport 8.1
64
+ # (`Date.beginning_of_month` etc. are never defined; only the instance
65
+ # methods are). A sixth, `self.beginning_of_week`, DOES exist but with the
66
+ # wrong return type here (real `Date.beginning_of_week` is the week-start
67
+ # config getter, returning a `Symbol`, not a `Date`) — and reads
68
+ # fiber-local execution state either way. None of the six can be soundly
69
+ # annotated `%a{pure}`, so they are left exactly as they were; this
70
+ # paragraph is the flag for whoever fixes the six next (out of scope for
71
+ # the #388 purity sweep).
21
72
 
22
73
  # ---------------------------------------------------------------
23
74
  # Object — universal predicates and helpers
@@ -25,12 +76,15 @@
25
76
 
26
77
  class Object
27
78
  # @return [bool]
79
+ %a{pure}
28
80
  def blank?: () -> bool
29
81
 
30
82
  # @return [bool]
83
+ %a{pure}
31
84
  def present?: () -> bool
32
85
 
33
86
  # @return [self?]
87
+ %a{pure}
34
88
  def presence: () -> self?
35
89
 
36
90
  # `active_support/core_ext/object/json` monkey-patches
@@ -46,44 +100,72 @@ class Object
46
100
  # `options` is the standard `only:` / `except:` / `methods:`
47
101
  # / `include:` hash — left as untyped because the surface
48
102
  # is large and per-call-site precision is rarely useful.
103
+ #
104
+ # NOT `%a{pure}`: `respond_to?(:to_hash) ? to_hash.as_json(options) :
105
+ # instance_values.as_json(options)` dispatches to `to_hash` on the
106
+ # receiver's own (unknown-at-this-declaration) class, exactly the way
107
+ # `try` dispatches to an argument-named method — a subclass override is
108
+ # invisible to this audit, and `Object` is as broad a receiver as RBS has.
49
109
  def as_json: (?untyped options) -> untyped
50
110
 
51
111
  # `try` swallows NoMethodError; declared return is `untyped`
52
112
  # because the called method's signature is not known to RBS
53
113
  # at this call site.
114
+ #
115
+ # NOT `%a{pure}`: `try` is pure only when the method it dispatches to is,
116
+ # and that is a fact about the *call site*, not about `try` itself.
54
117
  def try: (Symbol | String) -> untyped
55
118
  | (Symbol | String, *untyped) -> untyped
56
119
  | (Symbol | String, *untyped) { (?) -> untyped } -> untyped
57
120
 
121
+ # NOT `%a{pure}`, same reasoning as `try`.
58
122
  def try!: (Symbol | String) -> untyped
59
123
  | (Symbol | String, *untyped) -> untyped
60
124
  | (Symbol | String, *untyped) { (?) -> untyped } -> untyped
61
125
 
62
126
  # `acts_like?(:string)` / `acts_like?(:date)` / `acts_like?(:time)`
63
- # is ActiveSupport's "duck-typing helper" predicate.
127
+ # is ActiveSupport's "duck-typing helper" predicate: `respond_to?
128
+ # :"acts_like_#{duck}?"`. Deterministic given the receiver's class.
129
+ %a{pure}
64
130
  def acts_like?: (Symbol | String) -> bool
65
131
 
66
- # `core_ext/object/inclusion` — `x.in?([a, b])` / `x.in?(1..10)`.
132
+ # `core_ext/object/inclusion` — `x.in?([a, b])` / `x.in?(1..10)`. Dispatches
133
+ # to `#include?` / `Range#cover?` on the ARGUMENT, the one deliberate
134
+ # exception to the try-shaped-dispatch rule above (design note § 11.2).
135
+ %a{pure}
67
136
  def in?: (untyped) -> bool
68
137
  end
69
138
 
70
139
  # `nil.blank?` / `nil.present?` / `nil.try` are the most frequent
71
140
  # call sites the survey turned up.
72
141
  class NilClass
142
+ %a{pure}
73
143
  def blank?: () -> true
144
+ %a{pure}
74
145
  def present?: () -> false
146
+ %a{pure}
75
147
  def presence: () -> nil
148
+
149
+ # Unlike `Object#try`, `NilClass#try` is a hardcoded override that
150
+ # returns `nil` unconditionally — it never sends the message, so
151
+ # unlike `Object#try` above it never dispatches at all.
152
+ %a{pure}
76
153
  def try: (*untyped) -> nil
154
+ %a{pure}
77
155
  def try!: (*untyped) -> nil
78
156
  end
79
157
 
80
158
  class TrueClass
159
+ %a{pure}
81
160
  def blank?: () -> false
161
+ %a{pure}
82
162
  def present?: () -> true
83
163
  end
84
164
 
85
165
  class FalseClass
166
+ %a{pure}
86
167
  def blank?: () -> true
168
+ %a{pure}
87
169
  def present?: () -> false
88
170
  end
89
171
 
@@ -92,73 +174,119 @@ end
92
174
  # ---------------------------------------------------------------
93
175
 
94
176
  class String
95
- # `core_ext/string/inflections`
177
+ # `core_ext/string/inflections` — the ADR-90 inflection-rule-table
178
+ # assumption at the top of this file applies to the whole group below.
179
+ %a{pure}
96
180
  def underscore: () -> String
181
+ %a{pure}
97
182
  def camelize: (?Symbol upper_case_first) -> String
183
+ %a{pure}
98
184
  def camelcase: (?Symbol upper_case_first) -> String
185
+ %a{pure}
99
186
  def classify: () -> String
100
- def constantize: () -> untyped
101
- def safe_constantize: () -> untyped
187
+ def constantize: () -> untyped # NOT %a{pure}: resolves a constant, may trigger autoload.
188
+ def safe_constantize: () -> untyped # NOT %a{pure}: same as constantize.
189
+ %a{pure}
102
190
  def demodulize: () -> String
191
+ %a{pure}
103
192
  def deconstantize: () -> String
193
+ %a{pure}
104
194
  def titleize: () -> String
195
+ %a{pure}
105
196
  def titlecase: () -> String
197
+ %a{pure}
106
198
  def dasherize: () -> String
199
+ %a{pure}
107
200
  def upcase_first: () -> String
201
+ # NOT %a{pure}: transliterates through `I18n.transliterate`, which reads
202
+ # `I18n.locale` when `locale:` is left at its `nil` default — a request-
203
+ # scoped global read the inflection-table assumption does not cover.
108
204
  def parameterize: (?separator: String, ?preserve_case: bool, ?locale: Symbol?) -> String
205
+ %a{pure}
109
206
  def tableize: () -> String
207
+ %a{pure}
110
208
  def foreign_key: (?bool separate_class_name_and_id_with_underscore) -> String
209
+ %a{pure}
111
210
  def pluralize: (?Integer count, ?Symbol locale) -> String
211
+ %a{pure}
112
212
  def singularize: (?Symbol locale) -> String
213
+ %a{pure}
113
214
  def humanize: (?capitalize: bool, ?keep_id_suffix: bool) -> String
114
215
 
115
216
  # `core_ext/string/filters`
217
+ %a{pure}
116
218
  def squish: () -> String
117
- def squish!: () -> String
219
+ def squish!: () -> String # NOT %a{pure}: bang, mutates the receiver in place.
220
+ %a{pure}
118
221
  def truncate: (Integer truncate_at, ?omission: String, ?separator: String | Regexp | nil) -> String
222
+ %a{pure}
119
223
  def truncate_bytes: (Integer truncate_at, ?omission: String) -> String
224
+ %a{pure}
120
225
  def truncate_words: (Integer words_count, ?omission: String, ?separator: String | Regexp | nil) -> String
121
226
  # `remove` / `remove!` delete every occurrence of the given patterns.
227
+ %a{pure}
122
228
  def remove: (*Regexp | String patterns) -> String
123
- def remove!: (*Regexp | String patterns) -> String
229
+ def remove!: (*Regexp | String patterns) -> String # NOT %a{pure}: bang, mutates in place.
124
230
 
125
231
  # `core_ext/string/output_safety` — `html_safe` returns an
126
232
  # `ActiveSupport::SafeBuffer` (a String subclass). The closest
127
233
  # RBS-canonical carrier is plain `String`; precision lost is the
128
- # `html_safe?` predicate value.
234
+ # `html_safe?` predicate value. Neither mutates the receiver; `html_safe?`
235
+ # on a plain `String` receiver is a hardcoded `false`.
236
+ %a{pure}
129
237
  def html_safe: () -> String
238
+ %a{pure}
130
239
  def html_safe?: () -> bool
131
240
 
132
241
  # `core_ext/string/indent`
242
+ %a{pure}
133
243
  def indent: (Integer amount, ?String indent_string, ?bool indent_empty_lines) -> String
134
- def indent!: (Integer amount, ?String indent_string, ?bool indent_empty_lines) -> String?
244
+ def indent!: (Integer amount, ?String indent_string, ?bool indent_empty_lines) -> String? # NOT %a{pure}: bang, mutates in place.
135
245
 
136
246
  # `core_ext/string/starts_ends_with` — also covered by core
137
247
  # `start_with?` / `end_with?`; these are the ActiveSupport aliases.
248
+ %a{pure}
138
249
  def starts_with?: (*String prefixes) -> bool
250
+ %a{pure}
139
251
  def ends_with?: (*String suffixes) -> bool
140
252
 
141
253
  # `core_ext/string/conversions` — `to_time` / `to_date` /
142
- # `to_datetime` return Time / Date / DateTime.
254
+ # `to_datetime` return Time / Date / DateTime. NONE are `%a{pure}`:
255
+ # all three default missing date/time components from `Time.now` /
256
+ # the current date when the string under-specifies them (verified
257
+ # against `activesupport/lib/active_support/core_ext/string/conversions.rb`)
258
+ # — `nondet.time`, not a pure function of the string alone.
143
259
  def to_time: (?Symbol form) -> Time?
144
260
  def to_date: () -> Date?
145
261
  def to_datetime: () -> DateTime
262
+ # `to_hours` does not exist in vendored ActiveSupport 8.1.3.1 — no
263
+ # `String#to_hours` anywhere in `core_ext/string/`. Left unannotated
264
+ # because there is no implementation here to audit; flagged for a
265
+ # follow-up to confirm and either implement or remove the declaration.
146
266
  def to_hours: () -> Float
147
267
 
148
268
  # `core_ext/string/access`
269
+ %a{pure}
149
270
  def at: (Integer | Range[Integer] | Regexp position) -> String?
271
+ %a{pure}
150
272
  def from: (Integer position) -> String
273
+ %a{pure}
151
274
  def to: (Integer position) -> String
275
+ %a{pure}
152
276
  def first: (?Integer limit) -> String
277
+ %a{pure}
153
278
  def last: (?Integer limit) -> String
154
279
 
155
280
  # `core_ext/string/strip` — leading-blank strip with shared margin.
281
+ %a{pure}
156
282
  def strip_heredoc: () -> String
157
283
 
158
284
  # `core_ext/string/multibyte`
285
+ %a{pure}
159
286
  def mb_chars: () -> untyped
160
287
 
161
288
  # `core_ext/string/inquiry`
289
+ %a{pure}
162
290
  def inquiry: () -> untyped # ActiveSupport::StringInquirer
163
291
  end
164
292
 
@@ -167,45 +295,83 @@ end
167
295
  # ---------------------------------------------------------------
168
296
 
169
297
  class Integer
170
- # `core_ext/numeric/time` — Duration multipliers.
298
+ # `core_ext/numeric/time` — Duration multipliers. Each just wraps `self`
299
+ # in an `ActiveSupport::Duration` value object (`Duration.seconds(self)`,
300
+ # …); the clock is read only later, by calling `.ago` / `.since` / etc.
301
+ # on the resulting Duration — attributed on `ActiveSupport::Duration` in
302
+ # `lib/rigor/plugin/activesupport_core_ext/effects.rb`, not here.
303
+ %a{pure}
171
304
  def second: () -> untyped # ActiveSupport::Duration
305
+ %a{pure}
172
306
  def seconds: () -> untyped
307
+ %a{pure}
173
308
  def minute: () -> untyped
309
+ %a{pure}
174
310
  def minutes: () -> untyped
311
+ %a{pure}
175
312
  def hour: () -> untyped
313
+ %a{pure}
176
314
  def hours: () -> untyped
315
+ %a{pure}
177
316
  def day: () -> untyped
317
+ %a{pure}
178
318
  def days: () -> untyped
319
+ %a{pure}
179
320
  def week: () -> untyped
321
+ %a{pure}
180
322
  def weeks: () -> untyped
323
+ %a{pure}
181
324
  def fortnight: () -> untyped
325
+ %a{pure}
182
326
  def fortnights: () -> untyped
327
+ %a{pure}
183
328
  def month: () -> untyped
329
+ %a{pure}
184
330
  def months: () -> untyped
331
+ %a{pure}
185
332
  def year: () -> untyped
333
+ %a{pure}
186
334
  def years: () -> untyped
187
335
 
188
- # `core_ext/numeric/bytes`
336
+ # `core_ext/numeric/bytes` — plain arithmetic on `self`.
337
+ %a{pure}
189
338
  def byte: () -> Integer
339
+ %a{pure}
190
340
  def bytes: () -> Integer
341
+ %a{pure}
191
342
  def kilobyte: () -> Integer
343
+ %a{pure}
192
344
  def kilobytes: () -> Integer
345
+ %a{pure}
193
346
  def megabyte: () -> Integer
347
+ %a{pure}
194
348
  def megabytes: () -> Integer
349
+ %a{pure}
195
350
  def gigabyte: () -> Integer
351
+ %a{pure}
196
352
  def gigabytes: () -> Integer
353
+ %a{pure}
197
354
  def terabyte: () -> Integer
355
+ %a{pure}
198
356
  def terabytes: () -> Integer
357
+ %a{pure}
199
358
  def petabyte: () -> Integer
359
+ %a{pure}
200
360
  def petabytes: () -> Integer
361
+ %a{pure}
201
362
  def exabyte: () -> Integer
363
+ %a{pure}
202
364
  def exabytes: () -> Integer
203
365
 
204
366
  # `core_ext/integer/multiple`
367
+ %a{pure}
205
368
  def multiple_of?: (Integer) -> bool
206
369
 
207
- # `core_ext/integer/inflections`
370
+ # `core_ext/integer/inflections` — `ActiveSupport::Inflector.ordinal(ize)`,
371
+ # the same inflection-rule-table assumption as the String group above.
372
+ %a{pure}
208
373
  def ordinal: () -> String
374
+ %a{pure}
209
375
  def ordinalize: () -> String
210
376
  end
211
377
 
@@ -214,28 +380,52 @@ end
214
380
  # ---------------------------------------------------------------
215
381
 
216
382
  class Float
383
+ # Same reasoning as the `Integer` block above: value-object construction
384
+ # and arithmetic only.
385
+ %a{pure}
217
386
  def second: () -> untyped
387
+ %a{pure}
218
388
  def seconds: () -> untyped
389
+ %a{pure}
219
390
  def minute: () -> untyped
391
+ %a{pure}
220
392
  def minutes: () -> untyped
393
+ %a{pure}
221
394
  def hour: () -> untyped
395
+ %a{pure}
222
396
  def hours: () -> untyped
397
+ %a{pure}
223
398
  def day: () -> untyped
399
+ %a{pure}
224
400
  def days: () -> untyped
401
+ %a{pure}
225
402
  def week: () -> untyped
403
+ %a{pure}
226
404
  def weeks: () -> untyped
405
+ %a{pure}
227
406
  def month: () -> untyped
407
+ %a{pure}
228
408
  def months: () -> untyped
409
+ %a{pure}
229
410
  def year: () -> untyped
411
+ %a{pure}
230
412
  def years: () -> untyped
231
413
 
414
+ %a{pure}
232
415
  def byte: () -> Float
416
+ %a{pure}
233
417
  def bytes: () -> Float
418
+ %a{pure}
234
419
  def kilobyte: () -> Float
420
+ %a{pure}
235
421
  def kilobytes: () -> Float
422
+ %a{pure}
236
423
  def megabyte: () -> Float
424
+ %a{pure}
237
425
  def megabytes: () -> Float
426
+ %a{pure}
238
427
  def gigabyte: () -> Float
428
+ %a{pure}
239
429
  def gigabytes: () -> Float
240
430
  end
241
431
 
@@ -244,6 +434,9 @@ end
244
434
  # ---------------------------------------------------------------
245
435
 
246
436
  class Time
437
+ # NOT %a{pure}: already attributed nondet.time/global.read/global.write
438
+ # by `lib/rigor/plugin/activesupport_core_ext/effects.rb` (issue #387).
439
+ # Duplicating an RBS envelope here risks the two disagreeing.
247
440
  def self.current: () -> Time
248
441
  def self.zone: () -> untyped # ActiveSupport::TimeZone | nil
249
442
  def self.zone=: (String | Symbol | untyped) -> untyped
@@ -253,38 +446,80 @@ class Time
253
446
  # Declaring them here raised `RBS::DuplicatedMethodDefinitionError` and collapsed the whole `Time`
254
447
  # definition to `Dynamic[top]`.
255
448
 
256
- # `core_ext/time/calculations`
449
+ # `core_ext/time/calculations` — every method below is computed from the
450
+ # RECEIVER's own fields (`change` / `advance` / `+ seconds`), verified
451
+ # against `activesupport/lib/active_support/core_ext/time/calculations.rb`.
452
+ # None of them consult `Time.zone`; that is what distinguishes an
453
+ # instance method here from the `self.*` singletons above.
454
+ %a{pure}
257
455
  def yesterday: () -> Time
456
+ %a{pure}
258
457
  def tomorrow: () -> Time
458
+ %a{pure}
259
459
  def beginning_of_day: () -> Time
460
+ %a{pure}
260
461
  def end_of_day: () -> Time
462
+ %a{pure}
261
463
  def beginning_of_hour: () -> Time
464
+ %a{pure}
262
465
  def end_of_hour: () -> Time
466
+ %a{pure}
263
467
  def beginning_of_minute: () -> Time
468
+ %a{pure}
264
469
  def end_of_minute: () -> Time
470
+ # NOT %a{pure}: `start_day` defaults to `Date.beginning_of_week`, a
471
+ # fiber-local config getter (`ActiveSupport::IsolatedExecutionState[:beginning_of_week]`)
472
+ # — a global read when the argument is omitted.
265
473
  def beginning_of_week: (?Symbol start_day) -> Time
266
474
  def end_of_week: (?Symbol start_day) -> Time
475
+ %a{pure}
267
476
  def beginning_of_month: () -> Time
477
+ %a{pure}
268
478
  def end_of_month: () -> Time
479
+ %a{pure}
269
480
  def beginning_of_year: () -> Time
481
+ %a{pure}
270
482
  def end_of_year: () -> Time
483
+ # `Time#ago` / `#since` / `#in` (receiver a `Time`, not a `Duration`):
484
+ # `since(seconds) = self + seconds`, purely computed from the receiver.
485
+ # Distinct from `ActiveSupport::Duration#ago` (receiver a Duration,
486
+ # reads the clock), attributed separately in `effects.rb`.
487
+ %a{pure}
271
488
  def ago: (Numeric seconds) -> Time
489
+ %a{pure}
272
490
  def since: (Numeric seconds) -> Time
491
+ %a{pure}
273
492
  def in: (Numeric seconds) -> Time
493
+ %a{pure}
274
494
  def change: (**untyped) -> Time
495
+ %a{pure}
275
496
  def at_beginning_of_day: () -> Time
497
+ %a{pure}
276
498
  def at_end_of_day: () -> Time
499
+ # NOT %a{pure}: same `Date.beginning_of_week` default-argument read as
500
+ # `beginning_of_week` / `end_of_week` above.
277
501
  def at_beginning_of_week: () -> Time
278
502
  def at_end_of_week: () -> Time
503
+ %a{pure}
279
504
  def at_midnight: () -> Time
505
+ %a{pure}
280
506
  def at_noon: () -> Time
507
+ %a{pure}
281
508
  def midday: () -> Time
509
+ %a{pure}
282
510
  def midnight: () -> Time
511
+ %a{pure}
283
512
  def noon: () -> Time
284
513
  # NOTE: `Time#utc?` is NOT declared here — it is Ruby core, already typed by rbs's `core/time.rbs`.
514
+ %a{pure}
285
515
  def acts_like_time?: () -> true
286
516
  # `advance(days: 1, months: -2)` and `all_day` (a `beginning_of_day..end_of_day` Range).
517
+ # Both computed from the receiver only (`advance` goes through `to_date.gregorian.advance` +
518
+ # `change`, never `Time.zone`; `all_day` composes the two pure `beginning_of_day` / `end_of_day`
519
+ # above) — unlike the same-named methods on `Date` below.
520
+ %a{pure}
287
521
  def advance: (untyped options) -> Time
522
+ %a{pure}
288
523
  def all_day: () -> Range[Time]
289
524
  end
290
525
 
@@ -293,6 +528,11 @@ end
293
528
  # ---------------------------------------------------------------
294
529
 
295
530
  class Date
531
+ # NOT %a{pure}: `self.current` / `self.yesterday` / `self.tomorrow` are
532
+ # already attributed nondet.time/global.read by `effects.rb` (#387).
533
+ # The other five singletons declared here — `self.beginning_of_week`
534
+ # through `self.end_of_year` — do not soundly exist; see the file-header
535
+ # note. None get an annotation.
296
536
  def self.current: () -> Date
297
537
  def self.yesterday: () -> Date
298
538
  def self.tomorrow: () -> Date
@@ -303,16 +543,31 @@ class Date
303
543
  def self.beginning_of_year: () -> Date
304
544
  def self.end_of_year: () -> Date
305
545
 
546
+ # `advance(days: -1)` / `advance(days: 1)` — pure y/m/d arithmetic on the
547
+ # receiver (`core_ext/date_and_time/calculations.rb`), unlike the
548
+ # `self.yesterday` / `self.tomorrow` singletons above.
549
+ %a{pure}
306
550
  def yesterday: () -> Date
551
+ %a{pure}
307
552
  def tomorrow: () -> Date
553
+ # NOT %a{pure}: `start_day` defaults to `Date.beginning_of_week`, a
554
+ # fiber-local config read — same reasoning as `Time#beginning_of_week`.
308
555
  def beginning_of_week: (?Symbol start_day) -> Date
309
556
  def end_of_week: (?Symbol start_day) -> Date
557
+ %a{pure}
310
558
  def beginning_of_month: () -> Date
559
+ %a{pure}
311
560
  def end_of_month: () -> Date
561
+ %a{pure}
312
562
  def beginning_of_year: () -> Date
563
+ %a{pure}
313
564
  def end_of_year: () -> Date
565
+ # NOT %a{pure}: `Date#ago` / `#since` route through `in_time_zone`
566
+ # (`in_time_zone.since(...)`), which reads `Time.zone` by default —
567
+ # unlike `Time#ago` / `#since` above, which never leave the receiver.
314
568
  def ago: (Numeric seconds) -> Time
315
569
  def since: (Numeric seconds) -> Time
570
+ %a{pure}
316
571
  def acts_like_date?: () -> true
317
572
 
318
573
  # `core_ext/date/calculations` — `Date#midnight` /
@@ -320,16 +575,30 @@ class Date
320
575
  # Rails' `beginning_of_day` on Date returns a Time at
321
576
  # midnight of that day (not a Date). Mastodon's
322
577
  # `Date.current.at_midnight` shape relies on this.
578
+ #
579
+ # NONE of the six below are `%a{pure}`: every one is `in_time_zone.xxx`
580
+ # (`core_ext/date/zones.rb`, `DateAndTime::Zones#in_time_zone`), and
581
+ # `in_time_zone(zone = ::Time.zone)` reads `Time.zone` when no explicit
582
+ # zone is given. This is the one place `Date` and `Time` diverge on the
583
+ # exact same method names — verify against source before trusting the
584
+ # name alone.
323
585
  def beginning_of_day: () -> Time
324
586
  def midnight: () -> Time
325
587
  def at_midnight: () -> Time
326
588
  def at_beginning_of_day: () -> Time
327
589
  def end_of_day: () -> Time
328
590
  def at_end_of_day: () -> Time
329
- # `advance(days: 1)`, `all_day` (a Time Range), and `to_time(form)` the
330
- # `:local` / `:utc` form arg core RBS' `Date#to_time` (arity 0) omits.
591
+ # `advance(days: 1)` is pure y/m/d arithmetic on the receiver, same as
592
+ # `yesterday` / `tomorrow` above. `all_day` is NOT %a{pure}: it composes
593
+ # `beginning_of_day..end_of_day`, and both of those read `Time.zone` on
594
+ # `Date` (unlike on `Time`, where `all_day` is pure). `to_time(form)` is
595
+ # `Time.public_send(form, year, month, day)` — pure, all three components
596
+ # come from the receiver and none default from the clock (contrast
597
+ # `String#to_time` above, which does).
598
+ %a{pure}
331
599
  def advance: (untyped options) -> Date
332
600
  def all_day: () -> Range[Time]
601
+ %a{pure}
333
602
  def to_time: (?Symbol form) -> Time
334
603
  end
335
604
 
@@ -340,41 +609,61 @@ end
340
609
  class Array[unchecked out Elem]
341
610
  # `Array.wrap(x)` is the dominant Rails idiom: `nil → []`,
342
611
  # `Array x → x`, `else → [x]`.
612
+ %a{pure}
343
613
  def self.wrap: (untyped) -> Array[untyped]
344
614
 
345
615
  # `core_ext/array/access`
616
+ %a{pure}
346
617
  def from: (Integer position) -> Array[Elem]
618
+ %a{pure}
347
619
  def to: (Integer position) -> Array[Elem]
620
+ %a{pure}
348
621
  def second: () -> Elem?
622
+ %a{pure}
349
623
  def third: () -> Elem?
624
+ %a{pure}
350
625
  def fourth: () -> Elem?
626
+ %a{pure}
351
627
  def fifth: () -> Elem?
628
+ %a{pure}
352
629
  def forty_two: () -> Elem?
353
630
 
354
631
  # `core_ext/array/grouping`
632
+ %a{pure}
355
633
  def in_groups_of: (Integer number, ?untyped fill_with) -> Array[Array[Elem]]
356
634
  | (Integer number, ?untyped fill_with) { (Array[Elem]) -> void } -> Array[Elem]
635
+ %a{pure}
357
636
  def in_groups: (Integer number, ?untyped fill_with) -> Array[Array[Elem]]
358
637
  | (Integer number, ?untyped fill_with) { (Array[Elem]) -> void } -> Array[Elem]
638
+ %a{pure}
359
639
  def split: (?untyped value) -> Array[Array[Elem]]
360
640
  | () { (Elem) -> bool } -> Array[Array[Elem]]
361
641
 
362
- # `core_ext/array/conversions`
642
+ # `core_ext/array/conversions`. NEITHER `to_sentence` nor `to_fs` /
643
+ # `to_formatted_s` is `%a{pure}`: `to_sentence` calls `I18n.translate`
644
+ # for its connector words unless `locale: false` is passed explicitly
645
+ # (a request-scoped global read); `to_fs(:db)` calls `#id` on every
646
+ # element, a dispatch to an unknown-at-this-declaration method the same
647
+ # way `Object#try` does. `to_xml` takes a caller-supplied `:builder` and
648
+ # an optional block, both opaque callables — skipped for the same reason.
363
649
  def to_sentence: (?two_words_connector: String, ?last_word_connector: String, ?words_connector: String, ?locale: Symbol?) -> String
364
650
  def to_formatted_s: (?Symbol format) -> String
365
651
  def to_fs: (?Symbol format) -> String
366
652
  def to_xml: (**untyped) -> String
367
653
 
368
654
  # `core_ext/array/inquiry`
655
+ %a{pure}
369
656
  def inquiry: () -> untyped # ActiveSupport::ArrayInquirer
370
657
 
371
658
  # `core_ext/array/extract`
372
- def extract!: () { (Elem) -> bool } -> Array[Elem]
659
+ def extract!: () { (Elem) -> bool } -> Array[Elem] # NOT %a{pure}: bang, mutates in place.
373
660
 
374
661
  # `core_ext/object/blank` / `core_ext/enumerable` — both
375
662
  # ActiveSupport additions, shipped on Array specifically.
663
+ %a{pure}
376
664
  def compact_blank: () -> Array[Elem]
377
- def compact_blank!: () -> self
665
+ def compact_blank!: () -> self # NOT %a{pure}: bang (`delete_if`), mutates in place.
666
+ %a{pure}
378
667
  def exclude?: (Elem) -> bool
379
668
  end
380
669
 
@@ -383,20 +672,35 @@ end
383
672
  # ---------------------------------------------------------------
384
673
 
385
674
  module Enumerable[unchecked out Elem]
675
+ %a{pure}
386
676
  def index_by: () { (Elem) -> untyped } -> Hash[untyped, Elem]
677
+ %a{pure}
387
678
  def index_with: (?untyped default) { (Elem) -> untyped } -> Hash[Elem, untyped]
388
679
  | (untyped default) -> Hash[Elem, untyped]
680
+ %a{pure}
389
681
  def exclude?: (Elem) -> bool
682
+ %a{pure}
390
683
  def including: (*Elem) -> Array[Elem]
684
+ %a{pure}
391
685
  def excluding: (*Elem) -> Array[Elem]
686
+ %a{pure}
392
687
  def without: (*Elem) -> Array[Elem]
688
+ %a{pure}
393
689
  def pluck: (Symbol | String) -> Array[untyped]
394
690
  | (*Symbol | String) -> Array[Array[untyped]]
691
+ %a{pure}
395
692
  def pick: (Symbol | String) -> untyped
396
693
  | (*Symbol | String) -> Array[untyped]?
694
+ %a{pure}
397
695
  def maximum: (Symbol | String) -> untyped
696
+ %a{pure}
398
697
  def minimum: (Symbol | String) -> untyped
698
+ # Raises `SoleItemExpectedError` rather than returning on 0-or-2+ items;
699
+ # raising is not an effect label (AGENTS.md / design note), so this is
700
+ # still pure.
701
+ %a{pure}
399
702
  def sole: () -> Elem
703
+ %a{pure}
400
704
  def compact_blank: () -> Array[Elem]
401
705
  end
402
706
 
@@ -405,25 +709,39 @@ end
405
709
  # ---------------------------------------------------------------
406
710
 
407
711
  class Hash[unchecked out K, unchecked out V]
408
- # `core_ext/hash/keys`
712
+ # `core_ext/hash/keys` — bang variants mutate in place, so only the
713
+ # non-bang half is `%a{pure}`. `assert_valid_keys` raises rather than
714
+ # mutating (raising is not an effect label).
715
+ %a{pure}
409
716
  def symbolize_keys: () -> Hash[Symbol, V]
410
717
  def symbolize_keys!: () -> self
718
+ %a{pure}
411
719
  def deep_symbolize_keys: () -> Hash[untyped, untyped]
412
720
  def deep_symbolize_keys!: () -> self
721
+ %a{pure}
413
722
  def stringify_keys: () -> Hash[String, V]
414
723
  def stringify_keys!: () -> self
724
+ %a{pure}
415
725
  def deep_stringify_keys: () -> Hash[untyped, untyped]
416
726
  def deep_stringify_keys!: () -> self
727
+ %a{pure}
417
728
  def assert_valid_keys: (*K | Array[K]) -> self
729
+ %a{pure}
418
730
  def deep_transform_keys: () { (K) -> K } -> Hash[K, untyped]
419
731
  def deep_transform_keys!: () { (K) -> K } -> self
732
+ %a{pure}
420
733
  def deep_transform_values: () { (V) -> untyped } -> Hash[K, untyped]
421
734
  def deep_transform_values!: () { (V) -> untyped } -> self
422
735
 
423
- # `core_ext/hash/deep_dup`
736
+ # `core_ext/hash/deep_dup` — allocates a deep copy, never touches the
737
+ # receiver.
738
+ %a{pure}
424
739
  def deep_dup: () -> Hash[K, V]
425
740
 
426
- # `core_ext/hash/deep_merge`
741
+ # `core_ext/hash/deep_merge` — the block receives `(key, this_val,
742
+ # other_val)` and returns the merged value; neither `deep_merge` nor its
743
+ # block mutates either hash (containment covers the block itself).
744
+ %a{pure}
427
745
  def deep_merge: (Hash[K, V]) -> Hash[K, V]
428
746
  | (Hash[K, V]) { (K, V, V) -> V } -> Hash[K, V]
429
747
  def deep_merge!: (Hash[K, V]) -> self
@@ -433,15 +751,24 @@ class Hash[unchecked out K, unchecked out V]
433
751
  # Ruby 3.0+; `except!` is ActiveSupport-only. ActiveSupport
434
752
  # also aliases `Hash#without` to `Hash#except`, used by
435
753
  # `Mastodon`-shaped `options.without('type').merge(...)` chains.
436
- def except!: (*K) -> self
754
+ def except!: (*K) -> self # NOT %a{pure}: bang, mutates via `delete`.
755
+ %a{pure}
437
756
  def without: (*K) -> Hash[K, V]
438
757
 
439
- # `core_ext/hash/conversions`
758
+ # `core_ext/hash/conversions` — NOT `%a{pure}`: `to_query` / `to_param`
759
+ # recurse into `value.to_query` / `value.to_param` on every value, an
760
+ # unknown-at-this-declaration dispatch the same way `Object#try` and
761
+ # `Array#to_fs(:db)` are (a value's own `to_param` override is invisible
762
+ # here); `to_xml` takes a caller-supplied `:builder` and an optional
763
+ # block, both opaque callables.
440
764
  def to_query: (?String namespace) -> String
441
765
  def to_param: (?String namespace) -> String
442
766
  def to_xml: (**untyped) -> String
443
767
 
444
- # `core_ext/hash/indifferent_access`
768
+ # `core_ext/hash/indifferent_access` — wraps `self` in a new
769
+ # `HashWithIndifferentAccess`; the design note § 11.2 names this row
770
+ # `%a{pure}` explicitly.
771
+ %a{pure}
445
772
  def with_indifferent_access: () -> untyped
446
773
 
447
774
  # `core_ext/hash/conversions`
@@ -449,17 +776,19 @@ class Hash[unchecked out K, unchecked out V]
449
776
  def self.from_trusted_xml: (String) -> Hash[String, untyped]
450
777
 
451
778
  # `core_ext/object/blank` / `core_ext/hash`
779
+ %a{pure}
452
780
  def compact_blank: () -> Hash[K, V]
453
- def compact_blank!: () -> self
781
+ def compact_blank!: () -> self # NOT %a{pure}: bang (`delete_if`), mutates in place.
454
782
 
455
783
  # `core_ext/hash/reverse_merge`
784
+ %a{pure}
456
785
  def reverse_merge: (Hash[K, V]) -> Hash[K, V]
457
- def reverse_merge!: (Hash[K, V]) -> self
786
+ def reverse_merge!: (Hash[K, V]) -> self # NOT %a{pure}: bang (`replace`), mutates in place.
458
787
 
459
788
  # `core_ext/hash/slice` — `Hash#except` is in core RBS (Ruby 3.0+);
460
789
  # `Hash#slice` is in core RBS (Ruby 2.5+); the bang variants are
461
790
  # ActiveSupport-only.
462
- def slice!: (*K) -> Hash[K, V]
791
+ def slice!: (*K) -> Hash[K, V] # NOT %a{pure}: bang (`replace`), mutates in place.
463
792
  end
464
793
 
465
794
  # ---------------------------------------------------------------
@@ -467,19 +796,43 @@ end
467
796
  # ---------------------------------------------------------------
468
797
 
469
798
  class DateTime
799
+ # `utc` is pure Rational arithmetic on the receiver's own offset
800
+ # (`core_ext/date_time/calculations.rb`), no zone lookup.
801
+ %a{pure}
470
802
  def utc: () -> Time
471
803
  # NOTE: `DateTime#to_time` is NOT declared here — stdlib `date` already types it.
804
+ # NOT %a{pure}: `in_time_zone(zone = ::Time.zone)` reads `Time.zone`
805
+ # when no explicit zone is passed (`core_ext/date_and_time/zones.rb`).
806
+ # Not previously covered by #387's `effect_attributions:`; the plugin
807
+ # manifest gains this row in the same change (`global.read`).
472
808
  def in_time_zone: (?String | Symbol zone) -> untyped
809
+ # `yesterday` / `tomorrow` are inherited from `DateAndTime::Calculations`
810
+ # via `Date` (`DateTime < Date`), resolved against `DateTime`'s own
811
+ # `advance` — pure, same reasoning as `Date#yesterday` / `#tomorrow`
812
+ # above and unlike `Date.self.yesterday` / `.tomorrow`.
813
+ %a{pure}
473
814
  def yesterday: () -> DateTime
815
+ %a{pure}
474
816
  def tomorrow: () -> DateTime
817
+ # `ago` / `since` are `self + Rational(seconds, 86400)` — pure, and
818
+ # (unlike `Date#ago` / `#since`) never route through `in_time_zone`.
819
+ %a{pure}
475
820
  def ago: (Numeric seconds) -> DateTime
821
+ %a{pure}
476
822
  def since: (Numeric seconds) -> DateTime
823
+ %a{pure}
477
824
  def beginning_of_day: () -> DateTime
825
+ %a{pure}
478
826
  def end_of_day: () -> DateTime
827
+ %a{pure}
479
828
  def beginning_of_hour: () -> DateTime
829
+ %a{pure}
480
830
  def end_of_hour: () -> DateTime
831
+ %a{pure}
481
832
  def beginning_of_minute: () -> DateTime
833
+ %a{pure}
482
834
  def end_of_minute: () -> DateTime
835
+ %a{pure}
483
836
  def acts_like_time?: () -> true
484
837
  end
485
838
 
@@ -491,6 +844,7 @@ end
491
844
  # main String block above because it's a less-common method whose
492
845
  # coverage was added after a second-round survey.)
493
846
  class String
847
+ %a{pure}
494
848
  def exclude?: (String) -> bool
495
849
  end
496
850
 
@@ -505,6 +859,9 @@ class ERB
505
859
  module Util
506
860
  # `ERB::Util.html_escape_once(s)` escapes HTML without double-escaping
507
861
  # an already-escaped entity. ActionView adds it on top of stdlib ERB.
862
+ # A deterministic regex-substitution transform of the argument; no
863
+ # receiver, no global reads.
864
+ %a{pure}
508
865
  def self.html_escape_once: (untyped) -> String
509
866
  end
510
867
  end