rigortype 0.2.9 → 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 (184) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/docs/handbook/03-narrowing.md +2 -1
  4. data/docs/handbook/04-tuples-and-shapes.md +7 -3
  5. data/docs/handbook/06-classes.md +18 -9
  6. data/docs/handbook/08-understanding-errors.md +1 -0
  7. data/docs/handbook/11-sig-gen.md +11 -1
  8. data/docs/handbook/appendix-mypy.md +4 -3
  9. data/docs/install.md +14 -6
  10. data/docs/manual/01-installation.md +98 -4
  11. data/docs/manual/02-cli-reference.md +49 -12
  12. data/docs/manual/03-configuration.md +34 -2
  13. data/docs/manual/04-diagnostics.md +23 -0
  14. data/docs/manual/07-plugins.md +4 -4
  15. data/docs/manual/09-editor-integration.md +4 -4
  16. data/docs/manual/11-ci.md +51 -0
  17. data/docs/manual/12-caching.md +65 -0
  18. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
  19. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
  20. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
  21. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
  22. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
  23. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
  24. data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
  25. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  26. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
  27. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
  28. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
  29. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +93 -0
  30. data/lib/rigor/analysis/check_rules.rb +557 -95
  31. data/lib/rigor/analysis/dependency_recorder.rb +93 -9
  32. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  33. data/lib/rigor/analysis/incremental_session.rb +456 -51
  34. data/lib/rigor/analysis/path_expansion.rb +42 -0
  35. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
  36. data/lib/rigor/analysis/rule_catalog.rb +226 -1
  37. data/lib/rigor/analysis/run_cache_key.rb +68 -0
  38. data/lib/rigor/analysis/run_cache_probe.rb +72 -0
  39. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
  40. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
  41. data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
  42. data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
  43. data/lib/rigor/analysis/runner.rb +343 -68
  44. data/lib/rigor/analysis/severity_stamp.rb +43 -0
  45. data/lib/rigor/analysis/worker_session.rb +30 -2
  46. data/lib/rigor/bleeding_edge.rb +37 -8
  47. data/lib/rigor/builtins/regex_refinement.rb +36 -1
  48. data/lib/rigor/cache/descriptor.rb +88 -28
  49. data/lib/rigor/cache/file_digest.rb +158 -0
  50. data/lib/rigor/cache/incremental_snapshot.rb +41 -5
  51. data/lib/rigor/cache/rbs_descriptor.rb +56 -10
  52. data/lib/rigor/cache/store.rb +33 -3
  53. data/lib/rigor/ci_detector.rb +90 -0
  54. data/lib/rigor/cli/annotate_command.rb +15 -9
  55. data/lib/rigor/cli/baseline_command.rb +4 -1
  56. data/lib/rigor/cli/check_command.rb +212 -27
  57. data/lib/rigor/cli/coverage_command.rb +6 -1
  58. data/lib/rigor/cli/docs_command.rb +0 -30
  59. data/lib/rigor/cli/doctor_command.rb +163 -23
  60. data/lib/rigor/cli/lsp_command.rb +5 -0
  61. data/lib/rigor/cli/mcp_command.rb +5 -0
  62. data/lib/rigor/cli/plugins_command.rb +36 -6
  63. data/lib/rigor/cli/plugins_renderer.rb +44 -12
  64. data/lib/rigor/cli/probe_environment.rb +85 -0
  65. data/lib/rigor/cli/sig_gen_command.rb +36 -9
  66. data/lib/rigor/cli/skill_command.rb +1 -32
  67. data/lib/rigor/cli/trace_command.rb +5 -6
  68. data/lib/rigor/cli/type_of_command.rb +9 -8
  69. data/lib/rigor/cli/type_scan_command.rb +8 -8
  70. data/lib/rigor/cli.rb +15 -4
  71. data/lib/rigor/config_audit.rb +48 -2
  72. data/lib/rigor/configuration/severity_profile.rb +31 -0
  73. data/lib/rigor/configuration.rb +156 -8
  74. data/lib/rigor/environment/default_libraries.rb +35 -0
  75. data/lib/rigor/environment/rbs_loader.rb +164 -5
  76. data/lib/rigor/environment.rb +19 -27
  77. data/lib/rigor/inference/budget_trace.rb +77 -2
  78. data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
  79. data/lib/rigor/inference/def_handle.rb +23 -0
  80. data/lib/rigor/inference/def_node_resolver.rb +90 -0
  81. data/lib/rigor/inference/def_return_typer.rb +2 -1
  82. data/lib/rigor/inference/expression_typer.rb +361 -113
  83. data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
  84. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
  85. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
  86. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
  87. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +11 -0
  88. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
  89. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
  90. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
  91. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
  92. data/lib/rigor/inference/method_dispatcher.rb +57 -1
  93. data/lib/rigor/inference/mutation_widening.rb +42 -8
  94. data/lib/rigor/inference/narrowing.rb +127 -17
  95. data/lib/rigor/inference/parameter_inference_collector.rb +15 -0
  96. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  97. data/lib/rigor/inference/rbs_type_translator.rb +8 -1
  98. data/lib/rigor/inference/scope_indexer.rb +451 -36
  99. data/lib/rigor/inference/statement_evaluator.rb +64 -15
  100. data/lib/rigor/inference/struct_fold_safety.rb +48 -11
  101. data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
  102. data/lib/rigor/inference/void_origin.rb +25 -0
  103. data/lib/rigor/inference/void_tail_summary.rb +220 -0
  104. data/lib/rigor/language_server/completion_provider.rb +2 -1
  105. data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
  106. data/lib/rigor/language_server/folding_range_provider.rb +2 -1
  107. data/lib/rigor/language_server/selection_range_provider.rb +2 -1
  108. data/lib/rigor/language_server/signature_help_provider.rb +2 -1
  109. data/lib/rigor/plugin/base.rb +9 -22
  110. data/lib/rigor/plugin/inflector.rb +12 -3
  111. data/lib/rigor/plugin/io_boundary.rb +5 -1
  112. data/lib/rigor/plugin/isolation.rb +81 -11
  113. data/lib/rigor/plugin/load_error.rb +10 -1
  114. data/lib/rigor/plugin/loader.rb +96 -14
  115. data/lib/rigor/plugin/node_rule_walk.rb +5 -3
  116. data/lib/rigor/plugin/registry.rb +32 -23
  117. data/lib/rigor/plugin.rb +26 -0
  118. data/lib/rigor/protection/mutator.rb +3 -2
  119. data/lib/rigor/reflection.rb +64 -0
  120. data/lib/rigor/runtime/jit.rb +128 -0
  121. data/lib/rigor/scope/discovery_index.rb +12 -2
  122. data/lib/rigor/scope.rb +122 -28
  123. data/lib/rigor/sig_gen/classification.rb +5 -1
  124. data/lib/rigor/sig_gen/generator.rb +38 -6
  125. data/lib/rigor/sig_gen/observation_collector.rb +4 -3
  126. data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
  127. data/lib/rigor/sig_gen/renderer.rb +10 -0
  128. data/lib/rigor/sig_gen/write_result.rb +9 -4
  129. data/lib/rigor/sig_gen/writer.rb +24 -2
  130. data/lib/rigor/source/node_children.rb +116 -0
  131. data/lib/rigor/source/node_locator.rb +3 -1
  132. data/lib/rigor/source/node_walker.rb +4 -2
  133. data/lib/rigor/source.rb +1 -0
  134. data/lib/rigor/type/difference.rb +28 -24
  135. data/lib/rigor/type/hash_shape.rb +34 -10
  136. data/lib/rigor/version.rb +1 -1
  137. data/lib/rigortype.rb +24 -0
  138. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
  139. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
  140. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
  141. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
  142. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +3 -1
  143. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -2
  144. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
  145. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
  146. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
  147. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
  148. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
  149. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
  150. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
  151. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
  152. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
  153. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
  154. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +3 -1
  155. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
  156. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +7 -5
  157. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
  158. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
  159. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
  160. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
  161. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
  162. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
  163. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
  164. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
  165. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
  166. data/sig/prism_node_children.rbs +9 -0
  167. data/sig/rigor/inference.rbs +7 -0
  168. data/sig/rigor/plugin/base.rbs +2 -3
  169. data/sig/rigor/reflection.rbs +1 -0
  170. data/sig/rigor/scope.rbs +12 -1
  171. data/skills/rigor-ci-setup/SKILL.md +10 -0
  172. data/skills/rigor-editor-setup/SKILL.md +11 -0
  173. data/skills/rigor-next-steps/SKILL.md +23 -2
  174. data/skills/rigor-plugin-author/SKILL.md +3 -3
  175. data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
  176. data/skills/rigor-plugin-review/SKILL.md +2 -2
  177. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
  178. data/skills/rigor-project-init/SKILL.md +29 -3
  179. data/skills/rigor-project-init/references/01-detect.md +1 -1
  180. data/skills/rigor-project-init/references/02-configure.md +39 -5
  181. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
  182. data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
  183. metadata +42 -24
  184. 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
