rigortype 0.3.5 → 0.3.7

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 (266) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  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 +442 -11
  14. data/docs/handbook/02-everyday-types.md +1 -1
  15. data/docs/handbook/04-tuples-and-shapes.md +1 -1
  16. data/docs/handbook/08-understanding-errors.md +1 -1
  17. data/docs/handbook/09-plugins.md +2 -2
  18. data/docs/handbook/10-sorbet.md +1 -1
  19. data/docs/handbook/README.md +2 -2
  20. data/docs/handbook/appendix-go.md +1 -1
  21. data/docs/handbook/appendix-java-csharp.md +2 -2
  22. data/docs/handbook/appendix-mypy.md +1 -1
  23. data/docs/handbook/appendix-protocols-and-structural-typing.md +2 -2
  24. data/docs/handbook/appendix-rust.md +1 -1
  25. data/docs/handbook/appendix-type-theory.md +2 -2
  26. data/docs/handbook/appendix-typescript.md +4 -4
  27. data/docs/manual/02-cli-reference.md +29 -16
  28. data/docs/manual/03-configuration.md +1 -1
  29. data/docs/manual/04-diagnostics.md +12 -0
  30. data/docs/manual/05-inspecting-types.md +20 -8
  31. data/docs/manual/07-plugins.md +4 -0
  32. data/docs/manual/08-skills.md +1 -1
  33. data/docs/manual/11-ci.md +1 -1
  34. data/docs/manual/12-caching.md +1 -1
  35. data/docs/manual/15-type-protection-coverage.md +1 -1
  36. data/docs/manual/18-removing-dead-code.md +13 -4
  37. data/docs/manual/19-effect-labels.md +19 -12
  38. data/docs/manual/README.md +2 -2
  39. data/docs/manual/plugins/rigor-actionmailer.md +4 -4
  40. data/docs/manual/plugins/rigor-actionpack.md +61 -0
  41. data/docs/manual/plugins/rigor-activejob.md +3 -3
  42. data/docs/manual/plugins/rigor-activerecord.md +78 -5
  43. data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
  44. data/docs/manual/plugins/rigor-rails-i18n.md +5 -5
  45. data/docs/manual/plugins/rigor-rspec.md +6 -2
  46. data/docs/manual/plugins/rigor-sidekiq.md +22 -0
  47. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
  48. data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
  49. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
  50. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
  51. data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
  52. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
  53. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
  54. data/lib/rigor/analysis/check_rules.rb +383 -48
  55. data/lib/rigor/analysis/crash_signature.rb +116 -0
  56. data/lib/rigor/analysis/dependency_recorder.rb +18 -3
  57. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  58. data/lib/rigor/analysis/diagnostic.rb +17 -6
  59. data/lib/rigor/analysis/effects_cache_probe.rb +132 -0
  60. data/lib/rigor/analysis/incremental.rb +28 -0
  61. data/lib/rigor/analysis/incremental_session.rb +44 -9
  62. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
  63. data/lib/rigor/analysis/reachability/graph.rb +44 -16
  64. data/lib/rigor/analysis/reachability/plugin_roots.rb +11 -8
  65. data/lib/rigor/analysis/reachability/scan.rb +27 -7
  66. data/lib/rigor/analysis/reachability/scan_cache.rb +130 -0
  67. data/lib/rigor/analysis/result.rb +32 -0
  68. data/lib/rigor/analysis/run_cache_key.rb +53 -1
  69. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +93 -1
  70. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +14 -8
  71. data/lib/rigor/analysis/runner/pool_coordinator.rb +34 -0
  72. data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
  73. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -2
  74. data/lib/rigor/analysis/runner.rb +298 -38
  75. data/lib/rigor/analysis/worker_session.rb +10 -1
  76. data/lib/rigor/bleeding_edge.rb +7 -6
  77. data/lib/rigor/builtins/hkt_builtins.rb +45 -6
  78. data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
  79. data/lib/rigor/builtins/static_return_refinements.rb +20 -1
  80. data/lib/rigor/cache/descriptor.rb +42 -2
  81. data/lib/rigor/cache/file_digest.rb +20 -2
  82. data/lib/rigor/cache/incremental_snapshot.rb +40 -3
  83. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +31 -8
  84. data/lib/rigor/cache/store.rb +10 -1
  85. data/lib/rigor/cli/check_command.rb +67 -48
  86. data/lib/rigor/cli/coverage_command.rb +28 -24
  87. data/lib/rigor/cli/coverage_mutation.rb +13 -17
  88. data/lib/rigor/cli/coverage_scan.rb +47 -6
  89. data/lib/rigor/cli/doc_links.rb +100 -0
  90. data/lib/rigor/cli/docs_command.rb +32 -2
  91. data/lib/rigor/cli/effects_command.rb +27 -2
  92. data/lib/rigor/cli/effects_diff_renderer.rb +82 -12
  93. data/lib/rigor/cli/effects_explain_renderer.rb +6 -3
  94. data/lib/rigor/cli/effects_snapshot_command.rb +52 -7
  95. data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
  96. data/lib/rigor/cli/fused_protection_report.rb +23 -2
  97. data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
  98. data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
  99. data/lib/rigor/cli/mutation_protection_report.rb +31 -2
  100. data/lib/rigor/cli/sig_gen_command.rb +18 -0
  101. data/lib/rigor/cli/type_of_command.rb +188 -41
  102. data/lib/rigor/cli/type_of_renderer.rb +72 -3
  103. data/lib/rigor/cli/type_scan_command.rb +10 -1
  104. data/lib/rigor/cli/unused_command.rb +65 -16
  105. data/lib/rigor/cli.rb +22 -9
  106. data/lib/rigor/configuration.rb +7 -4
  107. data/lib/rigor/effects/definition_lines.rb +100 -0
  108. data/lib/rigor/effects/envelope_check.rb +18 -1
  109. data/lib/rigor/effects/liskov_check.rb +17 -8
  110. data/lib/rigor/effects/plugin_facts.rb +1 -1
  111. data/lib/rigor/effects/scanner.rb +2 -3
  112. data/lib/rigor/effects/signature_sources.rb +13 -2
  113. data/lib/rigor/effects/snapshot.rb +53 -21
  114. data/lib/rigor/effects/snapshot_diff.rb +26 -3
  115. data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
  116. data/lib/rigor/environment/lockfile_resolver.rb +15 -5
  117. data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
  118. data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
  119. data/lib/rigor/environment/rbs_loader.rb +525 -46
  120. data/lib/rigor/environment.rb +38 -15
  121. data/lib/rigor/inference/acceptance.rb +172 -20
  122. data/lib/rigor/inference/captured_locals.rb +62 -0
  123. data/lib/rigor/inference/content_join.rb +347 -0
  124. data/lib/rigor/inference/def_handle.rb +15 -3
  125. data/lib/rigor/inference/def_node_resolver.rb +62 -3
  126. data/lib/rigor/inference/expression_typer.rb +1020 -141
  127. data/lib/rigor/inference/fork_map.rb +6 -1
  128. data/lib/rigor/inference/hkt_reducer.rb +12 -1
  129. data/lib/rigor/inference/hkt_registry.rb +46 -0
  130. data/lib/rigor/inference/hkt_sugar_translator.rb +93 -0
  131. data/lib/rigor/inference/index_write_widening.rb +48 -0
  132. data/lib/rigor/inference/indexed_narrowing.rb +14 -0
  133. data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
  134. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
  135. data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
  136. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
  137. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
  138. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
  139. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
  140. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
  141. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
  142. data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
  143. data/lib/rigor/inference/method_dispatcher.rb +155 -14
  144. data/lib/rigor/inference/method_parameter_binder.rb +3 -3
  145. data/lib/rigor/inference/mutation_widening.rb +244 -174
  146. data/lib/rigor/inference/narrowing.rb +331 -27
  147. data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
  148. data/lib/rigor/inference/pre_eval_constants.rb +15 -0
  149. data/lib/rigor/inference/precision_scanner.rb +5 -2
  150. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  151. data/lib/rigor/inference/rbs_type_translator.rb +89 -39
  152. data/lib/rigor/inference/scope_indexer.rb +1493 -140
  153. data/lib/rigor/inference/statement_evaluator.rb +276 -91
  154. data/lib/rigor/inference/struct_fold_safety.rb +186 -27
  155. data/lib/rigor/inference/synthetic_method_scanner.rb +7 -0
  156. data/lib/rigor/inference/version_guard.rb +229 -0
  157. data/lib/rigor/language_server/selection_range_provider.rb +1 -1
  158. data/lib/rigor/plugin/base.rb +3 -2
  159. data/lib/rigor/plugin/inflector.rb +14 -5
  160. data/lib/rigor/plugin/io_boundary.rb +107 -4
  161. data/lib/rigor/plugin/loader.rb +14 -0
  162. data/lib/rigor/protection/analysis_guard.rb +60 -0
  163. data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
  164. data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
  165. data/lib/rigor/protection/discovery_seed.rb +1 -1
  166. data/lib/rigor/protection/measurement_integrity.rb +36 -0
  167. data/lib/rigor/protection/mutation_scanner.rb +61 -6
  168. data/lib/rigor/protection/mutator.rb +121 -18
  169. data/lib/rigor/rbs_extended/envelope_scanner.rb +9 -0
  170. data/lib/rigor/reflection.rb +182 -30
  171. data/lib/rigor/scope/discovery_index.rb +39 -1
  172. data/lib/rigor/scope.rb +385 -4
  173. data/lib/rigor/sig_gen/classification.rb +12 -1
  174. data/lib/rigor/sig_gen/generator.rb +254 -5
  175. data/lib/rigor/sig_gen/renderer.rb +1 -6
  176. data/lib/rigor/sig_gen/writer.rb +3 -0
  177. data/lib/rigor/source/constant_path.rb +79 -2
  178. data/lib/rigor/triage/catalogue.rb +1 -1
  179. data/lib/rigor/type/combinator.rb +10 -0
  180. data/lib/rigor/type/maybe.rb +47 -0
  181. data/lib/rigor/type/refined.rb +1 -2
  182. data/lib/rigor/type/result.rb +53 -0
  183. data/lib/rigor/type.rb +2 -0
  184. data/lib/rigor/version.rb +1 -1
  185. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
  186. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
  187. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
  188. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
  189. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
  190. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
  191. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
  192. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
  193. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
  194. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
  195. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
  196. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
  197. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
  198. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
  199. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
  200. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
  201. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
  202. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
  203. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
  204. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
  205. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
  206. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
  207. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
  208. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
  209. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
  210. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
  211. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
  212. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
  213. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
  214. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
  215. data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
  216. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
  217. data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
  218. data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
  219. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
  220. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
  221. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
  222. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
  223. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
  224. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
  225. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
  226. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
  227. data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
  228. data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
  229. data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
  230. data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
  231. data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
  232. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
  233. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
  234. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  235. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
  236. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
  237. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
  238. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
  239. data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
  240. data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
  241. data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
  242. data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
  243. data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
  244. data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
  245. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
  246. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
  247. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
  248. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
  249. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
  250. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
  251. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
  252. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
  253. data/sig/rigor/analysis/baseline.rbs +1 -1
  254. data/sig/rigor/environment.rbs +3 -0
  255. data/sig/rigor/inference.rbs +4 -1
  256. data/sig/rigor/plugin/io_boundary.rbs +2 -0
  257. data/sig/rigor/reflection.rbs +3 -1
  258. data/sig/rigor/scope.rbs +25 -1
  259. data/sig/rigor/type.rbs +95 -1
  260. data/sig/rigor.rbs +16 -0
  261. data/skills/rigor-ci-setup/SKILL.md +2 -2
  262. data/skills/rigor-editor-setup/SKILL.md +2 -2
  263. data/skills/rigor-mcp-setup/SKILL.md +2 -2
  264. data/skills/rigor-monkeypatch-resolve/SKILL.md +1 -1
  265. data/skills/rigor-plugin-review/SKILL.md +3 -3
  266. metadata +73 -3
