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
@@ -90,10 +90,48 @@ module Rigor
90
90
  add_project_signatures(env, signature_paths)
91
91
  add_virtual_rbs(env, virtual_rbs)
92
92
  synthesize_missing_namespaces(env)
93
- resolved = env.resolve_type_names
93
+ env, resolved = resolve_quarantining_virtual_collisions(env, virtual_rbs)
94
94
  stub_missing_referenced_types(env, resolved, project_sig_files(signature_paths))
95
95
  end
96
96
 
97
+ # True when `content` parses as an RBS signature. {#virtual_rbs_collision_quarantined} uses this to
98
+ # tell a collision-dropped virtual entry (parses, but absent from the env) from a parse-failed one
99
+ # (the synthesizer's own WD6 skip, reported separately).
100
+ def parseable_rbs?(content)
101
+ ::RBS::Parser.parse_signature(::RBS::Buffer.new(name: "(rigor: virtual parse check)", content: content))
102
+ true
103
+ rescue ::RBS::BaseError
104
+ false
105
+ end
106
+
107
+ # Backstop for a virtual-vs-anything `RBS::DuplicatedDeclarationError` that only materialises at
108
+ # `resolve_type_names` (which rebuilds the env from `sources`). {.add_virtual_rbs}'s transactional
109
+ # rescue already handles the add-time case — empirically everything on rbs 4.x — but the rbs gemspec
110
+ # range spans `>= 3.0, < 5.0` (ADR-79) and WHERE duplicate detection fires is an rbs-internal choice
111
+ # this code must not depend on. Resolution rule is the same as the add-time path: the explicit
112
+ # signature wins, the colliding VIRTUAL buffer is dropped whole (`RBS::Environment#unload`) and
113
+ # resolution retries; every pass removes at least one virtual buffer, so the loop is bounded by the
114
+ # virtual-entry count. A duplicate involving no virtual buffer (sig-vs-sig), or an env without
115
+ # `#unload` (rbs 3.x), re-raises into the existing one-warning degrade path.
116
+ #
117
+ # The dropped set is not returned: consumers recover it from the built env via
118
+ # {#virtual_rbs_collision_quarantined}, which also works on a cache HIT where this build never ran.
119
+ def resolve_quarantining_virtual_collisions(env, virtual_rbs)
120
+ virtual_names = virtual_rbs.to_set { |name, _content| name.to_s }
121
+ (virtual_names.size + 1).times do
122
+ return [env, env.resolve_type_names]
123
+ rescue ::RBS::DuplicatedDeclarationError => e
124
+ raise unless env.respond_to?(:unload)
125
+
126
+ culprits = e.decls.filter_map { |decl| decl.location&.buffer&.name }
127
+ .uniq.select { |name| virtual_names.include?(name) }
128
+ raise if culprits.empty?
129
+
130
+ env = env.unload(culprits)
131
+ end
132
+ [env, env.resolve_type_names]
133
+ end
134
+
97
135
  # ADR-5 robustness, second tier. A project `signature_paths:` RBS that *references* a type no loaded
98
136
  # signature declares — `def x: () -> DRb::DRbServer` when the `drb` RBS is not available, or a stale
99
137
  # reference to its own removed `Textbringer::EditorError` — makes
@@ -356,9 +394,20 @@ module Rigor
356
394
  _, directives, decls = ::RBS::Parser.parse_signature(buffer)
357
395
  add_parsed_decls(env, buffer, directives, decls)
358
396
  rescue ::RBS::BaseError
359
- # WD6 fail-soft: a single broken virtual RBS contribution does not pull the whole env down. The
360
- # plugin layer records a `source-rbs-synthesis-failed` info diagnostic in slice 2; here we just
361
- # skip the entry.
397
+ # WD6 fail-soft: a single broken virtual RBS contribution does not pull the whole env down — for
398
+ # a parse error, skipping the entry is enough. But `RBS::Environment#add_source` appends to
399
+ # `env.sources` BEFORE inserting decls, so when the raise is a mid-insert
400
+ # `RBS::DuplicatedDeclarationError` (the entry declares a constant the project's own `sig/`
401
+ # already declares — the expected state for a project migrating between `sig/` and inline
402
+ # annotations, not an authoring error), the POISONED SOURCE is left behind, and
403
+ # `resolve_type_names` — which rebuilds the env from `sources` — re-raises the same error outside
404
+ # this rescue and collapses the WHOLE env to nil (measured on herb: 1,490 classes → 0, `require`
405
+ # itself stopped resolving, 74 false `call.unresolved-toplevel`). Make the skip transactional:
406
+ # drop the poisoned source. The explicit `.rbs` declaration wins — the spec keeps standalone
407
+ # `.rbs` files "the preferred place for complete type definitions" (`overview.md`) — and
408
+ # {#warn_about_virtual_rbs_collisions} names the dropped file. `sources` is the rbs 4.x shape;
409
+ # under the 3.x API this degrades to today's behaviour.
410
+ env.sources.reject! { |source| source.buffer.name == buffer.name } if env.respond_to?(:sources)
362
411
  end
