rigortype 0.4.4 → 0.4.6

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 (233) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/docs/handbook/11-sig-gen.md +100 -18
  4. data/docs/manual/02-cli-reference.md +85 -16
  5. data/docs/manual/03-configuration.md +12 -9
  6. data/docs/manual/04-diagnostics.md +17 -6
  7. data/docs/manual/05-inspecting-types.md +2 -2
  8. data/docs/manual/06-baseline.md +1 -1
  9. data/docs/manual/07-plugins.md +2 -1
  10. data/docs/manual/08-skills.md +7 -0
  11. data/docs/manual/10-mcp-server.md +17 -2
  12. data/docs/manual/12-caching.md +8 -1
  13. data/docs/manual/18-removing-dead-code.md +18 -7
  14. data/docs/manual/plugins/rigor-actionmailer.md +30 -0
  15. data/docs/manual/plugins/rigor-actionpack.md +30 -0
  16. data/docs/manual/plugins/rigor-devise.md +15 -0
  17. data/docs/manual/plugins/rigor-rspec.md +102 -1
  18. data/lib/rigor/analysis/buffer_binding.rb +14 -0
  19. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  20. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
  21. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
  22. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +115 -7
  23. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +253 -0
  24. data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
  25. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
  26. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
  27. data/lib/rigor/analysis/check_rules/rule_ids.rb +3 -1
  28. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  29. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +64 -4
  30. data/lib/rigor/analysis/check_rules.rb +319 -21
  31. data/lib/rigor/analysis/dependency_recorder.rb +11 -0
  32. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  33. data/lib/rigor/analysis/edit_replay.rb +67 -0
  34. data/lib/rigor/analysis/incremental.rb +86 -0
  35. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  36. data/lib/rigor/analysis/incremental_session.rb +336 -46
  37. data/lib/rigor/analysis/path_expansion.rb +44 -1
  38. data/lib/rigor/analysis/reachability/graph.rb +90 -13
  39. data/lib/rigor/analysis/reachability/scan.rb +279 -39
  40. data/lib/rigor/analysis/require_relative_closure.rb +100 -0
  41. data/lib/rigor/analysis/rule_catalog.rb +22 -5
  42. data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
  43. data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
  44. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
  45. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
  46. data/lib/rigor/analysis/runner/pool_coordinator.rb +82 -16
  47. data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
  48. data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
  49. data/lib/rigor/analysis/runner.rb +222 -111
  50. data/lib/rigor/analysis/severity_stamp.rb +11 -0
  51. data/lib/rigor/analysis/worker_session.rb +24 -1
  52. data/lib/rigor/cache/descriptor.rb +40 -7
  53. data/lib/rigor/cache/engine_source.rb +3 -2
  54. data/lib/rigor/cache/incremental_snapshot.rb +49 -8
  55. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  56. data/lib/rigor/cache/store.rb +4 -3
  57. data/lib/rigor/cli/check_command.rb +93 -4
  58. data/lib/rigor/cli/command.rb +3 -1
  59. data/lib/rigor/cli/diff_command.rb +2 -1
  60. data/lib/rigor/cli/docs_command.rb +3 -2
  61. data/lib/rigor/cli/doctor_command.rb +1 -1
  62. data/lib/rigor/cli/explain_command.rb +3 -2
  63. data/lib/rigor/cli/lens_command.rb +5 -5
  64. data/lib/rigor/cli/mcp_command.rb +2 -1
  65. data/lib/rigor/cli/options.rb +20 -3
  66. data/lib/rigor/cli/plugin_command.rb +2 -1
  67. data/lib/rigor/cli/plugins_command.rb +7 -1
  68. data/lib/rigor/cli/plugins_renderer.rb +24 -2
  69. data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
  70. data/lib/rigor/cli/sig_gen_command.rb +73 -13
  71. data/lib/rigor/cli/skill_command.rb +2 -1
  72. data/lib/rigor/cli/skill_describe.rb +3 -2
  73. data/lib/rigor/cli/type_of_command.rb +2 -1
  74. data/lib/rigor/cli/type_of_project_scope.rb +27 -0
  75. data/lib/rigor/cli/unused_command.rb +10 -7
  76. data/lib/rigor/cli.rb +3 -2
  77. data/lib/rigor/config_audit.rb +72 -16
  78. data/lib/rigor/configuration/severity_profile.rb +20 -2
  79. data/lib/rigor/configuration.rb +43 -7
  80. data/lib/rigor/effects/signature_sources.rb +3 -1
  81. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  82. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  83. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  84. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  85. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  86. data/lib/rigor/environment/rbs_loader.rb +136 -7
  87. data/lib/rigor/environment/required_features.rb +136 -29
  88. data/lib/rigor/environment/ruby_library_roots.rb +122 -0
  89. data/lib/rigor/environment.rb +4 -2
  90. data/lib/rigor/inference/block_call_timing.rb +7 -1
  91. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  92. data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
  93. data/lib/rigor/inference/captured_locals.rb +44 -13
  94. data/lib/rigor/inference/clause_widening.rb +90 -0
  95. data/lib/rigor/inference/deep_erasure.rb +138 -0
  96. data/lib/rigor/inference/def_return_typer.rb +159 -9
  97. data/lib/rigor/inference/definer_resolution.rb +101 -20
  98. data/lib/rigor/inference/error_info.rb +17 -3
  99. data/lib/rigor/inference/expression_typer.rb +97 -18
  100. data/lib/rigor/inference/global_write_census.rb +36 -1
  101. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  102. data/lib/rigor/inference/ivar_writer_census.rb +195 -0
  103. data/lib/rigor/inference/loop_value_record.rb +53 -0
  104. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  105. data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
  106. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +278 -42
  107. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
  108. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  109. data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
  110. data/lib/rigor/inference/method_dispatcher.rb +113 -19
  111. data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
  112. data/lib/rigor/inference/module_function_state.rb +149 -42
  113. data/lib/rigor/inference/mutation_widening.rb +5 -2
  114. data/lib/rigor/inference/narrowing.rb +116 -11
  115. data/lib/rigor/inference/object_mixins.rb +13 -1
  116. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  117. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  118. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  119. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  120. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  121. data/lib/rigor/inference/project_seed_tables.rb +121 -0
  122. data/lib/rigor/inference/project_yielder.rb +128 -0
  123. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  124. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  125. data/lib/rigor/inference/reflective_ivar_write.rb +6 -6
  126. data/lib/rigor/inference/return_barrier.rb +16 -0
  127. data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
  128. data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
  129. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
  130. data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
  131. data/lib/rigor/inference/scope_indexer.rb +1144 -197
  132. data/lib/rigor/inference/self_call_ivar_writes.rb +29 -12
  133. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  134. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  135. data/lib/rigor/inference/statement_evaluator.rb +1088 -213
  136. data/lib/rigor/inference/synthetic_method_index.rb +23 -0
  137. data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
  138. data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
  139. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  140. data/lib/rigor/language_server/buffer_table.rb +6 -0
  141. data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
  142. data/lib/rigor/language_server/project_context.rb +59 -3
  143. data/lib/rigor/language_server/server.rb +5 -3
  144. data/lib/rigor/lens/contract_reader.rb +45 -2
  145. data/lib/rigor/lens/document.rb +2 -1
  146. data/lib/rigor/lens/file_reader.rb +12 -2
  147. data/lib/rigor/lens/members.rb +16 -2
  148. data/lib/rigor/lens/run.rb +34 -7
  149. data/lib/rigor/mcp/server.rb +11 -3
  150. data/lib/rigor/plugin/base.rb +29 -13
  151. data/lib/rigor/plugin/io_boundary.rb +42 -6
  152. data/lib/rigor/plugin/isolation.rb +4 -3
  153. data/lib/rigor/plugin/manifest.rb +29 -3
  154. data/lib/rigor/plugin/registry.rb +38 -0
  155. data/lib/rigor/protection/discovery_seed.rb +64 -36
  156. data/lib/rigor/protection/mutation_cache.rb +8 -2
  157. data/lib/rigor/rooted_glob.rb +36 -0
  158. data/lib/rigor/scope/discovery_index.rb +5 -0
  159. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  160. data/lib/rigor/scope/resolution_chain.rb +40 -5
  161. data/lib/rigor/scope.rb +59 -7
  162. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  163. data/lib/rigor/sig_gen/classification.rb +4 -1
  164. data/lib/rigor/sig_gen/generator.rb +465 -49
  165. data/lib/rigor/sig_gen/layout_index.rb +5 -2
  166. data/lib/rigor/sig_gen/observation_collector.rb +141 -12
  167. data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
  168. data/lib/rigor/sig_gen/renderer.rb +16 -155
  169. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  170. data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
  171. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  172. data/lib/rigor/sig_gen/write_result.rb +3 -1
  173. data/lib/rigor/sig_gen/writer.rb +5 -3
  174. data/lib/rigor/signature_path_audit.rb +4 -2
  175. data/lib/rigor/type/combinator.rb +4 -3
  176. data/lib/rigor/type/constant.rb +54 -3
  177. data/lib/rigor/type/hash_shape.rb +14 -9
  178. data/lib/rigor/type/struct_instance.rb +3 -2
  179. data/lib/rigor/version.rb +1 -1
  180. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  181. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +61 -44
  182. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
  183. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  184. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  185. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  186. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
  187. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  188. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  189. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  190. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
  191. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  192. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  193. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  194. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
  195. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +7 -6
  196. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  197. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +38 -9
  198. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  199. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  200. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  201. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  202. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  203. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  204. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  205. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
  206. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  207. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
  208. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  209. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  210. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  211. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
  212. data/sig/rigor/analysis/baseline.rbs +3 -2
  213. data/sig/rigor/effects/file_collection.rbs +5 -6
  214. data/sig/rigor/effects/registry.rbs +8 -6
  215. data/sig/rigor/effects/summary.rbs +6 -5
  216. data/sig/rigor/environment.rbs +5 -3
  217. data/sig/rigor/inference/optimistic_origin.rbs +11 -0
  218. data/sig/rigor/inference.rbs +6 -0
  219. data/sig/rigor/plugin/additional_initializer.rbs +5 -5
  220. data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
  221. data/sig/rigor/plugin/effect_attribution.rbs +25 -10
  222. data/sig/rigor/plugin/effect_edge.rbs +12 -5
  223. data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
  224. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  225. data/sig/rigor/plugin/manifest.rbs +16 -18
  226. data/sig/rigor/plugin/protocol_contract.rbs +10 -9
  227. data/sig/rigor/plugin/registry.rbs +1 -1
  228. data/sig/rigor/rooted_glob.rbs +7 -0
  229. data/sig/rigor/scope.rbs +34 -52
  230. data/sig/rigor/type.rbs +0 -6
  231. data/skills/rigor-doctor/references/01-checks.md +23 -11
  232. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  233. metadata +32 -1
