rigortype 0.4.5 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/docs/handbook/11-sig-gen.md +11 -2
  4. data/docs/manual/02-cli-reference.md +46 -7
  5. data/docs/manual/03-configuration.md +2 -1
  6. data/docs/manual/04-diagnostics.md +1 -1
  7. data/docs/manual/10-mcp-server.md +9 -1
  8. data/docs/manual/12-caching.md +8 -1
  9. data/docs/manual/plugins/rigor-actionmailer.md +30 -0
  10. data/docs/manual/plugins/rigor-actionpack.md +30 -0
  11. data/docs/manual/plugins/rigor-devise.md +15 -0
  12. data/docs/manual/plugins/rigor-rspec.md +2 -1
  13. data/lib/rigor/analysis/buffer_binding.rb +14 -0
  14. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
  15. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
  16. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +4 -0
  17. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +66 -3
  18. data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
  19. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
  20. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
  21. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +51 -0
  22. data/lib/rigor/analysis/check_rules.rb +93 -8
  23. data/lib/rigor/analysis/dependency_recorder.rb +11 -0
  24. data/lib/rigor/analysis/edit_replay.rb +67 -0
  25. data/lib/rigor/analysis/incremental.rb +37 -0
  26. data/lib/rigor/analysis/incremental_session.rb +175 -27
  27. data/lib/rigor/analysis/path_expansion.rb +41 -0
  28. data/lib/rigor/analysis/require_relative_closure.rb +100 -0
  29. data/lib/rigor/analysis/rule_catalog.rb +5 -1
  30. data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
  31. data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
  32. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
  33. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
  34. data/lib/rigor/analysis/runner/pool_coordinator.rb +17 -4
  35. data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
  36. data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
  37. data/lib/rigor/analysis/runner.rb +156 -49
  38. data/lib/rigor/analysis/worker_session.rb +14 -2
  39. data/lib/rigor/cache/incremental_snapshot.rb +30 -7
  40. data/lib/rigor/cli/check_command.rb +93 -4
  41. data/lib/rigor/cli/diff_command.rb +2 -1
  42. data/lib/rigor/cli/lens_command.rb +5 -5
  43. data/lib/rigor/cli/mcp_command.rb +2 -1
  44. data/lib/rigor/cli/options.rb +20 -3
  45. data/lib/rigor/cli/plugins_command.rb +5 -0
  46. data/lib/rigor/cli/plugins_renderer.rb +24 -2
  47. data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
  48. data/lib/rigor/cli/sig_gen_command.rb +46 -7
  49. data/lib/rigor/cli/type_of_command.rb +2 -1
  50. data/lib/rigor/cli/type_of_project_scope.rb +27 -0
  51. data/lib/rigor/config_audit.rb +16 -5
  52. data/lib/rigor/configuration.rb +14 -4
  53. data/lib/rigor/environment/rbs_loader.rb +115 -6
  54. data/lib/rigor/environment/required_features.rb +136 -29
  55. data/lib/rigor/environment/ruby_library_roots.rb +122 -0
  56. data/lib/rigor/environment.rb +4 -2
  57. data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
  58. data/lib/rigor/inference/deep_erasure.rb +138 -0
  59. data/lib/rigor/inference/def_return_typer.rb +159 -9
  60. data/lib/rigor/inference/definer_resolution.rb +17 -3
  61. data/lib/rigor/inference/error_info.rb +17 -3
  62. data/lib/rigor/inference/expression_typer.rb +62 -13
  63. data/lib/rigor/inference/global_write_census.rb +36 -1
  64. data/lib/rigor/inference/ivar_writer_census.rb +195 -0
  65. data/lib/rigor/inference/loop_value_record.rb +53 -0
  66. data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
  67. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +110 -9
  68. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
  69. data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
  70. data/lib/rigor/inference/method_dispatcher.rb +46 -16
  71. data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
  72. data/lib/rigor/inference/module_function_state.rb +12 -0
  73. data/lib/rigor/inference/mutation_widening.rb +5 -2
  74. data/lib/rigor/inference/narrowing.rb +66 -5
  75. data/lib/rigor/inference/object_mixins.rb +13 -1
  76. data/lib/rigor/inference/project_seed_tables.rb +2 -1
  77. data/lib/rigor/inference/project_yielder.rb +128 -0
  78. data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
  79. data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
  80. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
  81. data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
  82. data/lib/rigor/inference/scope_indexer.rb +480 -103
  83. data/lib/rigor/inference/self_call_ivar_writes.rb +18 -6
  84. data/lib/rigor/inference/statement_evaluator.rb +658 -90
  85. data/lib/rigor/inference/synthetic_method_index.rb +23 -0
  86. data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
  87. data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
  88. data/lib/rigor/language_server/buffer_table.rb +6 -0
  89. data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
  90. data/lib/rigor/language_server/project_context.rb +59 -3
  91. data/lib/rigor/language_server/server.rb +5 -3
  92. data/lib/rigor/mcp/server.rb +9 -1
  93. data/lib/rigor/plugin/base.rb +26 -11
  94. data/lib/rigor/plugin/manifest.rb +29 -3
  95. data/lib/rigor/plugin/registry.rb +38 -0
  96. data/lib/rigor/protection/discovery_seed.rb +57 -2
  97. data/lib/rigor/rooted_glob.rb +10 -1
  98. data/lib/rigor/scope.rb +14 -5
  99. data/lib/rigor/sig_gen/generator.rb +308 -37
  100. data/lib/rigor/sig_gen/observation_collector.rb +4 -1
  101. data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
  102. data/lib/rigor/sig_gen/renderer.rb +6 -3
  103. data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
  104. data/lib/rigor/type/combinator.rb +4 -3
  105. data/lib/rigor/type/constant.rb +45 -3
  106. data/lib/rigor/type/hash_shape.rb +14 -9
  107. data/lib/rigor/type/struct_instance.rb +3 -2
  108. data/lib/rigor/version.rb +1 -1
  109. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +60 -43
  110. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
  111. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
  112. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
  113. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
  114. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +6 -5
  115. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
  116. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
  117. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
  118. data/sig/rigor/analysis/baseline.rbs +3 -2
  119. data/sig/rigor/effects/file_collection.rbs +5 -6
  120. data/sig/rigor/effects/registry.rbs +8 -6
  121. data/sig/rigor/effects/summary.rbs +6 -5
  122. data/sig/rigor/environment.rbs +5 -3
  123. data/sig/rigor/inference/optimistic_origin.rbs +11 -0
  124. data/sig/rigor/inference.rbs +6 -0
  125. data/sig/rigor/plugin/additional_initializer.rbs +5 -5
  126. data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
  127. data/sig/rigor/plugin/effect_attribution.rbs +25 -10
  128. data/sig/rigor/plugin/effect_edge.rbs +12 -5
  129. data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
  130. data/sig/rigor/plugin/io_boundary.rbs +4 -4
  131. data/sig/rigor/plugin/manifest.rbs +16 -18
  132. data/sig/rigor/plugin/protocol_contract.rbs +10 -9
  133. data/sig/rigor/plugin/registry.rbs +1 -1
  134. data/sig/rigor/scope.rbs +31 -50
  135. data/sig/rigor/type.rbs +0 -6
  136. metadata +20 -1
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Inference
7
+ # Issue #1154 — the instance-variable writers {GlobalWriteCensus} records for the declared-parameter credit
8
+ # ({ScopeIndexer::DeclaredParamWrites}), which must not type an ivar a writer the class-ivar census never walks may
9
+ # replace. The standard is parity with a literal write: `@x = 1` in `initialize` already reads `1` in every sibling
10
+ # method whatever an unknown object's code does, so only the writers that literal writes would also have to answer
11
+ # for are recorded, and an unknown-receiver writer records nothing (as `ReflectiveIvarWrite` leaves
12
+ # `o.instance_variable_set` alone). Three entry kinds go into the census `Set`:
13
+ #
14
+ # - `[:ivar_set, name]` — a literal `o.instance_variable_set(:@x, v)` (or the same through a `send` that spells
15
+ # it) on an explicit receiver other than `self`, and an `@x` write in the literal block of an
16
+ # `o.instance_eval { … }` / `instance_exec`: code that names the ivar it writes.
17
+ # - `[:ivar_set_class, class_name]` — an open-world writer ({#open_world?}) with an implicit or `self` receiver
18
+ # inside an instance body of the class — a `def` or a `define_method` block in its class body, in the block of a
19
+ # `K = Struct.new` / `Data.define` / `Class.new` constant, or in a `K.class_eval` block (the host walk passes
20
+ # that class as `owner`): it may write any ivar of the class's instances. A writer in a class body, a
21
+ # `def self.` or at the top level records nothing.
22
+ # - `[:own_write, class_name, name]` — an `@x` write inside an instance body of the class.
23
+ # {ScopeIndexer#merge_constant_literal_tables} appends the file's path, so a reader can tell whether a second
24
+ # file writes the same ivar of the same class.
25
+ class IvarWriterCensus
26
+ SEND_CALLS = %i[send __send__ public_send].to_set.freeze
27
+ # The methods a `send` reaching which is an open-world writer.
28
+ OPEN_WORLD_SENDS = %i[instance_variable_set instance_eval instance_exec extend
29
+ define_singleton_method].to_set.freeze
30
+ EVAL_CALLS = %i[instance_eval instance_exec].to_set.freeze
31
+ # The calls on a `singleton_class` receiver that give the object methods or code the census cannot read.
32
+ SINGLETON_CLASS_CALLS = %i[
33
+ class_eval class_exec module_eval module_exec define_method include prepend extend attr attr_reader
34
+ attr_writer attr_accessor send public_send __send__
35
+ ].to_set.freeze
36
+ NO_ARGUMENTS = [].freeze
37
+
38
+ # The call names any rule here reads, so every other call returns at one Set probe.
39
+ # (`define_method` is in {SINGLETON_CLASS_CALLS}.)
40
+ WATCHED_CALLS = (SEND_CALLS | OPEN_WORLD_SENDS | EVAL_CALLS | SINGLETON_CLASS_CALLS).freeze
41
+
42
+ def initialize(census)
43
+ @census = census
44
+ @instance_ranges = []
45
+ @owner_names = {}.compare_by_identity
46
+ @own_writes = {}
47
+ end
48
+
49
+ # Fed each node of a pre-order descent. `owner` is the class whose body the node sits in, as its qualified name
50
+ # or as the host walk's prefix Array (joined here only when an entry needs it), or nil where no class owns an
51
+ # instance (`class << self`, the top level, an opaque block).
52
+ def visit(node, owner)
53
+ case node
54
+ when Prism::DefNode then remember(node.location, owner) if owner && node.receiver.nil?
55
+ when Prism::SingletonClassNode then visit_singleton_class(node)
56
+ when Prism::CallNode then visit_call(node, owner) if WATCHED_CALLS.include?(node.name)
57
+ when Prism::InstanceVariableWriteNode, Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableAndWriteNode,
58
+ Prism::InstanceVariableOperatorWriteNode, Prism::InstanceVariableTargetNode
59
+ record_own_write(node) unless @instance_ranges.empty?
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ # One entry per class and ivar: a repeated write costs a Hash probe, not a location and an Array.
66
+ def record_own_write(node)
67
+ owner = instance_owner(node)
68
+ return if owner.nil?
69
+
70
+ seen = (@own_writes[owner] ||= {})
71
+ return if seen[node.name]
72
+
73
+ seen[node.name] = true
74
+ @census << [:own_write, owner_name(owner), node.name].freeze
75
+ end
76
+
77
+ def owner_name(owner) = owner.is_a?(Array) ? (@owner_names[owner] ||= owner.join("::")) : owner
78
+
79
+ def visit_call(node, owner)
80
+ remember(node.block.location, owner) if owner && define_method_block?(node)
81
+ if self_receiver?(node) || own_singleton_class_call?(node)
82
+ return if @instance_ranges.empty? || !(own_singleton_class_call?(node) || open_world?(node))
83
+
84
+ instance = instance_owner(node)
85
+ record_class(instance) if instance
86
+ else
87
+ visit_foreign(node)
88
+ end
89
+ end
90
+
91
+ # `class << self` inside an instance `def` opens the instance's singleton class.
92
+ def visit_singleton_class(node)
93
+ return unless node.expression.is_a?(Prism::SelfNode) && !@instance_ranges.empty?
94
+
95
+ instance = instance_owner(node)
96
+ record_class(instance) if instance
97
+ end
98
+
99
+ def record_class(owner) = @census << [:ivar_set_class, owner_name(owner)].freeze
100
+
101
+ # The self writers whose target or code the census cannot read: a `send` with a computed method name or one
102
+ # naming {OPEN_WORLD_SENDS}; `instance_eval` / `instance_exec` given a block argument, and `instance_eval`
103
+ # given a string; `extend`; `define_singleton_method` with a body; and an `instance_variable_set` whose name no
104
+ # literal spells. A call in {SINGLETON_CLASS_CALLS} on the object's own `singleton_class` counts as well.
105
+ def open_world?(node)
106
+ name = node.name
107
+ arguments = node.arguments&.arguments || NO_ARGUMENTS
108
+ return open_world_send?(arguments) if SEND_CALLS.include?(name)
109
+ return open_world_eval?(node, arguments) if EVAL_CALLS.include?(name)
110
+ # #1931's census already makes every ivar of the class gradual for one in the same file; this entry covers one
111
+ # in a reopening in another file, which that census does not see.
112
+ return ivar_name(arguments.first).nil? if name == :instance_variable_set
113
+
114
+ name == :extend || (name == :define_singleton_method && (!node.block.nil? || arguments.size > 1))
115
+ end
116
+
117
+ def open_world_send?(arguments)
118
+ return false if arguments.empty?
119
+
120
+ sent = literal_name(arguments.first)
121
+ sent.nil? || OPEN_WORLD_SENDS.include?(sent)
122
+ end
123
+
124
+ def open_world_eval?(node, arguments)
125
+ node.block.is_a?(Prism::BlockArgumentNode) || (node.name == :instance_eval && !arguments.empty?)
126
+ end
127
+
128
+ # A call on another receiver: a named foreign write, or `self.singleton_class.class_eval` and its kin.
129
+ def visit_foreign(node)
130
+ arguments = node.arguments&.arguments || NO_ARGUMENTS
131
+ name = node.name
132
+ if SEND_CALLS.include?(name) && arguments.first && literal_name(arguments.first) == :instance_variable_set
133
+ name = :instance_variable_set
134
+ arguments = arguments.drop(1)
135
+ end
136
+ if name == :instance_variable_set
137
+ ivar = ivar_name(arguments.first)
138
+ @census << [:ivar_set, ivar].freeze if ivar
139
+ elsif EVAL_CALLS.include?(name) && node.block.is_a?(Prism::BlockNode)
140
+ record_block_ivar_writes(node.block)
141
+ end
142
+ end
143
+
144
+ def record_block_ivar_writes(node)
145
+ return unless node.is_a?(Prism::Node)
146
+ return if node.is_a?(Prism::DefNode) || node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
147
+
148
+ case node
149
+ when Prism::InstanceVariableWriteNode, Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableAndWriteNode,
150
+ Prism::InstanceVariableOperatorWriteNode, Prism::InstanceVariableTargetNode
151
+ @census << [:ivar_set, node.name].freeze
152
+ end
153
+ node.rigor_each_child { |child| record_block_ivar_writes(child) }
154
+ end
155
+
156
+ def define_method_block?(node)
157
+ node.name == :define_method && self_receiver?(node) && node.block.is_a?(Prism::BlockNode)
158
+ end
159
+
160
+ # An instance body (`def` or `define_method` block) and the class it belongs to. A `define_method` block's own
161
+ # nodes may reach the walk with no owner (the walk reads its `self` as opaque), so the class rides the range.
162
+ def remember(location, owner)
163
+ @instance_ranges << [(location.start_offset...location.end_offset), owner]
164
+ end
165
+
166
+ # The class of the innermost instance body covering `node` (pre-order, so the last covering range), or nil.
167
+ def instance_owner(node)
168
+ offset = node.location.start_offset
169
+ @instance_ranges.reverse_each { |(range, owner)| return owner if range.cover?(offset) }
170
+ nil
171
+ end
172
+
173
+ def self_receiver?(node) = node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
174
+
175
+ # A call in {SINGLETON_CLASS_CALLS} on the object's own `singleton_class` (`singleton_class.class_eval { … }`).
176
+ def own_singleton_class_call?(node)
177
+ receiver = node.receiver
178
+ receiver.is_a?(Prism::CallNode) && receiver.name == :singleton_class && self_receiver?(receiver) &&
179
+ SINGLETON_CLASS_CALLS.include?(node.name)
180
+ end
181
+
182
+ def literal_name(argument)
183
+ return unless argument.is_a?(Prism::SymbolNode) || argument.is_a?(Prism::StringNode)
184
+
185
+ text = argument.unescaped
186
+ text.to_sym if text.valid_encoding?
187
+ end
188
+
189
+ def ivar_name(argument)
190
+ name = literal_name(argument)
191
+ name if name.to_s.start_with?("@")
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Inference
5
+ # The value the statement evaluator last gave a loop node, and the scope it evaluated the loop from, so
6
+ # `ExpressionTyper#type_of_loop` reads it instead of evaluating the loop again (issue #2031 review). A loop's value
7
+ # costs an evaluation of its body, its fixpoint and its `break` passes included, and the typer types a loop again
8
+ # wherever it types a node that holds one: the value of a write that wraps it (`id(z = (while …))`), a call it is
9
+ # the receiver of (`(while …).itself`), each narrowing and rule that types that operand. Each such typing of a
10
+ # loop nested in another loop re-ran the inner loop's passes, once more per level of nesting.
11
+ #
12
+ # The record is reused only for the very scope object the loop was evaluated from, so it answers exactly what an
13
+ # evaluation would. It is not compared with `Scope#==`, which ignores several fields (`match_frame`,
14
+ # `declared_refinements`, `struct_fold_safe_locals`, the opaque-block flags, `lexical_nesting`, the origins tables,
15
+ # `discovery`): across the cost spec's 10933 reads every match was an identity match, and `lib/` read no record at
16
+ # all (issue #2262). It lives in a per-thread map whose keys are held weakly, so a record goes when its parse tree
17
+ # does.
18
+ module LoopValueRecord
19
+ KEY = :rigor_loop_value_record
20
+ private_constant :KEY
21
+
22
+ module_function
23
+
24
+ COUNT_KEY = :rigor_loop_value_count
25
+ private_constant :COUNT_KEY
26
+
27
+ def record(node, scope, type)
28
+ Thread.current[COUNT_KEY] = loops_evaluated + 1
29
+ conditional(node, scope, type)
30
+ end
31
+
32
+ # How many loops this thread has evaluated, so a conditional can tell whether its evaluation held one.
33
+ def loops_evaluated = Thread.current[COUNT_KEY] || 0
34
+
35
+ # The same record for a conditional that evaluated a loop in a branch: `id(c ? (while …) : 0)` types the
36
+ # conditional again from the scope it was evaluated from, and each typing evaluated the loop again from a
37
+ # branch scope of its own, which multiplied the passes of every loop nested in it (issue #2262).
38
+ def conditional(node, scope, type)
39
+ (Thread.current[KEY] ||= ObjectSpace::WeakKeyMap.new)[node] = [scope, type]
40
+ type
41
+ end
42
+
43
+ # The recorded value of `node` evaluated from `scope` itself, or nil.
44
+ def fetch(node, scope)
45
+ slot = Thread.current[KEY]&.[](node)
46
+ return nil if slot.nil?
47
+
48
+ recorded, type = slot
49
+ recorded.equal?(scope) ? type : nil
50
+ end
51
+ end
52
+ end
53
+ end
@@ -88,6 +88,18 @@ module Rigor
88
88
  @forwarded_block == name