@@ -413,65 +443,21 @@ module Rigor
413
443
  inner
414
444
  end
415
445
 
416
- # Try the literal name first, then walk Ruby's lexical lookup by progressively prefixing the surrounding
417
- # class path (peeled one `::segment` at a time). For each candidate the lookup consults
418
- # `Environment#singleton_for_name` (a class object) and then `Environment#constant_for_name` (a
419
- # non-class constant value such as `BUCKETS: Array[Symbol]`). Returns the matched `Rigor::Type` or nil;
420
- # the caller decides whether to fall back.
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.
421
451
  def resolve_constant_name(name)
422
- env = scope.environment
423
- discovered = scope.discovered_classes
424
- in_source = scope.in_source_constants
425
- lexical_constant_candidates(name).each do |candidate|
426
- singleton = env.singleton_for_name(candidate)
427
- return singleton if singleton
428
-
429
- in_source_class = discovered[candidate]
430
- return in_source_class if in_source_class
431
-
432
- # In-source value-bearing constants take precedence over RBS constant decls because user code is
433
- # the authoritative source for its own constants.
434
- in_source_value = in_source[candidate]
435
- return in_source_value if in_source_value
436
-
437
- value = env.constant_for_name(candidate)
438
- return value if value
439
- end
440
- nil
441
- end
442
-
443
- # The candidate qualified names to try, in Ruby's lexical order: most-qualified first (the surrounding
444
- # class path joined to `name`), then progressively less-qualified, then the bare `name`. Top-level
445
- # scopes (no `self_type`) yield only `[name]`, preserving the pre-walk behaviour.
446
- def lexical_constant_candidates(name)
447
- prefix = enclosing_class_path
448
- candidates = []
449
- while prefix && !prefix.empty?
450
- candidates << "#{prefix}::#{name}"
451
- # Strip the last `::` segment without `rpartition`'s throwaway 3-element array + extra substrings
452
- # (this loop is the sole caller of the `String#rpartition` allocation seen in the profile): `rindex`
453
- # + slice gives the same prefix, or nil.
454
- idx = prefix.rindex("::")
455
- prefix = idx ? prefix[0, idx] : nil
456
- end
457
- candidates << name
458
- candidates
459
- end
460
-
461
- # Pulls the enclosing qualified class name out of `scope.self_type` when one is set. `Nominal[T]` and
462
- # `Singleton[T]` both expose `class_name`. Returns nil when no class context is available (top-level).
463
- def enclosing_class_path
464
- st = scope.self_type
465
- case st
466
- when Type::Nominal, Type::Singleton then st.class_name
467
- end
452
+ Reflection.resolve_constant_type(name, scope: scope)
468
453
  end
