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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+
3
5
  require_relative "synthetic_method"
4
6
 
5
7
  module Rigor
@@ -65,8 +67,29 @@ module Rigor
65
67
  { "entries" => entries.map(&:to_h), "class_names" => class_names }
66
68
  end
67
69
 
70
+ # What a dispatch can read from the index, digested (nil for the empty index): each key's rows in their
71
+ # first-wins order, with the return type and the Tier B `origin_module` the dispatcher promotes through,
72
+ # and the synthesised class names. The rest of the provenance (the call site's path and line) is left
73
+ # out, so an edit that only moves the `devise` line keeps the digest. `IncrementalSession` compares it
74
+ # across runs (PR #2286 review M1): the index is built from class-body calls that no declaration
75
+ # signature records, so a recheck that sees it move cannot trust its served files.
76
+ def surface_digest
77
+ return nil if empty?
78
+
79
+ rows = entries.each_with_index
80
+ .sort_by { |entry, index| [entry.kind.to_s, entry.class_name, entry.method_name.to_s, index] }
81
+ .map { |entry, _| surface_row(entry) }
82
+ Digest::SHA256.hexdigest((rows + class_names.sort.map { |name| "class\x1f#{name}" }).join("\x00"))
83
+ end
84
+
68
85
  EMPTY_ROW = [].freeze
69
86
 
87
+ def surface_row(entry)
88
+ [entry.kind, entry.class_name, entry.method_name, entry.return_type,
89
+ entry.provenance[:origin_module], entry.provenance[:plugin_id]].join("\x1f")
90
+ end
91
+ private :surface_row
92
+
70
93
  def bucket(entries, kind)
71
94
  h = {}
