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
@@ -0,0 +1,347 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../type"
4
+
5
+ module Rigor
6
+ module Inference
7
+ # The element / key / value **content join** shared by every path that observes a collection
8
+ # being content-mutated in place.
9
+ #
10
+ # {MutationWidening} forgets what a mutation falsified — a literal's arity, and (for a
11
+ # slot-rewriting mutator) its value pinning. That half only ever subtracts. This module is the
12
+ # other half: it adds back what the mutation is known to have PUT in the collection, so the
13
+ # continuation carrier covers the mutated contents instead of only the seed's.
14
+ #
15
+ # Two callers share it, and they must agree:
16
+ #
17
+ # - the **block-capture** path (ADR-56 slice C) — `out = [0]; xs.each { |x| out << x }` types
18
+ # `out` as `Array[0 | Integer]`, not `Array[0]`;
19
+ # - the **straight-line** path (issue #560) — `u = [1, 2]; u.push(6)` types `u` as
20
+ # `Array[1 | 2 | Integer]`, not `Array[1 | 2]`. Before the join, `u.last` read back `1 | 2`,
21
+ # the constant-comparison fold turned `u.last == 6` into `Constant[false]`, and a correct
22
+ # program drew a false `flow.always-truthy-condition`. `mail`'s ragel tables are the same root
23
+ # seen from the precision side: `stack = []; stack[top] = cs` widened to `Array[untyped]`
24
+ # where the join reads `Array[Integer]` (issue #533 item 8).
25
+ module ContentJoin
26
+ # Array content-mutators that append/store ELEMENTS. The appended element type is the call's
27
+ # argument type(s); `[]=`'s value is its LAST argument (the keys precede it). Subset of
28
+ # {MutationWidening::ARRAY_MUTATORS}: only the element-INTRODUCING methods (removers /
29
+ # reorderers add no new element evidence and are already covered by the arity-forget).
30
+ ARRAY_CONTENT_ADDERS = %i[
31
+ << push append prepend unshift concat insert []= fill replace
32
+ ].to_set.freeze
33
+
34
+ # Hash content-mutators that store a key→value pair. For `[]=` / `store` the key is the first
35
+ # argument and the value the last.
36
+ HASH_CONTENT_ADDERS = %i[[]= store].to_set.freeze
37
+
38
+ # String content-mutators that append to the buffer. String carries no element parameter, so
39
+ # these contribute nothing to a join — they are listed so the orchestrator recognises them as
40
+ # content mutators (the binding already widens to `String` via normal typing); the join
41
+ # helpers below short-circuit on a non-collection pre-state.
42
+ STRING_CONTENT_ADDERS = %i[<< concat prepend insert replace].to_set.freeze
43
+
44
+ # Every method name that mutates a collection's CONTENT — the union the orchestrators scan a
45
+ # block body for, and the gate the straight-line path types its arguments behind.
46
+ CONTENT_ADDERS = (ARRAY_CONTENT_ADDERS | HASH_CONTENT_ADDERS | STRING_CONTENT_ADDERS).freeze
47
+
48
+ module_function
49
+
50
+ # The element types a single content-mutator call introduces into an Array, given the
51
+ # per-argument types (already typed by the caller in the scope the arguments are evaluated
52
+ # in). `concat`/`replace` take collection arguments, so their element evidence is the
53
+ # arguments' OWN element types unioned; the rest append the argument values directly. Returns
54
+ # `[]` when there is no element evidence (e.g. a `<<` with no resolvable arg).
55
+ def array_added_elements(method_name, arg_types)
56
+ return [] if arg_types.empty?
57
+
58
+ case method_name
59
+ when :concat, :replace
60
+ arg_types.flat_map { |t| collection_element_types(t) }
61
+ when :insert
62
+ # `insert(index, *objs)` — first arg is the position.
63
+ arg_types.drop(1)
64
+ when :[]=
65
+ # `arr[i] = v` / `arr[i, n] = v` — value is the last argument.
66
+ #
67
+ # The SPLICE forms (`arr[i, n] = other` / `arr[range] = other`) store `other`'s ELEMENTS,
68
+ # not `other` itself, so reading the value as one element over-widens: `a[0, 2] = [1, 2]`
69
+ # contributes `Array[Integer]` where `Integer` is the truth. Left alone deliberately —
70
+ # the answer is a superset either way, so it can only cost precision, and splitting the
71
+ # arities here would need the receiver's own element type to unwrap against. Revisit if a
72
+ # corpus site ever reads an element back through a splice-built array.
73
+ [arg_types.last]
74
+ when :fill
75
+ # `fill(value)` — only the no-block single-value form adds a concrete element; block /
76
+ # range forms are conservatively ignored (the arity-forget already widened the binding).
77
+ arg_types.size == 1 ? arg_types : []
78
+ else # << push append prepend unshift
79
+ arg_types
80
+ end
81
+ end
82
+
83
+ # Builds the continuation Array type from the pre-state binding and the appended element
84
+ # types. The floor is `Array[Dynamic[top]]` (the sound empty-seed behaviour) when there is no
85
+ # element evidence at all.
86
+ #
87
+ # **Every arm the seed carries survives, a `Dynamic` arm included.** A gradual seed element is
88
+ # a statement about what the collection ALREADY holds — a parameter declared `Array[untyped]`,
89
+ # a local seeded from a call whose signature returns the same, a literal `[x]` whose slot the
90
+ # engine could not type — and the stores a body adds are evidence about what the body put in,
91
+ # never about what was there first. Dropping the
92
+ # arm once concrete evidence appeared closed a declared `Array[untyped]` parameter to
93
+ # `Array[Integer]` under `[1, 2].each { a.push(rand(9)) }` and drew `undefined method 'upcase'`
94
+ # on `a.first.upcase`, which the declaration licenses (issue #586).
95
+ #
96
+ # What this method therefore never sees is the arity-forget's OWN floor: `widen_tuple` spells
97
+ # an empty `[]` as `Array[untyped]`, and read back through here that `untyped` would be
98
+ # indistinguishable from a declared one. Each caller keeps that floor out at its source by
99
+ # reading the seed from before the widening ran — the block seam from the pre-widen scope, the
100
+ # loop seam from the pre-body scope — so an empty literal contributes no element and `out = [];
101
+ # xs.each { out << x*2 }` still reads `Array[Integer]`. The straight-line seam passes the
102
+ # widened carrier, and adds the same `Dynamic` as its one-store floor anyway.
103
+ #
104
+ # **The rederived carrier replaces only the members it stands for** — see {#array_residue}.
105
+ def join_array_content(pre_state, added_elements)
106
+ elements = collection_element_types(pre_state) + added_elements.compact
107
+ element_t = elements.empty? ? Type::Combinator.untyped : Type::Combinator.union(*elements)
108
+ with_residue(Type::Combinator.nominal_of("Array", type_args: [element_t]), array_residue(pre_state))
109
+ end
110
+
111
+ # Builds the continuation Hash type from the pre-state binding and a list of `[key_type,
112
+ # value_type]` pairs stored by `[]=` / `store`. The seed's key and value arms survive on the
113
+ # same terms as {#join_array_content}'s elements: a declared `Hash[untyped, untyped]` stays
114
+ # gradual on both sides however many pairs the body stores, and a pre-state member that is not
115
+ # a Hash carrier at all survives whole ({#hash_residue}).
116
+ def join_hash_content(pre_state, added_pairs)
117
+ seed_keys, seed_values = hash_shape_key_values(pre_state)
118
+ keys = seed_keys + added_pairs.map(&:first).compact
119
+ values = seed_values + added_pairs.map(&:last).compact
120
+ key_t = keys.empty? ? Type::Combinator.untyped : Type::Combinator.union(*keys)
121
+ value_t = values.empty? ? Type::Combinator.untyped : Type::Combinator.union(*values)
122
+ with_residue(Type::Combinator.nominal_of("Hash", type_args: [key_t, value_t]), hash_residue(pre_state))
123
+ end
124
+
125
+ # ----------------------------------------------------------------
126
+ # Union residue — the members the rederived carrier does NOT stand for (issue #631).
127
+ #
128
+ # A join REPLACES the binding with one freshly built `Nominal`, and that is the right answer
129
+ # only for the pre-state members the mutation actually applied to as a collection of that
130
+ # class. A `Union` seed can carry members that are not collection carriers at all, and those
131
+ # contribute no element / key / value evidence — `collection_element_types` and
132
+ # `hash_shape_key_values` both answer "nothing" for them. Before this rule they were simply
133
+ # gone:
134
+ #
135
+ # out = flag ? u : [2] # Dynamic[top] | [2]
136
+ # [1].each { out << 2 } # -> Array[2]; the whole-variable Dynamic arm dropped
137
+ # out.first.upcase # undefined method `upcase' for 2 -- on correct code
138
+ #
139
+ # This is WD2.9's rule ("a seed's own gradual arm survives the rederivation") one level out:
140
+ # there the surviving `Dynamic` was an ELEMENT of an Array carrier, here it is the whole
141
+ # variable. Both say the same thing — the body's stores are evidence about what the body put
142
+ # in, never about what the variable WAS — and the straight-line seam already reads the union
143
+ # through untouched, so the block and loop seams were the outliers.
144
+ #
145
+ # A member survives whole; it is never re-examined for element evidence, so nothing is
146
+ # double-counted. `Array.new`'s bare `Nominal[Array]` (no type args) IS a carrier and is
147
+ # absorbed, so #615's seed keeps closing rather than growing a second arm.
148
+ #
149
+ # **`nil` is the one member the mutation itself refutes, and it does not survive.** The rule
150
+ # above is a rule about ABSENCE of evidence — the seam cannot say a `Dynamic` or a foreign
151
+ # `Nominal` was mutated as an Array, so it must not rewrite it. `NilClass` defines no content
152
+ # mutator at all, so on every path where the body ran the binding was not nil; only the
153
+ # zero-iteration path keeps the arm, and that path is modelled upstream (the `while` base
154
+ # scope's nil-injection, slice A's `Constant[nil]` fixpoint seed) rather than here. Keeping it
155
+ # measured out as a pure cost: `r = nil; while …; r ||= []; r << x; end; r.each` gained a
156
+ # `call.possible-nil-receiver` on an idiom Rubyists write deliberately, while the genuinely
157
+ # live nil arm is already reported once — at the mutation site, where `r << x` draws the same
158
+ # diagnostic. This is the only member the join can refute without a method lookup it has no
159
+ # environment for; the general "the mutator is undefined on this member" rule would buy
160
+ # rarer shapes for machinery this seam does not have.
161
+ NON_SURVIVING_CLASSES = %w[NilClass].freeze
162
+
163
+ # Pre-state members the rederived Array does not stand for: a whole-variable `Dynamic`, a
164
+ # non-nil `Constant`, a foreign `Nominal`, a `Refined`. Mirrors {#collection_element_types}'s
165
+ # recursion so the absorbed set and the residue partition the union exactly. A `Difference` is
166
+ # absorbed with its base (`non-empty-array[T]` is an Array carrier) and otherwise kept whole,
167
+ # refinement and all.
168
+ def array_residue(type)
169
+ case type
170
+ when Type::Union then type.members.flat_map { |m| array_residue(m) }
171
+ when Type::Tuple then []
172
+ when Type::Nominal then type.class_name == "Array" ? [] : keep_member(type)
173
+ when Type::Difference then array_residue(type.base).empty? ? [] : [type]
174
+ else keep_member(type)
175
+ end
176
+ end
177
+
178
+ # The Hash-side twin of {#array_residue}, mirroring {#hash_shape_key_values}. A bare
179
+ # `Nominal[Hash]` with no type args is a carrier here even though it yields no key/value
180
+ # evidence — `Hash.new` must close like `Array.new`, not grow an arm.
181
+ def hash_residue(type)
182
+ case type
183
+ when Type::Union then type.members.flat_map { |m| hash_residue(m) }
184
+ when Type::HashShape then []
185
+ when Type::Nominal then type.class_name == "Hash" ? [] : keep_member(type)
186
+ when Type::Difference then hash_residue(type.base).empty? ? [] : [type]
187
+ else keep_member(type)
188
+ end
189
+ end
190
+
191
+ # `[type]`, unless the mutation refutes the member outright — see {NON_SURVIVING_CLASSES}.
192
+ def keep_member(type)
193
+ NON_SURVIVING_CLASSES.include?(evidence_class(type)) ? [] : [type]
194
+ end
195
+
196
+ def with_residue(carrier, residue)
197
+ residue.empty? ? carrier : Type::Combinator.union(*residue, carrier)
198
+ end
199
+
200
+ # ----------------------------------------------------------------
201
+ # Seed-admissibility — the straight-line join's signature gate (issue #560).
202
+ #
203
+ # A join is not free: it grows the carrier's element union, and a carrier that grows a member
204
+ # the enclosing method's HAND-WRITTEN signature does not admit turns a correct program into a
205
+ # `def.return-type-mismatch`. haml's temple builders are the corpus shape —
206
+ #
207
+ # def compile_html(node) # sig: (untyped) -> Array[:multi]
208
+ # temple = [:multi]
209
+ # temple << [:static, "<style>\n"]
210
+ # temple
211
+ # end
212
+ #
213
+ # — where joining the appended tuple precisely reads `Array[:multi | [:static, String]]` and
214
+ # draws a mismatch against `Array[:multi]` on eight sites. (PR #561 hit the same wall from the
215
+ # other direction and had to scope value-pin widening away from adders to avoid it.) A gradual
216
+ # member does NOT rescue that: `Array[:multi | [:static, String] | untyped]` is still rejected,
217
+ # because every non-`Dynamic` member is judged on its own. The gate is what keeps the foreign
218
+ # member out; the caller's gradual floor is a separate concern and neither substitutes for the
219
+ # other.
220
+ #
221
+ # So added evidence is admitted per member, against the class set the SEED already carries:
222
+ #
223
+ # - the seed's class set already admits the added member's class — the collection is
224
+ # homogeneous in the sense that matters, and the member joins as itself
225
+ # (`u = [1, 2]; u.push(6)` contributes `Integer`);
226
+ # - it does not — the collection is provably heterogeneous, and between the literal seed and
227
+ # the author's signature the engine has no ground to adjudicate. It contributes
228
+ # `Dynamic[top]` instead of a foreign precise member;
229
+ # - the seed carries nothing to contradict — an empty literal, or a slot the engine cannot type
230
+ # — and every member is admitted as itself.
231
+ #
232
+ # The second case only ever replaces a WRONG precise element type with a gradual one, so it is
233
+ # a soundness improvement paid for in opacity on exactly the sites that were lying.
234
+ #
235
+ # What this module does NOT decide is whether the resulting parameter may be CLOSED. That is
236
+ # the caller's call, and it turns on whether the caller saw every store —
237
+ # {MutationWidening#join_added_elements} carries the rule and the counter-example.
238
+
239
+ # `added`, with every member the seed's class set does not admit replaced by `Dynamic[top]`.
240
+ #
241
+ # A seed that carries nothing to contradict admits everything: an empty literal has no class
242
+ # set, and a `Dynamic` member means the engine could not type that slot, so it cannot rule
243
+ # anything out either.
244
+ #
245
+ # Both sides are flattened through their `Union` members before matching. Judging a union
246
+ # wholesale would floor `["a", 1]`-shaped evidence against an `Integer` seed even though its
247
+ # `Integer` half is admissible, and `evidence_class` has no answer for a `Union` at all — so
248
+ # the wholesale reading is strictly worse and no simpler.
249
+ def admissible_evidence(seed_members, added)
250
+ members = seed_members.flat_map { |m| union_members(m) }
251
+ return added if members.empty? || members.any?(Type::Dynamic)
252
+
253
+ classes = members.filter_map { |m| evidence_class(m) }.to_set
254
+ added.flat_map { |type| admit_members(union_members(type), classes) }
255
+ end
256
+
257
+ # Each member of one added type, kept when its class is admitted and floored when it is not.
258
+ # A type whose members are all admitted returns them unchanged, so the common single-member
259
+ # case is `[type]`.
260
+ def admit_members(members, classes)
261
+ members.map { |m| classes.include?(evidence_class(m)) ? m : Type::Combinator.untyped }
262
+ end
263
+
264
+ # The class name a type carrier commits its values to, or `nil` when it commits to none. A
265
+ # `nil` answer is never admitted: an unnameable carrier cannot be shown compatible with the
266
+ # seed, so it takes the gradual floor.
267
+ def evidence_class(type)
268
+ case type
269
+ when Type::Nominal then type.class_name
270
+ when Type::Constant then type.value.class.name
271
+ when Type::Tuple then "Array"
272
+ when Type::HashShape then "Hash"
273
+ end
274
+ end
275
+
276
+ def union_members(type)
277
+ type.is_a?(Type::Union) ? type.members : [type]
278
+ end
279
+
280
+ # Element types carried by a collection binding, regardless of which carrier holds them: a
281
+ # `Tuple` lists them, a `Nominal[Array, [E]]` has one element param, a bare `Array` /
282
+ # anything else yields none.
283
+ #
284
+ # A `Difference` reads through to its base: `non-empty-array[T]` holds `T`s, and the seams
285
+ # that read a seed from BEFORE the arity-forget ran (see {#join_array_content}) meet the
286
+ # refinement carrier itself where they used to meet the base the widening had left. Declining
287
+ # it there would hand the continuation the widened base ALONE, with every appended arm missing
288
+ # — a wrong type, not a wide one.
289
+ def collection_element_types(type)
290
+ case type
291
+ when Type::Tuple
292
+ type.elements
293
+ when Type::Nominal
294
+ type.class_name == "Array" ? type.type_args : []
295
+ when Type::Union
296
+ # A loop's single-pass join can union the widened collection with its un-widened literal
297
+ # seed (`Array[0] | [0]`); pull element evidence from every Array-ish member.
298
+ type.members.flat_map { |m| collection_element_types(m) }
299
+ when Type::Difference
300
+ collection_element_types(type.base)
301
+ else
302
+ []
303
+ end
304
+ end
305
+
306
+ # `[keys, values]` evidence from a Hash-ish pre-state binding — a `HashShape` (literal pairs)
307
+ # or a `Nominal[Hash, [K, V]]`. A `Difference` (`non-empty-hash[K, V]`) reads through to its
308
+ # base, as {#collection_element_types} does for the Array side.
309
+ def hash_shape_key_values(type)
310
+ case type
311
+ when Type::HashShape
312
+ return [[], []] if type.pairs.empty?
313
+
314
+ [[key_union_for(type.pairs.keys)], type.pairs.values]
315
+ when Type::Nominal
316
+ type.class_name == "Hash" && type.type_args.size == 2 ? [[type.type_args[0]], [type.type_args[1]]] : [[], []]
317
+ when Type::Union
318
+ type.members.each_with_object([[], []]) do |m, (ks, vs)|
319
+ mk, mv = hash_shape_key_values(m)
320
+ ks.concat(mk)
321
+ vs.concat(mv)
322
+ end
323
+ when Type::Difference
324
+ hash_shape_key_values(type.base)
325
+ else
326
+ [[], []]
327
+ end
328
+ end
329
+
330
+ # Maps a literal Ruby key set to a union of the corresponding type carriers. Symbol / String /
331
+ # Integer / Float keys widen to their class nominal; the `true` / `false` / `nil` singleton
332
+ # keys keep their constant carrier (the constant IS the class's whole value set, and `nil`
333
+ # reads better than `NilClass` in a widened `Hash[K, V]`). We deliberately do NOT fold the
334
+ # widenable kinds to a `Constant<:k1> | Constant<:k2>` union — that would be a precision
335
+ # improvement that complicates the widening contract; the goal there is to LOSE precision, not
336
+ # to record a new fact set.
337
+ def key_union_for(keys)
338
+ carriers = keys.map do |key|
339
+ next Type::Combinator.constant_of(key) if [true, false, nil].include?(key)
340
+
341
+ Type::Combinator.nominal_of(key.class.name)
342
+ end.uniq
343
+ carriers.size == 1 ? carriers.first : Type::Combinator.union(*carriers)
344
+ end
345
+ end
346
+ end
347
+ end
@@ -6,7 +6,7 @@ module Rigor
6
6
  # the live node itself. When an incremental recheck rebuilds the discovery index from cached per-file seed