@@ -14,10 +14,13 @@ module Rigor
14
14
  # @param contracts — `{[owner, scope, name] => ContractReader::Contract or nil}`
15
15
  # @param elsewhere — callable `(owner, scope, name) -> [{path, line}]`: other project files defining it
16
16
  # @param site_paths — `{site key => absolute path of its file}`, to match an inline contract's buffer
17
- def initialize(sites:, omissions:, contracts:, elsewhere:, site_paths:)
17
+ # @param defaulted_blocks — `{[owner, scope, name] => {required, type}}`: the block a member's otherwise
18
+ # defaulted inline declaration declares (ContractReader#read_defaulted_block)
19
+ def initialize(sites:, omissions:, contracts:, elsewhere:, site_paths:, defaulted_blocks: {})
18
20
  @sites = sites
19
21
  @omissions = omissions
20
22
  @contracts = contracts
23
+ @defaulted_blocks = defaulted_blocks
21
24
  @elsewhere = elsewhere
22
25
  @site_paths = site_paths
23
26
  end
@@ -115,11 +118,22 @@ module Rigor
115
118
 
116
119
  params, block, head = Parameters.read(node.parameters, yields: Parameters.yields?(node.body))
117
120
  slot = inferred_return(single, scope)
118
- block_rbs = block ? " ?{ (?) -> untyped }" : ""
121
+ declared = !site.data["alias_of"] && @defaulted_blocks[[site.data["owner"], scope, name]]
122
+ block = declared || block
123
+ block_rbs = block_spelling(block, declared)
119
124
  { "rbs" => "#{head}#{block_rbs} -> #{return_spelling(slot)}", "params" => params, "block" => block,
120
125
  "return" => slot }