72
95
  entries.each do |entry|
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../../source/literals"
5
+
6
+ module Rigor
7
+ module Inference
8
+ module SyntheticMethodScanner
9
+ # The instance-method names a project class answers ahead of a module a trait registry includes into it
10
+ # (PR #2286 review S1). Ruby looks a method up on the class itself before any module it includes, and a
11
+ # module the class itself includes or prepends sits ahead of one `devise` included earlier — so where
12
+ # the project names a member, the Tier B entry for it would answer the call from the wrong rung:
13
+ #
14
+ # class User < ApplicationRecord
15
+ # devise :confirmable
16
+ # include PasswordPolicy # defines password_required?
17
+ # def confirmed? = :yes
18
+ # end
19
+ #
20
+ # `User.new.confirmed?` is `:yes` and `password_required?` is the policy's, not `bool` from the Devise
21
+ # module's RBS. {SyntheticMethodScanner} therefore drops a trait entry whose name this index reports.
22
+ #
23
+ # A name counts as the class's when any reopening of the class body (in any project file) writes `def`,
24
+ # `alias` / `alias_method`, `attr_*`, `define_method` or `delegate` for it, or when a project module the
25
+ # class includes or prepends (followed through that module's own `include` / `prepend`) does. The MRO
26
+ # order of an `include` relative to the `devise` call is NOT consulted: a module included before the
27
+ # call sits behind the Devise module at runtime, but the name is declined anyway. Declining is the
28
+ # conservative direction — the call then reaches the tiers below the synthetic one (the discovered
29
+ # method, the project mixin), which answer it as they did before Tier B was reachable. A mixin constant
30
+ # is matched to a project module by name or by `::`-suffix, again widening only what is declined.
31
+ class ProjectMembers
32
+ # The calls that name members through their literal arguments, and the suffixes each name takes.
33
+ SUFFIXES_BY_CALL = Ractor.make_shareable(
34
+ {
35
+ attr_reader: [""], attr: [""], attr_writer: ["="], attr_accessor: ["", "="], delegate: [""]
36
+ }
37
+ )
38
+ NAME_ARG_CALLS = %i[define_method alias_method].freeze
39
+ MIXIN_CALLS = %i[include prepend].freeze
40
+
41
+ Entry = Struct.new(:names, :mixins)
42
+ private_constant :Entry
43
+
44
+ def initialize(asts)
45
+ @classes = Hash.new { |hash, key| hash[key] = Entry.new(Set.new, []) }
46
+ @modules = Hash.new { |hash, key| hash[key] = Entry.new(Set.new, []) }
47
+ asts.each_value { |ast| walk_namespaces(ast, []) unless ast.nil? }
48
+ @declared = {}
49
+ end
50
+
51
+ # True when `class_name`'s own body, or a project module it includes or prepends, names `method_name`.
52
+ def declares?(class_name, method_name)
53
+ (@declared[class_name] ||= declared_names(class_name)).include?(method_name.to_sym)
54
+ end
55
+
56
+ private
57
+
58
+ def declared_names(class_name)
59
+ entry = @classes.fetch(class_name, nil)
60
+ return Set.new.freeze if entry.nil?
61
+
62
+ names = entry.names.dup
63
+ visited = Set.new
64
+ queue = entry.mixins.dup
65
+ until queue.empty?
66
+ module_keys_for(queue.shift).each do |key|
67
+ next unless visited.add?(key)
68
+
69
+ names.merge(@modules[key].names)
70
+ queue.concat(@modules[key].mixins)
71
+ end
72
+ end
73
+ names.freeze
74
+ end
75
+
76
+ def module_keys_for(name)
77
+ @modules.keys.select { |key| key == name || key.end_with?("::#{name}") }
78
+ end
79
+
80
+ def walk_namespaces(node, scope_stack)
81
+ case node
82
+ when Prism::ClassNode, Prism::ModuleNode
83
+ name = SyntheticMethodScanner.class_name_from(node, scope_stack)
84
+ if name
85
+ table = node.is_a?(Prism::ClassNode) ? @classes : @modules
86
+ collect_members(node.body, table[name])
87
+ end
88
+ inner = scope_stack + [node]
89
+ node.body&.rigor_each_child { |child| walk_namespaces(child, inner) }
90
+ else
91
+ node.rigor_each_child { |child| walk_namespaces(child, scope_stack) }
92
+ end
93
+ end
94
+
95
+ # Every member-naming form under the namespace body, at any depth short of a nested namespace (a
96
+ # `private def`, a `def` under a conditional and an `included do ... def` all count). A nested `class`,
97
+ # `module` or `class << self` is another namespace's body.
98
+ def collect_members(node, entry)
99
+ return if node.nil?
100
+
101
+ case node
102
+ when Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode then return
103
+ when Prism::DefNode then entry.names << node.name if node.receiver.nil?
104
+ when Prism::AliasMethodNode then add_symbol(entry, node.new_name)
105
+ when Prism::CallNode then collect_call(node, entry)
106
+ end
107
+ node.rigor_each_child { |child| collect_members(child, entry) }
108
+ end
109
+
110
+ def collect_call(call, entry)
111
+ return unless call.receiver.nil? || call.receiver.is_a?(Prism::SelfNode)
112
+
113
+ args = call.arguments&.arguments || []
114
+ if MIXIN_CALLS.include?(call.name)
115
+ entry.mixins.concat(args.filter_map { |arg| SyntheticMethodScanner.const_name_string(arg) })
116
+ elsif NAME_ARG_CALLS.include?(call.name)
117
+ add_symbol(entry, args.first)
118
+ elsif (suffixes = SUFFIXES_BY_CALL[call.name])
119
+ args.each { |arg| suffixes.each { |suffix| add_symbol(entry, arg, suffix: suffix) } }
120
+ end
121
+ end
122
+
123
+ def add_symbol(entry, node, suffix: "")
124
+ value = Source::Literals.symbol_or_string_name(node)
125
+ entry.names << :"#{value}#{suffix}" unless value.nil?
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -8,6 +8,7 @@ require_relative "../source/literals"
8
8
  require_relative "../source/node_children"
9
9
  require_relative "synthetic_method"
10
10
  require_relative "synthetic_method_index"
11
+ require_relative "synthetic_method_scanner/project_members"
11
12
 
12
13
  module Rigor
13
14
  module Inference
@@ -64,17 +65,30 @@ module Rigor
64
65
  nested_templates = collect_nested_class_templates(plugin_registry)
65
66
  return SyntheticMethodIndex::EMPTY if templates.empty? && registries.empty? && nested_templates.empty?
66
67
  # Tier B alone cannot emit without an environment: every trait entry — the direct route and the