363
412
  end
364
413
 
@@ -478,6 +527,55 @@ module Rigor
478
527
  names_synthesized_in(SYNTHETIC_NAMESPACE_BUFFER)
479
528
  end
480
529
 
530
+ # The project `signature_paths:` files that were QUARANTINED this run (they do not parse, so
531
+ # {RbsLoader.add_project_signatures} skipped them to keep the rest of the env alive), as
532
+ # `[absolute_path, first_error_line]` pairs. Memoised per loader: the detection re-parses only the user's
533
+ # own `sig/` set, but every consumer (the `rbs.coverage.quarantined-signature` diagnostic, `rigor doctor`,
534
+ # the stderr banner) reads it, and a cache HIT reaches it too — the env was built with the file already
535
+ # quarantined, so the condition is invisible in the cached env itself.
536
+ #
537
+ # @return [Array<Array(String, String)>] empty when every `signature_paths:` file parses.
538
+ def quarantined_signatures
539
+ @state[:quarantined] ||= self.class.quarantined_project_signatures(@signature_paths).freeze
540
+ end
541
+
542
+ # The total RBS-environment build failure captured this run, or nil when the env built. Unlike
543
+ # {#quarantined_signatures} — which the env survives, one file lighter, and which is re-derived by
544
+ # re-parsing so a cache HIT reports it too — a total failure (typically `RBS::DuplicatedDeclarationError`:
545
+ # a `signature_paths:` entry redeclaring a constant/class Rigor's bundled RBS already ships) collapses the
546
+ # WHOLE env to nil. A failed build produces no cached success to hide behind (nothing is persisted, so
547
+ # every run re-attempts and re-raises), so this is captured directly in {#env}'s rescue rather than
548
+ # re-derived. Forcing `env` (any query does) populates it.
549
+ #
550
+ # @return [Array(String, String, Array<String>), nil] `[error_class_name, first_error_line,
551
+ # conflicting_buffer_names]`, or nil when the environment built successfully.
552
+ def env_build_failure
553
+ env unless @state[:env_loaded]
554
+ @state[:env_build_failure]
555
+ end
556
+
557
+ # Virtual (inline-synthesized) contributions dropped by the collision quarantine
558
+ # ({.resolve_quarantining_virtual_collisions}): buffer names absent from the built env even though the
559
+ # entry's content is non-empty and parses (a parse failure is the synthesizer's own WD6 skip, reported
560
+ # through the synthesis reporter instead). Derived from the env rather than recorded during build —
561
+ # the {#quarantined_signatures} trick — so a cache HIT, which never runs the build, reports the same
562
+ # condition: the marshalled env simply lacks the dropped buffers.
563
+ #
564
+ # @return [Array<String>] virtual buffer names (source-file paths) whose contribution was dropped.
565
+ def virtual_rbs_collision_quarantined
566
+ @state[:virtual_rbs_collisions] ||= begin
567
+ built = @state[:env]
568
+ if built.nil? || @virtual_rbs.empty?
569
+ [].freeze
570
+ else
571
+ present = built.buffers.to_set(&:name)
572
+ @virtual_rbs.filter_map do |name, content|
573
+ name if !content.empty? && !present.include?(name) && self.class.parseable_rbs?(content)
574
+ end.freeze
575
+ end
576
+ end
577
+ end
578
+
481
579
  # The referenced-but-undeclared types {.stub_missing_referenced_types} stubbed so the project classes
482
580
  # that mention them could build (e.g. an unavailable `DRb::DRbServer`, or a stale
483
581
  # `Textbringer::EditorError`). Recovered off the built env like {#synthesized_namespaces}, so it
@@ -970,8 +1068,10 @@ module Rigor
970
1068
  @state[:env_loaded] = true
971
1069
  @state[:env] = cache_store ? cached_env : build_env
972
1070
  warn_about_quarantined_signatures
1071
+ warn_about_virtual_rbs_collisions
973
1072
  @state[:env]
974
1073
  rescue ::RBS::BaseError => e
1074
+ record_env_build_failure(e)
975
1075
  warn_about_env_build_failure_once(e)
976
1076
  @state[:env] = nil
977
1077
  end
@@ -982,10 +1082,15 @@ module Rigor
982
1082
  # is the visibility half of the fix: a shrinking diagnostic count must never be mistaken for a clean run
983
1083
  # when it actually means "your sig/ stopped loading". No-op when `signature_paths:` is empty (the cost is
984
1084
  # then a single empty-set check) or every file parses.
