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
@@ -0,0 +1,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../type"
6
+ require_relative "reflective_ivar_write"
7
+ require_relative "refined_dispatch/own_method"
8
+ require_relative "self_call_ivar_writes"
9
+
10
+ module Rigor
11
+ module Inference
12
+ # The intervening-call reset of instance variables. An implicit-self or `self.` call may write any instance
13
+ # variable of `self`, so past one each instance variable whose binding is narrower than its class-ivar seed reads
14
+ # the union of the two again; a call proven to write only some ({SelfCallIvarWrites.of}, issue #1596) widens only
15
+ # those. A call on another receiver cannot reach `self`'s instance variables and widens nothing.
16
+ #
17
+ # {.widen} is the reset itself, which the statement evaluator applies past a statement call
18
+ # (`StatementEvaluator#invalidate_ivars_for_intervening_call`). Issue #1963 — {.after_operand} applies it past
19
+ # the calls in a condition operand, where a narrowing an earlier operand or clause made would otherwise survive
20
+ # them: `if @x.nil? && reset!` read `@x` as `nil` in the body although `reset!` assigns it.
21
+ module InterveningIvarReset
22
+ # Nodes whose body does not run where it is written, or runs on another `self`: a `def`, and a class or
23
+ # module body, where `@x` is the class's own instance variable.
24
+ NON_RUNNING_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
25
+ # Issue #1402 — the nodes that run code the program does not show on `self`'s behalf: `yield` runs the
26
+ # method's block, and `super` the superclass's method on `self`.
27
+ UNKNOWN_CODE_NODES = [Prism::YieldNode, Prism::SuperNode, Prism::ForwardingSuperNode].freeze
28
+ # Issue #1402 — the names of a proc's call (`blk.call`, `blk.()`, `blk.yield`), which runs the block the method
29
+ # was given as `yield` does.
30
+ PROC_CALL_NAMES = Set[:call, :yield].freeze
31
+ # Issue #1402 — the calls whose block runs with `self` bound to the receiver: on a receiver that is provably
32
+ # another object ({.foreign_self_block?}), an instance variable the block writes, and a method it calls
33
+ # without a receiver, are that object's.
34
+ SELF_REBINDING_BLOCK_CALLS = Set[
35
+ :instance_eval, :instance_exec, :class_eval, :class_exec, :module_eval, :module_exec, :define_method,
36
+ :define_singleton_method
37
+ ].freeze
38
+ # The classes whose instances may be a class or module object: a `self` of one of them may be the receiver of
39
+ # `Foo.class_eval`, and an instance of one of them may be a `self` that is a class object.
40
+ CLASS_OBJECT_CLASSES = Set["Class", "Module", "Object", "BasicObject"].freeze
41
+ private_constant :NON_RUNNING_NODES, :UNKNOWN_CODE_NODES, :PROC_CALL_NAMES, :SELF_REBINDING_BLOCK_CALLS,
42
+ :CLASS_OBJECT_CLASSES
43
+
44
+ module_function
45
+
46
+ # True when `node` is a call that may write `self`'s instance variables: an implicit-self or `self.` call
47
+ # ({.self_call?}). Issue #1402 — and a `yield`, a `super`, or a proc's call on a local ({.proc_call?}), which
48
+ # run code the program does not show; {SelfCallIvarWrites.of} proves no write set for any of them.
49
+ def candidate?(node)
50
+ return true if UNKNOWN_CODE_NODES.any? { |klass| node.is_a?(klass) }
51
+ return false unless node.is_a?(Prism::CallNode)
52
+
53
+ self_call?(node) || proc_call?(node)
54
+ end
55
+
56
+ # Whether `node`, a call, has no receiver or `self` as its receiver.
57
+ def self_call?(node)
58
+ receiver = node.receiver
59
+ receiver.nil? || receiver.is_a?(Prism::SelfNode)
60
+ end
61
+
62
+ # Issue #1402 — `blk.call` / `blk.()` / `blk.yield` on a local: the method's own `&blk`, or a proc it was
63
+ # handed, runs code that may call back into `self`, as `yield` does. Read by name, since an unannotated
64
+ # `&blk` binds `Dynamic`.
65
+ def proc_call?(node)
66
+ PROC_CALL_NAMES.include?(node.name) && node.receiver.is_a?(Prism::LocalVariableReadNode)
67
+ end
68
+
69
+ # Issue #1402 — whether `call_node`'s block runs on an object that is provably not `self`: a
70
+ # {SELF_REBINDING_BLOCK_CALLS} call whose receiver, typed in `scope`, cannot be `self` ({.other_object?}).
71
+ # A receiver that may be `self` at run time (`obj = self; obj.instance_eval { … }`, a block parameter, an
72
+ # `Object` or `Dynamic` value, a class the analysis cannot order against `self`'s) is not foreign.
73
+ def foreign_self_block?(call_node, scope)
74
+ return false unless call_node.is_a?(Prism::CallNode) && SELF_REBINDING_BLOCK_CALLS.include?(call_node.name)
75
+ return false if self_call?(call_node)
76
+
77
+ other_object?(scope.type_of(call_node.receiver), scope)
78
+ rescue StandardError
79
+ false
80
+ end
81
+
82
+ # Whether a value of `type` cannot be `scope`'s `self`. Only an instance method of a class answers: `self` is
83
+ # then an instance of that class (one other than `Class`, `Module`, `Object` and `BasicObject`, and no module,
84
+ # declared in source or RBS), and a class object (`Singleton[X]`: `self.class`, a constant naming a class or
85
+ # module) is not one. Where `self` is a class object, or a module's instance (`module_function`, `extend
86
+ # self`), any receiver may be `self` — a subclass, the module itself — and none is foreign. An instance
87
+ # receiver is never foreign: its class may be a subclass of `self`'s that the environment cannot order.
88
+ def other_object?(type, scope)
89
+ self_type = scope.self_type
90
+ return false unless self_type.is_a?(Type::Nominal) && plain_class?(self_type.class_name, scope)
91
+
92
+ type.is_a?(Type::Singleton)
93
+ end
94
+
95
+ # Whether `class_name` names a class whose instances are no class or module object, and not a module.
96
+ def plain_class?(class_name, scope)
97
+ name = class_name.to_s.delete_prefix("::")
98
+ !CLASS_OBJECT_CLASSES.include?(name) && !RefinedDispatch::OwnMethod.module_name?(scope, name) &&
99
+ !module_subclass?(name, scope)
100
+ end
101
+
102
+ # Whether `name` may be a subclass of `Module` (`class Meta < Module`), whose instance is a module object.
103
+ # Read off the class's instance `Scope::ResolutionChain`, which resolves a superclass written relative to the
104
+ # subclass's namespace (`class Meta < Base` in `module NS`) as Ruby does: true when an entry is one the
105
+ # environment orders `:subclass` or `:equal` to `Module` (`Module` itself, `Class`, a project class whose
106
+ # signature declares it, an external superclass), when an external superclass names no class the
107
+ # environment knows (it may be `Module`), or when the chain is truncated or unsettled.
108
+ def module_subclass?(name, scope)
109
+ chain = Scope::ResolutionChain.for(scope, name, :instance, :methods)
110
+ return true if chain.truncated? || chain.unsettled?
111
+
112
+ environment = scope.environment
113
+ chain.search(scope) { |entry| module_entry?(entry, environment) || nil } ? true : false
114
+ end
115
+ private_class_method :module_subclass?
116
+
117
+ # Whether a chain entry makes the class a possible `Module` subclass: a project entry {.module_like?}, or an
118
+ # external superclass that names no class the environment knows, or one {.module_like?}. An external mixin
119
+ # (`include Enumerable`) does not.
120
+ def module_entry?(entry, environment)
121
+ return module_like?(entry.name.to_s, environment) unless entry.external?
122
+ return false unless entry.superclass_edge
123
+
124
+ known = entry.candidates.map { |candidate| candidate.to_s.delete_prefix("::") }
125
+ .select { |candidate| environment.class_known?(candidate) }
126
+ known.empty? || known.any? { |candidate| module_like?(candidate, environment) }
127
+ end
128
+ private_class_method :module_entry?
129
+
130
+ def module_like?(name, environment)
131
+ %i[subclass equal].include?(environment.class_ordering(name, "Module"))
132
+ end
133
+ private_class_method :module_like?
134
+ private_class_method :plain_class?
135
+
136
+ # Issue #1402 — `scope` past a statement call whose receiver or arguments run code the program does not
137
+ # show (`@items << yield`, `puts(yield)`, `log(blk.call)`, `items.each(&blk)`): {.widen} over every narrowed
138
+ # name, as past a statement `yield`. Every other call in them is the operand walk's.
139
+ def after_operands(call_node, scope)
140
+ return scope unless narrowed_ivars?(scope)
141
+
142
+ found = []
143
+ collect_calls(call_node.receiver, false, found, scope, unknown_only: true)
144
+ collect_calls(call_node.arguments, false, found, scope, unknown_only: true)
145
+ block = call_node.block
146
+ found << block if block.is_a?(Prism::BlockArgumentNode) && block.expression.is_a?(Prism::LocalVariableReadNode)
147
+ return scope if found.empty?
148
+
149
+ widen(scope, mark: true) { nil }
150
+ end
151
+
152
+ # Issue #1402 — `scope` past a call on another receiver whose literal block may make a {.candidate?} call
153
+ # (`[1].each { reset }`, `items.each { yield }`): {.widen} over the names those calls may write, as
154
+ # {.after_operand} widens past the calls in a condition. In a block that runs on another object
155
+ # ({.foreign_self_block?}) only a `yield`, a `super` or a proc's call counts: its implicit-self calls are that
156
+ # object's, while the method's block runs as it always does. A literal-name
157
+ # `instance_variable_set` on `self` ({ReflectiveIvarWrite.literal_name}) writes the one ivar it names, which
158
+ # the block's captured-binding write-back already joins, so it is not counted.
159
+ def after_block(call_node, scope)
160
+ block = call_node.block
161
+ return scope unless block.is_a?(Prism::BlockNode)
162
+ return scope unless narrowed_ivars?(scope)
163
+
164
+ calls = []
165
+ if foreign_self_block?(call_node, scope)
166
+ collect_calls(block, false, calls, scope, unknown_only: true)
167
+ else
168
+ collect_calls(block, false, calls, scope)
169
+ calls.reject! { |call| ReflectiveIvarWrite.literal_name(call) }
170
+ end
171
+ return scope if calls.empty?
172
+
173
+ widen(scope, mark: true) { proven_writes(calls, scope) }
174
+ end
175
+
176
+ # `scope` with each instance variable narrowed below its class-ivar seed widened back over the seed. The block
177
+ # answers the names the call may write, or nil for any; it runs at most once, and only when an instance
178
+ # variable is narrowed, since a proof costs a read of the callee. An instance variable whose binding carries
179
+ # the declaration mark keeps it (issue #1889). With `mark: true` a binding whose narrowing had ruled `nil` out
180
+ # takes the mark when widened, so a `nil` only the seed brings back is not fuel for
181
+ # `call.possible-nil-receiver` (ADR-58), as a declared one is not. A narrowing that kept `nil`
182
+ # (`return if @tag`, `@tag.nil?`) widens unmarked, so a read the program really makes on `nil` still reports.
183
+ def widen(scope, mark: false)
184
+ seed = class_ivar_seed(scope)
185
+ return scope if seed.nil? || seed.empty?
186
+
187
+ computed = false
188
+ writes = nil
189
+ seed.reduce(scope) do |widened, (name, seed_type)|
190
+ local_type = scope.ivar(name)
191
+ next widened if local_type.nil? || local_type == seed_type
192
+
193
+ unless computed
194
+ writes = yield
195
+ computed = true
196
+ end
197
+ next widened if writes && !writes.include?(name)
198
+
199
+ joined = Type::Combinator.union(local_type, seed_type)
200
+ widened = widened.with_ivar(name, joined)
201
+ marked = (mark && nil_free?(local_type)) || scope.declaration_sourced?(:ivar, name)
202
+ marked ? widened.seed_declaration_sourced_ivar(name, joined) : widened
203
+ end
204
+ end
205
+
206
+ # Issue #1963 — `scope` past the calls `node`, a condition operand, makes: {.widen} over the names every
207
+ # candidate call in it may write, or every name when one of them is not proven. A call's literal block may run,
208
+ # so its calls count; a `def`, a class or module body and (unless `run_lambdas`) a lambda literal run nothing
209
+ # on this `self` there.
210
+ #
211
+ # A binding whose narrowing ruled `nil` out takes the declaration mark when widened ({.widen}'s `mark:`): the
212
+ # reset forgets the narrowing without making the variable newly nil-reportable, since a call the proof cannot
213
+ # clear is far more often one that leaves the variable alone (`if @tag && items.any?` then `t = @tag;
214
+ # t.upcase`) than one that assigns `nil`. A `when` test passes `run_lambdas: true`, since its `===` runs a
215
+ # lambda literal's body.
216
+ def after_operand(node, scope, run_lambdas: false)
217
+ return scope unless narrowed_ivars?(scope)
218
+
219
+ calls = candidate_calls(node, run_lambdas, scope)
220
+ return scope if calls.empty?
221
+
222
+ widen(scope, mark: true) { proven_writes(calls, scope) }
223
+ end
224
+
225
+ # Whether a candidate call in `node` may write the instance variable `name`. A `when` test passes
226
+ # `run_lambdas: true`, since its `===` runs a lambda literal's body. A write to it in `node` is the caller's
227
+ # to check.
228
+ def may_write?(node, name, scope, run_lambdas: false)
229
+ calls = candidate_calls(node, run_lambdas, scope)
230
+ return false if calls.empty?
231
+
232
+ proven = proven_writes(calls, scope)
233
+ proven.nil? || proven.include?(name)
234
+ end
235
+
236
+ # The union of the names each call may write ({SelfCallIvarWrites.of}), or nil when one is not proven.
237
+ def proven_writes(calls, scope)
238
+ calls.reduce(Set.new) do |acc, call|
239
+ writes = SelfCallIvarWrites.of(call, scope)
240
+ break nil if writes.nil?
241
+
242
+ acc.merge(writes)
243
+ end
244
+ end
245
+
246
+ # The candidate calls under `node`, in source order.
247
+ def candidate_calls(node, run_lambdas, scope)
248
+ found = []
249
+ collect_calls(node, run_lambdas, found, scope)
250
+ found
251
+ end
252
+
253
+ # Issue #1402 — with `unknown_only: true` only the `yield`s, `super`s and proc calls ({.unknown_code?}) count,
254
+ # wherever they sit. A block that runs on another object ({.foreign_self_block?}) is read that way: its
255
+ # implicit-self calls are that object's, while the method's block runs as anywhere else.
256
+ def collect_calls(node, run_lambdas, found, scope, unknown_only: false)
257
+ return unless node.is_a?(Prism::Node)
258
+ return if NON_RUNNING_NODES.any? { |klass| node.is_a?(klass) }
259
+ return if !run_lambdas && node.is_a?(Prism::LambdaNode)
260
+
261
+ found << node if unknown_only ? unknown_code?(node) : candidate?(node)
262
+ node.compact_child_nodes.each do |child|
263
+ foreign = !unknown_only && child.is_a?(Prism::BlockNode) && foreign_self_block?(node, scope)
264
+ collect_calls(child, run_lambdas, found, scope, unknown_only: unknown_only || foreign)
265
+ end
266
+ end
267
+ private_class_method :collect_calls
268
+
269
+ # Issue #1402 — a `yield`, a `super` or a proc's call ({.proc_call?}).
270
+ def unknown_code?(node)
271
+ UNKNOWN_CODE_NODES.any? { |klass| node.is_a?(klass) } || (node.is_a?(Prism::CallNode) && proc_call?(node))
272
+ end
273
+ private_class_method :unknown_code?
274
+
275
+ def nil_free?(type)
276
+ Narrowing.narrow_non_nil(type) == type
277
+ end
278
+ private_class_method :nil_free?
279
+
280
+ # Whether any instance variable is bound narrower than its class-ivar seed: the only state {.widen} changes.
281
+ def narrowed_ivars?(scope)
282
+ seed = class_ivar_seed(scope)
283
+ return false if seed.nil? || seed.empty?
284
+
285
+ seed.any? do |name, seed_type|
286
+ local_type = scope.ivar(name)
287
+ !local_type.nil? && local_type != seed_type
288
+ end
289
+ end
290
+ private_class_method :narrowed_ivars?
291
+
292
+ def class_ivar_seed(scope)
293
+ self_type = scope.self_type
294
+ class_name =
295
+ case self_type
296
+ when Type::Nominal, Type::Singleton then self_type.class_name
297
+ end
298
+ class_name && scope.class_ivars_for(class_name)
299
+ end
300
+ private_class_method :class_ivar_seed
301
+ end
302
+ end
303
+ end
@@ -47,6 +47,25 @@ module Rigor
47
47
  # same call, another plugin's included, is not consulted. A `:lexical` entry (issue #1667) keeps the caller's
