rigortype 0.4.4 → 0.4.5

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 (147) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/docs/handbook/11-sig-gen.md +90 -17
  4. data/docs/manual/02-cli-reference.md +39 -9
  5. data/docs/manual/03-configuration.md +10 -8
  6. data/docs/manual/04-diagnostics.md +16 -5
  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 +8 -1
  12. data/docs/manual/18-removing-dead-code.md +18 -7
  13. data/docs/manual/plugins/rigor-rspec.md +101 -1
  14. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  15. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +111 -7
  16. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +190 -0
  17. data/lib/rigor/analysis/check_rules/rule_ids.rb +3 -1
  18. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  19. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +13 -4
  20. data/lib/rigor/analysis/check_rules.rb +228 -15
  21. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  22. data/lib/rigor/analysis/incremental.rb +49 -0
  23. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  24. data/lib/rigor/analysis/incremental_session.rb +166 -24
  25. data/lib/rigor/analysis/path_expansion.rb +3 -1
  26. data/lib/rigor/analysis/reachability/graph.rb +90 -13
  27. data/lib/rigor/analysis/reachability/scan.rb +279 -39
  28. data/lib/rigor/analysis/rule_catalog.rb +17 -4
  29. data/lib/rigor/analysis/runner/pool_coordinator.rb +69 -16
  30. data/lib/rigor/analysis/runner.rb +69 -65
  31. data/lib/rigor/analysis/severity_stamp.rb +11 -0
  32. data/lib/rigor/analysis/worker_session.rb +12 -1
  33. data/lib/rigor/cache/descriptor.rb +40 -7
  34. data/lib/rigor/cache/engine_source.rb +3 -2
  35. data/lib/rigor/cache/incremental_snapshot.rb +26 -8
  36. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  37. data/lib/rigor/cache/store.rb +4 -3
  38. data/lib/rigor/cli/command.rb +3 -1
  39. data/lib/rigor/cli/docs_command.rb +3 -2
  40. data/lib/rigor/cli/doctor_command.rb +1 -1
  41. data/lib/rigor/cli/explain_command.rb +3 -2
  42. data/lib/rigor/cli/plugin_command.rb +2 -1
  43. data/lib/rigor/cli/plugins_command.rb +2 -1
  44. data/lib/rigor/cli/sig_gen_command.rb +32 -11
  45. data/lib/rigor/cli/skill_command.rb +2 -1
  46. data/lib/rigor/cli/skill_describe.rb +3 -2
  47. data/lib/rigor/cli/unused_command.rb +10 -7
  48. data/lib/rigor/cli.rb +3 -2
  49. data/lib/rigor/config_audit.rb +60 -15
  50. data/lib/rigor/configuration/severity_profile.rb +20 -2
  51. data/lib/rigor/configuration.rb +29 -3
  52. data/lib/rigor/effects/signature_sources.rb +3 -1
  53. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  54. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  55. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  56. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  57. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  58. data/lib/rigor/environment/rbs_loader.rb +21 -1
  59. data/lib/rigor/inference/block_call_timing.rb +7 -1
  60. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  61. data/lib/rigor/inference/captured_locals.rb +44 -13
  62. data/lib/rigor/inference/clause_widening.rb +90 -0
  63. data/lib/rigor/inference/definer_resolution.rb +87 -20
  64. data/lib/rigor/inference/expression_typer.rb +35 -5
  65. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  66. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  67. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +170 -35
  68. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  69. data/lib/rigor/inference/method_dispatcher.rb +67 -3
  70. data/lib/rigor/inference/module_function_state.rb +137 -42
  71. data/lib/rigor/inference/narrowing.rb +54 -10
  72. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  73. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  74. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  75. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  76. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  77. data/lib/rigor/inference/project_seed_tables.rb +120 -0
  78. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  79. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  80. data/lib/rigor/inference/reflective_ivar_write.rb +6 -6
  81. data/lib/rigor/inference/return_barrier.rb +16 -0
  82. data/lib/rigor/inference/scope_indexer.rb +675 -105
  83. data/lib/rigor/inference/self_call_ivar_writes.rb +11 -6
  84. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  85. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  86. data/lib/rigor/inference/statement_evaluator.rb +446 -139
  87. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  88. data/lib/rigor/lens/contract_reader.rb +45 -2
  89. data/lib/rigor/lens/document.rb +2 -1
  90. data/lib/rigor/lens/file_reader.rb +12 -2
  91. data/lib/rigor/lens/members.rb +16 -2
  92. data/lib/rigor/lens/run.rb +34 -7
  93. data/lib/rigor/mcp/server.rb +2 -2
  94. data/lib/rigor/plugin/base.rb +3 -2
  95. data/lib/rigor/plugin/io_boundary.rb +42 -6
  96. data/lib/rigor/plugin/isolation.rb +4 -3
  97. data/lib/rigor/protection/discovery_seed.rb +9 -36
  98. data/lib/rigor/protection/mutation_cache.rb +8 -2
  99. data/lib/rigor/rooted_glob.rb +27 -0
  100. data/lib/rigor/scope/discovery_index.rb +5 -0
  101. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  102. data/lib/rigor/scope/resolution_chain.rb +40 -5
  103. data/lib/rigor/scope.rb +45 -2
  104. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  105. data/lib/rigor/sig_gen/classification.rb +4 -1
  106. data/lib/rigor/sig_gen/generator.rb +167 -22
  107. data/lib/rigor/sig_gen/layout_index.rb +5 -2
  108. data/lib/rigor/sig_gen/observation_collector.rb +137 -11
  109. data/lib/rigor/sig_gen/renderer.rb +13 -155
  110. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  111. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  112. data/lib/rigor/sig_gen/write_result.rb +3 -1
  113. data/lib/rigor/sig_gen/writer.rb +5 -3
  114. data/lib/rigor/signature_path_audit.rb +4 -2
  115. data/lib/rigor/type/constant.rb +10 -1
  116. data/lib/rigor/version.rb +1 -1
  117. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  118. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +1 -1
  119. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  120. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  121. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  122. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  123. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  124. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  125. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  126. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  127. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  128. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +1 -1
  129. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  130. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +38 -9
  131. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  132. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  133. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  134. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  135. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  136. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  137. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  138. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  139. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  140. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  141. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  142. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  143. data/sig/rigor/rooted_glob.rbs +7 -0
  144. data/sig/rigor/scope.rbs +4 -3
  145. data/skills/rigor-doctor/references/01-checks.md +23 -11
  146. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  147. metadata +13 -1
