rigortype 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/date.yml +6 -3
  4. data/data/builtins/ruby_core/enumerable.yml +1 -0
  5. data/data/builtins/ruby_core/exception.yml +2 -1
  6. data/data/builtins/ruby_core/file.yml +28 -14
  7. data/data/builtins/ruby_core/hash.yml +5 -2
  8. data/data/builtins/ruby_core/io.yml +33 -16
  9. data/data/builtins/ruby_core/random.yml +2 -1
  10. data/data/builtins/ruby_core/re.yml +6 -3
  11. data/data/builtins/ruby_core/struct.yml +2 -0
  12. data/data/builtins/ruby_core/time.yml +2 -1
  13. data/data/gem_overlay/activesupport/core_ext.rbs +409 -11
  14. data/docs/handbook/11-sig-gen.md +24 -14
  15. data/docs/manual/02-cli-reference.md +23 -7
  16. data/docs/manual/04-diagnostics.md +1 -1
  17. data/docs/manual/05-inspecting-types.md +20 -8
  18. data/docs/manual/07-plugins.md +4 -0
  19. data/docs/manual/plugins/rigor-actionpack.md +61 -0
  20. data/docs/manual/plugins/rigor-activerecord.md +74 -1
  21. data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
  22. data/docs/manual/plugins/rigor-sidekiq.md +22 -0
  23. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
  24. data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
  25. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
  26. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
  27. data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
  28. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
  29. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
  30. data/lib/rigor/analysis/check_rules.rb +383 -48
  31. data/lib/rigor/analysis/crash_signature.rb +188 -0
  32. data/lib/rigor/analysis/dependency_recorder.rb +18 -3
  33. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  34. data/lib/rigor/analysis/incremental.rb +28 -0
  35. data/lib/rigor/analysis/incremental_session.rb +63 -16
  36. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
  37. data/lib/rigor/analysis/reachability/graph.rb +14 -5
  38. data/lib/rigor/analysis/reachability/scan.rb +7 -3
  39. data/lib/rigor/analysis/result.rb +32 -0
  40. data/lib/rigor/analysis/run_cache_key.rb +41 -1
  41. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +233 -16
  42. data/lib/rigor/analysis/runner/pool_coordinator.rb +266 -47
  43. data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
  44. data/lib/rigor/analysis/runner/run_snapshots.rb +12 -2
  45. data/lib/rigor/analysis/runner.rb +212 -16
  46. data/lib/rigor/analysis/worker_session.rb +37 -3
  47. data/lib/rigor/bleeding_edge.rb +7 -6
  48. data/lib/rigor/builtins/hkt_builtins.rb +45 -6
  49. data/lib/rigor/builtins/imported_refinements.rb +11 -9
  50. data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
  51. data/lib/rigor/builtins/static_return_refinements.rb +20 -1
  52. data/lib/rigor/cache/annotation_location.rb +72 -0
  53. data/lib/rigor/cache/descriptor.rb +42 -2
  54. data/lib/rigor/cache/incremental_snapshot.rb +40 -3
  55. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +70 -8
  56. data/lib/rigor/cache/store.rb +17 -1
  57. data/lib/rigor/cli/coverage_command.rb +23 -18
  58. data/lib/rigor/cli/coverage_mutation.rb +13 -17
  59. data/lib/rigor/cli/coverage_scan.rb +47 -6
  60. data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
  61. data/lib/rigor/cli/fused_protection_report.rb +23 -2
  62. data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
  63. data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
  64. data/lib/rigor/cli/mutation_protection_report.rb +31 -2
  65. data/lib/rigor/cli/sig_gen_command.rb +47 -0
  66. data/lib/rigor/cli/type_of_command.rb +188 -41
  67. data/lib/rigor/cli/type_of_renderer.rb +72 -3
  68. data/lib/rigor/cli/type_scan_command.rb +10 -1
  69. data/lib/rigor/cli.rb +1 -1
  70. data/lib/rigor/configuration.rb +7 -4
  71. data/lib/rigor/effects/plugin_facts.rb +1 -1
  72. data/lib/rigor/effects/scanner.rb +2 -3
  73. data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
  74. data/lib/rigor/environment/failure_slot.rb +28 -0
  75. data/lib/rigor/environment/lockfile_resolver.rb +15 -5
  76. data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
  77. data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
  78. data/lib/rigor/environment/rbs_loader.rb +743 -93
  79. data/lib/rigor/environment.rb +125 -25
  80. data/lib/rigor/inference/acceptance.rb +172 -20
  81. data/lib/rigor/inference/captured_locals.rb +62 -0
  82. data/lib/rigor/inference/content_join.rb +347 -0
  83. data/lib/rigor/inference/def_handle.rb +15 -3
  84. data/lib/rigor/inference/def_node_resolver.rb +62 -3
  85. data/lib/rigor/inference/expression_typer.rb +1020 -141
  86. data/lib/rigor/inference/fork_map.rb +6 -1
  87. data/lib/rigor/inference/hkt_reducer.rb +12 -1
  88. data/lib/rigor/inference/hkt_registry.rb +61 -3
  89. data/lib/rigor/inference/hkt_sugar_translator.rb +87 -0
  90. data/lib/rigor/inference/index_write_widening.rb +48 -0
  91. data/lib/rigor/inference/indexed_narrowing.rb +14 -0
  92. data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
  93. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
  94. data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
  95. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
  96. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
  97. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
  98. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
  99. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
  100. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
  101. data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
  102. data/lib/rigor/inference/method_dispatcher.rb +155 -14
  103. data/lib/rigor/inference/method_parameter_binder.rb +3 -3
  104. data/lib/rigor/inference/mutation_widening.rb +244 -174
  105. data/lib/rigor/inference/narrowing.rb +331 -27
  106. data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
  107. data/lib/rigor/inference/pre_eval_constants.rb +15 -0
  108. data/lib/rigor/inference/precision_scanner.rb +5 -2
  109. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  110. data/lib/rigor/inference/rbs_type_translator.rb +89 -39
  111. data/lib/rigor/inference/scope_indexer.rb +1493 -140
  112. data/lib/rigor/inference/statement_evaluator.rb +276 -91
  113. data/lib/rigor/inference/struct_fold_safety.rb +186 -27
  114. data/lib/rigor/inference/version_guard.rb +229 -0
  115. data/lib/rigor/language_server/selection_range_provider.rb +1 -1
  116. data/lib/rigor/plugin/base.rb +3 -2
  117. data/lib/rigor/plugin/inflector.rb +14 -5
  118. data/lib/rigor/plugin/io_boundary.rb +107 -4
  119. data/lib/rigor/plugin/loader.rb +14 -0
  120. data/lib/rigor/plugin/registry.rb +25 -2
  121. data/lib/rigor/protection/analysis_guard.rb +60 -0
  122. data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
  123. data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
  124. data/lib/rigor/protection/discovery_seed.rb +1 -1
  125. data/lib/rigor/protection/measurement_integrity.rb +36 -0
  126. data/lib/rigor/protection/mutation_scanner.rb +61 -6
  127. data/lib/rigor/protection/mutator.rb +121 -18
  128. data/lib/rigor/rbs_extended/envelope_scanner.rb +8 -7
  129. data/lib/rigor/rbs_extended/hkt_directives.rb +16 -1
  130. data/lib/rigor/rbs_extended/reporter.rb +93 -13
  131. data/lib/rigor/rbs_extended.rb +6 -1
  132. data/lib/rigor/reflection.rb +182 -30
  133. data/lib/rigor/scope/discovery_index.rb +39 -1
  134. data/lib/rigor/scope.rb +385 -4
  135. data/lib/rigor/sig_gen/classification.rb +12 -1
  136. data/lib/rigor/sig_gen/generator.rb +320 -85
  137. data/lib/rigor/sig_gen/renderer.rb +14 -12
  138. data/lib/rigor/sig_gen/writer.rb +3 -0
  139. data/lib/rigor/source/constant_path.rb +79 -2
  140. data/lib/rigor/triage/catalogue.rb +1 -1
  141. data/lib/rigor/type/combinator.rb +10 -0
  142. data/lib/rigor/type/maybe.rb +47 -0
  143. data/lib/rigor/type/refined.rb +1 -2
  144. data/lib/rigor/type/result.rb +53 -0
  145. data/lib/rigor/type.rb +2 -0
  146. data/lib/rigor/version.rb +1 -1
  147. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
  148. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
  149. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
  150. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
  151. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
  152. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
  154. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
  157. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
  158. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
  159. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
  160. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
  161. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
  162. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
  163. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
  164. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
  165. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
  166. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
  167. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
  168. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
  169. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
  170. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
  171. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
  172. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
  173. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
  174. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
  175. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
  176. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
  177. data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
  178. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
  179. data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
  180. data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
  181. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
  182. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
  183. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
  184. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
  185. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
  186. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
  187. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
  188. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
  189. data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
  190. data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
  191. data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
  192. data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
  193. data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
  194. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
  195. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
  196. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  197. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
  198. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
  199. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
  200. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
  201. data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
  202. data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
  203. data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
  204. data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
  205. data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
  206. data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
  207. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
  208. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
  209. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
  210. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
  211. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
  212. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
  213. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
  214. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
  215. data/sig/rigor/analysis/baseline.rbs +1 -1
  216. data/sig/rigor/environment.rbs +9 -0
  217. data/sig/rigor/inference.rbs +4 -1
  218. data/sig/rigor/plugin/io_boundary.rbs +2 -0
  219. data/sig/rigor/reflection.rbs +3 -1
  220. data/sig/rigor/scope.rbs +25 -1
  221. data/sig/rigor/type.rbs +95 -1
  222. data/sig/rigor.rbs +16 -0
  223. metadata +71 -3
@@ -23,7 +23,7 @@ module Rigor
23
23
  # API stays purely functional.
24
24
  #
25
25
  # See docs/internal-spec/inference-engine.md for the binding contract.
26
- # rubocop:disable Metrics/ClassLength
26
+ # rubocop:disable-next Metrics/ClassLength
27
27
  class RbsLoader