89
89
  end
90
90
 
91
+ # Issue #1157 — whether a call's `&expr` block argument hands on the block THIS method was called with: the
92
+ # anonymous `&` of a method that declares an anonymous `&` parameter, or {#forwarded_block?} for `&name`.
93
+ # Anything else (`&:sym`, a proc made here, a rebound name) is a block of this frame's own making.
94
+ def forwards_block?(block_argument)
95
+ expression = block_argument.expression
96
+ case expression
97
+ when nil then anonymous_block_parameter?
98
+ when Prism::LocalVariableReadNode then forwarded_block?(expression.name)
99
+ else false
100
+ end
101
+ end
102
+
91
103
  # The scan of `node` under `scope`, kept while `scope`'s local and instance-variable tables are the same
92
104
  # objects: a lookup argument's answer reads them ({MatchRebinding.may_match?}), and a rebuild that leaves
93
105
  # them alone passes the same tables on. `kind` keeps another reading of the same node apart (issue #1365:
@@ -125,6 +137,13 @@ module Rigor
125
137
  name
126
138
  end
127
139
 
140
+ # True when the method declares a bare `&` parameter, which only an anonymous `&` argument can read.
141
+ def anonymous_block_parameter?
142
+ parameters = @parameters
143
+ block = parameters.is_a?(Prism::ParametersNode) ? parameters.block : nil
144
+ !block.nil? && block.name.nil?
145
+ end
146
+
128
147
  # The method's own `&block` parameter's name, or nil.