469
454
 
470
455
  # Slice 5 phase 1 upgrades hash literals to `HashShape{...}` when every entry is a static `AssocNode`
471
- # whose key is a `SymbolNode` or `StringNode` with a known value (covering the `{ a: 1, "b" => 2 }`
472
- # pattern and falling back to the generic `Hash[K, V]` form otherwise). Splatted entries (`{ **other }`)
473
- # and dynamic keys widen to the underlying `Hash[K, V]` form by unioning the types each entry exposes;
474
- # 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.
475
461
  def type_of_hash(node)
476
462
  elements = node.respond_to?(:elements) ? node.elements : []
477
463
  # v0.0.7 — `{}` resolves to the empty `HashShape{}` carrier rather than `Nominal[Hash]`, mirroring the
@@ -491,16 +477,22 @@ module Rigor
491
477
  )
492
478
  end
493
479
 
494
- # Builds `HashShape{...}` when every entry is an `AssocNode` whose key is a static Symbol or String
495
- # 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.
496
489
  def static_hash_shape_for(elements)
497
490
  pairs = {}
498
491
  elements.each do |entry|
499
492
  return nil unless entry.is_a?(Prism::AssocNode)
500
493
 
501
494
  key = static_hash_key(entry.key)
502
- return nil if key.nil?
503
- return nil if pairs.key?(key)
495
+ return nil if key.equal?(NO_STATIC_HASH_KEY)
504
496
 