@@ -3,6 +3,7 @@
3
3
  require "prism"
4
4
 
5
5
  require_relative "scope_indexer"
6
+ require_relative "project_seed_tables"
6
7
  require_relative "fork_map"
7
8
  require_relative "../source/node_walker"
8
9
 
@@ -238,18 +239,10 @@ module Rigor
238
239
  base.with_discovery(base.discovery.with(**tables))
239
240
  end
240
241
 
242
+ # Issue #1614 — the tables a `check` run seeds, from the one list {ProjectSeedTables} keeps for every path that
243
+ # runs no `check`, so an argument types here as it does in the file `check` analyses.
241
244
  def discovery_seed_tables
242
- classes = ScopeIndexer.discovered_classes_for_paths(@files)
243
- def_index = ScopeIndexer.discovered_def_index_for_paths(@files)
244
- tables = {}
245
- tables[:discovered_classes] = classes unless classes.empty?
246
- DISCOVERY_FIELD.each do |index_key, field|
247
- table = def_index.fetch(index_key)
248
- tables[field] = table unless table.empty? && !Scope::DiscoveryIndex.paired?(field)
249
- end
250
- # ADR-119 WD1 — the pairs travel together: a pair is dropped only when both halves are empty. A sibling whose
251
- # member this collector does not seed completes to that member's empty value, which the base scope holds.
252
- Scope::DiscoveryIndex.compact_pairs(tables.merge(def_index.fetch(:siblings)))
245
+ ProjectSeedTables.for_paths(@files)
253
246
  rescue StandardError => e
254
247
  # Discovery is best-effort; a malformed corner of the project must not crash the
255
248
  # protection scan. Without discovery the collector simply resolves fewer call sites. A broken
@@ -257,21 +250,6 @@ module Rigor
257
250
  e.is_a?(DeclarationWalk::ContractError) ? raise : {}
258
251
  end
259
252
 
260
- DISCOVERY_FIELD = {
261
- def_nodes: :discovered_def_nodes,
262
- singleton_def_nodes: :discovered_singleton_def_nodes,
263
- def_sources: :discovered_def_sources,
264
- superclasses: :discovered_superclasses,
265
- header_nestings: :discovered_header_nestings,
266
- includes: :discovered_includes, unpositioned_mixins: :unpositioned_mixins,
267
- class_sources: :discovered_class_sources,
268
- method_visibilities: :discovered_method_visibilities,
269
- methods: :discovered_methods,
270
- data_member_layouts: :data_member_layouts,
271
- struct_member_layouts: :struct_member_layouts, deferred_ranges: :discovered_deferred_ranges
272
- }.freeze
273
- private_constant :DISCOVERY_FIELD
274
-
275
253
  # Built once over the whole project and inherited by the fork workers via copy-on-write; a frozen Set
276
254
  # for O(1) membership in the {#record_call} hot loop.
277
255
  def user_method_names(discovery)