121
126
  end
122
127
 
128
+ # A block an otherwise defaulted declaration declares reads as declared (`{ (Integer) -> void }`); one the
129
+ # definition merely takes reads as `?{ (?) -> untyped }`.
130
+ def block_spelling(block, declared)
131
+ return "" if block.nil?
132
+ return " ?{ (?) -> untyped }" unless declared
133
+
134
+ " #{'?' unless declared['required']}{ #{declared['type']['type']} }"
135
+ end
136
+
123
137
  # An alias whose original Rigor cannot pin to one `def`: nothing about its signature is known.
124
138
  def unknown_overload
125
139
  { "rbs" => nil, "params" => nil, "block" => nil, "return" => Slot.unavailable("not_computed") }
@@ -78,6 +78,7 @@ module Rigor
78
78
  def reset_captures
79
79
  @captured = {}
80
80
  @contracts = {}
81
+ @defaulted_blocks = {}
81
82
  @site_paths = {}
82
83
  end
83
84
 
@@ -145,18 +146,39 @@ module Rigor
145
146
  if published
146
147
  published_slot(published, scope.environment)
147
148
  else
148
- declared_slot(site, contract_reader(scope.environment).read_constant(name, scope.environment))
149
+ declared_slot(site, contract_reader(scope.environment).read_constant(name, scope.environment), scope)
149
150
  end
150
151
  end
151
152
 
152
153
  # The rbs-inline plugin declares every constant of a file it reads, typing an unannotated one from its
153
154
  # literal (`[]` as `Array[untyped]`), and reads only a `#:` trailing the assignment as its type: an inline
154
- # declaration is the constant's own only when that is the site's one contract comment.
155
- def declared_slot(site, declared)
155
+ # declaration is the constant's own only when that is the site's one contract comment. Otherwise it is the
156
+ # plugin's default, which `check` reads all the same: that type, `inferred`. A `Class.new` / `Module.new`
157
+ # value is the exception, which `check` types through its own write reading, not the default `untyped`.
158
+ # An inline declaration from another file says nothing about this site: when the name is written in two
159
+ # files, the one the environment kept decides, and this site's own comment may say otherwise.
160
+ def declared_slot(site, declared, scope)
156
161
  return Slot.unavailable("not_computed") if declared.nil?
157
- return Slot.unavailable("not_computed") if declared["provenance"] == "inline" && !trailing_type?(site)
162
+ return declared unless declared["provenance"] == "inline"
163
+ return Slot.unavailable("not_computed") unless own_inline_declaration?(site, scope)
164
+ return declared if trailing_type?(site)
165
+ return Slot.unavailable("not_computed") if anonymous_namespace?(site.node.value)
158
166
 
159
- declared
167
+ published_slot(scope.environment.constant_for_name(site.data["name"]), scope.environment)
168
+ end
169
+
170
+ def own_inline_declaration?(site, scope)
171
+ buffer = scope.environment.rbs_loader&.constant_declaration(site.data["name"])&.last
172
+ path = @site_paths.fetch(site.data["key"])
173
+ buffer&.end_with?(":#{path}") || false
174
+ end
175
+
176
+ def anonymous_namespace?(node)
177
+ return false unless node.is_a?(Prism::Node)
178
+ return true if node.is_a?(Prism::CallNode) && node.name == :new && node.receiver &&
179
+ %w[Class Module].include?(node.receiver.slice.delete_prefix("::"))
180
+
181
+ node.compact_child_nodes.any? { |child| anonymous_namespace?(child) }
160
182
  end
161
183
 
162
184
  def trailing_type?(site)
@@ -166,6 +188,7 @@ module Rigor
166
188
  end
167
189
 
168
190
  def published_slot(type, environment)
191
+ return Slot.unavailable("not_computed") if type.nil?
169
192
  return Slot.dynamic("inferred", nil) if type.is_a?(Type::Dynamic)
170
193
 
171
194
  spelled = speller(environment).call(type, nil)
@@ -178,7 +201,11 @@ module Rigor
178
201
  key = [owner, member_scope, name]
179
202
  return if @contracts.key?(key)
180
203
 
181
- @contracts[key] = contract_reader(scope.environment).read(owner, member_scope.to_sym, name, scope)
204
+ reader = contract_reader(scope.environment)
205
+ @contracts[key] = reader.read(owner, member_scope.to_sym, name, scope)
206
+ return if @contracts[key]
207
+
208
+ @defaulted_blocks[key] = reader.read_defaulted_block(owner, member_scope.to_sym, name, scope)
182
209
  end
183
210
 
184
211
  def target(entry, diagnostics, session)
@@ -229,7 +256,7 @@ module Rigor
229
256
  sites = readers.values.flat_map(&:sites)
230
257
  omissions = readers.values.flat_map(&:omissions)
231
258
  requested = readers.keys.to_set
232
- Members.new(sites: sites, omissions: omissions, contracts: @contracts,
259
+ Members.new(sites: sites, omissions: omissions, contracts: @contracts, defaulted_blocks: @defaulted_blocks,
233
260
  elsewhere: elsewhere(session, requested), site_paths: @site_paths).build
234
261
  end
235
262
 
@@ -128,8 +128,8 @@ module Rigor
128
128
  properties: {
129
129
  rule: {
130
130
  type: "string",
131
- description: "Rule ID, legacy alias, or family prefix (call, flow, assert, dump, def). " \
132
- "Omit to list every rule."
131
+ description: "Rule ID, legacy alias, or family prefix (call, flow, def, assert, dump, global, " \
132
+ "suppression, static, effect). Omit to list every rule."
133
133
  }
134
134
  }
135
135
  }
