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
@@ -12,7 +12,8 @@ module Rigor
12
12
  #
13
13
  # Steps per entry (in order):
14
14
  #
15
- # 1. Normalise the entry into `{ gem:, id:, config: }`.
15
+ # 0. Skip the entry entirely when it carries `enabled: false` (the ADR-93 WD2 opt-out).
16
+ # 1. Normalise the entry into `{ gem:, id:, config:, enabled: }`.
16
17
  # 2. `require` the gem (failures surface as a {LoadError}).
17
18
  # 3. Look up the registered plugin class by id (or by gem name if the entry omitted an explicit id).
18
19
  # 4. Validate the user's config against the manifest's `config_schema`.
@@ -22,18 +23,57 @@ module Rigor
22
23
  # entries that resolve to the same gem. Failures do not abort the run; the loader collects them on the
23
24
  # {Registry} so the runner can convert each one into a `:plugin_loader` diagnostic.
24
25
  class Loader # rubocop:disable Metrics/ClassLength
25
- attr_reader :services, :requirer
26
+ attr_reader :services, :requirer, :feature_resolver
27
+
28
+ # #194 slice 1 — resolve the file that satisfied `require <gem>` by scanning `$LOADED_FEATURES` for the
29
+ # entry ending in `/<gem>.rb`, LAST match preferred. Provenance-agnostic: it pins the actual file for a
30
+ # Bundler path gem, an installed gem, and a `-I`-injected checkout alike, and — because the feature is
31
+ # already present from the first load — it still resolves when `require` no-ops on a repeat in-process
32
+ # load (ADR-88 WD4b). Returns nil, never raises, in the degenerate cases: a gem whose entry-file
33
+ # basename differs from the gem name, and a spec's fake requirer that never populated `$LOADED_FEATURES`.
34
+ FEATURE_RESOLVER = lambda do |gem_name|
35
+ suffix = "/#{gem_name}.rb"
36
+ $LOADED_FEATURES.rfind { |feature| feature.end_with?(suffix) }
37
+ end
38
+
39
+ # #194 slice 2 (ADR-93 WD5) — the engine's own root, anchored from THIS file's location: the loader
40
+ # lives at `<root>/lib/rigor/plugin/loader.rb`, so three levels up is the engine root. It resolves
41
+ # identically in a git checkout and inside an installed `rigortype` gem, because the gem packages the
42
+ # `plugins/` tree at the same relative path — which is exactly what makes it a trustworthy anchor for
43
+ # the engine's own bundled plugin copies.
44
+ ENGINE_ROOT = File.expand_path("../../..", __dir__)
26
45
 
27
46
  # @param services [Rigor::Plugin::Services]
28
- # @param requirer [#call] takes a gem name and returns truthy on successful require. Defaulted to
29
- # `Kernel.require` via a lambda; the spec injects a fake to avoid touching the real load path.
30
- def initialize(services:, requirer: ->(name) { require name })
47
+ # @param requirer [#call] takes a gem name OR an absolute file path (#194 slice 2 — a bundled plugin is
48
+ # required by its {.bundled_plugin_path}) and returns truthy on successful require. Defaulted to
49
+ # `Kernel.require` via a lambda, which accepts both forms; the spec injects a fake to avoid touching
50
+ # the real load path.
51
+ # @param feature_resolver [#call] takes a gem name and returns the absolute path `require` resolved it
52
+ # to (or nil). Defaulted to {FEATURE_RESOLVER}; the spec injects a fake so it never has to mutate the
53
+ # real `$LOADED_FEATURES` global.
54
+ def initialize(services:, requirer: ->(name) { require name }, feature_resolver: FEATURE_RESOLVER)
31
55
  @services = services
32
56
  @requirer = requirer
57
+ @feature_resolver = feature_resolver
58
+ end
59
+
60
+ def self.load(configuration:, services:, requirer: ->(name) { require name }, feature_resolver: FEATURE_RESOLVER)
61
+ new(services: services, requirer: requirer, feature_resolver: feature_resolver).load(configuration.plugins)
33
62
  end
34
63
 