67
- # concern-re-targeted one — funnels through `module_instance_method_names`, whose first line
68
- # answers `[]` for a nil environment. The production pre-pass passes `environment: nil` (#476),
69
- # so a project whose only contributing plugin registers trait registries (rigor-devise on a
70
- # Rails app) would otherwise pay a whole-project parse to build a provably empty index. This
71
- # gate mirrors that nil guard and MUST be removed in the change that threads a real environment.
68
+ # concern-re-targeted one — funnels through `module_instance_method_names`, whose first line answers `[]`
69
+ # for a nil environment. The production pre-pass hands Tier B a (lazy) environment since #476, so this
70
+ # gate now guards the direct callers that pass none (a spec, a probe): a registry-only scan with
71
+ # no environment would otherwise pay a whole-project parse to build a provably empty index. It mirrors
72
+ # that nil guard and goes with it.
72
73
  return SyntheticMethodIndex::EMPTY if environment.nil? && templates.empty? && nested_templates.empty?
73
74
 
74
- asts = parse_paths(paths, buffer: buffer)
75
+ sources = read_sources(paths, buffer: buffer)
76
+ # With only trait registries contributing, a project that never writes a registry's method name (no
77
+ # `devise` anywhere) cannot produce an entry: a call node named `devise` needs the text `devise` in its
78
+ # file. Skip the parse (the dominant cost) then, and the environment build it would have asked for.
79
+ return SyntheticMethodIndex::EMPTY if templates.empty? && nested_templates.empty? &&
80
+ none_mention_registry_method?(sources, registries)
81
+
82
+ asts = parse_sources(sources)
83
+ entries, class_names = collect_all_entries(asts, templates, registries, nested_templates, environment,
84
+ fact_store)
85
+ SyntheticMethodIndex.new(entries: decline_project_declared(entries, asts), class_names: class_names)
86
+ end
87
+
88
+ # The match + emit walk over every parsed file: `[entries, class_names]`.
89
+ def collect_all_entries(asts, templates, registries, nested_templates, environment, fact_store)
75
90
  hierarchy = build_hierarchy(asts)
76
91
  concern_index = build_concern_index(asts)
77
-
78
92
  entries = []
79
93
  class_names = []
80
94
  asts.each do |path, ast|
@@ -88,8 +102,19 @@ module Rigor
88
102
  end
89
103
  collect_nested_class_entries(entries, class_names, nested_templates, ast, path) unless nested_templates.empty?
90
104
  end
105
+ [entries, class_names]
106
+ end
91
107
 
92
- SyntheticMethodIndex.new(entries: entries, class_names: class_names)
108
+ # PR #2286 review S1 — a Tier B entry whose name the class itself declares, or a project module it includes
109
+ # or prepends declares, is dropped: Ruby answers that call from the project's member, ahead of the module
110
+ # the registry included ({ProjectMembers}). Tier C entries (no `origin_module`) are left as they were.
111
+ def decline_project_declared(entries, asts)
112
+ return entries unless entries.any? { |entry| entry.provenance[:origin_module] }
113
+
114
+ members = ProjectMembers.new(asts)
115
+ entries.reject do |entry|
116
+ entry.provenance[:origin_module] && members.declares?(entry.class_name, entry.method_name)
117
+ end
93
118
  end
94
119
 
95
120
  # Aggregates `(plugin_id, template)` pairs across every plugin's `manifest.heredoc_templates` in registration
@@ -249,16 +274,32 @@ module Rigor
249
274
  call_node.arguments&.arguments&.first
250
275
  end
251
276
 
252
- def parse_paths(paths, buffer: nil)
277
+ # `path => source text` (nil when the file cannot be read), reading an editor buffer's in-flight bytes
278
+ # for the logical path it is bound to.
279
+ def read_sources(paths, buffer: nil)
253
280
  paths.to_h do |path|
254
281
  physical = buffer ? buffer.resolve(path) : path
255
- source = File.read(physical)
256
- [path, Prism.parse(source, filepath: path).value]
282
+ [path, File.read(physical)]
257
283
  rescue StandardError
258
284
  [path, nil]
259
285
  end
260
286
  end
261
287
 
288
+ def parse_sources(sources)
289
+ sources.to_h do |path, source|
290
+ [path, source.nil? ? nil : Prism.parse(source, filepath: path).value]
291
+ rescue StandardError
292
+ [path, nil]
293
+ end
294
+ end
295
+
296
+ def none_mention_registry_method?(sources, registries)
297
+ names = registries.map { |(_plugin_id, registry)| registry.method_name.to_s }
298
+ sources.each_value.none? do |source|
299
+ !source.nil? && names.any? { |name| source.include?(name) }
300
+ end
301
+ end
302
+
262
303
  # ADR-16 slice 4 — Concern re-targeting index.