48
48
  # `self`, so its `self_type` is the calling scope's.
49
49
  def match_for(scope:, call_node:, receiver_type:)
50
+ entry = first_matching_entry(scope, call_node, receiver_type)
51
+ return nil if entry.nil?
52
+
53
+ receiver_name = singleton_receiver_class_name(receiver_type) || nominal_receiver_class_name(receiver_type)
54
+ entry_match(entry, receiver_name, scope, scope.environment)
55
+ end
56
+
57
+ # Whether `call_node`'s block runs as an instance method of the receiver class (issue #1768): its first matching
58
+ # entry binds `:receiver_instance`, so the block body's `self` is the instance {#match_for} narrows to and its
59
+ # `@x` writes are that instance's ivars, as a `def` body's are. A `:lexical` or named-`self_type` entry runs the
60
+ # block on another object, and no entry means no narrowing.
61
+ def runs_as_receiver_instance_method?(scope:, call_node:, receiver_type:)
62
+ entry = first_matching_entry(scope, call_node, receiver_type)
63
+ !entry.nil? && entry.self_type == Plugin::Macro::BlockAsMethod::SELF_TYPE_RECEIVER_INSTANCE
64
+ end
65
+
66
+ # The first registered entry that matches `call_node` on `receiver_type`, in (plugin registration, declaration)
67
+ # order, or nil.
68
+ def first_matching_entry(scope, call_node, receiver_type)
50
69
  return nil if receiver_type.nil?