@@ -27,25 +27,57 @@ module Rigor
27
27
  # alias earlier in the `pre_eval:` files binds `old`, the alias's entry copies that entry's `return_type` (and
28
28
  # its `alias_of`); otherwise `alias_of` names `old`, a method the class had before the patch, and the
29
29
  # dispatcher answers the alias with what that method answers. `alias_of` is nil for a `def`.
30
- Entry = Data.define(:class_name, :method_name, :kind, :source_path, :source_line, :return_type, :alias_of) do
31
- def initialize(class_name:, method_name:, kind:, source_path:, source_line:, return_type: nil, alias_of: nil)
30
+ #
31
+ # Issue #1708 — `visibility` is `:public`, `:private` or `:protected`, as the patch file leaves the method: a
32
+ # `private def x`, a `def` after a bare `private` in the same body, a later `private :x`, and the instance copy
33
+ # a `module_function` makes are `:private` ({#callable?} reads it). An alias takes its old name's visibility,
34
+ # as Ruby's does; one whose old name no patch binds is `:public`.
35
+ #
36
+ # `farther` is set only on an entry {#lookup_through_ancestors} returns: the other definers of the name farther
37
+ # on the walk (entries, or {SOURCE_DEFINER} for a project-source `def`), which {#callable?} reads before it
38
+ # refuses the call.
39
+ Entry = Data.define(:class_name, :method_name, :kind, :source_path, :source_line, :return_type, :alias_of,
40
+ :visibility, :farther) do
41
+ def initialize(class_name:, method_name:, kind:, source_path:, source_line:, return_type: nil, alias_of: nil,
42
+ visibility: :public, farther: nil)
32
43
  super
33
44
  end
34
45
  end
35
46
 
36
- attr_reader :by_key, :write_census
47
+ # Issue #1708 — one `include` or `prepend` a `pre_eval:` file writes (`Numeric.include(M)`, or `include M` in
48
+ # `class Numeric`): `owner` gains `module_name` as an ancestor, `kind` `:include` or `:prepend`. Names are as
49
+ # written, with no lexical resolution, as a patch's class name is.
50
+ Mixin = Data.define(:owner, :module_name, :kind)
51
+
52
+ attr_reader :by_key, :write_census, :mixins, :overrides
53
+
54
+ # Each owner's prepended and included modules, the most recent first.
55
+ def self.mixins_by_owner(mixins)
56
+ table = mixins.each_with_object({}) do |mixin, acc|
57
+ edges = (acc[mixin.owner] ||= { prepend: [], include: [] })
58
+ edges[mixin.kind].unshift(mixin.module_name)
59
+ end
60
+ table.each_value { |edges| edges.each_value(&:freeze) }.freeze
61
+ end
37
62
 
38
63
  # @param entries — flat list of declarations observed during the pre-pass.
39
64
  # Last-write-wins on `(class_name, method_name, kind)` duplicates, as at runtime (ADR-17); the
40
65
  # `pre-eval.duplicate-declaration` diagnostic emission stays decoupled from registry behaviour.
41
66
  # @param write_census — issue #1367: the `pre_eval:` files' {GlobalWriteCensus}, which the `global.*` write
42
67
  # rules join with the project's, since a patch file is loaded ahead of the code it patches.
43
- def initialize(entries: [], write_census: GlobalWriteCensus::EMPTY)
68
+ # @param mixins — issue #1708: the {Mixin} edges the `pre_eval:` files write, in the order they run.
69
+ # @param overrides — issue #1708: `[class_name, method_name, kind] => visibility` for a named visibility call
70
+ # (`public :kp` in `class Integer`) on a method the class inherits rather than binds, which Ruby copies onto
71
+ # the class with that visibility.
72
+ def initialize(entries: [], write_census: GlobalWriteCensus::EMPTY, mixins: [], overrides: {})
44
73
  @by_key = entries.each_with_object({}) do |entry, acc|
45
74
  key = [entry.class_name, entry.method_name, entry.kind]
46
75
  acc[key] = entry
47
76
  end.freeze
48
77
  @write_census = write_census.frozen? ? write_census : write_census.dup.freeze
78
+ @mixins = mixins.dup.freeze
79
+ @mixins_of = ProjectPatchedMethods.mixins_by_owner(@mixins)
80
+ @overrides = overrides.dup.freeze
49
81
  freeze
50
82
  end
51
83
 
@@ -55,11 +87,143 @@ module Rigor
55
87
  @by_key[[class_name, method_name, kind]]
56
88
  end
57
89
 
