rigortype 0.2.8 → 0.3.0

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 (207) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/core_overlay/csv.rbs +28 -0
  4. data/data/core_overlay/psych.rbs +22 -0
  5. data/docs/handbook/01-getting-started.md +9 -1
  6. data/docs/handbook/02-everyday-types.md +4 -1
  7. data/docs/handbook/03-narrowing.md +2 -1
  8. data/docs/handbook/04-tuples-and-shapes.md +7 -3
  9. data/docs/handbook/06-classes.md +18 -9
  10. data/docs/handbook/08-understanding-errors.md +4 -3
  11. data/docs/handbook/11-sig-gen.md +11 -1
  12. data/docs/handbook/appendix-mypy.md +4 -3
  13. data/docs/install.md +14 -6
  14. data/docs/manual/01-installation.md +99 -4
  15. data/docs/manual/02-cli-reference.md +65 -19
  16. data/docs/manual/03-configuration.md +34 -2
  17. data/docs/manual/04-diagnostics.md +23 -0
  18. data/docs/manual/07-plugins.md +5 -5
  19. data/docs/manual/09-editor-integration.md +4 -4
  20. data/docs/manual/11-ci.md +51 -0
  21. data/docs/manual/12-caching.md +65 -0
  22. data/docs/manual/14-rails-quickstart.md +4 -2
  23. data/docs/manual/15-type-protection-coverage.md +21 -0
  24. data/docs/manual/plugins/rigor-actionpack.md +1 -1
  25. data/docs/manual/plugins/rigor-activerecord.md +12 -5
  26. data/docs/manual/plugins/rigor-rails-routes.md +11 -0
  27. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
  28. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
  29. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
  30. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
  31. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
  32. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
  33. data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
  34. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  35. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
  36. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
  37. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
  38. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +93 -0
  39. data/lib/rigor/analysis/check_rules.rb +557 -95
  40. data/lib/rigor/analysis/dependency_recorder.rb +93 -9
  41. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  42. data/lib/rigor/analysis/incremental_session.rb +456 -51
  43. data/lib/rigor/analysis/path_expansion.rb +42 -0
  44. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
  45. data/lib/rigor/analysis/rule_catalog.rb +226 -1
  46. data/lib/rigor/analysis/run_cache_key.rb +68 -0
  47. data/lib/rigor/analysis/run_cache_probe.rb +72 -0
  48. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
  49. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
  50. data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
  51. data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
  52. data/lib/rigor/analysis/runner.rb +343 -68
  53. data/lib/rigor/analysis/severity_stamp.rb +43 -0
  54. data/lib/rigor/analysis/worker_session.rb +30 -2
  55. data/lib/rigor/bleeding_edge.rb +37 -8
  56. data/lib/rigor/builtins/regex_refinement.rb +36 -1
  57. data/lib/rigor/cache/descriptor.rb +88 -28
  58. data/lib/rigor/cache/file_digest.rb +158 -0
  59. data/lib/rigor/cache/incremental_snapshot.rb +41 -5
  60. data/lib/rigor/cache/rbs_descriptor.rb +56 -10
  61. data/lib/rigor/cache/store.rb +207 -60
  62. data/lib/rigor/ci_detector.rb +90 -0
  63. data/lib/rigor/cli/annotate_command.rb +15 -9
  64. data/lib/rigor/cli/baseline_command.rb +4 -1
  65. data/lib/rigor/cli/check_command.rb +212 -27
  66. data/lib/rigor/cli/coverage_command.rb +56 -30
  67. data/lib/rigor/cli/diagnostic_formats.rb +4 -1
  68. data/lib/rigor/cli/docs_command.rb +0 -30
  69. data/lib/rigor/cli/doctor_command.rb +163 -23
  70. data/lib/rigor/cli/lsp_command.rb +5 -0
  71. data/lib/rigor/cli/mcp_command.rb +5 -0
  72. data/lib/rigor/cli/plugins_command.rb +36 -6
  73. data/lib/rigor/cli/plugins_renderer.rb +44 -12
  74. data/lib/rigor/cli/probe_environment.rb +85 -0
  75. data/lib/rigor/cli/protection_fork_scan.rb +55 -0
  76. data/lib/rigor/cli/protection_report.rb +7 -1
  77. data/lib/rigor/cli/sig_gen_command.rb +36 -9
  78. data/lib/rigor/cli/skill_command.rb +1 -32
  79. data/lib/rigor/cli/trace_command.rb +5 -6
  80. data/lib/rigor/cli/type_of_command.rb +9 -8
  81. data/lib/rigor/cli/type_scan_command.rb +8 -8
  82. data/lib/rigor/cli.rb +15 -4
  83. data/lib/rigor/config_audit.rb +48 -2
  84. data/lib/rigor/configuration/severity_profile.rb +31 -0
  85. data/lib/rigor/configuration.rb +156 -8
  86. data/lib/rigor/environment/default_libraries.rb +35 -0
  87. data/lib/rigor/environment/missing_gem_constant_index.rb +128 -0
  88. data/lib/rigor/environment/rbs_loader.rb +164 -5
  89. data/lib/rigor/environment.rb +71 -43
  90. data/lib/rigor/inference/budget_trace.rb +77 -2
  91. data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
  92. data/lib/rigor/inference/def_handle.rb +23 -0
  93. data/lib/rigor/inference/def_node_resolver.rb +90 -0
  94. data/lib/rigor/inference/def_return_typer.rb +2 -1
  95. data/lib/rigor/inference/expression_typer.rb +379 -113
  96. data/lib/rigor/inference/fork_map.rb +87 -0
  97. data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
  98. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
  99. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
  100. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
  101. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +11 -0
  102. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
  103. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
  104. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
  105. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
  106. data/lib/rigor/inference/method_dispatcher.rb +57 -1
  107. data/lib/rigor/inference/mutation_widening.rb +42 -8
  108. data/lib/rigor/inference/narrowing.rb +245 -17
  109. data/lib/rigor/inference/parameter_inference_collector.rb +70 -10
  110. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  111. data/lib/rigor/inference/rbs_type_translator.rb +8 -1
  112. data/lib/rigor/inference/scope_indexer.rb +460 -49
  113. data/lib/rigor/inference/statement_evaluator.rb +111 -17
  114. data/lib/rigor/inference/struct_fold_safety.rb +48 -11
  115. data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
  116. data/lib/rigor/inference/void_origin.rb +25 -0
  117. data/lib/rigor/inference/void_tail_summary.rb +220 -0
  118. data/lib/rigor/language_server/completion_provider.rb +2 -1
  119. data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
  120. data/lib/rigor/language_server/folding_range_provider.rb +2 -1
  121. data/lib/rigor/language_server/selection_range_provider.rb +2 -1
  122. data/lib/rigor/language_server/signature_help_provider.rb +2 -1
  123. data/lib/rigor/plugin/base.rb +9 -22
  124. data/lib/rigor/plugin/inflector.rb +12 -3
  125. data/lib/rigor/plugin/io_boundary.rb +5 -1
  126. data/lib/rigor/plugin/isolation.rb +81 -11
  127. data/lib/rigor/plugin/load_error.rb +10 -1
  128. data/lib/rigor/plugin/loader.rb +96 -14
  129. data/lib/rigor/plugin/node_rule_walk.rb +5 -3
  130. data/lib/rigor/plugin/registry.rb +32 -23
  131. data/lib/rigor/plugin.rb +26 -0
  132. data/lib/rigor/protection/mutator.rb +3 -2
  133. data/lib/rigor/reflection.rb +64 -0
  134. data/lib/rigor/runtime/jit.rb +128 -0
  135. data/lib/rigor/scope/discovery_index.rb +12 -2
  136. data/lib/rigor/scope.rb +122 -28
  137. data/lib/rigor/sig_gen/classification.rb +5 -1
  138. data/lib/rigor/sig_gen/generator.rb +38 -6
  139. data/lib/rigor/sig_gen/observation_collector.rb +4 -3
  140. data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
  141. data/lib/rigor/sig_gen/renderer.rb +10 -0
  142. data/lib/rigor/sig_gen/write_result.rb +9 -4
  143. data/lib/rigor/sig_gen/writer.rb +24 -2
  144. data/lib/rigor/source/node_children.rb +116 -0
  145. data/lib/rigor/source/node_locator.rb +3 -1
  146. data/lib/rigor/source/node_walker.rb +4 -2
  147. data/lib/rigor/source.rb +1 -0
  148. data/lib/rigor/type/difference.rb +28 -24
  149. data/lib/rigor/type/hash_shape.rb +34 -10
  150. data/lib/rigor/version.rb +1 -1
  151. data/lib/rigortype.rb +24 -0
  152. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
  154. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +23 -8
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +21 -0
  157. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
  158. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +13 -2
  159. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +65 -2
  160. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +20 -2
  161. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
  162. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +172 -0
  163. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +22 -8
  164. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
  165. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
  166. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +32 -0
  167. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
  168. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
  169. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
  170. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
  171. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
  172. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
  173. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
  174. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +191 -0
  175. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
  176. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +19 -1
  177. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +48 -15
  178. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +42 -12
  179. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
  180. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
  181. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
  182. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
  183. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
  184. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
  185. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
  186. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
  187. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
  188. data/sig/prism_node_children.rbs +9 -0
  189. data/sig/rigor/environment.rbs +1 -0
  190. data/sig/rigor/inference.rbs +7 -0
  191. data/sig/rigor/plugin/base.rbs +2 -3
  192. data/sig/rigor/reflection.rbs +1 -0
  193. data/sig/rigor/scope.rbs +12 -1
  194. data/skills/rigor-ci-setup/SKILL.md +10 -0
  195. data/skills/rigor-editor-setup/SKILL.md +11 -0
  196. data/skills/rigor-next-steps/SKILL.md +23 -2
  197. data/skills/rigor-plugin-author/SKILL.md +3 -3
  198. data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
  199. data/skills/rigor-plugin-review/SKILL.md +2 -2
  200. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
  201. data/skills/rigor-project-init/SKILL.md +29 -3
  202. data/skills/rigor-project-init/references/01-detect.md +1 -1
  203. data/skills/rigor-project-init/references/02-configure.md +39 -5
  204. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
  205. data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
  206. metadata +49 -24
  207. data/lib/rigor/cli/ci_detector.rb +0 -89