28
28
  # Buffer name stamped on the `module` declarations synthesized by {.synthesize_missing_namespaces}.
29
29
  # Re-read off the built env by {#synthesized_namespaces} so the analysis layer can surface an `:info`
@@ -38,6 +38,10 @@ module Rigor
38
38
  # into the set {MethodDispatcher} resolves to `Dynamic[Top]` (no false `call.undefined-method`).
39
39
  SYNTHETIC_STUB_BUFFER = "(rigor: synthesized stub types)"
40
40
 
41
+ # The buffer name `Cache::RbsEnvironmentMarshalPatch` reconstructs every `RBS::Location` behind on a
42
+ # cache HIT. Never a real path, so it must never be reported as one (issue #696).
43
+ CACHED_LOCATION_BUFFER_NAME = "<cached>"
44
+
41
45
  # Cap on how many quarantined `signature_paths:` files {#warn_about_quarantined_signatures} lists by name
42
46
  # before collapsing the tail to "… and N more" — a broken generator can emit many, and a wall of parse
43
47
  # errors buries the signal.
@@ -63,7 +67,7 @@ module Rigor
63
67
  # Vendored gem stubs (`data/vendored_gem_sigs/<gem>/`) are loaded on top of `signature_paths` so the
64
68
  # per-gem RBS bundled with Rigor itself is in scope for every analysis run. The gem stubs are
65
69
  # intentionally read-only and appended LAST so user-supplied `signature_paths` win on name conflicts.
66
- def build_env_for(libraries:, signature_paths:, virtual_rbs: [])
70
+ def build_env_for(libraries:, signature_paths:, virtual_rbs: [], deferred_signature_paths: [])
67
71
  rbs_loader = RBS::EnvironmentLoader.new
68
72
  libraries = libraries_without_shadowed_bigdecimal_math(libraries)
69
73
  loaded_libraries = libraries.select do |library|
@@ -80,11 +84,14 @@ module Rigor
80
84
  # sigs are Rigor-shipped and trusted, so they stay on the loader's fast batch path.
81
85
  add_bundled_signatures(rbs_loader, loaded_libraries.to_set(&:to_s))
82
86
  env = RBS::Environment.from_loader(rbs_loader)
83
- add_project_signatures(env, signature_paths)
87
+ project_files = project_sig_files(signature_paths)
88
+ add_project_signatures(env, signature_paths, deferred_signature_paths)
84
89
  add_virtual_rbs(env, virtual_rbs)
85
90
  synthesize_missing_namespaces(env)
86
- env, resolved = resolve_quarantining_virtual_collisions(env, virtual_rbs)
87
- stub_missing_referenced_types(env, resolved, project_sig_files(signature_paths))
91
+ # Issue #777 — resolve-time backstop: also unload colliding PROJECT buffers (class-vs-module /
92
+ # constant redeclarations against bundled RBS). Virtual culprits are preferred when both appear.
93
+ env, resolved = resolve_quarantining_virtual_collisions(env, virtual_rbs, project_files: project_files)
94
+ stub_missing_referenced_types(env, resolved, project_files)
88
95
  end
89
96
 
90
97
  # rbs ships `stdlib/bigdecimal/` and `stdlib/bigdecimal-math/` as two libraries, so
@@ -153,32 +160,61 @@ module Rigor
153
160
  false
154
161
  end
155
162
 
156
- # Backstop for a virtual-vs-anything `RBS::DuplicatedDeclarationError` that only materialises at
157
- # `resolve_type_names` (which rebuilds the env from `sources`). {.add_virtual_rbs}'s transactional
158
- # rescue already handles the add-time case — empirically everything on rbs 4.x — but the rbs gemspec
159
- # range spans `>= 3.0, < 5.0` (ADR-79) and WHERE duplicate detection fires is an rbs-internal choice
160
- # this code must not depend on. Resolution rule is the same as the add-time path: the explicit
161
- # signature wins, the colliding VIRTUAL buffer is dropped whole (`RBS::Environment#unload`) and
162
- # resolution retries; every pass removes at least one virtual buffer, so the loop is bounded by the
163
- # virtual-entry count. A duplicate involving no virtual buffer (sig-vs-sig), or an env without
164
- # `#unload` (rbs 3.x), re-raises into the existing one-warning degrade path.
163
+ # Backstop for a `RBS::DuplicatedDeclarationError` that only materialises at `resolve_type_names`
164
+ # (which rebuilds the env from `sources`). {.add_virtual_rbs} / {.add_project_parsed_decls}
165
+ # transactionally handle the add-time case — empirically everything on rbs 4.x — but the rbs
166
+ # gemspec range spans `>= 3.0, < 5.0` (ADR-79) and WHERE duplicate detection fires is an
167
+ # rbs-internal choice this code must not depend on. Resolution preference: drop colliding VIRTUAL
168
+ # buffers first (explicit `.rbs` wins), else drop colliding PROJECT `signature_paths:` buffers
169
+ # (issue #777 bundled RBS wins over a kind-colliding project file), then retry. Every pass
170
+ # removes at least one buffer, so the loop is bounded. A duplicate with neither virtual nor
171
+ # project culprits (bundled-vs-bundled), or an env without `#unload` (rbs 3.x), re-raises into
172
+ # the existing one-warning total-failure path.
165
173
  #
166
174
  # The dropped set is not returned: consumers recover it from the built env via
167
175
  # {#virtual_rbs_collision_quarantined}, which also works on a cache HIT where this build never ran.
168
- def resolve_quarantining_virtual_collisions(env, virtual_rbs)
176
+ def resolve_quarantining_virtual_collisions(env, virtual_rbs, project_files: [])
169
177
  virtual_names = virtual_rbs.to_set { |name, _content| name.to_s }
170
- (virtual_names.size + 1).times do
178
+ project_names = Array(project_files).to_set(&:to_s)
179
+ # Bound by virtual + project culprit candidates: each pass removes at least one buffer.
180
+ (virtual_names.size + project_names.size + 1).times do
171
181
  return [env, env.resolve_type_names]
172
182
  rescue ::RBS::DuplicatedDeclarationError => e
173
- raise unless env.respond_to?(:unload)
183
+ env = unload_duplicated_declaration_culprits(
184
+ env, e, virtual_names: virtual_names, project_names: project_names
185
+ )
186
+ end
187
+ [env, env.resolve_type_names]
188
+ end
189
+
190
+ # Prefer dropping colliding VIRTUAL buffers (explicit `.rbs` wins), else PROJECT `signature_paths:`
191
+ # buffers (issue #777 — bundled RBS wins over a kind-colliding project file). A duplicate with
192
+ # neither virtual nor project culprits (bundled-vs-bundled), or an env without `#unload`, re-raises.
193
+ def unload_duplicated_declaration_culprits(env, error, virtual_names:, project_names:)
194
+ raise unless env.respond_to?(:unload)
195
+
196
+ culprits = duplicated_declaration_buffer_names(error)
197
+ virtual_culprits = culprits.select { |name| virtual_names.include?(name.to_s) }
198
+ return env.unload(virtual_culprits) unless virtual_culprits.empty?
174
199
 
175
- culprits = e.decls.filter_map { |decl| decl.location&.buffer&.name }
176
- .uniq.select { |name| virtual_names.include?(name) }
177
- raise if culprits.empty?
200
+ # Prefer dropping the project buffer(s): the bundled declaration stays, Greeter/core stay
201
+ # usable, and only the conflicting file is absent.
202
+ project_culprits = select_project_collision_culprits(culprits, project_names)
203
+ raise if project_culprits.empty?
178
204
 
179
- env = env.unload(culprits)
205
+ env.unload(project_culprits)
206
+ end
207
+
208
+ def duplicated_declaration_buffer_names(error)
209
+ error.decls.filter_map { |decl| decl.location&.buffer&.name }.uniq
210
+ end
211
+
212
+ def select_project_collision_culprits(culprits, project_names)
213
+ culprits.select do |name|
214
+ project_names.include?(File.expand_path(name.to_s))
215
+ rescue ArgumentError, TypeError
216
+ project_names.include?(name.to_s)
180
217
  end
181
- [env, env.resolve_type_names]
182
218
  end
183
219
 
184
220
  # ADR-5 robustness, second tier. A project `signature_paths:` RBS that *references* a type no loaded
@@ -287,13 +323,112 @@ module Rigor
287
323
  # Buffer names are the file's absolute path (matching {.project_sig_files}) so {.project_entry?} — which
288
324
  # attributes a `class_decls` entry to the project by buffer name — still recognises these declarations.
289
325
  # Sorted for a deterministic add order (the env feeds the cache, ADR-54).
290
- def add_project_signatures(env, signature_paths)
291
- project_sig_files(signature_paths).sort.each do |file|
326
+ def add_project_signatures(env, signature_paths, deferred_paths = [])
327
+ deferred = project_sig_files(deferred_paths)
328
+ (project_sig_files(signature_paths) - deferred).sort.each do |file|
292
329
  parsed = parse_signature_file(file)
293
330
  next if parsed.nil? # quarantined (unparseable) or unreadable — skip so the env survives
294
331
 
295
332
  buffer, directives, decls = parsed