@@ -235,7 +235,7 @@ module Rigor
235
235
  end
236
236
 
237
237
  def build_argv(name, args) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/AbcSize,Metrics/PerceivedComplexity
238
- effective_config = args["config"] || @config_path
238
+ effective_config = present_config(args["config"]) || @config_path
239
239
 
240
240
  case name
241
241
  when "rigor_check"
@@ -290,6 +290,14 @@ module Rigor
290
290
  end
291
291
  end
292
292
 
293
+ # A per-call `config` that is blank names no file, so the session default applies rather than `--config=`
294
+ # reaching the CLI as an empty path (which the CLI refuses).
295
+ def present_config(value)
296
+ return value unless value.is_a?(String) && value.strip.empty?
297
+
298
+ nil
299
+ end
300
+
293
301
  def success(id, result)
294
302
  { "jsonrpc" => "2.0", "id" => id, "result" => result }
295
303
  end
@@ -8,11 +8,13 @@ require "prism"
8
8
  require_relative "manifest"
9
9
  require_relative "node_context"
10
10
  require_relative "../analysis/diagnostic"
11
+ require_relative "../analysis/run_disclosure_credit"
11
12
  require_relative "../cache/engine_source"
12
13
  # `producer generation_cap:` defaults to (and validates against) `Cache::Store::UNBOUNDED_GENERATIONS`, and a
13
14
  # plugin class body can be evaluated before anything else pulled the cache layer in.
14
15
  require_relative "../cache/store"
15
16
  require_relative "../source/node_walker"
17
+ require_relative "../rooted_glob"
16
18
 
17
19
  module Rigor
18
20
  module Plugin
@@ -779,11 +781,12 @@ module Rigor
779
781
  # that differs between workers.
780
782
  def disclose_once(key, message:, severity: :info, rule: "load-error")
781
783
  id = key.to_s
782
- return nil if run_registration_taken?(id, batch: false)
783
-
784
- @run_disclosures[id] = {
785
- key: id, message: message.to_s, severity: severity.to_sym, rule: rule.to_s
786
- }.freeze
784
+ unless run_registration_taken?(id, batch: false)
785
+ @run_disclosures[id] = {
786
+ key: id, message: message.to_s, severity: severity.to_sym, rule: rule.to_s
787
+ }.freeze
788
+ end
789
+ attribute_run_registration(id)
787
790
  nil
788
791
  end
789
792
 
@@ -815,14 +818,15 @@ module Rigor
815
818
  # raises `ArgumentError`, which the engine reports as this plugin's `runtime-error`.
816
819
  def emit_once(key, diagnostics)
817
820
  id = key.to_s
818
- return nil if run_registration_taken?(id, batch: true)
819
-
820
- batch = Array(diagnostics).map do |row|
821
- next row.dup.freeze if row.is_a?(Rigor::Analysis::Diagnostic)
821
+ unless run_registration_taken?(id, batch: true)
822
+ batch = Array(diagnostics).map do |row|
823
+ next row.dup.freeze if row.is_a?(Rigor::Analysis::Diagnostic)
822
824
 
823
- raise ArgumentError, "emit_once expects Rigor::Analysis::Diagnostic rows, got #{row.class}"
825
+ raise ArgumentError, "emit_once expects Rigor::Analysis::Diagnostic rows, got #{row.class}"
826
+ end
827
+ @run_disclosures[id] = { key: id, diagnostics: batch.freeze }.freeze
824
828
  end
825
- @run_disclosures[id] = { key: id, diagnostics: batch.freeze }.freeze
829
+ attribute_run_registration(id)
826
830
  nil
827
831
  end
828
832
 
@@ -846,6 +850,18 @@ module Rigor
846
850
  end
847
851
  private :run_registration_taken?
848
852
 
853
+ # Issue #2020 — hands every call, a repeat of a key already registered included, to the engine's per-file
854
+ # attribution ({Analysis::RunDisclosureCredit}), so an `--incremental` recheck can replay the row for a file
855
+ # it serves from cache. A repeat counts because a discloser memoises the input its notice is about and
856
+ # re-registers the notice from each file's hook. A batch registered empty adds nothing to replay.
857
+ def attribute_run_registration(id)
858
+ record = @run_disclosures[id]
859
+ return if record.nil? || record[:diagnostics]&.empty?
860
+
861
+ Analysis::RunDisclosureCredit.attribute(self, record)
862
+ end
863
+ private :attribute_run_registration
864
+
849
865
  # Boilerplate-reduction helper (review §1.3): the "did you mean …?" suggestion every
850
866
  # diagnostic-emitting plugin otherwise hand-rolls. Returns the closest of `candidates` to `name` via
851
867
  # `DidYouMean::SpellChecker` (the same engine Ruby's own `NoMethodError` hints use), or `nil` when
@@ -1003,7 +1019,7 @@ module Rigor
1003
1019
  # plugin code calls `watch:`.
1004
1020
  #
1005
1021
  # @param roots — search roots (relative to the project root, or absolute paths)
1006
- # @param patterns — glob suffixes joined under each root via `File.join(root, pattern)`.
1022
+ # @param patterns — glob suffixes matched under each root, the root read literally (#1956).
1007
1023
  # Multiple patterns union into one descriptor (`"**/*.erb", "**/*.html"` etc.).
1008
1024
  def glob_descriptor(roots, *patterns)
1009
1025
  files = collect_glob_files(Array(roots), patterns)
@@ -1163,7 +1179,7 @@ module Rigor
1163
1179
  absolute = File.absolute_path(root.to_s)
1164
1180
  next [] unless File.directory?(absolute)