7
7
  # bundles (ADR-85 WD2), the `def_nodes` / `singleton_def_nodes` tables carry handles for every unchanged
8
8
  # file's methods: the file was never parsed this run, so there is no live node to store. A handle records
9
- # the three things needed to resolve or fingerprint it without the node:
9
+ # the four things needed to resolve, fingerprint, or scope it without the node:
10
10
  #
11
11
  # - `path` + `node_id` — the resolution key. The per-run parse memo ({Scope#resolve_def_handle}) parses the
12
12
  # file once and returns the `Prism::DefNode` whose `node_id` matches (Prism `node_id` is stable across
@@ -16,8 +16,20 @@ module Rigor
16
16
  # - `fingerprint` — the SHA-256 of the def's source slice, captured when the bundle was built. This lets
17
17
  # `Runner#symbol_fingerprints` read the change-detection fingerprint (ADR-46 slice 4) off the handle
18
18
  # without re-parsing the file — the one value-deref consumer besides the three accessor choke points.
19
+ # - `nesting` — the `Module.nesting` in force where the def is WRITTEN (issue #681's chain), `[]` for a
20
+ # top-level def (issue #716: that is Ruby's answer, not the absence of one), and nil only when no
21
+ # declaration walk recorded any. The cross-file `def_nestings` table is keyed by node IDENTITY, and
22
+ # {DefNodeResolver} hands back a node from its OWN parse, so no identity-keyed table built by the
23
+ # discovery walk can ever contain it. Travelling on the handle is what lets the resolver re-attach the
24
+ # chain to the node it mints, instead of the re-walk falling back to peeling the receiver's qualified
25
+ # name — which cannot tell a compact `class Admin::Maker` from the nested spelling, so an unchanged
26
+ # file's callee answered a different constant on the warm path than on a cold run ([#707]).
19
27
  #
