rigortype 0.1.15 → 0.1.17

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 (220) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/exe/rigor +19 -0
  4. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +100 -0
  5. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +209 -0
  6. data/lib/rigor/analysis/check_rules.rb +174 -71
  7. data/lib/rigor/analysis/dependency_recorder.rb +122 -0
  8. data/lib/rigor/analysis/diagnostic.rb +58 -0
  9. data/lib/rigor/analysis/incremental.rb +162 -0
  10. data/lib/rigor/analysis/incremental_session.rb +337 -0
  11. data/lib/rigor/analysis/rule_catalog.rb +48 -0
  12. data/lib/rigor/analysis/runner.rb +485 -29
  13. data/lib/rigor/analysis/self_call_resolution_recorder.rb +121 -0
  14. data/lib/rigor/analysis/worker_session.rb +3 -2
  15. data/lib/rigor/builtins/static_return_refinements.rb +7 -1
  16. data/lib/rigor/cache/descriptor.rb +56 -51
  17. data/lib/rigor/cache/incremental_snapshot.rb +147 -0
  18. data/lib/rigor/cache/rbs_cache_producer.rb +30 -0
  19. data/lib/rigor/cache/rbs_class_ancestor_table.rb +2 -8
  20. data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -8
  21. data/lib/rigor/cache/rbs_constant_table.rb +2 -8
  22. data/lib/rigor/cache/rbs_environment.rb +2 -8
  23. data/lib/rigor/cache/rbs_instance_definitions.rb +3 -16
  24. data/lib/rigor/cache/rbs_known_class_names.rb +2 -8
  25. data/lib/rigor/cache/store.rb +99 -1
  26. data/lib/rigor/cli/annotate_command.rb +2 -7
  27. data/lib/rigor/cli/baseline_command.rb +2 -7
  28. data/lib/rigor/cli/command.rb +47 -0
  29. data/lib/rigor/cli/coverage_command.rb +3 -23
  30. data/lib/rigor/cli/coverage_renderer.rb +3 -8
  31. data/lib/rigor/cli/diff_command.rb +3 -7
  32. data/lib/rigor/cli/explain_command.rb +2 -7
  33. data/lib/rigor/cli/lsp_command.rb +3 -7
  34. data/lib/rigor/cli/mcp_command.rb +3 -7
  35. data/lib/rigor/cli/options.rb +57 -0
  36. data/lib/rigor/cli/plugin_command.rb +3 -7
  37. data/lib/rigor/cli/plugins_command.rb +52 -10
  38. data/lib/rigor/cli/plugins_renderer.rb +86 -1
  39. data/lib/rigor/cli/renderable.rb +26 -0
  40. data/lib/rigor/cli/sig_gen_command.rb +2 -7
  41. data/lib/rigor/cli/skill_command.rb +3 -7
  42. data/lib/rigor/cli/triage_command.rb +2 -7
  43. data/lib/rigor/cli/type_of_command.rb +5 -38
  44. data/lib/rigor/cli/type_of_renderer.rb +4 -9
  45. data/lib/rigor/cli/type_scan_command.rb +3 -23
  46. data/lib/rigor/cli/type_scan_renderer.rb +4 -9
  47. data/lib/rigor/cli.rb +260 -48
  48. data/lib/rigor/configuration/dependencies.rb +18 -1
  49. data/lib/rigor/configuration/severity_profile.rb +22 -3
  50. data/lib/rigor/configuration.rb +13 -3
  51. data/lib/rigor/environment/rbs_loader.rb +335 -4
  52. data/lib/rigor/environment.rb +8 -2
  53. data/lib/rigor/inference/block_parameter_binder.rb +1 -2
  54. data/lib/rigor/inference/budget_trace.rb +137 -0
  55. data/lib/rigor/inference/builtins/array_catalog.rb +2 -5
  56. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -5
  57. data/lib/rigor/inference/builtins/complex_catalog.rb +2 -5
  58. data/lib/rigor/inference/builtins/date_catalog.rb +2 -5
  59. data/lib/rigor/inference/builtins/encoding_catalog.rb +2 -5
  60. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -5
  61. data/lib/rigor/inference/builtins/exception_catalog.rb +2 -5
  62. data/lib/rigor/inference/builtins/hash_catalog.rb +2 -5
  63. data/lib/rigor/inference/builtins/method_catalog.rb +15 -0
  64. data/lib/rigor/inference/builtins/numeric_catalog.rb +21 -93
  65. data/lib/rigor/inference/builtins/pathname_catalog.rb +2 -5
  66. data/lib/rigor/inference/builtins/proc_catalog.rb +2 -5
  67. data/lib/rigor/inference/builtins/random_catalog.rb +2 -5
  68. data/lib/rigor/inference/builtins/range_catalog.rb +2 -5
  69. data/lib/rigor/inference/builtins/rational_catalog.rb +2 -5
  70. data/lib/rigor/inference/builtins/re_catalog.rb +2 -5
  71. data/lib/rigor/inference/builtins/set_catalog.rb +2 -5
  72. data/lib/rigor/inference/builtins/string_catalog.rb +2 -5
  73. data/lib/rigor/inference/builtins/struct_catalog.rb +2 -5
  74. data/lib/rigor/inference/builtins/time_catalog.rb +2 -5
  75. data/lib/rigor/inference/expression_typer.rb +149 -22
  76. data/lib/rigor/inference/hkt_reducer.rb +2 -0
  77. data/lib/rigor/inference/method_dispatcher/block_folding.rb +5 -1
  78. data/lib/rigor/inference/method_dispatcher/call_context.rb +65 -0
  79. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +11 -10
  80. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +12 -6
  81. data/lib/rigor/inference/method_dispatcher/data_folding.rb +246 -0
  82. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -2
  83. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -2
  84. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -1
  85. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +4 -1
  86. data/lib/rigor/inference/method_dispatcher/math_folding.rb +6 -6
  87. data/lib/rigor/inference/method_dispatcher/method_folding.rb +12 -7
  88. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -6
  89. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +100 -23
  90. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +9 -9
  91. data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -6
  92. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +120 -9
  93. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +12 -12
  94. data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +49 -0
  95. data/lib/rigor/inference/method_dispatcher/time_folding.rb +6 -6
  96. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -9
  97. data/lib/rigor/inference/method_dispatcher.rb +147 -60
  98. data/lib/rigor/inference/narrowing.rb +202 -5
  99. data/lib/rigor/inference/precision_scanner.rb +60 -1
  100. data/lib/rigor/inference/scope_indexer.rb +257 -11
  101. data/lib/rigor/inference/statement_evaluator.rb +110 -26
  102. data/lib/rigor/inference/synthetic_method_index.rb +23 -4
  103. data/lib/rigor/inference/synthetic_method_scanner.rb +148 -14
  104. data/lib/rigor/language_server/buffer_resolution.rb +33 -0
  105. data/lib/rigor/language_server/completion_provider.rb +4 -4
  106. data/lib/rigor/language_server/document_symbol_provider.rb +4 -4
  107. data/lib/rigor/language_server/folding_range_provider.rb +4 -4
  108. data/lib/rigor/language_server/hover_provider.rb +4 -4
  109. data/lib/rigor/language_server/selection_range_provider.rb +4 -4
  110. data/lib/rigor/language_server/signature_help_provider.rb +4 -4
  111. data/lib/rigor/plugin/additional_initializer.rb +108 -0
  112. data/lib/rigor/plugin/base.rb +337 -2
  113. data/lib/rigor/plugin/box.rb +64 -0
  114. data/lib/rigor/plugin/inflector.rb +121 -0
  115. data/lib/rigor/plugin/isolation.rb +191 -0
  116. data/lib/rigor/plugin/macro/nested_class_template.rb +140 -0
  117. data/lib/rigor/plugin/macro.rb +1 -0
  118. data/lib/rigor/plugin/manifest.rb +120 -23
  119. data/lib/rigor/plugin/node_context.rb +62 -0
  120. data/lib/rigor/plugin/registry.rb +49 -1
  121. data/lib/rigor/plugin.rb +3 -0
  122. data/lib/rigor/rbs_extended/conformance_checker.rb +208 -0
  123. data/lib/rigor/rbs_extended.rb +39 -0
  124. data/lib/rigor/scope.rb +123 -9
  125. data/lib/rigor/sig_gen/generator.rb +2 -3
  126. data/lib/rigor/sig_gen/observation_collector.rb +2 -2
  127. data/lib/rigor/source/literals.rb +118 -0
  128. data/lib/rigor/source/node_walker.rb +26 -0
  129. data/lib/rigor/source.rb +1 -0
  130. data/lib/rigor/type/acceptance_router.rb +19 -0
  131. data/lib/rigor/type/accepts_result.rb +3 -10
  132. data/lib/rigor/type/app.rb +3 -7
  133. data/lib/rigor/type/bot.rb +2 -3
  134. data/lib/rigor/type/bound_method.rb +5 -12
  135. data/lib/rigor/type/combinator.rb +23 -1
  136. data/lib/rigor/type/constant.rb +2 -3
  137. data/lib/rigor/type/data_class.rb +80 -0
  138. data/lib/rigor/type/data_instance.rb +100 -0
  139. data/lib/rigor/type/difference.rb +5 -10
  140. data/lib/rigor/type/dynamic.rb +5 -10
  141. data/lib/rigor/type/hash_shape.rb +5 -15
  142. data/lib/rigor/type/integer_range.rb +5 -10
  143. data/lib/rigor/type/intersection.rb +5 -10
  144. data/lib/rigor/type/nominal.rb +5 -10
  145. data/lib/rigor/type/refined.rb +5 -10
  146. data/lib/rigor/type/singleton.rb +5 -10
  147. data/lib/rigor/type/top.rb +2 -3
  148. data/lib/rigor/type/tuple.rb +5 -10
  149. data/lib/rigor/type/union.rb +69 -10
  150. data/lib/rigor/type.rb +2 -0
  151. data/lib/rigor/value_semantics.rb +77 -0
  152. data/lib/rigor/version.rb +1 -1
  153. data/lib/rigor.rb +2 -0
  154. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +31 -53
  155. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +21 -23
  156. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +38 -59
  157. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +7 -13
  158. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +22 -33
  159. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +298 -413
  160. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +69 -71
  161. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +24 -34
  162. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +18 -16
  163. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +4 -46
  164. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  165. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +1 -1
  166. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +17 -12
  167. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -8
  168. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +2 -7
  169. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +2 -6
  170. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +4 -3
  171. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  172. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +40 -45
  173. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +7 -17
  174. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +20 -42
  175. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +7 -4
  176. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +4 -8
  177. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +188 -0
  178. data/plugins/rigor-mangrove/lib/rigor-mangrove.rb +3 -0
  179. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +4 -0
  180. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +24 -8
  181. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +31 -48
  182. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +21 -23
  183. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +54 -82
  184. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +25 -25
  185. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +63 -147
  186. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -17
  187. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +23 -114
  188. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +48 -33
  189. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  190. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +6 -3
  191. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +4 -2
  192. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +13 -12
  193. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +28 -40
  194. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +44 -47
  195. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +11 -10
  196. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +45 -87
  197. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +11 -12
  198. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +29 -42
  199. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +20 -19
  200. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +73 -0
  201. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +43 -1
  202. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +21 -29
  203. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +36 -96
  204. data/sig/rigor/cache.rbs +19 -0
  205. data/sig/rigor/inference.rbs +22 -0
  206. data/sig/rigor/plugin/access_denied_error.rbs +3 -1
  207. data/sig/rigor/plugin/base.rbs +58 -3
  208. data/sig/rigor/plugin/io_boundary.rbs +3 -0
  209. data/sig/rigor/plugin/manifest.rbs +31 -1
  210. data/sig/rigor/rbs_extended.rbs +2 -0
  211. data/sig/rigor/scope.rbs +5 -0
  212. data/sig/rigor/source.rbs +12 -0
  213. data/sig/rigor/type.rbs +58 -1
  214. data/sig/rigor.rbs +11 -1
  215. data/skills/rigor-plugin-author/SKILL.md +13 -9
  216. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +6 -5
  217. data/skills/rigor-plugin-author/references/02-walker-and-types.md +159 -75
  218. data/skills/rigor-plugin-author/references/03-test-and-ship.md +3 -3
  219. metadata +73 -2
  220. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +0 -114