90
+ # Issue #1708 — the entry a `kind` receiver of `class_name` reaches through its ancestors, searched in Ruby's
91
+ # order: a patch on `Object`, `Kernel`, `Numeric`, `Comparable` or `Enumerable` answers an `Integer`, `Array`
92
+ # or `String` receiver, and a patch on a nearer ancestor wins over one on a farther. A class object's chain
93
+ # is its singleton classes, then `Class`, `Module`, `Object`, `Kernel` and `BasicObject`, so `def zz` in
94
+ # `class Object` answers `Integer.zz` too. The ancestors are the class's RBS ones; a class RBS does not
95
+ # declare is searched as itself only, as {#lookup} searches it.
96
+ #
97
+ # It answers only which patch the chain holds. A method a nearer ancestor's RBS declares wins over the patch
98
+ # in Ruby, so the callers ask once that RBS has come back without the method. A method the project's own
99
+ # source defines on the way wins too: `source_defines` is called with each `owner, side` BEFORE that owner's
100
+ # patch is read, and when it answers true the walk stops with no entry, leaving the call to what types that
101
+ # method. That includes an owner holding both a patch and a source `def` (a `lib/` reopen loaded after the
102
+ # patch, or a module the project prepends to it), unlike {#lookup}, which answers the patch.
103
+ #
104
+ # A module the `pre_eval:` files include into or prepend to an owner on the way ({Mixin}) is searched where Ruby
105
+ # puts it: prepends ahead of the owner, includes after it, the most recent first, each with its own mixins. A
106
+ # named visibility call on an owner the walk passes before the body (`public :kp` in `class Integer`, an
107
+ # override) gives the entry it returns that visibility, and that owner for {#callable?}'s protected check.
108
+ # @return the entry, or `nil` when no ancestor's patch declares the method ahead of a source `def`.
109
+ def lookup_through_ancestors(class_name:, method_name:, kind:, rbs_loader:, source_defines: nil) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
110
+ return nil if empty?
111
+
112
+ override = nil
113
+ found = nil
114
+ farther = []
115
+ ancestor_keys(class_name.to_s, kind, rbs_loader).each do |owner, side|
116
+ if source_defines&.call(owner, side)
117
+ return nil if found.nil?
118
+
119
+ farther << SOURCE_DEFINER
120
+ break
121
+ end
122
+
123
+ entry = @by_key[[owner, method_name, side]]
124
+ if found
125
+ farther << entry if entry
126
+ elsif entry
127
+ found = override ? entry.with(class_name: override.first, visibility: override.last) : entry
128
+ elsif (visibility = @overrides[[owner, method_name, side]])
129
+ override ||= [owner, visibility]
130
+ end
131
+ end
132
+ farther.empty? || found.nil? ? found : found.with(farther: farther.freeze)
133
+ end
134
+
135
+ # A project-source `def` of the name farther on the walk than the entry it returns, in {Entry#farther}.
136
+ SOURCE_DEFINER = Object.new.freeze
137
+
138
+ # Issue #1708 — whether Ruby lets a call reach `entry`. A public method answers any call; a private one only a
139
+ # call with no receiver or with `self` as it (`explicit` is false for those); a protected one also a call from
140
+ # a method whose `self` is an instance of the entry's owner. `self_type` is the caller's `self` (`nil` at the
141
+ # top level, where it is `main`, an Object); a `self` this cannot name (the default) may be one, so a
142
+ # protected entry answers it.
143
+ #
144
+ # It never refuses a call some other definer of the name may answer: the walk's order is an approximation
145
+ # (a module two owners mix in is spliced at the nearer one, where Ruby keeps the first include and skips the
146
+ # second), so when {Entry#farther} holds a definer the call may reach (a project-source `def`, or an entry this
147
+ # would let it reach), it answers nil: decline, neither refused nor typed from `entry`.
148
+ # @return true (the call reaches `entry`), false (Ruby refuses it), or nil (declined).
149
+ def callable?(entry, explicit:, self_type: :unknown, rbs_loader: nil)
150
+ return true unless explicit
151
+
152
+ allowed =
153
+ case entry.visibility
154
+ when :private, :module_function then false
155
+ when :protected then self_kind_of?(self_type, entry.class_name, rbs_loader)
156
+ else true
157
+ end
158
+ return true if allowed
159
+
160
+ reachable = (entry.farther || []).any? do |other|
161
+ other.equal?(SOURCE_DEFINER) || callable?(other, explicit: true, self_type: self_type, rbs_loader: rbs_loader)
162
+ end
163
+ reachable ? nil : false # rubocop:disable Style/ReturnNilInPredicateMethodDefinition
164
+ end
165
+
58
166
  def empty?
59
167
  @by_key.empty?
60
168
  end
61
169
 
62
170
  EMPTY = new.freeze