51
70
 
52
71
  environment = scope&.environment
@@ -61,12 +80,7 @@ module Rigor
61
80
  # (plugin registration, declaration) order; the method-name membership is guaranteed
62
81
  # by the table key.
63
82
  entries = registry.contribution_index.block_entries_for(call_node.name)
64
- entries.each do |entry|
65
- next unless entry_matches?(entry, singleton_name, nominal_name, call_node, scope, environment)
66
-
67
- return entry_match(entry, singleton_name || nominal_name, scope, environment)
68
- end
69
- nil
83
+ entries.find { |entry| entry_matches?(entry, singleton_name, nominal_name, call_node, scope, environment) }
70
84
  end
71
85
 
72
86
  # The {Match} a matching entry contributes: a `:lexical` one keeps the calling scope's `self`, unknown where the
@@ -190,6 +190,35 @@ module Rigor
190
190
 
191
191
  STRING_FOLD_BYTE_LIMIT = 4096
192
192
 
193
+ # #2115 — the numeric twin of `STRING_FOLD_BYTE_LIMIT`. A folded Integer (and each Integer part of a
194
+ # folded Rational / Complex) is at most this many bits, the largest bit length whose decimal
195
+ # rendering still fits in `STRING_FOLD_BYTE_LIMIT` digits: `2 ** 13_605` folds, `2 ** 13_606`
196
+ # (bit length 13_607) does not. Computing `2 ** 100_000_000` takes Ruby milliseconds, but
197
+ # rendering it in decimal — which describing, sorting and printing the `Constant` does — takes
198
+ # minutes. `**` / `pow` / `<<` / `>>` also check their operands before invoking, so the big value
199
+ # is never computed.
200
+ INTEGER_FOLD_BIT_LIMIT = (STRING_FOLD_BYTE_LIMIT * Math.log2(10)).floor
201
+
202
+ # #2115 — the most elements a fold walks out of a `Constant<Range>` receiver. A method that
203
+ # enumerates the range (`to_a`, `to_set`, `all?`, `include?` on a String range, …) declines when
204
+ # the range holds more elements, or is endless (`1..Float::INFINITY`); a method that reads only
205
+ # the endpoints (`RANGE_ENDPOINT_METHODS`) still folds whatever the range's size.
206
+ RANGE_ENUMERATION_LIMIT = 1024
207
+
208
+ # The `Range` methods whose CRuby body reads the endpoints without walking the elements, so they
209
+ # stay foldable on a range past `RANGE_ENUMERATION_LIMIT`. `include?` / `member?` join them on a
210
+ # numeric range, where CRuby answers them by `cover?`.
211
+ RANGE_ENDPOINT_METHODS = Set[
212
+ :begin, :end, :exclude_end?, :cover?, :===, :size, :to_s, :inspect, :==, :eql?, :hash
213
+ ].freeze
214
+ RANGE_COVER_METHODS = Set[:include?, :member?].freeze
215
+ private_constant :RANGE_ENDPOINT_METHODS, :RANGE_COVER_METHODS
216
+
217
+ # A result `INTEGER_FOLD_BIT_LIMIT` keeps from becoming a `Constant`: the fold still knows its class,
218
+ # and answers `Nominal[class_name]` for it. Falling through to RBS instead would read `Integer#**`'s
219
+ # declared `Numeric`, on which `(2 ** 20_000).even?` is an undefined method.
220
+ WidenedResult = Data.define(:class_name)
221
+
193
222
  # Input cartesian product hard cap. Keeps fold cost bounded even when the receiver and argument are
