rigortype 0.4.7 → 0.4.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 (268) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -1
  3. data/data/core_overlay/bigdecimal.rbs +33 -0
  4. data/data/core_overlay/resolv.rbs +30 -0
  5. data/data/gem_overlay/activesupport/core_ext.rbs +29 -0
  6. data/data/gem_overlay/addressable/addressable.rbs +40 -0
  7. data/docs/handbook/01-getting-started.md +1 -1
  8. data/docs/handbook/11-sig-gen.md +35 -0
  9. data/docs/install.md +20 -0
  10. data/docs/manual/01-installation.md +67 -26
  11. data/docs/manual/02-cli-reference.md +307 -91
  12. data/docs/manual/03-configuration.md +41 -3
  13. data/docs/manual/04-diagnostics.md +5 -4
  14. data/docs/manual/08-skills.md +11 -0
  15. data/docs/manual/09-editor-integration.md +8 -0
  16. data/docs/manual/14-rails-quickstart.md +1 -1
  17. data/docs/manual/19-effect-labels.md +3 -4
  18. data/docs/manual/plugins/rigor-actionpack.md +15 -6
  19. data/docs/manual/plugins/rigor-activesupport-core-ext.md +3 -0
  20. data/docs/manual/plugins/rigor-sorbet.md +17 -3
  21. data/lib/rigor/analysis/check_rules/always_raises_shield.rb +147 -0
  22. data/lib/rigor/analysis/check_rules/call_facts.rb +58 -0
  23. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +8 -0
  24. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +13 -2
  25. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +6 -0
  26. data/lib/rigor/analysis/check_rules/plugin_api_surface.rb +195 -0
  27. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +40 -11
  28. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +27 -1
  29. data/lib/rigor/analysis/check_rules/replaced_singleton_defs.rb +96 -0
  30. data/lib/rigor/analysis/check_rules/rule_ids.rb +8 -1
  31. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +1 -1
  32. data/lib/rigor/analysis/check_rules.rb +598 -99
  33. data/lib/rigor/analysis/crash_signature.rb +43 -1
  34. data/lib/rigor/analysis/dependency_recorder.rb +233 -76
  35. data/lib/rigor/analysis/incremental.rb +11 -1
  36. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +2 -1
  37. data/lib/rigor/analysis/incremental_session.rb +279 -37
  38. data/lib/rigor/analysis/reachability/plugin_roots.rb +6 -3
  39. data/lib/rigor/analysis/rule_catalog.rb +37 -1
  40. data/lib/rigor/analysis/run_cache_key.rb +27 -3
  41. data/lib/rigor/analysis/run_disclosure_credit.rb +26 -6
  42. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +4 -0
  43. data/lib/rigor/analysis/runner/declaration_position.rb +7 -7
  44. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +72 -20
  45. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +3 -8
  46. data/lib/rigor/analysis/runner/pool_coordinator.rb +26 -5
  47. data/lib/rigor/analysis/runner/project_pre_passes.rb +4 -37
  48. data/lib/rigor/analysis/runner.rb +222 -39
  49. data/lib/rigor/analysis/self_call_resolution_recorder.rb +61 -22
  50. data/lib/rigor/analysis/worker_session.rb +42 -41
  51. data/lib/rigor/bleeding_edge.rb +19 -4
  52. data/lib/rigor/cache/file_digest.rb +39 -31
  53. data/lib/rigor/cache/incremental_snapshot.rb +66 -7
  54. data/lib/rigor/cache/rbs_descriptor.rb +37 -2
  55. data/lib/rigor/cache/rbs_environment.rb +5 -1
  56. data/lib/rigor/cli/baseline_command.rb +15 -2
  57. data/lib/rigor/cli/check_command.rb +4 -2
  58. data/lib/rigor/cli/effects_snapshot_command.rb +8 -1
  59. data/lib/rigor/cli/lens_command.rb +17 -2
  60. data/lib/rigor/cli/sig_gen_command.rb +8 -0
  61. data/lib/rigor/cli/skill_describe.rb +1 -0
  62. data/lib/rigor/cli.rb +5 -2
  63. data/lib/rigor/config_audit.rb +32 -18
  64. data/lib/rigor/configuration/severity_profile.rb +9 -0
  65. data/lib/rigor/configuration.rb +32 -5
  66. data/lib/rigor/effects/collector.rb +53 -21
  67. data/lib/rigor/environment/bundle_sig_discovery.rb +63 -28
  68. data/lib/rigor/environment/constant_type_cache_holder.rb +5 -0
  69. data/lib/rigor/environment/default_libraries.rb +17 -0
  70. data/lib/rigor/environment/deferred_signature_plan.rb +104 -6
  71. data/lib/rigor/environment/hkt_registry_holder.rb +6 -0
  72. data/lib/rigor/environment/rbs_collection_discovery.rb +123 -8
  73. data/lib/rigor/environment/rbs_coverage_report.rb +12 -11
  74. data/lib/rigor/environment/rbs_hierarchy.rb +6 -0
  75. data/lib/rigor/environment/rbs_loader.rb +312 -63
  76. data/lib/rigor/environment.rb +131 -35
  77. data/lib/rigor/inference/acceptance.rb +50 -15
  78. data/lib/rigor/inference/alias_mutation_floor/foreign_locals.rb +117 -0
  79. data/lib/rigor/inference/alias_mutation_floor/memos.rb +85 -0
  80. data/lib/rigor/inference/alias_mutation_floor/reach.rb +119 -0
  81. data/lib/rigor/inference/alias_mutation_floor/site_collector.rb +252 -0
  82. data/lib/rigor/inference/alias_mutation_floor.rb +339 -0
  83. data/lib/rigor/inference/anonymous_meta_class.rb +18 -0
  84. data/lib/rigor/inference/block_auto_splat.rb +1 -1
  85. data/lib/rigor/inference/block_run_count.rb +9 -2
  86. data/lib/rigor/inference/builtins/array_catalog.rb +2 -2
  87. data/lib/rigor/inference/callee_mutation_chain.rb +1 -1
  88. data/lib/rigor/inference/captured_locals.rb +2 -0
  89. data/lib/rigor/inference/census_foreign_block.rb +354 -0
  90. data/lib/rigor/inference/clause_widening.rb +1 -1
  91. data/lib/rigor/inference/closure_escape_analyzer.rb +11 -4
  92. data/lib/rigor/inference/construction_escape.rb +56 -15
  93. data/lib/rigor/inference/declaration_walk.rb +4 -0
  94. data/lib/rigor/inference/def_node_resolver.rb +32 -14
  95. data/lib/rigor/inference/def_return_typer.rb +1 -1
  96. data/lib/rigor/inference/definer_resolution.rb +15 -7
  97. data/lib/rigor/inference/definers_index.rb +72 -16
  98. data/lib/rigor/inference/error_info.rb +16 -0
  99. data/lib/rigor/inference/expression_typer.rb +1459 -287
  100. data/lib/rigor/inference/flow_tracer.rb +31 -14
  101. data/lib/rigor/inference/fork_map.rb +3 -0
  102. data/lib/rigor/inference/fresh_frame_blocks.rb +52 -1
  103. data/lib/rigor/inference/global_write_census.rb +8 -6
  104. data/lib/rigor/inference/in_effect_refinements.rb +7 -0
  105. data/lib/rigor/inference/indexed_narrowing.rb +57 -8
  106. data/lib/rigor/inference/ivar_writer_census.rb +106 -11
  107. data/lib/rigor/inference/key_presence_guard.rb +51 -25
  108. data/lib/rigor/inference/last_line/self_evidence.rb +30 -0
  109. data/lib/rigor/inference/last_line.rb +5 -2
  110. data/lib/rigor/inference/loop_value_record.rb +63 -15
  111. data/lib/rigor/inference/match_rebinding/frame.rb +11 -0
  112. data/lib/rigor/inference/match_rebinding/yielding_match.rb +74 -18
  113. data/lib/rigor/inference/match_rebinding.rb +21 -8
  114. data/lib/rigor/inference/method_dispatcher/block_folding.rb +43 -29
  115. data/lib/rigor/inference/method_dispatcher/class_keyed_block_params.rb +65 -0
  116. data/lib/rigor/inference/method_dispatcher/core_enumerable_receiver.rb +44 -0
  117. data/lib/rigor/inference/method_dispatcher/declared_constructor.rb +151 -0
  118. data/lib/rigor/inference/method_dispatcher/facet_distribution.rb +1 -1
  119. data/lib/rigor/inference/method_dispatcher/imprecise_argument.rb +2 -2
  120. data/lib/rigor/inference/method_dispatcher/interface_shape.rb +181 -0
  121. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +12 -0
  122. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +409 -67
  123. data/lib/rigor/inference/method_dispatcher/self_substitute.rb +1 -1
  124. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +384 -64
  125. data/lib/rigor/inference/method_dispatcher/singleton_type_params.rb +104 -0
  126. data/lib/rigor/inference/method_dispatcher.rb +66 -18
  127. data/lib/rigor/inference/method_parameter_binder.rb +136 -56
  128. data/lib/rigor/inference/module_function_state/spellings.rb +2 -2
  129. data/lib/rigor/inference/module_function_state.rb +13 -2
  130. data/lib/rigor/inference/multi_target_binder.rb +1 -1
  131. data/lib/rigor/inference/mutation_widening.rb +44 -4
  132. data/lib/rigor/inference/narrowing.rb +201 -27
  133. data/lib/rigor/inference/operand_effects.rb +43 -2
  134. data/lib/rigor/inference/optimistic_origin.rb +133 -8
  135. data/lib/rigor/inference/origin_lookup.rb +1 -1
  136. data/lib/rigor/inference/precision_scanner.rb +1 -1
  137. data/lib/rigor/inference/project_method_ownership.rb +28 -10
  138. data/lib/rigor/inference/project_patched_methods.rb +65 -13
  139. data/lib/rigor/inference/project_patched_scanner.rb +120 -25
  140. data/lib/rigor/inference/project_patched_visibility.rb +39 -3
  141. data/lib/rigor/inference/rbs_type_translator.rb +68 -15
  142. data/lib/rigor/inference/receiver_writer_call.rb +67 -0
  143. data/lib/rigor/inference/recursion_rebinding.rb +157 -0
  144. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +56 -18
  145. data/lib/rigor/inference/refined_dispatch/own_method.rb +51 -17
  146. data/lib/rigor/inference/refined_dispatch.rb +13 -0
  147. data/lib/rigor/inference/reflective_ivar_write.rb +19 -11
  148. data/lib/rigor/inference/remover_holder_scan.rb +76 -22
  149. data/lib/rigor/inference/scope_indexer/class_cvars_collector.rb +13 -6
  150. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +12 -11
  151. data/lib/rigor/inference/scope_indexer/ivar_contributions.rb +188 -28
  152. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +136 -9
  153. data/lib/rigor/inference/scope_indexer.rb +2079 -183
  154. data/lib/rigor/inference/self_call_ivar_writes.rb +26 -7
  155. data/lib/rigor/inference/self_preserving_block.rb +87 -0
  156. data/lib/rigor/inference/singleton_hook_decline.rb +41 -6
  157. data/lib/rigor/inference/singleton_object_constant.rb +32 -2
  158. data/lib/rigor/inference/statement_evaluator.rb +970 -250
  159. data/lib/rigor/inference/string_mutation.rb +14 -0
  160. data/lib/rigor/inference/synthetic_method_scanner.rb +35 -11
  161. data/lib/rigor/inference/toplevel_def_census.rb +14 -6
  162. data/lib/rigor/inference/unknown_store_widening.rb +1 -1
  163. data/lib/rigor/inference/untyped_rejoin.rb +1 -1
  164. data/lib/rigor/inference/void_tail_summary.rb +29 -10
  165. data/lib/rigor/lens/claims/comparison.rb +78 -0
  166. data/lib/rigor/lens/claims/request.rb +131 -0
  167. data/lib/rigor/lens/claims/type_reader.rb +216 -0
  168. data/lib/rigor/lens/claims.rb +208 -0
  169. data/lib/rigor/lens/contract_reader.rb +5 -0
  170. data/lib/rigor/lens/document.rb +1 -1
  171. data/lib/rigor/lens/file_reader.rb +21 -8
  172. data/lib/rigor/lens/run.rb +8 -0
  173. data/lib/rigor/lens.rb +1 -1
  174. data/lib/rigor/library_probe.rb +68 -0
  175. data/lib/rigor/memo_audit.rb +282 -0
  176. data/lib/rigor/plugin/base.rb +15 -2
  177. data/lib/rigor/plugin/io_boundary.rb +25 -3
  178. data/lib/rigor/plugin/manifest.rb +7 -3
  179. data/lib/rigor/plugin/registry.rb +13 -2
  180. data/lib/rigor/plugin/trust_policy.rb +46 -0
  181. data/lib/rigor/protection/mutation_cache.rb +4 -1
  182. data/lib/rigor/reflection/constant_ancestors.rb +79 -22
  183. data/lib/rigor/reflection/declaration_collectors.rb +55 -0
  184. data/lib/rigor/reflection/declarations.rb +58 -0
  185. data/lib/rigor/reflection/project_method.rb +177 -0
  186. data/lib/rigor/reflection.rb +2 -0
  187. data/lib/rigor/rooted_glob.rb +18 -1
  188. data/lib/rigor/run_scoped_slots.rb +20 -20
  189. data/lib/rigor/scope/discovery_index.rb +149 -2
  190. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -1
  191. data/lib/rigor/scope/resolution_chain.rb +900 -55
  192. data/lib/rigor/scope.rb +307 -34
  193. data/lib/rigor/sig_gen/bot_proposal_guard.rb +306 -0
  194. data/lib/rigor/sig_gen/classification.rb +14 -1
  195. data/lib/rigor/sig_gen/generator.rb +222 -13
  196. data/lib/rigor/sig_gen/method_candidate.rb +14 -0
  197. data/lib/rigor/sig_gen/observation_collector.rb +11 -2
  198. data/lib/rigor/sig_gen/platform_guarded_files.rb +184 -0
  199. data/lib/rigor/sig_gen/skip_reason_catalog.rb +47 -0
  200. data/lib/rigor/sig_gen/writer.rb +21 -0
  201. data/lib/rigor/sig_gen.rb +1 -0
  202. data/lib/rigor/source/forwardable_delegators.rb +122 -0
  203. data/lib/rigor/source/node_children.rb +13 -0
  204. data/lib/rigor/type/combinator.rb +164 -8
  205. data/lib/rigor/type/interface.rb +60 -0
  206. data/lib/rigor/type.rb +1 -0
  207. data/lib/rigor/type_of_memo/serving.rb +99 -0
  208. data/lib/rigor/type_of_memo/stats.rb +43 -0
  209. data/lib/rigor/type_of_memo.rb +337 -0
  210. data/lib/rigor/typing_state.rb +1094 -0
  211. data/lib/rigor/version.rb +1 -1
  212. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +2 -2
  213. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +57 -25
  214. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +3 -6
  215. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +25 -1
  216. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +16 -6
  217. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +4 -2
  218. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -1
  219. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +130 -30
  220. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +20 -5
  221. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +7 -4
  222. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +49 -13
  223. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +19 -5
  224. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +4 -2
  225. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +13 -5
  226. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +29 -0
  227. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_collector.rb +15 -21
  228. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -6
  229. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +4 -2
  230. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +12 -2
  231. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +6 -0
  232. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +199 -1
  233. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +3 -2
  234. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +4 -4
  235. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +209 -2
  236. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +146 -7
  237. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +52 -34
  238. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +80 -3
  239. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_collector.rb +16 -16
  240. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +1 -6
  241. data/sig/rigor/analysis/check_rules/rule_walk.rbs +6 -0
  242. data/sig/rigor/analysis/fact_store.rbs +2 -3
  243. data/sig/rigor/cache/descriptor.rbs +14 -0
  244. data/sig/rigor/environment.rbs +4 -1
  245. data/sig/rigor/plugin/base.rbs +53 -19
  246. data/sig/rigor/plugin/interfaces.rbs +51 -0
  247. data/sig/rigor/plugin/io_boundary.rbs +2 -3
  248. data/sig/rigor/plugin/node_context.rbs +21 -0
  249. data/sig/rigor/reflection.rbs +37 -0
  250. data/sig/rigor/scope.rbs +22 -3
  251. data/sig/rigor/trinary.rbs +1 -0
  252. data/sig/rigor/type.rbs +9 -3
  253. data/sig/rigor.rbs +3 -2
  254. data/skills/rigor-ask/SKILL.md +1 -0
  255. data/skills/rigor-project-init/references/01-detect.md +1 -0
  256. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +5 -1
  257. data/skills/rigor-project-init/references/06-agent-contract.md +4 -4
  258. data/skills/rigor-rbs-contract/SKILL.md +147 -0
  259. data/skills/rigor-rbs-contract/references/01-finding-candidates.md +212 -0
  260. data/skills/rigor-rbs-contract/references/02-contract-rules.md +157 -0
  261. data/skills/rigor-rbs-contract/references/03-target-own-code.md +90 -0
  262. data/skills/rigor-rbs-contract/references/04-target-gem-rbs-collection.md +210 -0
  263. data/skills/rigor-rbs-contract/references/05-declaration-mutants.md +182 -0
  264. data/skills/rigor-rbs-setup/SKILL.md +3 -1
  265. data/skills/rigor-type-oracle/SKILL.md +26 -1
  266. data/skills/rigor-type-oracle/references/02-agents-md-fragment.md +4 -3
  267. metadata +48 -2
  268. data/lib/rigor/inference/memo_audit.rb +0 -55