20
- # Marshal-clean by construction (Integer + Strings), so it rides the `IncrementalSnapshot` blob directly.
21
- DefHandle = Data.define(:path, :node_id, :name, :fingerprint)
28
+ # A required member rather than a defaulted one: a writer that has a chain and forgets to pass it
29
+ # reintroduces exactly the divergence above, silently, so the constructor is where that must fail loudly.
30
+ #
31
+ # Marshal-clean by construction (Integer + Strings + a String array), so it rides the
32
+ # `IncrementalSnapshot` blob directly.
33
+ DefHandle = Data.define(:path, :node_id, :name, :fingerprint, :nesting)
22
34
  end
23
35
  end
@@ -28,15 +28,24 @@ module Rigor
28
28
  private_constant :MEMO_KEY
29
29
 
30
30
  # Installs a fresh per-run resolution memo, restoring the previous one on exit (always, even on a raise).
31
- # `nodes` caches the resolved node per `(path, node_id)`; `indexes` caches each file's parse index.
31
+ # `nodes` caches the resolved node per `(path, node_id)`; `indexes` caches each file's parse index;
32
+ # `nestings` re-attaches each handle's recorded `Module.nesting` to the node this module minted for it
33
+ # ({.rehydrated_nesting}).
32
34
  def self.with_run
