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
@@ -37,7 +37,7 @@ module Rigor
37
37
  end
38
38
 
39
39
  def dynamic?(plugin) = @dynamic.include?(plugin)
40
- def type_specifier?(plugin) = @type_specifier.include?(plugin)
40
+ def narrowing_facts?(plugin) = @narrowing_facts.include?(plugin)
41
41
 
42
42
  # O(1) "could any plugin contribute a return type for a call named `method_name`?" — false only when
43
43
  # every `dynamic_return` rule is `methods:`-gated on other names, in which case the ungated walk would
@@ -48,12 +48,12 @@ module Rigor
48
48
  @dynamic_global_gate.include?(method_name)
49
49
  end
50
50
 
51
- # O(1) statement-path sibling of {#dispatch_candidate?} over the `type_specifier` rules (which are
51
+ # O(1) statement-path sibling of {#dispatch_candidate?} over the `narrowing_facts` rules (which are
52
52
  # always `methods:`-gated).
53
53
  def statement_candidate?(method_name)
54
- return true if @type_specifier_global_gate.nil?
54
+ return true if @narrowing_facts_global_gate.nil?
55
55
 
56
- @type_specifier_global_gate.include?(method_name)
56
+ @narrowing_facts_global_gate.include?(method_name)
57
57
  end
58
58
 
59
59
  # Per-plugin gate: false when the plugin declares no `dynamic_return` rules at all, or when every rule
@@ -67,11 +67,11 @@ module Rigor
67
67
  gate.nil? || gate.include?(method_name)
68
68
  end
69
69
 
70
- # Per-plugin gate over `type_specifier` rules; same contract as {#dynamic_candidate_for?}.
71
- def type_specifier_candidate_for?(plugin, method_name)
72
- return false unless @type_specifier.include?(plugin)
70
+ # Per-plugin gate over `narrowing_facts` rules; same contract as {#dynamic_candidate_for?}.
71
+ def narrowing_facts_candidate_for?(plugin, method_name)
72
+ return false unless @narrowing_facts.include?(plugin)
73
73
 
74
- gate = @type_specifier_gates[plugin]
74
+ gate = @narrowing_facts_gates[plugin]
75
75
  gate.nil? || gate.include?(method_name)
76
76
  end
77
77
 
@@ -101,13 +101,13 @@ module Rigor
101
101
  def compile_memberships(plugins)
102
102
  plugins.each { |p| reject_legacy_flow_hook!(p) }
103
103
  @dynamic = plugins.reject { |p| p.class.dynamic_returns.empty? }.to_set
104
- @type_specifier = plugins.reject { |p| p.class.type_specifiers.empty? }.to_set
104
+ @narrowing_facts = plugins.reject { |p| p.class.narrowing_facts_rules.empty? }.to_set
105
105
  compile_collector_subsets(plugins)
106
106
  end
107
107
 
108
108
  def compile_collector_subsets(plugins)
109
109
  @for_method_dispatch = plugins.select { |p| @dynamic.include?(p) }.freeze
110
- @for_statement = plugins.select { |p| @type_specifier.include?(p) }.freeze
110
+ @for_statement = plugins.select { |p| @narrowing_facts.include?(p) }.freeze
111
111
  @for_file_diagnostics =
112
112
  plugins.select { |p| file_diagnostics_overridden?(p) || !p.class.node_rules.empty? }.freeze
113
113
  end
@@ -115,9 +115,9 @@ module Rigor
115
115
  # The per-plugin and registry-global method-name gates.
116
116
  def compile_gates
117
117
  @dynamic_gates = build_name_gates(@dynamic) { |p| p.class.dynamic_returns }
118
- @type_specifier_gates = build_name_gates(@type_specifier) { |p| p.class.type_specifiers }
118
+ @narrowing_facts_gates = build_name_gates(@narrowing_facts) { |p| p.class.narrowing_facts_rules }
119
119
  @dynamic_global_gate = union_gate(@dynamic_gates)
120
- @type_specifier_global_gate = union_gate(@type_specifier_gates)
120
+ @narrowing_facts_global_gate = union_gate(@narrowing_facts_gates)
121
121
  end
122
122
 
123
123
  # ADR-52 WD3 — the legacy ungated `flow_contribution_for` hook was deleted pre-1.0. A plugin still