171
+
172
+ private
173
+
174
+ # Whether `self_type` may be an instance of `owner`: false only when the ancestors of a class RBS knows,
175
+ # with the patches' mixins, do not hold it.
176
+ def self_kind_of?(self_type, owner, rbs_loader)
177
+ name, kind =
178
+ if self_type.nil? then ["Object", :instance]
179
+ elsif self_type.respond_to?(:class_name) && self_type.class_name
180
+ [self_type.class_name.to_s, self_type.is_a?(Type::Singleton) ? :singleton : :instance]
181
+ end
182
+ return true if name.nil? || rbs_loader.nil?
183
+
184
+ keys = ancestor_keys(name, kind, rbs_loader)
185
+ keys == [[name, kind]] || keys.include?([owner, :instance])
186
+ end
187
+
188
+ def ancestor_keys(class_name, kind, rbs_loader)
189
+ pairs =
190
+ if kind == :singleton
191
+ sides = rbs_loader ? rbs_loader.singleton_ancestors_for(class_name) : []
192
+ sides.empty? ? [[class_name, :singleton]] : sides.map { |side, owner| [owner, side] }
193
+ else
194
+ names = rbs_loader ? rbs_loader.ancestor_names_for(class_name) : []
195
+ (names.empty? ? [class_name] : names).map { |owner| [owner, :instance] }
196
+ end
197
+ @mixins_of.empty? ? pairs : with_mixins(pairs)
198
+ end
199
+
200
+ # The RBS ancestor pairs with each instance-side owner's `pre_eval:` mixins spliced in. A module already on
201
+ # the chain is not added again, as Ruby skips a module the ancestors already hold.
202
+ def with_mixins(pairs)
203
+ chain = pairs.filter_map { |owner, side| owner if side == :instance }.to_set
204
+ seen = Set.new
205
+ pairs.flat_map do |owner, side|
206
+ side == :instance ? expand_mixins(owner, chain, seen).map { |name| [name, :instance] } : [[owner, side]]
207
+ end
208
+ end
209
+
210
+ # `owner` with the modules the patches prepend to it ahead and the ones they include after, each expanded the
211
+ # same way: the order Ruby's `ancestors` lists them in. An include of a module the RBS chain already holds
212
+ # changes nothing, as Ruby skips it (`Integer.include(Comparable)`); a prepend of one is spliced ahead all the
213
+ # same, as Ruby adds it again (`Integer.prepend(Comparable)`). `seen` holds the modules already spliced, so a
214
+ # module is spliced once, at the nearest owner that mixes it in, and an include cycle ends. Ruby skips
215
+ # `Integer.include(A)` run after `Numeric.include(A)`, leaving A after Numeric; the registry, which does not read
216
+ # the order the two edges ran in, splices A after Integer either way.
217
+ def expand_mixins(owner, chain, seen)
218
+ edges = @mixins_of[owner]
219
+ return [owner] if edges.nil?
220
+
221
+ ahead = edges[:prepend].flat_map { |name| seen.add?(name) ? expand_mixins(name, chain, seen) : [] }
222
+ after = edges[:include].flat_map do |name|
223
+ !chain.include?(name) && seen.add?(name) ? expand_mixins(name, chain, seen) : []
224
+ end
225
+ ahead + [owner] + after
226
+ end
63
227
  end
64
228
  end
65
229
  end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "project_patched_methods"