@@ -3,8 +3,10 @@
3
3
  require "prism"
4
4
 
5
5
  require_relative "../type"
6
+ require_relative "../reflection"
6
7
  require_relative "../ast"
7
8
  require_relative "../source/constant_path"
9
+ require_relative "../source/node_children"
8
10
  require_relative "../analysis/self_call_resolution_recorder"
9
11
  require_relative "block_parameter_binder"
10
12
  require_relative "body_fixpoint"
@@ -209,6 +211,11 @@ module Rigor
209
211
  }.freeze
210
212
  private_constant :PRISM_DISPATCH
211
213
 
214
+ # Sentinel distinguishing "the key is not a value-pinned literal" from the legitimate literal hash
215
+ # keys `nil` and `false` (see {#static_hash_key}).
216
+ NO_STATIC_HASH_KEY = Object.new.freeze
217
+ private_constant :NO_STATIC_HASH_KEY
218
+
212
219
  def initialize(scope:, tracer: nil)
213
220
  @scope = scope
214
221
  @tracer = tracer
@@ -244,6 +251,29 @@ module Rigor
244
251
  fallback_for(node, family: :prism)
245
252
  end
246
253
 
254
+ # ADR-89 WD2 — the return type of `def_node` called with `receiver` / `arg_types`, computed exactly as a
255
+ # resolved in-body dispatch does (through the ADR-84 memo, so it is cheap and yields FINAL values only).
256
+ # Public entry point for the incremental session's return-summary re-evaluation: it re-drives a
257
+ # declaration-stable changed callee at each previously-observed call key to prove its return is
258
+ # unchanged before skipping the callee's symbol dependents. nil for an abstract / bodyless def, or when
259
+ # the memo refuses a transient result — the caller treats either as "not provably stable" (keeps the
260
+ # dependents), the conservative direction.
261
+ def return_type_for(def_node, receiver, arg_types)
262
+ infer_user_method_return(def_node, receiver, arg_types)
263
+ end
264
+
265
+ # ADR-89 WD2 — the current run's return memo bucket as `{ def_node => [MemoEntry, …] }` (only entries
266
+ # that carry a call descriptor, i.e. every stored entry). Read by the incremental session right after a
267
+ # recording run to harvest each analyzed callee's observed call keys → return descriptors. Returns an
268
+ # empty hash when no bucket exists (a run that memoised nothing). Class method: the bucket lives on
269
+ # `Thread.current`, independent of any one `ExpressionTyper` instance.
270
+ def self.harvest_return_memo
271
+ slot = Thread.current[RETURN_MEMO_KEY]
272
+ return {} if slot.nil?
273
+
274
+ slot[1].transform_values(&:values)
275
+ end
276
+
247
277
  private
248
278
 
249
279
  attr_reader :scope, :tracer
@@ -385,75 +415,49 @@ module Rigor
385
415
  # `Inference::FallbackTracer` from inside `Rigor::CLI::Foo` resolves to
386
416
  # `Rigor::Inference::FallbackTracer`.
387
417
  def type_of_constant_read(node)
388
- resolve_constant_name(node.name.to_s) || fallback_for(node, family: :prism)
418
+ resolve_constant_name(node.name.to_s) || unresolved_constant_fallback(node, node.name.to_s)
389
419
  end
390
420
 
391
421
  def type_of_constant_path(node)
392
422
  full_name = Source::ConstantPath.qualified_name_or_nil(node)
393
423
  return fallback_for(node, family: :prism) if full_name.nil?
394
424
 
395
- resolve_constant_name(full_name) || fallback_for(node, family: :prism)
425
+ resolve_constant_name(full_name) || unresolved_constant_fallback(node, full_name)
396
426
  end
397
427
 