data/lib/rigor/scope.rb CHANGED
@@ -16,16 +16,16 @@ module Rigor
16
16
  # #type_of(node), the Rigor counterpart of PHPStan's $scope->getType($node).
17
17
  #
18
18
  # See docs/internal-spec/inference-engine.md for the binding contract.
19
- # rubocop:disable Metrics/ClassLength,Metrics/ParameterLists
19
+ # rubocop:disable-next Metrics/ClassLength,Metrics/ParameterLists
20
20
  class Scope
21
21
  attr_reader :environment, :locals, :fact_store, :self_type,
22
22
  :ivars, :cvars, :globals,
23
23
  :indexed_narrowings, :method_chain_narrowings,
24
24
  :declaration_sourced,
25
25
  :source_path, :discovery, :struct_fold_safe_locals,
26
- :opaque_block_self,
26
+ :opaque_block_self, :lexical_nesting,
27
27
  :dynamic_origins, :local_origins, :ivar_origins,
28
- :void_origins,
28
+ :void_origins, :plugin_typed_calls,
29
29
  :optimistic_origins, :optimistic_locals, :optimistic_ivars
30
30
 
31
31
  # ADR-53 Track A — the seed-time discovery tables live on the {DiscoveryIndex} the scope carries by a single
@@ -50,6 +50,50 @@ module Rigor
50
50
  def discovered_superclasses = @discovery.discovered_superclasses
