rigortype 0.3.3 → 0.3.5

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 (171) 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 +860 -0
  16. data/data/effects/registry.yml +95 -0
  17. data/docs/handbook/07-rbs-and-extended.md +106 -5
  18. data/docs/manual/02-cli-reference.md +359 -2
  19. data/docs/manual/03-configuration.md +101 -1
  20. data/docs/manual/04-diagnostics.md +8 -3
  21. data/docs/manual/11-ci.md +37 -0
  22. data/docs/manual/12-caching.md +39 -0
  23. data/docs/manual/16-rbs-extended-annotations.md +90 -0
  24. data/docs/manual/18-removing-dead-code.md +326 -0
  25. data/docs/manual/19-effect-labels.md +671 -0
  26. data/docs/manual/README.md +10 -0
  27. data/docs/manual/ci-templates/README.md +9 -0
  28. data/docs/manual/plugins/rigor-actionmailer.md +5 -0
  29. data/docs/manual/plugins/rigor-activejob.md +47 -0
  30. data/docs/manual/plugins/rigor-factorybot.md +16 -0
  31. data/docs/manual/plugins/rigor-pundit.md +26 -0
  32. data/docs/manual/plugins/rigor-rails-routes.md +41 -2
  33. data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
  34. data/docs/manual/plugins/rigor-rspec.md +8 -0
  35. data/docs/manual/plugins/rigor-sidekiq.md +54 -0
  36. data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
  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 +168 -3
  44. data/lib/rigor/analysis/run_cache_key.rb +16 -0
  45. data/lib/rigor/analysis/run_cache_probe.rb +69 -1
  46. data/lib/rigor/analysis/runner/declaration_position.rb +38 -0
  47. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
  48. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
  49. data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
  50. data/lib/rigor/analysis/runner/pool_coordinator.rb +93 -10
  51. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
  52. data/lib/rigor/analysis/runner.rb +429 -10
  53. data/lib/rigor/analysis/worker_session.rb +60 -2
  54. data/lib/rigor/bleeding_edge.rb +22 -0
  55. data/lib/rigor/cache/incremental_snapshot.rb +24 -5
  56. data/lib/rigor/cli/check_command.rb +13 -1
  57. data/lib/rigor/cli/check_runner_factory.rb +4 -1
  58. data/lib/rigor/cli/effects_command.rb +245 -0
  59. data/lib/rigor/cli/effects_diff_renderer.rb +127 -0
  60. data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
  61. data/lib/rigor/cli/effects_renderer.rb +92 -0
  62. data/lib/rigor/cli/effects_report.rb +163 -0
  63. data/lib/rigor/cli/effects_snapshot_command.rb +275 -0
  64. data/lib/rigor/cli/unused_command.rb +288 -0
  65. data/lib/rigor/cli.rb +28 -1
  66. data/lib/rigor/configuration/severity_profile.rb +23 -0
  67. data/lib/rigor/configuration.rb +322 -28
  68. data/lib/rigor/configuration_error.rb +20 -0
  69. data/lib/rigor/effects/attribution.rb +76 -0
  70. data/lib/rigor/effects/catalog.rb +275 -0
  71. data/lib/rigor/effects/collector.rb +219 -0
  72. data/lib/rigor/effects/config_envelopes.rb +185 -0
  73. data/lib/rigor/effects/discharge.rb +69 -0
  74. data/lib/rigor/effects/effect_table.rb +92 -0
  75. data/lib/rigor/effects/entry_points.rb +139 -0
  76. data/lib/rigor/effects/envelope.rb +86 -0
  77. data/lib/rigor/effects/envelope_check.rb +172 -0
  78. data/lib/rigor/effects/envelope_index.rb +157 -0
  79. data/lib/rigor/effects/file_collection.rb +174 -0
  80. data/lib/rigor/effects/framework_units.rb +222 -0
  81. data/lib/rigor/effects/identity.rb +104 -0
  82. data/lib/rigor/effects/inline_anchor.rb +134 -0
  83. data/lib/rigor/effects/label.rb +77 -0
  84. data/lib/rigor/effects/label_intent.rb +73 -0
  85. data/lib/rigor/effects/label_set.rb +136 -0
  86. data/lib/rigor/effects/liskov_check.rb +167 -0
  87. data/lib/rigor/effects/local_ownership.rb +132 -0
  88. data/lib/rigor/effects/method_key.rb +40 -0
  89. data/lib/rigor/effects/mutation_classifier.rb +92 -0
  90. data/lib/rigor/effects/narrowing.rb +202 -0
  91. data/lib/rigor/effects/origin.rb +66 -0
  92. data/lib/rigor/effects/path_finder.rb +89 -0
  93. data/lib/rigor/effects/plugin_facts.rb +384 -0
  94. data/lib/rigor/effects/propagator.rb +335 -0
  95. data/lib/rigor/effects/registry.rb +200 -0
  96. data/lib/rigor/effects/scanner.rb +302 -0
  97. data/lib/rigor/effects/signature_sources.rb +90 -0
  98. data/lib/rigor/effects/snapshot.rb +396 -0
  99. data/lib/rigor/effects/snapshot_diff.rb +265 -0
  100. data/lib/rigor/effects/summary.rb +154 -0
  101. data/lib/rigor/effects/taint_cause.rb +39 -0
  102. data/lib/rigor/effects/unit_scan.rb +647 -0
  103. data/lib/rigor/effects/unknown_label_check.rb +86 -0
  104. data/lib/rigor/effects/unknown_label_report.rb +59 -0
  105. data/lib/rigor/effects.rb +47 -0
  106. data/lib/rigor/environment/rbs_loader.rb +34 -0
  107. data/lib/rigor/environment.rb +8 -1
  108. data/lib/rigor/flow_contribution/element.rb +1 -0
  109. data/lib/rigor/flow_contribution/merge_result.rb +5 -3
  110. data/lib/rigor/flow_contribution/merger.rb +16 -1
  111. data/lib/rigor/flow_contribution.rb +20 -4
  112. data/lib/rigor/inference/expression_typer.rb +18 -4
  113. data/lib/rigor/inference/pre_eval_constants.rb +189 -0
  114. data/lib/rigor/inference/scope_indexer.rb +15 -3
  115. data/lib/rigor/plugin/base.rb +28 -0
  116. data/lib/rigor/plugin/box.rb +18 -2
  117. data/lib/rigor/plugin/effect_ancestry.rb +80 -0
  118. data/lib/rigor/plugin/effect_attribution.rb +208 -0
  119. data/lib/rigor/plugin/effect_edge.rb +101 -0
  120. data/lib/rigor/plugin/effect_entry_points.rb +51 -0
  121. data/lib/rigor/plugin/first_party.rb +57 -0
  122. data/lib/rigor/plugin/isolation.rb +4 -1
  123. data/lib/rigor/plugin/manifest.rb +127 -3
  124. data/lib/rigor/plugin/registry.rb +55 -0
  125. data/lib/rigor/plugin.rb +1 -0
  126. data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
  127. data/lib/rigor/rbs_extended.rb +188 -0
  128. data/lib/rigor/reflection.rb +131 -30
  129. data/lib/rigor/version.rb +1 -1
  130. data/lib/rigor.rb +1 -0
  131. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
  132. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
  133. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
  134. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
  135. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
  136. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
  137. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
  138. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
  139. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
  140. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
  141. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
  142. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
  143. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
  144. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
  145. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
  146. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
  147. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +401 -29
  148. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
  149. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
  150. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
  151. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
  152. data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
  153. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
  154. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
  155. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
  156. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
  157. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
  158. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
  159. data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
  160. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
  161. data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
  162. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +56 -1
  163. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +75 -0
  164. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
  165. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +68 -3
  166. data/sig/rigor/analysis/fact_store.rbs +1 -0
  167. data/sig/rigor/inference.rbs +2 -0
  168. data/sig/rigor/rbs_extended.rbs +6 -0
  169. data/sig/rigor.rbs +17 -1
  170. data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
  171. metadata +84 -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,17 +575,46 @@ 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]