1085
+ #
1086
+ # `rigor check` ALSO reports this as the `rbs.coverage.quarantined-signature` diagnostic, which is what
1087
+ # reaches JSON / SARIF / CI annotations / the LSP. The banner is kept because the commands that build an
1088
+ # env WITHOUT producing a diagnostic stream (`coverage`, `sig-gen`) have no other channel, and a silently
1089
+ # degraded env is exactly what misleads there too.
985
1090
  def warn_about_quarantined_signatures
986
1091
  return if @state[:quarantine_warned]
987
1092
 
988
- quarantined = self.class.quarantined_project_signatures(@signature_paths)
1093
+ quarantined = quarantined_signatures
989
1094
  return if quarantined.empty?
990
1095
 
991
1096
  @state[:quarantine_warned] = true
@@ -1002,6 +1107,60 @@ module Rigor
1002
1107
  )
1003
1108
  end
1004
1109
 
1110
+ # The collision twin of {#warn_about_quarantined_signatures}: name, once per run, the source files
1111
+ # whose inline-synthesized RBS was dropped because it collides with a declaration another signature
1112
+ # source already made ({.resolve_quarantining_virtual_collisions} — the explicit `.rbs` wins). Without
1113
+ # this the drop is silent, and "my `#:` annotation does nothing" has no visible cause. Reads the
1114
+ # derived {#virtual_rbs_collision_quarantined}, so a cache HIT warns identically.
1115
+ def warn_about_virtual_rbs_collisions
1116
+ return if @state[:virtual_collision_warned]
1117
+
1118
+ dropped = virtual_rbs_collision_quarantined
1119
+ return if dropped.empty?
1120
+
1121
+ @state[:virtual_collision_warned] = true
1122
+ listed = dropped.first(QUARANTINE_WARN_LIMIT)
1123
+ more = dropped.size - listed.size
1124
+ lines = listed.map { |name| " - #{name}" }
1125
+ lines << " … and #{more} more" if more.positive?
1126
+ warn(
1127
+ "rigor: dropped inline-RBS contribution(s) from #{dropped.size} file(s): they declare a
1128
+ " \
1129
+ "constant, alias, or global that another signature source (typically the project's own
1130
+ " \
1131
+ "`sig/`) already declares. The explicit `.rbs` declaration wins, so inline annotations in
1132
+ " \
1133
+ "these files do not bind. Remove the duplication from either side to restore them:
1134
+ " \
1135
+ "#{lines.join("\n")}"
1136
+ )
1137
+ end
1138
+
1139
+ # Records the total RBS-environment build failure captured in {#env}'s rescue so the analysis layer can
1140
+ # surface it as the `rbs.coverage.environment-build-failed` diagnostic (the twin of
1141
+ # {#quarantined_signatures}: quarantine keeps the env alive minus one file, a total failure collapses the
1142
+ # WHOLE env to nil, so every type-of query degrades to `Dynamic[top]` and most rules stop firing). Stored
1143
+ # as `[error_class_name, first_error_line, conflicting_buffer_names]`. The buffer names are lifted off a
1144
+ # `RBS::DuplicatedDeclarationError#decls` — the typical failure, a `signature_paths:` entry redeclaring a
1145
+ # constant/class Rigor's bundled RBS already ships — so the diagnostic can name the colliding files.
1146
+ def record_env_build_failure(error)
1147
+ first_line = error.message.to_s.lines.first.to_s.strip
1148
+ @state[:env_build_failure] = [error.class.name, first_line, env_build_conflict_buffers(error)].freeze
1149
+ end
1150
+
1151
+ # The buffer (file) names carried by the colliding declarations of a `RBS::DuplicatedDeclarationError`, so
1152
+ # the diagnostic names the conflicting signature files rather than guessing. A buffer name is a `String`
1153
+ # path for a project `signature_paths:` file and a `Pathname` for a bundled RBS file, so each is coerced
1154
+ # to `String`. Other RBS build errors carry no `.decls`; they yield an empty list and the diagnostic
1155
+ # falls back to the message's first line alone.
1156
+ def env_build_conflict_buffers(error)
1157
+ return [].freeze unless error.respond_to?(:decls)
1158
+
1159
+ error.decls.filter_map { |decl| decl.location&.buffer&.name }.map(&:to_s).uniq.freeze
1160
+ rescue ::RBS::BaseError, StandardError
1161
+ [].freeze
1162
+ end
1163
+
1005
1164
  def warn_about_env_build_failure_once(error)
1006
1165
  return if @state[:env_build_warned]
1007
1166
 
@@ -2,15 +2,18 @@
2
2
 
3
3
  require "digest"
4
4
 
5
+ require_relative "environment/default_libraries"
5
6
  require_relative "environment/class_registry"
6
7
  require_relative "environment/rbs_loader"
7
8
  require_relative "environment/reflection"
8
9
  require_relative "environment/reporters"
9
10
  require_relative "environment/hkt_registry_holder"
10
11
  require_relative "environment/constant_type_cache_holder"
12
+ require_relative "environment/missing_gem_constant_index"
11
13
  require_relative "environment/bundle_sig_discovery"