129
148
  def block_parameter_name
130
149
  parameters = @parameters
@@ -219,6 +219,15 @@ module Rigor
219
219
  # declared `Numeric`, on which `(2 ** 20_000).even?` is an undefined method.
220
220
  WidenedResult = Data.define(:class_name)
221
221
 
222
+ # #2125 — a String result past `STRING_FOLD_BYTE_LIMIT`, or an operand that would build one. Unlike a
223
+ # `WidenedResult` it declines the WHOLE fold, every other member of a union receiver included: dropping
224
+ # only the oversized member would answer the rest (`(c ? "a" : big) * 2` read `"aa"`). The next tier then
225
+ # answers the call — `LiteralStringFolding`'s `literal-string`, or RBS's `String`, never less than the
226
+ # `Nominal[String]` the fold could have widened to.
227
+ OversizedString = Data.define
228
+ OVERSIZED_STRING = [OversizedString.new].freeze
229
+ private_constant :OversizedString, :OVERSIZED_STRING
230
+
222
231
  # Input cartesian product hard cap. Keeps fold cost bounded even when the receiver and argument are
223
232
  # both `Union[Constant…]`. 5 × 5 = 25 inputs is permitted; 6 × 6 = 36 is not. The user-facing payoff
224
233
  # (a precise small enum) drops off fast past this range and CRuby method invocation cost adds up.