@@ -215,7 +215,7 @@ module Rigor
215
215
  # Ractor. The eventual Phase 4 pool ships `blueprints` across the boundary and calls {.materialize}
216
216
  # per-Ractor; the live `plugins` carriage on the coordinator registry stays unchanged.
217
217
  class Registry
218
- attr_reader :plugins, :load_errors, :blueprints, :contribution_index
218
+ attr_reader :plugins, :load_errors, :blueprints, :contribution_index, :resolved_gem_paths
219
219
 
220
220
  # @param plugins [Array<Rigor::Plugin::Base>] instantiated plugin instances in deterministic order.
221
221
  # @param load_errors [Array<Rigor::Plugin::LoadError>] failures surfaced during loading. Each error is
@@ -223,20 +223,17 @@ module Rigor
223
223
  # @param blueprints [Array<Rigor::Plugin::Blueprint>] frozen, Ractor-shareable replay descriptors
224
224
  # aligned 1:1 with `plugins`. The loader fills this in; callers that construct Registry manually MAY
225
225
  # pass `[]` and accept that {.materialize} cannot replay the set.
226
- def initialize(plugins: [], load_errors: [], blueprints: [])
226
+ # @param resolved_gem_paths [Hash{String=>String,nil}] #194 slice 1 — `gem name => resolved file path`
227
+ # for each successfully required plugin gem, so `rigor plugins` can print where a loaded (and
228
+ # possibly frozen) plugin actually loaded from. Defaults to empty; a worker registry built by
229
+ # {.materialize} carries none (the provenance surface runs only on the coordinator).
230
+ def initialize(plugins: [], load_errors: [], blueprints: [], resolved_gem_paths: {})
227
231
  @plugins = plugins.dup.freeze
228
232
  @load_errors = load_errors.dup.freeze
229
233
  @blueprints = blueprints.dup.freeze
234
+ @resolved_gem_paths = resolved_gem_paths.dup.freeze
230
235
  @contribution_index = ContributionIndex.new(@plugins)
231
- # ADR-52 WD1 — aggregate queries the engine issues per def / per diagnostic candidate / per path are
232
- # compiled once here (the registry is frozen, so the flat_map-on-every-call versions re-derived an
233
- # invariant). `@contracts_by_path` is a mutable per-path memo inside the frozen registry — safe
234
- # because the contract set and the glob semantics are fixed for the lifetime of the run.
235
- @additional_initializers = @plugins.flat_map { |p| safe_manifest(p)&.additional_initializers || [] }.freeze
236
- @open_receivers = @plugins.flat_map { |p| (safe_manifest(p)&.open_receivers || []).map(&:to_s) }.uniq.freeze
237
- @open_receivers_set = @open_receivers.to_set.freeze
238
- @protocol_contracts = @plugins.flat_map { |p| safe_protocol_contracts(p) }.freeze
239
- @contracts_by_path = {}
236
+ compile_aggregates
240
237
  # ADR-52 WD4 — the single engine-owned node-rule walk, compiled once per run from the node-rule
241
238
  # plugin subset (registry order). The runner reuses it for every file; it builds fresh per-file
242
239
  # state internally, so it is safe to freeze and share.
@@ -372,6 +369,18 @@ module Rigor
372
369
 
373
370
  private
374
371
 
372
+ # ADR-52 WD1 — aggregate queries the engine issues per def / per diagnostic candidate / per path are
373
+ # compiled once at construction (the registry is frozen, so the flat_map-on-every-call versions
374
+ # re-derived an invariant). `@contracts_by_path` is a mutable per-path memo inside the frozen registry
375
+ # — safe because the contract set and the glob semantics are fixed for the lifetime of the run.
376
+ def compile_aggregates
377
+ @additional_initializers = @plugins.flat_map { |p| safe_manifest(p)&.additional_initializers || [] }.freeze
378
+ @open_receivers = @plugins.flat_map { |p| (safe_manifest(p)&.open_receivers || []).map(&:to_s) }.uniq.freeze
379
+ @open_receivers_set = @open_receivers.to_set.freeze
380
+ @protocol_contracts = @plugins.flat_map { |p| safe_protocol_contracts(p) }.freeze
381
+ @contracts_by_path = {}
382
+ end
383
+
375
384
  def path_matches_glob?(glob, path)
376
385
  File.fnmatch?(glob, path, FNMATCH_FLAGS) ||