12
14
  require_relative "environment/lockfile_resolver"
13
15
  require_relative "environment/rbs_collection_discovery"
16
+ require_relative "plugin/isolation"
14
17
  require_relative "environment/rbs_coverage_report"
15
18
  require_relative "inference/synthetic_method_index"
16
19
  require_relative "inference/project_patched_methods"
@@ -31,31 +34,8 @@ module Rigor
31
34
  DEFAULT_PROJECT_SIG_DIR = "sig"
32
35
  private_constant :DEFAULT_PROJECT_SIG_DIR
33
36
 
34
- # Slice A stdlib expansion. Stdlib libraries that `Environment.for_project` loads on top of RBS core
35
- # unless the caller passes an explicit `libraries:` array. Each entry MUST be a stdlib library name
36
- # accepted by `RBS::EnvironmentLoader#has_library?`; unknown libraries MUST fail-soft
37
- # (`RbsLoader#build_env` already filters through `has_library?`). The default set covers the common
38
- # stdlib surface a Ruby program is likely to import (`pathname`, `optparse`, `json`, `yaml`, `fileutils`,
39
- # `tempfile`, `uri`, `logger`, `date`) plus the analyzer-adjacent gems shipping their own RBS in this
40
- # bundle (`prism`, `rbs`). On hosts where one of these libraries is not installed, the loader silently
41
- # drops it.
42
- #
43
- # Callers MAY add to the default by passing `libraries: %w[csv ...]`; the explicit list is appended to
44
- # `DEFAULT_LIBRARIES` and de-duplicated. Callers that need a strictly RBS-core view MUST construct an
45
- # `RbsLoader` directly instead of going through `for_project`.
46
- DEFAULT_LIBRARIES = %w[
47
- pathname optparse json yaml fileutils tempfile tmpdir
48
- stringio forwardable digest securerandom
49
- uri logger date
50
- pp delegate observable abbrev find tsort singleton
51
- shellwords benchmark base64 did_you_mean
52
- monitor mutex_m timeout
53
- open3 erb etc ipaddr bigdecimal bigdecimal-math
54
- prettyprint random-formatter time open-uri resolv
55
- csv pstore objspace io-console cgi cgi-escape
56
- strscan
57
- prism rbs
58
- ].freeze
37
+ # `DEFAULT_LIBRARIES` is defined in the light `environment/default_libraries.rb` (required below) so the
38
+ # ADR-87 WD4 boot-slimming probe can read it without loading the inference engine.
59
39
 
60
40
  # ADR-72 — a Gemfile.lock gem name mapped to the opt-in plugin id that ships the SAME core-ext RBS. When
61
41
  # that plugin is loaded the auto-overlay for the gem stands down, so the two never both declare the
@@ -84,7 +64,7 @@ module Rigor
84
64
  rbs_extended_reporter: nil, boundary_cross_reporter: nil,
85
65
  source_rbs_synthesis_reporter: nil,
86
66
  synthetic_method_index: nil, project_patched_methods: nil,
87
- hkt_registry: nil)
67
+ hkt_registry: nil, missing_rbs_gems: [], missing_rbs_bundle_path: nil)
88
68
  @class_registry = class_registry
89
69
  @rbs_loader = rbs_loader
90
70
  @plugin_registry = plugin_registry
@@ -109,6 +89,12 @@ module Rigor
109
89
  @hkt_registry_base = hkt_registry || Inference::HktRegistry::EMPTY
110
90
  @hkt_registry_holder = HktRegistryHolder.new
111
91
  @constant_type_cache = ConstantTypeCacheHolder.new
92
+ # ADR-82 WD9 — `[gem_name, version]` pairs for the locked gems with no resolvable RBS. The
93
+ # root-constant ownership index over them is built lazily (first unresolved constant read) so runs
94
+ # whose constants all resolve never pay the entry-file scan.
95
+ @missing_rbs_gems = missing_rbs_gems.freeze
96
+ @missing_rbs_bundle_path = missing_rbs_bundle_path
97
+ @missing_rbs_gem_constants_holder = HktRegistryHolder.new
112
98
  @name_scope = build_name_scope
113
99
  freeze
114
100
  end
@@ -133,6 +119,21 @@ module Rigor
133
119
  end
134
120
  end
135
121
 
122
+ # ADR-82 WD9 — the gem name owning `root_constant_name`, when that gem is locked in the project's
123
+ # Gemfile.lock, ships no resolvable RBS, and its entry file declares the constant at top level. Nil for
124
+ # everything else — the caller then keeps the generic provenance cause. The index is built once, lazily,
125
+ # from RubyGems metadata + a Prism parse of each gem's entry file; no gem code runs (see
126
+ # {MissingGemConstantIndex}). Under the fork pool a worker that never meets an unresolved constant never
127
+ # builds it.
128
+ def missing_rbs_gem_owner(root_constant_name)
129
+ return nil if @missing_rbs_gems.empty?
130
+
131
+ index = @missing_rbs_gem_constants_holder.fetch do
132
+ MissingGemConstantIndex.build(@missing_rbs_gems, bundle_path: @missing_rbs_bundle_path)
133
+ end
134
+ index[root_constant_name.to_s]
135
+ end
136
+
136
137
  # Backwards-compatible reporter accessors — every existing consumer (rbs_extended, method_dispatcher)