@@ -30,6 +30,24 @@ module Rigor
30
30
  # See docs/internal-spec/inference-engine.md for the binding contract.
31
31
  # rubocop:disable Metrics/ClassLength
32
32
  class RbsLoader
33
+ # Buffer name stamped on the `module` declarations synthesized by
34
+ # {.synthesize_missing_namespaces}. Re-read off the built env by
35
+ # {#synthesized_namespaces} so the analysis layer can surface an
36
+ # `:info` diagnostic naming the project's malformed-RBS namespaces
37
+ # — robust across the marshalled env cache, since the sentinel
38
+ # rides along on each synthetic declaration's location.
39
+ SYNTHETIC_NAMESPACE_BUFFER = "(rigor: synthesized namespaces)"
40
+
41
+ # Buffer name stamped on the stub `class` / `module` declarations
42
+ # synthesized by {.stub_missing_referenced_types} for types the
43
+ # project's RBS references but no loaded signature declares.
44
+ # {#synthesized_stub_types} reads them back off the built env (so
45
+ # the answer survives the marshalled env cache), and
46
+ # {#synthesized_type_names} folds them together with the
47
+ # namespace stubs into the set {MethodDispatcher} resolves to
48
+ # `Dynamic[Top]` (no false `call.undefined-method`).
49
+ SYNTHETIC_STUB_BUFFER = "(rigor: synthesized stub types)"
50
+
33
51
  class << self