333
- def to_time: (?Symbol form) -> Time
601
+ # NOTE: `Date#to_time` is an OVERLOAD CONTINUATION (`| ...`), not a plain
602
+ # declaration — stdlib `date` already types `to_time: () -> Time`, and a
603
+ # second full declaration of the same method from a second signature source
604
+ # makes `RBS::DefinitionBuilder` raise `DuplicatedMethodDefinitionError`,
605
+ # which collapses `Date` AND `DateTime` to `Dynamic[top]` for every project
606
+ # that activates this plugin (#437 — the same failure the `DateTime#to_time`
607
+ # note below records). The row cannot simply be DROPPED the way
608
+ # `DateTime#to_time` was: ActiveSupport genuinely widens the arity, and
609
+ # without the row `date.to_time(:utc)` — correct Rails code — would draw an
610
+ # arity diagnostic. `| ...` appends this overload ahead of the stdlib one, so
611
+ # both arities resolve and the `%a{pure}` envelope stays attached to the
612
+ # ActiveSupport overload (which is the one a no-argument call selects).
613
+ # `| ...` requires the base declaration to exist, which `Environment.for_project`
614
+ # guarantees: it merges `DEFAULT_LIBRARIES` — including `date` — into every
615
+ # environment that loads a plugin's `signature_paths`.
616
+ %a{pure}
617
+ def to_time: (?Symbol form) -> Time | ...
334
618
  end