@@ -331,6 +340,7 @@ module Rigor
331
340
  arg = args.first
332
341
  ruby_arg = format_argument_value(arg)
333
342
  return nil if ruby_arg.nil?
343
+ return nil if format_blow_up?(receiver.value, ruby_arg)
334
344
 
335
345
  result = receiver.value % ruby_arg
336
346
  return nil unless foldable_constant_value?(result)
@@ -1200,6 +1210,8 @@ module Rigor
1200
1210
  # "Integer-ness" was already implied by the inputs (so the widening fallback only fires for
1201
1211
  # arithmetic over integers).
1202
1212
  def build_constant_type(values, source: nil)
1213
+ return nil if values.any?(OversizedString)
1214
+
1203
1215
  widened, values = values.partition { |value| value.is_a?(WidenedResult) }
1204
1216
  return build_scalar_constant_type(values, source) if widened.empty?
1205
1217
 
@@ -1213,7 +1225,8 @@ module Rigor
1213
1225
  def build_scalar_constant_type(values, source)
1214
1226
  return nil if values.empty?
1215
1227
 
1216
- unique = values.uniq
1228
+ # #1513 — keyed so the possible results `0.0` and `-0.0` stay two members, as `Constant#==` keeps them.
1229
+ unique = values.uniq { |value| Type::Constant.value_key(value) }
1217
1230
  return collapse_constants(unique) if unique.size <= UNION_FOLD_OUTPUT_LIMIT