33
35
  previous = Thread.current[MEMO_KEY]
34
- Thread.current[MEMO_KEY] = { nodes: {}, indexes: {} }
36
+ Thread.current[MEMO_KEY] = { nodes: {}, indexes: {}, nestings: {}.compare_by_identity }
35
37
  yield
36
38
  ensure
37
39
  Thread.current[MEMO_KEY] = previous
38
40
  end
39
41
 
42
+ # Whether a per-run memo scope is in force. The rehydrated `Module.nesting` ({.rehydrated_nesting}) is
43
+ # recorded only inside one, so this names the precondition the internal spec's "within the resolver's
44
+ # per-run memo scope" clause states — and lets a spec assert that no handle-consuming entry point
45
+ # resolves outside it, which is the difference between the peel fallback being a documented edge and
46
+ # being silently reachable from production.
47
+ def self.run_scope? = !Thread.current[MEMO_KEY].nil?
48
+
40
49
  # Resolves `handle` to a `Prism::DefNode` (the same object across the run for a given (path, node_id)), or
41
50
  # nil. A non-handle argument is returned unchanged, so callers can pass a table value that is either a live
42
51
  # node (cold / re-walked file) or a handle (unchanged file) without branching.
@@ -50,8 +59,58 @@ module Rigor
50
59
  nodes = memo[:nodes]