505
497
  pairs[key] = type_of(entry.value)
506
498
  end
@@ -509,16 +501,22 @@ module Rigor
509
501
  Type::Combinator.hash_shape_of(pairs)
510
502
  end
511
503
 
512
- # Returns the static (Symbol|String) literal carried by a hash key node, or nil when the key is
513
- # dynamic. We only treat SymbolNode#value and StringNode#unescaped as static when they are non-nil
514
- # (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.
515
508
  def static_hash_key(node)
516
509
  case node
517
510
  when Prism::SymbolNode
518
511
  raw = node.value
519
- raw&.to_sym
512
+ raw.nil? ? NO_STATIC_HASH_KEY : raw.to_sym
520
513
  when Prism::StringNode
521
- 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
522
520
  end
523
521
  end
524
522
 
@@ -1609,18 +1607,106 @@ module Rigor
1609
1607
  # args change folds (`factorial(5)` vs `factorial(6)`), so a coarser key would serve a stale fold. The
1610
1608
  # third unsafe dimension — the ADR-55 recursion machinery (unroll fuel / fixpoint Kleene
1611
1609
  # assumption / WD1 clamp) producing a TRANSIENT result rather than a final return — is excluded
1612
- # structurally: the memo is consulted and populated ONLY when the incoming guard stack is empty (a
1613
- # genuine top-of-stack entry, whose result is final and cannot be an in-progress assumption or a
1614
- # 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).
1615
1624
  #
1616
- # Keyed by the identity of the frozen discovery `def_nodes` table (a new analysis generation lands in
1617
- # a fresh bucket, mirroring `class_graph_buckets`) then by the identity of the `def_node` and the
1618
- # `[receiver, *args]` descriptor tuple. `ExpressionTyper` is rebuilt per `Scope#type_of`, so the store
1619
- # lives on `Thread.current`; fork-pool workers are separate processes, so it never crosses a project
1620
- # 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).
1621
1638
  RETURN_MEMO_KEY = :__rigor_user_method_return_memo__
1622
1639
  private_constant :RETURN_MEMO_KEY
1623
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
+
1624
1710
  # Per-inference recursion context threaded through the guard / fixpoint helpers (ADR-55 slice 2).
1625
1711
  # Bundles the call descriptor (`receiver`, `arg_types`, `plain_signature`), the thread-local summary
1626
1712
  # table, and the WD1 clamp flag so the helpers stay within the parameter-list budget. `def_node` is
@@ -1647,7 +1733,7 @@ module Rigor
1647
1733
  RECURSION_VALUE_SIZE_CAP = 64
1648
1734
  private_constant :RECURSION_VALUE_SIZE_CAP
1649
1735
 
1650
- 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)
1651
1737
  return nil if def_node.body.nil?
1652
1738
 
1653
1739
  body_scope = build_user_method_body_scope(def_node, receiver, arg_types)
@@ -1664,6 +1750,9 @@ module Rigor
1664
1750
  stack = (Thread.current[INFERENCE_GUARD_KEY] ||= [])
1665
1751
  summaries = (Thread.current[INFERENCE_SUMMARY_KEY] ||= {})
1666
1752
 
1753
+ # WD0 (RIGOR_BUDGET_TRACE) — one entry into user-method return inference. No-op when disabled.
1754
+ BudgetTrace.hit(BudgetTrace::MEMO_ENTRIES)
1755
+
1667
1756
  # ADR-57 follow-up — return memo. The inferred return is a pure function of `(def_node, receiver,
1668
1757
  # arg_types)` and the frozen discovery index whenever the computation does NOT depend on a transient