35
- def self.load(configuration:, services:, requirer: ->(name) { require name })
36
- new(services: services, requirer: requirer).load(configuration.plugins)
64
+ # #194 slice 2 (ADR-93 WD5) the absolute path of the engine's OWN bundled copy of a plugin gem
65
+ # (`<ENGINE_ROOT>/plugins/<gem>/lib/<gem>.rb`), or nil when the engine does not bundle it. A bundled
66
+ # plugin is required by this path rather than by gem name, so a stale installed `rigortype` gem can
67
+ # never displace the engine's own versioned copy through RubyGems name resolution — the engine and its
68
+ # bundled plugins are versioned together. Returns nil for a third-party / project-bundle plugin and for
69
+ # a trimmed packaging (the ADR-27 single-binary target that ships no `plugins/` tree), where the caller
70
+ # falls back to today's gem-name require so no install mode regresses. The rule is uniform across the
71
+ # auto-wired `rigor-rbs-inline` default and every user-listed entry, because the skew mechanism is
72
+ # identical for both. Also read by `rigor doctor`'s skew check (#194 slice 3) to decide which loaded
73
+ # plugins the engine bundles.
74
+ def self.bundled_plugin_path(gem_name)
75
+ path = File.join(ENGINE_ROOT, "plugins", gem_name, "lib", "#{gem_name}.rb")
76
+ File.file?(path) ? path : nil
37
77
  end
38
78
 
39
79
  # @param entries [Array<String, Hash>] the raw `plugins:` list from the configuration.
@@ -42,12 +82,21 @@ module Rigor
42
82
  plugins = []
43
83
  load_errors = []
44
84
  seen_ids = {}
85
+ # #194 slice 1 — `gem name => resolved file path` for every gem the loader successfully required.
86
+ # A frozen plugin instance (e.g. `rigor-rbs-inline` self-freezes per ADR-32) can't carry the path,
87
+ # so it rides on the Registry keyed by gem name; the `rigor plugins` loaded row reads it back.
88
+ @resolved_gem_paths = {}
45
89
 
46
90
  Array(entries).each_with_index do |raw, index|
47
91
  entry = normalise_entry(raw, index)
48
92
  rescue LoadError => e
49
93
  load_errors << e
50
94
  else
95
+ # ADR-93 WD2 — `enabled: false` opts a listed plugin out entirely: it is neither required nor
96
+ # trusted nor id-checked. This is the project-level opt-out for the auto-wired `rigor-rbs-inline`
97
+ # default (a user re-lists it with `enabled: false`), but it works for any entry.
98
+ next unless entry[:enabled]
99
+
51
100
  begin
52
101
  plugin = resolve_and_instantiate(entry, seen_ids)
53
102
  plugins << plugin if plugin
@@ -64,19 +113,23 @@ module Rigor
64
113
  load_errors.concat(sort_errors)
65
114
 
66
115
  blueprints = plugins.map { |plugin| Blueprint.new(klass_name: plugin.class.name, config: plugin.config) }
67
- Registry.new(plugins: plugins, blueprints: blueprints, load_errors: load_errors)
116
+ Registry.new(plugins: plugins, blueprints: blueprints, load_errors: load_errors,
117
+ resolved_gem_paths: @resolved_gem_paths)
68
118
  end
69
119
 
70
120
  private
71
121
 
72
122
  # Accepts:
73
- # "rigor-rails"
74
- # { "gem" => "rigor-rails", "id" => "rails", "config" => {...} }
75
- # { gem: "rigor-rails", id: "rails", config: {...} }
123
+ # "rigor-activerecord"
124
+ # { "gem" => "rigor-activerecord", "id" => "activerecord", "config" => {...} }
125
+ # { gem: "rigor-activerecord", id: "activerecord", config: {...} }
126
+ #
127
+ # The `id:` form selects one plugin from a gem that registers several — the bare-string form
128
+ # raises for those (see `lookup_plugin_class!`).
76
129
  def normalise_entry(raw, index)
77
130
  case raw
78
131
  when String
79
- { gem: raw, id: nil, config: {} }
132
+ { gem: raw, id: nil, config: {}, enabled: true }
80
133
  when Hash
81
134
  string_keyed = raw.to_h { |k, v| [k.to_s, v] }
82
135
  gem_name = string_keyed["gem"] || string_keyed["id"]
@@ -87,7 +140,9 @@ module Rigor
87
140
  )
88
141
  end
89
142
 
90
- { gem: gem_name, id: string_keyed["id"], config: string_keyed["config"] || {} }
143
+ # `enabled:` defaults to true; only the explicit `false` disables (nil / absent stays enabled).
144
+ { gem: gem_name, id: string_keyed["id"], config: string_keyed["config"] || {},
145
+ enabled: string_keyed["enabled"] != false }
91
146
  else