51
51
  def discovered_includes = @discovery.discovered_includes
52
52
  def discovered_class_sources = @discovery.discovered_class_sources
53
+ # Issue #644 — `{qualified constant name => Set[declaring file]}`; seeded only on an ADR-46 recording run.
54
+ def constant_sources = @discovery.constant_sources
55
+ def published_constant_names = @discovery.published_constant_names
56
+ def local_constant_names = @discovery.local_constant_names
57
+
58
+ # Issue #644 — true when the constant reference `name` (as written: `MODE` or `AppConfig::MODE`) is
59
+ # published by the cross-file value-constant table and is NOT assigned by the file being analysed: a
60
+ # value this file's author cannot see the declaration of. {Analysis::CheckRules::PublishedConstantGuard}
61
+ # is the only caller — a rule MUST ask through the guard rather than re-derive the question here, exactly
62
+ # as ADR-58's mark is asked through `DeclarationSourcedGuard`.
63
+ #
64
+ # The two halves match at deliberately different granularities, because the two errors are not
65
+ # symmetric. The PUBLISHED half matches on the last segment and so over-answers (a reader of a nested
66
+ # `MyApp::MODE` answers true for a top-level `MODE`) — over-answering only ever WITHHOLDS a firing, the
67
+ # safe direction, and it is the grammar the ADR-46 negative keys already use. The LOCAL half is an
68
+ # exemption, so over-answering there would UN-withhold: matching it on the last segment too would let an
69
+ # unrelated `Local::MODE` in this file make a read of `AppConfig::MODE` fire.
70
+ #
71
+ # The exemption therefore matches a **suffix relation on qualified names**, not the spelling and not the
72
+ # last segment: a local assignment exempts a reference when it IS that reference or ENDS with `::` plus
73
+ # it. That is what a lexically relative spelling resolves to — inside `module AppConfig`, the reference
74
+ # `Nested::X` is the file's own `AppConfig::Nested::X` — and it subsumes the bare case (`OWNED` matched
75
+ # by `Owner::OWNED`) without matching the unrelated `Local::MODE` above. It is deliberately a relation
76
+ # over already-qualified names rather than a second lexical ladder: the engine's ladder
77
+ # (`Reflection.resolve_constant_type`) answers with a TYPE and not with the candidate that won, and
78
+ # reimplementing the walk here is the very thing that made the receiver resolution wrong once already.
79
+ def published_constant?(name)
80
+ names = @discovery.published_constant_names
81
+ return false if names.empty?
82
+ return false unless names.include?(name.split("::").last)
83
+
84
+ !locally_declared_constant?(name)
85
+ end
86
+
87
+ def locally_declared_constant?(reference_name)
88
+ local = @discovery.local_constant_names
89
+ return false if local.empty?
90
+ return true if local.include?(reference_name)
91
+
92
+ suffix = "::#{reference_name}"
93
+ local.any? { |name| name.end_with?(suffix) }
94
+ end
95
+ private :locally_declared_constant?
96
+
53
97
  def data_member_layouts = @discovery.data_member_layouts
54
98
  def struct_member_layouts = @discovery.struct_member_layouts
55
99
  # ADR-67 WD3 — call-site-inferred parameter types, keyed by `[class_name, method_name, kind]`.
@@ -135,6 +179,23 @@ module Rigor
135
179
  self
136
180
  end
137
181
 
