rigortype 0.2.9 → 0.3.1

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 (222) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/builtins/ruby_core/array.yml +416 -392
  4. data/data/builtins/ruby_core/file.yml +42 -42
  5. data/data/builtins/ruby_core/hash.yml +302 -302
  6. data/data/builtins/ruby_core/io.yml +191 -191
  7. data/data/builtins/ruby_core/numeric.yml +321 -366
  8. data/data/builtins/ruby_core/proc.yml +124 -124
  9. data/data/builtins/ruby_core/range.yml +21 -21
  10. data/data/builtins/ruby_core/rational.yml +39 -39
  11. data/data/builtins/ruby_core/re.yml +65 -65
  12. data/data/builtins/ruby_core/set.yml +106 -106
  13. data/data/builtins/ruby_core/struct.yml +14 -14
  14. data/data/core_overlay/string_scanner.rbs +6 -5
  15. data/docs/handbook/01-getting-started.md +22 -34
  16. data/docs/handbook/03-narrowing.md +2 -1
  17. data/docs/handbook/04-tuples-and-shapes.md +7 -3
  18. data/docs/handbook/06-classes.md +19 -10
  19. data/docs/handbook/07-rbs-and-extended.md +76 -101
  20. data/docs/handbook/08-understanding-errors.md +114 -246
  21. data/docs/handbook/09-plugins.md +54 -144
  22. data/docs/handbook/11-sig-gen.md +11 -1
  23. data/docs/handbook/README.md +5 -3
  24. data/docs/handbook/appendix-liskov.md +4 -2
  25. data/docs/handbook/appendix-mypy.md +4 -3
  26. data/docs/handbook/appendix-phpstan.md +14 -7
  27. data/docs/handbook/appendix-steep.md +4 -2
  28. data/docs/handbook/appendix-type-theory.md +3 -1
  29. data/docs/install.md +14 -6
  30. data/docs/manual/01-installation.md +98 -4
  31. data/docs/manual/02-cli-reference.md +81 -12
  32. data/docs/manual/03-configuration.md +34 -2
  33. data/docs/manual/04-diagnostics.md +59 -4
  34. data/docs/manual/06-baseline.md +35 -1
  35. data/docs/manual/07-plugins.md +4 -4
  36. data/docs/manual/08-skills.md +6 -1
  37. data/docs/manual/09-editor-integration.md +7 -6
  38. data/docs/manual/11-ci.md +51 -0
  39. data/docs/manual/12-caching.md +65 -0
  40. data/docs/manual/plugins/rigor-actioncable.md +32 -0
  41. data/docs/manual/plugins/rigor-devise.md +4 -2
  42. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
  43. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
  44. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
  45. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
  46. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
  47. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
  48. data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
  49. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  50. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
  51. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
  52. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
  53. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +112 -0
  54. data/lib/rigor/analysis/check_rules.rb +581 -98
  55. data/lib/rigor/analysis/dependency_recorder.rb +93 -9
  56. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  57. data/lib/rigor/analysis/incremental_session.rb +456 -51
  58. data/lib/rigor/analysis/path_expansion.rb +42 -0
  59. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
  60. data/lib/rigor/analysis/rule_catalog.rb +226 -1
  61. data/lib/rigor/analysis/run_cache_key.rb +78 -0
  62. data/lib/rigor/analysis/run_cache_probe.rb +72 -0
  63. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
  64. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
  65. data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
  66. data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
  67. data/lib/rigor/analysis/runner.rb +344 -68
  68. data/lib/rigor/analysis/severity_stamp.rb +43 -0
  69. data/lib/rigor/analysis/worker_session.rb +30 -2
  70. data/lib/rigor/bleeding_edge.rb +37 -8
  71. data/lib/rigor/builtins/regex_refinement.rb +36 -1
  72. data/lib/rigor/cache/descriptor.rb +88 -28
  73. data/lib/rigor/cache/file_digest.rb +158 -0
  74. data/lib/rigor/cache/incremental_snapshot.rb +41 -5
  75. data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
  76. data/lib/rigor/cache/rbs_descriptor.rb +56 -10
  77. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
  78. data/lib/rigor/cache/store.rb +132 -27
  79. data/lib/rigor/ci_detector.rb +90 -0
  80. data/lib/rigor/cli/annotate_command.rb +15 -9
  81. data/lib/rigor/cli/baseline_command.rb +4 -1
  82. data/lib/rigor/cli/check_command.rb +223 -32
  83. data/lib/rigor/cli/check_invocation.rb +84 -0
  84. data/lib/rigor/cli/coverage_command.rb +6 -1
  85. data/lib/rigor/cli/docs_command.rb +0 -30
  86. data/lib/rigor/cli/doctor_command.rb +169 -31
  87. data/lib/rigor/cli/lsp_command.rb +5 -0
  88. data/lib/rigor/cli/mcp_command.rb +5 -0
  89. data/lib/rigor/cli/plugins_command.rb +36 -6
  90. data/lib/rigor/cli/plugins_renderer.rb +44 -12
  91. data/lib/rigor/cli/probe_environment.rb +85 -0
  92. data/lib/rigor/cli/sig_gen_command.rb +36 -9
  93. data/lib/rigor/cli/skill_command.rb +22 -33
  94. data/lib/rigor/cli/skill_deep_probe.rb +172 -0
  95. data/lib/rigor/cli/skill_describe.rb +75 -9
  96. data/lib/rigor/cli/trace_command.rb +5 -6
  97. data/lib/rigor/cli/type_of_command.rb +9 -8
  98. data/lib/rigor/cli/type_scan_command.rb +8 -8
  99. data/lib/rigor/cli.rb +15 -4
  100. data/lib/rigor/config_audit.rb +48 -2
  101. data/lib/rigor/configuration/severity_profile.rb +31 -0
  102. data/lib/rigor/configuration.rb +156 -8
  103. data/lib/rigor/environment/default_libraries.rb +36 -0
  104. data/lib/rigor/environment/rbs_loader.rb +164 -5
  105. data/lib/rigor/environment.rb +29 -28
  106. data/lib/rigor/inference/budget_trace.rb +77 -2
  107. data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
  108. data/lib/rigor/inference/def_handle.rb +23 -0
  109. data/lib/rigor/inference/def_node_resolver.rb +90 -0
  110. data/lib/rigor/inference/def_return_typer.rb +2 -1
  111. data/lib/rigor/inference/expression_typer.rb +361 -113
  112. data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
  113. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
  114. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
  115. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
  116. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +17 -1
  117. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
  118. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
  119. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
  120. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
  121. data/lib/rigor/inference/method_dispatcher.rb +57 -1
  122. data/lib/rigor/inference/mutation_widening.rb +42 -8
  123. data/lib/rigor/inference/narrowing.rb +127 -17
  124. data/lib/rigor/inference/parameter_inference_collector.rb +15 -0
  125. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  126. data/lib/rigor/inference/rbs_type_translator.rb +8 -1
  127. data/lib/rigor/inference/scope_indexer.rb +451 -36
  128. data/lib/rigor/inference/statement_evaluator.rb +64 -15
  129. data/lib/rigor/inference/struct_fold_safety.rb +48 -11
  130. data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
  131. data/lib/rigor/inference/void_origin.rb +25 -0
  132. data/lib/rigor/inference/void_tail_summary.rb +220 -0
  133. data/lib/rigor/language_server/buffer_resolution.rb +6 -3
  134. data/lib/rigor/language_server/buffer_table.rb +46 -6
  135. data/lib/rigor/language_server/completion_provider.rb +2 -1
  136. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -0
  137. data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
  138. data/lib/rigor/language_server/folding_range_provider.rb +2 -1
  139. data/lib/rigor/language_server/incremental_sync.rb +159 -0
  140. data/lib/rigor/language_server/selection_range_provider.rb +2 -1
  141. data/lib/rigor/language_server/server.rb +19 -9
  142. data/lib/rigor/language_server/signature_help_provider.rb +2 -1
  143. data/lib/rigor/language_server.rb +1 -0
  144. data/lib/rigor/plugin/base.rb +38 -24
  145. data/lib/rigor/plugin/inflector.rb +12 -3
  146. data/lib/rigor/plugin/io_boundary.rb +5 -1
  147. data/lib/rigor/plugin/isolation.rb +81 -11
  148. data/lib/rigor/plugin/load_error.rb +10 -1
  149. data/lib/rigor/plugin/loader.rb +96 -14
  150. data/lib/rigor/plugin/node_rule_walk.rb +5 -3
  151. data/lib/rigor/plugin/registry.rb +32 -23
  152. data/lib/rigor/plugin.rb +26 -0
  153. data/lib/rigor/protection/mutator.rb +3 -2
  154. data/lib/rigor/reflection.rb +64 -0
  155. data/lib/rigor/runtime/jit.rb +128 -0
  156. data/lib/rigor/scope/discovery_index.rb +12 -2
  157. data/lib/rigor/scope.rb +122 -28
  158. data/lib/rigor/sig_gen/classification.rb +5 -1
  159. data/lib/rigor/sig_gen/generator.rb +38 -6
  160. data/lib/rigor/sig_gen/observation_collector.rb +4 -3
  161. data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
  162. data/lib/rigor/sig_gen/renderer.rb +10 -0
  163. data/lib/rigor/sig_gen/write_result.rb +9 -4
  164. data/lib/rigor/sig_gen/writer.rb +207 -49
  165. data/lib/rigor/source/node_children.rb +116 -0
  166. data/lib/rigor/source/node_locator.rb +3 -1
  167. data/lib/rigor/source/node_walker.rb +4 -2
  168. data/lib/rigor/source.rb +1 -0
  169. data/lib/rigor/type/difference.rb +28 -24
  170. data/lib/rigor/type/hash_shape.rb +34 -10
  171. data/lib/rigor/version.rb +1 -1
  172. data/lib/rigortype.rb +24 -0
  173. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
  174. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
  175. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
  176. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
  177. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
  178. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +3 -1
  179. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -2
  180. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
  181. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
  182. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
  183. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
  184. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
  185. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
  186. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
  187. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
  188. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
  189. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
  190. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +3 -1
  191. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
  192. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +7 -5
  193. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
  194. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
  195. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
  196. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
  197. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
  198. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
  199. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
  200. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
  201. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
  202. data/sig/prism_node_children.rbs +9 -0
  203. data/sig/rigor/cache.rbs +6 -0
  204. data/sig/rigor/inference/void_origin.rbs +18 -0
  205. data/sig/rigor/inference.rbs +7 -0
  206. data/sig/rigor/plugin/base.rbs +6 -6
  207. data/sig/rigor/reflection.rbs +1 -0
  208. data/sig/rigor/scope.rbs +12 -1
  209. data/skills/rigor-ci-setup/SKILL.md +10 -0
  210. data/skills/rigor-editor-setup/SKILL.md +11 -0
  211. data/skills/rigor-next-steps/SKILL.md +23 -2
  212. data/skills/rigor-plugin-author/SKILL.md +3 -3
  213. data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
  214. data/skills/rigor-plugin-review/SKILL.md +2 -2
  215. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
  216. data/skills/rigor-project-init/SKILL.md +29 -3
  217. data/skills/rigor-project-init/references/01-detect.md +1 -1
  218. data/skills/rigor-project-init/references/02-configure.md +39 -5
  219. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
  220. data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
  221. metadata +48 -26
  222. data/lib/rigor/cli/ci_detector.rb +0 -89