137
138
  # calls these. The frozen `@reporters` container is mutable for slot reassignment via
138
139
  # {#attach_reporters!} below.
@@ -220,6 +221,12 @@ module Rigor
220
221
  auto_detect: bundler_auto_detect,
221
222
  locked_gems: locked.empty? ? nil : locked
222
223
  ).map(&:to_s)
224
+ # ADR-82 WD9 — the resolved bundle root, so the missing-gem constant index reads each RBS-less gem's
225
+ # entry file from the TARGET's bundle (not rigor's own — see `MissingGemConstantIndex`). Resolved
226
+ # once here; passed through to the lazy index build.
227
+ bundle_root = resolve_target_bundle_root(
228
+ bundle_path: bundler_bundle_path, project_root: root, auto_detect: bundler_auto_detect
229
+ )
223
230
  # O4 Layer 3 slice 2 — when `rbs collection install` has been run for the target project, parse the
224
231
  # resulting `rbs_collection.lock.yaml` and feed each gem's `<collection_path>/<name>/<version>/`
225
232
  # directory into `signature_paths:`. Stdlib-typed entries are skipped (already covered by
@@ -248,10 +255,9 @@ module Rigor
248
255
  # diagnostic. Appended last so any RBS the project already supplies wins, and skipped for a gem whose
249
256
  # opt-in plugin twin is loaded (no duplicate declaration). The paths ride in
250
257
  # `loader_signature_paths`, so the env cache descriptor digests them for free.
251
- overlay_paths = gem_overlay_paths(
252
- locked: locked, default_libraries: merged_libraries,
253
- bundle_sig_paths: gem_sig_paths, rbs_collection_paths: collection_paths,
254
- plugin_registry: plugin_registry
258
+ missing_gems, overlay_paths = missing_gems_and_overlay_paths(
259
+ locked: locked, default_libraries: merged_libraries, bundle_sig_paths: gem_sig_paths,
260
+ rbs_collection_paths: collection_paths, plugin_registry: plugin_registry
255
261
  )
256
262
  loader_signature_paths = resolved_paths + plugin_sig_paths + gem_sig_paths +
257
263
  collection_paths + overlay_paths
@@ -286,7 +292,9 @@ module Rigor
286
292
  source_rbs_synthesis_reporter: source_rbs_synthesis_reporter,
287
293
  synthetic_method_index: synthetic_method_index,
288
294
  project_patched_methods: project_patched_methods,
289
- hkt_registry: Builtins::HktBuiltins.registry
295
+ hkt_registry: Builtins::HktBuiltins.registry,
296
+ missing_rbs_gems: missing_gems,
297
+ missing_rbs_bundle_path: bundle_root
290
298
  )
291
299
  end
292
300
  # rubocop:enable Metrics/MethodLength, Metrics/ParameterLists
@@ -298,22 +306,42 @@ module Rigor
298
306
  sig.directory? ? [sig] : []
299
307
  end
300
308
 