377
386
  File.fnmatch?(File.join("**", glob), path, FNMATCH_FLAGS)
data/lib/rigor/plugin.rb CHANGED
@@ -19,6 +19,15 @@ require_relative "plugin/inflector"
19
19
  module Rigor
20
20
  module Plugin
21
21
  @registered = {}
22
+ # ADR-88 WD4b — process-global map `gem name (String) => [registered plugin id, ...]`, recorded by the
23
+ # loader the FIRST time a gem's `require` runs its body and registers plugins. A second in-process
24
+ # `Loader.load` of the same bare-string (`id:`-less) entry sees `require` return false (the gem is already
25
+ # loaded, so its `Rigor::Plugin.register` calls do not re-run) and an empty newly-registered delta — the
26
+ # loader recovers the gem's plugin id(s) from this memo instead of failing with "did not register any
27
+ # plugin". Populated by {.record_gem_registration}, read by {.ids_for_gem}. This is what makes a second
28
+ # in-process analysis (the `--verify-incremental` full-run oracle, the incremental session's subset
29
+ # re-analysis, the LSP's re-loads) load the same plugins the first run did.
30
+ @gem_registrations = {}
22
31
  @mutex = Mutex.new
23
32
 
24
33
  class << self
@@ -52,10 +61,27 @@ module Rigor
52
61
  @mutex.synchronize { @registered.dup.freeze }
53
62
  end
54
63
 
64
+ # ADR-88 WD4b — remember, for a gem loaded for the first time this process, the plugin id(s) its
65
+ # `require` registered. Idempotent (a repeated first-load records the same ids). See {@gem_registrations}.
66
+ def record_gem_registration(gem_name, ids)
67
+ return if gem_name.nil? || ids.nil? || ids.empty?
68
+
69
+ @mutex.synchronize { @gem_registrations[gem_name.to_s] = ids.map(&:to_s).freeze }
70
+ nil
71
+ end
72
+
73
+ # ADR-88 WD4b — the plugin id(s) `gem_name` registered on its first in-process load, or `[]` when the gem
74
+ # was never loaded this process. The loader consults this when a re-load's `require` no-ops and the
75
+ # newly-registered delta is empty, so a bare-string plugin entry still resolves on the second load.
76
+ def ids_for_gem(gem_name)
77
+ @mutex.synchronize { @gem_registrations[gem_name.to_s] } || []
78
+ end
79
+
55
80
  def unregister!(id = nil)
56
81
  @mutex.synchronize do
57
82
  if id.nil?
58
83
  @registered.clear
84
+ @gem_registrations.clear
59
85
  else
60
86
  @registered.delete(id.to_s)
61
87
  end
@@ -4,6 +4,7 @@ require "prism"
4
4
 
5
5
  require_relative "../scope"
6
6
  require_relative "../inference/scope_indexer"
7
+ require_relative "../source/node_children"
7
8
 
8
9
  module Rigor
9
10
  # ADR-63 Tier 2 — the productized subset of the dev-only mutation-testing harness (`tool/mutation/`, ADR-62).
@@ -111,7 +112,7 @@ module Rigor
111
112
  return if node.nil?
112
113
 
113
114
  blk.call(node)
114
- node.compact_child_nodes.each { |child| walk(child, &blk) }
115
+ node.rigor_each_child { |child| walk(child, &blk) }
115
116
  end
116
117
 
117
118
  def collect(node, out)
@@ -136,7 +137,7 @@ module Rigor
136
137
  @anchor_for[arg] = [node.receiver, node.name.to_s] if literal?(arg)
137
138
  end
138
139
  end
139
- node.compact_child_nodes.each { |child| index_literal_anchors(child) }
140
+ node.rigor_each_child { |child| index_literal_anchors(child) }
140
141
  end
141
142
 
142
143
  def literal?(node)
@@ -93,6 +93,11 @@ module Rigor
93
93
  # Returns the type of the named constant. Joins in-source constants (recorded by
94
94
  # `ScopeIndexer`) and RBS-side constants. In-source wins on collision because the user's
95
95
  # source is the authoritative declaration.
96
+ #
97
+ # This is the flat lookup keyed on the literal `constant_name`. Callers that need Ruby's
98
+ # lexical constant resolution (walking the enclosing class path, and folding in registry
99
+ # classes / source-discovered classes as `Singleton` / class types) use
100
+ # {.resolve_constant_type} instead.
96
101
  def constant_type_for(constant_name, scope: Scope.empty)