194
223
  # both `Union[Constant…]`. 5 × 5 = 25 inputs is permitted; 6 × 6 = 36 is not. The user-facing payoff
195
224
  # (a precise small enum) drops off fast past this range and CRuby method invocation cost adds up.
@@ -649,45 +678,41 @@ module Rigor
649
678
  def range_constant_unary(range, method_name)
650
679
  case method_name
651
680
  when :to_a, :entries then range_to_a_tuple(range)
652
- when :first, :min then range_endpoint_constant(range, :first)
653
- when :last, :max then range_endpoint_constant(range, :last)
654
- when :count, :size, :length then Type::Combinator.constant_of(range.to_a.size)
655
- when :minmax then range_minmax_tuple(range)
681
+ # #2115 — `Range#min` / `#max` / `#minmax` / `#size` are O(1) on Integer endpoints in CRuby, so
682
+ # none of these materialises the range. The no-arg `first` / `last` read the endpoints, not the
683
+ # elements: `first` is `begin` and `last` is `end` whether the range is empty, reversed or
684
+ # exclusive (`(5..1).first` is 5, `(1...10).last` is 10). Only `min` / `max` depend on emptiness
685
+ # and exclusivity (`(5..1).min` is nil, `(1...10).max` is 9).
686
+ when :first then Type::Combinator.constant_of(range.begin)
687
+ when :last then Type::Combinator.constant_of(range.end)
688
+ when :min then Type::Combinator.constant_of(range.min)
689
+ when :max then Type::Combinator.constant_of(range.max)
690
+ when :count, :size, :length then Type::Combinator.constant_of(range.size)
691
+ when :minmax then Type::Combinator.tuple_of(*range.minmax.map { |v| Type::Combinator.constant_of(v) })
656
692
  # `range.sum` is closed-form (Gauss) for an integer range, so a huge range still costs O(1) and