1669
1758
  # ADR-55 Kleene assumption (an in-flight fixpoint summary). Two structural preconditions decide
@@ -1679,25 +1768,104 @@ module Rigor
1679
1768
  # call, re-walking the shared sub-readers combinatorially (~932k body evaluations for ~20 tiny
1680
1769
  # methods). The computation itself lives in `compute_user_method_return`.
1681
1770
  unless memo_candidate?(stack, plain_signature)
1771
+ trace_memo_refusal(stack, plain_signature)
1682
1772
  return compute_user_method_return(def_node, body_scope, stack, summaries,
1683
1773
  receiver, arg_types, plain_signature)
1684
1774
  end
1685
1775
 
1686
- memo = return_memo_bucket
1687
- 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),
1688
1810
  arg_types.map { |type| type.describe(:short) }]
1689
- 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
1690
1835
 
1691
- consults_before = Thread.current[SUMMARY_CONSULT_COUNTER_KEY] || 0
1692
- result = compute_user_method_return(def_node, body_scope, stack, summaries,
1693
- receiver, arg_types, plain_signature)
1694
- 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
1695
1839
 
1696
- # Store only a FINAL result. If a fixpoint summary was consulted during the computation, `result`
1697
- # embeds a transient Kleene iterate whose value depends on the iteration in flight, so it must not
1698
- # be shared across call sites.
1699
- memo[memo_key] = result if consults_after == consults_before
1700
- 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
1701
1869
  end
1702
1870
 
1703
1871
  # The ADR-55 recursion-guard + value-unroll + fixpoint body of user-method return inference, factored
@@ -1705,6 +1873,7 @@ module Rigor
1705
1873
  # unchanged from pre-memo behaviour).
1706
1874
  def compute_user_method_return(def_node, body_scope, stack, summaries,
1707
1875
  receiver, arg_types, plain_signature)
1876
+ trace_body_eval(plain_signature)
1708
1877
  # ADR-55 slice 1: when every bound argument is value-pinned, extend the guard key with a stable
1709
1878
  # descriptor of the argument *values* so distinct constant frames may recurse (e.g. `factorial(5)`
1710
1879
  # folds to `Constant[120]`). Distinct constant frames are bounded by `RECURSION_UNROLL_FUEL` per
@@ -1716,7 +1885,10 @@ module Rigor
1716
1885
  signature = [plain_signature, value_key] if extended
1717
1886
 
1718
1887
  if stack.include?(signature)
1719
- 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)
1720
1892
  # ADR-55 slice 2: in-cycle re-entries return the current assumed summary (Kleene iterate, seeded
1721
1893
  # `bot`) instead of bare `untyped`. The fixpoint loop below seeds the entry on the outermost frame;
1722
1894
  # if a re-entry beats it here the entry already exists. The WD4 composition: slice 1's clamp/fuel
@@ -1742,30 +1914,85 @@ module Rigor
1742
1914
  evaluate_guarded_user_method_body(def_node, body_scope, stack, signature, context)
1743
1915
  end
1744
1916
 
1745
- # True when this frame's result is a candidate for the return memo: the structural preconditions, both
1746
- # stable across the body walk, that are necessary (but not sufficient) for a FINAL result. Sufficiency
1747
- # is decided post-hoc in `infer_user_method_return` by the consult-counter check (no transient
1748
- # fixpoint summary was read during the compute) so unlike the prior `memoisable_return?` this
1749
- # deliberately does NOT require an empty `summaries` table: inert seeded-but-unconsulted entries left
1750
- # by unrelated outermost frames do not contaminate a result, and gating on them disabled the memo for
1751
- # an entire non-recursive DAG (the scaling wall). The two preconditions: no constant-arg unroll in
1752
- # flight (its value-keyed frames are transient) and this plain signature not itself on the recursion
1753
- # 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).
1754
1926
  def memo_candidate?(stack, plain_signature)
1755
- # Read the unroll fuel WITHOUT the decrement side effect of `unroll_fuel_remaining`: a constant-arg
1756
- # unroll has begun iff the thread-local is set and the stack is non-empty (the `ensure` in
1757
- # `evaluate_guarded_user_method_body` clears it at stack-empty).
1758
- unroll_idle = stack.empty? || Thread.current[INFERENCE_UNROLL_FUEL_KEY].nil?
1759
- unroll_idle &&
1760
- stack.none? { |frame| plain_part(frame) == plain_signature }
1927
+ stack.none? { |frame| plain_part(frame) == plain_signature }
1761
1928
  end