97
102
  key = constant_name.to_s
98
103
  in_source = scope.in_source_constants[key]
@@ -101,6 +106,65 @@ module Rigor
101
106
  scope.environment.constant_for_name(constant_name)
102
107
  end
103
108
 
109
+ # Resolves a constant *reference* to its type through Ruby's lexical constant lookup: the
110
+ # most-qualified candidate first (the enclosing class path joined to `name`), then
111
+ # progressively less-qualified, then the bare `name`. Each candidate consults, in order,
112
+ # the class registry (yielding a `Singleton[C]`), source-discovered classes, in-source
113
+ # value constants, and finally RBS-side constants — in-source value constants winning over
114
+ # RBS because the user's source is authoritative for its own constants. Returns the matched
115
+ # `Rigor::Type`, or nil when no source knows the constant.
116
+ #
117
+ # This is the shared owner of the lexical-constant resolution: `Inference::ExpressionTyper`
118
+ # reads it to type a constant read, and `Inference::Narrowing` reads it to recognise a
119
+ # value-pinned `Constant[Regexp]` match-predicate operand.
120
+ def resolve_constant_type(name, scope: Scope.empty)
121
+ env = scope.environment
122
+ discovered = scope.discovered_classes
123
+ in_source = scope.in_source_constants
124
+ lexical_constant_candidates(name, scope: scope).each do |candidate|
125
+ singleton = env.singleton_for_name(candidate)
126
+ return singleton if singleton
127
+
128
+ in_source_class = discovered[candidate]
129
+ return in_source_class if in_source_class
130
+
131
+ # In-source value-bearing constants take precedence over RBS constant decls because
132
+ # user code is the authoritative source for its own constants.
133
+ in_source_value = in_source[candidate]
134
+ return in_source_value if in_source_value
135
+
136
+ value = env.constant_for_name(candidate)
137
+ return value if value
138
+ end
139
+ nil
140
+ end
141
+
142
+ # The candidate qualified names to try, in Ruby's lexical order: most-qualified first (the
143
+ # enclosing class path joined to `name`), then progressively less-qualified, then the bare
144
+ # `name`. A top-level scope (no `self_type`) yields only `[name]`.
145
+ def lexical_constant_candidates(name, scope: Scope.empty)
146
+ prefix = enclosing_class_path(scope)
147
+ candidates = []
148
+ while prefix && !prefix.empty?
149
+ candidates << "#{prefix}::#{name}"
150
+ idx = prefix.rindex("::")
151
+ prefix = idx ? prefix[0, idx] : nil
152
+ end
153
+ candidates << name
154
+ candidates
155
+ end
156
+ private_class_method :lexical_constant_candidates
157
+
158
+ # Pulls the enclosing qualified class name out of `scope.self_type` when one is set.
159
+ # `Nominal[T]` and `Singleton[T]` both expose `class_name`. Returns nil at the top level.
160
+ def enclosing_class_path(scope)
161
+ st = scope.self_type
162
+ case st
163
+ when Type::Nominal, Type::Singleton then st.class_name
164
+ end
165
+ end
166
+ private_class_method :enclosing_class_path
167
+
104
168
  # Returns the RBS `RBS::Definition::Method` for the instance method, or nil when the
105
169
  # class or method is not in RBS. The source-side discovered-method facts are reachable