296
- add_parsed_decls(env, buffer, directives, decls)
333
+ add_project_parsed_decls(env, buffer, directives, decls)
334
+ end
335
+ add_deferred_signatures(env, deferred)
336
+ end
337
+
338
+ # Issue #777 — add one project signature transactionally. `RBS::Environment#add_source` appends to
339
+ # `sources` BEFORE inserting decls, so a mid-insert `RBS::DuplicatedDeclarationError` (class-vs-module
340
+ # kind collision against bundled RBS, or a constant redeclared from core) would otherwise leave a
341
+ # poisoned source that `resolve_type_names` re-raises outside every per-file rescue, collapsing the
342
+ # WHOLE env to nil. Mirror {.add_virtual_rbs}: drop the poisoned source and continue so Greeter /
343
+ # core / every non-conflicting project file still load. Without a `sources` table (rbs 3.x) re-raise
344
+ # into the existing total-failure path — we cannot make the skip transactional there.
345
+ def add_project_parsed_decls(env, buffer, directives, decls)
346
+ add_parsed_decls(env, buffer, directives, decls)
347
+ rescue ::RBS::DuplicatedDeclarationError
348
+ raise unless env.respond_to?(:sources)
349
+
350
+ env.sources.reject! { |source| source.buffer.name == buffer.name }
351
+ end
352
+
353
+ # Issue #610 — the DEFERRED half: signature files a bundled plugin contributes through its manifest,
354
+ # added after every other source so each can be asked a question the eager half cannot be asked —
355
+ # "does something already declare this class, at a different generic arity?".
356
+ #
357
+ # `plugins/rigor-activerecord/sig` declares `class Relation[Elem]`; `rbs collection install` ships a
358
+ # NON-generic `ActiveRecord::Relation`. Two declarations of one class with different arity make
359
+ # `RBS::DefinitionBuilder` raise `GenericParameterMismatchError`, and Rigor keeps the class KNOWN
360
+ # after a failed build, so every call INTO a relation reads `Dynamic[top]` — the plugin and
361
+ # `rigor-project-init`'s own advice to install the collection cancel each other out.
362
+ #
363
+ # The plugin stands down, not the user's source, for the reason `Reflection.constant_type_at`
364
+ # already encodes: the project's own sources are authoritative for the project. Standing down is
365
+ # strictly better than the collision it replaces — the collection's own declaration builds, where
366
+ # today NEITHER does — and it cannot regress a project without a colliding declaration, because
367
+ # nothing triggers.
368
+ #
369
+ # Granularity is the FILE, not the declaration: a bundled plugin's `sig/` file is single-purpose
370
+ # (rigor-activerecord's is `relation.rbs`, declaring exactly `Relation`), so dropping the file drops
371
+ # exactly the conflicting declaration. Splitting a plugin file that mixes a colliding class with
372
+ # innocent ones is the change to make if that ever stops being true.
373
+ def add_deferred_signatures(env, deferred_files)
374
+ deferred_files.sort.each do |file|
375
+ parsed = parse_signature_file(file)
376
+ next if parsed.nil?
377
+
378
+ buffer, directives, decls = parsed
379
+ next if generic_arity_conflict(env, decls)
380
+
381
+ add_project_parsed_decls(env, buffer, directives, decls)
382
+ end
383
+ end
384
+
385
+ # Issue #610 — which deferred (plugin-contributed) files stood down, as
386
+ # `[absolute_path, class_name, existing_arity, incoming_arity]`, sorted. DERIVED from a built env
387
+ # rather than recorded during the build, for {RbsLoader#quarantined_signatures}' reason: a cache HIT
388
+ # never runs the build, and a condition only the build knew would silently disappear on the second
389
+ # run. Re-asking the same question of the FINAL env gives the same answer — the file's declaration is
390
+ # absent from it, and the source that displaced it is still there with its own arity.
391
+ def deferred_standdowns(env, deferred_paths)
392
+ return [] if env.nil? || deferred_paths.nil? || deferred_paths.empty?
393
+
394
+ project_sig_files(deferred_paths).sort.filter_map do |file|
395
+ parsed = parse_signature_file(file)
396
+ next if parsed.nil?
397
+
398
+ conflict = generic_arity_conflict(env, parsed[2])
399
+ conflict && [file, *conflict]
400
+ end
401
+ end
402
+
403
+ # The first `[class_name, existing_arity, incoming_arity]` where `decls` would re-declare a class the
404
+ # env already has with a DIFFERENT number of type parameters, or nil when they agree everywhere.
405
+ # Equal arity is not a conflict: RBS reopens the class, which is the ordinary and supported case, so
406
+ # the stand-down must not fire on it.
407
+ def generic_arity_conflict(env, decls)
408
+ each_declared_class(decls) do |name, arity|
409
+ entry = env.class_decls[::RBS::TypeName.parse(name)]
410
+ next if entry.nil? || !entry.respond_to?(:type_params)
411
+
412
+ existing = entry.type_params&.size
413
+ return [name, existing, arity] if existing && existing != arity
414
+ end
415
+ nil
416
+ end
417
+
418
+ # Yields `[absolute_class_name, type_param_count]` for every class declared anywhere in `decls`,
419
+ # descending through modules and nested classes. Names inside a `module`/`class` body are parsed
420
+ # RELATIVE, so the enclosing path is accumulated here — the env is keyed by absolute name, and
421
+ # comparing a relative `Relation` against it would silently never match.
422
+ def each_declared_class(decls, prefix = [], &block)
423
+ decls.each do |decl|
424
+ case decl
425
+ when ::RBS::AST::Declarations::Module
426
+ each_declared_class(decl.members, prefix + [decl.name.to_s], &block)
427
+ when ::RBS::AST::Declarations::Class
428
+ inner = prefix + [decl.name.to_s]
429
+ block.call("::#{inner.join('::')}", decl.type_params.size)
430
+ each_declared_class(decl.members, inner, &block)
431
+ end
297
432
  end
298
433
  end
299
434
 
@@ -348,6 +483,55 @@ module Rigor
348
483
  end
349
484
  end
350
485
 
486
+ # Issue #777 — project `signature_paths:` files that PARSE but are absent from the built env because
487
+ # {.add_project_parsed_decls} / {.resolve_quarantining_virtual_collisions} quarantined a duplicated
488
+ # declaration (typically class-vs-module or a constant already shipped by bundled RBS). Derived from
489
+ # the env like {#virtual_rbs_collision_quarantined}, so a cache HIT reports the same condition.
490
+ #
491
+ # Path membership alone is not enough: the suite's {RbsEnvMemo} reuses a byte-identical env across
492
+ # `Dir.mktmpdir` roots when `signature_paths:` are relative (`sig/sink.rbs`), leaving buffers named
493
+ # after the FIRST path. A later example's absolute path is then absent from `present` even though
494
+ # the declarations loaded — that used to spam `duplicated declaration against bundled RBS` banners
495
+ # (~60 per CI shard) for fixtures that never collided. Basename + content equality catches that
496
+ # reuse without hiding a real quarantine (e.g. project `class Base64` vs bundled `module Base64`,
497
+ # same basename, different bytes).
498
+ COLLISION_QUARANTINE_NOTE =
499
+ "duplicated declaration against bundled RBS — quarantined so the rest of the RBS env still loads"
500
+
501
+ def collision_quarantined_project_signatures(env, signature_paths)
502
+ return [] if env.nil?
503
+
504
+ present = env.buffers.to_set do |buffer|
505
+ name = buffer.name
506
+ next name.to_s unless name
507
+
508
+ File.expand_path(name.to_s)
509
+ rescue ArgumentError, TypeError
510
+ name.to_s
511
+ end
512
+ buffers_by_basename = env.buffers.group_by { |buffer| File.basename(buffer.name.to_s) }
513
+ project_sig_files(signature_paths).sort.filter_map do |file|
514
+ next if present.include?(file)
515
+ next if parse_signature_file(file).nil? # parse / encoding quarantine owns these
516
+ next if project_signature_loaded_under_other_path?(file, buffers_by_basename)
517
+
518
+ [file, "#{file}: #{COLLISION_QUARANTINE_NOTE}"]
519
+ end
520
+ end
521
+
522
+ # True when `file`'s bytes are already in `env` under a different absolute path (same basename).
523
+ # See {#collision_quarantined_project_signatures} — keeps memoised env reuse from looking like a
524
+ # collision quarantine.
525
+ def project_signature_loaded_under_other_path?(file, buffers_by_basename)
526
+ peers = buffers_by_basename[File.basename(file)]
527
+ return false if peers.nil? || peers.empty?
528
+
529
+ content = File.read(file, encoding: "UTF-8")
530
+ peers.any? { |buffer| buffer.content == content }
531
+ rescue Errno::ENOENT, Errno::EISDIR, Errno::EACCES
532
+ false
533
+ end
534
+
351
535
  # The `::`-stripped names of every type a PROJECT signature references that no loaded declaration
352
536
  # provides — the input to {.append_stub_declarations}.
353
537
  #
@@ -758,6 +942,79 @@ module Rigor
758
942
  end
759
943
  end
760
944
 