182
+ # Issue #653 — records that a plugin's `dynamic_return` answered the return type for `node`, i.e. that
183
+ # `MethodDispatcher`'s plugin tier (which sits ABOVE `RbsDispatch`) is where the site's type came from.
184
+ # `Analysis::CheckRules` consults it so `call.undefined-method` does not then read the receiver's RBS to
185
+ # prove the same call undefined: the engine already decided the plugin outranks the signature here, and a
186
+ # partially-declared receiver is not a closed world (ADR-5). Mirrors {#record_void_origin} exactly —
187
+ # identity-keyed advisory metadata on a table threaded by reference through `#join` / `#rebuild`,
188
+ # excluded from `==` / `hash`, so it never forks a flow-dedup or cache key.
189
+ def record_plugin_typed_call(node)
190
+ @plugin_typed_calls[node] = true
191
+ self
192
+ end
193
+
194
+ # Issue #653 — whether a plugin answered the return type for `node` on this run.
195
+ def plugin_typed_call?(node)
196
+ @plugin_typed_calls.key?(node)
197
+ end
198
+
138
199
  def initialize(
139
200
  environment:, locals:,
140
201
  fact_store: Analysis::FactStore.empty,
@@ -149,10 +210,12 @@ module Rigor
149
210
  source_path: nil,
150
211
  struct_fold_safe_locals: EMPTY_FOLD_SAFE,
151
212
  opaque_block_self: false,
213
+ lexical_nesting: nil,
152
214
  dynamic_origins: {}.compare_by_identity,
153
215
  local_origins: EMPTY_ORIGINS,
154
216
  ivar_origins: EMPTY_ORIGINS,
155
217
  void_origins: {}.compare_by_identity,
218
+ plugin_typed_calls: {}.compare_by_identity,
156
219
  optimistic_origins: {}.compare_by_identity,
157
220
  optimistic_locals: EMPTY_ORIGINS,
158
221
  optimistic_ivars: EMPTY_ORIGINS
@@ -171,10 +234,12 @@ module Rigor
171
234
  @source_path = source_path
172
235
  @struct_fold_safe_locals = struct_fold_safe_locals
173
236
  @opaque_block_self = opaque_block_self
237
+ @lexical_nesting = lexical_nesting
174
238
  @dynamic_origins = dynamic_origins
175
239
  @local_origins = local_origins
176
240
  @ivar_origins = ivar_origins
177
241
  @void_origins = void_origins
242
+ @plugin_typed_calls = plugin_typed_calls
178
243
  @optimistic_origins = optimistic_origins
179
244
  @optimistic_locals = optimistic_locals
180
245
  @optimistic_ivars = optimistic_ivars
@@ -264,6 +329,29 @@ module Rigor
264
329
  rebuild(self_type: type)
265
330
  end
266
331
 
332
+ # Issue #652 — installs the body's REAL `Module.nesting`, innermost first, as recorded at declaration
333
+ # time by `Inference::StatementEvaluator` (`["Admin::UsersController"]` for a compact
334
+ # `class Admin::UsersController`, `["Admin::UsersController", "Admin"]` for the nested spelling). A
335
+ # qualified name cannot be un-flattened back into the chain that produced it, so the chain has to travel
336
+ # with the scope; `Reflection.lexical_nesting_chain` is the single reader.
337
+ #
338
+ # The value is THREE-valued, and issue #716 is what the third state is for:
339
+ #
340
+ # - a non-empty chain — the body's real `Module.nesting`, recorded at declaration time;
341
+ # - `[]` — recorded, and the body is written at the TOP LEVEL. Ruby's `Module.nesting` there is empty,
342
+ # so a top-level `def helper = Post.new` names `::Post` no matter which namespace calls it.
343
+ # `Reflection.resolve_constant_type` consults the top level FIRST for such a scope and only then the
344
+ # caller-derived rungs, instead of letting the caller's namespace answer;
345
+ # - `nil` — NOT recorded, for a scope built outside a declaration walk (a callee body re-entered through
346
+ # `Scope#evaluate`, a plugin-constructed scope). The reader then peels `self_type`'s class name — a
347
+ # gradual answer, never a new firing.
348
+ #
349
+ # Collapsing `[]` into `nil` is precisely the bug #716 fixed, so a writer that has an empty chain must
350
+ # pass it rather than skip the stamp.
351
+ def with_lexical_nesting(chain)
352
+ rebuild(lexical_nesting: chain)
353
+ end
354
+
267
355
  # ADR-28 / ADR-52 slice 5a — per-file source path carried on the scope. The analyzer stamps the current file's
268
356
  # path onto the seed scope; nested rebuilds propagate it so plugin rules (`dynamic_return`'s `file_methods:`
269
357
  # gate, sigil checks) can resolve "which file does this call site belong to?" without thread-locals.
@@ -602,6 +690,15 @@ module Rigor
602
690
  site
603
691
  end
604
692
 
693
+ # Issue #735 — the singleton-side mirror of {#user_def_site_for}: the `"path:line"` where the project
694
+ # defines `class_name.method_name`, or nil. Records the same cross-file edge, keyed `"Class.method"`.
695
+ def user_singleton_def_site_for(class_name, method_name)
696
+ table = @discovery.discovered_singleton_def_sources[class_name.to_s]
697
+ site = table && table[method_name.to_sym]
698
+ record_cross_file_method(class_name, method_name, site, singleton: true) if Analysis::DependencyRecorder.active?
699
+ site
700
+ end
701
+
605
702
  # ADR-24 slice 2 — per-class table mapping a fully qualified user-class name to its superclass name AS WRITTEN
606
703
  # at the `class Foo < Bar` declaration (`"Bar"`, possibly a qualified `"A::B"`). Populated by `ScopeIndexer` —
607
704
  # per-file plus the cross-file project pre-pass — and consumed by
@@ -647,6 +744,223 @@ module Rigor
647
744
  @discovery.discovered_includes[class_name.to_s] || []
648
745
  end
649
746
 
747
+ # ADR-24 slice 2 — the user-side ancestor walk: resolves `method_name` against `class_name`'s own `def`s,
748
+ # then breadth-first through its included / prepended modules (mixins first, as Ruby orders them) and its
749
+ # superclass chain. Returns `[def_node, owner]`, or `[nil, nil]` when nothing in the project defines it.
750
+ # RBS-known / third-party ancestors resolve to no discovered class and end that branch.
751
+ #
752
+ # It lives HERE, not in a consumer, because it reads nothing but this scope's frozen discovery index
753
+ # (`user_def_for` / `includes_of` / `superclass_of` / the `discovered_*` tables) — the same property that
754
+ # lets `ExpressionTyper` memoise its results run-wide. Two callers depend on that single answer:
755
+ # `ExpressionTyper#compute_user_def_with_owner`, which wraps it in the run-scoped memo for the dispatch hot
756
+ # path, and {Inference::MethodDispatcher::StructMaterialization}, whose `.with` guard must see a `with`
757
+ # contributed by an included module. They forked once — the guard resolved own-class-only while the
758
+ # dispatcher walked ancestors — and answered differently about the same receiver expression (#598 review).
759
+ #
760
+ # `name_memo` is a CACHE, not semantics: the per-edge as-written-name resolutions, so a caller resolving
761
+ # many methods against one class pays each ancestor edge once. Omit it and the walk is identical, just
762
+ # uncached.
763
+ def user_def_through_ancestors(class_name, method_name, name_memo: {})
764
+ queue = [class_name.to_s]
765
+ seen = {}
766
+ visited = 0
767
+ until queue.empty?
768
+ current = queue.shift
769
+ next if current.nil? || seen[current]
770
+
771
+ seen[current] = true
772
+ visited += 1
773
+ return ancestor_walk_gave_up if visited > ANCESTOR_WALK_LIMIT
774
+
775
+ found = user_def_for(current, method_name)
776
+ return [found, current] if found
777
+
778
+ enqueue_ancestors(current, queue, name_memo)
779
+ end
780
+ [nil, nil]
781
+ end
782
+
783
+ # Issue #731 — the singleton-side twin of {#user_def_through_ancestors}: resolves `method_name` against
784
+ # `class_name`'s own `def self.` / `class << self` bodies, then up the SUPERCLASS chain. Returns
785
+ # `[def_node, owner]`, or `[nil, nil]`.
786
+ #
787
+ # Superclasses only, and that is the whole shape of class-method inheritance in Ruby: a subclass
788
+ # inherits its parent's class methods, while an `include`d module contributes INSTANCE methods and its
789
+ # own `def self.x` is not callable on the includer. `extend M` needs no walk either — `ScopeIndexer`
790
+ # folds an extend into the extender's own singleton entries before the table is frozen — so the one
791
+ # edge this misses is a module's singleton surface reached through some other route, which nothing
792
+ # resolves today.
793
+ def singleton_def_through_ancestors(class_name, method_name, name_memo: {})
794
+ queue = [class_name.to_s]
795
+ seen = {}
796
+ visited = 0
797
+ until queue.empty?
798
+ current = queue.shift
799
+ next if current.nil? || seen[current]
800
+
801
+ seen[current] = true
802
+ visited += 1
803
+ return ancestor_walk_gave_up if visited > ANCESTOR_WALK_LIMIT
804
+
805
+ found = singleton_def_for(current, method_name)
806
+ return [found, current] if found
807
+
808
+ enqueue_ancestors(current, queue, name_memo, mixins: false)
809
+ end
810
+ [nil, nil]
811
+ end
812
+
813
+ # The BFS node cap; a hierarchy past it gives up rather than walking unboundedly (ADR-41 WD4).
814
+ ANCESTOR_WALK_LIMIT = 100
815
+
816
+ def ancestor_walk_gave_up
817
+ Inference::BudgetTrace.hit(Inference::BudgetTrace::ANCESTOR_WALK_LIMIT)
818
+ [nil, nil]
819
+ end
820
+
821
+ # Issue #723 — the same ancestor walk as {#user_def_through_ancestors}, asked of the DISCOVERY table
822
+ # rather than the def-node table: does the project define `method_name` on `class_name` or on any
823
+ # ancestor the project itself declares? The two tables are not interchangeable —
824
+ # `discovered_methods` also carries `define_method`, `attr_*` and the whole singleton side, none of
825
+ # which contributes a `Prism::DefNode` — and the suppression probe shared by the `call.*` check rules
826
+ # needs the broader one.
827
+ #
828
+ # Why it must walk at all: `Analysis::CheckRules#source_declared_method?` asked `discovered_method?`,
829
+ # which is keyed on the receiver's OWN name, while the typer resolves the same call through this
830
+ # ancestry. A project class whose `sig/` declares it without its project superclass therefore drew
831
+ # `call.undefined-method` on a method `dump_type` resolved on the same line of the same run — writing
832
+ # MORE RBS made the run worse, the incentive #653 removed for plugin-typed calls.
833
+ #
834
+ # `kind: :singleton` follows the superclass chain ONLY: a class method is inherited by a subclass, but
835
+ # an `include`d module contributes instance methods and its own `def self.x` is not callable on the
836
+ # includer. `extend M` is not lost to that narrowing — `Inference::ScopeIndexer` folds an extend into
837
+ # the extender's own singleton entries before this table is frozen.
838
+ def discovered_method_through_ancestors?(class_name, method_name, kind, name_memo: {})
839
+ return false if class_name.nil?
840
+
841
+ queue = [class_name.to_s]
842
+ seen = {}
843
+ visited = 0
844
+ until queue.empty?
845
+ current = queue.shift
846
+ next if current.nil? || seen[current]
847
+
848
+ seen[current] = true
849
+ visited += 1
850
+ # Budget exhaustion is uncertainty, not absence: answering "not declared" here would hand a
851
+ # `call.undefined-method` a fired verdict it has no evidence for. Suppress and record the hit.
852
+ if visited > ANCESTOR_WALK_LIMIT
853
+ Inference::BudgetTrace.hit(Inference::BudgetTrace::ANCESTOR_WALK_LIMIT)
854
+ return true
855
+ end
856
+
857
+ return true if discovered_method?(current, method_name, kind)
858
+
859
+ enqueue_ancestors(current, queue, name_memo, mixins: kind != :singleton)
860
+ end
861
+ false
862
+ end
863
+
864
+ # Pushes `current`'s direct ancestors onto the BFS queue: included / prepended modules first (Ruby places
865
+ # mixins nearer than the superclass), then the superclass. Each as-written name is resolved against the
866
+ # nesting `current`'s declaration header is written in ({#ancestor_name_candidates}); names that resolve
867
+ # to no project class/module are dropped. `mixins: false` walks the superclass chain alone, for the
868
+ # singleton-side question where an `include` contributes nothing.
869
+ def enqueue_ancestors(current, queue, name_memo, mixins: true)
870
+ if mixins
871
+ includes_of(current).each do |raw|
872
+ resolved = resolve_ancestor_class_name(current, raw, name_memo)
873
+ queue.push(resolved) if resolved
874
+ end
875
+ end
876
+ raw_super = superclass_of(current)
877
+ return if raw_super.nil?
878
+
879
+ resolved_super = resolve_ancestor_class_name(current, raw_super, name_memo)
880
+ queue.push(resolved_super) if resolved_super
881
+ end
882
+
883
+ # Resolves an ancestor name AS WRITTEN (`"Base"`, or a qualified `"A::B"`) to a project-discovered class,
884
+ # following Ruby's `Module.nesting` constant lookup from the subclass's declaration HEADER, innermost
885
+ # first, then bare. nil when no candidate names a discovered user class.
886
+ def resolve_ancestor_class_name(subclass_qualified, raw_ancestor, name_memo)
887
+ by_subclass = (name_memo[subclass_qualified] ||= {})
888
+ return by_subclass[raw_ancestor] if by_subclass.key?(raw_ancestor)
889
+
890
+ by_subclass[raw_ancestor] = compute_ancestor_class_name(subclass_qualified, raw_ancestor)
891
+ end
892
+ private :ancestor_walk_gave_up, :resolve_ancestor_class_name
893
+
894
+ # Issue #682 — the candidate names an ancestor spelled `raw_ancestor` can denote in `subclass_qualified`,
895
+ # in Ruby's lookup order, most-qualified first and the bare name last. The single owner of the question:
896
+ # three walks resolved a superclass / include name apiece ({#enqueue_ancestors} for method lookup,
897
+ # `Reflection.resolve_ancestor_name` for the constant ladder's ancestor rung, and
898
+ # `Analysis::CheckRules`' override-visibility rule), and each derived the order by PEELING the
899
+ # subclass's qualified name one `::` segment at a time.
900
+ #
901
+ # That peel is the NESTED spelling's answer, given to both spellings. Ruby evaluates a superclass
902
+ # expression before entering the body, so the cref that governs it is the one the declaration's HEADER
903
+ # is written in — `Module.nesting` minus the declaration's own entry. For `module Admin; class Widget <
904
+ # Base` that is `["Admin"]` and the peel agrees; for the compact `class Admin::Widget < Base` written at
905
+ # the top level it is EMPTY, and the peel searched an `Admin::Base` Ruby never looks at — a wrong class
906
+ # for the ancestor rung of constant lookup and for method dispatch alike.
907
+ #
908
+ # The header nesting is a property of the DECLARATION, not of the reader, so it is read from the
909
+ # discovery table `Inference::ScopeIndexer` records it in rather than from this scope's own
910
+ # `#lexical_nesting`. Answering off the reader's chain would be wrong past the first hop of the ancestor
911
+ # BFS (the chain describes the reader's class, not the ancestor being resolved) and would silently
912
+ # corrupt the run-scoped ancestor memos in `Inference::ExpressionTyper#class_graph_buckets` and
913
+ # `Reflection.ancestor_constant_scopes`, both of which key on the class name alone because this walk is
914
+ # a pure function of the frozen discovery tables.
915
+ #
916
+ # The peel survives as the FALLBACK, for a class no declaration walk recorded — a scope seeded without
917
+ # the table, an anonymous `Class.new` name, a class reached only through RBS. It is the same answer this
918
+ # walk gave before, so an unrecorded class is unchanged rather than degraded.
919
+ #
920
+ # An include / prepend name is written INSIDE the body, so Ruby also tries `<subclass>::<raw>` ahead of
921
+ # everything here. That rung is deliberately still missing — it was missing from the peel too, and
922
+ # adding it is a widening this change does not need.
923
+ def ancestor_name_candidates(subclass_qualified, raw_ancestor)
924
+ # Issue #722 residue 1 / #637 — a ROOTED ancestor name is anchored at the top level and has no
925
+ # candidate list: `class Rooted < ::Base` names `::Base` wherever it is written, exactly as
926
+ # `Source::ConstantPath.declaration_prefix` already re-anchors a rooted HEADER. The marker is the
927
+ # leading `::` `Inference::ScopeIndexer.recorded_ancestor_name` preserves.
928
+ raw = raw_ancestor.to_s
929
+ return [raw.delete_prefix("::")] if raw.start_with?("::")
930
+
931
+ recorded = @discovery.discovered_header_nestings[subclass_qualified.to_s]
932
+ entries = recorded || peeled_header_nesting(subclass_qualified)
933
+ entries.map { |entry| "#{entry}::#{raw_ancestor}" } << raw_ancestor.to_s
934
+ end
935
+
936
+ # The pre-#682 reading of a qualified class name: every proper prefix of it, innermost first, as if the
937
+ # class had been declared one `module` keyword per segment.
938
+ def peeled_header_nesting(subclass_qualified)
939
+ segments = subclass_qualified.to_s.split("::")
940
+ (segments.length - 1).downto(1).map { |i| segments[0, i].join("::") }
941
+ end
942
+ private :peeled_header_nesting
943
+
944
+ def compute_ancestor_class_name(subclass_qualified, raw_ancestor)
945
+ ancestor_name_candidates(subclass_qualified, raw_ancestor).find { |c| known_user_class?(c) }
946
+ end
947
+
948
+ # Issue #723 — `discovered_methods` is in the list because the other three miss a class whose only
949
+ # project-side content is CLASS methods: `class Base; def self.build = :built; end` records no instance
950
+ # def node, no superclass and no include, so the ancestor-name resolver did not recognise `Base` as a
951
+ # project class at all and every walk through it ended one hop early. The table that answers "the
952
+ # project defines something here" on both sides is this one.
953
+ def known_user_class?(name)
954
+ discovered_superclasses.key?(name) || discovered_def_nodes.key?(name) ||
955
+ discovered_includes.key?(name) || discovered_methods.key?(name)
956
+ end
957
+ # `known_user_class?` is PUBLIC (issue #530): `Inference::ExpressionTyper`'s external-gem ancestry probe
958
+ # has to ask the same "is this name a project class?" question this walk asks, and answering it with a
959
+ # narrower predicate (`discovered_classes` alone) would read a project class as having left the project
960
+ # and attribute its root name to a same-named locked gem — a WRONG provenance label, which is the one
961
+ # direction ADR-82 forbids.
962
+ private :ancestor_walk_gave_up, :compute_ancestor_class_name
963
+
650
964
  # Records, for a resolved cross-class ancestry read, every file that declares `class_name` (its declaration /
651
965
  # reopening / superclass / include sites). The `discovered_class_sources` table it reads is populated only by
652
966
  # the cross-file project pre-pass ({Inference::ScopeIndexer.discovered_def_index_for_paths}) and only when
@@ -660,6 +974,22 @@ module Rigor
660
974
  end
661
975
  private :record_class_dependency
662
976
 
977
+ # Issue #644 — the positive ADR-46 edge for a cross-file VALUE constant. `Reflection.constant_type_at`
978
+ # calls this the moment a candidate resolves through `in_source_constants`, so the reader depends on the
979
+ # file that wrote the constant: editing the literal, or deleting the file, re-checks the reader. Recorded
980
+ # WITHOUT a symbol (a file-granularity / ancestry edge) because a constant's published value is a
981
+ # declaration-level fact of the whole file, which is also the granularity
982
+ # `ScopeIndexer#append_constant_signature` moves the declaration signature at.
983
+ #
984
+ # `constant_sources` is seeded only when dependency recording is on, so on every ordinary run the table
985
+ # is empty and this is one Hash miss; the caller additionally gates on the recorder being active.
986
+ def record_constant_dependency(name)
987
+ sites = @discovery.constant_sources[name]
988
+ return if sites.nil?
989
+
990
+ sites.each { |site| Analysis::DependencyRecorder.read_site(site) }
991
+ end
992
+
663
993
  # v0.1.2 — per-class table mapping `method_name (Symbol) → :public | :private | :protected`. Populated by
664
994
  # `ScopeIndexer` for every `def` it sees inside a class body, with the visibility taken from the surrounding
665
995
  # `private` / `protected` / `public` modifier state plus any post-hoc `private :name, ...` named-argument
@@ -815,10 +1145,12 @@ module Rigor
815
1145
  source_path: @source_path,
816
1146
  struct_fold_safe_locals: @struct_fold_safe_locals,
817
1147
  opaque_block_self: @opaque_block_self,
1148
+ lexical_nesting: @lexical_nesting,
818
1149
  dynamic_origins: @dynamic_origins,
819
1150
  local_origins: @local_origins,
820
1151
  ivar_origins: @ivar_origins,
821
1152
  void_origins: @void_origins,
1153
+ plugin_typed_calls: @plugin_typed_calls,
822
1154
  optimistic_origins: @optimistic_origins,
823
1155
  optimistic_locals: @optimistic_locals,
824
1156
  optimistic_ivars: @optimistic_ivars
@@ -834,10 +1166,12 @@ module Rigor
834
1166
  source_path: source_path,
835
1167
  struct_fold_safe_locals: struct_fold_safe_locals,
836
1168
  opaque_block_self: opaque_block_self,
1169
+ lexical_nesting: lexical_nesting,
837
1170
  dynamic_origins: dynamic_origins,
838
1171
  local_origins: local_origins,
839
1172
  ivar_origins: ivar_origins,
840
1173
  void_origins: void_origins,
1174
+ plugin_typed_calls: plugin_typed_calls,
841
1175
  optimistic_origins: optimistic_origins,
842
1176
  optimistic_locals: optimistic_locals,
843
1177
  optimistic_ivars: optimistic_ivars
@@ -875,16 +1209,64 @@ module Rigor
875
1209
  # flow-live and `possible-nil-receiver` fires as before.
876
1210
  declaration_sourced: join_declaration_sourced(other),
877
1211
  source_path: source_path,
1212
+ # Issue #589 — the fold-safe set MUST survive a merge. It was simply absent from this constructor
1213
+ # call, so it fell back to the empty default and every `if` / `while` in a method silently revoked
1214
+ # struct member folding for the whole body after it: `s = S.new("r"); while c; i += 1; end; s.raw`
1215
+ # answered `Dynamic[top]` while the straight-line sibling folded to `"r"`. The carrier was never
1216
+ # the casualty — `s` still reads `S(raw: "r")` at the merge — only the grant that lets a read
1217
+ # consult it, which is why the shape looked like carrier erasure from the outside.
1218
+ #
1219
+ # The set is a property of the method BODY (a static scan over its root, stamped once by
1220
+ # `ScopeIndexer` / `StatementEvaluator` and threaded by `rebuild`), so both arms of a merge
1221
+ # normally carry the identical set and the intersection below is that set. Intersecting rather
1222
+ # than unioning is the FP-safe direction anyway: the grant licenses a FOLD, so retaining one an
1223
+ # arm did not have could fold a stale constant, while dropping one only costs precision.
1224
+ struct_fold_safe_locals: join_struct_fold_safe(other),
1225
+ # Issue #600 — the sibling omission of the same class, and the same failure mode: absent from this
1226
+ # constructor call, the flag fell back to `false`, so ANY merge inside a block re-armed the #316/#319
1227
+ # decline gate. `RSpec.describe do; if c; end; output; end` bound a cross-file top-level `def output`
1228
+ # again after the `if`, which is the wrong-bind direction the gate exists to prevent — the block's real
1229
+ # `self` may have gained a public same-named method by `include`, and that wins the MRO over the
1230
+ # private `Object` def.
1231
+ #
1232
+ # `||`, not `&&`: opacity is the DECLINING state, so keeping it once either arm has it is the FP-safe
1233
+ # merge. Like the fold-safe set this is normally a property of the enclosing block (stamped once at
1234
+ # block entry by `entering_opaque_block` and inherited through `rebuild`), so both arms usually carry
1235
+ # the same value and the `||` is that value.
1236
+ opaque_block_self: @opaque_block_self || other.opaque_block_self,
1237
+ # Issue #652 — the recorded `Module.nesting`, stamped once at body entry and threaded by `rebuild`
1238
+ # exactly as the fold-safe set is. A join is a control-flow merge INSIDE one body, so both arms
1239
+ # always carry the identical chain; taking this scope's is that chain. Dropping it would silently
1240
+ # re-enable the name-peel for everything after the first `if` in a body.
1241
+ lexical_nesting: @lexical_nesting,
878
1242
  dynamic_origins: @dynamic_origins,
879
1243
  local_origins: join_origins(@local_origins, other.local_origins),
880
1244
  ivar_origins: join_origins(@ivar_origins, other.ivar_origins),
881
1245
  void_origins: @void_origins,
1246
+ plugin_typed_calls: @plugin_typed_calls,
882
1247
  optimistic_origins: @optimistic_origins,
883
1248
  optimistic_locals: join_origins(@optimistic_locals, other.optimistic_locals),
884
1249
  optimistic_ivars: join_origins(@optimistic_ivars, other.optimistic_ivars)
885
1250
  )