1165
1181
 
1166
- patterns.flat_map { |pattern| Dir.glob(File.join(absolute, pattern.to_s)) }
1182
+ patterns.flat_map { |pattern| RootedGlob.glob(absolute, pattern) }
1167
1183
  end
1168
1184
  matched.uniq.sort.select { |path| File.file?(path) }
1169
1185
  end
@@ -3,6 +3,7 @@
3
3
  require "digest"
4
4
 
5
5
  require_relative "access_denied_error"
6
+ require_relative "../rooted_glob"
6
7
 
7
8
  module Rigor
8
9
  module Plugin
@@ -33,6 +34,8 @@ module Rigor
33
34
  # for a plugin that asks "which of these N candidate files exists here?" — rigor-actionpack tries
34
35
  # nine template extensions per `render` call — where N per-path probe rows would put thousands of
35
36
  # validations on every warm run and one listing row puts one per directory.
37
+ # - `#list_directory_kinds(path)` — the KIND-aware listing (#1959): each entry with its `lstat` kind, and one
38
+ # `:kinds` row that moves when an entry appears, vanishes or changes kind under the same name.
36
39
  # - `#open_url(url)` — fetches the URL when the policy permits it (`network_policy: :allowlist` plus an
37
40
  # `allowed_url_hosts` match) and raises {AccessDeniedError} otherwise. v0.1.2 ships the allowlist
38
41
  # surface; the default project policy still has `network_policy: :disabled` so plugins that want network
@@ -189,7 +192,7 @@ module Rigor
189
192
  # @return the absolute paths directly under `path` in `Dir.glob` order; empty when not a directory
190
193
  def list_directory(path)
191
194
  absolute = File.absolute_path(path.to_s)
192
- entries = Dir.glob(File.join(absolute, "*"))
195
+ entries = RootedGlob.glob(absolute, "*")
193
196
  unless @policy.allow_read?(absolute)
194
197
  record_refusal(absolute)
195
198
  return entries
@@ -199,6 +202,34 @@ module Rigor
199
202
  entries
200
203
  end
201
204
 
205
+ # Issue #1959 — the KIND-aware listing. Returns each entry directly under `path` with what it is, a symlink
206
+ # not followed ({Cache::Descriptor::GlobEntry.lstat_kind}: `:file`, `:directory`, `:symlink` or `:other`, and
207
+ # `:absent` for an entry that vanished between the listing and its `lstat`),
208
+ # and records one `:kinds` {Cache::Descriptor::GlobEntry} over `path/*`. The row reads stale when an entry
209
+ # appears, disappears, or keeps its name and changes kind; NOT when an entry's bytes or stat tuple move, nor
210
+ # when anything below a subdirectory changes. It is the row for a walk that decides per top-level entry
211
+ # whether to descend: rigor-ffi skips `vendor/` and does not follow a symlinked directory, so a file `gen`
212
+ # replaced by a directory `gen/`, or a symlink replaced by a real directory, changes what it reads while
213
+ # every name stays put. {#list_directory}'s `:stat` row leaves directories out, and {#glob}'s `:names` row
214
+ # reads names alone.
215
+ #
216
+ # Like {#list_directory}, dot entries are not listed, and an out-of-policy directory is listed truthfully
217
+ # and contributes no row.
218
+ #
219
+ # @param path — project directory; relative paths expand against the working directory
220
+ # @return a Hash from each absolute entry path, in `Dir.glob` order, to its kind; empty when not a directory
221
+ def list_directory_kinds(path)
222
+ absolute = File.absolute_path(path.to_s)
223
+ entries = RootedGlob.glob(absolute, "*").to_h { |entry| [entry, Cache::Descriptor::GlobEntry.lstat_kind(entry)] }
224
+ unless @policy.allow_read?(absolute)
225
+ record_refusal(absolute)
226
+ return entries
227
+ end
228
+
229
+ record_glob_entry(absolute, "*", mode: :kinds)
230
+ entries
231
+ end
232
+
202
233
  # Issue #1652 — the MEMBERSHIP fingerprint of a `Dir.glob` pattern under `root`, `**` included. Returns the
203
234
  # matching paths and records one `:names` {Cache::Descriptor::GlobEntry} over `root` and `pattern`: the row
204
235
  # reads stale when a match appears or disappears, in a subdirectory created after the read too, and NOT
@@ -209,8 +240,13 @@ module Rigor
209
240
  #
210
241
  # The pattern must stay under `root`, since the policy is checked against `root` alone: a `..` segment, a
211
242
  # brace (`{..,x}`) or a backslash escape (`.\\./`) raises {AccessDeniedError} (`:read_outside_scope`).
212
- # The last two are refused outright because `Dir.glob` can spell `..` with them. Out of policy, `root` is
213
- # globbed truthfully and contributes no row, as in {#list_directory}.
243
+ # The last two are refused outright because `Dir.glob` can spell `..` with them. A leading `/` is read under
244
+ # `root`, as the joined spelling always read it (`/lib/**/*.rb` is `root/lib/**/*.rb`). Out of policy, `root`
245
+ # is globbed truthfully and contributes no row, as in {#list_directory}.
246
+ #
247
+ # `root` is read literally (#1959): {RootedGlob} escapes its glob metacharacters before the join, so a root
248
+ # spelled `fx[1]` or a directory named `{..,zz}` neither matches a sibling nor leaves itself.
249
+ # {#list_directory} lists the same way, and {Cache::Descriptor::GlobEntry} re-globs the row the same way.
214
250
  #
215
251
  # @param root — project directory; relative paths expand against the working directory
216
252
  # @param pattern — a `Dir.glob` pattern relative to `root`, such as `"ext/**/extconf.rb"`
@@ -227,7 +263,7 @@ module Rigor
227
263
  )
228
264
  end
229
265
 
230
- entries = Dir.glob(File.join(absolute, pattern))
266
+ entries = RootedGlob.glob(absolute, pattern)
231
267
  unless @policy.allow_read?(absolute)