1218
1231
 
1219
1232
  widen_to_integer_range(unique, source) ||
@@ -1643,6 +1656,7 @@ module Rigor
1643
1656
  # `flat_map`.
1644
1657
  def invoke_binary(receiver_value, method_name, arg_value)
1645
1658
  return nil unless safe?(receiver_value, method_name, arg_value)
1659
+ return OVERSIZED_STRING if string_blow_up?(receiver_value, method_name, arg_value)
1646
1660
  return nil if range_enumeration_blow_up?(receiver_value, method_name)
1647
1661
 
1648
1662
  widened = numeric_blow_up_class(receiver_value, method_name, arg_value)
@@ -1658,6 +1672,7 @@ module Rigor
1658
1672
  # `false`/`nil` folds.
1659
1673
  def invoke_ternary(receiver_value, method_name, av0, av1)
1660
1674
  return nil unless ternary_method_allowed?(receiver_value, method_name)
1675
+ return OVERSIZED_STRING if string_ternary_blow_up?(receiver_value, method_name, av0, av1)
1661
1676
 
1662
1677
  fold_result(receiver_value.public_send(method_name, av0, av1))
1663
1678
  rescue StandardError
@@ -1667,7 +1682,7 @@ module Rigor
1667
1682
  # Returns `[value]` on success, `nil` to signal "skip". See `invoke_binary` for why we wrap.