886
1251
  end
887
1252
 
1253
+ # Issue #589 — intersect the struct fold-safe grants. Zero-alloc on the common path, where both arms
1254
+ # carry the same frozen set the body scan stamped.
1255
+ # A `nil` on EITHER side yields no grants. The field is keyword-defaulted to `EMPTY_FOLD_SAFE` and
1256
+ # threaded by `rebuild`, so `nil` should be unreachable — but the two sides are deliberately symmetric
1257
+ # rather than one arm trusting the other, because the asymmetric form kept every grant when the OTHER
1258
+ # side was nil, which is the one direction that can fold a stale value.
1259
+ def join_struct_fold_safe(other)
1260
+ mine = @struct_fold_safe_locals
1261
+ theirs = other.struct_fold_safe_locals
1262
+ return EMPTY_FOLD_SAFE if mine.nil? || theirs.nil?
1263
+ return mine if mine.equal?(theirs) || mine == theirs
1264
+ return EMPTY_FOLD_SAFE if mine.empty? || theirs.empty?
1265
+
1266
+ intersected = mine & theirs
1267
+ intersected.empty? ? EMPTY_FOLD_SAFE : intersected.freeze
1268
+ end
1269
+
888
1270
  # ADR-82 WD1 — merge two branches' propagated origins (self wins on a name conflict; advisory metadata, so