232
268
  record_refusal(absolute)
233
269
  return entries
@@ -390,8 +426,8 @@ module Rigor
390
426
  self.class.attribute(entry)
391
427
  end
392
428
 
393
- # ADR-45 WD1c (#629) — the listing row for {#list_directory} (and the `:names` row for {#glob}),
394
- # deduplicated per (root, pattern, mode) slot.
429
+ # ADR-45 WD1c (#629) — the listing row for {#list_directory} (and the `:names` row for {#glob} and the
430
+ # `:kinds` row for {#list_directory_kinds}), deduplicated per (root, pattern, mode) slot.
395
431
  # Recomputed on each call rather than `||=`d, so a directory listed again after a mid-run mutation
396
432
  # carries the signature of the state the LAST reader saw.
397
433
  def record_glob_entry(root, pattern, mode: :stat)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "box"
4
+ require_relative "../rooted_glob"
4
5
 
5
6
  module Rigor
6
7
  module Plugin
@@ -74,8 +75,8 @@ module Rigor
74
75
  # requiring the gem, which is what the caller is about to do.
75
76
  def bundle_require_paths(bundle_root)
76
77
  bundle_gem_dirs(bundle_root).flat_map do |gem_home|
77
- specs = Dir.glob(File.join(gem_home, "specifications", "*.gemspec"))
78
- .filter_map { |file| Gem::Specification.load(file) }
78
+ specs = RootedGlob.glob(gem_home, "specifications/*.gemspec")
79
+ .filter_map { |file| Gem::Specification.load(file) }
79
80
  specs.group_by(&:name)
80
81
  .flat_map { |_, versions| versions.max_by(&:version).full_require_paths }
81
82
  end
@@ -88,7 +89,7 @@ module Rigor
88
89
  return [] if bundle_root.nil? || bundle_root.to_s.empty?
89
90
 
90
91
  root = bundle_root.to_s
91
- (Dir.glob(File.join(root, "ruby", "*")) + [root])
92
+ (RootedGlob.glob(root, "ruby/*") + [root])
92
93
  .select { |dir| File.directory?(File.join(dir, "specifications")) }
93
94
  end
94
95
 
@@ -56,7 +56,7 @@ module Rigor
56
56
  :hkt_registrations, :hkt_definitions, :signature_paths, :protocol_contracts,
57
57
  :source_rbs_synthesizer, :additional_initializers,
58
58
  :effect_root, :effect_labels, :effect_attributions, :effect_edges, :effect_entry_points,
59
- :effect_ancestry
59
+ :effect_ancestry, :per_invocation_classes, :per_invocation_non_action_macros
60
60
 
61
61
  # #392 — the project-relative globs whose files this plugin compiles into {TemplateUnit}s. Purely a
62
62
  # CLAIM: the engine globs these, reads each match's bytes and offers them to
@@ -74,7 +74,8 @@ module Rigor
74
74
  hkt_registrations: [], hkt_definitions: [], signature_paths: [], protocol_contracts: [],
75
75
  source_rbs_synthesizer: nil, additional_initializers: [],
76
76
  effect_root: nil, effect_labels: [], effect_attributions: [], effect_edges: [],
77
- effect_entry_points: [], effect_ancestry: [], template_globs: []
77
+ effect_entry_points: [], effect_ancestry: [], template_globs: [], per_invocation_classes: [],
78
+ per_invocation_non_action_macros: []
78
79
  )
79
80
  validate_id!(id)
80
81
  validate_version!(version)
@@ -109,6 +110,7 @@ module Rigor
109
110
  assign_additional_initializers(additional_initializers)
110
111
  assign_effect_fields(effect_root, effect_labels, effect_attributions, effect_edges,
111
112
  effect_entry_points, effect_ancestry, template_globs)
113
+ assign_per_invocation_classes(per_invocation_classes, per_invocation_non_action_macros)
112
114
  freeze
113
115
  end
114
116
 
@@ -202,6 +204,28 @@ module Rigor
202
204
  @template_globs = globs.map { |glob| glob.to_s.dup.freeze }.uniq.freeze
203
205
  end
204
206
  private :assign_template_globs
207
+
208
+ # ADR-125 — `per_invocation_classes:` names the framework classes whose every instance serves exactly one
209
+ # entry point, so ivar writes in two distinct entry points of a descendant are never live together.
210
+ # `per_invocation_non_action_macros:` names the class-body macros whose Symbol, String or inline-`def`
211
+ # arguments name a public method that is NOT an entry point (`before_action :load`, `helper_method
212
+ # :current_user`); they apply to this manifest's own `per_invocation_classes:`, so a list without classes
213
+ # is refused. Validated and assigned in one call, because `#initialize` is at its line budget.
214
+ def assign_per_invocation_classes(names, macros)
215
+ validate_array_of!("per_invocation_classes", names, "non-empty String") do |name|
216
+ name.is_a?(String) && !name.empty?
217
+ end
218
+ validate_array_of!("per_invocation_non_action_macros", macros, "Symbol/non-empty String") do |macro|
219
+ macro.is_a?(Symbol) || (macro.is_a?(String) && !macro.empty?)
220
+ end
221
+ if names.empty? && !macros.empty?
222
+ raise ArgumentError, "per_invocation_non_action_macros needs per_invocation_classes to apply to"
223
+ end
224
+
225
+ @per_invocation_classes = names.map { |name| name.to_s.delete_prefix("::").dup.freeze }.uniq.freeze
226
+ @per_invocation_non_action_macros = macros.map(&:to_sym).uniq.freeze
227
+ end
228
+ private :assign_per_invocation_classes
205
229
  # rubocop:enable Metrics/ParameterLists, Metrics/AbcSize
206
230
 
207
231
  public
@@ -258,7 +282,9 @@ module Rigor
258
282
  "effect_edges" => effect_edges.map(&:to_h),
259
283
  "effect_entry_points" => effect_entry_points.map(&:to_h),