945
+ # @param path [String, Pathname] typically an entry from an {RbsLoader} instance's
946
+ # {#signature_paths}.
947
+ # @return [Boolean] whether `path` sits under the bundled gem-overlay root — what
948
+ # `CheckRules::GEM_OVERLAY_OPEN_RECEIVERS`'s gate consults so that a class name's membership in
949
+ # that list alone never grants the open-receiver exemption; the overlay directory that made the
950
+ # entry true must have actually loaded THIS run too (issue #632, tracked further by #660).
951
+ # `GEM_OVERLAY_SIGS_ROOT` is defined inside this `class << self` block, so it is reachable from
952
+ # here directly but NOT as `RbsLoader::GEM_OVERLAY_SIGS_ROOT` from outside — this method is the
953
+ # public seam callers outside this class use instead of reaching for the constant themselves.
954
+ def under_gem_overlay_root?(path)
955
+ path.to_s.start_with?("#{GEM_OVERLAY_SIGS_ROOT}/")
956
+ end
957
+
958
+ # The twin of {.under_gem_overlay_root?}, for the OTHER copy of the same declarations. ADR-72 ships
959
+ # each overlay's surface twice — as `data/gem_overlay/<gem>/` and as the opt-in plugin's own `sig/` —
960
+ # and a project can reach the second WITHOUT the plugin registry, by naming it in `signature_paths:`
961
+ # (issue #672). Two callers ask, for different reasons:
962
+ #
963
+ # - `Environment.gem_overlay_paths` asks per gem, so the overlay stands down instead of loading
964
+ # alongside the twin and collapsing every class they share;
965
+ # - `CheckRules#gem_overlay_loaded?` asks across all of them, because the twin's `sig/` carries no
966
+ # plugin manifest, so `open_receivers:` cannot protect the partial `ActiveSupport::Duration`
967
+ # declaration it nonetheless loads.
968
+ #
969
+ # **Both ask what an entry LOADS, not what it looks like.** Comparing path strings is wrong in both
970
+ # directions, and the false-positive direction is the expensive one: an entry naming the twin's
971
+ # `.rbs` FILE, or a subdirectory of the twin that does not exist, matches as a string while the
972
+ # loader reads nothing from it ({.project_sig_files} takes directories only — the same fact
973
+ # {Rigor::SignaturePathAudit} reports as `:not_directory` / `:missing`). Standing an overlay down for
974
+ # those leaves the project with NEITHER copy, so ordinary `3.minutes` draws `call.undefined-method`
975
+ # on correct code. The quiet direction is the mirror: a symlink to the twin, or a case-variant
976
+ # spelling of it where the filesystem folds case, loads the twin while matching no prefix. Asking
977
+ # {.project_sig_files} for the twin's files and canonicalising both sides answers both.
978
+ #
979
+ # Lives here rather than on {Rigor::Environment} because it is an internal seam, not ADR-2 public
980
+ # API: `spec/rigor/public_api_drift_spec.rb` pins `Environment`'s singleton surface, and a predicate
981
+ # two call sites share is not a promise to plugin authors. Here it sits beside the overlay-side
982
+ # answer to the very same question, on the class that already owns the overlay's layout
983
+ # ({GEM_OVERLAY_SIGS_ROOT}, {.gem_overlay_sig_paths}).
984
+ #
985
+ # @param signature_paths [Array<String, Pathname>] typically an {RbsLoader} instance's
986
+ # {#signature_paths}. Takes the whole list rather than one entry so the twin's file set resolves
987
+ # once per question — `CheckRules` asks it per `ActiveSupport::Duration` receiver.
988
+ def gem_overlay_twin_signatures_loaded?(signature_paths)
989
+ # `GEM_OVERLAY_PLUGIN_IDS` is ADR-72 eligibility policy and stays owned by `Environment`; it is
990
+ # read here only to enumerate which bundled plugins HAVE an overlay twin.
991
+ GEM_OVERLAY_PLUGIN_IDS.each_value.any? do |plugin_id|
992
+ bundled_overlay_twin_signatures_loaded?(plugin_id, signature_paths)
993
+ end
994
+ end
995
+
996
+ # The per-plugin half of {.gem_overlay_twin_signatures_loaded?}. `Environment` owns the gem → plugin
997
+ # id mapping and passes the id; this resolves the id to the engine's own bundled `sig/` and answers
998
+ # the filesystem question.
999
+ #
1000
+ # @param plugin_id [String] a manifest id, e.g. `"activesupport-core-ext"`.
1001
+ def bundled_overlay_twin_signatures_loaded?(plugin_id, signature_paths)
1002
+ return false if signature_paths.nil? || signature_paths.empty?
1003
+
1004
+ # In-method for the reason `Plugin::FirstParty` documents at its own `require`: the plugin
1005
+ # subsystem's load graph reaches back into the environment, and only the overlay path calls this —
1006
+ # a run with no eligible overlay gem never pays them.
1007
+ require_relative "../plugin/first_party"
1008
+ require_relative "../plugin/loader"
1009
+ twin = Plugin::Loader.bundled_plugin_sig_path("#{Plugin::FirstParty::GEM_PREFIX}#{plugin_id}")
1010
+ return false unless twin
1011
+
1012
+ twin_files = project_sig_files([twin]).filter_map { |file| canonical_real_path(file) }
1013
+ return false if twin_files.empty?
1014
+
1015
+ signature_paths.any? { |entry| signature_entry_loads?(entry, twin_files) }
1016
+ end
1017
+
761
1018
  def vendored_gem_sig_paths
762
1019
  return [] unless File.directory?(VENDORED_GEM_SIGS_ROOT)
763
1020
 
@@ -778,6 +1035,34 @@ module Rigor
778
1035
  File.file?(File.join(VENDORED_GEM_SIGS_ROOT, child))
779
1036
  end
780
1037
  end
1038
+
1039
+ # Everything below is internal to {.bundled_overlay_twin_signatures_loaded?}. `private` (not
1040
+ # `private_class_method`) because inside `class << self` these are instance methods of the
1041
+ # singleton class; `private_class_method` looks for them one level further out and raises NameError
1042
+ # at load. Placed last so the modifier cannot capture a method that is meant to be reachable.
1043
+ private
1044
+
1045
+ # Whether one `signature_paths:` entry is a directory the loader would read one of `twin_files`
1046
+ # from. Both sides are canonicalised first, which is what makes a symlinked or case-variant
1047
+ # spelling of the twin answer the same as the twin itself.
1048
+ def signature_entry_loads?(entry, twin_files)
1049
+ dir = canonical_real_path(entry)
1050
+ return false unless dir && File.directory?(dir)
1051
+
1052
+ prefix = "#{dir}#{File::SEPARATOR}"
1053
+ twin_files.any? { |file| file.start_with?(prefix) }
1054
+ end
1055
+
1056
+ # `File.realpath` rather than `File.expand_path`: it resolves symlinks and, on a case-insensitive
1057
+ # filesystem, the on-disk spelling — the two ways a path can reach the twin's files without looking
1058
+ # like it. It raises for a path that does not exist, which is the answer we want (an entry that
1059
+ # resolves to nothing loads nothing), so the rescue returns nil rather than falling back to a
1060
+ # lexical expansion that would resurrect the string match.
1061
+ def canonical_real_path(path)
1062
+ File.realpath(path.to_s)
1063
+ rescue SystemCallError
1064
+ nil
1065
+ end
781
1066
  end
782
1067
 
783
1068
  attr_reader :libraries, :signature_paths, :cache_store, :virtual_rbs
@@ -797,15 +1082,24 @@ module Rigor
797
1082
  # synthesised from project source by a plugin's `Manifest#source_rbs_synthesizer`. Merged into the
798
1083
  # env after `signature_paths:` and the vendored stubs. Pass `[]` (the default) when no
799
1084
  # synthesizer-emitting plugin is loaded.
800
- def initialize(libraries: [], signature_paths: [], cache_store: nil, virtual_rbs: [])
1085
+ def initialize(libraries: [], signature_paths: [], cache_store: nil, virtual_rbs: [],
1086
+ deferred_signature_paths: [])
801
1087
  @libraries = libraries.map(&:to_s).freeze
802
1088
  @signature_paths = signature_paths.map { |p| Pathname(p) }.freeze
1089
+ # Issue #610 — the subset of `signature_paths:` a bundled PLUGIN contributed. Loaded last and
1090
+ # allowed to stand down against a colliding generic arity ({.add_deferred_signatures}). Every entry
1091
+ # is also in `@signature_paths`, so the env cache descriptor already digests these files.
1092
+ @deferred_signature_paths = deferred_signature_paths.map { |p| Pathname(p) }.freeze
803
1093
  @cache_store = cache_store
804
1094
  @virtual_rbs = virtual_rbs.map { |name, content| [name.to_s.dup.freeze, content.to_s.dup.freeze].freeze }.freeze
805
1095
  # Per-loader memoization bucket. Held as a single mutable Hash so the loader instance itself can be
806
1096
  # `.freeze`d (per ADR-15 reflection-facade contract) without losing the lazy-memo behaviour. Slot
807
1097
  # names currently consulted: `:env`, `:env_loaded`, `:env_build_warned`, `:definition_build_warned`,
808
- # `:builder`, `:reflection`, `:instance_definitions_table`, `:singleton_definitions_table`.
1098
+ # `:definition_build_details`, `:definition_build_reported`, `:definition_build_failures`,
1099
+ # `:definition_build_deferred_count`, `:definition_build_deferred_first`,
1100
+ # `:definition_build_summary_warned`, `:internal_demand`, `:internal_demand_status`, `:builder`,
1101
+ # `:reflection`,
1102
+ # `:instance_definitions_table`, `:singleton_definitions_table`.
809
1103
  # Constructed via `Hash.new` (NOT a `{ ... }` literal) so Rigor's `HashShape` narrowing doesn't
810
1104
  # infer a fixed key set from the initial state and fold post-initial slot reads (e.g.
811
1105
  # `@state[:env_loaded]`) to a constant `nil`.
@@ -835,16 +1129,75 @@ module Rigor
835
1129
  #
836
1130
  # @return [Array<Array(String, String)>] empty when every `signature_paths:` file parses.
837
1131
  def quarantined_signatures