34
52
  def default
35
53
  @default ||= new.freeze
@@ -72,7 +90,172 @@ module Rigor
72
90
  end
73
91
  env = RBS::Environment.from_loader(rbs_loader)
74
92
  add_virtual_rbs(env, virtual_rbs)
75
- env.resolve_type_names
93
+ synthesize_missing_namespaces(env)
94
+ resolved = env.resolve_type_names
95
+ stub_missing_referenced_types(env, resolved, project_sig_files(signature_paths))
96
+ end
97
+
98
+ # ADR-5 robustness, second tier. A project `signature_paths:`
99
+ # RBS that *references* a type no loaded signature declares —
100
+ # `def x: () -> DRb::DRbServer` when the `drb` RBS is not
101
+ # available, or a stale reference to its own removed
102
+ # `Textbringer::EditorError` — makes
103
+ # `RBS::DefinitionBuilder#build_instance` raise
104
+ # `NoTypeFoundError`, and (per RBS's all-or-nothing per-class
105
+ # build) that single unresolved reference takes down EVERY
106
+ # method on the class, not just the one signature. Observed on
107
+ # shugo/textbringer: one `DRb::DRbServer` reference left the
108
+ # whole `Textbringer::Commands` module — including its
109
+ # 186-call-site `define_command` DSL — resolving as
110
+ # `Dynamic[Top]`.
111
+ #
112
+ # We synthesize an empty stub for each such referenced-but-
113
+ # undeclared type so the rest of the class builds. A leaf type
114
+ # is stubbed as `class`, its enclosing namespaces as `module`.
115
+ # Stubbed types carry no methods, so a call against a value of
116
+ # a stubbed type would otherwise mis-fire `call.undefined-method`;
117
+ # {MethodDispatcher} consults {#synthesized_type_names} and
118
+ # resolves such calls to `Dynamic[Top]` instead (the same
119
+ # no-false-positive contract as the dependency-source tier).
120
+ #
121
+ # Detection re-uses RBS's own builder (correct by construction):
122
+ # build every PROJECT class and read the missing name out of the
123
+ # raised error. Bounded to `signature_paths` classes (stdlib /
124
+ # vendored RBS is well-formed) and to {MAX_STUB_PASSES}
125
+ # iterations — a fresh stub can expose a deeper reference the
126
+ # first build error hid, but empty stubs reference nothing, so
127
+ # the fixpoint converges quickly.
128
+ MAX_STUB_PASSES = 5
129
+
130
+ def stub_missing_referenced_types(base_env, resolved, project_files)
131
+ return resolved if project_files.empty?
132
+
133
+ MAX_STUB_PASSES.times do
134
+ missing = unresolved_referenced_types(resolved, project_files)
135
+ break if missing.empty?
136
+
137
+ append_stub_declarations(base_env, missing)
138
+ resolved = base_env.resolve_type_names
139
+ end
140
+ resolved
141
+ end
142
+
143
+ # Robustness (ADR-5): a project whose RBS declares qualified
144
+ # names (`class Foo::Bar`) without ever declaring the enclosing
145
+ # namespace (`module Foo`) is invalid by upstream RBS rules —
146
+ # `RBS::DefinitionBuilder#build_instance` raises
147
+ # `NoTypeFoundError: Could not find ::Foo`, which the loader's
148
+ # fail-soft rescue turns into a silent dispatch miss (every
149
+ # method on every such class degrades to `Dynamic[Top]`). This
150
+ # is a common authoring mistake (e.g. shugo/textbringer ships a
151
+ # `sig/` that `rbs validate` itself rejects). Rather than let an
152
+ # otherwise-usable signature set contribute nothing, synthesize
153
+ # an empty `module` declaration for each undeclared enclosing
154
+ # namespace so the definitions build. We only ever add names
155
+ # that are absent — a genuinely-declared namespace (module or
156
+ # class, here or in a loaded gem) is left untouched.
157
+ def synthesize_missing_namespaces(env)
158
+ missing = collect_missing_namespaces(env)
159
+ return if missing.empty?
160
+
161
+ source = missing.map { |name| "module #{name}\nend\n" }.join
162
+ buffer = ::RBS::Buffer.new(name: SYNTHETIC_NAMESPACE_BUFFER, content: source)
163
+ _, directives, decls = ::RBS::Parser.parse_signature(buffer)
164
+ env.add_source(::RBS::Source::RBS.new(buffer, directives || [], decls || []))
165
+ rescue ::RBS::BaseError
166
+ # Fail-soft: synthesis is an opportunistic uplift, never a
167
+ # hard requirement. A parse failure here just leaves the env
168
+ # as it was (dispatch misses on the affected classes).
169
+ nil
170
+ end
171
+
172
+ # Returns the `::`-stripped names of every enclosing namespace
173
+ # that some declaration references but no declaration defines,
174
+ # shallowest-first so the synthesized source declares `Foo`
175
+ # before `Foo::Bar`.
176
+ def collect_missing_namespaces(env)
177
+ declared = env.class_decls.keys.to_set
178
+ missing = {}
179
+ env.class_decls.each_key do |type_name|
180
+ path = type_name.namespace.path
181
+ path.each_index do |i|
182
+ prefix = path[0..i]
183
+ full = ::RBS::TypeName.parse("::#{prefix.join('::')}")
184
+ missing[prefix.join("::")] = prefix.length unless declared.include?(full)
185
+ end
186
+ end
187
+ missing.sort_by { |_name, depth| depth }.map(&:first)
188
+ end
189
+
190
+ # The absolute paths of every `.rbs` file under the project's
191
+ # `signature_paths:` (NOT vendored / stdlib RBS — those are
192
+ # well-formed, so attempting to build them would only waste
193
+ # time). Used to scope the referenced-type build sweep.
194
+ def project_sig_files(signature_paths)
195
+ signature_paths.flat_map do |path|
196
+ path = Pathname(path) unless path.is_a?(Pathname)
197
+ next [] unless path.directory?
198
+
199
+ Dir.glob(path.join("**", "*.rbs")).map { |p| File.expand_path(p) }
200
+ end.to_set
201
+ end
202
+
203
+ # Builds every project class (instance + singleton side) and
204
+ # returns the `::`-stripped names of the types whose absence
205
+ # raised `NoTypeFoundError`. Only the FIRST missing reference
206
+ # per class surfaces per build, which is why the caller loops.
207
+ def unresolved_referenced_types(env, project_files)
208
+ builder = ::RBS::DefinitionBuilder.new(env: env)
209
+ missing = []
210
+ env.class_decls.each do |type_name, entry|
211
+ next unless project_entry?(entry, project_files)
212
+
213
+ %i[build_instance build_singleton].each do |build|
214
+ builder.public_send(build, type_name)
215
+ rescue ::RBS::NoTypeFoundError => e
216
+ name = e.message[/Could not find (\S+)/, 1]
217
+ missing << name.sub(/\A::/, "") if name
218
+ rescue ::RBS::BaseError
219
+ # Other build failures (duplicate decl, mixin cycle, ...)
220
+ # are not ours to repair here — leave them fail-soft.
221
+ end
222
+ end
223
+ missing.uniq
224
+ end
225
+
226
+ # True when a `class_decls` entry was declared in one of the
227
+ # project's own signature files (by declaration location), so
228
+ # the sweep skips the bundled stdlib / vendored universe.
229
+ def project_entry?(entry, project_files)
230
+ decl = entry.respond_to?(:primary_decl) ? entry.primary_decl : nil
231
+ location = decl&.location
232
+ buffer_name = location&.buffer&.name
233
+ return false unless buffer_name
234
+
235
+ project_files.include?(File.expand_path(buffer_name.to_s))
236
+ end
237
+
238
+ # Adds empty stub declarations for the missing referenced types
239
+ # (and any enclosing namespace they need) to the pre-resolve
240
+ # env, tagged with {SYNTHETIC_STUB_BUFFER}. A name that is a
241
+ # prefix of another name is declared `module` (it is a
242
+ # namespace); a leaf is declared `class` (referenced types
243
+ # appear in instance position far more often than as mixins).
244
+ def append_stub_declarations(base_env, missing)
245
+ names = missing.to_set
246
+ missing.each do |name|
247
+ parts = name.split("::")
248
+ (1...parts.length).each { |i| names << parts[0, i].join("::") }
249
+ end
250
+ source = names.sort_by { |n| n.count(":") }.map do |name|
251
+ keyword = names.any? { |other| other != name && other.start_with?("#{name}::") } ? "module" : "class"
252
+ "#{keyword} #{name}\nend\n"
253
+ end.join
254
+ buffer = ::RBS::Buffer.new(name: SYNTHETIC_STUB_BUFFER, content: source)
255
+ _, directives, decls = ::RBS::Parser.parse_signature(buffer)
256
+ base_env.add_source(::RBS::Source::RBS.new(buffer, directives || [], decls || []))
257
+ rescue ::RBS::BaseError
258
+ nil
76
259
  end