260
284
  "effect_ancestry" => effect_ancestry.map(&:to_h),
261
- "template_globs" => template_globs
285
+ "template_globs" => template_globs,
286
+ "per_invocation_classes" => per_invocation_classes,
287
+ "per_invocation_non_action_macros" => per_invocation_non_action_macros.map(&:to_s)
262
288
  }
263
289
  end
264
290
 
@@ -397,6 +397,28 @@ module Rigor
397
397
  @rbs_complete_extends_set.include?(class_name.to_s)
398
398
  end
399
399
 
400
+ # ADR-125 — the aggregate class names declared across loaded plugins as per-invocation (manifest
401
+ # `per_invocation_classes:`): each instance of a descendant serves exactly one public-method entry point.
402
+ # `per_invocation_class?` is the exact-name membership predicate; `def.ivar-write-mismatch` walks a
403
+ # class's ancestry and asks it of each ancestor.
404
+ attr_reader :per_invocation_classes
405
+
406
+ def per_invocation_class?(class_name)
407
+ return false if class_name.nil?
408
+
409
+ @per_invocation_classes_set.include?(class_name.to_s.delete_prefix("::"))
410
+ end
411
+
412
+ # ADR-125 — the macros whose arguments name a non-entry-point method on a descendant of the declared
413
+ # per-invocation class `class_name`: the union of `per_invocation_non_action_macros:` over the manifests
414
+ # that declare that class. An empty Set for a class no manifest declares.
415
+ def per_invocation_non_action_macros(class_name)
416
+ @per_invocation_macros.fetch(class_name.to_s.delete_prefix("::"), EMPTY_MACROS)
417
+ end
418
+
419
+ EMPTY_MACROS = Set.new.freeze
420
+ private_constant :EMPTY_MACROS
421
+
400
422
  # ADR-28 — flat, ordered list of every loaded plugin's path-scoped method-protocol contracts, in
401
423
  # plugin registration order. Read from each plugin's `#protocol_contracts` (which the manifest backs
402
424
  # by default but a plugin MAY override to fold in per-project config). Consumed by
@@ -465,12 +487,28 @@ module Rigor
465
487
  @rbs_complete_ancestors_set = @rbs_complete_ancestors.to_set.freeze
466
488
  @rbs_complete_extends = aggregate_class_names(manifests, :rbs_complete_extends)
467
489
  @rbs_complete_extends_set = @rbs_complete_extends.to_set.freeze
490
+ @per_invocation_classes = aggregate_class_names(manifests, :per_invocation_classes)
491
+ @per_invocation_classes_set = @per_invocation_classes.to_set.freeze
492
+ @per_invocation_macros = compile_per_invocation_macros(manifests)
468
493
  @type_node_resolvers = manifests.flat_map { |m| m&.type_node_resolvers || [] }.freeze
469
494
  @protocol_contracts = @plugins.flat_map { |p| safe_protocol_contracts(p) }.freeze
470
495
  @contracts_by_path = {}
471
496
  @effect_memo = {}
472
497
  end
473
498
 
499
+ # ADR-125 — declared class name => frozen Set of the non-action macros its declaring manifests list.
500
+ def compile_per_invocation_macros(manifests)
501
+ table = Hash.new { |hash, key| hash[key] = Set.new }
502
+ manifests.each do |manifest|
503
+ next if manifest.nil?
504
+
505
+ manifest.per_invocation_classes.each do |name|
506
+ table[name].merge(manifest.per_invocation_non_action_macros)
507
+ end
508
+ end
509
+ table.to_h { |name, macros| [name, macros.freeze] }.freeze
510
+ end
511
+
474
512
  # The shared shape of `open_receivers:` / `rbs_complete_ancestors:` — a flat uniq'd class-name list
475
513
  # aggregated across manifests, each entry normalised to String.
476
514
  def aggregate_class_names(manifests, field)
@@ -2,6 +2,10 @@
2
2
 
3
3
  require_relative "../inference/parameter_inference_collector"
4
4
  require_relative "../inference/scope_indexer"
5
+ require_relative "../inference/project_seed_tables"
6
+ require_relative "../cache/incremental_snapshot"
7
+ require_relative "../analysis/path_expansion"
8
+ require_relative "../analysis/buffer_binding"
5
9
 
6
10
  module Rigor
7
11
  module Protection
@@ -72,55 +76,79 @@ module Rigor
72
76
  # @param paths — the measured file set, in the same order {#bundles} was built from.
73
77
  # @param bundles — that bundle set.
74
78
  # @param buffer — the mutant binding (logical path → mutant bytes).
79
+ # @param constant_sources — ADR-124 — also carry the per-name writer paths (`constant_sources`), which a
80
+ # `prebuilt:` runner needs to retract a `pre_eval:` constant another file also assigns (#663).
75
81
  # @return frozen seed tables.
76
- def tables_for_buffer(paths:, bundles:, buffer:)
82
+ def tables_for_buffer(paths:, bundles:, buffer:, constant_sources: false)
77
83
  index = Inference::ScopeIndexer.discovered_project_index_incremental(
78
84
  paths, seed_bundles: bundles, buffer: buffer
79
85
  )
80
- seed_tables(index).freeze
86
+ tables = seed_tables(index)
87
+ sources = index.fetch(:def_index).fetch(:constant_sources)
88
+ tables = tables.merge(constant_sources: sources) if constant_sources && !sources.empty?
89
+ tables.freeze
81
90
  end
82
91
 
83
- # The whole-project discovery tables, from the single-walk combined pass (one parse per file, both
84
- # collectors driven over the same tree) that {Analysis::Runner::ProjectPrePasses#discover} uses. Empty
85
- # tables are dropped so an empty seed stays `{}` and every consumer's "seed nothing" branch keeps working.
92
+ # Issue #512 — the seed a one-shot probe (`rigor type-of`) can afford: the cross-file discovery tables for
93
+ # the configured paths, folded from the per-file bundles a prior `rigor check --incremental` left in the
94
+ # persistent snapshot, with `buffer`'s file (and any file whose bytes moved since) re-walked. A probe has
95
+ # no business re-parsing the project — that is what made the first attempt cost twice a single-file probe —
96
+ # so when there is no reusable snapshot this answers nil and the caller keeps its single-file scope. It
97
+ # never writes the snapshot.
86
98
  #