838
- @state[:quarantined] ||= self.class.quarantined_project_signatures(@signature_paths).freeze
1132
+ @state[:quarantined] ||= begin
1133
+ parse_quarantined = self.class.quarantined_project_signatures(@signature_paths)
1134
+ collision_quarantined = self.class.collision_quarantined_project_signatures(
1135
+ @state[:env], @signature_paths
1136
+ )
1137
+ parse_paths = parse_quarantined.to_set { |path, _note| path }
1138
+ (
1139
+ parse_quarantined +
1140
+ collision_quarantined.reject { |entry| parse_paths.include?(entry[0]) }
1141
+ ).freeze
1142
+ end
1143
+ end
1144
+
1145
+ # Issue #610 — plugin-contributed signature files that stood down against a colliding generic arity,
1146
+ # as `[absolute_path, class_name, existing_arity, incoming_arity]`. Empty whenever no plugin sig
1147
+ # collides, which is every project that has not run `rbs collection install` for a gem a bundled
1148
+ # plugin also declares.
1149
+ def signature_standdowns
1150
+ @state[:signature_standdowns] ||= self.class.deferred_standdowns(env, @deferred_signature_paths).freeze
1151
+ end
1152
+
1153
+ # Issue #735 — the class / module names whose PRIMARY declaration lives under the project's own
1154
+ # `signature_paths:`, top-level prefix stripped ("::Admin::Widget" reads back as "Admin::Widget").
1155
+ #
1156
+ # The distinction it exists to draw: a BUNDLED declaration (core, stdlib, a gem's RBS) is
1157
+ # authoritative about its class, so a project `def` on that class is a monkey-patch and ADR-17 has
1158
+ # the analyzer report it rather than adopt it. A declaration the project wrote itself — by hand or
1159
+ # through `rigor sig-gen --write` — is a sidecar of the same source tree, and a `def` in another of
1160
+ # the project's own files is not a monkey-patch, just the second file of an ordinary class. Reading
1161
+ # a partial sidecar as authoritative made `sig-gen --write` on redmine report 5.9x more
1162
+ # `call.undefined-method` than the same project with no `sig/` at all.
1163
+ #
1164
+ # Memoised per loader: one pass over `class_decls` (~1-2k entries), and every consumer is a check
1165
+ # rule on the analysis hot path. Attribution is by buffer NAME, which survives the ADR-54 environment
1166
+ # cache since #725 — an environment blob written before that carries the `<cached>` sentinel instead,
1167
+ # which lands every class outside this set and leaves the pre-#735 behaviour, never a new silence.
1168
+ def project_declared_classes
1169
+ @state[:project_declared_classes] ||= build_project_declared_classes
1170
+ end
1171
+
1172
+ def build_project_declared_classes
1173
+ environment = env
1174
+ return Set.new.freeze if environment.nil?
1175
+
1176
+ project_files = self.class.project_sig_files(@signature_paths)
1177
+ return Set.new.freeze if project_files.empty?
1178
+
1179
+ names = environment.class_decls.each_with_object(Set.new) do |(rbs_name, entry), acc|
1180
+ acc << rbs_name.to_s.delete_prefix("::") if self.class.project_entry?(entry, project_files)
1181
+ end
1182
+ names.freeze
1183
+ rescue ::RBS::BaseError
1184
+ Set.new.freeze
1185
+ end
1186
+ private :build_project_declared_classes
1187
+
1188
+ # True when `class_name`'s RBS declaration is one the project wrote (see {#project_declared_classes}).
1189
+ def project_declared_class?(class_name)
1190
+ project_declared_classes.include?(class_name.to_s.delete_prefix("::"))
839
1191
  end
840
1192
 
841
1193
  # The total RBS-environment build failure captured this run, or nil when the env built. Unlike
842
- # {#quarantined_signatures} — which the env survives, one file lighter, and which is re-derived by
843
- # re-parsing so a cache HIT reports it too — a total failure (typically `RBS::DuplicatedDeclarationError`:
844
- # a `signature_paths:` entry redeclaring a constant/class Rigor's bundled RBS already ships) collapses the
845
- # WHOLE env to nil. A failed build produces no cached success to hide behind (nothing is persisted, so
846
- # every run re-attempts and re-raises), so this is captured directly in {#env}'s rescue rather than
847
- # re-derived. Forcing `env` (any query does) populates it.
1194
+ # {#quarantined_signatures} — which the env survives, one file lighter, and which is re-derived so a
1195
+ # cache HIT reports it too — a total failure collapses the WHOLE env to nil. Project-vs-bundled
1196
+ # `RBS::DuplicatedDeclarationError`s are quarantined per-file since #777; this slot remains for
1197
+ # unrecoverable build errors (e.g. bundled-vs-bundled collisions, or hosts without a transactional
1198
+ # `sources`/`unload` API). A failed build produces no cached success to hide behind (nothing is
1199
+ # persisted, so every run re-attempts and re-raises), so this is captured directly in {#env}'s rescue
1200
+ # rather than re-derived. Forcing `env` (any query does) populates it.
848
1201
  #
849
1202
  # @return [Array(String, String, Array<String>), nil] `[error_class_name, first_error_line,
850
1203
  # conflicting_buffer_names]`, or nil when the environment built successfully.
@@ -853,6 +1206,30 @@ module Rigor
853
1206
  @state[:env_build_failure]
854
1207
  end
855
1208
 
1209
+ # Issue #696 — the PER-CLASS sibling of {#env_build_failure}, one tier quieter in consequence: an
1210
+ # `RBS::DefinitionBuilder` failure ({#build_instance_definition} / {#build_singleton_definition}'s
1211
+ # rescue) leaves the class KNOWN but with no method surface, so every call on it — real methods and
1212
+ # typos alike — reads `Dynamic[top]` and stops being checkable. `class_known?` consults
1213
+ # {#known_class_names_set}, never a definition build, so nothing downstream can tell the difference.
1214
+ #
1215
+ # NOT forced the way {#env_build_failure} forces `env`, and it MUST NOT be: definition builds are lazy
1216
+ # (ADR-54 WD1 — per class, on first demand), so this answers "which classes has THIS loader failed to
1217
+ # build so far". A reader that forces would have to build every known class, which is a different and
1218
+ # far more expensive question than the run asked. The consequence for callers is a timing contract: a
1219
+ # snapshot taken before the per-file loop reads empty. `Runner::PoolCoordinator` reads it after.
1220
+ #
1221
+ # Recorded, not derived. The two conditions this sits beside are re-derivable from the built env
1222
+ # ({#quarantined_signatures} re-parses; {#virtual_rbs_collision_quarantined} inspects buffers), but a
1223
+ # definition-build failure leaves no trace in the env at all — the env is fine; it is the BUILD over it
1224
+ # that raised — so the rescue is the only place that ever knows.
1225
+ #
1226
+ # @return [Array<Array(String, String, String, Array<String>)>] `[class_name, error_class_name,
1227
+ # first_error_line, conflicting_buffer_names]`, one per class, in first-failure order. Empty for a
1228
+ # healthy sig set, which is the common case.
1229
+ def definition_build_failures
1230
+ (@state[:definition_build_failures] || []).dup.freeze
1231
+ end
1232
+
856
1233
  # Virtual (inline-synthesized) contributions dropped by the collision quarantine
857
1234
  # ({.resolve_quarantining_virtual_collisions}): buffer names absent from the built env even though the
858
1235
  # entry's content is non-empty and parses (a parse failure is the synthesizer's own WD6 skip, reported
@@ -951,8 +1328,25 @@ module Rigor
951
1328
  # silently the way the v0.0.9 cache `Cache::Descriptor` regression did.
952
1329
  end
953
1330
 
1331
+ # Yields every type-alias declaration loaded into the environment (`type foo = ...`).
1332
+ # Yields `(RBS::TypeName, RBS::Environment::TypeAliasEntry)` pairs.
1333
+ def each_type_alias_decl
1334
+ return enum_for(:each_type_alias_decl) unless block_given?
1335
+ return unless env
1336
+
1337
+ env.type_alias_decls.each do |type_name, decl_entry|
1338
+ yield [type_name, decl_entry]
1339
+ end
1340
+ end
1341
+
954
1342
  # ADR-20 slice 2e — iterates over every `%a{...}` annotation attached to a class- or module-level
955
1343
  # declaration in the loaded RBS environment, yielding `(annotation_string, source_location)` pairs.
1344
+ #
1345
+ # Declarations come from {.entry_declarations}, NOT from a direct `entry.each_decl`: that accessor is
1346
+ # RBS 4.x-only, so the direct call raised `NoMethodError` on every RBS 3.x host — inside a rescue that
1347
+ # deliberately does not swallow `NoMethodError`. It stayed latent because nothing the `rbs-compat` job
1348
+ # runs reached an annotated declaration until #672 added a spec under `spec/rigor/environment` that
1349
+ # drives a whole `Runner`.
956
1350
  # Used by {Rigor::Inference::HktRegistry.scan_rbs_loader} to find `rigor:v1:hkt_register` /
957
1351
  # `rigor:v1:hkt_define` directives in user-authored overlays and merge them into the per-`Environment`
958
1352
  # HKT registry. Yields nothing when the env failed to build (fail-soft, same shape as
@@ -962,7 +1356,7 @@ module Rigor
962
1356
  return if env.nil?
963
1357
 
964
1358
  env.class_decls.each_value do |entry|
965
- entry.each_decl do |decl|
1359
+ self.class.entry_declarations(entry).each do |decl|
966
1360
  next unless decl.respond_to?(:annotations)
967
1361
 
968
1362
  decl.annotations.each { |a| yield a.string, a.location }
@@ -983,7 +1377,7 @@ module Rigor
983
1377
  return if env.nil?
984
1378
 
985
1379
  env.class_decls.each do |rbs_name, entry|
986
- entry.each_decl do |decl|
1380
+ self.class.entry_declarations(entry).each do |decl|
987
1381
  next unless decl.respond_to?(:annotations)
988
1382
 
989
1383
  decl.annotations.each { |a| yield rbs_name.to_s, a.string, a.location }
@@ -1029,11 +1423,22 @@ module Rigor
1029
1423
  # keeps the per-process short-circuit. ADR-54 WD1 retired the definitions disk blob: given a cached
1030
1424
  # env, `Marshal.load`-ing every definition was measurably slower (and allocation-heavier) than
1031
1425
  # rebuilding the ones a run actually touches.
1426
+ #
1427
+ # Issue #696 — the report fires HERE, on the result, rather than in `build_*`'s rescue, and it fires on
1428
+ # a memo hit too. `#prewarm`'s cached producers walk every known class through this method, so by the
1429
+ # time a run demands `String` the nil is already memoised and the rescue will never run again; a report
1430
+ # wired to the rescue would say nothing. The guard keeps the cost on the hot path at one `@state` read,
1431
+ # and only on the nil branch — `nil` here is overwhelmingly an unknown class, not a failed build.
1032
1432
  def instance_definition(class_name)
1033
1433
  key = class_name.to_s
1034
- return @instance_definition_cache[key] if @instance_definition_cache.key?(key)
1035
-
1036
- @instance_definition_cache[key] = build_instance_definition(class_name)
1434
+ definition =
1435
+ if @instance_definition_cache.key?(key)
1436
+ @instance_definition_cache[key]
1437
+ else
1438
+ @instance_definition_cache[key] = build_instance_definition(class_name)
1439
+ end
1440
+ report_definition_build_failure(class_name) if definition.nil? && @state[:definition_build_details]
1441
+ definition
1037
1442
  end