335
619
 
336
620
  # ---------------------------------------------------------------
@@ -340,41 +624,61 @@ end
340
624
  class Array[unchecked out Elem]
341
625
  # `Array.wrap(x)` is the dominant Rails idiom: `nil → []`,
342
626
  # `Array x → x`, `else → [x]`.
627
+ %a{pure}
343
628
  def self.wrap: (untyped) -> Array[untyped]
344
629
 
345
630
  # `core_ext/array/access`
631
+ %a{pure}
346
632
  def from: (Integer position) -> Array[Elem]
633
+ %a{pure}
347
634
  def to: (Integer position) -> Array[Elem]
635
+ %a{pure}
348
636
  def second: () -> Elem?
637
+ %a{pure}
349
638
  def third: () -> Elem?
639
+ %a{pure}
350
640
  def fourth: () -> Elem?
641
+ %a{pure}
351
642
  def fifth: () -> Elem?
643
+ %a{pure}
352
644
  def forty_two: () -> Elem?
353
645
 
354
646
  # `core_ext/array/grouping`
647
+ %a{pure}
355
648
  def in_groups_of: (Integer number, ?untyped fill_with) -> Array[Array[Elem]]
356
649
  | (Integer number, ?untyped fill_with) { (Array[Elem]) -> void } -> Array[Elem]
650
+ %a{pure}
357
651
  def in_groups: (Integer number, ?untyped fill_with) -> Array[Array[Elem]]
358
652
  | (Integer number, ?untyped fill_with) { (Array[Elem]) -> void } -> Array[Elem]
653
+ %a{pure}
359
654
  def split: (?untyped value) -> Array[Array[Elem]]
360
655
  | () { (Elem) -> bool } -> Array[Array[Elem]]
361
656
 
362
- # `core_ext/array/conversions`
657
+ # `core_ext/array/conversions`. NEITHER `to_sentence` nor `to_fs` /
658
+ # `to_formatted_s` is `%a{pure}`: `to_sentence` calls `I18n.translate`
659
+ # for its connector words unless `locale: false` is passed explicitly
660
+ # (a request-scoped global read); `to_fs(:db)` calls `#id` on every
661
+ # element, a dispatch to an unknown-at-this-declaration method the same
662
+ # way `Object#try` does. `to_xml` takes a caller-supplied `:builder` and
663
+ # an optional block, both opaque callables — skipped for the same reason.
363
664
  def to_sentence: (?two_words_connector: String, ?last_word_connector: String, ?words_connector: String, ?locale: Symbol?) -> String
