rigortype 0.4.3 → 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 (172) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/data/core_overlay/ostruct.rbs +40 -0
  4. data/docs/handbook/11-sig-gen.md +104 -15
  5. data/docs/manual/02-cli-reference.md +43 -12
  6. data/docs/manual/03-configuration.md +20 -7
  7. data/docs/manual/04-diagnostics.md +56 -3
  8. data/docs/manual/05-inspecting-types.md +2 -2
  9. data/docs/manual/06-baseline.md +1 -1
  10. data/docs/manual/07-plugins.md +3 -2
  11. data/docs/manual/08-skills.md +7 -0
  12. data/docs/manual/10-mcp-server.md +40 -4
  13. data/docs/manual/18-removing-dead-code.md +23 -2
  14. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  15. data/docs/manual/plugins/rigor-rspec.md +101 -1
  16. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  17. data/exe/rigor +12 -17
  18. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  19. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +111 -7
  20. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +190 -0
  21. data/lib/rigor/analysis/check_rules/rule_ids.rb +60 -1
  22. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  23. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +13 -4
  24. data/lib/rigor/analysis/check_rules.rb +274 -51
  25. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  26. data/lib/rigor/analysis/incremental.rb +61 -0
  27. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  28. data/lib/rigor/analysis/incremental_session.rb +270 -28
  29. data/lib/rigor/analysis/path_expansion.rb +3 -1
  30. data/lib/rigor/analysis/reachability/graph.rb +111 -14
  31. data/lib/rigor/analysis/reachability/scan.rb +305 -23
  32. data/lib/rigor/analysis/rule_catalog.rb +22 -4
  33. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  34. data/lib/rigor/analysis/run_cache_probe.rb +5 -3
  35. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +48 -12
  36. data/lib/rigor/analysis/runner/pool_coordinator.rb +179 -31
  37. data/lib/rigor/analysis/runner/project_pre_passes.rb +29 -4
  38. data/lib/rigor/analysis/runner.rb +102 -72
  39. data/lib/rigor/analysis/severity_stamp.rb +45 -1
  40. data/lib/rigor/analysis/worker_session.rb +69 -2
  41. data/lib/rigor/cache/descriptor.rb +79 -12
  42. data/lib/rigor/cache/engine_source.rb +3 -2
  43. data/lib/rigor/cache/incremental_snapshot.rb +39 -5
  44. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  45. data/lib/rigor/cache/store.rb +4 -3
  46. data/lib/rigor/cli/command.rb +3 -1
  47. data/lib/rigor/cli/docs_command.rb +3 -2
  48. data/lib/rigor/cli/doctor_command.rb +1 -1
  49. data/lib/rigor/cli/explain_command.rb +3 -2
  50. data/lib/rigor/cli/mcp_command.rb +1 -1
  51. data/lib/rigor/cli/plugin_command.rb +2 -1
  52. data/lib/rigor/cli/plugins_command.rb +2 -1
  53. data/lib/rigor/cli/sig_gen_command.rb +32 -11
  54. data/lib/rigor/cli/skill_command.rb +2 -1
  55. data/lib/rigor/cli/skill_describe.rb +3 -2
  56. data/lib/rigor/cli/unused_command.rb +10 -7
  57. data/lib/rigor/cli.rb +3 -2
  58. data/lib/rigor/config_audit.rb +163 -22
  59. data/lib/rigor/configuration/dependencies.rb +19 -11
  60. data/lib/rigor/configuration/severity_profile.rb +20 -2
  61. data/lib/rigor/configuration.rb +29 -3
  62. data/lib/rigor/effects/signature_sources.rb +3 -1
  63. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  64. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  65. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  66. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  67. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  68. data/lib/rigor/environment/rbs_loader.rb +67 -11
  69. data/lib/rigor/inference/block_call_timing.rb +12 -1
  70. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  71. data/lib/rigor/inference/captured_locals.rb +67 -13
  72. data/lib/rigor/inference/clause_widening.rb +90 -0
  73. data/lib/rigor/inference/definer_resolution.rb +101 -25
  74. data/lib/rigor/inference/expression_typer.rb +94 -9
  75. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  76. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  77. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  78. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  79. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +170 -35
  80. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  81. data/lib/rigor/inference/method_dispatcher.rb +67 -3
  82. data/lib/rigor/inference/module_function_state.rb +213 -51
  83. data/lib/rigor/inference/narrowing.rb +235 -75
  84. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  85. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  86. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  87. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  88. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  89. data/lib/rigor/inference/project_seed_tables.rb +120 -0
  90. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  91. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  92. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  93. data/lib/rigor/inference/repeated_or_writes.rb +414 -1
  94. data/lib/rigor/inference/return_barrier.rb +16 -0
  95. data/lib/rigor/inference/scope_indexer.rb +1061 -122
  96. data/lib/rigor/inference/self_call_ivar_writes.rb +346 -0
  97. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  98. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  99. data/lib/rigor/inference/statement_evaluator.rb +512 -150
  100. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  101. data/lib/rigor/lens/anchor.rb +3 -3
  102. data/lib/rigor/lens/contract_reader.rb +60 -2
  103. data/lib/rigor/lens/document.rb +4 -3
  104. data/lib/rigor/lens/file_reader.rb +452 -73
  105. data/lib/rigor/lens/members.rb +81 -17
  106. data/lib/rigor/lens/run.rb +73 -4
  107. data/lib/rigor/lens.rb +2 -1
  108. data/lib/rigor/mcp/server.rb +63 -22
  109. data/lib/rigor/plugin/base.rb +3 -2
  110. data/lib/rigor/plugin/box_probe.rb +154 -7
  111. data/lib/rigor/plugin/io_boundary.rb +42 -6
  112. data/lib/rigor/plugin/isolation.rb +4 -3
  113. data/lib/rigor/project_environment.rb +6 -3
  114. data/lib/rigor/protection/discovery_seed.rb +9 -36
  115. data/lib/rigor/protection/mutation_cache.rb +8 -2
  116. data/lib/rigor/rooted_glob.rb +27 -0
  117. data/lib/rigor/scope/discovery_index.rb +5 -0
  118. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  119. data/lib/rigor/scope/resolution_chain.rb +40 -5
  120. data/lib/rigor/scope.rb +56 -8
  121. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  122. data/lib/rigor/sig_gen/classification.rb +4 -1
  123. data/lib/rigor/sig_gen/generator.rb +167 -22
  124. data/lib/rigor/sig_gen/layout_index.rb +40 -1
  125. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  126. data/lib/rigor/sig_gen/observation_collector.rb +137 -11
  127. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  128. data/lib/rigor/sig_gen/renderer.rb +13 -155
  129. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  130. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  131. data/lib/rigor/sig_gen/write_result.rb +3 -1
  132. data/lib/rigor/sig_gen/writer.rb +50 -35
  133. data/lib/rigor/signature_path_audit.rb +4 -2
  134. data/lib/rigor/type/constant.rb +10 -1
  135. data/lib/rigor/version.rb +1 -1
  136. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  137. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +1 -1
  138. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  139. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  140. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  141. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  142. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  143. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  144. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  145. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  146. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  147. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  148. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +1 -1
  149. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  150. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  151. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +57 -6
  152. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  153. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  154. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  155. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  156. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  157. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  158. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  159. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  160. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  161. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  162. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  163. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  164. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  165. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  166. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  167. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  168. data/sig/rigor/rooted_glob.rbs +7 -0
  169. data/sig/rigor/scope.rbs +4 -3
  170. data/skills/rigor-doctor/references/01-checks.md +23 -11
  171. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  172. metadata +16 -1
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../source/literals"
6
+ require_relative "../source/node_children"
7
+ require_relative "module_function_state"
8
+ require_relative "project_patched_mixins"
9
+
10
+ module Rigor
11
+ module Inference
12
+ module ProjectPatchedScanner
13
+ # Issue #1708 — the visibility a `pre_eval:` file gives each `def` it writes in a class, module or `class <<`
14
+ # body, so a private patch does not answer a call with an explicit receiver. Ruby 4.0.5:
15
+ # `module Kernel; private def kp = 1; end; 1.kp` raises NoMethodError (private method 'kp' called for an
16
+ # instance of Integer), while a bare `kp` runs.
17
+ #
18
+ # The readings, over each body's direct statements in order:
19
+ #
20
+ # - a bare `private`, `protected`, `public` or `module_function` (receiverless or on `self`) sets the default
21
+ # for the `def`s after it in that body;
22
+ # - `private def x`, `protected def x`, `public def x`, `module_function def x` set that `def`'s, and
23
+ # `private_class_method def self.x` / `public_class_method def self.x` a singleton `def`'s;
24
+ # - a `def self.x` is public whatever the default says, as in Ruby;
25
+ # - a `def` nested in a statement (an `if`, a `begin`) takes the default, unless the statement holds a bare
26
+ # call of its own, which may or may not run: then it and every `def` after it are public, the reading that
27
+ # answers more calls.
28
+ #
29
+ # A `def` this does not reach (one in a block, a `rescue` clause, or a body nested in a conditional, a block or a
30
+ # method, which may not run) is public. A named call with Symbol arguments (`private :x`) is the scanner's to
31
+ # apply, since it may name a `def` of an earlier file ({.pending}).
32
+ module Visibility
33
+ # The receiverless calls that set an instance `def`'s visibility, and what they set. `module_function` makes
34
+ # the instance method private and adds a public singleton copy.
35
+ INSTANCE_CALLS = {
36
+ private: :private, protected: :protected, public: :public, module_function: :module_function
37
+ }.freeze
38
+ # The calls that set a singleton `def`'s visibility.
39
+ SINGLETON_CALLS = { private_class_method: :private, public_class_method: :public }.freeze
40
+
41
+ # The nodes a nested `def` does not take the body's default under: another body, or a block.
42
+ OPAQUE = [
43
+ Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode, Prism::DefNode, Prism::BlockNode,
44
+ Prism::LambdaNode
45
+ ].freeze
46
+ private_constant :OPAQUE
47
+
48
+ # The bodies {.read_body} reads when they are a direct statement of a body it reads.
49
+ BODIES = [Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
50
+ private_constant :BODIES
51
+
52
+ # A named visibility call (`private :x`, `module_function :x`, `private_class_method :x`) in a body, which
53
+ # {ProjectPatchedScanner.resolve_aliases} applies to the binding its name has when it runs. `visibility` is
54
+ # `:private`, `:protected`, `:public` or `:module_function`.
55
+ Pending = Data.define(:class_name, :names, :kind, :visibility, :source_path, :source_line)
56
+
57
+ module_function
58
+
59
+ # The {Pending} change a named visibility call in a body makes, or nil. The `def`-argument form
60
+ # (`private def x`) is {.defs}'s; a computed name is skipped, leaving the method public.
61
+ def pending(node, qualified_prefix, in_singleton_class, source_path)
62
+ return nil if qualified_prefix.empty?
63
+
64
+ visibility, side = call(node)
65
+ return nil if visibility.nil? || node.arguments.nil?
66
+
67
+ names = node.arguments.arguments.filter_map { |arg| Source::Literals.symbol_or_string(arg) }
68
+ return nil if names.empty?
69
+
70
+ Pending.new(
71
+ class_name: qualified_prefix.join("::"), names: names,
72
+ kind: side == :singleton || in_singleton_class ? :singleton : :instance, visibility: visibility,
73
+ source_path: source_path, source_line: node.location&.start_line || 1
74
+ )
75
+ end
76
+
77
+ # The value {.defs} records for a call written as a direct statement of a body or of the file's top level, the
78
+ # only calls whose named visibility change ({.pending}) or mixin edge ({Mixins.pending}) the scanner applies to
79
+ # the body's class. A call nested in a conditional, a block, a method body or a `Class.new` may not run, or
80
+ # runs on another class, so it changes nothing: the reading that answers more calls.
81
+ STATEMENT = Object.new.freeze
82
+
83
+ # @return the visibility of each `def` (by identity) the file's bodies give one other than public:
84
+ # `:private`, `:protected` or `:module_function`; and {STATEMENT} for each call that is a direct statement,
85
+ # and for the include an idempotence guard runs ({Mixins.guarded}).
86
+ def defs(root)
87
+ found = {}.compare_by_identity
88
+ # The file's top level, and through {.read_body} every class, module or `class <<` body written as a direct
89
+ # statement of it or of another body so read: the bodies that run whenever the file loads. A body nested in
90
+ # a conditional, a block or a method may not run, so none of its calls or toggles is read and its `def`s
91
+ # are public.
92
+ read_body(root.statements, found, top_level: true) if root.is_a?(Prism::ProgramNode)
93
+ found
94
+ end
95
+
96
+ # Applies a {Pending} change to the binding each of its names has: `bound` maps `[class_name, name, kind]` to
97
+ # that binding and `homes` to the per-file table holding it, which is updated in place, so the change is not a
98
+ # second declaration. A name the class does not bind is one it inherits (`public :kp` in `class Integer` over
99
+ # `module Kernel; private def kp`), which Ruby copies onto the class with the new visibility: `overrides` gets
100
+ # `key => visibility` for {ProjectPatchedMethods#lookup_through_ancestors} to apply to the body it finds farther
101
+ # on. Yields each copy that binds (`module_function :x`'s singleton), at the call's line, for the caller to
102
+ # bind.
103
+ def apply(pending, bound, homes, overrides)
104
+ pending.names.each do |name|
105
+ key = [pending.class_name, name, pending.kind]
106
+ entry = bound[key]
107
+ next overrides[key] = pending.visibility if entry.nil?
108
+
109
+ changed, *copies = applied(entry, pending.visibility)
110
+ bound[key] = homes[key][key] = changed
111
+ copies.each { |copy| yield copy.with(source_path: pending.source_path, source_line: pending.source_line) }
112
+ end
113
+ end
114
+
115
+ # `entry` as `visibility` leaves it, then any copy that binds: `module_function` makes an instance method
116
+ # private and binds a public singleton copy, as Ruby does.
117
+ def applied(entry, visibility)
118
+ return [entry.with(visibility: visibility)] unless visibility == :module_function
119
+
120
+ copies = [entry.with(visibility: :private)]
121
+ copies << entry.with(kind: :singleton, visibility: :public) if entry.kind == :instance
122
+ copies
123
+ end
124
+
125
+ # The visibility call `node` is, as `[visibility, side]`, or nil: `side` is `:instance` for the
126
+ # {INSTANCE_CALLS} and `:singleton` for the {SINGLETON_CALLS}.
127
+ def call(node)
128
+ return nil unless node.is_a?(Prism::CallNode)
129
+ return nil unless node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
130
+
131
+ if (visibility = INSTANCE_CALLS[node.name])
132
+ [visibility, :instance]
133
+ elsif (visibility = SINGLETON_CALLS[node.name])
134
+ [visibility, :singleton]
135
+ end
136
+ end
137
+
138
+ # The include an idempotence guard runs (`X.include(M) unless X.include?(M)`) is read as a direct statement.
139
+ def mark_guarded(stmt, found, top_level)
140
+ guarded = Mixins.guarded(stmt, top_level)
141
+ found[guarded] = STATEMENT if guarded
142
+ end
143
+ private_class_method :mark_guarded
144
+
145
+ # `top_level` is true for the file's own statements only; a body it reads is a class or module body.
146
+ def read_body(body, found, top_level: false) # rubocop:disable Metrics/PerceivedComplexity
147
+ default = :public
148
+ direct_statements(body).each do |stmt|
149
+ found[stmt] = STATEMENT if stmt.is_a?(Prism::CallNode)
150
+ visibility, side = call(stmt)
151
+ if visibility && ModuleFunctionState.bare?(stmt)
152
+ default = visibility if side == :instance
153
+ elsif visibility
154
+ mark_arguments(stmt, visibility, side, found)
155
+ elsif stmt.is_a?(Prism::DefNode)
156
+ mark(stmt, default, found) if stmt.receiver.nil?
157
+ elsif BODIES.any? { |kind| stmt.is_a?(kind) }
158
+ read_body(stmt.body, found)
159
+ else
160
+ mark_guarded(stmt, found, top_level)
161
+ default = :public if bare_call_nested?(stmt)
162
+ mark_nested(stmt, default, found)
163
+ end
164
+ end
165
+ end
166
+ private_class_method :read_body
167
+
168
+ def direct_statements(body)
169
+ body = body.statements if body.is_a?(Prism::BeginNode)
170
+ body.is_a?(Prism::StatementsNode) ? body.body : []
171
+ end
172
+ private_class_method :direct_statements
173
+
174
+ def mark_arguments(call, visibility, side, found)
175
+ call.arguments&.arguments&.each do |arg|
176
+ next unless arg.is_a?(Prism::DefNode)
177
+ next unless (side == :singleton) == arg.receiver.is_a?(Prism::SelfNode)
178
+
179
+ mark(arg, visibility, found)
180
+ end
181
+ end
182
+ private_class_method :mark_arguments
183
+
184
+ def mark(def_node, visibility, found)
185
+ found[def_node] = visibility unless visibility == :public
186
+ end
187
+ private_class_method :mark
188
+
189
+ def mark_nested(node, default, found)
190
+ return unless node.is_a?(Prism::Node)
191
+ return if OPAQUE.any? { |kind| node.is_a?(kind) } && !node.is_a?(Prism::DefNode)
192
+
193
+ if node.is_a?(Prism::DefNode)
194
+ mark(node, default, found) if node.receiver.nil?
195
+ return
196
+ end
197
+ node.rigor_each_child { |child| mark_nested(child, default, found) }
198
+ end
199
+ private_class_method :mark_nested
200
+
201
+ def bare_call_nested?(node)
202
+ return false unless node.is_a?(Prism::Node)
203
+ return false if OPAQUE.any? { |kind| node.is_a?(kind) }
204
+
205
+ visibility, = call(node)
206
+ return true if visibility && ModuleFunctionState.bare?(node)
207
+
208
+ node.compact_child_nodes.any? { |child| bare_call_nested?(child) }
209
+ end
210
+ private_class_method :bare_call_nested?
211
+ end
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "scope_indexer"
4
+
5
+ module Rigor
6
+ module Inference
7
+ # Issue #1614 — the cross-file discovery seed the paths that type a project WITHOUT a `check` run hand their
8
+ # per-file scopes: {Protection::DiscoverySeed} (the protection scan, `rigor coverage`) and
9
+ # {ParameterInferenceCollector} (the ADR-67 call-site pre-pass, which `check` itself runs under
10
+ # `parameter_inference:`). Each used to keep its own list of tables, and each dropped some that
11
+ # `Analysis::Runner#project_scope_seed_tables` carries, so a cross-file constant read `30` under `check` and
12
+ # `Dynamic` under coverage, and a parameter whose call sites pass it went uninferred.
13
+ #
14
+ # This module is the one list both paths read. {Analysis::Runner#project_scope_seed_tables} stays the
15
+ # reference; `spec/rigor/inference/project_seed_tables_spec.rb` compares the two over a project that fills
16
+ # every table, and requires each table the runner seeds and this one does not to be in {NOT_SEEDED}, with its
17
+ # reason.
18
+ module ProjectSeedTables
19
+ module_function
20
+
21
+ # The def-index slots that seed a per-file scope as they are, each with the {Scope::DiscoveryIndex} member it
22
+ # fills.
23
+ SLOTS = {
24
+ def_nodes: :discovered_def_nodes, def_nestings: :discovered_def_nestings,
25
+ singleton_def_nodes: :discovered_singleton_def_nodes, def_sources: :discovered_def_sources,
26
+ singleton_def_sources: :discovered_singleton_def_sources, superclasses: :discovered_superclasses,
27
+ header_nestings: :discovered_header_nestings, includes: :discovered_includes,
28
+ prepends: :discovered_prepends, extends: :discovered_extends, unpositioned_mixins: :unpositioned_mixins,
29
+ class_sources: :discovered_class_sources, method_visibilities: :discovered_method_visibilities,
30
+ methods: :discovered_methods, parameter_envelopes: :discovered_parameter_envelopes,
31
+ data_member_layouts: :data_member_layouts, struct_member_layouts: :struct_member_layouts,
32
+ deferred_ranges: :discovered_deferred_ranges, refinements: :discovered_refinements,
33
+ global_write_census: :discovered_global_write_census
34
+ }.freeze
35
+
36
+ # The tables `Runner#project_scope_seed_tables` can seed that {.from_index} leaves out, and why. Parity is over
37
+ # the table LIST: the runner's opt-in additions to a table's content are not reproduced here.
38
+ # `discovered_refinements` is the project's alone (the runner unions in the refinements of gems under
39
+ # `dependencies.source_inference:`), and no `discovery_seed:` base is merged in (the runner can start from one).
40
+ NOT_SEEDED = {
41
+ run_generation: "the identity token of a check run's return memo; these paths run no check",
42
+ constant_sources: "read only by dependency recording, which only an incremental check run turns on",
43
+ param_inferred_types: "the parameter-inference output itself; each caller adds its own " \
44
+ "(DiscoverySeed.build, coverage under parameter_inference:, the collector's last round)"
45
+ }.freeze
46
+
47
+ # The seed tables over `paths`, from one parse of each file.
48
+ def for_paths(paths)
49
+ from_index(ScopeIndexer.discovered_project_index_for_paths(paths))
50
+ end
51
+
52
+ # Maps a `{ classes:, def_index: }` discovery index onto {Scope::DiscoveryIndex} member names. Empty tables are
53
+ # dropped so an empty seed stays `{}`; a paired member and its sibling are dropped only together.
54
+ #
55
+ # `in_source_constants` is the project's literal constant table alone: the `pre_eval:` publication a `check` run
56
+ # joins to it types each listed file's writes against the RBS environment, which these paths do not build first.
57
+ def from_index(index)
58
+ def_index = index.fetch(:def_index)
59
+ tables = { discovered_classes: index.fetch(:classes) }
60
+ SLOTS.each { |slot, member| tables[member] = def_index.fetch(slot) }
61
+ tables.merge!(constant_tables(def_index.fetch(:constant_values), def_index.fetch(:constant_writes)))
62
+ tables.merge!(def_index.fetch(:siblings))
63
+ Scope::DiscoveryIndex.compact_pairs(
64
+ tables.reject { |name, table| !Scope::DiscoveryIndex.paired?(name) && (table.nil? || table.empty?) }
65
+ )
66
+ end
67
+
68
+ def constant_tables(values, writes)
69
+ {
70
+ in_source_constants: values,
71
+ published_constant_names: published_constant_names(values),
72
+ constant_writers: constant_writers(writes),
73
+ constant_shadowers: constant_shadowers(writes)
74
+ }
75
+ end
76
+
77
+ # Issue #644 — the last segments of the published constant table, the run-wide half of
78
+ # `Scope#published_constant?`.
79
+ def published_constant_names(values)
80
+ values.keys.to_set { |name| name.split("::").last }.freeze
81
+ end
82
+
83
+ # Issue #617 — the censused names that bind, grouped by last segment (`Scope#bound_constant_names`). A name some
84
+ # write other than a memo `||=` touches always binds. A memo-only name binds once memos of its segment span two
85
+ # files: either may load first and set the constant the other then reads, so only a memo no other file shares
86
+ # keeps the idiom's reading. Files are counted expanded, because discovery can walk one file under two
87
+ # spellings (`lib/b.rb`, `./lib/b.rb`).
88
+ def constant_writers(writes)
89
+ memo_files = memo_files_by_segment(writes)
90
+ writes.each_with_object({}) do |(name, by_path), index|
91
+ segment = name.split("::").last
92
+ next if memo_only?(by_path) && memo_files.fetch(segment).size < 2
93
+
94
+ (index[segment] ||= []) << name
95
+ end.each_value(&:freeze).freeze
96
+ end
97
+
98
+ # Issue #1290 — the censused names some write other than a memo `||=` assigns, grouped by last segment
99
+ # (`Scope#shadowing_constant_names`): the ones that exist whether or not an outer constant of the name does, and
100
+ # so stop the lexical ladder above it. A memo-only name is left out however many files memoize it, unlike in
101
+ # {.constant_writers}: `X ||= v` resolves `X` through the very lookup whose lower rung the stop would replace,
102
+ # so wherever that rung answers, the memo finds it and never creates the name.
103
+ def constant_shadowers(writes)
104
+ writes.each_with_object({}) do |(name, by_path), index|
105
+ (index[name.split("::").last] ||= []) << name unless memo_only?(by_path)
106
+ end.each_value(&:freeze).freeze
107
+ end
108
+
109
+ def memo_files_by_segment(writes)
110
+ writes.each_with_object({}) do |(name, by_path), files|
111
+ set = (files[name.split("::").last] ||= Set.new)
112
+ by_path.each { |path, descriptor| set << File.absolute_path(path) if memo_descriptor?(descriptor) }
113
+ end
114
+ end
115
+
116
+ def memo_only?(by_path) = by_path.each_value.all? { |descriptor| memo_descriptor?(descriptor) }
117
+ def memo_descriptor?(descriptor) = descriptor == ScopeIndexer::CONSTANT_MEMO
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Inference
5
+ module RefinedDispatch
6
+ # Issue #1979 — whether a class whose superclass the tables do not know is `class K < <expr>` with `<expr>` a
7
+ # core constructor call (`Data.define(...)`, `Struct.new(...)`, `Class.new(<constant>)`) that cannot answer a
8
+ # given class or one of its subclasses, so a subclass walk may leave it out (`OwnMethod.each_subclass` with
9
+ # `possible_only:`).
10
+ #
11
+ # Each constant the expression names (the indexer's `[DiscoveryIndex::ENVELOPE_CONSTRUCTED_KIND, name]` entries)
12
+ # must be a class the environment knows that is neither the given class nor a subclass of it, and the project
13
+ # must not touch the name: no project class or module whose last segment is the name (Ruby resolves a constant
14
+ # through the enclosing classes' ancestors and mixins too, so a `Base::StandardError` a class inherits may be
15
+ # what `Class.new(StandardError)` names), no reopening, singleton `def`, refinement or method table keyed by
16
+ # the name itself, and no constant write of it. Any other shape answers false, as does a class a
17
+ # `K = Class.new(...)` write names.
18
+ module ConstructedClass
19
+ MEMO_KEY = :__rigor_constructed_class__
20
+ EMPTY = [].freeze
21
+ private_constant :MEMO_KEY, :EMPTY
22
+
23
+ module_function
24
+
25
+ # True when `name` cannot be `class_name` or a subclass of it. Files `name`'s class edge and a `constant:`
26
+ # edge per named constant on every call; a class declaration appearing fires the walk's `subclass_of:*`.
27
+ def elsewhere?(scope, name, class_name)
28
+ table = memo(scope)
29
+ named = constructed_names(scope, name, table)
30
+ if Analysis::DependencyRecorder.active?
31
+ Scope::ResolutionChain.record_class(scope, name)
32
+ named.each { |constant| Analysis::DependencyRecorder.read_name(:constant, constant) }
33
+ end
34
+ return false if named.empty?
35
+
36
+ by_class = (table[:verdicts][class_name] ||= {})
37
+ return by_class[named] if by_class.key?(named)
38
+
39
+ by_class[named] = named.all? { |constant| core_class_apart?(scope, constant, class_name.to_s, table) }
40
+ end
41
+
42
+ def core_class_apart?(scope, constant, class_name, table)
43
+ environment = scope.environment
44
+ return false unless environment&.class_known?(constant)
45
+ return false if project_name?(scope, constant, table)
46
+ return false unless scope.shadowing_constant_names(constant).empty?
47
+
48
+ ordering = environment.class_ordering(constant, class_name)
49
+ %i[disjoint superclass].include?(ordering) ||
50
+ (ordering == :unknown && !environment.class_known?(class_name))
51
+ end
52
+
53
+ # Whether the project touches `constant` by name: a declared or reopened class or module whose last segment
54
+ # it is, or a top-level table keyed by it (a singleton `def Data.define`, methods, envelope entries or marks,
55
+ # mixins, a refinement).
56
+ def project_name?(scope, constant, table)
57
+ return true if declared_segments(scope, table).include?(constant)
58
+
59
+ discovery = scope.discovery
60
+ [discovery.discovered_classes, discovery.discovered_superclasses, discovery.discovered_includes,
61
+ discovery.discovered_prepends, discovery.discovered_extends, discovery.discovered_methods,
62
+ discovery.discovered_parameter_envelopes, discovery.discovered_refinements].any? do |by_name|
63
+ by_name.key?(constant)
64
+ end
65
+ end
66
+
67
+ # The last segment of every class or module the project declares, reopenings included.
68
+ def declared_segments(scope, table)
69
+ table[:segments] ||= declared_names(scope).each_key.to_set { |key| key.to_s.split("::").last }.freeze
70
+ end
71
+
72
+ # The constants `name`'s superclass expression names, or an empty Array; memoised per envelope bucket,
73
+ # which a file's own discovery shares with the run's seed for every class it does not declare.
74
+ def constructed_names(scope, name, table)
75
+ bucket = scope.discovered_parameter_envelopes[name]
76
+ return EMPTY if bucket.nil?
77
+
78
+ names = table[:names]
79
+ return names[bucket] if names.key?(bucket)
80
+
81
+ names[bucket] =
82
+ if bucket.key?(Scope::DiscoveryIndex::ENVELOPE_META_SUPERCLASS_MARK)
83
+ EMPTY
84
+ else
85
+ kind = Scope::DiscoveryIndex::ENVELOPE_CONSTRUCTED_KIND
86
+ bucket.each_key.filter_map { |key| key[1] if key.is_a?(Array) && key[0] == kind }.freeze
87
+ end
88
+ end
89
+
90
+ # The run-wide table of declared classes: the seeded `discovered_class_sources`, or a single-file run's
91
+ # `discovered_classes` where nothing seeded it.
92
+ def declared_names(scope)
93
+ sources = scope.discovery.discovered_class_sources
94
+ sources.empty? ? scope.discovery.discovered_classes : sources
95
+ end
96
+
97
+ # One thread-local slot keyed on the run-generation token the runner seeds (or, unseeded, the file's merged
98
+ # `discovered_def_nodes`, as `ExpressionTyper` falls back) and the environment: every table read here is
99
+ # fixed for a run, so each answer is computed once a run, and a new run replaces the slot.
100
+ def memo(scope)
101
+ slot = Thread.current[MEMO_KEY]
102
+ generation = scope.run_generation || scope.discovered_def_nodes
103
+ environment = scope.environment
104
+ unless slot && slot[0].equal?(generation) && slot[1].equal?(environment)
105
+ slot = [generation, environment, { names: {}.compare_by_identity, verdicts: {} }]
106
+ Thread.current[MEMO_KEY] = slot
107
+ end
108
+ slot[2]
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../builtins/cruby_definers"
4
+ require_relative "constructed_class"
4
5
 
5
6
  module Rigor
6
7
  module Inference
@@ -193,8 +194,7 @@ module Rigor
193
194
  # A module some code `prepend`s to an object's singleton class (or `extend`s an object with in a method
194
195
  # body), which puts it ahead of the object's class even where the class already includes it.
195
196
  def object_prepended?(scope, name)
196
- scope.discovered_parameter_envelopes[name]&.key?(Scope::DiscoveryIndex::ENVELOPE_OBJECT_EXTENDED_MARK) ||
197
- false
197
+ scope.parameter_envelopes_of(name).key?(Scope::DiscoveryIndex::ENVELOPE_OBJECT_EXTENDED_MARK)
198
198
  end
199
199
 
200
200
  # Is some in-effect row under a class no table declares by any spelling of it (the keys sharing its last
@@ -282,10 +282,16 @@ module Rigor
282
282
  # declaration with that superclass appearing or vanishing fires (`IncrementalSession#negative_affected`).
283
283
  # A class whose superclass the tables do not know may be a subclass of any class, so it joins the walk too
284
284
  # (`subclass_of:*`, which any class declaration appearing or vanishing fires).
285
- def each_subclass(scope, class_name)
285
+ #
286
+ # Issue #1979 — with `possible_only:`, a class whose superclass expression is a core constructor call that
287
+ # cannot answer `class_name` or a subclass of it (`ConstructedClass.elsewhere?`) is left out, with its
288
+ # subclasses.
289
+ def each_subclass(scope, class_name, possible_only: false)
286
290
  children = children_by_parent(scope)
287
291
  record_subclasses_read(class_name)
288
- queue = children.fetch(class_name.to_s, []) + unknown_superclass_classes(scope, class_name)
292
+ unknown = unknown_superclass_classes(scope, class_name)
293
+ unknown = unknown.reject { |name| ConstructedClass.elsewhere?(scope, name, class_name) } if possible_only
294
+ queue = children.fetch(class_name.to_s, []) + unknown
289
295
  seen = {}
290
296
  until queue.empty?
291
297
  subclass = queue.shift
@@ -300,10 +306,32 @@ module Rigor
300
306
 
301
307
  def unknown_superclass_classes(scope, class_name)
302
308
  Analysis::DependencyRecorder.read_name(:subclass_of, "*") if Analysis::DependencyRecorder.active?
303
- mark = Scope::DiscoveryIndex::ENVELOPE_META_SUPERCLASS_MARK
304
- computed = scope.discovered_superclasses.filter_map { |child, parent| child if parent.nil? }
305
- meta = scope.discovered_parameter_envelopes.filter_map { |name, bucket| name if bucket.key?(mark) }
306
- (computed + meta).uniq - [class_name.to_s]
309
+ table = subclass_memo(scope)
310
+ all = table[:unknown] ||= begin
311
+ mark = Scope::DiscoveryIndex::ENVELOPE_META_SUPERCLASS_MARK
312
+ computed = scope.discovered_superclasses.filter_map { |child, parent| child if parent.nil? }
313
+ meta = scope.discovered_parameter_envelopes.filter_map { |name, bucket| name if bucket.key?(mark) }
314
+ (computed + meta).uniq.freeze
315
+ end
316
+ name = class_name.to_s
317
+ all.include?(name) ? all - [name] : all
318
+ end
319
+
320
+ SUBCLASS_MEMO_KEY = :__rigor_own_method_subclasses__
321
+ private_constant :SUBCLASS_MEMO_KEY
322
+
323
+ # One thread-local slot keyed on the scope's discovery index (a frozen `Data`) and environment, the only
324
+ # inputs `children_by_parent` and the unknown-superclass list read; neither files a dependency edge
325
+ # (`each_subclass` files its own `subclass_of:` edges on every call), so a hit drops none.
326
+ def subclass_memo(scope)
327
+ slot = Thread.current[SUBCLASS_MEMO_KEY]
328
+ discovery = scope.discovery
329
+ environment = scope.environment
330
+ unless slot && slot[0].equal?(discovery) && slot[1].equal?(environment)
331
+ slot = [discovery, environment, {}]
332
+ Thread.current[SUBCLASS_MEMO_KEY] = slot
333
+ end
334
+ slot[2]
307
335
  end
308
336
 
309
337
  def record_subclasses_read(class_name)
@@ -313,10 +341,11 @@ module Rigor
313
341
  end
314
342
 
315
343
  def children_by_parent(scope)
316
- scope.discovered_superclasses.each_with_object({}) do |(child, raw_parent), children|
317
- parent = scope.ancestor_name_candidates(child, raw_parent).find { |name| known_class?(scope, name) }
318
- (children[parent] ||= []) << child if parent
319
- end
344
+ subclass_memo(scope)[:children] ||=
345
+ scope.discovered_superclasses.each_with_object({}) do |(child, raw_parent), children|
346
+ parent = scope.ancestor_name_candidates(child, raw_parent).find { |name| known_class?(scope, name) }
347
+ (children[parent] ||= []) << child if parent
348
+ end.each_value(&:freeze).freeze
320
349
  end
321
350
 
322
351
  def known_class?(scope, name)
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../type"
6
+
7
+ module Rigor
8
+ module Inference
9
+ # Issue #1889 — `instance_variable_set` called on `self` writes an instance variable that no write node shows.
10
+ # Left unseen, the binding the call site holds outlived it: `@n = 0; instance_variable_set(:@n, 5)` kept `@n`
11
+ # on `0`, and a block that wrote `@n` only this way never entered the captured-binding name set, so
12
+ # `[1, 2].inject(0) { |a, e| instance_variable_set(:@n, @n + 1); a + @n }` folded to a constant.
13
+ #
14
+ # A call counts when its receiver is implicit or `self` and it passes any argument ({.self_write?}). With
15
+ # exactly two plain positional arguments, the first a Symbol or String literal naming an instance variable
16
+ # (`:@n`, `"@n"`; Ruby accepts both), it is a write of that variable ({.literal_name}). Any other argument list
17
+ # — a computed name, a splat (`instance_variable_set(*args)`), a keyword hash, or a literal Ruby rejects with
18
+ # `NameError` (`:n`) — names an ivar the analysis cannot see, so every ivar the scope binds may have been
19
+ # written ({.apply}; for a rejected literal this over-approximates, which only widens).
20
+ # `obj.instance_variable_set` writes another object's ivar and does not count; neither does the reflective
21
+ # `send(:instance_variable_set, …)`.
22
+ module ReflectiveIvarWrite
23
+ METHOD_NAME = :instance_variable_set
24
+ private_constant :METHOD_NAME
25
+
26
+ IVAR_NAME = /\A@[A-Za-z_]\w*\z/
27
+ private_constant :IVAR_NAME
28
+
29
+ REPEATING_KEY = :__rigor_reflective_ivar_write_repeating__
30
+ private_constant :REPEATING_KEY
31
+
32
+ module_function
33
+
34
+ # True when `node` is an `instance_variable_set` on `self` with at least one argument.
35
+ def self_write?(node)
36
+ return false unless node.is_a?(Prism::CallNode) && node.name == METHOD_NAME
37
+ return false unless node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
38
+
39
+ arguments = node.arguments&.arguments
40
+ !arguments.nil? && !arguments.empty?
41
+ end
42
+
43
+ # `:@n` for `instance_variable_set(:@n, v)` or `instance_variable_set("@n", v)` on `self`, else nil.
44
+ def literal_name(node)
45
+ return nil unless self_write?(node)
46
+
47
+ arguments = node.arguments.arguments
48
+ return nil unless arguments.size == 2 && arguments.none? { |arg| splat_or_keywords?(arg) }
49
+
50
+ name = arguments.first
51
+ text =
52
+ case name
53
+ when Prism::SymbolNode, Prism::StringNode then name.unescaped
54
+ end
55
+ text && IVAR_NAME.match?(text) ? text.to_sym : nil
56
+ end
57
+
58
+ # True for a call {.self_write?} takes whose target {.literal_name} cannot name: it may write any ivar.
59
+ def unknown_target?(node)
60
+ self_write?(node) && literal_name(node).nil?
61
+ end
62
+
63
+ # The value argument of a call {.literal_name} names.
64
+ def value_node(node)
65
+ node.arguments.arguments[1]
66
+ end
67
+
68
+ # Runs the block with the evaluator inside a `for` body or a `while` / `until` predicate. A `for` loop has no
69
+ # rebind fixpoint, and a predicate runs before every iteration but is walked once, so a literal-name write there
70
+ # must not replace the binding with one pass's value ({.apply}). A `while` / `until` body does not enter it: its
71
+ # fixpoint widens the ivar (issue #1930).
72
+ def in_repeating_body
73
+ depth = Thread.current[REPEATING_KEY] || 0
74
+ Thread.current[REPEATING_KEY] = depth + 1
75
+ begin
76
+ yield
77
+ ensure
78
+ Thread.current[REPEATING_KEY] = depth
79
+ end
80
+ end
81
+
82
+ def repeating_body?
83
+ (Thread.current[REPEATING_KEY] || 0).positive?
84
+ end
85
+
86
+ # `scope` after `node` runs. A literal name binds that ivar to the value's type (yielded), as `@n = v` does.
87
+ # Inside a `for` body or a loop predicate ({.in_repeating_body}) the value and `Dynamic[top]` are joined into the
88
+ # current binding instead: nothing widens the single pass's value across iterations there. Any other call joins
89
+ # `Dynamic[top]` into every ivar `scope` binds: any one of them may now hold the value, and joining the
90
+ # value's own type would report a method the old value has and the new one lacks. An ivar on its ADR-58
91
+ # declaration seed keeps its mark, which stops a nil in the seed from fuelling a nil-receiver diagnostic.
92
+ def apply(node, scope)
93
+ return scope unless self_write?(node)
94
+
95
+ name = literal_name(node)
96
+ return bind_literal(scope, name, yield) if name
97
+
98
+ floor = Type::Combinator.untyped
99
+ scope.ivars.reduce(scope) do |acc, (ivar, type)|
100
+ widened = Type::Combinator.union(type, floor)
101
+ rebound = acc.with_ivar(ivar, widened).without_ivar_guard(ivar)
102
+ scope.declaration_sourced?(:ivar, ivar) ? rebound.seed_declaration_sourced_ivar(ivar, widened) : rebound
103
+ end
104
+ end
105
+
106
+ def bind_literal(scope, name, value)
107
+ type = repeating_body? ? Type::Combinator.union(*scope.ivar(name), value, Type::Combinator.untyped) : value
108
+ scope.with_ivar(name, type).without_ivar_guard(name)
109
+ end
110
+
111
+ def splat_or_keywords?(arg)
112
+ arg.is_a?(Prism::SplatNode) || arg.is_a?(Prism::KeywordHashNode) || arg.is_a?(Prism::BlockArgumentNode)
113
+ end
114
+ end
115
+ end
116
+ end