398
- # Try the literal name first, then walk Ruby's lexical lookup by progressively prefixing the surrounding
399
- # class path (peeled one `::segment` at a time). For each candidate the lookup consults
400
- # `Environment#singleton_for_name` (a class object) and then `Environment#constant_for_name` (a
401
- # non-class constant value such as `BUCKETS: Array[Symbol]`). Returns the matched `Rigor::Type` or nil;
402
- # the caller decides whether to fall back.
403
- def resolve_constant_name(name)
404
- env = scope.environment
405
- discovered = scope.discovered_classes
406
- in_source = scope.in_source_constants
407
- lexical_constant_candidates(name).each do |candidate|
408
- singleton = env.singleton_for_name(candidate)
409
- return singleton if singleton
410
-
411
- in_source_class = discovered[candidate]
412
- return in_source_class if in_source_class
413
-
414
- # In-source value-bearing constants take precedence over RBS constant decls because user code is
415
- # the authoritative source for its own constants.
416
- in_source_value = in_source[candidate]
417
- return in_source_value if in_source_value
418
-
419
- value = env.constant_for_name(candidate)
420
- return value if value
421
- end
422
- nil
423
- end
428
+ # ADR-82 WD9 an unresolved constant whose root name a locked, RBS-less gem declares carries the
429
+ # `external_gem_without_rbs` cause instead of the generic `unsupported_syntax`. The constant read is
430
+ # where the class name is last visible (a no-RBS gem's receiver never types Nominal, so the dispatch
431
+ # tiers that record this cause under ADR-10 / `pre_eval:` opt-ins can't see it); WD6 chain inheritance
432
+ # then carries the cause through `Faraday.new.get(...)`. Side-channel only — the type stays the same
433
+ # `Dynamic[top]`, and an unindexed constant (project typo, unanalyzed project path) keeps the generic
434
+ # cause: the fail-open direction is a missing label, never a wrong one.
435
+ def unresolved_constant_fallback(node, full_name)
436
+ root = full_name.delete_prefix("::").split("::").first
437
+ owner = root && scope.environment.missing_rbs_gem_owner(root)
438
+ return fallback_for(node, family: :prism) unless owner
424
439
 
425
- # The candidate qualified names to try, in Ruby's lexical order: most-qualified first (the surrounding
426
- # class path joined to `name`), then progressively less-qualified, then the bare `name`. Top-level
427
- # scopes (no `self_type`) yield only `[name]`, preserving the pre-walk behaviour.
428
- def lexical_constant_candidates(name)
429
- prefix = enclosing_class_path
430
- candidates = []
431
- while prefix && !prefix.empty?
432
- candidates << "#{prefix}::#{name}"
433
- # Strip the last `::` segment without `rpartition`'s throwaway 3-element array + extra substrings
434
- # (this loop is the sole caller of the `String#rpartition` allocation seen in the profile): `rindex`
435
- # + slice gives the same prefix, or nil.
436
- idx = prefix.rindex("::")
437
- prefix = idx ? prefix[0, idx] : nil
438
- end
439
- candidates << name
440
- candidates
440
+ inner = dynamic_top
441
+ record_fallback(node, family: :prism, inner_type: inner, origin: DynamicOrigin::EXTERNAL_GEM_WITHOUT_RBS)
442
+ scope.record_dynamic_origin(node, DynamicOrigin::EXTERNAL_GEM_WITHOUT_RBS)
443
+ inner
441
444
  end
442
445
 
443
- # Pulls the enclosing qualified class name out of `scope.self_type` when one is set. `Nominal[T]` and
444
- # `Singleton[T]` both expose `class_name`. Returns nil when no class context is available (top-level).
445
- def enclosing_class_path
446
- st = scope.self_type
447
- case st
448
- when Type::Nominal, Type::Singleton then st.class_name
449
- end
446
+ # Resolves a constant reference through Ruby's lexical constant lookup. Delegates to the shared
447
+ # `Reflection.resolve_constant_type` owner so the same walk (registry singleton, discovered class,
448
+ # in-source value, RBS constant, across the peeled `::` prefix candidates) is reused by
449
+ # `Inference::Narrowing`'s `Constant[Regexp]` match-operand recognition. Returns the matched
450
+ # `Rigor::Type` or nil; the caller decides whether to fall back.
451
+ def resolve_constant_name(name)
452
+ Reflection.resolve_constant_type(name, scope: scope)
450
453
  end
451
454
 
452
455
  # Slice 5 phase 1 upgrades hash literals to `HashShape{...}` when every entry is a static `AssocNode`
453
- # whose key is a `SymbolNode` or `StringNode` with a known value (covering the `{ a: 1, "b" => 2 }`
454
- # pattern and falling back to the generic `Hash[K, V]` form otherwise). Splatted entries (`{ **other }`)
455
- # and dynamic keys widen to the underlying `Hash[K, V]` form by unioning the types each entry exposes;
456
- # when no concrete pair survives we fall back to the raw `Hash` so callers stay backward compatible.
456
+ # whose key is a value-pinned scalar literal Symbol, plain String, Integer, Float, `true`, `false`,
457
+ # or `nil` (covering `{ a: 1, "b" => 2 }` and `{ 1 => 2, 1.0 => 4 }` alike) — falling back to the
458
+ # generic `Hash[K, V]` form otherwise. Splatted entries (`{ **other }`) and dynamic keys widen to the
459
+ # underlying `Hash[K, V]` form by unioning the types each entry exposes; when no concrete pair
460
+ # survives we fall back to the raw `Hash` so callers stay backward compatible.
457
461
  def type_of_hash(node)
458
462
  elements = node.respond_to?(:elements) ? node.elements : []
459
463
  # v0.0.7 — `{}` resolves to the empty `HashShape{}` carrier rather than `Nominal[Hash]`, mirroring the
@@ -473,16 +477,22 @@ module Rigor
473
477
  )
474
478
  end
475
479
 
476
- # Builds `HashShape{...}` when every entry is an `AssocNode` whose key is a static Symbol or String
477
- # literal. Returns nil otherwise so the caller falls back to the generic shape.
480
+ # Builds `HashShape{...}` when every entry is an `AssocNode` whose key is a value-pinned scalar
481
+ # literal (Symbol, plain String, Integer, Float, true, false, nil). Returns nil otherwise so the
482
+ # caller falls back to the generic shape.
483
+ #
484
+ # Duplicate keys are LAST-WINS, matching the runtime (`{ a: 1, a: 2 }` keeps `a: 2`; Ruby itself
485
+ # only warns under `-w`). Key identity is Ruby `Hash` `eql?` identity because `pairs` is a native
486
+ # Hash — `1` and `1.0` stay distinct entries, `1.0` and `1.00` collide. The plain re-assignment
487
+ # also reproduces the runtime's ordering: the key keeps its FIRST insertion position while the
488
+ # value comes from the LAST occurrence.
478
489
  def static_hash_shape_for(elements)
479
490
  pairs = {}
480
491
  elements.each do |entry|
481
492
  return nil unless entry.is_a?(Prism::AssocNode)
482
493
 
483
494
  key = static_hash_key(entry.key)
484
- return nil if key.nil?
485
- return nil if pairs.key?(key)
495
+ return nil if key.equal?(NO_STATIC_HASH_KEY)
486
496
 
487
497
  pairs[key] = type_of(entry.value)
488
498
  end
@@ -491,16 +501,22 @@ module Rigor
491
501
  Type::Combinator.hash_shape_of(pairs)