1038
1443
 
1039
1444
  # @return [RBS::Definition::Method, nil]
@@ -1091,7 +1496,14 @@ module Rigor
1091
1496
  name = name.absolute! unless name.absolute?
1092
1497
  return nil unless env.type_alias_decls.key?(name)
1093
1498
 
1094
- builder.expand_alias2(name, rbs_alias.args)
1499
+ # Memoized per (name, args): the env is immutable for the loader's lifetime, so the expansion is a
1500
+ # pure function of the pair, and #529's translator wiring re-expands the same handful of aliases
1501
+ # (`Prism::node`, `int`, `string`, …) at every call site — the memo recoups most of that wall cost.
1502
+ memo = (@state[:type_alias_expansions] ||= {})
1503
+ key = [name, rbs_alias.args]
1504
+ return memo[key] if memo.key?(key)
1505
+
1506
+ memo[key] = builder.expand_alias2(name, rbs_alias.args)
1095
1507
  rescue ::RBS::BaseError, StandardError
1096
1508
  nil
1097
1509
  end
@@ -1103,11 +1515,18 @@ module Rigor
1103
1515
  #
1104
1516
  # Built on demand from the env with a per-process memo; the same on-demand discipline as
1105
1517
  # {#instance_definition} (ADR-54 WD1).
1518
+ #
1519
+ # The singleton twin of {#instance_definition}, reporting on the same terms (issue #696).
1106
1520
  def singleton_definition(class_name)
1107
1521
  key = class_name.to_s
1108
- return @singleton_definition_cache[key] if @singleton_definition_cache.key?(key)
1109
-
1110
- @singleton_definition_cache[key] = build_singleton_definition(class_name)
1522
+ definition =
1523
+ if @singleton_definition_cache.key?(key)
1524
+ @singleton_definition_cache[key]
1525
+ else
1526
+ @singleton_definition_cache[key] = build_singleton_definition(class_name)
1527
+ end
1528
+ report_definition_build_failure(class_name) if definition.nil? && @state[:definition_build_details]
1529
+ definition
1111
1530
  end
1112
1531
 
1113
1532
  # @return [RBS::Definition::Method, nil] the class method on `class_name`. For example,
@@ -1150,6 +1569,47 @@ module Rigor
1150
1569
  @hierarchy.class_ordering(lhs, rhs)
1151
1570
  end
1152
1571
 
1572
+ # The ancestor chain of ONE class, as {RbsHierarchy} asks it to order two classes.
1573
+ #
1574
+ # Issue #696 review (second pass) — this used to live in the hierarchy, which branched on
1575
+ # `cache_store`: with a store it built the whole `RbsClassAncestorTable` and read one key, without a
1576
+ # store it demanded that one class. Three configurations, three different answers to "which classes
1577
+ # failed to build" for one project — 504 classes on a cold store, 0 on a warm one, 2 with no store —
1578
+ # and on the DEFAULT `--workers=0` path, where nothing pre-warms the store first, the cold answer was
1579
+ # the one written into the run-result cache and replayed on every warm run after it.
1580
+ #
1581
+ # The fix is {#during_internal_demand} on BOTH sides, not the removal of the branch. Collapsing to a
1582
+ # per-class demand everywhere was tried first and measured 50x slower on the warm path a cached run
1583
+ # actually takes (200 orderings: 0.0035s reading the table, 0.18s building 400 definitions), which is
1584
+ # the wrong trade for a determinism the marking already buys. What has to be identical across cache
1585
+ # states is the diagnostic, and with both sides marked neither contributes to it: the reported set is
1586
+ # the classes whose METHOD SURFACE the analysis demanded, in every configuration. The memo-hit report
1587
+ # in {#instance_definition} is what makes that hold — a class this path built and memoised as nil is
1588
+ # still reported when a real demand for it arrives later.
1589
+ #
1590
+ # The table side goes through {#ancestor_names_table}, the loader's own marked accessor, rather than
1591
+ # `Cache::RbsClassAncestorTable.fetch` directly. That direct fetch was the bypass: it walked every
1592
+ # known class through the public {#instance_definition} with nothing marking it.
1593
+ #
1594
+ # Answers are identical on both sides — the table's producer computes exactly this, keyed the same
1595
+ # way, and both yield `[]` for an unknown or unbuildable class. Pinned by spec across all three cache
1596
+ # states.
1597
+ #
1598
+ # @return [Array<String>] `::`-stripped ancestor names, or `[]` for an unknown or unbuildable class.
1599
+ def ancestor_names_for(class_name)
1600
+ key = class_name.to_s.delete_prefix("::")
1601
+ during_internal_demand do
1602
+ next ancestor_names_table.fetch(key, [].freeze) if cache_store
1603
+
1604
+ definition = instance_definition(key)
1605
+ next [].freeze if definition.nil?
1606
+
1607
+ definition.ancestors.ancestors.map { |ancestor| ancestor.name.to_s.delete_prefix("::") }.uniq.freeze
1608
+ end
1609
+ rescue ::RBS::BaseError, StandardError
1610
+ [].freeze
1611
+ end
1612
+
1153
1613
  # @return [Array<String>] every RBS-declared constant name (top-level prefixed, e.g., `"::Math::PI"`)
1154
1614
  # currently loaded into the environment. Used by the cache producer that materialises the
1155
1615
  # constant-type table; ordinary callers should keep using {#constant_type} for point lookups.
@@ -1210,16 +1670,23 @@ module Rigor
1210
1670
  # No-op when `cache_store` is nil — without a Store the worker has no choice but to build env via the
1211
1671
  # loader, so the caller MUST ensure pool mode runs with caching enabled. Returns `self` so the call
1212
1672
  # chains cleanly from the `Runner` pre-spawn hook.
1673
+ #
1674
+ # Issue #696 — the whole body is a {#during_internal_demand}, not only the producers that happen to walk
1675
+ # definitions today. On a COLD store `RbsClassTypeParamNames.compute` and `RbsClassAncestorTable.compute`
1676
+ # ask {#instance_definition} for every known class; marking each producer covers that, and marking
1677
+ # `#prewarm` itself covers whichever producer grows the same appetite next.
1213
1678
  def prewarm
1214
1679
  return self if cache_store.nil?
1215
1680
 
1216
- env
1217
- known_class_names_set
1218
- constant_type_table
1219
- type_param_names_table
1220
- ancestor_names_table
1221
- instance_definitions_table
1222
- singleton_definitions_table
1681
+ during_internal_demand do
1682
+ env
1683
+ known_class_names_set
1684
+ constant_type_table
1685
+ type_param_names_table
1686
+ ancestor_names_table
1687
+ instance_definitions_table
1688
+ singleton_definitions_table
1689
+ end
1223
1690
  self
1224
1691
  end
1225
1692
 
@@ -1318,21 +1785,21 @@ module Rigor
1318
1785
  end
1319
1786
 
1320
1787
  def constant_type_table
1321
- @constant_type_table ||= begin
1788
+ @constant_type_table ||= during_internal_demand do
1322
1789
  require_relative "../cache/rbs_constant_table"
1323
1790
  fetch_or_compute_producer(Cache::RbsConstantTable)
1324
1791
  end
1325
1792
  end
1326
1793
 
1327
1794
  def known_class_names_set
1328
- @known_class_names_set ||= begin
1795
+ @known_class_names_set ||= during_internal_demand do
1329
1796
  require_relative "../cache/rbs_known_class_names"
1330
1797
  fetch_or_compute_producer(Cache::RbsKnownClassNames)
1331
1798
  end
1332
1799
  end
1333
1800
 
1334
1801
  def type_param_names_table
1335
- @type_param_names_table ||= begin
1802
+ @type_param_names_table ||= during_internal_demand do
1336
1803
  require_relative "../cache/rbs_class_type_param_names"
1337
1804
  fetch_or_compute_producer(Cache::RbsClassTypeParamNames)
1338
1805
  end
@@ -1352,7 +1819,7 @@ module Rigor
1352
1819
  # the existing `RbsClassAncestorTable` producer when `cache_store` is set; falls back to the
1353
1820
  # producer's `compute` otherwise. Used by {#reflection}.
1354
1821
  def ancestor_names_table
1355
- @ancestor_names_table ||= begin
1822
+ @ancestor_names_table ||= during_internal_demand do
1356
1823
  require_relative "../cache/rbs_class_ancestor_table"
1357
1824
  fetch_or_compute_producer(Cache::RbsClassAncestorTable)
1358
1825
  end
@@ -1378,11 +1845,10 @@ module Rigor
1378
1845
  end
1379
1846
 
1380
1847
  # The RBS environment for this loader. Memoised both on success AND on failure: when the env build
1381
- # raises (typically `RBS::DuplicatedDeclarationError` because a `signature_paths:` entry redeclares a
1382
- # constant or class already shipped by stdlib RBS), retrying on every subsequent `env` call would
1383
- # re-parse and re-resolve the whole sig set per AST node touched during analysis, multiplying per-file
1384
- # analysis cost by ~100x. Failures short-circuit to `nil` here and are surfaced to the user via
1385
- # `warn_about_env_build_failure_once` so the broken `signature_paths:` entry is identifiable.
1848
+ # raises an *unrecoverable* `RBS::BaseError` (project-vs-bundled duplicates are quarantined per-file
1849
+ # since #777), retrying on every subsequent `env` call would re-parse and re-resolve the whole sig set
1850
+ # per AST node touched during analysis, multiplying per-file analysis cost by ~100x. Failures
1851
+ # short-circuit to `nil` here and are surfaced to the user via `warn_about_env_build_failure_once`.
1386
1852
  def env
1387
1853
  return @state[:env] if @state[:env_loaded]
1388
1854
 
@@ -1420,10 +1886,11 @@ module Rigor
1420
1886
  lines = listed.map { |_path, first_line| " - #{first_line}" }