364
665
  def to_formatted_s: (?Symbol format) -> String
365
666
  def to_fs: (?Symbol format) -> String
366
667
  def to_xml: (**untyped) -> String
367
668
 
368
669
  # `core_ext/array/inquiry`
670
+ %a{pure}
369
671
  def inquiry: () -> untyped # ActiveSupport::ArrayInquirer
370
672
 
371
673
  # `core_ext/array/extract`
372
- def extract!: () { (Elem) -> bool } -> Array[Elem]
674
+ def extract!: () { (Elem) -> bool } -> Array[Elem] # NOT %a{pure}: bang, mutates in place.
373
675
 
374
676
  # `core_ext/object/blank` / `core_ext/enumerable` — both
375
677
  # ActiveSupport additions, shipped on Array specifically.
678
+ %a{pure}
376
679
  def compact_blank: () -> Array[Elem]
377
- def compact_blank!: () -> self
680
+ def compact_blank!: () -> self # NOT %a{pure}: bang (`delete_if`), mutates in place.
681
+ %a{pure}
378
682
  def exclude?: (Elem) -> bool
379
683
  end
380
684
 
@@ -383,20 +687,35 @@ end
383
687
  # ---------------------------------------------------------------
384
688
 
385
689
  module Enumerable[unchecked out Elem]
690
+ %a{pure}
386
691
  def index_by: () { (Elem) -> untyped } -> Hash[untyped, Elem]
692
+ %a{pure}
387
693
  def index_with: (?untyped default) { (Elem) -> untyped } -> Hash[Elem, untyped]
388
694
  | (untyped default) -> Hash[Elem, untyped]
695
+ %a{pure}
389
696
  def exclude?: (Elem) -> bool
697
+ %a{pure}
390
698
  def including: (*Elem) -> Array[Elem]
699
+ %a{pure}
391
700
  def excluding: (*Elem) -> Array[Elem]
701
+ %a{pure}
392
702
  def without: (*Elem) -> Array[Elem]
703
+ %a{pure}
393
704
  def pluck: (Symbol | String) -> Array[untyped]
394
705
  | (*Symbol | String) -> Array[Array[untyped]]
706
+ %a{pure}
395
707
  def pick: (Symbol | String) -> untyped
396
708
  | (*Symbol | String) -> Array[untyped]?
709
+ %a{pure}
397
710
  def maximum: (Symbol | String) -> untyped
711
+ %a{pure}
398
712
  def minimum: (Symbol | String) -> untyped
713
+ # Raises `SoleItemExpectedError` rather than returning on 0-or-2+ items;
714
+ # raising is not an effect label (AGENTS.md / design note), so this is
715
+ # still pure.
716
+ %a{pure}
399
717
  def sole: () -> Elem
718
+ %a{pure}
400
719
  def compact_blank: () -> Array[Elem]
401
720
  end
402
721
 
@@ -405,25 +724,39 @@ end
405
724
  # ---------------------------------------------------------------
406
725
 
407
726
  class Hash[unchecked out K, unchecked out V]
408
- # `core_ext/hash/keys`
727
+ # `core_ext/hash/keys` — bang variants mutate in place, so only the
728
+ # non-bang half is `%a{pure}`. `assert_valid_keys` raises rather than
729
+ # mutating (raising is not an effect label).
730
+ %a{pure}
409
731
  def symbolize_keys: () -> Hash[Symbol, V]
410
732
  def symbolize_keys!: () -> self
733
+ %a{pure}
411
734
  def deep_symbolize_keys: () -> Hash[untyped, untyped]
412
735
  def deep_symbolize_keys!: () -> self