6
+ require_relative "../source/constant_path"
7
+
8
+ module Rigor
9
+ module Inference
10
+ module ProjectPatchedScanner
11
+ # Issue #1708 — the `include` / `prepend` edges a `pre_eval:` file writes, so a module it mixes into an
12
+ # ancestor (`Numeric.include(M)`) answers the receivers below that ancestor through
13
+ # {ProjectPatchedMethods#lookup_through_ancestors}. Ruby 4.0.5: after `Numeric.include(NumExt)`, `1.inc_m` and
14
+ # `1.5.inc_m` run.
15
+ module Mixins
16
+ # An `include` / `prepend` the walk met, with the lexical prefix {.resolve} resolves its module's written
17
+ # name in.
18
+ Pending = Data.define(:owner, :module_name, :kind, :prefix)
19
+
20
+ CALLS = %i[include prepend].freeze
21
+ private_constant :CALLS
22
+
23
+ module_function
24
+
25
+ # The {Pending} edges `node` writes, or none: `Numeric.include(M)` on a constant, or `include M` /
26
+ # `self.include(M)` in a body. A top-level `include M` includes into `Object`, as Ruby's does. One in a
27
+ # `class << self` body is an `extend`, which this does not read; a module argument that is not a constant is
28
+ # skipped.
29
+ def pending(node, qualified_prefix, in_singleton_class)
30
+ return [] unless CALLS.include?(node.name) && node.arguments
31
+
32
+ owner = owner(node.receiver, qualified_prefix, in_singleton_class)
33
+ return [] if owner.nil?
34
+
35
+ node.arguments.arguments.filter_map do |arg|
36
+ name = Source::ConstantPath.qualified_name_or_nil(arg)
37
+ next if name.nil?
38
+
39
+ prefix = arg.is_a?(Prism::ConstantPathNode) && arg.parent.nil? ? [] : qualified_prefix
40
+ Pending.new(owner: owner, module_name: name, kind: node.name, prefix: prefix)
41
+ end
42
+ end
43
+
44
+ def owner(receiver, qualified_prefix, in_singleton_class)
45
+ case receiver
46
+ when nil, Prism::SelfNode
47
+ return nil if in_singleton_class
48
+ return "Object" if qualified_prefix.empty? && receiver.nil?
49
+
50
+ qualified_prefix.join("::") unless qualified_prefix.empty?
51
+ else
52
+ Source::ConstantPath.qualified_name_or_nil(receiver)
53
+ end
54
+ end
55
+ private_class_method :owner
56
+
57
+ # The `include` / `prepend` call an idempotence guard runs only while its module is not yet an ancestor of its
58
+ # receiver, or nil. The guards are `X.include?(M)`, `X.ancestors.include?(M)` and `X < M`, written after the
59
+ # call as `unless` or as `if !`; the guard only skips a second include, so the call is read as a direct
60
+ # statement would be (#2109). The guard names the call's own receiver and module; any other condition, or a
61
+ # guard naming another receiver or module, leaves the call unread.
62
+ #
63
+ # `top_level` says the conditional is a direct statement of the file's top level, where a receiverless call
64
+ # includes into Object; see {guarded_call?}. The `CALLS` check is defensive: the scanner applies a call's mixin
65
+ # edge only when {Mixins.pending} reads it as include or prepend, so a call of another name changes nothing.
66
+ def guarded(node, top_level)
67
+ return nil unless node.is_a?(Prism::UnlessNode) || node.is_a?(Prism::IfNode)
68
+
69
+ call = guarded_body(node)
70
+ return nil unless call.is_a?(Prism::CallNode) && CALLS.include?(call.name) && guarded_call?(call, top_level)
71
+
72
+ guard = when_undefined(node)
73
+ call if guard && guards?(guard, call)
74
+ end
75
+
76
+ # Whether `call` takes exactly one module, and is not receiverless at the file's top level. Such an include's
77
+ # guard would be `include?` on main, which Ruby does not define: the guard raises, so it is not read as an
78
+ # include into Object. A receiverless call in a class or module body names that body's own module.
79
+ def guarded_call?(call, top_level)
80
+ call.arguments&.arguments&.size == 1 && !(top_level && call.receiver.nil?)
81
+ end
82
+ private_class_method :guarded_call?
83
+
84
+ # The sole statement of an `unless` or `if` body, or nil when the conditional has an else branch or another
85
+ # body.
86
+ def guarded_body(node)
87
+ return nil if node.is_a?(Prism::UnlessNode) ? node.else_clause : node.subsequent
88
+
89
+ body = node.statements&.body
90
+ body.first if body&.size == 1
91
+ end
92
+ private_class_method :guarded_body
93
+
94
+ # The predicate the body runs under: `unless P` runs its body when P is false; `if !P` does too.
95
+ def when_undefined(node)
96
+ predicate = unwrap(node.predicate)
97
+ return predicate if node.is_a?(Prism::UnlessNode)
98
+ return nil unless predicate.is_a?(Prism::CallNode) && predicate.name == :! && predicate.receiver
99
+
100
+ unwrap(predicate.receiver)
101
+ end
102
+ private_class_method :when_undefined
103
+
104
+ def unwrap(node)
105
+ body = node.body.body if node.is_a?(Prism::ParenthesesNode) && node.body.is_a?(Prism::StatementsNode)
106
+ body&.size == 1 ? body.first : node
107
+ end
108
+ private_class_method :unwrap
109
+
110
+ # Whether `guard` is `X.include?(M)`, `X.ancestors.include?(M)` or `X < M`, where `X` and `M` are the receiver
111
+ # and the argument of `call`; {guarded} passes only a call with exactly one argument.
112
+ def guards?(guard, call)
113
+ return false unless guard.is_a?(Prism::CallNode) && guard.arguments&.arguments&.size == 1
114
+
115
+ subject, named = guard_subject(guard)
116
+ named && same_constant?(subject, call.receiver) && same_constant?(named, call.arguments.arguments.first)
117
+ end
118
+ private_class_method :guards?
119
+
120
+ # The `[receiver, module]` a guard names, or nil.
121
+ def guard_subject(guard)
122
+ named = guard.arguments.arguments.first
123
+ case guard.name
124
+ when :<
125
+ [guard.receiver, named] if guard.receiver
126
+ when :include?
127
+ receiver = guard.receiver
128
+ if receiver.is_a?(Prism::CallNode) && receiver.name == :ancestors && receiver.arguments.nil?
129
+ [receiver.receiver, named]
130
+ else
131
+ [receiver, named]
132
+ end
133
+ end
134
+ end
135
+ private_class_method :guard_subject
136
+
137
+ # Whether two receivers or modules name the same constant; two absent receivers (self) match.
138
+ def same_constant?(left, right)
139
+ return left.nil? && right.nil? if left.nil? || right.nil?
140
+
141
+ name = Source::ConstantPath.qualified_name_or_nil(left)
142
+ !name.nil? && name == Source::ConstantPath.qualified_name_or_nil(right)
143
+ end
144
+ private_class_method :same_constant?
145
+
146
+ # Each {Pending} edge's module name, resolved as Ruby resolves the constant from where it is written:
147
+ # `include NumExt` in `module Ext` names `Ext::NumExt` when a patch body opens that, else `NumExt`. Only the
148
+ # names the `pre_eval:` files open or mix into are candidates; one they do not name stays as written.
149
+ def resolve(pending, entries)
150
+ known = entries.to_set(&:class_name).merge(pending.map(&:owner))
151
+ pending.map do |item|
152
+ candidates = item.prefix.size.downto(1).map do |depth|
153
+ [*item.prefix.first(depth), item.module_name].join("::")
154
+ end
155
+ name = candidates.find { |candidate| known.include?(candidate) } || item.module_name
156
+ ProjectPatchedMethods::Mixin.new(owner: item.owner, module_name: name, kind: item.kind)
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -4,6 +4,8 @@ require "prism"
4
4
 