492
502
  end
493
503
 
494
- # Returns the static (Symbol|String) literal carried by a hash key node, or nil when the key is
495
- # dynamic. We only treat SymbolNode#value and StringNode#unescaped as static when they are non-nil
496
- # (interpolation produces a nil unescaped).
504
+ # Returns the value-pinned scalar literal carried by a hash key node, or {NO_STATIC_HASH_KEY} when
505
+ # the key is dynamic (a computed expression, an interpolated string SymbolNode#value /
506
+ # StringNode#unescaped are nil under interpolation a constant, a local, …). `nil` / `false` are
507
+ # real key values here, hence the sentinel rather than a nil return.
497
508
  def static_hash_key(node)
498
509
  case node
499
510
  when Prism::SymbolNode
500
511
  raw = node.value
501
- raw&.to_sym
512
+ raw.nil? ? NO_STATIC_HASH_KEY : raw.to_sym
502
513
  when Prism::StringNode
503
- node.unescaped
514
+ node.unescaped || NO_STATIC_HASH_KEY
515
+ when Prism::IntegerNode, Prism::FloatNode then node.value
516
+ when Prism::TrueNode then true
517
+ when Prism::FalseNode then false
518
+ when Prism::NilNode then nil
519
+ else NO_STATIC_HASH_KEY
504
520
  end
505
521
  end
506
522
 
@@ -1591,18 +1607,106 @@ module Rigor
1591
1607
  # args change folds (`factorial(5)` vs `factorial(6)`), so a coarser key would serve a stale fold. The
1592
1608
  # third unsafe dimension — the ADR-55 recursion machinery (unroll fuel / fixpoint Kleene
1593
1609
  # assumption / WD1 clamp) producing a TRANSIENT result rather than a final return — is excluded
1594
- # structurally: the memo is consulted and populated ONLY when the incoming guard stack is empty (a
1595
- # genuine top-of-stack entry, whose result is final and cannot be an in-progress assumption or a
1596
- # clamped value). Frames entered with a non-empty stack bypass the memo entirely and compute as before.
1610
+ # post-hoc (ADR-84 WD3): candidacy only requires that the plain signature not already be on the
1611
+ # recursion guard stack (a frame inside its own cycle returns a Kleene iterate by construction), and
1612
+ # the store gate brackets the compute over the transient-event depth log
1613
+ # (TRANSIENT_EVENT_DEPTHS_KEY), refusing only when an event referenced a frame BELOW the bracket's
1614
+ # entry depth — proof the ancestor context (an in-flight ancestor summary, the shared unroll fuel, a
1615
+ # possibly-ancestor-caused clamp) influenced the result. Events at-or-above the entry depth are the
1616
+ # compute's own deterministic machinery — a recursive method's own converged fixpoint stores from a
1617
+ # top-of-stack entry (standalone by construction: fuel reseeds, summary tables cleared at the
1618
+ # previous drain) — and a nested compute whose bracket saw no below-entry event ran as if standalone
1619
+ # (fuel consumed without exhausting is invisible; every summary read fires a logged guard event). A
1620
+ # memo HIT while an unroll is merely in flight elsewhere on the stack is legal (stored values are
1621
+ # final and context-free). This replaced the blanket unroll-in-flight candidacy exclusion, which
1622
+ # refused 82% of mail's body evaluations for results that were overwhelmingly final (PR #79
1623
+ # counters; ADR-84).
1597
1624
  #
1598
- # Keyed by the identity of the frozen discovery `def_nodes` table (a new analysis generation lands in
1599
- # a fresh bucket, mirroring `class_graph_buckets`) then by the identity of the `def_node` and the
1600
- # `[receiver, *args]` descriptor tuple. `ExpressionTyper` is rebuilt per `Scope#type_of`, so the store
1601
- # lives on `Thread.current`; fork-pool workers are separate processes, so it never crosses a project
1602
- # boundary.
1625
+ # Bucket scope (ADR-84 WD2): ONE bucket per analysis run, keyed by the identity of the run-generation
1626
+ # token `Analysis::Runner#run_analysis` mints and seeds through `scope.discovery` hits cross
1627
+ # consumer-file boundaries within a run, and a re-run in the same process (LSP re-check, ADR-62 warm
1628
+ # loop) rolls the bucket over. Scopes without a runner seed (single-file probes) fall back to the
1629
+ # per-file merged `discovered_def_nodes` identity — exactly the pre-WD2 per-file scope. Inside the
1630
+ # bucket, entries key on the `def_node`'s object identity (the identity Hash holds the node strongly,
1631
+ # so a collected parse can never recycle an object id into a stale hit) and then on the
1632
+ # `[receiver, *args]` descriptor tuple; a callee may appear under at most two node identities per run
1633
+ # (the project-index parse every OTHER file resolves through, plus the defining file's own analysis
1634
+ # parse). Only the current generation's bucket is retained (single slot) — a rolled-over run's entries
1635
+ # become garbage instead of accumulating across LSP runs. `ExpressionTyper` is rebuilt per
1636
+ # `Scope#type_of`, so the store lives on `Thread.current`; fork-pool workers are separate processes,
1637
+ # so it never crosses a project boundary (ADR-84 WD5).
1603
1638
  RETURN_MEMO_KEY = :__rigor_user_method_return_memo__
1604
1639
  private_constant :RETURN_MEMO_KEY
1605
1640
 