263
304
  #
264
305
  # Walks every top-level / nested `module M` decl looking for the ActiveSupport::Concern shape:
@@ -534,6 +575,18 @@ module Rigor
534
575
  # Returns the Symbol method-name list defined on `module_name`'s RBS instance definition. Empty Array when the
535
576
  # module is not in the RBS env (silent skip — the synthetic emit produces nothing rather than fabricating method
536
577
  # names).
578
+ #
579
+ # RBS builds a module's instance definition over its self types, or over `Object` when it declares none:
580
+ # `Kernel#puts`, `Object#hash`, the pretty-printer's mixin — and, for `module Devise::Models::X :
581
+ # ActiveRecord::Base`, every `ActiveRecord::Base` method (`save`, `update`) — appear in its method table
582
+ # beside its own members. They are not what including the module adds, and synthesising them would answer
583
+ # the class's own or inherited methods from the module's view of them. A method is kept only when its
584
+ # defining owner is the module itself or one of the module's own ancestors (its `include` / `prepend`
585
+ # chain, which `definition.ancestors` lists without the self types or `Object`).
586
+ #
587
+ # A non-public method is skipped: the tier answers a call by name, and a private module method answered
588
+ # for `user.secret` would silence the call Ruby rejects. A definition that carries no owner (the specs'
589
+ # stubs) keeps every name.
537
590
  def module_instance_method_names(module_name, environment)
538
591
  return [] if environment.nil?
539
592
 
@@ -543,7 +596,14 @@ module Rigor
543
596
  definition = loader.instance_definition(module_name)
544
597
  return [] if definition.nil?
545
598
 
546
- definition.methods.keys
599
+ own_ancestors = nil
600
+ definition.methods.filter_map do |name, method|
601
+ next name unless method.respond_to?(:defined_in)
602
+ next unless method.public?
603
+
604
+ own_ancestors ||= definition.ancestors.ancestors.to_set { |ancestor| ancestor.name.to_s }
605
+ name if own_ancestors.include?(method.defined_in.to_s)
606
+ end
547
607
  rescue StandardError
548
608
  []
549
609
  end
@@ -23,7 +23,9 @@ module Rigor
23
23
  # - a statement, and the body of `(…)`, `begin` / `rescue` / `else` / `ensure`;
24
24
  # - an assignment's value — every variable-write form, and an index `||=` / `&&=` / `op=`, but not its
25
25
  # receiver or index arguments;
26
- # - an `if` / `unless` / `case` predicate and every branch, but not a `when` condition or an `in` guard;
26
+ # - an `if` / `unless` / `case` predicate and every branch, but not a `when` condition or an `in` guard (since
27
+ # issue #1963 the evaluator threads a `when` test that runs code in a `case` with a subject; the scan does
28
+ # not admit it yet, which floors a name needlessly rather than letting a pin through);
27
29
  # - both operands of `&&` / `||`;
28
30
  # - a `while` / `until` predicate and body, a `for` index, collection and body;
29
31
  # - a multi-assign value and its local and instance-variable targets, and the LOCAL a `rescue => e`
@@ -98,6 +98,12 @@ module Rigor
98
98
  @dirty.key?(uri)
99
99
  end
100
100
 
101
+ # @return true when `uri` is open, its bytes are the ones on disk, and the table holds them faithfully: the
102
+ # buffers a whole-project round analysed as they are.
103
+ def on_disk?(uri)
104
+ open?(uri) && !dirty?(uri) && !desynchronized?(uri)
105
+ end
106
+
101
107
  def [](uri)
102
108
  @entries[uri]
103
109
  end
@@ -100,7 +100,8 @@ module Rigor
100
100
  publish_batch(eligible)
101
101
  end
102
102
 
103
- # Runs one whole-project save round and publishes to the publish set (#246). Called from `didSave`.
103
+ # Runs one whole-project save round and publishes to the publish set (#246), then re-publishes each dirty buffer
104
+ # from its own bytes (ADR-124). Called from `didSave`.
104
105
  #
105
106
  # Analysis scope is the whole project; the PUBLISH SET is the open buffers that are not dirty, plus the
106
107
  # buffer that was just saved. A dirty buffer is excluded because only its own `didChange` analysis has
@@ -117,6 +118,12 @@ module Rigor
117
118
  end