87
- # `discovered_class_sources` is carried as the runner carries it on every run: Tier 2 never records edges,
88
- # but an ancestor-order reader asks whether a class is declared in more than one file. The mixin tables
89
- # (`discovered_includes`, `discovered_prepends`, `discovered_extends`) ride the same fold, so a singleton
90
- # `extend` reads the same from a per-file scope under coverage and protection as it does under `check`.
91
- def discovery_tables(paths)
92
- seed_tables(Inference::ScopeIndexer.discovered_project_index_for_paths(paths))
99
+ # The snapshot is reused only under the fingerprint a bare `rigor check --incremental` writes (roots = the
100
+ # configured paths, the plain run mode): any other fingerprint, a different engine, configuration,
101
+ # lockfile or `sig/` included, is a miss.
102
+ #
103
+ # @param configuration — the probe's loaded configuration.
104
+ # @param buffer — the editor binding, or nil.
105
+ # @return frozen seed tables, or nil when the snapshot cannot seed this project.
106
+ def from_snapshot(configuration:, buffer: nil)
107
+ paths = Analysis::PathExpansion.configured_files(configuration)
108
+ return nil if paths.empty?
109
+
110
+ bundles = snapshot_bundles(configuration)
111
+ return nil if bundles.nil? || bundles.empty?
112
+
113
+ tables_for_buffer(paths: respell_for_buffer(paths, buffer), bundles: bundles, buffer: buffer,
114
+ constant_sources: true)
93
115
  end
94
116
 
95
- # Maps a `{ classes:, def_index: }` discovery index onto the {Scope::DiscoveryIndex} slot names both
96
- # consumers take. Shared by the whole-walk ({#discovery_tables}) and bundle-fold ({#tables_for_buffer})
97
- # producers so a mutant's seed and the parent's seed can never disagree about shape.
98
- def seed_tables(index)
99
- def_index = index.fetch(:def_index)
100
- tables = { discovered_classes: index.fetch(:classes) }
101
- %i[
102
- def_nodes def_nestings singleton_def_nodes def_sources singleton_def_sources superclasses includes
103
- extends prepends unpositioned_mixins class_sources
104
- method_visibilities methods parameter_envelopes data_member_layouts struct_member_layouts
105
- deferred_ranges refinements global_write_census
106
- ].each do |slot|
107
- tables[seed_key(slot)] = def_index.fetch(slot)
117
+ # The configured file list with the entry that reaches `buffer`'s file respelled as the buffer's logical
118
+ # path: the binding substitutes bytes only for a path spelled exactly as its logical path, while the
119
+ # configured paths may be relative or reach the file through a symlink. `real_paths` is the memoised
120
+ # `BufferBinding.real_path` of each entry when the caller keeps one.
121
+ def respell_for_buffer(paths, buffer, real_paths: nil)
122
+ return paths if buffer.nil?
123
+
124
+ real = Analysis::BufferBinding.real_path(buffer.logical_path)
125
+ paths.each_with_index.map do |path, index|
126
+ entry_real = real_paths ? real_paths[index] : Analysis::BufferBinding.real_path(path)
127
+ entry_real == real ? buffer.logical_path : path
108
128
  end
109
- # ADR-119 WD1 — the pairs travel as one: an unpaired empty table is dropped here, a pair only when both
110
- # halves are empty (`compact_pairs`), so a non-empty sibling is never stranded by an empty member.
111
- tables.merge!(def_index.fetch(:siblings))
112
- Scope::DiscoveryIndex.compact_pairs(
113
- tables.reject { |name, table| !Scope::DiscoveryIndex.paired?(name) && (table.nil? || table.empty?) }
114
- )
115
129
  end
116
130
 
117
- UNPREFIXED_SLOTS = %i[data_member_layouts struct_member_layouts unpositioned_mixins].freeze
118
- private_constant :UNPREFIXED_SLOTS
131
+ def snapshot_bundles(configuration)
132
+ fingerprint = Cache::IncrementalSnapshot.fingerprint(configuration: configuration, roots: configuration.paths)
133
+ return nil if fingerprint.nil?
134
+
135
+ loaded = Cache::IncrementalSnapshot.new(root: configuration.cache_path).load_any(fingerprints: [fingerprint])
136
+ loaded&.last&.seed_bundles
137
+ end
138
+
139
+ # The whole-project discovery tables, from the single-walk combined pass (one parse per file) that
140
+ # {Analysis::Runner::ProjectPrePasses#discover} uses, mapped by the one list {Inference::ProjectSeedTables}
141
+ # keeps (issue #1614). Empty tables are dropped so an empty seed stays `{}` and every consumer's "seed nothing"
142
+ # branch keeps working.
143
+ def discovery_tables(paths)
144
+ seed_tables(Inference::ScopeIndexer.discovered_project_index_for_paths(paths))
145
+ end
119
146
 
120
- # The def-index slot names match the {Scope::DiscoveryIndex} ones under a `discovered_` prefix, except the
121
- # two member-layout tables and the unpositioned-mixin table, which carry the same name on both sides.
122
- def seed_key(slot)
123
- UNPREFIXED_SLOTS.include?(slot) ? slot : :"discovered_#{slot}"
147
+ # Maps a `{ classes:, def_index: }` discovery index onto the {Scope::DiscoveryIndex} slot names both consumers
148
+ # take. Shared by the whole-walk ({#discovery_tables}) and bundle-fold ({#tables_for_buffer}) producers so a
149
+ # mutant's seed and the parent's seed can never disagree about shape.
150
+ def seed_tables(index)
151
+ Inference::ProjectSeedTables.from_index(index)
124
152
  end
125
153
  end
126
154
  end