1641
+ # ADR-84 WD2 — one memo entry: the final inferred return plus, when the entry was computed under
1642
+ # ADR-46 dependency recording, the frozen `Analysis::DependencyRecorder::ReadSet` its body walk
1643
+ # produced (nil on non-recording runs — recording is decided per run and the bucket never outlives a
1644
+ # run, so a recording run only ever hits entries that carry a read-set; pinned by
1645
+ # return_memo_recording_spec's rollover example). ADR-89 WD2 additionally carries the CALL DESCRIPTOR
1646
+ # (`receiver` + `arg_types`) so the incremental session can harvest each observed call key and persist
1647
+ # it as a return-summary — a recheck re-evaluates the (declaration-stable) callee at those keys and,
1648
+ # when every return is unchanged, skips its symbol dependents. Carrying two frozen type refs is
1649
+ # negligible on the memo-store path (already in scope) and never enters memo equality (entries are
1650
+ # keyed by `memo_key`, retrieved by `.result` / `.read_set`).
1651
+ MemoEntry = Data.define(:result, :read_set, :receiver, :arg_types)
1652
+ private_constant :MemoEntry
1653
+
1654
+ # ADR-84 WD3 — thread-local transient-machinery event log, appended by `note_transient_fallback` at
1655
+ # EVERY site where the ADR-55 recursion machinery substitutes transient state for a plain body
1656
+ # evaluation. Each entry is the STACK POSITION (guard-stack index) of the frame whose in-flight state
1657
+ # the event referenced — the consulted owner for a guard hit, 0 for the whole-stack resources (shared
1658
+ # unroll fuel; conservatively the clamp), the owner's own position for its cap collapse.
1659
+ # `consult_and_store_return_memo` brackets a candidate's compute over the log and refuses to store
1660
+ # only when an event referenced a frame BELOW the bracket's entry depth: such an event proves the
1661
+ # ancestor context influenced the result, while events at-or-above the entry depth are the compute's
1662
+ # OWN deterministic machinery (a recursive method's own converged fixpoint, a sub-cycle that opened
1663
+ # and closed inside the bracket) which a standalone recompute reproduces event-for-event. A
1664
+ # top-of-stack compute (entry depth 0) can never see a below-entry event — it is standalone by
1665
+ # construction (fuel reseeds on an empty stack, the summary tables were cleared at the previous
1666
+ # drain). Cleared with the other per-outermost-entry tables when the guard stack drains. LOAD-BEARING:
1667
+ # unlike `BudgetTrace` counters the log must be maintained on every run, not only under
1668
+ # RIGOR_BUDGET_TRACE.
1669
+ #
1670
+ # Audit table (ADR-84 WD3) — every early-return / fallback in the recursion machinery, each either
1671
+ # LOGGED (routes through `note_transient_fallback` with the referenced frame's position) or PROVABLY
1672
+ # FINAL (a deterministic pure function of the frame's own inputs, so a standalone recompute
1673
+ # reproduces it):
1674
+ #
1675
+ # compute_user_method_return
1676
+ # - in-cycle re-entry (`stack.include?(signature)` → `consult_summary`) LOGGED at the consulted
1677
+ # owner's stack position (the outermost frame carrying the plain signature — the seeder whose
1678
+ # in-flight Kleene iterate the re-entry returns)
1679
+ # evaluate_guarded_user_method_body
1680
+ # - non-outermost plain evaluation, clamp pass-through PROVABLY FINAL (plain
1681
+ # body eval; nested transient events log at their own sites)
1682
+ # - `clamp_unroll_result` clamp branch (ADR-55 WD1 → `untyped`) LOGGED at 0 (the
1683
+ # would-have-been-guarded match may be an ancestor frame; position not threaded — conservative)
1684
+ # fixpoint_user_method_return
1685
+ # - `degrade_entangled_fixpoint` (foreign in-flight consult) LOGGED at the degrading
1686
+ # owner's own position (the foreign consult that caused it was already logged at the ancestor's
1687
+ # position by the guard site, which is what taints the enclosing brackets)
1688
+ # - "summary never consulted" early return (body did not recurse) PROVABLY FINAL
1689
+ # - `resolve_bot_collapse` (widened re-run / explicit-return floor) PROVABLY FINAL in itself
1690
+ # (deterministic); transient events inside the re-run log at their own sites
1691
+ # - `fixpoint_step` convergence (`joined == assumption`) converged value; any
1692
+ # consults the iterations performed were already LOGGED at the guard site
1693
+ # - `fixpoint_step` cap collapse (`RECURSION_FIXPOINT_CAP` → `untyped`) LOGGED at the owner's own
1694
+ # position (per-owner constant cap — deterministic for every enclosing bracket; the iterates'
1695
+ # context-dependence, if any, logs separately at its own sites)
1696
+ # unroll helpers
1697
+ # - `unroll_fuel_remaining` exhaustion (extended key denied → plain) LOGGED at 0 (fuel is ONE
1698
+ # shared resource seeded at stack bottom, so a nested frame's unroll budget depends on what ran
1699
+ # before it — the hazard the blanket exclusion used to over-approximate)
1700
+ # - `constant_argument_value_key` / `pinned_value_descriptor` nil PROVABLY FINAL (pure
1701
+ # functions of `arg_types`)
1702
+ #
1703
+ # Adding a fallback? Route it through `note_transient_fallback` unless it is provably final in the
1704
+ # sense above — position 0 is always a sound (conservative) choice; spec/rigor/inference/
1705
+ # return_memo_taint_spec.rb pins this (no raw `BudgetTrace.hit` on the recursion categories outside
1706
+ # the helper).
1707
+ TRANSIENT_EVENT_DEPTHS_KEY = :__rigor_user_method_transient_event_depths__
1708
+ private_constant :TRANSIENT_EVENT_DEPTHS_KEY
1709
+
1606
1710
  # Per-inference recursion context threaded through the guard / fixpoint helpers (ADR-55 slice 2).
1607
1711
  # Bundles the call descriptor (`receiver`, `arg_types`, `plain_signature`), the thread-local summary
1608
1712
  # table, and the WD1 clamp flag so the helpers stay within the parameter-list budget. `def_node` is
@@ -1629,7 +1733,7 @@ module Rigor
1629
1733
  RECURSION_VALUE_SIZE_CAP = 64
1630
1734
  private_constant :RECURSION_VALUE_SIZE_CAP
1631
1735
 
1632
- def infer_user_method_return(def_node, receiver, arg_types) # rubocop:disable Metrics/AbcSize
1736
+ def infer_user_method_return(def_node, receiver, arg_types)
1633
1737
  return nil if def_node.body.nil?
1634
1738
 
1635
1739
  body_scope = build_user_method_body_scope(def_node, receiver, arg_types)
@@ -1646,6 +1750,9 @@ module Rigor
1646
1750
  stack = (Thread.current[INFERENCE_GUARD_KEY] ||= [])
1647
1751
  summaries = (Thread.current[INFERENCE_SUMMARY_KEY] ||= {})
1648
1752
 
1753
+ # WD0 (RIGOR_BUDGET_TRACE) — one entry into user-method return inference. No-op when disabled.
1754
+ BudgetTrace.hit(BudgetTrace::MEMO_ENTRIES)
1755
+
1649
1756
  # ADR-57 follow-up — return memo. The inferred return is a pure function of `(def_node, receiver,
1650
1757
  # arg_types)` and the frozen discovery index whenever the computation does NOT depend on a transient
1651
1758
  # ADR-55 Kleene assumption (an in-flight fixpoint summary). Two structural preconditions decide
@@ -1661,25 +1768,104 @@ module Rigor
1661
1768
  # call, re-walking the shared sub-readers combinatorially (~932k body evaluations for ~20 tiny
1662
1769
  # methods). The computation itself lives in `compute_user_method_return`.
1663
1770
  unless memo_candidate?(stack, plain_signature)
1771
+ trace_memo_refusal(stack, plain_signature)
1664
1772
  return compute_user_method_return(def_node, body_scope, stack, summaries,
1665
1773
  receiver, arg_types, plain_signature)
1666
1774
  end
1667
1775
 