889
1271
  # a disagreement is harmless). Zero-alloc when either side is empty — the common case, keeping the join
890
1272
  # hot-path cost negligible.
@@ -986,5 +1368,4 @@ module Rigor
986
1368
  filtered.size == @method_chain_narrowings.size ? @method_chain_narrowings : filtered.freeze
987
1369
  end
988
1370
  end
989
- # rubocop:enable Metrics/ClassLength,Metrics/ParameterLists
990
1371
  end
@@ -16,6 +16,11 @@ module Rigor
16
16
  EQUIVALENT = :equivalent
17
17
  SKIPPED = :skipped
18
18
 
19
+ # The classifications that actually produce a line in a generated `sig/`. Consulted by the renderer,
20
+ # the writer and the generator's own post-passes; it lived as a private constant in the first two,
21
+ # which is one fork too many for a list this load-bearing.
22
+ EMITTABLE = [NEW_FILE, NEW_METHOD, TIGHTER_RETURN].freeze
23
+
19
24
  DIAGNOSTIC_IDS = {
20
25
  NEW_FILE => "sig.generated.new-file",
21
26
  NEW_METHOD => "sig.generated.new-method",
@@ -29,7 +34,13 @@ module Rigor
29
34
  # The generator rendered a line `rbs` itself rejects. Skipping the method keeps sig-gen useful on a
30
35
  # project with one pathological def, where failing the command outright would deny the user every other
31
36
  # signature; the count is reported so the defect is not silent. See {SigGen::RbsValidity}.
32
- unrenderable_rbs: "sig.skipped.unrenderable-rbs"
37
+ unrenderable_rbs: "sig.skipped.unrenderable-rbs",
38
+ # Issue #735 — the class's superclass chain does not terminate in a class the RBS environment knows,
39
+ # so emitting the declaration would collapse the class on the next run rather than type it.
40
+ unresolvable_superclass: "sig.skipped.unresolvable-superclass",
41
+ # Issue #744 — a project subclass overrides this method and its override is NOT emitted, so the
42
+ # declaration would be inherited by a subclass it does not describe.
43
+ overridden_by_unsigned_subclass: "sig.skipped.overridden-by-unsigned-subclass"
33
44
  }.freeze
34
45
  end
35
46
  end