51
60
  return nodes[key] if nodes.key?(key)
52
61
 
53
- nodes[key] = locate(handle, memo[:indexes])
62
+ node = nodes[key] = locate(handle, memo[:indexes])
63
+ record_nesting(memo, node, handle.nesting)
64
+ node
65
+ end
66
+
67
+ # Issue #707 — the recorded `Module.nesting` for a node this module minted from a {DefHandle}, or nil.
68
+ #
69
+ # This is a REHYDRATION of `Scope::DiscoveryIndex#discovered_def_nestings`, not a rival source for the
70
+ # same question, and the single reader ({Inference::ExpressionTyper#recorded_def_nesting}) may consult
71
+ # the table first and fall through here without shadowing a live answer. Two independent facts license
72
+ # that order, and the tempting third one is FALSE:
73
+ #
74
+ # 1. OBJECT PROVENANCE — no node can be a key in both. {.build_file_index} runs its OWN `Prism.parse`,
75
+ # and both tables are `compare_by_identity`, so a node this module mints is never the object any
76
+ # walk over the analyzer's own parse produced. This holds per NODE and needs nothing about files.
77
+ # 2. AGREEMENT — where both tables answer for the same DEF (through different objects), they answer the
78
+ # same chain. A bundle is only reused when the file's content SHA-256 matches
79
+ # ({Cache::FileDigest.hexdigest}, the digest tier, not the stat one), and `build_def_nestings` is a
80
+ # pure function of that file's AST, so identical bytes yield an identical chain.
81
+ #
82
+ # What is NOT true — and was asserted here before it was measured — is that a file takes exactly ONE of
83
+ # the two branches per run. It does so in the cross-file pre-pass only. An UNCHANGED file re-analysed as
84
+ # a dependent is served from its bundle there AND walked live by `ScopeIndexer#merge_def_node_tables`
85
+ # for its own per-file index, contributing keys to both tables in one `Runner#run`. Fact 1 is what makes
86
+ # that harmless, so a future change must preserve the separate parse, not the file-level split.
87
+ #
88
+ # `--verify-incremental` is the standing detector for the whole property: it compares the warm and cold
89
+ # diagnostics project-wide, so a disagreement surfaces as a reported incremental-only / full-only
90
+ # diagnostic rather than as a silently preferred answer.
91
+ #
92
+ # Outside a run scope ({.with_run} never entered) nothing is recorded and the reader keeps
93
+ # `Reflection.lexical_nesting_chain`'s peel fallback — the same gradual answer that path already gives.
94
+ # Every entry point that CONSUMES a handle runs inside `with_run`; `spec/rigor/inference/
95
+ # def_node_resolver_spec.rb` pins that, so a new one added outside a run scope fails there rather than
96
+ # silently reverting to the peel.
97
+ def self.rehydrated_nesting(node)
98
+ memo = Thread.current[MEMO_KEY]
99
+ memo && memo[:nestings][node]
100
+ end
101
+
102
+ # Files the chain the bundle recorded against the node just minted for it. Issue #716 — an EMPTY chain
103
+ # is recorded like any other: it is the bundle saying the def is written at the top level, and dropping
104
+ # it would leave the warm path peeling the caller's namespace where a cold run resolves at the top
105
+ # level. Only `nil` — a bundle that recorded nothing for the def — is skipped, and
106
+ # `Cache::IncrementalSnapshot::SCHEMA` 17 is what keeps a pre-#716 blob (where `nil` MEANT top level)
107
+ # from being read as one.
108
+ def self.record_nesting(memo, node, nesting)
109
+ return if node.nil? || nesting.nil?
110
+
111
+ memo[:nestings][node] = nesting
54
112
  end
113
+ private_class_method :record_nesting
55
114
 
56
115
  # Finds the node for `handle` using a per-file `{node_id => DefNode}` + `{name => DefNode}` index cache.
57
116
  def self.locate(handle, index_cache)