@@ -30,6 +30,65 @@ module Rigor
30
30
 
31
31
  module_function
32
32
 
33
+ # The analyzed project's resolved bundler install root (e.g. `<project>/vendor/bundle`), or nil.
34
+ # Set from the same `bundler.*` resolution that feeds bundle sig discovery (O4 / ADR-82 WD9) — by
35
+ # the runner's pre-passes BEFORE any plugin `#prepare` runs, by `Environment.for_project` (which
36
+ # also covers pool workers), and by `rigor plugins`' probe. When a target library cannot be
37
+ # required from Rigor's own gem
38
+ # environment (the standalone `gem install rigortype` case — activesupport is deliberately NOT a
39
+ # runtime dependency), {require_with_target_bundle} falls back to requiring it from this bundle:
40
+ # the analyzed Rails project always carries its own activesupport on disk, and loading the
41
+ # project's locked copy is the higher-fidelity source of inflection rules anyway (ADR-79).
42
+ def target_bundle_root
43
+ @target_bundle_root
44
+ end
45
+
46
+ def target_bundle_root=(root)
47
+ @target_bundle_root = root.nil? ? nil : File.expand_path(root.to_s)
48
+ end
49
+
50
+ # Requires `feature`, falling back to the analyzed project's bundler install tree. The fallback
51
+ # appends every bundle gem's `full_require_paths` (from its RubyGems-generated `specifications/`
52
+ # gemspec — the gem's own metadata, so nonstandard require paths like concurrent-ruby's
53
+ # `lib/concurrent-ruby` resolve correctly) to `$LOAD_PATH` and retries. `$LOAD_PATH` is appended,
54
+ # never prepended, and only on a failed require: Rigor's own activated gems keep precedence, and a
55
+ # host environment that carries the gem never consults the bundle. (`Gem.paths` augmentation was
56
+ # rejected — Bundler-locked processes silently ignore it.) Under the default `process` strategy the
57
+ # mutation happens inside the forked worker only; under `none`/Direct it lands in the main space,
58
+ # which is what that strategy means (documented tradeoff — the invoked library is trusted + pure).
59
+ def require_with_target_bundle(feature, bundle_root)
60
+ require feature
61
+ rescue ::LoadError
62
+ added = bundle_require_paths(bundle_root) - $LOAD_PATH
63
+ raise if added.empty?
64
+
65
+ $LOAD_PATH.concat(added)
66
+ require feature
67
+ end
68
+
69
+ # Every bundle gem's require paths, newest version per gem name. Loading a `specifications/*.gemspec`
70
+ # evaluates RubyGems-generated metadata of an installed third-party gem — the same trust level as
71
+ # requiring the gem, which is what the caller is about to do.
72
+ def bundle_require_paths(bundle_root)
73
+ bundle_gem_dirs(bundle_root).flat_map do |gem_home|
74
+ specs = Dir.glob(File.join(gem_home, "specifications", "*.gemspec"))
75
+ .filter_map { |file| Gem::Specification.load(file) }
76
+ specs.group_by(&:name)
77
+ .flat_map { |_, versions| versions.max_by(&:version).full_require_paths }
78
+ end
79
+ end
80
+
81
+ # The RubyGems-shaped directories under a bundler install root: bundler nests them as
82
+ # `<root>/ruby/<ruby-version>/`; a `BUNDLE_PATH` may also point directly at such a directory. Only
83
+ # directories that actually carry a `specifications/` index are returned.
84
+ def bundle_gem_dirs(bundle_root)
85
+ return [] if bundle_root.nil? || bundle_root.to_s.empty?
86
+
87
+ root = bundle_root.to_s
88
+ (Dir.glob(File.join(root, "ruby", "*")) + [root])
89
+ .select { |dir| File.directory?(File.join(dir, "specifications")) }
90
+ end
91
+
33
92
  # The default strategy. `process` (a crash-contained forked worker) is the default: it isolates the