5
5
  require_relative "project_patched_methods"
6
6
  require_relative "project_patched_definedness_guard"
7
+ require_relative "project_patched_visibility"
8
+ require_relative "project_patched_mixins"
7
9
  require_relative "../analysis/dependency_source_inference/return_type_heuristic"
8
10
  require_relative "../source/alias_names"
9
11
  require_relative "../source/constant_path"
@@ -20,7 +22,7 @@ module Rigor
20
22
  # only needs `class C; def m; end; end` shape recognition, not full inference. Parse errors degrade
21
23
  # to a fail-soft `:warning` `pre-eval.parse-error` diagnostic accumulated alongside the registry;
22
24
  # per ADR-17 § "Failure modes" a parse failure in a pre-eval file MUST NOT abort the rest of the run.
23
- module ProjectPatchedScanner
25
+ module ProjectPatchedScanner # rubocop:disable Metrics/ModuleLength
24
26
  # Frozen scan outcome carrying the populated registry and the per-file warnings the runner emits
25
27
  # at run start.
26
28
  class Result < Data.define(:registry, :diagnostics)
@@ -57,10 +59,13 @@ module Rigor
57
59
  diagnostics = []
58
60
  census = Set.new
59
61
  paths.each { |path| scan_file(path, collected, diagnostics, buffer, census) }
60
- entries = resolve_aliases(collected)
62
+ pending_mixins, collected = collected.partition { |item| item.is_a?(Mixins::Pending) }
63
+ overrides = {}
64
+ entries = resolve_aliases(collected, overrides)
61
65
  diagnostics.concat(duplicate_declaration_diagnostics(entries))
62
66
  Result.new(
63
- registry: ProjectPatchedMethods.new(entries: entries, write_census: census.freeze),
67
+ registry: ProjectPatchedMethods.new(entries: entries, write_census: census.freeze, overrides: overrides,
68
+ mixins: Mixins.resolve(pending_mixins, entries)),
64
69
  diagnostics: diagnostics
65
70
  )
66
71
  end
@@ -105,32 +110,50 @@ module Rigor
105
110
  # idiom (`def foo`, `alias_method :foo_without_x, :foo`, `alias_method :foo, :foo_with_x`) leaves `foo` the
106
111
  # alias. Bindings of one name in DIFFERENT files are what `pre-eval.duplicate-declaration` reports, and the
107
112
  # registry keeps the last of those, as Ruby does.
108
- def resolve_aliases(collected)
113
+ #
114
+ # A {Visibility::Pending} (issue #1708) changes the visibility of the binding its name has when it runs, in the
115
+ # file that bound it, so it is not a second declaration; a name no earlier patch binds on the class is one it
116
+ # inherits, recorded in `overrides` ({Visibility.apply}). `module_function :x` also binds the public singleton
117
+ # copy.
118
+ def resolve_aliases(collected, overrides = {})
109
119
  bound = {}
120
+ homes = {}
110
121
  per_file = collected.group_by(&:source_path).values.map do |items|
111
122
  final = {}