736
+ %a{pure}
413
737
  def stringify_keys: () -> Hash[String, V]
414
738
  def stringify_keys!: () -> self
739
+ %a{pure}
415
740
  def deep_stringify_keys: () -> Hash[untyped, untyped]
416
741
  def deep_stringify_keys!: () -> self
742
+ %a{pure}
417
743
  def assert_valid_keys: (*K | Array[K]) -> self
744
+ %a{pure}
418
745
  def deep_transform_keys: () { (K) -> K } -> Hash[K, untyped]
419
746
  def deep_transform_keys!: () { (K) -> K } -> self
747
+ %a{pure}
420
748
  def deep_transform_values: () { (V) -> untyped } -> Hash[K, untyped]
421
749
  def deep_transform_values!: () { (V) -> untyped } -> self
422
750
 
423
- # `core_ext/hash/deep_dup`
751
+ # `core_ext/hash/deep_dup` — allocates a deep copy, never touches the
752
+ # receiver.
753
+ %a{pure}
424
754
  def deep_dup: () -> Hash[K, V]
425
755
 
426
- # `core_ext/hash/deep_merge`
756
+ # `core_ext/hash/deep_merge` — the block receives `(key, this_val,
757
+ # other_val)` and returns the merged value; neither `deep_merge` nor its
758
+ # block mutates either hash (containment covers the block itself).
759
+ %a{pure}
427
760
  def deep_merge: (Hash[K, V]) -> Hash[K, V]
428
761
  | (Hash[K, V]) { (K, V, V) -> V } -> Hash[K, V]
429
762
  def deep_merge!: (Hash[K, V]) -> self
@@ -433,15 +766,24 @@ class Hash[unchecked out K, unchecked out V]
433
766
  # Ruby 3.0+; `except!` is ActiveSupport-only. ActiveSupport
434
767
  # also aliases `Hash#without` to `Hash#except`, used by
435
768
  # `Mastodon`-shaped `options.without('type').merge(...)` chains.
436
- def except!: (*K) -> self
769
+ def except!: (*K) -> self # NOT %a{pure}: bang, mutates via `delete`.
770
+ %a{pure}
437
771
  def without: (*K) -> Hash[K, V]
438
772
 
439
- # `core_ext/hash/conversions`
773
+ # `core_ext/hash/conversions` — NOT `%a{pure}`: `to_query` / `to_param`
774
+ # recurse into `value.to_query` / `value.to_param` on every value, an
775
+ # unknown-at-this-declaration dispatch the same way `Object#try` and
776
+ # `Array#to_fs(:db)` are (a value's own `to_param` override is invisible
777
+ # here); `to_xml` takes a caller-supplied `:builder` and an optional
778
+ # block, both opaque callables.
440
779
  def to_query: (?String namespace) -> String
441
780
  def to_param: (?String namespace) -> String
442
781
  def to_xml: (**untyped) -> String
443
782
 
444
- # `core_ext/hash/indifferent_access`
783
+ # `core_ext/hash/indifferent_access` — wraps `self` in a new
784
+ # `HashWithIndifferentAccess`; the design note § 11.2 names this row
785
+ # `%a{pure}` explicitly.
786
+ %a{pure}
445
787
  def with_indifferent_access: () -> untyped
446
788
 
447
789
  # `core_ext/hash/conversions`
@@ -449,17 +791,19 @@ class Hash[unchecked out K, unchecked out V]
449
791
  def self.from_trusted_xml: (String) -> Hash[String, untyped]
450
792
 
451
793
  # `core_ext/object/blank` / `core_ext/hash`
794
+ %a{pure}
452
795
  def compact_blank: () -> Hash[K, V]
453
- def compact_blank!: () -> self
796
+ def compact_blank!: () -> self # NOT %a{pure}: bang (`delete_if`), mutates in place.
454
797
 
455
798
  # `core_ext/hash/reverse_merge`