77
260
 
78
261
  # ADR-32 WD4 — merge synthesised-from-source RBS strings
@@ -192,6 +375,40 @@ module Rigor
192
375
  @hierarchy = RbsHierarchy.new(self)
193
376
  end
194
377
 
378
+ # The enclosing namespaces {.synthesize_missing_namespaces} had to
379
+ # invent because the project's `signature_paths:` RBS declared
380
+ # qualified names (`class Foo::Bar`) without ever declaring `Foo`.
381
+ # Recovered by scanning the built env for class/module entries
382
+ # whose every declaration originated from the synthetic buffer, so
383
+ # the answer survives the marshalled-env cache (where no build-time
384
+ # collector would). Returns `::`-stripped names, shallowest-first.
385
+ # Empty for a well-formed sig set (the common case) and whenever
386
+ # the env failed to build.
387
+ def synthesized_namespaces
388
+ names_synthesized_in(SYNTHETIC_NAMESPACE_BUFFER)
389
+ end
390
+
391
+ # The referenced-but-undeclared types
392
+ # {.stub_missing_referenced_types} stubbed so the project classes
393
+ # that mention them could build (e.g. an unavailable
394
+ # `DRb::DRbServer`, or a stale `Textbringer::EditorError`).
395
+ # Recovered off the built env like {#synthesized_namespaces}, so
396
+ # it survives the marshalled-env cache.
397
+ def synthesized_stub_types
398
+ names_synthesized_in(SYNTHETIC_STUB_BUFFER)
399
+ end
400
+
401
+ # Every type name Rigor invented to make an otherwise-inert /
402
+ # unbuildable project signature set resolve — both the namespace
403
+ # stubs and the referenced-type stubs. {MethodDispatcher} resolves
404
+ # a call whose receiver is one of these (and that no real
405
+ # signature answered) to `Dynamic[Top]`, so the empty stub never
406
+ # mis-fires `call.undefined-method`. Memoised; empty (and cheap)
407
+ # for the common well-formed sig set.
408
+ def synthesized_type_names
409
+ @state[:synthesized_type_names] ||= (synthesized_namespaces + synthesized_stub_types).to_set
410
+ end
411
+
195
412
  # Returns true when an RBS class or module declaration with the given