118
119
  end
119
120
 
121
+ # Re-publishes every open buffer from its own bytes, each through {#publish_for}. Called after the project
122
+ # context is invalidated (a watched file or the configuration changed): every marker on screen was computed
123
+ # against the world that is gone, and since ADR-124 a buffer's markers read other files' declarations, so
124
+ # they can move without the buffer itself changing.
125
+ def republish_open = @buffer_table.uris.each { |uri| publish_for(uri) }
126
+
120
127
  # Publishes an EMPTY diagnostic array for `uri`. The LSP-spec idiom for "clear inline markers" — called
121
128
  # from `didClose` so clients drop stale highlights when the user closes a buffer.
122
129
  def publish_empty(uri)
@@ -187,16 +194,20 @@ module Rigor
187
194
 
188
195
  notify(uri, (by_path[path] || []).filter_map { |diagnostic| to_lsp_diagnostic(diagnostic, path) })
189
196
  end
197
+ republish_dirty
190
198
  end
191
199
 
200
+ # The dirty buffers the round may not speak for, re-published from their own bytes (ADR-124). The save that
201
+ # started the round may have moved a declaration a dirty buffer's markers read across files, and nothing
202
+ # else re-analyses that buffer until its next change or save. Each goes through {#publish_for}, so the
203
+ # debouncer and `PublishBatcher` order it against the buffer's own `didChange` publishes: a re-publish run
204
+ # here, beside them, could land its older bytes' markers after a newer edit's.
205
+ def republish_dirty = @buffer_table.uris.each { |uri| publish_for(uri) if @buffer_table.dirty?(uri) }
206
+
192
207
  # The URIs this round may speak for: every open buffer whose bytes are the ones on disk. The just-saved
193
208
  # URI qualifies by definition (the client wrote it), and is named explicitly so a client that sends
194
209
  # `didSave` without a preceding applied `didChange` still refreshes it.
195
- def publish_set(saved_uri)
196
- ([saved_uri] + @buffer_table.uris).uniq.select do |uri|
197
- @buffer_table.open?(uri) && !@buffer_table.dirty?(uri) && !@buffer_table.desynchronized?(uri)
198
- end
199
- end
210
+ def publish_set(saved_uri) = ([saved_uri] + @buffer_table.uris).uniq.select { |uri| @buffer_table.on_disk?(uri) }
200
211
 
201
212
  def run_and_notify(uri, path)
202
213
  entry = @buffer_table[uri]
@@ -239,11 +250,13 @@ module Rigor
239
250
  def publish_batch(jobs)
240
251
  with_tempfiles(jobs) do |bound_jobs|
241
252
  bindings = bound_jobs.map { |job| job.fetch(:binding) }
253
+ @project_context.prepare_discovery_seed
242
254
  dispatcher = Analysis::Runner::BufferPoolDispatcher.new(
243
255
  configuration: @project_context.configuration,
244
256
  cache_store: @project_context.cache_store,
245
257
  environment: @project_context.environment,
246
258
  prebuilt: @project_context.project_scan,
259
+ discovery_seed_for: @project_context.method(:discovery_seed_for),
247
260
  workers: worker_count
248
261
  )
249
262
  diagnostics_per_binding = dispatcher.analyze(bindings)
@@ -282,6 +295,7 @@ module Rigor
282
295
  collect_stats: false,
283
296
  buffer: binding,
284
297
  prebuilt: @project_context.project_scan,
298
+ discovery_seed: @project_context.discovery_seed_for(binding),
285
299
  environment: @project_context.environment
286
300
  )
287
301
  result = runner.run([path])
@@ -7,6 +7,8 @@ require_relative "../analysis/runner"
7
7
  require_relative "../analysis/incremental_session"
8
8
  require_relative "../cache/incremental_snapshot"
9
9
  require_relative "../effects/identity"
10
+ require_relative "../analysis/path_expansion"
11
+ require_relative "../protection/discovery_seed"
10
12
 
11
13
  module Rigor
12
14
  module LanguageServer
@@ -58,6 +60,7 @@ module Rigor
58
60
  @primed_key = nil
59
61
  @buffer_sources = {}
60
62
  @disk_feature_files = nil
63
+ @disk_sig_names = nil
61
64
  @environment_features = nil
62
65
  @generation = 0
63
66
  @environment = nil