112
123
  items.each do |item|
124
+ if item.is_a?(Visibility::Pending)
125
+ next Visibility.apply(item, bound, homes, overrides) { |copy| bind(copy, bound, homes, final) }
126
+ end
127
+
113
128
  entry = item.is_a?(PendingAlias) ? alias_entry(item, bound) : item
114
129
  entry = entry.entry if entry.is_a?(GuardedDef)
115
130
  key = [entry.class_name, entry.method_name, entry.kind]
116
131
  next if item.is_a?(GuardedDef) && bound.key?(key)
117
132
 
118
- bound[key] = entry
119
- final.delete(key)
120
- final[key] = entry
133
+ bind(entry, bound, homes, final)
121
134
  end
122
- final.values
135
+ final
123
136
  end
124
- per_file.flatten(1)
137
+ per_file.flat_map(&:values)
125
138
  end
126
139
  private_class_method :resolve_aliases
127
140
 
141
+ def bind(entry, bound, homes, final)
142
+ key = [entry.class_name, entry.method_name, entry.kind]
143
+ bound[key] = entry
144
+ homes[key] = final
145
+ final.delete(key)
146
+ final[key] = entry
147
+ end
148
+ private_class_method :bind
149
+
128
150
  def alias_entry(item, bound)
129
151
  target = bound[[item.class_name, item.old_name, item.kind]]
130
152
  ProjectPatchedMethods::Entry.new(
131
153
  class_name: item.class_name, method_name: item.new_name, kind: item.kind,
132
154
  source_path: item.source_path, source_line: item.source_line,
133
- return_type: target&.return_type, alias_of: target ? target.alias_of : item.old_name
155
+ return_type: target&.return_type, alias_of: target ? target.alias_of : item.old_name,
156
+ visibility: target ? target.visibility : :public
134
157
  )
135
158
  end
136
159
  private_class_method :alias_entry
@@ -148,8 +171,7 @@ module Rigor
148
171
  return
149
172
  end
150
173
 
151
- Thread.current[:rigor_guarded_defs] = DefinednessGuard.defs(parse_result.value)
152
- walk_node(parse_result.value, [], false, path, entries)
174
+ walk_file(parse_result.value, path, entries)
153
175
  # Issue #1367 — the file's `global.*` write facts: a patch file may alias a special or give a class the
154
176
  # method a setter asks for, as any project file may.
155
177
  census.merge(GlobalWriteCensus.scan(parse_result.value))
@@ -164,9 +186,19 @@ module Rigor
164
186
  )
165
187
  ensure
166
188
  Thread.current[:rigor_guarded_defs] = nil
189
+ Thread.current[:rigor_def_visibilities] = nil
167
190
  end
168
191
  private_class_method :scan_file
169
192
 
193
+ # Walks one parsed file with its definedness guards and `def` visibilities (issue #1708) in reach of
194
+ # {record_def_node}.
195
+ def walk_file(root, path, entries)
196
+ Thread.current[:rigor_guarded_defs] = DefinednessGuard.defs(root)
197
+ Thread.current[:rigor_def_visibilities] = Visibility.defs(root)
198
+ walk_node(root, [], false, path, entries)
199
+ end
200
+ private_class_method :walk_file
201
+
170
202
  def parse_error_diagnostic(path, errors)
171
203
  first = errors.first
172
204
  line = first.respond_to?(:location) ? first.location&.start_line || 1 : 1
@@ -205,6 +237,11 @@ module Rigor
205
237
  when Prism::CallNode
206
238
  record_alias(node, Source::AliasNames.alias_method_call_names(node), qualified_prefix,
207
239
  in_singleton_class, source_path, entries)
240
+ if Thread.current[:rigor_def_visibilities]&.[](node).equal?(Visibility::STATEMENT)
241
+ pending = Visibility.pending(node, qualified_prefix, in_singleton_class, source_path)
242
+ entries << pending if pending
243
+ entries.concat(Mixins.pending(node, qualified_prefix, in_singleton_class))
244
+ end
208
245
  walk_children(node, qualified_prefix, in_singleton_class, source_path, entries)
209
246
  else
210
247
  walk_children(node, qualified_prefix, in_singleton_class, source_path, entries)
@@ -251,7 +288,9 @@ module Rigor
251
288
  source_path: source_path, source_line: line,
252
289
  return_type: return_type
253
290
  )
291
+ entry, *copies = Visibility.applied(entry, Thread.current[:rigor_def_visibilities]&.[](node) || :public)
254
292
  entries << (Thread.current[:rigor_guarded_defs]&.key?(node) ? GuardedDef.new(entry: entry) : entry)
293
+ entries.concat(copies)
255
294
  end
256
295
  private_class_method :record_def_node
257
296