196
413
  # name is loaded. Accepts unprefixed or top-level-prefixed names
197
414
  # ("Integer" or "::Integer"). Memoized per-name (positive and
@@ -284,6 +501,27 @@ module Rigor
284
501
  # registry rather than crashing.
285
502
  end
286
503
 
504
+ # Like {#each_class_decl_annotation}, but also yields the
505
+ # owning class / module's RBS name as the first block
506
+ # argument: `(class_name, annotation_string, location)`. Used
507
+ # by {Rigor::RbsExtended::ConformanceChecker} to resolve a
508
+ # `rigor:v1:conforms-to` directive back to the class it
509
+ # annotates. Same fail-soft policy as the un-named variant.
510
+ def each_class_decl_annotation_with_name
511
+ return enum_for(:each_class_decl_annotation_with_name) unless block_given?
512
+ return if env.nil?
513
+
514
+ env.class_decls.each do |rbs_name, entry|
515
+ entry.each_decl do |decl|
516
+ next unless decl.respond_to?(:annotations)
517
+
518
+ decl.annotations.each { |a| yield rbs_name.to_s, a.string, a.location }
519
+ end
520
+ end
521
+ rescue ::RBS::BaseError, ::Ractor::IsolationError
522
+ # fail-soft: see #each_class_decl_annotation.
523
+ end
524
+
287
525
  # Returns a frozen `Hash<String, String>` mapping each loaded
288
526
  # class / module name (top-level prefixed) to the file path of
289
527
  # its FIRST declaration's RBS source. Used by
@@ -354,6 +592,44 @@ module Rigor
354
592
  definition.methods[method_name.to_sym]
355
593
  end
356
594
 
595
+ # @return [Array<Symbol>, nil] every instance-method name on
596
+ # `class_name` — own, inherited, and included — as resolved
597
+ # by `RBS::DefinitionBuilder`. Returns `nil` (NOT `[]`) when
598
+ # the class definition cannot be built so callers can tell
599
+ # "no methods" apart from "unknown class". Used by the
600
+ # `rigor:v1:conforms-to` presence check
601
+ # ({Rigor::RbsExtended::ConformanceChecker}).
602
+ def instance_method_names(class_name)
603
+ definition = instance_definition(class_name)
604
+ return nil unless definition
605
+
606
+ definition.methods.keys
607
+ end
608
+
609
+ # @return [RBS::Definition, nil] the built definition for the RBS
610
+ # interface `interface_name` (`_RewindableStream`), whose `.methods`
611
+ # are the required members (including interface-ancestor members).
612
+ # Returns `nil` when the name does not resolve to a loaded interface
613
+ # (a typo, or the defining library / sig set is not on the load
614
+ # path). Fail-soft on RBS build errors.
615
+ def interface_definition(interface_name)
616
+ rbs_name = parse_type_name(interface_name)
617
+ return nil unless rbs_name
618
+ return nil if env.nil?
619
+ return nil unless env.interface_decls.key?(rbs_name)
620
+
621
+ builder.build_interface(rbs_name)
622
+ rescue ::RBS::BaseError
623
+ nil
624
+ end
625
+
626
+ # @return [Array<Symbol>, nil] every method name required by the RBS
627
+ # interface `interface_name`, or nil when it does not resolve. Thin
628
+ # accessor over {#interface_definition} for the presence check.
629
+ def interface_method_names(interface_name)
630
+ interface_definition(interface_name)&.methods&.keys
631
+ end
632
+
357
633
  # @return [RBS::Definition, nil] the resolved singleton (class