1668
- memo = return_memo_bucket
1669
- memo_key = [def_node.object_id, receiver.describe(:short),
1776
+ # INVARIANT (ADR-46 recording soundness, ADR-84 WD2) — the deep cross-file dependency edges (the
1777
+ # reads a callee body's dispatches perform through the instrumented `Scope` accessors) are recorded,
1778
+ # per consumer, only as a side effect of evaluating that body, and a memo hit serves the return
1779
+ # WITHOUT re-evaluating it. Since the bucket is run-scoped, hits CROSS consumer-file boundaries, so
1780
+ # every cross-file hit is PAIRED WITH CACHE-AND-REPLAY of the callee's read-set: under recording,
1781
+ # the first evaluation of a key captures the recorder events of its body walk
1782
+ # (`DependencyRecorder.capture` — observe-and-forward, so the first consumer's own record is
1783
+ # untouched) onto the entry, and a hit replays that set into the current consumer's accumulator
1784
+ # (`DependencyRecorder.replay`, which re-applies the per-consumer self-read filter). A memo hit is
1785
+ # thereby edge-equivalent to a fresh body evaluation for EVERY consumer. The naive alternative —
1786
+ # bypassing the memo while the recorder is active — measured >200x wall on analyzer-shaped files
1787
+ # (ActiveStorage video_analyzer.rb's subtree, 0.43s -> >90s; PR #79) and stays rejected. Pinned by
1788
+ # spec/rigor/inference/return_memo_recording_spec.rb (cross-file replay completeness) and
1789
+ # dependency_recorder_spec.rb's transitive deep-edge example.
1790
+ consult_and_store_return_memo(def_node, body_scope, stack, summaries,
1791
+ receiver, arg_types, plain_signature)
1792
+ end
1793
+
1794
+ # The candidate-frame memo path: consult the current run generation's bucket, and on a miss compute
1795
+ # and store a FINAL result. Reached only when `memo_candidate?` held (see `infer_user_method_return`).
1796
+ # The store gate (ADR-84 WD3): a result is stored when the ADR-55 fixpoint consult counter did not
1797
+ # move across the compute (the WD0 `MEMO_REFUSE_CONSULT_TAINTED` non-store) AND no transient-machinery
1798
+ # event during the bracket referenced a stack frame BELOW the bracket's entry depth (see
1799
+ # TRANSIENT_EVENT_DEPTHS_KEY). Below-entry events — an ancestor's in-flight Kleene iterate read by a
1800
+ # guard hit, a shared-fuel exhaustion, a possibly-ancestor-caused WD1 clamp — mean the ancestor
1801
+ # context influenced `result`, which a standalone recompute would not reproduce; at-or-above-entry
1802
+ # events are the compute's own deterministic machinery (its own converged fixpoint, sub-cycles that
1803
+ # opened and closed inside the bracket) and do not block the store. A top-of-stack compute (entry
1804
+ # depth 0) is standalone by construction. A hit under ADR-46 recording replays the entry's captured
1805
+ # read-set into the current consumer (see the INVARIANT comment at the call site).
1806
+ def consult_and_store_return_memo(def_node, body_scope, stack, summaries,
1807
+ receiver, arg_types, plain_signature)
1808
+ per_def = (return_memo_bucket[def_node] ||= {})
1809
+ memo_key = [receiver.describe(:short),
1670
1810
  arg_types.map { |type| type.describe(:short) }]
1671
- return memo[memo_key] if memo.key?(memo_key)
1811
+ if (entry = per_def[memo_key])
1812
+ BudgetTrace.hit(BudgetTrace::MEMO_HITS)
1813
+ Analysis::DependencyRecorder.replay(entry.read_set) if Analysis::DependencyRecorder.active?
1814
+ return entry.result
1815
+ end
1816
+ BudgetTrace.hit(BudgetTrace::MEMO_MISSES)
1817
+ trace_distinct_memo_key(plain_signature, def_node, memo_key)
1818
+
1819
+ entry_depth = stack.size
1820
+ event_mark = transient_event_mark
1821
+ consults_before = summary_consult_count
1822
+ result, read_set = compute_with_read_capture(def_node, body_scope, stack, summaries,
1823
+ receiver, arg_types, plain_signature)
1824
+
1825
+ if summary_consult_count != consults_before
1826
+ BudgetTrace.hit(BudgetTrace::MEMO_REFUSE_CONSULT_TAINTED)
1827
+ elsif context_tainted?(event_mark, entry_depth)
1828
+ BudgetTrace.hit(BudgetTrace::MEMO_REFUSE_TRANSIENT)
1829
+ else
1830
+ per_def[memo_key] = MemoEntry.new(result: result, read_set: read_set,
1831
+ receiver: receiver, arg_types: arg_types)
1832
+ end
1833
+ result
1834
+ end
1672
1835
 
1673
- consults_before = Thread.current[SUMMARY_CONSULT_COUNTER_KEY] || 0
1674
- result = compute_user_method_return(def_node, body_scope, stack, summaries,
1675
- receiver, arg_types, plain_signature)
1676
- consults_after = Thread.current[SUMMARY_CONSULT_COUNTER_KEY] || 0
1836
+ def transient_event_mark
1837
+ Thread.current[TRANSIENT_EVENT_DEPTHS_KEY]&.size || 0
1838
+ end
1677
1839
 
1678
- # Store only a FINAL result. If a fixpoint summary was consulted during the computation, `result`
1679
- # embeds a transient Kleene iterate whose value depends on the iteration in flight, so it must not
1680
- # be shared across call sites.
1681
- memo[memo_key] = result if consults_after == consults_before
1682
- result
1840
+ def summary_consult_count
1841
+ Thread.current[SUMMARY_CONSULT_COUNTER_KEY] || 0
1842
+ end
1843
+
1844
+ # ADR-84 WD3 — true when a transient-machinery event logged during the bracket (entries past
1845
+ # `event_mark`) referenced a frame below `entry_depth`. The log is cleared when the guard stack drains
1846
+ # (only possible mid-bracket for a top-of-stack compute, whose events are deterministic anyway), so a
1847
+ # missing / shorter log reads as untainted.
1848
+ def context_tainted?(event_mark, entry_depth)
1849
+ log = Thread.current[TRANSIENT_EVENT_DEPTHS_KEY]
1850
+ return false if log.nil? || log.size <= event_mark
1851
+
1852
+ log[event_mark..].any? { |depth| depth < entry_depth }
1853
+ end
1854
+
1855
+ # ADR-84 WD2 — the memo-miss compute, wrapped in a `DependencyRecorder.capture` window when ADR-46
1856
+ # recording is active so the entry can carry its replayable read-set. Returns `[result, read_set]`
1857
+ # (`read_set` nil when not recording).
1858
+ def compute_with_read_capture(def_node, body_scope, stack, summaries,
1859
+ receiver, arg_types, plain_signature)
1860
+ unless Analysis::DependencyRecorder.active?
1861
+ return [compute_user_method_return(def_node, body_scope, stack, summaries,
1862
+ receiver, arg_types, plain_signature), nil]
1863
+ end
1864
+
1865
+ Analysis::DependencyRecorder.capture do
1866
+ compute_user_method_return(def_node, body_scope, stack, summaries,
1867
+ receiver, arg_types, plain_signature)
1868
+ end
1683
1869
  end
1684
1870
 
1685
1871
  # The ADR-55 recursion-guard + value-unroll + fixpoint body of user-method return inference, factored
@@ -1687,6 +1873,7 @@ module Rigor
1687
1873
  # unchanged from pre-memo behaviour).
1688
1874
  def compute_user_method_return(def_node, body_scope, stack, summaries,
1689
1875
  receiver, arg_types, plain_signature)
1876
+ trace_body_eval(plain_signature)
1690
1877
  # ADR-55 slice 1: when every bound argument is value-pinned, extend the guard key with a stable
1691
1878
  # descriptor of the argument *values* so distinct constant frames may recurse (e.g. `factorial(5)`
1692
1879
  # folds to `Constant[120]`). Distinct constant frames are bounded by `RECURSION_UNROLL_FUEL` per
@@ -1698,7 +1885,10 @@ module Rigor
1698
1885
  signature = [plain_signature, value_key] if extended
1699
1886
 
1700
1887
  if stack.include?(signature)
1701
- BudgetTrace.hit(BudgetTrace::RECURSION_GUARD)
1888
+ # ADR-84 WD3 — the referenced frame is the consulted owner: the OUTERMOST frame carrying this
1889
+ # plain signature (the seeder whose in-flight iterate the re-entry returns).
1890
+ note_transient_fallback(BudgetTrace::RECURSION_GUARD,
1891
+ stack.index { |frame| plain_part(frame) == plain_signature } || 0)
1702
1892
  # ADR-55 slice 2: in-cycle re-entries return the current assumed summary (Kleene iterate, seeded
1703
1893
  # `bot`) instead of bare `untyped`. The fixpoint loop below seeds the entry on the outermost frame;
1704
1894
  # if a re-entry beats it here the entry already exists. The WD4 composition: slice 1's clamp/fuel
@@ -1724,30 +1914,85 @@ module Rigor
1724
1914
  evaluate_guarded_user_method_body(def_node, body_scope, stack, signature, context)
1725
1915
  end
1726
1916
 
1727
- # True when this frame's result is a candidate for the return memo: the structural preconditions, both
1728
- # stable across the body walk, that are necessary (but not sufficient) for a FINAL result. Sufficiency
1729
- # is decided post-hoc in `infer_user_method_return` by the consult-counter check (no transient
1730
- # fixpoint summary was read during the compute) so unlike the prior `memoisable_return?` this
1731
- # deliberately does NOT require an empty `summaries` table: inert seeded-but-unconsulted entries left
1732
- # by unrelated outermost frames do not contaminate a result, and gating on them disabled the memo for
1733
- # an entire non-recursive DAG (the scaling wall). The two preconditions: no constant-arg unroll in
1734
- # flight (its value-keyed frames are transient) and this plain signature not itself on the recursion
1735
- # guard stack (else we are inside its own cycle, returning a Kleene iterate).
1917
+ # True when this frame's result is a candidate for the return memo: the one structural precondition,
1918
+ # stable across the body walk, that is necessary (but not sufficient) for a FINAL result — this plain
1919
+ # signature must not itself be on the recursion guard stack (else we are inside its own cycle,
1920
+ # returning a Kleene iterate by construction). Sufficiency is decided post-hoc in
1921
+ # `consult_and_store_return_memo` by the two bracket counters (fixpoint consults + ADR-84 WD3
1922
+ # transient-machinery events) so unlike the prior form this deliberately does NOT refuse while a
1923
+ # constant-arg unroll is in flight: a nested frame whose compute finishes without a single transient
1924
+ # event ran exactly as it would standalone (fuel consumption without exhaustion is invisible), and
1925
+ # the blanket exclusion refused 82% of mail's body evaluations for such final results (ADR-84).
1736
1926
  def memo_candidate?(stack, plain_signature)
1737
- # Read the unroll fuel WITHOUT the decrement side effect of `unroll_fuel_remaining`: a constant-arg
1738
- # unroll has begun iff the thread-local is set and the stack is non-empty (the `ensure` in
1739
- # `evaluate_guarded_user_method_body` clears it at stack-empty).
1740
- unroll_idle = stack.empty? || Thread.current[INFERENCE_UNROLL_FUEL_KEY].nil?
1741
- unroll_idle &&
1742
- stack.none? { |frame| plain_part(frame) == plain_signature }
1927
+ stack.none? { |frame| plain_part(frame) == plain_signature }
1743
1928
  end
1744
1929
 
1745
- # Run-scoped return-memo bucket for the current discovery generation. Keyed by the identity of the
1746
- # frozen `def_nodes` table so a new analysis generation (or any scope that swaps the index)
1747
- # transparently lands in a fresh bucket. See RETURN_MEMO_KEY.
1930
+ # Run-scoped return-memo bucket (ADR-84 WD2): a single retained slot keyed by the identity of the
1931
+ # run-generation token the runner seeds (`Scope#run_generation`), falling back to the per-file merged
1932
+ # `def_nodes` table identity for runner-less scopes (single-file probes) — the pre-WD2 per-file
1933
+ # scope. A generation mismatch drops the previous bucket whole, so a re-run in one process (LSP,
1934
+ # ADR-62 warm loop) can neither hit stale entries nor accumulate them. The bucket maps `def_node`
1935
+ # (object identity, held strongly — see RETURN_MEMO_KEY) to its per-descriptor entries.
1748
1936
  def return_memo_bucket
1749
- store = (Thread.current[RETURN_MEMO_KEY] ||= {}.compare_by_identity)
1750
- store[scope.discovered_def_nodes] ||= {}
1937
+ generation = scope.run_generation || scope.discovered_def_nodes
1938
+ slot = Thread.current[RETURN_MEMO_KEY]
1939
+ unless slot && slot[0].equal?(generation)
1940
+ slot = [generation, {}.compare_by_identity]
1941
+ Thread.current[RETURN_MEMO_KEY] = slot
1942
+ end
1943
+ slot[1]
1944
+ end
1945
+
1946
+ # WD0 (RIGOR_BUDGET_TRACE) trace helpers. Each guards on `BudgetTrace.enabled?` before doing any work
1947
+ # beyond a single boolean check, so a normal run pays nothing (no signature-label string built, no
1948
+ # stack scan). Attributes a non-candidate frame's non-store to on-stack vs unroll-in-flight; since
1949
+ # ADR-84 WD3 reduced candidacy to the on-stack check alone, the unroll-in-flight branch is
1950
+ # structurally unreachable and is kept only so the printed counter table still shows the category
1951
+ # (pinned ~0 — the ADR-84 WD3 gate).
1952
+ def trace_memo_refusal(stack, plain_signature)
1953
+ return unless BudgetTrace.enabled?
1954
+
1955
+ if stack.any? { |frame| plain_part(frame) == plain_signature }
1956
+ BudgetTrace.hit(BudgetTrace::MEMO_REFUSE_ON_STACK)
1957
+ else
1958
+ BudgetTrace.hit(BudgetTrace::MEMO_REFUSE_UNROLL)
1959
+ end
1960
+ end
1961
+
1962
+ # The observed member is the full `(def-node identity, receiver, args)` entry key, so the per-signature
1963
+ # count also surfaces the ADR-84 WD2 dual-parse duplication (project-index parse vs the defining
1964
+ # file's own parse — at most 2 node identities per def per run).
1965
+ def trace_distinct_memo_key(plain_signature, def_node, memo_key)
1966
+ return unless BudgetTrace.enabled?
1967
+
1968
+ BudgetTrace.observe_distinct(
1969
+ BudgetTrace::MEMO_DISTINCT_KEY_BY_SIGNATURE, signature_label(plain_signature),
1970
+ [def_node.object_id, memo_key]
1971
+ )
1972
+ end
1973
+
1974
+ def trace_body_eval(plain_signature)
1975
+ return unless BudgetTrace.enabled?
1976
+
1977
+ BudgetTrace.hit(BudgetTrace::MEMO_BODY_EVALS)
1978
+ BudgetTrace.observe(BudgetTrace::MEMO_BODY_EVAL_BY_SIGNATURE, signature_label(plain_signature))
1979
+ end
1980
+
1981
+ # `"Receiver#method"` label for a `[receiver_descriptor, method_name]` plain signature — the bucket key
1982
+ # for the WD0 per-signature distributions. Built only under `BudgetTrace.enabled?`.
1983
+ def signature_label(plain_signature)
1984
+ "#{plain_signature[0]}##{plain_signature[1]}"
1985
+ end
1986
+
1987
+ # ADR-84 WD3 — the single choke point every transient-machinery fallback routes through: appends the
1988
+ # referenced frame's stack position to the load-bearing thread-local event log (regardless of
1989
+ # RIGOR_BUDGET_TRACE) and forwards the category to `BudgetTrace`. The taint spec pins that no
1990
+ # recursion-machinery `BudgetTrace.hit` exists outside this helper, so a future fallback cannot
1991
+ # silently join unlogged (see the audit table at TRANSIENT_EVENT_DEPTHS_KEY; `context_depth` 0 is
1992
+ # always a sound conservative choice).
1993
+ def note_transient_fallback(category, context_depth)
1994
+ (Thread.current[TRANSIENT_EVENT_DEPTHS_KEY] ||= []) << context_depth
1995
+ BudgetTrace.hit(category)
1751
1996
  end
1752
1997
 
1753
1998
  # Pushes the recursion-guard frame, evaluates the body (the outermost frame for a plain signature runs
@@ -1772,6 +2017,7 @@ module Rigor
1772
2017
  Thread.current[INFERENCE_UNROLL_FUEL_KEY] = nil
1773
2018
  Thread.current[INFERENCE_SUMMARY_KEY] = nil
1774
2019
  Thread.current[SUMMARY_CONSULT_DEPTHS_KEY] = nil
2020
+ Thread.current[TRANSIENT_EVENT_DEPTHS_KEY] = nil
1775
2021
  end
1776
2022
  end
1777
2023
  end
@@ -1861,12 +2107,21 @@ module Rigor
1861
2107
  # soundness fix, 2026-06-12), parking `untyped` in the assumption so any consumer that still reads
1862
2108
  # this signature's summary sees the floor, not the stale `bot` seed.
1863
2109
  def degrade_entangled_fixpoint(summaries, plain_signature)
1864
- BudgetTrace.hit(BudgetTrace::RECURSION_GUARD)
2110
+ # ADR-84 WD3 — logged at the degrading owner's own position: the foreign consult that caused the
2111
+ # entanglement was already logged at the ANCESTOR's position by the guard site.
2112
+ note_transient_fallback(BudgetTrace::RECURSION_GUARD, own_guard_frame_position)
1865
2113
  scope.record_dynamic_origin(@typing_node, DynamicOrigin::ANALYZER_BUDGET_CUTOFF) if @typing_node
1866
2114
  summaries[plain_signature][:assumption] = Type::Combinator.untyped
1867
2115
  Type::Combinator.untyped
1868
2116
  end
1869
2117
 
2118
+ # The stack position of the currently-evaluating owner frame (the guard stack's top) — the
2119
+ # self-referential `context_depth` for events that are deterministic per owner (ADR-84 WD3).
2120
+ def own_guard_frame_position
2121
+ size = Thread.current[INFERENCE_GUARD_KEY]&.size || 0
2122
+ size.positive? ? size - 1 : 0
2123
+ end
2124
+
1870
2125
  # One Kleene-iteration step of the fixpoint loop. Joins `computed` into the running assumption
1871
2126
  # (widening value-pinned constituents on the final permitted iteration to force convergence) and
1872
2127
  # either returns a final type — convergence, or the capped `untyped` collapse — or `:continue` to
@@ -1882,9 +2137,9 @@ module Rigor
1882
2137
  return candidate if joined == assumption
1883
2138
 
1884
2139
  if last_iteration
1885
- # Out of iterations and still unstable — collapse to today's
1886
- # widening behaviour.
1887
- BudgetTrace.hit(BudgetTrace::RECURSION_FIXPOINT_CAP)
2140
+ # Out of iterations and still unstable — collapse to today's widening behaviour. ADR-84 WD3: the
2141
+ # cap is a per-owner constant, so the event references the owner's own frame.
2142
+ note_transient_fallback(BudgetTrace::RECURSION_FIXPOINT_CAP, own_guard_frame_position)
1888
2143
  scope.record_dynamic_origin(@typing_node, DynamicOrigin::ANALYZER_BUDGET_CUTOFF) if @typing_node
1889
2144
  summaries[plain_signature][:assumption] = Type::Combinator.untyped
1890
2145
  return Type::Combinator.untyped
@@ -1944,7 +2199,14 @@ module Rigor
1944
2199
  return false if RETURN_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
1945
2200
  return true if node.is_a?(Prism::ReturnNode)
1946
2201
 
1947
- node.compact_child_nodes.any? { |child| body_has_explicit_return?(child) }
2202
+ found = false
2203
+ node.rigor_each_child do |child|
2204
+ next unless body_has_explicit_return?(child)
2205
+
2206
+ found = true
2207
+ break
2208
+ end
2209
+ found
1948
2210
  end
1949
2211
 
1950
2212
  # Returns the current assumed summary for `plain_signature`, recording that it was consulted (so the
@@ -1967,7 +2229,9 @@ module Rigor
1967
2229
  def clamp_unroll_result(type, would_have_been_guarded)
1968
2230
  return type unless would_have_been_guarded && !fully_value_pinned?(type)
1969
2231
 
1970
- BudgetTrace.hit(BudgetTrace::RECURSION_GUARD)
2232
+ # ADR-84 WD3 — position 0 (conservative): the would-have-been-guarded match may be an ancestor
2233
+ # frame, and the matched position is not threaded through RecursionContext.
2234
+ note_transient_fallback(BudgetTrace::RECURSION_GUARD, 0)
1971
2235
  scope.record_dynamic_origin(@typing_node, DynamicOrigin::ANALYZER_BUDGET_CUTOFF) if @typing_node
1972
2236
  # ADR-55 WD1 clamp: a guarded extended frame whose body is non-pinned must be byte-identical to the
1973
2237
  # plain guard's `untyped`. This path deliberately does NOT route to the in-progress fixpoint summary:
@@ -1998,7 +2262,9 @@ module Rigor
1998
2262
  if remaining.positive?
1999
2263
  Thread.current[INFERENCE_UNROLL_FUEL_KEY] = remaining - 1
2000
2264
  else
2001
- BudgetTrace.hit(BudgetTrace::RECURSION_UNROLL_FUEL)
2265
+ # ADR-84 WD3 — position 0: fuel is one shared resource seeded at the stack bottom, so its
2266
+ # exhaustion is context-dependent for every nested bracket.
2267
+ note_transient_fallback(BudgetTrace::RECURSION_UNROLL_FUEL, 0)
2002
2268
  end
2003
2269
  remaining
2004
2270
  end