301
- # ADR-72resolve the bundled RBS overlay directories to load for this project. A gem is eligible when
302
- # it is locked in the Gemfile.lock, classified `:missing` by {RbsCoverageReport} (no RBS through
303
- # default-library / vendored / bundle-`sig/` / rbs-collection), and its conflicting opt-in plugin (if
304
- # any) is not loaded. Returns `[Pathname]`, deterministically ordered, or `[]` when no lockfile / no
305
- # eligible gem.
306
- def gem_overlay_paths(locked:, default_libraries:, bundle_sig_paths:,
307
- rbs_collection_paths:, plugin_registry:)
308
- return [] if locked.empty?
309
-
310
- missing = RbsCoverageReport.classify(
309
+ # ADR-82 WD9 — the resolved bundle root for the missing-gem constant index, doubling (ADR-90) as the
310
+ # hand-off to the plugin isolation layer: ADR-39 target-library invocation (`Plugin::Inflector`
311
+ # activesupport, the Rack status table) can then require the analyzed project's own locked gems when
312
+ # Rigor's host gem environment does not carry them the standalone `gem install rigortype` case,
313
+ # where activesupport is deliberately not a runtime dependency.
314
+ def resolve_target_bundle_root(bundle_path:, project_root:, auto_detect:)
315
+ bundle_root = BundleSigDiscovery.resolve_bundle_path(
316
+ bundle_path: bundle_path, project_root: project_root, auto_detect: auto_detect
317
+ )&.to_s
318
+ Plugin::Isolation.target_bundle_root = bundle_root
319
+ bundle_root
320
+ end
321
+
322
+ # The `:missing` classification (locked gems with no RBS through any resolution path), computed once
323
+ # and consumed twice: as `[gem_name, version]` pairs for the ADR-82 WD9 constant-ownership index, and
324
+ # as the eligible-gem set for the ADR-72 overlay resolution. Returns `[pairs, overlay_paths]`.
325
+ def missing_gems_and_overlay_paths(locked:, default_libraries:, bundle_sig_paths:,
326
+ rbs_collection_paths:, plugin_registry:)
327
+ return [[], []] if locked.empty?
328
+
329
+ rows = RbsCoverageReport.classify(
311
330
  locked_gems: locked, default_libraries: default_libraries,
312
331
  bundle_sig_paths: bundle_sig_paths, rbs_collection_paths: rbs_collection_paths
313
- ).select { |row| row.source == :missing }.map(&:gem_name)
332
+ ).select { |row| row.source == :missing }
333
+ overlays = gem_overlay_paths(missing_gem_names: rows.map(&:gem_name), plugin_registry: plugin_registry)
334
+ [rows.map { |row| [row.gem_name, row.version] }, overlays]
335
+ end
336
+
337
+ # ADR-72 — resolve the bundled RBS overlay directories to load for this project. A gem is eligible when
338
+ # it is classified `:missing` (see {missing_rbs_gem_rows}) and its conflicting opt-in plugin (if any)
339
+ # is not loaded. Returns `[Pathname]`, deterministically ordered, or `[]` when no eligible gem.
340
+ def gem_overlay_paths(missing_gem_names:, plugin_registry:)
341
+ return [] if missing_gem_names.empty?
314
342
 
315
343
  loaded_ids = plugin_registry ? plugin_registry.ids.to_set : Set.new
316
- eligible = missing.reject do |gem_name|
344
+ eligible = missing_gem_names.reject do |gem_name|
317
345
  plugin_id = GEM_OVERLAY_PLUGIN_IDS[gem_name]
318
346
  plugin_id && loaded_ids.include?(plugin_id)
319
347
  end.sort
@@ -8,6 +8,14 @@ module Rigor
8
8
  # docs/type-specification/inference-budgets.md is not yet wired); counting how often each fires on a real
9
9
  # project is the only way to see where inference actually stops.
10
10
  #
11
+ # A second family of counters (the `MEMO_*` categories) profiles the ADR-57 user-method return memo
12
+ # (`ExpressionTyper#infer_user_method_return`) rather than a cutoff — inference entries, memo hits/misses,
13
+ # the split of non-stored results by reason, and body-evaluation (compute) entries — plus two
14
+ # per-signature distributions (body-eval count and distinct-memo-key count). This is the evidence for the
15
+ # next return-memo design slice: a finalization-aware taint gate (recover the `consult-tainted`
16
+ # non-stores) vs memo-key normalization (collapse arg-granularity thrash — a signature whose distinct-key
17
+ # count far exceeds its body-eval savings).
18
+ #
11
19
  # Three categories, one per guard site:
12
20
  #
13
21
  # - {RECURSION_GUARD} — `ExpressionTyper#infer_user_method_return` detected a `(receiver, method)` cycle
@@ -44,9 +52,42 @@ module Rigor
44
52
  # `Dynamic[top]` (the escaping-block floor). Shared by slice B's loop-body fixpoint.
45
53
  BLOCK_WRITEBACK_CAP = :block_writeback_cap
46
54
 