657
- # yields a single Integer — no materialisation, no cap needed. Endless ranges are already excluded
658
- # by the Integer-endpoint guard in the caller.
659
- when :sum then Type::Combinator.constant_of(range.sum)
693
+ # yields a single Integer — no materialisation; only `INTEGER_FOLD_BIT_LIMIT` applies. Endless
694
+ # ranges are already excluded by the Integer-endpoint guard in the caller.
695
+ when :sum then range_sum_constant(range)
660
696
  end
661
697
  end
662
698
 
663
- def range_to_a_tuple(range)
664
- values = range.to_a
665
- return Type::Combinator.tuple_of if values.empty?
666
- return nil if values.size > RANGE_TO_A_LIMIT
699
+ # The Gauss sum of a range of large endpoints can still pass `INTEGER_FOLD_BIT_LIMIT`; it is then
700
+ # an `Integer` (both endpoints are).
701
+ def range_sum_constant(range)
702
+ sum = range.sum
703
+ return Type::Combinator.nominal_of("Integer") if oversized_number?(sum)
667
704
 
668
- Type::Combinator.tuple_of(*values.map { |v| Type::Combinator.constant_of(v) })
705
+ Type::Combinator.constant_of(sum)
669
706
  end
670
707
 
671
- def range_endpoint_constant(range, edge)
672
- values = range.to_a
673
- return Type::Combinator.constant_of(nil) if values.empty?
674
-
675
- Type::Combinator.constant_of(edge == :first ? values.first : values.last)
676
- end
708
+ def range_to_a_tuple(range)
709
+ # `Range#size` is O(1) on Integer endpoints: check the cardinality before materialising.
710
+ return nil if range.size > RANGE_TO_A_LIMIT
677
711
 