1421
1887
  lines << " … and #{more} more" if more.positive?
1422
1888
  warn(
1423
- "rigor: skipped #{quarantined.size} unparseable RBS file(s) under `signature_paths:`.\n " \
1424
- "They were QUARANTINED so the rest of your RBS env still loads, but the types they\n " \
1425
- "declare are absent — calls into them read `Dynamic[top]`, so coverage and diagnostics\n " \
1426
- "are reduced. Fix the parse error(s) to restore that coverage:\n" \
1889
+ "rigor: skipped #{quarantined.size} RBS file(s) under `signature_paths:` (unparseable or\n " \
1890
+ "duplicated against bundled RBS). They were QUARANTINED so the rest of your RBS env\n " \
1891
+ "still loads, but the types they declare are absent — calls into them read\n " \
1892
+ "`Dynamic[top]`, so coverage and diagnostics are reduced. Fix the parse error(s) or\n " \
1893
+ "remove the conflicting declaration(s) to restore that coverage:\n" \
1427
1894
  "#{lines.join("\n")}"
1428
1895
  )
1429
1896
  end
@@ -1509,15 +1976,20 @@ module Rigor
1509
1976
  # all definitions from a cached env is faster), so the eager-table cost is now a compute, not a load.
1510
1977
  # Keys stay in `RBS::TypeName#to_s` form (top-level prefixed `"::Hash"`) — the shape
1511
1978
  # {Environment::Reflection} documents.
1979
+ #
1980
+ # {#during_internal_demand} (issue #696) — these two walk EVERY known class, so a failure they hit is a
1981
+ # failure of the sig set, not of anything the run asked about, and it must not reach
1982
+ # {#definition_build_failures}. The stderr fallback stays armed, but {#during_internal_demand} folds
1983
+ # every failure found by one outermost walk into one bounded summary (issue #718).
1512
1984
  def instance_definitions_table
1513
- @state[:instance_definitions_table] ||= build_definitions_table do |name|
1514
- build_instance_definition(name)
1985
+ @state[:instance_definitions_table] ||= during_internal_demand do
1986
+ build_definitions_table { |name| build_instance_definition(name) }
1515
1987
  end
1516
1988
  end
1517
1989
 
1518
1990
  def singleton_definitions_table
1519
- @state[:singleton_definitions_table] ||= build_definitions_table do |name|
1520
- build_singleton_definition(name)
1991
+ @state[:singleton_definitions_table] ||= during_internal_demand do
1992
+ build_definitions_table { |name| build_singleton_definition(name) }
1521
1993
  end
1522
1994
  end
1523
1995
 
@@ -1538,7 +2010,8 @@ module Rigor
1538
2010
  self.class.build_env_for(
1539
2011
  libraries: @libraries,
1540
2012
  signature_paths: @signature_paths,
1541
- virtual_rbs: @virtual_rbs
2013
+ virtual_rbs: @virtual_rbs,
2014
+ deferred_signature_paths: @deferred_signature_paths
1542
2015
  )
1543
2016
  end
1544
2017
 
@@ -1552,6 +2025,7 @@ module Rigor
1552
2025
 
1553
2026
  builder.build_instance(rbs_name)
1554
2027
  rescue ::RBS::BaseError => e
2028
+ store_definition_build_detail(class_name, e)
1555
2029
  warn_about_definition_build_failure(class_name, e)
1556
2030
  nil
1557
2031
  end
@@ -1566,10 +2040,115 @@ module Rigor
1566
2040
 
1567
2041
  builder.build_singleton(rbs_name)
1568
2042
  rescue ::RBS::BaseError => e
2043
+ store_definition_build_detail(class_name, e)
1569
2044
  warn_about_definition_build_failure(class_name, e)
1570
2045
  nil
1571
2046
  end
1572
2047
 
2048
+ # Issue #696 — the DETAIL half of the same rescue: what went wrong for this class, remembered so
2049
+ # {#definition_build_failures} can report it. Split from the REPORTING decision on purpose, and the
2050
+ # split is the whole fix.
2051
+ #
2052
+ # Recording in the rescue is wrong in both directions at once. Too loud: `#prewarm` and the cached
2053
+ # table producers walk EVERY known class, so on a cold store they enter this rescue for every class a
2054
+ # collision took down — 1,336 of them for one `class Object` duplicate — and the diagnostic named all
2055
+ # of them under `--workers=N` with a cold cache while naming 3 under every other configuration. Too
2056
+ # quiet: {#instance_definition} memoises the nil, so once that walk has run, the FIRST real demand
2057
+ # never re-enters the rescue and would record nothing at all.
2058
+ #
2059
+ # So the rescue only remembers, keyed by the `::`-stripped name (the eager walk spells it `::Acme`,
2060
+ # a dispatch site spells it `Acme`, and they are one class — the {#synthesized_namespaces}
2061
+ # convention), and {#report_definition_build_failure} decides. First writer wins: the instance and
2062
+ # singleton sides fail for the same underlying collision, and the first is the one a caller hit.
2063
+ def store_definition_build_detail(class_name, error)
2064
+ key = class_name.to_s.delete_prefix("::")
2065
+ details = (@state[:definition_build_details] ||= {})
2066
+ return if details.key?(key)
2067
+
2068
+ details[key] = [key, error.class.name.to_s, definition_build_member(error),
2069
+ definition_build_conflict_buffers(error)].freeze
2070
+ end
2071
+
2072
+ # Issue #696 — the REPORTING half: promote a remembered detail into {#definition_build_failures}
2073
+ # because something the run was actually doing asked for this class's definition and got nothing.
2074
+ #
2075
+ # Called from {#instance_definition} / {#singleton_definition} — the demand entries — rather than from
2076
+ # the rescue, so it fires on a MEMO HIT too. That is what survives a pre-warm: the walk builds the
2077
+ # definition, fails, memoises nil, and never enters the rescue again, but the next real demand still
2078
+ # reads nil here and still reports. Reporting from the rescue could only ever see the first build.
2079
+ #
2080
+ # Silent inside a whole-universe walk ({#during_internal_demand}): a producer that touches every known
2081
+ # class is asking a question the RUN did not ask, and letting it contribute makes the reported class
2082
+ # list depend on whether a cache was cold — the same project saying different things under
2083
+ # `--workers=N` than under `--workers=0`, which is the defect this diagnostic exists to end.
2084
+ def report_definition_build_failure(class_name)
2085
+ return if @state[:internal_demand]
2086
+
2087
+ details = @state[:definition_build_details]
2088
+ return if details.nil?
2089
+
2090
+ key = class_name.to_s.delete_prefix("::")
2091
+ detail = details[key]
2092
+ return if detail.nil?
2093
+
2094
+ reported = (@state[:definition_build_reported] ||= {})
2095
+ return if reported[key]
2096
+
2097
+ reported[key] = true
2098
+ (@state[:definition_build_failures] ||= []) << detail
2099
+ end
2100
+
2101
+ # Marks a definition demand that is RIGOR'S OWN, not the analysed program's, so
2102
+ # {#report_definition_build_failure} stays silent for the duration.
2103
+ #
2104
+ # The diagnostic reports classes whose METHOD SURFACE the analysis asked to resolve — that is what its
2105
+ # message promises, and it is the only demand set that does not vary with how the run was invoked. Two
2106
+ # kinds of demand are Rigor's own and must be excluded:
2107
+ #
2108
+ # - a whole-universe cache producer ({#prewarm}'s tables), which asks about every known class;
2109
+ # - the hierarchy oracle's ancestry lookup ({#ancestor_names_for}), which asks whether two classes are
2110
+ # ordered, not whether either one's methods resolve.
2111
+ #
2112
+ # Both were cache-state-dependent before this, and each produced a DIFFERENT class list per
2113
+ # configuration for the same project — the second one on the DEFAULT `--workers=0` path (issue #696
2114
+ # review, second pass).
2115
+ #
2116
+ # Save-and-restore rather than a bare flag: `#prewarm` wraps a body whose members wrap themselves, and
2117
+ # a nested demand must not un-mark its caller on the way out. A successful outermost exit flushes the
2118
+ # deferred stderr summary; an outermost exit that raises keeps the detailed fallback banner armed even
2119
+ # when an earlier internal-demand episode already emitted the loader-instance summary.
2120
+ #
2121
+ # Per LOADER, not per thread. Nesting and a raise mid-demand are both handled, and the fork pool forks
2122
+ # after `#prewarm` returns, so no CLI path shares a loader across concurrent analyses. An in-process
2123
+ # host that did (`language_server/debouncer.rb` runs analysis on a `Thread`) could have one analysis
2124
+ # silence another's reporting; not reachable today, and not worth a thread-local until it is.
2125
+ def during_internal_demand
2126
+ previous = @state[:internal_demand]
2127
+ outermost = !previous
2128
+ if outermost
2129
+ @state[:definition_build_deferred_count] = 0
2130
+ @state[:definition_build_deferred_first] = nil
2131
+ end
2132
+ @state[:internal_demand] = true
2133
+ # `rescue`/`else` keeps the normal block result precise for the analyzer; the pending marker covers
2134
+ # non-local exits (e.g. `throw`) that unwind through `ensure` without entering either branch.
2135
+ @state[:internal_demand_status] = :pending if outermost
2136
+ begin
2137
+ result = yield
2138
+ @state[:internal_demand_status] = :completed if outermost
2139
+ rescue StandardError, ScriptError => e
2140
+ @state[:internal_demand_status] = :aborted if outermost
2141
+ warn_about_aborted_definition_build_failures if outermost
2142
+ raise e
2143
+ else
2144
+ warn_about_deferred_definition_build_failures if outermost
2145
+ result
2146
+ ensure
2147
+ @state[:internal_demand] = previous
2148
+ warn_about_aborted_definition_build_failures if outermost && @state[:internal_demand_status] == :pending
2149
+ end
2150
+ end
2151
+
1573
2152
  # The third twin of {#warn_about_quarantined_signatures} / {#warn_about_virtual_rbs_collisions}: name,