1668
1683
  def invoke_unary(receiver_value, method_name)
1669
1684
  return nil unless unary_safe?(receiver_value, method_name)
1670
- return nil if string_unary_blow_up?(receiver_value, method_name)
1685
+ return OVERSIZED_STRING if string_unary_blow_up?(receiver_value, method_name)
1671
1686
  return nil if range_enumeration_blow_up?(receiver_value, method_name)
1672
1687
 
1673
1688
  fold_result(receiver_value.public_send(method_name))
@@ -1675,10 +1690,11 @@ module Rigor
1675
1690
  nil
1676
1691
  end
1677
1692
 
1678
- # `[result]` for a foldable value, `[WidenedResult]` for a number past `INTEGER_FOLD_BIT_LIMIT`, nil
1679
- # for anything else.
1693
+ # `[result]` for a foldable value, `[WidenedResult]` for a number past `INTEGER_FOLD_BIT_LIMIT`,
1694
+ # `OVERSIZED_STRING` for a String past `STRING_FOLD_BYTE_LIMIT`, nil for anything else.
1680
1695
  def fold_result(result)
1681
1696
  return [WidenedResult.new(result.class.name)] if oversized_number?(result)
1697
+ return OVERSIZED_STRING if oversized_string?(result)
1682
1698
 
1683
1699
  foldable_constant_value?(result) ? [result] : nil
1684
1700
  end
@@ -1822,10 +1838,20 @@ module Rigor
1822
1838
  return false if value.is_a?(Float) && value.nan?
1823
1839
  return false if value.is_a?(Complex) && complex_nan?(value)
1824
1840
  return false if oversized_number?(value)
1841
+ return false if oversized_string?(value)
1825
1842
 
1826
1843
  true
1827
1844
  end
1828
1845
 
1846
+ # #2125 — the result gate every String-producing fold passes through, the String twin of
1847
+ # `oversized_number?`. The operand prechecks (`string_blow_up?`, `string_ternary_blow_up?`,
1848
+ # `format_blow_up?`) keep the costly cases from being built at all; this catches the rest after the
1849
+ # fact (`tr` into multibyte characters, `dump`, `Integer#to_s(2)`, whose receiver
1850
+ # `INTEGER_FOLD_BIT_LIMIT` already bounds, so its rendering is at most 13 KB).
1851
+ def oversized_string?(value)
1852
+ value.is_a?(String) && value.bytesize > STRING_FOLD_BYTE_LIMIT
1853
+ end
1854
+
1829
1855
  # True when an Integer, or an Integer part of a Rational / Complex, is past `INTEGER_FOLD_BIT_LIMIT`.
1830
1856
  def oversized_number?(value)
1831
1857
  case value
@@ -1874,7 +1900,6 @@ module Rigor
1874
1900
  catalog_allows?(receiver_value, method_name)
1875
1901
  return false unless allowed
1876
1902
  return false if integer_division_by_zero?(receiver_value, method_name, arg_value)
1877
- return false if string_blow_up?(receiver_value, method_name, arg_value)
1878
1903
 
1879
1904
  true
1880
1905
  end
@@ -1960,16 +1985,91 @@ module Rigor
1960
1985
  case method_name
1961
1986
  when :+ then string_concat_blow_up?(receiver_value, arg_value)
1962
1987
  when :* then string_repeat_blow_up?(receiver_value, arg_value)
1988
+ when :% then format_blow_up?(receiver_value, arg_value)
1963
1989
  when :center, :ljust, :rjust then string_pad_blow_up?(arg_value)
1964
1990
  else false
1965
1991
  end
1966
1992
  end
1967
1993
 