106
170
  # through {.discovered_method?}; a future slice will unify the two under a
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ # Process-level runtime concerns that sit beside the analyzer rather than
5
+ # inside it. Currently just deferred YJIT enablement.
6
+ module Runtime
7
+ # Deferred YJIT enablement for the long-running analysis commands.
8
+ #
9
+ # Ruby 3.3+ ships YJIT but leaves it off unless the process opts in
10
+ # (`--yjit`, `RUBY_YJIT_ENABLE=1`, or `RubyVM::YJIT.enable`). Enabling it
11
+ # unconditionally at boot is a net LOSS on short `rigor check` / `coverage`
12
+ # runs: the JIT compile cost is paid up front, but a run that finishes in a
13
+ # couple of seconds ends before it amortizes. Measured cold A/B (2026-07-13,
14
+ # Ruby 4.0.5): mastodon `app/models` 4.0s -> 5.0s (0.80x), kramdown `lib`
15
+ # 1.7s -> 2.55s (0.66x), dependabot 1.6s -> 2.34s (0.68x). Long runs win big
16
+ # in the same measurement: mastodon `app`+`lib` 25.2s -> 14.9s (1.69x),
17
+ # redmine `app/models` 8.6s -> 5.5s (1.56x). Allocations and diagnostics are
18
+ # byte-identical under YJIT — the tradeoff is purely wall-time.
19
+ #
20
+ # The resolution is to enable YJIT only once a run has proven long enough to
21
+ # amortize the compile cost. {enable_after} spawns one daemon thread that
22
+ # sleeps the deadline then enables: a run that finishes before the deadline
23
+ # never pays JIT compile (the sub-deadline penalty zone is avoided by
24
+ # construction), while a long run enables mid-flight and JITs the remaining
25
+ # — dominant — work. Long-lived servers (`rigor lsp` / `rigor mcp`) instead
26
+ # enable at boot via {enable_now}, since they always run long.
27
+ #
28
+ # Opt-out: `RIGOR_DISABLE_YJIT=1` makes every entry point a no-op (the pin
29
+ # the byte-identical-diagnostics gate uses). If YJIT is already enabled (the
30
+ # user passed `--yjit` / `RUBY_YJIT_ENABLE=1`) or is unavailable (a build
31
+ # without YJIT, or a non-MRI Ruby), both entry points are no-ops too.
32
+ module Jit
33
+ # Opt-out switch: `RIGOR_DISABLE_YJIT=1` disables both entry points.
34
+ DISABLE_ENV = "RIGOR_DISABLE_YJIT"
35
+
36
+ # Advanced / calibration override of the {enable_after} deadline, in
37
+ # float seconds. Unset (or unparseable / negative) falls back to
38
+ # {DEFAULT_DEADLINE_SECONDS}. Exposed so the deadline can be swept during
39
+ # calibration and tuned per-project without an engine change.
40
+ DEADLINE_ENV = "RIGOR_YJIT_DEADLINE"
41
+
42
+ # The amortization deadline for {enable_after}, in seconds.
43
+ #
44
+ # Calibrated by interleaved cold A/B measurement on 2026-07-13 (Ruby
45
+ # 4.0.5, arm64-darwin25). Enabling YJIT mid-run costs a ~1s compile burst,
46
+ # so a run whose length is only ~0-1s longer than the deadline pays the
47
+ # burst without time to amortize and LOSES (measured: `app/models` at a
48
+ # 3.0s deadline regressed 4.14s -> 4.68s). The short-run cases cluster at
49
+ # <=1.7s (kramdown / dependabot) and ~4.0-4.9s (mastodon `app/models` ~4.1s,
50
+ # mail ~4.9s), with the next case at redmine ~7s. 5.0s parks that danger
51
+ # zone in the empty 5-6s gap: every case <=4.9s finishes before the
52
+ # deadline fires (guaranteed no-YJIT parity — the sleeping thread is killed
53
+ # at exit), while runs long enough to pay off (>=~15s: gitlab, mastodon
54
+ # `app`+`lib`) enable mid-flight and JIT their dominant tail — the mastodon
55
+ # `app`+`lib` win is preserved ~100% (25.4s -> 15.1s, vs 15.1s always-on).
56
+ DEFAULT_DEADLINE_SECONDS = 5.0
57
+
58
+ module_function
59
+
60
+ # Enables YJIT now, unless opted out, already enabled, or unavailable.
61
+ #
62
+ # An imperative command, not a query — the boolean reports whether this
63
+ # call is what enabled YJIT (so the `?`-suffix predicate convention does
64
+ # not apply; hence the Naming/PredicateMethod exemption).
65
+ #
66
+ # @return [Boolean] true if this call enabled YJIT; false if it was a
67
+ # no-op (unavailable / opted out / already enabled).
68
+ def enable_now # rubocop:disable Naming/PredicateMethod
69
+ return false unless available?
70
+ return false if disabled?
71
+ return false if RubyVM::YJIT.enabled?
72
+
73
+ RubyVM::YJIT.enable
74
+ true
75
+ end
76
+
77
+ # Spawns one background thread that sleeps `seconds` then calls
78
+ # {enable_now}. The thread is a daemon in effect — Ruby kills all live
79
+ # threads when the main thread exits — so a run that finishes before the
80
+ # deadline simply never enables YJIT, which is the point.
81
+ #
82
+ # Returns nil (and spawns no thread) when the feature is already a no-op
83
+ # up front (unavailable / opted out / already enabled), so callers never
84
+ # pay for a thread that could only no-op.
85
+ #
86
+ # @param seconds [Numeric] the amortization deadline.
87
+ # @return [Thread, nil] the deadline thread, or nil when no-op up front.
88
+ def enable_after(seconds)
89
+ return nil unless available?
90
+ return nil if disabled?
91
+ return nil if RubyVM::YJIT.enabled?
92
+
93
+ thread = Thread.new do
94
+ sleep(seconds)
95
+ enable_now
96
+ end
97
+ # A background perf optimization must never spill a backtrace onto the
98
+ # run's stderr; on any unexpected failure, stay silent and leave YJIT
99
+ # off. The deadline sleep gives ample time to set this before the body
100
+ # could raise.
101
+ thread.report_on_exception = false
102
+ thread
103
+ end
104
+
105
+ # The resolved {enable_after} deadline: {DEADLINE_ENV} when it parses to a
106
+ # non-negative float, else {DEFAULT_DEADLINE_SECONDS}.
107
+ #
108
+ # @return [Numeric]
109
+ def deadline_seconds
110
+ raw = ENV.fetch(DEADLINE_ENV, nil)
111
+ return DEFAULT_DEADLINE_SECONDS if raw.nil? || raw.empty?
112
+
113
+ value = Float(raw, exception: false)
114
+ value && value >= 0 ? value : DEFAULT_DEADLINE_SECONDS
115
+ end
116
+
117
+ # @return [Boolean] whether this Ruby exposes `RubyVM::YJIT.enable`.
118
+ def available?
119
+ defined?(RubyVM::YJIT.enable) ? true : false
120
+ end
121
+
122
+ # @return [Boolean] whether the opt-out env switch is set.
123
+ def disabled?
124
+ ENV[DISABLE_ENV] == "1"
125
+ end
126
+ end
127
+ end
128
+ end
@@ -21,13 +21,15 @@ module Rigor
21
21
  :discovered_def_nodes,