34
93
  # target library's monkey-patches + crashes from Rigor with no in-process contamination, and forks a
35
94
  # single persistent worker (not one per call). It falls back to `none` where fork is unavailable (see
@@ -68,10 +127,15 @@ module Rigor
68
127
  module Direct
69
128
  module_function
70
129
 
130
+ # NOTE: rescue classes are `::`-qualified throughout this file: `Rigor::Plugin::LoadError` (the
131
+ # plugin-loading error) shadows the global `LoadError` in this lexical scope once plugin machinery
132
+ # is loaded, so a bare `rescue LoadError` matches the WRONG class — the real `::LoadError` (a
133
+ # ScriptError, not a StandardError) then escapes, kills the worker, and surfaces as an opaque
134
+ # EOFError instead of the clean decline (the standalone-install regression of 2026-07-16).
71
135
  def call(feature:, receiver:, method:, args:)
72
- require feature
136
+ Isolation.require_with_target_bundle(feature, Isolation.target_bundle_root)
73
137
  Object.const_get(receiver).public_send(method, *args)
74
- rescue LoadError, NameError => e
138
+ rescue ::LoadError, ::NameError => e
75
139
  raise Unavailable, "#{receiver} could not be loaded (#{e.class}: #{e.message})"
76
140
  end
77
141
  end