@@ -112,6 +115,31 @@ module Rigor
112
115
  @project_scan ||= build_project_scan
113
116
  end
114
117
 
118
+ # Issue #2147 — the cross-file discovery tables a per-buffer publish seeds its `Runner` with
119
+ # (`discovery_seed:`, #260). A `prebuilt:` runner runs no discovery pass of its own, so without them every
120
+ # class another file declares reads as an external ancestor and a publish answers differently from
121
+ # `rigor check` over the same tree. The per-file bundles are walked once per generation; each publish
122
+ # re-folds them with `buffer`'s file re-walked from the buffer's bytes
123
+ # ({Protection::DiscoverySeed.tables_for_buffer}), so the buffer's own declarations win over its on-disk
124
+ # ones, and a file whose bytes on disk moved since the bundles were built is re-walked too (its digest no
125
+ # longer matches).
126
+ #
127
+ # The buffer's file is matched by its real path and handed to the fold under the buffer's own spelling: the
128
+ # binding substitutes bytes only for a path spelled exactly as its logical path (the URI's absolute path),
129
+ # while the configured paths may be relative (`lib`, the default) or reach the file through a symlink.
130
+ def discovery_seed_for(buffer)
131
+ Protection::DiscoverySeed.tables_for_buffer(paths: discovery_paths_for(buffer), bundles: discovery_bundles,
132
+ buffer: buffer, constant_sources: true)
133
+ end
134
+
135
+ # Builds what {#discovery_seed_for} reads once per generation, so a fork batch's children inherit it instead
136
+ # of each walking the project.
137
+ def prepare_discovery_seed
138
+ discovery_bundles
139
+ discovery_real_paths
140
+ nil
141
+ end
142
+
115
143
  # Whole-project diagnostics for one save round (#246). The first call primes an in-process
116
144
  # {Analysis::IncrementalSession} — seeded from the on-disk snapshot a terminal
117
145
  # `rigor check --incremental` may have left, and otherwise from a full baseline; every later call is a
@@ -168,9 +196,14 @@ module Rigor
168
196
  # file.
169
197
  def required_features
170
198
  disk = (@disk_feature_files ||= Environment::RequiredFeatures.feature_files(@configuration))
171
- found = disk.reject { |path, _| @buffer_sources.key?(path) }.values.flatten
172
- found += @buffer_sources.values.flat_map { |source| Environment::RequiredFeatures.features_of(source) }
173
- found.uniq.sort.freeze
199
+ scanned = disk.reject { |path, _| @buffer_sources.key?(path) }.values
200
+ scanned += @buffer_sources.values.map { |source| Environment::RequiredFeatures.scan_source(source) }
201
+ features = scanned.flat_map(&:first).uniq
202
+ names = scanned.flat_map(&:last).to_set
203
+ names.merge(@disk_sig_names ||= Environment::RequiredFeatures.sig_names(
204
+ Environment::RequiredFeatures.configured_sig_paths(@configuration)
205
+ ))
206
+ Environment::RequiredFeatures.relevant(features, names).sort.freeze
174
207
  end
175
208
 
176
209
  # Drops every cached collaborator and bumps the generation. The next reader rebuilds from scratch.
@@ -188,7 +221,11 @@ module Rigor
188
221
  @environment = nil
189
222
  @environment_features = nil
190
223
  @disk_feature_files = nil
224
+ @disk_sig_names = nil
191
225
  @project_scan = nil
226
+ @discovery_paths = nil
227
+ @discovery_real_paths = nil
228
+ @discovery_bundles = nil
192
229
  # The session's per-file cache was computed against the old environment / project scan, so it cannot
193
230
  # outlive them. The next save round primes a fresh one.
194
231
  @incremental_session = nil
@@ -230,6 +267,25 @@ module Rigor
230
267
  )
231
268
  end
232
269
 
270
+ # The configured project files, expanded as `rigor check` expands its configured paths.
271
+ def discovery_paths
272
+ @discovery_paths ||= Analysis::PathExpansion.configured_files(@configuration).freeze
273
+ end
274
+
275
+ # Every entry that reaches the buffer's file is respelled, so a file configured under two roots (`lib` and a
276
+ # symlink to it) is not folded from disk through the second.
277
+ def discovery_paths_for(buffer)
278
+ Protection::DiscoverySeed.respell_for_buffer(discovery_paths, buffer, real_paths: discovery_real_paths)
279
+ end
280
+
281
+ def discovery_real_paths
282
+ @discovery_real_paths ||= discovery_paths.map { |path| Rigor::Analysis::BufferBinding.real_path(path) }.freeze
283
+ end
284
+
285
+ def discovery_bundles
286
+ @discovery_bundles ||= Protection::DiscoverySeed.bundles(paths: discovery_paths)
287
+ end
288
+
233
289
  def build_project_scan