22
22
  :discovered_singleton_def_nodes,
23
23
  :discovered_def_sources,
24
+ :discovered_singleton_def_sources,
24
25
  :discovered_method_visibilities,
25
26
  :discovered_superclasses,
26
27
  :discovered_includes,
27
28
  :discovered_class_sources,
28
29
  :data_member_layouts,
29
30
  :struct_member_layouts,
30
- :param_inferred_types
31
+ :param_inferred_types,
32
+ :run_generation
31
33
  )
32
34
 
33
35
  class DiscoveryIndex
@@ -48,6 +50,7 @@ module Rigor
48
50
  discovered_def_nodes: EMPTY_TABLE,
49
51
  discovered_singleton_def_nodes: EMPTY_TABLE,
50
52
  discovered_def_sources: EMPTY_TABLE,
53
+ discovered_singleton_def_sources: EMPTY_TABLE,
51
54
  discovered_method_visibilities: EMPTY_TABLE,
52
55
  discovered_superclasses: EMPTY_TABLE,
53
56
  discovered_includes: EMPTY_TABLE,
@@ -60,7 +63,14 @@ module Rigor
60
63
  # `{param_name(Symbol) => Rigor::Type}` map of the union of resolved call-site argument types. Empty on
61
64
  # every normal run; only the `coverage --protection` collection pass populates it today, so a `check` run
62
65
  # leaves it empty and seeds nothing (byte-identical).
63
- param_inferred_types: EMPTY_TABLE
66
+ param_inferred_types: EMPTY_TABLE,
67
+ # ADR-84 WD2 — the run-scope identity token `Analysis::Runner#run_analysis` mints per run (a frozen
68
+ # bare Object) and seeds through `project_scope_seed_tables`. The user-method return memo keys its
69
+ # bucket on this token's identity so hits cross consumer-file boundaries within one run but never
70
+ # cross a run boundary (LSP / ADR-62 warm-loop re-runs land in a fresh bucket). Nil on scopes that
71
+ # never see the runner seed (single-file probes, `run_source` before the seed applies): the memo
72
+ # falls back to today's per-file `discovered_def_nodes` identity for those.
73
+ run_generation: nil
64
74
  )
65
75
  end
66
76
  end