358
634
  # object) definition for `class_name`. The methods on this
359
635
  # definition are the *class methods* of `class_name`, including
@@ -546,6 +822,47 @@ module Rigor
546
822
 
547
823
  private
548
824
 
825
+ # The `::`-stripped names of every class/module entry whose
826
+ # declarations ALL originated from the given sentinel buffer —
827
+ # i.e. names Rigor synthesized, not names the project declared.
828
+ # Reads off the built env so the answer survives the marshalled
829
+ # env cache; shallowest-first. Empty when the env failed to build.
830
+ def names_synthesized_in(buffer_name)
831
+ e = env
832
+ return [] if e.nil?
833
+
834
+ names = e.class_decls.filter_map do |type_name, entry|
835
+ decls = entry_declarations(entry)
836
+ next if decls.empty?
837
+ next unless decls.all? { |decl| synthetic_decl?(decl, buffer_name) }
838
+
839
+ type_name.to_s.sub(/\A::/, "")
840
+ end
841
+ names.sort_by { |name| name.count("::") }
842
+ end
843
+
844
+ # Collects the AST declaration nodes behind a `class_decls`
845
+ # entry. RBS 4's `ModuleEntry` / `ClassEntry` expose `each_decl`;
846
+ # the older single-`decl` shape is handled defensively so the
847
+ # loader survives an rbs-gem minor bump.
848
+ def entry_declarations(entry)
849
+ if entry.respond_to?(:each_decl)
850
+ [].tap { |acc| entry.each_decl { |decl| acc << decl } }
851
+ elsif entry.respond_to?(:decl)
852
+ [entry.decl]
853
+ else
854
+ []
855
+ end
856
+ end
857
+
858
+ # True when an AST declaration was emitted into `buffer_name`
859
+ # (one of the synthetic-source sentinels) — identified by the
860
+ # buffer name on its location.
861
+ def synthetic_decl?(decl, buffer_name)
862
+ location = decl.respond_to?(:location) ? decl.location : nil
863
+ location&.buffer&.name.to_s == buffer_name
864
+ end
865
+
549
866
  def constant_type_table
550
867
  @constant_type_table ||= begin
551
868
  require_relative "../cache/rbs_constant_table"
@@ -725,14 +1042,28 @@ module Rigor
725
1042
  nil
726
1043
  end
727
1044
 
1045
+ # Memoised on `@state` (the per-loader store also holding `:env` /
1046
+ # `:builder`): `RBS::TypeName.parse` is a pure, deterministic
1047
+ # function of the normalised string, and the `RBS::TypeName` it
1048
+ # returns is a frozen value object safe to share across callers
1049
+ # (every consumer only reads it — `env.class_decls.key?` /
1050
+ # `builder.build_*`). The same handful of class names are parsed
1051
+ # on nearly every call-site dispatch, so this was a top allocation
1052
+ # site; caching the immutable result (nil included) removes it.
728
1053
  def parse_type_name(name)
729
1054
  s = name.to_s
730
1055
  return nil if s.empty?
731
1056
 
732
1057
  s = "::#{s}" unless s.start_with?("::")
733
- RBS::TypeName.parse(s)
734
- rescue ::RBS::BaseError
735
- nil
1058
+ cache = (@state[:type_name_cache] ||= {})
1059
+ return cache[s] if cache.key?(s)
1060
+
1061
+ cache[s] =
1062
+ begin
1063
+ RBS::TypeName.parse(s)
1064
+ rescue ::RBS::BaseError
1065
+ nil
1066
+ end
736
1067
  end
737
1068
 
738
1069
  def compute_class_known(name)
@@ -512,8 +512,14 @@ module Rigor
512
512
  # presence check without materialising a type carrier.
513
513
  def class_known?(name)
514
514
  return true if class_registry.nominal_for_name(name)
515
-
516
- class_known_in_rbs?(name)
515
+ return true if class_known_in_rbs?(name)
516
+
517
+ # ADR-36 nested-class emission — a variant subclass the
518
+ # substrate synthesised (e.g. `Shape::Circle` from a
519
+ # `variants do variant Circle, Float end` block) is a real
520
+ # class for resolution purposes even though no RBS / source
521
+ # declares it.
522
+ @synthetic_method_index&.knows_class?(name) || false
517
523
  end
518
524
 
519
525
  # ADR-15 Phase 2b — returns the loader's read-only,
@@ -174,8 +174,7 @@ module Rigor
174
174
 
175
175
  def bind_trailing_positionals(params_node, bindings, cursor)
176
176
  params_node.posts.each do |param|
177
- name = required_name(param)
178
- bindings[name] = positional_type_at(cursor) if name
177
+ bind_required_param(param, cursor, bindings)
179
178
  cursor += 1
180
179
  end