55
+ # ADR-57 return-memo profile counters (not cutoffs — see the module doc). All bumped by
56
+ # `ExpressionTyper#infer_user_method_return` / `#compute_user_method_return`.
57
+ # - {MEMO_ENTRIES} — every `infer_user_method_return` entry (a user-method return inference).
58
+ # - {MEMO_HITS} / {MEMO_MISSES} — the memo was consulted (candidate frame) and the key was present /
59
+ # absent. Consults = hits + misses.
60
+ # - {MEMO_BODY_EVALS} — every `compute_user_method_return` entry (a body-evaluation compute; some are
61
+ # in-cycle re-entries that consult an ADR-55 summary rather than walk the body — those coincide with a
62
+ # `RECURSION_GUARD` hit). Body evals = misses + on-stack refusals (ADR-84 WD3 reduced candidacy to the
63
+ # on-stack check, so unroll-in-flight refusals are structurally gone).
64
+ # - {MEMO_REFUSE_ON_STACK} — the frame was not a memo candidate (bypassed the memo and computed): its
65
+ # plain `(receiver, method)` signature was already on the recursion guard stack.
66
+ # - {MEMO_REFUSE_UNROLL} — pre-ADR-84 blanket refusal (constant-arg unroll in flight). Kept in the table
67
+ # as the ADR-84 WD3 gate pin: structurally unreachable, must read ~0.
68
+ # - {MEMO_REFUSE_CONSULT_TAINTED} — a candidate frame computed a result but an ADR-55 fixpoint summary
69
+ # was *consulted* during the compute, so the result is a transient Kleene iterate and was NOT stored.
70
+ # - {MEMO_REFUSE_TRANSIENT} — ADR-84 WD3: a candidate frame's compute bracket saw a
71
+ # transient-machinery event (recursion-guard hit / unroll-fuel exhaustion / ADR-55 WD1 clamp /
72
+ # fixpoint-cap collapse — `ExpressionTyper#note_transient_fallback`) that referenced a stack frame
73
+ # BELOW the bracket's entry depth, so the ancestor context influenced the result and it was NOT
74
+ # stored. Own-machinery events (a recursive method's own fixpoint) do not refuse; top-of-stack
75
+ # computes are standalone by construction. Replaces the blanket unroll-in-flight exclusion.
76
+ MEMO_ENTRIES = :memo_entries
77
+ MEMO_HITS = :memo_hits
78
+ MEMO_MISSES = :memo_misses
79
+ MEMO_BODY_EVALS = :memo_body_evals
80
+ MEMO_REFUSE_ON_STACK = :memo_refuse_on_stack
81
+ MEMO_REFUSE_UNROLL = :memo_refuse_unroll
82
+ MEMO_REFUSE_CONSULT_TAINTED = :memo_refuse_consult_tainted
83
+ MEMO_REFUSE_TRANSIENT = :memo_refuse_transient
84
+
47
85
  CATEGORIES = [
48
86
  RECURSION_GUARD, ANCESTOR_WALK_LIMIT, HKT_FUEL_EXHAUSTED, RECURSION_UNROLL_FUEL,
49
- RECURSION_FIXPOINT_CAP, BLOCK_WRITEBACK_CAP
87
+ RECURSION_FIXPOINT_CAP, BLOCK_WRITEBACK_CAP,
88
+ MEMO_ENTRIES, MEMO_HITS, MEMO_MISSES, MEMO_BODY_EVALS,
89
+ MEMO_REFUSE_ON_STACK, MEMO_REFUSE_UNROLL, MEMO_REFUSE_CONSULT_TAINTED,
90
+ MEMO_REFUSE_TRANSIENT
50
91
  ].freeze
51
92
 
52
93
  # Distribution (histogram) categories — read-only observations of a value's size at a site, used to
@@ -55,12 +96,28 @@ module Rigor
55
96
  # `Combinator.union` produces — the distribution the `union_size` budget default should be set from.
56
97
  UNION_ARITY = :union_arity
57
98
 
58
- DISTRIBUTION_CATEGORIES = [UNION_ARITY].freeze
99
+ # ADR-57 return-memo per-signature distributions — `{signature => count}` maps, NOT integer-size
100
+ # histograms, so `summarize` (percentiles over integer values) does not apply; read them with
101
+ # {distribution}. Both keyed by the `"Receiver#method"` plain signature.
102
+ # - {MEMO_BODY_EVAL_BY_SIGNATURE} — body-eval (compute) count per signature (via {observe}).
103
+ # - {MEMO_DISTINCT_KEY_BY_SIGNATURE} — distinct memo keys per signature (via {observe_distinct}), i.e.
104
+ # how many `(receiver, arg-type)` variants a signature was memoised under. A count far above the
105
+ # signature's body-eval savings is arg-granularity thrash — the key-normalization candidate.
106
+ MEMO_BODY_EVAL_BY_SIGNATURE = :memo_body_eval_by_signature
107
+ MEMO_DISTINCT_KEY_BY_SIGNATURE = :memo_distinct_key_by_signature
108
+
109
+ DISTRIBUTION_CATEGORIES = [
110
+ UNION_ARITY, MEMO_BODY_EVAL_BY_SIGNATURE, MEMO_DISTINCT_KEY_BY_SIGNATURE
111
+ ].freeze
59
112
 
60
113
  @enabled = !ENV["RIGOR_BUDGET_TRACE"].to_s.empty?
61
114
  @mutex = Mutex.new
62
115
  @counts = Hash.new(0)
63
116
  @distributions = Hash.new { |h, k| h[k] = Hash.new(0) }
117
+ # Auxiliary de-duplication state for {observe_distinct}: category → Set of the `[bucket, member]`
118
+ # pairs already counted, so a repeated `(signature, memo-key)` observation is counted once. Only ever
119
+ # written when tracing is enabled; not part of the reported surface.
120
+ @distinct_seen = Hash.new { |h, k| h[k] = Set.new }
64
121
 
65
122
  module_function
66
123
 
@@ -101,6 +158,23 @@ module Rigor
101
158
  @mutex.synchronize { @distributions[category][value] += 1 }
102
159
  end
103
160
 