@@ -17,6 +17,7 @@ require_relative "../inference/def_return_typer"
17
17
  require_relative "../inference/scope_indexer"
18
18
  require_relative "../inference/project_seed_tables"
19
19
  require_relative "../inference/module_function_state"
20
+ require_relative "bot_proposal_guard"
20
21
  require_relative "../inference/rbs_type_translator"
21
22
  require_relative "alias_index"
22
23
  require_relative "meta_class_shape"
@@ -27,6 +28,7 @@ require_relative "declaration_equivalence"
27
28
  require_relative "inline_declarations"
28
29
  require_relative "layout_index"
29
30
  require_relative "method_candidate"
31
+ require_relative "platform_guarded_files"
30
32
  require_relative "type_name_qualification"
31
33
  require_relative "writer"
32
34
  require_relative "../rooted_glob"
@@ -164,6 +166,11 @@ module Rigor
164
166
  def run
165
167
  resolved = resolve_paths(@paths)
166
168
  @resolved_paths = resolved
169
+ # Issue #3035 — the scanned files Ruby loads only through a platform-conditional require; their
170
+ # candidates are demoted in {#demote_platform_guarded}.
171
+ @platform_guarded_files = PlatformGuardedFiles.call(
172
+ resolved, ruby_version: @configuration.target_ruby
173
+ )
167
174
  @environment = build_environment(resolved)