92
147
  raise LoadError.new(
93
148
  "plugin entry ##{index} must be a String or Hash, got #{raw.class}",
@@ -99,9 +154,24 @@ module Rigor
99
154
  def resolve_and_instantiate(entry, seen_ids) # rubocop:disable Metrics/AbcSize
100
155
  before = Plugin.registered.keys.to_set
101
156
  require_gem!(entry)
157
+ # The require SUCCEEDED — pin the file it resolved to (nil when the resolver can't, never a raise).
158
+ # Recorded before the fallible steps below so a config/init failure can still name the loaded copy.
159
+ @resolved_gem_paths[entry[:gem]] = @feature_resolver.call(entry[:gem])
102
160
  after = Plugin.registered.keys.to_set
103
161
  newly_registered = (after - before).to_a
104
162
 
163
+ # ADR-88 WD4b — `require` runs a gem's body (and its `Rigor::Plugin.register` calls) at most ONCE per
164
+ # process. A first load captures the gem's ids in `newly_registered` and memoises the gem→ids mapping;
165
+ # a SECOND in-process load (the `--verify-incremental` full-run oracle, the incremental session's
166
+ # subset re-analysis, LSP re-loads) sees `require` no-op and an empty delta, so a bare-string
167
+ # (`id:`-less) entry recovers the gem's ids from the memo instead of raising "did not register any
168
+ # plugin". An explicit `id:` entry never needs this — it resolves by id directly in `lookup_plugin_class!`.
169
+ if newly_registered.any?
170
+ Plugin.record_gem_registration(entry[:gem], newly_registered)
171
+ elsif entry[:id].nil?
172
+ newly_registered = Plugin.ids_for_gem(entry[:gem])
173
+ end
174
+
105
175
  plugin_class = lookup_plugin_class!(entry, newly_registered)
106
176
  manifest = plugin_class.manifest
107
177
 
@@ -118,6 +188,13 @@ module Rigor
118
188
  plugin = instantiate(plugin_class, entry[:config])
119
189
  validate_signature_paths!(plugin)
120
190
  plugin
191
+ rescue LoadError => e
192
+ # #194 slice 1 — annotate a POST-require failure (bad config, a raising `#init`, a missing signature
193
+ # dir, a duplicate id, no/many registrations) with the file the gem loaded from, so the surfaced
194
+ # diagnostic names the exact plugin copy. A require that failed outright never populated the map for
195
+ # this gem, so the lookup is nil and the message is left unchanged.
196
+ e.resolved_path ||= @resolved_gem_paths[entry[:gem]]
197
+ raise
121
198
  end
122
199
 
123
200
  # ADR-25 — a plugin's manifest-declared `signature_paths:` are resolved (by
@@ -137,8 +214,13 @@ module Rigor
137
214
  end
138
215
  end
139
216
 
217
+ # #194 slice 2 (ADR-93 WD5) — a bundled plugin is required by its engine-anchored absolute path; every
218
+ # other gem keeps today's bare-name require. The injectable `requirer` seam therefore widens from gem
219
+ # names to name-or-absolute-path (both are valid arguments to `Kernel.require`). The slice-1
220
+ # `feature_resolver` is unaffected: an absolute-path require's `$LOADED_FEATURES` entry still ends in
221
+ # `/<gem>.rb`, the suffix {FEATURE_RESOLVER} matches.
140
222
  def require_gem!(entry)
141
- @requirer.call(entry[:gem])
223
+ @requirer.call(self.class.bundled_plugin_path(entry[:gem]) || entry[:gem])
142
224
  rescue ::LoadError => e
143
225
  raise LoadError.new(
144
226
  "could not load plugin gem #{entry[:gem].inspect}: #{e.message}",
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "node_context"
4
+ require_relative "../source/node_children"
4
5
  require_relative "../source/node_walker"
5
6
  require_relative "../analysis/check_rules/rule_walk"
6
7
 
@@ -82,8 +83,9 @@ module Rigor
82
83
  # The single converged DFS pre-order traversal. Threads both the live `ancestors` stack (for plugin
83
84
  # {NodeContext}) and the immutable built-in {RuleWalk::Context} (for the collectors), derived together
84
85
  # as the walk descends — the cheap-ancestors option from the ADR-53 B4 design note. Identical pre-order
85
- # over `compact_child_nodes` to both the legacy `Source::NodeWalker.each_with_ancestors` and
86
- # `RuleWalk.walk`, so every node is visited in the same order each side saw before.
86
+ # over each node's children (`#rigor_each_child`, which yields `compact_child_nodes` order) to
87
+ # both the legacy `Source::NodeWalker.each_with_ancestors` and `RuleWalk.walk`, so every node is visited in
88
+ # the same order each side saw before.
87
89
  def walk_node(node, ancestors, context, path, scope, states, collector_driver)
88
90
  return unless node.is_a?(Prism::Node)
89
91
 
@@ -92,7 +94,7 @@ module Rigor
92
94
 
93
95
  child_context = collector_driver&.descend(node, context)
94
96
  ancestors.push(node)
95
- node.compact_child_nodes.each do |child|
97
+ node.rigor_each_child do |child|
96
98
  walk_node(child, ancestors, child_context, path, scope, states, collector_driver)
97
99
  end
98
100
  ancestors.pop
@@ -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