678
- def range_minmax_tuple(range)
679
712
  values = range.to_a
680
- if values.empty?
681
- return Type::Combinator.tuple_of(
682
- Type::Combinator.constant_of(nil),
683
- Type::Combinator.constant_of(nil)
684
- )
685
- end
713
+ return Type::Combinator.tuple_of if values.empty?
686
714
 
687
- Type::Combinator.tuple_of(
688
- Type::Combinator.constant_of(values.first),
689
- Type::Combinator.constant_of(values.last)
690
- )
715
+ Type::Combinator.tuple_of(*values.map { |v| Type::Combinator.constant_of(v) })
691
716
  end
692
717
 
693
718
  # `(1..10).first(3)` / `.take(3)` / `.last(3)` — the 1-arg head / tail forms. `first`/`last` already
@@ -1175,6 +1200,17 @@ module Rigor
1175
1200
  # "Integer-ness" was already implied by the inputs (so the widening fallback only fires for
1176
1201
  # arithmetic over integers).
1177
1202
  def build_constant_type(values, source: nil)
1203
+ widened, values = values.partition { |value| value.is_a?(WidenedResult) }
1204
+ return build_scalar_constant_type(values, source) if widened.empty?
1205
+
1206
+ nominals = widened.map(&:class_name).uniq.map { |name| Type::Combinator.nominal_of(name) }
1207
+ return Type::Combinator.union(*nominals) if values.empty?
1208
+
1209
+ constants = build_scalar_constant_type(values, source)
1210
+ constants && Type::Combinator.union(constants, *nominals)
1211
+ end
1212
+
1213
+ def build_scalar_constant_type(values, source)
1178
1214
  return nil if values.empty?