168
175
  # ADR-84 WD2, as `Runner#run_analysis` mints it for `check` (#2300): one return-memo bucket for the whole
169
176
  # run. Without it the memo keys on each file's merged `discovered_def_nodes`, a new table per file, so a
@@ -176,10 +183,21 @@ module Rigor
176
183
  @file_base_scope = nil
177
184
  # #2461 — which `sig/` targets the writer will write to, read from the files as they are now.
178
185
  @writable_targets = {}
179
- candidates = resolved.flat_map { |path| analyse_file(path, @environment) }
180
- demoted = demote_overridden_base_methods(
181
- demote_unresolvable_superclasses(
182
- demote_generic_reopenings(demote_inline_generic_classes(resolve_superclass_spellings(candidates)))
186
+ # ADR-130 WD5 — with interface dispatch off, so a return read through a `Rigor::Plugin` interface member
187
+ # (`_FactStore#publish: ... -> nil`) is not written into RBS a plugin author never wrote against the
188
+ # interface: the output is the output of erasure.
189
+ candidates = Inference::MethodDispatcher::RbsDispatch.without_interface_dispatch do
190
+ resolved.flat_map { |path| analyse_file(path, @environment) }
191
+ end
192
+ demoted = shelter_withheld_classes(
193
+ demote_platform_guarded(
194
+ demote_overridden_base_methods(
195
+ demote_bot_proposals(
196
+ demote_unresolvable_superclasses(
197
+ demote_generic_reopenings(demote_inline_generic_classes(resolve_superclass_spellings(candidates)))
198
+ )
199
+ )
200
+ )
183
201
  )
184
202
  )
185
203
  # Again after the override demotion: a demoted method can leave its file with nothing to write, and a class
@@ -390,6 +408,82 @@ module Rigor
390
408
  end
391
409
  end
392
410
 
411
+ # Issue #3029 — an emittable `-> bot` proposal for an overridden, abstract or template method is skipped
412
+ # ({BotProposalGuard}). It runs before {#demote_overridden_base_methods}, so a subclass's `bot` override this
413
+ # skips counts there as an override that is not emitted, and its parent's declaration is held back too.
414
+ def demote_bot_proposals(candidates)
415
+ guard = nil
416
+ candidates.map do |candidate|
417
+ next candidate unless Classification::EMITTABLE.include?(candidate.classification)
418
+ next candidate if candidate.classification == Classification::INLINE_OVERWRITE
419
+ next candidate unless candidate.inferred_return.is_a?(Type::Bot)
420
+
421
+ guard ||= BotProposalGuard.new(project_tables)
422
+ reason = guard.reason_for(candidate)
423
+ reason ? demoted_candidate(candidate, reason) : candidate
424
+ end
425
+ end
426
+
427
+ # The reasons that withhold a member because of what its class's subclasses or callees do, not because the class
428
+ # itself cannot be declared. `:platform_guarded` counts too (#3035): a class whose every proposal the guard
429
+ # withheld — the raising-stub shape — still needs its shell on a subclass's file, or the subclass's own
430
+ # declaration reads `unresolvable-superclass` against a class this platform does load.
431
+ WITHHELD_FOR_OVERRIDES = %i[overridden_bot abstract_method template_method overridden_by_unsigned_subclass
432
+ platform_guarded].freeze
433
+ private_constant :WITHHELD_FOR_OVERRIDES
434
+
435
+ # Issue #3029 — a class whose members {#demote_bot_proposals} or {#demote_overridden_base_methods} withheld,
436
+ # leaving it nothing to emit, is still declared, as a class shell, so its subclasses keep their proposals.
437
+ # Without it an all-abstract base has nothing to write, {#demote_unresolvable_superclasses} finds no
438
+ # declaration for it, and every subclass proposal is skipped as `unresolvable-superclass`. The shell rides on
439
+ # each file that declares one of its subclasses, so it reaches disk wherever a subclass's members do. The writer
440
+ # spells its header with the run's recorded superclass and namespace kinds. A class the file already declares
441
+ # makes the shell a no-op, and one the loaded RBS declares gets none ({#wholly_withheld_classes}). A class whose
442
+ # own superclass chain is unresolvable is already skipped as `unresolvable-superclass` when this pass runs, so
443
+ # it is never withheld here and gets no shell.
444
+ def shelter_withheld_classes(candidates)
445
+ withheld = wholly_withheld_classes(candidates)
446
+ return candidates if withheld.empty?
447
+
448
+ shells_by_path = withheld_shell_paths(candidates, withheld)
449
+ candidates.map do |candidate|
450
+ extra = shells_by_path.fetch(candidate.path, nil)
451
+ next candidate if extra.nil?
452
+
453
+ shells = (candidate.class_shells + extra.to_a).uniq
454
+ shells.size == candidate.class_shells.size ? candidate : candidate.with_class_shells(shells)
455
+ end
456
+ end
457
+
458
+ # The classes to shelter: a withheld member and nothing to emit, and no declaration in the loaded RBS (`sig/`,
459
+ # the collection, gems). A declared class needs no shell for its subclasses' headers to resolve, and a shell
460
+ # spelling the source's superclass could contradict the declaration's (`RBS::SuperclassMismatchError`).
461
+ def wholly_withheld_classes(candidates)
462
+ by_class = candidates.select(&:class_name).group_by(&:class_name)
463
+ by_class.each_with_object(Set.new) do |(name, rows), acc|
464
+ next unless rows.any? { |c| WITHHELD_FOR_OVERRIDES.include?(c.skip_reason) }
465
+ next if rows.any? { |c| Classification::EMITTABLE.include?(c.classification) }
466
+ next if class_known?(name)
467
+
468
+ acc << name
469
+ end
470
+ end
471
+
472
+ # `{ path => Set[class name] }`: the files each withheld class's shell rides on, which are the files of every
473
+ # candidate whose class descends from it, however far down.
474
+ def withheld_shell_paths(candidates, withheld)
475
+ superclasses = candidates.each_with_object({}) do |candidate, acc|
476
+ (candidate.class_superclasses || {}).each { |name, sup| acc[name] = bare_superclass_name(sup) }
477
+ end
478
+ candidates.each_with_object(Hash.new { |h, k| h[k] = Set.new }) do |candidate, acc|
479
+ next if candidate.class_name.nil?
480
+
481
+ each_ancestor(candidate.class_name, superclasses) do |ancestor|
482
+ acc[candidate.path] << ancestor if withheld.include?(ancestor)
483
+ end
484
+ end
485
+ end
486
+
393
487
  # `[class name, method name]` pairs an UNSIGNED override shadows: for every candidate that will not
394
488
  # be emitted, every ancestor of its class paired with its method name.
395
489
  def unsigned_override_ancestors(candidates, superclasses)
@@ -421,6 +515,54 @@ module Rigor
421
515
  )
422
516
  end
423
517
 
518
+ # Issue #3035 — a file Ruby loads only under a `RUBY_ENGINE` / `RUBY_PLATFORM` / `defined?(…)`
519
+ # require guard is not an ordinary definition for the platform sig-gen cannot pick:
520
+ # `stackprof/truffleruby.rb`'s raising `StackProf.run` stub proposed `-> bot` against the C
521
+ # extension's real signature. {PlatformGuardedFiles} names those files; a candidate from one is
522
+ # demoted when another file also provides the method — the guarded proposal would compete with
523
+ # or duplicate the definition this platform actually loads — or when its inferred return is
524
+ # `bot`, the shape a raising stub takes and never a contract worth writing from a file the
525
+ # current engine may never read. It runs after every demotion — LAST of them — so an earlier
526
+ # pass still sees the guarded candidate as emittable: a platform-conditional override is no
527
+ # "unsigned subclass" ({#demote_overridden_base_methods}), {BotProposalGuard} still reads its
528
+ # `bot` on the discovery tables, and demoting the base for a file this platform never loads
529
+ # would withhold a declaration the guarded file cannot shadow here. It runs BEFORE
530
+ # {#shelter_withheld_classes}, which must see the final emittable set: a class left with
531
+ # nothing to emit only because this pass withheld its last member still needs its shell.
532
+ def demote_platform_guarded(candidates)
533
+ guarded = @platform_guarded_files
534
+ return candidates if guarded.nil? || guarded.empty?
535
+
536
+ providers = method_providers(candidates)
537
+ candidates.map do |candidate|
538
+ next candidate unless platform_guarded_demote?(candidate, guarded, providers)
539
+
540
+ demoted_candidate(candidate, :platform_guarded)
541
+ end
542
+ end
543
+
544
+ # `[class_name, kind, method_name]` → the expanded paths of every candidate providing it, the
545
+ # guarded file's own included — "another definition also provides" is the `!= own` test.
546
+ def method_providers(candidates)
547
+ candidates.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |candidate, acc|
548
+ next if candidate.class_name.nil?
549
+
550
+ acc[[candidate.class_name, candidate.kind, candidate.method_name]] <<
551
+ File.expand_path(candidate.path)
552
+ end
553
+ end
554
+
555
+ def platform_guarded_demote?(candidate, guarded, providers)
556
+ return false unless Classification::EMITTABLE.include?(candidate.classification)
557
+ return false if candidate.class_name.nil?
558
+
559
+ own = File.expand_path(candidate.path)
560
+ return false unless guarded.include?(own)
561
+
562
+ slot = [candidate.class_name, candidate.kind, candidate.method_name]
563
+ candidate.inferred_return&.erase_to_rbs == "bot" || providers[slot].any? { |path| path != own }
564
+ end
565
+
424
566
  # Issue #722 — resolves each recorded superclass token to the class Ruby means by it, and rewrites the
425
567
  # emitted spelling when the two differ.
426
568
  #
@@ -537,11 +679,30 @@ module Rigor
537
679
  unresolvable = found
538
680
  current = candidates.map do |candidate|
539
681
  owner = unresolvable_for(candidate.class_name, unresolvable)
540
- owner ? record_unresolvable_superclass(candidate, owner) : candidate
682
+ candidate = record_unresolvable_superclass(candidate, owner) if owner
683
+ drop_unresolvable_shells(candidate, unresolvable)
541
684
  end
542
685
  end
543
686
  end
544
687
 
688
+ # Issue #2715 — a nested declaration's class shell rides on a surviving sibling's candidate (`class QueryColumn`
689
+ # beside `class Query < BaseRecord` wrapping `class QueryError`), so demoting the container's own candidates
690
+ # leaves the shell, and the writer renders the container around it with the unresolvable superclass: the next
691
+ # `check` fails `RBS::NoSuperclassFoundError`. A shell inside a demoted class goes with it, like a demoted
692
+ # class's own members, and a demoted candidate sheds them too: a shell left on one would still count as emitted
693
+ # ({#declared_by_run}), so a written subclass of the dropped nested class would fail its build.
694
+ # Writing the container bare instead would make it RBS-known without its ancestry, which
695
+ # a later run (or a path-subset run) would take for a resolvable superclass of its subclasses.
696
+ def drop_unresolvable_shells(candidate, unresolvable)
697
+ shells = (candidate.class_shells || []).to_a
698
+ kept = shells.reject do |name|
699
+ owner = unresolvable_for(name, unresolvable)
700
+ @unresolvable_superclasses[owner.first] ||= owner.last if owner
701
+ owner
702
+ end
703
+ kept.size == shells.size ? candidate : candidate.with_class_shells(kept)
704
+ end
705
+
545
706
  def unresolvable_superclass_roots(superclasses, emitted)
546
707
  superclasses.each_with_object({}) do |(name, _sup), acc|
547
708
  root = unresolved_superclass_root(name, superclasses, emitted)
@@ -683,6 +844,8 @@ module Rigor
683
844
  end
684
845
 
685
846
  # #1406 — sig-gen keeps the census's own answer: a declared ivar type read back would echo the declaration.
847
+ # #2489 — `base_scope` names the file, so the class-ivar census joins the writes another file's reopening and
848
+ # the class's ancestors make ({Inference::ScopeIndexer::IvarContributions}), as `rigor check`'s does.
686
849
  def index_file(root, base_scope)
687
850
  Inference::ScopeIndexer.index(
688
851
  root, default_scope: base_scope, ivar_writer_scope: ivar_writer_scope(base_scope), declared_ivar_types: false
@@ -706,7 +869,7 @@ module Rigor
706
869
  parse_result = Prism.parse(source, filepath: path, version: @configuration.target_ruby)
707
870
  return [] if parse_result.errors.any?
708
871
 
709
- scope_index = index_file(parse_result.value, file_base_scope(environment))
872
+ scope_index = index_file(parse_result.value, file_base_scope(environment).with_source_path(path))
710
873
 
711
874
  reset_file_maps
712
875
  @meta_layouts = collect_meta_layouts(scope_index)
@@ -859,6 +1022,8 @@ module Rigor
859
1022
  @in_effect_refinements = nil
860
1023
  out = []
861
1024
  @block_depth = 0
1025
+ @hook_blocks = {}.compare_by_identity
1026
+ @hook_block_prefixes = []
862
1027
  @certain_bodies = Set.new.compare_by_identity
863
1028
  @certain_definers = Set.new.compare_by_identity
864
1029
  mark_certain_body(root.statements)
@@ -873,7 +1038,9 @@ module Rigor
873
1038
  when Prism::ClassNode, Prism::ModuleNode
874
1039
  return if descend_into_namespace?(node, prefix, out)
875
1040
  when Prism::SingletonClassNode
876
- if node.expression.is_a?(Prism::SelfNode) && node.body
1041
+ # `class << M` inside `module M` opens the same singleton as `class << self` (#3155).
1042
+ if node.body && (node.expression.is_a?(Prism::SelfNode) ||
1043
+ Inference::ScopeIndexer.def_receiver_targets_lexical_self?(node.expression, prefix))
877
1044
  mark_certain_body(node.body)
878
1045
  walk_defs(node.body, prefix, true, false, out)
879
1046
  return
@@ -908,12 +1075,30 @@ module Rigor
908
1075
  in_block = node.is_a?(Prism::BlockNode) || node.is_a?(Prism::LambdaNode) ||
909
1076
  node.is_a?(Prism::SingletonClassNode)
910
1077
  @block_depth += 1 if in_block
1078
+ hook_block = @hook_blocks.key?(node)
1079
+ @hook_block_prefixes.push(prefix) if hook_block
911
1080
  node.rigor_each_child do |child|
912
1081
  walk_defs(child, prefix, in_singleton_class, module_function_active, out)
913
1082
  end
1083
+ @hook_block_prefixes.pop if hook_block
914
1084
  @block_depth -= 1 if in_block
915
1085
  end
916
1086
 
1087
+ # Issue #3015 — the `included do … end` / `prepended do … end` statements of a body that runs `extend
1088
+ # ActiveSupport::Concern`: Ruby runs the block in the includer's class body, so a `def self.x` or `class << self`
1089
+ # def there is the includer's singleton method, not the module's, and {#collect_def_node} leaves it out.
1090
+ def mark_hook_blocks(namespace_node, prefix)
1091
+ body = namespace_node.body
1092
+ return unless namespace_node.is_a?(Prism::ModuleNode) && Inference::ScopeIndexer.concern_body?(body, prefix)
1093
+
1094
+ body.body.each do |statement|
1095
+ next unless statement.is_a?(Prism::CallNode) && statement.receiver.nil? &&
1096
+ %i[included prepended].include?(statement.name) && statement.block.is_a?(Prism::BlockNode)
1097
+
1098
+ @hook_blocks[statement] = true
1099
+ end
1100
+ end
1101
+
917
1102
  # [#722](https://github.com/rigortype/rigor/issues/722) residue 3 — the qualified name is built by
918
1103
  # `Source::ConstantPath.declaration_prefix`, the same reader the engine's declaration walks use, so a
919
1104
  # ROOTED header re-anchors here too. Appending the rendered name to the enclosing prefix wrote
@@ -1113,6 +1298,7 @@ module Rigor
1113
1298
  return if namespace_node.body.nil?
1114
1299
 
1115
1300
  mark_certain_body(namespace_node.body)
1301
+ mark_hook_blocks(namespace_node, prefix)
1116
1302
  walk_defs(namespace_node.body, prefix, false, false, out)
1117
1303
  end
1118
1304
 
@@ -1151,7 +1337,12 @@ module Rigor
1151
1337
  return
1152
1338
  end
1153
1339
 
1154
- kind = node.receiver.is_a?(Prism::SelfNode) || in_singleton_class ? :singleton : :instance
1340
+ # `def Foo.x` inside `module Foo` is `def self.x` (#2950), and a def on another object's singleton is no method
1341
+ # of this class (#3155); the rule is the one check indexes defs by.
1342
+ kind = Inference::ScopeIndexer.def_side(node, prefix, in_singleton_class)
1343
+ return if kind.nil?
1344
+ return if kind == :singleton && @hook_block_prefixes.last == prefix
1345
+
1155
1346
  class_name = prefix.join("::")
1156
1347
  if Inference::ModuleFunctionState.sig_gen_module_function?(module_function_active, kind)
1157
1348
  @module_function_methods << node
@@ -1771,7 +1962,7 @@ module Rigor
1771
1962
  return equivalent(path, def_node, class_name, kind, inferred, declared_rbs)
1772
1963
  end
1773
1964
 
1774
- unless tighter?(declared, inferred) && !literal_decline?(inferred, def_node)
1965
+ unless tighter?(declared, inferred) && !literal_decline?(inferred, def_node, declared)
1775
1966
  return equivalent(path, def_node, class_name, kind, inferred, declared_rbs)
1776
1967
  end
1777
1968
 
@@ -2060,11 +2251,24 @@ module Rigor
2060
2251
 
2061
2252
  def structurally_covers?(inferred_member, declared_member)
2062
2253
  return true if inferred_member == declared_member
2254
+ return true if nominal_subclass_of?(inferred_member, declared_member)
2063
2255
 
2064
2256
  result = inferred_member.accepts(declared_member)
2065
2257
  result.respond_to?(:yes?) && result.yes?
2066
2258
  end
2067
2259
 
2260
+ # Issue #2914 — a proposed member that is a subclass of the declared one keeps that member: declared
2261
+ # `Prism::Node?` against an inferred `Prism::DefNode?` narrows what a caller receives without dropping a
2262
+ # branch the caller could observe, unlike a lost `nil` or a lost alternate. Only a bare nominal pair whose
2263
+ # ordering the analyzer's class data decides (`Environment#class_ordering`, RBS hierarchy included) is
2264
+ # read this way; a generic pair or an `:unknown` ordering stays uncovered, so the guard still declines.
2265
+ def nominal_subclass_of?(inferred_member, declared_member)
2266
+ return false unless inferred_member.is_a?(Type::Nominal) && declared_member.is_a?(Type::Nominal)
2267
+ return false unless inferred_member.type_args.empty? && declared_member.type_args.empty?
2268
+
2269
+ @environment&.class_ordering(inferred_member.class_name, declared_member.class_name) == :subclass
2270
+ end
2271
+
2068
2272
  GENERIC_COLLECTION_CLASSES = %w[
2069
2273
  Array Hash Set Range Enumerable Enumerator Enumerator::Lazy
2070
2274
  ].freeze
@@ -2080,8 +2284,8 @@ module Rigor
2080
2284
  # The two reasons a `Type::Constant` the body proves is not the method's contract. Both say the same
2081
2285
  # thing from opposite ends: the precision is real about this body and says nothing about the promise
2082
2286
  # the method makes to its callers.
2083
- def literal_decline?(inferred, def_node)
2084
- computed_literal_tightening?(inferred, def_node) || pins_literal_over_declaration?(inferred)
2287
+ def literal_decline?(inferred, def_node, declared)
2288
+ computed_literal_tightening?(inferred, def_node) || pins_literal_over_declaration?(inferred, declared)
2085
2289
  end
2086
2290
 
2087
2291
  # Issue #837 — a proposal that erases to an RBS LITERAL type never tightens an existing declaration.
@@ -2106,9 +2310,14 @@ module Rigor
2106
2310
  # is refused when ANY member is a literal: a mixed `("a" | Float)` pins `"a"` just as a bare one does.
2107
2311
  # A literal nested inside a shape carrier (`[1, 2]`, a `HashShape` value) is a different question,
2108
2312
  # already answered for the collection declarations that matter by {#narrows_collection_to_shape?}.
2109
- def pins_literal_over_declaration?(inferred)
2313
+ #
2314
+ # A literal the declaration itself spells is not pinned over it (#2914): declared `Prism::Node?` against
2315
+ # a proposed `Prism::DefNode?` carries the same `nil` member the author wrote, so only the narrowed
2316
+ # nominal arm is new. A literal the declaration does not spell is refused as before.
2317
+ def pins_literal_over_declaration?(inferred, declared)
2110
2318
  members = inferred.is_a?(Type::Union) ? inferred.members : [inferred]
2111
- members.any? { |member| rbs_literal?(member) }
2319
+ declared_members = declared.is_a?(Type::Union) ? declared.members : [declared]
2320
+ members.any? { |member| rbs_literal?(member) && !declared_members.include?(member) }
2112
2321
  end
2113
2322
 
2114
2323
  # `Type::Constant#erase_to_rbs` spells the values RBS writes as literal types (`true` / `false` /
@@ -108,6 +108,20 @@ module Rigor
108
108
  )
109
109
  end
110
110
 
111
+ # A copy carrying more class shells: a class whose every member a later pass withheld still has to be
112
+ # declared for its subclasses' headers (#3029).
113
+ def with_class_shells(class_shells)
114
+ self.class.new(
115
+ path: @path, class_name: @class_name, method_name: @method_name, kind: @kind,
116
+ classification: @classification, inferred_return: @inferred_return,
117
+ declared_return_rbs: @declared_return_rbs, rbs: @rbs, skip_reason: @skip_reason,
118
+ namespace_kinds: @namespace_kinds, class_shells: class_shells,
119
+ class_superclasses: @class_superclasses, annotations: @annotations, effect_reason: @effect_reason,
120
+ declared_rbs: @declared_rbs, declared_annotations: @declared_annotations,
121
+ class_type_params: @class_type_params
122
+ )
123
+ end
124
+
111
125
  def to_h
112
126
  {
113
127
  file: path,
@@ -4,6 +4,7 @@ require "prism"
4
4
 
5
5
  require_relative "../environment"
6
6
  require_relative "../project_environment"
7
+ require_relative "../run_scoped_slots"
7
8
  require_relative "../scope"
8
9
  require_relative "../type"
9
10
  require_relative "../source/constant_path"
@@ -56,10 +57,18 @@ module Rigor
56
57
  environment = build_environment(resolved)
57
58
  discovered_classes = preindex_source_classes
58
59
  observations = Hash.new { |h, k| h[k] = [] }
59
- resolved.each do |path|
60
- collect_from_file(path, environment, discovered_classes, observations)
60
+ # ADR-130 WD5 — with interface dispatch off, as {Generator#run} analyses: an argument read through a
61
+ # `Rigor::Plugin` interface member is not written into a parameter type either.
62
+ Inference::MethodDispatcher::RbsDispatch.without_interface_dispatch do
63
+ resolved.each do |path|
64
+ collect_from_file(path, environment, discovered_classes, observations)
65
+ end
61
66
  end
62
67
  observations.transform_values(&:freeze).freeze
68
+ ensure
69
+ # Issue #2869 — release this analysis's run-scoped slots, as every other entry point that runs one does, so
70
+ # a later run (the generator's, under its own interface-dispatch install) starts from none of them.
71
+ RunScopedSlots.release
63
72
  end
64
73
 
65
74
  # Whether `arg` names something {#collect} reads: a directory, or a `.rb` file. The CLI's
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../source/node_children"
6
+
7
+ module Rigor
8
+ module SigGen
9
+ # Issue #3035 — the files a sig-gen run must not read as ordinary definitions: every scanned `.rb`
10
+ # file Ruby loads ONLY through a platform-conditional `require` / `require_relative`.
11
+ #
12
+ # `stackprof.rb` opens with `if RUBY_ENGINE == 'truffleruby'` → `require "stackprof/truffleruby"`,
13
+ # and `truffleruby.rb` re-implements `StackProf.run` / `.results` as raising stubs — on MRI the C
14
+ # extension defines them. Scanning every `.rb` under `lib/` reads that stub file as an ordinary
15
+ # definition and proposes `StackProf.run -> bot` against a method whose real signature returns
16
+ # `Hash[Symbol, untyped]`.
17
+ #
18
+ # {.call} answers the guarded set from the resolved file list alone: it parses each file once,
19
+ # collects the literal `require` / `require_relative` sites, marks a site guarded when it sits
20
+ # inside a conditional whose predicate reads `RUBY_ENGINE`, `RUBY_PLATFORM` or `defined?(…)`,
21
+ # resolves each feature against the scanned set (`require_relative` against the file's own
22
+ # directory; `require` against the file's ancestor directories — the load-path approximation a
23
+ # gem's `lib/` root satisfies), and keeps the files whose EVERY incoming edge is guarded —
24
+ # directly, or through a file already known guarded (a helper `truffleruby.rb` requires
25
+ # unconditionally is still truffleruby-only). A file with no scanned require site is never
26
+ # guarded: an entry point, an autoload target or a `load`ed file cannot be told apart from a
27
+ # guarded one, and the unguarded reading is the one that emits.
28
+ #
29
+ # Limits, deliberately: require sites in files OUTSIDE the scanned set are invisible (a sibling
30
+ # the run did not read may require the file unguarded); `load`, `autoload` and computed features
31
+ # are not edges; a `require` sitting in the guard's own predicate — an `&&` / `||` operand, say —
32
+ # inherits the branch's guarded mark without the walk checking whether Ruby reaches it before the
33
+ # platform test decides; the `begin … rescue LoadError` fallback (`require "json"` else
34
+ # `require "json/pure"`) is not a platform guard, so both of its edges read unguarded; and a file
35
+ # that fails to parse poisons the whole answer — its require edges are unknowable, so {.call}
36
+ # returns an empty set rather than risk a false guarded verdict.
37
+ module PlatformGuardedFiles
38
+ # The constants a conditional predicate may read to make a require platform-dependent.
39
+ GUARD_CONSTANTS = %i[RUBY_ENGINE RUBY_PLATFORM].freeze
40
+ private_constant :GUARD_CONSTANTS
41
+
42
+ # The conditional forms whose whole subtree a platform predicate guards — `if` / `unless` in
43
+ # statement, modifier and ternary spelling, `case` and `case/in` on a guarded subject. Both
44
+ # branches count: `else` of `RUBY_ENGINE == 'x'` is `RUBY_ENGINE != 'x'`, still conditional.
45
+ CONDITIONALS = [Prism::IfNode, Prism::UnlessNode, Prism::CaseNode, Prism::CaseMatchNode].freeze
46
+ private_constant :CONDITIONALS
47
+
48
+ REQUIRE_CALLS = %i[require require_relative].freeze
49
+ private_constant :REQUIRE_CALLS
50
+
51
+ module_function
52
+
53
+ # @param paths — the `.rb` files the run will analyse, as spelled by the run.
54
+ # @param ruby_version — the Prism version to parse with (the configuration's `target_ruby`).
55
+ # @return a Set of `File.expand_path`'d paths that are required only under platform guards.
56
+ def call(paths, ruby_version: nil)
57
+ files = paths.map { |path| File.expand_path(path) }.uniq
58
+ known = files.to_set
59
+ edges = Hash.new { |hash, key| hash[key] = [] }
60
+ files.each do |file|
61
+ sites = require_sites(file, ruby_version, known)
62
+ return Set.new if sites.nil?
63
+
64
+ sites.each { |target, guarded| edges[target] << { from: file, guarded: guarded } }
65
+ end
66
+ guarded_closure(files, edges)
67
+ end
68
+
69
+ # Every `[target, guarded]` pair `file`'s literal require sites resolve to inside `known`;
70
+ # `nil` when the file does not parse (see the class comment for why that voids the answer).
71
+ def require_sites(file, ruby_version, known)
72
+ result = Prism.parse(File.read(file), filepath: file, version: ruby_version)
73
+ return nil if result.errors.any?
74
+
75
+ sites = []
76
+ walk_require_sites(result.value) do |node, guarded|
77
+ target = require_target(file, node, known)
78
+ sites << [target, guarded] if target
79
+ end
80
+ sites
81
+ end
82
+
83
+ # An explicit stack, not recursion: a require site deep in a generated file must not exhaust it.
84
+ def walk_require_sites(root, &block)
85
+ stack = [[root, false]]
86
+ until stack.empty?
87
+ node, guarded = stack.pop
88
+ next unless node.is_a?(Prism::Node)
89
+
90
+ block.call(node, guarded) if require_call?(node)
91
+ child_guarded = guarded || platform_conditional?(node)
92
+ node.rigor_each_child { |child| stack << [child, child_guarded] }
93
+ end
94
+ end
95
+
96
+ # A `require "feature"` / `require_relative "feature"` with one string literal and no receiver —
97
+ # the same strictness {Analysis::RequireRelativeClosure} reads `require_relative` with.
98
+ def require_call?(node)
99
+ return false unless node.is_a?(Prism::CallNode) && node.receiver.nil?
100
+ return false unless REQUIRE_CALLS.include?(node.name)
101
+
102
+ arguments = node.arguments&.arguments
103
+ arguments&.size == 1 && arguments.first.is_a?(Prism::StringNode)
104
+ end
105
+
106
+ # Whether `node` is a conditional whose predicate makes what it guards platform-dependent: a
107
+ # `RUBY_ENGINE` / `RUBY_PLATFORM` read or a `defined?(…)` anywhere in it.
108
+ def platform_conditional?(node)
109
+ return false unless CONDITIONALS.any? { |klass| node.is_a?(klass) }
110
+
111
+ predicate = node.predicate
112
+ !predicate.nil? && platform_expression?(predicate)
113
+ end
114
+
115
+ # Iterative like {#walk_require_sites}: a predicate can chain thousands of `||` operands. A hit is
116
+ # the only answer — a constant that is NOT a guard (`RUBY_VERSION`, `ENV`, a namespaced path) does
117
+ # not end the walk, or `RUBY_ENGINE == "x" && RUBY_VERSION >= "y"` would read unguarded on the
118
+ # strength of which operand the stack pops first.
119
+ def platform_expression?(root)
120
+ stack = [root]
121
+ until stack.empty?
122
+ node = stack.pop
123
+ next unless node.is_a?(Prism::Node)
124
+ return true if node.is_a?(Prism::DefinedNode)
125
+ return true if node.is_a?(Prism::ConstantReadNode) && GUARD_CONSTANTS.include?(node.name)
126
+ # `::RUBY_ENGINE` names the same constant; `Foo::RUBY_ENGINE` is a different one.
127
+ return true if node.is_a?(Prism::ConstantPathNode) && node.parent.nil? &&
128
+ GUARD_CONSTANTS.include?(node.name)
129
+
130
+ node.rigor_each_child { |child| stack << child }
131
+ end
132
+ false
133
+ end
134
+
135
+ # The scanned file `node`'s feature resolves to, or nil when it names nothing the run reads
136
+ # (a gem, a `.so`, a computed path's sibling).
137
+ def require_target(site_file, node, known)
138
+ feature = node.arguments.arguments.first.unescaped
139
+ feature += ".rb" unless feature.end_with?(".rb")
140
+ if node.name == :require_relative
141
+ candidate = File.expand_path(feature, File.dirname(site_file))
142
+ return candidate if known.include?(candidate)
143
+
144
+ return nil
145
+ end
146
+
147
+ # `require` resolves through the load path; the file's own directory upward is the
148
+ # approximation that finds `lib/stackprof/truffleruby.rb` for `require "stackprof/truffleruby"`
149
+ # in `lib/stackprof.rb`, nearest ancestor first.
150
+ dir = File.dirname(site_file)
151
+ loop do
152
+ candidate = File.expand_path(feature, dir)
153
+ return candidate if known.include?(candidate)
154
+
155
+ parent = File.dirname(dir)
156
+ return nil if parent == dir
157
+
158
+ dir = parent
159
+ end
160
+ end
161
+
162
+ # The fixpoint: a file is guarded-only when it has at least one scanned require edge and every
163
+ # edge reaching it is either syntactically guarded or issued from a file already guarded-only.
164
+ def guarded_closure(files, edges)
165
+ guarded = Set.new
166
+ loop do
167
+ added = false
168
+ files.each do |file|
169
+ next if guarded.include?(file)
170
+
171
+ incoming = edges[file]
172
+ next if incoming.empty?
173
+ next unless incoming.all? { |edge| edge[:guarded] || guarded.include?(edge[:from]) }
174
+
175
+ guarded << file
176
+ added = true
177
+ end
178
+ break unless added
179
+ end
180
+ guarded
181
+ end
182
+ end
183
+ end
184
+ end