@@ -109,13 +173,13 @@ module Rigor
109
173
  def call(feature:, receiver:, method:, args:)
110
174
  raise Unavailable, "process isolation unavailable: fork is not supported" unless available?
111
175
 
112
- status, value = exchange([feature, receiver, method, args])
176
+ status, value = exchange([feature, receiver, method, args, Isolation.target_bundle_root])
113
177
  raise Unavailable, "process isolation worker error: #{value}" if status == :error
114
178
 
115
179
  value
116
180
  rescue Unavailable
117
181
  raise
118
- rescue StandardError => e
182
+ rescue ::StandardError => e
119
183
  # A dead worker surfaces as EOFError (Marshal.load) or Errno::EPIPE (Marshal.dump) — both
120
184
  # StandardError. The crash is contained: the parent resets the worker (respawn next call) and
121
185
  # declines.
@@ -123,7 +187,9 @@ module Rigor
123
187
  raise Unavailable, "process isolation worker failed (#{e.class})"
124
188
  end
125
189
 
126
- # Sends one request to the persistent worker and reads its reply.
190
+ # Sends one request to the persistent worker and reads its reply. The request carries the
191
+ # target-bundle root so the fallback require (see {Isolation.require_with_target_bundle}) happens
192
+ # inside the worker — the parent's gem environment is never touched.
127
193
  def exchange(request)