1762
1929
 
1763
- # Run-scoped return-memo bucket for the current discovery generation. Keyed by the identity of the
1764
- # frozen `def_nodes` table so a new analysis generation (or any scope that swaps the index)
1765
- # 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.
1766
1936
  def return_memo_bucket
1767
- store = (Thread.current[RETURN_MEMO_KEY] ||= {}.compare_by_identity)
1768
- 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)
1769
1996
  end
1770
1997
 
1771
1998
  # Pushes the recursion-guard frame, evaluates the body (the outermost frame for a plain signature runs
@@ -1790,6 +2017,7 @@ module Rigor
1790
2017
  Thread.current[INFERENCE_UNROLL_FUEL_KEY] = nil
1791
2018
  Thread.current[INFERENCE_SUMMARY_KEY] = nil
1792
2019
  Thread.current[SUMMARY_CONSULT_DEPTHS_KEY] = nil
2020
+ Thread.current[TRANSIENT_EVENT_DEPTHS_KEY] = nil
1793
2021
  end
1794
2022
  end
1795
2023
  end
@@ -1879,12 +2107,21 @@ module Rigor
1879
2107
  # soundness fix, 2026-06-12), parking `untyped` in the assumption so any consumer that still reads
1880
2108
  # this signature's summary sees the floor, not the stale `bot` seed.
1881
2109
  def degrade_entangled_fixpoint(summaries, plain_signature)
1882
- 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)
1883
2113
  scope.record_dynamic_origin(@typing_node, DynamicOrigin::ANALYZER_BUDGET_CUTOFF) if @typing_node
1884
2114
  summaries[plain_signature][:assumption] = Type::Combinator.untyped
1885
2115
  Type::Combinator.untyped
1886
2116
  end
1887
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
+
1888
2125
  # One Kleene-iteration step of the fixpoint loop. Joins `computed` into the running assumption
1889
2126
  # (widening value-pinned constituents on the final permitted iteration to force convergence) and
1890
2127
  # either returns a final type — convergence, or the capped `untyped` collapse — or `:continue` to
@@ -1900,9 +2137,9 @@ module Rigor
1900
2137
  return candidate if joined == assumption
1901
2138
 
1902
2139
  if last_iteration
1903
- # Out of iterations and still unstable — collapse to today's
1904
- # widening behaviour.
1905
- 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)
1906
2143
  scope.record_dynamic_origin(@typing_node, DynamicOrigin::ANALYZER_BUDGET_CUTOFF) if @typing_node
1907
2144
  summaries[plain_signature][:assumption] = Type::Combinator.untyped
1908
2145
  return Type::Combinator.untyped
@@ -1962,7 +2199,14 @@ module Rigor
1962
2199
  return false if RETURN_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
1963
2200
  return true if node.is_a?(Prism::ReturnNode)
1964
2201
 
1965
- 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
1966
2210
  end
1967
2211
 
1968
2212
  # Returns the current assumed summary for `plain_signature`, recording that it was consulted (so the
@@ -1985,7 +2229,9 @@ module Rigor
1985
2229
  def clamp_unroll_result(type, would_have_been_guarded)
1986
2230
  return type unless would_have_been_guarded && !fully_value_pinned?(type)
1987
2231
 
1988
- 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)
1989
2235
  scope.record_dynamic_origin(@typing_node, DynamicOrigin::ANALYZER_BUDGET_CUTOFF) if @typing_node
1990
2236
  # ADR-55 WD1 clamp: a guarded extended frame whose body is non-pinned must be byte-identical to the
1991
2237
  # plain guard's `untyped`. This path deliberately does NOT route to the in-progress fixpoint summary:
@@ -2016,7 +2262,9 @@ module Rigor
2016
2262
  if remaining.positive?
2017
2263
  Thread.current[INFERENCE_UNROLL_FUEL_KEY] = remaining - 1
2018
2264
  else
2019
- 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)
2020
2268
  end
2021
2269
  remaining
2022
2270
  end