1179
1215
 
1180
1216
  unique = values.uniq
@@ -1607,9 +1643,12 @@ module Rigor
1607
1643
  # `flat_map`.
1608
1644
  def invoke_binary(receiver_value, method_name, arg_value)
1609
1645
  return nil unless safe?(receiver_value, method_name, arg_value)
1646
+ return nil if range_enumeration_blow_up?(receiver_value, method_name)
1610
1647
 
1611
- result = receiver_value.public_send(method_name, arg_value)
1612
- foldable_constant_value?(result) ? [result] : nil
1648
+ widened = numeric_blow_up_class(receiver_value, method_name, arg_value)
1649
+ return [WidenedResult.new(widened)] if widened
1650
+
1651
+ fold_result(receiver_value.public_send(method_name, arg_value))
1613
1652
  rescue StandardError
1614
1653
  nil
1615
1654
  end
@@ -1620,8 +1659,7 @@ module Rigor
1620
1659
  def invoke_ternary(receiver_value, method_name, av0, av1)
1621
1660
  return nil unless ternary_method_allowed?(receiver_value, method_name)
1622
1661
 
1623
- result = receiver_value.public_send(method_name, av0, av1)
1624
- foldable_constant_value?(result) ? [result] : nil
1662
+ fold_result(receiver_value.public_send(method_name, av0, av1))
1625
1663
  rescue StandardError
1626
1664
  nil
1627
1665
  end
@@ -1630,13 +1668,21 @@ module Rigor
1630
1668
  def invoke_unary(receiver_value, method_name)
1631
1669
  return nil unless unary_safe?(receiver_value, method_name)
1632
1670
  return nil if string_unary_blow_up?(receiver_value, method_name)
1671
+ return nil if range_enumeration_blow_up?(receiver_value, method_name)
1633
1672
 
1634
- result = receiver_value.public_send(method_name)
1635
- foldable_constant_value?(result) ? [result] : nil
1673
+ fold_result(receiver_value.public_send(method_name))
1636
1674
  rescue StandardError
1637
1675
  nil
1638
1676
  end
1639
1677
 
1678
+ # `[result]` for a foldable value, `[WidenedResult]` for a number past `INTEGER_FOLD_BIT_LIMIT`, nil
1679
+ # for anything else.
1680
+ def fold_result(result)
1681
+ return [WidenedResult.new(result.class.name)] if oversized_number?(result)
1682
+
1683
+ foldable_constant_value?(result) ? [result] : nil
1684
+ end
1685
+
1640
1686
  def unary_safe?(receiver_value, method_name)
1641
1687
  return true if unary_ops_for(receiver_value).include?(method_name)
1642
1688
 
@@ -1775,7 +1821,44 @@ module Rigor
1775
1821
  # with the widened class.
1776
1822
  return false if value.is_a?(Float) && value.nan?
1777
1823
  return false if value.is_a?(Complex) && complex_nan?(value)
1824
+ return false if oversized_number?(value)
1825
+
1826
+ true
1827
+ end
1828
+
1829
+ # True when an Integer, or an Integer part of a Rational / Complex, is past `INTEGER_FOLD_BIT_LIMIT`.
1830
+ def oversized_number?(value)
1831
+ case value
1832
+ when Integer then value.bit_length > INTEGER_FOLD_BIT_LIMIT
1833
+ when Rational then oversized_number?(value.numerator) || oversized_number?(value.denominator)
1834
+ when Complex then oversized_number?(value.real) || oversized_number?(value.imaginary)
1835
+ else false
1836
+ end
1837
+ end
1838
+
1839
+ # #2115 — true when `receiver_value` is a `Range` the method would walk element by element and the
1840
+ # range holds more than `RANGE_ENUMERATION_LIMIT` elements (an endless one never stops).
1841
+ def range_enumeration_blow_up?(receiver_value, method_name)
1842
+ return false unless receiver_value.is_a?(Range)
1843
+ return false if RANGE_ENDPOINT_METHODS.include?(method_name)
1844
+ return false if RANGE_COVER_METHODS.include?(method_name) && numeric_range?(receiver_value)
1845
+
1846
+ !range_within_enumeration_budget?(receiver_value)
1847
+ end
1778
1848
 