161
+ # Records one observation of `member` into `category`'s `{bucket => count}` map, but only the FIRST time
162
+ # a given `(bucket, member)` pair is seen — so the resulting count per bucket is the number of DISTINCT
163
+ # members, not the number of observations. Used for the distinct-memo-key-per-signature distribution.
164
+ # No-op (one boolean check) when disabled.
165
+ def observe_distinct(category, bucket, member)
166
+ return unless @enabled
167
+
168
+ @mutex.synchronize do
169
+ seen = @distinct_seen[category]
170
+ pair = [bucket, member]
171
+ unless seen.include?(pair)
172
+ seen << pair
173
+ @distributions[category][bucket] += 1
174
+ end
175
+ end
176
+ end
177
+
104
178
  # Frozen `{value => count}` histogram for a distribution category.
105
179
  def distribution(category)
106
180
  @mutex.synchronize { @distributions[category].dup.freeze }
@@ -137,6 +211,7 @@ module Rigor
137
211
  @mutex.synchronize do
138
212
  @counts.clear
139
213
  @distributions.clear
214
+ @distinct_seen.clear
140
215
  end
141
216
  end
142
217
  end
@@ -144,6 +144,16 @@ module Rigor
144
144
  RANGE_EXTRA = %i[step].freeze
145
145
  INTEGER_EXTRA = %i[times upto downto].freeze
146
146
 
147
+ # IO / File / StringIO line- and byte-iteration methods invoke the block immediately, once per line /
148
+ # byte / char, and never retain it — the same immediate-invocation contract as `Array#each`. Both the
149
+ # singleton `File.foreach` / `IO.foreach` (receiver `singleton(File)` → class name "File") and the
150
+ # instance `io.each_line` / `each_char` … forms resolve through the same class-name key. Without these,
151
+ # `File.foreach(path) { case … when … then flag = true when … then return true if flag end }` classifies
152
+ # `:unknown` and misses the loop-body re-narrowing, so a local written in one `when` arm reads its
153
+ # pre-loop value in a sibling arm and a guarding condition folds to a spurious constant.
154
+ IO_ITERATION = %i[each_line each each_byte each_char each_codepoint].freeze
155
+ IO_SINGLETON_ITERATION = %i[foreach].freeze
156
+
147
157
  NON_ESCAPING = {
148
158
  "Array" => (ENUMERABLE_NON_ESCAPING + ARRAY_EXTRA).freeze,
149
159
  "Hash" => (ENUMERABLE_NON_ESCAPING + HASH_EXTRA).freeze,
@@ -151,7 +161,10 @@ module Rigor
151
161
  "Set" => ENUMERABLE_NON_ESCAPING,
152
162
  "Integer" => INTEGER_EXTRA,
153
163
  "Enumerator" => ENUMERABLE_NON_ESCAPING,
154
- "Enumerator::Lazy" => ENUMERABLE_NON_ESCAPING
164
+ "Enumerator::Lazy" => ENUMERABLE_NON_ESCAPING,
165
+ "IO" => (IO_ITERATION + IO_SINGLETON_ITERATION).freeze,
166
+ "File" => (IO_ITERATION + IO_SINGLETON_ITERATION).freeze,
167
+ "StringIO" => IO_ITERATION
155
168
  }.freeze
156
169
 
157
170
  # Methods that are documented to **retain** the block past the call. The block is stored or scheduled,
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Inference
5
+ # ADR-85 WD3 — a lazy reference to a `Prism::DefNode` stored in a cross-file discovery table, in place of
6
+ # the live node itself. When an incremental recheck rebuilds the discovery index from cached per-file seed
7
+ # bundles (ADR-85 WD2), the `def_nodes` / `singleton_def_nodes` tables carry handles for every unchanged
8
+ # file's methods: the file was never parsed this run, so there is no live node to store. A handle records
9
+ # the three things needed to resolve or fingerprint it without the node:
10
+ #
11
+ # - `path` + `node_id` — the resolution key. The per-run parse memo ({Scope#resolve_def_handle}) parses the
12
+ # file once and returns the `Prism::DefNode` whose `node_id` matches (Prism `node_id` is stable across
13
+ # repeated parses of identical bytes). `name` is the cross-check: a resolved node whose `name` differs
14
+ # from the handle's forces a fresh walk (bundle/source skew that the digest gate should make impossible —
15
+ # conservative, never silent).
16
+ # - `fingerprint` — the SHA-256 of the def's source slice, captured when the bundle was built. This lets
17
+ # `Runner#symbol_fingerprints` read the change-detection fingerprint (ADR-46 slice 4) off the handle
18
+ # without re-parsing the file — the one value-deref consumer besides the three accessor choke points.
19
+ #
20
+ # Marshal-clean by construction (Integer + Strings), so it rides the `IncrementalSnapshot` blob directly.
21
+ DefHandle = Data.define(:path, :node_id, :name, :fingerprint)
22
+ end
23
+ end