1994
+ # #2125 — the two-argument String folds that can grow past the input: the padded `center` / `ljust`
1995
+ # / `rjust` (width, pad) and `sub` / `gsub` (pattern, replacement).
1996
+ def string_ternary_blow_up?(receiver_value, method_name, av0, av1)
1997
+ return false unless receiver_value.is_a?(String)
1998
+
1999
+ case method_name
2000
+ when :center, :ljust, :rjust then string_pad_blow_up?(av0)
2001
+ when :sub, :gsub then string_substitution_blow_up?(receiver_value, method_name, av1)
2002
+ else false
2003
+ end
2004
+ end
2005
+
2006
+ # An upper bound on a `sub` / `gsub` result with a String replacement: `gsub` matches at most once per
2007
+ # position plus once at the end, and each replacement is the replacement's bytes plus, for every
2008
+ # backslash in it (`\0`, `\1`, `\k<name>`, `` \` ``, `\'`), at most the receiver's bytes again.
2009
+ def string_substitution_blow_up?(receiver_value, method_name, replacement)
2010
+ return false unless replacement.is_a?(String)
2011
+
2012
+ size = receiver_value.bytesize
2013
+ matches = method_name == :gsub ? size + 1 : 1
2014
+ per_match = replacement.bytesize + (replacement.count("\\") * size)
2015
+ size + (matches * per_match) > STRING_FOLD_BYTE_LIMIT
2016
+ end
2017
+
2018
+ # #2125 — `String#%` / `Kernel#format` pad to a field width and render to a precision the format
2019
+ # string spells (`"%02000000000d"`, `"%.100000f"`) or an argument supplies (`"%*d" % [n, 1]`).
2020
+ # Formatting builds the whole padded string before any result gate can look at it — two billion
2021
+ # bytes took 13 s and 4 GB — so the numbers are read from the directives first. A `*` width counts
2022
+ # every Integer argument as a possible width, and a `%%` escape matches as a whole, carrying no digits.
2023
+ FORMAT_DIRECTIVE = /%%|%(?:<[^>]*>|\{[^}]*\}|[-+ 0#*$.\d])*/
2024
+ private_constant :FORMAT_DIRECTIVE
2025
+
2026
+ def format_blow_up?(format_string, arguments)
2027
+ star = false
2028
+ format_string.scan(FORMAT_DIRECTIVE) do |directive|
2029
+ spec = directive.gsub(/<[^>]*>|\{[^}]*\}|\d+\$/, "")
2030
+ return true if spec.scan(/\d+/).any? { |digits| digits.to_i > STRING_FOLD_BYTE_LIMIT }
2031
+
2032
+ star ||= spec.include?("*")
2033
+ end
2034
+ star && format_argument_values(arguments).any? do |value|
2035
+ width = integer_operand(value)
2036
+ !width.nil? && width.abs > STRING_FOLD_BYTE_LIMIT
2037
+ end
2038
+ end
2039
+
2040
+ def format_argument_values(arguments)
2041
+ case arguments
2042
+ when Hash then arguments.values
2043
+ when Array then arguments
2044
+ else [arguments]
2045
+ end
2046
+ end
2047
+
1968
2048
  # `"x".center(width)` / `#ljust` / `#rjust` produce a string of `max(width, len)` characters. A
1969
2049
  # literal `width` far larger than the receiver would materialise a huge Constant; cap it at the same
1970
2050
  # byte limit the concat / repeat paths use.
1971
2051
  def string_pad_blow_up?(arg_value)
1972
- arg_value.is_a?(Integer) && arg_value > STRING_FOLD_BYTE_LIMIT
2052
+ width = integer_operand(arg_value)
2053
+ !width.nil? && width > STRING_FOLD_BYTE_LIMIT
2054
+ end
2055
+
2056
+ # The Integer a width or count operand stands for. Ruby converts a Float or Rational one with `NUM2LONG`
2057
+ # (`"x" * 2.9` is `"xx"`, `"x".ljust(2e9)` pads to two billion), and a Complex one whose imaginary part is an
2058
+ # exact zero through its real part (`"x" * Complex(3, 0)` is `"xxx"`; `Complex(3, 0.0)` raises), so the
2059
+ # prechecks read it the same way. Those are every Numeric class a `Constant` carries. A non-finite Float
2060
+ # raises in Ruby, and any other operand is left to the call, so both answer nil.
2061
+ def integer_operand(value)
2062
+ case value
2063
+ when Integer then value
2064
+ when Float then value.finite? ? value.to_i : nil
2065
+ when Rational then value.to_i
2066
+ when Complex then exact_real_operand(value)
2067
+ end
2068
+ end
2069
+
2070
+ def exact_real_operand(value)
2071
+ imaginary = value.imaginary
2072
+ imaginary.is_a?(Integer) && imaginary.zero? ? integer_operand(value.real) : nil
1973
2073
  end
1974
2074
 
1975
2075
  def string_concat_blow_up?(receiver_value, arg_value)
@@ -1978,10 +2078,11 @@ module Rigor
1978
2078
  end
1979
2079
 
1980
2080
  def string_repeat_blow_up?(receiver_value, arg_value)
1981
- return false unless arg_value.is_a?(Integer)
1982
- return true if arg_value.negative?
2081
+ # A negative count raises (`ArgumentError`), so the call itself skips that member, as any raising one is.
2082
+ count = integer_operand(arg_value)
2083
+ return false if count.nil? || count.negative?
1983
2084
 
1984
- receiver_value.bytesize * arg_value > STRING_FOLD_BYTE_LIMIT
2085
+ receiver_value.bytesize * count > STRING_FOLD_BYTE_LIMIT
1985
2086
  end
1986
2087
  end
1987
2088
  end
@@ -191,12 +191,16 @@ module Rigor
191
191
  # Runs the real `Kernel#format` when everything is value-pinned. A malformed directive or an
192
192
  # argument-count mismatch raises at fold time; the handler declines so the literal-string
193
193
  # lift (or the RBS `String` envelope) answers instead. Results larger than the shared