181
180
  cursor
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Inference
5
+ # Opt-in counters for the hard-coded inference cutoffs — the
6
+ # "budget" guards that silently return `Dynamic[top]` / `nil` /
7
+ # a fallback bound rather than emitting a diagnostic. These are
8
+ # the *operative* cutoffs in the engine today (the configurable
9
+ # `budgets:` table in docs/type-specification/inference-budgets.md
10
+ # is not yet wired); counting how often each fires on a real
11
+ # project is the only way to see where inference actually stops.
12
+ #
13
+ # Three categories, one per guard site:
14
+ #
15
+ # - {RECURSION_GUARD} — `ExpressionTyper#infer_user_method_return`
16
+ # detected a `(receiver, method)` cycle and returned
17
+ # `Dynamic[top]` (the de-facto recursion-depth budget, effective
18
+ # depth 1).
19
+ # - {ANCESTOR_WALK_LIMIT} — `resolve_user_def_through_ancestors`
20
+ # hit the 100-node BFS cap and gave up resolving the self-call.
21
+ # - {HKT_FUEL_EXHAUSTED} — `HktReducer` ran out of its reduction
22
+ # fuel budget and unwound to `app.bound`.
23
+ #
24
+ # Enabled only when `RIGOR_BUDGET_TRACE` is set (to any non-empty
25
+ # value) in the environment, or via {enable!} in tests. When
26
+ # disabled, {hit} is a single boolean check and returns
27
+ # immediately, so normal runs pay nothing.
28
+ #
29
+ # Counters are process-global (Mutex-guarded) so they aggregate
30
+ # across threads, but they do NOT cross `fork` boundaries — run
31
+ # `rigor check --workers 0` to keep all inference in one process
32
+ # when collecting a trace.
33
+ module BudgetTrace
34
+ RECURSION_GUARD = :recursion_guard
35
+ ANCESTOR_WALK_LIMIT = :ancestor_walk_limit
36
+ HKT_FUEL_EXHAUSTED = :hkt_fuel_exhausted
37
+
38
+ CATEGORIES = [RECURSION_GUARD, ANCESTOR_WALK_LIMIT, HKT_FUEL_EXHAUSTED].freeze
39
+
40
+ # Distribution (histogram) categories — read-only observations of
41
+ # a value's size at a site, used to choose budget defaults from an
42
+ # observed tail rather than a guess (ADR-41 WD3 / Slice 2a). No cap
43
+ # is enforced; these only record. `UNION_ARITY` is the member count
44
+ # of every `Type::Union` that `Combinator.union` produces — the
45
+ # distribution the `union_size` budget default should be set from.
46
+ UNION_ARITY = :union_arity
47
+
48
+ DISTRIBUTION_CATEGORIES = [UNION_ARITY].freeze
49
+
50
+ @enabled = !ENV["RIGOR_BUDGET_TRACE"].to_s.empty?
51
+ @mutex = Mutex.new
52
+ @counts = Hash.new(0)
53
+ @distributions = Hash.new { |h, k| h[k] = Hash.new(0) }
54
+
55
+ module_function
56
+
57
+ def enabled?
58
+ @enabled
59
+ end
60
+
61
+ # Test / programmatic toggles. Production enablement is the
62
+ # `RIGOR_BUDGET_TRACE` env var read once at load time.
63
+ def enable!
64
+ @enabled = true
65
+ end
66
+
67
+ def disable!
68
+ @enabled = false
69
+ end
70
+
71
+ # Records one firing of `category`. No-op (one boolean check)
72
+ # when tracing is disabled.
73
+ def hit(category)
74
+ return unless @enabled
75
+
76
+ @mutex.synchronize { @counts[category] += 1 }
77
+ end
78
+
79
+ # Frozen snapshot of the current counts, every known category
80
+ # present (zero-filled) so consumers can render a stable table.
81
+ def snapshot
82
+ @mutex.synchronize do
83
+ CATEGORIES.to_h { |category| [category, @counts[category]] }.freeze
84
+ end
85
+ end
86
+
87
+ # Records one observation of `value` (an Integer size) into
88
+ # `category`'s histogram. No-op (one boolean check) when disabled.
89
+ def observe(category, value)
90
+ return unless @enabled
91
+
92
+ @mutex.synchronize { @distributions[category][value] += 1 }
93
+ end
94
+
95
+ # Frozen `{value => count}` histogram for a distribution category.
96
+ def distribution(category)
97
+ @mutex.synchronize { @distributions[category].dup.freeze }
98
+ end
99
+
100
+ # Summary of a distribution category: total observation count, max
101
+ # observed value, selected percentiles, and how many observations
102
+ # met or exceeded each threshold in `over`. Percentiles use the
103
+ # nearest-rank method over the expanded sample.
104
+ def summarize(category, over: [])
105
+ hist = distribution(category)
106
+ total = hist.values.sum
107
+ return { count: 0, max: 0, percentiles: {}, over: over.to_h { |t| [t, 0] } } if total.zero?
108
+
109
+ sorted = hist.keys.sort
110
+ { count: total,
111
+ max: sorted.last,
112
+ percentiles: { p50: percentile(hist, total, 0.50), p90: percentile(hist, total, 0.90),
113
+ p99: percentile(hist, total, 0.99) },
114
+ over: over.to_h { |t| [t, hist.sum { |value, n| value >= t ? n : 0 }] } }
115
+ end
116
+
117
+ # Nearest-rank percentile over a `{value => count}` histogram
118
+ # without materialising the full sample.
119
+ def percentile(hist, total, fraction)
120
+ rank = (fraction * total).ceil
121
+ cumulative = 0
122
+ hist.keys.sort.each do |value|
123
+ cumulative += hist[value]
124
+ return value if cumulative >= rank
125
+ end
126
+ hist.keys.max
127
+ end
128
+
129
+ def reset
130
+ @mutex.synchronize do
131
+ @counts.clear
132
+ @distributions.clear
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -13,11 +13,8 @@ module Rigor
13
13
  # `ary_push_internal`, …) that the classifier does not yet