1574
2153
  # once per class per PROCESS, a `RBS::DefinitionBuilder` failure caught in
1575
2154
  # {#build_instance_definition} / {#build_singleton_definition}'s rescue. Without it, `class_known?`
@@ -1580,21 +2159,33 @@ module Rigor
1580
2159
  # {#env}, because the whole env is built eagerly and every quarantine/collision is already known by
1581
2160
  # then. Definition builds are LAZY (ADR-54 WD1 — built on demand per class the FIRST time a caller asks,
1582
2161
  # long after {#env} has already run), so there is no later central checkpoint to fire from before the
1583
- # affected classes even exist. This warns inline at the rescue site instead, gated on
1584
- # `@state[:definition_build_warned]` (keyed by class name) so the instance and singleton sides and
1585
- # any re-entry once the per-process `@instance_definition_cache` / `@singleton_definition_cache`
1586
- # memoize the failure warn at most once per class name, cache-hit runs included (a definition build
1587
- # is per-process regardless of the RBS-env cache tier). `@state` is per-LOADER-INSTANCE, not
1588
- # process-global, so under the fork-based analysis pool each worker holds its own loader and its own
1589
- # `@state`: a class whose definition fails can print its warning once per worker that happens to touch
1590
- # it, i.e. more than once in a single `rigor check` run. Deduplicating that across processes is out of
1591
- # scope here see [#295](https://github.com/rigortype/rigor/issues/295).
2162
+ # affected classes even exist. An ordinary demand therefore warns inline at the rescue site; Rigor's
2163
+ # own internal demand defers until its outermost boundary and collapses the whole walk to one summary
2164
+ # (issue #718). Both routes share `@state[:definition_build_warned]` (keyed by normalized class name),
2165
+ # so the instance and singleton sides and any re-entry once the per-loader-instance definition caches memoize
2166
+ # the failure count or warn at most once per class name, cache-hit runs included.
2167
+ #
2168
+ # `@state` is per LOADER INSTANCE, not process-global. A fork-pool prewarm finishes before the fork, so
2169
+ # that loader instance emits its summary once in the parent and its dedupe state is inherited by the
2170
+ # workers. A separate loader instance (for example, a parameter-inference pre-pass) has its own summary
2171
+ # budget. A failure first reached by an ordinary demand can still warn once in each worker that reaches
2172
+ # it; deduplicating that across processes is out of scope here — see [#295](https://github.com/rigortype/rigor/issues/295).
1592
2173
  def warn_about_definition_build_failure(class_name, error)
1593
2174
  warned = (@state[:definition_build_warned] ||= {})
1594
- key = class_name.to_s
2175
+ key = class_name.to_s.delete_prefix("::")
1595
2176
  return if warned[key]
1596
2177
 
1597
2178
  warned[key] = true
2179
+ if @state[:internal_demand]
2180
+ @state[:definition_build_deferred_count] = @state.fetch(:definition_build_deferred_count, 0) + 1
2181
+ @state[:definition_build_deferred_first] ||= [class_name.to_s, error]
2182
+ return
2183
+ end
2184
+
2185
+ warn(definition_build_failure_warning(class_name, error))
2186
+ end
2187
+
2188
+ def definition_build_failure_warning(class_name, error)
1598
2189
  first_line = error.message.to_s.lines.first.to_s.strip
1599
2190
  buffers = definition_build_conflict_buffers(error)
1600
2191
  collisions =
@@ -1607,13 +2198,42 @@ module Rigor
1607
2198
  lines << " … and #{more} more" if more.positive?
1608
2199
  "\n Colliding declaration(s):\n#{lines.join("\n")}"
1609
2200
  end
1610
- warn(
1611
- "rigor: RBS definition build failed for `#{class_name}`: #{error.class}: #{first_line}\n " \
2201
+ "rigor: RBS definition build failed for `#{class_name}`: #{error.class}: #{first_line}\n " \
1612
2202
  "Rigor still treats the class as known, so calls into it now silently degrade to\n " \
1613
2203
  "`Dynamic[top]` — real methods and typos alike — instead of resolving normally.#{collisions}"
2204
+ end
2205
+
2206
+ def warn_about_deferred_definition_build_failures
2207
+ return if @state[:definition_build_summary_warned]
2208
+
2209
+ count = @state[:definition_build_deferred_count]
2210
+ return if count.nil? || count.zero?
2211
+
2212
+ @state[:definition_build_summary_warned] = true
2213
+ first_class, first_error = @state[:definition_build_deferred_first]
2214
+ affected = if count == 1
2215
+ "1 class affected"
2216
+ else
2217
+ more = count - 1
2218
+ noun = more == 1 ? "class" : "classes"
2219
+ "… and #{more} more #{noun} affected (#{count} total)"
2220
+ end
2221
+ warn(
2222
+ "#{definition_build_failure_warning(first_class, first_error)}\n " \
2223
+ "Internal whole-universe demand found #{affected}; the " \
2224
+ "`rbs.coverage.definition-build-failed` diagnostic reports the classes the analysis demanded and " \
2225
+ "the culprit member and signature files."
1614
2226
  )
1615
2227
  end
1616
2228
 
2229
+ def warn_about_aborted_definition_build_failures
2230
+ count = @state[:definition_build_deferred_count]
2231
+ return if count.nil? || count.zero?
2232
+
2233
+ first_class, first_error = @state[:definition_build_deferred_first]
2234
+ warn(definition_build_failure_warning(first_class, first_error))
2235
+ end
2236
+
1617
2237
  # The definition-build twin of {#env_build_conflict_buffers}: the declaration source file(s) named by a
1618
2238
  # `RBS::DefinitionBuilder` failure, so {#warn_about_definition_build_failure} can name the colliding
1619
2239
  # declarations rather than only the exception's class and message. The payload shape varies by error
@@ -1624,22 +2244,53 @@ module Rigor
1624
2244
  # `#location`. Falls back to an empty list — the warning still names the class and the exception's own
1625
2245
  # message — for the remainder (e.g. `SuperclassMismatchError`, which carries neither).
1626
2246
  def definition_build_conflict_buffers(error)
1627
- locations =
1628
- if error.respond_to?(:members) && error.members
1629
- Array(error.members).map(&:location)
1630
- elsif error.respond_to?(:member) && error.member
1631
- [error.member.location]
1632
- elsif error.respond_to?(:location)
1633
- [error.location]
1634
- else
1635
- []
1636
- end
1637
-
1638
- locations.compact.filter_map { |loc| loc.buffer&.name }.map(&:to_s).uniq.freeze
2247
+ definition_build_error_locations(error)
2248
+ .compact.filter_map { |loc| loc.buffer&.name }.map(&:to_s)
2249
+ .reject { |name| name == CACHED_LOCATION_BUFFER_NAME }.uniq.freeze
1639
2250
  rescue ::RBS::BaseError, StandardError
1640
2251
  [].freeze
1641
2252
  end
1642
2253
 
2254
+ # The `RBS::Location`s an error carries, by whichever accessor its class happens to expose (see the
2255
+ # shape survey above). Split out so the buffer-name extraction reads as one pipeline.
2256
+ def definition_build_error_locations(error)
2257
+ return Array(error.members).map(&:location) if error.respond_to?(:members) && error.members
2258
+ return [error.member.location] if error.respond_to?(:member) && error.member
2259
+ return [error.location] if error.respond_to?(:location)
2260
+
2261
+ []
2262
+ end
2263
+
2264
+ # The member the failure is ABOUT, taken off the error object rather than parsed out of its message.
2265
+ #
2266
+ # Issue #696 — the message cannot serve here. It is built from `RBS::Location`s, and the ADR-54 env
2267
+ # cache drops their POSITIONS, so a warm run's message would name no line and the same project would
2268
+ # report different text cold and warm. These accessors are `RBS::TypeName`s and Symbols, so they
2269
+ # survive the marshal round trip unchanged — and they name the class that actually CARRIES the
2270
+ # duplicate, which the failed-class list does not: a collision on `::Object#blank?` fails `String`,
2271
+ # `Integer` and `Array`, none of which is where the fix goes. (The buffer NAME does survive, since the
2272
+ # second review pass; it is the file list that uses it, not this.)
2273
+ #
2274
+ # Shapes vary by error class (`references/rbs`'s `lib/rbs/errors.rb`): the two duplicated-definition
2275
+ # errors expose `#qualified_method_name` directly; `InvalidOverloadMethodError` has `#type_name` +
2276
+ # `#method_name`; `UnknownMethodAliasError` and `NoSuperclassFoundError` have `#type_name`;
2277
+ # `SuperclassMismatchError` has `#name`. `RecursiveAncestorError` has none of them and yields nil, and
2278
+ # the diagnostic then omits the clause rather than inventing one.
2279
+ #
2280
+ # @return [String, nil]
2281
+ def definition_build_member(error)
2282
+ return error.qualified_method_name.to_s if error.respond_to?(:qualified_method_name)
2283
+
2284
+ type_name = error.respond_to?(:type_name) ? error.type_name : nil
2285
+ type_name ||= error.respond_to?(:name) ? error.name : nil
2286
+ return nil if type_name.nil?
2287
+
2288
+ method_name = error.respond_to?(:method_name) ? error.method_name : nil
2289
+ method_name.nil? ? type_name.to_s : "#{type_name}##{method_name}"
2290
+ rescue ::RBS::BaseError, StandardError
2291
+ nil
2292
+ end
2293
+
1643
2294
  # Resolve an RBS class/module ALIAS to its canonical declared name. `class Mutex = Thread::Mutex`
1644
2295
  # lives only in `class_alias_decls`, so `class_known?` reports it (it checks that table) but the
1645
2296
  # definition builder — which only knows `class_decls` — could not enumerate its methods, leaving alias
@@ -1688,6 +2339,5 @@ module Rigor
1688
2339
  false
1689
2340
  end
1690
2341
  end
1691
- # rubocop:enable Metrics/ClassLength
1692
2342
  end
1693
2343
  end