799
+ %a{pure}
456
800
  def reverse_merge: (Hash[K, V]) -> Hash[K, V]
457
- def reverse_merge!: (Hash[K, V]) -> self
801
+ def reverse_merge!: (Hash[K, V]) -> self # NOT %a{pure}: bang (`replace`), mutates in place.
458
802
 
459
803
  # `core_ext/hash/slice` — `Hash#except` is in core RBS (Ruby 3.0+);
460
804
  # `Hash#slice` is in core RBS (Ruby 2.5+); the bang variants are
461
805
  # ActiveSupport-only.
462
- def slice!: (*K) -> Hash[K, V]
806
+ def slice!: (*K) -> Hash[K, V] # NOT %a{pure}: bang (`replace`), mutates in place.
463
807
  end
464
808
 
465
809
  # ---------------------------------------------------------------
@@ -467,19 +811,43 @@ end
467
811
  # ---------------------------------------------------------------
468
812
 
469
813
  class DateTime
814
+ # `utc` is pure Rational arithmetic on the receiver's own offset
815
+ # (`core_ext/date_time/calculations.rb`), no zone lookup.
816
+ %a{pure}
470
817
  def utc: () -> Time
471
818
  # NOTE: `DateTime#to_time` is NOT declared here — stdlib `date` already types it.
819
+ # NOT %a{pure}: `in_time_zone(zone = ::Time.zone)` reads `Time.zone`
820
+ # when no explicit zone is passed (`core_ext/date_and_time/zones.rb`).
821
+ # Not previously covered by #387's `effect_attributions:`; the plugin
822
+ # manifest gains this row in the same change (`global.read`).
472
823
  def in_time_zone: (?String | Symbol zone) -> untyped
824
+ # `yesterday` / `tomorrow` are inherited from `DateAndTime::Calculations`
825
+ # via `Date` (`DateTime < Date`), resolved against `DateTime`'s own
826
+ # `advance` — pure, same reasoning as `Date#yesterday` / `#tomorrow`
827
+ # above and unlike `Date.self.yesterday` / `.tomorrow`.
828
+ %a{pure}
473
829
  def yesterday: () -> DateTime
830
+ %a{pure}
474
831
  def tomorrow: () -> DateTime
832
+ # `ago` / `since` are `self + Rational(seconds, 86400)` — pure, and
833
+ # (unlike `Date#ago` / `#since`) never route through `in_time_zone`.
834
+ %a{pure}
475
835
  def ago: (Numeric seconds) -> DateTime
836
+ %a{pure}
476
837
  def since: (Numeric seconds) -> DateTime
838
+ %a{pure}
477
839
  def beginning_of_day: () -> DateTime
840
+ %a{pure}
478
841
  def end_of_day: () -> DateTime
842
+ %a{pure}
479
843
  def beginning_of_hour: () -> DateTime
844
+ %a{pure}
480
845
  def end_of_hour: () -> DateTime
846
+ %a{pure}
481
847
  def beginning_of_minute: () -> DateTime
848
+ %a{pure}
482
849
  def end_of_minute: () -> DateTime
850
+ %a{pure}
483
851
  def acts_like_time?: () -> true
484
852
  end
485
853
 
@@ -491,6 +859,7 @@ end
491
859
  # main String block above because it's a less-common method whose
492
860
  # coverage was added after a second-round survey.)
493
861
  class String
862
+ %a{pure}
494
863
  def exclude?: (String) -> bool
495
864
  end
496
865
 
@@ -505,6 +874,9 @@ class ERB
505
874
  module Util
506
875
  # `ERB::Util.html_escape_once(s)` escapes HTML without double-escaping
507
876
  # an already-escaped entity. ActionView adds it on top of stdlib ERB.
877
+ # A deterministic regex-substitution transform of the argument; no
878
+ # receiver, no global reads.
879
+ %a{pure}
508
880
  def self.html_escape_once: (untyped) -> String
509
881
  end
510
882
  end