128
194
  w = worker
129
195
  Marshal.dump(request, w[:req])
@@ -152,22 +218,26 @@ module Rigor
152
218
  end
153
219
  end
154
220
 
155
- # The child loop: read a `[feature, receiver, method, args]` request, require + call, and write
156
- # `[:ok, result]` or `[:error, message]`. EOF (parent gone) ends the loop.
221
+ # The child loop: read a `[feature, receiver, method, args, bundle_root]` request, require + call,
222
+ # and write `[:ok, result]` or `[:error, message]`. EOF (parent gone) ends the loop.
223
+ #
224
+ # The rescue is `::`-qualified (see {Direct.call}) and covers `::ScriptError` so a failed require
225
+ # (`::LoadError` is a ScriptError) replies with the clean `[:error, …]` decline instead of
226
+ # silently killing the worker — the parent would otherwise see only an opaque EOFError.
157
227
  def run_worker_loop(req_r, res_w)
158
228
  loop do
159
- feature, receiver, method, args = Marshal.load(req_r) # rubocop:disable Security/MarshalLoad -- parent input
229
+ feature, receiver, method, args, bundle_root = Marshal.load(req_r) # rubocop:disable Security/MarshalLoad -- parent input
160
230
  reply =
161
231
  begin
162
- require feature
232
+ Isolation.require_with_target_bundle(feature, bundle_root)
163
233
  [:ok, Object.const_get(receiver).public_send(method, *args)]
164
- rescue StandardError, LoadError => e
234
+ rescue ::StandardError, ::ScriptError => e
165
235
  [:error, "#{e.class}: #{e.message}"]
166
236
  end
167
237
  Marshal.dump(reply, res_w)
168
238
  res_w.flush
169
239
  end
170
- rescue EOFError
240
+ rescue ::EOFError
171
241
  # parent closed the request pipe — exit quietly
172
242
  ensure
173
243
  exit!(0)
@@ -11,6 +11,14 @@ module Rigor
11
11
  class LoadError < StandardError
12
12
  attr_reader :plugin_ref, :cause_class, :reason
13
13
 
14
+ # #194 slice 1 — the file `require` resolved the plugin gem to, when the require SUCCEEDED but the
15
+ # later configuration / instantiation step then failed. The loader stamps it in a rescue after a
16
+ # successful require (a require that failed outright never resolves a file and leaves this nil), so a
17
+ # config/init failure names the exact plugin copy it loaded from — the engine↔plugin version skew that
18
+ # made #194 a multi-round diagnosis. Consumed by the `plugin_loader.load-error` diagnostic message and
19
+ # the `rigor plugins` load-error row.
20
+ attr_accessor :resolved_path
21
+
14
22
  # ADR-9 slice 5 introduces two new reason codes alongside the implicit "load failure" used for require /
15
23
  # configuration / init failures:
16
24
  #
@@ -19,11 +27,12 @@ module Rigor
19
27
  # - `:dependency-cycle` — the consumes graph forms a cycle.
20
28
  #
21
29
  # Older callers omit `reason:` and the field defaults to nil (the legacy "load failure" envelope).
22
- def initialize(message, plugin_ref:, cause: nil, reason: nil)
30
+ def initialize(message, plugin_ref:, cause: nil, reason: nil, resolved_path: nil)
23
31
  super(message)
24
32
  @plugin_ref = plugin_ref
25
33
  @cause_class = cause&.class
26
34
  @reason = reason&.to_sym
35
+ @resolved_path = resolved_path
27
36
  end
28
37
  end
29
38
  end
@@ -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)