194
- # `ConstantFolding::STRING_FOLD_BYTE_LIMIT` decline too, keeping the carrier-size convention.
194
+ # `ConstantFolding::STRING_FOLD_BYTE_LIMIT` decline too, keeping the carrier-size convention,
195
+ # and a field width or precision past it declines before the string is built (#2125).
195
196
  def fold_format_constant(args)
196
197
  return nil unless args.all?(Type::Constant)
197
198
  return nil unless args.first.value.is_a?(String)
198
199
 
199
- result = format(args.first.value, *args.drop(1).map(&:value))
200
+ values = args.drop(1).map(&:value)
201
+ return nil if ConstantFolding.format_blow_up?(args.first.value, values)
202
+
203
+ result = format(args.first.value, *values)
200
204
  return nil if result.bytesize > ConstantFolding::STRING_FOLD_BYTE_LIMIT
201
205
 
202
206
  Type::Combinator.constant_of(result)
@@ -35,43 +35,76 @@ module Rigor
35
35
  return nil unless SingletonFolding.receiver?(receiver, "Set")
36
36
 
37
37
  case method_name
38
- when :[] then fold_bracket(args)
39
- when :new then fold_new(args)
38
+ when :[] then fold_bracket(args, context.call_node)
39
+ when :new then fold_new(args, context.call_node, context.block_type)
40
40
  end
41
41
  end
42
42
 
43
- # `Set["a", "b", "c"]` — all positional args must be Constant.
44
- def fold_bracket(args)
45
- values = args.map do |a|
46
- return nil unless a.is_a?(Type::Constant)
43
+ # `Set["a", "b", "c"]` — all positional args must be Constant to fold to a value. Otherwise the
44
+ # element type is the union of the arguments, widened off their literal pins (#2278).
45
+ def fold_bracket(args, call_node = nil)
46
+ return nil if splatted?(call_node)
47
47
 
48
- a.value
49
- end
48
+ return Type::Combinator.constant_of(::Set.new(args.map(&:value))) if args.all?(Type::Constant)
50
49
 
51
- Type::Combinator.constant_of(::Set.new(values))
50
+ typed_set(args)
52
51
  rescue StandardError
53
52
  nil
54
53
  end
55
54
 
56
- # `Set.new` / `Set.new(tuple_of_constants)`.
57
- def fold_new(args)
55
+ # `Set.new` / `Set.new(enum)`. A call with a block declines: the block maps each element
56
+ # (`Set.new([1, 2], &:to_s)` holds `"1"` and `"2"`), so the argument's elements are not the set's.
57
+ def fold_new(args, call_node = nil, block_type = nil)
58
+ return nil if block_type || block_given?(call_node) || splatted?(call_node)
58
59
  return Type::Combinator.constant_of(::Set.new) if args.empty?
59
60
  return nil if args.size > 1
60
61
 
61
62
  arg = args.first
62
- values = case arg
63
- when Type::Tuple
64
- return nil unless arg.elements.all?(Type::Constant)
65
-
66
- arg.elements.map(&:value)
67
- else
68
- return nil
69
- end
63
+ return fold_tuple(arg) if arg.is_a?(Type::Tuple)
70
64
 
71
- Type::Combinator.constant_of(::Set.new(values))
65
+ element = nominal_element(arg)
66
+ element ? typed_set([element]) : nil
72
67
  rescue StandardError
73
68
  nil
74
69
  end
70
+
71
+ def fold_tuple(tuple)
72
+ if tuple.elements.all?(Type::Constant)
73
+ return Type::Combinator.constant_of(::Set.new(tuple.elements.map(&:value)))
74
+ end
75
+
76
+ typed_set(tuple.elements)
77
+ end
78
+
79
+ # The element of an `Array[T]` / `Set[T]` argument, or nil for any other carrier.
80
+ def nominal_element(arg)
81
+ return nil unless arg.is_a?(Type::Nominal) && %w[Array Set].include?(arg.class_name)
82
+ return nil unless arg.type_args.size == 1
83
+
84
+ arg.type_args.first
85
+ end
86
+
87
+ # `Nominal[Set, [E]]`, `E` the members widened as an `Array.new` fill is (value pins dropped, a literal
88
+ # container widened to its nominal) joined with a `Dynamic[top]` floor. The floor is the conservative half:
89
+ # nothing widens a Set's element when the program mutates it (`add`, `<<`, `merge`, `replace`, `clear`
90
+ # then `<<`, a callee that fills it), so the classes the set was built with are not a claim about what it
91
+ # holds later, and a read of the element must not report on them (#2278).
92
+ def typed_set(types)
93
+ return nil if types.empty?
94
+
95
+ members = types.map { |t| MethodDispatcher.array_new_element(t) }
96
+ Type::Combinator.nominal_of("Set", type_args: [Type::Combinator.union(*members, Type::Combinator.untyped)])
97
+ end
98
+
99
+ def block_given?(call_node)
100
+ call_node.respond_to?(:block) && !call_node.block.nil?
101
+ end
102
+
103
+ def splatted?(call_node)
104
+ return false unless call_node.respond_to?(:arguments)
105
+
106
+ (call_node.arguments&.arguments || []).any?(Prism::SplatNode)
107
+ end
75
108
  end
76
109
  end
77
110
  end