234
290
  runner = Analysis::Runner.new(
235
291
  configuration: @configuration,
@@ -282,11 +282,12 @@ module Rigor
282
282
  end
283
283
 
284
284
  # workspace/didChangeWatchedFiles NOTIFICATION. Invalidates the ProjectContext so cached pre-pass /
285
- # Environment is rebuilt on the next request. Slice 7's floor: any watched file change triggers a full
286
- # context rebuild. Per-file surgical invalidation (per design doc § "Project context refresh") is a
287
- # follow-up; this is the LSP-correct floor.
285
+ # Environment is rebuilt on the next request, and re-publishes the open buffers against the new world
286
+ # (ADR-124). Slice 7's floor: any watched file change triggers a full context rebuild. Per-file surgical
287
+ # invalidation (per design doc § "Project context refresh") is a follow-up; this is the LSP-correct floor.
288
288
  def handle_did_change_watched_files(_params)
289
289
  @project_context&.invalidate!
290
+ @publisher&.republish_open
290
291
  nil
291
292
  end
292
293
 
@@ -295,6 +296,7 @@ module Rigor
295
296
  # (.rigor.yml / Gemfile.lock / etc).
296
297
  def handle_did_change_configuration(_params)
297
298
  @project_context&.invalidate!
299
+ @publisher&.republish_open
298
300
  nil
299
301
  end
300
302
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "slot"
4
+ require_relative "parameters"
4
5
  require_relative "../reflection"
5
6
 
6
7
  module Rigor
@@ -34,6 +35,26 @@ module Rigor
34
35
  unreadable("definition_build_failed", [])
35
36
  end
36
37
 
38
+ # The block an otherwise defaulted inline declaration (`rigor:v1:inferred-signature`) declares, as a
39
+ # `{required, type}` slot, or nil. The rbs-inline plugin defaults an unannotated `&block` to `?{ (?) ->
40
+ # untyped }`, so any other block came from a `# @rbs &block:` annotation, and `check` binds the block's
41
+ # parameters from it. A block spelled like the default carries nothing either way.
42
+ def read_defaulted_block(owner, scope, name, lookup_scope)
43
+ method = lookup(owner, scope, name, lookup_scope)
44
+ defs = method ? method.defs.select { |definition| definition.defined_in.to_s.delete_prefix("::") == owner } : []
45
+ return nil unless defs.size == 1 && annotations_of(method, defs).include?(INFERRED_SIGNATURE)
46
+
47
+ block = defs.first.type.block
48
+ return nil if block.nil? || default_block?(block)
49
+
50
+ provenance, path = @provenance.classify(buffer_name(defs.first))
51
+ return nil if provenance.nil?
52
+
53
+ { "required" => block.required, "type" => Slot.from_rbs(block_type(block), provenance, path) }
54
+ rescue StandardError
55
+ nil
56
+ end
57
+
37
58
  # The declared type of the constant `name` (qualified), as a slot with its declaration's provenance, or nil
38
59
  # when no RBS or inline declaration names it.
39
60
  def read_constant(name, environment)
@@ -102,14 +123,36 @@ module Rigor
102
123
  def overload(method_type, provenance, path, return_inferred, annotations)
103
124
  function = method_type.type
104
125
  params = function.respond_to?(:required_positionals) ? params_of(function, provenance, path) : nil
105
- block = method_type.block && { "required" => method_type.block.required,
106
- "type" => Slot.from_rbs(method_type.block.type.to_s, provenance, path) }
126
+ partly_defaulted = return_inferred && provenance == "inline"
127
+ block = method_type.block && block_slot(method_type.block, provenance, path, partly_defaulted)
107
128
  prefix = annotations.map { |string| "%a{#{string}} " }.join
108
129
  return_slot = return_inferred ? nil : Slot.from_rbs(function.return_type.to_s, provenance, path)
109
130
  { "rbs" => "#{prefix}#{method_type}", "params" => params, "block" => block, "return" => return_slot,
110
131
  "return_rbs" => function.return_type.to_s }
111
132
  end
112
133
 
134
+ # A block's function type as `RBS::MethodType#to_s` spells it inside the braces (`(Integer) -> void`, with a
135
+ # `[self: T]` binding when declared). Neither `RBS::Types::Function` nor `UntypedFunction` has a `to_s`.
136
+ def block_type(block)
137
+ self_binding = ::RBS::Types::SelfTypeBindingHelper.self_type_binding_to_s(block.self_type)
138
+ "(#{block.type.param_to_s}) #{self_binding}-> #{block.type.return_to_s}"
139
+ end
140
+
141
+ # Under an inline declaration the rbs-inline plugin defaulted in part (`partly_defaulted`), a block shaped
142
+ # like its default was not written: it reads as an undeclared block does.
143
+ def block_slot(block, provenance, path, partly_defaulted)
144
+ if partly_defaulted && default_block?(block)
145
+ return { "required" => false, "type" => Slot.dynamic("inferred", Parameters::UNDECLARED) }
146
+ end
147
+
148
+ { "required" => block.required, "type" => Slot.from_rbs(block_type(block), provenance, path) }
149
+ end
150
+
151
+ def default_block?(block)
152
+ !block.required && block.type.is_a?(::RBS::Types::UntypedFunction) && block.self_type.nil? &&
153
+ block.type.return_type.is_a?(::RBS::Types::Bases::Any)
154
+ end
155
+
113
156
  def params_of(function, provenance, path)
114
157
  (positional_params(function) + keyword_params(function)).map do |kind, name, param|
115
158
  { "name" => name&.to_s, "kind" => kind, "type" => Slot.from_rbs(param.type.to_s, provenance, path) }
@@ -5,6 +5,7 @@ require "json"
5
5
  require_relative "../version"
6
6
  require_relative "../cache/engine_source"
7
7
  require_relative "../analysis/run_cache_key"
8
+ require_relative "../rooted_glob"
8
9
 
9
10
  module Rigor
10
11
  module Lens
@@ -60,7 +61,7 @@ module Rigor
60
61
  end
61
62
 
62
63
  def signature_digest
63
- listing = signature_roots.flat_map { |root| Dir.glob(File.join(root, "**", "*.rbs")) }.sort.map do |path|
64
+ listing = signature_roots.flat_map { |root| RootedGlob.glob(root, "**/*.rbs") }.sort.map do |path|
64
65
  "#{relative(path)}\t#{Lens.digest(File.binread(path))}\n"
65
66
  end
66
67
  Lens.digest(listing.join)
@@ -417,15 +417,25 @@ module Rigor
417
417
  fields.each do |field, field_node|
418
418
  # rbs-inline reads a `#:` trailing a field on its own line of a multi-line call as that field's type.
419
419
  trailing = field_node.location.start_line != start_line
420
+ members = attribute_members(field, attribute)
421
+ visibility = builder_field_visibility(members)
420
422
  add_site(field_node, { "kind" => "attribute", "name" => field, "owner" => name, "scope" => "instance",
421
- "attribute" => attribute, "members" => attribute_members(field, attribute),
422
- "module_function" => false, "visibility" => "public", "params" => nil,
423
+ "attribute" => attribute, "members" => members,
424
+ "module_function" => false, "visibility" => visibility, "params" => nil,
423
425
  "block" => nil, "body_return" => nil }, contracts: trailing ? :trailing : false)
424
426
  end
425
427
  block = node.value.block
426
428
  walk_body(block.body, nesting + [name], singleton: false, kind: :builder) if block&.body
427
429
  end
428
430
 
431
+ # Ruby forces a field reader named `initialize_copy` (say) private, but not its `Struct` writer: a `Data`
432
+ # field reads `private`, a `Struct` one, whose site covers both, `null`.
433
+ def builder_field_visibility(members)
434
+ return "public" unless members.intersect?(FORCED_PRIVATE)
435
+
436
+ members.all? { |member| FORCED_PRIVATE.include?(member) } ? "private" : nil
437
+ end
438
+
429
439
  # Whether a constant, class or module written here lands somewhere the nesting does not name: a builder
430
440
  # block, which Ruby `class_eval`s (defs go to the new class, but constants stay with the lexical scope), or a
431
441
  # `class << self` body (constants go to the singleton class). Such a declaration is an omission.