1849
+ def numeric_range?(range)
1850
+ range.begin.is_a?(Numeric) || range.end.is_a?(Numeric)
1851
+ end
1852
+
1853
+ # Takes at most `RANGE_ENUMERATION_LIMIT + 1` elements (`Range#first(n)` stops there, endless or
1854
+ # not). A range that cannot iterate at all (a Float or Time begin) raises, and so does every
1855
+ # enumerating method on it before doing any work, so it counts as within budget: the invoke's own
1856
+ # rescue declines an enumerating fold, and `(t0..t1).include?(t)`, which CRuby answers by `cover?`,
1857
+ # keeps folding. A beginless range raises `RangeError` here, which the invoke's own rescue turns into
1858
+ # a declined fold, as the enumerating method itself would raise.
1859
+ def range_within_enumeration_budget?(range)
1860
+ range.first(RANGE_ENUMERATION_LIMIT + 1).size <= RANGE_ENUMERATION_LIMIT
1861
+ rescue TypeError
1779
1862
  true
1780
1863
  end
1781
1864
 
@@ -1796,6 +1879,58 @@ module Rigor
1796
1879
  true
1797
1880
  end
1798
1881
 
1882
+ # #2115 — the result's class name when `**` / `pow` / `<<` / `>>` would produce a number past
1883
+ # `INTEGER_FOLD_BIT_LIMIT`, read from the operands so the huge value is never computed; nil
1884
+ # otherwise. `fold_result` applies the same bound to every other result after the fact.
1885
+ def numeric_blow_up_class(receiver_value, method_name, arg_value)
1886
+ return nil unless arg_value.is_a?(Integer)
1887
+
1888
+ case method_name
1889
+ when :**, :pow then power_blow_up_class(receiver_value, arg_value)
1890
+ when :<<, :>> then "Integer" if shift_blow_up?(receiver_value, method_name, arg_value)
1891
+ end
1892
+ end
1893
+
1894
+ # An Integer base to a negative power is a Rational (`2 ** -3 == (1/8)`); a Rational or Complex
1895
+ # base keeps its class.
1896
+ def power_blow_up_class(base, exponent)
1897
+ return nil unless power_blow_up?(base, exponent)
1898
+ return base.class.name unless base.is_a?(Integer)
1899
+
1900
+ exponent.negative? ? "Rational" : "Integer"
1901
+ end
1902
+
1903
+ # A lower bound on the result's bit length is `floor(log2 |base|) * |exponent|`; past the limit the
1904
+ # result is declined anyway. A Float base (or Float part) stays in Float arithmetic and never grows.
1905
+ def power_blow_up?(base, exponent)
1906
+ bits = power_base_bits(base)
1907
+ !bits.nil? && bits * exponent.abs > INTEGER_FOLD_BIT_LIMIT
1908
+ end
1909
+
1910
+ # `floor(log2 |base|)` for an Integer, the larger of its terms' for a Rational. A Complex part
1911
+ # counts one bit more: `(1+1i) ** n` grows although neither part is past 1.
1912
+ def power_base_bits(base)
1913
+ case base
1914
+ when Integer then base.abs.bit_length - 1
1915
+ when Rational then [base.numerator.abs.bit_length, base.denominator.bit_length].max - 1
1916
+ when Complex then complex_power_base_bits(base)
1917
+ end
1918
+ end
1919
+
1920
+ def complex_power_base_bits(base)
1921
+ parts = [base.real, base.imaginary]
1922
+ return nil unless parts.all? { |part| part.is_a?(Integer) || part.is_a?(Rational) }
1923
+
1924
+ parts.map { |part| power_base_bits(part) + 1 }.max
1925
+ end
1926
+
1927
+ def shift_blow_up?(receiver_value, method_name, arg_value)
1928
+ return false unless receiver_value.is_a?(Integer) && !receiver_value.zero?
1929
+
1930
+ shift = method_name == :<< ? arg_value : -arg_value
1931
+ receiver_value.bit_length + shift > INTEGER_FOLD_BIT_LIMIT
1932
+ end
1933
+
1799
1934
  def ops_for(receiver_value)
1800
1935
  case receiver_value
1801
1936
  when Integer, Float then NUMERIC_BINARY