14
14
  # recognise. The blocklist captures the methods we have
15
15
  # specifically observed flowing as `:leaf` despite mutating.
16
- ARRAY_CATALOG = MethodCatalog.new(
17
- path: File.expand_path(
18
- "../../../../data/builtins/ruby_core/array.yml",
19
- __dir__
20
- ),
16
+ ARRAY_CATALOG = MethodCatalog.for_topic(
17
+ "array",
21
18
  mutating_selectors: {
22
19
  "Array" => Set[
23
20
  # Mutators classified `:leaf` by the C-body heuristic
@@ -13,11 +13,8 @@ module Rigor
13
13
  # (which dispatches on the receiver's concrete class).
14
14
  # The data is consumed by future include-aware lookup —
15
15
  # see `docs/CURRENT_WORK.md` for the planned slice.
16
- COMPARABLE_CATALOG = MethodCatalog.new(
17
- path: File.expand_path(
18
- "../../../../data/builtins/ruby_core/comparable.yml",
19
- __dir__
20
- ),
16
+ COMPARABLE_CATALOG = MethodCatalog.for_topic(
17
+ "comparable",
21
18
  mutating_selectors: {
22
19
  "Comparable" => Set[]
23
20
  }
@@ -21,11 +21,8 @@ module Rigor
21
21
  # so a hypothetical future `Constant<Complex>` carrier cannot
22
22
  # fold an aliasing copy through the catalog (mirrors
23
23
  # `range_catalog.rb`, `time_catalog.rb`, `date_catalog.rb`).
24
- COMPLEX_CATALOG = MethodCatalog.new(
25
- path: File.expand_path(
26
- "../../../../data/builtins/ruby_core/complex.yml",
27
- __dir__
28
- ),
24
+ COMPLEX_CATALOG = MethodCatalog.for_topic(
25
+ "complex",
29
26
  mutating_selectors: {
30
27
  "Complex" => Set[
31
28
  # Defence in depth: `Complex` does not currently expose
@@ -54,11 +54,8 @@ module Rigor
54
54
  # blocklist entry; the entries below are defense-in-depth
55
55
  # against indirect mutators the regex might miss in a future
56
56
  # CRuby bump.
57
- DATE_CATALOG = MethodCatalog.new(
58
- path: File.expand_path(
59
- "../../../../data/builtins/ruby_core/date.yml",
60
- __dir__
61
- ),
57
+ DATE_CATALOG = MethodCatalog.for_topic(
58
+ "date",
62
59
  mutating_selectors: {
63
60
  "Date" => Set[
64
61
  # `d_lite_initialize_copy` is already classed
@@ -22,11 +22,8 @@ module Rigor
22
22
  # against the analyzer process's registry — what UTF-8's
23
23
  # alias list is in the analyzer is not necessarily what it
24
24
  # is in the analysed program.
25
- ENCODING_CATALOG = MethodCatalog.new(
26
- path: File.expand_path(
27
- "../../../../data/builtins/ruby_core/encoding.yml",
28
- __dir__
29
- ),
25
+ ENCODING_CATALOG = MethodCatalog.for_topic(
26
+ "encoding",
30
27
  mutating_selectors: {
31
28
  "Encoding" => Set[
32
29
  # Defence-in-depth: mirrors range_catalog.rb /
@@ -13,11 +13,8 @@ module Rigor
13
13
  # (which dispatches on the receiver's concrete class).
14
14
  # The data is consumed by future include-aware lookup —
15
15
  # see `docs/CURRENT_WORK.md` for the planned slice.
16
- ENUMERABLE_CATALOG = MethodCatalog.new(
17
- path: File.expand_path(
18
- "../../../../data/builtins/ruby_core/enumerable.yml",
19
- __dir__
20
- ),
16
+ ENUMERABLE_CATALOG = MethodCatalog.for_topic(
17
+ "enumerable",
21
18
  mutating_selectors: {
22
19
  "Enumerable" => Set[]
23
20
  }
@@ -26,11 +26,8 @@ module Rigor
26
26
  # already declines) or blocklisted because the static classifier
27
27
  # missed an indirect side effect. The remaining `:leaf` method
28
28
  # that DOES fold is `#cause`, a pure accessor.
29
- EXCEPTION_CATALOG = MethodCatalog.new(
30
- path: File.expand_path(
31
- "../../../../data/builtins/ruby_core/exception.yml",
32
- __dir__
33
- ),
29
+ EXCEPTION_CATALOG = MethodCatalog.for_topic(
30
+ "exception",
34
31
  mutating_selectors: {
35
32
  "Exception" => Set[
36
33
  # `exc_initialize` writes `mesg` / `backtrace` ivars on
@@ -15,11 +15,8 @@ module Rigor
15
15
  # captures every false-positive `:leaf` we have spotted in the
16
16
  # generated YAML — bias toward conservatism so a missed fold is
17
17
  # acceptable but a folded mutator/yielder is not.
18
- HASH_CATALOG = MethodCatalog.new(
19
- path: File.expand_path(
20
- "../../../../data/builtins/ruby_core/hash.yml",
21
- __dir__
22
- ),
18
+ HASH_CATALOG = MethodCatalog.for_topic(
19
+ "hash",
23
20
  mutating_selectors: {
24
21
  "Hash" => Set[
25
22
  # Block-dependent iteration — yields via `rb_hash_foreach`